426 lines
14 KiB
TypeScript
426 lines
14 KiB
TypeScript
// Encrypted attachment upload / download.
|
||
//
|
||
// Per-message symmetric key + nonce encrypts the raw blob (XSalsa20-Poly1305
|
||
// via secretbox). The encrypted blob is uploaded to Supabase Storage under
|
||
// `{conversation_id}/{attachment_id}.bin`. The symmetric key + blob-nonce
|
||
// travel inside the per-device message envelope as JSON, so the server never
|
||
// sees the decryption material.
|
||
|
||
import { fromBase64, getCryptoBackend, toBase64 } from '../crypto/index';
|
||
import type { AppSupabaseClient } from '../supabase/client';
|
||
|
||
export const ATTACHMENT_BUCKET = 'chat-attachments';
|
||
export const MAX_ATTACHMENT_BYTES = 10 * 1024 * 1024; // 10 MB
|
||
|
||
// The per-attachment envelope material + public metadata. The base64 fields
|
||
// live in the encrypted message payload; the storage path + mime live on
|
||
// the public message_attachments row.
|
||
export interface AttachmentHandle {
|
||
id: string; // matches message_attachments.id + storage sub-path
|
||
storagePath: string;
|
||
mimeType: string;
|
||
sizeBytes: number;
|
||
width?: number;
|
||
height?: number;
|
||
// base64-encoded — only readable via per-device envelope decrypt.
|
||
keyB64: string;
|
||
nonceB64: string;
|
||
/** View-once flag — set by sender on the encrypted envelope and mirrored
|
||
* to `message_attachments.view_once` so the renderer can show a "tap to
|
||
* view" tombstone and call the mark-viewed RPC on first open. */
|
||
viewOnce?: boolean;
|
||
/** ISO timestamp the first non-sender opened the attachment. Populated by
|
||
* the mark-viewed RPC (server-authoritative); undefined until burnt. */
|
||
viewedAt?: string | null;
|
||
/** UUID of the first non-sender who viewed. Populated alongside `viewedAt`
|
||
* by the mark-viewed RPC so the renderer can render attribution. */
|
||
viewedBy?: string | null;
|
||
/** Storage path of the encrypted WebP preview thumb (max 320×320). The
|
||
* thumb shares the per-attachment symmetric key with the full blob but
|
||
* uses its own nonce. Absent on pre-Phase-6B messages — the receiver
|
||
* falls through to downloading the full blob in that case. */
|
||
thumbStoragePath?: string;
|
||
/** Base-64 nonce that decrypts `<id>-thumb.bin`. Always present iff
|
||
* `thumbStoragePath` is set. */
|
||
thumbNonceB64?: string;
|
||
}
|
||
|
||
export type CallEventStatus = 'ended' | 'missed' | 'declined';
|
||
export type CallEventKind = 'audio' | 'video';
|
||
|
||
// Plaintext payload wire format: either text+attachments or a compact
|
||
// call-event record. Pre-attachment messages without JSON auto-upgrade
|
||
// via the fallback branch in parseMessagePayload.
|
||
|
||
export interface TextMessagePayload {
|
||
v: 1;
|
||
type?: 'text';
|
||
text: string;
|
||
attachments: AttachmentHandle[];
|
||
/** When set, the message self-destructs `expireMs` milliseconds after the
|
||
* server's createdAt. Sender initiates the delete; receivers hide locally
|
||
* as soon as the window elapses. Added in /tempmsg support. */
|
||
expireMs?: number;
|
||
}
|
||
|
||
export interface CallEventPayload {
|
||
v: 1;
|
||
type: 'call_event';
|
||
status: CallEventStatus;
|
||
mediaKind: CallEventKind;
|
||
durationSec: number;
|
||
}
|
||
|
||
export interface PollOption {
|
||
id: string;
|
||
emoji: string;
|
||
text: string;
|
||
}
|
||
|
||
export interface PollPayload {
|
||
v: 1;
|
||
type: 'poll';
|
||
question: string;
|
||
options: PollOption[];
|
||
}
|
||
|
||
export interface WhiteboardPayload {
|
||
v: 1;
|
||
type: 'whiteboard';
|
||
whiteboard_id: string;
|
||
}
|
||
|
||
export interface WatchTogetherPayload {
|
||
v: 1;
|
||
type: 'watch_together';
|
||
session_id: string;
|
||
}
|
||
|
||
export interface GamePayload {
|
||
v: 1;
|
||
type: 'game';
|
||
game_id: string;
|
||
game_type: 'ttt' | 'c4';
|
||
}
|
||
|
||
export type MessagePayload =
|
||
| TextMessagePayload
|
||
| CallEventPayload
|
||
| PollPayload
|
||
| WhiteboardPayload
|
||
| WatchTogetherPayload
|
||
| GamePayload;
|
||
|
||
export type ParsedMessagePayload =
|
||
| {
|
||
kind: 'text';
|
||
text: string;
|
||
attachments: AttachmentHandle[];
|
||
expireMs?: number;
|
||
}
|
||
| {
|
||
kind: 'call_event';
|
||
status: CallEventStatus;
|
||
mediaKind: CallEventKind;
|
||
durationSec: number;
|
||
}
|
||
| {
|
||
kind: 'poll';
|
||
question: string;
|
||
options: PollOption[];
|
||
}
|
||
| {
|
||
kind: 'whiteboard';
|
||
whiteboardId: string;
|
||
}
|
||
| {
|
||
kind: 'watch_together';
|
||
sessionId: string;
|
||
}
|
||
| {
|
||
kind: 'game';
|
||
gameId: string;
|
||
gameType: 'ttt' | 'c4';
|
||
};
|
||
|
||
export function serializeMessagePayload(payload: MessagePayload): string {
|
||
if (
|
||
(!('type' in payload) || payload.type === 'text' || payload.type === undefined) &&
|
||
'attachments' in payload &&
|
||
payload.attachments.length === 0
|
||
) {
|
||
return payload.text;
|
||
}
|
||
return JSON.stringify(payload);
|
||
}
|
||
|
||
export function parseMessagePayload(raw: string | null): ParsedMessagePayload {
|
||
if (!raw) return { kind: 'text', text: '', attachments: [] };
|
||
if (!raw.startsWith('{')) return { kind: 'text', text: raw, attachments: [] };
|
||
try {
|
||
const obj = JSON.parse(raw) as Partial<MessagePayload> & { type?: string };
|
||
if (obj && obj.v === 1) {
|
||
if (obj.type === 'call_event') {
|
||
const p = obj as CallEventPayload;
|
||
return {
|
||
kind: 'call_event',
|
||
status: p.status,
|
||
mediaKind: p.mediaKind,
|
||
durationSec: typeof p.durationSec === 'number' ? p.durationSec : 0,
|
||
};
|
||
}
|
||
if (obj.type === 'poll') {
|
||
const p = obj as Partial<PollPayload>;
|
||
const options = Array.isArray(p.options)
|
||
? p.options
|
||
.map((option, idx) => {
|
||
const candidate = option as Partial<PollOption>;
|
||
const text = typeof candidate.text === 'string' ? candidate.text.trim() : '';
|
||
if (!text) return null;
|
||
return {
|
||
id:
|
||
typeof candidate.id === 'string' && candidate.id.trim()
|
||
? candidate.id.trim()
|
||
: 'option-' + (idx + 1),
|
||
emoji:
|
||
typeof candidate.emoji === 'string' && candidate.emoji.trim()
|
||
? candidate.emoji.trim()
|
||
: String(idx + 1),
|
||
text,
|
||
};
|
||
})
|
||
.filter((option): option is PollOption => option !== null)
|
||
: [];
|
||
return {
|
||
kind: 'poll',
|
||
question: typeof p.question === 'string' ? p.question.trim() : '',
|
||
options,
|
||
};
|
||
}
|
||
if (obj.type === 'whiteboard') {
|
||
const p = obj as Partial<WhiteboardPayload>;
|
||
const id = typeof p.whiteboard_id === 'string' && p.whiteboard_id.length > 0
|
||
? p.whiteboard_id
|
||
: '';
|
||
return { kind: 'whiteboard', whiteboardId: id };
|
||
}
|
||
if (obj.type === 'watch_together') {
|
||
const p = obj as Partial<WatchTogetherPayload>;
|
||
const id = typeof p.session_id === 'string' && p.session_id.length > 0
|
||
? p.session_id
|
||
: '';
|
||
return { kind: 'watch_together', sessionId: id };
|
||
}
|
||
if (obj.type === 'game') {
|
||
const p = obj as Partial<GamePayload>;
|
||
const id = typeof p.game_id === 'string' && p.game_id.length > 0 ? p.game_id : '';
|
||
const t = p.game_type === 'ttt' || p.game_type === 'c4' ? p.game_type : 'ttt';
|
||
return { kind: 'game', gameId: id, gameType: t };
|
||
}
|
||
const t = obj as TextMessagePayload;
|
||
return {
|
||
kind: 'text',
|
||
text: typeof t.text === 'string' ? t.text : '',
|
||
attachments: Array.isArray(t.attachments) ? t.attachments : [],
|
||
...(typeof t.expireMs === 'number' && t.expireMs > 0
|
||
? { expireMs: t.expireMs }
|
||
: {}),
|
||
};
|
||
}
|
||
} catch {
|
||
/* fall through to plain text */
|
||
}
|
||
return { kind: 'text', text: raw, attachments: [] };
|
||
}
|
||
|
||
function ensureUuid(): string {
|
||
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
|
||
return crypto.randomUUID();
|
||
}
|
||
throw new Error('crypto.randomUUID unavailable');
|
||
}
|
||
|
||
export interface EncryptedAttachmentResult {
|
||
handle: AttachmentHandle;
|
||
key: Uint8Array; // raw bytes — caller is responsible for wiping
|
||
nonce: Uint8Array;
|
||
}
|
||
|
||
// Encrypt + upload a blob. Does NOT insert the message_attachments row —
|
||
// the caller combines this with a message insert so everything commits
|
||
// atomically at the application layer.
|
||
//
|
||
// When `thumbBlob` is supplied (Phase 6B image-thumbnail path) a second
|
||
// ciphertext is uploaded to `<conversationId>/<id>-thumb.bin` encrypted
|
||
// with the SAME per-attachment key + a fresh nonce. The handle's
|
||
// `thumbStoragePath` / `thumbNonceB64` get populated so the receiver
|
||
// can prefer the thumb for inline preview. Thumb-upload failure is
|
||
// non-fatal — we log and fall through so the full image still posts.
|
||
export async function encryptAndUploadAttachment(params: {
|
||
client: AppSupabaseClient;
|
||
conversationId: string;
|
||
file: Blob;
|
||
mimeType: string;
|
||
sizeBytes: number;
|
||
width?: number;
|
||
height?: number;
|
||
thumbBlob?: Blob | null;
|
||
}): Promise<EncryptedAttachmentResult> {
|
||
const backend = getCryptoBackend();
|
||
|
||
const bytes = new Uint8Array(await params.file.arrayBuffer());
|
||
const key = backend.randomBytes(backend.secretboxKeyLength);
|
||
const nonce = backend.randomBytes(backend.secretboxNonceLength);
|
||
const ciphertext = backend.secretbox(bytes, nonce, key);
|
||
|
||
const id = ensureUuid();
|
||
const storagePath = params.conversationId + '/' + id + '.bin';
|
||
|
||
const { error } = await params.client.storage
|
||
.from(ATTACHMENT_BUCKET)
|
||
.upload(storagePath, ciphertext, {
|
||
contentType: 'application/octet-stream',
|
||
upsert: false,
|
||
});
|
||
if (error) throw error;
|
||
|
||
let thumbStoragePath: string | undefined;
|
||
let thumbNonceB64: string | undefined;
|
||
if (params.thumbBlob) {
|
||
try {
|
||
const thumbBytes = new Uint8Array(await params.thumbBlob.arrayBuffer());
|
||
const thumbNonce = backend.randomBytes(backend.secretboxNonceLength);
|
||
const thumbCipher = backend.secretbox(thumbBytes, thumbNonce, key);
|
||
const thumbPath = params.conversationId + '/' + id + '-thumb.bin';
|
||
const { error: thumbErr } = await params.client.storage
|
||
.from(ATTACHMENT_BUCKET)
|
||
.upload(thumbPath, thumbCipher, {
|
||
contentType: 'application/octet-stream',
|
||
upsert: false,
|
||
});
|
||
if (thumbErr) {
|
||
// Non-fatal: log and continue with full-only handle. Receiver will
|
||
// fall back to fetching the full blob.
|
||
console.warn('thumb upload failed', thumbErr);
|
||
} else {
|
||
thumbStoragePath = thumbPath;
|
||
thumbNonceB64 = await toBase64(thumbNonce);
|
||
}
|
||
// Wipe nonce buffer.
|
||
for (let i = 0; i < thumbNonce.length; i++) thumbNonce[i] = 0;
|
||
} catch (err: unknown) {
|
||
console.warn('thumb encrypt/upload failed', err);
|
||
}
|
||
}
|
||
|
||
const handle: AttachmentHandle = {
|
||
id,
|
||
storagePath,
|
||
mimeType: params.mimeType,
|
||
sizeBytes: params.sizeBytes,
|
||
...(params.width !== undefined ? { width: params.width } : {}),
|
||
...(params.height !== undefined ? { height: params.height } : {}),
|
||
keyB64: await toBase64(key),
|
||
nonceB64: await toBase64(nonce),
|
||
...(thumbStoragePath !== undefined ? { thumbStoragePath } : {}),
|
||
...(thumbNonceB64 !== undefined ? { thumbNonceB64 } : {}),
|
||
};
|
||
|
||
return { handle, key, nonce };
|
||
}
|
||
|
||
// Download + decrypt an attachment blob and return a Blob the caller can use
|
||
// with URL.createObjectURL. Throws on network or authentication failures.
|
||
export async function downloadAndDecryptAttachment(params: {
|
||
client: AppSupabaseClient;
|
||
handle: AttachmentHandle;
|
||
}): Promise<Blob> {
|
||
const backend = getCryptoBackend();
|
||
|
||
const { data, error } = await params.client.storage
|
||
.from(ATTACHMENT_BUCKET)
|
||
.download(params.handle.storagePath);
|
||
if (error) throw error;
|
||
if (!data) throw new Error('empty download');
|
||
|
||
const ciphertext = new Uint8Array(await data.arrayBuffer());
|
||
const key = await fromBase64(params.handle.keyB64);
|
||
const nonce = await fromBase64(params.handle.nonceB64);
|
||
const plainBytes = backend.secretboxOpen(ciphertext, nonce, key);
|
||
// Zero key/nonce buffers on the way out.
|
||
for (let i = 0; i < key.length; i++) key[i] = 0;
|
||
for (let i = 0; i < nonce.length; i++) nonce[i] = 0;
|
||
// Copy into a fresh ArrayBuffer so Blob accepts it across TS lib variants.
|
||
const copy = new Uint8Array(plainBytes.byteLength);
|
||
copy.set(plainBytes);
|
||
return new Blob([copy.buffer], { type: params.handle.mimeType });
|
||
}
|
||
|
||
// Download + decrypt the small WebP preview thumb that the sender uploaded
|
||
// alongside an image attachment (Phase 6B optimisation). Returns `null` if
|
||
// the handle has no thumb metadata (pre-Phase-6B message) or if the thumb
|
||
// blob is missing from storage — caller falls back to the full image.
|
||
//
|
||
// We deliberately swallow ANY download error (missing object, transient
|
||
// 5xx) so the receiver gracefully degrades to the full-blob path; only a
|
||
// successful decrypt-failure throws, since that signals a real corruption.
|
||
export async function downloadAndDecryptAttachmentThumb(params: {
|
||
client: AppSupabaseClient;
|
||
handle: AttachmentHandle;
|
||
}): Promise<Blob | null> {
|
||
if (!params.handle.thumbStoragePath || !params.handle.thumbNonceB64) {
|
||
return null;
|
||
}
|
||
const backend = getCryptoBackend();
|
||
|
||
let data: Blob | null = null;
|
||
try {
|
||
const res = await params.client.storage
|
||
.from(ATTACHMENT_BUCKET)
|
||
.download(params.handle.thumbStoragePath);
|
||
if (res.error) {
|
||
// Likely 404 — sender failed to upload thumb, or it's been GC'd.
|
||
// Receiver falls back to full image.
|
||
return null;
|
||
}
|
||
data = res.data;
|
||
} catch {
|
||
return null;
|
||
}
|
||
if (!data) return null;
|
||
|
||
const ciphertext = new Uint8Array(await data.arrayBuffer());
|
||
const key = await fromBase64(params.handle.keyB64);
|
||
const nonce = await fromBase64(params.handle.thumbNonceB64);
|
||
const plainBytes = backend.secretboxOpen(ciphertext, nonce, key);
|
||
for (let i = 0; i < key.length; i++) key[i] = 0;
|
||
for (let i = 0; i < nonce.length; i++) nonce[i] = 0;
|
||
const copy = new Uint8Array(plainBytes.byteLength);
|
||
copy.set(plainBytes);
|
||
// Thumbs are always image/webp regardless of original mime.
|
||
return new Blob([copy.buffer], { type: 'image/webp' });
|
||
}
|
||
|
||
// Insert the public metadata row for an attachment. The ciphertext itself has
|
||
// already been uploaded to storage under `handle.storagePath`.
|
||
export async function insertAttachmentRow(
|
||
client: AppSupabaseClient,
|
||
messageId: string,
|
||
handle: AttachmentHandle,
|
||
blobNonceHex: string,
|
||
): Promise<void> {
|
||
const row: Record<string, unknown> = {
|
||
id: handle.id,
|
||
message_id: messageId,
|
||
storage_path: handle.storagePath,
|
||
nonce: blobNonceHex,
|
||
mime_type: handle.mimeType,
|
||
size_bytes: handle.sizeBytes,
|
||
view_once: handle.viewOnce ?? false,
|
||
};
|
||
if (handle.width !== undefined) row.width = handle.width;
|
||
if (handle.height !== undefined) row.height = handle.height;
|
||
const { error } = await client.from('message_attachments').insert(row as never);
|
||
if (error) throw error;
|
||
}
|