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
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:
parent
e475d98c88
commit
18bb922839
75
README.md
75
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
|
||||
|
||||
1
go.mod
1
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
|
||||
|
||||
4
go.sum
4
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=
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
160
routers/api/v2/pages_api.go
Normal 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
553
routers/api/v2/releases.go
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user