From 00e7ac92ae1d54da6fafa8296eac20752ce2110f Mon Sep 17 00:00:00 2001 From: Mattsson <111893710+mattssonn@users.noreply.github.com> Date: Wed, 26 Aug 2026 12:32:29 +0200 Subject: [PATCH] feat(support): attach images and PDFs to the in-app contact form Add optional image and PDF attachments to the existing in-app support contact form, with client-side limits, server-side validation, and email delivery. Preserve the existing subject, rate-limit, analytics, and storage behavior. --- DECISIONS.md | 1 + .../support/contact/__tests__/route.test.ts | 238 ++++++++++++++++++ app/api/support/contact/route.ts | 135 +++++++++- components/ui/support-link.tsx | 162 +++++++++++- lib/support/__tests__/attachments.test.ts | 94 +++++++ lib/support/__tests__/submit-feedback.test.ts | 58 +++++ lib/support/attachments.ts | 81 ++++++ lib/support/submit-feedback.ts | 33 ++- messages/en.json | 8 +- messages/sv.json | 8 +- 10 files changed, 797 insertions(+), 21 deletions(-) create mode 100644 app/api/support/contact/__tests__/route.test.ts create mode 100644 lib/support/__tests__/attachments.test.ts create mode 100644 lib/support/attachments.ts diff --git a/DECISIONS.md b/DECISIONS.md index f50d1088..b6b09aad 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -1245,6 +1245,7 @@ One line per decision: `[YYYY-MM-DD] : `. Appended by agents and [2026-08-25] Payslip YTD ("Ackumulerat") stays a stored snapshot on salary_run_employees, refreshed at approve + book, rather than being recomputed at PDF-render time: an employee who re-downloads a lonebesked must see the figures it had when it was issued, and a render-time sum would silently restate delivered payslips after any backdated correction. The same change widens the counted prior-run statuses from booked-only to approved/paid/booked (corrected stays excluded: its correction run replaces the whole month), because the original snapshot-at-calculate-time rule froze a YTD that was missing every month not yet booked when next month's run was prepared. [2026-08-25] ROT/RUT BegartBelopp truncates to whole kronor (truncateToWholeKronor), not half-up: the deduction is capped at 50%/30% of arbetskostnaden. At the RUT cap, half-up manufactured begart > betalt and blocked correct invoices; for ROT below the cap it over-requested past the cap and the 1513 fordran, so those files now ask 1 kr less (skeptic-verified on PR #1910). [2026-08-25] Woo bulk revenue template = per-rate account choice, no hardcoded varor/tjanster preset: BAS 2026 has no standard 30xx goods/services subdivision (3040-series is company-specific), so presets would invent accounts; chosen accounts are validated against the company chart instead, and only diffs from the 3001-series default are sent. +[2026-08-26] Support-dialog attachments use the existing email delivery path without storage or schema changes: this keeps the feature scoped to the contact form. The budget is 5 files / 4 MB total under the 4.5 MB hosted request-body ceiling, with client-side image shrinking when needed. [2026-08-26] RFC 9728 protected-resource metadata is served at THREE locations (root, path-based /.well-known/oauth-protected-resource/, and /.well-known/oauth-protected-resource): Claude.ai's connector setup derives the metadata URL from the server URL and fetches it before any 401, so the root document our WWW-Authenticate header points at was not enough ('Authorization with Accounted failed' with only 404s in the logs). One builder, three routes; the path-based route answers 404 for any path other than the MCP endpoint so no phantom resource is advertised. [2026-08-20] The swedish-e-invoicing skill now names Upphandlingsmyndigheten as Sweden Peppol Authority across all eight files, not just the one that was flagged: the handover completed 1 July 2026 (regeringsbeslut Fi2025/01826) and the skill was written in future tense, so a partial fix would have left the atom internally contradictory and still pointed agents at peppol@digg.se. Four digg.se URLs were repointed to their verified 301 targets on upphandlingsmyndigheten.se; the fifth, DIGG Peppol testbadd, is a hard 404 with no redirect and no successor page at the new authority, so it was replaced with the SFTI Validex verification service (https://sfti.validex.net/) rather than left dead or guessed at. Historical attributions (Q4 2025 traffic statistics, the 0007:2021006883 Peppol-ID example) deliberately still say DIGG because they were accurate when published. [2026-08-26] gnubok_connect_bank / gnubok_connect_skatteverket moved from catalogVisibility 'search' to the default catalog: Claude.ai can only invoke tools present in tools/list, so search-only tools are discover-only there and the onboarding skill's steps 3-4 dead-ended on client-side tool-not-found (verified via event_log: the server never received the calls). Search-only visibility remains fine for tools an agent reads about before asking the user, but anything a skill instructs the agent to CALL must be in the default catalog. diff --git a/app/api/support/contact/__tests__/route.test.ts b/app/api/support/contact/__tests__/route.test.ts new file mode 100644 index 00000000..1e6138b1 --- /dev/null +++ b/app/api/support/contact/__tests__/route.test.ts @@ -0,0 +1,238 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { NextResponse } from 'next/server' +import { createMockSupabase } from '@/tests/helpers' +import { + SUPPORT_MAX_ATTACHMENTS, + SUPPORT_MAX_ATTACHMENT_TOTAL_BYTES, +} from '@/lib/support/attachments' + +const mockSupabase = createMockSupabase() +const requireAuthMock = vi.fn() +const sendEmailMock = vi.fn() +const isConfiguredMock = vi.fn() + +vi.mock('@/lib/init', () => ({ ensureInitialized: vi.fn() })) +vi.mock('@/lib/auth/require-auth', () => ({ + requireAuth: (...args: unknown[]) => requireAuthMock(...args), +})) +vi.mock('@/lib/company/context', () => ({ + requireCompanyId: vi.fn().mockResolvedValue('company-1'), +})) +vi.mock('@/lib/email/service', () => ({ + getEmailService: () => ({ + sendEmail: (...args: unknown[]) => sendEmailMock(...args), + isConfigured: () => isConfiguredMock(), + }), +})) +vi.mock('@/lib/support', () => ({ + getSupportRecipientEmail: () => 'support@example.test', +})) +vi.mock('@/lib/branding/service', () => ({ + getBranding: () => ({ appName: 'Accounted' }), +})) + +import { POST } from '../route' + +const user = { id: 'user-1', email: 'user@example.test' } + +/** Real PNG signature: a declared image/png with anything else is rejected. */ +const PNG_BYTES = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d]) + +function pngFile(name = 'skarmbild.png', padTo = 0): File { + const bytes = padTo > PNG_BYTES.length + ? new Uint8Array([...PNG_BYTES, ...new Uint8Array(padTo - PNG_BYTES.length)]) + : PNG_BYTES + return new File([bytes], name, { type: 'image/png' }) +} + +function jsonRequest(body: unknown): Request { + return new Request('http://localhost/api/support/contact', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }) +} + +function multipartRequest(fields: { + subject?: string + message?: string + files?: File[] +}): Request { + const form = new FormData() + if (fields.subject !== undefined) form.append('subject', fields.subject) + if (fields.message !== undefined) form.append('message', fields.message) + for (const file of fields.files ?? []) form.append('files', file, file.name) + return new Request('http://localhost/api/support/contact', { method: 'POST', body: form }) +} + +describe('POST /api/support/contact', () => { + beforeEach(() => { + vi.clearAllMocks() + isConfiguredMock.mockReturnValue(true) + sendEmailMock.mockResolvedValue({ success: true }) + requireAuthMock.mockResolvedValue({ user, supabase: mockSupabase, error: null }) + }) + + it('returns 401 when not authenticated', async () => { + requireAuthMock.mockResolvedValue({ + user: null, + supabase: mockSupabase, + error: NextResponse.json({ error: 'Unauthorized' }, { status: 401 }), + }) + const response = await POST(jsonRequest({ message: 'Hjälp tack' })) + expect(response.status).toBe(401) + expect(sendEmailMock).not.toHaveBeenCalled() + }) + + it('rejects a message shorter than 5 characters', async () => { + const response = await POST(jsonRequest({ message: 'hej' })) + expect(response.status).toBe(400) + }) + + it('maps an invalid request body to a Swedish user-facing error', async () => { + const response = await POST( + new Request('http://localhost/api/support/contact', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: '{', + }) + ) + expect(response.status).toBe(400) + expect((await response.json()).error).toBe('Förfrågan innehåller ogiltiga uppgifter.') + }) + + it('still accepts the JSON body with no attachments', async () => { + const response = await POST(jsonRequest({ subject: 'Moms', message: 'Jag fastnar på ruta 05' })) + expect(response.status).toBe(200) + expect(sendEmailMock).toHaveBeenCalledWith( + expect.objectContaining({ + to: 'support@example.test', + replyTo: 'user@example.test', + attachments: undefined, + }) + ) + }) + + it('attaches an uploaded screenshot to the support mail', async () => { + const response = await POST( + multipartRequest({ subject: 'Trasig vy', message: 'Ser ut så här', files: [pngFile()] }) + ) + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ data: { sent: true } }) + + const sent = sendEmailMock.mock.calls[0][0] + expect(sent.subject).toBe('[accounted support] Trasig vy') + expect(sent.attachments).toHaveLength(1) + expect(sent.attachments[0].filename).toBe('skarmbild.png') + expect(sent.attachments[0].contentType).toBe('image/png') + expect(Buffer.isBuffer(sent.attachments[0].content)).toBe(true) + // The reader must see that files came along even in a folded mail client. + expect(sent.text).toContain('Bilagor (1): skarmbild.png') + }) + + it('strips path segments out of the attachment filename', async () => { + await POST( + multipartRequest({ + message: 'Se bilagan', + files: [pngFile('../../etc/passwd.png')], + }) + ) + expect(sendEmailMock.mock.calls[0][0].attachments[0].filename).toBe('passwd.png') + }) + + it('forces an attachment extension that matches the verified type', async () => { + await POST( + multipartRequest({ + message: 'Se bilagan', + files: [pngFile('update.exe')], + }) + ) + expect(sendEmailMock.mock.calls[0][0].attachments[0].filename).toBe('update.png') + }) + + it('rejects an empty attachment instead of silently omitting it', async () => { + const response = await POST( + multipartRequest({ + message: 'Tom bild', + files: [new File([], 'tom.png', { type: 'image/png' })], + }) + ) + expect(response.status).toBe(400) + expect(sendEmailMock).not.toHaveBeenCalled() + }) + + it('rejects more attachments than the cap allows', async () => { + const files = Array.from( + { length: SUPPORT_MAX_ATTACHMENTS + 1 }, + (_, index) => pngFile(`${index}.png`) + ) + const response = await POST( + multipartRequest({ + message: 'För många bilder', + files, + }) + ) + expect(response.status).toBe(400) + expect(sendEmailMock).not.toHaveBeenCalled() + }) + + it('rejects attachments over the total size budget', async () => { + const response = await POST( + multipartRequest({ + message: 'En stor bild', + files: [pngFile('stor.png', SUPPORT_MAX_ATTACHMENT_TOTAL_BYTES + 1)], + }) + ) + expect(response.status).toBe(400) + expect(sendEmailMock).not.toHaveBeenCalled() + }) + + it('rejects an unsupported attachment type', async () => { + const response = await POST( + multipartRequest({ + message: 'Ett skript', + files: [new File(['#!/bin/sh'], 'run.sh', { type: 'application/x-sh' })], + }) + ) + expect(response.status).toBe(400) + expect(sendEmailMock).not.toHaveBeenCalled() + }) + + it('rejects content that does not match its declared type', async () => { + const response = await POST( + multipartRequest({ + message: 'Utger sig för att vara en png', + files: [new File(['not a png at all'], 'fake.png', { type: 'image/png' })], + }) + ) + expect(response.status).toBe(400) + expect(sendEmailMock).not.toHaveBeenCalled() + }) + + it('rejects an executable-shaped PDF polyglot', async () => { + const bytes = new Uint8Array(64) + bytes.set([0x4d, 0x5a]) + bytes.set([0x25, 0x50, 0x44, 0x46, 0x2d], 16) + const response = await POST( + multipartRequest({ + message: 'Misstänkt PDF', + files: [new File([bytes], 'update.exe', { type: 'application/pdf' })], + }) + ) + expect(response.status).toBe(400) + expect(sendEmailMock).not.toHaveBeenCalled() + }) + + it('returns 503 when the email service is not configured', async () => { + isConfiguredMock.mockReturnValue(false) + const response = await POST(jsonRequest({ message: 'Hjälp tack' })) + expect(response.status).toBe(503) + }) + + it('returns 500 when the send fails', async () => { + sendEmailMock.mockResolvedValue({ success: false, error: 'boom' }) + const response = await POST(jsonRequest({ message: 'Hjälp tack' })) + expect(response.status).toBe(500) + }) +}) diff --git a/app/api/support/contact/route.ts b/app/api/support/contact/route.ts index ca7112d6..297271b3 100644 --- a/app/api/support/contact/route.ts +++ b/app/api/support/contact/route.ts @@ -2,9 +2,18 @@ import { NextResponse } from 'next/server' import { requireAuth } from '@/lib/auth/require-auth' import { getEmailService } from '@/lib/email/service' import { getSupportRecipientEmail } from '@/lib/support' +import { + SUPPORT_MAX_ATTACHMENTS, + SUPPORT_MAX_ATTACHMENT_TOTAL_BYTES, + SUPPORT_MAX_ATTACHMENT_TOTAL_MB, + isSupportedAttachmentType, + supportAttachmentFilename, +} from '@/lib/support/attachments' +import { validateDocumentMagicBytes } from '@/lib/core/documents/document-service' import { requireCompanyId } from '@/lib/company/context' import { ensureInitialized } from '@/lib/init' import { getBranding } from '@/lib/branding/service' +import { getErrorMessage } from '@/lib/errors/get-error-message' ensureInitialized() @@ -12,20 +21,110 @@ function escapeHtml(s: string): string { return s.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"') } +interface ParsedAttachment { + filename: string + content: Buffer + contentType: string +} + +function hasUnsafePdfPrefix(buffer: ArrayBuffer): boolean { + const bytes = new Uint8Array(buffer) + const signatures = [ + [0x4d, 0x5a], // Windows executable + [0x7f, 0x45, 0x4c, 0x46], // ELF executable + [0x50, 0x4b, 0x03, 0x04], // ZIP container + [0x23, 0x21], // Executable script + ] + return signatures.some( + (signature) => + signature.length <= bytes.length && + signature.every((byte, index) => bytes[index] === byte) + ) +} + +/** + * Attachments are relayed through the existing email service. Their bytes are + * checked against the declared type instead of trusting multipart headers. + */ +async function parseAttachments( + files: File[] +): Promise<{ attachments: ParsedAttachment[] } | { error: string }> { + if (files.length > SUPPORT_MAX_ATTACHMENTS) { + return { error: `Du kan bifoga max ${SUPPORT_MAX_ATTACHMENTS} filer` } + } + + const attachments: ParsedAttachment[] = [] + let totalBytes = 0 + + for (const file of files) { + const fileType = file.type.toLowerCase() + if (!isSupportedAttachmentType(fileType)) { + return { error: 'Bifogade filer måste vara bilder (JPG, PNG, WEBP) eller PDF' } + } + + totalBytes += file.size + if (totalBytes > SUPPORT_MAX_ATTACHMENT_TOTAL_BYTES) { + return { error: `Bilagorna får väga max ${SUPPORT_MAX_ATTACHMENT_TOTAL_MB} MB tillsammans` } + } + + const buffer = await file.arrayBuffer() + if (fileType === 'application/pdf' && hasUnsafePdfPrefix(buffer)) { + return { error: 'PDF-filen har ett ogiltigt innehåll' } + } + const magicError = validateDocumentMagicBytes(buffer, fileType) + if (magicError) return { error: magicError } + + attachments.push({ + filename: supportAttachmentFilename(file.name, fileType), + content: Buffer.from(buffer), + contentType: fileType, + }) + } + + return { attachments } +} + export async function POST(request: Request) { const { user, supabase, error } = await requireAuth() if (error) return error await requireCompanyId(supabase, user.id) - let body: { subject?: string; message?: string } - try { - body = await request.json() - } catch { - return NextResponse.json({ error: 'Invalid request body' }, { status: 400 }) + let subjectRaw: string | undefined + let messageRaw: string | undefined + let files: File[] = [] + + const contentType = request.headers.get('content-type') || '' + if (contentType.includes('multipart/form-data')) { + let form: FormData + try { + form = await request.formData() + } catch { + return NextResponse.json( + { error: getErrorMessage('Invalid request body', { statusCode: 400 }) }, + { status: 400 } + ) + } + const subjectField = form.get('subject') + const messageField = form.get('message') + subjectRaw = typeof subjectField === 'string' ? subjectField : undefined + messageRaw = typeof messageField === 'string' ? messageField : undefined + files = form.getAll('files').filter((f): f is File => f instanceof File) + } else { + let body: { subject?: string; message?: string } + try { + body = await request.json() + } catch { + return NextResponse.json( + { error: getErrorMessage('Invalid request body', { statusCode: 400 }) }, + { status: 400 } + ) + } + subjectRaw = body.subject + messageRaw = body.message } - const message = body.message?.trim() + const message = messageRaw?.trim() if (!message || message.length < 5) { return NextResponse.json({ error: 'Meddelandet måste vara minst 5 tecken' }, { status: 400 }) } @@ -33,7 +132,16 @@ export async function POST(request: Request) { return NextResponse.json({ error: 'Meddelandet får vara max 5000 tecken' }, { status: 400 }) } - const subject = body.subject?.trim() || 'Supportärende' + const subject = subjectRaw?.trim() || 'Supportärende' + + const parsed = await parseAttachments(files) + if ('error' in parsed) { + return NextResponse.json( + { error: getErrorMessage(parsed.error, { statusCode: 400 }) }, + { status: 400 } + ) + } + const { attachments } = parsed const emailService = getEmailService() if (!emailService.isConfigured()) { @@ -45,6 +153,15 @@ export async function POST(request: Request) { const safeSubject = escapeHtml(subject) const safeMessage = escapeHtml(message).replace(/\n/g, '
') + // Named in the body as well as attached: a mail client that folds + // attachments away otherwise hides the fact that they exist at all. + const attachmentNames = attachments.map((a) => a.filename) + const attachmentHtml = attachmentNames.length + ? `

Bilagor (${attachmentNames.length}): ${escapeHtml(attachmentNames.join(', '))}

` + : '' + const attachmentText = attachmentNames.length + ? `\n\nBilagor (${attachmentNames.length}): ${attachmentNames.join(', ')}` + : '' const result = await emailService.sendEmail({ to: getSupportRecipientEmail(), @@ -56,8 +173,10 @@ export async function POST(request: Request) {

Ämne: ${safeSubject}


${safeMessage}

+ ${attachmentHtml} `, - text: `Från: ${user.email}\nUser ID: ${user.id}\nÄmne: ${subject}\n\n${message}`, + text: `Från: ${user.email}\nUser ID: ${user.id}\nÄmne: ${subject}\n\n${message}${attachmentText}`, + attachments: attachments.length ? attachments : undefined, }) if (!result.success) { diff --git a/components/ui/support-link.tsx b/components/ui/support-link.tsx index 865c176d..6b1793f2 100644 --- a/components/ui/support-link.tsx +++ b/components/ui/support-link.tsx @@ -1,9 +1,9 @@ 'use client' -import { useState } from 'react' +import { useRef, useState } from 'react' import { useTranslations } from 'next-intl' import { cn } from '@/lib/utils' -import { Mail, Loader2, Send } from 'lucide-react' +import { FileText, Loader2, Mail, Paperclip, Send, X } from 'lucide-react' import { Dialog, DialogContent, @@ -18,6 +18,15 @@ import { Textarea } from '@/components/ui/textarea' import { useToast } from '@/components/ui/use-toast' import { submitFeedback } from '@/lib/support/submit-feedback' import { useCompanyOptional } from '@/contexts/CompanyContext' +import { + SUPPORT_ATTACHMENT_ACCEPT, + SUPPORT_MAX_ATTACHMENTS, + SUPPORT_MAX_ATTACHMENT_TOTAL_BYTES, + SUPPORT_MAX_ATTACHMENT_TOTAL_MB, + isSupportedAttachmentType, +} from '@/lib/support/attachments' +import { shrinkImageForUpload } from '@/lib/documents/shrink-image' +import { isShrinkableImage } from '@/lib/documents/upload-size' interface SupportLinkProps { variant?: 'inline' | 'muted' @@ -26,6 +35,12 @@ interface SupportLinkProps { className?: string } +type AttachmentError = 'unsupported' | 'too_many' | 'too_large' + +function totalBytes(files: File[]): number { + return files.reduce((sum, file) => sum + file.size, 0) +} + export function SupportLink({ variant = 'inline', subject, @@ -35,19 +50,91 @@ export function SupportLink({ const t = useTranslations('support_link') const [open, setOpen] = useState(false) const [message, setMessage] = useState('') + const [attachments, setAttachments] = useState([]) + const [isPreparing, setIsPreparing] = useState(false) const [isSending, setIsSending] = useState(false) const [sent, setSent] = useState(false) + const fileInputRef = useRef(null) + const attachmentGenerationRef = useRef(0) const { toast } = useToast() const companyCtx = useCompanyOptional() if (companyCtx?.isSandbox) return null + function showAttachmentError(error: AttachmentError) { + if (error === 'unsupported') { + toast({ title: t('attach_unsupported'), variant: 'destructive' }) + return + } + if (error === 'too_many') { + toast({ + title: t('attach_too_many', { count: SUPPORT_MAX_ATTACHMENTS }), + variant: 'destructive', + }) + return + } + toast({ + title: t('attach_too_large', { limit: SUPPORT_MAX_ATTACHMENT_TOTAL_MB }), + variant: 'destructive', + }) + } + + async function addFiles(incoming: File[]) { + if (!incoming.length || isPreparing || isSending) return + + const generation = attachmentGenerationRef.current + setIsPreparing(true) + try { + const next = [...attachments] + let firstError: AttachmentError | null = null + + for (const original of incoming) { + if (!isSupportedAttachmentType(original.type)) { + firstError ??= 'unsupported' + continue + } + if (next.length >= SUPPORT_MAX_ATTACHMENTS) { + firstError ??= 'too_many' + break + } + + const remaining = SUPPORT_MAX_ATTACHMENT_TOTAL_BYTES - totalBytes(next) + const file = original.size > remaining && isShrinkableImage(original.type) + ? await shrinkImageForUpload(original, remaining) + : original + + if (file.size > remaining) { + firstError ??= 'too_large' + continue + } + next.push(file) + } + + if (generation === attachmentGenerationRef.current) { + setAttachments(next) + if (firstError) showAttachmentError(firstError) + } + } finally { + setIsPreparing(false) + } + } + + function resetAttachments() { + attachmentGenerationRef.current += 1 + setAttachments([]) + if (fileInputRef.current) fileInputRef.current.value = '' + } + async function handleSubmit(e: React.FormEvent) { e.preventDefault() if (message.trim().length < 5) return setIsSending(true) - const result = await submitFeedback({ subject, message: message.trim() }) + const result = await submitFeedback({ + subject, + message: message.trim(), + files: attachments, + }) setIsSending(false) if (result.ok) { @@ -56,6 +143,7 @@ export function SupportLink({ setOpen(false) setSent(false) setMessage('') + resetAttachments() }, 2000) } else { toast({ @@ -71,6 +159,7 @@ export function SupportLink({ if (!next) { setSent(false) setMessage('') + resetAttachments() } } @@ -130,18 +219,81 @@ export function SupportLink({

{t('char_count', { count: message.length })}

+ + {attachments.length > 0 ? ( +
    + {attachments.map((file, index) => ( +
  • +
  • + ))} +
+ ) : null} + +
+ { + void addFiles(Array.from(e.target.files ?? [])) + e.target.value = '' + }} + /> + + + {t('attach_hint', { + count: SUPPORT_MAX_ATTACHMENTS, + limit: SUPPORT_MAX_ATTACHMENT_TOTAL_MB, + })} + +
+