Something went wrong. Try again.
[READ-ONLY] Mirror of https://github.com/improsocial/impro
An extensible Bluesky client for web impro.social
Something went wrong. Try again.
JavaScript
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241import { unique } from "/js/utils.js";
const ACTION_SCOPES = ["mute", "block", "feedFeedback", "privateData"];
// Wrapper class for permissions objectexport class Permissions { #obj;
constructor(obj) { this.#obj = obj; }
static parse(raw) { const permissions = raw ?? {}; const parsed = {}; if (permissions.fetch) { const fetchArray = Array.isArray(permissions.fetch) ? permissions.fetch : [permissions.fetch]; const fetchPatterns = unique( fetchArray.filter((entry) => typeof entry === "string"), ); if (fetchPatterns.length > 0) parsed.fetch = fetchPatterns; } if (permissions.userFetch === true) parsed.userFetch = true; if (permissions.actions) { const actionsArray = Array.isArray(permissions.actions) ? permissions.actions : [permissions.actions]; const actionScopes = unique( actionsArray.filter((entry) => ACTION_SCOPES.includes(entry)), ); if (actionScopes.length > 0) parsed.actions = actionScopes; } return new Permissions(parsed); }
static fromManifest(manifest) { return Permissions.parse(manifest.permissions ?? {}); }
// Discards path, keeps port static normalizeFetchOrigin(url) { let parsedUrl = null; try { parsedUrl = new URL(url); } catch { return null; } if (parsedUrl.username || parsedUrl.password) return null; const host = parsedUrl.hostname.toLowerCase(); if (!host || host.includes("*")) return null; if (parsedUrl.protocol === "http:") { if (!isLoopbackHost(host)) return null; } else if (parsedUrl.protocol !== "https:") { return null; } const port = parsedUrl.port ? `:${parsedUrl.port}` : ""; return `${parsedUrl.protocol}//${host}${port}/*`; }
get fetch() { return this.#obj.fetch; }
get userFetch() { return this.#obj.userFetch; }
get actions() { return this.#obj.actions; }
toJSON() { return { ...this.#obj }; }
isEmpty() { return Object.values(this.#obj).every((value) => Array.isArray(value) ? value.length === 0 : !value, ); }
allowsAction(action) { if (!ACTION_SCOPES.includes(action)) { throw new Error(`unknown action scope "${action}"`); } return (this.#obj.actions ?? []).includes(action); }
allowsUserFetch() { return this.#obj.userFetch === true; }
hasFetchPattern(pattern) { return (this.#obj.fetch ?? []).includes(pattern); }
allowsFetch(url) { let parsedUrl = null; try { parsedUrl = new URL(url); } catch { return false; } if (parsedUrl.protocol !== "https:") { if (parsedUrl.protocol !== "http:") return false; if (!isLoopbackHost(parsedUrl.hostname)) return false; } return (this.#obj.fetch ?? []).some((pattern) => matchesPattern(parsedUrl, pattern), ); }
withFetchOrigins(origins) { if (origins.length === 0) return this; return new Permissions({ ...this.#obj, fetch: [...(this.#obj.fetch ?? []), ...origins], }); }
diff(next) { const nextObj = next.toJSON(); const diff = {}; let hasAny = false; for (const key of Object.keys(nextObj)) { const nextValue = nextObj[key]; // Scope flags (userFetch) are booleans, not pattern lists if (!Array.isArray(nextValue)) { if (nextValue && !this.#obj[key]) { diff[key] = nextValue; hasAny = true; } continue; } const have = new Set(Array.isArray(this.#obj[key]) ? this.#obj[key] : []); const added = nextValue.filter((entry) => !have.has(entry)); if (added.length > 0) { diff[key] = added; hasAny = true; } } return hasAny ? diff : null; }}
// Permission pattern matching:// https://example.com/path — exact host, exact path// https://example.com/path/* — exact host, path prefix// https://*.example.com/* — example.com and any subdomain// https://example.com/* — exact host, any path// https://example.com:8443/* — exact port; without one, any port// http://localhost:11434/* — http is loopback-only
function matchesPattern(parsedUrl, pattern) { let parsedPattern = null; try { parsedPattern = parsePattern(pattern); } catch (e) { console.error(e); console.warn(`invalid permission: ${pattern}`); return false; } const { scheme, host, port, path } = parsedPattern; if (scheme !== parsedUrl.protocol.slice(0, -1)) return false; if (!hostMatches(parsedUrl.hostname, host)) return false; if (port !== null && port !== effectivePort(parsedUrl)) return false; if (!pathMatches(parsedUrl.pathname, path)) return false; return true;}
function parsePattern(pattern) { if (typeof pattern !== "string") throw new Error("must be a string"); const schemeSep = pattern.indexOf("://"); if (schemeSep === -1) throw new Error("no protocol found"); const scheme = pattern.slice(0, schemeSep); if (scheme !== "https" && scheme !== "http") { throw new Error("https required"); } const rest = pattern.slice(schemeSep + 3); const pathStart = rest.indexOf("/"); const authority = ( pathStart === -1 ? rest : rest.slice(0, pathStart) ).toLowerCase(); const path = pathStart === -1 ? "/*" : rest.slice(pathStart); const { host, port } = splitHostPort(authority); if (!host) throw new Error("no host found"); if (scheme === "http" && !isLoopbackHost(host)) { throw new Error("http is only allowed for loopback hosts"); } return { scheme, host, port, path };}
function splitHostPort(authority) { const portSep = authority.startsWith("[") ? authority.indexOf(":", authority.indexOf("]")) : authority.lastIndexOf(":"); if (portSep === -1) return { host: authority, port: null }; const port = authority.slice(portSep + 1); if (!/^\d+$/.test(port)) throw new Error("invalid port"); return { host: authority.slice(0, portSep), port };}
function effectivePort(parsedUrl) { if (parsedUrl.port) return parsedUrl.port; return parsedUrl.protocol === "https:" ? "443" : "80";}
// Mirrors the "potentially trustworthy origin" hosts browsers exempt from// mixed-content blocking. Note Safari does not honor the exemption, so http// loopback fetches fail there when the app itself is served over https.function isLoopbackHost(host) { const normalized = host.toLowerCase().replace(/^\[|\]$/g, ""); if (normalized === "localhost" || normalized.endsWith(".localhost")) { return true; } if (normalized === "::1") return true; return /^127(?:\.\d{1,3}){3}$/.test(normalized);}
function hostMatches(actualHost, patternHost) { const actual = actualHost.toLowerCase(); if (patternHost.startsWith("*.")) { const suffix = patternHost.slice(2); if (!suffix || suffix.includes("*")) return false; if (actual === suffix) return true; return actual.endsWith("." + suffix); } if (patternHost.includes("*")) return false; return actual === patternHost;}
function pathMatches(actualPath, patternPath) { if (patternPath.endsWith("*")) { const prefix = patternPath.slice(0, -1); return actualPath.startsWith(prefix); } return actualPath === patternPath;}