From 03d56006dc62b79aeff0b822dba2b1478473a8aa Mon Sep 17 00:00:00 2001 From: h Date: Wed, 21 Jan 2026 10:20:17 +0100 Subject: [PATCH] feat(frontend): POST for images --- caddy/Caddyfile.example | 3 + frontend/src/app.d.ts | 12 ++- frontend/src/hooks.server.ts | 8 ++ frontend/src/routes/[mnemonic]/+server.ts | 91 +++++++++++++++++++++++ 4 files changed, 107 insertions(+), 7 deletions(-) create mode 100644 frontend/src/hooks.server.ts create mode 100644 frontend/src/routes/[mnemonic]/+server.ts diff --git a/caddy/Caddyfile.example b/caddy/Caddyfile.example index 945d10c..5e53783 100644 --- a/caddy/Caddyfile.example +++ b/caddy/Caddyfile.example @@ -26,6 +26,9 @@ } handle { + request_body { + max_size 50MB + } reverse_proxy stealth-ai-relay-frontend:3000 } } diff --git a/frontend/src/app.d.ts b/frontend/src/app.d.ts index da08e6d..785b0e4 100644 --- a/frontend/src/app.d.ts +++ b/frontend/src/app.d.ts @@ -1,12 +1,10 @@ -// See https://svelte.dev/docs/kit/types#app.d.ts -// for information about these interfaces +import type { ConvexHttpClient } from 'convex/browser'; + declare global { namespace App { - // interface Error {} - // interface Locals {} - // interface PageData {} - // interface PageState {} - // interface Platform {} + interface Locals { + convex: ConvexHttpClient; + } } } diff --git a/frontend/src/hooks.server.ts b/frontend/src/hooks.server.ts new file mode 100644 index 0000000..77eff0a --- /dev/null +++ b/frontend/src/hooks.server.ts @@ -0,0 +1,8 @@ +import { ConvexHttpClient } from 'convex/browser'; +import { PUBLIC_CONVEX_URL } from '$env/static/public'; +import type { Handle } from '@sveltejs/kit'; + +export const handle: Handle = async ({ event, resolve }) => { + event.locals.convex = new ConvexHttpClient(PUBLIC_CONVEX_URL); + return resolve(event); +}; diff --git a/frontend/src/routes/[mnemonic]/+server.ts b/frontend/src/routes/[mnemonic]/+server.ts new file mode 100644 index 0000000..006768c --- /dev/null +++ b/frontend/src/routes/[mnemonic]/+server.ts @@ -0,0 +1,91 @@ +import { api } from '$lib/convex/_generated/api'; +import { error } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; + +function detectImageType(bytes: Uint8Array): string | null { + if (bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) { + return 'image/jpeg'; + } + if (bytes[0] === 0x89 && bytes[1] === 0x50 && bytes[2] === 0x4e && bytes[3] === 0x47) { + return 'image/png'; + } + if (bytes[0] === 0x47 && bytes[1] === 0x49 && bytes[2] === 0x46) { + return 'image/gif'; + } + if (bytes[0] === 0x52 && bytes[1] === 0x49 && bytes[2] === 0x46 && bytes[3] === 0x46) { + return 'image/webp'; + } + return null; +} + +export const POST: RequestHandler = async ({ params, request, locals }) => { + const mnemonic = params.mnemonic; + + const chatData = await locals.convex.query(api.chats.getByMnemonic, { mnemonic }); + if (!chatData) { + throw error(404, 'Chat not found'); + } + + const rawContentType = request.headers.get('content-type') || ''; + const caption = request.headers.get('x-caption') || ''; + + console.log('[POST /{mnemonic}] headers:', Object.fromEntries(request.headers.entries())); + console.log('[POST /{mnemonic}] content-type:', rawContentType); + + let base64: string; + let mediaType: string; + + if (rawContentType.includes('multipart/form-data')) { + const formData = await request.formData(); + const keys = [...formData.keys()]; + console.log('[POST /{mnemonic}] formData keys:', keys); + + let file: File | null = null; + for (const key of ['file', 'image', 'photo', 'upload', 'attachment', ...keys]) { + const value = formData.get(key); + if (value instanceof File) { + file = value; + console.log('[POST /{mnemonic}] found file in field:', key); + break; + } + } + + if (!file) { + throw error(400, `No file found in form data. Keys: ${keys.join(', ')}`); + } + + const buffer = await file.arrayBuffer(); + const bytes = new Uint8Array(buffer); + base64 = Buffer.from(buffer).toString('base64'); + mediaType = detectImageType(bytes) || file.type || 'image/jpeg'; + + console.log('[POST /{mnemonic}] file:', file.name, file.type, 'size:', buffer.byteLength); + } else if (rawContentType.includes('application/x-www-form-urlencoded')) { + throw error(400, 'Use Form with File field, not URL-encoded form'); + } else { + const buffer = await request.arrayBuffer(); + const bytes = new Uint8Array(buffer); + base64 = Buffer.from(buffer).toString('base64'); + mediaType = detectImageType(bytes) || rawContentType || 'image/jpeg'; + + console.log('[POST /{mnemonic}] raw bytes size:', buffer.byteLength); + console.log('[POST /{mnemonic}] detected type:', mediaType); + } + + if (!base64 || base64.length === 0) { + throw error(400, 'Empty image data'); + } + + await locals.convex.mutation(api.messages.create, { + chatId: chatData._id, + role: 'user', + content: caption, + source: 'web', + imageBase64: base64, + imageMediaType: mediaType + }); + + return new Response(JSON.stringify({ ok: true, mediaType, size: base64.length }), { + headers: { 'Content-Type': 'application/json' } + }); +};