pygitweb 0.1.0__tar.gz → 0.2.0__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.0}/PKG-INFO +2 -28
- {pygitweb-0.1.0 → pygitweb-0.2.0}/README.md +0 -26
- {pygitweb-0.1.0 → pygitweb-0.2.0}/__meta__.py +1 -1
- {pygitweb-0.1.0 → pygitweb-0.2.0}/actions.py +2 -1
- {pygitweb-0.1.0 → pygitweb-0.2.0}/addproject_permissions_test.py +2 -3
- {pygitweb-0.1.0 → pygitweb-0.2.0}/auth.py +69 -0
- {pygitweb-0.1.0 → pygitweb-0.2.0}/auth.schema.json +1 -1
- {pygitweb-0.1.0 → pygitweb-0.2.0}/auth_config.py +1 -1
- {pygitweb-0.1.0 → pygitweb-0.2.0}/auth_test.py +11 -11
- pygitweb-0.2.0/board_refs.py +154 -0
- pygitweb-0.2.0/board_refs_test.py +106 -0
- {pygitweb-0.1.0 → pygitweb-0.2.0}/boards_permissions_test.py +33 -3
- {pygitweb-0.1.0 → pygitweb-0.2.0}/change_queue_routes_test.py +22 -0
- {pygitweb-0.1.0 → pygitweb-0.2.0}/comments_permissions_test.py +97 -5
- {pygitweb-0.1.0 → pygitweb-0.2.0}/dependencies.py +81 -2
- {pygitweb-0.1.0 → pygitweb-0.2.0}/git_helpers.py +59 -0
- {pygitweb-0.1.0 → pygitweb-0.2.0}/git_helpers_test.py +44 -0
- {pygitweb-0.1.0 → pygitweb-0.2.0}/hooks_install.py +46 -1
- {pygitweb-0.1.0 → pygitweb-0.2.0}/hooks_install_test.py +20 -0
- {pygitweb-0.1.0 → pygitweb-0.2.0}/hooks_permissions_test.py +3 -4
- {pygitweb-0.1.0 → pygitweb-0.2.0}/main.py +63 -65
- {pygitweb-0.1.0 → pygitweb-0.2.0}/merge_requests.py +15 -9
- {pygitweb-0.1.0 → pygitweb-0.2.0}/merge_requests_test.py +1 -1
- {pygitweb-0.1.0 → pygitweb-0.2.0}/mr_permissions_test.py +84 -5
- {pygitweb-0.1.0 → pygitweb-0.2.0}/permissions.py +29 -3
- {pygitweb-0.1.0 → pygitweb-0.2.0}/permissions_test.py +54 -0
- pygitweb-0.2.0/project_list_routes_test.py +56 -0
- {pygitweb-0.1.0 → pygitweb-0.2.0}/pygitweb.egg-info/PKG-INFO +2 -28
- {pygitweb-0.1.0 → pygitweb-0.2.0}/pygitweb.egg-info/SOURCES.txt +16 -0
- {pygitweb-0.1.0 → pygitweb-0.2.0}/pygitweb.egg-info/requires.txt +1 -1
- {pygitweb-0.1.0 → pygitweb-0.2.0}/pyproject.toml +2 -2
- {pygitweb-0.1.0 → pygitweb-0.2.0}/settings.py +17 -4
- {pygitweb-0.1.0 → pygitweb-0.2.0}/settings_permissions_test.py +24 -3
- pygitweb-0.2.0/shutdown.py +61 -0
- pygitweb-0.2.0/smart_http.py +219 -0
- pygitweb-0.2.0/smart_http_test.py +232 -0
- pygitweb-0.2.0/static/board-refs.js +302 -0
- {pygitweb-0.1.0 → pygitweb-0.2.0}/static/board.js +90 -2
- {pygitweb-0.1.0 → pygitweb-0.2.0}/static/main.css +73 -1
- {pygitweb-0.1.0 → pygitweb-0.2.0}/tasks.py +54 -21
- {pygitweb-0.1.0 → pygitweb-0.2.0}/tasks_permissions_test.py +2 -3
- {pygitweb-0.1.0 → pygitweb-0.2.0}/tasks_routes_test.py +1 -0
- {pygitweb-0.1.0 → pygitweb-0.2.0}/templates/board.html +1 -0
- {pygitweb-0.1.0 → pygitweb-0.2.0}/templates/board_card.html +2 -1
- {pygitweb-0.1.0 → pygitweb-0.2.0}/templates/merge_request_tag.html +4 -0
- pygitweb-0.2.0/templates/pygitweb_settings.html +21 -0
- pygitweb-0.2.0/templates/shutdown_ack.html +6 -0
- pygitweb-0.1.0/templates/pygitweb_settings.html +0 -12
- {pygitweb-0.1.0 → pygitweb-0.2.0}/LICENSE +0 -0
- {pygitweb-0.1.0 → pygitweb-0.2.0}/__init__.py +0 -0
- {pygitweb-0.1.0 → pygitweb-0.2.0}/api/__init__.py +0 -0
- {pygitweb-0.1.0 → pygitweb-0.2.0}/api/action.py +0 -0
- {pygitweb-0.1.0 → pygitweb-0.2.0}/api/plugins/__init__.py +0 -0
- {pygitweb-0.1.0 → pygitweb-0.2.0}/api/plugins/project_zip.py +0 -0
- {pygitweb-0.1.0 → pygitweb-0.2.0}/api/project.py +0 -0
- {pygitweb-0.1.0 → pygitweb-0.2.0}/api/subpage.py +0 -0
- {pygitweb-0.1.0 → pygitweb-0.2.0}/auth_oauth.py +0 -0
- {pygitweb-0.1.0 → pygitweb-0.2.0}/change_queue.py +0 -0
- {pygitweb-0.1.0 → pygitweb-0.2.0}/change_queue_test.py +0 -0
- {pygitweb-0.1.0 → pygitweb-0.2.0}/config.py +0 -0
- {pygitweb-0.1.0 → pygitweb-0.2.0}/conftest.py +0 -0
- {pygitweb-0.1.0 → pygitweb-0.2.0}/formatting.py +0 -0
- {pygitweb-0.1.0 → pygitweb-0.2.0}/formatting_test.py +0 -0
- {pygitweb-0.1.0 → pygitweb-0.2.0}/gravatar.py +0 -0
- {pygitweb-0.1.0 → pygitweb-0.2.0}/gravatar_test.py +0 -0
- {pygitweb-0.1.0 → pygitweb-0.2.0}/password_hash.py +0 -0
- {pygitweb-0.1.0 → pygitweb-0.2.0}/password_hash_test.py +0 -0
- {pygitweb-0.1.0 → pygitweb-0.2.0}/plugin_loader.py +0 -0
- {pygitweb-0.1.0 → pygitweb-0.2.0}/projects.py +0 -0
- {pygitweb-0.1.0 → pygitweb-0.2.0}/projects_test.py +0 -0
- {pygitweb-0.1.0 → pygitweb-0.2.0}/pygitweb.egg-info/dependency_links.txt +0 -0
- {pygitweb-0.1.0 → pygitweb-0.2.0}/pygitweb.egg-info/top_level.txt +0 -0
- {pygitweb-0.1.0 → pygitweb-0.2.0}/sessions.py +0 -0
- {pygitweb-0.1.0 → pygitweb-0.2.0}/settings.schema.json +0 -0
- {pygitweb-0.1.0 → pygitweb-0.2.0}/settings_config.py +0 -0
- {pygitweb-0.1.0 → pygitweb-0.2.0}/settings_config_test.py +0 -0
- {pygitweb-0.1.0 → pygitweb-0.2.0}/setup.cfg +0 -0
- {pygitweb-0.1.0 → pygitweb-0.2.0}/static/blob-view.js +0 -0
- {pygitweb-0.1.0 → pygitweb-0.2.0}/static/hook-install.js +0 -0
- {pygitweb-0.1.0 → pygitweb-0.2.0}/static/icons/cloud-down.svg +0 -0
- {pygitweb-0.1.0 → pygitweb-0.2.0}/static/icons/cloud-exclamation.svg +0 -0
- {pygitweb-0.1.0 → pygitweb-0.2.0}/static/icons/cloud-filled.svg +0 -0
- {pygitweb-0.1.0 → pygitweb-0.2.0}/static/icons/cloud-storm.svg +0 -0
- {pygitweb-0.1.0 → pygitweb-0.2.0}/static/icons/cloud-up.svg +0 -0
- {pygitweb-0.1.0 → pygitweb-0.2.0}/static/icons/cloud.svg +0 -0
- {pygitweb-0.1.0 → pygitweb-0.2.0}/static/logo.svg +0 -0
- {pygitweb-0.1.0 → pygitweb-0.2.0}/static/navbar-auth.js +0 -0
- {pygitweb-0.1.0 → pygitweb-0.2.0}/static/readme-render.js +0 -0
- {pygitweb-0.1.0 → pygitweb-0.2.0}/static/search-page.js +0 -0
- {pygitweb-0.1.0 → pygitweb-0.2.0}/static/summary-ref-switcher.js +0 -0
- {pygitweb-0.1.0 → pygitweb-0.2.0}/static/themes/solarized.css +0 -0
- {pygitweb-0.1.0 → pygitweb-0.2.0}/templates/addproject.html +0 -0
- {pygitweb-0.1.0 → pygitweb-0.2.0}/templates/blobdiff.html +0 -0
- {pygitweb-0.1.0 → pygitweb-0.2.0}/templates/commitdiff.html +0 -0
- {pygitweb-0.1.0 → pygitweb-0.2.0}/templates/hooks_summary_cell.html +0 -0
- {pygitweb-0.1.0 → pygitweb-0.2.0}/templates/login.html +0 -0
- {pygitweb-0.1.0 → pygitweb-0.2.0}/templates/merge_request_summary_cell.html +0 -0
- {pygitweb-0.1.0 → pygitweb-0.2.0}/templates/pagination.html +0 -0
- {pygitweb-0.1.0 → pygitweb-0.2.0}/templates/preamble.html +0 -0
- {pygitweb-0.1.0 → pygitweb-0.2.0}/templates/project_settings.html +0 -0
- {pygitweb-0.1.0 → pygitweb-0.2.0}/templates/pygit2_settings.html +0 -0
- {pygitweb-0.1.0 → pygitweb-0.2.0}/templates/search.html +0 -0
- {pygitweb-0.1.0 → pygitweb-0.2.0}/templates/settings_form_field.html +0 -0
- {pygitweb-0.1.0 → pygitweb-0.2.0}/templates/table.html +0 -0
- {pygitweb-0.1.0 → pygitweb-0.2.0}/templates/user_profile.html +0 -0
- {pygitweb-0.1.0 → pygitweb-0.2.0}/templates_env.py +0 -0
- {pygitweb-0.1.0 → pygitweb-0.2.0}/timeline_cache.py +0 -0
- {pygitweb-0.1.0 → pygitweb-0.2.0}/validation.py +0 -0
- {pygitweb-0.1.0 → pygitweb-0.2.0}/validation_test.py +0 -0
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: pygitweb
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
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_*`
|
|
@@ -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_*`
|
|
@@ -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
|
|
|
@@ -319,17 +319,17 @@ def test_oauth_callback_creates_session(client: TestClient, oauth_auth_config: A
|
|
|
319
319
|
params={"code": "auth-code", "state": "idp-state"},
|
|
320
320
|
follow_redirects=False,
|
|
321
321
|
)
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
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")
|
|
333
333
|
clear_client_cookies(client)
|
|
334
334
|
|
|
335
335
|
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
"""Client-side reference manifest for board task @ / # lookups."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from datetime import UTC, datetime, timedelta, timezone
|
|
7
|
+
from typing import TypedDict
|
|
8
|
+
from urllib.parse import quote
|
|
9
|
+
|
|
10
|
+
from pygit2 import Oid, Repository, Tag
|
|
11
|
+
|
|
12
|
+
from pygittools.merge import MR_REF_PREFIX, get_merge_request_by_oid
|
|
13
|
+
from pygitweb.config import settings
|
|
14
|
+
from pygitweb.git_helpers import get_commit_history
|
|
15
|
+
from pygitweb.tasks import TASK_REF_PREFIX, get_board_tasks_grouped
|
|
16
|
+
|
|
17
|
+
BOARD_REF_MANIFEST_COMMIT_LIMIT = 100
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class BoardRefManifestEntry(TypedDict):
|
|
21
|
+
name: str
|
|
22
|
+
insert: str
|
|
23
|
+
url: str
|
|
24
|
+
tags: str
|
|
25
|
+
snippet: str
|
|
26
|
+
created: str
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class BoardRefManifest(TypedDict):
|
|
30
|
+
commits: list[BoardRefManifestEntry]
|
|
31
|
+
refs: list[BoardRefManifestEntry]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _format_epoch(epoch: int | None, tz_offset: int | None = None) -> str:
|
|
35
|
+
if epoch is None:
|
|
36
|
+
return ""
|
|
37
|
+
try:
|
|
38
|
+
tz = timezone(timedelta(seconds=tz_offset * 60)) if tz_offset is not None else UTC
|
|
39
|
+
return datetime.fromtimestamp(epoch, tz=tz).strftime("%Y-%m-%d %H:%M:%S")
|
|
40
|
+
except (ValueError, OSError):
|
|
41
|
+
return ""
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _project_url(project: str) -> str:
|
|
45
|
+
return f"/project/{quote(project, safe='/')}"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _board_task_url(project: str, board_name: str, task_ref: str) -> str:
|
|
49
|
+
base = f"{_project_url(project)}/board/"
|
|
50
|
+
qs = f"b={quote(board_name, safe='')}&task={quote(task_ref, safe='')}"
|
|
51
|
+
return f"{base}?{qs}" if board_name != "Tasks" else f"{base}?task={quote(task_ref, safe='')}"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _task_display_id(ref: str) -> str:
|
|
55
|
+
slug = ref.removeprefix(TASK_REF_PREFIX)
|
|
56
|
+
if slug.startswith("task_"):
|
|
57
|
+
return slug.removeprefix("task_")
|
|
58
|
+
return slug
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _manifest_commits(project: str) -> list[BoardRefManifestEntry]:
|
|
62
|
+
project_url = _project_url(project)
|
|
63
|
+
entries: list[BoardRefManifestEntry] = []
|
|
64
|
+
for commit in get_commit_history(project, ref=None, max_count=BOARD_REF_MANIFEST_COMMIT_LIMIT):
|
|
65
|
+
oid = str(commit.get("oid") or "")
|
|
66
|
+
if not oid:
|
|
67
|
+
continue
|
|
68
|
+
short = oid[:7]
|
|
69
|
+
subject = str(commit.get("subject") or "")
|
|
70
|
+
author = str(commit.get("author") or "")
|
|
71
|
+
created = _format_epoch(commit.get("author_epoch"), commit.get("author_tz"))
|
|
72
|
+
search_tags = " ".join(part.lower() for part in (oid, short, subject, author) if part)
|
|
73
|
+
entries.append({
|
|
74
|
+
"name": f"{short} {subject}".strip(),
|
|
75
|
+
"insert": short,
|
|
76
|
+
"url": f"{project_url}?a=commit&h={quote(oid, safe='')}",
|
|
77
|
+
"tags": search_tags,
|
|
78
|
+
"snippet": author,
|
|
79
|
+
"created": created,
|
|
80
|
+
})
|
|
81
|
+
return entries
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _manifest_board_tasks(project: str, board_name: str) -> list[BoardRefManifestEntry]:
|
|
85
|
+
entries: list[BoardRefManifestEntry] = []
|
|
86
|
+
seen_refs: set[str] = set()
|
|
87
|
+
try:
|
|
88
|
+
columns = get_board_tasks_grouped(project, board_name)
|
|
89
|
+
except KeyError:
|
|
90
|
+
return entries
|
|
91
|
+
for column in columns:
|
|
92
|
+
for task in column["tasks"]:
|
|
93
|
+
task_ref = str(task.get("ref") or "")
|
|
94
|
+
if not task_ref or task_ref in seen_refs:
|
|
95
|
+
continue
|
|
96
|
+
seen_refs.add(task_ref)
|
|
97
|
+
task_id = str(task.get("id") or _task_display_id(task_ref))
|
|
98
|
+
title = str(task.get("title") or "Untitled")
|
|
99
|
+
slug = task_ref.removeprefix("refs/tags/")
|
|
100
|
+
created = str(task.get("updated_at") or task.get("created_at") or "")
|
|
101
|
+
search_tags = " ".join(
|
|
102
|
+
part.lower() for part in (task_id, slug, task_ref, title, str(task.get("status") or "")) if part
|
|
103
|
+
)
|
|
104
|
+
entries.append({
|
|
105
|
+
"name": title,
|
|
106
|
+
"insert": task_id,
|
|
107
|
+
"url": _board_task_url(project, board_name, task_ref),
|
|
108
|
+
"tags": search_tags,
|
|
109
|
+
"snippet": f"task {task_id}",
|
|
110
|
+
"created": created,
|
|
111
|
+
})
|
|
112
|
+
return entries
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _manifest_merge_requests(project: str) -> list[BoardRefManifestEntry]:
|
|
116
|
+
project_url = _project_url(project)
|
|
117
|
+
repo = Repository(os.path.join(settings.PROJECTROOT, project))
|
|
118
|
+
entries: list[BoardRefManifestEntry] = []
|
|
119
|
+
for ref_name in repo.references:
|
|
120
|
+
if not ref_name.startswith(MR_REF_PREFIX):
|
|
121
|
+
continue
|
|
122
|
+
try:
|
|
123
|
+
oid = repo.references[ref_name].resolve().target
|
|
124
|
+
obj = repo[oid]
|
|
125
|
+
if not isinstance(obj, Tag):
|
|
126
|
+
continue
|
|
127
|
+
mr = get_merge_request_by_oid(repo, Oid(hex=str(oid)))
|
|
128
|
+
except (ValueError, KeyError, TypeError):
|
|
129
|
+
continue
|
|
130
|
+
slug = ref_name.removeprefix(MR_REF_PREFIX)
|
|
131
|
+
title = mr.title or slug
|
|
132
|
+
created = mr.updated_at.isoformat() if mr.updated_at else ""
|
|
133
|
+
search_tags = " ".join(
|
|
134
|
+
part.lower() for part in (slug, ref_name, title, mr.description or "", mr.ours or "") if part
|
|
135
|
+
)
|
|
136
|
+
entries.append({
|
|
137
|
+
"name": title,
|
|
138
|
+
"insert": slug,
|
|
139
|
+
"url": f"{project_url}?a=tag&h={quote(ref_name, safe='')}",
|
|
140
|
+
"tags": search_tags,
|
|
141
|
+
"snippet": f"merge request {slug}",
|
|
142
|
+
"created": created,
|
|
143
|
+
})
|
|
144
|
+
return entries
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def board_ref_manifest(project: str, board_name: str) -> BoardRefManifest:
|
|
148
|
+
refs = _manifest_board_tasks(project, board_name)
|
|
149
|
+
refs.extend(_manifest_merge_requests(project))
|
|
150
|
+
refs.sort(key=lambda entry: (entry["created"], entry["name"]), reverse=True)
|
|
151
|
+
return {
|
|
152
|
+
"commits": _manifest_commits(project),
|
|
153
|
+
"refs": refs,
|
|
154
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from unittest.mock import patch
|
|
5
|
+
|
|
6
|
+
import pytest
|
|
7
|
+
from fastapi.testclient import TestClient
|
|
8
|
+
from pygit2 import Oid, Signature, init_repository
|
|
9
|
+
|
|
10
|
+
from pygittools.merge import MR_REF_PREFIX, MergeRequest
|
|
11
|
+
from pygittools.tasks import BOARD_REF_PREFIX, EMPTY_TREE_OID_HEX, TASK_REF_PREFIX, Board, Task
|
|
12
|
+
from pygitweb.board_refs import board_ref_manifest
|
|
13
|
+
from pygitweb.config import settings
|
|
14
|
+
from pygitweb.main import app
|
|
15
|
+
|
|
16
|
+
EMPTY_TREE_OID = Oid(hex=EMPTY_TREE_OID_HEX)
|
|
17
|
+
SIG = Signature("tester", "tester@example.com")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@pytest.fixture
|
|
21
|
+
def manifest_env(tmp_path: Path) -> dict[str, str | TestClient]:
|
|
22
|
+
root = tmp_path / "manifest"
|
|
23
|
+
repo_dir = root / "demo"
|
|
24
|
+
repo_dir.mkdir(parents=True)
|
|
25
|
+
repo = init_repository(str(repo_dir), bare=False)
|
|
26
|
+
|
|
27
|
+
index = repo.index
|
|
28
|
+
(repo_dir / "README").write_text("hello\n", encoding="utf-8")
|
|
29
|
+
index.add("README")
|
|
30
|
+
index.write()
|
|
31
|
+
tree = index.write_tree()
|
|
32
|
+
first = repo.create_commit("refs/heads/main", SIG, SIG, "Initial commit", tree, [])
|
|
33
|
+
second_tree = index.write_tree()
|
|
34
|
+
second = repo.create_commit("refs/heads/main", SIG, SIG, "Second commit", second_tree, [first])
|
|
35
|
+
repo.set_head(second)
|
|
36
|
+
|
|
37
|
+
board_ref = f"{BOARD_REF_PREFIX}Tasks"
|
|
38
|
+
board = Board(EMPTY_TREE_OID, board_ref, tagger="", description="Main")
|
|
39
|
+
task_ref = f"{TASK_REF_PREFIX}task_1782144988054"
|
|
40
|
+
task = Task(
|
|
41
|
+
EMPTY_TREE_OID,
|
|
42
|
+
task_ref,
|
|
43
|
+
tagger="",
|
|
44
|
+
title="Ref lookup task",
|
|
45
|
+
description="See @8054",
|
|
46
|
+
status=Task.Status.TODO,
|
|
47
|
+
)
|
|
48
|
+
task_oid = task.write(repo)
|
|
49
|
+
board.tasks = [str(task_oid)]
|
|
50
|
+
board.update_message()
|
|
51
|
+
board.write(repo)
|
|
52
|
+
|
|
53
|
+
mr_ref = f"{MR_REF_PREFIX}aaaabbbbccccdddd"
|
|
54
|
+
mr = MergeRequest(
|
|
55
|
+
target=second,
|
|
56
|
+
opener=SIG,
|
|
57
|
+
ours="refs/heads/main",
|
|
58
|
+
title="Test MR",
|
|
59
|
+
description="Merge me",
|
|
60
|
+
name=mr_ref,
|
|
61
|
+
)
|
|
62
|
+
mr.write(repo)
|
|
63
|
+
|
|
64
|
+
with (
|
|
65
|
+
patch.object(settings, "PROJECTROOT", str(root)),
|
|
66
|
+
patch.object(settings, "PROJECTS_LIST", str(root)),
|
|
67
|
+
patch.object(settings, "STRICT_EXPORT", False),
|
|
68
|
+
patch.object(settings, "EXPORT_OK", ""),
|
|
69
|
+
patch.object(settings, "AUTH", False),
|
|
70
|
+
):
|
|
71
|
+
yield {"client": TestClient(app), "project": "demo", "task_ref": task_ref, "mr_ref": mr_ref}
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def test_board_ref_manifest_includes_commits_tasks_and_merge_requests(
|
|
75
|
+
manifest_env: dict[str, str | TestClient],
|
|
76
|
+
) -> None:
|
|
77
|
+
manifest = board_ref_manifest(str(manifest_env["project"]), "Tasks")
|
|
78
|
+
|
|
79
|
+
assert len(manifest["commits"]) == 2
|
|
80
|
+
assert "Second commit" in manifest["commits"][0]["name"]
|
|
81
|
+
assert manifest["commits"][0]["insert"] == manifest["commits"][0]["name"].split()[0]
|
|
82
|
+
|
|
83
|
+
task_entries = [entry for entry in manifest["refs"] if "1782144988054" in entry["tags"]]
|
|
84
|
+
assert len(task_entries) == 1
|
|
85
|
+
assert task_entries[0]["insert"] == "1782144988054"
|
|
86
|
+
assert task_entries[0]["name"] == "Ref lookup task"
|
|
87
|
+
assert "task=refs" in task_entries[0]["url"]
|
|
88
|
+
assert "task_1782144988054" in task_entries[0]["url"]
|
|
89
|
+
|
|
90
|
+
mr_entries = [entry for entry in manifest["refs"] if entry["insert"] == "aaaabbbbccccdddd"]
|
|
91
|
+
assert len(mr_entries) == 1
|
|
92
|
+
assert mr_entries[0]["name"] == "Test MR"
|
|
93
|
+
assert "a=tag" in mr_entries[0]["url"]
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def test_board_ref_manifest_route(manifest_env: dict[str, str | TestClient]) -> None:
|
|
97
|
+
client = manifest_env["client"]
|
|
98
|
+
assert isinstance(client, TestClient)
|
|
99
|
+
resp = client.get(
|
|
100
|
+
f"/project/{manifest_env['project']}/board-ref-manifest",
|
|
101
|
+
params={"b": "Tasks"},
|
|
102
|
+
)
|
|
103
|
+
assert resp.status_code == 200
|
|
104
|
+
body = resp.json()
|
|
105
|
+
assert len(body["commits"]) == 2
|
|
106
|
+
assert any(entry["insert"] == "1782144988054" for entry in body["refs"])
|
|
@@ -12,14 +12,14 @@ from fastapi.testclient import TestClient
|
|
|
12
12
|
from pygitweb.auth_config import AuthConfig, LocalUser
|
|
13
13
|
from pygitweb.config import settings
|
|
14
14
|
from pygitweb.conftest import client_as_user
|
|
15
|
-
from pygitweb.
|
|
15
|
+
from pygitweb.main import app
|
|
16
|
+
from pygitweb.permissions import Permission, permission_key, permissions_with_defaults
|
|
16
17
|
from pygitweb.tasks import board_router
|
|
17
18
|
|
|
18
19
|
_BOARDS_AUTH = AuthConfig(
|
|
19
20
|
auth_mode="local",
|
|
20
21
|
local_users=[LocalUser(user="admin", password="secret"), LocalUser(user="viewer", password="secret")],
|
|
21
|
-
oauth_permissions=
|
|
22
|
-
"*": [],
|
|
22
|
+
oauth_permissions=permissions_with_defaults({
|
|
23
23
|
permission_key(Permission.BOARDS, "demo"): ["admin"],
|
|
24
24
|
}),
|
|
25
25
|
)
|
|
@@ -102,6 +102,36 @@ def test_board_create_forbidden_without_grant(board_env: dict[str, str | TestCli
|
|
|
102
102
|
assert r.status_code == 403
|
|
103
103
|
|
|
104
104
|
|
|
105
|
+
def test_board_create_get_forbidden_without_grant(board_env: dict[str, str | TestClient]) -> None:
|
|
106
|
+
with (
|
|
107
|
+
patch.object(settings, "AUTH", True),
|
|
108
|
+
patch("pygitweb.main.auth_config", _BOARDS_AUTH),
|
|
109
|
+
):
|
|
110
|
+
client = TestClient(app)
|
|
111
|
+
with client_as_user(client, "viewer"):
|
|
112
|
+
r = client.get(
|
|
113
|
+
"/board/create",
|
|
114
|
+
params={"project": board_env["project"]},
|
|
115
|
+
follow_redirects=False,
|
|
116
|
+
)
|
|
117
|
+
assert r.status_code == 403
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def test_board_create_get_allowed_with_grant(board_env: dict[str, str | TestClient]) -> None:
|
|
121
|
+
with (
|
|
122
|
+
patch.object(settings, "AUTH", True),
|
|
123
|
+
patch("pygitweb.main.auth_config", _BOARDS_AUTH),
|
|
124
|
+
):
|
|
125
|
+
client = TestClient(app)
|
|
126
|
+
with client_as_user(client, "admin"):
|
|
127
|
+
r = client.get(
|
|
128
|
+
"/board/create",
|
|
129
|
+
params={"project": board_env["project"]},
|
|
130
|
+
follow_redirects=False,
|
|
131
|
+
)
|
|
132
|
+
assert r.status_code == 303
|
|
133
|
+
|
|
134
|
+
|
|
105
135
|
def test_board_delete_forbidden_without_grant(board_env: dict[str, str | TestClient]) -> None:
|
|
106
136
|
client = board_env["client"]
|
|
107
137
|
assert isinstance(client, TestClient)
|
|
@@ -12,6 +12,7 @@ from httpx import ASGITransport
|
|
|
12
12
|
|
|
13
13
|
from pygitweb.change_queue import CHANGE_QUEUE
|
|
14
14
|
from pygitweb.config import settings
|
|
15
|
+
from pygitweb.dependencies import is_loopback_host
|
|
15
16
|
from pygitweb.main import app
|
|
16
17
|
|
|
17
18
|
|
|
@@ -144,3 +145,24 @@ class TestUpdatesLongPoll:
|
|
|
144
145
|
return resp.status_code
|
|
145
146
|
|
|
146
147
|
assert asyncio.run(scenario()) == 404
|
|
148
|
+
|
|
149
|
+
def test_notify_rejects_non_loopback_client(self, updates_env: dict[str, str]) -> None:
|
|
150
|
+
del updates_env
|
|
151
|
+
|
|
152
|
+
async def scenario() -> int:
|
|
153
|
+
transport = ASGITransport(app=app, client=("203.0.113.1", 999))
|
|
154
|
+
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
|
|
155
|
+
resp = await client.post("/_internal/notify", params={"project": "alpha"})
|
|
156
|
+
return resp.status_code
|
|
157
|
+
|
|
158
|
+
assert asyncio.run(scenario()) == 403
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
class TestLoopbackHost:
|
|
162
|
+
def test_is_loopback_host(self) -> None:
|
|
163
|
+
assert is_loopback_host("127.0.0.1")
|
|
164
|
+
assert is_loopback_host("::1")
|
|
165
|
+
assert is_loopback_host("localhost")
|
|
166
|
+
assert not is_loopback_host("203.0.113.1")
|
|
167
|
+
assert not is_loopback_host(None)
|
|
168
|
+
assert not is_loopback_host("")
|