FossilRepo
Security hardening: HSTS headers, Cloudflare Turnstile on login (optional via constance)
Commit
0c354aca4fcba6a7a35a85c9c333ba694255548c26164d42ecd8bc453049d61a
Parent
0e40dc289c28022…
10 files changed
+50
-1
+8
+15
-2
+47
-2
+24
-3
+2
-2
+6
+5
-5
+15
+1
-1
+50
-1
| --- accounts/views.py | ||
| +++ accounts/views.py | ||
| @@ -1,7 +1,9 @@ | ||
| 1 | +import logging | |
| 1 | 2 | import re |
| 2 | 3 | |
| 4 | +import requests | |
| 3 | 5 | from django.contrib import messages |
| 4 | 6 | from django.contrib.auth import login, logout |
| 5 | 7 | from django.contrib.auth.decorators import login_required |
| 6 | 8 | from django.http import HttpResponse |
| 7 | 9 | from django.shortcuts import get_object_or_404, redirect, render |
| @@ -9,10 +11,12 @@ | ||
| 9 | 11 | from django.views.decorators.http import require_POST |
| 10 | 12 | from django_ratelimit.decorators import ratelimit |
| 11 | 13 | |
| 12 | 14 | from .forms import LoginForm |
| 13 | 15 | from .models import PersonalAccessToken, UserProfile |
| 16 | + | |
| 17 | +logger = logging.getLogger(__name__) | |
| 14 | 18 | |
| 15 | 19 | # Allowed SSH key type prefixes |
| 16 | 20 | _SSH_KEY_PREFIXES = ("ssh-ed25519", "ssh-rsa", "ecdsa-sha2-", "ssh-dss") |
| 17 | 21 | |
| 18 | 22 | |
| @@ -46,17 +50,57 @@ | ||
| 46 | 50 | if not re.match(r"^[A-Za-z0-9+/=]+$", parts[1]): |
| 47 | 51 | return None, "Invalid SSH key data encoding." |
| 48 | 52 | |
| 49 | 53 | return key, "" |
| 50 | 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 | + | |
| 51 | 73 | |
| 52 | 74 | @ratelimit(key="ip", rate="10/m", block=True) |
| 53 | 75 | def login_view(request): |
| 54 | 76 | if request.user.is_authenticated: |
| 55 | 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 | |
| 56 | 83 | |
| 57 | 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 | + | |
| 58 | 102 | form = LoginForm(request, data=request.POST) |
| 59 | 103 | if form.is_valid(): |
| 60 | 104 | login(request, form.get_user()) |
| 61 | 105 | next_url = request.GET.get("next", "") |
| 62 | 106 | if next_url and url_has_allowed_host_and_scheme(next_url, allowed_hosts={request.get_host()}): |
| @@ -63,11 +107,16 @@ | ||
| 63 | 107 | return redirect(next_url) |
| 64 | 108 | return redirect("dashboard") |
| 65 | 109 | else: |
| 66 | 110 | form = LoginForm() |
| 67 | 111 | |
| 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) | |
| 69 | 118 | |
| 70 | 119 | |
| 71 | 120 | @require_POST |
| 72 | 121 | def logout_view(request): |
| 73 | 122 | logout(request) |
| 74 | 123 |
| --- 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 |
+8
| --- config/settings.py | ||
| +++ config/settings.py | ||
| @@ -140,10 +140,13 @@ | ||
| 140 | 140 | if not DEBUG: |
| 141 | 141 | SESSION_COOKIE_SECURE = env_bool("SESSION_COOKIE_SECURE", True) |
| 142 | 142 | CSRF_COOKIE_SECURE = env_bool("CSRF_COOKIE_SECURE", True) |
| 143 | 143 | SECURE_SSL_REDIRECT = env_bool("SECURE_SSL_REDIRECT", True) |
| 144 | 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 | |
| 145 | 148 | |
| 146 | 149 | # --- i18n --- |
| 147 | 150 | |
| 148 | 151 | LANGUAGE_CODE = "en-us" |
| 149 | 152 | TIME_ZONE = "UTC" |
| @@ -243,17 +246,22 @@ | ||
| 243 | 246 | "GIT_SSH_KEY_DIR": ("/data/ssh-keys", "Directory for SSH key storage"), |
| 244 | 247 | "GITHUB_OAUTH_CLIENT_ID": ("", "GitHub OAuth App Client ID"), |
| 245 | 248 | "GITHUB_OAUTH_CLIENT_SECRET": ("", "GitHub OAuth App Client Secret"), |
| 246 | 249 | "GITLAB_OAUTH_CLIENT_ID": ("", "GitLab OAuth App Client ID"), |
| 247 | 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)"), | |
| 248 | 255 | } |
| 249 | 256 | CONSTANCE_CONFIG_FIELDSETS = { |
| 250 | 257 | "General": ("SITE_NAME",), |
| 251 | 258 | "Fossil Storage": ("FOSSIL_DATA_DIR", "FOSSIL_STORE_IN_DB", "FOSSIL_S3_TRACKING", "FOSSIL_S3_BUCKET", "FOSSIL_BINARY_PATH"), |
| 252 | 259 | "Git Sync": ("GIT_SYNC_MODE", "GIT_SYNC_SCHEDULE", "GIT_MIRROR_DIR", "GIT_SSH_KEY_DIR"), |
| 253 | 260 | "GitHub OAuth": ("GITHUB_OAUTH_CLIENT_ID", "GITHUB_OAUTH_CLIENT_SECRET"), |
| 254 | 261 | "GitLab OAuth": ("GITLAB_OAUTH_CLIENT_ID", "GITLAB_OAUTH_CLIENT_SECRET"), |
| 262 | + "Cloudflare Turnstile": ("TURNSTILE_ENABLED", "TURNSTILE_SITE_KEY", "TURNSTILE_SECRET_KEY"), | |
| 255 | 263 | } |
| 256 | 264 | |
| 257 | 265 | # --- Sentry --- |
| 258 | 266 | |
| 259 | 267 | SENTRY_DSN = env_str("SENTRY_DSN") |
| 260 | 268 |
| --- 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 |
+15
-2
| --- fossil/api_auth.py | ||
| +++ fossil/api_auth.py | ||
| @@ -1,16 +1,20 @@ | ||
| 1 | 1 | """API authentication for both project-scoped and user-scoped tokens. |
| 2 | 2 | |
| 3 | 3 | Supports: |
| 4 | 4 | 1. Project-scoped APIToken (tied to a FossilRepository) — permissions enforced |
| 5 | 5 | 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 | |
| 7 | 7 | """ |
| 8 | 8 | |
| 9 | 9 | from django.http import JsonResponse |
| 10 | +from django.middleware.csrf import CsrfViewMiddleware | |
| 10 | 11 | from django.utils import timezone |
| 11 | 12 | |
| 13 | +# Reusable CSRF checker for session-auth API callers. | |
| 14 | +_csrf_middleware = CsrfViewMiddleware(lambda req: None) | |
| 15 | + | |
| 12 | 16 | |
| 13 | 17 | def authenticate_request(request, repository=None, required_scope="read"): |
| 14 | 18 | """Authenticate an API request via Bearer token. |
| 15 | 19 | |
| 16 | 20 | Args: |
| @@ -21,12 +25,19 @@ | ||
| 21 | 25 | Returns (user_or_none, token_or_none, error_response_or_none). |
| 22 | 26 | If error_response is not None, return it immediately. |
| 23 | 27 | """ |
| 24 | 28 | auth = request.META.get("HTTP_AUTHORIZATION", "") |
| 25 | 29 | 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. | |
| 27 | 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) | |
| 28 | 39 | return request.user, None, None |
| 29 | 40 | return None, None, JsonResponse({"error": "Authentication required"}, status=401) |
| 30 | 41 | |
| 31 | 42 | raw_token = auth[7:] |
| 32 | 43 | |
| @@ -57,10 +68,12 @@ | ||
| 57 | 68 | if pat.expires_at and pat.expires_at < timezone.now(): |
| 58 | 69 | return None, None, JsonResponse({"error": "Token expired"}, status=401) |
| 59 | 70 | # Enforce PAT scopes |
| 60 | 71 | if not _token_has_scope(pat.scopes, required_scope): |
| 61 | 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) | |
| 62 | 75 | pat.last_used_at = timezone.now() |
| 63 | 76 | pat.save(update_fields=["last_used_at"]) |
| 64 | 77 | return pat.user, pat, None |
| 65 | 78 | except PersonalAccessToken.DoesNotExist: |
| 66 | 79 | pass |
| 67 | 80 |
| --- 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 |
+47
-2
| --- fossil/api_views.py | ||
| +++ fossil/api_views.py | ||
| @@ -716,10 +716,37 @@ | ||
| 716 | 716 | workspace = AgentWorkspace.objects.filter(repository=repo, name=workspace_name).first() |
| 717 | 717 | if workspace is None: |
| 718 | 718 | return None |
| 719 | 719 | return workspace |
| 720 | 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 | + | |
| 721 | 748 | |
| 722 | 749 | @csrf_exempt |
| 723 | 750 | def api_workspace_list(request, slug): |
| 724 | 751 | """List agent workspaces for a repository. |
| 725 | 752 | |
| @@ -936,10 +963,14 @@ | ||
| 936 | 963 | |
| 937 | 964 | try: |
| 938 | 965 | data = json.loads(request.body) |
| 939 | 966 | except (json.JSONDecodeError, ValueError): |
| 940 | 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 | |
| 941 | 972 | |
| 942 | 973 | message = (data.get("message") or "").strip() |
| 943 | 974 | if not message: |
| 944 | 975 | return JsonResponse({"error": "Commit message is required"}, status=400) |
| 945 | 976 | |
| @@ -1033,10 +1064,14 @@ | ||
| 1033 | 1064 | |
| 1034 | 1065 | try: |
| 1035 | 1066 | data = json.loads(request.body) if request.body else {} |
| 1036 | 1067 | except (json.JSONDecodeError, ValueError): |
| 1037 | 1068 | data = {} |
| 1069 | + | |
| 1070 | + ownership_err = _check_workspace_ownership(workspace, request, token, data) | |
| 1071 | + if ownership_err is not None: | |
| 1072 | + return ownership_err | |
| 1038 | 1073 | |
| 1039 | 1074 | target_branch = (data.get("target_branch") or "trunk").strip() |
| 1040 | 1075 | |
| 1041 | 1076 | # --- Branch protection enforcement --- |
| 1042 | 1077 | is_admin = user is not None and can_admin_project(user, project) |
| @@ -1173,10 +1208,14 @@ | ||
| 1173 | 1208 | if workspace is None: |
| 1174 | 1209 | return JsonResponse({"error": "Workspace not found"}, status=404) |
| 1175 | 1210 | |
| 1176 | 1211 | if workspace.status != "active": |
| 1177 | 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 | |
| 1178 | 1217 | |
| 1179 | 1218 | from fossil.cli import FossilCLI |
| 1180 | 1219 | |
| 1181 | 1220 | cli = FossilCLI() |
| 1182 | 1221 | checkout_dir = workspace.checkout_path |
| @@ -1896,19 +1935,25 @@ | ||
| 1896 | 1935 | return JsonResponse({"error": "Review not found"}, status=404) |
| 1897 | 1936 | |
| 1898 | 1937 | if review.status == "merged": |
| 1899 | 1938 | return JsonResponse({"error": "Review is already merged"}, status=409) |
| 1900 | 1939 | |
| 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. | |
| 1902 | 1942 | # Session-auth users (human reviewers) are allowed since they represent human oversight. |
| 1903 | 1943 | if token is not None and review.agent_id: |
| 1904 | 1944 | try: |
| 1905 | 1945 | data = json.loads(request.body) if request.body else {} |
| 1906 | 1946 | except (json.JSONDecodeError, ValueError): |
| 1907 | 1947 | data = {} |
| 1908 | 1948 | 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: | |
| 1910 | 1955 | return JsonResponse({"error": "Cannot approve your own review"}, status=403) |
| 1911 | 1956 | |
| 1912 | 1957 | review.status = "approved" |
| 1913 | 1958 | review.save(update_fields=["status", "updated_at", "version"]) |
| 1914 | 1959 | |
| 1915 | 1960 |
| --- 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 @@ | ||
| 1 | 1 | """Thin wrapper around the fossil binary for write operations.""" |
| 2 | 2 | |
| 3 | 3 | import logging |
| 4 | +import os | |
| 4 | 5 | import subprocess |
| 5 | 6 | from pathlib import Path |
| 6 | 7 | |
| 7 | 8 | logger = logging.getLogger(__name__) |
| 8 | 9 | |
| @@ -319,26 +320,46 @@ | ||
| 319 | 320 | mirror_dir.mkdir(parents=True, exist_ok=True) |
| 320 | 321 | cmd = [self.binary, "git", "export", str(mirror_dir), "-R", str(repo_path)] |
| 321 | 322 | |
| 322 | 323 | env = dict(self._env) |
| 323 | 324 | |
| 325 | + temp_paths = [] | |
| 324 | 326 | if autopush_url: |
| 325 | 327 | cmd.extend(["--autopush", autopush_url]) |
| 326 | 328 | if auth_token: |
| 327 | 329 | 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 | |
| 331 | 349 | |
| 332 | 350 | try: |
| 333 | 351 | result = subprocess.run(cmd, capture_output=True, text=True, timeout=300, env=env) |
| 334 | 352 | output = (result.stdout + result.stderr).strip() |
| 335 | 353 | if auth_token: |
| 336 | 354 | output = output.replace(auth_token, "[REDACTED]") |
| 337 | 355 | return {"success": result.returncode == 0, "message": output} |
| 338 | 356 | except subprocess.TimeoutExpired: |
| 339 | 357 | return {"success": False, "message": "Export timed out after 5 minutes"} |
| 358 | + finally: | |
| 359 | + for p in temp_paths: | |
| 360 | + os.unlink(p) | |
| 340 | 361 | |
| 341 | 362 | def generate_ssh_key(self, key_path: Path, comment: str = "fossilrepo") -> dict: |
| 342 | 363 | """Generate an SSH key pair for Git authentication. |
| 343 | 364 | |
| 344 | 365 | Returns {success, public_key, fingerprint}. |
| 345 | 366 |
| --- 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 @@ | ||
| 98 | 98 | path("sync/git/<int:mirror_id>/edit/", views.git_mirror_config, name="git_mirror_edit"), |
| 99 | 99 | path("sync/git/<int:mirror_id>/delete/", views.git_mirror_delete, name="git_mirror_delete"), |
| 100 | 100 | path("sync/git/<int:mirror_id>/run/", views.git_mirror_run, name="git_mirror_run"), |
| 101 | 101 | path("sync/git/connect/github/", views.oauth_github_start, name="oauth_github"), |
| 102 | 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"), | |
| 103 | + # Per-project OAuth callbacks removed — global /oauth/callback/ handlers | |
| 104 | + # enforce nonce/state validation. Keeping these would bypass that check. | |
| 105 | 105 | path("code/raw/<path:filepath>", views.code_raw, name="code_raw"), |
| 106 | 106 | path("code/blame/<path:filepath>", views.code_blame, name="code_blame"), |
| 107 | 107 | path("code/history/<path:filepath>", views.file_history, name="file_history"), |
| 108 | 108 | path("watch/", views.toggle_watch, name="toggle_watch"), |
| 109 | 109 | path("timeline/rss/", views.timeline_rss, name="timeline_rss"), |
| 110 | 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 | 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 |
+6
| --- fossil/views.py | ||
| +++ fossil/views.py | ||
| @@ -3350,10 +3350,16 @@ | ||
| 3350 | 3350 | return JsonResponse({"error": f"state must be one of: {', '.join(StatusCheck.State.values)}"}, status=400) |
| 3351 | 3351 | if len(context) > 200: |
| 3352 | 3352 | return JsonResponse({"error": "context must be 200 characters or fewer"}, status=400) |
| 3353 | 3353 | if len(description) > 500: |
| 3354 | 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) | |
| 3355 | 3361 | |
| 3356 | 3362 | check, created = StatusCheck.objects.update_or_create( |
| 3357 | 3363 | repository=fossil_repo, |
| 3358 | 3364 | checkin_uuid=checkin_uuid, |
| 3359 | 3365 | context=context, |
| 3360 | 3366 |
| --- 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 |
+5
-5
| --- projects/access.py | ||
| +++ projects/access.py | ||
| @@ -19,11 +19,11 @@ | ||
| 19 | 19 | def get_user_role(user, project: Project) -> str | None: |
| 20 | 20 | """Get the highest role a user has on a project via their teams. |
| 21 | 21 | |
| 22 | 22 | Returns "admin", "write", "read", or None. |
| 23 | 23 | """ |
| 24 | - if not user or not user.is_authenticated: | |
| 24 | + if not user or not user.is_authenticated or not user.is_active: | |
| 25 | 25 | return None |
| 26 | 26 | |
| 27 | 27 | if user.is_superuser: |
| 28 | 28 | return "admin" |
| 29 | 29 | |
| @@ -51,32 +51,32 @@ | ||
| 51 | 51 | - Private: team members only (or superuser) |
| 52 | 52 | """ |
| 53 | 53 | if project.visibility == "public": |
| 54 | 54 | return True |
| 55 | 55 | if project.visibility == "internal": |
| 56 | - return user and user.is_authenticated | |
| 56 | + return user and user.is_authenticated and user.is_active | |
| 57 | 57 | # Private |
| 58 | - if not user or not user.is_authenticated: | |
| 58 | + if not user or not user.is_authenticated or not user.is_active: | |
| 59 | 59 | return False |
| 60 | 60 | if user.is_superuser: |
| 61 | 61 | return True |
| 62 | 62 | return get_user_role(user, project) is not None |
| 63 | 63 | |
| 64 | 64 | |
| 65 | 65 | def can_write_project(user, project: Project) -> bool: |
| 66 | 66 | """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: | |
| 68 | 68 | return False |
| 69 | 69 | if user.is_superuser: |
| 70 | 70 | return True |
| 71 | 71 | role = get_user_role(user, project) |
| 72 | 72 | return role in ("write", "admin") |
| 73 | 73 | |
| 74 | 74 | |
| 75 | 75 | def can_admin_project(user, project: Project) -> bool: |
| 76 | 76 | """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: | |
| 78 | 78 | return False |
| 79 | 79 | if user.is_superuser: |
| 80 | 80 | return True |
| 81 | 81 | return get_user_role(user, project) == "admin" |
| 82 | 82 | |
| 83 | 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: |
| 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 @@ | ||
| 1 | 1 | {% extends "base.html" %} |
| 2 | 2 | {% load static %} |
| 3 | 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 %} | |
| 4 | 10 | |
| 5 | 11 | {% block content %} |
| 6 | 12 | <div class="flex min-h-[80vh] items-center justify-center"> |
| 7 | 13 | <div class="w-full max-w-sm space-y-8"> |
| 8 | 14 | <div class="flex flex-col items-center"> |
| @@ -14,10 +20,16 @@ | ||
| 14 | 20 | {% if form.errors %} |
| 15 | 21 | <div class="rounded-md bg-red-900/50 border border-red-700 p-4"> |
| 16 | 22 | <p class="text-sm text-red-300">Invalid username or password.</p> |
| 17 | 23 | </div> |
| 18 | 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 %} | |
| 19 | 31 | |
| 20 | 32 | <form method="post" class="space-y-6"> |
| 21 | 33 | {% csrf_token %} |
| 22 | 34 | <div> |
| 23 | 35 | <label for="id_username" class="block text-sm font-medium text-gray-300">Username</label> |
| @@ -25,13 +37,16 @@ | ||
| 25 | 37 | </div> |
| 26 | 38 | <div> |
| 27 | 39 | <label for="id_password" class="block text-sm font-medium text-gray-300">Password</label> |
| 28 | 40 | <div class="mt-1">{{ form.password }}</div> |
| 29 | 41 | </div> |
| 42 | + {% if turnstile_enabled %} | |
| 43 | + <div class="cf-turnstile" data-sitekey="{{ turnstile_site_key }}" data-theme="dark"></div> | |
| 44 | + {% endif %} | |
| 30 | 45 | <button type="submit" |
| 31 | 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"> |
| 32 | 47 | Sign in |
| 33 | 48 | </button> |
| 34 | 49 | </form> |
| 35 | 50 | </div> |
| 36 | 51 | </div> |
| 37 | 52 | {% endblock %} |
| 38 | 53 |
| --- 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 @@ | ||
| 78 | 78 | <span class="text-xs font-medium text-gray-400">CI Status</span> |
| 79 | 79 | <img src="{% url 'fossil:status_badge' slug=project.slug checkin_uuid=checkin.uuid %}" alt="CI Status" class="h-5"> |
| 80 | 80 | </div> |
| 81 | 81 | <div class="flex flex-wrap gap-2"> |
| 82 | 82 | {% 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 %} | |
| 84 | 84 | class="inline-flex items-center gap-1.5 rounded-md border px-2 py-1 text-xs |
| 85 | 85 | {% if check.state == 'success' %}border-green-800 bg-green-900/30 text-green-300 |
| 86 | 86 | {% elif check.state == 'failure' %}border-red-800 bg-red-900/30 text-red-300 |
| 87 | 87 | {% elif check.state == 'error' %}border-red-800 bg-red-900/30 text-red-300 |
| 88 | 88 | {% else %}border-yellow-800 bg-yellow-900/30 text-yellow-300{% endif %}" |
| 89 | 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 %}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 |