-
Notifications
You must be signed in to change notification settings - Fork 0
fix: store avatars in R2 (not base64) to stop context-window blow-up #32
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
61736ba
7e37650
44d01f4
4eab26e
9a48a53
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
| @@ -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 <memory> 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; | ||||||
| } | ||||||
|
greptile-apps[bot] marked this conversation as resolved.
|
||||||
|
|
||||||
| 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 <script>/<foreignObject>, and with Content-Disposition: inline | ||||||
| // on a public bucket a leaked URL becomes an XSS page in the bucket | ||||||
| // origin. (attachments enforces R2_ALLOWED_CONTENT_TYPES, which omits | ||||||
| // svg by default; we mirror that intent here.) | ||||||
| const contentType = parsed.data.contentType.toLowerCase(); | ||||||
| if (!contentType.startsWith("image/") || contentType === "image/svg+xml") { | ||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The SVG rejection uses an exact-string match ( Either tighten to
Suggested change
|
||||||
| return NextResponse.json({ code: "CONTENT_TYPE_NOT_ALLOWED", contentType }, { status: 400 }); | ||||||
| } | ||||||
|
|
||||||
| const cap = maxBytes(); | ||||||
| if (parsed.data.sizeBytes > cap) { | ||||||
| return NextResponse.json( | ||||||
| { code: "FILE_TOO_LARGE", maxBytes: cap, sizeBytes: parsed.data.sizeBytes }, | ||||||
| { status: 400 }, | ||||||
| ); | ||||||
| } | ||||||
|
|
||||||
| const key = `u/${user.id}/avatar/${generateId()}-${safeFilename(parsed.data.name)}`; | ||||||
|
|
||||||
| let uploadUrl: string; | ||||||
| try { | ||||||
| uploadUrl = await presignPut({ key, contentLength: parsed.data.sizeBytes }); | ||||||
|
FireTable marked this conversation as resolved.
|
||||||
| } catch (e) { | ||||||
| if (e instanceof R2NotConfiguredError) return notConfiguredResponse(); | ||||||
| throw e; | ||||||
| } | ||||||
|
|
||||||
| return NextResponse.json( | ||||||
| { | ||||||
| key, | ||||||
| uploadUrl, | ||||||
| publicUrl: buildPublicUrl(key), | ||||||
| uploadHeaders: { "Content-Type": contentType, "Content-Disposition": "inline" }, | ||||||
| }, | ||||||
| { status: 201 }, | ||||||
| ); | ||||||
| }); | ||||||
| Original file line number | Diff line number | Diff line change | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,55 @@ | ||||||||||
| import { NextResponse } from "next/server"; | ||||||||||
|
|
||||||||||
| import { R2NotConfiguredError, deleteObject, getR2PublicBaseUrl } from "@/lib/r2/client"; | ||||||||||
| import { withAuth } from "@/lib/auth/with-auth"; | ||||||||||
|
|
||||||||||
| // ponytail: avatars have no DB row, so nothing sweeps the old R2 object when | ||||||||||
| // the user deletes or replaces their avatar — it would leak forever at its | ||||||||||
| // public URL. This route deletes the object behind an avatar URL, owner- | ||||||||||
| // scoped: the key must live under u/<user.id>/avatar/. External avatar URLs | ||||||||||
| // (a github/google-hosted image) aren't ours → 204 no-op. Idempotent: an | ||||||||||
| // already-gone object is a success. | ||||||||||
|
|
||||||||||
| 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 DELETE = 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 url = (body as { url?: unknown })?.url; | ||||||||||
| if (typeof url !== "string" || url.length === 0) { | ||||||||||
| return NextResponse.json({ code: "BAD_REQUEST", error: "url required" }, { status: 400 }); | ||||||||||
| } | ||||||||||
|
|
||||||||||
| let base: string; | ||||||||||
| try { | ||||||||||
| base = getR2PublicBaseUrl(); | ||||||||||
| } catch (e) { | ||||||||||
| if (e instanceof R2NotConfiguredError) return notConfiguredResponse(); | ||||||||||
| throw e; | ||||||||||
| } | ||||||||||
|
|
||||||||||
| // Not one of our R2 objects (e.g. an OAuth-hosted avatar) → nothing to do. | ||||||||||
| if (!url.startsWith(`${base}/`)) return new NextResponse(null, { status: 204 }); | ||||||||||
|
|
||||||||||
| const key = url.slice(base.length + 1); | ||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Parse the URL first so only the pathname reaches R2:
Suggested change
|
||||||||||
| const prefix = `u/${user.id}/avatar/`; | ||||||||||
| if (!key.startsWith(prefix)) { | ||||||||||
| return NextResponse.json({ code: "FORBIDDEN" }, { status: 403 }); | ||||||||||
| } | ||||||||||
|
|
||||||||||
| await deleteObject(key).catch(() => undefined); | ||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2: Avatar cleanup can silently fail and leave public R2 objects orphaned, because every Prompt for AI agents |
||||||||||
| return new NextResponse(null, { status: 204 }); | ||||||||||
| }); | ||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
P3 — modulo bias, cosmetic but cheap to fix.
256 % 36 === 4, so values0..3are ~0.3% more likely than the rest. Standard fix:Non-security-critical (collisions are still ~0 in practice), but worth doing if you have other crypto-grade ID generators elsewhere in the codebase.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Declining — the ID is only used to namespace R2 keys under
u/<userId>/avatar/<id>-<safe-name>for the presign URL, not as a security boundary (ownership is enforced server-side inDELETE /api/avatarvia the key prefix check). WithID_LENof 16 the collision space is still effectively zero at human upload rates, and the rejection-sampling loop adds a branch + 4 extra bytes on a hot path that has to run before every avatar change. Filing a separate ticket if we ever need a uniformly-distributed ID for a security-sensitive use.🤖 Generated with Claude Code