pygitweb 0.2.0__tar.gz → 0.2.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (108) hide show
  1. {pygitweb-0.2.0 → pygitweb-0.2.2}/PKG-INFO +11 -11
  2. {pygitweb-0.2.0 → pygitweb-0.2.2}/README.md +10 -10
  3. {pygitweb-0.2.0 → pygitweb-0.2.2}/actions.py +8 -3
  4. {pygitweb-0.2.0 → pygitweb-0.2.2}/auth.py +32 -4
  5. {pygitweb-0.2.0 → pygitweb-0.2.2}/auth.schema.json +5 -1
  6. {pygitweb-0.2.0 → pygitweb-0.2.2}/auth_config.py +7 -4
  7. {pygitweb-0.2.0 → pygitweb-0.2.2}/auth_oauth.py +1 -2
  8. {pygitweb-0.2.0 → pygitweb-0.2.2}/auth_test.py +284 -6
  9. {pygitweb-0.2.0 → pygitweb-0.2.2}/hooks_install.py +1 -0
  10. {pygitweb-0.2.0 → pygitweb-0.2.2}/main.py +4 -3
  11. {pygitweb-0.2.0 → pygitweb-0.2.2}/permissions.py +52 -1
  12. {pygitweb-0.2.0 → pygitweb-0.2.2}/permissions_test.py +61 -0
  13. {pygitweb-0.2.0 → pygitweb-0.2.2}/pygitweb.egg-info/PKG-INFO +11 -11
  14. {pygitweb-0.2.0 → pygitweb-0.2.2}/pygitweb.egg-info/SOURCES.txt +1 -0
  15. {pygitweb-0.2.0 → pygitweb-0.2.2}/pyproject.toml +2 -2
  16. {pygitweb-0.2.0 → pygitweb-0.2.2}/static/board.js +13 -1
  17. {pygitweb-0.2.0 → pygitweb-0.2.2}/static/main.css +6 -0
  18. {pygitweb-0.2.0 → pygitweb-0.2.2}/tasks.py +6 -1
  19. {pygitweb-0.2.0 → pygitweb-0.2.2}/templates/board_card.html +40 -7
  20. {pygitweb-0.2.0 → pygitweb-0.2.2}/LICENSE +0 -0
  21. {pygitweb-0.2.0 → pygitweb-0.2.2}/__init__.py +0 -0
  22. {pygitweb-0.2.0 → pygitweb-0.2.2}/__meta__.py +0 -0
  23. {pygitweb-0.2.0 → pygitweb-0.2.2}/addproject_permissions_test.py +0 -0
  24. {pygitweb-0.2.0 → pygitweb-0.2.2}/api/__init__.py +0 -0
  25. {pygitweb-0.2.0 → pygitweb-0.2.2}/api/action.py +0 -0
  26. {pygitweb-0.2.0 → pygitweb-0.2.2}/api/plugins/__init__.py +0 -0
  27. {pygitweb-0.2.0 → pygitweb-0.2.2}/api/plugins/project_zip.py +0 -0
  28. {pygitweb-0.2.0 → pygitweb-0.2.2}/api/project.py +0 -0
  29. {pygitweb-0.2.0 → pygitweb-0.2.2}/api/subpage.py +0 -0
  30. {pygitweb-0.2.0 → pygitweb-0.2.2}/board_refs.py +0 -0
  31. {pygitweb-0.2.0 → pygitweb-0.2.2}/board_refs_test.py +0 -0
  32. {pygitweb-0.2.0 → pygitweb-0.2.2}/boards_permissions_test.py +0 -0
  33. {pygitweb-0.2.0 → pygitweb-0.2.2}/change_queue.py +0 -0
  34. {pygitweb-0.2.0 → pygitweb-0.2.2}/change_queue_routes_test.py +0 -0
  35. {pygitweb-0.2.0 → pygitweb-0.2.2}/change_queue_test.py +0 -0
  36. {pygitweb-0.2.0 → pygitweb-0.2.2}/comments_permissions_test.py +0 -0
  37. {pygitweb-0.2.0 → pygitweb-0.2.2}/config.py +0 -0
  38. {pygitweb-0.2.0 → pygitweb-0.2.2}/conftest.py +0 -0
  39. {pygitweb-0.2.0 → pygitweb-0.2.2}/dependencies.py +0 -0
  40. {pygitweb-0.2.0 → pygitweb-0.2.2}/formatting.py +0 -0
  41. {pygitweb-0.2.0 → pygitweb-0.2.2}/formatting_test.py +0 -0
  42. {pygitweb-0.2.0 → pygitweb-0.2.2}/git_helpers.py +0 -0
  43. {pygitweb-0.2.0 → pygitweb-0.2.2}/git_helpers_test.py +0 -0
  44. {pygitweb-0.2.0 → pygitweb-0.2.2}/gravatar.py +0 -0
  45. {pygitweb-0.2.0 → pygitweb-0.2.2}/gravatar_test.py +0 -0
  46. {pygitweb-0.2.0 → pygitweb-0.2.2}/hooks_install_test.py +0 -0
  47. {pygitweb-0.2.0 → pygitweb-0.2.2}/hooks_permissions_test.py +0 -0
  48. {pygitweb-0.2.0 → pygitweb-0.2.2}/merge_requests.py +0 -0
  49. {pygitweb-0.2.0 → pygitweb-0.2.2}/merge_requests_test.py +0 -0
  50. {pygitweb-0.2.0 → pygitweb-0.2.2}/mr_permissions_test.py +0 -0
  51. {pygitweb-0.2.0 → pygitweb-0.2.2}/password_hash.py +0 -0
  52. {pygitweb-0.2.0 → pygitweb-0.2.2}/password_hash_test.py +0 -0
  53. {pygitweb-0.2.0 → pygitweb-0.2.2}/plugin_loader.py +0 -0
  54. {pygitweb-0.2.0 → pygitweb-0.2.2}/project_list_routes_test.py +0 -0
  55. {pygitweb-0.2.0 → pygitweb-0.2.2}/projects.py +0 -0
  56. {pygitweb-0.2.0 → pygitweb-0.2.2}/projects_test.py +0 -0
  57. {pygitweb-0.2.0 → pygitweb-0.2.2}/pygitweb.egg-info/dependency_links.txt +0 -0
  58. {pygitweb-0.2.0 → pygitweb-0.2.2}/pygitweb.egg-info/requires.txt +0 -0
  59. {pygitweb-0.2.0 → pygitweb-0.2.2}/pygitweb.egg-info/top_level.txt +0 -0
  60. {pygitweb-0.2.0 → pygitweb-0.2.2}/sessions.py +0 -0
  61. {pygitweb-0.2.0 → pygitweb-0.2.2}/settings.py +0 -0
  62. {pygitweb-0.2.0 → pygitweb-0.2.2}/settings.schema.json +0 -0
  63. {pygitweb-0.2.0 → pygitweb-0.2.2}/settings_config.py +0 -0
  64. {pygitweb-0.2.0 → pygitweb-0.2.2}/settings_config_test.py +0 -0
  65. {pygitweb-0.2.0 → pygitweb-0.2.2}/settings_permissions_test.py +0 -0
  66. {pygitweb-0.2.0 → pygitweb-0.2.2}/setup.cfg +0 -0
  67. {pygitweb-0.2.0 → pygitweb-0.2.2}/shutdown.py +0 -0
  68. {pygitweb-0.2.0 → pygitweb-0.2.2}/smart_http.py +0 -0
  69. {pygitweb-0.2.0 → pygitweb-0.2.2}/smart_http_test.py +0 -0
  70. {pygitweb-0.2.0 → pygitweb-0.2.2}/static/blob-view.js +0 -0
  71. {pygitweb-0.2.0 → pygitweb-0.2.2}/static/board-refs.js +0 -0
  72. {pygitweb-0.2.0 → pygitweb-0.2.2}/static/hook-install.js +0 -0
  73. {pygitweb-0.2.0 → pygitweb-0.2.2}/static/icons/cloud-down.svg +0 -0
  74. {pygitweb-0.2.0 → pygitweb-0.2.2}/static/icons/cloud-exclamation.svg +0 -0
  75. {pygitweb-0.2.0 → pygitweb-0.2.2}/static/icons/cloud-filled.svg +0 -0
  76. {pygitweb-0.2.0 → pygitweb-0.2.2}/static/icons/cloud-storm.svg +0 -0
  77. {pygitweb-0.2.0 → pygitweb-0.2.2}/static/icons/cloud-up.svg +0 -0
  78. {pygitweb-0.2.0 → pygitweb-0.2.2}/static/icons/cloud.svg +0 -0
  79. {pygitweb-0.2.0 → pygitweb-0.2.2}/static/logo.svg +0 -0
  80. {pygitweb-0.2.0 → pygitweb-0.2.2}/static/navbar-auth.js +0 -0
  81. {pygitweb-0.2.0 → pygitweb-0.2.2}/static/readme-render.js +0 -0
  82. {pygitweb-0.2.0 → pygitweb-0.2.2}/static/search-page.js +0 -0
  83. {pygitweb-0.2.0 → pygitweb-0.2.2}/static/summary-ref-switcher.js +0 -0
  84. {pygitweb-0.2.0 → pygitweb-0.2.2}/static/themes/solarized.css +0 -0
  85. {pygitweb-0.2.0 → pygitweb-0.2.2}/tasks_permissions_test.py +0 -0
  86. {pygitweb-0.2.0 → pygitweb-0.2.2}/tasks_routes_test.py +0 -0
  87. {pygitweb-0.2.0 → pygitweb-0.2.2}/templates/addproject.html +0 -0
  88. {pygitweb-0.2.0 → pygitweb-0.2.2}/templates/blobdiff.html +0 -0
  89. {pygitweb-0.2.0 → pygitweb-0.2.2}/templates/board.html +0 -0
  90. {pygitweb-0.2.0 → pygitweb-0.2.2}/templates/commitdiff.html +0 -0
  91. {pygitweb-0.2.0 → pygitweb-0.2.2}/templates/hooks_summary_cell.html +0 -0
  92. {pygitweb-0.2.0 → pygitweb-0.2.2}/templates/login.html +0 -0
  93. {pygitweb-0.2.0 → pygitweb-0.2.2}/templates/merge_request_summary_cell.html +0 -0
  94. {pygitweb-0.2.0 → pygitweb-0.2.2}/templates/merge_request_tag.html +0 -0
  95. {pygitweb-0.2.0 → pygitweb-0.2.2}/templates/pagination.html +0 -0
  96. {pygitweb-0.2.0 → pygitweb-0.2.2}/templates/preamble.html +0 -0
  97. {pygitweb-0.2.0 → pygitweb-0.2.2}/templates/project_settings.html +0 -0
  98. {pygitweb-0.2.0 → pygitweb-0.2.2}/templates/pygit2_settings.html +0 -0
  99. {pygitweb-0.2.0 → pygitweb-0.2.2}/templates/pygitweb_settings.html +0 -0
  100. {pygitweb-0.2.0 → pygitweb-0.2.2}/templates/search.html +0 -0
  101. {pygitweb-0.2.0 → pygitweb-0.2.2}/templates/settings_form_field.html +0 -0
  102. {pygitweb-0.2.0 → pygitweb-0.2.2}/templates/shutdown_ack.html +0 -0
  103. {pygitweb-0.2.0 → pygitweb-0.2.2}/templates/table.html +0 -0
  104. {pygitweb-0.2.0 → pygitweb-0.2.2}/templates/user_profile.html +0 -0
  105. {pygitweb-0.2.0 → pygitweb-0.2.2}/templates_env.py +0 -0
  106. {pygitweb-0.2.0 → pygitweb-0.2.2}/timeline_cache.py +0 -0
  107. {pygitweb-0.2.0 → pygitweb-0.2.2}/validation.py +0 -0
  108. {pygitweb-0.2.0 → pygitweb-0.2.2}/validation_test.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pygitweb
3
- Version: 0.2.0
3
+ Version: 0.2.2
4
4
  Summary: Gitweb reimplementation with FastAPI and Pygit2
5
5
  License-Expression: MIT
6
6
  Requires-Python: >=3.11
@@ -40,7 +40,7 @@ environment variables, and optionally a `.env` file in the working directory via
40
40
 
41
41
  ## Routes
42
42
 
43
- Load the `/docs` page for a detailed view of routes (below the readme) if in debug mode.
43
+ Load the `/docs` page for a detailed view of routes (below the readme).
44
44
 
45
45
  **No-project routes**
46
46
 
@@ -68,8 +68,8 @@ Load the `/docs` page for a detailed view of routes (below the readme) if in deb
68
68
  | heads | `updates`, `pf` | `GET /project/{project}?a=heads` | List branch heads. Supports long-poll subscribe. |
69
69
  | tags | `updates`, `pf` | `GET /project/{project}?a=tags` | List all tags. Supports long-poll subscribe. |
70
70
  | tag | `h` | `GET /project/{project}?a=tag&h=...` | Single tag view (tag ref or hash). |
71
- | commit | — | `GET /project/{project}?a=commit` | Commit information. |
72
- | commitdiff | — | `GET /project/{project}?a=commitdiff` | Commit diff (unified diff rendered with [diff2html](https://github.com/rtfpessoa/diff2html)). |
71
+ | commit | `h` | `GET /project/{project}?a=commit&h=...` | Commit information. |
72
+ | commitdiff | `h` | `GET /project/{project}?a=commitdiff&h=...` | Commit diff (unified diff rendered with [diff2html](https://github.com/rtfpessoa/diff2html)). |
73
73
  | patch | `h` | `GET /project/{project}?a=patch&h=...` | Single-commit patch (plain text). |
74
74
  | patches | `h`, `hb` | `GET /project/{project}?a=patches&h=...&hb=...` | Multi-commit patches for range `hb..h` (plain text). |
75
75
  | blobdiff | `h`, `hb`, `f`, `fp` | `GET /project/{project}?a=blobdiff&h=...&hb=...&f=...&fp=...` | Blob diff (two versions of a file) rendered with diff2html. |
@@ -120,16 +120,16 @@ subscribe to the project's change queue. The request is held for up to 30 second
120
120
  just the URL project (the action is still rendered for the URL project).
121
121
 
122
122
  The notify endpoint is intended for loopback use by `pygittools` post-receive hooks (see
123
- `pygittools/hook_samples/post-receive.notify`); production deployments should restrict it
124
- at the reverse proxy.
123
+ `pygittools/hook_samples/post-receive.notify`). The server enforces loopback-only access and
124
+ rejects unknown projects; production deployments should still restrict it at the reverse proxy.
125
125
 
126
126
  ## Hook management
127
127
 
128
- The project summary page exposes an **Update Hook** row that installs / removes the
129
- `update` bundle: `post-receive.notify` (feeds the change queue when refs are pushed) and
130
- `post-commit.notify` (feeds the change queue when a working clone of this repo records
131
- a local commit). Either is sufficient to wake long-poll subscribers; installing both
132
- covers server-side and client-side commit paths.
128
+ The project summary page exposes a **Hooks** row that lists installed hooks and lets you
129
+ install or remove pygittools samples, including the `update` bundle: `post-receive.notify`
130
+ (feeds the change queue when refs are pushed) and `post-commit.notify` (feeds the change
131
+ queue when a working clone of this repo records a local commit). Either is sufficient to
132
+ wake long-poll subscribers; installing both covers server-side and client-side commit paths.
133
133
 
134
134
  The same operations are available programmatically via
135
135
  `POST /project/{name}/hook?name=<sample-or-bundle>&op=<...>`. Bundle status is
@@ -20,7 +20,7 @@ environment variables, and optionally a `.env` file in the working directory via
20
20
 
21
21
  ## Routes
22
22
 
23
- Load the `/docs` page for a detailed view of routes (below the readme) if in debug mode.
23
+ Load the `/docs` page for a detailed view of routes (below the readme).
24
24
 
25
25
  **No-project routes**
26
26
 
@@ -48,8 +48,8 @@ Load the `/docs` page for a detailed view of routes (below the readme) if in deb
48
48
  | heads | `updates`, `pf` | `GET /project/{project}?a=heads` | List branch heads. Supports long-poll subscribe. |
49
49
  | tags | `updates`, `pf` | `GET /project/{project}?a=tags` | List all tags. Supports long-poll subscribe. |
50
50
  | tag | `h` | `GET /project/{project}?a=tag&h=...` | Single tag view (tag ref or hash). |
51
- | commit | — | `GET /project/{project}?a=commit` | Commit information. |
52
- | commitdiff | — | `GET /project/{project}?a=commitdiff` | Commit diff (unified diff rendered with [diff2html](https://github.com/rtfpessoa/diff2html)). |
51
+ | commit | `h` | `GET /project/{project}?a=commit&h=...` | Commit information. |
52
+ | commitdiff | `h` | `GET /project/{project}?a=commitdiff&h=...` | Commit diff (unified diff rendered with [diff2html](https://github.com/rtfpessoa/diff2html)). |
53
53
  | patch | `h` | `GET /project/{project}?a=patch&h=...` | Single-commit patch (plain text). |
54
54
  | patches | `h`, `hb` | `GET /project/{project}?a=patches&h=...&hb=...` | Multi-commit patches for range `hb..h` (plain text). |
55
55
  | blobdiff | `h`, `hb`, `f`, `fp` | `GET /project/{project}?a=blobdiff&h=...&hb=...&f=...&fp=...` | Blob diff (two versions of a file) rendered with diff2html. |
@@ -100,16 +100,16 @@ subscribe to the project's change queue. The request is held for up to 30 second
100
100
  just the URL project (the action is still rendered for the URL project).
101
101
 
102
102
  The notify endpoint is intended for loopback use by `pygittools` post-receive hooks (see
103
- `pygittools/hook_samples/post-receive.notify`); production deployments should restrict it
104
- at the reverse proxy.
103
+ `pygittools/hook_samples/post-receive.notify`). The server enforces loopback-only access and
104
+ rejects unknown projects; production deployments should still restrict it at the reverse proxy.
105
105
 
106
106
  ## Hook management
107
107
 
108
- The project summary page exposes an **Update Hook** row that installs / removes the
109
- `update` bundle: `post-receive.notify` (feeds the change queue when refs are pushed) and
110
- `post-commit.notify` (feeds the change queue when a working clone of this repo records
111
- a local commit). Either is sufficient to wake long-poll subscribers; installing both
112
- covers server-side and client-side commit paths.
108
+ The project summary page exposes a **Hooks** row that lists installed hooks and lets you
109
+ install or remove pygittools samples, including the `update` bundle: `post-receive.notify`
110
+ (feeds the change queue when refs are pushed) and `post-commit.notify` (feeds the change
111
+ queue when a working clone of this repo records a local commit). Either is sufficient to
112
+ wake long-poll subscribers; installing both covers server-side and client-side commit paths.
113
113
 
114
114
  The same operations are available programmatically via
115
115
  `POST /project/{name}/hook?name=<sample-or-bundle>&op=<...>`. Bundle status is
@@ -20,6 +20,7 @@ from fastapi.responses import HTMLResponse, JSONResponse, PlainTextResponse, Res
20
20
  from python_ripgrep import PySortMode, PySortModeKind
21
21
  from python_ripgrep import search as rg_search
22
22
 
23
+ from pygitweb.auth import can_read_branch
23
24
  from pygitweb.config import BLOB_LANG, settings
24
25
  from pygitweb.formatting import age_string, sanitize, to_utf8
25
26
  from pygitweb.git_helpers import (
@@ -972,13 +973,17 @@ def git_history(
972
973
  )
973
974
 
974
975
 
975
- def git_heads(project: str) -> HTMLResponse:
976
+ def git_heads(project: str, token: str | None) -> HTMLResponse:
976
977
  """Heads (branches) list page. Port of git_heads."""
977
978
  heads_list = git_get_heads_list(project)
978
- if not heads_list:
979
+ if settings.AUTH:
980
+ visible_heads = [(name, ref, oid) for name, ref, oid in heads_list if can_read_branch(project, ref, token)]
981
+ else:
982
+ visible_heads = heads_list
983
+ if not visible_heads:
979
984
  raise HTTPException(status_code=404, detail="No heads found")
980
985
  rows = []
981
- for name, _ref, oid in heads_list:
986
+ for name, _ref, oid in visible_heads:
982
987
  oid_short = oid[:7] if oid else ""
983
988
  commit_link = f"/project/{project}?a=commit&h={quote(oid, safe='')}"
984
989
  tree_link = f"/project/{project}?a=tree&h={quote(oid, safe='')}"
@@ -32,7 +32,7 @@ from pygitweb.auth_oauth import OAUTH_STATE_COOKIE_NAME, oauth_router
32
32
  from pygitweb.config import settings
33
33
  from pygitweb.gravatar import gravatar_url
34
34
  from pygitweb.password_hash import verify_password
35
- from pygitweb.permissions import Permission, PermissionPrincipal
35
+ from pygitweb.permissions import Permission, PermissionPrincipal, has_branch_read_permission
36
36
  from pygitweb.projects import git_get_project_owner
37
37
  from pygitweb.sessions import create_session, get_session, revoke_session
38
38
  from pygitweb.templates_env import POSTAMBLE, PREAMBLE, env
@@ -65,6 +65,23 @@ def principal_from_session(token: str | None) -> PermissionPrincipal | None:
65
65
  return PermissionPrincipal(username=rec.username, email=rec.email)
66
66
 
67
67
 
68
+ def _anonymous_principal_for_permissions() -> PermissionPrincipal | None:
69
+ for entry in auth_config.local_users:
70
+ if getattr(entry, "assume_anonymous", False):
71
+ return PermissionPrincipal(username=entry.user, email=entry.email)
72
+ return None
73
+
74
+
75
+ def _principal_identity_for_permissions(token: str | None) -> str | None:
76
+ principal = principal_from_session(token)
77
+ if principal is not None:
78
+ return principal.identity
79
+ anonymous = _anonymous_principal_for_permissions()
80
+ if anonymous is not None:
81
+ return anonymous.identity
82
+ return None
83
+
84
+
68
85
  def has_permission(
69
86
  permission: Permission,
70
87
  project: str | None = None,
@@ -73,10 +90,10 @@ def has_permission(
73
90
  ) -> bool:
74
91
  if not settings.AUTH:
75
92
  return True
76
- principal = principal_from_session(token)
77
- if principal is None:
93
+ identity = _principal_identity_for_permissions(token)
94
+ if identity is None:
78
95
  return False
79
- return auth_config.oauth_permissions.has_permission(principal.identity, permission, project)
96
+ return auth_config.oauth_permissions.has_permission(identity, permission, project)
80
97
 
81
98
 
82
99
  def _owner_matches(project: str, token: str | None) -> bool:
@@ -111,6 +128,17 @@ def can_write_project(project: str, token: str | None) -> bool:
111
128
  return has_permission(Permission.WRITE, project, token=token)
112
129
 
113
130
 
131
+ def can_read_branch(project: str, ref_name: str, token: str | None) -> bool:
132
+ if not settings.AUTH:
133
+ return True
134
+ if _owner_matches(project, token):
135
+ return True
136
+ identity = _principal_identity_for_permissions(token)
137
+ if identity is None:
138
+ return False
139
+ return has_branch_read_permission(auth_config.oauth_permissions, identity, project, ref_name)
140
+
141
+
114
142
  def ensure_read_project(project: str, token: str | None) -> None:
115
143
  if can_read_project(project, token):
116
144
  return
@@ -60,7 +60,7 @@
60
60
  "additionalProperties": {
61
61
  "type": "array",
62
62
  "items": { "type": "string" },
63
- "description": "Scoped keys: pgw.read.{scope}, pgw.write.{scope}, pgw.boards.{scope}, pgw.tasks.{scope}, pgw.comments.{scope}, pgw.hooks.{scope}, pgw.mr.create.{scope}, pgw.mr.merge.{scope} (fnmatch on project path). Principal * matches any authenticated user."
63
+ "description": "Scoped keys: pgw.read.{scope}, pgw.write.{scope}, pgw.read_branch.{scope}, pgw.write_branch.{scope}, pgw.boards.{scope}, pgw.tasks.{scope}, pgw.comments.{scope}, pgw.hooks.{scope}, pgw.mr.create.{scope}, pgw.mr.merge.{scope} (fnmatch on project or project/branch path). Principal * matches any authenticated user."
64
64
  }
65
65
  },
66
66
  "local_users": {
@@ -94,6 +94,10 @@
94
94
  "type": "string",
95
95
  "format": "email",
96
96
  "description": "Optional e-mail for Gravatar and permission grants by principal e-mail."
97
+ },
98
+ "assume_anonymous": {
99
+ "type": "boolean",
100
+ "description": "If true, unauthenticated requests assume this user for read permissions (e.g. public viewer role)."
97
101
  }
98
102
  }
99
103
  }
@@ -12,14 +12,16 @@ import secrets
12
12
  import stat
13
13
  import sys
14
14
  from pathlib import Path
15
- from typing import Literal
15
+ from typing import TYPE_CHECKING, Literal
16
16
 
17
17
  from pydantic import BaseModel, ConfigDict, Field, field_serializer, field_validator
18
18
 
19
- from pygitweb.config import Settings
20
19
  from pygitweb.password_hash import hash_password
21
20
  from pygitweb.permissions import PermissionsMap
22
21
 
22
+ if TYPE_CHECKING:
23
+ from pygitweb.config import Settings
24
+
23
25
  DEFAULT_AUTH_CONFIG_PATH = Path.home() / ".pygitweb" / "auth.json"
24
26
  _AUTH_CONFIG_MODE = 0o600
25
27
 
@@ -70,6 +72,7 @@ class LocalUser(BaseModel):
70
72
  password: str | None = Field(default=None, alias="pass", serialization_alias="pass")
71
73
  pass_hash: str = ""
72
74
  email: str | None = None
75
+ assume_anonymous: bool = False
73
76
 
74
77
  @field_validator("email", mode="before")
75
78
  @classmethod
@@ -91,7 +94,7 @@ class AuthConfig(BaseModel):
91
94
  oauth_client_id: str = ""
92
95
  oauth_client_secret: str = ""
93
96
  oauth_redirect_uri: str = ""
94
- oauth_username_from: OAuthUsernameFrom = "email"
97
+ oauth_username_from: OAuthUsernameFrom = "preffered_username"
95
98
  oauth_allowed_emails: list[str] = Field(default_factory=list)
96
99
  oauth_permissions: PermissionsMap = Field(default_factory=PermissionsMap.empty)
97
100
  local_users: list[LocalUser] = Field(default_factory=list)
@@ -132,7 +135,7 @@ def default_auth_config(*, admin_password: str, salt: str) -> AuthConfig:
132
135
  oauth_client_id="",
133
136
  oauth_client_secret="",
134
137
  oauth_redirect_uri="",
135
- oauth_username_from="email",
138
+ oauth_username_from="preffered_username",
136
139
  oauth_allowed_emails=[],
137
140
  oauth_permissions=PermissionsMap.default_access(),
138
141
  local_users=[LocalUser(user="admin", pass_hash=hash_password(admin_password, config_salt=salt))],
@@ -323,10 +323,9 @@ async def oauth_callback(
323
323
  if not is_oauth_email_allowed(auth_config, email):
324
324
  raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Email is not allowed to sign in")
325
325
  username = oauth_username_from_profile(auth_config, profile)
326
- subject = _profile_subject(profile)
327
326
  sid = create_session(
328
327
  username,
329
- subject=subject,
328
+ subject=username,
330
329
  email=email,
331
330
  auth_method="oauth",
332
331
  )
@@ -1,16 +1,29 @@
1
+ import asyncio
1
2
  import json
2
3
  import os
3
4
  import sys
4
5
  from collections.abc import Generator
5
6
  from pathlib import Path
6
7
  from unittest.mock import AsyncMock, patch
8
+ from urllib.parse import parse_qs, urlparse
7
9
 
10
+ import httpx
8
11
  import pytest
12
+ from fastapi import FastAPI, HTTPException, Request
9
13
  from fastapi.testclient import TestClient
14
+ from httpx import ASGITransport
10
15
 
11
16
  from pygitweb.auth import ACCESS_TOKEN_COOKIE_NAME
12
17
  from pygitweb.auth_config import AuthConfig, LocalUser, init_auth_config, write_auth_config
13
- from pygitweb.auth_oauth import OAUTH_STATE_COOKIE_NAME, _seal_oauth_state, is_oauth_email_allowed
18
+ from pygitweb.auth_oauth import (
19
+ OAUTH_STATE_COOKIE_NAME,
20
+ _exchange_code,
21
+ _fetch_github_profile,
22
+ _fetch_google_profile,
23
+ _fetch_profile,
24
+ _seal_oauth_state,
25
+ is_oauth_email_allowed,
26
+ )
14
27
  from pygitweb.config import Settings, settings
15
28
  from pygitweb.conftest import clear_client_cookies
16
29
  from pygitweb.gravatar import gravatar_url
@@ -19,12 +32,68 @@ from pygitweb.password_hash import hash_password, verify_password
19
32
  from pygitweb.permissions import PERMISSION_ADD_PROJECTS, PermissionsMap
20
33
  from pygitweb.sessions import get_session
21
34
 
35
+ _REAL_ASYNC_CLIENT = httpx.AsyncClient
36
+
37
+
38
+ class _DummyResponse:
39
+ def __init__(self, payload: object, is_success: bool = True) -> None:
40
+ self._payload = payload
41
+ self.is_success = is_success
42
+
43
+ def raise_for_status(self) -> None:
44
+ return None
45
+
46
+ def json(self) -> object:
47
+ return self._payload
48
+
49
+
22
50
  _LOCAL_ADMIN = AuthConfig(
23
51
  auth_mode="local",
24
52
  local_users=[LocalUser(user="admin", password="secret")],
25
53
  )
26
54
 
27
55
 
56
+ def _build_test_idp_app(
57
+ email: str = "user@example.com",
58
+ preferred_username: str = "oauthuser",
59
+ ) -> FastAPI:
60
+ idp = FastAPI()
61
+
62
+ @idp.post("/token")
63
+ async def google_token(request: Request) -> dict[str, str]:
64
+ form = await request.form()
65
+ grant_type = str(form.get("grant_type") or "")
66
+ code = str(form.get("code") or "")
67
+ if grant_type != "authorization_code" or code != "auth-code":
68
+ return {"error": "invalid_grant"}
69
+ return {"access_token": "idp-access-token"}
70
+
71
+ @idp.post("/login/oauth/access_token")
72
+ async def github_token(request: Request) -> dict[str, str]:
73
+ form = await request.form()
74
+ code = str(form.get("code") or "")
75
+ if code != "auth-code":
76
+ return {"error": "bad_code"}
77
+ return {"access_token": "idp-access-token"}
78
+
79
+ @idp.get("/v1/userinfo")
80
+ async def google_userinfo() -> dict[str, str]:
81
+ return {"sub": "oauth-sub-1", "email": email, "preferred_username": preferred_username}
82
+
83
+ @idp.get("/user")
84
+ async def github_user() -> dict[str, object]:
85
+ return {"id": "123", "login": preferred_username, "email": None}
86
+
87
+ @idp.get("/user/emails")
88
+ async def github_emails() -> list[dict[str, object]]:
89
+ return [
90
+ {"email": email, "primary": True, "verified": True},
91
+ {"email": "other@example.com", "primary": False, "verified": True},
92
+ ]
93
+
94
+ return idp
95
+
96
+
28
97
  @pytest.fixture
29
98
  def client() -> Generator[TestClient, None, None]:
30
99
  with TestClient(app) as c:
@@ -271,6 +340,32 @@ def test_oauth_allowed_emails_exact_match() -> None:
271
340
  assert not is_oauth_email_allowed(cfg, "other@example.com")
272
341
 
273
342
 
343
+ def test_anonymous_user_assumes_local_public_role_for_read_permissions() -> None:
344
+ from pygitweb.auth import can_read_project
345
+
346
+ cfg = AuthConfig(
347
+ auth_mode="local",
348
+ local_users=[
349
+ LocalUser(
350
+ user="public",
351
+ password="secret",
352
+ email="public@example.com",
353
+ assume_anonymous=True,
354
+ ),
355
+ ],
356
+ oauth_permissions=PermissionsMap.model_validate({
357
+ "*": [],
358
+ "pgw.read.secret": ["public@example.com"],
359
+ }),
360
+ )
361
+ with (
362
+ patch.object(settings, "AUTH", True),
363
+ patch("pygitweb.auth.auth_config", cfg),
364
+ ):
365
+ assert can_read_project("secret", None)
366
+ assert not can_read_project("other", None)
367
+
368
+
274
369
  _OAUTH_CFG = AuthConfig(
275
370
  auth_mode="oauth",
276
371
  oauth_provider="google",
@@ -322,15 +417,50 @@ def test_oauth_callback_creates_session(client: TestClient, oauth_auth_config: A
322
417
  assert r.status_code == 303
323
418
  rec = get_session(r.cookies["pygitweb_access_token"])
324
419
  assert rec is not None
325
- assert rec.username == "user@example.com"
326
- assert rec.subject == "oauth-sub-1"
420
+ assert rec.username == "oauthuser"
421
+ assert rec.subject == "oauthuser"
327
422
  assert rec.email == "user@example.com"
328
423
  assert rec.auth_method == "oauth"
329
- assert client.get("/users/me").json()["username"] == "user@example.com"
424
+ assert client.get("/users/me").json()["username"] == "oauthuser"
330
425
  status = client.get("/auth/status").json()
331
- assert status["username"] == "user@example.com"
426
+ assert status["username"] == "oauthuser"
332
427
  assert status["gravatar_url"] == gravatar_url("user@example.com")
333
- clear_client_cookies(client)
428
+ clear_client_cookies(client)
429
+
430
+
431
+ def test_oauth_flow_with_asgi_idp(client: TestClient, oauth_auth_config: AuthConfig) -> None:
432
+ idp_app = _build_test_idp_app()
433
+
434
+ async def runner() -> None:
435
+ transport = ASGITransport(app=idp_app)
436
+
437
+ def _idp_client_factory(*_: object, **__: object) -> httpx.AsyncClient:
438
+ return _REAL_ASYNC_CLIENT(transport=transport, base_url="https://idp.test")
439
+
440
+ with (
441
+ patch.object(settings, "AUTH", True),
442
+ patch("pygitweb.auth_oauth.httpx.AsyncClient", side_effect=_idp_client_factory),
443
+ ):
444
+ start = client.get("/auth/oauth/start", params={"next": "/boards"}, follow_redirects=False)
445
+ assert start.status_code == 303
446
+ location = start.headers["location"]
447
+ state_values = parse_qs(urlparse(location).query).get("state", [])
448
+ assert state_values
449
+ state = state_values[0]
450
+ callback = client.get(
451
+ "/auth/oauth/callback",
452
+ params={"code": "auth-code", "state": state},
453
+ follow_redirects=False,
454
+ )
455
+ assert callback.status_code == 303
456
+ rec = get_session(callback.cookies[ACCESS_TOKEN_COOKIE_NAME])
457
+ assert rec is not None
458
+ assert rec.username == "oauthuser"
459
+ assert rec.subject == "oauthuser"
460
+ assert rec.email == "user@example.com"
461
+ assert rec.auth_method == "oauth"
462
+
463
+ asyncio.run(runner())
334
464
 
335
465
 
336
466
  def test_auth_status_no_gravatar_for_local_login(client: TestClient, local_auth_config: AuthConfig) -> None:
@@ -388,3 +518,151 @@ def test_authenticate_local_user_pass_hash() -> None:
388
518
  assert user is not None
389
519
  assert user.username == "admin"
390
520
  assert authenticate_local_user("admin", "wrong") is None
521
+
522
+
523
+ def test_exchange_code_success(oauth_auth_config: AuthConfig) -> None:
524
+ resp = _DummyResponse({"access_token": "tok", "other": "x"})
525
+
526
+ client = AsyncMock()
527
+ client.post.return_value = resp
528
+ client.__aenter__.return_value = client
529
+ client.__aexit__.return_value = None
530
+
531
+ async def runner() -> None:
532
+ with patch("pygitweb.auth_oauth.httpx.AsyncClient", return_value=client):
533
+ out = await _exchange_code(
534
+ oauth_auth_config,
535
+ code="auth-code",
536
+ code_verifier="verifier",
537
+ )
538
+ assert out["access_token"] == "tok"
539
+
540
+ asyncio.run(runner())
541
+
542
+
543
+ def test_exchange_code_rejects_non_mapping(oauth_auth_config: AuthConfig) -> None:
544
+ resp = _DummyResponse(["not-a-dict"])
545
+
546
+ client = AsyncMock()
547
+ client.post.return_value = resp
548
+ client.__aenter__.return_value = client
549
+ client.__aexit__.return_value = None
550
+
551
+ async def runner() -> None:
552
+ with patch("pygitweb.auth_oauth.httpx.AsyncClient", return_value=client), pytest.raises(HTTPException):
553
+ await _exchange_code(
554
+ oauth_auth_config,
555
+ code="auth-code",
556
+ code_verifier="verifier",
557
+ )
558
+
559
+ asyncio.run(runner())
560
+
561
+
562
+ def test_fetch_google_profile_success(oauth_auth_config: AuthConfig) -> None:
563
+ resp = _DummyResponse({"sub": "123", "email": "user@example.com"})
564
+
565
+ client = AsyncMock()
566
+ client.get.return_value = resp
567
+ client.__aenter__.return_value = client
568
+ client.__aexit__.return_value = None
569
+
570
+ async def runner() -> None:
571
+ with patch("pygitweb.auth_oauth.httpx.AsyncClient", return_value=client):
572
+ profile = await _fetch_google_profile("access-token")
573
+ assert profile["email"] == "user@example.com"
574
+
575
+ asyncio.run(runner())
576
+
577
+
578
+ def test_fetch_google_profile_rejects_non_mapping(oauth_auth_config: AuthConfig) -> None:
579
+ resp = _DummyResponse("not-a-dict")
580
+
581
+ client = AsyncMock()
582
+ client.get.return_value = resp
583
+ client.__aenter__.return_value = client
584
+ client.__aexit__.return_value = None
585
+
586
+ async def runner() -> None:
587
+ with patch("pygitweb.auth_oauth.httpx.AsyncClient", return_value=client), pytest.raises(HTTPException):
588
+ await _fetch_google_profile("access-token")
589
+
590
+ asyncio.run(runner())
591
+
592
+
593
+ def test_fetch_github_profile_uses_primary_verified_email() -> None:
594
+ resp_user = _DummyResponse({"login": "octocat", "email": None})
595
+ resp_emails = _DummyResponse([
596
+ {"email": "secondary@example.com", "primary": False, "verified": True},
597
+ {"email": "primary@example.com", "primary": True, "verified": True},
598
+ ])
599
+
600
+ client = AsyncMock()
601
+ client.get.side_effect = (resp_user, resp_emails)
602
+ client.__aenter__.return_value = client
603
+ client.__aexit__.return_value = None
604
+
605
+ async def runner() -> None:
606
+ with patch("pygitweb.auth_oauth.httpx.AsyncClient", return_value=client):
607
+ profile = await _fetch_github_profile("access-token")
608
+ assert profile["email"] == "primary@example.com"
609
+
610
+ asyncio.run(runner())
611
+
612
+
613
+ def test_fetch_github_profile_rejects_non_mapping() -> None:
614
+ resp_user = _DummyResponse("not-a-dict")
615
+
616
+ client = AsyncMock()
617
+ client.get.return_value = resp_user
618
+ client.__aenter__.return_value = client
619
+ client.__aexit__.return_value = None
620
+
621
+ async def runner() -> None:
622
+ with patch("pygitweb.auth_oauth.httpx.AsyncClient", return_value=client), pytest.raises(HTTPException):
623
+ await _fetch_github_profile("access-token")
624
+
625
+ asyncio.run(runner())
626
+
627
+
628
+ def test_fetch_profile_google(oauth_auth_config: AuthConfig) -> None:
629
+ token_payload = {"access_token": "tok"}
630
+ profile: dict[str, object] = {"sub": "123"}
631
+
632
+ async def runner() -> None:
633
+ with patch("pygitweb.auth_oauth._fetch_google_profile", new_callable=AsyncMock, return_value=profile):
634
+ out = await _fetch_profile(oauth_auth_config, token_payload)
635
+ assert out is profile
636
+
637
+ asyncio.run(runner())
638
+
639
+
640
+ def test_fetch_profile_github() -> None:
641
+ cfg = _OAUTH_CFG.model_copy(update={"oauth_provider": "github"})
642
+ token_payload = {"access_token": "tok"}
643
+ profile: dict[str, object] = {"id": "123"}
644
+
645
+ async def runner() -> None:
646
+ with patch("pygitweb.auth_oauth._fetch_github_profile", new_callable=AsyncMock, return_value=profile):
647
+ out = await _fetch_profile(cfg, token_payload)
648
+ assert out is profile
649
+
650
+ asyncio.run(runner())
651
+
652
+
653
+ def test_fetch_profile_missing_access_token(oauth_auth_config: AuthConfig) -> None:
654
+ async def runner() -> None:
655
+ with pytest.raises(HTTPException):
656
+ await _fetch_profile(oauth_auth_config, {})
657
+
658
+ asyncio.run(runner())
659
+
660
+
661
+ def test_fetch_profile_unknown_provider() -> None:
662
+ cfg = _OAUTH_CFG.model_copy(update={"oauth_provider": "other"})
663
+
664
+ async def runner() -> None:
665
+ with pytest.raises(HTTPException):
666
+ await _fetch_profile(cfg, {"access_token": "tok"})
667
+
668
+ asyncio.run(runner())
@@ -34,6 +34,7 @@ _SAMPLE_LABELS: dict[str, str] = {
34
34
  "pre-commit.ruff": "Ruff Pre-commit",
35
35
  "commit-msg.pattern": "Commit Message Pattern",
36
36
  "pre-receive.protected-pattern": "Protected Branch Commit Messages",
37
+ "pre-receive.branch-permissions": "Branch Write Permissions",
37
38
  "post-commit.push-remotes": "Post-commit Push Remotes",
38
39
  }
39
40
 
@@ -10,6 +10,7 @@ import os
10
10
  from collections.abc import AsyncGenerator
11
11
  from contextlib import asynccontextmanager
12
12
  from datetime import UTC, datetime
13
+ from importlib.resources import files
13
14
  from pathlib import Path
14
15
  from typing import Annotated, Literal
15
16
  from urllib.parse import quote, urlencode
@@ -163,8 +164,7 @@ async def app_lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
163
164
  clear_all_sessions()
164
165
 
165
166
 
166
- with open("pygitweb/README.md", encoding="utf-8") as _readme_file:
167
- _app_description = _readme_file.read()
167
+ _app_description = (files("pygitweb") / "README.md").read_text(encoding="utf-8")
168
168
 
169
169
  app = FastAPI(
170
170
  debug=(settings.AUTH is False),
@@ -751,6 +751,7 @@ async def internal_notify(
751
751
  async def dispatch(
752
752
  request: Request,
753
753
  project: ValidatedReadableProject,
754
+ token: Annotated[str | None, Depends(access_token_from_request)],
754
755
  a: Annotated[str | None, Query(alias="a")] = None,
755
756
  h: Annotated[str | None, Query(alias="h")] = None,
756
757
  hb: Annotated[str | None, Query(alias="hb")] = None,
@@ -845,7 +846,7 @@ async def dispatch(
845
846
  p, pc = parse_pagination(page, pagecount)
846
847
  return git_history(project, hash_param, file_name, request, p, pc)
847
848
  if action == "heads":
848
- return git_heads(project)
849
+ return git_heads(project, token)
849
850
  if action == "tags":
850
851
  p, pc = parse_pagination(page, pagecount)
851
852
  return git_tags(project, request, p, pc)