2
0

feat(pages): add static route configuration for direct file serving
Some checks failed
Build and Release / Create Release (push) Has been skipped
Build and Release / Unit Tests (push) Failing after 47s
Build and Release / Integration Tests (PostgreSQL) (push) Failing after 1m29s
Build and Release / Lint (push) Failing after 2m16s
Build and Release / Build Binaries (amd64, linux, linux-latest) (push) Has been skipped
Build and Release / Build Binaries (amd64, windows, windows-latest) (push) Has been skipped
Build and Release / Build Binaries (amd64, darwin, macos) (push) Has been skipped
Build and Release / Build Binaries (arm64, darwin, macos) (push) Has been skipped
Build and Release / Build Binary (linux/arm64) (push) Has been skipped

Allow repository pages to serve files directly at specific URL paths instead of rendering the landing page. Supports exact paths (/badge.svg) and glob patterns (/schema/*). Add advanced settings UI and API endpoints for managing static routes alongside existing redirects and custom code options.
This commit is contained in:
2026-03-30 02:07:41 -04:00
parent 48aab974fe
commit 46f7570d25
9 changed files with 294 additions and 6 deletions

View File

@@ -362,6 +362,7 @@ type AdvancedConfig struct {
CustomCSS string `yaml:"custom_css,omitempty" json:"custom_css,omitempty"` CustomCSS string `yaml:"custom_css,omitempty" json:"custom_css,omitempty"`
CustomHead string `yaml:"custom_head,omitempty" json:"custom_head,omitempty"` CustomHead string `yaml:"custom_head,omitempty" json:"custom_head,omitempty"`
Redirects map[string]string `yaml:"redirects,omitempty" json:"redirects,omitempty"` Redirects map[string]string `yaml:"redirects,omitempty" json:"redirects,omitempty"`
StaticRoutes []string `yaml:"static_routes,omitempty" json:"static_routes,omitempty"`
PublicReleases bool `yaml:"public_releases,omitempty" json:"public_releases,omitempty"` PublicReleases bool `yaml:"public_releases,omitempty" json:"public_releases,omitempty"`
HideMobileReleases bool `yaml:"hide_mobile_releases,omitempty" json:"hide_mobile_releases,omitempty"` HideMobileReleases bool `yaml:"hide_mobile_releases,omitempty" json:"hide_mobile_releases,omitempty"`
GooglePlayID string `yaml:"google_play_id,omitempty" json:"google_play_id,omitempty"` GooglePlayID string `yaml:"google_play_id,omitempty" json:"google_play_id,omitempty"`

View File

@@ -255,12 +255,13 @@ type UpdatePagesSEOOption struct {
// UpdatePagesAdvancedOption represents advanced settings update // UpdatePagesAdvancedOption represents advanced settings update
type UpdatePagesAdvancedOption struct { type UpdatePagesAdvancedOption struct {
CustomCSS *string `json:"custom_css"` CustomCSS *string `json:"custom_css"`
CustomHead *string `json:"custom_head"` CustomHead *string `json:"custom_head"`
PublicReleases *bool `json:"public_releases"` StaticRoutes *[]string `json:"static_routes"`
HideMobileReleases *bool `json:"hide_mobile_releases"` PublicReleases *bool `json:"public_releases"`
GooglePlayID *string `json:"google_play_id"` HideMobileReleases *bool `json:"hide_mobile_releases"`
AppStoreID *string `json:"app_store_id"` GooglePlayID *string `json:"google_play_id"`
AppStoreID *string `json:"app_store_id"`
} }
// UpdatePagesContentOption bundles content-page sections for PUT /config/content // UpdatePagesContentOption bundles content-page sections for PUT /config/content

View File

@@ -4640,6 +4640,18 @@
"repo.settings.pages.trans_footer_link": "Link Label", "repo.settings.pages.trans_footer_link": "Link Label",
"repo.settings.pages.trans_seo_title": "SEO Title", "repo.settings.pages.trans_seo_title": "SEO Title",
"repo.settings.pages.trans_seo_description": "SEO Description", "repo.settings.pages.trans_seo_description": "SEO Description",
"repo.settings.pages.advanced": "Advanced",
"repo.settings.pages.static_routes": "Static Routes",
"repo.settings.pages.static_routes_desc": "Serve files directly from your repository at specific URL paths instead of rendering the landing page. Use exact paths (/badge.svg) or glob patterns (/schema/*).",
"repo.settings.pages.add_route": "Add Route",
"repo.settings.pages.redirects": "Redirects",
"repo.settings.pages.redirects_desc": "Redirect specific paths to other URLs. The source path must start with /.",
"repo.settings.pages.add_redirect": "Add Redirect",
"repo.settings.pages.custom_code": "Custom Code",
"repo.settings.pages.custom_css": "Custom CSS",
"repo.settings.pages.custom_head": "Custom Head HTML",
"repo.settings.pages.app_stores": "App Stores",
"repo.settings.pages.hide_mobile_releases": "Hide Mobile Releases",
"repo.vault": "Vault", "repo.vault": "Vault",
"repo.vault.secrets": "Secrets", "repo.vault.secrets": "Secrets",
"repo.vault.new_secret": "New Secret", "repo.vault.new_secret": "New Secret",

View File

@@ -393,6 +393,9 @@ func applyAdvanced(dst *pages_module.AdvancedConfig, src *api.UpdatePagesAdvance
if src.CustomHead != nil { if src.CustomHead != nil {
dst.CustomHead = *src.CustomHead dst.CustomHead = *src.CustomHead
} }
if src.StaticRoutes != nil {
dst.StaticRoutes = *src.StaticRoutes
}
if src.PublicReleases != nil { if src.PublicReleases != nil {
dst.PublicReleases = *src.PublicReleases dst.PublicReleases = *src.PublicReleases
} }

View File

@@ -76,6 +76,15 @@ func ServeLandingPage(ctx *context.Context) {
} }
} }
// Check for static route file serving
if len(config.Advanced.StaticRoutes) > 0 {
cleanPath := path.Clean(requestPath)
if matchesStaticRoute(cleanPath, config.Advanced.StaticRoutes) {
serveStaticRouteFile(ctx, repo, cleanPath)
return
}
}
// Handle event tracking POST // Handle event tracking POST
if ctx.Req.Method == http.MethodPost && (requestPath == "/pages/events" || requestPath == "/pages/events/") { if ctx.Req.Method == http.MethodPost && (requestPath == "/pages/events" || requestPath == "/pages/events/") {
servePageEvent(ctx, repo) servePageEvent(ctx, repo)
@@ -593,6 +602,89 @@ func serveCustomDomainAsset(ctx *context.Context, repo *repo_model.Repository, a
serveRepoFileAsset(ctx, commit, assetPath) serveRepoFileAsset(ctx, commit, assetPath)
} }
// matchesStaticRoute checks if a request path matches any configured static route pattern.
// Supports exact matches (/badge.svg) and glob patterns (/schema/*).
func matchesStaticRoute(requestPath string, routes []string) bool {
for _, route := range routes {
if route == requestPath {
return true
}
// Handle glob patterns like /schema/*
if matched, _ := path.Match(route, requestPath); matched {
return true
}
// Handle prefix patterns: /schema/* should match /schema/sub/deep.json
if strings.HasSuffix(route, "/*") {
prefix := strings.TrimSuffix(route, "*")
if strings.HasPrefix(requestPath, prefix) {
return true
}
}
}
return false
}
// serveStaticRouteFile serves a file directly from the repo tree for static route matches.
// The request path maps directly to the repo root (e.g., /schema/v0.1.json → schema/v0.1.json).
func serveStaticRouteFile(ctx *context.Context, repo *repo_model.Repository, requestPath string) {
// Strip leading slash to get repo-relative path
repoPath := strings.TrimPrefix(requestPath, "/")
if repoPath == "" {
ctx.NotFound(errors.New("empty static route path"))
return
}
// Security: never serve .gitea config files
if strings.HasPrefix(repoPath, ".gitea/") || strings.HasPrefix(repoPath, ".gitea\\") {
ctx.NotFound(errors.New("config files are not served"))
return
}
gitRepo, err := git.OpenRepository(ctx, repo.RepoPath())
if err != nil {
ctx.NotFound(err)
return
}
defer gitRepo.Close()
branch := repo.DefaultBranch
if branch == "" {
branch = "main"
}
commit, err := gitRepo.GetBranchCommit(branch)
if err != nil {
ctx.NotFound(err)
return
}
entry, err := commit.GetTreeEntryByPath(repoPath)
if err != nil {
ctx.NotFound(err)
return
}
// Only serve blobs, not trees
if !entry.IsRegular() {
ctx.NotFound(errors.New("path is not a file"))
return
}
reader, err := entry.Blob().DataAsync()
if err != nil {
ctx.ServerError("Failed to read static route file", err)
return
}
defer reader.Close()
ext := path.Ext(repoPath)
contentType := getContentType(ext)
ctx.Resp.Header().Set("Content-Type", contentType)
ctx.Resp.Header().Set("Cache-Control", "public, max-age=3600")
ctx.Resp.Header().Set("Content-Length", strconv.FormatInt(entry.Blob().Size(), 10))
_, _ = io.Copy(ctx.Resp, reader)
}
// serveSocialPreview generates and serves the social card image for a repo // serveSocialPreview generates and serves the social card image for a repo
// on custom domain / subdomain requests. // on custom domain / subdomain requests.
func serveSocialPreview(ctx *context.Context, repo *repo_model.Repository) { func serveSocialPreview(ctx *context.Context, repo *repo_model.Repository) {

View File

@@ -37,6 +37,7 @@ const (
tplRepoSettingsPagesFooter templates.TplName = "repo/settings/pages_footer" tplRepoSettingsPagesFooter templates.TplName = "repo/settings/pages_footer"
tplRepoSettingsPagesTheme templates.TplName = "repo/settings/pages_theme" tplRepoSettingsPagesTheme templates.TplName = "repo/settings/pages_theme"
tplRepoSettingsPagesLanguages templates.TplName = "repo/settings/pages_languages" tplRepoSettingsPagesLanguages templates.TplName = "repo/settings/pages_languages"
tplRepoSettingsPagesAdvanced templates.TplName = "repo/settings/pages_advanced"
) )
// getPagesLandingConfig loads the landing page configuration // getPagesLandingConfig loads the landing page configuration
@@ -1479,3 +1480,68 @@ func loadRawReadme(ctx *context.Context, repo *repo_model.Repository) string {
} }
return "" return ""
} }
// PagesAdvanced renders the advanced settings page
func PagesAdvanced(ctx *context.Context) {
ctx.Data["Title"] = ctx.Tr("repo.settings.pages.advanced")
ctx.Data["PageIsSettingsPages"] = true
ctx.Data["PageIsSettingsPagesAdvanced"] = true
setCommonPagesData(ctx)
ctx.HTML(http.StatusOK, tplRepoSettingsPagesAdvanced)
}
// PagesAdvancedPost handles the advanced settings form submission
func PagesAdvancedPost(ctx *context.Context) {
config := getPagesLandingConfig(ctx)
// Parse static routes
var routes []string
for i := range 100 {
route := strings.TrimSpace(ctx.FormString(fmt.Sprintf("static_route_%d", i)))
if route != "" {
routes = append(routes, route)
}
}
config.Advanced.StaticRoutes = routes
// Parse redirects
redirects := make(map[string]string)
for i := range 100 {
from := strings.TrimSpace(ctx.FormString(fmt.Sprintf("redirect_from_%d", i)))
to := strings.TrimSpace(ctx.FormString(fmt.Sprintf("redirect_to_%d", i)))
if from != "" && to != "" {
redirects[from] = to
}
}
// Also handle redirects keyed by path (from existing entries)
for key, vals := range ctx.Req.Form {
if strings.HasPrefix(key, "redirect_from_/") {
from := strings.TrimSpace(vals[0])
toKey := "redirect_to_" + strings.TrimPrefix(key, "redirect_from_")
to := strings.TrimSpace(ctx.Req.FormValue(toKey))
if from != "" && to != "" {
redirects[from] = to
}
}
}
if len(redirects) > 0 {
config.Advanced.Redirects = redirects
} else {
config.Advanced.Redirects = nil
}
// Parse remaining fields
config.Advanced.CustomCSS = ctx.FormString("custom_css")
config.Advanced.CustomHead = ctx.FormString("custom_head")
config.Advanced.GooglePlayID = ctx.FormString("google_play_id")
config.Advanced.AppStoreID = ctx.FormString("app_store_id")
config.Advanced.PublicReleases = ctx.FormBool("public_releases")
config.Advanced.HideMobileReleases = ctx.FormBool("hide_mobile_releases")
if err := savePagesLandingConfig(ctx, config); err != nil {
ctx.ServerError("SavePagesConfig", err)
return
}
ctx.Flash.Success(ctx.Tr("repo.settings.pages.saved"))
ctx.Redirect(ctx.Repo.Repository.Link() + "/settings/pages/advanced")
}

View File

@@ -1347,6 +1347,7 @@ func registerWebRoutes(m *web.Router) {
m.Combo("/footer").Get(repo_setting.PagesFooter).Post(repo_setting.PagesFooterPost) m.Combo("/footer").Get(repo_setting.PagesFooter).Post(repo_setting.PagesFooterPost)
m.Combo("/theme").Get(repo_setting.PagesTheme).Post(repo_setting.PagesThemePost) m.Combo("/theme").Get(repo_setting.PagesTheme).Post(repo_setting.PagesThemePost)
m.Combo("/languages").Get(repo_setting.PagesLanguages).Post(repo_setting.PagesLanguagesPost) m.Combo("/languages").Get(repo_setting.PagesLanguages).Post(repo_setting.PagesLanguagesPost)
m.Combo("/advanced").Get(repo_setting.PagesAdvanced).Post(repo_setting.PagesAdvancedPost)
}) })
m.Group("/actions/general", func() { m.Group("/actions/general", func() {
m.Get("", repo_setting.ActionsGeneralSettings) m.Get("", repo_setting.ActionsGeneralSettings)

View File

@@ -0,0 +1,109 @@
{{template "repo/settings/layout_head" (dict "ctxData" . "pageClass" "repository settings pages")}}
<div class="user-main-content twelve wide column">
<h4 class="ui top attached header">{{ctx.Locale.Tr "repo.settings.pages.advanced"}}</h4>
<div class="ui attached segment">
<form class="ui form" method="post">
{{.CsrfTokenHtml}}
<h5 class="ui dividing header">{{ctx.Locale.Tr "repo.settings.pages.static_routes"}}</h5>
<p class="help">{{ctx.Locale.Tr "repo.settings.pages.static_routes_desc"}}</p>
<div id="routes-container">
{{range $i, $route := .Config.Advanced.StaticRoutes}}
<div class="field route-item tw-flex tw-items-center tw-gap-2">
<input name="static_route_{{$i}}" value="{{$route}}" placeholder="/schema/*" class="tw-flex-1">
<button type="button" class="ui red mini icon button" onclick="this.closest('.route-item').remove()">{{svg "octicon-trash" 14}}</button>
</div>
{{end}}
{{if not .Config.Advanced.StaticRoutes}}
<div class="field route-item tw-flex tw-items-center tw-gap-2">
<input name="static_route_0" placeholder="/schema/*" class="tw-flex-1">
<button type="button" class="ui red mini icon button" onclick="this.closest('.route-item').remove()">{{svg "octicon-trash" 14}}</button>
</div>
{{end}}
</div>
<button type="button" class="ui mini button tw-mb-4" onclick="addRoute()">+ {{ctx.Locale.Tr "repo.settings.pages.add_route"}}</button>
<h5 class="ui dividing header">{{ctx.Locale.Tr "repo.settings.pages.redirects"}}</h5>
<p class="help">{{ctx.Locale.Tr "repo.settings.pages.redirects_desc"}}</p>
<div id="redirects-container">
{{range $path, $target := .Config.Advanced.Redirects}}
<div class="two fields redirect-item">
<div class="field"><input name="redirect_from_{{$path}}" value="{{$path}}" placeholder="/old-path"></div>
<div class="field tw-flex tw-items-center tw-gap-2">
<input name="redirect_to_{{$path}}" value="{{$target}}" placeholder="https://new-url" class="tw-flex-1">
<button type="button" class="ui red mini icon button" onclick="this.closest('.redirect-item').remove()">{{svg "octicon-trash" 14}}</button>
</div>
</div>
{{end}}
{{if not .Config.Advanced.Redirects}}
<div class="two fields redirect-item">
<div class="field"><input name="redirect_from_0" placeholder="/old-path"></div>
<div class="field tw-flex tw-items-center tw-gap-2">
<input name="redirect_to_0" placeholder="https://new-url" class="tw-flex-1">
<button type="button" class="ui red mini icon button" onclick="this.closest('.redirect-item').remove()">{{svg "octicon-trash" 14}}</button>
</div>
</div>
{{end}}
</div>
<button type="button" class="ui mini button tw-mb-4" onclick="addRedirect()">+ {{ctx.Locale.Tr "repo.settings.pages.add_redirect"}}</button>
<h5 class="ui dividing header">{{ctx.Locale.Tr "repo.settings.pages.custom_code"}}</h5>
<div class="field">
<label>{{ctx.Locale.Tr "repo.settings.pages.custom_css"}}</label>
<textarea name="custom_css" rows="4" placeholder="body { }">{{.Config.Advanced.CustomCSS}}</textarea>
</div>
<div class="field">
<label>{{ctx.Locale.Tr "repo.settings.pages.custom_head"}}</label>
<textarea name="custom_head" rows="4" placeholder="<meta ...>">{{.Config.Advanced.CustomHead}}</textarea>
</div>
<h5 class="ui dividing header">{{ctx.Locale.Tr "repo.settings.pages.app_stores"}}</h5>
<div class="two fields">
<div class="field">
<label>Google Play ID</label>
<input name="google_play_id" value="{{.Config.Advanced.GooglePlayID}}" placeholder="com.example.app">
</div>
<div class="field">
<label>App Store ID</label>
<input name="app_store_id" value="{{.Config.Advanced.AppStoreID}}" placeholder="123456789">
</div>
</div>
<div class="inline fields">
<div class="field">
<div class="ui checkbox">
<input type="checkbox" name="public_releases" {{if .Config.Advanced.PublicReleases}}checked{{end}}>
<label>{{ctx.Locale.Tr "repo.settings.pages.public_releases"}}</label>
</div>
</div>
<div class="field">
<div class="ui checkbox">
<input type="checkbox" name="hide_mobile_releases" {{if .Config.Advanced.HideMobileReleases}}checked{{end}}>
<label>{{ctx.Locale.Tr "repo.settings.pages.hide_mobile_releases"}}</label>
</div>
</div>
</div>
<div class="field">
<button class="ui primary button">{{ctx.Locale.Tr "save"}}</button>
</div>
</form>
</div>
</div>
<script>
let routeCount = {{len .Config.Advanced.StaticRoutes}};
let redirectCount = {{len .Config.Advanced.Redirects}};
if (routeCount === 0) routeCount = 1;
if (redirectCount === 0) redirectCount = 1;
function addRoute() {
const c = document.getElementById('routes-container');
c.insertAdjacentHTML('beforeend', `<div class="field route-item tw-flex tw-items-center tw-gap-2"><input name="static_route_${routeCount}" placeholder="/schema/*" class="tw-flex-1"><button type="button" class="ui red mini icon button" onclick="this.closest('.route-item').remove()">{{svg "octicon-trash" 14}}</button></div>`);
routeCount++;
}
function addRedirect() {
const c = document.getElementById('redirects-container');
c.insertAdjacentHTML('beforeend', `<div class="two fields redirect-item"><div class="field"><input name="redirect_from_${redirectCount}" placeholder="/old-path"></div><div class="field tw-flex tw-items-center tw-gap-2"><input name="redirect_to_${redirectCount}" placeholder="https://new-url" class="tw-flex-1"><button type="button" class="ui red mini icon button" onclick="this.closest('.redirect-item').remove()">{{svg "octicon-trash" 14}}</button></div></div>`);
redirectCount++;
}
</script>
{{template "repo/settings/layout_footer" .}}

View File

@@ -30,5 +30,8 @@
<a class="{{if .PageIsSettingsPagesLanguages}}active {{end}}item" href="{{.RepoLink}}/settings/pages/languages"> <a class="{{if .PageIsSettingsPagesLanguages}}active {{end}}item" href="{{.RepoLink}}/settings/pages/languages">
{{ctx.Locale.Tr "repo.settings.pages.languages"}} {{ctx.Locale.Tr "repo.settings.pages.languages"}}
</a> </a>
<a class="{{if .PageIsSettingsPagesAdvanced}}active {{end}}item" href="{{.RepoLink}}/settings/pages/advanced">
{{ctx.Locale.Tr "repo.settings.pages.advanced"}}
</a>
</div> </div>
{{end}} {{end}}