1// Browser-local names and keys for encrypted files. Nothing in this catalog2// is sent to the relay; the tenant and signed-in key are part of its scope.3(() => {4 "use strict";56 const MAX_ENTRIES = 1000;7 const MAX_NAME = 1024;8 const HASH = /^[0-9a-f]{64}$/;9 const PUBKEY = /^[0-9a-f]{64}$/;1011 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 };151152 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})();