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
@@ -176,3 +176,40 @@ backgroundColor: 'rgba(18, 18, 18, 0.85)',
|
||||
The `BlurView` in `tabBarBackground` is unreliable and can fail during re-renders/transitions, causing a fully transparent tab bar. The solid rgba background acts as a fallback while still allowing the blur effect on top.
|
||||
|
||||
**Location:** `apps/mobile/app/(app)/_layout.tsx` - `tabBarStyle`
|
||||
|
||||
## E2EE (End-to-End Encryption) Implementation
|
||||
|
||||
Chat messages (text, images, videos) are encrypted client-side using AES-256-GCM with triple-wrapped RSA keys.
|
||||
|
||||
### Architecture
|
||||
- **Full documentation:** See `docs/e2ee-implementation.md` for complete details
|
||||
- **Encryption library:** `apps/mobile/src/lib/encryption/`
|
||||
- **React hooks:** `apps/mobile/src/hooks/useEncryptionKeys.ts`, `useDecryptedMessage.ts`
|
||||
- **Edge functions:** `handle-key-rotation`, `reencrypt-pending-messages`
|
||||
|
||||
### Key Concepts
|
||||
1. **Message versions:** v1 = plaintext (legacy), v2 = E2EE
|
||||
2. **Triple-wrapped keys:** Each message's AES key is RSA-encrypted for sender, recipient, and admin
|
||||
3. **Device recovery:** When user gets new device, admin key re-wraps messages for new user key
|
||||
|
||||
### Database Tables
|
||||
- `messages.version` - Encryption version (1 or 2)
|
||||
- `messages.encrypted_content` - Base64 ciphertext
|
||||
- `messages.encryption_iv` - Base64 IV for AES-GCM
|
||||
- `messages.keys_metadata` - JSONB with wrapped keys
|
||||
- `profiles.public_key_jwk` - User's RSA public key
|
||||
- `master_keys` - Admin RSA public keys
|
||||
|
||||
### Setup Required
|
||||
1. Run `node scripts/generate-admin-keys.mjs` to generate admin keypair
|
||||
2. Insert public key into `master_keys` table
|
||||
3. Store private key in `ADMIN_PRIVATE_KEY_JWK` environment variable for edge functions
|
||||
4. Install crypto dependency: `npm install react-native-quick-crypto@^0.7.x`
|
||||
5. Run `npx expo prebuild --clean` after installation
|
||||
|
||||
### Edge Functions for E2EE
|
||||
| Function | verify_jwt | Purpose |
|
||||
|----------|------------|---------|
|
||||
| `handle-key-rotation` | false | Re-wraps message keys when user gets new device |
|
||||
| `reencrypt-pending-messages` | false | Wraps keys for recipient after they generate keys |
|
||||
|
||||
+177
-168
@@ -1,168 +1,177 @@
|
||||
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
|
||||
import { AuthProvider, PERMISSION_KEYS } from '@/contexts/AuthContext';
|
||||
import { ProtectedRoute } from '@/components/ProtectedRoute';
|
||||
import { AppLayout } from '@/components/layout/AppLayout';
|
||||
import { LoginPage } from '@/pages/LoginPage';
|
||||
import { DashboardPage } from '@/pages/DashboardPage';
|
||||
import { CategoriesPage } from '@/pages/content/CategoriesPage';
|
||||
import { PacksPage } from '@/pages/content/PacksPage';
|
||||
import { QuestionsPage } from '@/pages/content/QuestionsPage';
|
||||
import { UsersPage } from '@/pages/users/UsersPage';
|
||||
import { UserDetailPage } from '@/pages/users/UserDetailPage';
|
||||
import { MatchChatPage } from '@/pages/users/MatchChatPage';
|
||||
import { AuditLogsPage } from '@/pages/AuditLogsPage';
|
||||
import { AdminsPage } from '@/pages/admins/AdminsPage';
|
||||
import { RedemptionCodesPage } from '@/pages/RedemptionCodesPage';
|
||||
import { FeedbackPage } from '@/pages/FeedbackPage';
|
||||
import { UsageInsightsPage } from '@/pages/UsageInsightsPage';
|
||||
import { UserActivityPage } from '@/pages/activity/UserActivityPage';
|
||||
import { AiSettingsPage } from '@/pages/AiSettingsPage';
|
||||
import { AppSettingsPage } from '@/pages/AppSettingsPage';
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<AuthProvider>
|
||||
<Routes>
|
||||
{/* Public routes */}
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
|
||||
{/* Protected routes */}
|
||||
<Route
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<AppLayout />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
>
|
||||
{/* Dashboard */}
|
||||
<Route path="/" element={<DashboardPage />} />
|
||||
|
||||
{/* Content management */}
|
||||
<Route
|
||||
path="/categories"
|
||||
element={
|
||||
<ProtectedRoute requiredPermission={PERMISSION_KEYS.MANAGE_CATEGORIES}>
|
||||
<CategoriesPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/categories/:categoryId/packs"
|
||||
element={
|
||||
<ProtectedRoute requiredPermission={PERMISSION_KEYS.MANAGE_PACKS}>
|
||||
<PacksPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/packs/:packId/questions"
|
||||
element={
|
||||
<ProtectedRoute requiredPermission={PERMISSION_KEYS.MANAGE_QUESTIONS}>
|
||||
<QuestionsPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* User management */}
|
||||
<Route
|
||||
path="/users"
|
||||
element={
|
||||
<ProtectedRoute requiredPermission={PERMISSION_KEYS.VIEW_USERS}>
|
||||
<UsersPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/users/:userId"
|
||||
element={
|
||||
<ProtectedRoute requiredPermission={PERMISSION_KEYS.VIEW_USERS}>
|
||||
<UserDetailPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/users/:userId/matches/:matchId"
|
||||
element={
|
||||
<ProtectedRoute requiredPermission={PERMISSION_KEYS.VIEW_CHATS}>
|
||||
<MatchChatPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/usage-insights"
|
||||
element={
|
||||
<ProtectedRoute requiredPermission={PERMISSION_KEYS.VIEW_USERS}>
|
||||
<UsageInsightsPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/activity"
|
||||
element={
|
||||
<ProtectedRoute requiredPermission={PERMISSION_KEYS.VIEW_ACTIVITY}>
|
||||
<UserActivityPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* System */}
|
||||
<Route
|
||||
path="/admins"
|
||||
element={
|
||||
<ProtectedRoute requiredPermission={PERMISSION_KEYS.MANAGE_ADMINS}>
|
||||
<AdminsPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/redemption-codes"
|
||||
element={
|
||||
<ProtectedRoute requiredPermission={PERMISSION_KEYS.MANAGE_CODES}>
|
||||
<RedemptionCodesPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/audit-logs"
|
||||
element={
|
||||
<ProtectedRoute requiredPermission={PERMISSION_KEYS.VIEW_AUDIT_LOGS}>
|
||||
<AuditLogsPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/feedback"
|
||||
element={
|
||||
<ProtectedRoute requiredPermission={PERMISSION_KEYS.MANAGE_FEEDBACK}>
|
||||
<FeedbackPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/ai-settings"
|
||||
element={
|
||||
<ProtectedRoute requiredPermission={PERMISSION_KEYS.MANAGE_AI_CONFIG}>
|
||||
<AiSettingsPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/app-settings"
|
||||
element={
|
||||
<ProtectedRoute requiredPermission={PERMISSION_KEYS.MANAGE_APP_CONFIG}>
|
||||
<AppSettingsPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
</Route>
|
||||
|
||||
{/* Catch all - redirect to dashboard */}
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
</AuthProvider>
|
||||
</BrowserRouter>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
|
||||
import { AuthProvider, PERMISSION_KEYS } from '@/contexts/AuthContext';
|
||||
import { ProtectedRoute } from '@/components/ProtectedRoute';
|
||||
import { AppLayout } from '@/components/layout/AppLayout';
|
||||
import { LoginPage } from '@/pages/LoginPage';
|
||||
import { DashboardPage } from '@/pages/DashboardPage';
|
||||
import { CategoriesPage } from '@/pages/content/CategoriesPage';
|
||||
import { PacksPage } from '@/pages/content/PacksPage';
|
||||
import { QuestionsPage } from '@/pages/content/QuestionsPage';
|
||||
import { UsersPage } from '@/pages/users/UsersPage';
|
||||
import { UserDetailPage } from '@/pages/users/UserDetailPage';
|
||||
import { MatchChatPage } from '@/pages/users/MatchChatPage';
|
||||
import { AuditLogsPage } from '@/pages/AuditLogsPage';
|
||||
import { AdminsPage } from '@/pages/admins/AdminsPage';
|
||||
import { RedemptionCodesPage } from '@/pages/RedemptionCodesPage';
|
||||
import { FeedbackPage } from '@/pages/FeedbackPage';
|
||||
import { UsageInsightsPage } from '@/pages/UsageInsightsPage';
|
||||
import { UserActivityPage } from '@/pages/activity/UserActivityPage';
|
||||
import { AiSettingsPage } from '@/pages/AiSettingsPage';
|
||||
import { AppSettingsPage } from '@/pages/AppSettingsPage';
|
||||
import { FlaggedMessagesPage } from '@/pages/FlaggedMessagesPage';
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<AuthProvider>
|
||||
<Routes>
|
||||
{/* Public routes */}
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
|
||||
{/* Protected routes */}
|
||||
<Route
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<AppLayout />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
>
|
||||
{/* Dashboard */}
|
||||
<Route path="/" element={<DashboardPage />} />
|
||||
|
||||
{/* Content management */}
|
||||
<Route
|
||||
path="/categories"
|
||||
element={
|
||||
<ProtectedRoute requiredPermission={PERMISSION_KEYS.MANAGE_CATEGORIES}>
|
||||
<CategoriesPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/categories/:categoryId/packs"
|
||||
element={
|
||||
<ProtectedRoute requiredPermission={PERMISSION_KEYS.MANAGE_PACKS}>
|
||||
<PacksPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/packs/:packId/questions"
|
||||
element={
|
||||
<ProtectedRoute requiredPermission={PERMISSION_KEYS.MANAGE_QUESTIONS}>
|
||||
<QuestionsPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* User management */}
|
||||
<Route
|
||||
path="/users"
|
||||
element={
|
||||
<ProtectedRoute requiredPermission={PERMISSION_KEYS.VIEW_USERS}>
|
||||
<UsersPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/users/:userId"
|
||||
element={
|
||||
<ProtectedRoute requiredPermission={PERMISSION_KEYS.VIEW_USERS}>
|
||||
<UserDetailPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/users/:userId/matches/:matchId"
|
||||
element={
|
||||
<ProtectedRoute requiredPermission={PERMISSION_KEYS.VIEW_CHATS}>
|
||||
<MatchChatPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/usage-insights"
|
||||
element={
|
||||
<ProtectedRoute requiredPermission={PERMISSION_KEYS.VIEW_USERS}>
|
||||
<UsageInsightsPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/activity"
|
||||
element={
|
||||
<ProtectedRoute requiredPermission={PERMISSION_KEYS.VIEW_ACTIVITY}>
|
||||
<UserActivityPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* System */}
|
||||
<Route
|
||||
path="/admins"
|
||||
element={
|
||||
<ProtectedRoute requiredPermission={PERMISSION_KEYS.MANAGE_ADMINS}>
|
||||
<AdminsPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/redemption-codes"
|
||||
element={
|
||||
<ProtectedRoute requiredPermission={PERMISSION_KEYS.MANAGE_CODES}>
|
||||
<RedemptionCodesPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/audit-logs"
|
||||
element={
|
||||
<ProtectedRoute requiredPermission={PERMISSION_KEYS.VIEW_AUDIT_LOGS}>
|
||||
<AuditLogsPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/feedback"
|
||||
element={
|
||||
<ProtectedRoute requiredPermission={PERMISSION_KEYS.MANAGE_FEEDBACK}>
|
||||
<FeedbackPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/ai-settings"
|
||||
element={
|
||||
<ProtectedRoute requiredPermission={PERMISSION_KEYS.MANAGE_AI_CONFIG}>
|
||||
<AiSettingsPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/flagged-messages"
|
||||
element={
|
||||
<ProtectedRoute requiredPermission={PERMISSION_KEYS.MANAGE_AI_CONFIG}>
|
||||
<FlaggedMessagesPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/app-settings"
|
||||
element={
|
||||
<ProtectedRoute requiredPermission={PERMISSION_KEYS.MANAGE_APP_CONFIG}>
|
||||
<AppSettingsPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
</Route>
|
||||
|
||||
{/* Catch all - redirect to dashboard */}
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
</AuthProvider>
|
||||
</BrowserRouter>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
X,
|
||||
Home,
|
||||
Activity,
|
||||
AlertTriangle,
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
@@ -82,6 +83,12 @@ const systemNav: NavItem[] = [
|
||||
icon: <Shield className="h-5 w-5" />,
|
||||
requiredPermission: PERMISSION_KEYS.MANAGE_ADMINS,
|
||||
},
|
||||
{
|
||||
label: 'Flagged Messages',
|
||||
href: '/flagged-messages',
|
||||
icon: <AlertTriangle className="h-5 w-5" />,
|
||||
requiredPermission: PERMISSION_KEYS.MANAGE_AI_CONFIG,
|
||||
},
|
||||
{
|
||||
label: 'AI Settings',
|
||||
href: '/ai-settings',
|
||||
|
||||
@@ -20,6 +20,9 @@ export interface AiConfig {
|
||||
council_reviewer_model: string | null;
|
||||
council_selection_mode: CouncilSelectionMode | null; // 'whole_set' or 'cherry_pick'
|
||||
cherry_pick_ensure_intensity_distribution: boolean | null; // Balance intensity levels in cherry-pick mode
|
||||
classifier_enabled: boolean | null;
|
||||
classifier_model: string | null;
|
||||
classifier_prompt: string | null;
|
||||
updated_at: string | null;
|
||||
updated_by: string | null;
|
||||
}
|
||||
|
||||
@@ -9,7 +9,8 @@ import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||
import { Save, Eye, EyeOff, AlertTriangle, Bot, Sparkles, RefreshCw, Plus, Trash2, Package, Cherry } from 'lucide-react';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Save, Eye, EyeOff, AlertTriangle, Bot, Sparkles, RefreshCw, Plus, Trash2, Package, Cherry, Shield } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { format } from 'date-fns';
|
||||
import { ModelCombobox } from '@/components/ModelCombobox';
|
||||
@@ -47,6 +48,9 @@ export function AiSettingsPage() {
|
||||
council_reviewer_model: config.council_reviewer_model || '',
|
||||
council_selection_mode: config.council_selection_mode || 'whole_set',
|
||||
cherry_pick_ensure_intensity_distribution: config.cherry_pick_ensure_intensity_distribution ?? true,
|
||||
classifier_enabled: config.classifier_enabled ?? true,
|
||||
classifier_model: config.classifier_model || 'openai/gpt-4o',
|
||||
classifier_prompt: config.classifier_prompt || '',
|
||||
});
|
||||
setHasChanges(false);
|
||||
}
|
||||
@@ -92,6 +96,9 @@ export function AiSettingsPage() {
|
||||
council_reviewer_model: config.council_reviewer_model || '',
|
||||
council_selection_mode: config.council_selection_mode || 'whole_set',
|
||||
cherry_pick_ensure_intensity_distribution: config.cherry_pick_ensure_intensity_distribution ?? true,
|
||||
classifier_enabled: config.classifier_enabled ?? true,
|
||||
classifier_model: config.classifier_model || 'openai/gpt-4o',
|
||||
classifier_prompt: config.classifier_prompt || '',
|
||||
});
|
||||
setHasChanges(false);
|
||||
}
|
||||
@@ -248,6 +255,59 @@ export function AiSettingsPage() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Moderation Settings */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Shield className="h-5 w-5" />
|
||||
Moderation Settings
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Configure automatic content moderation for messages
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
<Label>Enable Auto-Moderation</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Automatically flag illegal/dangerous content
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={formData.classifier_enabled ?? true}
|
||||
onCheckedChange={(checked) => handleChange('classifier_enabled', checked)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="classifier-model">Classifier Model</Label>
|
||||
<ModelCombobox
|
||||
value={formData.classifier_model || ''}
|
||||
onChange={(value) => handleChange('classifier_model', value)}
|
||||
placeholder="openai/gpt-4o"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Model used for classification (must support vision if image moderation is needed)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="classifier-prompt">System Prompt</Label>
|
||||
<Textarea
|
||||
id="classifier-prompt"
|
||||
value={formData.classifier_prompt || ''}
|
||||
onChange={(e) => handleChange('classifier_prompt', e.target.value)}
|
||||
className="h-32 font-mono text-sm"
|
||||
placeholder="You are a content moderator..."
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Instructions for the AI classifier.
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Model Configuration */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
|
||||
@@ -0,0 +1,323 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { supabase } from '@/config';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||
import { Shield, CheckCircle, AlertTriangle, Eye, RefreshCw } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { format } from 'date-fns';
|
||||
|
||||
interface FlaggedMessage {
|
||||
id: string;
|
||||
created_at: string;
|
||||
user_id: string;
|
||||
moderation_status: 'flagged' | 'safe' | 'unmoderated';
|
||||
flag_reason: string | null;
|
||||
content?: string; // Encrypted usually
|
||||
media_path?: string;
|
||||
media_type?: string;
|
||||
}
|
||||
|
||||
export function FlaggedMessagesPage() {
|
||||
const [messages, setMessages] = useState<FlaggedMessage[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [selectedMessage, setSelectedMessage] = useState<FlaggedMessage | null>(null);
|
||||
const [decryptedContent, setDecryptedContent] = useState<{ text: string | null; mediaUrl: string | null }>({ text: null, mediaUrl: null });
|
||||
const [decrypting, setDecrypting] = useState(false);
|
||||
const [processing, setProcessing] = useState<string | null>(null); // messageId being processed
|
||||
|
||||
const fetchMessages = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const { data, error } = await supabase
|
||||
.from('messages')
|
||||
.select('id, created_at, user_id, moderation_status, flag_reason, content, media_path, media_type')
|
||||
.eq('moderation_status', 'flagged')
|
||||
.order('created_at', { ascending: false });
|
||||
|
||||
if (error) throw error;
|
||||
setMessages(data || []);
|
||||
} catch (error) {
|
||||
console.error('Failed to load flagged messages:', error);
|
||||
toast.error('Failed to load messages');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchMessages();
|
||||
}, []);
|
||||
|
||||
const handleView = async (message: FlaggedMessage) => {
|
||||
setSelectedMessage(message);
|
||||
setDecrypting(true);
|
||||
setDecryptedContent({ text: null, mediaUrl: null });
|
||||
|
||||
try {
|
||||
// Call edge function to decrypt
|
||||
// We use admin-decrypt-message for text
|
||||
let text = null;
|
||||
let mediaUrl = null;
|
||||
|
||||
// 1. Decrypt Text/Metadata
|
||||
const { data: decryptData, error: decryptError } = await supabase.functions.invoke('admin-decrypt-message', {
|
||||
body: { messageId: message.id }
|
||||
});
|
||||
|
||||
if (decryptError) throw decryptError;
|
||||
|
||||
if (decryptData) {
|
||||
text = decryptData.content;
|
||||
|
||||
// 2. Handle Media
|
||||
// If it has media, admin-decrypt-message returns media_path but not the file content directly decrypted usually
|
||||
// But admin-decrypt-media function returns the raw bytes
|
||||
if (decryptData.media_path) {
|
||||
const { data: mediaBlob, error: mediaError } = await supabase.functions.invoke('admin-decrypt-media', {
|
||||
body: { messageId: message.id },
|
||||
// Response is a blob/file usually?
|
||||
// supabase.functions.invoke by default parses JSON.
|
||||
// We might need to handle blob response.
|
||||
});
|
||||
|
||||
// Invoke returns data as parsed JSON if header is json, or blob?
|
||||
// The admin-decrypt-media returns binary body.
|
||||
// Supabase js client might try to parse it.
|
||||
// We might need a direct fetch or handle responseType.
|
||||
}
|
||||
}
|
||||
|
||||
// NOTE: For now, displaying text content. Media decryption in frontend via edge function binary response
|
||||
// is tricky with supabase-js invoke().
|
||||
// We will just show the text for now or implement media later if critical.
|
||||
// Actually, we can just use a direct fetch with the user's session token.
|
||||
|
||||
setDecryptedContent({ text, mediaUrl });
|
||||
|
||||
} catch (error) {
|
||||
console.error('Decryption failed:', error);
|
||||
toast.error('Failed to decrypt content');
|
||||
setDecryptedContent({ text: 'Error decrypting content', mediaUrl: null });
|
||||
} finally {
|
||||
setDecrypting(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Custom media fetcher because supabase.functions.invoke is JSON-centric
|
||||
const fetchDecryptedMedia = async (messageId: string) => {
|
||||
try {
|
||||
const { data: { session } } = await supabase.auth.getSession();
|
||||
if (!session) return null;
|
||||
|
||||
const response = await fetch(`https://ckjcrkjpmhqhiucifukx.supabase.co/functions/v1/admin-decrypt-media`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${session.access_token}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ messageId })
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error('Failed to fetch media');
|
||||
|
||||
const blob = await response.blob();
|
||||
return URL.createObjectURL(blob);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
// Improved handleView with media support
|
||||
const handleViewComplete = async (message: FlaggedMessage) => {
|
||||
setSelectedMessage(message);
|
||||
setDecrypting(true);
|
||||
setDecryptedContent({ text: null, mediaUrl: null });
|
||||
|
||||
try {
|
||||
// Text
|
||||
const { data: textData, error: textError } = await supabase.functions.invoke('admin-decrypt-message', {
|
||||
body: { messageId: message.id }
|
||||
});
|
||||
|
||||
let mediaUrl = null;
|
||||
if (message.media_path) {
|
||||
mediaUrl = await fetchDecryptedMedia(message.id);
|
||||
}
|
||||
|
||||
setDecryptedContent({
|
||||
text: textData?.content || (textError ? 'Error decrypting text' : null),
|
||||
mediaUrl
|
||||
});
|
||||
|
||||
} catch (e) {
|
||||
toast.error("Error loading content");
|
||||
} finally {
|
||||
setDecrypting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMarkSafe = async (messageId: string) => {
|
||||
setProcessing(messageId);
|
||||
try {
|
||||
const { error } = await supabase
|
||||
.from('messages')
|
||||
.update({ moderation_status: 'safe' })
|
||||
.eq('id', messageId);
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
toast.success('Message marked as safe');
|
||||
setMessages(prev => prev.filter(m => m.id !== messageId));
|
||||
if (selectedMessage?.id === messageId) setSelectedMessage(null);
|
||||
} catch (error) {
|
||||
toast.error('Failed to update status');
|
||||
} finally {
|
||||
setProcessing(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">Flagged Messages</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Review content flagged by AI moderation
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="outline" onClick={fetchMessages} disabled={loading}>
|
||||
<RefreshCw className={`h-4 w-4 mr-2 ${loading ? 'animate-spin' : ''}`} />
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Time</TableHead>
|
||||
<TableHead>Reason</TableHead>
|
||||
<TableHead>Type</TableHead>
|
||||
<TableHead className="text-right">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{messages.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={4} className="text-center py-8 text-muted-foreground">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<CheckCircle className="h-8 w-8 text-green-500/50" />
|
||||
<p>No flagged messages found. Good job!</p>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
messages.map((message) => (
|
||||
<TableRow key={message.id}>
|
||||
<TableCell className="font-mono text-sm">
|
||||
{format(new Date(message.created_at), 'MMM d, HH:mm')}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="destructive" className="bg-red-500/10 text-red-500 hover:bg-red-500/20 border-0">
|
||||
{message.flag_reason || 'Unknown'}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{message.media_path ? (
|
||||
<Badge variant="outline">{message.media_type || 'Media'}</Badge>
|
||||
) : (
|
||||
<Badge variant="secondary">Text</Badge>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right space-x-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleViewComplete(message)}
|
||||
>
|
||||
<Eye className="h-4 w-4 mr-2" />
|
||||
Review
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-green-600 hover:text-green-700 hover:bg-green-50"
|
||||
onClick={() => handleMarkSafe(message.id)}
|
||||
disabled={processing === message.id}
|
||||
>
|
||||
<CheckCircle className="h-4 w-4 mr-2" />
|
||||
Mark Safe
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Card>
|
||||
|
||||
<Dialog open={!!selectedMessage} onOpenChange={(open) => !open && setSelectedMessage(null)}>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Review Content</DialogTitle>
|
||||
<DialogDescription>
|
||||
Decrypted content for review. Flagged for: {selectedMessage?.flag_reason}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="min-h-[200px] bg-muted/30 rounded-md p-4 flex items-center justify-center">
|
||||
{decrypting ? (
|
||||
<div className="flex flex-col items-center gap-2 text-muted-foreground">
|
||||
<RefreshCw className="h-6 w-6 animate-spin" />
|
||||
<p className="text-sm">Decrypting secure content...</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-full space-y-4">
|
||||
{decryptedContent.text && (
|
||||
<div className="bg-background border rounded p-3 text-sm">
|
||||
{decryptedContent.text}
|
||||
</div>
|
||||
)}
|
||||
{decryptedContent.mediaUrl && (
|
||||
<div className="flex justify-center">
|
||||
{selectedMessage?.media_type === 'video' ? (
|
||||
<video src={decryptedContent.mediaUrl} controls className="max-h-[400px] rounded" />
|
||||
) : (
|
||||
<img src={decryptedContent.mediaUrl} alt="Flagged content" className="max-h-[400px] rounded object-contain" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{!decryptedContent.text && !decryptedContent.mediaUrl && (
|
||||
<p className="text-center text-muted-foreground">No content displayed</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={() => setSelectedMessage(null)}>Cancel</Button>
|
||||
<Button
|
||||
className="bg-green-600 hover:bg-green-700"
|
||||
onClick={() => selectedMessage && handleMarkSafe(selectedMessage.id)}
|
||||
>
|
||||
Mark Safe
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useParams, Link } from 'react-router-dom';
|
||||
import { supabase } from '@/config';
|
||||
import { supabase, supabaseConfig } from '@/config';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
@@ -36,6 +37,10 @@ interface Message {
|
||||
created_at: string;
|
||||
read_at: string | null;
|
||||
media_viewed_at: string | null;
|
||||
version?: number | null;
|
||||
encrypted_content?: string | null;
|
||||
encryption_iv?: string | null;
|
||||
keys_metadata?: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
const matchTypeLabels = {
|
||||
@@ -55,44 +60,72 @@ function formatMessageTime(date: Date): string {
|
||||
|
||||
|
||||
|
||||
function ChatImage({ mediaPath }: { mediaPath: string }) {
|
||||
function ChatImage({ messageId }: { messageId: string }) {
|
||||
const { session, isSuperAdmin } = useAuth();
|
||||
const [url, setUrl] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!mediaPath) return;
|
||||
const getUrl = async () => {
|
||||
if (!messageId) return;
|
||||
|
||||
if (!isSuperAdmin) {
|
||||
setError('Decryption requires super admin');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!session?.access_token) {
|
||||
setError('Not authenticated');
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
let objectUrl: string | null = null;
|
||||
|
||||
const load = async () => {
|
||||
try {
|
||||
// Handle legacy full URLs
|
||||
let path = mediaPath;
|
||||
if (path.startsWith('http')) {
|
||||
const parts = path.split('/chat-media/');
|
||||
if (parts.length > 1) {
|
||||
path = decodeURIComponent(parts[1]);
|
||||
setError(null);
|
||||
setUrl(null);
|
||||
|
||||
const res = await fetch(`${supabaseConfig.url}/functions/v1/admin-decrypt-media`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
apikey: supabaseConfig.anonKey,
|
||||
Authorization: `Bearer ${session.access_token}`,
|
||||
},
|
||||
body: JSON.stringify({ messageId }),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const raw = await res.text().catch(() => '');
|
||||
let message = raw;
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as { error?: unknown };
|
||||
if (typeof parsed?.error === 'string') message = parsed.error;
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
throw new Error(message || `Failed to load media (${res.status})`);
|
||||
}
|
||||
|
||||
const blob = await res.blob();
|
||||
objectUrl = URL.createObjectURL(blob);
|
||||
|
||||
const { data, error } = await supabase.storage
|
||||
.from('chat-media')
|
||||
.createSignedUrl(path, 3600);
|
||||
|
||||
if (error) {
|
||||
console.error('Error creating signed URL:', error);
|
||||
setError(error.message);
|
||||
return;
|
||||
}
|
||||
|
||||
if (data?.signedUrl) {
|
||||
setUrl(data.signedUrl);
|
||||
}
|
||||
if (cancelled) return;
|
||||
setUrl(objectUrl);
|
||||
} catch (err) {
|
||||
console.error('Unexpected error loading image:', err);
|
||||
setError('Failed to load');
|
||||
if (cancelled) return;
|
||||
setError(err instanceof Error ? err.message : 'Failed to load');
|
||||
}
|
||||
};
|
||||
getUrl();
|
||||
}, [mediaPath]);
|
||||
|
||||
load();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (objectUrl) URL.revokeObjectURL(objectUrl);
|
||||
};
|
||||
}, [messageId, isSuperAdmin, session?.access_token]);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
@@ -118,43 +151,72 @@ function ChatImage({ mediaPath }: { mediaPath: string }) {
|
||||
}
|
||||
|
||||
|
||||
function ChatVideo({ mediaPath }: { mediaPath: string }) {
|
||||
function ChatVideo({ messageId }: { messageId: string }) {
|
||||
const { session, isSuperAdmin } = useAuth();
|
||||
const [url, setUrl] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!mediaPath) return;
|
||||
const getUrl = async () => {
|
||||
if (!messageId) return;
|
||||
|
||||
if (!isSuperAdmin) {
|
||||
setError('Decryption requires super admin');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!session?.access_token) {
|
||||
setError('Not authenticated');
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
let objectUrl: string | null = null;
|
||||
|
||||
const load = async () => {
|
||||
try {
|
||||
// Handle legacy full URLs
|
||||
let path = mediaPath;
|
||||
if (path.startsWith('http')) {
|
||||
const parts = path.split('/chat-media/');
|
||||
if (parts.length > 1) {
|
||||
path = decodeURIComponent(parts[1]);
|
||||
setError(null);
|
||||
setUrl(null);
|
||||
|
||||
const res = await fetch(`${supabaseConfig.url}/functions/v1/admin-decrypt-media`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
apikey: supabaseConfig.anonKey,
|
||||
Authorization: `Bearer ${session.access_token}`,
|
||||
},
|
||||
body: JSON.stringify({ messageId }),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const raw = await res.text().catch(() => '');
|
||||
let message = raw;
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as { error?: unknown };
|
||||
if (typeof parsed?.error === 'string') message = parsed.error;
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
throw new Error(message || `Failed to load media (${res.status})`);
|
||||
}
|
||||
|
||||
const { data, error } = await supabase.storage
|
||||
.from('chat-media')
|
||||
.createSignedUrl(path, 3600);
|
||||
const blob = await res.blob();
|
||||
objectUrl = URL.createObjectURL(blob);
|
||||
|
||||
if (error) {
|
||||
console.error('Error creating signed URL for video:', error);
|
||||
setError(error.message);
|
||||
return;
|
||||
}
|
||||
|
||||
if (data?.signedUrl) {
|
||||
setUrl(data.signedUrl);
|
||||
}
|
||||
if (cancelled) return;
|
||||
setUrl(objectUrl);
|
||||
} catch (err) {
|
||||
console.error('Unexpected error loading video:', err);
|
||||
setError('Failed to load');
|
||||
if (cancelled) return;
|
||||
setError(err instanceof Error ? err.message : 'Failed to load');
|
||||
}
|
||||
};
|
||||
getUrl();
|
||||
}, [mediaPath]);
|
||||
|
||||
load();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (objectUrl) URL.revokeObjectURL(objectUrl);
|
||||
};
|
||||
}, [messageId, isSuperAdmin, session?.access_token]);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
@@ -191,6 +253,77 @@ function ChatVideo({ mediaPath }: { mediaPath: string }) {
|
||||
}
|
||||
|
||||
|
||||
function DecryptedMessageText({ message }: { message: Message }) {
|
||||
const { isSuperAdmin } = useAuth();
|
||||
const [text, setText] = useState<string | null>(null);
|
||||
const [isDecrypting, setIsDecrypting] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const version = message.version ?? 1;
|
||||
|
||||
// v1: legacy plaintext
|
||||
if (version !== 2) {
|
||||
setText(message.content ?? null);
|
||||
setIsDecrypting(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// v2: encrypted
|
||||
if (!message.encrypted_content) {
|
||||
setText(null);
|
||||
setIsDecrypting(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isSuperAdmin) {
|
||||
setText('[Encrypted]');
|
||||
setIsDecrypting(false);
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
const decrypt = async () => {
|
||||
setIsDecrypting(true);
|
||||
try {
|
||||
const { data, error } = await supabase.functions.invoke('admin-decrypt-message', {
|
||||
body: { messageId: message.id },
|
||||
});
|
||||
|
||||
if (cancelled) return;
|
||||
|
||||
if (error) {
|
||||
setText('[Unable to decrypt]');
|
||||
return;
|
||||
}
|
||||
|
||||
setText((data as { content?: string | null } | null)?.content ?? null);
|
||||
} catch (err) {
|
||||
if (cancelled) return;
|
||||
setText('[Unable to decrypt]');
|
||||
} finally {
|
||||
if (!cancelled) setIsDecrypting(false);
|
||||
}
|
||||
};
|
||||
|
||||
decrypt();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [isSuperAdmin, message.content, message.encrypted_content, message.id, message.version]);
|
||||
|
||||
const shouldRender = Boolean(message.content || message.encrypted_content);
|
||||
if (!shouldRender) return null;
|
||||
|
||||
return (
|
||||
<p className="text-sm whitespace-pre-wrap">
|
||||
{isDecrypting ? 'Loading...' : (text ?? '')}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
export function MatchChatPage() {
|
||||
const { userId, matchId } = useParams<{ userId: string; matchId: string }>();
|
||||
const [user, setUser] = useState<Profile | null>(null);
|
||||
@@ -405,16 +538,14 @@ export function MatchChatPage() {
|
||||
: "bg-muted rounded-tl-sm"
|
||||
)}
|
||||
>
|
||||
{message.content && (
|
||||
<p className="text-sm whitespace-pre-wrap">{message.content}</p>
|
||||
)}
|
||||
<DecryptedMessageText message={message} />
|
||||
{message.media_path && (
|
||||
<div className="mt-2">
|
||||
<div className="relative group">
|
||||
{message.media_type === 'video' ? (
|
||||
<ChatVideo mediaPath={message.media_path} />
|
||||
<ChatVideo messageId={message.id} />
|
||||
) : (
|
||||
<ChatImage mediaPath={message.media_path} />
|
||||
<ChatImage messageId={message.id} />
|
||||
)}
|
||||
{message.media_viewed_at && (
|
||||
<Badge
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useParams, Link } from 'react-router-dom';
|
||||
import { supabase } from '@/config';
|
||||
import { supabase, supabaseConfig } from '@/config';
|
||||
import { auditedSupabase } from '@/hooks/useAuditedSupabase';
|
||||
import { useRealtimeSubscription } from '@/hooks/useRealtimeSubscription';
|
||||
import { RealtimeStatusIndicator } from '@/components/RealtimeStatusIndicator';
|
||||
@@ -37,12 +37,14 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Crown, Users, MessageCircle, ChevronRight, ThumbsUp, ThumbsDown, Minus, Gift, Image, HardDrive, ExternalLink, Target, User, Eye, EyeOff, CheckCircle } from 'lucide-react';
|
||||
import { Crown, Users, MessageCircle, ChevronRight, ThumbsUp, ThumbsDown, Minus, Gift, Image, Video as VideoIcon, Target, User, Eye, EyeOff, CheckCircle } from 'lucide-react';
|
||||
import { format } from 'date-fns';
|
||||
|
||||
type UsageReason = 'improve_communication' | 'spice_up_intimacy' | 'deeper_connection' | 'have_fun' | 'strengthen_relationship';
|
||||
type Gender = 'male' | 'female' | 'non-binary' | 'prefer-not-to-say';
|
||||
|
||||
// ... (interfaces and constants are the same)
|
||||
|
||||
interface Profile {
|
||||
id: string;
|
||||
name: string | null;
|
||||
@@ -102,18 +104,6 @@ interface Match {
|
||||
message_count?: number;
|
||||
}
|
||||
|
||||
interface MediaFile {
|
||||
id: string;
|
||||
name: string;
|
||||
bucket_id: string;
|
||||
created_at: string | null;
|
||||
metadata: {
|
||||
size: number;
|
||||
mimetype: string;
|
||||
};
|
||||
signedUrl?: string;
|
||||
}
|
||||
|
||||
const answerIcons = {
|
||||
yes: <ThumbsUp className="h-4 w-4 text-green-500" />,
|
||||
no: <ThumbsDown className="h-4 w-4 text-red-500" />,
|
||||
@@ -126,12 +116,117 @@ const matchTypeLabels = {
|
||||
maybe_maybe: 'Both Maybe',
|
||||
};
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes === 0) return '0 B';
|
||||
const k = 1024;
|
||||
const sizes = ['B', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
|
||||
|
||||
function AdminDecryptedImage({ messageId, alt }: { messageId: string; alt: string }) {
|
||||
const { session, isSuperAdmin } = useAuth();
|
||||
const [url, setUrl] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!messageId) return;
|
||||
if (!isSuperAdmin) {
|
||||
setError("Super admin required");
|
||||
return;
|
||||
}
|
||||
if (!session?.access_token) {
|
||||
setError("Not authenticated");
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
let objectUrl: string | null = null;
|
||||
|
||||
const load = async () => {
|
||||
try {
|
||||
setError(null);
|
||||
const res = await fetch(`${supabaseConfig.url}/functions/v1/admin-decrypt-media`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', apikey: supabaseConfig.anonKey, Authorization: `Bearer ${session.access_token}` },
|
||||
body: JSON.stringify({ messageId }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const message = (await res.json().catch(() => ({})))?.error || `Failed to load media (${res.status})`;
|
||||
throw new Error(message);
|
||||
}
|
||||
const blob = await res.blob();
|
||||
objectUrl = URL.createObjectURL(blob);
|
||||
if (!cancelled) setUrl(objectUrl);
|
||||
} catch (err) {
|
||||
if (!cancelled) setError(err instanceof Error ? err.message : 'Failed to load');
|
||||
}
|
||||
};
|
||||
|
||||
load();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (objectUrl) URL.revokeObjectURL(objectUrl);
|
||||
};
|
||||
}, [isSuperAdmin, messageId, session?.access_token]);
|
||||
|
||||
if (error) {
|
||||
return <div className="w-full h-full flex items-center justify-center text-center p-2"><Image className="h-8 w-8 text-muted-foreground mx-auto" /><div className="text-[10px] text-red-500 mt-1 line-clamp-2" title={error}>{error}</div></div>;
|
||||
}
|
||||
if (!url) {
|
||||
return <div className="w-full h-full flex items-center justify-center"><Skeleton className="h-16 w-16 rounded-md" /></div>;
|
||||
}
|
||||
return <img src={url} alt={alt} className="w-full h-full object-cover cursor-pointer" onClick={() => window.open(url, '_blank')} />;
|
||||
}
|
||||
|
||||
function AdminDecryptedVideo({ messageId }: { messageId: string }) {
|
||||
const { session, isSuperAdmin } = useAuth();
|
||||
const [url, setUrl] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!messageId) return;
|
||||
if (!isSuperAdmin) {
|
||||
setError("Super admin required");
|
||||
return;
|
||||
}
|
||||
if (!session?.access_token) {
|
||||
setError("Not authenticated");
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
let objectUrl: string | null = null;
|
||||
|
||||
const load = async () => {
|
||||
try {
|
||||
setError(null);
|
||||
const res = await fetch(`${supabaseConfig.url}/functions/v1/admin-decrypt-media`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', apikey: supabaseConfig.anonKey, Authorization: `Bearer ${session.access_token}` },
|
||||
body: JSON.stringify({ messageId }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const message = (await res.json().catch(() => ({})))?.error || `Failed to load media (${res.status})`;
|
||||
throw new Error(message);
|
||||
}
|
||||
const blob = await res.blob();
|
||||
objectUrl = URL.createObjectURL(blob);
|
||||
if (!cancelled) setUrl(objectUrl);
|
||||
} catch (err) {
|
||||
if (!cancelled) setError(err instanceof Error ? err.message : 'Failed to load');
|
||||
}
|
||||
};
|
||||
|
||||
load();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (objectUrl) URL.revokeObjectURL(objectUrl);
|
||||
};
|
||||
}, [isSuperAdmin, messageId, session?.access_token]);
|
||||
|
||||
if (error) {
|
||||
return <div className="w-full h-full flex items-center justify-center text-center p-2"><VideoIcon className="h-8 w-8 text-muted-foreground mx-auto" /><div className="text-[10px] text-red-500 mt-1 line-clamp-2" title={error}>{error}</div></div>;
|
||||
}
|
||||
if (!url) {
|
||||
return <div className="w-full h-full flex items-center justify-center"><Skeleton className="h-16 w-16 rounded-md" /></div>;
|
||||
}
|
||||
return <video src={url} controls className="w-full h-full object-cover" preload="metadata" />;
|
||||
}
|
||||
|
||||
export function UserDetailPage() {
|
||||
@@ -141,8 +236,7 @@ export function UserDetailPage() {
|
||||
const [partner, setPartner] = useState<Partner | null>(null);
|
||||
const [responses, setResponses] = useState<Response[]>([]);
|
||||
const [matches, setMatches] = useState<Match[]>([]);
|
||||
const [media, setMedia] = useState<MediaFile[]>([]);
|
||||
const [totalStorage, setTotalStorage] = useState(0);
|
||||
const [mediaMessages, setMediaMessages] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const canViewResponses = hasPermission(PERMISSION_KEYS.VIEW_RESPONSES);
|
||||
@@ -158,11 +252,9 @@ export function UserDetailPage() {
|
||||
const handleUpgrade = async () => {
|
||||
if (!profile || !userId) return;
|
||||
setUpgrading(true);
|
||||
|
||||
try {
|
||||
let expiresAt = null;
|
||||
const now = new Date();
|
||||
|
||||
if (expiryType === '1_month') {
|
||||
const date = new Date(now);
|
||||
date.setMonth(date.getMonth() + 1);
|
||||
@@ -172,7 +264,6 @@ export function UserDetailPage() {
|
||||
date.setFullYear(date.getFullYear() + 1);
|
||||
expiresAt = date.toISOString();
|
||||
} else if (expiryType === 'custom' && customDate) {
|
||||
// Ensure custom date is in the future
|
||||
const date = new Date(customDate);
|
||||
if (date <= now) {
|
||||
alert('Expiry date must be in the future');
|
||||
@@ -181,7 +272,6 @@ export function UserDetailPage() {
|
||||
}
|
||||
expiresAt = date.toISOString();
|
||||
}
|
||||
|
||||
const { error } = await auditedSupabase.insert('subscriptions', {
|
||||
user_id: userId,
|
||||
revenuecat_app_user_id: 'admin_grant',
|
||||
@@ -191,24 +281,10 @@ export function UserDetailPage() {
|
||||
purchased_at: new Date().toISOString(),
|
||||
expires_at: expiresAt,
|
||||
});
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
// Refresh profile to get updated is_premium status
|
||||
// The trigger sync_premium_on_subscription_change will update the profile
|
||||
// We might need a small delay or just wait for the select
|
||||
const { data: updatedProfile } = await supabase
|
||||
.from('profiles')
|
||||
.select('*')
|
||||
.eq('id', userId)
|
||||
.single();
|
||||
|
||||
if (updatedProfile) {
|
||||
setProfile(updatedProfile);
|
||||
}
|
||||
|
||||
const { data: updatedProfile } = await supabase.from('profiles').select('*').eq('id', userId).single();
|
||||
if (updatedProfile) setProfile(updatedProfile);
|
||||
setUpgradeOpen(false);
|
||||
// Optionally add toast here if available in context
|
||||
alert('User upgraded to premium successfully!');
|
||||
} catch (err) {
|
||||
console.error('Failed to upgrade:', err);
|
||||
@@ -220,101 +296,43 @@ export function UserDetailPage() {
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
if (!userId) return;
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
// Fetch profile
|
||||
const { data: profileData } = await supabase
|
||||
.from('profiles')
|
||||
.select('*')
|
||||
.eq('id', userId)
|
||||
.single();
|
||||
|
||||
const { data: profileData } = await supabase.from('profiles').select('*').eq('id', userId).single();
|
||||
setProfile(profileData);
|
||||
|
||||
// Fetch partner if coupled
|
||||
if (profileData?.couple_id) {
|
||||
const { data: partnerData } = await supabase
|
||||
.from('profiles')
|
||||
.select('id, name')
|
||||
.eq('couple_id', profileData.couple_id)
|
||||
.neq('id', userId)
|
||||
.single();
|
||||
|
||||
const { data: partnerData } = await supabase.from('profiles').select('id, name').eq('couple_id', profileData.couple_id).neq('id', userId).single();
|
||||
setPartner(partnerData);
|
||||
} else {
|
||||
setPartner(null);
|
||||
}
|
||||
|
||||
// Fetch responses with question and pack info
|
||||
const { data: responseData } = await supabase
|
||||
.from('responses')
|
||||
.select(`
|
||||
id, answer, created_at,
|
||||
question:questions (
|
||||
id, text,
|
||||
pack:question_packs (name)
|
||||
)
|
||||
`)
|
||||
.eq('user_id', userId)
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(50);
|
||||
|
||||
const { data: responseData } = await supabase.from('responses').select('id, answer, created_at, question:questions(id, text, pack:question_packs(name))').eq('user_id', userId).order('created_at', { ascending: false }).limit(50);
|
||||
setResponses((responseData || []) as unknown as Response[]);
|
||||
|
||||
// Fetch matches if coupled
|
||||
if (profileData?.couple_id) {
|
||||
const { data: matchData } = await supabase
|
||||
.from('matches')
|
||||
.select(`
|
||||
id, match_type, is_new, created_at,
|
||||
question:questions (id, text)
|
||||
`)
|
||||
.eq('couple_id', profileData.couple_id)
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(50);
|
||||
|
||||
// Get message counts for each match
|
||||
const { data: matchData } = await supabase.from('matches').select('id, match_type, is_new, created_at, question:questions(id, text)').eq('couple_id', profileData.couple_id).order('created_at', { ascending: false }).limit(50);
|
||||
const matchIds = (matchData || []).map(m => m.id);
|
||||
const { data: messages } = await supabase
|
||||
.from('messages')
|
||||
.select('match_id')
|
||||
.in('match_id', matchIds);
|
||||
|
||||
const messageCounts: Record<string, number> = {};
|
||||
messages?.forEach(m => {
|
||||
messageCounts[m.match_id] = (messageCounts[m.match_id] || 0) + 1;
|
||||
});
|
||||
|
||||
setMatches(
|
||||
(matchData || []).map(m => ({
|
||||
...m,
|
||||
message_count: messageCounts[m.id] || 0,
|
||||
})) as unknown as Match[]
|
||||
);
|
||||
}
|
||||
|
||||
// Fetch media files uploaded by this user
|
||||
const { data: mediaData } = await supabase
|
||||
.rpc('get_user_media_files', { user_id: userId });
|
||||
|
||||
if (mediaData && mediaData.length > 0) {
|
||||
// Calculate total storage
|
||||
const total = mediaData.reduce((sum: number, file: MediaFile) =>
|
||||
sum + (file.metadata?.size || 0), 0);
|
||||
setTotalStorage(total);
|
||||
|
||||
// Get signed URLs for each file
|
||||
const mediaWithUrls = await Promise.all(
|
||||
mediaData.map(async (file: MediaFile) => {
|
||||
const { data: signedData } = await supabase.storage
|
||||
.from('chat-media')
|
||||
.createSignedUrl(file.name, 3600); // 1 hour expiry
|
||||
return {
|
||||
...file,
|
||||
signedUrl: signedData?.signedUrl,
|
||||
};
|
||||
})
|
||||
);
|
||||
setMedia(mediaWithUrls);
|
||||
if (matchIds.length > 0) {
|
||||
const { data: messages } = await supabase.from('messages').select('match_id').in('match_id', matchIds);
|
||||
const messageCounts: Record<string, number> = {};
|
||||
messages?.forEach(m => { messageCounts[m.match_id] = (messageCounts[m.match_id] || 0) + 1; });
|
||||
setMatches((matchData || []).map(m => ({ ...m, message_count: messageCounts[m.id] || 0 })) as unknown as Match[]);
|
||||
const { data: mediaMessagesData, error: mediaError } = await supabase.from('messages').select('id, user_id, media_path, media_type, version, created_at, keys_metadata, match_id').in('match_id', matchIds).not('media_path', 'is', null).order('created_at', { ascending: false });
|
||||
if (mediaError) {
|
||||
console.error("Error fetching media messages:", mediaError);
|
||||
setMediaMessages([]);
|
||||
} else {
|
||||
setMediaMessages(mediaMessagesData || []);
|
||||
}
|
||||
} else {
|
||||
setMatches([]);
|
||||
setMediaMessages([]);
|
||||
}
|
||||
} else {
|
||||
setMatches([]);
|
||||
setMediaMessages([]);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load user data:', error);
|
||||
@@ -323,431 +341,56 @@ export function UserDetailPage() {
|
||||
}
|
||||
}, [userId]);
|
||||
|
||||
// Real-time subscription for this user's profile
|
||||
const { status: profileStatus } = useRealtimeSubscription<Profile>({
|
||||
table: 'profiles',
|
||||
filter: userId ? `id=eq.${userId}` : undefined,
|
||||
enabled: !!userId,
|
||||
onUpdate: useCallback(({ new: updated }: { old: Profile; new: Profile }) => {
|
||||
setProfile(updated);
|
||||
}, []),
|
||||
});
|
||||
const { status: profileStatus } = useRealtimeSubscription<Profile>({ table: 'profiles', filter: userId ? `id=eq.${userId}` : undefined, enabled: !!userId, onUpdate: useCallback(({ new: updated }: { old: Profile; new: Profile }) => { setProfile(updated); }, []) });
|
||||
const { status: responsesStatus } = useRealtimeSubscription<Response>({ table: 'responses', filter: userId ? `user_id=eq.${userId}` : undefined, enabled: !!userId, onInsert: fetchData, onDelete: fetchData });
|
||||
|
||||
// Real-time subscription for this user's responses
|
||||
const { status: responsesStatus } = useRealtimeSubscription<Response>({
|
||||
table: 'responses',
|
||||
filter: userId ? `user_id=eq.${userId}` : undefined,
|
||||
enabled: !!userId,
|
||||
onInsert: fetchData, // Refetch to get question/pack relations
|
||||
onDelete: fetchData,
|
||||
});
|
||||
useEffect(() => { fetchData(); }, [fetchData]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, [fetchData]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Skeleton className="h-32" />
|
||||
<Skeleton className="h-96" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!profile) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-12">
|
||||
<p className="text-lg font-medium">User not found</p>
|
||||
<Link to="/users">
|
||||
<Button variant="link">Back to users</Button>
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (loading) return <div className="space-y-6"><Skeleton className="h-32" /><Skeleton className="h-96" /></div>;
|
||||
if (!profile) return <div className="flex flex-col items-center justify-center py-12"><p className="text-lg font-medium">User not found</p><Link to="/users"><Button variant="link">Back to users</Button></Link></div>;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Profile Header */}
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-start gap-6">
|
||||
<Avatar className="h-20 w-20">
|
||||
<AvatarImage src={profile.avatar_url || undefined} />
|
||||
<AvatarFallback className="text-2xl">
|
||||
{profile.name?.charAt(0).toUpperCase() || 'U'}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<h1 className="text-2xl font-bold">
|
||||
{profile.name || 'Unnamed User'}
|
||||
</h1>
|
||||
<RealtimeStatusIndicator
|
||||
status={profileStatus === 'SUBSCRIBED' && responsesStatus === 'SUBSCRIBED' ? 'SUBSCRIBED' : profileStatus}
|
||||
showLabel
|
||||
/>
|
||||
{profile.is_premium && (
|
||||
<Badge className="bg-amber-500">
|
||||
<Crown className="h-3 w-3 mr-1" />
|
||||
Premium
|
||||
</Badge>
|
||||
|
||||
)}
|
||||
{!profile.is_premium && (
|
||||
<Dialog open={upgradeOpen} onOpenChange={setUpgradeOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button size="sm" variant="outline" className="ml-2 gap-1 h-7">
|
||||
<Gift className="h-3.5 w-3.5" />
|
||||
Gift Premium
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Gift Premium Access</DialogTitle>
|
||||
<DialogDescription>
|
||||
Manually upgrade this user to premium status.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="expiry-type">Duration</Label>
|
||||
<Select value={expiryType} onValueChange={setExpiryType}>
|
||||
<SelectTrigger id="expiry-type">
|
||||
<SelectValue placeholder="Select duration" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="forever">Lifetime (No Expiry)</SelectItem>
|
||||
<SelectItem value="1_month">1 Month</SelectItem>
|
||||
<SelectItem value="1_year">1 Year</SelectItem>
|
||||
<SelectItem value="custom">Custom Date</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{expiryType === 'custom' && (
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="custom-date">Expiry Date</Label>
|
||||
<Input
|
||||
id="custom-date"
|
||||
type="datetime-local"
|
||||
value={customDate}
|
||||
onChange={(e) => setCustomDate(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setUpgradeOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleUpgrade} disabled={upgrading}>
|
||||
{upgrading ? 'Upgrading...' : 'Confirm Upgrade'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="text-muted-foreground mb-4">
|
||||
{profile.email || 'No email'}
|
||||
</p>
|
||||
|
||||
<div className="flex gap-6 text-sm">
|
||||
<div>
|
||||
<span className="text-muted-foreground">Joined: </span>
|
||||
<span>
|
||||
{profile.created_at
|
||||
? format(new Date(profile.created_at), 'MMMM d, yyyy')
|
||||
: '—'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{partner ? (
|
||||
<div className="flex items-center gap-1">
|
||||
<Users className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="text-muted-foreground">Paired with: </span>
|
||||
<Link to={`/users/${partner.id}`} className="text-primary hover:underline">
|
||||
{partner.name || 'Unnamed Partner'}
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-muted-foreground">Not paired</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Card><CardContent className="pt-6"><div className="flex items-start gap-6">
|
||||
<Avatar className="h-20 w-20"><AvatarImage src={profile.avatar_url || undefined} /><AvatarFallback className="text-2xl">{profile.name?.charAt(0).toUpperCase() || 'U'}</AvatarFallback></Avatar>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<h1 className="text-2xl font-bold">{profile.name || 'Unnamed User'}</h1>
|
||||
<RealtimeStatusIndicator status={profileStatus === 'SUBSCRIBED' && responsesStatus === 'SUBSCRIBED' ? 'SUBSCRIBED' : profileStatus} showLabel />
|
||||
{profile.is_premium && <Badge className="bg-amber-500"><Crown className="h-3 w-3 mr-1" />Premium</Badge>}
|
||||
{!profile.is_premium && <Dialog open={upgradeOpen} onOpenChange={setUpgradeOpen}><DialogTrigger asChild><Button size="sm" variant="outline" className="ml-2 gap-1 h-7"><Gift className="h-3.5 w-3.5" />Gift Premium</Button></DialogTrigger><DialogContent><DialogHeader><DialogTitle>Gift Premium Access</DialogTitle><DialogDescription>Manually upgrade this user to premium status.</DialogDescription></DialogHeader><div className="grid gap-4 py-4"><div className="grid gap-2"><Label htmlFor="expiry-type">Duration</Label><Select value={expiryType} onValueChange={setExpiryType}><SelectTrigger id="expiry-type"><SelectValue placeholder="Select duration" /></SelectTrigger><SelectContent><SelectItem value="forever">Lifetime (No Expiry)</SelectItem><SelectItem value="1_month">1 Month</SelectItem><SelectItem value="1_year">1 Year</SelectItem><SelectItem value="custom">Custom Date</SelectItem></SelectContent></Select></div>{expiryType === 'custom' && (<div className="grid gap-2"><Label htmlFor="custom-date">Expiry Date</Label><Input id="custom-date" type="datetime-local" value={customDate} onChange={(e) => setCustomDate(e.target.value)} /></div>)}</div><DialogFooter><Button variant="outline" onClick={() => setUpgradeOpen(false)}>Cancel</Button><Button onClick={handleUpgrade} disabled={upgrading}>{upgrading ? 'Upgrading...' : 'Confirm Upgrade'}</Button></DialogFooter></DialogContent></Dialog>}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<p className="text-muted-foreground mb-4">{profile.email || 'No email'}</p>
|
||||
<div className="flex gap-6 text-sm">
|
||||
<div><span className="text-muted-foreground">Joined: </span><span>{profile.created_at ? format(new Date(profile.created_at), 'MMMM d, yyyy') : '—'}</span></div>
|
||||
{partner ? <div className="flex items-center gap-1"><Users className="h-4 w-4 text-muted-foreground" /><span className="text-muted-foreground">Paired with: </span><Link to={`/users/${partner.id}`} className="text-primary hover:underline">{partner.name || 'Unnamed Partner'}</Link></div> : <div className="text-muted-foreground">Not paired</div>}
|
||||
</div>
|
||||
</div>
|
||||
</div></CardContent></Card>
|
||||
{/* Onboarding Details */}
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
<CheckCircle className="h-5 w-5 text-primary" />
|
||||
Onboarding Details
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Information collected during user onboarding
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
{/* Gender */}
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="p-2 rounded-lg bg-blue-100 dark:bg-blue-900">
|
||||
<User className="h-4 w-4 text-blue-600 dark:text-blue-400" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground">Gender</p>
|
||||
<p className="font-medium">
|
||||
{profile.gender ? genderLabels[profile.gender] : 'Not set'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Usage Reason */}
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="p-2 rounded-lg bg-purple-100 dark:bg-purple-900">
|
||||
<Target className="h-4 w-4 text-purple-600 dark:text-purple-400" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground">Why using Sauci</p>
|
||||
<p className="font-medium">
|
||||
{profile.usage_reason
|
||||
? usageReasonLabels[profile.usage_reason]?.label
|
||||
: 'Not set'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Explicit Content */}
|
||||
<div className="flex items-start gap-3">
|
||||
<div className={`p-2 rounded-lg ${profile.show_explicit_content ? 'bg-amber-100 dark:bg-amber-900' : 'bg-gray-100 dark:bg-gray-800'}`}>
|
||||
{profile.show_explicit_content ? (
|
||||
<Eye className="h-4 w-4 text-amber-600 dark:text-amber-400" />
|
||||
) : (
|
||||
<EyeOff className="h-4 w-4 text-gray-500 dark:text-gray-400" />
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground">Explicit Content</p>
|
||||
<p className="font-medium">
|
||||
{profile.show_explicit_content === null
|
||||
? 'Not set'
|
||||
: profile.show_explicit_content
|
||||
? 'Enabled'
|
||||
: 'Disabled'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Onboarding Status */}
|
||||
<div className="flex items-start gap-3">
|
||||
<div className={`p-2 rounded-lg ${profile.onboarding_completed ? 'bg-green-100 dark:bg-green-900' : 'bg-red-100 dark:bg-red-900'}`}>
|
||||
<CheckCircle className={`h-4 w-4 ${profile.onboarding_completed ? 'text-green-600 dark:text-green-400' : 'text-red-500 dark:text-red-400'}`} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground">Onboarding</p>
|
||||
<p className="font-medium">
|
||||
{profile.onboarding_completed ? 'Completed' : 'Not completed'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card><CardHeader className="pb-3"><CardTitle className="text-lg flex items-center gap-2"><CheckCircle className="h-5 w-5 text-primary" />Onboarding Details</CardTitle><CardDescription>Information collected during user onboarding</CardDescription></CardHeader><CardContent><div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
<div className="flex items-start gap-3"><div className="p-2 rounded-lg bg-blue-100 dark:bg-blue-900"><User className="h-4 w-4 text-blue-600 dark:text-blue-400" /></div><div><p className="text-sm font-medium text-muted-foreground">Gender</p><p className="font-medium">{profile.gender ? genderLabels[profile.gender] : 'Not set'}</p></div></div>
|
||||
<div className="flex items-start gap-3"><div className="p-2 rounded-lg bg-purple-100 dark:bg-purple-900"><Target className="h-4 w-4 text-purple-600 dark:text-purple-400" /></div><div><p className="text-sm font-medium text-muted-foreground">Why using Sauci</p><p className="font-medium">{profile.usage_reason ? usageReasonLabels[profile.usage_reason]?.label : 'Not set'}</p></div></div>
|
||||
<div className="flex items-start gap-3"><div className={`p-2 rounded-lg ${profile.show_explicit_content ? 'bg-amber-100 dark:bg-amber-900' : 'bg-gray-100 dark:bg-gray-800'}`}>{profile.show_explicit_content ? <Eye className="h-4 w-4 text-amber-600 dark:text-amber-400" /> : <EyeOff className="h-4 w-4 text-gray-500 dark:text-gray-400" />}</div><div><p className="text-sm font-medium text-muted-foreground">Explicit Content</p><p className="font-medium">{profile.show_explicit_content === null ? 'Not set' : profile.show_explicit_content ? 'Enabled' : 'Disabled'}</p></div></div>
|
||||
<div className="flex items-start gap-3"><div className={`p-2 rounded-lg ${profile.onboarding_completed ? 'bg-green-100 dark:bg-green-900' : 'bg-red-100 dark:bg-red-900'}`}><CheckCircle className={`h-4 w-4 ${profile.onboarding_completed ? 'text-green-600 dark:text-green-400' : 'text-red-500 dark:text-red-400'}`} /></div><div><p className="text-sm font-medium text-muted-foreground">Onboarding</p><p className="font-medium">{profile.onboarding_completed ? 'Completed' : 'Not completed'}</p></div></div>
|
||||
</div></CardContent></Card>
|
||||
{/* Tabs */}
|
||||
<Tabs defaultValue={canViewResponses ? "responses" : canViewMatches ? "matches" : canViewMedia ? "media" : "responses"}>
|
||||
<TabsList>
|
||||
{canViewResponses && (
|
||||
<TabsTrigger value="responses">
|
||||
Responses ({responses.length})
|
||||
</TabsTrigger>
|
||||
)}
|
||||
{canViewMatches && (
|
||||
<TabsTrigger value="matches" disabled={!profile.couple_id}>
|
||||
Matches ({matches.length})
|
||||
</TabsTrigger>
|
||||
)}
|
||||
{canViewMedia && (
|
||||
<TabsTrigger value="media">
|
||||
Media ({media.length})
|
||||
</TabsTrigger>
|
||||
)}
|
||||
{canViewResponses && <TabsTrigger value="responses">Responses ({responses.length})</TabsTrigger>}
|
||||
{canViewMatches && <TabsTrigger value="matches" disabled={!profile.couple_id}>Matches ({matches.length})</TabsTrigger>}
|
||||
{canViewMedia && <TabsTrigger value="media">Media ({mediaMessages.length})</TabsTrigger>}
|
||||
</TabsList>
|
||||
|
||||
{canViewResponses && (
|
||||
<TabsContent value="responses" className="mt-4">
|
||||
{responses.length === 0 ? (
|
||||
<Card className="flex flex-col items-center justify-center py-12">
|
||||
<p className="text-muted-foreground">No responses yet</p>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Question</TableHead>
|
||||
<TableHead>Pack</TableHead>
|
||||
<TableHead className="w-24">Answer</TableHead>
|
||||
<TableHead className="w-32">Date</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{responses.map((r) => (
|
||||
<TableRow key={r.id}>
|
||||
<TableCell className="max-w-md">
|
||||
<span className="line-clamp-2">{r.question.text}</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground">
|
||||
{r.question.pack?.name || '—'}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
{answerIcons[r.answer]}
|
||||
<span className="capitalize">{r.answer}</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground text-sm">
|
||||
{r.created_at
|
||||
? format(new Date(r.created_at), 'MMM d, yyyy')
|
||||
: '—'}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
)}
|
||||
|
||||
{canViewMatches && (
|
||||
<TabsContent value="matches" className="mt-4">
|
||||
{matches.length === 0 ? (
|
||||
<Card className="flex flex-col items-center justify-center py-12">
|
||||
<p className="text-muted-foreground">No matches yet</p>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
{matches.map((match) => (
|
||||
<Card key={match.id} className="hover:shadow-md transition-shadow">
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-start justify-between">
|
||||
<Badge variant="secondary">
|
||||
{matchTypeLabels[match.match_type]}
|
||||
</Badge>
|
||||
{match.is_new && (
|
||||
<Badge variant="default" className="text-xs">New</Badge>
|
||||
)}
|
||||
</div>
|
||||
<CardDescription className="line-clamp-2 mt-2">
|
||||
{match.question?.text || 'Unknown question'}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-1 text-sm text-muted-foreground">
|
||||
<MessageCircle className="h-4 w-4" />
|
||||
{match.message_count} message{match.message_count !== 1 ? 's' : ''}
|
||||
</div>
|
||||
{canViewChats && (
|
||||
<Link to={`/users/${userId}/matches/${match.id}`}>
|
||||
<Button variant="ghost" size="sm">
|
||||
View Chat
|
||||
<ChevronRight className="ml-1 h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
)}
|
||||
|
||||
{canViewMedia && (
|
||||
<TabsContent value="media" className="mt-4">
|
||||
{media.length === 0 ? (
|
||||
<Card className="flex flex-col items-center justify-center py-12">
|
||||
<Image className="h-12 w-12 text-muted-foreground mb-4" />
|
||||
<p className="text-muted-foreground">No media uploaded</p>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{/* Storage Summary */}
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<HardDrive className="h-5 w-5 text-muted-foreground" />
|
||||
<div>
|
||||
<p className="font-medium">Total Storage Used</p>
|
||||
<p className="text-2xl font-bold">{formatBytes(totalStorage)}</p>
|
||||
</div>
|
||||
<div className="ml-auto text-sm text-muted-foreground">
|
||||
{media.length} file{media.length !== 1 ? 's' : ''}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Media Grid */}
|
||||
<div className="grid gap-4 grid-cols-2 md:grid-cols-3 lg:grid-cols-4">
|
||||
{media.map((file) => (
|
||||
<Card key={file.id} className="overflow-hidden">
|
||||
<div className="aspect-square bg-muted relative">
|
||||
{file.signedUrl ? (
|
||||
<img
|
||||
src={file.signedUrl}
|
||||
alt={file.name}
|
||||
className="w-full h-full object-cover"
|
||||
onError={(e) => {
|
||||
(e.target as HTMLImageElement).style.display = 'none';
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center">
|
||||
<Image className="h-8 w-8 text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<CardContent className="p-3">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<div className="truncate text-muted-foreground">
|
||||
{formatBytes(file.metadata?.size || 0)}
|
||||
</div>
|
||||
{file.signedUrl && (
|
||||
<a
|
||||
href={file.signedUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:underline flex items-center gap-1"
|
||||
>
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground mt-1">
|
||||
{file.created_at
|
||||
? format(new Date(file.created_at), 'MMM d, yyyy')
|
||||
: '—'}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
)}
|
||||
{canViewResponses && <TabsContent value="responses" className="mt-4">{responses.length === 0 ? <Card className="flex flex-col items-center justify-center py-12"><p className="text-muted-foreground">No responses yet</p></Card> : <div className="rounded-md border"><Table><TableHeader><TableRow><TableHead>Question</TableHead><TableHead>Pack</TableHead><TableHead className="w-24">Answer</TableHead><TableHead className="w-32">Date</TableHead></TableRow></TableHeader><TableBody>{responses.map((r) => (<TableRow key={r.id}><TableCell className="max-w-md"><span className="line-clamp-2">{r.question.text}</span></TableCell><TableCell className="text-muted-foreground">{r.question.pack?.name || '—'}</TableCell><TableCell><div className="flex items-center gap-2">{answerIcons[r.answer]}<span className="capitalize">{r.answer}</span></div></TableCell><TableCell className="text-muted-foreground text-sm">{r.created_at ? format(new Date(r.created_at), 'MMM d, yyyy') : '—'}</TableCell></TableRow>))}</TableBody></Table></div>}</TabsContent>}
|
||||
{canViewMatches && <TabsContent value="matches" className="mt-4">{matches.length === 0 ? <Card className="flex flex-col items-center justify-center py-12"><p className="text-muted-foreground">No matches yet</p></Card> : <div className="grid gap-4 md:grid-cols-2">{matches.map((match) => (<Card key={match.id} className="hover:shadow-md transition-shadow"><CardHeader className="pb-2"><div className="flex items-start justify-between"><Badge variant="secondary">{matchTypeLabels[match.match_type]}</Badge>{match.is_new && <Badge variant="default" className="text-xs">New</Badge>}</div><CardDescription className="line-clamp-2 mt-2">{match.question?.text || 'Unknown question'}</CardDescription></CardHeader><CardContent><div className="flex items-center justify-between"><div className="flex items-center gap-1 text-sm text-muted-foreground"><MessageCircle className="h-4 w-4" />{match.message_count} message{match.message_count !== 1 ? 's' : ''}</div>{canViewChats && <Link to={`/users/${userId}/matches/${match.id}`}><Button variant="ghost" size="sm">View Chat<ChevronRight className="ml-1 h-4 w-4" /></Button></Link>}</div></CardContent></Card>))}</div>}</TabsContent>}
|
||||
{canViewMedia && <TabsContent value="media" className="mt-4">{mediaMessages.length === 0 ? <Card className="flex flex-col items-center justify-center py-12"><Image className="h-12 w-12 text-muted-foreground mb-4" /><p className="text-muted-foreground">No media uploaded</p></Card> : <div className="space-y-4"><div className="grid gap-4 grid-cols-2 md:grid-cols-3 lg:grid-cols-4">{mediaMessages.map((message) => (<Card key={message.id} className="overflow-hidden"><div className="aspect-square bg-muted relative">
|
||||
{message.media_type === 'video' ? (
|
||||
<AdminDecryptedVideo messageId={message.id} />
|
||||
) : (
|
||||
<AdminDecryptedImage messageId={message.id} alt={`Media from ${message.created_at}`} />
|
||||
)}
|
||||
</div><CardContent className="p-3"><div className="flex items-center justify-between text-sm"><div className="truncate text-muted-foreground">{message.media_type === 'video' ? 'Video' : 'Image'}</div>{canViewChats && message.match_id && <Link to={`/users/${userId}/matches/${message.match_id}`} className="text-primary hover:underline text-xs">View Chat</Link>}</div><div className="text-xs text-muted-foreground mt-1">{message.created_at ? format(new Date(message.created_at), 'MMM d, yyyy') : '—'}</div></CardContent></Card>))}</div></div>}</TabsContent>}
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -10,7 +10,6 @@
|
||||
<uses-permission android:name="android.permission.VIBRATE"/>
|
||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>
|
||||
<uses-permission android:name="com.android.vending.BILLING"/>
|
||||
<uses-permission android:name="com.android.vending.BILLING"/>
|
||||
<queries>
|
||||
<intent>
|
||||
<action android:name="android.intent.action.VIEW"/>
|
||||
|
||||
@@ -5,6 +5,8 @@ import { BlurView } from "expo-blur";
|
||||
import { LinearGradient } from "expo-linear-gradient";
|
||||
import { useEffect, useState, useCallback, useRef } from "react";
|
||||
import { useAuthStore, useMatchStore, useMessageStore, useSubscriptionStore, usePacksStore } from "../../src/store";
|
||||
import { useDecryptedMessage } from "../../src/hooks";
|
||||
import type { KeysMetadata } from "../../src/lib/encryption";
|
||||
import { colors, gradients, blur, radius, spacing, typography, shadows } from "../../src/theme";
|
||||
import { supabase } from "../../src/lib/supabase";
|
||||
import { isBiometricEnabled } from "../../src/lib/biometricAuth";
|
||||
@@ -16,6 +18,61 @@ import type { Database } from "../../src/types/supabase";
|
||||
|
||||
type Message = Database["public"]["Tables"]["messages"]["Row"];
|
||||
|
||||
function MessageToastPreview({
|
||||
message,
|
||||
currentUserId,
|
||||
}: {
|
||||
message: Message;
|
||||
currentUserId: string;
|
||||
}) {
|
||||
if (message.media_path) {
|
||||
const fallback = message.media_type === 'video' ? 'Sent a video' : 'Sent an image';
|
||||
return (
|
||||
<Text style={styles.messageToastBody} numberOfLines={1}>
|
||||
{fallback}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
const version = message.version ?? 1;
|
||||
const isV2 = version === 2;
|
||||
|
||||
const { content, isDecrypting, error } = useDecryptedMessage({
|
||||
message: {
|
||||
id: message.id,
|
||||
content: message.content,
|
||||
version,
|
||||
encrypted_content: message.encrypted_content,
|
||||
encryption_iv: message.encryption_iv,
|
||||
keys_metadata: message.keys_metadata as unknown as KeysMetadata | null,
|
||||
user_id: message.user_id,
|
||||
},
|
||||
currentUserId,
|
||||
});
|
||||
|
||||
if (isDecrypting) {
|
||||
return (
|
||||
<Text style={styles.messageToastBody} numberOfLines={1}>
|
||||
Loading...
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Text style={styles.messageToastBody} numberOfLines={1}>
|
||||
Failed to load message
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Text style={styles.messageToastBody} numberOfLines={1}>
|
||||
{content ?? (!isV2 ? message.content : null) ?? 'New message'}
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
function TabBarBackground() {
|
||||
// Only use BlurView on iOS - Android experimental blur causes white overlay artifacts
|
||||
// The solid rgba background in tabBarStyle provides the visual effect on Android
|
||||
@@ -820,9 +877,7 @@ export default function AppLayout() {
|
||||
<Text style={styles.messageToastTitle} numberOfLines={1}>
|
||||
New message
|
||||
</Text>
|
||||
<Text style={styles.messageToastBody} numberOfLines={1}>
|
||||
{lastMessage.content || "Sent an image"}
|
||||
</Text>
|
||||
<MessageToastPreview message={lastMessage} currentUserId={user?.id ?? ''} />
|
||||
</View>
|
||||
<Pressable onPress={dismissMessageToast} hitSlop={8}>
|
||||
<Ionicons name="close" size={20} color={colors.textTertiary} />
|
||||
|
||||
@@ -45,12 +45,19 @@ function PackRow({ pack, isEnabled, isPremiumLocked, isExpanded, onToggle, onPre
|
||||
</View>
|
||||
|
||||
<View style={styles.packContent}>
|
||||
<View style={styles.packTitleRow}>
|
||||
<Text style={styles.packName} numberOfLines={1}>{pack.name}</Text>
|
||||
{isPremiumLocked && (
|
||||
<Ionicons name="lock-closed" size={14} color={colors.premium.gold} />
|
||||
)}
|
||||
</View>
|
||||
<View style={styles.packTitleRow}>
|
||||
<Text style={styles.packName} numberOfLines={1}>{pack.name}</Text>
|
||||
{pack.is_premium && (
|
||||
<View style={[styles.premiumBadge, isPremiumLocked && styles.premiumBadgeLocked]}>
|
||||
<Ionicons
|
||||
name={isPremiumLocked ? "lock-closed" : "sparkles"}
|
||||
size={12}
|
||||
color={colors.premium.gold}
|
||||
/>
|
||||
<Text style={styles.premiumBadgeText}>PRO</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
<Text style={styles.packMeta}>
|
||||
{questionCount} {questionCount === 1 ? "question" : "questions"}
|
||||
{isEnabled && <Text style={styles.packMetaEnabled}> · Enabled</Text>}
|
||||
@@ -631,11 +638,33 @@ const styles = StyleSheet.create({
|
||||
alignItems: "center",
|
||||
gap: spacing.xs,
|
||||
},
|
||||
packName: {
|
||||
...typography.body,
|
||||
color: colors.text,
|
||||
fontWeight: "500",
|
||||
},
|
||||
packName: {
|
||||
...typography.body,
|
||||
color: colors.text,
|
||||
fontWeight: "500",
|
||||
flexShrink: 1,
|
||||
},
|
||||
premiumBadge: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: 4,
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 3,
|
||||
borderRadius: radius.full,
|
||||
backgroundColor: "rgba(212, 175, 55, 0.12)",
|
||||
borderWidth: 1,
|
||||
borderColor: "rgba(212, 175, 55, 0.25)",
|
||||
},
|
||||
premiumBadgeLocked: {
|
||||
backgroundColor: "rgba(212, 175, 55, 0.18)",
|
||||
borderColor: "rgba(212, 175, 55, 0.35)",
|
||||
},
|
||||
premiumBadgeText: {
|
||||
...typography.caption2,
|
||||
color: colors.premium.gold,
|
||||
fontWeight: "700",
|
||||
letterSpacing: 0.6,
|
||||
},
|
||||
packMeta: {
|
||||
...typography.caption1,
|
||||
color: colors.textTertiary,
|
||||
|
||||
+65
-62
@@ -1,52 +1,54 @@
|
||||
// Web polyfills must be imported FIRST before any other React Native imports
|
||||
import "../src/polyfills/web";
|
||||
|
||||
// Initialize Sentry early to capture all errors
|
||||
import { initSentry, setUserContext, clearUserContext } from "../src/lib/sentry";
|
||||
initSentry();
|
||||
|
||||
// Initialize Analytics
|
||||
import { initAnalytics, setUserId, clearUserId, logScreenView, Events } from "../src/lib/analytics";
|
||||
initAnalytics();
|
||||
|
||||
// Track cold start immediately
|
||||
Events.appOpened("cold");
|
||||
|
||||
// Import push notification utilities
|
||||
import {
|
||||
clearPushToken,
|
||||
setupNotificationResponseListener,
|
||||
getInitialNotification,
|
||||
handleNotificationResponse,
|
||||
} from "../src/lib/notifications";
|
||||
|
||||
import React, { useEffect, useRef } from "react";
|
||||
import { Platform, View, AppState, AppStateStatus } from "react-native";
|
||||
|
||||
// Suppress useLayoutEffect warning on server-side rendering
|
||||
if (Platform.OS === "web" && typeof globalThis.window === "undefined") {
|
||||
React.useLayoutEffect = useEffect;
|
||||
// Also silence the warning in case some libraries ignore the monkey-patch
|
||||
const originalError = console.error;
|
||||
console.error = (...args) => {
|
||||
if (typeof args[0] === "string" && args[0].includes("useLayoutEffect does nothing on the server")) {
|
||||
return;
|
||||
}
|
||||
originalError(...args);
|
||||
};
|
||||
}
|
||||
|
||||
import { Stack, router, usePathname } from "expo-router";
|
||||
import { StatusBar } from "expo-status-bar";
|
||||
// GestureHandlerRootView is only needed on native platforms
|
||||
// On web, we use a simple View to avoid findNodeHandle errors
|
||||
const GestureHandlerRootView = Platform.OS === "web"
|
||||
? View
|
||||
: require("react-native-gesture-handler").GestureHandlerRootView;
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import * as Linking from "expo-linking";
|
||||
import { useAuthStore } from "../src/store";
|
||||
import { supabase } from "../src/lib/supabase";
|
||||
// Polyfills must be imported FIRST before any other React Native imports
|
||||
import "../src/polyfills/web";
|
||||
import "../src/polyfills/crypto";
|
||||
|
||||
// Initialize Sentry early to capture all errors
|
||||
import { initSentry, setUserContext, clearUserContext } from "../src/lib/sentry";
|
||||
initSentry();
|
||||
|
||||
// Initialize Analytics
|
||||
import { initAnalytics, setUserId, clearUserId, logScreenView, Events } from "../src/lib/analytics";
|
||||
initAnalytics();
|
||||
|
||||
// Track cold start immediately
|
||||
Events.appOpened("cold");
|
||||
|
||||
// Import push notification utilities
|
||||
import {
|
||||
clearPushToken,
|
||||
setupNotificationResponseListener,
|
||||
getInitialNotification,
|
||||
handleNotificationResponse,
|
||||
} from "../src/lib/notifications";
|
||||
|
||||
import React, { useEffect, useRef } from "react";
|
||||
import { Platform, View, AppState, AppStateStatus } from "react-native";
|
||||
|
||||
// Suppress useLayoutEffect warning on server-side rendering
|
||||
if (Platform.OS === "web" && typeof globalThis.window === "undefined") {
|
||||
React.useLayoutEffect = useEffect;
|
||||
// Also silence the warning in case some libraries ignore the monkey-patch
|
||||
const originalError = console.error;
|
||||
console.error = (...args) => {
|
||||
if (typeof args[0] === "string" && args[0].includes("useLayoutEffect does nothing on the server")) {
|
||||
return;
|
||||
}
|
||||
originalError(...args);
|
||||
};
|
||||
}
|
||||
|
||||
import { Stack, router, usePathname } from "expo-router";
|
||||
import { StatusBar } from "expo-status-bar";
|
||||
// GestureHandlerRootView is only needed on native platforms
|
||||
// On web, we use a simple View to avoid findNodeHandle errors
|
||||
const GestureHandlerRootView = Platform.OS === "web"
|
||||
? View
|
||||
: require("react-native-gesture-handler").GestureHandlerRootView;
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import * as Linking from "expo-linking";
|
||||
import { useAuthStore } from "../src/store";
|
||||
import { supabase } from "../src/lib/supabase";
|
||||
import { EncryptionKeyInitializer } from "../src/components/EncryptionKeyInitializer";
|
||||
|
||||
const queryClient = new QueryClient();
|
||||
|
||||
@@ -207,17 +209,18 @@ export default function RootLayout() {
|
||||
}, []);
|
||||
|
||||
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<GestureHandlerRootView style={{ flex: 1 }}>
|
||||
<StatusBar style="light" />
|
||||
<Stack
|
||||
screenOptions={{
|
||||
headerShown: false,
|
||||
contentStyle: { backgroundColor: "#1a1a2e" },
|
||||
}}
|
||||
/>
|
||||
</GestureHandlerRootView>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<GestureHandlerRootView style={{ flex: 1 }}>
|
||||
<StatusBar style="light" />
|
||||
<EncryptionKeyInitializer />
|
||||
<Stack
|
||||
screenOptions={{
|
||||
headerShown: false,
|
||||
contentStyle: { backgroundColor: "#1a1a2e" },
|
||||
}}
|
||||
/>
|
||||
</GestureHandlerRootView>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
@@ -467,7 +467,7 @@ const styles = StyleSheet.create({
|
||||
marginBottom: spacing.lg,
|
||||
},
|
||||
heroInner: {
|
||||
paddingTop: STATUS_BAR_HEIGHT + NAV_BAR_HEIGHT + spacing.md,
|
||||
paddingTop: STATUS_BAR_HEIGHT + spacing.md,
|
||||
paddingBottom: spacing.lg,
|
||||
minHeight: HERO_HEIGHT,
|
||||
},
|
||||
|
||||
@@ -271,6 +271,9 @@ PODS:
|
||||
- ExpoModulesCore
|
||||
- ExpoLocalAuthentication (15.0.2):
|
||||
- ExpoModulesCore
|
||||
- ExpoMediaLibrary (17.0.6):
|
||||
- ExpoModulesCore
|
||||
- React-Core
|
||||
- ExpoModulesCore (2.2.3):
|
||||
- DoubleConversion
|
||||
- glog
|
||||
@@ -328,6 +331,7 @@ PODS:
|
||||
- libavif/core
|
||||
- libdav1d (>= 0.6.0)
|
||||
- libdav1d (1.2.0)
|
||||
- OpenSSL-Universal (3.3.3001)
|
||||
- PromisesObjC (2.4.0)
|
||||
- PurchasesHybridCommon (17.25.0):
|
||||
- RevenueCat (= 5.51.1)
|
||||
@@ -1630,6 +1634,29 @@ PODS:
|
||||
- ReactCommon/turbomodule/bridging
|
||||
- ReactCommon/turbomodule/core
|
||||
- Yoga
|
||||
- react-native-quick-crypto (0.7.17):
|
||||
- DoubleConversion
|
||||
- glog
|
||||
- hermes-engine
|
||||
- OpenSSL-Universal
|
||||
- RCT-Folly (= 2024.10.14.00)
|
||||
- RCTRequired
|
||||
- RCTTypeSafety
|
||||
- React
|
||||
- React-Core
|
||||
- React-debug
|
||||
- React-Fabric
|
||||
- React-featureflags
|
||||
- React-graphics
|
||||
- React-ImageManager
|
||||
- React-NativeModulesApple
|
||||
- React-RCTFabric
|
||||
- React-rendererdebug
|
||||
- React-utils
|
||||
- ReactCodegen
|
||||
- ReactCommon/turbomodule/bridging
|
||||
- ReactCommon/turbomodule/core
|
||||
- Yoga
|
||||
- react-native-safe-area-context (4.12.0):
|
||||
- React-Core
|
||||
- React-nativeconfig (0.76.9)
|
||||
@@ -2108,6 +2135,7 @@ DEPENDENCIES:
|
||||
- ExpoLinearGradient (from `../../../node_modules/expo-linear-gradient/ios`)
|
||||
- ExpoLinking (from `../../../node_modules/expo-linking/ios`)
|
||||
- ExpoLocalAuthentication (from `../../../node_modules/expo-local-authentication/ios`)
|
||||
- ExpoMediaLibrary (from `../../../node_modules/expo-media-library/ios`)
|
||||
- ExpoModulesCore (from `../../../node_modules/expo-modules-core`)
|
||||
- ExpoSecureStore (from `../../../node_modules/expo-secure-store/ios`)
|
||||
- EXUpdatesInterface (from `../../../node_modules/expo-updates-interface/ios`)
|
||||
@@ -2148,6 +2176,7 @@ DEPENDENCIES:
|
||||
- React-Mapbuffer (from `../../../node_modules/react-native/ReactCommon`)
|
||||
- React-microtasksnativemodule (from `../../../node_modules/react-native/ReactCommon/react/nativemodule/microtasks`)
|
||||
- react-native-compressor (from `../../../node_modules/react-native-compressor`)
|
||||
- react-native-quick-crypto (from `../../../node_modules/react-native-quick-crypto`)
|
||||
- react-native-safe-area-context (from `../../../node_modules/react-native-safe-area-context`)
|
||||
- React-nativeconfig (from `../../../node_modules/react-native/ReactCommon`)
|
||||
- React-NativeModulesApple (from `../../../node_modules/react-native/ReactCommon/react/nativemodule/core/platform/ios`)
|
||||
@@ -2195,6 +2224,7 @@ SPEC REPOS:
|
||||
- GTMSessionFetcher
|
||||
- libavif
|
||||
- libdav1d
|
||||
- OpenSSL-Universal
|
||||
- PromisesObjC
|
||||
- PurchasesHybridCommon
|
||||
- RevenueCat
|
||||
@@ -2263,6 +2293,8 @@ EXTERNAL SOURCES:
|
||||
:path: "../../../node_modules/expo-linking/ios"
|
||||
ExpoLocalAuthentication:
|
||||
:path: "../../../node_modules/expo-local-authentication/ios"
|
||||
ExpoMediaLibrary:
|
||||
:path: "../../../node_modules/expo-media-library/ios"
|
||||
ExpoModulesCore:
|
||||
:path: "../../../node_modules/expo-modules-core"
|
||||
ExpoSecureStore:
|
||||
@@ -2340,6 +2372,8 @@ EXTERNAL SOURCES:
|
||||
:path: "../../../node_modules/react-native/ReactCommon/react/nativemodule/microtasks"
|
||||
react-native-compressor:
|
||||
:path: "../../../node_modules/react-native-compressor"
|
||||
react-native-quick-crypto:
|
||||
:path: "../../../node_modules/react-native-quick-crypto"
|
||||
react-native-safe-area-context:
|
||||
:path: "../../../node_modules/react-native-safe-area-context"
|
||||
React-nativeconfig:
|
||||
@@ -2445,6 +2479,7 @@ SPEC CHECKSUMS:
|
||||
ExpoLinearGradient: 35ebd83b16f80b3add053a2fd68cc328ed927f60
|
||||
ExpoLinking: 8d12bee174ba0cdf31239706578e29e74a417402
|
||||
ExpoLocalAuthentication: eb2be9c7bcdc68e9434d4be4bb5aa5cf7943e816
|
||||
ExpoMediaLibrary: 2709fc2e4fcf87d28767714abcc603c4bec228c6
|
||||
ExpoModulesCore: 725faec070d590810d2ea5983d9f78f7cf6a38ec
|
||||
ExpoSecureStore: 9a3665d7161dd1031be386e278bffd623658f316
|
||||
EXUpdatesInterface: 7c977640bdd8b85833c19e3959ba46145c5719db
|
||||
@@ -2459,6 +2494,7 @@ SPEC CHECKSUMS:
|
||||
hermes-engine: 9e868dc7be781364296d6ee2f56d0c1a9ef0bb11
|
||||
libavif: 84bbb62fb232c3018d6f1bab79beea87e35de7b7
|
||||
libdav1d: 23581a4d8ec811ff171ed5e2e05cd27bad64c39f
|
||||
OpenSSL-Universal: 6082b0bf950e5636fe0d78def171184e2b3899c2
|
||||
PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47
|
||||
PurchasesHybridCommon: 6a79a873ab52f777bfa36e9516f3fcd84d3b3428
|
||||
RCT-Folly: ea9d9256ba7f9322ef911169a9f696e5857b9e17
|
||||
@@ -2491,6 +2527,7 @@ SPEC CHECKSUMS:
|
||||
React-Mapbuffer: 33546a3ebefbccb8770c33a1f8a5554fa96a54de
|
||||
React-microtasksnativemodule: 5c3d795318c22ab8df55100e50b151384a4a60b3
|
||||
react-native-compressor: 2c3a41768bdd367f6e121f555dff9d3eda1810e5
|
||||
react-native-quick-crypto: 7bb26af9b3705dbb9277c09305104d37ede74916
|
||||
react-native-safe-area-context: 8b8404e70b0cbf2a56428a17017c14c1dcc16448
|
||||
React-nativeconfig: 8efdb1ef1e9158c77098a93085438f7e7b463678
|
||||
React-NativeModulesApple: cebca2e5320a3d66e123cade23bd90a167ffce5e
|
||||
|
||||
@@ -3,37 +3,37 @@
|
||||
archiveVersion = 1;
|
||||
classes = {
|
||||
};
|
||||
objectVersion = 54;
|
||||
objectVersion = 46;
|
||||
objects = {
|
||||
|
||||
/* Begin PBXBuildFile section */
|
||||
13B07FBC1A68108700A75B9A /* AppDelegate.mm in Sources */ = {isa = PBXBuildFile; fileRef = 13B07FB01A68108700A75B9A /* AppDelegate.mm */; };
|
||||
13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 13B07FB51A68108700A75B9A /* Images.xcassets */; };
|
||||
13B07FC11A68108700A75B9A /* main.m in Sources */ = {isa = PBXBuildFile; fileRef = 13B07FB71A68108700A75B9A /* main.m */; };
|
||||
356786A828768878A952F4E5 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 9F524F031F5995D63DB94706 /* PrivacyInfo.xcprivacy */; };
|
||||
3E461D99554A48A4959DE609 /* SplashScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */; };
|
||||
720501527E1546CCB1AFBCF5 /* noop-file.swift in Sources */ = {isa = PBXBuildFile; fileRef = F115714F0491496B8D41CDD9 /* noop-file.swift */; };
|
||||
7FA67D035683415E8F745B3C /* noop-file.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7459E7F6C9814324B0B1C2B8 /* noop-file.swift */; };
|
||||
96905EF65AED1B983A6B3ABC /* libPods-Sauci.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 58EEBF8E8E6FB1BC6CAF49B5 /* libPods-Sauci.a */; };
|
||||
B18059E884C0ABDD17F3DC3D /* ExpoModulesProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAC715A2D49A985799AEE119 /* ExpoModulesProvider.swift */; };
|
||||
BB2F792D24A3F905000567C9 /* Expo.plist in Resources */ = {isa = PBXBuildFile; fileRef = BB2F792C24A3F905000567C9 /* Expo.plist */; };
|
||||
C1A16C113B7B1472578327D9 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 091FB6EEA51288ED888C8E3C /* PrivacyInfo.xcprivacy */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXFileReference section */
|
||||
091FB6EEA51288ED888C8E3C /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; includeInIndex = 1; name = PrivacyInfo.xcprivacy; path = Sauci/PrivacyInfo.xcprivacy; sourceTree = "<group>"; };
|
||||
13B07F961A680F5B00A75B9A /* Sauci.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Sauci.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
13B07FAF1A68108700A75B9A /* AppDelegate.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = AppDelegate.h; path = Sauci/AppDelegate.h; sourceTree = "<group>"; };
|
||||
13B07FB01A68108700A75B9A /* AppDelegate.mm */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.cpp.objcpp; name = AppDelegate.mm; path = Sauci/AppDelegate.mm; sourceTree = "<group>"; };
|
||||
13B07FB51A68108700A75B9A /* Images.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Images.xcassets; path = Sauci/Images.xcassets; sourceTree = "<group>"; };
|
||||
13B07FB61A68108700A75B9A /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = Info.plist; path = Sauci/Info.plist; sourceTree = "<group>"; };
|
||||
13B07FB71A68108700A75B9A /* main.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = main.m; path = Sauci/main.m; sourceTree = "<group>"; };
|
||||
37BFBB5C91964731BEAF4E57 /* Sauci-Bridging-Header.h */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = 4; includeInIndex = 0; lastKnownFileType = sourcecode.c.h; name = "Sauci-Bridging-Header.h"; path = "Sauci/Sauci-Bridging-Header.h"; sourceTree = "<group>"; };
|
||||
58EEBF8E8E6FB1BC6CAF49B5 /* libPods-Sauci.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-Sauci.a"; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
6C2E3173556A471DD304B334 /* Pods-Sauci.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Sauci.debug.xcconfig"; path = "Target Support Files/Pods-Sauci/Pods-Sauci.debug.xcconfig"; sourceTree = "<group>"; };
|
||||
7459E7F6C9814324B0B1C2B8 /* noop-file.swift */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = 4; includeInIndex = 0; lastKnownFileType = sourcecode.swift; name = "noop-file.swift"; path = "Sauci/noop-file.swift"; sourceTree = "<group>"; };
|
||||
7A4D352CD337FB3A3BF06240 /* Pods-Sauci.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Sauci.release.xcconfig"; path = "Target Support Files/Pods-Sauci/Pods-Sauci.release.xcconfig"; sourceTree = "<group>"; };
|
||||
9F524F031F5995D63DB94706 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xml; name = PrivacyInfo.xcprivacy; path = Sauci/PrivacyInfo.xcprivacy; sourceTree = "<group>"; };
|
||||
AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; name = SplashScreen.storyboard; path = Sauci/SplashScreen.storyboard; sourceTree = "<group>"; };
|
||||
B802546CC5A74898A9211A64 /* Sauci-Bridging-Header.h */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = 4; includeInIndex = 0; lastKnownFileType = sourcecode.c.h; name = "Sauci-Bridging-Header.h"; path = "Sauci/Sauci-Bridging-Header.h"; sourceTree = "<group>"; };
|
||||
BB2F792C24A3F905000567C9 /* Expo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = Expo.plist; sourceTree = "<group>"; };
|
||||
ED297162215061F000B7C4FE /* JavaScriptCore.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = JavaScriptCore.framework; path = System/Library/Frameworks/JavaScriptCore.framework; sourceTree = SDKROOT; };
|
||||
F115714F0491496B8D41CDD9 /* noop-file.swift */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = 4; includeInIndex = 0; lastKnownFileType = sourcecode.swift; name = "noop-file.swift"; path = "Sauci/noop-file.swift"; sourceTree = "<group>"; };
|
||||
FAC715A2D49A985799AEE119 /* ExpoModulesProvider.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ExpoModulesProvider.swift; path = "Pods/Target Support Files/Pods-Sauci/ExpoModulesProvider.swift"; sourceTree = "<group>"; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
@@ -59,9 +59,9 @@
|
||||
13B07FB61A68108700A75B9A /* Info.plist */,
|
||||
13B07FB71A68108700A75B9A /* main.m */,
|
||||
AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */,
|
||||
F115714F0491496B8D41CDD9 /* noop-file.swift */,
|
||||
B802546CC5A74898A9211A64 /* Sauci-Bridging-Header.h */,
|
||||
9F524F031F5995D63DB94706 /* PrivacyInfo.xcprivacy */,
|
||||
7459E7F6C9814324B0B1C2B8 /* noop-file.swift */,
|
||||
37BFBB5C91964731BEAF4E57 /* Sauci-Bridging-Header.h */,
|
||||
091FB6EEA51288ED888C8E3C /* PrivacyInfo.xcprivacy */,
|
||||
);
|
||||
name = Sauci;
|
||||
sourceTree = "<group>";
|
||||
@@ -147,14 +147,14 @@
|
||||
buildConfigurationList = 13B07F931A680F5B00A75B9A /* Build configuration list for PBXNativeTarget "Sauci" */;
|
||||
buildPhases = (
|
||||
08A4A3CD28434E44B6B9DE2E /* [CP] Check Pods Manifest.lock */,
|
||||
E65B38D1E7CDAB3F0A2275AF /* [Expo] Configure project */,
|
||||
19CD656710F28335C54F2FDE /* [Expo] Configure project */,
|
||||
13B07F871A680F5B00A75B9A /* Sources */,
|
||||
13B07F8C1A680F5B00A75B9A /* Frameworks */,
|
||||
13B07F8E1A680F5B00A75B9A /* Resources */,
|
||||
00DD1BFF1BD5951E006B06BC /* Bundle React Native code and images */,
|
||||
800E24972A6A228C8D4807E9 /* [CP] Copy Pods Resources */,
|
||||
B4C9C862821045DC99C4CEEE /* Upload Debug Symbols to Sentry */,
|
||||
295448ADEAF428243104EA3C /* [CP] Embed Pods Frameworks */,
|
||||
987394C7D2294ABDB4919B28 /* Upload Debug Symbols to Sentry */,
|
||||
AFEE2A0A2946A54F41B5668F /* [CP] Embed Pods Frameworks */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
@@ -174,7 +174,9 @@
|
||||
LastUpgradeCheck = 1130;
|
||||
TargetAttributes = {
|
||||
13B07F861A680F5B00A75B9A = {
|
||||
DevelopmentTeam = BXVPBATDZY;
|
||||
LastSwiftMigration = 1250;
|
||||
ProvisioningStyle = Automatic;
|
||||
};
|
||||
};
|
||||
};
|
||||
@@ -204,7 +206,7 @@
|
||||
BB2F792D24A3F905000567C9 /* Expo.plist in Resources */,
|
||||
13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */,
|
||||
3E461D99554A48A4959DE609 /* SplashScreen.storyboard in Resources */,
|
||||
356786A828768878A952F4E5 /* PrivacyInfo.xcprivacy in Resources */,
|
||||
C1A16C113B7B1472578327D9 /* PrivacyInfo.xcprivacy in Resources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
@@ -248,23 +250,24 @@
|
||||
shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n";
|
||||
showEnvVarsInLog = 0;
|
||||
};
|
||||
295448ADEAF428243104EA3C /* [CP] Embed Pods Frameworks */ = {
|
||||
19CD656710F28335C54F2FDE /* [Expo] Configure project */ = {
|
||||
isa = PBXShellScriptBuildPhase;
|
||||
alwaysOutOfDate = 1;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
inputFileListPaths = (
|
||||
);
|
||||
inputPaths = (
|
||||
"${PODS_ROOT}/Target Support Files/Pods-Sauci/Pods-Sauci-frameworks.sh",
|
||||
"${PODS_XCFRAMEWORKS_BUILD_DIR}/hermes-engine/Pre-built/hermes.framework/hermes",
|
||||
);
|
||||
name = "[Expo] Configure project";
|
||||
outputFileListPaths = (
|
||||
);
|
||||
name = "[CP] Embed Pods Frameworks";
|
||||
outputPaths = (
|
||||
"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/hermes.framework",
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
shellPath = /bin/sh;
|
||||
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Sauci/Pods-Sauci-frameworks.sh\"\n";
|
||||
showEnvVarsInLog = 0;
|
||||
shellScript = "# This script configures Expo modules and generates the modules provider file.\nbash -l -c \"./Pods/Target\\ Support\\ Files/Pods-Sauci/expo-configure-project.sh\"\n";
|
||||
};
|
||||
800E24972A6A228C8D4807E9 /* [CP] Copy Pods Resources */ = {
|
||||
isa = PBXShellScriptBuildPhase;
|
||||
@@ -279,6 +282,7 @@
|
||||
"${PODS_CONFIGURATION_BUILD_DIR}/EXConstants/ExpoConstants_privacy.bundle",
|
||||
"${PODS_CONFIGURATION_BUILD_DIR}/EXNotifications/ExpoNotifications_privacy.bundle",
|
||||
"${PODS_CONFIGURATION_BUILD_DIR}/ExpoFileSystem/ExpoFileSystem_privacy.bundle",
|
||||
"${PODS_CONFIGURATION_BUILD_DIR}/ExpoMediaLibrary/ExpoMediaLibrary_privacy.bundle",
|
||||
"${PODS_CONFIGURATION_BUILD_DIR}/GTMAppAuth/GTMAppAuth_Privacy.bundle",
|
||||
"${PODS_CONFIGURATION_BUILD_DIR}/GTMSessionFetcher/GTMSessionFetcher_Core_Privacy.bundle",
|
||||
"${PODS_CONFIGURATION_BUILD_DIR}/GoogleSignIn/GoogleSignIn.bundle",
|
||||
@@ -305,6 +309,7 @@
|
||||
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ExpoConstants_privacy.bundle",
|
||||
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ExpoNotifications_privacy.bundle",
|
||||
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ExpoFileSystem_privacy.bundle",
|
||||
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ExpoMediaLibrary_privacy.bundle",
|
||||
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/GTMAppAuth_Privacy.bundle",
|
||||
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/GTMSessionFetcher_Core_Privacy.bundle",
|
||||
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/GoogleSignIn.bundle",
|
||||
@@ -328,7 +333,7 @@
|
||||
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Sauci/Pods-Sauci-resources.sh\"\n";
|
||||
showEnvVarsInLog = 0;
|
||||
};
|
||||
B4C9C862821045DC99C4CEEE /* Upload Debug Symbols to Sentry */ = {
|
||||
987394C7D2294ABDB4919B28 /* Upload Debug Symbols to Sentry */ = {
|
||||
isa = PBXShellScriptBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
@@ -342,24 +347,25 @@
|
||||
shellPath = /bin/sh;
|
||||
shellScript = "/bin/sh `${NODE_BINARY:-node} --print \"require('path').dirname(require.resolve('@sentry/react-native/package.json')) + '/scripts/sentry-xcode-debug-files.sh'\"`";
|
||||
};
|
||||
E65B38D1E7CDAB3F0A2275AF /* [Expo] Configure project */ = {
|
||||
AFEE2A0A2946A54F41B5668F /* [CP] Embed Pods Frameworks */ = {
|
||||
isa = PBXShellScriptBuildPhase;
|
||||
alwaysOutOfDate = 1;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
inputFileListPaths = (
|
||||
);
|
||||
inputPaths = (
|
||||
"${PODS_ROOT}/Target Support Files/Pods-Sauci/Pods-Sauci-frameworks.sh",
|
||||
"${PODS_XCFRAMEWORKS_BUILD_DIR}/OpenSSL-Universal/OpenSSL.framework/OpenSSL",
|
||||
"${PODS_XCFRAMEWORKS_BUILD_DIR}/hermes-engine/Pre-built/hermes.framework/hermes",
|
||||
);
|
||||
name = "[Expo] Configure project";
|
||||
outputFileListPaths = (
|
||||
);
|
||||
name = "[CP] Embed Pods Frameworks";
|
||||
outputPaths = (
|
||||
"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/OpenSSL.framework",
|
||||
"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/hermes.framework",
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
shellPath = /bin/sh;
|
||||
shellScript = "# This script configures Expo modules and generates the modules provider file.\nbash -l -c \"./Pods/Target\\ Support\\ Files/Pods-Sauci/expo-configure-project.sh\"\n";
|
||||
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Sauci/Pods-Sauci-frameworks.sh\"\n";
|
||||
showEnvVarsInLog = 0;
|
||||
};
|
||||
/* End PBXShellScriptBuildPhase section */
|
||||
|
||||
@@ -371,7 +377,7 @@
|
||||
13B07FBC1A68108700A75B9A /* AppDelegate.mm in Sources */,
|
||||
13B07FC11A68108700A75B9A /* main.m in Sources */,
|
||||
B18059E884C0ABDD17F3DC3D /* ExpoModulesProvider.swift in Sources */,
|
||||
720501527E1546CCB1AFBCF5 /* noop-file.swift in Sources */,
|
||||
7FA67D035683415E8F745B3C /* noop-file.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
@@ -385,6 +391,8 @@
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = Sauci/Sauci.entitlements;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_TEAM = BXVPBATDZY;
|
||||
ENABLE_BITCODE = NO;
|
||||
@@ -394,10 +402,7 @@
|
||||
);
|
||||
INFOPLIST_FILE = Sauci/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.1;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
|
||||
MARKETING_VERSION = 1.0;
|
||||
OTHER_LDFLAGS = (
|
||||
"$(inherited)",
|
||||
@@ -422,14 +427,13 @@
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_ENTITLEMENTS = Sauci/Sauci.entitlements;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_TEAM = BXVPBATDZY;
|
||||
INFOPLIST_FILE = Sauci/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.1;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
|
||||
MARKETING_VERSION = 1.0;
|
||||
OTHER_LDFLAGS = (
|
||||
"$(inherited)",
|
||||
@@ -494,14 +498,14 @@
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.1;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
/usr/lib/swift,
|
||||
"$(inherited)",
|
||||
);
|
||||
LD_RUNPATH_SEARCH_PATHS = "/usr/lib/swift $(inherited)";
|
||||
LIBRARY_SEARCH_PATHS = "$(SDKROOT)/usr/lib/swift\"$(inherited)\"";
|
||||
MTL_ENABLE_DEBUG_INFO = YES;
|
||||
ONLY_ACTIVE_ARCH = YES;
|
||||
OTHER_LDFLAGS = "$(inherited) ";
|
||||
OTHER_LDFLAGS = (
|
||||
"$(inherited)",
|
||||
" ",
|
||||
);
|
||||
REACT_NATIVE_PATH = "${PODS_ROOT}/../../../../node_modules/react-native";
|
||||
SDKROOT = iphoneos;
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$(inherited) DEBUG";
|
||||
@@ -550,13 +554,13 @@
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 15.1;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
/usr/lib/swift,
|
||||
"$(inherited)",
|
||||
);
|
||||
LD_RUNPATH_SEARCH_PATHS = "/usr/lib/swift $(inherited)";
|
||||
LIBRARY_SEARCH_PATHS = "$(SDKROOT)/usr/lib/swift\"$(inherited)\"";
|
||||
MTL_ENABLE_DEBUG_INFO = NO;
|
||||
OTHER_LDFLAGS = "$(inherited) ";
|
||||
OTHER_LDFLAGS = (
|
||||
"$(inherited)",
|
||||
" ",
|
||||
);
|
||||
REACT_NATIVE_PATH = "${PODS_ROOT}/../../../../node_modules/react-native";
|
||||
SDKROOT = iphoneos;
|
||||
USE_HERMES = true;
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
module.exports = {
|
||||
preset: 'jest-expo',
|
||||
clearMocks: true,
|
||||
setupFiles: ['<rootDir>/node_modules/react-native-gesture-handler/jestSetup.js'],
|
||||
setupFiles: ['../../node_modules/react-native-gesture-handler/jestSetup.js'],
|
||||
setupFilesAfterEnv: [
|
||||
'@testing-library/jest-native/extend-expect',
|
||||
'<rootDir>/src/test/setup.ts',
|
||||
|
||||
+35
-28
@@ -1,28 +1,35 @@
|
||||
const { getDefaultConfig } = require('expo/metro-config');
|
||||
const path = require('path');
|
||||
|
||||
// Find the project and workspace directories
|
||||
const projectRoot = __dirname;
|
||||
const workspaceRoot = path.resolve(projectRoot, '../..');
|
||||
|
||||
const config = getDefaultConfig(projectRoot);
|
||||
|
||||
// 1. Watch all files within the monorepo
|
||||
// 1. Watch only relevant folders to improve performance on Windows
|
||||
// watching the entire workspaceRoot is too slow
|
||||
config.watchFolders = [
|
||||
path.resolve(workspaceRoot, 'node_modules'),
|
||||
path.resolve(workspaceRoot, 'packages/shared')
|
||||
];
|
||||
|
||||
// 2. Let Metro know where to resolve packages and in which order
|
||||
config.resolver.nodeModulesPaths = [
|
||||
path.resolve(projectRoot, 'node_modules'),
|
||||
path.resolve(workspaceRoot, 'node_modules'),
|
||||
];
|
||||
|
||||
// 3. Force Metro to resolve (sub)dependencies from the `node_modules`
|
||||
// of the workspace root, unless they are installed in the project root.
|
||||
config.resolver.disableHierarchicalLookup = true;
|
||||
|
||||
module.exports = config;
|
||||
const { getDefaultConfig } = require('expo/metro-config');
|
||||
const path = require('path');
|
||||
|
||||
// Find the project and workspace directories
|
||||
const projectRoot = __dirname;
|
||||
const workspaceRoot = path.resolve(projectRoot, '../..');
|
||||
|
||||
const config = getDefaultConfig(projectRoot);
|
||||
|
||||
// 1. Watch all files within the monorepo
|
||||
// 1. Watch only relevant folders to improve performance on Windows
|
||||
// watching the entire workspaceRoot is too slow
|
||||
config.watchFolders = [
|
||||
path.resolve(workspaceRoot, 'node_modules'),
|
||||
path.resolve(workspaceRoot, 'packages/shared')
|
||||
];
|
||||
|
||||
// 2. Let Metro know where to resolve packages and in which order
|
||||
config.resolver.nodeModulesPaths = [
|
||||
path.resolve(projectRoot, 'node_modules'),
|
||||
path.resolve(workspaceRoot, 'node_modules'),
|
||||
];
|
||||
|
||||
// 3. Force Metro to resolve (sub)dependencies from the `node_modules`
|
||||
// of the workspace root, unless they are installed in the project root.
|
||||
config.resolver.disableHierarchicalLookup = true;
|
||||
|
||||
// 4. E2EE crypto polyfill: resolve 'crypto' to react-native-quick-crypto
|
||||
// This enables Web Crypto API for encryption operations
|
||||
config.resolver.extraNodeModules = {
|
||||
...config.resolver.extraNodeModules,
|
||||
crypto: require.resolve('react-native-quick-crypto'),
|
||||
};
|
||||
|
||||
module.exports = config;
|
||||
@@ -44,6 +44,7 @@
|
||||
"expo-linear-gradient": "~14.0.2",
|
||||
"expo-linking": "~7.0.0",
|
||||
"expo-local-authentication": "~15.0.2",
|
||||
"expo-media-library": "~17.0.6",
|
||||
"expo-notifications": "~0.29.0",
|
||||
"expo-router": "~4.0.0",
|
||||
"expo-secure-store": "~14.0.0",
|
||||
@@ -55,6 +56,7 @@
|
||||
"react-native-compressor": "^1.13.0",
|
||||
"react-native-gesture-handler": "~2.20.0",
|
||||
"react-native-purchases": "^9.6.12",
|
||||
"react-native-quick-crypto": "^0.7.11",
|
||||
"react-native-reanimated": "~3.16.0",
|
||||
"react-native-safe-area-context": "4.12.0",
|
||||
"react-native-screens": "~4.4.0",
|
||||
|
||||
@@ -42,13 +42,28 @@ describe('useMessageSubscription', () => {
|
||||
|
||||
const fetchQuery = createThenableQuery({ data: [partnerMessage] });
|
||||
const updateQuery = createThenableQuery({ data: null });
|
||||
const deletionsQuery = createThenableQuery({ data: [] });
|
||||
|
||||
const fromMock = jest
|
||||
.fn()
|
||||
.mockReturnValueOnce(fetchQuery)
|
||||
.mockReturnValueOnce(updateQuery);
|
||||
const fromMock = jest.fn((table: string) => {
|
||||
if (table === 'message_deletions') return deletionsQuery;
|
||||
if (table === 'messages') {
|
||||
// If it's the first call to messages, it's fetch, otherwise update.
|
||||
// But simplified for the test logic:
|
||||
return fetchQuery;
|
||||
}
|
||||
return createThenableQuery({ data: null });
|
||||
});
|
||||
|
||||
(supabase as any).from = fromMock;
|
||||
// Use mockReturnValueOnce to handle the sequence:
|
||||
// 1. message_deletions (select)
|
||||
// 2. messages (select)
|
||||
// 3. messages (update)
|
||||
const fromMockSequence = jest.fn()
|
||||
.mockReturnValueOnce(deletionsQuery) // message_deletions select
|
||||
.mockReturnValueOnce(fetchQuery) // messages select
|
||||
.mockReturnValueOnce(updateQuery); // messages update
|
||||
|
||||
(supabase as any).from = fromMockSequence;
|
||||
|
||||
const channelMock: any = {
|
||||
on: jest.fn(() => channelMock),
|
||||
@@ -63,7 +78,7 @@ describe('useMessageSubscription', () => {
|
||||
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
|
||||
expect(fromMock).toHaveBeenCalledWith('messages');
|
||||
expect(fromMockSequence).toHaveBeenCalledWith('messages');
|
||||
expect(updateQuery.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
delivered_at: expect.stringMatching(/^2024-01-01T00:00:00\./),
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
/**
|
||||
* EncryptionKeyInitializer Component
|
||||
*
|
||||
* Handles automatic initialization and upload of E2EE encryption keys.
|
||||
* Should be placed in the app layout to run after authentication.
|
||||
*
|
||||
* Uses global Zustand store for key state to ensure all components
|
||||
* see the same keys at all times.
|
||||
*/
|
||||
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Platform } from 'react-native';
|
||||
import { useEncryptionKeys } from '../hooks';
|
||||
import { useAuthStore } from '../store';
|
||||
import { reencryptPendingMessages } from '../lib/encryption/reencryptPendingMessages';
|
||||
import { triggerKeyRotation } from '../lib/encryption';
|
||||
import { LoadingOverlay } from './ui/LoadingOverlay';
|
||||
import { colors } from '../theme';
|
||||
|
||||
/**
|
||||
* Check if crypto is available (react-native-quick-crypto polyfill installed)
|
||||
*/
|
||||
function isCryptoAvailable(): boolean {
|
||||
return typeof crypto !== 'undefined' && crypto.subtle !== undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Component that initializes encryption keys after user authentication.
|
||||
*
|
||||
* Flow:
|
||||
* 1. When user signs in, check if they have encryption keys
|
||||
* 2. If not, generate new RSA key pair
|
||||
* 3. Upload public key to Supabase profile
|
||||
* 4. If user has a partner without keys, trigger re-encryption when partner generates keys
|
||||
*/
|
||||
export function EncryptionKeyInitializer(): JSX.Element | null {
|
||||
const { initializeAndUploadKeys, isLoading: keysLoading, hasKeys, error } = useEncryptionKeys();
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const couple = useAuthStore((s) => s.couple);
|
||||
|
||||
// Track if keys were regenerated this session (new device, reinstall, etc.)
|
||||
const [wasRegenerated, setWasRegenerated] = useState(false);
|
||||
const hasTriggeredRotation = useRef(false);
|
||||
const hasLoggedCryptoWarning = useRef(false);
|
||||
const isInitializing = useRef(false);
|
||||
const hasInitialized = useRef(false);
|
||||
|
||||
// Show loading overlay when we are initializing keys (generating new ones takes time)
|
||||
// or when rotation is in progress
|
||||
const [showLoading, setShowLoading] = useState(false);
|
||||
const [loadingStatus, setLoadingStatus] = useState('Securing your app...');
|
||||
|
||||
// Effect 1: Initialize encryption keys when user is authenticated
|
||||
useEffect(() => {
|
||||
// Skip on web - E2EE not supported
|
||||
if (Platform.OS === 'web') return;
|
||||
|
||||
// Check if crypto is available (native modules rebuilt)
|
||||
if (!isCryptoAvailable()) {
|
||||
if (!hasLoggedCryptoWarning.current) {
|
||||
console.warn('[E2EE Init] Crypto not available. Run: npx expo prebuild --clean');
|
||||
hasLoggedCryptoWarning.current = true;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip if no user, already initialized, or currently initializing
|
||||
if (!user?.id || hasInitialized.current || isInitializing.current) return;
|
||||
|
||||
// Wait for initial key loading to complete
|
||||
if (keysLoading) return;
|
||||
|
||||
// Skip if keys already exist in global state
|
||||
if (hasKeys) {
|
||||
console.log('[E2EE Init] Keys already loaded in global state');
|
||||
hasInitialized.current = true;
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip if there was an error loading keys (avoid retry loop)
|
||||
if (error) {
|
||||
console.warn('[E2EE Init] Skipping key initialization due to previous error:', error.message);
|
||||
return;
|
||||
}
|
||||
|
||||
const initKeys = async () => {
|
||||
try {
|
||||
// Mark as initializing to prevent re-entry
|
||||
isInitializing.current = true;
|
||||
setShowLoading(true);
|
||||
setLoadingStatus('Securing your app...');
|
||||
|
||||
console.log('[E2EE Init] Starting key initialization...');
|
||||
|
||||
// Initialize keys and upload in one operation
|
||||
const result = await initializeAndUploadKeys();
|
||||
|
||||
console.log('[E2EE Init] Encryption keys initialized and uploaded');
|
||||
|
||||
// Track if keys were regenerated (for rotation decision)
|
||||
if (result.wasRegenerated) {
|
||||
console.log('[E2EE Init] Keys were regenerated this session');
|
||||
setWasRegenerated(true);
|
||||
}
|
||||
|
||||
hasInitialized.current = true;
|
||||
} catch (err) {
|
||||
console.error('[E2EE Init] Failed to initialize encryption keys:', err);
|
||||
// Reset initialization flag so we can retry on next render
|
||||
// But only if it's a transient error, not a crypto unavailable error
|
||||
if (err instanceof Error && !err.message.includes('Crypto')) {
|
||||
isInitializing.current = false;
|
||||
}
|
||||
setShowLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
initKeys();
|
||||
}, [user?.id, keysLoading, hasKeys, error, initializeAndUploadKeys]);
|
||||
|
||||
// Effect 2: Trigger key rotation when keys were regenerated and couple is available
|
||||
useEffect(() => {
|
||||
// Skip on web or if crypto not available
|
||||
if (Platform.OS === 'web' || !isCryptoAvailable()) return;
|
||||
|
||||
// Need user and couple to trigger rotation
|
||||
if (!user?.id || !couple?.id) return;
|
||||
|
||||
// Only trigger once per session
|
||||
if (hasTriggeredRotation.current) return;
|
||||
|
||||
// Wait for keys to be initialized in global state
|
||||
if (!hasKeys) return;
|
||||
|
||||
// Only trigger rotation if keys were actually regenerated this session
|
||||
// (new device, app reinstall, key corruption recovery, etc.)
|
||||
if (!wasRegenerated) {
|
||||
// If we're done initializing but not rotating, hide loading
|
||||
if (showLoading) {
|
||||
setShowLoading(false);
|
||||
isInitializing.current = false;
|
||||
}
|
||||
console.log('[E2EE Init] Keys unchanged, skipping rotation');
|
||||
return;
|
||||
}
|
||||
|
||||
const runRotation = async () => {
|
||||
try {
|
||||
hasTriggeredRotation.current = true;
|
||||
setLoadingStatus('Updating message security...');
|
||||
|
||||
// Trigger key rotation - re-wraps message keys for the new public key
|
||||
console.log('[E2EE Init] Keys were regenerated, triggering key rotation...');
|
||||
await triggerKeyRotation();
|
||||
|
||||
// Trigger re-encryption of pending messages (for messages where
|
||||
// recipient had no key at send time)
|
||||
await reencryptPendingMessages({ force: true });
|
||||
|
||||
console.log('[E2EE Init] Key rotation complete');
|
||||
} catch (err) {
|
||||
console.error('[E2EE Init] Failed to run key rotation:', err);
|
||||
// Reset so we can retry
|
||||
hasTriggeredRotation.current = false;
|
||||
} finally {
|
||||
setShowLoading(false);
|
||||
isInitializing.current = false;
|
||||
}
|
||||
};
|
||||
|
||||
runRotation();
|
||||
}, [user?.id, couple?.id, hasKeys, wasRegenerated, showLoading]);
|
||||
|
||||
// Reset initialization flags when user changes (sign out/sign in)
|
||||
useEffect(() => {
|
||||
if (!user?.id) {
|
||||
hasInitialized.current = false;
|
||||
setWasRegenerated(false);
|
||||
isInitializing.current = false;
|
||||
hasTriggeredRotation.current = false;
|
||||
setShowLoading(false);
|
||||
}
|
||||
}, [user?.id]);
|
||||
|
||||
return (
|
||||
<LoadingOverlay
|
||||
visible={showLoading}
|
||||
statusText={loadingStatus}
|
||||
detailText="This helps keep your conversations private."
|
||||
fullScreen={true}
|
||||
spinnerColor={colors.primary}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -28,7 +28,7 @@ export interface ReadReceiptProps {
|
||||
export function ReadReceipt({
|
||||
status,
|
||||
size = 14,
|
||||
readColor = colors.premium.gold,
|
||||
readColor = colors.info,
|
||||
unreadColor = 'rgba(255, 255, 255, 0.4)',
|
||||
style,
|
||||
}: ReadReceiptProps) {
|
||||
|
||||
@@ -11,10 +11,11 @@ import Animated, { FadeInDown, useAnimatedStyle } from "react-native-reanimated"
|
||||
|
||||
import { InputBar, ChatHeader, ChatMessages } from "./components";
|
||||
import { useMediaUpload } from "./hooks/useMediaUpload";
|
||||
import { useMessageActions } from "./hooks/useMessageActions";
|
||||
|
||||
import { supabase } from "../../lib/supabase";
|
||||
import { useAuthStore, useMessageStore } from "../../store";
|
||||
import { useAmbientOrbAnimation, useMediaPicker, useTypingIndicator, useMessageSubscription } from "../../hooks";
|
||||
import { useAmbientOrbAnimation, useMediaPicker, useTypingIndicator, useMessageSubscription, useEncryptedSend, useMediaSaver } from "../../hooks";
|
||||
import { Database } from "../../types/supabase";
|
||||
import { GradientBackground } from "../../components/ui";
|
||||
import { colors, gradients, spacing } from "../../theme";
|
||||
@@ -37,6 +38,7 @@ export const ChatScreen: React.FC = () => {
|
||||
const [match, setMatch] = useState<any>(null);
|
||||
const [inputText, setInputText] = useState("");
|
||||
const { uploading, uploadStatus, uploadMedia } = useMediaUpload(matchId, user?.id);
|
||||
const { saveMedia, saving: mediaSaving } = useMediaSaver();
|
||||
const [fullScreenImage, setFullScreenImage] = useState<string | null>(null);
|
||||
const [fullScreenImageLoading, setFullScreenImageLoading] = useState(true);
|
||||
const [fullScreenVideo, setFullScreenVideo] = useState<string | null>(null);
|
||||
@@ -67,6 +69,19 @@ export const ChatScreen: React.FC = () => {
|
||||
onNewMessage: clearTypingIndicator,
|
||||
});
|
||||
|
||||
// E2EE encryption hook for sending messages
|
||||
const { encryptMessage, isE2EEAvailable } = useEncryptedSend();
|
||||
|
||||
// Message actions hook for deletion
|
||||
const { showDeleteOptions } = useMessageActions({ userId: user?.id });
|
||||
|
||||
// Handle long press on message to show delete options
|
||||
const handleMessageLongPress = useCallback((message: Message, isMe: boolean) => {
|
||||
// Don't show options for already deleted messages
|
||||
if (message.deleted_at) return;
|
||||
showDeleteOptions(message, isMe);
|
||||
}, [showDeleteOptions]);
|
||||
|
||||
// Track active chat to prevent notifications for current chat
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
@@ -121,17 +136,56 @@ export const ChatScreen: React.FC = () => {
|
||||
const content = inputText.trim();
|
||||
setInputText("");
|
||||
|
||||
const { error } = await supabase.from("messages").insert({
|
||||
match_id: matchId,
|
||||
user_id: user.id,
|
||||
content: content,
|
||||
});
|
||||
try {
|
||||
// Try to encrypt the message if E2EE is available
|
||||
console.log(`[E2EE Send] isE2EEAvailable=${isE2EEAvailable}`);
|
||||
const encryptedPayload = isE2EEAvailable ? await encryptMessage(content) : null;
|
||||
console.log(`[E2EE Send] encryptedPayload=`, encryptedPayload ? {
|
||||
version: encryptedPayload.version,
|
||||
encrypted_content_length: encryptedPayload.encrypted_content.length,
|
||||
encryption_iv_length: encryptedPayload.encryption_iv.length,
|
||||
keys_metadata: encryptedPayload.keys_metadata,
|
||||
} : null);
|
||||
|
||||
if (error) {
|
||||
if (encryptedPayload) {
|
||||
// Send encrypted message (v2)
|
||||
const { error, data } = await supabase.from("messages").insert({
|
||||
match_id: matchId,
|
||||
user_id: user.id,
|
||||
content: null,
|
||||
version: encryptedPayload.version,
|
||||
encrypted_content: encryptedPayload.encrypted_content,
|
||||
encryption_iv: encryptedPayload.encryption_iv,
|
||||
keys_metadata: encryptedPayload.keys_metadata as unknown as Record<string, unknown>,
|
||||
}).select();
|
||||
|
||||
console.log(`[E2EE Send] Insert result:`, { error, data });
|
||||
|
||||
if (error) {
|
||||
Alert.alert("Error", "Failed to send message");
|
||||
setInputText(content);
|
||||
} else {
|
||||
Events.messageSent();
|
||||
}
|
||||
} else {
|
||||
// Fallback to plaintext (v1) if E2EE not available
|
||||
const { error } = await supabase.from("messages").insert({
|
||||
match_id: matchId,
|
||||
user_id: user.id,
|
||||
content: content,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
Alert.alert("Error", "Failed to send message");
|
||||
setInputText(content);
|
||||
} else {
|
||||
Events.messageSent();
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Error sending message:", err);
|
||||
Alert.alert("Error", "Failed to send message");
|
||||
setInputText(content);
|
||||
} else {
|
||||
Events.messageSent();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -223,6 +277,7 @@ export const ChatScreen: React.FC = () => {
|
||||
onImagePress={setFullScreenImage}
|
||||
onVideoFullScreen={setFullScreenVideo}
|
||||
revealMessage={revealMessage}
|
||||
onMessageLongPress={handleMessageLongPress}
|
||||
/>
|
||||
|
||||
{/* Premium Input Bar */}
|
||||
@@ -260,6 +315,26 @@ export const ChatScreen: React.FC = () => {
|
||||
onRequestClose={() => setFullScreenImage(null)}
|
||||
>
|
||||
<View style={styles.fullScreenOverlay}>
|
||||
<TouchableOpacity
|
||||
style={styles.fullScreenSaveButton}
|
||||
onPress={() => fullScreenImage && saveMedia(fullScreenImage, 'image')}
|
||||
activeOpacity={0.8}
|
||||
disabled={mediaSaving}
|
||||
>
|
||||
<LinearGradient
|
||||
colors={gradients.premiumGold as [string, string]}
|
||||
style={styles.fullScreenCloseGradient}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
>
|
||||
{mediaSaving ? (
|
||||
<ActivityIndicator size="small" color={colors.text} />
|
||||
) : (
|
||||
<Ionicons name="download-outline" size={24} color={colors.text} />
|
||||
)}
|
||||
</LinearGradient>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
style={styles.fullScreenCloseButton}
|
||||
onPress={() => setFullScreenImage(null)}
|
||||
@@ -298,6 +373,26 @@ export const ChatScreen: React.FC = () => {
|
||||
onRequestClose={() => setFullScreenVideo(null)}
|
||||
>
|
||||
<View style={styles.fullScreenOverlay}>
|
||||
<TouchableOpacity
|
||||
style={styles.fullScreenSaveButton}
|
||||
onPress={() => fullScreenVideo && saveMedia(fullScreenVideo, 'video')}
|
||||
activeOpacity={0.8}
|
||||
disabled={mediaSaving}
|
||||
>
|
||||
<LinearGradient
|
||||
colors={gradients.premiumGold as [string, string]}
|
||||
style={styles.fullScreenCloseGradient}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
>
|
||||
{mediaSaving ? (
|
||||
<ActivityIndicator size="small" color={colors.text} />
|
||||
) : (
|
||||
<Ionicons name="download-outline" size={24} color={colors.text} />
|
||||
)}
|
||||
</LinearGradient>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
style={styles.fullScreenCloseButton}
|
||||
onPress={() => setFullScreenVideo(null)}
|
||||
@@ -365,6 +460,12 @@ const styles = StyleSheet.create({
|
||||
right: 20,
|
||||
zIndex: 10,
|
||||
},
|
||||
fullScreenSaveButton: {
|
||||
position: "absolute",
|
||||
top: 50,
|
||||
left: 20,
|
||||
zIndex: 10,
|
||||
},
|
||||
fullScreenCloseGradient: {
|
||||
width: 44,
|
||||
height: 44,
|
||||
|
||||
@@ -22,6 +22,8 @@ interface ChatMessagesProps {
|
||||
onImagePress: (uri: string) => void;
|
||||
onVideoFullScreen: (uri: string) => void;
|
||||
revealMessage: (messageId: string) => void;
|
||||
/** Called when a message bubble is long pressed */
|
||||
onMessageLongPress: (message: Message, isMe: boolean) => void;
|
||||
}
|
||||
|
||||
const ACCENT = colors.premium.gold;
|
||||
@@ -35,16 +37,22 @@ export const ChatMessages: React.FC<ChatMessagesProps> = ({
|
||||
onImagePress,
|
||||
onVideoFullScreen,
|
||||
revealMessage,
|
||||
onMessageLongPress,
|
||||
}) => {
|
||||
const flatListRef = useRef<FlatList>(null);
|
||||
|
||||
const renderMessage = ({ item, index }: { item: Message; index: number }) => {
|
||||
const isMe = item.user_id === userId;
|
||||
return (
|
||||
<MessageBubble isMe={isMe} index={index}>
|
||||
<MessageBubble
|
||||
isMe={isMe}
|
||||
index={index}
|
||||
onLongPress={() => onMessageLongPress(item, isMe)}
|
||||
>
|
||||
<MessageContent
|
||||
item={item}
|
||||
isMe={isMe}
|
||||
currentUserId={userId || ''}
|
||||
revealMessage={revealMessage}
|
||||
onImagePress={onImagePress}
|
||||
onVideoFullScreen={onVideoFullScreen}
|
||||
|
||||
@@ -38,9 +38,26 @@ export function ChatVideoPlayer({
|
||||
// Use video cache hook
|
||||
const { cachedUri, cacheVideoFile } = useVideoCache(storagePath, signedUrl);
|
||||
|
||||
// For encrypted media, signedUrl is already the decrypted local file URI
|
||||
// Skip the cache lookup for local file URIs
|
||||
const isLocalFile = signedUrl?.startsWith('file://');
|
||||
const videoSource = isLocalFile ? signedUrl : (cachedUri || signedUrl);
|
||||
|
||||
// Debug logging
|
||||
useEffect(() => {
|
||||
console.log(`[ChatVideoPlayer] isLocalFile=${isLocalFile}, storagePath=${storagePath}, signedUrl=${signedUrl?.substring(0, 60)}..., videoSource=${videoSource?.substring(0, 60)}...`);
|
||||
}, [storagePath, signedUrl, videoSource, isLocalFile]);
|
||||
|
||||
// Handle playback status updates
|
||||
const handlePlaybackStatusUpdate = useCallback(async (status: AVPlaybackStatus) => {
|
||||
if (!status.isLoaded) return;
|
||||
if (!status.isLoaded) {
|
||||
// Log error if load failed
|
||||
if ('error' in status && status.error) {
|
||||
console.error(`[ChatVideoPlayer] Video load error:`, status.error);
|
||||
setIsLoading(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(false);
|
||||
setIsPlaying(status.isPlaying);
|
||||
@@ -49,12 +66,12 @@ export function ChatVideoPlayer({
|
||||
setHasEnded(true);
|
||||
setIsPlaying(false);
|
||||
|
||||
// Cache in background if not already cached
|
||||
if (signedUrl && !cachedUri) {
|
||||
// Cache in background if not already cached (only for remote URLs)
|
||||
if (signedUrl && !cachedUri && !isLocalFile) {
|
||||
cacheVideoFile();
|
||||
}
|
||||
}
|
||||
}, [signedUrl, storagePath, cachedUri]);
|
||||
}, [signedUrl, storagePath, cachedUri, isLocalFile]);
|
||||
|
||||
// Toggle play/pause with native-like behavior
|
||||
const handleTapToPlay = useCallback(async () => {
|
||||
@@ -90,8 +107,6 @@ export function ChatVideoPlayer({
|
||||
}
|
||||
}, [cachedUri, signedUrl, isPlaying, onFullScreen]);
|
||||
|
||||
const videoSource = cachedUri || signedUrl;
|
||||
|
||||
if (urlError || !videoSource) {
|
||||
return (
|
||||
<View style={[styles.video, styles.errorContainer]}>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* Applies gradient styling for sender vs receiver messages.
|
||||
*/
|
||||
import React from 'react';
|
||||
import { View, StyleSheet } from 'react-native';
|
||||
import { View, StyleSheet, TouchableOpacity } from 'react-native';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import Animated, { FadeInUp } from 'react-native-reanimated';
|
||||
import { colors, gradients, spacing, radius } from '../../../theme';
|
||||
@@ -14,16 +14,23 @@ export interface MessageBubbleProps {
|
||||
isMe: boolean;
|
||||
index: number;
|
||||
children: React.ReactNode;
|
||||
/** Called when the message bubble is long pressed */
|
||||
onLongPress?: () => void;
|
||||
}
|
||||
|
||||
export function MessageBubble({ isMe, index, children }: MessageBubbleProps) {
|
||||
export function MessageBubble({ isMe, index, children, onLongPress }: MessageBubbleProps) {
|
||||
return (
|
||||
<Animated.View
|
||||
entering={FadeInUp.delay(index * 30).duration(200)}
|
||||
style={[styles.messageRow, isMe ? styles.myMessageRow : styles.theirMessageRow]}
|
||||
>
|
||||
{isMe ? (
|
||||
<View style={styles.myBubbleContainer}>
|
||||
<TouchableOpacity
|
||||
onLongPress={onLongPress}
|
||||
activeOpacity={0.9}
|
||||
delayLongPress={300}
|
||||
style={styles.myBubbleContainer}
|
||||
>
|
||||
<LinearGradient
|
||||
colors={gradients.primary as [string, string]}
|
||||
style={[styles.bubble, styles.myBubble]}
|
||||
@@ -39,9 +46,14 @@ export function MessageBubble({ isMe, index, children }: MessageBubbleProps) {
|
||||
/>
|
||||
{children}
|
||||
</LinearGradient>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
) : (
|
||||
<View style={styles.theirBubbleContainer}>
|
||||
<TouchableOpacity
|
||||
onLongPress={onLongPress}
|
||||
activeOpacity={0.9}
|
||||
delayLongPress={300}
|
||||
style={styles.theirBubbleContainer}
|
||||
>
|
||||
<View style={[styles.bubble, styles.theirBubble]}>
|
||||
{/* Subtle gradient background */}
|
||||
<LinearGradient
|
||||
@@ -59,7 +71,7 @@ export function MessageBubble({ isMe, index, children }: MessageBubbleProps) {
|
||||
/>
|
||||
{children}
|
||||
</View>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</Animated.View>
|
||||
);
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
/**
|
||||
* MessageContent - Renders message content (text, images, videos)
|
||||
* Handles media reveal logic, expired media, and loading states.
|
||||
* Supports both v1 (plaintext) and v2 (E2EE encrypted) messages.
|
||||
*/
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { View, TouchableOpacity, Text, StyleSheet, ActivityIndicator } from 'react-native';
|
||||
import React from 'react';
|
||||
import { View, TouchableOpacity, Text, StyleSheet, ActivityIndicator, Alert } from 'react-native';
|
||||
import { Image } from 'expo-image';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { colors, gradients, spacing, radius } from '../../../theme';
|
||||
import { getCachedSignedUrl, getStoragePath } from '../../../lib/imageCache';
|
||||
import { MessageMeta } from './MessageMeta';
|
||||
import { ChatVideoPlayer } from './ChatVideoPlayer';
|
||||
import { useDecryptedMessage, useDecryptedMedia } from '../../../hooks';
|
||||
import type { KeysMetadata } from '../../../lib/encryption';
|
||||
import type { Message } from '../types';
|
||||
|
||||
const ACCENT = colors.premium.gold;
|
||||
@@ -18,6 +20,7 @@ const ACCENT = colors.premium.gold;
|
||||
export interface MessageContentProps {
|
||||
item: Message;
|
||||
isMe: boolean;
|
||||
currentUserId: string;
|
||||
revealMessage: (id: string) => void;
|
||||
onImagePress: (uri: string) => void;
|
||||
onVideoFullScreen: (uri: string) => void;
|
||||
@@ -26,51 +29,82 @@ export interface MessageContentProps {
|
||||
export function MessageContent({
|
||||
item,
|
||||
isMe,
|
||||
currentUserId,
|
||||
revealMessage,
|
||||
onImagePress,
|
||||
onVideoFullScreen,
|
||||
}: MessageContentProps) {
|
||||
const [signedUrl, setSignedUrl] = useState<string | null>(null);
|
||||
const [urlError, setUrlError] = useState(false);
|
||||
// Check if message was deleted for everyone
|
||||
if (item.deleted_at) {
|
||||
return (
|
||||
<View style={styles.deletedContainer}>
|
||||
<Ionicons
|
||||
name="ban-outline"
|
||||
size={14}
|
||||
color={colors.textTertiary}
|
||||
/>
|
||||
<Text style={styles.deletedText}>
|
||||
{isMe ? 'You deleted this message' : 'This message was deleted'}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
// Use decryption hook for E2EE text messages
|
||||
const {
|
||||
content: decryptedContent,
|
||||
isDecrypting,
|
||||
error: decryptError,
|
||||
errorCode: decryptErrorCode,
|
||||
retry: retryDecrypt,
|
||||
} = useDecryptedMessage({
|
||||
message: {
|
||||
id: item.id,
|
||||
content: item.content,
|
||||
version: item.version,
|
||||
encrypted_content: item.encrypted_content,
|
||||
encryption_iv: item.encryption_iv,
|
||||
keys_metadata: item.keys_metadata as KeysMetadata | null,
|
||||
user_id: item.user_id,
|
||||
},
|
||||
currentUserId,
|
||||
});
|
||||
|
||||
const isVideo = (item as any).media_type === 'video';
|
||||
const isViewed = !!item.media_viewed_at;
|
||||
const isRecipientHidden = !isMe && !isViewed;
|
||||
|
||||
useEffect(() => {
|
||||
if (!item.media_path) {
|
||||
setSignedUrl(null);
|
||||
setUrlError(false);
|
||||
return;
|
||||
}
|
||||
// Use decryption hook for E2EE media (images/videos)
|
||||
const {
|
||||
uri: mediaUri,
|
||||
isDecrypting: isDecryptingMedia,
|
||||
error: mediaError,
|
||||
errorCode: mediaErrorCode,
|
||||
retry: retryMedia,
|
||||
isEncrypted: isMediaEncrypted,
|
||||
} = useDecryptedMedia({
|
||||
messageId: item.id,
|
||||
mediaPath: item.media_path ?? null,
|
||||
version: item.version,
|
||||
encryptionIv: item.encryption_iv,
|
||||
keysMetadata: item.keys_metadata as KeysMetadata | null,
|
||||
isMe,
|
||||
mediaType: isVideo ? 'video' : 'image',
|
||||
shouldFetch: !!item.media_path && !isRecipientHidden,
|
||||
});
|
||||
|
||||
if (isRecipientHidden) {
|
||||
setSignedUrl(null);
|
||||
setUrlError(false);
|
||||
return;
|
||||
}
|
||||
const isPendingMedia = mediaErrorCode === 'E2EE_PENDING_RECIPIENT_KEY';
|
||||
|
||||
let isMounted = true;
|
||||
|
||||
const fetchSignedUrl = async () => {
|
||||
const storagePath = getStoragePath(item.media_path!);
|
||||
const url = await getCachedSignedUrl(storagePath);
|
||||
|
||||
if (!isMounted) return;
|
||||
|
||||
if (url) {
|
||||
setSignedUrl(url);
|
||||
} else {
|
||||
setUrlError(true);
|
||||
}
|
||||
};
|
||||
|
||||
fetchSignedUrl();
|
||||
|
||||
return () => {
|
||||
isMounted = false;
|
||||
};
|
||||
}, [item.media_path, isRecipientHidden]);
|
||||
const showSecureMediaInfo = () => {
|
||||
Alert.alert(
|
||||
'Secure media',
|
||||
"To keep your chats private, photos and videos are locked to each person’s secure key.\n\nThis media was sent before your secure key was ready, so it’s still locked. We’re now securely updating the lock for your device (without exposing the media) so you can open it.\n\nIf it doesn’t unlock in a few seconds, tap “Try again”.",
|
||||
[
|
||||
{ text: 'OK', style: 'cancel' },
|
||||
{ text: 'Try again', onPress: () => retryMedia() },
|
||||
]
|
||||
);
|
||||
};
|
||||
|
||||
// Handle expired videos
|
||||
const isExpired = !!(item as any).media_expired;
|
||||
@@ -99,51 +133,63 @@ export function MessageContent({
|
||||
// Handle media (images and videos)
|
||||
if (item.media_path || isExpired) {
|
||||
const canOpenFullScreen = (isMe || isViewed) && !isVideo; // Only images can go fullscreen
|
||||
const hasMediaError = !!mediaError;
|
||||
const isLoading = isDecryptingMedia || (!mediaUri && !hasMediaError && !isRecipientHidden);
|
||||
|
||||
// Video content
|
||||
if (isVideo) {
|
||||
return (
|
||||
<View>
|
||||
{isRecipientHidden ? (
|
||||
// Blurred placeholder with reveal overlay for unrevealed videos
|
||||
<View>
|
||||
<View style={[styles.messageVideo, styles.videoBlurred]}>
|
||||
// Clean reveal UI for unrevealed videos
|
||||
<TouchableOpacity
|
||||
style={[styles.messageVideo, styles.unrevealedPlaceholder]}
|
||||
activeOpacity={0.8}
|
||||
onPress={() => revealMessage(item.id)}
|
||||
>
|
||||
<LinearGradient
|
||||
colors={['rgba(22, 33, 62, 0.95)', 'rgba(13, 13, 26, 0.98)']}
|
||||
style={StyleSheet.absoluteFill}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
/>
|
||||
<LinearGradient
|
||||
colors={gradients.premiumGold as [string, string]}
|
||||
style={styles.revealIconContainer}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
>
|
||||
<Ionicons name="eye-off-outline" size={24} color={colors.text} />
|
||||
</LinearGradient>
|
||||
<Text style={styles.unrevealedText}>Video hidden</Text>
|
||||
<Text style={styles.revealHint}>Tap to reveal</Text>
|
||||
</TouchableOpacity>
|
||||
) : isPendingMedia ? (
|
||||
<TouchableOpacity activeOpacity={0.85} onPress={showSecureMediaInfo}>
|
||||
<View style={[styles.messageVideo, styles.secureMediaPlaceholder]}>
|
||||
<LinearGradient
|
||||
colors={['rgba(22, 33, 62, 0.9)', 'rgba(13, 13, 26, 0.95)']}
|
||||
colors={['rgba(22, 33, 62, 0.85)', 'rgba(13, 13, 26, 0.95)']}
|
||||
style={StyleSheet.absoluteFill}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
/>
|
||||
<Ionicons name="videocam" size={40} color={colors.textTertiary} />
|
||||
<Ionicons name="lock-closed-outline" size={32} color={colors.textSecondary} />
|
||||
<Text style={styles.secureMediaPlaceholderText}>Secure video</Text>
|
||||
<Text style={styles.secureMediaPlaceholderSubtext}>Finishing security setup • Tap for info</Text>
|
||||
{isDecryptingMedia && <ActivityIndicator color={ACCENT} />}
|
||||
</View>
|
||||
<TouchableOpacity
|
||||
style={styles.revealOverlay}
|
||||
activeOpacity={0.8}
|
||||
onPress={() => revealMessage(item.id)}
|
||||
>
|
||||
<View style={styles.revealContent}>
|
||||
<LinearGradient
|
||||
colors={gradients.premiumGold as [string, string]}
|
||||
style={styles.revealIconContainer}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
>
|
||||
<Ionicons name="eye-off-outline" size={24} color={colors.text} />
|
||||
</LinearGradient>
|
||||
<Text style={styles.revealText}>Tap to reveal video</Text>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
) : signedUrl || urlError ? (
|
||||
</TouchableOpacity>
|
||||
) : mediaUri || hasMediaError ? (
|
||||
<ChatVideoPlayer
|
||||
signedUrl={signedUrl}
|
||||
signedUrl={mediaUri}
|
||||
storagePath={item.media_path!}
|
||||
urlError={urlError}
|
||||
urlError={hasMediaError}
|
||||
onFullScreen={onVideoFullScreen}
|
||||
/>
|
||||
) : (
|
||||
<View style={[styles.messageVideo, styles.messageImageLoading]}>
|
||||
<ActivityIndicator color={ACCENT} />
|
||||
<Text style={styles.decryptingText}>Loading...</Text>
|
||||
</View>
|
||||
)}
|
||||
{/* Viewed indicator for sender */}
|
||||
@@ -162,50 +208,30 @@ export function MessageContent({
|
||||
return (
|
||||
<View>
|
||||
<TouchableOpacity
|
||||
activeOpacity={canOpenFullScreen ? 0.8 : 1}
|
||||
onPress={() => canOpenFullScreen && signedUrl && onImagePress(signedUrl)}
|
||||
disabled={!canOpenFullScreen || !signedUrl}
|
||||
activeOpacity={isPendingMedia || canOpenFullScreen ? 0.8 : 1}
|
||||
onPress={() => {
|
||||
if (isPendingMedia) {
|
||||
showSecureMediaInfo();
|
||||
return;
|
||||
}
|
||||
if (canOpenFullScreen && mediaUri) {
|
||||
onImagePress(mediaUri);
|
||||
}
|
||||
}}
|
||||
disabled={!(isPendingMedia || (canOpenFullScreen && !!mediaUri))}
|
||||
>
|
||||
{isRecipientHidden ? (
|
||||
<View style={[styles.messageImage, styles.unrevealedPlaceholder]}>
|
||||
<TouchableOpacity
|
||||
style={[styles.messageImage, styles.unrevealedPlaceholder]}
|
||||
activeOpacity={0.8}
|
||||
onPress={() => revealMessage(item.id)}
|
||||
>
|
||||
<LinearGradient
|
||||
colors={['rgba(22, 33, 62, 0.95)', 'rgba(13, 13, 26, 0.98)']}
|
||||
style={StyleSheet.absoluteFill}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
/>
|
||||
<View style={styles.unrevealedIconContainer}>
|
||||
<Ionicons name="image-outline" size={28} color={colors.text} />
|
||||
</View>
|
||||
<Text style={styles.unrevealedText}>Photo hidden</Text>
|
||||
</View>
|
||||
) : signedUrl ? (
|
||||
<Image
|
||||
source={{ uri: signedUrl }}
|
||||
style={styles.messageImage}
|
||||
blurRadius={0}
|
||||
cachePolicy="disk"
|
||||
transition={200}
|
||||
/>
|
||||
) : urlError ? (
|
||||
<View style={[styles.messageImage, styles.messageImageError]}>
|
||||
<Ionicons name="image-outline" size={32} color={colors.textSecondary} />
|
||||
<Text style={styles.messageImageErrorText}>Image unavailable</Text>
|
||||
</View>
|
||||
) : (
|
||||
<View style={[styles.messageImage, styles.messageImageLoading]}>
|
||||
<ActivityIndicator color={ACCENT} />
|
||||
</View>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
{/* Reveal overlay for recipient */}
|
||||
{isRecipientHidden && (
|
||||
<TouchableOpacity
|
||||
style={styles.revealOverlay}
|
||||
activeOpacity={0.8}
|
||||
onPress={() => revealMessage(item.id)}
|
||||
>
|
||||
<View style={styles.revealContent}>
|
||||
<LinearGradient
|
||||
colors={gradients.premiumGold as [string, string]}
|
||||
style={styles.revealIconContainer}
|
||||
@@ -214,10 +240,42 @@ export function MessageContent({
|
||||
>
|
||||
<Ionicons name="eye-off-outline" size={24} color={colors.text} />
|
||||
</LinearGradient>
|
||||
<Text style={styles.revealText}>Tap to reveal</Text>
|
||||
<Text style={styles.unrevealedText}>Photo hidden</Text>
|
||||
<Text style={styles.revealHint}>Tap to reveal</Text>
|
||||
</TouchableOpacity>
|
||||
) : mediaUri ? (
|
||||
<Image
|
||||
source={{ uri: mediaUri }}
|
||||
style={styles.messageImage}
|
||||
blurRadius={0}
|
||||
cachePolicy="disk"
|
||||
transition={200}
|
||||
/>
|
||||
) : isPendingMedia ? (
|
||||
<View style={[styles.messageImage, styles.secureMediaPlaceholder]}>
|
||||
<LinearGradient
|
||||
colors={['rgba(22, 33, 62, 0.85)', 'rgba(13, 13, 26, 0.95)']}
|
||||
style={StyleSheet.absoluteFill}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
/>
|
||||
<Ionicons name="lock-closed-outline" size={32} color={colors.textSecondary} />
|
||||
<Text style={styles.secureMediaPlaceholderText}>Secure photo</Text>
|
||||
<Text style={styles.secureMediaPlaceholderSubtext}>Finishing security setup • Tap for info</Text>
|
||||
{isDecryptingMedia && <ActivityIndicator color={ACCENT} />}
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
) : hasMediaError ? (
|
||||
<View style={[styles.messageImage, styles.messageImageError]}>
|
||||
<Ionicons name="image-outline" size={32} color={colors.textSecondary} />
|
||||
<Text style={styles.messageImageErrorText}>Failed to load image</Text>
|
||||
</View>
|
||||
) : (
|
||||
<View style={[styles.messageImage, styles.messageImageLoading]}>
|
||||
<ActivityIndicator color={ACCENT} />
|
||||
<Text style={styles.decryptingText}>Loading...</Text>
|
||||
</View>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
{/* Viewed indicator for sender */}
|
||||
{isMe && isViewed && (
|
||||
<View style={styles.viewedBadge}>
|
||||
@@ -231,26 +289,139 @@ export function MessageContent({
|
||||
}
|
||||
|
||||
// Text message
|
||||
const version = item.version ?? 1;
|
||||
const isV2 = version === 2;
|
||||
|
||||
const showSecureMessageInfo = () => {
|
||||
Alert.alert(
|
||||
'Secure message',
|
||||
"To keep your chats private, each message is locked to your device’s secure key.\n\nThis message was sent before your secure key was ready, so it’s still locked. We’re now securely updating the lock for your device (without exposing the message content) so you can read it.\n\nIf it doesn’t unlock in a few seconds, tap “Try again”.",
|
||||
[
|
||||
{ text: 'OK', style: 'cancel' },
|
||||
{ text: 'Try again', onPress: () => retryDecrypt() },
|
||||
]
|
||||
);
|
||||
};
|
||||
|
||||
const renderText = () => {
|
||||
if (decryptErrorCode === 'E2EE_PENDING_RECIPIENT_KEY') {
|
||||
return (
|
||||
<TouchableOpacity activeOpacity={0.85} onPress={showSecureMessageInfo}>
|
||||
<View style={styles.securePlaceholder}>
|
||||
<View style={styles.securePlaceholderRow}>
|
||||
<Ionicons name="lock-closed-outline" size={16} color={colors.textSecondary} />
|
||||
<Text style={styles.securePlaceholderTitle}>Secure message</Text>
|
||||
{isDecrypting && <ActivityIndicator size="small" color={ACCENT} />}
|
||||
</View>
|
||||
<Text style={styles.securePlaceholderSubtext}>Finishing security setup • Tap for info</Text>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
}
|
||||
|
||||
if (isDecrypting) {
|
||||
return (
|
||||
<View style={styles.decryptingContainer}>
|
||||
<ActivityIndicator size="small" color={ACCENT} />
|
||||
<Text style={styles.decryptingText}>Loading...</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (decryptError) {
|
||||
return (
|
||||
<TouchableOpacity activeOpacity={0.85} onPress={retryDecrypt}>
|
||||
<Text style={[styles.messageText, styles.errorText]}>Couldn’t open secure message • Tap to retry</Text>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
}
|
||||
|
||||
if (isV2) {
|
||||
return <Text style={styles.messageText}>{decryptedContent ?? 'Secure message'}</Text>;
|
||||
}
|
||||
|
||||
return <Text style={styles.messageText}>{item.content ?? 'Failed to load message'}</Text>;
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Text style={styles.messageText}>{item.content}</Text>
|
||||
{renderText()}
|
||||
<MessageMeta item={item} isMe={isMe} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
deletedContainer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.xs,
|
||||
},
|
||||
deletedText: {
|
||||
color: colors.textTertiary,
|
||||
fontSize: 14,
|
||||
fontStyle: 'italic',
|
||||
},
|
||||
messageText: {
|
||||
color: colors.text,
|
||||
fontSize: 16,
|
||||
lineHeight: 22,
|
||||
},
|
||||
securePlaceholder: {
|
||||
gap: spacing.xs,
|
||||
},
|
||||
securePlaceholderRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.xs,
|
||||
},
|
||||
securePlaceholderTitle: {
|
||||
color: colors.textSecondary,
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
},
|
||||
securePlaceholderSubtext: {
|
||||
color: colors.textTertiary,
|
||||
fontSize: 12,
|
||||
},
|
||||
decryptingContainer: {
|
||||
paddingVertical: spacing.sm,
|
||||
paddingHorizontal: spacing.md,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
decryptingText: {
|
||||
color: colors.textSecondary,
|
||||
fontSize: 11,
|
||||
marginTop: spacing.xs,
|
||||
},
|
||||
errorText: {
|
||||
color: colors.textSecondary,
|
||||
fontStyle: 'italic',
|
||||
},
|
||||
messageImage: {
|
||||
width: 200,
|
||||
height: 200,
|
||||
borderRadius: radius.md,
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.05)',
|
||||
},
|
||||
secureMediaPlaceholder: {
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
gap: spacing.sm,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
secureMediaPlaceholderText: {
|
||||
color: colors.textSecondary,
|
||||
fontSize: 12,
|
||||
fontWeight: '600',
|
||||
},
|
||||
secureMediaPlaceholderSubtext: {
|
||||
color: colors.textTertiary,
|
||||
fontSize: 11,
|
||||
textAlign: 'center',
|
||||
paddingHorizontal: spacing.md,
|
||||
},
|
||||
messageImageLoading: {
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
@@ -270,21 +441,15 @@ const styles = StyleSheet.create({
|
||||
gap: spacing.sm,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
unrevealedIconContainer: {
|
||||
width: 44,
|
||||
height: 44,
|
||||
borderRadius: 22,
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.08)',
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(255, 255, 255, 0.15)',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
unrevealedText: {
|
||||
color: colors.textSecondary,
|
||||
fontSize: 12,
|
||||
color: colors.text,
|
||||
fontSize: 13,
|
||||
fontWeight: '600',
|
||||
letterSpacing: 0.3,
|
||||
},
|
||||
revealHint: {
|
||||
color: gradients.premiumGold[0],
|
||||
fontSize: 12,
|
||||
fontWeight: '500',
|
||||
},
|
||||
messageVideo: {
|
||||
width: 200,
|
||||
@@ -293,20 +458,6 @@ const styles = StyleSheet.create({
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.05)',
|
||||
overflow: 'hidden',
|
||||
},
|
||||
videoBlurred: {
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
revealOverlay: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
borderRadius: radius.md,
|
||||
},
|
||||
revealContent: {
|
||||
alignItems: 'center',
|
||||
gap: spacing.sm,
|
||||
},
|
||||
revealIconContainer: {
|
||||
width: 48,
|
||||
height: 48,
|
||||
@@ -314,11 +465,6 @@ const styles = StyleSheet.create({
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
revealText: {
|
||||
color: colors.text,
|
||||
fontSize: 13,
|
||||
fontWeight: '600',
|
||||
},
|
||||
viewedBadge: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
|
||||
@@ -7,10 +7,25 @@ import { decode } from 'base64-arraybuffer';
|
||||
import { supabase } from '../../../lib/supabase';
|
||||
import { Events } from '../../../lib/analytics';
|
||||
import { UploadStatus } from '../types';
|
||||
import { useAuthStore } from '../../../store';
|
||||
import {
|
||||
encryptMediaFile,
|
||||
getAdminPublicKey,
|
||||
getAdminKeyId,
|
||||
MESSAGE_VERSION_E2EE,
|
||||
} from '../../../lib/encryption';
|
||||
import type { RSAPublicKeyJWK, EncryptedMediaPayload } from '../../../lib/encryption';
|
||||
import { useEncryptionKeys } from '../../../hooks';
|
||||
|
||||
export function useMediaUpload(matchId: string, userId: string | undefined) {
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [uploadStatus, setUploadStatus] = useState<UploadStatus>(null);
|
||||
|
||||
const refreshPartner = useAuthStore((s) => s.refreshPartner);
|
||||
const { publicKeyJwk, hasKeys, isLoading: keysLoading } = useEncryptionKeys();
|
||||
|
||||
// Check if E2EE is available for media
|
||||
const isE2EEAvailable = Platform.OS !== 'web' && hasKeys && !keysLoading && !!publicKeyJwk;
|
||||
|
||||
const uploadMedia = async (uri: string, mediaType: 'image' | 'video') => {
|
||||
if (!userId) return;
|
||||
@@ -21,6 +36,7 @@ export function useMediaUpload(matchId: string, userId: string | undefined) {
|
||||
let fileUri = uri;
|
||||
let fileBody;
|
||||
let ext = mediaType === 'video' ? 'mp4' : 'jpg';
|
||||
let encryptedPayload: EncryptedMediaPayload | null = null;
|
||||
|
||||
// Compress videos on native platforms before upload
|
||||
if (mediaType === 'video' && Platform.OS !== 'web') {
|
||||
@@ -43,6 +59,49 @@ export function useMediaUpload(matchId: string, userId: string | undefined) {
|
||||
}
|
||||
}
|
||||
|
||||
// Try to encrypt the media if E2EE is available
|
||||
if (isE2EEAvailable && Platform.OS !== 'web') {
|
||||
try {
|
||||
// CRITICAL: Read keys directly from global store at encryption time
|
||||
// This ensures we always use the most current keys, not potentially stale hook state
|
||||
const currentKeys = useAuthStore.getState().encryptionKeys;
|
||||
|
||||
if (!currentKeys.hasKeys || !currentKeys.publicKeyJwk) {
|
||||
console.warn('[E2EE Media Upload] No keys available in global store at encryption time');
|
||||
throw new Error('No encryption keys available');
|
||||
}
|
||||
|
||||
const senderPublicKey = currentKeys.publicKeyJwk;
|
||||
console.log('[E2EE Media Upload] Using sender public key from global store, version:', currentKeys.keysVersion);
|
||||
|
||||
// Refresh partner's profile to get their latest public key
|
||||
console.log('[E2EE Media Upload] Refreshing partner profile...');
|
||||
const freshPartner = await refreshPartner();
|
||||
|
||||
const adminPublicKey = await getAdminPublicKey();
|
||||
const adminKeyId = await getAdminKeyId();
|
||||
const partnerPublicKey = freshPartner?.public_key_jwk as RSAPublicKeyJWK | null | undefined;
|
||||
|
||||
console.log('[E2EE Media Upload] Partner public key available:', !!partnerPublicKey);
|
||||
|
||||
encryptedPayload = await encryptMediaFile(
|
||||
fileUri,
|
||||
senderPublicKey as RSAPublicKeyJWK,
|
||||
partnerPublicKey ?? null,
|
||||
adminPublicKey,
|
||||
adminKeyId
|
||||
);
|
||||
|
||||
// Use the encrypted file for upload
|
||||
fileUri = encryptedPayload.encryptedFileUri;
|
||||
ext = 'enc'; // Encrypted files use .enc extension
|
||||
console.log('Media encrypted successfully');
|
||||
} catch (encryptError) {
|
||||
console.warn('Media encryption failed, uploading unencrypted:', encryptError);
|
||||
encryptedPayload = null;
|
||||
}
|
||||
}
|
||||
|
||||
if (Platform.OS === 'web') {
|
||||
const response = await fetch(fileUri);
|
||||
const blob = await response.blob();
|
||||
@@ -63,8 +122,8 @@ export function useMediaUpload(matchId: string, userId: string | undefined) {
|
||||
const base64 = await FileSystem.readAsStringAsync(fileUri, { encoding: FileSystem.EncodingType.Base64 });
|
||||
fileBody = decode(base64);
|
||||
|
||||
if (mediaType !== 'video') {
|
||||
// Only use URI extension for non-videos (videos are always mp4 after compression)
|
||||
if (mediaType !== 'video' && !encryptedPayload) {
|
||||
// Only use URI extension for non-videos and non-encrypted (videos are always mp4 after compression)
|
||||
const uriExt = fileUri.split('.').pop();
|
||||
if (uriExt && uriExt !== fileUri) {
|
||||
ext = uriExt.toLowerCase();
|
||||
@@ -73,9 +132,11 @@ export function useMediaUpload(matchId: string, userId: string | undefined) {
|
||||
}
|
||||
|
||||
const fileName = `${matchId}/${Date.now()}_${Math.random().toString(36).substring(7)}.${ext}`;
|
||||
const contentType = mediaType === 'video'
|
||||
? `video/${ext === 'mov' ? 'quicktime' : ext}`
|
||||
: `image/${ext === 'jpg' ? 'jpeg' : ext}`;
|
||||
const contentType = encryptedPayload
|
||||
? 'application/octet-stream' // Encrypted files are binary
|
||||
: mediaType === 'video'
|
||||
? `video/${ext === 'mov' ? 'quicktime' : ext}`
|
||||
: `image/${ext === 'jpg' ? 'jpeg' : ext}`;
|
||||
|
||||
const { error: uploadError } = await supabase.storage
|
||||
.from("chat-media")
|
||||
@@ -86,15 +147,39 @@ export function useMediaUpload(matchId: string, userId: string | undefined) {
|
||||
|
||||
if (uploadError) throw uploadError;
|
||||
|
||||
// Store just the path, not the full URL (bucket is private)
|
||||
await supabase.from("messages").insert({
|
||||
match_id: matchId,
|
||||
user_id: userId,
|
||||
content: mediaType === 'video' ? 'Sent a video' : 'Sent an image',
|
||||
media_path: fileName,
|
||||
media_type: mediaType,
|
||||
media_expired: false,
|
||||
});
|
||||
// Clean up encrypted temp file
|
||||
if (encryptedPayload && encryptedPayload.encryptedFileUri) {
|
||||
try {
|
||||
await FileSystem.deleteAsync(encryptedPayload.encryptedFileUri, { idempotent: true });
|
||||
} catch (cleanupError) {
|
||||
console.warn('Failed to clean up encrypted temp file:', cleanupError);
|
||||
}
|
||||
}
|
||||
|
||||
// Store the message with encryption metadata if encrypted
|
||||
if (encryptedPayload) {
|
||||
await supabase.from("messages").insert({
|
||||
match_id: matchId,
|
||||
user_id: userId,
|
||||
content: mediaType === 'video' ? 'Sent a video' : 'Sent an image',
|
||||
media_path: fileName,
|
||||
media_type: mediaType,
|
||||
media_expired: false,
|
||||
version: MESSAGE_VERSION_E2EE,
|
||||
encryption_iv: encryptedPayload.encryption_iv,
|
||||
keys_metadata: encryptedPayload.keys_metadata as unknown as Record<string, unknown>,
|
||||
});
|
||||
} else {
|
||||
// Unencrypted upload (v1)
|
||||
await supabase.from("messages").insert({
|
||||
match_id: matchId,
|
||||
user_id: userId,
|
||||
content: mediaType === 'video' ? 'Sent a video' : 'Sent an image',
|
||||
media_path: fileName,
|
||||
media_type: mediaType,
|
||||
media_expired: false,
|
||||
});
|
||||
}
|
||||
Events.mediaUploaded();
|
||||
|
||||
} catch (error) {
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* useMessageActions - Hook for message deletion actions
|
||||
* Handles "delete for self" and "delete for everyone" functionality.
|
||||
*/
|
||||
import { Platform, ActionSheetIOS, Alert } from 'react-native';
|
||||
import * as Haptics from 'expo-haptics';
|
||||
import { supabase } from '../../../lib/supabase';
|
||||
import type { Message } from '../types';
|
||||
|
||||
interface UseMessageActionsConfig {
|
||||
userId: string | undefined;
|
||||
}
|
||||
|
||||
export function useMessageActions({ userId }: UseMessageActionsConfig) {
|
||||
/**
|
||||
* Delete a message for the current user only.
|
||||
* Message will still be visible to the partner.
|
||||
*/
|
||||
const deleteForSelf = async (messageId: string) => {
|
||||
if (!userId) return;
|
||||
|
||||
const { error } = await supabase
|
||||
.from('message_deletions')
|
||||
.insert({ message_id: messageId, user_id: userId });
|
||||
|
||||
if (error) {
|
||||
Alert.alert('Error', 'Failed to delete message');
|
||||
console.error('Delete for self error:', error);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Delete a message for everyone (author only).
|
||||
* Message will show as "deleted" to both users.
|
||||
*/
|
||||
const deleteForEveryone = async (messageId: string) => {
|
||||
const { error } = await supabase
|
||||
.from('messages')
|
||||
.update({ deleted_at: new Date().toISOString() })
|
||||
.eq('id', messageId);
|
||||
|
||||
if (error) {
|
||||
Alert.alert('Error', 'Failed to delete message');
|
||||
console.error('Delete for everyone error:', error);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Show delete options for a message.
|
||||
* Author gets both options, non-author only gets "delete for me".
|
||||
*/
|
||||
const showDeleteOptions = (message: Message, isMe: boolean) => {
|
||||
// Don't show options for already deleted messages
|
||||
if (message.deleted_at) return;
|
||||
|
||||
// Haptic feedback
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
|
||||
|
||||
if (Platform.OS === 'ios') {
|
||||
const options = ['Cancel', 'Delete for me'];
|
||||
if (isMe) options.push('Delete for everyone');
|
||||
|
||||
ActionSheetIOS.showActionSheetWithOptions(
|
||||
{
|
||||
options,
|
||||
destructiveButtonIndex: isMe ? 2 : 1,
|
||||
cancelButtonIndex: 0,
|
||||
title: 'Delete message?',
|
||||
},
|
||||
(buttonIndex) => {
|
||||
if (buttonIndex === 1) {
|
||||
deleteForSelf(message.id);
|
||||
} else if (buttonIndex === 2 && isMe) {
|
||||
deleteForEveryone(message.id);
|
||||
}
|
||||
}
|
||||
);
|
||||
} else {
|
||||
// Android fallback using Alert
|
||||
const buttons: Array<{ text: string; style?: 'cancel' | 'default' | 'destructive'; onPress?: () => void }> = [
|
||||
{ text: 'Cancel', style: 'cancel' },
|
||||
{
|
||||
text: 'Delete for me',
|
||||
style: 'destructive',
|
||||
onPress: () => deleteForSelf(message.id),
|
||||
},
|
||||
];
|
||||
|
||||
if (isMe) {
|
||||
buttons.push({
|
||||
text: 'Delete for everyone',
|
||||
style: 'destructive',
|
||||
onPress: () => deleteForEveryone(message.id),
|
||||
});
|
||||
}
|
||||
|
||||
Alert.alert('Delete message?', undefined, buttons);
|
||||
}
|
||||
};
|
||||
|
||||
return { showDeleteOptions, deleteForSelf, deleteForEveryone };
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import { useAuthStore, useSubscriptionStore } from '../../store';
|
||||
import { resetSwipeTutorial } from '../../lib/swipeTutorialSeen';
|
||||
import { resetMatchesTutorial } from '../../lib/matchesTutorialSeen';
|
||||
import { supabase } from '../../lib/supabase'; // Needed for debug reset
|
||||
import { clearKeys } from '../../lib/encryption';
|
||||
|
||||
// Hooks
|
||||
import { useProfileSettings, useCoupleManagement } from './hooks';
|
||||
@@ -296,6 +297,30 @@ export function ProfileScreen() {
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<View style={styles.preferencesDivider} />
|
||||
<MenuItem
|
||||
icon="key-outline"
|
||||
label="Clear Private Key"
|
||||
description="Remove E2EE keys from device"
|
||||
onPress={() => {
|
||||
Alert.alert(
|
||||
"Clear Keys",
|
||||
"Are you sure? You will lose access to encrypted messages until keys are regenerated.",
|
||||
[
|
||||
{ text: "Cancel", style: "cancel" },
|
||||
{
|
||||
text: "Clear",
|
||||
style: "destructive",
|
||||
onPress: async () => {
|
||||
await clearKeys();
|
||||
Alert.alert("Success", "Keys cleared. They will be regenerated on next launch/login.");
|
||||
}
|
||||
}
|
||||
]
|
||||
);
|
||||
}}
|
||||
variant="danger"
|
||||
/>
|
||||
</SettingsSection>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
export { useAmbientOrbAnimation } from './useAmbientOrbAnimation';
|
||||
export type { AmbientOrbConfig } from './useAmbientOrbAnimation';
|
||||
export { useFeatureInterest } from './useFeatureInterest';
|
||||
export { useMediaPicker } from './useMediaPicker';
|
||||
export type { MediaPickerConfig, MediaResult } from './useMediaPicker';
|
||||
export { useTypingIndicator } from './useTypingIndicator';
|
||||
export type { UseTypingIndicatorConfig, UseTypingIndicatorReturn } from './useTypingIndicator';
|
||||
export { useMessageSubscription } from './useMessageSubscription';
|
||||
export type { UseMessageSubscriptionConfig, UseMessageSubscriptionReturn } from './useMessageSubscription';
|
||||
export { useAmbientOrbAnimation } from './useAmbientOrbAnimation';
|
||||
export type { AmbientOrbConfig } from './useAmbientOrbAnimation';
|
||||
export { useFeatureInterest } from './useFeatureInterest';
|
||||
export { useMediaPicker } from './useMediaPicker';
|
||||
export type { MediaPickerConfig, MediaResult } from './useMediaPicker';
|
||||
export { useTypingIndicator } from './useTypingIndicator';
|
||||
export type { UseTypingIndicatorConfig, UseTypingIndicatorReturn } from './useTypingIndicator';
|
||||
export { useMessageSubscription } from './useMessageSubscription';
|
||||
export type { UseMessageSubscriptionConfig, UseMessageSubscriptionReturn } from './useMessageSubscription';
|
||||
export { useEncryptionKeys } from './useEncryptionKeys';
|
||||
export { useDecryptedMessage } from './useDecryptedMessage';
|
||||
export { useEncryptedSend } from './useEncryptedSend';
|
||||
export { useDecryptedMedia, clearDecryptedMediaCache } from './useDecryptedMedia';
|
||||
export { useMediaSaver } from './useMediaSaver';
|
||||
@@ -0,0 +1,332 @@
|
||||
/**
|
||||
* useDecryptedMedia Hook
|
||||
*
|
||||
* React hook for decrypting E2EE encrypted media files (images/videos).
|
||||
* Handles downloading, decrypting, and caching decrypted media.
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { Platform } from 'react-native';
|
||||
import * as FileSystem from 'expo-file-system';
|
||||
import { decryptMediaFile, cleanupDecryptedMedia, MESSAGE_VERSION_E2EE } from '../lib/encryption';
|
||||
import type { KeysMetadata, RSAPrivateKeyJWK } from '../lib/encryption';
|
||||
import { useEncryptionKeys } from './useEncryptionKeys';
|
||||
import { getCachedSignedUrl, getStoragePath } from '../lib/imageCache';
|
||||
import { supabase } from '../lib/supabase';
|
||||
import { reencryptPendingMessages } from '../lib/encryption/reencryptPendingMessages';
|
||||
|
||||
export type DecryptedMediaErrorCode =
|
||||
| 'E2EE_NOT_SUPPORTED'
|
||||
| 'E2EE_KEYS_UNAVAILABLE'
|
||||
| 'E2EE_MISSING_FIELDS'
|
||||
| 'E2EE_PENDING_RECIPIENT_KEY'
|
||||
| 'E2EE_DECRYPT_FAILED';
|
||||
|
||||
// In-memory cache to avoid re-decrypting the same media
|
||||
const decryptedMediaCache = new Map<string, string>();
|
||||
|
||||
interface DecryptedMediaState {
|
||||
/** Local file URI to the decrypted media (or null if not ready) */
|
||||
uri: string | null;
|
||||
/** Whether decryption is in progress */
|
||||
isDecrypting: boolean;
|
||||
/** Error if decryption failed */
|
||||
error: Error | null;
|
||||
/** Optional error code for UX */
|
||||
errorCode?: DecryptedMediaErrorCode;
|
||||
}
|
||||
|
||||
interface UseDecryptedMediaOptions {
|
||||
/** The message ID (used for cache key) */
|
||||
messageId: string;
|
||||
/** Path to the media file in storage */
|
||||
mediaPath: string | null;
|
||||
/** Message version (1 = plaintext, 2 = encrypted) */
|
||||
version: number | null | undefined;
|
||||
/** Encryption IV (required for v2) */
|
||||
encryptionIv: string | null | undefined;
|
||||
/** Keys metadata containing wrapped keys (required for v2) */
|
||||
keysMetadata: KeysMetadata | null | undefined;
|
||||
/** Whether current user is the sender */
|
||||
isMe: boolean;
|
||||
/** Media type */
|
||||
mediaType: 'image' | 'video';
|
||||
/** Whether media should be fetched (e.g., not hidden) */
|
||||
shouldFetch: boolean;
|
||||
}
|
||||
|
||||
export type UseDecryptedMediaResult = DecryptedMediaState & {
|
||||
isEncrypted: boolean;
|
||||
retry: () => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to decrypt encrypted media files
|
||||
*/
|
||||
export function useDecryptedMedia({
|
||||
messageId,
|
||||
mediaPath,
|
||||
version,
|
||||
encryptionIv,
|
||||
keysMetadata,
|
||||
isMe,
|
||||
mediaType,
|
||||
shouldFetch,
|
||||
}: UseDecryptedMediaOptions): UseDecryptedMediaResult {
|
||||
const [state, setState] = useState<DecryptedMediaState>({
|
||||
uri: null,
|
||||
isDecrypting: false,
|
||||
error: null,
|
||||
});
|
||||
|
||||
const { privateKeyJwk, hasKeys, isLoading: keysLoading } = useEncryptionKeys();
|
||||
const isMounted = useRef(true);
|
||||
|
||||
const repairAttemptedByMessageId = useRef(new Set<string>());
|
||||
const [retryNonce, setRetryNonce] = useState(0);
|
||||
|
||||
const retry = useCallback(() => {
|
||||
repairAttemptedByMessageId.current.delete(messageId);
|
||||
setRetryNonce((n) => n + 1);
|
||||
}, [messageId]);
|
||||
|
||||
// Determine if this is encrypted media
|
||||
const isEncrypted = version === MESSAGE_VERSION_E2EE && (mediaPath?.endsWith('.enc') ?? false);
|
||||
|
||||
const decryptMedia = useCallback(async () => {
|
||||
// No media path - nothing to do
|
||||
if (!mediaPath || !shouldFetch) {
|
||||
setState({ uri: null, isDecrypting: false, error: null, errorCode: undefined });
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(
|
||||
`[E2EE Media] Message ${messageId}: Starting decrypt - version=${version}, mediaPath=${mediaPath}, isEncrypted=${isEncrypted}`,
|
||||
);
|
||||
|
||||
// Check cache first
|
||||
const cacheKey = `${messageId}-${mediaPath}`;
|
||||
const cachedUri = decryptedMediaCache.get(cacheKey);
|
||||
if (cachedUri) {
|
||||
try {
|
||||
const fileInfo = await FileSystem.getInfoAsync(cachedUri);
|
||||
if (fileInfo.exists) {
|
||||
console.log(`[E2EE Media] Message ${messageId}: Using cached decrypted file`);
|
||||
setState({ uri: cachedUri, isDecrypting: false, error: null, errorCode: undefined });
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
decryptedMediaCache.delete(cacheKey);
|
||||
}
|
||||
}
|
||||
|
||||
// v1 (plaintext) or non-.enc files: Use existing signed URL flow
|
||||
if (!isEncrypted) {
|
||||
console.log(`[E2EE Media] Message ${messageId}: Not encrypted, using signed URL flow`);
|
||||
try {
|
||||
const storagePath = getStoragePath(mediaPath);
|
||||
const signedUrl = await getCachedSignedUrl(storagePath);
|
||||
if (isMounted.current) {
|
||||
setState({
|
||||
uri: signedUrl,
|
||||
isDecrypting: false,
|
||||
error: signedUrl ? null : new Error('Failed to get signed URL'),
|
||||
errorCode: signedUrl ? undefined : 'E2EE_DECRYPT_FAILED',
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`[E2EE Media] Message ${messageId}: Failed to get signed URL:`, err);
|
||||
if (isMounted.current) {
|
||||
setState({
|
||||
uri: null,
|
||||
isDecrypting: false,
|
||||
error: err as Error,
|
||||
errorCode: 'E2EE_DECRYPT_FAILED',
|
||||
});
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// v2 (encrypted) media - requires decryption
|
||||
console.log(`[E2EE Media] Message ${messageId}: Encrypted media, starting decryption`);
|
||||
|
||||
if (Platform.OS === 'web') {
|
||||
setState({
|
||||
uri: null,
|
||||
isDecrypting: false,
|
||||
error: new Error('Failed to load media'),
|
||||
errorCode: 'E2EE_NOT_SUPPORTED',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Wait for keys to load
|
||||
if (keysLoading) {
|
||||
console.log(`[E2EE Media] Message ${messageId}: Keys still loading...`);
|
||||
setState((prev) => ({ ...prev, isDecrypting: true }));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!hasKeys || !privateKeyJwk) {
|
||||
console.log(
|
||||
`[E2EE Media] Message ${messageId}: No keys available - hasKeys=${hasKeys}, hasPrivateKey=${!!privateKeyJwk}`,
|
||||
);
|
||||
setState({
|
||||
uri: null,
|
||||
isDecrypting: false,
|
||||
error: new Error('Failed to load media'),
|
||||
errorCode: 'E2EE_KEYS_UNAVAILABLE',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate required encryption fields
|
||||
if (!encryptionIv || !keysMetadata) {
|
||||
console.log(
|
||||
`[E2EE Media] Message ${messageId}: Missing encryption metadata - iv=${!!encryptionIv}, keysMetadata=${!!keysMetadata}`,
|
||||
);
|
||||
setState({
|
||||
uri: null,
|
||||
isDecrypting: false,
|
||||
error: new Error('Failed to load media'),
|
||||
errorCode: 'E2EE_MISSING_FIELDS',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// If the recipient key is missing, try server-assisted repair (admin recovery model).
|
||||
let effectiveKeysMetadata: KeysMetadata | null = keysMetadata;
|
||||
|
||||
const getWrappedKey = (metadata: KeysMetadata | null): string | undefined =>
|
||||
isMe ? metadata?.sender_wrapped_key : metadata?.recipient_wrapped_key;
|
||||
|
||||
let wrappedKey = getWrappedKey(effectiveKeysMetadata);
|
||||
|
||||
if (!wrappedKey) {
|
||||
console.log(
|
||||
`[E2EE Media] Message ${messageId}: No wrapped key for ${isMe ? 'sender' : 'recipient'} - pending_recipient=${(effectiveKeysMetadata as any)?.pending_recipient}`,
|
||||
);
|
||||
|
||||
if (!isMe) {
|
||||
setState({
|
||||
uri: null,
|
||||
isDecrypting: true,
|
||||
error: null,
|
||||
errorCode: 'E2EE_PENDING_RECIPIENT_KEY',
|
||||
});
|
||||
|
||||
if (!repairAttemptedByMessageId.current.has(messageId)) {
|
||||
repairAttemptedByMessageId.current.add(messageId);
|
||||
await reencryptPendingMessages();
|
||||
}
|
||||
|
||||
const { data: refreshed } = await supabase
|
||||
.from('messages')
|
||||
.select('keys_metadata')
|
||||
.eq('id', messageId)
|
||||
.maybeSingle();
|
||||
|
||||
effectiveKeysMetadata = (refreshed?.keys_metadata ?? null) as KeysMetadata | null;
|
||||
wrappedKey = getWrappedKey(effectiveKeysMetadata);
|
||||
}
|
||||
|
||||
if (!wrappedKey) {
|
||||
setState({
|
||||
uri: null,
|
||||
isDecrypting: false,
|
||||
error: null,
|
||||
errorCode: 'E2EE_PENDING_RECIPIENT_KEY',
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setState((prev) => ({ ...prev, isDecrypting: true, error: null, errorCode: undefined }));
|
||||
|
||||
try {
|
||||
// Get signed URL for the encrypted file
|
||||
const storagePath = getStoragePath(mediaPath);
|
||||
console.log(`[E2EE Media] Message ${messageId}: Getting signed URL for ${storagePath}`);
|
||||
const encryptedUrl = await getCachedSignedUrl(storagePath);
|
||||
|
||||
if (!encryptedUrl) {
|
||||
throw new Error('Failed to load media');
|
||||
}
|
||||
|
||||
console.log(`[E2EE Media] Message ${messageId}: Decrypting ${mediaType}...`);
|
||||
|
||||
const decryptedUri = await decryptMediaFile(
|
||||
encryptedUrl,
|
||||
encryptionIv,
|
||||
effectiveKeysMetadata as KeysMetadata,
|
||||
privateKeyJwk as RSAPrivateKeyJWK,
|
||||
!isMe, // isRecipient
|
||||
mediaType,
|
||||
);
|
||||
|
||||
console.log(`[E2EE Media] Message ${messageId}: Decryption successful - ${decryptedUri}`);
|
||||
|
||||
decryptedMediaCache.set(cacheKey, decryptedUri);
|
||||
|
||||
if (isMounted.current) {
|
||||
setState({
|
||||
uri: decryptedUri,
|
||||
isDecrypting: false,
|
||||
error: null,
|
||||
errorCode: undefined,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`[E2EE Media] Message ${messageId}: Decryption failed:`, err);
|
||||
if (isMounted.current) {
|
||||
setState({
|
||||
uri: null,
|
||||
isDecrypting: false,
|
||||
error: new Error('Failed to load media'),
|
||||
errorCode: 'E2EE_DECRYPT_FAILED',
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [
|
||||
messageId,
|
||||
mediaPath,
|
||||
version,
|
||||
encryptionIv,
|
||||
keysMetadata,
|
||||
isMe,
|
||||
mediaType,
|
||||
shouldFetch,
|
||||
isEncrypted,
|
||||
privateKeyJwk,
|
||||
hasKeys,
|
||||
keysLoading,
|
||||
retryNonce,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
isMounted.current = true;
|
||||
decryptMedia();
|
||||
|
||||
return () => {
|
||||
isMounted.current = false;
|
||||
};
|
||||
}, [decryptMedia, retryNonce]);
|
||||
|
||||
return {
|
||||
...state,
|
||||
isEncrypted,
|
||||
retry,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the decrypted media cache
|
||||
* Call this when logging out or when memory is low
|
||||
*/
|
||||
export async function clearDecryptedMediaCache(): Promise<void> {
|
||||
for (const uri of decryptedMediaCache.values()) {
|
||||
await cleanupDecryptedMedia(uri);
|
||||
}
|
||||
decryptedMediaCache.clear();
|
||||
}
|
||||
@@ -0,0 +1,364 @@
|
||||
/**
|
||||
* useDecryptedMessage Hook
|
||||
*
|
||||
* React hook for decrypting E2EE messages.
|
||||
* Handles both v1 (plaintext) and v2 (encrypted) messages.
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { Platform } from 'react-native';
|
||||
import {
|
||||
decryptTextMessage,
|
||||
MESSAGE_VERSION_PLAINTEXT,
|
||||
MESSAGE_VERSION_E2EE,
|
||||
repairStaleKey,
|
||||
} from '../lib/encryption';
|
||||
import type {
|
||||
DecryptedMessageState,
|
||||
KeysMetadata,
|
||||
RSAPrivateKeyJWK,
|
||||
} from '../lib/encryption';
|
||||
import { supabase } from '../lib/supabase';
|
||||
import { reencryptPendingMessages } from '../lib/encryption/reencryptPendingMessages';
|
||||
import { useEncryptionKeys } from './useEncryptionKeys';
|
||||
|
||||
interface Message {
|
||||
id: string;
|
||||
content: string | null;
|
||||
version?: number | null;
|
||||
encrypted_content?: string | null;
|
||||
encryption_iv?: string | null;
|
||||
keys_metadata?: KeysMetadata | null;
|
||||
user_id: string;
|
||||
}
|
||||
|
||||
interface UseDecryptedMessageOptions {
|
||||
/** The message to decrypt */
|
||||
message: Message;
|
||||
/** Current user's ID to determine if sender or recipient */
|
||||
currentUserId: string;
|
||||
}
|
||||
|
||||
export type UseDecryptedMessageResult = DecryptedMessageState & {
|
||||
/** Force a re-attempt (used by UI retry affordances) */
|
||||
retry: () => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to decrypt a message
|
||||
*
|
||||
* Automatically handles:
|
||||
* - v1 (plaintext) messages: returns content directly
|
||||
* - v2 (encrypted) messages: decrypts using private key
|
||||
* - Server-assisted re-wrap for pending recipient keys (admin recovery model)
|
||||
*/
|
||||
export function useDecryptedMessage({
|
||||
message,
|
||||
currentUserId,
|
||||
}: UseDecryptedMessageOptions): UseDecryptedMessageResult {
|
||||
const [state, setState] = useState<DecryptedMessageState>({
|
||||
content: null,
|
||||
mediaUri: null,
|
||||
isDecrypting: false,
|
||||
error: null,
|
||||
});
|
||||
|
||||
const { privateKeyJwk, hasKeys, isLoading: keysLoading } = useEncryptionKeys();
|
||||
|
||||
// Track per-message repair attempts to avoid tight retry loops.
|
||||
const repairAttemptedByMessageId = useRef(new Set<string>());
|
||||
const [retryNonce, setRetryNonce] = useState(0);
|
||||
|
||||
const retry = useCallback(() => {
|
||||
repairAttemptedByMessageId.current.delete(message.id);
|
||||
setRetryNonce((n) => n + 1);
|
||||
}, [message.id]);
|
||||
|
||||
const decrypt = useCallback(async () => {
|
||||
// Version 1 or undefined: Legacy plaintext message
|
||||
const version = message.version ?? MESSAGE_VERSION_PLAINTEXT;
|
||||
|
||||
console.log(
|
||||
`[E2EE Decrypt] Message ${message.id}: version=${version}, hasEncryptedContent=${!!message.encrypted_content}, hasIV=${!!message.encryption_iv}, hasKeysMetadata=${!!message.keys_metadata}`,
|
||||
);
|
||||
|
||||
if (version === MESSAGE_VERSION_PLAINTEXT) {
|
||||
console.log(`[E2EE Decrypt] Message ${message.id}: Using plaintext content`);
|
||||
setState({
|
||||
content: message.content,
|
||||
mediaUri: null,
|
||||
isDecrypting: false,
|
||||
error: null,
|
||||
errorCode: undefined,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Version 2: E2EE - requires decryption
|
||||
if (version === MESSAGE_VERSION_E2EE) {
|
||||
console.log(`[E2EE Decrypt] Message ${message.id}: Attempting E2EE decryption`);
|
||||
|
||||
// E2EE not supported on web
|
||||
if (Platform.OS === 'web') {
|
||||
console.log(`[E2EE Decrypt] Message ${message.id}: Web platform - E2EE not supported`);
|
||||
setState({
|
||||
content: null,
|
||||
mediaUri: null,
|
||||
isDecrypting: false,
|
||||
error: new Error('Failed to load message'),
|
||||
errorCode: 'E2EE_NOT_SUPPORTED',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Wait for keys to load
|
||||
if (keysLoading) {
|
||||
console.log(`[E2EE Decrypt] Message ${message.id}: Keys still loading, waiting...`);
|
||||
setState((prev) => ({ ...prev, isDecrypting: true }));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!hasKeys || !privateKeyJwk) {
|
||||
console.log(
|
||||
`[E2EE Decrypt] Message ${message.id}: No keys available - hasKeys=${hasKeys}, hasPrivateKey=${!!privateKeyJwk}`,
|
||||
);
|
||||
setState({
|
||||
content: null,
|
||||
mediaUri: null,
|
||||
isDecrypting: false,
|
||||
error: new Error('Failed to load message'),
|
||||
errorCode: 'E2EE_KEYS_UNAVAILABLE',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate required encryption fields
|
||||
if (!message.encrypted_content || !message.encryption_iv || !message.keys_metadata) {
|
||||
console.log(
|
||||
`[E2EE Decrypt] Message ${message.id}: Missing encryption fields - encrypted_content=${!!message.encrypted_content}, encryption_iv=${!!message.encryption_iv}, keys_metadata=${!!message.keys_metadata}`,
|
||||
);
|
||||
setState({
|
||||
content: null,
|
||||
mediaUri: null,
|
||||
isDecrypting: false,
|
||||
error: new Error('Failed to load message'),
|
||||
errorCode: 'E2EE_MISSING_FIELDS',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setState((prev) => ({ ...prev, isDecrypting: true, error: null, errorCode: undefined }));
|
||||
|
||||
try {
|
||||
const isMe = message.user_id === currentUserId;
|
||||
console.log(`[E2EE Decrypt] Message ${message.id}: Decrypting as ${isMe ? 'sender' : 'recipient'}`);
|
||||
console.log(
|
||||
`[E2EE Decrypt] Message ${message.id}: keys_metadata=`,
|
||||
JSON.stringify(message.keys_metadata, null, 2),
|
||||
);
|
||||
|
||||
const decryptedContent = await decryptTextMessage(
|
||||
message.encrypted_content,
|
||||
message.encryption_iv,
|
||||
message.keys_metadata as KeysMetadata,
|
||||
privateKeyJwk as RSAPrivateKeyJWK,
|
||||
!isMe, // isRecipient
|
||||
);
|
||||
|
||||
console.log(
|
||||
`[E2EE Decrypt] Message ${message.id}: Decryption successful, content length=${decryptedContent.length}`,
|
||||
);
|
||||
setState({
|
||||
content: decryptedContent,
|
||||
mediaUri: null,
|
||||
isDecrypting: false,
|
||||
error: null,
|
||||
errorCode: undefined,
|
||||
});
|
||||
} catch (err) {
|
||||
const error = err instanceof Error ? err : new Error('Unknown error');
|
||||
console.error(`[E2EE Decrypt] Message ${message.id}: Decryption failed:`, error);
|
||||
|
||||
const isMe = message.user_id === currentUserId;
|
||||
const isMissingWrappedKey = error.message.includes('No wrapped key available for decryption');
|
||||
const metadata = message.keys_metadata as KeysMetadata;
|
||||
|
||||
// Check if this is a stale key failure (message encrypted with old key)
|
||||
// This happens when: message was composed offline, recipient regenerated keys before sync
|
||||
const isStaleKeyFailure =
|
||||
error.name === 'OperationError' ||
|
||||
error.message.includes('OperationError') ||
|
||||
error.message.includes('decryption failed') ||
|
||||
error.message.includes('decrypt error') ||
|
||||
// react-native-quick-crypto specific error for RSA decryption failure
|
||||
error.message.includes('error in DoCipher');
|
||||
|
||||
// Case 1: Missing wrapped key (pending_recipient)
|
||||
// - Partner sent message before recipient had keys
|
||||
// - keys_metadata has no recipient_wrapped_key
|
||||
const shouldAttemptPendingRepair =
|
||||
!isMe &&
|
||||
isMissingWrappedKey &&
|
||||
(!!metadata?.pending_recipient || !metadata?.recipient_wrapped_key);
|
||||
|
||||
// Case 2: Stale key failure
|
||||
// - Message was encrypted with old public key
|
||||
// - Need admin to re-wrap with current key
|
||||
const shouldAttemptStaleKeyRepair =
|
||||
isStaleKeyFailure &&
|
||||
!isMissingWrappedKey &&
|
||||
!repairAttemptedByMessageId.current.has(message.id);
|
||||
|
||||
if (shouldAttemptPendingRepair) {
|
||||
setState({
|
||||
content: null,
|
||||
mediaUri: null,
|
||||
isDecrypting: true,
|
||||
error: null,
|
||||
errorCode: 'E2EE_PENDING_RECIPIENT_KEY',
|
||||
});
|
||||
|
||||
if (!repairAttemptedByMessageId.current.has(message.id)) {
|
||||
repairAttemptedByMessageId.current.add(message.id);
|
||||
await reencryptPendingMessages();
|
||||
}
|
||||
|
||||
// Fetch the latest keys_metadata and retry once locally.
|
||||
const { data: refreshed } = await supabase
|
||||
.from('messages')
|
||||
.select('encrypted_content, encryption_iv, keys_metadata, user_id')
|
||||
.eq('id', message.id)
|
||||
.maybeSingle();
|
||||
|
||||
const refreshedMetadata = (refreshed?.keys_metadata ?? null) as KeysMetadata | null;
|
||||
const refreshedEncryptedContent = refreshed?.encrypted_content ?? null;
|
||||
const refreshedIv = refreshed?.encryption_iv ?? null;
|
||||
|
||||
if (refreshedMetadata && refreshedEncryptedContent && refreshedIv) {
|
||||
try {
|
||||
const decryptedContent = await decryptTextMessage(
|
||||
refreshedEncryptedContent,
|
||||
refreshedIv,
|
||||
refreshedMetadata,
|
||||
privateKeyJwk as RSAPrivateKeyJWK,
|
||||
true, // isRecipient
|
||||
);
|
||||
|
||||
setState({
|
||||
content: decryptedContent,
|
||||
mediaUri: null,
|
||||
isDecrypting: false,
|
||||
error: null,
|
||||
errorCode: undefined,
|
||||
});
|
||||
return;
|
||||
} catch {
|
||||
// Still pending (or repair failed); show the placeholder instead of a hard error.
|
||||
}
|
||||
}
|
||||
|
||||
setState({
|
||||
content: null,
|
||||
mediaUri: null,
|
||||
isDecrypting: false,
|
||||
error: null,
|
||||
errorCode: 'E2EE_PENDING_RECIPIENT_KEY',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (shouldAttemptStaleKeyRepair) {
|
||||
console.log(`[E2EE Decrypt] Message ${message.id}: Attempting stale key repair...`);
|
||||
setState({
|
||||
content: null,
|
||||
mediaUri: null,
|
||||
isDecrypting: true,
|
||||
error: null,
|
||||
errorCode: 'E2EE_REPAIRING_STALE_KEY',
|
||||
});
|
||||
|
||||
repairAttemptedByMessageId.current.add(message.id);
|
||||
|
||||
try {
|
||||
const repairResult = await repairStaleKey(message.id);
|
||||
|
||||
if (repairResult.success) {
|
||||
console.log(`[E2EE Decrypt] Message ${message.id}: Stale key repaired, retrying decryption...`);
|
||||
|
||||
// Fetch updated message and retry
|
||||
const { data: refreshed } = await supabase
|
||||
.from('messages')
|
||||
.select('encrypted_content, encryption_iv, keys_metadata, user_id')
|
||||
.eq('id', message.id)
|
||||
.maybeSingle();
|
||||
|
||||
const refreshedMetadata = (refreshed?.keys_metadata ?? null) as KeysMetadata | null;
|
||||
const refreshedEncryptedContent = refreshed?.encrypted_content ?? null;
|
||||
const refreshedIv = refreshed?.encryption_iv ?? null;
|
||||
|
||||
if (refreshedMetadata && refreshedEncryptedContent && refreshedIv) {
|
||||
const decryptedContent = await decryptTextMessage(
|
||||
refreshedEncryptedContent,
|
||||
refreshedIv,
|
||||
refreshedMetadata,
|
||||
privateKeyJwk as RSAPrivateKeyJWK,
|
||||
!isMe, // isRecipient
|
||||
);
|
||||
|
||||
setState({
|
||||
content: decryptedContent,
|
||||
mediaUri: null,
|
||||
isDecrypting: false,
|
||||
error: null,
|
||||
errorCode: undefined,
|
||||
});
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
console.error(`[E2EE Decrypt] Message ${message.id}: Stale key repair failed:`, repairResult.error);
|
||||
}
|
||||
} catch (repairErr) {
|
||||
console.error(`[E2EE Decrypt] Message ${message.id}: Stale key repair threw:`, repairErr);
|
||||
}
|
||||
|
||||
// Repair failed or retry failed
|
||||
setState({
|
||||
content: null,
|
||||
mediaUri: null,
|
||||
isDecrypting: false,
|
||||
error: new Error('Failed to load message'),
|
||||
errorCode: 'E2EE_DECRYPT_FAILED',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setState({
|
||||
content: null,
|
||||
mediaUri: null,
|
||||
isDecrypting: false,
|
||||
error: new Error('Failed to load message'),
|
||||
errorCode: 'E2EE_DECRYPT_FAILED',
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [
|
||||
message.id,
|
||||
message.version,
|
||||
message.content,
|
||||
message.encrypted_content,
|
||||
message.encryption_iv,
|
||||
message.keys_metadata,
|
||||
message.user_id,
|
||||
currentUserId,
|
||||
privateKeyJwk,
|
||||
hasKeys,
|
||||
keysLoading,
|
||||
retryNonce,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
decrypt();
|
||||
}, [decrypt, retryNonce]);
|
||||
|
||||
return { ...state, retry };
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
/**
|
||||
* useEncryptedSend Hook
|
||||
*
|
||||
* Hook for sending encrypted messages via E2EE.
|
||||
* Handles encryption with triple-wrapped keys (sender, recipient, admin).
|
||||
*
|
||||
* IMPORTANT: This hook reads keys from the global Zustand store to ensure
|
||||
* consistency with all other encryption operations.
|
||||
*/
|
||||
|
||||
import { useCallback, useState } from 'react';
|
||||
import { Platform } from 'react-native';
|
||||
import {
|
||||
encryptTextMessage,
|
||||
getAdminPublicKey,
|
||||
getAdminKeyId,
|
||||
} from '../lib/encryption';
|
||||
import type { RSAPublicKeyJWK, EncryptedMessagePayload } from '../lib/encryption';
|
||||
import { useEncryptionKeys } from './useEncryptionKeys';
|
||||
import { useAuthStore } from '../store';
|
||||
|
||||
interface UseEncryptedSendReturn {
|
||||
/** Encrypt a text message for sending */
|
||||
encryptMessage: (plaintext: string) => Promise<EncryptedMessagePayload | null>;
|
||||
/** Whether E2EE is available (keys loaded, not on web) */
|
||||
isE2EEAvailable: boolean;
|
||||
/** Whether encryption is currently in progress */
|
||||
isEncrypting: boolean;
|
||||
/** Last encryption error, if any */
|
||||
error: Error | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for encrypting messages before sending
|
||||
*
|
||||
* Returns null if E2EE is not available (web platform, no keys).
|
||||
* In that case, the caller should send plaintext (v1) messages.
|
||||
*/
|
||||
export function useEncryptedSend(): UseEncryptedSendReturn {
|
||||
const [isEncrypting, setIsEncrypting] = useState(false);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
|
||||
const { publicKeyJwk, hasKeys, isLoading } = useEncryptionKeys();
|
||||
const refreshPartner = useAuthStore((s) => s.refreshPartner);
|
||||
|
||||
// E2EE is available if:
|
||||
// - Not on web platform
|
||||
// - Keys are loaded and available
|
||||
const isE2EEAvailable =
|
||||
Platform.OS !== 'web' && hasKeys && !isLoading && !!publicKeyJwk;
|
||||
|
||||
const encryptMessage = useCallback(
|
||||
async (plaintext: string): Promise<EncryptedMessagePayload | null> => {
|
||||
// Return null if E2EE not available - caller will send plaintext
|
||||
if (!isE2EEAvailable) {
|
||||
console.log('[E2EE Send] E2EE not available, will send plaintext');
|
||||
return null;
|
||||
}
|
||||
|
||||
// CRITICAL: Read keys directly from the global store at encryption time
|
||||
// This ensures we always use the most current keys, not potentially stale hook state
|
||||
const currentKeys = useAuthStore.getState().encryptionKeys;
|
||||
|
||||
if (!currentKeys.hasKeys || !currentKeys.publicKeyJwk) {
|
||||
console.warn('[E2EE Send] No keys available in global store at encryption time');
|
||||
return null;
|
||||
}
|
||||
|
||||
const senderPublicKey = currentKeys.publicKeyJwk;
|
||||
|
||||
console.log(
|
||||
'[E2EE Send] Using sender public key from global store, version:',
|
||||
currentKeys.keysVersion
|
||||
);
|
||||
|
||||
setIsEncrypting(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
// Always refresh partner's profile to get their latest public key
|
||||
// This ensures we don't miss their key if they just generated it
|
||||
console.log('[E2EE Send] Refreshing partner profile to get latest public key...');
|
||||
const freshPartner = await refreshPartner();
|
||||
|
||||
// Get admin key for moderation access
|
||||
const adminPublicKey = await getAdminPublicKey();
|
||||
const adminKeyId = await getAdminKeyId();
|
||||
|
||||
// Get partner's public key from the freshly fetched profile
|
||||
const partnerPublicKey = freshPartner?.public_key_jwk as
|
||||
| RSAPublicKeyJWK
|
||||
| null
|
||||
| undefined;
|
||||
|
||||
console.log('[E2EE Send] Partner public key available:', !!partnerPublicKey);
|
||||
|
||||
// Encrypt the message using the key from global store
|
||||
const encryptedPayload = await encryptTextMessage(
|
||||
plaintext,
|
||||
senderPublicKey as RSAPublicKeyJWK,
|
||||
partnerPublicKey ?? null,
|
||||
adminPublicKey,
|
||||
adminKeyId
|
||||
);
|
||||
|
||||
console.log('[E2EE Send] Message encrypted successfully');
|
||||
|
||||
setIsEncrypting(false);
|
||||
return encryptedPayload;
|
||||
} catch (err) {
|
||||
console.error('[E2EE Send] Failed to encrypt message:', err);
|
||||
setError(err as Error);
|
||||
setIsEncrypting(false);
|
||||
return null;
|
||||
}
|
||||
},
|
||||
[isE2EEAvailable, refreshPartner]
|
||||
);
|
||||
|
||||
return {
|
||||
encryptMessage,
|
||||
isE2EEAvailable,
|
||||
isEncrypting,
|
||||
error,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,325 @@
|
||||
/**
|
||||
* useEncryptionKeys Hook
|
||||
*
|
||||
* React hook for managing E2EE encryption keys.
|
||||
* Uses global Zustand store to ensure all components share the same key state.
|
||||
*
|
||||
* IMPORTANT: This hook reads from and writes to the global authStore.encryptionKeys
|
||||
* to prevent race conditions where different hook instances have different state.
|
||||
*/
|
||||
|
||||
import { useCallback, useRef, useEffect } from 'react';
|
||||
import { Platform } from 'react-native';
|
||||
import {
|
||||
generateAndStoreKeyPair,
|
||||
getPrivateKey,
|
||||
getLocalPublicKey,
|
||||
hasKeyPair,
|
||||
isPublicKeyUploaded,
|
||||
markPublicKeyUploaded,
|
||||
isValidPublicKeyJwk,
|
||||
sanitizePublicKeyJwk,
|
||||
} from '../lib/encryption';
|
||||
import type { RSAPublicKeyJWK } from '../lib/encryption';
|
||||
import { supabase } from '../lib/supabase';
|
||||
import { useAuthStore } from '../store';
|
||||
|
||||
interface UseEncryptionKeysReturn {
|
||||
/** RSA private key in JWK format */
|
||||
privateKeyJwk: RSAPublicKeyJWK | null;
|
||||
/** RSA public key in JWK format */
|
||||
publicKeyJwk: RSAPublicKeyJWK | null;
|
||||
/** Whether keys are currently being loaded */
|
||||
isLoading: boolean;
|
||||
/** Whether valid keys exist */
|
||||
hasKeys: boolean;
|
||||
/** Any error that occurred */
|
||||
error: Error | null;
|
||||
/** Initialize keys and upload to Supabase - does everything in one call */
|
||||
initializeAndUploadKeys: () => Promise<{ wasRegenerated: boolean }>;
|
||||
/** Refresh the key state from storage */
|
||||
refresh: () => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for accessing and managing encryption keys.
|
||||
*
|
||||
* All key state is stored in the global Zustand store to ensure
|
||||
* all components see the same keys at all times.
|
||||
*/
|
||||
export function useEncryptionKeys(): UseEncryptionKeysReturn {
|
||||
// Get global state and actions from Zustand store
|
||||
const encryptionKeys = useAuthStore((s) => s.encryptionKeys);
|
||||
const setEncryptionKeys = useAuthStore((s) => s.setEncryptionKeys);
|
||||
const user = useAuthStore((s) => s.user);
|
||||
|
||||
// Track if we've already loaded keys in this session
|
||||
const hasLoadedRef = useRef(false);
|
||||
const isLoadingRef = useRef(false);
|
||||
|
||||
/**
|
||||
* Load existing keys from SecureStore into global state
|
||||
*/
|
||||
const loadKeys = useCallback(async () => {
|
||||
// E2EE not supported on web
|
||||
if (Platform.OS === 'web') {
|
||||
setEncryptionKeys({
|
||||
privateKeyJwk: null,
|
||||
publicKeyJwk: null,
|
||||
isLoadingKeys: false,
|
||||
hasKeys: false,
|
||||
keysError: null,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
// Prevent concurrent loads
|
||||
if (isLoadingRef.current) {
|
||||
return encryptionKeys.publicKeyJwk;
|
||||
}
|
||||
|
||||
try {
|
||||
isLoadingRef.current = true;
|
||||
setEncryptionKeys({ isLoadingKeys: true, keysError: null });
|
||||
|
||||
const keysExist = await hasKeyPair();
|
||||
if (keysExist) {
|
||||
const [privateKey, publicKey] = await Promise.all([
|
||||
getPrivateKey(),
|
||||
getLocalPublicKey(),
|
||||
]);
|
||||
|
||||
console.log('[E2EE Global] Keys loaded from storage');
|
||||
|
||||
setEncryptionKeys({
|
||||
privateKeyJwk: privateKey,
|
||||
publicKeyJwk: publicKey,
|
||||
isLoadingKeys: false,
|
||||
hasKeys: true,
|
||||
keysError: null,
|
||||
});
|
||||
|
||||
hasLoadedRef.current = true;
|
||||
return publicKey;
|
||||
} else {
|
||||
console.log('[E2EE Global] No keys in storage');
|
||||
setEncryptionKeys({
|
||||
privateKeyJwk: null,
|
||||
publicKeyJwk: null,
|
||||
isLoadingKeys: false,
|
||||
hasKeys: false,
|
||||
keysError: null,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[E2EE Global] Failed to load encryption keys:', error);
|
||||
setEncryptionKeys({
|
||||
privateKeyJwk: null,
|
||||
publicKeyJwk: null,
|
||||
isLoadingKeys: false,
|
||||
hasKeys: false,
|
||||
keysError: error as Error,
|
||||
});
|
||||
return null;
|
||||
} finally {
|
||||
isLoadingRef.current = false;
|
||||
}
|
||||
}, [encryptionKeys.publicKeyJwk, setEncryptionKeys]);
|
||||
|
||||
/**
|
||||
* Initialize encryption keys and upload to Supabase in one operation.
|
||||
* This avoids race conditions between state updates and upload.
|
||||
*
|
||||
* @returns { wasRegenerated: boolean } - true if keys were newly generated or regenerated
|
||||
*/
|
||||
const initializeAndUploadKeys = useCallback(async (): Promise<{
|
||||
wasRegenerated: boolean;
|
||||
}> => {
|
||||
if (Platform.OS === 'web') {
|
||||
console.warn('[E2EE Global] E2EE not supported on web');
|
||||
return { wasRegenerated: false };
|
||||
}
|
||||
|
||||
if (!user?.id) {
|
||||
console.warn('[E2EE Global] Cannot initialize keys: user not authenticated');
|
||||
return { wasRegenerated: false };
|
||||
}
|
||||
|
||||
// Prevent concurrent initialization
|
||||
if (isLoadingRef.current) {
|
||||
console.log('[E2EE Global] Key initialization already in progress');
|
||||
return { wasRegenerated: false };
|
||||
}
|
||||
|
||||
try {
|
||||
isLoadingRef.current = true;
|
||||
setEncryptionKeys({ isLoadingKeys: true, keysError: null });
|
||||
|
||||
let publicKey: RSAPublicKeyJWK | null = null;
|
||||
let privateKey = null;
|
||||
let wasRegenerated = false;
|
||||
|
||||
const keysExist = await hasKeyPair();
|
||||
|
||||
if (!keysExist) {
|
||||
// Generate new key pair
|
||||
console.log('[E2EE Global] Generating new key pair...');
|
||||
publicKey = await generateAndStoreKeyPair();
|
||||
privateKey = await getPrivateKey();
|
||||
wasRegenerated = true;
|
||||
|
||||
// Update global state IMMEDIATELY with new keys
|
||||
setEncryptionKeys({
|
||||
privateKeyJwk: privateKey,
|
||||
publicKeyJwk: publicKey,
|
||||
isLoadingKeys: false,
|
||||
hasKeys: true,
|
||||
keysError: null,
|
||||
});
|
||||
|
||||
console.log('[E2EE Global] Key pair generated and stored in global state');
|
||||
} else {
|
||||
// Load existing keys
|
||||
[privateKey, publicKey] = await Promise.all([
|
||||
getPrivateKey(),
|
||||
getLocalPublicKey(),
|
||||
]);
|
||||
|
||||
// Update global state with loaded keys
|
||||
setEncryptionKeys({
|
||||
privateKeyJwk: privateKey,
|
||||
publicKeyJwk: publicKey,
|
||||
isLoadingKeys: false,
|
||||
hasKeys: true,
|
||||
keysError: null,
|
||||
});
|
||||
}
|
||||
|
||||
// Validate keys
|
||||
if (!publicKey || !isValidPublicKeyJwk(publicKey)) {
|
||||
if (publicKey) {
|
||||
console.warn(
|
||||
'[E2EE Global] Local public key invalid, regenerating...'
|
||||
);
|
||||
} else {
|
||||
console.warn(
|
||||
'[E2EE Global] Local public key missing, regenerating...'
|
||||
);
|
||||
}
|
||||
|
||||
publicKey = await generateAndStoreKeyPair();
|
||||
privateKey = await getPrivateKey();
|
||||
wasRegenerated = true;
|
||||
|
||||
// Update global state IMMEDIATELY with regenerated keys
|
||||
setEncryptionKeys({
|
||||
privateKeyJwk: privateKey,
|
||||
publicKeyJwk: publicKey,
|
||||
isLoadingKeys: false,
|
||||
hasKeys: true,
|
||||
keysError: null,
|
||||
});
|
||||
|
||||
console.log('[E2EE Global] Keys regenerated and stored in global state');
|
||||
}
|
||||
|
||||
// Fetch the server copy to verify validity and avoid stale uploads
|
||||
const { data: profile, error: profileError } = await supabase
|
||||
.from('profiles')
|
||||
.select('public_key_jwk')
|
||||
.eq('id', user.id)
|
||||
.maybeSingle();
|
||||
|
||||
if (profileError) {
|
||||
throw profileError;
|
||||
}
|
||||
|
||||
const remoteKey = (profile?.public_key_jwk ?? null) as RSAPublicKeyJWK | null;
|
||||
const remoteValid = isValidPublicKeyJwk(remoteKey);
|
||||
const localValid = isValidPublicKeyJwk(publicKey);
|
||||
|
||||
// Sanitize both keys before comparing (local key may have trailing dots)
|
||||
const sanitizedLocal = sanitizePublicKeyJwk(publicKey!);
|
||||
const sanitizedRemote = remoteKey ? sanitizePublicKeyJwk(remoteKey) : null;
|
||||
const sameKey =
|
||||
remoteValid &&
|
||||
localValid &&
|
||||
sanitizedRemote?.n === sanitizedLocal.n &&
|
||||
sanitizedRemote?.e === sanitizedLocal.e;
|
||||
|
||||
// Upload if remote key is missing/invalid or doesn't match local.
|
||||
// If we just regenerated the key, we MUST upload it regardless
|
||||
if ((publicKey && localValid && !sameKey) || (publicKey && wasRegenerated)) {
|
||||
console.log('[E2EE Global] Uploading public key to profile...');
|
||||
|
||||
// Sanitize key before upload
|
||||
const sanitizedKey = sanitizePublicKeyJwk(publicKey);
|
||||
|
||||
const { error } = await supabase
|
||||
.from('profiles')
|
||||
.update({
|
||||
public_key_jwk: sanitizedKey as unknown as Record<string, unknown>,
|
||||
})
|
||||
.eq('id', user.id);
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
await markPublicKeyUploaded();
|
||||
console.log('[E2EE Global] Public key uploaded successfully');
|
||||
} else if (sameKey) {
|
||||
const alreadyUploaded = await isPublicKeyUploaded();
|
||||
if (!alreadyUploaded) {
|
||||
await markPublicKeyUploaded();
|
||||
}
|
||||
console.log('[E2EE Global] Public key already uploaded and valid');
|
||||
}
|
||||
|
||||
hasLoadedRef.current = true;
|
||||
return { wasRegenerated };
|
||||
} catch (error) {
|
||||
console.error('[E2EE Global] Failed to initialize encryption keys:', error);
|
||||
setEncryptionKeys({
|
||||
isLoadingKeys: false,
|
||||
keysError: error as Error,
|
||||
});
|
||||
throw error;
|
||||
} finally {
|
||||
isLoadingRef.current = false;
|
||||
}
|
||||
}, [user?.id, setEncryptionKeys]);
|
||||
|
||||
/**
|
||||
* Refresh keys from storage
|
||||
*/
|
||||
const refresh = useCallback(async () => {
|
||||
hasLoadedRef.current = false;
|
||||
await loadKeys();
|
||||
}, [loadKeys]);
|
||||
|
||||
// Load keys on mount if not already loaded
|
||||
useEffect(() => {
|
||||
if (!hasLoadedRef.current && !isLoadingRef.current && !encryptionKeys.hasKeys) {
|
||||
loadKeys();
|
||||
}
|
||||
}, [loadKeys, encryptionKeys.hasKeys]);
|
||||
|
||||
// Reset loaded flag when user changes
|
||||
useEffect(() => {
|
||||
if (!user?.id) {
|
||||
hasLoadedRef.current = false;
|
||||
}
|
||||
}, [user?.id]);
|
||||
|
||||
return {
|
||||
privateKeyJwk: encryptionKeys.privateKeyJwk,
|
||||
publicKeyJwk: encryptionKeys.publicKeyJwk,
|
||||
isLoading: encryptionKeys.isLoadingKeys,
|
||||
hasKeys: encryptionKeys.hasKeys,
|
||||
error: encryptionKeys.keysError,
|
||||
initializeAndUploadKeys,
|
||||
refresh,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { useState } from 'react';
|
||||
import { Alert } from 'react-native';
|
||||
import * as MediaLibrary from 'expo-media-library';
|
||||
import * as FileSystem from 'expo-file-system';
|
||||
|
||||
export function useMediaSaver() {
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [permissionResponse, requestPermission] = MediaLibrary.usePermissions();
|
||||
|
||||
const saveMedia = async (uri: string, type: 'image' | 'video') => {
|
||||
try {
|
||||
setSaving(true);
|
||||
|
||||
// Check permissions
|
||||
if (!permissionResponse?.granted) {
|
||||
const { granted } = await requestPermission();
|
||||
if (!granted) {
|
||||
Alert.alert(
|
||||
'Permission required',
|
||||
'Please allow access to your photo library to save media.'
|
||||
);
|
||||
setSaving(false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Handle remote URIs
|
||||
let fileUri = uri;
|
||||
if (uri.startsWith('http') || uri.startsWith('https')) {
|
||||
const extension = type === 'video' ? '.mp4' : '.jpg';
|
||||
const fileName = `download_${Date.now()}${extension}`;
|
||||
const downloadRes = await FileSystem.downloadAsync(
|
||||
uri,
|
||||
FileSystem.cacheDirectory + fileName
|
||||
);
|
||||
fileUri = downloadRes.uri;
|
||||
}
|
||||
|
||||
// Save to library
|
||||
await MediaLibrary.createAssetAsync(fileUri);
|
||||
|
||||
Alert.alert('Saved', `${type === 'video' ? 'Video' : 'Image'} saved to your gallery.`);
|
||||
|
||||
// Cleanup if we downloaded
|
||||
if (uri !== fileUri) {
|
||||
await FileSystem.deleteAsync(fileUri, { idempotent: true });
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error saving media:', error);
|
||||
Alert.alert('Error', 'Failed to save media.');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
saveMedia,
|
||||
saving,
|
||||
};
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { useState, useEffect, useRef, useCallback, useMemo } from 'react';
|
||||
import { supabase } from '../lib/supabase';
|
||||
import { Database } from '../types/supabase';
|
||||
|
||||
@@ -14,7 +14,7 @@ export interface UseMessageSubscriptionConfig {
|
||||
}
|
||||
|
||||
export interface UseMessageSubscriptionReturn {
|
||||
/** Array of messages, sorted newest first */
|
||||
/** Array of messages, sorted newest first (filtered by user's deletions) */
|
||||
messages: Message[];
|
||||
/** Set messages externally (e.g., for optimistic updates) */
|
||||
setMessages: React.Dispatch<React.SetStateAction<Message[]>>;
|
||||
@@ -36,10 +36,65 @@ export const useMessageSubscription = (
|
||||
): UseMessageSubscriptionReturn => {
|
||||
const { matchId, userId, onNewMessage } = config;
|
||||
|
||||
const [messages, setMessages] = useState<Message[]>([]);
|
||||
const [allMessages, setAllMessages] = useState<Message[]>([]);
|
||||
const [deletedMessageIds, setDeletedMessageIds] = useState<Set<string>>(new Set());
|
||||
const [loading, setLoading] = useState(true);
|
||||
const isFocusedRef = useRef(false);
|
||||
|
||||
// Filter out messages that the user has deleted for themselves
|
||||
const messages = useMemo(() => {
|
||||
return allMessages.filter(m => !deletedMessageIds.has(m.id));
|
||||
}, [allMessages, deletedMessageIds]);
|
||||
|
||||
// Wrapper to update allMessages (for external use like optimistic updates)
|
||||
const setMessages: React.Dispatch<React.SetStateAction<Message[]>> = useCallback((updater) => {
|
||||
setAllMessages(updater);
|
||||
}, []);
|
||||
|
||||
// Fetch user's deleted message IDs
|
||||
useEffect(() => {
|
||||
if (!userId) return;
|
||||
|
||||
const fetchDeletions = async () => {
|
||||
const { data } = await supabase
|
||||
.from('message_deletions')
|
||||
.select('message_id')
|
||||
.eq('user_id', userId);
|
||||
|
||||
if (data) {
|
||||
setDeletedMessageIds(new Set(data.map(d => d.message_id)));
|
||||
}
|
||||
};
|
||||
|
||||
fetchDeletions();
|
||||
}, [userId]);
|
||||
|
||||
// Subscribe to user's deletions for real-time sync across devices
|
||||
useEffect(() => {
|
||||
if (!userId) return;
|
||||
|
||||
const channel = supabase
|
||||
.channel(`deletions:${userId}`)
|
||||
.on(
|
||||
'postgres_changes',
|
||||
{
|
||||
event: 'INSERT',
|
||||
schema: 'public',
|
||||
table: 'message_deletions',
|
||||
filter: `user_id=eq.${userId}`,
|
||||
},
|
||||
(payload) => {
|
||||
const deletion = payload.new as { message_id: string };
|
||||
setDeletedMessageIds(prev => new Set([...prev, deletion.message_id]));
|
||||
}
|
||||
)
|
||||
.subscribe();
|
||||
|
||||
return () => {
|
||||
supabase.removeChannel(channel);
|
||||
};
|
||||
}, [userId]);
|
||||
|
||||
// Fetch messages and mark as read
|
||||
useEffect(() => {
|
||||
if (!matchId || !userId) {
|
||||
@@ -57,7 +112,7 @@ export const useMessageSubscription = (
|
||||
.order('created_at', { ascending: false });
|
||||
|
||||
if (data) {
|
||||
setMessages(data);
|
||||
setAllMessages(data);
|
||||
|
||||
const now = new Date().toISOString();
|
||||
|
||||
@@ -73,7 +128,7 @@ export const useMessageSubscription = (
|
||||
.update({ delivered_at: now, read_at: now })
|
||||
.in('id', unreadIds);
|
||||
|
||||
setMessages(prev => prev.map(m =>
|
||||
setAllMessages(prev => prev.map(m =>
|
||||
unreadIds.includes(m.id) ? { ...m, delivered_at: now, read_at: now } : m
|
||||
));
|
||||
} else if (undeliveredIds.length > 0) {
|
||||
@@ -83,7 +138,7 @@ export const useMessageSubscription = (
|
||||
.update({ delivered_at: now })
|
||||
.in('id', undeliveredIds);
|
||||
|
||||
setMessages(prev => prev.map(m =>
|
||||
setAllMessages(prev => prev.map(m =>
|
||||
undeliveredIds.includes(m.id) ? { ...m, delivered_at: now } : m
|
||||
));
|
||||
}
|
||||
@@ -123,7 +178,7 @@ export const useMessageSubscription = (
|
||||
newMessage.read_at = now;
|
||||
}
|
||||
|
||||
setMessages(prev => [newMessage, ...prev]);
|
||||
setAllMessages(prev => [newMessage, ...prev]);
|
||||
onNewMessage?.();
|
||||
}
|
||||
)
|
||||
@@ -137,7 +192,7 @@ export const useMessageSubscription = (
|
||||
},
|
||||
(payload) => {
|
||||
const updatedMessage = payload.new as Message;
|
||||
setMessages(prev =>
|
||||
setAllMessages(prev =>
|
||||
prev.map(m => m.id === updatedMessage.id ? updatedMessage : m)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* Admin Keys Management
|
||||
*
|
||||
* Fetches the active admin public key from the master_keys table.
|
||||
*/
|
||||
|
||||
import { supabase } from '../supabase';
|
||||
import type { MasterKey, RSAPublicKeyJWK } from './types';
|
||||
|
||||
// Cache for admin key to avoid repeated database calls
|
||||
let cachedAdminKey: MasterKey | null = null;
|
||||
let cacheTimestamp: number = 0;
|
||||
const CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes
|
||||
|
||||
/**
|
||||
* Fetch the active admin public key from the database
|
||||
*
|
||||
* Results are cached for 5 minutes to reduce database calls.
|
||||
*
|
||||
* @param forceRefresh - Force a fresh fetch, bypassing cache
|
||||
* @returns The active master key record
|
||||
*/
|
||||
export async function getActiveMasterKey(forceRefresh = false): Promise<MasterKey> {
|
||||
const now = Date.now();
|
||||
|
||||
// Return cached key if still valid
|
||||
if (!forceRefresh && cachedAdminKey && (now - cacheTimestamp) < CACHE_TTL_MS) {
|
||||
return cachedAdminKey;
|
||||
}
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('master_keys')
|
||||
.select('*')
|
||||
.eq('is_active', true)
|
||||
.limit(1)
|
||||
.maybeSingle();
|
||||
|
||||
if (error) {
|
||||
throw new Error(`Failed to fetch admin key: ${error.message}`);
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
throw new Error('No active admin key found. E2EE is not configured.');
|
||||
}
|
||||
|
||||
// Validate the key structure
|
||||
const publicKeyJwk = data.public_key_jwk as RSAPublicKeyJWK;
|
||||
if (!publicKeyJwk || publicKeyJwk.kty !== 'RSA') {
|
||||
throw new Error('Invalid admin public key format');
|
||||
}
|
||||
|
||||
cachedAdminKey = {
|
||||
id: data.id,
|
||||
key_name: data.key_name,
|
||||
public_key_jwk: publicKeyJwk,
|
||||
is_active: data.is_active ?? true,
|
||||
created_at: data.created_at ?? '',
|
||||
rotated_at: data.rotated_at,
|
||||
};
|
||||
cacheTimestamp = now;
|
||||
|
||||
return cachedAdminKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the admin public key JWK directly
|
||||
*/
|
||||
export async function getAdminPublicKey(): Promise<RSAPublicKeyJWK> {
|
||||
const masterKey = await getActiveMasterKey();
|
||||
return masterKey.public_key_jwk;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the admin key ID for storing in message metadata
|
||||
*/
|
||||
export async function getAdminKeyId(): Promise<string> {
|
||||
const masterKey = await getActiveMasterKey();
|
||||
return masterKey.id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the admin key cache
|
||||
* Called when keys might have been rotated
|
||||
*/
|
||||
export function clearAdminKeyCache(): void {
|
||||
cachedAdminKey = null;
|
||||
cacheTimestamp = 0;
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* E2EE Constants
|
||||
*
|
||||
* Algorithm identifiers and storage key names for encryption
|
||||
*/
|
||||
|
||||
// SecureStore key names for private key storage
|
||||
export const PRIVATE_KEY_PREFIX = 'e2ee_private_key_chunk_';
|
||||
export const PUBLIC_KEY_UPLOADED = 'e2ee_public_key_uploaded';
|
||||
export const LOCAL_PUBLIC_KEY = 'e2ee_local_public_key';
|
||||
|
||||
// Encryption algorithms
|
||||
export const SYMMETRIC_ALGORITHM = 'AES-GCM';
|
||||
export const SYMMETRIC_KEY_LENGTH = 256;
|
||||
export const IV_LENGTH_BYTES = 12; // 96 bits for AES-GCM
|
||||
|
||||
// Key wrapping
|
||||
export const KEY_WRAP_ALGORITHM = 'RSA-OAEP';
|
||||
export const KEY_WRAP_HASH = 'SHA-256';
|
||||
export const RSA_KEY_SIZE = 2048;
|
||||
|
||||
// SecureStore chunking (RSA-2048 JWK is ~1.7KB, SecureStore limit is 2KB)
|
||||
export const SECURE_STORE_CHUNK_SIZE = 1900;
|
||||
|
||||
// Default master key name
|
||||
export const DEFAULT_MASTER_KEY_NAME = 'e2ee_admin_key_v1';
|
||||
|
||||
// Message version constants
|
||||
export const MESSAGE_VERSION_PLAINTEXT = 1;
|
||||
export const MESSAGE_VERSION_E2EE = 2;
|
||||
@@ -0,0 +1,171 @@
|
||||
/**
|
||||
* Low-level Crypto Operations
|
||||
*
|
||||
* AES-256-GCM encryption/decryption and RSA key wrapping operations.
|
||||
*/
|
||||
|
||||
import {
|
||||
SYMMETRIC_ALGORITHM,
|
||||
SYMMETRIC_KEY_LENGTH,
|
||||
IV_LENGTH_BYTES,
|
||||
KEY_WRAP_ALGORITHM
|
||||
} from './constants';
|
||||
import { arrayBufferToBase64, base64ToArrayBuffer } from './utils';
|
||||
import type { RSAPublicKeyJWK, RSAPrivateKeyJWK } from './types';
|
||||
import { importPublicKey, importPrivateKey } from './keyManager';
|
||||
|
||||
/**
|
||||
* Get the Web Crypto API
|
||||
*/
|
||||
function getSubtle(): SubtleCrypto {
|
||||
if (typeof crypto !== 'undefined' && crypto.subtle) {
|
||||
return crypto.subtle;
|
||||
}
|
||||
throw new Error('Web Crypto API not available');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get crypto.getRandomValues
|
||||
*/
|
||||
function getRandomValues(array: Uint8Array): Uint8Array {
|
||||
if (typeof crypto !== 'undefined' && crypto.getRandomValues) {
|
||||
return crypto.getRandomValues(array);
|
||||
}
|
||||
throw new Error('Crypto random not available');
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a random AES-256 key
|
||||
*/
|
||||
export async function generateAESKey(): Promise<CryptoKey> {
|
||||
const subtle = getSubtle();
|
||||
|
||||
return await subtle.generateKey(
|
||||
{ name: SYMMETRIC_ALGORITHM, length: SYMMETRIC_KEY_LENGTH },
|
||||
true,
|
||||
['encrypt', 'decrypt']
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a random initialization vector for AES-GCM (12 bytes)
|
||||
*/
|
||||
export function generateIV(): Uint8Array {
|
||||
return getRandomValues(new Uint8Array(IV_LENGTH_BYTES));
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypt data with AES-256-GCM
|
||||
*
|
||||
* @param plaintext - The data to encrypt (string or ArrayBuffer)
|
||||
* @param key - The AES key
|
||||
* @param iv - The initialization vector
|
||||
* @returns The encrypted ciphertext as ArrayBuffer
|
||||
*/
|
||||
export async function encryptAES(
|
||||
plaintext: string | ArrayBuffer,
|
||||
key: CryptoKey,
|
||||
iv: Uint8Array
|
||||
): Promise<ArrayBuffer> {
|
||||
const subtle = getSubtle();
|
||||
|
||||
const data = typeof plaintext === 'string'
|
||||
? new TextEncoder().encode(plaintext)
|
||||
: plaintext;
|
||||
|
||||
return await subtle.encrypt(
|
||||
{ name: SYMMETRIC_ALGORITHM, iv: iv as BufferSource },
|
||||
key,
|
||||
data
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt data with AES-256-GCM
|
||||
*
|
||||
* @param ciphertext - The encrypted data
|
||||
* @param key - The AES key
|
||||
* @param iv - The initialization vector
|
||||
* @returns The decrypted plaintext as ArrayBuffer
|
||||
*/
|
||||
export async function decryptAES(
|
||||
ciphertext: ArrayBuffer,
|
||||
key: CryptoKey,
|
||||
iv: Uint8Array
|
||||
): Promise<ArrayBuffer> {
|
||||
const subtle = getSubtle();
|
||||
|
||||
return await subtle.decrypt(
|
||||
{ name: SYMMETRIC_ALGORITHM, iv: iv as BufferSource },
|
||||
key,
|
||||
ciphertext
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Export an AES key to raw bytes
|
||||
*/
|
||||
export async function exportAESKey(key: CryptoKey): Promise<ArrayBuffer> {
|
||||
const subtle = getSubtle();
|
||||
return await subtle.exportKey('raw', key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Import an AES key from raw bytes
|
||||
*/
|
||||
export async function importAESKey(rawKey: ArrayBuffer): Promise<CryptoKey> {
|
||||
const subtle = getSubtle();
|
||||
|
||||
return await subtle.importKey(
|
||||
'raw',
|
||||
rawKey,
|
||||
{ name: SYMMETRIC_ALGORITHM },
|
||||
false,
|
||||
['decrypt']
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap (encrypt) an AES key with an RSA public key
|
||||
*
|
||||
* @param aesKeyRaw - The raw AES key bytes
|
||||
* @param publicKeyJwk - The recipient's RSA public key
|
||||
* @returns The wrapped key as base64 string
|
||||
*/
|
||||
export async function wrapAESKey(
|
||||
aesKeyRaw: ArrayBuffer,
|
||||
publicKeyJwk: RSAPublicKeyJWK
|
||||
): Promise<string> {
|
||||
const subtle = getSubtle();
|
||||
const rsaKey = await importPublicKey(publicKeyJwk);
|
||||
|
||||
const wrappedKey = await subtle.encrypt(
|
||||
{ name: KEY_WRAP_ALGORITHM },
|
||||
rsaKey,
|
||||
aesKeyRaw
|
||||
);
|
||||
|
||||
return arrayBufferToBase64(wrappedKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* Unwrap (decrypt) an AES key with an RSA private key
|
||||
*
|
||||
* @param wrappedKeyBase64 - The wrapped key as base64 string
|
||||
* @param privateKeyJwk - The user's RSA private key
|
||||
* @returns The unwrapped AES key as ArrayBuffer
|
||||
*/
|
||||
export async function unwrapAESKey(
|
||||
wrappedKeyBase64: string,
|
||||
privateKeyJwk: RSAPrivateKeyJWK
|
||||
): Promise<ArrayBuffer> {
|
||||
const subtle = getSubtle();
|
||||
const rsaKey = await importPrivateKey(privateKeyJwk);
|
||||
const wrappedKey = base64ToArrayBuffer(wrappedKeyBase64);
|
||||
|
||||
return await subtle.decrypt(
|
||||
{ name: KEY_WRAP_ALGORITHM },
|
||||
rsaKey,
|
||||
wrappedKey
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* E2EE Encryption Library
|
||||
*
|
||||
* Public API exports for the Sauci E2EE implementation.
|
||||
*/
|
||||
|
||||
// Types
|
||||
export type {
|
||||
KeysMetadata,
|
||||
EncryptedMessagePayload,
|
||||
EncryptedMediaPayload,
|
||||
DecryptedContent,
|
||||
DecryptedMessageState,
|
||||
RSAPublicKeyJWK,
|
||||
RSAPrivateKeyJWK,
|
||||
EncryptionKeysState,
|
||||
MasterKey,
|
||||
EncryptionContext,
|
||||
} from './types';
|
||||
|
||||
// Constants
|
||||
export {
|
||||
MESSAGE_VERSION_PLAINTEXT,
|
||||
MESSAGE_VERSION_E2EE,
|
||||
} from './constants';
|
||||
|
||||
// Key Management
|
||||
export {
|
||||
generateAndStoreKeyPair,
|
||||
getPrivateKey,
|
||||
getLocalPublicKey,
|
||||
hasKeyPair,
|
||||
isPublicKeyUploaded,
|
||||
markPublicKeyUploaded,
|
||||
clearKeys,
|
||||
isCryptoAvailable,
|
||||
isValidPublicKeyJwk,
|
||||
sanitizePublicKeyJwk,
|
||||
} from './keyManager';
|
||||
|
||||
// Text Message Encryption/Decryption
|
||||
export { encryptTextMessage } from './messageEncryption';
|
||||
export { decryptTextMessage } from './messageDecryption';
|
||||
|
||||
// Media Encryption/Decryption
|
||||
export { encryptMediaFile, getEncryptedFileExtension } from './mediaEncryption';
|
||||
export { decryptMediaFile, cleanupDecryptedMedia } from './mediaDecryption';
|
||||
|
||||
// Admin Keys
|
||||
export {
|
||||
getActiveMasterKey,
|
||||
getAdminPublicKey,
|
||||
getAdminKeyId,
|
||||
clearAdminKeyCache,
|
||||
} from './adminKeys';
|
||||
|
||||
// Utilities
|
||||
export {
|
||||
arrayBufferToBase64,
|
||||
base64ToArrayBuffer,
|
||||
} from './utils';
|
||||
|
||||
// Key Rotation
|
||||
export { triggerKeyRotation } from './triggerKeyRotation';
|
||||
|
||||
// Stale Key Repair
|
||||
export { repairStaleKey } from './repairStaleKey';
|
||||
@@ -0,0 +1,294 @@
|
||||
/**
|
||||
* E2EE Key Manager
|
||||
*
|
||||
* Handles RSA key pair generation and secure storage using expo-secure-store.
|
||||
* Keys are stored in chunks due to SecureStore's 2KB limit.
|
||||
*/
|
||||
|
||||
import * as SecureStore from 'expo-secure-store';
|
||||
import { Platform } from 'react-native';
|
||||
import {
|
||||
PRIVATE_KEY_PREFIX,
|
||||
PUBLIC_KEY_UPLOADED,
|
||||
LOCAL_PUBLIC_KEY,
|
||||
SECURE_STORE_CHUNK_SIZE,
|
||||
RSA_KEY_SIZE,
|
||||
KEY_WRAP_ALGORITHM,
|
||||
KEY_WRAP_HASH
|
||||
} from './constants';
|
||||
import { chunkString } from './utils';
|
||||
import type { RSAPublicKeyJWK, RSAPrivateKeyJWK } from './types';
|
||||
|
||||
/**
|
||||
* Check if we're running in a web environment
|
||||
*/
|
||||
function isWeb(): boolean {
|
||||
return Platform.OS === 'web';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if crypto is available (polyfill installed and native modules built)
|
||||
*/
|
||||
export function isCryptoAvailable(): boolean {
|
||||
if (isWeb()) return false;
|
||||
return typeof crypto !== 'undefined' && crypto.subtle !== undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize a public key by removing trailing dots that may appear
|
||||
* due to react-native-quick-crypto encoding issues.
|
||||
*/
|
||||
export function sanitizePublicKeyJwk(publicKeyJwk: RSAPublicKeyJWK): RSAPublicKeyJWK {
|
||||
return {
|
||||
...publicKeyJwk,
|
||||
n: publicKeyJwk.n.replace(/\.+$/, ''),
|
||||
e: publicKeyJwk.e.replace(/\.+$/, ''),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate that a public key JWK looks well-formed for RSA import.
|
||||
*/
|
||||
export function isValidPublicKeyJwk(publicKeyJwk: RSAPublicKeyJWK | null): boolean {
|
||||
if (!publicKeyJwk) return false;
|
||||
if (publicKeyJwk.kty !== 'RSA') return false;
|
||||
if (typeof publicKeyJwk.n !== 'string' || typeof publicKeyJwk.e !== 'string') return false;
|
||||
|
||||
// Sanitize before validation (handle trailing dots from quick-crypto)
|
||||
const sanitized = sanitizePublicKeyJwk(publicKeyJwk);
|
||||
const base64UrlPattern = /^[A-Za-z0-9_\-+/=]+$/;
|
||||
return base64UrlPattern.test(sanitized.n) && base64UrlPattern.test(sanitized.e);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the Web Crypto API
|
||||
* In React Native with react-native-quick-crypto, it's available globally
|
||||
*/
|
||||
function getSubtle(): SubtleCrypto {
|
||||
// react-native-quick-crypto polyfills global.crypto
|
||||
if (typeof crypto !== 'undefined' && crypto.subtle) {
|
||||
return crypto.subtle;
|
||||
}
|
||||
throw new Error('Web Crypto API not available. Run: npx expo prebuild --clean');
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a new RSA-2048 key pair for E2EE
|
||||
*
|
||||
* @returns The public key in JWK format (private key is stored in SecureStore)
|
||||
*/
|
||||
export async function generateAndStoreKeyPair(): Promise<RSAPublicKeyJWK> {
|
||||
if (isWeb()) {
|
||||
console.warn('E2EE key generation not supported on web platform');
|
||||
throw new Error('E2EE is not supported on web');
|
||||
}
|
||||
|
||||
const subtle = getSubtle();
|
||||
|
||||
// Generate RSA-2048 key pair
|
||||
const keyPair = await subtle.generateKey(
|
||||
{
|
||||
name: KEY_WRAP_ALGORITHM,
|
||||
modulusLength: RSA_KEY_SIZE,
|
||||
publicExponent: new Uint8Array([1, 0, 1]), // 65537
|
||||
hash: KEY_WRAP_HASH,
|
||||
},
|
||||
true, // extractable
|
||||
['encrypt', 'decrypt']
|
||||
);
|
||||
|
||||
// Export keys as JWK
|
||||
const publicKeyJwk = await subtle.exportKey('jwk', keyPair.publicKey) as RSAPublicKeyJWK;
|
||||
const privateKeyJwk = await subtle.exportKey('jwk', keyPair.privateKey) as RSAPrivateKeyJWK;
|
||||
|
||||
// Store private key in SecureStore (chunked)
|
||||
await storePrivateKey(privateKeyJwk);
|
||||
|
||||
// Store public key locally for quick access
|
||||
await storeLocalPublicKey(publicKeyJwk);
|
||||
|
||||
return publicKeyJwk;
|
||||
}
|
||||
|
||||
/**
|
||||
* Store private key in SecureStore using chunked storage
|
||||
* RSA-2048 JWK is ~1.7KB, SecureStore limit is 2KB
|
||||
*/
|
||||
async function storePrivateKey(privateKeyJwk: RSAPrivateKeyJWK): Promise<void> {
|
||||
const json = JSON.stringify(privateKeyJwk);
|
||||
const chunks = chunkString(json, SECURE_STORE_CHUNK_SIZE);
|
||||
|
||||
// Store chunk count
|
||||
await SecureStore.setItemAsync(
|
||||
`${PRIVATE_KEY_PREFIX}count`,
|
||||
chunks.length.toString()
|
||||
);
|
||||
|
||||
// Store each chunk with high security settings
|
||||
for (let i = 0; i < chunks.length; i++) {
|
||||
await SecureStore.setItemAsync(
|
||||
`${PRIVATE_KEY_PREFIX}${i}`,
|
||||
chunks[i],
|
||||
{
|
||||
keychainAccessible: SecureStore.WHEN_UNLOCKED_THIS_DEVICE_ONLY
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Store public key locally for quick access during encryption
|
||||
*/
|
||||
async function storeLocalPublicKey(publicKeyJwk: RSAPublicKeyJWK): Promise<void> {
|
||||
await SecureStore.setItemAsync(
|
||||
LOCAL_PUBLIC_KEY,
|
||||
JSON.stringify(publicKeyJwk)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve the private key from SecureStore
|
||||
*
|
||||
* @returns The private key in JWK format, or null if not found
|
||||
*/
|
||||
export async function getPrivateKey(): Promise<RSAPrivateKeyJWK | null> {
|
||||
if (isWeb()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const countStr = await SecureStore.getItemAsync(`${PRIVATE_KEY_PREFIX}count`);
|
||||
if (!countStr) return null;
|
||||
|
||||
const count = parseInt(countStr, 10);
|
||||
const chunks: string[] = [];
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
const chunk = await SecureStore.getItemAsync(`${PRIVATE_KEY_PREFIX}${i}`);
|
||||
if (!chunk) return null;
|
||||
chunks.push(chunk);
|
||||
}
|
||||
|
||||
return JSON.parse(chunks.join('')) as RSAPrivateKeyJWK;
|
||||
} catch (error) {
|
||||
console.error('Failed to retrieve private key:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve the locally stored public key
|
||||
*
|
||||
* @returns The public key in JWK format, or null if not found
|
||||
*/
|
||||
export async function getLocalPublicKey(): Promise<RSAPublicKeyJWK | null> {
|
||||
if (isWeb()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const json = await SecureStore.getItemAsync(LOCAL_PUBLIC_KEY);
|
||||
if (!json) return null;
|
||||
return JSON.parse(json) as RSAPublicKeyJWK;
|
||||
} catch (error) {
|
||||
console.error('Failed to retrieve local public key:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a key pair exists in SecureStore
|
||||
*/
|
||||
export async function hasKeyPair(): Promise<boolean> {
|
||||
if (isWeb()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const countStr = await SecureStore.getItemAsync(`${PRIVATE_KEY_PREFIX}count`);
|
||||
return countStr !== null;
|
||||
} catch (error) {
|
||||
console.error('Failed to check for key pair:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if public key has been uploaded to Supabase
|
||||
*/
|
||||
export async function isPublicKeyUploaded(): Promise<boolean> {
|
||||
if (isWeb()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const uploaded = await SecureStore.getItemAsync(PUBLIC_KEY_UPLOADED);
|
||||
return uploaded === 'true';
|
||||
} catch (error) {
|
||||
console.error('Failed to check public key upload status:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark public key as uploaded to Supabase
|
||||
*/
|
||||
export async function markPublicKeyUploaded(): Promise<void> {
|
||||
await SecureStore.setItemAsync(PUBLIC_KEY_UPLOADED, 'true');
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all encryption keys from SecureStore
|
||||
* Used when user logs out or leaves a couple
|
||||
*/
|
||||
export async function clearKeys(): Promise<void> {
|
||||
if (isWeb()) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const countStr = await SecureStore.getItemAsync(`${PRIVATE_KEY_PREFIX}count`);
|
||||
if (countStr) {
|
||||
const count = parseInt(countStr, 10);
|
||||
for (let i = 0; i < count; i++) {
|
||||
await SecureStore.deleteItemAsync(`${PRIVATE_KEY_PREFIX}${i}`);
|
||||
}
|
||||
await SecureStore.deleteItemAsync(`${PRIVATE_KEY_PREFIX}count`);
|
||||
}
|
||||
|
||||
await SecureStore.deleteItemAsync(PUBLIC_KEY_UPLOADED);
|
||||
await SecureStore.deleteItemAsync(LOCAL_PUBLIC_KEY);
|
||||
} catch (error) {
|
||||
console.error('Failed to clear encryption keys:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Import an RSA public key from JWK format for encryption operations
|
||||
*/
|
||||
export async function importPublicKey(publicKeyJwk: RSAPublicKeyJWK): Promise<CryptoKey> {
|
||||
const subtle = getSubtle();
|
||||
|
||||
return await subtle.importKey(
|
||||
'jwk',
|
||||
publicKeyJwk,
|
||||
{ name: KEY_WRAP_ALGORITHM, hash: KEY_WRAP_HASH },
|
||||
false,
|
||||
['encrypt']
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Import an RSA private key from JWK format for decryption operations
|
||||
*/
|
||||
export async function importPrivateKey(privateKeyJwk: RSAPrivateKeyJWK): Promise<CryptoKey> {
|
||||
const subtle = getSubtle();
|
||||
|
||||
return await subtle.importKey(
|
||||
'jwk',
|
||||
privateKeyJwk,
|
||||
{ name: KEY_WRAP_ALGORITHM, hash: KEY_WRAP_HASH },
|
||||
false,
|
||||
['decrypt']
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
/**
|
||||
* Media Decryption
|
||||
*
|
||||
* Decrypts E2EE images and videos using a download-first approach.
|
||||
*/
|
||||
|
||||
import * as FileSystem from 'expo-file-system';
|
||||
import { unwrapAESKey, importAESKey, decryptAES } from './crypto';
|
||||
import { base64ToArrayBuffer, base64ToUint8Array, arrayBufferToBase64 } from './utils';
|
||||
import type { KeysMetadata, RSAPrivateKeyJWK } from './types';
|
||||
|
||||
/**
|
||||
* Decrypt an encrypted media file
|
||||
*
|
||||
* Downloads the encrypted file, decrypts it, and returns a local file URI.
|
||||
*
|
||||
* @param encryptedUrl - Signed URL to the .enc file in storage
|
||||
* @param encryptionIv - Base64-encoded initialization vector
|
||||
* @param keysMetadata - The wrapped keys metadata
|
||||
* @param privateKeyJwk - User's RSA private key
|
||||
* @param isRecipient - Whether the current user is the recipient
|
||||
* @param mediaType - Type of media ('image' or 'video')
|
||||
* @returns Local file URI to the decrypted media
|
||||
*/
|
||||
export async function decryptMediaFile(
|
||||
encryptedUrl: string,
|
||||
encryptionIv: string,
|
||||
keysMetadata: KeysMetadata,
|
||||
privateKeyJwk: RSAPrivateKeyJWK,
|
||||
isRecipient: boolean,
|
||||
mediaType: 'image' | 'video'
|
||||
): Promise<string> {
|
||||
|
||||
// 1. Download entire encrypted file
|
||||
const tempEncryptedPath = `${FileSystem.cacheDirectory}${Date.now()}_encrypted.enc`;
|
||||
const downloadResult = await FileSystem.downloadAsync(
|
||||
encryptedUrl,
|
||||
tempEncryptedPath
|
||||
);
|
||||
|
||||
if (downloadResult.status !== 200) {
|
||||
throw new Error(`Failed to download encrypted file: ${downloadResult.status}`);
|
||||
}
|
||||
|
||||
// 2. Read encrypted data
|
||||
const encryptedBase64 = await FileSystem.readAsStringAsync(
|
||||
downloadResult.uri,
|
||||
{ encoding: FileSystem.EncodingType.Base64 }
|
||||
);
|
||||
const encryptedData = base64ToArrayBuffer(encryptedBase64);
|
||||
|
||||
// 3. Unwrap AES key
|
||||
const wrappedKeyBase64 = isRecipient
|
||||
? keysMetadata.recipient_wrapped_key
|
||||
: keysMetadata.sender_wrapped_key;
|
||||
|
||||
if (!wrappedKeyBase64) {
|
||||
throw new Error('No wrapped key available for media decryption');
|
||||
}
|
||||
|
||||
const rawAesKey = await unwrapAESKey(wrappedKeyBase64, privateKeyJwk);
|
||||
const aesKey = await importAESKey(rawAesKey);
|
||||
|
||||
// 4. Decrypt file
|
||||
const iv = base64ToUint8Array(encryptionIv);
|
||||
const decryptedData = await decryptAES(encryptedData, aesKey, iv);
|
||||
|
||||
// 5. Write decrypted file
|
||||
const ext = mediaType === 'video' ? 'mp4' : 'jpg';
|
||||
const decryptedUri = `${FileSystem.cacheDirectory}${Date.now()}_decrypted.${ext}`;
|
||||
|
||||
await FileSystem.writeAsStringAsync(
|
||||
decryptedUri,
|
||||
arrayBufferToBase64(decryptedData),
|
||||
{ encoding: FileSystem.EncodingType.Base64 }
|
||||
);
|
||||
|
||||
// 6. Clean up encrypted temp file
|
||||
await FileSystem.deleteAsync(downloadResult.uri, { idempotent: true });
|
||||
|
||||
return decryptedUri;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up a decrypted media file from cache
|
||||
*
|
||||
* @param uri - Local file URI to delete
|
||||
*/
|
||||
export async function cleanupDecryptedMedia(uri: string): Promise<void> {
|
||||
try {
|
||||
await FileSystem.deleteAsync(uri, { idempotent: true });
|
||||
} catch (error) {
|
||||
console.warn('Failed to clean up decrypted media:', error);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* Media Encryption
|
||||
*
|
||||
* Encrypts images and videos using AES-256-GCM with triple-wrapped keys.
|
||||
* Uses a download-then-decrypt approach for simplicity.
|
||||
*/
|
||||
|
||||
import * as FileSystem from 'expo-file-system';
|
||||
import { generateAESKey, generateIV, encryptAES, exportAESKey, wrapAESKey } from './crypto';
|
||||
import { arrayBufferToBase64, base64ToArrayBuffer } from './utils';
|
||||
import type { KeysMetadata, EncryptedMediaPayload, RSAPublicKeyJWK } from './types';
|
||||
|
||||
/**
|
||||
* Encrypt a media file (image or video) for E2EE
|
||||
*
|
||||
* @param sourceUri - Local file URI of the media to encrypt
|
||||
* @param senderPublicKeyJwk - Sender's RSA public key
|
||||
* @param recipientPublicKeyJwk - Recipient's RSA public key (may be null)
|
||||
* @param adminPublicKeyJwk - Admin's RSA public key
|
||||
* @param adminKeyId - UUID of the admin key record
|
||||
* @returns Encrypted media payload with local file URI
|
||||
*/
|
||||
export async function encryptMediaFile(
|
||||
sourceUri: string,
|
||||
senderPublicKeyJwk: RSAPublicKeyJWK,
|
||||
recipientPublicKeyJwk: RSAPublicKeyJWK | null,
|
||||
adminPublicKeyJwk: RSAPublicKeyJWK,
|
||||
adminKeyId: string
|
||||
): Promise<EncryptedMediaPayload> {
|
||||
|
||||
// 1. Read entire file into memory as base64
|
||||
const fileBase64 = await FileSystem.readAsStringAsync(sourceUri, {
|
||||
encoding: FileSystem.EncodingType.Base64,
|
||||
});
|
||||
const fileBuffer = base64ToArrayBuffer(fileBase64);
|
||||
|
||||
// 2. Generate AES key and IV
|
||||
const aesKey = await generateAESKey();
|
||||
const iv = generateIV();
|
||||
|
||||
// 3. Encrypt entire file
|
||||
const encryptedData = await encryptAES(fileBuffer, aesKey, iv);
|
||||
|
||||
// 4. Write encrypted file to temp location
|
||||
const encryptedUri = `${FileSystem.cacheDirectory}${Date.now()}.enc`;
|
||||
await FileSystem.writeAsStringAsync(
|
||||
encryptedUri,
|
||||
arrayBufferToBase64(encryptedData),
|
||||
{ encoding: FileSystem.EncodingType.Base64 }
|
||||
);
|
||||
|
||||
// 5. Wrap AES key for all parties
|
||||
const rawAesKey = await exportAESKey(aesKey);
|
||||
const senderWrappedKey = await wrapAESKey(rawAesKey, senderPublicKeyJwk);
|
||||
const adminWrappedKey = await wrapAESKey(rawAesKey, adminPublicKeyJwk);
|
||||
|
||||
let recipientWrappedKey: string | undefined;
|
||||
if (recipientPublicKeyJwk) {
|
||||
recipientWrappedKey = await wrapAESKey(rawAesKey, recipientPublicKeyJwk);
|
||||
}
|
||||
|
||||
return {
|
||||
encryptedFileUri: encryptedUri,
|
||||
encryption_iv: arrayBufferToBase64(iv.buffer as ArrayBuffer),
|
||||
keys_metadata: {
|
||||
sender_wrapped_key: senderWrappedKey,
|
||||
recipient_wrapped_key: recipientWrappedKey,
|
||||
admin_wrapped_key: adminWrappedKey,
|
||||
admin_key_id: adminKeyId,
|
||||
algorithm: 'AES-256-GCM',
|
||||
key_wrap_algorithm: 'RSA-OAEP-SHA256',
|
||||
pending_recipient: !recipientPublicKeyJwk,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the file extension for encrypted media
|
||||
*/
|
||||
export function getEncryptedFileExtension(): string {
|
||||
return '.enc';
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* Text Message Decryption
|
||||
*
|
||||
* Decrypts E2EE text messages using the user's RSA private key.
|
||||
*/
|
||||
|
||||
import { unwrapAESKey, importAESKey, decryptAES } from './crypto';
|
||||
import { base64ToArrayBuffer, base64ToUint8Array } from './utils';
|
||||
import type { KeysMetadata, RSAPrivateKeyJWK } from './types';
|
||||
|
||||
/**
|
||||
* Decrypt an E2EE text message
|
||||
*
|
||||
* @param encryptedContent - Base64-encoded ciphertext
|
||||
* @param encryptionIv - Base64-encoded initialization vector
|
||||
* @param keysMetadata - The wrapped keys metadata
|
||||
* @param privateKeyJwk - User's RSA private key
|
||||
* @param isRecipient - Whether the current user is the recipient (true) or sender (false)
|
||||
* @returns The decrypted plaintext message
|
||||
*/
|
||||
export async function decryptTextMessage(
|
||||
encryptedContent: string,
|
||||
encryptionIv: string,
|
||||
keysMetadata: KeysMetadata,
|
||||
privateKeyJwk: RSAPrivateKeyJWK,
|
||||
isRecipient: boolean
|
||||
): Promise<string> {
|
||||
|
||||
// 1. Select the correct wrapped key
|
||||
const wrappedKeyBase64 = isRecipient
|
||||
? keysMetadata.recipient_wrapped_key
|
||||
: keysMetadata.sender_wrapped_key;
|
||||
|
||||
if (!wrappedKeyBase64) {
|
||||
throw new Error('No wrapped key available for decryption');
|
||||
}
|
||||
|
||||
// 2. Unwrap AES key using RSA private key
|
||||
const rawAesKey = await unwrapAESKey(wrappedKeyBase64, privateKeyJwk);
|
||||
|
||||
// 3. Import AES key
|
||||
const aesKey = await importAESKey(rawAesKey);
|
||||
|
||||
// 4. Decrypt content
|
||||
const iv = base64ToUint8Array(encryptionIv);
|
||||
const ciphertext = base64ToArrayBuffer(encryptedContent);
|
||||
|
||||
const plaintext = await decryptAES(ciphertext, aesKey, iv);
|
||||
|
||||
// 5. Decode to string
|
||||
return new TextDecoder().decode(plaintext);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* Text Message Encryption
|
||||
*
|
||||
* Encrypts text messages using AES-256-GCM with triple-wrapped keys.
|
||||
*/
|
||||
|
||||
import { generateAESKey, generateIV, encryptAES, exportAESKey, wrapAESKey } from './crypto';
|
||||
import { arrayBufferToBase64 } from './utils';
|
||||
import type { KeysMetadata, EncryptedMessagePayload, RSAPublicKeyJWK } from './types';
|
||||
|
||||
/**
|
||||
* Encrypt a text message for E2EE
|
||||
*
|
||||
* The message is encrypted with a random AES-256 key, and that key is
|
||||
* wrapped (encrypted) separately for the sender, recipient, and admin.
|
||||
*
|
||||
* @param plaintext - The message text to encrypt
|
||||
* @param senderPublicKeyJwk - Sender's RSA public key (for their own decryption)
|
||||
* @param recipientPublicKeyJwk - Recipient's RSA public key (may be null if not yet generated)
|
||||
* @param adminPublicKeyJwk - Admin's RSA public key (for moderation access)
|
||||
* @param adminKeyId - UUID of the admin key record in master_keys table
|
||||
* @returns The encrypted message payload
|
||||
*/
|
||||
export async function encryptTextMessage(
|
||||
plaintext: string,
|
||||
senderPublicKeyJwk: RSAPublicKeyJWK,
|
||||
recipientPublicKeyJwk: RSAPublicKeyJWK | null,
|
||||
adminPublicKeyJwk: RSAPublicKeyJWK,
|
||||
adminKeyId: string
|
||||
): Promise<EncryptedMessagePayload> {
|
||||
|
||||
// 1. Generate random AES-256 key
|
||||
const aesKey = await generateAESKey();
|
||||
|
||||
// 2. Generate random 96-bit IV
|
||||
const iv = generateIV();
|
||||
|
||||
// 3. Encrypt plaintext
|
||||
const ciphertext = await encryptAES(plaintext, aesKey, iv);
|
||||
|
||||
// 4. Export AES key for wrapping
|
||||
const rawAesKey = await exportAESKey(aesKey);
|
||||
|
||||
// 5. Wrap AES key for each party
|
||||
const senderWrappedKey = await wrapAESKey(rawAesKey, senderPublicKeyJwk);
|
||||
const adminWrappedKey = await wrapAESKey(rawAesKey, adminPublicKeyJwk);
|
||||
|
||||
let recipientWrappedKey: string | undefined;
|
||||
if (recipientPublicKeyJwk) {
|
||||
recipientWrappedKey = await wrapAESKey(rawAesKey, recipientPublicKeyJwk);
|
||||
}
|
||||
|
||||
return {
|
||||
version: 2,
|
||||
encrypted_content: arrayBufferToBase64(ciphertext),
|
||||
encryption_iv: arrayBufferToBase64(iv.buffer as ArrayBuffer),
|
||||
keys_metadata: {
|
||||
sender_wrapped_key: senderWrappedKey,
|
||||
recipient_wrapped_key: recipientWrappedKey,
|
||||
admin_wrapped_key: adminWrappedKey,
|
||||
admin_key_id: adminKeyId,
|
||||
algorithm: 'AES-256-GCM',
|
||||
key_wrap_algorithm: 'RSA-OAEP-SHA256',
|
||||
pending_recipient: !recipientPublicKeyJwk,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* Pending Message Re-encryption (Server Assisted)
|
||||
*
|
||||
* Triggers the Supabase Edge Function `reencrypt-pending-messages` to re-wrap
|
||||
* AES keys for messages that were sent before the current user had E2EE keys.
|
||||
*
|
||||
* This does NOT decrypt message content on the server. It only re-wraps the
|
||||
* per-message AES key using the user's public key (admin recovery model).
|
||||
*/
|
||||
|
||||
import { supabase } from '../supabase';
|
||||
|
||||
export interface ReencryptPendingMessagesResult {
|
||||
success: boolean;
|
||||
updated: number;
|
||||
reason?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
let inFlight: Promise<ReencryptPendingMessagesResult | null> | null = null;
|
||||
let lastRunAtMs = 0;
|
||||
|
||||
const MIN_INTERVAL_MS = 15_000;
|
||||
|
||||
export async function reencryptPendingMessages(
|
||||
options: { force?: boolean } = {}
|
||||
): Promise<ReencryptPendingMessagesResult | null> {
|
||||
const now = Date.now();
|
||||
|
||||
if (inFlight) return inFlight;
|
||||
|
||||
if (!options.force && now - lastRunAtMs < MIN_INTERVAL_MS) {
|
||||
return null;
|
||||
}
|
||||
|
||||
inFlight = (async () => {
|
||||
try {
|
||||
const { data: { session } } = await supabase.auth.getSession();
|
||||
if (!session?.access_token) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const baseUrl = process.env.EXPO_PUBLIC_SUPABASE_URL;
|
||||
if (!baseUrl) {
|
||||
return { success: false, updated: 0, error: 'Missing EXPO_PUBLIC_SUPABASE_URL' };
|
||||
}
|
||||
|
||||
const response = await fetch(`${baseUrl}/functions/v1/reencrypt-pending-messages`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${session.access_token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
return { success: false, updated: 0, error: text || 'Failed to re-encrypt pending messages' };
|
||||
}
|
||||
|
||||
const result = (await response.json()) as ReencryptPendingMessagesResult;
|
||||
lastRunAtMs = Date.now();
|
||||
return result;
|
||||
} catch (err) {
|
||||
return {
|
||||
success: false,
|
||||
updated: 0,
|
||||
error: err instanceof Error ? err.message : 'Unknown error',
|
||||
};
|
||||
} finally {
|
||||
inFlight = null;
|
||||
}
|
||||
})();
|
||||
|
||||
return inFlight;
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* Stale Key Repair (Server Assisted)
|
||||
*
|
||||
* When a message was encrypted offline with a recipient's old public key,
|
||||
* and the recipient has since regenerated their keys, this function requests
|
||||
* the server to re-wrap the AES key using the admin recovery model.
|
||||
*/
|
||||
|
||||
import { supabase } from '../supabase';
|
||||
|
||||
export interface RepairStaleKeyResult {
|
||||
success: boolean;
|
||||
message_id?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
// Track in-flight repairs to avoid duplicate requests
|
||||
const inFlightRepairs = new Map<string, Promise<RepairStaleKeyResult>>();
|
||||
|
||||
/**
|
||||
* Request the server to repair a message encrypted with a stale key.
|
||||
*
|
||||
* @param messageId - The ID of the message to repair
|
||||
* @returns Result of the repair operation
|
||||
*/
|
||||
export async function repairStaleKey(
|
||||
messageId: string
|
||||
): Promise<RepairStaleKeyResult> {
|
||||
// Check if already in flight
|
||||
const existing = inFlightRepairs.get(messageId);
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
|
||||
const promise = (async (): Promise<RepairStaleKeyResult> => {
|
||||
try {
|
||||
const { data: { session } } = await supabase.auth.getSession();
|
||||
if (!session?.access_token) {
|
||||
return { success: false, error: 'Not authenticated' };
|
||||
}
|
||||
|
||||
const baseUrl = process.env.EXPO_PUBLIC_SUPABASE_URL;
|
||||
if (!baseUrl) {
|
||||
return { success: false, error: 'Missing EXPO_PUBLIC_SUPABASE_URL' };
|
||||
}
|
||||
|
||||
const response = await fetch(`${baseUrl}/functions/v1/repair-stale-key`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${session.access_token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ message_id: messageId }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
let errorMessage = 'Failed to repair message key';
|
||||
try {
|
||||
const json = JSON.parse(text);
|
||||
errorMessage = json.error || errorMessage;
|
||||
} catch {
|
||||
// Use default error message
|
||||
}
|
||||
return { success: false, error: errorMessage };
|
||||
}
|
||||
|
||||
const result = (await response.json()) as RepairStaleKeyResult;
|
||||
return result;
|
||||
} catch (err) {
|
||||
return {
|
||||
success: false,
|
||||
error: err instanceof Error ? err.message : 'Unknown error',
|
||||
};
|
||||
} finally {
|
||||
inFlightRepairs.delete(messageId);
|
||||
}
|
||||
})();
|
||||
|
||||
inFlightRepairs.set(messageId, promise);
|
||||
return promise;
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* Trigger Key Rotation (Server Assisted)
|
||||
*
|
||||
* Calls the Supabase Edge Function `handle-key-rotation` to re-wrap
|
||||
* all message AES keys with the user's current public key.
|
||||
*
|
||||
* This is needed when a user regenerates their keys (new device, app reinstall).
|
||||
* The admin key is used to unwrap the message keys and re-wrap them for
|
||||
* the user's new public key.
|
||||
*/
|
||||
|
||||
import { supabase } from '../supabase';
|
||||
|
||||
export interface KeyRotationResult {
|
||||
success: boolean;
|
||||
updated: number;
|
||||
reason?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
let inFlight: Promise<KeyRotationResult | null> | null = null;
|
||||
|
||||
export async function triggerKeyRotation(): Promise<KeyRotationResult | null> {
|
||||
// Prevent concurrent calls
|
||||
if (inFlight) return inFlight;
|
||||
|
||||
inFlight = (async () => {
|
||||
try {
|
||||
const { data: { session } } = await supabase.auth.getSession();
|
||||
if (!session?.access_token) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const baseUrl = process.env.EXPO_PUBLIC_SUPABASE_URL;
|
||||
if (!baseUrl) {
|
||||
return { success: false, updated: 0, error: 'Missing EXPO_PUBLIC_SUPABASE_URL' };
|
||||
}
|
||||
|
||||
console.log('[E2EE] Triggering key rotation for regenerated keys...');
|
||||
|
||||
const response = await fetch(`${baseUrl}/functions/v1/handle-key-rotation`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${session.access_token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
console.error('[E2EE] Key rotation failed:', text);
|
||||
return { success: false, updated: 0, error: text || 'Failed to rotate keys' };
|
||||
}
|
||||
|
||||
const result = (await response.json()) as KeyRotationResult;
|
||||
console.log(`[E2EE] Key rotation complete: ${result.updated} messages updated`);
|
||||
return result;
|
||||
} catch (err) {
|
||||
console.error('[E2EE] Key rotation error:', err);
|
||||
return {
|
||||
success: false,
|
||||
updated: 0,
|
||||
error: err instanceof Error ? err.message : 'Unknown error',
|
||||
};
|
||||
} finally {
|
||||
inFlight = null;
|
||||
}
|
||||
})();
|
||||
|
||||
return inFlight;
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
/**
|
||||
* E2EE Types for Sauci Chat
|
||||
*
|
||||
* This file contains all TypeScript interfaces for the E2EE implementation.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Metadata about how message keys are wrapped for each party
|
||||
*/
|
||||
export interface KeysMetadata {
|
||||
/** AES key encrypted with sender's RSA public key (base64) */
|
||||
sender_wrapped_key: string;
|
||||
|
||||
/** AES key encrypted with recipient's RSA public key (base64). Null if recipient hasn't generated keys yet */
|
||||
recipient_wrapped_key?: string;
|
||||
|
||||
/** AES key encrypted with admin's RSA public key (base64) */
|
||||
admin_wrapped_key: string;
|
||||
|
||||
/** UUID of the master_keys record used */
|
||||
admin_key_id: string;
|
||||
|
||||
/** Symmetric encryption algorithm */
|
||||
algorithm: 'AES-256-GCM';
|
||||
|
||||
/** Key wrapping algorithm */
|
||||
key_wrap_algorithm: 'RSA-OAEP-SHA256';
|
||||
|
||||
/** True if recipient key needs to be added when they generate their keys */
|
||||
pending_recipient?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Payload for an encrypted text message
|
||||
*/
|
||||
export interface EncryptedMessagePayload {
|
||||
version: 2;
|
||||
encrypted_content: string;
|
||||
encryption_iv: string;
|
||||
keys_metadata: KeysMetadata;
|
||||
}
|
||||
|
||||
/**
|
||||
* Payload for encrypted media
|
||||
*/
|
||||
export interface EncryptedMediaPayload {
|
||||
encryptedFileUri: string;
|
||||
encryption_iv: string;
|
||||
keys_metadata: KeysMetadata;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypted content result
|
||||
*/
|
||||
export interface DecryptedContent {
|
||||
content: string | null;
|
||||
mediaUrl: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* State for useDecryptedMessage hook
|
||||
*/
|
||||
export type DecryptedMessageErrorCode =
|
||||
| 'E2EE_NOT_SUPPORTED'
|
||||
| 'E2EE_KEYS_UNAVAILABLE'
|
||||
| 'E2EE_MISSING_FIELDS'
|
||||
| 'E2EE_PENDING_RECIPIENT_KEY'
|
||||
| 'E2EE_REPAIRING_STALE_KEY'
|
||||
| 'E2EE_DECRYPT_FAILED';
|
||||
|
||||
export interface DecryptedMessageState {
|
||||
content: string | null;
|
||||
mediaUri: string | null;
|
||||
isDecrypting: boolean;
|
||||
error: Error | null;
|
||||
errorCode?: DecryptedMessageErrorCode;
|
||||
}
|
||||
|
||||
/**
|
||||
* RSA public key in JWK format (JSON Web Key)
|
||||
*/
|
||||
export interface RSAPublicKeyJWK {
|
||||
kty: 'RSA';
|
||||
n: string; // modulus
|
||||
e: string; // exponent
|
||||
alg?: string;
|
||||
key_ops?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* RSA private key in JWK format (includes public key components)
|
||||
*/
|
||||
export interface RSAPrivateKeyJWK extends RSAPublicKeyJWK {
|
||||
d: string; // private exponent
|
||||
p: string; // first prime factor
|
||||
q: string; // second prime factor
|
||||
dp: string; // first factor CRT exponent
|
||||
dq: string; // second factor CRT exponent
|
||||
qi: string; // first CRT coefficient
|
||||
}
|
||||
|
||||
/**
|
||||
* State for useEncryptionKeys hook
|
||||
*/
|
||||
export interface EncryptionKeysState {
|
||||
privateKeyJwk: RSAPrivateKeyJWK | null;
|
||||
publicKeyJwk: RSAPublicKeyJWK | null;
|
||||
isLoading: boolean;
|
||||
hasKeys: boolean;
|
||||
error: Error | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Master key record from database
|
||||
*/
|
||||
export interface MasterKey {
|
||||
id: string;
|
||||
key_name: string;
|
||||
public_key_jwk: RSAPublicKeyJWK;
|
||||
is_active: boolean;
|
||||
created_at: string;
|
||||
rotated_at: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Encryption context for sending messages
|
||||
*/
|
||||
export interface EncryptionContext {
|
||||
senderPublicKeyJwk: RSAPublicKeyJWK;
|
||||
recipientPublicKeyJwk: RSAPublicKeyJWK | null;
|
||||
adminPublicKeyJwk: RSAPublicKeyJWK;
|
||||
adminKeyId: string;
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* E2EE Utility Functions
|
||||
*
|
||||
* Base64 and ArrayBuffer conversion utilities for encryption operations.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Convert ArrayBuffer to base64 string
|
||||
*/
|
||||
export function arrayBufferToBase64(buffer: ArrayBuffer): string {
|
||||
const bytes = new Uint8Array(buffer);
|
||||
let binary = '';
|
||||
for (let i = 0; i < bytes.byteLength; i++) {
|
||||
binary += String.fromCharCode(bytes[i]);
|
||||
}
|
||||
return btoa(binary);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert base64 string to ArrayBuffer
|
||||
*/
|
||||
export function base64ToArrayBuffer(base64: string): ArrayBuffer {
|
||||
const binary = atob(base64);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let i = 0; i < binary.length; i++) {
|
||||
bytes[i] = binary.charCodeAt(i);
|
||||
}
|
||||
return bytes.buffer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert Uint8Array to base64 string
|
||||
*/
|
||||
export function uint8ArrayToBase64(array: Uint8Array): string {
|
||||
let binary = '';
|
||||
for (let i = 0; i < array.length; i++) {
|
||||
binary += String.fromCharCode(array[i]);
|
||||
}
|
||||
return btoa(binary);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert base64 string to Uint8Array
|
||||
*/
|
||||
export function base64ToUint8Array(base64: string): Uint8Array {
|
||||
const binary = atob(base64);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let i = 0; i < binary.length; i++) {
|
||||
bytes[i] = binary.charCodeAt(i);
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Split string into chunks of specified size
|
||||
*/
|
||||
export function chunkString(str: string, size: number): string[] {
|
||||
const chunks: string[] = [];
|
||||
for (let i = 0; i < str.length; i += size) {
|
||||
chunks.push(str.slice(i, i + size));
|
||||
}
|
||||
return chunks;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a random string for unique identifiers
|
||||
*/
|
||||
export function generateRandomId(): string {
|
||||
const array = new Uint8Array(16);
|
||||
crypto.getRandomValues(array);
|
||||
return Array.from(array, byte => byte.toString(16).padStart(2, '0')).join('');
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* Crypto polyfill for React Native
|
||||
*
|
||||
* This file must be imported BEFORE any code that uses crypto.
|
||||
* It installs react-native-quick-crypto as the global crypto implementation.
|
||||
*
|
||||
* On web, we skip this since the native Web Crypto API is available.
|
||||
*/
|
||||
|
||||
import { Platform } from 'react-native';
|
||||
|
||||
if (Platform.OS !== 'web') {
|
||||
try {
|
||||
// Import react-native-quick-crypto to install its polyfill
|
||||
const QuickCrypto = require('react-native-quick-crypto');
|
||||
|
||||
// Install the polyfill globally if not already present
|
||||
if (typeof global.crypto === 'undefined') {
|
||||
(global as any).crypto = QuickCrypto;
|
||||
}
|
||||
|
||||
// Also ensure subtle is available
|
||||
if (global.crypto && !global.crypto.subtle) {
|
||||
(global.crypto as any).subtle = QuickCrypto.subtle;
|
||||
}
|
||||
|
||||
console.log('[Crypto] react-native-quick-crypto polyfill installed');
|
||||
} catch (error) {
|
||||
console.error('[Crypto] Failed to install crypto polyfill:', error);
|
||||
console.error('[Crypto] Make sure you have run: npx expo prebuild --clean');
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,23 @@
|
||||
import { create } from "zustand";
|
||||
import { supabase } from "../lib/supabase";
|
||||
import { Events } from "../lib/analytics";
|
||||
import { clearKeys } from "../lib/encryption";
|
||||
import type { Profile, Couple } from "@/types";
|
||||
import type { RSAPublicKeyJWK, RSAPrivateKeyJWK } from "../lib/encryption/types";
|
||||
|
||||
/**
|
||||
* Encryption key state - stored globally to ensure all components
|
||||
* share the same key state and avoid race conditions during encryption.
|
||||
*/
|
||||
interface EncryptionKeyState {
|
||||
privateKeyJwk: RSAPrivateKeyJWK | null;
|
||||
publicKeyJwk: RSAPublicKeyJWK | null;
|
||||
hasKeys: boolean;
|
||||
isLoadingKeys: boolean;
|
||||
keysError: Error | null;
|
||||
/** Incremented when keys change - consumers can use this to trigger re-renders */
|
||||
keysVersion: number;
|
||||
}
|
||||
|
||||
interface AuthState {
|
||||
user: Profile | null;
|
||||
@@ -10,11 +26,19 @@ interface AuthState {
|
||||
isLoading: boolean;
|
||||
isAuthenticated: boolean;
|
||||
|
||||
// Encryption key state (global to avoid hook instance sync issues)
|
||||
encryptionKeys: EncryptionKeyState;
|
||||
|
||||
// Actions
|
||||
fetchUser: () => Promise<void>;
|
||||
fetchCouple: () => Promise<void>;
|
||||
refreshPartner: () => Promise<Profile | null>;
|
||||
signOut: () => Promise<void>;
|
||||
setUser: (user: Profile | null) => void;
|
||||
|
||||
// Encryption key actions
|
||||
setEncryptionKeys: (keys: Partial<EncryptionKeyState>) => void;
|
||||
clearEncryptionKeys: () => void;
|
||||
}
|
||||
|
||||
// Import other stores lazily to avoid circular dependency issues
|
||||
@@ -26,12 +50,22 @@ const getOtherStores = () => {
|
||||
return { useMatchStore, usePacksStore, useMessageStore, useSubscriptionStore };
|
||||
};
|
||||
|
||||
const initialEncryptionKeyState: EncryptionKeyState = {
|
||||
privateKeyJwk: null,
|
||||
publicKeyJwk: null,
|
||||
hasKeys: false,
|
||||
isLoadingKeys: true,
|
||||
keysError: null,
|
||||
keysVersion: 0,
|
||||
};
|
||||
|
||||
export const useAuthStore = create<AuthState>((set, get) => ({
|
||||
user: null,
|
||||
couple: null,
|
||||
partner: null,
|
||||
isLoading: true,
|
||||
isAuthenticated: false,
|
||||
encryptionKeys: initialEncryptionKeyState,
|
||||
|
||||
fetchUser: async () => {
|
||||
try {
|
||||
@@ -49,6 +83,14 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
||||
|
||||
if (authError || !authUser) {
|
||||
console.log("[Auth] Session invalid, signing out:", authError?.message);
|
||||
|
||||
// Clear E2EE keys
|
||||
try {
|
||||
await clearKeys();
|
||||
} catch (error) {
|
||||
console.error("Failed to clear encryption keys:", error);
|
||||
}
|
||||
|
||||
// Session is invalid - clear everything
|
||||
set({ user: null, couple: null, partner: null, isAuthenticated: false, isLoading: false });
|
||||
// Clear other stores
|
||||
@@ -108,8 +150,37 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
||||
set({ couple, partner });
|
||||
},
|
||||
|
||||
/**
|
||||
* Refresh partner's profile data (useful to get latest public_key_jwk for E2EE)
|
||||
* Returns the updated partner profile or null if not found
|
||||
*/
|
||||
refreshPartner: async () => {
|
||||
const user = get().user;
|
||||
if (!user?.couple_id) return null;
|
||||
|
||||
const { data: partner } = await supabase
|
||||
.from("profiles")
|
||||
.select("*")
|
||||
.eq("couple_id", user.couple_id)
|
||||
.neq("id", user.id)
|
||||
.maybeSingle();
|
||||
|
||||
if (partner) {
|
||||
set({ partner });
|
||||
}
|
||||
return partner;
|
||||
},
|
||||
|
||||
signOut: async () => {
|
||||
Events.signOut();
|
||||
|
||||
// Clear E2EE keys from storage
|
||||
try {
|
||||
await clearKeys();
|
||||
} catch (error) {
|
||||
console.error("Failed to clear encryption keys:", error);
|
||||
}
|
||||
|
||||
// Clear local state FIRST to ensure UI updates even if Supabase call fails
|
||||
set({
|
||||
user: null,
|
||||
@@ -117,6 +188,7 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
||||
partner: null,
|
||||
isAuthenticated: false,
|
||||
isLoading: false,
|
||||
encryptionKeys: initialEncryptionKeyState,
|
||||
});
|
||||
// Clear other stores
|
||||
const { useMatchStore, usePacksStore, useMessageStore, useSubscriptionStore } = getOtherStores();
|
||||
@@ -150,4 +222,28 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
||||
useSubscriptionStore.getState().clearSubscription();
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Update encryption key state. This is the ONLY way to update keys
|
||||
* to ensure all consumers see the same state.
|
||||
*/
|
||||
setEncryptionKeys: (keys) => {
|
||||
set((state) => ({
|
||||
encryptionKeys: {
|
||||
...state.encryptionKeys,
|
||||
...keys,
|
||||
// Increment version when keys actually change
|
||||
keysVersion: (keys.publicKeyJwk !== undefined || keys.privateKeyJwk !== undefined)
|
||||
? state.encryptionKeys.keysVersion + 1
|
||||
: state.encryptionKeys.keysVersion,
|
||||
},
|
||||
}));
|
||||
},
|
||||
|
||||
/**
|
||||
* Clear encryption keys (called on sign out or when leaving couple)
|
||||
*/
|
||||
clearEncryptionKeys: () => {
|
||||
set({ encryptionKeys: initialEncryptionKeyState });
|
||||
},
|
||||
}));
|
||||
@@ -36,10 +36,11 @@ export const colors = {
|
||||
successLight: 'rgba(46, 204, 113, 0.15)',
|
||||
warning: '#F39C12',
|
||||
warningLight: 'rgba(243, 156, 18, 0.15)',
|
||||
error: '#E74C3C',
|
||||
errorLight: 'rgba(231, 76, 60, 0.15)',
|
||||
|
||||
// Premium/Luxury
|
||||
error: '#E74C3C',
|
||||
errorLight: 'rgba(231, 76, 60, 0.15)',
|
||||
info: '#53bdeb', // WhatsApp-like blue for read receipts
|
||||
|
||||
// Premium/Luxury
|
||||
premium: {
|
||||
gold: '#D4AF37',
|
||||
goldDark: '#B8860B',
|
||||
|
||||
@@ -1,20 +1,22 @@
|
||||
// User profile (extends Supabase auth.users)
|
||||
export type Gender = 'male' | 'female' | 'non-binary' | 'prefer-not-to-say';
|
||||
|
||||
export interface Profile {
|
||||
id: string;
|
||||
name: string | null;
|
||||
email: string | null;
|
||||
avatar_url: string | null;
|
||||
push_token: string | null;
|
||||
is_premium: boolean;
|
||||
couple_id: string | null;
|
||||
gender: Gender | null;
|
||||
show_explicit_content: boolean;
|
||||
onboarding_completed: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
export interface Profile {
|
||||
id: string;
|
||||
name: string | null;
|
||||
email: string | null;
|
||||
avatar_url: string | null;
|
||||
push_token: string | null;
|
||||
is_premium: boolean;
|
||||
couple_id: string | null;
|
||||
gender: Gender | null;
|
||||
show_explicit_content: boolean;
|
||||
onboarding_completed: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
/** RSA public key for E2EE (JWK format) */
|
||||
public_key_jwk?: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
// Couple pairing
|
||||
export interface Couple {
|
||||
|
||||
+1168
-1027
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,38 @@
|
||||
import { decode, encode } from "https://deno.land/std@0.208.0/encoding/base64.ts";
|
||||
|
||||
export interface KeysMetadata {
|
||||
sender_wrapped_key: string;
|
||||
recipient_wrapped_key?: string;
|
||||
admin_wrapped_key: string;
|
||||
admin_key_id: string;
|
||||
algorithm: string;
|
||||
key_wrap_algorithm: string;
|
||||
pending_recipient?: boolean;
|
||||
}
|
||||
|
||||
export async function unwrapWithAdminKey(
|
||||
wrappedKeyBase64: string,
|
||||
adminPrivateKeyJwk: JsonWebKey
|
||||
): Promise<ArrayBuffer> {
|
||||
const privateKey = await crypto.subtle.importKey(
|
||||
"jwk",
|
||||
adminPrivateKeyJwk,
|
||||
{ name: "RSA-OAEP", hash: "SHA-256" },
|
||||
false,
|
||||
["decrypt"]
|
||||
);
|
||||
|
||||
return await crypto.subtle.decrypt(
|
||||
{ name: "RSA-OAEP" },
|
||||
privateKey,
|
||||
base64ToUint8Array(wrappedKeyBase64)
|
||||
);
|
||||
}
|
||||
|
||||
export function base64ToUint8Array(base64: string): Uint8Array {
|
||||
return decode(base64);
|
||||
}
|
||||
|
||||
export function arrayBufferToBase64(buffer: ArrayBuffer): string {
|
||||
return encode(buffer);
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
// @ts-nocheck
|
||||
import "jsr:@supabase/functions-js/edge-runtime.d.ts";
|
||||
import { createClient } from "https://esm.sh/@supabase/supabase-js@2";
|
||||
import { decode } from "https://deno.land/std@0.208.0/encoding/base64.ts";
|
||||
import { unwrapWithAdminKey, base64ToUint8Array, KeysMetadata } from "../_shared/encryption.ts";
|
||||
|
||||
const corsHeaders = {
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type",
|
||||
"Access-Control-Allow-Methods": "POST, OPTIONS",
|
||||
};
|
||||
|
||||
Deno.serve(async (req) => {
|
||||
if (req.method === "OPTIONS") {
|
||||
return new Response("ok", { headers: corsHeaders });
|
||||
}
|
||||
|
||||
if (req.method !== "POST") {
|
||||
return json({ error: "Method not allowed" }, 405);
|
||||
}
|
||||
|
||||
try {
|
||||
const supabaseAdmin = createClient(
|
||||
Deno.env.get("SUPABASE_URL")!,
|
||||
Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!
|
||||
);
|
||||
|
||||
const user = await getUserFromRequest(supabaseAdmin, req);
|
||||
await assertSuperAdmin(supabaseAdmin, user.id);
|
||||
|
||||
const body = await req.json().catch(() => ({}));
|
||||
const messageId = typeof body?.messageId === "string" ? body.messageId : null;
|
||||
if (!messageId) {
|
||||
return json({ error: "Missing messageId" }, 400);
|
||||
}
|
||||
|
||||
const { data: message, error: messageError } = await supabaseAdmin
|
||||
.from("messages")
|
||||
.select(
|
||||
"id, version, media_path, media_type, encryption_iv, keys_metadata"
|
||||
)
|
||||
.eq("id", messageId)
|
||||
.single();
|
||||
|
||||
if (messageError || !message) {
|
||||
return json({ error: "Message not found" }, 404);
|
||||
}
|
||||
|
||||
const mediaPath = message.media_path as string | null;
|
||||
const mediaType = message.media_type as ("image" | "video" | null);
|
||||
const version = message.version ?? 1;
|
||||
|
||||
if (!mediaPath) {
|
||||
return json({ error: "Message has no media" }, 400);
|
||||
}
|
||||
|
||||
const normalizedPath = normalizeChatMediaPath(mediaPath);
|
||||
|
||||
const { data: mediaBlob, error: downloadError } = await supabaseAdmin.storage
|
||||
.from("chat-media")
|
||||
.download(normalizedPath);
|
||||
|
||||
if (downloadError || !mediaBlob) {
|
||||
return json({ error: `Failed to download media: ${downloadError.message}` }, 500);
|
||||
}
|
||||
|
||||
const encryptedBytes = await mediaBlob.arrayBuffer();
|
||||
|
||||
let outputBytes = encryptedBytes;
|
||||
|
||||
if (version === 2) {
|
||||
const encryptionIv = message.encryption_iv as string | null;
|
||||
const keysMetadata = message.keys_metadata as KeysMetadata | null;
|
||||
|
||||
if (!encryptionIv || !keysMetadata) {
|
||||
return json({ error: "Missing encryption metadata" }, 400);
|
||||
}
|
||||
|
||||
const adminPrivateKeyJwkStr = Deno.env.get("ADMIN_PRIVATE_KEY_JWK");
|
||||
if (!adminPrivateKeyJwkStr) {
|
||||
return json({ error: "Admin decryption key not configured" }, 500);
|
||||
}
|
||||
|
||||
const rawAesKey = await unwrapWithAdminKey(
|
||||
keysMetadata.admin_wrapped_key,
|
||||
JSON.parse(adminPrivateKeyJwkStr)
|
||||
);
|
||||
|
||||
const aesKey = await crypto.subtle.importKey(
|
||||
"raw",
|
||||
rawAesKey,
|
||||
{ name: "AES-GCM" },
|
||||
false,
|
||||
["decrypt"]
|
||||
);
|
||||
|
||||
outputBytes = await crypto.subtle.decrypt(
|
||||
{ name: "AES-GCM", iv: base64ToUint8Array(encryptionIv) },
|
||||
aesKey,
|
||||
encryptedBytes
|
||||
);
|
||||
}
|
||||
|
||||
const contentType =
|
||||
mediaType === "video" ? "video/mp4" : mediaType === "image" ? "image/jpeg" : "application/octet-stream";
|
||||
|
||||
return new Response(outputBytes, {
|
||||
status: 200,
|
||||
headers: {
|
||||
...corsHeaders,
|
||||
"Content-Type": contentType,
|
||||
"Cache-Control": "no-store",
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Unknown error";
|
||||
|
||||
if (message.includes("authorization header") || message.startsWith("Invalid token")) {
|
||||
return json({ error: "Unauthorized" }, 401);
|
||||
}
|
||||
|
||||
if (message === "Access denied") {
|
||||
return json({ error: "Forbidden" }, 403);
|
||||
}
|
||||
|
||||
console.error("admin-decrypt-media error:", error);
|
||||
return json({ error: "Internal server error" }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
function json(body: unknown, status = 200) {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: {
|
||||
...corsHeaders,
|
||||
"Content-Type": "application/json",
|
||||
"Cache-Control": "no-store",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function getUserFromRequest(
|
||||
supabaseAdmin: ReturnType<typeof createClient>,
|
||||
req: Request
|
||||
) {
|
||||
const authHeader = req.headers.get("Authorization");
|
||||
if (!authHeader) {
|
||||
throw new Error("Missing authorization header");
|
||||
}
|
||||
|
||||
const { data, error } = await supabaseAdmin.auth.getUser(
|
||||
authHeader.replace("Bearer ", "")
|
||||
);
|
||||
|
||||
if (error || !data.user) {
|
||||
throw new Error(`Invalid token: ${error?.message || "No user"}`);
|
||||
}
|
||||
|
||||
return data.user;
|
||||
}
|
||||
|
||||
async function assertSuperAdmin(
|
||||
supabaseAdmin: ReturnType<typeof createClient>,
|
||||
userId: string
|
||||
) {
|
||||
const { data, error } = await supabaseAdmin
|
||||
.from("admin_users")
|
||||
.select("role")
|
||||
.eq("user_id", userId)
|
||||
.maybeSingle();
|
||||
|
||||
if (error || !data) {
|
||||
throw new Error("Access denied");
|
||||
}
|
||||
|
||||
if (data.role !== "super_admin") {
|
||||
throw new Error("Access denied");
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeChatMediaPath(mediaPath: string): string {
|
||||
if (mediaPath.startsWith("http")) {
|
||||
const parts = mediaPath.split("/chat-media/");
|
||||
if (parts.length > 1) {
|
||||
return decodeURIComponent(parts[1]);
|
||||
}
|
||||
}
|
||||
|
||||
return mediaPath;
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
// @ts-nocheck
|
||||
import "jsr:@supabase/functions-js/edge-runtime.d.ts";
|
||||
import { createClient } from "https://esm.sh/@supabase/supabase-js@2";
|
||||
import { unwrapWithAdminKey, base64ToUint8Array, KeysMetadata } from "../_shared/encryption.ts";
|
||||
|
||||
const corsHeaders = {
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type",
|
||||
"Access-Control-Allow-Methods": "POST, OPTIONS",
|
||||
};
|
||||
|
||||
Deno.serve(async (req) => {
|
||||
if (req.method === "OPTIONS") {
|
||||
return new Response("ok", { headers: corsHeaders });
|
||||
}
|
||||
|
||||
if (req.method !== "POST") {
|
||||
return json({ error: "Method not allowed" }, 405);
|
||||
}
|
||||
|
||||
try {
|
||||
const supabaseAdmin = createClient(
|
||||
Deno.env.get("SUPABASE_URL")!,
|
||||
Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!
|
||||
);
|
||||
|
||||
const user = await getUserFromRequest(supabaseAdmin, req);
|
||||
await assertSuperAdmin(supabaseAdmin, user.id);
|
||||
|
||||
const body = await req.json().catch(() => ({}));
|
||||
const messageId = typeof body?.messageId === "string" ? body.messageId : null;
|
||||
if (!messageId) {
|
||||
return json({ error: "Missing messageId" }, 400);
|
||||
}
|
||||
|
||||
const { data: message, error: messageError } = await supabaseAdmin
|
||||
.from("messages")
|
||||
.select(
|
||||
"id, version, content, encrypted_content, encryption_iv, keys_metadata, media_path, media_type"
|
||||
)
|
||||
.eq("id", messageId)
|
||||
.single();
|
||||
|
||||
if (messageError || !message) {
|
||||
return json({ error: "Message not found" }, 404);
|
||||
}
|
||||
|
||||
const version = message.version ?? 1;
|
||||
|
||||
if (version !== 2) {
|
||||
return json({
|
||||
version,
|
||||
content: message.content ?? null,
|
||||
media_path: message.media_path ?? null,
|
||||
media_type: message.media_type ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
const encryptedContent = message.encrypted_content as string | null;
|
||||
const encryptionIv = message.encryption_iv as string | null;
|
||||
const keysMetadata = message.keys_metadata as KeysMetadata | null;
|
||||
|
||||
let decryptedContent: string | null = null;
|
||||
|
||||
if (encryptedContent) {
|
||||
if (!encryptionIv || !keysMetadata) {
|
||||
return json({ error: "Missing encryption metadata" }, 400);
|
||||
}
|
||||
|
||||
const adminPrivateKeyJwkStr = Deno.env.get("ADMIN_PRIVATE_KEY_JWK");
|
||||
if (!adminPrivateKeyJwkStr) {
|
||||
return json({ error: "Admin decryption key not configured" }, 500);
|
||||
}
|
||||
|
||||
const rawAesKey = await unwrapWithAdminKey(
|
||||
keysMetadata.admin_wrapped_key,
|
||||
JSON.parse(adminPrivateKeyJwkStr)
|
||||
);
|
||||
|
||||
const aesKey = await crypto.subtle.importKey(
|
||||
"raw",
|
||||
rawAesKey,
|
||||
{ name: "AES-GCM" },
|
||||
false,
|
||||
["decrypt"]
|
||||
);
|
||||
|
||||
const plaintext = await crypto.subtle.decrypt(
|
||||
{ name: "AES-GCM", iv: base64ToUint8Array(encryptionIv) },
|
||||
aesKey,
|
||||
base64ToUint8Array(encryptedContent)
|
||||
);
|
||||
|
||||
decryptedContent = new TextDecoder().decode(plaintext);
|
||||
}
|
||||
|
||||
return json({
|
||||
version,
|
||||
content: decryptedContent,
|
||||
media_path: message.media_path ?? null,
|
||||
media_type: message.media_type ?? null,
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Unknown error";
|
||||
|
||||
if (message.includes("authorization header") || message.startsWith("Invalid token")) {
|
||||
return json({ error: "Unauthorized" }, 401);
|
||||
}
|
||||
|
||||
if (message === "Access denied") {
|
||||
return json({ error: "Forbidden" }, 403);
|
||||
}
|
||||
|
||||
console.error("admin-decrypt-message error:", error);
|
||||
return json({ error: "Internal server error" }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
function json(body: unknown, status = 200, extraHeaders: Record<string, string> = {}) {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: {
|
||||
...corsHeaders,
|
||||
"Content-Type": "application/json",
|
||||
"Cache-Control": "no-store",
|
||||
...extraHeaders,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function getUserFromRequest(
|
||||
supabaseAdmin: ReturnType<typeof createClient>,
|
||||
req: Request
|
||||
) {
|
||||
const authHeader = req.headers.get("Authorization");
|
||||
if (!authHeader) {
|
||||
throw new Error("Missing authorization header");
|
||||
}
|
||||
|
||||
const { data, error } = await supabaseAdmin.auth.getUser(
|
||||
authHeader.replace("Bearer ", "")
|
||||
);
|
||||
|
||||
if (error || !data.user) {
|
||||
throw new Error(`Invalid token: ${error?.message || "No user"}`);
|
||||
}
|
||||
|
||||
return data.user;
|
||||
}
|
||||
|
||||
async function assertSuperAdmin(
|
||||
supabaseAdmin: ReturnType<typeof createClient>,
|
||||
userId: string
|
||||
) {
|
||||
const { data, error } = await supabaseAdmin
|
||||
.from("admin_users")
|
||||
.select("role")
|
||||
.eq("user_id", userId)
|
||||
.maybeSingle();
|
||||
|
||||
if (error || !data) {
|
||||
throw new Error("Access denied");
|
||||
}
|
||||
|
||||
if (data.role !== "super_admin") {
|
||||
throw new Error("Access denied");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
// @ts-nocheck
|
||||
import "jsr:@supabase/functions-js/edge-runtime.d.ts";
|
||||
import { createClient } from "https://esm.sh/@supabase/supabase-js@2";
|
||||
import { unwrapWithAdminKey, base64ToUint8Array, arrayBufferToBase64, KeysMetadata } from "../_shared/encryption.ts";
|
||||
|
||||
const corsHeaders = {
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type",
|
||||
"Access-Control-Allow-Methods": "POST, OPTIONS",
|
||||
};
|
||||
|
||||
Deno.serve(async (req) => {
|
||||
if (req.method === "OPTIONS") {
|
||||
return new Response("ok", { headers: corsHeaders });
|
||||
}
|
||||
|
||||
try {
|
||||
const supabaseAdmin = createClient(
|
||||
Deno.env.get("SUPABASE_URL")!,
|
||||
Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!
|
||||
);
|
||||
|
||||
const body = await req.json().catch(() => ({}));
|
||||
const messageId = body.messageId || body.message_id; // Support both cases
|
||||
|
||||
if (!messageId) {
|
||||
return new Response(JSON.stringify({ error: "Missing message_id" }), { status: 400, headers: corsHeaders });
|
||||
}
|
||||
|
||||
// 1. Fetch AI Config
|
||||
const { data: config, error: configError } = await supabaseAdmin
|
||||
.from("ai_config")
|
||||
.select("*")
|
||||
.single();
|
||||
|
||||
if (configError || !config) {
|
||||
console.error("Failed to fetch AI config:", configError);
|
||||
return new Response(JSON.stringify({ error: "Configuration missing" }), { status: 500, headers: corsHeaders });
|
||||
}
|
||||
|
||||
if (!config.classifier_enabled) {
|
||||
return new Response(JSON.stringify({ message: "Classifier disabled" }), { status: 200, headers: corsHeaders });
|
||||
}
|
||||
|
||||
const apiKey = config.openrouter_api_key || Deno.env.get("OPENROUTER_API_KEY");
|
||||
if (!apiKey) {
|
||||
console.error("OpenRouter API Key missing");
|
||||
return new Response(JSON.stringify({ error: "API Key missing" }), { status: 500, headers: corsHeaders });
|
||||
}
|
||||
|
||||
// 2. Fetch Message
|
||||
const { data: message, error: messageError } = await supabaseAdmin
|
||||
.from("messages")
|
||||
.select("id, version, content, encrypted_content, encryption_iv, keys_metadata, media_path, media_type")
|
||||
.eq("id", messageId)
|
||||
.single();
|
||||
|
||||
if (messageError || !message) {
|
||||
return new Response(JSON.stringify({ error: "Message not found" }), { status: 404, headers: corsHeaders });
|
||||
}
|
||||
|
||||
// 3. Decrypt Content
|
||||
const version = message.version ?? 1;
|
||||
let decryptedText = message.content;
|
||||
let mediaBase64: string | null = null;
|
||||
let mediaMimeType: string | null = null;
|
||||
|
||||
if (version === 2) {
|
||||
const keysMetadata = message.keys_metadata as KeysMetadata;
|
||||
const encryptionIv = message.encryption_iv;
|
||||
|
||||
if (!keysMetadata || !encryptionIv) {
|
||||
return new Response(JSON.stringify({ error: "Missing encryption metadata" }), { status: 400, headers: corsHeaders });
|
||||
}
|
||||
|
||||
const adminPrivateKeyJwkStr = Deno.env.get("ADMIN_PRIVATE_KEY_JWK");
|
||||
if (!adminPrivateKeyJwkStr) {
|
||||
throw new Error("ADMIN_PRIVATE_KEY_JWK not set");
|
||||
}
|
||||
|
||||
const adminPrivateKey = JSON.parse(adminPrivateKeyJwkStr);
|
||||
const rawAesKey = await unwrapWithAdminKey(keysMetadata.admin_wrapped_key, adminPrivateKey);
|
||||
|
||||
const aesKey = await crypto.subtle.importKey(
|
||||
"raw",
|
||||
rawAesKey,
|
||||
{ name: "AES-GCM" },
|
||||
false,
|
||||
["decrypt"]
|
||||
);
|
||||
|
||||
// Decrypt Text
|
||||
if (message.encrypted_content) {
|
||||
const plaintext = await crypto.subtle.decrypt(
|
||||
{ name: "AES-GCM", iv: base64ToUint8Array(encryptionIv) },
|
||||
aesKey,
|
||||
base64ToUint8Array(message.encrypted_content)
|
||||
);
|
||||
decryptedText = new TextDecoder().decode(plaintext);
|
||||
}
|
||||
|
||||
// Decrypt Media (Image only for now)
|
||||
if (message.media_path && message.media_type === "image") {
|
||||
const { data: mediaBlob, error: downloadError } = await supabaseAdmin.storage
|
||||
.from("chat-media")
|
||||
.download(normalizeChatMediaPath(message.media_path));
|
||||
|
||||
if (!downloadError && mediaBlob) {
|
||||
const encryptedBytes = await mediaBlob.arrayBuffer();
|
||||
const decryptedBytes = await crypto.subtle.decrypt(
|
||||
{ name: "AES-GCM", iv: base64ToUint8Array(encryptionIv) },
|
||||
aesKey,
|
||||
encryptedBytes
|
||||
);
|
||||
mediaBase64 = arrayBufferToBase64(decryptedBytes);
|
||||
mediaMimeType = "image/jpeg"; // Default or infer?
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Version 1 (Plaintext)
|
||||
if (message.media_path && message.media_type === "image") {
|
||||
const { data: mediaBlob } = await supabaseAdmin.storage
|
||||
.from("chat-media")
|
||||
.download(normalizeChatMediaPath(message.media_path));
|
||||
if (mediaBlob) {
|
||||
const buffer = await mediaBlob.arrayBuffer();
|
||||
mediaBase64 = arrayBufferToBase64(buffer);
|
||||
mediaMimeType = "image/jpeg";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Construct LLM Payload
|
||||
const messages = [
|
||||
{ role: "system", content: config.classifier_prompt },
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
decryptedText ? { type: "text", text: decryptedText } : null,
|
||||
mediaBase64 ? {
|
||||
type: "image_url",
|
||||
image_url: { url: `data:${mediaMimeType};base64,${mediaBase64}` }
|
||||
} : null
|
||||
].filter(Boolean)
|
||||
}
|
||||
];
|
||||
|
||||
// If no content to classify, mark safe
|
||||
if (messages[1].content.length === 0) {
|
||||
await updateStatus(supabaseAdmin, messageId, "safe", "No content");
|
||||
return new Response(JSON.stringify({ status: "safe" }), { headers: corsHeaders });
|
||||
}
|
||||
|
||||
// 5. Call OpenRouter
|
||||
const response = await fetch("https://openrouter.ai/api/v1/chat/completions", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Authorization": `Bearer ${apiKey}`,
|
||||
"Content-Type": "application/json",
|
||||
"HTTP-Referer": "https://sauci.app",
|
||||
"X-Title": "Sauci"
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: config.classifier_model || "openai/gpt-4o",
|
||||
messages: messages,
|
||||
response_format: { type: "json_object" }
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errText = await response.text();
|
||||
console.error("OpenRouter Error:", errText);
|
||||
throw new Error(`OpenRouter API Error: ${response.status}`);
|
||||
}
|
||||
|
||||
const aiResult = await response.json();
|
||||
const contentStr = aiResult.choices?.[0]?.message?.content;
|
||||
|
||||
if (!contentStr) {
|
||||
throw new Error("Empty response from AI");
|
||||
}
|
||||
|
||||
let classification;
|
||||
try {
|
||||
classification = JSON.parse(contentStr);
|
||||
} catch (e) {
|
||||
console.error("Failed to parse JSON:", contentStr);
|
||||
// Fallback: simple text check
|
||||
const lower = contentStr.toLowerCase();
|
||||
if (lower.includes("flagged") || lower.includes("unsafe")) {
|
||||
classification = { status: "flagged", reason: "AI output parsing failed, but keyword detected." };
|
||||
} else {
|
||||
classification = { status: "safe" };
|
||||
}
|
||||
}
|
||||
|
||||
// 6. Update Message
|
||||
const status = classification.status === "flagged" ? "flagged" : "safe";
|
||||
const reason = classification.reason || null;
|
||||
const category = classification.category || "Neutral"; // Default to Neutral if missing
|
||||
|
||||
await updateStatus(supabaseAdmin, messageId, status, reason, category);
|
||||
|
||||
return new Response(JSON.stringify({ success: true, classification }), { headers: corsHeaders });
|
||||
|
||||
} catch (error) {
|
||||
console.error("Classify Error:", error);
|
||||
return new Response(JSON.stringify({ error: error.message }), { status: 500, headers: corsHeaders });
|
||||
}
|
||||
});
|
||||
|
||||
async function updateStatus(supabase, id, status, reason, category) {
|
||||
await supabase.from("messages").update({
|
||||
moderation_status: status,
|
||||
flag_reason: reason,
|
||||
category: category
|
||||
}).eq("id", id);
|
||||
}
|
||||
|
||||
function normalizeChatMediaPath(mediaPath: string): string {
|
||||
if (mediaPath.startsWith("http")) {
|
||||
const parts = mediaPath.split("/chat-media/");
|
||||
if (parts.length > 1) {
|
||||
return decodeURIComponent(parts[1]);
|
||||
}
|
||||
}
|
||||
return mediaPath;
|
||||
}
|
||||
@@ -0,0 +1,315 @@
|
||||
import "jsr:@supabase/functions-js/edge-runtime.d.ts";
|
||||
import { createClient } from "https://esm.sh/@supabase/supabase-js@2";
|
||||
|
||||
const corsHeaders = {
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type",
|
||||
};
|
||||
|
||||
// Only re-wrap messages from the last 7 days synchronously
|
||||
// Older messages will be repaired on-demand via repair-stale-key
|
||||
const RECENT_DAYS = 7;
|
||||
const MAX_MESSAGES_PER_REQUEST = 500;
|
||||
|
||||
/**
|
||||
* Handles key rotation when a user gets a new device.
|
||||
*
|
||||
* When a user's public key changes (detected via database trigger or called manually),
|
||||
* this function re-wraps RECENT message AES keys with the user's new public key.
|
||||
* Older messages are repaired on-demand when the user tries to view them.
|
||||
* This prevents timeouts for couples with large message histories.
|
||||
*/
|
||||
Deno.serve(async (req) => {
|
||||
// Handle CORS preflight
|
||||
if (req.method === "OPTIONS") {
|
||||
return new Response("ok", { headers: corsHeaders });
|
||||
}
|
||||
|
||||
try {
|
||||
const supabaseAdmin = createClient(
|
||||
Deno.env.get("SUPABASE_URL")!,
|
||||
Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!
|
||||
);
|
||||
|
||||
// Get user from JWT
|
||||
const authHeader = req.headers.get("Authorization");
|
||||
if (!authHeader) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: "Missing authorization header" }),
|
||||
{ status: 401, headers: { ...corsHeaders, "Content-Type": "application/json" } }
|
||||
);
|
||||
}
|
||||
|
||||
const { data: { user }, error: authError } = await supabaseAdmin.auth.getUser(
|
||||
authHeader.replace("Bearer ", "")
|
||||
);
|
||||
|
||||
if (authError || !user) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: `Invalid token: ${authError?.message || 'No user found'}` }),
|
||||
{ status: 401, headers: { ...corsHeaders, "Content-Type": "application/json" } }
|
||||
);
|
||||
}
|
||||
|
||||
// Get user's couple_id and new public key
|
||||
const { data: profile, error: profileError } = await supabaseAdmin
|
||||
.from("profiles")
|
||||
.select("couple_id, public_key_jwk")
|
||||
.eq("id", user.id)
|
||||
.maybeSingle();
|
||||
|
||||
if (profileError || !profile) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: "Profile not found" }),
|
||||
{ status: 404, headers: { ...corsHeaders, "Content-Type": "application/json" } }
|
||||
);
|
||||
}
|
||||
|
||||
if (!profile.couple_id) {
|
||||
return new Response(
|
||||
JSON.stringify({ success: true, updated: 0, reason: "No couple" }),
|
||||
{ headers: { ...corsHeaders, "Content-Type": "application/json" } }
|
||||
);
|
||||
}
|
||||
|
||||
if (!profile.public_key_jwk) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: "User has no public key" }),
|
||||
{ status: 400, headers: { ...corsHeaders, "Content-Type": "application/json" } }
|
||||
);
|
||||
}
|
||||
|
||||
// Find all matches for this couple
|
||||
const { data: matches } = await supabaseAdmin
|
||||
.from("matches")
|
||||
.select("id")
|
||||
.eq("couple_id", profile.couple_id);
|
||||
|
||||
const matchIds = matches?.map(m => m.id) || [];
|
||||
|
||||
if (matchIds.length === 0) {
|
||||
return new Response(
|
||||
JSON.stringify({ success: true, updated: 0, reason: "No matches found" }),
|
||||
{ headers: { ...corsHeaders, "Content-Type": "application/json" } }
|
||||
);
|
||||
}
|
||||
|
||||
// Calculate cutoff date for recent messages
|
||||
const recentCutoff = new Date(Date.now() - RECENT_DAYS * 24 * 60 * 60 * 1000);
|
||||
|
||||
// Find RECENT v2 messages in these matches (lazy re-encryption)
|
||||
const { data: messages, error: messagesError } = await supabaseAdmin
|
||||
.from("messages")
|
||||
.select("id, user_id, keys_metadata, created_at")
|
||||
.eq("version", 2)
|
||||
.in("match_id", matchIds)
|
||||
.gte("created_at", recentCutoff.toISOString())
|
||||
.order("created_at", { ascending: false })
|
||||
.limit(MAX_MESSAGES_PER_REQUEST);
|
||||
|
||||
if (messagesError) {
|
||||
console.error("Error fetching messages:", messagesError);
|
||||
return new Response(
|
||||
JSON.stringify({ error: "Failed to fetch messages" }),
|
||||
{ status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" } }
|
||||
);
|
||||
}
|
||||
|
||||
// Check if there are older messages that weren't processed
|
||||
const { count: totalCount } = await supabaseAdmin
|
||||
.from("messages")
|
||||
.select("id", { count: "exact", head: true })
|
||||
.eq("version", 2)
|
||||
.in("match_id", matchIds);
|
||||
|
||||
const hasOlderMessages = (totalCount ?? 0) > (messages?.length ?? 0);
|
||||
|
||||
let updatedCount = 0;
|
||||
let errorCount = 0;
|
||||
|
||||
for (const message of messages || []) {
|
||||
const metadata = message.keys_metadata as {
|
||||
sender_wrapped_key: string;
|
||||
recipient_wrapped_key?: string;
|
||||
admin_wrapped_key: string;
|
||||
admin_key_id: string;
|
||||
algorithm: string;
|
||||
key_wrap_algorithm: string;
|
||||
pending_recipient?: boolean;
|
||||
};
|
||||
|
||||
const isSender = message.user_id === user.id;
|
||||
|
||||
try {
|
||||
// Get admin private key - supports multi-key lookup
|
||||
const adminPrivateKeyJwk = getAdminPrivateKey(metadata.admin_key_id);
|
||||
if (!adminPrivateKeyJwk) {
|
||||
console.error(`Admin key ${metadata.admin_key_id} not found for message ${message.id}`);
|
||||
errorCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Unwrap AES key using admin key
|
||||
const rawAesKey = await unwrapWithAdminKey(
|
||||
metadata.admin_wrapped_key,
|
||||
adminPrivateKeyJwk
|
||||
);
|
||||
|
||||
// Re-wrap with new public key
|
||||
const newWrappedKey = await wrapForRecipient(
|
||||
rawAesKey,
|
||||
profile.public_key_jwk
|
||||
);
|
||||
|
||||
// Update the appropriate field based on whether user is sender or recipient
|
||||
const updatedMetadata = { ...metadata };
|
||||
if (isSender) {
|
||||
updatedMetadata.sender_wrapped_key = newWrappedKey;
|
||||
} else {
|
||||
updatedMetadata.recipient_wrapped_key = newWrappedKey;
|
||||
updatedMetadata.pending_recipient = false;
|
||||
}
|
||||
|
||||
const { error: updateError } = await supabaseAdmin
|
||||
.from("messages")
|
||||
.update({ keys_metadata: updatedMetadata })
|
||||
.eq("id", message.id);
|
||||
|
||||
if (updateError) {
|
||||
console.error(`Failed to update message ${message.id}:`, updateError);
|
||||
errorCount++;
|
||||
} else {
|
||||
updatedCount++;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`Failed to re-encrypt message ${message.id}:`, err);
|
||||
errorCount++;
|
||||
}
|
||||
}
|
||||
|
||||
// Log the key rotation event for audit purposes
|
||||
console.log(`Key rotation completed for user ${user.id}: ${updatedCount} messages updated, ${errorCount} errors, hasOlderMessages=${hasOlderMessages}`);
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
success: true,
|
||||
updated: updatedCount,
|
||||
errors: errorCount,
|
||||
has_older_messages: hasOlderMessages,
|
||||
recent_days: RECENT_DAYS,
|
||||
}),
|
||||
{ headers: { ...corsHeaders, "Content-Type": "application/json" } }
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Error:", error);
|
||||
return new Response(
|
||||
JSON.stringify({ error: "Internal server error" }),
|
||||
{ status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" } }
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Get admin private key by ID.
|
||||
* Supports multi-key format: ADMIN_KEYS_JSON = { "uuid1": {...}, "uuid2": {...} }
|
||||
* Falls back to single-key format: ADMIN_PRIVATE_KEY_JWK = {...}
|
||||
*/
|
||||
function getAdminPrivateKey(keyId: string): JsonWebKey | null {
|
||||
// Try multi-key format first
|
||||
const keysJson = Deno.env.get("ADMIN_KEYS_JSON");
|
||||
if (keysJson) {
|
||||
try {
|
||||
const keys = JSON.parse(keysJson);
|
||||
if (keys[keyId]) {
|
||||
return keys[keyId];
|
||||
}
|
||||
} catch {
|
||||
console.error("Failed to parse ADMIN_KEYS_JSON");
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to single-key format
|
||||
const singleKeyJson = Deno.env.get("ADMIN_PRIVATE_KEY_JWK");
|
||||
if (singleKeyJson) {
|
||||
try {
|
||||
return JSON.parse(singleKeyJson);
|
||||
} catch {
|
||||
console.error("Failed to parse ADMIN_PRIVATE_KEY_JWK");
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// Crypto helper functions
|
||||
async function unwrapWithAdminKey(
|
||||
wrappedKeyBase64: string,
|
||||
adminPrivateKeyJwk: JsonWebKey
|
||||
): Promise<ArrayBuffer> {
|
||||
const privateKey = await crypto.subtle.importKey(
|
||||
"jwk",
|
||||
adminPrivateKeyJwk,
|
||||
{ name: "RSA-OAEP", hash: "SHA-256" },
|
||||
false,
|
||||
["decrypt"]
|
||||
);
|
||||
|
||||
const wrappedKey = base64ToArrayBuffer(wrappedKeyBase64);
|
||||
return await crypto.subtle.decrypt(
|
||||
{ name: "RSA-OAEP" },
|
||||
privateKey,
|
||||
wrappedKey
|
||||
);
|
||||
}
|
||||
|
||||
async function wrapForRecipient(
|
||||
rawAesKey: ArrayBuffer,
|
||||
recipientPublicKeyJwk: JsonWebKey
|
||||
): Promise<string> {
|
||||
// Sanitize JWK to ensure Base64URL encoding for n and e
|
||||
const sanitizedJwk = { ...recipientPublicKeyJwk };
|
||||
if (sanitizedJwk.n) {
|
||||
sanitizedJwk.n = toBase64Url(sanitizedJwk.n);
|
||||
}
|
||||
if (sanitizedJwk.e) {
|
||||
sanitizedJwk.e = toBase64Url(sanitizedJwk.e);
|
||||
}
|
||||
|
||||
const publicKey = await crypto.subtle.importKey(
|
||||
"jwk",
|
||||
sanitizedJwk,
|
||||
{ name: "RSA-OAEP", hash: "SHA-256" },
|
||||
false,
|
||||
["encrypt"]
|
||||
);
|
||||
|
||||
const wrappedKey = await crypto.subtle.encrypt(
|
||||
{ name: "RSA-OAEP" },
|
||||
publicKey,
|
||||
rawAesKey
|
||||
);
|
||||
|
||||
return arrayBufferToBase64(wrappedKey);
|
||||
}
|
||||
|
||||
function toBase64Url(base64: string): string {
|
||||
return base64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
||||
}
|
||||
|
||||
function base64ToArrayBuffer(base64: string): ArrayBuffer {
|
||||
const binary = atob(base64);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let i = 0; i < binary.length; i++) {
|
||||
bytes[i] = binary.charCodeAt(i);
|
||||
}
|
||||
return bytes.buffer;
|
||||
}
|
||||
|
||||
function arrayBufferToBase64(buffer: ArrayBuffer): string {
|
||||
const bytes = new Uint8Array(buffer);
|
||||
let binary = "";
|
||||
for (let i = 0; i < bytes.byteLength; i++) {
|
||||
binary += String.fromCharCode(bytes[i]);
|
||||
}
|
||||
return btoa(binary);
|
||||
}
|
||||
@@ -0,0 +1,282 @@
|
||||
import "jsr:@supabase/functions-js/edge-runtime.d.ts";
|
||||
import { createClient } from "https://esm.sh/@supabase/supabase-js@2";
|
||||
|
||||
const corsHeaders = {
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type",
|
||||
};
|
||||
|
||||
/**
|
||||
* Re-encrypts pending messages when a partner generates their encryption keys.
|
||||
*
|
||||
* Called when a user uploads their public key and their partner already
|
||||
* sent messages with pending_recipient = true.
|
||||
*/
|
||||
Deno.serve(async (req) => {
|
||||
// Handle CORS preflight
|
||||
if (req.method === "OPTIONS") {
|
||||
return new Response("ok", { headers: corsHeaders });
|
||||
}
|
||||
|
||||
try {
|
||||
const supabaseAdmin = createClient(
|
||||
Deno.env.get("SUPABASE_URL")!,
|
||||
Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!
|
||||
);
|
||||
|
||||
// Get user from JWT
|
||||
const authHeader = req.headers.get("Authorization");
|
||||
if (!authHeader) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: "Missing authorization header" }),
|
||||
{ status: 401, headers: { ...corsHeaders, "Content-Type": "application/json" } }
|
||||
);
|
||||
}
|
||||
|
||||
const { data: { user }, error: authError } = await supabaseAdmin.auth.getUser(
|
||||
authHeader.replace("Bearer ", "")
|
||||
);
|
||||
|
||||
if (authError || !user) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: `Invalid token: ${authError?.message || 'No user found'}` }),
|
||||
{ status: 401, headers: { ...corsHeaders, "Content-Type": "application/json" } }
|
||||
);
|
||||
}
|
||||
|
||||
// Get user's couple_id and public key
|
||||
const { data: profile, error: profileError } = await supabaseAdmin
|
||||
.from("profiles")
|
||||
.select("couple_id, public_key_jwk")
|
||||
.eq("id", user.id)
|
||||
.maybeSingle();
|
||||
|
||||
if (profileError || !profile) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: "Profile not found" }),
|
||||
{ status: 404, headers: { ...corsHeaders, "Content-Type": "application/json" } }
|
||||
);
|
||||
}
|
||||
|
||||
if (!profile.couple_id) {
|
||||
return new Response(
|
||||
JSON.stringify({ success: true, updated: 0, reason: "No couple" }),
|
||||
{ headers: { ...corsHeaders, "Content-Type": "application/json" } }
|
||||
);
|
||||
}
|
||||
|
||||
if (!profile.public_key_jwk) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: "User has no public key" }),
|
||||
{ status: 400, headers: { ...corsHeaders, "Content-Type": "application/json" } }
|
||||
);
|
||||
}
|
||||
|
||||
// Find matches for this couple
|
||||
const { data: matches } = await supabaseAdmin
|
||||
.from("matches")
|
||||
.select("id")
|
||||
.eq("couple_id", profile.couple_id);
|
||||
|
||||
const matchIds = matches?.map(m => m.id) || [];
|
||||
|
||||
if (matchIds.length === 0) {
|
||||
return new Response(
|
||||
JSON.stringify({ success: true, updated: 0, reason: "No matches found" }),
|
||||
{ headers: { ...corsHeaders, "Content-Type": "application/json" } }
|
||||
);
|
||||
}
|
||||
|
||||
// Find v2 messages where this user is the recipient but doesn't have their wrapped key yet
|
||||
const { data: messages, error: messagesError } = await supabaseAdmin
|
||||
.from("messages")
|
||||
.select("id, user_id, keys_metadata")
|
||||
.eq("version", 2)
|
||||
.in("match_id", matchIds)
|
||||
.neq("user_id", user.id); // Messages sent TO this user (they are recipient)
|
||||
|
||||
if (messagesError) {
|
||||
console.error("Error fetching messages:", messagesError);
|
||||
return new Response(
|
||||
JSON.stringify({ error: "Failed to fetch messages" }),
|
||||
{ status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" } }
|
||||
);
|
||||
}
|
||||
|
||||
let updatedCount = 0;
|
||||
|
||||
for (const message of messages || []) {
|
||||
const metadata = message.keys_metadata as {
|
||||
sender_wrapped_key: string;
|
||||
recipient_wrapped_key?: string;
|
||||
admin_wrapped_key: string;
|
||||
admin_key_id: string;
|
||||
algorithm: string;
|
||||
key_wrap_algorithm: string;
|
||||
pending_recipient?: boolean;
|
||||
};
|
||||
|
||||
// Only process if pending_recipient OR missing recipient key
|
||||
if (!metadata.pending_recipient && metadata.recipient_wrapped_key) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
// Get admin private key - supports multi-key lookup
|
||||
const adminPrivateKeyJwk = getAdminPrivateKey(metadata.admin_key_id);
|
||||
if (!adminPrivateKeyJwk) {
|
||||
console.error(`Admin key ${metadata.admin_key_id} not found for message ${message.id}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Unwrap AES key using admin key
|
||||
const rawAesKey = await unwrapWithAdminKey(
|
||||
metadata.admin_wrapped_key,
|
||||
adminPrivateKeyJwk
|
||||
);
|
||||
|
||||
// Re-wrap for recipient
|
||||
const recipientWrappedKey = await wrapForRecipient(
|
||||
rawAesKey,
|
||||
profile.public_key_jwk
|
||||
);
|
||||
|
||||
// Update message
|
||||
const { error: updateError } = await supabaseAdmin
|
||||
.from("messages")
|
||||
.update({
|
||||
keys_metadata: {
|
||||
...metadata,
|
||||
recipient_wrapped_key: recipientWrappedKey,
|
||||
pending_recipient: false,
|
||||
}
|
||||
})
|
||||
.eq("id", message.id);
|
||||
|
||||
if (updateError) {
|
||||
console.error(`Failed to update message ${message.id}:`, updateError);
|
||||
} else {
|
||||
updatedCount++;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`Failed to re-encrypt message ${message.id}:`, err);
|
||||
}
|
||||
}
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({ success: true, updated: updatedCount }),
|
||||
{ headers: { ...corsHeaders, "Content-Type": "application/json" } }
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Error:", error);
|
||||
return new Response(
|
||||
JSON.stringify({ error: "Internal server error" }),
|
||||
{ status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" } }
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Get admin private key by ID.
|
||||
* Supports multi-key format: ADMIN_KEYS_JSON = { "uuid1": {...}, "uuid2": {...} }
|
||||
* Falls back to single-key format: ADMIN_PRIVATE_KEY_JWK = {...}
|
||||
*/
|
||||
function getAdminPrivateKey(keyId: string): JsonWebKey | null {
|
||||
// Try multi-key format first
|
||||
const keysJson = Deno.env.get("ADMIN_KEYS_JSON");
|
||||
if (keysJson) {
|
||||
try {
|
||||
const keys = JSON.parse(keysJson);
|
||||
if (keys[keyId]) {
|
||||
return keys[keyId];
|
||||
}
|
||||
} catch {
|
||||
console.error("Failed to parse ADMIN_KEYS_JSON");
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to single-key format
|
||||
const singleKeyJson = Deno.env.get("ADMIN_PRIVATE_KEY_JWK");
|
||||
if (singleKeyJson) {
|
||||
try {
|
||||
return JSON.parse(singleKeyJson);
|
||||
} catch {
|
||||
console.error("Failed to parse ADMIN_PRIVATE_KEY_JWK");
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// Crypto helper functions
|
||||
async function unwrapWithAdminKey(
|
||||
wrappedKeyBase64: string,
|
||||
adminPrivateKeyJwk: JsonWebKey
|
||||
): Promise<ArrayBuffer> {
|
||||
const privateKey = await crypto.subtle.importKey(
|
||||
"jwk",
|
||||
adminPrivateKeyJwk,
|
||||
{ name: "RSA-OAEP", hash: "SHA-256" },
|
||||
false,
|
||||
["decrypt"]
|
||||
);
|
||||
|
||||
const wrappedKey = base64ToArrayBuffer(wrappedKeyBase64);
|
||||
return await crypto.subtle.decrypt(
|
||||
{ name: "RSA-OAEP" },
|
||||
privateKey,
|
||||
wrappedKey
|
||||
);
|
||||
}
|
||||
|
||||
async function wrapForRecipient(
|
||||
rawAesKey: ArrayBuffer,
|
||||
recipientPublicKeyJwk: JsonWebKey
|
||||
): Promise<string> {
|
||||
// Sanitize JWK to ensure Base64URL encoding for n and e
|
||||
const sanitizedJwk = { ...recipientPublicKeyJwk };
|
||||
if (sanitizedJwk.n) {
|
||||
sanitizedJwk.n = toBase64Url(sanitizedJwk.n);
|
||||
}
|
||||
if (sanitizedJwk.e) {
|
||||
sanitizedJwk.e = toBase64Url(sanitizedJwk.e);
|
||||
}
|
||||
|
||||
const publicKey = await crypto.subtle.importKey(
|
||||
"jwk",
|
||||
sanitizedJwk,
|
||||
{ name: "RSA-OAEP", hash: "SHA-256" },
|
||||
false,
|
||||
["encrypt"]
|
||||
);
|
||||
|
||||
const wrappedKey = await crypto.subtle.encrypt(
|
||||
{ name: "RSA-OAEP" },
|
||||
publicKey,
|
||||
rawAesKey
|
||||
);
|
||||
|
||||
return arrayBufferToBase64(wrappedKey);
|
||||
}
|
||||
|
||||
function toBase64Url(base64: string): string {
|
||||
return base64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
||||
}
|
||||
|
||||
function base64ToArrayBuffer(base64: string): ArrayBuffer {
|
||||
const binary = atob(base64);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let i = 0; i < binary.length; i++) {
|
||||
bytes[i] = binary.charCodeAt(i);
|
||||
}
|
||||
return bytes.buffer;
|
||||
}
|
||||
|
||||
function arrayBufferToBase64(buffer: ArrayBuffer): string {
|
||||
const bytes = new Uint8Array(buffer);
|
||||
let binary = "";
|
||||
for (let i = 0; i < bytes.byteLength; i++) {
|
||||
binary += String.fromCharCode(bytes[i]);
|
||||
}
|
||||
return btoa(binary);
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
import "jsr:@supabase/functions-js/edge-runtime.d.ts";
|
||||
import { createClient } from "https://esm.sh/@supabase/supabase-js@2";
|
||||
|
||||
const corsHeaders = {
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type",
|
||||
};
|
||||
|
||||
/**
|
||||
* Repairs a message encrypted with a stale recipient key.
|
||||
*
|
||||
* When a message was encrypted while offline and the recipient's key changed
|
||||
* before sync, the recipient can't decrypt. This function uses the admin key
|
||||
* to re-wrap the AES key for the recipient's current public key.
|
||||
*/
|
||||
Deno.serve(async (req) => {
|
||||
// Handle CORS preflight
|
||||
if (req.method === "OPTIONS") {
|
||||
return new Response("ok", { headers: corsHeaders });
|
||||
}
|
||||
|
||||
try {
|
||||
const supabaseAdmin = createClient(
|
||||
Deno.env.get("SUPABASE_URL")!,
|
||||
Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!
|
||||
);
|
||||
|
||||
// Get user from JWT
|
||||
const authHeader = req.headers.get("Authorization");
|
||||
if (!authHeader) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: "Missing authorization header" }),
|
||||
{ status: 401, headers: { ...corsHeaders, "Content-Type": "application/json" } }
|
||||
);
|
||||
}
|
||||
|
||||
const { data: { user }, error: authError } = await supabaseAdmin.auth.getUser(
|
||||
authHeader.replace("Bearer ", "")
|
||||
);
|
||||
|
||||
if (authError || !user) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: `Invalid token: ${authError?.message || 'No user found'}` }),
|
||||
{ status: 401, headers: { ...corsHeaders, "Content-Type": "application/json" } }
|
||||
);
|
||||
}
|
||||
|
||||
// Parse request body
|
||||
const { message_id } = await req.json();
|
||||
|
||||
if (!message_id) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: "Missing message_id" }),
|
||||
{ status: 400, headers: { ...corsHeaders, "Content-Type": "application/json" } }
|
||||
);
|
||||
}
|
||||
|
||||
// Get user's current public key
|
||||
const { data: profile, error: profileError } = await supabaseAdmin
|
||||
.from("profiles")
|
||||
.select("id, couple_id, public_key_jwk")
|
||||
.eq("id", user.id)
|
||||
.maybeSingle();
|
||||
|
||||
if (profileError || !profile) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: "Profile not found" }),
|
||||
{ status: 404, headers: { ...corsHeaders, "Content-Type": "application/json" } }
|
||||
);
|
||||
}
|
||||
|
||||
if (!profile.public_key_jwk) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: "User has no public key" }),
|
||||
{ status: 400, headers: { ...corsHeaders, "Content-Type": "application/json" } }
|
||||
);
|
||||
}
|
||||
|
||||
// Get the message
|
||||
const { data: message, error: messageError } = await supabaseAdmin
|
||||
.from("messages")
|
||||
.select("id, user_id, match_id, version, keys_metadata")
|
||||
.eq("id", message_id)
|
||||
.maybeSingle();
|
||||
|
||||
if (messageError || !message) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: "Message not found" }),
|
||||
{ status: 404, headers: { ...corsHeaders, "Content-Type": "application/json" } }
|
||||
);
|
||||
}
|
||||
|
||||
// Verify this is a v2 message
|
||||
if (message.version !== 2) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: "Message is not encrypted (v1)" }),
|
||||
{ status: 400, headers: { ...corsHeaders, "Content-Type": "application/json" } }
|
||||
);
|
||||
}
|
||||
|
||||
// Verify user has access to this message (via match -> couple)
|
||||
const { data: match } = await supabaseAdmin
|
||||
.from("matches")
|
||||
.select("couple_id")
|
||||
.eq("id", message.match_id)
|
||||
.maybeSingle();
|
||||
|
||||
if (!match || match.couple_id !== profile.couple_id) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: "Access denied" }),
|
||||
{ status: 403, headers: { ...corsHeaders, "Content-Type": "application/json" } }
|
||||
);
|
||||
}
|
||||
|
||||
// Determine if user is sender or recipient
|
||||
const isSender = message.user_id === user.id;
|
||||
|
||||
const metadata = message.keys_metadata as {
|
||||
sender_wrapped_key: string;
|
||||
recipient_wrapped_key?: string;
|
||||
admin_wrapped_key: string;
|
||||
admin_key_id: string;
|
||||
algorithm: string;
|
||||
key_wrap_algorithm: string;
|
||||
pending_recipient?: boolean;
|
||||
};
|
||||
|
||||
// Get admin private key - supports multi-key lookup
|
||||
const adminPrivateKeyJwk = getAdminPrivateKey(metadata.admin_key_id);
|
||||
if (!adminPrivateKeyJwk) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: "Admin key not found for this message" }),
|
||||
{ status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" } }
|
||||
);
|
||||
}
|
||||
|
||||
// Unwrap AES key using admin key
|
||||
const rawAesKey = await unwrapWithAdminKey(
|
||||
metadata.admin_wrapped_key,
|
||||
adminPrivateKeyJwk
|
||||
);
|
||||
|
||||
// Re-wrap with user's current public key
|
||||
const newWrappedKey = await wrapForRecipient(
|
||||
rawAesKey,
|
||||
profile.public_key_jwk
|
||||
);
|
||||
|
||||
// Update the appropriate field
|
||||
const updatedMetadata = { ...metadata };
|
||||
if (isSender) {
|
||||
updatedMetadata.sender_wrapped_key = newWrappedKey;
|
||||
} else {
|
||||
updatedMetadata.recipient_wrapped_key = newWrappedKey;
|
||||
updatedMetadata.pending_recipient = false;
|
||||
}
|
||||
|
||||
const { error: updateError } = await supabaseAdmin
|
||||
.from("messages")
|
||||
.update({ keys_metadata: updatedMetadata })
|
||||
.eq("id", message.id);
|
||||
|
||||
if (updateError) {
|
||||
console.error(`Failed to update message ${message.id}:`, updateError);
|
||||
return new Response(
|
||||
JSON.stringify({ error: "Failed to update message" }),
|
||||
{ status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" } }
|
||||
);
|
||||
}
|
||||
|
||||
console.log(`Repaired stale key for message ${message.id}, user ${user.id} (${isSender ? 'sender' : 'recipient'})`);
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({ success: true, message_id: message.id }),
|
||||
{ headers: { ...corsHeaders, "Content-Type": "application/json" } }
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Error:", error);
|
||||
return new Response(
|
||||
JSON.stringify({ error: "Internal server error" }),
|
||||
{ status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" } }
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Get admin private key by ID.
|
||||
* Supports multi-key format: ADMIN_KEYS_JSON = { "uuid1": {...}, "uuid2": {...} }
|
||||
* Falls back to single-key format: ADMIN_PRIVATE_KEY_JWK = {...}
|
||||
*/
|
||||
function getAdminPrivateKey(keyId: string): JsonWebKey | null {
|
||||
// Try multi-key format first
|
||||
const keysJson = Deno.env.get("ADMIN_KEYS_JSON");
|
||||
if (keysJson) {
|
||||
try {
|
||||
const keys = JSON.parse(keysJson);
|
||||
if (keys[keyId]) {
|
||||
return keys[keyId];
|
||||
}
|
||||
} catch {
|
||||
console.error("Failed to parse ADMIN_KEYS_JSON");
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to single-key format
|
||||
const singleKeyJson = Deno.env.get("ADMIN_PRIVATE_KEY_JWK");
|
||||
if (singleKeyJson) {
|
||||
try {
|
||||
return JSON.parse(singleKeyJson);
|
||||
} catch {
|
||||
console.error("Failed to parse ADMIN_PRIVATE_KEY_JWK");
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// Crypto helper functions
|
||||
async function unwrapWithAdminKey(
|
||||
wrappedKeyBase64: string,
|
||||
adminPrivateKeyJwk: JsonWebKey
|
||||
): Promise<ArrayBuffer> {
|
||||
const privateKey = await crypto.subtle.importKey(
|
||||
"jwk",
|
||||
adminPrivateKeyJwk,
|
||||
{ name: "RSA-OAEP", hash: "SHA-256" },
|
||||
false,
|
||||
["decrypt"]
|
||||
);
|
||||
|
||||
const wrappedKey = base64ToArrayBuffer(wrappedKeyBase64);
|
||||
return await crypto.subtle.decrypt(
|
||||
{ name: "RSA-OAEP" },
|
||||
privateKey,
|
||||
wrappedKey
|
||||
);
|
||||
}
|
||||
|
||||
async function wrapForRecipient(
|
||||
rawAesKey: ArrayBuffer,
|
||||
recipientPublicKeyJwk: JsonWebKey
|
||||
): Promise<string> {
|
||||
// Sanitize JWK to ensure Base64URL encoding for n and e
|
||||
const sanitizedJwk = { ...recipientPublicKeyJwk };
|
||||
if (sanitizedJwk.n) {
|
||||
sanitizedJwk.n = toBase64Url(sanitizedJwk.n);
|
||||
}
|
||||
if (sanitizedJwk.e) {
|
||||
sanitizedJwk.e = toBase64Url(sanitizedJwk.e);
|
||||
}
|
||||
|
||||
const publicKey = await crypto.subtle.importKey(
|
||||
"jwk",
|
||||
sanitizedJwk,
|
||||
{ name: "RSA-OAEP", hash: "SHA-256" },
|
||||
false,
|
||||
["encrypt"]
|
||||
);
|
||||
|
||||
const wrappedKey = await crypto.subtle.encrypt(
|
||||
{ name: "RSA-OAEP" },
|
||||
publicKey,
|
||||
rawAesKey
|
||||
);
|
||||
|
||||
return arrayBufferToBase64(wrappedKey);
|
||||
}
|
||||
|
||||
function toBase64Url(base64: string): string {
|
||||
return base64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
||||
}
|
||||
|
||||
function base64ToArrayBuffer(base64: string): ArrayBuffer {
|
||||
const binary = atob(base64);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let i = 0; i < binary.length; i++) {
|
||||
bytes[i] = binary.charCodeAt(i);
|
||||
}
|
||||
return bytes.buffer;
|
||||
}
|
||||
|
||||
function arrayBufferToBase64(buffer: ArrayBuffer): string {
|
||||
const bytes = new Uint8Array(buffer);
|
||||
let binary = "";
|
||||
for (let i = 0; i < bytes.byteLength; i++) {
|
||||
binary += String.fromCharCode(bytes[i]);
|
||||
}
|
||||
return btoa(binary);
|
||||
}
|
||||
@@ -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.';
|
||||
File diff suppressed because it is too large.
Load diff
Generated
+3325
-1
File diff suppressed because it is too large.
Load diff
@@ -1,14 +1,16 @@
|
||||
// User profile (extends Supabase auth.users)
|
||||
export interface Profile {
|
||||
id: string;
|
||||
name: string | null;
|
||||
avatar_url: string | null;
|
||||
push_token: string | null;
|
||||
is_premium: boolean;
|
||||
couple_id: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
// User profile (extends Supabase auth.users)
|
||||
export interface Profile {
|
||||
id: string;
|
||||
name: string | null;
|
||||
avatar_url: string | null;
|
||||
push_token: string | null;
|
||||
is_premium: boolean;
|
||||
couple_id: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
/** RSA public key in JWK format for E2EE */
|
||||
public_key_jwk?: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
// Couple pairing
|
||||
export interface Couple {
|
||||
@@ -72,10 +74,68 @@ export interface MatchWithQuestion extends Match {
|
||||
question: Question;
|
||||
}
|
||||
|
||||
// Pack with progress info
|
||||
export interface PackProgress {
|
||||
pack: QuestionPack;
|
||||
total_questions: number;
|
||||
answered_questions: number;
|
||||
matches_count: number;
|
||||
}
|
||||
// Pack with progress info
|
||||
export interface PackProgress {
|
||||
pack: QuestionPack;
|
||||
total_questions: number;
|
||||
answered_questions: number;
|
||||
matches_count: number;
|
||||
}
|
||||
|
||||
// E2EE Keys Metadata for encrypted messages
|
||||
export interface KeysMetadata {
|
||||
/** AES key encrypted with sender's RSA public key (base64) */
|
||||
sender_wrapped_key: string;
|
||||
/** AES key encrypted with recipient's RSA public key (base64) */
|
||||
recipient_wrapped_key?: string;
|
||||
/** AES key encrypted with admin's RSA public key (base64) */
|
||||
admin_wrapped_key: string;
|
||||
/** UUID of the master_keys record used */
|
||||
admin_key_id: string;
|
||||
/** Symmetric encryption algorithm */
|
||||
algorithm: 'AES-256-GCM';
|
||||
/** Key wrapping algorithm */
|
||||
key_wrap_algorithm: 'RSA-OAEP-SHA256';
|
||||
/** True if recipient key needs to be added */
|
||||
pending_recipient?: boolean;
|
||||
}
|
||||
|
||||
// Chat message
|
||||
export interface Message {
|
||||
id: string;
|
||||
match_id: string;
|
||||
user_id: string;
|
||||
content: string | null;
|
||||
created_at: string;
|
||||
read_at: string | null;
|
||||
delivered_at: string | null;
|
||||
media_path: string | null;
|
||||
media_type: 'image' | 'video' | null;
|
||||
media_expires_at: string | null;
|
||||
media_expired: boolean;
|
||||
media_viewed_at: string | null;
|
||||
/** Encryption version: 1 = plaintext (legacy), 2 = E2EE */
|
||||
version: number | null;
|
||||
/** Encrypted message content (base64, for v2 messages) */
|
||||
encrypted_content: string | null;
|
||||
/** Initialization vector for AES-GCM (base64, for v2 messages) */
|
||||
encryption_iv: string | null;
|
||||
/** Triple-wrapped encryption keys metadata (for v2 messages) */
|
||||
keys_metadata: KeysMetadata | null;
|
||||
/** Content moderation status: safe, flagged, unmoderated */
|
||||
moderation_status?: 'safe' | 'flagged' | 'unmoderated' | null;
|
||||
/** Reason for flagging */
|
||||
flag_reason?: string | null;
|
||||
/** Content category: Neutral, Romantic, Playful, Explicit, etc. */
|
||||
category?: 'Neutral' | 'Romantic' | 'Playful' | 'Explicit' | string | null;
|
||||
}
|
||||
|
||||
// Master key for admin E2EE access
|
||||
export interface MasterKey {
|
||||
id: string;
|
||||
key_name: string;
|
||||
public_key_jwk: Record<string, unknown>;
|
||||
is_active: boolean;
|
||||
created_at: string;
|
||||
rotated_at: string | null;
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
/**
|
||||
* Admin Key Generation Script for E2EE
|
||||
*
|
||||
* Run this to generate an admin RSA-2048 key pair for E2EE.
|
||||
*
|
||||
* Usage:
|
||||
* node scripts/generate-admin-keys.mjs # Generate new key
|
||||
* node scripts/generate-admin-keys.mjs --rotate # Generate for rotation (shows migration steps)
|
||||
*
|
||||
* Output:
|
||||
* - PUBLIC KEY: Store in master_keys table via Supabase dashboard or migration
|
||||
* - PRIVATE KEY: Store in environment variables for Supabase Edge Functions
|
||||
*
|
||||
* IMPORTANT: Never commit the private key to version control!
|
||||
*/
|
||||
|
||||
import { webcrypto, randomUUID } from 'crypto';
|
||||
|
||||
const isRotation = process.argv.includes('--rotate');
|
||||
|
||||
async function generateAdminKeyPair() {
|
||||
console.log('Generating RSA-2048 key pair for E2EE admin access...\n');
|
||||
|
||||
// Generate a UUID for this key (used in keys_metadata.admin_key_id)
|
||||
const keyId = randomUUID();
|
||||
const keyName = `e2ee_admin_key_${Date.now()}`;
|
||||
|
||||
const keyPair = await webcrypto.subtle.generateKey(
|
||||
{
|
||||
name: 'RSA-OAEP',
|
||||
modulusLength: 2048,
|
||||
publicExponent: new Uint8Array([1, 0, 1]), // 65537
|
||||
hash: 'SHA-256',
|
||||
},
|
||||
true, // extractable
|
||||
['encrypt', 'decrypt']
|
||||
);
|
||||
|
||||
const publicKeyJwk = await webcrypto.subtle.exportKey('jwk', keyPair.publicKey);
|
||||
const privateKeyJwk = await webcrypto.subtle.exportKey('jwk', keyPair.privateKey);
|
||||
|
||||
console.log('='.repeat(80));
|
||||
console.log(`KEY ID: ${keyId}`);
|
||||
console.log('='.repeat(80));
|
||||
console.log('\nSave this ID - it will be stored in messages and used for key lookup.\n');
|
||||
|
||||
console.log('='.repeat(80));
|
||||
console.log('PUBLIC KEY (store in master_keys table)');
|
||||
console.log('='.repeat(80));
|
||||
|
||||
if (isRotation) {
|
||||
console.log('\n1. First, mark old key as inactive:\n');
|
||||
console.log(`UPDATE master_keys SET is_active = false WHERE is_active = true;`);
|
||||
console.log('\n2. Then insert the new key:\n');
|
||||
} else {
|
||||
console.log('\nRun this SQL in Supabase dashboard or create a migration:\n');
|
||||
}
|
||||
|
||||
console.log(`INSERT INTO master_keys (id, key_name, public_key_jwk, is_active)
|
||||
VALUES ('${keyId}', '${keyName}', '${JSON.stringify(publicKeyJwk)}'::jsonb, true);`);
|
||||
|
||||
console.log('\n');
|
||||
console.log('='.repeat(80));
|
||||
console.log('PRIVATE KEY - SINGLE KEY FORMAT (backwards compatible)');
|
||||
console.log('='.repeat(80));
|
||||
console.log('\nFor first-time setup, add this to Supabase Edge Function secrets:\n');
|
||||
console.log('Key name: ADMIN_PRIVATE_KEY_JWK');
|
||||
console.log('Value (single line):');
|
||||
console.log(JSON.stringify(privateKeyJwk));
|
||||
|
||||
console.log('\n');
|
||||
console.log('='.repeat(80));
|
||||
console.log('PRIVATE KEY - MULTI-KEY FORMAT (for key rotation)');
|
||||
console.log('='.repeat(80));
|
||||
console.log('\nFor key rotation support, use ADMIN_KEYS_JSON instead:\n');
|
||||
console.log('Key name: ADMIN_KEYS_JSON');
|
||||
console.log('Value format: { "key-id-1": {...jwk...}, "key-id-2": {...jwk...} }');
|
||||
console.log('\nFor this new key, add to your existing ADMIN_KEYS_JSON:');
|
||||
console.log(`"${keyId}": ${JSON.stringify(privateKeyJwk)}`);
|
||||
|
||||
if (isRotation) {
|
||||
console.log('\n');
|
||||
console.log('='.repeat(80));
|
||||
console.log('KEY ROTATION CHECKLIST');
|
||||
console.log('='.repeat(80));
|
||||
console.log(`
|
||||
1. [ ] Run the SQL above to insert new public key and deactivate old one
|
||||
2. [ ] Add the new private key to ADMIN_KEYS_JSON (keep the old key!)
|
||||
3. [ ] Deploy updated edge functions
|
||||
4. [ ] Test that new messages use the new key
|
||||
5. [ ] Test that old messages can still be decrypted
|
||||
6. [ ] (Optional) After 90+ days, remove old key from ADMIN_KEYS_JSON
|
||||
`);
|
||||
}
|
||||
|
||||
console.log('\n');
|
||||
console.log('='.repeat(80));
|
||||
console.log('IMPORTANT SECURITY NOTES');
|
||||
console.log('='.repeat(80));
|
||||
console.log(`
|
||||
1. NEVER commit the private key to version control
|
||||
2. Store the private key securely in Supabase Edge Function secrets
|
||||
3. The public key can be safely stored in the database
|
||||
4. Keep a secure backup of the private key offline
|
||||
5. If the private key is compromised, rotate immediately
|
||||
6. When rotating keys, keep old private keys until all messages are migrated
|
||||
`);
|
||||
|
||||
// Also output a JSON file for backup (to stderr so it can be redirected)
|
||||
console.error('\n--- KEY BACKUP (redirect stderr to save) ---');
|
||||
console.error(JSON.stringify({
|
||||
keyId,
|
||||
keyName,
|
||||
publicKeyJwk,
|
||||
privateKeyJwk,
|
||||
generatedAt: new Date().toISOString(),
|
||||
}, null, 2));
|
||||
}
|
||||
|
||||
generateAdminKeyPair().catch(console.error);
|
||||
Reference in new issue
Block a user