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
- 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>
554 lines
14 KiB
Go
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
|
|
}
|
|
}
|
|
}
|