Something went wrong. Try again.
Mirror of https://github.com/improsocial/improAn extensible Bluesky client for web
Something went wrong. Try again.
JavaScript
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531import { resolveIdentity, getServiceEndpointFromDidDoc } from "/js/atproto.js";
const REQUIRED_MANIFEST_FIELDS = ["id", "name", "version"];
function isRelativePath(file) { if (typeof file !== "string" || file.length === 0) return false; if (file.startsWith("/")) return false; if (file.includes("://")) return false; for (const segment of file.split("/")) { if (segment === "" || segment === "." || segment === "..") return false; } return true;}
function parseFontEntry(entry, index) { if (!entry || typeof entry !== "object") { throw new Error(`fonts[${index}] must be an object`); } const { family, file } = entry; if (typeof family !== "string" || family.length === 0) { throw new Error(`fonts[${index}] missing required field "family"`); } if (typeof file !== "string" || file.length === 0) { throw new Error(`fonts[${index}] missing required field "file"`); } if (!/\.(woff2?|woff)$/i.test(file)) { throw new Error(`fonts[${index}] file must end in .woff2 or .woff`); } if (!isRelativePath(file)) { throw new Error(`fonts[${index}] file must be a relative path`); } return { ...entry, family, file };}
function isPositiveInt(value) { return Number.isInteger(value) && value > 0;}
function parseImageEntry(entry, index) { if (!entry || typeof entry !== "object") { throw new Error(`images[${index}] must be an object`); } const { name, file, frameWidth, frameHeight, frameCount } = entry; if (typeof name !== "string" || name.length === 0) { throw new Error(`images[${index}] missing required field "name"`); } if (typeof file !== "string" || file.length === 0) { throw new Error(`images[${index}] missing required field "file"`); } if (!/\.png$/i.test(file)) { throw new Error(`images[${index}] file must end in .png`); } if (!isRelativePath(file)) { throw new Error(`images[${index}] file must be a relative path`); } if (!isPositiveInt(frameWidth)) { throw new Error(`images[${index}] frameWidth must be a positive integer`); } if (!isPositiveInt(frameHeight)) { throw new Error(`images[${index}] frameHeight must be a positive integer`); } if (!isPositiveInt(frameCount)) { throw new Error(`images[${index}] frameCount must be a positive integer`); } return { ...entry, name, file, frameWidth, frameHeight, frameCount };}
function parsePluginManifest(pluginId, manifest) { for (const field of REQUIRED_MANIFEST_FIELDS) { if (typeof manifest[field] !== "string") { throw new Error(`missing required field "${field}"`); } } if (manifest.id !== pluginId) { throw new Error( `manifest id "${manifest.id}" does not match plugin id "${pluginId}"`, ); } if (manifest.fonts !== undefined) { if (!Array.isArray(manifest.fonts)) { throw new Error(`fonts must be an array`); } manifest.fonts = manifest.fonts.map((entry, i) => parseFontEntry(entry, i)); } if (manifest.images !== undefined) { if (!Array.isArray(manifest.images)) { throw new Error(`images must be an array`); } manifest.images = manifest.images.map((entry, i) => parseImageEntry(entry, i), ); } return manifest;}
// A plugin listing's `repo` field is normally a bare "owner/repo" GitHub// path. It may also be prefixed with a host name ("host:owner/repo"):// "github:" spells out the default explicitly, and "tangled:" sources from// tangled.sh. GitHub owner/repo names can't contain ":", so this is// unambiguous.export function parseRepoSpec(repo) { const colonIndex = repo.indexOf(":"); if (colonIndex === -1) return { host: "github", path: repo }; const host = repo.slice(0, colonIndex); const path = repo.slice(colonIndex + 1); if (host !== "github" && host !== "tangled") { throw new Error(`Unsupported plugin repo host "${host}"`); } return { host, path };}
// The human-facing "view source" URL for a plugin's repo field.export function repoWebUrl(repo) { const { host, path } = parseRepoSpec(repo); if (host === "tangled") { return `https://tangled.org/${path}`; } return `https://github.com/${path}`;}
function assertFontMagicBytes(file, bytes) { const view = new Uint8Array(bytes, 0, 4); const isWoff2 = view[0] === 0x77 && view[1] === 0x4f && view[2] === 0x46 && view[3] === 0x32; const isWoff = view[0] === 0x77 && view[1] === 0x4f && view[2] === 0x46 && view[3] === 0x46; const wantWoff2 = /\.woff2$/i.test(file); if (wantWoff2 ? !isWoff2 : !isWoff) { throw new Error(`font "${file}" has invalid magic bytes`); }}
const PNG_MAGIC = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];const MAX_IMAGE_BYTES = 2 * 1024 * 1024;
function assertPngMagicBytes(file, bytes) { const view = new Uint8Array(bytes, 0, PNG_MAGIC.length); const isPng = PNG_MAGIC.every((byte, i) => view[i] === byte); if (!isPng) { throw new Error(`image "${file}" has invalid magic bytes`); }}
// Shared spritesheet validation: PNG magic bytes, a size cap, and a// dimension check against the declared frame geometry. Used both for// plugin-bundled images (manifest.json's `images` array, fetched from the// plugin's own repo — see getImage() below) and for images a user uploads// at runtime (pluginCustomImages.js) — the trust bar is the same either// way, so the check is written once. `label` is only ever used in error// text (a filename for bundled images, a user-chosen name for uploads).export async function validateSpritesheetImage( bytes, { frameWidth, frameHeight, frameCount }, label,) { if (bytes.byteLength > MAX_IMAGE_BYTES) { throw new Error( `image "${label}" exceeds max size of ${MAX_IMAGE_BYTES} bytes`, ); } assertPngMagicBytes(label, bytes); const blob = new Blob([bytes], { type: "image/png" }); const bitmap = await createImageBitmap(blob); const expectedWidth = frameWidth * frameCount; if (bitmap.width !== expectedWidth || bitmap.height !== frameHeight) { bitmap.close(); throw new Error( `image "${label}" is ${bitmap.width}x${bitmap.height}, expected ` + `${expectedWidth}x${frameHeight} (frameWidth*frameCount x frameHeight)`, ); } bitmap.close(); return blob;}
// tangled.org's own HTTP endpoints (the "/raw/<ref>/<path>" route and the// mirror.tangled.network XRPC service it redirects through) don't set// Access-Control-Allow-Origin, so browsers block fetching them cross-origin// entirely — this isn't fixable by changing the URL we hit on that host.//// Instead, resolve and fetch entirely through standard AT Protocol// infrastructure, which is CORS-enabled throughout (impro already depends// on plc.directory and the handle resolver for its own core function, so// this adds no new trust surface):// 1. resolveIdentity(ownerHandle) -> owner DID + DID doc (handle// resolver + plc.directory)// 2. getServiceEndpointFromDidDoc -> owner's PDS// 3. the repo's own "sh.tangled.repo" record on the owner's PDS ->// {knot, repoDid} (see findTangledRepoRecord below re. the two// record-key schemes this has to handle)// 4. the individual knot server's own "sh.tangled.repo.blob" XRPC route// (not the mirror), which does set Access-Control-Allow-Origin: *//// Cached per repo path since each resolution costs multiple network// round-trips and rarely changes.const tangledRepoInfoCache = new Map();
// The "sh.tangled.repo" record key scheme has changed at least once:// repos created more recently use the repo name directly as the record// key (rkey), while older repos use an opaque TID key instead, with the// repo name only present as an explicit "name" field on the record value// (confirmed against real accounts, e.g. tangled.org's own "infra" repo// still uses a TID key). Try the fast direct lookup first, and fall back// to scanning the owner's records by name — this has to keep working for// both existing schemes, and possibly future ones with the same fallback.async function findTangledRepoRecord(pds, ownerDid, repoName) { const directUrl = `${pds}/xrpc/com.atproto.repo.getRecord?` + new URLSearchParams({ repo: ownerDid, collection: "sh.tangled.repo", rkey: repoName, }); const directResponse = await fetch(directUrl); if (directResponse.ok) { const record = await directResponse.json(); if (record.value) return record.value; }
let cursor = null; for (let page = 0; page < 20; page++) { const params = new URLSearchParams({ repo: ownerDid, collection: "sh.tangled.repo", limit: "100", }); if (cursor) params.set("cursor", cursor); const response = await fetch( `${pds}/xrpc/com.atproto.repo.listRecords?${params}`, ); if (!response.ok) return null; const data = await response.json(); const records = data.records ?? []; const match = records.find((record) => record.value?.name === repoName); if (match) return match.value; if (!data.cursor || records.length === 0) return null; cursor = data.cursor; } return null;}
async function resolveTangledRepoInfo(path) { if (tangledRepoInfoCache.has(path)) { return tangledRepoInfoCache.get(path); } const promise = (async () => { const slashIndex = path.indexOf("/"); if (slashIndex === -1) { throw new Error(`Invalid tangled repo path "${path}"`); } const ownerHandle = path.slice(0, slashIndex); const repoName = path.slice(slashIndex + 1);
const identity = await resolveIdentity(ownerHandle); if (!identity) { throw new Error(`Could not resolve tangled repo owner "${ownerHandle}"`); } const pds = getServiceEndpointFromDidDoc(identity.didDoc);
const record = await findTangledRepoRecord(pds, identity.did, repoName); if (!record) { throw new Error( `Could not find a tangled repo record named "${repoName}" for "${ownerHandle}"`, ); } const { knot, repoDid } = record; if (!knot || !repoDid) { throw new Error( `tangled repo record for "${path}" is missing knot/repoDid`, ); } return { knot, repoDid }; })(); // Don't cache a failed resolution — allow retrying on a later call. promise.catch(() => tangledRepoInfoCache.delete(path)); tangledRepoInfoCache.set(path, promise); return promise;}
// The knot's raw=true mode only serves image/video/text mime types (fonts// come back 403 "only image, video, and text files can be accessed// directly"). Passing raw=false instead gets the JSON-wrapped response// (content + encoding, "base64" for binary files) that every file type// supports — needed for fonts, usable for any file type.async function remoteAssetUrl({ repo, file, release = null, raw = true }) { const { host, path } = parseRepoSpec(repo); if (host === "tangled") { const { knot, repoDid } = await resolveTangledRepoInfo(path); const params = new URLSearchParams({ repo: repoDid, ref: release ?? "main", path: file, }); if (raw) params.set("raw", "true"); return `https://${knot}/xrpc/sh.tangled.repo.blob?${params}`; } const ref = release ? `refs/tags/${release}` : "refs/heads/main"; return `https://raw.githubusercontent.com/${path}/${ref}/${file}`;}
// Decodes a tangled knot's JSON-wrapped blob response (from a non-raw// sh.tangled.repo.blob fetch) into an ArrayBuffer.function decodeTangledBlobContent(data, file) { if (typeof data.content !== "string") { throw new Error(`tangled blob response for "${file}" has no content`); } if (data.encoding === "base64") { const binary = atob(data.content); const bytes = new Uint8Array(binary.length); for (let i = 0; i < binary.length; i++) { bytes[i] = binary.charCodeAt(i); } return bytes.buffer; } return new TextEncoder().encode(data.content).buffer;}
export class SourceProvider { constructor(pluginCache) { this.pluginCache = pluginCache; }
async getManifest(pluginId, version, repo) { if (pluginId.endsWith("__LOCAL")) { const response = await fetch(`/plugins-local/${pluginId}/manifest.json`); if (!response.ok) throw new Error(`HTTP ${response.status}`); const manifest = await response.json(); manifest.id = manifest.id + "__LOCAL"; return parsePluginManifest(pluginId, manifest); } if (!version || !repo) { throw new Error("Version and repo are required"); } const url = await remoteAssetUrl({ repo, file: "manifest.json", release: version, }); const response = await this.pluginCache.fetch(url); return parsePluginManifest(pluginId, await response.json()); }
async getLiveManifest(pluginId, repo) { if (pluginId.endsWith("__LOCAL")) { return this.getManifest(pluginId, null, null); } if (!repo) { throw new Error("Repo is required"); } // Fetch from main branch const url = await remoteAssetUrl({ repo, file: "manifest.json" }); const response = await fetch(url, { cache: "no-store" }); if (!response.ok) throw new Error(`HTTP ${response.status}`); return parsePluginManifest(pluginId, await response.json()); }
async getLiveManifestFromRepo(repo) { if (!repo) { throw new Error("Repo is required"); } const url = await remoteAssetUrl({ repo, file: "manifest.json" }); const response = await fetch(url, { cache: "no-store" }); if (!response.ok) throw new Error(`HTTP ${response.status}`); const manifest = await response.json(); return parsePluginManifest(manifest.id, manifest); }
async getSource(pluginId, version, repo) { if (pluginId.endsWith("__LOCAL")) { const response = await fetch(`/plugins-local/${pluginId}/main.js`); if (!response.ok) throw new Error(`HTTP ${response.status}`); return await response.text(); } if (!version || !repo) { throw new Error("Version and repo are required"); } const url = await remoteAssetUrl({ repo, file: "main.js", release: version, }); const response = await this.pluginCache.fetch(url); return await response.text(); }
// Returns CSS text if the plugin includes a styles.css, otherwise null. async getStyles(pluginId, version, repo) { if (pluginId.endsWith("__LOCAL")) { const response = await fetch(`/plugins-local/${pluginId}/styles.css`); if (response.status === 404) return null; if (!response.ok) throw new Error(`HTTP ${response.status}`); return await response.text(); } if (!version || !repo) { throw new Error("Version and repo are required"); } const url = await remoteAssetUrl({ repo, file: "styles.css", release: version, }); try { const response = await this.pluginCache.fetch(url, { doCacheNotFound: true, }); return await response.text(); } catch (error) { if (error?.status === 404) return null; throw error; } }
async getFont(pluginId, version, repo, file) { let bytes; if (pluginId.endsWith("__LOCAL")) { const response = await fetch(`/plugins-local/${pluginId}/${file}`); if (!response.ok) throw new Error(`HTTP ${response.status}`); bytes = await response.arrayBuffer(); } else { if (!version || !repo) { throw new Error("Version and repo are required"); } const { host } = parseRepoSpec(repo); if (host === "tangled") { const url = await remoteAssetUrl({ repo, file, release: version, raw: false, }); const response = await this.pluginCache.fetch(url); bytes = decodeTangledBlobContent(await response.json(), file); } else { const url = await remoteAssetUrl({ repo, file, release: version }); const response = await this.pluginCache.fetch(url); bytes = await response.arrayBuffer(); } } assertFontMagicBytes(file, bytes); const mime = /\.woff2$/i.test(file) ? "font/woff2" : "font/woff"; return new Blob([bytes], { type: mime }); }
async getImage( pluginId, version, repo, file, { frameWidth, frameHeight, frameCount }, ) { let bytes; if (pluginId.endsWith("__LOCAL")) { const response = await fetch(`/plugins-local/${pluginId}/${file}`); if (!response.ok) throw new Error(`HTTP ${response.status}`); bytes = await response.arrayBuffer(); } else { if (!version || !repo) { throw new Error("Version and repo are required"); } const { host } = parseRepoSpec(repo); if (host === "tangled") { const url = await remoteAssetUrl({ repo, file, release: version, raw: false, }); const response = await this.pluginCache.fetch(url); bytes = decodeTangledBlobContent(await response.json(), file); } else { const url = await remoteAssetUrl({ repo, file, release: version }); const response = await this.pluginCache.fetch(url); bytes = await response.arrayBuffer(); } } return validateSpritesheetImage( bytes, { frameWidth, frameHeight, frameCount }, file, ); }
async getReadme(pluginId, repo) { if (pluginId.endsWith("__LOCAL")) { const response = await fetch(`/plugins-local/${pluginId}/README.md`); if (response.status === 404) return null; if (!response.ok) throw new Error(`HTTP ${response.status}`); return await response.text(); } if (!repo) { throw new Error("Repo is required"); } // Fetch from main branch so we show the latest README const url = await remoteAssetUrl({ repo, file: "README.md" }); const response = await fetch(url, { cache: "no-store" }); if (response.status === 404) return null; if (!response.ok) throw new Error(`HTTP ${response.status}`); return await response.text(); }
// URLs that should be retained in the cache // Local plugins have no cached URLs async getCacheUrls(pluginId, version, repo) { if (pluginId.endsWith("__LOCAL")) { return []; } const files = ["manifest.json", "main.js", "styles.css"]; try { const manifest = await this.getManifest(pluginId, version, repo); for (const font of manifest.fonts ?? []) { files.push(font.file); } for (const image of manifest.images ?? []) { files.push(image.file); } } catch { // If the manifest can't be read the base URLs are still returned so // reconcile doesn't purge a partially-cached plugin. } return await Promise.all( files.map((file) => remoteAssetUrl({ repo, file, release: version })), ); }}