From ce5b83076e529bce6628b2d098855b6225d81e5b Mon Sep 17 00:00:00 2001 From: alanqianghe-ai Date: Mon, 20 Jul 2026 22:11:40 +0800 Subject: [PATCH] feat: add Google authentication Co-authored-by: multica-agent --- README.md | 17 +++++++++ apps/web/src/App.tsx | 22 ++++++++++-- apps/web/src/api.ts | 6 +++- apps/web/src/auth.css | 1 + apps/worker/migrations/0002_auth.sql | 19 ++++++++++ apps/worker/package.json | 2 +- apps/worker/src/auth.ts | 54 ++++++++++++++++++++++++++++ apps/worker/src/http.ts | 1 + apps/worker/src/index.ts | 31 ++++++++++------ apps/worker/src/types.ts | 4 +++ apps/worker/test/http.test.ts | 11 ++++-- apps/worker/wrangler.jsonc | 2 +- package-lock.json | 10 ++++++ 13 files changed, 162 insertions(+), 18 deletions(-) create mode 100644 apps/web/src/auth.css create mode 100644 apps/worker/migrations/0002_auth.sql create mode 100644 apps/worker/src/auth.ts diff --git a/README.md b/README.md index 4c78593..abab5d6 100644 --- a/README.md +++ b/README.md @@ -66,6 +66,23 @@ npm run deploy:worker 生产构建不要设置 `VITE_API_BASE_URL`,前端会直接请求同域的 `/api/*`。 +## Google 登录配置 + +在 Google Cloud Console 创建 Web OAuth 客户端,并配置以下授权回调地址: + +- 生产:`https://podforge.1991421.cn/api/auth/google/callback` +- 本地:`http://localhost:5173/api/auth/google/callback` + +客户端 ID 和客户端密钥均通过 Worker Secret 注入,不写入源码或 Wrangler 配置: + +```bash +cd apps/worker +npx wrangler secret put GOOGLE_CLIENT_ID +npx wrangler secret put GOOGLE_CLIENT_SECRET +``` + +本地开发时在 `apps/worker/.dev.vars` 中设置同名变量,并先应用 `0002_auth.sql` migration。登录会话有效期为 30 天;服务端仅保存随机 Session Token 的 SHA-256 哈希。所有任务与音频 API 均要求登录,并校验任务所有者。 + ## 验证 ```bash diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 6196546..d6c8f6c 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -1,2 +1,20 @@ -import { useCallback,useEffect,useState } from "react";import { cancelJob,createJob,getJob,retryJob,type Job } from "./api";import { JobForm } from "./components/JobForm";import { JobPanel } from "./components/JobPanel";import { useI18n } from "./i18n"; -export default function App(){const {locale,setLocale,t}=useI18n(),[job,setJob]=useState(null),[id,setId]=useState(()=>new URLSearchParams(location.search).get("job"));const refresh=useCallback(async()=>{if(id)setJob(await getJob(id))},[id]);useEffect(()=>{if(!id)return;void refresh();const timer=setInterval(()=>{if(job?.status!=="completed"&&job?.status!=="failed"&&job?.status!=="canceled")void refresh()},2500);return()=>clearInterval(timer)},[id,job?.status,refresh]);async function create(data:FormData){const result=await createJob(data);history.replaceState(null,"",`/?job=${result.id}`);setId(result.id)}async function retry(){if(id){await retryJob(id);setJob(job?{...job,status:"queued",progress:5,stage:"等待重试"}:null)}}async function cancel(){if(id){await cancelJob(id);setJob(job?{...job,status:"canceled",stage:"已取消"}:null)}}function reset(){history.replaceState(null,"","/");setId(null);setJob(null)}return <>
PF PodForge
{t.tagline}

AI PODCAST STUDIO

{t.heroLead} {t.heroEmphasis}

{t.heroCopy}

} +import { useCallback,useEffect,useState } from "react"; +import { cancelJob,createJob,getJob,getMe,googleLoginUrl,logout,retryJob,type Job,type User } from "./api"; +import { JobForm } from "./components/JobForm"; +import { JobPanel } from "./components/JobPanel"; +import { useI18n } from "./i18n"; +import "./auth.css"; + +export default function App(){ + const {locale,setLocale,t}=useI18n(),[job,setJob]=useState(null),[id,setId]=useState(()=>new URLSearchParams(location.search).get("job")),[user,setUser]=useState(); + useEffect(()=>{void getMe().then(result=>setUser(result.user)).catch(()=>setUser(null))},[]); + const refresh=useCallback(async()=>{if(id)setJob(await getJob(id))},[id]); + useEffect(()=>{if(!id||!user)return;void refresh();const timer=setInterval(()=>{if(job?.status!=="completed"&&job?.status!=="failed"&&job?.status!=="canceled")void refresh()},2500);return()=>clearInterval(timer)},[id,user,job?.status,refresh]); + async function create(data:FormData){const result=await createJob(data);history.replaceState(null,"",`/?job=${result.id}`);setId(result.id)} + async function retry(){if(id){await retryJob(id);setJob(job?{...job,status:"queued",progress:5,stage:"等待重试"}:null)}} + async function cancel(){if(id){await cancelJob(id);setJob(job?{...job,status:"canceled",stage:"已取消"}:null)}} + function reset(){history.replaceState(null,"","/");setId(null);setJob(null)} + async function signOut(){await logout();reset();setUser(null)} + const english=locale==="en-US"; + return <>
PF PodForge
{t.tagline}
{user&&
{user.picture&&}{user.name}
}

AI PODCAST STUDIO

{t.heroLead} {t.heroEmphasis}

{t.heroCopy}

{user===undefined?
{english?"Checking your session…":"正在确认登录状态…"}
:user?
:

{english?"Sign in to start creating":"登录后开始创作"}

{english?"Sign in with Google to protect your jobs and generated content and help us control service costs.":"使用 Google 登录以保护你的任务和生成内容,并帮助我们控制服务成本。"}

G{english?"Continue with Google":"使用 Google 登录"}
}
+} diff --git a/apps/web/src/api.ts b/apps/web/src/api.ts index 25261f0..3d3da1e 100644 --- a/apps/web/src/api.ts +++ b/apps/web/src/api.ts @@ -1,7 +1,11 @@ export interface DialogueLine { speaker: "host" | "guest"; text: string } export interface Job { id:string; title:string; status:"queued"|"processing"|"completed"|"failed"|"canceled"; progress:number; stage:string; error?:string; script?:{lines:DialogueLine[]}; audioUrl?:string } const base = (import.meta.env.VITE_API_BASE_URL as string | undefined)?.replace(/\/$/, "") ?? ""; -async function request(path:string, init?:RequestInit):Promise{const response=await fetch(`${base}${path}`,init);const data=await response.json() as T & {error?:string};if(!response.ok)throw new Error(data.error??"服务暂时不可用");return data} +async function request(path:string, init?:RequestInit):Promise{const response=await fetch(`${base}${path}`,{...init,credentials:"include"});const data=await response.json() as T & {error?:string};if(!response.ok)throw new Error(data.error??"服务暂时不可用");return data} +export interface User { id:string; email:string; name:string; picture:string|null } +export const getMe=()=>request<{user:User|null}>("/api/auth/me"); +export const logout=()=>request<{ok:true}>("/api/auth/logout",{method:"POST"}); +export const googleLoginUrl=()=>`${base}/api/auth/google`; export const createJob=(form:FormData)=>request<{id:string}>("/api/jobs",{method:"POST",body:form}); export const getJob=(id:string)=>request(`/api/jobs/${id}`); export const retryJob=(id:string)=>request<{id:string}>(`/api/jobs/${id}/retry`,{method:"POST"}); diff --git a/apps/web/src/auth.css b/apps/web/src/auth.css new file mode 100644 index 0000000..98412dd --- /dev/null +++ b/apps/web/src/auth.css @@ -0,0 +1 @@ +.header-actions,.user-menu{display:flex;align-items:center;gap:12px}.locale-switch{display:flex;gap:4px}.locale-switch button,.user-menu button{padding:7px 10px;border-radius:8px;font-size:12px}.user-menu img{width:30px;height:30px;border-radius:50%}.user-menu span{font-size:13px}.auth-card{max-width:520px;margin:auto;padding:48px;text-align:center;background:var(--cream);border:1px solid var(--line);border-radius:22px}.auth-card .record{margin:0 auto 28px}.auth-card h2{font-family:Georgia,serif;font-size:32px}.auth-card p{color:var(--muted);line-height:1.6}.google-login{display:inline-flex;align-items:center;gap:12px;margin-top:16px;padding:14px 22px;border-radius:12px;background:var(--ink);color:white;text-decoration:none;font-weight:750}.google-login b{display:grid;place-items:center;width:24px;height:24px;border-radius:50%;background:white;color:#4285f4} diff --git a/apps/worker/migrations/0002_auth.sql b/apps/worker/migrations/0002_auth.sql new file mode 100644 index 0000000..1267086 --- /dev/null +++ b/apps/worker/migrations/0002_auth.sql @@ -0,0 +1,19 @@ +CREATE TABLE users ( + id TEXT PRIMARY KEY, + google_sub TEXT NOT NULL UNIQUE, + email TEXT NOT NULL, + name TEXT NOT NULL, + picture TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); +CREATE TABLE sessions ( + token_hash TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + expires_at TEXT NOT NULL, + created_at TEXT NOT NULL +); +ALTER TABLE jobs ADD COLUMN user_id TEXT REFERENCES users(id); +CREATE INDEX idx_jobs_user_id ON jobs(user_id); +CREATE INDEX idx_sessions_user_id ON sessions(user_id); +CREATE INDEX idx_sessions_expires_at ON sessions(expires_at); diff --git a/apps/worker/package.json b/apps/worker/package.json index 8eff7d2..2c85270 100644 --- a/apps/worker/package.json +++ b/apps/worker/package.json @@ -1 +1 @@ -{"name":"@podforge/worker","version":"0.1.0","private":true,"type":"module","scripts":{"dev":"wrangler dev --port 8787","deploy":"wrangler deploy","build":"wrangler deploy --dry-run --outdir dist","typecheck":"tsc --noEmit","test":"vitest run","cf-typegen":"wrangler types"},"dependencies":{"mammoth":"^1.12.0","unpdf":"^1.2.2"},"devDependencies":{"@types/node":"^26.1.1","jszip":"^3.10.1","typescript":"^5.9.3","vitest":"^4.1.10","wrangler":"^4.112.0"}} \ No newline at end of file +{"name":"@podforge/worker","version":"0.1.0","private":true,"type":"module","scripts":{"dev":"wrangler dev --port 8787","deploy":"wrangler deploy","build":"wrangler deploy --dry-run --outdir dist","typecheck":"tsc --noEmit","test":"vitest run","cf-typegen":"wrangler types"},"dependencies":{"jose":"^6.2.3","mammoth":"^1.12.0","unpdf":"^1.2.2"},"devDependencies":{"@types/node":"^26.1.1","jszip":"^3.10.1","typescript":"^5.9.3","vitest":"^4.1.10","wrangler":"^4.112.0"}} \ No newline at end of file diff --git a/apps/worker/src/auth.ts b/apps/worker/src/auth.ts new file mode 100644 index 0000000..966f504 --- /dev/null +++ b/apps/worker/src/auth.ts @@ -0,0 +1,54 @@ +import { createRemoteJWKSet, jwtVerify } from "jose"; +import { errorResponse, json } from "./http"; +import type { Env, User } from "./types"; + +const googleKeys = createRemoteJWKSet(new URL("https://www.googleapis.com/oauth2/v3/certs")); +const SESSION_COOKIE = "podforge_session"; +const OAUTH_COOKIE = "podforge_oauth"; +const SESSION_SECONDS = 60 * 60 * 24 * 30; + +function encode(value: Uint8Array): string { return btoa(String.fromCharCode(...value)).replaceAll("+", "-").replaceAll("/", "_").replaceAll("=", ""); } +function randomToken(size = 32): string { const value = new Uint8Array(size); crypto.getRandomValues(value); return encode(value); } +async function sha256(value: string): Promise { return encode(new Uint8Array(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(value)))); } +function cookies(request: Request): Map { return new Map((request.headers.get("cookie") ?? "").split(";").map(part => part.trim().split("=", 2)).filter(pair => pair.length === 2) as [string, string][]); } +function cookie(name: string, value: string, maxAge: number, secure: boolean): string { return `${name}=${value}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${maxAge}${secure ? "; Secure" : ""}`; } +function redirect(location: string): Response { return new Response(null, { status: 302, headers: { location } }); } +function appOrigin(request: Request, env: Env): string { const origin = new URL(request.url).origin; return origin.includes("localhost") ? env.WEB_ORIGIN : origin; } + +export async function currentUser(request: Request, env: Env): Promise { + const token = cookies(request).get(SESSION_COOKIE); if (!token) return null; + return env.DB.prepare(`SELECT users.id,users.email,users.name,users.picture FROM sessions JOIN users ON users.id=sessions.user_id WHERE sessions.token_hash=? AND sessions.expires_at>?`).bind(await sha256(token), new Date().toISOString()).first(); +} +export async function requireUser(request: Request, env: Env): Promise { return (await currentUser(request, env)) ?? errorResponse("请先使用 Google 登录", 401); } + +export async function beginGoogleLogin(request: Request, env: Env): Promise { + if (!env.GOOGLE_CLIENT_ID || !env.GOOGLE_CLIENT_SECRET) return errorResponse("Google 登录尚未配置", 503); + const state = randomToken(), verifier = randomToken(48), origin = appOrigin(request, env); + const params = new URLSearchParams({ client_id: env.GOOGLE_CLIENT_ID, redirect_uri: `${origin}/api/auth/google/callback`, response_type: "code", scope: "openid email profile", state, code_challenge: await sha256(verifier), code_challenge_method: "S256", prompt: "select_account" }); + const response = redirect(`https://accounts.google.com/o/oauth2/v2/auth?${params}`); + response.headers.append("set-cookie", cookie(OAUTH_COOKIE, `${state}.${verifier}`, 600, origin.startsWith("https:"))); + return response; +} + +export async function finishGoogleLogin(request: Request, env: Env): Promise { + const url = new URL(request.url), origin = appOrigin(request, env); + const [expectedState, verifier] = (cookies(request).get(OAUTH_COOKIE) ?? "").split("."); + if (url.searchParams.get("error")) return redirect(`${origin}/?auth_error=cancelled`); + const code = url.searchParams.get("code"); + if (!code || !expectedState || !verifier || url.searchParams.get("state") !== expectedState) return errorResponse("Google 登录请求无效", 400); + const tokenResponse = await fetch("https://oauth2.googleapis.com/token", { method: "POST", headers: { "content-type": "application/x-www-form-urlencoded" }, body: new URLSearchParams({ code, client_id: env.GOOGLE_CLIENT_ID, client_secret: env.GOOGLE_CLIENT_SECRET, redirect_uri: `${origin}/api/auth/google/callback`, grant_type: "authorization_code", code_verifier: verifier }) }); + if (!tokenResponse.ok) return errorResponse("Google 登录失败", 401); + const tokens = await tokenResponse.json<{ id_token?: string }>(); if (!tokens.id_token) return errorResponse("Google 登录响应无效", 401); + const { payload } = await jwtVerify(tokens.id_token, googleKeys, { audience: env.GOOGLE_CLIENT_ID, issuer: ["https://accounts.google.com", "accounts.google.com"] }); + if (!payload.sub || !payload.email || payload.email_verified !== true) return errorResponse("Google 账号未通过验证", 401); + const now = new Date().toISOString(), existing = await env.DB.prepare("SELECT id FROM users WHERE google_sub=?").bind(payload.sub).first<{ id: string }>(), userId = existing?.id ?? crypto.randomUUID(); + await env.DB.prepare(`INSERT INTO users (id,google_sub,email,name,picture,created_at,updated_at) VALUES (?,?,?,?,?,?,?) ON CONFLICT(google_sub) DO UPDATE SET email=excluded.email,name=excluded.name,picture=excluded.picture,updated_at=excluded.updated_at`).bind(userId, payload.sub, payload.email, String(payload.name ?? payload.email), typeof payload.picture === "string" ? payload.picture : null, now, now).run(); + const session = randomToken(), expires = new Date(Date.now() + SESSION_SECONDS * 1000).toISOString(); + await env.DB.prepare("INSERT INTO sessions (token_hash,user_id,expires_at,created_at) VALUES (?,?,?,?)").bind(await sha256(session), userId, expires, now).run(); + const response = redirect(origin); + response.headers.append("set-cookie", cookie(SESSION_COOKIE, session, SESSION_SECONDS, origin.startsWith("https:"))); + response.headers.append("set-cookie", cookie(OAUTH_COOKIE, "", 0, origin.startsWith("https:"))); + return response; +} +export async function authStatus(request: Request, env: Env): Promise { return json({ user: await currentUser(request, env) }); } +export async function logout(request: Request, env: Env): Promise { const token = cookies(request).get(SESSION_COOKIE); if (token) await env.DB.prepare("DELETE FROM sessions WHERE token_hash=?").bind(await sha256(token)).run(); const response = json({ ok: true }); response.headers.append("set-cookie", cookie(SESSION_COOKIE, "", 0, new URL(request.url).protocol === "https:")); return response; } diff --git a/apps/worker/src/http.ts b/apps/worker/src/http.ts index 3b7bd8a..0e2a3ff 100644 --- a/apps/worker/src/http.ts +++ b/apps/worker/src/http.ts @@ -15,6 +15,7 @@ export function withCors(response: Response, request: Request, allowedOrigin: st if (request.headers.get("origin") !== allowedOrigin) return response; const result = new Response(response.body, response); result.headers.set("access-control-allow-origin", allowedOrigin); + result.headers.set("access-control-allow-credentials", "true"); result.headers.set("vary", "Origin"); return result; } diff --git a/apps/worker/src/index.ts b/apps/worker/src/index.ts index 2a0cc6a..945922e 100644 --- a/apps/worker/src/index.ts +++ b/apps/worker/src/index.ts @@ -1,6 +1,7 @@ import { errorResponse, json, assertSameOrigin, withCors } from "./http"; import { consume } from "./jobs"; import type { Env, JobMessage, JobRow } from "./types"; +import { authStatus, beginGoogleLogin, finishGoogleLogin, logout, requireUser } from "./auth"; const allowed = new Set([ "application/pdf", @@ -13,6 +14,8 @@ const cleanName = (name: string) => name.replace(/[^a-zA-Z0-9._-]/g, "_").slice( async function createJob(request: Request, env: Env): Promise { if (!assertSameOrigin(request, env.WEB_ORIGIN)) return errorResponse("Invalid request origin", 403); + const user = await requireUser(request, env); + if (user instanceof Response) return user; const form = await request.formData(); const files = form.getAll("files").filter((value): value is File => value instanceof File); const title = String(form.get("title") ?? "").trim().slice(0, 120); @@ -31,14 +34,15 @@ async function createJob(request: Request, env: Env): Promise { keys.push(key); } const now = new Date().toISOString(); - await env.DB.prepare("INSERT INTO jobs (id,title,language,duration,style,status,progress,stage,input_keys,created_at,updated_at) VALUES (?,?,?,?,?,'queued',5,'等待处理',?,?,?)") - .bind(id, title, language, duration, style, JSON.stringify(keys), now, now).run(); + await env.DB.prepare("INSERT INTO jobs (id,title,language,duration,style,status,progress,stage,input_keys,created_at,updated_at,user_id) VALUES (?,?,?,?,?,'queued',5,'等待处理',?,?,?,?)") + .bind(id, title, language, duration, style, JSON.stringify(keys), now, now, user.id).run(); await env.JOBS.send({ jobId: id }); return json({ id, status: "queued" }, 202); } -async function getJob(id: string, env: Env): Promise { - const row = await env.DB.prepare("SELECT * FROM jobs WHERE id=?").bind(id).first(); +async function getJob(id: string, request: Request, env: Env): Promise { + const user = await requireUser(request, env); if (user instanceof Response) return user; + const row = await env.DB.prepare("SELECT * FROM jobs WHERE id=? AND user_id=?").bind(id, user.id).first(); if (!row) return errorResponse("任务不存在", 404); return json({ id: row.id, title: row.title, status: row.status, progress: row.progress, stage: row.stage, error: row.error, script: row.script ? JSON.parse(row.script) : null, audioUrl: row.audio_key ? `/api/jobs/${id}/audio` : null, createdAt: row.created_at }); @@ -46,7 +50,8 @@ async function getJob(id: string, env: Env): Promise { async function retryJob(id: string, request: Request, env: Env): Promise { if (!assertSameOrigin(request, env.WEB_ORIGIN)) return errorResponse("Invalid request origin", 403); - const row = await env.DB.prepare("SELECT status FROM jobs WHERE id=?").bind(id).first<{ status: string }>(); + const user = await requireUser(request, env); if (user instanceof Response) return user; + const row = await env.DB.prepare("SELECT status FROM jobs WHERE id=? AND user_id=?").bind(id, user.id).first<{ status: string }>(); if (!row) return errorResponse("任务不存在", 404); if (row.status !== "failed") return errorResponse("只有失败的任务可以重试", 409); await env.DB.prepare("UPDATE jobs SET status='queued', progress=5, stage='等待重试', error=NULL, updated_at=? WHERE id=?").bind(new Date().toISOString(), id).run(); @@ -56,7 +61,8 @@ async function retryJob(id: string, request: Request, env: Env): Promise { if (!assertSameOrigin(request, env.WEB_ORIGIN)) return errorResponse("Invalid request origin", 403); - const row = await env.DB.prepare("SELECT status FROM jobs WHERE id=?").bind(id).first<{ status: string }>(); + const user = await requireUser(request, env); if (user instanceof Response) return user; + const row = await env.DB.prepare("SELECT status FROM jobs WHERE id=? AND user_id=?").bind(id, user.id).first<{ status: string }>(); if (!row) return errorResponse("任务不存在", 404); if (row.status === "canceled") return json({ id, status: "canceled" }); if (row.status !== "queued" && row.status !== "processing") return errorResponse("当前任务无法取消", 409); @@ -71,7 +77,8 @@ export async function cancelJob(id: string, request: Request, env: Env): Promise } async function audio(id: string, request: Request, env: Env): Promise { - const row = await env.DB.prepare("SELECT audio_key FROM jobs WHERE id=? AND status='completed'").bind(id).first<{ audio_key: string }>(); + const user = await requireUser(request, env); if (user instanceof Response) return user; + const row = await env.DB.prepare("SELECT audio_key FROM jobs WHERE id=? AND user_id=? AND status='completed'").bind(id, user.id).first<{ audio_key: string }>(); if (!row?.audio_key) return errorResponse("音频尚未生成", 404); const object = await env.FILES.get(row.audio_key, { range: request.headers }); if (!object) return errorResponse("音频不存在", 404); @@ -86,15 +93,19 @@ export default { async fetch(request: Request, env: Env): Promise { const url = new URL(request.url); try { - if (request.method === "OPTIONS" && request.headers.get("origin") === env.WEB_ORIGIN) return new Response(null, { status: 204, headers: { "access-control-allow-origin": env.WEB_ORIGIN, "access-control-allow-methods": "GET,POST,OPTIONS", "access-control-allow-headers": "content-type", "access-control-max-age": "86400" } }); + if (request.method === "OPTIONS" && request.headers.get("origin") === env.WEB_ORIGIN) return new Response(null, { status: 204, headers: { "access-control-allow-origin": env.WEB_ORIGIN, "access-control-allow-credentials": "true", "access-control-allow-methods": "GET,POST,OPTIONS", "access-control-allow-headers": "content-type", "access-control-max-age": "86400" } }); let response: Response; const retryMatch = url.pathname.match(/^\/api\/jobs\/([0-9a-f-]+)\/retry$/); const cancelMatch = url.pathname.match(/^\/api\/jobs\/([0-9a-f-]+)\/cancel$/); const match = url.pathname.match(/^\/api\/jobs\/([0-9a-f-]+)(\/audio)?$/); - if (request.method === "POST" && url.pathname === "/api/jobs") response = await createJob(request, env); + if (request.method === "GET" && url.pathname === "/api/auth/google") response = await beginGoogleLogin(request, env); + else if (request.method === "GET" && url.pathname === "/api/auth/google/callback") response = await finishGoogleLogin(request, env); + else if (request.method === "GET" && url.pathname === "/api/auth/me") response = await authStatus(request, env); + else if (request.method === "POST" && url.pathname === "/api/auth/logout") response = await logout(request, env); + else if (request.method === "POST" && url.pathname === "/api/jobs") response = await createJob(request, env); else if (retryMatch && request.method === "POST") response = await retryJob(retryMatch[1], request, env); else if (cancelMatch && request.method === "POST") response = await cancelJob(cancelMatch[1], request, env); - else if (match && request.method === "GET") response = match[2] ? await audio(match[1], request, env) : await getJob(match[1], env); + else if (match && request.method === "GET") response = match[2] ? await audio(match[1], request, env) : await getJob(match[1], request, env); else response = errorResponse("Not found", 404); return withCors(response, request, env.WEB_ORIGIN); } catch (cause) { diff --git a/apps/worker/src/types.ts b/apps/worker/src/types.ts index 868f8e2..b888493 100644 --- a/apps/worker/src/types.ts +++ b/apps/worker/src/types.ts @@ -9,7 +9,10 @@ export interface Env { MINIMAX_TTS_MODEL: string; MAX_UPLOAD_BYTES: string; WEB_ORIGIN: string; + GOOGLE_CLIENT_ID: string; + GOOGLE_CLIENT_SECRET: string; } +export interface User { id: string; email: string; name: string; picture: string | null } export interface JobMessage { jobId: string } export interface JobRow { @@ -17,6 +20,7 @@ export interface JobRow { status: string; progress: number; stage: string; error: string | null; script: string | null; audio_key: string | null; input_keys: string; created_at: string; updated_at: string; + user_id: string; } export type PodcastTone = "calm" | "happy" | "surprised" | "sad" | "angry" | "fearful"; export interface DialogueLine { speaker: "host" | "guest"; text: string; tone?: PodcastTone } diff --git a/apps/worker/test/http.test.ts b/apps/worker/test/http.test.ts index bec328e..6798473 100644 --- a/apps/worker/test/http.test.ts +++ b/apps/worker/test/http.test.ts @@ -9,7 +9,7 @@ function cancelEnv(status: string, changes = 1): Env { DB: { prepare: vi.fn((sql: string) => ({ bind: vi.fn(() => ({ - first: vi.fn(async () => ({ status })), + first: vi.fn(async () => sql.includes("FROM sessions") ? ({ id: "user-1", email: "user@example.com", name: "User", picture: null }) : ({ status })), run: vi.fn(async () => ({ meta: { changes } })) })) })) @@ -41,10 +41,15 @@ describe("HTTP helpers", () => { expect(response.headers.get("content-type")).toContain("application/json"); }); + it("requires authentication before canceling a job", async () => { + const response = await cancelJob("11111111-1111-1111-1111-111111111111", new Request("https://api.example/api/jobs/11111111-1111-1111-1111-111111111111/cancel", { method: "POST", headers: { origin: "https://web.example" } }), cancelEnv("processing")); + expect(response.status).toBe(401); + }); + it("cancels a processing job", async () => { const env = cancelEnv("processing"); const response = await cancelJob("11111111-1111-1111-1111-111111111111", new Request("https://api.example/api/jobs/11111111-1111-1111-1111-111111111111/cancel", { - method: "POST", headers: { origin: "https://web.example" } + method: "POST", headers: { origin: "https://web.example", cookie: "podforge_session=test-session" } }), env); expect(response.status).toBe(200); @@ -54,7 +59,7 @@ describe("HTTP helpers", () => { it("does not cancel a completed job", async () => { const response = await cancelJob("11111111-1111-1111-1111-111111111111", new Request("https://api.example/api/jobs/11111111-1111-1111-1111-111111111111/cancel", { - method: "POST", headers: { origin: "https://web.example" } + method: "POST", headers: { origin: "https://web.example", cookie: "podforge_session=test-session" } }), cancelEnv("completed")); expect(response.status).toBe(409); diff --git a/apps/worker/wrangler.jsonc b/apps/worker/wrangler.jsonc index 6929b60..0ea3c43 100644 --- a/apps/worker/wrangler.jsonc +++ b/apps/worker/wrangler.jsonc @@ -2,7 +2,7 @@ "$schema": "node_modules/wrangler/config-schema.json", "name": "podforge", "main": "src/index.ts", - "compatibility_date": "2026-07-19", + "compatibility_date": "2026-07-20", "compatibility_flags": ["nodejs_compat"], "routes": [ { "pattern": "podforge.1991421.cn", "custom_domain": true } diff --git a/package-lock.json b/package-lock.json index ae3fd0f..1d7ac12 100644 --- a/package-lock.json +++ b/package-lock.json @@ -31,6 +31,7 @@ "name": "@podforge/worker", "version": "0.1.0", "dependencies": { + "jose": "^6.2.3", "mammoth": "^1.12.0", "unpdf": "^1.2.2" }, @@ -2387,6 +2388,15 @@ "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", "license": "MIT" }, + "node_modules/jose": { + "version": "6.2.3", + "resolved": "http://mirrors.tencentyun.com/npm/jose/-/jose-6.2.3.tgz", + "integrity": "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",