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 // A stored file page is /file/{hash}; the hash is the last path segment.44 const fileHash = url => (url.pathname.match(/(?:^|\/)file\/([^/]+)$/) || [])[1] || null;45 const validLink = (value, hash) => {46 if (typeof value !== "string") return false;47 try {48 const url = new URL(value, location.href);49 const expected = new URL((globalThis.tiny?.localPath?.("/file/") || "/file/") + hash, location.href);50 if (url.origin !== location.origin || url.pathname !== expected.pathname) return false;51 const params = new URLSearchParams(url.hash.slice(1));52 return Boolean((params.get("key") && params.get("iv")) ||53 (params.get("enc") === "chk-v1" && params.get("key")) ||54 (params.get("manifest") === hash && params.get("key")));55 } catch {56 return false;57 }58 };59 const validEntry = (entry, pubkey) => Boolean(entry && typeof entry === "object" &&60 actorKey(pubkey) && HASH.test(entry.hash) && validLink(entry.link, entry.hash) &&61 validName(entry.name) && (entry.type === undefined || typeof entry.type === "string") &&62 (entry.size === undefined || (Number.isSafeInteger(entry.size) && entry.size >= 0)) &&63 (entry.folder === undefined || typeof entry.folder === "boolean") && validPath(entry.path));64 const write = (pubkey, entries) => {65 const store = storage();66 if (!store) return false;67 try {68 store.setItem(storageKey(pubkey), JSON.stringify(entries.slice(-MAX_ENTRIES)));69 return true;70 } catch {71 return false;72 }73 };74 const entryFor = (hash, pubkey) => {75 const key = actorKey(pubkey);76 return HASH.test(String(hash || "")) ? read(key).find(entry => entry.hash === hash) || null : null;77 };78 const formatSize = size => {79 if (!Number.isSafeInteger(size) || size < 0) return "";80 if (size < 1024) return `${size} B`;81 const units = ["KiB", "MiB", "GiB"];82 let value = size, unit = "B";83 for (const next of units) {84 value /= 1024;85 unit = next;86 if (value < 1024 || next === units.at(-1)) break;87 }88 return `${value >= 10 ? value.toFixed(0) : value.toFixed(1)} ${unit}`;89 };90 const decorate = pubkey => {91 const section = document.querySelector?.("#file-library[data-pubkey]");92 if (!section) return 0;93 let count = 0;94 for (const row of section.querySelectorAll?.("tr[data-hash]") || []) {95 const entry = entryFor(row.getAttribute("data-hash"), pubkey);96 if (!entry) continue;97 const link = row.querySelector("td:first-child > a") || row.querySelector("a");98 if (link) {99 link.href = entry.link;100 link.setAttribute("fx-ignore", "");101 const name = link.querySelector("span:last-child") || link;102 name.textContent = entry.name;103 }104 const details = row.querySelector("td:nth-child(2)");105 if (details && entry.size !== undefined) {106 const access = /secret link/i.test(details.textContent || "");107 details.textContent = access ? "Secret link" : formatSize(entry.size);108 if (access) {109 const size = document.createElement("small");110 size.textContent = formatSize(entry.size);111 details.append(size);112 }113 if (entry.type) {114 const type = document.createElement("small");115 type.textContent = entry.type;116 details.append(type);117 }118 }119 count++;120 }121 return count;122 };123 const restore = pubkey => {124 const current = new URL(location.href);125 const hash = fileHash(current);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 (fileHash(saved) !== 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})();