Files
ChatApp/packages/shared/src/chat/attachments.ts
T

426 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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;
}