pygitweb 0.2.1__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.
Files changed (108) hide show
  1. {pygitweb-0.2.1/pygitweb.egg-info → pygitweb-0.2.2}/PKG-INFO +1 -1
  2. {pygitweb-0.2.1 → pygitweb-0.2.2}/actions.py +8 -3
  3. {pygitweb-0.2.1 → pygitweb-0.2.2}/auth.py +32 -4
  4. {pygitweb-0.2.1 → pygitweb-0.2.2}/auth.schema.json +5 -1
  5. {pygitweb-0.2.1 → pygitweb-0.2.2}/auth_config.py +7 -4
  6. {pygitweb-0.2.1 → pygitweb-0.2.2}/auth_oauth.py +1 -2
  7. {pygitweb-0.2.1 → pygitweb-0.2.2}/auth_test.py +113 -6
  8. {pygitweb-0.2.1 → pygitweb-0.2.2}/hooks_install.py +1 -0
  9. {pygitweb-0.2.1 → pygitweb-0.2.2}/main.py +2 -1
  10. {pygitweb-0.2.1 → pygitweb-0.2.2}/permissions.py +52 -1
  11. {pygitweb-0.2.1 → pygitweb-0.2.2}/permissions_test.py +61 -0
  12. {pygitweb-0.2.1 → pygitweb-0.2.2/pygitweb.egg-info}/PKG-INFO +1 -1
  13. {pygitweb-0.2.1 → pygitweb-0.2.2}/pyproject.toml +1 -1
  14. {pygitweb-0.2.1 → pygitweb-0.2.2}/static/board.js +13 -1
  15. {pygitweb-0.2.1 → pygitweb-0.2.2}/static/main.css +6 -0
  16. {pygitweb-0.2.1 → pygitweb-0.2.2}/tasks.py +6 -1
  17. {pygitweb-0.2.1 → pygitweb-0.2.2}/templates/board_card.html +40 -7
  18. {pygitweb-0.2.1 → pygitweb-0.2.2}/LICENSE +0 -0
  19. {pygitweb-0.2.1 → pygitweb-0.2.2}/README.md +0 -0
  20. {pygitweb-0.2.1 → pygitweb-0.2.2}/__init__.py +0 -0
  21. {pygitweb-0.2.1 → pygitweb-0.2.2}/__meta__.py +0 -0
  22. {pygitweb-0.2.1 → pygitweb-0.2.2}/addproject_permissions_test.py +0 -0
  23. {pygitweb-0.2.1 → pygitweb-0.2.2}/api/__init__.py +0 -0
  24. {pygitweb-0.2.1 → pygitweb-0.2.2}/api/action.py +0 -0
  25. {pygitweb-0.2.1 → pygitweb-0.2.2}/api/plugins/__init__.py +0 -0
  26. {pygitweb-0.2.1 → pygitweb-0.2.2}/api/plugins/project_zip.py +0 -0
  27. {pygitweb-0.2.1 → pygitweb-0.2.2}/api/project.py +0 -0
  28. {pygitweb-0.2.1 → pygitweb-0.2.2}/api/subpage.py +0 -0
  29. {pygitweb-0.2.1 → pygitweb-0.2.2}/board_refs.py +0 -0
  30. {pygitweb-0.2.1 → pygitweb-0.2.2}/board_refs_test.py +0 -0
  31. {pygitweb-0.2.1 → pygitweb-0.2.2}/boards_permissions_test.py +0 -0
  32. {pygitweb-0.2.1 → pygitweb-0.2.2}/change_queue.py +0 -0
  33. {pygitweb-0.2.1 → pygitweb-0.2.2}/change_queue_routes_test.py +0 -0
  34. {pygitweb-0.2.1 → pygitweb-0.2.2}/change_queue_test.py +0 -0
  35. {pygitweb-0.2.1 → pygitweb-0.2.2}/comments_permissions_test.py +0 -0
  36. {pygitweb-0.2.1 → pygitweb-0.2.2}/config.py +0 -0
  37. {pygitweb-0.2.1 → pygitweb-0.2.2}/conftest.py +0 -0
  38. {pygitweb-0.2.1 → pygitweb-0.2.2}/dependencies.py +0 -0
  39. {pygitweb-0.2.1 → pygitweb-0.2.2}/formatting.py +0 -0
  40. {pygitweb-0.2.1 → pygitweb-0.2.2}/formatting_test.py +0 -0
  41. {pygitweb-0.2.1 → pygitweb-0.2.2}/git_helpers.py +0 -0
  42. {pygitweb-0.2.1 → pygitweb-0.2.2}/git_helpers_test.py +0 -0
  43. {pygitweb-0.2.1 → pygitweb-0.2.2}/gravatar.py +0 -0
  44. {pygitweb-0.2.1 → pygitweb-0.2.2}/gravatar_test.py +0 -0
  45. {pygitweb-0.2.1 → pygitweb-0.2.2}/hooks_install_test.py +0 -0
  46. {pygitweb-0.2.1 → pygitweb-0.2.2}/hooks_permissions_test.py +0 -0
  47. {pygitweb-0.2.1 → pygitweb-0.2.2}/merge_requests.py +0 -0
  48. {pygitweb-0.2.1 → pygitweb-0.2.2}/merge_requests_test.py +0 -0
  49. {pygitweb-0.2.1 → pygitweb-0.2.2}/mr_permissions_test.py +0 -0
  50. {pygitweb-0.2.1 → pygitweb-0.2.2}/password_hash.py +0 -0
  51. {pygitweb-0.2.1 → pygitweb-0.2.2}/password_hash_test.py +0 -0
  52. {pygitweb-0.2.1 → pygitweb-0.2.2}/plugin_loader.py +0 -0
  53. {pygitweb-0.2.1 → pygitweb-0.2.2}/project_list_routes_test.py +0 -0
  54. {pygitweb-0.2.1 → pygitweb-0.2.2}/projects.py +0 -0
  55. {pygitweb-0.2.1 → pygitweb-0.2.2}/projects_test.py +0 -0
  56. {pygitweb-0.2.1 → pygitweb-0.2.2}/pygitweb.egg-info/SOURCES.txt +0 -0
  57. {pygitweb-0.2.1 → pygitweb-0.2.2}/pygitweb.egg-info/dependency_links.txt +0 -0
  58. {pygitweb-0.2.1 → pygitweb-0.2.2}/pygitweb.egg-info/requires.txt +0 -0
  59. {pygitweb-0.2.1 → pygitweb-0.2.2}/pygitweb.egg-info/top_level.txt +0 -0
  60. {pygitweb-0.2.1 → pygitweb-0.2.2}/sessions.py +0 -0
  61. {pygitweb-0.2.1 → pygitweb-0.2.2}/settings.py +0 -0
  62. {pygitweb-0.2.1 → pygitweb-0.2.2}/settings.schema.json +0 -0
  63. {pygitweb-0.2.1 → pygitweb-0.2.2}/settings_config.py +0 -0
  64. {pygitweb-0.2.1 → pygitweb-0.2.2}/settings_config_test.py +0 -0
  65. {pygitweb-0.2.1 → pygitweb-0.2.2}/settings_permissions_test.py +0 -0
  66. {pygitweb-0.2.1 → pygitweb-0.2.2}/setup.cfg +0 -0
  67. {pygitweb-0.2.1 → pygitweb-0.2.2}/shutdown.py +0 -0
  68. {pygitweb-0.2.1 → pygitweb-0.2.2}/smart_http.py +0 -0
  69. {pygitweb-0.2.1 → pygitweb-0.2.2}/smart_http_test.py +0 -0
  70. {pygitweb-0.2.1 → pygitweb-0.2.2}/static/blob-view.js +0 -0
  71. {pygitweb-0.2.1 → pygitweb-0.2.2}/static/board-refs.js +0 -0
  72. {pygitweb-0.2.1 → pygitweb-0.2.2}/static/hook-install.js +0 -0
  73. {pygitweb-0.2.1 → pygitweb-0.2.2}/static/icons/cloud-down.svg +0 -0
  74. {pygitweb-0.2.1 → pygitweb-0.2.2}/static/icons/cloud-exclamation.svg +0 -0
  75. {pygitweb-0.2.1 → pygitweb-0.2.2}/static/icons/cloud-filled.svg +0 -0
  76. {pygitweb-0.2.1 → pygitweb-0.2.2}/static/icons/cloud-storm.svg +0 -0
  77. {pygitweb-0.2.1 → pygitweb-0.2.2}/static/icons/cloud-up.svg +0 -0
  78. {pygitweb-0.2.1 → pygitweb-0.2.2}/static/icons/cloud.svg +0 -0
  79. {pygitweb-0.2.1 → pygitweb-0.2.2}/static/logo.svg +0 -0
  80. {pygitweb-0.2.1 → pygitweb-0.2.2}/static/navbar-auth.js +0 -0
  81. {pygitweb-0.2.1 → pygitweb-0.2.2}/static/readme-render.js +0 -0
  82. {pygitweb-0.2.1 → pygitweb-0.2.2}/static/search-page.js +0 -0
  83. {pygitweb-0.2.1 → pygitweb-0.2.2}/static/summary-ref-switcher.js +0 -0
  84. {pygitweb-0.2.1 → pygitweb-0.2.2}/static/themes/solarized.css +0 -0
  85. {pygitweb-0.2.1 → pygitweb-0.2.2}/tasks_permissions_test.py +0 -0
  86. {pygitweb-0.2.1 → pygitweb-0.2.2}/tasks_routes_test.py +0 -0
  87. {pygitweb-0.2.1 → pygitweb-0.2.2}/templates/addproject.html +0 -0
  88. {pygitweb-0.2.1 → pygitweb-0.2.2}/templates/blobdiff.html +0 -0
  89. {pygitweb-0.2.1 → pygitweb-0.2.2}/templates/board.html +0 -0
  90. {pygitweb-0.2.1 → pygitweb-0.2.2}/templates/commitdiff.html +0 -0
  91. {pygitweb-0.2.1 → pygitweb-0.2.2}/templates/hooks_summary_cell.html +0 -0
  92. {pygitweb-0.2.1 → pygitweb-0.2.2}/templates/login.html +0 -0
  93. {pygitweb-0.2.1 → pygitweb-0.2.2}/templates/merge_request_summary_cell.html +0 -0
  94. {pygitweb-0.2.1 → pygitweb-0.2.2}/templates/merge_request_tag.html +0 -0
  95. {pygitweb-0.2.1 → pygitweb-0.2.2}/templates/pagination.html +0 -0
  96. {pygitweb-0.2.1 → pygitweb-0.2.2}/templates/preamble.html +0 -0
  97. {pygitweb-0.2.1 → pygitweb-0.2.2}/templates/project_settings.html +0 -0
  98. {pygitweb-0.2.1 → pygitweb-0.2.2}/templates/pygit2_settings.html +0 -0
  99. {pygitweb-0.2.1 → pygitweb-0.2.2}/templates/pygitweb_settings.html +0 -0
  100. {pygitweb-0.2.1 → pygitweb-0.2.2}/templates/search.html +0 -0
  101. {pygitweb-0.2.1 → pygitweb-0.2.2}/templates/settings_form_field.html +0 -0
  102. {pygitweb-0.2.1 → pygitweb-0.2.2}/templates/shutdown_ack.html +0 -0
  103. {pygitweb-0.2.1 → pygitweb-0.2.2}/templates/table.html +0 -0
  104. {pygitweb-0.2.1 → pygitweb-0.2.2}/templates/user_profile.html +0 -0
  105. {pygitweb-0.2.1 → pygitweb-0.2.2}/templates_env.py +0 -0
  106. {pygitweb-0.2.1 → pygitweb-0.2.2}/timeline_cache.py +0 -0
  107. {pygitweb-0.2.1 → pygitweb-0.2.2}/validation.py +0 -0
  108. {pygitweb-0.2.1 → 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.1
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
@@ -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 not heads_list:
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 heads_list:
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
- principal = principal_from_session(token)
77
- if principal is None:
93
+ identity = _principal_identity_for_permissions(token)
94
+ if identity is None:
78
95
  return False
79
- return auth_config.oauth_permissions.has_permission(principal.identity, permission, project)
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 = "email"
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="email",
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=subject,
328
+ subject=username,
330
329
  email=email,
331
330
  auth_method="oauth",
332
331
  )
@@ -5,10 +5,13 @@ import sys
5
5
  from collections.abc import Generator
6
6
  from pathlib import Path
7
7
  from unittest.mock import AsyncMock, patch
8
+ from urllib.parse import parse_qs, urlparse
8
9
 
10
+ import httpx
9
11
  import pytest
10
- from fastapi import HTTPException
12
+ from fastapi import FastAPI, HTTPException, Request
11
13
  from fastapi.testclient import TestClient
14
+ from httpx import ASGITransport
12
15
 
13
16
  from pygitweb.auth import ACCESS_TOKEN_COOKIE_NAME
14
17
  from pygitweb.auth_config import AuthConfig, LocalUser, init_auth_config, write_auth_config
@@ -29,6 +32,8 @@ from pygitweb.password_hash import hash_password, verify_password
29
32
  from pygitweb.permissions import PERMISSION_ADD_PROJECTS, PermissionsMap
30
33
  from pygitweb.sessions import get_session
31
34
 
35
+ _REAL_ASYNC_CLIENT = httpx.AsyncClient
36
+
32
37
 
33
38
  class _DummyResponse:
34
39
  def __init__(self, payload: object, is_success: bool = True) -> None:
@@ -48,6 +53,47 @@ _LOCAL_ADMIN = AuthConfig(
48
53
  )
49
54
 
50
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
+
51
97
  @pytest.fixture
52
98
  def client() -> Generator[TestClient, None, None]:
53
99
  with TestClient(app) as c:
@@ -294,6 +340,32 @@ def test_oauth_allowed_emails_exact_match() -> None:
294
340
  assert not is_oauth_email_allowed(cfg, "other@example.com")
295
341
 
296
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
+
297
369
  _OAUTH_CFG = AuthConfig(
298
370
  auth_mode="oauth",
299
371
  oauth_provider="google",
@@ -345,15 +417,50 @@ def test_oauth_callback_creates_session(client: TestClient, oauth_auth_config: A
345
417
  assert r.status_code == 303
346
418
  rec = get_session(r.cookies["pygitweb_access_token"])
347
419
  assert rec is not None
348
- assert rec.username == "user@example.com"
349
- assert rec.subject == "oauth-sub-1"
420
+ assert rec.username == "oauthuser"
421
+ assert rec.subject == "oauthuser"
350
422
  assert rec.email == "user@example.com"
351
423
  assert rec.auth_method == "oauth"
352
- assert client.get("/users/me").json()["username"] == "user@example.com"
424
+ assert client.get("/users/me").json()["username"] == "oauthuser"
353
425
  status = client.get("/auth/status").json()
354
- assert status["username"] == "user@example.com"
426
+ assert status["username"] == "oauthuser"
355
427
  assert status["gravatar_url"] == gravatar_url("user@example.com")
356
- clear_client_cookies(client)
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())
357
464
 
358
465
 
359
466
  def test_auth_status_no_gravatar_for_local_login(client: TestClient, local_auth_config: AuthConfig) -> None:
@@ -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
 
@@ -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)
@@ -27,7 +27,11 @@ WILDCARD_PRINCIPAL = "*"
27
27
  PERMISSION_ADD_PROJECTS = "pgw.addprojects"
28
28
  PERMISSION_SETTINGS = "pgw.settings"
29
29
 
30
- _SCOPED_PREFIX = re.compile(r"^pgw\.(boards|tasks|comments|hooks|mr\.create|mr\.merge|read|write)\.(.+)$")
30
+ _SCOPED_PREFIX = re.compile(
31
+ r"^pgw\.(boards|tasks|comments|hooks|mr\.create|mr\.merge|read|write|read_branch|write_branch)\.(.+)$",
32
+ )
33
+
34
+ _BRANCH_REF_PREFIX = "refs/heads/"
31
35
 
32
36
 
33
37
  class Permission(StrEnum):
@@ -35,6 +39,8 @@ class Permission(StrEnum):
35
39
 
36
40
  READ = "read"
37
41
  WRITE = "write"
42
+ READ_BRANCH = "read_branch"
43
+ WRITE_BRANCH = "write_branch"
38
44
  BOARDS = "boards"
39
45
  TASKS = "tasks"
40
46
  COMMENTS = "comments"
@@ -57,6 +63,8 @@ class Permission(StrEnum):
57
63
  if self in (
58
64
  Permission.READ,
59
65
  Permission.WRITE,
66
+ Permission.READ_BRANCH,
67
+ Permission.WRITE_BRANCH,
60
68
  Permission.BOARDS,
61
69
  Permission.TASKS,
62
70
  Permission.COMMENTS,
@@ -116,6 +124,49 @@ def project_matches_scope(project: str, scope: str) -> bool:
116
124
  return fnmatch.fnmatchcase(project, scope)
117
125
 
118
126
 
127
+ def short_branch_name_from_ref(ref_name: str) -> str | None:
128
+ if not ref_name.startswith(_BRANCH_REF_PREFIX):
129
+ return None
130
+ return ref_name.removeprefix(_BRANCH_REF_PREFIX)
131
+
132
+
133
+ def branch_permission_subject(project: str, ref_name: str) -> str | None:
134
+ short_name = short_branch_name_from_ref(ref_name)
135
+ if short_name is None:
136
+ return None
137
+ return f"{project}/{short_name}"
138
+
139
+
140
+ def has_branch_read_permission(
141
+ permissions: PermissionsMap,
142
+ principal: str | None,
143
+ project: str,
144
+ ref_name: str,
145
+ ) -> bool:
146
+ subject = branch_permission_subject(project, ref_name)
147
+ if subject is None:
148
+ return True
149
+ branch_specific = permissions.matching_scoped_principals(Permission.READ_BRANCH, subject)
150
+ if branch_specific:
151
+ return permissions.has_permission(principal, Permission.READ_BRANCH, subject)
152
+ return permissions.has_permission(principal, Permission.READ, project)
153
+
154
+
155
+ def has_branch_write_permission(
156
+ permissions: PermissionsMap,
157
+ principal: str | None,
158
+ project: str,
159
+ ref_name: str,
160
+ ) -> bool:
161
+ subject = branch_permission_subject(project, ref_name)
162
+ if subject is None:
163
+ return True
164
+ branch_specific = permissions.matching_scoped_principals(Permission.WRITE_BRANCH, subject)
165
+ if branch_specific:
166
+ return permissions.has_permission(principal, Permission.WRITE_BRANCH, subject)
167
+ return permissions.has_permission(principal, Permission.WRITE, project)
168
+
169
+
119
170
  class PermissionsMap(RootModel[dict[str, list[str]]]):
120
171
  """Validated ``oauth_permissions`` object from auth.json."""
121
172
 
@@ -5,9 +5,13 @@ from pygitweb.permissions import (
5
5
  PERMISSION_SETTINGS,
6
6
  Permission,
7
7
  PermissionsMap,
8
+ branch_permission_subject,
9
+ has_branch_read_permission,
10
+ has_branch_write_permission,
8
11
  parse_permission_key,
9
12
  permission_key,
10
13
  project_matches_scope,
14
+ short_branch_name_from_ref,
11
15
  )
12
16
 
13
17
 
@@ -54,12 +58,31 @@ def test_parse_mr_scoped_keys() -> None:
54
58
  assert merge.permission is Permission.MR_MERGE
55
59
 
56
60
 
61
+ def test_parse_branch_scoped_keys() -> None:
62
+ read = parse_permission_key("pgw.read_branch.team/app-main")
63
+ assert read is not None
64
+ assert read.permission is Permission.READ_BRANCH
65
+ assert read.project_scope == "team/app-main"
66
+ write = parse_permission_key("pgw.write_branch.dev/alice.*")
67
+ assert write is not None
68
+ assert write.permission is Permission.WRITE_BRANCH
69
+ assert write.project_scope == "dev/alice.*"
70
+
71
+
57
72
  def test_project_glob() -> None:
58
73
  assert project_matches_scope("team/app", "team/*")
59
74
  assert not project_matches_scope("other/app", "team/*")
60
75
  assert project_matches_scope("anything", "*")
61
76
 
62
77
 
78
+ def test_branch_permission_subject() -> None:
79
+ assert short_branch_name_from_ref("refs/heads/main") == "main"
80
+ assert short_branch_name_from_ref("refs/heads/dev/alice.feature") == "dev/alice.feature"
81
+ assert short_branch_name_from_ref("refs/tags/v1.0.0") is None
82
+ assert branch_permission_subject("demo", "refs/heads/main") == "demo/main"
83
+ assert branch_permission_subject("demo", "refs/tags/v1.0.0") is None
84
+
85
+
63
86
  def test_superuser_grants_all() -> None:
64
87
  m = PermissionsMap.model_validate({
65
88
  "*": ["admin@example.com"],
@@ -138,3 +161,41 @@ def test_owner_grants_read_and_write() -> None:
138
161
  assert can_read_project("secret", token)
139
162
  assert can_write_project("secret", token)
140
163
  assert not can_read_project("secret", None)
164
+
165
+
166
+ def test_has_branch_write_permission_falls_back_to_project_scope() -> None:
167
+ m = PermissionsMap.model_validate({
168
+ "*": [],
169
+ "pgw.write.demo": ["writer@example.com"],
170
+ })
171
+ assert has_branch_write_permission(m, "writer@example.com", "demo", "refs/heads/main")
172
+ assert not has_branch_write_permission(m, "other@example.com", "demo", "refs/heads/main")
173
+
174
+
175
+ def test_has_branch_write_permission_enforces_branch_specific_rules() -> None:
176
+ m = PermissionsMap.model_validate({
177
+ "*": [],
178
+ "pgw.write.demo": ["writer@example.com"],
179
+ "pgw.write_branch.demo/main": ["branch-writer@example.com"],
180
+ })
181
+ assert has_branch_write_permission(m, "branch-writer@example.com", "demo", "refs/heads/main")
182
+ assert not has_branch_write_permission(m, "writer@example.com", "demo", "refs/heads/main")
183
+
184
+
185
+ def test_has_branch_read_permission_falls_back_to_project_scope() -> None:
186
+ m = PermissionsMap.model_validate({
187
+ "*": [],
188
+ "pgw.read.demo": ["reader@example.com"],
189
+ })
190
+ assert has_branch_read_permission(m, "reader@example.com", "demo", "refs/heads/main")
191
+ assert not has_branch_read_permission(m, "other@example.com", "demo", "refs/heads/main")
192
+
193
+
194
+ def test_has_branch_read_permission_enforces_branch_specific_rules() -> None:
195
+ m = PermissionsMap.model_validate({
196
+ "*": [],
197
+ "pgw.read.demo": ["reader@example.com"],
198
+ "pgw.read_branch.demo/main": ["branch-reader@example.com"],
199
+ })
200
+ assert has_branch_read_permission(m, "branch-reader@example.com", "demo", "refs/heads/main")
201
+ assert not has_branch_read_permission(m, "reader@example.com", "demo", "refs/heads/main")
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pygitweb
3
- Version: 0.2.1
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
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "pygitweb"
7
- version = "0.2.1"
7
+ version = "0.2.2"
8
8
  description = "Gitweb reimplementation with FastAPI and Pygit2"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -225,11 +225,13 @@
225
225
  var statusEl = details.querySelector('.board-card-status');
226
226
  var priorityEl = details.querySelector('.board-card-priority');
227
227
  var assigneeEl = details.querySelector('.board-card-assignee');
228
+ var tagsEl = details.querySelector('.board-card-tags');
228
229
  if (dueDate) dueDate.textContent = task.due_date || 'None';
229
230
  if (updatedAt) updatedAt.textContent = task.updated_at || 'None';
230
231
  if (statusEl) statusEl.textContent = status;
231
232
  if (priorityEl) priorityEl.textContent = task.priority || 'LOW';
232
233
  if (assigneeEl) assigneeEl.textContent = task.assignee || 'None';
234
+ if (tagsEl) tagsEl.textContent = task.tags || '';
233
235
  updateCardCommentCount(card, task.comments_count || 0);
234
236
  }
235
237
  return clone;
@@ -617,6 +619,7 @@
617
619
  var dueEl = popoutCard.querySelector('.board-card-due-date');
618
620
  var statusEl = popoutCard.querySelector('.board-card-status');
619
621
  var priorityEl = popoutCard.querySelector('.board-card-priority');
622
+ var tagsEl = popoutCard.querySelector('.board-card-tags');
620
623
 
621
624
  function pushTaskUpdate() {
622
625
  var taskRef = popoutCard.dataset.taskRef;
@@ -629,16 +632,19 @@
629
632
  var sEl = popoutCard.querySelector('.board-card-status');
630
633
  var pEl = popoutCard.querySelector('.board-card-priority');
631
634
  var aEl = popoutCard.querySelector('.board-card-assignee');
635
+ var tagsEl_ = popoutCard.querySelector('.board-card-tags');
632
636
  var dueVal = (dueEl_ && dueEl_.textContent) ? dueEl_.textContent.trim() : '';
633
637
  var statusDisplay = (sEl && sEl.textContent) ? sEl.textContent.trim() : 'TODO';
634
638
  var priorityDisplay = (pEl && pEl.textContent) ? pEl.textContent.trim() : 'LOW';
639
+ var tagsVal = (tagsEl_ && tagsEl_.textContent) ? tagsEl_.textContent.trim() : '';
635
640
  var body = {
636
641
  title: (tEl && tEl.textContent) ? tEl.textContent.trim() || 'Untitled' : 'Untitled',
637
642
  description: getMarkdownFromElement(dEl),
638
643
  status: statusDisplay ? statusDisplay.replace(/\s+/g, '_').toUpperCase() : 'TODO',
639
644
  priority: priorityDisplay ? priorityDisplay.replace(/\s+/g, '_').toUpperCase() : 'LOW',
640
645
  assignee: (aEl && aEl.textContent && aEl.textContent.trim() !== 'None') ? aEl.textContent.trim() : null,
641
- due_date: (dueVal && dueVal !== 'None') ? dueVal : null
646
+ due_date: (dueVal && dueVal !== 'None') ? dueVal : null,
647
+ tags: tagsVal || ''
642
648
  };
643
649
  fetch('/tasks/update?' + new URLSearchParams({ project: ctx.project, board: ctx.board, task: taskRef }).toString(), {
644
650
  method: 'POST',
@@ -783,6 +789,12 @@
783
789
  sel.addEventListener('change', commitPriority);
784
790
  });
785
791
  }
792
+
793
+ if (tagsEl) {
794
+ tagsEl.addEventListener('blur', function () {
795
+ pushTaskUpdate();
796
+ });
797
+ }
786
798
  }
787
799
 
788
800
  function showCardPopout(card) {
@@ -1126,6 +1126,12 @@ pre {
1126
1126
  text-transform: uppercase;
1127
1127
  }
1128
1128
 
1129
+ .board-card-tags[contenteditable="true"]:empty::before {
1130
+ content: "(click to add tags)";
1131
+ color: var(--pgw-text-muted);
1132
+ opacity: 0.8;
1133
+ }
1134
+
1129
1135
  .board-card-comment-body {
1130
1136
  min-width: 0;
1131
1137
  flex: 1;
@@ -220,6 +220,7 @@ def _task_to_json(t: Task) -> dict[str, Any]:
220
220
  "priority": t.priority.value if t.priority else None,
221
221
  "assignee": t.assignee,
222
222
  "due_date": t.due_date.isoformat() if t.due_date else None,
223
+ "tags": getattr(t, "tags", "") or "",
223
224
  "created_at": t.created_at.isoformat() if t.created_at else None,
224
225
  "updated_at": t.updated_at.isoformat() if t.updated_at else None,
225
226
  "comments_count": len(getattr(t, "comments", [])),
@@ -305,6 +306,7 @@ def task_create(
305
306
  priority: str = Query("LOW", description="Task priority"),
306
307
  assignee: str = Query("", description="Assignee"),
307
308
  due_date: str = Query("", description="Due date ISO"),
309
+ tags: str = Query("", description="Task tags (KEY=VALUE list)"),
308
310
  ) -> JSONResponse:
309
311
  """Create a new task on a board."""
310
312
  repo = pygit2.Repository(os.path.join(settings.PROJECTROOT, project))
@@ -335,6 +337,7 @@ def task_create(
335
337
  priority=priority_enum,
336
338
  assignee=assignee or None,
337
339
  due_date=due,
340
+ tags=tags or "",
338
341
  )
339
342
  oid = task.write(repo)
340
343
  b.tasks = getattr(b, "tasks", []) or []
@@ -355,7 +358,7 @@ async def task_update(
355
358
  board: str = Query(..., description="Board name"),
356
359
  task_ref: str = Query(..., alias="task", description="Task ref (e.g. refs/tags/tasks/task_123)"),
357
360
  ) -> JSONResponse:
358
- """Update a task (body: optional title, description, status, priority, assignee, due_date)."""
361
+ """Update a task (body: optional title, description, status, priority, assignee, due_date, tags)."""
359
362
  try:
360
363
  body = await request.json() if request.headers.get("content-type", "").startswith("application/json") else {}
361
364
  except Exception:
@@ -385,6 +388,8 @@ async def task_update(
385
388
  )
386
389
  except ValueError:
387
390
  pass
391
+ if "tags" in body:
392
+ t.tags = str(body.get("tags") or "")
388
393
  old_oid = str(repo.references[ref].resolve().target)
389
394
  t.update_message()
390
395
  new_oid = t.write(repo)
@@ -24,13 +24,46 @@
24
24
  {% endif %}
25
25
  </div>
26
26
  <div class="board-card-details d-none mt-2 pt-2 border-top small">
27
- <div class="mb-2"><strong class="text-muted">Ref</strong><div class="board-card-task-ref font-monospace text-muted mt-1 text-break">{% if task and task.ref %}{{ task.ref }}{% else %}{% endif %}</div></div>
28
- <div class="mb-2"><strong class="text-muted">Description</strong><div class="board-card-description-full board-card-editable board-card-markdown mt-1">{% if task and task.description %}{{ task.description }}{% else %}{% endif %}</div></div>
29
- <div class="mb-2"><strong class="text-muted">Due date</strong><div class="board-card-due-date board-card-editable text-muted mt-1">{% if task and task.due_date %}{{ task.due_date }}{% else %}None{% endif %}</div></div>
30
- <div class="mb-2"><strong class="text-muted">Last modified</strong><div class="board-card-updated-at text-muted mt-1">{% if task and task.updated_at %}{{ task.updated_at }}{% else %}None{% endif %}</div></div>
31
- <div class="mb-2"><strong class="text-muted">Status</strong><div class="board-card-status board-card-editable text-muted mt-1">{% if task %}{{ task.status or 'TODO' }}{% else %}TODO{% endif %}</div></div>
32
- <div class="mb-2"><strong class="text-muted">Priority</strong><div class="board-card-priority board-card-editable text-muted mt-1">{% if task and task.priority %}{{ task.priority }}{% else %}LOW{% endif %}</div></div>
33
- <div class="mb-2"><strong class="text-muted">Assignee</strong><div class="board-card-assignee text-muted mt-1">{% if task and task.assignee %}{{ task.assignee }}{% else %}None{% endif %}</div></div>
27
+ <div class="mb-2">
28
+ <strong class="text-muted">Ref</strong>
29
+ <div class="board-card-task-ref font-monospace text-muted mt-1 text-break">{% if task and task.ref %}{{ task.ref }}{% else %}{% endif %}</div>
30
+ </div>
31
+ <div class="mb-2">
32
+ <strong class="text-muted">Description</strong>
33
+ <div class="board-card-description-full board-card-editable board-card-markdown mt-1">{% if task and task.description %}{{ task.description }}{% else %}{% endif %}</div>
34
+ </div>
35
+ <div class="mb-2">
36
+ <strong class="text-muted">Due date</strong>
37
+ <div class="board-card-due-date board-card-editable text-muted mt-1">{% if task and task.due_date %}{{ task.due_date }}{% else %}None{% endif %}</div>
38
+ </div>
39
+ <div class="mb-2">
40
+ <strong class="text-muted">Last modified</strong>
41
+ <div class="board-card-updated-at text-muted mt-1">{% if task and task.updated_at %}{{ task.updated_at }}{% else %}None{% endif %}</div>
42
+ </div>
43
+ <div class="mb-2">
44
+ <strong class="text-muted">Status</strong>
45
+ <div class="board-card-status board-card-editable text-muted mt-1">{% if task %}{{ task.status or 'TODO' }}{% else %}TODO{% endif %}</div>
46
+ </div>
47
+ <div class="mb-2">
48
+ <strong class="text-muted">Priority</strong>
49
+ <div class="board-card-priority board-card-editable text-muted mt-1">{% if task and task.priority %}{{ task.priority }}{% else %}LOW{% endif %}</div>
50
+ </div>
51
+ <div class="mb-2">
52
+ <strong class="text-muted">Assignee</strong>
53
+ <div class="board-card-assignee text-muted mt-1">{% if task and task.assignee %}{{ task.assignee }}{% else %}None{% endif %}</div>
54
+ </div>
55
+ <div class="mb-2">
56
+ <details class="board-card-tags-details" open>
57
+ <summary class="text-muted">Tags</summary>
58
+ <div
59
+ class="board-card-tags font-monospace text-muted mt-1 small"
60
+ contenteditable="true"
61
+ spellcheck="false"
62
+ >
63
+ {% if task and task.tags %}{{ task.tags }}{% endif %}
64
+ </div>
65
+ </details>
66
+ </div>
34
67
  <div class="mb-0 board-card-comments-section">
35
68
  <strong class="text-muted">Comments</strong>
36
69
  <div class="board-card-comments-list mt-2"></div>
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes