forgejo.py 4.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114
  1. """Forgejo backend — diverges from Gitea on token-scope validation (v15+)."""
  2. import logging
  3. import httpx
  4. from backend.app.services.git_providers.gitea import GiteaBackend
  5. logger = logging.getLogger(__name__)
  6. class ForgejoBackend(GiteaBackend):
  7. """Backend for Forgejo instances.
  8. Forgejo v15+ returns 404 (not 403) for private repositories when the token
  9. lacks repository scope, so a bare repo call cannot tell "bad token" from
  10. "repo not visible" on its own. test_connection probes /user first to catch
  11. the outright-rejected token, then lets the repo call decide everything else.
  12. Other methods are inherited from GiteaBackend unchanged.
  13. """
  14. async def test_connection(self, repo_url: str, token: str, client: httpx.AsyncClient) -> dict:
  15. try:
  16. owner, repo = self.parse_repo_url(repo_url)
  17. api_base = self.get_api_base(repo_url)
  18. headers = self.get_headers(token)
  19. # Probe /user, but only a 401 here is conclusive: the instance rejects
  20. # the token outright, and saying so beats the 404 the repo call may
  21. # answer with instead (Forgejo v15+ hides private repos behind 404
  22. # rather than 403).
  23. #
  24. # Every other status falls through to the repo check (#2775). A
  25. # repository-scoped token — the kind Forgejo v15 recommends, limited
  26. # to one repo — can only carry read/write:issue and
  27. # read/write:repository, so /user answers 403 for exactly the tokens
  28. # worth encouraging. Treating that as fatal rejected a token that
  29. # reaches its own repository perfectly well, which is all a backup
  30. # needs: the push path uses the Contents API and the restore path
  31. # reads commits, trees and blobs, all under /repos/{owner}/{repo}.
  32. user_resp = await client.get(f"{api_base}/user", headers=headers)
  33. if user_resp.status_code == 401:
  34. return {"success": False, "message": "Invalid access token", "repo_name": None, "permissions": None}
  35. # Whether the token's identity was confirmed. Only used to word the
  36. # 404 below — an unconfirmed identity leaves "the token is invalid"
  37. # on the list of causes, a confirmed one rules it out.
  38. identity_confirmed = user_resp.status_code == 200
  39. repo_resp = await client.get(f"{api_base}/repos/{owner}/{repo}", headers=headers)
  40. if repo_resp.status_code == 401:
  41. return {"success": False, "message": "Invalid access token", "repo_name": None, "permissions": None}
  42. if repo_resp.status_code == 404:
  43. message = (
  44. "Repository not found or token cannot access it. "
  45. "On Forgejo v15+, private repositories return 404 (not 403) "
  46. "when the token lacks repository scope. Check that the token has "
  47. "write:repository, and that this repository is one it covers if the "
  48. "token is scoped to specific repositories."
  49. )
  50. if not identity_confirmed:
  51. message += " The token itself may also be invalid or expired."
  52. return {
  53. "success": False,
  54. "message": message,
  55. "repo_name": None,
  56. "permissions": None,
  57. }
  58. if repo_resp.status_code != 200:
  59. return {
  60. "success": False,
  61. "message": f"API error: {repo_resp.status_code}",
  62. "repo_name": None,
  63. "permissions": None,
  64. }
  65. data = repo_resp.json()
  66. permissions = data.get("permissions", {})
  67. is_private = bool(data.get("private", False))
  68. if not permissions.get("push", False):
  69. return {
  70. "success": False,
  71. "message": "Token does not have push permission to this repository",
  72. "repo_name": data.get("full_name"),
  73. "permissions": permissions,
  74. "is_private": is_private,
  75. }
  76. return {
  77. "success": True,
  78. "message": "Connection successful",
  79. "repo_name": data.get("full_name"),
  80. "permissions": permissions,
  81. "is_private": is_private,
  82. }
  83. except Exception as e:
  84. logger.exception("Forgejo connection test failed")
  85. detail = str(e)[:200]
  86. message = (
  87. f"Connection failed: {type(e).__name__}: {detail}"
  88. if detail
  89. else f"Connection failed: {type(e).__name__}"
  90. )
  91. return {
  92. "success": False,
  93. "message": message,
  94. "repo_name": None,
  95. "permissions": None,
  96. "is_private": None,
  97. }