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
10 changes: 10 additions & 0 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ concurrency:
env:
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
VITE_TURNSTILE_SITE_KEY: ${{ secrets.TURNSTILE_SITE_KEY }}

jobs:
changes:
Expand Down Expand Up @@ -74,6 +75,9 @@ jobs:
- name: Install dependencies
run: npm ci

- name: Check production security configuration
run: test -n "$VITE_TURNSTILE_SITE_KEY"

- name: Generate Worker types
run: npm run cf-typegen -w @podforge/worker

Expand Down Expand Up @@ -114,6 +118,9 @@ jobs:
- name: Install dependencies
run: npm ci

- name: Check production security configuration
run: test -n "$VITE_TURNSTILE_SITE_KEY"

- name: Build web app
run: npm run build -w @podforge/web

Expand All @@ -139,6 +146,9 @@ jobs:
- name: Install dependencies
run: npm ci

- name: Check production security configuration
run: test -n "$VITE_TURNSTILE_SITE_KEY"

- name: Type-check
run: npm run typecheck

Expand Down
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,8 @@ npx wrangler secret put MINIMAX_API_KEY
npx wrangler secret put MINIMAX_GROUP_ID
```

Turnstile site key 配置为 GitHub Actions secret `TURNSTILE_SITE_KEY`,服务端 secret 配置为 Worker Secret `TURNSTILE_SECRET`。WAF 限速和 R2 原始资料生命周期的生产配置见 [生产环境安全配置](docs/production-security.md)。

也可以手动执行同一套生产部署步骤:

```bash
Expand All @@ -74,4 +76,4 @@ npm test
npm run build
```

生产环境建议增加 Cloudflare Turnstile/WAF 限速规则,并为 R2 原始资料配置生命周期删除策略。正式发布前需使用实际 MiniMax 账号确认两个系统音色可用。
生产发布前需按 [生产环境安全配置](docs/production-security.md) 完成 Cloudflare 账号侧配置,并使用实际 MiniMax 账号确认两个系统音色可用。
2 changes: 2 additions & 0 deletions apps/web/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
# Cloudflare's always-pass public test key. Replace it with the production widget site key in deployment.
VITE_TURNSTILE_SITE_KEY=1x00000000000000000000AA
2 changes: 1 addition & 1 deletion apps/web/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
import { useCallback,useEffect,useState } from "react";import { createJob,getJob,retryJob,type Job } from "./api";import { JobForm } from "./components/JobForm";import { JobPanel } from "./components/JobPanel";
export default function App(){const [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")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)}}function reset(){history.replaceState(null,"","/");setId(null);setJob(null)}return <><header><a className="brand" href="/"><span>PF</span> PodForge</a><div className="tag">资料里的好故事,值得被听见</div></header><main><section className="hero"><p className="eyebrow">AI PODCAST STUDIO</p><h1>把你的资料,<em>锻造成播客。</em></h1><p>上传 PDF、DOCX、TXT 或 Markdown,几分钟内获得一场自然、有重点的中文双人对谈。</p></section><div className="layout"><JobForm onCreate={create}/><aside><JobPanel job={job} onRetry={retry} onReset={reset}/></aside></div></main><footer>PodForge <span>·</span> Powered securely on Cloudflare</footer></>}
export default function App(){const [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")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(token:string){if(id){await retryJob(id,token);setJob(job?{...job,status:"queued",progress:5,stage:"等待重试"}:null)}}function reset(){history.replaceState(null,"","/");setId(null);setJob(null)}return <><header><a className="brand" href="/"><span>PF</span> PodForge</a><div className="tag">资料里的好故事,值得被听见</div></header><main><section className="hero"><p className="eyebrow">AI PODCAST STUDIO</p><h1>把你的资料,<em>锻造成播客。</em></h1><p>上传 PDF、DOCX、TXT 或 Markdown,几分钟内获得一场自然、有重点的中文双人对谈。</p></section><div className="layout"><JobForm onCreate={create}/><aside><JobPanel job={job} onRetry={retry} onReset={reset}/></aside></div></main><footer>PodForge <span>·</span> Powered securely on Cloudflare</footer></>}
2 changes: 1 addition & 1 deletion apps/web/src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,5 @@ 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}
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"});
export const retryJob=(id:string,token:string)=>{const form=new FormData();form.set("cf-turnstile-response",token);return request<{id:string}>(`/api/jobs/${id}/retry`,{method:"POST",body:form})};
export const audioUrl=(path:string)=>`${base}${path}`;
7 changes: 4 additions & 3 deletions apps/web/src/components/JobForm.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
import { useRef, useState, type DragEvent } from "react";
import { useCallback, useRef, useState, type DragEvent } from "react";
import { Turnstile } from "./Turnstile";
type Props={onCreate:(data:FormData)=>Promise<void>};
export function JobForm({onCreate}:Props){const [files,setFiles]=useState<File[]>([]),[error,setError]=useState(""),[busy,setBusy]=useState(false),[dragging,setDragging]=useState(false),input=useRef<HTMLInputElement>(null);function handleDrag(event:DragEvent<HTMLLabelElement>){event.preventDefault();event.stopPropagation();if(event.type==="dragenter"||event.type==="dragover"){event.dataTransfer.dropEffect="copy";setDragging(true)}else{setDragging(false)}}function handleDrop(event:DragEvent<HTMLLabelElement>){event.preventDefault();event.stopPropagation();setDragging(false);setFiles([...event.dataTransfer.files])}async function submit(event:React.FormEvent<HTMLFormElement>){event.preventDefault();setError("");setBusy(true);try{const data=new FormData(event.currentTarget);files.forEach(file=>data.append("files",file));await onCreate(data)}catch(cause){setError(cause instanceof Error?cause.message:"创建任务失败")}finally{setBusy(false)}}return <form className="card" onSubmit={submit}>
export function JobForm({onCreate}:Props){const [files,setFiles]=useState<File[]>([]),[error,setError]=useState(""),[busy,setBusy]=useState(false),[dragging,setDragging]=useState(false),[token,setToken]=useState(""),[challenge,setChallenge]=useState(0),input=useRef<HTMLInputElement>(null),handleVerify=useCallback((value:string)=>setToken(value),[]);function handleDrag(event:DragEvent<HTMLLabelElement>){event.preventDefault();event.stopPropagation();if(event.type==="dragenter"||event.type==="dragover"){event.dataTransfer.dropEffect="copy";setDragging(true)}else{setDragging(false)}}function handleDrop(event:DragEvent<HTMLLabelElement>){event.preventDefault();event.stopPropagation();setDragging(false);setFiles([...event.dataTransfer.files])}async function submit(event:React.FormEvent<HTMLFormElement>){event.preventDefault();setError("");setBusy(true);try{const data=new FormData(event.currentTarget);data.set("cf-turnstile-response",token);files.forEach(file=>data.append("files",file));await onCreate(data);setToken("");setChallenge(value=>value+1)}catch(cause){setError(cause instanceof Error?cause.message:"创建任务失败");setToken("");setChallenge(value=>value+1)}finally{setBusy(false)}}return <form className="card" onSubmit={submit}>
<Step number="01" title="选择资料" copy="单个文件最大 10 MB,可同时上传多个"/>
<label className={`dropzone${dragging?" drag":""}`} onDragEnter={handleDrag} onDragOver={handleDrag} onDragLeave={handleDrag} onDrop={handleDrop}><input ref={input} type="file" multiple accept=".pdf,.docx,.txt,.md,application/pdf,application/vnd.openxmlformats-officedocument.wordprocessingml.document,text/plain,text/markdown" onChange={e=>setFiles([...e.target.files??[]])}/><span className="upload-icon">↑</span><strong>拖放文件到这里</strong><small>或点击浏览本地文件</small></label>
<ul className="file-list" aria-live="polite">{files.map(file=><li key={`${file.name}-${file.size}`}>{file.name}<span>{(file.size/1048576).toFixed(1)} MB</span></li>)}</ul>
<Step number="02" title="定制节目" copy="为这期节目设置声音方向"/>
<label>播客标题<input name="title" maxLength={120} placeholder="例如:重新理解人工智能" required/></label>
<div className="fields"><label>目标语言<select name="language"><option value="zh-CN">简体中文</option><option value="en-US">English</option></select></label><label>目标时长<select name="duration" defaultValue="8"><option value="5">约 5 分钟</option><option value="8">约 8 分钟</option><option value="15">约 15 分钟</option><option value="30">约 30 分钟</option></select></label></div>
<label>节目风格<select name="style"><option>轻松科普</option><option>深度访谈</option><option>新闻解读</option><option>故事叙述</option></select></label>
<button id="submit" disabled={busy||files.length===0}>开始生成播客 <span>→</span></button><p className="error" role="alert">{error}</p>
<Turnstile key={challenge} onVerify={handleVerify}/><button id="submit" disabled={busy||files.length===0||!token}>开始生成播客 <span>→</span></button><p className="error" role="alert">{error}</p>
</form>}
function Step({number,title,copy}:{number:string;title:string;copy:string}){return <div className="step"><b>{number}</b><div><h2>{title}</h2><p>{copy}</p></div></div>}
4 changes: 2 additions & 2 deletions apps/web/src/components/JobPanel.tsx
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
import type { Job } from "../api";import { audioUrl } from "../api";
export function JobPanel({job,onRetry,onReset}:{job:Job|null;onRetry:()=>void;onReset:()=>void}){if(!job)return <section className="preview empty"><div className="record"><i/></div><h2>你的节目将在这里诞生</h2><p>上传资料并设置节目参数,生成进度与成品会实时显示。</p></section>;if(job.status==="failed")return <section className="preview"><p className="eyebrow danger">生成未完成</p><h2>这次锻造遇到了问题</h2><p>{job.error}</p><button onClick={onRetry}>重试生成</button> <button className="secondary" onClick={onReset}>返回修改资料</button></section>;if(job.status==="completed"&&job.audioUrl)return <section className="preview"><p className="eyebrow">新鲜出炉</p><h2>{job.title}</h2><audio controls preload="metadata" src={audioUrl(job.audioUrl)}/><a className="download" href={audioUrl(job.audioUrl)} download>下载 MP3 ↓</a><details><summary>查看完整脚本</summary><div className="script">{job.script?.lines.map((line,index)=><div className="line" key={index}><b>{line.speaker==="host"?"主持人":"嘉宾"}</b><p>{line.text}</p></div>)}</div></details></section>;return <section className="preview"><p className="eyebrow">正在制作</p><h2>{job.title}</h2><div className="meter"><i style={{width:`${job.progress}%`}}/></div><div className="progress-copy"><strong>{job.progress}%</strong><span>{job.stage}</span></div><p className="hint">可以保存此页面地址,刷新后任务仍会继续。</p></section>}
import { useCallback, useState } from "react";import type { Job } from "../api";import { audioUrl } from "../api";import { Turnstile } from "./Turnstile";
export function JobPanel({job,onRetry,onReset}:{job:Job|null;onRetry:(token:string)=>void;onReset:()=>void}){const[token,setToken]=useState("");const handleVerify=useCallback((value:string)=>setToken(value),[]);if(!job)return <section className="preview empty"><div className="record"><i/></div><h2>你的节目将在这里诞生</h2><p>上传资料并设置节目参数,生成进度与成品会实时显示。</p></section>;if(job.status==="failed")return <section className="preview"><p className="eyebrow danger">生成未完成</p><h2>这次锻造遇到了问题</h2><p>{job.error}</p><Turnstile onVerify={handleVerify}/><button disabled={!token} onClick={()=>onRetry(token)}>重试生成</button> <button className="secondary" onClick={onReset}>返回修改资料</button></section>;if(job.status==="completed"&&job.audioUrl)return <section className="preview"><p className="eyebrow">新鲜出炉</p><h2>{job.title}</h2><audio controls preload="metadata" src={audioUrl(job.audioUrl)}/><a className="download" href={audioUrl(job.audioUrl)} download>下载 MP3 ↓</a><details><summary>查看完整脚本</summary><div className="script">{job.script?.lines.map((line,index)=><div className="line" key={index}><b>{line.speaker==="host"?"主持人":"嘉宾"}</b><p>{line.text}</p></div>)}</div></details></section>;return <section className="preview"><p className="eyebrow">正在制作</p><h2>{job.title}</h2><div className="meter"><i style={{width:`${job.progress}%`}}/></div><div className="progress-copy"><strong>{job.progress}%</strong><span>{job.stage}</span></div><p className="hint">可以保存此页面地址,刷新后任务仍会继续。</p></section>}
51 changes: 51 additions & 0 deletions apps/web/src/components/Turnstile.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { useEffect, useRef } from "react";

declare global {
interface Window {
turnstile?: {
render: (container: HTMLElement, options: Record<string, unknown>) => string;
remove: (widgetId: string) => void;
};
}
}

const scriptId = "cloudflare-turnstile-script";

export function Turnstile({ onVerify }: { onVerify: (token: string) => void }) {
const container = useRef<HTMLDivElement>(null);
const widgetId = useRef<string | undefined>(undefined);
const sitekey = import.meta.env.VITE_TURNSTILE_SITE_KEY as string | undefined;

useEffect(() => {
if (!sitekey) return;
const render = () => {
if (!container.current || !window.turnstile || widgetId.current) return;
widgetId.current = window.turnstile.render(container.current, {
sitekey,
action: "turnstile-spin-v2",
callback: onVerify,
"expired-callback": () => onVerify(""),
"error-callback": () => onVerify("")
});
};
let script = document.getElementById(scriptId) as HTMLScriptElement | null;
if (!script) {
script = document.createElement("script");
script.id = scriptId;
script.src = "https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit";
script.async = true;
script.defer = true;
document.head.appendChild(script);
}
script.addEventListener("load", render);
render();
return () => {
script?.removeEventListener("load", render);
if (widgetId.current && window.turnstile) window.turnstile.remove(widgetId.current);
widgetId.current = undefined;
};
}, [onVerify, sitekey]);

if (!sitekey) return <p className="error" role="alert">Turnstile 尚未配置,暂时无法提交任务。</p>;
return <div ref={container} className="turnstile" data-action="turnstile-spin-v2"/>;
}
2 changes: 2 additions & 0 deletions apps/worker/.dev.vars.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
# Cloudflare's always-pass test secret. Never use it in production.
TURNSTILE_SECRET=1x0000000000000000000000000000000AA
6 changes: 5 additions & 1 deletion apps/worker/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { errorResponse, json, assertSameOrigin, withCors } from "./http";
import { consume } from "./jobs";
import type { Env, JobMessage, JobRow } from "./types";
import { verifyTurnstile } from "./turnstile";

const allowed = new Set([
"application/pdf",
Expand All @@ -14,6 +15,7 @@ const cleanName = (name: string) => name.replace(/[^a-zA-Z0-9._-]/g, "_").slice(
async function createJob(request: Request, env: Env): Promise<Response> {
if (!assertSameOrigin(request, env.WEB_ORIGIN)) return errorResponse("Invalid request origin", 403);
const form = await request.formData();
if (!await verifyTurnstile(request, env, form.get("cf-turnstile-response"))) return errorResponse("人机验证失败,请重试", 403);
const files = form.getAll("files").filter((value): value is File => value instanceof File);
const title = String(form.get("title") ?? "").trim().slice(0, 120);
const language = String(form.get("language") ?? "zh-CN");
Expand All @@ -26,7 +28,7 @@ async function createJob(request: Request, env: Env): Promise<Response> {
const id = crypto.randomUUID();
const keys: string[] = [];
for (const file of files) {
const key = `jobs/${id}/input/${crypto.randomUUID()}-${cleanName(file.name).toLowerCase()}`;
const key = `inputs/${id}/${crypto.randomUUID()}-${cleanName(file.name).toLowerCase()}`;
await env.FILES.put(key, file.stream(), { httpMetadata: { contentType: file.type } });
keys.push(key);
}
Expand All @@ -46,6 +48,8 @@ async function getJob(id: string, env: Env): Promise<Response> {

async function retryJob(id: string, request: Request, env: Env): Promise<Response> {
if (!assertSameOrigin(request, env.WEB_ORIGIN)) return errorResponse("Invalid request origin", 403);
const form = await request.formData();
if (!await verifyTurnstile(request, env, form.get("cf-turnstile-response"))) return errorResponse("人机验证失败,请重试", 403);
const row = await env.DB.prepare("SELECT status FROM jobs WHERE id=?").bind(id).first<{ status: string }>();
if (!row) return errorResponse("任务不存在", 404);
if (row.status !== "failed") return errorResponse("只有失败的任务可以重试", 409);
Expand Down
2 changes: 1 addition & 1 deletion apps/worker/src/jobs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ export async function processJob(env: Env, jobId: string): Promise<void> {
parts.push(await synthesizeLine(env, line.text, line.speaker));
await update(env, jobId, "processing", 45 + Math.floor(((index + 1) / script.lines.length) * 45), `正在合成语音 ${index + 1}/${script.lines.length}`);
}
const audioKey = `jobs/${jobId}/podcast.mp3`;
const audioKey = `outputs/${jobId}/podcast.mp3`;
const total = parts.reduce((sum, part) => sum + part.byteLength, 0);
const audio = new Uint8Array(total);
let offset = 0;
Expand Down
29 changes: 29 additions & 0 deletions apps/worker/src/turnstile.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import type { Env } from "./types";

interface TurnstileResult {
success: boolean;
action?: string;
hostname?: string;
"error-codes"?: string[];
}

export async function verifyTurnstile(request: Request, env: Env, token: FormDataEntryValue | null): Promise<boolean> {
if (!env.TURNSTILE_SECRET || typeof token !== "string" || !token || token.length > 2048) return false;
const body = new URLSearchParams({ secret: env.TURNSTILE_SECRET, response: token, idempotency_key: crypto.randomUUID() });
const remoteIp = request.headers.get("CF-Connecting-IP");
if (remoteIp) body.set("remoteip", remoteIp);
try {
const response = await fetch("https://challenges.cloudflare.com/turnstile/v0/siteverify", {
method: "POST",
headers: { "content-type": "application/x-www-form-urlencoded" },
body,
signal: AbortSignal.timeout(5000)
});
if (!response.ok) return false;
const result = await response.json<TurnstileResult>();
const hostnames = env.TURNSTILE_HOSTNAMES.split(",").map(value => value.trim()).filter(Boolean);
return result.success === true && result.action === "turnstile-spin-v2" && !!result.hostname && hostnames.includes(result.hostname);
} catch {
return false;
}
}
2 changes: 2 additions & 0 deletions apps/worker/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ export interface Env {
MINIMAX_TTS_MODEL: string;
MAX_UPLOAD_BYTES: string;
WEB_ORIGIN: string;
TURNSTILE_SECRET: string;
TURNSTILE_HOSTNAMES: string;
}

export interface JobMessage { jobId: string }
Expand Down
Loading