Lunny Xiao 2025-12-11 10:25:52 +07:00 committed by GitHub
commit af357be574
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
30 changed files with 2178 additions and 39 deletions

@ -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 `<pre>`
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).

@ -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": []
}

@ -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
}

@ -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))
}

@ -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
}

@ -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"`
}

@ -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)
}

@ -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 + "/"
}

@ -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,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

@ -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)
}

@ -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)
}
}

@ -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,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)

@ -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
})
}

@ -44,30 +44,24 @@
</div>
</details>
<!-- Webhooks and OAuth can be both disabled here, so add this if statement to display different ui -->
{{if and (not DisableWebhooks) .EnableOAuth2}}
<details class="item toggleable-item" {{if or .PageIsAdminDefaultHooks .PageIsAdminSystemHooks .PageIsAdminApplications}}open{{end}}>
<summary>{{ctx.Locale.Tr "admin.integrations"}}</summary>
<div class="menu">
<a class="{{if .PageIsAdminApplications}}active {{end}}item" href="{{AppSubUrl}}/-/admin/applications">
{{ctx.Locale.Tr "settings.applications"}}
</a>
<details class="item toggleable-item" {{if or .PageIsAdminDefaultHooks .PageIsAdminSystemHooks .PageIsAdminApplications .PageIsAdminRenderPlugins}}open{{end}}>
<summary>{{ctx.Locale.Tr "admin.integrations"}}</summary>
<div class="menu">
<a class="{{if .PageIsAdminRenderPlugins}}active {{end}}item" href="{{AppSubUrl}}/-/admin/render-plugins">
{{ctx.Locale.Tr "admin.render_plugins"}}
</a>
{{if not DisableWebhooks}}
<a class="{{if or .PageIsAdminDefaultHooks .PageIsAdminSystemHooks}}active {{end}}item" href="{{AppSubUrl}}/-/admin/hooks">
{{ctx.Locale.Tr "admin.hooks"}}
</a>
</div>
</details>
{{else}}
{{if not DisableWebhooks}}
<a class="{{if or .PageIsAdminDefaultHooks .PageIsAdminSystemHooks}}active {{end}}item" href="{{AppSubUrl}}/-/admin/hooks">
{{ctx.Locale.Tr "admin.hooks"}}
</a>
{{end}}
{{if .EnableOAuth2}}
<a class="{{if .PageIsAdminApplications}}active {{end}}item" href="{{AppSubUrl}}/-/admin/applications">
{{ctx.Locale.Tr "settings.applications"}}
</a>
{{end}}
{{end}}
{{end}}
{{if .EnableOAuth2}}
<a class="{{if .PageIsAdminApplications}}active {{end}}item" href="{{AppSubUrl}}/-/admin/applications">
{{ctx.Locale.Tr "settings.applications"}}
</a>
{{end}}
</div>
</details>
{{if .EnableActions}}
<details class="item toggleable-item" {{if or .PageIsSharedSettingsRunners .PageIsSharedSettingsVariables}}open{{end}}>
<summary>{{ctx.Locale.Tr "actions.actions"}}</summary>

@ -0,0 +1,89 @@
{{template "admin/layout_head" (dict "ctxData" . "pageClass" "admin render-plugins")}}
<div class="admin-render-plugin-confirm tw-max-w-4xl tw-mx-auto">
<h2 class="tw-mb-4">
{{if .IsUpgradePreview}}
{{ctx.Locale.Tr "admin.render_plugins.confirm_upgrade" .CurrentPlugin.Name}}
{{else}}
{{ctx.Locale.Tr "admin.render_plugins.confirm_install" .PluginManifest.Name}}
{{end}}
</h2>
<div class="ui message">
{{ctx.Locale.Tr "admin.render_plugins.confirm.description"}}
</div>
<div class="ui segments tw-mb-4">
<div class="ui segment">
<h3>{{ctx.Locale.Tr "admin.render_plugins.detail.actions"}}</h3>
<table class="ui very basic table">
<tbody>
<tr>
<th class="tw-w-48">{{ctx.Locale.Tr "admin.render_plugins.table.name"}}</th>
<td>{{.PluginManifest.Name}}</td>
</tr>
<tr>
<th>{{ctx.Locale.Tr "admin.render_plugins.table.identifier"}}</th>
<td>{{.PluginManifest.ID}}</td>
</tr>
<tr>
<th>{{ctx.Locale.Tr "admin.render_plugins.table.version"}}</th>
<td>{{.PluginManifest.Version}}</td>
</tr>
<tr>
<th>{{ctx.Locale.Tr "admin.render_plugins.confirm.archive"}}</th>
<td>{{.UploadFilename}}</td>
</tr>
</tbody>
</table>
</div>
<div class="ui segment">
<h3>{{ctx.Locale.Tr "admin.render_plugins.confirm.permissions"}}</h3>
<p class="tw-text-sm tw-text-gray-500">{{ctx.Locale.Tr "admin.render_plugins.confirm.permission_hint"}}</p>
{{if .PluginManifest.Permissions}}
<ul class="tw-list-disc tw-ml-6">
{{range .PluginManifest.Permissions}}
<li><code>{{.}}</code></li>
{{end}}
</ul>
{{else}}
<p>{{ctx.Locale.Tr "admin.render_plugins.confirm.permission_none"}}</p>
{{end}}
</div>
{{if .PluginManifest.Description}}
<div class="ui segment">
<h3>{{ctx.Locale.Tr "admin.render_plugins.detail.description"}}</h3>
<p>{{.PluginManifest.Description}}</p>
</div>
{{end}}
{{if .IsUpgradePreview}}
<div class="ui segment">
<h3>{{ctx.Locale.Tr "admin.render_plugins.detail.actions"}}</h3>
<p>{{ctx.Locale.Tr "admin.render_plugins.detail.entry"}}: {{.PluginManifest.Entry}}</p>
</div>
{{end}}
</div>
<div class="tw-flex tw-gap-2">
<form class="ui form" method="post" action="{{if .IsUpgradePreview}}{{AppSubUrl}}/-/admin/render-plugins/{{.CurrentPlugin.ID}}/upgrade/confirm{{else}}{{AppSubUrl}}/-/admin/render-plugins/upload/confirm{{end}}">
{{.CsrfTokenHtml}}
<input type="hidden" name="token" value="{{.PendingUploadToken}}">
{{if .RedirectTo}}
<input type="hidden" name="redirect_to" value="{{.RedirectTo}}">
{{end}}
<button class="ui primary button" type="submit">
{{if .IsUpgradePreview}}
{{ctx.Locale.Tr "admin.render_plugins.confirm.actions.upgrade"}}
{{else}}
{{ctx.Locale.Tr "admin.render_plugins.confirm.actions.install"}}
{{end}}
</button>
</form>
<form class="ui form" method="post" action="{{AppSubUrl}}/-/admin/render-plugins/upload/discard">
{{.CsrfTokenHtml}}
<input type="hidden" name="token" value="{{.PendingUploadToken}}">
{{if .RedirectTo}}
<input type="hidden" name="redirect_to" value="{{.RedirectTo}}">
{{end}}
<button class="ui button" type="submit">{{ctx.Locale.Tr "admin.render_plugins.confirm.actions.cancel"}}</button>
</form>
</div>
</div>
{{template "admin/layout_footer" .}}

@ -0,0 +1,122 @@
{{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>
<tr>
<td>{{ctx.Locale.Tr "admin.render_plugins.detail.permissions"}}</td>
<td>
{{if .Plugin.Permissions}}
{{range $i, $perm := .Plugin.Permissions}}{{if $i}}, {{end}}<code>{{$perm}}</code>{{end}}
{{else}}
<span class="text light">{{ctx.Locale.Tr "admin.render_plugins.detail.none"}}</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" .}}

@ -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">

@ -0,0 +1,195 @@
// Copyright 2025 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package integration
import (
"archive/zip"
"bytes"
"fmt"
"mime/multipart"
"net/http"
"path"
"strconv"
"strings"
"testing"
render_model "code.gitea.io/gitea/models/render"
"code.gitea.io/gitea/models/unittest"
"code.gitea.io/gitea/modules/json"
"code.gitea.io/gitea/modules/renderplugin"
"code.gitea.io/gitea/modules/storage"
"code.gitea.io/gitea/modules/web/middleware"
"code.gitea.io/gitea/tests"
"github.com/PuerkitoBio/goquery"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestRenderPluginLifecycle(t *testing.T) {
defer tests.PrepareTestEnv(t)()
require.NoError(t, storage.Clean(renderplugin.Storage()))
t.Cleanup(func() {
_ = storage.Clean(renderplugin.Storage())
})
const pluginID = "itest-plugin"
session := loginUser(t, "user1")
uploadArchive(t, session, "/-/admin/render-plugins/upload", buildRenderPluginArchive(t, pluginID, "Integration Plugin", "1.0.0"))
flash := expectFlashSuccess(t, session)
assert.Contains(t, flash.SuccessMsg, "installed")
row := requireRenderPluginRow(t, session, pluginID)
assert.Equal(t, "1.0.0", row.Version)
assert.False(t, row.Enabled)
postPluginAction(t, session, fmt.Sprintf("/-/admin/render-plugins/%d/enable", row.ID))
flash = expectFlashSuccess(t, session)
assert.Contains(t, flash.SuccessMsg, "enabled")
row = requireRenderPluginRow(t, session, pluginID)
assert.True(t, row.Enabled)
metas := fetchRenderPluginMetadata(t)
require.Len(t, metas, 1)
assert.Equal(t, pluginID, metas[0].ID)
assert.Contains(t, metas[0].EntryURL, "render.js")
MakeRequest(t, NewRequest(t, "GET", metas[0].EntryURL), http.StatusOK)
uploadArchive(t, session, fmt.Sprintf("/-/admin/render-plugins/%d/upgrade", row.ID), buildRenderPluginArchive(t, pluginID, "Integration Plugin", "2.0.0"))
flash = expectFlashSuccess(t, session)
assert.Contains(t, flash.SuccessMsg, "upgraded")
row = requireRenderPluginRow(t, session, pluginID)
assert.Equal(t, "2.0.0", row.Version)
postPluginAction(t, session, fmt.Sprintf("/-/admin/render-plugins/%d/disable", row.ID))
flash = expectFlashSuccess(t, session)
assert.Contains(t, flash.SuccessMsg, "disabled")
row = requireRenderPluginRow(t, session, pluginID)
assert.False(t, row.Enabled)
require.Empty(t, fetchRenderPluginMetadata(t))
postPluginAction(t, session, fmt.Sprintf("/-/admin/render-plugins/%d/delete", row.ID))
flash = expectFlashSuccess(t, session)
assert.Contains(t, flash.SuccessMsg, "deleted")
unittest.AssertNotExistsBean(t, &render_model.Plugin{Identifier: pluginID})
_, err := renderplugin.Storage().Stat(renderplugin.ObjectPath(pluginID, "render.js"))
assert.Error(t, err)
require.Nil(t, findRenderPluginRow(t, session, pluginID))
}
func postPluginAction(t *testing.T, session *TestSession, path string) {
req := NewRequestWithValues(t, "POST", path, map[string]string{
"_csrf": GetUserCSRFToken(t, session),
})
session.MakeRequest(t, req, http.StatusSeeOther)
}
func uploadArchive(t *testing.T, session *TestSession, path string, archive []byte) {
var body bytes.Buffer
writer := multipart.NewWriter(&body)
require.NoError(t, writer.WriteField("_csrf", GetUserCSRFToken(t, session)))
part, err := writer.CreateFormFile("plugin", "plugin.zip")
require.NoError(t, err)
_, err = part.Write(archive)
require.NoError(t, err)
require.NoError(t, writer.Close())
req := NewRequestWithBody(t, "POST", path, bytes.NewReader(body.Bytes()))
req.Header.Set("Content-Type", writer.FormDataContentType())
resp := session.MakeRequest(t, req, http.StatusOK)
doc := NewHTMLParser(t, resp.Body)
token := doc.GetInputValueByName("token")
require.NotEmpty(t, token, "pending upload token not found")
confirmReq := NewRequestWithValues(t, "POST", path+"/confirm", map[string]string{
"_csrf": GetUserCSRFToken(t, session),
"token": token,
})
session.MakeRequest(t, confirmReq, http.StatusSeeOther)
}
func buildRenderPluginArchive(t *testing.T, id, name, version string) []byte {
manifest := fmt.Sprintf(`{
"schemaVersion": 1,
"id": %q,
"name": %q,
"version": %q,
"description": "integration test plugin",
"entry": "render.js",
"filePatterns": ["*.itest"]
}`, id, name, version)
var buf bytes.Buffer
zipWriter := zip.NewWriter(&buf)
file, err := zipWriter.Create("manifest.json")
require.NoError(t, err)
_, err = file.Write([]byte(manifest))
require.NoError(t, err)
file, err = zipWriter.Create("render.js")
require.NoError(t, err)
_, err = file.Write([]byte("export default {render(){}};"))
require.NoError(t, err)
require.NoError(t, zipWriter.Close())
return buf.Bytes()
}
func fetchRenderPluginMetadata(t *testing.T) []renderplugin.Metadata {
resp := MakeRequest(t, NewRequest(t, "GET", "/assets/render-plugins/index.json"), http.StatusOK)
var metas []renderplugin.Metadata
require.NoError(t, json.Unmarshal(resp.Body.Bytes(), &metas))
return metas
}
func expectFlashSuccess(t *testing.T, session *TestSession) *middleware.Flash {
flash := session.GetCookieFlashMessage()
require.NotNil(t, flash, "expected flash message")
require.Empty(t, flash.ErrorMsg)
return flash
}
type renderPluginRow struct {
ID int64
Identifier string
Version string
Enabled bool
}
func requireRenderPluginRow(t *testing.T, session *TestSession, identifier string) *renderPluginRow {
row := findRenderPluginRow(t, session, identifier)
require.NotNil(t, row, "plugin %s not found", identifier)
return row
}
func findRenderPluginRow(t *testing.T, session *TestSession, identifier string) *renderPluginRow {
resp := session.MakeRequest(t, NewRequest(t, "GET", "/-/admin/render-plugins"), http.StatusOK)
doc := NewHTMLParser(t, resp.Body)
var result *renderPluginRow
doc.Find("table tbody tr").EachWithBreak(func(_ int, s *goquery.Selection) bool {
cols := s.Find("td")
if cols.Length() < 6 {
return true
}
idText := strings.TrimSpace(cols.Eq(1).Text())
if idText != identifier {
return true
}
link := cols.Eq(5).Find("a[href]").First()
href, _ := link.Attr("href")
id, err := strconv.ParseInt(path.Base(href), 10, 64)
if err != nil {
return true
}
version := strings.TrimSpace(cols.Eq(2).Text())
enabled := cols.Eq(4).Find(".ui.green").Length() > 0
result = &renderPluginRow{
ID: id,
Identifier: idText,
Version: version,
Enabled: enabled,
}
return false
})
return result
}

@ -0,0 +1,26 @@
import {Buffer} from 'node:buffer';
import {describe, expect, it, vi} from 'vitest';
import {decodeHeadChunk} from './file-view.ts';
describe('decodeHeadChunk', () => {
it('returns null when input is empty', () => {
expect(decodeHeadChunk(null)).toBeNull();
expect(decodeHeadChunk('')).toBeNull();
});
it('decodes base64 content into a Uint8Array', () => {
const data = 'Gitea Render Plugin';
const encoded = Buffer.from(data, 'utf-8').toString('base64');
const decoded = decodeHeadChunk(encoded);
expect(decoded).not.toBeNull();
expect(new TextDecoder().decode(decoded!)).toBe(data);
});
it('logs and returns null for invalid input', () => {
const spy = vi.spyOn(console, 'error').mockImplementation(() => {});
const result = decodeHeadChunk('%invalid-base64%');
expect(result).toBeNull();
expect(spy).toHaveBeenCalled();
spy.mockRestore();
});
});

@ -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());
export function decodeHeadChunk(value: string | null): Uint8Array | null {
if (!value) return null;
try {
const binary = window.atob(value);
const buffer = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) {
buffer[i] = binary.charCodeAt(i);
}
return buffer;
} catch (err) {
console.error('Failed to decode render plugin head chunk', err);
return null;
}
}
async function initPluginsOnce(): Promise<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): FileRenderPlugin | null {
return plugins.find((plugin) => plugin.canHandle(filename, mimeType)) || null;
function findFileRenderPlugin(filename: string, mimeType: string, headChunk: Uint8Array | null): FileRenderPlugin | null {
return plugins.find((plugin) => plugin.canHandle(filename, mimeType, headChunk)) || null;
}
function showRenderRawFileButton(elFileView: HTMLElement, renderContainer: HTMLElement | null): void {
@ -26,17 +54,17 @@ function showRenderRawFileButton(elFileView: HTMLElement, renderContainer: HTMLE
// TODO: if there is only one button, hide it?
}
async function renderRawFileToContainer(container: HTMLElement, rawFileLink: string, mimeType: string) {
async function renderRawFileToContainer(container: HTMLElement, rawFileLink: string, mimeType: string, headChunk: Uint8Array | null) {
const elViewRawPrompt = container.querySelector('.file-view-raw-prompt');
if (!rawFileLink || !elViewRawPrompt) throw new Error('unexpected file view container');
let rendered = false, errorMsg = '';
try {
const plugin = findFileRenderPlugin(basename(rawFileLink), mimeType);
const plugin = findFileRenderPlugin(basename(rawFileLink), mimeType, headChunk);
if (plugin) {
container.classList.add('is-loading');
container.setAttribute('data-render-name', plugin.name); // not used yet
await plugin.render(container, rawFileLink);
await plugin.render(container, rawFileLink, {mimeType, headChunk});
rendered = true;
}
} catch (e) {
@ -61,16 +89,16 @@ async function renderRawFileToContainer(container: HTMLElement, rawFileLink: str
export function initRepoFileView(): void {
registerGlobalInitFunc('initRepoFileView', async (elFileView: HTMLElement) => {
initPluginsOnce();
await initPluginsOnce();
const rawFileLink = elFileView.getAttribute('data-raw-file-link')!;
const mimeType = elFileView.getAttribute('data-mime-type') || ''; // not used yet
// TODO: we should also provide the prefetched file head bytes to let the plugin decide whether to render or not
const plugin = findFileRenderPlugin(basename(rawFileLink), mimeType);
const mimeType = elFileView.getAttribute('data-mime-type') || '';
const headChunk = decodeHeadChunk(elFileView.getAttribute('data-head-chunk'));
const plugin = findFileRenderPlugin(basename(rawFileLink), mimeType, headChunk);
if (!plugin) return;
const renderContainer = elFileView.querySelector<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);
},

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

@ -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');
},