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.
- {pygitweb-0.1.0 → pygitweb-0.2.1}/PKG-INFO +12 -38
- {pygitweb-0.1.0 → pygitweb-0.2.1}/README.md +10 -36
- {pygitweb-0.1.0 → pygitweb-0.2.1}/__meta__.py +1 -1
- {pygitweb-0.1.0 → pygitweb-0.2.1}/actions.py +2 -1
- {pygitweb-0.1.0 → pygitweb-0.2.1}/addproject_permissions_test.py +2 -3
- {pygitweb-0.1.0 → pygitweb-0.2.1}/auth.py +69 -0
- {pygitweb-0.1.0 → pygitweb-0.2.1}/auth.schema.json +1 -1
- {pygitweb-0.1.0 → pygitweb-0.2.1}/auth_config.py +1 -1
- {pygitweb-0.1.0 → pygitweb-0.2.1}/auth_test.py +183 -12
- pygitweb-0.2.1/board_refs.py +154 -0
- pygitweb-0.2.1/board_refs_test.py +106 -0
- {pygitweb-0.1.0 → pygitweb-0.2.1}/boards_permissions_test.py +33 -3
- {pygitweb-0.1.0 → pygitweb-0.2.1}/change_queue_routes_test.py +22 -0
- {pygitweb-0.1.0 → pygitweb-0.2.1}/comments_permissions_test.py +97 -5
- {pygitweb-0.1.0 → pygitweb-0.2.1}/dependencies.py +81 -2
- {pygitweb-0.1.0 → pygitweb-0.2.1}/git_helpers.py +59 -0
- {pygitweb-0.1.0 → pygitweb-0.2.1}/git_helpers_test.py +44 -0
- {pygitweb-0.1.0 → pygitweb-0.2.1}/hooks_install.py +46 -1
- {pygitweb-0.1.0 → pygitweb-0.2.1}/hooks_install_test.py +20 -0
- {pygitweb-0.1.0 → pygitweb-0.2.1}/hooks_permissions_test.py +3 -4
- {pygitweb-0.1.0 → pygitweb-0.2.1}/main.py +65 -67
- {pygitweb-0.1.0 → pygitweb-0.2.1}/merge_requests.py +15 -9
- {pygitweb-0.1.0 → pygitweb-0.2.1}/merge_requests_test.py +1 -1
- {pygitweb-0.1.0 → pygitweb-0.2.1}/mr_permissions_test.py +84 -5
- {pygitweb-0.1.0 → pygitweb-0.2.1}/permissions.py +29 -3
- {pygitweb-0.1.0 → pygitweb-0.2.1}/permissions_test.py +54 -0
- pygitweb-0.2.1/project_list_routes_test.py +56 -0
- {pygitweb-0.1.0 → pygitweb-0.2.1}/pygitweb.egg-info/PKG-INFO +12 -38
- {pygitweb-0.1.0 → pygitweb-0.2.1}/pygitweb.egg-info/SOURCES.txt +17 -0
- {pygitweb-0.1.0 → pygitweb-0.2.1}/pygitweb.egg-info/requires.txt +1 -1
- {pygitweb-0.1.0 → pygitweb-0.2.1}/pyproject.toml +3 -3
- {pygitweb-0.1.0 → pygitweb-0.2.1}/settings.py +17 -4
- {pygitweb-0.1.0 → pygitweb-0.2.1}/settings_permissions_test.py +24 -3
- pygitweb-0.2.1/shutdown.py +61 -0
- pygitweb-0.2.1/smart_http.py +219 -0
- pygitweb-0.2.1/smart_http_test.py +232 -0
- pygitweb-0.2.1/static/board-refs.js +302 -0
- {pygitweb-0.1.0 → pygitweb-0.2.1}/static/board.js +90 -2
- {pygitweb-0.1.0 → pygitweb-0.2.1}/static/main.css +73 -1
- {pygitweb-0.1.0 → pygitweb-0.2.1}/tasks.py +54 -21
- {pygitweb-0.1.0 → pygitweb-0.2.1}/tasks_permissions_test.py +2 -3
- {pygitweb-0.1.0 → pygitweb-0.2.1}/tasks_routes_test.py +1 -0
- {pygitweb-0.1.0 → pygitweb-0.2.1}/templates/board.html +1 -0
- {pygitweb-0.1.0 → pygitweb-0.2.1}/templates/board_card.html +2 -1
- {pygitweb-0.1.0 → pygitweb-0.2.1}/templates/merge_request_tag.html +4 -0
- pygitweb-0.2.1/templates/pygitweb_settings.html +21 -0
- pygitweb-0.2.1/templates/shutdown_ack.html +6 -0
- pygitweb-0.1.0/templates/pygitweb_settings.html +0 -12
- {pygitweb-0.1.0 → pygitweb-0.2.1}/LICENSE +0 -0
- {pygitweb-0.1.0 → pygitweb-0.2.1}/__init__.py +0 -0
- {pygitweb-0.1.0 → pygitweb-0.2.1}/api/__init__.py +0 -0
- {pygitweb-0.1.0 → pygitweb-0.2.1}/api/action.py +0 -0
- {pygitweb-0.1.0 → pygitweb-0.2.1}/api/plugins/__init__.py +0 -0
- {pygitweb-0.1.0 → pygitweb-0.2.1}/api/plugins/project_zip.py +0 -0
- {pygitweb-0.1.0 → pygitweb-0.2.1}/api/project.py +0 -0
- {pygitweb-0.1.0 → pygitweb-0.2.1}/api/subpage.py +0 -0
- {pygitweb-0.1.0 → pygitweb-0.2.1}/auth_oauth.py +0 -0
- {pygitweb-0.1.0 → pygitweb-0.2.1}/change_queue.py +0 -0
- {pygitweb-0.1.0 → pygitweb-0.2.1}/change_queue_test.py +0 -0
- {pygitweb-0.1.0 → pygitweb-0.2.1}/config.py +0 -0
- {pygitweb-0.1.0 → pygitweb-0.2.1}/conftest.py +0 -0
- {pygitweb-0.1.0 → pygitweb-0.2.1}/formatting.py +0 -0
- {pygitweb-0.1.0 → pygitweb-0.2.1}/formatting_test.py +0 -0
- {pygitweb-0.1.0 → pygitweb-0.2.1}/gravatar.py +0 -0
- {pygitweb-0.1.0 → pygitweb-0.2.1}/gravatar_test.py +0 -0
- {pygitweb-0.1.0 → pygitweb-0.2.1}/password_hash.py +0 -0
- {pygitweb-0.1.0 → pygitweb-0.2.1}/password_hash_test.py +0 -0
- {pygitweb-0.1.0 → pygitweb-0.2.1}/plugin_loader.py +0 -0
- {pygitweb-0.1.0 → pygitweb-0.2.1}/projects.py +0 -0
- {pygitweb-0.1.0 → pygitweb-0.2.1}/projects_test.py +0 -0
- {pygitweb-0.1.0 → pygitweb-0.2.1}/pygitweb.egg-info/dependency_links.txt +0 -0
- {pygitweb-0.1.0 → pygitweb-0.2.1}/pygitweb.egg-info/top_level.txt +0 -0
- {pygitweb-0.1.0 → pygitweb-0.2.1}/sessions.py +0 -0
- {pygitweb-0.1.0 → pygitweb-0.2.1}/settings.schema.json +0 -0
- {pygitweb-0.1.0 → pygitweb-0.2.1}/settings_config.py +0 -0
- {pygitweb-0.1.0 → pygitweb-0.2.1}/settings_config_test.py +0 -0
- {pygitweb-0.1.0 → pygitweb-0.2.1}/setup.cfg +0 -0
- {pygitweb-0.1.0 → pygitweb-0.2.1}/static/blob-view.js +0 -0
- {pygitweb-0.1.0 → pygitweb-0.2.1}/static/hook-install.js +0 -0
- {pygitweb-0.1.0 → pygitweb-0.2.1}/static/icons/cloud-down.svg +0 -0
- {pygitweb-0.1.0 → pygitweb-0.2.1}/static/icons/cloud-exclamation.svg +0 -0
- {pygitweb-0.1.0 → pygitweb-0.2.1}/static/icons/cloud-filled.svg +0 -0
- {pygitweb-0.1.0 → pygitweb-0.2.1}/static/icons/cloud-storm.svg +0 -0
- {pygitweb-0.1.0 → pygitweb-0.2.1}/static/icons/cloud-up.svg +0 -0
- {pygitweb-0.1.0 → pygitweb-0.2.1}/static/icons/cloud.svg +0 -0
- {pygitweb-0.1.0 → pygitweb-0.2.1}/static/logo.svg +0 -0
- {pygitweb-0.1.0 → pygitweb-0.2.1}/static/navbar-auth.js +0 -0
- {pygitweb-0.1.0 → pygitweb-0.2.1}/static/readme-render.js +0 -0
- {pygitweb-0.1.0 → pygitweb-0.2.1}/static/search-page.js +0 -0
- {pygitweb-0.1.0 → pygitweb-0.2.1}/static/summary-ref-switcher.js +0 -0
- {pygitweb-0.1.0 → pygitweb-0.2.1}/static/themes/solarized.css +0 -0
- {pygitweb-0.1.0 → pygitweb-0.2.1}/templates/addproject.html +0 -0
- {pygitweb-0.1.0 → pygitweb-0.2.1}/templates/blobdiff.html +0 -0
- {pygitweb-0.1.0 → pygitweb-0.2.1}/templates/commitdiff.html +0 -0
- {pygitweb-0.1.0 → pygitweb-0.2.1}/templates/hooks_summary_cell.html +0 -0
- {pygitweb-0.1.0 → pygitweb-0.2.1}/templates/login.html +0 -0
- {pygitweb-0.1.0 → pygitweb-0.2.1}/templates/merge_request_summary_cell.html +0 -0
- {pygitweb-0.1.0 → pygitweb-0.2.1}/templates/pagination.html +0 -0
- {pygitweb-0.1.0 → pygitweb-0.2.1}/templates/preamble.html +0 -0
- {pygitweb-0.1.0 → pygitweb-0.2.1}/templates/project_settings.html +0 -0
- {pygitweb-0.1.0 → pygitweb-0.2.1}/templates/pygit2_settings.html +0 -0
- {pygitweb-0.1.0 → pygitweb-0.2.1}/templates/search.html +0 -0
- {pygitweb-0.1.0 → pygitweb-0.2.1}/templates/settings_form_field.html +0 -0
- {pygitweb-0.1.0 → pygitweb-0.2.1}/templates/table.html +0 -0
- {pygitweb-0.1.0 → pygitweb-0.2.1}/templates/user_profile.html +0 -0
- {pygitweb-0.1.0 → pygitweb-0.2.1}/templates_env.py +0 -0
- {pygitweb-0.1.0 → pygitweb-0.2.1}/timeline_cache.py +0 -0
- {pygitweb-0.1.0 → pygitweb-0.2.1}/validation.py +0 -0
- {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
|
|
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.
|
|
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)
|
|
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 |
|
|
98
|
-
| commitdiff |
|
|
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`)
|
|
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
|
|
155
|
-
`update` bundle: `post-receive.notify`
|
|
156
|
-
|
|
157
|
-
a local commit). Either is sufficient to
|
|
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)
|
|
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 |
|
|
78
|
-
| commitdiff |
|
|
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`)
|
|
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
|
|
135
|
-
`update` bundle: `post-receive.notify`
|
|
136
|
-
|
|
137
|
-
a local commit). Either is sufficient to
|
|
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
|
|
@@ -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,
|
|
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=
|
|
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.
|
|
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
|
|
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
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
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())
|