From 535092dcb83b6f94b4dd6ea523d3b535e3b779d2 Mon Sep 17 00:00:00 2001 From: Muhammad Ahmed Cheema Date: Thu, 16 Apr 2026 21:29:16 -0700 Subject: [PATCH 1/7] api: identity/avatar endpoints (POST, DELETE, GET /ui/avatar) POST validates PNG/JPEG/WebP at MIME allowlist AND magic-byte sniff; SVG is rejected twice. Extension is derived from MIME, never from filename. 2MB cap enforced at content-length AND at read. Atomic tmp+rename for both image and meta files. Zero server-side image decoding; Bun writes bytes verbatim. GET /ui/avatar is public (the landing page surfaces before login), 5min cache, sha256 ETag with 304 revalidation. /health and the core server mount /chat/icon as a PWA-scope-friendly mirror of the same bytes. --- src/chat/http.ts | 19 +- src/core/health-page.ts | 1 + src/core/server.ts | 13 + src/ui/api/__tests__/identity.test.ts | 363 ++++++++++++++++++++++++++ src/ui/api/identity.ts | 237 +++++++++++++++++ src/ui/serve.ts | 21 ++ 6 files changed, 645 insertions(+), 9 deletions(-) create mode 100644 src/ui/api/__tests__/identity.test.ts create mode 100644 src/ui/api/identity.ts diff --git a/src/chat/http.ts b/src/chat/http.ts index 1d11500..8c21d9c 100644 --- a/src/chat/http.ts +++ b/src/chat/http.ts @@ -1,5 +1,6 @@ import type { Database } from "bun:sqlite"; import type { AgentRuntime } from "../agent/runtime.ts"; +import { avatarUrlIfPresent, readAvatarMetaForManifest } from "../ui/api/identity.ts"; import { isAuthenticated } from "../ui/serve.ts"; import type { ChatAttachmentStore } from "./attachment-store.ts"; import type { ChatEventLog } from "./event-log.ts"; @@ -94,7 +95,8 @@ function isApiPath(path: string): boolean { async function routeApi(req: Request, url: URL, path: string, deps: ChatHandlerDeps): Promise { if (path === "/chat/bootstrap" && req.method === "GET") { - return Response.json(deps.getBootstrapData?.() ?? {}); + const base = deps.getBootstrapData?.() ?? {}; + return Response.json({ ...base, avatar_url: avatarUrlIfPresent() }); } if (path === "/chat/sessions" && req.method === "POST") { @@ -263,6 +265,12 @@ async function handlePushTest(deps: ChatHandlerDeps): Promise { function serveManifest(agentName?: string): Response { const name = agentName && agentName.length > 0 ? agentName : "Phantom"; + const avatar = readAvatarMetaForManifest(); + const icons: Array<{ src: string; sizes: string; type: string; purpose: string }> = []; + if (avatar) { + icons.push({ src: "/chat/icon", sizes: "256x256", type: avatar.mime, purpose: "any" }); + } + icons.push({ src: "/chat/favicon.svg", sizes: "any", type: "image/svg+xml", purpose: "any" }); const manifest = { name, short_name: name, @@ -273,14 +281,7 @@ function serveManifest(agentName?: string): Response { display: "standalone", background_color: "#faf9f5", theme_color: "#4850c4", - icons: [ - { - src: "/chat/favicon.svg", - sizes: "any", - type: "image/svg+xml", - purpose: "any", - }, - ], + icons, }; return new Response(JSON.stringify(manifest), { headers: { diff --git a/src/core/health-page.ts b/src/core/health-page.ts index abad477..50cc400 100644 --- a/src/core/health-page.ts +++ b/src/core/health-page.ts @@ -6,6 +6,7 @@ export type HealthPayload = { uptime: number; version: string; agent: string; + avatar_url: string | null; public_url?: string; role: { id: string; name: string }; channels: Record; diff --git a/src/core/server.ts b/src/core/server.ts index be06a95..58b1b2a 100644 --- a/src/core/server.ts +++ b/src/core/server.ts @@ -7,6 +7,7 @@ import { loadMcpConfig } from "../mcp/config.ts"; import type { PhantomMcpServer } from "../mcp/server.ts"; import type { MemoryHealth } from "../memory/types.ts"; import type { SchedulerHealthSummary } from "../scheduler/health.ts"; +import { avatarUrlIfPresent, handleAvatarGet } from "../ui/api/identity.ts"; import { handleUiRequest } from "../ui/serve.ts"; import { type HealthPayload, renderHealthHtml } from "./health-page.ts"; @@ -128,6 +129,7 @@ export function startServer(config: PhantomConfig, startedAt: number): ReturnTyp uptime: Math.floor((Date.now() - startedAt) / 1000), version: VERSION, agent: config.name, + avatar_url: avatarUrlIfPresent(), ...(config.public_url ? { public_url: config.public_url } : {}), role: roleInfo ?? { id: config.role, name: config.role }, channels, @@ -177,6 +179,17 @@ export function startServer(config: PhantomConfig, startedAt: number): ReturnTyp return handleEmailLogin(req, publicUrl, config.name); } + // Public PWA/SW-scoped mirror of the operator avatar. Service + // workers cannot reliably reach /ui/* across the /chat/ scope, so + // we expose the same bytes under /chat/icon. Same headers as + // /ui/avatar. + if (url.pathname === "/chat/icon" && req.method === "GET") { + return handleAvatarGet(req); + } + if (url.pathname === "/chat/icon") { + return new Response("Method not allowed", { status: 405, headers: { Allow: "GET" } }); + } + if (url.pathname.startsWith("/chat") && chatHandler) { const response = await chatHandler(req); if (response) return response; diff --git a/src/ui/api/__tests__/identity.test.ts b/src/ui/api/__tests__/identity.test.ts new file mode 100644 index 0000000..850de52 --- /dev/null +++ b/src/ui/api/__tests__/identity.test.ts @@ -0,0 +1,363 @@ +// Tests for POST/DELETE /ui/api/identity/avatar and GET /ui/avatar. +// +// We point the handler at a tmp dir via setIdentityDirForTests so each case +// exercises real disk I/O (atomic rename, extension swap, ETag parity) with +// no cross-test bleed. + +import { Database } from "bun:sqlite"; +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { createHash } from "node:crypto"; +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { MIGRATIONS } from "../../../db/schema.ts"; +import { handleUiRequest, setDashboardDb, setPublicDir } from "../../serve.ts"; +import { createSession, revokeAllSessions } from "../../session.ts"; +import { setIdentityDirForTests } from "../identity.ts"; + +setPublicDir(resolve(import.meta.dir, "../../../../public")); + +function runMigrations(target: Database): void { + for (const migration of MIGRATIONS) { + try { + target.run(migration); + } catch { + // idempotent + } + } +} + +// Minimal valid PNG header followed by padding. The handler does not decode, +// so anything matching the magic bytes and MIME is accepted. +function pngBytes(length = 128): Uint8Array { + const out = new Uint8Array(length); + out[0] = 0x89; + out[1] = 0x50; + out[2] = 0x4e; + out[3] = 0x47; + out[4] = 0x0d; + out[5] = 0x0a; + out[6] = 0x1a; + out[7] = 0x0a; + for (let i = 8; i < length; i++) out[i] = i & 0xff; + return out; +} + +function jpegBytes(length = 128): Uint8Array { + const out = new Uint8Array(length); + out[0] = 0xff; + out[1] = 0xd8; + out[2] = 0xff; + out[3] = 0xe0; + for (let i = 4; i < length; i++) out[i] = (i * 7) & 0xff; + return out; +} + +function webpBytes(length = 128): Uint8Array { + const out = new Uint8Array(length); + out[0] = 0x52; + out[1] = 0x49; + out[2] = 0x46; + out[3] = 0x46; + out[4] = 0x00; + out[5] = 0x00; + out[6] = 0x00; + out[7] = 0x00; + out[8] = 0x57; + out[9] = 0x45; + out[10] = 0x42; + out[11] = 0x50; + for (let i = 12; i < length; i++) out[i] = (i * 3) & 0xff; + return out; +} + +function svgBytes(): Uint8Array { + return new TextEncoder().encode(''); +} + +let db: Database; +let sessionToken: string; +let tmpDir: string; + +beforeEach(() => { + db = new Database(":memory:"); + runMigrations(db); + setDashboardDb(db); + sessionToken = createSession().sessionToken; + tmpDir = mkdtempSync(join(tmpdir(), "phantom-identity-test-")); + setIdentityDirForTests(tmpDir); +}); + +afterEach(() => { + setIdentityDirForTests(null); + db.close(); + revokeAllSessions(); + rmSync(tmpDir, { recursive: true, force: true }); +}); + +function authHeaders(extra: Record = {}): Record { + return { + Cookie: `phantom_session=${encodeURIComponent(sessionToken)}`, + ...extra, + }; +} + +function publicHeaders(extra: Record = {}): Record { + return { ...extra }; +} + +async function postAvatar( + mime: string, + bytes: Uint8Array, + filename = "logo.bin", + opts: { cookie?: boolean; contentLength?: number | null } = {}, +): Promise { + const form = new FormData(); + const blob = new Blob([bytes], { type: mime }); + form.append("file", blob, filename); + const headers: Record = opts.cookie === false ? {} : authHeaders(); + if (opts.contentLength != null) { + headers["content-length"] = String(opts.contentLength); + } + return handleUiRequest( + new Request("http://localhost/ui/api/identity/avatar", { + method: "POST", + body: form, + headers, + }), + ); +} + +async function deleteAvatar(opts: { cookie?: boolean } = {}): Promise { + const headers = opts.cookie === false ? {} : authHeaders(); + return handleUiRequest( + new Request("http://localhost/ui/api/identity/avatar", { + method: "DELETE", + headers, + }), + ); +} + +async function getAvatar(extra: Record = {}): Promise { + return handleUiRequest( + new Request("http://localhost/ui/avatar", { method: "GET", headers: publicHeaders(extra) }), + ); +} + +describe("identity avatar API", () => { + test("401 on POST without cookie", async () => { + const res = await postAvatar("image/png", pngBytes(), "logo.png", { cookie: false }); + expect(res.status).toBe(401); + }); + + test("401 on DELETE without cookie", async () => { + const res = await deleteAvatar({ cookie: false }); + expect(res.status).toBe(401); + }); + + test("GET /ui/avatar is public (no cookie required) and 404s with no upload", async () => { + const res = await getAvatar(); + expect(res.status).toBe(404); + }); + + test("POST with PNG writes file + meta and returns 200", async () => { + const bytes = pngBytes(200); + const res = await postAvatar("image/png", bytes, "logo.png"); + expect(res.status).toBe(200); + const body = (await res.json()) as { ok: boolean; url: string; size: number; mime: string }; + expect(body.ok).toBe(true); + expect(body.url).toBe("/ui/avatar"); + expect(body.size).toBe(bytes.byteLength); + expect(body.mime).toBe("image/png"); + expect(existsSync(join(tmpDir, "avatar.png"))).toBe(true); + const meta = JSON.parse(readFileSync(join(tmpDir, "avatar.meta.json"), "utf-8")) as { + ext: string; + mime: string; + size: number; + sha256: string; + }; + expect(meta.ext).toBe("png"); + expect(meta.mime).toBe("image/png"); + expect(meta.size).toBe(bytes.byteLength); + expect(meta.sha256).toBe(createHash("sha256").update(bytes).digest("hex")); + }); + + test("POST with JPEG writes .jpg on disk", async () => { + const res = await postAvatar("image/jpeg", jpegBytes(), "photo.jpeg"); + expect(res.status).toBe(200); + expect(existsSync(join(tmpDir, "avatar.jpg"))).toBe(true); + const meta = JSON.parse(readFileSync(join(tmpDir, "avatar.meta.json"), "utf-8")) as { mime: string }; + expect(meta.mime).toBe("image/jpeg"); + }); + + test("POST with WebP writes .webp on disk", async () => { + const res = await postAvatar("image/webp", webpBytes(), "logo.webp"); + expect(res.status).toBe(200); + expect(existsSync(join(tmpDir, "avatar.webp"))).toBe(true); + }); + + test("POST with SVG MIME rejected 400, no file written", async () => { + const res = await postAvatar("image/svg+xml", svgBytes(), "logo.svg"); + expect(res.status).toBe(400); + expect(existsSync(join(tmpDir, "avatar.svg"))).toBe(false); + expect(existsSync(join(tmpDir, "avatar.meta.json"))).toBe(false); + }); + + test("POST with SVG bytes but MIME=image/png rejected by magic-byte sniff", async () => { + const res = await postAvatar("image/png", svgBytes(), "logo.png"); + expect(res.status).toBe(400); + expect(existsSync(join(tmpDir, "avatar.png"))).toBe(false); + }); + + test("POST with PNG magic bytes but MIME=image/jpeg rejected by magic-byte sniff", async () => { + const res = await postAvatar("image/jpeg", pngBytes(), "logo.jpg"); + expect(res.status).toBe(400); + expect(existsSync(join(tmpDir, "avatar.jpg"))).toBe(false); + }); + + test("POST with HEIC rejected 400", async () => { + const res = await postAvatar("image/heic", pngBytes(), "photo.heic"); + expect(res.status).toBe(400); + }); + + test("POST with GIF rejected 400", async () => { + const res = await postAvatar("image/gif", pngBytes(), "logo.gif"); + expect(res.status).toBe(400); + }); + + test("POST over 2MB via content-length header returns 413", async () => { + const bytes = pngBytes(16); + const res = await postAvatar("image/png", bytes, "logo.png", { contentLength: 3 * 1024 * 1024 }); + expect(res.status).toBe(413); + expect(existsSync(join(tmpDir, "avatar.png"))).toBe(false); + }); + + test("POST over 2MB at read-time returns 413 even if Content-Length is absent", async () => { + // Simulate a >2MB PNG payload. Content-Length is not set manually so Bun + // computes it from the form, but the handler re-checks after reading. + const bytes = pngBytes(2 * 1024 * 1024 + 100); + const res = await postAvatar("image/png", bytes, "logo.png"); + expect(res.status).toBe(413); + }); + + test("POST with traversal filename has extension derived from MIME, path is hardcoded", async () => { + const bytes = pngBytes(); + const res = await postAvatar("image/png", bytes, "../../etc/passwd.png"); + expect(res.status).toBe(200); + expect(existsSync(join(tmpDir, "avatar.png"))).toBe(true); + // Nothing else got written outside tmpDir. + expect(existsSync(join(tmpDir, "..", "etc"))).toBe(false); + }); + + test("POST replaces previous avatar with different extension (PNG -> WebP)", async () => { + await postAvatar("image/png", pngBytes(), "logo.png"); + expect(existsSync(join(tmpDir, "avatar.png"))).toBe(true); + + const res = await postAvatar("image/webp", webpBytes(), "logo.webp"); + expect(res.status).toBe(200); + expect(existsSync(join(tmpDir, "avatar.webp"))).toBe(true); + expect(existsSync(join(tmpDir, "avatar.png"))).toBe(false); + }); + + test("DELETE removes avatar + meta and returns 204", async () => { + await postAvatar("image/png", pngBytes(), "logo.png"); + expect(existsSync(join(tmpDir, "avatar.png"))).toBe(true); + const res = await deleteAvatar(); + expect(res.status).toBe(204); + expect(existsSync(join(tmpDir, "avatar.png"))).toBe(false); + expect(existsSync(join(tmpDir, "avatar.meta.json"))).toBe(false); + }); + + test("DELETE is idempotent: returns 204 even when no avatar exists", async () => { + const res = await deleteAvatar(); + expect(res.status).toBe(204); + }); + + test("GET returns bytes with correct Content-Type", async () => { + const bytes = jpegBytes(50); + await postAvatar("image/jpeg", bytes, "logo.jpg"); + const res = await getAvatar(); + expect(res.status).toBe(200); + expect(res.headers.get("Content-Type")).toBe("image/jpeg"); + expect(res.headers.get("Cache-Control")).toContain("max-age=300"); + const got = new Uint8Array(await res.arrayBuffer()); + expect(got.byteLength).toBe(bytes.byteLength); + }); + + test("GET with If-None-Match matching ETag returns 304", async () => { + await postAvatar("image/png", pngBytes(), "logo.png"); + const first = await getAvatar(); + const etag = first.headers.get("ETag"); + expect(etag).toBeTruthy(); + const second = await getAvatar({ "If-None-Match": etag ?? "" }); + expect(second.status).toBe(304); + expect(second.headers.get("ETag")).toBe(etag); + }); + + test("GET with non-matching ETag returns 200 and new ETag", async () => { + await postAvatar("image/png", pngBytes(), "logo.png"); + const res = await getAvatar({ "If-None-Match": '"stale"' }); + expect(res.status).toBe(200); + }); + + test("GET 404 when meta exists but file is missing", async () => { + await postAvatar("image/png", pngBytes(), "logo.png"); + // Clobber the image bytes but leave the meta in place. + const { unlinkSync } = await import("node:fs"); + unlinkSync(join(tmpDir, "avatar.png")); + const res = await getAvatar(); + expect(res.status).toBe(404); + }); + + test("POST rejects empty file with 400", async () => { + const res = await postAvatar("image/png", new Uint8Array(0), "empty.png"); + expect(res.status).toBe(400); + }); + + test("POST rejects missing file with 400", async () => { + const form = new FormData(); + form.append("other", "nofile"); + const res = await handleUiRequest( + new Request("http://localhost/ui/api/identity/avatar", { + method: "POST", + body: form, + headers: authHeaders(), + }), + ); + expect(res.status).toBe(400); + }); + + test("POST unknown MIME rejected", async () => { + const res = await postAvatar("application/pdf", pngBytes(), "logo.pdf"); + expect(res.status).toBe(400); + }); + + test("atomic rename: tmp files are not left behind on success", async () => { + await postAvatar("image/png", pngBytes(), "logo.png"); + expect(existsSync(join(tmpDir, "avatar.png.tmp"))).toBe(false); + expect(existsSync(join(tmpDir, "avatar.meta.json.tmp"))).toBe(false); + }); + + test("pre-existing stale meta + stale file: new upload cleans prior extension", async () => { + // Seed the directory as if a prior deployment wrote a .gif (old allowlist). + // The handler must remove it on the next successful upload. + writeFileSync(join(tmpDir, "avatar.gif"), new Uint8Array([0x47, 0x49, 0x46, 0x38])); + await postAvatar("image/png", pngBytes(), "logo.png"); + expect(existsSync(join(tmpDir, "avatar.gif"))).toBe(false); + expect(existsSync(join(tmpDir, "avatar.png"))).toBe(true); + }); + + test("GET /ui/avatar other methods return 405", async () => { + const res = await handleUiRequest( + new Request("http://localhost/ui/avatar", { method: "POST", headers: publicHeaders() }), + ); + expect(res.status).toBe(405); + }); + + test("/ui/api/identity/avatar unsupported method returns 405", async () => { + const res = await handleUiRequest( + new Request("http://localhost/ui/api/identity/avatar", { method: "GET", headers: authHeaders() }), + ); + expect(res.status).toBe(405); + }); +}); diff --git a/src/ui/api/identity.ts b/src/ui/api/identity.ts new file mode 100644 index 0000000..c865bb3 --- /dev/null +++ b/src/ui/api/identity.ts @@ -0,0 +1,237 @@ +// Avatar upload endpoints. Single operator-visible identity asset on disk at +// data/identity/avatar. + avatar.meta.json. All three serve paths +// (/ui/avatar, /chat/icon, /health avatar_url) share one reader so the bytes +// only live in one place. +// +// Security posture: +// - Server never decodes the image. Bun writes bytes verbatim; the browser +// decodes in its sandbox. +// - MIME allowlist: PNG, JPEG, WebP. SVG rejected at MIME AND via magic-byte +// sniff because some form parse libs derive MIME from the filename. +// - Extension is derived from the validated MIME, never from the uploaded +// filename. Path is hardcoded so traversal is impossible. +// - 2MB cap at content-length AND at read. Both checks required (the +// Content-Length header can lie or be absent). + +import { createHash } from "node:crypto"; +import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, unlinkSync, writeFileSync } from "node:fs"; +import { resolve } from "node:path"; + +const MAX_BYTES = 2 * 1024 * 1024; +const ALLOWED_MIMES = new Set(["image/png", "image/jpeg", "image/webp"]); + +let identityDirOverride: string | null = null; + +export function setIdentityDirForTests(dir: string | null): void { + identityDirOverride = dir; +} + +export function getIdentityDir(): string { + return identityDirOverride ?? resolve(process.cwd(), "data", "identity"); +} + +type AvatarMeta = { + ext: "png" | "jpg" | "webp"; + mime: string; + size: number; + uploaded_at: string; + sha256: string; +}; + +function metaPath(): string { + return resolve(getIdentityDir(), "avatar.meta.json"); +} + +function avatarPath(ext: string): string { + return resolve(getIdentityDir(), `avatar.${ext}`); +} + +function readMetaSync(): AvatarMeta | null { + const p = metaPath(); + if (!existsSync(p)) return null; + try { + const text = readFileSync(p, "utf-8"); + const parsed = JSON.parse(text) as AvatarMeta; + if (!parsed || typeof parsed.ext !== "string" || typeof parsed.mime !== "string") return null; + return parsed; + } catch { + return null; + } +} + +export function hasAvatar(): boolean { + const meta = readMetaSync(); + if (!meta) return false; + return existsSync(avatarPath(meta.ext)); +} + +export function avatarUrlIfPresent(): string | null { + return hasAvatar() ? "/ui/avatar" : null; +} + +// Manifest consumer needs the MIME to set the icons[].type correctly so +// Android/iOS pick the right entry. Returns null when no avatar is uploaded. +export function readAvatarMetaForManifest(): { mime: string } | null { + const meta = readMetaSync(); + if (!meta) return null; + if (!existsSync(avatarPath(meta.ext))) return null; + return { mime: meta.mime }; +} + +function extFromMime(mime: string): "png" | "jpg" | "webp" | null { + if (mime === "image/png") return "png"; + if (mime === "image/jpeg") return "jpg"; + if (mime === "image/webp") return "webp"; + return null; +} + +// Magic-byte sniff. Even if the MIME check is bypassed, this catches SVG +// masquerading as PNG (opening `3C 3F 78 6D 6C` or `3C 73 76 67`) and other +// format swaps. Defense in depth. +function sniffMatches(bytes: Uint8Array, mime: string): boolean { + if (bytes.length < 12) return false; + if (mime === "image/png") { + return bytes[0] === 0x89 && bytes[1] === 0x50 && bytes[2] === 0x4e && bytes[3] === 0x47; + } + if (mime === "image/jpeg") { + return bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff; + } + if (mime === "image/webp") { + const riff = bytes[0] === 0x52 && bytes[1] === 0x49 && bytes[2] === 0x46 && bytes[3] === 0x46; + const webp = bytes[8] === 0x57 && bytes[9] === 0x45 && bytes[10] === 0x42 && bytes[11] === 0x50; + return riff && webp; + } + return false; +} + +function errJson(message: string, status: number): Response { + return new Response(JSON.stringify({ error: message }), { + status, + headers: { "Content-Type": "application/json" }, + }); +} + +export async function handleAvatarPost(req: Request): Promise { + const contentLengthHeader = req.headers.get("content-length"); + if (contentLengthHeader !== null) { + const cl = Number(contentLengthHeader); + if (Number.isFinite(cl) && cl > MAX_BYTES) { + return errJson("Avatar too large. Max 2 MB.", 413); + } + } + + let formData: FormData; + try { + formData = await req.formData(); + } catch { + return errJson("Could not parse multipart form data.", 400); + } + + const files = formData.getAll("file").filter((v): v is File => v instanceof File); + if (files.length === 0) return errJson("No file attached.", 400); + if (files.length > 1) return errJson("Exactly one file is required.", 400); + + const file = files[0]; + const mime = file.type; + if (!ALLOWED_MIMES.has(mime)) { + return errJson("Unsupported image type. Use PNG, JPEG, or WebP.", 400); + } + + if (file.size === 0) return errJson("File is empty.", 400); + if (file.size > MAX_BYTES) return errJson("Avatar too large. Max 2 MB.", 413); + + const bytes = new Uint8Array(await file.arrayBuffer()); + if (bytes.byteLength > MAX_BYTES) return errJson("Avatar too large. Max 2 MB.", 413); + + if (!sniffMatches(bytes, mime)) { + return errJson("File bytes do not match declared type.", 400); + } + + const ext = extFromMime(mime); + if (!ext) return errJson("Unsupported image type. Use PNG, JPEG, or WebP.", 400); + + const dir = getIdentityDir(); + mkdirSync(dir, { recursive: true }); + + const targetFile = avatarPath(ext); + const tmpFile = `${targetFile}.tmp`; + const targetMeta = metaPath(); + const tmpMeta = `${targetMeta}.tmp`; + + const sha256 = createHash("sha256").update(bytes).digest("hex"); + const meta: AvatarMeta = { + ext, + mime, + size: bytes.byteLength, + uploaded_at: new Date().toISOString(), + sha256, + }; + + try { + writeFileSync(tmpFile, bytes); + renameSync(tmpFile, targetFile); + } catch (err: unknown) { + try { + if (existsSync(tmpFile)) unlinkSync(tmpFile); + } catch {} + const msg = err instanceof Error ? err.message : String(err); + return errJson(`Avatar write failed: ${msg}`, 500); + } + + try { + writeFileSync(tmpMeta, JSON.stringify(meta, null, 2)); + renameSync(tmpMeta, targetMeta); + } catch (err: unknown) { + try { + if (existsSync(tmpMeta)) unlinkSync(tmpMeta); + } catch {} + const msg = err instanceof Error ? err.message : String(err); + return errJson(`Avatar meta write failed: ${msg}`, 500); + } + + // Prune any previous avatar with a different extension (PNG -> WebP etc). + for (const entry of readdirSync(dir)) { + if (!entry.startsWith("avatar.")) continue; + if (entry === `avatar.${ext}` || entry === "avatar.meta.json") continue; + if (entry.endsWith(".tmp")) continue; + try { + unlinkSync(resolve(dir, entry)); + } catch {} + } + + return Response.json({ ok: true, url: "/ui/avatar", size: bytes.byteLength, mime }); +} + +export function handleAvatarDelete(): Response { + const dir = getIdentityDir(); + if (!existsSync(dir)) return new Response(null, { status: 204 }); + for (const entry of readdirSync(dir)) { + if (!entry.startsWith("avatar.")) continue; + try { + unlinkSync(resolve(dir, entry)); + } catch {} + } + return new Response(null, { status: 204 }); +} + +export async function handleAvatarGet(req: Request): Promise { + const meta = readMetaSync(); + if (!meta) return new Response("Not found", { status: 404 }); + const file = Bun.file(avatarPath(meta.ext)); + if (!(await file.exists())) { + console.warn("[identity] avatar meta exists but file is missing; returning 404"); + return new Response("Not found", { status: 404 }); + } + const etag = `"${meta.sha256}"`; + const ifNoneMatch = req.headers.get("if-none-match"); + if (ifNoneMatch && ifNoneMatch === etag) { + return new Response(null, { status: 304, headers: { ETag: etag } }); + } + return new Response(file, { + headers: { + "Content-Type": meta.mime, + "Cache-Control": "private, max-age=300, must-revalidate", + ETag: etag, + }, + }); +} diff --git a/src/ui/serve.ts b/src/ui/serve.ts index 98bd2a1..0973100 100644 --- a/src/ui/serve.ts +++ b/src/ui/serve.ts @@ -16,6 +16,7 @@ import { getSecretRequest, saveSecrets, validateMagicToken } from "../secrets/st import { handleCostApi } from "./api/cost.ts"; import { handleEvolutionApi } from "./api/evolution.ts"; import { handleHooksApi } from "./api/hooks.ts"; +import { handleAvatarDelete, handleAvatarGet, handleAvatarPost } from "./api/identity.ts"; import { handleMemoryFilesApi } from "./api/memory-files.ts"; import { handleMemoryApi } from "./api/memory.ts"; import { type PhantomConfigPaths, handlePhantomConfigApi } from "./api/phantom-config.ts"; @@ -220,6 +221,16 @@ export async function handleUiRequest(req: Request): Promise { return handleSecretSave(req, secretSaveMatch[1]); } + // Public read for avatar: surfaces on the landing + login + agent pages + // before the operator has authenticated, so this endpoint is unauth. + // The write path (POST/DELETE) falls through to the auth gate below. + if (url.pathname === "/ui/avatar" && req.method === "GET") { + return handleAvatarGet(req); + } + if (url.pathname === "/ui/avatar") { + return new Response("Method not allowed", { status: 405, headers: { Allow: "GET" } }); + } + // Public assets (logo, favicon) - no auth needed if (url.pathname === "/ui/phantom-logo.svg") { const filePath = isPathSafe(url.pathname); @@ -248,6 +259,16 @@ export async function handleUiRequest(req: Request): Promise { return createSSEResponse(); } + // Avatar write/delete. Cookie-auth required; the public read lives above. + if (url.pathname === "/ui/api/identity/avatar") { + if (req.method === "POST") return handleAvatarPost(req); + if (req.method === "DELETE") return handleAvatarDelete(); + return new Response("Method not allowed", { + status: 405, + headers: { Allow: "POST, DELETE" }, + }); + } + // Dashboard API routes (PR1). Return as soon as one matches so the static // file fallthrough below never sees them. if (url.pathname.startsWith("/ui/api/skills")) { From 9826bd1ec92fd610274d06020a457197ee51ff59 Mon Sep 17 00:00:00 2001 From: Muhammad Ahmed Cheema Date: Thu, 16 Apr 2026 21:31:48 -0700 Subject: [PATCH 2/7] ui: identity IIFE, base-template substitution, static pages, login avatar The _agent-name.js IIFE now reads avatar_url from /health, fills every [data-agent-avatar] slot with an img tag (with an error-triggered fallback to the letter badge), and caches the URL in localStorage so warm loads paint without a flash. _base.html gets AGENT_AVATAR_IMG + AGENT_FALLBACK_DISPLAY placeholders so phantom_create_page output picks up the current avatar. Landing, dashboard, and login pages all swap the single-letter badge for avatar slots and point their favicon links at /ui/avatar (falling back to data:, on 404). The login page server-renders conditionally using a cheap existsSync on avatar.meta.json so cookie-less visitors see the correct identity. --- public/_agent-name.js | 88 +++++++++++++++++++++++---- public/_base.html | 2 +- public/dashboard/dashboard.css | 84 +++++++++++++++++++++++++ public/dashboard/index.html | 5 +- public/index.html | 7 ++- src/ui/api/__tests__/identity.test.ts | 5 +- src/ui/login-page.ts | 14 ++++- src/ui/tools.ts | 15 ++++- 8 files changed, 201 insertions(+), 19 deletions(-) diff --git a/public/_agent-name.js b/public/_agent-name.js index 3fc6c16..43a3365 100644 --- a/public/_agent-name.js +++ b/public/_agent-name.js @@ -1,16 +1,22 @@ -// Canonical agent-name customization IIFE for Phantom static pages. +// Canonical agent-name and avatar customization IIFE for Phantom static pages. // // Loaded once per page with . // Replaces [data-agent-name], [data-agent-name-initial], [data-agent-name-lower] // nodes with the deployed agent name and substitutes {{AGENT_NAME_CAPITALIZED}} // in any template. // +// Avatar: if the operator has uploaded one, any [data-agent-avatar] element +// gets an <img src="/ui/avatar"> inserted. A sibling marked +// [data-agent-avatar-fallback] is hidden on successful load and un-hidden if +// the image errors (so the initial-letter badge still reads). +// // Mirrors the server-side capitalizeAgentName contract: empty/whitespace name // falls back to "Phantom" so the brand never reads as blank. Paints an -// optimistic value from localStorage (or "Phantom") on load, then swaps when -// /health resolves so warm loads have no flash and cold loads see "Phantom" -// instead of a stray   until the fetch resolves. +// optimistic value from localStorage (agent name AND avatar URL) on load, then +// swaps when /health resolves so warm loads have no flash. (function () { + var AVATAR_KEY = "phantom-agent-avatar"; + function cap(name) { if (!name) return "Phantom"; var trimmed = String(name).trim(); @@ -35,7 +41,7 @@ } } - function apply(name) { + function applyName(name) { var display = cap(name); var initial = display.charAt(0).toUpperCase(); var lower = display.toLowerCase(); @@ -52,24 +58,82 @@ titleEl.textContent = titleTemplate.split("{{AGENT_NAME_CAPITALIZED}}").join(display); } try { - if (name) { - localStorage.setItem("phantom-agent-name", name); + if (name) localStorage.setItem("phantom-agent-name", name); + } catch (e) {} + } + + function applyAvatar(url) { + // null means "no avatar uploaded" — make sure any previously-inserted + // img is removed and fallbacks are visible. + document.querySelectorAll("[data-agent-avatar]").forEach(function (slot) { + var existing = slot.querySelector("img[data-agent-avatar-img]"); + var fallback = slot.querySelector("[data-agent-avatar-fallback]"); + if (!url) { + if (existing) existing.remove(); + if (fallback) fallback.style.display = ""; + return; + } + if (existing) { + if (existing.getAttribute("src") !== url) existing.setAttribute("src", url); + return; } + var img = document.createElement("img"); + img.setAttribute("data-agent-avatar-img", ""); + img.setAttribute("alt", ""); + img.className = "phantom-nav-logo-img"; + img.addEventListener("error", function () { + img.remove(); + if (fallback) fallback.style.display = ""; + }); + img.addEventListener("load", function () { + if (fallback) fallback.style.display = "none"; + }); + img.setAttribute("src", url); + // Hide the fallback letter the moment we commit to inserting the + // img. If it errors the listener above brings it back. + if (fallback) fallback.style.display = "none"; + slot.insertBefore(img, fallback || null); + }); + document.querySelectorAll("[data-agent-avatar-url]").forEach(function (el) { + if (url) { + el.setAttribute("content", url); + el.setAttribute("href", url); + } + }); + try { + if (url) localStorage.setItem(AVATAR_KEY, url); + else localStorage.removeItem(AVATAR_KEY); } catch (e) {} } - var cached = ""; + var cachedName = ""; + var cachedAvatar = null; try { - cached = localStorage.getItem("phantom-agent-name") || ""; + cachedName = localStorage.getItem("phantom-agent-name") || ""; + cachedAvatar = localStorage.getItem(AVATAR_KEY); } catch (e) {} - apply(cached || "Phantom"); + applyName(cachedName || "Phantom"); + if (cachedAvatar) applyAvatar(cachedAvatar); - fetch("/health", { credentials: "same-origin" }) + fetch("/health", { credentials: "same-origin", headers: { Accept: "application/json" } }) .then(function (r) { return r.ok ? r.json() : null; }) .then(function (d) { - if (d && d.agent) apply(d.agent); + if (!d) return; + if (d.agent) applyName(d.agent); + // avatar_url is null when no upload, "/ui/avatar" otherwise. + if (Object.prototype.hasOwnProperty.call(d, "avatar_url")) { + applyAvatar(d.avatar_url || null); + } }) .catch(function () {}); + + // Exposed so the dashboard Settings > Identity section can force the + // surrounding navbar to repaint immediately after a successful upload, + // without waiting for the 5-minute cache to expire. + window.addEventListener("phantom:avatar-updated", function (ev) { + var url = ev && ev.detail && ev.detail.url; + applyAvatar(url === undefined ? "/ui/avatar" : url); + }); })(); diff --git a/public/_base.html b/public/_base.html index 8e38cc1..dfbc987 100644 --- a/public/_base.html +++ b/public/_base.html @@ -1010,7 +1010,7 @@ <!-- Navbar --> <nav class="phantom-nav" aria-label="Primary"> <a href="/ui/" class="phantom-nav-brand"> - <span style="display:inline-flex;width:22px;height:22px;border-radius:6px;background:var(--color-primary);align-items:center;justify-content:center;color:var(--color-primary-content);font-family:var(--font-family-serif);font-size:14px;font-weight:500;">{{AGENT_NAME_INITIAL}}</span> + <span style="display:inline-flex;align-items:center;">{{AGENT_AVATAR_IMG}}<span style="display:{{AGENT_FALLBACK_DISPLAY}};width:22px;height:22px;border-radius:6px;background:var(--color-primary);align-items:center;justify-content:center;color:var(--color-primary-content);font-family:var(--font-family-serif);font-size:14px;font-weight:500;">{{AGENT_NAME_INITIAL}}</span></span> <span>{{AGENT_NAME_CAPITALIZED}}</span> </a> <span class="phantom-breadcrumb-sep">/</span> diff --git a/public/dashboard/dashboard.css b/public/dashboard/dashboard.css index 241a09f..96aab69 100644 --- a/public/dashboard/dashboard.css +++ b/public/dashboard/dashboard.css @@ -3026,3 +3026,87 @@ body { @media (prefers-reduced-motion: reduce) { .dash-sched-inline-spinner { animation: none; } } + +/* ==== Identity section (Settings > Identity): avatar upload ==== */ +.phantom-nav-logo-img { width: 22px; height: 22px; border-radius: 6px; object-fit: cover; display: inline-block; } +.phantom-nav-avatar-slot { display: inline-flex; align-items: center; } + +.dash-identity-card { + display: grid; + grid-template-columns: auto 1fr; + gap: var(--space-6); + align-items: center; + padding: var(--space-5); + border: 1px solid var(--color-base-300); + border-radius: var(--radius-lg); + background: var(--color-base-200); +} +@media (max-width: 640px) { + .dash-identity-card { grid-template-columns: 1fr; justify-items: center; text-align: center; } +} + +.dash-avatar-preview-wrap { + width: 96px; + height: 96px; + position: relative; +} +.dash-avatar-preview { + width: 96px; + height: 96px; + border-radius: 50%; + object-fit: cover; + border: 1px solid var(--color-base-300); + display: block; + background: var(--color-base-100); +} +.dash-avatar-preview-letter { + width: 96px; + height: 96px; + border-radius: 50%; + background: var(--color-primary); + color: var(--color-primary-content); + display: inline-flex; + align-items: center; + justify-content: center; + font-family: 'Instrument Serif', Georgia, serif; + font-size: 56px; + font-weight: 400; + border: 1px solid var(--color-base-300); + user-select: none; +} + +.dash-avatar-drop { + border: 1.5px dashed var(--color-base-300); + border-radius: var(--radius-md); + padding: var(--space-4); + font-size: 13px; + color: color-mix(in oklab, var(--color-base-content) 62%, transparent); + transition: border-color var(--motion-fast) var(--ease-out), background-color var(--motion-fast) var(--ease-out); + cursor: pointer; + outline: none; +} +.dash-avatar-drop[data-drag="true"], +.dash-avatar-drop:focus-visible { + border-color: var(--color-primary); + background: color-mix(in oklab, var(--color-primary) 5%, transparent); + color: var(--color-base-content); +} +.dash-avatar-drop strong { color: var(--color-base-content); } + +.dash-identity-actions { + display: flex; + flex-wrap: wrap; + gap: var(--space-2); + margin-top: var(--space-3); +} +.dash-identity-guidance { + font-size: 12px; + color: color-mix(in oklab, var(--color-base-content) 58%, transparent); + line-height: 1.5; +} +.dash-identity-guidance p { margin: 0 0 var(--space-2); } +.dash-identity-guidance p:last-child { margin-bottom: 0; } +.dash-identity-slack { + font-style: italic; +} + diff --git a/public/dashboard/index.html b/public/dashboard/index.html index db3dca7..2ef39e8 100644 --- a/public/dashboard/index.html +++ b/public/dashboard/index.html @@ -4,6 +4,7 @@ <meta charset="utf-8"> <meta name="viewport" content="width=device-width, initial-scale=1"> <title data-agent-name-title>Dashboard + @@ -16,7 +17,9 @@