gitea/routers/api/v2/releases.go
logikonline 212117f077
Some checks failed
Build and Release / Lint (push) Successful in 2m39s
Build and Release / Integration Tests (PostgreSQL) (push) Successful in 1m8s
Build and Release / Unit Tests (push) Failing after 2m41s
Build and Release / Build Binaries (amd64, linux) (push) Failing after 54s
Build and Release / Build Binaries (amd64, windows) (push) Failing after 57s
Build and Release / Build Binaries (amd64, darwin) (push) Failing after 1m19s
Build and Release / Build Binaries (arm64, linux) (push) Failing after 50s
Build and Release / Build Binaries (arm64, darwin) (push) Failing after 55s
fix: Go linter issues in v2 API
- Remove omitempty from nested struct fields in PagesConfigResponse
- Remove unused ctx parameter from findUpdateAsset and convertToAPIRelease
- Simplify nil check for release.Attachments (len() handles nil)
- Use strings.EqualFold instead of strings.ToUpper for case-insensitive comparison

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 08:22:37 -05:00

554 lines
14 KiB
Go

// 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(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(release *repo_model.Release, platform, arch string) (string, *PlatformInfo) {
if 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.EqualFold(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(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(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(repo, release))
}
// convertToAPIRelease converts a repo_model.Release to api.Release
func convertToAPIRelease(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
}
}
}