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:
@@ -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' }
|
||||
|
||||
Reference in New Issue
Block a user