import type { ElyR2Bucket } from "./bindings.js"; const SHA256_HEX = /^[a-f0-9]{64}$/; const REGION = /^[a-z0-9][a-z0-9-]{1,31}$/; const SEGMENT = /^[a-z0-9][a-z0-9._-]{0,127}$/; const SYNC_OBJECT_TYPES = new Set([ "spaces", "tabs", "bookmarks", "notes", "reading-list", "profiles", "site-permissions", "history", "plugin-settings", ]); export class StorageObjectError extends Error { constructor(message: string) { super(message); this.name = "StorageObjectError"; } } export interface StoredObject { key: string; sha256: string; sizeBytes: number; } export function syncPayloadKey(params: { region: string; userHash: string; objectType: string; objectId: string; payloadHash: string; }): string { assertRegion(params.region); assertSha256Hex(params.userHash, "user_hash"); assertSyncObjectType(params.objectType); assertSegment(params.objectId, "object_id"); assertSha256Hex(params.payloadHash, "payload_hash"); return [ "sync-payloads", params.region, params.userHash, params.objectType, params.objectId, `${params.payloadHash}.bin`, ].join("/"); } export function syncSnapshotKey(params: { region: string; userHash: string; snapshotId: string; payloadHash: string; }): string { assertRegion(params.region); assertSha256Hex(params.userHash, "user_hash"); assertSegment(params.snapshotId, "snapshot_id"); assertSha256Hex(params.payloadHash, "payload_hash"); return [ "sync-snapshots", params.region, params.userHash, params.snapshotId, `${params.payloadHash}.bin`, ].join("/"); } export function pluginPackageKey(params: { pluginId: string; packageHash: string }): string { assertSegment(params.pluginId, "plugin_id"); assertSha256Hex(params.packageHash, "package_hash"); return ["plugin-packages", params.pluginId, `${params.packageHash}.rplug`].join("/"); } export function pluginAssetKey(params: { pluginId: string; assetHash: string }): string { assertSegment(params.pluginId, "plugin_id"); assertSha256Hex(params.assetHash, "asset_hash"); return ["plugin-assets", params.pluginId, params.assetHash].join("/"); } export function userAvatarKey(params: { userHash: string; avatarHash: string }): string { assertSha256Hex(params.userHash, "user_hash"); assertSha256Hex(params.avatarHash, "avatar_hash"); return ["user-avatars", params.userHash, params.avatarHash].join("/"); } export function crashAttachmentKey(params: { reportId: string; attachmentHash: string; }): string { assertSegment(params.reportId, "report_id"); assertSha256Hex(params.attachmentHash, "attachment_hash"); return ["crash-attachments", params.reportId, params.attachmentHash].join("/"); } export function exportObjectKey(params: { userHash: string; exportId: string }): string { assertSha256Hex(params.userHash, "user_hash"); assertSegment(params.exportId, "export_id"); return ["exports", params.userHash, `${params.exportId}.bin`].join("/"); } export async function putVerifiedObject( bucket: ElyR2Bucket, key: string, payload: ArrayBuffer, expectedSha256: string, contentType: string, ): Promise { assertKnownObjectKeyHash(key, expectedSha256); const actualSha256 = await sha256Hex(payload); if (actualSha256 !== expectedSha256) { throw new StorageObjectError("r2_checksum_mismatch"); } await bucket.put(key, payload, { httpMetadata: { contentType }, customMetadata: { sha256: expectedSha256 }, sha256: hexToArrayBuffer(expectedSha256), }); return { key, sha256: expectedSha256, sizeBytes: payload.byteLength }; } export async function getVerifiedObject( bucket: ElyR2Bucket, key: string, expectedSha256: string, ): Promise { assertKnownObjectKeyHash(key, expectedSha256); const object = await bucket.get(key); if (object === null) { return null; } const payload = await object.arrayBuffer(); const actualSha256 = await sha256Hex(payload); if (actualSha256 !== expectedSha256) { throw new StorageObjectError("r2_checksum_mismatch"); } return payload; } export async function deleteKnownObject(bucket: ElyR2Bucket, key: string): Promise { assertKnownObjectKey(key); await bucket.delete(key); } export function assertKnownObjectKey(key: string): void { const matches = [ /^sync-payloads\/[a-z0-9][a-z0-9-]{1,31}\/[a-f0-9]{64}\/[a-z0-9][a-z0-9._-]{0,127}\/[a-z0-9][a-z0-9._-]{0,127}\/[a-f0-9]{64}\.bin$/, /^sync-snapshots\/[a-z0-9][a-z0-9-]{1,31}\/[a-f0-9]{64}\/[a-z0-9][a-z0-9._-]{0,127}\.bin$/, /^sync-snapshots\/[a-z0-9][a-z0-9-]{1,31}\/[a-f0-9]{64}\/[a-z0-9][a-z0-9._-]{0,127}\/[a-f0-9]{64}\.bin$/, /^plugin-packages\/[a-z0-9][a-z0-9._-]{0,127}\/[a-f0-9]{64}\.rplug$/, /^plugin-assets\/[a-z0-9][a-z0-9._-]{0,127}\/[a-f0-9]{64}$/, /^user-avatars\/[a-f0-9]{64}\/[a-f0-9]{64}$/, /^crash-attachments\/[a-z0-9][a-z0-9._-]{0,127}\/[a-f0-9]{64}$/, /^exports\/[a-f0-9]{64}\/[a-z0-9][a-z0-9._-]{0,127}\.bin$/, ]; if (!matches.some((pattern) => pattern.test(key))) { throw new StorageObjectError("r2_key_invalid"); } if (key.startsWith("sync-payloads/")) { assertSyncObjectType(key.split("/")[3] ?? ""); } } export function assertKnownObjectKeyHash(key: string, expectedSha256: string): void { assertKnownObjectKey(key); assertSha256Hex(expectedSha256, "sha256"); assertKeyChecksum(key, expectedSha256); } function assertKeyChecksum(key: string, expectedSha256: string): void { const keyChecksum = checksumFromKey(key); if (keyChecksum !== null && keyChecksum !== expectedSha256) { throw new StorageObjectError("r2_key_checksum_mismatch"); } } function checksumFromKey(key: string): string | null { const segments = key.split("/"); const prefix = segments[0]; const lastSegment = segments[segments.length - 1]; if (lastSegment === undefined) { return null; } if (prefix === "sync-payloads" || (prefix === "sync-snapshots" && segments.length === 5)) { return lastSegment.slice(0, -".bin".length); } if (prefix === "plugin-packages") { return lastSegment.slice(0, -".rplug".length); } if (prefix === "plugin-assets" || prefix === "user-avatars" || prefix === "crash-attachments") { return lastSegment; } return null; } function assertRegion(value: string): void { if (!REGION.test(value)) { throw new StorageObjectError("region_invalid"); } } function assertSegment(value: string, name: string): void { if (!SEGMENT.test(value)) { throw new StorageObjectError(`${name}_invalid`); } } export function assertSyncObjectType(value: string): void { if (!SYNC_OBJECT_TYPES.has(value)) { throw new StorageObjectError("object_type_invalid"); } } function assertSha256Hex(value: string, name: string): void { if (!SHA256_HEX.test(value)) { throw new StorageObjectError(`${name}_invalid`); } } async function sha256Hex(payload: ArrayBuffer): Promise { const digest = await crypto.subtle.digest("SHA-256", payload); return [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, "0")).join(""); } function hexToArrayBuffer(hex: string): ArrayBuffer { const bytes = new Uint8Array(hex.length / 2); for (let index = 0; index < bytes.length; index += 1) { bytes[index] = Number.parseInt(hex.slice(index * 2, index * 2 + 2), 16); } return bytes.buffer; }