pygitweb 0.1.0__py3-none-any.whl
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/__init__.py +22 -0
- pygitweb/__meta__.py +9 -0
- pygitweb/actions.py +1303 -0
- pygitweb/addproject_permissions_test.py +114 -0
- pygitweb/api/__init__.py +0 -0
- pygitweb/api/action.py +20 -0
- pygitweb/api/plugins/__init__.py +0 -0
- pygitweb/api/plugins/project_zip.py +71 -0
- pygitweb/api/project.py +67 -0
- pygitweb/api/subpage.py +22 -0
- pygitweb/auth.py +387 -0
- pygitweb/auth.schema.json +106 -0
- pygitweb/auth_config.py +243 -0
- pygitweb/auth_oauth.py +343 -0
- pygitweb/auth_test.py +390 -0
- pygitweb/boards_permissions_test.py +113 -0
- pygitweb/change_queue.py +110 -0
- pygitweb/change_queue_routes_test.py +146 -0
- pygitweb/change_queue_test.py +91 -0
- pygitweb/comments_permissions_test.py +140 -0
- pygitweb/config.py +185 -0
- pygitweb/conftest.py +43 -0
- pygitweb/dependencies.py +87 -0
- pygitweb/formatting.py +182 -0
- pygitweb/formatting_test.py +68 -0
- pygitweb/git_helpers.py +454 -0
- pygitweb/git_helpers_test.py +223 -0
- pygitweb/gravatar.py +16 -0
- pygitweb/gravatar_test.py +10 -0
- pygitweb/hooks_install.py +278 -0
- pygitweb/hooks_install_test.py +363 -0
- pygitweb/hooks_permissions_test.py +116 -0
- pygitweb/main.py +887 -0
- pygitweb/merge_requests.py +230 -0
- pygitweb/merge_requests_test.py +85 -0
- pygitweb/mr_permissions_test.py +148 -0
- pygitweb/password_hash.py +74 -0
- pygitweb/password_hash_test.py +20 -0
- pygitweb/permissions.py +209 -0
- pygitweb/permissions_test.py +86 -0
- pygitweb/plugin_loader.py +83 -0
- pygitweb/projects.py +213 -0
- pygitweb/projects_test.py +186 -0
- pygitweb/sessions.py +73 -0
- pygitweb/settings.py +412 -0
- pygitweb/settings.schema.json +73 -0
- pygitweb/settings_config.py +122 -0
- pygitweb/settings_config_test.py +101 -0
- pygitweb/settings_permissions_test.py +76 -0
- pygitweb/static/blob-view.js +133 -0
- pygitweb/static/board.js +839 -0
- pygitweb/static/hook-install.js +53 -0
- pygitweb/static/icons/cloud-down.svg +1 -0
- pygitweb/static/icons/cloud-exclamation.svg +1 -0
- pygitweb/static/icons/cloud-filled.svg +1 -0
- pygitweb/static/icons/cloud-storm.svg +1 -0
- pygitweb/static/icons/cloud-up.svg +1 -0
- pygitweb/static/icons/cloud.svg +1 -0
- pygitweb/static/logo.svg +44 -0
- pygitweb/static/main.css +1210 -0
- pygitweb/static/navbar-auth.js +82 -0
- pygitweb/static/readme-render.js +174 -0
- pygitweb/static/search-page.js +139 -0
- pygitweb/static/summary-ref-switcher.js +130 -0
- pygitweb/static/themes/solarized.css +34 -0
- pygitweb/tasks.py +595 -0
- pygitweb/tasks_permissions_test.py +137 -0
- pygitweb/tasks_routes_test.py +430 -0
- pygitweb/templates/addproject.html +174 -0
- pygitweb/templates/blobdiff.html +57 -0
- pygitweb/templates/board.html +31 -0
- pygitweb/templates/board_card.html +44 -0
- pygitweb/templates/commitdiff.html +57 -0
- pygitweb/templates/hooks_summary_cell.html +37 -0
- pygitweb/templates/login.html +37 -0
- pygitweb/templates/merge_request_summary_cell.html +18 -0
- pygitweb/templates/merge_request_tag.html +90 -0
- pygitweb/templates/pagination.html +44 -0
- pygitweb/templates/preamble.html +89 -0
- pygitweb/templates/project_settings.html +13 -0
- pygitweb/templates/pygit2_settings.html +12 -0
- pygitweb/templates/pygitweb_settings.html +12 -0
- pygitweb/templates/search.html +77 -0
- pygitweb/templates/settings_form_field.html +27 -0
- pygitweb/templates/table.html +15 -0
- pygitweb/templates/user_profile.html +10 -0
- pygitweb/templates_env.py +21 -0
- pygitweb/timeline_cache.py +243 -0
- pygitweb/validation.py +75 -0
- pygitweb/validation_test.py +167 -0
- pygitweb-0.1.0.dist-info/METADATA +165 -0
- pygitweb-0.1.0.dist-info/RECORD +95 -0
- pygitweb-0.1.0.dist-info/WHEEL +5 -0
- pygitweb-0.1.0.dist-info/licenses/LICENSE +21 -0
- pygitweb-0.1.0.dist-info/top_level.txt +1 -0
pygitweb/permissions.py
ADDED
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
"""
|
|
2
|
+
PyGitWeb permission keys and grant checks.
|
|
3
|
+
|
|
4
|
+
``auth.json`` field ``oauth_permissions`` maps permission keys to principal lists
|
|
5
|
+
(typically OAuth email addresses). The special key ``*`` lists principals granted
|
|
6
|
+
every permission.
|
|
7
|
+
|
|
8
|
+
Scoped keys use a project segment that supports ``fnmatch`` globs (``*``, ``?``,
|
|
9
|
+
``[seq]``), e.g. ``pgw.tasks.foo/*`` or ``pgw.boards.*``.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import fnmatch
|
|
15
|
+
import re
|
|
16
|
+
from enum import StrEnum
|
|
17
|
+
from typing import Self
|
|
18
|
+
|
|
19
|
+
from pydantic import BaseModel, ConfigDict, RootModel, model_validator
|
|
20
|
+
|
|
21
|
+
WILDCARD_PERMISSION_KEY = "*"
|
|
22
|
+
|
|
23
|
+
# Known permission families (suffix after ``pgw.``).
|
|
24
|
+
PERMISSION_ADD_PROJECTS = "pgw.addprojects"
|
|
25
|
+
PERMISSION_SETTINGS = "pgw.settings"
|
|
26
|
+
|
|
27
|
+
_SCOPED_PREFIX = re.compile(r"^pgw\.(boards|tasks|comments|hooks|mr\.create|mr\.merge)\.(.+)$")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class Permission(StrEnum):
|
|
31
|
+
"""Logical permission checked by route dependencies."""
|
|
32
|
+
|
|
33
|
+
BOARDS = "boards"
|
|
34
|
+
TASKS = "tasks"
|
|
35
|
+
COMMENTS = "comments"
|
|
36
|
+
HOOKS = "hooks"
|
|
37
|
+
MR_CREATE = "mr.create"
|
|
38
|
+
MR_MERGE = "mr.merge"
|
|
39
|
+
ADD_PROJECTS = "addprojects"
|
|
40
|
+
SETTINGS = "settings"
|
|
41
|
+
|
|
42
|
+
@property
|
|
43
|
+
def global_key(self) -> str | None:
|
|
44
|
+
if self is Permission.ADD_PROJECTS:
|
|
45
|
+
return PERMISSION_ADD_PROJECTS
|
|
46
|
+
if self is Permission.SETTINGS:
|
|
47
|
+
return PERMISSION_SETTINGS
|
|
48
|
+
return None
|
|
49
|
+
|
|
50
|
+
@property
|
|
51
|
+
def scoped_prefix(self) -> str | None:
|
|
52
|
+
if self in (
|
|
53
|
+
Permission.BOARDS,
|
|
54
|
+
Permission.TASKS,
|
|
55
|
+
Permission.COMMENTS,
|
|
56
|
+
Permission.HOOKS,
|
|
57
|
+
Permission.MR_CREATE,
|
|
58
|
+
Permission.MR_MERGE,
|
|
59
|
+
):
|
|
60
|
+
return f"pgw.{self.value}."
|
|
61
|
+
return None
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class ParsedPermissionKey(BaseModel):
|
|
65
|
+
model_config = ConfigDict(frozen=True)
|
|
66
|
+
|
|
67
|
+
raw: str
|
|
68
|
+
permission: Permission
|
|
69
|
+
project_scope: str | None = None
|
|
70
|
+
|
|
71
|
+
@property
|
|
72
|
+
def is_global(self) -> bool:
|
|
73
|
+
return self.project_scope is None
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def parse_permission_key(key: str) -> ParsedPermissionKey | None:
|
|
77
|
+
"""Return a parsed key, or ``None`` if ``key`` is not a known permission name."""
|
|
78
|
+
raw = key.strip()
|
|
79
|
+
if not raw or raw == WILDCARD_PERMISSION_KEY:
|
|
80
|
+
return None
|
|
81
|
+
if raw == PERMISSION_ADD_PROJECTS:
|
|
82
|
+
return ParsedPermissionKey(raw=raw, permission=Permission.ADD_PROJECTS)
|
|
83
|
+
if raw == PERMISSION_SETTINGS:
|
|
84
|
+
return ParsedPermissionKey(raw=raw, permission=Permission.SETTINGS)
|
|
85
|
+
m = _SCOPED_PREFIX.match(raw)
|
|
86
|
+
if not m:
|
|
87
|
+
return None
|
|
88
|
+
kind, scope = m.group(1), m.group(2)
|
|
89
|
+
try:
|
|
90
|
+
perm = Permission(kind)
|
|
91
|
+
except ValueError:
|
|
92
|
+
return None
|
|
93
|
+
if not scope:
|
|
94
|
+
return None
|
|
95
|
+
return ParsedPermissionKey(raw=raw, permission=perm, project_scope=scope)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def permission_key(permission: Permission, project: str) -> str:
|
|
99
|
+
"""Build the canonical scoped permission key for a project."""
|
|
100
|
+
prefix = permission.scoped_prefix
|
|
101
|
+
if prefix is None:
|
|
102
|
+
raise ValueError(f"{permission!r} is not project-scoped")
|
|
103
|
+
return f"{prefix}{project}"
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def project_matches_scope(project: str, scope: str) -> bool:
|
|
107
|
+
if scope == WILDCARD_PERMISSION_KEY:
|
|
108
|
+
return True
|
|
109
|
+
return fnmatch.fnmatchcase(project, scope)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class PermissionsMap(RootModel[dict[str, list[str]]]):
|
|
113
|
+
"""Validated ``oauth_permissions`` object from auth.json."""
|
|
114
|
+
|
|
115
|
+
root: dict[str, list[str]]
|
|
116
|
+
|
|
117
|
+
@model_validator(mode="before")
|
|
118
|
+
@classmethod
|
|
119
|
+
def _coerce_root(cls, v: object) -> object:
|
|
120
|
+
if isinstance(v, PermissionsMap):
|
|
121
|
+
return v.root
|
|
122
|
+
if v is None:
|
|
123
|
+
return {WILDCARD_PERMISSION_KEY: []}
|
|
124
|
+
if not isinstance(v, dict):
|
|
125
|
+
raise TypeError("oauth_permissions must be an object")
|
|
126
|
+
out: dict[str, list[str]] = {}
|
|
127
|
+
for key, principals in v.items():
|
|
128
|
+
if not isinstance(key, str):
|
|
129
|
+
raise TypeError("permission keys must be strings")
|
|
130
|
+
if key != WILDCARD_PERMISSION_KEY and parse_permission_key(key) is None:
|
|
131
|
+
raise ValueError(f"unknown permission key: {key!r}")
|
|
132
|
+
if not isinstance(principals, list):
|
|
133
|
+
raise TypeError(f"oauth_permissions[{key!r}] must be a list")
|
|
134
|
+
normalized: list[str] = []
|
|
135
|
+
for p in principals:
|
|
136
|
+
if not isinstance(p, str):
|
|
137
|
+
raise TypeError(f"oauth_permissions[{key!r}] entries must be strings")
|
|
138
|
+
s = p.strip()
|
|
139
|
+
if s:
|
|
140
|
+
normalized.append(s)
|
|
141
|
+
out[key] = normalized
|
|
142
|
+
if WILDCARD_PERMISSION_KEY not in out:
|
|
143
|
+
out = {**out, WILDCARD_PERMISSION_KEY: []}
|
|
144
|
+
return out
|
|
145
|
+
|
|
146
|
+
@classmethod
|
|
147
|
+
def empty(cls) -> Self:
|
|
148
|
+
return cls({WILDCARD_PERMISSION_KEY: []})
|
|
149
|
+
|
|
150
|
+
def principals_for_key(self, key: str) -> frozenset[str]:
|
|
151
|
+
return frozenset(self.root.get(key, []))
|
|
152
|
+
|
|
153
|
+
def superuser_principals(self) -> frozenset[str]:
|
|
154
|
+
return self.principals_for_key(WILDCARD_PERMISSION_KEY)
|
|
155
|
+
|
|
156
|
+
def matching_scoped_principals(self, permission: Permission, project: str) -> frozenset[str]:
|
|
157
|
+
prefix = permission.scoped_prefix
|
|
158
|
+
if prefix is None:
|
|
159
|
+
return frozenset()
|
|
160
|
+
acc: set[str] = set()
|
|
161
|
+
for key, principals in self.root.items():
|
|
162
|
+
if key == WILDCARD_PERMISSION_KEY:
|
|
163
|
+
continue
|
|
164
|
+
parsed = parse_permission_key(key)
|
|
165
|
+
if parsed is None or parsed.permission is not permission or parsed.project_scope is None:
|
|
166
|
+
continue
|
|
167
|
+
if project_matches_scope(project, parsed.project_scope):
|
|
168
|
+
acc.update(principals)
|
|
169
|
+
return frozenset(acc)
|
|
170
|
+
|
|
171
|
+
def principals_with_permission(self, permission: Permission, project: str | None = None) -> frozenset[str]:
|
|
172
|
+
acc = set(self.superuser_principals())
|
|
173
|
+
global_key = permission.global_key
|
|
174
|
+
if global_key is not None:
|
|
175
|
+
acc.update(self.principals_for_key(global_key))
|
|
176
|
+
return frozenset(acc)
|
|
177
|
+
if project is None:
|
|
178
|
+
return frozenset(acc)
|
|
179
|
+
acc.update(self.matching_scoped_principals(permission, project))
|
|
180
|
+
return frozenset(acc)
|
|
181
|
+
|
|
182
|
+
def has_permission(self, principal: str | None, permission: Permission, project: str | None = None) -> bool:
|
|
183
|
+
if not principal or not principal.strip():
|
|
184
|
+
return False
|
|
185
|
+
normalized = principal.strip()
|
|
186
|
+
allowed = self.principals_with_permission(permission, project)
|
|
187
|
+
if normalized in allowed:
|
|
188
|
+
return True
|
|
189
|
+
# Case-insensitive match for email principals.
|
|
190
|
+
lower = normalized.lower()
|
|
191
|
+
return any(p.lower() == lower for p in allowed)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def normalize_oauth_permissions(raw: object) -> dict[str, list[str]]:
|
|
195
|
+
return PermissionsMap.model_validate(raw).root
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
class PermissionPrincipal(BaseModel):
|
|
199
|
+
model_config = ConfigDict(frozen=True)
|
|
200
|
+
|
|
201
|
+
username: str
|
|
202
|
+
email: str | None = None
|
|
203
|
+
|
|
204
|
+
@property
|
|
205
|
+
def identity(self) -> str:
|
|
206
|
+
"""Principal string used for grant lookup (email when present, else username)."""
|
|
207
|
+
if self.email and self.email.strip():
|
|
208
|
+
return self.email.strip()
|
|
209
|
+
return self.username.strip()
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
|
|
3
|
+
from pygitweb.permissions import (
|
|
4
|
+
PERMISSION_ADD_PROJECTS,
|
|
5
|
+
PERMISSION_SETTINGS,
|
|
6
|
+
Permission,
|
|
7
|
+
PermissionsMap,
|
|
8
|
+
parse_permission_key,
|
|
9
|
+
permission_key,
|
|
10
|
+
project_matches_scope,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def test_parse_global_keys() -> None:
|
|
15
|
+
assert parse_permission_key(PERMISSION_ADD_PROJECTS) is not None
|
|
16
|
+
assert parse_permission_key(PERMISSION_SETTINGS) is not None
|
|
17
|
+
assert parse_permission_key("pgw.unknown") is None
|
|
18
|
+
assert parse_permission_key("*") is None
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def test_parse_scoped_key() -> None:
|
|
22
|
+
parsed = parse_permission_key("pgw.tasks.my/repo")
|
|
23
|
+
assert parsed is not None
|
|
24
|
+
assert parsed.permission is Permission.TASKS
|
|
25
|
+
assert parsed.project_scope == "my/repo"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def test_permission_key_roundtrip() -> None:
|
|
29
|
+
assert permission_key(Permission.BOARDS, "foo") == "pgw.boards.foo"
|
|
30
|
+
assert permission_key(Permission.MR_CREATE, "foo") == "pgw.mr.create.foo"
|
|
31
|
+
assert permission_key(Permission.MR_MERGE, "foo") == "pgw.mr.merge.foo"
|
|
32
|
+
assert permission_key(Permission.HOOKS, "foo") == "pgw.hooks.foo"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def test_parse_mr_scoped_keys() -> None:
|
|
36
|
+
create = parse_permission_key("pgw.mr.create.demo")
|
|
37
|
+
assert create is not None
|
|
38
|
+
assert create.permission is Permission.MR_CREATE
|
|
39
|
+
merge = parse_permission_key("pgw.mr.merge.demo")
|
|
40
|
+
assert merge is not None
|
|
41
|
+
assert merge.permission is Permission.MR_MERGE
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def test_project_glob() -> None:
|
|
45
|
+
assert project_matches_scope("team/app", "team/*")
|
|
46
|
+
assert not project_matches_scope("other/app", "team/*")
|
|
47
|
+
assert project_matches_scope("anything", "*")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def test_superuser_grants_all() -> None:
|
|
51
|
+
m = PermissionsMap.model_validate({
|
|
52
|
+
"*": ["admin@example.com"],
|
|
53
|
+
"pgw.tasks.secret": ["other@example.com"],
|
|
54
|
+
})
|
|
55
|
+
assert m.has_permission("admin@example.com", Permission.TASKS, "secret")
|
|
56
|
+
assert m.has_permission("admin@example.com", Permission.SETTINGS)
|
|
57
|
+
assert m.has_permission("other@example.com", Permission.TASKS, "secret")
|
|
58
|
+
assert not m.has_permission("other@example.com", Permission.SETTINGS)
|
|
59
|
+
assert not m.has_permission("other@example.com", Permission.TASKS, "public")
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def test_scoped_grant() -> None:
|
|
63
|
+
m = PermissionsMap.model_validate({
|
|
64
|
+
"*": [],
|
|
65
|
+
"pgw.comments.proj-a": ["reader@example.com"],
|
|
66
|
+
"pgw.boards.team/*": ["lead@example.com"],
|
|
67
|
+
})
|
|
68
|
+
assert m.has_permission("reader@example.com", Permission.COMMENTS, "proj-a")
|
|
69
|
+
assert not m.has_permission("reader@example.com", Permission.TASKS, "proj-a")
|
|
70
|
+
assert m.has_permission("lead@example.com", Permission.BOARDS, "team/widget")
|
|
71
|
+
assert not m.has_permission("lead@example.com", Permission.BOARDS, "other/widget")
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def test_email_case_insensitive() -> None:
|
|
75
|
+
m = PermissionsMap.model_validate({"*": [], PERMISSION_SETTINGS: ["Admin@Example.COM"]})
|
|
76
|
+
assert m.has_permission("admin@example.com", Permission.SETTINGS)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def test_rejects_unknown_key() -> None:
|
|
80
|
+
with pytest.raises(ValueError, match="unknown permission key"):
|
|
81
|
+
PermissionsMap.model_validate({"pgw.notreal": ["a@b.com"]})
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def test_ensures_wildcard_key() -> None:
|
|
85
|
+
m = PermissionsMap.model_validate({PERMISSION_ADD_PROJECTS: ["u@x.com"]})
|
|
86
|
+
assert "*" in m.root
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import importlib.metadata
|
|
4
|
+
import logging
|
|
5
|
+
from collections.abc import Iterable
|
|
6
|
+
|
|
7
|
+
from pygitweb.api.action import Action
|
|
8
|
+
from pygitweb.api.subpage import Subpage
|
|
9
|
+
from pygitweb.config import ACTIONS as BUILTIN_ACTIONS
|
|
10
|
+
|
|
11
|
+
logger = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
SUBPAGE_GROUP = "pygitweb.subpages"
|
|
14
|
+
ACTION_GROUP = "pygitweb.actions"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _entry_points(group: str) -> Iterable[importlib.metadata.EntryPoint]:
|
|
18
|
+
return importlib.metadata.entry_points().select(group=group)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def load_subpages() -> list[Subpage]:
|
|
22
|
+
out: list[Subpage] = []
|
|
23
|
+
seen_names: set[str] = set()
|
|
24
|
+
for ep in _entry_points(SUBPAGE_GROUP):
|
|
25
|
+
try:
|
|
26
|
+
loaded = ep.load()
|
|
27
|
+
except Exception:
|
|
28
|
+
logger.exception("pygitweb: failed to load subpage entry point %r", ep.name)
|
|
29
|
+
continue
|
|
30
|
+
if not isinstance(loaded, type) or not issubclass(loaded, Subpage):
|
|
31
|
+
logger.error(
|
|
32
|
+
"pygitweb: entry point %r must be a Subpage subclass, got %r",
|
|
33
|
+
ep.name,
|
|
34
|
+
loaded,
|
|
35
|
+
)
|
|
36
|
+
continue
|
|
37
|
+
try:
|
|
38
|
+
instance = loaded()
|
|
39
|
+
except Exception:
|
|
40
|
+
logger.exception("pygitweb: failed to instantiate subpage %r", ep.name)
|
|
41
|
+
continue
|
|
42
|
+
key = instance.subpage_name()
|
|
43
|
+
if key in seen_names:
|
|
44
|
+
logger.warning("pygitweb: duplicate subpage %r from %r; skipping", key, ep.name)
|
|
45
|
+
continue
|
|
46
|
+
seen_names.add(key)
|
|
47
|
+
out.append(instance)
|
|
48
|
+
return out
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def load_plugin_actions() -> dict[str, Action]:
|
|
52
|
+
out: dict[str, Action] = {}
|
|
53
|
+
for ep in _entry_points(ACTION_GROUP):
|
|
54
|
+
try:
|
|
55
|
+
loaded = ep.load()
|
|
56
|
+
except Exception:
|
|
57
|
+
logger.exception("pygitweb: failed to load action entry point %r", ep.name)
|
|
58
|
+
continue
|
|
59
|
+
if not isinstance(loaded, type) or not issubclass(loaded, Action):
|
|
60
|
+
logger.error(
|
|
61
|
+
"pygitweb: entry point %r must be an Action subclass, got %r",
|
|
62
|
+
ep.name,
|
|
63
|
+
loaded,
|
|
64
|
+
)
|
|
65
|
+
continue
|
|
66
|
+
try:
|
|
67
|
+
instance = loaded()
|
|
68
|
+
except Exception:
|
|
69
|
+
logger.exception("pygitweb: failed to instantiate action %r", ep.name)
|
|
70
|
+
continue
|
|
71
|
+
name = instance.action_name()
|
|
72
|
+
if name in BUILTIN_ACTIONS:
|
|
73
|
+
logger.warning(
|
|
74
|
+
"pygitweb: plugin action %r conflicts with built-in; skipping entry %r",
|
|
75
|
+
name,
|
|
76
|
+
ep.name,
|
|
77
|
+
)
|
|
78
|
+
continue
|
|
79
|
+
if name in out:
|
|
80
|
+
logger.warning("pygitweb: duplicate plugin action %r; skipping entry %r", name, ep.name)
|
|
81
|
+
continue
|
|
82
|
+
out[name] = instance
|
|
83
|
+
return out
|
pygitweb/projects.py
ADDED
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Project list: get_projects_list, search_projects_list, project_in_list,
|
|
3
|
+
get_project_owner, get_project_list_from_file, get_last_activity.
|
|
4
|
+
Ported from gitweb/gitweb.perl.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
import re
|
|
11
|
+
from collections.abc import Callable
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
import pygit2
|
|
15
|
+
|
|
16
|
+
from pygitweb.config import settings
|
|
17
|
+
from pygitweb.validation import check_export_ok
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def project_in_list(
|
|
21
|
+
project: str,
|
|
22
|
+
get_projects_list_fn: Callable[[], list[dict[str, Any]]],
|
|
23
|
+
) -> bool:
|
|
24
|
+
"""True if project appears in project list. Port of project_in_list."""
|
|
25
|
+
lst = get_projects_list_fn()
|
|
26
|
+
return any(p.get("path") == project for p in lst)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _find_projects_in_dir(
|
|
30
|
+
root: str,
|
|
31
|
+
prefix_len: int,
|
|
32
|
+
prefix_depth: int,
|
|
33
|
+
maxdepth: int,
|
|
34
|
+
export_ok: str,
|
|
35
|
+
export_auth_hook: Callable[[str], bool] | None,
|
|
36
|
+
skip_export_check: bool = False,
|
|
37
|
+
) -> list[dict[str, Any]]:
|
|
38
|
+
result = []
|
|
39
|
+
for dirpath, dirnames, _ in os.walk(root, topdown=True):
|
|
40
|
+
rel = os.path.relpath(dirpath, root)
|
|
41
|
+
depth = 0 if rel == "." else rel.count(os.sep) + 1
|
|
42
|
+
if depth >= maxdepth:
|
|
43
|
+
dirnames.clear()
|
|
44
|
+
continue
|
|
45
|
+
for d in list(dirnames):
|
|
46
|
+
path = os.path.join(dirpath, d)
|
|
47
|
+
if not os.path.isdir(path):
|
|
48
|
+
continue
|
|
49
|
+
try:
|
|
50
|
+
if not os.access(path, os.X_OK):
|
|
51
|
+
continue
|
|
52
|
+
except OSError:
|
|
53
|
+
continue
|
|
54
|
+
repo = pygit2.discover_repository(path)
|
|
55
|
+
if not repo:
|
|
56
|
+
continue
|
|
57
|
+
project_path = os.path.relpath(path, settings.PROJECTROOT)
|
|
58
|
+
project_path = project_path.replace("\\", "/")
|
|
59
|
+
git_dir = os.path.join(settings.PROJECTROOT, project_path)
|
|
60
|
+
if not skip_export_check and not check_export_ok(git_dir, export_ok, export_auth_hook):
|
|
61
|
+
continue
|
|
62
|
+
result.append({"path": project_path})
|
|
63
|
+
dirnames.remove(d)
|
|
64
|
+
return result
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def git_get_projects_list(
|
|
68
|
+
filter_path: str = "",
|
|
69
|
+
paranoid: bool = False,
|
|
70
|
+
projectroot: str | None = None,
|
|
71
|
+
projects_list: str | None = None,
|
|
72
|
+
project_maxdepth: int | None = None,
|
|
73
|
+
export_ok: str = "",
|
|
74
|
+
export_auth_hook: Callable[[str], bool] | None = None,
|
|
75
|
+
) -> list[dict[str, Any]]:
|
|
76
|
+
"""List projects from directory scan or file. Port of git_get_projects_list."""
|
|
77
|
+
projectroot = projectroot if projectroot is not None else settings.PROJECTROOT
|
|
78
|
+
projects_list = projects_list if projects_list is not None else settings.PROJECTS_LIST
|
|
79
|
+
project_maxdepth = project_maxdepth if project_maxdepth is not None else settings.PROJECT_MAXDEPTH
|
|
80
|
+
if os.path.isdir(projects_list):
|
|
81
|
+
root = projects_list.rstrip("/")
|
|
82
|
+
prefix_len = len(root) + 1
|
|
83
|
+
prefix_depth = root.count(os.sep)
|
|
84
|
+
if filter_path and not paranoid:
|
|
85
|
+
root = os.path.join(root, filter_path).rstrip("/")
|
|
86
|
+
result = _find_projects_in_dir(
|
|
87
|
+
root,
|
|
88
|
+
prefix_len,
|
|
89
|
+
prefix_depth,
|
|
90
|
+
project_maxdepth,
|
|
91
|
+
export_ok,
|
|
92
|
+
export_auth_hook,
|
|
93
|
+
skip_export_check=settings.LIST_ALL,
|
|
94
|
+
)
|
|
95
|
+
if filter_path and paranoid:
|
|
96
|
+
result = [p for p in result if p["path"].startswith(filter_path + "/")]
|
|
97
|
+
return result
|
|
98
|
+
if os.path.isfile(projects_list):
|
|
99
|
+
from urllib.parse import unquote
|
|
100
|
+
|
|
101
|
+
result = []
|
|
102
|
+
with open(projects_list) as f:
|
|
103
|
+
for line in f:
|
|
104
|
+
line = line.strip()
|
|
105
|
+
if not line:
|
|
106
|
+
continue
|
|
107
|
+
parts = line.split(None, 1)
|
|
108
|
+
path = unquote(parts[0]) if parts else ""
|
|
109
|
+
owner = unquote(parts[1]) if len(parts) > 1 else None
|
|
110
|
+
if not path:
|
|
111
|
+
continue
|
|
112
|
+
if filter_path and not path.startswith(filter_path + "/"):
|
|
113
|
+
continue
|
|
114
|
+
git_dir = os.path.join(projectroot, path)
|
|
115
|
+
if not settings.LIST_ALL and not check_export_ok(git_dir, export_ok, export_auth_hook):
|
|
116
|
+
continue
|
|
117
|
+
pr = {"path": path}
|
|
118
|
+
if owner:
|
|
119
|
+
pr["owner"] = owner
|
|
120
|
+
result.append(pr)
|
|
121
|
+
return result
|
|
122
|
+
return []
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
_gitweb_project_owner: dict[str, str] | None = None
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def git_get_project_list_from_file(
|
|
129
|
+
projects_list: str | None = None,
|
|
130
|
+
projectroot: str | None = None,
|
|
131
|
+
) -> dict[str, str]:
|
|
132
|
+
"""Load project -> owner from file. Port of git_get_project_list_from_file."""
|
|
133
|
+
global _gitweb_project_owner
|
|
134
|
+
if _gitweb_project_owner is not None:
|
|
135
|
+
return _gitweb_project_owner
|
|
136
|
+
projects_list = projects_list if projects_list is not None else settings.PROJECTS_LIST
|
|
137
|
+
_gitweb_project_owner = {}
|
|
138
|
+
if os.path.isfile(projects_list):
|
|
139
|
+
from urllib.parse import unquote
|
|
140
|
+
|
|
141
|
+
with open(projects_list) as f:
|
|
142
|
+
for line in f:
|
|
143
|
+
line = line.strip()
|
|
144
|
+
if not line:
|
|
145
|
+
continue
|
|
146
|
+
parts = line.split(None, 1)
|
|
147
|
+
path = unquote(parts[0]) if parts else ""
|
|
148
|
+
owner = unquote(parts[1]) if len(parts) > 1 else ""
|
|
149
|
+
if path:
|
|
150
|
+
_gitweb_project_owner[path] = owner
|
|
151
|
+
return _gitweb_project_owner
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def git_get_project_owner(
|
|
155
|
+
project: str,
|
|
156
|
+
projectroot: str | None = None,
|
|
157
|
+
get_project_config: Callable[[str, str], Any] | None = None,
|
|
158
|
+
) -> str | None:
|
|
159
|
+
"""Owner from list file or config or file ownership. Port of git_get_project_owner."""
|
|
160
|
+
if not project:
|
|
161
|
+
return None
|
|
162
|
+
projectroot = projectroot if projectroot is not None else settings.PROJECTROOT
|
|
163
|
+
owners = git_get_project_list_from_file(settings.PROJECTS_LIST, projectroot)
|
|
164
|
+
if project in owners and owners[project]:
|
|
165
|
+
return owners[project]
|
|
166
|
+
if get_project_config:
|
|
167
|
+
val = get_project_config(project, "owner")
|
|
168
|
+
if val:
|
|
169
|
+
return val[0] if isinstance(val, list) else val
|
|
170
|
+
return None
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def git_get_last_activity(project: str, projectroot: str | None = None) -> int | None:
|
|
174
|
+
"""Last commit timestamp for project. Port of git_get_last_activity."""
|
|
175
|
+
try:
|
|
176
|
+
repo = pygit2.Repository(os.path.join(settings.PROJECTROOT, project))
|
|
177
|
+
if not repo.head:
|
|
178
|
+
return None
|
|
179
|
+
commit = repo[repo.head.target]
|
|
180
|
+
if isinstance(commit, pygit2.Commit):
|
|
181
|
+
return commit.committer.time
|
|
182
|
+
except (KeyError, pygit2.GitError, OSError):
|
|
183
|
+
return None
|
|
184
|
+
return None
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def search_projects_list(
|
|
188
|
+
projlist: list[dict[str, Any]],
|
|
189
|
+
tagfilter: str | None = None,
|
|
190
|
+
search_regexp: str | None = None,
|
|
191
|
+
fill_project_list_info: Callable[..., None] | None = None,
|
|
192
|
+
) -> list[dict[str, Any]]:
|
|
193
|
+
"""Filter by tag or search regex. Port of search_projects_list."""
|
|
194
|
+
if not tagfilter and not search_regexp:
|
|
195
|
+
return projlist
|
|
196
|
+
if fill_project_list_info:
|
|
197
|
+
fill_project_list_info(projlist, tagfilter=tagfilter, search_re=search_regexp)
|
|
198
|
+
result = []
|
|
199
|
+
for pr in projlist:
|
|
200
|
+
if tagfilter:
|
|
201
|
+
ctags = pr.get("ctags") or {}
|
|
202
|
+
if not any(k.lower() == tagfilter.lower() for k in ctags):
|
|
203
|
+
continue
|
|
204
|
+
if search_regexp:
|
|
205
|
+
try:
|
|
206
|
+
rex = re.compile(search_regexp)
|
|
207
|
+
except re.error:
|
|
208
|
+
continue
|
|
209
|
+
descr = (pr.get("descr_long") or "") + (pr.get("path") or "")
|
|
210
|
+
if not rex.search(descr):
|
|
211
|
+
continue
|
|
212
|
+
result.append(pr)
|
|
213
|
+
return result
|