Compare commits

...

3 Commits

Author SHA1 Message Date
wxiaoguang
b2f2f8528a Fix external render, make iframe render work (#35727, #35730) (#35731)
Backport #35727 and #35730

---------

Signed-off-by: wxiaoguang <wxiaoguang@gmail.com>
2025-10-23 16:07:17 +08:00
Giteabot
0925089b5e Honor delete branch on merge repo setting when using merge API (#35488) (#35726)
Backport #35488 by @kemzeb

Fix #35463.

---------

Co-authored-by: Kemal Zebari <60799661+kemzeb@users.noreply.github.com>
Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
2025-10-22 09:41:40 -07:00
Giteabot
c84d17b1bb Don't block site admin's operation if SECRET_KEY is lost (#35721) (#35724)
Backport #35721 by wxiaoguang

Related: #24573

Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
2025-10-22 05:02:47 +00:00
43 changed files with 739 additions and 394 deletions

View File

@@ -2540,7 +2540,19 @@ LEVEL = Info
;; * sanitized: Sanitize the content and render it inside current page, default to only allow a few HTML tags and attributes. Customized sanitizer rules can be defined in [markup.sanitizer.*] .
;; * no-sanitizer: Disable the sanitizer and render the content inside current page. It's **insecure** and may lead to XSS attack if the content contains malicious code.
;; * iframe: Render the content in a separate standalone page and embed it into current page by iframe. The iframe is in sandbox mode with same-origin disabled, and the JS code are safely isolated from parent page.
;RENDER_CONTENT_MODE=sanitized
;RENDER_CONTENT_MODE = sanitized
;; The sandbox applied to the iframe and Content-Security-Policy header when RENDER_CONTENT_MODE is `iframe`.
;; It defaults to a safe set of "allow-*" restrictions (space separated).
;; You can also set it by your requirements or use "disabled" to disable the sandbox completely.
;; When set it, make sure there is no security risk:
;; * PDF-only content: generally safe to use "disabled", and it needs to be "disabled" because PDF only renders with no sandbox.
;; * HTML content with JS: if the "RENDER_COMMAND" can guarantee there is no XSS, then it is safe, otherwise, you need to fine tune the "allow-*" restrictions.
;RENDER_CONTENT_SANDBOX =
;; Whether post-process the rendered HTML content, including:
;; resolve relative links and image sources, recognizing issue/commit references, escaping invisible characters,
;; mentioning users, rendering permlink code blocks, replacing emoji shorthands, etc.
;; By default, this is true when RENDER_CONTENT_MODE is `sanitized`, otherwise false.
;NEED_POST_PROCESS = false
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;

View File

@@ -11,6 +11,7 @@ import (
repo_model "code.gitea.io/gitea/models/repo"
user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/json"
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/migration"
"code.gitea.io/gitea/modules/secret"
"code.gitea.io/gitea/modules/setting"
@@ -123,17 +124,17 @@ func (task *Task) MigrateConfig() (*migration.MigrateOptions, error) {
// decrypt credentials
if opts.CloneAddrEncrypted != "" {
if opts.CloneAddr, err = secret.DecryptSecret(setting.SecretKey, opts.CloneAddrEncrypted); err != nil {
return nil, err
log.Error("Unable to decrypt CloneAddr, maybe SECRET_KEY is wrong: %v", err)
}
}
if opts.AuthPasswordEncrypted != "" {
if opts.AuthPassword, err = secret.DecryptSecret(setting.SecretKey, opts.AuthPasswordEncrypted); err != nil {
return nil, err
log.Error("Unable to decrypt AuthPassword, maybe SECRET_KEY is wrong: %v", err)
}
}
if opts.AuthTokenEncrypted != "" {
if opts.AuthToken, err = secret.DecryptSecret(setting.SecretKey, opts.AuthTokenEncrypted); err != nil {
return nil, err
log.Error("Unable to decrypt AuthToken, maybe SECRET_KEY is wrong: %v", err)
}
}

View File

@@ -111,11 +111,11 @@ func (t *TwoFactor) SetSecret(secretString string) error {
func (t *TwoFactor) ValidateTOTP(passcode string) (bool, error) {
decodedStoredSecret, err := base64.StdEncoding.DecodeString(t.Secret)
if err != nil {
return false, err
return false, fmt.Errorf("ValidateTOTP invalid base64: %w", err)
}
secretBytes, err := secret.AesDecrypt(t.getEncryptionKey(), decodedStoredSecret)
if err != nil {
return false, err
return false, fmt.Errorf("ValidateTOTP unable to decrypt (maybe SECRET_KEY is wrong): %w", err)
}
secretStr := string(secretBytes)
return totp.Validate(passcode, secretStr), nil

View File

@@ -5,7 +5,6 @@ package git
import (
"context"
"errors"
"fmt"
"slices"
"strings"
@@ -25,7 +24,7 @@ import (
"xorm.io/builder"
)
var ErrBranchIsProtected = errors.New("branch is protected")
var ErrBranchIsProtected = util.ErrorWrap(util.ErrPermissionDenied, "branch is protected")
// ProtectedBranch struct
type ProtectedBranch struct {

View File

@@ -178,8 +178,8 @@ func GetSecretsOfTask(ctx context.Context, task *actions_model.ActionTask) (map[
for _, secret := range append(ownerSecrets, repoSecrets...) {
v, err := secret_module.DecryptSecret(setting.SecretKey, secret.Data)
if err != nil {
log.Error("decrypt secret %v %q: %v", secret.ID, secret.Name, err)
return nil, err
log.Error("Unable to decrypt Actions secret %v %q, maybe SECRET_KEY is wrong: %v", secret.ID, secret.Name, err)
continue
}
secrets[secret.Name] = v
}

View File

@@ -126,6 +126,7 @@ func setServeHeadersByFile(r *http.Request, w http.ResponseWriter, mineBuf []byt
// no sandbox attribute for pdf as it breaks rendering in at least safari. this
// should generally be safe as scripts inside PDF can not escape the PDF document
// see https://bugs.chromium.org/p/chromium/issues/detail?id=413851 for more discussion
// HINT: PDF-RENDER-SANDBOX: PDF won't render in sandboxed context
w.Header().Set("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'")
}

View File

@@ -15,6 +15,8 @@ import (
"code.gitea.io/gitea/modules/markup"
"code.gitea.io/gitea/modules/process"
"code.gitea.io/gitea/modules/setting"
"github.com/kballard/go-shellquote"
)
// RegisterRenderers registers all supported third part renderers according settings
@@ -56,14 +58,11 @@ func (p *Renderer) SanitizerRules() []setting.MarkupSanitizerRule {
return p.MarkupSanitizerRules
}
// SanitizerDisabled disabled sanitize if return true
func (p *Renderer) SanitizerDisabled() bool {
return p.RenderContentMode == setting.RenderContentModeNoSanitizer || p.RenderContentMode == setting.RenderContentModeIframe
}
// DisplayInIFrame represents whether render the content with an iframe
func (p *Renderer) DisplayInIFrame() bool {
return p.RenderContentMode == setting.RenderContentModeIframe
func (p *Renderer) GetExternalRendererOptions() (ret markup.ExternalRendererOptions) {
ret.SanitizerDisabled = p.RenderContentMode == setting.RenderContentModeNoSanitizer || p.RenderContentMode == setting.RenderContentModeIframe
ret.DisplayInIframe = p.RenderContentMode == setting.RenderContentModeIframe
ret.ContentSandbox = p.RenderContentSandbox
return ret
}
func envMark(envName string) string {
@@ -81,7 +80,10 @@ func (p *Renderer) Render(ctx *markup.RenderContext, input io.Reader, output io.
envMark("GITEA_PREFIX_SRC"), baseLinkSrc,
envMark("GITEA_PREFIX_RAW"), baseLinkRaw,
).Replace(p.Command)
commands := strings.Fields(command)
commands, err := shellquote.Split(command)
if err != nil || len(commands) == 0 {
return fmt.Errorf("%s invalid command %q: %w", p.Name(), p.Command, err)
}
args := commands[1:]
if p.IsInputFile {

View File

@@ -5,11 +5,13 @@ package internal
import (
"bytes"
"html/template"
"io"
)
type finalProcessor struct {
renderInternal *RenderInternal
extraHeadHTML template.HTML
output io.Writer
buf bytes.Buffer
@@ -25,6 +27,32 @@ func (p *finalProcessor) Close() error {
// because "postProcess" already does so. In the future we could optimize the code to process data on the fly.
buf := p.buf.Bytes()
buf = bytes.ReplaceAll(buf, []byte(` data-attr-class="`+p.renderInternal.secureIDPrefix), []byte(` class="`))
_, err := p.output.Write(buf)
tmp := bytes.TrimSpace(buf)
isLikelyHTML := len(tmp) != 0 && tmp[0] == '<' && tmp[len(tmp)-1] == '>' && bytes.Index(tmp, []byte(`</`)) > 0
if !isLikelyHTML {
// not HTML, write back directly
_, err := p.output.Write(buf)
return err
}
// add our extra head HTML into output
headBytes := []byte("<head>")
posHead := bytes.Index(buf, headBytes)
var part1, part2 []byte
if posHead >= 0 {
part1, part2 = buf[:posHead+len(headBytes)], buf[posHead+len(headBytes):]
} else {
part1, part2 = nil, buf
}
if len(part1) > 0 {
if _, err := p.output.Write(part1); err != nil {
return err
}
}
if _, err := io.WriteString(p.output, string(p.extraHeadHTML)); err != nil {
return err
}
_, err := p.output.Write(part2)
return err
}

View File

@@ -12,7 +12,7 @@ import (
"github.com/stretchr/testify/assert"
)
func TestRenderInternal(t *testing.T) {
func TestRenderInternalAttrs(t *testing.T) {
cases := []struct {
input, protected, recovered string
}{
@@ -30,7 +30,7 @@ func TestRenderInternal(t *testing.T) {
for _, c := range cases {
var r RenderInternal
out := &bytes.Buffer{}
in := r.init("sec", out)
in := r.init("sec", out, "")
protected := r.ProtectSafeAttrs(template.HTML(c.input))
assert.EqualValues(t, c.protected, protected)
_, _ = io.WriteString(in, string(protected))
@@ -41,7 +41,7 @@ func TestRenderInternal(t *testing.T) {
var r1, r2 RenderInternal
protected := r1.ProtectSafeAttrs(`<div class="test"></div>`)
assert.EqualValues(t, `<div class="test"></div>`, protected, "non-initialized RenderInternal should not protect any attributes")
_ = r1.init("sec", nil)
_ = r1.init("sec", nil, "")
protected = r1.ProtectSafeAttrs(`<div class="test"></div>`)
assert.EqualValues(t, `<div data-attr-class="sec:test"></div>`, protected)
assert.Equal(t, "data-attr-class", r1.SafeAttr("class"))
@@ -54,8 +54,37 @@ func TestRenderInternal(t *testing.T) {
assert.Empty(t, recovered)
out2 := &bytes.Buffer{}
in2 := r2.init("sec-other", out2)
in2 := r2.init("sec-other", out2, "")
_, _ = io.WriteString(in2, string(protected))
_ = in2.Close()
assert.Equal(t, `<div data-attr-class="sec:test"></div>`, out2.String(), "different secureID should not recover the value")
}
func TestRenderInternalExtraHead(t *testing.T) {
t.Run("HeadExists", func(t *testing.T) {
out := &bytes.Buffer{}
var r RenderInternal
in := r.init("sec", out, `<MY-TAG>`)
_, _ = io.WriteString(in, `<head>any</head>`)
_ = in.Close()
assert.Equal(t, `<head><MY-TAG>any</head>`, out.String())
})
t.Run("HeadNotExists", func(t *testing.T) {
out := &bytes.Buffer{}
var r RenderInternal
in := r.init("sec", out, `<MY-TAG>`)
_, _ = io.WriteString(in, `<div></div>`)
_ = in.Close()
assert.Equal(t, `<MY-TAG><div></div>`, out.String())
})
t.Run("NotHTML", func(t *testing.T) {
out := &bytes.Buffer{}
var r RenderInternal
in := r.init("sec", out, `<MY-TAG>`)
_, _ = io.WriteString(in, `<any>`)
_ = in.Close()
assert.Equal(t, `<any>`, out.String())
})
}

View File

@@ -29,19 +29,19 @@ type RenderInternal struct {
secureIDPrefix string
}
func (r *RenderInternal) Init(output io.Writer) io.WriteCloser {
func (r *RenderInternal) Init(output io.Writer, extraHeadHTML template.HTML) io.WriteCloser {
buf := make([]byte, 12)
_, err := rand.Read(buf)
if err != nil {
panic("unable to generate secure id")
}
return r.init(base64.URLEncoding.EncodeToString(buf), output)
return r.init(base64.URLEncoding.EncodeToString(buf), output, extraHeadHTML)
}
func (r *RenderInternal) init(secID string, output io.Writer) io.WriteCloser {
func (r *RenderInternal) init(secID string, output io.Writer, extraHeadHTML template.HTML) io.WriteCloser {
r.secureID = secID
r.secureIDPrefix = r.secureID + ":"
return &finalProcessor{renderInternal: r, output: output}
return &finalProcessor{renderInternal: r, output: output, extraHeadHTML: extraHeadHTML}
}
func (r *RenderInternal) RecoverProtectedValue(v string) (string, bool) {

View File

@@ -6,12 +6,14 @@ package markup
import (
"context"
"fmt"
"html/template"
"io"
"net/url"
"strconv"
"strings"
"time"
"code.gitea.io/gitea/modules/htmlutil"
"code.gitea.io/gitea/modules/markup/internal"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/util"
@@ -120,31 +122,38 @@ func (ctx *RenderContext) WithHelper(helper RenderHelper) *RenderContext {
return ctx
}
// Render renders markup file to HTML with all specific handling stuff.
func Render(ctx *RenderContext, input io.Reader, output io.Writer) error {
// FindRendererByContext finds renderer by RenderContext
// TODO: it should be merged with other similar functions like GetRendererByFileName, DetectMarkupTypeByFileName, etc
func FindRendererByContext(ctx *RenderContext) (Renderer, error) {
if ctx.RenderOptions.MarkupType == "" && ctx.RenderOptions.RelativePath != "" {
ctx.RenderOptions.MarkupType = DetectMarkupTypeByFileName(ctx.RenderOptions.RelativePath)
if ctx.RenderOptions.MarkupType == "" {
return util.NewInvalidArgumentErrorf("unsupported file to render: %q", ctx.RenderOptions.RelativePath)
return nil, util.NewInvalidArgumentErrorf("unsupported file to render: %q", ctx.RenderOptions.RelativePath)
}
}
renderer := renderers[ctx.RenderOptions.MarkupType]
if renderer == nil {
return util.NewInvalidArgumentErrorf("unsupported markup type: %q", ctx.RenderOptions.MarkupType)
return nil, util.NewNotExistErrorf("unsupported markup type: %q", ctx.RenderOptions.MarkupType)
}
if ctx.RenderOptions.RelativePath != "" {
if externalRender, ok := renderer.(ExternalRenderer); ok && externalRender.DisplayInIFrame() {
if !ctx.RenderOptions.InStandalonePage {
// for an external "DisplayInIFrame" render, it could only output its content in a standalone page
// otherwise, a <iframe> should be outputted to embed the external rendered page
return renderIFrame(ctx, output)
}
}
}
return renderer, nil
}
return render(ctx, renderer, input, output)
func RendererNeedPostProcess(renderer Renderer) bool {
if r, ok := renderer.(PostProcessRenderer); ok && r.NeedPostProcess() {
return true
}
return false
}
// Render renders markup file to HTML with all specific handling stuff.
func Render(ctx *RenderContext, input io.Reader, output io.Writer) error {
renderer, err := FindRendererByContext(ctx)
if err != nil {
return err
}
return RenderWithRenderer(ctx, renderer, input, output)
}
// RenderString renders Markup string to HTML with all specific handling stuff and return string
@@ -156,24 +165,20 @@ func RenderString(ctx *RenderContext, content string) (string, error) {
return buf.String(), nil
}
func renderIFrame(ctx *RenderContext, output io.Writer) error {
// set height="0" ahead, otherwise the scrollHeight would be max(150, realHeight)
// at the moment, only "allow-scripts" is allowed for sandbox mode.
// "allow-same-origin" should never be used, it leads to XSS attack, and it makes the JS in iframe can access parent window's config and CSRF token
// TODO: when using dark theme, if the rendered content doesn't have proper style, the default text color is black, which is not easy to read
_, err := io.WriteString(output, fmt.Sprintf(`
<iframe src="%s/%s/%s/render/%s/%s"
name="giteaExternalRender"
onload="this.height=giteaExternalRender.document.documentElement.scrollHeight"
width="100%%" height="0" scrolling="no" frameborder="0" style="overflow: hidden"
sandbox="allow-scripts"
></iframe>`,
setting.AppSubURL,
func renderIFrame(ctx *RenderContext, sandbox string, output io.Writer) error {
src := fmt.Sprintf("%s/%s/%s/render/%s/%s", setting.AppSubURL,
url.PathEscape(ctx.RenderOptions.Metas["user"]),
url.PathEscape(ctx.RenderOptions.Metas["repo"]),
ctx.RenderOptions.Metas["RefTypeNameSubURL"],
url.PathEscape(ctx.RenderOptions.RelativePath),
))
util.PathEscapeSegments(ctx.RenderOptions.Metas["RefTypeNameSubURL"]),
util.PathEscapeSegments(ctx.RenderOptions.RelativePath),
)
var sandboxAttrValue template.HTML
if sandbox != "" {
sandboxAttrValue = htmlutil.HTMLFormat(`sandbox="%s"`, sandbox)
}
iframe := htmlutil.HTMLFormat(`<iframe data-src="%s" class="external-render-iframe" %s></iframe>`, src, sandboxAttrValue)
_, err := io.WriteString(output, string(iframe))
return err
}
@@ -185,13 +190,34 @@ func pipes() (io.ReadCloser, io.WriteCloser, func()) {
}
}
func render(ctx *RenderContext, renderer Renderer, input io.Reader, output io.Writer) error {
func getExternalRendererOptions(renderer Renderer) (ret ExternalRendererOptions, _ bool) {
if externalRender, ok := renderer.(ExternalRenderer); ok {
return externalRender.GetExternalRendererOptions(), true
}
return ret, false
}
func RenderWithRenderer(ctx *RenderContext, renderer Renderer, input io.Reader, output io.Writer) error {
var extraHeadHTML template.HTML
if extOpts, ok := getExternalRendererOptions(renderer); ok && extOpts.DisplayInIframe {
if !ctx.RenderOptions.InStandalonePage {
// for an external "DisplayInIFrame" render, it could only output its content in a standalone page
// otherwise, a <iframe> should be outputted to embed the external rendered page
return renderIFrame(ctx, extOpts.ContentSandbox, output)
}
// else: this is a standalone page, fallthrough to the real rendering, and add extra JS/CSS
extraStyleHref := setting.AppSubURL + "/assets/css/external-render-iframe.css"
extraScriptSrc := setting.AppSubURL + "/assets/js/external-render-iframe.js"
// "<script>" must go before "<link>", to make Golang's http.DetectContentType() can still recognize the content as "text/html"
extraHeadHTML = htmlutil.HTMLFormat(`<script src="%s"></script><link rel="stylesheet" href="%s">`, extraScriptSrc, extraStyleHref)
}
ctx.usedByRender = true
if ctx.RenderHelper != nil {
defer ctx.RenderHelper.CleanUp()
}
finalProcessor := ctx.RenderInternal.Init(output)
finalProcessor := ctx.RenderInternal.Init(output, extraHeadHTML)
defer finalProcessor.Close()
// input -> (pw1=pr1) -> renderer -> (pw2=pr2) -> SanitizeReader -> finalProcessor -> output
@@ -202,7 +228,7 @@ func render(ctx *RenderContext, renderer Renderer, input io.Reader, output io.Wr
eg, _ := errgroup.WithContext(ctx)
var pw2 io.WriteCloser = util.NopCloser{Writer: finalProcessor}
if r, ok := renderer.(ExternalRenderer); !ok || !r.SanitizerDisabled() {
if r, ok := renderer.(ExternalRenderer); !ok || !r.GetExternalRendererOptions().SanitizerDisabled {
var pr2 io.ReadCloser
var close2 func()
pr2, pw2, close2 = pipes()
@@ -214,7 +240,7 @@ func render(ctx *RenderContext, renderer Renderer, input io.Reader, output io.Wr
}
eg.Go(func() (err error) {
if r, ok := renderer.(PostProcessRenderer); ok && r.NeedPostProcess() {
if RendererNeedPostProcess(renderer) {
err = PostProcessDefault(ctx, pr1, pw2)
} else {
_, err = io.Copy(pw2, pr1)

View File

@@ -25,13 +25,15 @@ type PostProcessRenderer interface {
NeedPostProcess() bool
}
type ExternalRendererOptions struct {
SanitizerDisabled bool
DisplayInIframe bool
ContentSandbox string
}
// ExternalRenderer defines an interface for external renderers
type ExternalRenderer interface {
// SanitizerDisabled disabled sanitize if return true
SanitizerDisabled() bool
// DisplayInIFrame represents whether render the content with an iframe
DisplayInIFrame() bool
GetExternalRendererOptions() ExternalRendererOptions
}
// RendererContentDetector detects if the content can be rendered

View File

@@ -63,6 +63,7 @@ type MarkupRenderer struct {
NeedPostProcess bool
MarkupSanitizerRules []MarkupSanitizerRule
RenderContentMode string
RenderContentSandbox string
}
// MarkupSanitizerRule defines the policy for whitelisting attributes on
@@ -253,13 +254,24 @@ func newMarkupRenderer(name string, sec ConfigSection) {
renderContentMode = RenderContentModeSanitized
}
// ATTENTION! at the moment, only a safe set like "allow-scripts" are allowed for sandbox mode.
// "allow-same-origin" should never be used, it leads to XSS attack, and it makes the JS in iframe can access parent window's config and CSRF token
renderContentSandbox := sec.Key("RENDER_CONTENT_SANDBOX").MustString("allow-scripts allow-popups")
if renderContentSandbox == "disabled" {
renderContentSandbox = ""
}
ExternalMarkupRenderers = append(ExternalMarkupRenderers, &MarkupRenderer{
Enabled: sec.Key("ENABLED").MustBool(false),
MarkupName: name,
FileExtensions: exts,
Command: command,
IsInputFile: sec.Key("IS_INPUT_FILE").MustBool(false),
NeedPostProcess: sec.Key("NEED_POSTPROCESS").MustBool(true),
RenderContentMode: renderContentMode,
Enabled: sec.Key("ENABLED").MustBool(false),
MarkupName: name,
FileExtensions: exts,
Command: command,
IsInputFile: sec.Key("IS_INPUT_FILE").MustBool(false),
RenderContentMode: renderContentMode,
RenderContentSandbox: renderContentSandbox,
// if no sanitizer is needed, no post process is needed
NeedPostProcess: sec.Key("NEED_POST_PROCESS").MustBool(renderContentMode == RenderContentModeSanitized),
})
}

View File

@@ -6,6 +6,7 @@ package util
import (
"errors"
"fmt"
"html/template"
)
// Common Errors forming the base of our error system
@@ -40,22 +41,6 @@ func (w errorWrapper) Unwrap() error {
return w.Err
}
type LocaleWrapper struct {
err error
TrKey string
TrArgs []any
}
// Error returns the message
func (w LocaleWrapper) Error() string {
return w.err.Error()
}
// Unwrap returns the underlying error
func (w LocaleWrapper) Unwrap() error {
return w.err
}
// ErrorWrap returns an error that formats as the given text but unwraps as the provided error
func ErrorWrap(unwrap error, message string, args ...any) error {
if len(args) == 0 {
@@ -84,15 +69,39 @@ func NewNotExistErrorf(message string, args ...any) error {
return ErrorWrap(ErrNotExist, message, args...)
}
// ErrorWrapLocale wraps an err with a translation key and arguments
func ErrorWrapLocale(err error, trKey string, trArgs ...any) error {
return LocaleWrapper{err: err, TrKey: trKey, TrArgs: trArgs}
// ErrorTranslatable wraps an error with translation information
type ErrorTranslatable interface {
error
Unwrap() error
Translate(ErrorLocaleTranslator) template.HTML
}
func ErrorAsLocale(err error) *LocaleWrapper {
var e LocaleWrapper
type errorTranslatableWrapper struct {
err error
trKey string
trArgs []any
}
type ErrorLocaleTranslator interface {
Tr(key string, args ...any) template.HTML
}
func (w *errorTranslatableWrapper) Error() string { return w.err.Error() }
func (w *errorTranslatableWrapper) Unwrap() error { return w.err }
func (w *errorTranslatableWrapper) Translate(t ErrorLocaleTranslator) template.HTML {
return t.Tr(w.trKey, w.trArgs...)
}
func ErrorWrapTranslatable(err error, trKey string, trArgs ...any) ErrorTranslatable {
return &errorTranslatableWrapper{err: err, trKey: trKey, trArgs: trArgs}
}
func ErrorAsTranslatable(err error) ErrorTranslatable {
var e *errorTranslatableWrapper
if errors.As(err, &e) {
return &e
return e
}
return nil
}

View File

@@ -0,0 +1,29 @@
// Copyright 2025 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package util
import (
"io"
"testing"
"github.com/stretchr/testify/assert"
)
func TestErrorTranslatable(t *testing.T) {
var err error
err = ErrorWrapTranslatable(io.EOF, "key", 1)
assert.ErrorIs(t, err, io.EOF)
assert.Equal(t, "EOF", err.Error())
assert.Equal(t, "key", err.(*errorTranslatableWrapper).trKey)
assert.Equal(t, []any{1}, err.(*errorTranslatableWrapper).trArgs)
err = ErrorWrap(err, "new msg %d", 100)
assert.ErrorIs(t, err, io.EOF)
assert.Equal(t, "new msg 100", err.Error())
errTr := ErrorAsTranslatable(err)
assert.Equal(t, "EOF", errTr.Error())
assert.Equal(t, "key", errTr.(*errorTranslatableWrapper).trKey)
}

View File

@@ -13,7 +13,6 @@ import (
"time"
activities_model "code.gitea.io/gitea/models/activities"
git_model "code.gitea.io/gitea/models/git"
issues_model "code.gitea.io/gitea/models/issues"
access_model "code.gitea.io/gitea/models/perm/access"
pull_model "code.gitea.io/gitea/models/pull"
@@ -938,7 +937,7 @@ func MergePullRequest(ctx *context.APIContext) {
} else if errors.Is(err, pull_service.ErrNoPermissionToMerge) {
ctx.APIError(http.StatusMethodNotAllowed, "User not allowed to merge PR")
} else if errors.Is(err, pull_service.ErrHasMerged) {
ctx.APIError(http.StatusMethodNotAllowed, "")
ctx.APIError(http.StatusMethodNotAllowed, "The PR is already merged")
} else if errors.Is(err, pull_service.ErrIsWorkInProgress) {
ctx.APIError(http.StatusMethodNotAllowed, "Work in progress PRs cannot be merged")
} else if errors.Is(err, pull_service.ErrNotMergeableState) {
@@ -989,8 +988,14 @@ func MergePullRequest(ctx *context.APIContext) {
message += "\n\n" + form.MergeMessageField
}
deleteBranchAfterMerge, err := pull_service.ShouldDeleteBranchAfterMerge(ctx, form.DeleteBranchAfterMerge, ctx.Repo.Repository, pr)
if err != nil {
ctx.APIErrorInternal(err)
return
}
if form.MergeWhenChecksSucceed {
scheduled, err := automerge.ScheduleAutoMerge(ctx, ctx.Doer, pr, repo_model.MergeStyle(form.Do), message, form.DeleteBranchAfterMerge)
scheduled, err := automerge.ScheduleAutoMerge(ctx, ctx.Doer, pr, repo_model.MergeStyle(form.Do), message, deleteBranchAfterMerge)
if err != nil {
if pull_model.IsErrAlreadyScheduledToAutoMerge(err) {
ctx.APIError(http.StatusConflict, err)
@@ -1035,47 +1040,10 @@ func MergePullRequest(ctx *context.APIContext) {
}
log.Trace("Pull request merged: %d", pr.ID)
// for agit flow, we should not delete the agit reference after merge
if form.DeleteBranchAfterMerge && pr.Flow == issues_model.PullRequestFlowGithub {
// check permission even it has been checked in repo_service.DeleteBranch so that we don't need to
// do RetargetChildrenOnMerge
if err := repo_service.CanDeleteBranch(ctx, pr.HeadRepo, pr.HeadBranch, ctx.Doer); err == nil {
// Don't cleanup when there are other PR's that use this branch as head branch.
exist, err := issues_model.HasUnmergedPullRequestsByHeadInfo(ctx, pr.HeadRepoID, pr.HeadBranch)
if err != nil {
ctx.APIErrorInternal(err)
return
}
if exist {
ctx.Status(http.StatusOK)
return
}
var headRepo *git.Repository
if ctx.Repo != nil && ctx.Repo.Repository != nil && ctx.Repo.Repository.ID == pr.HeadRepoID && ctx.Repo.GitRepo != nil {
headRepo = ctx.Repo.GitRepo
} else {
headRepo, err = gitrepo.OpenRepository(ctx, pr.HeadRepo)
if err != nil {
ctx.APIErrorInternal(err)
return
}
defer headRepo.Close()
}
if err := repo_service.DeleteBranch(ctx, ctx.Doer, pr.HeadRepo, headRepo, pr.HeadBranch, pr); err != nil {
switch {
case git.IsErrBranchNotExist(err):
ctx.APIErrorNotFound(err)
case errors.Is(err, repo_service.ErrBranchIsDefault):
ctx.APIError(http.StatusForbidden, errors.New("can not delete default branch"))
case errors.Is(err, git_model.ErrBranchIsProtected):
ctx.APIError(http.StatusForbidden, errors.New("branch protected"))
default:
ctx.APIErrorInternal(err)
}
return
}
if deleteBranchAfterMerge {
if err = repo_service.DeleteBranchAfterMerge(ctx, ctx.Doer, pr.ID, nil); err != nil {
// no way to tell users that what error happens, and the PR has been merged, so ignore the error
log.Debug("DeleteBranchAfterMerge: pr %d, err: %v", pr.ID, err)
}
}

View File

@@ -850,8 +850,8 @@ func Run(ctx *context_module.Context) {
return nil
})
if err != nil {
if errLocale := util.ErrorAsLocale(err); errLocale != nil {
ctx.Flash.Error(ctx.Tr(errLocale.TrKey, errLocale.TrArgs...))
if errTr := util.ErrorAsTranslatable(err); errTr != nil {
ctx.Flash.Error(errTr.Translate(ctx.Locale))
ctx.Redirect(redirectURL)
} else {
ctx.ServerError("DispatchActionWorkflow", err)

View File

@@ -41,7 +41,7 @@ func NewDiffPatchPost(ctx *context.Context) {
Committer: parsed.GitCommitter,
})
if err != nil {
err = util.ErrorWrapLocale(err, "repo.editor.fail_to_apply_patch")
err = util.ErrorWrapTranslatable(err, "repo.editor.fail_to_apply_patch")
}
if err != nil {
editorHandleFileOperationError(ctx, parsed.NewBranchName, err)

View File

@@ -74,7 +74,7 @@ func CherryPickPost(ctx *context.Context) {
opts.Content = buf.String()
_, err = files.ApplyDiffPatch(ctx, ctx.Repo.Repository, ctx.Doer, opts)
if err != nil {
err = util.ErrorWrapLocale(err, "repo.editor.fail_to_apply_patch")
err = util.ErrorWrapTranslatable(err, "repo.editor.fail_to_apply_patch")
}
}
if err != nil {

View File

@@ -38,8 +38,8 @@ func editorHandleFileOperationErrorRender(ctx *context_service.Context, message,
}
func editorHandleFileOperationError(ctx *context_service.Context, targetBranchName string, err error) {
if errAs := util.ErrorAsLocale(err); errAs != nil {
ctx.JSONError(ctx.Tr(errAs.TrKey, errAs.TrArgs...))
if errAs := util.ErrorAsTranslatable(err); errAs != nil {
ctx.JSONError(errAs.Translate(ctx.Locale))
} else if errAs, ok := errorAs[git.ErrNotExist](err); ok {
ctx.JSONError(ctx.Tr("repo.editor.file_modifying_no_longer_exists", errAs.RelPath))
} else if errAs, ok := errorAs[git_model.ErrLFSFileLocked](err); ok {

View File

@@ -1095,11 +1095,17 @@ func MergePullRequest(ctx *context.Context) {
message += "\n\n" + form.MergeMessageField
}
deleteBranchAfterMerge, err := pull_service.ShouldDeleteBranchAfterMerge(ctx, form.DeleteBranchAfterMerge, ctx.Repo.Repository, pr)
if err != nil {
ctx.ServerError("ShouldDeleteBranchAfterMerge", err)
return
}
if form.MergeWhenChecksSucceed {
// delete all scheduled auto merges
_ = pull_model.DeleteScheduledAutoMerge(ctx, pr.ID)
// schedule auto merge
scheduled, err := automerge.ScheduleAutoMerge(ctx, ctx.Doer, pr, repo_model.MergeStyle(form.Do), message, form.DeleteBranchAfterMerge)
scheduled, err := automerge.ScheduleAutoMerge(ctx, ctx.Doer, pr, repo_model.MergeStyle(form.Do), message, deleteBranchAfterMerge)
if err != nil {
ctx.ServerError("ScheduleAutoMerge", err)
return
@@ -1185,37 +1191,29 @@ func MergePullRequest(ctx *context.Context) {
log.Trace("Pull request merged: %d", pr.ID)
if !form.DeleteBranchAfterMerge {
ctx.JSONRedirect(issue.Link())
return
}
// Don't cleanup when other pr use this branch as head branch
exist, err := issues_model.HasUnmergedPullRequestsByHeadInfo(ctx, pr.HeadRepoID, pr.HeadBranch)
if err != nil {
ctx.ServerError("HasUnmergedPullRequestsByHeadInfo", err)
return
}
if exist {
ctx.JSONRedirect(issue.Link())
return
}
var headRepo *git.Repository
if ctx.Repo != nil && ctx.Repo.Repository != nil && pr.HeadRepoID == ctx.Repo.Repository.ID && ctx.Repo.GitRepo != nil {
headRepo = ctx.Repo.GitRepo
} else {
headRepo, err = gitrepo.OpenRepository(ctx, pr.HeadRepo)
if err != nil {
ctx.ServerError(fmt.Sprintf("OpenRepository[%s]", pr.HeadRepo.FullName()), err)
if deleteBranchAfterMerge {
deleteBranchAfterMergeAndFlashMessage(ctx, pr.ID)
if ctx.Written() {
return
}
defer headRepo.Close()
}
deleteBranch(ctx, pr, headRepo)
ctx.JSONRedirect(issue.Link())
}
func deleteBranchAfterMergeAndFlashMessage(ctx *context.Context, prID int64) {
var fullBranchName string
err := repo_service.DeleteBranchAfterMerge(ctx, ctx.Doer, prID, &fullBranchName)
if errTr := util.ErrorAsTranslatable(err); errTr != nil {
ctx.Flash.Error(errTr.Translate(ctx.Locale))
return
} else if err == nil {
ctx.Flash.Success(ctx.Tr("repo.branch.deletion_success", fullBranchName))
return
}
// catch unknown errors
ctx.ServerError("DeleteBranchAfterMerge", err)
}
// CancelAutoMergePullRequest cancels a scheduled pr
func CancelAutoMergePullRequest(ctx *context.Context) {
issue, ok := getPullInfo(ctx)
@@ -1402,131 +1400,17 @@ func CompareAndPullRequestPost(ctx *context.Context) {
}
// CleanUpPullRequest responses for delete merged branch when PR has been merged
// Used by "DeleteBranchLink" for "delete branch" button
func CleanUpPullRequest(ctx *context.Context) {
issue, ok := getPullInfo(ctx)
if !ok {
return
}
pr := issue.PullRequest
// Don't cleanup unmerged and unclosed PRs and agit PRs
if !pr.HasMerged && !issue.IsClosed && pr.Flow != issues_model.PullRequestFlowGithub {
ctx.NotFound(nil)
deleteBranchAfterMergeAndFlashMessage(ctx, issue.PullRequest.ID)
if ctx.Written() {
return
}
// Don't cleanup when there are other PR's that use this branch as head branch.
exist, err := issues_model.HasUnmergedPullRequestsByHeadInfo(ctx, pr.HeadRepoID, pr.HeadBranch)
if err != nil {
ctx.ServerError("HasUnmergedPullRequestsByHeadInfo", err)
return
}
if exist {
ctx.NotFound(nil)
return
}
if err := pr.LoadHeadRepo(ctx); err != nil {
ctx.ServerError("LoadHeadRepo", err)
return
} else if pr.HeadRepo == nil {
// Forked repository has already been deleted
ctx.NotFound(nil)
return
} else if err = pr.LoadBaseRepo(ctx); err != nil {
ctx.ServerError("LoadBaseRepo", err)
return
} else if err = pr.HeadRepo.LoadOwner(ctx); err != nil {
ctx.ServerError("HeadRepo.LoadOwner", err)
return
}
if err := repo_service.CanDeleteBranch(ctx, pr.HeadRepo, pr.HeadBranch, ctx.Doer); err != nil {
if errors.Is(err, util.ErrPermissionDenied) {
ctx.NotFound(nil)
} else {
ctx.ServerError("CanDeleteBranch", err)
}
return
}
fullBranchName := pr.HeadRepo.Owner.Name + "/" + pr.HeadBranch
var gitBaseRepo *git.Repository
// Assume that the base repo is the current context (almost certainly)
if ctx.Repo != nil && ctx.Repo.Repository != nil && ctx.Repo.Repository.ID == pr.BaseRepoID && ctx.Repo.GitRepo != nil {
gitBaseRepo = ctx.Repo.GitRepo
} else {
// If not just open it
gitBaseRepo, err = gitrepo.OpenRepository(ctx, pr.BaseRepo)
if err != nil {
ctx.ServerError(fmt.Sprintf("OpenRepository[%s]", pr.BaseRepo.FullName()), err)
return
}
defer gitBaseRepo.Close()
}
// Now assume that the head repo is the same as the base repo (reasonable chance)
gitRepo := gitBaseRepo
// But if not: is it the same as the context?
if pr.BaseRepoID != pr.HeadRepoID && ctx.Repo != nil && ctx.Repo.Repository != nil && ctx.Repo.Repository.ID == pr.HeadRepoID && ctx.Repo.GitRepo != nil {
gitRepo = ctx.Repo.GitRepo
} else if pr.BaseRepoID != pr.HeadRepoID {
// Otherwise just load it up
gitRepo, err = gitrepo.OpenRepository(ctx, pr.HeadRepo)
if err != nil {
ctx.ServerError(fmt.Sprintf("OpenRepository[%s]", pr.HeadRepo.FullName()), err)
return
}
defer gitRepo.Close()
}
defer func() {
ctx.JSONRedirect(issue.Link())
}()
// Check if branch has no new commits
headCommitID, err := gitBaseRepo.GetRefCommitID(pr.GetGitHeadRefName())
if err != nil {
log.Error("GetRefCommitID: %v", err)
ctx.Flash.Error(ctx.Tr("repo.branch.deletion_failed", fullBranchName))
return
}
branchCommitID, err := gitRepo.GetBranchCommitID(pr.HeadBranch)
if err != nil {
log.Error("GetBranchCommitID: %v", err)
ctx.Flash.Error(ctx.Tr("repo.branch.deletion_failed", fullBranchName))
return
}
if headCommitID != branchCommitID {
ctx.Flash.Error(ctx.Tr("repo.branch.delete_branch_has_new_commits", fullBranchName))
return
}
deleteBranch(ctx, pr, gitRepo)
}
func deleteBranch(ctx *context.Context, pr *issues_model.PullRequest, gitRepo *git.Repository) {
fullBranchName := pr.HeadRepo.FullName() + ":" + pr.HeadBranch
if err := repo_service.DeleteBranch(ctx, ctx.Doer, pr.HeadRepo, gitRepo, pr.HeadBranch, pr); err != nil {
switch {
case git.IsErrBranchNotExist(err):
ctx.Flash.Error(ctx.Tr("repo.branch.deletion_failed", fullBranchName))
case errors.Is(err, repo_service.ErrBranchIsDefault):
ctx.Flash.Error(ctx.Tr("repo.branch.deletion_failed", fullBranchName))
case errors.Is(err, git_model.ErrBranchIsProtected):
ctx.Flash.Error(ctx.Tr("repo.branch.deletion_failed", fullBranchName))
default:
log.Error("DeleteBranch: %v", err)
ctx.Flash.Error(ctx.Tr("repo.branch.deletion_failed", fullBranchName))
}
return
}
ctx.Flash.Success(ctx.Tr("repo.branch.deletion_success", fullBranchName))
ctx.JSONRedirect(issue.Link())
}
// DownloadPullDiff render a pull's raw diff

View File

@@ -4,18 +4,13 @@
package repo
import (
"bytes"
"io"
"net/http"
"path"
"code.gitea.io/gitea/models/renderhelper"
"code.gitea.io/gitea/modules/charset"
"code.gitea.io/gitea/modules/git"
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/markup"
"code.gitea.io/gitea/modules/typesniffer"
"code.gitea.io/gitea/modules/util"
"code.gitea.io/gitea/services/context"
)
@@ -44,22 +39,8 @@ func RenderFile(ctx *context.Context) {
}
defer dataRc.Close()
buf := make([]byte, 1024)
n, _ := util.ReadAtMost(dataRc, buf)
buf = buf[:n]
st := typesniffer.DetectContentType(buf)
isTextFile := st.IsText()
rd := charset.ToUTF8WithFallbackReader(io.MultiReader(bytes.NewReader(buf), dataRc), charset.ConvertOpts{})
ctx.Resp.Header().Add("Content-Security-Policy", "frame-src 'self'; sandbox allow-scripts")
if markupType := markup.DetectMarkupTypeByFileName(blob.Name()); markupType == "" {
if isTextFile {
_, _ = io.Copy(ctx.Resp, rd)
} else {
http.Error(ctx.Resp, "Unsupported file type render", http.StatusInternalServerError)
}
http.Error(ctx.Resp, "Unsupported file type render", http.StatusBadRequest)
return
}
@@ -68,7 +49,29 @@ func RenderFile(ctx *context.Context) {
CurrentTreePath: path.Dir(ctx.Repo.TreePath),
}).WithRelativePath(ctx.Repo.TreePath).WithInStandalonePage(true)
err = markup.Render(rctx, rd, ctx.Resp)
renderer, err := markup.FindRendererByContext(rctx)
if err != nil {
http.Error(ctx.Resp, "Unable to find renderer", http.StatusBadRequest)
return
}
extRenderer, ok := renderer.(markup.ExternalRenderer)
if !ok {
http.Error(ctx.Resp, "Unable to get external renderer", http.StatusBadRequest)
return
}
// To render PDF in iframe, the sandbox must NOT be used (iframe & CSP header).
// Chrome blocks the PDF rendering when sandboxed, even if all "allow-*" are set.
// HINT: PDF-RENDER-SANDBOX: PDF won't render in sandboxed context
extRendererOpts := extRenderer.GetExternalRendererOptions()
if extRendererOpts.ContentSandbox != "" {
ctx.Resp.Header().Add("Content-Security-Policy", "frame-src 'self'; sandbox "+extRendererOpts.ContentSandbox)
} else {
ctx.Resp.Header().Add("Content-Security-Policy", "frame-src 'self'")
}
err = markup.RenderWithRenderer(rctx, renderer, dataRc, ctx.Resp)
if err != nil {
log.Error("Failed to render file %q: %v", ctx.Repo.TreePath, err)
http.Error(ctx.Resp, "Failed to render file", http.StatusInternalServerError)

View File

@@ -151,17 +151,28 @@ func loadLatestCommitData(ctx *context.Context, latestCommit *git.Commit) bool {
}
func markupRender(ctx *context.Context, renderCtx *markup.RenderContext, input io.Reader) (escaped *charset.EscapeStatus, output template.HTML, err error) {
renderer, err := markup.FindRendererByContext(renderCtx)
if err != nil {
return nil, "", err
}
markupRd, markupWr := io.Pipe()
defer markupWr.Close()
done := make(chan struct{})
go func() {
sb := &strings.Builder{}
// We allow NBSP here this is rendered
escaped, _ = charset.EscapeControlReader(markupRd, sb, ctx.Locale, charset.RuneNBSP)
if markup.RendererNeedPostProcess(renderer) {
escaped, _ = charset.EscapeControlReader(markupRd, sb, ctx.Locale, charset.RuneNBSP) // We allow NBSP here this is rendered
} else {
escaped = &charset.EscapeStatus{}
_, _ = io.Copy(sb, markupRd)
}
output = template.HTML(sb.String())
close(done)
}()
err = markup.Render(renderCtx, input, markupWr)
err = markup.RenderWithRenderer(renderCtx, renderer, input, markupWr)
_ = markupWr.CloseWithError(err)
<-done
return escaped, output, err

View File

@@ -49,14 +49,14 @@ func EnableOrDisableWorkflow(ctx *context.APIContext, workflowID string, isEnabl
func DispatchActionWorkflow(ctx reqctx.RequestContext, doer *user_model.User, repo *repo_model.Repository, gitRepo *git.Repository, workflowID, ref string, processInputs func(model *model.WorkflowDispatch, inputs map[string]any) error) error {
if workflowID == "" {
return util.ErrorWrapLocale(
return util.ErrorWrapTranslatable(
util.NewNotExistErrorf("workflowID is empty"),
"actions.workflow.not_found", workflowID,
)
}
if ref == "" {
return util.ErrorWrapLocale(
return util.ErrorWrapTranslatable(
util.NewNotExistErrorf("ref is empty"),
"form.target_ref_not_exist", ref,
)
@@ -66,7 +66,7 @@ func DispatchActionWorkflow(ctx reqctx.RequestContext, doer *user_model.User, re
cfgUnit := repo.MustGetUnit(ctx, unit.TypeActions)
cfg := cfgUnit.ActionsConfig()
if cfg.IsWorkflowDisabled(workflowID) {
return util.ErrorWrapLocale(
return util.ErrorWrapTranslatable(
util.NewPermissionDeniedErrorf("workflow is disabled"),
"actions.workflow.disabled",
)
@@ -85,7 +85,7 @@ func DispatchActionWorkflow(ctx reqctx.RequestContext, doer *user_model.User, re
runTargetCommit, err = gitRepo.GetBranchCommit(ref)
}
if err != nil {
return util.ErrorWrapLocale(
return util.ErrorWrapTranslatable(
util.NewNotExistErrorf("ref %q doesn't exist", ref),
"form.target_ref_not_exist", ref,
)
@@ -126,7 +126,7 @@ func DispatchActionWorkflow(ctx reqctx.RequestContext, doer *user_model.User, re
}
if entry == nil {
return util.ErrorWrapLocale(
return util.ErrorWrapTranslatable(
util.NewNotExistErrorf("workflow %q doesn't exist", workflowID),
"actions.workflow.not_found", workflowID,
)
@@ -164,7 +164,7 @@ func DispatchActionWorkflow(ctx reqctx.RequestContext, doer *user_model.User, re
}
if len(workflows) == 0 {
return util.ErrorWrapLocale(
return util.ErrorWrapTranslatable(
util.NewNotExistErrorf("workflow %q doesn't exist", workflowID),
"actions.workflow.not_found", workflowID,
)

View File

@@ -8,6 +8,7 @@ import (
"code.gitea.io/gitea/models/auth"
"code.gitea.io/gitea/modules/json"
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/secret"
"code.gitea.io/gitea/modules/setting"
)
@@ -66,9 +67,12 @@ func (source *Source) FromDB(bs []byte) error {
}
if source.BindPasswordEncrypt != "" {
source.BindPassword, err = secret.DecryptSecret(setting.SecretKey, source.BindPasswordEncrypt)
if err != nil {
log.Error("Unable to decrypt bind password for LDAP source, maybe SECRET_KEY is wrong: %v", err)
}
source.BindPasswordEncrypt = ""
}
return err
return nil
}
// ToDB exports a LDAPConfig to a serialized format.

View File

@@ -205,18 +205,6 @@ func handlePullRequestAutoMerge(pullID int64, sha string) {
return
}
var headGitRepo *git.Repository
if pr.BaseRepoID == pr.HeadRepoID {
headGitRepo = baseGitRepo
} else {
headGitRepo, err = gitrepo.OpenRepository(ctx, pr.HeadRepo)
if err != nil {
log.Error("OpenRepository %-v: %v", pr.HeadRepo, err)
return
}
defer headGitRepo.Close()
}
switch pr.Flow {
case issues_model.PullRequestFlowGithub:
headBranchExist := pr.HeadRepo != nil && gitrepo.IsBranchExist(ctx, pr.HeadRepo, pr.HeadBranch)
@@ -276,9 +264,12 @@ func handlePullRequestAutoMerge(pullID int64, sha string) {
return
}
if pr.Flow == issues_model.PullRequestFlowGithub && scheduledPRM.DeleteBranchAfterMerge {
if err := repo_service.DeleteBranch(ctx, doer, pr.HeadRepo, headGitRepo, pr.HeadBranch, pr); err != nil {
log.Error("DeletePullRequestHeadBranch: %v", err)
deleteBranchAfterMerge, err := pull_service.ShouldDeleteBranchAfterMerge(ctx, &scheduledPRM.DeleteBranchAfterMerge, pr.BaseRepo, pr)
if err != nil {
log.Error("ShouldDeleteBranchAfterMerge: %v", err)
} else if deleteBranchAfterMerge {
if err = repo_service.DeleteBranchAfterMerge(ctx, doer, pr.ID, nil); err != nil {
log.Error("DeleteBranchAfterMerge: %v", err)
}
}
}

View File

@@ -540,7 +540,7 @@ type MergePullRequestForm struct {
HeadCommitID string `json:"head_commit_id,omitempty"`
ForceMerge bool `json:"force_merge,omitempty"`
MergeWhenChecksSucceed bool `json:"merge_when_checks_succeed,omitempty"`
DeleteBranchAfterMerge bool `json:"delete_branch_after_merge,omitempty"`
DeleteBranchAfterMerge *bool `json:"delete_branch_after_merge,omitempty"`
}
// Validate validates the fields

View File

@@ -730,3 +730,24 @@ func SetMerged(ctx context.Context, pr *issues_model.PullRequest, mergedCommitID
return true, nil
})
}
func ShouldDeleteBranchAfterMerge(ctx context.Context, userOption *bool, repo *repo_model.Repository, pr *issues_model.PullRequest) (bool, error) {
if pr.Flow != issues_model.PullRequestFlowGithub {
// only support GitHub workflow (branch-based)
// for agit workflow, there is no branch, so nothing to delete
// TODO: maybe in the future, it should delete the "agit reference (refs/for/xxxx)"?
return false, nil
}
// if user has set an option, respect it
if userOption != nil {
return *userOption, nil
}
// otherwise, use repository default
prUnit, err := repo.GetUnit(ctx, unit.TypePullRequests)
if err != nil {
return false, err
}
return prUnit.PullRequestsConfig().DefaultDeleteBranchAfterMerge, nil
}

View File

@@ -485,10 +485,7 @@ func RenameBranch(ctx context.Context, repo *repo_model.Repository, doer *user_m
return "", nil
}
// enmuerates all branch related errors
var (
ErrBranchIsDefault = errors.New("branch is default")
)
var ErrBranchIsDefault = util.ErrorWrap(util.ErrPermissionDenied, "branch is default")
func CanDeleteBranch(ctx context.Context, repo *repo_model.Repository, branchName string, doer *user_model.User) error {
if branchName == repo.DefaultBranch {
@@ -748,3 +745,89 @@ func GetBranchDivergingInfo(ctx reqctx.RequestContext, baseRepo *repo_model.Repo
info.BaseHasNewCommits = info.HeadCommitsBehind > 0
return info, nil
}
func DeleteBranchAfterMerge(ctx context.Context, doer *user_model.User, prID int64, outFullBranchName *string) error {
pr, err := issues_model.GetPullRequestByID(ctx, prID)
if err != nil {
return err
}
if err = pr.LoadIssue(ctx); err != nil {
return err
}
if err = pr.LoadBaseRepo(ctx); err != nil {
return err
}
if err := pr.LoadHeadRepo(ctx); err != nil {
return err
}
if pr.HeadRepo == nil {
// Forked repository has already been deleted
return util.ErrorWrapTranslatable(util.ErrNotExist, "repo.branch.deletion_failed", "(deleted-repo):"+pr.HeadBranch)
}
if err = pr.HeadRepo.LoadOwner(ctx); err != nil {
return err
}
fullBranchName := pr.HeadRepo.FullName() + ":" + pr.HeadBranch
if outFullBranchName != nil {
*outFullBranchName = fullBranchName
}
errFailedToDelete := func(err error) error {
return util.ErrorWrapTranslatable(err, "repo.branch.deletion_failed", fullBranchName)
}
// Don't clean up unmerged and unclosed PRs and agit PRs
if !pr.HasMerged && !pr.Issue.IsClosed && pr.Flow != issues_model.PullRequestFlowGithub {
return errFailedToDelete(util.ErrUnprocessableContent)
}
// Don't clean up when there are other PR's that use this branch as head branch.
exist, err := issues_model.HasUnmergedPullRequestsByHeadInfo(ctx, pr.HeadRepoID, pr.HeadBranch)
if err != nil {
return err
}
if exist {
return errFailedToDelete(util.ErrUnprocessableContent)
}
if err := CanDeleteBranch(ctx, pr.HeadRepo, pr.HeadBranch, doer); err != nil {
if errors.Is(err, util.ErrPermissionDenied) {
return errFailedToDelete(err)
}
return err
}
gitBaseRepo, gitBaseCloser, err := gitrepo.RepositoryFromContextOrOpen(ctx, pr.BaseRepo)
if err != nil {
return err
}
defer gitBaseCloser.Close()
gitHeadRepo, gitHeadCloser, err := gitrepo.RepositoryFromContextOrOpen(ctx, pr.HeadRepo)
if err != nil {
return err
}
defer gitHeadCloser.Close()
// Check if branch has no new commits
headCommitID, err := gitBaseRepo.GetRefCommitID(pr.GetGitHeadRefName())
if err != nil {
log.Error("GetRefCommitID: %v", err)
return errFailedToDelete(err)
}
branchCommitID, err := gitHeadRepo.GetBranchCommitID(pr.HeadBranch)
if err != nil {
log.Error("GetBranchCommitID: %v", err)
return errFailedToDelete(err)
}
if headCommitID != branchCommitID {
return util.ErrorWrapTranslatable(util.ErrUnprocessableContent, "repo.branch.delete_branch_has_new_commits", fullBranchName)
}
err = DeleteBranch(ctx, doer, pr.HeadRepo, gitHeadRepo, pr.HeadBranch, pr)
if errors.Is(err, util.ErrPermissionDenied) || errors.Is(err, util.ErrNotExist) {
return errFailedToDelete(err)
}
return err
}

View File

@@ -51,7 +51,7 @@
</div>
</div>
<div class="item-section-right">
<button class="delete-button ui button" data-url="{{.DeleteBranchLink}}">{{ctx.Locale.Tr "repo.branch.delete_html"}}</button>
<button class="ui button link-action delete-branch-after-merge" data-url="{{.DeleteBranchLink}}">{{ctx.Locale.Tr "repo.branch.delete_html"}}</button>
</div>
</div>
{{end}}
@@ -69,7 +69,7 @@
</div>
{{if and .IsPullBranchDeletable (not .IsPullRequestBroken)}}
<div class="item-section-right">
<button class="delete-button ui button" data-url="{{.DeleteBranchLink}}">{{ctx.Locale.Tr "repo.branch.delete_html"}}</button>
<button class="ui button link-action delete-branch-after-merge" data-url="{{.DeleteBranchLink}}">{{ctx.Locale.Tr "repo.branch.delete_html"}}</button>
</div>
{{end}}
</div>

View File

@@ -129,7 +129,7 @@ func TestAPIRepoIssueConfigPaths(t *testing.T) {
configData, err := yaml.Marshal(configMap)
assert.NoError(t, err)
_, err = createFileInBranch(owner, repo, fullPath, repo.DefaultBranch, string(configData))
_, err = createFile(owner, repo, fullPath, string(configData))
assert.NoError(t, err)
issueConfig := getIssueConfig(t, owner.Name, repo.Name)

View File

@@ -17,19 +17,23 @@ import (
issues_model "code.gitea.io/gitea/models/issues"
"code.gitea.io/gitea/models/perm"
repo_model "code.gitea.io/gitea/models/repo"
unit_model "code.gitea.io/gitea/models/unit"
"code.gitea.io/gitea/models/unittest"
user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/setting"
api "code.gitea.io/gitea/modules/structs"
"code.gitea.io/gitea/modules/util"
"code.gitea.io/gitea/services/convert"
"code.gitea.io/gitea/services/forms"
"code.gitea.io/gitea/services/gitdiff"
issue_service "code.gitea.io/gitea/services/issue"
pull_service "code.gitea.io/gitea/services/pull"
repo_service "code.gitea.io/gitea/services/repository"
files_service "code.gitea.io/gitea/services/repository/files"
"code.gitea.io/gitea/tests"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestAPIViewPulls(t *testing.T) {
@@ -186,6 +190,76 @@ func TestAPIMergePullWIP(t *testing.T) {
MakeRequest(t, req, http.StatusMethodNotAllowed)
}
func TestAPIMergePull(t *testing.T) {
onGiteaRun(t, func(t *testing.T, giteaURL *url.URL) {
repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID})
apiCtx := NewAPITestContext(t, repo.OwnerName, repo.Name, auth_model.AccessTokenScopeWriteRepository)
checkBranchExists := func(t *testing.T, branchName string, status int) {
req := NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/%s/%s/branches/%s", owner.Name, repo.Name, branchName)).AddTokenAuth(apiCtx.Token)
MakeRequest(t, req, status)
}
createTestBranchPR := func(t *testing.T, branchName string) *api.PullRequest {
testCreateFileInBranch(t, owner, repo, createFileInBranchOptions{NewBranch: branchName}, map[string]string{"a-new-file-" + branchName + ".txt": "dummy content"})
prDTO, err := doAPICreatePullRequest(apiCtx, repo.OwnerName, repo.Name, repo.DefaultBranch, branchName)(t)
require.NoError(t, err)
return &prDTO
}
performMerge := func(t *testing.T, prIndex int64, params map[string]any, optExpectedStatus ...int) {
req := NewRequestWithJSON(t, http.MethodPost, fmt.Sprintf("/api/v1/repos/%s/%s/pulls/%d/merge", owner.Name, repo.Name, prIndex), params).AddTokenAuth(apiCtx.Token)
expectedStatus := util.OptionalArg(optExpectedStatus, http.StatusOK)
MakeRequest(t, req, expectedStatus)
}
t.Run("Normal", func(t *testing.T) {
newBranch := "test-pull-1"
prDTO := createTestBranchPR(t, newBranch)
performMerge(t, prDTO.Index, map[string]any{"do": "merge"})
checkBranchExists(t, newBranch, http.StatusOK)
// try to merge again, make sure we cannot perform a merge on the same PR
performMerge(t, prDTO.Index, map[string]any{"do": "merge"}, http.StatusMethodNotAllowed)
})
t.Run("DeleteBranchAfterMergePassedByFormField", func(t *testing.T) {
newBranch := "test-pull-2"
prDTO := createTestBranchPR(t, newBranch)
performMerge(t, prDTO.Index, map[string]any{"do": "merge", "delete_branch_after_merge": true})
checkBranchExists(t, newBranch, http.StatusNotFound)
})
updateRepoUnitDefaultDeleteBranchAfterMerge := func(t *testing.T, repo *repo_model.Repository, value bool) {
prUnit, err := repo.GetUnit(t.Context(), unit_model.TypePullRequests)
require.NoError(t, err)
prUnit.PullRequestsConfig().DefaultDeleteBranchAfterMerge = value
require.NoError(t, repo_service.UpdateRepositoryUnits(t.Context(), repo, []repo_model.RepoUnit{{
RepoID: repo.ID,
Type: unit_model.TypePullRequests,
Config: prUnit.PullRequestsConfig(),
}}, nil))
}
t.Run("DeleteBranchAfterMergePassedByRepoSettings", func(t *testing.T) {
newBranch := "test-pull-3"
prDTO := createTestBranchPR(t, newBranch)
updateRepoUnitDefaultDeleteBranchAfterMerge(t, repo, true)
performMerge(t, prDTO.Index, map[string]any{"do": "merge"})
checkBranchExists(t, newBranch, http.StatusNotFound)
})
t.Run("DeleteBranchAfterMergeFormFieldIsSetButNotRepoSettings", func(t *testing.T) {
newBranch := "test-pull-4"
prDTO := createTestBranchPR(t, newBranch)
updateRepoUnitDefaultDeleteBranchAfterMerge(t, repo, false)
performMerge(t, prDTO.Index, map[string]any{"do": "merge", "delete_branch_after_merge": true})
checkBranchExists(t, newBranch, http.StatusNotFound)
})
})
}
func TestAPICreatePullSuccess(t *testing.T) {
defer tests.PrepareTestEnv(t)()
repo10 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 10})

View File

@@ -133,7 +133,7 @@ func BenchmarkAPICreateFileSmall(b *testing.B) {
b.ResetTimer()
for n := 0; b.Loop(); n++ {
treePath := fmt.Sprintf("update/file%d.txt", n)
_, _ = createFileInBranch(user2, repo1, treePath, repo1.DefaultBranch, treePath)
_, _ = createFile(user2, repo1, treePath)
}
})
}
@@ -149,7 +149,7 @@ func BenchmarkAPICreateFileMedium(b *testing.B) {
for n := 0; b.Loop(); n++ {
treePath := fmt.Sprintf("update/file%d.txt", n)
copy(data, treePath)
_, _ = createFileInBranch(user2, repo1, treePath, repo1.DefaultBranch, treePath)
_, _ = createFile(user2, repo1, treePath)
}
})
}

View File

@@ -6,26 +6,36 @@ package integration
import (
"context"
"strings"
"testing"
repo_model "code.gitea.io/gitea/models/repo"
user_model "code.gitea.io/gitea/models/user"
api "code.gitea.io/gitea/modules/structs"
"code.gitea.io/gitea/modules/util"
files_service "code.gitea.io/gitea/services/repository/files"
"github.com/stretchr/testify/require"
)
func createFileInBranch(user *user_model.User, repo *repo_model.Repository, treePath, branchName, content string) (*api.FilesResponse, error) {
type createFileInBranchOptions struct {
OldBranch, NewBranch string
}
func testCreateFileInBranch(t *testing.T, user *user_model.User, repo *repo_model.Repository, createOpts createFileInBranchOptions, files map[string]string) *api.FilesResponse {
resp, err := createFileInBranch(user, repo, createOpts, files)
require.NoError(t, err)
return resp
}
func createFileInBranch(user *user_model.User, repo *repo_model.Repository, createOpts createFileInBranchOptions, files map[string]string) (*api.FilesResponse, error) {
ctx := context.TODO()
opts := &files_service.ChangeRepoFilesOptions{
Files: []*files_service.ChangeRepoFile{
{
Operation: "create",
TreePath: treePath,
ContentReader: strings.NewReader(content),
},
},
OldBranch: branchName,
Author: nil,
Committer: nil,
opts := &files_service.ChangeRepoFilesOptions{OldBranch: createOpts.OldBranch, NewBranch: createOpts.NewBranch}
for path, content := range files {
opts.Files = append(opts.Files, &files_service.ChangeRepoFile{
Operation: "create",
TreePath: path,
ContentReader: strings.NewReader(content),
})
}
return files_service.ChangeRepoFiles(ctx, repo, user, opts)
}
@@ -53,10 +63,12 @@ func createOrReplaceFileInBranch(user *user_model.User, repo *repo_model.Reposit
return err
}
_, err = createFileInBranch(user, repo, treePath, branchName, content)
_, err = createFileInBranch(user, repo, createFileInBranchOptions{OldBranch: branchName}, map[string]string{treePath: content})
return err
}
func createFile(user *user_model.User, repo *repo_model.Repository, treePath string) (*api.FilesResponse, error) {
return createFileInBranch(user, repo, treePath, repo.DefaultBranch, "This is a NEW file")
// TODO: replace all usages of this function with testCreateFileInBranch or testCreateFile
func createFile(user *user_model.User, repo *repo_model.Repository, treePath string, optContent ...string) (*api.FilesResponse, error) {
content := util.OptionalArg(optContent, "This is a NEW file") // some tests need this default content because its SHA is hardcoded
return createFileInBranch(user, repo, createFileInBranchOptions{}, map[string]string{treePath: content})
}

View File

@@ -4,18 +4,22 @@
package integration
import (
"bytes"
"io"
"net/http"
"net/url"
"strings"
"testing"
repo_model "code.gitea.io/gitea/models/repo"
"code.gitea.io/gitea/models/unittest"
user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/markup"
"code.gitea.io/gitea/modules/markup/external"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/test"
"code.gitea.io/gitea/tests"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestExternalMarkupRenderer(t *testing.T) {
@@ -25,36 +29,79 @@ func TestExternalMarkupRenderer(t *testing.T) {
return
}
req := NewRequest(t, "GET", "/user30/renderer/src/branch/master/README.html")
resp := MakeRequest(t, req, http.StatusOK)
assert.Equal(t, "text/html; charset=utf-8", resp.Header().Get("Content-Type"))
onGiteaRun(t, func(t *testing.T, _ *url.URL) {
user2 := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
repo1 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
_, err := createFile(user2, repo1, "file.no-sanitizer", "master", `any content`)
require.NoError(t, err)
bs, err := io.ReadAll(resp.Body)
assert.NoError(t, err)
t.Run("RenderNoSanitizer", func(t *testing.T) {
req := NewRequest(t, "GET", "/user2/repo1/src/branch/master/file.no-sanitizer")
resp := MakeRequest(t, req, http.StatusOK)
doc := NewHTMLParser(t, resp.Body)
div := doc.Find("div.file-view")
data, err := div.Html()
assert.NoError(t, err)
assert.Equal(t, `<script>window.alert("hi")</script>`, strings.TrimSpace(data))
})
})
doc := NewHTMLParser(t, bytes.NewBuffer(bs))
div := doc.Find("div.file-view")
data, err := div.Html()
assert.NoError(t, err)
assert.Equal(t, "<div>\n\ttest external renderer\n</div>", strings.TrimSpace(data))
t.Run("RenderContentDirectly", func(t *testing.T) {
req := NewRequest(t, "GET", "/user30/renderer/src/branch/master/README.html")
resp := MakeRequest(t, req, http.StatusOK)
assert.Equal(t, "text/html; charset=utf-8", resp.Header().Get("Content-Type"))
r := markup.GetRendererByFileName("a.html").(*external.Renderer)
r.RenderContentMode = setting.RenderContentModeIframe
doc := NewHTMLParser(t, resp.Body)
div := doc.Find("div.file-view")
data, err := div.Html()
assert.NoError(t, err)
assert.Equal(t, "<div>\n\ttest external renderer\n</div>", strings.TrimSpace(data))
})
req = NewRequest(t, "GET", "/user30/renderer/src/branch/master/README.html")
resp = MakeRequest(t, req, http.StatusOK)
assert.Equal(t, "text/html; charset=utf-8", resp.Header().Get("Content-Type"))
bs, err = io.ReadAll(resp.Body)
assert.NoError(t, err)
doc = NewHTMLParser(t, bytes.NewBuffer(bs))
iframe := doc.Find("iframe")
assert.Equal(t, "/user30/renderer/render/branch/master/README.html", iframe.AttrOr("src", ""))
// above tested "no-sanitizer" mode, then we test iframe mode below
r := markup.GetRendererByFileName("any-file.html").(*external.Renderer)
defer test.MockVariableValue(&r.RenderContentMode, setting.RenderContentModeIframe)()
r = markup.GetRendererByFileName("any-file.no-sanitizer").(*external.Renderer)
defer test.MockVariableValue(&r.RenderContentMode, setting.RenderContentModeIframe)()
req = NewRequest(t, "GET", "/user30/renderer/render/branch/master/README.html")
resp = MakeRequest(t, req, http.StatusOK)
assert.Equal(t, "text/html; charset=utf-8", resp.Header().Get("Content-Type"))
bs, err = io.ReadAll(resp.Body)
assert.NoError(t, err)
assert.Equal(t, "frame-src 'self'; sandbox allow-scripts", resp.Header().Get("Content-Security-Policy"))
assert.Equal(t, "<div>\n\ttest external renderer\n</div>\n", string(bs))
t.Run("RenderContentInIFrame", func(t *testing.T) {
t.Run("DefaultSandbox", func(t *testing.T) {
req := NewRequest(t, "GET", "/user30/renderer/src/branch/master/README.html")
t.Run("ParentPage", func(t *testing.T) {
respParent := MakeRequest(t, req, http.StatusOK)
assert.Equal(t, "text/html; charset=utf-8", respParent.Header().Get("Content-Type"))
iframe := NewHTMLParser(t, respParent.Body).Find("iframe.external-render-iframe")
assert.Empty(t, iframe.AttrOr("src", "")) // src should be empty, "data-src" is used instead
// default sandbox on parent page
assert.Equal(t, "allow-scripts allow-popups", iframe.AttrOr("sandbox", ""))
assert.Equal(t, "/user30/renderer/render/branch/master/README.html", iframe.AttrOr("data-src", ""))
})
t.Run("SubPage", func(t *testing.T) {
req = NewRequest(t, "GET", "/user30/renderer/render/branch/master/README.html")
respSub := MakeRequest(t, req, http.StatusOK)
assert.Equal(t, "text/html; charset=utf-8", respSub.Header().Get("Content-Type"))
// default sandbox in sub page response
assert.Equal(t, "frame-src 'self'; sandbox allow-scripts allow-popups", respSub.Header().Get("Content-Security-Policy"))
assert.Equal(t, "<script src=\"/assets/js/external-render-iframe.js\"></script><link rel=\"stylesheet\" href=\"/assets/css/external-render-iframe.css\"><div>\n\ttest external renderer\n</div>\n", respSub.Body.String())
})
})
t.Run("NoSanitizerNoSandbox", func(t *testing.T) {
req := NewRequest(t, "GET", "/user2/repo1/src/branch/master/file.no-sanitizer")
respParent := MakeRequest(t, req, http.StatusOK)
iframe := NewHTMLParser(t, respParent.Body).Find("iframe.external-render-iframe")
assert.Equal(t, "/user2/repo1/render/branch/master/file.no-sanitizer", iframe.AttrOr("data-src", ""))
req = NewRequest(t, "GET", "/user2/repo1/render/branch/master/file.no-sanitizer")
respSub := MakeRequest(t, req, http.StatusOK)
// no sandbox (disabled by RENDER_CONTENT_SANDBOX)
assert.Empty(t, iframe.AttrOr("sandbox", ""))
assert.Equal(t, "frame-src 'self'", respSub.Header().Get("Content-Security-Policy"))
})
})
}

View File

@@ -79,7 +79,7 @@ func testPullCleanUp(t *testing.T, session *TestSession, user, repo, pullnum str
// Click the little button to create a pull
htmlDoc := NewHTMLParser(t, resp.Body)
link, exists := htmlDoc.doc.Find(".timeline-item .delete-button").Attr("data-url")
link, exists := htmlDoc.doc.Find(".timeline-item .delete-branch-after-merge").Attr("data-url")
assert.True(t, exists, "The template has changed, can not find delete button url")
req = NewRequestWithValues(t, "POST", link, map[string]string{
"_csrf": htmlDoc.GetCSRF(),

View File

@@ -114,9 +114,19 @@ ENABLED = true
[markup.html]
ENABLED = true
FILE_EXTENSIONS = .html
RENDER_COMMAND = `go run build/test-echo.go`
IS_INPUT_FILE = false
RENDER_CONTENT_MODE=sanitized
RENDER_COMMAND = go run build/test-echo.go
;RENDER_COMMAND = cat
;IS_INPUT_FILE = true
RENDER_CONTENT_MODE = sanitized
[markup.no-sanitizer]
ENABLED = true
FILE_EXTENSIONS = .no-sanitizer
RENDER_COMMAND = echo '<script>window.alert("hi")</script>'
; This test case is reused, at first it is used to test "no-sanitizer" (sandbox doesn't take effect here)
; Then it will be updated and used to test "iframe + sandbox-disabled"
RENDER_CONTENT_MODE = no-sanitizer
RENDER_CONTENT_SANDBOX = disabled
[actions]
ENABLED = true

View File

@@ -545,6 +545,11 @@ In markup content, we always use bottom margin for all elements */
margin: 0 0.25em;
}
.external-render-iframe {
width: 100%;
height: max(300px, 80vh);
}
.markup-content-iframe {
display: block;
border: none;

View File

@@ -0,0 +1 @@
/* dummy */

View File

@@ -4,6 +4,7 @@ import {initMarkupCodeCopy} from './codecopy.ts';
import {initMarkupRenderAsciicast} from './asciicast.ts';
import {initMarkupTasklist} from './tasklist.ts';
import {registerGlobalSelectorFunc} from '../modules/observer.ts';
import {initMarkupRenderIframe} from './render-iframe.ts';
// code that runs for all markup content
export function initMarkupContent(): void {
@@ -13,5 +14,6 @@ export function initMarkupContent(): void {
initMarkupCodeMermaid(el);
initMarkupCodeMath(el);
initMarkupRenderAsciicast(el);
initMarkupRenderIframe(el);
});
}

View File

@@ -0,0 +1,32 @@
import {generateElemId, queryElemChildren} from '../utils/dom.ts';
import {isDarkTheme} from '../utils.ts';
export async function loadRenderIframeContent(iframe: HTMLIFrameElement) {
const iframeSrcUrl = iframe.getAttribute('data-src');
if (!iframe.id) iframe.id = generateElemId('gitea-iframe-');
window.addEventListener('message', (e) => {
if (!e.data?.giteaIframeCmd || e.data?.giteaIframeId !== iframe.id) return;
const cmd = e.data.giteaIframeCmd;
if (cmd === 'resize') {
iframe.style.height = `${e.data.iframeHeight}px`;
} else if (cmd === 'open-link') {
if (e.data.anchorTarget === '_blank') {
window.open(e.data.openLink, '_blank');
} else {
window.location.href = e.data.openLink;
}
} else {
throw new Error(`Unknown gitea iframe cmd: ${cmd}`);
}
});
const u = new URL(iframeSrcUrl, window.location.origin);
u.searchParams.set('gitea-is-dark-theme', String(isDarkTheme()));
u.searchParams.set('gitea-iframe-id', iframe.id);
iframe.src = u.href;
}
export function initMarkupRenderIframe(el: HTMLElement) {
queryElemChildren(el, 'iframe.external-render-iframe', loadRenderIframeContent);
}

View File

@@ -0,0 +1,43 @@
/* To manually test:
[markup.in-iframe]
ENABLED = true
FILE_EXTENSIONS = .in-iframe
RENDER_CONTENT_MODE = iframe
RENDER_COMMAND = `echo '<div style="width: 100%; height: 2000px; border: 10px solid red; box-sizing: border-box;"><a href="/">a link</a> <a target="_blank" href="//gitea.com">external link</a></div>'`
;RENDER_COMMAND = cat /path/to/file.pdf
;RENDER_CONTENT_SANDBOX = disabled
*/
function mainExternalRenderIframe() {
const u = new URL(window.location.href);
const iframeId = u.searchParams.get('gitea-iframe-id');
// iframe is in different origin, so we need to use postMessage to communicate
const postIframeMsg = (cmd: string, data: Record<string, any> = {}) => {
window.parent.postMessage({giteaIframeCmd: cmd, giteaIframeId: iframeId, ...data}, '*');
};
const updateIframeHeight = () => postIframeMsg('resize', {iframeHeight: document.documentElement.scrollHeight});
updateIframeHeight();
window.addEventListener('DOMContentLoaded', updateIframeHeight);
// the easiest way to handle dynamic content changes and easy to debug, can be fine-tuned in the future
setInterval(updateIframeHeight, 1000);
// no way to open an absolute link with CSP frame-src, it also needs some tricks like "postMessage" or "copy the link to clipboard"
const openIframeLink = (link: string, target: string) => postIframeMsg('open-link', {openLink: link, anchorTarget: target});
document.addEventListener('click', (e) => {
const el = e.target as HTMLAnchorElement;
if (el.nodeName !== 'A') return;
const href = el.getAttribute('href') || '';
// safe links: "./any", "../any", "/any", "//host/any", "http://host/any", "https://host/any"
if (href.startsWith('.') || href.startsWith('/') || href.startsWith('http://') || href.startsWith('https://')) {
e.preventDefault();
openIframeLink(href, el.getAttribute('target'));
}
});
}
mainExternalRenderIframe();

View File

@@ -75,6 +75,10 @@ export default {
fileURLToPath(new URL('web_src/js/standalone/swagger.ts', import.meta.url)),
fileURLToPath(new URL('web_src/css/standalone/swagger.css', import.meta.url)),
],
'external-render-iframe': [
fileURLToPath(new URL('web_src/js/standalone/external-render-iframe.ts', import.meta.url)),
fileURLToPath(new URL('web_src/css/standalone/external-render-iframe.css', import.meta.url)),
],
'eventsource.sharedworker': [
fileURLToPath(new URL('web_src/js/features/eventsource.sharedworker.ts', import.meta.url)),
],