Skip to content

Commit 5c2da5f

Browse files
HMarzbanclaude
andcommitted
fix(supabase): close chat mention, media, avatar and delete leaks
A picked chat mention now notifies the user it names by data-id. Before, a rename sent it to the next holder of that name. A typed @name still notifies the current holder. One helper, internal.mentioned_user_ids, serves both fan-outs. Anon and signed-in non-members now read only chat media that a live message names. Before, the public key could list unsent uploads. Channel members still read every object. The two public avatar buckets no longer accept SVG. Settings no longer uploads an SVG as it is. A soft delete now clears the text, files and comment quote of a message. A one-shot backfill clears rows that were deleted before. Each migration pairs with its script, and seed.sql is regenerated. Push the four migrations by hand. Refs #415 Refs #431 Refs #432 Refs #435 Co-Authored-By: Claude Opus 5.5 <[email protected]>
1 parent 369961d commit 5c2da5f

14 files changed

Lines changed: 492 additions & 78 deletions

‎CHANGELOG.md‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -318,6 +318,12 @@ This file is the product changelog. The webapp package keeps [`apps/webapp/CHANG
318318
so the next person on a shared browser could see unsent text. Sign-out also
319319
removes four old document keys from browser storage
320320
([#444](https://github.com/docs-plus/docs.plus/issues/444)).
321+
- Clear the text, files and comment quote of a chat message when it is
322+
deleted. Before, a signed-in account could still read a deleted message
323+
([#435](https://github.com/docs-plus/docs.plus/issues/435)).
324+
- Show guests only the chat files that a sent message uses. An upload that
325+
was never sent can no longer be listed with the public key
326+
([#432](https://github.com/docs-plus/docs.plus/issues/432)).
321327

322328
### Removed
323329

‎apps/hocuspocus.server/CHANGELOG.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -577,6 +577,11 @@ SMTP_PASS=...
577577
deploy this server, and run `scripts/backfill-document-access.ts` (a dry run
578578
first, then `--apply`). Until the backfill runs, Private documents' chat
579579
stays open ([#396](https://github.com/docs-plus/docs.plus/issues/396)).
580+
- **A picked chat mention notifies the user it was picked for.** A username
581+
change no longer sends it to the next holder of that name. A typed `@name`
582+
still notifies the current holder of that name. The Supabase migration
583+
`20261009120300_resolve_mentions_by_user_id` carries the fix; push it by hand
584+
([#415](https://github.com/docs-plus/docs.plus/issues/415)).
580585
- **A local media purge refuses an id that is not one path segment.** On a
581586
server with `PERSIST_TO_LOCAL_STORAGE=true`, a crafted document id could
582587
reach another document's media folder, or a folder outside the storage root.

‎apps/webapp/CHANGELOG.md‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -570,6 +570,10 @@ plus the house order in [`RELEASE_POLICY.md`](../../RELEASE_POLICY.md).
570570
the `new.` host, the home page and shared files make an 11-character link
571571
that nobody can predict and claim first
572572
([#442](https://github.com/docs-plus/docs.plus/issues/442)).
573+
- **Profile photos are never stored as SVG.** The `user_avatars` and
574+
`channel_avatars` buckets accept JPEG, PNG, GIF and WebP only. Settings
575+
converts other images, or shows an error
576+
([#431](https://github.com/docs-plus/docs.plus/issues/431)).
573577

574578
### Removed
575579

‎apps/webapp/src/components/chatroom/CLAUDE.md‎

Lines changed: 4 additions & 4 deletions
Large diffs are not rendered by default.

‎apps/webapp/src/components/settings/utils/prepareAvatarFile.ts‎

Lines changed: 1 addition & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/** Bucket allowlist in `packages/supabase/scripts/12-buckets.sql` (`user_avatars`). */
2-
const BUCKET_MIME = new Set(['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/svg+xml'])
2+
const BUCKET_MIME = new Set(['image/jpeg', 'image/png', 'image/gif', 'image/webp'])
33

44
const EDGE_STEPS_PX = [512, 320, 192] as const
55

@@ -33,13 +33,6 @@ const canvasToFile = (
3333
* canvas when needed and emit a bucket-safe file (upload path stays avatar.png).
3434
*/
3535
export async function prepareAvatarFile(file: File, maxBytes: number): Promise<File> {
36-
if (file.type === 'image/svg+xml') {
37-
if (file.size > maxBytes) {
38-
throw new Error(`Avatar must be less than ${Math.round(maxBytes / 1024)}KB`)
39-
}
40-
return file
41-
}
42-
4336
if (BUCKET_MIME.has(file.type) && file.size <= maxBytes) {
4437
return file
4538
}

‎packages/supabase/CLAUDE.md‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ Moved verbatim out of the repo-root [AGENTS.md](../../AGENTS.md) so it loads onl
2929
- **A `workspaces` row implies a `channels` row, so a read path may guard on the channel and must never create one.** `join_workspace` is the only writer of `public.workspaces`, and it always follows that insert with a `workspace_members` insert, whose `notify_user_join_workspace` trigger mints the channel. `notify_document_content_change` shipped with a `public.channels` insert that was dead on the normal path, cascaded into `channel_creator_as_admin` and `increment_member_count`, and raced two concurrent callers into `23505`, which aborts the whole RPC.
3030
- **Clients do not UPDATE `channels` and do not INSERT or UPDATE `workspaces`.** `join_workspace` and the `SECURITY DEFINER` triggers are the writers. `13-RLS.sql` has no UPDATE policy on `channels` and no write policy on `workspaces` (#401, #409). Clients still INSERT a channel through `channels_member_insert`. The channel-wide mute is not client-writable; the per-user mute on `channel_members` is. Do not add a client write grant back. Route a new write through a definer RPC.
3131
- **Clients INSERT and UPDATE only the granted `public.messages` columns (#400).** INSERT covers `id`, `channel_id`, `user_id`, `content`, `html`, `medias`, `type`, `metadata` and `reply_to_message_id`. UPDATE covers `content`, `html`, `medias`, `type` and `deleted_at`. Migration `20261008120000_lock_message_write_columns.sql` is the current mirror for these grants. A new client-written column needs a grant in `13-RLS.sql` and a migration. Without both, the write fails with `42501`, which PostgREST returns as 403. Cypress intercepts Supabase HTTP, so no test catches this. Notice rows (`type = 'notification'`) come only from definer or service-role writers; both message policies refuse them. SELECT stays table-wide, or realtime drops rows.
32+
- **A soft delete clears the text of a message (#435).** The BEFORE trigger `clear_deleted_message_text` (`scripts/10-3-func-message.sql`) sets `content`, `html` and `medias` to null and drops `metadata.comment`. It keeps the row, `metadata.replied` and `reply_to_message_id`. The AFTER trigger `message_soft_delete` still reads `OLD.medias` for media cleanup. To clear rows that are already deleted, write `deleted_at = deleted_at`, never `content`: a `content` write fires `update_message_previews` and overwrites reply and channel previews. Keep `messages_visible_select` without a `deleted_at` filter. The delete's `.select()` and the realtime delete event need the deleted row. The trigger drops `metadata.comment` only from an object, because the jsonb minus operator raises on a scalar.
3233
- **`public.document_access` is the Private mirror (#396).** It holds `is_private` and `owner_id` for each documentId, the same value as `channels.workspace_id`. Hocuspocus writes it with the service role, and clients have no grant. A missing row means public. `internal.can_open_document(document_id, user_id)` reads it. Chat policies, `join_workspace` and the unread trigger call it, and the anon policies pass a null user. Chat media policies reach it through `internal.can_read_channel` and `internal.is_channel_member`. Notification rows have one gate: the `BEFORE INSERT` trigger `skip_unopenable_notification` on `public.notifications` drops a row whose receiver cannot open the channel's document. Chat and `content_change` rows both pass through it, so a new fan-out needs no check of its own. It costs about 19 µs per row (measured 2026-10-09 at 10,000 rows). The three primitives copy its predicate inline, because a nested definer call doubled chat read time. Keep all four copies identical. The table lives in `03-2-document_access.sql`, so it seeds before `10-0`. `purge_document_footprint` deletes the row. It matches the documentId verbatim, because the mirror does not store `lower(documentId)`.
3334
- **`notify_document_title_change` is service-role only.** REST calls it after a signed-in Pad title rename. It returns 0 and does not raise. It does not create a workspace or a channel. Keep it out of `user_facing_names`. Mention, everyone, and regular-message INSERT triggers skip `type = notification`, so this notice cannot mail or write inbox rows.
3435
- **`idx_notifications_content_change_open` is load-bearing, not decoration.** The 24-hour dedupe `not exists` in the fan-out is not evaluated per row: the planner de-correlates it into a plain Join Filter. Measured on a 600k-row table at 10,000 followers, a second call inside the window took 2081 ms with `Rows Removed by Join Filter: 49,995,000`; with the index it is a Hash Anti Join at 4.9 ms. It is partial (`where type = 'content_change' and readed_at is null`), so it holds 592 kB, and the first-call insert measured 855.8 ms without it and 833.8 ms with it. Reading the index list suggests `idx_notifications_receiver_readed` covers this. It does not — that index cannot serve an inner side reached by a Join Filter.
@@ -39,9 +40,10 @@ Moved verbatim out of the repo-root [AGENTS.md](../../AGENTS.md) so it loads onl
3940
- **`supabaseClient.rpc(...)` is lazy.** It returns a `PostgrestBuilder`; the request only leaves the browser when `.then(...)` / `await` / `.single()` / `.maybeSingle()` is attached. Fire-and-forget calls that drop the result (e.g. a debounced cursor advance) MUST attach `.then((res) => { if (res.error) console.warn(...) })`, or the network call silently never dispatches. Any `supabaseClient.rpc(` without a continuation is a bug.
4041
- **Storage RLS is path-based, not column-based.** Bucket policies for `user_avatars` / `channel_avatars` check `(storage.foldername(name))[1] = (select auth.uid()::text)`, NOT `owner` (uuid) or `owner_id` (text). Storage-api v1.54.1 doesn't reliably auto-populate either column on insert; predicates against them evaluate `NULL = …` → false → 403 "new row violates RLS policy". The FE writes paths as `{userId}/{filename}` (`useAvatarUpload` uses `${user.id}/avatar.png`; `Config.app.profile.getAvatarURL` emits the matching read URL). Encoding ownership in the path is Supabase's canonical pattern and matches Slack/Discord/Linear conventions.
4142
- **Storage bucket policies are CRUD-complete, not write-only.** Migrations that re-declare INSERT / UPDATE / DELETE policies on `storage.objects` MUST also confirm a matching SELECT policy exists. Supabase Storage runs `INSERT … RETURNING` (or INSERT followed by SELECT) on every upload. A missing SELECT policy then returns the same generic "new row violates row-level security policy" 403, which misleadingly points at the INSERT. The actual denial is on the readback. `scripts/12-buckets.sql` declares SELECT policies for `user_avatars` / `channel_avatars` / `media`. Each is scoped by `bucket_id` plus the same path/owner predicate as its write policies. The migration tree must keep parity. Public-URL reads (`/storage/v1/object/public/...`) take a different code path that doesn't go through PostgREST RLS. The avatar display can therefore work while uploads silently 403. That is exactly how this gap survived three rounds of "fix the RLS" migrations.
42-
- **`storage.objects` EXISTS subqueries:** inside `exists (select 1 from public.channels c where …)`, unqualified `name` binds to **`c.name`** (channel slug), not the storage row — correlate media paths with **`objects.name`** (e.g. `(storage.foldername(objects.name))[2]` for `{userId}/{channelId}/…`). Bare `foldername(name)` in public-channel chat media SELECT policies broke anon and non-member signed-URL reads. The signed-in read policy now passes that segment to `internal.can_read_channel`.
43+
- **`storage.objects` EXISTS subqueries:** inside `exists (select 1 from public.channels c where …)`, unqualified `name` binds to **`c.name`** (channel slug), not the storage row — correlate media paths with **`objects.name`** (e.g. `(storage.foldername(objects.name))[2]` for `{userId}/{channelId}/…`). Bare `foldername(name)` in public-channel chat media SELECT policies broke anon and non-member signed-URL reads. The signed-in read policy passes that segment to `internal.is_channel_member` and `internal.can_read_channel`.
44+
- **Anon and signed-in non-members read only chat media that a live message names (#432).** Listing and signing both need SELECT on `storage.objects`, so the old PUBLIC check let the anon key list unsent uploads. Both `media` read policies in `scripts/12-buckets.sql` now also need a live `messages` row in that channel. One of its `medias` entries must have `path` (or `url`) equal to `objects.name`. Active channel members keep read on every object. That member arm is load-bearing: the upload readback and `validate_message_medias` (SECURITY INVOKER) read an unsent object as the uploader. A signed-in viewer joins a PUBLIC channel when they open it, so the gain is mostly for anon. Measured 2026-10-09 on a 5,000-message channel: one sign takes 6.7 ms as anon and 33 ms as a non-member at worst. A 100-row list takes 136 ms as anon and 303 ms as a non-member. The match is raw, not `internal.normalize_chat_media_path`, because that helper stops inlining and keeps signed URLs as they are.
4345
- **Avatar source-of-truth is a two-field hybrid.** `users.avatar_url` holds the OAuth provider URL (set by `handle_new_user` from Google's `picture` claim on signup); `users.avatar_updated_at` is the "user has uploaded a custom avatar" indicator. `<Avatar>` reads `avatar_updated_at` first — if set, derives the bucket URL `${BUCKET}/${id}/avatar.png?${avatar_updated_at}` (the timestamp doubles as cache-buster); if null, falls back to `src` (= `avatar_url` = OAuth URL). `useAvatarUpload.handleUpload` overwrites `avatar_url` with the bucket URL. After a custom upload, `avatar_url` is the bucket URL, not the OAuth one. `handleRemove` nulls BOTH `avatar_url` and `avatar_updated_at`. It writes DB-first, before deleting the storage object, so a render never points at a deleted file. Removing a custom avatar therefore drops to the default avatar; it does not restore the OAuth picture.
44-
- **`user_avatars` rejects AVIF.** Bucket allowlist in `scripts/12-buckets.sql` is jpeg/png/gif/webp/svg only; the file picker’s `image/*` still admits AVIF on macOS. Always run `prepareAvatarFile` (`settings/utils/`) before upload, so unsupported/oversized images become bucket-safe PNG/WebP/JPEG. Do not widen the bucket MIME list, and do not merge this helper into chat-media downscale.
46+
- **`user_avatars` rejects AVIF.** Bucket allowlist in `scripts/12-buckets.sql` is jpeg/png/gif/webp only. SVG was removed because a public bucket serves it inline, and an SVG can carry script (#431). The file picker’s `image/*` still admits AVIF on macOS. Always run `prepareAvatarFile` (`settings/utils/`) before upload, so unsupported/oversized images become bucket-safe PNG/WebP/JPEG. Do not widen the bucket MIME list, and do not merge this helper into chat-media downscale.
4547
- **Channel/workspace IDs are `varchar(36)`, not `uuid`.** `channels.id`, `channels.workspace_id`, and `workspaces.id` are declared `varchar(36) default uuid_generate_v4()` (`scripts/04-channels.sql:5-6`) — they hold UUID-shaped text in a `varchar` column, and `messages.channel_id` follows the same typing via its FK. SQL that compares them against a real `uuid` (e.g. `auth.uid()`, a `uuid`-typed column, or a `::uuid` literal) must cast both sides to the same type or Postgres throws `operator does not exist: character varying = uuid`. This bit the chat-media storage RLS / `validate_message_medias` path, where the `media` object path segment (text) is compared to `channel_id`. Keep those comparisons text-vs-text (or cast explicitly), never `varchar = uuid`.
4648
- **RLS predicates initplan-wrap auth calls** (Supabase lint 0003): write `(select auth.uid())`, never bare `auth.uid()`. For row-independent helper calls, wrap the whole call — `(select public.is_admin((select auth.uid())))`. The linter also flags a bare `auth.<fn>()` nested inside helper-call arguments. The 2026-07-17 sweep cleared all 21; keep new policies clean.
4749
- **`users.profile_data` is public; private settings live in `users.notification_preferences`** (moved 2026-09-30, migration `20260930120000_private_notification_preferences.sql`). `users_select` is `USING (true)` and `profile_data` is in the `anon` and `authenticated` SELECT grants, so every visitor reads it. The new column is in no client grant, and Realtime drops it from `postgres_changes` too, because `realtime.apply_rls` checks `has_column_privilege`. The owner reads it through `get_notification_preferences()` and writes it through `update_notification_preferences()`. The `profile_data_has_no_notification_preferences` check keeps the key from coming back, and a stale client that sends it gets `23514`. Never put a private field in `profile_data`, and never add the column to a grant list. `users_profile_changed` sends a value-free `profile_changed` on the private `profile:<uid>` topic (`profile_topic_access`). Its column list and `IS DISTINCT FROM` guard keep the status heartbeat and cron out; keep both when adding a column. `update_notification_preferences()` checks each key that the push and email triggers cast, and refuses a bad value with `22023` `invalid_preference_value` (#417). A bad value used to abort message inserts. A new key that a trigger casts needs a check there too.
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
-- Issue #435. A soft delete clears the text, files and comment quote of a message.
2+
-- Pairs with scripts/10-3-func-message.sql. The backfill at the end is one-shot and stays here only.
3+
4+
-- The AFTER trigger message_soft_delete cannot change NEW, and it still reads OLD.medias for
5+
-- media cleanup. The WHEN clause leaves out OLD, so the backfill below fires this trigger only.
6+
create or replace function public.clear_deleted_message_text()
7+
returns trigger
8+
language plpgsql
9+
set search_path = ''
10+
as $$
11+
begin
12+
new.content := null;
13+
new.html := null;
14+
new.medias := null;
15+
-- The jsonb minus operator raises on a scalar, and metadata has no type check.
16+
new.metadata := case when jsonb_typeof(new.metadata) = 'object' then new.metadata - 'comment' else new.metadata end;
17+
return new;
18+
end;
19+
$$;
20+
21+
comment on function public.clear_deleted_message_text() is 'Clears the text, files and comment quote of a soft-deleted message.';
22+
23+
drop trigger if exists clear_deleted_message_text on public.messages;
24+
create trigger clear_deleted_message_text
25+
before update of deleted_at on public.messages
26+
for each row
27+
when (new.deleted_at is not null)
28+
execute function public.clear_deleted_message_text();
29+
30+
-- Write deleted_at, never content. A content write fires update_message_previews and
31+
-- overwrites the reply and channel previews. The cleanup triggers check OLD.deleted_at is null.
32+
update public.messages
33+
set deleted_at = deleted_at
34+
where deleted_at is not null
35+
and (content is not null or html is not null or medias is not null
36+
or (jsonb_typeof(metadata) = 'object' and metadata ? 'comment'));
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
-- Issue #432. Anon and signed-in non-members read only chat media that a live message names.
2+
-- Listing and signing both need SELECT, so an unsent upload was listable with the anon key.
3+
-- Pairs with scripts/12-buckets.sql. Keeps every #396 gate from 20261008130000.
4+
5+
-- Members keep read on every object in the channel. The upload readback and
6+
-- validate_message_medias read an unsent object, and both run as the uploader.
7+
-- The path match is raw on purpose. The GC normalizer stops inlining and keeps signed URLs as is.
8+
drop policy if exists "Authed can read chat media" on storage.objects;
9+
create policy "Authed can read chat media" on storage.objects
10+
for select to authenticated using (
11+
bucket_id = 'media'
12+
and (
13+
internal.is_channel_member((storage.foldername(objects.name))[2])
14+
or (
15+
internal.can_read_channel((storage.foldername(objects.name))[2])
16+
and exists (
17+
select 1
18+
from public.messages m
19+
cross join lateral jsonb_array_elements(coalesce(m.medias, '[]'::jsonb)) elem
20+
where m.channel_id = (storage.foldername(objects.name))[2]
21+
and m.deleted_at is null
22+
and coalesce(nullif(elem->>'path', ''), elem->>'url') = objects.name
23+
)
24+
)
25+
)
26+
);
27+
28+
-- Inside the EXISTS, objects.name names the storage row.
29+
drop policy if exists "Anon can read public channel chat media" on storage.objects;
30+
create policy "Anon can read public channel chat media" on storage.objects
31+
for select to anon using (
32+
bucket_id = 'media'
33+
and exists (
34+
select 1
35+
from public.channels c
36+
where c.id = (storage.foldername(objects.name))[2]
37+
and c.type = 'PUBLIC'
38+
and internal.can_open_document(c.workspace_id, null::uuid)
39+
)
40+
and exists (
41+
select 1
42+
from public.messages m
43+
cross join lateral jsonb_array_elements(coalesce(m.medias, '[]'::jsonb)) elem
44+
where m.channel_id = (storage.foldername(objects.name))[2]
45+
and m.deleted_at is null
46+
and coalesce(nullif(elem->>'path', ''), elem->>'url') = objects.name
47+
)
48+
);

0 commit comments

Comments
 (0)