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
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:
@@ -362,6 +362,7 @@ type AdvancedConfig struct {
|
||||
CustomCSS string `yaml:"custom_css,omitempty" json:"custom_css,omitempty"`
|
||||
CustomHead string `yaml:"custom_head,omitempty" json:"custom_head,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"`
|
||||
HideMobileReleases bool `yaml:"hide_mobile_releases,omitempty" json:"hide_mobile_releases,omitempty"`
|
||||
GooglePlayID string `yaml:"google_play_id,omitempty" json:"google_play_id,omitempty"`
|
||||
|
||||
@@ -255,12 +255,13 @@ type UpdatePagesSEOOption struct {
|
||||
|
||||
// UpdatePagesAdvancedOption represents advanced settings update
|
||||
type UpdatePagesAdvancedOption struct {
|
||||
CustomCSS *string `json:"custom_css"`
|
||||
CustomHead *string `json:"custom_head"`
|
||||
PublicReleases *bool `json:"public_releases"`
|
||||
HideMobileReleases *bool `json:"hide_mobile_releases"`
|
||||
GooglePlayID *string `json:"google_play_id"`
|
||||
AppStoreID *string `json:"app_store_id"`
|
||||
CustomCSS *string `json:"custom_css"`
|
||||
CustomHead *string `json:"custom_head"`
|
||||
StaticRoutes *[]string `json:"static_routes"`
|
||||
PublicReleases *bool `json:"public_releases"`
|
||||
HideMobileReleases *bool `json:"hide_mobile_releases"`
|
||||
GooglePlayID *string `json:"google_play_id"`
|
||||
AppStoreID *string `json:"app_store_id"`
|
||||
}
|
||||
|
||||
// UpdatePagesContentOption bundles content-page sections for PUT /config/content
|
||||
|
||||
@@ -4640,6 +4640,18 @@
|
||||
"repo.settings.pages.trans_footer_link": "Link Label",
|
||||
"repo.settings.pages.trans_seo_title": "SEO Title",
|
||||
"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.secrets": "Secrets",
|
||||
"repo.vault.new_secret": "New Secret",
|
||||
|
||||
@@ -393,6 +393,9 @@ func applyAdvanced(dst *pages_module.AdvancedConfig, src *api.UpdatePagesAdvance
|
||||
if src.CustomHead != nil {
|
||||
dst.CustomHead = *src.CustomHead
|
||||
}
|
||||
if src.StaticRoutes != nil {
|
||||
dst.StaticRoutes = *src.StaticRoutes
|
||||
}
|
||||
if src.PublicReleases != nil {
|
||||
dst.PublicReleases = *src.PublicReleases
|
||||
}
|
||||
|
||||
@@ -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
|
||||
if ctx.Req.Method == http.MethodPost && (requestPath == "/pages/events" || requestPath == "/pages/events/") {
|
||||
servePageEvent(ctx, repo)
|
||||
@@ -593,6 +602,89 @@ func serveCustomDomainAsset(ctx *context.Context, repo *repo_model.Repository, a
|
||||
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
|
||||
// on custom domain / subdomain requests.
|
||||
func serveSocialPreview(ctx *context.Context, repo *repo_model.Repository) {
|
||||
|
||||
@@ -37,6 +37,7 @@ const (
|
||||
tplRepoSettingsPagesFooter templates.TplName = "repo/settings/pages_footer"
|
||||
tplRepoSettingsPagesTheme templates.TplName = "repo/settings/pages_theme"
|
||||
tplRepoSettingsPagesLanguages templates.TplName = "repo/settings/pages_languages"
|
||||
tplRepoSettingsPagesAdvanced templates.TplName = "repo/settings/pages_advanced"
|
||||
)
|
||||
|
||||
// getPagesLandingConfig loads the landing page configuration
|
||||
@@ -1479,3 +1480,68 @@ func loadRawReadme(ctx *context.Context, repo *repo_model.Repository) string {
|
||||
}
|
||||
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")
|
||||
}
|
||||
|
||||
@@ -1347,6 +1347,7 @@ func registerWebRoutes(m *web.Router) {
|
||||
m.Combo("/footer").Get(repo_setting.PagesFooter).Post(repo_setting.PagesFooterPost)
|
||||
m.Combo("/theme").Get(repo_setting.PagesTheme).Post(repo_setting.PagesThemePost)
|
||||
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.Get("", repo_setting.ActionsGeneralSettings)
|
||||
|
||||
109
templates/repo/settings/pages_advanced.tmpl
Normal file
109
templates/repo/settings/pages_advanced.tmpl
Normal 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" .}}
|
||||
@@ -30,5 +30,8 @@
|
||||
<a class="{{if .PageIsSettingsPagesLanguages}}active {{end}}item" href="{{.RepoLink}}/settings/pages/languages">
|
||||
{{ctx.Locale.Tr "repo.settings.pages.languages"}}
|
||||
</a>
|
||||
<a class="{{if .PageIsSettingsPagesAdvanced}}active {{end}}item" href="{{.RepoLink}}/settings/pages/advanced">
|
||||
{{ctx.Locale.Tr "repo.settings.pages.advanced"}}
|
||||
</a>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
Reference in New Issue
Block a user