Quellcode durchsuchen

Accept Forgejo tokens scoped to a single repository (#2775)

ForgejoBackend.test_connection asked GET /user who the token belonged to
before asking whether the token could reach the repository, and treated a 403
there as fatal. A Forgejo v15 repository-scoped token may only carry
read/write on issues and repositories, so it 403s on /user -- and was rejected
despite reaching its own repository fine, which is all a backup needs: the push
path uses the Contents API and restore reads commits, trees and blobs, all
under /repos/{owner}/{repo}. That /user call was the only one in the whole
provider layer.

The probe stays, because a 401 from it is genuinely conclusive and names a bad
token before the repo call has to guess -- Forgejo v15+ hides a private repo
behind 404 rather than 403, so the repo call cannot always tell those apart.
Every other status now falls through to the repo check.

Two additions keep the messages as sharp as before: the repo call's own 401 is
mapped to "Invalid access token" instead of a generic API error, and the 404
names write:repository and the scoped-to-another-repository case, mentioning a
possibly-invalid token only when /user did not confirm the identity.

The token hint under the field was one shared string reading "fine-grained
token with Contents read/write" -- GitHub's advice, shown to Gitea, Forgejo and
GitLab users too. It is now per provider via PROVIDER_TOKEN_HINT_I18N_KEY,
following the existing repo-URL placeholder map, translated in all 13 locales.

Tests pin the repository-scoped token connecting, a transient /user status not
blocking the repo call, both 404 wordings, and the repo-call 401; a frontend
test switches providers and asserts the hint follows.
maziggy vor 1 Monat
Ursprung
Commit
306b9ba7fd

Datei-Diff unterdrückt, da er zu groß ist
+ 1 - 0
CHANGELOG.md


+ 33 - 24
backend/app/services/git_providers/forgejo.py

@@ -13,8 +13,9 @@ class ForgejoBackend(GiteaBackend):
     """Backend for Forgejo instances.
 
     Forgejo v15+ returns 404 (not 403) for private repositories when the token
-    lacks repository scope, requiring a /user pre-check to distinguish bad tokens
-    from inaccessible repos. test_connection is overridden to handle this.
+    lacks repository scope, so a bare repo call cannot tell "bad token" from
+    "repo not visible" on its own. test_connection probes /user first to catch
+    the outright-rejected token, then lets the repo call decide everything else.
     Other methods are inherited from GiteaBackend unchanged.
     """
 
@@ -24,37 +25,45 @@ class ForgejoBackend(GiteaBackend):
             api_base = self.get_api_base(repo_url)
             headers = self.get_headers(token)
 
-            # Verify token validity before hitting the repo. On Forgejo v15+,
-            # private repos return 404 (not 403) when the token lacks repo scope,
-            # so we must distinguish "bad token" from "token OK but repo not visible".
+            # Probe /user, but only a 401 here is conclusive: the instance rejects
+            # the token outright, and saying so beats the 404 the repo call may
+            # answer with instead (Forgejo v15+ hides private repos behind 404
+            # rather than 403).
+            #
+            # Every other status falls through to the repo check (#2775). A
+            # repository-scoped token — the kind Forgejo v15 recommends, limited
+            # to one repo — can only carry read/write:issue and
+            # read/write:repository, so /user answers 403 for exactly the tokens
+            # worth encouraging. Treating that as fatal rejected a token that
+            # reaches its own repository perfectly well, which is all a backup
+            # needs: the push path uses the Contents API and the restore path
+            # reads commits, trees and blobs, all under /repos/{owner}/{repo}.
             user_resp = await client.get(f"{api_base}/user", headers=headers)
             if user_resp.status_code == 401:
                 return {"success": False, "message": "Invalid access token", "repo_name": None, "permissions": None}
-            if user_resp.status_code == 403:
-                return {
-                    "success": False,
-                    "message": "Token has no read:user scope; cannot validate identity",
-                    "repo_name": None,
-                    "permissions": None,
-                }
-            if user_resp.status_code != 200:
-                return {
-                    "success": False,
-                    "message": f"Forgejo API error on /user: {user_resp.status_code}",
-                    "repo_name": None,
-                    "permissions": None,
-                }
+            # Whether the token's identity was confirmed. Only used to word the
+            # 404 below — an unconfirmed identity leaves "the token is invalid"
+            # on the list of causes, a confirmed one rules it out.
+            identity_confirmed = user_resp.status_code == 200
 
             repo_resp = await client.get(f"{api_base}/repos/{owner}/{repo}", headers=headers)
 
+            if repo_resp.status_code == 401:
+                return {"success": False, "message": "Invalid access token", "repo_name": None, "permissions": None}
+
             if repo_resp.status_code == 404:
+                message = (
+                    "Repository not found or token cannot access it. "
+                    "On Forgejo v15+, private repositories return 404 (not 403) "
+                    "when the token lacks repository scope. Check that the token has "
+                    "write:repository, and that this repository is one it covers if the "
+                    "token is scoped to specific repositories."
+                )
+                if not identity_confirmed:
+                    message += " The token itself may also be invalid or expired."
                 return {
                     "success": False,
-                    "message": (
-                        "Repository not found or token cannot access it. "
-                        "On Forgejo v15+, private repositories return 404 (not 403) "
-                        "when the token lacks repository scope."
-                    ),
+                    "message": message,
                     "repo_name": None,
                     "permissions": None,
                 }

+ 65 - 10
backend/tests/unit/test_git_providers.py

@@ -1424,28 +1424,80 @@ class TestForgejoTestConnection:
         assert client.get.call_count == 1  # only /user was called
 
     @pytest.mark.asyncio
-    async def test_zero_scope_token_403_on_user_returns_scope_hint(self):
-        """A 403 from /user (v15+ zero-scope token) returns a clear message without hitting the repo."""
+    async def test_repository_scoped_token_403_on_user_still_connects(self):
+        """#2775: a Forgejo v15 repository-scoped token can only hold
+        read/write on issues and repositories, so /user answers 403. That says
+        nothing about whether the token reaches its own repository — which is
+        all a backup needs — so the repo call decides."""
         client = AsyncMock()
-        client.get = AsyncMock(return_value=_make_mock_response(403, {}))
+        client.get = AsyncMock(
+            side_effect=[
+                _make_mock_response(403, {}),
+                _make_mock_response(200, {"full_name": "owner/repo", "permissions": {"push": True, "pull": True}}),
+            ]
+        )
+
+        result = await self.backend.test_connection(self.repo_url, self.token, client)
+
+        assert result["success"] is True
+        assert result["repo_name"] == "owner/repo"
+        assert client.get.call_count == 2  # /user did not short-circuit the repo call
+
+    @pytest.mark.asyncio
+    async def test_unexpected_user_status_does_not_block_the_repo_call(self):
+        """A transient non-200 from /user (429, 5xx) is not a verdict on the
+        token either — the repo call is the one that matters."""
+        client = AsyncMock()
+        client.get = AsyncMock(
+            side_effect=[
+                _make_mock_response(429, {}),
+                _make_mock_response(200, {"full_name": "owner/repo", "permissions": {"push": True, "pull": True}}),
+            ]
+        )
+
+        result = await self.backend.test_connection(self.repo_url, self.token, client)
+
+        assert result["success"] is True
+        assert client.get.call_count == 2
+
+    @pytest.mark.asyncio
+    async def test_scoped_token_without_this_repo_names_the_scope_to_fix(self):
+        """The 404 is the only signal left when /user was inconclusive, so its
+        message has to name both remaining causes: a repository the token's
+        scope doesn't cover, and a token that was never valid."""
+        client = AsyncMock()
+        client.get = AsyncMock(
+            side_effect=[
+                _make_mock_response(403, {}),
+                _make_mock_response(404, {}),
+            ]
+        )
 
         result = await self.backend.test_connection(self.repo_url, self.token, client)
 
         assert result["success"] is False
-        assert "read:user scope" in result["message"]
-        assert client.get.call_count == 1
+        assert "write:repository" in result["message"]
+        assert "specific repositories" in result["message"]
+        assert "may also be invalid" in result["message"]
 
     @pytest.mark.asyncio
-    async def test_unexpected_user_status_returns_status_code(self):
-        """A non-200/401/403 response from /user (e.g. 429, 5xx) surfaces the status code."""
+    async def test_bad_token_rejected_by_the_repo_call_is_named_as_such(self):
+        """An instance that answers /user with something inconclusive but 401s
+        the repo call must still surface 'invalid token', not a generic API
+        error — otherwise relaxing the /user gate would have cost the clearest
+        message we can give."""
         client = AsyncMock()
-        client.get = AsyncMock(return_value=_make_mock_response(429, {}))
+        client.get = AsyncMock(
+            side_effect=[
+                _make_mock_response(403, {}),
+                _make_mock_response(401, {}),
+            ]
+        )
 
         result = await self.backend.test_connection(self.repo_url, self.token, client)
 
         assert result["success"] is False
-        assert "429" in result["message"]
-        assert client.get.call_count == 1
+        assert result["message"] == "Invalid access token"
 
     @pytest.mark.asyncio
     async def test_repo_404_after_valid_token_surfaces_v15_scope_hint(self):
@@ -1462,6 +1514,9 @@ class TestForgejoTestConnection:
         assert result["success"] is False
         assert "v15" in result["message"]
         assert "scope" in result["message"]
+        # /user confirmed the identity, so the token itself is not a suspect
+        # here and the message must not send the user off checking it.
+        assert "may also be invalid" not in result["message"]
 
     @pytest.mark.asyncio
     async def test_token_lacks_push_permission_returns_failed(self):

+ 25 - 0
frontend/src/__tests__/components/GitHubBackupSettings.provider.test.tsx

@@ -112,6 +112,31 @@ describe('GitHubBackupSettings - Provider Selection', () => {
     expect(options).toContain('forgejo');
   });
 
+  it('names the scopes of the selected provider under the token field', async () => {
+    // #2775: one shared hint could only ever be right for one provider, and the
+    // one it was right for was GitHub. A Forgejo user reading "Contents read and
+    // write" has no such setting to find, and guesses wide.
+    render(<GitHubBackupSettings />);
+    await waitFor(() => {
+      expect(screen.getByRole('combobox', { name: /git provider/i })).toBeInTheDocument();
+    });
+
+    expect(screen.getByText(/Contents read and write access/i)).toBeInTheDocument();
+
+    const select = screen.getByRole('combobox', { name: /git provider/i });
+    fireEvent.change(select, { target: { value: 'forgejo' } });
+
+    await waitFor(() => {
+      expect(screen.getByText(/write:repository/i)).toBeInTheDocument();
+    });
+    expect(screen.queryByText(/Contents read and write access/i)).not.toBeInTheDocument();
+
+    fireEvent.change(select, { target: { value: 'gitlab' } });
+    await waitFor(() => {
+      expect(screen.getByText(/read_repository/i)).toBeInTheDocument();
+    });
+  });
+
   it('loads forgejo provider from existing config', async () => {
     server.use(
       http.get('/api/v1/github-backup/config', () =>

+ 11 - 1
frontend/src/components/GitHubBackupSettings.tsx

@@ -95,6 +95,16 @@ const PROVIDER_TOKEN_PLACEHOLDER: Record<GitProviderType, string> = {
   gitlab: 'glpat-xxxxxxxxxxxx',
 };
 
+// Each provider names its scopes differently, so a single hint could only ever
+// be right for one of them (#2775). Naming the scopes up front is also what
+// keeps a token from being minted more permissive than a backup needs.
+const PROVIDER_TOKEN_HINT_I18N_KEY: Record<GitProviderType, string> = {
+  github: 'backup.tokenHintGitHub',
+  gitea: 'backup.tokenHintGitea',
+  forgejo: 'backup.tokenHintForgejo',
+  gitlab: 'backup.tokenHintGitLab',
+};
+
 interface GitHubBackupAutosaveState {
   repository_url: string;
   branch: string;
@@ -714,7 +724,7 @@ export function GitHubBackupSettings() {
                     className="w-full h-10 px-3 py-2 bg-bambu-dark border border-bambu-dark-tertiary rounded-lg text-white focus:border-bambu-green focus:outline-none"
                   />
                   <p className="text-xs text-bambu-gray mt-1">
-                    {t('backup.tokenHint')}
+                    {t(PROVIDER_TOKEN_HINT_I18N_KEY[provider])}
                   </p>
                 </div>
 

+ 4 - 1
frontend/src/i18n/locales/de.ts

@@ -4840,7 +4840,10 @@ export default {
     personalAccessToken: 'Persönlicher Zugriffstoken',
     tokenSaved: '(gespeichert)',
     enterNewToken: 'Neuen Token eingeben zum Aktualisieren',
-    tokenHint: 'Feingranularer Token mit Lese-/Schreibberechtigung für Inhalte',
+    tokenHintGitHub: 'Feingranularer Token mit Lese- und Schreibzugriff auf Contents oder klassischer Token mit dem Bereich repo.',
+    tokenHintGitLab: 'Token mit dem Bereich api oder read_repository zusammen mit write_repository.',
+    tokenHintGitea: 'Token mit dem Bereich write:repository.',
+    tokenHintForgejo: 'Token mit dem Bereich write:repository. Ein Token, der nur für dieses eine Repository gilt, genügt.',
     branch: 'Branch',
     provider: 'Git-Anbieter',
     providerGitHub: 'GitHub',

+ 4 - 1
frontend/src/i18n/locales/en.ts

@@ -4883,7 +4883,10 @@ export default {
     personalAccessToken: 'Personal Access Token',
     tokenSaved: '(saved)',
     enterNewToken: 'Enter new token to update',
-    tokenHint: 'Fine-grained token with Contents read/write permission',
+    tokenHintGitHub: 'Fine-grained token with Contents read and write access, or a classic token with the repo scope.',
+    tokenHintGitLab: 'Token with the api scope, or read_repository and write_repository together.',
+    tokenHintGitea: 'Token with the write:repository scope.',
+    tokenHintForgejo: 'Token with the write:repository scope. A token limited to this one repository is enough.',
     branch: 'Branch',
     provider: 'Git Provider',
     providerGitHub: 'GitHub',

+ 4 - 1
frontend/src/i18n/locales/es.ts

@@ -4848,7 +4848,10 @@ export default {
     personalAccessToken: 'Token de acceso personal',
     tokenSaved: '(guardado)',
     enterNewToken: 'Introduzca un nuevo token para actualizar',
-    tokenHint: 'Token de granularidad fina con permiso de lectura/escritura de Contents',
+    tokenHintGitHub: 'Token de granularidad fina con acceso de lectura y escritura a Contents, o token clásico con el ámbito repo.',
+    tokenHintGitLab: 'Token con el ámbito api, o read_repository junto con write_repository.',
+    tokenHintGitea: 'Token con el ámbito write:repository.',
+    tokenHintForgejo: 'Token con el ámbito write:repository. Basta con un token limitado a este único repositorio.',
     branch: 'Rama',
     provider: 'Proveedor de Git',
     providerGitHub: 'GitHub',

+ 4 - 1
frontend/src/i18n/locales/fr.ts

@@ -4829,7 +4829,10 @@ export default {
     personalAccessToken: 'Jeton d\'accès personnel',
     tokenSaved: '(enregistré)',
     enterNewToken: 'Entrez un nouveau jeton pour mettre à jour',
-    tokenHint: 'Jeton à granularité fine avec permission de lecture/écriture du contenu',
+    tokenHintGitHub: 'Jeton à granularité fine avec accès en lecture et écriture à Contents, ou jeton classique avec la portée repo.',
+    tokenHintGitLab: 'Jeton avec la portée api, ou read_repository et write_repository ensemble.',
+    tokenHintGitea: 'Jeton avec la portée write:repository.',
+    tokenHintForgejo: 'Jeton avec la portée write:repository. Un jeton limité à ce seul dépôt suffit.',
     branch: 'Branche',
     provider: 'Fournisseur Git',
     providerGitHub: 'GitHub',

+ 4 - 1
frontend/src/i18n/locales/it.ts

@@ -4828,7 +4828,10 @@ export default {
     personalAccessToken: 'Token di accesso personale',
     tokenSaved: '(salvato)',
     enterNewToken: 'Inserisci un nuovo token per aggiornare',
-    tokenHint: 'Token a grana fine con permesso di lettura/scrittura dei contenuti',
+    tokenHintGitHub: 'Token a grana fine con accesso in lettura e scrittura a Contents, oppure token classico con ambito repo.',
+    tokenHintGitLab: 'Token con ambito api, oppure read_repository insieme a write_repository.',
+    tokenHintGitea: 'Token con ambito write:repository.',
+    tokenHintForgejo: 'Token con ambito write:repository. Basta un token limitato a questo solo repository.',
     branch: 'Branch',
     provider: 'Provider Git',
     providerGitHub: 'GitHub',

+ 4 - 1
frontend/src/i18n/locales/ja.ts

@@ -4840,7 +4840,10 @@ export default {
     personalAccessToken: '個人アクセストークン',
     tokenSaved: '(保存済み)',
     enterNewToken: '新しいトークンを入力して更新',
-    tokenHint: 'Contents読み書き権限を持つきめ細かいトークン',
+    tokenHintGitHub: 'Contentsの読み書き権限を持つきめ細かいトークン、またはrepoスコープを持つクラシックトークン。',
+    tokenHintGitLab: 'apiスコープ、またはread_repositoryとwrite_repositoryの両方を持つトークン。',
+    tokenHintGitea: 'write:repositoryスコープを持つトークン。',
+    tokenHintForgejo: 'write:repositoryスコープを持つトークン。このリポジトリだけに限定したトークンで十分です。',
     branch: 'ブランチ',
     provider: 'Gitプロバイダー',
     providerGitHub: 'GitHub',

+ 4 - 1
frontend/src/i18n/locales/ko.ts

@@ -4605,7 +4605,10 @@ export default {
     personalAccessToken: '개인 액세스 토큰',
     tokenSaved: '(저장됨)',
     enterNewToken: '업데이트하려면 새 토큰 입력',
-    tokenHint: '콘텐츠 읽기/쓰기 권한이 있는 세분화된 토큰',
+    tokenHintGitHub: 'Contents 읽기 및 쓰기 권한이 있는 세분화된 토큰 또는 repo 범위를 가진 클래식 토큰.',
+    tokenHintGitLab: 'api 범위 또는 read_repository와 write_repository를 함께 가진 토큰.',
+    tokenHintGitea: 'write:repository 범위를 가진 토큰.',
+    tokenHintForgejo: 'write:repository 범위를 가진 토큰. 이 저장소 하나로만 제한된 토큰이면 충분합니다.',
     branch: '브랜치',
     provider: 'Git 제공자',
     providerGitHub: 'GitHub',

+ 4 - 1
frontend/src/i18n/locales/pt-BR.ts

@@ -4828,7 +4828,10 @@ export default {
     personalAccessToken: 'Token de acesso pessoal',
     tokenSaved: '(salvo)',
     enterNewToken: 'Digite um novo token para atualizar',
-    tokenHint: 'Token de granularidade fina com permissão de leitura/escrita de conteúdo',
+    tokenHintGitHub: 'Token de granularidade fina com acesso de leitura e escrita a Contents, ou token clássico com o escopo repo.',
+    tokenHintGitLab: 'Token com o escopo api, ou read_repository junto com write_repository.',
+    tokenHintGitea: 'Token com o escopo write:repository.',
+    tokenHintForgejo: 'Token com o escopo write:repository. Um token limitado somente a este repositório já basta.',
     branch: 'Branch',
     provider: 'Provedor Git',
     providerGitHub: 'GitHub',

+ 4 - 1
frontend/src/i18n/locales/ru.ts

@@ -4597,7 +4597,10 @@ export default {
     personalAccessToken: "Персональный токен доступа",
     tokenSaved: "(сохранён)",
     enterNewToken: "Введите новый токен для обновления",
-    tokenHint: "Токен с точечными правами на чтение и запись содержимого",
+    tokenHintGitHub: "Токен с точечными правами на чтение и запись Contents либо классический токен с областью repo.",
+    tokenHintGitLab: "Токен с областью api либо read_repository вместе с write_repository.",
+    tokenHintGitea: "Токен с областью write:repository.",
+    tokenHintForgejo: "Токен с областью write:repository. Достаточно токена, ограниченного только этим репозиторием.",
     branch: "Ветка",
     provider: "Git-провайдер",
     providerGitHub: "GitHub",

+ 4 - 1
frontend/src/i18n/locales/tr.ts

@@ -4818,7 +4818,10 @@ export default {
     personalAccessToken: 'Kişisel Erişim Belirteci',
     tokenSaved: '(kaydedildi)',
     enterNewToken: 'Güncellemek için yeni belirteç girin',
-    tokenHint: 'Contents okuma/yazma izni olan ayrıntılı belirteç',
+    tokenHintGitHub: 'Contents okuma ve yazma erişimi olan ayrıntılı belirteç ya da repo kapsamlı klasik belirteç.',
+    tokenHintGitLab: 'api kapsamlı belirteç ya da read_repository ile write_repository birlikte.',
+    tokenHintGitea: 'write:repository kapsamlı belirteç.',
+    tokenHintForgejo: 'write:repository kapsamlı belirteç. Yalnızca bu depoyla sınırlı bir belirteç yeterlidir.',
     branch: 'Dal',
     provider: 'Git Sağlayıcısı',
     providerGitHub: 'GitHub',

+ 4 - 1
frontend/src/i18n/locales/uk.ts

@@ -4883,7 +4883,10 @@ export default {
     personalAccessToken: "Персональний токен доступу",
     tokenSaved: "(збережено)",
     enterNewToken: "Введіть новий токен для оновлення",
-    tokenHint: "Токен із деталізованими правами та дозволом на читання й запис вмісту",
+    tokenHintGitHub: "Токен із деталізованими правами на читання й запис Contents або класичний токен з областю repo.",
+    tokenHintGitLab: "Токен з областю api або read_repository разом із write_repository.",
+    tokenHintGitea: "Токен з областю write:repository.",
+    tokenHintForgejo: "Токен з областю write:repository. Достатньо токена, обмеженого лише цим репозиторієм.",
     branch: "Гілка",
     provider: "Постачальник Git",
     providerGitHub: "GitHub",

+ 4 - 1
frontend/src/i18n/locales/zh-CN.ts

@@ -4828,7 +4828,10 @@ export default {
     personalAccessToken: '个人访问令牌',
     tokenSaved: '(已保存)',
     enterNewToken: '输入新令牌以更新',
-    tokenHint: '具有内容读写权限的细粒度令牌',
+    tokenHintGitHub: '具有 Contents 读写权限的细粒度令牌,或具有 repo 范围的经典令牌。',
+    tokenHintGitLab: '具有 api 范围的令牌,或同时具有 read_repository 和 write_repository。',
+    tokenHintGitea: '具有 write:repository 范围的令牌。',
+    tokenHintForgejo: '具有 write:repository 范围的令牌。仅限于此仓库的令牌即可。',
     branch: '分支',
     provider: 'Git 提供商',
     providerGitHub: 'GitHub',

+ 4 - 1
frontend/src/i18n/locales/zh-TW.ts

@@ -4828,7 +4828,10 @@ export default {
     personalAccessToken: '個人存取權杖',
     tokenSaved: '(已儲存)',
     enterNewToken: '輸入新權杖以更新',
-    tokenHint: '具有內容讀寫權限的細粒度權杖',
+    tokenHintGitHub: '具有 Contents 讀寫權限的細粒度權杖,或具有 repo 範圍的傳統權杖。',
+    tokenHintGitLab: '具有 api 範圍的權杖,或同時具有 read_repository 與 write_repository。',
+    tokenHintGitea: '具有 write:repository 範圍的權杖。',
+    tokenHintForgejo: '具有 write:repository 範圍的權杖。僅限於此存放庫的權杖即可。',
     branch: '分支',
     provider: 'Git 供應商',
     providerGitHub: 'GitHub',

Datei-Diff unterdrückt, da er zu groß ist
+ 0 - 0
static/assets/index-CBRJgDPF.js


+ 1 - 1
static/index.html

@@ -26,7 +26,7 @@
 
     <!-- Splash screens for iOS -->
     <link rel="apple-touch-startup-image" href="/img/android-chrome-512x512.png" />
-    <script type="module" crossorigin src="/assets/index-FVTa4dYE.js"></script>
+    <script type="module" crossorigin src="/assets/index-CBRJgDPF.js"></script>
     <link rel="stylesheet" crossorigin href="/assets/index-DJ8Q_OV9.css">
   </head>
   <body>

Einige Dateien werden nicht angezeigt, da zu viele Dateien in diesem Diff geändert wurden.