From 71021673517908f4522e58c451d5a598fd36cbc7 Mon Sep 17 00:00:00 2001 From: logikonline Date: Wed, 11 Feb 2026 20:32:23 -0500 Subject: [PATCH] feat(repo): add public app integration toggle for repositories Add repository setting to control anonymous access to app integration endpoints (issue submission, update checks). When enabled (default), the desktop app can access these endpoints without authentication. When disabled, vault token authentication is required. This provides granular control over app integration access, allowing repository owners to enforce full authentication on sensitive repositories while maintaining ease of use for public/limited repos. Changes include: - New PublicAppIntegration boolean field on Repository model - Database migration v365 to add the field (defaults to true) - Repository settings UI to toggle the feature - Updated checkVaultTokenForRepo to respect the setting - Security enhancement: IssueStatusJSONEndpoint now only returns app-submitted issues to anonymous users --- .notes/note-1770858253427-5d5d6gaia.json | 8 +++ models/migrations/migrations.go | 1 + models/migrations/v1_26/v365.go | 13 +++++ models/repo/repo.go | 1 + options/locale/locale_en-US.json | 3 ++ routers/web/repo/issue_json.go | 65 ++++++++++++++---------- routers/web/repo/release.go | 35 ++----------- routers/web/repo/setting/setting.go | 5 ++ routers/web/web.go | 14 +++-- services/context/repo.go | 58 +++++++++++++++++++++ services/forms/repo_form.go | 1 + templates/repo/settings/options.tmpl | 10 ++++ 12 files changed, 153 insertions(+), 61 deletions(-) create mode 100644 .notes/note-1770858253427-5d5d6gaia.json create mode 100644 models/migrations/v1_26/v365.go diff --git a/.notes/note-1770858253427-5d5d6gaia.json b/.notes/note-1770858253427-5d5d6gaia.json new file mode 100644 index 0000000000..d43ad6fc12 --- /dev/null +++ b/.notes/note-1770858253427-5d5d6gaia.json @@ -0,0 +1,8 @@ +{ + "id": "note-1770858253427-5d5d6gaia", + "title": "Issues/Releases", + "content": " Access matrix after changes\n ┌────────────────────────┬────────────┬────────────┬────────────┬─────────────┐\n │ Endpoint │ Public │ Limited │ Private │ Private Org │\n ├────────────────────────┼────────────┼────────────┼────────────┼─────────────┤\n │ POST submit.json │ anonymous │ anonymous │ anonymous │ anonymous │\n ├────────────────────────┼────────────┼────────────┼────────────┼─────────────┤\n │ GET status.json │ anonymous* │ anonymous* │ anonymous* │ anonymous* │\n ├────────────────────────┼────────────┼────────────┼────────────┼─────────────┤\n │ GET external/{id}.json │ anonymous │ anonymous │ anonymous │ anonymous │\n ├────────────────────────┼────────────┼────────────┼────────────┼─────────────┤\n │ GET latest.json │ anonymous │ anonymous │ anonymous │ anonymous │\n └────────────────────────┴────────────┴────────────┴────────────┴─────────────┘\n * Only returns issues where external_source = \"gitcaddy-desktop\"", + "createdAt": 1770858253425, + "updatedAt": 1770858258193, + "tags": [] +} \ No newline at end of file diff --git a/models/migrations/migrations.go b/models/migrations/migrations.go index 69739ca531..624d4ea864 100644 --- a/models/migrations/migrations.go +++ b/models/migrations/migrations.go @@ -439,6 +439,7 @@ func prepareMigrationTasks() []*migration { newMigration(362, "Create wishlist_comment_reaction table", v1_26.CreateWishlistCommentReactionTable), newMigration(363, "Add keep_packages_private to user", v1_26.AddKeepPackagesPrivateToUser), newMigration(364, "Add view_count to blog_post", v1_26.AddViewCountToBlogPost), + newMigration(365, "Add public_app_integration to repository", v1_26.AddPublicAppIntegrationToRepository), } return preparedMigrations } diff --git a/models/migrations/v1_26/v365.go b/models/migrations/v1_26/v365.go new file mode 100644 index 0000000000..a10e7e647b --- /dev/null +++ b/models/migrations/v1_26/v365.go @@ -0,0 +1,13 @@ +// Copyright 2026 MarketAlly. All rights reserved. +// SPDX-License-Identifier: MIT + +package v1_26 + +import "xorm.io/xorm" + +func AddPublicAppIntegrationToRepository(x *xorm.Engine) error { + type Repository struct { + PublicAppIntegration bool `xorm:"NOT NULL DEFAULT true"` + } + return x.Sync(new(Repository)) +} diff --git a/models/repo/repo.go b/models/repo/repo.go index 4b21a28283..7abdbd8afb 100644 --- a/models/repo/repo.go +++ b/models/repo/repo.go @@ -221,6 +221,7 @@ type Repository struct { SubscriptionsEnabled bool `xorm:"NOT NULL DEFAULT false"` BlogEnabled bool `xorm:"NOT NULL DEFAULT false"` WishlistEnabled bool `xorm:"NOT NULL DEFAULT false"` + PublicAppIntegration bool `xorm:"NOT NULL DEFAULT true"` ObjectFormatName string `xorm:"VARCHAR(6) NOT NULL DEFAULT 'sha1'"` TrustModel TrustModelType diff --git a/options/locale/locale_en-US.json b/options/locale/locale_en-US.json index 08bc79c2b5..ce70f7ad14 100644 --- a/options/locale/locale_en-US.json +++ b/options/locale/locale_en-US.json @@ -4379,6 +4379,9 @@ "repo.settings.hidden_folders.already_hidden": "This folder is already hidden.", "repo.settings.dotfiles": "Dotfiles", "repo.settings.dotfiles.hide_desc": "Hide files and folders starting with \".\" from the code browser for non-admin users", + "repo.settings.app_integration": "App Integration", + "repo.settings.app_integration.enable": "Allow anonymous issue reporting and update checks", + "repo.settings.app_integration.enable_help": "When enabled, the desktop app can submit issues and check for updates without authentication. Disable this for full access control on private repositories.", "repo.gallery": "Gallery", "api": "API", "admin.config.api_header_url": "API Header Link", diff --git a/routers/web/repo/issue_json.go b/routers/web/repo/issue_json.go index 376c6e7f38..9fcd381505 100644 --- a/routers/web/repo/issue_json.go +++ b/routers/web/repo/issue_json.go @@ -19,7 +19,6 @@ import ( "code.gitcaddy.com/server/v3/services/attachment" "code.gitcaddy.com/server/v3/services/context" issue_service "code.gitcaddy.com/server/v3/services/issue" - vault_service "code.gitcaddy.com/server/v3/services/vault" ) // IssueSubmitJSON is the request body for submitting an issue via JSON API @@ -62,34 +61,35 @@ type IssueListJSON struct { TotalCount int64 `json:"total_count"` } -// checkVaultTokenForRepo validates vault token for private repo access +// checkVaultTokenForRepo validates access for app integration endpoints. +// If the user is signed in or the repo has public app integration enabled, +// access is granted. Otherwise, a valid vault token is required. func checkVaultTokenForRepo(ctx *context.Context) bool { - // If user is logged in, they have access through normal means + // If user is logged in, always allow if ctx.Doer != nil { return true } - - // For private repos, check vault token - if ctx.Repo.Repository.IsPrivate { - token := vaultTokenFromHeader(ctx.Req) - if token == "" { - ctx.JSON(http.StatusUnauthorized, map[string]string{ - "error": "unauthorized", - "message": "This repository is private. Provide a vault token via Authorization header.", - }) - return false - } - - validToken, err := validateVaultTokenForRepo(ctx, token, ctx.Repo.Repository.ID) - if err != nil || validToken == nil { - ctx.JSON(http.StatusUnauthorized, map[string]string{ - "error": "invalid_token", - "message": "Invalid or expired vault token", - }) - return false - } + // If public app integration is enabled, allow anonymous access + if ctx.Repo.Repository.PublicAppIntegration { + return true + } + // Otherwise require vault token + token := vaultTokenFromHeader(ctx.Req) + if token == "" { + ctx.JSON(http.StatusUnauthorized, map[string]string{ + "error": "unauthorized", + "message": "Anonymous app integration is disabled. Provide a vault token via Authorization header.", + }) + return false + } + validToken, err := validateVaultTokenForRepo(ctx, token, ctx.Repo.Repository.ID) + if err != nil || validToken == nil { + ctx.JSON(http.StatusUnauthorized, map[string]string{ + "error": "invalid_token", + "message": "Invalid or expired vault token", + }) + return false } - return true } @@ -313,6 +313,10 @@ func IssueSubmitJSONEndpoint(ctx *context.Context) { // IssueStatusJSONEndpoint returns the status of a specific issue // URL: GET /{owner}/{repo}/issues/{index}/status.json +// +// For security, this endpoint only returns issues that were submitted through the +// app integration (external_source = "gitcaddy-desktop"). This prevents anonymous +// enumeration of developer-created issues on private/limited repos. func IssueStatusJSONEndpoint(ctx *context.Context) { // Set rate limit exemption headers ctx.Resp.Header().Set("X-RateLimit-Exempt", "app-integration") @@ -339,6 +343,16 @@ func IssueStatusJSONEndpoint(ctx *context.Context) { return } + // Only expose app-submitted issues to anonymous users. + // Authenticated users with repo access can see all issues through the normal UI. + if ctx.Doer == nil && issue.ExternalSource != "gitcaddy-desktop" { + ctx.JSON(http.StatusNotFound, map[string]string{ + "error": "not_found", + "message": "Issue not found", + }) + return + } + // Load labels if err = issue.LoadLabels(ctx); err != nil { issue.Labels = nil @@ -407,6 +421,3 @@ func IssuesByExternalUserJSONEndpoint(ctx *context.Context) { // validateVaultTokenForRepo is defined in release.go // vaultTokenFromHeader is defined in release.go - -// Ensure vault_service is used (avoid unused import error) -var _ = vault_service.ErrAccessDenied diff --git a/routers/web/repo/release.go b/routers/web/repo/release.go index fb277a8c9a..4fdf0a98b6 100644 --- a/routers/web/repo/release.go +++ b/routers/web/repo/release.go @@ -829,9 +829,9 @@ func validateVaultTokenForRepo(ctx stdCtx.Context, rawToken string, repoID int64 // This is a simple endpoint for apps to check for updates // URL: /{owner}/{repo}/releases/latest.json?channel=stable|prerelease|any // -// For private repos, supports authentication via: -// - Normal user session -// - Vault token in Authorization header (Bearer gvt_xxx) +// This endpoint is accessible without authentication regardless of repo visibility. +// Release info is effectively public to anyone running the installed app, since the +// repo URL is embedded in the app binary. // // This endpoint is exempt from rate limiting for update checks func LatestReleaseJSONEndpoint(ctx *context.Context) { @@ -839,33 +839,8 @@ func LatestReleaseJSONEndpoint(ctx *context.Context) { ctx.Resp.Header().Set("X-RateLimit-Exempt", "update-check") ctx.Resp.Header().Set("X-Update-Check-Endpoint", "true") - // Check access for private repos - if ctx.Repo.Repository.IsPrivate { - // If user is logged in and has access, allow - if ctx.Doer != nil { - // User is authenticated, normal permission checks apply - } else { - // Try vault token authentication for machine-to-machine access - token := vaultTokenFromHeader(ctx.Req) - if token == "" { - ctx.JSON(http.StatusUnauthorized, map[string]string{ - "error": "unauthorized", - "message": "This repository is private. Provide a vault token via Authorization header.", - }) - return - } - - // Validate the vault token - use "read" action with "*" to check basic repo access - // Import lazily to avoid circular dependencies - validToken, err := validateVaultTokenForRepo(ctx, token, ctx.Repo.Repository.ID) - if err != nil || validToken == nil { - ctx.JSON(http.StatusUnauthorized, map[string]string{ - "error": "invalid_token", - "message": "Invalid or expired vault token", - }) - return - } - } + if !checkVaultTokenForRepo(ctx) { + return } // Get channel from query param (default: stable) diff --git a/routers/web/repo/setting/setting.go b/routers/web/repo/setting/setting.go index a598cdfecd..6c1c57693f 100644 --- a/routers/web/repo/setting/setting.go +++ b/routers/web/repo/setting/setting.go @@ -530,6 +530,11 @@ func handleSettingsPostAdvanced(ctx *context.Context) { repoChanged = true } + if repo.PublicAppIntegration != form.EnablePublicAppIntegration { + repo.PublicAppIntegration = form.EnablePublicAppIntegration + repoChanged = true + } + if form.EnableCode && !unit_model.TypeCode.UnitGlobalDisabled() { units = append(units, newRepoUnit(repo, unit_model.TypeCode, nil)) } else if !unit_model.TypeCode.UnitGlobalDisabled() { diff --git a/routers/web/web.go b/routers/web/web.go index abac6d0ef9..a3d26a0529 100644 --- a/routers/web/web.go +++ b/routers/web/web.go @@ -349,6 +349,9 @@ func registerWebRoutes(m *web.Router) { // optional sign in (if signed in, use the user as doer, if not, no doer) optSignIn := verifyAuthWithOptions(&common.VerifyOptions{SignInRequired: setting.Service.RequireSignInViewStrict}) optExploreSignIn := verifyAuthWithOptions(&common.VerifyOptions{SignInRequired: setting.Service.RequireSignInViewStrict || setting.Service.Explore.RequireSigninView}) + // app integration endpoints: never require sign-in and skip cross-origin protection. + // Used by the desktop app for issue reporting, update checks, etc. + optSignInForIntegration := verifyAuthWithOptions(&common.VerifyOptions{DisableCrossOriginProtection: true}) validation.AddBindingRules() @@ -1598,7 +1601,6 @@ func registerWebRoutes(m *web.Router) { m.Get(".atom", feedEnabled, repo.ReleasesFeedAtom) m.Get("/tag/*", repo.SingleRelease) m.Get("/latest", repo.LatestRelease) - m.Get("/latest.json", repo.LatestReleaseJSONEndpoint) // Simple JSON endpoint for update checks }, ctxDataSet("EnableFeed", setting.Other.EnableFeed)) m.Get("/releases/attachments/{uuid}", repo.GetAttachment) m.Get("/releases/download/{vTag}/{fileName}", repo.RedirectDownload) @@ -1619,14 +1621,18 @@ func registerWebRoutes(m *web.Router) { }, optSignIn, context.RepoAssignment, repo.MustBeNotEmpty, reqRepoReleaseReader) // end "/{username}/{reponame}": repo releases - m.Group("/{username}/{reponame}", func() { // issue JSON API for app integration + m.Group("/{username}/{reponame}", func() { // app integration endpoints (issue reporting, update checks) + // These endpoints are exempt from unit permission checks and cross-origin protection + // so the desktop app can submit issues and check for updates without authentication, + // even for repos with Limited visibility. Each endpoint handles its own auth via checkVaultTokenForRepo. m.Group("/issues", func() { m.Post("/submit.json", web.Bind(repo.IssueSubmitJSON{}), repo.IssueSubmitJSONEndpoint) m.Get("/external/{external_user_id}.json", repo.IssuesByExternalUserJSONEndpoint) m.Get("/{index}/status.json", repo.IssueStatusJSONEndpoint) }) - }, optSignIn, context.RepoAssignment, reqRepoIssuesOrPullsReader) - // end "/{username}/{reponame}": issue JSON API + m.Get("/releases/latest.json", repo.LatestReleaseJSONEndpoint) + }, optSignInForIntegration, context.RepoAssignmentForIntegration) + // end "/{username}/{reponame}": app integration m.Group("/{username}/{reponame}", func() { // to maintain compatibility with old attachments m.Get("/attachments/{uuid}", repo.GetAttachment) diff --git a/services/context/repo.go b/services/context/repo.go index cf6c712b46..b0d03174c5 100644 --- a/services/context/repo.go +++ b/services/context/repo.go @@ -786,6 +786,64 @@ func RepoAssignment(ctx *Context) { } } +// RepoAssignmentForIntegration is a lightweight middleware that loads a repository +// without checking permissions, org visibility, or unit access. It is used for +// app integration endpoints (issue reporting, update checks) that handle their own +// authentication and must be accessible regardless of repo or org visibility settings. +func RepoAssignmentForIntegration(ctx *Context) { + var err error + userName := ctx.PathParam("username") + repoName := ctx.PathParam("reponame") + repoName = strings.TrimSuffix(repoName, ".git") + + // Resolve owner + if ctx.IsSigned && strings.EqualFold(ctx.Doer.LowerName, userName) { + ctx.Repo.Owner = ctx.Doer + } else { + ctx.Repo.Owner, err = user_model.GetUserByName(ctx, userName) + if err != nil { + if user_model.IsErrUserNotExist(err) { + if redirectUserID, err := user_model.LookupUserRedirect(ctx, userName); err == nil { + RedirectToUser(ctx.Base, ctx.Doer, userName, redirectUserID) + } else { + ctx.NotFound(nil) + } + } else { + ctx.ServerError("GetUserByName", err) + } + return + } + } + ctx.ContextUser = ctx.Repo.Owner + + // Resolve repo + repo, err := repo_model.GetRepositoryByName(ctx, ctx.Repo.Owner.ID, repoName) + if err != nil { + if repo_model.IsErrRepoNotExist(err) { + if redirectRepoID, err := repo_model.LookupRedirect(ctx, ctx.Repo.Owner.ID, repoName); err == nil { + RedirectToRepo(ctx.Base, redirectRepoID) + } else { + ctx.NotFound(nil) + } + } else { + ctx.ServerError("GetRepositoryByName", err) + } + return + } + + repo.Owner = ctx.Repo.Owner + if err = repo.LoadOwner(ctx); err != nil { + ctx.ServerError("LoadOwner", err) + return + } + + ctx.Repo.Repository = repo + ctx.Repo.RepoLink = repo.Link() + ctx.Data["Repository"] = repo + ctx.Data["Owner"] = repo.Owner + ctx.Data["RepoLink"] = ctx.Repo.RepoLink +} + const headRefName = "HEAD" func getRefNameFromPath(repo *Repository, path string, isExist func(string) bool) string { diff --git a/services/forms/repo_form.go b/services/forms/repo_form.go index a63a7a9c31..95f2cd68d9 100644 --- a/services/forms/repo_form.go +++ b/services/forms/repo_form.go @@ -126,6 +126,7 @@ type RepoSettingForm struct { EnableCloseIssuesViaCommitInAnyBranch bool HideDotfiles bool EnableBlog bool + EnablePublicAppIntegration bool EnableProjects bool ProjectsMode string diff --git a/templates/repo/settings/options.tmpl b/templates/repo/settings/options.tmpl index 228563cf42..40b53b4311 100644 --- a/templates/repo/settings/options.tmpl +++ b/templates/repo/settings/options.tmpl @@ -659,6 +659,16 @@ {{end}} +
+
+ +
+ + +
+

{{ctx.Locale.Tr "repo.settings.app_integration.enable_help"}}

+
+