Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/backend/clients/database/SqliteDatabaseClient.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = (
Expand Down
1 change: 1 addition & 0 deletions src/backend/clients/database/SqliteDatabaseClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
44 changes: 44 additions & 0 deletions src/backend/clients/database/migrations/mysql/mysql_mig_22.sql
Original file line number Diff line number Diff line change
@@ -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 <https://www.gnu.org/licenses/>.

-- 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;
Original file line number Diff line number Diff line change
@@ -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 <https://www.gnu.org/licenses/>.

-- 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;
Original file line number Diff line number Diff line change
@@ -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 <https://www.gnu.org/licenses/>.

-- 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;
115 changes: 102 additions & 13 deletions src/backend/controllers/system/SystemController.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';

/**
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand All @@ -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);
Expand Down
Loading
Loading