mirror of
https://github.com/go-gitea/gitea.git
synced 2026-04-20 23:09:04 +02:00
Add frontend render plugin system support
This commit is contained in:
parent
c287a8cdb5
commit
2c56b90cd4
51
contrib/render-plugins/example-wasm/README.md
Normal file
51
contrib/render-plugins/example-wasm/README.md
Normal file
@ -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.
|
||||
26
contrib/render-plugins/example-wasm/build.sh
Executable file
26
contrib/render-plugins/example-wasm/build.sh
Executable file
@ -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"
|
||||
11
contrib/render-plugins/example-wasm/manifest.json
Normal file
11
contrib/render-plugins/example-wasm/manifest.json
Normal file
@ -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"
|
||||
]
|
||||
}
|
||||
163
contrib/render-plugins/example-wasm/render.js
Normal file
163
contrib/render-plugins/example-wasm/render.js
Normal file
@ -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}`);
|
||||
}
|
||||
},
|
||||
};
|
||||
26
contrib/render-plugins/example-wasm/wasm/main.go
Normal file
26
contrib/render-plugins/example-wasm/wasm/main.go
Normal file
@ -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 {}
|
||||
}
|
||||
575
contrib/render-plugins/example-wasm/wasm_exec.js
Normal file
575
contrib/render-plugins/example-wasm/wasm_exec.js
Normal file
@ -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;
|
||||
};
|
||||
}
|
||||
}
|
||||
})();
|
||||
30
contrib/render-plugins/example/README.md
Normal file
30
contrib/render-plugins/example/README.md
Normal file
@ -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 `<pre>`
|
||||
|
||||
## 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).
|
||||
9
contrib/render-plugins/example/manifest.json
Normal file
9
contrib/render-plugins/example/manifest.json
Normal file
@ -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"]
|
||||
}
|
||||
28
contrib/render-plugins/example/render.js
Normal file
28
contrib/render-plugins/example/render.js
Normal file
@ -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};
|
||||
@ -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
|
||||
}
|
||||
|
||||
30
models/migrations/v1_26/v324.go
Normal file
30
models/migrations/v1_26/v324.go
Normal file
@ -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))
|
||||
}
|
||||
125
models/render/plugin.go
Normal file
125
models/render/plugin.go
Normal file
@ -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
|
||||
}
|
||||
105
modules/renderplugin/manifest.go
Normal file
105
modules/renderplugin/manifest.go
Normal file
@ -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"`
|
||||
}
|
||||
32
modules/renderplugin/path.go
Normal file
32
modules/renderplugin/path.go
Normal file
@ -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 + "/"
|
||||
}
|
||||
16
modules/setting/render_plugin.go
Normal file
16
modules/setting/render_plugin.go
Normal file
@ -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
|
||||
}
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
165
routers/web/admin/render_plugins.go
Normal file
165
routers/web/admin/render_plugins.go
Normal file
@ -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")
|
||||
}
|
||||
93
routers/web/renderplugin/assets.go
Normal file
93
routers/web/renderplugin/assets.go
Normal file
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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 {
|
||||
|
||||
@ -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)
|
||||
|
||||
283
services/renderplugin/service.go
Normal file
283
services/renderplugin/service.go
Normal file
@ -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
|
||||
})
|
||||
}
|
||||
@ -38,6 +38,9 @@
|
||||
{{ctx.Locale.Tr "packages.title"}}
|
||||
</a>
|
||||
{{end}}
|
||||
<a class="{{if .PageIsAdminRenderPlugins}}active {{end}}item" href="{{AppSubUrl}}/-/admin/render-plugins">
|
||||
{{ctx.Locale.Tr "admin.render_plugins"}}
|
||||
</a>
|
||||
<a class="{{if .PageIsAdminRepositories}}active {{end}}item" href="{{AppSubUrl}}/-/admin/repos">
|
||||
{{ctx.Locale.Tr "admin.repositories"}}
|
||||
</a>
|
||||
|
||||
112
templates/admin/render/plugin_detail.tmpl
Normal file
112
templates/admin/render/plugin_detail.tmpl
Normal file
@ -0,0 +1,112 @@
|
||||
{{template "admin/layout_head" (dict "ctxData" . "pageClass" "admin render-plugin-detail")}}
|
||||
<div class="admin-setting-content">
|
||||
<h4 class="ui top attached header">
|
||||
{{.Plugin.Name}}
|
||||
<div class="ui right">
|
||||
<a class="ui small button" href="{{AppSubUrl}}/-/admin/render-plugins">{{ctx.Locale.Tr "admin.render_plugins.back_to_list"}}</a>
|
||||
</div>
|
||||
<div class="sub header tw-text-normal">{{ctx.Locale.Tr "admin.render_plugins.detail_title" .Plugin.Name}}</div>
|
||||
</h4>
|
||||
<div class="ui attached segment">
|
||||
<table class="ui very basic definition table">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>{{ctx.Locale.Tr "admin.render_plugins.detail.format_version"}}</td>
|
||||
<td>{{.Plugin.FormatVersion}}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>{{ctx.Locale.Tr "admin.render_plugins.table.version"}}</td>
|
||||
<td>{{.Plugin.Version}}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>{{ctx.Locale.Tr "admin.render_plugins.table.identifier"}}</td>
|
||||
<td>{{.Plugin.Identifier}}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>{{ctx.Locale.Tr "admin.render_plugins.table.status"}}</td>
|
||||
<td>
|
||||
{{if .Plugin.Enabled}}
|
||||
<span class="ui green basic label">{{ctx.Locale.Tr "admin.render_plugins.status.enabled"}}</span>
|
||||
{{else}}
|
||||
<span class="ui grey basic label">{{ctx.Locale.Tr "admin.render_plugins.status.disabled"}}</span>
|
||||
{{end}}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>{{ctx.Locale.Tr "admin.render_plugins.detail.description"}}</td>
|
||||
<td>
|
||||
{{if .Plugin.Description}}
|
||||
<div class="tw-whitespace-pre-wrap">{{.Plugin.Description}}</div>
|
||||
{{else}}
|
||||
<span class="text light">{{ctx.Locale.Tr "admin.render_plugins.detail.description_empty"}}</span>
|
||||
{{end}}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>{{ctx.Locale.Tr "admin.render_plugins.detail.entry"}}</td>
|
||||
<td>{{.Plugin.Entry}}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>{{ctx.Locale.Tr "admin.render_plugins.detail.source"}}</td>
|
||||
<td>
|
||||
{{if .Plugin.Source}}
|
||||
{{.Plugin.Source}}
|
||||
{{else}}
|
||||
<span class="text light">{{ctx.Locale.Tr "admin.render_plugins.detail.none"}}</span>
|
||||
{{end}}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>{{ctx.Locale.Tr "admin.render_plugins.table.patterns"}}</td>
|
||||
<td>
|
||||
{{if .Plugin.FilePatterns}}
|
||||
{{range $i, $pattern := .Plugin.FilePatterns}}{{if $i}}, {{end}}{{$pattern}}{{end}}
|
||||
{{else}}
|
||||
<span class="text light">{{ctx.Locale.Tr "admin.render_plugins.detail.file_patterns_empty"}}</span>
|
||||
{{end}}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<h4 class="ui top attached header">
|
||||
{{ctx.Locale.Tr "admin.render_plugins.detail.actions"}}
|
||||
</h4>
|
||||
<div class="ui attached segment">
|
||||
<div class="tw-flex tw-flex-wrap tw-gap-2">
|
||||
{{if .Plugin.Enabled}}
|
||||
<form method="post" action="{{AppSubUrl}}/-/admin/render-plugins/{{.Plugin.ID}}/disable">
|
||||
{{.CsrfTokenHtml}}
|
||||
<input type="hidden" name="redirect_to" value="{{AppSubUrl}}/-/admin/render-plugins/{{.Plugin.ID}}">
|
||||
<button class="ui button" type="submit">{{ctx.Locale.Tr "admin.render_plugins.disable"}}</button>
|
||||
</form>
|
||||
{{else}}
|
||||
<form method="post" action="{{AppSubUrl}}/-/admin/render-plugins/{{.Plugin.ID}}/enable">
|
||||
{{.CsrfTokenHtml}}
|
||||
<input type="hidden" name="redirect_to" value="{{AppSubUrl}}/-/admin/render-plugins/{{.Plugin.ID}}">
|
||||
<button class="ui primary button" type="submit">{{ctx.Locale.Tr "admin.render_plugins.enable"}}</button>
|
||||
</form>
|
||||
{{end}}
|
||||
<form method="post" action="{{AppSubUrl}}/-/admin/render-plugins/{{.Plugin.ID}}/delete">
|
||||
{{.CsrfTokenHtml}}
|
||||
<button class="ui red button" type="submit" data-confirm="{{ctx.Locale.Tr "admin.render_plugins.delete_confirm" .Plugin.Name}}">{{ctx.Locale.Tr "admin.render_plugins.delete"}}</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<h4 class="ui top attached header">
|
||||
{{ctx.Locale.Tr "admin.render_plugins.detail.upgrade"}}
|
||||
</h4>
|
||||
<div class="ui attached segment">
|
||||
<form class="ui form" method="post" action="{{AppSubUrl}}/-/admin/render-plugins/{{.Plugin.ID}}/upgrade" enctype="multipart/form-data">
|
||||
{{.CsrfTokenHtml}}
|
||||
<input type="hidden" name="redirect_to" value="{{AppSubUrl}}/-/admin/render-plugins/{{.Plugin.ID}}">
|
||||
<div class="field">
|
||||
<label>{{ctx.Locale.Tr "admin.render_plugins.upload_label"}}</label>
|
||||
<input type="file" name="plugin" accept=".zip" required>
|
||||
</div>
|
||||
<button class="ui primary button" type="submit">{{ctx.Locale.Tr "admin.render_plugins.upgrade"}}</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{template "admin/layout_footer" .}}
|
||||
65
templates/admin/render/plugins.tmpl
Normal file
65
templates/admin/render/plugins.tmpl
Normal file
@ -0,0 +1,65 @@
|
||||
{{template "admin/layout_head" (dict "ctxData" . "pageClass" "admin render-plugins")}}
|
||||
<div class="admin-setting-content">
|
||||
<h4 class="ui top attached header">
|
||||
{{ctx.Locale.Tr "admin.render_plugins"}}
|
||||
<div class="sub header tw-text-normal">{{ctx.Locale.Tr "admin.render_plugins.description"}}</div>
|
||||
</h4>
|
||||
<div class="ui attached segment">
|
||||
<form class="ui form" method="post" action="{{AppSubUrl}}/-/admin/render-plugins/upload" enctype="multipart/form-data">
|
||||
{{.CsrfTokenHtml}}
|
||||
<div class="field">
|
||||
<label>{{ctx.Locale.Tr "admin.render_plugins.upload_label"}}</label>
|
||||
<input type="file" name="plugin" accept=".zip" required>
|
||||
</div>
|
||||
<button class="ui primary button" type="submit">{{ctx.Locale.Tr "admin.render_plugins.install"}}</button>
|
||||
</form>
|
||||
<div class="tw-mt-2 tw-text-sm tw-text-secondary">
|
||||
{{ctx.Locale.Tr "admin.render_plugins.example_hint"}}
|
||||
</div>
|
||||
</div>
|
||||
<div class="ui attached table segment">
|
||||
<table class="ui very basic striped table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ctx.Locale.Tr "admin.render_plugins.table.name"}}</th>
|
||||
<th>{{ctx.Locale.Tr "admin.render_plugins.table.identifier"}}</th>
|
||||
<th>{{ctx.Locale.Tr "admin.render_plugins.table.version"}}</th>
|
||||
<th>{{ctx.Locale.Tr "admin.render_plugins.table.patterns"}}</th>
|
||||
<th>{{ctx.Locale.Tr "admin.render_plugins.table.status"}}</th>
|
||||
<th class="tw-text-right">{{ctx.Locale.Tr "admin.render_plugins.table.actions"}}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .Plugins}}
|
||||
<tr>
|
||||
<td>
|
||||
<div>{{.Name}}</div>
|
||||
<div class="text light tw-text-sm">{{.Description}}</div>
|
||||
</td>
|
||||
<td>{{.Identifier}}</td>
|
||||
<td>{{.Version}}</td>
|
||||
<td class="tw-text-sm">
|
||||
{{range $i, $pattern := .FilePatterns}}{{if $i}}, {{end}}{{$pattern}}{{end}}
|
||||
</td>
|
||||
<td>
|
||||
{{if .Enabled}}
|
||||
<span class="ui green basic label">{{ctx.Locale.Tr "admin.render_plugins.status.enabled"}}</span>
|
||||
{{else}}
|
||||
<span class="ui grey basic label">{{ctx.Locale.Tr "admin.render_plugins.status.disabled"}}</span>
|
||||
{{end}}
|
||||
</td>
|
||||
<td class="tw-text-right">
|
||||
<a class="ui mini basic button" href="{{AppSubUrl}}/-/admin/render-plugins/{{.ID}}">{{ctx.Locale.Tr "view"}}</a>
|
||||
</td>
|
||||
</tr>
|
||||
{{else}}
|
||||
<tr>
|
||||
<td class="tw-text-center" colspan="6">{{ctx.Locale.Tr "admin.render_plugins.empty"}}</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{template "admin/layout_footer" .}}
|
||||
@ -1,5 +1,6 @@
|
||||
<div {{if .ReadmeInList}}id="readme"{{end}} class="{{TabSizeClass .Editorconfig .FileTreePath}} non-diff-file-content"
|
||||
data-global-init="initRepoFileView" data-raw-file-link="{{.RawFileLink}}">
|
||||
data-global-init="initRepoFileView" data-raw-file-link="{{.RawFileLink}}"
|
||||
data-mime-type="{{.RenderFileMimeType}}"{{if .RenderFileHeadChunk}} data-head-chunk="{{.RenderFileHeadChunk}}"{{end}}>
|
||||
|
||||
{{- if .FileError}}
|
||||
<div class="ui error message">
|
||||
|
||||
@ -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<void> | 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;
|
||||
}
|
||||
}
|
||||
|
||||
function findFileRenderPlugin(filename: string, mimeType: string): FileRenderPlugin | null {
|
||||
return plugins.find((plugin) => plugin.canHandle(filename, mimeType)) || null;
|
||||
async function initPluginsOnce(): Promise<void> {
|
||||
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, 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<HTMLElement>('.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);
|
||||
});
|
||||
}
|
||||
|
||||
@ -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<void>;
|
||||
render: (container: HTMLElement, fileUrl: string, options?: FileRenderOptions) => Promise<void>;
|
||||
};
|
||||
|
||||
@ -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);
|
||||
},
|
||||
|
||||
96
web_src/js/render/plugins/dynamic-plugin.ts
Normal file
96
web_src/js/render/plugins/dynamic-plugin.ts
Normal file
@ -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<void>;
|
||||
};
|
||||
|
||||
const moduleCache = new Map<string, Promise<RemotePluginModule>>();
|
||||
const SUPPORTED_SCHEMA_VERSION = 1;
|
||||
|
||||
async function fetchRemoteMetadata(): Promise<RemotePluginMeta[]> {
|
||||
const base = window.config.appSubUrl || '';
|
||||
const response = await window.fetch(`${base}/assets/render-plugins/index.json`, {headers: {'Accept': 'application/json'}});
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to load render plugin metadata (${response.status})`);
|
||||
}
|
||||
return response.json() as Promise<RemotePluginMeta[]>;
|
||||
}
|
||||
|
||||
async function loadRemoteModule(meta: RemotePluginMeta): Promise<RemotePluginModule> {
|
||||
let cached = moduleCache.get(meta.id);
|
||||
if (!cached) {
|
||||
cached = (async () => {
|
||||
try {
|
||||
const mod = await import(/* webpackIgnore: true */ meta.entryUrl);
|
||||
const exported = (mod?.default ?? mod) as RemotePluginModule | undefined;
|
||||
if (!exported || typeof exported.render !== 'function') {
|
||||
throw new Error(`Plugin ${meta.id} does not export a render() function`);
|
||||
}
|
||||
return exported;
|
||||
} catch (err) {
|
||||
moduleCache.delete(meta.id);
|
||||
throw err;
|
||||
}
|
||||
})();
|
||||
moduleCache.set(meta.id, cached);
|
||||
}
|
||||
return cached;
|
||||
}
|
||||
|
||||
function createMatcher(patterns: string[]) {
|
||||
const compiled = patterns.map((pattern) => {
|
||||
const normalized = pattern.toLowerCase();
|
||||
try {
|
||||
return globCompile(normalized);
|
||||
} catch (err) {
|
||||
console.error('Failed to compile render plugin glob pattern', pattern, err);
|
||||
return null;
|
||||
}
|
||||
}).filter(Boolean) as ReturnType<typeof globCompile>[];
|
||||
return (filename: string) => {
|
||||
const lower = filename.toLowerCase();
|
||||
return compiled.some((glob) => glob.regexp.test(lower));
|
||||
};
|
||||
}
|
||||
|
||||
function wrapRemotePlugin(meta: RemotePluginMeta): FileRenderPlugin {
|
||||
const matcher = createMatcher(meta.filePatterns);
|
||||
return {
|
||||
name: meta.name,
|
||||
canHandle(filename: string, _mimeType: string, _headChunk?: Uint8Array | null) {
|
||||
return matcher(filename);
|
||||
},
|
||||
async render(container, fileUrl, options) {
|
||||
const remote = await loadRemoteModule(meta);
|
||||
await remote.render(container, fileUrl, options);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function loadDynamicRenderPlugins(): Promise<FileRenderPlugin[]> {
|
||||
try {
|
||||
const metadata = await fetchRemoteMetadata();
|
||||
return metadata.filter((meta) => {
|
||||
if (meta.schemaVersion !== SUPPORTED_SCHEMA_VERSION) {
|
||||
console.warn(`Render plugin ${meta.id} ignored due to incompatible schemaVersion ${meta.schemaVersion}`);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}).map((meta) => wrapRemotePlugin(meta));
|
||||
} catch (err) {
|
||||
console.error('Failed to load dynamic render plugins', err);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@ -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');
|
||||
},
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user