From 2c56b90cd41f4bea2044d7ed1321a429637d85e5 Mon Sep 17 00:00:00 2001 From: Lunny Xiao Date: Fri, 5 Dec 2025 20:01:56 -0800 Subject: [PATCH 1/5] Add frontend render plugin system support --- contrib/render-plugins/example-wasm/README.md | 51 ++ contrib/render-plugins/example-wasm/build.sh | 26 + .../render-plugins/example-wasm/manifest.json | 11 + contrib/render-plugins/example-wasm/render.js | 163 +++++ .../render-plugins/example-wasm/wasm/main.go | 26 + .../render-plugins/example-wasm/wasm_exec.js | 575 ++++++++++++++++++ contrib/render-plugins/example/README.md | 30 + contrib/render-plugins/example/manifest.json | 9 + contrib/render-plugins/example/render.js | 28 + models/migrations/migrations.go | 1 + models/migrations/v1_26/v324.go | 30 + models/render/plugin.go | 125 ++++ modules/renderplugin/manifest.go | 105 ++++ modules/renderplugin/path.go | 32 + modules/setting/render_plugin.go | 16 + modules/setting/setting.go | 3 + modules/storage/storage.go | 10 + options/locale/locale_en-US.ini | 39 ++ routers/web/admin/render_plugins.go | 165 +++++ routers/web/renderplugin/assets.go | 93 +++ routers/web/repo/view_file.go | 9 + routers/web/web.go | 12 + services/renderplugin/service.go | 283 +++++++++ templates/admin/navbar.tmpl | 3 + templates/admin/render/plugin_detail.tmpl | 112 ++++ templates/admin/render/plugins.tmpl | 65 ++ templates/repo/view_file.tmpl | 3 +- web_src/js/features/file-view.ts | 54 +- web_src/js/render/plugin.ts | 13 +- web_src/js/render/plugins/3d-viewer.ts | 2 +- web_src/js/render/plugins/dynamic-plugin.ts | 96 +++ web_src/js/render/plugins/pdf-viewer.ts | 2 +- 32 files changed, 2174 insertions(+), 18 deletions(-) create mode 100644 contrib/render-plugins/example-wasm/README.md create mode 100755 contrib/render-plugins/example-wasm/build.sh create mode 100644 contrib/render-plugins/example-wasm/manifest.json create mode 100644 contrib/render-plugins/example-wasm/render.js create mode 100644 contrib/render-plugins/example-wasm/wasm/main.go create mode 100644 contrib/render-plugins/example-wasm/wasm_exec.js create mode 100644 contrib/render-plugins/example/README.md create mode 100644 contrib/render-plugins/example/manifest.json create mode 100644 contrib/render-plugins/example/render.js create mode 100644 models/migrations/v1_26/v324.go create mode 100644 models/render/plugin.go create mode 100644 modules/renderplugin/manifest.go create mode 100644 modules/renderplugin/path.go create mode 100644 modules/setting/render_plugin.go create mode 100644 routers/web/admin/render_plugins.go create mode 100644 routers/web/renderplugin/assets.go create mode 100644 services/renderplugin/service.go create mode 100644 templates/admin/render/plugin_detail.tmpl create mode 100644 templates/admin/render/plugins.tmpl create mode 100644 web_src/js/render/plugins/dynamic-plugin.ts diff --git a/contrib/render-plugins/example-wasm/README.md b/contrib/render-plugins/example-wasm/README.md new file mode 100644 index 0000000000000..f60954f4fc195 --- /dev/null +++ b/contrib/render-plugins/example-wasm/README.md @@ -0,0 +1,51 @@ +# Go + WASM Render Plugin Example + +This example shows how to build a frontend render plugin whose heavy lifting +runs inside a WebAssembly module compiled from Go. The plugin loads the WASM +binary in the browser, asks Go to post-process the fetched file content, and +then renders the result inside the file viewer. + +## Files + +- `manifest.json` — plugin metadata consumed by the Gitea backend +- `render.js` — the ES module entry point that initializes the Go runtime and + renders files handled by the plugin +- `wasm/` — contains the Go source that compiles to `plugin.wasm` +- `wasm_exec.js` — the Go runtime shim required by all Go-generated WASM + binaries (copied verbatim from the Go distribution) +- `build.sh` — helper script that builds `plugin.wasm` and produces a zip + archive ready for upload + +## Build & Install + +1. Build the WASM binary and zip archive: + + ```bash + cd contrib/render-plugins/example-wasm + ./build.sh + ``` + + The script requires Go 1.21+ on your PATH. It stores the compiled WASM and + an installable `example-go-wasm.zip` under `dist/`. + +2. In the Gitea web UI, visit `Site Administration → Render Plugins`, upload + `dist/example-go-wasm.zip`, and enable the plugin. + +3. Open any file whose name ends with `.wasmnote`; the viewer will show the + processed output from the Go code running inside WebAssembly. + +## How It Works + +- `wasm/main.go` exposes a single `wasmProcessFile` function to JavaScript. It + uppercases each line, prefixes it with the line number, and runs entirely + inside WebAssembly compiled from Go. +- `render.js` injects the Go runtime (`wasm_exec.js`), instantiates the compiled + module, and caches the exported `wasmProcessFile` function. +- During initialization the frontend passes the sniffed MIME type and the first + 1 KiB of file data to the plugin (`options.mimeType`/`options.headChunk`), + allowing renderers to make decisions without issuing extra network requests. +- During rendering the plugin downloads the target file, passes the contents to + Go, and displays the transformed text with minimal styling. + +Feel free to modify the Go source or the JS wrapper to experiment with richer +interfaces between JavaScript and WebAssembly. diff --git a/contrib/render-plugins/example-wasm/build.sh b/contrib/render-plugins/example-wasm/build.sh new file mode 100755 index 0000000000000..7d8966851bbd4 --- /dev/null +++ b/contrib/render-plugins/example-wasm/build.sh @@ -0,0 +1,26 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "$SCRIPT_DIR" + +BUILD_DIR="$SCRIPT_DIR/.build" +DIST_DIR="$SCRIPT_DIR/dist" +ARCHIVE_NAME="example-go-wasm.zip" + +rm -rf "$BUILD_DIR" +mkdir -p "$BUILD_DIR" "$DIST_DIR" + +export GOOS=js +export GOARCH=wasm + +echo "[+] Building Go WASM binary..." +go build -o "$BUILD_DIR/plugin.wasm" ./wasm + +cp manifest.json "$BUILD_DIR/" +cp render.js "$BUILD_DIR/" +cp wasm_exec.js "$BUILD_DIR/" + +( cd "$BUILD_DIR" && zip -q "../dist/$ARCHIVE_NAME" manifest.json render.js wasm_exec.js plugin.wasm ) + +echo "[+] Wrote $DIST_DIR/$ARCHIVE_NAME" diff --git a/contrib/render-plugins/example-wasm/manifest.json b/contrib/render-plugins/example-wasm/manifest.json new file mode 100644 index 0000000000000..c03d6f665ce1c --- /dev/null +++ b/contrib/render-plugins/example-wasm/manifest.json @@ -0,0 +1,11 @@ +{ + "schemaVersion": 1, + "id": "example-go-wasm", + "name": "Example Go WASM Renderer", + "version": "0.1.0", + "description": "Demonstrates calling a Go-compiled WebAssembly module inside a Gitea render plugin.", + "entry": "render.js", + "filePatterns": [ + "*.wasmnote" + ] +} diff --git a/contrib/render-plugins/example-wasm/render.js b/contrib/render-plugins/example-wasm/render.js new file mode 100644 index 0000000000000..4397ce0e52a3b --- /dev/null +++ b/contrib/render-plugins/example-wasm/render.js @@ -0,0 +1,163 @@ +const wasmUrl = new URL('plugin.wasm', import.meta.url); +const wasmExecUrl = new URL('wasm_exec.js', import.meta.url); +let wasmBridgePromise; +let styleInjected = false; + +function injectScriptOnce(url) { + return new Promise((resolve, reject) => { + const existing = document.querySelector(`script[data-go-runtime="${url.href}"]`); + if (existing) { + if (existing.dataset.loaded === 'true') { + resolve(); + } else { + existing.addEventListener('load', resolve, {once: true}); + existing.addEventListener('error', reject, {once: true}); + } + return; + } + const script = document.createElement('script'); + script.dataset.goRuntime = url.href; + script.src = url.href; + script.async = true; + script.addEventListener('load', () => { + script.dataset.loaded = 'true'; + resolve(); + }, {once: true}); + script.addEventListener('error', reject, {once: true}); + document.head.appendChild(script); + }); +} + +function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +async function waitForExport(name, timeoutMs = 2000) { + const start = Date.now(); + while (typeof globalThis[name] !== 'function') { + if (Date.now() - start > timeoutMs) { + throw new Error(`Go runtime did not expose ${name} within ${timeoutMs}ms`); + } + await sleep(20); + } + return globalThis[name]; +} + +async function ensureWasmBridge() { + if (!wasmBridgePromise) { + wasmBridgePromise = (async () => { + if (typeof globalThis.Go === 'undefined') { + await injectScriptOnce(wasmExecUrl); + } + if (typeof globalThis.Go === 'undefined') { + throw new Error('Go runtime (wasm_exec.js) is unavailable'); + } + const go = new globalThis.Go(); + let result; + const fetchRequest = fetch(wasmUrl); + if (WebAssembly.instantiateStreaming) { + try { + result = await WebAssembly.instantiateStreaming(fetchRequest, go.importObject); + } catch (err) { + console.warn('instantiateStreaming failed; falling back to ArrayBuffer', err); + const buffer = await (await fetchRequest).arrayBuffer(); + result = await WebAssembly.instantiate(buffer, go.importObject); + } + } else { + const buffer = await (await fetchRequest).arrayBuffer(); + result = await WebAssembly.instantiate(buffer, go.importObject); + } + go.run(result.instance); + const processFile = await waitForExport('wasmProcessFile'); + return { + process(content) { + const output = processFile(content); + return typeof output === 'string' ? output : String(output ?? ''); + }, + }; + })(); + } + return wasmBridgePromise; +} + +async function fetchFileText(fileUrl) { + const response = await window.fetch(fileUrl, {headers: {'Accept': 'text/plain'}}); + if (!response.ok) { + throw new Error(`failed to fetch file (${response.status})`); + } + return response.text(); +} + +function ensureStyles() { + if (styleInjected) return; + styleInjected = true; + const style = document.createElement('style'); + style.textContent = ` +.go-wasm-renderer { + font-family: var(--fonts-proportional, system-ui); + border: 1px solid var(--color-secondary); + border-radius: 6px; + overflow: hidden; +} +.go-wasm-renderer__header { + margin: 0; + padding: 0.75rem 1rem; + background: var(--color-secondary-alpha-20); + font-weight: 600; +} +.go-wasm-renderer pre { + margin: 0; + padding: 1rem; + background: var(--color-box-body); + font-family: var(--fonts-monospace, SFMono-Regular, monospace); + white-space: pre; + overflow-x: auto; +} +.go-wasm-renderer__error { + color: var(--color-danger); +} +`; + document.head.appendChild(style); +} + +function renderError(container, message) { + container.innerHTML = ''; + const wrapper = document.createElement('div'); + wrapper.className = 'go-wasm-renderer'; + const header = document.createElement('div'); + header.className = 'go-wasm-renderer__header'; + header.textContent = 'Go WASM Renderer'; + const body = document.createElement('pre'); + body.className = 'go-wasm-renderer__error'; + body.textContent = message; + wrapper.append(header, body); + container.appendChild(wrapper); +} + +export default { + name: 'Go WASM Renderer', + async render(container, fileUrl) { + ensureStyles(); + try { + const [bridge, content] = await Promise.all([ + ensureWasmBridge(), + fetchFileText(fileUrl), + ]); + + const processed = await bridge.process(content); + const wrapper = document.createElement('div'); + wrapper.className = 'go-wasm-renderer'; + const header = document.createElement('div'); + header.className = 'go-wasm-renderer__header'; + header.textContent = 'Go WASM Renderer'; + const body = document.createElement('pre'); + body.textContent = processed; + wrapper.append(header, body); + container.innerHTML = ''; + container.appendChild(wrapper); + } catch (err) { + console.error('Go WASM plugin failed', err); + renderError(container, `Unable to render file: ${err.message}`); + } + }, +}; diff --git a/contrib/render-plugins/example-wasm/wasm/main.go b/contrib/render-plugins/example-wasm/wasm/main.go new file mode 100644 index 0000000000000..a43eb0aa2cf7b --- /dev/null +++ b/contrib/render-plugins/example-wasm/wasm/main.go @@ -0,0 +1,26 @@ +package main + +import ( + "fmt" + "strings" + "syscall/js" +) + +func processFile(this js.Value, args []js.Value) any { + if len(args) == 0 { + return js.ValueOf("(no content)") + } + content := args[0].String() + lines := strings.Split(content, "\n") + var b strings.Builder + b.Grow(len(content) + len(lines)*8) + for i, line := range lines { + fmt.Fprintf(&b, "%4d │ %s\n", i+1, strings.ToUpper(line)) + } + return js.ValueOf(b.String()) +} + +func main() { + js.Global().Set("wasmProcessFile", js.FuncOf(processFile)) + select {} +} diff --git a/contrib/render-plugins/example-wasm/wasm_exec.js b/contrib/render-plugins/example-wasm/wasm_exec.js new file mode 100644 index 0000000000000..d71af9e97e8ca --- /dev/null +++ b/contrib/render-plugins/example-wasm/wasm_exec.js @@ -0,0 +1,575 @@ +// Copyright 2018 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +"use strict"; + +(() => { + const enosys = () => { + const err = new Error("not implemented"); + err.code = "ENOSYS"; + return err; + }; + + if (!globalThis.fs) { + let outputBuf = ""; + globalThis.fs = { + constants: { O_WRONLY: -1, O_RDWR: -1, O_CREAT: -1, O_TRUNC: -1, O_APPEND: -1, O_EXCL: -1, O_DIRECTORY: -1 }, // unused + writeSync(fd, buf) { + outputBuf += decoder.decode(buf); + const nl = outputBuf.lastIndexOf("\n"); + if (nl != -1) { + console.log(outputBuf.substring(0, nl)); + outputBuf = outputBuf.substring(nl + 1); + } + return buf.length; + }, + write(fd, buf, offset, length, position, callback) { + if (offset !== 0 || length !== buf.length || position !== null) { + callback(enosys()); + return; + } + const n = this.writeSync(fd, buf); + callback(null, n); + }, + chmod(path, mode, callback) { callback(enosys()); }, + chown(path, uid, gid, callback) { callback(enosys()); }, + close(fd, callback) { callback(enosys()); }, + fchmod(fd, mode, callback) { callback(enosys()); }, + fchown(fd, uid, gid, callback) { callback(enosys()); }, + fstat(fd, callback) { callback(enosys()); }, + fsync(fd, callback) { callback(null); }, + ftruncate(fd, length, callback) { callback(enosys()); }, + lchown(path, uid, gid, callback) { callback(enosys()); }, + link(path, link, callback) { callback(enosys()); }, + lstat(path, callback) { callback(enosys()); }, + mkdir(path, perm, callback) { callback(enosys()); }, + open(path, flags, mode, callback) { callback(enosys()); }, + read(fd, buffer, offset, length, position, callback) { callback(enosys()); }, + readdir(path, callback) { callback(enosys()); }, + readlink(path, callback) { callback(enosys()); }, + rename(from, to, callback) { callback(enosys()); }, + rmdir(path, callback) { callback(enosys()); }, + stat(path, callback) { callback(enosys()); }, + symlink(path, link, callback) { callback(enosys()); }, + truncate(path, length, callback) { callback(enosys()); }, + unlink(path, callback) { callback(enosys()); }, + utimes(path, atime, mtime, callback) { callback(enosys()); }, + }; + } + + if (!globalThis.process) { + globalThis.process = { + getuid() { return -1; }, + getgid() { return -1; }, + geteuid() { return -1; }, + getegid() { return -1; }, + getgroups() { throw enosys(); }, + pid: -1, + ppid: -1, + umask() { throw enosys(); }, + cwd() { throw enosys(); }, + chdir() { throw enosys(); }, + } + } + + if (!globalThis.path) { + globalThis.path = { + resolve(...pathSegments) { + return pathSegments.join("/"); + } + } + } + + if (!globalThis.crypto) { + throw new Error("globalThis.crypto is not available, polyfill required (crypto.getRandomValues only)"); + } + + if (!globalThis.performance) { + throw new Error("globalThis.performance is not available, polyfill required (performance.now only)"); + } + + if (!globalThis.TextEncoder) { + throw new Error("globalThis.TextEncoder is not available, polyfill required"); + } + + if (!globalThis.TextDecoder) { + throw new Error("globalThis.TextDecoder is not available, polyfill required"); + } + + const encoder = new TextEncoder("utf-8"); + const decoder = new TextDecoder("utf-8"); + + globalThis.Go = class { + constructor() { + this.argv = ["js"]; + this.env = {}; + this.exit = (code) => { + if (code !== 0) { + console.warn("exit code:", code); + } + }; + this._exitPromise = new Promise((resolve) => { + this._resolveExitPromise = resolve; + }); + this._pendingEvent = null; + this._scheduledTimeouts = new Map(); + this._nextCallbackTimeoutID = 1; + + const setInt64 = (addr, v) => { + this.mem.setUint32(addr + 0, v, true); + this.mem.setUint32(addr + 4, Math.floor(v / 4294967296), true); + } + + const setInt32 = (addr, v) => { + this.mem.setUint32(addr + 0, v, true); + } + + const getInt64 = (addr) => { + const low = this.mem.getUint32(addr + 0, true); + const high = this.mem.getInt32(addr + 4, true); + return low + high * 4294967296; + } + + const loadValue = (addr) => { + const f = this.mem.getFloat64(addr, true); + if (f === 0) { + return undefined; + } + if (!isNaN(f)) { + return f; + } + + const id = this.mem.getUint32(addr, true); + return this._values[id]; + } + + const storeValue = (addr, v) => { + const nanHead = 0x7FF80000; + + if (typeof v === "number" && v !== 0) { + if (isNaN(v)) { + this.mem.setUint32(addr + 4, nanHead, true); + this.mem.setUint32(addr, 0, true); + return; + } + this.mem.setFloat64(addr, v, true); + return; + } + + if (v === undefined) { + this.mem.setFloat64(addr, 0, true); + return; + } + + let id = this._ids.get(v); + if (id === undefined) { + id = this._idPool.pop(); + if (id === undefined) { + id = this._values.length; + } + this._values[id] = v; + this._goRefCounts[id] = 0; + this._ids.set(v, id); + } + this._goRefCounts[id]++; + let typeFlag = 0; + switch (typeof v) { + case "object": + if (v !== null) { + typeFlag = 1; + } + break; + case "string": + typeFlag = 2; + break; + case "symbol": + typeFlag = 3; + break; + case "function": + typeFlag = 4; + break; + } + this.mem.setUint32(addr + 4, nanHead | typeFlag, true); + this.mem.setUint32(addr, id, true); + } + + const loadSlice = (addr) => { + const array = getInt64(addr + 0); + const len = getInt64(addr + 8); + return new Uint8Array(this._inst.exports.mem.buffer, array, len); + } + + const loadSliceOfValues = (addr) => { + const array = getInt64(addr + 0); + const len = getInt64(addr + 8); + const a = new Array(len); + for (let i = 0; i < len; i++) { + a[i] = loadValue(array + i * 8); + } + return a; + } + + const loadString = (addr) => { + const saddr = getInt64(addr + 0); + const len = getInt64(addr + 8); + return decoder.decode(new DataView(this._inst.exports.mem.buffer, saddr, len)); + } + + const testCallExport = (a, b) => { + this._inst.exports.testExport0(); + return this._inst.exports.testExport(a, b); + } + + const timeOrigin = Date.now() - performance.now(); + this.importObject = { + _gotest: { + add: (a, b) => a + b, + callExport: testCallExport, + }, + gojs: { + // Go's SP does not change as long as no Go code is running. Some operations (e.g. calls, getters and setters) + // may synchronously trigger a Go event handler. This makes Go code get executed in the middle of the imported + // function. A goroutine can switch to a new stack if the current stack is too small (see morestack function). + // This changes the SP, thus we have to update the SP used by the imported function. + + // func wasmExit(code int32) + "runtime.wasmExit": (sp) => { + sp >>>= 0; + const code = this.mem.getInt32(sp + 8, true); + this.exited = true; + delete this._inst; + delete this._values; + delete this._goRefCounts; + delete this._ids; + delete this._idPool; + this.exit(code); + }, + + // func wasmWrite(fd uintptr, p unsafe.Pointer, n int32) + "runtime.wasmWrite": (sp) => { + sp >>>= 0; + const fd = getInt64(sp + 8); + const p = getInt64(sp + 16); + const n = this.mem.getInt32(sp + 24, true); + fs.writeSync(fd, new Uint8Array(this._inst.exports.mem.buffer, p, n)); + }, + + // func resetMemoryDataView() + "runtime.resetMemoryDataView": (sp) => { + sp >>>= 0; + this.mem = new DataView(this._inst.exports.mem.buffer); + }, + + // func nanotime1() int64 + "runtime.nanotime1": (sp) => { + sp >>>= 0; + setInt64(sp + 8, (timeOrigin + performance.now()) * 1000000); + }, + + // func walltime() (sec int64, nsec int32) + "runtime.walltime": (sp) => { + sp >>>= 0; + const msec = (new Date).getTime(); + setInt64(sp + 8, msec / 1000); + this.mem.setInt32(sp + 16, (msec % 1000) * 1000000, true); + }, + + // func scheduleTimeoutEvent(delay int64) int32 + "runtime.scheduleTimeoutEvent": (sp) => { + sp >>>= 0; + const id = this._nextCallbackTimeoutID; + this._nextCallbackTimeoutID++; + this._scheduledTimeouts.set(id, setTimeout( + () => { + this._resume(); + while (this._scheduledTimeouts.has(id)) { + // for some reason Go failed to register the timeout event, log and try again + // (temporary workaround for https://github.com/golang/go/issues/28975) + console.warn("scheduleTimeoutEvent: missed timeout event"); + this._resume(); + } + }, + getInt64(sp + 8), + )); + this.mem.setInt32(sp + 16, id, true); + }, + + // func clearTimeoutEvent(id int32) + "runtime.clearTimeoutEvent": (sp) => { + sp >>>= 0; + const id = this.mem.getInt32(sp + 8, true); + clearTimeout(this._scheduledTimeouts.get(id)); + this._scheduledTimeouts.delete(id); + }, + + // func getRandomData(r []byte) + "runtime.getRandomData": (sp) => { + sp >>>= 0; + crypto.getRandomValues(loadSlice(sp + 8)); + }, + + // func finalizeRef(v ref) + "syscall/js.finalizeRef": (sp) => { + sp >>>= 0; + const id = this.mem.getUint32(sp + 8, true); + this._goRefCounts[id]--; + if (this._goRefCounts[id] === 0) { + const v = this._values[id]; + this._values[id] = null; + this._ids.delete(v); + this._idPool.push(id); + } + }, + + // func stringVal(value string) ref + "syscall/js.stringVal": (sp) => { + sp >>>= 0; + storeValue(sp + 24, loadString(sp + 8)); + }, + + // func valueGet(v ref, p string) ref + "syscall/js.valueGet": (sp) => { + sp >>>= 0; + const result = Reflect.get(loadValue(sp + 8), loadString(sp + 16)); + sp = this._inst.exports.getsp() >>> 0; // see comment above + storeValue(sp + 32, result); + }, + + // func valueSet(v ref, p string, x ref) + "syscall/js.valueSet": (sp) => { + sp >>>= 0; + Reflect.set(loadValue(sp + 8), loadString(sp + 16), loadValue(sp + 32)); + }, + + // func valueDelete(v ref, p string) + "syscall/js.valueDelete": (sp) => { + sp >>>= 0; + Reflect.deleteProperty(loadValue(sp + 8), loadString(sp + 16)); + }, + + // func valueIndex(v ref, i int) ref + "syscall/js.valueIndex": (sp) => { + sp >>>= 0; + storeValue(sp + 24, Reflect.get(loadValue(sp + 8), getInt64(sp + 16))); + }, + + // valueSetIndex(v ref, i int, x ref) + "syscall/js.valueSetIndex": (sp) => { + sp >>>= 0; + Reflect.set(loadValue(sp + 8), getInt64(sp + 16), loadValue(sp + 24)); + }, + + // func valueCall(v ref, m string, args []ref) (ref, bool) + "syscall/js.valueCall": (sp) => { + sp >>>= 0; + try { + const v = loadValue(sp + 8); + const m = Reflect.get(v, loadString(sp + 16)); + const args = loadSliceOfValues(sp + 32); + const result = Reflect.apply(m, v, args); + sp = this._inst.exports.getsp() >>> 0; // see comment above + storeValue(sp + 56, result); + this.mem.setUint8(sp + 64, 1); + } catch (err) { + sp = this._inst.exports.getsp() >>> 0; // see comment above + storeValue(sp + 56, err); + this.mem.setUint8(sp + 64, 0); + } + }, + + // func valueInvoke(v ref, args []ref) (ref, bool) + "syscall/js.valueInvoke": (sp) => { + sp >>>= 0; + try { + const v = loadValue(sp + 8); + const args = loadSliceOfValues(sp + 16); + const result = Reflect.apply(v, undefined, args); + sp = this._inst.exports.getsp() >>> 0; // see comment above + storeValue(sp + 40, result); + this.mem.setUint8(sp + 48, 1); + } catch (err) { + sp = this._inst.exports.getsp() >>> 0; // see comment above + storeValue(sp + 40, err); + this.mem.setUint8(sp + 48, 0); + } + }, + + // func valueNew(v ref, args []ref) (ref, bool) + "syscall/js.valueNew": (sp) => { + sp >>>= 0; + try { + const v = loadValue(sp + 8); + const args = loadSliceOfValues(sp + 16); + const result = Reflect.construct(v, args); + sp = this._inst.exports.getsp() >>> 0; // see comment above + storeValue(sp + 40, result); + this.mem.setUint8(sp + 48, 1); + } catch (err) { + sp = this._inst.exports.getsp() >>> 0; // see comment above + storeValue(sp + 40, err); + this.mem.setUint8(sp + 48, 0); + } + }, + + // func valueLength(v ref) int + "syscall/js.valueLength": (sp) => { + sp >>>= 0; + setInt64(sp + 16, parseInt(loadValue(sp + 8).length)); + }, + + // valuePrepareString(v ref) (ref, int) + "syscall/js.valuePrepareString": (sp) => { + sp >>>= 0; + const str = encoder.encode(String(loadValue(sp + 8))); + storeValue(sp + 16, str); + setInt64(sp + 24, str.length); + }, + + // valueLoadString(v ref, b []byte) + "syscall/js.valueLoadString": (sp) => { + sp >>>= 0; + const str = loadValue(sp + 8); + loadSlice(sp + 16).set(str); + }, + + // func valueInstanceOf(v ref, t ref) bool + "syscall/js.valueInstanceOf": (sp) => { + sp >>>= 0; + this.mem.setUint8(sp + 24, (loadValue(sp + 8) instanceof loadValue(sp + 16)) ? 1 : 0); + }, + + // func copyBytesToGo(dst []byte, src ref) (int, bool) + "syscall/js.copyBytesToGo": (sp) => { + sp >>>= 0; + const dst = loadSlice(sp + 8); + const src = loadValue(sp + 32); + if (!(src instanceof Uint8Array || src instanceof Uint8ClampedArray)) { + this.mem.setUint8(sp + 48, 0); + return; + } + const toCopy = src.subarray(0, dst.length); + dst.set(toCopy); + setInt64(sp + 40, toCopy.length); + this.mem.setUint8(sp + 48, 1); + }, + + // func copyBytesToJS(dst ref, src []byte) (int, bool) + "syscall/js.copyBytesToJS": (sp) => { + sp >>>= 0; + const dst = loadValue(sp + 8); + const src = loadSlice(sp + 16); + if (!(dst instanceof Uint8Array || dst instanceof Uint8ClampedArray)) { + this.mem.setUint8(sp + 48, 0); + return; + } + const toCopy = src.subarray(0, dst.length); + dst.set(toCopy); + setInt64(sp + 40, toCopy.length); + this.mem.setUint8(sp + 48, 1); + }, + + "debug": (value) => { + console.log(value); + }, + } + }; + } + + async run(instance) { + if (!(instance instanceof WebAssembly.Instance)) { + throw new Error("Go.run: WebAssembly.Instance expected"); + } + this._inst = instance; + this.mem = new DataView(this._inst.exports.mem.buffer); + this._values = [ // JS values that Go currently has references to, indexed by reference id + NaN, + 0, + null, + true, + false, + globalThis, + this, + ]; + this._goRefCounts = new Array(this._values.length).fill(Infinity); // number of references that Go has to a JS value, indexed by reference id + this._ids = new Map([ // mapping from JS values to reference ids + [0, 1], + [null, 2], + [true, 3], + [false, 4], + [globalThis, 5], + [this, 6], + ]); + this._idPool = []; // unused ids that have been garbage collected + this.exited = false; // whether the Go program has exited + + // Pass command line arguments and environment variables to WebAssembly by writing them to the linear memory. + let offset = 4096; + + const strPtr = (str) => { + const ptr = offset; + const bytes = encoder.encode(str + "\0"); + new Uint8Array(this.mem.buffer, offset, bytes.length).set(bytes); + offset += bytes.length; + if (offset % 8 !== 0) { + offset += 8 - (offset % 8); + } + return ptr; + }; + + const argc = this.argv.length; + + const argvPtrs = []; + this.argv.forEach((arg) => { + argvPtrs.push(strPtr(arg)); + }); + argvPtrs.push(0); + + const keys = Object.keys(this.env).sort(); + keys.forEach((key) => { + argvPtrs.push(strPtr(`${key}=${this.env[key]}`)); + }); + argvPtrs.push(0); + + const argv = offset; + argvPtrs.forEach((ptr) => { + this.mem.setUint32(offset, ptr, true); + this.mem.setUint32(offset + 4, 0, true); + offset += 8; + }); + + // The linker guarantees global data starts from at least wasmMinDataAddr. + // Keep in sync with cmd/link/internal/ld/data.go:wasmMinDataAddr. + const wasmMinDataAddr = 4096 + 8192; + if (offset >= wasmMinDataAddr) { + throw new Error("total length of command line and environment variables exceeds limit"); + } + + this._inst.exports.run(argc, argv); + if (this.exited) { + this._resolveExitPromise(); + } + await this._exitPromise; + } + + _resume() { + if (this.exited) { + throw new Error("Go program has already exited"); + } + this._inst.exports.resume(); + if (this.exited) { + this._resolveExitPromise(); + } + } + + _makeFuncWrapper(id) { + const go = this; + return function () { + const event = { id: id, this: this, args: arguments }; + go._pendingEvent = event; + go._resume(); + return event.result; + }; + } + } +})(); diff --git a/contrib/render-plugins/example/README.md b/contrib/render-plugins/example/README.md new file mode 100644 index 0000000000000..b2273a8dbf6e9 --- /dev/null +++ b/contrib/render-plugins/example/README.md @@ -0,0 +1,30 @@ +# Example Frontend Render Plugin + +This directory contains a minimal render plugin that highlights `.txt` files +with a custom color scheme. Use it as a starting point for your own plugins or +as a quick way to validate the dynamic plugin system locally. + +## Files + +- `manifest.json` — metadata (including the required `schemaVersion`) consumed by Gitea when installing a plugin +- `render.js` — an ES module that exports a `render(container, fileUrl)` + function; it downloads the source file and renders it in a styled `
`
+
+## Build & Install
+
+1. Create a zip archive that contains both files:
+
+   ```bash
+   cd contrib/render-plugins/example
+   zip -r ../example-highlight-txt.zip manifest.json render.js
+   ```
+
+2. In the Gitea web UI, visit `Site Administration → Render Plugins`, upload
+   `example-highlight-txt.zip`, and enable it.
+
+3. Open any `.txt` file in a repository; the viewer will display the content in
+   the custom colors to confirm the plugin is active.
+
+Feel free to modify `render.js` to experiment with the API. The plugin runs in
+the browser, so only standard Web APIs are available (no bundler is required
+as long as the file stays a plain ES module).
diff --git a/contrib/render-plugins/example/manifest.json b/contrib/render-plugins/example/manifest.json
new file mode 100644
index 0000000000000..05addce610bdd
--- /dev/null
+++ b/contrib/render-plugins/example/manifest.json
@@ -0,0 +1,9 @@
+{
+  "schemaVersion": 1,
+  "id": "example-highlight-txt",
+  "name": "Example TXT Highlighter",
+  "version": "1.0.0",
+  "description": "Simple sample plugin that renders .txt files with a custom color scheme.",
+  "entry": "render.js",
+  "filePatterns": ["*.txt"]
+}
diff --git a/contrib/render-plugins/example/render.js b/contrib/render-plugins/example/render.js
new file mode 100644
index 0000000000000..c689bd517164b
--- /dev/null
+++ b/contrib/render-plugins/example/render.js
@@ -0,0 +1,28 @@
+const TEXT_COLOR = '#f6e05e';
+const BACKGROUND_COLOR = '#1a202c';
+
+async function render(container, fileUrl) {
+  container.innerHTML = '';
+
+  const message = document.createElement('div');
+  message.className = 'ui tiny message';
+  message.textContent = 'Rendered by example-highlight-txt plugin';
+  container.append(message);
+
+  const response = await fetch(fileUrl);
+  if (!response.ok) {
+    throw new Error(`Failed to download file (${response.status})`);
+  }
+  const text = await response.text();
+
+  const pre = document.createElement('pre');
+  pre.style.backgroundColor = BACKGROUND_COLOR;
+  pre.style.color = TEXT_COLOR;
+  pre.style.padding = '1rem';
+  pre.style.borderRadius = '0.5rem';
+  pre.style.overflow = 'auto';
+  pre.textContent = text;
+  container.append(pre);
+}
+
+export default {render};
diff --git a/models/migrations/migrations.go b/models/migrations/migrations.go
index e8ebb5df43ce1..d4895a2546e1f 100644
--- a/models/migrations/migrations.go
+++ b/models/migrations/migrations.go
@@ -398,6 +398,7 @@ func prepareMigrationTasks() []*migration {
 		// Gitea 1.25.0 ends at migration ID number 322 (database version 323)
 
 		newMigration(323, "Add support for actions concurrency", v1_26.AddActionsConcurrency),
+		newMigration(324, "Add frontend render plugin table", v1_26.AddRenderPluginTable),
 	}
 	return preparedMigrations
 }
diff --git a/models/migrations/v1_26/v324.go b/models/migrations/v1_26/v324.go
new file mode 100644
index 0000000000000..e1c4ccb4d92ed
--- /dev/null
+++ b/models/migrations/v1_26/v324.go
@@ -0,0 +1,30 @@
+// Copyright 2025 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package v1_26
+
+import (
+	"code.gitea.io/gitea/modules/timeutil"
+
+	"xorm.io/xorm"
+)
+
+// AddRenderPluginTable creates the render_plugin table used by the frontend plugin system.
+func AddRenderPluginTable(x *xorm.Engine) error {
+	type RenderPlugin struct {
+		ID            int64              `xorm:"pk autoincr"`
+		Identifier    string             `xorm:"UNIQUE NOT NULL"`
+		Name          string             `xorm:"NOT NULL"`
+		Version       string             `xorm:"NOT NULL"`
+		Description   string             `xorm:"TEXT"`
+		Source        string             `xorm:"TEXT"`
+		Entry         string             `xorm:"NOT NULL"`
+		FilePatterns  []string           `xorm:"JSON"`
+		FormatVersion int                `xorm:"NOT NULL DEFAULT 1"`
+		Enabled       bool               `xorm:"NOT NULL DEFAULT false"`
+		CreatedUnix   timeutil.TimeStamp `xorm:"created NOT NULL"`
+		UpdatedUnix   timeutil.TimeStamp `xorm:"updated NOT NULL"`
+	}
+
+	return x.Sync(new(RenderPlugin))
+}
diff --git a/models/render/plugin.go b/models/render/plugin.go
new file mode 100644
index 0000000000000..1414b2d71f26f
--- /dev/null
+++ b/models/render/plugin.go
@@ -0,0 +1,125 @@
+// Copyright 2025 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package render
+
+import (
+	"context"
+
+	"code.gitea.io/gitea/models/db"
+	"code.gitea.io/gitea/modules/timeutil"
+)
+
+// Plugin represents a frontend render plugin installed on the instance.
+type Plugin struct {
+	ID            int64              `xorm:"pk autoincr"`
+	Identifier    string             `xorm:"UNIQUE NOT NULL"`
+	Name          string             `xorm:"NOT NULL"`
+	Version       string             `xorm:"NOT NULL"`
+	Description   string             `xorm:"TEXT"`
+	Source        string             `xorm:"TEXT"`
+	Entry         string             `xorm:"NOT NULL"`
+	FilePatterns  []string           `xorm:"JSON"`
+	FormatVersion int                `xorm:"NOT NULL DEFAULT 1"`
+	Enabled       bool               `xorm:"NOT NULL DEFAULT false"`
+	CreatedUnix   timeutil.TimeStamp `xorm:"created NOT NULL"`
+	UpdatedUnix   timeutil.TimeStamp `xorm:"updated NOT NULL"`
+}
+
+func init() {
+	db.RegisterModel(new(Plugin))
+}
+
+// TableName implements xorm's table name convention.
+func (Plugin) TableName() string {
+	return "render_plugin"
+}
+
+// ListPlugins returns all registered render plugins ordered by identifier.
+func ListPlugins(ctx context.Context) ([]*Plugin, error) {
+	plugins := make([]*Plugin, 0, 4)
+	return plugins, db.GetEngine(ctx).Asc("identifier").Find(&plugins)
+}
+
+// ListEnabledPlugins returns all enabled render plugins.
+func ListEnabledPlugins(ctx context.Context) ([]*Plugin, error) {
+	plugins := make([]*Plugin, 0, 4)
+	return plugins, db.GetEngine(ctx).
+		Where("enabled = ?", true).
+		Asc("identifier").
+		Find(&plugins)
+}
+
+// GetPluginByID returns the plugin with the given primary key.
+func GetPluginByID(ctx context.Context, id int64) (*Plugin, error) {
+	plug := new(Plugin)
+	has, err := db.GetEngine(ctx).ID(id).Get(plug)
+	if err != nil {
+		return nil, err
+	}
+	if !has {
+		return nil, db.ErrNotExist{ID: id}
+	}
+	return plug, nil
+}
+
+// GetPluginByIdentifier returns the plugin with the given identifier.
+func GetPluginByIdentifier(ctx context.Context, identifier string) (*Plugin, error) {
+	plug := new(Plugin)
+	has, err := db.GetEngine(ctx).
+		Where("identifier = ?", identifier).
+		Get(plug)
+	if err != nil {
+		return nil, err
+	}
+	if !has {
+		return nil, db.ErrNotExist{Resource: identifier}
+	}
+	return plug, nil
+}
+
+// UpsertPlugin inserts or updates the plugin identified by Identifier.
+func UpsertPlugin(ctx context.Context, plug *Plugin) error {
+	return db.WithTx(ctx, func(ctx context.Context) error {
+		existing := new(Plugin)
+		has, err := db.GetEngine(ctx).
+			Where("identifier = ?", plug.Identifier).
+			Get(existing)
+		if err != nil {
+			return err
+		}
+		if has {
+			plug.ID = existing.ID
+			plug.Enabled = existing.Enabled
+			plug.CreatedUnix = existing.CreatedUnix
+			_, err = db.GetEngine(ctx).
+				ID(existing.ID).
+				AllCols().
+				Update(plug)
+			return err
+		}
+		_, err = db.GetEngine(ctx).Insert(plug)
+		return err
+	})
+}
+
+// SetPluginEnabled toggles plugin enabled state.
+func SetPluginEnabled(ctx context.Context, plug *Plugin, enabled bool) error {
+	if plug.Enabled == enabled {
+		return nil
+	}
+	plug.Enabled = enabled
+	_, err := db.GetEngine(ctx).
+		ID(plug.ID).
+		Cols("enabled").
+		Update(plug)
+	return err
+}
+
+// DeletePlugin removes the plugin row.
+func DeletePlugin(ctx context.Context, plug *Plugin) error {
+	_, err := db.GetEngine(ctx).
+		ID(plug.ID).
+		Delete(new(Plugin))
+	return err
+}
diff --git a/modules/renderplugin/manifest.go b/modules/renderplugin/manifest.go
new file mode 100644
index 0000000000000..65ca6a5829055
--- /dev/null
+++ b/modules/renderplugin/manifest.go
@@ -0,0 +1,105 @@
+// Copyright 2025 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package renderplugin
+
+import (
+	"encoding/json"
+	"fmt"
+	"os"
+	"path/filepath"
+	"regexp"
+	"sort"
+	"strings"
+
+	"code.gitea.io/gitea/modules/util"
+)
+
+var identifierRegexp = regexp.MustCompile(`^[a-z0-9][a-z0-9\-_.]{1,63}$`)
+
+// Manifest describes the metadata declared by a render plugin.
+const SupportedManifestVersion = 1
+
+type Manifest struct {
+	SchemaVersion int      `json:"schemaVersion"`
+	ID            string   `json:"id"`
+	Name          string   `json:"name"`
+	Version       string   `json:"version"`
+	Description   string   `json:"description"`
+	Entry         string   `json:"entry"`
+	FilePatterns  []string `json:"filePatterns"`
+}
+
+// Normalize validates mandatory fields and normalizes values.
+func (m *Manifest) Normalize() error {
+	if m.SchemaVersion == 0 {
+		return fmt.Errorf("manifest schemaVersion is required")
+	}
+	if m.SchemaVersion != SupportedManifestVersion {
+		return fmt.Errorf("manifest schemaVersion %d is not supported", m.SchemaVersion)
+	}
+	m.ID = strings.TrimSpace(strings.ToLower(m.ID))
+	if !identifierRegexp.MatchString(m.ID) {
+		return fmt.Errorf("manifest id %q is invalid; only lowercase letters, numbers, dash, underscore and dot are allowed", m.ID)
+	}
+	m.Name = strings.TrimSpace(m.Name)
+	if m.Name == "" {
+		return fmt.Errorf("manifest name is required")
+	}
+	m.Version = strings.TrimSpace(m.Version)
+	if m.Version == "" {
+		return fmt.Errorf("manifest version is required")
+	}
+	if m.Entry == "" {
+		m.Entry = "render.js"
+	}
+	m.Entry = util.PathJoinRelX(m.Entry)
+	if m.Entry == "" || strings.HasPrefix(m.Entry, "../") {
+		return fmt.Errorf("manifest entry %q is invalid", m.Entry)
+	}
+	cleanPatterns := make([]string, 0, len(m.FilePatterns))
+	for _, pattern := range m.FilePatterns {
+		pattern = strings.TrimSpace(pattern)
+		if pattern == "" {
+			continue
+		}
+		cleanPatterns = append(cleanPatterns, pattern)
+	}
+	if len(cleanPatterns) == 0 {
+		return fmt.Errorf("manifest must declare at least one file pattern")
+	}
+	sort.Strings(cleanPatterns)
+	m.FilePatterns = cleanPatterns
+	return nil
+}
+
+// LoadManifest reads and validates the manifest.json file located under dir.
+func LoadManifest(dir string) (*Manifest, error) {
+	manifestPath := filepath.Join(dir, "manifest.json")
+	f, err := os.Open(manifestPath)
+	if err != nil {
+		return nil, err
+	}
+	defer f.Close()
+	var manifest Manifest
+	if err := json.NewDecoder(f).Decode(&manifest); err != nil {
+		return nil, fmt.Errorf("malformed manifest.json: %w", err)
+	}
+	if err := manifest.Normalize(); err != nil {
+		return nil, err
+	}
+	return &manifest, nil
+}
+
+// Metadata is the public information exposed to the frontend for an enabled plugin.
+type Metadata struct {
+	ID           string   `json:"id"`
+	Name         string   `json:"name"`
+	Version      string   `json:"version"`
+	Description  string   `json:"description"`
+	Entry        string   `json:"entry"`
+	EntryURL     string   `json:"entryUrl"`
+	AssetsBase   string   `json:"assetsBaseUrl"`
+	FilePatterns []string `json:"filePatterns"`
+	SchemaVersion int     `json:"schemaVersion"`
+}
diff --git a/modules/renderplugin/path.go b/modules/renderplugin/path.go
new file mode 100644
index 0000000000000..600c42eaa8491
--- /dev/null
+++ b/modules/renderplugin/path.go
@@ -0,0 +1,32 @@
+// Copyright 2025 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package renderplugin
+
+import (
+	"path"
+
+	"code.gitea.io/gitea/modules/storage"
+)
+
+// Storage returns the object storage used for render plugins.
+func Storage() storage.ObjectStorage {
+	return storage.RenderPlugins
+}
+
+// ObjectPath builds a storage-relative path for a plugin asset.
+func ObjectPath(identifier string, elems ...string) string {
+	joined := path.Join(elems...)
+	if joined == "." || joined == "" {
+		return path.Join(identifier)
+	}
+	return path.Join(identifier, joined)
+}
+
+// ObjectPrefix returns the storage prefix for a plugin identifier.
+func ObjectPrefix(identifier string) string {
+	if identifier == "" {
+		return ""
+	}
+	return identifier + "/"
+}
diff --git a/modules/setting/render_plugin.go b/modules/setting/render_plugin.go
new file mode 100644
index 0000000000000..40a187e0cebbe
--- /dev/null
+++ b/modules/setting/render_plugin.go
@@ -0,0 +1,16 @@
+// Copyright 2025 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package setting
+
+type RenderPluginSetting struct {
+	Storage *Storage
+}
+
+var RenderPlugin RenderPluginSetting
+
+func loadRenderPluginFrom(rootCfg ConfigProvider) (err error) {
+	sec, _ := rootCfg.GetSection("render_plugins")
+	RenderPlugin.Storage, err = getStorage(rootCfg, "render-plugins", "", sec)
+	return err
+}
diff --git a/modules/setting/setting.go b/modules/setting/setting.go
index e14997801fed4..13c9be56c66a0 100644
--- a/modules/setting/setting.go
+++ b/modules/setting/setting.go
@@ -138,6 +138,9 @@ func loadCommonSettingsFrom(cfg ConfigProvider) error {
 	if err := loadActionsFrom(cfg); err != nil {
 		return err
 	}
+	if err := loadRenderPluginFrom(cfg); err != nil {
+		return err
+	}
 	loadUIFrom(cfg)
 	loadAdminFrom(cfg)
 	loadAPIFrom(cfg)
diff --git a/modules/storage/storage.go b/modules/storage/storage.go
index 1868817c057cf..278d8f63e47b0 100644
--- a/modules/storage/storage.go
+++ b/modules/storage/storage.go
@@ -133,6 +133,9 @@ var (
 	Actions ObjectStorage = uninitializedStorage
 	// Actions Artifacts represents actions artifacts storage
 	ActionsArtifacts ObjectStorage = uninitializedStorage
+
+	// RenderPlugins represents render plugin storage
+	RenderPlugins ObjectStorage = uninitializedStorage
 )
 
 // Init init the storage
@@ -145,6 +148,7 @@ func Init() error {
 		initRepoArchives,
 		initPackages,
 		initActions,
+		initRenderPlugins,
 	} {
 		if err := f(); err != nil {
 			return err
@@ -228,3 +232,9 @@ func initActions() (err error) {
 	ActionsArtifacts, err = NewStorage(setting.Actions.ArtifactStorage.Type, setting.Actions.ArtifactStorage)
 	return err
 }
+
+func initRenderPlugins() (err error) {
+	log.Info("Initialising Render Plugin storage with type: %s", setting.RenderPlugin.Storage.Type)
+	RenderPlugins, err = NewStorage(setting.RenderPlugin.Storage.Type, setting.RenderPlugin.Storage)
+	return err
+}
diff --git a/options/locale/locale_en-US.ini b/options/locale/locale_en-US.ini
index 981d9de2f8623..10665780c5895 100644
--- a/options/locale/locale_en-US.ini
+++ b/options/locale/locale_en-US.ini
@@ -2991,6 +2991,45 @@ users = User Accounts
 organizations = Organizations
 assets = Code Assets
 repositories = Repositories
+render_plugins = Render Plugins
+render_plugins.description = Upload, enable, or disable frontend renderers provided as plugins.
+render_plugins.upload_label = Plugin Archive (.zip)
+render_plugins.install = Install Plugin
+render_plugins.example_hint = Example source files are available in contrib/render-plugins/example (zip both files and upload the archive here).
+render_plugins.table.name = Name
+render_plugins.table.identifier = Identifier
+render_plugins.table.version = Version
+render_plugins.table.patterns = File Patterns
+render_plugins.table.status = Status
+render_plugins.table.actions = Actions
+render_plugins.empty = No render plugins are installed yet.
+render_plugins.enable = Enable
+render_plugins.disable = Disable
+render_plugins.delete = Delete
+render_plugins.delete_confirm = Delete plugin "%s"? All of its files will be removed.
+render_plugins.status.enabled = Enabled
+render_plugins.status.disabled = Disabled
+render_plugins.upload_success = Plugin "%s" installed successfully.
+render_plugins.upload_failed = Failed to install plugin: %v
+render_plugins.upload_missing = Please choose a plugin archive to upload.
+render_plugins.enabled = Plugin "%s" enabled.
+render_plugins.disabled = Plugin "%s" disabled.
+render_plugins.deleted = Plugin "%s" deleted.
+render_plugins.invalid = Unknown plugin request.
+render_plugins.upgrade = Upgrade
+render_plugins.upgrade_success = Plugin "%s" upgraded to version %s.
+render_plugins.upgrade_failed = Failed to upgrade plugin: %v
+render_plugins.back_to_list = Back to plugin list
+render_plugins.detail_title = Plugin: %s
+render_plugins.detail.description = Description
+render_plugins.detail.description_empty = No description provided.
+render_plugins.detail.format_version = Manifest format version
+render_plugins.detail.entry = Entry file
+render_plugins.detail.source = Source
+render_plugins.detail.none = Not provided
+render_plugins.detail.file_patterns_empty = No file patterns declared.
+render_plugins.detail.actions = Plugin actions
+render_plugins.detail.upgrade = Upgrade plugin
 hooks = Webhooks
 integrations = Integrations
 authentication = Authentication Sources
diff --git a/routers/web/admin/render_plugins.go b/routers/web/admin/render_plugins.go
new file mode 100644
index 0000000000000..38d209e1ae275
--- /dev/null
+++ b/routers/web/admin/render_plugins.go
@@ -0,0 +1,165 @@
+// Copyright 2025 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package admin
+
+import (
+	"fmt"
+	"net/http"
+	"strings"
+
+	render_model "code.gitea.io/gitea/models/render"
+	"code.gitea.io/gitea/modules/setting"
+	"code.gitea.io/gitea/modules/templates"
+	"code.gitea.io/gitea/services/context"
+	plugin_service "code.gitea.io/gitea/services/renderplugin"
+)
+
+const (
+	tplRenderPlugins      templates.TplName = "admin/render/plugins"
+	tplRenderPluginDetail templates.TplName = "admin/render/plugin_detail"
+)
+
+// RenderPlugins shows the plugin management page.
+func RenderPlugins(ctx *context.Context) {
+	plugs, err := render_model.ListPlugins(ctx)
+	if err != nil {
+		ctx.ServerError("ListPlugins", err)
+		return
+	}
+	ctx.Data["Title"] = ctx.Tr("admin.render_plugins")
+	ctx.Data["PageIsAdminRenderPlugins"] = true
+	ctx.Data["Plugins"] = plugs
+	ctx.HTML(http.StatusOK, tplRenderPlugins)
+}
+
+// RenderPluginDetail shows a single plugin detail page.
+func RenderPluginDetail(ctx *context.Context) {
+	plug := mustGetRenderPlugin(ctx)
+	if plug == nil {
+		return
+	}
+	ctx.Data["Title"] = ctx.Tr("admin.render_plugins.detail_title", plug.Name)
+	ctx.Data["PageIsAdminRenderPlugins"] = true
+	ctx.Data["Plugin"] = plug
+	ctx.HTML(http.StatusOK, tplRenderPluginDetail)
+}
+
+// RenderPluginsUpload handles plugin uploads.
+func RenderPluginsUpload(ctx *context.Context) {
+	file, header, err := ctx.Req.FormFile("plugin")
+	if err != nil {
+		ctx.Flash.Error(ctx.Tr("admin.render_plugins.upload_failed", err))
+		redirectRenderPlugins(ctx)
+		return
+	}
+	defer file.Close()
+	if header.Size == 0 {
+		ctx.Flash.Error(ctx.Tr("admin.render_plugins.upload_missing"))
+		redirectRenderPlugins(ctx)
+		return
+	}
+	if _, err := plugin_service.InstallFromArchive(ctx, file, header.Filename, ""); err != nil {
+		ctx.Flash.Error(ctx.Tr("admin.render_plugins.upload_failed", err))
+	} else {
+		ctx.Flash.Success(ctx.Tr("admin.render_plugins.upload_success", header.Filename))
+	}
+	redirectRenderPlugins(ctx)
+}
+
+// RenderPluginsEnable toggles plugin state to enabled.
+func RenderPluginsEnable(ctx *context.Context) {
+	plug := mustGetRenderPlugin(ctx)
+	if plug == nil {
+		return
+	}
+	if err := plugin_service.SetEnabled(ctx, plug, true); err != nil {
+		ctx.Flash.Error(err.Error())
+	} else {
+		ctx.Flash.Success(ctx.Tr("admin.render_plugins.enabled", plug.Name))
+	}
+	redirectRenderPlugins(ctx)
+}
+
+// RenderPluginsDisable toggles plugin state to disabled.
+func RenderPluginsDisable(ctx *context.Context) {
+	plug := mustGetRenderPlugin(ctx)
+	if plug == nil {
+		return
+	}
+	if err := plugin_service.SetEnabled(ctx, plug, false); err != nil {
+		ctx.Flash.Error(err.Error())
+	} else {
+		ctx.Flash.Success(ctx.Tr("admin.render_plugins.disabled", plug.Name))
+	}
+	redirectRenderPlugins(ctx)
+}
+
+// RenderPluginsDelete removes a plugin entirely.
+func RenderPluginsDelete(ctx *context.Context) {
+	plug := mustGetRenderPlugin(ctx)
+	if plug == nil {
+		return
+	}
+	if err := plugin_service.Delete(ctx, plug); err != nil {
+		ctx.Flash.Error(err.Error())
+	} else {
+		ctx.Flash.Success(ctx.Tr("admin.render_plugins.deleted", plug.Name))
+	}
+	redirectRenderPlugins(ctx)
+}
+
+// RenderPluginsUpgrade upgrades an existing plugin with a new archive.
+func RenderPluginsUpgrade(ctx *context.Context) {
+	plug := mustGetRenderPlugin(ctx)
+	if plug == nil {
+		return
+	}
+	file, header, err := ctx.Req.FormFile("plugin")
+	if err != nil {
+		ctx.Flash.Error(ctx.Tr("admin.render_plugins.upgrade_failed", err))
+		redirectRenderPlugins(ctx)
+		return
+	}
+	defer file.Close()
+	if header.Size == 0 {
+		ctx.Flash.Error(ctx.Tr("admin.render_plugins.upload_missing"))
+		redirectRenderPlugins(ctx)
+		return
+	}
+	updated, err := plugin_service.InstallFromArchive(ctx, file, header.Filename, plug.Identifier)
+	if err != nil {
+		ctx.Flash.Error(ctx.Tr("admin.render_plugins.upgrade_failed", err))
+	} else {
+		ctx.Flash.Success(ctx.Tr("admin.render_plugins.upgrade_success", updated.Name, updated.Version))
+	}
+	redirectRenderPlugins(ctx)
+}
+
+func mustGetRenderPlugin(ctx *context.Context) *render_model.Plugin {
+	id := ctx.PathParamInt64("id")
+	if id <= 0 {
+		ctx.Flash.Error(ctx.Tr("admin.render_plugins.invalid"))
+		redirectRenderPlugins(ctx)
+		return nil
+	}
+	plug, err := render_model.GetPluginByID(ctx, id)
+	if err != nil {
+		ctx.Flash.Error(fmt.Sprintf("%v", err))
+		redirectRenderPlugins(ctx)
+		return nil
+	}
+	return plug
+}
+
+func redirectRenderPlugins(ctx *context.Context) {
+	redirectTo := ctx.FormString("redirect_to")
+	if redirectTo != "" {
+		base := setting.AppSubURL + "/"
+		if strings.HasPrefix(redirectTo, base) {
+			ctx.Redirect(redirectTo)
+			return
+		}
+	}
+	ctx.Redirect(setting.AppSubURL + "/-/admin/render-plugins")
+}
diff --git a/routers/web/renderplugin/assets.go b/routers/web/renderplugin/assets.go
new file mode 100644
index 0000000000000..6dbeb2a4a96f8
--- /dev/null
+++ b/routers/web/renderplugin/assets.go
@@ -0,0 +1,93 @@
+// Copyright 2025 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package renderplugin
+
+import (
+	"encoding/json"
+	"net/http"
+	"os"
+	"path"
+	"strings"
+
+	"code.gitea.io/gitea/modules/log"
+	"code.gitea.io/gitea/modules/renderplugin"
+	"code.gitea.io/gitea/modules/setting"
+	plugin_service "code.gitea.io/gitea/services/renderplugin"
+)
+
+// AssetsHandler returns an http.Handler that serves plugin metadata and static files.
+func AssetsHandler() http.Handler {
+	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		if r.Method != http.MethodGet && r.Method != http.MethodHead {
+			w.WriteHeader(http.StatusMethodNotAllowed)
+			return
+		}
+		prefix := setting.AppSubURL + "/assets/render-plugins/"
+		if !strings.HasPrefix(r.URL.Path, prefix) {
+			w.WriteHeader(http.StatusNotFound)
+			return
+		}
+		rel := strings.TrimPrefix(r.URL.Path, prefix)
+		rel = strings.TrimLeft(rel, "/")
+		if rel == "" {
+			w.WriteHeader(http.StatusNotFound)
+			return
+		}
+		if rel == "index.json" {
+			serveMetadata(w, r)
+			return
+		}
+		parts := strings.SplitN(rel, "/", 2)
+		if len(parts) != 2 {
+			w.WriteHeader(http.StatusNotFound)
+			return
+		}
+		clean := path.Clean("/" + parts[1])
+		if clean == "/" {
+			w.WriteHeader(http.StatusNotFound)
+			return
+		}
+		clean = strings.TrimPrefix(clean, "/")
+		if strings.HasPrefix(clean, "../") {
+			w.WriteHeader(http.StatusNotFound)
+			return
+		}
+		objectPath := renderplugin.ObjectPath(parts[0], clean)
+		obj, err := renderplugin.Storage().Open(objectPath)
+		if err != nil {
+			if os.IsNotExist(err) {
+				w.WriteHeader(http.StatusNotFound)
+			} else {
+				log.Error("Unable to open render plugin asset %s: %v", objectPath, err)
+				w.WriteHeader(http.StatusInternalServerError)
+			}
+			return
+		}
+		defer obj.Close()
+		info, err := obj.Stat()
+		if err != nil {
+			log.Error("Unable to stat render plugin asset %s: %v", objectPath, err)
+			w.WriteHeader(http.StatusInternalServerError)
+			return
+		}
+		http.ServeContent(w, r, path.Base(clean), info.ModTime(), obj)
+	})
+}
+
+func serveMetadata(w http.ResponseWriter, r *http.Request) {
+	meta, err := plugin_service.BuildMetadata(r.Context())
+	if err != nil {
+		log.Error("Unable to build render plugin metadata: %v", err)
+		w.WriteHeader(http.StatusInternalServerError)
+		return
+	}
+	w.Header().Set("Content-Type", "application/json")
+	if r.Method == http.MethodHead {
+		w.WriteHeader(http.StatusOK)
+		return
+	}
+	if err := json.NewEncoder(w).Encode(meta); err != nil {
+		log.Error("Failed to encode render plugin metadata: %v", err)
+	}
+}
diff --git a/routers/web/repo/view_file.go b/routers/web/repo/view_file.go
index 167cd5f927f38..ed879d60ed475 100644
--- a/routers/web/repo/view_file.go
+++ b/routers/web/repo/view_file.go
@@ -5,6 +5,7 @@ package repo
 
 import (
 	"bytes"
+	"encoding/base64"
 	"fmt"
 	"image"
 	"io"
@@ -228,6 +229,14 @@ func prepareFileView(ctx *context.Context, entry *git.TreeEntry) {
 	ctx.Data["IsRepresentableAsText"] = fInfo.st.IsRepresentableAsText()
 	ctx.Data["IsExecutable"] = entry.IsExecutable()
 	ctx.Data["CanCopyContent"] = fInfo.st.IsRepresentableAsText() || fInfo.st.IsImage()
+	ctx.Data["RenderFileMimeType"] = fInfo.st.GetMimeType()
+	if len(buf) > 0 {
+		chunk := buf
+		if len(chunk) > typesniffer.SniffContentSize {
+			chunk = chunk[:typesniffer.SniffContentSize]
+		}
+		ctx.Data["RenderFileHeadChunk"] = base64.StdEncoding.EncodeToString(chunk)
+	}
 
 	attrs, ok := prepareFileViewLfsAttrs(ctx)
 	if !ok {
diff --git a/routers/web/web.go b/routers/web/web.go
index 89a570dce0773..0b44e45e1956d 100644
--- a/routers/web/web.go
+++ b/routers/web/web.go
@@ -34,6 +34,7 @@ import (
 	"code.gitea.io/gitea/routers/web/misc"
 	"code.gitea.io/gitea/routers/web/org"
 	org_setting "code.gitea.io/gitea/routers/web/org/setting"
+	"code.gitea.io/gitea/routers/web/renderplugin"
 	"code.gitea.io/gitea/routers/web/repo"
 	"code.gitea.io/gitea/routers/web/repo/actions"
 	repo_setting "code.gitea.io/gitea/routers/web/repo/setting"
@@ -232,6 +233,7 @@ func Routes() *web.Router {
 	routes := web.NewRouter()
 
 	routes.Head("/", misc.DummyOK) // for health check - doesn't need to be passed through gzip handler
+	routes.Methods("GET, HEAD, OPTIONS", "/assets/render-plugins/*", optionsCorsHandler(), renderplugin.AssetsHandler())
 	routes.Methods("GET, HEAD, OPTIONS", "/assets/*", optionsCorsHandler(), public.FileHandlerFunc())
 	routes.Methods("GET, HEAD", "/avatars/*", avatarStorageHandler(setting.Avatar.Storage, "avatars", storage.Avatars))
 	routes.Methods("GET, HEAD", "/repo-avatars/*", avatarStorageHandler(setting.RepoAvatar.Storage, "repo-avatars", storage.RepoAvatars))
@@ -772,6 +774,16 @@ func registerWebRoutes(m *web.Router) {
 			m.Post("/cleanup", admin.CleanupExpiredData)
 		}, packagesEnabled)
 
+		m.Group("/render-plugins", func() {
+			m.Get("", admin.RenderPlugins)
+			m.Get("/{id}", admin.RenderPluginDetail)
+			m.Post("/upload", admin.RenderPluginsUpload)
+			m.Post("/{id}/enable", admin.RenderPluginsEnable)
+			m.Post("/{id}/disable", admin.RenderPluginsDisable)
+			m.Post("/{id}/delete", admin.RenderPluginsDelete)
+			m.Post("/{id}/upgrade", admin.RenderPluginsUpgrade)
+		})
+
 		m.Group("/hooks", func() {
 			m.Get("", admin.DefaultOrSystemWebhooks)
 			m.Post("/delete", admin.DeleteDefaultOrSystemWebhook)
diff --git a/services/renderplugin/service.go b/services/renderplugin/service.go
new file mode 100644
index 0000000000000..39b334e5fc354
--- /dev/null
+++ b/services/renderplugin/service.go
@@ -0,0 +1,283 @@
+// Copyright 2025 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package renderplugin
+
+import (
+	"archive/zip"
+	"context"
+	"errors"
+	"fmt"
+	"io"
+	"io/fs"
+	"os"
+	"path/filepath"
+	"strings"
+
+	render_model "code.gitea.io/gitea/models/render"
+	"code.gitea.io/gitea/modules/log"
+	"code.gitea.io/gitea/modules/renderplugin"
+	"code.gitea.io/gitea/modules/setting"
+	"code.gitea.io/gitea/modules/storage"
+	"code.gitea.io/gitea/modules/util"
+)
+
+var errManifestNotFound = errors.New("manifest.json not found in plugin archive")
+
+// InstallFromArchive installs or upgrades a plugin from an uploaded ZIP archive.
+// If expectedIdentifier is non-empty the archive must contain the matching plugin id.
+func InstallFromArchive(ctx context.Context, upload io.Reader, filename, expectedIdentifier string) (*render_model.Plugin, error) {
+	tmpFile, cleanupFile, err := setting.AppDataTempDir("render-plugins").CreateTempFileRandom("upload", "*.zip")
+	if err != nil {
+		return nil, err
+	}
+	defer cleanupFile()
+	if _, err := io.Copy(tmpFile, upload); err != nil {
+		return nil, err
+	}
+	if err := tmpFile.Close(); err != nil {
+		return nil, err
+	}
+
+	pluginDir, manifest, cleanupDir, err := extractArchive(tmpFile.Name())
+	if err != nil {
+		return nil, err
+	}
+	defer cleanupDir()
+	if expectedIdentifier != "" && manifest.ID != expectedIdentifier {
+		return nil, fmt.Errorf("uploaded plugin id %s does not match %s", manifest.ID, expectedIdentifier)
+	}
+
+	entryPath := filepath.Join(pluginDir, filepath.FromSlash(manifest.Entry))
+	if ok, _ := util.IsExist(entryPath); !ok {
+		return nil, fmt.Errorf("plugin entry %s not found", manifest.Entry)
+	}
+	if err := replacePluginFiles(manifest.ID, pluginDir); err != nil {
+		return nil, err
+	}
+
+	plug := &render_model.Plugin{
+		Identifier:    manifest.ID,
+		Name:          manifest.Name,
+		Version:       manifest.Version,
+		Description:   manifest.Description,
+		Source:        strings.TrimSpace(filename),
+		Entry:         manifest.Entry,
+		FilePatterns:  manifest.FilePatterns,
+		FormatVersion: manifest.SchemaVersion,
+	}
+	if err := render_model.UpsertPlugin(ctx, plug); err != nil {
+		return nil, err
+	}
+	return plug, nil
+}
+
+// Delete removes a plugin from disk and database.
+func Delete(ctx context.Context, plug *render_model.Plugin) error {
+	if err := deletePluginFiles(plug.Identifier); err != nil {
+		return err
+	}
+	return render_model.DeletePlugin(ctx, plug)
+}
+
+// SetEnabled toggles plugin availability after verifying assets exist when enabling.
+func SetEnabled(ctx context.Context, plug *render_model.Plugin, enabled bool) error {
+	if enabled {
+		if err := ensureEntryExists(plug); err != nil {
+			return err
+		}
+	}
+	return render_model.SetPluginEnabled(ctx, plug, enabled)
+}
+
+// BuildMetadata returns metadata for all enabled plugins.
+func BuildMetadata(ctx context.Context) ([]renderplugin.Metadata, error) {
+	plugs, err := render_model.ListEnabledPlugins(ctx)
+	if err != nil {
+		return nil, err
+	}
+	base := setting.AppSubURL + "/assets/render-plugins/"
+	metas := make([]renderplugin.Metadata, 0, len(plugs))
+	for _, plug := range plugs {
+		if plug.FormatVersion != renderplugin.SupportedManifestVersion {
+			log.Warn("Render plugin %s disabled due to incompatible schema version %d", plug.Identifier, plug.FormatVersion)
+			continue
+		}
+		if err := ensureEntryExists(plug); err != nil {
+			log.Error("Render plugin %s entry missing: %v", plug.Identifier, err)
+			continue
+		}
+		assetsBase := base + plug.Identifier + "/"
+		metas = append(metas, renderplugin.Metadata{
+			ID:           plug.Identifier,
+			Name:         plug.Name,
+			Version:      plug.Version,
+			Description:  plug.Description,
+			Entry:        plug.Entry,
+			EntryURL:     assetsBase + plug.Entry,
+			AssetsBase:   assetsBase,
+			FilePatterns: append([]string(nil), plug.FilePatterns...),
+			SchemaVersion: plug.FormatVersion,
+		})
+	}
+	return metas, nil
+}
+
+func ensureEntryExists(plug *render_model.Plugin) error {
+	entryPath := renderplugin.ObjectPath(plug.Identifier, filepath.ToSlash(plug.Entry))
+	if _, err := renderplugin.Storage().Stat(entryPath); err != nil {
+		return fmt.Errorf("plugin entry %s missing: %w", plug.Entry, err)
+	}
+	return nil
+}
+
+func extractArchive(zipPath string) (string, *renderplugin.Manifest, func(), error) {
+	reader, err := zip.OpenReader(zipPath)
+	if err != nil {
+		return "", nil, nil, err
+	}
+
+	extractDir, cleanup, err := setting.AppDataTempDir("render-plugins").MkdirTempRandom("extract", "*")
+	if err != nil {
+		_ = reader.Close()
+		return "", nil, nil, err
+	}
+
+	closeAll := func() {
+		_ = reader.Close()
+		cleanup()
+	}
+
+	for _, file := range reader.File {
+		if err := extractZipEntry(file, extractDir); err != nil {
+			closeAll()
+			return "", nil, nil, err
+		}
+	}
+
+	manifestPath, err := findManifest(extractDir)
+	if err != nil {
+		closeAll()
+		return "", nil, nil, err
+	}
+	manifestDir := filepath.Dir(manifestPath)
+	manifest, err := renderplugin.LoadManifest(manifestDir)
+	if err != nil {
+		closeAll()
+		return "", nil, nil, err
+	}
+
+	return manifestDir, manifest, closeAll, nil
+}
+
+func extractZipEntry(file *zip.File, dest string) error {
+	cleanRel := util.PathJoinRelX(file.Name)
+	if cleanRel == "" || cleanRel == "." {
+		return nil
+	}
+	target := filepath.Join(dest, filepath.FromSlash(cleanRel))
+	rel, err := filepath.Rel(dest, target)
+	if err != nil || strings.HasPrefix(rel, "..") {
+		return fmt.Errorf("archive path %q escapes extraction directory", file.Name)
+	}
+	if file.FileInfo().IsDir() {
+		return os.MkdirAll(target, os.ModePerm)
+	}
+	if file.FileInfo().Mode()&os.ModeSymlink != 0 {
+		return fmt.Errorf("symlinks are not supported inside plugin archives: %s", file.Name)
+	}
+	if err := os.MkdirAll(filepath.Dir(target), os.ModePerm); err != nil {
+		return err
+	}
+	rc, err := file.Open()
+	if err != nil {
+		return err
+	}
+	defer rc.Close()
+	out, err := os.OpenFile(target, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, file.Mode().Perm())
+	if err != nil {
+		return err
+	}
+	defer out.Close()
+	if _, err := io.Copy(out, rc); err != nil {
+		return err
+	}
+	return nil
+}
+
+func findManifest(root string) (string, error) {
+	var manifestPath string
+	err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
+		if err != nil {
+			return err
+		}
+		if d.IsDir() {
+			return nil
+		}
+		if strings.EqualFold(d.Name(), "manifest.json") {
+			if manifestPath != "" {
+				return fmt.Errorf("multiple manifest.json files found")
+			}
+			manifestPath = path
+		}
+		return nil
+	})
+	if err != nil {
+		return "", err
+	}
+	if manifestPath == "" {
+		return "", errManifestNotFound
+	}
+	return manifestPath, nil
+}
+
+func replacePluginFiles(identifier, srcDir string) error {
+	if err := deletePluginFiles(identifier); err != nil {
+		return err
+	}
+	return uploadPluginDir(identifier, srcDir)
+}
+
+func deletePluginFiles(identifier string) error {
+	store := renderplugin.Storage()
+	prefix := renderplugin.ObjectPrefix(identifier)
+	return store.IterateObjects(prefix, func(path string, obj storage.Object) error {
+		_ = obj.Close()
+		return store.Delete(path)
+	})
+}
+
+func uploadPluginDir(identifier, src string) error {
+	store := renderplugin.Storage()
+	return filepath.WalkDir(src, func(path string, d fs.DirEntry, err error) error {
+		if err != nil {
+			return err
+		}
+		if d.IsDir() {
+			return nil
+		}
+		if d.Type()&os.ModeSymlink != 0 {
+			return fmt.Errorf("symlinks are not supported inside plugin archives")
+		}
+		rel, err := filepath.Rel(src, path)
+		if err != nil {
+			return err
+		}
+		file, err := os.Open(path)
+		if err != nil {
+			return err
+		}
+		info, err := file.Stat()
+		if err != nil {
+			file.Close()
+			return err
+		}
+		objectPath := renderplugin.ObjectPath(identifier, filepath.ToSlash(rel))
+		_, err = store.Save(objectPath, file, info.Size())
+		closeErr := file.Close()
+		if err != nil {
+			return err
+		}
+		return closeErr
+	})
+}
diff --git a/templates/admin/navbar.tmpl b/templates/admin/navbar.tmpl
index 72584ec799cc3..03c46feba997d 100644
--- a/templates/admin/navbar.tmpl
+++ b/templates/admin/navbar.tmpl
@@ -38,6 +38,9 @@
 						{{ctx.Locale.Tr "packages.title"}}
 					
 				{{end}}
+				
+					{{ctx.Locale.Tr "admin.render_plugins"}}
+				
 				
 					{{ctx.Locale.Tr "admin.repositories"}}
 				
diff --git a/templates/admin/render/plugin_detail.tmpl b/templates/admin/render/plugin_detail.tmpl
new file mode 100644
index 0000000000000..a328a4273289d
--- /dev/null
+++ b/templates/admin/render/plugin_detail.tmpl
@@ -0,0 +1,112 @@
+{{template "admin/layout_head" (dict "ctxData" . "pageClass" "admin render-plugin-detail")}}
+	
+

+ {{.Plugin.Name}} + +
{{ctx.Locale.Tr "admin.render_plugins.detail_title" .Plugin.Name}}
+

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
{{ctx.Locale.Tr "admin.render_plugins.detail.format_version"}}{{.Plugin.FormatVersion}}
{{ctx.Locale.Tr "admin.render_plugins.table.version"}}{{.Plugin.Version}}
{{ctx.Locale.Tr "admin.render_plugins.table.identifier"}}{{.Plugin.Identifier}}
{{ctx.Locale.Tr "admin.render_plugins.table.status"}} + {{if .Plugin.Enabled}} + {{ctx.Locale.Tr "admin.render_plugins.status.enabled"}} + {{else}} + {{ctx.Locale.Tr "admin.render_plugins.status.disabled"}} + {{end}} +
{{ctx.Locale.Tr "admin.render_plugins.detail.description"}} + {{if .Plugin.Description}} +
{{.Plugin.Description}}
+ {{else}} + {{ctx.Locale.Tr "admin.render_plugins.detail.description_empty"}} + {{end}} +
{{ctx.Locale.Tr "admin.render_plugins.detail.entry"}}{{.Plugin.Entry}}
{{ctx.Locale.Tr "admin.render_plugins.detail.source"}} + {{if .Plugin.Source}} + {{.Plugin.Source}} + {{else}} + {{ctx.Locale.Tr "admin.render_plugins.detail.none"}} + {{end}} +
{{ctx.Locale.Tr "admin.render_plugins.table.patterns"}} + {{if .Plugin.FilePatterns}} + {{range $i, $pattern := .Plugin.FilePatterns}}{{if $i}}, {{end}}{{$pattern}}{{end}} + {{else}} + {{ctx.Locale.Tr "admin.render_plugins.detail.file_patterns_empty"}} + {{end}} +
+
+

+ {{ctx.Locale.Tr "admin.render_plugins.detail.actions"}} +

+
+
+ {{if .Plugin.Enabled}} +
+ {{.CsrfTokenHtml}} + + +
+ {{else}} +
+ {{.CsrfTokenHtml}} + + +
+ {{end}} +
+ {{.CsrfTokenHtml}} + +
+
+
+

+ {{ctx.Locale.Tr "admin.render_plugins.detail.upgrade"}} +

+
+
+ {{.CsrfTokenHtml}} + +
+ + +
+ +
+
+
+ +{{template "admin/layout_footer" .}} diff --git a/templates/admin/render/plugins.tmpl b/templates/admin/render/plugins.tmpl new file mode 100644 index 0000000000000..49c6bfc67dcb5 --- /dev/null +++ b/templates/admin/render/plugins.tmpl @@ -0,0 +1,65 @@ +{{template "admin/layout_head" (dict "ctxData" . "pageClass" "admin render-plugins")}} +
+

+ {{ctx.Locale.Tr "admin.render_plugins"}} +
{{ctx.Locale.Tr "admin.render_plugins.description"}}
+

+
+
+ {{.CsrfTokenHtml}} +
+ + +
+ +
+
+ {{ctx.Locale.Tr "admin.render_plugins.example_hint"}} +
+
+
+ + + + + + + + + + + + + {{range .Plugins}} + + + + + + + + + {{else}} + + + + {{end}} + +
{{ctx.Locale.Tr "admin.render_plugins.table.name"}}{{ctx.Locale.Tr "admin.render_plugins.table.identifier"}}{{ctx.Locale.Tr "admin.render_plugins.table.version"}}{{ctx.Locale.Tr "admin.render_plugins.table.patterns"}}{{ctx.Locale.Tr "admin.render_plugins.table.status"}}{{ctx.Locale.Tr "admin.render_plugins.table.actions"}}
+
{{.Name}}
+
{{.Description}}
+
{{.Identifier}}{{.Version}} + {{range $i, $pattern := .FilePatterns}}{{if $i}}, {{end}}{{$pattern}}{{end}} + + {{if .Enabled}} + {{ctx.Locale.Tr "admin.render_plugins.status.enabled"}} + {{else}} + {{ctx.Locale.Tr "admin.render_plugins.status.disabled"}} + {{end}} + + {{ctx.Locale.Tr "view"}} +
{{ctx.Locale.Tr "admin.render_plugins.empty"}}
+
+
+ +{{template "admin/layout_footer" .}} diff --git a/templates/repo/view_file.tmpl b/templates/repo/view_file.tmpl index 8fce1b6f2c8fc..0fbd4aaa99c97 100644 --- a/templates/repo/view_file.tmpl +++ b/templates/repo/view_file.tmpl @@ -1,5 +1,6 @@
+ data-global-init="initRepoFileView" data-raw-file-link="{{.RawFileLink}}" + data-mime-type="{{.RenderFileMimeType}}"{{if .RenderFileHeadChunk}} data-head-chunk="{{.RenderFileHeadChunk}}"{{end}}> {{- if .FileError}}
diff --git a/web_src/js/features/file-view.ts b/web_src/js/features/file-view.ts index ff9e8cfa26384..3f76ed151905a 100644 --- a/web_src/js/features/file-view.ts +++ b/web_src/js/features/file-view.ts @@ -1,20 +1,48 @@ import type {FileRenderPlugin} from '../render/plugin.ts'; import {newRenderPlugin3DViewer} from '../render/plugins/3d-viewer.ts'; import {newRenderPluginPdfViewer} from '../render/plugins/pdf-viewer.ts'; +import {loadDynamicRenderPlugins} from '../render/plugins/dynamic-plugin.ts'; import {registerGlobalInitFunc} from '../modules/observer.ts'; import {createElementFromHTML, showElem, toggleElemClass} from '../utils/dom.ts'; import {html} from '../utils/html.ts'; import {basename} from '../utils.ts'; const plugins: FileRenderPlugin[] = []; +let pluginsInitialized = false; +let pluginsInitPromise: Promise | null = null; -function initPluginsOnce(): void { - if (plugins.length) return; - plugins.push(newRenderPlugin3DViewer(), newRenderPluginPdfViewer()); +function decodeHeadChunk(value: string | null): Uint8Array | null { + if (!value) return null; + try { + const binary = window.atob(value); + const buffer = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) { + buffer[i] = binary.charCodeAt(i); + } + return buffer; + } catch (err) { + console.error('Failed to decode render plugin head chunk', err); + return null; + } +} + +async function initPluginsOnce(): Promise { + if (pluginsInitialized) return; + if (!pluginsInitPromise) { + pluginsInitPromise = (async () => { + if (!pluginsInitialized) { + plugins.push(newRenderPlugin3DViewer(), newRenderPluginPdfViewer()); + const dynamicPlugins = await loadDynamicRenderPlugins(); + plugins.push(...dynamicPlugins); + pluginsInitialized = true; + } + })(); + } + await pluginsInitPromise; } -function findFileRenderPlugin(filename: string, mimeType: string): FileRenderPlugin | null { - return plugins.find((plugin) => plugin.canHandle(filename, mimeType)) || null; +function findFileRenderPlugin(filename: string, mimeType: string, headChunk: Uint8Array | null): FileRenderPlugin | null { + return plugins.find((plugin) => plugin.canHandle(filename, mimeType, headChunk)) || null; } function showRenderRawFileButton(elFileView: HTMLElement, renderContainer: HTMLElement | null): void { @@ -26,17 +54,17 @@ function showRenderRawFileButton(elFileView: HTMLElement, renderContainer: HTMLE // TODO: if there is only one button, hide it? } -async function renderRawFileToContainer(container: HTMLElement, rawFileLink: string, mimeType: string) { +async function renderRawFileToContainer(container: HTMLElement, rawFileLink: string, mimeType: string, headChunk: Uint8Array | null) { const elViewRawPrompt = container.querySelector('.file-view-raw-prompt'); if (!rawFileLink || !elViewRawPrompt) throw new Error('unexpected file view container'); let rendered = false, errorMsg = ''; try { - const plugin = findFileRenderPlugin(basename(rawFileLink), mimeType); + const plugin = findFileRenderPlugin(basename(rawFileLink), mimeType, headChunk); if (plugin) { container.classList.add('is-loading'); container.setAttribute('data-render-name', plugin.name); // not used yet - await plugin.render(container, rawFileLink); + await plugin.render(container, rawFileLink, {mimeType, headChunk}); rendered = true; } } catch (e) { @@ -61,16 +89,16 @@ async function renderRawFileToContainer(container: HTMLElement, rawFileLink: str export function initRepoFileView(): void { registerGlobalInitFunc('initRepoFileView', async (elFileView: HTMLElement) => { - initPluginsOnce(); + await initPluginsOnce(); const rawFileLink = elFileView.getAttribute('data-raw-file-link')!; - const mimeType = elFileView.getAttribute('data-mime-type') || ''; // not used yet - // TODO: we should also provide the prefetched file head bytes to let the plugin decide whether to render or not - const plugin = findFileRenderPlugin(basename(rawFileLink), mimeType); + const mimeType = elFileView.getAttribute('data-mime-type') || ''; + const headChunk = decodeHeadChunk(elFileView.getAttribute('data-head-chunk')); + const plugin = findFileRenderPlugin(basename(rawFileLink), mimeType, headChunk); if (!plugin) return; const renderContainer = elFileView.querySelector('.file-view-render-container'); showRenderRawFileButton(elFileView, renderContainer); // maybe in the future multiple plugins can render the same file, so we should not assume only one plugin will render it - if (renderContainer) await renderRawFileToContainer(renderContainer, rawFileLink, mimeType); + if (renderContainer) await renderRawFileToContainer(renderContainer, rawFileLink, mimeType, headChunk); }); } diff --git a/web_src/js/render/plugin.ts b/web_src/js/render/plugin.ts index 234be4118f40b..0ae487432f15c 100644 --- a/web_src/js/render/plugin.ts +++ b/web_src/js/render/plugin.ts @@ -1,10 +1,19 @@ +export type FileRenderOptions = { + /** MIME type reported by the backend (may be empty). */ + mimeType?: string; + /** First bytes of the file as raw bytes (<= 1 KiB). */ + headChunk?: Uint8Array | null; + /** Additional plugin-specific options. */ + [key: string]: any; +}; + export type FileRenderPlugin = { // unique plugin name name: string; // test if plugin can handle a specified file - canHandle: (filename: string, mimeType: string) => boolean; + canHandle: (filename: string, mimeType: string, headChunk?: Uint8Array | null) => boolean; // render file content - render: (container: HTMLElement, fileUrl: string, options?: any) => Promise; + render: (container: HTMLElement, fileUrl: string, options?: FileRenderOptions) => Promise; }; diff --git a/web_src/js/render/plugins/3d-viewer.ts b/web_src/js/render/plugins/3d-viewer.ts index 6f3ee15d2653e..20b1d19b7f5a5 100644 --- a/web_src/js/render/plugins/3d-viewer.ts +++ b/web_src/js/render/plugins/3d-viewer.ts @@ -40,7 +40,7 @@ export function newRenderPlugin3DViewer(): FileRenderPlugin { return { name: '3d-model-viewer', - canHandle(filename: string, _mimeType: string): boolean { + canHandle(filename: string, _mimeType: string, _headChunk?: Uint8Array | null): boolean { const ext = extname(filename).toLowerCase(); return SUPPORTED_EXTENSIONS.includes(ext); }, diff --git a/web_src/js/render/plugins/dynamic-plugin.ts b/web_src/js/render/plugins/dynamic-plugin.ts new file mode 100644 index 0000000000000..72a1933e23160 --- /dev/null +++ b/web_src/js/render/plugins/dynamic-plugin.ts @@ -0,0 +1,96 @@ +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[]; +}; + +type RemotePluginModule = { + render: (container: HTMLElement, fileUrl: string, options?: any) => void | Promise; +}; + +const moduleCache = new Map>(); +const SUPPORTED_SCHEMA_VERSION = 1; + +async function fetchRemoteMetadata(): Promise { + 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; +} + +async function loadRemoteModule(meta: RemotePluginMeta): Promise { + 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[]; + 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 remote = await loadRemoteModule(meta); + await remote.render(container, fileUrl, options); + }, + }; +} + +export async function loadDynamicRenderPlugins(): Promise { + 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 []; + } +} diff --git a/web_src/js/render/plugins/pdf-viewer.ts b/web_src/js/render/plugins/pdf-viewer.ts index 40623be05576f..d97d46a1a9a3a 100644 --- a/web_src/js/render/plugins/pdf-viewer.ts +++ b/web_src/js/render/plugins/pdf-viewer.ts @@ -4,7 +4,7 @@ export function newRenderPluginPdfViewer(): FileRenderPlugin { return { name: 'pdf-viewer', - canHandle(filename: string, _mimeType: string): boolean { + canHandle(filename: string, _mimeType: string, _headChunk?: Uint8Array | null): boolean { return filename.toLowerCase().endsWith('.pdf'); }, From 75fd8b5b3e1bdc2ae4dda96cc8ee81b1d56870f7 Mon Sep 17 00:00:00 2001 From: Lunny Xiao Date: Fri, 5 Dec 2025 20:38:42 -0800 Subject: [PATCH 2/5] Fix lint --- .../render-plugins/example-wasm/wasm/main.go | 3 ++ modules/renderplugin/manifest.go | 29 +++++++------- routers/web/renderplugin/assets.go | 2 +- services/renderplugin/service.go | 20 +++++----- templates/admin/navbar.tmpl | 39 +++++++------------ 5 files changed, 44 insertions(+), 49 deletions(-) diff --git a/contrib/render-plugins/example-wasm/wasm/main.go b/contrib/render-plugins/example-wasm/wasm/main.go index a43eb0aa2cf7b..ef53fd9e9a4f4 100644 --- a/contrib/render-plugins/example-wasm/wasm/main.go +++ b/contrib/render-plugins/example-wasm/wasm/main.go @@ -1,3 +1,6 @@ +//go:build js && wasm +// +build js,wasm + package main import ( diff --git a/modules/renderplugin/manifest.go b/modules/renderplugin/manifest.go index 65ca6a5829055..7866fda07062b 100644 --- a/modules/renderplugin/manifest.go +++ b/modules/renderplugin/manifest.go @@ -4,7 +4,7 @@ package renderplugin import ( - "encoding/json" + "errors" "fmt" "os" "path/filepath" @@ -12,6 +12,7 @@ import ( "sort" "strings" + "code.gitea.io/gitea/modules/json" "code.gitea.io/gitea/modules/util" ) @@ -33,7 +34,7 @@ type Manifest struct { // Normalize validates mandatory fields and normalizes values. func (m *Manifest) Normalize() error { if m.SchemaVersion == 0 { - return fmt.Errorf("manifest schemaVersion is required") + return errors.New("manifest schemaVersion is required") } if m.SchemaVersion != SupportedManifestVersion { return fmt.Errorf("manifest schemaVersion %d is not supported", m.SchemaVersion) @@ -44,11 +45,11 @@ func (m *Manifest) Normalize() error { } m.Name = strings.TrimSpace(m.Name) if m.Name == "" { - return fmt.Errorf("manifest name is required") + return errors.New("manifest name is required") } m.Version = strings.TrimSpace(m.Version) if m.Version == "" { - return fmt.Errorf("manifest version is required") + return errors.New("manifest version is required") } if m.Entry == "" { m.Entry = "render.js" @@ -66,7 +67,7 @@ func (m *Manifest) Normalize() error { cleanPatterns = append(cleanPatterns, pattern) } if len(cleanPatterns) == 0 { - return fmt.Errorf("manifest must declare at least one file pattern") + return errors.New("manifest must declare at least one file pattern") } sort.Strings(cleanPatterns) m.FilePatterns = cleanPatterns @@ -93,13 +94,13 @@ func LoadManifest(dir string) (*Manifest, error) { // Metadata is the public information exposed to the frontend for an enabled plugin. type Metadata struct { - ID string `json:"id"` - Name string `json:"name"` - Version string `json:"version"` - Description string `json:"description"` - Entry string `json:"entry"` - EntryURL string `json:"entryUrl"` - AssetsBase string `json:"assetsBaseUrl"` - FilePatterns []string `json:"filePatterns"` - SchemaVersion int `json:"schemaVersion"` + ID string `json:"id"` + Name string `json:"name"` + Version string `json:"version"` + Description string `json:"description"` + Entry string `json:"entry"` + EntryURL string `json:"entryUrl"` + AssetsBase string `json:"assetsBaseUrl"` + FilePatterns []string `json:"filePatterns"` + SchemaVersion int `json:"schemaVersion"` } diff --git a/routers/web/renderplugin/assets.go b/routers/web/renderplugin/assets.go index 6dbeb2a4a96f8..2423267cf9454 100644 --- a/routers/web/renderplugin/assets.go +++ b/routers/web/renderplugin/assets.go @@ -4,12 +4,12 @@ package renderplugin import ( - "encoding/json" "net/http" "os" "path" "strings" + "code.gitea.io/gitea/modules/json" "code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/renderplugin" "code.gitea.io/gitea/modules/setting" diff --git a/services/renderplugin/service.go b/services/renderplugin/service.go index 39b334e5fc354..b797720d2877c 100644 --- a/services/renderplugin/service.go +++ b/services/renderplugin/service.go @@ -109,14 +109,14 @@ func BuildMetadata(ctx context.Context) ([]renderplugin.Metadata, error) { } assetsBase := base + plug.Identifier + "/" metas = append(metas, renderplugin.Metadata{ - ID: plug.Identifier, - Name: plug.Name, - Version: plug.Version, - Description: plug.Description, - Entry: plug.Entry, - EntryURL: assetsBase + plug.Entry, - AssetsBase: assetsBase, - FilePatterns: append([]string(nil), plug.FilePatterns...), + ID: plug.Identifier, + Name: plug.Name, + Version: plug.Version, + Description: plug.Description, + Entry: plug.Entry, + EntryURL: assetsBase + plug.Entry, + AssetsBase: assetsBase, + FilePatterns: append([]string(nil), plug.FilePatterns...), SchemaVersion: plug.FormatVersion, }) } @@ -216,7 +216,7 @@ func findManifest(root string) (string, error) { } if strings.EqualFold(d.Name(), "manifest.json") { if manifestPath != "" { - return fmt.Errorf("multiple manifest.json files found") + return errors.New("multiple manifest.json files found") } manifestPath = path } @@ -257,7 +257,7 @@ func uploadPluginDir(identifier, src string) error { return nil } if d.Type()&os.ModeSymlink != 0 { - return fmt.Errorf("symlinks are not supported inside plugin archives") + return errors.New("symlinks are not supported inside plugin archives") } rel, err := filepath.Rel(src, path) if err != nil { diff --git a/templates/admin/navbar.tmpl b/templates/admin/navbar.tmpl index 03c46feba997d..0e08633f5de16 100644 --- a/templates/admin/navbar.tmpl +++ b/templates/admin/navbar.tmpl @@ -38,39 +38,30 @@ {{ctx.Locale.Tr "packages.title"}} {{end}} - - {{ctx.Locale.Tr "admin.render_plugins"}} - {{ctx.Locale.Tr "admin.repositories"}}
- {{if and (not DisableWebhooks) .EnableOAuth2}} -
- {{ctx.Locale.Tr "admin.integrations"}} - +
{{if .EnableActions}}
{{ctx.Locale.Tr "actions.actions"}} From 89af9e2f01898aee042bee47f86e8ecb830480b4 Mon Sep 17 00:00:00 2001 From: Lunny Xiao Date: Sat, 6 Dec 2025 11:12:41 -0800 Subject: [PATCH 3/5] some improvements --- modules/renderplugin/manifest_test.go | 82 +++++++++++ services/renderplugin/service.go | 10 +- tests/integration/render_plugin_test.go | 188 ++++++++++++++++++++++++ web_src/js/features/file-view.test.ts | 26 ++++ web_src/js/features/file-view.ts | 2 +- 5 files changed, 305 insertions(+), 3 deletions(-) create mode 100644 modules/renderplugin/manifest_test.go create mode 100644 tests/integration/render_plugin_test.go create mode 100644 web_src/js/features/file-view.test.ts diff --git a/modules/renderplugin/manifest_test.go b/modules/renderplugin/manifest_test.go new file mode 100644 index 0000000000000..1168f60fc709a --- /dev/null +++ b/modules/renderplugin/manifest_test.go @@ -0,0 +1,82 @@ +package renderplugin + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestManifestNormalizeDefaults(t *testing.T) { + manifest := Manifest{ + SchemaVersion: SupportedManifestVersion, + ID: " Example.Plugin ", + Name: " Demo Plugin ", + Version: " 1.0.0 ", + Description: "test", + Entry: "", + FilePatterns: []string{" *.TXT ", "README.md", ""}, + } + + require.NoError(t, manifest.Normalize()) + assert.Equal(t, "example.plugin", manifest.ID) + assert.Equal(t, "render.js", manifest.Entry) + assert.Equal(t, []string{"*.TXT", "README.md"}, manifest.FilePatterns) +} + +func TestManifestNormalizeErrors(t *testing.T) { + base := Manifest{ + SchemaVersion: SupportedManifestVersion, + ID: "example", + Name: "demo", + Version: "1.0", + Entry: "render.js", + FilePatterns: []string{"*.md"}, + } + + tests := []struct { + name string + mutate func(m *Manifest) + message string + }{ + {"missing schema version", func(m *Manifest) { m.SchemaVersion = 0 }, "schemaVersion is required"}, + {"unsupported schema", func(m *Manifest) { m.SchemaVersion = SupportedManifestVersion + 1 }, "not supported"}, + {"invalid id", func(m *Manifest) { m.ID = "bad id" }, "manifest id"}, + {"missing name", func(m *Manifest) { m.Name = "" }, "name is required"}, + {"missing version", func(m *Manifest) { m.Version = "" }, "version is required"}, + {"no patterns", func(m *Manifest) { m.FilePatterns = nil }, "at least one file pattern"}, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + m := base + tt.mutate(&m) + err := m.Normalize() + require.Error(t, err) + assert.Contains(t, err.Error(), tt.message) + }) + } +} + +func TestLoadManifest(t *testing.T) { + dir := t.TempDir() + manifestJSON := `{ + "schemaVersion": 1, + "id": "Example", + "name": "Example", + "version": "2.0.0", + "description": "demo", + "entry": "render.js", + "filePatterns": ["*.txt", "*.md"] + }` + path := filepath.Join(dir, "manifest.json") + require.NoError(t, os.WriteFile(path, []byte(manifestJSON), 0o644)) + + manifest, err := LoadManifest(dir) + require.NoError(t, err) + assert.Equal(t, "example", manifest.ID) + assert.Equal(t, []string{"*.md", "*.txt"}, manifest.FilePatterns) +} diff --git a/services/renderplugin/service.go b/services/renderplugin/service.go index b797720d2877c..692fae4e47261 100644 --- a/services/renderplugin/service.go +++ b/services/renderplugin/service.go @@ -241,10 +241,16 @@ func replacePluginFiles(identifier, srcDir string) error { func deletePluginFiles(identifier string) error { store := renderplugin.Storage() prefix := renderplugin.ObjectPrefix(identifier) - return store.IterateObjects(prefix, func(path string, obj storage.Object) error { + if err := store.IterateObjects(prefix, func(path string, obj storage.Object) error { _ = obj.Close() return store.Delete(path) - }) + }); err != nil { + if os.IsNotExist(err) { + return nil + } + return err + } + return nil } func uploadPluginDir(identifier, src string) error { diff --git a/tests/integration/render_plugin_test.go b/tests/integration/render_plugin_test.go new file mode 100644 index 0000000000000..0268055e1fb6e --- /dev/null +++ b/tests/integration/render_plugin_test.go @@ -0,0 +1,188 @@ +// Copyright 2025 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package integration + +import ( + "archive/zip" + "bytes" + "fmt" + "mime/multipart" + "net/http" + "path" + "strconv" + "strings" + "testing" + + render_model "code.gitea.io/gitea/models/render" + "code.gitea.io/gitea/models/unittest" + "code.gitea.io/gitea/modules/json" + "code.gitea.io/gitea/modules/renderplugin" + "code.gitea.io/gitea/modules/storage" + "code.gitea.io/gitea/modules/web/middleware" + "code.gitea.io/gitea/tests" + + "github.com/PuerkitoBio/goquery" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestRenderPluginLifecycle(t *testing.T) { + defer tests.PrepareTestEnv(t)() + require.NoError(t, storage.Clean(renderplugin.Storage())) + t.Cleanup(func() { + _ = storage.Clean(renderplugin.Storage()) + }) + + const pluginID = "itest-plugin" + + session := loginUser(t, "user1") + + uploadArchive(t, session, "/-/admin/render-plugins/upload", buildRenderPluginArchive(t, pluginID, "Integration Plugin", "1.0.0")) + flash := expectFlashSuccess(t, session) + assert.Contains(t, flash.SuccessMsg, "installed") + row := requireRenderPluginRow(t, session, pluginID) + assert.Equal(t, "1.0.0", row.Version) + assert.False(t, row.Enabled) + + postPluginAction(t, session, fmt.Sprintf("/-/admin/render-plugins/%d/enable", row.ID)) + flash = expectFlashSuccess(t, session) + assert.Contains(t, flash.SuccessMsg, "enabled") + row = requireRenderPluginRow(t, session, pluginID) + assert.True(t, row.Enabled) + + metas := fetchRenderPluginMetadata(t) + require.Len(t, metas, 1) + assert.Equal(t, pluginID, metas[0].ID) + assert.Contains(t, metas[0].EntryURL, "render.js") + MakeRequest(t, NewRequest(t, "GET", metas[0].EntryURL), http.StatusOK) + + uploadArchive(t, session, fmt.Sprintf("/-/admin/render-plugins/%d/upgrade", row.ID), buildRenderPluginArchive(t, pluginID, "Integration Plugin", "2.0.0")) + flash = expectFlashSuccess(t, session) + assert.Contains(t, flash.SuccessMsg, "upgraded") + row = requireRenderPluginRow(t, session, pluginID) + assert.Equal(t, "2.0.0", row.Version) + + postPluginAction(t, session, fmt.Sprintf("/-/admin/render-plugins/%d/disable", row.ID)) + flash = expectFlashSuccess(t, session) + assert.Contains(t, flash.SuccessMsg, "disabled") + row = requireRenderPluginRow(t, session, pluginID) + assert.False(t, row.Enabled) + require.Len(t, fetchRenderPluginMetadata(t), 0) + + postPluginAction(t, session, fmt.Sprintf("/-/admin/render-plugins/%d/delete", row.ID)) + flash = expectFlashSuccess(t, session) + assert.Contains(t, flash.SuccessMsg, "deleted") + unittest.AssertNotExistsBean(t, &render_model.Plugin{Identifier: pluginID}) + _, err := renderplugin.Storage().Stat(renderplugin.ObjectPath(pluginID, "render.js")) + assert.Error(t, err) + require.Nil(t, findRenderPluginRow(t, session, pluginID)) +} + +func postPluginAction(t *testing.T, session *TestSession, path string) { + req := NewRequestWithValues(t, "POST", path, map[string]string{ + "_csrf": GetUserCSRFToken(t, session), + }) + session.MakeRequest(t, req, http.StatusSeeOther) +} + +func uploadArchive(t *testing.T, session *TestSession, path string, archive []byte) { + var body bytes.Buffer + writer := multipart.NewWriter(&body) + require.NoError(t, writer.WriteField("_csrf", GetUserCSRFToken(t, session))) + part, err := writer.CreateFormFile("plugin", "plugin.zip") + require.NoError(t, err) + _, err = part.Write(archive) + require.NoError(t, err) + require.NoError(t, writer.Close()) + + req := NewRequestWithBody(t, "POST", path, bytes.NewReader(body.Bytes())) + req.Header.Set("Content-Type", writer.FormDataContentType()) + session.MakeRequest(t, req, http.StatusSeeOther) +} + +func buildRenderPluginArchive(t *testing.T, id, name, version string) []byte { + manifest := fmt.Sprintf(`{ + "schemaVersion": 1, + "id": %q, + "name": %q, + "version": %q, + "description": "integration test plugin", + "entry": "render.js", + "filePatterns": ["*.itest"] +}`, id, name, version) + + var buf bytes.Buffer + zipWriter := zip.NewWriter(&buf) + file, err := zipWriter.Create("manifest.json") + require.NoError(t, err) + _, err = file.Write([]byte(manifest)) + require.NoError(t, err) + + file, err = zipWriter.Create("render.js") + require.NoError(t, err) + _, err = file.Write([]byte("export default {render(){}};")) + require.NoError(t, err) + require.NoError(t, zipWriter.Close()) + return buf.Bytes() +} + +func fetchRenderPluginMetadata(t *testing.T) []renderplugin.Metadata { + resp := MakeRequest(t, NewRequest(t, "GET", "/assets/render-plugins/index.json"), http.StatusOK) + var metas []renderplugin.Metadata + require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &metas)) + return metas +} + +func expectFlashSuccess(t *testing.T, session *TestSession) *middleware.Flash { + flash := session.GetCookieFlashMessage() + require.NotNil(t, flash, "expected flash message") + require.Empty(t, flash.ErrorMsg) + return flash +} + +type renderPluginRow struct { + ID int64 + Identifier string + Version string + Enabled bool +} + +func requireRenderPluginRow(t *testing.T, session *TestSession, identifier string) *renderPluginRow { + row := findRenderPluginRow(t, session, identifier) + require.NotNil(t, row, "plugin %s not found", identifier) + return row +} + +func findRenderPluginRow(t *testing.T, session *TestSession, identifier string) *renderPluginRow { + resp := session.MakeRequest(t, NewRequest(t, "GET", "/-/admin/render-plugins"), http.StatusOK) + doc := NewHTMLParser(t, resp.Body) + var result *renderPluginRow + doc.Find("table tbody tr").EachWithBreak(func(_ int, s *goquery.Selection) bool { + cols := s.Find("td") + if cols.Length() < 6 { + return true + } + idText := strings.TrimSpace(cols.Eq(1).Text()) + if idText != identifier { + return true + } + link := cols.Eq(5).Find("a[href]").First() + href, _ := link.Attr("href") + id, err := strconv.ParseInt(path.Base(href), 10, 64) + if err != nil { + return true + } + version := strings.TrimSpace(cols.Eq(2).Text()) + enabled := cols.Eq(4).Find(".ui.green").Length() > 0 + result = &renderPluginRow{ + ID: id, + Identifier: idText, + Version: version, + Enabled: enabled, + } + return false + }) + return result +} diff --git a/web_src/js/features/file-view.test.ts b/web_src/js/features/file-view.test.ts new file mode 100644 index 0000000000000..b2d9dd224a95c --- /dev/null +++ b/web_src/js/features/file-view.test.ts @@ -0,0 +1,26 @@ +import {Buffer} from 'node:buffer'; +import {describe, expect, it, vi} from 'vitest'; +import {decodeHeadChunk} from './file-view.ts'; + +describe('decodeHeadChunk', () => { + it('returns null when input is empty', () => { + expect(decodeHeadChunk(null)).toBeNull(); + expect(decodeHeadChunk('')).toBeNull(); + }); + + it('decodes base64 content into a Uint8Array', () => { + const data = 'Gitea Render Plugin'; + const encoded = Buffer.from(data, 'utf-8').toString('base64'); + const decoded = decodeHeadChunk(encoded); + expect(decoded).not.toBeNull(); + expect(new TextDecoder().decode(decoded!)).toBe(data); + }); + + it('logs and returns null for invalid input', () => { + const spy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const result = decodeHeadChunk('%invalid-base64%'); + expect(result).toBeNull(); + expect(spy).toHaveBeenCalled(); + spy.mockRestore(); + }); +}); diff --git a/web_src/js/features/file-view.ts b/web_src/js/features/file-view.ts index 3f76ed151905a..6b9b7c214bafd 100644 --- a/web_src/js/features/file-view.ts +++ b/web_src/js/features/file-view.ts @@ -11,7 +11,7 @@ const plugins: FileRenderPlugin[] = []; let pluginsInitialized = false; let pluginsInitPromise: Promise | null = null; -function decodeHeadChunk(value: string | null): Uint8Array | null { +export function decodeHeadChunk(value: string | null): Uint8Array | null { if (!value) return null; try { const binary = window.atob(value); From d22069de0bc89ea7982d2767336b71286dc0ca38 Mon Sep 17 00:00:00 2001 From: Lunny Xiao Date: Sat, 6 Dec 2025 11:51:41 -0800 Subject: [PATCH 4/5] Fix lint --- modules/renderplugin/manifest_test.go | 4 ++- tests/integration/render_plugin_test.go | 3 +-- web_src/js/features/file-view.test.ts | 36 ++++++++++++------------- 3 files changed, 22 insertions(+), 21 deletions(-) diff --git a/modules/renderplugin/manifest_test.go b/modules/renderplugin/manifest_test.go index 1168f60fc709a..7f1fa82ec1322 100644 --- a/modules/renderplugin/manifest_test.go +++ b/modules/renderplugin/manifest_test.go @@ -1,3 +1,6 @@ +// Copyright 2025 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + package renderplugin import ( @@ -50,7 +53,6 @@ func TestManifestNormalizeErrors(t *testing.T) { } for _, tt := range tests { - tt := tt t.Run(tt.name, func(t *testing.T) { m := base tt.mutate(&m) diff --git a/tests/integration/render_plugin_test.go b/tests/integration/render_plugin_test.go index 0268055e1fb6e..bab0979676bb9 100644 --- a/tests/integration/render_plugin_test.go +++ b/tests/integration/render_plugin_test.go @@ -23,7 +23,6 @@ import ( "code.gitea.io/gitea/tests" "github.com/PuerkitoBio/goquery" - "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -69,7 +68,7 @@ func TestRenderPluginLifecycle(t *testing.T) { assert.Contains(t, flash.SuccessMsg, "disabled") row = requireRenderPluginRow(t, session, pluginID) assert.False(t, row.Enabled) - require.Len(t, fetchRenderPluginMetadata(t), 0) + require.Empty(t, fetchRenderPluginMetadata(t)) postPluginAction(t, session, fmt.Sprintf("/-/admin/render-plugins/%d/delete", row.ID)) flash = expectFlashSuccess(t, session) diff --git a/web_src/js/features/file-view.test.ts b/web_src/js/features/file-view.test.ts index b2d9dd224a95c..072996c08ecf4 100644 --- a/web_src/js/features/file-view.test.ts +++ b/web_src/js/features/file-view.test.ts @@ -3,24 +3,24 @@ import {describe, expect, it, vi} from 'vitest'; import {decodeHeadChunk} from './file-view.ts'; describe('decodeHeadChunk', () => { - it('returns null when input is empty', () => { - expect(decodeHeadChunk(null)).toBeNull(); - expect(decodeHeadChunk('')).toBeNull(); - }); + it('returns null when input is empty', () => { + expect(decodeHeadChunk(null)).toBeNull(); + expect(decodeHeadChunk('')).toBeNull(); + }); - it('decodes base64 content into a Uint8Array', () => { - const data = 'Gitea Render Plugin'; - const encoded = Buffer.from(data, 'utf-8').toString('base64'); - const decoded = decodeHeadChunk(encoded); - expect(decoded).not.toBeNull(); - expect(new TextDecoder().decode(decoded!)).toBe(data); - }); + it('decodes base64 content into a Uint8Array', () => { + const data = 'Gitea Render Plugin'; + const encoded = Buffer.from(data, 'utf-8').toString('base64'); + const decoded = decodeHeadChunk(encoded); + expect(decoded).not.toBeNull(); + expect(new TextDecoder().decode(decoded!)).toBe(data); + }); - it('logs and returns null for invalid input', () => { - const spy = vi.spyOn(console, 'error').mockImplementation(() => {}); - const result = decodeHeadChunk('%invalid-base64%'); - expect(result).toBeNull(); - expect(spy).toHaveBeenCalled(); - spy.mockRestore(); - }); + it('logs and returns null for invalid input', () => { + const spy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const result = decodeHeadChunk('%invalid-base64%'); + expect(result).toBeNull(); + expect(spy).toHaveBeenCalled(); + spy.mockRestore(); + }); }); From 63fc35ea2295655105c9d1eadaf780eb5fc1ecef Mon Sep 17 00:00:00 2001 From: Lunny Xiao Date: Sat, 6 Dec 2025 15:13:35 -0800 Subject: [PATCH 5/5] Add permissions for render plugin --- contrib/render-plugins/example-wasm/README.md | 5 + .../render-plugins/example-wasm/manifest.json | 3 +- contrib/render-plugins/example/README.md | 5 + contrib/render-plugins/example/manifest.json | 3 +- models/migrations/v1_26/v324.go | 1 + models/render/plugin.go | 1 + modules/renderplugin/manifest.go | 27 +++ modules/renderplugin/manifest_test.go | 17 ++ options/locale/locale_en-US.ini | 14 ++ routers/web/admin/render_plugins.go | 214 +++++++++++++++++- routers/web/web.go | 3 + services/renderplugin/service.go | 12 + templates/admin/render/plugin_confirm.tmpl | 89 ++++++++ templates/admin/render/plugin_detail.tmpl | 28 ++- web_src/js/render/plugins/dynamic-plugin.ts | 156 ++++++++++++- 15 files changed, 558 insertions(+), 20 deletions(-) create mode 100644 templates/admin/render/plugin_confirm.tmpl diff --git a/contrib/render-plugins/example-wasm/README.md b/contrib/render-plugins/example-wasm/README.md index f60954f4fc195..5a01c3dea6ef2 100644 --- a/contrib/render-plugins/example-wasm/README.md +++ b/contrib/render-plugins/example-wasm/README.md @@ -16,6 +16,11 @@ then renders the result inside the file viewer. - `build.sh` — helper script that builds `plugin.wasm` and produces a zip archive ready for upload +As with other plugins, declare any Gitea endpoints or external hosts the WASM +module needs to call inside the `permissions` array in `manifest.json`. Without +an explicit entry, the plugin may only download the file that is currently being +rendered. + ## Build & Install 1. Build the WASM binary and zip archive: diff --git a/contrib/render-plugins/example-wasm/manifest.json b/contrib/render-plugins/example-wasm/manifest.json index c03d6f665ce1c..b592e226aa221 100644 --- a/contrib/render-plugins/example-wasm/manifest.json +++ b/contrib/render-plugins/example-wasm/manifest.json @@ -7,5 +7,6 @@ "entry": "render.js", "filePatterns": [ "*.wasmnote" - ] + ], + "permissions": [] } diff --git a/contrib/render-plugins/example/README.md b/contrib/render-plugins/example/README.md index b2273a8dbf6e9..9dda2f1d0111f 100644 --- a/contrib/render-plugins/example/README.md +++ b/contrib/render-plugins/example/README.md @@ -10,6 +10,11 @@ as a quick way to validate the dynamic plugin system locally. - `render.js` — an ES module that exports a `render(container, fileUrl)` function; it downloads the source file and renders it in a styled `
`
 
+By default plugins may only fetch the file that is currently being rendered.
+If your plugin needs to contact Gitea APIs or any external services, list their
+domains under the `permissions` array in `manifest.json`. Requests to hosts that
+are not declared there will be blocked by the runtime.
+
 ## Build & Install
 
 1. Create a zip archive that contains both files:
diff --git a/contrib/render-plugins/example/manifest.json b/contrib/render-plugins/example/manifest.json
index 05addce610bdd..ba2825a1d9811 100644
--- a/contrib/render-plugins/example/manifest.json
+++ b/contrib/render-plugins/example/manifest.json
@@ -5,5 +5,6 @@
   "version": "1.0.0",
   "description": "Simple sample plugin that renders .txt files with a custom color scheme.",
   "entry": "render.js",
-  "filePatterns": ["*.txt"]
+  "filePatterns": ["*.txt"],
+  "permissions": []
 }
diff --git a/models/migrations/v1_26/v324.go b/models/migrations/v1_26/v324.go
index e1c4ccb4d92ed..1c54fa9517fd4 100644
--- a/models/migrations/v1_26/v324.go
+++ b/models/migrations/v1_26/v324.go
@@ -18,6 +18,7 @@ func AddRenderPluginTable(x *xorm.Engine) error {
 		Version       string             `xorm:"NOT NULL"`
 		Description   string             `xorm:"TEXT"`
 		Source        string             `xorm:"TEXT"`
+		Permissions   []string           `xorm:"JSON"`
 		Entry         string             `xorm:"NOT NULL"`
 		FilePatterns  []string           `xorm:"JSON"`
 		FormatVersion int                `xorm:"NOT NULL DEFAULT 1"`
diff --git a/models/render/plugin.go b/models/render/plugin.go
index 1414b2d71f26f..11d1dcad4278b 100644
--- a/models/render/plugin.go
+++ b/models/render/plugin.go
@@ -20,6 +20,7 @@ type Plugin struct {
 	Source        string             `xorm:"TEXT"`
 	Entry         string             `xorm:"NOT NULL"`
 	FilePatterns  []string           `xorm:"JSON"`
+	Permissions   []string           `xorm:"JSON"`
 	FormatVersion int                `xorm:"NOT NULL DEFAULT 1"`
 	Enabled       bool               `xorm:"NOT NULL DEFAULT false"`
 	CreatedUnix   timeutil.TimeStamp `xorm:"created NOT NULL"`
diff --git a/modules/renderplugin/manifest.go b/modules/renderplugin/manifest.go
index 7866fda07062b..7fa9796fd8283 100644
--- a/modules/renderplugin/manifest.go
+++ b/modules/renderplugin/manifest.go
@@ -29,6 +29,7 @@ type Manifest struct {
 	Description   string   `json:"description"`
 	Entry         string   `json:"entry"`
 	FilePatterns  []string `json:"filePatterns"`
+	Permissions   []string `json:"permissions"`
 }
 
 // Normalize validates mandatory fields and normalizes values.
@@ -71,9 +72,34 @@ func (m *Manifest) Normalize() error {
 	}
 	sort.Strings(cleanPatterns)
 	m.FilePatterns = cleanPatterns
+
+	cleanPerms := make([]string, 0, len(m.Permissions))
+	seenPerm := make(map[string]struct{}, len(m.Permissions))
+	for _, perm := range m.Permissions {
+		perm = strings.TrimSpace(strings.ToLower(perm))
+		if perm == "" {
+			continue
+		}
+		if !isValidPermissionHost(perm) {
+			return fmt.Errorf("manifest permission %q is invalid; only plain domains optionally including a port are allowed", perm)
+		}
+		if _, ok := seenPerm[perm]; ok {
+			continue
+		}
+		seenPerm[perm] = struct{}{}
+		cleanPerms = append(cleanPerms, perm)
+	}
+	sort.Strings(cleanPerms)
+	m.Permissions = cleanPerms
 	return nil
 }
 
+var permissionHostRegexp = regexp.MustCompile(`^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]*[a-z0-9])?)*(?::[0-9]{1,5})?$`)
+
+func isValidPermissionHost(value string) bool {
+	return permissionHostRegexp.MatchString(value)
+}
+
 // LoadManifest reads and validates the manifest.json file located under dir.
 func LoadManifest(dir string) (*Manifest, error) {
 	manifestPath := filepath.Join(dir, "manifest.json")
@@ -103,4 +129,5 @@ type Metadata struct {
 	AssetsBase    string   `json:"assetsBaseUrl"`
 	FilePatterns  []string `json:"filePatterns"`
 	SchemaVersion int      `json:"schemaVersion"`
+	Permissions   []string `json:"permissions"`
 }
diff --git a/modules/renderplugin/manifest_test.go b/modules/renderplugin/manifest_test.go
index 7f1fa82ec1322..11ffd2bb7fb28 100644
--- a/modules/renderplugin/manifest_test.go
+++ b/modules/renderplugin/manifest_test.go
@@ -27,6 +27,7 @@ func TestManifestNormalizeDefaults(t *testing.T) {
 	assert.Equal(t, "example.plugin", manifest.ID)
 	assert.Equal(t, "render.js", manifest.Entry)
 	assert.Equal(t, []string{"*.TXT", "README.md"}, manifest.FilePatterns)
+	assert.Empty(t, manifest.Permissions)
 }
 
 func TestManifestNormalizeErrors(t *testing.T) {
@@ -50,6 +51,7 @@ func TestManifestNormalizeErrors(t *testing.T) {
 		{"missing name", func(m *Manifest) { m.Name = "" }, "name is required"},
 		{"missing version", func(m *Manifest) { m.Version = "" }, "version is required"},
 		{"no patterns", func(m *Manifest) { m.FilePatterns = nil }, "at least one file pattern"},
+		{"invalid permission", func(m *Manifest) { m.Permissions = []string{"http://bad"} }, "manifest permission"},
 	}
 
 	for _, tt := range tests {
@@ -82,3 +84,18 @@ func TestLoadManifest(t *testing.T) {
 	assert.Equal(t, "example", manifest.ID)
 	assert.Equal(t, []string{"*.md", "*.txt"}, manifest.FilePatterns)
 }
+
+func TestManifestNormalizePermissions(t *testing.T) {
+	manifest := Manifest{
+		SchemaVersion: SupportedManifestVersion,
+		ID:            "perm",
+		Name:          "perm",
+		Version:       "1.0.0",
+		Entry:         "render.js",
+		FilePatterns:  []string{"*.md"},
+		Permissions:   []string{" Example.com ", "api.example.com:8080", "example.com", ""},
+	}
+
+	require.NoError(t, manifest.Normalize())
+	assert.Equal(t, []string{"api.example.com:8080", "example.com"}, manifest.Permissions)
+}
diff --git a/options/locale/locale_en-US.ini b/options/locale/locale_en-US.ini
index 10665780c5895..95c21d28c8cd0 100644
--- a/options/locale/locale_en-US.ini
+++ b/options/locale/locale_en-US.ini
@@ -3030,6 +3030,20 @@ render_plugins.detail.none = Not provided
 render_plugins.detail.file_patterns_empty = No file patterns declared.
 render_plugins.detail.actions = Plugin actions
 render_plugins.detail.upgrade = Upgrade plugin
+render_plugins.detail.permissions = Permissions
+render_plugins.confirm_install = Review permissions before installing "%s"
+render_plugins.confirm_upgrade = Review permissions before upgrading "%s"
+render_plugins.confirm.description = Gitea will only allow this plugin to contact the domains listed below (plus the file being rendered). Continue only if you trust these endpoints.
+render_plugins.confirm.permissions = Requested domains
+render_plugins.confirm.permission_hint = If the list is empty the plugin will only fetch the file currently being rendered.
+render_plugins.confirm.permission_none = None
+render_plugins.confirm.archive = Archive
+render_plugins.confirm.actions.install = Install Plugin
+render_plugins.confirm.actions.upgrade = Upgrade Plugin
+render_plugins.confirm.actions.cancel = Cancel Upload
+render_plugins.upload_token_invalid = Plugin upload session expired. Please upload the archive again.
+render_plugins.upload_discarded = Plugin upload discarded.
+render_plugins.identifier_mismatch = Uploaded plugin identifier "%s" does not match "%s".
 hooks = Webhooks
 integrations = Integrations
 authentication = Authentication Sources
diff --git a/routers/web/admin/render_plugins.go b/routers/web/admin/render_plugins.go
index 38d209e1ae275..62a31475dc544 100644
--- a/routers/web/admin/render_plugins.go
+++ b/routers/web/admin/render_plugins.go
@@ -5,21 +5,77 @@ package admin
 
 import (
 	"fmt"
+	"io"
+	"mime/multipart"
 	"net/http"
+	"os"
 	"strings"
+	"sync"
 
 	render_model "code.gitea.io/gitea/models/render"
+	"code.gitea.io/gitea/modules/log"
 	"code.gitea.io/gitea/modules/setting"
 	"code.gitea.io/gitea/modules/templates"
+	"code.gitea.io/gitea/modules/util"
 	"code.gitea.io/gitea/services/context"
 	plugin_service "code.gitea.io/gitea/services/renderplugin"
 )
 
 const (
-	tplRenderPlugins      templates.TplName = "admin/render/plugins"
-	tplRenderPluginDetail templates.TplName = "admin/render/plugin_detail"
+	tplRenderPlugins       templates.TplName = "admin/render/plugins"
+	tplRenderPluginDetail  templates.TplName = "admin/render/plugin_detail"
+	tplRenderPluginConfirm templates.TplName = "admin/render/plugin_confirm"
 )
 
+type pendingRenderPluginUpload struct {
+	Path               string
+	Filename           string
+	ExpectedIdentifier string
+	PluginID           int64
+}
+
+var (
+	pendingUploadsMu sync.Mutex
+	pendingUploads   = make(map[string]*pendingRenderPluginUpload)
+)
+
+func rememberPendingUpload(info *pendingRenderPluginUpload) (string, error) {
+	for {
+		token, err := util.CryptoRandomString(32)
+		if err != nil {
+			return "", err
+		}
+		pendingUploadsMu.Lock()
+		if _, ok := pendingUploads[token]; ok {
+			pendingUploadsMu.Unlock()
+			continue
+		}
+		pendingUploads[token] = info
+		pendingUploadsMu.Unlock()
+		return token, nil
+	}
+}
+
+func takePendingUpload(token string) *pendingRenderPluginUpload {
+	if token == "" {
+		return nil
+	}
+	pendingUploadsMu.Lock()
+	defer pendingUploadsMu.Unlock()
+	info := pendingUploads[token]
+	delete(pendingUploads, token)
+	return info
+}
+
+func discardPendingUpload(info *pendingRenderPluginUpload) {
+	if info == nil {
+		return
+	}
+	if err := os.Remove(info.Path); err != nil && !os.IsNotExist(err) {
+		log.Warn("Failed to remove pending render plugin upload %s: %v", info.Path, err)
+	}
+}
+
 // RenderPlugins shows the plugin management page.
 func RenderPlugins(ctx *context.Context) {
 	plugs, err := render_model.ListPlugins(ctx)
@@ -59,12 +115,39 @@ func RenderPluginsUpload(ctx *context.Context) {
 		redirectRenderPlugins(ctx)
 		return
 	}
-	if _, err := plugin_service.InstallFromArchive(ctx, file, header.Filename, ""); err != nil {
+	previewPath, err := saveRenderPluginUpload(file)
+	if err != nil {
 		ctx.Flash.Error(ctx.Tr("admin.render_plugins.upload_failed", err))
-	} else {
-		ctx.Flash.Success(ctx.Tr("admin.render_plugins.upload_success", header.Filename))
+		redirectRenderPlugins(ctx)
+		return
 	}
-	redirectRenderPlugins(ctx)
+	manifest, err := plugin_service.LoadManifestFromArchive(previewPath)
+	if err != nil {
+		_ = os.Remove(previewPath)
+		ctx.Flash.Error(ctx.Tr("admin.render_plugins.upload_failed", err))
+		redirectRenderPlugins(ctx)
+		return
+	}
+	token, err := rememberPendingUpload(&pendingRenderPluginUpload{
+		Path:               previewPath,
+		Filename:           header.Filename,
+		ExpectedIdentifier: "",
+		PluginID:           0,
+	})
+	if err != nil {
+		_ = os.Remove(previewPath)
+		ctx.Flash.Error(ctx.Tr("admin.render_plugins.upload_failed", err))
+		redirectRenderPlugins(ctx)
+		return
+	}
+	ctx.Data["Title"] = ctx.Tr("admin.render_plugins.confirm_install", manifest.Name)
+	ctx.Data["PageIsAdminRenderPlugins"] = true
+	ctx.Data["PluginManifest"] = manifest
+	ctx.Data["UploadFilename"] = header.Filename
+	ctx.Data["PendingUploadToken"] = token
+	ctx.Data["IsUpgradePreview"] = false
+	ctx.Data["RedirectTo"] = ctx.FormString("redirect_to")
+	ctx.HTML(http.StatusOK, tplRenderPluginConfirm)
 }
 
 // RenderPluginsEnable toggles plugin state to enabled.
@@ -127,7 +210,84 @@ func RenderPluginsUpgrade(ctx *context.Context) {
 		redirectRenderPlugins(ctx)
 		return
 	}
-	updated, err := plugin_service.InstallFromArchive(ctx, file, header.Filename, plug.Identifier)
+	previewPath, err := saveRenderPluginUpload(file)
+	if err != nil {
+		ctx.Flash.Error(ctx.Tr("admin.render_plugins.upgrade_failed", err))
+		redirectRenderPlugins(ctx)
+		return
+	}
+	manifest, err := plugin_service.LoadManifestFromArchive(previewPath)
+	if err != nil {
+		_ = os.Remove(previewPath)
+		ctx.Flash.Error(ctx.Tr("admin.render_plugins.upgrade_failed", err))
+		redirectRenderPlugins(ctx)
+		return
+	}
+	if manifest.ID != plug.Identifier {
+		_ = os.Remove(previewPath)
+		ctx.Flash.Error(ctx.Tr("admin.render_plugins.identifier_mismatch", manifest.ID, plug.Identifier))
+		redirectRenderPlugins(ctx)
+		return
+	}
+	token, err := rememberPendingUpload(&pendingRenderPluginUpload{
+		Path:               previewPath,
+		Filename:           header.Filename,
+		ExpectedIdentifier: plug.Identifier,
+		PluginID:           plug.ID,
+	})
+	if err != nil {
+		_ = os.Remove(previewPath)
+		ctx.Flash.Error(ctx.Tr("admin.render_plugins.upgrade_failed", err))
+		redirectRenderPlugins(ctx)
+		return
+	}
+	ctx.Data["Title"] = ctx.Tr("admin.render_plugins.confirm_upgrade", plug.Name)
+	ctx.Data["PageIsAdminRenderPlugins"] = true
+	ctx.Data["PluginManifest"] = manifest
+	ctx.Data["UploadFilename"] = header.Filename
+	ctx.Data["PendingUploadToken"] = token
+	ctx.Data["IsUpgradePreview"] = true
+	ctx.Data["CurrentPlugin"] = plug
+	ctx.Data["RedirectTo"] = ctx.FormString("redirect_to")
+	ctx.HTML(http.StatusOK, tplRenderPluginConfirm)
+}
+
+// RenderPluginsUploadConfirm finalizes a pending plugin installation.
+func RenderPluginsUploadConfirm(ctx *context.Context) {
+	info := takePendingUpload(ctx.FormString("token"))
+	if info == nil || info.PluginID != 0 {
+		ctx.Flash.Error(ctx.Tr("admin.render_plugins.upload_token_invalid"))
+		if info != nil {
+			discardPendingUpload(info)
+		}
+		redirectRenderPlugins(ctx)
+		return
+	}
+	_, err := installPendingUpload(ctx, info)
+	if err != nil {
+		ctx.Flash.Error(ctx.Tr("admin.render_plugins.upload_failed", err))
+	} else {
+		ctx.Flash.Success(ctx.Tr("admin.render_plugins.upload_success", info.Filename))
+	}
+	redirectRenderPlugins(ctx)
+}
+
+// RenderPluginsUpgradeConfirm finalizes a pending plugin upgrade.
+func RenderPluginsUpgradeConfirm(ctx *context.Context) {
+	plug := mustGetRenderPlugin(ctx)
+	if plug == nil {
+		return
+	}
+	info := takePendingUpload(ctx.FormString("token"))
+	if info == nil || info.PluginID != plug.ID || info.ExpectedIdentifier != plug.Identifier {
+		ctx.Flash.Error(ctx.Tr("admin.render_plugins.upload_token_invalid"))
+		if info != nil {
+			discardPendingUpload(info)
+		}
+		redirectRenderPlugins(ctx)
+		return
+	}
+	updated, err := installPendingUpload(ctx, info)
 	if err != nil {
 		ctx.Flash.Error(ctx.Tr("admin.render_plugins.upgrade_failed", err))
 	} else {
@@ -136,6 +296,16 @@ func RenderPluginsUpgrade(ctx *context.Context) {
 	redirectRenderPlugins(ctx)
 }
 
+// RenderPluginsUploadDiscard removes a pending upload archive without installing it.
+func RenderPluginsUploadDiscard(ctx *context.Context) {
+	info := takePendingUpload(ctx.FormString("token"))
+	if info != nil {
+		discardPendingUpload(info)
+	}
+	ctx.Flash.Success(ctx.Tr("admin.render_plugins.upload_discarded"))
+	redirectRenderPlugins(ctx)
+}
+
 func mustGetRenderPlugin(ctx *context.Context) *render_model.Plugin {
 	id := ctx.PathParamInt64("id")
 	if id <= 0 {
@@ -163,3 +333,33 @@ func redirectRenderPlugins(ctx *context.Context) {
 	}
 	ctx.Redirect(setting.AppSubURL + "/-/admin/render-plugins")
 }
+
+func saveRenderPluginUpload(file multipart.File) (_ string, err error) {
+	tmpFile, cleanup, err := setting.AppDataTempDir("render-plugins").CreateTempFileRandom("pending", "*.zip")
+	if err != nil {
+		return "", err
+	}
+	defer func() {
+		if err != nil {
+			cleanup()
+		}
+	}()
+	if _, err = io.Copy(tmpFile, file); err != nil {
+		return "", err
+	}
+	if err = tmpFile.Close(); err != nil {
+		return "", err
+	}
+	return tmpFile.Name(), nil
+}
+
+func installPendingUpload(ctx *context.Context, info *pendingRenderPluginUpload) (*render_model.Plugin, error) {
+	file, err := os.Open(info.Path)
+	if err != nil {
+		discardPendingUpload(info)
+		return nil, err
+	}
+	defer file.Close()
+	defer discardPendingUpload(info)
+	return plugin_service.InstallFromArchive(ctx, file, info.Filename, info.ExpectedIdentifier)
+}
diff --git a/routers/web/web.go b/routers/web/web.go
index 0b44e45e1956d..e6f6252923363 100644
--- a/routers/web/web.go
+++ b/routers/web/web.go
@@ -778,10 +778,13 @@ func registerWebRoutes(m *web.Router) {
 			m.Get("", admin.RenderPlugins)
 			m.Get("/{id}", admin.RenderPluginDetail)
 			m.Post("/upload", admin.RenderPluginsUpload)
+			m.Post("/upload/confirm", admin.RenderPluginsUploadConfirm)
+			m.Post("/upload/discard", admin.RenderPluginsUploadDiscard)
 			m.Post("/{id}/enable", admin.RenderPluginsEnable)
 			m.Post("/{id}/disable", admin.RenderPluginsDisable)
 			m.Post("/{id}/delete", admin.RenderPluginsDelete)
 			m.Post("/{id}/upgrade", admin.RenderPluginsUpgrade)
+			m.Post("/{id}/upgrade/confirm", admin.RenderPluginsUpgradeConfirm)
 		})
 
 		m.Group("/hooks", func() {
diff --git a/services/renderplugin/service.go b/services/renderplugin/service.go
index 692fae4e47261..b82f70a70d464 100644
--- a/services/renderplugin/service.go
+++ b/services/renderplugin/service.go
@@ -64,6 +64,7 @@ func InstallFromArchive(ctx context.Context, upload io.Reader, filename, expecte
 		Source:        strings.TrimSpace(filename),
 		Entry:         manifest.Entry,
 		FilePatterns:  manifest.FilePatterns,
+		Permissions:   manifest.Permissions,
 		FormatVersion: manifest.SchemaVersion,
 	}
 	if err := render_model.UpsertPlugin(ctx, plug); err != nil {
@@ -72,6 +73,16 @@ func InstallFromArchive(ctx context.Context, upload io.Reader, filename, expecte
 	return plug, nil
 }
 
+// LoadManifestFromArchive extracts and validates only the manifest from a plugin archive.
+func LoadManifestFromArchive(zipPath string) (*renderplugin.Manifest, error) {
+	_, manifest, cleanup, err := extractArchive(zipPath)
+	if err != nil {
+		return nil, err
+	}
+	defer cleanup()
+	return manifest, nil
+}
+
 // Delete removes a plugin from disk and database.
 func Delete(ctx context.Context, plug *render_model.Plugin) error {
 	if err := deletePluginFiles(plug.Identifier); err != nil {
@@ -118,6 +129,7 @@ func BuildMetadata(ctx context.Context) ([]renderplugin.Metadata, error) {
 			AssetsBase:    assetsBase,
 			FilePatterns:  append([]string(nil), plug.FilePatterns...),
 			SchemaVersion: plug.FormatVersion,
+			Permissions:   append([]string(nil), plug.Permissions...),
 		})
 	}
 	return metas, nil
diff --git a/templates/admin/render/plugin_confirm.tmpl b/templates/admin/render/plugin_confirm.tmpl
new file mode 100644
index 0000000000000..358aaac0a089e
--- /dev/null
+++ b/templates/admin/render/plugin_confirm.tmpl
@@ -0,0 +1,89 @@
+{{template "admin/layout_head" (dict "ctxData" . "pageClass" "admin render-plugins")}}
+	
+

+ {{if .IsUpgradePreview}} + {{ctx.Locale.Tr "admin.render_plugins.confirm_upgrade" .CurrentPlugin.Name}} + {{else}} + {{ctx.Locale.Tr "admin.render_plugins.confirm_install" .PluginManifest.Name}} + {{end}} +

+
+ {{ctx.Locale.Tr "admin.render_plugins.confirm.description"}} +
+
+
+

{{ctx.Locale.Tr "admin.render_plugins.detail.actions"}}

+ + + + + + + + + + + + + + + + + + + +
{{ctx.Locale.Tr "admin.render_plugins.table.name"}}{{.PluginManifest.Name}}
{{ctx.Locale.Tr "admin.render_plugins.table.identifier"}}{{.PluginManifest.ID}}
{{ctx.Locale.Tr "admin.render_plugins.table.version"}}{{.PluginManifest.Version}}
{{ctx.Locale.Tr "admin.render_plugins.confirm.archive"}}{{.UploadFilename}}
+
+
+

{{ctx.Locale.Tr "admin.render_plugins.confirm.permissions"}}

+

{{ctx.Locale.Tr "admin.render_plugins.confirm.permission_hint"}}

+ {{if .PluginManifest.Permissions}} +
    + {{range .PluginManifest.Permissions}} +
  • {{.}}
  • + {{end}} +
+ {{else}} +

{{ctx.Locale.Tr "admin.render_plugins.confirm.permission_none"}}

+ {{end}} +
+ {{if .PluginManifest.Description}} +
+

{{ctx.Locale.Tr "admin.render_plugins.detail.description"}}

+

{{.PluginManifest.Description}}

+
+ {{end}} + {{if .IsUpgradePreview}} +
+

{{ctx.Locale.Tr "admin.render_plugins.detail.actions"}}

+

{{ctx.Locale.Tr "admin.render_plugins.detail.entry"}}: {{.PluginManifest.Entry}}

+
+ {{end}} +
+
+
+ {{.CsrfTokenHtml}} + + {{if .RedirectTo}} + + {{end}} + +
+
+ {{.CsrfTokenHtml}} + + {{if .RedirectTo}} + + {{end}} + +
+
+
+ +{{template "admin/layout_footer" .}} diff --git a/templates/admin/render/plugin_detail.tmpl b/templates/admin/render/plugin_detail.tmpl index a328a4273289d..7b02836a9e9a3 100644 --- a/templates/admin/render/plugin_detail.tmpl +++ b/templates/admin/render/plugin_detail.tmpl @@ -57,15 +57,25 @@ - {{ctx.Locale.Tr "admin.render_plugins.table.patterns"}} - - {{if .Plugin.FilePatterns}} - {{range $i, $pattern := .Plugin.FilePatterns}}{{if $i}}, {{end}}{{$pattern}}{{end}} - {{else}} - {{ctx.Locale.Tr "admin.render_plugins.detail.file_patterns_empty"}} - {{end}} - - + {{ctx.Locale.Tr "admin.render_plugins.table.patterns"}} + + {{if .Plugin.FilePatterns}} + {{range $i, $pattern := .Plugin.FilePatterns}}{{if $i}}, {{end}}{{$pattern}}{{end}} + {{else}} + {{ctx.Locale.Tr "admin.render_plugins.detail.file_patterns_empty"}} + {{end}} + + + + {{ctx.Locale.Tr "admin.render_plugins.detail.permissions"}} + + {{if .Plugin.Permissions}} + {{range $i, $perm := .Plugin.Permissions}}{{if $i}}, {{end}}{{$perm}}{{end}} + {{else}} + {{ctx.Locale.Tr "admin.render_plugins.detail.none"}} + {{end}} + +
diff --git a/web_src/js/render/plugins/dynamic-plugin.ts b/web_src/js/render/plugins/dynamic-plugin.ts index 72a1933e23160..f206a30a3e6ee 100644 --- a/web_src/js/render/plugins/dynamic-plugin.ts +++ b/web_src/js/render/plugins/dynamic-plugin.ts @@ -10,6 +10,7 @@ type RemotePluginMeta = { entryUrl: string; assetsBaseUrl: string; filePatterns: string[]; + permissions?: string[]; }; type RemotePluginModule = { @@ -73,12 +74,163 @@ function wrapRemotePlugin(meta: RemotePluginMeta): FileRenderPlugin { return matcher(filename); }, async render(container, fileUrl, options) { - const remote = await loadRemoteModule(meta); - await remote.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 { + const hosts = new Set(); + 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): 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, fn: () => Promise): Promise { + 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): 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): 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): 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): 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): 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 { try { const metadata = await fetchRemoteMetadata();