From c27fa4fe22bf98f6a6cd7ee776f6ce414c9deb8d Mon Sep 17 00:00:00 2001 From: Codex Bot Date: Thu, 29 Oct 2026 16:39:09 +0800 Subject: [PATCH 01/10] Fail closed without admin configuration and document SMTP operations --- apps/waterfall-wiki/ACCOUNT.md | 46 ++++++++++++++++++++++++++++++++ apps/waterfall-wiki/src/proxy.ts | 5 +++- compose.yml | 1 + scripts/integration.sh | 2 ++ 4 files changed, 53 insertions(+), 1 deletion(-) create mode 100644 apps/waterfall-wiki/ACCOUNT.md diff --git a/apps/waterfall-wiki/ACCOUNT.md b/apps/waterfall-wiki/ACCOUNT.md new file mode 100644 index 0000000..03fbf3f --- /dev/null +++ b/apps/waterfall-wiki/ACCOUNT.md @@ -0,0 +1,46 @@ +# Email accounts + +`/account` offers registration, password login, email verification/resend, and logout. +`/api/account/session` returns the current account; other actions use same-origin POST. +Passwords require at least 12 characters and at most 256 UTF-8 bytes. Only verified +accounts can log in. Verification links expire after 30 minutes and are consumed once. +Session tokens expire after seven days; logout deletes the current token immediately. +Only token SHA-256 digests are stored. Passwords use individually salted scrypt. + +## Runtime configuration + +The existing production deployment reads `/etc/prompt.env` on ces-control. Set its +ownership to root and mode to 0600. The operator should update SMTP_PASSWORD there +through a secure remote session, without putting it in command arguments, repository, +CI output, release.env, or chat. Existing compose deployment reads this file without +copying credentials into the release directory. Do not run `docker compose config` +without `--quiet` against the production environment because interpolation shows secrets. +Docker administrators can inspect container environment; access to Docker is privileged. + +Set ACCOUNT_PUBLIC_ORIGIN to the exact browser origin and SMTP_HOST=smtpdm.aliyun.com, +SMTP_PORT=465, SMTP_USERNAME and SMTP_FROM=noreply@notice.entroia.com. TLS certificate +validation stays enabled. Missing SMTP configuration returns a generic service failure; +resend with the registration password can recover an account whose initial delivery failed. + +Keep AUTH_CONFIG_FILE configured for administrator access. Missing administrator +configuration fails closed. AUTH_DISABLED_FOR_TESTS is false by default and is enabled +only by the disposable legacy integration stack; never enable it in production. +Email account cookies never satisfy administrator authorization. With the current HTTP +acceptance origin use AUTH_COOKIE_SECURE=false; use true after enabling HTTPS. + +Limits persist in PostgreSQL: 10 logins and 3 registration/resend attempts per email per +15 minutes, plus 300 requests per 15 minutes globally. ACCOUNT_TRUST_PROXY defaults +false. Enable it only when a trusted ingress overwrites x-forwarded-for; this changes +the global cap to 60 per trusted client address. Do not enable it on the direct public port. +Limits count failures and successes. Registration/resend always give the same success +response for existing/unknown emails, and login uses a dummy KDF for unknown accounts. +The rate limit itself can reveal recent activity for a known email; it does not reveal +whether an account exists. No password recovery or account deletion is included. + +## Verification + +ces-control runs crypto/origin tests, PostgreSQL verification-token replay races, +expired/revoked sessions, concurrent limit consumption and unverified login rejection. +Production-auth exercises verify that account cookies cannot authorize legacy writes. +Actual SMTP delivery and visual acceptance at http://8.211.154.134:18131/account require +operator runtime configuration and the normal manually approved deployment. diff --git a/apps/waterfall-wiki/src/proxy.ts b/apps/waterfall-wiki/src/proxy.ts index 11b2a4e..65450ad 100644 --- a/apps/waterfall-wiki/src/proxy.ts +++ b/apps/waterfall-wiki/src/proxy.ts @@ -9,7 +9,10 @@ export function proxy(request: NextRequest) { if (path === '/brand-mark.svg' || path === '/login' || path === '/api/auth/login' || path === '/api/auth/logout' || path === '/api/health' || path === '/api/ready' || path.startsWith('/_next/static/')) return NextResponse.next({headers:responseHeaders}); let config; try { config = authConfig(); } catch { return NextResponse.json({error:'登录服务配置不可用'}, {status:503,headers:responseHeaders}); } - if (!config) return NextResponse.next(); + if (!config) { + if (process.env.AUTH_DISABLED_FOR_TESTS === 'true' && process.env.AUTH_CONFIG_FILE === '') return NextResponse.next(); + return NextResponse.json({error:'管理员登录服务尚未配置'}, {status:503,headers:responseHeaders}); + } const bearer = request.headers.get('authorization'); const token = bearer?.startsWith('Bearer ') ? bearer.slice(7) : request.cookies.get(SESSION_COOKIE)?.value; if (validSession(token,config)) { diff --git a/compose.yml b/compose.yml index 5bd9c3d..48f6f12 100644 --- a/compose.yml +++ b/compose.yml @@ -12,6 +12,7 @@ x-waterfall-wiki-environment: &waterfall-wiki-environment CHROMIUM_PATH: /usr/bin/chromium RENDER_ORIGIN: http://waterfall-wiki:3000 AUTH_CONFIG_FILE: ${AUTH_CONFIG_FILE:-} + AUTH_DISABLED_FOR_TESTS: ${AUTH_DISABLED_FOR_TESTS:-false} AUTH_COOKIE_SECURE: ${AUTH_COOKIE_SECURE:-true} ACCOUNT_PUBLIC_ORIGIN: ${ACCOUNT_PUBLIC_ORIGIN:-http://8.211.154.134:18131} ACCOUNT_TRUST_PROXY: ${ACCOUNT_TRUST_PROXY:-false} diff --git a/scripts/integration.sh b/scripts/integration.sh index 857a956..4e65f87 100644 --- a/scripts/integration.sh +++ b/scripts/integration.sh @@ -11,6 +11,8 @@ cleanup(){ code=$?; if [ "$code" -ne 0 ]; then compose logs --tail 160 waterfall trap cleanup EXIT # Random credentials exist only for this disposable stack; no host Node required. docker run --rm --entrypoint node "$WATERFALL_WIKI_IMAGE" -e "const c=require('crypto');const hex=()=>c.randomBytes(24).toString('hex');console.log('POSTGRES_PASSWORD='+hex()+'\nS3_ACCESS_KEY='+hex()+'\nS3_SECRET_KEY='+hex())" > "$TEST_ENV" +# Explicitly isolate legacy unauthenticated integration coverage; production fails closed. +printf '\nAUTH_DISABLED_FOR_TESTS=true\n' >> "$TEST_ENV" compose up -d --no-build --wait --wait-timeout 120 postgres objects compose run --rm --no-deps migrate compose up -d --no-build --wait --wait-timeout 150 waterfall-wiki waterfall-wiki-worker From d5807aa2988369982fa4fe394dd33990e32b9806 Mon Sep 17 00:00:00 2001 From: Codex Bot Date: Thu, 29 Oct 2026 16:47:21 +0800 Subject: [PATCH 02/10] Add persistent author-owned guidance publishing and immutable revisions --- .../db/015_guidance_documents.sql | 7 +++++ apps/waterfall-wiki/node_modules | 1 + apps/waterfall-wiki/package.json | 2 +- apps/waterfall-wiki/src/lib/guidance-input.ts | 19 ++++++++++++++ apps/waterfall-wiki/src/lib/guidance-store.ts | 15 +++++++++++ .../src/pages/api/documents/[[...path]].ts | 26 +++++++++++++++++++ .../src/pages/documents/[id].tsx | 6 +++++ .../src/pages/documents/index.tsx | 3 +++ apps/waterfall-wiki/src/pages/write.tsx | 10 +++++++ apps/waterfall-wiki/src/proxy.ts | 2 +- .../src/styles/documents.module.css | 1 + .../tests/guidance-integration.ts | 15 +++++++++++ .../tests/guidance-publishing.test.ts | 6 +++++ apps/waterfall-wiki/tests/integration.test.ts | 2 ++ docs/linked-guidance/publishing.md | 12 +++++++++ 15 files changed, 125 insertions(+), 2 deletions(-) create mode 100644 apps/waterfall-wiki/db/015_guidance_documents.sql create mode 120000 apps/waterfall-wiki/node_modules create mode 100644 apps/waterfall-wiki/src/lib/guidance-input.ts create mode 100644 apps/waterfall-wiki/src/lib/guidance-store.ts create mode 100644 apps/waterfall-wiki/src/pages/api/documents/[[...path]].ts create mode 100644 apps/waterfall-wiki/src/pages/documents/[id].tsx create mode 100644 apps/waterfall-wiki/src/pages/documents/index.tsx create mode 100644 apps/waterfall-wiki/src/pages/write.tsx create mode 100644 apps/waterfall-wiki/src/styles/documents.module.css create mode 100644 apps/waterfall-wiki/tests/guidance-integration.ts create mode 100644 apps/waterfall-wiki/tests/guidance-publishing.test.ts create mode 100644 docs/linked-guidance/publishing.md diff --git a/apps/waterfall-wiki/db/015_guidance_documents.sql b/apps/waterfall-wiki/db/015_guidance_documents.sql new file mode 100644 index 0000000..e20ce10 --- /dev/null +++ b/apps/waterfall-wiki/db/015_guidance_documents.sql @@ -0,0 +1,7 @@ +BEGIN; +CREATE TABLE guidance_documents(id text PRIMARY KEY CHECK(id ~ '^user-[a-z0-9-]{1,74}$'), owner_id uuid NOT NULL REFERENCES account_users(id), latest integer NOT NULL DEFAULT 0, deleted_at timestamptz); +CREATE TABLE guidance_revisions(document_id text NOT NULL REFERENCES guidance_documents(id), revision integer NOT NULL CHECK(revision>0), content jsonb NOT NULL, created_at timestamptz NOT NULL DEFAULT now(), PRIMARY KEY(document_id,revision)); +CREATE FUNCTION guidance_immutable() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN RAISE EXCEPTION 'Published revisions are immutable'; END $$; +CREATE TRIGGER guidance_immutable BEFORE UPDATE OR DELETE ON guidance_revisions FOR EACH ROW EXECUTE FUNCTION guidance_immutable(); +INSERT INTO schema_migrations(version) VALUES('015_guidance_documents'); +COMMIT; diff --git a/apps/waterfall-wiki/node_modules b/apps/waterfall-wiki/node_modules new file mode 120000 index 0000000..473f58b --- /dev/null +++ b/apps/waterfall-wiki/node_modules @@ -0,0 +1 @@ +/Users/zzyang/ProjectMedia/probably-rendering-one-more-pixel-tonight/apps/waterfall-wiki/node_modules \ No newline at end of file diff --git a/apps/waterfall-wiki/package.json b/apps/waterfall-wiki/package.json index 80f258c..71b593c 100644 --- a/apps/waterfall-wiki/package.json +++ b/apps/waterfall-wiki/package.json @@ -10,7 +10,7 @@ "build": "next build && esbuild src/worker.ts --bundle --platform=node --format=cjs --external:pg-native --external:playwright-core --outfile=dist/worker.cjs && esbuild scripts/bootstrap.ts --bundle --platform=node --format=cjs --external:pg-native --external:playwright-core --outfile=dist/bootstrap.cjs && esbuild scripts/publish-learning-series.ts --bundle --platform=node --format=cjs --external:pg-native --external:playwright-core --outfile=dist/publish-learning-series.cjs && esbuild scripts/auth-cli.ts --bundle --platform=node --format=cjs --external:pg-native --outfile=dist/auth-cli.cjs && node scripts/prepare-standalone.mjs", "start": "next start", "typecheck": "next typegen && tsc --noEmit", - "test": "tsx --test tests/api-documentation.test.ts tests/health.test.ts tests/model.test.ts tests/learning-series.test.ts tests/skill-docs.test.ts tests/wiki.test.ts tests/explore.test.ts tests/preview.test.ts tests/note-markdown.test.tsx tests/api-publication.test.ts tests/discovery.test.ts tests/auth.test.ts tests/account.test.ts tests/guidance.test.ts", + "test": "tsx --test tests/api-documentation.test.ts tests/health.test.ts tests/model.test.ts tests/learning-series.test.ts tests/skill-docs.test.ts tests/wiki.test.ts tests/explore.test.ts tests/preview.test.ts tests/note-markdown.test.tsx tests/api-publication.test.ts tests/discovery.test.ts tests/auth.test.ts tests/account.test.ts tests/guidance.test.ts tests/guidance-publishing.test.ts", "test:smoke": "node scripts/smoke.mjs", "storage:prepare": "tsx scripts/bootstrap.ts", "content:publish-learning": "tsx scripts/publish-learning-series.ts", diff --git a/apps/waterfall-wiki/src/lib/guidance-input.ts b/apps/waterfall-wiki/src/lib/guidance-input.ts new file mode 100644 index 0000000..7c320cc --- /dev/null +++ b/apps/waterfall-wiki/src/lib/guidance-input.ts @@ -0,0 +1,19 @@ +import { z } from 'zod'; +const kind=z.enum(['guide','context','evidence']); +const text=z.string().trim().min(1).max(20000).refine(s=>!/[\u0000-\u0008\u000b\u000c\u000e-\u001f\ufffe\uffff]/.test(s),'非法 XML 字符'); +export const documentInput=z.object({id:z.string().regex(/^user-[a-z0-9-]{1,74}$/),revision:z.number().int().min(1).max(2147483647),kind,title:text.max(200),summary:text.max(1000),applies:text,body:text,steps:z.array(text).max(100),checks:z.array(text).max(100),links:z.array(z.object({target:z.string().regex(/^[a-z0-9-]{1,79}$/),revision:z.number().int().min(1).max(2147483647),relation:kind,when:text.max(1000),label:text.max(200)}).strict()).max(100),tags:z.array(text.max(80)).max(20)}).strict(); +// Deliberately narrow XML 1.0 grammar: no DTD, comments, CDATA, nested markup or arbitrary entities. +export function parseDocumentXml(source:string){ + if(Buffer.byteLength(source)>131072)throw Error('XML 过大'); + let s=source.replace(/^<\?xml version="1.0" encoding="UTF-8"\?>\s*/, ''); + if(/<\?|{if(/&(?!amp;|lt;|gt;|quot;|apos;)/.test(v))throw Error('非法实体');return v.replace(/&(amp|lt|gt|quot|apos);/g,(_,k)=>({amp:'&',lt:'<',gt:'>',quot:'"',apos:"'"}[k] as string));}; + const attrs=(v:string)=>{const result:Record={};while(v.trim()){const m=/^\s+([a-z]+)="([^"<]*)"/.exec(v);if(!m||m[1] in result)throw Error('非法或重复属性');result[m[1]]=decode(m[2]);v=v.slice(m[0].length);}return result;}; + const root=/^]*)>([\s\S]*)<\/guidance>\s*$/.exec(s);if(!root)throw Error('非法根元素');const a=attrs(root[1]); + if(Object.keys(a).sort().join(',')!=='id,kind,lang,revision,xmlns'||a.xmlns!=='urn:linked-guidance:0.1'||a.lang!=='zh-CN'||! /^[1-9][0-9]*$/.test(a.revision))throw Error('不支持的协议或属性'); + const result:Record={id:a.id,kind:a.kind,revision:Number(a.revision),steps:[],checks:[],links:[],tags:[]};s=root[2]; + while(s.trim()){const m=/^\s*<(title|summary|applies|body|step|check|link)([^>]*)>([^<]*)<\/\1>/.exec(s);if(!m)throw Error('非法子元素');const [,tag,raw,value]=m;const at=attrs(raw);const v=decode(value); + if(tag==='link'){if(Object.keys(at).sort().join(',')!=='relation,revision,target,when'||! /^[1-9][0-9]*$/.test(at.revision))throw Error('非法链接');(result.links as unknown[]).push({...at,revision:Number(at.revision),label:v});} + else {if(Object.keys(at).length)throw Error('非法属性');if(tag==='step'||tag==='check')(result[tag==='step'?'steps':'checks'] as string[]).push(v);else{if(tag in result)throw Error('重复元素');result[tag]=v;}}s=s.slice(m[0].length);} + return documentInput.parse(result); +} diff --git a/apps/waterfall-wiki/src/lib/guidance-store.ts b/apps/waterfall-wiki/src/lib/guidance-store.ts new file mode 100644 index 0000000..a792f6d --- /dev/null +++ b/apps/waterfall-wiki/src/lib/guidance-store.ts @@ -0,0 +1,15 @@ +import { db,transaction } from './db'; +import { documentInput } from './guidance-input'; +import { findGuide,type Guidance } from '../content/guides/catalog'; +export async function resolveDocument(id:string,revision?:number):Promise{ + if(!id.startsWith('user-'))return findGuide(id,revision)||null; + const r=await db().query(`SELECT r.content FROM guidance_documents d JOIN guidance_revisions r ON r.document_id=d.id AND r.revision=COALESCE($2,d.latest) WHERE d.id=$1 AND ($2::integer IS NOT NULL OR d.deleted_at IS NULL)`,[id,revision??null]);return r.rows[0]?.content||null; +} +export async function publishDocument(owner:string,value:unknown){const content=documentInput.parse(value);return transaction(async c=>{ + await c.query('INSERT INTO guidance_documents(id,owner_id) VALUES($1,$2) ON CONFLICT DO NOTHING',[content.id,owner]); + const d=(await c.query('SELECT * FROM guidance_documents WHERE id=$1 FOR UPDATE',[content.id])).rows[0]; + if(d.owner_id!==owner)throw Error('FORBIDDEN');if(d.deleted_at||content.revision!==d.latest+1)throw Error('CONFLICT'); + await c.query('INSERT INTO guidance_revisions(document_id,revision,content) VALUES($1,$2,$3)',[content.id,content.revision,content]);await c.query('UPDATE guidance_documents SET latest=$2 WHERE id=$1',[content.id,content.revision]);return content; + });} +export async function withdrawDocument(owner:string,id:string){const r=await db().query('UPDATE guidance_documents SET deleted_at=COALESCE(deleted_at,now()) WHERE id=$1 AND owner_id=$2 RETURNING id',[id,owner]);return !!r.rowCount;} +export async function discoverDocuments(){const r=await db().query(`SELECT d.id,d.latest AS revision,r.content->>'title' AS title,r.content->>'summary' AS summary,r.content->>'kind' AS kind,d.owner_id AS author FROM guidance_documents d JOIN guidance_revisions r ON r.document_id=d.id AND r.revision=d.latest WHERE d.deleted_at IS NULL ORDER BY r.created_at DESC LIMIT 100`);return r.rows;} diff --git a/apps/waterfall-wiki/src/pages/api/documents/[[...path]].ts b/apps/waterfall-wiki/src/pages/api/documents/[[...path]].ts new file mode 100644 index 0000000..4dcb7f1 --- /dev/null +++ b/apps/waterfall-wiki/src/pages/api/documents/[[...path]].ts @@ -0,0 +1,26 @@ +import type { NextApiRequest,NextApiResponse } from 'next'; +import { currentAccount } from '@/lib/account'; +import { trustedOrigin } from '@/lib/account-crypto'; +import { documentInput,parseDocumentXml } from '@/lib/guidance-input'; +import { discoverDocuments,publishDocument,resolveDocument,withdrawDocument } from '@/lib/guidance-store'; +import { guidanceXml } from '@/content/guides/catalog'; +export default async function handler(req:NextApiRequest,res:NextApiResponse){ + res.setHeader('Cache-Control','no-store');const path=req.query.path||[];if(!Array.isArray(path)||path.length>1)return res.status(404).end();const id=path[0]; + try{ + if(req.method==='GET'){ + if(!id)return res.json({documents:await discoverDocuments()}); + const revision=req.query.revision;if(revision!==undefined&&(typeof revision!=='string'||! /^[1-9][0-9]{0,9}$/.test(revision)||Number(revision)>2147483647))return res.status(400).json({error:'版本无效'}); + const doc=await resolveDocument(id,revision===undefined?undefined:Number(revision));if(!doc)return res.status(404).json({error:'文档或精确版本不存在'}); + if(req.query.format==='xml'){res.setHeader('Content-Type','application/xml; charset=utf-8');return res.send(guidanceXml(doc));}return res.json({document:doc});} + if(!['POST','DELETE'].includes(req.method||'')){res.setHeader('Allow','GET, POST, DELETE');return res.status(405).end();} + const request=new Request(new URL(req.url||'/',process.env.ACCOUNT_PUBLIC_ORIGIN||'http://'+req.headers.host),{headers:new Headers(Object.entries(req.headers).filter(([,v])=>typeof v==='string') as [string,string][])}); + if(!trustedOrigin(request))return res.status(403).json({error:'请求来源不受信任'}); + const user=await currentAccount(request);if(!user)return res.status(401).json({error:'请先登录已验证邮箱账户'}); + if(req.method==='DELETE')return id&&await withdrawDocument(user.id,id)?res.json({ok:true}):res.status(404).json({error:'无权删除或不存在'}); + const input=typeof req.body==='string'?parseDocumentXml(req.body):documentInput.parse(req.body); + if(id==='preview'){const links=await Promise.all(input.links.map(async l=>({...l,resolved:!!await resolveDocument(l.target,l.revision)})));return res.json({document:input,xml:guidanceXml(input),links});} + if(id&&id!==input.id)return res.status(400).json({error:'文档 ID 不匹配'}); + return res.status(201).json({document:await publishDocument(user.id,input)}); + }catch(e){const message=e instanceof Error?e.message:'';if(message==='FORBIDDEN')return res.status(403).json({error:'仅作者可编辑'});if(message==='CONFLICT'||(e as {code?:string}).code==='23505')return res.status(409).json({error:'版本冲突:请加载最新版本,已发布版本不可修改'});if(e instanceof Error&&(e.name==='ZodError'||/XML|实体|元素|属性|协议|链接/.test(message)))return res.status(400).json({error:'输入不符合协议'});return res.status(503).json({error:'文档服务暂不可用'});} +} +export const config={api:{bodyParser:{sizeLimit:'128kb'}}}; diff --git a/apps/waterfall-wiki/src/pages/documents/[id].tsx b/apps/waterfall-wiki/src/pages/documents/[id].tsx new file mode 100644 index 0000000..76c38cb --- /dev/null +++ b/apps/waterfall-wiki/src/pages/documents/[id].tsx @@ -0,0 +1,6 @@ +import type { GetServerSideProps } from 'next'; +import type { Guidance } from '@/content/guides/catalog'; +import styles from '@/styles/documents.module.css'; +type Props={doc:Guidance;links:(Guidance['links'][number]&{resolved:boolean})[]}; +export default function Read({doc,links}:Props){return

{doc.title}

{doc.kind} · {doc.id} · 固定版本 {doc.revision}

{doc.summary}

适用条件

{doc.applies}

{doc.body}

    {doc.steps.map((s,i)=>
  1. {s}
  2. )}

条件链接

{links.map((l,i)=>)}

检查项

    {doc.checks.map((s,i)=>
  • {s}
  • )}
读取规范 XML

;} +export const getServerSideProps:GetServerSideProps=async({params,query,res})=>{res.setHeader('Cache-Control','no-store');const revision=query.revision;if(revision!==undefined&&(typeof revision!=='string'||! /^[1-9][0-9]{0,9}$/.test(revision)||Number(revision)>2147483647))return {notFound:true};const {resolveDocument}=await import('@/lib/guidance-store');const doc=await resolveDocument(String(params?.id),revision===undefined?undefined:Number(revision));if(!doc)return {notFound:true};const links=await Promise.all(doc.links.map(async l=>({...l,resolved:!!await resolveDocument(l.target,l.revision)})));return {props:{doc,links}};}; diff --git a/apps/waterfall-wiki/src/pages/documents/index.tsx b/apps/waterfall-wiki/src/pages/documents/index.tsx new file mode 100644 index 0000000..53e3008 --- /dev/null +++ b/apps/waterfall-wiki/src/pages/documents/index.tsx @@ -0,0 +1,3 @@ +import { useEffect,useState } from 'react'; +import styles from '@/styles/documents.module.css'; +export default function Documents(){const [docs,setDocs]=useState<{id:string;revision:number;title:string;summary:string;kind:string}[]>([]),[error,setError]=useState('');useEffect(()=>{fetch('/api/documents').then(async r=>{const b=await r.json();if(!r.ok)throw Error(b.error);setDocs(b.documents);}).catch(e=>setError(e.message));},[]);return

用户发布的文档

{error}

{docs.map(d=>

{d.title}

{d.kind} · v{d.revision}

{d.summary}

)}
;} diff --git a/apps/waterfall-wiki/src/pages/write.tsx b/apps/waterfall-wiki/src/pages/write.tsx new file mode 100644 index 0000000..49668f5 --- /dev/null +++ b/apps/waterfall-wiki/src/pages/write.tsx @@ -0,0 +1,10 @@ +import { useState } from 'react'; +import type { Guidance } from '@/content/guides/catalog'; +import styles from '@/styles/documents.module.css'; +const blank:Guidance={id:'user-',revision:1,kind:'guide',title:'',summary:'',applies:'',body:'',steps:[],checks:[],links:[],tags:[]}; +export default function Write(){ + const [doc,setDoc]=useState(blank),[xml,setXml]=useState(''),[mode,setMode]=useState(false),[message,setMessage]=useState(''),[preview,setPreview]=useState<{document:Guidance;xml:string;links:{label:string;when:string;resolved:boolean}[]}|null>(null); + async function load(){const r=await fetch('/api/documents/'+encodeURIComponent(doc.id));const b=await r.json();if(r.ok){setDoc({...b.document,revision:b.document.revision+1});setPreview(null);setMessage('已加载,保存将创建新版本');}else setMessage(b.error);} + async function submit(action:string){setMessage('处理中…');const r=await fetch('/api/documents'+(action==='preview'?'/preview':''),{method:'POST',headers:{'Content-Type':mode?'application/xml':'application/json'},body:mode?xml:JSON.stringify(doc)});const b=await r.json();if(!r.ok)return setMessage(b.error);if(action==='preview'){setPreview(b);setMessage('预览成功,尚未发布');}else{setDoc({...b.document,revision:b.document.revision+1});setMessage('已发布版本 '+b.document.revision);setPreview(null);}} + return

发布独立文档

需要已验证邮箱。每次发布创建不可变版本;只有原作者可以更新或撤下。

{mode?