mirror of
https://github.com/go-gitea/gitea.git
synced 2025-12-14 21:15:18 +08:00
Compare commits
13 Commits
55a54bda5c
...
ff3e0c9c40
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ff3e0c9c40 | ||
|
|
7190519fb3 | ||
|
|
1f5237e0d7 | ||
|
|
29057ea55f | ||
|
|
ac8308b5cb | ||
|
|
3310c6ad45 | ||
|
|
09c0c456a8 | ||
|
|
40ddacfe4f | ||
|
|
63fc35ea22 | ||
|
|
d22069de0b | ||
|
|
89af9e2f01 | ||
|
|
75fd8b5b3e | ||
|
|
2c56b90cd4 |
35
contrib/render-plugins/example/README.md
Normal file
35
contrib/render-plugins/example/README.md
Normal file
@ -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
contrib/render-plugins/example/manifest.json
Normal file
10
contrib/render-plugins/example/manifest.json
Normal file
@ -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
contrib/render-plugins/example/render.js
Normal file
28
contrib/render-plugins/example/render.js
Normal file
@ -0,0 +1,28 @@
|
||||
const TEXT_COLOR = '#f6e05e';
|
||||
const BACKGROUND_COLOR = '#1a202c';
|
||||
|
||||
async function render(container, fileUrl) {
|
||||
container.innerHTML = '';
|
||||
|
||||
const message = document.createElement('div');
|
||||
message.className = 'ui tiny message';
|
||||
message.textContent = 'Rendered by example-highlight-txt plugin';
|
||||
container.append(message);
|
||||
|
||||
const response = await fetch(fileUrl);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to download file (${response.status})`);
|
||||
}
|
||||
const text = await response.text();
|
||||
|
||||
const pre = document.createElement('pre');
|
||||
pre.style.backgroundColor = BACKGROUND_COLOR;
|
||||
pre.style.color = TEXT_COLOR;
|
||||
pre.style.padding = '1rem';
|
||||
pre.style.borderRadius = '0.5rem';
|
||||
pre.style.overflow = 'auto';
|
||||
pre.textContent = text;
|
||||
container.append(pre);
|
||||
}
|
||||
|
||||
export default {render};
|
||||
@ -398,6 +398,7 @@ func prepareMigrationTasks() []*migration {
|
||||
// Gitea 1.25.0 ends at migration ID number 322 (database version 323)
|
||||
|
||||
newMigration(323, "Add support for actions concurrency", v1_26.AddActionsConcurrency),
|
||||
newMigration(324, "Add frontend render plugin table", v1_26.AddRenderPluginTable),
|
||||
}
|
||||
return preparedMigrations
|
||||
}
|
||||
|
||||
31
models/migrations/v1_26/v324.go
Normal file
31
models/migrations/v1_26/v324.go
Normal file
@ -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
models/render/plugin.go
Normal file
126
models/render/plugin.go
Normal file
@ -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
|
||||
}
|
||||
@ -5,12 +5,10 @@ package charset
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"code.gitea.io/gitea/modules/log"
|
||||
"code.gitea.io/gitea/modules/setting"
|
||||
"code.gitea.io/gitea/modules/util"
|
||||
|
||||
@ -23,60 +21,39 @@ import (
|
||||
var UTF8BOM = []byte{'\xef', '\xbb', '\xbf'}
|
||||
|
||||
type ConvertOpts struct {
|
||||
KeepBOM bool
|
||||
KeepBOM bool
|
||||
ErrorReplacement []byte
|
||||
ErrorReturnOrigin bool
|
||||
}
|
||||
|
||||
var ToUTF8WithFallbackReaderPrefetchSize = 16 * 1024
|
||||
|
||||
// ToUTF8WithFallbackReader detects the encoding of content and converts to UTF-8 reader if possible
|
||||
func ToUTF8WithFallbackReader(rd io.Reader, opts ConvertOpts) io.Reader {
|
||||
buf := make([]byte, 2048)
|
||||
buf := make([]byte, ToUTF8WithFallbackReaderPrefetchSize)
|
||||
n, err := util.ReadAtMost(rd, buf)
|
||||
if err != nil {
|
||||
return io.MultiReader(bytes.NewReader(MaybeRemoveBOM(buf[:n], opts)), rd)
|
||||
}
|
||||
|
||||
charsetLabel, err := DetectEncoding(buf[:n])
|
||||
if err != nil || charsetLabel == "UTF-8" {
|
||||
return io.MultiReader(bytes.NewReader(MaybeRemoveBOM(buf[:n], opts)), rd)
|
||||
}
|
||||
|
||||
encoding, _ := charset.Lookup(charsetLabel)
|
||||
if encoding == nil {
|
||||
// read error occurs, don't do any processing
|
||||
return io.MultiReader(bytes.NewReader(buf[:n]), rd)
|
||||
}
|
||||
|
||||
return transform.NewReader(
|
||||
io.MultiReader(
|
||||
bytes.NewReader(MaybeRemoveBOM(buf[:n], opts)),
|
||||
rd,
|
||||
),
|
||||
encoding.NewDecoder(),
|
||||
)
|
||||
}
|
||||
|
||||
// ToUTF8 converts content to UTF8 encoding
|
||||
func ToUTF8(content []byte, opts ConvertOpts) (string, error) {
|
||||
charsetLabel, err := DetectEncoding(content)
|
||||
if err != nil {
|
||||
return "", err
|
||||
} else if charsetLabel == "UTF-8" {
|
||||
return string(MaybeRemoveBOM(content, opts)), nil
|
||||
charsetLabel, _ := DetectEncoding(buf[:n])
|
||||
if charsetLabel == "UTF-8" {
|
||||
// is utf-8, try to remove BOM and read it as-is
|
||||
return io.MultiReader(bytes.NewReader(maybeRemoveBOM(buf[:n], opts)), rd)
|
||||
}
|
||||
|
||||
encoding, _ := charset.Lookup(charsetLabel)
|
||||
if encoding == nil {
|
||||
return string(content), fmt.Errorf("Unknown encoding: %s", charsetLabel)
|
||||
// unknown charset, don't do any processing
|
||||
return io.MultiReader(bytes.NewReader(buf[:n]), rd)
|
||||
}
|
||||
|
||||
// If there is an error, we concatenate the nicely decoded part and the
|
||||
// original left over. This way we won't lose much data.
|
||||
result, n, err := transform.Bytes(encoding.NewDecoder(), content)
|
||||
if err != nil {
|
||||
result = append(result, content[n:]...)
|
||||
}
|
||||
|
||||
result = MaybeRemoveBOM(result, opts)
|
||||
|
||||
return string(result), err
|
||||
// convert from charset to utf-8
|
||||
return transform.NewReader(
|
||||
io.MultiReader(bytes.NewReader(buf[:n]), rd),
|
||||
encoding.NewDecoder(),
|
||||
)
|
||||
}
|
||||
|
||||
// ToUTF8WithFallback detects the encoding of content and converts to UTF-8 if possible
|
||||
@ -85,73 +62,84 @@ func ToUTF8WithFallback(content []byte, opts ConvertOpts) []byte {
|
||||
return bs
|
||||
}
|
||||
|
||||
// ToUTF8DropErrors makes sure the return string is valid utf-8; attempts conversion if possible
|
||||
func ToUTF8DropErrors(content []byte, opts ConvertOpts) []byte {
|
||||
charsetLabel, err := DetectEncoding(content)
|
||||
if err != nil || charsetLabel == "UTF-8" {
|
||||
return MaybeRemoveBOM(content, opts)
|
||||
func ToUTF8DropErrors(content []byte) []byte {
|
||||
return ToUTF8(content, ConvertOpts{ErrorReplacement: []byte{' '}})
|
||||
}
|
||||
|
||||
func ToUTF8(content []byte, opts ConvertOpts) []byte {
|
||||
charsetLabel, _ := DetectEncoding(content)
|
||||
if charsetLabel == "UTF-8" {
|
||||
return maybeRemoveBOM(content, opts)
|
||||
}
|
||||
|
||||
encoding, _ := charset.Lookup(charsetLabel)
|
||||
if encoding == nil {
|
||||
setting.PanicInDevOrTesting("unsupported detected charset %q, it shouldn't happen", charsetLabel)
|
||||
return content
|
||||
}
|
||||
|
||||
// We ignore any non-decodable parts from the file.
|
||||
// Some parts might be lost
|
||||
var decoded []byte
|
||||
decoder := encoding.NewDecoder()
|
||||
idx := 0
|
||||
for {
|
||||
for idx < len(content) {
|
||||
result, n, err := transform.Bytes(decoder, content[idx:])
|
||||
decoded = append(decoded, result...)
|
||||
if err == nil {
|
||||
break
|
||||
}
|
||||
decoded = append(decoded, ' ')
|
||||
idx = idx + n + 1
|
||||
if idx >= len(content) {
|
||||
break
|
||||
if opts.ErrorReturnOrigin {
|
||||
return content
|
||||
}
|
||||
if opts.ErrorReplacement == nil {
|
||||
decoded = append(decoded, content[idx+n])
|
||||
} else {
|
||||
decoded = append(decoded, opts.ErrorReplacement...)
|
||||
}
|
||||
idx += n + 1
|
||||
}
|
||||
|
||||
return MaybeRemoveBOM(decoded, opts)
|
||||
return maybeRemoveBOM(decoded, opts)
|
||||
}
|
||||
|
||||
// MaybeRemoveBOM removes a UTF-8 BOM from a []byte when opts.KeepBOM is false
|
||||
func MaybeRemoveBOM(content []byte, opts ConvertOpts) []byte {
|
||||
// maybeRemoveBOM removes a UTF-8 BOM from a []byte when opts.KeepBOM is false
|
||||
func maybeRemoveBOM(content []byte, opts ConvertOpts) []byte {
|
||||
if opts.KeepBOM {
|
||||
return content
|
||||
}
|
||||
if len(content) > 2 && bytes.Equal(content[0:3], UTF8BOM) {
|
||||
return content[3:]
|
||||
}
|
||||
return content
|
||||
return bytes.TrimPrefix(content, UTF8BOM)
|
||||
}
|
||||
|
||||
// DetectEncoding detect the encoding of content
|
||||
func DetectEncoding(content []byte) (string, error) {
|
||||
// it always returns a detected or guessed "encoding" string, no matter error happens or not
|
||||
func DetectEncoding(content []byte) (encoding string, _ error) {
|
||||
// First we check if the content represents valid utf8 content excepting a truncated character at the end.
|
||||
|
||||
// Now we could decode all the runes in turn but this is not necessarily the cheapest thing to do
|
||||
// instead we walk backwards from the end to trim off a the incomplete character
|
||||
// instead we walk backwards from the end to trim off the incomplete character
|
||||
toValidate := content
|
||||
end := len(toValidate) - 1
|
||||
|
||||
if end < 0 {
|
||||
// no-op
|
||||
} else if toValidate[end]>>5 == 0b110 {
|
||||
// Incomplete 1 byte extension e.g. © <c2><a9> which has been truncated to <c2>
|
||||
toValidate = toValidate[:end]
|
||||
} else if end > 0 && toValidate[end]>>6 == 0b10 && toValidate[end-1]>>4 == 0b1110 {
|
||||
// Incomplete 2 byte extension e.g. ⛔ <e2><9b><94> which has been truncated to <e2><9b>
|
||||
toValidate = toValidate[:end-1]
|
||||
} else if end > 1 && toValidate[end]>>6 == 0b10 && toValidate[end-1]>>6 == 0b10 && toValidate[end-2]>>3 == 0b11110 {
|
||||
// Incomplete 3 byte extension e.g. 💩 <f0><9f><92><a9> which has been truncated to <f0><9f><92>
|
||||
toValidate = toValidate[:end-2]
|
||||
// U+0000 U+007F 0yyyzzzz
|
||||
// U+0080 U+07FF 110xxxyy 10yyzzzz
|
||||
// U+0800 U+FFFF 1110wwww 10xxxxyy 10yyzzzz
|
||||
// U+010000 U+10FFFF 11110uvv 10vvwwww 10xxxxyy 10yyzzzz
|
||||
cnt := 0
|
||||
for end >= 0 && cnt < 4 {
|
||||
c := toValidate[end]
|
||||
if c>>5 == 0b110 || c>>4 == 0b1110 || c>>3 == 0b11110 {
|
||||
// a leading byte
|
||||
toValidate = toValidate[:end]
|
||||
break
|
||||
} else if c>>6 == 0b10 {
|
||||
// a continuation byte
|
||||
end--
|
||||
} else {
|
||||
// not an utf-8 byte
|
||||
break
|
||||
}
|
||||
cnt++
|
||||
}
|
||||
|
||||
if utf8.Valid(toValidate) {
|
||||
log.Debug("Detected encoding: utf-8 (fast)")
|
||||
return "UTF-8", nil
|
||||
}
|
||||
|
||||
@ -160,7 +148,7 @@ func DetectEncoding(content []byte) (string, error) {
|
||||
if len(content) < 1024 {
|
||||
// Check if original content is valid
|
||||
if _, err := textDetector.DetectBest(content); err != nil {
|
||||
return "", err
|
||||
return util.IfZero(setting.Repository.AnsiCharset, "UTF-8"), err
|
||||
}
|
||||
times := 1024 / len(content)
|
||||
detectContent = make([]byte, 0, times*len(content))
|
||||
@ -171,14 +159,10 @@ func DetectEncoding(content []byte) (string, error) {
|
||||
detectContent = content
|
||||
}
|
||||
|
||||
// Now we can't use DetectBest or just results[0] because the result isn't stable - so we need a tie break
|
||||
// Now we can't use DetectBest or just results[0] because the result isn't stable - so we need a tie-break
|
||||
results, err := textDetector.DetectAll(detectContent)
|
||||
if err != nil {
|
||||
if err == chardet.NotDetectedError && len(setting.Repository.AnsiCharset) > 0 {
|
||||
log.Debug("Using default AnsiCharset: %s", setting.Repository.AnsiCharset)
|
||||
return setting.Repository.AnsiCharset, nil
|
||||
}
|
||||
return "", err
|
||||
return util.IfZero(setting.Repository.AnsiCharset, "UTF-8"), err
|
||||
}
|
||||
|
||||
topConfidence := results[0].Confidence
|
||||
@ -201,11 +185,9 @@ func DetectEncoding(content []byte) (string, error) {
|
||||
}
|
||||
|
||||
// FIXME: to properly decouple this function the fallback ANSI charset should be passed as an argument
|
||||
if topResult.Charset != "UTF-8" && len(setting.Repository.AnsiCharset) > 0 {
|
||||
log.Debug("Using default AnsiCharset: %s", setting.Repository.AnsiCharset)
|
||||
if topResult.Charset != "UTF-8" && setting.Repository.AnsiCharset != "" {
|
||||
return setting.Repository.AnsiCharset, err
|
||||
}
|
||||
|
||||
log.Debug("Detected encoding: %s", topResult.Charset)
|
||||
return topResult.Charset, err
|
||||
return topResult.Charset, nil
|
||||
}
|
||||
|
||||
@ -4,108 +4,89 @@
|
||||
package charset
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"code.gitea.io/gitea/modules/setting"
|
||||
"code.gitea.io/gitea/modules/test"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func resetDefaultCharsetsOrder() {
|
||||
defaultDetectedCharsetsOrder := make([]string, 0, len(setting.Repository.DetectedCharsetsOrder))
|
||||
for _, charset := range setting.Repository.DetectedCharsetsOrder {
|
||||
defaultDetectedCharsetsOrder = append(defaultDetectedCharsetsOrder, strings.ToLower(strings.TrimSpace(charset)))
|
||||
}
|
||||
func TestMain(m *testing.M) {
|
||||
setting.Repository.DetectedCharsetScore = map[string]int{}
|
||||
i := 0
|
||||
for _, charset := range defaultDetectedCharsetsOrder {
|
||||
canonicalCharset := strings.ToLower(strings.TrimSpace(charset))
|
||||
if _, has := setting.Repository.DetectedCharsetScore[canonicalCharset]; !has {
|
||||
setting.Repository.DetectedCharsetScore[canonicalCharset] = i
|
||||
i++
|
||||
}
|
||||
for i, charset := range setting.Repository.DetectedCharsetsOrder {
|
||||
setting.Repository.DetectedCharsetScore[strings.ToLower(charset)] = i
|
||||
}
|
||||
os.Exit(m.Run())
|
||||
}
|
||||
|
||||
func TestMaybeRemoveBOM(t *testing.T) {
|
||||
res := MaybeRemoveBOM([]byte{0xc3, 0xa1, 0xc3, 0xa9, 0xc3, 0xad, 0xc3, 0xb3, 0xc3, 0xba}, ConvertOpts{})
|
||||
res := maybeRemoveBOM([]byte{0xc3, 0xa1, 0xc3, 0xa9, 0xc3, 0xad, 0xc3, 0xb3, 0xc3, 0xba}, ConvertOpts{})
|
||||
assert.Equal(t, []byte{0xc3, 0xa1, 0xc3, 0xa9, 0xc3, 0xad, 0xc3, 0xb3, 0xc3, 0xba}, res)
|
||||
|
||||
res = MaybeRemoveBOM([]byte{0xef, 0xbb, 0xbf, 0xc3, 0xa1, 0xc3, 0xa9, 0xc3, 0xad, 0xc3, 0xb3, 0xc3, 0xba}, ConvertOpts{})
|
||||
res = maybeRemoveBOM([]byte{0xef, 0xbb, 0xbf, 0xc3, 0xa1, 0xc3, 0xa9, 0xc3, 0xad, 0xc3, 0xb3, 0xc3, 0xba}, ConvertOpts{})
|
||||
assert.Equal(t, []byte{0xc3, 0xa1, 0xc3, 0xa9, 0xc3, 0xad, 0xc3, 0xb3, 0xc3, 0xba}, res)
|
||||
}
|
||||
|
||||
func TestToUTF8(t *testing.T) {
|
||||
resetDefaultCharsetsOrder()
|
||||
|
||||
// Note: golang compiler seems so behave differently depending on the current
|
||||
// locale, so some conversions might behave differently. For that reason, we don't
|
||||
// depend on particular conversions but in expected behaviors.
|
||||
|
||||
res, err := ToUTF8([]byte{0x41, 0x42, 0x43}, ConvertOpts{})
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "ABC", res)
|
||||
res := ToUTF8([]byte{0x41, 0x42, 0x43}, ConvertOpts{})
|
||||
assert.Equal(t, "ABC", string(res))
|
||||
|
||||
// "áéíóú"
|
||||
res, err = ToUTF8([]byte{0xc3, 0xa1, 0xc3, 0xa9, 0xc3, 0xad, 0xc3, 0xb3, 0xc3, 0xba}, ConvertOpts{})
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, []byte{0xc3, 0xa1, 0xc3, 0xa9, 0xc3, 0xad, 0xc3, 0xb3, 0xc3, 0xba}, []byte(res))
|
||||
res = ToUTF8([]byte{0xc3, 0xa1, 0xc3, 0xa9, 0xc3, 0xad, 0xc3, 0xb3, 0xc3, 0xba}, ConvertOpts{})
|
||||
assert.Equal(t, []byte{0xc3, 0xa1, 0xc3, 0xa9, 0xc3, 0xad, 0xc3, 0xb3, 0xc3, 0xba}, res)
|
||||
|
||||
// "áéíóú"
|
||||
res, err = ToUTF8([]byte{
|
||||
res = ToUTF8([]byte{
|
||||
0xef, 0xbb, 0xbf, 0xc3, 0xa1, 0xc3, 0xa9, 0xc3, 0xad, 0xc3, 0xb3,
|
||||
0xc3, 0xba,
|
||||
}, ConvertOpts{})
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, []byte{0xc3, 0xa1, 0xc3, 0xa9, 0xc3, 0xad, 0xc3, 0xb3, 0xc3, 0xba}, []byte(res))
|
||||
assert.Equal(t, []byte{0xc3, 0xa1, 0xc3, 0xa9, 0xc3, 0xad, 0xc3, 0xb3, 0xc3, 0xba}, res)
|
||||
|
||||
res, err = ToUTF8([]byte{
|
||||
res = ToUTF8([]byte{
|
||||
0x48, 0x6F, 0x6C, 0x61, 0x2C, 0x20, 0x61, 0x73, 0xED, 0x20, 0x63,
|
||||
0xF3, 0x6D, 0x6F, 0x20, 0xF1, 0x6F, 0x73, 0x41, 0x41, 0x41, 0x2e,
|
||||
}, ConvertOpts{})
|
||||
assert.NoError(t, err)
|
||||
stringMustStartWith(t, "Hola,", res)
|
||||
stringMustEndWith(t, "AAA.", res)
|
||||
|
||||
res, err = ToUTF8([]byte{
|
||||
res = ToUTF8([]byte{
|
||||
0x48, 0x6F, 0x6C, 0x61, 0x2C, 0x20, 0x61, 0x73, 0xED, 0x20, 0x63,
|
||||
0xF3, 0x6D, 0x6F, 0x20, 0x07, 0xA4, 0x6F, 0x73, 0x41, 0x41, 0x41, 0x2e,
|
||||
}, ConvertOpts{})
|
||||
assert.NoError(t, err)
|
||||
stringMustStartWith(t, "Hola,", res)
|
||||
stringMustEndWith(t, "AAA.", res)
|
||||
|
||||
res, err = ToUTF8([]byte{
|
||||
res = ToUTF8([]byte{
|
||||
0x48, 0x6F, 0x6C, 0x61, 0x2C, 0x20, 0x61, 0x73, 0xED, 0x20, 0x63,
|
||||
0xF3, 0x6D, 0x6F, 0x20, 0x81, 0xA4, 0x6F, 0x73, 0x41, 0x41, 0x41, 0x2e,
|
||||
}, ConvertOpts{})
|
||||
assert.NoError(t, err)
|
||||
stringMustStartWith(t, "Hola,", res)
|
||||
stringMustEndWith(t, "AAA.", res)
|
||||
|
||||
// Japanese (Shift-JIS)
|
||||
// 日属秘ぞしちゅ。
|
||||
res, err = ToUTF8([]byte{
|
||||
res = ToUTF8([]byte{
|
||||
0x93, 0xFA, 0x91, 0xAE, 0x94, 0xE9, 0x82, 0xBC, 0x82, 0xB5, 0x82,
|
||||
0xBF, 0x82, 0xE3, 0x81, 0x42,
|
||||
}, ConvertOpts{})
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, []byte{
|
||||
0xE6, 0x97, 0xA5, 0xE5, 0xB1, 0x9E, 0xE7, 0xA7, 0x98, 0xE3,
|
||||
0x81, 0x9E, 0xE3, 0x81, 0x97, 0xE3, 0x81, 0xA1, 0xE3, 0x82, 0x85, 0xE3, 0x80, 0x82,
|
||||
},
|
||||
[]byte(res))
|
||||
}, res)
|
||||
|
||||
res, err = ToUTF8([]byte{0x00, 0x00, 0x00, 0x00}, ConvertOpts{})
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, []byte{0x00, 0x00, 0x00, 0x00}, []byte(res))
|
||||
res = ToUTF8([]byte{0x00, 0x00, 0x00, 0x00}, ConvertOpts{})
|
||||
assert.Equal(t, []byte{0x00, 0x00, 0x00, 0x00}, res)
|
||||
}
|
||||
|
||||
func TestToUTF8WithFallback(t *testing.T) {
|
||||
resetDefaultCharsetsOrder()
|
||||
// "ABC"
|
||||
res := ToUTF8WithFallback([]byte{0x41, 0x42, 0x43}, ConvertOpts{})
|
||||
assert.Equal(t, []byte{0x41, 0x42, 0x43}, res)
|
||||
@ -152,54 +133,58 @@ func TestToUTF8WithFallback(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestToUTF8DropErrors(t *testing.T) {
|
||||
resetDefaultCharsetsOrder()
|
||||
// "ABC"
|
||||
res := ToUTF8DropErrors([]byte{0x41, 0x42, 0x43}, ConvertOpts{})
|
||||
res := ToUTF8DropErrors([]byte{0x41, 0x42, 0x43})
|
||||
assert.Equal(t, []byte{0x41, 0x42, 0x43}, res)
|
||||
|
||||
// "áéíóú"
|
||||
res = ToUTF8DropErrors([]byte{0xc3, 0xa1, 0xc3, 0xa9, 0xc3, 0xad, 0xc3, 0xb3, 0xc3, 0xba}, ConvertOpts{})
|
||||
res = ToUTF8DropErrors([]byte{0xc3, 0xa1, 0xc3, 0xa9, 0xc3, 0xad, 0xc3, 0xb3, 0xc3, 0xba})
|
||||
assert.Equal(t, []byte{0xc3, 0xa1, 0xc3, 0xa9, 0xc3, 0xad, 0xc3, 0xb3, 0xc3, 0xba}, res)
|
||||
|
||||
// UTF8 BOM + "áéíóú"
|
||||
res = ToUTF8DropErrors([]byte{0xef, 0xbb, 0xbf, 0xc3, 0xa1, 0xc3, 0xa9, 0xc3, 0xad, 0xc3, 0xb3, 0xc3, 0xba}, ConvertOpts{})
|
||||
res = ToUTF8DropErrors([]byte{0xef, 0xbb, 0xbf, 0xc3, 0xa1, 0xc3, 0xa9, 0xc3, 0xad, 0xc3, 0xb3, 0xc3, 0xba})
|
||||
assert.Equal(t, []byte{0xc3, 0xa1, 0xc3, 0xa9, 0xc3, 0xad, 0xc3, 0xb3, 0xc3, 0xba}, res)
|
||||
|
||||
// "Hola, así cómo ños"
|
||||
res = ToUTF8DropErrors([]byte{0x48, 0x6F, 0x6C, 0x61, 0x2C, 0x20, 0x61, 0x73, 0xED, 0x20, 0x63, 0xF3, 0x6D, 0x6F, 0x20, 0xF1, 0x6F, 0x73}, ConvertOpts{})
|
||||
res = ToUTF8DropErrors([]byte{0x48, 0x6F, 0x6C, 0x61, 0x2C, 0x20, 0x61, 0x73, 0xED, 0x20, 0x63, 0xF3, 0x6D, 0x6F, 0x20, 0xF1, 0x6F, 0x73})
|
||||
assert.Equal(t, []byte{0x48, 0x6F, 0x6C, 0x61, 0x2C, 0x20, 0x61, 0x73}, res[:8])
|
||||
assert.Equal(t, []byte{0x73}, res[len(res)-1:])
|
||||
|
||||
// "Hola, así cómo "
|
||||
minmatch := []byte{0x48, 0x6F, 0x6C, 0x61, 0x2C, 0x20, 0x61, 0x73, 0xC3, 0xAD, 0x20, 0x63, 0xC3, 0xB3, 0x6D, 0x6F, 0x20}
|
||||
|
||||
res = ToUTF8DropErrors([]byte{0x48, 0x6F, 0x6C, 0x61, 0x2C, 0x20, 0x61, 0x73, 0xED, 0x20, 0x63, 0xF3, 0x6D, 0x6F, 0x20, 0x07, 0xA4, 0x6F, 0x73}, ConvertOpts{})
|
||||
res = ToUTF8DropErrors([]byte{0x48, 0x6F, 0x6C, 0x61, 0x2C, 0x20, 0x61, 0x73, 0xED, 0x20, 0x63, 0xF3, 0x6D, 0x6F, 0x20, 0x07, 0xA4, 0x6F, 0x73})
|
||||
// Do not fail for differences in invalid cases, as the library might change the conversion criteria for those
|
||||
assert.Equal(t, minmatch, res[0:len(minmatch)])
|
||||
|
||||
res = ToUTF8DropErrors([]byte{0x48, 0x6F, 0x6C, 0x61, 0x2C, 0x20, 0x61, 0x73, 0xED, 0x20, 0x63, 0xF3, 0x6D, 0x6F, 0x20, 0x81, 0xA4, 0x6F, 0x73}, ConvertOpts{})
|
||||
res = ToUTF8DropErrors([]byte{0x48, 0x6F, 0x6C, 0x61, 0x2C, 0x20, 0x61, 0x73, 0xED, 0x20, 0x63, 0xF3, 0x6D, 0x6F, 0x20, 0x81, 0xA4, 0x6F, 0x73})
|
||||
// Do not fail for differences in invalid cases, as the library might change the conversion criteria for those
|
||||
assert.Equal(t, minmatch, res[0:len(minmatch)])
|
||||
|
||||
// Japanese (Shift-JIS)
|
||||
// "日属秘ぞしちゅ。"
|
||||
res = ToUTF8DropErrors([]byte{0x93, 0xFA, 0x91, 0xAE, 0x94, 0xE9, 0x82, 0xBC, 0x82, 0xB5, 0x82, 0xBF, 0x82, 0xE3, 0x81, 0x42}, ConvertOpts{})
|
||||
res = ToUTF8DropErrors([]byte{0x93, 0xFA, 0x91, 0xAE, 0x94, 0xE9, 0x82, 0xBC, 0x82, 0xB5, 0x82, 0xBF, 0x82, 0xE3, 0x81, 0x42})
|
||||
assert.Equal(t, []byte{
|
||||
0xE6, 0x97, 0xA5, 0xE5, 0xB1, 0x9E, 0xE7, 0xA7, 0x98, 0xE3,
|
||||
0x81, 0x9E, 0xE3, 0x81, 0x97, 0xE3, 0x81, 0xA1, 0xE3, 0x82, 0x85, 0xE3, 0x80, 0x82,
|
||||
}, res)
|
||||
|
||||
res = ToUTF8DropErrors([]byte{0x00, 0x00, 0x00, 0x00}, ConvertOpts{})
|
||||
res = ToUTF8DropErrors([]byte{0x00, 0x00, 0x00, 0x00})
|
||||
assert.Equal(t, []byte{0x00, 0x00, 0x00, 0x00}, res)
|
||||
}
|
||||
|
||||
func TestDetectEncoding(t *testing.T) {
|
||||
resetDefaultCharsetsOrder()
|
||||
testSuccess := func(b []byte, expected string) {
|
||||
encoding, err := DetectEncoding(b)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, expected, encoding)
|
||||
}
|
||||
|
||||
// invalid bytes
|
||||
encoding, err := DetectEncoding([]byte{0xfa})
|
||||
assert.Error(t, err)
|
||||
assert.Equal(t, "UTF-8", encoding)
|
||||
|
||||
// utf-8
|
||||
b := []byte("just some ascii")
|
||||
testSuccess(b, "UTF-8")
|
||||
@ -214,169 +199,49 @@ func TestDetectEncoding(t *testing.T) {
|
||||
|
||||
// iso-8859-1: d<accented e>cor<newline>
|
||||
b = []byte{0x44, 0xe9, 0x63, 0x6f, 0x72, 0x0a}
|
||||
encoding, err := DetectEncoding(b)
|
||||
encoding, err = DetectEncoding(b)
|
||||
assert.NoError(t, err)
|
||||
assert.Contains(t, encoding, "ISO-8859-1")
|
||||
|
||||
old := setting.Repository.AnsiCharset
|
||||
setting.Repository.AnsiCharset = "placeholder"
|
||||
defer func() {
|
||||
setting.Repository.AnsiCharset = old
|
||||
}()
|
||||
testSuccess(b, "placeholder")
|
||||
|
||||
// invalid bytes
|
||||
b = []byte{0xfa}
|
||||
_, err = DetectEncoding(b)
|
||||
assert.Error(t, err)
|
||||
defer test.MockVariableValue(&setting.Repository.AnsiCharset, "MyEncoding")()
|
||||
testSuccess(b, "MyEncoding")
|
||||
}
|
||||
|
||||
func stringMustStartWith(t *testing.T, expected, value string) {
|
||||
assert.Equal(t, expected, value[:len(expected)])
|
||||
func stringMustStartWith(t *testing.T, expected string, value []byte) {
|
||||
assert.Equal(t, expected, string(value[:len(expected)]))
|
||||
}
|
||||
|
||||
func stringMustEndWith(t *testing.T, expected, value string) {
|
||||
assert.Equal(t, expected, value[len(value)-len(expected):])
|
||||
func stringMustEndWith(t *testing.T, expected string, value []byte) {
|
||||
assert.Equal(t, expected, string(value[len(value)-len(expected):]))
|
||||
}
|
||||
|
||||
func TestToUTF8WithFallbackReader(t *testing.T) {
|
||||
resetDefaultCharsetsOrder()
|
||||
test.MockVariableValue(&ToUTF8WithFallbackReaderPrefetchSize)
|
||||
|
||||
for testLen := range 2048 {
|
||||
pattern := " test { () }\n"
|
||||
input := ""
|
||||
for len(input) < testLen {
|
||||
input += pattern
|
||||
}
|
||||
input = input[:testLen]
|
||||
input += "// Выключаем"
|
||||
rd := ToUTF8WithFallbackReader(bytes.NewReader([]byte(input)), ConvertOpts{})
|
||||
block := "aá啊🤔"
|
||||
runes := []rune(block)
|
||||
assert.Len(t, string(runes[0]), 1)
|
||||
assert.Len(t, string(runes[1]), 2)
|
||||
assert.Len(t, string(runes[2]), 3)
|
||||
assert.Len(t, string(runes[3]), 4)
|
||||
|
||||
content := strings.Repeat(block, 2)
|
||||
for i := 1; i < len(content); i++ {
|
||||
encoding, err := DetectEncoding([]byte(content[:i]))
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "UTF-8", encoding)
|
||||
|
||||
ToUTF8WithFallbackReaderPrefetchSize = i
|
||||
rd := ToUTF8WithFallbackReader(strings.NewReader(content), ConvertOpts{})
|
||||
r, _ := io.ReadAll(rd)
|
||||
assert.Equalf(t, input, string(r), "testing string len=%d", testLen)
|
||||
assert.Equal(t, content, string(r))
|
||||
}
|
||||
for _, r := range runes {
|
||||
content = "abc abc " + string(r) + string(r) + string(r)
|
||||
for i := 0; i < len(content); i++ {
|
||||
encoding, err := DetectEncoding([]byte(content[:i]))
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "UTF-8", encoding)
|
||||
}
|
||||
}
|
||||
|
||||
truncatedOneByteExtension := failFastBytes
|
||||
encoding, _ := DetectEncoding(truncatedOneByteExtension)
|
||||
assert.Equal(t, "UTF-8", encoding)
|
||||
|
||||
truncatedTwoByteExtension := failFastBytes
|
||||
truncatedTwoByteExtension[len(failFastBytes)-1] = 0x9b
|
||||
truncatedTwoByteExtension[len(failFastBytes)-2] = 0xe2
|
||||
|
||||
encoding, _ = DetectEncoding(truncatedTwoByteExtension)
|
||||
assert.Equal(t, "UTF-8", encoding)
|
||||
|
||||
truncatedThreeByteExtension := failFastBytes
|
||||
truncatedThreeByteExtension[len(failFastBytes)-1] = 0x92
|
||||
truncatedThreeByteExtension[len(failFastBytes)-2] = 0x9f
|
||||
truncatedThreeByteExtension[len(failFastBytes)-3] = 0xf0
|
||||
|
||||
encoding, _ = DetectEncoding(truncatedThreeByteExtension)
|
||||
assert.Equal(t, "UTF-8", encoding)
|
||||
}
|
||||
|
||||
var failFastBytes = []byte{
|
||||
0x69, 0x6d, 0x70, 0x6f, 0x72, 0x74, 0x20, 0x6f, 0x72, 0x67, 0x2e, 0x61, 0x70, 0x61, 0x63, 0x68, 0x65, 0x2e, 0x74, 0x6f,
|
||||
0x6f, 0x6c, 0x73, 0x2e, 0x61, 0x6e, 0x74, 0x2e, 0x74, 0x61, 0x73, 0x6b, 0x64, 0x65, 0x66, 0x73, 0x2e, 0x63, 0x6f, 0x6e,
|
||||
0x64, 0x69, 0x74, 0x69, 0x6f, 0x6e, 0x2e, 0x4f, 0x73, 0x0a, 0x69, 0x6d, 0x70, 0x6f, 0x72, 0x74, 0x20, 0x6f, 0x72, 0x67,
|
||||
0x2e, 0x73, 0x70, 0x72, 0x69, 0x6e, 0x67, 0x66, 0x72, 0x61, 0x6d, 0x65, 0x77, 0x6f, 0x72, 0x6b, 0x2e, 0x62, 0x6f, 0x6f,
|
||||
0x74, 0x2e, 0x67, 0x72, 0x61, 0x64, 0x6c, 0x65, 0x2e, 0x74, 0x61, 0x73, 0x6b, 0x73, 0x2e, 0x72, 0x75, 0x6e, 0x2e, 0x42,
|
||||
0x6f, 0x6f, 0x74, 0x52, 0x75, 0x6e, 0x0a, 0x0a, 0x70, 0x6c, 0x75, 0x67, 0x69, 0x6e, 0x73, 0x20, 0x7b, 0x0a, 0x20, 0x20,
|
||||
0x20, 0x20, 0x69, 0x64, 0x28, 0x22, 0x6f, 0x72, 0x67, 0x2e, 0x73, 0x70, 0x72, 0x69, 0x6e, 0x67, 0x66, 0x72, 0x61, 0x6d,
|
||||
0x65, 0x77, 0x6f, 0x72, 0x6b, 0x2e, 0x62, 0x6f, 0x6f, 0x74, 0x22, 0x29, 0x0a, 0x7d, 0x0a, 0x0a, 0x64, 0x65, 0x70, 0x65,
|
||||
0x6e, 0x64, 0x65, 0x6e, 0x63, 0x69, 0x65, 0x73, 0x20, 0x7b, 0x0a, 0x20, 0x20, 0x20, 0x20, 0x69, 0x6d, 0x70, 0x6c, 0x65,
|
||||
0x6d, 0x65, 0x6e, 0x74, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x28, 0x70, 0x72, 0x6f, 0x6a, 0x65, 0x63, 0x74, 0x28, 0x22, 0x3a,
|
||||
0x73, 0x65, 0x72, 0x76, 0x65, 0x72, 0x3a, 0x61, 0x70, 0x69, 0x22, 0x29, 0x29, 0x0a, 0x20, 0x20, 0x20, 0x20, 0x69, 0x6d,
|
||||
0x70, 0x6c, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x28, 0x70, 0x72, 0x6f, 0x6a, 0x65, 0x63, 0x74,
|
||||
0x28, 0x22, 0x3a, 0x73, 0x65, 0x72, 0x76, 0x65, 0x72, 0x3a, 0x61, 0x70, 0x69, 0x2d, 0x64, 0x6f, 0x63, 0x73, 0x22, 0x29,
|
||||
0x29, 0x0a, 0x20, 0x20, 0x20, 0x20, 0x69, 0x6d, 0x70, 0x6c, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x61, 0x74, 0x69, 0x6f, 0x6e,
|
||||
0x28, 0x70, 0x72, 0x6f, 0x6a, 0x65, 0x63, 0x74, 0x28, 0x22, 0x3a, 0x73, 0x65, 0x72, 0x76, 0x65, 0x72, 0x3a, 0x64, 0x62,
|
||||
0x22, 0x29, 0x29, 0x0a, 0x20, 0x20, 0x20, 0x20, 0x69, 0x6d, 0x70, 0x6c, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x61, 0x74, 0x69,
|
||||
0x6f, 0x6e, 0x28, 0x70, 0x72, 0x6f, 0x6a, 0x65, 0x63, 0x74, 0x28, 0x22, 0x3a, 0x73, 0x65, 0x72, 0x76, 0x65, 0x72, 0x3a,
|
||||
0x73, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x22, 0x29, 0x29, 0x0a, 0x20, 0x20, 0x20, 0x20, 0x69, 0x6d, 0x70, 0x6c, 0x65,
|
||||
0x6d, 0x65, 0x6e, 0x74, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x28, 0x70, 0x72, 0x6f, 0x6a, 0x65, 0x63, 0x74, 0x28, 0x22, 0x3a,
|
||||
0x73, 0x65, 0x72, 0x76, 0x65, 0x72, 0x3a, 0x69, 0x6e, 0x74, 0x65, 0x67, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x2d, 0x66,
|
||||
0x73, 0x22, 0x29, 0x29, 0x0a, 0x20, 0x20, 0x20, 0x20, 0x69, 0x6d, 0x70, 0x6c, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x61, 0x74,
|
||||
0x69, 0x6f, 0x6e, 0x28, 0x70, 0x72, 0x6f, 0x6a, 0x65, 0x63, 0x74, 0x28, 0x22, 0x3a, 0x73, 0x65, 0x72, 0x76, 0x65, 0x72,
|
||||
0x3a, 0x69, 0x6e, 0x74, 0x65, 0x67, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x2d, 0x6d, 0x71, 0x22, 0x29, 0x29, 0x0a, 0x0a,
|
||||
0x20, 0x20, 0x20, 0x20, 0x69, 0x6d, 0x70, 0x6c, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x28, 0x22,
|
||||
0x6a, 0x66, 0x75, 0x73, 0x69, 0x6f, 0x6e, 0x2e, 0x70, 0x65, 0x3a, 0x70, 0x65, 0x2d, 0x63, 0x6f, 0x6d, 0x6d, 0x6f, 0x6e,
|
||||
0x2d, 0x61, 0x75, 0x74, 0x68, 0x2d, 0x72, 0x65, 0x73, 0x6f, 0x75, 0x72, 0x63, 0x65, 0x2d, 0x73, 0x74, 0x61, 0x72, 0x74,
|
||||
0x65, 0x72, 0x22, 0x29, 0x0a, 0x20, 0x20, 0x20, 0x20, 0x69, 0x6d, 0x70, 0x6c, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x61, 0x74,
|
||||
0x69, 0x6f, 0x6e, 0x28, 0x22, 0x6a, 0x66, 0x75, 0x73, 0x69, 0x6f, 0x6e, 0x2e, 0x70, 0x65, 0x3a, 0x70, 0x65, 0x2d, 0x63,
|
||||
0x6f, 0x6d, 0x6d, 0x6f, 0x6e, 0x2d, 0x68, 0x61, 0x6c, 0x22, 0x29, 0x0a, 0x20, 0x20, 0x20, 0x20, 0x69, 0x6d, 0x70, 0x6c,
|
||||
0x65, 0x6d, 0x65, 0x6e, 0x74, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x28, 0x22, 0x6a, 0x66, 0x75, 0x73, 0x69, 0x6f, 0x6e, 0x2e,
|
||||
0x70, 0x65, 0x3a, 0x70, 0x65, 0x2d, 0x63, 0x6f, 0x6d, 0x6d, 0x6f, 0x6e, 0x2d, 0x63, 0x6f, 0x72, 0x65, 0x22, 0x29, 0x0a,
|
||||
0x0a, 0x20, 0x20, 0x20, 0x20, 0x69, 0x6d, 0x70, 0x6c, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x28,
|
||||
0x22, 0x6f, 0x72, 0x67, 0x2e, 0x73, 0x70, 0x72, 0x69, 0x6e, 0x67, 0x66, 0x72, 0x61, 0x6d, 0x65, 0x77, 0x6f, 0x72, 0x6b,
|
||||
0x2e, 0x62, 0x6f, 0x6f, 0x74, 0x3a, 0x73, 0x70, 0x72, 0x69, 0x6e, 0x67, 0x2d, 0x62, 0x6f, 0x6f, 0x74, 0x2d, 0x73, 0x74,
|
||||
0x61, 0x72, 0x74, 0x65, 0x72, 0x2d, 0x77, 0x65, 0x62, 0x22, 0x29, 0x0a, 0x20, 0x20, 0x20, 0x20, 0x69, 0x6d, 0x70, 0x6c,
|
||||
0x65, 0x6d, 0x65, 0x6e, 0x74, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x28, 0x22, 0x6f, 0x72, 0x67, 0x2e, 0x73, 0x70, 0x72, 0x69,
|
||||
0x6e, 0x67, 0x66, 0x72, 0x61, 0x6d, 0x65, 0x77, 0x6f, 0x72, 0x6b, 0x2e, 0x62, 0x6f, 0x6f, 0x74, 0x3a, 0x73, 0x70, 0x72,
|
||||
0x69, 0x6e, 0x67, 0x2d, 0x62, 0x6f, 0x6f, 0x74, 0x2d, 0x73, 0x74, 0x61, 0x72, 0x74, 0x65, 0x72, 0x2d, 0x61, 0x6f, 0x70,
|
||||
0x22, 0x29, 0x0a, 0x20, 0x20, 0x20, 0x20, 0x69, 0x6d, 0x70, 0x6c, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x61, 0x74, 0x69, 0x6f,
|
||||
0x6e, 0x28, 0x22, 0x6f, 0x72, 0x67, 0x2e, 0x73, 0x70, 0x72, 0x69, 0x6e, 0x67, 0x66, 0x72, 0x61, 0x6d, 0x65, 0x77, 0x6f,
|
||||
0x72, 0x6b, 0x2e, 0x62, 0x6f, 0x6f, 0x74, 0x3a, 0x73, 0x70, 0x72, 0x69, 0x6e, 0x67, 0x2d, 0x62, 0x6f, 0x6f, 0x74, 0x2d,
|
||||
0x73, 0x74, 0x61, 0x72, 0x74, 0x65, 0x72, 0x2d, 0x61, 0x63, 0x74, 0x75, 0x61, 0x74, 0x6f, 0x72, 0x22, 0x29, 0x0a, 0x20,
|
||||
0x20, 0x20, 0x20, 0x69, 0x6d, 0x70, 0x6c, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x28, 0x22, 0x6f,
|
||||
0x72, 0x67, 0x2e, 0x73, 0x70, 0x72, 0x69, 0x6e, 0x67, 0x66, 0x72, 0x61, 0x6d, 0x65, 0x77, 0x6f, 0x72, 0x6b, 0x2e, 0x63,
|
||||
0x6c, 0x6f, 0x75, 0x64, 0x3a, 0x73, 0x70, 0x72, 0x69, 0x6e, 0x67, 0x2d, 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x2d, 0x73, 0x74,
|
||||
0x61, 0x72, 0x74, 0x65, 0x72, 0x2d, 0x62, 0x6f, 0x6f, 0x74, 0x73, 0x74, 0x72, 0x61, 0x70, 0x22, 0x29, 0x0a, 0x20, 0x20,
|
||||
0x20, 0x20, 0x69, 0x6d, 0x70, 0x6c, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x28, 0x22, 0x6f, 0x72,
|
||||
0x67, 0x2e, 0x73, 0x70, 0x72, 0x69, 0x6e, 0x67, 0x66, 0x72, 0x61, 0x6d, 0x65, 0x77, 0x6f, 0x72, 0x6b, 0x2e, 0x63, 0x6c,
|
||||
0x6f, 0x75, 0x64, 0x3a, 0x73, 0x70, 0x72, 0x69, 0x6e, 0x67, 0x2d, 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x2d, 0x73, 0x74, 0x61,
|
||||
0x72, 0x74, 0x65, 0x72, 0x2d, 0x63, 0x6f, 0x6e, 0x73, 0x75, 0x6c, 0x2d, 0x61, 0x6c, 0x6c, 0x22, 0x29, 0x0a, 0x20, 0x20,
|
||||
0x20, 0x20, 0x69, 0x6d, 0x70, 0x6c, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x28, 0x22, 0x6f, 0x72,
|
||||
0x67, 0x2e, 0x73, 0x70, 0x72, 0x69, 0x6e, 0x67, 0x66, 0x72, 0x61, 0x6d, 0x65, 0x77, 0x6f, 0x72, 0x6b, 0x2e, 0x63, 0x6c,
|
||||
0x6f, 0x75, 0x64, 0x3a, 0x73, 0x70, 0x72, 0x69, 0x6e, 0x67, 0x2d, 0x63, 0x6c, 0x6f, 0x75, 0x64, 0x2d, 0x73, 0x74, 0x61,
|
||||
0x72, 0x74, 0x65, 0x72, 0x2d, 0x73, 0x6c, 0x65, 0x75, 0x74, 0x68, 0x22, 0x29, 0x0a, 0x20, 0x20, 0x20, 0x20, 0x69, 0x6d,
|
||||
0x70, 0x6c, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x28, 0x22, 0x6f, 0x72, 0x67, 0x2e, 0x73, 0x70,
|
||||
0x72, 0x69, 0x6e, 0x67, 0x66, 0x72, 0x61, 0x6d, 0x65, 0x77, 0x6f, 0x72, 0x6b, 0x2e, 0x72, 0x65, 0x74, 0x72, 0x79, 0x3a,
|
||||
0x73, 0x70, 0x72, 0x69, 0x6e, 0x67, 0x2d, 0x72, 0x65, 0x74, 0x72, 0x79, 0x22, 0x29, 0x0a, 0x0a, 0x20, 0x20, 0x20, 0x20,
|
||||
0x69, 0x6d, 0x70, 0x6c, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x28, 0x22, 0x63, 0x68, 0x2e, 0x71,
|
||||
0x6f, 0x73, 0x2e, 0x6c, 0x6f, 0x67, 0x62, 0x61, 0x63, 0x6b, 0x3a, 0x6c, 0x6f, 0x67, 0x62, 0x61, 0x63, 0x6b, 0x2d, 0x63,
|
||||
0x6c, 0x61, 0x73, 0x73, 0x69, 0x63, 0x22, 0x29, 0x0a, 0x0a, 0x20, 0x20, 0x20, 0x20, 0x69, 0x6d, 0x70, 0x6c, 0x65, 0x6d,
|
||||
0x65, 0x6e, 0x74, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x28, 0x22, 0x69, 0x6f, 0x2e, 0x6d, 0x69, 0x63, 0x72, 0x6f, 0x6d, 0x65,
|
||||
0x74, 0x65, 0x72, 0x3a, 0x6d, 0x69, 0x63, 0x72, 0x6f, 0x6d, 0x65, 0x74, 0x65, 0x72, 0x2d, 0x72, 0x65, 0x67, 0x69, 0x73,
|
||||
0x74, 0x72, 0x79, 0x2d, 0x70, 0x72, 0x6f, 0x6d, 0x65, 0x74, 0x68, 0x65, 0x75, 0x73, 0x22, 0x29, 0x0a, 0x0a, 0x20, 0x20,
|
||||
0x20, 0x20, 0x69, 0x6d, 0x70, 0x6c, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x28, 0x6b, 0x6f, 0x74,
|
||||
0x6c, 0x69, 0x6e, 0x28, 0x22, 0x73, 0x74, 0x64, 0x6c, 0x69, 0x62, 0x22, 0x29, 0x29, 0x0a, 0x0a, 0x20, 0x20, 0x20, 0x20,
|
||||
0x2f, 0x2f, 0x2f, 0x2f, 0x2f, 0x2f, 0x2f, 0x2f, 0x2f, 0x2f, 0x2f, 0x2f, 0x2f, 0x2f, 0x2f, 0x2f, 0x2f, 0x2f, 0x2f, 0x2f,
|
||||
0x2f, 0x0a, 0x20, 0x20, 0x20, 0x20, 0x2f, 0x2f, 0x20, 0x54, 0x65, 0x73, 0x74, 0x20, 0x64, 0x65, 0x70, 0x65, 0x6e, 0x64,
|
||||
0x65, 0x6e, 0x63, 0x69, 0x65, 0x73, 0x2e, 0x0a, 0x20, 0x20, 0x20, 0x20, 0x2f, 0x2f, 0x2f, 0x2f, 0x2f, 0x2f, 0x2f, 0x2f,
|
||||
0x2f, 0x2f, 0x2f, 0x2f, 0x2f, 0x2f, 0x2f, 0x2f, 0x2f, 0x2f, 0x2f, 0x2f, 0x2f, 0x0a, 0x0a, 0x20, 0x20, 0x20, 0x20, 0x74,
|
||||
0x65, 0x73, 0x74, 0x49, 0x6d, 0x70, 0x6c, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x28, 0x22, 0x6a,
|
||||
0x66, 0x75, 0x73, 0x69, 0x6f, 0x6e, 0x2e, 0x70, 0x65, 0x3a, 0x70, 0x65, 0x2d, 0x63, 0x6f, 0x6d, 0x6d, 0x6f, 0x6e, 0x2d,
|
||||
0x74, 0x65, 0x73, 0x74, 0x22, 0x29, 0x0a, 0x7d, 0x0a, 0x0a, 0x76, 0x61, 0x6c, 0x20, 0x70, 0x61, 0x74, 0x63, 0x68, 0x4a,
|
||||
0x61, 0x72, 0x20, 0x62, 0x79, 0x20, 0x74, 0x61, 0x73, 0x6b, 0x73, 0x2e, 0x72, 0x65, 0x67, 0x69, 0x73, 0x74, 0x65, 0x72,
|
||||
0x69, 0x6e, 0x67, 0x28, 0x4a, 0x61, 0x72, 0x3a, 0x3a, 0x63, 0x6c, 0x61, 0x73, 0x73, 0x29, 0x20, 0x7b, 0x0a, 0x20, 0x20,
|
||||
0x20, 0x20, 0x61, 0x72, 0x63, 0x68, 0x69, 0x76, 0x65, 0x43, 0x6c, 0x61, 0x73, 0x73, 0x69, 0x66, 0x69, 0x65, 0x72, 0x2e,
|
||||
0x73, 0x65, 0x74, 0x28, 0x22, 0x70, 0x61, 0x74, 0x63, 0x68, 0x65, 0x64, 0x22, 0x29, 0x0a, 0x0a, 0x20, 0x20, 0x20, 0x20,
|
||||
0x76, 0x61, 0x6c, 0x20, 0x72, 0x75, 0x6e, 0x74, 0x69, 0x6d, 0x65, 0x43, 0x6c, 0x61, 0x73, 0x73, 0x70, 0x61, 0x74, 0x68,
|
||||
0x20, 0x62, 0x79, 0x20, 0x63, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x75, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x2e, 0x67,
|
||||
0x65, 0x74, 0x74, 0x69, 0x6e, 0x67, 0x0a, 0x0a, 0x20, 0x20, 0x20, 0x20, 0x6d, 0x61, 0x6e, 0x69, 0x66, 0x65, 0x73, 0x74,
|
||||
0x20, 0x7b, 0x0a, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x61, 0x74, 0x74, 0x72, 0x69, 0x62, 0x75, 0x74, 0x65,
|
||||
0x73, 0x28, 0x22, 0x43, 0x6c, 0x61, 0x73, 0x73, 0x2d, 0x50, 0x61, 0x74, 0x68, 0x22, 0x20, 0x74, 0x6f, 0x20, 0x6f, 0x62,
|
||||
0x6a, 0x65, 0x63, 0x74, 0x20, 0x7b, 0x0a, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x70,
|
||||
0x72, 0x69, 0x76, 0x61, 0x74, 0x65, 0x20, 0x76, 0x61, 0x6c, 0x20, 0x70, 0x61, 0x74, 0x74, 0x65, 0x72, 0x6e, 0x20, 0x3d,
|
||||
0x20, 0x22, 0x66, 0x69, 0x6c, 0x65, 0x3a, 0x2f, 0x2b, 0x22, 0x2e, 0x74, 0x6f, 0x52, 0x65, 0x67, 0x65, 0x78, 0x28, 0x29,
|
||||
0x0a, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x6f, 0x76, 0x65, 0x72, 0x72, 0x69, 0x64,
|
||||
0x65, 0x20, 0x66, 0x75, 0x6e, 0x20, 0x74, 0x6f, 0x53, 0x74, 0x72, 0x69, 0x6e, 0x67, 0x28, 0x29, 0x3a, 0x20, 0x53, 0x74,
|
||||
0x72, 0x69, 0x6e, 0x67, 0x20, 0x3d, 0x20, 0x72, 0x75, 0x6e, 0x74, 0x69, 0x6d, 0x65, 0x43, 0x6c, 0x61, 0x73, 0x73, 0x70,
|
||||
0x61, 0x74, 0x68, 0x2e, 0x66, 0x69, 0x6c, 0x65, 0x73, 0x2e, 0x6a, 0x6f, 0x69, 0x6e, 0x54, 0x6f, 0x53, 0x74, 0x72, 0x69,
|
||||
0x6e, 0x67, 0x28, 0x22, 0x20, 0x22, 0x29, 0x20, 0x7b, 0x0a, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20,
|
||||
0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x69, 0x74, 0x2e, 0x74, 0x6f, 0x55, 0x52, 0x49, 0x28, 0x29, 0x2e, 0x74, 0x6f, 0x55,
|
||||
0x52, 0x4c, 0x28, 0x29, 0x2e, 0x74, 0x6f, 0x53, 0x74, 0x72, 0x69, 0x6e, 0x67, 0x28, 0x29, 0x2e, 0x72, 0x65, 0x70, 0x6c,
|
||||
0x61, 0x63, 0x65, 0x46, 0x69, 0x72, 0x73, 0x74, 0x28, 0x70, 0x61, 0x74, 0x74, 0x65, 0x72, 0x6e, 0x2c, 0x20, 0x22, 0x2f,
|
||||
0x22, 0x29, 0x0a, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x7d, 0x0a, 0x20, 0x20, 0x20,
|
||||
0x20, 0x20, 0x20, 0x20, 0x20, 0x7d, 0x29, 0x0a, 0x20, 0x20, 0x20, 0x20, 0x7d, 0x0a, 0x7d, 0x0a, 0x0a, 0x74, 0x61, 0x73,
|
||||
0x6b, 0x73, 0x2e, 0x6e, 0x61, 0x6d, 0x65, 0x64, 0x3c, 0x42, 0x6f, 0x6f, 0x74, 0x52, 0x75, 0x6e, 0x3e, 0x28, 0x22, 0x62,
|
||||
0x6f, 0x6f, 0x74, 0x52, 0x75, 0x6e, 0x22, 0x29, 0x20, 0x7b, 0x0a, 0x20, 0x20, 0x20, 0x20, 0x69, 0x66, 0x20, 0x28, 0x4f,
|
||||
0x73, 0x2e, 0x69, 0x73, 0x46, 0x61, 0x6d, 0x69, 0x6c, 0x79, 0x28, 0x4f, 0x73, 0x2e, 0x46, 0x41, 0x4d, 0x49, 0x4c, 0x59,
|
||||
0x5f, 0x57, 0x49, 0x4e, 0x44, 0x4f, 0x57, 0x53, 0x29, 0x29, 0x20, 0x7b, 0x0a, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20, 0x20,
|
||||
0x20, 0x63, 0x6c, 0x61, 0x73, 0x73, 0x70, 0x61, 0x74, 0x68, 0x20, 0x3d, 0x20, 0x66, 0x69, 0x6c, 0x65, 0x73, 0x28, 0x73,
|
||||
0x6f, 0x75, 0x72, 0x63, 0x65, 0x53, 0x65, 0x74, 0x73, 0x2e, 0x6e, 0x61, 0x6d, 0x65, 0x64, 0x28, 0x22, 0x6d, 0x61, 0x69,
|
||||
0x6e, 0x22, 0x29, 0x2e, 0x6d, 0x61, 0x70, 0x20, 0x7b, 0x20, 0x69, 0x74, 0x2e, 0x6f, 0x75, 0x74, 0x70, 0x75, 0x74, 0x20,
|
||||
0x7d, 0x2c, 0x20, 0x70, 0x61, 0x74, 0x63, 0x68, 0x4a, 0x61, 0x72, 0x29, 0x0a, 0x20, 0x20, 0x20, 0x20, 0x7d, 0x0a, 0x0a,
|
||||
0x20, 0x20, 0x20, 0x20, 0x2f, 0x2f, 0x20, 0xd0,
|
||||
}
|
||||
|
||||
@ -20,14 +20,17 @@ import (
|
||||
// RuneNBSP is the codepoint for NBSP
|
||||
const RuneNBSP = 0xa0
|
||||
|
||||
// EscapeControlHTML escapes the unicode control sequences in a provided html document
|
||||
// EscapeControlHTML escapes the Unicode control sequences in a provided html document
|
||||
func EscapeControlHTML(html template.HTML, locale translation.Locale, allowed ...rune) (escaped *EscapeStatus, output template.HTML) {
|
||||
if !setting.UI.AmbiguousUnicodeDetection {
|
||||
return &EscapeStatus{}, html
|
||||
}
|
||||
sb := &strings.Builder{}
|
||||
escaped, _ = EscapeControlReader(strings.NewReader(string(html)), sb, locale, allowed...) // err has been handled in EscapeControlReader
|
||||
return escaped, template.HTML(sb.String())
|
||||
}
|
||||
|
||||
// EscapeControlReader escapes the unicode control sequences in a provided reader of HTML content and writer in a locale and returns the findings as an EscapeStatus
|
||||
// EscapeControlReader escapes the Unicode control sequences in a provided reader of HTML content and writer in a locale and returns the findings as an EscapeStatus
|
||||
func EscapeControlReader(reader io.Reader, writer io.Writer, locale translation.Locale, allowed ...rune) (escaped *EscapeStatus, err error) {
|
||||
if !setting.UI.AmbiguousUnicodeDetection {
|
||||
_, err = io.Copy(writer, reader)
|
||||
|
||||
@ -56,7 +56,39 @@ func NewContext() {
|
||||
})
|
||||
}
|
||||
|
||||
// Code returns a HTML version of code string with chroma syntax highlighting classes and the matched lexer name
|
||||
// UnsafeSplitHighlightedLines splits highlighted code into lines preserving HTML tags
|
||||
// It always includes '\n', '\n' can appear at the end of each line or in the middle of HTML tags
|
||||
// The '\n' is necessary for copying code from web UI to preserve original code lines
|
||||
// ATTENTION: It uses the unsafe conversion between string and []byte for performance reason
|
||||
// DO NOT make any modification to the returned [][]byte slice items
|
||||
func UnsafeSplitHighlightedLines(code template.HTML) (ret [][]byte) {
|
||||
buf := util.UnsafeStringToBytes(string(code))
|
||||
lineCount := bytes.Count(buf, []byte("\n")) + 1
|
||||
ret = make([][]byte, 0, lineCount)
|
||||
nlTagClose := []byte("\n</")
|
||||
for {
|
||||
pos := bytes.IndexByte(buf, '\n')
|
||||
if pos == -1 {
|
||||
if len(buf) > 0 {
|
||||
ret = append(ret, buf)
|
||||
}
|
||||
return ret
|
||||
}
|
||||
// Chroma highlighting output sometimes have "</span>" right after \n, sometimes before.
|
||||
// * "<span>text\n</span>"
|
||||
// * "<span>text</span>\n"
|
||||
if bytes.HasPrefix(buf[pos:], nlTagClose) {
|
||||
pos1 := bytes.IndexByte(buf[pos:], '>')
|
||||
if pos1 != -1 {
|
||||
pos += pos1
|
||||
}
|
||||
}
|
||||
ret = append(ret, buf[:pos+1])
|
||||
buf = buf[pos+1:]
|
||||
}
|
||||
}
|
||||
|
||||
// Code returns an HTML version of code string with chroma syntax highlighting classes and the matched lexer name
|
||||
func Code(fileName, language, code string) (output template.HTML, lexerName string) {
|
||||
NewContext()
|
||||
|
||||
|
||||
@ -181,3 +181,21 @@ c=2`),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnsafeSplitHighlightedLines(t *testing.T) {
|
||||
ret := UnsafeSplitHighlightedLines("")
|
||||
assert.Empty(t, ret)
|
||||
|
||||
ret = UnsafeSplitHighlightedLines("a")
|
||||
assert.Len(t, ret, 1)
|
||||
assert.Equal(t, "a", string(ret[0]))
|
||||
|
||||
ret = UnsafeSplitHighlightedLines("\n")
|
||||
assert.Len(t, ret, 1)
|
||||
assert.Equal(t, "\n", string(ret[0]))
|
||||
|
||||
ret = UnsafeSplitHighlightedLines("<span>a</span>\n<span>b\n</span>")
|
||||
assert.Len(t, ret, 2)
|
||||
assert.Equal(t, "<span>a</span>\n", string(ret[0]))
|
||||
assert.Equal(t, "<span>b\n</span>", string(ret[1]))
|
||||
}
|
||||
|
||||
@ -19,7 +19,6 @@ import (
|
||||
charsetModule "code.gitea.io/gitea/modules/charset"
|
||||
"code.gitea.io/gitea/modules/container"
|
||||
"code.gitea.io/gitea/modules/httpcache"
|
||||
"code.gitea.io/gitea/modules/log"
|
||||
"code.gitea.io/gitea/modules/setting"
|
||||
"code.gitea.io/gitea/modules/typesniffer"
|
||||
"code.gitea.io/gitea/modules/util"
|
||||
@ -109,11 +108,7 @@ func setServeHeadersByFile(r *http.Request, w http.ResponseWriter, mineBuf []byt
|
||||
}
|
||||
|
||||
if isPlain {
|
||||
charset, err := charsetModule.DetectEncoding(mineBuf)
|
||||
if err != nil {
|
||||
log.Error("Detect raw file %s charset failed: %v, using by default utf-8", opts.Filename, err)
|
||||
charset = "utf-8"
|
||||
}
|
||||
charset, _ := charsetModule.DetectEncoding(mineBuf)
|
||||
opts.ContentTypeCharset = strings.ToLower(charset)
|
||||
}
|
||||
|
||||
|
||||
@ -203,7 +203,7 @@ func (b *Indexer) addUpdate(ctx context.Context, batchWriter git.WriteCloserErro
|
||||
RepoID: repo.ID,
|
||||
CommitID: commitSha,
|
||||
Filename: update.Filename,
|
||||
Content: string(charset.ToUTF8DropErrors(fileContents, charset.ConvertOpts{})),
|
||||
Content: string(charset.ToUTF8DropErrors(fileContents)),
|
||||
Language: analyze.GetCodeLanguage(update.Filename, fileContents),
|
||||
UpdatedAt: time.Now().UTC(),
|
||||
})
|
||||
|
||||
@ -191,7 +191,7 @@ func (b *Indexer) addUpdate(ctx context.Context, batchWriter git.WriteCloserErro
|
||||
Doc(map[string]any{
|
||||
"repo_id": repo.ID,
|
||||
"filename": update.Filename,
|
||||
"content": string(charset.ToUTF8DropErrors(fileContents, charset.ConvertOpts{})),
|
||||
"content": string(charset.ToUTF8DropErrors(fileContents)),
|
||||
"commit_id": sha,
|
||||
"language": analyze.GetCodeLanguage(update.Filename, fileContents),
|
||||
"updated_at": timeutil.TimeStampNow(),
|
||||
|
||||
133
modules/renderplugin/manifest.go
Normal file
133
modules/renderplugin/manifest.go
Normal file
@ -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"`
|
||||
}
|
||||
101
modules/renderplugin/manifest_test.go
Normal file
101
modules/renderplugin/manifest_test.go
Normal file
@ -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)
|
||||
}
|
||||
32
modules/renderplugin/path.go
Normal file
32
modules/renderplugin/path.go
Normal file
@ -0,0 +1,32 @@
|
||||
// Copyright 2025 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package renderplugin
|
||||
|
||||
import (
|
||||
"path"
|
||||
|
||||
"code.gitea.io/gitea/modules/storage"
|
||||
)
|
||||
|
||||
// Storage returns the object storage used for render plugins.
|
||||
func Storage() storage.ObjectStorage {
|
||||
return storage.RenderPlugins
|
||||
}
|
||||
|
||||
// ObjectPath builds a storage-relative path for a plugin asset.
|
||||
func ObjectPath(identifier string, elems ...string) string {
|
||||
joined := path.Join(elems...)
|
||||
if joined == "." || joined == "" {
|
||||
return path.Join(identifier)
|
||||
}
|
||||
return path.Join(identifier, joined)
|
||||
}
|
||||
|
||||
// ObjectPrefix returns the storage prefix for a plugin identifier.
|
||||
func ObjectPrefix(identifier string) string {
|
||||
if identifier == "" {
|
||||
return ""
|
||||
}
|
||||
return identifier + "/"
|
||||
}
|
||||
16
modules/setting/render_plugin.go
Normal file
16
modules/setting/render_plugin.go
Normal file
@ -0,0 +1,16 @@
|
||||
// Copyright 2025 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package setting
|
||||
|
||||
type RenderPluginSetting struct {
|
||||
Storage *Storage
|
||||
}
|
||||
|
||||
var RenderPlugin RenderPluginSetting
|
||||
|
||||
func loadRenderPluginFrom(rootCfg ConfigProvider) (err error) {
|
||||
sec, _ := rootCfg.GetSection("render_plugins")
|
||||
RenderPlugin.Storage, err = getStorage(rootCfg, "render-plugins", "", sec)
|
||||
return err
|
||||
}
|
||||
@ -138,6 +138,9 @@ func loadCommonSettingsFrom(cfg ConfigProvider) error {
|
||||
if err := loadActionsFrom(cfg); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := loadRenderPluginFrom(cfg); err != nil {
|
||||
return err
|
||||
}
|
||||
loadUIFrom(cfg)
|
||||
loadAdminFrom(cfg)
|
||||
loadAPIFrom(cfg)
|
||||
@ -240,4 +243,5 @@ func PanicInDevOrTesting(msg string, a ...any) {
|
||||
if !IsProd || IsInTesting {
|
||||
panic(fmt.Sprintf(msg, a...))
|
||||
}
|
||||
log.Error(msg, a...)
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -152,7 +152,7 @@ func repoAssignment() func(ctx *context.APIContext) {
|
||||
if err != nil {
|
||||
if user_model.IsErrUserNotExist(err) {
|
||||
if redirectUserID, err := user_model.LookupUserRedirect(ctx, userName); err == nil {
|
||||
context.RedirectToUser(ctx.Base, userName, redirectUserID)
|
||||
context.RedirectToUser(ctx.Base, ctx.Doer, userName, redirectUserID)
|
||||
} else if user_model.IsErrUserRedirectNotExist(err) {
|
||||
ctx.APIErrorNotFound("GetUserByName", err)
|
||||
} else {
|
||||
@ -612,7 +612,7 @@ func orgAssignment(args ...bool) func(ctx *context.APIContext) {
|
||||
if organization.IsErrOrgNotExist(err) {
|
||||
redirectUserID, err := user_model.LookupUserRedirect(ctx, ctx.PathParam("org"))
|
||||
if err == nil {
|
||||
context.RedirectToUser(ctx.Base, ctx.PathParam("org"), redirectUserID)
|
||||
context.RedirectToUser(ctx.Base, ctx.Doer, ctx.PathParam("org"), redirectUserID)
|
||||
} else if user_model.IsErrUserRedirectNotExist(err) {
|
||||
ctx.APIErrorNotFound("GetOrgByName", err)
|
||||
} else {
|
||||
|
||||
@ -16,7 +16,7 @@ func GetUserByPathParam(ctx *context.APIContext, name string) *user_model.User {
|
||||
if err != nil {
|
||||
if user_model.IsErrUserNotExist(err) {
|
||||
if redirectUserID, err2 := user_model.LookupUserRedirect(ctx, username); err2 == nil {
|
||||
context.RedirectToUser(ctx.Base, username, redirectUserID)
|
||||
context.RedirectToUser(ctx.Base, ctx.Doer, username, redirectUserID)
|
||||
} else {
|
||||
ctx.APIErrorNotFound("GetUserByName", err)
|
||||
}
|
||||
|
||||
365
routers/web/admin/render_plugins.go
Normal file
365
routers/web/admin/render_plugins.go
Normal file
@ -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)
|
||||
}
|
||||
93
routers/web/renderplugin/assets.go
Normal file
93
routers/web/renderplugin/assets.go
Normal file
@ -0,0 +1,93 @@
|
||||
// Copyright 2025 The Gitea Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package renderplugin
|
||||
|
||||
import (
|
||||
"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)
|
||||
}
|
||||
}
|
||||
@ -4,8 +4,9 @@
|
||||
package repo
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
gotemplate "html/template"
|
||||
"html/template"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"path"
|
||||
@ -25,18 +26,17 @@ import (
|
||||
)
|
||||
|
||||
type blameRow struct {
|
||||
RowNumber int
|
||||
Avatar gotemplate.HTML
|
||||
RepoLink string
|
||||
PartSha string
|
||||
RowNumber int
|
||||
|
||||
Avatar template.HTML
|
||||
PreviousSha string
|
||||
PreviousShaURL string
|
||||
IsFirstCommit bool
|
||||
CommitURL string
|
||||
CommitMessage string
|
||||
CommitSince gotemplate.HTML
|
||||
Code gotemplate.HTML
|
||||
EscapeStatus *charset.EscapeStatus
|
||||
CommitSince template.HTML
|
||||
|
||||
Code template.HTML
|
||||
EscapeStatus *charset.EscapeStatus
|
||||
}
|
||||
|
||||
// RefBlame render blame page
|
||||
@ -220,76 +220,64 @@ func processBlameParts(ctx *context.Context, blameParts []*git.BlamePart) map[st
|
||||
return commitNames
|
||||
}
|
||||
|
||||
func renderBlame(ctx *context.Context, blameParts []*git.BlamePart, commitNames map[string]*user_model.UserCommit) {
|
||||
repoLink := ctx.Repo.RepoLink
|
||||
func renderBlameFillFirstBlameRow(repoLink string, avatarUtils *templates.AvatarUtils, part *git.BlamePart, commit *user_model.UserCommit, br *blameRow) {
|
||||
if commit.User != nil {
|
||||
br.Avatar = avatarUtils.Avatar(commit.User, 18)
|
||||
} else {
|
||||
br.Avatar = avatarUtils.AvatarByEmail(commit.Author.Email, commit.Author.Name, 18)
|
||||
}
|
||||
|
||||
br.PreviousSha = part.PreviousSha
|
||||
br.PreviousShaURL = fmt.Sprintf("%s/blame/commit/%s/%s", repoLink, url.PathEscape(part.PreviousSha), util.PathEscapeSegments(part.PreviousPath))
|
||||
br.CommitURL = fmt.Sprintf("%s/commit/%s", repoLink, url.PathEscape(part.Sha))
|
||||
br.CommitMessage = commit.CommitMessage
|
||||
br.CommitSince = templates.TimeSince(commit.Author.When)
|
||||
}
|
||||
|
||||
func renderBlame(ctx *context.Context, blameParts []*git.BlamePart, commitNames map[string]*user_model.UserCommit) {
|
||||
language, err := languagestats.GetFileLanguage(ctx, ctx.Repo.GitRepo, ctx.Repo.CommitID, ctx.Repo.TreePath)
|
||||
if err != nil {
|
||||
log.Error("Unable to get file language for %-v:%s. Error: %v", ctx.Repo.Repository, ctx.Repo.TreePath, err)
|
||||
}
|
||||
|
||||
lines := make([]string, 0)
|
||||
buf := &bytes.Buffer{}
|
||||
rows := make([]*blameRow, 0)
|
||||
avatarUtils := templates.NewAvatarUtils(ctx)
|
||||
rowNumber := 0 // will be 1-based
|
||||
for _, part := range blameParts {
|
||||
for partLineIdx, line := range part.Lines {
|
||||
rowNumber++
|
||||
|
||||
br := &blameRow{RowNumber: rowNumber}
|
||||
rows = append(rows, br)
|
||||
|
||||
if int64(buf.Len()) < setting.UI.MaxDisplayFileSize {
|
||||
buf.WriteString(line)
|
||||
buf.WriteByte('\n')
|
||||
}
|
||||
|
||||
if partLineIdx == 0 {
|
||||
renderBlameFillFirstBlameRow(ctx.Repo.RepoLink, avatarUtils, part, commitNames[part.Sha], br)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
escapeStatus := &charset.EscapeStatus{}
|
||||
|
||||
var lexerName string
|
||||
|
||||
avatarUtils := templates.NewAvatarUtils(ctx)
|
||||
i := 0
|
||||
commitCnt := 0
|
||||
for _, part := range blameParts {
|
||||
for index, line := range part.Lines {
|
||||
i++
|
||||
lines = append(lines, line)
|
||||
|
||||
br := &blameRow{
|
||||
RowNumber: i,
|
||||
}
|
||||
|
||||
commit := commitNames[part.Sha]
|
||||
if index == 0 {
|
||||
// Count commit number
|
||||
commitCnt++
|
||||
|
||||
// User avatar image
|
||||
commitSince := templates.TimeSince(commit.Author.When)
|
||||
|
||||
var avatar string
|
||||
if commit.User != nil {
|
||||
avatar = string(avatarUtils.Avatar(commit.User, 18))
|
||||
} else {
|
||||
avatar = string(avatarUtils.AvatarByEmail(commit.Author.Email, commit.Author.Name, 18, "tw-mr-2"))
|
||||
}
|
||||
|
||||
br.Avatar = gotemplate.HTML(avatar)
|
||||
br.RepoLink = repoLink
|
||||
br.PartSha = part.Sha
|
||||
br.PreviousSha = part.PreviousSha
|
||||
br.PreviousShaURL = fmt.Sprintf("%s/blame/commit/%s/%s", repoLink, url.PathEscape(part.PreviousSha), util.PathEscapeSegments(part.PreviousPath))
|
||||
br.CommitURL = fmt.Sprintf("%s/commit/%s", repoLink, url.PathEscape(part.Sha))
|
||||
br.CommitMessage = commit.CommitMessage
|
||||
br.CommitSince = commitSince
|
||||
}
|
||||
|
||||
if i != len(lines)-1 {
|
||||
line += "\n"
|
||||
}
|
||||
line, lexerNameForLine := highlight.Code(path.Base(ctx.Repo.TreePath), language, line)
|
||||
|
||||
// set lexer name to the first detected lexer. this is certainly suboptimal and
|
||||
// we should instead highlight the whole file at once
|
||||
if lexerName == "" {
|
||||
lexerName = lexerNameForLine
|
||||
}
|
||||
|
||||
br.EscapeStatus, br.Code = charset.EscapeControlHTML(line, ctx.Locale)
|
||||
rows = append(rows, br)
|
||||
escapeStatus = escapeStatus.Or(br.EscapeStatus)
|
||||
bufContent := buf.Bytes()
|
||||
bufContent = charset.ToUTF8(bufContent, charset.ConvertOpts{})
|
||||
highlighted, lexerName := highlight.Code(path.Base(ctx.Repo.TreePath), language, util.UnsafeBytesToString(bufContent))
|
||||
unsafeLines := highlight.UnsafeSplitHighlightedLines(highlighted)
|
||||
for i, br := range rows {
|
||||
var line template.HTML
|
||||
if i < len(rows) {
|
||||
line = template.HTML(util.UnsafeBytesToString(unsafeLines[i]))
|
||||
}
|
||||
br.EscapeStatus, br.Code = charset.EscapeControlHTML(line, ctx.Locale)
|
||||
escapeStatus = escapeStatus.Or(br.EscapeStatus)
|
||||
}
|
||||
|
||||
ctx.Data["EscapeStatus"] = escapeStatus
|
||||
ctx.Data["BlameRows"] = rows
|
||||
ctx.Data["CommitCnt"] = commitCnt
|
||||
ctx.Data["LexerName"] = lexerName
|
||||
}
|
||||
|
||||
@ -317,11 +317,7 @@ func EditFile(ctx *context.Context) {
|
||||
ctx.ServerError("ReadAll", err)
|
||||
return
|
||||
}
|
||||
if content, err := charset.ToUTF8(buf, charset.ConvertOpts{KeepBOM: true}); err != nil {
|
||||
ctx.Data["FileContent"] = string(buf)
|
||||
} else {
|
||||
ctx.Data["FileContent"] = content
|
||||
}
|
||||
ctx.Data["FileContent"] = string(charset.ToUTF8(buf, charset.ConvertOpts{KeepBOM: true, ErrorReturnOrigin: true}))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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"
|
||||
@ -234,6 +235,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))
|
||||
@ -774,6 +776,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)
|
||||
|
||||
@ -20,15 +20,27 @@ import (
|
||||
"code.gitea.io/gitea/modules/httplib"
|
||||
"code.gitea.io/gitea/modules/log"
|
||||
"code.gitea.io/gitea/modules/setting"
|
||||
"code.gitea.io/gitea/modules/structs"
|
||||
"code.gitea.io/gitea/modules/templates"
|
||||
"code.gitea.io/gitea/modules/web/middleware"
|
||||
)
|
||||
|
||||
// RedirectToUser redirect to a differently-named user
|
||||
func RedirectToUser(ctx *Base, userName string, redirectUserID int64) {
|
||||
func RedirectToUser(ctx *Base, doer *user_model.User, userName string, redirectUserID int64) {
|
||||
user, err := user_model.GetUserByID(ctx, redirectUserID)
|
||||
if err != nil {
|
||||
ctx.HTTPError(http.StatusInternalServerError, "unable to get user")
|
||||
if user_model.IsErrUserNotExist(err) {
|
||||
ctx.HTTPError(http.StatusNotFound, "user does not exist")
|
||||
} else {
|
||||
ctx.HTTPError(http.StatusInternalServerError, "unable to get user")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Handle Visibility
|
||||
if user.Visibility != structs.VisibleTypePublic && doer == nil {
|
||||
// We must be signed in to see limited or private organizations
|
||||
ctx.HTTPError(http.StatusNotFound, "user does not exist")
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@ -49,7 +49,7 @@ func GetOrganizationByParams(ctx *Context) {
|
||||
if organization.IsErrOrgNotExist(err) {
|
||||
redirectUserID, err := user_model.LookupUserRedirect(ctx, orgName)
|
||||
if err == nil {
|
||||
RedirectToUser(ctx.Base, orgName, redirectUserID)
|
||||
RedirectToUser(ctx.Base, ctx.Doer, orgName, redirectUserID)
|
||||
} else if user_model.IsErrUserRedirectNotExist(err) {
|
||||
ctx.NotFound(err)
|
||||
} else {
|
||||
|
||||
@ -443,7 +443,7 @@ func RepoAssignment(ctx *Context) {
|
||||
}
|
||||
|
||||
if redirectUserID, err := user_model.LookupUserRedirect(ctx, userName); err == nil {
|
||||
RedirectToUser(ctx.Base, userName, redirectUserID)
|
||||
RedirectToUser(ctx.Base, ctx.Doer, userName, redirectUserID)
|
||||
} else if user_model.IsErrUserRedirectNotExist(err) {
|
||||
ctx.NotFound(nil)
|
||||
} else {
|
||||
|
||||
@ -69,7 +69,7 @@ func userAssignment(ctx *Base, doer *user_model.User, errCb func(int, any)) (con
|
||||
if err != nil {
|
||||
if user_model.IsErrUserNotExist(err) {
|
||||
if redirectUserID, err := user_model.LookupUserRedirect(ctx, username); err == nil {
|
||||
RedirectToUser(ctx, username, redirectUserID)
|
||||
RedirectToUser(ctx, doer, username, redirectUserID)
|
||||
} else if user_model.IsErrUserRedirectNotExist(err) {
|
||||
errCb(http.StatusNotFound, err)
|
||||
} else {
|
||||
|
||||
@ -835,11 +835,11 @@ parsingLoop:
|
||||
if buffer.Len() == 0 {
|
||||
continue
|
||||
}
|
||||
charsetLabel, err := charset.DetectEncoding(buffer.Bytes())
|
||||
if charsetLabel != "UTF-8" && err == nil {
|
||||
encoding, _ := stdcharset.Lookup(charsetLabel)
|
||||
if encoding != nil {
|
||||
diffLineTypeDecoders[lineType] = encoding.NewDecoder()
|
||||
charsetLabel, _ := charset.DetectEncoding(buffer.Bytes())
|
||||
if charsetLabel != "UTF-8" {
|
||||
charsetEncoding, _ := stdcharset.Lookup(charsetLabel)
|
||||
if charsetEncoding != nil {
|
||||
diffLineTypeDecoders[lineType] = charsetEncoding.NewDecoder()
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1325,10 +1325,10 @@ func GetDiffForRender(ctx context.Context, repoLink string, gitRepo *git.Reposit
|
||||
shouldFullFileHighlight := !setting.Git.DisableDiffHighlight && attrDiff.Value() == ""
|
||||
if shouldFullFileHighlight {
|
||||
if limitedContent.LeftContent != nil && limitedContent.LeftContent.buf.Len() < MaxDiffHighlightEntireFileSize {
|
||||
diffFile.highlightedLeftLines = highlightCodeLines(diffFile, true /* left */, limitedContent.LeftContent.buf.String())
|
||||
diffFile.highlightedLeftLines = highlightCodeLines(diffFile, true /* left */, limitedContent.LeftContent.buf.Bytes())
|
||||
}
|
||||
if limitedContent.RightContent != nil && limitedContent.RightContent.buf.Len() < MaxDiffHighlightEntireFileSize {
|
||||
diffFile.highlightedRightLines = highlightCodeLines(diffFile, false /* right */, limitedContent.RightContent.buf.String())
|
||||
diffFile.highlightedRightLines = highlightCodeLines(diffFile, false /* right */, limitedContent.RightContent.buf.Bytes())
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1336,10 +1336,11 @@ func GetDiffForRender(ctx context.Context, repoLink string, gitRepo *git.Reposit
|
||||
return diff, nil
|
||||
}
|
||||
|
||||
func highlightCodeLines(diffFile *DiffFile, isLeft bool, content string) map[int]template.HTML {
|
||||
func highlightCodeLines(diffFile *DiffFile, isLeft bool, rawContent []byte) map[int]template.HTML {
|
||||
content := util.UnsafeBytesToString(charset.ToUTF8(rawContent, charset.ConvertOpts{}))
|
||||
highlightedNewContent, _ := highlight.Code(diffFile.Name, diffFile.Language, content)
|
||||
splitLines := strings.Split(string(highlightedNewContent), "\n")
|
||||
lines := make(map[int]template.HTML, len(splitLines))
|
||||
unsafeLines := highlight.UnsafeSplitHighlightedLines(highlightedNewContent)
|
||||
lines := make(map[int]template.HTML, len(unsafeLines))
|
||||
// only save the highlighted lines we need, but not the whole file, to save memory
|
||||
for _, sec := range diffFile.Sections {
|
||||
for _, ln := range sec.Lines {
|
||||
@ -1349,8 +1350,8 @@ func highlightCodeLines(diffFile *DiffFile, isLeft bool, content string) map[int
|
||||
}
|
||||
if lineIdx >= 1 {
|
||||
idx := lineIdx - 1
|
||||
if idx < len(splitLines) {
|
||||
lines[idx] = template.HTML(splitLines[idx])
|
||||
if idx < len(unsafeLines) {
|
||||
lines[idx] = template.HTML(util.UnsafeBytesToString(unsafeLines[idx]))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -5,6 +5,7 @@
|
||||
package gitdiff
|
||||
|
||||
import (
|
||||
"html/template"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
@ -1106,3 +1107,41 @@ func TestDiffLine_GetExpandDirection(t *testing.T) {
|
||||
assert.Equal(t, c.direction, c.diffLine.GetExpandDirection(), "case %s expected direction: %s", c.name, c.direction)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHighlightCodeLines(t *testing.T) {
|
||||
t.Run("CharsetDetecting", func(t *testing.T) {
|
||||
diffFile := &DiffFile{
|
||||
Name: "a.c",
|
||||
Language: "c",
|
||||
Sections: []*DiffSection{
|
||||
{
|
||||
Lines: []*DiffLine{{LeftIdx: 1}},
|
||||
},
|
||||
},
|
||||
}
|
||||
ret := highlightCodeLines(diffFile, true, []byte("// abc\xcc def\xcd")) // ISO-8859-1 bytes
|
||||
assert.Equal(t, "<span class=\"c1\">// abcÌ defÍ\n</span>", string(ret[0]))
|
||||
})
|
||||
|
||||
t.Run("LeftLines", func(t *testing.T) {
|
||||
diffFile := &DiffFile{
|
||||
Name: "a.c",
|
||||
Language: "c",
|
||||
Sections: []*DiffSection{
|
||||
{
|
||||
Lines: []*DiffLine{
|
||||
{LeftIdx: 1},
|
||||
{LeftIdx: 2},
|
||||
{LeftIdx: 3},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
const nl = "\n"
|
||||
ret := highlightCodeLines(diffFile, true, []byte("a\nb\n"))
|
||||
assert.Equal(t, map[int]template.HTML{
|
||||
0: `<span class="n">a</span>` + nl,
|
||||
1: `<span class="n">b</span>`,
|
||||
}, ret)
|
||||
})
|
||||
}
|
||||
|
||||
@ -25,12 +25,12 @@ func TestDiffWithHighlight(t *testing.T) {
|
||||
|
||||
t.Run("CleanUp", func(t *testing.T) {
|
||||
hcd := newHighlightCodeDiff()
|
||||
codeA := template.HTML(`<span class="cm>this is a comment</span>`)
|
||||
codeB := template.HTML(`<span class="cm>this is updated comment</span>`)
|
||||
codeA := template.HTML(`<span class="cm">this is a comment</span>`)
|
||||
codeB := template.HTML(`<span class="cm">this is updated comment</span>`)
|
||||
outDel := hcd.diffLineWithHighlight(DiffLineDel, codeA, codeB)
|
||||
assert.Equal(t, `<span class="cm>this is <span class="removed-code">a</span> comment</span>`, string(outDel))
|
||||
assert.Equal(t, `<span class="cm">this is <span class="removed-code">a</span> comment</span>`, string(outDel))
|
||||
outAdd := hcd.diffLineWithHighlight(DiffLineAdd, codeA, codeB)
|
||||
assert.Equal(t, `<span class="cm>this is <span class="added-code">updated</span> comment</span>`, string(outAdd))
|
||||
assert.Equal(t, `<span class="cm">this is <span class="added-code">updated</span> comment</span>`, string(outAdd))
|
||||
})
|
||||
|
||||
t.Run("OpenCloseTags", func(t *testing.T) {
|
||||
|
||||
301
services/renderplugin/service.go
Normal file
301
services/renderplugin/service.go
Normal file
@ -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>
|
||||
|
||||
89
templates/admin/render/plugin_confirm.tmpl
Normal file
89
templates/admin/render/plugin_confirm.tmpl
Normal file
@ -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" .}}
|
||||
122
templates/admin/render/plugin_detail.tmpl
Normal file
122
templates/admin/render/plugin_detail.tmpl
Normal file
@ -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" .}}
|
||||
65
templates/admin/render/plugins.tmpl
Normal file
65
templates/admin/render/plugins.tmpl
Normal file
@ -0,0 +1,65 @@
|
||||
{{template "admin/layout_head" (dict "ctxData" . "pageClass" "admin render-plugins")}}
|
||||
<div class="admin-setting-content">
|
||||
<h4 class="ui top attached header">
|
||||
{{ctx.Locale.Tr "admin.render_plugins"}}
|
||||
<div class="sub header tw-text-normal">{{ctx.Locale.Tr "admin.render_plugins.description"}}</div>
|
||||
</h4>
|
||||
<div class="ui attached segment">
|
||||
<form class="ui form" method="post" action="{{AppSubUrl}}/-/admin/render-plugins/upload" enctype="multipart/form-data">
|
||||
{{.CsrfTokenHtml}}
|
||||
<div class="field">
|
||||
<label>{{ctx.Locale.Tr "admin.render_plugins.upload_label"}}</label>
|
||||
<input type="file" name="plugin" accept=".zip" required>
|
||||
</div>
|
||||
<button class="ui primary button" type="submit">{{ctx.Locale.Tr "admin.render_plugins.install"}}</button>
|
||||
</form>
|
||||
<div class="tw-mt-2 tw-text-sm tw-text-secondary">
|
||||
{{ctx.Locale.Tr "admin.render_plugins.example_hint"}}
|
||||
</div>
|
||||
</div>
|
||||
<div class="ui attached table segment">
|
||||
<table class="ui very basic striped table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ctx.Locale.Tr "admin.render_plugins.table.name"}}</th>
|
||||
<th>{{ctx.Locale.Tr "admin.render_plugins.table.identifier"}}</th>
|
||||
<th>{{ctx.Locale.Tr "admin.render_plugins.table.version"}}</th>
|
||||
<th>{{ctx.Locale.Tr "admin.render_plugins.table.patterns"}}</th>
|
||||
<th>{{ctx.Locale.Tr "admin.render_plugins.table.status"}}</th>
|
||||
<th class="tw-text-right">{{ctx.Locale.Tr "admin.render_plugins.table.actions"}}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .Plugins}}
|
||||
<tr>
|
||||
<td>
|
||||
<div>{{.Name}}</div>
|
||||
<div class="text light tw-text-sm">{{.Description}}</div>
|
||||
</td>
|
||||
<td>{{.Identifier}}</td>
|
||||
<td>{{.Version}}</td>
|
||||
<td class="tw-text-sm">
|
||||
{{range $i, $pattern := .FilePatterns}}{{if $i}}, {{end}}{{$pattern}}{{end}}
|
||||
</td>
|
||||
<td>
|
||||
{{if .Enabled}}
|
||||
<span class="ui green basic label">{{ctx.Locale.Tr "admin.render_plugins.status.enabled"}}</span>
|
||||
{{else}}
|
||||
<span class="ui grey basic label">{{ctx.Locale.Tr "admin.render_plugins.status.disabled"}}</span>
|
||||
{{end}}
|
||||
</td>
|
||||
<td class="tw-text-right">
|
||||
<a class="ui mini basic button" href="{{AppSubUrl}}/-/admin/render-plugins/{{.ID}}">{{ctx.Locale.Tr "view"}}</a>
|
||||
</td>
|
||||
</tr>
|
||||
{{else}}
|
||||
<tr>
|
||||
<td class="tw-text-center" colspan="6">{{ctx.Locale.Tr "admin.render_plugins.empty"}}</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{template "admin/layout_footer" .}}
|
||||
@ -38,7 +38,7 @@
|
||||
<table>
|
||||
<tbody>
|
||||
{{range $row := .BlameRows}}
|
||||
<tr class="{{if and (gt $.CommitCnt 1) ($row.CommitMessage)}}top-line-blame{{end}}">
|
||||
<tr class="{{if $row.CommitURL}}top-line-blame{{end}}">
|
||||
<td class="lines-commit">
|
||||
<div class="blame-info">
|
||||
<div class="blame-data">
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -4,6 +4,7 @@
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"context"
|
||||
"database/sql"
|
||||
@ -21,7 +22,6 @@ import (
|
||||
"code.gitea.io/gitea/models/migrations"
|
||||
migrate_base "code.gitea.io/gitea/models/migrations/base"
|
||||
"code.gitea.io/gitea/models/unittest"
|
||||
"code.gitea.io/gitea/modules/charset"
|
||||
"code.gitea.io/gitea/modules/git"
|
||||
"code.gitea.io/gitea/modules/log"
|
||||
"code.gitea.io/gitea/modules/setting"
|
||||
@ -108,11 +108,11 @@ func readSQLFromFile(version string) (string, error) {
|
||||
}
|
||||
defer gr.Close()
|
||||
|
||||
bytes, err := io.ReadAll(gr)
|
||||
buf, err := io.ReadAll(gr)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(charset.MaybeRemoveBOM(bytes, charset.ConvertOpts{})), nil
|
||||
return string(bytes.TrimPrefix(buf, []byte{'\xef', '\xbb', '\xbf'})), nil
|
||||
}
|
||||
|
||||
func restoreOldDB(t *testing.T, version string) {
|
||||
|
||||
195
tests/integration/render_plugin_test.go
Normal file
195
tests/integration/render_plugin_test.go
Normal file
@ -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
|
||||
}
|
||||
@ -45,6 +45,78 @@ func TestRenameUsername(t *testing.T) {
|
||||
unittest.AssertNotExistsBean(t, &user_model.User{Name: "user2"})
|
||||
}
|
||||
|
||||
func TestViewLimitedAndPrivateUserAndRename(t *testing.T) {
|
||||
defer tests.PrepareTestEnv(t)()
|
||||
|
||||
// user 22 is a limited visibility org
|
||||
org22 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 22})
|
||||
req := NewRequest(t, "GET", "/"+org22.Name)
|
||||
MakeRequest(t, req, http.StatusNotFound)
|
||||
|
||||
session := loginUser(t, "user1")
|
||||
oldName := org22.Name
|
||||
newName := "org22_renamed"
|
||||
req = NewRequestWithValues(t, "POST", "/org/"+oldName+"/settings/rename", map[string]string{
|
||||
"_csrf": GetUserCSRFToken(t, session),
|
||||
"org_name": oldName,
|
||||
"new_org_name": newName,
|
||||
})
|
||||
session.MakeRequest(t, req, http.StatusOK)
|
||||
|
||||
unittest.AssertExistsAndLoadBean(t, &user_model.User{Name: newName})
|
||||
unittest.AssertNotExistsBean(t, &user_model.User{Name: oldName})
|
||||
|
||||
req = NewRequest(t, "GET", "/"+oldName)
|
||||
MakeRequest(t, req, http.StatusNotFound) // anonymous user cannot visit limited visibility org via old name
|
||||
req = NewRequest(t, "GET", "/"+oldName)
|
||||
session.MakeRequest(t, req, http.StatusTemporaryRedirect) // login user can visit limited visibility org via old name
|
||||
|
||||
// org 23 is a private visibility org
|
||||
org23 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 23})
|
||||
req = NewRequest(t, "GET", "/"+org23.Name)
|
||||
MakeRequest(t, req, http.StatusNotFound)
|
||||
|
||||
oldName = org23.Name
|
||||
newName = "org23_renamed"
|
||||
req = NewRequestWithValues(t, "POST", "/org/"+oldName+"/settings/rename", map[string]string{
|
||||
"_csrf": GetUserCSRFToken(t, session),
|
||||
"org_name": oldName,
|
||||
"new_org_name": newName,
|
||||
})
|
||||
session.MakeRequest(t, req, http.StatusOK)
|
||||
|
||||
unittest.AssertExistsAndLoadBean(t, &user_model.User{Name: newName})
|
||||
unittest.AssertNotExistsBean(t, &user_model.User{Name: oldName})
|
||||
|
||||
req = NewRequest(t, "GET", "/"+oldName)
|
||||
MakeRequest(t, req, http.StatusNotFound) // anonymous user cannot visit limited visibility org via old name
|
||||
req = NewRequest(t, "GET", "/"+oldName)
|
||||
session.MakeRequest(t, req, http.StatusTemporaryRedirect) // login user can visit limited visibility org via old name
|
||||
|
||||
// user 31 is a private visibility user
|
||||
user31 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 31})
|
||||
req = NewRequest(t, "GET", "/"+user31.Name)
|
||||
MakeRequest(t, req, http.StatusNotFound)
|
||||
|
||||
oldName = user31.Name
|
||||
newName = "user31_renamed"
|
||||
session2 := loginUser(t, oldName)
|
||||
req = NewRequestWithValues(t, "POST", "/user/settings", map[string]string{
|
||||
"_csrf": GetUserCSRFToken(t, session2),
|
||||
"name": newName,
|
||||
"visibility": "2", // private
|
||||
})
|
||||
session2.MakeRequest(t, req, http.StatusSeeOther)
|
||||
|
||||
unittest.AssertExistsAndLoadBean(t, &user_model.User{Name: newName})
|
||||
unittest.AssertNotExistsBean(t, &user_model.User{Name: oldName})
|
||||
|
||||
req = NewRequest(t, "GET", "/"+oldName)
|
||||
MakeRequest(t, req, http.StatusNotFound) // anonymous user cannot visit private visibility user via old name
|
||||
req = NewRequest(t, "GET", "/"+oldName)
|
||||
session.MakeRequest(t, req, http.StatusTemporaryRedirect) // login user2 can visit private visibility user via old name
|
||||
}
|
||||
|
||||
func TestRenameInvalidUsername(t *testing.T) {
|
||||
defer tests.PrepareTestEnv(t)()
|
||||
|
||||
|
||||
@ -919,7 +919,7 @@ overflow-menu .ui.label {
|
||||
.blame-avatar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-right: 4px;
|
||||
margin-right: 6px;
|
||||
}
|
||||
|
||||
tr.top-line-blame {
|
||||
|
||||
@ -2,20 +2,10 @@
|
||||
import {SvgIcon} from '../svg.ts';
|
||||
import {isPlainClick} from '../utils/dom.ts';
|
||||
import {shallowRef} from 'vue';
|
||||
import {type createViewFileTreeStore} from './ViewFileTreeStore.ts';
|
||||
|
||||
export type Item = {
|
||||
entryName: string;
|
||||
entryMode: 'blob' | 'exec' | 'tree' | 'commit' | 'symlink' | 'unknown';
|
||||
entryIcon: string;
|
||||
entryIconOpen: string;
|
||||
fullPath: string;
|
||||
submoduleUrl?: string;
|
||||
children?: Item[];
|
||||
};
|
||||
import type {createViewFileTreeStore, FileTreeItem} from './ViewFileTreeStore.ts';
|
||||
|
||||
const props = defineProps<{
|
||||
item: Item,
|
||||
item: FileTreeItem,
|
||||
store: ReturnType<typeof createViewFileTreeStore>
|
||||
}>();
|
||||
|
||||
|
||||
@ -3,11 +3,20 @@ import {GET} from '../modules/fetch.ts';
|
||||
import {pathEscapeSegments} from '../utils/url.ts';
|
||||
import {createElementFromHTML} from '../utils/dom.ts';
|
||||
import {html} from '../utils/html.ts';
|
||||
import type {Item} from './ViewFileTreeItem.vue';
|
||||
|
||||
export type FileTreeItem = {
|
||||
entryName: string;
|
||||
entryMode: 'blob' | 'exec' | 'tree' | 'commit' | 'symlink' | 'unknown';
|
||||
entryIcon: string;
|
||||
entryIconOpen: string;
|
||||
fullPath: string;
|
||||
submoduleUrl?: string;
|
||||
children?: Array<FileTreeItem>;
|
||||
};
|
||||
|
||||
export function createViewFileTreeStore(props: {repoLink: string, treePath: string, currentRefNameSubURL: string}) {
|
||||
const store = reactive({
|
||||
rootFiles: [] as Array<Item>,
|
||||
rootFiles: [] as Array<FileTreeItem>,
|
||||
selectedItem: props.treePath,
|
||||
|
||||
async loadChildren(treePath: string, subPath: string = '') {
|
||||
|
||||
26
web_src/js/features/file-view.test.ts
Normal file
26
web_src/js/features/file-view.test.ts
Normal file
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
function findFileRenderPlugin(filename: string, mimeType: string): FileRenderPlugin | null {
|
||||
return plugins.find((plugin) => plugin.canHandle(filename, mimeType)) || null;
|
||||
async function initPluginsOnce(): Promise<void> {
|
||||
if (pluginsInitialized) return;
|
||||
if (!pluginsInitPromise) {
|
||||
pluginsInitPromise = (async () => {
|
||||
if (!pluginsInitialized) {
|
||||
plugins.push(newRenderPlugin3DViewer(), newRenderPluginPdfViewer());
|
||||
const dynamicPlugins = await loadDynamicRenderPlugins();
|
||||
plugins.push(...dynamicPlugins);
|
||||
pluginsInitialized = true;
|
||||
}
|
||||
})();
|
||||
}
|
||||
await pluginsInitPromise;
|
||||
}
|
||||
|
||||
function findFileRenderPlugin(filename: string, mimeType: string, headChunk: Uint8Array | null): FileRenderPlugin | null {
|
||||
return plugins.find((plugin) => plugin.canHandle(filename, mimeType, headChunk)) || null;
|
||||
}
|
||||
|
||||
function showRenderRawFileButton(elFileView: HTMLElement, renderContainer: HTMLElement | null): void {
|
||||
@ -26,17 +54,17 @@ function showRenderRawFileButton(elFileView: HTMLElement, renderContainer: HTMLE
|
||||
// TODO: if there is only one button, hide it?
|
||||
}
|
||||
|
||||
async function renderRawFileToContainer(container: HTMLElement, rawFileLink: string, mimeType: string) {
|
||||
async function renderRawFileToContainer(container: HTMLElement, rawFileLink: string, mimeType: string, headChunk: Uint8Array | null) {
|
||||
const elViewRawPrompt = container.querySelector('.file-view-raw-prompt');
|
||||
if (!rawFileLink || !elViewRawPrompt) throw new Error('unexpected file view container');
|
||||
|
||||
let rendered = false, errorMsg = '';
|
||||
try {
|
||||
const plugin = findFileRenderPlugin(basename(rawFileLink), mimeType);
|
||||
const plugin = findFileRenderPlugin(basename(rawFileLink), mimeType, headChunk);
|
||||
if (plugin) {
|
||||
container.classList.add('is-loading');
|
||||
container.setAttribute('data-render-name', plugin.name); // not used yet
|
||||
await plugin.render(container, rawFileLink);
|
||||
await plugin.render(container, rawFileLink, {mimeType, headChunk});
|
||||
rendered = true;
|
||||
}
|
||||
} catch (e) {
|
||||
@ -61,16 +89,16 @@ async function renderRawFileToContainer(container: HTMLElement, rawFileLink: str
|
||||
|
||||
export function initRepoFileView(): void {
|
||||
registerGlobalInitFunc('initRepoFileView', async (elFileView: HTMLElement) => {
|
||||
initPluginsOnce();
|
||||
await initPluginsOnce();
|
||||
const rawFileLink = elFileView.getAttribute('data-raw-file-link')!;
|
||||
const mimeType = elFileView.getAttribute('data-mime-type') || ''; // not used yet
|
||||
// TODO: we should also provide the prefetched file head bytes to let the plugin decide whether to render or not
|
||||
const plugin = findFileRenderPlugin(basename(rawFileLink), mimeType);
|
||||
const mimeType = elFileView.getAttribute('data-mime-type') || '';
|
||||
const headChunk = decodeHeadChunk(elFileView.getAttribute('data-head-chunk'));
|
||||
const plugin = findFileRenderPlugin(basename(rawFileLink), mimeType, headChunk);
|
||||
if (!plugin) return;
|
||||
|
||||
const renderContainer = elFileView.querySelector<HTMLElement>('.file-view-render-container');
|
||||
showRenderRawFileButton(elFileView, renderContainer);
|
||||
// maybe in the future multiple plugins can render the same file, so we should not assume only one plugin will render it
|
||||
if (renderContainer) await renderRawFileToContainer(renderContainer, rawFileLink, mimeType);
|
||||
if (renderContainer) await renderRawFileToContainer(renderContainer, rawFileLink, mimeType, headChunk);
|
||||
});
|
||||
}
|
||||
|
||||
4
web_src/js/globals.d.ts
vendored
4
web_src/js/globals.d.ts
vendored
@ -12,8 +12,8 @@ declare module '*.vue' {
|
||||
import type {DefineComponent} from 'vue';
|
||||
const component: DefineComponent<unknown, unknown, any>;
|
||||
export default component;
|
||||
// List of named exports from vue components, used to make `tsc` output clean.
|
||||
// To actually lint .vue files, `vue-tsc` is used because `tsc` can not parse them.
|
||||
// Here we declare all exports from vue files so `tsc` or `tsgo` can work for
|
||||
// non-vue files. To lint .vue files, `vue-tsc` must be used.
|
||||
export function initDashboardRepoList(): void;
|
||||
export function initRepositoryActionView(): void;
|
||||
}
|
||||
|
||||
@ -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);
|
||||
},
|
||||
|
||||
248
web_src/js/render/plugins/dynamic-plugin.ts
Normal file
248
web_src/js/render/plugins/dynamic-plugin.ts
Normal file
@ -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');
|
||||
},
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user