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:
@@ -317,7 +317,7 @@ func TestRender_email(t *testing.T) {
|
|||||||
|
|
||||||
func TestRender_emoji(t *testing.T) {
|
func TestRender_emoji(t *testing.T) {
|
||||||
setting.AppURL = markup.TestAppURL
|
setting.AppURL = markup.TestAppURL
|
||||||
setting.StaticURLPrefix = markup.TestAppURL
|
setting.StaticURLPrefix = strings.TrimSuffix(markup.TestAppURL, "/")
|
||||||
|
|
||||||
test := func(input, expected string) {
|
test := func(input, expected string) {
|
||||||
expected = strings.ReplaceAll(expected, "&", "&")
|
expected = strings.ReplaceAll(expected, "&", "&")
|
||||||
@@ -500,7 +500,7 @@ func Test_ParseClusterFuzz(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestPostProcess(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)()
|
defer testModule.MockVariableValue(&markup.RenderBehaviorForTesting.DisableAdditionalAttributes, true)()
|
||||||
|
|
||||||
test := func(input, expected string) {
|
test := func(input, expected string) {
|
||||||
|
|||||||
@@ -4,7 +4,6 @@
|
|||||||
package setting
|
package setting
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/base64"
|
|
||||||
"net"
|
"net"
|
||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
@@ -13,7 +12,6 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"code.gitea.io/gitea/modules/json"
|
|
||||||
"code.gitea.io/gitea/modules/log"
|
"code.gitea.io/gitea/modules/log"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -112,72 +110,9 @@ var (
|
|||||||
StartupTimeout time.Duration
|
StartupTimeout time.Duration
|
||||||
PerWriteTimeout = 30 * time.Second
|
PerWriteTimeout = 30 * time.Second
|
||||||
PerWritePerKbTimeout = 10 * time.Second
|
PerWritePerKbTimeout = 10 * time.Second
|
||||||
StaticURLPrefix string
|
StaticURLPrefix string // no trailing slash, defaults to AppSubURL, the URL can be relative or absolute
|
||||||
AbsoluteAssetURL string
|
|
||||||
|
|
||||||
ManifestData string
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// 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) {
|
func loadServerFrom(rootCfg ConfigProvider) {
|
||||||
sec := rootCfg.Section("server")
|
sec := rootCfg.Section("server")
|
||||||
AppName = rootCfg.Section("").Key("APP_NAME").MustString("Gitea: Git with a cup of tea")
|
AppName = rootCfg.Section("").Key("APP_NAME").MustString("Gitea: Git with a cup of tea")
|
||||||
@@ -313,10 +248,6 @@ func loadServerFrom(rootCfg ConfigProvider) {
|
|||||||
Domain = urlHostname
|
Domain = urlHostname
|
||||||
}
|
}
|
||||||
|
|
||||||
AbsoluteAssetURL = MakeAbsoluteAssetURL(appURL, StaticURLPrefix)
|
|
||||||
manifestBytes := MakeManifestData(AppName, AppURL, AbsoluteAssetURL)
|
|
||||||
ManifestData = `application/json;base64,` + base64.StdEncoding.EncodeToString(manifestBytes)
|
|
||||||
|
|
||||||
var defaultLocalURL string
|
var defaultLocalURL string
|
||||||
switch Protocol {
|
switch Protocol {
|
||||||
case HTTPUnix:
|
case HTTPUnix:
|
||||||
|
|||||||
@@ -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))
|
|
||||||
}
|
|
||||||
@@ -7,9 +7,12 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"path"
|
"path"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"code.gitea.io/gitea/modules/git"
|
"code.gitea.io/gitea/modules/git"
|
||||||
"code.gitea.io/gitea/modules/httpcache"
|
"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/log"
|
||||||
"code.gitea.io/gitea/modules/setting"
|
"code.gitea.io/gitea/modules/setting"
|
||||||
"code.gitea.io/gitea/modules/util"
|
"code.gitea.io/gitea/modules/util"
|
||||||
@@ -17,6 +20,29 @@ import (
|
|||||||
"code.gitea.io/gitea/services/context"
|
"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) {
|
func SSHInfo(rw http.ResponseWriter, req *http.Request) {
|
||||||
if !git.DefaultFeatures().SupportProcReceive {
|
if !git.DefaultFeatures().SupportProcReceive {
|
||||||
rw.WriteHeader(http.StatusNotFound)
|
rw.WriteHeader(http.StatusNotFound)
|
||||||
|
|||||||
@@ -260,6 +260,7 @@ func Routes() *web.Router {
|
|||||||
routes.BeforeRouting(chi_middleware.GetHead)
|
routes.BeforeRouting(chi_middleware.GetHead)
|
||||||
|
|
||||||
routes.Head("/", misc.DummyOK) // for health check - doesn't need to be passed through gzip handler
|
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, OPTIONS", "/assets/*", routing.MarkLogLevelTrace, public.AssetsCors(), public.FileHandlerFunc())
|
||||||
routes.Methods("GET, HEAD", "/avatars/*", avatarStorageHandler(setting.Avatar.Storage, "avatars", storage.Avatars))
|
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))
|
routes.Methods("GET, HEAD", "/repo-avatars/*", avatarStorageHandler(setting.RepoAvatar.Storage, "repo-avatars", storage.RepoAvatars))
|
||||||
|
|||||||
@@ -205,7 +205,6 @@ func Contexter() func(next http.Handler) http.Handler {
|
|||||||
ctx.Data["DisableStars"] = setting.Repository.DisableStars
|
ctx.Data["DisableStars"] = setting.Repository.DisableStars
|
||||||
ctx.Data["EnableActions"] = setting.Actions.Enabled && !unit.TypeActions.UnitGlobalDisabled()
|
ctx.Data["EnableActions"] = setting.Actions.Enabled && !unit.TypeActions.UnitGlobalDisabled()
|
||||||
|
|
||||||
ctx.Data["ManifestData"] = setting.ManifestData
|
|
||||||
ctx.Data["AllLangs"] = translation.AllLangs()
|
ctx.Data["AllLangs"] = translation.AllLangs()
|
||||||
|
|
||||||
next.ServeHTTP(ctx.Resp, ctx.Req)
|
next.ServeHTTP(ctx.Resp, ctx.Req)
|
||||||
|
|||||||
@@ -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
|
// * 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="` +
|
return template.HTML(`<meta http-equiv="Content-Security-Policy" content="` +
|
||||||
// allow all by default (the same as old releases with no CSP)
|
// 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 or markup (external) renders need "data:", need to investigate
|
||||||
// maybe some images are also loaded by "data:", need to investigate
|
|
||||||
`default-src * data:;` +
|
`default-src * data:;` +
|
||||||
|
|
||||||
// enforce nonce for all scripts, disallow inline scripts
|
// enforce nonce for all scripts, disallow inline scripts
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
{{ctx.HeadMetaContentSecurityPolicy}}
|
{{ctx.HeadMetaContentSecurityPolicy}}
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<title>{{if .Title}}{{.Title}} - {{end}}{{.PageTitleCommon}}</title>
|
<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="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="description" content="{{if .Repository}}{{.Repository.Name}}{{if .Repository.Description}} - {{.Repository.Description}}{{end}}{{else}}{{MetaDescription}}{{end}}">
|
||||||
<meta name="keywords" content="{{MetaKeywords}}">
|
<meta name="keywords" content="{{MetaKeywords}}">
|
||||||
|
|||||||
@@ -4,9 +4,12 @@
|
|||||||
package integration
|
package integration
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"code.gitea.io/gitea/modules/setting"
|
||||||
"code.gitea.io/gitea/tests"
|
"code.gitea.io/gitea/tests"
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
@@ -17,6 +20,7 @@ func TestView(t *testing.T) {
|
|||||||
t.Run("RenderFileSVGIsInImgTag", testRenderFileSVGIsInImgTag)
|
t.Run("RenderFileSVGIsInImgTag", testRenderFileSVGIsInImgTag)
|
||||||
t.Run("CommitListActions", testCommitListActions)
|
t.Run("CommitListActions", testCommitListActions)
|
||||||
t.Run("SecurityHeadersDefaults", testSecurityHeadersDefaults)
|
t.Run("SecurityHeadersDefaults", testSecurityHeadersDefaults)
|
||||||
|
t.Run("SiteManifest", testSiteManifest)
|
||||||
}
|
}
|
||||||
|
|
||||||
func testRenderFileSVGIsInImgTag(t *testing.T) {
|
func testRenderFileSVGIsInImgTag(t *testing.T) {
|
||||||
@@ -81,3 +85,31 @@ func testSecurityHeadersDefaults(t *testing.T) {
|
|||||||
assertSecurityHeaders(t, "/api/v1/version")
|
assertSecurityHeaders(t, "/api/v1/version")
|
||||||
assertSecurityHeaders(t, "/assets/img/favicon.png")
|
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())
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user