0
0
mirror of https://github.com/go-gitea/gitea.git synced 2025-12-08 22:11:40 +01:00
gitea/web_src/js/render/plugins/dynamic-plugin.ts
2025-12-06 15:13:35 -08:00

249 lines
8.3 KiB
TypeScript

import type {FileRenderPlugin} from '../plugin.ts';
import {globCompile} from '../../utils/glob.ts';
type RemotePluginMeta = {
schemaVersion: number;
id: string;
name: string;
version: string;
description: string;
entryUrl: string;
assetsBaseUrl: string;
filePatterns: string[];
permissions?: string[];
};
type RemotePluginModule = {
render: (container: HTMLElement, fileUrl: string, options?: any) => void | Promise<void>;
};
const moduleCache = new Map<string, Promise<RemotePluginModule>>();
const SUPPORTED_SCHEMA_VERSION = 1;
async function fetchRemoteMetadata(): Promise<RemotePluginMeta[]> {
const base = window.config.appSubUrl || '';
const response = await window.fetch(`${base}/assets/render-plugins/index.json`, {headers: {'Accept': 'application/json'}});
if (!response.ok) {
throw new Error(`Failed to load render plugin metadata (${response.status})`);
}
return response.json() as Promise<RemotePluginMeta[]>;
}
async function loadRemoteModule(meta: RemotePluginMeta): Promise<RemotePluginModule> {
let cached = moduleCache.get(meta.id);
if (!cached) {
cached = (async () => {
try {
const mod = await import(/* webpackIgnore: true */ meta.entryUrl);
const exported = (mod?.default ?? mod) as RemotePluginModule | undefined;
if (!exported || typeof exported.render !== 'function') {
throw new Error(`Plugin ${meta.id} does not export a render() function`);
}
return exported;
} catch (err) {
moduleCache.delete(meta.id);
throw err;
}
})();
moduleCache.set(meta.id, cached);
}
return cached;
}
function createMatcher(patterns: string[]) {
const compiled = patterns.map((pattern) => {
const normalized = pattern.toLowerCase();
try {
return globCompile(normalized);
} catch (err) {
console.error('Failed to compile render plugin glob pattern', pattern, err);
return null;
}
}).filter(Boolean) as ReturnType<typeof globCompile>[];
return (filename: string) => {
const lower = filename.toLowerCase();
return compiled.some((glob) => glob.regexp.test(lower));
};
}
function wrapRemotePlugin(meta: RemotePluginMeta): FileRenderPlugin {
const matcher = createMatcher(meta.filePatterns);
return {
name: meta.name,
canHandle(filename: string, _mimeType: string, _headChunk?: Uint8Array | null) {
return matcher(filename);
},
async render(container, fileUrl, options) {
const allowedHosts = collectAllowedHosts(meta, fileUrl);
await withNetworkRestrictions(allowedHosts, async () => {
const remote = await loadRemoteModule(meta);
await remote.render(container, fileUrl, options);
});
},
};
}
type RestoreFn = () => void;
function collectAllowedHosts(meta: RemotePluginMeta, fileUrl: string): Set<string> {
const hosts = new Set<string>();
const addHost = (value?: string | null) => {
if (!value) return;
hosts.add(value.toLowerCase());
};
addHost(parseHost(fileUrl));
for (const perm of meta.permissions ?? []) {
addHost(normalizeHost(perm));
}
return hosts;
}
function normalizeHost(host: string | null | undefined): string | null {
if (!host) return null;
return host.trim().toLowerCase();
}
function parseHost(value: string | URL | null | undefined): string | null {
if (!value) return null;
try {
const url = value instanceof URL ? value : new URL(value, window.location.href);
return normalizeHost(url.host);
} catch {
return null;
}
}
function ensureAllowedHost(kind: string, url: URL, allowedHosts: Set<string>): void {
const host = normalizeHost(url.host);
if (!host || allowedHosts.has(host)) {
return;
}
throw new Error(`Render plugin network request for ${kind} blocked: ${host} is not in the declared permissions`);
}
function resolveRequestURL(input: RequestInfo | URL): URL {
if (typeof Request !== 'undefined' && input instanceof Request) {
return new URL(input.url, window.location.href);
}
if (input instanceof URL) {
return new URL(input.toString(), window.location.href);
}
return new URL(input as string, window.location.href);
}
async function withNetworkRestrictions(allowedHosts: Set<string>, fn: () => Promise<void>): Promise<void> {
const restoreFns: RestoreFn[] = [];
const register = (restorer: RestoreFn | null | undefined) => {
if (restorer) {
restoreFns.push(restorer);
}
};
register(patchFetch(allowedHosts));
register(patchXHR(allowedHosts));
register(patchSendBeacon(allowedHosts));
register(patchWebSocket(allowedHosts));
register(patchEventSource(allowedHosts));
try {
await fn();
} finally {
while (restoreFns.length > 0) {
const restore = restoreFns.pop();
restore?.();
}
}
}
function patchFetch(allowedHosts: Set<string>): RestoreFn {
const originalFetch = window.fetch;
const guarded = (input: RequestInfo | URL, init?: RequestInit) => {
const target = resolveRequestURL(input);
ensureAllowedHost('fetch', target, allowedHosts);
return originalFetch.call(window, input as any, init);
};
window.fetch = guarded as typeof window.fetch;
return () => {
window.fetch = originalFetch;
};
}
function patchXHR(allowedHosts: Set<string>): RestoreFn {
const originalOpen = XMLHttpRequest.prototype.open;
function guardedOpen(this: XMLHttpRequest, method: string, url: string | URL, async?: boolean, user?: string | null, password?: string | null) {
const target = url instanceof URL ? url : new URL(url, window.location.href);
ensureAllowedHost('XMLHttpRequest', target, allowedHosts);
return originalOpen.call(this, method, url as any, async ?? true, user ?? undefined, password ?? undefined);
}
XMLHttpRequest.prototype.open = guardedOpen;
return () => {
XMLHttpRequest.prototype.open = originalOpen;
};
}
function patchSendBeacon(allowedHosts: Set<string>): RestoreFn | null {
if (typeof navigator.sendBeacon !== 'function') {
return null;
}
const original = navigator.sendBeacon;
const bound = original.bind(navigator);
navigator.sendBeacon = ((url: string | URL, data?: BodyInit | null) => {
const target = url instanceof URL ? url : new URL(url, window.location.href);
ensureAllowedHost('sendBeacon', target, allowedHosts);
return bound(url as any, data);
}) as typeof navigator.sendBeacon;
return () => {
navigator.sendBeacon = original;
};
}
function patchWebSocket(allowedHosts: Set<string>): RestoreFn {
const OriginalWebSocket = window.WebSocket;
const GuardedWebSocket = function(url: string | URL, protocols?: string | string[]) {
const target = url instanceof URL ? url : new URL(url, window.location.href);
ensureAllowedHost('WebSocket', target, allowedHosts);
return new OriginalWebSocket(url as any, protocols);
} as unknown as typeof WebSocket;
GuardedWebSocket.prototype = OriginalWebSocket.prototype;
Object.setPrototypeOf(GuardedWebSocket, OriginalWebSocket);
window.WebSocket = GuardedWebSocket;
return () => {
window.WebSocket = OriginalWebSocket;
};
}
function patchEventSource(allowedHosts: Set<string>): RestoreFn | null {
if (typeof window.EventSource !== 'function') {
return null;
}
const OriginalEventSource = window.EventSource;
const GuardedEventSource = function(url: string | URL, eventSourceInitDict?: EventSourceInit) {
const target = url instanceof URL ? url : new URL(url, window.location.href);
ensureAllowedHost('EventSource', target, allowedHosts);
return new OriginalEventSource(url as any, eventSourceInitDict);
} as unknown as typeof EventSource;
GuardedEventSource.prototype = OriginalEventSource.prototype;
Object.setPrototypeOf(GuardedEventSource, OriginalEventSource);
window.EventSource = GuardedEventSource;
return () => {
window.EventSource = OriginalEventSource;
};
}
export async function loadDynamicRenderPlugins(): Promise<FileRenderPlugin[]> {
try {
const metadata = await fetchRemoteMetadata();
return metadata.filter((meta) => {
if (meta.schemaVersion !== SUPPORTED_SCHEMA_VERSION) {
console.warn(`Render plugin ${meta.id} ignored due to incompatible schemaVersion ${meta.schemaVersion}`);
return false;
}
return true;
}).map((meta) => wrapRemotePlugin(meta));
} catch (err) {
console.error('Failed to load dynamic render plugins', err);
return [];
}
}