From 37d59e7b59921a851d892b14f6334d9dabced2a9 Mon Sep 17 00:00:00 2001 From: Jonathan Miller Date: Sun, 7 Jun 2026 11:07:30 -0500 Subject: [PATCH] feat(cdn): built-in CDN for release asset delivery (#561) Add CDN system that serves release assets via a dedicated hostname (e.g., cdn.mokoconsulting.tech) with per-asset public/private toggles, IP/referrer allowlists, and aggressive caching headers. - Host-based routing intercepts CDN domain before auth middleware - Per-attachment cdn_public flag controls CDN visibility - Releases in an update stream are excluded from CDN (update server takes precedence) - CORS, ETag, Cache-Control headers for downstream CDN compatibility - IP/CIDR and referrer domain allowlists for abuse prevention --- models/migrations/migrations.go | 1 + models/migrations/v1_27/v352.go | 14 ++ models/repo/attachment.go | 1 + modules/setting/cdn.go | 34 ++++ modules/setting/setting.go | 1 + options/locale/locale_en-US.json | 4 +- routers/web/repo/cdn.go | 278 +++++++++++++++++++++++++++++++ routers/web/repo/release.go | 27 ++- routers/web/web.go | 14 ++ templates/repo/release/new.tmpl | 6 + 10 files changed, 378 insertions(+), 2 deletions(-) create mode 100644 models/migrations/v1_27/v352.go create mode 100644 modules/setting/cdn.go create mode 100644 routers/web/repo/cdn.go diff --git a/models/migrations/migrations.go b/models/migrations/migrations.go index 83b3b8ee04..894487b449 100644 --- a/models/migrations/migrations.go +++ b/models/migrations/migrations.go @@ -428,6 +428,7 @@ func prepareMigrationTasks() []*migration { newMigration(348, "Add issue priority definitions table", v1_27.AddIssuePriorityDefTable), newMigration(349, "Add security scanning tables", v1_27.AddSecurityScanningTables), newMigration(350, "Add issue type definitions table", v1_27.AddIssueTypeDefTable), + newMigration(351, "Add CDN public flag to attachments", v1_27.AddAttachmentCDNPublic), } return preparedMigrations } diff --git a/models/migrations/v1_27/v352.go b/models/migrations/v1_27/v352.go new file mode 100644 index 0000000000..57209388e0 --- /dev/null +++ b/models/migrations/v1_27/v352.go @@ -0,0 +1,14 @@ +// Copyright 2026 Moko Consulting +// SPDX-License-Identifier: GPL-3.0-or-later + +package v1_27 + +import "xorm.io/xorm" + +// AddAttachmentCDNPublic adds the cdn_public column to the attachment table. +func AddAttachmentCDNPublic(x *xorm.Engine) error { + type Attachment struct { + CDNPublic bool `xorm:"NOT NULL DEFAULT false 'cdn_public'"` + } + return x.Sync(new(Attachment)) +} diff --git a/models/repo/attachment.go b/models/repo/attachment.go index b2821a9d7e..5e6b24f0b6 100644 --- a/models/repo/attachment.go +++ b/models/repo/attachment.go @@ -31,6 +31,7 @@ type Attachment struct { Name string DownloadCount int64 `xorm:"DEFAULT 0"` Size int64 `xorm:"DEFAULT 0"` + CDNPublic bool `xorm:"NOT NULL DEFAULT false 'cdn_public'"` CreatedUnix timeutil.TimeStamp `xorm:"created"` CustomDownloadURL string `xorm:"-"` } diff --git a/modules/setting/cdn.go b/modules/setting/cdn.go new file mode 100644 index 0000000000..6dcbc05a9f --- /dev/null +++ b/modules/setting/cdn.go @@ -0,0 +1,34 @@ +// Copyright 2026 Moko Consulting +// SPDX-License-Identifier: GPL-3.0-or-later + +package setting + +import "time" + +// CDN holds configuration for the built-in CDN asset delivery system. +var CDN = struct { + Enabled bool + Domain string // e.g. "cdn.mokoconsulting.tech" + CacheTTL time.Duration // Cache-Control max-age for CDN responses + AllowedOrigins []string // CORS origins allowed to fetch CDN assets + AllowedIPs []string // IP/CIDR allowlist (empty = allow all) + AllowedDomains []string // Referrer domain allowlist (empty = allow all) + MaxFileSize int64 // max file size to serve (bytes) +}{ + Enabled: false, + Domain: "", + CacheTTL: 24 * time.Hour, + MaxFileSize: 100 * 1024 * 1024, // 100MB +} + +func loadCDNFrom(cfg ConfigProvider) { + sec := cfg.Section("cdn") + CDN.Enabled = sec.Key("ENABLED").MustBool(false) + CDN.Domain = sec.Key("DOMAIN").String() + CDN.CacheTTL = sec.Key("CACHE_TTL").MustDuration(CDN.CacheTTL) + CDN.MaxFileSize = sec.Key("MAX_FILE_SIZE").MustInt64(CDN.MaxFileSize) + + CDN.AllowedOrigins = sec.Key("ALLOWED_ORIGINS").Strings(",") + CDN.AllowedIPs = sec.Key("ALLOWED_IPS").Strings(",") + CDN.AllowedDomains = sec.Key("ALLOWED_DOMAINS").Strings(",") +} diff --git a/modules/setting/setting.go b/modules/setting/setting.go index b097b7e95c..11d8d37ce4 100644 --- a/modules/setting/setting.go +++ b/modules/setting/setting.go @@ -178,6 +178,7 @@ func loadCommonSettingsFrom(cfg ConfigProvider) error { loadOtherFrom(cfg) loadUpdateCheckerFrom(cfg) loadNtfyFrom(cfg) + loadCDNFrom(cfg) loadLoginNotificationFrom(cfg) return nil } diff --git a/options/locale/locale_en-US.json b/options/locale/locale_en-US.json index 9e5aef5955..7d77e6716c 100644 --- a/options/locale/locale_en-US.json +++ b/options/locale/locale_en-US.json @@ -2734,7 +2734,7 @@ "repo.settings.support_url_help": "Shown when downloads are gated. Can point to your wiki, product page, or external support site.", "repo.settings.custom_fields": "Custom Fields", "repo.settings.manifest": "Manifest", - "repo.settings.manifest_desc": "Project identity, governance, and build settings from the moko-platform manifest. These are accessible via API for Actions workflows and the moko-platform CLI.", + "repo.settings.manifest_desc": "Project identity, governance, and build settings from the mokoplatform manifest. These are accessible via API for Actions workflows and the mokoplatform CLI.", "repo.settings.manifest_identity": "Identity", "repo.settings.manifest_name": "Project Name", "repo.settings.manifest_org": "Organization", @@ -2831,6 +2831,8 @@ "repo.release.message": "Describe this release", "repo.release.prerelease_desc": "Mark as Pre-Release", "repo.release.prerelease_helper": "Mark this release unsuitable for production use.", + "repo.release.cdn_public": "CDN", + "repo.release.cdn_public_tooltip": "Make this asset available via the CDN. Disabled when the release is assigned to an update stream.", "repo.release.cancel": "Cancel", "repo.release.publish": "Publish Release", "repo.release.save_draft": "Save Draft", diff --git a/routers/web/repo/cdn.go b/routers/web/repo/cdn.go new file mode 100644 index 0000000000..904e99ffb9 --- /dev/null +++ b/routers/web/repo/cdn.go @@ -0,0 +1,278 @@ +// Copyright 2026 Moko Consulting +// SPDX-License-Identifier: GPL-3.0-or-later + +package repo + +import ( + "fmt" + "net" + "net/http" + "strings" + + licenses_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/licenses" + repo_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/repo" + "code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/httplib" + "code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/log" + "code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/setting" + "code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/storage" +) + +// CDNHandler serves release assets via the CDN hostname. +// URL format: /:owner/:repo/releases/:tag/:filename +// Only assets with cdn_public=true are served. +func CDNHandler(w http.ResponseWriter, req *http.Request) { + if !setting.CDN.Enabled { + http.NotFound(w, req) + return + } + + if !cdnCheckIPAllowed(req) { + http.Error(w, "Forbidden", http.StatusForbidden) + return + } + + if !cdnCheckReferrerAllowed(req) { + http.Error(w, "Forbidden: referrer not allowed", http.StatusForbidden) + return + } + + // Parse: /:owner/:repo/releases/:tag/:filename + urlPath := strings.TrimPrefix(req.URL.Path, "/") + parts := strings.SplitN(urlPath, "/", 6) + + // Minimum: owner/repo/releases/tag/filename = 5 parts + if len(parts) < 5 || parts[2] != "releases" { + http.Error(w, "Not Found: expected /:owner/:repo/releases/:tag/:filename", http.StatusNotFound) + return + } + + ownerName := parts[0] + repoName := parts[1] + tagName := parts[3] + fileName := parts[4] + // Allow filenames with slashes (parts[5] if present) + if len(parts) == 6 { + fileName = parts[4] + "/" + parts[5] + } + + // Load repository + repo, err := repo_model.GetRepositoryByOwnerAndName(req.Context(), ownerName, repoName) + if err != nil { + if repo_model.IsErrRepoNotExist(err) { + http.NotFound(w, req) + } else { + log.Error("CDN: GetRepositoryByOwnerAndName %s/%s: %v", ownerName, repoName, err) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + } + return + } + + // Look up the release by tag + release, err := repo_model.GetRelease(req.Context(), repo.ID, tagName) + if err != nil { + if repo_model.IsErrReleaseNotExist(err) { + http.NotFound(w, req) + } else { + log.Error("CDN: GetRelease %s/%s tag=%s: %v", ownerName, repoName, tagName, err) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + } + return + } + + // Don't serve draft releases via CDN + if release.IsDraft { + http.NotFound(w, req) + return + } + + // If the release is assigned to an update stream, CDN is disabled - + // the update server handles distribution for streamed releases. + if stream := licenses_model.GetReleaseStream(req.Context(), release.ID); stream != "" { + http.Error(w, "Forbidden: release is served via update stream", http.StatusForbidden) + return + } + + // Find the specific attachment by filename + attach, err := repo_model.GetAttachmentByReleaseIDFileName(req.Context(), release.ID, fileName) + if err != nil || attach == nil { + http.NotFound(w, req) + return + } + + // Only serve assets marked as CDN public + if !attach.CDNPublic { + http.Error(w, "Forbidden: asset is not CDN-enabled", http.StatusForbidden) + return + } + + // Check file size limit + if setting.CDN.MaxFileSize > 0 && attach.Size > setting.CDN.MaxFileSize { + http.Error(w, "File too large for CDN delivery", http.StatusRequestEntityTooLarge) + return + } + + // CORS headers + if len(setting.CDN.AllowedOrigins) > 0 { + origin := req.Header.Get("Origin") + for _, allowed := range setting.CDN.AllowedOrigins { + if allowed == "*" || allowed == origin { + w.Header().Set("Access-Control-Allow-Origin", allowed) + break + } + } + } else { + w.Header().Set("Access-Control-Allow-Origin", "*") + } + w.Header().Set("Access-Control-Allow-Methods", "GET, HEAD, OPTIONS") + w.Header().Set("Access-Control-Max-Age", "86400") + + if req.Method == http.MethodOptions { + w.WriteHeader(http.StatusNoContent) + return + } + + // ETag based on attachment UUID (immutable for same content) + etag := `"` + attach.UUID + `"` + w.Header().Set("Etag", etag) + + // 304 Not Modified check + if inm := req.Header.Get("If-None-Match"); inm != "" { + for item := range strings.SplitSeq(inm, ",") { + item = strings.TrimPrefix(strings.TrimSpace(item), "W/") + if item == etag { + w.WriteHeader(http.StatusNotModified) + return + } + } + } + + // Last-Modified + lastModified := attach.CreatedUnix.AsTimePtr() + if lastModified != nil { + w.Header().Set("Last-Modified", lastModified.UTC().Format(http.TimeFormat)) + } + + // CDN cache headers + cacheTTL := int(setting.CDN.CacheTTL.Seconds()) + w.Header().Set("Cache-Control", fmt.Sprintf("public, max-age=%d, no-transform", cacheTTL)) + + // Increment download count + if err := attach.IncreaseDownloadCount(req.Context()); err != nil { + log.Error("CDN: IncreaseDownloadCount: %v", err) + } + + // Try direct storage URL (S3/object storage) + if setting.Attachment.Storage.ServeDirect() { + u, err := storage.Attachments.ServeDirectURL(attach.RelativePath(), attach.Name, req.Method, nil) + if u != nil && err == nil { + http.Redirect(w, req, u.String(), http.StatusTemporaryRedirect) + return + } + } + + // Serve from local storage + fr, err := storage.Attachments.Open(attach.RelativePath()) + if err != nil { + log.Error("CDN: storage.Open %s: %v", attach.RelativePath(), err) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + defer fr.Close() + + httplib.ServeUserContentByFile(req, w, fr, httplib.ServeHeaderOptions{ + Filename: attach.Name, + CacheIsPublic: true, + CacheDuration: setting.CDN.CacheTTL, + }) +} + +// cdnCheckIPAllowed checks if the request IP is in the configured allowlist. +func cdnCheckIPAllowed(req *http.Request) bool { + if len(setting.CDN.AllowedIPs) == 0 { + return true + } + + remoteIP := cdnGetRemoteIP(req) + if remoteIP == nil { + return false + } + + for _, cidr := range setting.CDN.AllowedIPs { + cidr = strings.TrimSpace(cidr) + if cidr == "" { + continue + } + + if !strings.Contains(cidr, "/") { + if remoteIP.Equal(net.ParseIP(cidr)) { + return true + } + continue + } + + _, network, err := net.ParseCIDR(cidr) + if err != nil { + log.Warn("CDN: invalid CIDR in AllowedIPs: %s", cidr) + continue + } + if network.Contains(remoteIP) { + return true + } + } + return false +} + +// cdnCheckReferrerAllowed checks if the request referrer domain is allowed. +func cdnCheckReferrerAllowed(req *http.Request) bool { + if len(setting.CDN.AllowedDomains) == 0 { + return true + } + + referer := req.Header.Get("Referer") + if referer == "" { + return true // direct requests always allowed + } + + for _, domain := range setting.CDN.AllowedDomains { + domain = strings.TrimSpace(strings.ToLower(domain)) + if domain == "" { + continue + } + if domain == "*" { + return true + } + refLower := strings.ToLower(referer) + if strings.Contains(refLower, "://"+domain+"/") || strings.Contains(refLower, "://"+domain+":") || + strings.HasSuffix(refLower, "://"+domain) { + return true + } + if strings.HasPrefix(domain, "*.") { + baseDomain := domain[2:] + if strings.Contains(refLower, "."+baseDomain+"/") || strings.Contains(refLower, "."+baseDomain+":") || + strings.HasSuffix(refLower, "."+baseDomain) { + return true + } + } + } + return false +} + +// cdnGetRemoteIP extracts the client IP, checking proxy headers. +func cdnGetRemoteIP(req *http.Request) net.IP { + if xff := req.Header.Get("X-Forwarded-For"); xff != "" { + parts := strings.SplitN(xff, ",", 2) + if ip := net.ParseIP(strings.TrimSpace(parts[0])); ip != nil { + return ip + } + } + if xri := req.Header.Get("X-Real-IP"); xri != "" { + if ip := net.ParseIP(strings.TrimSpace(xri)); ip != nil { + return ip + } + } + host, _, err := net.SplitHostPort(req.RemoteAddr) + if err != nil { + return net.ParseIP(req.RemoteAddr) + } + return net.ParseIP(host) +} diff --git a/routers/web/repo/release.go b/routers/web/repo/release.go index 6f7484112a..595b237d72 100644 --- a/routers/web/repo/release.go +++ b/routers/web/repo/release.go @@ -596,7 +596,10 @@ func EditRelease(ctx *context.Context) { ctx.Data["content"] = rel.Note ctx.Data["prerelease"] = rel.IsPrerelease ctx.Data["IsDraft"] = rel.IsDraft - ctx.Data["ReleaseStream"] = licenses_model.GetReleaseStream(ctx, rel.ID) + releaseStream := licenses_model.GetReleaseStream(ctx, rel.ID) + ctx.Data["ReleaseStream"] = releaseStream + ctx.Data["ReleaseHasStream"] = releaseStream != "" + ctx.Data["CDNEnabled"] = setting.CDN.Enabled rel.Repo = ctx.Repo.Repository if err := rel.LoadAttributes(ctx); err != nil { @@ -683,6 +686,28 @@ func EditReleasePost(ctx *context.Context) { } else { _ = licenses_model.DeleteReleaseStream(ctx, rel.ID) } + + // Update per-asset CDN visibility flags. + if setting.CDN.Enabled { + const cdnPrefix = "attachment-cdn-" + cdnUUIDs := make(map[string]bool) + for k := range ctx.Req.Form { + if strings.HasPrefix(k, cdnPrefix) { + cdnUUIDs[k[len(cdnPrefix):]] = true + } + } + // Load all attachments for this release to update cdn_public + if err := repo_model.GetReleaseAttachments(ctx, rel); err == nil { + for _, attach := range rel.Attachments { + wantCDN := cdnUUIDs[attach.UUID] + if attach.CDNPublic != wantCDN { + attach.CDNPublic = wantCDN + _ = repo_model.UpdateAttachmentByUUID(ctx, attach, "cdn_public") + } + } + } + } + ctx.Redirect(ctx.Repo.RepoLink + "/releases") } diff --git a/routers/web/web.go b/routers/web/web.go index 491bc86372..55b933672d 100644 --- a/routers/web/web.go +++ b/routers/web/web.go @@ -260,6 +260,20 @@ func Routes() *web.Router { // GetHead allows a HEAD request redirect to GET if HEAD method is not defined for that route routes.BeforeRouting(chi_middleware.GetHead) + // CDN hostname handler - intercepts requests on the CDN domain before any + // session/auth middleware runs, serving only CDN-public release assets. + if setting.CDN.Enabled && setting.CDN.Domain != "" { + routes.BeforeRouting(func(resp http.ResponseWriter, req *http.Request) { + host := req.Host + if idx := strings.Index(host, ":"); idx >= 0 { + host = host[:idx] + } + if strings.EqualFold(host, setting.CDN.Domain) { + repo.CDNHandler(resp, req) + } + }) + } + 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()) diff --git a/templates/repo/release/new.tmpl b/templates/repo/release/new.tmpl index d23e103c3d..035f0d524e 100644 --- a/templates/repo/release/new.tmpl +++ b/templates/repo/release/new.tmpl @@ -86,6 +86,12 @@ {{svg "octicon-info"}} + {{if $.CDNEnabled}} + + {{end}} {{ctx.Locale.Tr "remove"}} -- 2.52.0