pygitweb 0.1.0__tar.gz → 0.2.1__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 (109) hide show
  1. {pygitweb-0.1.0 → pygitweb-0.2.1}/PKG-INFO +12 -38
  2. {pygitweb-0.1.0 → pygitweb-0.2.1}/README.md +10 -36
  3. {pygitweb-0.1.0 → pygitweb-0.2.1}/__meta__.py +1 -1
  4. {pygitweb-0.1.0 → pygitweb-0.2.1}/actions.py +2 -1
  5. {pygitweb-0.1.0 → pygitweb-0.2.1}/addproject_permissions_test.py +2 -3
  6. {pygitweb-0.1.0 → pygitweb-0.2.1}/auth.py +69 -0
  7. {pygitweb-0.1.0 → pygitweb-0.2.1}/auth.schema.json +1 -1
  8. {pygitweb-0.1.0 → pygitweb-0.2.1}/auth_config.py +1 -1
  9. {pygitweb-0.1.0 → pygitweb-0.2.1}/auth_test.py +183 -12
  10. pygitweb-0.2.1/board_refs.py +154 -0
  11. pygitweb-0.2.1/board_refs_test.py +106 -0
  12. {pygitweb-0.1.0 → pygitweb-0.2.1}/boards_permissions_test.py +33 -3
  13. {pygitweb-0.1.0 → pygitweb-0.2.1}/change_queue_routes_test.py +22 -0
  14. {pygitweb-0.1.0 → pygitweb-0.2.1}/comments_permissions_test.py +97 -5
  15. {pygitweb-0.1.0 → pygitweb-0.2.1}/dependencies.py +81 -2
  16. {pygitweb-0.1.0 → pygitweb-0.2.1}/git_helpers.py +59 -0
  17. {pygitweb-0.1.0 → pygitweb-0.2.1}/git_helpers_test.py +44 -0
  18. {pygitweb-0.1.0 → pygitweb-0.2.1}/hooks_install.py +46 -1
  19. {pygitweb-0.1.0 → pygitweb-0.2.1}/hooks_install_test.py +20 -0
  20. {pygitweb-0.1.0 → pygitweb-0.2.1}/hooks_permissions_test.py +3 -4
  21. {pygitweb-0.1.0 → pygitweb-0.2.1}/main.py +65 -67
  22. {pygitweb-0.1.0 → pygitweb-0.2.1}/merge_requests.py +15 -9
  23. {pygitweb-0.1.0 → pygitweb-0.2.1}/merge_requests_test.py +1 -1
  24. {pygitweb-0.1.0 → pygitweb-0.2.1}/mr_permissions_test.py +84 -5
  25. {pygitweb-0.1.0 → pygitweb-0.2.1}/permissions.py +29 -3
  26. {pygitweb-0.1.0 → pygitweb-0.2.1}/permissions_test.py +54 -0
  27. pygitweb-0.2.1/project_list_routes_test.py +56 -0
  28. {pygitweb-0.1.0 → pygitweb-0.2.1}/pygitweb.egg-info/PKG-INFO +12 -38
  29. {pygitweb-0.1.0 → pygitweb-0.2.1}/pygitweb.egg-info/SOURCES.txt +17 -0
  30. {pygitweb-0.1.0 → pygitweb-0.2.1}/pygitweb.egg-info/requires.txt +1 -1
  31. {pygitweb-0.1.0 → pygitweb-0.2.1}/pyproject.toml +3 -3
  32. {pygitweb-0.1.0 → pygitweb-0.2.1}/settings.py +17 -4
  33. {pygitweb-0.1.0 → pygitweb-0.2.1}/settings_permissions_test.py +24 -3
  34. pygitweb-0.2.1/shutdown.py +61 -0
  35. pygitweb-0.2.1/smart_http.py +219 -0
  36. pygitweb-0.2.1/smart_http_test.py +232 -0
  37. pygitweb-0.2.1/static/board-refs.js +302 -0
  38. {pygitweb-0.1.0 → pygitweb-0.2.1}/static/board.js +90 -2
  39. {pygitweb-0.1.0 → pygitweb-0.2.1}/static/main.css +73 -1
  40. {pygitweb-0.1.0 → pygitweb-0.2.1}/tasks.py +54 -21
  41. {pygitweb-0.1.0 → pygitweb-0.2.1}/tasks_permissions_test.py +2 -3
  42. {pygitweb-0.1.0 → pygitweb-0.2.1}/tasks_routes_test.py +1 -0
  43. {pygitweb-0.1.0 → pygitweb-0.2.1}/templates/board.html +1 -0
  44. {pygitweb-0.1.0 → pygitweb-0.2.1}/templates/board_card.html +2 -1
  45. {pygitweb-0.1.0 → pygitweb-0.2.1}/templates/merge_request_tag.html +4 -0
  46. pygitweb-0.2.1/templates/pygitweb_settings.html +21 -0
  47. pygitweb-0.2.1/templates/shutdown_ack.html +6 -0
  48. pygitweb-0.1.0/templates/pygitweb_settings.html +0 -12
  49. {pygitweb-0.1.0 → pygitweb-0.2.1}/LICENSE +0 -0
  50. {pygitweb-0.1.0 → pygitweb-0.2.1}/__init__.py +0 -0
  51. {pygitweb-0.1.0 → pygitweb-0.2.1}/api/__init__.py +0 -0
  52. {pygitweb-0.1.0 → pygitweb-0.2.1}/api/action.py +0 -0
  53. {pygitweb-0.1.0 → pygitweb-0.2.1}/api/plugins/__init__.py +0 -0
  54. {pygitweb-0.1.0 → pygitweb-0.2.1}/api/plugins/project_zip.py +0 -0
  55. {pygitweb-0.1.0 → pygitweb-0.2.1}/api/project.py +0 -0
  56. {pygitweb-0.1.0 → pygitweb-0.2.1}/api/subpage.py +0 -0
  57. {pygitweb-0.1.0 → pygitweb-0.2.1}/auth_oauth.py +0 -0
  58. {pygitweb-0.1.0 → pygitweb-0.2.1}/change_queue.py +0 -0
  59. {pygitweb-0.1.0 → pygitweb-0.2.1}/change_queue_test.py +0 -0
  60. {pygitweb-0.1.0 → pygitweb-0.2.1}/config.py +0 -0
  61. {pygitweb-0.1.0 → pygitweb-0.2.1}/conftest.py +0 -0
  62. {pygitweb-0.1.0 → pygitweb-0.2.1}/formatting.py +0 -0
  63. {pygitweb-0.1.0 → pygitweb-0.2.1}/formatting_test.py +0 -0
  64. {pygitweb-0.1.0 → pygitweb-0.2.1}/gravatar.py +0 -0
  65. {pygitweb-0.1.0 → pygitweb-0.2.1}/gravatar_test.py +0 -0
  66. {pygitweb-0.1.0 → pygitweb-0.2.1}/password_hash.py +0 -0
  67. {pygitweb-0.1.0 → pygitweb-0.2.1}/password_hash_test.py +0 -0
  68. {pygitweb-0.1.0 → pygitweb-0.2.1}/plugin_loader.py +0 -0
  69. {pygitweb-0.1.0 → pygitweb-0.2.1}/projects.py +0 -0
  70. {pygitweb-0.1.0 → pygitweb-0.2.1}/projects_test.py +0 -0
  71. {pygitweb-0.1.0 → pygitweb-0.2.1}/pygitweb.egg-info/dependency_links.txt +0 -0
  72. {pygitweb-0.1.0 → pygitweb-0.2.1}/pygitweb.egg-info/top_level.txt +0 -0
  73. {pygitweb-0.1.0 → pygitweb-0.2.1}/sessions.py +0 -0
  74. {pygitweb-0.1.0 → pygitweb-0.2.1}/settings.schema.json +0 -0
  75. {pygitweb-0.1.0 → pygitweb-0.2.1}/settings_config.py +0 -0
  76. {pygitweb-0.1.0 → pygitweb-0.2.1}/settings_config_test.py +0 -0
  77. {pygitweb-0.1.0 → pygitweb-0.2.1}/setup.cfg +0 -0
  78. {pygitweb-0.1.0 → pygitweb-0.2.1}/static/blob-view.js +0 -0
  79. {pygitweb-0.1.0 → pygitweb-0.2.1}/static/hook-install.js +0 -0
  80. {pygitweb-0.1.0 → pygitweb-0.2.1}/static/icons/cloud-down.svg +0 -0
  81. {pygitweb-0.1.0 → pygitweb-0.2.1}/static/icons/cloud-exclamation.svg +0 -0
  82. {pygitweb-0.1.0 → pygitweb-0.2.1}/static/icons/cloud-filled.svg +0 -0
  83. {pygitweb-0.1.0 → pygitweb-0.2.1}/static/icons/cloud-storm.svg +0 -0
  84. {pygitweb-0.1.0 → pygitweb-0.2.1}/static/icons/cloud-up.svg +0 -0
  85. {pygitweb-0.1.0 → pygitweb-0.2.1}/static/icons/cloud.svg +0 -0
  86. {pygitweb-0.1.0 → pygitweb-0.2.1}/static/logo.svg +0 -0
  87. {pygitweb-0.1.0 → pygitweb-0.2.1}/static/navbar-auth.js +0 -0
  88. {pygitweb-0.1.0 → pygitweb-0.2.1}/static/readme-render.js +0 -0
  89. {pygitweb-0.1.0 → pygitweb-0.2.1}/static/search-page.js +0 -0
  90. {pygitweb-0.1.0 → pygitweb-0.2.1}/static/summary-ref-switcher.js +0 -0
  91. {pygitweb-0.1.0 → pygitweb-0.2.1}/static/themes/solarized.css +0 -0
  92. {pygitweb-0.1.0 → pygitweb-0.2.1}/templates/addproject.html +0 -0
  93. {pygitweb-0.1.0 → pygitweb-0.2.1}/templates/blobdiff.html +0 -0
  94. {pygitweb-0.1.0 → pygitweb-0.2.1}/templates/commitdiff.html +0 -0
  95. {pygitweb-0.1.0 → pygitweb-0.2.1}/templates/hooks_summary_cell.html +0 -0
  96. {pygitweb-0.1.0 → pygitweb-0.2.1}/templates/login.html +0 -0
  97. {pygitweb-0.1.0 → pygitweb-0.2.1}/templates/merge_request_summary_cell.html +0 -0
  98. {pygitweb-0.1.0 → pygitweb-0.2.1}/templates/pagination.html +0 -0
  99. {pygitweb-0.1.0 → pygitweb-0.2.1}/templates/preamble.html +0 -0
  100. {pygitweb-0.1.0 → pygitweb-0.2.1}/templates/project_settings.html +0 -0
  101. {pygitweb-0.1.0 → pygitweb-0.2.1}/templates/pygit2_settings.html +0 -0
  102. {pygitweb-0.1.0 → pygitweb-0.2.1}/templates/search.html +0 -0
  103. {pygitweb-0.1.0 → pygitweb-0.2.1}/templates/settings_form_field.html +0 -0
  104. {pygitweb-0.1.0 → pygitweb-0.2.1}/templates/table.html +0 -0
  105. {pygitweb-0.1.0 → pygitweb-0.2.1}/templates/user_profile.html +0 -0
  106. {pygitweb-0.1.0 → pygitweb-0.2.1}/templates_env.py +0 -0
  107. {pygitweb-0.1.0 → pygitweb-0.2.1}/timeline_cache.py +0 -0
  108. {pygitweb-0.1.0 → pygitweb-0.2.1}/validation.py +0 -0
  109. {pygitweb-0.1.0 → pygitweb-0.2.1}/validation_test.py +0 -0
@@ -1,12 +1,12 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pygitweb
3
- Version: 0.1.0
3
+ Version: 0.2.1
4
4
  Summary: Gitweb reimplementation with FastAPI and Pygit2
5
5
  License-Expression: MIT
6
6
  Requires-Python: >=3.11
7
7
  Description-Content-Type: text/markdown
8
8
  License-File: LICENSE
9
- Requires-Dist: pygittools>=0.1.0
9
+ Requires-Dist: pygittools>=0.2.0
10
10
  Requires-Dist: fastapi[standard-no-fastapi-cloud-cli]>=0.104.0
11
11
  Requires-Dist: uvicorn[standard]>=0.24.0
12
12
  Requires-Dist: python-multipart>=0.0.6
@@ -22,32 +22,6 @@ Dynamic: license-file
22
22
 
23
23
  Gitweb reimplementation using **Python**, **FastAPI**, and **Pygit2**, ported from `git/gitweb/gitweb.perl`.
24
24
 
25
- ## Setup
26
-
27
- ```bash
28
- uv sync --package pygitweb
29
- ```
30
-
31
- Or install everything in the workspace (all members + dev tools):
32
-
33
- ```bash
34
- uv sync --all-packages --group dev
35
- ```
36
-
37
- ## Run
38
-
39
- From the repository root (after `uv sync`), with the workspace `.venv` activated:
40
-
41
- ```bash
42
- uvicorn pygitweb.main:app --reload --host 0.0.0.0 --port 8000
43
- ```
44
-
45
- Or:
46
-
47
- ```bash
48
- python -m pygitweb.main
49
- ```
50
-
51
25
  ## Config
52
26
 
53
27
  Settings load from `~/.pygitweb/settings.json` (or `PYGITWEB_SETTINGS_CONFIG`), `PYGITWEB_*`
@@ -66,7 +40,7 @@ environment variables, and optionally a `.env` file in the working directory via
66
40
 
67
41
  ## Routes
68
42
 
69
- 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).
70
44
 
71
45
  **No-project routes**
72
46
 
@@ -94,8 +68,8 @@ Load the `/docs` page for a detailed view of routes (below the readme) if in deb
94
68
  | heads | `updates`, `pf` | `GET /project/{project}?a=heads` | List branch heads. Supports long-poll subscribe. |
95
69
  | tags | `updates`, `pf` | `GET /project/{project}?a=tags` | List all tags. Supports long-poll subscribe. |
96
70
  | tag | `h` | `GET /project/{project}?a=tag&h=...` | Single tag view (tag ref or hash). |
97
- | commit | — | `GET /project/{project}?a=commit` | Commit information. |
98
- | 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)). |
99
73
  | patch | `h` | `GET /project/{project}?a=patch&h=...` | Single-commit patch (plain text). |
100
74
  | patches | `h`, `hb` | `GET /project/{project}?a=patches&h=...&hb=...` | Multi-commit patches for range `hb..h` (plain text). |
101
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. |
@@ -146,16 +120,16 @@ subscribe to the project's change queue. The request is held for up to 30 second
146
120
  just the URL project (the action is still rendered for the URL project).
147
121
 
148
122
  The notify endpoint is intended for loopback use by `pygittools` post-receive hooks (see
149
- `pygittools/hook_samples/post-receive.notify`); production deployments should restrict it
150
- 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.
151
125
 
152
126
  ## Hook management
153
127
 
154
- The project summary page exposes an **Update Hook** row that installs / removes the
155
- `update` bundle: `post-receive.notify` (feeds the change queue when refs are pushed) and
156
- `post-commit.notify` (feeds the change queue when a working clone of this repo records
157
- a local commit). Either is sufficient to wake long-poll subscribers; installing both
158
- 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.
159
133
 
160
134
  The same operations are available programmatically via
161
135
  `POST /project/{name}/hook?name=<sample-or-bundle>&op=<...>`. Bundle status is
@@ -2,32 +2,6 @@
2
2
 
3
3
  Gitweb reimplementation using **Python**, **FastAPI**, and **Pygit2**, ported from `git/gitweb/gitweb.perl`.
4
4
 
5
- ## Setup
6
-
7
- ```bash
8
- uv sync --package pygitweb
9
- ```
10
-
11
- Or install everything in the workspace (all members + dev tools):
12
-
13
- ```bash
14
- uv sync --all-packages --group dev
15
- ```
16
-
17
- ## Run
18
-
19
- From the repository root (after `uv sync`), with the workspace `.venv` activated:
20
-
21
- ```bash
22
- uvicorn pygitweb.main:app --reload --host 0.0.0.0 --port 8000
23
- ```
24
-
25
- Or:
26
-
27
- ```bash
28
- python -m pygitweb.main
29
- ```
30
-
31
5
  ## Config
32
6
 
33
7
  Settings load from `~/.pygitweb/settings.json` (or `PYGITWEB_SETTINGS_CONFIG`), `PYGITWEB_*`
@@ -46,7 +20,7 @@ environment variables, and optionally a `.env` file in the working directory via
46
20
 
47
21
  ## Routes
48
22
 
49
- 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).
50
24
 
51
25
  **No-project routes**
52
26
 
@@ -74,8 +48,8 @@ Load the `/docs` page for a detailed view of routes (below the readme) if in deb
74
48
  | heads | `updates`, `pf` | `GET /project/{project}?a=heads` | List branch heads. Supports long-poll subscribe. |
75
49
  | tags | `updates`, `pf` | `GET /project/{project}?a=tags` | List all tags. Supports long-poll subscribe. |
76
50
  | tag | `h` | `GET /project/{project}?a=tag&h=...` | Single tag view (tag ref or hash). |
77
- | commit | — | `GET /project/{project}?a=commit` | Commit information. |
78
- | 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)). |
79
53
  | patch | `h` | `GET /project/{project}?a=patch&h=...` | Single-commit patch (plain text). |
80
54
  | patches | `h`, `hb` | `GET /project/{project}?a=patches&h=...&hb=...` | Multi-commit patches for range `hb..h` (plain text). |
81
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. |
@@ -126,16 +100,16 @@ subscribe to the project's change queue. The request is held for up to 30 second
126
100
  just the URL project (the action is still rendered for the URL project).
127
101
 
128
102
  The notify endpoint is intended for loopback use by `pygittools` post-receive hooks (see
129
- `pygittools/hook_samples/post-receive.notify`); production deployments should restrict it
130
- 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.
131
105
 
132
106
  ## Hook management
133
107
 
134
- The project summary page exposes an **Update Hook** row that installs / removes the
135
- `update` bundle: `post-receive.notify` (feeds the change queue when refs are pushed) and
136
- `post-commit.notify` (feeds the change queue when a working clone of this repo records
137
- a local commit). Either is sufficient to wake long-poll subscribers; installing both
138
- 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.
139
113
 
140
114
  The same operations are available programmatically via
141
115
  `POST /project/{name}/hook?name=<sample-or-bundle>&op=<...>`. Bundle status is
@@ -2,7 +2,7 @@
2
2
  Metadata for PyGitWeb - this is the canonical source of all information below.
3
3
  """
4
4
 
5
- __version__ = "0.1.0"
5
+ __version__ = "0.2.0"
6
6
  __author__ = "Will Bowers"
7
7
  __license__ = "MIT"
8
8
  __description__ = "FastAPI + Pygit2 Repo Browser"
@@ -1057,7 +1057,7 @@ def git_tags(project: str, request: Request, page: int, pagecount: int) -> HTMLR
1057
1057
  )
1058
1058
 
1059
1059
 
1060
- def git_tag(project: str, h: str | None) -> HTMLResponse:
1060
+ def git_tag(project: str, h: str | None, notice: str | None = None) -> HTMLResponse:
1061
1061
  """Single tag view. Port of git_tag."""
1062
1062
  if not h:
1063
1063
  raise HTTPException(status_code=400, detail="Tag ref or hash (h) required")
@@ -1101,6 +1101,7 @@ def git_tag(project: str, h: str | None) -> HTMLResponse:
1101
1101
  tag_oid=tag_oid,
1102
1102
  tagger=tagger,
1103
1103
  tagger_date=_format_date(tagger_epoch, tagger_tz),
1104
+ notice=notice,
1104
1105
  )
1105
1106
  target_short = target_oid[:7] if target_oid else ""
1106
1107
  if target_type == "commit":
@@ -6,13 +6,12 @@ from fastapi.testclient import TestClient
6
6
 
7
7
  from pygitweb.auth_config import AuthConfig, LocalUser
8
8
  from pygitweb.main import app
9
- from pygitweb.permissions import PERMISSION_ADD_PROJECTS, PermissionsMap
9
+ from pygitweb.permissions import PERMISSION_ADD_PROJECTS, permissions_with_defaults
10
10
 
11
11
  _ADDPROJECTS_AUTH = AuthConfig(
12
12
  auth_mode="local",
13
13
  local_users=[LocalUser(user="admin", password="secret"), LocalUser(user="viewer", password="secret")],
14
- oauth_permissions=PermissionsMap.model_validate({
15
- "*": [],
14
+ oauth_permissions=permissions_with_defaults({
16
15
  PERMISSION_ADD_PROJECTS: ["admin"],
17
16
  }),
18
17
  )
@@ -33,6 +33,7 @@ from pygitweb.config import settings
33
33
  from pygitweb.gravatar import gravatar_url
34
34
  from pygitweb.password_hash import verify_password
35
35
  from pygitweb.permissions import Permission, PermissionPrincipal
36
+ from pygitweb.projects import git_get_project_owner
36
37
  from pygitweb.sessions import create_session, get_session, revoke_session
37
38
  from pygitweb.templates_env import POSTAMBLE, PREAMBLE, env
38
39
 
@@ -78,6 +79,72 @@ def has_permission(
78
79
  return auth_config.oauth_permissions.has_permission(principal.identity, permission, project)
79
80
 
80
81
 
82
+ def _owner_matches(project: str, token: str | None) -> bool:
83
+ if not settings.AUTH or not token:
84
+ return False
85
+ principal = principal_from_session(token)
86
+ if principal is None:
87
+ return False
88
+ owner = git_get_project_owner(project)
89
+ if not owner or not owner.strip():
90
+ return False
91
+ owner_norm = owner.strip()
92
+ identity = principal.identity
93
+ if hmac.compare_digest(owner_norm, identity):
94
+ return True
95
+ return owner_norm.lower() == identity.lower()
96
+
97
+
98
+ def can_read_project(project: str, token: str | None) -> bool:
99
+ if not settings.AUTH:
100
+ return True
101
+ if _owner_matches(project, token):
102
+ return True
103
+ return has_permission(Permission.READ, project, token=token)
104
+
105
+
106
+ def can_write_project(project: str, token: str | None) -> bool:
107
+ if not settings.AUTH:
108
+ return True
109
+ if _owner_matches(project, token):
110
+ return True
111
+ return has_permission(Permission.WRITE, project, token=token)
112
+
113
+
114
+ def ensure_read_project(project: str, token: str | None) -> None:
115
+ if can_read_project(project, token):
116
+ return
117
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="No such project")
118
+
119
+
120
+ def ensure_write_project(project: str, token: str | None) -> None:
121
+ if not settings.AUTH:
122
+ return
123
+ if not is_auth_configured(auth_config):
124
+ raise HTTPException(
125
+ status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
126
+ detail="Authentication enabled but auth.json is not configured",
127
+ )
128
+ if not token:
129
+ raise HTTPException(
130
+ status_code=status.HTTP_401_UNAUTHORIZED,
131
+ detail="Not authenticated",
132
+ headers={"WWW-Authenticate": "Bearer"},
133
+ )
134
+ if decode_access_token(token) is None:
135
+ raise HTTPException(
136
+ status_code=status.HTTP_401_UNAUTHORIZED,
137
+ detail="Not authenticated",
138
+ headers={"WWW-Authenticate": "Bearer"},
139
+ )
140
+ if can_write_project(project, token):
141
+ return
142
+ raise HTTPException(
143
+ status_code=status.HTTP_403_FORBIDDEN,
144
+ detail="Insufficient permissions",
145
+ )
146
+
147
+
81
148
  def ensure_permission(
82
149
  permission: Permission,
83
150
  project: str | None = None,
@@ -85,6 +152,8 @@ def ensure_permission(
85
152
  token: str | None,
86
153
  ) -> None:
87
154
  ensure_active_user_if_auth_enabled(token)
155
+ if project is not None:
156
+ ensure_read_project(project, token)
88
157
  if has_permission(permission, project, token=token):
89
158
  return
90
159
  raise HTTPException(
@@ -60,7 +60,7 @@
60
60
  "additionalProperties": {
61
61
  "type": "array",
62
62
  "items": { "type": "string" },
63
- "description": "Scoped keys: pgw.boards.{scope}, pgw.tasks.{scope}, pgw.comments.{scope}, pgw.hooks.{scope}, pgw.mr.create.{scope}, pgw.mr.merge.{scope} (fnmatch on project path)"
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."
64
64
  }
65
65
  },
66
66
  "local_users": {
@@ -134,7 +134,7 @@ def default_auth_config(*, admin_password: str, salt: str) -> AuthConfig:
134
134
  oauth_redirect_uri="",
135
135
  oauth_username_from="email",
136
136
  oauth_allowed_emails=[],
137
- oauth_permissions=PermissionsMap.empty(),
137
+ oauth_permissions=PermissionsMap.default_access(),
138
138
  local_users=[LocalUser(user="admin", pass_hash=hash_password(admin_password, config_salt=salt))],
139
139
  )
140
140
 
@@ -1,3 +1,4 @@
1
+ import asyncio
1
2
  import json
2
3
  import os
3
4
  import sys
@@ -6,11 +7,20 @@ from pathlib import Path
6
7
  from unittest.mock import AsyncMock, patch
7
8
 
8
9
  import pytest
10
+ from fastapi import HTTPException
9
11
  from fastapi.testclient import TestClient
10
12
 
11
13
  from pygitweb.auth import ACCESS_TOKEN_COOKIE_NAME
12
14
  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
15
+ from pygitweb.auth_oauth import (
16
+ OAUTH_STATE_COOKIE_NAME,
17
+ _exchange_code,
18
+ _fetch_github_profile,
19
+ _fetch_google_profile,
20
+ _fetch_profile,
21
+ _seal_oauth_state,
22
+ is_oauth_email_allowed,
23
+ )
14
24
  from pygitweb.config import Settings, settings
15
25
  from pygitweb.conftest import clear_client_cookies
16
26
  from pygitweb.gravatar import gravatar_url
@@ -19,6 +29,19 @@ from pygitweb.password_hash import hash_password, verify_password
19
29
  from pygitweb.permissions import PERMISSION_ADD_PROJECTS, PermissionsMap
20
30
  from pygitweb.sessions import get_session
21
31
 
32
+
33
+ class _DummyResponse:
34
+ def __init__(self, payload: object, is_success: bool = True) -> None:
35
+ self._payload = payload
36
+ self.is_success = is_success
37
+
38
+ def raise_for_status(self) -> None:
39
+ return None
40
+
41
+ def json(self) -> object:
42
+ return self._payload
43
+
44
+
22
45
  _LOCAL_ADMIN = AuthConfig(
23
46
  auth_mode="local",
24
47
  local_users=[LocalUser(user="admin", password="secret")],
@@ -319,17 +342,17 @@ def test_oauth_callback_creates_session(client: TestClient, oauth_auth_config: A
319
342
  params={"code": "auth-code", "state": "idp-state"},
320
343
  follow_redirects=False,
321
344
  )
322
- assert r.status_code == 303
323
- rec = get_session(r.cookies["pygitweb_access_token"])
324
- assert rec is not None
325
- assert rec.username == "user@example.com"
326
- assert rec.subject == "oauth-sub-1"
327
- assert rec.email == "user@example.com"
328
- assert rec.auth_method == "oauth"
329
- assert client.get("/users/me").json()["username"] == "user@example.com"
330
- status = client.get("/auth/status").json()
331
- assert status["username"] == "user@example.com"
332
- assert status["gravatar_url"] == gravatar_url("user@example.com")
345
+ assert r.status_code == 303
346
+ rec = get_session(r.cookies["pygitweb_access_token"])
347
+ assert rec is not None
348
+ assert rec.username == "user@example.com"
349
+ assert rec.subject == "oauth-sub-1"
350
+ assert rec.email == "user@example.com"
351
+ assert rec.auth_method == "oauth"
352
+ assert client.get("/users/me").json()["username"] == "user@example.com"
353
+ status = client.get("/auth/status").json()
354
+ assert status["username"] == "user@example.com"
355
+ assert status["gravatar_url"] == gravatar_url("user@example.com")
333
356
  clear_client_cookies(client)
334
357
 
335
358
 
@@ -388,3 +411,151 @@ def test_authenticate_local_user_pass_hash() -> None:
388
411
  assert user is not None
389
412
  assert user.username == "admin"
390
413
  assert authenticate_local_user("admin", "wrong") is None
414
+
415
+
416
+ def test_exchange_code_success(oauth_auth_config: AuthConfig) -> None:
417
+ resp = _DummyResponse({"access_token": "tok", "other": "x"})
418
+
419
+ client = AsyncMock()
420
+ client.post.return_value = resp
421
+ client.__aenter__.return_value = client
422
+ client.__aexit__.return_value = None
423
+
424
+ async def runner() -> None:
425
+ with patch("pygitweb.auth_oauth.httpx.AsyncClient", return_value=client):
426
+ out = await _exchange_code(
427
+ oauth_auth_config,
428
+ code="auth-code",
429
+ code_verifier="verifier",
430
+ )
431
+ assert out["access_token"] == "tok"
432
+
433
+ asyncio.run(runner())
434
+
435
+
436
+ def test_exchange_code_rejects_non_mapping(oauth_auth_config: AuthConfig) -> None:
437
+ resp = _DummyResponse(["not-a-dict"])
438
+
439
+ client = AsyncMock()
440
+ client.post.return_value = resp
441
+ client.__aenter__.return_value = client
442
+ client.__aexit__.return_value = None
443
+
444
+ async def runner() -> None:
445
+ with patch("pygitweb.auth_oauth.httpx.AsyncClient", return_value=client), pytest.raises(HTTPException):
446
+ await _exchange_code(
447
+ oauth_auth_config,
448
+ code="auth-code",
449
+ code_verifier="verifier",
450
+ )
451
+
452
+ asyncio.run(runner())
453
+
454
+
455
+ def test_fetch_google_profile_success(oauth_auth_config: AuthConfig) -> None:
456
+ resp = _DummyResponse({"sub": "123", "email": "user@example.com"})
457
+
458
+ client = AsyncMock()
459
+ client.get.return_value = resp
460
+ client.__aenter__.return_value = client
461
+ client.__aexit__.return_value = None
462
+
463
+ async def runner() -> None:
464
+ with patch("pygitweb.auth_oauth.httpx.AsyncClient", return_value=client):
465
+ profile = await _fetch_google_profile("access-token")
466
+ assert profile["email"] == "user@example.com"
467
+
468
+ asyncio.run(runner())
469
+
470
+
471
+ def test_fetch_google_profile_rejects_non_mapping(oauth_auth_config: AuthConfig) -> None:
472
+ resp = _DummyResponse("not-a-dict")
473
+
474
+ client = AsyncMock()
475
+ client.get.return_value = resp
476
+ client.__aenter__.return_value = client
477
+ client.__aexit__.return_value = None
478
+
479
+ async def runner() -> None:
480
+ with patch("pygitweb.auth_oauth.httpx.AsyncClient", return_value=client), pytest.raises(HTTPException):
481
+ await _fetch_google_profile("access-token")
482
+
483
+ asyncio.run(runner())
484
+
485
+
486
+ def test_fetch_github_profile_uses_primary_verified_email() -> None:
487
+ resp_user = _DummyResponse({"login": "octocat", "email": None})
488
+ resp_emails = _DummyResponse([
489
+ {"email": "secondary@example.com", "primary": False, "verified": True},
490
+ {"email": "primary@example.com", "primary": True, "verified": True},
491
+ ])
492
+
493
+ client = AsyncMock()
494
+ client.get.side_effect = (resp_user, resp_emails)
495
+ client.__aenter__.return_value = client
496
+ client.__aexit__.return_value = None
497
+
498
+ async def runner() -> None:
499
+ with patch("pygitweb.auth_oauth.httpx.AsyncClient", return_value=client):
500
+ profile = await _fetch_github_profile("access-token")
501
+ assert profile["email"] == "primary@example.com"
502
+
503
+ asyncio.run(runner())
504
+
505
+
506
+ def test_fetch_github_profile_rejects_non_mapping() -> None:
507
+ resp_user = _DummyResponse("not-a-dict")
508
+
509
+ client = AsyncMock()
510
+ client.get.return_value = resp_user
511
+ client.__aenter__.return_value = client
512
+ client.__aexit__.return_value = None
513
+
514
+ async def runner() -> None:
515
+ with patch("pygitweb.auth_oauth.httpx.AsyncClient", return_value=client), pytest.raises(HTTPException):
516
+ await _fetch_github_profile("access-token")
517
+
518
+ asyncio.run(runner())
519
+
520
+
521
+ def test_fetch_profile_google(oauth_auth_config: AuthConfig) -> None:
522
+ token_payload = {"access_token": "tok"}
523
+ profile: dict[str, object] = {"sub": "123"}
524
+
525
+ async def runner() -> None:
526
+ with patch("pygitweb.auth_oauth._fetch_google_profile", new_callable=AsyncMock, return_value=profile):
527
+ out = await _fetch_profile(oauth_auth_config, token_payload)
528
+ assert out is profile
529
+
530
+ asyncio.run(runner())
531
+
532
+
533
+ def test_fetch_profile_github() -> None:
534
+ cfg = _OAUTH_CFG.model_copy(update={"oauth_provider": "github"})
535
+ token_payload = {"access_token": "tok"}
536
+ profile: dict[str, object] = {"id": "123"}
537
+
538
+ async def runner() -> None:
539
+ with patch("pygitweb.auth_oauth._fetch_github_profile", new_callable=AsyncMock, return_value=profile):
540
+ out = await _fetch_profile(cfg, token_payload)
541
+ assert out is profile
542
+
543
+ asyncio.run(runner())
544
+
545
+
546
+ def test_fetch_profile_missing_access_token(oauth_auth_config: AuthConfig) -> None:
547
+ async def runner() -> None:
548
+ with pytest.raises(HTTPException):
549
+ await _fetch_profile(oauth_auth_config, {})
550
+
551
+ asyncio.run(runner())
552
+
553
+
554
+ def test_fetch_profile_unknown_provider() -> None:
555
+ cfg = _OAUTH_CFG.model_copy(update={"oauth_provider": "other"})
556
+
557
+ async def runner() -> None:
558
+ with pytest.raises(HTTPException):
559
+ await _fetch_profile(cfg, {"access_token": "tok"})
560
+
561
+ asyncio.run(runner())