refactor: serve site manifest via /assets/site-manifest.json endpoint (#37405)

Slightly reduce the page size for every request, and don't need to use `href="data:`

Signed-off-by: wxiaoguang <wxiaoguang@gmail.com>
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: wxiaoguang <2114189+wxiaoguang@users.noreply.github.com>
Co-authored-by: wxiaoguang <wxiaoguang@gmail.com>
Co-authored-by: silverwind <me@silverwind.io>
Co-authored-by: Claude (Opus 4.7) <noreply@anthropic.com>
This commit is contained in:
Copilot
2026-04-24 13:00:59 +00:00
committed by GitHub
parent 6826321570
commit c5c9713ed4
9 changed files with 64 additions and 117 deletions

View File

@@ -317,7 +317,7 @@ func TestRender_email(t *testing.T) {
func TestRender_emoji(t *testing.T) {
setting.AppURL = markup.TestAppURL
setting.StaticURLPrefix = markup.TestAppURL
setting.StaticURLPrefix = strings.TrimSuffix(markup.TestAppURL, "/")
test := func(input, expected string) {
expected = strings.ReplaceAll(expected, "&", "&amp;")
@@ -500,7 +500,7 @@ func Test_ParseClusterFuzz(t *testing.T) {
}
func TestPostProcess(t *testing.T) {
setting.StaticURLPrefix = markup.TestAppURL // can't run standalone
setting.StaticURLPrefix = strings.TrimSuffix(markup.TestAppURL, "/") // can't run standalone
defer testModule.MockVariableValue(&markup.RenderBehaviorForTesting.DisableAdditionalAttributes, true)()
test := func(input, expected string) {

View File

@@ -4,7 +4,6 @@
package setting
import (
"encoding/base64"
"net"
"net/url"
"os"
@@ -13,7 +12,6 @@ import (
"strings"
"time"
"code.gitea.io/gitea/modules/json"
"code.gitea.io/gitea/modules/log"
)
@@ -112,72 +110,9 @@ var (
StartupTimeout time.Duration
PerWriteTimeout = 30 * time.Second
PerWritePerKbTimeout = 10 * time.Second
StaticURLPrefix string
AbsoluteAssetURL string
ManifestData string
StaticURLPrefix string // no trailing slash, defaults to AppSubURL, the URL can be relative or absolute
)
// MakeManifestData generates web app manifest JSON
func MakeManifestData(appName, appURL, absoluteAssetURL string) []byte {
type manifestIcon struct {
Src string `json:"src"`
Type string `json:"type"`
Sizes string `json:"sizes"`
}
type manifestJSON struct {
Name string `json:"name"`
ShortName string `json:"short_name"`
StartURL string `json:"start_url"`
Icons []manifestIcon `json:"icons"`
}
bytes, err := json.Marshal(&manifestJSON{
Name: appName,
ShortName: appName,
StartURL: appURL,
Icons: []manifestIcon{
{
Src: absoluteAssetURL + "/assets/img/logo.png",
Type: "image/png",
Sizes: "512x512",
},
{
Src: absoluteAssetURL + "/assets/img/logo.svg",
Type: "image/svg+xml",
Sizes: "512x512",
},
},
})
if err != nil {
log.Error("unable to marshal manifest JSON. Error: %v", err)
return make([]byte, 0)
}
return bytes
}
// MakeAbsoluteAssetURL returns the absolute asset url prefix without a trailing slash
func MakeAbsoluteAssetURL(appURL *url.URL, staticURLPrefix string) string {
parsedPrefix, err := url.Parse(strings.TrimSuffix(staticURLPrefix, "/"))
if err != nil {
log.Fatal("Unable to parse STATIC_URL_PREFIX: %v", err)
}
if err == nil && parsedPrefix.Hostname() == "" {
if staticURLPrefix == "" {
return strings.TrimSuffix(appURL.String(), "/")
}
// StaticURLPrefix is just a path
appHostURL := &url.URL{Scheme: appURL.Scheme, Host: appURL.Host}
return appHostURL.String() + "/" + strings.Trim(staticURLPrefix, "/")
}
return strings.TrimSuffix(staticURLPrefix, "/")
}
func loadServerFrom(rootCfg ConfigProvider) {
sec := rootCfg.Section("server")
AppName = rootCfg.Section("").Key("APP_NAME").MustString("Gitea: Git with a cup of tea")
@@ -313,10 +248,6 @@ func loadServerFrom(rootCfg ConfigProvider) {
Domain = urlHostname
}
AbsoluteAssetURL = MakeAbsoluteAssetURL(appURL, StaticURLPrefix)
manifestBytes := MakeManifestData(AppName, AppURL, AbsoluteAssetURL)
ManifestData = `application/json;base64,` + base64.StdEncoding.EncodeToString(manifestBytes)
var defaultLocalURL string
switch Protocol {
case HTTPUnix:

View File

@@ -1,41 +0,0 @@
// Copyright 2020 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package setting
import (
"net/url"
"testing"
"code.gitea.io/gitea/modules/json"
"github.com/stretchr/testify/assert"
)
func TestMakeAbsoluteAssetURL(t *testing.T) {
appURL1, _ := url.Parse("https://localhost:1234")
appURL2, _ := url.Parse("https://localhost:1234/")
appURLSub1, _ := url.Parse("https://localhost:1234/foo")
appURLSub2, _ := url.Parse("https://localhost:1234/foo/")
// static URL is an absolute URL, so should be used
assert.Equal(t, "https://localhost:2345", MakeAbsoluteAssetURL(appURL1, "https://localhost:2345"))
assert.Equal(t, "https://localhost:2345", MakeAbsoluteAssetURL(appURL1, "https://localhost:2345/"))
assert.Equal(t, "https://localhost:1234/foo", MakeAbsoluteAssetURL(appURL1, "/foo"))
assert.Equal(t, "https://localhost:1234/foo", MakeAbsoluteAssetURL(appURL2, "/foo"))
assert.Equal(t, "https://localhost:1234/foo", MakeAbsoluteAssetURL(appURL1, "/foo/"))
assert.Equal(t, "https://localhost:1234/foo", MakeAbsoluteAssetURL(appURLSub1, "/foo"))
assert.Equal(t, "https://localhost:1234/foo", MakeAbsoluteAssetURL(appURLSub2, "/foo"))
assert.Equal(t, "https://localhost:1234/foo", MakeAbsoluteAssetURL(appURLSub1, "/foo/"))
assert.Equal(t, "https://localhost:1234/bar", MakeAbsoluteAssetURL(appURLSub1, "/bar"))
assert.Equal(t, "https://localhost:1234/bar", MakeAbsoluteAssetURL(appURLSub2, "/bar"))
assert.Equal(t, "https://localhost:1234/bar", MakeAbsoluteAssetURL(appURLSub1, "/bar/"))
}
func TestMakeManifestData(t *testing.T) {
jsonBytes := MakeManifestData(`Example App '\"`, "https://example.com", "https://example.com/foo/bar")
assert.True(t, json.Valid(jsonBytes))
}

View File

@@ -7,9 +7,12 @@ import (
"net/http"
"path"
"strconv"
"strings"
"code.gitea.io/gitea/modules/git"
"code.gitea.io/gitea/modules/httpcache"
"code.gitea.io/gitea/modules/httplib"
"code.gitea.io/gitea/modules/json"
"code.gitea.io/gitea/modules/log"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/modules/util"
@@ -17,6 +20,29 @@ import (
"code.gitea.io/gitea/services/context"
)
func SiteManifest(w http.ResponseWriter, req *http.Request) {
w.Header().Set("Content-Type", "application/manifest+json")
if httpcache.HandleGenericETagPublicCache(req, w, "", &setting.AppStartTime) {
return
}
if req.Method == http.MethodHead {
return
}
ctx := req.Context()
absoluteAssetURL := strings.TrimSuffix(httplib.MakeAbsoluteURL(ctx, setting.StaticURLPrefix), "/")
manifest := map[string]any{
"name": setting.AppName,
"short_name": setting.AppName,
"start_url": httplib.GuessCurrentAppURL(ctx),
"icons": []map[string]string{
{"src": absoluteAssetURL + "/assets/img/logo.png", "type": "image/png", "sizes": "512x512"},
{"src": absoluteAssetURL + "/assets/img/logo.svg", "type": "image/svg+xml", "sizes": "512x512"},
},
}
_ = json.NewEncoder(w).Encode(manifest)
}
func SSHInfo(rw http.ResponseWriter, req *http.Request) {
if !git.DefaultFeatures().SupportProcReceive {
rw.WriteHeader(http.StatusNotFound)

View File

@@ -260,6 +260,7 @@ func Routes() *web.Router {
routes.BeforeRouting(chi_middleware.GetHead)
routes.Head("/", misc.DummyOK) // for health check - doesn't need to be passed through gzip handler
routes.Methods("GET, HEAD", "/assets/site-manifest.json", misc.SiteManifest)
routes.Methods("GET, HEAD, OPTIONS", "/assets/*", routing.MarkLogLevelTrace, public.AssetsCors(), public.FileHandlerFunc())
routes.Methods("GET, HEAD", "/avatars/*", avatarStorageHandler(setting.Avatar.Storage, "avatars", storage.Avatars))
routes.Methods("GET, HEAD", "/repo-avatars/*", avatarStorageHandler(setting.RepoAvatar.Storage, "repo-avatars", storage.RepoAvatars))

View File

@@ -205,7 +205,6 @@ func Contexter() func(next http.Handler) http.Handler {
ctx.Data["DisableStars"] = setting.Repository.DisableStars
ctx.Data["EnableActions"] = setting.Actions.Enabled && !unit.TypeActions.UnitGlobalDisabled()
ctx.Data["ManifestData"] = setting.ManifestData
ctx.Data["AllLangs"] = translation.AllLangs()
next.ServeHTTP(ctx.Resp, ctx.Req)

View File

@@ -148,8 +148,7 @@ func (c TemplateContext) HeadMetaContentSecurityPolicy() template.HTML {
// * Maybe this approach should be avoided, don't make the config system too complex, just let users use A
return template.HTML(`<meta http-equiv="Content-Security-Policy" content="` +
// allow all by default (the same as old releases with no CSP)
// "data:" is used to load the manifest in head (maybe also need to be refactored in the future)
// maybe some images are also loaded by "data:", need to investigate
// maybe some images or markup (external) renders need "data:", need to investigate
`default-src * data:;` +
// enforce nonce for all scripts, disallow inline scripts

View File

@@ -4,7 +4,7 @@
{{ctx.HeadMetaContentSecurityPolicy}}
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{if .Title}}{{.Title}} - {{end}}{{.PageTitleCommon}}</title>
{{if .ManifestData}}<link rel="manifest" href="data:{{.ManifestData}}">{{end}}
<link rel="manifest" href="{{AssetUrlPrefix}}/site-manifest.json">
<meta name="author" content="{{if .Repository}}{{.Owner.Name}}{{else}}{{MetaAuthor}}{{end}}">
<meta name="description" content="{{if .Repository}}{{.Repository.Name}}{{if .Repository.Description}} - {{.Repository.Description}}{{end}}{{else}}{{MetaDescription}}{{end}}">
<meta name="keywords" content="{{MetaKeywords}}">

View File

@@ -4,9 +4,12 @@
package integration
import (
"fmt"
"net/http"
"strings"
"testing"
"code.gitea.io/gitea/modules/setting"
"code.gitea.io/gitea/tests"
"github.com/stretchr/testify/assert"
@@ -17,6 +20,7 @@ func TestView(t *testing.T) {
t.Run("RenderFileSVGIsInImgTag", testRenderFileSVGIsInImgTag)
t.Run("CommitListActions", testCommitListActions)
t.Run("SecurityHeadersDefaults", testSecurityHeadersDefaults)
t.Run("SiteManifest", testSiteManifest)
}
func testRenderFileSVGIsInImgTag(t *testing.T) {
@@ -81,3 +85,31 @@ func testSecurityHeadersDefaults(t *testing.T) {
assertSecurityHeaders(t, "/api/v1/version")
assertSecurityHeaders(t, "/assets/img/favicon.png")
}
func testSiteManifest(t *testing.T) {
req := NewRequest(t, "GET", "/")
resp := MakeRequest(t, req, http.StatusOK)
assert.Contains(t, resp.Body.String(), `<link rel="manifest" href="/assets/site-manifest.json">`)
req = NewRequest(t, "GET", "/assets/site-manifest.json")
resp = MakeRequest(t, req, http.StatusOK)
assert.Equal(t, "application/manifest+json", resp.Header().Get("Content-Type"))
assetBase := strings.TrimSuffix(setting.AppURL, "/")
expectedJSON := fmt.Sprintf(`{
"name": %q,
"short_name": %q,
"start_url": %q,
"icons": [
{"src": %q, "type": "image/png", "sizes": "512x512"},
{"src": %q, "type": "image/svg+xml", "sizes": "512x512"}
]
}`,
setting.AppName,
setting.AppName,
setting.AppURL,
assetBase+"/assets/img/logo.png",
assetBase+"/assets/img/logo.svg",
)
assert.JSONEq(t, expectedJSON, resp.Body.String())
}