Merge branch 'main' into fork-on-edit

This commit is contained in:
wxiaoguang 2025-06-22 20:17:42 +08:00 committed by GitHub
commit c60f83d418
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
20 changed files with 220 additions and 420 deletions

View File

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

View File

@ -69,6 +69,6 @@ func TestSanitizer(t *testing.T) {
}
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))
}
// SanitizeHTML sanitizes the input by pre-defined markdown rules
// SanitizeHTML sanitizes the input by default sanitization rules.
func SanitizeHTML(s string) template.HTML {
return template.HTML(markup.Sanitize(s))
return markup.Sanitize(s)
}
func htmlEscape(s any) template.HTML {

View File

@ -6,6 +6,7 @@ package web
import (
"net/http"
"regexp"
"slices"
"strings"
"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)
}
type RouterPathGroupPattern struct {
re *regexp.Regexp
params []routerPathParam
middlewares []any
}
// 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)
// It is only designed to resolve some special cases which chi router can't handle.
// The pattern uses "<...>" to define path parameters, for example, "/<name>" (different from chi router)
// 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).
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...))
}
@ -96,8 +107,8 @@ func isValidMethod(name string) bool {
return false
}
func newRouterPathMatcher(methods, pattern string, h ...any) *routerPathMatcher {
middlewares, handlerFunc := wrapMiddlewareAndHandler(nil, h)
func newRouterPathMatcher(methods string, patternRegexp *RouterPathGroupPattern, h ...any) *routerPathMatcher {
middlewares, handlerFunc := wrapMiddlewareAndHandler(patternRegexp.middlewares, h)
p := &routerPathMatcher{methods: make(container.Set[string]), middlewares: middlewares, handlerFunc: handlerFunc}
for method := range strings.SplitSeq(methods, ",") {
method = strings.TrimSpace(method)
@ -106,19 +117,25 @@ func newRouterPathMatcher(methods, pattern string, h ...any) *routerPathMatcher
}
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{'^'}
lastEnd := 0
for lastEnd < len(pattern) {
start := strings.IndexByte(pattern[lastEnd:], '<')
if start == -1 {
re = append(re, pattern[lastEnd:]...)
re = append(re, regexp.QuoteMeta(pattern[lastEnd:])...)
break
}
end := strings.IndexByte(pattern[lastEnd+start:], '>')
if end == -1 {
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], ":")
lastEnd += start + end + 1
@ -140,7 +157,10 @@ func newRouterPathMatcher(methods, pattern string, h ...any) *routerPathMatcher
p.params = append(p.params, param)
}
re = append(re, '$')
reStr := string(re)
p.re = regexp.MustCompile(reStr)
p.re = regexp.MustCompile(string(re))
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) {
chiCtx := chi.NewRouteContext()
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.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
type resultStruct struct {
method string
pathParams map[string]string
handlerMark string
method string
pathParams map[string]string
handlerMarks []string
}
var res resultStruct
var res resultStruct
h := func(optMark ...string) func(resp http.ResponseWriter, req *http.Request) {
mark := util.OptionalArg(optMark, "")
return func(resp http.ResponseWriter, req *http.Request) {
res.method = req.Method
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) {
h(stop)(resp, req)
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.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"))
})
})
@ -126,31 +130,31 @@ func TestRouter(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{
method: "GET",
pathParams: map[string]string{"username": "the-user", "reponame": "the-repo", "type": "pulls"},
handlerMark: "list-issues-b",
method: "GET",
pathParams: map[string]string{"username": "the-user", "reponame": "the-repo", "type": "pulls"},
handlerMarks: []string{"list-issues-b"},
})
testRoute(t, "GET /the-user/the-repo/issues/123", resultStruct{
method: "GET",
pathParams: map[string]string{"username": "the-user", "reponame": "the-repo", "type": "issues", "index": "123"},
handlerMark: "view-issue",
method: "GET",
pathParams: map[string]string{"username": "the-user", "reponame": "the-repo", "type": "issues", "index": "123"},
handlerMarks: []string{"view-issue"},
})
testRoute(t, "GET /the-user/the-repo/issues/123?stop=hijack", resultStruct{
method: "GET",
pathParams: map[string]string{"username": "the-user", "reponame": "the-repo", "type": "issues", "index": "123"},
handlerMark: "hijack",
method: "GET",
pathParams: map[string]string{"username": "the-user", "reponame": "the-repo", "type": "issues", "index": "123"},
handlerMarks: []string{"hijack"},
})
testRoute(t, "POST /the-user/the-repo/issues/123/update", resultStruct{
method: "POST",
pathParams: map[string]string{"username": "the-user", "reponame": "the-repo", "index": "123"},
handlerMark: "update-issue",
method: "POST",
pathParams: map[string]string{"username": "the-user", "reponame": "the-repo", "index": "123"},
handlerMarks: []string{"update-issue"},
})
})
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{
method: "GET",
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) {
testRoute(t, "GET /api/v1/repos/the-user/the-repo/branches/d1/d2/fn", resultStruct{
method: "GET",
pathParams: map[string]string{"username": "the-user", "reponame": "the-repo", "*": "d1/d2/fn", "dir": "d1/d2", "file": "fn"},
handlerMark: "match-path",
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", "match-path"},
})
testRoute(t, "GET /api/v1/repos/the-user/the-repo/branches/d1%2fd2/fn", resultStruct{
method: "GET",
pathParams: map[string]string{"username": "the-user", "reponame": "the-repo", "*": "d1%2fd2/fn", "dir": "d1%2fd2", "file": "fn"},
handlerMark: "match-path",
method: "GET",
pathParams: map[string]string{"username": "the-user", "reponame": "the-repo", "*": "d1%2fd2/fn", "dir": "d1%2fd2", "file": "fn"},
handlerMarks: []string{"s1", "s2", "s3", "match-path"},
})
testRoute(t, "GET /api/v1/repos/the-user/the-repo/branches/d1/d2/000", resultStruct{
method: "GET",
pathParams: map[string]string{"reponame": "the-repo", "username": "the-user", "*": "d1/d2/000"},
handlerMark: "not-found:/api/v1",
method: "GET",
pathParams: map[string]string{"reponame": "the-repo", "username": "the-user", "*": "d1/d2/000"},
handlerMarks: []string{"s1", "not-found:/api/v1"},
})
testRoute(t, "GET /api/v1/repos/the-user/the-repo/branches/d1/d2/fn?stop=s1", resultStruct{
method: "GET",
pathParams: map[string]string{"username": "the-user", "reponame": "the-repo", "*": "d1/d2/fn"},
handlerMark: "s1",
method: "GET",
pathParams: map[string]string{"username": "the-user", "reponame": "the-repo", "*": "d1/d2/fn"},
handlerMarks: []string{"s1"},
})
testRoute(t, "GET /api/v1/repos/the-user/the-repo/branches/d1/d2/fn?stop=s2", resultStruct{
method: "GET",
pathParams: map[string]string{"username": "the-user", "reponame": "the-repo", "*": "d1/d2/fn", "dir": "d1/d2", "file": "fn"},
handlerMark: "s2",
method: "GET",
pathParams: map[string]string{"username": "the-user", "reponame": "the-repo", "*": "d1/d2/fn", "dir": "d1/d2", "file": "fn"},
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

@ -5,8 +5,6 @@ package packages
import (
"net/http"
"regexp"
"strings"
auth_model "code.gitea.io/gitea/models/auth"
"code.gitea.io/gitea/models/perm"
@ -282,42 +280,10 @@ func CommonRoutes() *web.Router {
})
})
}, reqPackageAccess(perm.AccessModeRead))
r.Group("/conda", func() {
var (
downloadPattern = regexp.MustCompile(`\A(.+/)?(.+)/((?:[^/]+(?:\.tar\.bz2|\.conda))|(?:current_)?repodata\.json(?:\.bz2)?)\z`)
uploadPattern = regexp.MustCompile(`\A(.+/)?([^/]+(?:\.tar\.bz2|\.conda))\z`)
)
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)
})
r.PathGroup("/conda/*", func(g *web.RouterPathGroup) {
g.MatchPath("GET", "/<architecture>/<filename>", conda.ListOrGetPackages)
g.MatchPath("GET", "/<channel:*>/<architecture>/<filename>", conda.ListOrGetPackages)
g.MatchPath("PUT", "/<channel:*>/<filename>", reqPackageAccess(perm.AccessModeWrite), conda.UploadPackageFile)
}, reqPackageAccess(perm.AccessModeRead))
r.Group("/cran", func() {
r.Group("/src", func() {
@ -358,60 +324,15 @@ func CommonRoutes() *web.Router {
}, reqPackageAccess(perm.AccessModeRead))
r.Group("/go", func() {
r.Put("/upload", reqPackageAccess(perm.AccessModeWrite), goproxy.UploadPackage)
r.Get("/sumdb/sum.golang.org/supported", func(ctx *context.Context) {
ctx.Status(http.StatusNotFound)
})
r.Get("/sumdb/sum.golang.org/supported", http.NotFound)
// Manual mapping of routes because the package name contains slashes which chi does not support
// https://go.dev/ref/mod#goproxy-protocol
r.Get("/*", func(ctx *context.Context) {
path := ctx.PathParam("*")
if strings.HasSuffix(path, "/@latest") {
ctx.SetPathParam("name", path[:len(path)-len("/@latest")])
ctx.SetPathParam("version", "latest")
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)
r.PathGroup("/*", func(g *web.RouterPathGroup) {
g.MatchPath("GET", "/<name:*>/@<version:latest>", goproxy.PackageVersionMetadata)
g.MatchPath("GET", "/<name:*>/@v/list", goproxy.EnumeratePackageVersions)
g.MatchPath("GET", "/<name:*>/@v/<version>.zip", goproxy.DownloadPackageFile)
g.MatchPath("GET", "/<name:*>/@v/<version>.info", goproxy.PackageVersionMetadata)
g.MatchPath("GET", "/<name:*>/@v/<version>.mod", goproxy.PackageVersionGoModContent)
})
}, reqPackageAccess(perm.AccessModeRead))
r.Group("/generic", func() {
@ -532,82 +453,24 @@ func CommonRoutes() *web.Router {
})
})
}, reqPackageAccess(perm.AccessModeRead))
r.Group("/pypi", func() {
r.Post("/", reqPackageAccess(perm.AccessModeWrite), pypi.UploadPackageFile)
r.Get("/files/{id}/{version}/{filename}", pypi.DownloadPackageFile)
r.Get("/simple/{id}", pypi.PackageMetadata)
}, reqPackageAccess(perm.AccessModeRead))
r.Group("/rpm", func() {
r.Group("/repository.key", func() {
r.Head("", rpm.GetRepositoryKey)
r.Get("", rpm.GetRepositoryKey)
})
var (
repoPattern = regexp.MustCompile(`\A(.*?)\.repo\z`)
uploadPattern = regexp.MustCompile(`\A(.*?)/upload\z`)
filePattern = regexp.MustCompile(`\A(.*?)/package/([^/]+)/([^/]+)/([^/]+)(?:/([^/]+\.rpm)|)\z`)
repoFilePattern = regexp.MustCompile(`\A(.*?)/repodata/([^/]+)\z`)
)
r.Methods("HEAD,GET,PUT,DELETE", "*", func(ctx *context.Context) {
path := ctx.PathParam("*")
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)
})
r.Methods("HEAD,GET", "/rpm.repo", reqPackageAccess(perm.AccessModeRead), rpm.GetRepositoryConfig)
r.PathGroup("/rpm/*", func(g *web.RouterPathGroup) {
g.MatchPath("HEAD,GET", "/repository.key", rpm.GetRepositoryKey)
g.MatchPath("HEAD,GET", "/<group:*>.repo", rpm.GetRepositoryConfig)
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)
g.MatchPath("HEAD,GET", "/<group:*>/package/<name>/<version>/<architecture>", rpm.DownloadPackageFile)
g.MatchPath("DELETE", "/<group:*>/package/<name>/<version>/<architecture>", reqPackageAccess(perm.AccessModeWrite), rpm.DeletePackageFile)
}, reqPackageAccess(perm.AccessModeRead))
r.Group("/rubygems", func() {
r.Get("/specs.4.8.gz", rubygems.EnumeratePackages)
r.Get("/latest_specs.4.8.gz", rubygems.EnumeratePackagesLatest)
@ -621,6 +484,7 @@ func CommonRoutes() *web.Router {
r.Delete("/yank", rubygems.DeletePackage)
}, reqPackageAccess(perm.AccessModeWrite))
}, reqPackageAccess(perm.AccessModeRead))
r.Group("/swift", func() {
r.Group("", func() { // Needs to be unauthenticated.
r.Post("", swift.CheckAuthenticate)
@ -632,31 +496,12 @@ func CommonRoutes() *web.Router {
r.Get("", swift.EnumeratePackageVersions)
r.Get(".json", swift.EnumeratePackageVersions)
}, swift.CheckAcceptMediaType(swift.AcceptJSON))
r.Group("/{version}", func() {
r.Get("/Package.swift", swift.CheckAcceptMediaType(swift.AcceptSwift), swift.DownloadManifest)
r.Put("", reqPackageAccess(perm.AccessModeWrite), swift.CheckAcceptMediaType(swift.AcceptJSON), swift.UploadPackageFile)
r.Get("", func(ctx *context.Context) {
// Can't use normal routes here: https://github.com/go-chi/chi/issues/781
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.PathGroup("/*", func(g *web.RouterPathGroup) {
g.MatchPath("GET", "/<version>.json", swift.CheckAcceptMediaType(swift.AcceptJSON), swift.PackageVersionMetadata)
g.MatchPath("GET", "/<version>.zip", swift.CheckAcceptMediaType(swift.AcceptZip), swift.DownloadPackageFile)
g.MatchPath("GET", "/<version>/Package.swift", swift.CheckAcceptMediaType(swift.AcceptSwift), swift.DownloadManifest)
g.MatchPath("GET", "/<version>", swift.CheckAcceptMediaType(swift.AcceptJSON), swift.PackageVersionMetadata)
g.MatchPath("PUT", "/<version>", reqPackageAccess(perm.AccessModeWrite), swift.CheckAcceptMediaType(swift.AcceptJSON), swift.UploadPackageFile)
})
})
r.Get("/identifiers", swift.CheckAcceptMediaType(swift.AcceptJSON), swift.LookupPackageIdentifiers)
@ -705,18 +550,13 @@ func ContainerRoutes() *web.Router {
r.PathGroup("/*", func(g *web.RouterPathGroup) {
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,PATCH,PUT,DELETE", `/<image:*>/blobs/uploads/<uuid:[-.=\w]+>`, reqPackageAccess(perm.AccessModeWrite), container.VerifyImageName, func(ctx *context.Context) {
switch ctx.Req.Method {
case http.MethodGet:
container.GetBlobsUpload(ctx)
case http.MethodPatch:
container.PatchBlobsUpload(ctx)
case http.MethodPut:
container.PutBlobsUpload(ctx)
default: /* DELETE */
container.DeleteBlobsUpload(ctx)
}
})
patternBlobsUploadsUUID := g.PatternRegexp(`/<image:*>/blobs/uploads/<uuid:[-.=\w]+>`, reqPackageAccess(perm.AccessModeWrite), container.VerifyImageName)
g.MatchPattern("GET", patternBlobsUploadsUUID, container.GetBlobsUpload)
g.MatchPattern("PATCH", patternBlobsUploadsUUID, container.PatchBlobsUpload)
g.MatchPattern("PUT", patternBlobsUploadsUUID, container.PutBlobsUpload)
g.MatchPattern("DELETE", patternBlobsUploadsUUID, container.DeleteBlobsUpload)
g.MatchPath("HEAD", `/<image:*>/blobs/<digest>`, container.VerifyImageName, container.HeadBlob)
g.MatchPath("GET", `/<image:*>/blobs/<digest>`, container.VerifyImageName, container.GetBlob)
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) {
type Info struct {
Subdir string `json:"subdir"`
@ -174,6 +192,12 @@ func EnumeratePackages(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()
if err != nil {
apiError(ctx, http.StatusInternalServerError, err)
@ -191,7 +215,7 @@ func UploadPackageFile(ctx *context.Context) {
defer buf.Close()
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)
} else {
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 {
return fmt.Sprintf("pkg_%d_container_%s", piOwnerID, strings.ToLower(piName))
func containerGlobalLockKey(piOwnerID int64, piName, usage string) string {
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) {
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 {
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 {
releaser, err := globallock.Lock(ctx, containerPkgName(ownerID, image))
releaser, err := globallock.Lock(ctx, containerGlobalLockKey(ownerID, image, "blob"))
if err != nil {
return err
}

View File

@ -32,7 +32,7 @@ import (
packages_service "code.gitea.io/gitea/services/packages"
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

View File

@ -16,6 +16,7 @@ import (
packages_model "code.gitea.io/gitea/models/packages"
container_model "code.gitea.io/gitea/models/packages/container"
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/log"
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) {
return processOciImageManifest(ctx, mci, buf)
} else if container_module.IsMediaTypeImageIndex(mci.MediaType) {

View File

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

View File

@ -164,7 +164,7 @@ func TestEditWiki(t *testing.T) {
EditWiki(ctx)
assert.Equal(t, http.StatusOK, ctx.Resp.WrittenStatus())
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.SetPathParam("*", "jpeg.jpg")

View File

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

View File

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

View File

@ -1,22 +1,22 @@
{{if .EscapeStatus}}
{{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>
<div class="header">
{{ctx.Locale.Tr "repo.invisible_runes_header"}}
</div>
<p>{{ctx.Locale.Tr "repo.invisible_runes_description"}}</p>
<div>{{ctx.Locale.Tr "repo.invisible_runes_description"}}</div>
{{if .EscapeStatus.HasAmbiguous}}
<p>{{ctx.Locale.Tr "repo.ambiguous_runes_description"}}</p>
<div>{{ctx.Locale.Tr "repo.ambiguous_runes_description"}}</div>
{{end}}
</div>
{{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>
<div class="header">
{{ctx.Locale.Tr "repo.ambiguous_runes_header"}}
</div>
<p>{{ctx.Locale.Tr "repo.ambiguous_runes_description"}}</p>
<div>{{ctx.Locale.Tr "repo.ambiguous_runes_description"}}</div>
</div>
{{end}}
{{end}}

View File

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

View File

@ -62,36 +62,34 @@
{{end}}
<div class="wiki-content-parts">
{{if .sidebarTocContent}}
{{if .WikiSidebarTocHTML}}
<div class="render-content markup wiki-content-sidebar wiki-content-toc">
{{.sidebarTocContent | SafeHTML}}
{{.WikiSidebarTocHTML}}
</div>
{{end}}
<div class="render-content markup wiki-content-main {{if or .sidebarTocContent .sidebarPresent}}with-sidebar{{end}}">
{{template "repo/unicode_escape_prompt" dict "EscapeStatus" .EscapeStatus "root" $}}
{{.content | SafeHTML}}
<div class="render-content markup wiki-content-main {{if or .WikiSidebarTocHTML .WikiSidebarHTML}}with-sidebar{{end}}">
{{template "repo/unicode_escape_prompt" dict "EscapeStatus" .EscapeStatus}}
{{.WikiContentHTML}}
</div>
{{if .sidebarPresent}}
{{if .WikiSidebarHTML}}
<div class="render-content markup wiki-content-sidebar">
{{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>
{{end}}
{{template "repo/unicode_escape_prompt" dict "EscapeStatus" .sidebarEscapeStatus "root" $}}
{{.sidebarContent | SafeHTML}}
{{.WikiSidebarHTML}}
</div>
{{end}}
<div class="tw-clear-both"></div>
{{if .footerPresent}}
{{if .WikiFooterHTML}}
<div class="render-content markup wiki-content-footer">
{{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>
{{end}}
{{template "repo/unicode_escape_prompt" dict "footerEscapeStatus" .sidebarEscapeStatus "root" $}}
{{.footerContent | SafeHTML}}
{{.WikiFooterHTML}}
</div>
{{end}}
</div>

View File

@ -134,7 +134,9 @@
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 {
margin-bottom: 16px;
}

View File

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

View File

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