Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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