feat(P4C.T2): shared soundboards wrappers + sealed-blob crypto + tests
This commit is contained in:
@@ -0,0 +1,189 @@
|
||||
import { decryptFrom, encryptFor } from '../crypto/box';
|
||||
import type { AppSupabaseClient } from '../supabase/client';
|
||||
|
||||
export const SOUNDBOARDS_BUCKET = 'soundboards';
|
||||
|
||||
export interface RemoteSound {
|
||||
id: string;
|
||||
userId: string;
|
||||
name: string;
|
||||
mime: string;
|
||||
size: number;
|
||||
category: string | null;
|
||||
hotkey: string | null;
|
||||
gain: number;
|
||||
sortOrder: number;
|
||||
storagePath: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface UpsertSoundInput {
|
||||
id: string;
|
||||
name: string;
|
||||
mime: string;
|
||||
size: number;
|
||||
category: string | null;
|
||||
hotkey: string | null;
|
||||
gain: number;
|
||||
sortOrder: number;
|
||||
storagePath: string;
|
||||
updatedAtIso: string;
|
||||
}
|
||||
|
||||
export async function listOwnSounds(client: AppSupabaseClient): Promise<RemoteSound[]> {
|
||||
const { data: session } = await client.auth.getUser();
|
||||
if (!session.user) throw new Error('not authenticated');
|
||||
const { data, error } = await client
|
||||
.from('user_soundboards')
|
||||
.select(
|
||||
'id, user_id, name, mime, size, category, hotkey, gain, sort_order, storage_path, created_at, updated_at',
|
||||
)
|
||||
.eq('user_id', session.user.id)
|
||||
.order('updated_at', { ascending: false });
|
||||
if (error) throw error;
|
||||
return data.map(mapRow);
|
||||
}
|
||||
|
||||
export async function upsertSound(
|
||||
client: AppSupabaseClient,
|
||||
input: UpsertSoundInput,
|
||||
): Promise<RemoteSound> {
|
||||
const { data: session } = await client.auth.getUser();
|
||||
if (!session.user) throw new Error('not authenticated');
|
||||
const { data, error } = await client
|
||||
.from('user_soundboards')
|
||||
.upsert(
|
||||
{
|
||||
id: input.id,
|
||||
user_id: session.user.id,
|
||||
name: input.name,
|
||||
mime: input.mime,
|
||||
size: input.size,
|
||||
category: input.category,
|
||||
hotkey: input.hotkey,
|
||||
gain: input.gain,
|
||||
sort_order: input.sortOrder,
|
||||
storage_path: input.storagePath,
|
||||
updated_at: input.updatedAtIso,
|
||||
},
|
||||
{ onConflict: 'id' },
|
||||
)
|
||||
.select(
|
||||
'id, user_id, name, mime, size, category, hotkey, gain, sort_order, storage_path, created_at, updated_at',
|
||||
)
|
||||
.single();
|
||||
if (error) throw error;
|
||||
return mapRow(data);
|
||||
}
|
||||
|
||||
export async function deleteSound(
|
||||
client: AppSupabaseClient,
|
||||
soundId: string,
|
||||
): Promise<void> {
|
||||
const { data: session } = await client.auth.getUser();
|
||||
if (!session.user) throw new Error('not authenticated');
|
||||
const { data: row } = await client
|
||||
.from('user_soundboards')
|
||||
.select('storage_path')
|
||||
.eq('id', soundId)
|
||||
.eq('user_id', session.user.id)
|
||||
.maybeSingle();
|
||||
if (row?.storage_path) {
|
||||
const { error: storageErr } = await client.storage
|
||||
.from(SOUNDBOARDS_BUCKET)
|
||||
.remove([row.storage_path]);
|
||||
if (storageErr && !/not found/i.test(storageErr.message)) throw storageErr;
|
||||
}
|
||||
const { error } = await client
|
||||
.from('user_soundboards')
|
||||
.delete()
|
||||
.eq('id', soundId)
|
||||
.eq('user_id', session.user.id);
|
||||
if (error) throw error;
|
||||
}
|
||||
|
||||
// Sealed-to-self envelope: nonce || ciphertext. encryptFor's sender and
|
||||
// recipient are both the current user, equivalent to crypto_box_seal but
|
||||
// reuses the existing helper (no new backend method).
|
||||
export async function encryptSoundBlob(
|
||||
blob: Blob,
|
||||
myPublicKey: Uint8Array,
|
||||
myPrivateKey: Uint8Array,
|
||||
): Promise<Uint8Array> {
|
||||
const bytes = new Uint8Array(await blob.arrayBuffer());
|
||||
const { ciphertext, nonce } = await encryptFor(bytes, myPublicKey, myPrivateKey);
|
||||
const out = new Uint8Array(nonce.length + ciphertext.length);
|
||||
out.set(nonce, 0);
|
||||
out.set(ciphertext, nonce.length);
|
||||
return out;
|
||||
}
|
||||
|
||||
export async function decryptSoundEnvelope(
|
||||
envelope: Uint8Array,
|
||||
myPublicKey: Uint8Array,
|
||||
myPrivateKey: Uint8Array,
|
||||
): Promise<Uint8Array> {
|
||||
const NONCE_LEN = 24;
|
||||
if (envelope.length < NONCE_LEN + 16) {
|
||||
throw new Error('sound_envelope_too_short');
|
||||
}
|
||||
const nonce = envelope.slice(0, NONCE_LEN);
|
||||
const ciphertext = envelope.slice(NONCE_LEN);
|
||||
return decryptFrom(ciphertext, nonce, myPublicKey, myPrivateKey);
|
||||
}
|
||||
|
||||
export async function uploadSoundCiphertext(
|
||||
client: AppSupabaseClient,
|
||||
storagePath: string,
|
||||
ciphertext: Uint8Array,
|
||||
): Promise<void> {
|
||||
const { error } = await client.storage
|
||||
.from(SOUNDBOARDS_BUCKET)
|
||||
.upload(storagePath, ciphertext, {
|
||||
contentType: 'application/octet-stream',
|
||||
upsert: true,
|
||||
});
|
||||
if (error) throw error;
|
||||
}
|
||||
|
||||
export async function downloadSoundCiphertext(
|
||||
client: AppSupabaseClient,
|
||||
storagePath: string,
|
||||
): Promise<Uint8Array> {
|
||||
const { data, error } = await client.storage
|
||||
.from(SOUNDBOARDS_BUCKET)
|
||||
.download(storagePath);
|
||||
if (error) throw error;
|
||||
return new Uint8Array(await data.arrayBuffer());
|
||||
}
|
||||
|
||||
function mapRow(row: {
|
||||
id: string;
|
||||
user_id: string;
|
||||
name: string;
|
||||
mime: string;
|
||||
size: number;
|
||||
category: string | null;
|
||||
hotkey: string | null;
|
||||
gain: number;
|
||||
sort_order: number;
|
||||
storage_path: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}): RemoteSound {
|
||||
return {
|
||||
id: row.id,
|
||||
userId: row.user_id,
|
||||
name: row.name,
|
||||
mime: row.mime,
|
||||
size: row.size,
|
||||
category: row.category,
|
||||
hotkey: row.hotkey,
|
||||
gain: row.gain,
|
||||
sortOrder: row.sort_order,
|
||||
storagePath: row.storage_path,
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user