Compare commits

...

8 Commits

Author SHA1 Message Date
silverwind 69cd909e0b
Merge 92c9876892 into 181db69e0c 2025-06-22 21:05:57 +02:00
Kilisei 181db69e0c
Use `shallowRef` instead of `ref` in `.vue` files where possible (#34813)
This PR improves some `.vue` components by using `shallowRef instead of
ref`, which `should improve performance`. It's probably not significant,
but it's an improvement because Vue no longer deep watches the ref
(shallowRef). Also i used `useTemplateRef` instead of `ref`.

---------

Signed-off-by: wxiaoguang <wxiaoguang@gmail.com>
Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
2025-06-22 21:37:03 +08:00
Brecht Van Lommel a46b16f10f
Edit file workflow for creating a fork and proposing changes (#34240)
When viewing a file that the user can't edit because they can't write to
the branch, the new, upload, patch, edit and delete functionality is no
longer disabled.

If no user fork of the repository exists, there is now a page to create one.
It will automatically create a fork with a single branch matching the one
being viewed, and a unique repository name will be automatically picked.

When a fork exists, but it's archived, a mirror or the user can't write
code to it, there will instead be a message explaining the situation.

If the usable fork exists, a message will appear at the top of the edit page
explaining that the changes will be applied to a branch in the fork. The
base repository branch will be pushed to a new branch to the fork, and
then the edits will be applied on top.

The suggestion to fork happens when accessing /_edit/, so that for
example online documentation can have an "edit this page" link to
the base repository that does the right thing.

Also includes changes to properly report errors when trying to commit
to a new branch that is protected, and when trying to commit to an
existing branch when choosing the new branch option.

Resolves #9017, #20882

---------

Co-authored-by: Brecht Van Lommel <brecht@blender.org>
Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
2025-06-22 12:43:43 +00:00
wxiaoguang 1748045285
Refactor packages (#34777) 2025-06-22 19:22:51 +08:00
wxiaoguang f114c388ff
Refactor wiki (#34805)
Remove unclear code
2025-06-22 18:53:33 +08:00
silverwind 92c9876892
Update templates/base/head_navbar.tmpl 2025-06-03 09:33:02 +02:00
silverwind f31b5cb14b
fint-tune 2025-06-02 19:10:18 +02:00
silverwind 4f10849ecd
Change admin indicator to icon-only with title 2025-06-02 19:02:41 +02:00
57 changed files with 1037 additions and 912 deletions

View File

@ -653,7 +653,7 @@ func (repo *Repository) AllowsPulls(ctx context.Context) bool {
// CanEnableEditor returns true if repository meets the requirements of web editor. // CanEnableEditor returns true if repository meets the requirements of web editor.
func (repo *Repository) CanEnableEditor() bool { func (repo *Repository) CanEnableEditor() bool {
return !repo.IsMirror return !repo.IsMirror && !repo.IsArchived
} }
// DescriptionHTML does special handles to description and return HTML string. // DescriptionHTML does special handles to description and return HTML string.

View File

@ -4,6 +4,7 @@
package markup package markup
import ( import (
"html/template"
"io" "io"
"net/url" "net/url"
"regexp" "regexp"
@ -92,9 +93,9 @@ func (st *Sanitizer) createDefaultPolicy() *bluemonday.Policy {
return policy return policy
} }
// Sanitize takes a string that contains a HTML fragment or document and applies policy whitelist. // Sanitize use default sanitizer policy to sanitize a string
func Sanitize(s string) string { func Sanitize(s string) template.HTML {
return GetDefaultSanitizer().defaultPolicy.Sanitize(s) return template.HTML(GetDefaultSanitizer().defaultPolicy.Sanitize(s))
} }
// SanitizeReader sanitizes a Reader // SanitizeReader sanitizes a Reader

View File

@ -69,6 +69,6 @@ func TestSanitizer(t *testing.T) {
} }
for i := 0; i < len(testCases); i += 2 { for i := 0; i < len(testCases); i += 2 {
assert.Equal(t, testCases[i+1], Sanitize(testCases[i])) assert.Equal(t, testCases[i+1], string(Sanitize(testCases[i])))
} }
} }

View File

@ -176,9 +176,9 @@ func safeHTML(s any) template.HTML {
panic(fmt.Sprintf("unexpected type %T", s)) panic(fmt.Sprintf("unexpected type %T", s))
} }
// SanitizeHTML sanitizes the input by pre-defined markdown rules // SanitizeHTML sanitizes the input by default sanitization rules.
func SanitizeHTML(s string) template.HTML { func SanitizeHTML(s string) template.HTML {
return template.HTML(markup.Sanitize(s)) return markup.Sanitize(s)
} }
func htmlEscape(s any) template.HTML { func htmlEscape(s any) template.HTML {

View File

@ -6,6 +6,7 @@ package web
import ( import (
"net/http" "net/http"
"regexp" "regexp"
"slices"
"strings" "strings"
"code.gitea.io/gitea/modules/container" "code.gitea.io/gitea/modules/container"
@ -36,11 +37,21 @@ func (g *RouterPathGroup) ServeHTTP(resp http.ResponseWriter, req *http.Request)
g.r.chiRouter.NotFoundHandler().ServeHTTP(resp, req) g.r.chiRouter.NotFoundHandler().ServeHTTP(resp, req)
} }
type RouterPathGroupPattern struct {
re *regexp.Regexp
params []routerPathParam
middlewares []any
}
// MatchPath matches the request method, and uses regexp to match the path. // MatchPath matches the request method, and uses regexp to match the path.
// The pattern uses "<...>" to define path parameters, for example: "/<name>" (different from chi router) // The pattern uses "<...>" to define path parameters, for example, "/<name>" (different from chi router)
// It is only designed to resolve some special cases which chi router can't handle. // It is only designed to resolve some special cases that chi router can't handle.
// For most cases, it shouldn't be used because it needs to iterate all rules to find the matched one (inefficient). // For most cases, it shouldn't be used because it needs to iterate all rules to find the matched one (inefficient).
func (g *RouterPathGroup) MatchPath(methods, pattern string, h ...any) { func (g *RouterPathGroup) MatchPath(methods, pattern string, h ...any) {
g.MatchPattern(methods, g.PatternRegexp(pattern), h...)
}
func (g *RouterPathGroup) MatchPattern(methods string, pattern *RouterPathGroupPattern, h ...any) {
g.matchers = append(g.matchers, newRouterPathMatcher(methods, pattern, h...)) g.matchers = append(g.matchers, newRouterPathMatcher(methods, pattern, h...))
} }
@ -96,8 +107,8 @@ func isValidMethod(name string) bool {
return false return false
} }
func newRouterPathMatcher(methods, pattern string, h ...any) *routerPathMatcher { func newRouterPathMatcher(methods string, patternRegexp *RouterPathGroupPattern, h ...any) *routerPathMatcher {
middlewares, handlerFunc := wrapMiddlewareAndHandler(nil, h) middlewares, handlerFunc := wrapMiddlewareAndHandler(patternRegexp.middlewares, h)
p := &routerPathMatcher{methods: make(container.Set[string]), middlewares: middlewares, handlerFunc: handlerFunc} p := &routerPathMatcher{methods: make(container.Set[string]), middlewares: middlewares, handlerFunc: handlerFunc}
for method := range strings.SplitSeq(methods, ",") { for method := range strings.SplitSeq(methods, ",") {
method = strings.TrimSpace(method) method = strings.TrimSpace(method)
@ -106,19 +117,25 @@ func newRouterPathMatcher(methods, pattern string, h ...any) *routerPathMatcher
} }
p.methods.Add(method) p.methods.Add(method)
} }
p.re, p.params = patternRegexp.re, patternRegexp.params
return p
}
func patternRegexp(pattern string, h ...any) *RouterPathGroupPattern {
p := &RouterPathGroupPattern{middlewares: slices.Clone(h)}
re := []byte{'^'} re := []byte{'^'}
lastEnd := 0 lastEnd := 0
for lastEnd < len(pattern) { for lastEnd < len(pattern) {
start := strings.IndexByte(pattern[lastEnd:], '<') start := strings.IndexByte(pattern[lastEnd:], '<')
if start == -1 { if start == -1 {
re = append(re, pattern[lastEnd:]...) re = append(re, regexp.QuoteMeta(pattern[lastEnd:])...)
break break
} }
end := strings.IndexByte(pattern[lastEnd+start:], '>') end := strings.IndexByte(pattern[lastEnd+start:], '>')
if end == -1 { if end == -1 {
panic("invalid pattern: " + pattern) panic("invalid pattern: " + pattern)
} }
re = append(re, pattern[lastEnd:lastEnd+start]...) re = append(re, regexp.QuoteMeta(pattern[lastEnd:lastEnd+start])...)
partName, partExp, _ := strings.Cut(pattern[lastEnd+start+1:lastEnd+start+end], ":") partName, partExp, _ := strings.Cut(pattern[lastEnd+start+1:lastEnd+start+end], ":")
lastEnd += start + end + 1 lastEnd += start + end + 1
@ -140,7 +157,10 @@ func newRouterPathMatcher(methods, pattern string, h ...any) *routerPathMatcher
p.params = append(p.params, param) p.params = append(p.params, param)
} }
re = append(re, '$') re = append(re, '$')
reStr := string(re) p.re = regexp.MustCompile(string(re))
p.re = regexp.MustCompile(reStr)
return p return p
} }
func (g *RouterPathGroup) PatternRegexp(pattern string, h ...any) *RouterPathGroupPattern {
return patternRegexp(pattern, h...)
}

View File

@ -34,7 +34,7 @@ func TestPathProcessor(t *testing.T) {
testProcess := func(pattern, uri string, expectedPathParams map[string]string) { testProcess := func(pattern, uri string, expectedPathParams map[string]string) {
chiCtx := chi.NewRouteContext() chiCtx := chi.NewRouteContext()
chiCtx.RouteMethod = "GET" chiCtx.RouteMethod = "GET"
p := newRouterPathMatcher("GET", pattern, http.NotFound) p := newRouterPathMatcher("GET", patternRegexp(pattern), http.NotFound)
assert.True(t, p.matchPath(chiCtx, uri), "use pattern %s to process uri %s", pattern, uri) assert.True(t, p.matchPath(chiCtx, uri), "use pattern %s to process uri %s", pattern, uri)
assert.Equal(t, expectedPathParams, chiURLParamsToMap(chiCtx), "use pattern %s to process uri %s", pattern, uri) assert.Equal(t, expectedPathParams, chiURLParamsToMap(chiCtx), "use pattern %s to process uri %s", pattern, uri)
} }
@ -56,18 +56,20 @@ func TestRouter(t *testing.T) {
recorder.Body = buff recorder.Body = buff
type resultStruct struct { type resultStruct struct {
method string method string
pathParams map[string]string pathParams map[string]string
handlerMark string handlerMarks []string
} }
var res resultStruct
var res resultStruct
h := func(optMark ...string) func(resp http.ResponseWriter, req *http.Request) { h := func(optMark ...string) func(resp http.ResponseWriter, req *http.Request) {
mark := util.OptionalArg(optMark, "") mark := util.OptionalArg(optMark, "")
return func(resp http.ResponseWriter, req *http.Request) { return func(resp http.ResponseWriter, req *http.Request) {
res.method = req.Method res.method = req.Method
res.pathParams = chiURLParamsToMap(chi.RouteContext(req.Context())) res.pathParams = chiURLParamsToMap(chi.RouteContext(req.Context()))
res.handlerMark = mark if mark != "" {
res.handlerMarks = append(res.handlerMarks, mark)
}
} }
} }
@ -77,6 +79,8 @@ func TestRouter(t *testing.T) {
if stop := req.FormValue("stop"); stop != "" && (mark == "" || mark == stop) { if stop := req.FormValue("stop"); stop != "" && (mark == "" || mark == stop) {
h(stop)(resp, req) h(stop)(resp, req)
resp.WriteHeader(http.StatusOK) resp.WriteHeader(http.StatusOK)
} else if mark != "" {
res.handlerMarks = append(res.handlerMarks, mark)
} }
} }
} }
@ -108,7 +112,7 @@ func TestRouter(t *testing.T) {
m.Delete("", h()) m.Delete("", h())
}) })
m.PathGroup("/*", func(g *RouterPathGroup) { m.PathGroup("/*", func(g *RouterPathGroup) {
g.MatchPath("GET", `/<dir:*>/<file:[a-z]{1,2}>`, stopMark("s2"), h("match-path")) g.MatchPattern("GET", g.PatternRegexp(`/<dir:*>/<file:[a-z]{1,2}>`, stopMark("s2")), stopMark("s3"), h("match-path"))
}, stopMark("s1")) }, stopMark("s1"))
}) })
}) })
@ -126,31 +130,31 @@ func TestRouter(t *testing.T) {
} }
t.Run("RootRouter", func(t *testing.T) { t.Run("RootRouter", func(t *testing.T) {
testRoute(t, "GET /the-user/the-repo/other", resultStruct{method: "GET", handlerMark: "not-found:/"}) testRoute(t, "GET /the-user/the-repo/other", resultStruct{method: "GET", handlerMarks: []string{"not-found:/"}})
testRoute(t, "GET /the-user/the-repo/pulls", resultStruct{ testRoute(t, "GET /the-user/the-repo/pulls", resultStruct{
method: "GET", method: "GET",
pathParams: map[string]string{"username": "the-user", "reponame": "the-repo", "type": "pulls"}, pathParams: map[string]string{"username": "the-user", "reponame": "the-repo", "type": "pulls"},
handlerMark: "list-issues-b", handlerMarks: []string{"list-issues-b"},
}) })
testRoute(t, "GET /the-user/the-repo/issues/123", resultStruct{ testRoute(t, "GET /the-user/the-repo/issues/123", resultStruct{
method: "GET", method: "GET",
pathParams: map[string]string{"username": "the-user", "reponame": "the-repo", "type": "issues", "index": "123"}, pathParams: map[string]string{"username": "the-user", "reponame": "the-repo", "type": "issues", "index": "123"},
handlerMark: "view-issue", handlerMarks: []string{"view-issue"},
}) })
testRoute(t, "GET /the-user/the-repo/issues/123?stop=hijack", resultStruct{ testRoute(t, "GET /the-user/the-repo/issues/123?stop=hijack", resultStruct{
method: "GET", method: "GET",
pathParams: map[string]string{"username": "the-user", "reponame": "the-repo", "type": "issues", "index": "123"}, pathParams: map[string]string{"username": "the-user", "reponame": "the-repo", "type": "issues", "index": "123"},
handlerMark: "hijack", handlerMarks: []string{"hijack"},
}) })
testRoute(t, "POST /the-user/the-repo/issues/123/update", resultStruct{ testRoute(t, "POST /the-user/the-repo/issues/123/update", resultStruct{
method: "POST", method: "POST",
pathParams: map[string]string{"username": "the-user", "reponame": "the-repo", "index": "123"}, pathParams: map[string]string{"username": "the-user", "reponame": "the-repo", "index": "123"},
handlerMark: "update-issue", handlerMarks: []string{"update-issue"},
}) })
}) })
t.Run("Sub Router", func(t *testing.T) { t.Run("Sub Router", func(t *testing.T) {
testRoute(t, "GET /api/v1/other", resultStruct{method: "GET", handlerMark: "not-found:/api/v1"}) testRoute(t, "GET /api/v1/other", resultStruct{method: "GET", handlerMarks: []string{"not-found:/api/v1"}})
testRoute(t, "GET /api/v1/repos/the-user/the-repo/branches", resultStruct{ testRoute(t, "GET /api/v1/repos/the-user/the-repo/branches", resultStruct{
method: "GET", method: "GET",
pathParams: map[string]string{"username": "the-user", "reponame": "the-repo"}, pathParams: map[string]string{"username": "the-user", "reponame": "the-repo"},
@ -179,31 +183,37 @@ func TestRouter(t *testing.T) {
t.Run("MatchPath", func(t *testing.T) { t.Run("MatchPath", func(t *testing.T) {
testRoute(t, "GET /api/v1/repos/the-user/the-repo/branches/d1/d2/fn", resultStruct{ testRoute(t, "GET /api/v1/repos/the-user/the-repo/branches/d1/d2/fn", resultStruct{
method: "GET", method: "GET",
pathParams: map[string]string{"username": "the-user", "reponame": "the-repo", "*": "d1/d2/fn", "dir": "d1/d2", "file": "fn"}, pathParams: map[string]string{"username": "the-user", "reponame": "the-repo", "*": "d1/d2/fn", "dir": "d1/d2", "file": "fn"},
handlerMark: "match-path", handlerMarks: []string{"s1", "s2", "s3", "match-path"},
}) })
testRoute(t, "GET /api/v1/repos/the-user/the-repo/branches/d1%2fd2/fn", resultStruct{ testRoute(t, "GET /api/v1/repos/the-user/the-repo/branches/d1%2fd2/fn", resultStruct{
method: "GET", method: "GET",
pathParams: map[string]string{"username": "the-user", "reponame": "the-repo", "*": "d1%2fd2/fn", "dir": "d1%2fd2", "file": "fn"}, pathParams: map[string]string{"username": "the-user", "reponame": "the-repo", "*": "d1%2fd2/fn", "dir": "d1%2fd2", "file": "fn"},
handlerMark: "match-path", handlerMarks: []string{"s1", "s2", "s3", "match-path"},
}) })
testRoute(t, "GET /api/v1/repos/the-user/the-repo/branches/d1/d2/000", resultStruct{ testRoute(t, "GET /api/v1/repos/the-user/the-repo/branches/d1/d2/000", resultStruct{
method: "GET", method: "GET",
pathParams: map[string]string{"reponame": "the-repo", "username": "the-user", "*": "d1/d2/000"}, pathParams: map[string]string{"reponame": "the-repo", "username": "the-user", "*": "d1/d2/000"},
handlerMark: "not-found:/api/v1", handlerMarks: []string{"s1", "not-found:/api/v1"},
}) })
testRoute(t, "GET /api/v1/repos/the-user/the-repo/branches/d1/d2/fn?stop=s1", resultStruct{ testRoute(t, "GET /api/v1/repos/the-user/the-repo/branches/d1/d2/fn?stop=s1", resultStruct{
method: "GET", method: "GET",
pathParams: map[string]string{"username": "the-user", "reponame": "the-repo", "*": "d1/d2/fn"}, pathParams: map[string]string{"username": "the-user", "reponame": "the-repo", "*": "d1/d2/fn"},
handlerMark: "s1", handlerMarks: []string{"s1"},
}) })
testRoute(t, "GET /api/v1/repos/the-user/the-repo/branches/d1/d2/fn?stop=s2", resultStruct{ testRoute(t, "GET /api/v1/repos/the-user/the-repo/branches/d1/d2/fn?stop=s2", resultStruct{
method: "GET", method: "GET",
pathParams: map[string]string{"username": "the-user", "reponame": "the-repo", "*": "d1/d2/fn", "dir": "d1/d2", "file": "fn"}, pathParams: map[string]string{"username": "the-user", "reponame": "the-repo", "*": "d1/d2/fn", "dir": "d1/d2", "file": "fn"},
handlerMark: "s2", handlerMarks: []string{"s1", "s2"},
})
testRoute(t, "GET /api/v1/repos/the-user/the-repo/branches/d1/d2/fn?stop=s3", resultStruct{
method: "GET",
pathParams: map[string]string{"username": "the-user", "reponame": "the-repo", "*": "d1/d2/fn", "dir": "d1/d2", "file": "fn"},
handlerMarks: []string{"s1", "s2", "s3"},
}) })
}) })
} }

View File

@ -1399,6 +1399,13 @@ editor.revert = Revert %s onto:
editor.failed_to_commit = Failed to commit changes. editor.failed_to_commit = Failed to commit changes.
editor.failed_to_commit_summary = Error Message: editor.failed_to_commit_summary = Error Message:
editor.fork_create = Fork Repository to Propose Changes
editor.fork_create_description = You can not edit this repository directly. Instead you can create a fork, make edits and create a pull request.
editor.fork_edit_description = You can not edit this repository directly. The changes will be written to your fork <b>%s</b>, so you can create a pull request.
editor.fork_not_editable = You have forked this repository but your fork is not editable.
editor.fork_failed_to_push_branch = Failed to push branch %s to your repository.
editor.fork_branch_exists = Branch "%s" already exists in your fork, please choose a new branch name.
commits.desc = Browse source code change history. commits.desc = Browse source code change history.
commits.commits = Commits commits.commits = Commits
commits.no_commits = No commits in common. "%s" and "%s" have entirely different histories. commits.no_commits = No commits in common. "%s" and "%s" have entirely different histories.

View File

@ -5,8 +5,6 @@ package packages
import ( import (
"net/http" "net/http"
"regexp"
"strings"
auth_model "code.gitea.io/gitea/models/auth" auth_model "code.gitea.io/gitea/models/auth"
"code.gitea.io/gitea/models/perm" "code.gitea.io/gitea/models/perm"
@ -282,42 +280,10 @@ func CommonRoutes() *web.Router {
}) })
}) })
}, reqPackageAccess(perm.AccessModeRead)) }, reqPackageAccess(perm.AccessModeRead))
r.Group("/conda", func() { r.PathGroup("/conda/*", func(g *web.RouterPathGroup) {
var ( g.MatchPath("GET", "/<architecture>/<filename>", conda.ListOrGetPackages)
downloadPattern = regexp.MustCompile(`\A(.+/)?(.+)/((?:[^/]+(?:\.tar\.bz2|\.conda))|(?:current_)?repodata\.json(?:\.bz2)?)\z`) g.MatchPath("GET", "/<channel:*>/<architecture>/<filename>", conda.ListOrGetPackages)
uploadPattern = regexp.MustCompile(`\A(.+/)?([^/]+(?:\.tar\.bz2|\.conda))\z`) g.MatchPath("PUT", "/<channel:*>/<filename>", reqPackageAccess(perm.AccessModeWrite), conda.UploadPackageFile)
)
r.Get("/*", func(ctx *context.Context) {
m := downloadPattern.FindStringSubmatch(ctx.PathParam("*"))
if len(m) == 0 {
ctx.Status(http.StatusNotFound)
return
}
ctx.SetPathParam("channel", strings.TrimSuffix(m[1], "/"))
ctx.SetPathParam("architecture", m[2])
ctx.SetPathParam("filename", m[3])
switch m[3] {
case "repodata.json", "repodata.json.bz2", "current_repodata.json", "current_repodata.json.bz2":
conda.EnumeratePackages(ctx)
default:
conda.DownloadPackageFile(ctx)
}
})
r.Put("/*", reqPackageAccess(perm.AccessModeWrite), func(ctx *context.Context) {
m := uploadPattern.FindStringSubmatch(ctx.PathParam("*"))
if len(m) == 0 {
ctx.Status(http.StatusNotFound)
return
}
ctx.SetPathParam("channel", strings.TrimSuffix(m[1], "/"))
ctx.SetPathParam("filename", m[2])
conda.UploadPackageFile(ctx)
})
}, reqPackageAccess(perm.AccessModeRead)) }, reqPackageAccess(perm.AccessModeRead))
r.Group("/cran", func() { r.Group("/cran", func() {
r.Group("/src", func() { r.Group("/src", func() {
@ -358,60 +324,15 @@ func CommonRoutes() *web.Router {
}, reqPackageAccess(perm.AccessModeRead)) }, reqPackageAccess(perm.AccessModeRead))
r.Group("/go", func() { r.Group("/go", func() {
r.Put("/upload", reqPackageAccess(perm.AccessModeWrite), goproxy.UploadPackage) r.Put("/upload", reqPackageAccess(perm.AccessModeWrite), goproxy.UploadPackage)
r.Get("/sumdb/sum.golang.org/supported", func(ctx *context.Context) { r.Get("/sumdb/sum.golang.org/supported", http.NotFound)
ctx.Status(http.StatusNotFound)
})
// Manual mapping of routes because the package name contains slashes which chi does not support
// https://go.dev/ref/mod#goproxy-protocol // https://go.dev/ref/mod#goproxy-protocol
r.Get("/*", func(ctx *context.Context) { r.PathGroup("/*", func(g *web.RouterPathGroup) {
path := ctx.PathParam("*") g.MatchPath("GET", "/<name:*>/@<version:latest>", goproxy.PackageVersionMetadata)
g.MatchPath("GET", "/<name:*>/@v/list", goproxy.EnumeratePackageVersions)
if strings.HasSuffix(path, "/@latest") { g.MatchPath("GET", "/<name:*>/@v/<version>.zip", goproxy.DownloadPackageFile)
ctx.SetPathParam("name", path[:len(path)-len("/@latest")]) g.MatchPath("GET", "/<name:*>/@v/<version>.info", goproxy.PackageVersionMetadata)
ctx.SetPathParam("version", "latest") g.MatchPath("GET", "/<name:*>/@v/<version>.mod", goproxy.PackageVersionGoModContent)
goproxy.PackageVersionMetadata(ctx)
return
}
parts := strings.SplitN(path, "/@v/", 2)
if len(parts) != 2 {
ctx.Status(http.StatusNotFound)
return
}
ctx.SetPathParam("name", parts[0])
// <package/name>/@v/list
if parts[1] == "list" {
goproxy.EnumeratePackageVersions(ctx)
return
}
// <package/name>/@v/<version>.zip
if strings.HasSuffix(parts[1], ".zip") {
ctx.SetPathParam("version", parts[1][:len(parts[1])-len(".zip")])
goproxy.DownloadPackageFile(ctx)
return
}
// <package/name>/@v/<version>.info
if strings.HasSuffix(parts[1], ".info") {
ctx.SetPathParam("version", parts[1][:len(parts[1])-len(".info")])
goproxy.PackageVersionMetadata(ctx)
return
}
// <package/name>/@v/<version>.mod
if strings.HasSuffix(parts[1], ".mod") {
ctx.SetPathParam("version", parts[1][:len(parts[1])-len(".mod")])
goproxy.PackageVersionGoModContent(ctx)
return
}
ctx.Status(http.StatusNotFound)
}) })
}, reqPackageAccess(perm.AccessModeRead)) }, reqPackageAccess(perm.AccessModeRead))
r.Group("/generic", func() { r.Group("/generic", func() {
@ -532,82 +453,24 @@ func CommonRoutes() *web.Router {
}) })
}) })
}, reqPackageAccess(perm.AccessModeRead)) }, reqPackageAccess(perm.AccessModeRead))
r.Group("/pypi", func() { r.Group("/pypi", func() {
r.Post("/", reqPackageAccess(perm.AccessModeWrite), pypi.UploadPackageFile) r.Post("/", reqPackageAccess(perm.AccessModeWrite), pypi.UploadPackageFile)
r.Get("/files/{id}/{version}/{filename}", pypi.DownloadPackageFile) r.Get("/files/{id}/{version}/{filename}", pypi.DownloadPackageFile)
r.Get("/simple/{id}", pypi.PackageMetadata) r.Get("/simple/{id}", pypi.PackageMetadata)
}, reqPackageAccess(perm.AccessModeRead)) }, reqPackageAccess(perm.AccessModeRead))
r.Group("/rpm", func() {
r.Group("/repository.key", func() {
r.Head("", rpm.GetRepositoryKey)
r.Get("", rpm.GetRepositoryKey)
})
var ( r.Methods("HEAD,GET", "/rpm.repo", reqPackageAccess(perm.AccessModeRead), rpm.GetRepositoryConfig)
repoPattern = regexp.MustCompile(`\A(.*?)\.repo\z`) r.PathGroup("/rpm/*", func(g *web.RouterPathGroup) {
uploadPattern = regexp.MustCompile(`\A(.*?)/upload\z`) g.MatchPath("HEAD,GET", "/repository.key", rpm.GetRepositoryKey)
filePattern = regexp.MustCompile(`\A(.*?)/package/([^/]+)/([^/]+)/([^/]+)(?:/([^/]+\.rpm)|)\z`) g.MatchPath("HEAD,GET", "/<group:*>.repo", rpm.GetRepositoryConfig)
repoFilePattern = regexp.MustCompile(`\A(.*?)/repodata/([^/]+)\z`) g.MatchPath("HEAD", "/<group:*>/repodata/<filename>", rpm.CheckRepositoryFileExistence)
) g.MatchPath("GET", "/<group:*>/repodata/<filename>", rpm.GetRepositoryFile)
g.MatchPath("PUT", "/<group:*>/upload", reqPackageAccess(perm.AccessModeWrite), rpm.UploadPackageFile)
r.Methods("HEAD,GET,PUT,DELETE", "*", func(ctx *context.Context) { g.MatchPath("HEAD,GET", "/<group:*>/package/<name>/<version>/<architecture>", rpm.DownloadPackageFile)
path := ctx.PathParam("*") g.MatchPath("DELETE", "/<group:*>/package/<name>/<version>/<architecture>", reqPackageAccess(perm.AccessModeWrite), rpm.DeletePackageFile)
isHead := ctx.Req.Method == http.MethodHead
isGetHead := ctx.Req.Method == http.MethodHead || ctx.Req.Method == http.MethodGet
isPut := ctx.Req.Method == http.MethodPut
isDelete := ctx.Req.Method == http.MethodDelete
m := repoPattern.FindStringSubmatch(path)
if len(m) == 2 && isGetHead {
ctx.SetPathParam("group", strings.Trim(m[1], "/"))
rpm.GetRepositoryConfig(ctx)
return
}
m = repoFilePattern.FindStringSubmatch(path)
if len(m) == 3 && isGetHead {
ctx.SetPathParam("group", strings.Trim(m[1], "/"))
ctx.SetPathParam("filename", m[2])
if isHead {
rpm.CheckRepositoryFileExistence(ctx)
} else {
rpm.GetRepositoryFile(ctx)
}
return
}
m = uploadPattern.FindStringSubmatch(path)
if len(m) == 2 && isPut {
reqPackageAccess(perm.AccessModeWrite)(ctx)
if ctx.Written() {
return
}
ctx.SetPathParam("group", strings.Trim(m[1], "/"))
rpm.UploadPackageFile(ctx)
return
}
m = filePattern.FindStringSubmatch(path)
if len(m) == 6 && (isGetHead || isDelete) {
ctx.SetPathParam("group", strings.Trim(m[1], "/"))
ctx.SetPathParam("name", m[2])
ctx.SetPathParam("version", m[3])
ctx.SetPathParam("architecture", m[4])
if isGetHead {
rpm.DownloadPackageFile(ctx)
} else {
reqPackageAccess(perm.AccessModeWrite)(ctx)
if ctx.Written() {
return
}
rpm.DeletePackageFile(ctx)
}
return
}
ctx.Status(http.StatusNotFound)
})
}, reqPackageAccess(perm.AccessModeRead)) }, reqPackageAccess(perm.AccessModeRead))
r.Group("/rubygems", func() { r.Group("/rubygems", func() {
r.Get("/specs.4.8.gz", rubygems.EnumeratePackages) r.Get("/specs.4.8.gz", rubygems.EnumeratePackages)
r.Get("/latest_specs.4.8.gz", rubygems.EnumeratePackagesLatest) r.Get("/latest_specs.4.8.gz", rubygems.EnumeratePackagesLatest)
@ -621,6 +484,7 @@ func CommonRoutes() *web.Router {
r.Delete("/yank", rubygems.DeletePackage) r.Delete("/yank", rubygems.DeletePackage)
}, reqPackageAccess(perm.AccessModeWrite)) }, reqPackageAccess(perm.AccessModeWrite))
}, reqPackageAccess(perm.AccessModeRead)) }, reqPackageAccess(perm.AccessModeRead))
r.Group("/swift", func() { r.Group("/swift", func() {
r.Group("", func() { // Needs to be unauthenticated. r.Group("", func() { // Needs to be unauthenticated.
r.Post("", swift.CheckAuthenticate) r.Post("", swift.CheckAuthenticate)
@ -632,31 +496,12 @@ func CommonRoutes() *web.Router {
r.Get("", swift.EnumeratePackageVersions) r.Get("", swift.EnumeratePackageVersions)
r.Get(".json", swift.EnumeratePackageVersions) r.Get(".json", swift.EnumeratePackageVersions)
}, swift.CheckAcceptMediaType(swift.AcceptJSON)) }, swift.CheckAcceptMediaType(swift.AcceptJSON))
r.Group("/{version}", func() { r.PathGroup("/*", func(g *web.RouterPathGroup) {
r.Get("/Package.swift", swift.CheckAcceptMediaType(swift.AcceptSwift), swift.DownloadManifest) g.MatchPath("GET", "/<version>.json", swift.CheckAcceptMediaType(swift.AcceptJSON), swift.PackageVersionMetadata)
r.Put("", reqPackageAccess(perm.AccessModeWrite), swift.CheckAcceptMediaType(swift.AcceptJSON), swift.UploadPackageFile) g.MatchPath("GET", "/<version>.zip", swift.CheckAcceptMediaType(swift.AcceptZip), swift.DownloadPackageFile)
r.Get("", func(ctx *context.Context) { g.MatchPath("GET", "/<version>/Package.swift", swift.CheckAcceptMediaType(swift.AcceptSwift), swift.DownloadManifest)
// Can't use normal routes here: https://github.com/go-chi/chi/issues/781 g.MatchPath("GET", "/<version>", swift.CheckAcceptMediaType(swift.AcceptJSON), swift.PackageVersionMetadata)
g.MatchPath("PUT", "/<version>", reqPackageAccess(perm.AccessModeWrite), swift.CheckAcceptMediaType(swift.AcceptJSON), swift.UploadPackageFile)
version := ctx.PathParam("version")
if strings.HasSuffix(version, ".zip") {
swift.CheckAcceptMediaType(swift.AcceptZip)(ctx)
if ctx.Written() {
return
}
ctx.SetPathParam("version", version[:len(version)-4])
swift.DownloadPackageFile(ctx)
} else {
swift.CheckAcceptMediaType(swift.AcceptJSON)(ctx)
if ctx.Written() {
return
}
if strings.HasSuffix(version, ".json") {
ctx.SetPathParam("version", version[:len(version)-5])
}
swift.PackageVersionMetadata(ctx)
}
})
}) })
}) })
r.Get("/identifiers", swift.CheckAcceptMediaType(swift.AcceptJSON), swift.LookupPackageIdentifiers) r.Get("/identifiers", swift.CheckAcceptMediaType(swift.AcceptJSON), swift.LookupPackageIdentifiers)
@ -705,18 +550,13 @@ func ContainerRoutes() *web.Router {
r.PathGroup("/*", func(g *web.RouterPathGroup) { r.PathGroup("/*", func(g *web.RouterPathGroup) {
g.MatchPath("POST", "/<image:*>/blobs/uploads", reqPackageAccess(perm.AccessModeWrite), container.VerifyImageName, container.PostBlobsUploads) g.MatchPath("POST", "/<image:*>/blobs/uploads", reqPackageAccess(perm.AccessModeWrite), container.VerifyImageName, container.PostBlobsUploads)
g.MatchPath("GET", "/<image:*>/tags/list", container.VerifyImageName, container.GetTagsList) g.MatchPath("GET", "/<image:*>/tags/list", container.VerifyImageName, container.GetTagsList)
g.MatchPath("GET,PATCH,PUT,DELETE", `/<image:*>/blobs/uploads/<uuid:[-.=\w]+>`, reqPackageAccess(perm.AccessModeWrite), container.VerifyImageName, func(ctx *context.Context) {
switch ctx.Req.Method { patternBlobsUploadsUUID := g.PatternRegexp(`/<image:*>/blobs/uploads/<uuid:[-.=\w]+>`, reqPackageAccess(perm.AccessModeWrite), container.VerifyImageName)
case http.MethodGet: g.MatchPattern("GET", patternBlobsUploadsUUID, container.GetBlobsUpload)
container.GetBlobsUpload(ctx) g.MatchPattern("PATCH", patternBlobsUploadsUUID, container.PatchBlobsUpload)
case http.MethodPatch: g.MatchPattern("PUT", patternBlobsUploadsUUID, container.PutBlobsUpload)
container.PatchBlobsUpload(ctx) g.MatchPattern("DELETE", patternBlobsUploadsUUID, container.DeleteBlobsUpload)
case http.MethodPut:
container.PutBlobsUpload(ctx)
default: /* DELETE */
container.DeleteBlobsUpload(ctx)
}
})
g.MatchPath("HEAD", `/<image:*>/blobs/<digest>`, container.VerifyImageName, container.HeadBlob) g.MatchPath("HEAD", `/<image:*>/blobs/<digest>`, container.VerifyImageName, container.HeadBlob)
g.MatchPath("GET", `/<image:*>/blobs/<digest>`, container.VerifyImageName, container.GetBlob) g.MatchPath("GET", `/<image:*>/blobs/<digest>`, container.VerifyImageName, container.GetBlob)
g.MatchPath("DELETE", `/<image:*>/blobs/<digest>`, container.VerifyImageName, reqPackageAccess(perm.AccessModeWrite), container.DeleteBlob) g.MatchPath("DELETE", `/<image:*>/blobs/<digest>`, container.VerifyImageName, reqPackageAccess(perm.AccessModeWrite), container.DeleteBlob)

View File

@ -36,6 +36,24 @@ func apiError(ctx *context.Context, status int, obj any) {
}) })
} }
func isCondaPackageFileName(filename string) bool {
return strings.HasSuffix(filename, ".tar.bz2") || strings.HasSuffix(filename, ".conda")
}
func ListOrGetPackages(ctx *context.Context) {
filename := ctx.PathParam("filename")
switch filename {
case "repodata.json", "repodata.json.bz2", "current_repodata.json", "current_repodata.json.bz2":
EnumeratePackages(ctx)
return
}
if isCondaPackageFileName(filename) {
DownloadPackageFile(ctx)
return
}
ctx.NotFound(nil)
}
func EnumeratePackages(ctx *context.Context) { func EnumeratePackages(ctx *context.Context) {
type Info struct { type Info struct {
Subdir string `json:"subdir"` Subdir string `json:"subdir"`
@ -174,6 +192,12 @@ func EnumeratePackages(ctx *context.Context) {
} }
func UploadPackageFile(ctx *context.Context) { func UploadPackageFile(ctx *context.Context) {
filename := ctx.PathParam("filename")
if !isCondaPackageFileName(filename) {
apiError(ctx, http.StatusBadRequest, nil)
return
}
upload, needToClose, err := ctx.UploadStream() upload, needToClose, err := ctx.UploadStream()
if err != nil { if err != nil {
apiError(ctx, http.StatusInternalServerError, err) apiError(ctx, http.StatusInternalServerError, err)
@ -191,7 +215,7 @@ func UploadPackageFile(ctx *context.Context) {
defer buf.Close() defer buf.Close()
var pck *conda_module.Package var pck *conda_module.Package
if strings.HasSuffix(strings.ToLower(ctx.PathParam("filename")), ".tar.bz2") { if strings.HasSuffix(filename, ".tar.bz2") {
pck, err = conda_module.ParsePackageBZ2(buf) pck, err = conda_module.ParsePackageBZ2(buf)
} else { } else {
pck, err = conda_module.ParsePackageConda(buf, buf.Size()) pck, err = conda_module.ParsePackageConda(buf, buf.Size())

View File

@ -90,14 +90,14 @@ func mountBlob(ctx context.Context, pi *packages_service.PackageInfo, pb *packag
}) })
} }
func containerPkgName(piOwnerID int64, piName string) string { func containerGlobalLockKey(piOwnerID int64, piName, usage string) string {
return fmt.Sprintf("pkg_%d_container_%s", piOwnerID, strings.ToLower(piName)) return fmt.Sprintf("pkg_%d_container_%s_%s", piOwnerID, strings.ToLower(piName), usage)
} }
func getOrCreateUploadVersion(ctx context.Context, pi *packages_service.PackageInfo) (*packages_model.PackageVersion, error) { func getOrCreateUploadVersion(ctx context.Context, pi *packages_service.PackageInfo) (*packages_model.PackageVersion, error) {
var uploadVersion *packages_model.PackageVersion var uploadVersion *packages_model.PackageVersion
releaser, err := globallock.Lock(ctx, containerPkgName(pi.Owner.ID, pi.Name)) releaser, err := globallock.Lock(ctx, containerGlobalLockKey(pi.Owner.ID, pi.Name, "package"))
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -178,7 +178,7 @@ func createFileForBlob(ctx context.Context, pv *packages_model.PackageVersion, p
} }
func deleteBlob(ctx context.Context, ownerID int64, image string, digest digest.Digest) error { func deleteBlob(ctx context.Context, ownerID int64, image string, digest digest.Digest) error {
releaser, err := globallock.Lock(ctx, containerPkgName(ownerID, image)) releaser, err := globallock.Lock(ctx, containerGlobalLockKey(ownerID, image, "blob"))
if err != nil { if err != nil {
return err return err
} }

View File

@ -32,7 +32,7 @@ import (
packages_service "code.gitea.io/gitea/services/packages" packages_service "code.gitea.io/gitea/services/packages"
container_service "code.gitea.io/gitea/services/packages/container" container_service "code.gitea.io/gitea/services/packages/container"
digest "github.com/opencontainers/go-digest" "github.com/opencontainers/go-digest"
) )
// maximum size of a container manifest // maximum size of a container manifest

View File

@ -16,6 +16,7 @@ import (
packages_model "code.gitea.io/gitea/models/packages" packages_model "code.gitea.io/gitea/models/packages"
container_model "code.gitea.io/gitea/models/packages/container" container_model "code.gitea.io/gitea/models/packages/container"
user_model "code.gitea.io/gitea/models/user" user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/globallock"
"code.gitea.io/gitea/modules/json" "code.gitea.io/gitea/modules/json"
"code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/log"
packages_module "code.gitea.io/gitea/modules/packages" packages_module "code.gitea.io/gitea/modules/packages"
@ -61,6 +62,13 @@ func processManifest(ctx context.Context, mci *manifestCreationInfo, buf *packag
} }
} }
// .../container/manifest.go:453:createManifestBlob() [E] Error inserting package blob: Error 1062 (23000): Duplicate entry '..........' for key 'package_blob.UQE_package_blob_md5'
releaser, err := globallock.Lock(ctx, containerGlobalLockKey(mci.Owner.ID, mci.Image, "manifest"))
if err != nil {
return "", err
}
defer releaser()
if container_module.IsMediaTypeImageManifest(mci.MediaType) { if container_module.IsMediaTypeImageManifest(mci.MediaType) {
return processOciImageManifest(ctx, mci, buf) return processOciImageManifest(ctx, mci, buf)
} else if container_module.IsMediaTypeImageIndex(mci.MediaType) { } else if container_module.IsMediaTypeImageIndex(mci.MediaType) {

View File

@ -16,6 +16,7 @@ import (
"code.gitea.io/gitea/modules/charset" "code.gitea.io/gitea/modules/charset"
"code.gitea.io/gitea/modules/git" "code.gitea.io/gitea/modules/git"
"code.gitea.io/gitea/modules/httplib" "code.gitea.io/gitea/modules/httplib"
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/markup" "code.gitea.io/gitea/modules/markup"
"code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/templates" "code.gitea.io/gitea/modules/templates"
@ -39,7 +40,7 @@ const (
editorCommitChoiceNewBranch string = "commit-to-new-branch" editorCommitChoiceNewBranch string = "commit-to-new-branch"
) )
func prepareEditorCommitFormOptions(ctx *context.Context, editorAction string) { func prepareEditorCommitFormOptions(ctx *context.Context, editorAction string) *context.CommitFormOptions {
cleanedTreePath := files_service.CleanGitTreePath(ctx.Repo.TreePath) cleanedTreePath := files_service.CleanGitTreePath(ctx.Repo.TreePath)
if cleanedTreePath != ctx.Repo.TreePath { if cleanedTreePath != ctx.Repo.TreePath {
redirectTo := fmt.Sprintf("%s/%s/%s/%s", ctx.Repo.RepoLink, editorAction, util.PathEscapeSegments(ctx.Repo.BranchName), util.PathEscapeSegments(cleanedTreePath)) redirectTo := fmt.Sprintf("%s/%s/%s/%s", ctx.Repo.RepoLink, editorAction, util.PathEscapeSegments(ctx.Repo.BranchName), util.PathEscapeSegments(cleanedTreePath))
@ -47,18 +48,28 @@ func prepareEditorCommitFormOptions(ctx *context.Context, editorAction string) {
redirectTo += "?" + ctx.Req.URL.RawQuery redirectTo += "?" + ctx.Req.URL.RawQuery
} }
ctx.Redirect(redirectTo) ctx.Redirect(redirectTo)
return return nil
} }
commitFormBehaviors, err := ctx.Repo.PrepareCommitFormBehaviors(ctx, ctx.Doer) commitFormOptions, err := context.PrepareCommitFormOptions(ctx, ctx.Doer, ctx.Repo.Repository, ctx.Repo.Permission, ctx.Repo.RefFullName)
if err != nil { if err != nil {
ctx.ServerError("PrepareCommitFormBehaviors", err) ctx.ServerError("PrepareCommitFormOptions", err)
return return nil
}
if commitFormOptions.NeedFork {
ForkToEdit(ctx)
return nil
}
if commitFormOptions.WillSubmitToFork && !commitFormOptions.TargetRepo.CanEnableEditor() {
ctx.Data["NotFoundPrompt"] = ctx.Locale.Tr("repo.editor.fork_not_editable")
ctx.NotFound(nil)
} }
ctx.Data["BranchLink"] = ctx.Repo.RepoLink + "/src/" + ctx.Repo.RefTypeNameSubURL() ctx.Data["BranchLink"] = ctx.Repo.RepoLink + "/src/" + ctx.Repo.RefTypeNameSubURL()
ctx.Data["TreePath"] = ctx.Repo.TreePath ctx.Data["TreePath"] = ctx.Repo.TreePath
ctx.Data["CommitFormBehaviors"] = commitFormBehaviors ctx.Data["CommitFormOptions"] = commitFormOptions
// for online editor // for online editor
ctx.Data["PreviewableExtensions"] = strings.Join(markup.PreviewableExtensions(), ",") ctx.Data["PreviewableExtensions"] = strings.Join(markup.PreviewableExtensions(), ",")
@ -69,25 +80,27 @@ func prepareEditorCommitFormOptions(ctx *context.Context, editorAction string) {
// form fields // form fields
ctx.Data["commit_summary"] = "" ctx.Data["commit_summary"] = ""
ctx.Data["commit_message"] = "" ctx.Data["commit_message"] = ""
ctx.Data["commit_choice"] = util.Iif(commitFormBehaviors.CanCommitToBranch, editorCommitChoiceDirect, editorCommitChoiceNewBranch) ctx.Data["commit_choice"] = util.Iif(commitFormOptions.CanCommitToBranch, editorCommitChoiceDirect, editorCommitChoiceNewBranch)
ctx.Data["new_branch_name"] = getUniquePatchBranchName(ctx, ctx.Doer.LowerName, ctx.Repo.Repository) ctx.Data["new_branch_name"] = getUniquePatchBranchName(ctx, ctx.Doer.LowerName, commitFormOptions.TargetRepo)
ctx.Data["last_commit"] = ctx.Repo.CommitID ctx.Data["last_commit"] = ctx.Repo.CommitID
return commitFormOptions
} }
func prepareTreePathFieldsAndPaths(ctx *context.Context, treePath string) { func prepareTreePathFieldsAndPaths(ctx *context.Context, treePath string) {
// show the tree path fields in the "breadcrumb" and help users to edit the target tree path // show the tree path fields in the "breadcrumb" and help users to edit the target tree path
ctx.Data["TreeNames"], ctx.Data["TreePaths"] = getParentTreeFields(treePath) ctx.Data["TreeNames"], ctx.Data["TreePaths"] = getParentTreeFields(strings.TrimPrefix(treePath, "/"))
} }
type parsedEditorCommitForm[T any] struct { type preparedEditorCommitForm[T any] struct {
form T form T
commonForm *forms.CommitCommonForm commonForm *forms.CommitCommonForm
CommitFormBehaviors *context.CommitFormBehaviors CommitFormOptions *context.CommitFormOptions
TargetBranchName string OldBranchName string
GitCommitter *files_service.IdentityOptions NewBranchName string
GitCommitter *files_service.IdentityOptions
} }
func (f *parsedEditorCommitForm[T]) GetCommitMessage(defaultCommitMessage string) string { func (f *preparedEditorCommitForm[T]) GetCommitMessage(defaultCommitMessage string) string {
commitMessage := util.IfZero(strings.TrimSpace(f.commonForm.CommitSummary), defaultCommitMessage) commitMessage := util.IfZero(strings.TrimSpace(f.commonForm.CommitSummary), defaultCommitMessage)
if body := strings.TrimSpace(f.commonForm.CommitMessage); body != "" { if body := strings.TrimSpace(f.commonForm.CommitMessage); body != "" {
commitMessage += "\n\n" + body commitMessage += "\n\n" + body
@ -95,7 +108,7 @@ func (f *parsedEditorCommitForm[T]) GetCommitMessage(defaultCommitMessage string
return commitMessage return commitMessage
} }
func parseEditorCommitSubmittedForm[T forms.CommitCommonFormInterface](ctx *context.Context) *parsedEditorCommitForm[T] { func prepareEditorCommitSubmittedForm[T forms.CommitCommonFormInterface](ctx *context.Context) *preparedEditorCommitForm[T] {
form := web.GetForm(ctx).(T) form := web.GetForm(ctx).(T)
if ctx.HasError() { if ctx.HasError() {
ctx.JSONError(ctx.GetErrMsg()) ctx.JSONError(ctx.GetErrMsg())
@ -105,15 +118,22 @@ func parseEditorCommitSubmittedForm[T forms.CommitCommonFormInterface](ctx *cont
commonForm := form.GetCommitCommonForm() commonForm := form.GetCommitCommonForm()
commonForm.TreePath = files_service.CleanGitTreePath(commonForm.TreePath) commonForm.TreePath = files_service.CleanGitTreePath(commonForm.TreePath)
commitFormBehaviors, err := ctx.Repo.PrepareCommitFormBehaviors(ctx, ctx.Doer) commitFormOptions, err := context.PrepareCommitFormOptions(ctx, ctx.Doer, ctx.Repo.Repository, ctx.Repo.Permission, ctx.Repo.RefFullName)
if err != nil { if err != nil {
ctx.ServerError("PrepareCommitFormBehaviors", err) ctx.ServerError("PrepareCommitFormOptions", err)
return nil
}
if commitFormOptions.NeedFork {
// It shouldn't happen, because we should have done the checks in the "GET" request. But just in case.
ctx.JSONError(ctx.Locale.TrString("error.not_found"))
return nil return nil
} }
// check commit behavior // check commit behavior
targetBranchName := util.Iif(commonForm.CommitChoice == editorCommitChoiceNewBranch, commonForm.NewBranchName, ctx.Repo.BranchName) fromBaseBranch := ctx.FormString("from_base_branch")
if targetBranchName == ctx.Repo.BranchName && !commitFormBehaviors.CanCommitToBranch { commitToNewBranch := commonForm.CommitChoice == editorCommitChoiceNewBranch || fromBaseBranch != ""
targetBranchName := util.Iif(commitToNewBranch, commonForm.NewBranchName, ctx.Repo.BranchName)
if targetBranchName == ctx.Repo.BranchName && !commitFormOptions.CanCommitToBranch {
ctx.JSONError(ctx.Tr("repo.editor.cannot_commit_to_protected_branch", targetBranchName)) ctx.JSONError(ctx.Tr("repo.editor.cannot_commit_to_protected_branch", targetBranchName))
return nil return nil
} }
@ -125,28 +145,63 @@ func parseEditorCommitSubmittedForm[T forms.CommitCommonFormInterface](ctx *cont
return nil return nil
} }
return &parsedEditorCommitForm[T]{ if commitToNewBranch {
form: form, // if target branch exists, we should stop
commonForm: commonForm, targetBranchExists, err := git_model.IsBranchExist(ctx, commitFormOptions.TargetRepo.ID, targetBranchName)
CommitFormBehaviors: commitFormBehaviors, if err != nil {
TargetBranchName: targetBranchName, ctx.ServerError("IsBranchExist", err)
GitCommitter: gitCommitter, return nil
} else if targetBranchExists {
if fromBaseBranch != "" {
ctx.JSONError(ctx.Tr("repo.editor.fork_branch_exists", targetBranchName))
} else {
ctx.JSONError(ctx.Tr("repo.editor.branch_already_exists", targetBranchName))
}
return nil
}
}
oldBranchName := ctx.Repo.BranchName
if fromBaseBranch != "" {
err = editorPushBranchToForkedRepository(ctx, ctx.Doer, ctx.Repo.Repository.BaseRepo, fromBaseBranch, commitFormOptions.TargetRepo, targetBranchName)
if err != nil {
log.Error("Unable to editorPushBranchToForkedRepository: %v", err)
ctx.JSONError(ctx.Tr("repo.editor.fork_failed_to_push_branch", targetBranchName))
return nil
}
// we have pushed the base branch as the new branch, now we need to commit the changes directly to the new branch
oldBranchName = targetBranchName
}
return &preparedEditorCommitForm[T]{
form: form,
commonForm: commonForm,
CommitFormOptions: commitFormOptions,
OldBranchName: oldBranchName,
NewBranchName: targetBranchName,
GitCommitter: gitCommitter,
} }
} }
// redirectForCommitChoice redirects after committing the edit to a branch // redirectForCommitChoice redirects after committing the edit to a branch
func redirectForCommitChoice[T any](ctx *context.Context, parsed *parsedEditorCommitForm[T], treePath string) { func redirectForCommitChoice[T any](ctx *context.Context, parsed *preparedEditorCommitForm[T], treePath string) {
// when editing a file in a PR, it should return to the origin location
if returnURI := ctx.FormString("return_uri"); returnURI != "" && httplib.IsCurrentGiteaSiteURL(ctx, returnURI) {
ctx.JSONRedirect(returnURI)
return
}
if parsed.commonForm.CommitChoice == editorCommitChoiceNewBranch { if parsed.commonForm.CommitChoice == editorCommitChoiceNewBranch {
// Redirect to a pull request when possible // Redirect to a pull request when possible
redirectToPullRequest := false redirectToPullRequest := false
repo, baseBranch, headBranch := ctx.Repo.Repository, ctx.Repo.BranchName, parsed.TargetBranchName repo, baseBranch, headBranch := ctx.Repo.Repository, parsed.OldBranchName, parsed.NewBranchName
if repo.UnitEnabled(ctx, unit.TypePullRequests) { if ctx.Repo.Repository.IsFork && parsed.CommitFormOptions.CanCreateBasePullRequest {
redirectToPullRequest = true
} else if parsed.CommitFormBehaviors.CanCreateBasePullRequest {
redirectToPullRequest = true redirectToPullRequest = true
baseBranch = repo.BaseRepo.DefaultBranch baseBranch = repo.BaseRepo.DefaultBranch
headBranch = repo.Owner.Name + "/" + repo.Name + ":" + headBranch headBranch = repo.Owner.Name + "/" + repo.Name + ":" + headBranch
repo = repo.BaseRepo repo = repo.BaseRepo
} else if repo.UnitEnabled(ctx, unit.TypePullRequests) {
redirectToPullRequest = true
} }
if redirectToPullRequest { if redirectToPullRequest {
ctx.JSONRedirect(repo.Link() + "/compare/" + util.PathEscapeSegments(baseBranch) + "..." + util.PathEscapeSegments(headBranch)) ctx.JSONRedirect(repo.Link() + "/compare/" + util.PathEscapeSegments(baseBranch) + "..." + util.PathEscapeSegments(headBranch))
@ -154,11 +209,9 @@ func redirectForCommitChoice[T any](ctx *context.Context, parsed *parsedEditorCo
} }
} }
returnURI := ctx.FormString("return_uri") // redirect to the newly updated file
if returnURI == "" || !httplib.IsCurrentGiteaSiteURL(ctx, returnURI) { redirectTo := util.URLJoin(ctx.Repo.RepoLink, "src/branch", util.PathEscapeSegments(parsed.NewBranchName), util.PathEscapeSegments(treePath))
returnURI = util.URLJoin(ctx.Repo.RepoLink, "src/branch", util.PathEscapeSegments(parsed.TargetBranchName), util.PathEscapeSegments(treePath)) ctx.JSONRedirect(redirectTo)
}
ctx.JSONRedirect(returnURI)
} }
func editFileOpenExisting(ctx *context.Context) (prefetch []byte, dataRc io.ReadCloser, fInfo *fileInfo) { func editFileOpenExisting(ctx *context.Context) (prefetch []byte, dataRc io.ReadCloser, fInfo *fileInfo) {
@ -268,7 +321,7 @@ func EditFile(ctx *context.Context) {
func EditFilePost(ctx *context.Context) { func EditFilePost(ctx *context.Context) {
editorAction := ctx.PathParam("editor_action") editorAction := ctx.PathParam("editor_action")
isNewFile := editorAction == "_new" isNewFile := editorAction == "_new"
parsed := parseEditorCommitSubmittedForm[*forms.EditRepoFileForm](ctx) parsed := prepareEditorCommitSubmittedForm[*forms.EditRepoFileForm](ctx)
if ctx.Written() { if ctx.Written() {
return return
} }
@ -292,8 +345,8 @@ func EditFilePost(ctx *context.Context) {
_, err := files_service.ChangeRepoFiles(ctx, ctx.Repo.Repository, ctx.Doer, &files_service.ChangeRepoFilesOptions{ _, err := files_service.ChangeRepoFiles(ctx, ctx.Repo.Repository, ctx.Doer, &files_service.ChangeRepoFilesOptions{
LastCommitID: parsed.form.LastCommit, LastCommitID: parsed.form.LastCommit,
OldBranch: ctx.Repo.BranchName, OldBranch: parsed.OldBranchName,
NewBranch: parsed.TargetBranchName, NewBranch: parsed.NewBranchName,
Message: parsed.GetCommitMessage(defaultCommitMessage), Message: parsed.GetCommitMessage(defaultCommitMessage),
Files: []*files_service.ChangeRepoFile{ Files: []*files_service.ChangeRepoFile{
{ {
@ -308,7 +361,7 @@ func EditFilePost(ctx *context.Context) {
Committer: parsed.GitCommitter, Committer: parsed.GitCommitter,
}) })
if err != nil { if err != nil {
editorHandleFileOperationError(ctx, parsed.TargetBranchName, err) editorHandleFileOperationError(ctx, parsed.NewBranchName, err)
return return
} }
@ -327,7 +380,7 @@ func DeleteFile(ctx *context.Context) {
// DeleteFilePost response for deleting file // DeleteFilePost response for deleting file
func DeleteFilePost(ctx *context.Context) { func DeleteFilePost(ctx *context.Context) {
parsed := parseEditorCommitSubmittedForm[*forms.DeleteRepoFileForm](ctx) parsed := prepareEditorCommitSubmittedForm[*forms.DeleteRepoFileForm](ctx)
if ctx.Written() { if ctx.Written() {
return return
} }
@ -335,8 +388,8 @@ func DeleteFilePost(ctx *context.Context) {
treePath := ctx.Repo.TreePath treePath := ctx.Repo.TreePath
_, err := files_service.ChangeRepoFiles(ctx, ctx.Repo.Repository, ctx.Doer, &files_service.ChangeRepoFilesOptions{ _, err := files_service.ChangeRepoFiles(ctx, ctx.Repo.Repository, ctx.Doer, &files_service.ChangeRepoFilesOptions{
LastCommitID: parsed.form.LastCommit, LastCommitID: parsed.form.LastCommit,
OldBranch: ctx.Repo.BranchName, OldBranch: parsed.OldBranchName,
NewBranch: parsed.TargetBranchName, NewBranch: parsed.NewBranchName,
Files: []*files_service.ChangeRepoFile{ Files: []*files_service.ChangeRepoFile{
{ {
Operation: "delete", Operation: "delete",
@ -349,29 +402,29 @@ func DeleteFilePost(ctx *context.Context) {
Committer: parsed.GitCommitter, Committer: parsed.GitCommitter,
}) })
if err != nil { if err != nil {
editorHandleFileOperationError(ctx, parsed.TargetBranchName, err) editorHandleFileOperationError(ctx, parsed.NewBranchName, err)
return return
} }
ctx.Flash.Success(ctx.Tr("repo.editor.file_delete_success", treePath)) ctx.Flash.Success(ctx.Tr("repo.editor.file_delete_success", treePath))
redirectTreePath := getClosestParentWithFiles(ctx.Repo.GitRepo, parsed.TargetBranchName, treePath) redirectTreePath := getClosestParentWithFiles(ctx.Repo.GitRepo, parsed.NewBranchName, treePath)
redirectForCommitChoice(ctx, parsed, redirectTreePath) redirectForCommitChoice(ctx, parsed, redirectTreePath)
} }
func UploadFile(ctx *context.Context) { func UploadFile(ctx *context.Context) {
ctx.Data["PageIsUpload"] = true ctx.Data["PageIsUpload"] = true
upload.AddUploadContext(ctx, "repo")
prepareTreePathFieldsAndPaths(ctx, ctx.Repo.TreePath) prepareTreePathFieldsAndPaths(ctx, ctx.Repo.TreePath)
opts := prepareEditorCommitFormOptions(ctx, "_upload")
prepareEditorCommitFormOptions(ctx, "_upload")
if ctx.Written() { if ctx.Written() {
return return
} }
upload.AddUploadContextForRepo(ctx, opts.TargetRepo)
ctx.HTML(http.StatusOK, tplUploadFile) ctx.HTML(http.StatusOK, tplUploadFile)
} }
func UploadFilePost(ctx *context.Context) { func UploadFilePost(ctx *context.Context) {
parsed := parseEditorCommitSubmittedForm[*forms.UploadRepoFileForm](ctx) parsed := prepareEditorCommitSubmittedForm[*forms.UploadRepoFileForm](ctx)
if ctx.Written() { if ctx.Written() {
return return
} }
@ -379,8 +432,8 @@ func UploadFilePost(ctx *context.Context) {
defaultCommitMessage := ctx.Locale.TrString("repo.editor.upload_files_to_dir", util.IfZero(parsed.form.TreePath, "/")) defaultCommitMessage := ctx.Locale.TrString("repo.editor.upload_files_to_dir", util.IfZero(parsed.form.TreePath, "/"))
err := files_service.UploadRepoFiles(ctx, ctx.Repo.Repository, ctx.Doer, &files_service.UploadRepoFileOptions{ err := files_service.UploadRepoFiles(ctx, ctx.Repo.Repository, ctx.Doer, &files_service.UploadRepoFileOptions{
LastCommitID: parsed.form.LastCommit, LastCommitID: parsed.form.LastCommit,
OldBranch: ctx.Repo.BranchName, OldBranch: parsed.OldBranchName,
NewBranch: parsed.TargetBranchName, NewBranch: parsed.NewBranchName,
TreePath: parsed.form.TreePath, TreePath: parsed.form.TreePath,
Message: parsed.GetCommitMessage(defaultCommitMessage), Message: parsed.GetCommitMessage(defaultCommitMessage),
Files: parsed.form.Files, Files: parsed.form.Files,
@ -389,7 +442,7 @@ func UploadFilePost(ctx *context.Context) {
Committer: parsed.GitCommitter, Committer: parsed.GitCommitter,
}) })
if err != nil { if err != nil {
editorHandleFileOperationError(ctx, parsed.TargetBranchName, err) editorHandleFileOperationError(ctx, parsed.NewBranchName, err)
return return
} }
redirectForCommitChoice(ctx, parsed, parsed.form.TreePath) redirectForCommitChoice(ctx, parsed, parsed.form.TreePath)

View File

@ -25,7 +25,7 @@ func NewDiffPatch(ctx *context.Context) {
// NewDiffPatchPost response for sending patch page // NewDiffPatchPost response for sending patch page
func NewDiffPatchPost(ctx *context.Context) { func NewDiffPatchPost(ctx *context.Context) {
parsed := parseEditorCommitSubmittedForm[*forms.EditRepoFileForm](ctx) parsed := prepareEditorCommitSubmittedForm[*forms.EditRepoFileForm](ctx)
if ctx.Written() { if ctx.Written() {
return return
} }
@ -33,8 +33,8 @@ func NewDiffPatchPost(ctx *context.Context) {
defaultCommitMessage := ctx.Locale.TrString("repo.editor.patch") defaultCommitMessage := ctx.Locale.TrString("repo.editor.patch")
_, err := files.ApplyDiffPatch(ctx, ctx.Repo.Repository, ctx.Doer, &files.ApplyDiffPatchOptions{ _, err := files.ApplyDiffPatch(ctx, ctx.Repo.Repository, ctx.Doer, &files.ApplyDiffPatchOptions{
LastCommitID: parsed.form.LastCommit, LastCommitID: parsed.form.LastCommit,
OldBranch: ctx.Repo.BranchName, OldBranch: parsed.OldBranchName,
NewBranch: parsed.TargetBranchName, NewBranch: parsed.NewBranchName,
Message: parsed.GetCommitMessage(defaultCommitMessage), Message: parsed.GetCommitMessage(defaultCommitMessage),
Content: strings.ReplaceAll(parsed.form.Content.Value(), "\r\n", "\n"), Content: strings.ReplaceAll(parsed.form.Content.Value(), "\r\n", "\n"),
Author: parsed.GitCommitter, Author: parsed.GitCommitter,
@ -44,7 +44,7 @@ func NewDiffPatchPost(ctx *context.Context) {
err = util.ErrorWrapLocale(err, "repo.editor.fail_to_apply_patch") err = util.ErrorWrapLocale(err, "repo.editor.fail_to_apply_patch")
} }
if err != nil { if err != nil {
editorHandleFileOperationError(ctx, parsed.TargetBranchName, err) editorHandleFileOperationError(ctx, parsed.NewBranchName, err)
return return
} }
redirectForCommitChoice(ctx, parsed, parsed.form.TreePath) redirectForCommitChoice(ctx, parsed, parsed.form.TreePath)

View File

@ -45,7 +45,7 @@ func CherryPick(ctx *context.Context) {
func CherryPickPost(ctx *context.Context) { func CherryPickPost(ctx *context.Context) {
fromCommitID := ctx.PathParam("sha") fromCommitID := ctx.PathParam("sha")
parsed := parseEditorCommitSubmittedForm[*forms.CherryPickForm](ctx) parsed := prepareEditorCommitSubmittedForm[*forms.CherryPickForm](ctx)
if ctx.Written() { if ctx.Written() {
return return
} }
@ -53,8 +53,8 @@ func CherryPickPost(ctx *context.Context) {
defaultCommitMessage := util.Iif(parsed.form.Revert, ctx.Locale.TrString("repo.commit.revert-header", fromCommitID), ctx.Locale.TrString("repo.commit.cherry-pick-header", fromCommitID)) defaultCommitMessage := util.Iif(parsed.form.Revert, ctx.Locale.TrString("repo.commit.revert-header", fromCommitID), ctx.Locale.TrString("repo.commit.cherry-pick-header", fromCommitID))
opts := &files.ApplyDiffPatchOptions{ opts := &files.ApplyDiffPatchOptions{
LastCommitID: parsed.form.LastCommit, LastCommitID: parsed.form.LastCommit,
OldBranch: ctx.Repo.BranchName, OldBranch: parsed.OldBranchName,
NewBranch: parsed.TargetBranchName, NewBranch: parsed.NewBranchName,
Message: parsed.GetCommitMessage(defaultCommitMessage), Message: parsed.GetCommitMessage(defaultCommitMessage),
Author: parsed.GitCommitter, Author: parsed.GitCommitter,
Committer: parsed.GitCommitter, Committer: parsed.GitCommitter,
@ -78,7 +78,7 @@ func CherryPickPost(ctx *context.Context) {
} }
} }
if err != nil { if err != nil {
editorHandleFileOperationError(ctx, parsed.TargetBranchName, err) editorHandleFileOperationError(ctx, parsed.NewBranchName, err)
return return
} }
} }

View File

@ -0,0 +1,31 @@
// Copyright 2025 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package repo
import (
"net/http"
"code.gitea.io/gitea/modules/templates"
"code.gitea.io/gitea/services/context"
repo_service "code.gitea.io/gitea/services/repository"
)
const tplEditorFork templates.TplName = "repo/editor/fork"
func ForkToEdit(ctx *context.Context) {
ctx.HTML(http.StatusOK, tplEditorFork)
}
func ForkToEditPost(ctx *context.Context) {
ForkRepoTo(ctx, ctx.Doer, repo_service.ForkRepoOptions{
BaseRepo: ctx.Repo.Repository,
Name: getUniqueRepositoryName(ctx, ctx.Doer.ID, ctx.Repo.Repository.Name),
Description: ctx.Repo.Repository.Description,
SingleBranch: ctx.Repo.Repository.DefaultBranch, // maybe we only need the default branch in the fork?
})
if ctx.Written() {
return
}
ctx.JSONRedirect("") // reload the page, the new fork should be editable now
}

View File

@ -11,9 +11,11 @@ import (
git_model "code.gitea.io/gitea/models/git" git_model "code.gitea.io/gitea/models/git"
repo_model "code.gitea.io/gitea/models/repo" repo_model "code.gitea.io/gitea/models/repo"
user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/git" "code.gitea.io/gitea/modules/git"
"code.gitea.io/gitea/modules/json" "code.gitea.io/gitea/modules/json"
"code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/log"
repo_module "code.gitea.io/gitea/modules/repository"
context_service "code.gitea.io/gitea/services/context" context_service "code.gitea.io/gitea/services/context"
) )
@ -83,3 +85,26 @@ func getParentTreeFields(treePath string) (treeNames, treePaths []string) {
} }
return treeNames, treePaths return treeNames, treePaths
} }
// getUniqueRepositoryName Gets a unique repository name for a user
// It will append a -<num> postfix if the name is already taken
func getUniqueRepositoryName(ctx context.Context, ownerID int64, name string) string {
uniqueName := name
for i := 1; i < 1000; i++ {
_, err := repo_model.GetRepositoryByName(ctx, ownerID, uniqueName)
if err != nil || repo_model.IsErrRepoNotExist(err) {
return uniqueName
}
uniqueName = fmt.Sprintf("%s-%d", name, i)
i++
}
return ""
}
func editorPushBranchToForkedRepository(ctx context.Context, doer *user_model.User, baseRepo *repo_model.Repository, baseBranchName string, targetRepo *repo_model.Repository, targetBranchName string) error {
return git.Push(ctx, baseRepo.RepoPath(), git.PushOptions{
Remote: targetRepo.RepoPath(),
Branch: baseBranchName + ":" + targetBranchName,
Env: repo_module.PushingEnvironment(doer, targetRepo),
})
}

View File

@ -189,17 +189,25 @@ func ForkPost(ctx *context.Context) {
} }
} }
repo, err := repo_service.ForkRepository(ctx, ctx.Doer, ctxUser, repo_service.ForkRepoOptions{ repo := ForkRepoTo(ctx, ctxUser, repo_service.ForkRepoOptions{
BaseRepo: forkRepo, BaseRepo: forkRepo,
Name: form.RepoName, Name: form.RepoName,
Description: form.Description, Description: form.Description,
SingleBranch: form.ForkSingleBranch, SingleBranch: form.ForkSingleBranch,
}) })
if ctx.Written() {
return
}
ctx.JSONRedirect(ctxUser.HomeLink() + "/" + url.PathEscape(repo.Name))
}
func ForkRepoTo(ctx *context.Context, owner *user_model.User, forkOpts repo_service.ForkRepoOptions) *repo_model.Repository {
repo, err := repo_service.ForkRepository(ctx, ctx.Doer, owner, forkOpts)
if err != nil { if err != nil {
ctx.Data["Err_RepoName"] = true ctx.Data["Err_RepoName"] = true
switch { switch {
case repo_model.IsErrReachLimitOfRepo(err): case repo_model.IsErrReachLimitOfRepo(err):
maxCreationLimit := ctxUser.MaxCreationLimit() maxCreationLimit := owner.MaxCreationLimit()
msg := ctx.TrN(maxCreationLimit, "repo.form.reach_limit_of_creation_1", "repo.form.reach_limit_of_creation_n", maxCreationLimit) msg := ctx.TrN(maxCreationLimit, "repo.form.reach_limit_of_creation_1", "repo.form.reach_limit_of_creation_n", maxCreationLimit)
ctx.JSONError(msg) ctx.JSONError(msg)
case repo_model.IsErrRepoAlreadyExist(err): case repo_model.IsErrRepoAlreadyExist(err):
@ -224,9 +232,7 @@ func ForkPost(ctx *context.Context) {
default: default:
ctx.ServerError("ForkPost", err) ctx.ServerError("ForkPost", err)
} }
return return nil
} }
return repo
log.Trace("Repository forked[%d]: %s/%s", forkRepo.ID, ctxUser.Name, repo.Name)
ctx.JSONRedirect(ctxUser.HomeLink() + "/" + url.PathEscape(repo.Name))
} }

View File

@ -290,7 +290,7 @@ func prepareToRenderFile(ctx *context.Context, entry *git.TreeEntry) {
func prepareToRenderButtons(ctx *context.Context, lfsLock *git_model.LFSLock) { func prepareToRenderButtons(ctx *context.Context, lfsLock *git_model.LFSLock) {
// archived or mirror repository, the buttons should not be shown // archived or mirror repository, the buttons should not be shown
if ctx.Repo.Repository.IsArchived || !ctx.Repo.Repository.CanEnableEditor() { if !ctx.Repo.Repository.CanEnableEditor() {
return return
} }
@ -302,7 +302,9 @@ func prepareToRenderButtons(ctx *context.Context, lfsLock *git_model.LFSLock) {
} }
if !ctx.Repo.CanWriteToBranch(ctx, ctx.Doer, ctx.Repo.BranchName) { if !ctx.Repo.CanWriteToBranch(ctx, ctx.Doer, ctx.Repo.BranchName) {
ctx.Data["CanEditFile"] = true
ctx.Data["EditFileTooltip"] = ctx.Tr("repo.editor.fork_before_edit") ctx.Data["EditFileTooltip"] = ctx.Tr("repo.editor.fork_before_edit")
ctx.Data["CanDeleteFile"] = true
ctx.Data["DeleteFileTooltip"] = ctx.Tr("repo.editor.must_have_write_access") ctx.Data["DeleteFileTooltip"] = ctx.Tr("repo.editor.must_have_write_access")
return return
} }

View File

@ -212,7 +212,7 @@ func prepareToRenderReadmeFile(ctx *context.Context, subfolder string, readmeFil
ctx.Data["EscapeStatus"], ctx.Data["FileContent"] = charset.EscapeControlHTML(template.HTML(contentEscaped), ctx.Locale) ctx.Data["EscapeStatus"], ctx.Data["FileContent"] = charset.EscapeControlHTML(template.HTML(contentEscaped), ctx.Locale)
} }
if !fInfo.isLFSFile && ctx.Repo.CanEnableEditor(ctx, ctx.Doer) { if !fInfo.isLFSFile && ctx.Repo.Repository.CanEnableEditor() {
ctx.Data["CanEditReadmeFile"] = true ctx.Data["CanEditReadmeFile"] = true
} }
} }

View File

@ -7,6 +7,7 @@ package repo
import ( import (
"bytes" "bytes"
gocontext "context" gocontext "context"
"html/template"
"io" "io"
"net/http" "net/http"
"net/url" "net/url"
@ -61,9 +62,9 @@ func MustEnableWiki(ctx *context.Context) {
return return
} }
unit, err := ctx.Repo.Repository.GetUnit(ctx, unit.TypeExternalWiki) repoUnit, err := ctx.Repo.Repository.GetUnit(ctx, unit.TypeExternalWiki)
if err == nil { if err == nil {
ctx.Redirect(unit.ExternalWikiConfig().ExternalWikiURL) ctx.Redirect(repoUnit.ExternalWikiConfig().ExternalWikiURL)
return return
} }
} }
@ -95,7 +96,7 @@ func findEntryForFile(commit *git.Commit, target string) (*git.TreeEntry, error)
} }
func findWikiRepoCommit(ctx *context.Context) (*git.Repository, *git.Commit, error) { func findWikiRepoCommit(ctx *context.Context) (*git.Repository, *git.Commit, error) {
wikiGitRepo, errGitRepo := gitrepo.OpenRepository(ctx, ctx.Repo.Repository.WikiStorageRepo()) wikiGitRepo, errGitRepo := gitrepo.RepositoryFromRequestContextOrOpen(ctx, ctx.Repo.Repository.WikiStorageRepo())
if errGitRepo != nil { if errGitRepo != nil {
ctx.ServerError("OpenRepository", errGitRepo) ctx.ServerError("OpenRepository", errGitRepo)
return nil, nil, errGitRepo return nil, nil, errGitRepo
@ -178,23 +179,17 @@ func wikiContentsByName(ctx *context.Context, commit *git.Commit, wikiName wiki_
} }
func renderViewPage(ctx *context.Context) (*git.Repository, *git.TreeEntry) { func renderViewPage(ctx *context.Context) (*git.Repository, *git.TreeEntry) {
wikiRepo, commit, err := findWikiRepoCommit(ctx) wikiGitRepo, commit, err := findWikiRepoCommit(ctx)
if err != nil { if err != nil {
if wikiRepo != nil {
wikiRepo.Close()
}
if !git.IsErrNotExist(err) { if !git.IsErrNotExist(err) {
ctx.ServerError("GetBranchCommit", err) ctx.ServerError("GetBranchCommit", err)
} }
return nil, nil return nil, nil
} }
// Get page list. // get the wiki pages list.
entries, err := commit.ListEntries() entries, err := commit.ListEntries()
if err != nil { if err != nil {
if wikiRepo != nil {
wikiRepo.Close()
}
ctx.ServerError("ListEntries", err) ctx.ServerError("ListEntries", err)
return nil, nil return nil, nil
} }
@ -208,9 +203,6 @@ func renderViewPage(ctx *context.Context) (*git.Repository, *git.TreeEntry) {
if repo_model.IsErrWikiInvalidFileName(err) { if repo_model.IsErrWikiInvalidFileName(err) {
continue continue
} }
if wikiRepo != nil {
wikiRepo.Close()
}
ctx.ServerError("WikiFilenameToName", err) ctx.ServerError("WikiFilenameToName", err)
return nil, nil return nil, nil
} else if wikiName == "_Sidebar" || wikiName == "_Footer" { } else if wikiName == "_Sidebar" || wikiName == "_Footer" {
@ -249,58 +241,26 @@ func renderViewPage(ctx *context.Context) (*git.Repository, *git.TreeEntry) {
ctx.Redirect(util.URLJoin(ctx.Repo.RepoLink, "wiki/raw", string(pageName))) ctx.Redirect(util.URLJoin(ctx.Repo.RepoLink, "wiki/raw", string(pageName)))
} }
if entry == nil || ctx.Written() { if entry == nil || ctx.Written() {
if wikiRepo != nil {
wikiRepo.Close()
}
return nil, nil return nil, nil
} }
// get filecontent // get page content
data := wikiContentsByEntry(ctx, entry) data := wikiContentsByEntry(ctx, entry)
if ctx.Written() { if ctx.Written() {
if wikiRepo != nil {
wikiRepo.Close()
}
return nil, nil return nil, nil
} }
var sidebarContent []byte
if !isSideBar {
sidebarContent, _, _, _ = wikiContentsByName(ctx, commit, "_Sidebar")
if ctx.Written() {
if wikiRepo != nil {
wikiRepo.Close()
}
return nil, nil
}
} else {
sidebarContent = data
}
var footerContent []byte
if !isFooter {
footerContent, _, _, _ = wikiContentsByName(ctx, commit, "_Footer")
if ctx.Written() {
if wikiRepo != nil {
wikiRepo.Close()
}
return nil, nil
}
} else {
footerContent = data
}
rctx := renderhelper.NewRenderContextRepoWiki(ctx, ctx.Repo.Repository) rctx := renderhelper.NewRenderContextRepoWiki(ctx, ctx.Repo.Repository)
buf := &strings.Builder{} renderFn := func(data []byte) (escaped *charset.EscapeStatus, output template.HTML, err error) {
renderFn := func(data []byte) (escaped *charset.EscapeStatus, output string, err error) { buf := &strings.Builder{}
markupRd, markupWr := io.Pipe() markupRd, markupWr := io.Pipe()
defer markupWr.Close() defer markupWr.Close()
done := make(chan struct{}) done := make(chan struct{})
go func() { go func() {
// We allow NBSP here this is rendered // We allow NBSP here this is rendered
escaped, _ = charset.EscapeControlReader(markupRd, buf, ctx.Locale, charset.RuneNBSP) escaped, _ = charset.EscapeControlReader(markupRd, buf, ctx.Locale, charset.RuneNBSP)
output = buf.String() output = template.HTML(buf.String())
buf.Reset() buf.Reset()
close(done) close(done)
}() }()
@ -311,75 +271,61 @@ func renderViewPage(ctx *context.Context) (*git.Repository, *git.TreeEntry) {
return escaped, output, err return escaped, output, err
} }
ctx.Data["EscapeStatus"], ctx.Data["content"], err = renderFn(data) ctx.Data["EscapeStatus"], ctx.Data["WikiContentHTML"], err = renderFn(data)
if err != nil { if err != nil {
if wikiRepo != nil {
wikiRepo.Close()
}
ctx.ServerError("Render", err) ctx.ServerError("Render", err)
return nil, nil return nil, nil
} }
if rctx.SidebarTocNode != nil { if rctx.SidebarTocNode != nil {
sb := &strings.Builder{} sb := strings.Builder{}
err = markdown.SpecializedMarkdown(rctx).Renderer().Render(sb, nil, rctx.SidebarTocNode) if err = markdown.SpecializedMarkdown(rctx).Renderer().Render(&sb, nil, rctx.SidebarTocNode); err != nil {
if err != nil {
log.Error("Failed to render wiki sidebar TOC: %v", err) log.Error("Failed to render wiki sidebar TOC: %v", err)
} else {
ctx.Data["sidebarTocContent"] = sb.String()
} }
ctx.Data["WikiSidebarTocHTML"] = templates.SanitizeHTML(sb.String())
} }
if !isSideBar { if !isSideBar {
buf.Reset() sidebarContent, _, _, _ := wikiContentsByName(ctx, commit, "_Sidebar")
ctx.Data["sidebarEscapeStatus"], ctx.Data["sidebarContent"], err = renderFn(sidebarContent) if ctx.Written() {
return nil, nil
}
ctx.Data["WikiSidebarEscapeStatus"], ctx.Data["WikiSidebarHTML"], err = renderFn(sidebarContent)
if err != nil { if err != nil {
if wikiRepo != nil {
wikiRepo.Close()
}
ctx.ServerError("Render", err) ctx.ServerError("Render", err)
return nil, nil return nil, nil
} }
ctx.Data["sidebarPresent"] = sidebarContent != nil
} else {
ctx.Data["sidebarPresent"] = false
} }
if !isFooter { if !isFooter {
buf.Reset() footerContent, _, _, _ := wikiContentsByName(ctx, commit, "_Footer")
ctx.Data["footerEscapeStatus"], ctx.Data["footerContent"], err = renderFn(footerContent) if ctx.Written() {
return nil, nil
}
ctx.Data["WikiFooterEscapeStatus"], ctx.Data["WikiFooterHTML"], err = renderFn(footerContent)
if err != nil { if err != nil {
if wikiRepo != nil {
wikiRepo.Close()
}
ctx.ServerError("Render", err) ctx.ServerError("Render", err)
return nil, nil return nil, nil
} }
ctx.Data["footerPresent"] = footerContent != nil
} else {
ctx.Data["footerPresent"] = false
} }
// get commit count - wiki revisions // get commit count - wiki revisions
commitsCount, _ := wikiRepo.FileCommitsCount(ctx.Repo.Repository.DefaultWikiBranch, pageFilename) commitsCount, _ := wikiGitRepo.FileCommitsCount(ctx.Repo.Repository.DefaultWikiBranch, pageFilename)
ctx.Data["CommitCount"] = commitsCount ctx.Data["CommitCount"] = commitsCount
return wikiRepo, entry return wikiGitRepo, entry
} }
func renderRevisionPage(ctx *context.Context) (*git.Repository, *git.TreeEntry) { func renderRevisionPage(ctx *context.Context) (*git.Repository, *git.TreeEntry) {
wikiRepo, commit, err := findWikiRepoCommit(ctx) wikiGitRepo, commit, err := findWikiRepoCommit(ctx)
if err != nil { if err != nil {
if wikiRepo != nil {
wikiRepo.Close()
}
if !git.IsErrNotExist(err) { if !git.IsErrNotExist(err) {
ctx.ServerError("GetBranchCommit", err) ctx.ServerError("GetBranchCommit", err)
} }
return nil, nil return nil, nil
} }
// get requested pagename // get requested page name
pageName := wiki_service.WebPathFromRequest(ctx.PathParamRaw("*")) pageName := wiki_service.WebPathFromRequest(ctx.PathParamRaw("*"))
if len(pageName) == 0 { if len(pageName) == 0 {
pageName = "Home" pageName = "Home"
@ -394,50 +340,35 @@ func renderRevisionPage(ctx *context.Context) (*git.Repository, *git.TreeEntry)
ctx.Data["Username"] = ctx.Repo.Owner.Name ctx.Data["Username"] = ctx.Repo.Owner.Name
ctx.Data["Reponame"] = ctx.Repo.Repository.Name ctx.Data["Reponame"] = ctx.Repo.Repository.Name
// lookup filename in wiki - get filecontent, gitTree entry , real filename // lookup filename in wiki - get page content, gitTree entry , real filename
data, entry, pageFilename, noEntry := wikiContentsByName(ctx, commit, pageName) _, entry, pageFilename, noEntry := wikiContentsByName(ctx, commit, pageName)
if noEntry { if noEntry {
ctx.Redirect(ctx.Repo.RepoLink + "/wiki/?action=_pages") ctx.Redirect(ctx.Repo.RepoLink + "/wiki/?action=_pages")
} }
if entry == nil || ctx.Written() { if entry == nil || ctx.Written() {
if wikiRepo != nil {
wikiRepo.Close()
}
return nil, nil return nil, nil
} }
ctx.Data["content"] = string(data)
ctx.Data["sidebarPresent"] = false
ctx.Data["sidebarContent"] = ""
ctx.Data["footerPresent"] = false
ctx.Data["footerContent"] = ""
// get commit count - wiki revisions // get commit count - wiki revisions
commitsCount, _ := wikiRepo.FileCommitsCount(ctx.Repo.Repository.DefaultWikiBranch, pageFilename) commitsCount, _ := wikiGitRepo.FileCommitsCount(ctx.Repo.Repository.DefaultWikiBranch, pageFilename)
ctx.Data["CommitCount"] = commitsCount ctx.Data["CommitCount"] = commitsCount
// get page // get page
page := max(ctx.FormInt("page"), 1) page := max(ctx.FormInt("page"), 1)
// get Commit Count // get Commit Count
commitsHistory, err := wikiRepo.CommitsByFileAndRange( commitsHistory, err := wikiGitRepo.CommitsByFileAndRange(
git.CommitsByFileAndRangeOptions{ git.CommitsByFileAndRangeOptions{
Revision: ctx.Repo.Repository.DefaultWikiBranch, Revision: ctx.Repo.Repository.DefaultWikiBranch,
File: pageFilename, File: pageFilename,
Page: page, Page: page,
}) })
if err != nil { if err != nil {
if wikiRepo != nil {
wikiRepo.Close()
}
ctx.ServerError("CommitsByFileAndRange", err) ctx.ServerError("CommitsByFileAndRange", err)
return nil, nil return nil, nil
} }
ctx.Data["Commits"], err = git_service.ConvertFromGitCommit(ctx, commitsHistory, ctx.Repo.Repository) ctx.Data["Commits"], err = git_service.ConvertFromGitCommit(ctx, commitsHistory, ctx.Repo.Repository)
if err != nil { if err != nil {
if wikiRepo != nil {
wikiRepo.Close()
}
ctx.ServerError("ConvertFromGitCommit", err) ctx.ServerError("ConvertFromGitCommit", err)
return nil, nil return nil, nil
} }
@ -446,16 +377,11 @@ func renderRevisionPage(ctx *context.Context) (*git.Repository, *git.TreeEntry)
pager.AddParamFromRequest(ctx.Req) pager.AddParamFromRequest(ctx.Req)
ctx.Data["Page"] = pager ctx.Data["Page"] = pager
return wikiRepo, entry return wikiGitRepo, entry
} }
func renderEditPage(ctx *context.Context) { func renderEditPage(ctx *context.Context) {
wikiRepo, commit, err := findWikiRepoCommit(ctx) _, commit, err := findWikiRepoCommit(ctx)
defer func() {
if wikiRepo != nil {
_ = wikiRepo.Close()
}
}()
if err != nil { if err != nil {
if !git.IsErrNotExist(err) { if !git.IsErrNotExist(err) {
ctx.ServerError("GetBranchCommit", err) ctx.ServerError("GetBranchCommit", err)
@ -463,7 +389,7 @@ func renderEditPage(ctx *context.Context) {
return return
} }
// get requested pagename // get requested page name
pageName := wiki_service.WebPathFromRequest(ctx.PathParamRaw("*")) pageName := wiki_service.WebPathFromRequest(ctx.PathParamRaw("*"))
if len(pageName) == 0 { if len(pageName) == 0 {
pageName = "Home" pageName = "Home"
@ -487,17 +413,13 @@ func renderEditPage(ctx *context.Context) {
return return
} }
// get filecontent // get wiki page content
data := wikiContentsByEntry(ctx, entry) data := wikiContentsByEntry(ctx, entry)
if ctx.Written() { if ctx.Written() {
return return
} }
ctx.Data["content"] = string(data) ctx.Data["WikiEditContent"] = string(data)
ctx.Data["sidebarPresent"] = false
ctx.Data["sidebarContent"] = ""
ctx.Data["footerPresent"] = false
ctx.Data["footerContent"] = ""
} }
// WikiPost renders post of wiki page // WikiPost renders post of wiki page
@ -559,12 +481,7 @@ func Wiki(ctx *context.Context) {
return return
} }
wikiRepo, entry := renderViewPage(ctx) wikiGitRepo, entry := renderViewPage(ctx)
defer func() {
if wikiRepo != nil {
wikiRepo.Close()
}
}()
if ctx.Written() { if ctx.Written() {
return return
} }
@ -580,7 +497,7 @@ func Wiki(ctx *context.Context) {
ctx.Data["FormatWarning"] = ext + " rendering is not supported at the moment. Rendered as Markdown." ctx.Data["FormatWarning"] = ext + " rendering is not supported at the moment. Rendered as Markdown."
} }
// Get last change information. // Get last change information.
lastCommit, err := wikiRepo.GetCommitByPath(wikiPath) lastCommit, err := wikiGitRepo.GetCommitByPath(wikiPath)
if err != nil { if err != nil {
ctx.ServerError("GetCommitByPath", err) ctx.ServerError("GetCommitByPath", err)
return return
@ -600,13 +517,7 @@ func WikiRevision(ctx *context.Context) {
return return
} }
wikiRepo, entry := renderRevisionPage(ctx) wikiGitRepo, entry := renderRevisionPage(ctx)
defer func() {
if wikiRepo != nil {
wikiRepo.Close()
}
}()
if ctx.Written() { if ctx.Written() {
return return
} }
@ -618,7 +529,7 @@ func WikiRevision(ctx *context.Context) {
// Get last change information. // Get last change information.
wikiPath := entry.Name() wikiPath := entry.Name()
lastCommit, err := wikiRepo.GetCommitByPath(wikiPath) lastCommit, err := wikiGitRepo.GetCommitByPath(wikiPath)
if err != nil { if err != nil {
ctx.ServerError("GetCommitByPath", err) ctx.ServerError("GetCommitByPath", err)
return return
@ -638,12 +549,7 @@ func WikiPages(ctx *context.Context) {
ctx.Data["Title"] = ctx.Tr("repo.wiki.pages") ctx.Data["Title"] = ctx.Tr("repo.wiki.pages")
ctx.Data["CanWriteWiki"] = ctx.Repo.CanWrite(unit.TypeWiki) && !ctx.Repo.Repository.IsArchived ctx.Data["CanWriteWiki"] = ctx.Repo.CanWrite(unit.TypeWiki) && !ctx.Repo.Repository.IsArchived
wikiRepo, commit, err := findWikiRepoCommit(ctx) _, commit, err := findWikiRepoCommit(ctx)
defer func() {
if wikiRepo != nil {
_ = wikiRepo.Close()
}
}()
if err != nil { if err != nil {
ctx.Redirect(ctx.Repo.RepoLink + "/wiki") ctx.Redirect(ctx.Repo.RepoLink + "/wiki")
return return
@ -697,13 +603,7 @@ func WikiPages(ctx *context.Context) {
// WikiRaw outputs raw blob requested by user (image for example) // WikiRaw outputs raw blob requested by user (image for example)
func WikiRaw(ctx *context.Context) { func WikiRaw(ctx *context.Context) {
wikiRepo, commit, err := findWikiRepoCommit(ctx) _, commit, err := findWikiRepoCommit(ctx)
defer func() {
if wikiRepo != nil {
wikiRepo.Close()
}
}()
if err != nil { if err != nil {
if git.IsErrNotExist(err) { if git.IsErrNotExist(err) {
ctx.NotFound(nil) ctx.NotFound(nil)

View File

@ -164,7 +164,7 @@ func TestEditWiki(t *testing.T) {
EditWiki(ctx) EditWiki(ctx)
assert.Equal(t, http.StatusOK, ctx.Resp.WrittenStatus()) assert.Equal(t, http.StatusOK, ctx.Resp.WrittenStatus())
assert.EqualValues(t, "Home", ctx.Data["Title"]) assert.EqualValues(t, "Home", ctx.Data["Title"])
assert.Equal(t, wikiContent(t, ctx.Repo.Repository, "Home"), ctx.Data["content"]) assert.Equal(t, wikiContent(t, ctx.Repo.Repository, "Home"), ctx.Data["WikiEditContent"])
ctx, _ = contexttest.MockContext(t, "user2/repo1/wiki/jpeg.jpg?action=_edit") ctx, _ = contexttest.MockContext(t, "user2/repo1/wiki/jpeg.jpg?action=_edit")
ctx.SetPathParam("*", "jpeg.jpg") ctx.SetPathParam("*", "jpeg.jpg")

View File

@ -1313,23 +1313,35 @@ func registerWebRoutes(m *web.Router) {
}, reqSignIn, context.RepoAssignment, context.RepoMustNotBeArchived()) }, reqSignIn, context.RepoAssignment, context.RepoMustNotBeArchived())
// end "/{username}/{reponame}": create or edit issues, pulls, labels, milestones // end "/{username}/{reponame}": create or edit issues, pulls, labels, milestones
m.Group("/{username}/{reponame}", func() { // repo code m.Group("/{username}/{reponame}", func() { // repo code (at least "code reader")
m.Group("", func() { m.Group("", func() {
m.Group("", func() { m.Group("", func() {
m.Post("/_preview/*", repo.DiffPreviewPost) // "GET" requests only need "code reader" permission, "POST" requests need "code writer" permission.
m.Combo("/{editor_action:_edit}/*").Get(repo.EditFile). // Because reader can "fork and edit"
Post(web.Bind(forms.EditRepoFileForm{}), repo.EditFilePost) canWriteToBranch := context.CanWriteToBranch()
m.Combo("/{editor_action:_new}/*").Get(repo.EditFile). m.Post("/_preview/*", repo.DiffPreviewPost) // read-only, fine with "code reader"
Post(web.Bind(forms.EditRepoFileForm{}), repo.EditFilePost) m.Post("/_fork/*", repo.ForkToEditPost) // read-only, fork to own repo, fine with "code reader"
m.Combo("/_delete/*").Get(repo.DeleteFile).
Post(web.Bind(forms.DeleteRepoFileForm{}), repo.DeleteFilePost) // the path params are used in PrepareCommitFormOptions to construct the correct form action URL
m.Combo("/_upload/*", repo.MustBeAbleToUpload).Get(repo.UploadFile). m.Combo("/{editor_action:_edit}/*").
Post(web.Bind(forms.UploadRepoFileForm{}), repo.UploadFilePost) Get(repo.EditFile).
m.Combo("/_diffpatch/*").Get(repo.NewDiffPatch). Post(web.Bind(forms.EditRepoFileForm{}), canWriteToBranch, repo.EditFilePost)
Post(web.Bind(forms.EditRepoFileForm{}), repo.NewDiffPatchPost) m.Combo("/{editor_action:_new}/*").
m.Combo("/_cherrypick/{sha:([a-f0-9]{7,64})}/*").Get(repo.CherryPick). Get(repo.EditFile).
Post(web.Bind(forms.CherryPickForm{}), repo.CherryPickPost) Post(web.Bind(forms.EditRepoFileForm{}), canWriteToBranch, repo.EditFilePost)
}, context.RepoRefByType(git.RefTypeBranch), context.CanWriteToBranch(), repo.WebGitOperationCommonData) m.Combo("/{editor_action:_delete}/*").
Get(repo.DeleteFile).
Post(web.Bind(forms.DeleteRepoFileForm{}), canWriteToBranch, repo.DeleteFilePost)
m.Combo("/{editor_action:_upload}/*", repo.MustBeAbleToUpload).
Get(repo.UploadFile).
Post(web.Bind(forms.UploadRepoFileForm{}), canWriteToBranch, repo.UploadFilePost)
m.Combo("/{editor_action:_diffpatch}/*").
Get(repo.NewDiffPatch).
Post(web.Bind(forms.EditRepoFileForm{}), canWriteToBranch, repo.NewDiffPatchPost)
m.Combo("/{editor_action:_cherrypick}/{sha:([a-f0-9]{7,64})}/*").
Get(repo.CherryPick).
Post(web.Bind(forms.CherryPickForm{}), canWriteToBranch, repo.CherryPickPost)
}, context.RepoRefByType(git.RefTypeBranch), repo.WebGitOperationCommonData)
m.Group("", func() { m.Group("", func() {
m.Post("/upload-file", repo.UploadFileToServer) m.Post("/upload-file", repo.UploadFileToServer)
m.Post("/upload-remove", repo.RemoveUploadFileFromServer) m.Post("/upload-remove", repo.RemoveUploadFileFromServer)

View File

@ -71,11 +71,6 @@ func (r *Repository) CanWriteToBranch(ctx context.Context, user *user_model.User
return issues_model.CanMaintainerWriteToBranch(ctx, r.Permission, branch, user) return issues_model.CanMaintainerWriteToBranch(ctx, r.Permission, branch, user)
} }
// CanEnableEditor returns true if repository is editable and user has proper access level.
func (r *Repository) CanEnableEditor(ctx context.Context, user *user_model.User) bool {
return r.RefFullName.IsBranch() && r.CanWriteToBranch(ctx, user, r.BranchName) && r.Repository.CanEnableEditor() && !r.Repository.IsArchived
}
// CanCreateBranch returns true if repository is editable and user has proper access level. // CanCreateBranch returns true if repository is editable and user has proper access level.
func (r *Repository) CanCreateBranch() bool { func (r *Repository) CanCreateBranch() bool {
return r.Permission.CanWrite(unit_model.TypeCode) && r.Repository.CanCreateBranch() return r.Permission.CanWrite(unit_model.TypeCode) && r.Repository.CanCreateBranch()
@ -94,9 +89,13 @@ func RepoMustNotBeArchived() func(ctx *Context) {
} }
} }
type CommitFormBehaviors struct { type CommitFormOptions struct {
NeedFork bool
TargetRepo *repo_model.Repository
TargetFormAction string
WillSubmitToFork bool
CanCommitToBranch bool CanCommitToBranch bool
EditorEnabled bool
UserCanPush bool UserCanPush bool
RequireSigned bool RequireSigned bool
WillSign bool WillSign bool
@ -106,51 +105,84 @@ type CommitFormBehaviors struct {
CanCreateBasePullRequest bool CanCreateBasePullRequest bool
} }
func (r *Repository) PrepareCommitFormBehaviors(ctx *Context, doer *user_model.User) (*CommitFormBehaviors, error) { func PrepareCommitFormOptions(ctx *Context, doer *user_model.User, targetRepo *repo_model.Repository, doerRepoPerm access_model.Permission, refName git.RefName) (*CommitFormOptions, error) {
protectedBranch, err := git_model.GetFirstMatchProtectedBranchRule(ctx, r.Repository.ID, r.BranchName) if !refName.IsBranch() {
// it shouldn't happen because middleware already checks
return nil, util.NewInvalidArgumentErrorf("ref %q is not a branch", refName)
}
originRepo := targetRepo
branchName := refName.ShortName()
// TODO: CanMaintainerWriteToBranch is a bad name, but it really does what "CanWriteToBranch" does
if !issues_model.CanMaintainerWriteToBranch(ctx, doerRepoPerm, branchName, doer) {
targetRepo = repo_model.GetForkedRepo(ctx, doer.ID, targetRepo.ID)
if targetRepo == nil {
return &CommitFormOptions{NeedFork: true}, nil
}
// now, we get our own forked repo; it must be writable by us.
}
submitToForkedRepo := targetRepo.ID != originRepo.ID
err := targetRepo.GetBaseRepo(ctx)
if err != nil { if err != nil {
return nil, err return nil, err
} }
userCanPush := true
requireSigned := false
if protectedBranch != nil {
protectedBranch.Repo = r.Repository
userCanPush = protectedBranch.CanUserPush(ctx, doer)
requireSigned = protectedBranch.RequireSignedCommits
}
sign, keyID, _, err := asymkey_service.SignCRUDAction(ctx, r.Repository.RepoPath(), doer, r.Repository.RepoPath(), git.BranchPrefix+r.BranchName) protectedBranch, err := git_model.GetFirstMatchProtectedBranchRule(ctx, targetRepo.ID, branchName)
canEnableEditor := r.CanEnableEditor(ctx, doer)
canCommit := canEnableEditor && userCanPush
if requireSigned {
canCommit = canCommit && sign
}
wontSignReason := ""
if err != nil { if err != nil {
if asymkey_service.IsErrWontSign(err) { return nil, err
wontSignReason = string(err.(*asymkey_service.ErrWontSign).Reason) }
err = nil canPushWithProtection := true
} else { protectionRequireSigned := false
wontSignReason = "error" if protectedBranch != nil {
} protectedBranch.Repo = targetRepo
canPushWithProtection = protectedBranch.CanUserPush(ctx, doer)
protectionRequireSigned = protectedBranch.RequireSignedCommits
} }
canCreateBasePullRequest := ctx.Repo.Repository.BaseRepo != nil && ctx.Repo.Repository.BaseRepo.UnitEnabled(ctx, unit_model.TypePullRequests) willSign, signKeyID, _, err := asymkey_service.SignCRUDAction(ctx, targetRepo.RepoPath(), doer, targetRepo.RepoPath(), refName.String())
canCreatePullRequest := ctx.Repo.Repository.UnitEnabled(ctx, unit_model.TypePullRequests) || canCreateBasePullRequest wontSignReason := ""
if asymkey_service.IsErrWontSign(err) {
wontSignReason = string(err.(*asymkey_service.ErrWontSign).Reason)
} else if err != nil {
return nil, err
}
return &CommitFormBehaviors{ canCommitToBranch := !submitToForkedRepo /* same repo */ && targetRepo.CanEnableEditor() && canPushWithProtection
CanCommitToBranch: canCommit, if protectionRequireSigned {
EditorEnabled: canEnableEditor, canCommitToBranch = canCommitToBranch && willSign
UserCanPush: userCanPush, }
RequireSigned: requireSigned,
WillSign: sign, canCreateBasePullRequest := targetRepo.BaseRepo != nil && targetRepo.BaseRepo.UnitEnabled(ctx, unit_model.TypePullRequests)
SigningKey: keyID, canCreatePullRequest := targetRepo.UnitEnabled(ctx, unit_model.TypePullRequests) || canCreateBasePullRequest
opts := &CommitFormOptions{
TargetRepo: targetRepo,
WillSubmitToFork: submitToForkedRepo,
CanCommitToBranch: canCommitToBranch,
UserCanPush: canPushWithProtection,
RequireSigned: protectionRequireSigned,
WillSign: willSign,
SigningKey: signKeyID,
WontSignReason: wontSignReason, WontSignReason: wontSignReason,
CanCreatePullRequest: canCreatePullRequest, CanCreatePullRequest: canCreatePullRequest,
CanCreateBasePullRequest: canCreateBasePullRequest, CanCreateBasePullRequest: canCreateBasePullRequest,
}, err }
editorAction := ctx.PathParam("editor_action")
editorPathParamRemaining := util.PathEscapeSegments(branchName) + "/" + util.PathEscapeSegments(ctx.Repo.TreePath)
if submitToForkedRepo {
// there is only "default branch" in forked repo, we will use "from_base_branch" to get a new branch from base repo
editorPathParamRemaining = util.PathEscapeSegments(targetRepo.DefaultBranch) + "/" + util.PathEscapeSegments(ctx.Repo.TreePath) + "?from_base_branch=" + url.QueryEscape(branchName)
}
if editorAction == "_cherrypick" {
opts.TargetFormAction = targetRepo.Link() + "/" + editorAction + "/" + ctx.PathParam("sha") + "/" + editorPathParamRemaining
} else {
opts.TargetFormAction = targetRepo.Link() + "/" + editorAction + "/" + editorPathParamRemaining
}
if ctx.Req.URL.RawQuery != "" {
opts.TargetFormAction += util.Iif(strings.Contains(opts.TargetFormAction, "?"), "&", "?") + ctx.Req.URL.RawQuery
}
return opts, nil
} }
// CanUseTimetracker returns whether a user can use the timetracker. // CanUseTimetracker returns whether a user can use the timetracker.

View File

@ -11,7 +11,9 @@ import (
"regexp" "regexp"
"strings" "strings"
repo_model "code.gitea.io/gitea/models/repo"
"code.gitea.io/gitea/modules/log" "code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/reqctx"
"code.gitea.io/gitea/modules/setting" "code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/services/context" "code.gitea.io/gitea/services/context"
) )
@ -106,14 +108,17 @@ func AddUploadContext(ctx *context.Context, uploadType string) {
ctx.Data["UploadAccepts"] = strings.ReplaceAll(setting.Attachment.AllowedTypes, "|", ",") ctx.Data["UploadAccepts"] = strings.ReplaceAll(setting.Attachment.AllowedTypes, "|", ",")
ctx.Data["UploadMaxFiles"] = setting.Attachment.MaxFiles ctx.Data["UploadMaxFiles"] = setting.Attachment.MaxFiles
ctx.Data["UploadMaxSize"] = setting.Attachment.MaxSize ctx.Data["UploadMaxSize"] = setting.Attachment.MaxSize
case "repo":
ctx.Data["UploadUrl"] = ctx.Repo.RepoLink + "/upload-file"
ctx.Data["UploadRemoveUrl"] = ctx.Repo.RepoLink + "/upload-remove"
ctx.Data["UploadLinkUrl"] = ctx.Repo.RepoLink + "/upload-file"
ctx.Data["UploadAccepts"] = strings.ReplaceAll(setting.Repository.Upload.AllowedTypes, "|", ",")
ctx.Data["UploadMaxFiles"] = setting.Repository.Upload.MaxFiles
ctx.Data["UploadMaxSize"] = setting.Repository.Upload.FileMaxSize
default: default:
setting.PanicInDevOrTesting("Invalid upload type: %s", uploadType) setting.PanicInDevOrTesting("Invalid upload type: %s", uploadType)
} }
} }
func AddUploadContextForRepo(ctx reqctx.RequestContext, repo *repo_model.Repository) {
ctxData, repoLink := ctx.GetData(), repo.Link()
ctxData["UploadUrl"] = repoLink + "/upload-file"
ctxData["UploadRemoveUrl"] = repoLink + "/upload-remove"
ctxData["UploadLinkUrl"] = repoLink + "/upload-file"
ctxData["UploadAccepts"] = strings.ReplaceAll(setting.Repository.Upload.AllowedTypes, "|", ",")
ctxData["UploadMaxFiles"] = setting.Repository.Upload.MaxFiles
ctxData["UploadMaxSize"] = setting.Repository.Upload.FileMaxSize
}

View File

@ -13,7 +13,7 @@ import (
container_module "code.gitea.io/gitea/modules/packages/container" container_module "code.gitea.io/gitea/modules/packages/container"
packages_service "code.gitea.io/gitea/services/packages" packages_service "code.gitea.io/gitea/services/packages"
digest "github.com/opencontainers/go-digest" "github.com/opencontainers/go-digest"
) )
// Cleanup removes expired container data // Cleanup removes expired container data

View File

@ -195,7 +195,7 @@ func (telegramConvertor) WorkflowJob(p *api.WorkflowJobPayload) (TelegramPayload
func createTelegramPayloadHTML(msgHTML string) TelegramPayload { func createTelegramPayloadHTML(msgHTML string) TelegramPayload {
// https://core.telegram.org/bots/api#formatting-options // https://core.telegram.org/bots/api#formatting-options
return TelegramPayload{ return TelegramPayload{
Message: strings.TrimSpace(markup.Sanitize(msgHTML)), Message: strings.TrimSpace(string(markup.Sanitize(msgHTML))),
ParseMode: "HTML", ParseMode: "HTML",
DisableWebPreview: true, DisableWebPreview: true,
} }

View File

@ -130,8 +130,7 @@
<span class="only-mobile">{{.SignedUser.Name}}</span> <span class="only-mobile">{{.SignedUser.Name}}</span>
<span class="not-mobile">{{svg "octicon-triangle-down"}}</span> <span class="not-mobile">{{svg "octicon-triangle-down"}}</span>
</span> </span>
{{/* do not localize it, here it needs the fixed length (width) to make UI comfortable */}} {{if .IsAdmin}}<span class="navbar-profile-admin">{{svg "octicon-server" 11}}</span>{{end}}
{{if .IsAdmin}}<span class="navbar-profile-admin">admin</span>{{end}}
<div class="menu user-menu"> <div class="menu user-menu">
<div class="header"> <div class="header">
{{ctx.Locale.Tr "signed_in_as"}} <strong>{{.SignedUser.Name}}</strong> {{ctx.Locale.Tr "signed_in_as"}} <strong>{{.SignedUser.Name}}</strong>

View File

@ -3,8 +3,9 @@
{{template "repo/header" .}} {{template "repo/header" .}}
<div class="ui container"> <div class="ui container">
{{template "base/alert" .}} {{template "base/alert" .}}
<form class="ui edit form form-fetch-action" method="post" action="{{.RepoLink}}/_cherrypick/{{.FromCommitID}}/{{.BranchName | PathEscapeSegments}}"> <form class="ui edit form form-fetch-action" method="post" action="{{.CommitFormOptions.TargetFormAction}}">
{{.CsrfTokenHtml}} {{.CsrfTokenHtml}}
{{template "repo/editor/common_top" .}}
<input type="hidden" name="revert" value="{{if eq .CherryPickType "revert"}}true{{else}}false{{end}}"> <input type="hidden" name="revert" value="{{if eq .CherryPickType "revert"}}true{{else}}false{{end}}">
<div class="repo-editor-header"> <div class="repo-editor-header">
<div class="breadcrumb"> <div class="breadcrumb">

View File

@ -1,11 +1,11 @@
<div class="commit-form-wrapper"> <div class="commit-form-wrapper">
{{ctx.AvatarUtils.Avatar .SignedUser 40 "commit-avatar"}} {{ctx.AvatarUtils.Avatar .SignedUser 40 "commit-avatar"}}
<div class="commit-form"> <div class="commit-form">
<h3>{{- if .CommitFormBehaviors.WillSign}} <h3>{{- if .CommitFormOptions.WillSign}}
<span title="{{ctx.Locale.Tr "repo.signing.will_sign" .CommitFormBehaviors.SigningKey}}">{{svg "octicon-lock" 24}}</span> <span title="{{ctx.Locale.Tr "repo.signing.will_sign" .CommitFormOptions.SigningKey}}">{{svg "octicon-lock" 24}}</span>
{{ctx.Locale.Tr "repo.editor.commit_signed_changes"}} {{ctx.Locale.Tr "repo.editor.commit_signed_changes"}}
{{- else}} {{- else}}
<span title="{{ctx.Locale.Tr (printf "repo.signing.wont_sign.%s" .CommitFormBehaviors.WontSignReason)}}">{{svg "octicon-unlock" 24}}</span> <span title="{{ctx.Locale.Tr (printf "repo.signing.wont_sign.%s" .CommitFormOptions.WontSignReason)}}">{{svg "octicon-unlock" 24}}</span>
{{ctx.Locale.Tr "repo.editor.commit_changes"}} {{ctx.Locale.Tr "repo.editor.commit_changes"}}
{{- end}}</h3> {{- end}}</h3>
<div class="field"> <div class="field">
@ -22,17 +22,17 @@
</div> </div>
<div class="quick-pull-choice js-quick-pull-choice"> <div class="quick-pull-choice js-quick-pull-choice">
<div class="field"> <div class="field">
<div class="ui radio checkbox {{if not .CommitFormBehaviors.CanCommitToBranch}}disabled{{end}}"> <div class="ui radio checkbox {{if not .CommitFormOptions.CanCommitToBranch}}disabled{{end}}">
<input type="radio" class="js-quick-pull-choice-option" name="commit_choice" value="direct" data-button-text="{{ctx.Locale.Tr "repo.editor.commit_changes"}}" {{if eq .commit_choice "direct"}}checked{{end}}> <input type="radio" class="js-quick-pull-choice-option" name="commit_choice" value="direct" data-button-text="{{ctx.Locale.Tr "repo.editor.commit_changes"}}" {{if eq .commit_choice "direct"}}checked{{end}}>
<label> <label>
{{svg "octicon-git-commit"}} {{svg "octicon-git-commit"}}
{{ctx.Locale.Tr "repo.editor.commit_directly_to_this_branch" .BranchName}} {{ctx.Locale.Tr "repo.editor.commit_directly_to_this_branch" .BranchName}}
{{if not .CommitFormBehaviors.CanCommitToBranch}} {{if not .CommitFormOptions.CanCommitToBranch}}
<div class="ui visible small warning message"> <div class="ui visible small warning message">
{{ctx.Locale.Tr "repo.editor.no_commit_to_branch"}} {{ctx.Locale.Tr "repo.editor.no_commit_to_branch"}}
<ul> <ul>
{{if not .CommitFormBehaviors.UserCanPush}}<li>{{ctx.Locale.Tr "repo.editor.user_no_push_to_branch"}}</li>{{end}} {{if not .CommitFormOptions.UserCanPush}}<li>{{ctx.Locale.Tr "repo.editor.user_no_push_to_branch"}}</li>{{end}}
{{if and .CommitFormBehaviors.RequireSigned (not .CommitFormBehaviors.WillSign)}}<li>{{ctx.Locale.Tr "repo.editor.require_signed_commit"}}</li>{{end}} {{if and .CommitFormOptions.RequireSigned (not .CommitFormOptions.WillSign)}}<li>{{ctx.Locale.Tr "repo.editor.require_signed_commit"}}</li>{{end}}
</ul> </ul>
</div> </div>
{{end}} {{end}}
@ -42,14 +42,14 @@
{{if and (not .Repository.IsEmpty) (not .IsEditingFileOnly)}} {{if and (not .Repository.IsEmpty) (not .IsEditingFileOnly)}}
<div class="field"> <div class="field">
<div class="ui radio checkbox"> <div class="ui radio checkbox">
{{if .CommitFormBehaviors.CanCreatePullRequest}} {{if .CommitFormOptions.CanCreatePullRequest}}
<input type="radio" class="js-quick-pull-choice-option" name="commit_choice" value="commit-to-new-branch" data-button-text="{{ctx.Locale.Tr "repo.editor.propose_file_change"}}" {{if eq .commit_choice "commit-to-new-branch"}}checked{{end}}> <input type="radio" class="js-quick-pull-choice-option" name="commit_choice" value="commit-to-new-branch" data-button-text="{{ctx.Locale.Tr "repo.editor.propose_file_change"}}" {{if eq .commit_choice "commit-to-new-branch"}}checked{{end}}>
{{else}} {{else}}
<input type="radio" class="js-quick-pull-choice-option" name="commit_choice" value="commit-to-new-branch" data-button-text="{{ctx.Locale.Tr "repo.editor.commit_changes"}}" {{if eq .commit_choice "commit-to-new-branch"}}checked{{end}}> <input type="radio" class="js-quick-pull-choice-option" name="commit_choice" value="commit-to-new-branch" data-button-text="{{ctx.Locale.Tr "repo.editor.commit_changes"}}" {{if eq .commit_choice "commit-to-new-branch"}}checked{{end}}>
{{end}} {{end}}
<label> <label>
{{svg "octicon-git-pull-request"}} {{svg "octicon-git-pull-request"}}
{{if .CommitFormBehaviors.CanCreatePullRequest}} {{if .CommitFormOptions.CanCreatePullRequest}}
{{ctx.Locale.Tr "repo.editor.create_new_branch"}} {{ctx.Locale.Tr "repo.editor.create_new_branch"}}
{{else}} {{else}}
{{ctx.Locale.Tr "repo.editor.create_new_branch_np"}} {{ctx.Locale.Tr "repo.editor.create_new_branch_np"}}

View File

@ -0,0 +1,6 @@
{{if .CommitFormOptions.WillSubmitToFork}}
<div class="ui blue message">
{{$repoLinkHTML := HTMLFormat `<a href="%s">%s</a>` .CommitFormOptions.TargetRepo.Link .CommitFormOptions.TargetRepo.FullName}}
{{ctx.Locale.Tr "repo.editor.fork_edit_description" $repoLinkHTML}}
</div>
{{end}}

View File

@ -3,8 +3,9 @@
{{template "repo/header" .}} {{template "repo/header" .}}
<div class="ui container"> <div class="ui container">
{{template "base/alert" .}} {{template "base/alert" .}}
<form class="ui form form-fetch-action" method="post"> <form class="ui form form-fetch-action" method="post" action="{{.CommitFormOptions.TargetFormAction}}">
{{.CsrfTokenHtml}} {{.CsrfTokenHtml}}
{{template "repo/editor/common_top" .}}
{{template "repo/editor/commit_form" .}} {{template "repo/editor/commit_form" .}}
</form> </form>
</div> </div>

View File

@ -3,11 +3,12 @@
{{template "repo/header" .}} {{template "repo/header" .}}
<div class="ui container"> <div class="ui container">
{{template "base/alert" .}} {{template "base/alert" .}}
<form class="ui edit form form-fetch-action" method="post" <form class="ui edit form form-fetch-action" method="post" action="{{.CommitFormOptions.TargetFormAction}}"
data-text-empty-confirm-header="{{ctx.Locale.Tr "repo.editor.commit_empty_file_header"}}" data-text-empty-confirm-header="{{ctx.Locale.Tr "repo.editor.commit_empty_file_header"}}"
data-text-empty-confirm-content="{{ctx.Locale.Tr "repo.editor.commit_empty_file_text"}}" data-text-empty-confirm-content="{{ctx.Locale.Tr "repo.editor.commit_empty_file_text"}}"
> >
{{.CsrfTokenHtml}} {{.CsrfTokenHtml}}
{{template "repo/editor/common_top" .}}
<div class="repo-editor-header"> <div class="repo-editor-header">
{{template "repo/editor/common_breadcrumb" .}} {{template "repo/editor/common_breadcrumb" .}}
</div> </div>

View File

@ -0,0 +1,18 @@
{{template "base/head" .}}
<div role="main" aria-label="{{.Title}}" class="page-content repository">
{{template "repo/header" .}}
<div class="ui container">
{{template "base/alert" .}}
<form class="ui form form-fetch-action" method="post" action="{{.RepoLink}}/_fork/{{.BranchName | PathEscapeSegments}}">
{{.CsrfTokenHtml}}
<div class="tw-text-center">
<div class="tw-my-[40px]">
<h3>{{ctx.Locale.Tr "repo.editor.fork_create"}}</h3>
<p>{{ctx.Locale.Tr "repo.editor.fork_create_description"}}</p>
</div>
<button class="ui primary button">{{ctx.Locale.Tr "repo.fork_repo"}}</button>
</div>
</form>
</div>
</div>
{{template "base/footer" .}}

View File

@ -3,11 +3,12 @@
{{template "repo/header" .}} {{template "repo/header" .}}
<div class="ui container"> <div class="ui container">
{{template "base/alert" .}} {{template "base/alert" .}}
<form class="ui edit form form-fetch-action" method="post" action="{{.RepoLink}}/_diffpatch/{{.BranchName | PathEscapeSegments}}" <form class="ui edit form form-fetch-action" method="post" action="{{.CommitFormOptions.TargetFormAction}}"
data-text-empty-confirm-header="{{ctx.Locale.Tr "repo.editor.commit_empty_file_header"}}" data-text-empty-confirm-header="{{ctx.Locale.Tr "repo.editor.commit_empty_file_header"}}"
data-text-empty-confirm-content="{{ctx.Locale.Tr "repo.editor.commit_empty_file_text"}}" data-text-empty-confirm-content="{{ctx.Locale.Tr "repo.editor.commit_empty_file_text"}}"
> >
{{.CsrfTokenHtml}} {{.CsrfTokenHtml}}
{{template "repo/editor/common_top" .}}
<div class="repo-editor-header"> <div class="repo-editor-header">
<div class="breadcrumb"> <div class="breadcrumb">
{{ctx.Locale.Tr "repo.editor.patching"}} {{ctx.Locale.Tr "repo.editor.patching"}}

View File

@ -3,8 +3,9 @@
{{template "repo/header" .}} {{template "repo/header" .}}
<div class="ui container"> <div class="ui container">
{{template "base/alert" .}} {{template "base/alert" .}}
<form class="ui comment form form-fetch-action" method="post"> <form class="ui comment form form-fetch-action" method="post" action="{{.CommitFormOptions.TargetFormAction}}">
{{.CsrfTokenHtml}} {{.CsrfTokenHtml}}
{{template "repo/editor/common_top" .}}
<div class="repo-editor-header"> <div class="repo-editor-header">
{{template "repo/editor/common_breadcrumb" .}} {{template "repo/editor/common_breadcrumb" .}}
</div> </div>

View File

@ -1,22 +1,22 @@
{{if .EscapeStatus}} {{if .EscapeStatus}}
{{if .EscapeStatus.HasInvisible}} {{if .EscapeStatus.HasInvisible}}
<div class="ui warning message unicode-escape-prompt tw-text-left"> <div class="ui warning message unicode-escape-prompt">
<button class="btn close icon hide-panel" data-panel-closest=".message">{{svg "octicon-x" 16 "close inside"}}</button> <button class="btn close icon hide-panel" data-panel-closest=".message">{{svg "octicon-x" 16 "close inside"}}</button>
<div class="header"> <div class="header">
{{ctx.Locale.Tr "repo.invisible_runes_header"}} {{ctx.Locale.Tr "repo.invisible_runes_header"}}
</div> </div>
<p>{{ctx.Locale.Tr "repo.invisible_runes_description"}}</p> <div>{{ctx.Locale.Tr "repo.invisible_runes_description"}}</div>
{{if .EscapeStatus.HasAmbiguous}} {{if .EscapeStatus.HasAmbiguous}}
<p>{{ctx.Locale.Tr "repo.ambiguous_runes_description"}}</p> <div>{{ctx.Locale.Tr "repo.ambiguous_runes_description"}}</div>
{{end}} {{end}}
</div> </div>
{{else if .EscapeStatus.HasAmbiguous}} {{else if .EscapeStatus.HasAmbiguous}}
<div class="ui warning message unicode-escape-prompt tw-text-left"> <div class="ui warning message unicode-escape-prompt">
<button class="btn close icon hide-panel" data-panel-closest=".message">{{svg "octicon-x" 16 "close inside"}}</button> <button class="btn close icon hide-panel" data-panel-closest=".message">{{svg "octicon-x" 16 "close inside"}}</button>
<div class="header"> <div class="header">
{{ctx.Locale.Tr "repo.ambiguous_runes_header"}} {{ctx.Locale.Tr "repo.ambiguous_runes_header"}}
</div> </div>
<p>{{ctx.Locale.Tr "repo.ambiguous_runes_description"}}</p> <div>{{ctx.Locale.Tr "repo.ambiguous_runes_description"}}</div>
</div> </div>
{{end}} {{end}}
{{end}} {{end}}

View File

@ -41,8 +41,8 @@
<a href="{{.Repository.Link}}/find/{{.RefTypeNameSubURL}}" class="ui compact basic button">{{ctx.Locale.Tr "repo.find_file.go_to_file"}}</a> <a href="{{.Repository.Link}}/find/{{.RefTypeNameSubURL}}" class="ui compact basic button">{{ctx.Locale.Tr "repo.find_file.go_to_file"}}</a>
{{end}} {{end}}
{{if and .CanWriteCode .RefFullName.IsBranch (not .Repository.IsMirror) (not .Repository.IsArchived) (not .IsViewFile)}} {{if and .RefFullName.IsBranch (not .IsViewFile)}}
<button class="ui dropdown basic compact jump button"{{if not .Repository.CanEnableEditor}} disabled{{end}}> <button class="ui dropdown basic compact jump button repo-add-file" {{if not .Repository.CanEnableEditor}}disabled{{end}}>
{{ctx.Locale.Tr "repo.editor.add_file"}} {{ctx.Locale.Tr "repo.editor.add_file"}}
{{svg "octicon-triangle-down" 14 "dropdown icon"}} {{svg "octicon-triangle-down" 14 "dropdown icon"}}
<div class="menu"> <div class="menu">

View File

@ -18,7 +18,7 @@
{{ctx.Locale.Tr "repo.wiki.page_name_desc"}} {{ctx.Locale.Tr "repo.wiki.page_name_desc"}}
</div> </div>
{{$content := .content}} {{$content := .WikiEditContent}}
{{if not .PageIsWikiEdit}} {{if not .PageIsWikiEdit}}
{{$content = ctx.Locale.Tr "repo.wiki.welcome"}} {{$content = ctx.Locale.Tr "repo.wiki.welcome"}}
{{end}} {{end}}

View File

@ -62,36 +62,34 @@
{{end}} {{end}}
<div class="wiki-content-parts"> <div class="wiki-content-parts">
{{if .sidebarTocContent}} {{if .WikiSidebarTocHTML}}
<div class="render-content markup wiki-content-sidebar wiki-content-toc"> <div class="render-content markup wiki-content-sidebar wiki-content-toc">
{{.sidebarTocContent | SafeHTML}} {{.WikiSidebarTocHTML}}
</div> </div>
{{end}} {{end}}
<div class="render-content markup wiki-content-main {{if or .sidebarTocContent .sidebarPresent}}with-sidebar{{end}}"> <div class="render-content markup wiki-content-main {{if or .WikiSidebarTocHTML .WikiSidebarHTML}}with-sidebar{{end}}">
{{template "repo/unicode_escape_prompt" dict "EscapeStatus" .EscapeStatus "root" $}} {{template "repo/unicode_escape_prompt" dict "EscapeStatus" .EscapeStatus}}
{{.content | SafeHTML}} {{.WikiContentHTML}}
</div> </div>
{{if .sidebarPresent}} {{if .WikiSidebarHTML}}
<div class="render-content markup wiki-content-sidebar"> <div class="render-content markup wiki-content-sidebar">
{{if and .CanWriteWiki (not .Repository.IsMirror)}} {{if and .CanWriteWiki (not .Repository.IsMirror)}}
<a class="tw-float-right muted" href="{{.RepoLink}}/wiki/_Sidebar?action=_edit" aria-label="{{ctx.Locale.Tr "repo.wiki.edit_page_button"}}">{{svg "octicon-pencil"}}</a> <a class="tw-float-right muted" href="{{.RepoLink}}/wiki/_Sidebar?action=_edit" aria-label="{{ctx.Locale.Tr "repo.wiki.edit_page_button"}}">{{svg "octicon-pencil"}}</a>
{{end}} {{end}}
{{template "repo/unicode_escape_prompt" dict "EscapeStatus" .sidebarEscapeStatus "root" $}} {{.WikiSidebarHTML}}
{{.sidebarContent | SafeHTML}}
</div> </div>
{{end}} {{end}}
<div class="tw-clear-both"></div> <div class="tw-clear-both"></div>
{{if .footerPresent}} {{if .WikiFooterHTML}}
<div class="render-content markup wiki-content-footer"> <div class="render-content markup wiki-content-footer">
{{if and .CanWriteWiki (not .Repository.IsMirror)}} {{if and .CanWriteWiki (not .Repository.IsMirror)}}
<a class="tw-float-right muted" href="{{.RepoLink}}/wiki/_Footer?action=_edit" aria-label="{{ctx.Locale.Tr "repo.wiki.edit_page_button"}}">{{svg "octicon-pencil"}}</a> <a class="tw-float-right muted" href="{{.RepoLink}}/wiki/_Footer?action=_edit" aria-label="{{ctx.Locale.Tr "repo.wiki.edit_page_button"}}">{{svg "octicon-pencil"}}</a>
{{end}} {{end}}
{{template "repo/unicode_escape_prompt" dict "footerEscapeStatus" .sidebarEscapeStatus "root" $}} {{.WikiFooterHTML}}
{{.footerContent | SafeHTML}}
</div> </div>
{{end}} {{end}}
</div> </div>

View File

@ -7,6 +7,7 @@ import (
"bytes" "bytes"
"fmt" "fmt"
"io" "io"
"maps"
"mime/multipart" "mime/multipart"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
@ -19,292 +20,278 @@ import (
"code.gitea.io/gitea/models/unittest" "code.gitea.io/gitea/models/unittest"
user_model "code.gitea.io/gitea/models/user" user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/git" "code.gitea.io/gitea/modules/git"
"code.gitea.io/gitea/modules/json"
"code.gitea.io/gitea/modules/test" "code.gitea.io/gitea/modules/test"
"code.gitea.io/gitea/modules/translation" "code.gitea.io/gitea/modules/translation"
"code.gitea.io/gitea/modules/util"
"code.gitea.io/gitea/tests" "code.gitea.io/gitea/tests"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
) )
func TestCreateFile(t *testing.T) { func TestEditor(t *testing.T) {
onGiteaRun(t, func(t *testing.T, u *url.URL) { onGiteaRun(t, func(t *testing.T, u *url.URL) {
session := loginUser(t, "user2") sessionUser2 := loginUser(t, "user2")
testCreateFile(t, session, "user2", "repo1", "master", "test.txt", "Content") t.Run("EditFileNotAllowed", testEditFileNotAllowed)
t.Run("DiffPreview", testEditorDiffPreview)
t.Run("CreateFile", testEditorCreateFile)
t.Run("EditFile", func(t *testing.T) {
testEditFile(t, sessionUser2, "user2", "repo1", "master", "README.md", "Hello, World (direct)\n")
testEditFileToNewBranch(t, sessionUser2, "user2", "repo1", "master", "feature/test", "README.md", "Hello, World (commit-to-new-branch)\n")
})
t.Run("PatchFile", testEditorPatchFile)
t.Run("DeleteFile", func(t *testing.T) {
viewLink := "/user2/repo1/src/branch/branch2/README.md"
sessionUser2.MakeRequest(t, NewRequest(t, "GET", viewLink), http.StatusOK)
testEditorActionPostRequest(t, sessionUser2, "/user2/repo1/_delete/branch2/README.md", map[string]string{"commit_choice": "direct"})
sessionUser2.MakeRequest(t, NewRequest(t, "GET", viewLink), http.StatusNotFound)
})
t.Run("ForkToEditFile", func(t *testing.T) {
testForkToEditFile(t, loginUser(t, "user4"), "user4", "user2", "repo1", "master", "README.md")
})
t.Run("WebGitCommitEmail", testEditorWebGitCommitEmail)
t.Run("ProtectedBranch", testEditorProtectedBranch)
}) })
} }
func testEditorCreateFile(t *testing.T) {
session := loginUser(t, "user2")
testCreateFile(t, session, "user2", "repo1", "master", "test.txt", "Content")
testEditorActionPostRequestError(t, session, "/user2/repo1/_new/master/", map[string]string{
"tree_path": "test.txt",
"commit_choice": "direct",
"new_branch_name": "master",
}, `A file named "test.txt" already exists in this repository.`)
testEditorActionPostRequestError(t, session, "/user2/repo1/_new/master/", map[string]string{
"tree_path": "test.txt",
"commit_choice": "commit-to-new-branch",
"new_branch_name": "master",
}, `Branch "master" already exists in this repository.`)
}
func testCreateFile(t *testing.T, session *TestSession, user, repo, branch, filePath, content string) { func testCreateFile(t *testing.T, session *TestSession, user, repo, branch, filePath, content string) {
// Request editor page testEditorActionEdit(t, session, user, repo, "_new", branch, "", map[string]string{
newURL := fmt.Sprintf("/%s/%s/_new/%s/", user, repo, branch)
req := NewRequest(t, "GET", newURL)
resp := session.MakeRequest(t, req, http.StatusOK)
doc := NewHTMLParser(t, resp.Body)
lastCommit := doc.GetInputValueByName("last_commit")
assert.NotEmpty(t, lastCommit)
// Save new file to master branch
req = NewRequestWithValues(t, "POST", newURL, map[string]string{
"_csrf": doc.GetCSRF(),
"last_commit": lastCommit,
"tree_path": filePath, "tree_path": filePath,
"content": content, "content": content,
"commit_choice": "direct", "commit_choice": "direct",
}) })
resp = session.MakeRequest(t, req, http.StatusOK)
assert.NotEmpty(t, test.RedirectURL(resp))
} }
func TestCreateFileOnProtectedBranch(t *testing.T) { func testEditorProtectedBranch(t *testing.T) {
onGiteaRun(t, func(t *testing.T, u *url.URL) { session := loginUser(t, "user2")
session := loginUser(t, "user2") // Change the "master" branch to "protected"
req := NewRequestWithValues(t, "POST", "/user2/repo1/settings/branches/edit", map[string]string{
"_csrf": GetUserCSRFToken(t, session),
"rule_name": "master",
"enable_push": "true",
})
session.MakeRequest(t, req, http.StatusSeeOther)
flashMsg := session.GetCookieFlashMessage()
assert.Equal(t, `Branch protection for rule "master" has been updated.`, flashMsg.SuccessMsg)
csrf := GetUserCSRFToken(t, session) // Try to commit a file to the "master" branch and it should fail
// Change master branch to protected resp := testEditorActionPostRequest(t, session, "/user2/repo1/_new/master/", map[string]string{"tree_path": "test-protected-branch.txt", "commit_choice": "direct"})
req := NewRequestWithValues(t, "POST", "/user2/repo1/settings/branches/edit", map[string]string{ assert.Equal(t, http.StatusBadRequest, resp.Code)
"_csrf": csrf, assert.Equal(t, `Cannot commit to protected branch "master".`, test.ParseJSONError(resp.Body.Bytes()).ErrorMessage)
"rule_name": "master", }
"enable_push": "true",
})
session.MakeRequest(t, req, http.StatusSeeOther)
// Check if master branch has been locked successfully
flashMsg := session.GetCookieFlashMessage()
assert.Equal(t, `Branch protection for rule "master" has been updated.`, flashMsg.SuccessMsg)
// Request editor page func testEditorActionPostRequest(t *testing.T, session *TestSession, requestPath string, params map[string]string) *httptest.ResponseRecorder {
req = NewRequest(t, "GET", "/user2/repo1/_new/master/") req := NewRequest(t, "GET", requestPath)
resp := session.MakeRequest(t, req, http.StatusOK)
htmlDoc := NewHTMLParser(t, resp.Body)
form := map[string]string{
"_csrf": htmlDoc.GetCSRF(),
"last_commit": htmlDoc.GetInputValueByName("last_commit"),
}
maps.Copy(form, params)
req = NewRequestWithValues(t, "POST", requestPath, form)
return session.MakeRequest(t, req, NoExpectedStatus)
}
func testEditorActionPostRequestError(t *testing.T, session *TestSession, requestPath string, params map[string]string, errorMessage string) {
resp := testEditorActionPostRequest(t, session, requestPath, params)
assert.Equal(t, http.StatusBadRequest, resp.Code)
assert.Equal(t, errorMessage, test.ParseJSONError(resp.Body.Bytes()).ErrorMessage)
}
func testEditorActionEdit(t *testing.T, session *TestSession, user, repo, editorAction, branch, filePath string, params map[string]string) *httptest.ResponseRecorder {
params["tree_path"] = util.IfZero(params["tree_path"], filePath)
newBranchName := util.Iif(params["commit_choice"] == "direct", branch, params["new_branch_name"])
resp := testEditorActionPostRequest(t, session, fmt.Sprintf("/%s/%s/%s/%s/%s", user, repo, editorAction, branch, filePath), params)
assert.Equal(t, http.StatusOK, resp.Code)
assert.NotEmpty(t, test.RedirectURL(resp))
req := NewRequest(t, "GET", path.Join(user, repo, "raw/branch", newBranchName, params["tree_path"]))
resp = session.MakeRequest(t, req, http.StatusOK)
assert.Equal(t, params["content"], resp.Body.String())
return resp
}
func testEditFile(t *testing.T, session *TestSession, user, repo, branch, filePath, newContent string) {
testEditorActionEdit(t, session, user, repo, "_edit", branch, filePath, map[string]string{
"content": newContent,
"commit_choice": "direct",
})
}
func testEditFileToNewBranch(t *testing.T, session *TestSession, user, repo, branch, targetBranch, filePath, newContent string) {
testEditorActionEdit(t, session, user, repo, "_edit", branch, filePath, map[string]string{
"content": newContent,
"commit_choice": "commit-to-new-branch",
"new_branch_name": targetBranch,
})
}
func testEditorDiffPreview(t *testing.T) {
session := loginUser(t, "user2")
req := NewRequestWithValues(t, "POST", "/user2/repo1/_preview/master/README.md", map[string]string{
"_csrf": GetUserCSRFToken(t, session),
"content": "Hello, World (Edited)\n",
})
resp := session.MakeRequest(t, req, http.StatusOK)
assert.Contains(t, resp.Body.String(), `<span class="added-code">Hello, World (Edited)</span>`)
}
func testEditorPatchFile(t *testing.T) {
session := loginUser(t, "user2")
pathContentCommon := `diff --git a/patch-file-1.txt b/patch-file-1.txt
new file mode 100644
index 0000000000..aaaaaaaaaa
--- /dev/null
+++ b/patch-file-1.txt
@@ -0,0 +1 @@
+`
testEditorActionPostRequest(t, session, "/user2/repo1/_diffpatch/master/", map[string]string{
"content": pathContentCommon + "patched content\n",
"commit_choice": "commit-to-new-branch",
"new_branch_name": "patched-branch",
})
resp := MakeRequest(t, NewRequest(t, "GET", "/user2/repo1/raw/branch/patched-branch/patch-file-1.txt"), http.StatusOK)
assert.Equal(t, "patched content\n", resp.Body.String())
// patch again, it should fail
resp = testEditorActionPostRequest(t, session, "/user2/repo1/_diffpatch/patched-branch/", map[string]string{
"content": pathContentCommon + "another patched content\n",
"commit_choice": "commit-to-new-branch",
"new_branch_name": "patched-branch-1",
})
assert.Equal(t, "Unable to apply patch", test.ParseJSONError(resp.Body.Bytes()).ErrorMessage)
}
func testEditorWebGitCommitEmail(t *testing.T) {
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
require.True(t, user.KeepEmailPrivate)
repo1 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
gitRepo, _ := git.OpenRepository(git.DefaultContext, repo1.RepoPath())
defer gitRepo.Close()
getLastCommit := func(t *testing.T) *git.Commit {
c, err := gitRepo.GetBranchCommit("master")
require.NoError(t, err)
return c
}
session := loginUser(t, user.Name)
makeReq := func(t *testing.T, link string, params map[string]string, expectedUserName, expectedEmail string) *httptest.ResponseRecorder {
lastCommit := getLastCommit(t)
params["_csrf"] = GetUserCSRFToken(t, session)
params["last_commit"] = lastCommit.ID.String()
params["commit_choice"] = "direct"
req := NewRequestWithValues(t, "POST", link, params)
resp := session.MakeRequest(t, req, NoExpectedStatus)
newCommit := getLastCommit(t)
if expectedUserName == "" {
require.Equal(t, lastCommit.ID.String(), newCommit.ID.String())
respErr := test.ParseJSONError(resp.Body.Bytes())
assert.Equal(t, translation.NewLocale("en-US").TrString("repo.editor.invalid_commit_email"), respErr.ErrorMessage)
} else {
require.NotEqual(t, lastCommit.ID.String(), newCommit.ID.String())
assert.Equal(t, expectedUserName, newCommit.Author.Name)
assert.Equal(t, expectedEmail, newCommit.Author.Email)
assert.Equal(t, expectedUserName, newCommit.Committer.Name)
assert.Equal(t, expectedEmail, newCommit.Committer.Email)
}
return resp
}
uploadFile := func(t *testing.T, name, content string) string {
body := &bytes.Buffer{}
uploadForm := multipart.NewWriter(body)
file, _ := uploadForm.CreateFormFile("file", name)
_, _ = io.Copy(file, strings.NewReader(content))
_ = uploadForm.WriteField("_csrf", GetUserCSRFToken(t, session))
_ = uploadForm.Close()
req := NewRequestWithBody(t, "POST", "/user2/repo1/upload-file", body)
req.Header.Add("Content-Type", uploadForm.FormDataContentType())
resp := session.MakeRequest(t, req, http.StatusOK) resp := session.MakeRequest(t, req, http.StatusOK)
doc := NewHTMLParser(t, resp.Body) respMap := map[string]string{}
lastCommit := doc.GetInputValueByName("last_commit") DecodeJSON(t, resp, &respMap)
assert.NotEmpty(t, lastCommit) return respMap["uuid"]
}
// Save new file to master branch t.Run("EmailInactive", func(t *testing.T) {
req = NewRequestWithValues(t, "POST", "/user2/repo1/_new/master/", map[string]string{ defer tests.PrintCurrentTest(t)()
"_csrf": doc.GetCSRF(), email := unittest.AssertExistsAndLoadBean(t, &user_model.EmailAddress{ID: 35, UID: user.ID})
"last_commit": lastCommit, require.False(t, email.IsActivated)
"tree_path": "test.txt", makeReq(t, "/user2/repo1/_edit/master/README.md", map[string]string{
"content": "Content", "tree_path": "README.md",
"commit_choice": "direct", "content": "test content",
}) "commit_email": email.Email,
}, "", "")
resp = session.MakeRequest(t, req, http.StatusBadRequest)
respErr := test.ParseJSONError(resp.Body.Bytes())
assert.Equal(t, `Cannot commit to protected branch "master".`, respErr.ErrorMessage)
// remove the protected branch
csrf = GetUserCSRFToken(t, session)
// Change master branch to protected
req = NewRequestWithValues(t, "POST", "/user2/repo1/settings/branches/1/delete", map[string]string{
"_csrf": csrf,
})
resp = session.MakeRequest(t, req, http.StatusOK)
res := make(map[string]string)
assert.NoError(t, json.NewDecoder(resp.Body).Decode(&res))
assert.Equal(t, "/user2/repo1/settings/branches", res["redirect"])
// Check if master branch has been locked successfully
flashMsg = session.GetCookieFlashMessage()
assert.Equal(t, `Removing branch protection rule "1" failed.`, flashMsg.ErrorMsg)
}) })
}
func testEditFile(t *testing.T, session *TestSession, user, repo, branch, filePath, newContent string) *httptest.ResponseRecorder { t.Run("EmailInvalid", func(t *testing.T) {
// Get to the 'edit this file' page defer tests.PrintCurrentTest(t)()
req := NewRequest(t, "GET", path.Join(user, repo, "_edit", branch, filePath)) email := unittest.AssertExistsAndLoadBean(t, &user_model.EmailAddress{ID: 1, IsActivated: true})
resp := session.MakeRequest(t, req, http.StatusOK) require.NotEqual(t, email.UID, user.ID)
makeReq(t, "/user2/repo1/_edit/master/README.md", map[string]string{
htmlDoc := NewHTMLParser(t, resp.Body) "tree_path": "README.md",
lastCommit := htmlDoc.GetInputValueByName("last_commit") "content": "test content",
assert.NotEmpty(t, lastCommit) "commit_email": email.Email,
}, "", "")
// Submit the edits
req = NewRequestWithValues(t, "POST", path.Join(user, repo, "_edit", branch, filePath),
map[string]string{
"_csrf": htmlDoc.GetCSRF(),
"last_commit": lastCommit,
"tree_path": filePath,
"content": newContent,
"commit_choice": "direct",
},
)
resp = session.MakeRequest(t, req, http.StatusOK)
assert.NotEmpty(t, test.RedirectURL(resp))
// Verify the change
req = NewRequest(t, "GET", path.Join(user, repo, "raw/branch", branch, filePath))
resp = session.MakeRequest(t, req, http.StatusOK)
assert.Equal(t, newContent, resp.Body.String())
return resp
}
func testEditFileToNewBranch(t *testing.T, session *TestSession, user, repo, branch, targetBranch, filePath, newContent string) *httptest.ResponseRecorder {
// Get to the 'edit this file' page
req := NewRequest(t, "GET", path.Join(user, repo, "_edit", branch, filePath))
resp := session.MakeRequest(t, req, http.StatusOK)
htmlDoc := NewHTMLParser(t, resp.Body)
lastCommit := htmlDoc.GetInputValueByName("last_commit")
assert.NotEmpty(t, lastCommit)
// Submit the edits
req = NewRequestWithValues(t, "POST", path.Join(user, repo, "_edit", branch, filePath),
map[string]string{
"_csrf": htmlDoc.GetCSRF(),
"last_commit": lastCommit,
"tree_path": filePath,
"content": newContent,
"commit_choice": "commit-to-new-branch",
"new_branch_name": targetBranch,
},
)
resp = session.MakeRequest(t, req, http.StatusOK)
assert.NotEmpty(t, test.RedirectURL(resp))
// Verify the change
req = NewRequest(t, "GET", path.Join(user, repo, "raw/branch", targetBranch, filePath))
resp = session.MakeRequest(t, req, http.StatusOK)
assert.Equal(t, newContent, resp.Body.String())
return resp
}
func TestEditFile(t *testing.T) {
onGiteaRun(t, func(t *testing.T, u *url.URL) {
session := loginUser(t, "user2")
testEditFile(t, session, "user2", "repo1", "master", "README.md", "Hello, World (Edited)\n")
}) })
}
func TestEditFileToNewBranch(t *testing.T) { testWebGit := func(t *testing.T, linkForKeepPrivate string, paramsForKeepPrivate map[string]string, linkForChosenEmail string, paramsForChosenEmail map[string]string) (resp1, resp2 *httptest.ResponseRecorder) {
onGiteaRun(t, func(t *testing.T, u *url.URL) { t.Run("DefaultEmailKeepPrivate", func(t *testing.T) {
session := loginUser(t, "user2")
testEditFileToNewBranch(t, session, "user2", "repo1", "master", "feature/test", "README.md", "Hello, World (Edited)\n")
})
}
func TestWebGitCommitEmail(t *testing.T) {
onGiteaRun(t, func(t *testing.T, _ *url.URL) {
user := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: 2})
require.True(t, user.KeepEmailPrivate)
repo1 := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1})
gitRepo, _ := git.OpenRepository(git.DefaultContext, repo1.RepoPath())
defer gitRepo.Close()
getLastCommit := func(t *testing.T) *git.Commit {
c, err := gitRepo.GetBranchCommit("master")
require.NoError(t, err)
return c
}
session := loginUser(t, user.Name)
makeReq := func(t *testing.T, link string, params map[string]string, expectedUserName, expectedEmail string) *httptest.ResponseRecorder {
lastCommit := getLastCommit(t)
params["_csrf"] = GetUserCSRFToken(t, session)
params["last_commit"] = lastCommit.ID.String()
params["commit_choice"] = "direct"
req := NewRequestWithValues(t, "POST", link, params)
resp := session.MakeRequest(t, req, NoExpectedStatus)
newCommit := getLastCommit(t)
if expectedUserName == "" {
require.Equal(t, lastCommit.ID.String(), newCommit.ID.String())
respErr := test.ParseJSONError(resp.Body.Bytes())
assert.Equal(t, translation.NewLocale("en-US").TrString("repo.editor.invalid_commit_email"), respErr.ErrorMessage)
} else {
require.NotEqual(t, lastCommit.ID.String(), newCommit.ID.String())
assert.Equal(t, expectedUserName, newCommit.Author.Name)
assert.Equal(t, expectedEmail, newCommit.Author.Email)
assert.Equal(t, expectedUserName, newCommit.Committer.Name)
assert.Equal(t, expectedEmail, newCommit.Committer.Email)
}
return resp
}
uploadFile := func(t *testing.T, name, content string) string {
body := &bytes.Buffer{}
uploadForm := multipart.NewWriter(body)
file, _ := uploadForm.CreateFormFile("file", name)
_, _ = io.Copy(file, strings.NewReader(content))
_ = uploadForm.WriteField("_csrf", GetUserCSRFToken(t, session))
_ = uploadForm.Close()
req := NewRequestWithBody(t, "POST", "/user2/repo1/upload-file", body)
req.Header.Add("Content-Type", uploadForm.FormDataContentType())
resp := session.MakeRequest(t, req, http.StatusOK)
respMap := map[string]string{}
DecodeJSON(t, resp, &respMap)
return respMap["uuid"]
}
t.Run("EmailInactive", func(t *testing.T) {
defer tests.PrintCurrentTest(t)() defer tests.PrintCurrentTest(t)()
email := unittest.AssertExistsAndLoadBean(t, &user_model.EmailAddress{ID: 35, UID: user.ID}) paramsForKeepPrivate["commit_email"] = ""
require.False(t, email.IsActivated) resp1 = makeReq(t, linkForKeepPrivate, paramsForKeepPrivate, "User Two", "user2@noreply.example.org")
makeReq(t, "/user2/repo1/_edit/master/README.md", map[string]string{
"tree_path": "README.md",
"content": "test content",
"commit_email": email.Email,
}, "", "")
}) })
t.Run("ChooseEmail", func(t *testing.T) {
t.Run("EmailInvalid", func(t *testing.T) {
defer tests.PrintCurrentTest(t)() defer tests.PrintCurrentTest(t)()
email := unittest.AssertExistsAndLoadBean(t, &user_model.EmailAddress{ID: 1, IsActivated: true}) paramsForChosenEmail["commit_email"] = "user2@example.com"
require.NotEqual(t, email.UID, user.ID) resp2 = makeReq(t, linkForChosenEmail, paramsForChosenEmail, "User Two", "user2@example.com")
makeReq(t, "/user2/repo1/_edit/master/README.md", map[string]string{
"tree_path": "README.md",
"content": "test content",
"commit_email": email.Email,
}, "", "")
}) })
return resp1, resp2
}
testWebGit := func(t *testing.T, linkForKeepPrivate string, paramsForKeepPrivate map[string]string, linkForChosenEmail string, paramsForChosenEmail map[string]string) (resp1, resp2 *httptest.ResponseRecorder) { t.Run("Edit", func(t *testing.T) {
t.Run("DefaultEmailKeepPrivate", func(t *testing.T) { testWebGit(t,
defer tests.PrintCurrentTest(t)() "/user2/repo1/_edit/master/README.md", map[string]string{"tree_path": "README.md", "content": "for keep private"},
paramsForKeepPrivate["commit_email"] = "" "/user2/repo1/_edit/master/README.md", map[string]string{"tree_path": "README.md", "content": "for chosen email"},
resp1 = makeReq(t, linkForKeepPrivate, paramsForKeepPrivate, "User Two", "user2@noreply.example.org") )
}) })
t.Run("ChooseEmail", func(t *testing.T) {
defer tests.PrintCurrentTest(t)()
paramsForChosenEmail["commit_email"] = "user2@example.com"
resp2 = makeReq(t, linkForChosenEmail, paramsForChosenEmail, "User Two", "user2@example.com")
})
return resp1, resp2
}
t.Run("Edit", func(t *testing.T) { t.Run("UploadDelete", func(t *testing.T) {
testWebGit(t, file1UUID := uploadFile(t, "file1", "File 1")
"/user2/repo1/_edit/master/README.md", map[string]string{"tree_path": "README.md", "content": "for keep private"}, file2UUID := uploadFile(t, "file2", "File 2")
"/user2/repo1/_edit/master/README.md", map[string]string{"tree_path": "README.md", "content": "for chosen email"}, testWebGit(t,
) "/user2/repo1/_upload/master", map[string]string{"files": file1UUID},
}) "/user2/repo1/_upload/master", map[string]string{"files": file2UUID},
)
testWebGit(t,
"/user2/repo1/_delete/master/file1", map[string]string{},
"/user2/repo1/_delete/master/file2", map[string]string{},
)
})
t.Run("UploadDelete", func(t *testing.T) { t.Run("ApplyPatchCherryPick", func(t *testing.T) {
file1UUID := uploadFile(t, "file1", "File 1") testWebGit(t,
file2UUID := uploadFile(t, "file2", "File 2") "/user2/repo1/_diffpatch/master", map[string]string{
testWebGit(t, "tree_path": "__dummy__",
"/user2/repo1/_upload/master", map[string]string{"files": file1UUID}, "content": `diff --git a/patch-file-1.txt b/patch-file-1.txt
"/user2/repo1/_upload/master", map[string]string{"files": file2UUID},
)
testWebGit(t,
"/user2/repo1/_delete/master/file1", map[string]string{},
"/user2/repo1/_delete/master/file2", map[string]string{},
)
})
t.Run("ApplyPatchCherryPick", func(t *testing.T) {
testWebGit(t,
"/user2/repo1/_diffpatch/master", map[string]string{
"tree_path": "__dummy__",
"content": `diff --git a/patch-file-1.txt b/patch-file-1.txt
new file mode 100644 new file mode 100644
index 0000000000..aaaaaaaaaa index 0000000000..aaaaaaaaaa
--- /dev/null --- /dev/null
@ -312,10 +299,10 @@ index 0000000000..aaaaaaaaaa
@@ -0,0 +1 @@ @@ -0,0 +1 @@
+File 1 +File 1
`, `,
}, },
"/user2/repo1/_diffpatch/master", map[string]string{ "/user2/repo1/_diffpatch/master", map[string]string{
"tree_path": "__dummy__", "tree_path": "__dummy__",
"content": `diff --git a/patch-file-2.txt b/patch-file-2.txt "content": `diff --git a/patch-file-2.txt b/patch-file-2.txt
new file mode 100644 new file mode 100644
index 0000000000..bbbbbbbbbb index 0000000000..bbbbbbbbbb
--- /dev/null --- /dev/null
@ -323,20 +310,146 @@ index 0000000000..bbbbbbbbbb
@@ -0,0 +1 @@ @@ -0,0 +1 @@
+File 2 +File 2
`, `,
}, },
) )
commit1, err := gitRepo.GetCommitByPath("patch-file-1.txt") commit1, err := gitRepo.GetCommitByPath("patch-file-1.txt")
require.NoError(t, err) require.NoError(t, err)
commit2, err := gitRepo.GetCommitByPath("patch-file-2.txt") commit2, err := gitRepo.GetCommitByPath("patch-file-2.txt")
require.NoError(t, err) require.NoError(t, err)
resp1, _ := testWebGit(t, resp1, _ := testWebGit(t,
"/user2/repo1/_cherrypick/"+commit1.ID.String()+"/master", map[string]string{"revert": "true"}, "/user2/repo1/_cherrypick/"+commit1.ID.String()+"/master", map[string]string{"revert": "true"},
"/user2/repo1/_cherrypick/"+commit2.ID.String()+"/master", map[string]string{"revert": "true"}, "/user2/repo1/_cherrypick/"+commit2.ID.String()+"/master", map[string]string{"revert": "true"},
) )
// By the way, test the "cherrypick" page: a successful revert redirects to the main branch // By the way, test the "cherrypick" page: a successful revert redirects to the main branch
assert.Equal(t, "/user2/repo1/src/branch/master", test.RedirectURL(resp1)) assert.Equal(t, "/user2/repo1/src/branch/master", test.RedirectURL(resp1))
})
}) })
} }
func testForkToEditFile(t *testing.T, session *TestSession, user, owner, repo, branch, filePath string) {
forkToEdit := func(t *testing.T, session *TestSession, owner, repo, operation, branch, filePath string) {
// visit the base repo, see the "Add File" button
req := NewRequest(t, "GET", path.Join(owner, repo))
resp := session.MakeRequest(t, req, http.StatusOK)
htmlDoc := NewHTMLParser(t, resp.Body)
AssertHTMLElement(t, htmlDoc, ".repo-add-file", 1)
// attempt to edit a file, see the guideline page
req = NewRequest(t, "GET", path.Join(owner, repo, operation, branch, filePath))
resp = session.MakeRequest(t, req, http.StatusOK)
assert.Contains(t, resp.Body.String(), "Fork Repository to Propose Changes")
// fork the repository
req = NewRequestWithValues(t, "POST", path.Join(owner, repo, "_fork", branch), map[string]string{"_csrf": GetUserCSRFToken(t, session)})
resp = session.MakeRequest(t, req, http.StatusOK)
assert.JSONEq(t, `{"redirect":""}`, resp.Body.String())
}
t.Run("ForkButArchived", func(t *testing.T) {
// Fork repository because we can't edit it
forkToEdit(t, session, owner, repo, "_edit", branch, filePath)
// Archive the repository
req := NewRequestWithValues(t, "POST", path.Join(user, repo, "settings"),
map[string]string{
"_csrf": GetUserCSRFToken(t, session),
"repo_name": repo,
"action": "archive",
},
)
session.MakeRequest(t, req, http.StatusSeeOther)
// Check editing archived repository is disabled
req = NewRequest(t, "GET", path.Join(owner, repo, "_edit", branch, filePath)).SetHeader("Accept", "text/html")
resp := session.MakeRequest(t, req, http.StatusNotFound)
assert.Contains(t, resp.Body.String(), "You have forked this repository but your fork is not editable.")
// Unfork the repository
req = NewRequestWithValues(t, "POST", path.Join(user, repo, "settings"),
map[string]string{
"_csrf": GetUserCSRFToken(t, session),
"repo_name": repo,
"action": "convert_fork",
},
)
session.MakeRequest(t, req, http.StatusSeeOther)
})
// Fork repository again, and check the existence of the forked repo with unique name
forkToEdit(t, session, owner, repo, "_edit", branch, filePath)
session.MakeRequest(t, NewRequestf(t, "GET", "/%s/%s-1", user, repo), http.StatusOK)
t.Run("CheckBaseRepoForm", func(t *testing.T) {
// the base repo's edit form should have the correct action and upload links (pointing to the forked repo)
req := NewRequest(t, "GET", path.Join(owner, repo, "_upload", branch, filePath)+"?foo=bar")
resp := session.MakeRequest(t, req, http.StatusOK)
htmlDoc := NewHTMLParser(t, resp.Body)
uploadForm := htmlDoc.doc.Find(".form-fetch-action")
formAction := uploadForm.AttrOr("action", "")
assert.Equal(t, fmt.Sprintf("/%s/%s-1/_upload/%s/%s?from_base_branch=%s&foo=bar", user, repo, branch, filePath, branch), formAction)
uploadLink := uploadForm.Find(".dropzone").AttrOr("data-link-url", "")
assert.Equal(t, fmt.Sprintf("/%s/%s-1/upload-file", user, repo), uploadLink)
newBranchName := uploadForm.Find("input[name=new_branch_name]").AttrOr("value", "")
assert.Equal(t, user+"-patch-1", newBranchName)
commitChoice := uploadForm.Find("input[name=commit_choice][checked]").AttrOr("value", "")
assert.Equal(t, "commit-to-new-branch", commitChoice)
lastCommit := uploadForm.Find("input[name=last_commit]").AttrOr("value", "")
assert.NotEmpty(t, lastCommit)
})
t.Run("ViewBaseEditFormAndCommitToFork", func(t *testing.T) {
req := NewRequest(t, "GET", path.Join(owner, repo, "_edit", branch, filePath))
resp := session.MakeRequest(t, req, http.StatusOK)
htmlDoc := NewHTMLParser(t, resp.Body)
editRequestForm := map[string]string{
"_csrf": GetUserCSRFToken(t, session),
"last_commit": htmlDoc.GetInputValueByName("last_commit"),
"tree_path": filePath,
"content": "new content in fork",
"commit_choice": "commit-to-new-branch",
}
// change a file in the forked repo with existing branch name (should fail)
editRequestForm["new_branch_name"] = "master"
req = NewRequestWithValues(t, "POST", fmt.Sprintf("/%s/%s-1/_edit/%s/%s?from_base_branch=%s", user, repo, branch, filePath, branch), editRequestForm)
resp = session.MakeRequest(t, req, http.StatusBadRequest)
respJSON := test.ParseJSONError(resp.Body.Bytes())
assert.Equal(t, `Branch "master" already exists in your fork, please choose a new branch name.`, respJSON.ErrorMessage)
// change a file in the forked repo (should succeed)
newBranchName := htmlDoc.GetInputValueByName("new_branch_name")
editRequestForm["new_branch_name"] = newBranchName
req = NewRequestWithValues(t, "POST", fmt.Sprintf("/%s/%s-1/_edit/%s/%s?from_base_branch=%s", user, repo, branch, filePath, branch), editRequestForm)
resp = session.MakeRequest(t, req, http.StatusOK)
assert.Equal(t, fmt.Sprintf("/%s/%s/compare/%s...%s/%s-1:%s", owner, repo, branch, user, repo, newBranchName), test.RedirectURL(resp))
// check the file in the fork's branch is changed
req = NewRequest(t, "GET", fmt.Sprintf("/%s/%s-1/src/branch/%s/%s", user, repo, newBranchName, filePath))
resp = session.MakeRequest(t, req, http.StatusOK)
assert.Contains(t, resp.Body.String(), "new content in fork")
})
}
func testEditFileNotAllowed(t *testing.T) {
sessionUser1 := loginUser(t, "user1") // admin, all access
sessionUser4 := loginUser(t, "user4")
// "_cherrypick" has a different route pattern, so skip its test
operations := []string{"_new", "_edit", "_delete", "_upload", "_diffpatch"}
for _, operation := range operations {
t.Run(operation, func(t *testing.T) {
// Branch does not exist
targetLink := path.Join("user2", "repo1", operation, "missing", "README.md")
sessionUser1.MakeRequest(t, NewRequest(t, "GET", targetLink), http.StatusNotFound)
// Private repository
targetLink = path.Join("user2", "repo2", operation, "master", "Home.md")
sessionUser1.MakeRequest(t, NewRequest(t, "GET", targetLink), http.StatusOK)
sessionUser4.MakeRequest(t, NewRequest(t, "GET", targetLink), http.StatusNotFound)
// Empty repository
targetLink = path.Join("org41", "repo61", operation, "master", "README.md")
sessionUser1.MakeRequest(t, NewRequest(t, "GET", targetLink), http.StatusNotFound)
})
}
}

View File

@ -42,7 +42,7 @@ func (doc *HTMLDoc) GetCSRF() string {
return doc.GetInputValueByName("_csrf") return doc.GetInputValueByName("_csrf")
} }
// AssertHTMLElement check if element by selector exists or does not exist depending on checkExists // AssertHTMLElement check if the element by selector exists or does not exist depending on checkExists
func AssertHTMLElement[T int | bool](t testing.TB, doc *HTMLDoc, selector string, checkExists T) { func AssertHTMLElement[T int | bool](t testing.TB, doc *HTMLDoc, selector string, checkExists T) {
sel := doc.doc.Find(selector) sel := doc.doc.Find(selector)
switch v := any(checkExists).(type) { switch v := any(checkExists).(type) {

View File

@ -129,7 +129,7 @@ func TestPullCreate_EmptyChangesWithDifferentCommits(t *testing.T) {
session := loginUser(t, "user1") session := loginUser(t, "user1")
testRepoFork(t, session, "user2", "repo1", "user1", "repo1", "") testRepoFork(t, session, "user2", "repo1", "user1", "repo1", "")
testEditFileToNewBranch(t, session, "user1", "repo1", "master", "status1", "README.md", "status1") testEditFileToNewBranch(t, session, "user1", "repo1", "master", "status1", "README.md", "status1")
testEditFileToNewBranch(t, session, "user1", "repo1", "status1", "status1", "README.md", "# repo1\n\nDescription for repo1") testEditFile(t, session, "user1", "repo1", "status1", "README.md", "# repo1\n\nDescription for repo1")
url := path.Join("user1", "repo1", "compare", "master...status1") url := path.Join("user1", "repo1", "compare", "master...status1")
req := NewRequestWithValues(t, "POST", url, req := NewRequestWithValues(t, "POST", url,

View File

@ -134,7 +134,9 @@
margin-bottom: 16px; margin-bottom: 16px;
} }
/* override p:last-child from base.css */ /* override p:last-child from base.css.
Fomantic assumes that <p>/<hX> elements only have margins between elements, but not for the first's top or last's bottom.
In markup content, we always use bottom margin for all elements */
.markup p:last-child { .markup p:last-child {
margin-bottom: 16px; margin-bottom: 16px;
} }

View File

@ -101,17 +101,22 @@
} }
} }
#navbar .ui.dropdown.active .navbar-profile-admin {
background: var(--color-nav-hover-bg);
border-color: var(--color-nav-hover-bg);
}
#navbar .ui.dropdown .navbar-profile-admin { #navbar .ui.dropdown .navbar-profile-admin {
display: block; display: block;
position: absolute; position: absolute;
font-size: 9px; color: var(--color-text);
font-weight: var(--font-weight-bold); background: var(--color-nav-bg);
color: var(--color-nav-bg); border: 1px solid var(--color-nav-bg);
background: var(--color-primary); padding: 2px;
padding: 2px 3px;
border-radius: 10px; border-radius: 10px;
top: -1px; top: -0.5px;
left: 18px; left: 28px;
line-height: 0;
} }
#navbar a.item:hover .notification_count, #navbar a.item:hover .notification_count,

View File

@ -1834,6 +1834,7 @@ tbody.commit-list {
border-radius: 0; border-radius: 0;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 0.5em;
} }
/* fomantic's last-child selector does not work with hidden last child */ /* fomantic's last-child selector does not work with hidden last child */

View File

@ -39,10 +39,6 @@
min-width: 150px; min-width: 150px;
} }
.repository.wiki .wiki-content-sidebar .ui.message.unicode-escape-prompt p {
display: none;
}
.repository.wiki .wiki-content-footer { .repository.wiki .wiki-content-footer {
margin-top: 1em; margin-top: 1em;
} }

View File

@ -1,7 +1,7 @@
<script lang="ts" setup> <script lang="ts" setup>
// TODO: Switch to upstream after https://github.com/razorness/vue3-calendar-heatmap/pull/34 is merged // TODO: Switch to upstream after https://github.com/razorness/vue3-calendar-heatmap/pull/34 is merged
import {CalendarHeatmap} from '@silverwind/vue3-calendar-heatmap'; import {CalendarHeatmap} from '@silverwind/vue3-calendar-heatmap';
import {onMounted, ref} from 'vue'; import {onMounted, shallowRef} from 'vue';
import type {Value as HeatmapValue, Locale as HeatmapLocale} from '@silverwind/vue3-calendar-heatmap'; import type {Value as HeatmapValue, Locale as HeatmapLocale} from '@silverwind/vue3-calendar-heatmap';
defineProps<{ defineProps<{
@ -24,7 +24,7 @@ const colorRange = [
'var(--color-primary-dark-4)', 'var(--color-primary-dark-4)',
]; ];
const endDate = ref(new Date()); const endDate = shallowRef(new Date());
onMounted(() => { onMounted(() => {
// work around issue with first legend color being rendered twice and legend cut off // work around issue with first legend color being rendered twice and legend cut off

View File

@ -2,16 +2,16 @@
import {SvgIcon} from '../svg.ts'; import {SvgIcon} from '../svg.ts';
import {GET} from '../modules/fetch.ts'; import {GET} from '../modules/fetch.ts';
import {getIssueColor, getIssueIcon} from '../features/issue.ts'; import {getIssueColor, getIssueIcon} from '../features/issue.ts';
import {computed, onMounted, ref} from 'vue'; import {computed, onMounted, shallowRef, useTemplateRef} from 'vue';
import type {IssuePathInfo} from '../types.ts'; import type {IssuePathInfo} from '../types.ts';
const {appSubUrl, i18n} = window.config; const {appSubUrl, i18n} = window.config;
const loading = ref(false); const loading = shallowRef(false);
const issue = ref(null); const issue = shallowRef(null);
const renderedLabels = ref(''); const renderedLabels = shallowRef('');
const i18nErrorOccurred = i18n.error_occurred; const i18nErrorOccurred = i18n.error_occurred;
const i18nErrorMessage = ref(null); const i18nErrorMessage = shallowRef(null);
const createdAt = computed(() => new Date(issue.value.created_at).toLocaleDateString(undefined, {year: 'numeric', month: 'short', day: 'numeric'})); const createdAt = computed(() => new Date(issue.value.created_at).toLocaleDateString(undefined, {year: 'numeric', month: 'short', day: 'numeric'}));
const body = computed(() => { const body = computed(() => {
@ -22,7 +22,7 @@ const body = computed(() => {
return body; return body;
}); });
const root = ref<HTMLElement | null>(null); const root = useTemplateRef('root');
onMounted(() => { onMounted(() => {
root.value.addEventListener('ce-load-context-popup', (e: CustomEventInit<IssuePathInfo>) => { root.value.addEventListener('ce-load-context-popup', (e: CustomEventInit<IssuePathInfo>) => {

View File

@ -1,6 +1,6 @@
<script lang="ts" setup> <script lang="ts" setup>
import {SvgIcon, type SvgName} from '../svg.ts'; import {SvgIcon, type SvgName} from '../svg.ts';
import {ref} from 'vue'; import {shallowRef} from 'vue';
import {type DiffStatus, type DiffTreeEntry, diffTreeStore} from '../modules/diff-file.ts'; import {type DiffStatus, type DiffTreeEntry, diffTreeStore} from '../modules/diff-file.ts';
const props = defineProps<{ const props = defineProps<{
@ -8,7 +8,7 @@ const props = defineProps<{
}>(); }>();
const store = diffTreeStore(); const store = diffTreeStore();
const collapsed = ref(props.item.IsViewed); const collapsed = shallowRef(props.item.IsViewed);
function getIconForDiffStatus(pType: DiffStatus) { function getIconForDiffStatus(pType: DiffStatus) {
const diffTypes: Record<DiffStatus, { name: SvgName, classes: Array<string> }> = { const diffTypes: Record<DiffStatus, { name: SvgName, classes: Array<string> }> = {

View File

@ -1,19 +1,19 @@
<script lang="ts" setup> <script lang="ts" setup>
import {computed, onMounted, onUnmounted, ref, watch} from 'vue'; import {computed, onMounted, onUnmounted, shallowRef, watch} from 'vue';
import {SvgIcon} from '../svg.ts'; import {SvgIcon} from '../svg.ts';
import {toggleElem} from '../utils/dom.ts'; import {toggleElem} from '../utils/dom.ts';
const {csrfToken, pageData} = window.config; const {csrfToken, pageData} = window.config;
const mergeForm = ref(pageData.pullRequestMergeForm); const mergeForm = pageData.pullRequestMergeForm;
const mergeTitleFieldValue = ref(''); const mergeTitleFieldValue = shallowRef('');
const mergeMessageFieldValue = ref(''); const mergeMessageFieldValue = shallowRef('');
const deleteBranchAfterMerge = ref(false); const deleteBranchAfterMerge = shallowRef(false);
const autoMergeWhenSucceed = ref(false); const autoMergeWhenSucceed = shallowRef(false);
const mergeStyle = ref(''); const mergeStyle = shallowRef('');
const mergeStyleDetail = ref({ const mergeStyleDetail = shallowRef({
hideMergeMessageTexts: false, hideMergeMessageTexts: false,
textDoMerge: '', textDoMerge: '',
mergeTitleFieldText: '', mergeTitleFieldText: '',
@ -21,33 +21,33 @@ const mergeStyleDetail = ref({
hideAutoMerge: false, hideAutoMerge: false,
}); });
const mergeStyleAllowedCount = ref(0); const mergeStyleAllowedCount = shallowRef(0);
const showMergeStyleMenu = ref(false); const showMergeStyleMenu = shallowRef(false);
const showActionForm = ref(false); const showActionForm = shallowRef(false);
const mergeButtonStyleClass = computed(() => { const mergeButtonStyleClass = computed(() => {
if (mergeForm.value.allOverridableChecksOk) return 'primary'; if (mergeForm.allOverridableChecksOk) return 'primary';
return autoMergeWhenSucceed.value ? 'primary' : 'red'; return autoMergeWhenSucceed.value ? 'primary' : 'red';
}); });
const forceMerge = computed(() => { const forceMerge = computed(() => {
return mergeForm.value.canMergeNow && !mergeForm.value.allOverridableChecksOk; return mergeForm.canMergeNow && !mergeForm.allOverridableChecksOk;
}); });
watch(mergeStyle, (val) => { watch(mergeStyle, (val) => {
mergeStyleDetail.value = mergeForm.value.mergeStyles.find((e: any) => e.name === val); mergeStyleDetail.value = mergeForm.mergeStyles.find((e: any) => e.name === val);
for (const elem of document.querySelectorAll('[data-pull-merge-style]')) { for (const elem of document.querySelectorAll('[data-pull-merge-style]')) {
toggleElem(elem, elem.getAttribute('data-pull-merge-style') === val); toggleElem(elem, elem.getAttribute('data-pull-merge-style') === val);
} }
}); });
onMounted(() => { onMounted(() => {
mergeStyleAllowedCount.value = mergeForm.value.mergeStyles.reduce((v: any, msd: any) => v + (msd.allowed ? 1 : 0), 0); mergeStyleAllowedCount.value = mergeForm.mergeStyles.reduce((v: any, msd: any) => v + (msd.allowed ? 1 : 0), 0);
let mergeStyle = mergeForm.value.mergeStyles.find((e: any) => e.allowed && e.name === mergeForm.value.defaultMergeStyle)?.name; let mergeStyle = mergeForm.mergeStyles.find((e: any) => e.allowed && e.name === mergeForm.defaultMergeStyle)?.name;
if (!mergeStyle) mergeStyle = mergeForm.value.mergeStyles.find((e: any) => e.allowed)?.name; if (!mergeStyle) mergeStyle = mergeForm.mergeStyles.find((e: any) => e.allowed)?.name;
switchMergeStyle(mergeStyle, !mergeForm.value.canMergeNow); switchMergeStyle(mergeStyle, !mergeForm.canMergeNow);
document.addEventListener('mouseup', hideMergeStyleMenu); document.addEventListener('mouseup', hideMergeStyleMenu);
}); });
@ -63,7 +63,7 @@ function hideMergeStyleMenu() {
function toggleActionForm(show: boolean) { function toggleActionForm(show: boolean) {
showActionForm.value = show; showActionForm.value = show;
if (!show) return; if (!show) return;
deleteBranchAfterMerge.value = mergeForm.value.defaultDeleteBranchAfterMerge; deleteBranchAfterMerge.value = mergeForm.defaultDeleteBranchAfterMerge;
mergeTitleFieldValue.value = mergeStyleDetail.value.mergeTitleFieldText; mergeTitleFieldValue.value = mergeStyleDetail.value.mergeTitleFieldText;
mergeMessageFieldValue.value = mergeStyleDetail.value.mergeMessageFieldText; mergeMessageFieldValue.value = mergeStyleDetail.value.mergeMessageFieldText;
} }
@ -74,7 +74,7 @@ function switchMergeStyle(name: string, autoMerge = false) {
} }
function clearMergeMessage() { function clearMergeMessage() {
mergeMessageFieldValue.value = mergeForm.value.defaultMergeMessage; mergeMessageFieldValue.value = mergeForm.defaultMergeMessage;
} }
</script> </script>

View File

@ -1,9 +1,9 @@
<script lang="ts" setup> <script lang="ts" setup>
// @ts-expect-error - module exports no types // @ts-expect-error - module exports no types
import {VueBarGraph} from 'vue-bar-graph'; import {VueBarGraph} from 'vue-bar-graph';
import {computed, onMounted, ref} from 'vue'; import {computed, onMounted, shallowRef, useTemplateRef} from 'vue';
const colors = ref({ const colors = shallowRef({
barColor: 'green', barColor: 'green',
textColor: 'black', textColor: 'black',
textAltColor: 'white', textAltColor: 'white',
@ -41,8 +41,8 @@ const graphWidth = computed(() => {
return activityTopAuthors.length * 40; return activityTopAuthors.length * 40;
}); });
const styleElement = ref<HTMLElement | null>(null); const styleElement = useTemplateRef('styleElement');
const altStyleElement = ref<HTMLElement | null>(null); const altStyleElement = useTemplateRef('altStyleElement');
onMounted(() => { onMounted(() => {
const refStyle = window.getComputedStyle(styleElement.value); const refStyle = window.getComputedStyle(styleElement.value);

View File

@ -23,7 +23,7 @@ import {
import {chartJsColors} from '../utils/color.ts'; import {chartJsColors} from '../utils/color.ts';
import {sleep} from '../utils.ts'; import {sleep} from '../utils.ts';
import 'chartjs-adapter-dayjs-4/dist/chartjs-adapter-dayjs-4.esm'; import 'chartjs-adapter-dayjs-4/dist/chartjs-adapter-dayjs-4.esm';
import {onMounted, ref} from 'vue'; import {onMounted, shallowRef} from 'vue';
const {pageData} = window.config; const {pageData} = window.config;
@ -47,10 +47,10 @@ defineProps<{
}; };
}>(); }>();
const isLoading = ref(false); const isLoading = shallowRef(false);
const errorText = ref(''); const errorText = shallowRef('');
const repoLink = ref(pageData.repoLink || []); const repoLink = pageData.repoLink;
const data = ref<DayData[]>([]); const data = shallowRef<DayData[]>([]);
onMounted(() => { onMounted(() => {
fetchGraphData(); fetchGraphData();
@ -61,7 +61,7 @@ async function fetchGraphData() {
try { try {
let response: Response; let response: Response;
do { do {
response = await GET(`${repoLink.value}/activity/code-frequency/data`); response = await GET(`${repoLink}/activity/code-frequency/data`);
if (response.status === 202) { if (response.status === 202) {
await sleep(1000); // wait for 1 second before retrying await sleep(1000); // wait for 1 second before retrying
} }

View File

@ -21,7 +21,7 @@ import {
import {chartJsColors} from '../utils/color.ts'; import {chartJsColors} from '../utils/color.ts';
import {sleep} from '../utils.ts'; import {sleep} from '../utils.ts';
import 'chartjs-adapter-dayjs-4/dist/chartjs-adapter-dayjs-4.esm'; import 'chartjs-adapter-dayjs-4/dist/chartjs-adapter-dayjs-4.esm';
import {onMounted, ref} from 'vue'; import {onMounted, ref, shallowRef} from 'vue';
const {pageData} = window.config; const {pageData} = window.config;
@ -43,9 +43,9 @@ defineProps<{
}; };
}>(); }>();
const isLoading = ref(false); const isLoading = shallowRef(false);
const errorText = ref(''); const errorText = shallowRef('');
const repoLink = ref(pageData.repoLink || []); const repoLink = pageData.repoLink;
const data = ref<DayData[]>([]); const data = ref<DayData[]>([]);
onMounted(() => { onMounted(() => {
@ -57,7 +57,7 @@ async function fetchGraphData() {
try { try {
let response: Response; let response: Response;
do { do {
response = await GET(`${repoLink.value}/activity/recent-commits/data`); response = await GET(`${repoLink}/activity/recent-commits/data`);
if (response.status === 202) { if (response.status === 202) {
await sleep(1000); // wait for 1 second before retrying await sleep(1000); // wait for 1 second before retrying
} }

View File

@ -1,9 +1,9 @@
<script lang="ts" setup> <script lang="ts" setup>
import ViewFileTreeItem from './ViewFileTreeItem.vue'; import ViewFileTreeItem from './ViewFileTreeItem.vue';
import {onMounted, ref} from 'vue'; import {onMounted, useTemplateRef} from 'vue';
import {createViewFileTreeStore} from './ViewFileTreeStore.ts'; import {createViewFileTreeStore} from './ViewFileTreeStore.ts';
const elRoot = ref<HTMLElement | null>(null); const elRoot = useTemplateRef('elRoot');
const props = defineProps({ const props = defineProps({
repoLink: {type: String, required: true}, repoLink: {type: String, required: true},

View File

@ -1,7 +1,7 @@
<script lang="ts" setup> <script lang="ts" setup>
import {SvgIcon} from '../svg.ts'; import {SvgIcon} from '../svg.ts';
import {isPlainClick} from '../utils/dom.ts'; import {isPlainClick} from '../utils/dom.ts';
import {ref} from 'vue'; import {shallowRef} from 'vue';
import {type createViewFileTreeStore} from './ViewFileTreeStore.ts'; import {type createViewFileTreeStore} from './ViewFileTreeStore.ts';
type Item = { type Item = {
@ -20,9 +20,9 @@ const props = defineProps<{
}>(); }>();
const store = props.store; const store = props.store;
const isLoading = ref(false); const isLoading = shallowRef(false);
const children = ref(props.item.children); const children = shallowRef(props.item.children);
const collapsed = ref(!props.item.children); const collapsed = shallowRef(!props.item.children);
const doLoadChildren = async () => { const doLoadChildren = async () => {
collapsed.value = !collapsed.value; collapsed.value = !collapsed.value;

View File

@ -5,7 +5,7 @@ import {confirmModal} from './comp/ConfirmModal.ts';
import type {RequestOpts} from '../types.ts'; import type {RequestOpts} from '../types.ts';
import {ignoreAreYouSure} from '../vendor/jquery.are-you-sure.ts'; import {ignoreAreYouSure} from '../vendor/jquery.are-you-sure.ts';
const {appSubUrl, i18n} = window.config; const {appSubUrl} = window.config;
// fetchActionDoRedirect does real redirection to bypass the browser's limitations of "location" // fetchActionDoRedirect does real redirection to bypass the browser's limitations of "location"
// more details are in the backend's fetch-redirect handler // more details are in the backend's fetch-redirect handler
@ -23,11 +23,20 @@ function fetchActionDoRedirect(redirect: string) {
} }
async function fetchActionDoRequest(actionElem: HTMLElement, url: string, opt: RequestOpts) { async function fetchActionDoRequest(actionElem: HTMLElement, url: string, opt: RequestOpts) {
const showErrorForResponse = (code: number, message: string) => {
showErrorToast(`Error ${code || 'request'}: ${message}`);
};
let respStatus = 0;
let respText = '';
try { try {
hideToastsAll(); hideToastsAll();
const resp = await request(url, opt); const resp = await request(url, opt);
if (resp.status === 200) { respStatus = resp.status;
let {redirect} = await resp.json(); respText = await resp.text();
const respJson = JSON.parse(respText);
if (respStatus === 200) {
let {redirect} = respJson;
redirect = redirect || actionElem.getAttribute('data-redirect'); redirect = redirect || actionElem.getAttribute('data-redirect');
ignoreAreYouSure(actionElem); // ignore the areYouSure check before reloading ignoreAreYouSure(actionElem); // ignore the areYouSure check before reloading
if (redirect) { if (redirect) {
@ -38,22 +47,19 @@ async function fetchActionDoRequest(actionElem: HTMLElement, url: string, opt: R
return; return;
} }
if (resp.status >= 400 && resp.status < 500) { if (respStatus >= 400 && respStatus < 500 && respJson?.errorMessage) {
const data = await resp.json();
// the code was quite messy, sometimes the backend uses "err", sometimes it uses "error", and even "user_error" // the code was quite messy, sometimes the backend uses "err", sometimes it uses "error", and even "user_error"
// but at the moment, as a new approach, we only use "errorMessage" here, backend can use JSONError() to respond. // but at the moment, as a new approach, we only use "errorMessage" here, backend can use JSONError() to respond.
if (data.errorMessage) { showErrorToast(respJson.errorMessage, {useHtmlBody: respJson.renderFormat === 'html'});
showErrorToast(data.errorMessage, {useHtmlBody: data.renderFormat === 'html'});
} else {
showErrorToast(`server error: ${resp.status}`);
}
} else { } else {
showErrorToast(`server error: ${resp.status}`); showErrorForResponse(respStatus, respText);
} }
} catch (e) { } catch (e) {
if (e.name !== 'AbortError') { if (e.name === 'SyntaxError') {
console.error('error when doRequest', e); showErrorForResponse(respStatus, (respText || '').substring(0, 100));
showErrorToast(`${i18n.network_error} ${e}`); } else if (e.name !== 'AbortError') {
console.error('fetchActionDoRequest error', e);
showErrorForResponse(respStatus, `${e}`);
} }
} }
actionElem.classList.remove('is-loading', 'loading-icon-2px'); actionElem.classList.remove('is-loading', 'loading-icon-2px');