mirror of
https://github.com/jjbrunton/Sauci.git
synced 2026-10-31 18:19:43 +00:00
Implement E2EE and message content moderation
This commit is contained in:
1 parent
e1ff87dc03
commit
198cd80dfd
74 files changed
+12939
-2178
No files matched your search
@@ -0,0 +1,29 @@
|
||||
-- Add E2EE support to messages table
|
||||
-- This migration adds columns for end-to-end encryption of chat messages
|
||||
|
||||
-- Version indicator: 1 = plaintext (legacy), 2 = E2EE
|
||||
ALTER TABLE messages ADD COLUMN IF NOT EXISTS version INTEGER DEFAULT 1;
|
||||
|
||||
-- Encrypted message content (base64 encoded ciphertext)
|
||||
ALTER TABLE messages ADD COLUMN IF NOT EXISTS encrypted_content TEXT;
|
||||
|
||||
-- Initialization vector for AES-GCM (base64 encoded, 12 bytes)
|
||||
ALTER TABLE messages ADD COLUMN IF NOT EXISTS encryption_iv TEXT;
|
||||
|
||||
-- Triple-wrapped encryption keys and metadata
|
||||
ALTER TABLE messages ADD COLUMN IF NOT EXISTS keys_metadata JSONB;
|
||||
|
||||
-- Add comment for documentation
|
||||
COMMENT ON COLUMN messages.version IS
|
||||
'Encryption version: 1 = plaintext (legacy), 2 = E2EE with triple-wrapped keys';
|
||||
|
||||
-- Constraint: v2 messages must have encryption fields
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint WHERE conname = 'e2ee_fields_required'
|
||||
) THEN
|
||||
ALTER TABLE messages ADD CONSTRAINT e2ee_fields_required
|
||||
CHECK (version = 1 OR (version = 2 AND encryption_iv IS NOT NULL AND keys_metadata IS NOT NULL));
|
||||
END IF;
|
||||
END $$;
|
||||
@@ -0,0 +1,12 @@
|
||||
-- Add E2EE public key to profiles table
|
||||
-- This migration adds a column to store user's RSA public key for E2EE
|
||||
|
||||
-- RSA public key in JWK format (JSON Web Key)
|
||||
ALTER TABLE profiles ADD COLUMN IF NOT EXISTS public_key_jwk JSONB;
|
||||
|
||||
-- Index for efficient lookups when encrypting for partner
|
||||
CREATE INDEX IF NOT EXISTS idx_profiles_couple_id_public_key ON profiles(couple_id)
|
||||
WHERE public_key_jwk IS NOT NULL;
|
||||
|
||||
COMMENT ON COLUMN profiles.public_key_jwk IS
|
||||
'RSA-2048 public key in JWK format for E2EE. Private key stored on device only.';
|
||||
@@ -0,0 +1,70 @@
|
||||
-- Create master_keys table for admin E2EE access
|
||||
-- This migration creates the table to store admin RSA public keys for moderation
|
||||
|
||||
CREATE TABLE IF NOT EXISTS master_keys (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
key_name TEXT UNIQUE NOT NULL,
|
||||
public_key_jwk JSONB NOT NULL,
|
||||
created_at TIMESTAMPTZ DEFAULT now(),
|
||||
rotated_at TIMESTAMPTZ,
|
||||
is_active BOOLEAN DEFAULT true
|
||||
);
|
||||
|
||||
-- Add table comment
|
||||
COMMENT ON TABLE master_keys IS
|
||||
'Stores admin RSA public keys for E2EE moderation access. Private key stored securely in environment variables.';
|
||||
|
||||
-- Enable RLS
|
||||
ALTER TABLE master_keys ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- Super admins can view master keys
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_policies WHERE policyname = 'Super admins can view master keys' AND tablename = 'master_keys'
|
||||
) THEN
|
||||
CREATE POLICY "Super admins can view master keys"
|
||||
ON master_keys FOR SELECT
|
||||
TO authenticated
|
||||
USING (is_super_admin(auth.uid()));
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- Super admins can insert master keys
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_policies WHERE policyname = 'Super admins can insert master keys' AND tablename = 'master_keys'
|
||||
) THEN
|
||||
CREATE POLICY "Super admins can insert master keys"
|
||||
ON master_keys FOR INSERT
|
||||
TO authenticated
|
||||
WITH CHECK (is_super_admin(auth.uid()));
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- Super admins can update master keys
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_policies WHERE policyname = 'Super admins can update master keys' AND tablename = 'master_keys'
|
||||
) THEN
|
||||
CREATE POLICY "Super admins can update master keys"
|
||||
ON master_keys FOR UPDATE
|
||||
TO authenticated
|
||||
USING (is_super_admin(auth.uid()));
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- Allow all authenticated users to read active master key public keys (needed for encryption)
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_policies WHERE policyname = 'Anyone can read active master key public keys' AND tablename = 'master_keys'
|
||||
) THEN
|
||||
CREATE POLICY "Anyone can read active master key public keys"
|
||||
ON master_keys FOR SELECT
|
||||
TO authenticated
|
||||
USING (is_active = true);
|
||||
END IF;
|
||||
END $$;
|
||||
@@ -0,0 +1,39 @@
|
||||
-- Migration: Sanitize public keys in profiles table
|
||||
-- Description: Replaces base64 url unsafe characters (+, /) with safe ones (-, _) and removes padding (=) in 'n' and 'e' fields of public_key_jwk
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
r RECORD;
|
||||
new_n TEXT;
|
||||
new_e TEXT;
|
||||
original_n TEXT;
|
||||
original_e TEXT;
|
||||
BEGIN
|
||||
FOR r IN SELECT id, public_key_jwk FROM profiles WHERE public_key_jwk IS NOT NULL LOOP
|
||||
original_n := r.public_key_jwk->>'n';
|
||||
original_e := r.public_key_jwk->>'e';
|
||||
|
||||
IF original_n IS NOT NULL AND original_e IS NOT NULL THEN
|
||||
-- Sanitize 'n'
|
||||
new_n := REPLACE(original_n, '+', '-');
|
||||
new_n := REPLACE(new_n, '/', '_');
|
||||
new_n := REPLACE(new_n, '=', '');
|
||||
|
||||
-- Sanitize 'e'
|
||||
new_e := REPLACE(original_e, '+', '-');
|
||||
new_e := REPLACE(new_e, '/', '_');
|
||||
new_e := REPLACE(new_e, '=', '');
|
||||
|
||||
-- Update if changed
|
||||
IF new_n <> original_n OR new_e <> original_e THEN
|
||||
UPDATE profiles
|
||||
SET public_key_jwk = jsonb_set(
|
||||
jsonb_set(public_key_jwk, '{n}', to_jsonb(new_n)),
|
||||
'{e}',
|
||||
to_jsonb(new_e)
|
||||
)
|
||||
WHERE id = r.id;
|
||||
END IF;
|
||||
END IF;
|
||||
END LOOP;
|
||||
END $$;
|
||||
@@ -0,0 +1,15 @@
|
||||
-- Update the content_or_media constraint to allow E2EE messages
|
||||
-- E2EE messages have encrypted_content instead of content
|
||||
|
||||
ALTER TABLE public.messages DROP CONSTRAINT IF EXISTS content_or_media;
|
||||
|
||||
ALTER TABLE public.messages ADD CONSTRAINT content_or_media CHECK (
|
||||
-- v1 (plaintext): must have content or media_path
|
||||
(version = 1 AND (content IS NOT NULL OR media_path IS NOT NULL))
|
||||
OR
|
||||
-- v2 (E2EE): must have encrypted_content or media_path
|
||||
(version = 2 AND (encrypted_content IS NOT NULL OR media_path IS NOT NULL))
|
||||
OR
|
||||
-- Legacy messages without version: require content or media_path
|
||||
(version IS NULL AND (content IS NOT NULL OR media_path IS NOT NULL))
|
||||
);
|
||||
@@ -0,0 +1,66 @@
|
||||
-- Add moderation columns to messages table
|
||||
ALTER TABLE messages
|
||||
ADD COLUMN moderation_status text DEFAULT 'unmoderated' CHECK (moderation_status IN ('unmoderated', 'safe', 'flagged')),
|
||||
ADD COLUMN flag_reason text;
|
||||
|
||||
-- Add classifier configuration to ai_config table
|
||||
ALTER TABLE ai_config
|
||||
ADD COLUMN classifier_enabled boolean DEFAULT true,
|
||||
ADD COLUMN classifier_model text DEFAULT 'openai/gpt-4o',
|
||||
ADD COLUMN classifier_prompt text DEFAULT 'You are a content moderator for a couples app. Your task is to analyze the following message (text and/or image) and determine if it contains ILLEGAL or DANGEROUS content.
|
||||
|
||||
Sexual content (nudity, sexual acts, intimate conversations) is ALLOWED and should be marked as SAFE.
|
||||
|
||||
You must FLAG content ONLY if it falls into these categories:
|
||||
1. Child Sexual Abuse Material (CSAM) or any depiction of minors in a sexual context.
|
||||
2. Non-consensual sexual content (revenge porn, rape, assault).
|
||||
3. Promotion of self-harm or suicide.
|
||||
4. Human trafficking, terrorism, or extreme violence/gore.
|
||||
5. Illegal drugs or weapons trafficking.
|
||||
|
||||
If the content is safe (including consensual adult sexual content), respond with {"status": "safe"}.
|
||||
If the content is illegal/dangerous, respond with {"status": "flagged", "reason": "Brief explanation"}.
|
||||
|
||||
Response must be valid JSON.';
|
||||
|
||||
-- Function to trigger classification
|
||||
CREATE OR REPLACE FUNCTION trigger_classify_message()
|
||||
RETURNS TRIGGER
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path = ''
|
||||
AS $$
|
||||
DECLARE
|
||||
v_classifier_enabled boolean;
|
||||
BEGIN
|
||||
-- Check if classifier is enabled
|
||||
SELECT classifier_enabled INTO v_classifier_enabled
|
||||
FROM public.ai_config
|
||||
LIMIT 1;
|
||||
|
||||
-- If enabled (or null which defaults to true conceptually, but let's be strict), trigger function
|
||||
IF v_classifier_enabled IS TRUE THEN
|
||||
PERFORM net.http_post(
|
||||
url := 'https://ckjcrkjpmhqhiucifukx.supabase.co/functions/v1/classify-message',
|
||||
headers := jsonb_build_object(
|
||||
'Content-Type', 'application/json'
|
||||
),
|
||||
body := jsonb_build_object(
|
||||
'message_id', NEW.id
|
||||
)
|
||||
);
|
||||
END IF;
|
||||
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- Create trigger on messages table
|
||||
CREATE TRIGGER on_message_created_classify
|
||||
AFTER INSERT ON messages
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION trigger_classify_message();
|
||||
|
||||
-- Add comments
|
||||
COMMENT ON COLUMN messages.moderation_status IS 'Content moderation status: unmoderated, safe, or flagged.';
|
||||
COMMENT ON COLUMN messages.flag_reason IS 'Reason for flagging the content (if status is flagged).';
|
||||
@@ -0,0 +1,32 @@
|
||||
-- Add category column to messages table
|
||||
ALTER TABLE messages
|
||||
ADD COLUMN category text;
|
||||
|
||||
COMMENT ON COLUMN messages.category IS 'Content category: Neutral, Romantic, Playful, Explicit, etc.';
|
||||
|
||||
-- Update the default classifier prompt in ai_config
|
||||
UPDATE ai_config
|
||||
SET classifier_prompt = 'You are a content moderator for a couples app. Your task is to analyze the following message (text and/or image) and determine if it contains ILLEGAL or DANGEROUS content, and also categorize the tone/nature of the message.
|
||||
|
||||
1. SAFETY CHECK:
|
||||
Sexual content (nudity, sexual acts, intimate conversations) is ALLOWED and should be marked as SAFE.
|
||||
You must FLAG content ONLY if it falls into these categories:
|
||||
- Child Sexual Abuse Material (CSAM) or any depiction of minors in a sexual context.
|
||||
- Non-consensual sexual content (revenge porn, rape, assault).
|
||||
- Promotion of self-harm or suicide.
|
||||
- Human trafficking, terrorism, or extreme violence/gore.
|
||||
- Illegal drugs or weapons trafficking.
|
||||
|
||||
2. CATEGORIZATION:
|
||||
Classify the content into one of these categories:
|
||||
- "Neutral": General conversation, logistics, day-to-day, non-sexual.
|
||||
- "Romantic": Affectionate, emotional connection, loving, sweet.
|
||||
- "Playful": Flirty, teasing, fun, joking.
|
||||
- "Explicit": Sexual, intimate, NSFW, steamy.
|
||||
|
||||
Response Format (JSON):
|
||||
{
|
||||
"status": "safe" | "flagged",
|
||||
"reason": "Explanation if flagged, or null if safe",
|
||||
"category": "Neutral" | "Romantic" | "Playful" | "Explicit"
|
||||
}';
|
||||
@@ -0,0 +1,52 @@
|
||||
-- Add deleted_at column to messages table for "delete for everyone"
|
||||
ALTER TABLE messages ADD COLUMN deleted_at TIMESTAMPTZ;
|
||||
|
||||
-- Create message_deletions table for "delete for self" (per-user deletions)
|
||||
CREATE TABLE message_deletions (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
message_id UUID NOT NULL REFERENCES messages(id) ON DELETE CASCADE,
|
||||
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
|
||||
deleted_at TIMESTAMPTZ DEFAULT now(),
|
||||
UNIQUE(message_id, user_id)
|
||||
);
|
||||
|
||||
-- Index for efficient lookup when filtering messages
|
||||
CREATE INDEX idx_message_deletions_user_message ON message_deletions(user_id, message_id);
|
||||
CREATE INDEX idx_messages_deleted_at ON messages(deleted_at) WHERE deleted_at IS NOT NULL;
|
||||
|
||||
-- Enable RLS on message_deletions
|
||||
ALTER TABLE message_deletions ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- RLS Policies for message_deletions
|
||||
|
||||
-- Users can view their own deletions (needed for filtering)
|
||||
CREATE POLICY "Users can view own deletions"
|
||||
ON message_deletions FOR SELECT
|
||||
USING (user_id = auth.uid());
|
||||
|
||||
-- Users can insert their own deletions (for messages in their matches)
|
||||
CREATE POLICY "Users can delete messages for self"
|
||||
ON message_deletions FOR INSERT
|
||||
WITH CHECK (
|
||||
user_id = auth.uid() AND
|
||||
EXISTS (
|
||||
SELECT 1 FROM messages m
|
||||
JOIN matches ma ON ma.id = m.match_id
|
||||
JOIN profiles p ON p.couple_id = ma.couple_id
|
||||
WHERE m.id = message_deletions.message_id
|
||||
AND p.id = auth.uid()
|
||||
)
|
||||
);
|
||||
|
||||
-- Update messages RLS policy to allow authors to set deleted_at
|
||||
CREATE POLICY "Authors can soft delete messages for everyone"
|
||||
ON messages FOR UPDATE
|
||||
USING (user_id = auth.uid())
|
||||
WITH CHECK (user_id = auth.uid());
|
||||
|
||||
-- Add to realtime for live updates
|
||||
ALTER PUBLICATION supabase_realtime ADD TABLE message_deletions;
|
||||
|
||||
-- Comments for documentation
|
||||
COMMENT ON COLUMN messages.deleted_at IS 'Timestamp when message was deleted for everyone (author-only action). Shows "Message deleted" to all users.';
|
||||
COMMENT ON TABLE message_deletions IS 'Per-user message deletions for "delete for self" functionality. Message still visible to other user.';
|
||||
Reference in new issue
Block a user