Files
ChatApp/packages/shared/src/chat/attachments.ts
T
byGalax b61f929cf7 fix(shared): drop .js extensions from relative imports for Metro
packages/shared/src/index.ts and all sub-modules used .js extensions on
relative imports (e.g. './admin/index.js') pointing at .ts source files.
TypeScript with moduleResolution: "Bundler" doesn't need them, and
Metro's eager exporter (used for preview / production builds) reads
them literally and fails — only the dev-server Metro fell back to .ts.

Workspace typecheck remains 8/8 green; Vite and TS Bundler resolution
already accept both styles, so desktop is unaffected.
2026-05-15 01:52:14 +02:00

265 lines
8.6 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;
}
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 type MessagePayload = TextMessagePayload | CallEventPayload | PollPayload;
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[];
};
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,
};
}
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.
export async function encryptAndUploadAttachment(params: {
client: AppSupabaseClient;
conversationId: string;
file: Blob;
mimeType: string;
sizeBytes: number;
width?: number;
height?: number;
}): 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;
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),
};
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 });
}
// 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,
};
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;
}