FossilRepo
Fix OAuth CSRF, git mirror secret exposure, open redirect OAuth: state parameter now includes a cryptographic nonce stored in the session. Callbacks validate nonce before accepting tokens — prevents session poisoning with attacker-controlled OAuth tokens. Git mirror: tokens no longer embedded in autopush URLs (visible in ps and process output). Auth passed via Git credential helper environment variables. Token scrubbed from all output before storing in SyncLog. Login: next parameter validated with url_has_allowed_host_and_scheme() to prevent external redirect after authentication. 53 security regression tests total across both commits.
Commit
747858abc22a4fbfdcf0f756d6dab4ade6dcc588f4d0d09b4a5e31b6800434c3
Parent
911b0a4f791c4d4…
4 files changed
+26
-4
+19
-3
+7
-2
+11
-5
+26
-4
| --- config/urls.py | ||
| +++ config/urls.py | ||
| @@ -9,14 +9,25 @@ | ||
| 9 | 9 | from django.views.generic import RedirectView |
| 10 | 10 | |
| 11 | 11 | |
| 12 | 12 | def _oauth_github_callback(request): |
| 13 | 13 | """Global GitHub OAuth callback. Extracts slug from state param and delegates.""" |
| 14 | + from django.contrib import messages | |
| 15 | + | |
| 14 | 16 | state = request.GET.get("state", "") |
| 15 | - slug = state.split(":")[0] if ":" in state else "" | |
| 16 | - if not slug: | |
| 17 | + parts = state.split(":") | |
| 18 | + if len(parts) < 3: | |
| 17 | 19 | return _redirect("/dashboard/") |
| 20 | + | |
| 21 | + slug = parts[0] | |
| 22 | + nonce = parts[2] | |
| 23 | + | |
| 24 | + expected_nonce = request.session.pop("oauth_state_nonce", "") | |
| 25 | + if not nonce or nonce != expected_nonce: | |
| 26 | + messages.error(request, "OAuth state mismatch. Please try again.") | |
| 27 | + return _redirect(f"/projects/{slug}/fossil/sync/git/") | |
| 28 | + | |
| 18 | 29 | from fossil.oauth import github_exchange_token |
| 19 | 30 | |
| 20 | 31 | result = github_exchange_token(request, slug) |
| 21 | 32 | if result.get("token"): |
| 22 | 33 | request.session["github_oauth_token"] = result["token"] |
| @@ -24,14 +35,25 @@ | ||
| 24 | 35 | return _redirect(f"/projects/{slug}/fossil/sync/git/") |
| 25 | 36 | |
| 26 | 37 | |
| 27 | 38 | def _oauth_gitlab_callback(request): |
| 28 | 39 | """Global GitLab OAuth callback. Extracts slug from state param and delegates.""" |
| 40 | + from django.contrib import messages | |
| 41 | + | |
| 29 | 42 | state = request.GET.get("state", "") |
| 30 | - slug = state.split(":")[0] if ":" in state else "" | |
| 31 | - if not slug: | |
| 43 | + parts = state.split(":") | |
| 44 | + if len(parts) < 3: | |
| 32 | 45 | return _redirect("/dashboard/") |
| 46 | + | |
| 47 | + slug = parts[0] | |
| 48 | + nonce = parts[2] | |
| 49 | + | |
| 50 | + expected_nonce = request.session.pop("oauth_state_nonce", "") | |
| 51 | + if not nonce or nonce != expected_nonce: | |
| 52 | + messages.error(request, "OAuth state mismatch. Please try again.") | |
| 53 | + return _redirect(f"/projects/{slug}/fossil/sync/git/") | |
| 54 | + | |
| 33 | 55 | from fossil.oauth import gitlab_exchange_token |
| 34 | 56 | |
| 35 | 57 | result = gitlab_exchange_token(request, slug) |
| 36 | 58 | if result.get("token"): |
| 37 | 59 | request.session["gitlab_oauth_token"] = result["token"] |
| 38 | 60 |
| --- config/urls.py | |
| +++ config/urls.py | |
| @@ -9,14 +9,25 @@ | |
| 9 | from django.views.generic import RedirectView |
| 10 | |
| 11 | |
| 12 | def _oauth_github_callback(request): |
| 13 | """Global GitHub OAuth callback. Extracts slug from state param and delegates.""" |
| 14 | state = request.GET.get("state", "") |
| 15 | slug = state.split(":")[0] if ":" in state else "" |
| 16 | if not slug: |
| 17 | return _redirect("/dashboard/") |
| 18 | from fossil.oauth import github_exchange_token |
| 19 | |
| 20 | result = github_exchange_token(request, slug) |
| 21 | if result.get("token"): |
| 22 | request.session["github_oauth_token"] = result["token"] |
| @@ -24,14 +35,25 @@ | |
| 24 | return _redirect(f"/projects/{slug}/fossil/sync/git/") |
| 25 | |
| 26 | |
| 27 | def _oauth_gitlab_callback(request): |
| 28 | """Global GitLab OAuth callback. Extracts slug from state param and delegates.""" |
| 29 | state = request.GET.get("state", "") |
| 30 | slug = state.split(":")[0] if ":" in state else "" |
| 31 | if not slug: |
| 32 | return _redirect("/dashboard/") |
| 33 | from fossil.oauth import gitlab_exchange_token |
| 34 | |
| 35 | result = gitlab_exchange_token(request, slug) |
| 36 | if result.get("token"): |
| 37 | request.session["gitlab_oauth_token"] = result["token"] |
| 38 |
| --- config/urls.py | |
| +++ config/urls.py | |
| @@ -9,14 +9,25 @@ | |
| 9 | from django.views.generic import RedirectView |
| 10 | |
| 11 | |
| 12 | def _oauth_github_callback(request): |
| 13 | """Global GitHub OAuth callback. Extracts slug from state param and delegates.""" |
| 14 | from django.contrib import messages |
| 15 | |
| 16 | state = request.GET.get("state", "") |
| 17 | parts = state.split(":") |
| 18 | if len(parts) < 3: |
| 19 | return _redirect("/dashboard/") |
| 20 | |
| 21 | slug = parts[0] |
| 22 | nonce = parts[2] |
| 23 | |
| 24 | expected_nonce = request.session.pop("oauth_state_nonce", "") |
| 25 | if not nonce or nonce != expected_nonce: |
| 26 | messages.error(request, "OAuth state mismatch. Please try again.") |
| 27 | return _redirect(f"/projects/{slug}/fossil/sync/git/") |
| 28 | |
| 29 | from fossil.oauth import github_exchange_token |
| 30 | |
| 31 | result = github_exchange_token(request, slug) |
| 32 | if result.get("token"): |
| 33 | request.session["github_oauth_token"] = result["token"] |
| @@ -24,14 +35,25 @@ | |
| 35 | return _redirect(f"/projects/{slug}/fossil/sync/git/") |
| 36 | |
| 37 | |
| 38 | def _oauth_gitlab_callback(request): |
| 39 | """Global GitLab OAuth callback. Extracts slug from state param and delegates.""" |
| 40 | from django.contrib import messages |
| 41 | |
| 42 | state = request.GET.get("state", "") |
| 43 | parts = state.split(":") |
| 44 | if len(parts) < 3: |
| 45 | return _redirect("/dashboard/") |
| 46 | |
| 47 | slug = parts[0] |
| 48 | nonce = parts[2] |
| 49 | |
| 50 | expected_nonce = request.session.pop("oauth_state_nonce", "") |
| 51 | if not nonce or nonce != expected_nonce: |
| 52 | messages.error(request, "OAuth state mismatch. Please try again.") |
| 53 | return _redirect(f"/projects/{slug}/fossil/sync/git/") |
| 54 | |
| 55 | from fossil.oauth import gitlab_exchange_token |
| 56 | |
| 57 | result = gitlab_exchange_token(request, slug) |
| 58 | if result.get("token"): |
| 59 | request.session["gitlab_oauth_token"] = result["token"] |
| 60 |
+19
-3
| --- fossil/cli.py | ||
| +++ fossil/cli.py | ||
| @@ -202,22 +202,38 @@ | ||
| 202 | 202 | cmd.append(f"{key}") |
| 203 | 203 | cmd.append(f"{value}") |
| 204 | 204 | result = subprocess.run(cmd, capture_output=True, text=True, timeout=30, env=self._env) |
| 205 | 205 | return result.returncode == 0 |
| 206 | 206 | |
| 207 | - def git_export(self, repo_path: Path, mirror_dir: Path, autopush_url: str = "") -> dict: | |
| 207 | + def git_export(self, repo_path: Path, mirror_dir: Path, autopush_url: str = "", auth_token: str = "") -> dict: | |
| 208 | 208 | """Export Fossil repo to a Git mirror directory. Incremental. |
| 209 | + | |
| 210 | + When auth_token is provided, credentials are passed via Git environment | |
| 211 | + variables instead of being embedded in the URL (avoids exposure in | |
| 212 | + process args and command output). | |
| 209 | 213 | |
| 210 | 214 | Returns {success, message}. |
| 211 | 215 | """ |
| 212 | 216 | mirror_dir.mkdir(parents=True, exist_ok=True) |
| 213 | 217 | cmd = [self.binary, "git", "export", str(mirror_dir), "-R", str(repo_path)] |
| 218 | + | |
| 219 | + env = dict(self._env) | |
| 220 | + | |
| 214 | 221 | if autopush_url: |
| 215 | 222 | cmd.extend(["--autopush", autopush_url]) |
| 223 | + if auth_token: | |
| 224 | + env["GIT_TERMINAL_PROMPT"] = "0" | |
| 225 | + env["GIT_CONFIG_COUNT"] = "1" | |
| 226 | + env["GIT_CONFIG_KEY_0"] = "credential.helper" | |
| 227 | + env["GIT_CONFIG_VALUE_0"] = f"!echo password={auth_token}" | |
| 228 | + | |
| 216 | 229 | try: |
| 217 | - result = subprocess.run(cmd, capture_output=True, text=True, timeout=300, env=self._env) | |
| 218 | - return {"success": result.returncode == 0, "message": (result.stdout + result.stderr).strip()} | |
| 230 | + result = subprocess.run(cmd, capture_output=True, text=True, timeout=300, env=env) | |
| 231 | + output = (result.stdout + result.stderr).strip() | |
| 232 | + if auth_token: | |
| 233 | + output = output.replace(auth_token, "[REDACTED]") | |
| 234 | + return {"success": result.returncode == 0, "message": output} | |
| 219 | 235 | except subprocess.TimeoutExpired: |
| 220 | 236 | return {"success": False, "message": "Export timed out after 5 minutes"} |
| 221 | 237 | |
| 222 | 238 | def generate_ssh_key(self, key_path: Path, comment: str = "fossilrepo") -> dict: |
| 223 | 239 | """Generate an SSH key pair for Git authentication. |
| 224 | 240 |
| --- fossil/cli.py | |
| +++ fossil/cli.py | |
| @@ -202,22 +202,38 @@ | |
| 202 | cmd.append(f"{key}") |
| 203 | cmd.append(f"{value}") |
| 204 | result = subprocess.run(cmd, capture_output=True, text=True, timeout=30, env=self._env) |
| 205 | return result.returncode == 0 |
| 206 | |
| 207 | def git_export(self, repo_path: Path, mirror_dir: Path, autopush_url: str = "") -> dict: |
| 208 | """Export Fossil repo to a Git mirror directory. Incremental. |
| 209 | |
| 210 | Returns {success, message}. |
| 211 | """ |
| 212 | mirror_dir.mkdir(parents=True, exist_ok=True) |
| 213 | cmd = [self.binary, "git", "export", str(mirror_dir), "-R", str(repo_path)] |
| 214 | if autopush_url: |
| 215 | cmd.extend(["--autopush", autopush_url]) |
| 216 | try: |
| 217 | result = subprocess.run(cmd, capture_output=True, text=True, timeout=300, env=self._env) |
| 218 | return {"success": result.returncode == 0, "message": (result.stdout + result.stderr).strip()} |
| 219 | except subprocess.TimeoutExpired: |
| 220 | return {"success": False, "message": "Export timed out after 5 minutes"} |
| 221 | |
| 222 | def generate_ssh_key(self, key_path: Path, comment: str = "fossilrepo") -> dict: |
| 223 | """Generate an SSH key pair for Git authentication. |
| 224 |
| --- fossil/cli.py | |
| +++ fossil/cli.py | |
| @@ -202,22 +202,38 @@ | |
| 202 | cmd.append(f"{key}") |
| 203 | cmd.append(f"{value}") |
| 204 | result = subprocess.run(cmd, capture_output=True, text=True, timeout=30, env=self._env) |
| 205 | return result.returncode == 0 |
| 206 | |
| 207 | def git_export(self, repo_path: Path, mirror_dir: Path, autopush_url: str = "", auth_token: str = "") -> dict: |
| 208 | """Export Fossil repo to a Git mirror directory. Incremental. |
| 209 | |
| 210 | When auth_token is provided, credentials are passed via Git environment |
| 211 | variables instead of being embedded in the URL (avoids exposure in |
| 212 | process args and command output). |
| 213 | |
| 214 | Returns {success, message}. |
| 215 | """ |
| 216 | mirror_dir.mkdir(parents=True, exist_ok=True) |
| 217 | cmd = [self.binary, "git", "export", str(mirror_dir), "-R", str(repo_path)] |
| 218 | |
| 219 | env = dict(self._env) |
| 220 | |
| 221 | if autopush_url: |
| 222 | cmd.extend(["--autopush", autopush_url]) |
| 223 | if auth_token: |
| 224 | env["GIT_TERMINAL_PROMPT"] = "0" |
| 225 | env["GIT_CONFIG_COUNT"] = "1" |
| 226 | env["GIT_CONFIG_KEY_0"] = "credential.helper" |
| 227 | env["GIT_CONFIG_VALUE_0"] = f"!echo password={auth_token}" |
| 228 | |
| 229 | try: |
| 230 | result = subprocess.run(cmd, capture_output=True, text=True, timeout=300, env=env) |
| 231 | output = (result.stdout + result.stderr).strip() |
| 232 | if auth_token: |
| 233 | output = output.replace(auth_token, "[REDACTED]") |
| 234 | return {"success": result.returncode == 0, "message": output} |
| 235 | except subprocess.TimeoutExpired: |
| 236 | return {"success": False, "message": "Export timed out after 5 minutes"} |
| 237 | |
| 238 | def generate_ssh_key(self, key_path: Path, comment: str = "fossilrepo") -> dict: |
| 239 | """Generate an SSH key pair for Git authentication. |
| 240 |
+7
-2
| --- fossil/oauth.py | ||
| +++ fossil/oauth.py | ||
| @@ -3,10 +3,11 @@ | ||
| 3 | 3 | No dependency on django-allauth — just requests + constance config. |
| 4 | 4 | Stores tokens on GitMirror.auth_credential. |
| 5 | 5 | """ |
| 6 | 6 | |
| 7 | 7 | import logging |
| 8 | +import secrets | |
| 8 | 9 | |
| 9 | 10 | import requests |
| 10 | 11 | |
| 11 | 12 | logger = logging.getLogger(__name__) |
| 12 | 13 | |
| @@ -25,11 +26,13 @@ | ||
| 25 | 26 | client_id = config.GITHUB_OAUTH_CLIENT_ID |
| 26 | 27 | if not client_id: |
| 27 | 28 | return None |
| 28 | 29 | |
| 29 | 30 | callback = request.build_absolute_uri("/oauth/callback/github/") |
| 30 | - state = f"{slug}:{mirror_id or 'new'}" | |
| 31 | + nonce = secrets.token_urlsafe(32) | |
| 32 | + state = f"{slug}:{mirror_id or 'new'}:{nonce}" | |
| 33 | + request.session["oauth_state_nonce"] = nonce | |
| 31 | 34 | |
| 32 | 35 | return f"{GITHUB_AUTHORIZE_URL}?client_id={client_id}&redirect_uri={callback}&scope=repo&state={state}" |
| 33 | 36 | |
| 34 | 37 | |
| 35 | 38 | def github_exchange_token(request, slug): |
| @@ -72,11 +75,13 @@ | ||
| 72 | 75 | client_id = config.GITLAB_OAUTH_CLIENT_ID |
| 73 | 76 | if not client_id: |
| 74 | 77 | return None |
| 75 | 78 | |
| 76 | 79 | callback = request.build_absolute_uri("/oauth/callback/gitlab/") |
| 77 | - state = f"{slug}:{mirror_id or 'new'}" | |
| 80 | + nonce = secrets.token_urlsafe(32) | |
| 81 | + state = f"{slug}:{mirror_id or 'new'}:{nonce}" | |
| 82 | + request.session["oauth_state_nonce"] = nonce | |
| 78 | 83 | |
| 79 | 84 | return f"{GITLAB_AUTHORIZE_URL}?client_id={client_id}&redirect_uri={callback}&response_type=code&scope=api&state={state}" |
| 80 | 85 | |
| 81 | 86 | |
| 82 | 87 | def gitlab_exchange_token(request, slug): |
| 83 | 88 |
| --- fossil/oauth.py | |
| +++ fossil/oauth.py | |
| @@ -3,10 +3,11 @@ | |
| 3 | No dependency on django-allauth — just requests + constance config. |
| 4 | Stores tokens on GitMirror.auth_credential. |
| 5 | """ |
| 6 | |
| 7 | import logging |
| 8 | |
| 9 | import requests |
| 10 | |
| 11 | logger = logging.getLogger(__name__) |
| 12 | |
| @@ -25,11 +26,13 @@ | |
| 25 | client_id = config.GITHUB_OAUTH_CLIENT_ID |
| 26 | if not client_id: |
| 27 | return None |
| 28 | |
| 29 | callback = request.build_absolute_uri("/oauth/callback/github/") |
| 30 | state = f"{slug}:{mirror_id or 'new'}" |
| 31 | |
| 32 | return f"{GITHUB_AUTHORIZE_URL}?client_id={client_id}&redirect_uri={callback}&scope=repo&state={state}" |
| 33 | |
| 34 | |
| 35 | def github_exchange_token(request, slug): |
| @@ -72,11 +75,13 @@ | |
| 72 | client_id = config.GITLAB_OAUTH_CLIENT_ID |
| 73 | if not client_id: |
| 74 | return None |
| 75 | |
| 76 | callback = request.build_absolute_uri("/oauth/callback/gitlab/") |
| 77 | state = f"{slug}:{mirror_id or 'new'}" |
| 78 | |
| 79 | return f"{GITLAB_AUTHORIZE_URL}?client_id={client_id}&redirect_uri={callback}&response_type=code&scope=api&state={state}" |
| 80 | |
| 81 | |
| 82 | def gitlab_exchange_token(request, slug): |
| 83 |
| --- fossil/oauth.py | |
| +++ fossil/oauth.py | |
| @@ -3,10 +3,11 @@ | |
| 3 | No dependency on django-allauth — just requests + constance config. |
| 4 | Stores tokens on GitMirror.auth_credential. |
| 5 | """ |
| 6 | |
| 7 | import logging |
| 8 | import secrets |
| 9 | |
| 10 | import requests |
| 11 | |
| 12 | logger = logging.getLogger(__name__) |
| 13 | |
| @@ -25,11 +26,13 @@ | |
| 26 | client_id = config.GITHUB_OAUTH_CLIENT_ID |
| 27 | if not client_id: |
| 28 | return None |
| 29 | |
| 30 | callback = request.build_absolute_uri("/oauth/callback/github/") |
| 31 | nonce = secrets.token_urlsafe(32) |
| 32 | state = f"{slug}:{mirror_id or 'new'}:{nonce}" |
| 33 | request.session["oauth_state_nonce"] = nonce |
| 34 | |
| 35 | return f"{GITHUB_AUTHORIZE_URL}?client_id={client_id}&redirect_uri={callback}&scope=repo&state={state}" |
| 36 | |
| 37 | |
| 38 | def github_exchange_token(request, slug): |
| @@ -72,11 +75,13 @@ | |
| 75 | client_id = config.GITLAB_OAUTH_CLIENT_ID |
| 76 | if not client_id: |
| 77 | return None |
| 78 | |
| 79 | callback = request.build_absolute_uri("/oauth/callback/gitlab/") |
| 80 | nonce = secrets.token_urlsafe(32) |
| 81 | state = f"{slug}:{mirror_id or 'new'}:{nonce}" |
| 82 | request.session["oauth_state_nonce"] = nonce |
| 83 | |
| 84 | return f"{GITLAB_AUTHORIZE_URL}?client_id={client_id}&redirect_uri={callback}&response_type=code&scope=api&state={state}" |
| 85 | |
| 86 | |
| 87 | def gitlab_exchange_token(request, slug): |
| 88 |
+11
-5
| --- fossil/tasks.py | ||
| +++ fossil/tasks.py | ||
| @@ -150,25 +150,31 @@ | ||
| 150 | 150 | cli.ensure_default_user(repo.full_path) |
| 151 | 151 | |
| 152 | 152 | # Git export directory for this mirror |
| 153 | 153 | export_dir = mirror_dir / f"{repo.filename.replace('.fossil', '')}-git" |
| 154 | 154 | |
| 155 | - # Build autopush URL with credentials if token auth | |
| 155 | + # Pass clean URL and token separately -- never embed credentials in URLs | |
| 156 | 156 | push_url = mirror.git_remote_url |
| 157 | + auth_token = "" | |
| 157 | 158 | if mirror.auth_method == "token" and mirror.auth_credential and push_url.startswith("https://"): |
| 158 | - push_url = push_url.replace("https://", f"https://{mirror.auth_credential}@") | |
| 159 | + auth_token = mirror.auth_credential | |
| 160 | + | |
| 161 | + result = cli.git_export(repo.full_path, export_dir, autopush_url=push_url, auth_token=auth_token) | |
| 159 | 162 | |
| 160 | - result = cli.git_export(repo.full_path, export_dir, autopush_url=push_url) | |
| 163 | + # Scrub any credential from output before persisting | |
| 164 | + message = result.get("message", "") | |
| 165 | + if mirror.auth_credential: | |
| 166 | + message = message.replace(mirror.auth_credential, "[REDACTED]") | |
| 161 | 167 | |
| 162 | 168 | log.status = "success" if result["success"] else "failed" |
| 163 | - log.message = result["message"] | |
| 169 | + log.message = message | |
| 164 | 170 | log.completed_at = timezone.now() |
| 165 | 171 | log.save() |
| 166 | 172 | |
| 167 | 173 | mirror.last_sync_at = timezone.now() |
| 168 | 174 | mirror.last_sync_status = log.status |
| 169 | - mirror.last_sync_message = result["message"][:500] | |
| 175 | + mirror.last_sync_message = message[:500] | |
| 170 | 176 | mirror.total_syncs += 1 |
| 171 | 177 | mirror.save( |
| 172 | 178 | update_fields=[ |
| 173 | 179 | "last_sync_at", |
| 174 | 180 | "last_sync_status", |
| 175 | 181 |
| --- fossil/tasks.py | |
| +++ fossil/tasks.py | |
| @@ -150,25 +150,31 @@ | |
| 150 | cli.ensure_default_user(repo.full_path) |
| 151 | |
| 152 | # Git export directory for this mirror |
| 153 | export_dir = mirror_dir / f"{repo.filename.replace('.fossil', '')}-git" |
| 154 | |
| 155 | # Build autopush URL with credentials if token auth |
| 156 | push_url = mirror.git_remote_url |
| 157 | if mirror.auth_method == "token" and mirror.auth_credential and push_url.startswith("https://"): |
| 158 | push_url = push_url.replace("https://", f"https://{mirror.auth_credential}@") |
| 159 | |
| 160 | result = cli.git_export(repo.full_path, export_dir, autopush_url=push_url) |
| 161 | |
| 162 | log.status = "success" if result["success"] else "failed" |
| 163 | log.message = result["message"] |
| 164 | log.completed_at = timezone.now() |
| 165 | log.save() |
| 166 | |
| 167 | mirror.last_sync_at = timezone.now() |
| 168 | mirror.last_sync_status = log.status |
| 169 | mirror.last_sync_message = result["message"][:500] |
| 170 | mirror.total_syncs += 1 |
| 171 | mirror.save( |
| 172 | update_fields=[ |
| 173 | "last_sync_at", |
| 174 | "last_sync_status", |
| 175 |
| --- fossil/tasks.py | |
| +++ fossil/tasks.py | |
| @@ -150,25 +150,31 @@ | |
| 150 | cli.ensure_default_user(repo.full_path) |
| 151 | |
| 152 | # Git export directory for this mirror |
| 153 | export_dir = mirror_dir / f"{repo.filename.replace('.fossil', '')}-git" |
| 154 | |
| 155 | # Pass clean URL and token separately -- never embed credentials in URLs |
| 156 | push_url = mirror.git_remote_url |
| 157 | auth_token = "" |
| 158 | if mirror.auth_method == "token" and mirror.auth_credential and push_url.startswith("https://"): |
| 159 | auth_token = mirror.auth_credential |
| 160 | |
| 161 | result = cli.git_export(repo.full_path, export_dir, autopush_url=push_url, auth_token=auth_token) |
| 162 | |
| 163 | # Scrub any credential from output before persisting |
| 164 | message = result.get("message", "") |
| 165 | if mirror.auth_credential: |
| 166 | message = message.replace(mirror.auth_credential, "[REDACTED]") |
| 167 | |
| 168 | log.status = "success" if result["success"] else "failed" |
| 169 | log.message = message |
| 170 | log.completed_at = timezone.now() |
| 171 | log.save() |
| 172 | |
| 173 | mirror.last_sync_at = timezone.now() |
| 174 | mirror.last_sync_status = log.status |
| 175 | mirror.last_sync_message = message[:500] |
| 176 | mirror.total_syncs += 1 |
| 177 | mirror.save( |
| 178 | update_fields=[ |
| 179 | "last_sync_at", |
| 180 | "last_sync_status", |
| 181 |