diff --git a/.gitignore b/.gitignore index c0fe47c..7d6e01a 100644 --- a/.gitignore +++ b/.gitignore @@ -165,7 +165,9 @@ dist .env* !.env.example .wrangler/ +wrangler.local.jsonc # Mac folder index -.DS_Store \ No newline at end of file +.DS_Store + diff --git a/package.json b/package.json index 35559ad..ef4abef 100644 --- a/package.json +++ b/package.json @@ -9,8 +9,7 @@ }, "scripts": { "deploy": "wrangler deploy", - "dev": "wrangler dev", - "start": "wrangler dev", + "dev": "wrangler dev -c wrangler.local.jsonc", "preview": "wrangler dev --env preview", "test": "vitest run", "test:coverage": "vitest run --coverage --coverage.provider=istanbul", diff --git a/src/StartupAPIEnv.ts b/src/StartupAPIEnv.ts index f5e2a70..2f4b666 100644 --- a/src/StartupAPIEnv.ts +++ b/src/StartupAPIEnv.ts @@ -9,6 +9,4 @@ export type StartupAPIEnv = { ADMIN_IDS: string; SESSION_SECRET: string; ENVIRONMENT?: string; - SYSTEM: DurableObjectNamespace; - IMAGE_STORAGE: R2Bucket; } & Env; diff --git a/src/SystemDO.ts b/src/SystemDO.ts index c97e521..9328a7e 100644 --- a/src/SystemDO.ts +++ b/src/SystemDO.ts @@ -44,8 +44,14 @@ export class SystemDO extends DurableObject { const adminIds = (this.env.ADMIN_IDS || '').split(',').map((id) => id.trim()); const isAdmin = adminIds.includes(u.id) || - (u.email && adminIds.includes(u.email)) || - (u.provider && u.id && adminIds.includes(`${u.provider}:${u.id}`)); + (this.env.ENVIRONMENT === 'test' && + adminIds.some((id) => { + try { + return u.id === this.env.USER.idFromName(id).toString(); + } catch (e) { + return false; + } + })); return { ...u, diff --git a/src/index.ts b/src/index.ts index 5f43798..166371c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -241,7 +241,8 @@ async function handleAdmin(request: Request, env: StartupAPIEnv, usersPath: stri } } } else if (parts[0] === 'impersonate' && request.method === 'POST') { - const { user_id } = (await request.json()) as { user_id: string }; + const data = (await request.json()) as any; + const user_id = data.user_id || data.userId; if (!user_id) return new Response('Missing user_id', { status: 400 }); if (user_id === user.id) { @@ -269,6 +270,8 @@ async function handleAdmin(request: Request, env: StartupAPIEnv, usersPath: stri return Response.json({ success: true }, { headers }); } + + return new Response('Not Found', { status: 404 }); } url.pathname = '/users/admin' + path; @@ -281,7 +284,7 @@ async function handleMe(request: Request, env: StartupAPIEnv, cookieManager: Coo const user = await getUserFromSession(request, env, cookieManager); if (!user) return new Response('Unauthorized', { status: 401 }); - const { id: doId, sessionId, profile: initialProfile, credential } = user; + const { id: doId, profile: initialProfile, credential } = user; try { const id = env.USER.idFromString(doId); @@ -347,22 +350,19 @@ function isAdmin(user: any, env: StartupAPIEnv): boolean { const adminIds = env.ADMIN_IDS.split(',') .map((e) => e.trim()) .filter(Boolean); - const profile = user.profile || {}; - const credential = user.credential || {}; + + const userId = user.id; return ( - adminIds.includes(user.id) || + adminIds.includes(userId) || (env.ENVIRONMENT === 'test' && adminIds.some((id) => { try { - return user.id === env.USER.idFromName(id).toString(); + return userId === env.USER.idFromName(id).toString(); } catch (e) { return false; } - })) || - (profile.email && adminIds.includes(profile.email)) || - (credential.subject_id && adminIds.includes(credential.subject_id)) || - (credential.provider && credential.subject_id && adminIds.includes(`${credential.provider}:${credential.subject_id}`)) + })) ); } @@ -508,25 +508,11 @@ async function handleDeleteCredential(request: Request, env: StartupAPIEnv, cook } async function handleMeImage(request: Request, env: StartupAPIEnv, type: string, cookieManager: CookieManager): Promise { - const cookieHeader = request.headers.get('Cookie'); - if (!cookieHeader) return new Response('Unauthorized', { status: 401 }); - - const cookies = parseCookies(cookieHeader); - const sessionCookieEncrypted = cookies['session_id']; - - if (!sessionCookieEncrypted) { - return new Response('Unauthorized', { status: 401 }); - } - - const sessionCookie = await cookieManager.decrypt(sessionCookieEncrypted); - if (!sessionCookie || !sessionCookie.includes(':')) { - return new Response('Unauthorized', { status: 401 }); - } - - const [sessionId, doId] = sessionCookie.split(':'); + const user = await getUserFromSession(request, env, cookieManager); + if (!user) return new Response('Unauthorized', { status: 401 }); try { - const id = env.USER.idFromString(doId); + const id = env.USER.idFromString(user.id); const stub = env.USER.get(id); if (request.method === 'PUT') { @@ -549,7 +535,7 @@ async function handleMeImage(request: Request, env: StartupAPIEnv, type: string, return Response.json({ success: true }); } - return handleUserImage(request, env, doId, type, cookieManager); + return handleUserImage(request, env, user.id, type, cookieManager); } catch (e: any) { console.error('[handleMeImage] Error:', e.message, e.stack); return new Response('Error fetching image: ' + e.message, { status: 500 }); @@ -680,29 +666,12 @@ function parseCookies(cookieHeader: string): Record { } async function handleMyAccounts(request: Request, env: StartupAPIEnv, cookieManager: CookieManager): Promise { - const cookieHeader = request.headers.get('Cookie'); - if (!cookieHeader) return new Response('Unauthorized', { status: 401 }); - - const cookies = parseCookies(cookieHeader); - const sessionCookieEncrypted = cookies['session_id']; - - if (!sessionCookieEncrypted) { - return new Response('Unauthorized', { status: 401 }); - } - - const sessionCookie = await cookieManager.decrypt(sessionCookieEncrypted); - if (!sessionCookie || !sessionCookie.includes(':')) { - return new Response('Unauthorized', { status: 401 }); - } - - const [sessionId, doId] = sessionCookie.split(':'); + const user = await getUserFromSession(request, env, cookieManager); + if (!user) return new Response('Unauthorized', { status: 401 }); try { - const id = env.USER.idFromString(doId); + const id = env.USER.idFromString(user.id); const userStub = env.USER.get(id); - const data = await userStub.validateSession(sessionId); - - if (!data.valid) return Response.json(data, { status: 401 }); // Fetch memberships const memberships = await userStub.getMemberships(); @@ -731,22 +700,9 @@ async function handleMyAccounts(request: Request, env: StartupAPIEnv, cookieMana } async function handleSwitchAccount(request: Request, env: StartupAPIEnv, cookieManager: CookieManager): Promise { - const cookieHeader = request.headers.get('Cookie'); - if (!cookieHeader) return new Response('Unauthorized', { status: 401 }); - - const cookies = parseCookies(cookieHeader); - const sessionCookieEncrypted = cookies['session_id']; - - if (!sessionCookieEncrypted) { - return new Response('Unauthorized', { status: 401 }); - } - - const sessionCookie = await cookieManager.decrypt(sessionCookieEncrypted); - if (!sessionCookie || !sessionCookie.includes(':')) { - return new Response('Unauthorized', { status: 401 }); - } + const user = await getUserFromSession(request, env, cookieManager); + if (!user) return new Response('Unauthorized', { status: 401 }); - const [sessionId, doId] = sessionCookie.split(':'); const { account_id } = (await request.json()) as { account_id: string }; if (!account_id) { @@ -754,12 +710,8 @@ async function handleSwitchAccount(request: Request, env: StartupAPIEnv, cookieM } try { - const id = env.USER.idFromString(doId); + const id = env.USER.idFromString(user.id); const userStub = env.USER.get(id); - const data = await userStub.validateSession(sessionId); - - if (!data.valid) return Response.json(data, { status: 401 }); - return Response.json(await userStub.switchAccount(account_id)); } catch (e: any) { return new Response(e.message, { status: 400 }); @@ -773,29 +725,16 @@ async function handleSSR( usersPath: string, cookieManager: CookieManager, ): Promise { - const cookieHeader = request.headers.get('Cookie'); - const cookies = parseCookies(cookieHeader || ''); - const sessionCookieEncrypted = cookies['session_id']; - - if (!sessionCookieEncrypted) { - return Response.redirect(url.origin + '/', 302); - } - - const sessionCookie = await cookieManager.decrypt(sessionCookieEncrypted); - if (!sessionCookie || !sessionCookie.includes(':')) { + const user = await getUserFromSession(request, env, cookieManager); + if (!user) { return Response.redirect(url.origin + '/', 302); } - const [sessionId, doId] = sessionCookie.split(':'); + const { id: doId, sessionId, profile: initialProfile, credential } = user; try { const id = env.USER.idFromString(doId); const userStub = env.USER.get(id); - const data = await userStub.validateSession(sessionId); - - if (!data.valid) { - return Response.redirect(url.origin + '/', 302); - } // Get HTML from assets const assetUrl = new URL(url.toString()); @@ -821,17 +760,21 @@ async function handleSSR( let html = await assetResponse.text(); - const profile = { ...data.profile }; + const data: any = { + valid: true, + profile: { ...initialProfile }, + credential, + }; + const image = await userStub.getImage('avatar'); if (image) { const usersPathNormalized = usersPath.endsWith('/') ? usersPath : usersPath + '/'; - profile.picture = usersPathNormalized + 'me/avatar'; + data.profile.picture = usersPathNormalized + 'me/avatar'; } else { - profile.picture = null; + data.profile.picture = null; } - data.profile = profile; - data.is_admin = isAdmin({ id: doId, ...data.profile }, env); + data.is_admin = isAdmin({ id: doId, profile: data.profile, credential }, env); // Fetch memberships to find current account const memberships = await userStub.getMemberships(); @@ -864,14 +807,16 @@ async function handleSSR( providers: getActiveProviders(env).join(','), profile_json: JSON.stringify(data).replace(/"/g, '"'), credentials_json: JSON.stringify(credentials).replace(/"/g, '"'), - profile_name: profile.name || 'Anonymous', + profile_name: data.profile.name || 'Anonymous', profile_id: doId, - profile_email: profile.email || '', - profile_picture: profile.picture || '', - profile_picture_display: profile.picture ? 'display: block;' : 'display: none;', - profile_placeholder_display: profile.picture ? 'display: none;' : 'display: flex;', - profile_remove_btn_display: profile.picture ? 'display: flex;' : 'display: none;', - profile_provider_label: profile.provider ? `(from ${profile.provider.charAt(0).toUpperCase() + profile.provider.slice(1)})` : '', + profile_email: data.profile.email || '', + profile_picture: data.profile.picture || '', + profile_picture_display: data.profile.picture ? 'display: block;' : 'display: none;', + profile_placeholder_display: data.profile.picture ? 'display: none;' : 'display: flex;', + profile_remove_btn_display: data.profile.picture ? 'display: flex;' : 'display: none;', + profile_provider_label: data.profile.provider + ? `(from ${data.profile.provider.charAt(0).toUpperCase() + data.profile.provider.slice(1)})` + : '', nav_account_display: account && (account.role === 1 || data.is_admin) ? 'display: block;' : 'display: none;', credentials_list_html: renderCredentialsList(credentials, data.credential?.provider), link_credentials_html: renderLinkCredentialsList(getActiveProviders(env)), diff --git a/worker-configuration.d.ts b/worker-configuration.d.ts index 49b9680..bec96ac 100644 --- a/worker-configuration.d.ts +++ b/worker-configuration.d.ts @@ -1,5 +1,5 @@ /* eslint-disable */ -// Generated by Wrangler by running `wrangler types` (hash: 1b2f4131cc4427d006995ed2932f1531) +// Generated by Wrangler by running `wrangler types` (hash: b67527d07bd06e5d88bcac89eae3c0e4) // Runtime types generated with workerd@1.20260120.0 2025-09-27 global_fetch_strictly_public declare namespace Cloudflare { interface GlobalProps { @@ -9,34 +9,65 @@ declare namespace Cloudflare { interface PreviewEnv { IMAGE_STORAGE: R2Bucket; ASSETS: Fetcher; - SESSION_SECRET: string; - ADMIN_IDS: string; - ORIGIN_URL: string; - AUTH_ORIGIN: string; - TWITCH_CLIENT_ID: string; - TWITCH_CLIENT_SECRET: string; - GOOGLE_CLIENT_ID: string; - GOOGLE_CLIENT_SECRET: string; + ENVIRONMENT: "preview"; + SESSION_SECRET: ""; + ADMIN_IDS: ""; + ORIGIN_URL: "https://startup-api-demo-origin.sergeychernyshev.workers.dev/"; + TWITCH_CLIENT_ID: ""; + TWITCH_CLIENT_SECRET: ""; + GITHUB_PROJECT_ID: string; + USER: DurableObjectNamespace; + ACCOUNT: DurableObjectNamespace; + SYSTEM: DurableObjectNamespace; + CREDENTIAL: DurableObjectNamespace; + } + interface MinimalEnv { + IMAGE_STORAGE: R2Bucket; + ASSETS: Fetcher; + ENVIRONMENT: "minimal"; + SESSION_SECRET: ""; + ORIGIN_URL: ""; + GITHUB_PROJECT_ID: string; + USER: DurableObjectNamespace; + ACCOUNT: DurableObjectNamespace; + SYSTEM: DurableObjectNamespace; + CREDENTIAL: DurableObjectNamespace; + } + interface OptionalEnv { + IMAGE_STORAGE: R2Bucket; + ASSETS: Fetcher; + ENVIRONMENT: ""; + SESSION_SECRET: ""; + ADMIN_IDS: ""; + ORIGIN_URL: ""; + AUTH_ORIGIN: ""; + TWITCH_CLIENT_ID: ""; + TWITCH_CLIENT_SECRET: ""; + GOOGLE_CLIENT_ID: ""; + GOOGLE_CLIENT_SECRET: ""; + GITHUB_PROJECT_ID: string; USER: DurableObjectNamespace; ACCOUNT: DurableObjectNamespace; SYSTEM: DurableObjectNamespace; CREDENTIAL: DurableObjectNamespace; } interface Env { - SESSION_SECRET: string; - ADMIN_IDS: string; - ORIGIN_URL: string; - AUTH_ORIGIN: string; - TWITCH_CLIENT_ID: string; - TWITCH_CLIENT_SECRET: string; - GOOGLE_CLIENT_ID: string; - GOOGLE_CLIENT_SECRET: string; + GITHUB_PROJECT_ID: string; IMAGE_STORAGE: R2Bucket; ASSETS: Fetcher; + ENVIRONMENT?: "preview" | "minimal" | ""; + SESSION_SECRET?: ""; + ADMIN_IDS?: ""; + ORIGIN_URL?: "https://startup-api-demo-origin.sergeychernyshev.workers.dev/" | ""; + TWITCH_CLIENT_ID?: ""; + TWITCH_CLIENT_SECRET?: ""; USER: DurableObjectNamespace; ACCOUNT: DurableObjectNamespace; SYSTEM: DurableObjectNamespace; CREDENTIAL: DurableObjectNamespace; + AUTH_ORIGIN?: ""; + GOOGLE_CLIENT_ID?: ""; + GOOGLE_CLIENT_SECRET?: ""; } } interface Env extends Cloudflare.Env {} diff --git a/wrangler.jsonc b/wrangler.jsonc index 69c1092..4a0e1e1 100644 --- a/wrangler.jsonc +++ b/wrangler.jsonc @@ -61,8 +61,8 @@ }, ], "env": { + // preview environment used for preview site deployments in PRs and etc. "preview": { - // copied from main enviroment to make sure env.ASSETS is not optional "assets": { "directory": "./public", "run_worker_first": true, @@ -94,6 +94,98 @@ }, ], }, + "vars": { + "ENVIRONMENT": "preview", + "SESSION_SECRET": "", + "ADMIN_IDS": "", + "ORIGIN_URL": "https://startup-api-demo-origin.sergeychernyshev.workers.dev/", + "TWITCH_CLIENT_ID": "", + "TWITCH_CLIENT_SECRET": "", + }, + }, + // environment used to define the minimal number of variables + // so the rest are properly generated as optional in type definitions + "minimal": { + "assets": { + "directory": "./public", + "run_worker_first": true, + "binding": "ASSETS", + }, + "r2_buckets": [ + { + "binding": "IMAGE_STORAGE", + "bucket_name": "startup-api-images", + }, + ], + "durable_objects": { + "bindings": [ + { + "name": "USER", + "class_name": "UserDO", + }, + { + "name": "ACCOUNT", + "class_name": "AccountDO", + }, + { + "name": "SYSTEM", + "class_name": "SystemDO", + }, + { + "name": "CREDENTIAL", + "class_name": "CredentialDO", + }, + ], + }, + "vars": { + "ENVIRONMENT": "minimal", + "SESSION_SECRET": "", + "ORIGIN_URL": "", + }, + }, + "optional": { + "assets": { + "directory": "./public", + "run_worker_first": true, + "binding": "ASSETS", + }, + "r2_buckets": [ + { + "binding": "IMAGE_STORAGE", + "bucket_name": "startup-api-images", + }, + ], + "durable_objects": { + "bindings": [ + { + "name": "USER", + "class_name": "UserDO", + }, + { + "name": "ACCOUNT", + "class_name": "AccountDO", + }, + { + "name": "SYSTEM", + "class_name": "SystemDO", + }, + { + "name": "CREDENTIAL", + "class_name": "CredentialDO", + }, + ], + }, + "vars": { + "ENVIRONMENT": "", + "SESSION_SECRET": "", + "ADMIN_IDS": "", + "ORIGIN_URL": "", + "AUTH_ORIGIN": "", + "TWITCH_CLIENT_ID": "", + "TWITCH_CLIENT_SECRET": "", + "GOOGLE_CLIENT_ID": "", + "GOOGLE_CLIENT_SECRET": "", + }, }, }, }