@freqhole/api-client 0.1.30
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/auth-test.html +651 -0
- package/package.json +42 -0
- package/src/AdminClient.ts +154 -0
- package/src/CharnelLocalTransport.ts +598 -0
- package/src/CharnelTransport.ts +616 -0
- package/src/FreqholeClient.ts +261 -0
- package/src/WasmTransport.ts +1077 -0
- package/src/automerge/IrohNetworkAdapter.ts +727 -0
- package/src/automerge/index.ts +14 -0
- package/src/codegen/admin_commands.ts +61 -0
- package/src/codegen/routes.ts +213 -0
- package/src/codegen/schema.ts +6721 -0
- package/src/domains/admin.ts +97 -0
- package/src/domains/app.ts +98 -0
- package/src/domains/auth.ts +191 -0
- package/src/domains/favorites.types.ts +52 -0
- package/src/domains/jobs.ts +48 -0
- package/src/domains/music.ts +1935 -0
- package/src/domains/types.ts +19 -0
- package/src/domains/upload.ts +214 -0
- package/src/index.ts +48 -0
- package/src/permissions.ts +468 -0
- package/src/playlistz/convert.ts +267 -0
- package/src/playlistz/index.ts +6 -0
- package/src/playlistz/mutations.ts +181 -0
- package/src/playlistz/protocol.ts +222 -0
- package/src/playlistz/schema.ts +138 -0
- package/src/playlistz/shareLink.ts +76 -0
- package/src/storage/blobs.ts +272 -0
- package/src/storage/identity.ts +190 -0
- package/src/storage/index.ts +5 -0
- package/src/storage/webLocks.ts +92 -0
- package/src/test/automerge.ts +796 -0
- package/src/test/coverage.ts +105 -0
- package/src/test/fixtures.ts +460 -0
- package/src/test/integration.ts +320 -0
- package/src/test/playlistz.ts +631 -0
- package/src/test/stateful.ts +561 -0
- package/src/test/storage.ts +443 -0
- package/src/test.ts +75 -0
- package/src/transport.ts +391 -0
- package/src/utils.ts +79 -0
- package/src/webauthn.ts +228 -0
- package/tsconfig.json +22 -0
- package/vite.config.js +9 -0
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
// zod schemas for the playlistz automerge document model.
|
|
2
|
+
// plain objects + zod only - no automerge imports.
|
|
3
|
+
import * as z from "zod";
|
|
4
|
+
|
|
5
|
+
// the set of blob types a stored binary can represent
|
|
6
|
+
export const BlobKindSchema = z.union([
|
|
7
|
+
z.literal("original"),
|
|
8
|
+
z.literal("thumbnail"),
|
|
9
|
+
z.literal("waveform"),
|
|
10
|
+
z.literal("preview"),
|
|
11
|
+
]);
|
|
12
|
+
export type BlobKind = z.infer<typeof BlobKindSchema>;
|
|
13
|
+
|
|
14
|
+
// a reference to a binary stored in the blob store, keyed by sha256
|
|
15
|
+
export const ImageRefSchema = z.object({
|
|
16
|
+
blobId: z.string(),
|
|
17
|
+
isPrimary: z.boolean(),
|
|
18
|
+
blobType: BlobKindSchema,
|
|
19
|
+
});
|
|
20
|
+
export type ImageRef = z.infer<typeof ImageRefSchema>;
|
|
21
|
+
|
|
22
|
+
// a named url associated with a playlist or song
|
|
23
|
+
export const EntityUrlSchema = z.object({
|
|
24
|
+
id: z.string().optional(),
|
|
25
|
+
name: z.string().optional(),
|
|
26
|
+
url: z.string(),
|
|
27
|
+
});
|
|
28
|
+
export type EntityUrl = z.infer<typeof EntityUrlSchema>;
|
|
29
|
+
|
|
30
|
+
// a single song as stored in the playlist doc
|
|
31
|
+
export const SongEntrySchema = z.object({
|
|
32
|
+
id: z.string(),
|
|
33
|
+
title: z.string(),
|
|
34
|
+
artist: z.string(),
|
|
35
|
+
album: z.string(),
|
|
36
|
+
duration: z.number(),
|
|
37
|
+
mimeType: z.string(),
|
|
38
|
+
fileSize: z.number(),
|
|
39
|
+
sha256: z.string(),
|
|
40
|
+
blake3: z.string().optional(),
|
|
41
|
+
images: z.array(ImageRefSchema).default([]),
|
|
42
|
+
urls: z.array(EntityUrlSchema).default([]),
|
|
43
|
+
lyrics: z.string().optional(),
|
|
44
|
+
});
|
|
45
|
+
export type SongEntry = z.infer<typeof SongEntrySchema>;
|
|
46
|
+
|
|
47
|
+
// the top-level automerge document for a playlist.
|
|
48
|
+
// all fields have defaults so schema.parse({}) succeeds for graceful degradation.
|
|
49
|
+
export const PlaylistDocSchema = z.object({
|
|
50
|
+
version: z.literal(1).default(1),
|
|
51
|
+
title: z.string().default(""),
|
|
52
|
+
description: z.string().default(""),
|
|
53
|
+
createdAt: z.string().default(() => new Date().toISOString()),
|
|
54
|
+
lastModified: z.string().default(() => new Date().toISOString()),
|
|
55
|
+
lastModifiedBy: z.string().default(""),
|
|
56
|
+
images: z.array(ImageRefSchema).default([]),
|
|
57
|
+
urls: z.array(EntityUrlSchema).default([]),
|
|
58
|
+
songs: z.record(z.string(), SongEntrySchema).default({}),
|
|
59
|
+
order: z.array(z.string()).default([]),
|
|
60
|
+
peers: z
|
|
61
|
+
.record(
|
|
62
|
+
z.string(),
|
|
63
|
+
z.object({
|
|
64
|
+
nodeId: z.string(),
|
|
65
|
+
joinedAt: z.string(),
|
|
66
|
+
lastSeenAt: z.string().optional(),
|
|
67
|
+
}),
|
|
68
|
+
)
|
|
69
|
+
.default({}),
|
|
70
|
+
acl: z
|
|
71
|
+
.record(
|
|
72
|
+
z.string(),
|
|
73
|
+
z.object({
|
|
74
|
+
role: z.union([
|
|
75
|
+
z.literal("owner"),
|
|
76
|
+
z.literal("editor"),
|
|
77
|
+
z.literal("viewer"),
|
|
78
|
+
]),
|
|
79
|
+
}),
|
|
80
|
+
)
|
|
81
|
+
.optional(),
|
|
82
|
+
deleted: z.boolean().optional(),
|
|
83
|
+
// per-playlist sharing mode. unset = private (not shared).
|
|
84
|
+
sharingMode: z.union([z.literal("public"), z.literal("knock")]).optional(),
|
|
85
|
+
// display filter settings for the playlist background and cover
|
|
86
|
+
bgFilterEnabled: z.boolean().optional(),
|
|
87
|
+
bgFilterBlur: z.number().optional(),
|
|
88
|
+
bgFilterContrast: z.number().optional(),
|
|
89
|
+
bgFilterBrightness: z.number().optional(),
|
|
90
|
+
coverFilterEnabled: z.boolean().optional(),
|
|
91
|
+
coverFilterBlur: z.number().optional(),
|
|
92
|
+
// background image layout
|
|
93
|
+
bgSize: z.string().optional(), // css background-size, default: "cover"
|
|
94
|
+
bgPosition: z.string().optional(), // css background-position, default: "top"
|
|
95
|
+
bgRepeat: z.string().optional(), // css background-repeat, default: "no-repeat"
|
|
96
|
+
});
|
|
97
|
+
export type PlaylistDoc = z.infer<typeof PlaylistDocSchema>;
|
|
98
|
+
|
|
99
|
+
// recursively copy an object keeping only string-keyed enumerable props.
|
|
100
|
+
// automerge doc proxies carry symbol keys (_am_objectId, _am_datatype_) that
|
|
101
|
+
// zod records reject, so raw docs are plainified before parsing.
|
|
102
|
+
function plainify(value: unknown): unknown {
|
|
103
|
+
if (Array.isArray(value)) return value.map(plainify);
|
|
104
|
+
if (value && typeof value === "object") {
|
|
105
|
+
const out: Record<string, unknown> = {};
|
|
106
|
+
for (const key of Object.keys(value)) {
|
|
107
|
+
out[key] = plainify((value as Record<string, unknown>)[key]);
|
|
108
|
+
}
|
|
109
|
+
return out;
|
|
110
|
+
}
|
|
111
|
+
return value;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// parse raw unknown data into a PlaylistDoc.
|
|
115
|
+
// on failure, logs a warning and returns schema defaults (graceful degradation).
|
|
116
|
+
export function parsePlaylistDoc(raw: unknown): PlaylistDoc {
|
|
117
|
+
try {
|
|
118
|
+
return PlaylistDocSchema.parse(plainify(raw ?? {}));
|
|
119
|
+
} catch (err) {
|
|
120
|
+
console.warn(
|
|
121
|
+
"[playlistz:schema] parse failed - falling back to defaults. error:",
|
|
122
|
+
err instanceof z.ZodError ? err.issues : err,
|
|
123
|
+
"raw keys:",
|
|
124
|
+
raw && typeof raw === "object" ? Object.keys(raw) : "null",
|
|
125
|
+
);
|
|
126
|
+
return PlaylistDocSchema.parse({});
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// create an empty PlaylistDoc, optionally merged with caller-supplied fields.
|
|
131
|
+
export function emptyPlaylistDoc(init?: Partial<PlaylistDoc>): PlaylistDoc {
|
|
132
|
+
const now = new Date().toISOString();
|
|
133
|
+
return PlaylistDocSchema.parse({
|
|
134
|
+
createdAt: now,
|
|
135
|
+
lastModified: now,
|
|
136
|
+
...init,
|
|
137
|
+
});
|
|
138
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// share token encoding/decoding for playlistz p2p sharing.
|
|
2
|
+
// versioned base64url payload, no padding.
|
|
3
|
+
// format: base64url({ v: 1, n: nodeId, d: docId, t?: titleHint })
|
|
4
|
+
// url fragment: #share/<token>
|
|
5
|
+
import * as z from "zod";
|
|
6
|
+
|
|
7
|
+
// v1 share payload: node id, doc id, optional title hint, optional mode hint
|
|
8
|
+
const SharePayloadV1Schema = z.object({
|
|
9
|
+
v: z.literal(1),
|
|
10
|
+
n: z.string().min(1),
|
|
11
|
+
d: z.string().min(1),
|
|
12
|
+
t: z.string().optional(),
|
|
13
|
+
m: z.enum(["public", "knock"]).optional(), // sharing mode at link-creation time
|
|
14
|
+
});
|
|
15
|
+
export type SharePayloadV1 = z.infer<typeof SharePayloadV1Schema>;
|
|
16
|
+
|
|
17
|
+
// convert standard base64 to base64url (no padding)
|
|
18
|
+
function toBase64Url(b64: string): string {
|
|
19
|
+
return b64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "");
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// normalize base64url back to standard base64 for decoding
|
|
23
|
+
function fromBase64Url(b64url: string): string {
|
|
24
|
+
let s = b64url.replace(/-/g, "+").replace(/_/g, "/");
|
|
25
|
+
// re-add padding
|
|
26
|
+
while (s.length % 4 !== 0) {
|
|
27
|
+
s += "=";
|
|
28
|
+
}
|
|
29
|
+
return s;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// encode a share payload to a base64url token (no padding)
|
|
33
|
+
export function encodeShareToken(payload: SharePayloadV1): string {
|
|
34
|
+
const json = JSON.stringify(payload);
|
|
35
|
+
const b64 = btoa(json);
|
|
36
|
+
return toBase64Url(b64);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// decode a share token back to a SharePayloadV1.
|
|
40
|
+
// forgiving: strips whitespace, full urls, "#share/" and "share/" prefixes.
|
|
41
|
+
// accepts both base64 and base64url.
|
|
42
|
+
// returns null on anything invalid.
|
|
43
|
+
export function decodeShareToken(input: string): SharePayloadV1 | null {
|
|
44
|
+
try {
|
|
45
|
+
let raw = input.trim();
|
|
46
|
+
|
|
47
|
+
// strip full url up to and including the #share/ fragment
|
|
48
|
+
const hashIdx = raw.indexOf("#share/");
|
|
49
|
+
if (hashIdx !== -1) {
|
|
50
|
+
raw = raw.slice(hashIdx + 7);
|
|
51
|
+
} else if (raw.startsWith("share/")) {
|
|
52
|
+
raw = raw.slice(6);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// strip any trailing fragment or query that may have been appended
|
|
56
|
+
const ampIdx = raw.indexOf("&");
|
|
57
|
+
if (ampIdx !== -1) raw = raw.slice(0, ampIdx);
|
|
58
|
+
|
|
59
|
+
if (!raw) return null;
|
|
60
|
+
|
|
61
|
+
const b64 = fromBase64Url(raw);
|
|
62
|
+
const json = atob(b64);
|
|
63
|
+
const parsed: unknown = JSON.parse(json);
|
|
64
|
+
|
|
65
|
+
const result = SharePayloadV1Schema.safeParse(parsed);
|
|
66
|
+
if (!result.success) return null;
|
|
67
|
+
return result.data;
|
|
68
|
+
} catch {
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// build a url fragment for embedding in window.location.hash
|
|
74
|
+
export function shareFragment(payload: SharePayloadV1): string {
|
|
75
|
+
return `#share/${encodeShareToken(payload)}`;
|
|
76
|
+
}
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
// local blob storage for images and media
|
|
2
|
+
// metadata in IndexedDB, actual blob data in OPFS or Cache API
|
|
3
|
+
|
|
4
|
+
import { openDB, type IDBPDatabase } from "idb";
|
|
5
|
+
|
|
6
|
+
// module-local logger - no-op by default; wire up via setStorageLogger
|
|
7
|
+
let logger: (scope: string, ...args: unknown[]) => void = () => {};
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* set the logger function used by blob storage operations.
|
|
11
|
+
* call this at app startup to opt into logging.
|
|
12
|
+
*/
|
|
13
|
+
export function setStorageLogger(
|
|
14
|
+
fn: (scope: string, ...args: unknown[]) => void,
|
|
15
|
+
): void {
|
|
16
|
+
logger = fn;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export const BLOB_DB_NAME = "freqhole_blobs";
|
|
20
|
+
const BLOB_DB_VERSION = 1;
|
|
21
|
+
const STORE_BLOBS = "blobs";
|
|
22
|
+
const CACHE_NAME = "freqhole-blobs";
|
|
23
|
+
|
|
24
|
+
// global blob URL cache - persists for session, cleared on page reload
|
|
25
|
+
const BLOB_URL_CACHE = new Map<string, string>();
|
|
26
|
+
|
|
27
|
+
export type BlobStorageType = "opfs" | "cache";
|
|
28
|
+
|
|
29
|
+
export interface BlobRecord {
|
|
30
|
+
blob_id: string; // sha256 hash of content (primary key)
|
|
31
|
+
storage_type: BlobStorageType;
|
|
32
|
+
storage_path: string; // opfs path or cache key
|
|
33
|
+
mime_type: string;
|
|
34
|
+
file_size: number;
|
|
35
|
+
created_at: number;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
let blobDbInstance: IDBPDatabase | null = null;
|
|
39
|
+
|
|
40
|
+
async function initBlobDB(): Promise<IDBPDatabase> {
|
|
41
|
+
if (blobDbInstance) return blobDbInstance;
|
|
42
|
+
|
|
43
|
+
blobDbInstance = await openDB(BLOB_DB_NAME, BLOB_DB_VERSION, {
|
|
44
|
+
upgrade(db) {
|
|
45
|
+
if (!db.objectStoreNames.contains(STORE_BLOBS)) {
|
|
46
|
+
db.createObjectStore(STORE_BLOBS, { keyPath: "blob_id" });
|
|
47
|
+
}
|
|
48
|
+
},
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
return blobDbInstance;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// close database connection (required before deletion)
|
|
55
|
+
export function closeBlobDB(): void {
|
|
56
|
+
if (blobDbInstance) {
|
|
57
|
+
blobDbInstance.close();
|
|
58
|
+
blobDbInstance = null;
|
|
59
|
+
logger("blobs", "blob database connection closed");
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// check if OPFS is available
|
|
64
|
+
async function isOPFSAvailable(): Promise<boolean> {
|
|
65
|
+
try {
|
|
66
|
+
if (!navigator.storage?.getDirectory) return false;
|
|
67
|
+
await navigator.storage.getDirectory();
|
|
68
|
+
return true;
|
|
69
|
+
} catch {
|
|
70
|
+
return false;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// write blob to OPFS
|
|
75
|
+
async function writeBlobToOPFS(blobId: string, data: Blob): Promise<string> {
|
|
76
|
+
const root = await navigator.storage.getDirectory();
|
|
77
|
+
const blobsDir = await root.getDirectoryHandle("blobs", { create: true });
|
|
78
|
+
const fileHandle = await blobsDir.getFileHandle(blobId, { create: true });
|
|
79
|
+
const writable = await fileHandle.createWritable();
|
|
80
|
+
await writable.write(data);
|
|
81
|
+
await writable.close();
|
|
82
|
+
return `/blobs/${blobId}`;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// read blob from OPFS
|
|
86
|
+
async function readBlobFromOPFS(storagePath: string): Promise<Blob | null> {
|
|
87
|
+
try {
|
|
88
|
+
const root = await navigator.storage.getDirectory();
|
|
89
|
+
const blobsDir = await root.getDirectoryHandle("blobs");
|
|
90
|
+
const fileName = storagePath.split("/").pop()!;
|
|
91
|
+
const fileHandle = await blobsDir.getFileHandle(fileName);
|
|
92
|
+
const file = await fileHandle.getFile();
|
|
93
|
+
return file;
|
|
94
|
+
} catch {
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// write blob to Cache API
|
|
100
|
+
async function writeBlobToCache(
|
|
101
|
+
blobId: string,
|
|
102
|
+
data: Blob,
|
|
103
|
+
mimeType: string,
|
|
104
|
+
): Promise<string> {
|
|
105
|
+
const cache = await caches.open(CACHE_NAME);
|
|
106
|
+
// use synthetic HTTP URL - webkitgtk (linux) rejects non-HTTP URLs in Cache API
|
|
107
|
+
const url = `https://blob.local/${blobId}`;
|
|
108
|
+
const response = new Response(data, {
|
|
109
|
+
headers: { "Content-Type": mimeType },
|
|
110
|
+
});
|
|
111
|
+
await cache.put(url, response);
|
|
112
|
+
return url;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// read blob from Cache API
|
|
116
|
+
async function readBlobFromCache(storagePath: string): Promise<Blob | null> {
|
|
117
|
+
try {
|
|
118
|
+
const cache = await caches.open(CACHE_NAME);
|
|
119
|
+
const response = await cache.match(storagePath);
|
|
120
|
+
if (!response) return null;
|
|
121
|
+
return await response.blob();
|
|
122
|
+
} catch {
|
|
123
|
+
return null;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* store a blob in local storage and return its ID (sha256 hash).
|
|
129
|
+
* automatically chooses OPFS or Cache API based on availability.
|
|
130
|
+
*/
|
|
131
|
+
export async function storeBlob(
|
|
132
|
+
data: Blob,
|
|
133
|
+
mimeType: string,
|
|
134
|
+
): Promise<string> {
|
|
135
|
+
const db = await initBlobDB();
|
|
136
|
+
|
|
137
|
+
// compute sha256 hash of blob data
|
|
138
|
+
const arrayBuffer = await data.arrayBuffer();
|
|
139
|
+
const hashBuffer = await crypto.subtle.digest("SHA-256", arrayBuffer);
|
|
140
|
+
const hashArray = Array.from(new Uint8Array(hashBuffer));
|
|
141
|
+
const blobId = hashArray.map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
142
|
+
|
|
143
|
+
// check if already exists
|
|
144
|
+
const existing = await db.get(STORE_BLOBS, blobId);
|
|
145
|
+
if (existing) {
|
|
146
|
+
return blobId;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// determine storage type and write blob data
|
|
150
|
+
const useOPFS = await isOPFSAvailable();
|
|
151
|
+
const storageType: BlobStorageType = useOPFS ? "opfs" : "cache";
|
|
152
|
+
const storagePath = useOPFS
|
|
153
|
+
? await writeBlobToOPFS(blobId, data)
|
|
154
|
+
: await writeBlobToCache(blobId, data, mimeType);
|
|
155
|
+
|
|
156
|
+
// store metadata
|
|
157
|
+
const record: BlobRecord = {
|
|
158
|
+
blob_id: blobId,
|
|
159
|
+
storage_type: storageType,
|
|
160
|
+
storage_path: storagePath,
|
|
161
|
+
mime_type: mimeType,
|
|
162
|
+
file_size: data.size,
|
|
163
|
+
created_at: Date.now(),
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
await db.put(STORE_BLOBS, record);
|
|
167
|
+
return blobId;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* get blob metadata by ID
|
|
172
|
+
*/
|
|
173
|
+
export async function getBlobMetadata(
|
|
174
|
+
blobId: string,
|
|
175
|
+
): Promise<BlobRecord | null> {
|
|
176
|
+
const db = await initBlobDB();
|
|
177
|
+
const record = await db.get(STORE_BLOBS, blobId);
|
|
178
|
+
return record || null;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* get blob data by ID
|
|
183
|
+
*/
|
|
184
|
+
export async function getBlob(blobId: string): Promise<Blob | null> {
|
|
185
|
+
const metadata = await getBlobMetadata(blobId);
|
|
186
|
+
if (!metadata) return null;
|
|
187
|
+
|
|
188
|
+
if (metadata.storage_type === "opfs") {
|
|
189
|
+
return await readBlobFromOPFS(metadata.storage_path);
|
|
190
|
+
} else {
|
|
191
|
+
return await readBlobFromCache(metadata.storage_path);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* get blob object URL by ID, cached to avoid repeated OPFS reads and URL creations.
|
|
197
|
+
* returns an object URL string usable directly in an img src.
|
|
198
|
+
* URLs are cached for the session and revoked on page unload.
|
|
199
|
+
*/
|
|
200
|
+
export async function getBlobObjectURL(
|
|
201
|
+
blobId: string,
|
|
202
|
+
): Promise<string | null> {
|
|
203
|
+
if (!blobId) return null;
|
|
204
|
+
|
|
205
|
+
// check cache first
|
|
206
|
+
const cachedUrl = BLOB_URL_CACHE.get(blobId);
|
|
207
|
+
if (cachedUrl) return cachedUrl;
|
|
208
|
+
|
|
209
|
+
// fetch blob and create object URL
|
|
210
|
+
const blob = await getBlob(blobId);
|
|
211
|
+
if (!blob) return null;
|
|
212
|
+
|
|
213
|
+
const objectUrl = URL.createObjectURL(blob);
|
|
214
|
+
BLOB_URL_CACHE.set(blobId, objectUrl);
|
|
215
|
+
|
|
216
|
+
return objectUrl;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* synchronous blob URL cache lookup (no OPFS read).
|
|
221
|
+
* returns cached object URL if available, null otherwise.
|
|
222
|
+
*/
|
|
223
|
+
export function getCachedBlobObjectURL(blobId: string): string | null {
|
|
224
|
+
if (!blobId) return null;
|
|
225
|
+
return BLOB_URL_CACHE.get(blobId) ?? null;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* clear all cached blob URLs and revoke object URLs
|
|
230
|
+
*/
|
|
231
|
+
export function clearBlobUrlCache(): void {
|
|
232
|
+
BLOB_URL_CACHE.forEach((url) => URL.revokeObjectURL(url));
|
|
233
|
+
BLOB_URL_CACHE.clear();
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// revoke object URLs on page unload to avoid memory leaks
|
|
237
|
+
if (typeof window !== "undefined") {
|
|
238
|
+
window.addEventListener("beforeunload", () => {
|
|
239
|
+
clearBlobUrlCache();
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* delete blob by ID from both storage and metadata db
|
|
245
|
+
*/
|
|
246
|
+
export async function deleteBlob(blobId: string): Promise<void> {
|
|
247
|
+
const metadata = await getBlobMetadata(blobId);
|
|
248
|
+
if (!metadata) return;
|
|
249
|
+
|
|
250
|
+
// delete from storage
|
|
251
|
+
if (metadata.storage_type === "opfs") {
|
|
252
|
+
try {
|
|
253
|
+
const root = await navigator.storage.getDirectory();
|
|
254
|
+
const blobsDir = await root.getDirectoryHandle("blobs");
|
|
255
|
+
const fileName = metadata.storage_path.split("/").pop()!;
|
|
256
|
+
await blobsDir.removeEntry(fileName);
|
|
257
|
+
} catch {
|
|
258
|
+
// ignore errors during cleanup
|
|
259
|
+
}
|
|
260
|
+
} else {
|
|
261
|
+
try {
|
|
262
|
+
const cache = await caches.open(CACHE_NAME);
|
|
263
|
+
await cache.delete(metadata.storage_path);
|
|
264
|
+
} catch {
|
|
265
|
+
// ignore errors during cleanup
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// delete metadata
|
|
270
|
+
const db = await initBlobDB();
|
|
271
|
+
await db.delete(STORE_BLOBS, blobId);
|
|
272
|
+
}
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
// p2p identity resolution with read-only access to spume's freqhole_app IndexedDB.
|
|
2
|
+
//
|
|
3
|
+
// policy:
|
|
4
|
+
// - never create or upgrade the "freqhole_app" database
|
|
5
|
+
// - if freqhole_app exists and has an "app_state" store, use it as the primary store
|
|
6
|
+
// - otherwise, use the injected IdentityStore (local, per-app)
|
|
7
|
+
// - when both have an identity, prefer spume's db
|
|
8
|
+
|
|
9
|
+
const FREQHOLE_APP_DB = "freqhole_app";
|
|
10
|
+
const APP_STATE_STORE = "app_state";
|
|
11
|
+
const IDENTITY_KEY = "p2p_identity";
|
|
12
|
+
|
|
13
|
+
// snake_case fields are a wire/storage compatibility contract with spume
|
|
14
|
+
export interface P2PIdentity {
|
|
15
|
+
id: "p2p_identity";
|
|
16
|
+
secret_key: Uint8Array;
|
|
17
|
+
node_id: string;
|
|
18
|
+
created_at: number;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** per-app fallback store - apps supply their own settings persistence */
|
|
22
|
+
export interface IdentityStore {
|
|
23
|
+
get(): Promise<P2PIdentity | null>;
|
|
24
|
+
set(identity: P2PIdentity): Promise<void>;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* check whether freqhole_app exists without creating it.
|
|
29
|
+
*
|
|
30
|
+
* strategy:
|
|
31
|
+
* 1. use indexedDB.databases() when available (Chrome/Firefox 126+)
|
|
32
|
+
* 2. fall back to a versionless open; abort the versionchange transaction
|
|
33
|
+
* if upgrade fires (meaning the db did not exist), then close and delete.
|
|
34
|
+
*/
|
|
35
|
+
async function freqholeAppExists(): Promise<boolean> {
|
|
36
|
+
// prefer the non-destructive databases() API
|
|
37
|
+
if (typeof indexedDB.databases === "function") {
|
|
38
|
+
try {
|
|
39
|
+
const dbs = await indexedDB.databases();
|
|
40
|
+
return dbs.some((d) => d.name === FREQHOLE_APP_DB);
|
|
41
|
+
} catch {
|
|
42
|
+
// fall through to open-based detection
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// open-based detection: if upgradeneeded fires, the db did not exist -
|
|
47
|
+
// abort immediately so IDB never persists it
|
|
48
|
+
return new Promise<boolean>((resolve) => {
|
|
49
|
+
const req = indexedDB.open(FREQHOLE_APP_DB);
|
|
50
|
+
let existed = true;
|
|
51
|
+
|
|
52
|
+
req.onupgradeneeded = (event) => {
|
|
53
|
+
// upgradeneeded on a version-0 open means the db is being created now
|
|
54
|
+
existed = false;
|
|
55
|
+
// abort the versionchange transaction to prevent creation
|
|
56
|
+
(event.target as IDBOpenDBRequest).transaction?.abort();
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
req.onsuccess = () => {
|
|
60
|
+
req.result.close();
|
|
61
|
+
if (!existed) {
|
|
62
|
+
// clean up the empty database that was created before we could abort
|
|
63
|
+
indexedDB.deleteDatabase(FREQHOLE_APP_DB);
|
|
64
|
+
}
|
|
65
|
+
resolve(existed);
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
req.onerror = () => {
|
|
69
|
+
// the abort above causes an error - if existed is false this is expected
|
|
70
|
+
resolve(existed);
|
|
71
|
+
};
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* check whether freqhole_app has the app_state object store.
|
|
77
|
+
* caller must have already confirmed the db exists.
|
|
78
|
+
*/
|
|
79
|
+
async function hasAppStateStore(): Promise<boolean> {
|
|
80
|
+
return new Promise<boolean>((resolve) => {
|
|
81
|
+
const req = indexedDB.open(FREQHOLE_APP_DB);
|
|
82
|
+
|
|
83
|
+
req.onupgradeneeded = () => {
|
|
84
|
+
// should not fire here since we know the db exists - if it does, abort
|
|
85
|
+
req.transaction?.abort();
|
|
86
|
+
resolve(false);
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
req.onsuccess = () => {
|
|
90
|
+
const db = req.result;
|
|
91
|
+
const has = db.objectStoreNames.contains(APP_STATE_STORE);
|
|
92
|
+
db.close();
|
|
93
|
+
resolve(has);
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
req.onerror = () => resolve(false);
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** read p2p_identity from spume's app_state store */
|
|
101
|
+
function readFromFreqholeApp(): Promise<P2PIdentity | null> {
|
|
102
|
+
return new Promise<P2PIdentity | null>((resolve) => {
|
|
103
|
+
const req = indexedDB.open(FREQHOLE_APP_DB);
|
|
104
|
+
|
|
105
|
+
req.onupgradeneeded = () => {
|
|
106
|
+
req.transaction?.abort();
|
|
107
|
+
resolve(null);
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
req.onsuccess = () => {
|
|
111
|
+
const db = req.result;
|
|
112
|
+
const tx = db.transaction(APP_STATE_STORE, "readonly");
|
|
113
|
+
const getReq = tx.objectStore(APP_STATE_STORE).get(IDENTITY_KEY);
|
|
114
|
+
|
|
115
|
+
getReq.onsuccess = () => {
|
|
116
|
+
db.close();
|
|
117
|
+
resolve((getReq.result as P2PIdentity) ?? null);
|
|
118
|
+
};
|
|
119
|
+
getReq.onerror = () => {
|
|
120
|
+
db.close();
|
|
121
|
+
resolve(null);
|
|
122
|
+
};
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
req.onerror = () => resolve(null);
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** write p2p_identity to spume's app_state store (no version bump) */
|
|
130
|
+
function writeToFreqholeApp(identity: P2PIdentity): Promise<void> {
|
|
131
|
+
return new Promise<void>((resolve, reject) => {
|
|
132
|
+
const req = indexedDB.open(FREQHOLE_APP_DB);
|
|
133
|
+
|
|
134
|
+
req.onupgradeneeded = () => {
|
|
135
|
+
req.transaction?.abort();
|
|
136
|
+
reject(new Error("freqhole_app unexpectedly absent during write"));
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
req.onsuccess = () => {
|
|
140
|
+
const db = req.result;
|
|
141
|
+
const tx = db.transaction(APP_STATE_STORE, "readwrite");
|
|
142
|
+
const putReq = tx.objectStore(APP_STATE_STORE).put(identity, IDENTITY_KEY);
|
|
143
|
+
|
|
144
|
+
putReq.onsuccess = () => {
|
|
145
|
+
db.close();
|
|
146
|
+
resolve();
|
|
147
|
+
};
|
|
148
|
+
putReq.onerror = () => {
|
|
149
|
+
db.close();
|
|
150
|
+
reject(putReq.error);
|
|
151
|
+
};
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
req.onerror = () => reject(req.error);
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* resolve the p2p identity for this origin.
|
|
160
|
+
* prefers spume's db when both sources have an identity.
|
|
161
|
+
*/
|
|
162
|
+
export async function resolveIdentity(
|
|
163
|
+
local: IdentityStore,
|
|
164
|
+
): Promise<P2PIdentity | null> {
|
|
165
|
+
const exists = await freqholeAppExists();
|
|
166
|
+
if (exists && (await hasAppStateStore())) {
|
|
167
|
+
const spumeIdentity = await readFromFreqholeApp();
|
|
168
|
+
if (spumeIdentity) return spumeIdentity;
|
|
169
|
+
// spume db exists but has no identity - fall through to local
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
return local.get();
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* persist a p2p identity.
|
|
177
|
+
* writes to spume's db if it exists and has app_state; otherwise uses local.
|
|
178
|
+
*/
|
|
179
|
+
export async function persistIdentity(
|
|
180
|
+
identity: P2PIdentity,
|
|
181
|
+
local: IdentityStore,
|
|
182
|
+
): Promise<void> {
|
|
183
|
+
const exists = await freqholeAppExists();
|
|
184
|
+
if (exists && (await hasAppStateStore())) {
|
|
185
|
+
await writeToFreqholeApp(identity);
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
await local.set(identity);
|
|
190
|
+
}
|