mirror of
https://github.com/HeyPuter/puter.git
synced 2026-10-03 18:38:32 +00:00
feat: let the Contact Us form carry screenshots and recordings (#3563)
* 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 `report<RLO>gnp.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. * fix: stream Contact Us attachments as multipart instead of base64 JSON Base64 in a JSON body went through the global JSON parser before the route ran: raw buffer, rawBody copy, decoded string, parsed strings, then decoded Buffers plus a re-encode for the strict check. A max-size submission peaked around 110-135 MB of heap for 15 MB of files, and the route's Content-Length check ran after all of it. Attachments now arrive as multipart/form-data, which the global parser skips. Auth, rate limit, concurrency and the Content-Length 413 all run before the body is read, and busboy enforces the count, per-file and total caps while streaming, so only the decoded file bytes are held (~16 MB peak). On a broken limit the reader stops and the response closes the connection. JSON stays supported for message-only posts; a JSON `attachments` field is rejected. * fix: deliver Contact Us 413s instead of resetting the upload Closing the socket on a mid-stream limit sent the 413 and then reset the connection with the client still uploading, so the client could see a reset instead of the error. After a limit trips, the rest of the body is now read and discarded, bounded by the body budget; past the budget the request is destroyed. Adds an HTTP-level test through the full middleware stack (FormData upload, unauthenticated refusal, 413 on declared length before the body is sent, 413 on a chunked upload over the per-file cap), trims comments to the AGENTS.md length rule, and drops a box-drawing test divider. --------- Co-authored-by: Daniel Salazar <daniel.salazar@puter.com>
This commit is contained in:
co-authored by
Daniel Salazar
parent
a9ee3d1ea6
commit
3d3678a555
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
-- 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;
|
||||
@@ -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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
-- 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;
|
||||
@@ -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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
-- 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;
|
||||
@@ -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);
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
headers?: Record<string, string>;
|
||||
}): 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<string, string> = {},
|
||||
) => {
|
||||
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('<svg><script/></svg>'),
|
||||
},
|
||||
],
|
||||
],
|
||||
[
|
||||
'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();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
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<string, string>,
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -17,6 +17,15 @@
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
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/<uid>/<size>` 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:<type>/<subtype>[;param[=value]]…,<payload>`. 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 `<svg` root element. SVG is
|
||||
// the one allow-listed type without a fixed-offset magic number; a file that
|
||||
// buries its root past this much leading comment/PI text is not something we
|
||||
// need to accept.
|
||||
export const SVG_SNIFF_WINDOW = 8 * 1024;
|
||||
|
||||
/**
|
||||
* Decode strict base64 — no whitespace, correct padding, and byte-for-byte
|
||||
* round-trip. `Buffer.from(s, 'base64')` silently skips characters it doesn't
|
||||
* recognise, so `iVBORw0KGgo=" onerror=alert(1)` decodes without complaint; the
|
||||
* round-trip is what rejects it.
|
||||
*/
|
||||
function decodeStrictBase64(value: string): Buffer | null {
|
||||
if (!BASE64_CHARS_REGEX.test(value)) return null;
|
||||
if (value.length === 0 || value.length % 4 !== 0) return null;
|
||||
try {
|
||||
const decoded = Buffer.from(value, 'base64');
|
||||
if (decoded.length === 0) return null;
|
||||
const stripped = value.replace(/=+$/, '');
|
||||
const reencoded = decoded.toString('base64').replace(/=+$/, '');
|
||||
return stripped === reencoded ? decoded : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** `image/jpg` is a common misspelling of `image/jpeg`; treat them as one. */
|
||||
function canonicalImageMime(mime: string): string {
|
||||
return mime === 'image/jpg' ? 'image/jpeg' : mime;
|
||||
}
|
||||
|
||||
function looksLikeSvg(bytes: Buffer): boolean {
|
||||
let head = bytes.subarray(0, SVG_SNIFF_WINDOW).toString('utf8');
|
||||
if (head.charCodeAt(0) === 0xfeff) head = head.slice(1);
|
||||
// Must open as markup (rules out arbitrary text that merely mentions
|
||||
// `<svg` somewhere), and must actually contain an `<svg` root.
|
||||
if (!head.trimStart().startsWith('<')) return false;
|
||||
return /<svg[\s/>]/i.test(head);
|
||||
}
|
||||
|
||||
/**
|
||||
* Identify image bytes by content, returning a canonical allow-listed MIME type
|
||||
* or null. Signature-based: the declared MIME on a data URL is caller input and
|
||||
* cannot be trusted to describe the payload.
|
||||
*/
|
||||
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;
|
||||
}
|
||||
|
||||
export interface IconDataUrlVerdict {
|
||||
ok: boolean;
|
||||
/** Human-readable failure clause, suffixed onto "`icon` …". */
|
||||
|
||||
@@ -0,0 +1,478 @@
|
||||
/*
|
||||
* 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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
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<string, string> = HEADERS) =>
|
||||
readContactSubmission(
|
||||
Object.assign(Readable.from([multipart(parts)]), { headers }),
|
||||
LIMITS,
|
||||
);
|
||||
|
||||
const accepted = (attachment: ReturnType<typeof validateAttachment>) => {
|
||||
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(
|
||||
'<svg xmlns="http://www.w3.org/2000/svg"><script>alert(1)</script></svg>',
|
||||
);
|
||||
expect(validateAttachment(svg, 'x.png', 0)).toMatchObject({
|
||||
ok: false,
|
||||
reason: expect.stringContaining('not a supported image or video'),
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
['HTML', Buffer.from('<html><body>hi</body></html>')],
|
||||
['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!!'),
|
||||
'<img onerror=alert(1)>',
|
||||
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');
|
||||
});
|
||||
});
|
||||
@@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
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<Record<string, string>> = {
|
||||
'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-<n>` 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<ContactSubmissionVerdict> {
|
||||
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`;
|
||||
}
|
||||
@@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
// 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 `<svg` root; SVG has no fixed magic
|
||||
* number.
|
||||
*/
|
||||
export const SVG_SNIFF_WINDOW = 8 * 1024;
|
||||
|
||||
/** How far into an EBML header to look for the DocType string. */
|
||||
const EBML_SNIFF_WINDOW = 64;
|
||||
|
||||
/**
|
||||
* ISO Base Media brands treated as MP4 video. HEIF, M4A audio and JPEG 2000
|
||||
* share the `ftyp` box, so the brand decides.
|
||||
*/
|
||||
const MP4_BRANDS = new Set([
|
||||
'avc1',
|
||||
'dash',
|
||||
'iso2',
|
||||
'iso4',
|
||||
'iso5',
|
||||
'iso6',
|
||||
'isom',
|
||||
'mmp4',
|
||||
'mp41',
|
||||
'mp42',
|
||||
'mp71',
|
||||
'M4V ',
|
||||
'M4VH',
|
||||
'M4VP',
|
||||
'M4P ',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Decode base64 that round-trips byte for byte. `Buffer.from(s, 'base64')`
|
||||
* silently skips characters outside the alphabet; the round-trip rejects them.
|
||||
*/
|
||||
export function decodeStrictBase64(value: string): Buffer | null {
|
||||
if (!BASE64_CHARS_REGEX.test(value)) return null;
|
||||
if (value.length === 0 || value.length % 4 !== 0) return null;
|
||||
try {
|
||||
const decoded = Buffer.from(value, 'base64');
|
||||
if (decoded.length === 0) return null;
|
||||
const stripped = value.replace(/=+$/, '');
|
||||
const reencoded = decoded.toString('base64').replace(/=+$/, '');
|
||||
return stripped === reencoded ? decoded : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function looksLikeSvg(bytes: Buffer): boolean {
|
||||
let head = bytes.subarray(0, SVG_SNIFF_WINDOW).toString('utf8');
|
||||
if (head.charCodeAt(0) === 0xfeff) head = head.slice(1);
|
||||
// Must open as markup, not just mention `<svg` somewhere in text.
|
||||
if (!head.trimStart().startsWith('<')) return false;
|
||||
return /<svg[\s/>]/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;
|
||||
}
|
||||
@@ -17,13 +17,37 @@
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
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 += '<div style="padding: 20px; margin-top: 0;">';
|
||||
// success
|
||||
@@ -35,6 +59,14 @@ async function UIWindowQR (options) {
|
||||
h += '<div class="feedback-form">';
|
||||
h += `<p style="margin-top:0; font-size: 15px; -webkit-font-smoothing: antialiased;">${i18n('feedback_c2a')}</p>`;
|
||||
h += '<textarea class="feedback-message" style="width:100%; height: 200px; padding: 10px; box-sizing: border-box;"></textarea>';
|
||||
// attachments
|
||||
h += '<div class="feedback-attachments">';
|
||||
h += `<input type="file" class="feedback-attach-input" accept="${html_encode(ATTACHMENT_ACCEPT_ATTRIBUTE)}" multiple style="display:none;">`;
|
||||
h += `<button type="button" class="button button-small feedback-attach-btn">${i18n('contact_us_attach')}</button>`;
|
||||
h += `<span class="feedback-attach-hint">${i18n('contact_us_attach_hint', [MAX_ATTACHMENTS, MAX_ATTACHMENT_BYTES / MB])}</span>`;
|
||||
h += '<ul class="feedback-attachment-list"></ul>';
|
||||
h += '</div>';
|
||||
h += '<p class="feedback-error" role="alert" style="display:none;"></p>';
|
||||
h += `<button class="button button-primary send-feedback-btn" style="float: right; margin-bottom: 15px; margin-top: 10px;">${i18n('send')}</button>`;
|
||||
h += '</div>';
|
||||
h += '</div>';
|
||||
@@ -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 = $(
|
||||
'<li class="feedback-attachment">' +
|
||||
`<span class="feedback-attachment-name" title="${html_encode(file.name)}">${html_encode(file.name)}</span>` +
|
||||
`<span class="feedback-attachment-size">${html_encode(window.byte_format(file.size))}</span>` +
|
||||
`<button type="button" class="feedback-attachment-remove" aria-label="${removeLabel}" title="${removeLabel}">×</button>` +
|
||||
'</li>',
|
||||
);
|
||||
$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;
|
||||
export default UIWindowFeedback;
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
// 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 };
|
||||
}
|
||||
@@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
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',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user