diff --git a/contrib/render-plugins/example-wasm/README.md b/contrib/render-plugins/example-wasm/README.md new file mode 100644 index 0000000000000..5a01c3dea6ef2 --- /dev/null +++ b/contrib/render-plugins/example-wasm/README.md @@ -0,0 +1,56 @@ +# 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 + +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: + + ```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..b592e226aa221 --- /dev/null +++ b/contrib/render-plugins/example-wasm/manifest.json @@ -0,0 +1,12 @@ +{ + "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" + ], + "permissions": [] +} 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..ef53fd9e9a4f4 --- /dev/null +++ b/contrib/render-plugins/example-wasm/wasm/main.go @@ -0,0 +1,29 @@ +//go:build js && wasm +// +build js,wasm + +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..9dda2f1d0111f --- /dev/null +++ b/contrib/render-plugins/example/README.md @@ -0,0 +1,35 @@ +# 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 `
`
+
+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:
+
+ ```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..ba2825a1d9811
--- /dev/null
+++ b/contrib/render-plugins/example/manifest.json
@@ -0,0 +1,10 @@
+{
+ "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"],
+ "permissions": []
+}
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..1c54fa9517fd4
--- /dev/null
+++ b/models/migrations/v1_26/v324.go
@@ -0,0 +1,31 @@
+// 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"`
+ Permissions []string `xorm:"JSON"`
+ 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..11d1dcad4278b
--- /dev/null
+++ b/models/render/plugin.go
@@ -0,0 +1,126 @@
+// 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"`
+ 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"`
+ 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..7fa9796fd8283
--- /dev/null
+++ b/modules/renderplugin/manifest.go
@@ -0,0 +1,133 @@
+// Copyright 2025 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+package renderplugin
+
+import (
+ "errors"
+ "fmt"
+ "os"
+ "path/filepath"
+ "regexp"
+ "sort"
+ "strings"
+
+ "code.gitea.io/gitea/modules/json"
+ "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"`
+ Permissions []string `json:"permissions"`
+}
+
+// Normalize validates mandatory fields and normalizes values.
+func (m *Manifest) Normalize() error {
+ if m.SchemaVersion == 0 {
+ return errors.New("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 errors.New("manifest name is required")
+ }
+ m.Version = strings.TrimSpace(m.Version)
+ if m.Version == "" {
+ return errors.New("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 errors.New("manifest must declare at least one file pattern")
+ }
+ 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")
+ 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"`
+ Permissions []string `json:"permissions"`
+}
diff --git a/modules/renderplugin/manifest_test.go b/modules/renderplugin/manifest_test.go
new file mode 100644
index 0000000000000..11ffd2bb7fb28
--- /dev/null
+++ b/modules/renderplugin/manifest_test.go
@@ -0,0 +1,101 @@
+// Copyright 2025 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+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)
+ assert.Empty(t, manifest.Permissions)
+}
+
+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"},
+ {"invalid permission", func(m *Manifest) { m.Permissions = []string{"http://bad"} }, "manifest permission"},
+ }
+
+ for _, tt := range tests {
+ 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)
+}
+
+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/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..95c21d28c8cd0 100644
--- a/options/locale/locale_en-US.ini
+++ b/options/locale/locale_en-US.ini
@@ -2991,6 +2991,59 @@ 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
+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
new file mode 100644
index 0000000000000..62a31475dc544
--- /dev/null
+++ b/routers/web/admin/render_plugins.go
@@ -0,0 +1,365 @@
+// Copyright 2025 The Gitea Authors. All rights reserved.
+// SPDX-License-Identifier: MIT
+
+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"
+ 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)
+ 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
+ }
+ previewPath, err := saveRenderPluginUpload(file)
+ if err != nil {
+ ctx.Flash.Error(ctx.Tr("admin.render_plugins.upload_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.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.
+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
+ }
+ 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 {
+ ctx.Flash.Success(ctx.Tr("admin.render_plugins.upgrade_success", updated.Name, updated.Version))
+ }
+ 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 {
+ 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")
+}
+
+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/renderplugin/assets.go b/routers/web/renderplugin/assets.go
new file mode 100644
index 0000000000000..2423267cf9454
--- /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 (
+ "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"
+ 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..e6f6252923363 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,19 @@ 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("/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() {
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..b82f70a70d464
--- /dev/null
+++ b/services/renderplugin/service.go
@@ -0,0 +1,301 @@
+// 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,
+ Permissions: manifest.Permissions,
+ FormatVersion: manifest.SchemaVersion,
+ }
+ if err := render_model.UpsertPlugin(ctx, plug); err != nil {
+ return nil, err
+ }
+ 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 {
+ 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,
+ Permissions: append([]string(nil), plug.Permissions...),
+ })
+ }
+ 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 errors.New("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)
+ 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 {
+ 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 errors.New("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..0e08633f5de16 100644
--- a/templates/admin/navbar.tmpl
+++ b/templates/admin/navbar.tmpl
@@ -44,30 +44,24 @@
- {{if and (not DisableWebhooks) .EnableOAuth2}}
-
- {{ctx.Locale.Tr "admin.integrations"}}
-
+
{{if .EnableActions}}
{{ctx.Locale.Tr "actions.actions"}}
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.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}}
+
+
+
+
+
+
+
+{{template "admin/layout_footer" .}}
diff --git a/templates/admin/render/plugin_detail.tmpl b/templates/admin/render/plugin_detail.tmpl
new file mode 100644
index 0000000000000..7b02836a9e9a3
--- /dev/null
+++ b/templates/admin/render/plugin_detail.tmpl
@@ -0,0 +1,122 @@
+{{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.permissions"}}
+
+ {{if .Plugin.Permissions}}
+ {{range $i, $perm := .Plugin.Permissions}}{{if $i}}, {{end}}{{$perm}}{{end}}
+ {{else}}
+ {{ctx.Locale.Tr "admin.render_plugins.detail.none"}}
+ {{end}}
+
+
+
+
+
+
+ {{ctx.Locale.Tr "admin.render_plugins.detail.actions"}}
+
+
+
+ {{if .Plugin.Enabled}}
+
+ {{else}}
+
+ {{end}}
+
+
+
+
+ {{ctx.Locale.Tr "admin.render_plugins.detail.upgrade"}}
+
+
+
+
+
+
+{{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"}}
+
+
+
+
+ {{ctx.Locale.Tr "admin.render_plugins.example_hint"}}
+
+
+
+
+
+
+ {{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"}}
+
+
+
+ {{range .Plugins}}
+
+
+ {{.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"}}
+
+
+ {{else}}
+
+ {{ctx.Locale.Tr "admin.render_plugins.empty"}}
+
+ {{end}}
+
+
+
+
+
+{{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}}