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