From 18fc79fa0a79119ece2d5791e2a3e1bcf718c6e1 Mon Sep 17 00:00:00 2001 From: Jonathan Miller Date: Sun, 7 Jun 2026 10:32:04 -0500 Subject: [PATCH 1/3] feat(security): add dependency vulnerability scanner (#551) Add dependency scanner module that parses manifest files (go.mod, package.json, composer.json, requirements.txt) and checks dependencies against the OSV.dev API for known CVEs. Implements the existing Scanner interface and wires into the orchestrator for push-time scanning. --- services/security/dependency_scanner.go | 541 ++++++++++++++++++++++++ services/security/orchestrator.go | 4 +- 2 files changed, 544 insertions(+), 1 deletion(-) create mode 100644 services/security/dependency_scanner.go diff --git a/services/security/dependency_scanner.go b/services/security/dependency_scanner.go new file mode 100644 index 0000000000..b5773abbfd --- /dev/null +++ b/services/security/dependency_scanner.go @@ -0,0 +1,541 @@ +// Copyright 2026 Moko Consulting +// SPDX-License-Identifier: GPL-3.0-or-later + +package security + +import ( + "bytes" + "crypto/sha256" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" + + security_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/security" + "code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/git" + "code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/log" +) + +// ────────────────────────────────────────────────────────────────────── +// Dependency manifest parsers +// ────────────────────────────────────────────────────────────────────── + +// dependency represents a single package with version. +type dependency struct { + Name string + Version string + Ecosystem string // "Go", "npm", "PyPI", "Packagist" + FilePath string // which manifest file it came from +} + +// manifestParser extracts dependencies from a file's contents. +type manifestParser struct { + FileName string + Ecosystem string + Parse func(content string, filePath string) []dependency +} + +var manifestParsers = []manifestParser{ + {"go.mod", "Go", parseGoMod}, + {"package.json", "npm", parsePackageJSON}, + {"composer.json", "Packagist", parseComposerJSON}, + {"requirements.txt", "PyPI", parseRequirementsTxt}, +} + +// parseGoMod extracts dependencies from go.mod. +func parseGoMod(content, filePath string) []dependency { + var deps []dependency + inRequire := false + + for _, line := range strings.Split(content, "\n") { + line = strings.TrimSpace(line) + + if line == ")" { + inRequire = false + continue + } + if strings.HasPrefix(line, "require (") || strings.HasPrefix(line, "require(") { + inRequire = true + continue + } + + if inRequire { + // Lines like: github.com/foo/bar v1.2.3 + parts := strings.Fields(line) + if len(parts) >= 2 && !strings.HasPrefix(parts[0], "//") { + deps = append(deps, dependency{ + Name: parts[0], + Version: parts[1], + Ecosystem: "Go", + FilePath: filePath, + }) + } + continue + } + + // Single-line require: require github.com/foo/bar v1.2.3 + if strings.HasPrefix(line, "require ") && !strings.Contains(line, "(") { + parts := strings.Fields(line) + if len(parts) >= 3 { + deps = append(deps, dependency{ + Name: parts[1], + Version: parts[2], + Ecosystem: "Go", + FilePath: filePath, + }) + } + } + } + return deps +} + +// parsePackageJSON extracts dependencies from package.json. +func parsePackageJSON(content, filePath string) []dependency { + var pkg struct { + Dependencies map[string]string `json:"dependencies"` + DevDependencies map[string]string `json:"devDependencies"` + } + if err := json.Unmarshal([]byte(content), &pkg); err != nil { + return nil + } + + var deps []dependency + for name, version := range pkg.Dependencies { + deps = append(deps, dependency{ + Name: name, + Version: cleanSemver(version), + Ecosystem: "npm", + FilePath: filePath, + }) + } + for name, version := range pkg.DevDependencies { + deps = append(deps, dependency{ + Name: name, + Version: cleanSemver(version), + Ecosystem: "npm", + FilePath: filePath, + }) + } + return deps +} + +// parseComposerJSON extracts dependencies from composer.json. +func parseComposerJSON(content, filePath string) []dependency { + var pkg struct { + Require map[string]string `json:"require"` + RequireDev map[string]string `json:"require-dev"` + } + if err := json.Unmarshal([]byte(content), &pkg); err != nil { + return nil + } + + var deps []dependency + for name, version := range pkg.Require { + if name == "php" || strings.HasPrefix(name, "ext-") { + continue // skip platform requirements + } + deps = append(deps, dependency{ + Name: name, + Version: cleanSemver(version), + Ecosystem: "Packagist", + FilePath: filePath, + }) + } + for name, version := range pkg.RequireDev { + if name == "php" || strings.HasPrefix(name, "ext-") { + continue + } + deps = append(deps, dependency{ + Name: name, + Version: cleanSemver(version), + Ecosystem: "Packagist", + FilePath: filePath, + }) + } + return deps +} + +// parseRequirementsTxt extracts dependencies from requirements.txt. +func parseRequirementsTxt(content, filePath string) []dependency { + var deps []dependency + for _, line := range strings.Split(content, "\n") { + line = strings.TrimSpace(line) + if line == "" || strings.HasPrefix(line, "#") || strings.HasPrefix(line, "-") { + continue + } + + // Handle: package==1.0.0, package>=1.0.0, package~=1.0.0 + for _, sep := range []string{"==", ">=", "~=", "<=", "!="} { + if idx := strings.Index(line, sep); idx > 0 { + name := strings.TrimSpace(line[:idx]) + version := strings.TrimSpace(line[idx+len(sep):]) + // Strip any trailing constraints like ",<2.0" + if ci := strings.Index(version, ","); ci > 0 { + version = version[:ci] + } + deps = append(deps, dependency{ + Name: name, + Version: version, + Ecosystem: "PyPI", + FilePath: filePath, + }) + break + } + } + } + return deps +} + +// cleanSemver strips npm/composer range prefixes (^, ~, >=) to get a plain version. +func cleanSemver(v string) string { + v = strings.TrimSpace(v) + v = strings.TrimLeft(v, "^~>= 0 { + v = v[:idx] + } + return v +} + +// ────────────────────────────────────────────────────────────────────── +// OSV.dev API client +// ────────────────────────────────────────────────────────────────────── + +const osvBatchURL = "https://api.osv.dev/v1/querybatch" +const osvMaxBatch = 1000 // OSV batch limit + +var osvClient = &http.Client{Timeout: 30 * time.Second} + +// osvQuery is a single query in a batch request. +type osvQuery struct { + Package *osvPackage `json:"package"` + Version string `json:"version"` +} + +type osvPackage struct { + Name string `json:"name"` + Ecosystem string `json:"ecosystem"` +} + +// osvBatchRequest is the batch query body. +type osvBatchRequest struct { + Queries []osvQuery `json:"queries"` +} + +// osvBatchResponse is the batch response. +type osvBatchResponse struct { + Results []osvResult `json:"results"` +} + +type osvResult struct { + Vulns []osvVuln `json:"vulns"` +} + +type osvVuln struct { + ID string `json:"id"` + Summary string `json:"summary"` + Details string `json:"details"` + Severity []osvSeverity `json:"severity"` + Aliases []string `json:"aliases"` +} + +type osvSeverity struct { + Type string `json:"type"` // "CVSS_V3", "CVSS_V2" + Score string `json:"score"` // CVSS vector string +} + +// queryOSV sends a batch of dependencies to OSV.dev and returns vulnerabilities. +func queryOSV(deps []dependency) (*osvBatchResponse, error) { + queries := make([]osvQuery, 0, len(deps)) + for _, d := range deps { + if d.Version == "" || d.Version == "*" || d.Version == "latest" { + continue // can't query without a concrete version + } + queries = append(queries, osvQuery{ + Package: &osvPackage{Name: d.Name, Ecosystem: d.Ecosystem}, + Version: d.Version, + }) + } + + if len(queries) == 0 { + return &osvBatchResponse{}, nil + } + + body, err := json.Marshal(osvBatchRequest{Queries: queries}) + if err != nil { + return nil, fmt.Errorf("marshal OSV request: %w", err) + } + + resp, err := osvClient.Post(osvBatchURL, "application/json", bytes.NewReader(body)) + if err != nil { + return nil, fmt.Errorf("OSV API request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 1024)) + return nil, fmt.Errorf("OSV API returned %d: %s", resp.StatusCode, string(respBody)) + } + + var result osvBatchResponse + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return nil, fmt.Errorf("decode OSV response: %w", err) + } + return &result, nil +} + +// ────────────────────────────────────────────────────────────────────── +// Severity mapping +// ────────────────────────────────────────────────────────────────────── + +// mapCVSSSeverity converts a CVSS v3 base score to an AlertSeverity. +func mapCVSSSeverity(vulnSeverities []osvSeverity) security_model.AlertSeverity { + for _, s := range vulnSeverities { + if s.Type == "CVSS_V3" { + score := extractCVSSBaseScore(s.Score) + switch { + case score >= 9.0: + return security_model.SeverityCritical + case score >= 7.0: + return security_model.SeverityHigh + case score >= 4.0: + return security_model.SeverityMedium + case score > 0: + return security_model.SeverityLow + } + } + } + + // No CVSS score available - default to medium + return security_model.SeverityMedium +} + +// extractCVSSBaseScore parses the base score from a CVSS v3 vector string. +// Vector format: CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H +// We compute a simplified score from the vector metrics. +func extractCVSSBaseScore(vector string) float64 { + if vector == "" { + return 0 + } + + // CVSS v3 vectors encode severity in metrics. Use a simplified + // lookup based on the most impactful metrics. + parts := make(map[string]string) + for _, segment := range strings.Split(vector, "/") { + kv := strings.SplitN(segment, ":", 2) + if len(kv) == 2 { + parts[kv[0]] = kv[1] + } + } + + // Simplified scoring based on key CVSS v3 metrics + var score float64 + + // Attack Vector (AV) + switch parts["AV"] { + case "N": // Network + score += 3.0 + case "A": // Adjacent + score += 2.0 + case "L": // Local + score += 1.0 + case "P": // Physical + score += 0.5 + } + + // Attack Complexity (AC) + switch parts["AC"] { + case "L": // Low + score += 1.5 + case "H": // High + score += 0.5 + } + + // Privileges Required (PR) + switch parts["PR"] { + case "N": // None + score += 1.5 + case "L": // Low + score += 1.0 + case "H": // High + score += 0.5 + } + + // Impact metrics (C/I/A) + for _, metric := range []string{"C", "I", "A"} { + switch parts[metric] { + case "H": + score += 1.2 + case "L": + score += 0.5 + } + } + + // Cap at 10.0 + if score > 10.0 { + score = 10.0 + } + return score +} + +// ────────────────────────────────────────────────────────────────────── +// DependencyScanner +// ────────────────────────────────────────────────────────────────────── + +// DependencyScanner checks project dependencies against known vulnerabilities. +type DependencyScanner struct{} + +// NewDependencyScanner creates a new dependency vulnerability scanner. +func NewDependencyScanner() *DependencyScanner { + return &DependencyScanner{} +} + +func (s *DependencyScanner) Type() security_model.ScannerType { + return security_model.ScannerDependency +} + +func (s *DependencyScanner) ScanCommit(commit *git.Commit) ([]Finding, error) { + return s.ScanTree(commit) +} + +func (s *DependencyScanner) ScanTree(commit *git.Commit) ([]Finding, error) { + if commit == nil { + return nil, nil + } + + // Step 1: Find and parse manifest files + entries, err := commit.ListEntriesRecursiveFast() + if err != nil { + return nil, fmt.Errorf("ListEntriesRecursiveFast: %w", err) + } + + var allDeps []dependency + for _, entry := range entries { + if !entry.IsRegular() { + continue + } + + path := entry.Name() + baseName := path + if idx := strings.LastIndex(path, "/"); idx >= 0 { + baseName = path[idx+1:] + } + + // Skip vendored/nested files + lower := strings.ToLower(path) + if strings.Contains(lower, "vendor/") || strings.Contains(lower, "node_modules/") || + strings.Contains(lower, "testdata/") { + continue + } + + for _, parser := range manifestParsers { + if baseName == parser.FileName { + reader, err := entry.Blob().DataAsync() + if err != nil { + log.Trace("DependencyScanner: skip %s: %v", path, err) + continue + } + content, err := io.ReadAll(io.LimitReader(reader, 5*1024*1024)) // 5MB limit + reader.Close() + if err != nil { + continue + } + + deps := parser.Parse(string(content), path) + allDeps = append(allDeps, deps...) + break + } + } + } + + if len(allDeps) == 0 { + return nil, nil + } + + log.Info("DependencyScanner: found %d dependencies across manifest files", len(allDeps)) + + // Step 2: Query OSV in batches + var findings []Finding + for i := 0; i < len(allDeps); i += osvMaxBatch { + end := i + osvMaxBatch + if end > len(allDeps) { + end = len(allDeps) + } + batch := allDeps[i:end] + + resp, err := queryOSV(batch) + if err != nil { + log.Error("DependencyScanner: OSV query failed: %v", err) + continue + } + + // Step 3: Map results to findings + // OSV batch response indices correspond 1:1 with the query indices. + // But we may have skipped deps with empty versions, so build the + // queryable subset to align indices. + queryable := make([]dependency, 0, len(batch)) + for _, d := range batch { + if d.Version != "" && d.Version != "*" && d.Version != "latest" { + queryable = append(queryable, d) + } + } + + for j, result := range resp.Results { + if j >= len(queryable) { + break + } + dep := queryable[j] + + for _, vuln := range result.Vulns { + severity := mapCVSSSeverity(vuln.Severity) + + // Build CVE alias for rule ID (prefer CVE over GHSA) + ruleID := vuln.ID + for _, alias := range vuln.Aliases { + if strings.HasPrefix(alias, "CVE-") { + ruleID = alias + break + } + } + + title := fmt.Sprintf("%s in %s@%s", ruleID, dep.Name, dep.Version) + + description := vuln.Summary + if description == "" { + description = vuln.Details + } + // Truncate long descriptions + if len(description) > 500 { + description = description[:497] + "..." + } + + // Metadata JSON + meta, _ := json.Marshal(map[string]string{ + "vuln_id": vuln.ID, + "ecosystem": dep.Ecosystem, + "package": dep.Name, + "version": dep.Version, + }) + + fingerprint := fmt.Sprintf("%x", sha256.Sum256([]byte(vuln.ID+":"+dep.Name+":"+dep.Version))) + + findings = append(findings, Finding{ + Scanner: security_model.ScannerDependency, + Severity: severity, + RuleID: ruleID, + Title: title, + Description: description, + FilePath: dep.FilePath, + CommitSHA: commit.ID.String(), + Fingerprint: fingerprint[:32], + Metadata: string(meta), + }) + } + } + } + + return findings, nil +} diff --git a/services/security/orchestrator.go b/services/security/orchestrator.go index a44c9c1b73..f4108d9a89 100644 --- a/services/security/orchestrator.go +++ b/services/security/orchestrator.go @@ -32,8 +32,10 @@ func ScanOnPush(ctx context.Context, repo *repo_model.Repository, commit *git.Co if cfg.SecretScanner { scanners = append(scanners, NewSecretScanner()) } + if cfg.DependScanner { + scanners = append(scanners, NewDependencyScanner()) + } // Future scanners added here: - // if cfg.DependScanner { scanners = append(scanners, NewDependencyScanner()) } // if cfg.CodeScanner { scanners = append(scanners, NewCodeScanner()) } if len(scanners) == 0 { -- 2.52.0 From 37d59e7b59921a851d892b14f6334d9dabced2a9 Mon Sep 17 00:00:00 2001 From: Jonathan Miller Date: Sun, 7 Jun 2026 11:07:30 -0500 Subject: [PATCH 2/3] 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 From 74279c55e33842daeb8512b990279dde7ceebb59 Mon Sep 17 00:00:00 2001 From: Jonathan Miller Date: Sun, 7 Jun 2026 11:38:05 -0500 Subject: [PATCH 3/3] fix(licensing): hide require-key option for Joomla update servers Joomla's update system does not support license key authentication, so hide the "Require license key for update feeds" checkbox when the platform is set to Joomla or Joomla+Dolibarr. --- templates/org/settings/update_streams.tmpl | 2 ++ templates/repo/settings/licensing.tmpl | 4 +++- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/templates/org/settings/update_streams.tmpl b/templates/org/settings/update_streams.tmpl index 5da64c43e1..920fffcc79 100644 --- a/templates/org/settings/update_streams.tmpl +++ b/templates/org/settings/update_streams.tmpl @@ -19,6 +19,7 @@

{{ctx.Locale.Tr "org.settings.enable_licensing_help"}}

+ {{if and (ne .StreamConfig.Platform "joomla") (ne .StreamConfig.Platform "both") (ne .StreamConfig.Platform "")}}
@@ -26,6 +27,7 @@

{{ctx.Locale.Tr "org.settings.require_key_help"}}

+ {{end}}
diff --git a/templates/repo/settings/licensing.tmpl b/templates/repo/settings/licensing.tmpl index f85d6a5173..7dc52faf5c 100644 --- a/templates/repo/settings/licensing.tmpl +++ b/templates/repo/settings/licensing.tmpl @@ -31,13 +31,15 @@

{{ctx.Locale.Tr "repo.settings.update_platform_help"}}

+ {{if and .RepoUpdateConfig (ne .RepoUpdateConfig.Platform "joomla") (ne .RepoUpdateConfig.Platform "both") (ne .RepoUpdateConfig.Platform "")}}
- +

{{ctx.Locale.Tr "repo.settings.require_update_key_help"}}

+ {{end}}
-- 2.52.0