tinyrelayfile
tinyrelay/tinyclient/file-catalog.js/

tinyclient/file-catalog.js raw
1// Browser-local names and keys for encrypted files. Nothing in this catalog
2// is sent to the relay; the tenant and signed-in key are part of its scope.
3(() => {
4 "use strict";
5
6 const MAX_ENTRIES = 1000;
7 const MAX_NAME = 1024;
8 const HASH = /^[0-9a-f]{64}$/;
9 const PUBKEY = /^[0-9a-f]{64}$/;
10
11 const actorKey = value => {
12 const key = String(value || "").trim().toLowerCase();
13 return PUBKEY.test(key) ? key : "";
14 };
15 const tenantPath = () => {
16 try {
17 const local = globalThis.tiny?.localPath;
18 const value = new URL(typeof local === "function" ? local("/") : "/", location.href);
19 return value.pathname.replace(/\/+$/, "") || "/";
20 } catch {
21 return "/";
22 }
23 };
24 const storageKey = pubkey => `tiny.files.catalog.v1:${location.origin}:${tenantPath()}:${pubkey}`;
25 const storage = () => {
26 try { return globalThis.localStorage; } catch { return null; }
27 };
28 const read = pubkey => {
29 const key = actorKey(pubkey), store = storage();
30 if (!key || !store) return [];
31 try {
32 const value = JSON.parse(store.getItem(storageKey(key)) || "[]");
33 return Array.isArray(value) ? value.filter(entry => validEntry(entry, key)) : [];
34 } catch {
35 return [];
36 }
37 };
38 const validName = value =>
39 typeof value === "string" && value.length > 0 && value.length <= MAX_NAME &&
40 value.isWellFormed?.() !== false && !/[\u0000/\\]/.test(value);
41 const validPath = value => value === undefined ||
42 (typeof value === "string" && value.length <= MAX_NAME && !/[\u0000\\]/.test(value));
43 const validLink = (value, hash) => {
44 if (typeof value !== "string") return false;
45 try {
46 const url = new URL(value, location.href);
47 const expected = new URL(globalThis.tiny?.localPath?.("/file") || "/file", location.href);
48 if (url.origin !== location.origin || url.pathname !== expected.pathname) return false;
49 if (url.searchParams.get("hash") !== hash && url.searchParams.get("sha") !== hash) return false;
50 const params = new URLSearchParams(url.hash.slice(1));
51 return Boolean((params.get("key") && params.get("iv")) ||
52 (params.get("enc") === "chk-v1" && params.get("key")) ||
53 (params.get("manifest") === hash && params.get("key")));
54 } catch {
55 return false;
56 }
57 };
58 const validEntry = (entry, pubkey) => Boolean(entry && typeof entry === "object" &&
59 actorKey(pubkey) && HASH.test(entry.hash) && validLink(entry.link, entry.hash) &&
60 validName(entry.name) && (entry.type === undefined || typeof entry.type === "string") &&
61 (entry.size === undefined || (Number.isSafeInteger(entry.size) && entry.size >= 0)) &&
62 (entry.folder === undefined || typeof entry.folder === "boolean") && validPath(entry.path));
63 const write = (pubkey, entries) => {
64 const store = storage();
65 if (!store) return false;
66 try {
67 store.setItem(storageKey(pubkey), JSON.stringify(entries.slice(-MAX_ENTRIES)));
68 return true;
69 } catch {
70 return false;
71 }
72 };
73 const entryFor = (hash, pubkey) => {
74 const key = actorKey(pubkey);
75 return HASH.test(String(hash || "")) ? read(key).find(entry => entry.hash === hash) || null : null;
76 };
77 const formatSize = size => {
78 if (!Number.isSafeInteger(size) || size < 0) return "";
79 if (size < 1024) return `${size} B`;
80 const units = ["KiB", "MiB", "GiB"];
81 let value = size, unit = "B";
82 for (const next of units) {
83 value /= 1024;
84 unit = next;
85 if (value < 1024 || next === units.at(-1)) break;
86 }
87 return `${value >= 10 ? value.toFixed(0) : value.toFixed(1)} ${unit}`;
88 };
89 const decorate = pubkey => {
90 const section = document.querySelector?.("#file-library[data-pubkey]");
91 if (!section) return 0;
92 let count = 0;
93 for (const row of section.querySelectorAll?.("tr[data-hash]") || []) {
94 const entry = entryFor(row.getAttribute("data-hash"), pubkey);
95 if (!entry) continue;
96 const link = row.querySelector("td:first-child > a") || row.querySelector("a");
97 if (link) {
98 link.href = entry.link;
99 link.setAttribute("fx-ignore", "");
100 const name = link.querySelector("span:last-child") || link;
101 name.textContent = entry.name;
102 }
103 const details = row.querySelector("td:nth-child(2)");
104 if (details && entry.size !== undefined) {
105 const access = /secret link/i.test(details.textContent || "");
106 details.textContent = access ? "Secret link" : formatSize(entry.size);
107 if (access) {
108 const size = document.createElement("small");
109 size.textContent = formatSize(entry.size);
110 details.append(size);
111 }
112 if (entry.type) {
113 const type = document.createElement("small");
114 type.textContent = entry.type;
115 details.append(type);
116 }
117 }
118 count++;
119 }
120 return count;
121 };
122 const restore = pubkey => {
123 const current = new URL(location.href);
124 if (!/(?:^|\/)file$/.test(current.pathname)) return null;
125 const hash = current.searchParams.get("hash") || current.searchParams.get("sha");
126 const fragment = new URLSearchParams(current.hash.slice(1));
127 if (!HASH.test(hash || "") || fragment.get("key") || fragment.get("iv")) return null;
128 const entry = entryFor(hash, pubkey);
129 if (!entry) return null;
130 const saved = new URL(entry.link);
131 if (saved.searchParams.get("hash") !== hash && saved.searchParams.get("sha") !== hash) return null;
132 history.replaceState?.(null, "", saved.pathname + saved.search + saved.hash);
133 return entry;
134 };
135 const save = (value, pubkey) => {
136 const key = actorKey(pubkey);
137 if (!key || !validEntry(value, key)) return false;
138 const entries = read(key).filter(entry => entry.hash !== value.hash);
139 entries.push({
140 hash: value.hash,
141 link: value.link,
142 name: value.name,
143 ...(value.type === undefined ? {} : {type: value.type}),
144 ...(value.size === undefined ? {} : {size: value.size}),
145 ...(value.folder === undefined ? {} : {folder: value.folder}),
146 ...(value.path === undefined ? {} : {path: value.path}),
147 savedAt: Date.now()
148 });
149 return write(key, entries);
150 };
151
152 const api = Object.freeze({save, get: entryFor, restore, decorate});
153 globalThis.tiny = globalThis.tiny || {};
154 globalThis.tiny.files = {...(globalThis.tiny.files || {}), catalog: api};
155 const sync = () => {
156 const surface = document.querySelector?.("#file-library[data-pubkey], #file-detail[data-pubkey], #file-view[data-pubkey]");
157 const actor = surface?.getAttribute("data-pubkey");
158 if (!actor) return;
159 restore(actor);
160 decorate(actor);
161 };
162 sync();
163 document.addEventListener?.("tiny:navigation", sync);
164})();

About

ownerc01503e8035f
visibilitypublic
headd730679639bf

Maintainers

  • c01503e8035f owner
  • ba1b5beed5b5 agent hermes

Clone

  • https://012.run/npub1cq2s86qrtadf36eqq3epm38wmhdynrrcwxf84eldk3f94dkv7ttsrulrz5/tinyrelay.git

Refs