diff --git a/modules/pages/config.go b/modules/pages/config.go index 58896fd4da..fd21d7de33 100644 --- a/modules/pages/config.go +++ b/modules/pages/config.go @@ -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"` diff --git a/modules/structs/repo_pages.go b/modules/structs/repo_pages.go index fa96ef2800..47e27ff1ee 100644 --- a/modules/structs/repo_pages.go +++ b/modules/structs/repo_pages.go @@ -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 diff --git a/options/locale/locale_en-US.json b/options/locale/locale_en-US.json index a985868bbb..b5c4168d52 100644 --- a/options/locale/locale_en-US.json +++ b/options/locale/locale_en-US.json @@ -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", diff --git a/routers/api/v2/pages_api.go b/routers/api/v2/pages_api.go index 260682274d..445a2f75cf 100644 --- a/routers/api/v2/pages_api.go +++ b/routers/api/v2/pages_api.go @@ -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 } diff --git a/routers/web/pages/pages.go b/routers/web/pages/pages.go index 095d3c3f3a..73e49d9d10 100644 --- a/routers/web/pages/pages.go +++ b/routers/web/pages/pages.go @@ -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) { diff --git a/routers/web/repo/setting/pages.go b/routers/web/repo/setting/pages.go index 9df7c1901e..a29f78f673 100644 --- a/routers/web/repo/setting/pages.go +++ b/routers/web/repo/setting/pages.go @@ -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") +} diff --git a/routers/web/web.go b/routers/web/web.go index 420f47cb4f..90e12eca8e 100644 --- a/routers/web/web.go +++ b/routers/web/web.go @@ -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) diff --git a/templates/repo/settings/pages_advanced.tmpl b/templates/repo/settings/pages_advanced.tmpl new file mode 100644 index 0000000000..5aa5ff1b51 --- /dev/null +++ b/templates/repo/settings/pages_advanced.tmpl @@ -0,0 +1,109 @@ +{{template "repo/settings/layout_head" (dict "ctxData" . "pageClass" "repository settings pages")}} +
+

{{ctx.Locale.Tr "repo.settings.pages.advanced"}}

+
+
+ {{.CsrfTokenHtml}} + +
{{ctx.Locale.Tr "repo.settings.pages.static_routes"}}
+

{{ctx.Locale.Tr "repo.settings.pages.static_routes_desc"}}

+
+ {{range $i, $route := .Config.Advanced.StaticRoutes}} +
+ + +
+ {{end}} + {{if not .Config.Advanced.StaticRoutes}} +
+ + +
+ {{end}} +
+ + +
{{ctx.Locale.Tr "repo.settings.pages.redirects"}}
+

{{ctx.Locale.Tr "repo.settings.pages.redirects_desc"}}

+
+ {{range $path, $target := .Config.Advanced.Redirects}} +
+
+
+ + +
+
+ {{end}} + {{if not .Config.Advanced.Redirects}} +
+
+
+ + +
+
+ {{end}} +
+ + +
{{ctx.Locale.Tr "repo.settings.pages.custom_code"}}
+
+ + +
+
+ + +
+ +
{{ctx.Locale.Tr "repo.settings.pages.app_stores"}}
+
+
+ + +
+
+ + +
+
+ +
+
+
+ + +
+
+
+
+ + +
+
+
+ +
+ +
+
+
+
+ +{{template "repo/settings/layout_footer" .}} diff --git a/templates/repo/settings/pages_nav.tmpl b/templates/repo/settings/pages_nav.tmpl index 67b21f8928..b8e200e2cc 100644 --- a/templates/repo/settings/pages_nav.tmpl +++ b/templates/repo/settings/pages_nav.tmpl @@ -30,5 +30,8 @@ {{ctx.Locale.Tr "repo.settings.pages.languages"}} + + {{ctx.Locale.Tr "repo.settings.pages.advanced"}} + {{end}}