feat: complete v0.2 source and sync management

This commit is contained in:
2026-07-19 01:33:45 +08:00
parent 4e9a13981d
commit 5b5129fad5
21 changed files with 367 additions and 39 deletions
+18
View File
@@ -0,0 +1,18 @@
import bcrypt from "bcryptjs";
import { NextResponse } from "next/server";
import { requireUser } from "@/lib/auth";
import { db } from "@/lib/db";
import { externalUrl } from "@/lib/http";
export async function POST(req: Request) {
try {
const user = await requireUser(); const form = await req.formData();
const currentPassword = String(form.get("currentPassword") || ""); const newPassword = String(form.get("newPassword") || ""); const confirmPassword = String(form.get("confirmPassword") || "");
if (newPassword.length < 10) throw new Error("新密碼至少需要 10 個字元");
if (newPassword !== confirmPassword) throw new Error("兩次新密碼不一致");
const account = db.prepare("SELECT password_hash FROM users WHERE id=? AND disabled=0").get(user.id) as { password_hash: string } | undefined;
if (!account || !(await bcrypt.compare(currentPassword, account.password_hash))) throw new Error("目前密碼不正確");
db.prepare("UPDATE users SET password_hash=? WHERE id=?").run(await bcrypt.hash(newPassword, 12), user.id);
return NextResponse.redirect(externalUrl(req, "/account?updated=1"));
} catch (error) { return NextResponse.redirect(externalUrl(req, "/account?error=" + encodeURIComponent(error instanceof Error ? error.message : "password"))); }
}
+1 -1
View File
@@ -1,2 +1,2 @@
import { NextResponse } from "next/server"; import { requireUser } from "@/lib/auth"; import { db } from "@/lib/db"; import { externalUrl } from "@/lib/http"; import { mkdir, writeFile } from "node:fs/promises"; import { randomUUID } from "node:crypto"; import { extname, join } from "node:path";
export async function POST(req:Request){const json=req.headers.get("accept")?.includes("application/json");try{const user=await requireUser();const f=await req.formData();const content=String(f.get("content")||"").trim();const visibility=String(f.get("visibility")||"PUBLIC");const sourceId=Number(f.get("sourceId"));const tags=String(f.get("tags")||"").split(/\s*,\s*/).filter(Boolean).map(t=>t.replace(/^#/,""));if(!content||!['PRIVATE','PROTECTED','PUBLIC'].includes(visibility)||!sourceId)throw new Error("Invalid post");const source=db.prepare("SELECT s.id FROM sources s JOIN source_members sm ON sm.source_id=s.id WHERE s.id=? AND sm.user_id=?").get(sourceId,user.id);if(!source)throw new Error("Source not found");const max=Number(process.env.UPLOAD_MAX_BYTES||10485760);const files=f.getAll('attachments').filter((x):x is File=>x instanceof File&&x.size>0);const attachments:any[]=[];await mkdir(join(process.cwd(),'public','uploads'),{recursive:true});for(const file of files){if(file.size>max)throw new Error(`${file.name} exceeds upload limit`);const id=randomUUID()+extname(file.name);await writeFile(join(process.cwd(),'public','uploads',id),Buffer.from(await file.arrayBuffer()));attachments.push({name:file.name,url:`/uploads/${id}`,type:file.type,size:file.size});}const out=db.prepare("INSERT INTO posts(source_id,author_id,content,visibility,tags_json,attachments_json,origin,sync_status) VALUES(?,?,?,?,?,?,'hub','queued')").run(sourceId,user.id,content,visibility,JSON.stringify(tags),JSON.stringify(attachments));db.prepare("INSERT INTO sync_jobs(source_id,kind,payload_json) VALUES(?, 'push', ?)").run(sourceId,JSON.stringify({postId:out.lastInsertRowid}));if(json)return NextResponse.json({id:Number(out.lastInsertRowid)},{status:201});return NextResponse.redirect(externalUrl(req,`/posts/${out.lastInsertRowid}`));}catch(e){const message=e instanceof Error?e.message:'post';if(json)return NextResponse.json({error:message},{status:400});return NextResponse.redirect(externalUrl(req,'/dashboard?error='+encodeURIComponent(message)));}}
export async function POST(req:Request){const json=req.headers.get("accept")?.includes("application/json");try{const user=await requireUser();const f=await req.formData();const content=String(f.get("content")||"").trim();const visibility=String(f.get("visibility")||"PUBLIC");const sourceId=Number(f.get("sourceId"));const tags=String(f.get("tags")||"").split(/\s*,\s*/).filter(Boolean).map(t=>t.replace(/^#/,""));if(!content||!['PRIVATE','PROTECTED','PUBLIC'].includes(visibility)||!sourceId)throw new Error("Invalid post");const source=db.prepare("SELECT s.id FROM sources s JOIN source_members sm ON sm.source_id=s.id WHERE s.id=? AND sm.user_id=? AND s.is_enabled=1").get(sourceId,user.id);if(!source)throw new Error("Source not available");const max=Number(process.env.UPLOAD_MAX_BYTES||10485760);const files=f.getAll('attachments').filter((x):x is File=>x instanceof File&&x.size>0);const attachments:any[]=[];await mkdir(join(process.cwd(),'public','uploads'),{recursive:true});for(const file of files){if(file.size>max)throw new Error(`${file.name} exceeds upload limit`);const id=randomUUID()+extname(file.name);await writeFile(join(process.cwd(),'public','uploads',id),Buffer.from(await file.arrayBuffer()));attachments.push({name:file.name,url:`/uploads/${id}`,type:file.type,size:file.size});}const out=db.prepare("INSERT INTO posts(source_id,author_id,content,visibility,tags_json,attachments_json,origin,sync_status) VALUES(?,?,?,?,?,?,'hub','queued')").run(sourceId,user.id,content,visibility,JSON.stringify(tags),JSON.stringify(attachments));db.prepare("INSERT INTO sync_jobs(source_id,kind,payload_json,trigger) VALUES(?, 'push', ?, 'manual')").run(sourceId,JSON.stringify({postId:out.lastInsertRowid}));if(json)return NextResponse.json({id:Number(out.lastInsertRowid)},{status:201});return NextResponse.redirect(externalUrl(req,`/posts/${out.lastInsertRowid}`));}catch(e){const message=e instanceof Error?e.message:'post';if(json)return NextResponse.json({error:message},{status:400});return NextResponse.redirect(externalUrl(req,'/dashboard?error='+encodeURIComponent(message)));}}
+37
View File
@@ -0,0 +1,37 @@
import { NextResponse } from "next/server";
import { requireUser } from "@/lib/auth";
import { db } from "@/lib/db";
import { externalUrl } from "@/lib/http";
import { queuePull } from "@/lib/sync";
export async function POST(req: Request, { params }: { params: Promise<{ id: string }> }) {
try {
const user = await requireUser(); const { id: rawId } = await params; const id = Number(rawId); const form = await req.formData(); const action = String(form.get("action") || "");
const source = db.prepare("SELECT id,user_id FROM sources WHERE id=?").get(id) as { id: number; user_id: number } | undefined;
const member = db.prepare("SELECT role FROM source_members WHERE source_id=? AND user_id=?").get(id, user.id);
if (!source || !member) throw new Error("Source not found");
const owner = source.user_id === user.id;
if (action === "rename") {
const name = String(form.get("name") || "").trim(); if (!owner || !name || name.length > 80) throw new Error("Only the owner can rename a source");
db.prepare("UPDATE sources SET name=? WHERE id=?").run(name, id);
} else if (action === "set-enabled") {
if (!owner) throw new Error("Only the owner can change source status"); const enabled = String(form.get("enabled")) === "1";
db.prepare("UPDATE sources SET is_enabled=?,disabled_at=CASE WHEN ? THEN NULL ELSE CURRENT_TIMESTAMP END,sync_status=CASE WHEN ? THEN 'pending' ELSE 'disabled' END WHERE id=?").run(enabled ? 1 : 0, enabled ? 1 : 0, enabled ? 1 : 0, id);
if (enabled) queuePull(id, "manual");
} else if (action === "leave") {
if (owner) throw new Error("Transfer ownership or delete the source before leaving");
db.prepare("DELETE FROM source_members WHERE source_id=? AND user_id=?").run(id, user.id);
} else if (action === "transfer") {
if (!owner) throw new Error("Only the owner can transfer ownership"); const username = String(form.get("username") || "").trim();
const target = db.prepare("SELECT u.id FROM users u JOIN source_members sm ON sm.user_id=u.id WHERE sm.source_id=? AND u.username=?").get(id, username) as { id: number } | undefined;
if (!target || target.id === user.id) throw new Error("Choose another existing member");
const transfer = db.transaction(() => { db.prepare("UPDATE sources SET user_id=? WHERE id=?").run(target.id, id); db.prepare("UPDATE source_members SET role='member' WHERE source_id=? AND user_id=?").run(id, user.id); db.prepare("UPDATE source_members SET role='owner' WHERE source_id=? AND user_id=?").run(id, target.id); });
transfer();
} else if (action === "delete") {
if (!owner) throw new Error("Only the owner can delete a source");
const remove = db.transaction(() => { db.prepare("DELETE FROM posts WHERE source_id=? AND origin='memos'").run(id); db.prepare("UPDATE posts SET source_id=NULL WHERE source_id=? AND origin='hub'").run(id); db.prepare("DELETE FROM sources WHERE id=?").run(id); });
remove();
} else throw new Error("Unknown source action");
return NextResponse.redirect(externalUrl(req, "/dashboard?source=updated"));
} catch (error) { return NextResponse.redirect(externalUrl(req, "/dashboard?error=" + encodeURIComponent(error instanceof Error ? error.message : "source"))); }
}
+29 -8
View File
@@ -1,8 +1,29 @@
import { NextResponse } from "next/server"; import { requireUser } from "@/lib/auth"; import { decrypt, encrypt } from "@/lib/crypto"; import { getMemosIdentity, verifyMemos } from "@/lib/memos"; import { db } from "@/lib/db"; import { externalUrl } from "@/lib/http";
export async function POST(req: Request) { try { const user=await requireUser(); const form=await req.formData(); const name=String(form.get("name")||"").trim(); const rawBaseUrl=String(form.get("baseUrl")||"").trim(); const token=String(form.get("token")||"").trim(); let baseUrl=""; try { const url=new URL(rawBaseUrl); if(!['http:','https:'].includes(url.protocol)) throw new Error(); baseUrl=`${url.origin}${url.pathname.replace(/\/+$/,"")}`; } catch { throw new Error("Invalid source URL"); } if(!name||token.length<20) throw new Error("Invalid source"); await verifyMemos(baseUrl,token); const identity=await getMemosIdentity(baseUrl,token);
const legacySources=db.prepare("SELECT id,token_encrypted FROM sources WHERE base_url=? AND remote_user IS NULL").all(baseUrl) as {id:number;token_encrypted:string}[];
for(const legacy of legacySources){try{const legacyIdentity=await getMemosIdentity(baseUrl,decrypt(legacy.token_encrypted));db.prepare("UPDATE sources SET remote_user=? WHERE id=? AND remote_user IS NULL").run(legacyIdentity.name,legacy.id);}catch{ /* Keep unavailable legacy sources unchanged. */ }}
const shared=db.prepare("SELECT id FROM sources WHERE base_url=? AND remote_user=?").get(baseUrl,identity.name) as {id:number}|undefined;
if(shared){db.prepare("INSERT OR IGNORE INTO source_members(source_id,user_id) VALUES(?,?)").run(shared.id,user.id);return NextResponse.redirect(externalUrl(req,"/dashboard?source=shared"));}
const out=db.prepare("INSERT INTO sources(user_id,name,base_url,token_encrypted,remote_user,sync_status) VALUES(?,?,?,?,?, 'queued')").run(user.id,name,baseUrl,encrypt(token),identity.name);const sourceId=Number(out.lastInsertRowid);db.prepare("INSERT INTO source_members(source_id,user_id,role) VALUES(?,?,'owner')").run(sourceId,user.id);db.prepare("INSERT INTO sync_jobs(source_id,kind) VALUES(?, 'pull')").run(sourceId);return NextResponse.redirect(externalUrl(req,"/dashboard?source=connected"));
} catch(e){ return NextResponse.redirect(externalUrl(req,"/dashboard?error="+encodeURIComponent(e instanceof Error?e.message:"source"))); } }
import { NextResponse } from "next/server";
import { requireUser } from "@/lib/auth";
import { decrypt, encrypt } from "@/lib/crypto";
import { db } from "@/lib/db";
import { externalUrl } from "@/lib/http";
import { getMemosIdentity, verifyMemos } from "@/lib/memos";
import { queuePull } from "@/lib/sync";
export async function POST(req: Request) {
try {
const user = await requireUser(); const form = await req.formData();
const name = String(form.get("name") || "").trim(); const rawBaseUrl = String(form.get("baseUrl") || "").trim(); const token = String(form.get("token") || "").trim();
let baseUrl = "";
try { const url = new URL(rawBaseUrl); if (!['http:', 'https:'].includes(url.protocol)) throw new Error(); baseUrl = `${url.origin}${url.pathname.replace(/\/+$/, "")}`; } catch { throw new Error("Invalid source URL"); }
if (!name || token.length < 20) throw new Error("Invalid source");
await verifyMemos(baseUrl, token); const identity = await getMemosIdentity(baseUrl, token);
const legacySources = db.prepare("SELECT id,token_encrypted FROM sources WHERE base_url=? AND remote_user IS NULL").all(baseUrl) as { id: number; token_encrypted: string }[];
for (const legacy of legacySources) {
try { const legacyIdentity = await getMemosIdentity(baseUrl, decrypt(legacy.token_encrypted)); db.prepare("UPDATE sources SET remote_user=? WHERE id=? AND remote_user IS NULL").run(legacyIdentity.name, legacy.id); } catch { /* Retry on a future connection. */ }
}
const shared = db.prepare("SELECT id FROM sources WHERE base_url=? AND remote_user=?").get(baseUrl, identity.name) as { id: number } | undefined;
if (shared) { db.prepare("INSERT OR IGNORE INTO source_members(source_id,user_id) VALUES(?,?)").run(shared.id, user.id); return NextResponse.redirect(externalUrl(req, "/dashboard?source=shared")); }
const out = db.prepare("INSERT INTO sources(user_id,name,base_url,token_encrypted,remote_user,sync_status) VALUES(?,?,?,?,?, 'queued')").run(user.id, name, baseUrl, encrypt(token), identity.name);
const sourceId = Number(out.lastInsertRowid);
db.prepare("INSERT INTO source_members(source_id,user_id,role) VALUES(?,?,'owner')").run(sourceId, user.id);
queuePull(sourceId, "source-created");
return NextResponse.redirect(externalUrl(req, "/dashboard?source=connected"));
} catch (error) { return NextResponse.redirect(externalUrl(req, "/dashboard?error=" + encodeURIComponent(error instanceof Error ? error.message : "source"))); }
}
+15 -2
View File
@@ -1,2 +1,15 @@
import { NextResponse } from "next/server"; import { requireUser } from "@/lib/auth"; import { db } from "@/lib/db"; import { externalUrl } from "@/lib/http";
export async function POST(req:Request){try{const user=await requireUser();const f=await req.formData();const sourceId=Number(f.get('sourceId'));const source=db.prepare('SELECT s.id FROM sources s JOIN source_members sm ON sm.source_id=s.id WHERE s.id=? AND sm.user_id=?').get(sourceId,user.id);if(!source)throw 0;db.prepare("INSERT INTO sync_jobs(source_id,kind) VALUES(?, 'pull')").run(sourceId);return NextResponse.redirect(externalUrl(req,'/dashboard'));}catch{return NextResponse.redirect(externalUrl(req,'/'));}}
import { NextResponse } from "next/server";
import { requireUser } from "@/lib/auth";
import { db } from "@/lib/db";
import { externalUrl } from "@/lib/http";
import { queuePull } from "@/lib/sync";
export async function POST(req: Request) {
try {
const user = await requireUser(); const form = await req.formData(); const sourceId = Number(form.get("sourceId"));
const source = db.prepare("SELECT s.id FROM sources s JOIN source_members sm ON sm.source_id=s.id WHERE s.id=? AND sm.user_id=? AND s.is_enabled=1").get(sourceId, user.id);
if (!source) throw new Error("Source not available");
const created = queuePull(sourceId, "manual");
return NextResponse.redirect(externalUrl(req, `/dashboard?sync=${created ? "queued" : "already-queued"}`));
} catch (error) { return NextResponse.redirect(externalUrl(req, "/dashboard?error=" + encodeURIComponent(error instanceof Error ? error.message : "sync"))); }
}
@@ -2,17 +2,18 @@ import { NextResponse } from "next/server";
import { db } from "@/lib/db";
import { withinRateLimit } from "@/lib/rate-limit";
import { webhookSecretMatches } from "@/lib/webhook";
import { queuePull } from "@/lib/sync";
export async function POST(request: Request, { params }: { params: Promise<{ sourceId: string; secret: string }> }) {
const { sourceId, secret } = await params;
const id = Number(sourceId);
const source = db.prepare("SELECT id, webhook_secret_hash FROM sources WHERE id=?").get(id) as { id: number; webhook_secret_hash: string | null } | undefined;
const source = db.prepare("SELECT id, webhook_secret_hash FROM sources WHERE id=? AND is_enabled=1").get(id) as { id: number; webhook_secret_hash: string | null } | undefined;
if (!source || !webhookSecretMatches(secret, source.webhook_secret_hash)) return NextResponse.json({ error: "Not found" }, { status: 404 });
const forwarded = request.headers.get("x-forwarded-for")?.split(",")[0].trim() || "unknown";
if (!withinRateLimit(`webhook:${id}:${forwarded}`)) return NextResponse.json({ error: "Too many requests" }, { status: 429 });
let payload: unknown = {};
try { payload = await request.json(); } catch { /* Memos payload is optional; a pull reconciles source state. */ }
db.prepare("UPDATE sources SET last_webhook_at=CURRENT_TIMESTAMP WHERE id=?").run(id);
db.prepare("INSERT INTO sync_jobs(source_id,kind,payload_json) VALUES(?, 'pull', ?)").run(id, JSON.stringify(payload));
return NextResponse.json({ ok: true });
const queued = queuePull(id, "webhook", payload);
return NextResponse.json({ ok: true, queued });
}