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.
This commit is contained in:
@@ -1245,6 +1245,7 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. 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/<mcp path>, and <mcp url>/.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.
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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, '>').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, '<br />')
|
||||
// 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
|
||||
? `<hr /><p><strong>Bilagor (${attachmentNames.length}):</strong> ${escapeHtml(attachmentNames.join(', '))}</p>`
|
||||
: ''
|
||||
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) {
|
||||
<p><strong>Ämne:</strong> ${safeSubject}</p>
|
||||
<hr />
|
||||
<p>${safeMessage}</p>
|
||||
${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) {
|
||||
|
||||
@@ -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<File[]>([])
|
||||
const [isPreparing, setIsPreparing] = useState(false)
|
||||
const [isSending, setIsSending] = useState(false)
|
||||
const [sent, setSent] = useState(false)
|
||||
const fileInputRef = useRef<HTMLInputElement>(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({
|
||||
<p className="text-xs text-muted-foreground mt-1.5">
|
||||
{t('char_count', { count: message.length })}
|
||||
</p>
|
||||
|
||||
{attachments.length > 0 ? (
|
||||
<ul className="mt-3 space-y-2">
|
||||
{attachments.map((file, index) => (
|
||||
<li
|
||||
key={`${file.name}-${file.size}-${index}`}
|
||||
className="ph-no-capture flex min-w-0 items-center gap-2 rounded-lg border border-border px-3 py-1"
|
||||
>
|
||||
<FileText className="h-4 w-4 shrink-0 text-muted-foreground" aria-hidden="true" />
|
||||
<span className="min-w-0 flex-1 truncate text-xs">
|
||||
{file.name}
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setAttachments((current) => current.filter((_, i) => i !== index))}
|
||||
disabled={isSending || isPreparing}
|
||||
aria-label={t('remove_attachment')}
|
||||
className="shrink-0"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
|
||||
<div className="mt-3 flex flex-col items-start gap-2 sm:flex-row sm:items-center">
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
accept={SUPPORT_ATTACHMENT_ACCEPT}
|
||||
className="hidden"
|
||||
disabled={isSending || isPreparing}
|
||||
onChange={(e) => {
|
||||
void addFiles(Array.from(e.target.files ?? []))
|
||||
e.target.value = ''
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={isSending || isPreparing || attachments.length >= SUPPORT_MAX_ATTACHMENTS}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
>
|
||||
{isPreparing ? (
|
||||
<Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<Paperclip className="mr-2 h-3.5 w-3.5" />
|
||||
)}
|
||||
{t('attach_label')}
|
||||
</Button>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t('attach_hint', {
|
||||
count: SUPPORT_MAX_ATTACHMENTS,
|
||||
limit: SUPPORT_MAX_ATTACHMENT_TOTAL_MB,
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="mt-4">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={() => setOpen(false)}
|
||||
onClick={() => handleOpenChange(false)}
|
||||
disabled={isSending}
|
||||
>
|
||||
{t('cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isSending || message.trim().length < 5}
|
||||
disabled={isSending || isPreparing || message.trim().length < 5}
|
||||
>
|
||||
{isSending ? (
|
||||
<>
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import {
|
||||
SUPPORT_ATTACHMENT_ACCEPT,
|
||||
SUPPORT_MAX_ATTACHMENTS,
|
||||
SUPPORT_MAX_ATTACHMENT_TOTAL_BYTES,
|
||||
SUPPORT_MAX_ATTACHMENT_TOTAL_MB,
|
||||
isSupportedAttachmentType,
|
||||
sanitizeAttachmentFilename,
|
||||
supportAttachmentFilename,
|
||||
} from '@/lib/support/attachments'
|
||||
import { HOSTED_REQUEST_BODY_LIMIT_BYTES } from '@/lib/documents/upload-size'
|
||||
|
||||
describe('support attachments', () => {
|
||||
it('accepts the types a support reader can open', () => {
|
||||
expect(isSupportedAttachmentType('image/png')).toBe(true)
|
||||
expect(isSupportedAttachmentType('IMAGE/JPEG')).toBe(true)
|
||||
expect(isSupportedAttachmentType('application/pdf')).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects everything else, including a missing type', () => {
|
||||
expect(isSupportedAttachmentType('application/x-sh')).toBe(false)
|
||||
expect(isSupportedAttachmentType('image/heic')).toBe(false)
|
||||
expect(isSupportedAttachmentType(undefined)).toBe(false)
|
||||
expect(isSupportedAttachmentType('')).toBe(false)
|
||||
})
|
||||
|
||||
it('offers the same list to the file picker', () => {
|
||||
expect(SUPPORT_ATTACHMENT_ACCEPT).toBe('image/jpeg,image/png,image/webp,application/pdf')
|
||||
})
|
||||
|
||||
it('allows five files with a four megabyte combined budget', () => {
|
||||
expect(SUPPORT_MAX_ATTACHMENTS).toBe(5)
|
||||
expect(SUPPORT_MAX_ATTACHMENT_TOTAL_MB).toBe(4)
|
||||
expect(SUPPORT_MAX_ATTACHMENT_TOTAL_BYTES).toBe(4 * 1024 * 1024)
|
||||
})
|
||||
|
||||
// Vercel kills the request before the route runs if the body is over its own
|
||||
// ceiling, so the budget has to leave room for the multipart envelope.
|
||||
it('stays under the platform request-body ceiling', () => {
|
||||
expect(SUPPORT_MAX_ATTACHMENT_TOTAL_BYTES).toBeLessThan(HOSTED_REQUEST_BODY_LIMIT_BYTES)
|
||||
})
|
||||
|
||||
describe('sanitizeAttachmentFilename', () => {
|
||||
it('keeps an ordinary name', () => {
|
||||
expect(sanitizeAttachmentFilename('skarmbild.png')).toBe('skarmbild.png')
|
||||
})
|
||||
|
||||
it('drops path segments from both separators', () => {
|
||||
expect(sanitizeAttachmentFilename('../../etc/passwd.png')).toBe('passwd.png')
|
||||
expect(sanitizeAttachmentFilename('C:\\Users\\emil\\bild.png')).toBe('bild.png')
|
||||
})
|
||||
|
||||
it('strips control characters that would break a mail header', () => {
|
||||
const withNewline = `bild${String.fromCharCode(13)}${String.fromCharCode(10)}.png`
|
||||
expect(sanitizeAttachmentFilename(withNewline)).toBe('bild.png')
|
||||
})
|
||||
|
||||
it('falls back when nothing usable is left', () => {
|
||||
expect(sanitizeAttachmentFilename('')).toBe('bilaga')
|
||||
expect(sanitizeAttachmentFilename(null)).toBe('bilaga')
|
||||
expect(sanitizeAttachmentFilename(String.fromCharCode(0))).toBe('bilaga')
|
||||
})
|
||||
|
||||
it('bounds a very long name but keeps the extension', () => {
|
||||
const long = `${'a'.repeat(300)}.png`
|
||||
const result = sanitizeAttachmentFilename(long)
|
||||
expect(result.length).toBeLessThanOrEqual(100)
|
||||
expect(result.endsWith('.png')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('supportAttachmentFilename', () => {
|
||||
it('forces the extension to agree with the verified MIME type', () => {
|
||||
expect(supportAttachmentFilename('update.exe', 'application/pdf')).toBe('update.pdf')
|
||||
expect(supportAttachmentFilename('photo.png', 'image/jpeg')).toBe('photo.jpg')
|
||||
})
|
||||
|
||||
it('keeps the final filename within the mail-header bound', () => {
|
||||
const filename = supportAttachmentFilename('a'.repeat(150), 'application/pdf')
|
||||
expect(filename.length).toBeLessThanOrEqual(100)
|
||||
})
|
||||
})
|
||||
|
||||
it('does not expose attachment names in session-replay attributes', () => {
|
||||
const source = readFileSync(
|
||||
join(process.cwd(), 'components', 'ui', 'support-link.tsx'),
|
||||
'utf8'
|
||||
)
|
||||
expect(source).toContain('ph-no-capture')
|
||||
expect(source).not.toContain('title={file.name}')
|
||||
})
|
||||
})
|
||||
@@ -271,5 +271,63 @@ describe('submitFeedback', () => {
|
||||
await submitFeedback({ subject: 'Moms', message: 'hemlig fritext om bolaget' })
|
||||
expect(JSON.stringify(captureMock.mock.calls)).not.toContain('hemlig fritext')
|
||||
})
|
||||
|
||||
it('does not add attachment metadata to analytics', async () => {
|
||||
stubFetchOk()
|
||||
await submitFeedback({
|
||||
message: 'Se bilagan',
|
||||
files: [new File(['x'], 'kontoutdrag-privat.png', { type: 'image/png' })],
|
||||
})
|
||||
const props = captureMock.mock.calls[0][1] as Record<string, unknown>
|
||||
expect(props).not.toHaveProperty('attachment_count')
|
||||
expect(JSON.stringify(captureMock.mock.calls)).not.toContain('kontoutdrag-privat')
|
||||
})
|
||||
})
|
||||
|
||||
describe('attachments', () => {
|
||||
it('sends multipart with the files when there are any', async () => {
|
||||
const fetchSpy = stubFetchOk()
|
||||
const file = new File(['x'], 'skarmbild.png', { type: 'image/png' })
|
||||
|
||||
await submitFeedback({ subject: 'Trasig vy', message: 'Ser ut så här', files: [file] })
|
||||
|
||||
const init = fetchSpy.mock.calls[0][1]
|
||||
expect(init.body).toBeInstanceOf(FormData)
|
||||
// The browser has to set the multipart boundary itself.
|
||||
expect(init.headers).toBeUndefined()
|
||||
const form = init.body as FormData
|
||||
expect(form.get('subject')).toBe('Trasig vy')
|
||||
expect(form.get('message')).toBe('Ser ut så här')
|
||||
expect(form.getAll('files')).toHaveLength(1)
|
||||
expect((form.get('files') as File).name).toBe('skarmbild.png')
|
||||
})
|
||||
|
||||
it('keeps the JSON body when the file list is empty', async () => {
|
||||
const fetchSpy = stubFetchOk()
|
||||
|
||||
await submitFeedback({ subject: 'Moms', message: 'Jag fastnar', files: [] })
|
||||
|
||||
expect(fetchSpy.mock.calls[0][1].body).toBe(
|
||||
JSON.stringify({ subject: 'Moms', message: 'Jag fastnar' })
|
||||
)
|
||||
})
|
||||
|
||||
it('surfaces the route error when an attachment is rejected', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
json: async () => ({ error: 'Du kan bifoga max 5 filer' }),
|
||||
})
|
||||
)
|
||||
|
||||
const result = await submitFeedback({
|
||||
message: 'För många bilder',
|
||||
files: [new File(['x'], 'a.png', { type: 'image/png' })],
|
||||
})
|
||||
|
||||
expect(result.ok).toBe(false)
|
||||
expect(result.error).toBe('Du kan bifoga max 5 filer')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* Support attachment rules, shared by the dialog and the /api/support/contact
|
||||
* route so the client never offers to send something the server will reject.
|
||||
*
|
||||
* Attachments ride along on the existing support email. This module only
|
||||
* defines the transport limits and does not add a storage path.
|
||||
*/
|
||||
|
||||
/** What a support reader can actually open without extra tooling. */
|
||||
export const SUPPORT_ATTACHMENT_TYPES = [
|
||||
'image/jpeg',
|
||||
'image/png',
|
||||
'image/webp',
|
||||
'application/pdf',
|
||||
] as const
|
||||
|
||||
const SUPPORT_ATTACHMENT_EXTENSIONS: Record<string, string> = {
|
||||
'image/jpeg': '.jpg',
|
||||
'image/png': '.png',
|
||||
'image/webp': '.webp',
|
||||
'application/pdf': '.pdf',
|
||||
}
|
||||
|
||||
export const SUPPORT_MAX_ATTACHMENTS = 5
|
||||
|
||||
/**
|
||||
* Total bytes across all attachments in one message.
|
||||
*
|
||||
* Vercel rejects a request body over 4.5 MB before the function runs (see
|
||||
* lib/documents/upload-size.ts), so the ceiling has to leave room for the
|
||||
* multipart envelope and the message text on top of the files themselves.
|
||||
* Self-hosted has no such proxy limit, but the same cap applies there: a
|
||||
* support mailbox is not a file transfer service.
|
||||
*/
|
||||
export const SUPPORT_MAX_ATTACHMENT_TOTAL_MB = 4
|
||||
export const SUPPORT_MAX_ATTACHMENT_TOTAL_BYTES = SUPPORT_MAX_ATTACHMENT_TOTAL_MB * 1024 * 1024
|
||||
|
||||
export function isSupportedAttachmentType(type: string | null | undefined): boolean {
|
||||
return (SUPPORT_ATTACHMENT_TYPES as readonly string[]).includes(String(type ?? '').toLowerCase())
|
||||
}
|
||||
|
||||
/** The `accept` attribute for the file picker: same list, one source of truth. */
|
||||
export const SUPPORT_ATTACHMENT_ACCEPT = SUPPORT_ATTACHMENT_TYPES.join(',')
|
||||
|
||||
/**
|
||||
* Make a client-supplied name safe to put in a mail header: no path
|
||||
* separators, no control characters, bounded length. The extension is kept
|
||||
* when there is one so the attachment still opens with the right app.
|
||||
*
|
||||
* Control characters are stripped by code point rather than by a regex class:
|
||||
* a literal control character in a source file is invisible in review and has
|
||||
* corrupted this repo's files before.
|
||||
*/
|
||||
export function sanitizeAttachmentFilename(raw: string | undefined | null): string {
|
||||
const base = (String(raw ?? '').split(/[\\/]/).pop() ?? '')
|
||||
.split('')
|
||||
.filter((ch) => {
|
||||
const code = ch.charCodeAt(0)
|
||||
return code >= 0x20 && code !== 0x7f
|
||||
})
|
||||
.join('')
|
||||
.trim()
|
||||
|
||||
if (!base) return 'bilaga'
|
||||
return base.length > 100 ? `${base.slice(0, 80)}-${base.slice(-16)}` : base
|
||||
}
|
||||
|
||||
/**
|
||||
* Give the mail attachment an extension that agrees with the verified MIME
|
||||
* type. A browser-supplied name must not make a PDF look executable.
|
||||
*/
|
||||
export function supportAttachmentFilename(
|
||||
raw: string | undefined | null,
|
||||
type: string
|
||||
): string {
|
||||
const safe = sanitizeAttachmentFilename(raw)
|
||||
const extension = SUPPORT_ATTACHMENT_EXTENSIONS[type.toLowerCase()] ?? '.bin'
|
||||
const lastDot = safe.lastIndexOf('.')
|
||||
const stem = (lastDot > 0 ? safe.slice(0, lastDot) : safe) || 'bilaga'
|
||||
return `${stem.slice(0, 100 - extension.length)}${extension}`
|
||||
}
|
||||
@@ -4,6 +4,8 @@ import { isAnalyticsEnabled } from '@/lib/analytics/enabled'
|
||||
export interface SubmitFeedbackInput {
|
||||
message: string
|
||||
subject?: string
|
||||
/** Screenshots or PDFs relayed on the support email only. */
|
||||
files?: File[]
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -27,15 +29,34 @@ export interface SubmitFeedbackResult {
|
||||
error?: string
|
||||
}
|
||||
|
||||
async function submitViaEmail(
|
||||
{ message, subject }: SubmitFeedbackInput
|
||||
): Promise<{ ok: true } | { ok: false; error: string }> {
|
||||
try {
|
||||
const res = await fetch('/api/support/contact', {
|
||||
/**
|
||||
* JSON when there is nothing to attach, multipart when there is. The JSON path
|
||||
* is kept byte-identical rather than always sending multipart: it is the shape
|
||||
* every existing message uses, and a plain body is the one that still works if
|
||||
* multipart parsing is ever the thing that broke.
|
||||
*/
|
||||
function buildEmailRequest({ message, subject, files }: SubmitFeedbackInput): RequestInit {
|
||||
if (!files?.length) {
|
||||
return {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ subject, message }),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const form = new FormData()
|
||||
if (subject) form.append('subject', subject)
|
||||
form.append('message', message)
|
||||
for (const file of files) form.append('files', file, file.name)
|
||||
// No Content-Type header: the browser has to set the multipart boundary.
|
||||
return { method: 'POST', body: form }
|
||||
}
|
||||
|
||||
async function submitViaEmail(
|
||||
input: SubmitFeedbackInput
|
||||
): Promise<{ ok: true } | { ok: false; error: string }> {
|
||||
try {
|
||||
const res = await fetch('/api/support/contact', buildEmailRequest(input))
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}))
|
||||
return { ok: false, error: data.error || 'Kunde inte skicka meddelandet' }
|
||||
|
||||
+7
-1
@@ -4910,7 +4910,13 @@
|
||||
"send": "Send",
|
||||
"sending": "Sending...",
|
||||
"send_failed_title": "Could not send",
|
||||
"send_failed_fallback": "Please try again."
|
||||
"send_failed_fallback": "Please try again.",
|
||||
"attach_label": "Attach files",
|
||||
"attach_hint": "JPG, PNG, WEBP or PDF. Up to {count} files, {limit} MB total.",
|
||||
"attach_too_many": "You can attach at most {count} files",
|
||||
"attach_too_large": "Attachments may total at most {limit} MB",
|
||||
"attach_unsupported": "Only images (JPG, PNG, WEBP) and PDF can be attached",
|
||||
"remove_attachment": "Remove attachment"
|
||||
},
|
||||
"journal_list": {
|
||||
"import_attn": "This list contains vouchers from an SIE import. If something went wrong, the whole import can be undone.",
|
||||
|
||||
+7
-1
@@ -4910,7 +4910,13 @@
|
||||
"send": "Skicka",
|
||||
"sending": "Skickar...",
|
||||
"send_failed_title": "Kunde inte skicka",
|
||||
"send_failed_fallback": "Försök igen."
|
||||
"send_failed_fallback": "Försök igen.",
|
||||
"attach_label": "Bifoga filer",
|
||||
"attach_hint": "JPG, PNG, WEBP eller PDF. Max {count} filer, {limit} MB totalt.",
|
||||
"attach_too_many": "Du kan bifoga max {count} filer",
|
||||
"attach_too_large": "Bilagorna får väga max {limit} MB tillsammans",
|
||||
"attach_unsupported": "Bara bilder (JPG, PNG, WEBP) och PDF kan bifogas",
|
||||
"remove_attachment": "Ta bort bilaga"
|
||||
},
|
||||
"journal_list": {
|
||||
"import_attn": "Listan innehåller verifikat från en SIE-import. Blev något fel kan hela importen ångras.",
|
||||
|
||||
Reference in New Issue
Block a user