Skip to content
35 changes: 35 additions & 0 deletions contrib/render-plugins/example/README.md
Original file line number Diff line number Diff line change
@@ -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).
10 changes: 10 additions & 0 deletions contrib/render-plugins/example/manifest.json
Original file line number Diff line number Diff line change
@@ -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": []
}
28 changes: 28 additions & 0 deletions contrib/render-plugins/example/render.js
Original file line number Diff line number Diff line change
@@ -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};
1 change: 1 addition & 0 deletions models/migrations/migrations.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
31 changes: 31 additions & 0 deletions models/migrations/v1_26/v324.go
Original file line number Diff line number Diff line change
@@ -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))
}
126 changes: 126 additions & 0 deletions models/render/plugin.go
Original file line number Diff line number Diff line change
@@ -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
}
133 changes: 133 additions & 0 deletions modules/renderplugin/manifest.go
Original file line number Diff line number Diff line change
@@ -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"`
}
Loading