updates
This commit is contained in:
+133
@@ -0,0 +1,133 @@
|
||||
import { mkdir } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { config } from "./config.ts";
|
||||
|
||||
// --- local file CDN -----------------------------------------------
|
||||
// Generic, future-proof layout:
|
||||
//
|
||||
// PUT /cdn/<ns>/<key> (auth) -> { url: "/cdn/<ns>/<key>.<ext>" }
|
||||
// GET /cdn/<ns>/<key>.<ext> (public)
|
||||
//
|
||||
// <ns> is a registered namespace (see CDN_NAMESPACES — `avatar` today,
|
||||
// more tomorrow). <key> is namespaced client-chosen id ([A-Za-z0-9_-]).
|
||||
// The stored extension ALWAYS comes from magic-byte detection, never from
|
||||
// the client. Everything else is systematically blacklisted.
|
||||
|
||||
export type ImageExt = "jpg" | "png" | "webp";
|
||||
|
||||
export interface CdnNamespace {
|
||||
maxBytes: number;
|
||||
windowMs: number;
|
||||
maxUploads: number;
|
||||
}
|
||||
|
||||
export const CDN_NAMESPACES: Record<string, CdnNamespace> = {
|
||||
// Avatar rules (spec): 5 MB max, 5 changes max per 2 h per account.
|
||||
avatar: { maxBytes: 5 * 1024 * 1024, windowMs: 2 * 3600_000, maxUploads: 5 },
|
||||
};
|
||||
|
||||
export const CDN_EXTS: ImageExt[] = ["jpg", "png", "webp"];
|
||||
|
||||
const CDN_MIME: Record<ImageExt, string> = {
|
||||
jpg: "image/jpeg",
|
||||
png: "image/png",
|
||||
webp: "image/webp",
|
||||
};
|
||||
|
||||
export function cdnMime(ext: ImageExt): string {
|
||||
return CDN_MIME[ext];
|
||||
}
|
||||
|
||||
export function validNamespace(ns: string): boolean {
|
||||
return /^[a-z0-9]{1,16}$/.test(ns) && ns in CDN_NAMESPACES;
|
||||
}
|
||||
|
||||
export function validKey(key: string): boolean {
|
||||
return /^[A-Za-z0-9_-]{1,64}$/.test(key);
|
||||
}
|
||||
|
||||
export function validExt(ext: string): ext is ImageExt {
|
||||
return (CDN_EXTS as string[]).includes(ext);
|
||||
}
|
||||
|
||||
function cdnDir(): string {
|
||||
return config.cdnDir;
|
||||
}
|
||||
|
||||
export function cdnPath(ns: string, key: string, ext: ImageExt): string {
|
||||
return join(cdnDir(), ns, `${key}.${ext}`);
|
||||
}
|
||||
|
||||
export async function cdnFile(ns: string, key: string, ext: ImageExt): Promise<{ path: string; exists: boolean }> {
|
||||
const path = cdnPath(ns, key, ext);
|
||||
return { path, exists: await Bun.file(path).exists() };
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect the real image type from magic bytes (first bytes of the file).
|
||||
* Extension alone is NEVER trusted.
|
||||
* JPEG: FF D8 FF
|
||||
* PNG: 89 50 4E 47 0D 0A 1A 0A
|
||||
* WEBP: "RIFF"...."WEBP" (bytes 0-3 and 8-11)
|
||||
*/
|
||||
export function detectImageType(buf: Uint8Array): ImageExt | null {
|
||||
if (buf.length >= 3 && buf[0] === 0xff && buf[1] === 0xd8 && buf[2] === 0xff) return "jpg";
|
||||
if (
|
||||
buf.length >= 8 &&
|
||||
buf[0] === 0x89 && buf[1] === 0x50 && buf[2] === 0x4e && buf[3] === 0x47 &&
|
||||
buf[4] === 0x0d && buf[5] === 0x0a && buf[6] === 0x1a && buf[7] === 0x0a
|
||||
) {
|
||||
return "png";
|
||||
}
|
||||
if (
|
||||
buf.length >= 12 &&
|
||||
buf[0] === 0x52 && buf[1] === 0x49 && buf[2] === 0x46 && buf[3] === 0x46 && // RIFF
|
||||
buf[8] === 0x57 && buf[9] === 0x45 && buf[10] === 0x42 && buf[11] === 0x50 // WEBP
|
||||
) {
|
||||
return "webp";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Remove every stored variant for a key (used before replacing an avatar). */
|
||||
export async function removeCdnVariants(ns: string, key: string): Promise<void> {
|
||||
const { unlink } = await import("node:fs/promises");
|
||||
for (const ext of CDN_EXTS) {
|
||||
try {
|
||||
await unlink(cdnPath(ns, key, ext));
|
||||
} catch {
|
||||
// missing — fine
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function writeCdnFile(ns: string, key: string, ext: ImageExt, data: Uint8Array): Promise<string> {
|
||||
await mkdir(join(cdnDir(), ns), { recursive: true });
|
||||
await removeCdnVariants(ns, key);
|
||||
await Bun.write(cdnPath(ns, key, ext), data);
|
||||
return `/cdn/${ns}/${key}.${ext}`;
|
||||
}
|
||||
|
||||
// --- per-account upload rate limiter (sliding window) ---
|
||||
|
||||
const uploads = new Map<string, number[]>();
|
||||
|
||||
export function uploadAllowed(userId: string, ns: string): { ok: boolean; retryAfterSec: number } {
|
||||
const cfg = CDN_NAMESPACES[ns]!;
|
||||
const now = Date.now();
|
||||
const k = `${ns}:${userId}`;
|
||||
const list = (uploads.get(k) ?? []).filter((t) => t > now - cfg.windowMs);
|
||||
uploads.set(k, list);
|
||||
if (list.length >= cfg.maxUploads) {
|
||||
const retryAfterSec = Math.ceil((list[0]! + cfg.windowMs - now) / 1000);
|
||||
return { ok: false, retryAfterSec: Math.max(retryAfterSec, 1) };
|
||||
}
|
||||
return { ok: true, retryAfterSec: 0 };
|
||||
}
|
||||
|
||||
export function recordUpload(userId: string, ns: string): void {
|
||||
const k = `${ns}:${userId}`;
|
||||
const list = uploads.get(k) ?? [];
|
||||
list.push(Date.now());
|
||||
uploads.set(k, list);
|
||||
}
|
||||
@@ -44,6 +44,11 @@ export const config = {
|
||||
|
||||
// Per-driver debug logging to stdout ([driver:name] lines).
|
||||
driverDebug: envBool("DRIVER_DEBUG", true),
|
||||
|
||||
// Local file CDN (avatars today, more namespaces tomorrow).
|
||||
// Dev default is a folder next to the codebase; NixOS module points it
|
||||
// under the service state dir.
|
||||
cdnDir: env("CDN_DIR", "./cdn"),
|
||||
};
|
||||
|
||||
export function assertConfig(): void {
|
||||
|
||||
@@ -37,6 +37,18 @@ import {
|
||||
} from "./db.ts";
|
||||
import type { ChatMessage } from "./drivers/types.ts";
|
||||
import { DriverRegistry } from "./drivers/registry.ts";
|
||||
import {
|
||||
CDN_NAMESPACES,
|
||||
cdnFile,
|
||||
cdnMime,
|
||||
detectImageType,
|
||||
recordUpload,
|
||||
uploadAllowed,
|
||||
validExt,
|
||||
validKey,
|
||||
validNamespace,
|
||||
writeCdnFile,
|
||||
} from "./cdn.ts";
|
||||
|
||||
assertConfig();
|
||||
const registry = new DriverRegistry();
|
||||
@@ -92,6 +104,8 @@ function validAvatar(v: unknown): string | null {
|
||||
if (typeof v !== "string") return null;
|
||||
const t = v.trim();
|
||||
if (t.length > 2048) return null;
|
||||
// Local CDN avatar (uploaded via PUT /cdn/…) or remote http(s) URL.
|
||||
if (/^\/cdn\/[a-z0-9]{1,16}\/[A-Za-z0-9_-]{1,64}\.(jpg|png|webp)$/.test(t)) return t;
|
||||
if (!/^https?:\/\//i.test(t)) return null;
|
||||
return t;
|
||||
}
|
||||
@@ -142,6 +156,66 @@ const server = Bun.serve({
|
||||
return Response.redirect(config.wikiUrl, 302);
|
||||
}
|
||||
|
||||
// --- public: local file CDN (avatars today, more namespaces tomorrow) ---
|
||||
// GET /cdn/<ns>/<key>.<ext> — serves stored images, nothing else.
|
||||
const cdnGet = path.match(/^\/cdn\/([a-z0-9]{1,16})\/([A-Za-z0-9_-]{1,64})\.([a-z0-9]{1,8})$/);
|
||||
if (cdnGet && req.method === "GET") {
|
||||
const [, ns, key, ext] = cdnGet as [string, string, string, string];
|
||||
if (!validNamespace(ns) || !validKey(key) || !validExt(ext)) {
|
||||
return new Response("not found", { status: 404 });
|
||||
}
|
||||
const f = await cdnFile(ns, key, ext);
|
||||
if (!f.exists) return new Response("not found", { status: 404 });
|
||||
return new Response(Bun.file(f.path), {
|
||||
headers: {
|
||||
"Content-Type": cdnMime(ext),
|
||||
"Cache-Control": "public, max-age=3600",
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// --- authed (non-/api/): CDN upload ---
|
||||
// PUT /cdn/<ns>/<key> with the raw file bytes as body. The stored
|
||||
// extension always comes from magic-byte detection, never the client.
|
||||
const cdnPut = path.match(/^\/cdn\/([a-z0-9]{1,16})\/([A-Za-z0-9_-]{1,64})$/);
|
||||
if (cdnPut && req.method === "PUT") {
|
||||
const user = requireAuth(req);
|
||||
if (!user) return json({ error: "unauthorized" }, 401);
|
||||
const [, ns, key] = cdnPut as [string, string, string];
|
||||
if (!validNamespace(ns) || !validKey(key)) return json({ error: "unknown namespace or key" }, 404);
|
||||
const rules = CDN_NAMESPACES[ns]!;
|
||||
// Avatar ownership: your own account id, unless admin.
|
||||
if (ns === "avatar" && key !== user.id && !user.isAdmin) {
|
||||
return json({ error: "forbidden" }, 403);
|
||||
}
|
||||
const declared = Number(req.headers.get("content-length") || "0");
|
||||
const maxMb = (rules.maxBytes / (1024 * 1024)).toFixed(0);
|
||||
if (declared > rules.maxBytes) return json({ error: `file too large (max ${maxMb}MB)` }, 413);
|
||||
let buf: Uint8Array;
|
||||
try {
|
||||
buf = new Uint8Array(await req.arrayBuffer());
|
||||
} catch {
|
||||
return json({ error: "unreadable body" }, 400);
|
||||
}
|
||||
if (buf.length < 16) return json({ error: "empty file" }, 400);
|
||||
if (buf.length > rules.maxBytes) return json({ error: `file too large (max ${maxMb}MB)` }, 413);
|
||||
const ext = detectImageType(buf);
|
||||
if (!ext) {
|
||||
return json({ error: "unsupported file type: only jpg/png/webp images are accepted (verified by content, not extension)" }, 415);
|
||||
}
|
||||
const rl = uploadAllowed(user.id, ns);
|
||||
if (!rl.ok) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: `rate limited: ${rules.maxUploads} uploads per ${rules.windowMs / 3600_000}h`, retryAfterSec: rl.retryAfterSec }),
|
||||
{ status: 429, headers: { "Content-Type": "application/json", "Cache-Control": "no-store", "Retry-After": String(rl.retryAfterSec) } },
|
||||
);
|
||||
}
|
||||
const url = await writeCdnFile(ns, key, ext, buf);
|
||||
recordUpload(user.id, ns);
|
||||
return json({ url }, 201);
|
||||
}
|
||||
|
||||
// --- auth: login (rate limited) ---
|
||||
if (path === "/api/auth/login" && req.method === "POST") {
|
||||
const ip = clientIp(req, server);
|
||||
|
||||
Reference in New Issue
Block a user