Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
22 changes: 20 additions & 2 deletions apps/web/src/App.tsx
Original file line number Diff line number Diff line change
@@ -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<Job|null>(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 <><header><a className="brand" href="/"><span>PF</span> PodForge</a><div className="header-actions"><div className="tag">{t.tagline}</div><div className="locale-switch" role="group" aria-label={t.languageLabel}><button type="button" aria-pressed={locale==="zh-CN"} onClick={()=>setLocale("zh-CN")}>{t.zhShort}</button><button type="button" aria-pressed={locale==="en-US"} onClick={()=>setLocale("en-US")}>{t.enShort}</button></div></div></header><main><section className="hero"><p className="eyebrow">AI PODCAST STUDIO</p><h1>{t.heroLead} <em>{t.heroEmphasis}</em></h1><p>{t.heroCopy}</p></section><div className="layout"><JobForm onCreate={create}/><aside><JobPanel job={job} onRetry={retry} onCancel={cancel} onReset={reset}/></aside></div></main><footer>PodForge <span>·</span> Powered securely on Cloudflare <span>·</span> <a href="https://1991421.cn">Created by Alan H</a></footer></>}
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<Job|null>(null),[id,setId]=useState(()=>new URLSearchParams(location.search).get("job")),[user,setUser]=useState<User|null|undefined>();
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 <><header><a className="brand" href="/"><span>PF</span> PodForge</a><div className="header-actions"><div className="tag">{t.tagline}</div><div className="locale-switch" role="group" aria-label={t.languageLabel}><button type="button" aria-pressed={!english} onClick={()=>setLocale("zh-CN")}>{t.zhShort}</button><button type="button" aria-pressed={english} onClick={()=>setLocale("en-US")}>{t.enShort}</button></div>{user&&<div className="user-menu">{user.picture&&<img src={user.picture} alt="" referrerPolicy="no-referrer"/>}<span>{user.name}</span><button type="button" onClick={()=>void signOut()}>{english?"Sign out":"退出"}</button></div>}</div></header><main><section className="hero"><p className="eyebrow">AI PODCAST STUDIO</p><h1>{t.heroLead} <em>{t.heroEmphasis}</em></h1><p>{t.heroCopy}</p></section>{user===undefined?<div className="auth-card">{english?"Checking your session…":"正在确认登录状态…"}</div>:user?<div className="layout"><JobForm onCreate={create}/><aside><JobPanel job={job} onRetry={retry} onCancel={cancel} onReset={reset}/></aside></div>:<section className="auth-card"><div className="record"><i/></div><h2>{english?"Sign in to start creating":"登录后开始创作"}</h2><p>{english?"Sign in with Google to protect your jobs and generated content and help us control service costs.":"使用 Google 登录以保护你的任务和生成内容,并帮助我们控制服务成本。"}</p><a className="google-login" href={googleLoginUrl()}><b>G</b>{english?"Continue with Google":"使用 Google 登录"}</a></section>}</main><footer>PodForge <span>·</span> Powered securely on Cloudflare <span>·</span> <a href="https://1991421.cn">Created by Alan H</a></footer></>
}
6 changes: 5 additions & 1 deletion apps/web/src/api.ts
Original file line number Diff line number Diff line change
@@ -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<T>(path:string, init?:RequestInit):Promise<T>{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<T>(path:string, init?:RequestInit):Promise<T>{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<Job>(`/api/jobs/${id}`);
export const retryJob=(id:string)=>request<{id:string}>(`/api/jobs/${id}/retry`,{method:"POST"});
Expand Down
1 change: 1 addition & 0 deletions apps/web/src/auth.css

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

19 changes: 19 additions & 0 deletions apps/worker/migrations/0002_auth.sql
Original file line number Diff line number Diff line change
@@ -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);
2 changes: 1 addition & 1 deletion apps/worker/package.json
Original file line number Diff line number Diff line change
@@ -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"}}
{"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"}}
54 changes: 54 additions & 0 deletions apps/worker/src/auth.ts
Original file line number Diff line number Diff line change
@@ -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<string> { return encode(new Uint8Array(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(value)))); }
function cookies(request: Request): Map<string, string> { 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<User | null> {
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<User>();
}
export async function requireUser(request: Request, env: Env): Promise<User | Response> { return (await currentUser(request, env)) ?? errorResponse("请先使用 Google 登录", 401); }

export async function beginGoogleLogin(request: Request, env: Env): Promise<Response> {
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<Response> {
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<Response> { return json({ user: await currentUser(request, env) }); }
export async function logout(request: Request, env: Env): Promise<Response> { 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; }
1 change: 1 addition & 0 deletions apps/worker/src/http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Loading