feat(cdn): built-in CDN for release asset delivery #565

Merged
jmiller merged 1 commits from feat/cdn-release-delivery into dev 2026-06-07 16:12:50 +00:00
10 changed files with 378 additions and 2 deletions
+1
View File
@@ -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
}
+14
View File
@@ -0,0 +1,14 @@
// Copyright 2026 Moko Consulting <hello@mokoconsulting.tech>
// 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))
}
+1
View File
@@ -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:"-"`
}
+34
View File
@@ -0,0 +1,34 @@
// Copyright 2026 Moko Consulting <hello@mokoconsulting.tech>
// 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(",")
}
+1
View File
@@ -178,6 +178,7 @@ func loadCommonSettingsFrom(cfg ConfigProvider) error {
loadOtherFrom(cfg)
loadUpdateCheckerFrom(cfg)
loadNtfyFrom(cfg)
loadCDNFrom(cfg)
loadLoginNotificationFrom(cfg)
return nil
}
+3 -1
View File
@@ -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",
+278
View File
@@ -0,0 +1,278 @@
// Copyright 2026 Moko Consulting <hello@mokoconsulting.tech>
// 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)
}
+26 -1
View File
@@ -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")
}
+14
View File
@@ -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())
+6
View File
@@ -86,6 +86,12 @@
<span data-tooltip-content="{{ctx.Locale.Tr "repo.release.download_count" (ctx.Locale.PrettyNumber .DownloadCount)}}">
{{svg "octicon-info"}}
</span>
{{if $.CDNEnabled}}
<label class="tw-flex tw-items-center tw-gap-1 tw-ml-2" data-tooltip-content="{{ctx.Locale.Tr "repo.release.cdn_public_tooltip"}}">
<input type="checkbox" name="attachment-cdn-{{.UUID}}" {{if .CDNPublic}}checked{{end}} {{if $.ReleaseHasStream}}disabled{{end}}>
<span class="tw-text-text-light tw-text-xs">{{ctx.Locale.Tr "repo.release.cdn_public"}}</span>
</label>
{{end}}
</div>
<a class="ui mini compact red button" data-global-click="onReleaseEditAttachmentDelete" data-id="{{.ID}}" data-uuid="{{.UUID}}">
{{ctx.Locale.Tr "remove"}}