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.
- {pygitweb-0.2.0 → pygitweb-0.2.2}/PKG-INFO +11 -11
- {pygitweb-0.2.0 → pygitweb-0.2.2}/README.md +10 -10
- {pygitweb-0.2.0 → pygitweb-0.2.2}/actions.py +8 -3
- {pygitweb-0.2.0 → pygitweb-0.2.2}/auth.py +32 -4
- {pygitweb-0.2.0 → pygitweb-0.2.2}/auth.schema.json +5 -1
- {pygitweb-0.2.0 → pygitweb-0.2.2}/auth_config.py +7 -4
- {pygitweb-0.2.0 → pygitweb-0.2.2}/auth_oauth.py +1 -2
- {pygitweb-0.2.0 → pygitweb-0.2.2}/auth_test.py +284 -6
- {pygitweb-0.2.0 → pygitweb-0.2.2}/hooks_install.py +1 -0
- {pygitweb-0.2.0 → pygitweb-0.2.2}/main.py +4 -3
- {pygitweb-0.2.0 → pygitweb-0.2.2}/permissions.py +52 -1
- {pygitweb-0.2.0 → pygitweb-0.2.2}/permissions_test.py +61 -0
- {pygitweb-0.2.0 → pygitweb-0.2.2}/pygitweb.egg-info/PKG-INFO +11 -11
- {pygitweb-0.2.0 → pygitweb-0.2.2}/pygitweb.egg-info/SOURCES.txt +1 -0
- {pygitweb-0.2.0 → pygitweb-0.2.2}/pyproject.toml +2 -2
- {pygitweb-0.2.0 → pygitweb-0.2.2}/static/board.js +13 -1
- {pygitweb-0.2.0 → pygitweb-0.2.2}/static/main.css +6 -0
- {pygitweb-0.2.0 → pygitweb-0.2.2}/tasks.py +6 -1
- {pygitweb-0.2.0 → pygitweb-0.2.2}/templates/board_card.html +40 -7
- {pygitweb-0.2.0 → pygitweb-0.2.2}/LICENSE +0 -0
- {pygitweb-0.2.0 → pygitweb-0.2.2}/__init__.py +0 -0
- {pygitweb-0.2.0 → pygitweb-0.2.2}/__meta__.py +0 -0
- {pygitweb-0.2.0 → pygitweb-0.2.2}/addproject_permissions_test.py +0 -0
- {pygitweb-0.2.0 → pygitweb-0.2.2}/api/__init__.py +0 -0
- {pygitweb-0.2.0 → pygitweb-0.2.2}/api/action.py +0 -0
- {pygitweb-0.2.0 → pygitweb-0.2.2}/api/plugins/__init__.py +0 -0
- {pygitweb-0.2.0 → pygitweb-0.2.2}/api/plugins/project_zip.py +0 -0
- {pygitweb-0.2.0 → pygitweb-0.2.2}/api/project.py +0 -0
- {pygitweb-0.2.0 → pygitweb-0.2.2}/api/subpage.py +0 -0
- {pygitweb-0.2.0 → pygitweb-0.2.2}/board_refs.py +0 -0
- {pygitweb-0.2.0 → pygitweb-0.2.2}/board_refs_test.py +0 -0
- {pygitweb-0.2.0 → pygitweb-0.2.2}/boards_permissions_test.py +0 -0
- {pygitweb-0.2.0 → pygitweb-0.2.2}/change_queue.py +0 -0
- {pygitweb-0.2.0 → pygitweb-0.2.2}/change_queue_routes_test.py +0 -0
- {pygitweb-0.2.0 → pygitweb-0.2.2}/change_queue_test.py +0 -0
- {pygitweb-0.2.0 → pygitweb-0.2.2}/comments_permissions_test.py +0 -0
- {pygitweb-0.2.0 → pygitweb-0.2.2}/config.py +0 -0
- {pygitweb-0.2.0 → pygitweb-0.2.2}/conftest.py +0 -0
- {pygitweb-0.2.0 → pygitweb-0.2.2}/dependencies.py +0 -0
- {pygitweb-0.2.0 → pygitweb-0.2.2}/formatting.py +0 -0
- {pygitweb-0.2.0 → pygitweb-0.2.2}/formatting_test.py +0 -0
- {pygitweb-0.2.0 → pygitweb-0.2.2}/git_helpers.py +0 -0
- {pygitweb-0.2.0 → pygitweb-0.2.2}/git_helpers_test.py +0 -0
- {pygitweb-0.2.0 → pygitweb-0.2.2}/gravatar.py +0 -0
- {pygitweb-0.2.0 → pygitweb-0.2.2}/gravatar_test.py +0 -0
- {pygitweb-0.2.0 → pygitweb-0.2.2}/hooks_install_test.py +0 -0
- {pygitweb-0.2.0 → pygitweb-0.2.2}/hooks_permissions_test.py +0 -0
- {pygitweb-0.2.0 → pygitweb-0.2.2}/merge_requests.py +0 -0
- {pygitweb-0.2.0 → pygitweb-0.2.2}/merge_requests_test.py +0 -0
- {pygitweb-0.2.0 → pygitweb-0.2.2}/mr_permissions_test.py +0 -0
- {pygitweb-0.2.0 → pygitweb-0.2.2}/password_hash.py +0 -0
- {pygitweb-0.2.0 → pygitweb-0.2.2}/password_hash_test.py +0 -0
- {pygitweb-0.2.0 → pygitweb-0.2.2}/plugin_loader.py +0 -0
- {pygitweb-0.2.0 → pygitweb-0.2.2}/project_list_routes_test.py +0 -0
- {pygitweb-0.2.0 → pygitweb-0.2.2}/projects.py +0 -0
- {pygitweb-0.2.0 → pygitweb-0.2.2}/projects_test.py +0 -0
- {pygitweb-0.2.0 → pygitweb-0.2.2}/pygitweb.egg-info/dependency_links.txt +0 -0
- {pygitweb-0.2.0 → pygitweb-0.2.2}/pygitweb.egg-info/requires.txt +0 -0
- {pygitweb-0.2.0 → pygitweb-0.2.2}/pygitweb.egg-info/top_level.txt +0 -0
- {pygitweb-0.2.0 → pygitweb-0.2.2}/sessions.py +0 -0
- {pygitweb-0.2.0 → pygitweb-0.2.2}/settings.py +0 -0
- {pygitweb-0.2.0 → pygitweb-0.2.2}/settings.schema.json +0 -0
- {pygitweb-0.2.0 → pygitweb-0.2.2}/settings_config.py +0 -0
- {pygitweb-0.2.0 → pygitweb-0.2.2}/settings_config_test.py +0 -0
- {pygitweb-0.2.0 → pygitweb-0.2.2}/settings_permissions_test.py +0 -0
- {pygitweb-0.2.0 → pygitweb-0.2.2}/setup.cfg +0 -0
- {pygitweb-0.2.0 → pygitweb-0.2.2}/shutdown.py +0 -0
- {pygitweb-0.2.0 → pygitweb-0.2.2}/smart_http.py +0 -0
- {pygitweb-0.2.0 → pygitweb-0.2.2}/smart_http_test.py +0 -0
- {pygitweb-0.2.0 → pygitweb-0.2.2}/static/blob-view.js +0 -0
- {pygitweb-0.2.0 → pygitweb-0.2.2}/static/board-refs.js +0 -0
- {pygitweb-0.2.0 → pygitweb-0.2.2}/static/hook-install.js +0 -0
- {pygitweb-0.2.0 → pygitweb-0.2.2}/static/icons/cloud-down.svg +0 -0
- {pygitweb-0.2.0 → pygitweb-0.2.2}/static/icons/cloud-exclamation.svg +0 -0
- {pygitweb-0.2.0 → pygitweb-0.2.2}/static/icons/cloud-filled.svg +0 -0
- {pygitweb-0.2.0 → pygitweb-0.2.2}/static/icons/cloud-storm.svg +0 -0
- {pygitweb-0.2.0 → pygitweb-0.2.2}/static/icons/cloud-up.svg +0 -0
- {pygitweb-0.2.0 → pygitweb-0.2.2}/static/icons/cloud.svg +0 -0
- {pygitweb-0.2.0 → pygitweb-0.2.2}/static/logo.svg +0 -0
- {pygitweb-0.2.0 → pygitweb-0.2.2}/static/navbar-auth.js +0 -0
- {pygitweb-0.2.0 → pygitweb-0.2.2}/static/readme-render.js +0 -0
- {pygitweb-0.2.0 → pygitweb-0.2.2}/static/search-page.js +0 -0
- {pygitweb-0.2.0 → pygitweb-0.2.2}/static/summary-ref-switcher.js +0 -0
- {pygitweb-0.2.0 → pygitweb-0.2.2}/static/themes/solarized.css +0 -0
- {pygitweb-0.2.0 → pygitweb-0.2.2}/tasks_permissions_test.py +0 -0
- {pygitweb-0.2.0 → pygitweb-0.2.2}/tasks_routes_test.py +0 -0
- {pygitweb-0.2.0 → pygitweb-0.2.2}/templates/addproject.html +0 -0
- {pygitweb-0.2.0 → pygitweb-0.2.2}/templates/blobdiff.html +0 -0
- {pygitweb-0.2.0 → pygitweb-0.2.2}/templates/board.html +0 -0
- {pygitweb-0.2.0 → pygitweb-0.2.2}/templates/commitdiff.html +0 -0
- {pygitweb-0.2.0 → pygitweb-0.2.2}/templates/hooks_summary_cell.html +0 -0
- {pygitweb-0.2.0 → pygitweb-0.2.2}/templates/login.html +0 -0
- {pygitweb-0.2.0 → pygitweb-0.2.2}/templates/merge_request_summary_cell.html +0 -0
- {pygitweb-0.2.0 → pygitweb-0.2.2}/templates/merge_request_tag.html +0 -0
- {pygitweb-0.2.0 → pygitweb-0.2.2}/templates/pagination.html +0 -0
- {pygitweb-0.2.0 → pygitweb-0.2.2}/templates/preamble.html +0 -0
- {pygitweb-0.2.0 → pygitweb-0.2.2}/templates/project_settings.html +0 -0
- {pygitweb-0.2.0 → pygitweb-0.2.2}/templates/pygit2_settings.html +0 -0
- {pygitweb-0.2.0 → pygitweb-0.2.2}/templates/pygitweb_settings.html +0 -0
- {pygitweb-0.2.0 → pygitweb-0.2.2}/templates/search.html +0 -0
- {pygitweb-0.2.0 → pygitweb-0.2.2}/templates/settings_form_field.html +0 -0
- {pygitweb-0.2.0 → pygitweb-0.2.2}/templates/shutdown_ack.html +0 -0
- {pygitweb-0.2.0 → pygitweb-0.2.2}/templates/table.html +0 -0
- {pygitweb-0.2.0 → pygitweb-0.2.2}/templates/user_profile.html +0 -0
- {pygitweb-0.2.0 → pygitweb-0.2.2}/templates_env.py +0 -0
- {pygitweb-0.2.0 → pygitweb-0.2.2}/timeline_cache.py +0 -0
- {pygitweb-0.2.0 → pygitweb-0.2.2}/validation.py +0 -0
- {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.
|
|
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)
|
|
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 |
|
|
72
|
-
| 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)). |
|
|
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`)
|
|
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
|
|
129
|
-
`update` bundle: `post-receive.notify`
|
|
130
|
-
|
|
131
|
-
a local commit). Either is sufficient to
|
|
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)
|
|
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 |
|
|
52
|
-
| 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)). |
|
|
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`)
|
|
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
|
|
109
|
-
`update` bundle: `post-receive.notify`
|
|
110
|
-
|
|
111
|
-
a local commit). Either is sufficient to
|
|
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
|
|
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
|
|
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
|
-
|
|
77
|
-
if
|
|
93
|
+
identity = _principal_identity_for_permissions(token)
|
|
94
|
+
if identity is None:
|
|
78
95
|
return False
|
|
79
|
-
return auth_config.oauth_permissions.has_permission(
|
|
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 = "
|
|
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="
|
|
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=
|
|
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
|
|
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 == "
|
|
326
|
-
assert rec.subject == "
|
|
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"] == "
|
|
424
|
+
assert client.get("/users/me").json()["username"] == "oauthuser"
|
|
330
425
|
status = client.get("/auth/status").json()
|
|
331
|
-
assert status["username"] == "
|
|
426
|
+
assert status["username"] == "oauthuser"
|
|
332
427
|
assert status["gravatar_url"] == gravatar_url("user@example.com")
|
|
333
|
-
|
|
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
|
-
|
|
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)
|