diff --git a/README.md b/README.md index c866f2a..8ff1f00 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,7 @@ Create a `.env.local` file at the repo root: ``` # Google OAuth (https://console.cloud.google.com) +# GOOGLE_* is documented here; AUTH_GOOGLE_* also works with NextAuth v5 GOOGLE_ID=... GOOGLE_SECRET=... @@ -84,6 +85,7 @@ Add your production Vercel URL to the authorized redirect URIs in Google Cloud C - **Shared chats** — generate a shareable read-only URL with OG image preview; authenticated users can fork the chat to continue it - **Google OAuth** — restricted to a configurable allowlist of emails - **Health endpoint** — `GET /api/health` checks DB connectivity; suitable for uptime monitors +- **Observability** — API responses include `X-Request-Id`, and the app emits structured server/client diagnostics without logging plaintext chat history ## Scripts diff --git a/app/api/chat/route.ts b/app/api/chat/route.ts index be5b86b..890ae8a 100644 --- a/app/api/chat/route.ts +++ b/app/api/chat/route.ts @@ -3,15 +3,14 @@ import { getProvider, toOpenAIMessages, toAnthropicMessages, toGeminiContents, p import { auth } from '@/auth'; import { query } from '@/lib/db'; import logger from '@/lib/log'; +import { getRequestId, PRIVATE_NO_STORE, readContentLength, textResponse } from '@/lib/request'; +import { LIMITS, validateChatRequest } from '@/lib/validation'; export const runtime = 'nodejs'; const RATE_LIMIT = 20; const RATE_WINDOW_MS = 60_000; const TIMEOUT_MS = 60_000; -const MAX_MESSAGES = 200; - -import { ALLOWED_MODELS } from '@/lib/models'; async function checkRateLimit(email: string): Promise { const bucket = new Date(Math.floor(Date.now() / RATE_WINDOW_MS) * RATE_WINDOW_MS).toISOString(); @@ -27,6 +26,34 @@ async function checkRateLimit(email: string): Promise { return result.rows[0].count <= RATE_LIMIT; } +function chatHeaders(requestId: string): HeadersInit { + return { + 'Content-Type': 'text/plain; charset=utf-8', + 'Cache-Control': PRIVATE_NO_STORE, + 'X-Request-Id': requestId, + }; +} + +function getUpstreamRequestId(response: Response): string | null { + return response.headers.get('anthropic-request-id') + ?? response.headers.get('x-request-id') + ?? response.headers.get('request-id'); +} + +function summarizeMessages(messages: Message[]) { + let promptChars = 0; + let imageCount = 0; + let pdfCount = 0; + + for (const message of messages) { + promptChars += message.content.length; + imageCount += message.images?.length ?? 0; + pdfCount += message.pdfs?.length ?? 0; + } + + return { promptChars, imageCount, pdfCount }; +} + // Anthropic multi-turn loop for web search. // Each tool round is non-streaming (collect tool_use, return tool_result). // Final text is enqueued in one shot — client sees it after the search completes. @@ -36,6 +63,7 @@ async function anthropicWebSearch( systemPrompt: string | undefined, model: string, signal: AbortSignal, + requestId: string, ): Promise { const headers = { 'x-api-key': apiKey, @@ -60,7 +88,7 @@ async function anthropicWebSearch( signal, }); - if (!res.ok) return new Response(await res.text(), { status: res.status }); + if (!res.ok) return textResponse(await res.text(), { status: res.status }, { requestId, cacheControl: PRIVATE_NO_STORE }); // eslint-disable-next-line @typescript-eslint/no-explicit-any const data: { content: any[]; stop_reason: string } = await res.json(); @@ -82,7 +110,7 @@ async function anthropicWebSearch( controller.close(); }, }); - return new Response(stream, { headers: { 'Content-Type': 'text/plain; charset=utf-8' } }); + return new Response(stream, { headers: chatHeaders(requestId) }); } // Append assistant turn + tool results and loop @@ -101,62 +129,58 @@ async function anthropicWebSearch( ]; } - return new Response('Web search exceeded max rounds', { status: 500 }); + return textResponse('Web search exceeded max rounds', { status: 500 }, { requestId, cacheControl: PRIVATE_NO_STORE }); } export async function POST(req: NextRequest) { const t = Date.now(); - const ctx: Record = {}; + const requestId = getRequestId(req); + const ctx: Record = { requestId }; try { + const authStart = Date.now(); + const session = await auth(); + ctx.authMs = Date.now() - authStart; + if (!session?.user?.email) { + ctx.status = 401; ctx.error = 'unauthenticated'; + return textResponse('Unauthorized', { status: 401 }, { requestId, cacheControl: PRIVATE_NO_STORE }); + } + ctx.user = session.user.email; - const session = await auth(); - if (!session?.user?.email) { - ctx.status = 401; ctx.error = 'unauthenticated'; - return new Response('Unauthorized', { status: 401 }); - } - ctx.user = session.user.email; + const contentLength = readContentLength(req); + if (contentLength !== null) ctx.requestBytes = contentLength; + if (contentLength !== null && contentLength > LIMITS.chatBodyBytes) { + ctx.status = 413; ctx.error = 'request_too_large'; + return textResponse('Request too large', { status: 413 }, { requestId, cacheControl: PRIVATE_NO_STORE }); + } - const allowed = await checkRateLimit(session.user.email); - if (!allowed) { - ctx.status = 429; - return new Response('Rate limit exceeded', { status: 429 }); - } + const rateLimitStart = Date.now(); + const allowed = await checkRateLimit(session.user.email); + ctx.rateLimitMs = Date.now() - rateLimitStart; + if (!allowed) { + ctx.status = 429; + return textResponse('Rate limit exceeded', { status: 429 }, { requestId, cacheControl: PRIVATE_NO_STORE }); + } - const body = await req.json() as { - messages: Message[]; - model: string; - systemPrompt?: string; - webSearch?: boolean; - }; - const { messages, model, systemPrompt, webSearch } = body; + const parsed = validateChatRequest(await req.json()); + if (!parsed.ok) { + ctx.status = parsed.status; + ctx.error = parsed.error; + return textResponse(parsed.error, { status: parsed.status }, { requestId, cacheControl: PRIVATE_NO_STORE }); + } - if (!Array.isArray(messages) || messages.length === 0) { - ctx.status = 400; ctx.error = 'invalid_messages'; - return new Response('Invalid messages', { status: 400 }); - } - if (messages.length > MAX_MESSAGES) { - ctx.status = 400; ctx.error = 'too_many_messages'; - return new Response(`Too many messages (max ${MAX_MESSAGES})`, { status: 400 }); - } - if (!ALLOWED_MODELS.has(model)) { - ctx.status = 400; ctx.error = 'invalid_model'; - return new Response(`Unknown model: ${model}`, { status: 400 }); - } - if (systemPrompt !== undefined && typeof systemPrompt !== 'string') { - ctx.status = 400; ctx.error = 'invalid_system_prompt'; - return new Response('Invalid systemPrompt', { status: 400 }); - } - if (webSearch !== undefined && typeof webSearch !== 'boolean') { - ctx.status = 400; ctx.error = 'invalid_web_search'; - return new Response('Invalid webSearch', { status: 400 }); - } + const { messages, model, systemPrompt, webSearch } = parsed.value; const provider = getProvider(model); ctx.model = model; ctx.provider = provider; ctx.msgs = messages.length; + ctx.systemPromptChars = systemPrompt?.length ?? 0; if (webSearch) ctx.webSearch = true; + const summary = summarizeMessages(messages); + ctx.promptChars = summary.promptChars; + ctx.images = summary.imageCount; + ctx.pdfs = summary.pdfCount; const apiKey = (provider === 'openai' ? process.env.OPENAI_API_KEY : undefined) || @@ -165,13 +189,15 @@ export async function POST(req: NextRequest) { if (!apiKey) { ctx.status = 401; ctx.error = `no_api_key`; - return new Response(`No API key for ${provider}`, { status: 401 }); + return textResponse(`No API key for ${provider}`, { status: 401 }, { requestId, cacheControl: PRIVATE_NO_STORE }); } - const signal = AbortSignal.any([req.signal, AbortSignal.timeout(TIMEOUT_MS)]); + const timeoutSignal = AbortSignal.timeout(TIMEOUT_MS); + const signal = AbortSignal.any([req.signal, timeoutSignal]); // ── Google ─────────────────────────────────────────────────────────────── if (provider === 'google') { + const upstreamStartedAt = Date.now(); const upstream = await fetch( `https://generativelanguage.googleapis.com/v1beta/models/${model}:streamGenerateContent?key=${apiKey}&alt=sse`, { @@ -188,16 +214,26 @@ export async function POST(req: NextRequest) { if (!upstream.ok) { ctx.status = upstream.status; ctx.error = 'upstream_error'; - return new Response(await upstream.text(), { status: upstream.status }); + ctx.upstreamConnectMs = Date.now() - upstreamStartedAt; + return textResponse(await upstream.text(), { status: upstream.status }, { requestId, cacheControl: PRIVATE_NO_STORE }); } ctx.status = 200; + ctx.upstreamConnectMs = Date.now() - upstreamStartedAt; + ctx.upstreamStatus = upstream.status; + const upstreamRequestId = getUpstreamRequestId(upstream); + if (upstreamRequestId) ctx.upstreamRequestId = upstreamRequestId; const stream = new ReadableStream({ async start(controller) { const reader = upstream.body!.getReader(); const decoder = new TextDecoder(); const encoder = new TextEncoder(); let buffer = ''; + let firstByteMs: number | null = null; + let outputChars = 0; + let streamError: string | null = null; + let shouldClose = true; + const streamStartedAt = Date.now(); try { while (true) { const { done, value } = await reader.read(); @@ -210,21 +246,40 @@ export async function POST(req: NextRequest) { const data = line.slice(6).trim(); if (!data) continue; const text = parseGeminiChunk(data); - if (text) controller.enqueue(encoder.encode(text)); + if (!text) continue; + if (firstByteMs === null) firstByteMs = Date.now() - t; + outputChars += text.length; + controller.enqueue(encoder.encode(text)); } } + } catch (error) { + shouldClose = false; + streamError = String(error).slice(0, 200); + controller.error(error); } finally { - controller.close(); + if (shouldClose) controller.close(); + const fields = { + ...ctx, + firstByteMs, + streamDurationMs: Date.now() - streamStartedAt, + outputChars, + clientAborted: req.signal.aborted, + timedOut: timeoutSignal.aborted, + streamError, + }; + if (streamError && !req.signal.aborted && !timeoutSignal.aborted) logger.error(fields, 'chat.stream'); + else if (req.signal.aborted || timeoutSignal.aborted) logger.warn(fields, 'chat.stream'); + else logger.info(fields, 'chat.stream'); } }, }); - return new Response(stream, { headers: { 'Content-Type': 'text/plain; charset=utf-8' } }); + return new Response(stream, { headers: chatHeaders(requestId) }); } // ── Anthropic with web search: multi-turn loop ─────────────────────────── if (provider === 'anthropic' && webSearch) { - const res = await anthropicWebSearch(apiKey, messages, systemPrompt, model, signal); + const res = await anthropicWebSearch(apiKey, messages, systemPrompt, model, signal, requestId); ctx.status = res.status; return res; } @@ -233,6 +288,7 @@ export async function POST(req: NextRequest) { // Chat Completions only supports 'function'/'custom' tool types. // Web search requires the Responses API (/v1/responses) with a different SSE format. if (provider === 'openai' && webSearch) { + const upstreamStartedAt = Date.now(); const upstream = await fetch('https://api.openai.com/v1/responses', { method: 'POST', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json' }, @@ -248,10 +304,15 @@ export async function POST(req: NextRequest) { if (!upstream.ok) { ctx.status = upstream.status; ctx.error = 'upstream_error'; - return new Response(await upstream.text(), { status: upstream.status }); + ctx.upstreamConnectMs = Date.now() - upstreamStartedAt; + return textResponse(await upstream.text(), { status: upstream.status }, { requestId, cacheControl: PRIVATE_NO_STORE }); } ctx.status = 200; + ctx.upstreamConnectMs = Date.now() - upstreamStartedAt; + ctx.upstreamStatus = upstream.status; + const upstreamRequestId = getUpstreamRequestId(upstream); + if (upstreamRequestId) ctx.upstreamRequestId = upstreamRequestId; const stream = new ReadableStream({ async start(controller) { const reader = upstream.body!.getReader(); @@ -259,6 +320,11 @@ export async function POST(req: NextRequest) { const encoder = new TextEncoder(); let buffer = ''; let curEvent = ''; + let firstByteMs: number | null = null; + let outputChars = 0; + let streamError: string | null = null; + let shouldClose = true; + const streamStartedAt = Date.now(); try { while (true) { @@ -274,20 +340,40 @@ export async function POST(req: NextRequest) { if (line === '') { curEvent = ''; continue; } if (!line.startsWith('data: ')) continue; const out = parseOpenAIResponsesChunk(curEvent, line.slice(6)); - if (out) controller.enqueue(encoder.encode(out)); + if (!out) continue; + if (firstByteMs === null) firstByteMs = Date.now() - t; + outputChars += out.replace(/\0/g, '').length; + controller.enqueue(encoder.encode(out)); } } + } catch (error) { + shouldClose = false; + streamError = String(error).slice(0, 200); + controller.error(error); } finally { - controller.close(); + if (shouldClose) controller.close(); + const fields = { + ...ctx, + firstByteMs, + streamDurationMs: Date.now() - streamStartedAt, + outputChars, + clientAborted: req.signal.aborted, + timedOut: timeoutSignal.aborted, + streamError, + }; + if (streamError && !req.signal.aborted && !timeoutSignal.aborted) logger.error(fields, 'chat.stream'); + else if (req.signal.aborted || timeoutSignal.aborted) logger.warn(fields, 'chat.stream'); + else logger.info(fields, 'chat.stream'); } }, }); - return new Response(stream, { headers: { 'Content-Type': 'text/plain; charset=utf-8' } }); + return new Response(stream, { headers: chatHeaders(requestId) }); } // ── OpenAI / Anthropic: SSE ────────────────────────────────────────────── let upstream: Response; + const upstreamStartedAt = Date.now(); if (provider === 'openai') { upstream = await fetch('https://api.openai.com/v1/chat/completions', { @@ -322,16 +408,26 @@ export async function POST(req: NextRequest) { if (!upstream.ok) { const body = await upstream.text(); ctx.status = upstream.status; ctx.error = 'upstream_error'; - return new Response(body, { status: upstream.status }); + ctx.upstreamConnectMs = Date.now() - upstreamStartedAt; + return textResponse(body, { status: upstream.status }, { requestId, cacheControl: PRIVATE_NO_STORE }); } ctx.status = 200; + ctx.upstreamConnectMs = Date.now() - upstreamStartedAt; + ctx.upstreamStatus = upstream.status; + const upstreamRequestId = getUpstreamRequestId(upstream); + if (upstreamRequestId) ctx.upstreamRequestId = upstreamRequestId; const stream = new ReadableStream({ async start(controller) { const reader = upstream.body!.getReader(); const decoder = new TextDecoder(); const encoder = new TextEncoder(); let buffer = ''; + let firstByteMs: number | null = null; + let outputChars = 0; + let streamError: string | null = null; + let shouldClose = true; + const streamStartedAt = Date.now(); try { while (true) { @@ -350,21 +446,40 @@ export async function POST(req: NextRequest) { const text = provider === 'openai' ? parseOpenAIChunk(data) : parseAnthropicChunk(data); - if (text) controller.enqueue(encoder.encode(text)); + if (!text) continue; + if (firstByteMs === null) firstByteMs = Date.now() - t; + outputChars += text.length; + controller.enqueue(encoder.encode(text)); } } + } catch (error) { + shouldClose = false; + streamError = String(error).slice(0, 200); + controller.error(error); } finally { - controller.close(); + if (shouldClose) controller.close(); + const fields = { + ...ctx, + firstByteMs, + streamDurationMs: Date.now() - streamStartedAt, + outputChars, + clientAborted: req.signal.aborted, + timedOut: timeoutSignal.aborted, + streamError, + }; + if (streamError && !req.signal.aborted && !timeoutSignal.aborted) logger.error(fields, 'chat.stream'); + else if (req.signal.aborted || timeoutSignal.aborted) logger.warn(fields, 'chat.stream'); + else logger.info(fields, 'chat.stream'); } }, }); - return new Response(stream, { headers: { 'Content-Type': 'text/plain; charset=utf-8' } }); + return new Response(stream, { headers: chatHeaders(requestId) }); } catch (err) { ctx.status = 500; ctx.error = String(err).slice(0, 120); - throw err; + return textResponse('Internal Server Error', { status: 500 }, { requestId, cacheControl: PRIVATE_NO_STORE }); } finally { const fields = { ...ctx, ms: Date.now() - t }; const status = ctx.status as number | undefined; diff --git a/app/api/client-events/route.ts b/app/api/client-events/route.ts new file mode 100644 index 0000000..2629230 --- /dev/null +++ b/app/api/client-events/route.ts @@ -0,0 +1,45 @@ +import { auth } from '@/auth'; +import logger from '@/lib/log'; +import { getRequestId, jsonResponse, PRIVATE_NO_STORE } from '@/lib/request'; +import { LIMITS, validateClientEventRequest } from '@/lib/validation'; + +export async function POST(req: Request) { + const requestId = getRequestId(req); + const start = Date.now(); + + try { + const contentLength = Number(req.headers.get('content-length') ?? 0); + if (contentLength > LIMITS.clientEventBodyBytes) { + return jsonResponse({ error: 'event too large' }, { status: 413 }, { requestId, cacheControl: PRIVATE_NO_STORE }); + } + + const session = await auth(); + if (!session?.user?.email) { + return jsonResponse({ error: 'Unauthorized' }, { status: 401 }, { requestId, cacheControl: PRIVATE_NO_STORE }); + } + + const parsed = validateClientEventRequest(await req.json()); + if (!parsed.ok) { + logger.warn({ requestId, user: session.user.email, durationMs: Date.now() - start, error: parsed.error }, 'client.event.invalid'); + return jsonResponse({ error: parsed.error }, { status: parsed.status }, { requestId, cacheControl: PRIVATE_NO_STORE }); + } + + const fields = { + requestId, + user: session.user.email, + durationMs: Date.now() - start, + ...parsed.value.details, + }; + if (parsed.value.level === 'info') logger.info(fields, parsed.value.event); + else if (parsed.value.level === 'warn') logger.warn(fields, parsed.value.event); + else logger.error(fields, parsed.value.event); + + return new Response(null, { + status: 204, + headers: { 'X-Request-Id': requestId, 'Cache-Control': PRIVATE_NO_STORE }, + }); + } catch (error) { + logger.error({ requestId, durationMs: Date.now() - start, error: String(error).slice(0, 200) }, 'client.event.failed'); + return jsonResponse({ error: 'Internal Server Error' }, { status: 500 }, { requestId, cacheControl: PRIVATE_NO_STORE }); + } +} diff --git a/app/api/health/route.ts b/app/api/health/route.ts index 9235f9c..04060db 100644 --- a/app/api/health/route.ts +++ b/app/api/health/route.ts @@ -1,12 +1,19 @@ import { query } from '@/lib/db'; +import logger from '@/lib/log'; +import { getRequestId, jsonResponse } from '@/lib/request'; export const runtime = 'nodejs'; -export async function GET() { +export async function GET(req: Request) { + const requestId = getRequestId(req); + const start = Date.now(); + try { await query('SELECT 1'); - return Response.json({ ok: true }); - } catch (err) { - return Response.json({ ok: false, error: 'database unavailable' }, { status: 503 }); + logger.info({ requestId, durationMs: Date.now() - start, ok: true }, 'health.check'); + return jsonResponse({ ok: true }, {}, { requestId, cacheControl: 'no-store' }); + } catch (error) { + logger.error({ requestId, durationMs: Date.now() - start, ok: false, error: String(error).slice(0, 200) }, 'health.check'); + return jsonResponse({ ok: false, error: 'database unavailable' }, { status: 503 }, { requestId, cacheControl: 'no-store' }); } } diff --git a/app/api/history/[id]/route.ts b/app/api/history/[id]/route.ts index f593d81..f544703 100644 --- a/app/api/history/[id]/route.ts +++ b/app/api/history/[id]/route.ts @@ -1,20 +1,42 @@ import { auth } from '@/auth'; import { query } from '@/lib/db'; import logger from '@/lib/log'; +import { getRequestId, jsonResponse, PRIVATE_NO_STORE } from '@/lib/request'; -export async function DELETE(_req: Request, { params }: { params: Promise<{ id: string }> }) { +export async function DELETE(req: Request, { params }: { params: Promise<{ id: string }> }) { + const requestId = getRequestId(req); const start = Date.now(); - const session = await auth(); - if (!session?.user?.email) { - logger.warn({ durationMs: Date.now() - start }, 'history.delete.unauthenticated'); - return Response.json({ error: 'Unauthorized' }, { status: 401 }); - } - const { id } = await params; - const result = await query( - 'DELETE FROM chat_histories WHERE id = $1 AND user_email = $2', - [id, session.user.email], - ); - logger.info({ user: session.user.email, id, durationMs: Date.now() - start, deleted: result.rowCount ?? 0 }, 'history.delete'); - return new Response(null, { status: 204 }); + try { + const session = await auth(); + if (!session?.user?.email) { + logger.warn({ requestId, durationMs: Date.now() - start }, 'history.delete.unauthenticated'); + return jsonResponse({ error: 'Unauthorized' }, { status: 401 }, { requestId, cacheControl: PRIVATE_NO_STORE }); + } + + const { id } = await params; + if (!/^[a-z0-9-]{8,80}$/i.test(id)) { + logger.warn({ requestId, user: session.user.email, durationMs: Date.now() - start }, 'history.delete.invalid'); + return jsonResponse({ error: 'Invalid id' }, { status: 400 }, { requestId, cacheControl: PRIVATE_NO_STORE }); + } + + const result = await query( + 'DELETE FROM chat_histories WHERE id = $1 AND user_email = $2', + [id, session.user.email], + ); + logger.info({ + requestId, + user: session.user.email, + id, + durationMs: Date.now() - start, + deleted: result.rowCount ?? 0, + }, 'history.delete'); + return new Response(null, { + status: 204, + headers: { 'X-Request-Id': requestId, 'Cache-Control': PRIVATE_NO_STORE }, + }); + } catch (error) { + logger.error({ requestId, durationMs: Date.now() - start, error: String(error).slice(0, 200) }, 'history.delete.failed'); + return jsonResponse({ error: 'Internal Server Error' }, { status: 500 }, { requestId, cacheControl: PRIVATE_NO_STORE }); + } } diff --git a/app/api/history/route.ts b/app/api/history/route.ts index d6ea164..4174840 100644 --- a/app/api/history/route.ts +++ b/app/api/history/route.ts @@ -1,61 +1,100 @@ import { auth } from '@/auth'; import { query } from '@/lib/db'; import logger from '@/lib/log'; +import { getRequestId, jsonResponse, PRIVATE_NO_STORE, readContentLength } from '@/lib/request'; +import { LIMITS, validateHistorySaveRequest } from '@/lib/validation'; -export async function GET() { +export async function GET(req: Request) { + const requestId = getRequestId(req); const start = Date.now(); - const session = await auth(); - if (!session?.user?.email) { - logger.warn({ durationMs: Date.now() - start }, 'history.list.unauthenticated'); - return Response.json({ error: 'Unauthorized' }, { status: 401 }); - } - const result = await query( - 'SELECT id, iv, ciphertext, updated_at FROM chat_histories WHERE user_email = $1 ORDER BY updated_at DESC LIMIT 50', - [session.user.email], - ); - const rows = result.rows; - const newestAt = rows[0]?.updated_at ?? null; - const oldestAt = rows[rows.length - 1]?.updated_at ?? null; - logger.info({ - user: session.user.email, - durationMs: Date.now() - start, - rows: rows.length, - newestAt, - oldestAt, - }, 'history.list'); - return Response.json(rows); + try { + const session = await auth(); + if (!session?.user?.email) { + logger.warn({ requestId, durationMs: Date.now() - start }, 'history.list.unauthenticated'); + return jsonResponse({ error: 'Unauthorized' }, { status: 401 }, { requestId, cacheControl: PRIVATE_NO_STORE }); + } + + const result = await query( + 'SELECT id, iv, ciphertext, updated_at FROM chat_histories WHERE user_email = $1 ORDER BY updated_at DESC LIMIT 50', + [session.user.email], + ); + const rows = result.rows; + logger.info({ + requestId, + user: session.user.email, + durationMs: Date.now() - start, + rows: rows.length, + newestAt: rows[0]?.updated_at ?? null, + oldestAt: rows[rows.length - 1]?.updated_at ?? null, + }, 'history.list'); + return jsonResponse(rows, {}, { requestId, cacheControl: PRIVATE_NO_STORE }); + } catch (error) { + logger.error({ requestId, durationMs: Date.now() - start, error: String(error).slice(0, 200) }, 'history.list.failed'); + return jsonResponse({ error: 'Internal Server Error' }, { status: 500 }, { requestId, cacheControl: PRIVATE_NO_STORE }); + } } export async function POST(req: Request) { + const requestId = getRequestId(req); const start = Date.now(); - const session = await auth(); - if (!session?.user?.email) { - logger.warn({ durationMs: Date.now() - start }, 'history.save.unauthenticated'); - return Response.json({ error: 'Unauthorized' }, { status: 401 }); - } - const { id, iv, ciphertext } = await req.json(); - const ciphertextBytes = Math.round((ciphertext?.length ?? 0) * 0.75); + try { + const session = await auth(); + if (!session?.user?.email) { + logger.warn({ requestId, durationMs: Date.now() - start }, 'history.save.unauthenticated'); + return jsonResponse({ error: 'Unauthorized' }, { status: 401 }, { requestId, cacheControl: PRIVATE_NO_STORE }); + } - if (id) { - const upd = await query( - 'UPDATE chat_histories SET iv = $1, ciphertext = $2, updated_at = now() WHERE id = $3 AND user_email = $4', - [iv, ciphertext, id, session.user.email], - ); - if ((upd.rowCount ?? 0) > 0) { - logger.info({ user: session.user.email, id, op: 'update', durationMs: Date.now() - start, ciphertextBytes }, 'history.save'); - return Response.json({ id }); + const contentLength = readContentLength(req); + if (contentLength !== null && contentLength > LIMITS.historyBodyBytes) { + logger.warn({ requestId, user: session.user.email, durationMs: Date.now() - start, contentLength }, 'history.save.too_large'); + return jsonResponse({ error: 'Request too large' }, { status: 413 }, { requestId, cacheControl: PRIVATE_NO_STORE }); } - // Row was deleted externally — fall through to INSERT so the save is not lost - } - const result = await query( - `INSERT INTO chat_histories (id, user_email, iv, ciphertext) - VALUES (gen_random_uuid()::text, $1, $2, $3) - RETURNING id`, - [session.user.email, iv, ciphertext], - ); - logger.info({ user: session.user.email, id: result.rows[0].id, op: 'insert', durationMs: Date.now() - start, ciphertextBytes }, 'history.save'); - return Response.json({ id: result.rows[0].id }); + const parsed = validateHistorySaveRequest(await req.json()); + if (!parsed.ok) { + logger.warn({ requestId, user: session.user.email, durationMs: Date.now() - start, error: parsed.error }, 'history.save.invalid'); + return jsonResponse({ error: parsed.error }, { status: parsed.status }, { requestId, cacheControl: PRIVATE_NO_STORE }); + } + + const ciphertextBytes = Math.round((parsed.value.ciphertext.length ?? 0) * 0.75); + + if (parsed.value.id) { + const upd = await query( + 'UPDATE chat_histories SET iv = $1, ciphertext = $2, updated_at = now() WHERE id = $3 AND user_email = $4', + [parsed.value.iv, parsed.value.ciphertext, parsed.value.id, session.user.email], + ); + if ((upd.rowCount ?? 0) > 0) { + logger.info({ + requestId, + user: session.user.email, + id: parsed.value.id, + op: 'update', + durationMs: Date.now() - start, + ciphertextBytes, + }, 'history.save'); + return jsonResponse({ id: parsed.value.id }, {}, { requestId, cacheControl: PRIVATE_NO_STORE }); + } + } + + const result = await query( + `INSERT INTO chat_histories (id, user_email, iv, ciphertext) + VALUES (gen_random_uuid()::text, $1, $2, $3) + RETURNING id`, + [session.user.email, parsed.value.iv, parsed.value.ciphertext], + ); + logger.info({ + requestId, + user: session.user.email, + id: result.rows[0].id, + op: 'insert', + durationMs: Date.now() - start, + ciphertextBytes, + }, 'history.save'); + return jsonResponse({ id: result.rows[0].id }, {}, { requestId, cacheControl: PRIVATE_NO_STORE }); + } catch (error) { + logger.error({ requestId, durationMs: Date.now() - start, error: String(error).slice(0, 200) }, 'history.save.failed'); + return jsonResponse({ error: 'Internal Server Error' }, { status: 500 }, { requestId, cacheControl: PRIVATE_NO_STORE }); + } } diff --git a/app/api/settings/route.ts b/app/api/settings/route.ts index 8bf314d..f4c8aac 100644 --- a/app/api/settings/route.ts +++ b/app/api/settings/route.ts @@ -1,56 +1,88 @@ import { auth } from '@/auth'; import { query } from '@/lib/db'; import logger from '@/lib/log'; +import { getRequestId, jsonResponse, PRIVATE_NO_STORE, readContentLength } from '@/lib/request'; +import { LIMITS, validateSettingsRequest } from '@/lib/validation'; -export async function GET() { +export async function GET(req: Request) { + const requestId = getRequestId(req); const start = Date.now(); - const session = await auth(); - if (!session?.user?.email) { - logger.warn({ route: 'settings.get', durationMs: Date.now() - start }, 'unauthenticated'); - return Response.json({ error: 'Unauthorized' }, { status: 401 }); - } - const result = await query( - 'SELECT system_prompt, save_history, key_jwk FROM user_settings WHERE email = $1', - [session.user.email], - ); - const row = result.rows[0]; - logger.info({ - user: session.user.email, - durationMs: Date.now() - start, - hasKey: !!row?.key_jwk, - saveHistory: row?.save_history ?? false, - newUser: !row, - }, 'settings.get'); - return Response.json({ - systemPrompt: row?.system_prompt ?? '', - saveHistory: row?.save_history ?? false, - keyJwk: row?.key_jwk ?? null, - }); + try { + const session = await auth(); + if (!session?.user?.email) { + logger.warn({ requestId, route: 'settings.get', durationMs: Date.now() - start }, 'unauthenticated'); + return jsonResponse({ error: 'Unauthorized' }, { status: 401 }, { requestId, cacheControl: PRIVATE_NO_STORE }); + } + + const result = await query( + 'SELECT system_prompt, save_history, key_jwk FROM user_settings WHERE email = $1', + [session.user.email], + ); + const row = result.rows[0]; + logger.info({ + requestId, + user: session.user.email, + durationMs: Date.now() - start, + hasKey: !!row?.key_jwk, + saveHistory: row?.save_history ?? false, + newUser: !row, + }, 'settings.get'); + return jsonResponse({ + systemPrompt: row?.system_prompt ?? '', + saveHistory: row?.save_history ?? false, + keyJwk: row?.key_jwk ?? null, + }, {}, { requestId, cacheControl: PRIVATE_NO_STORE }); + } catch (error) { + logger.error({ requestId, durationMs: Date.now() - start, error: String(error).slice(0, 200) }, 'settings.get.failed'); + return jsonResponse({ error: 'Internal Server Error' }, { status: 500 }, { requestId, cacheControl: PRIVATE_NO_STORE }); + } } export async function PUT(req: Request) { + const requestId = getRequestId(req); const start = Date.now(); - const session = await auth(); - if (!session?.user?.email) { - logger.warn({ route: 'settings.put', durationMs: Date.now() - start }, 'unauthenticated'); - return Response.json({ error: 'Unauthorized' }, { status: 401 }); - } - const { systemPrompt, saveHistory, keyJwk } = await req.json(); - await query( - `INSERT INTO user_settings (email, system_prompt, save_history, key_jwk) VALUES ($1, $2, $3, $4) - ON CONFLICT (email) DO UPDATE SET - system_prompt = EXCLUDED.system_prompt, - save_history = EXCLUDED.save_history, - key_jwk = COALESCE(EXCLUDED.key_jwk, user_settings.key_jwk)`, - [session.user.email, systemPrompt ?? '', saveHistory ?? false, keyJwk ?? null], - ); - logger.info({ - user: session.user.email, - durationMs: Date.now() - start, - saveHistory: saveHistory ?? false, - hasKey: !!keyJwk, - }, 'settings.put'); - return new Response(null, { status: 204 }); + try { + const session = await auth(); + if (!session?.user?.email) { + logger.warn({ requestId, route: 'settings.put', durationMs: Date.now() - start }, 'unauthenticated'); + return jsonResponse({ error: 'Unauthorized' }, { status: 401 }, { requestId, cacheControl: PRIVATE_NO_STORE }); + } + + const contentLength = readContentLength(req); + if (contentLength !== null && contentLength > LIMITS.settingsBodyBytes) { + logger.warn({ requestId, user: session.user.email, durationMs: Date.now() - start, contentLength }, 'settings.put.too_large'); + return jsonResponse({ error: 'Request too large' }, { status: 413 }, { requestId, cacheControl: PRIVATE_NO_STORE }); + } + + const parsed = validateSettingsRequest(await req.json()); + if (!parsed.ok) { + logger.warn({ requestId, user: session.user.email, durationMs: Date.now() - start, error: parsed.error }, 'settings.put.invalid'); + return jsonResponse({ error: parsed.error }, { status: parsed.status }, { requestId, cacheControl: PRIVATE_NO_STORE }); + } + + await query( + `INSERT INTO user_settings (email, system_prompt, save_history, key_jwk) VALUES ($1, $2, $3, $4) + ON CONFLICT (email) DO UPDATE SET + system_prompt = EXCLUDED.system_prompt, + save_history = EXCLUDED.save_history, + key_jwk = COALESCE(EXCLUDED.key_jwk, user_settings.key_jwk)`, + [session.user.email, parsed.value.systemPrompt, parsed.value.saveHistory, parsed.value.keyJwk], + ); + logger.info({ + requestId, + user: session.user.email, + durationMs: Date.now() - start, + saveHistory: parsed.value.saveHistory, + hasKey: !!parsed.value.keyJwk, + }, 'settings.put'); + return new Response(null, { + status: 204, + headers: { 'X-Request-Id': requestId, 'Cache-Control': PRIVATE_NO_STORE }, + }); + } catch (error) { + logger.error({ requestId, durationMs: Date.now() - start, error: String(error).slice(0, 200) }, 'settings.put.failed'); + return jsonResponse({ error: 'Internal Server Error' }, { status: 500 }, { requestId, cacheControl: PRIVATE_NO_STORE }); + } } diff --git a/app/api/shares/[id]/route.ts b/app/api/shares/[id]/route.ts index d4d481f..65b69f2 100644 --- a/app/api/shares/[id]/route.ts +++ b/app/api/shares/[id]/route.ts @@ -1,15 +1,33 @@ -import { query } from '@/lib/db'; import logger from '@/lib/log'; +import { textResponse, getRequestId } from '@/lib/request'; +import { getSharedChat } from '@/lib/share'; +import { isShareId } from '@/lib/validation'; -export async function GET(_req: Request, { params }: { params: Promise<{ id: string }> }) { +const SHARE_CACHE = 'public, max-age=300, stale-while-revalidate=3600'; + +export async function GET(req: Request, { params }: { params: Promise<{ id: string }> }) { + const requestId = getRequestId(req); const start = Date.now(); - const { id } = await params; - const result = await query( - 'SELECT id, model, system_prompt, messages, created_at FROM shared_chats WHERE id = $1', - [id], - ); - const found = result.rows.length > 0; - logger.info({ id, found, durationMs: Date.now() - start }, 'share.get'); - if (!found) return new Response('Not found', { status: 404 }); - return Response.json(result.rows[0]); + + try { + const { id } = await params; + if (!isShareId(id)) { + logger.warn({ requestId, id, durationMs: Date.now() - start }, 'share.get.invalid'); + return textResponse('Not found', { status: 404 }, { requestId, cacheControl: SHARE_CACHE }); + } + + const share = await getSharedChat(id); + logger.info({ requestId, id, found: !!share, durationMs: Date.now() - start }, 'share.get'); + if (!share) return textResponse('Not found', { status: 404 }, { requestId, cacheControl: SHARE_CACHE }); + return textResponse(JSON.stringify(share), { + status: 200, + headers: { 'Content-Type': 'application/json; charset=utf-8' }, + }, { + requestId, + cacheControl: SHARE_CACHE, + }); + } catch (error) { + logger.error({ requestId, durationMs: Date.now() - start, error: String(error).slice(0, 200) }, 'share.get.failed'); + return textResponse('Internal Server Error', { status: 500 }, { requestId, cacheControl: SHARE_CACHE }); + } } diff --git a/app/api/shares/route.ts b/app/api/shares/route.ts index 500930a..35177f0 100644 --- a/app/api/shares/route.ts +++ b/app/api/shares/route.ts @@ -2,38 +2,77 @@ import { NextRequest } from 'next/server'; import { auth } from '@/auth'; import { query } from '@/lib/db'; import logger from '@/lib/log'; +import { getRequestId, jsonResponse, PRIVATE_NO_STORE, readContentLength } from '@/lib/request'; +import { LIMITS, validateShareRequest } from '@/lib/validation'; export async function POST(req: NextRequest) { + const requestId = getRequestId(req); const start = Date.now(); - const session = await auth(); - if (!session?.user?.email) { - logger.warn({ durationMs: Date.now() - start }, 'share.create.unauthenticated'); - return Response.json({ error: 'Unauthorized' }, { status: 401 }); - } - const body = await req.json(); - const bodyBytes = JSON.stringify(body).length; - if (bodyBytes > 500_000) { - logger.warn({ user: session.user.email, bodyBytes, durationMs: Date.now() - start }, 'share.create.too_large'); - return Response.json( - { error: 'Chat too large to share. Try removing image attachments first.' }, - { status: 413 }, - ); - } - const { messages, model, systemPrompt } = body; - if (!Array.isArray(messages) || messages.length === 0) { - return Response.json({ error: 'messages must be a non-empty array' }, { status: 400 }); - } - if (typeof model !== 'string' || !model) { - return Response.json({ error: 'model is required' }, { status: 400 }); - } - const id = crypto.randomUUID().replace(/-/g, '').slice(0, 16); + try { + const session = await auth(); + if (!session?.user?.email) { + logger.warn({ requestId, durationMs: Date.now() - start }, 'share.create.unauthenticated'); + return jsonResponse({ error: 'Unauthorized' }, { status: 401 }, { requestId, cacheControl: PRIVATE_NO_STORE }); + } + + const contentLength = readContentLength(req); + if (contentLength !== null && contentLength > LIMITS.shareBodyBytes) { + logger.warn({ requestId, user: session.user.email, contentLength, durationMs: Date.now() - start }, 'share.create.too_large'); + return jsonResponse( + { error: 'Chat too large to share. Try removing image attachments first.' }, + { status: 413 }, + { requestId, cacheControl: PRIVATE_NO_STORE }, + ); + } - await query( - 'INSERT INTO shared_chats (id, created_by, model, system_prompt, messages) VALUES ($1, $2, $3, $4, $5)', - [id, session.user.email, model, typeof systemPrompt === 'string' ? systemPrompt || null : null, JSON.stringify(messages)], - ); + const body = await req.json(); + const bodyBytes = JSON.stringify(body).length; + if (bodyBytes > LIMITS.shareBodyBytes) { + logger.warn({ requestId, user: session.user.email, bodyBytes, durationMs: Date.now() - start }, 'share.create.too_large'); + return jsonResponse( + { error: 'Chat too large to share. Try removing image attachments first.' }, + { status: 413 }, + { requestId, cacheControl: PRIVATE_NO_STORE }, + ); + } - logger.info({ user: session.user.email, id, model, msgs: messages.length, bodyBytes, durationMs: Date.now() - start }, 'share.create'); - return Response.json({ id }); + const parsed = validateShareRequest(body); + if (!parsed.ok) { + logger.warn({ requestId, user: session.user.email, durationMs: Date.now() - start, error: parsed.error }, 'share.create.invalid'); + return jsonResponse({ error: parsed.error }, { status: parsed.status }, { requestId, cacheControl: PRIVATE_NO_STORE }); + } + + let id = ''; + for (let attempt = 0; attempt < 3; attempt++) { + id = crypto.randomUUID().replace(/-/g, ''); + const result = await query( + `INSERT INTO shared_chats (id, created_by, model, system_prompt, messages) + VALUES ($1, $2, $3, $4, $5) + ON CONFLICT DO NOTHING`, + [id, session.user.email, parsed.value.model, parsed.value.systemPrompt || null, JSON.stringify(parsed.value.messages)], + ); + if ((result.rowCount ?? 0) > 0) break; + id = ''; + } + + if (!id) { + logger.error({ requestId, user: session.user.email, durationMs: Date.now() - start }, 'share.create.collision'); + return jsonResponse({ error: 'Could not create share' }, { status: 503 }, { requestId, cacheControl: PRIVATE_NO_STORE }); + } + + logger.info({ + requestId, + user: session.user.email, + id, + model: parsed.value.model, + msgs: parsed.value.messages.length, + bodyBytes, + durationMs: Date.now() - start, + }, 'share.create'); + return jsonResponse({ id }, {}, { requestId, cacheControl: PRIVATE_NO_STORE }); + } catch (error) { + logger.error({ requestId, durationMs: Date.now() - start, error: String(error).slice(0, 200) }, 'share.create.failed'); + return jsonResponse({ error: 'Internal Server Error' }, { status: 500 }, { requestId, cacheControl: PRIVATE_NO_STORE }); + } } diff --git a/app/error.tsx b/app/error.tsx index 4c57dbe..9b17aab 100644 --- a/app/error.tsx +++ b/app/error.tsx @@ -1,11 +1,32 @@ 'use client'; -export default function Error({ error, reset }: { error: Error; reset: () => void }) { +import { useEffect } from 'react'; + +export default function Error({ error, reset }: { error: Error & { digest?: string }; reset: () => void }) { + useEffect(() => { + const body = JSON.stringify({ + event: 'app.error', + level: 'error', + details: { + name: error.name, + digest: error.digest ?? null, + }, + }); + const blob = new Blob([body], { type: 'application/json' }); + if (navigator.sendBeacon) navigator.sendBeacon('/api/client-events', blob); + else fetch('/api/client-events', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body, + keepalive: true, + }).catch(() => {}); + }, [error]); + return (
GIPPIDY something went wrong - {error.message} + {error.digest && ref: {error.digest}}
); diff --git a/app/globals.css b/app/globals.css index 6f8bb71..ca2d4ed 100644 --- a/app/globals.css +++ b/app/globals.css @@ -412,6 +412,10 @@ header select option { min-width: 0; } +.stream-content { + white-space: pre-wrap; +} + .message.user .content { color: var(--fg); } .message.assistant .content { color: #e0e0e0; } diff --git a/app/login/page.tsx b/app/login/page.tsx index 1105711..77ab3fc 100644 --- a/app/login/page.tsx +++ b/app/login/page.tsx @@ -1,16 +1,22 @@ -import { signIn } from '@/auth'; +import { googleAuthConfigured, signIn } from '@/auth'; export default function LoginPage() { return (
GIPPIDY
-
{ - 'use server'; - await signIn('google', { redirectTo: '/' }); - }}> - -
+ {googleAuthConfigured ? ( +
{ + 'use server'; + await signIn('google', { redirectTo: '/' }); + }}> + +
+ ) : ( +
+ Google OAuth is not configured. +
+ )}
); diff --git a/app/page.tsx b/app/page.tsx index 4339d04..e691d7e 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -2,10 +2,12 @@ import { useState, useEffect, useRef } from 'react'; import { signOut } from 'next-auth/react'; +import RenderedMarkdown from './rendered-markdown'; import { renderMarkdown } from '@/lib/markdown'; import { getOrCreateKey, encrypt, decrypt } from '@/lib/crypto'; import { MODELS } from '@/lib/models'; import type { Role, Image, Pdf, Message } from '@/lib/chat'; +import { LIMITS } from '@/lib/validation'; type PendingFile = { name: string; content: string }; // text/code files type PendingPdf = Pdf; @@ -15,6 +17,18 @@ const KEY_WARNED = 'gippidy-key-warned'; type HistoryItem = { id: string; title: string; updatedAt: string; messages: Message[]; model: string; systemPrompt: string }; +function withRenderedHtml(message: Message): Message { + return message.content ? { ...message, html: renderMarkdown(message.content) } : message; +} + +function withRenderedMessages(messages: Message[]): Message[] { + return messages.map(withRenderedHtml); +} + +function stripMessageHtml(messages: Message[]): Array> { + return messages.map(({ html: _html, ...message }) => message); +} + function parseStreamError(status: number, body: string): string { if (status === 429) return '[RATE LIMITED] Wait a moment and try again.'; if (status === 401 || status === 403) return '[AUTH ERROR] API key issue — contact the admin.'; @@ -107,6 +121,75 @@ export default function Home() { // loadHistory awaits this so it never races against the settings fetch. const keyResolveRef = useRef<(() => void) | null>(null); const keyReadyRef = useRef>(new Promise(resolve => { keyResolveRef.current = resolve; })); + const systemPromptRef = useRef(systemPrompt); + const saveHistoryRef = useRef(saveHistory); + + useEffect(() => { + systemPromptRef.current = systemPrompt; + }, [systemPrompt]); + + useEffect(() => { + saveHistoryRef.current = saveHistory; + }, [saveHistory]); + + useEffect(() => () => { + if (saveSettingsTimer.current) clearTimeout(saveSettingsTimer.current); + }, []); + + const logClientEvent = ( + event: string, + level: 'info' | 'warn' | 'error', + details: Record = {}, + ) => { + const body = JSON.stringify({ event, level, details }); + if (body.length > LIMITS.clientEventBodyBytes) return; + const blob = new Blob([body], { type: 'application/json' }); + if (navigator.sendBeacon) { + navigator.sendBeacon('/api/client-events', blob); + return; + } + fetch('/api/client-events', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body, + keepalive: true, + }).catch(() => {}); + }; + + const persistSettings = (overrides: { systemPrompt?: string; saveHistory?: boolean; keyJwk?: string | null }, immediate = false) => { + const run = () => { + const body = JSON.stringify({ + systemPrompt: overrides.systemPrompt ?? systemPromptRef.current, + saveHistory: overrides.saveHistory ?? saveHistoryRef.current, + ...(overrides.keyJwk !== undefined ? { keyJwk: overrides.keyJwk } : {}), + }); + fetch('/api/settings', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body, + }) + .then(res => { + if (!res.ok) throw new Error(`settings_put_${res.status}`); + }) + .catch(() => { + logClientEvent('settings.persist_failed', 'warn', { + hasKey: overrides.keyJwk !== undefined ? Boolean(overrides.keyJwk) : null, + }); + }); + }; + + if (saveSettingsTimer.current) clearTimeout(saveSettingsTimer.current); + if (immediate) run(); + else saveSettingsTimer.current = setTimeout(run, 600); + }; + + const rejectLargeFiles = (files: File[], maxBytes: number, kind: 'image' | 'pdf' | 'text') => + files.filter(file => { + if (file.size <= maxBytes) return true; + alert(`${file.name} is too large to attach.`); + logClientEvent('attachment.rejected', 'warn', { kind, size: file.size }); + return false; + }); useEffect(() => { const saved = localStorage.getItem(MODEL_KEY); @@ -114,37 +197,41 @@ export default function Home() { const ac = new AbortController(); fetch('/api/settings', { signal: ac.signal }) - .then(r => r.json()) + .then(async r => { + if (!r.ok) throw new Error(`settings_get_${r.status}`); + return r.json(); + }) .then(async ({ systemPrompt, saveHistory: sh, keyJwk }) => { - if (systemPrompt) setSystemPrompt(systemPrompt); - if (sh) setSaveHistory(true); + setSystemPrompt(systemPrompt ?? ''); + setSaveHistory(Boolean(sh)); // Load or create the encryption key (shared across all deployments via DB) const { key, jwk } = await getOrCreateKey(keyJwk ?? null); cryptoKeyRef.current = key; keyResolveRef.current?.(); if (jwk) { // New key (or migrated from localStorage) — save to server so all deployments use it - fetch('/api/settings', { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ systemPrompt: systemPrompt ?? '', saveHistory: sh ?? false, keyJwk: jwk }), - }).catch(() => {}); + persistSettings({ systemPrompt: systemPrompt ?? '', saveHistory: sh ?? false, keyJwk: jwk }, true); } }) - .catch(e => { if (e.name !== 'AbortError') keyResolveRef.current?.(); }); + .catch(e => { + if (e.name !== 'AbortError') { + logClientEvent('settings.load_failed', 'error'); + keyResolveRef.current?.(); + } + }); const fork = localStorage.getItem('gippidy-fork'); localStorage.removeItem('gippidy-fork'); if (fork) { try { const { messages: m, model: mo, systemPrompt: sp } = JSON.parse(fork); - setMessages(m); + setMessages(withRenderedMessages(m)); setModel(mo); localStorage.setItem(MODEL_KEY, mo); - if (sp) { setSystemPrompt(sp); } + setSystemPrompt(sp ?? ''); chatIdRef.current = null; // fork always starts a new history entry } catch { - // Corrupt fork data — silently discard + logClientEvent('fork.parse_failed', 'warn'); } } return () => ac.abort(); @@ -192,10 +279,14 @@ export default function Home() { const res = await fetch('/api/shares', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ messages, model, systemPrompt }), + body: JSON.stringify({ messages: stripMessageHtml(messages), model, systemPrompt }), }); if (!res.ok) { const { error } = await res.json(); + logClientEvent('share.create_failed', 'warn', { + status: res.status, + requestId: res.headers.get('x-request-id'), + }); setShareLabel('[TOO LARGE]'); setTimeout(() => alert(error), 0); } else { @@ -204,6 +295,7 @@ export default function Home() { setShareLabel('[COPIED!]'); } } catch { + logClientEvent('share.create_failed', 'error'); setShareLabel('[ERROR]'); } setTimeout(() => setShareLabel('[SHARE]'), 3000); @@ -216,14 +308,7 @@ export default function Home() { const handleSystemChange = (s: string) => { setSystemPrompt(s); - if (saveSettingsTimer.current) clearTimeout(saveSettingsTimer.current); - saveSettingsTimer.current = setTimeout(() => { - fetch('/api/settings', { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ systemPrompt: s, saveHistory }), - }); - }, 600); + persistSettings({ systemPrompt: s }); }; const handleToggleSaveHistory = (val: boolean) => { @@ -232,11 +317,7 @@ export default function Home() { alert('Your encryption key is stored in this browser only.\nClearing browser data will make saved chats permanently unreadable.'); localStorage.setItem(KEY_WARNED, '1'); } - fetch('/api/settings', { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ systemPrompt, saveHistory: val }), - }); + persistSettings({ saveHistory: val }, true); }; const loadHistory = async () => { @@ -249,25 +330,32 @@ export default function Home() { await Promise.race([keyReadyRef.current, keyTimeout]); const key = cryptoKeyRef.current; if (!key) { - console.error('[history] key unavailable after wait — settings may have failed or JWK is corrupt'); + logClientEvent('history.key_unavailable', 'error'); return; } const res = await fetch('/api/history'); - if (!res.ok) { console.error('[history] fetch failed', res.status, await res.text()); return; } + if (!res.ok) { + logClientEvent('history.fetch_failed', 'error', { + status: res.status, + requestId: res.headers.get('x-request-id'), + }); + return; + } const rows = await res.json() as { id: string; iv: string; ciphertext: string; updated_at: string }[]; + const ordered = Array(rows.length).fill(null); let failed = 0; await Promise.allSettled(rows.map(async (row, i) => { try { const data = await decrypt<{ messages: Message[]; model: string; systemPrompt: string; title: string }>(key, row.iv, row.ciphertext); - setHistoryItems(prev => [...prev, { id: row.id, updatedAt: row.updated_at, ...data }]); - } catch (e) { + ordered[i] = { id: row.id, updatedAt: row.updated_at, ...data, messages: withRenderedMessages(data.messages) }; + setHistoryItems(ordered.filter((item): item is HistoryItem => Boolean(item))); + } catch { failed++; - console.warn(`[history] decrypt failed for row ${i + 1}/${rows.length} (${row.id}):`, e); } })); - if (failed) console.warn(`[history] ${failed}/${rows.length} rows failed to decrypt`); - } catch (e) { - console.error('[history] loadHistory error', e); + if (failed) logClientEvent('history.decrypt_failed', 'warn', { failed, total: rows.length }); + } catch { + logClientEvent('history.load_failed', 'error'); setHistoryItems([]); } finally { setHistoryLoading(false); @@ -281,16 +369,23 @@ export default function Home() { }; const handleLoadChat = (item: HistoryItem) => { - setMessages(item.messages); + setMessages(withRenderedMessages(item.messages)); setModel(item.model); localStorage.setItem(MODEL_KEY, item.model); - if (item.systemPrompt) setSystemPrompt(item.systemPrompt); + setSystemPrompt(item.systemPrompt ?? ''); chatIdRef.current = item.id; setShowHistory(false); }; const handleDeleteChat = async (id: string) => { - await fetch(`/api/history/${id}`, { method: 'DELETE' }); + const res = await fetch(`/api/history/${id}`, { method: 'DELETE' }); + if (!res.ok) { + logClientEvent('history.delete_failed', 'warn', { + status: res.status, + requestId: res.headers.get('x-request-id'), + }); + return; + } setHistoryItems(items => items.filter(i => i.id !== id)); if (chatIdRef.current === id) chatIdRef.current = null; }; @@ -310,14 +405,14 @@ export default function Home() { if (imageFiles.length === 0) return; e.preventDefault(); - readImageFiles(imageFiles, img => setPendingImages(imgs => [...imgs, img])); + readImageFiles(rejectLargeFiles(imageFiles, LIMITS.maxImageBytes, 'image'), img => setPendingImages(imgs => [...imgs, img])); }; const handleFileSelect = (e: React.ChangeEvent) => { const all = Array.from(e.target.files ?? []); - const imgs = all.filter(f => f.type.startsWith('image/')); - const pdfs = all.filter(f => f.type === 'application/pdf'); - const texts = all.filter(f => !f.type.startsWith('image/') && f.type !== 'application/pdf'); + const imgs = rejectLargeFiles(all.filter(f => f.type.startsWith('image/')), LIMITS.maxImageBytes, 'image'); + const pdfs = rejectLargeFiles(all.filter(f => f.type === 'application/pdf'), LIMITS.maxPdfBytes, 'pdf'); + const texts = rejectLargeFiles(all.filter(f => !f.type.startsWith('image/') && f.type !== 'application/pdf'), LIMITS.maxTextFileBytes, 'text'); readImageFiles(imgs, img => setPendingImages(imgs => [...imgs, img])); readPdfFiles(pdfs, pdf => setPendingPdfs(ps => [...ps, pdf])); readTextFiles(texts, f => setPendingFiles(fs => [...fs, f])); @@ -327,9 +422,9 @@ export default function Home() { const handleDrop = (e: React.DragEvent) => { e.preventDefault(); const all = Array.from(e.dataTransfer.files); - const imgs = all.filter(f => f.type.startsWith('image/')); - const pdfs = all.filter(f => f.type === 'application/pdf'); - const texts = all.filter(f => !f.type.startsWith('image/') && f.type !== 'application/pdf'); + const imgs = rejectLargeFiles(all.filter(f => f.type.startsWith('image/')), LIMITS.maxImageBytes, 'image'); + const pdfs = rejectLargeFiles(all.filter(f => f.type === 'application/pdf'), LIMITS.maxPdfBytes, 'pdf'); + const texts = rejectLargeFiles(all.filter(f => !f.type.startsWith('image/') && f.type !== 'application/pdf'), LIMITS.maxTextFileBytes, 'text'); readImageFiles(imgs, img => setPendingImages(imgs => [...imgs, img])); readPdfFiles(pdfs, pdf => setPendingPdfs(ps => [...ps, pdf])); readTextFiles(texts, f => setPendingFiles(fs => [...fs, f])); @@ -337,6 +432,9 @@ export default function Home() { const doStream = async (msgs: Message[], useWebSearch = false) => { const SMOOTH_RATE = 3; + const requestModel = model; + const requestSystemPrompt = systemPrompt; + const requestMessages = stripMessageHtml(msgs); pinnedRef.current = true; setShowScrollBtn(false); @@ -351,35 +449,48 @@ export default function Home() { webSearchPhaseRef.current = phase; setWebSearchPhase(phase); + abortControllerRef.current?.abort(); const controller = new AbortController(); abortControllerRef.current = controller; const finalize = (text: string) => { - const finalMsgs: Message[] = [...msgs, { role: 'assistant', content: text, html: renderMarkdown(text) }]; + const finalMsgs: Message[] = [...msgs, withRenderedHtml({ role: 'assistant', content: text })]; setMessages(finalMsgs); setStreamingContent(''); setStreaming(false); textareaRef.current?.focus(); webSearchPhaseRef.current = 'off'; setWebSearchPhase('off'); - if (saveHistory) { + if (saveHistoryRef.current) { (async () => { try { const key = cryptoKeyRef.current; - if (!key) return; + if (!key) { + logClientEvent('history.save_skipped_no_key', 'warn'); + return; + } const title = finalMsgs.find(m => m.role === 'user')?.content.slice(0, 60) ?? 'Untitled'; - const toSave = finalMsgs.map(({ html: _, ...m }) => m); - const { iv, ciphertext } = await encrypt(key, { messages: toSave, model, systemPrompt, title }); + const toSave = stripMessageHtml(finalMsgs); + const { iv, ciphertext } = await encrypt(key, { messages: toSave, model: requestModel, systemPrompt: requestSystemPrompt, title }); const res = await fetch('/api/history', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id: chatIdRef.current, iv, ciphertext }), }); + if (!res.ok) { + logClientEvent('history.save_failed', 'warn', { + status: res.status, + requestId: res.headers.get('x-request-id'), + }); + return; + } const { id } = await res.json(); chatIdRef.current = id; setSavedFlash(true); setTimeout(() => setSavedFlash(false), 2000); - } catch { /* non-critical */ } + } catch { + logClientEvent('history.save_failed', 'error'); + } })(); } }; @@ -413,16 +524,20 @@ export default function Home() { const res = await fetch('/api/chat', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ messages: msgs.map(({ html: _, ...m }) => m), model, systemPrompt, webSearch: useWebSearch }), + body: JSON.stringify({ messages: requestMessages, model: requestModel, systemPrompt: requestSystemPrompt, webSearch: useWebSearch }), signal: controller.signal, }); + const requestId = res.headers.get('x-request-id'); if (!res.ok) { cancelTicker(); const body = await res.text(); - setMessages(m => [...m, { role: 'assistant', content: parseStreamError(res.status, body) }]); + logClientEvent('chat.request_failed', 'warn', { status: res.status, requestId }); + setMessages(m => [...m, withRenderedHtml({ role: 'assistant', content: parseStreamError(res.status, body) })]); setStreamingContent(''); setStreaming(false); + setWebSearchPhase('off'); + webSearchPhaseRef.current = 'off'; textareaRef.current?.focus(); return; } @@ -457,11 +572,12 @@ export default function Home() { if (partial) finalize(partial); // preserve whatever arrived before STOP return; } + logClientEvent('chat.stream_failed', 'error'); const errMsg = `[ERROR] ${String(err)}`; if (partial) { finalize(partial + '\n\n' + errMsg); } else { - setMessages(m => [...m, { role: 'assistant', content: errMsg }]); + setMessages(m => [...m, withRenderedHtml({ role: 'assistant', content: errMsg })]); } } }; @@ -484,12 +600,12 @@ export default function Home() { .join('\n\n'); const fullContent = [trimmed, fileAttachments].filter(Boolean).join('\n\n'); - const userMessage: Message = { + const userMessage = withRenderedHtml({ role: 'user', content: fullContent, ...(pendingImages.length > 0 ? { images: pendingImages } : {}), ...(pendingPdfs.length > 0 ? { pdfs: pendingPdfs } : {}), - }; + }); const currentWebSearch = webSearch; setInput(''); setPendingImages([]); @@ -501,7 +617,7 @@ export default function Home() { await doStream([...messages, userMessage], currentWebSearch); }; - const handleRetry = () => doStream(messages.slice(0, -1)); + const handleRetry = () => doStream(withRenderedMessages(messages.slice(0, -1))); const handleKeyDown = (e: React.KeyboardEvent) => { if (e.key === 'Enter' && !e.shiftKey) { @@ -544,9 +660,9 @@ export default function Home() { const confirmEdit = () => { if (editingIndex === null) return; - const edited: Message = { ...messages[editingIndex], content: editingContent }; + const edited = withRenderedHtml({ role: messages[editingIndex].role, content: editingContent }); setEditingIndex(null); - doStream([...messages.slice(0, editingIndex), edited]); + doStream(withRenderedMessages([...messages.slice(0, editingIndex), edited])); }; const handleExport = () => { @@ -692,8 +808,8 @@ export default function Home() { ) : ( msg.role === 'assistant' - ?
- : msg.content &&
+ ? + : msg.content && )} {editingIndex === null && (
@@ -717,7 +833,7 @@ export default function Home() {
# {streamingContent - ?
+ ?
{streamingContent}
: {!connected ? ▋ diff --git a/app/rendered-markdown.tsx b/app/rendered-markdown.tsx new file mode 100644 index 0000000..f320fe9 --- /dev/null +++ b/app/rendered-markdown.tsx @@ -0,0 +1,30 @@ +'use client'; + +import { useMemo } from 'react'; +import { renderMarkdown } from '@/lib/markdown'; + +export default function RenderedMarkdown({ + text, + html, + className, +}: { + text: string; + html?: string; + className?: string; +}) { + const rendered = useMemo(() => html ?? renderMarkdown(text), [html, text]); + + const handleClick = async (event: React.MouseEvent) => { + const button = (event.target as HTMLElement).closest('button[data-copy-code]'); + if (!button) return; + const code = button.nextElementSibling?.querySelector('code')?.textContent ?? ''; + if (!code) return; + await navigator.clipboard.writeText(code); + button.textContent = '[COPIED!]'; + window.setTimeout(() => { + if (button.isConnected) button.textContent = '[COPY]'; + }, 2000); + }; + + return
; +} diff --git a/app/share/[id]/opengraph-image.tsx b/app/share/[id]/opengraph-image.tsx index b60287c..d4e59ac 100644 --- a/app/share/[id]/opengraph-image.tsx +++ b/app/share/[id]/opengraph-image.tsx @@ -1,12 +1,11 @@ import { ImageResponse } from 'next/og'; -import { query } from '@/lib/db'; import logger from '@/lib/log'; +import { getSharedChat } from '@/lib/share'; +import { isShareId } from '@/lib/validation'; export const size = { width: 1200, height: 630 }; export const contentType = 'image/png'; -type Message = { role: string; content: string }; - const fallback = (label: string) => new ImageResponse(
{label} @@ -16,60 +15,57 @@ const fallback = (label: string) => new ImageResponse( export default async function Image({ params }: { params: Promise<{ id: string }> }) { const { id } = await params; + if (!isShareId(id)) return fallback('not found'); - let result; try { - result = await query('SELECT model, messages, created_at FROM shared_chats WHERE id = $1', [id]); - } catch (err) { - logger.error({ id, err: String(err) }, 'og.image db_error'); - return fallback('unavailable'); - } + const share = await getSharedChat(id); + if (!share) return fallback('not found'); - if (result.rows.length === 0) { - return fallback('not found'); - } + const { model, messages, created_at } = share; + const userMessages = messages.filter(m => m.role === 'user'); + const firstMsg = userMessages[0]?.content ?? ''; + const preview = firstMsg.length > 160 ? firstMsg.slice(0, 160) + '…' : firstMsg; + const date = new Date(created_at).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }); + const msgCount = messages.length; - const { model, messages, created_at }: { model: string; messages: Message[]; created_at: string } = result.rows[0]; - const userMessages = messages.filter(m => m.role === 'user'); - const firstMsg = userMessages[0]?.content ?? ''; - const preview = firstMsg.length > 160 ? firstMsg.slice(0, 160) + '…' : firstMsg; - const date = new Date(created_at).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }); - const msgCount = messages.length; + return new ImageResponse( +
+ {/* header */} +
+ GIPPIDY + {model} · {date} +
- return new ImageResponse( -
- {/* header */} -
- GIPPIDY - {model} · {date} -
+ {/* message preview */} + {preview ? ( +
+ > + {preview} +
+ ) : ( +
+ )} - {/* message preview */} - {preview ? ( -
- > - {preview} + {/* footer */} +
+ {`${msgCount} message${msgCount !== 1 ? 's' : ''} · gippidy.chat`}
- ) : ( -
- )} - - {/* footer */} -
- {`${msgCount} message${msgCount !== 1 ? 's' : ''} · gippidy.chat`} -
-
, - size, - ); +
, + size, + ); + } catch (err) { + logger.error({ id, err: String(err) }, 'og.image db_error'); + return fallback('unavailable'); + } } diff --git a/app/share/[id]/page.tsx b/app/share/[id]/page.tsx index ed2fe04..a42f4bf 100644 --- a/app/share/[id]/page.tsx +++ b/app/share/[id]/page.tsx @@ -1,19 +1,21 @@ import { notFound } from 'next/navigation'; import Link from 'next/link'; import type { Metadata } from 'next'; -import { auth, signIn } from '@/auth'; -import { query } from '@/lib/db'; -import { renderMarkdown } from '@/lib/markdown'; +import { auth, googleAuthConfigured, signIn } from '@/auth'; +import RenderedMarkdown from '@/app/rendered-markdown'; import ForkButton from './fork-button'; import logger from '@/lib/log'; import type { Message } from '@/lib/chat'; +import { getSharedChat } from '@/lib/share'; +import { isShareId } from '@/lib/validation'; export async function generateMetadata({ params }: { params: Promise<{ id: string }> }): Promise { const { id } = await params; - const result = await query('SELECT model, messages FROM shared_chats WHERE id = $1', [id]); - if (result.rows.length === 0) return { title: 'Not found — GIPPIDY' }; + if (!isShareId(id)) return { title: 'Not found — GIPPIDY' }; + const share = await getSharedChat(id); + if (!share) return { title: 'Not found — GIPPIDY' }; - const { model, messages }: { model: string; messages: Message[] } = result.rows[0]; + const { model, messages } = share; const firstUserMsg = messages.find(m => m.role === 'user')?.content ?? ''; const preview = firstUserMsg.length > 140 ? firstUserMsg.slice(0, 140) + '…' : firstUserMsg; const title = `Shared chat · ${model}`; @@ -31,17 +33,17 @@ export async function generateMetadata({ params }: { params: Promise<{ id: strin export default async function SharePage({ params }: { params: Promise<{ id: string }> }) { const { id } = await params; - const [sessionResult, dbResult] = await Promise.all([ + if (!isShareId(id)) notFound(); + const [sessionResult, share] = await Promise.all([ auth(), - query('SELECT * FROM shared_chats WHERE id = $1', [id]), + getSharedChat(id), ]); - if (dbResult.rows.length === 0) { + if (!share) { logger.warn({ id }, 'share.view not_found'); notFound(); } - const share = dbResult.rows[0]; logger.info({ id, model: share.model, msgs: (share.messages as unknown[]).length, authed: !!sessionResult }, 'share.view'); const messages: Message[] = share.messages; const date = new Date(share.created_at).toLocaleDateString('en-US', { @@ -57,13 +59,15 @@ export default async function SharePage({ params }: { params: Promise<{ id: stri
{sessionResult && [BACK]} {sessionResult - ? - :
{ - 'use server'; - await signIn('google', { redirectTo: `/share/${id}` }); - }}> - -
+ ? + : googleAuthConfigured + ?
{ + 'use server'; + await signIn('google', { redirectTo: `/share/${id}` }); + }}> + +
+ : sign-in unavailable }
@@ -83,8 +87,8 @@ export default async function SharePage({ params }: { params: Promise<{ id: stri
)} {msg.role === 'assistant' - ?
- : msg.content && {msg.content} + ? + : msg.content && }
diff --git a/auth.ts b/auth.ts index 93e5eb2..d32b487 100644 --- a/auth.ts +++ b/auth.ts @@ -2,12 +2,18 @@ import NextAuth from 'next-auth'; import Google from 'next-auth/providers/google'; import { authConfig } from './auth.config'; +const googleId = process.env.AUTH_GOOGLE_ID ?? process.env.GOOGLE_ID; +const googleSecret = process.env.AUTH_GOOGLE_SECRET ?? process.env.GOOGLE_SECRET; +export const googleAuthConfigured = Boolean(googleId && googleSecret); + export const { handlers, auth, signIn, signOut } = NextAuth({ ...authConfig, - providers: [ - Google({ - clientId: process.env.GOOGLE_ID!, - clientSecret: process.env.GOOGLE_SECRET!, - }), - ], + providers: googleAuthConfigured + ? [ + Google({ + clientId: googleId!, + clientSecret: googleSecret!, + }), + ] + : [], }); diff --git a/lib/chat.ts b/lib/chat.ts index aa2410e..8adbfc2 100644 --- a/lib/chat.ts +++ b/lib/chat.ts @@ -11,7 +11,13 @@ export function getProvider(model: string): Provider { } export function toOpenAIMessages(messages: Message[], systemPrompt?: string) { - const result = messages.map(m => { + const result: Array<{ + role: Role | 'system'; + content: string | Array< + | { type: 'image_url'; image_url: { url: string } } + | { type: 'text'; text: string } + >; + }> = messages.map(m => { // PDFs not supported by chat completions; surface as a text note const pdfNote = m.pdfs?.map(p => `[PDF attached: ${p.name} — this model cannot read PDFs]`).join('\n') ?? ''; const fullContent = [pdfNote, m.content].filter(Boolean).join('\n'); @@ -21,10 +27,10 @@ export function toOpenAIMessages(messages: Message[], systemPrompt?: string) { role: m.role, content: [ ...m.images.map(img => ({ - type: 'image_url', + type: 'image_url' as const, image_url: { url: `data:${img.mimeType};base64,${img.data}` }, })), - ...(fullContent ? [{ type: 'text', text: fullContent }] : []), + ...(fullContent ? [{ type: 'text' as const, text: fullContent }] : []), ], }; }); diff --git a/lib/crypto.ts b/lib/crypto.ts index 30c16eb..0a3d99a 100644 --- a/lib/crypto.ts +++ b/lib/crypto.ts @@ -24,7 +24,7 @@ export async function getOrCreateKey( } // Migration: if the old per-browser localStorage key exists, promote it to server - const localJwk = typeof localStorage !== 'undefined' ? localStorage.getItem('gippidy-key') : null; + const localJwk = (() => { try { return localStorage.getItem('gippidy-key'); } catch { return null; } })(); if (localJwk) { const key = await crypto.subtle.importKey('jwk', JSON.parse(localJwk), ALG, true, ['encrypt', 'decrypt']); return { key, jwk: localJwk }; // caller saves this to server diff --git a/lib/db.ts b/lib/db.ts index 7f88bd7..b52c4a6 100644 --- a/lib/db.ts +++ b/lib/db.ts @@ -1,11 +1,28 @@ import { Pool } from 'pg'; -const connectionString = process.env.DATABASE_URL?.replace(/sslmode=\w+/, 'sslmode=verify-full'); +let pool: Pool | null = null; -const pool = new Pool({ - connectionString, - max: 3, - idleTimeoutMillis: 10_000, -}); +function getConnectionString(): string { + const databaseUrl = process.env.DATABASE_URL; + if (!databaseUrl) throw new Error('DATABASE_URL is required'); + return databaseUrl.includes('sslmode=') + ? databaseUrl.replace(/sslmode=[^&]+/, 'sslmode=verify-full') + : `${databaseUrl}${databaseUrl.includes('?') ? '&' : '?'}sslmode=verify-full`; +} -export const query = async (sql: string, params?: unknown[]) => pool.query(sql, params); \ No newline at end of file +function getPool(): Pool { + if (pool) return pool; + pool = new Pool({ + connectionString: getConnectionString(), + max: Number(process.env.PG_POOL_MAX ?? 5), + idleTimeoutMillis: 10_000, + connectionTimeoutMillis: 5_000, + query_timeout: 15_000, + statement_timeout: 15_000, + keepAlive: true, + application_name: 'gippidy', + }); + return pool; +} + +export const query = async (sql: string, params?: unknown[]) => getPool().query(sql, params); diff --git a/lib/log.ts b/lib/log.ts index 00ace59..01fd881 100644 --- a/lib/log.ts +++ b/lib/log.ts @@ -1,5 +1,28 @@ import pino from 'pino'; -const logger = pino({ level: process.env.LOG_LEVEL ?? 'info' }); +const logger = pino({ + level: process.env.LOG_LEVEL ?? 'info', + timestamp: pino.stdTimeFunctions.isoTime, + redact: { + paths: [ + 'authorization', + 'cookie', + 'headers.authorization', + 'headers.cookie', + 'apiKey', + 'messages', + '*.messages', + 'systemPrompt', + '*.systemPrompt', + 'keyJwk', + '*.keyJwk', + 'iv', + '*.iv', + 'ciphertext', + '*.ciphertext', + ], + remove: true, + }, +}); export default logger; diff --git a/lib/markdown.ts b/lib/markdown.ts index f2086a0..6c6118e 100644 --- a/lib/markdown.ts +++ b/lib/markdown.ts @@ -24,10 +24,11 @@ marked.use({ }, }, - // Neutralize javascript: and data: URLs in links and images before rendering. + // Neutralize unsafe URLs in links and images before rendering. walkTokens(token) { if ((token.type === 'link' || token.type === 'image') && token.href) { - if (/^(?:javascript:|data:text\/html)/i.test(token.href.trim())) token.href = '#'; + const href = token.href.trim(); + if (!/^(?:https?:|mailto:|\/|#|\?|\.\.?\/)/i.test(href)) token.href = '#'; } }, }); @@ -37,6 +38,6 @@ export function renderMarkdown(text: string): string { // Wrap each
 in a .code-block and inject a [COPY] button
   return html
     .replace(/
/g,
-      `
`)
+      `
`)
     .replace(/<\/pre>/g, '
'); } diff --git a/lib/request.ts b/lib/request.ts new file mode 100644 index 0000000..1260bde --- /dev/null +++ b/lib/request.ts @@ -0,0 +1,47 @@ +export const PRIVATE_NO_STORE = 'private, no-store'; + +function mergeHeaders(...headerSets: Array): Headers { + const headers = new Headers(); + for (const set of headerSets) { + if (!set) continue; + const next = new Headers(set); + next.forEach((value, key) => headers.set(key, value)); + } + return headers; +} + +export function getRequestId(req: Request): string { + const existing = req.headers.get('x-request-id')?.trim(); + return existing || crypto.randomUUID(); +} + +export function readContentLength(req: Request): number | null { + const raw = req.headers.get('content-length'); + if (!raw) return null; + const parsed = Number(raw); + return Number.isFinite(parsed) && parsed >= 0 ? parsed : null; +} + +export function jsonResponse( + data: unknown, + init: ResponseInit = {}, + options: { requestId?: string; cacheControl?: string } = {}, +): Response { + const headers = mergeHeaders(init.headers, { + ...(options.requestId ? { 'X-Request-Id': options.requestId } : {}), + ...(options.cacheControl ? { 'Cache-Control': options.cacheControl } : {}), + }); + return Response.json(data, { ...init, headers }); +} + +export function textResponse( + body: BodyInit | null, + init: ResponseInit = {}, + options: { requestId?: string; cacheControl?: string } = {}, +): Response { + const headers = mergeHeaders(init.headers, { + ...(options.requestId ? { 'X-Request-Id': options.requestId } : {}), + ...(options.cacheControl ? { 'Cache-Control': options.cacheControl } : {}), + }); + return new Response(body, { ...init, headers }); +} diff --git a/lib/share.ts b/lib/share.ts new file mode 100644 index 0000000..5ddb208 --- /dev/null +++ b/lib/share.ts @@ -0,0 +1,19 @@ +import { cache } from 'react'; +import type { Message } from './chat'; +import { query } from './db'; + +export type SharedChat = { + id: string; + model: string; + system_prompt: string | null; + messages: Message[]; + created_at: string; +}; + +export const getSharedChat = cache(async (id: string): Promise => { + const result = await query( + 'SELECT id, model, system_prompt, messages, created_at FROM shared_chats WHERE id = $1', + [id], + ); + return (result.rows[0] as SharedChat | undefined) ?? null; +}); diff --git a/lib/validation.ts b/lib/validation.ts new file mode 100644 index 0000000..9bfecab --- /dev/null +++ b/lib/validation.ts @@ -0,0 +1,246 @@ +import type { Image, Message, Pdf, Role } from './chat'; +import { ALLOWED_MODELS } from './models'; + +type ValidationResult = + | { ok: true; value: T } + | { ok: false; error: string; status: number }; + +type CleanMessage = Omit; +type ClientEventLevel = 'info' | 'warn' | 'error'; + +export const LIMITS = { + chatBodyBytes: 6_000_000, + shareBodyBytes: 500_000, + settingsBodyBytes: 20_000, + historyBodyBytes: 6_000_000, + clientEventBodyBytes: 8_000, + maxMessages: 200, + maxMessageChars: 120_000, + maxImagesPerMessage: 8, + maxPdfsPerMessage: 4, + maxImageBytes: 5_000_000, + maxPdfBytes: 15_000_000, + maxTextFileBytes: 500_000, + maxPdfNameChars: 200, + maxSystemPromptChars: 20_000, + maxJwkChars: 8_000, + maxIvChars: 256, + maxCiphertextBytes: 6_000_000, + maxClientEventNameChars: 80, + maxClientEventDetails: 20, + maxClientEventValueChars: 160, +} as const; + +function fail(error: string, status = 400): ValidationResult { + return { ok: false, error, status }; +} + +function ok(value: T): ValidationResult { + return { ok: true, value }; +} + +function isPlainObject(value: unknown): value is Record { + return !!value && typeof value === 'object' && !Array.isArray(value); +} + +function bytesFromBase64(data: string): number { + return Math.floor((data.length * 3) / 4); +} + +function isRole(value: unknown): value is Role { + return value === 'user' || value === 'assistant'; +} + +function validateImage(input: unknown): ValidationResult { + if (!isPlainObject(input)) return fail('invalid image'); + if (typeof input.data !== 'string' || !input.data) return fail('invalid image data'); + if (typeof input.mimeType !== 'string' || !/^image\/[a-z0-9.+-]+$/i.test(input.mimeType)) { + return fail('invalid image mime type'); + } + if (bytesFromBase64(input.data) > LIMITS.maxImageBytes) return fail('image too large', 413); + return ok({ data: input.data, mimeType: input.mimeType }); +} + +function validatePdf(input: unknown): ValidationResult { + if (!isPlainObject(input)) return fail('invalid pdf'); + if (typeof input.name !== 'string' || input.name.length === 0 || input.name.length > LIMITS.maxPdfNameChars) { + return fail('invalid pdf name'); + } + if (typeof input.data !== 'string' || !input.data) return fail('invalid pdf data'); + if (bytesFromBase64(input.data) > LIMITS.maxPdfBytes) return fail('pdf too large', 413); + return ok({ name: input.name, data: input.data }); +} + +export function validateMessages(input: unknown): ValidationResult { + if (!Array.isArray(input) || input.length === 0) return fail('messages must be a non-empty array'); + if (input.length > LIMITS.maxMessages) return fail(`too many messages (max ${LIMITS.maxMessages})`); + + const messages: CleanMessage[] = []; + for (const raw of input) { + if (!isPlainObject(raw)) return fail('invalid message'); + if (!isRole(raw.role)) return fail('invalid message role'); + if (typeof raw.content !== 'string') return fail('invalid message content'); + if (raw.content.length > LIMITS.maxMessageChars) return fail('message too large', 413); + + const msg: CleanMessage = { role: raw.role, content: raw.content }; + + if (raw.images !== undefined) { + if (!Array.isArray(raw.images)) return fail('invalid images'); + if (raw.images.length > LIMITS.maxImagesPerMessage) return fail('too many images', 413); + const images: Image[] = []; + for (const image of raw.images) { + const result = validateImage(image); + if (!result.ok) return result; + images.push(result.value); + } + if (images.length) msg.images = images; + } + + if (raw.pdfs !== undefined) { + if (!Array.isArray(raw.pdfs)) return fail('invalid pdfs'); + if (raw.pdfs.length > LIMITS.maxPdfsPerMessage) return fail('too many pdfs', 413); + const pdfs: Pdf[] = []; + for (const pdf of raw.pdfs) { + const result = validatePdf(pdf); + if (!result.ok) return result; + pdfs.push(result.value); + } + if (pdfs.length) msg.pdfs = pdfs; + } + + messages.push(msg); + } + + return ok(messages); +} + +export function validateChatRequest(input: unknown): ValidationResult<{ + messages: CleanMessage[]; + model: string; + systemPrompt?: string; + webSearch: boolean; +}> { + if (!isPlainObject(input)) return fail('invalid request body'); + const messages = validateMessages(input.messages); + if (!messages.ok) return messages; + if (typeof input.model !== 'string' || !ALLOWED_MODELS.has(input.model)) return fail('unknown model'); + if (input.systemPrompt !== undefined && typeof input.systemPrompt !== 'string') return fail('invalid systemPrompt'); + if (typeof input.systemPrompt === 'string' && input.systemPrompt.length > LIMITS.maxSystemPromptChars) { + return fail('systemPrompt too large', 413); + } + if (input.webSearch !== undefined && typeof input.webSearch !== 'boolean') return fail('invalid webSearch'); + return ok({ + messages: messages.value, + model: input.model, + ...(typeof input.systemPrompt === 'string' ? { systemPrompt: input.systemPrompt } : {}), + webSearch: input.webSearch ?? false, + }); +} + +export function validateShareRequest(input: unknown): ValidationResult<{ + messages: CleanMessage[]; + model: string; + systemPrompt: string; +}> { + if (!isPlainObject(input)) return fail('invalid request body'); + const messages = validateMessages(input.messages); + if (!messages.ok) return messages; + if (typeof input.model !== 'string' || !ALLOWED_MODELS.has(input.model)) return fail('unknown model'); + if (input.systemPrompt !== undefined && typeof input.systemPrompt !== 'string') return fail('invalid systemPrompt'); + if (typeof input.systemPrompt === 'string' && input.systemPrompt.length > LIMITS.maxSystemPromptChars) { + return fail('systemPrompt too large', 413); + } + return ok({ + messages: messages.value, + model: input.model, + systemPrompt: typeof input.systemPrompt === 'string' ? input.systemPrompt : '', + }); +} + +export function validateSettingsRequest(input: unknown): ValidationResult<{ + systemPrompt: string; + saveHistory: boolean; + keyJwk: string | null; +}> { + if (!isPlainObject(input)) return fail('invalid request body'); + if (input.systemPrompt !== undefined && typeof input.systemPrompt !== 'string') return fail('invalid systemPrompt'); + if (typeof input.systemPrompt === 'string' && input.systemPrompt.length > LIMITS.maxSystemPromptChars) { + return fail('systemPrompt too large', 413); + } + if (input.saveHistory !== undefined && typeof input.saveHistory !== 'boolean') return fail('invalid saveHistory'); + if (input.keyJwk !== undefined && input.keyJwk !== null && typeof input.keyJwk !== 'string') return fail('invalid keyJwk'); + if (typeof input.keyJwk === 'string') { + if (input.keyJwk.length > LIMITS.maxJwkChars) return fail('keyJwk too large', 413); + try { + const parsed = JSON.parse(input.keyJwk) as Record; + if (!parsed || typeof parsed.kty !== 'string') return fail('invalid keyJwk'); + } catch { + return fail('invalid keyJwk'); + } + } + + return ok({ + systemPrompt: typeof input.systemPrompt === 'string' ? input.systemPrompt : '', + saveHistory: input.saveHistory ?? false, + keyJwk: typeof input.keyJwk === 'string' ? input.keyJwk : null, + }); +} + +export function validateHistorySaveRequest(input: unknown): ValidationResult<{ + id?: string; + iv: string; + ciphertext: string; +}> { + if (!isPlainObject(input)) return fail('invalid request body'); + if (input.id !== undefined && typeof input.id !== 'string') return fail('invalid id'); + if (typeof input.id === 'string' && !/^[a-z0-9-]{8,80}$/i.test(input.id)) return fail('invalid id'); + if (typeof input.iv !== 'string' || input.iv.length === 0 || input.iv.length > LIMITS.maxIvChars) { + return fail('invalid iv'); + } + if (typeof input.ciphertext !== 'string' || input.ciphertext.length === 0) return fail('invalid ciphertext'); + if (bytesFromBase64(input.ciphertext) > LIMITS.maxCiphertextBytes) return fail('ciphertext too large', 413); + return ok({ + ...(typeof input.id === 'string' ? { id: input.id } : {}), + iv: input.iv, + ciphertext: input.ciphertext, + }); +} + +function sanitizeClientDetail(value: unknown): string | number | boolean | null { + if (value === null) return null; + if (typeof value === 'boolean' || typeof value === 'number') return value; + if (typeof value === 'string') return value.slice(0, LIMITS.maxClientEventValueChars); + return String(value).slice(0, LIMITS.maxClientEventValueChars); +} + +export function validateClientEventRequest(input: unknown): ValidationResult<{ + event: string; + level: ClientEventLevel; + details: Record; +}> { + if (!isPlainObject(input)) return fail('invalid request body'); + if (typeof input.event !== 'string' || !input.event || input.event.length > LIMITS.maxClientEventNameChars) { + return fail('invalid event'); + } + if (input.level !== undefined && input.level !== 'info' && input.level !== 'warn' && input.level !== 'error') { + return fail('invalid level'); + } + if (input.details !== undefined && !isPlainObject(input.details)) return fail('invalid details'); + + const details: Record = {}; + if (isPlainObject(input.details)) { + for (const [key, value] of Object.entries(input.details).slice(0, LIMITS.maxClientEventDetails)) { + details[key.slice(0, 40)] = sanitizeClientDetail(value); + } + } + + return ok({ + event: input.event, + level: (input.level ?? 'error') as ClientEventLevel, + details, + }); +} + +export function isShareId(id: string): boolean { + return /^[0-9a-f]{16,32}$/i.test(id); +} diff --git a/next.config.ts b/next.config.ts index 2c70bd4..675f933 100644 --- a/next.config.ts +++ b/next.config.ts @@ -1,16 +1,31 @@ import type { NextConfig } from 'next'; +const contentSecurityPolicy = [ + "default-src 'self'", + "base-uri 'none'", + "frame-ancestors 'none'", + "object-src 'none'", + "script-src 'self' 'unsafe-inline'", + "style-src 'self' 'unsafe-inline'", + "img-src 'self' data: blob: https:", + "font-src 'self' data:", + "connect-src 'self' ws: wss:", + "form-action 'self'", +].join('; '); + const securityHeaders = [ { key: 'X-Content-Type-Options', value: 'nosniff' }, { key: 'X-Frame-Options', value: 'DENY' }, { key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' }, { key: 'Permissions-Policy', value: 'camera=(), microphone=(), geolocation=()' }, + { key: 'Cross-Origin-Opener-Policy', value: 'same-origin' }, + { key: 'Cross-Origin-Resource-Policy', value: 'same-origin' }, + { key: 'Strict-Transport-Security', value: 'max-age=63072000; includeSubDomains; preload' }, + { key: 'Content-Security-Policy', value: contentSecurityPolicy }, ]; const config: NextConfig = { output: 'standalone', - eslint: { ignoreDuringBuilds: true }, - typescript: { ignoreBuildErrors: true }, serverExternalPackages: ['pg', 'pino'], experimental: { optimizePackageImports: ['highlight.js'], diff --git a/tests/unit.test.ts b/tests/unit.test.ts index ec83efb..4704a33 100644 --- a/tests/unit.test.ts +++ b/tests/unit.test.ts @@ -232,6 +232,12 @@ test('renderMarkdown: user message with mixed text and fenced code block renders assert.ok(html.includes('code-block'), `copy button wrapper should be present, got: ${html}`); }); +test('renderMarkdown: copy button uses data attribute instead of inline handler', () => { + const html = renderMarkdown('```js\nconst x = 1;\n```'); + assert.ok(html.includes('data-copy-code'), `copy button data attribute should be present, got: ${html}`); + assert.ok(!html.includes('onclick='), `inline handler should be absent, got: ${html}`); +}); + // ── parseStreamError ───────────────────────────────────────────────────────── test('parseStreamError: 429 → rate limit message', () => {