FossilRepo

Security hardening: HSTS headers, Cloudflare Turnstile on login (optional via constance)

ragelink 2026-04-07 21:25 trunk
Commit 0c354aca4fcba6a7a35a85c9c333ba694255548c26164d42ecd8bc453049d61a
--- accounts/views.py
+++ accounts/views.py
@@ -1,7 +1,9 @@
1
+import logging
12
import re
23
4
+import requests
35
from django.contrib import messages
46
from django.contrib.auth import login, logout
57
from django.contrib.auth.decorators import login_required
68
from django.http import HttpResponse
79
from django.shortcuts import get_object_or_404, redirect, render
@@ -9,10 +11,12 @@
911
from django.views.decorators.http import require_POST
1012
from django_ratelimit.decorators import ratelimit
1113
1214
from .forms import LoginForm
1315
from .models import PersonalAccessToken, UserProfile
16
+
17
+logger = logging.getLogger(__name__)
1418
1519
# Allowed SSH key type prefixes
1620
_SSH_KEY_PREFIXES = ("ssh-ed25519", "ssh-rsa", "ecdsa-sha2-", "ssh-dss")
1721
1822
@@ -46,17 +50,57 @@
4650
if not re.match(r"^[A-Za-z0-9+/=]+$", parts[1]):
4751
return None, "Invalid SSH key data encoding."
4852
4953
return key, ""
5054
55
+
56
+def _verify_turnstile(token: str, remote_ip: str) -> bool:
57
+ """Verify a Cloudflare Turnstile response token. Returns True if valid."""
58
+ from constance import config
59
+
60
+ if not config.TURNSTILE_SECRET_KEY:
61
+ return False
62
+ try:
63
+ resp = requests.post(
64
+ "https://challenges.cloudflare.com/turnstile/v0/siteverify",
65
+ data={"secret": config.TURNSTILE_SECRET_KEY, "response": token, "remoteip": remote_ip},
66
+ timeout=5,
67
+ )
68
+ return resp.status_code == 200 and resp.json().get("success", False)
69
+ except Exception:
70
+ logger.exception("Turnstile verification failed")
71
+ return False
72
+
5173
5274
@ratelimit(key="ip", rate="10/m", block=True)
5375
def login_view(request):
5476
if request.user.is_authenticated:
5577
return redirect("dashboard")
78
+
79
+ from constance import config
80
+
81
+ turnstile_enabled = config.TURNSTILE_ENABLED and config.TURNSTILE_SITE_KEY
82
+ turnstile_error = False
5683
5784
if request.method == "POST":
85
+ # Verify Turnstile before processing login
86
+ if turnstile_enabled:
87
+ turnstile_token = request.POST.get("cf-turnstile-response", "")
88
+ if not turnstile_token or not _verify_turnstile(turnstile_token, request.META.get("REMOTE_ADDR", "")):
89
+ turnstile_error = True
90
+ form = LoginForm()
91
+ return render(
92
+ request,
93
+ "accounts/login.html",
94
+ {
95
+ "form": form,
96
+ "turnstile_enabled": True,
97
+ "turnstile_site_key": config.TURNSTILE_SITE_KEY,
98
+ "turnstile_error": True,
99
+ },
100
+ )
101
+
58102
form = LoginForm(request, data=request.POST)
59103
if form.is_valid():
60104
login(request, form.get_user())
61105
next_url = request.GET.get("next", "")
62106
if next_url and url_has_allowed_host_and_scheme(next_url, allowed_hosts={request.get_host()}):
@@ -63,11 +107,16 @@
63107
return redirect(next_url)
64108
return redirect("dashboard")
65109
else:
66110
form = LoginForm()
67111
68
- return render(request, "accounts/login.html", {"form": form})
112
+ ctx = {"form": form}
113
+ if turnstile_enabled:
114
+ ctx["turnstile_enabled"] = True
115
+ ctx["turnstile_site_key"] = config.TURNSTILE_SITE_KEY
116
+ ctx["turnstile_error"] = turnstile_error
117
+ return render(request, "accounts/login.html", ctx)
69118
70119
71120
@require_POST
72121
def logout_view(request):
73122
logout(request)
74123
--- accounts/views.py
+++ accounts/views.py
@@ -1,7 +1,9 @@
 
1 import re
2
 
3 from django.contrib import messages
4 from django.contrib.auth import login, logout
5 from django.contrib.auth.decorators import login_required
6 from django.http import HttpResponse
7 from django.shortcuts import get_object_or_404, redirect, render
@@ -9,10 +11,12 @@
9 from django.views.decorators.http import require_POST
10 from django_ratelimit.decorators import ratelimit
11
12 from .forms import LoginForm
13 from .models import PersonalAccessToken, UserProfile
 
 
14
15 # Allowed SSH key type prefixes
16 _SSH_KEY_PREFIXES = ("ssh-ed25519", "ssh-rsa", "ecdsa-sha2-", "ssh-dss")
17
18
@@ -46,17 +50,57 @@
46 if not re.match(r"^[A-Za-z0-9+/=]+$", parts[1]):
47 return None, "Invalid SSH key data encoding."
48
49 return key, ""
50
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
51
52 @ratelimit(key="ip", rate="10/m", block=True)
53 def login_view(request):
54 if request.user.is_authenticated:
55 return redirect("dashboard")
 
 
 
 
 
56
57 if request.method == "POST":
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
58 form = LoginForm(request, data=request.POST)
59 if form.is_valid():
60 login(request, form.get_user())
61 next_url = request.GET.get("next", "")
62 if next_url and url_has_allowed_host_and_scheme(next_url, allowed_hosts={request.get_host()}):
@@ -63,11 +107,16 @@
63 return redirect(next_url)
64 return redirect("dashboard")
65 else:
66 form = LoginForm()
67
68 return render(request, "accounts/login.html", {"form": form})
 
 
 
 
 
69
70
71 @require_POST
72 def logout_view(request):
73 logout(request)
74
--- accounts/views.py
+++ accounts/views.py
@@ -1,7 +1,9 @@
1 import logging
2 import re
3
4 import requests
5 from django.contrib import messages
6 from django.contrib.auth import login, logout
7 from django.contrib.auth.decorators import login_required
8 from django.http import HttpResponse
9 from django.shortcuts import get_object_or_404, redirect, render
@@ -9,10 +11,12 @@
11 from django.views.decorators.http import require_POST
12 from django_ratelimit.decorators import ratelimit
13
14 from .forms import LoginForm
15 from .models import PersonalAccessToken, UserProfile
16
17 logger = logging.getLogger(__name__)
18
19 # Allowed SSH key type prefixes
20 _SSH_KEY_PREFIXES = ("ssh-ed25519", "ssh-rsa", "ecdsa-sha2-", "ssh-dss")
21
22
@@ -46,17 +50,57 @@
50 if not re.match(r"^[A-Za-z0-9+/=]+$", parts[1]):
51 return None, "Invalid SSH key data encoding."
52
53 return key, ""
54
55
56 def _verify_turnstile(token: str, remote_ip: str) -> bool:
57 """Verify a Cloudflare Turnstile response token. Returns True if valid."""
58 from constance import config
59
60 if not config.TURNSTILE_SECRET_KEY:
61 return False
62 try:
63 resp = requests.post(
64 "https://challenges.cloudflare.com/turnstile/v0/siteverify",
65 data={"secret": config.TURNSTILE_SECRET_KEY, "response": token, "remoteip": remote_ip},
66 timeout=5,
67 )
68 return resp.status_code == 200 and resp.json().get("success", False)
69 except Exception:
70 logger.exception("Turnstile verification failed")
71 return False
72
73
74 @ratelimit(key="ip", rate="10/m", block=True)
75 def login_view(request):
76 if request.user.is_authenticated:
77 return redirect("dashboard")
78
79 from constance import config
80
81 turnstile_enabled = config.TURNSTILE_ENABLED and config.TURNSTILE_SITE_KEY
82 turnstile_error = False
83
84 if request.method == "POST":
85 # Verify Turnstile before processing login
86 if turnstile_enabled:
87 turnstile_token = request.POST.get("cf-turnstile-response", "")
88 if not turnstile_token or not _verify_turnstile(turnstile_token, request.META.get("REMOTE_ADDR", "")):
89 turnstile_error = True
90 form = LoginForm()
91 return render(
92 request,
93 "accounts/login.html",
94 {
95 "form": form,
96 "turnstile_enabled": True,
97 "turnstile_site_key": config.TURNSTILE_SITE_KEY,
98 "turnstile_error": True,
99 },
100 )
101
102 form = LoginForm(request, data=request.POST)
103 if form.is_valid():
104 login(request, form.get_user())
105 next_url = request.GET.get("next", "")
106 if next_url and url_has_allowed_host_and_scheme(next_url, allowed_hosts={request.get_host()}):
@@ -63,11 +107,16 @@
107 return redirect(next_url)
108 return redirect("dashboard")
109 else:
110 form = LoginForm()
111
112 ctx = {"form": form}
113 if turnstile_enabled:
114 ctx["turnstile_enabled"] = True
115 ctx["turnstile_site_key"] = config.TURNSTILE_SITE_KEY
116 ctx["turnstile_error"] = turnstile_error
117 return render(request, "accounts/login.html", ctx)
118
119
120 @require_POST
121 def logout_view(request):
122 logout(request)
123
--- config/settings.py
+++ config/settings.py
@@ -140,10 +140,13 @@
140140
if not DEBUG:
141141
SESSION_COOKIE_SECURE = env_bool("SESSION_COOKIE_SECURE", True)
142142
CSRF_COOKIE_SECURE = env_bool("CSRF_COOKIE_SECURE", True)
143143
SECURE_SSL_REDIRECT = env_bool("SECURE_SSL_REDIRECT", True)
144144
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
145
+ SECURE_HSTS_SECONDS = 31536000 # 1 year
146
+ SECURE_HSTS_INCLUDE_SUBDOMAINS = True
147
+ SECURE_HSTS_PRELOAD = True
145148
146149
# --- i18n ---
147150
148151
LANGUAGE_CODE = "en-us"
149152
TIME_ZONE = "UTC"
@@ -243,17 +246,22 @@
243246
"GIT_SSH_KEY_DIR": ("/data/ssh-keys", "Directory for SSH key storage"),
244247
"GITHUB_OAUTH_CLIENT_ID": ("", "GitHub OAuth App Client ID"),
245248
"GITHUB_OAUTH_CLIENT_SECRET": ("", "GitHub OAuth App Client Secret"),
246249
"GITLAB_OAUTH_CLIENT_ID": ("", "GitLab OAuth App Client ID"),
247250
"GITLAB_OAUTH_CLIENT_SECRET": ("", "GitLab OAuth App Client Secret"),
251
+ # Cloudflare Turnstile (optional bot protection on login)
252
+ "TURNSTILE_ENABLED": (False, "Enable Cloudflare Turnstile on the login page"),
253
+ "TURNSTILE_SITE_KEY": ("", "Cloudflare Turnstile site key (public)"),
254
+ "TURNSTILE_SECRET_KEY": ("", "Cloudflare Turnstile secret key (server-side verification)"),
248255
}
249256
CONSTANCE_CONFIG_FIELDSETS = {
250257
"General": ("SITE_NAME",),
251258
"Fossil Storage": ("FOSSIL_DATA_DIR", "FOSSIL_STORE_IN_DB", "FOSSIL_S3_TRACKING", "FOSSIL_S3_BUCKET", "FOSSIL_BINARY_PATH"),
252259
"Git Sync": ("GIT_SYNC_MODE", "GIT_SYNC_SCHEDULE", "GIT_MIRROR_DIR", "GIT_SSH_KEY_DIR"),
253260
"GitHub OAuth": ("GITHUB_OAUTH_CLIENT_ID", "GITHUB_OAUTH_CLIENT_SECRET"),
254261
"GitLab OAuth": ("GITLAB_OAUTH_CLIENT_ID", "GITLAB_OAUTH_CLIENT_SECRET"),
262
+ "Cloudflare Turnstile": ("TURNSTILE_ENABLED", "TURNSTILE_SITE_KEY", "TURNSTILE_SECRET_KEY"),
255263
}
256264
257265
# --- Sentry ---
258266
259267
SENTRY_DSN = env_str("SENTRY_DSN")
260268
--- config/settings.py
+++ config/settings.py
@@ -140,10 +140,13 @@
140 if not DEBUG:
141 SESSION_COOKIE_SECURE = env_bool("SESSION_COOKIE_SECURE", True)
142 CSRF_COOKIE_SECURE = env_bool("CSRF_COOKIE_SECURE", True)
143 SECURE_SSL_REDIRECT = env_bool("SECURE_SSL_REDIRECT", True)
144 SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
 
 
 
145
146 # --- i18n ---
147
148 LANGUAGE_CODE = "en-us"
149 TIME_ZONE = "UTC"
@@ -243,17 +246,22 @@
243 "GIT_SSH_KEY_DIR": ("/data/ssh-keys", "Directory for SSH key storage"),
244 "GITHUB_OAUTH_CLIENT_ID": ("", "GitHub OAuth App Client ID"),
245 "GITHUB_OAUTH_CLIENT_SECRET": ("", "GitHub OAuth App Client Secret"),
246 "GITLAB_OAUTH_CLIENT_ID": ("", "GitLab OAuth App Client ID"),
247 "GITLAB_OAUTH_CLIENT_SECRET": ("", "GitLab OAuth App Client Secret"),
 
 
 
 
248 }
249 CONSTANCE_CONFIG_FIELDSETS = {
250 "General": ("SITE_NAME",),
251 "Fossil Storage": ("FOSSIL_DATA_DIR", "FOSSIL_STORE_IN_DB", "FOSSIL_S3_TRACKING", "FOSSIL_S3_BUCKET", "FOSSIL_BINARY_PATH"),
252 "Git Sync": ("GIT_SYNC_MODE", "GIT_SYNC_SCHEDULE", "GIT_MIRROR_DIR", "GIT_SSH_KEY_DIR"),
253 "GitHub OAuth": ("GITHUB_OAUTH_CLIENT_ID", "GITHUB_OAUTH_CLIENT_SECRET"),
254 "GitLab OAuth": ("GITLAB_OAUTH_CLIENT_ID", "GITLAB_OAUTH_CLIENT_SECRET"),
 
255 }
256
257 # --- Sentry ---
258
259 SENTRY_DSN = env_str("SENTRY_DSN")
260
--- config/settings.py
+++ config/settings.py
@@ -140,10 +140,13 @@
140 if not DEBUG:
141 SESSION_COOKIE_SECURE = env_bool("SESSION_COOKIE_SECURE", True)
142 CSRF_COOKIE_SECURE = env_bool("CSRF_COOKIE_SECURE", True)
143 SECURE_SSL_REDIRECT = env_bool("SECURE_SSL_REDIRECT", True)
144 SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
145 SECURE_HSTS_SECONDS = 31536000 # 1 year
146 SECURE_HSTS_INCLUDE_SUBDOMAINS = True
147 SECURE_HSTS_PRELOAD = True
148
149 # --- i18n ---
150
151 LANGUAGE_CODE = "en-us"
152 TIME_ZONE = "UTC"
@@ -243,17 +246,22 @@
246 "GIT_SSH_KEY_DIR": ("/data/ssh-keys", "Directory for SSH key storage"),
247 "GITHUB_OAUTH_CLIENT_ID": ("", "GitHub OAuth App Client ID"),
248 "GITHUB_OAUTH_CLIENT_SECRET": ("", "GitHub OAuth App Client Secret"),
249 "GITLAB_OAUTH_CLIENT_ID": ("", "GitLab OAuth App Client ID"),
250 "GITLAB_OAUTH_CLIENT_SECRET": ("", "GitLab OAuth App Client Secret"),
251 # Cloudflare Turnstile (optional bot protection on login)
252 "TURNSTILE_ENABLED": (False, "Enable Cloudflare Turnstile on the login page"),
253 "TURNSTILE_SITE_KEY": ("", "Cloudflare Turnstile site key (public)"),
254 "TURNSTILE_SECRET_KEY": ("", "Cloudflare Turnstile secret key (server-side verification)"),
255 }
256 CONSTANCE_CONFIG_FIELDSETS = {
257 "General": ("SITE_NAME",),
258 "Fossil Storage": ("FOSSIL_DATA_DIR", "FOSSIL_STORE_IN_DB", "FOSSIL_S3_TRACKING", "FOSSIL_S3_BUCKET", "FOSSIL_BINARY_PATH"),
259 "Git Sync": ("GIT_SYNC_MODE", "GIT_SYNC_SCHEDULE", "GIT_MIRROR_DIR", "GIT_SSH_KEY_DIR"),
260 "GitHub OAuth": ("GITHUB_OAUTH_CLIENT_ID", "GITHUB_OAUTH_CLIENT_SECRET"),
261 "GitLab OAuth": ("GITLAB_OAUTH_CLIENT_ID", "GITLAB_OAUTH_CLIENT_SECRET"),
262 "Cloudflare Turnstile": ("TURNSTILE_ENABLED", "TURNSTILE_SITE_KEY", "TURNSTILE_SECRET_KEY"),
263 }
264
265 # --- Sentry ---
266
267 SENTRY_DSN = env_str("SENTRY_DSN")
268
--- fossil/api_auth.py
+++ fossil/api_auth.py
@@ -1,16 +1,20 @@
11
"""API authentication for both project-scoped and user-scoped tokens.
22
33
Supports:
44
1. Project-scoped APIToken (tied to a FossilRepository) — permissions enforced
55
2. User-scoped PersonalAccessToken (tied to a Django User) — scopes enforced
6
-3. Session auth fallback (for browser testing)
6
+3. Session auth fallback — CSRF enforced on mutating requests
77
"""
88
99
from django.http import JsonResponse
10
+from django.middleware.csrf import CsrfViewMiddleware
1011
from django.utils import timezone
1112
13
+# Reusable CSRF checker for session-auth API callers.
14
+_csrf_middleware = CsrfViewMiddleware(lambda req: None)
15
+
1216
1317
def authenticate_request(request, repository=None, required_scope="read"):
1418
"""Authenticate an API request via Bearer token.
1519
1620
Args:
@@ -21,12 +25,19 @@
2125
Returns (user_or_none, token_or_none, error_response_or_none).
2226
If error_response is not None, return it immediately.
2327
"""
2428
auth = request.META.get("HTTP_AUTHORIZATION", "")
2529
if not auth.startswith("Bearer "):
26
- # Fall back to session auth — session users have full access
30
+ # Fall back to session auth — CSRF enforced on mutating requests
31
+ # because API views use @csrf_exempt for token-based callers.
2732
if request.user.is_authenticated:
33
+ if not request.user.is_active:
34
+ return None, None, JsonResponse({"error": "Account is deactivated"}, status=403)
35
+ if required_scope in ("write", "admin") and request.method not in ("GET", "HEAD", "OPTIONS"):
36
+ csrf_error = _csrf_middleware.process_view(request, None, (), {})
37
+ if csrf_error is not None:
38
+ return None, None, JsonResponse({"error": "CSRF validation failed"}, status=403)
2839
return request.user, None, None
2940
return None, None, JsonResponse({"error": "Authentication required"}, status=401)
3041
3142
raw_token = auth[7:]
3243
@@ -57,10 +68,12 @@
5768
if pat.expires_at and pat.expires_at < timezone.now():
5869
return None, None, JsonResponse({"error": "Token expired"}, status=401)
5970
# Enforce PAT scopes
6071
if not _token_has_scope(pat.scopes, required_scope):
6172
return None, None, JsonResponse({"error": f"Token lacks required scope: {required_scope}"}, status=403)
73
+ if not pat.user.is_active:
74
+ return None, None, JsonResponse({"error": "Account is deactivated"}, status=403)
6275
pat.last_used_at = timezone.now()
6376
pat.save(update_fields=["last_used_at"])
6477
return pat.user, pat, None
6578
except PersonalAccessToken.DoesNotExist:
6679
pass
6780
--- fossil/api_auth.py
+++ fossil/api_auth.py
@@ -1,16 +1,20 @@
1 """API authentication for both project-scoped and user-scoped tokens.
2
3 Supports:
4 1. Project-scoped APIToken (tied to a FossilRepository) — permissions enforced
5 2. User-scoped PersonalAccessToken (tied to a Django User) — scopes enforced
6 3. Session auth fallback (for browser testing)
7 """
8
9 from django.http import JsonResponse
 
10 from django.utils import timezone
11
 
 
 
12
13 def authenticate_request(request, repository=None, required_scope="read"):
14 """Authenticate an API request via Bearer token.
15
16 Args:
@@ -21,12 +25,19 @@
21 Returns (user_or_none, token_or_none, error_response_or_none).
22 If error_response is not None, return it immediately.
23 """
24 auth = request.META.get("HTTP_AUTHORIZATION", "")
25 if not auth.startswith("Bearer "):
26 # Fall back to session auth — session users have full access
 
27 if request.user.is_authenticated:
 
 
 
 
 
 
28 return request.user, None, None
29 return None, None, JsonResponse({"error": "Authentication required"}, status=401)
30
31 raw_token = auth[7:]
32
@@ -57,10 +68,12 @@
57 if pat.expires_at and pat.expires_at < timezone.now():
58 return None, None, JsonResponse({"error": "Token expired"}, status=401)
59 # Enforce PAT scopes
60 if not _token_has_scope(pat.scopes, required_scope):
61 return None, None, JsonResponse({"error": f"Token lacks required scope: {required_scope}"}, status=403)
 
 
62 pat.last_used_at = timezone.now()
63 pat.save(update_fields=["last_used_at"])
64 return pat.user, pat, None
65 except PersonalAccessToken.DoesNotExist:
66 pass
67
--- fossil/api_auth.py
+++ fossil/api_auth.py
@@ -1,16 +1,20 @@
1 """API authentication for both project-scoped and user-scoped tokens.
2
3 Supports:
4 1. Project-scoped APIToken (tied to a FossilRepository) — permissions enforced
5 2. User-scoped PersonalAccessToken (tied to a Django User) — scopes enforced
6 3. Session auth fallback — CSRF enforced on mutating requests
7 """
8
9 from django.http import JsonResponse
10 from django.middleware.csrf import CsrfViewMiddleware
11 from django.utils import timezone
12
13 # Reusable CSRF checker for session-auth API callers.
14 _csrf_middleware = CsrfViewMiddleware(lambda req: None)
15
16
17 def authenticate_request(request, repository=None, required_scope="read"):
18 """Authenticate an API request via Bearer token.
19
20 Args:
@@ -21,12 +25,19 @@
25 Returns (user_or_none, token_or_none, error_response_or_none).
26 If error_response is not None, return it immediately.
27 """
28 auth = request.META.get("HTTP_AUTHORIZATION", "")
29 if not auth.startswith("Bearer "):
30 # Fall back to session auth — CSRF enforced on mutating requests
31 # because API views use @csrf_exempt for token-based callers.
32 if request.user.is_authenticated:
33 if not request.user.is_active:
34 return None, None, JsonResponse({"error": "Account is deactivated"}, status=403)
35 if required_scope in ("write", "admin") and request.method not in ("GET", "HEAD", "OPTIONS"):
36 csrf_error = _csrf_middleware.process_view(request, None, (), {})
37 if csrf_error is not None:
38 return None, None, JsonResponse({"error": "CSRF validation failed"}, status=403)
39 return request.user, None, None
40 return None, None, JsonResponse({"error": "Authentication required"}, status=401)
41
42 raw_token = auth[7:]
43
@@ -57,10 +68,12 @@
68 if pat.expires_at and pat.expires_at < timezone.now():
69 return None, None, JsonResponse({"error": "Token expired"}, status=401)
70 # Enforce PAT scopes
71 if not _token_has_scope(pat.scopes, required_scope):
72 return None, None, JsonResponse({"error": f"Token lacks required scope: {required_scope}"}, status=403)
73 if not pat.user.is_active:
74 return None, None, JsonResponse({"error": "Account is deactivated"}, status=403)
75 pat.last_used_at = timezone.now()
76 pat.save(update_fields=["last_used_at"])
77 return pat.user, pat, None
78 except PersonalAccessToken.DoesNotExist:
79 pass
80
--- fossil/api_views.py
+++ fossil/api_views.py
@@ -716,10 +716,37 @@
716716
workspace = AgentWorkspace.objects.filter(repository=repo, name=workspace_name).first()
717717
if workspace is None:
718718
return None
719719
return workspace
720720
721
+
722
+def _check_workspace_ownership(workspace, request, token, data=None):
723
+ """Verify the caller owns this workspace.
724
+
725
+ Token-based callers (agents) must provide an agent_id matching the workspace.
726
+ Session-auth users (human operators) are allowed through as human oversight.
727
+ Returns an error JsonResponse if denied, or None if allowed.
728
+ """
729
+ if token is None:
730
+ # Session-auth user — human oversight, allowed
731
+ return None
732
+ if not workspace.agent_id:
733
+ # Workspace has no agent_id — any writer can operate
734
+ return None
735
+ # Token-based caller must supply matching agent_id
736
+ if data is None:
737
+ try:
738
+ data = json.loads(request.body) if request.body else {}
739
+ except (json.JSONDecodeError, ValueError):
740
+ data = {}
741
+ caller_agent_id = (data.get("agent_id") or "").strip()
742
+ if not caller_agent_id or caller_agent_id != workspace.agent_id:
743
+ return JsonResponse(
744
+ {"error": "Only the owning agent can modify this workspace"},
745
+ status=403,
746
+ )
747
+
721748
722749
@csrf_exempt
723750
def api_workspace_list(request, slug):
724751
"""List agent workspaces for a repository.
725752
@@ -936,10 +963,14 @@
936963
937964
try:
938965
data = json.loads(request.body)
939966
except (json.JSONDecodeError, ValueError):
940967
return JsonResponse({"error": "Invalid JSON body"}, status=400)
968
+
969
+ ownership_err = _check_workspace_ownership(workspace, request, token, data)
970
+ if ownership_err is not None:
971
+ return ownership_err
941972
942973
message = (data.get("message") or "").strip()
943974
if not message:
944975
return JsonResponse({"error": "Commit message is required"}, status=400)
945976
@@ -1033,10 +1064,14 @@
10331064
10341065
try:
10351066
data = json.loads(request.body) if request.body else {}
10361067
except (json.JSONDecodeError, ValueError):
10371068
data = {}
1069
+
1070
+ ownership_err = _check_workspace_ownership(workspace, request, token, data)
1071
+ if ownership_err is not None:
1072
+ return ownership_err
10381073
10391074
target_branch = (data.get("target_branch") or "trunk").strip()
10401075
10411076
# --- Branch protection enforcement ---
10421077
is_admin = user is not None and can_admin_project(user, project)
@@ -1173,10 +1208,14 @@
11731208
if workspace is None:
11741209
return JsonResponse({"error": "Workspace not found"}, status=404)
11751210
11761211
if workspace.status != "active":
11771212
return JsonResponse({"error": f"Workspace is already {workspace.status}"}, status=409)
1213
+
1214
+ ownership_err = _check_workspace_ownership(workspace, request, token)
1215
+ if ownership_err is not None:
1216
+ return ownership_err
11781217
11791218
from fossil.cli import FossilCLI
11801219
11811220
cli = FossilCLI()
11821221
checkout_dir = workspace.checkout_path
@@ -1896,19 +1935,25 @@
18961935
return JsonResponse({"error": "Review not found"}, status=404)
18971936
18981937
if review.status == "merged":
18991938
return JsonResponse({"error": "Review is already merged"}, status=409)
19001939
1901
- # Prevent self-approval: token-based callers (agents) cannot approve their own review.
1940
+ # Prevent self-approval: token-based callers (agents) must identify themselves
1941
+ # and cannot approve a review created by the same agent.
19021942
# Session-auth users (human reviewers) are allowed since they represent human oversight.
19031943
if token is not None and review.agent_id:
19041944
try:
19051945
data = json.loads(request.body) if request.body else {}
19061946
except (json.JSONDecodeError, ValueError):
19071947
data = {}
19081948
approver_agent_id = (data.get("agent_id") or "").strip()
1909
- if approver_agent_id and approver_agent_id == review.agent_id:
1949
+ if not approver_agent_id:
1950
+ return JsonResponse(
1951
+ {"error": "agent_id is required for token-based review approval"},
1952
+ status=400,
1953
+ )
1954
+ if approver_agent_id == review.agent_id:
19101955
return JsonResponse({"error": "Cannot approve your own review"}, status=403)
19111956
19121957
review.status = "approved"
19131958
review.save(update_fields=["status", "updated_at", "version"])
19141959
19151960
--- fossil/api_views.py
+++ fossil/api_views.py
@@ -716,10 +716,37 @@
716 workspace = AgentWorkspace.objects.filter(repository=repo, name=workspace_name).first()
717 if workspace is None:
718 return None
719 return workspace
720
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
721
722 @csrf_exempt
723 def api_workspace_list(request, slug):
724 """List agent workspaces for a repository.
725
@@ -936,10 +963,14 @@
936
937 try:
938 data = json.loads(request.body)
939 except (json.JSONDecodeError, ValueError):
940 return JsonResponse({"error": "Invalid JSON body"}, status=400)
 
 
 
 
941
942 message = (data.get("message") or "").strip()
943 if not message:
944 return JsonResponse({"error": "Commit message is required"}, status=400)
945
@@ -1033,10 +1064,14 @@
1033
1034 try:
1035 data = json.loads(request.body) if request.body else {}
1036 except (json.JSONDecodeError, ValueError):
1037 data = {}
 
 
 
 
1038
1039 target_branch = (data.get("target_branch") or "trunk").strip()
1040
1041 # --- Branch protection enforcement ---
1042 is_admin = user is not None and can_admin_project(user, project)
@@ -1173,10 +1208,14 @@
1173 if workspace is None:
1174 return JsonResponse({"error": "Workspace not found"}, status=404)
1175
1176 if workspace.status != "active":
1177 return JsonResponse({"error": f"Workspace is already {workspace.status}"}, status=409)
 
 
 
 
1178
1179 from fossil.cli import FossilCLI
1180
1181 cli = FossilCLI()
1182 checkout_dir = workspace.checkout_path
@@ -1896,19 +1935,25 @@
1896 return JsonResponse({"error": "Review not found"}, status=404)
1897
1898 if review.status == "merged":
1899 return JsonResponse({"error": "Review is already merged"}, status=409)
1900
1901 # Prevent self-approval: token-based callers (agents) cannot approve their own review.
 
1902 # Session-auth users (human reviewers) are allowed since they represent human oversight.
1903 if token is not None and review.agent_id:
1904 try:
1905 data = json.loads(request.body) if request.body else {}
1906 except (json.JSONDecodeError, ValueError):
1907 data = {}
1908 approver_agent_id = (data.get("agent_id") or "").strip()
1909 if approver_agent_id and approver_agent_id == review.agent_id:
 
 
 
 
 
1910 return JsonResponse({"error": "Cannot approve your own review"}, status=403)
1911
1912 review.status = "approved"
1913 review.save(update_fields=["status", "updated_at", "version"])
1914
1915
--- fossil/api_views.py
+++ fossil/api_views.py
@@ -716,10 +716,37 @@
716 workspace = AgentWorkspace.objects.filter(repository=repo, name=workspace_name).first()
717 if workspace is None:
718 return None
719 return workspace
720
721
722 def _check_workspace_ownership(workspace, request, token, data=None):
723 """Verify the caller owns this workspace.
724
725 Token-based callers (agents) must provide an agent_id matching the workspace.
726 Session-auth users (human operators) are allowed through as human oversight.
727 Returns an error JsonResponse if denied, or None if allowed.
728 """
729 if token is None:
730 # Session-auth user — human oversight, allowed
731 return None
732 if not workspace.agent_id:
733 # Workspace has no agent_id — any writer can operate
734 return None
735 # Token-based caller must supply matching agent_id
736 if data is None:
737 try:
738 data = json.loads(request.body) if request.body else {}
739 except (json.JSONDecodeError, ValueError):
740 data = {}
741 caller_agent_id = (data.get("agent_id") or "").strip()
742 if not caller_agent_id or caller_agent_id != workspace.agent_id:
743 return JsonResponse(
744 {"error": "Only the owning agent can modify this workspace"},
745 status=403,
746 )
747
748
749 @csrf_exempt
750 def api_workspace_list(request, slug):
751 """List agent workspaces for a repository.
752
@@ -936,10 +963,14 @@
963
964 try:
965 data = json.loads(request.body)
966 except (json.JSONDecodeError, ValueError):
967 return JsonResponse({"error": "Invalid JSON body"}, status=400)
968
969 ownership_err = _check_workspace_ownership(workspace, request, token, data)
970 if ownership_err is not None:
971 return ownership_err
972
973 message = (data.get("message") or "").strip()
974 if not message:
975 return JsonResponse({"error": "Commit message is required"}, status=400)
976
@@ -1033,10 +1064,14 @@
1064
1065 try:
1066 data = json.loads(request.body) if request.body else {}
1067 except (json.JSONDecodeError, ValueError):
1068 data = {}
1069
1070 ownership_err = _check_workspace_ownership(workspace, request, token, data)
1071 if ownership_err is not None:
1072 return ownership_err
1073
1074 target_branch = (data.get("target_branch") or "trunk").strip()
1075
1076 # --- Branch protection enforcement ---
1077 is_admin = user is not None and can_admin_project(user, project)
@@ -1173,10 +1208,14 @@
1208 if workspace is None:
1209 return JsonResponse({"error": "Workspace not found"}, status=404)
1210
1211 if workspace.status != "active":
1212 return JsonResponse({"error": f"Workspace is already {workspace.status}"}, status=409)
1213
1214 ownership_err = _check_workspace_ownership(workspace, request, token)
1215 if ownership_err is not None:
1216 return ownership_err
1217
1218 from fossil.cli import FossilCLI
1219
1220 cli = FossilCLI()
1221 checkout_dir = workspace.checkout_path
@@ -1896,19 +1935,25 @@
1935 return JsonResponse({"error": "Review not found"}, status=404)
1936
1937 if review.status == "merged":
1938 return JsonResponse({"error": "Review is already merged"}, status=409)
1939
1940 # Prevent self-approval: token-based callers (agents) must identify themselves
1941 # and cannot approve a review created by the same agent.
1942 # Session-auth users (human reviewers) are allowed since they represent human oversight.
1943 if token is not None and review.agent_id:
1944 try:
1945 data = json.loads(request.body) if request.body else {}
1946 except (json.JSONDecodeError, ValueError):
1947 data = {}
1948 approver_agent_id = (data.get("agent_id") or "").strip()
1949 if not approver_agent_id:
1950 return JsonResponse(
1951 {"error": "agent_id is required for token-based review approval"},
1952 status=400,
1953 )
1954 if approver_agent_id == review.agent_id:
1955 return JsonResponse({"error": "Cannot approve your own review"}, status=403)
1956
1957 review.status = "approved"
1958 review.save(update_fields=["status", "updated_at", "version"])
1959
1960
+24 -3
--- fossil/cli.py
+++ fossil/cli.py
@@ -1,8 +1,9 @@
11
"""Thin wrapper around the fossil binary for write operations."""
22
33
import logging
4
+import os
45
import subprocess
56
from pathlib import Path
67
78
logger = logging.getLogger(__name__)
89
@@ -319,26 +320,46 @@
319320
mirror_dir.mkdir(parents=True, exist_ok=True)
320321
cmd = [self.binary, "git", "export", str(mirror_dir), "-R", str(repo_path)]
321322
322323
env = dict(self._env)
323324
325
+ temp_paths = []
324326
if autopush_url:
325327
cmd.extend(["--autopush", autopush_url])
326328
if auth_token:
327329
env["GIT_TERMINAL_PROMPT"] = "0"
328
- env["GIT_CONFIG_COUNT"] = "1"
329
- env["GIT_CONFIG_KEY_0"] = "credential.helper"
330
- env["GIT_CONFIG_VALUE_0"] = f"!echo password={auth_token}"
330
+ # Use a temporary askpass script instead of a shell credential
331
+ # helper to avoid command injection via token metacharacters.
332
+ # The token is stored in a separate file so it never appears
333
+ # in shell syntax.
334
+ import stat
335
+ import tempfile
336
+
337
+ token_fd, token_path = tempfile.mkstemp(suffix=".tok")
338
+ with os.fdopen(token_fd, "w") as f:
339
+ f.write(auth_token)
340
+ os.chmod(token_path, stat.S_IRUSR)
341
+ temp_paths.append(token_path)
342
+
343
+ askpass_fd, askpass_path = tempfile.mkstemp(suffix=".sh")
344
+ with os.fdopen(askpass_fd, "w") as f:
345
+ f.write(f"#!/bin/sh\ncat '{token_path}'\n")
346
+ os.chmod(askpass_path, stat.S_IRWXU)
347
+ temp_paths.append(askpass_path)
348
+ env["GIT_ASKPASS"] = askpass_path
331349
332350
try:
333351
result = subprocess.run(cmd, capture_output=True, text=True, timeout=300, env=env)
334352
output = (result.stdout + result.stderr).strip()
335353
if auth_token:
336354
output = output.replace(auth_token, "[REDACTED]")
337355
return {"success": result.returncode == 0, "message": output}
338356
except subprocess.TimeoutExpired:
339357
return {"success": False, "message": "Export timed out after 5 minutes"}
358
+ finally:
359
+ for p in temp_paths:
360
+ os.unlink(p)
340361
341362
def generate_ssh_key(self, key_path: Path, comment: str = "fossilrepo") -> dict:
342363
"""Generate an SSH key pair for Git authentication.
343364
344365
Returns {success, public_key, fingerprint}.
345366
--- fossil/cli.py
+++ fossil/cli.py
@@ -1,8 +1,9 @@
1 """Thin wrapper around the fossil binary for write operations."""
2
3 import logging
 
4 import subprocess
5 from pathlib import Path
6
7 logger = logging.getLogger(__name__)
8
@@ -319,26 +320,46 @@
319 mirror_dir.mkdir(parents=True, exist_ok=True)
320 cmd = [self.binary, "git", "export", str(mirror_dir), "-R", str(repo_path)]
321
322 env = dict(self._env)
323
 
324 if autopush_url:
325 cmd.extend(["--autopush", autopush_url])
326 if auth_token:
327 env["GIT_TERMINAL_PROMPT"] = "0"
328 env["GIT_CONFIG_COUNT"] = "1"
329 env["GIT_CONFIG_KEY_0"] = "credential.helper"
330 env["GIT_CONFIG_VALUE_0"] = f"!echo password={auth_token}"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
331
332 try:
333 result = subprocess.run(cmd, capture_output=True, text=True, timeout=300, env=env)
334 output = (result.stdout + result.stderr).strip()
335 if auth_token:
336 output = output.replace(auth_token, "[REDACTED]")
337 return {"success": result.returncode == 0, "message": output}
338 except subprocess.TimeoutExpired:
339 return {"success": False, "message": "Export timed out after 5 minutes"}
 
 
 
340
341 def generate_ssh_key(self, key_path: Path, comment: str = "fossilrepo") -> dict:
342 """Generate an SSH key pair for Git authentication.
343
344 Returns {success, public_key, fingerprint}.
345
--- fossil/cli.py
+++ fossil/cli.py
@@ -1,8 +1,9 @@
1 """Thin wrapper around the fossil binary for write operations."""
2
3 import logging
4 import os
5 import subprocess
6 from pathlib import Path
7
8 logger = logging.getLogger(__name__)
9
@@ -319,26 +320,46 @@
320 mirror_dir.mkdir(parents=True, exist_ok=True)
321 cmd = [self.binary, "git", "export", str(mirror_dir), "-R", str(repo_path)]
322
323 env = dict(self._env)
324
325 temp_paths = []
326 if autopush_url:
327 cmd.extend(["--autopush", autopush_url])
328 if auth_token:
329 env["GIT_TERMINAL_PROMPT"] = "0"
330 # Use a temporary askpass script instead of a shell credential
331 # helper to avoid command injection via token metacharacters.
332 # The token is stored in a separate file so it never appears
333 # in shell syntax.
334 import stat
335 import tempfile
336
337 token_fd, token_path = tempfile.mkstemp(suffix=".tok")
338 with os.fdopen(token_fd, "w") as f:
339 f.write(auth_token)
340 os.chmod(token_path, stat.S_IRUSR)
341 temp_paths.append(token_path)
342
343 askpass_fd, askpass_path = tempfile.mkstemp(suffix=".sh")
344 with os.fdopen(askpass_fd, "w") as f:
345 f.write(f"#!/bin/sh\ncat '{token_path}'\n")
346 os.chmod(askpass_path, stat.S_IRWXU)
347 temp_paths.append(askpass_path)
348 env["GIT_ASKPASS"] = askpass_path
349
350 try:
351 result = subprocess.run(cmd, capture_output=True, text=True, timeout=300, env=env)
352 output = (result.stdout + result.stderr).strip()
353 if auth_token:
354 output = output.replace(auth_token, "[REDACTED]")
355 return {"success": result.returncode == 0, "message": output}
356 except subprocess.TimeoutExpired:
357 return {"success": False, "message": "Export timed out after 5 minutes"}
358 finally:
359 for p in temp_paths:
360 os.unlink(p)
361
362 def generate_ssh_key(self, key_path: Path, comment: str = "fossilrepo") -> dict:
363 """Generate an SSH key pair for Git authentication.
364
365 Returns {success, public_key, fingerprint}.
366
+2 -2
--- fossil/urls.py
+++ fossil/urls.py
@@ -98,12 +98,12 @@
9898
path("sync/git/<int:mirror_id>/edit/", views.git_mirror_config, name="git_mirror_edit"),
9999
path("sync/git/<int:mirror_id>/delete/", views.git_mirror_delete, name="git_mirror_delete"),
100100
path("sync/git/<int:mirror_id>/run/", views.git_mirror_run, name="git_mirror_run"),
101101
path("sync/git/connect/github/", views.oauth_github_start, name="oauth_github"),
102102
path("sync/git/connect/gitlab/", views.oauth_gitlab_start, name="oauth_gitlab"),
103
- path("sync/git/callback/github/", views.oauth_github_callback, name="oauth_github_callback"),
104
- path("sync/git/callback/gitlab/", views.oauth_gitlab_callback, name="oauth_gitlab_callback"),
103
+ # Per-project OAuth callbacks removed — global /oauth/callback/ handlers
104
+ # enforce nonce/state validation. Keeping these would bypass that check.
105105
path("code/raw/<path:filepath>", views.code_raw, name="code_raw"),
106106
path("code/blame/<path:filepath>", views.code_blame, name="code_blame"),
107107
path("code/history/<path:filepath>", views.file_history, name="file_history"),
108108
path("watch/", views.toggle_watch, name="toggle_watch"),
109109
path("timeline/rss/", views.timeline_rss, name="timeline_rss"),
110110
--- fossil/urls.py
+++ fossil/urls.py
@@ -98,12 +98,12 @@
98 path("sync/git/<int:mirror_id>/edit/", views.git_mirror_config, name="git_mirror_edit"),
99 path("sync/git/<int:mirror_id>/delete/", views.git_mirror_delete, name="git_mirror_delete"),
100 path("sync/git/<int:mirror_id>/run/", views.git_mirror_run, name="git_mirror_run"),
101 path("sync/git/connect/github/", views.oauth_github_start, name="oauth_github"),
102 path("sync/git/connect/gitlab/", views.oauth_gitlab_start, name="oauth_gitlab"),
103 path("sync/git/callback/github/", views.oauth_github_callback, name="oauth_github_callback"),
104 path("sync/git/callback/gitlab/", views.oauth_gitlab_callback, name="oauth_gitlab_callback"),
105 path("code/raw/<path:filepath>", views.code_raw, name="code_raw"),
106 path("code/blame/<path:filepath>", views.code_blame, name="code_blame"),
107 path("code/history/<path:filepath>", views.file_history, name="file_history"),
108 path("watch/", views.toggle_watch, name="toggle_watch"),
109 path("timeline/rss/", views.timeline_rss, name="timeline_rss"),
110
--- fossil/urls.py
+++ fossil/urls.py
@@ -98,12 +98,12 @@
98 path("sync/git/<int:mirror_id>/edit/", views.git_mirror_config, name="git_mirror_edit"),
99 path("sync/git/<int:mirror_id>/delete/", views.git_mirror_delete, name="git_mirror_delete"),
100 path("sync/git/<int:mirror_id>/run/", views.git_mirror_run, name="git_mirror_run"),
101 path("sync/git/connect/github/", views.oauth_github_start, name="oauth_github"),
102 path("sync/git/connect/gitlab/", views.oauth_gitlab_start, name="oauth_gitlab"),
103 # Per-project OAuth callbacks removed — global /oauth/callback/ handlers
104 # enforce nonce/state validation. Keeping these would bypass that check.
105 path("code/raw/<path:filepath>", views.code_raw, name="code_raw"),
106 path("code/blame/<path:filepath>", views.code_blame, name="code_blame"),
107 path("code/history/<path:filepath>", views.file_history, name="file_history"),
108 path("watch/", views.toggle_watch, name="toggle_watch"),
109 path("timeline/rss/", views.timeline_rss, name="timeline_rss"),
110
--- fossil/views.py
+++ fossil/views.py
@@ -3350,10 +3350,16 @@
33503350
return JsonResponse({"error": f"state must be one of: {', '.join(StatusCheck.State.values)}"}, status=400)
33513351
if len(context) > 200:
33523352
return JsonResponse({"error": "context must be 200 characters or fewer"}, status=400)
33533353
if len(description) > 500:
33543354
return JsonResponse({"error": "description must be 500 characters or fewer"}, status=400)
3355
+ if target_url:
3356
+ from urllib.parse import urlparse
3357
+
3358
+ parsed = urlparse(target_url)
3359
+ if parsed.scheme not in ("http", "https"):
3360
+ return JsonResponse({"error": "target_url must use http or https scheme"}, status=400)
33553361
33563362
check, created = StatusCheck.objects.update_or_create(
33573363
repository=fossil_repo,
33583364
checkin_uuid=checkin_uuid,
33593365
context=context,
33603366
--- fossil/views.py
+++ fossil/views.py
@@ -3350,10 +3350,16 @@
3350 return JsonResponse({"error": f"state must be one of: {', '.join(StatusCheck.State.values)}"}, status=400)
3351 if len(context) > 200:
3352 return JsonResponse({"error": "context must be 200 characters or fewer"}, status=400)
3353 if len(description) > 500:
3354 return JsonResponse({"error": "description must be 500 characters or fewer"}, status=400)
 
 
 
 
 
 
3355
3356 check, created = StatusCheck.objects.update_or_create(
3357 repository=fossil_repo,
3358 checkin_uuid=checkin_uuid,
3359 context=context,
3360
--- fossil/views.py
+++ fossil/views.py
@@ -3350,10 +3350,16 @@
3350 return JsonResponse({"error": f"state must be one of: {', '.join(StatusCheck.State.values)}"}, status=400)
3351 if len(context) > 200:
3352 return JsonResponse({"error": "context must be 200 characters or fewer"}, status=400)
3353 if len(description) > 500:
3354 return JsonResponse({"error": "description must be 500 characters or fewer"}, status=400)
3355 if target_url:
3356 from urllib.parse import urlparse
3357
3358 parsed = urlparse(target_url)
3359 if parsed.scheme not in ("http", "https"):
3360 return JsonResponse({"error": "target_url must use http or https scheme"}, status=400)
3361
3362 check, created = StatusCheck.objects.update_or_create(
3363 repository=fossil_repo,
3364 checkin_uuid=checkin_uuid,
3365 context=context,
3366
--- projects/access.py
+++ projects/access.py
@@ -19,11 +19,11 @@
1919
def get_user_role(user, project: Project) -> str | None:
2020
"""Get the highest role a user has on a project via their teams.
2121
2222
Returns "admin", "write", "read", or None.
2323
"""
24
- if not user or not user.is_authenticated:
24
+ if not user or not user.is_authenticated or not user.is_active:
2525
return None
2626
2727
if user.is_superuser:
2828
return "admin"
2929
@@ -51,32 +51,32 @@
5151
- Private: team members only (or superuser)
5252
"""
5353
if project.visibility == "public":
5454
return True
5555
if project.visibility == "internal":
56
- return user and user.is_authenticated
56
+ return user and user.is_authenticated and user.is_active
5757
# Private
58
- if not user or not user.is_authenticated:
58
+ if not user or not user.is_authenticated or not user.is_active:
5959
return False
6060
if user.is_superuser:
6161
return True
6262
return get_user_role(user, project) is not None
6363
6464
6565
def can_write_project(user, project: Project) -> bool:
6666
"""Can this user write to the project (create tickets, edit wiki, etc.)?"""
67
- if not user or not user.is_authenticated:
67
+ if not user or not user.is_authenticated or not user.is_active:
6868
return False
6969
if user.is_superuser:
7070
return True
7171
role = get_user_role(user, project)
7272
return role in ("write", "admin")
7373
7474
7575
def can_admin_project(user, project: Project) -> bool:
7676
"""Can this user administer the project (manage teams, settings, sync)?"""
77
- if not user or not user.is_authenticated:
77
+ if not user or not user.is_authenticated or not user.is_active:
7878
return False
7979
if user.is_superuser:
8080
return True
8181
return get_user_role(user, project) == "admin"
8282
8383
--- projects/access.py
+++ projects/access.py
@@ -19,11 +19,11 @@
19 def get_user_role(user, project: Project) -> str | None:
20 """Get the highest role a user has on a project via their teams.
21
22 Returns "admin", "write", "read", or None.
23 """
24 if not user or not user.is_authenticated:
25 return None
26
27 if user.is_superuser:
28 return "admin"
29
@@ -51,32 +51,32 @@
51 - Private: team members only (or superuser)
52 """
53 if project.visibility == "public":
54 return True
55 if project.visibility == "internal":
56 return user and user.is_authenticated
57 # Private
58 if not user or not user.is_authenticated:
59 return False
60 if user.is_superuser:
61 return True
62 return get_user_role(user, project) is not None
63
64
65 def can_write_project(user, project: Project) -> bool:
66 """Can this user write to the project (create tickets, edit wiki, etc.)?"""
67 if not user or not user.is_authenticated:
68 return False
69 if user.is_superuser:
70 return True
71 role = get_user_role(user, project)
72 return role in ("write", "admin")
73
74
75 def can_admin_project(user, project: Project) -> bool:
76 """Can this user administer the project (manage teams, settings, sync)?"""
77 if not user or not user.is_authenticated:
78 return False
79 if user.is_superuser:
80 return True
81 return get_user_role(user, project) == "admin"
82
83
--- projects/access.py
+++ projects/access.py
@@ -19,11 +19,11 @@
19 def get_user_role(user, project: Project) -> str | None:
20 """Get the highest role a user has on a project via their teams.
21
22 Returns "admin", "write", "read", or None.
23 """
24 if not user or not user.is_authenticated or not user.is_active:
25 return None
26
27 if user.is_superuser:
28 return "admin"
29
@@ -51,32 +51,32 @@
51 - Private: team members only (or superuser)
52 """
53 if project.visibility == "public":
54 return True
55 if project.visibility == "internal":
56 return user and user.is_authenticated and user.is_active
57 # Private
58 if not user or not user.is_authenticated or not user.is_active:
59 return False
60 if user.is_superuser:
61 return True
62 return get_user_role(user, project) is not None
63
64
65 def can_write_project(user, project: Project) -> bool:
66 """Can this user write to the project (create tickets, edit wiki, etc.)?"""
67 if not user or not user.is_authenticated or not user.is_active:
68 return False
69 if user.is_superuser:
70 return True
71 role = get_user_role(user, project)
72 return role in ("write", "admin")
73
74
75 def can_admin_project(user, project: Project) -> bool:
76 """Can this user administer the project (manage teams, settings, sync)?"""
77 if not user or not user.is_authenticated or not user.is_active:
78 return False
79 if user.is_superuser:
80 return True
81 return get_user_role(user, project) == "admin"
82
83
--- templates/accounts/login.html
+++ templates/accounts/login.html
@@ -1,8 +1,14 @@
11
{% extends "base.html" %}
22
{% load static %}
33
{% block title %}Sign In — Fossilrepo{% endblock %}
4
+
5
+{% block extra_head %}
6
+{% if turnstile_enabled %}
7
+<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
8
+{% endif %}
9
+{% endblock %}
410
511
{% block content %}
612
<div class="flex min-h-[80vh] items-center justify-center">
713
<div class="w-full max-w-sm space-y-8">
814
<div class="flex flex-col items-center">
@@ -14,10 +20,16 @@
1420
{% if form.errors %}
1521
<div class="rounded-md bg-red-900/50 border border-red-700 p-4">
1622
<p class="text-sm text-red-300">Invalid username or password.</p>
1723
</div>
1824
{% endif %}
25
+
26
+ {% if turnstile_error %}
27
+ <div class="rounded-md bg-red-900/50 border border-red-700 p-4">
28
+ <p class="text-sm text-red-300">Bot verification failed. Please try again.</p>
29
+ </div>
30
+ {% endif %}
1931
2032
<form method="post" class="space-y-6">
2133
{% csrf_token %}
2234
<div>
2335
<label for="id_username" class="block text-sm font-medium text-gray-300">Username</label>
@@ -25,13 +37,16 @@
2537
</div>
2638
<div>
2739
<label for="id_password" class="block text-sm font-medium text-gray-300">Password</label>
2840
<div class="mt-1">{{ form.password }}</div>
2941
</div>
42
+ {% if turnstile_enabled %}
43
+ <div class="cf-turnstile" data-sitekey="{{ turnstile_site_key }}" data-theme="dark"></div>
44
+ {% endif %}
3045
<button type="submit"
3146
class="w-full rounded-md bg-brand px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-brand-hover focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand">
3247
Sign in
3348
</button>
3449
</form>
3550
</div>
3651
</div>
3752
{% endblock %}
3853
--- templates/accounts/login.html
+++ templates/accounts/login.html
@@ -1,8 +1,14 @@
1 {% extends "base.html" %}
2 {% load static %}
3 {% block title %}Sign In — Fossilrepo{% endblock %}
 
 
 
 
 
 
4
5 {% block content %}
6 <div class="flex min-h-[80vh] items-center justify-center">
7 <div class="w-full max-w-sm space-y-8">
8 <div class="flex flex-col items-center">
@@ -14,10 +20,16 @@
14 {% if form.errors %}
15 <div class="rounded-md bg-red-900/50 border border-red-700 p-4">
16 <p class="text-sm text-red-300">Invalid username or password.</p>
17 </div>
18 {% endif %}
 
 
 
 
 
 
19
20 <form method="post" class="space-y-6">
21 {% csrf_token %}
22 <div>
23 <label for="id_username" class="block text-sm font-medium text-gray-300">Username</label>
@@ -25,13 +37,16 @@
25 </div>
26 <div>
27 <label for="id_password" class="block text-sm font-medium text-gray-300">Password</label>
28 <div class="mt-1">{{ form.password }}</div>
29 </div>
 
 
 
30 <button type="submit"
31 class="w-full rounded-md bg-brand px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-brand-hover focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand">
32 Sign in
33 </button>
34 </form>
35 </div>
36 </div>
37 {% endblock %}
38
--- templates/accounts/login.html
+++ templates/accounts/login.html
@@ -1,8 +1,14 @@
1 {% extends "base.html" %}
2 {% load static %}
3 {% block title %}Sign In — Fossilrepo{% endblock %}
4
5 {% block extra_head %}
6 {% if turnstile_enabled %}
7 <script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>
8 {% endif %}
9 {% endblock %}
10
11 {% block content %}
12 <div class="flex min-h-[80vh] items-center justify-center">
13 <div class="w-full max-w-sm space-y-8">
14 <div class="flex flex-col items-center">
@@ -14,10 +20,16 @@
20 {% if form.errors %}
21 <div class="rounded-md bg-red-900/50 border border-red-700 p-4">
22 <p class="text-sm text-red-300">Invalid username or password.</p>
23 </div>
24 {% endif %}
25
26 {% if turnstile_error %}
27 <div class="rounded-md bg-red-900/50 border border-red-700 p-4">
28 <p class="text-sm text-red-300">Bot verification failed. Please try again.</p>
29 </div>
30 {% endif %}
31
32 <form method="post" class="space-y-6">
33 {% csrf_token %}
34 <div>
35 <label for="id_username" class="block text-sm font-medium text-gray-300">Username</label>
@@ -25,13 +37,16 @@
37 </div>
38 <div>
39 <label for="id_password" class="block text-sm font-medium text-gray-300">Password</label>
40 <div class="mt-1">{{ form.password }}</div>
41 </div>
42 {% if turnstile_enabled %}
43 <div class="cf-turnstile" data-sitekey="{{ turnstile_site_key }}" data-theme="dark"></div>
44 {% endif %}
45 <button type="submit"
46 class="w-full rounded-md bg-brand px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-brand-hover focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand">
47 Sign in
48 </button>
49 </form>
50 </div>
51 </div>
52 {% endblock %}
53
--- templates/fossil/checkin_detail.html
+++ templates/fossil/checkin_detail.html
@@ -78,11 +78,11 @@
7878
<span class="text-xs font-medium text-gray-400">CI Status</span>
7979
<img src="{% url 'fossil:status_badge' slug=project.slug checkin_uuid=checkin.uuid %}" alt="CI Status" class="h-5">
8080
</div>
8181
<div class="flex flex-wrap gap-2">
8282
{% for check in status_checks %}
83
- <a {% if check.target_url %}href="{{ check.target_url }}" target="_blank" rel="noopener"{% endif %}
83
+ <a {% if check.target_url and check.target_url|slice:":4" == "http" %}href="{{ check.target_url }}" target="_blank" rel="noopener"{% endif %}
8484
class="inline-flex items-center gap-1.5 rounded-md border px-2 py-1 text-xs
8585
{% if check.state == 'success' %}border-green-800 bg-green-900/30 text-green-300
8686
{% elif check.state == 'failure' %}border-red-800 bg-red-900/30 text-red-300
8787
{% elif check.state == 'error' %}border-red-800 bg-red-900/30 text-red-300
8888
{% else %}border-yellow-800 bg-yellow-900/30 text-yellow-300{% endif %}"
8989
--- templates/fossil/checkin_detail.html
+++ templates/fossil/checkin_detail.html
@@ -78,11 +78,11 @@
78 <span class="text-xs font-medium text-gray-400">CI Status</span>
79 <img src="{% url 'fossil:status_badge' slug=project.slug checkin_uuid=checkin.uuid %}" alt="CI Status" class="h-5">
80 </div>
81 <div class="flex flex-wrap gap-2">
82 {% for check in status_checks %}
83 <a {% if check.target_url %}href="{{ check.target_url }}" target="_blank" rel="noopener"{% endif %}
84 class="inline-flex items-center gap-1.5 rounded-md border px-2 py-1 text-xs
85 {% if check.state == 'success' %}border-green-800 bg-green-900/30 text-green-300
86 {% elif check.state == 'failure' %}border-red-800 bg-red-900/30 text-red-300
87 {% elif check.state == 'error' %}border-red-800 bg-red-900/30 text-red-300
88 {% else %}border-yellow-800 bg-yellow-900/30 text-yellow-300{% endif %}"
89
--- templates/fossil/checkin_detail.html
+++ templates/fossil/checkin_detail.html
@@ -78,11 +78,11 @@
78 <span class="text-xs font-medium text-gray-400">CI Status</span>
79 <img src="{% url 'fossil:status_badge' slug=project.slug checkin_uuid=checkin.uuid %}" alt="CI Status" class="h-5">
80 </div>
81 <div class="flex flex-wrap gap-2">
82 {% for check in status_checks %}
83 <a {% if check.target_url and check.target_url|slice:":4" == "http" %}href="{{ check.target_url }}" target="_blank" rel="noopener"{% endif %}
84 class="inline-flex items-center gap-1.5 rounded-md border px-2 py-1 text-xs
85 {% if check.state == 'success' %}border-green-800 bg-green-900/30 text-green-300
86 {% elif check.state == 'failure' %}border-red-800 bg-red-900/30 text-red-300
87 {% elif check.state == 'error' %}border-red-800 bg-red-900/30 text-red-300
88 {% else %}border-yellow-800 bg-yellow-900/30 text-yellow-300{% endif %}"
89

Keyboard Shortcuts

Open search /
Next entry (timeline) j
Previous entry (timeline) k
Open focused entry Enter
Show this help ?
Toggle theme Top nav button