diff --git a/.env.example b/.env.example index 491fdd6..6f19c90 100644 --- a/.env.example +++ b/.env.example @@ -171,10 +171,11 @@ R2_MAX_BYTES=10485760 # window.__CONFIG__ (see app/layout.tsx). R2_ALLOWED_CONTENT_TYPES=image/png,image/jpeg,image/webp # Attachments feature toggle — gates the assistant-ui `` -# attachment UI. Default false; flip to "true" once every required -# R2_* above is set. Frontend and backend must be in sync: if this -# stays "false" the composer hides the attachment widget, but routes -# still answer when called. Read in browser via window.__CONFIG__. +# attachment UI AND the Settings → Account avatar upload (both backed by +# R2). Default false; flip to "true" once every required R2_* above is +# set. Frontend and backend must be in sync: if this stays "false" the +# composer hides the attachment widget + the avatar upload is disabled, +# but routes still answer when called. Read in browser via window.__CONFIG__. ATTACHMENTS_ENABLED=false diff --git a/app/api/avatar/presign/route.ts b/app/api/avatar/presign/route.ts new file mode 100644 index 0000000..63320e4 --- /dev/null +++ b/app/api/avatar/presign/route.ts @@ -0,0 +1,91 @@ +import { NextResponse } from "next/server"; +import { randomBytes } from "node:crypto"; + +import { R2NotConfiguredError, buildPublicUrl, presignPut } from "@/lib/r2/client"; +import { safeFilename } from "@/lib/attachments/keys"; +import { PresignBody } from "@/lib/attachments/validators"; +import { withAuth } from "@/lib/auth/with-auth"; + +// ponytail: avatars go to R2 under the user's own path, NOT base64 into +// user.image. A base64 data URL there flows through the memory auth-overlay +// (mergeMemory) into every system block uncapped — issue #28's +// 372K-token blow-up. This route hands back a presigned PUT + the public +// URL; the client stores only the URL. No DB row (avatars aren't chat +// attachments — no dedup / retention sweep needed). + +const ID_ALPHABET = "0123456789abcdefghijklmnopqrstuvwxyz"; +const ID_LEN = 12; + +function generateId(): string { + const bytes = randomBytes(ID_LEN); + let out = ""; + for (let i = 0; i < ID_LEN; i++) out += ID_ALPHABET[bytes[i] % ID_ALPHABET.length]; + return out; +} + +function maxBytes(): number { + const parsed = process.env.R2_MAX_BYTES ? Number(process.env.R2_MAX_BYTES) : NaN; + return Number.isFinite(parsed) && parsed > 0 ? parsed : 10 * 1024 * 1024; +} + +function notConfiguredResponse(): NextResponse { + return NextResponse.json( + { + code: "AVATAR_UPLOADS_NOT_CONFIGURED", + message: "Set the R2_* env vars to enable avatar uploads (see docs/ATTACHMENTS.md).", + }, + { status: 503 }, + ); +} + +export const POST = withAuth(async (req, { user }) => { + let body: unknown; + try { + body = await req.json(); + } catch { + return NextResponse.json({ code: "BAD_REQUEST", error: "invalid JSON" }, { status: 400 }); + } + + const parsed = PresignBody.safeParse(body); + if (!parsed.success) { + return NextResponse.json({ code: "BAD_REQUEST", error: parsed.error.issues }, { status: 400 }); + } + + // ponytail: image/* only, and explicitly NOT svg — an SVG can carry + // inline