Compare commits

...

9 Commits

Author SHA1 Message Date
Rephl3x a8aa8e38e2 Add Gitea CI/CD pipeline for beta and prod
Magent CI/CD / verify (push) Has been cancelled
Magent CI/CD / deploy-prod (push) Has been cancelled
2026-05-24 17:07:27 +12:00
Rephl3x 329884b789 Clarify qBittorrent status and fix status pill contrast 2026-05-24 17:03:45 +12:00
Rephl3x 0700d37469 Fix requests cache migration on legacy databases 2026-05-24 16:55:18 +12:00
Rephl3x 2d28047ad7 Merge latest beta with verified auth hardening 2026-05-23 21:14:03 +12:00
Rephl3x cbac743026 Merge security hardening from dev-1.4 2026-05-23 21:12:59 +12:00
Rephl3x 1ce01ec348 Harden auth and outbound admin surfaces 2026-05-23 21:12:45 +12:00
Rephl3x cc26ed9b2c hardening 2026-05-16 10:44:20 +00:00
Rephl3x d9ac54a2ff Process 1 build 0803262237 2026-03-08 22:38:31 +13:00
Rephl3x 3609f44607 Process 1 build 0803262229 2026-03-08 22:30:49 +13:00
32 changed files with 2045 additions and 1286 deletions
+1 -1
View File
@@ -1 +1 @@
0803262216
0803262237
+73
View File
@@ -0,0 +1,73 @@
name: Magent CI/CD
on:
push:
branches:
- beta
- prod
workflow_dispatch:
concurrency:
group: magent-${{ github.ref }}
cancel-in-progress: true
jobs:
verify:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Set up Node
uses: actions/setup-node@v4
with:
node-version: "24"
cache: npm
cache-dependency-path: frontend/package-lock.json
- name: Install frontend dependencies
working-directory: frontend
run: npm ci
- name: Run backend quality gate
run: bash scripts/ci_backend_quality_gate.sh
- name: Build frontend
working-directory: frontend
run: npm run build
deploy-prod:
if: github.ref_name == 'prod'
needs: verify
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Configure SSH key
env:
PROD_SSH_PRIVATE_KEY: ${{ secrets.PROD_SSH_PRIVATE_KEY }}
PROD_SSH_KNOWN_HOSTS: ${{ secrets.PROD_SSH_KNOWN_HOSTS }}
run: |
set -euo pipefail
mkdir -p ~/.ssh
chmod 700 ~/.ssh
printf '%s' "$PROD_SSH_PRIVATE_KEY" > ~/.ssh/id_ed25519
chmod 600 ~/.ssh/id_ed25519
if [ -n "${PROD_SSH_KNOWN_HOSTS:-}" ]; then
printf '%s\n' "$PROD_SSH_KNOWN_HOSTS" > ~/.ssh/known_hosts
chmod 644 ~/.ssh/known_hosts
fi
- name: Deploy to AMS-DEV01
env:
DEPLOY_HOST: ${{ secrets.PROD_SSH_HOST }}
DEPLOY_USER: ${{ secrets.PROD_SSH_USER }}
DEPLOY_PATH: ${{ secrets.PROD_DEPLOY_PATH }}
DEPLOY_SSH_OPTS: -o StrictHostKeyChecking=accept-new
run: bash scripts/deploy_ams_dev01.sh
+26 -6
View File
@@ -64,10 +64,10 @@ QBIT_URL="http://localhost:8080"
QBIT_USERNAME="..."
QBIT_PASSWORD="..."
SQLITE_PATH="data/magent.db"
JWT_SECRET="change-me"
JWT_SECRET="replace-with-a-long-random-secret"
JWT_EXP_MINUTES="720"
ADMIN_USERNAME="admin"
ADMIN_PASSWORD="adminadmin"
ADMIN_USERNAME="set-a-real-admin-username"
ADMIN_PASSWORD="set-a-long-unique-admin-password"
```
## Screenshots
@@ -112,10 +112,10 @@ $env:QBIT_URL="http://localhost:8080"
$env:QBIT_USERNAME="..."
$env:QBIT_PASSWORD="..."
$env:SQLITE_PATH="data/magent.db"
$env:JWT_SECRET="change-me"
$env:JWT_SECRET="replace-with-a-long-random-secret"
$env:JWT_EXP_MINUTES="720"
$env:ADMIN_USERNAME="admin"
$env:ADMIN_PASSWORD="adminadmin"
$env:ADMIN_USERNAME="set-a-real-admin-username"
$env:ADMIN_PASSWORD="set-a-long-unique-admin-password"
```
### Frontend (Next.js)
@@ -141,6 +141,26 @@ The frontend proxies `/api/*` to the backend container. Set:
If you prefer the browser to call the backend directly, set `NEXT_PUBLIC_API_BASE` to your public backend URL and ensure CORS is configured.
## Gitea CI/CD
This repo now includes a Gitea Actions workflow at `.gitea/workflows/ci-cd.yml`.
- Push to `beta`: runs the backend unit-test quality gate and a production frontend build.
- Push to `prod`: runs the same verification, then deploys to Docker on `AMS-DEV01`.
The deploy step ships tracked repository files over SSH, preserves the server's `.env` and `data/`, rebuilds with `docker compose up -d --build`, and smoke-tests:
- `http://127.0.0.1:8000/health`
- `http://127.0.0.1:3000/login`
Configure these Gitea Actions secrets before enabling the deploy job:
- `PROD_SSH_PRIVATE_KEY`: private key for the deployment account.
- `PROD_SSH_HOST`: target host, for example `AMS-DEV01`.
- `PROD_SSH_USER`: target user, for example `zak`.
- `PROD_DEPLOY_PATH`: target app path, for example `/home/zak/magent`.
- `PROD_SSH_KNOWN_HOSTS`: optional pinned `known_hosts` entry for stricter host verification.
## History endpoints
- `GET /requests/{id}/history?limit=10` recent snapshots
+96 -31
View File
@@ -1,13 +1,15 @@
from datetime import datetime, timezone
from typing import Dict, Any, Optional
from typing import Any, Dict, Optional
from fastapi import Depends, HTTPException, status, Request
from fastapi import Depends, HTTPException, Request, Response, status
from fastapi.security import OAuth2PasswordBearer
from .config import settings
from .db import get_user_by_username, set_user_auth_provider, upsert_user_activity
from .security import safe_decode_token, TokenError, verify_password
from .network_security import request_trusts_forwarded_headers
from .security import TokenError, safe_decode_token, verify_password
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/auth/login")
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/auth/login", auto_error=False)
def _is_expired(expires_at: str | None) -> bool:
@@ -24,20 +26,79 @@ def _is_expired(expires_at: str | None) -> bool:
parsed = parsed.replace(tzinfo=timezone.utc)
return parsed <= datetime.now(timezone.utc)
def _extract_client_ip(request: Request) -> str:
forwarded = request.headers.get("x-forwarded-for")
if forwarded:
parts = [part.strip() for part in forwarded.split(",") if part.strip()]
if parts:
return parts[0]
real_ip = request.headers.get("x-real-ip")
if real_ip:
return real_ip.strip()
if request.client and request.client.host:
return request.client.host
direct_host = request.client.host if request.client else None
if request_trusts_forwarded_headers(direct_host):
forwarded = request.headers.get("x-forwarded-for")
if forwarded:
parts = [part.strip() for part in forwarded.split(",") if part.strip()]
if parts:
return parts[0]
real_ip = request.headers.get("x-real-ip")
if real_ip:
return real_ip.strip()
if direct_host:
return direct_host
return "unknown"
def _cookie_settings() -> dict[str, Any]:
samesite = str(settings.auth_cookie_samesite or "lax").strip().lower()
if samesite not in {"lax", "strict", "none"}:
samesite = "lax"
return {
"secure": bool(settings.auth_cookie_secure),
"httponly": True,
"samesite": samesite,
"domain": settings.auth_cookie_domain or None,
"path": "/",
}
def _state_cookie_settings() -> dict[str, Any]:
cookie = _cookie_settings()
cookie["httponly"] = False
return cookie
def set_auth_cookies(response: Response, token: str) -> None:
max_age = max(60, int(settings.jwt_exp_minutes or 720) * 60)
response.set_cookie(
settings.auth_cookie_name,
token,
max_age=max_age,
**_cookie_settings(),
)
response.set_cookie(
settings.auth_state_cookie_name,
"1",
max_age=max_age,
**_state_cookie_settings(),
)
def clear_auth_cookies(response: Response) -> None:
response.delete_cookie(settings.auth_cookie_name, path="/", domain=settings.auth_cookie_domain or None)
response.delete_cookie(
settings.auth_state_cookie_name,
path="/",
domain=settings.auth_cookie_domain or None,
)
def _extract_access_token(request: Request, oauth_token: Optional[str]) -> Optional[str]:
auth_header = request.headers.get("authorization", "")
if auth_header.lower().startswith("bearer "):
return auth_header.split(" ", 1)[1].strip()
if oauth_token:
return oauth_token
cookie_token = request.cookies.get(settings.auth_cookie_name)
if isinstance(cookie_token, str) and cookie_token.strip():
return cookie_token.strip()
return None
def resolve_user_auth_provider(user: Optional[Dict[str, Any]]) -> str:
if not isinstance(user, dict):
return "local"
@@ -122,24 +183,28 @@ def _load_current_user_from_token(
}
def get_current_user(token: str = Depends(oauth2_scheme), request: Request = None) -> Dict[str, Any]:
return _load_current_user_from_token(token, request)
def get_current_user_event_stream(request: Request) -> Dict[str, Any]:
"""EventSource cannot send Authorization headers, so allow a short-lived stream token via query."""
token = None
stream_query_token = None
auth_header = request.headers.get("authorization", "")
if auth_header.lower().startswith("bearer "):
token = auth_header.split(" ", 1)[1].strip()
if not token:
stream_query_token = request.query_params.get("stream_token")
if not token and not stream_query_token:
def get_current_user(
request: Request,
token: Optional[str] = Depends(oauth2_scheme),
) -> Dict[str, Any]:
resolved_token = _extract_access_token(request, token)
if not resolved_token:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Missing token")
return _load_current_user_from_token(resolved_token, request)
def get_current_user_event_stream(
request: Request,
token: Optional[str] = Depends(oauth2_scheme),
) -> Dict[str, Any]:
"""EventSource cannot send Authorization headers, so allow a short-lived stream token via query."""
resolved_token = _extract_access_token(request, token)
stream_query_token = request.query_params.get("stream_token")
if resolved_token:
# Allow standard bearer tokens for non-browser EventSource clients.
return _load_current_user_from_token(resolved_token, None)
if not stream_query_token:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Missing token")
if token:
# Allow standard bearer tokens in Authorization for non-browser EventSource clients.
return _load_current_user_from_token(token, None)
return _load_current_user_from_token(
str(stream_query_token),
None,
File diff suppressed because one or more lines are too long
+11
View File
@@ -52,6 +52,17 @@ class QBittorrentClient(ApiClient):
response = await client.post(f"{self.base_url}{path}", data=data)
response.raise_for_status()
async def is_webui_reachable(self) -> bool:
if not self.base_url:
return False
try:
async with httpx.AsyncClient(timeout=10.0, follow_redirects=True) as client:
response = await client.get(self.base_url)
response.raise_for_status()
return True
except httpx.HTTPError:
return False
async def get_torrents(self) -> Optional[Any]:
return await self._get("/api/v2/torrents/info")
+26 -3
View File
@@ -12,7 +12,7 @@ class Settings(BaseSettings):
sqlite_journal_mode: str = Field(
default="DELETE", validation_alias=AliasChoices("SQLITE_JOURNAL_MODE")
)
jwt_secret: str = Field(default="change-me", validation_alias=AliasChoices("JWT_SECRET"))
jwt_secret: str = Field(default="", validation_alias=AliasChoices("JWT_SECRET"))
jwt_exp_minutes: int = Field(default=720, validation_alias=AliasChoices("JWT_EXP_MINUTES"))
api_docs_enabled: bool = Field(default=False, validation_alias=AliasChoices("API_DOCS_ENABLED"))
auth_rate_limit_window_seconds: int = Field(
@@ -34,7 +34,22 @@ class Settings(BaseSettings):
default=3, validation_alias=AliasChoices("PASSWORD_RESET_RATE_LIMIT_MAX_ATTEMPTS_IDENTIFIER")
)
admin_username: str = Field(default="admin", validation_alias=AliasChoices("ADMIN_USERNAME"))
admin_password: str = Field(default="adminadmin", validation_alias=AliasChoices("ADMIN_PASSWORD"))
admin_password: str = Field(default="", validation_alias=AliasChoices("ADMIN_PASSWORD"))
auth_cookie_name: str = Field(
default="magent_auth", validation_alias=AliasChoices("AUTH_COOKIE_NAME")
)
auth_cookie_secure: bool = Field(
default=False, validation_alias=AliasChoices("AUTH_COOKIE_SECURE")
)
auth_cookie_samesite: str = Field(
default="lax", validation_alias=AliasChoices("AUTH_COOKIE_SAMESITE")
)
auth_cookie_domain: Optional[str] = Field(
default=None, validation_alias=AliasChoices("AUTH_COOKIE_DOMAIN")
)
auth_state_cookie_name: str = Field(
default="magent_logged_in", validation_alias=AliasChoices("AUTH_STATE_COOKIE_NAME")
)
log_level: str = Field(default="INFO", validation_alias=AliasChoices("LOG_LEVEL"))
log_file: str = Field(default="data/magent.log", validation_alias=AliasChoices("LOG_FILE"))
log_file_max_bytes: int = Field(
@@ -121,6 +136,10 @@ class Settings(BaseSettings):
magent_proxy_trust_forwarded_headers: bool = Field(
default=True, validation_alias=AliasChoices("MAGENT_PROXY_TRUST_FORWARDED_HEADERS")
)
magent_proxy_trusted_proxies: str = Field(
default="127.0.0.1,::1",
validation_alias=AliasChoices("MAGENT_PROXY_TRUSTED_PROXIES"),
)
magent_proxy_forwarded_prefix: Optional[str] = Field(
default=None, validation_alias=AliasChoices("MAGENT_PROXY_FORWARDED_PREFIX")
)
@@ -216,6 +235,10 @@ class Settings(BaseSettings):
magent_notify_webhook_url: Optional[str] = Field(
default=None, validation_alias=AliasChoices("MAGENT_NOTIFY_WEBHOOK_URL")
)
magent_allow_private_notification_targets: bool = Field(
default=False,
validation_alias=AliasChoices("MAGENT_ALLOW_PRIVATE_NOTIFICATION_TARGETS"),
)
jellyseerr_base_url: Optional[str] = Field(
default=None, validation_alias=AliasChoices("JELLYSEERR_URL", "JELLYSEERR_BASE_URL")
@@ -288,7 +311,7 @@ class Settings(BaseSettings):
)
discord_webhook_url: Optional[str] = Field(
default="https://discord.com/api/webhooks/1464141924775629033/O_rvCAmIKowR04tyAN54IuMPcQFEiT-ustU3udDaMTlF62PmoI6w4-52H3ZQcjgHQOgt",
default=None,
validation_alias=AliasChoices("DISCORD_WEBHOOK_URL"),
)
+26 -7
View File
@@ -21,6 +21,8 @@ SQLITE_BUSY_TIMEOUT_MS = 5_000
SQLITE_CACHE_SIZE_KIB = 32_768
SQLITE_MMAP_SIZE_BYTES = 256 * 1024 * 1024
_DB_UNSET = object()
_DEFAULT_JWT_SECRET = "change-me"
_DEFAULT_ADMIN_PASSWORD = "adminadmin"
def _db_path() -> str:
@@ -178,6 +180,11 @@ def _normalize_stored_email(value: Optional[Any]) -> Optional[str]:
return candidate
def _has_secure_bootstrap_admin_credentials() -> bool:
password = str(settings.admin_password or "")
return bool(password and password != _DEFAULT_ADMIN_PASSWORD)
def init_db() -> None:
with _connect() as conn:
conn.execute(
@@ -411,12 +418,16 @@ def init_db() -> None:
ON requests_cache (updated_at DESC, request_id DESC)
"""
)
conn.execute(
"""
CREATE INDEX IF NOT EXISTS idx_requests_cache_requested_by_id_created_at
ON requests_cache (requested_by_id, created_at DESC, request_id DESC)
"""
)
try:
conn.execute(
"""
CREATE INDEX IF NOT EXISTS idx_requests_cache_requested_by_id_created_at
ON requests_cache (requested_by_id, created_at DESC, request_id DESC)
"""
)
except sqlite3.OperationalError:
# Older databases may not have requested_by_id until later migrations run.
pass
conn.execute(
"""
CREATE INDEX IF NOT EXISTS idx_requests_cache_requested_by_norm_created_at
@@ -767,7 +778,7 @@ def get_recent_actions(request_id: str, limit: int = 10) -> list[dict[str, Any]]
def ensure_admin_user() -> None:
if not settings.admin_username or not settings.admin_password:
if not settings.admin_username or not _has_secure_bootstrap_admin_credentials():
return
existing = get_user_by_username(settings.admin_username)
if existing:
@@ -775,6 +786,14 @@ def ensure_admin_user() -> None:
create_user(settings.admin_username, settings.admin_password, role="admin")
def has_admin_user() -> bool:
with _connect() as conn:
row = conn.execute(
"SELECT 1 FROM users WHERE LOWER(role) = 'admin' LIMIT 1"
).fetchone()
return bool(row)
def create_user(
username: str,
password: str,
+19 -5
View File
@@ -8,7 +8,7 @@ from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware
from .config import settings
from .db import init_db
from .db import has_admin_user, init_db
from .routers.requests import (
router as requests_router,
startup_warmup_requests_cache,
@@ -165,13 +165,15 @@ def _launch_background_task(name: str, coroutine_factory: Callable[[], Awaitable
def _log_security_configuration_warnings() -> None:
if str(settings.jwt_secret or "").strip() == "change-me":
jwt_secret = str(settings.jwt_secret or "").strip()
if not jwt_secret or jwt_secret == "change-me":
logger.warning(
"security configuration warning: JWT_SECRET is still set to the default value"
"security configuration warning: JWT_SECRET is unset or still set to the default value"
)
if str(settings.admin_password or "") == "adminadmin":
admin_password = str(settings.admin_password or "")
if not admin_password or admin_password == "adminadmin":
logger.warning(
"security configuration warning: ADMIN_PASSWORD is still set to the bootstrap default"
"security configuration warning: ADMIN_PASSWORD is unset or still set to the bootstrap default"
)
if bool(settings.api_docs_enabled):
logger.warning(
@@ -179,6 +181,17 @@ def _log_security_configuration_warnings() -> None:
)
def _enforce_secure_startup_configuration() -> None:
jwt_secret = str(settings.jwt_secret or "").strip()
if not jwt_secret or jwt_secret == "change-me":
raise RuntimeError("JWT_SECRET must be set to a strong, non-default value before startup.")
admin_password = str(settings.admin_password or "")
if not has_admin_user() and (not admin_password or admin_password == "adminadmin"):
raise RuntimeError(
"A secure ADMIN_PASSWORD is required on first startup until an admin account exists."
)
@app.on_event("startup")
async def startup() -> None:
configure_logging(
@@ -192,6 +205,7 @@ async def startup() -> None:
logger.info("startup begin app=%s build=%s", settings.app_name, settings.site_build_number)
_log_security_configuration_warnings()
init_db()
_enforce_secure_startup_configuration()
runtime = get_runtime_settings()
configure_logging(
runtime.log_level,
+132
View File
@@ -0,0 +1,132 @@
from __future__ import annotations
import ipaddress
import socket
from functools import lru_cache
from typing import Iterable
from urllib.parse import urlparse
from .config import settings
_METADATA_HOSTS = {
"169.254.169.254",
"metadata.google.internal",
"metadata.azure.internal",
}
def _normalize_text(value: object) -> str:
if value is None:
return ""
return str(value).strip()
def _split_csv(value: object) -> list[str]:
raw = _normalize_text(value)
if not raw:
return []
return [part.strip() for part in raw.split(",") if part.strip()]
def _ip_is_sensitive(ip_obj: ipaddress._BaseAddress) -> bool:
return bool(
ip_obj.is_loopback
or ip_obj.is_link_local
or ip_obj.is_multicast
or ip_obj.is_unspecified
or ip_obj.is_reserved
or ip_obj.is_private
)
@lru_cache(maxsize=256)
def _resolve_host_ips(host: str) -> tuple[ipaddress._BaseAddress, ...]:
resolved: list[ipaddress._BaseAddress] = []
for family, _, _, _, sockaddr in socket.getaddrinfo(host, None):
if family == socket.AF_INET:
resolved.append(ipaddress.ip_address(sockaddr[0]))
elif family == socket.AF_INET6:
resolved.append(ipaddress.ip_address(sockaddr[0]))
return tuple(resolved)
def _is_trusted_proxy_host(host: str, trusted_proxies: Iterable[str]) -> bool:
candidate = _normalize_text(host)
if not candidate:
return False
try:
host_ip = ipaddress.ip_address(candidate)
except ValueError:
return candidate.lower() in {entry.lower() for entry in trusted_proxies}
for entry in trusted_proxies:
raw = _normalize_text(entry)
if not raw:
continue
try:
if "/" in raw:
if host_ip in ipaddress.ip_network(raw, strict=False):
return True
elif host_ip == ipaddress.ip_address(raw):
return True
except ValueError:
continue
return False
def request_trusts_forwarded_headers(client_host: str | None) -> bool:
if not settings.magent_proxy_enabled or not settings.magent_proxy_trust_forwarded_headers:
return False
trusted = _split_csv(settings.magent_proxy_trusted_proxies)
if not trusted:
return False
return _is_trusted_proxy_host(client_host or "", trusted)
def validate_notification_target_url(
url: str,
*,
allow_private: bool | None = None,
) -> str:
raw = _normalize_text(url)
if not raw:
raise ValueError("URL cannot be empty.")
parsed = urlparse(raw)
if parsed.scheme not in {"http", "https"}:
raise ValueError("URL must use http:// or https://.")
if parsed.username or parsed.password:
raise ValueError("URL must not embed credentials.")
hostname = _normalize_text(parsed.hostname).lower()
if not hostname:
raise ValueError("URL must include a valid host.")
allow_private_targets = (
settings.magent_allow_private_notification_targets
if allow_private is None
else bool(allow_private)
)
if hostname in _METADATA_HOSTS:
raise ValueError("Metadata service targets are not allowed.")
if hostname == "localhost" and not allow_private_targets:
raise ValueError("Local notification targets are not allowed.")
try:
host_ip = ipaddress.ip_address(hostname)
except ValueError:
host_ip = None
if host_ip is not None:
if _ip_is_sensitive(host_ip) and not allow_private_targets:
raise ValueError("Private or local notification targets are not allowed.")
return raw
try:
resolved_ips = _resolve_host_ips(hostname)
except socket.gaierror as exc:
raise ValueError("Host could not be resolved.") from exc
if not resolved_ips:
raise ValueError("Host could not be resolved.")
if not allow_private_targets and any(_ip_is_sensitive(ip_obj) for ip_obj in resolved_ips):
raise ValueError("Private or local notification targets are not allowed.")
return raw
+13
View File
@@ -20,6 +20,7 @@ from ..auth import (
resolve_user_auth_provider,
)
from ..config import settings as env_settings
from ..network_security import validate_notification_target_url
from ..db import (
delete_setting,
get_all_users,
@@ -153,6 +154,12 @@ URL_SETTING_KEYS = {
"qbittorrent_base_url",
}
NOTIFICATION_URL_SETTING_KEYS = {
"magent_notify_discord_webhook_url",
"magent_notify_push_base_url",
"magent_notify_webhook_url",
}
SETTING_KEYS: List[str] = [
"magent_application_url",
"magent_application_port",
@@ -659,6 +666,12 @@ async def update_settings(payload: Dict[str, Any]) -> Dict[str, Any]:
except ValueError as exc:
friendly_key = key.replace("_", " ")
raise HTTPException(status_code=400, detail=f"{friendly_key}: {exc}") from exc
if key in NOTIFICATION_URL_SETTING_KEYS and value_to_store:
try:
value_to_store = validate_notification_target_url(value_to_store)
except ValueError as exc:
friendly_key = key.replace("_", " ")
raise HTTPException(status_code=400, detail=f"{friendly_key}: {exc}") from exc
set_setting(key, value_to_store)
updates += 1
changed_keys.append(key)
+81 -45
View File
@@ -7,7 +7,7 @@ import time
from threading import Lock
import httpx
from fastapi import APIRouter, HTTPException, status, Depends, Request
from fastapi import APIRouter, HTTPException, status, Depends, Request, Response
from fastapi.security import OAuth2PasswordRequestForm
from ..db import (
@@ -47,8 +47,15 @@ from ..security import (
verify_password,
)
from ..security import create_stream_token
from ..auth import get_current_user, normalize_user_auth_provider, resolve_user_auth_provider
from ..auth import (
clear_auth_cookies,
get_current_user,
normalize_user_auth_provider,
resolve_user_auth_provider,
set_auth_cookies,
)
from ..config import settings
from ..network_security import request_trusts_forwarded_headers
from ..services.user_cache import (
build_jellyseerr_candidate_map,
extract_jellyseerr_user_email,
@@ -96,12 +103,14 @@ def _require_recipient_email(value: object) -> str:
def _auth_client_ip(request: Request) -> str:
forwarded = request.headers.get("x-forwarded-for")
if isinstance(forwarded, str) and forwarded.strip():
return forwarded.split(",", 1)[0].strip()
real = request.headers.get("x-real-ip")
if isinstance(real, str) and real.strip():
return real.strip()
direct_host = request.client.host if request.client else None
if request_trusts_forwarded_headers(direct_host):
forwarded = request.headers.get("x-forwarded-for")
if isinstance(forwarded, str) and forwarded.strip():
return forwarded.split(",", 1)[0].strip()
real = request.headers.get("x-real-ip")
if isinstance(real, str) and real.strip():
return real.strip()
if request.client and request.client.host:
return str(request.client.host)
return "unknown"
@@ -358,6 +367,15 @@ def _assert_user_can_login(user: dict | None) -> None:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="User access has expired")
def _auth_success_response(response: Response, token: str, user_payload: dict) -> dict:
set_auth_cookies(response, token)
return {
"authenticated": True,
"token_type": "cookie",
"user": user_payload,
}
def _public_invite_payload(invite: dict, profile: dict | None = None) -> dict:
return {
"code": invite.get("code"),
@@ -580,7 +598,11 @@ def _master_invite_controlled_values(master_invite: dict) -> tuple[int | None, s
@router.post("/login")
async def login(request: Request, form_data: OAuth2PasswordRequestForm = Depends()) -> dict:
async def login(
request: Request,
response: Response,
form_data: OAuth2PasswordRequestForm = Depends(),
) -> dict:
_enforce_login_rate_limit(request, form_data.username)
logger.info(
"login attempt provider=local username=%s client=%s",
@@ -629,15 +651,19 @@ async def login(request: Request, form_data: OAuth2PasswordRequestForm = Depends
user["role"],
_auth_client_ip(request),
)
return {
"access_token": token,
"token_type": "bearer",
"user": {"username": user["username"], "role": user["role"]},
}
return _auth_success_response(
response,
token,
{"username": user["username"], "role": user["role"]},
)
@router.post("/jellyfin/login")
async def jellyfin_login(request: Request, form_data: OAuth2PasswordRequestForm = Depends()) -> dict:
async def jellyfin_login(
request: Request,
response: Response,
form_data: OAuth2PasswordRequestForm = Depends(),
) -> dict:
_enforce_login_rate_limit(request, form_data.username)
logger.info(
"login attempt provider=jellyfin username=%s client=%s",
@@ -668,13 +694,13 @@ async def jellyfin_login(request: Request, form_data: OAuth2PasswordRequestForm
canonical_username,
_auth_client_ip(request),
)
return {
"access_token": token,
"token_type": "bearer",
"user": {"username": canonical_username, "role": "user"},
}
return _auth_success_response(
response,
token,
{"username": canonical_username, "role": "user"},
)
try:
response = await client.authenticate_by_name(username, password)
auth_response = await client.authenticate_by_name(username, password)
except Exception as exc:
logger.exception(
"login upstream error provider=jellyfin username=%s client=%s",
@@ -682,7 +708,7 @@ async def jellyfin_login(request: Request, form_data: OAuth2PasswordRequestForm
_auth_client_ip(request),
)
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(exc)) from exc
if not isinstance(response, dict) or not response.get("User"):
if not isinstance(auth_response, dict) or not auth_response.get("User"):
_record_login_failure(request, username)
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid Jellyfin credentials")
if not preferred_match:
@@ -724,16 +750,20 @@ async def jellyfin_login(request: Request, form_data: OAuth2PasswordRequestForm
get_user_by_username(canonical_username).get("jellyseerr_user_id") if get_user_by_username(canonical_username) else None,
_auth_client_ip(request),
)
return {
"access_token": token,
"token_type": "bearer",
"user": {"username": canonical_username, "role": "user"},
}
return _auth_success_response(
response,
token,
{"username": canonical_username, "role": "user"},
)
@router.post("/seerr/login")
@router.post("/jellyseerr/login")
async def jellyseerr_login(request: Request, form_data: OAuth2PasswordRequestForm = Depends()) -> dict:
async def jellyseerr_login(
request: Request,
response: Response,
form_data: OAuth2PasswordRequestForm = Depends(),
) -> dict:
_enforce_login_rate_limit(request, form_data.username)
logger.info(
"login attempt provider=seerr username=%s client=%s",
@@ -745,7 +775,7 @@ async def jellyseerr_login(request: Request, form_data: OAuth2PasswordRequestFor
if not client.configured():
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Seerr not configured")
try:
response = await client.login_local(form_data.username, form_data.password)
auth_response = await client.login_local(form_data.username, form_data.password)
except Exception as exc:
logger.exception(
"login upstream error provider=seerr username=%s client=%s",
@@ -753,11 +783,11 @@ async def jellyseerr_login(request: Request, form_data: OAuth2PasswordRequestFor
_auth_client_ip(request),
)
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(exc)) from exc
if not isinstance(response, dict):
if not isinstance(auth_response, dict):
_record_login_failure(request, form_data.username)
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid Seerr credentials")
jellyseerr_user_id = _extract_jellyseerr_user_id(response)
jellyseerr_email = _extract_jellyseerr_response_email(response)
jellyseerr_user_id = _extract_jellyseerr_user_id(auth_response)
jellyseerr_email = _extract_jellyseerr_response_email(auth_response)
ci_matches = get_users_by_username_ci(form_data.username)
preferred_match = _pick_preferred_ci_user_match(ci_matches, form_data.username)
canonical_username = str(preferred_match.get("username") or form_data.username) if preferred_match else form_data.username
@@ -791,11 +821,11 @@ async def jellyseerr_login(request: Request, form_data: OAuth2PasswordRequestFor
jellyseerr_user_id,
_auth_client_ip(request),
)
return {
"access_token": token,
"token_type": "bearer",
"user": {"username": canonical_username, "role": "user"},
}
return _auth_success_response(
response,
token,
{"username": canonical_username, "role": "user"},
)
@router.get("/me")
@@ -803,6 +833,12 @@ async def me(current_user: dict = Depends(get_current_user)) -> dict:
return current_user
@router.post("/logout")
async def logout(response: Response) -> dict:
clear_auth_cookies(response)
return {"status": "ok"}
@router.get("/stream-token")
async def stream_token(current_user: dict = Depends(get_current_user)) -> dict:
token = create_stream_token(
@@ -832,7 +868,7 @@ async def invite_details(code: str) -> dict:
@router.post("/signup")
async def signup(payload: dict) -> dict:
async def signup(payload: dict, response: Response) -> dict:
if not isinstance(payload, dict):
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid payload")
invite_code = str(payload.get("invite_code") or "").strip()
@@ -908,14 +944,14 @@ async def signup(payload: dict) -> dict:
duplicate_like = status_code in {400, 409}
if duplicate_like:
try:
response = await jellyfin_client.authenticate_by_name(username, password_value)
auth_response = await jellyfin_client.authenticate_by_name(username, password_value)
except Exception as auth_exc:
detail = _extract_http_error_detail(auth_exc) or _extract_http_error_detail(exc)
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"Jellyfin account already exists and could not be authenticated: {detail}",
) from exc
if not isinstance(response, dict) or not response.get("User"):
if not isinstance(auth_response, dict) or not auth_response.get("User"):
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Jellyfin account already exists for that username.",
@@ -987,17 +1023,17 @@ async def signup(payload: dict) -> dict:
created_user.get("profile_id") if created_user else None,
invite.get("code"),
)
return {
"access_token": token,
"token_type": "bearer",
"user": {
return _auth_success_response(
response,
token,
{
"username": username,
"role": role,
"auth_provider": created_user.get("auth_provider") if created_user else auth_provider,
"profile_id": created_user.get("profile_id") if created_user else None,
"expires_at": created_user.get("expires_at") if created_user else None,
},
}
)
@router.post("/password/forgot")
+5
View File
@@ -3,6 +3,7 @@ import httpx
from fastapi import APIRouter, Depends, HTTPException
from ..auth import get_current_user
from ..network_security import validate_notification_target_url
from ..runtime import get_runtime_settings
router = APIRouter(prefix="/feedback", tags=["feedback"], dependencies=[Depends(get_current_user)])
@@ -17,6 +18,10 @@ async def send_feedback(payload: Dict[str, Any], user: Dict[str, str] = Depends(
)
if not webhook_url:
raise HTTPException(status_code=400, detail="Discord webhook not configured")
try:
webhook_url = validate_notification_target_url(webhook_url)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
feedback_type = str(payload.get("type") or "").strip().lower()
if feedback_type not in {"bug", "feature"}:
+33 -8
View File
@@ -26,6 +26,35 @@ async def _check(name: str, configured: bool, func) -> Dict[str, Any]:
return {"name": name, "status": "down", "message": str(exc)}
async def _check_qbittorrent(qbittorrent: QBittorrentClient) -> Dict[str, Any]:
if not qbittorrent.base_url:
return {"name": "qBittorrent", "status": "not_configured"}
if not qbittorrent.username or not qbittorrent.password:
reachable = await qbittorrent.is_webui_reachable()
return {
"name": "qBittorrent",
"status": "degraded" if reachable else "not_configured",
"message": "qBittorrent credentials are incomplete" if reachable else "qBittorrent is not fully configured",
}
try:
result = await qbittorrent.get_app_version()
return {"name": "qBittorrent", "status": "up", "detail": result}
except RuntimeError as exc:
if "login failed" in str(exc).lower():
reachable = await qbittorrent.is_webui_reachable()
if reachable:
return {
"name": "qBittorrent",
"status": "degraded",
"message": "qBittorrent is reachable but the saved credentials were rejected",
}
return {"name": "qBittorrent", "status": "down", "message": str(exc)}
except httpx.HTTPError as exc:
return {"name": "qBittorrent", "status": "down", "message": str(exc)}
except Exception as exc:
return {"name": "qBittorrent", "status": "down", "message": str(exc)}
@router.get("/services")
async def services_status() -> Dict[str, Any]:
runtime = get_runtime_settings()
@@ -71,13 +100,7 @@ async def services_status() -> Dict[str, Any]:
prowlarr_status["status"] = "degraded"
prowlarr_status["message"] = "Health warnings"
services.append(prowlarr_status)
services.append(
await _check(
"qBittorrent",
qbittorrent.configured(),
qbittorrent.get_app_version,
)
)
services.append(await _check_qbittorrent(qbittorrent))
services.append(
await _check(
"Jellyfin",
@@ -122,10 +145,12 @@ async def test_service(service: str) -> Dict[str, Any]:
"sonarr": ("Sonarr", sonarr.configured(), sonarr.get_system_status),
"radarr": ("Radarr", radarr.configured(), radarr.get_system_status),
"prowlarr": ("Prowlarr", prowlarr.configured(), prowlarr.get_health),
"qbittorrent": ("qBittorrent", qbittorrent.configured(), qbittorrent.get_app_version),
"jellyfin": ("Jellyfin", jellyfin.configured(), jellyfin.get_system_info),
}
if service_key == "qbittorrent":
return await _check_qbittorrent(qbittorrent)
if service_key not in checks:
raise HTTPException(status_code=404, detail="Unknown service")
+4
View File
@@ -44,6 +44,8 @@ def _create_token(
return jwt.encode(payload, settings.jwt_secret, algorithm=_ALGORITHM)
def create_access_token(subject: str, role: str, expires_minutes: Optional[int] = None) -> str:
if not settings.jwt_secret:
raise ValueError("JWT_SECRET is not configured")
minutes = expires_minutes or settings.jwt_exp_minutes
expires = datetime.now(timezone.utc) + timedelta(minutes=minutes)
return _create_token(subject, role, expires_at=expires, token_type="access")
@@ -55,6 +57,8 @@ def create_stream_token(subject: str, role: str, expires_seconds: int = 120) ->
def decode_token(token: str) -> Dict[str, Any]:
if not settings.jwt_secret:
raise ValueError("JWT_SECRET is not configured")
return jwt.decode(token, settings.jwt_secret, algorithms=[_ALGORITHM])
+32 -5
View File
@@ -17,6 +17,7 @@ from ..clients.radarr import RadarrClient
from ..clients.sonarr import SonarrClient
from ..config import settings as env_settings
from ..db import get_database_diagnostics
from ..network_security import validate_notification_target_url
from ..runtime import get_runtime_settings
from .invite_email import send_test_email, smtp_email_config_ready, smtp_email_delivery_warning
@@ -97,7 +98,12 @@ def _config_status(detail: str) -> str:
def _discord_config_ready(runtime) -> tuple[bool, str]:
if not runtime.magent_notify_enabled or not runtime.magent_notify_discord_enabled:
return False, "Discord notifications are disabled."
if _clean_text(runtime.magent_notify_discord_webhook_url) or _clean_text(runtime.discord_webhook_url):
webhook_url = _clean_text(runtime.magent_notify_discord_webhook_url) or _clean_text(runtime.discord_webhook_url)
if webhook_url:
try:
validate_notification_target_url(webhook_url)
except ValueError as exc:
return False, str(exc)
return True, "ok"
return False, "Discord webhook URL is required."
@@ -113,7 +119,12 @@ def _telegram_config_ready(runtime) -> tuple[bool, str]:
def _webhook_config_ready(runtime) -> tuple[bool, str]:
if not runtime.magent_notify_enabled or not runtime.magent_notify_webhook_enabled:
return False, "Generic webhook notifications are disabled."
if _clean_text(runtime.magent_notify_webhook_url):
webhook_url = _clean_text(runtime.magent_notify_webhook_url)
if webhook_url:
try:
validate_notification_target_url(webhook_url)
except ValueError as exc:
return False, str(exc)
return True, "ok"
return False, "Generic webhook URL is required."
@@ -123,11 +134,21 @@ def _push_config_ready(runtime) -> tuple[bool, str]:
return False, "Push notifications are disabled."
provider = _clean_text(runtime.magent_notify_push_provider, "ntfy").lower()
if provider == "ntfy":
if _clean_text(runtime.magent_notify_push_base_url) and _clean_text(runtime.magent_notify_push_topic):
push_url = _clean_text(runtime.magent_notify_push_base_url)
if push_url and _clean_text(runtime.magent_notify_push_topic):
try:
validate_notification_target_url(push_url)
except ValueError as exc:
return False, str(exc)
return True, "ok"
return False, "ntfy requires a base URL and topic."
if provider == "gotify":
if _clean_text(runtime.magent_notify_push_base_url) and _clean_text(runtime.magent_notify_push_token):
push_url = _clean_text(runtime.magent_notify_push_base_url)
if push_url and _clean_text(runtime.magent_notify_push_token):
try:
validate_notification_target_url(push_url)
except ValueError as exc:
return False, str(exc)
return True, "ok"
return False, "Gotify requires a base URL and app token."
if provider == "pushover":
@@ -135,7 +156,12 @@ def _push_config_ready(runtime) -> tuple[bool, str]:
return True, "ok"
return False, "Pushover requires an application token and user key."
if provider == "webhook":
if _clean_text(runtime.magent_notify_push_base_url):
push_url = _clean_text(runtime.magent_notify_push_base_url)
if push_url:
try:
validate_notification_target_url(push_url)
except ValueError as exc:
return False, str(exc)
return True, "ok"
return False, "Webhook relay requires a target URL."
if provider == "telegram":
@@ -190,6 +216,7 @@ async def _run_http_post(
params: Optional[Dict[str, Any]] = None,
headers: Optional[Dict[str, str]] = None,
) -> Dict[str, Any]:
validate_notification_target_url(url)
async with httpx.AsyncClient(timeout=15.0, follow_redirects=True) as client:
response = await client.post(url, json=json_payload, data=data_payload, params=params, headers=headers)
response.raise_for_status()
+4
View File
@@ -8,6 +8,7 @@ import httpx
from ..config import settings as env_settings
from ..db import get_setting
from ..network_security import validate_notification_target_url
from ..runtime import get_runtime_settings
from .invite_email import send_generic_email
@@ -49,6 +50,7 @@ def _portal_item_url(item_id: int) -> str:
async def _http_post_json(url: str, payload: Dict[str, Any]) -> Dict[str, Any]:
validate_notification_target_url(url)
async with httpx.AsyncClient(timeout=12.0) as client:
response = await client.post(url, json=payload)
response.raise_for_status()
@@ -115,6 +117,7 @@ async def _send_push(title: str, message: str, payload: Dict[str, Any]) -> Dict[
if provider == "ntfy":
if not base_url or not topic:
return {"status": "skipped", "detail": "ntfy needs base URL and topic."}
validate_notification_target_url(base_url)
url = f"{base_url.rstrip('/')}/{quote(topic)}"
headers = {"Title": title, "Tags": "magent,portal"}
async with httpx.AsyncClient(timeout=12.0) as client:
@@ -124,6 +127,7 @@ async def _send_push(title: str, message: str, payload: Dict[str, Any]) -> Dict[
if provider == "gotify":
if not base_url or not token:
return {"status": "skipped", "detail": "Gotify needs base URL and token."}
validate_notification_target_url(base_url)
url = f"{base_url.rstrip('/')}/message?token={quote(token)}"
body = {"title": title, "message": message, "priority": 5, "extras": {"client::display": {"contentType": "text/plain"}}}
result = await _http_post_json(url, body)
+45
View File
@@ -8,8 +8,10 @@ from starlette.requests import Request
from backend.app import db
from backend.app.config import settings
from backend.app.network_security import request_trusts_forwarded_headers, validate_notification_target_url
from backend.app.routers import auth as auth_router
from backend.app.routers import portal as portal_router
from backend.app.routers import status as status_router
from backend.app.security import PASSWORD_POLICY_MESSAGE, validate_password_policy
from backend.app.services import password_reset
@@ -72,6 +74,49 @@ class PasswordPolicyTests(unittest.TestCase):
self.assertEqual(validate_password_policy(" password123 "), "password123")
class NetworkSecurityTests(unittest.TestCase):
def test_notification_targets_reject_loopback(self) -> None:
with self.assertRaisesRegex(ValueError, "Private or local notification targets are not allowed."):
validate_notification_target_url("http://127.0.0.1:8080/webhook")
def test_forwarded_headers_require_trusted_proxy(self) -> None:
original_enabled = settings.magent_proxy_enabled
original_trust = settings.magent_proxy_trust_forwarded_headers
original_proxies = settings.magent_proxy_trusted_proxies
settings.magent_proxy_enabled = True
settings.magent_proxy_trust_forwarded_headers = True
settings.magent_proxy_trusted_proxies = "127.0.0.1,::1"
try:
self.assertTrue(request_trusts_forwarded_headers("127.0.0.1"))
self.assertFalse(request_trusts_forwarded_headers("203.0.113.10"))
finally:
settings.magent_proxy_enabled = original_enabled
settings.magent_proxy_trust_forwarded_headers = original_trust
settings.magent_proxy_trusted_proxies = original_proxies
class ServiceStatusTests(unittest.IsolatedAsyncioTestCase):
async def test_qbittorrent_incomplete_credentials_report_degraded_when_reachable(self) -> None:
client = status_router.QBittorrentClient("http://10.0.0.2:8080", "admin", None)
with patch.object(client, "is_webui_reachable", new=AsyncMock(return_value=True)):
result = await status_router._check_qbittorrent(client)
self.assertEqual(result["status"], "degraded")
self.assertIn("credentials", result["message"].lower())
async def test_qbittorrent_rejected_credentials_report_degraded_when_reachable(self) -> None:
client = status_router.QBittorrentClient("http://10.0.0.2:8080", "admin", "secret")
with patch.object(
client,
"get_app_version",
new=AsyncMock(side_effect=RuntimeError("qBittorrent login failed")),
), patch.object(client, "is_webui_reachable", new=AsyncMock(return_value=True)):
result = await status_router._check_qbittorrent(client)
self.assertEqual(result["status"], "degraded")
self.assertIn("credentials", result["message"].lower())
class DatabaseEmailTests(TempDatabaseMixin, unittest.TestCase):
def test_set_user_email_is_case_insensitive(self) -> None:
created = db.create_user_if_missing(
+12 -11
View File
@@ -2,7 +2,7 @@
import { useEffect, useState } from 'react'
import { useRouter } from 'next/navigation'
import { authFetch, clearToken, getApiBase, getToken } from '../lib/auth'
import { authFetchOrThrow, getApiBase, getToken, UnauthorizedError } from '../lib/auth'
type Profile = {
username?: string
@@ -24,15 +24,17 @@ export default function FeedbackPage() {
const load = async () => {
try {
const baseUrl = getApiBase()
const response = await authFetch(`${baseUrl}/auth/me`)
const response = await authFetchOrThrow(`${baseUrl}/auth/me`)
if (!response.ok) {
clearToken()
router.push('/login')
return
throw new Error('Could not load profile.')
}
const data = await response.json()
setProfile({ username: data?.username })
} catch (error) {
if (error instanceof UnauthorizedError) {
router.push('/login')
return
}
console.error(error)
}
}
@@ -49,7 +51,7 @@ export default function FeedbackPage() {
setSubmitting(true)
try {
const baseUrl = getApiBase()
const response = await authFetch(`${baseUrl}/feedback`, {
const response = await authFetchOrThrow(`${baseUrl}/feedback`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
@@ -58,17 +60,16 @@ export default function FeedbackPage() {
}),
})
if (!response.ok) {
if (response.status === 401) {
clearToken()
router.push('/login')
return
}
const text = await response.text()
throw new Error(text || `Request failed: ${response.status}`)
}
setMessage('')
setStatus('Thanks! Your message has been sent.')
} catch (error) {
if (error instanceof UnauthorizedError) {
router.push('/login')
return
}
console.error(error)
setStatus('That did not send. Please try again.')
} finally {
+24 -1
View File
@@ -3565,12 +3565,14 @@ button:disabled {
.user-grid-pill.is-blocked {
background: rgba(244, 114, 114, 0.14);
border-color: rgba(244, 114, 114, 0.24);
color: #ffd5d5;
}
.system-pill-degraded,
.user-grid-pill.is-disabled {
background: rgba(208, 166, 92, 0.14);
border-color: rgba(208, 166, 92, 0.22);
color: #ffe3a6;
}
.system-dot {
@@ -6565,6 +6567,27 @@ textarea {
gap: 16px;
}
.portal-workspace-switch {
display: inline-flex;
gap: 8px;
align-items: center;
}
.portal-workspace-switch button {
border: 1px solid var(--line);
border-radius: 10px;
background: var(--panel-soft);
color: var(--text);
padding: 8px 12px;
font-weight: 600;
}
.portal-workspace-switch button.is-active {
border-color: var(--accent);
box-shadow: 0 0 0 1px rgba(107, 146, 255, 0.25);
background: rgba(107, 146, 255, 0.12);
}
.portal-overview-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
@@ -6689,7 +6712,7 @@ textarea {
.portal-toolbar {
display: grid;
grid-template-columns: 160px 180px minmax(0, 1fr) auto;
grid-template-columns: 180px minmax(0, 1fr) auto;
gap: 10px;
align-items: end;
}
+72 -12
View File
@@ -1,27 +1,53 @@
const AUTH_STATE_COOKIE = 'magent_logged_in'
export const getApiBase = () => process.env.NEXT_PUBLIC_API_BASE ?? '/api'
export const getToken = () => {
if (typeof window === 'undefined') return null
return window.localStorage.getItem('magent_token')
const setCookie = (name: string, value: string, maxAgeSeconds: number) => {
if (typeof document === 'undefined') return
document.cookie = `${name}=${value}; Max-Age=${maxAgeSeconds}; Path=/; SameSite=Lax`
}
export const setToken = (token: string) => {
if (typeof window === 'undefined') return
window.localStorage.setItem('magent_token', token)
const clearCookie = (name: string) => {
if (typeof document === 'undefined') return
document.cookie = `${name}=; Max-Age=0; Path=/; SameSite=Lax`
}
export const getToken = () => {
if (typeof document === 'undefined') return null
const cookies = document.cookie.split(';').map((entry) => entry.trim())
const marker = cookies.find((entry) => entry.startsWith(`${AUTH_STATE_COOKIE}=`))
if (!marker) return null
const [, value] = marker.split('=', 2)
return value || null
}
export const setToken = (_token: string) => {
setCookie(AUTH_STATE_COOKIE, '1', 60 * 60 * 12)
}
export const clearToken = () => {
clearCookie(AUTH_STATE_COOKIE)
if (typeof window === 'undefined') return
window.localStorage.removeItem('magent_token')
const baseUrl = getApiBase()
void fetch(`${baseUrl}/auth/logout`, {
method: 'POST',
credentials: 'include',
keepalive: true,
}).catch(() => undefined)
}
export const logout = async () => {
const baseUrl = getApiBase()
clearCookie(AUTH_STATE_COOKIE)
await fetch(`${baseUrl}/auth/logout`, {
method: 'POST',
credentials: 'include',
})
}
export const authFetch = (input: RequestInfo | URL, init?: RequestInit) => {
const token = getToken()
const headers = new Headers(init?.headers || {})
if (token) {
headers.set('Authorization', `Bearer ${token}`)
}
return fetch(input, { ...init, headers })
return fetch(input, { ...init, headers, credentials: 'include' })
}
export const getEventStreamToken = async () => {
@@ -38,3 +64,37 @@ export const getEventStreamToken = async () => {
}
return token
}
export class UnauthorizedError extends Error {
constructor() {
super('Unauthorized')
this.name = 'UnauthorizedError'
}
}
export class ForbiddenError extends Error {
constructor() {
super('Forbidden')
this.name = 'ForbiddenError'
}
}
export const authFetchOrThrow = async (input: RequestInfo | URL, init?: RequestInit) => {
const response = await authFetch(input, init)
if (response.status === 401) {
clearToken()
throw new UnauthorizedError()
}
if (response.status === 403) {
throw new ForbiddenError()
}
return response
}
export const readResponseText = async (response: Response) => {
try {
return (await response.text()).trim()
} catch {
return ''
}
}
+3 -2
View File
@@ -42,13 +42,14 @@ export default function LoginPage() {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body,
credentials: 'include',
})
if (!response.ok) {
throw new Error('Login failed')
}
const data = await response.json()
if (data?.access_token) {
setToken(data.access_token)
if (data?.authenticated) {
setToken('cookie')
if (typeof window !== 'undefined') {
window.location.href = '/'
return
File diff suppressed because it is too large Load Diff
+6
View File
@@ -0,0 +1,6 @@
import PortalClient from '../PortalClient'
export default function IssuePortalPage() {
return <PortalClient workspace="issue" />
}
File diff suppressed because it is too large Load Diff
+6
View File
@@ -0,0 +1,6 @@
import PortalClient from '../PortalClient'
export default function RequestPortalPage() {
return <PortalClient workspace="request" />
}
+4 -3
View File
@@ -106,6 +106,7 @@ function SignupPageContent() {
const response = await fetch(`${baseUrl}/auth/signup`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({
invite_code: inviteCode,
username: username.trim(),
@@ -117,12 +118,12 @@ function SignupPageContent() {
throw new Error(text || 'Sign-up failed')
}
const data = await response.json()
if (data?.access_token) {
setToken(data.access_token)
if (data?.authenticated) {
setToken('cookie')
window.location.href = '/'
return
}
throw new Error('Sign-up did not return a token')
throw new Error('Sign-up did not complete')
} catch (err) {
console.error(err)
setError(err instanceof Error ? err.message : 'Unable to create account.')
+4 -3
View File
@@ -1,7 +1,7 @@
'use client'
import { useEffect, useState } from 'react'
import { authFetch, clearToken, getApiBase, getToken } from '../lib/auth'
import { authFetch, clearToken, getApiBase, getToken, logout } from '../lib/auth'
export default function HeaderIdentity() {
const [identity, setIdentity] = useState<{ username: string; role?: string } | null>(null)
@@ -49,7 +49,8 @@ export default function HeaderIdentity() {
const label = `${identity.username}${identity.role ? ` (${identity.role})` : ''}`
const initial = identity.username.slice(0, 1).toUpperCase()
const signOut = () => {
const signOut = async () => {
await logout().catch(() => undefined)
clearToken()
if (typeof window !== 'undefined') {
window.location.href = '/login'
@@ -83,7 +84,7 @@ export default function HeaderIdentity() {
<a href="/changelog" onClick={() => setOpen(false)}>
Changelog
</a>
<button type="button" className="signed-in-signout" onClick={signOut}>
<button type="button" className="signed-in-signout" onClick={() => void signOut()}>
Sign out
</button>
</div>
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "magent-frontend",
"version": "0803262216",
"version": "0803262237",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "magent-frontend",
"version": "0803262216",
"version": "0803262237",
"dependencies": {
"next": "16.1.6",
"react": "19.2.4",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "magent-frontend",
"private": true,
"version": "0803262216",
"version": "0803262237",
"scripts": {
"dev": "next dev",
"build": "next build",
+18
View File
@@ -0,0 +1,18 @@
#!/usr/bin/env bash
set -euo pipefail
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$repo_root"
python_bin="${PYTHON_BIN:-python3}"
echo "Installing backend Python requirements"
"$python_bin" -m pip install -r backend/requirements.txt
echo "Running Python dependency integrity check"
"$python_bin" -m pip check
echo "Running backend unit tests"
"$python_bin" -m unittest discover -s backend/tests -p "test_*.py" -v
echo "Backend quality gate passed"
+51
View File
@@ -0,0 +1,51 @@
#!/usr/bin/env bash
set -euo pipefail
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$repo_root"
deploy_host="${DEPLOY_HOST:-AMS-DEV01}"
deploy_user="${DEPLOY_USER:-zak}"
deploy_path="${DEPLOY_PATH:-/home/${deploy_user}/magent}"
ssh_opts="${DEPLOY_SSH_OPTS:-"-o StrictHostKeyChecking=accept-new"}"
timestamp="$(date -u +%Y%m%dT%H%M%SZ)"
remote="${deploy_user}@${deploy_host}"
echo "Deploying tracked repository contents to ${remote}:${deploy_path}"
git archive --format=tar HEAD | ssh ${ssh_opts} "${remote}" "
set -e
mkdir -p '${deploy_path}'
backup_root=\"\${HOME}/magent-backups/${timestamp}\"
mkdir -p \"\${backup_root}\"
cd '${deploy_path}'
for path in backend frontend docker-compose.yml docker-compose.hub.yml Dockerfile README.md docker scripts .build_number .gitattributes .gitignore; do
if [ -e \"\$path\" ]; then
cp -a \"\$path\" \"\${backup_root}/\"
fi
done
tar -xf - -C '${deploy_path}'
docker compose up -d --build
"
echo "Running remote smoke checks"
ssh ${ssh_opts} "${remote}" "
set -e
python3 - <<'PY'
from urllib import request
checks = [
('http://127.0.0.1:8000/health', 200),
('http://127.0.0.1:3000/login', 200),
]
for url, expected in checks:
with request.urlopen(url, timeout=20) as response:
if response.status != expected:
raise SystemExit(f'{url} returned {response.status}, expected {expected}')
print(url, response.status)
PY
"
echo "Deployment completed successfully"