Skip to content
Merged
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
33 changes: 23 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -587,21 +587,34 @@ receive automatically — see

### Public upload approval

New accounts start **without** permission to upload public files or media, and
verifying the email address does not grant it. The account carries
`meta.publicUploads: false` from registration; `POST /api/v1/attachments/uploads`
answers `403 public_uploads_not_approved` until an administrator flips it from
the **/admin → Users** tab (`POST /api/v1/admin/users/public-uploads`).

The flag is deliberately tri-state, so nothing here needs a data migration:

| `meta.publicUploads` | Meaning |
New accounts start **without** permission to upload files or media, and
verifying the email address does not grant it. The permission has two
independent scopes, so an administrator can approve the **public**, **private**,
or **all** variation per user:

| Scope | Covers | Flag |
| --- | --- | --- |
| `public` | post, comment, and custom-emoji attachments | `meta.publicUploads` |
| `private` | message attachments + the user's own profile avatar/banner | `meta.privateUploads` |
| `all` | both of the above in one write | both flags |

The account carries `meta.publicUploads: false` and `meta.privateUploads:
false` from registration; `POST /api/v1/attachments/uploads` answers
`403 public_uploads_not_approved` or `403 private_uploads_not_approved`
(depending on the requested purpose) until an administrator approves that
scope from the **/admin → Users** tab's per-row **Approve** menu
(`POST /api/v1/admin/users/public-uploads { userId, enabled, scope }`; scope
defaults to `public` for pre-scope callers).

Each flag is deliberately tri-state, so nothing here needs a data migration:

| flag value | Meaning |
| --- | --- |
| absent | account predates the change — uploads stay enabled |
| `false` | withheld, awaiting admin approval (every new signup) |
| `true` | granted by an administrator |

Administrators bypass the flag entirely, so the account that grants the
Administrators bypass the flags entirely, so the account that grants the
permission can never be locked out of the surface that grants it.

Use the SES **API** with an IAM key scoped to `ses:SendEmail` — do not create
Expand Down
38 changes: 26 additions & 12 deletions TESTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,21 @@ is fixed, and cite the checklist you ran in the PR description.
## Public upload approval (new-signup permissions)

- [ ] Register a brand-new account. `POST /api/v1/auth/register` returns
`publicUploadsEnabled: false`, and `POST /api/v1/attachments/uploads`
answers `403` with `code: "public_uploads_not_approved"`.
- [ ] Open the verification link. `emailVerified` flips to `true` while
`publicUploadsEnabled` stays `false` — verifying an email must never be
`publicUploadsEnabled: false` AND `privateUploadsEnabled: false`;
`POST /api/v1/attachments/uploads` answers `403` with
`code: "public_uploads_not_approved"` for public purposes (`post`,
`comment`, `custom-emoji`, or no purpose) and
`code: "private_uploads_not_approved"` for private ones (`message`,
`profile-avatar`, `profile-banner`).
- [ ] Open the verification link. `emailVerified` flips to `true` while both
`*UploadsEnabled` flags stay `false` — verifying an email must never be
what grants uploads.
- [ ] Scopes stay independent: approve only `private`
(`POST /api/v1/admin/users/public-uploads` with `scope: "private"`) and
confirm `profile-avatar`/`message` starts pass the gate while `post`
still 403s; approve only `public` on another account and confirm the
reverse; `scope: "all"` enables both, and a request without `scope`
keeps the legacy public-only behavior.
- [ ] Confirm the `admin.new_user` message reaches
`THINGTIME_ADMIN_NOTIFICATION_EMAIL` (default `admin@thingtime.com`) with
the username, display name, email, user id, and signup time. In dev read
Expand All @@ -22,15 +32,19 @@ is fixed, and cite the checklist you ran in the PR description.
verification still succeeds and still redirects to `/login?verify=success`
— a mail outage must never fail a committed verification, nor grant the
permission.
- [ ] In **/admin → Users**, the account shows a `pending` Uploads badge and the
warning banner counts it. Click **Enable**: the badge flips optimistically,
a Lopu toast confirms, and the upload start no longer 403s.
- [ ] Click **Withhold** on the same row: uploads 403 again. An account that
predates the change (no `meta.publicUploads`) shows `enabled`, and an
admin row shows `enabled` with no toggle.
- [ ] In **/admin → Users**, the account shows a `pending` Uploads badge and
the warning banner counts it. Use the **Approve ▾** menu: "Enable public
uploads" / "Enable private uploads" flip only that scope (badge shows
`public` or `private`), "Enable all" turns the badge green `all` — each
optimistically with a Lopu toast — and the matching upload starts stop
403ing.
- [ ] Withhold from the same menu: that scope 403s again ("Withhold all"
returns the badge to `pending`). An account that predates the change (no
`meta.publicUploads`/`meta.privateUploads`) shows `all`, and an admin row
shows `all` with no menu.
- [ ] Non-admins calling `POST /api/v1/admin/users/public-uploads` get `403`;
a missing `userId` or non-boolean `enabled` gets `400`; an unknown user
gets `404`.
a missing `userId`, non-boolean `enabled`, or unknown `scope` gets `400`;
an unknown user gets `404`.
- [ ] Run `npm run test:attachments` (it carries the public-upload permission
unit tests alongside the upload-gate regression test).

Expand Down
12 changes: 12 additions & 0 deletions remix/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,18 @@ assistant and manual changes attributed so future PR archaeology is less cursed.

### Security

- **Upload approval now has public / private / all scopes**: the
signup-permissions gate is split into two independent tri-state flags —
`meta.publicUploads` (post/comment/custom-emoji attachments) and the new
`meta.privateUploads` (message attachments + own profile media) — both
stamped `false` at registration and both privileged meta keys. The upload
start gate is purpose-aware (`403 public_uploads_not_approved` /
`private_uploads_not_approved`), `POST /api/v1/admin/users/public-uploads`
accepts `scope: 'public' | 'private' | 'all'` (default `public`, wire-
compatible), and the /admin Users tab's control becomes an Approve menu with
per-scope and enable/withhold-all actions plus per-scope pending flags.
Grandfathering and the admin bypass are unchanged. — Claude (AI), 2026-08-18

- **New signups no longer receive public upload permissions**: accounts created
from this change forward start with `meta.publicUploads: false`, and verifying
the email address no longer grants uploads. `POST /api/v1/attachments/uploads`
Expand Down
55 changes: 37 additions & 18 deletions remix/app/api/utils/attachments/attachmentResponses.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,8 +54,16 @@ const defaultDependencies: AttachmentMutationDependencies = {
readBody: readJsonBody
};

// Upload purposes by permission scope. Mirrors attachmentUploadIntent in
// attachments.ts (an absent purpose defaults to 'post'): public purposes land
// on publicly viewable surfaces, private ones only in the account's own DMs or
// profile. An unknown purpose is left to the service's own 400 — nothing is
// reserved either way.
const PUBLIC_UPLOAD_PURPOSES = new Set<unknown>([undefined, 'post', 'comment', 'custom-emoji']);
const PRIVATE_UPLOAD_PURPOSES = new Set<unknown>(['message', 'profile-avatar', 'profile-banner']);

export const createAttachmentMutationAction = (
options: { rateKey: string; service: MutationService; requirePublicUploads?: boolean },
options: { rateKey: string; service: MutationService; requireUploadPermission?: boolean },
overrides: Partial<AttachmentMutationDependencies> = {}
) => {
const dependencies = { ...defaultDependencies, ...overrides };
Expand All @@ -76,23 +84,6 @@ export const createAttachmentMutationAction = (
if (user.accountKind !== 'user') {
return json({ ok: false, error: 'Attachments require a user account' }, { status: 403 });
}
// Public file/media uploads are withheld from new accounts until an admin
// grants them (see auth/users.ts userPublicUploadsEnabled). Gate the
// START of an upload: nothing is reserved, no MPU is opened, and every
// downstream part/complete call has no upload id to act on. The already
// -uploaded lifecycle calls (parts/complete/abort/delete) stay ungated so
// a permission change mid-upload can't strand a paid-for reservation.
if (options.requirePublicUploads && !user.publicUploadsEnabled) {
return json(
{
ok: false,
error: 'Uploads are awaiting admin approval for this account',
code: 'public_uploads_not_approved'
},
{ status: 403 }
);
}

const limit = await dependencies.enforceLimit(request, options.rateKey, `user:${user.id}`, { failClosed: true });
if (!limit.allowed) {
if (limit.unavailable) {
Expand All @@ -102,6 +93,34 @@ export const createAttachmentMutationAction = (
}

const body = await dependencies.readBody(request, ATTACHMENT_JSON_BODY_BYTES);
// File/media uploads are withheld per SCOPE from new accounts until an
// admin grants them (auth/users.ts userPublicUploadsEnabled /
// userPrivateUploadsEnabled): the requested purpose decides which flag
// gates this start. Sits after the body read because the purpose lives in
// the body — a denied attempt still consumes rate budget, which only
// throttles retry spam. Gate the START of an upload: nothing is reserved,
// no MPU is opened, and every downstream part/complete call has no upload
// id to act on. The already-uploaded lifecycle calls
// (parts/complete/abort/delete) stay ungated so a permission change
// mid-upload can't strand a paid-for reservation.
if (options.requireUploadPermission) {
const purpose = body && typeof body === 'object' && !Array.isArray(body) ? (body as Record<string, unknown>).purpose : undefined;
const needsPublic = PUBLIC_UPLOAD_PURPOSES.has(purpose) && !user.publicUploadsEnabled;
const needsPrivate = PRIVATE_UPLOAD_PURPOSES.has(purpose) && !user.privateUploadsEnabled;
if (needsPublic || needsPrivate) {
return json(
{
ok: false,
error: needsPublic
? 'Public file and media uploads are awaiting admin approval for this account'
: 'Private file and media uploads are awaiting admin approval for this account',
code: needsPublic ? 'public_uploads_not_approved' : 'private_uploads_not_approved'
},
{ status: 403 }
);
}
}

const result = await options.service(user.id, body);
if (result.ok === false) {
const { status, ...bodyResult } = result;
Expand Down
21 changes: 20 additions & 1 deletion remix/app/api/utils/auth/publicUploads.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import assert from 'node:assert/strict';
import test from 'node:test';

import { userPublicUploadsEnabled } from './users';
import { userPrivateUploadsEnabled, userPublicUploadsEnabled } from './users';

// Signup-permissions hotfix. The permission is deliberately TRI-STATE, and each
// state has to survive a refactor:
Expand Down Expand Up @@ -43,3 +43,22 @@ test('admins are never gated by the flag', () => {
// An admin locked out of uploads could not test or fix the approval flow.
assert.equal(userPublicUploadsEnabled(doc({ publicUploads: false, admin: true })), true);
});

test('private upload permission is an independent tri-state scope', () => {
// same tri-state contract as the public flag…
assert.equal(userPrivateUploadsEnabled(doc(undefined)), true);
assert.equal(userPrivateUploadsEnabled(doc({})), true);
assert.equal(userPrivateUploadsEnabled(doc({ privateUploads: false })), false);
assert.equal(userPrivateUploadsEnabled(doc({ privateUploads: true })), true);
for (const value of [0, '', null, 'false']) {
assert.equal(userPrivateUploadsEnabled(doc({ privateUploads: value })), true, `unexpected deny for ${JSON.stringify(value)}`);
}
assert.equal(userPrivateUploadsEnabled(doc({ privateUploads: false, admin: true })), true);

// …and the scopes never bleed into each other: granting one variation must
// not grant the other ("all" is simply both flags true).
assert.equal(userPublicUploadsEnabled(doc({ publicUploads: false, privateUploads: true })), false);
assert.equal(userPrivateUploadsEnabled(doc({ publicUploads: true, privateUploads: false })), false);
assert.equal(userPublicUploadsEnabled(doc({ publicUploads: true, privateUploads: true })), true);
assert.equal(userPrivateUploadsEnabled(doc({ publicUploads: true, privateUploads: true })), true);
});
33 changes: 20 additions & 13 deletions remix/app/api/utils/auth/registerUser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,9 +36,10 @@ export type CreateUserAccountInput = {
accountKind?: 'user' | 'service';
emailVerificationRequiredBy?: Date | null;
storageAllowanceBytes?: number;
// Opt a creation path INTO public uploads (admin-provisioned accounts only).
// Public signup never sets it — see the meta assignment below.
// Opt a creation path INTO an upload scope (admin-provisioned accounts
// only). Public signup never sets them — see the meta assignment below.
publicUploads?: boolean;
privateUploads?: boolean;
meta?: Record<string, any>;
};

Expand All @@ -47,10 +48,10 @@ export type CreateUserAccountResult = { ok: false; status: number; error: string
const isEmail = (s: string) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(s);

// Privileged meta keys that must never be set at account creation (only via
// their own admin-gated / authenticated endpoints). `publicUploads` joins
// `admin` here: a public signup body must not be able to hand itself the
// upload permission this hotfix exists to withhold.
const PRIVILEGED_META_KEYS = ['admin', 'publicUploads'];
// their own admin-gated / authenticated endpoints). `publicUploads` and
// `privateUploads` join `admin` here: a public signup body must not be able to
// hand itself the upload permissions this hotfix exists to withhold.
const PRIVILEGED_META_KEYS = ['admin', 'publicUploads', 'privateUploads'];

// Drop privileged keys from any caller-supplied meta before it's persisted.
const sanitizeCreateMeta = (meta: unknown): Record<string, any> => {
Expand Down Expand Up @@ -103,13 +104,19 @@ export const createUserAccount = async (input: CreateUserAccountInput): Promise<
// Defense-in-depth: privileged flags can never be set at creation time,
// even if a caller sneaks them into meta. `admin` is granted only via the
// admin-gated setUserAdmin (auth/admin.ts).
// Public file/media uploads start WITHHELD for every newly created account
// — verifying the email address no longer grants them. An admin turns them
// on per user from /admin (POST /api/v1/admin/users/public-uploads) after
// the "new user" notification lands. `publicUploads` is stripped from any
// caller-supplied meta above, so this is the only writer at creation time.
// Accounts that predate the hotfix have no flag at all and stay enabled.
meta: { ...sanitizeCreateMeta(input.meta), publicUploads: input.publicUploads === true }
// File/media uploads start WITHHELD for every newly created account, in
// BOTH scopes (public = post/comment/emoji, private = messages + own
// profile media) — verifying the email address no longer grants either. An
// admin turns them on per user, per scope or all at once, from /admin
// (POST /api/v1/admin/users/public-uploads) after the "new user"
// notification lands. Both keys are stripped from any caller-supplied meta
// above, so this is the only writer at creation time. Accounts that
// predate the hotfix have no flags at all and stay enabled.
meta: {
...sanitizeCreateMeta(input.meta),
publicUploads: input.publicUploads === true,
privateUploads: input.privateUploads === true
}
};

if (input.emailVerificationRequiredBy !== undefined) {
Expand Down
Loading
Loading