From 82995c33ce268dd1fa12586bb91839fc5b14f69b Mon Sep 17 00:00:00 2001 From: Nariman Jelveh Date: Thu, 13 Aug 2026 11:41:40 -0700 Subject: [PATCH] feat: let the Contact Us form carry screenshots and recordings The form doubles as our bug-report channel, and the bugs worth reporting are often the ones that need a picture: a visual glitch, or something that takes five steps to reach. Up to 5 images or videos may now ride along with the message, delivered as attachments on the support email. Nothing the client says about a file is believed. The endpoint takes bare base64 and re-derives all three of the things that matter from the decoded bytes: - Type, sniffed from magic numbers and matched against an allow-list of PNG/JPEG/GIF/WebP and MP4/QuickTime/WebM. A declared MIME is never read, so it cannot smuggle anything past the list. SVG is excluded on purpose (it carries script, and support tooling renders what it is sent), as are the non-video brands that share MP4's `ftyp` box -- HEIC, M4A, JPEG 2000. - The file name, reduced to a display label with the extension taken from the sniffed type. PNG bytes named `payload.html` arrive as `payload.png`; a name can never carry the CR/LF that would break out of a Content-Disposition header, nor the bidi overrides that make `reportgnp.exe` render as `report.exe.mp4`. - Size: 10 MB per file, 15 MB per submission, counted on decoded bytes. Encoded length is capped before decoding, so an oversized payload costs a length check rather than a 10 MB allocation. The total stays well under the 25 MB most providers enforce, since the outgoing mail base64s these again. Base64 is decoded strictly, reusing the round-tripping decoder that already guards app icons -- `Buffer.from(s, 'base64')` silently drops characters it does not recognise, and the round-trip is what rejects bytes smuggled after the payload. That decoder and the image sniffer move from appIcon.ts to a new mediaSniff.ts, which gains the video counterpart. One request is now worth megabytes of parsing and outbound mail, so the existing per-user rate limit gains a per-IP backstop (which the per-user counter cannot see through freshly minted accounts), a concurrency cap, and a Content-Length gate that refuses an impossible body before anything decodes it. Payloads are not stored. They ride the email; the new `feedback.attachments` column records names, types and sizes only, so an abusive submission stays attributable once the mail has been dealt with. Also fixes the form posting an empty message when Send was pressed with nothing typed, and surfaces submit failures instead of leaving the button disabled with no explanation. --- .../database/SqliteDatabaseClient.test.ts | 2 +- .../clients/database/SqliteDatabaseClient.ts | 1 + .../migrations/mysql/mysql_mig_22.sql | 44 +++ .../migrations/postgres/postgres_mig_11.sql | 26 ++ .../sqlite/0067_feedback-attachments.sql | 25 ++ .../controllers/system/SystemController.js | 115 +++++- .../system/SystemController.test.ts | 150 +++++++- src/backend/util/appIcon.ts | 84 +---- src/backend/util/contactAttachments.test.ts | 335 ++++++++++++++++++ src/backend/util/contactAttachments.ts | 270 ++++++++++++++ src/backend/util/mediaSniff.ts | 170 +++++++++ src/gui/src/UI/UIWindowFeedback.js | 210 +++++++++-- src/gui/src/css/style.css | 91 +++++ src/gui/src/helpers/contact_attachments.js | 103 ++++++ .../src/helpers/contact_attachments.test.js | 102 ++++++ src/gui/src/i18n/translations/en.js | 10 + 16 files changed, 1619 insertions(+), 119 deletions(-) create mode 100644 src/backend/clients/database/migrations/mysql/mysql_mig_22.sql create mode 100644 src/backend/clients/database/migrations/postgres/postgres_mig_11.sql create mode 100644 src/backend/clients/database/migrations/sqlite/0067_feedback-attachments.sql create mode 100644 src/backend/util/contactAttachments.test.ts create mode 100644 src/backend/util/contactAttachments.ts create mode 100644 src/backend/util/mediaSniff.ts create mode 100644 src/gui/src/helpers/contact_attachments.js create mode 100644 src/gui/src/helpers/contact_attachments.test.js diff --git a/src/backend/clients/database/SqliteDatabaseClient.test.ts b/src/backend/clients/database/SqliteDatabaseClient.test.ts index 209da62620..c00a7d6eb3 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 = 62; +const CURRENT_SCHEMA_VERSION = 63; const SYSTEM_USER_UUID = '5d4adce0-a381-4982-9c02-6e2540026238'; const sqliteConfig = ( diff --git a/src/backend/clients/database/SqliteDatabaseClient.ts b/src/backend/clients/database/SqliteDatabaseClient.ts index 6877d16c45..a72e6cd795 100644 --- a/src/backend/clients/database/SqliteDatabaseClient.ts +++ b/src/backend/clients/database/SqliteDatabaseClient.ts @@ -96,6 +96,7 @@ const AVAILABLE_MIGRATIONS: [number, string[]][] = [ [59, ['0064_abuse-moderation-events.sql']], [60, ['0065_app-feedback.sql']], [61, ['0066_owned-email-unique.sql']], + [62, ['0067_feedback-attachments.sql']], ]; export class SqliteDatabaseClient extends AbstractDatabaseClient { diff --git a/src/backend/clients/database/migrations/mysql/mysql_mig_22.sql b/src/backend/clients/database/migrations/mysql/mysql_mig_22.sql new file mode 100644 index 0000000000..9a534e886b --- /dev/null +++ b/src/backend/clients/database/migrations/mysql/mysql_mig_22.sql @@ -0,0 +1,44 @@ +-- 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 attachments. Mirrors SQLite migration 0067 / Postgres +-- postgres_mig_11. A JSON array of `{name, type, size}` recording what a +-- submission carried, NULL when it carried nothing. Metadata only — the files +-- themselves ride the support email; this column is what keeps an abusive +-- submission attributable after the mail has been dealt with. +-- +-- MySQL has no `ADD COLUMN IF NOT EXISTS`, so the guard is 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_11.sql b/src/backend/clients/database/migrations/postgres/postgres_mig_11.sql new file mode 100644 index 0000000000..802c1f6a5e --- /dev/null +++ b/src/backend/clients/database/migrations/postgres/postgres_mig_11.sql @@ -0,0 +1,26 @@ +-- 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 attachments. Mirrors SQLite migration 0067 / MySQL mysql_mig_22. +-- A JSON array of `{name, type, size}` recording what a submission carried, +-- NULL when it carried nothing. Metadata only — the files themselves ride the +-- support email; this column is what keeps an abusive submission attributable +-- after the mail has been dealt with. +-- +-- Idempotent via IF NOT EXISTS. + +ALTER TABLE feedback ADD COLUMN IF NOT EXISTS attachments text DEFAULT NULL; diff --git a/src/backend/clients/database/migrations/sqlite/0067_feedback-attachments.sql b/src/backend/clients/database/migrations/sqlite/0067_feedback-attachments.sql new file mode 100644 index 0000000000..40aec70e8d --- /dev/null +++ b/src/backend/clients/database/migrations/sqlite/0067_feedback-attachments.sql @@ -0,0 +1,25 @@ +-- 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 . + +-- The Contact Us form accepts screenshots and screen recordings. The files +-- themselves ride the support email; this column records what was sent — +-- a JSON array of `{name, type, size}`, NULL when the submission carried +-- nothing. Metadata only, deliberately: the row exists so an abusive +-- submission is still attributable after the mail has been dealt with, which +-- names and sizes answer and megabytes of payload in the database would not. + +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 95d542ff9f..8780efe4eb 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, + validateContactAttachments, +} from '../../util/contactAttachments.js'; import { PuterController } from '../types.js'; /** @@ -94,6 +100,48 @@ const LSMOD_LIMIT = { key: 'user', }; +/** Longest message the Contact Us form will carry, in characters. */ +const CONTACT_MESSAGE_MAX_LENGTH = 100_000; + +/** + * Ceiling on the whole Contact Us request body: the attachment budget once + * base64 has inflated it by 4/3, plus the message, plus slack for the JSON + * envelope and file names. Checked against `Content-Length` so a body that + * cannot possibly be valid is refused before anything decodes it — the global + * `express.json` limit is a fleet-wide backstop and far larger than what this + * route has any business accepting. + */ +const CONTACT_BODY_MAX_BYTES = + Math.ceil(MAX_TOTAL_ATTACHMENT_BYTES / 3) * 4 + + CONTACT_MESSAGE_MAX_LENGTH + + 64 * 1024; + +/** + * Contact Us submissions are hand-typed by a person, and each one may now carry + * megabytes of screenshots and screen recordings. + * + * - Per-user is the limit that matters: submissions require an authenticated + * actor, and ten in a quarter hour is already far past what reporting a bug + * takes. + * - The per-IP backstop bounds how much a single machine can push through freshly + * minted accounts, which the per-user counter alone cannot see. Set well + * above what a shared office egress would ever legitimately produce. + */ +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 +228,62 @@ 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, + // Attachments make one request worth megabytes of parsing and + // outbound mail; a per-user rate limit still lets a client keep + // several of those in flight at once. + concurrent: { limit: 2, scope: 'contact-us', key: 'user' }, }, async (req, res) => { - const { message } = req.body ?? {}; + const declaredLength = Number(req.headers?.['content-length']); + if ( + Number.isFinite(declaredLength) && + declaredLength > CONTACT_BODY_MAX_BYTES + ) { + throw new HttpError(413, 'Request body is too large', { + legacyCode: 'bad_request', + }); + } + + const { message, attachments: rawAttachments } = req.body ?? {}; 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 + // Type, size and file name are all re-derived from the decoded + // bytes here — nothing the caller declared about them is used. + const verdict = validateContactAttachments(rawAttachments); + if (!verdict.ok) { + throw new HttpError(400, verdict.reason, { + legacyCode: 'bad_request', + }); + } + const attachments = verdict.attachments; + + // Persist to feedback table for durability. Attachment payloads + // stay out of the row — the mail carries those; the column is + // the record that they were sent. 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 +298,19 @@ 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, + // `attachment` disposition keeps the mail client + // from rendering these inline, and the file names + // are the sanitized ones with a re-derived + // extension — never what the sender typed. + 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 142b323ec4..7468400cbe 100644 --- a/src/backend/controllers/system/SystemController.test.ts +++ b/src/backend/controllers/system/SystemController.test.ts @@ -82,11 +82,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 +431,154 @@ 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(); + }); +}); + +// ── /contactUs attachments ────────────────────────────────────────── + +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), + ]); + const pngBase64 = (size = 64): string => pngBytes(size).toString('base64'); + + const submit = async ( + body: Record, + headers?: Record, + ) => { + const { actor, userId } = await makeUser(); + const { res, captured } = makeRes(); + const call = callRoute( + 'post', + '/contactUs', + makeReq({ body, actor, headers }), + res, + ); + return { call, captured, userId, actor }; + }; + + 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 submit({ + message, + attachments: [{ name: 'repro-step-3.png', data: pngBase64(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 submit({ + message, + // Declares .html, but the bytes are a PNG: the stored and + // emailed extension must follow the bytes. + attachments: [{ name: 'payload.html', data: pngBase64(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('sends no attachments array shape when none were supplied', async () => { + const sendRaw = vi + .spyOn(server.clients.email, 'sendRaw') + .mockResolvedValue(null); + try { + const message = `plain ${Math.random().toString(36).slice(2)}`; + const { call } = await submit({ message }); + await call; + 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.each([ + ['a non-array field', 'not-an-array'], + ['too many files', Array.from({ length: 6 }, () => ({ data: 'AAAA' }))], + ['an unsupported type', [{ data: Buffer.from('%PDF-1.7 x').toString('base64') }]], + ['a script-capable SVG', [{ data: Buffer.from('').toString('base64') }]], + ['invalid base64', [{ data: 'not base64 at all!!' }]], + ['a missing payload', [{ name: 'a.png' }]], + ])('rejects %s with 400', async (_label, attachments) => { + const { call } = await submit({ message: 'hi', attachments }); + await expect(call).rejects.toMatchObject({ statusCode: 400 }); + }); + + it('rejects a body whose declared length cannot possibly be valid', async () => { + const { call } = await submit( + { message: 'hi' }, + { 'content-length': String(64 * 1024 * 1024) }, + ); + await expect(call).rejects.toMatchObject({ statusCode: 413 }); + }); + + it('accepts a body whose declared length is within budget', async () => { + const { call, captured } = await submit( + { message: 'hi', attachments: [{ data: pngBase64() }] }, + { 'content-length': '4096' }, + ); + await call; + expect(captured.body).toEqual({}); }); }); diff --git a/src/backend/util/appIcon.ts b/src/backend/util/appIcon.ts index b6e5690c65..901415518b 100644 --- a/src/backend/util/appIcon.ts +++ b/src/backend/util/appIcon.ts @@ -17,6 +17,12 @@ * along with this program. If not, see . */ +import { decodeStrictBase64, sniffImageMime } from './mediaSniff.js'; + +// Re-exported because this module was the original home of the sniffer; the +// implementation now lives in mediaSniff.js alongside the video counterpart. +export { sniffImageMime }; + // Always routes through the backend `/app-icon//` endpoint rather // than the `puter-app-icons` subdomain directly. Some apps (especially those // imported with a URL icon column that predates the sharp pipeline) only have @@ -56,95 +62,17 @@ interface 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 { describe, expect, it } from 'vitest'; +import { + MAX_ATTACHMENTS, + MAX_ATTACHMENT_BYTES, + MAX_TOTAL_ATTACHMENT_BYTES, + attachmentMetadata, + attachmentSummary, + sanitizeAttachmentName, + validateContactAttachments, +} 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): string => pad(PNG_HEADER, size).toString('base64'); +const b64 = (buf: Buffer): string => buf.toString('base64'); + +describe('validateContactAttachments — accepted shapes', () => { + it('treats a missing or null field as no attachments', () => { + expect(validateContactAttachments(undefined)).toEqual({ + ok: true, + attachments: [], + }); + expect(validateContactAttachments(null)).toEqual({ + ok: true, + attachments: [], + }); + expect(validateContactAttachments([])).toEqual({ + ok: true, + attachments: [], + }); + }); + + 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 result = validateContactAttachments([ + { name: `capture.${ext}`, data: b64(pad(header, 64)) }, + ]); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.attachments[0].contentType).toBe(mime); + expect(result.attachments[0].filename).toBe(`capture.${ext}`); + expect(result.attachments[0].size).toBe(64); + }); + + it('tolerates line-wrapped base64', () => { + const wrapped = png(256).replace(/(.{40})/g, '$1\n'); + const result = validateContactAttachments([ + { name: 'a.png', data: wrapped }, + ]); + expect(result.ok).toBe(true); + }); + + it('accepts exactly the maximum number of files', () => { + const result = validateContactAttachments( + Array.from({ length: MAX_ATTACHMENTS }, () => ({ data: png() })), + ); + expect(result.ok).toBe(true); + }); +}); + +describe('validateContactAttachments — the type allow-list', () => { + it('rejects SVG, which is script-capable even though it is an image', () => { + const svg = Buffer.from( + '', + ); + const result = validateContactAttachments([ + { name: 'x.png', data: b64(svg) }, + ]); + expect(result).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) => { + const result = validateContactAttachments([ + { name: 'evidence.png', data: b64(pad(payload, 64)) }, + ]); + expect(result.ok).toBe(false); + }); + + it('ignores any type the caller declares and uses the sniffed one', () => { + const result = validateContactAttachments([ + { name: 'a.mp4', type: 'video/mp4', data: png() }, + ]); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.attachments[0].contentType).toBe('image/png'); + expect(result.attachments[0].filename).toBe('a.png'); + }); +}); + +describe('validateContactAttachments — size and count limits', () => { + it('rejects more files than the count cap', () => { + const result = validateContactAttachments( + Array.from({ length: MAX_ATTACHMENTS + 1 }, () => ({ data: png() })), + ); + expect(result).toMatchObject({ + ok: false, + reason: expect.stringContaining('too many attachments'), + }); + }); + + it('rejects a single file over the per-file cap', () => { + const result = validateContactAttachments([ + { data: png(MAX_ATTACHMENT_BYTES + 1) }, + ]); + expect(result).toMatchObject({ + ok: false, + reason: expect.stringContaining('too large'), + }); + }); + + it('rejects an oversized payload without decoding it', () => { + // Far past the cap; a length check has to catch this, not a decode. + const result = validateContactAttachments([ + { data: 'A'.repeat(MAX_ATTACHMENT_BYTES * 2) }, + ]); + expect(result).toMatchObject({ + ok: false, + reason: expect.stringContaining('too large'), + }); + }); + + it('rejects files that are individually fine but too large together', () => { + const each = Math.ceil(MAX_TOTAL_ATTACHMENT_BYTES / 2) + 1024; + const result = validateContactAttachments([ + { data: png(each) }, + { data: png(each) }, + ]); + expect(result).toMatchObject({ + ok: false, + reason: expect.stringContaining('in total'), + }); + }); +}); + +describe('validateContactAttachments — malformed entries', () => { + it.each([ + ['a non-array field', 'nope' as unknown], + ['an object field', { data: png() } as unknown], + ])('rejects %s', (_label, value) => { + expect(validateContactAttachments(value)).toMatchObject({ ok: false }); + }); + + it.each([ + ['a string entry', 'AAAA'], + ['a null entry', null], + ['an array entry', ['AAAA']], + ])('rejects %s', (_label, entry) => { + expect(validateContactAttachments([entry])).toMatchObject({ + ok: false, + reason: expect.stringContaining('must be an object'), + }); + }); + + it.each([ + ['missing data', {}], + ['empty data', { data: '' }], + ['non-string data', { data: 12345 }], + ])('rejects an entry with %s', (_label, entry) => { + expect(validateContactAttachments([entry])).toMatchObject({ + ok: false, + reason: expect.stringContaining('missing base64'), + }); + }); + + it('rejects base64 with characters smuggled past the alphabet', () => { + // Buffer.from(..., 'base64') silently drops the junk; the strict + // round-trip is what has to notice. + const result = validateContactAttachments([ + { data: `${png()}" onerror=alert(1)` }, + ]); + expect(result).toMatchObject({ + ok: false, + reason: expect.stringContaining('not valid base64'), + }); + }); + + it('names the offending file without echoing caller input back', () => { + const result = validateContactAttachments([ + { data: png() }, + { name: '', data: b64(Buffer.from('nope!!')) }, + ]); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.reason).toContain('attachment 2'); + expect(result.reason).not.toContain('alert'); + }); +}); + +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', () => { + it('records names, types and sizes but never payloads', () => { + const result = validateContactAttachments([ + { name: 'shot.png', data: png(128) }, + ]); + expect(result.ok).toBe(true); + if (!result.ok) return; + + const meta = attachmentMetadata(result.attachments); + expect(meta).toEqual([ + { name: 'shot.png', type: 'image/png', size: 128 }, + ]); + expect(JSON.stringify(meta)).not.toContain('iVBOR'); + }); + + it('summarizes what was attached for the email body', () => { + const result = validateContactAttachments([ + { name: 'shot.png', data: png(2048) }, + ]); + expect(result.ok).toBe(true); + if (!result.ok) return; + const summary = attachmentSummary(result.attachments); + 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 0000000000..3bba432728 --- /dev/null +++ b/src/backend/util/contactAttachments.ts @@ -0,0 +1,270 @@ +/* + * 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 { + decodeStrictBase64, + sniffImageMime, + sniffVideoMime, +} from './mediaSniff.js'; + +/** + * Validation for the screenshots and screen recordings people attach to the + * Contact Us form. Everything here treats the submission as hostile: the caller + * chooses the byte count, the file name and the claimed type, and the result is + * emailed to a human at Puter who will open it. + * + * The posture, in order of what it stops: + * + * - **Volume** — a per-file cap, a per-submission total, and a count cap, each + * checked against decoded bytes rather than anything the caller declares. + * - **Type** — the MIME type is sniffed from the payload and matched against an + * allow-list of images and videos. A declared type is never read, so it can't + * be used to smuggle anything past the list. `image/svg+xml` is deliberately + * absent: SVG carries script, and support tooling renders what it is sent. + * - **File name** — the caller's name is reduced to a display label and the + * extension is re-derived from the sniffed type, so a payload can never + * arrive as `.html`/`.exe`, and a name can never carry the CR/LF or quoting + * characters that would break out of a `Content-Disposition` header. + */ + +/** 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 decoded size of all files on one submission. Bounded well under the 25 MB + * message ceiling most mail providers enforce, since the outgoing mail carries + * these base64-encoded (~4/3 the size) alongside the message body. + */ +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; + +/** + * Sniffed MIME types we accept, and the extension each one is stored under. + * Between them these cover what the platforms people report bugs from actually + * produce: PNG/JPEG screenshots, GIF captures, and MP4/QuickTime/WebM screen + * recordings. + */ +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', +}; + +/** + * Ceiling on the encoded string we are willing to decode. Applied to the raw + * field before any decoding so an oversized payload costs a length check rather + * than a 10 MB allocation. Base64 is 4 characters per 3 bytes; the slack covers + * padding and any line wrapping. + */ +const MAX_ATTACHMENT_BASE64_CHARS = + Math.ceil(MAX_ATTACHMENT_BYTES / 3) * 4 + 1024; + +/** + * Characters stripped from a file name outright: C0/C1 controls (CR and LF + * would let a name break out of the `Content-Disposition` header), the bidi + * overrides and isolates that make `report.4pm.exe` render as `report.exe.mp4`, + * and the quoting characters that header would otherwise have to escape. + */ +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 tooLargeClause = (): string => + `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; + /** Decoded payload. */ + content: Buffer; + /** Decoded byte count (`content.length`, carried for metadata). */ + size: number; +} + +/** What gets recorded alongside the feedback row — names and sizes, no bytes. */ +export interface AttachmentMetadata { + name: string; + type: string; + size: number; +} + +export type ContactAttachmentsVerdict = + | { ok: true; attachments: ValidatedAttachment[] } + | { ok: false; 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}$/, '') + // Leading dots would make the file hidden (and `..` traversable) + // if anyone ever writes it 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}`; +} + +/** + * Validate the `attachments` field of a Contact Us submission. + * + * Each entry is `{ name?: string, data: string }` where `data` is bare base64 + * (no `data:` prefix). Absent/null means "no attachments" and is not an error — + * the field is optional. + * + * On failure the `reason` is safe to return to the caller: it names the + * offending index and the rule it broke, and never echoes caller input back. + */ +export function validateContactAttachments( + value: unknown, +): ContactAttachmentsVerdict { + if (value === undefined || value === null) { + return { ok: true, attachments: [] }; + } + if (!Array.isArray(value)) { + return { ok: false, reason: '`attachments` must be an array' }; + } + if (value.length > MAX_ATTACHMENTS) { + return { + ok: false, + reason: `too many attachments (max ${MAX_ATTACHMENTS})`, + }; + } + + const attachments: ValidatedAttachment[] = []; + let totalBytes = 0; + + for (let i = 0; i < value.length; i++) { + const label = `attachment ${i + 1}`; + const entry = value[i]; + if ( + typeof entry !== 'object' || + entry === null || + Array.isArray(entry) + ) { + return { ok: false, reason: `${label} must be an object` }; + } + + const { name, data } = entry as { name?: unknown; data?: unknown }; + if (typeof data !== 'string' || data.length === 0) { + return { + ok: false, + reason: `${label} is missing base64 \`data\``, + }; + } + if (data.length > MAX_ATTACHMENT_BASE64_CHARS) { + return { ok: false, reason: `${label} ${tooLargeClause()}` }; + } + + // Line-wrapped base64 is tolerated, but only whitespace is stripped — + // any other character outside the alphabet fails the strict decode. + const bytes = decodeStrictBase64(data.replace(/\s+/g, '')); + if (!bytes) { + return { ok: false, reason: `${label} is not valid base64` }; + } + if (bytes.length > MAX_ATTACHMENT_BYTES) { + return { ok: false, reason: `${label} ${tooLargeClause()}` }; + } + + totalBytes += bytes.length; + if (totalBytes > MAX_TOTAL_ATTACHMENT_BYTES) { + return { + ok: false, + reason: `attachments are too large in total (max ${mb(MAX_TOTAL_ATTACHMENT_BYTES)} MB)`, + }; + } + + 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`, + }; + } + + attachments.push({ + filename: sanitizeAttachmentName(name, i, extension), + contentType: sniffed, + content: bytes, + size: bytes.length, + }); + } + + return { ok: true, attachments }; +} + +/** 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, + })); +} + +/** + * A manifest to append to the support email's body. Mail gateways strip + * attachments, and without this the recipient has no way to tell a message that + * arrived intact from one that lost its screenshots on the way. + */ +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 0000000000..c31638b0b7 --- /dev/null +++ b/src/backend/util/mediaSniff.ts @@ -0,0 +1,170 @@ +/* + * 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 . + */ + +// Content-based identification of user-supplied media, plus the strict base64 +// decoder that gets us from a wire payload to bytes worth sniffing. A MIME type +// or a file extension supplied by a caller describes nothing — only the bytes +// do — so every write path that accepts uploaded media resolves the type here. + +const BASE64_CHARS_REGEX = /^[A-Za-z0-9+/]*={0,2}$/; + +/** + * Cap on how far into a payload we look for the `'; // success @@ -35,6 +70,14 @@ async function UIWindowQR (options) { h += ''; h += ''; @@ -56,7 +99,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 +116,149 @@ async function UIWindowQR (options) { }, }); - $(el_window).find('.send-feedback-btn').on('click', function (e) { + 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 remove_label = 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 thing that went wrong, but keep taking + // the files that do fit — dropping a folder of mixed + // content shouldn't discard the usable screenshots. + 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 = ''; + }); + + // Drag and drop onto the form. UIWindow's own dragster handler sits on + // the window body — an ancestor — and only uploads to the filesystem + // for `is_dir` windows, so a drop caught here reaches this form first + // and goes no further. + 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 ?? [])); + }); + + /** + * Read one staged file into the `{ name, data }` shape the endpoint + * takes. Rejects rather than sending a half-read file — a bug report + * missing the screenshot it refers to is worse than one that says so. + */ + const readAttachment = (file) => new Promise((res, rej) => { + const reader = new FileReader(); + reader.onload = () => { + const data = base64FromDataUrl(reader.result); + if ( ! data ) return rej(new Error(`unreadable attachment: ${file.name}`)); + res({ name: file.name, data }); + }; + reader.onerror = () => rej(reader.error ?? new Error('attachment read failed')); + reader.readAsDataURL(file); + }); + + // -- 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 ) - { - $(this).prop('disabled', true); + if ( ! message || ! message.trim() ) { + showError('contact_us_message_required'); + return; + } + + clearError(); + setSending(true); + try { + const payload = { message }; + if ( attachments.length ) { + payload.attachments = await Promise.all(attachments.map(readAttachment)); + } + + const resp = await fetch(`${window.api_origin}/contactUs`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${window.auth_token}`, + }, + body: JSON.stringify(payload), + }); + 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); } - $.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); - }, - }); }); + + 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 0a3ca51d03..93f1dab07f 100644 --- a/src/gui/src/css/style.css +++ b/src/gui/src/css/style.css @@ -2445,6 +2445,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; @@ -5935,6 +6022,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 0000000000..bf14524b36 --- /dev/null +++ b/src/gui/src/helpers/contact_attachments.js @@ -0,0 +1,103 @@ +/* + * 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 half of the Contact Us attachment rules. This is here to tell someone +// their 40 MB recording is too big before they wait for it to upload — the +// server re-derives every one of these decisions from the bytes it receives and +// is the only thing actually enforcing them. Keep the limits 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's allow-list will accept. Used for the file picker's + * `accept` filter and the pre-flight check; the server sniffs the payload + * rather than believing `File.type`, so a mismatch here only ever costs a + * clearer error message. + */ +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 }; +} + +/** + * Strip the `data:;base64,` prefix off a FileReader result, leaving the + * bare base64 the endpoint expects. Returns null for anything that isn't a + * base64 data URL — a reader that produced something else has nothing sendable + * in it. + * + * @param {unknown} dataUrl + * @returns {string|null} + */ +export function base64FromDataUrl (dataUrl) { + if ( typeof dataUrl !== 'string' ) return null; + const comma = dataUrl.indexOf(','); + if ( comma < 0 ) return null; + if ( ! /^data:[^,]*;base64$/i.test(dataUrl.slice(0, comma)) ) return null; + const payload = dataUrl.slice(comma + 1); + return payload.length > 0 ? payload : null; +} 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 0000000000..1a16ca5699 --- /dev/null +++ b/src/gui/src/helpers/contact_attachments.test.js @@ -0,0 +1,102 @@ +/* + * 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, + base64FromDataUrl, + 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', + }); + }); +}); + +describe('base64FromDataUrl', () => { + it('strips the prefix off a base64 data URL', () => { + expect(base64FromDataUrl('data:image/png;base64,iVBORw0KGgo=')).toBe('iVBORw0KGgo='); + }); + + it('returns null for anything that is not a base64 data URL', () => { + expect(base64FromDataUrl('data:image/png,rawtext')).toBeNull(); + expect(base64FromDataUrl('data:image/png;base64,')).toBeNull(); + expect(base64FromDataUrl('iVBORw0KGgo=')).toBeNull(); + expect(base64FromDataUrl(null)).toBeNull(); + expect(base64FromDataUrl(undefined)).toBeNull(); + }); +}); diff --git a/src/gui/src/i18n/translations/en.js b/src/gui/src/i18n/translations/en.js index bef9548a60..f393d0d390 100644 --- a/src/gui/src/i18n/translations/en.js +++ b/src/gui/src/i18n/translations/en.js @@ -113,6 +113,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',