diff --git a/src/backend/clients/database/SqliteDatabaseClient.test.ts b/src/backend/clients/database/SqliteDatabaseClient.test.ts index 904aa8bef..7909bfe89 100644 --- a/src/backend/clients/database/SqliteDatabaseClient.test.ts +++ b/src/backend/clients/database/SqliteDatabaseClient.test.ts @@ -27,7 +27,7 @@ import { DatabaseClientFactory } from './index.js'; import { SqliteDatabaseClient } from './SqliteDatabaseClient.js'; /** Highest schema version the migration table can reach. */ -const CURRENT_SCHEMA_VERSION = 82; +const CURRENT_SCHEMA_VERSION = 83; /** * These suites migrate real files on disk. Idle they finish in well under a diff --git a/src/backend/clients/database/SqliteDatabaseClient.ts b/src/backend/clients/database/SqliteDatabaseClient.ts index 9c13d96b4..812f86f60 100644 --- a/src/backend/clients/database/SqliteDatabaseClient.ts +++ b/src/backend/clients/database/SqliteDatabaseClient.ts @@ -116,6 +116,7 @@ const AVAILABLE_MIGRATIONS: [number, string[]][] = [ [79, ['0084_share-anyone-with-link.sql']], [80, ['0085_event-subscriptions-include-value.sql']], [81, ['0086_subdomains-app-owner-cascade.sql']], + [82, ['0087_feedback-attachments.sql']], ]; export class SqliteDatabaseClient extends AbstractDatabaseClient { diff --git a/src/backend/clients/database/migrations/mysql/mysql_mig_42.sql b/src/backend/clients/database/migrations/mysql/mysql_mig_42.sql new file mode 100644 index 000000000..10fc733d8 --- /dev/null +++ b/src/backend/clients/database/migrations/mysql/mysql_mig_42.sql @@ -0,0 +1,39 @@ +-- Copyright (C) 2024-present Puter Technologies Inc. +-- +-- This file is part of Puter. +-- +-- Puter is free software: you can redistribute it and/or modify +-- it under the terms of the GNU Affero General Public License as published +-- by the Free Software Foundation, either version 3 of the License, or +-- (at your option) any later version. +-- +-- This program is distributed in the hope that it will be useful, +-- but WITHOUT ANY WARRANTY; without even the implied warranty of +-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +-- GNU Affero General Public License for more details. +-- +-- You should have received a copy of the GNU Affero General Public License +-- along with this program. If not, see . + +-- Mirrors SQLite migration 0087: Contact Us attachment metadata, a JSON array +-- of `{name, type, size}` (NULL when none). Guarded by a throwaway procedure, +-- as in mysql_mig_21. + +DROP PROCEDURE IF EXISTS _puter_add_feedback_attachments; +DELIMITER // +CREATE PROCEDURE _puter_add_feedback_attachments() +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'feedback' + AND COLUMN_NAME = 'attachments' + ) THEN + ALTER TABLE `feedback` ADD COLUMN `attachments` text DEFAULT NULL; + END IF; +END// +DELIMITER ; + +CALL _puter_add_feedback_attachments(); + +DROP PROCEDURE IF EXISTS _puter_add_feedback_attachments; diff --git a/src/backend/clients/database/migrations/postgres/postgres_mig_31.sql b/src/backend/clients/database/migrations/postgres/postgres_mig_31.sql new file mode 100644 index 000000000..c5bfca982 --- /dev/null +++ b/src/backend/clients/database/migrations/postgres/postgres_mig_31.sql @@ -0,0 +1,21 @@ +-- Copyright (C) 2024-present Puter Technologies Inc. +-- +-- This file is part of Puter. +-- +-- Puter is free software: you can redistribute it and/or modify +-- it under the terms of the GNU Affero General Public License as published +-- by the Free Software Foundation, either version 3 of the License, or +-- (at your option) any later version. +-- +-- This program is distributed in the hope that it will be useful, +-- but WITHOUT ANY WARRANTY; without even the implied warranty of +-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +-- GNU Affero General Public License for more details. +-- +-- You should have received a copy of the GNU Affero General Public License +-- along with this program. If not, see . + +-- Mirrors SQLite migration 0087: Contact Us attachment metadata, a JSON array +-- of `{name, type, size}` (NULL when none). + +ALTER TABLE feedback ADD COLUMN IF NOT EXISTS attachments text DEFAULT NULL; diff --git a/src/backend/clients/database/migrations/sqlite/0087_feedback-attachments.sql b/src/backend/clients/database/migrations/sqlite/0087_feedback-attachments.sql new file mode 100644 index 000000000..233495cbb --- /dev/null +++ b/src/backend/clients/database/migrations/sqlite/0087_feedback-attachments.sql @@ -0,0 +1,21 @@ +-- Copyright (C) 2024-present Puter Technologies Inc. +-- +-- This file is part of Puter. +-- +-- Puter is free software: you can redistribute it and/or modify +-- it under the terms of the GNU Affero General Public License as published +-- by the Free Software Foundation, either version 3 of the License, or +-- (at your option) any later version. +-- +-- This program is distributed in the hope that it will be useful, +-- but WITHOUT ANY WARRANTY; without even the implied warranty of +-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +-- GNU Affero General Public License for more details. +-- +-- You should have received a copy of the GNU Affero General Public License +-- along with this program. If not, see . + +-- Contact Us attachment metadata: a JSON array of `{name, type, size}`, NULL +-- when none. The files themselves go out with the support email. + +ALTER TABLE `feedback` ADD COLUMN "attachments" TEXT DEFAULT NULL; diff --git a/src/backend/controllers/system/SystemController.js b/src/backend/controllers/system/SystemController.js index 95d542ff9..ffee503cc 100644 --- a/src/backend/controllers/system/SystemController.js +++ b/src/backend/controllers/system/SystemController.js @@ -18,6 +18,12 @@ */ import { HttpError } from '../../core/http/HttpError.js'; +import { + MAX_TOTAL_ATTACHMENT_BYTES, + attachmentMetadata, + attachmentSummary, + readContactSubmission, +} from '../../util/contactAttachments.js'; import { PuterController } from '../types.js'; /** @@ -94,6 +100,38 @@ const LSMOD_LIMIT = { key: 'user', }; +/** Longest message the Contact Us form will carry, in characters. */ +const CONTACT_MESSAGE_MAX_LENGTH = 100_000; + +/** UTF-8 worst case for {@link CONTACT_MESSAGE_MAX_LENGTH} UTF-16 code units. */ +const CONTACT_MESSAGE_MAX_BYTES = CONTACT_MESSAGE_MAX_LENGTH * 3; + +/** Largest valid multipart body: attachments, message, part-header slack. */ +const CONTACT_BODY_MAX_BYTES = + MAX_TOTAL_ATTACHMENT_BYTES + CONTACT_MESSAGE_MAX_BYTES + 64 * 1024; + +const isMultipart = (req) => + /^multipart\/form-data\b/i.test(req.headers?.['content-type'] ?? ''); + +/** + * Per user, plus a per-IP backstop against one machine cycling fresh accounts. + * The IP limit sits well above what a shared office would send. + */ +const CONTACT_US_LIMITS = [ + { + scope: 'contact-us', + limit: 10, + window: 15 * 60_000, + key: 'user', + }, + { + scope: 'contact-us-ip', + limit: 40, + window: 24 * 60 * 60_000, + key: 'ip', + }, +]; + export class SystemController extends PuterController { constructor(config, clients, stores, services, drivers) { super(config, clients, stores, services, drivers); @@ -180,33 +218,78 @@ export class SystemController extends PuterController { subdomain: 'api', requireUserActor: true, allowFullAccessToken: true, - rateLimit: { - scope: 'contact-us', - limit: 10, - window: 15 * 60_000, - key: 'user', - }, + rateLimit: CONTACT_US_LIMITS, + // Each in-flight request holds its attachments in memory until + // the mail is sent. + concurrent: { limit: 2, scope: 'contact-us', key: 'user' }, }, async (req, res) => { - const { message } = req.body ?? {}; + let message; + let attachments = []; + if (isMultipart(req)) { + const declaredLength = Number( + req.headers['content-length'], + ); + if ( + Number.isFinite(declaredLength) && + declaredLength > CONTACT_BODY_MAX_BYTES + ) { + // Don't read a body already known to be too large. + res.setHeader('Connection', 'close'); + throw new HttpError(413, 'Request body is too large', { + legacyCode: 'bad_request', + }); + } + const submission = await readContactSubmission(req, { + maxMessageBytes: CONTACT_MESSAGE_MAX_BYTES, + maxBodyBytes: CONTACT_BODY_MAX_BYTES, + }); + if (!submission.ok) { + throw new HttpError( + submission.status, + submission.reason, + { legacyCode: 'bad_request' }, + ); + } + ({ message, attachments } = submission); + } else { + message = req.body?.message; + if (req.body?.attachments !== undefined) { + throw new HttpError( + 400, + '`attachments` must be sent as multipart/form-data', + { legacyCode: 'bad_request' }, + ); + } + } + if (!message || typeof message !== 'string') { throw new HttpError(400, '`message` is required', { legacyCode: 'bad_request', }); } - if (message.length > 100_000) { + if (message.length > CONTACT_MESSAGE_MAX_LENGTH) { throw new HttpError( 400, - '`message` is too long (max 100,000 characters)', + `\`message\` is too long (max ${CONTACT_MESSAGE_MAX_LENGTH.toLocaleString('en-US')} characters)`, { legacyCode: 'bad_request' }, ); } - // Persist to feedback table for durability + // Persist to feedback table for durability. Attachments are + // recorded by name and size only; the mail carries the bytes. try { await this.clients.db.write( - 'INSERT INTO `feedback` (`user_id`, `message`) VALUES (?, ?)', - [req.actor.user.id, message], + 'INSERT INTO `feedback` (`user_id`, `message`, `attachments`) VALUES (?, ?, ?)', + [ + req.actor.user.id, + message, + attachments.length + ? JSON.stringify( + attachmentMetadata(attachments), + ) + : null, + ], ); } catch (e) { console.warn('[contactUs] feedback insert failed:', e); @@ -221,7 +304,16 @@ export class SystemController extends PuterController { to: supportEmail, replyTo: req.actor.user.email, subject: `Contact from ${req.actor.user.username}`, - text: message, + text: attachments.length + ? `${message}\n\n${attachmentSummary(attachments)}` + : message, + // Never rendered inline by the mail client. + attachments: attachments.map((a) => ({ + filename: a.filename, + content: a.content, + contentType: a.contentType, + contentDisposition: 'attachment', + })), }); } catch (e) { console.warn('[contactUs] email send failed:', e); diff --git a/src/backend/controllers/system/SystemController.test.ts b/src/backend/controllers/system/SystemController.test.ts index 142b323ec..b91b7b156 100644 --- a/src/backend/controllers/system/SystemController.test.ts +++ b/src/backend/controllers/system/SystemController.test.ts @@ -18,6 +18,7 @@ */ import type { Request, RequestHandler, Response } from 'express'; +import { Readable } from 'node:stream'; import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest'; import { v4 as uuidv4 } from 'uuid'; import type { Actor } from '../../core/actor.js'; @@ -82,11 +83,12 @@ const makeReq = (init: { body?: unknown; actor?: Actor; query?: Record; + headers?: Record; }): Request => { return { body: init.body ?? {}, query: init.query ?? {}, - headers: {}, + headers: init.headers ?? {}, actor: init.actor, } as unknown as Request; }; @@ -430,11 +432,253 @@ describe('SystemController POST /contactUs', () => { // The row landed in the real `feedback` table for the right user. const rows = (await server.clients.db.read( - 'SELECT `user_id`, `message` FROM `feedback` WHERE `user_id` = ? AND `message` = ?', + 'SELECT `user_id`, `message`, `attachments` FROM `feedback` WHERE `user_id` = ? AND `message` = ?', [userId, message], - )) as Array<{ user_id: number; message: string }>; + )) as Array<{ + user_id: number; + message: string; + attachments: string | null; + }>; expect(rows).toHaveLength(1); expect(rows[0]?.message).toBe(message); + // No files sent — the column stays null rather than an empty array. + expect(rows[0]?.attachments ?? null).toBeNull(); + }); +}); + +describe('SystemController POST /contactUs — attachments', () => { + // A real PNG signature with a filler body; the sniffer only reads the + // first eight bytes, and nothing downstream decodes the image. + const pngBytes = (size = 64): Buffer => + Buffer.concat([ + Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), + Buffer.alloc(size - 8, 0x61), + ]); + + type Part = + | { field: string; value: string } + | { field: string; filename: string; data: Buffer }; + + const BOUNDARY = '----contact-us-test'; + const multipart = (parts: Part[]): Buffer => + Buffer.concat([ + ...parts.flatMap((part) => + 'value' in part + ? [ + Buffer.from( + `--${BOUNDARY}\r\nContent-Disposition: form-data; name="${part.field}"\r\n\r\n${part.value}\r\n`, + ), + ] + : [ + Buffer.from( + `--${BOUNDARY}\r\nContent-Disposition: form-data; name="${part.field}"; filename="${part.filename}"\r\nContent-Type: application/octet-stream\r\n\r\n`, + ), + part.data, + Buffer.from('\r\n'), + ], + ), + Buffer.from(`--${BOUNDARY}--\r\n`), + ]); + + const submitMultipart = async ( + parts: Part[], + headers: Record = {}, + ) => { + const { actor, userId } = await makeUser(); + const { res, captured } = makeRes(); + const body = multipart(parts); + const req = Object.assign(Readable.from([body]), { + headers: { + 'content-type': `multipart/form-data; boundary=${BOUNDARY}`, + 'content-length': String(body.length), + ...headers, + }, + actor, + query: {}, + }) as unknown as Request; + const call = callRoute('post', '/contactUs', req, res); + return { call, captured, userId }; + }; + + const readAttachments = async (userId: number, message: string) => { + const rows = (await server.clients.db.read( + 'SELECT `attachments` FROM `feedback` WHERE `user_id` = ? AND `message` = ?', + [userId, message], + )) as Array<{ attachments: string | null }>; + return rows[0]?.attachments ?? null; + }; + + it('stores attachment metadata — names, types and sizes, no payloads', async () => { + const message = `attached ${Math.random().toString(36).slice(2)}`; + const { call, captured, userId } = await submitMultipart([ + { field: 'message', value: message }, + { + field: 'attachments', + filename: 'repro-step-3.png', + data: pngBytes(128), + }, + ]); + await call; + expect(captured.body).toEqual({}); + + const stored = await readAttachments(userId, message); + expect(JSON.parse(stored!)).toEqual([ + { name: 'repro-step-3.png', type: 'image/png', size: 128 }, + ]); + }); + + it('emails the files to support with sanitized names and a manifest', async () => { + const sendRaw = vi + .spyOn(server.clients.email, 'sendRaw') + .mockResolvedValue(null); + try { + const message = `attached ${Math.random().toString(36).slice(2)}`; + const { call } = await submitMultipart([ + { field: 'message', value: message }, + // Declares .html, but the bytes are a PNG: the stored and + // emailed extension must follow the bytes. + { + field: 'attachments', + filename: 'payload.html', + data: pngBytes(64), + }, + ]); + await call; + + expect(sendRaw).toHaveBeenCalledTimes(1); + const sent = sendRaw.mock.calls[0][0] as { + text: string; + attachments: Array<{ + filename: string; + content: Buffer; + contentType: string; + contentDisposition: string; + }>; + }; + expect(sent.attachments).toHaveLength(1); + expect(sent.attachments[0].filename).toBe('payload.png'); + expect(sent.attachments[0].contentType).toBe('image/png'); + expect(sent.attachments[0].contentDisposition).toBe('attachment'); + expect(sent.attachments[0].content.equals(pngBytes(64))).toBe(true); + // The body records what should have arrived, in case a gateway + // strips the files on the way. + expect(sent.text).toContain(message); + expect(sent.text).toContain('payload.png'); + } finally { + sendRaw.mockRestore(); + } + }); + + it('accepts a multipart submission with no files', async () => { + const sendRaw = vi + .spyOn(server.clients.email, 'sendRaw') + .mockResolvedValue(null); + try { + const message = `plain ${Math.random().toString(36).slice(2)}`; + const { call, captured } = await submitMultipart([ + { field: 'message', value: message }, + ]); + await call; + expect(captured.body).toEqual({}); + const sent = sendRaw.mock.calls[0][0] as { + text: string; + attachments: unknown[]; + }; + expect(sent.attachments).toEqual([]); + expect(sent.text).toBe(message); + } finally { + sendRaw.mockRestore(); + } + }); + + it('rejects base64 attachments in a JSON body', async () => { + const { actor } = await makeUser(); + const { res } = makeRes(); + await expect( + callRoute( + 'post', + '/contactUs', + makeReq({ + body: { + message: 'hi', + attachments: [{ data: pngBytes().toString('base64') }], + }, + actor, + }), + res, + ), + ).rejects.toMatchObject({ statusCode: 400 }); + }); + + it.each([ + [ + 'too many files', + [ + { field: 'message', value: 'hi' }, + ...Array.from({ length: 6 }, (_, i) => ({ + field: 'attachments', + filename: `${i}.png`, + data: pngBytes(), + })), + ], + ], + [ + 'an unsupported type', + [ + { field: 'message', value: 'hi' }, + { + field: 'attachments', + filename: 'a.pdf', + data: Buffer.from('%PDF-1.7 x'), + }, + ], + ], + [ + 'a script-capable SVG', + [ + { field: 'message', value: 'hi' }, + { + field: 'attachments', + filename: 'a.svg', + data: Buffer.from(''), + }, + ], + ], + [ + 'a missing message', + [{ field: 'attachments', filename: 'a.png', data: pngBytes() }], + ], + ] as [string, Part[]][])('rejects %s with 400', async (_label, parts) => { + const { call, captured } = await submitMultipart(parts); + await expect(call).rejects.toMatchObject({ statusCode: 400 }); + expect(captured.body).toBeUndefined(); + }); + + it('refuses a body whose declared length is over budget before reading it', async () => { + const { call, captured } = await submitMultipart( + [{ field: 'message', value: 'hi' }], + { 'content-length': String(64 * 1024 * 1024) }, + ); + await expect(call).rejects.toMatchObject({ statusCode: 413 }); + expect(captured.headers.Connection).toBe('close'); + }); + + it('rejects an upload that breaks a limit mid-stream', async () => { + const { call, captured } = await submitMultipart( + [ + { field: 'message', value: 'hi' }, + { + field: 'attachments', + filename: 'big.png', + data: pngBytes(10 * 1024 * 1024 + 1), + }, + ], + // Chunked uploads carry no length to check up front. + { 'content-length': '' }, + ); + await expect(call).rejects.toMatchObject({ statusCode: 413 }); + // The rest of the body is drained, so the connection can stay open. + expect(captured.headers.Connection).toBeUndefined(); }); }); diff --git a/src/backend/controllers/system/contactUs.http.test.ts b/src/backend/controllers/system/contactUs.http.test.ts new file mode 100644 index 000000000..3d5d641fe --- /dev/null +++ b/src/backend/controllers/system/contactUs.http.test.ts @@ -0,0 +1,136 @@ +/* + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import http from 'node:http'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { setupPuterTestEnv, type PuterTestEnv } from '../../testUtil.js'; + +/** + * Contact Us attachments over real HTTP. Multipart bodies must skip the global + * JSON parser so the route's gates run before any of the body is read; only a + * listening server shows that. + */ +describe('POST /contactUs over HTTP', () => { + let env: PuterTestEnv; + + beforeAll(async () => { + env = await setupPuterTestEnv(); + }, 120_000); + + afterAll(async () => { + await env?.shutdown(); + }); + + const png = (size: number): Buffer => + Buffer.concat([ + Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), + Buffer.alloc(size - 8, 0x61), + ]); + + const BOUNDARY = 'contact-us-http-test'; + const multipartHead = Buffer.from( + `--${BOUNDARY}\r\nContent-Disposition: form-data; name="message"\r\n\r\nhi\r\n` + + `--${BOUNDARY}\r\nContent-Disposition: form-data; name="attachments"; filename="a.png"\r\n` + + 'Content-Type: image/png\r\n\r\n', + ); + + /** Send raw bytes and resolve with the response, however early it comes. */ + const rawPost = ( + headers: Record, + write: (req: http.ClientRequest) => void, + ) => + new Promise<{ status: number; connection?: string }>( + (resolve, reject) => { + const url = new URL('/contactUs', env.apiOrigin); + const req = http.request( + url, + { + method: 'POST', + headers: { + Authorization: `Bearer ${env.users.user.token}`, + 'Content-Type': `multipart/form-data; boundary=${BOUNDARY}`, + ...headers, + }, + }, + (res) => { + res.resume(); + resolve({ + status: res.statusCode ?? 0, + connection: res.headers.connection, + }); + }, + ); + req.on('error', reject); + write(req); + }, + ); + + it('accepts the FormData the Contact Us window sends', async () => { + const message = `http ${Math.random().toString(36).slice(2)}`; + const body = new FormData(); + body.append('message', message); + body.append( + 'attachments', + new Blob([png(4096)], { type: 'image/png' }), + 'shot.png', + ); + + const res = await fetch(new URL('/contactUs', env.apiOrigin), { + method: 'POST', + headers: { Authorization: `Bearer ${env.users.user.token}` }, + body, + }); + expect(res.status).toBe(200); + + const rows = (await env.server.clients.db.read( + 'SELECT `attachments` FROM `feedback` WHERE `message` = ?', + [message], + )) as Array<{ attachments: string | null }>; + expect(JSON.parse(rows[0]!.attachments!)).toEqual([ + { name: 'shot.png', type: 'image/png', size: 4096 }, + ]); + }); + + it('refuses an unauthenticated upload', async () => { + const body = new FormData(); + body.append('message', 'hi'); + const res = await fetch(new URL('/contactUs', env.apiOrigin), { + method: 'POST', + body, + }); + expect(res.status).toBe(401); + }); + + it('answers an oversized Content-Length before the body is sent', async () => { + const res = await rawPost( + { 'Content-Length': String(64 * 1024 * 1024) }, + (req) => req.flushHeaders(), + ); + expect(res).toEqual({ status: 413, connection: 'close' }); + }); + + it('answers a chunked upload that breaks the per-file cap with 413', async () => { + const file = png(12 * 1024 * 1024); + const res = await rawPost({ 'Transfer-Encoding': 'chunked' }, (req) => { + req.write(multipartHead); + req.end(file); + }); + expect(res.status).toBe(413); + }); +}); diff --git a/src/backend/util/appIcon.ts b/src/backend/util/appIcon.ts index 34b04bf46..cf0f68d1d 100644 --- a/src/backend/util/appIcon.ts +++ b/src/backend/util/appIcon.ts @@ -17,6 +17,15 @@ * along with this program. If not, see . */ +import { + SVG_SNIFF_WINDOW, + decodeStrictBase64, + sniffImageMime, +} from './mediaSniff.js'; + +// Re-exported for existing importers. +export { SVG_SNIFF_WINDOW, sniffImageMime }; + // Icon URLs come in a pair. `getAppIconUrl` builds the backend // `/app-icon//` endpoint URL, which self-heals — it falls back to // the un-resized original, decodes data URLs inline, or serves the default @@ -65,95 +74,18 @@ export interface AppIconHostConfig extends TrustedIconHostConfig { } const RAW_BASE64_REGEX = /^[A-Za-z0-9+/]+={0,2}$/; -const BASE64_CHARS_REGEX = /^[A-Za-z0-9+/]*={0,2}$/; // `data:/[;param[=value]]…,`. The parameter list is // matched as a group of its own so it can be checked exhaustively — the // previous prefix-scan only looked at the bytes before the first `;` or `,` // and never inspected the payload at all. const DATA_URL_REGEX = /^data:([a-z0-9][a-z0-9!#$&^_.+-]*\/[a-z0-9][a-z0-9!#$&^_.+-]*)((?:;[a-z0-9!#$&^_.+-]+(?:=[^;,]*)?)*),([\s\S]*)$/i; -// Cap on how far into a payload we look for the `. + */ + +import { PassThrough, Readable } from 'node:stream'; +import { describe, expect, it } from 'vitest'; +import { + MAX_ATTACHMENTS, + MAX_ATTACHMENT_BYTES, + MAX_TOTAL_ATTACHMENT_BYTES, + type ValidatedAttachment, + attachmentMetadata, + attachmentSummary, + readContactSubmission, + sanitizeAttachmentName, + validateAttachment, +} from './contactAttachments.js'; + +// -- Fixtures -------------------------------------------------------- +// +// Real magic numbers with filler bodies. `pad` sizes a payload without +// disturbing the header the sniffer reads. + +const pad = (header: Buffer, size: number): Buffer => + Buffer.concat([ + header, + Buffer.alloc(Math.max(0, size - header.length), 0x61), + ]); + +const PNG_HEADER = Buffer.from([ + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, +]); +const JPEG_HEADER = Buffer.from([0xff, 0xd8, 0xff, 0xe0]); +const GIF_HEADER = Buffer.from('GIF89a', 'latin1'); +const WEBP_HEADER = Buffer.concat([ + Buffer.from('RIFF', 'latin1'), + Buffer.from([0x00, 0x00, 0x00, 0x00]), + Buffer.from('WEBP', 'latin1'), +]); +const mp4 = (brand: string): Buffer => + Buffer.concat([ + Buffer.from([0x00, 0x00, 0x00, 0x18]), + Buffer.from('ftyp', 'latin1'), + Buffer.from(brand, 'latin1'), + ]); +const WEBM_HEADER = Buffer.concat([ + Buffer.from([0x1a, 0x45, 0xdf, 0xa3]), + Buffer.from('\x42\x82\x84webm', 'latin1'), +]); + +const png = (size = 64): Buffer => pad(PNG_HEADER, size); + +type Part = + | { field: string; value: string } + | { field: string; filename: string; data: Buffer }; + +const BOUNDARY = '----contact-attachments-test'; +const HEADERS = { + 'content-type': `multipart/form-data; boundary=${BOUNDARY}`, +}; + +const multipart = (parts: Part[]): Buffer => + Buffer.concat([ + ...parts.flatMap((part) => + 'value' in part + ? [ + Buffer.from( + `--${BOUNDARY}\r\nContent-Disposition: form-data; name="${part.field}"\r\n\r\n${part.value}\r\n`, + ), + ] + : [ + Buffer.from( + `--${BOUNDARY}\r\nContent-Disposition: form-data; name="${part.field}"; filename="${part.filename}"\r\nContent-Type: image/png\r\n\r\n`, + ), + part.data, + Buffer.from('\r\n'), + ], + ), + Buffer.from(`--${BOUNDARY}--\r\n`), + ]); + +const file = (data: Buffer, filename = 'shot.png'): Part => ({ + field: 'attachments', + filename, + data, +}); + +const LIMITS = { maxMessageBytes: 1024, maxBodyBytes: 64 * 1024 * 1024 }; + +const read = (parts: Part[], headers: Record = HEADERS) => + readContactSubmission( + Object.assign(Readable.from([multipart(parts)]), { headers }), + LIMITS, + ); + +const accepted = (attachment: ReturnType) => { + if (!attachment.ok) throw new Error(attachment.reason); + return attachment.attachment; +}; + +describe('validateAttachment — the type allow-list', () => { + it.each([ + ['png', PNG_HEADER, 'image/png', 'png'], + ['jpeg', JPEG_HEADER, 'image/jpeg', 'jpg'], + ['gif', GIF_HEADER, 'image/gif', 'gif'], + ['webp', WEBP_HEADER, 'image/webp', 'webp'], + ['mp4', mp4('isom'), 'video/mp4', 'mp4'], + ['quicktime', mp4('qt '), 'video/quicktime', 'mov'], + ['webm', WEBM_HEADER, 'video/webm', 'webm'], + ])( + 'accepts %s and reports its sniffed type', + (_label, header, mime, ext) => { + const attachment = accepted( + validateAttachment(pad(header, 64), `capture.${ext}`, 0), + ); + expect(attachment.contentType).toBe(mime); + expect(attachment.filename).toBe(`capture.${ext}`); + expect(attachment.size).toBe(64); + }, + ); + + it('rejects SVG, which is script-capable even though it is an image', () => { + const svg = Buffer.from( + '', + ); + expect(validateAttachment(svg, 'x.png', 0)).toMatchObject({ + ok: false, + reason: expect.stringContaining('not a supported image or video'), + }); + }); + + it.each([ + ['HTML', Buffer.from('hi')], + ['a Windows executable', Buffer.from('MZ\x90\x00\x03\x00\x00\x00')], + ['a zip/office file', Buffer.from('PK\x03\x04nonsense')], + ['a PDF', Buffer.from('%PDF-1.7\nnonsense')], + ['plain text', Buffer.from('just some text, nothing to see here')], + ['HEIC (an ISO container that is not video)', mp4('heic')], + ['M4A audio (an ISO container that is not video)', mp4('M4A ')], + ])('rejects %s', (_label, payload) => { + expect(validateAttachment(pad(payload, 64), 'evidence.png', 0).ok).toBe( + false, + ); + }); + + it('rejects an empty file', () => { + expect(validateAttachment(Buffer.alloc(0), 'a.png', 1)).toEqual({ + ok: false, + reason: 'attachment 2 is empty', + }); + }); + + it('names the offending file without echoing caller input back', () => { + const verdict = validateAttachment( + Buffer.from('nope!!'), + '', + 1, + ); + expect(verdict.ok).toBe(false); + if (verdict.ok) return; + expect(verdict.reason).toContain('attachment 2'); + expect(verdict.reason).not.toContain('alert'); + }); +}); + +describe('readContactSubmission — accepted shapes', () => { + it('reads the message and files, typed from their bytes', async () => { + const result = await read([ + { field: 'message', value: 'it broke' }, + file(png(128), 'a.mp4'), + file(pad(mp4('isom'), 256), 'screen.mov'), + ]); + expect(result).toMatchObject({ ok: true, message: 'it broke' }); + if (!result.ok) return; + expect( + result.attachments.map(({ filename, contentType, size }) => ({ + filename, + contentType, + size, + })), + ).toEqual([ + { filename: 'a.png', contentType: 'image/png', size: 128 }, + { filename: 'screen.mp4', contentType: 'video/mp4', size: 256 }, + ]); + expect(result.attachments[0].content.equals(png(128))).toBe(true); + }); + + it('accepts a message with no files', async () => { + expect(await read([{ field: 'message', value: 'hi' }])).toEqual({ + ok: true, + message: 'hi', + attachments: [], + }); + }); + + it('leaves a missing message for the caller to reject', async () => { + const result = await read([file(png())]); + expect(result).toMatchObject({ ok: true, message: undefined }); + }); + + it('accepts exactly the maximum number of files', async () => { + const result = await read([ + { field: 'message', value: 'hi' }, + ...Array.from({ length: MAX_ATTACHMENTS }, () => file(png())), + ]); + expect(result.ok).toBe(true); + }); + + it('accepts a file exactly at the per-file cap', async () => { + const result = await read([ + { field: 'message', value: 'hi' }, + file(png(MAX_ATTACHMENT_BYTES)), + ]); + expect(result.ok).toBe(true); + }); + + it('keeps non-ASCII file names intact', async () => { + const result = await read([ + { field: 'message', value: 'hi' }, + file(png(), 'captura de pantalla — día 3.png'), + ]); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.attachments[0].filename).toBe( + 'captura de pantalla — día 3.png', + ); + }); +}); + +describe('readContactSubmission — limits', () => { + it('rejects more files than the count cap', async () => { + const result = await read([ + { field: 'message', value: 'hi' }, + ...Array.from({ length: MAX_ATTACHMENTS + 1 }, () => file(png())), + ]); + expect(result).toMatchObject({ + ok: false, + status: 400, + reason: expect.stringContaining('too many attachments'), + }); + }); + + it('rejects a single file over the per-file cap', async () => { + const result = await read([ + { field: 'message', value: 'hi' }, + file(png(MAX_ATTACHMENT_BYTES + 1)), + ]); + expect(result).toMatchObject({ + ok: false, + status: 413, + reason: 'attachment 1 is too large (max 10 MB per file)', + }); + }); + + it('rejects files that are individually fine but too large together', async () => { + const each = Math.ceil(MAX_TOTAL_ATTACHMENT_BYTES / 2) + 1024; + const result = await read([ + { field: 'message', value: 'hi' }, + file(png(each)), + file(png(each)), + ]); + expect(result).toMatchObject({ + ok: false, + status: 413, + reason: expect.stringContaining('in total'), + }); + }); + + it('discards the rest of the body after a broken limit', async () => { + const req = Object.assign(new PassThrough(), { headers: HEADERS }); + const pending = readContactSubmission(req, LIMITS); + const body = multipart([ + { field: 'message', value: 'hi' }, + file(png(MAX_ATTACHMENT_BYTES + 4096)), + ]); + req.write(body.subarray(0, body.length - 1024)); + await expect(pending).resolves.toMatchObject({ + ok: false, + status: 413, + }); + + // Drained to the end rather than cut off, so the error response can + // still be delivered. + const ended = new Promise((resolve) => req.once('end', resolve)); + req.end(body.subarray(body.length - 1024)); + await ended; + expect(req.readableEnded).toBe(true); + }); + + it('destroys a request that runs past the body budget', async () => { + const req = Object.assign(new PassThrough(), { headers: HEADERS }); + const pending = readContactSubmission(req, { + ...LIMITS, + maxBodyBytes: 4096, + }); + req.write( + multipart([{ field: 'message', value: 'hi' }, file(png(8192))]), + ); + await expect(pending).resolves.toEqual({ + ok: false, + status: 413, + reason: 'request body is too large', + }); + expect(req.destroyed).toBe(true); + }); + + it('accepts a message exactly at the byte budget', async () => { + const result = await read([ + { field: 'message', value: 'x'.repeat(1024) }, + ]); + expect(result.ok).toBe(true); + }); + + it('rejects a message past the byte budget', async () => { + const result = await read([ + { field: 'message', value: 'x'.repeat(1025) }, + ]); + expect(result).toMatchObject({ + ok: false, + reason: expect.stringContaining('too long'), + }); + }); +}); + +describe('readContactSubmission — malformed bodies', () => { + it('rejects a content type without a boundary', async () => { + const result = await read([{ field: 'message', value: 'hi' }], { + 'content-type': 'multipart/form-data', + }); + expect(result).toMatchObject({ + ok: false, + reason: 'malformed multipart body', + }); + }); + + it.each([ + ['an unknown text field', [{ field: 'note', value: 'x' }]], + [ + 'a duplicate message', + [ + { field: 'message', value: 'a' }, + { field: 'message', value: 'b' }, + ], + ], + [ + 'a file under another field name', + [ + { field: 'message', value: 'hi' }, + { ...file(png()), field: 'file' }, + ], + ], + ] as [string, Part[]][])('rejects %s', async (_label, parts) => { + expect(await read(parts)).toMatchObject({ + ok: false, + reason: 'unexpected form field', + }); + }); + + it('rejects a body cut off mid-file without throwing', async () => { + const body = multipart([ + { field: 'message', value: 'hi' }, + file(png(4096)), + ]); + const result = await readContactSubmission( + Object.assign(Readable.from([body.subarray(0, 2048)]), { + headers: HEADERS, + }), + LIMITS, + ); + expect(result).toMatchObject({ ok: false, status: 400 }); + }); + + it('rejects a submission whose file is not an allowed type', async () => { + const result = await read([ + { field: 'message', value: 'hi' }, + file(png()), + file(Buffer.from('%PDF-1.7\nnonsense'), 'b.pdf'), + ]); + expect(result).toMatchObject({ + ok: false, + status: 400, + reason: 'attachment 2 is not a supported image or video', + }); + }); +}); + +describe('sanitizeAttachmentName', () => { + it('re-derives the extension from the sniffed type', () => { + expect(sanitizeAttachmentName('payload.html', 0, 'png')).toBe( + 'payload.png', + ); + expect(sanitizeAttachmentName('installer.exe', 0, 'mp4')).toBe( + 'installer.mp4', + ); + }); + + it('keeps only the basename of a path', () => { + expect(sanitizeAttachmentName('../../etc/passwd', 0, 'png')).toBe( + 'passwd.png', + ); + expect(sanitizeAttachmentName('C:\\Windows\\notes.txt', 0, 'png')).toBe( + 'notes.png', + ); + }); + + it('strips characters that would break out of a header', () => { + const name = sanitizeAttachmentName( + 'bug\r\nBcc: victim@example.com"; x="y', + 0, + 'png', + ); + expect(name).not.toMatch(/[\r\n"';\\]/); + expect(name.endsWith('.png')).toBe(true); + }); + + it('strips bidi overrides used to disguise an extension', () => { + const name = sanitizeAttachmentName('report\u202Egnp.exe', 0, 'png'); + expect(name).not.toContain('\u202E'); + expect(name.endsWith('.png')).toBe(true); + }); + + it('never produces a leading dot or a traversal segment', () => { + expect(sanitizeAttachmentName('..', 0, 'png')).toBe('attachment-1.png'); + expect(sanitizeAttachmentName('.bashrc', 0, 'png')).toBe( + 'attachment-1.png', + ); + }); + + it('falls back to a positional name when nothing usable survives', () => { + expect(sanitizeAttachmentName(undefined, 2, 'mp4')).toBe( + 'attachment-3.mp4', + ); + expect(sanitizeAttachmentName(' ', 0, 'png')).toBe( + 'attachment-1.png', + ); + expect(sanitizeAttachmentName(42, 0, 'png')).toBe('attachment-1.png'); + }); + + it('bounds the length of a name it keeps', () => { + const name = sanitizeAttachmentName('x'.repeat(500), 0, 'png'); + expect(name.length).toBeLessThanOrEqual(90); + }); +}); + +describe('attachment reporting helpers', () => { + const shot = (size: number): ValidatedAttachment => + accepted(validateAttachment(png(size), 'shot.png', 0)); + + it('records names, types and sizes but never payloads', () => { + const meta = attachmentMetadata([shot(128)]); + expect(meta).toEqual([ + { name: 'shot.png', type: 'image/png', size: 128 }, + ]); + expect(JSON.stringify(meta)).not.toContain('PNG'); + }); + + it('summarizes what was attached for the email body', () => { + const summary = attachmentSummary([shot(2048)]); + expect(summary).toContain('Attachments (1)'); + expect(summary).toContain('shot.png'); + expect(summary).toContain('2.0 KB'); + }); +}); diff --git a/src/backend/util/contactAttachments.ts b/src/backend/util/contactAttachments.ts new file mode 100644 index 000000000..d77621115 --- /dev/null +++ b/src/backend/util/contactAttachments.ts @@ -0,0 +1,318 @@ +/* + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import Busboy from 'busboy'; +import type { IncomingHttpHeaders } from 'node:http'; +import type { Readable } from 'node:stream'; +import { sniffImageMime, sniffVideoMime } from './mediaSniff.js'; + +/** + * Screenshots and recordings attached to Contact Us, which end up in support's + * inbox. Type comes from the bytes and the file name is rebuilt, so nothing the + * sender declared reaches the mail. + */ + +/** Max files on one submission. */ +export const MAX_ATTACHMENTS = 5; + +/** Max decoded size of any one file. */ +export const MAX_ATTACHMENT_BYTES = 10 * 1024 * 1024; + +/** + * Max size of all files on one submission; base64 in the mail stays under 25 + * MB. + */ +export const MAX_TOTAL_ATTACHMENT_BYTES = 15 * 1024 * 1024; + +/** Max characters kept from the caller's file name, before the extension. */ +export const MAX_ATTACHMENT_NAME_LENGTH = 80; + +/** + * Accepted sniffed MIME types and the extension each is sent under. SVG is + * excluded: it carries script. + */ +export const ATTACHMENT_MIME_EXTENSIONS: Readonly> = { + 'image/png': 'png', + 'image/jpeg': 'jpg', + 'image/gif': 'gif', + 'image/webp': 'webp', + 'video/mp4': 'mp4', + 'video/quicktime': 'mov', + 'video/webm': 'webm', +}; + +/** + * Stripped from file names: control characters (CR/LF could break out of + * `Content-Disposition`), bidi overrides that disguise an extension, and + * quotes. + */ +const UNSAFE_NAME_CHARS_REGEX = + /[\u0000-\u001F\u007F-\u009F\u200E\u200F\u202A-\u202E\u2066-\u2069"'\\;]/g; + +const mb = (bytes: number): number => Math.round(bytes / (1024 * 1024)); +const TOO_LARGE_CLAUSE = `is too large (max ${mb(MAX_ATTACHMENT_BYTES)} MB per file)`; + +/** One validated attachment, in the shape nodemailer takes. */ +export interface ValidatedAttachment { + /** Safe display name; extension always matches `contentType`. */ + filename: string; + /** Sniffed, allow-listed MIME type. */ + contentType: string; + content: Buffer; + size: number; +} + +/** Stored on the feedback row in place of the payloads. */ +export interface AttachmentMetadata { + name: string; + type: string; + size: number; +} + +export type AttachmentVerdict = + | { ok: true; attachment: ValidatedAttachment } + | { ok: false; reason: string }; + +export type ContactSubmissionVerdict = + | { + ok: true; + /** The `message` field as sent; the caller validates it. */ + message: string | undefined; + attachments: ValidatedAttachment[]; + } + | { ok: false; status: 400 | 413; reason: string }; + +/** + * Reduce a caller-supplied file name to a safe display label and give it the + * extension implied by `extension` (derived from the sniffed type, never from + * the name). Falls back to `attachment-` when nothing usable survives. + */ +export function sanitizeAttachmentName( + raw: unknown, + index: number, + extension: string, +): string { + let base = ''; + if (typeof raw === 'string') { + base = (raw.split(/[/\\]/).pop() ?? '') + .normalize('NFC') + .replace(UNSAFE_NAME_CHARS_REGEX, '') + // Drop the caller's extension — the real one is appended below. + .replace(/\.[A-Za-z0-9]{1,10}$/, '') + // No hidden files or `..` if the name is ever written to disk. + .replace(/^[.\s]+/, '') + .replace(/\s+/g, ' ') + .trim() + .slice(0, MAX_ATTACHMENT_NAME_LENGTH) + .trim(); + } + if (base.length === 0) base = `attachment-${index + 1}`; + return `${base}.${extension}`; +} + +/** Check a file's sniffed type against the allow-list; give it a safe name. */ +export function validateAttachment( + bytes: Buffer, + rawName: unknown, + index: number, +): AttachmentVerdict { + const label = `attachment ${index + 1}`; + if (bytes.length === 0) { + return { ok: false, reason: `${label} is empty` }; + } + const sniffed = sniffImageMime(bytes) ?? sniffVideoMime(bytes); + const extension = sniffed ? ATTACHMENT_MIME_EXTENSIONS[sniffed] : undefined; + if (!sniffed || !extension) { + return { + ok: false, + reason: `${label} is not a supported image or video`, + }; + } + return { + ok: true, + attachment: { + filename: sanitizeAttachmentName(rawName, index, extension), + contentType: sniffed, + content: bytes, + size: bytes.length, + }, + }; +} + +/** + * Read a multipart submission: one `message` field plus `attachments` files. + * Caps are enforced as parts arrive. On failure the rest of the body is + * discarded so the error still reaches the client, up to `maxBodyBytes`; past + * that the request is destroyed. `reason` never echoes caller input. + */ +export function readContactSubmission( + req: Readable & { headers: IncomingHttpHeaders }, + { + maxMessageBytes, + maxBodyBytes, + }: { maxMessageBytes: number; maxBodyBytes: number }, +): Promise { + return new Promise((resolve) => { + let parser: Busboy.Busboy; + try { + parser = Busboy({ + headers: req.headers, + // Browsers send non-ASCII file names as raw UTF-8. + defParamCharset: 'utf8', + // Busboy trips a limit on reaching it, hence the + 1. + limits: { + fields: 1, + fieldSize: maxMessageBytes + 1, + files: MAX_ATTACHMENTS, + fileSize: MAX_ATTACHMENT_BYTES + 1, + }, + }); + } catch { + // Missing or malformed boundary. + resolve({ + ok: false, + status: 400, + reason: 'malformed multipart body', + }); + return; + } + + let message: string | undefined; + const received: { name: string; chunks: Buffer[]; size: number }[] = []; + let totalBytes = 0; + let settled = false; + + const fail = (status: 400 | 413, reason: string) => { + if (settled) return; + settled = true; + req.unpipe(parser); + received.length = 0; + req.resume(); + resolve({ ok: false, status, reason }); + }; + + parser.on('field', (name, value, info) => { + if (name !== 'message') { + return fail(400, 'unexpected form field'); + } + if (info.valueTruncated) { + return fail(400, '`message` is too long'); + } + message = value; + }); + + parser.on('file', (name, stream, info) => { + // Busboy errors an unfinished file stream when the body ends + // early; unhandled, that crashes the process. + stream.on('error', () => + fail(400, 'attachment upload was interrupted'), + ); + if (settled) { + stream.resume(); + return; + } + if (name !== 'attachments') { + stream.resume(); + return fail(400, 'unexpected form field'); + } + + const label = `attachment ${received.length + 1}`; + const entry = { + name: info.filename, + chunks: [] as Buffer[], + size: 0, + }; + received.push(entry); + stream.on('limit', () => fail(413, `${label} ${TOO_LARGE_CLAUSE}`)); + stream.on('data', (chunk: Buffer) => { + if (settled) return; + totalBytes += chunk.length; + if (totalBytes > MAX_TOTAL_ATTACHMENT_BYTES) { + return fail( + 413, + `attachments are too large in total (max ${mb(MAX_TOTAL_ATTACHMENT_BYTES)} MB)`, + ); + } + entry.chunks.push(chunk); + entry.size += chunk.length; + }); + }); + + parser.on('fieldsLimit', () => fail(400, 'unexpected form field')); + parser.on('filesLimit', () => + fail(400, `too many attachments (max ${MAX_ATTACHMENTS})`), + ); + parser.on('error', () => fail(400, 'malformed multipart body')); + req.on('error', () => fail(400, 'attachment upload was interrupted')); + + parser.on('close', () => { + if (settled) return; + settled = true; + const attachments: ValidatedAttachment[] = []; + for (const [i, entry] of received.entries()) { + const verdict = validateAttachment( + Buffer.concat(entry.chunks, entry.size), + entry.name, + i, + ); + if (verdict.ok === false) { + resolve({ ok: false, status: 400, reason: verdict.reason }); + return; + } + attachments.push(verdict.attachment); + } + resolve({ ok: true, message, attachments }); + }); + + req.pipe(parser); + + let bytesRead = 0; + req.on('data', (chunk: Buffer) => { + bytesRead += chunk.length; + if (bytesRead <= maxBodyBytes) return; + fail(413, 'request body is too large'); + req.destroy(); + }); + }); +} + +/** Names and sizes for the stored feedback row — never the payloads. */ +export function attachmentMetadata( + attachments: ValidatedAttachment[], +): AttachmentMetadata[] { + return attachments.map((a) => ({ + name: a.filename, + type: a.contentType, + size: a.size, + })); +} + +/** Body manifest, so a recipient can tell if a gateway stripped the files. */ +export function attachmentSummary(attachments: ValidatedAttachment[]): string { + const lines = attachments.map( + (a) => `- ${a.filename} (${a.contentType}, ${formatBytes(a.size)})`, + ); + return [`Attachments (${attachments.length}):`, ...lines].join('\n'); +} + +function formatBytes(bytes: number): string { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; +} diff --git a/src/backend/util/mediaSniff.ts b/src/backend/util/mediaSniff.ts new file mode 100644 index 000000000..24b53d71a --- /dev/null +++ b/src/backend/util/mediaSniff.ts @@ -0,0 +1,150 @@ +/* + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +// Identify uploaded media by its bytes. A caller-supplied MIME type or file +// extension is never trusted. + +const BASE64_CHARS_REGEX = /^[A-Za-z0-9+/]*={0,2}$/; + +/** + * How far into a payload to look for an `]/i.test(head); +} + +/** + * Canonical image MIME type for `bytes`, or null. Recognized is not accepted: + * this can return script-capable `image/svg+xml`, so callers allow-list. + */ +export function sniffImageMime(bytes: Buffer): string | null { + if ( + bytes.length >= 8 && + bytes[0] === 0x89 && + bytes[1] === 0x50 && + bytes[2] === 0x4e && + bytes[3] === 0x47 && + bytes[4] === 0x0d && + bytes[5] === 0x0a && + bytes[6] === 0x1a && + bytes[7] === 0x0a + ) { + return 'image/png'; + } + if ( + bytes.length >= 3 && + bytes[0] === 0xff && + bytes[1] === 0xd8 && + bytes[2] === 0xff + ) { + return 'image/jpeg'; + } + if (bytes.length >= 6) { + const head = bytes.subarray(0, 6).toString('latin1'); + if (head === 'GIF87a' || head === 'GIF89a') return 'image/gif'; + } + if ( + bytes.length >= 12 && + bytes.subarray(0, 4).toString('latin1') === 'RIFF' && + bytes.subarray(8, 12).toString('latin1') === 'WEBP' + ) { + return 'image/webp'; + } + if (looksLikeSvg(bytes)) return 'image/svg+xml'; + return null; +} + +/** + * Canonical video MIME type for `bytes` (MP4, QuickTime, WebM, Matroska), or + * null. + */ +export function sniffVideoMime(bytes: Buffer): string | null { + // ISO Base Media: a `ftyp` box at offset 4, major brand at offset 8. + if ( + bytes.length >= 12 && + bytes.subarray(4, 8).toString('latin1') === 'ftyp' + ) { + const brand = bytes.subarray(8, 12).toString('latin1'); + if (brand === 'qt ') return 'video/quicktime'; + return MP4_BRANDS.has(brand) ? 'video/mp4' : null; + } + // EBML: Matroska and WebM share the magic; the DocType tells them apart. + if ( + bytes.length >= 4 && + bytes[0] === 0x1a && + bytes[1] === 0x45 && + bytes[2] === 0xdf && + bytes[3] === 0xa3 + ) { + const head = bytes.subarray(0, EBML_SNIFF_WINDOW).toString('latin1'); + if (head.includes('webm')) return 'video/webm'; + if (head.includes('matroska')) return 'video/x-matroska'; + } + return null; +} diff --git a/src/gui/src/UI/UIWindowFeedback.js b/src/gui/src/UI/UIWindowFeedback.js index 6f426a689..63ee8608e 100644 --- a/src/gui/src/UI/UIWindowFeedback.js +++ b/src/gui/src/UI/UIWindowFeedback.js @@ -17,13 +17,37 @@ * along with this program. If not, see . */ -import UIAlert from './UIAlert.js'; +import { + ATTACHMENT_ACCEPT_ATTRIBUTE, + MAX_ATTACHMENTS, + MAX_ATTACHMENT_BYTES, + MAX_TOTAL_ATTACHMENT_BYTES, + checkAttachment, +} from '../helpers/contact_attachments.js'; import UIWindow from './UIWindow.js'; -async function UIWindowQR (options) { +const MB = 1024 * 1024; + +// Numbers interpolated into each error message, taken from the enforced limits. +const ERROR_VALUES = { + contact_us_attachment_too_many: [MAX_ATTACHMENTS], + contact_us_attachment_too_large: [MAX_ATTACHMENT_BYTES / MB], + contact_us_attachment_total_too_large: [MAX_TOTAL_ATTACHMENT_BYTES / MB], +}; + +/** + * Puter's own Contact Us form, delivered to support (UIWindowAppFeedback is the + * one for third-party apps). Attachments are posted as multipart form data; the + * client-side checks only fail fast, the server enforces the limits. + */ +async function UIWindowFeedback (options) { return new Promise(async (resolve) => { options = options ?? {}; + // Staged files; kept out of the DOM so re-rendering chips keeps them. + let attachments = []; + let sending = false; + let h = ''; h += '
'; // success @@ -35,6 +59,14 @@ async function UIWindowQR (options) { h += ''; h += '
'; @@ -56,7 +88,7 @@ async function UIWindowQR (options) { init_center: true, allow_native_ctxmenu: false, allow_user_select: false, - width: 350, + width: 380, height: 'auto', dominant: true, show_in_taskbar: false, @@ -73,30 +105,129 @@ async function UIWindowQR (options) { }, }); - $(el_window).find('.send-feedback-btn').on('click', function (e) { - const message = $(el_window).find('.feedback-message').val(); - if ( message ) - { - $(this).prop('disabled', true); - } - $.ajax({ - url: `${window.api_origin }/contactUs`, - type: 'POST', - async: true, - contentType: 'application/json', - headers: { - 'Authorization': `Bearer ${window.auth_token}`, - }, - data: JSON.stringify({ - message: message, - }), - success: async function (data) { - $(el_window).find('.feedback-form').hide(); - $(el_window).find('.feedback-sent-success').show(100); - }, + const $error = $(el_window).find('.feedback-error'); + // `.html()` rather than `.text()`: i18n() html-encodes what it returns, + // so text() would render the entities literally. + const showError = (key) => $error.html(i18n(key, ERROR_VALUES[key] ?? [])).show(); + const clearError = () => $error.hide().empty(); + + // -- Attachments ------------------------------------------------- + + const renderAttachments = () => { + const $list = $(el_window).find('.feedback-attachment-list').empty(); + attachments.forEach((file, index) => { + const removeLabel = i18n('contact_us_attachment_remove'); + const $item = $( + '', + ); + $item.find('.feedback-attachment-remove').on('click', () => { + if ( sending ) return; + attachments.splice(index, 1); + clearError(); + renderAttachments(); + }); + $list.append($item); }); + // Nothing left to add once the count cap is reached. + $(el_window).find('.feedback-attach-btn') + .prop('disabled', sending || attachments.length >= MAX_ATTACHMENTS); + }; + + const addFiles = (files) => { + if ( sending ) return; + let rejection = null; + for ( const file of files ) { + const verdict = checkAttachment(file, attachments); + if ( ! verdict.ok ) { + // Report the first rejection but keep the files that fit. + rejection = rejection ?? verdict.error; + continue; + } + attachments.push(file); + } + if ( rejection ) showError(rejection); + else clearError(); + renderAttachments(); + }; + + const $fileInput = $(el_window).find('.feedback-attach-input'); + $(el_window).find('.feedback-attach-btn').on('click', () => $fileInput.trigger('click')); + $fileInput.on('change', function () { + addFiles(Array.from(this.files ?? [])); + // Reset so re-picking the same file fires `change` again. + this.value = ''; }); + + // Caught here before UIWindow's body-level drop handler sees it. + const $form = $(el_window).find('.feedback-form'); + $form.on('dragover dragenter', (e) => { + e.preventDefault(); + e.stopPropagation(); + if ( ! sending ) $form.addClass('feedback-form-dragover'); + }); + $form.on('dragleave dragend', () => $form.removeClass('feedback-form-dragover')); + $form.on('drop', (e) => { + e.preventDefault(); + e.stopPropagation(); + $form.removeClass('feedback-form-dragover'); + addFiles(Array.from(e.originalEvent?.dataTransfer?.files ?? [])); + }); + + // -- Submit -------------------------------------------------------- + + const setSending = (value) => { + sending = value; + $(el_window).find('.send-feedback-btn').prop('disabled', value); + $(el_window).find('.feedback-attachment-remove').prop('disabled', value); + renderAttachments(); + }; + + $(el_window).find('.send-feedback-btn').on('click', async function () { + if ( sending ) return; + const message = $(el_window).find('.feedback-message').val(); + if ( ! message || ! message.trim() ) { + showError('contact_us_message_required'); + return; + } + + clearError(); + setSending(true); + try { + const body = new FormData(); + body.append('message', message); + for ( const file of attachments ) { + body.append('attachments', file, file.name); + } + + // No Content-Type: the browser sets it with the boundary. + const resp = await fetch(`${window.api_origin}/contactUs`, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${window.auth_token}`, + }, + body, + }); + if ( ! resp.ok ) { + showError(resp.status === 429 ? 'contact_us_rate_limited' : 'contact_us_error'); + return; + } + $(el_window).find('.feedback-form').hide(); + $(el_window).find('.feedback-sent-success').show(100); + } catch ( e ) { + console.error('contact-us: submit failed', e); + showError('contact_us_error'); + } finally { + setSending(false); + } + }); + + renderAttachments(); + resolve(el_window); }); } -export default UIWindowQR; \ No newline at end of file +export default UIWindowFeedback; diff --git a/src/gui/src/css/style.css b/src/gui/src/css/style.css index fecd43aad..725f9934d 100644 --- a/src/gui/src/css/style.css +++ b/src/gui/src/css/style.css @@ -2558,6 +2558,93 @@ label { position: relative; } +/* Contact Us attachments (screenshots and screen recordings on bug reports) */ +.feedback-form { + border: 1px dashed transparent; + border-radius: 4px; + /* Hold the dashed drop outline off the fields without shifting them. */ + margin: -6px; + padding: 6px; +} + +.feedback-form-dragover { + border-color: #4092da; + background-color: rgb(64 146 218 / 6%); +} + +.feedback-attachments { + margin-top: 10px; +} + +.feedback-attach-hint { + display: block; + margin-top: 6px; + font-size: 12px; + color: #5f6b7a; +} + +.feedback-attachment-list { + list-style: none; + margin: 8px 0 0; + padding: 0; +} + +.feedback-attachment { + display: flex; + align-items: center; + gap: 8px; + padding: 5px 8px; + margin-bottom: 4px; + font-size: 13px; + border: 1px solid #dfe3e8; + border-radius: 4px; + background-color: #fff; +} + +.feedback-attachment-name { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.feedback-attachment-size { + flex-shrink: 0; + color: #5f6b7a; + font-size: 12px; +} + +.feedback-attachment-remove { + flex-shrink: 0; + width: 20px; + height: 20px; + padding: 0; + border: none; + border-radius: 3px; + background: none; + color: #5f6b7a; + font-size: 17px; + line-height: 18px; + cursor: pointer; +} + +.feedback-attachment-remove:hover:not(:disabled) { + background-color: #eef1f4; + color: #12181f; +} + +.feedback-attachment-remove:disabled { + opacity: 0.5; + cursor: default; +} + +.feedback-error { + margin: 10px 0 0; + font-size: 13px; + color: #b3261e; +} + .save-account-success { display: none; padding: 30px; @@ -6084,6 +6171,10 @@ html.dark-mode .usage-table-show-less:hover { width: 100%; } +.device-phone .feedback-attach-btn { + width: 100%; +} + /* Taskbar container */ .device-phone .taskbar { /* Force taskbar to bottom on mobile devices, overriding any position classes */ diff --git a/src/gui/src/helpers/contact_attachments.js b/src/gui/src/helpers/contact_attachments.js new file mode 100644 index 000000000..1886fb91d --- /dev/null +++ b/src/gui/src/helpers/contact_attachments.js @@ -0,0 +1,81 @@ +/* + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +// Client-side Contact Us attachment checks, to fail fast before an upload. The +// server enforces the real limits; keep these in step with +// src/backend/util/contactAttachments.ts. + +/** Max files on one submission. */ +export const MAX_ATTACHMENTS = 5; + +/** Max size of any one file. */ +export const MAX_ATTACHMENT_BYTES = 10 * 1024 * 1024; + +/** Max size of all files on one submission. */ +export const MAX_TOTAL_ATTACHMENT_BYTES = 15 * 1024 * 1024; + +/** + * Types the server accepts. It sniffs the bytes, so `File.type` here only + * decides which error message is shown. + */ +export const ACCEPTED_ATTACHMENT_TYPES = [ + 'image/png', + 'image/jpeg', + 'image/gif', + 'image/webp', + 'video/mp4', + 'video/quicktime', + 'video/webm', +]; + +/** `accept` attribute for the file input. */ +export const ATTACHMENT_ACCEPT_ATTRIBUTE = ACCEPTED_ATTACHMENT_TYPES.join(','); + +/** + * Decide whether `file` can join `existing`. + * + * @param {{ name?: string, type?: string, size?: number }} file + * @param {Array<{ size?: number }>} existing files already staged + * @returns {{ ok: true } | { ok: false, error: string }} `error` is an i18n key + */ +export function checkAttachment (file, existing) { + const staged = Array.isArray(existing) ? existing : []; + + if ( staged.length >= MAX_ATTACHMENTS ) { + return { ok: false, error: 'contact_us_attachment_too_many' }; + } + // A directory dropped onto the form arrives as a zero-byte entry with no + // type; so does a file that vanished between the picker and the read. + if ( ! file || ! file.size ) { + return { ok: false, error: 'contact_us_attachment_unsupported' }; + } + if ( ! ACCEPTED_ATTACHMENT_TYPES.includes(file.type) ) { + return { ok: false, error: 'contact_us_attachment_unsupported' }; + } + if ( file.size > MAX_ATTACHMENT_BYTES ) { + return { ok: false, error: 'contact_us_attachment_too_large' }; + } + + const total = staged.reduce((sum, f) => sum + (f.size ?? 0), 0); + if ( total + file.size > MAX_TOTAL_ATTACHMENT_BYTES ) { + return { ok: false, error: 'contact_us_attachment_total_too_large' }; + } + + return { ok: true }; +} diff --git a/src/gui/src/helpers/contact_attachments.test.js b/src/gui/src/helpers/contact_attachments.test.js new file mode 100644 index 000000000..37aa9a3cd --- /dev/null +++ b/src/gui/src/helpers/contact_attachments.test.js @@ -0,0 +1,87 @@ +/* + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import { describe, it, expect } from 'vitest'; +import { + MAX_ATTACHMENTS, + MAX_ATTACHMENT_BYTES, + MAX_TOTAL_ATTACHMENT_BYTES, + checkAttachment, +} from './contact_attachments.js'; + +const file = (overrides = {}) => ({ + name: 'shot.png', + type: 'image/png', + size: 1024, + ...overrides, +}); + +describe('checkAttachment', () => { + it('accepts a screenshot on an empty form', () => { + expect(checkAttachment(file(), [])).toEqual({ ok: true }); + expect(checkAttachment(file(), undefined)).toEqual({ ok: true }); + }); + + it('accepts the screen recording formats each platform produces', () => { + for ( const type of ['video/mp4', 'video/quicktime', 'video/webm'] ) { + expect(checkAttachment(file({ type }), [])).toEqual({ ok: true }); + } + }); + + it('rejects types outside the allow-list', () => { + for ( const type of ['image/svg+xml', 'application/pdf', 'text/html', 'application/zip', ''] ) { + expect(checkAttachment(file({ type }), [])).toEqual({ + ok: false, error: 'contact_us_attachment_unsupported', + }); + } + }); + + it('rejects a dropped directory, which arrives as a typeless zero-byte entry', () => { + expect(checkAttachment(file({ type: '', size: 0 }), [])).toEqual({ + ok: false, error: 'contact_us_attachment_unsupported', + }); + expect(checkAttachment(undefined, [])).toEqual({ + ok: false, error: 'contact_us_attachment_unsupported', + }); + }); + + it('rejects a file over the per-file cap', () => { + expect(checkAttachment(file({ size: MAX_ATTACHMENT_BYTES + 1 }), [])).toEqual({ + ok: false, error: 'contact_us_attachment_too_large', + }); + expect(checkAttachment(file({ size: MAX_ATTACHMENT_BYTES }), [])).toEqual({ ok: true }); + }); + + it('rejects one more file than the count cap allows', () => { + const staged = Array.from({ length: MAX_ATTACHMENTS }, () => file()); + expect(checkAttachment(file(), staged)).toEqual({ + ok: false, error: 'contact_us_attachment_too_many', + }); + expect(checkAttachment(file(), staged.slice(1))).toEqual({ ok: true }); + }); + + it('counts what is already staged toward the total cap', () => { + const half = Math.floor(MAX_TOTAL_ATTACHMENT_BYTES / 2); + // Two halves exactly fill the budget; one byte more does not fit. + expect(checkAttachment(file({ size: half }), [file({ size: half })])).toEqual({ ok: true }); + expect(checkAttachment(file({ size: half + 1 }), [file({ size: half })])).toEqual({ + ok: false, error: 'contact_us_attachment_total_too_large', + }); + }); +}); diff --git a/src/gui/src/i18n/translations/en.js b/src/gui/src/i18n/translations/en.js index c0d6110be..867564dad 100644 --- a/src/gui/src/i18n/translations/en.js +++ b/src/gui/src/i18n/translations/en.js @@ -117,6 +117,16 @@ const en = { confirm_your_email_address: 'Confirm Your Email Address', choose_publishing_option: 'Choose how you want to publish your website:', contact_us: 'Contact Us', + contact_us_attach: 'Attach Files', + contact_us_attach_hint: 'Images and videos — up to %% files, %% MB each.', + contact_us_attachment_remove: 'Remove attachment', + contact_us_attachment_too_large: 'That file is too large. Each attachment can be up to %% MB.', + contact_us_attachment_too_many: 'You can attach up to %% files.', + contact_us_attachment_total_too_large: 'Those files add up to more than %% MB. Try attaching fewer of them, or smaller ones.', + contact_us_attachment_unsupported: 'That file type is not supported. Attach an image (PNG, JPEG, GIF, WebP) or a video (MP4, MOV, WebM).', + contact_us_error: 'Something went wrong. Please try again.', + contact_us_message_required: 'Please write a message before sending.', + contact_us_rate_limited: 'You have sent a lot of messages recently. Please try again later.', contact_us_verification_required: 'You must have a verified email address to use this.', contain: 'Contain', continue: 'Continue',