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
|
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
|
## Installation
|
||||||
|
|
||||||
### From Binary
|
### 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
|
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/Azure/azure-sdk-for-go/sdk/internal v1.11.2 // indirect
|
||||||
github.com/DataDog/zstd v1.5.7 // 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/Microsoft/go-winio v0.6.2 // indirect
|
||||||
github.com/RoaringBitmap/roaring/v2 v2.10.0 // indirect
|
github.com/RoaringBitmap/roaring/v2 v2.10.0 // indirect
|
||||||
github.com/STARRY-S/zip v0.2.3 // 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 h1:ybO8RBeh29qrxIhCA9E8gKY6xfONU9T6G6aP9DTKfLE=
|
||||||
github.com/DataDog/zstd v1.5.7/go.mod h1:g4AWEaM3yOg3HYfnJ3YIawPnVdXJh9QME85blwSAmyw=
|
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/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.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY=
|
||||||
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
|
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
|
||||||
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
|
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
|
// LandingConfig represents the parsed .gitea/landing.yaml configuration
|
||||||
type LandingConfig struct {
|
type LandingConfig struct {
|
||||||
Enabled bool `yaml:"enabled"`
|
Enabled bool `yaml:"enabled"`
|
||||||
Template string `yaml:"template"` // simple, documentation, product, portfolio
|
PublicLanding bool `yaml:"public_landing"` // Allow public access even for private repos
|
||||||
|
Template string `yaml:"template"` // simple, documentation, product, portfolio
|
||||||
|
|
||||||
// Custom domain (optional)
|
// Custom domain (optional)
|
||||||
Domain string `yaml:"domain,omitempty"`
|
Domain string `yaml:"domain,omitempty"`
|
||||||
@ -182,9 +183,10 @@ type UmamiConfig struct {
|
|||||||
|
|
||||||
// AdvancedConfig represents advanced settings
|
// AdvancedConfig represents advanced settings
|
||||||
type AdvancedConfig struct {
|
type AdvancedConfig struct {
|
||||||
CustomCSS string `yaml:"custom_css,omitempty"`
|
CustomCSS string `yaml:"custom_css,omitempty"`
|
||||||
CustomHead string `yaml:"custom_head,omitempty"`
|
CustomHead string `yaml:"custom_head,omitempty"`
|
||||||
Redirects map[string]string `yaml:"redirects,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
|
// ParseLandingConfig parses a landing.yaml file content
|
||||||
|
|||||||
@ -142,6 +142,25 @@ func Routes() *web.Router {
|
|||||||
m.Get("/runners/capabilities", repoAssignment(), GetActionsCapabilities)
|
m.Get("/runners/capabilities", repoAssignment(), GetActionsCapabilities)
|
||||||
m.Post("/workflows/validate", repoAssignment(), reqToken(), web.Bind(api.WorkflowValidationRequest{}), ValidateWorkflow)
|
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
|
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)
|
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