From 18bb922839f76beefdb4035d9c0d0682bc57f65d Mon Sep 17 00:00:00 2001 From: logikonline Date: Sat, 10 Jan 2026 08:10:41 -0500 Subject: [PATCH] feat(api): Add v2 API for public releases and app updates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add public_landing option to allow private repos to have public landing pages - Add public_releases option to allow private repos to serve releases publicly - Add /api/v2/repos/{owner}/{repo}/releases/update endpoint for Electron/Squirrel compatible app updates - Add /api/v2/repos/{owner}/{repo}/pages/config and /content endpoints - Add repoAssignmentWithPublicAccess middleware to bypass auth for public landing/releases - Update README with documentation for new features 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- README.md | 75 +++++ go.mod | 1 + go.sum | 4 + modules/pages/config.go | 12 +- routers/api/v2/api.go | 19 ++ routers/api/v2/pages_api.go | 160 +++++++++++ routers/api/v2/releases.go | 553 ++++++++++++++++++++++++++++++++++++ services/pages/pages.go | 20 ++ 8 files changed, 839 insertions(+), 5 deletions(-) create mode 100644 routers/api/v2/pages_api.go create mode 100644 routers/api/v2/releases.go diff --git a/README.md b/README.md index 2f693895d0..75cd364a3e 100644 --- a/README.md +++ b/README.md @@ -181,6 +181,81 @@ DELETE /api/v1/repos/{owner}/{repo}/releases/{id}/archive GET /api/v1/repos/{owner}/{repo}/releases?archived=false ``` +### Public Landing Pages & Releases for Private Repos + +Private repositories can expose a public landing page and/or public releases. Perfect for: +- Commercial software with private source but public downloads +- Open-core projects with public documentation +- Electron/desktop apps needing public update endpoints + +Configure in `.gitea/landing.yaml`: +```yaml +enabled: true +public_landing: true # Allow unauthenticated access to landing page + +hero: + title: "My App" + tagline: "The best app ever" + +advanced: + public_releases: true # Allow unauthenticated access to releases +``` + +**API Endpoints (no auth required when enabled):** +``` +GET /api/v2/repos/{owner}/{repo}/pages/config # Landing page config +GET /api/v2/repos/{owner}/{repo}/pages/content # Landing page content +GET /api/v2/repos/{owner}/{repo}/releases # List releases +GET /api/v2/repos/{owner}/{repo}/releases/latest # Latest release +``` + +### App Update API (Electron/Squirrel Compatible) + +Purpose-built endpoint for desktop app auto-updates. Returns Squirrel-compatible JSON format. + +**Endpoint:** `GET /api/v2/repos/{owner}/{repo}/releases/update` + +**Query Parameters:** +| Parameter | Description | Default | +|-----------|-------------|---------| +| `version` | Current app version (semver) | Required | +| `platform` | `darwin`, `windows`, `linux` | Runtime OS | +| `arch` | `x64`, `arm64` | Runtime arch | +| `channel` | `stable`, `beta`, `alpha` | `stable` | + +**Response (200 OK - update available):** +```json +{ + "url": "https://git.example.com/owner/repo/releases/download/v1.2.0/App-darwin-arm64.zip", + "name": "v1.2.0", + "notes": "Release notes in markdown...", + "pub_date": "2026-01-10T12:00:00Z", + "platform": { + "size": 45000000, + "releases_url": "https://...", // Windows RELEASES file + "nupkg_url": "https://..." // Windows nupkg + } +} +``` + +**Response (204 No Content):** No update available + +**Electron Integration:** +```typescript +// In your Electron app +import { autoUpdater } from 'electron' + +const version = app.getVersion() +const platform = process.platform +const arch = process.arch === 'arm64' ? 'arm64' : 'x64' + +autoUpdater.setFeedURL({ + url: `https://git.example.com/api/v2/repos/owner/repo/releases/update?version=${version}&platform=${platform}&arch=${arch}` +}) + +autoUpdater.checkForUpdates() +``` + ## Installation ### From Binary diff --git a/go.mod b/go.mod index a848e2e43c..1e98ed7860 100644 --- a/go.mod +++ b/go.mod @@ -145,6 +145,7 @@ require ( git.sr.ht/~mariusor/go-xsd-duration v0.0.0-20220703122237-02e73435a078 // indirect github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2 // indirect github.com/DataDog/zstd v1.5.7 // indirect + github.com/Masterminds/semver/v3 v3.4.0 // indirect github.com/Microsoft/go-winio v0.6.2 // indirect github.com/RoaringBitmap/roaring/v2 v2.10.0 // indirect github.com/STARRY-S/zip v0.2.3 // indirect diff --git a/go.sum b/go.sum index ad5cc3cc22..b1ca81550a 100644 --- a/go.sum +++ b/go.sum @@ -78,6 +78,10 @@ github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym github.com/DataDog/zstd v1.5.7 h1:ybO8RBeh29qrxIhCA9E8gKY6xfONU9T6G6aP9DTKfLE= github.com/DataDog/zstd v1.5.7/go.mod h1:g4AWEaM3yOg3HYfnJ3YIawPnVdXJh9QME85blwSAmyw= github.com/Julusian/godocdown v0.0.0-20170816220326-6d19f8ff2df8/go.mod h1:INZr5t32rG59/5xeltqoCJoNY7e5x/3xoY9WSWVWg74= +github.com/Masterminds/semver v1.5.0 h1:H65muMkzWKEuNDnfl9d70GUjFniHKHRbFPGBuZ3QEww= +github.com/Masterminds/semver v1.5.0/go.mod h1:MB6lktGJrhw8PrUyiEoblNEGEQ+RzHPF078ddwwvV3Y= +github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0= +github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY= github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= diff --git a/modules/pages/config.go b/modules/pages/config.go index 3c92779fb0..426553814b 100644 --- a/modules/pages/config.go +++ b/modules/pages/config.go @@ -13,8 +13,9 @@ import ( // LandingConfig represents the parsed .gitea/landing.yaml configuration type LandingConfig struct { - Enabled bool `yaml:"enabled"` - Template string `yaml:"template"` // simple, documentation, product, portfolio + Enabled bool `yaml:"enabled"` + PublicLanding bool `yaml:"public_landing"` // Allow public access even for private repos + Template string `yaml:"template"` // simple, documentation, product, portfolio // Custom domain (optional) Domain string `yaml:"domain,omitempty"` @@ -182,9 +183,10 @@ type UmamiConfig struct { // AdvancedConfig represents advanced settings type AdvancedConfig struct { - CustomCSS string `yaml:"custom_css,omitempty"` - CustomHead string `yaml:"custom_head,omitempty"` - Redirects map[string]string `yaml:"redirects,omitempty"` + CustomCSS string `yaml:"custom_css,omitempty"` + CustomHead string `yaml:"custom_head,omitempty"` + Redirects map[string]string `yaml:"redirects,omitempty"` + PublicReleases bool `yaml:"public_releases,omitempty"` // Allow public access to releases even for private repos } // ParseLandingConfig parses a landing.yaml file content diff --git a/routers/api/v2/api.go b/routers/api/v2/api.go index 2d84ee8f63..d016816e66 100644 --- a/routers/api/v2/api.go +++ b/routers/api/v2/api.go @@ -142,6 +142,25 @@ func Routes() *web.Router { m.Get("/runners/capabilities", repoAssignment(), GetActionsCapabilities) m.Post("/workflows/validate", repoAssignment(), reqToken(), web.Bind(api.WorkflowValidationRequest{}), ValidateWorkflow) }) + + // Releases v2 API - Enhanced releases with app update support + // Supports public access for private repos with public_releases enabled + m.Group("/repos/{owner}/{repo}/releases", func() { + // App update endpoint - Electron/Squirrel compatible + // Returns 200 with update info or 204 if no update available + m.Get("/update", repoAssignmentWithPublicAccess(), CheckAppUpdate) + + // List and get releases + m.Get("", repoAssignmentWithPublicAccess(), ListReleasesV2) + m.Get("/latest", repoAssignmentWithPublicAccess(), GetLatestReleaseV2) + m.Get("/{tag}", repoAssignmentWithPublicAccess(), GetReleaseV2) + }) + + // Public landing page API - for private repos with public_landing enabled + m.Group("/repos/{owner}/{repo}/pages", func() { + m.Get("/config", repoAssignmentWithPublicAccess(), GetPagesConfig) + m.Get("/content", repoAssignmentWithPublicAccess(), GetPagesContent) + }) }) return m diff --git a/routers/api/v2/pages_api.go b/routers/api/v2/pages_api.go new file mode 100644 index 0000000000..557a0bf1e0 --- /dev/null +++ b/routers/api/v2/pages_api.go @@ -0,0 +1,160 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package v2 + +import ( + "net/http" + + repo_model "code.gitea.io/gitea/models/repo" + "code.gitea.io/gitea/modules/git" + pages_module "code.gitea.io/gitea/modules/pages" + "code.gitea.io/gitea/services/context" + pages_service "code.gitea.io/gitea/services/pages" +) + +// PagesConfigResponse represents the pages configuration for a repository +type PagesConfigResponse struct { + Enabled bool `json:"enabled"` + PublicLanding bool `json:"public_landing"` + Template string `json:"template"` + Domain string `json:"domain,omitempty"` + Branding pages_module.BrandingConfig `json:"branding,omitempty"` + Hero pages_module.HeroConfig `json:"hero,omitempty"` + SEO pages_module.SEOConfig `json:"seo,omitempty"` + Footer pages_module.FooterConfig `json:"footer,omitempty"` +} + +// PagesContentResponse represents the rendered content for a landing page +type PagesContentResponse struct { + Title string `json:"title"` + Description string `json:"description"` + Readme string `json:"readme,omitempty"` +} + +// GetPagesConfig returns the pages configuration for a repository +// GET /api/v2/repos/{owner}/{repo}/pages/config +func GetPagesConfig(ctx *context.APIContext) { + repo := ctx.Repo.Repository + if repo == nil { + ctx.APIErrorNotFound("Repository not found") + return + } + + config, err := pages_service.GetPagesConfig(ctx, repo) + if err != nil { + ctx.APIErrorNotFound("Pages not configured") + return + } + + response := &PagesConfigResponse{ + Enabled: config.Enabled, + PublicLanding: config.PublicLanding, + Template: config.Template, + Domain: config.Domain, + Branding: config.Branding, + Hero: config.Hero, + SEO: config.SEO, + Footer: config.Footer, + } + + ctx.JSON(http.StatusOK, response) +} + +// GetPagesContent returns the rendered content for a repository's landing page +// GET /api/v2/repos/{owner}/{repo}/pages/content +func GetPagesContent(ctx *context.APIContext) { + repo := ctx.Repo.Repository + if repo == nil { + ctx.APIErrorNotFound("Repository not found") + return + } + + config, err := pages_service.GetPagesConfig(ctx, repo) + if err != nil || !config.Enabled { + ctx.APIErrorNotFound("Pages not enabled") + return + } + + // Load README content + readme := loadReadmeContent(ctx, repo) + + // Build title + title := config.SEO.Title + if title == "" { + title = config.Hero.Title + } + if title == "" { + title = repo.Name + } + + // Build description + description := config.SEO.Description + if description == "" { + description = config.Hero.Tagline + } + if description == "" { + description = repo.Description + } + + response := &PagesContentResponse{ + Title: title, + Description: description, + Readme: readme, + } + + ctx.JSON(http.StatusOK, response) +} + +// loadReadmeContent loads the README content from the repository +func loadReadmeContent(ctx *context.APIContext, repo *repo_model.Repository) string { + gitRepo, err := git.OpenRepository(ctx, repo.RepoPath()) + if err != nil { + return "" + } + defer gitRepo.Close() + + branch := repo.DefaultBranch + if branch == "" { + branch = "main" + } + + commit, err := gitRepo.GetBranchCommit(branch) + if err != nil { + return "" + } + + // Try common README paths + readmePaths := []string{ + "README.md", + "readme.md", + "Readme.md", + "README.markdown", + "README.txt", + "README", + } + + for _, path := range readmePaths { + entry, err := commit.GetTreeEntryByPath(path) + if err != nil { + continue + } + + reader, err := entry.Blob().DataAsync() + if err != nil { + continue + } + + content := make([]byte, entry.Blob().Size()) + _, err = reader.Read(content) + reader.Close() + + if err != nil && err.Error() != "EOF" { + continue + } + + return string(content) + } + + return "" +} diff --git a/routers/api/v2/releases.go b/routers/api/v2/releases.go new file mode 100644 index 0000000000..b06518df1f --- /dev/null +++ b/routers/api/v2/releases.go @@ -0,0 +1,553 @@ +// Copyright 2026 The Gitea Authors. All rights reserved. +// SPDX-License-Identifier: MIT + +package v2 + +import ( + "fmt" + "net/http" + "runtime" + "strings" + + "code.gitea.io/gitea/models/db" + access_model "code.gitea.io/gitea/models/perm/access" + repo_model "code.gitea.io/gitea/models/repo" + user_model "code.gitea.io/gitea/models/user" + apierrors "code.gitea.io/gitea/modules/errors" + "code.gitea.io/gitea/modules/optional" + "code.gitea.io/gitea/modules/setting" + api "code.gitea.io/gitea/modules/structs" + "code.gitea.io/gitea/services/context" + pages_service "code.gitea.io/gitea/services/pages" + + "github.com/Masterminds/semver/v3" +) + +// AppUpdateResponse represents the response for an app update check +// Compatible with Electron autoUpdater (Squirrel format) +type AppUpdateResponse struct { + // URL to download the update + URL string `json:"url"` + // Version name (semver) + Name string `json:"name"` + // Release notes (markdown) + Notes string `json:"notes"` + // Publication date (RFC3339) + PubDate string `json:"pub_date"` + // Whether this is a mandatory/priority update + Mandatory bool `json:"mandatory,omitempty"` + // Additional platform-specific info + Platform *PlatformInfo `json:"platform,omitempty"` +} + +// PlatformInfo contains platform-specific update information +type PlatformInfo struct { + // For Windows: URL to RELEASES file + ReleasesURL string `json:"releases_url,omitempty"` + // For Windows: URL to nupkg file + NupkgURL string `json:"nupkg_url,omitempty"` + // Signature/checksum for verification + Signature string `json:"signature,omitempty"` + // File size in bytes + Size int64 `json:"size,omitempty"` +} + +// CheckAppUpdate checks if an update is available for an app +// This endpoint is designed for Electron apps using autoUpdater +// GET /api/v2/repos/{owner}/{repo}/releases/update?version=1.0.0&platform=darwin&arch=arm64 +func CheckAppUpdate(ctx *context.APIContext) { + repo := ctx.Repo.Repository + if repo == nil { + ctx.APIErrorNotFound("Repository not found") + return + } + + // Get query parameters + currentVersion := ctx.FormString("version") + platform := ctx.FormString("platform") + arch := ctx.FormString("arch") + channel := ctx.FormString("channel") + + // Default to current runtime if not specified + if platform == "" { + platform = runtime.GOOS + } + if arch == "" { + arch = runtime.GOARCH + if arch == "amd64" { + arch = "x64" + } + } + if channel == "" { + channel = "stable" + } + + // Parse current version + current, err := semver.NewVersion(strings.TrimPrefix(currentVersion, "v")) + if err != nil { + ctx.APIErrorWithCodeAndMessage(apierrors.ValInvalidInput, "Invalid version format: "+currentVersion) + return + } + + // Build find options + opts := repo_model.FindReleasesOptions{ + ListOptions: db.ListOptions{PageSize: 50}, + RepoID: repo.ID, + IncludeDrafts: false, + IncludeTags: false, + } + if channel == "stable" { + opts.IsPreRelease = optional.Some(false) + } + + // Get releases + releases, err := db.Find[repo_model.Release](ctx, opts) + if err != nil { + ctx.APIErrorInternal(err) + return + } + + // Find the latest release newer than current version + var latestRelease *repo_model.Release + var latestVersion *semver.Version + + for _, release := range releases { + if release.IsDraft { + continue + } + + // Skip prereleases unless on beta/alpha channel + if release.IsPrerelease && channel == "stable" { + continue + } + + tagVersion := strings.TrimPrefix(release.TagName, "v") + ver, err := semver.NewVersion(tagVersion) + if err != nil { + continue // Skip invalid versions + } + + // Check if this version is newer than current + if ver.GreaterThan(current) { + if latestVersion == nil || ver.GreaterThan(latestVersion) { + latestVersion = ver + latestRelease = release + } + } + } + + // No update available + if latestRelease == nil { + // Return 204 No Content for no update (Squirrel convention) + ctx.Status(http.StatusNoContent) + return + } + + // Load release attachments + if err := repo_model.GetReleaseAttachments(ctx, latestRelease); err != nil { + ctx.APIErrorInternal(err) + return + } + + // Find the appropriate asset for this platform/arch + downloadURL, platformInfo := findUpdateAsset(ctx, latestRelease, platform, arch) + if downloadURL == "" { + // No compatible asset found + ctx.Status(http.StatusNoContent) + return + } + + response := &AppUpdateResponse{ + URL: downloadURL, + Name: latestRelease.TagName, + Notes: latestRelease.Note, + PubDate: latestRelease.CreatedUnix.AsTime().Format("2006-01-02T15:04:05Z07:00"), + Platform: platformInfo, + } + + ctx.JSON(http.StatusOK, response) +} + +// findUpdateAsset finds the appropriate download asset for the given platform and architecture +func findUpdateAsset(ctx *context.APIContext, release *repo_model.Release, platform, arch string) (string, *PlatformInfo) { + if release.Attachments == nil || len(release.Attachments) == 0 { + return "", nil + } + + var platformInfo *PlatformInfo + + // Platform-specific asset patterns + patterns := getAssetPatterns(platform, arch) + + for _, pattern := range patterns { + for _, asset := range release.Attachments { + name := strings.ToLower(asset.Name) + if matchesPattern(name, pattern) { + // Build direct download URL + directURL := fmt.Sprintf("%s%s/%s/releases/download/%s/%s", + setting.AppURL, + release.Repo.OwnerName, + release.Repo.Name, + release.TagName, + asset.Name, + ) + + platformInfo = &PlatformInfo{ + Size: asset.Size, + } + + // For Windows, also look for RELEASES file + if platform == "windows" { + for _, a := range release.Attachments { + if strings.ToUpper(a.Name) == "RELEASES" { + platformInfo.ReleasesURL = fmt.Sprintf("%s%s/%s/releases/download/%s/%s", + setting.AppURL, + release.Repo.OwnerName, + release.Repo.Name, + release.TagName, + a.Name, + ) + } + if strings.HasSuffix(strings.ToLower(a.Name), ".nupkg") { + platformInfo.NupkgURL = fmt.Sprintf("%s%s/%s/releases/download/%s/%s", + setting.AppURL, + release.Repo.OwnerName, + release.Repo.Name, + release.TagName, + a.Name, + ) + } + } + } + + return directURL, platformInfo + } + } + } + + return "", nil +} + +// getAssetPatterns returns file patterns to match for the given platform/arch +func getAssetPatterns(platform, arch string) []string { + switch platform { + case "darwin", "macos": + if arch == "arm64" { + return []string{ + "arm64.zip", + "darwin-arm64.zip", + "macos-arm64.zip", + "osx-arm64.zip", + "universal.zip", + ".zip", // Fallback + } + } + return []string{ + "x64.zip", + "darwin-x64.zip", + "macos-x64.zip", + "osx-x64.zip", + "intel.zip", + "universal.zip", + ".zip", // Fallback + } + case "windows", "win32": + if arch == "arm64" { + return []string{ + "arm64.exe", + "win-arm64.exe", + "windows-arm64.exe", + "setup-arm64.exe", + } + } + return []string{ + "x64.exe", + "win-x64.exe", + "windows-x64.exe", + "setup-x64.exe", + "setup.exe", // Fallback + ".exe", + } + case "linux": + if arch == "arm64" { + return []string{ + "arm64.appimage", + "linux-arm64.appimage", + "aarch64.appimage", + "arm64.deb", + "arm64.rpm", + } + } + return []string{ + "x86_64.appimage", + "linux-x64.appimage", + "amd64.appimage", + "amd64.deb", + "x86_64.rpm", + ".appimage", + ".deb", + } + } + return nil +} + +// matchesPattern checks if a filename matches a pattern (case-insensitive suffix) +func matchesPattern(name, pattern string) bool { + return strings.HasSuffix(name, pattern) +} + +// ListReleasesV2 lists releases with enhanced filtering +// GET /api/v2/repos/{owner}/{repo}/releases +func ListReleasesV2(ctx *context.APIContext) { + repo := ctx.Repo.Repository + if repo == nil { + ctx.APIErrorNotFound("Repository not found") + return + } + + // Get query parameters + page := ctx.FormInt("page") + if page <= 0 { + page = 1 + } + limit := ctx.FormInt("limit") + if limit <= 0 || limit > 100 { + limit = 30 + } + includePrereleases := ctx.FormBool("prereleases") + includeDrafts := ctx.FormBool("drafts") && ctx.Repo.Permission.IsAdmin() + + opts := repo_model.FindReleasesOptions{ + ListOptions: db.ListOptions{ + Page: page, + PageSize: limit, + }, + RepoID: repo.ID, + IncludeDrafts: includeDrafts, + IncludeTags: false, + } + + if !includePrereleases { + opts.IsPreRelease = optional.Some(false) + } + + releases, err := db.Find[repo_model.Release](ctx, opts) + if err != nil { + ctx.APIErrorInternal(err) + return + } + + // Load attachments for all releases + if err := repo_model.GetReleaseAttachments(ctx, releases...); err != nil { + ctx.APIErrorInternal(err) + return + } + + // Convert to API format + apiReleases := make([]*api.Release, 0, len(releases)) + for _, release := range releases { + apiReleases = append(apiReleases, convertToAPIRelease(ctx, repo, release)) + } + + ctx.JSON(http.StatusOK, apiReleases) +} + +// GetReleaseV2 gets a specific release by tag or ID +// GET /api/v2/repos/{owner}/{repo}/releases/{tag} +func GetReleaseV2(ctx *context.APIContext) { + repo := ctx.Repo.Repository + if repo == nil { + ctx.APIErrorNotFound("Repository not found") + return + } + + tag := ctx.PathParam("tag") + + var release *repo_model.Release + var err error + + // Try to parse as ID first + if id := ctx.PathParamInt64("tag"); id > 0 { + release, err = repo_model.GetReleaseByID(ctx, id) + } else { + // Try as tag name + release, err = repo_model.GetRelease(ctx, repo.ID, tag) + } + + if err != nil { + if repo_model.IsErrReleaseNotExist(err) { + ctx.APIErrorNotFound("Release not found") + } else { + ctx.APIErrorInternal(err) + } + return + } + + if err := repo_model.GetReleaseAttachments(ctx, release); err != nil { + ctx.APIErrorInternal(err) + return + } + + ctx.JSON(http.StatusOK, convertToAPIRelease(ctx, repo, release)) +} + +// GetLatestReleaseV2 gets the latest release +// GET /api/v2/repos/{owner}/{repo}/releases/latest +func GetLatestReleaseV2(ctx *context.APIContext) { + repo := ctx.Repo.Repository + if repo == nil { + ctx.APIErrorNotFound("Repository not found") + return + } + + channel := ctx.FormString("channel") + if channel == "" { + channel = "stable" + } + + opts := repo_model.FindReleasesOptions{ + ListOptions: db.ListOptions{ + Page: 1, + PageSize: 1, + }, + RepoID: repo.ID, + IncludeDrafts: false, + IncludeTags: false, + } + + if channel == "stable" { + opts.IsPreRelease = optional.Some(false) + } + + releases, err := db.Find[repo_model.Release](ctx, opts) + if err != nil { + ctx.APIErrorInternal(err) + return + } + + if len(releases) == 0 { + ctx.APIErrorNotFound("No releases found") + return + } + + release := releases[0] + if err := repo_model.GetReleaseAttachments(ctx, release); err != nil { + ctx.APIErrorInternal(err) + return + } + + ctx.JSON(http.StatusOK, convertToAPIRelease(ctx, repo, release)) +} + +// convertToAPIRelease converts a repo_model.Release to api.Release +func convertToAPIRelease(ctx *context.APIContext, repo *repo_model.Repository, release *repo_model.Release) *api.Release { + assets := make([]*api.Attachment, 0, len(release.Attachments)) + for _, attachment := range release.Attachments { + assets = append(assets, &api.Attachment{ + ID: attachment.ID, + Name: attachment.Name, + Size: attachment.Size, + DownloadCount: attachment.DownloadCount, + Created: attachment.CreatedUnix.AsTime(), + UUID: attachment.UUID, + DownloadURL: fmt.Sprintf("%s%s/%s/releases/download/%s/%s", + setting.AppURL, + repo.OwnerName, + repo.Name, + release.TagName, + attachment.Name, + ), + }) + } + + return &api.Release{ + ID: release.ID, + TagName: release.TagName, + Target: release.Target, + Title: release.Title, + Note: release.Note, + URL: release.HTMLURL(), + HTMLURL: release.HTMLURL(), + TarURL: release.TarURL(), + ZipURL: release.ZipURL(), + IsDraft: release.IsDraft, + IsPrerelease: release.IsPrerelease, + CreatedAt: release.CreatedUnix.AsTime(), + PublishedAt: release.CreatedUnix.AsTime(), + Attachments: assets, + } +} + +// repoAssignmentWithPublicAccess is a variant of repoAssignment that allows +// public access for repos with public_landing or public_releases enabled +func repoAssignmentWithPublicAccess() func(ctx *context.APIContext) { + return func(ctx *context.APIContext) { + ownerName := ctx.PathParam("owner") + repoName := ctx.PathParam("repo") + + // Get owner + var owner *user_model.User + var err error + + if ctx.IsSigned && strings.EqualFold(ctx.Doer.LowerName, ownerName) { + owner = ctx.Doer + } else { + owner, err = user_model.GetUserByName(ctx, ownerName) + if err != nil { + if user_model.IsErrUserNotExist(err) { + ctx.APIErrorNotFound("GetUserByName", err) + } else { + ctx.APIErrorInternal(err) + } + return + } + } + ctx.Repo.Owner = owner + ctx.ContextUser = owner + + // Get repository + repo, err := repo_model.GetRepositoryByName(ctx, owner.ID, repoName) + if err != nil { + if repo_model.IsErrRepoNotExist(err) { + ctx.APIErrorNotFound("GetRepositoryByName", err) + } else { + ctx.APIErrorInternal(err) + } + return + } + repo.Owner = owner + ctx.Repo.Repository = repo + + // Check if repo is public + if !repo.IsPrivate { + // Get permissions for public repo + ctx.Repo.Permission, _ = access_model.GetUserRepoPermission(ctx, repo, ctx.Doer) + return // Public repo, allow access + } + + // For private repos, check if public landing/releases is enabled + if pages_service.HasPublicLanding(ctx, repo) || pages_service.HasPublicReleases(ctx, repo) { + // Allow read-only access for public landing/releases + ctx.Repo.Permission, _ = access_model.GetUserRepoPermission(ctx, repo, ctx.Doer) + return + } + + // Otherwise, require authentication + if !ctx.IsSigned { + ctx.APIErrorWithCode(apierrors.AuthTokenMissing) + return + } + + // Check permission + ctx.Repo.Permission, err = access_model.GetUserRepoPermission(ctx, repo, ctx.Doer) + if err != nil { + ctx.APIErrorInternal(err) + return + } + + if !ctx.Repo.Permission.HasAnyUnitAccessOrPublicAccess() { + ctx.APIErrorNotFound("HasAnyUnitAccessOrPublicAccess") + return + } + } +} diff --git a/services/pages/pages.go b/services/pages/pages.go index 20873ca572..3be750ea92 100644 --- a/services/pages/pages.go +++ b/services/pages/pages.go @@ -252,3 +252,23 @@ func GetRepoByPagesDomain(ctx context.Context, domainName string) (*repo_model.R return repo_model.GetRepositoryByID(ctx, domain.RepoID) } + +// HasPublicLanding checks if a repository has public landing enabled +// This allows private repos to have a public-facing landing page +func HasPublicLanding(ctx context.Context, repo *repo_model.Repository) bool { + config, err := GetPagesConfig(ctx, repo) + if err != nil { + return false + } + return config.Enabled && config.PublicLanding +} + +// HasPublicReleases checks if a repository has public releases enabled +// This allows private repos to have publicly accessible releases +func HasPublicReleases(ctx context.Context, repo *repo_model.Repository) bool { + config, err := GetPagesConfig(ctx, repo) + if err != nil { + return false + } + return config.Enabled && config.Advanced.PublicReleases +}