feat: complete v0.5 operations foundation
This commit is contained in:
@@ -81,6 +81,13 @@ CREATE UNIQUE INDEX IF NOT EXISTS source_remote_identity_unique ON sources(base_
|
||||
CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||
version INTEGER PRIMARY KEY, applied_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS rate_limits (
|
||||
bucket TEXT PRIMARY KEY, count INTEGER NOT NULL, reset_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS error_events (
|
||||
id INTEGER PRIMARY KEY, scope TEXT NOT NULL, message TEXT NOT NULL, context_json TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
`);
|
||||
|
||||
db.exec("INSERT OR IGNORE INTO source_members(source_id,user_id,role) SELECT id,user_id,'owner' FROM sources");
|
||||
@@ -108,6 +115,8 @@ applyColumnMigration(14, "sources", "remote_avatar_url", "ALTER TABLE sources AD
|
||||
applyColumnMigration(15, "sources", "last_connection_at", "ALTER TABLE sources ADD COLUMN last_connection_at TEXT");
|
||||
applyColumnMigration(16, "sources", "last_connection_error", "ALTER TABLE sources ADD COLUMN last_connection_error TEXT");
|
||||
applyColumnMigration(17, "posts", "remote_url", "ALTER TABLE posts ADD COLUMN remote_url TEXT");
|
||||
db.prepare("INSERT OR IGNORE INTO schema_migrations(version) VALUES(18)").run();
|
||||
db.prepare("INSERT OR IGNORE INTO schema_migrations(version) VALUES(19)").run();
|
||||
|
||||
const admin = process.env.ADMIN_USERNAME;
|
||||
const adminPassword = process.env.ADMIN_PASSWORD;
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import { db } from "@/lib/db";
|
||||
|
||||
export function logEvent(level: "info" | "warn" | "error", event: string, fields: Record<string, unknown> = {}) {
|
||||
console[level](JSON.stringify({ timestamp: new Date().toISOString(), level, event, ...fields }));
|
||||
}
|
||||
|
||||
export function recordError(scope: string, error: unknown, context: Record<string, unknown> = {}) {
|
||||
const message = error instanceof Error ? error.message : "Unknown error";
|
||||
db.prepare("INSERT INTO error_events(scope,message,context_json) VALUES(?,?,?)").run(scope, message.slice(0, 1000), JSON.stringify(context));
|
||||
logEvent("error", "application_error", { scope, message, ...context });
|
||||
}
|
||||
+17
-6
@@ -1,8 +1,19 @@
|
||||
const visits = new Map<string, { count: number; resetAt: number }>();
|
||||
import { db } from "@/lib/db";
|
||||
|
||||
export function withinRateLimit(key: string, limit = 30, windowMs = 60_000) {
|
||||
const now = Date.now(); const record = visits.get(key);
|
||||
if (!record || record.resetAt <= now) { visits.set(key, { count: 1, resetAt: now + windowMs }); return true; }
|
||||
if (record.count >= limit) return false;
|
||||
record.count += 1; return true;
|
||||
/** SQLite-backed fixed-window limiter shared by every Web container using this database. */
|
||||
export function withinRateLimit(bucket: string, limit = 30, windowMs = 60_000) {
|
||||
const now = Date.now();
|
||||
const transaction = db.transaction(() => {
|
||||
const found = db.prepare("SELECT count,reset_at FROM rate_limits WHERE bucket=?").get(bucket) as { count: number; reset_at: number } | undefined;
|
||||
if (!found || found.reset_at <= now) {
|
||||
db.prepare("INSERT INTO rate_limits(bucket,count,reset_at) VALUES(?,?,?) ON CONFLICT(bucket) DO UPDATE SET count=excluded.count,reset_at=excluded.reset_at").run(bucket, 1, now + windowMs);
|
||||
return true;
|
||||
}
|
||||
if (found.count >= limit) return false;
|
||||
db.prepare("UPDATE rate_limits SET count=count+1 WHERE bucket=?").run(bucket);
|
||||
return true;
|
||||
});
|
||||
const allowed = transaction();
|
||||
if (Math.random() < 0.01) db.prepare("DELETE FROM rate_limits WHERE reset_at<?").run(now);
|
||||
return allowed;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
function requestOrigin(request: Request) {
|
||||
const proto = request.headers.get("x-forwarded-proto") || new URL(request.url).protocol.replace(":", "");
|
||||
const host = request.headers.get("x-forwarded-host") || request.headers.get("host") || new URL(request.url).host;
|
||||
return `${proto}://${host}`;
|
||||
}
|
||||
|
||||
/** Browser form POSTs and fetch requests must originate from this Hub. */
|
||||
export function requireSameOrigin(request: Request) {
|
||||
const origin = request.headers.get("origin");
|
||||
if (!origin || origin !== requestOrigin(request)) throw new Error("Invalid request origin");
|
||||
}
|
||||
|
||||
export function clientIp(request: Request) {
|
||||
return request.headers.get("x-forwarded-for")?.split(",")[0].trim() || request.headers.get("x-real-ip") || "unknown";
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { extname } from "node:path";
|
||||
import { logEvent } from "@/lib/observability";
|
||||
|
||||
const defaults = new Set(["image/jpeg", "image/png", "image/gif", "image/webp", "application/pdf", "text/plain", "text/markdown"]);
|
||||
|
||||
export async function validateUpload(file: File) {
|
||||
const allowed = new Set((process.env.UPLOAD_ALLOWED_TYPES || "").split(",").map((item) => item.trim()).filter(Boolean));
|
||||
const types = allowed.size ? allowed : defaults;
|
||||
const max = Number(process.env.UPLOAD_MAX_BYTES || 10 * 1024 * 1024);
|
||||
if (!types.has(file.type)) throw new Error(`不允許的附件類型:${file.type || extname(file.name) || "未知"}`);
|
||||
if (file.size > max) throw new Error(`${file.name} exceeds upload limit`);
|
||||
const scanner = process.env.VIRUS_SCAN_URL;
|
||||
if (!scanner) return;
|
||||
try {
|
||||
const response = await fetch(scanner, { method: "POST", headers: { "content-type": file.type || "application/octet-stream", "x-filename": encodeURIComponent(file.name) }, body: await file.arrayBuffer(), signal: AbortSignal.timeout(15_000) });
|
||||
const result = await response.json().catch(() => ({})) as { clean?: boolean };
|
||||
if (!response.ok || result.clean !== true) throw new Error("附件未通過掃描");
|
||||
} catch (error) {
|
||||
logEvent("warn", "upload_scan_unavailable", { name: file.name });
|
||||
if (process.env.VIRUS_SCAN_REQUIRED === "1") throw error;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user