perf(P6B.T7): WebP thumbnails for image attachments (320px max, thumb-first render)
This commit is contained in:
@@ -35,6 +35,14 @@ export interface AttachmentHandle {
|
||||
/** 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';
|
||||
@@ -241,6 +249,13 @@ export interface EncryptedAttachmentResult {
|
||||
// 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;
|
||||
@@ -249,6 +264,7 @@ export async function encryptAndUploadAttachment(params: {
|
||||
sizeBytes: number;
|
||||
width?: number;
|
||||
height?: number;
|
||||
thumbBlob?: Blob | null;
|
||||
}): Promise<EncryptedAttachmentResult> {
|
||||
const backend = getCryptoBackend();
|
||||
|
||||
@@ -268,6 +284,35 @@ export async function encryptAndUploadAttachment(params: {
|
||||
});
|
||||
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,
|
||||
@@ -277,6 +322,8 @@ export async function encryptAndUploadAttachment(params: {
|
||||
...(params.height !== undefined ? { height: params.height } : {}),
|
||||
keyB64: await toBase64(key),
|
||||
nonceB64: await toBase64(nonce),
|
||||
...(thumbStoragePath !== undefined ? { thumbStoragePath } : {}),
|
||||
...(thumbNonceB64 !== undefined ? { thumbNonceB64 } : {}),
|
||||
};
|
||||
|
||||
return { handle, key, nonce };
|
||||
@@ -309,6 +356,51 @@ export async function downloadAndDecryptAttachment(params: {
|
||||
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(
|
||||
|
||||
Reference in New Issue
Block a user