feat(api): Add v2 API for public releases and app updates
Some checks failed
Build and Release / Integration Tests (PostgreSQL) (push) Successful in 1m34s
Build and Release / Lint (push) Failing after 1m53s
Build and Release / Build Binaries (amd64, darwin) (push) Has been skipped
Build and Release / Build Binaries (amd64, linux) (push) Has been skipped
Build and Release / Build Binaries (amd64, windows) (push) Has been skipped
Build and Release / Build Binaries (arm64, darwin) (push) Has been skipped
Build and Release / Build Binaries (arm64, linux) (push) Has been skipped
Build and Release / Unit Tests (push) Failing after 2m5s

- 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 <noreply@anthropic.com>
This commit is contained in:
David H. Friedel Jr. 2026-01-10 08:10:41 -05:00
parent e475d98c88
commit 18bb922839
8 changed files with 839 additions and 5 deletions

View File

@ -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

1
go.mod
View File

@ -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

4
go.sum
View File

@ -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=

View File

@ -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

View File

@ -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

160
routers/api/v2/pages_api.go Normal file
View File

@ -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 ""
}

553
routers/api/v2/releases.go Normal file
View File

@ -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
}
}
}

View File

@ -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
}