From 1d60779206cb0d9d832b1a69b95085b63243869f Mon Sep 17 00:00:00 2001 From: 4www <4www@duck.com> Date: Mon, 19 Jan 2026 12:07:51 +0100 Subject: [PATCH 1/4] add migration to ban user; also a fix fixes --- .../20260119090943_ban_user_nuke_content.sql | 145 ++++++++++++++++++ .../20260119102953_prevent_track_reuse.sql | 4 + ...0119105952_fix_delete_user_search_path.sql | 32 ++++ 3 files changed, 181 insertions(+) create mode 100644 supabase/migrations/20260119090943_ban_user_nuke_content.sql create mode 100644 supabase/migrations/20260119102953_prevent_track_reuse.sql create mode 100644 supabase/migrations/20260119105952_fix_delete_user_search_path.sql diff --git a/supabase/migrations/20260119090943_ban_user_nuke_content.sql b/supabase/migrations/20260119090943_ban_user_nuke_content.sql new file mode 100644 index 0000000..d6dd206 --- /dev/null +++ b/supabase/migrations/20260119090943_ban_user_nuke_content.sql @@ -0,0 +1,145 @@ +-- Shadow banning and hard-delete moderation + +create or replace function public.is_banned() + returns boolean + language sql + stable +as $$ + -- Access tokens are stateless; this makes bans effective immediately via RLS. + select exists ( + select 1 + from auth.users + where id = auth.uid() + and banned_until > now() + ); +$$; + +create policy "Deny banned users" on public.accounts + as restrictive for all + to authenticated + using (not public.is_banned()) + with check (not public.is_banned()); + +create policy "Deny banned users" on public.channels + as restrictive for all + to authenticated + using (not public.is_banned()) + with check (not public.is_banned()); + +create policy "Deny banned users" on public.tracks + as restrictive for all + to authenticated + using (not public.is_banned()) + with check (not public.is_banned()); + +create policy "Deny banned users" on public.user_channel + as restrictive for all + to authenticated + using (not public.is_banned()) + with check (not public.is_banned()); + +create policy "Deny banned users" on public.channel_track + as restrictive for all + to authenticated + using (not public.is_banned()) + with check (not public.is_banned()); + +create policy "Deny banned users" on public.followers + as restrictive for all + to authenticated + using (not public.is_banned()) + with check (not public.is_banned()); + +create or replace function public.ban_user( + target_user_id uuid, + ban_reason text default null, + ban_until timestamp with time zone default now() + interval '4000 years' +) + returns void + language plpgsql + security definer + set search_path = public +as $$ +begin + if auth.role() <> 'service_role' then + raise exception 'not authorized'; + end if; + + -- This disables new logins; RLS blocks existing sessions immediately. + update auth.users + set banned_until = ban_until + where id = target_user_id; + + with target_channels as ( + select uc.channel_id + from public.user_channel uc + where uc.user_id = target_user_id + ), + tracks_to_delete as ( + select distinct ct.track_id + from public.channel_track ct + where ct.channel_id in (select channel_id from target_channels) + ) + delete from public.tracks + where id in (select track_id from tracks_to_delete); + + with target_channels as ( + select uc.channel_id + from public.user_channel uc + where uc.user_id = target_user_id + ) + delete from public.channels + where id in (select channel_id from target_channels); + + delete from public.user_channel where user_id = target_user_id; +end; +$$; + +create or replace function public.ban_user_by_channel_slug( + channel_slug text, + ban_reason text default null, + ban_until timestamp with time zone default now() + interval '4000 years' +) + returns void + language plpgsql + security definer + set search_path = public +as $$ +declare + target_user_id uuid; + user_count int; +begin + if auth.role() <> 'service_role' then + raise exception 'not authorized'; + end if; + + select count(distinct uc.user_id) + into user_count + from public.channels c + join public.user_channel uc on uc.channel_id = c.id + where c.slug = channel_slug; + + if user_count is null or user_count = 0 then + raise exception 'no owner found for channel slug %', channel_slug; + end if; + + if user_count > 1 then + raise exception 'multiple owners found for channel slug %', channel_slug; + end if; + + select uc.user_id + into target_user_id + from public.channels c + join public.user_channel uc on uc.channel_id = c.id + where c.slug = channel_slug + limit 1; + + perform public.ban_user(target_user_id, ban_reason, ban_until); +end; +$$; + +revoke execute on function public.ban_user(uuid, text, timestamp with time zone) + from anon, public, authenticated; + +revoke execute on function public.ban_user_by_channel_slug(text, text, timestamp with time zone) + from anon, public, authenticated; diff --git a/supabase/migrations/20260119102953_prevent_track_reuse.sql b/supabase/migrations/20260119102953_prevent_track_reuse.sql new file mode 100644 index 0000000..475e130 --- /dev/null +++ b/supabase/migrations/20260119102953_prevent_track_reuse.sql @@ -0,0 +1,4 @@ +-- Ensure a track can belong to only one channel. + +alter table public.channel_track + add constraint channel_track_track_id_unique unique (track_id); diff --git a/supabase/migrations/20260119105952_fix_delete_user_search_path.sql b/supabase/migrations/20260119105952_fix_delete_user_search_path.sql new file mode 100644 index 0000000..28a831a --- /dev/null +++ b/supabase/migrations/20260119105952_fix_delete_user_search_path.sql @@ -0,0 +1,32 @@ +-- Harden delete_user() by pinning search_path. + +create or replace function public.delete_user() + returns void + language sql + security definer + set search_path = public +as $$ + with target_channels as ( + select channel_id + from public.user_channel + where user_id = auth.uid() + ), + tracks_to_delete as ( + select distinct ct.track_id + from public.channel_track ct + where ct.channel_id in (select channel_id from target_channels) + ) + delete from public.tracks + where id in (select track_id from tracks_to_delete); + + with target_channels as ( + select channel_id + from public.user_channel + where user_id = auth.uid() + ) + delete from public.channels + where id in (select channel_id from target_channels); + + delete from public.user_channel where user_id = auth.uid(); + delete from auth.users where id = auth.uid(); +$$; From f1850163b2d4b051c593dbe1501ec11f457acd31 Mon Sep 17 00:00:00 2001 From: oskarrough Date: Wed, 21 Jan 2026 10:17:21 +0100 Subject: [PATCH 2/4] Add broadcast policy for banned users --- .../migrations/20260119090943_ban_user_nuke_content.sql | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/supabase/migrations/20260119090943_ban_user_nuke_content.sql b/supabase/migrations/20260119090943_ban_user_nuke_content.sql index d6dd206..78151c7 100644 --- a/supabase/migrations/20260119090943_ban_user_nuke_content.sql +++ b/supabase/migrations/20260119090943_ban_user_nuke_content.sql @@ -50,6 +50,12 @@ create policy "Deny banned users" on public.followers using (not public.is_banned()) with check (not public.is_banned()); +create policy "Deny banned users" on public.broadcast + as restrictive for all + to authenticated + using (not public.is_banned()) + with check (not public.is_banned()); + create or replace function public.ban_user( target_user_id uuid, ban_reason text default null, From 3608cb9bb3ca947a57e4478171d372e20cf25069 Mon Sep 17 00:00:00 2001 From: oskarrough Date: Wed, 21 Jan 2026 10:17:54 +0100 Subject: [PATCH 3/4] Remove unused ban_reason arg --- .../migrations/20260119090943_ban_user_nuke_content.sql | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/supabase/migrations/20260119090943_ban_user_nuke_content.sql b/supabase/migrations/20260119090943_ban_user_nuke_content.sql index 78151c7..8860abb 100644 --- a/supabase/migrations/20260119090943_ban_user_nuke_content.sql +++ b/supabase/migrations/20260119090943_ban_user_nuke_content.sql @@ -58,7 +58,6 @@ create policy "Deny banned users" on public.broadcast create or replace function public.ban_user( target_user_id uuid, - ban_reason text default null, ban_until timestamp with time zone default now() + interval '4000 years' ) returns void @@ -103,7 +102,6 @@ $$; create or replace function public.ban_user_by_channel_slug( channel_slug text, - ban_reason text default null, ban_until timestamp with time zone default now() + interval '4000 years' ) returns void @@ -140,12 +138,12 @@ begin where c.slug = channel_slug limit 1; - perform public.ban_user(target_user_id, ban_reason, ban_until); + perform public.ban_user(target_user_id, ban_until); end; $$; -revoke execute on function public.ban_user(uuid, text, timestamp with time zone) +revoke execute on function public.ban_user(uuid, timestamp with time zone) from anon, public, authenticated; -revoke execute on function public.ban_user_by_channel_slug(text, text, timestamp with time zone) +revoke execute on function public.ban_user_by_channel_slug(text, timestamp with time zone) from anon, public, authenticated; From 5c4dcbf34fd9c616007fd479e70d7e15ed5f9b93 Mon Sep 17 00:00:00 2001 From: oskarrough Date: Wed, 21 Jan 2026 10:18:05 +0100 Subject: [PATCH 4/4] Make sure delete_user() deletes account --- .../migrations/20260119105952_fix_delete_user_search_path.sql | 1 + 1 file changed, 1 insertion(+) diff --git a/supabase/migrations/20260119105952_fix_delete_user_search_path.sql b/supabase/migrations/20260119105952_fix_delete_user_search_path.sql index 28a831a..1bcffe7 100644 --- a/supabase/migrations/20260119105952_fix_delete_user_search_path.sql +++ b/supabase/migrations/20260119105952_fix_delete_user_search_path.sql @@ -28,5 +28,6 @@ as $$ where id in (select channel_id from target_channels); delete from public.user_channel where user_id = auth.uid(); + delete from public.accounts where id = auth.uid(); delete from auth.users where id = auth.uid(); $$;