Compare commits
18 Commits
b86223f943
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
60ce69e115
|
|||
|
d6d0735ff8
|
|||
|
72088dba9a
|
|||
|
6f1fffd6e8
|
|||
|
490cbbb812
|
|||
|
4fe22e3539
|
|||
|
3f7cdee995
|
|||
|
1a04b23e89
|
|||
|
2a5dfc3713
|
|||
|
1cd7d6541d
|
|||
|
ec6a20ebd1
|
|||
|
83d6a4f367
|
|||
|
8cf3748015
|
|||
|
daa11cb768
|
|||
|
8f2c357bfc
|
|||
|
d50169b883
|
|||
|
b5b74845f2
|
|||
|
0acce2e260
|
25
.env.example
25
.env.example
@@ -4,6 +4,9 @@
|
|||||||
# Development defaults (HTTP local stack)
|
# Development defaults (HTTP local stack)
|
||||||
APP_ENV=development
|
APP_ENV=development
|
||||||
HOST_BIND_IP=127.0.0.1
|
HOST_BIND_IP=127.0.0.1
|
||||||
|
# Optional host directory for persistent bind mounts in docker-compose.yml.
|
||||||
|
# Defaults to ./data when unset.
|
||||||
|
# DCM_DATA_DIR=./data
|
||||||
|
|
||||||
POSTGRES_USER=dcm
|
POSTGRES_USER=dcm
|
||||||
POSTGRES_PASSWORD=ChangeMe-Postgres-Secret
|
POSTGRES_PASSWORD=ChangeMe-Postgres-Secret
|
||||||
@@ -23,6 +26,11 @@ AUTH_LOGIN_FAILURE_LIMIT=5
|
|||||||
AUTH_LOGIN_FAILURE_WINDOW_SECONDS=900
|
AUTH_LOGIN_FAILURE_WINDOW_SECONDS=900
|
||||||
AUTH_LOGIN_LOCKOUT_BASE_SECONDS=30
|
AUTH_LOGIN_LOCKOUT_BASE_SECONDS=30
|
||||||
AUTH_LOGIN_LOCKOUT_MAX_SECONDS=900
|
AUTH_LOGIN_LOCKOUT_MAX_SECONDS=900
|
||||||
|
# Optional cookie controls for split frontend/api hosts:
|
||||||
|
# Leave AUTH_COOKIE_DOMAIN empty unless you explicitly need a parent-domain CSRF cookie mirror.
|
||||||
|
# Host-only auth cookies are issued automatically for the API host.
|
||||||
|
# AUTH_COOKIE_DOMAIN=docs.lan
|
||||||
|
# AUTH_COOKIE_SAMESITE=auto
|
||||||
|
|
||||||
APP_SETTINGS_ENCRYPTION_KEY=ChangeMe-Settings-Encryption-Key
|
APP_SETTINGS_ENCRYPTION_KEY=ChangeMe-Settings-Encryption-Key
|
||||||
TYPESENSE_API_KEY=ChangeMe-Typesense-Key
|
TYPESENSE_API_KEY=ChangeMe-Typesense-Key
|
||||||
@@ -39,15 +47,13 @@ PROVIDER_BASE_URL_ALLOWLIST=[]
|
|||||||
|
|
||||||
PUBLIC_BASE_URL=http://localhost:8000
|
PUBLIC_BASE_URL=http://localhost:8000
|
||||||
CORS_ORIGINS=["http://localhost:5173","http://localhost:3000"]
|
CORS_ORIGINS=["http://localhost:5173","http://localhost:3000"]
|
||||||
|
# Leave empty to use same-origin /api/v1 through the frontend proxy.
|
||||||
|
# Set an absolute URL only when you intentionally want split-origin frontend/API traffic.
|
||||||
VITE_API_BASE=
|
VITE_API_BASE=
|
||||||
|
# Development-only Vite proxy target. Docker compose sets this to http://api:8000 automatically.
|
||||||
# Optional frontend build network and npm fetch tuning:
|
VITE_API_PROXY_TARGET=http://localhost:8000
|
||||||
DOCKER_BUILD_NETWORK=default
|
# Development-only Vite host allowlist override.
|
||||||
NPM_REGISTRY=https://registry.npmjs.org/
|
VITE_ALLOWED_HOSTS=
|
||||||
NPM_FETCH_RETRIES=5
|
|
||||||
NPM_FETCH_RETRY_MINTIMEOUT=20000
|
|
||||||
NPM_FETCH_RETRY_MAXTIMEOUT=120000
|
|
||||||
NPM_FETCH_TIMEOUT=300000
|
|
||||||
|
|
||||||
# Production baseline overrides (set explicitly for live deployments):
|
# Production baseline overrides (set explicitly for live deployments):
|
||||||
# APP_ENV=production
|
# APP_ENV=production
|
||||||
@@ -55,9 +61,12 @@ NPM_FETCH_TIMEOUT=300000
|
|||||||
# REDIS_URL=rediss://:<strong-password>@redis.example.internal:6379/0
|
# REDIS_URL=rediss://:<strong-password>@redis.example.internal:6379/0
|
||||||
# REDIS_SECURITY_MODE=strict
|
# REDIS_SECURITY_MODE=strict
|
||||||
# REDIS_TLS_MODE=required
|
# REDIS_TLS_MODE=required
|
||||||
|
# AUTH_COOKIE_DOMAIN=example.com
|
||||||
|
# AUTH_COOKIE_SAMESITE=none
|
||||||
# PROVIDER_BASE_URL_ALLOW_HTTP=false
|
# PROVIDER_BASE_URL_ALLOW_HTTP=false
|
||||||
# PROVIDER_BASE_URL_ALLOW_PRIVATE_NETWORK=false
|
# PROVIDER_BASE_URL_ALLOW_PRIVATE_NETWORK=false
|
||||||
# PROVIDER_BASE_URL_ALLOWLIST=["api.openai.com"]
|
# PROVIDER_BASE_URL_ALLOWLIST=["api.openai.com"]
|
||||||
# PUBLIC_BASE_URL=https://api.example.com
|
# PUBLIC_BASE_URL=https://api.example.com
|
||||||
# CORS_ORIGINS=["https://app.example.com"]
|
# CORS_ORIGINS=["https://app.example.com"]
|
||||||
# VITE_API_BASE=https://api.example.com/api/v1
|
# VITE_API_BASE=https://api.example.com/api/v1
|
||||||
|
# VITE_ALLOWED_HOSTS=app.example.com
|
||||||
|
|||||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -20,9 +20,8 @@ build/
|
|||||||
!.env.example
|
!.env.example
|
||||||
|
|
||||||
# Data and generated artifacts (runtime only)
|
# Data and generated artifacts (runtime only)
|
||||||
data/postgres/
|
data/
|
||||||
data/redis/
|
typesense-data/
|
||||||
data/storage/
|
|
||||||
|
|
||||||
# OS / IDE
|
# OS / IDE
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
## Stack Snapshot
|
## Stack Snapshot
|
||||||
- DMS monorepo with FastAPI API + RQ worker (`backend/`) and React + Vite + TypeScript frontend (`frontend/`).
|
- DMS monorepo with FastAPI API + RQ worker (`backend/`) and React + Vite + TypeScript frontend (`frontend/`).
|
||||||
- Services in `docker-compose.yml`: `api`, `worker`, `frontend`, `db` (Postgres), `redis`, `typesense`.
|
- Services in `docker-compose.yml`: `api`, `worker`, `frontend`, `db` (Postgres), `redis`, `typesense`.
|
||||||
- Runtime persistence uses Docker named volumes (`db-data`, `redis-data`, `dcm-storage`, `typesense-data`).
|
- Runtime persistence uses host bind mounts under `${DCM_DATA_DIR:-./data}` (`db-data`, `redis-data`, `storage`, `typesense-data`).
|
||||||
|
|
||||||
## Project Layout
|
## Project Layout
|
||||||
- Backend app code: `backend/app/` (`api/`, `services/`, `db/`, `models/`, `schemas/`, `worker/`).
|
- Backend app code: `backend/app/` (`api/`, `services/`, `db/`, `models/`, `schemas/`, `worker/`).
|
||||||
|
|||||||
22
README.md
22
README.md
@@ -83,6 +83,7 @@ These create an extra non-admin account on first startup.
|
|||||||
|
|
||||||
- `APP_ENV=development`: Local mode (default).
|
- `APP_ENV=development`: Local mode (default).
|
||||||
- `APP_ENV=production`: Use when running as a real shared deployment with HTTPS and tighter security settings.
|
- `APP_ENV=production`: Use when running as a real shared deployment with HTTPS and tighter security settings.
|
||||||
|
- Frontend runtime switches to a static build served by Nginx in this mode.
|
||||||
|
|
||||||
## Daily Use Commands
|
## Daily Use Commands
|
||||||
|
|
||||||
@@ -112,17 +113,26 @@ docker compose logs -f api worker
|
|||||||
|
|
||||||
## Where Your Data Is Stored
|
## Where Your Data Is Stored
|
||||||
|
|
||||||
LedgerDock stores data in Docker volumes so it survives container restarts:
|
LedgerDock stores persistent runtime data in host bind mounts. By default the host root is `./data`, or set `DCM_DATA_DIR` to move it:
|
||||||
|
|
||||||
- `db-data` for PostgreSQL data
|
- `${DCM_DATA_DIR:-./data}/db-data` for PostgreSQL data
|
||||||
- `redis-data` for Redis data
|
- `${DCM_DATA_DIR:-./data}/redis-data` for Redis data
|
||||||
- `dcm-storage` for uploaded files and app storage
|
- `${DCM_DATA_DIR:-./data}/storage` for uploaded files and app storage
|
||||||
- `typesense-data` for the search index
|
- `${DCM_DATA_DIR:-./data}/typesense-data` for the search index
|
||||||
|
|
||||||
|
On startup, Compose runs a one-shot `storage-init` service that creates the storage tree and applies write access for the backend runtime user `uid=10001`. If you want to inspect or repair it manually, use:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p ${DCM_DATA_DIR:-./data}/storage
|
||||||
|
sudo chown -R 10001:10001 ${DCM_DATA_DIR:-./data}/storage
|
||||||
|
sudo chmod -R u+rwX,g+rwX ${DCM_DATA_DIR:-./data}/storage
|
||||||
|
```
|
||||||
|
|
||||||
To remove everything, including data:
|
To remove everything, including data:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose down -v
|
docker compose down
|
||||||
|
rm -rf ${DCM_DATA_DIR:-./data}
|
||||||
```
|
```
|
||||||
|
|
||||||
Warning: this permanently deletes your LedgerDock data on this machine.
|
Warning: this permanently deletes your LedgerDock data on this machine.
|
||||||
|
|||||||
@@ -54,6 +54,28 @@ def _requires_csrf_validation(method: str) -> bool:
|
|||||||
return method.upper() in CSRF_PROTECTED_METHODS
|
return method.upper() in CSRF_PROTECTED_METHODS
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_cookie_values(request: Request, cookie_name: str) -> tuple[str, ...]:
|
||||||
|
"""Extracts all values for one cookie name from raw Cookie header order."""
|
||||||
|
|
||||||
|
request_headers = getattr(request, "headers", None)
|
||||||
|
raw_cookie_header = request_headers.get("cookie", "") if request_headers is not None else ""
|
||||||
|
if not raw_cookie_header:
|
||||||
|
return ()
|
||||||
|
|
||||||
|
extracted_values: list[str] = []
|
||||||
|
for cookie_pair in raw_cookie_header.split(";"):
|
||||||
|
normalized_pair = cookie_pair.strip()
|
||||||
|
if not normalized_pair or "=" not in normalized_pair:
|
||||||
|
continue
|
||||||
|
key, value = normalized_pair.split("=", 1)
|
||||||
|
if key.strip() != cookie_name:
|
||||||
|
continue
|
||||||
|
normalized_value = value.strip()
|
||||||
|
if normalized_value:
|
||||||
|
extracted_values.append(normalized_value)
|
||||||
|
return tuple(extracted_values)
|
||||||
|
|
||||||
|
|
||||||
def _raise_unauthorized() -> None:
|
def _raise_unauthorized() -> None:
|
||||||
"""Raises a 401 challenge response for missing or invalid auth sessions."""
|
"""Raises a 401 challenge response for missing or invalid auth sessions."""
|
||||||
|
|
||||||
@@ -85,24 +107,39 @@ def get_request_auth_context(
|
|||||||
|
|
||||||
token = credentials.credentials.strip() if credentials is not None and credentials.credentials else ""
|
token = credentials.credentials.strip() if credentials is not None and credentials.credentials else ""
|
||||||
using_cookie_session = False
|
using_cookie_session = False
|
||||||
|
session_candidates: list[str] = []
|
||||||
|
|
||||||
if not token:
|
if not token:
|
||||||
token = (session_cookie or "").strip()
|
|
||||||
using_cookie_session = True
|
using_cookie_session = True
|
||||||
if not token:
|
session_candidates = [candidate for candidate in _extract_cookie_values(request, SESSION_COOKIE_NAME) if candidate]
|
||||||
_raise_unauthorized()
|
normalized_session_cookie = (session_cookie or "").strip()
|
||||||
|
if normalized_session_cookie and normalized_session_cookie not in session_candidates:
|
||||||
|
session_candidates.append(normalized_session_cookie)
|
||||||
|
if not session_candidates:
|
||||||
|
_raise_unauthorized()
|
||||||
|
|
||||||
if _requires_csrf_validation(request.method) and using_cookie_session:
|
if _requires_csrf_validation(request.method) and using_cookie_session:
|
||||||
normalized_csrf_header = (csrf_header or "").strip()
|
normalized_csrf_header = (csrf_header or "").strip()
|
||||||
|
csrf_candidates = [candidate for candidate in _extract_cookie_values(request, CSRF_COOKIE_NAME) if candidate]
|
||||||
normalized_csrf_cookie = (csrf_cookie or "").strip()
|
normalized_csrf_cookie = (csrf_cookie or "").strip()
|
||||||
|
if normalized_csrf_cookie and normalized_csrf_cookie not in csrf_candidates:
|
||||||
|
csrf_candidates.append(normalized_csrf_cookie)
|
||||||
if (
|
if (
|
||||||
not normalized_csrf_cookie
|
not csrf_candidates
|
||||||
or not normalized_csrf_header
|
or not normalized_csrf_header
|
||||||
or not hmac.compare_digest(normalized_csrf_cookie, normalized_csrf_header)
|
or not any(hmac.compare_digest(candidate, normalized_csrf_header) for candidate in csrf_candidates)
|
||||||
):
|
):
|
||||||
_raise_csrf_rejected()
|
_raise_csrf_rejected()
|
||||||
|
|
||||||
resolved_session = resolve_auth_session(session, token=token)
|
resolved_session = None
|
||||||
|
if token:
|
||||||
|
resolved_session = resolve_auth_session(session, token=token)
|
||||||
|
else:
|
||||||
|
for candidate in session_candidates:
|
||||||
|
resolved_session = resolve_auth_session(session, token=candidate)
|
||||||
|
if resolved_session is not None and resolved_session.user is not None:
|
||||||
|
break
|
||||||
|
|
||||||
if resolved_session is None or resolved_session.user is None:
|
if resolved_session is None or resolved_session.user is None:
|
||||||
_raise_unauthorized()
|
_raise_unauthorized()
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import logging
|
import logging
|
||||||
import secrets
|
import secrets
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
@@ -13,6 +14,7 @@ from app.api.auth import (
|
|||||||
CSRF_COOKIE_NAME,
|
CSRF_COOKIE_NAME,
|
||||||
require_user_or_admin,
|
require_user_or_admin,
|
||||||
)
|
)
|
||||||
|
from app.core.config import get_settings
|
||||||
from app.db.base import get_session
|
from app.db.base import get_session
|
||||||
from app.schemas.auth import (
|
from app.schemas.auth import (
|
||||||
AuthLoginRequest,
|
AuthLoginRequest,
|
||||||
@@ -28,9 +30,14 @@ from app.services.auth_login_throttle import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from fastapi import Response
|
from fastapi import Cookie, Response
|
||||||
except (ImportError, AttributeError):
|
except (ImportError, AttributeError):
|
||||||
from fastapi.responses import Response
|
from fastapi.responses import Response
|
||||||
|
|
||||||
|
def Cookie(_default=None, **_kwargs): # type: ignore[no-untyped-def]
|
||||||
|
"""Compatibility fallback for environments that stub fastapi without request params."""
|
||||||
|
|
||||||
|
return None
|
||||||
from app.services.authentication import authenticate_user, issue_user_session, revoke_auth_session
|
from app.services.authentication import authenticate_user, issue_user_session, revoke_auth_session
|
||||||
|
|
||||||
router = APIRouter(prefix="/auth", tags=["auth"])
|
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||||
@@ -66,9 +73,67 @@ def _is_https_request(request: Request) -> bool:
|
|||||||
if forwarded_protocol:
|
if forwarded_protocol:
|
||||||
return forwarded_protocol == "https"
|
return forwarded_protocol == "https"
|
||||||
request_url = getattr(request, "url", None)
|
request_url = getattr(request, "url", None)
|
||||||
if request_url is None:
|
request_scheme = str(getattr(request_url, "scheme", "")).lower() if request_url is not None else ""
|
||||||
|
if request_scheme == "https":
|
||||||
|
return True
|
||||||
|
|
||||||
|
parsed_public_base_url = urlparse(get_settings().public_base_url.strip())
|
||||||
|
return parsed_public_base_url.scheme.lower() == "https"
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_cookie_domain() -> str | None:
|
||||||
|
"""Returns optional cookie domain override for multi-subdomain deployments."""
|
||||||
|
|
||||||
|
configured_domain = get_settings().auth_cookie_domain.strip().lower().lstrip(".")
|
||||||
|
if not configured_domain or "." not in configured_domain:
|
||||||
|
return None
|
||||||
|
return configured_domain
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_cookie_domains() -> tuple[str | None, ...]:
|
||||||
|
"""Returns cookie domain variants with a host-only cookie first for browser compatibility."""
|
||||||
|
|
||||||
|
configured_domain = _resolve_cookie_domain()
|
||||||
|
if configured_domain is None:
|
||||||
|
return (None,)
|
||||||
|
return (None, configured_domain)
|
||||||
|
|
||||||
|
|
||||||
|
def _request_matches_cookie_domain(request: Request) -> bool:
|
||||||
|
"""Returns whether request and origin hosts both sit under the configured cookie domain."""
|
||||||
|
|
||||||
|
configured_domain = _resolve_cookie_domain()
|
||||||
|
if configured_domain is None:
|
||||||
return False
|
return False
|
||||||
return str(getattr(request_url, "scheme", "")).lower() == "https"
|
|
||||||
|
origin_header = request.headers.get("origin", "").strip()
|
||||||
|
origin_host = urlparse(origin_header).hostname.strip().lower() if origin_header else ""
|
||||||
|
if not origin_host:
|
||||||
|
return False
|
||||||
|
|
||||||
|
request_url = getattr(request, "url", None)
|
||||||
|
request_host = str(getattr(request_url, "hostname", "")).strip().lower() if request_url is not None else ""
|
||||||
|
if not request_host:
|
||||||
|
parsed_public_base_url = urlparse(get_settings().public_base_url.strip())
|
||||||
|
request_host = parsed_public_base_url.hostname.strip().lower() if parsed_public_base_url.hostname else ""
|
||||||
|
if not request_host:
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _matches(candidate: str) -> bool:
|
||||||
|
return candidate == configured_domain or candidate.endswith(f".{configured_domain}")
|
||||||
|
|
||||||
|
return _matches(origin_host) and _matches(request_host)
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_cookie_samesite(request: Request, secure_cookie: bool) -> str:
|
||||||
|
"""Returns cookie SameSite mode with same-site subdomain compatibility defaults."""
|
||||||
|
|
||||||
|
configured_mode = get_settings().auth_cookie_samesite.strip().lower()
|
||||||
|
if configured_mode in {"strict", "lax"}:
|
||||||
|
return configured_mode
|
||||||
|
if configured_mode == "none":
|
||||||
|
return "lax" if _request_matches_cookie_domain(request) else "none"
|
||||||
|
return "none" if secure_cookie else "lax"
|
||||||
|
|
||||||
|
|
||||||
def _session_cookie_ttl_seconds(expires_at: datetime) -> int:
|
def _session_cookie_ttl_seconds(expires_at: datetime) -> int:
|
||||||
@@ -79,27 +144,39 @@ def _session_cookie_ttl_seconds(expires_at: datetime) -> int:
|
|||||||
return max(1, ttl)
|
return max(1, ttl)
|
||||||
|
|
||||||
|
|
||||||
def _set_session_cookie(response: Response, session_token: str, *, expires_at: datetime, secure: bool) -> None:
|
def _set_session_cookie(
|
||||||
|
response: Response,
|
||||||
|
session_token: str,
|
||||||
|
*,
|
||||||
|
request: Request,
|
||||||
|
expires_at: datetime,
|
||||||
|
secure: bool,
|
||||||
|
) -> None:
|
||||||
"""Stores the issued session token in a browser HttpOnly auth cookie."""
|
"""Stores the issued session token in a browser HttpOnly auth cookie."""
|
||||||
|
|
||||||
if response is None or not hasattr(response, "set_cookie"):
|
if response is None or not hasattr(response, "set_cookie"):
|
||||||
return
|
return
|
||||||
expires_seconds = _session_cookie_ttl_seconds(expires_at)
|
expires_seconds = _session_cookie_ttl_seconds(expires_at)
|
||||||
response.set_cookie(
|
same_site_mode = _resolve_cookie_samesite(request, secure)
|
||||||
SESSION_COOKIE_NAME,
|
for cookie_domain in _resolve_cookie_domains():
|
||||||
value=session_token,
|
cookie_kwargs = {
|
||||||
max_age=expires_seconds,
|
"value": session_token,
|
||||||
httponly=True,
|
"max_age": expires_seconds,
|
||||||
secure=secure,
|
"httponly": True,
|
||||||
samesite="strict",
|
"secure": secure,
|
||||||
path="/",
|
"samesite": same_site_mode,
|
||||||
)
|
"path": "/",
|
||||||
|
}
|
||||||
|
if cookie_domain is not None:
|
||||||
|
cookie_kwargs["domain"] = cookie_domain
|
||||||
|
response.set_cookie(SESSION_COOKIE_NAME, **cookie_kwargs)
|
||||||
|
|
||||||
|
|
||||||
def _set_csrf_cookie(
|
def _set_csrf_cookie(
|
||||||
response: Response,
|
response: Response,
|
||||||
csrf_token: str,
|
csrf_token: str,
|
||||||
*,
|
*,
|
||||||
|
request: Request,
|
||||||
expires_at: datetime,
|
expires_at: datetime,
|
||||||
secure: bool,
|
secure: bool,
|
||||||
) -> None:
|
) -> None:
|
||||||
@@ -107,15 +184,19 @@ def _set_csrf_cookie(
|
|||||||
|
|
||||||
if response is None or not hasattr(response, "set_cookie"):
|
if response is None or not hasattr(response, "set_cookie"):
|
||||||
return
|
return
|
||||||
response.set_cookie(
|
same_site_mode = _resolve_cookie_samesite(request, secure)
|
||||||
CSRF_COOKIE_NAME,
|
for cookie_domain in _resolve_cookie_domains():
|
||||||
value=csrf_token,
|
cookie_kwargs = {
|
||||||
max_age=_session_cookie_ttl_seconds(expires_at),
|
"value": csrf_token,
|
||||||
httponly=False,
|
"max_age": _session_cookie_ttl_seconds(expires_at),
|
||||||
secure=secure,
|
"httponly": False,
|
||||||
samesite="strict",
|
"secure": secure,
|
||||||
path="/",
|
"samesite": same_site_mode,
|
||||||
)
|
"path": "/",
|
||||||
|
}
|
||||||
|
if cookie_domain is not None:
|
||||||
|
cookie_kwargs["domain"] = cookie_domain
|
||||||
|
response.set_cookie(CSRF_COOKIE_NAME, **cookie_kwargs)
|
||||||
|
|
||||||
|
|
||||||
def _clear_session_cookies(response: Response) -> None:
|
def _clear_session_cookies(response: Response) -> None:
|
||||||
@@ -123,8 +204,12 @@ def _clear_session_cookies(response: Response) -> None:
|
|||||||
|
|
||||||
if response is None or not hasattr(response, "delete_cookie"):
|
if response is None or not hasattr(response, "delete_cookie"):
|
||||||
return
|
return
|
||||||
response.delete_cookie(SESSION_COOKIE_NAME, path="/")
|
for cookie_domain in _resolve_cookie_domains():
|
||||||
response.delete_cookie(CSRF_COOKIE_NAME, path="/")
|
delete_kwargs = {"path": "/"}
|
||||||
|
if cookie_domain is not None:
|
||||||
|
delete_kwargs["domain"] = cookie_domain
|
||||||
|
response.delete_cookie(SESSION_COOKIE_NAME, **delete_kwargs)
|
||||||
|
response.delete_cookie(CSRF_COOKIE_NAME, **delete_kwargs)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/login", response_model=AuthLoginResponse)
|
@router.post("/login", response_model=AuthLoginResponse)
|
||||||
@@ -206,12 +291,14 @@ def login(
|
|||||||
_set_session_cookie(
|
_set_session_cookie(
|
||||||
response,
|
response,
|
||||||
issued_session.token,
|
issued_session.token,
|
||||||
|
request=request,
|
||||||
expires_at=issued_session.expires_at,
|
expires_at=issued_session.expires_at,
|
||||||
secure=secure_cookie,
|
secure=secure_cookie,
|
||||||
)
|
)
|
||||||
_set_csrf_cookie(
|
_set_csrf_cookie(
|
||||||
response,
|
response,
|
||||||
csrf_token,
|
csrf_token,
|
||||||
|
request=request,
|
||||||
expires_at=issued_session.expires_at,
|
expires_at=issued_session.expires_at,
|
||||||
secure=secure_cookie,
|
secure=secure_cookie,
|
||||||
)
|
)
|
||||||
@@ -225,9 +312,13 @@ def login(
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/me", response_model=AuthSessionResponse)
|
@router.get("/me", response_model=AuthSessionResponse)
|
||||||
def me(context: AuthContext = Depends(require_user_or_admin)) -> AuthSessionResponse:
|
def me(
|
||||||
|
context: AuthContext = Depends(require_user_or_admin),
|
||||||
|
csrf_cookie: str | None = Cookie(None, alias=CSRF_COOKIE_NAME),
|
||||||
|
) -> AuthSessionResponse:
|
||||||
"""Returns current authenticated session identity and expiration metadata."""
|
"""Returns current authenticated session identity and expiration metadata."""
|
||||||
|
|
||||||
|
normalized_csrf_cookie = (csrf_cookie or "").strip() or None
|
||||||
return AuthSessionResponse(
|
return AuthSessionResponse(
|
||||||
expires_at=context.expires_at,
|
expires_at=context.expires_at,
|
||||||
user=AuthUserResponse(
|
user=AuthUserResponse(
|
||||||
@@ -235,6 +326,7 @@ def me(context: AuthContext = Depends(require_user_or_admin)) -> AuthSessionResp
|
|||||||
username=context.username,
|
username=context.username,
|
||||||
role=context.role,
|
role=context.role,
|
||||||
),
|
),
|
||||||
|
csrf_token=normalized_csrf_cookie,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -33,6 +33,8 @@ class Settings(BaseSettings):
|
|||||||
auth_login_failure_window_seconds: int = 900
|
auth_login_failure_window_seconds: int = 900
|
||||||
auth_login_lockout_base_seconds: int = 30
|
auth_login_lockout_base_seconds: int = 30
|
||||||
auth_login_lockout_max_seconds: int = 900
|
auth_login_lockout_max_seconds: int = 900
|
||||||
|
auth_cookie_domain: str = ""
|
||||||
|
auth_cookie_samesite: str = "auto"
|
||||||
storage_root: Path = Path("/data/storage")
|
storage_root: Path = Path("/data/storage")
|
||||||
upload_chunk_size: int = 4 * 1024 * 1024
|
upload_chunk_size: int = 4 * 1024 * 1024
|
||||||
max_upload_files_per_request: int = 50
|
max_upload_files_per_request: int = 50
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ class AuthSessionResponse(BaseModel):
|
|||||||
|
|
||||||
user: AuthUserResponse
|
user: AuthUserResponse
|
||||||
expires_at: datetime
|
expires_at: datetime
|
||||||
|
csrf_token: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class AuthLoginResponse(AuthSessionResponse):
|
class AuthLoginResponse(AuthSessionResponse):
|
||||||
|
|||||||
@@ -364,6 +364,7 @@ if "app.services.routing_pipeline" not in sys.modules:
|
|||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
|
|
||||||
from app.api.auth import AuthContext, require_admin
|
from app.api.auth import AuthContext, require_admin
|
||||||
|
from app.api import auth as auth_dependency_module
|
||||||
from app.api import routes_auth as auth_routes_module
|
from app.api import routes_auth as auth_routes_module
|
||||||
from app.api import routes_documents as documents_routes_module
|
from app.api import routes_documents as documents_routes_module
|
||||||
from app.core import config as config_module
|
from app.core import config as config_module
|
||||||
@@ -420,6 +421,96 @@ class AuthDependencyTests(unittest.TestCase):
|
|||||||
resolved = require_admin(context=auth_context)
|
resolved = require_admin(context=auth_context)
|
||||||
self.assertEqual(resolved.role, UserRole.ADMIN)
|
self.assertEqual(resolved.role, UserRole.ADMIN)
|
||||||
|
|
||||||
|
def test_csrf_validation_accepts_matching_token_among_duplicate_cookie_values(self) -> None:
|
||||||
|
"""PATCH CSRF validation accepts header token matching any duplicate csrf cookie value."""
|
||||||
|
|
||||||
|
request = SimpleNamespace(
|
||||||
|
method="PATCH",
|
||||||
|
headers={"cookie": "dcm_session=session-token; dcm_csrf=stale-token; dcm_csrf=fresh-token"},
|
||||||
|
)
|
||||||
|
resolved_session = SimpleNamespace(
|
||||||
|
id=uuid.uuid4(),
|
||||||
|
expires_at=datetime.now(UTC),
|
||||||
|
user=SimpleNamespace(
|
||||||
|
id=uuid.uuid4(),
|
||||||
|
username="admin",
|
||||||
|
role=UserRole.ADMIN,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
with patch.object(auth_dependency_module, "resolve_auth_session", return_value=resolved_session):
|
||||||
|
context = auth_dependency_module.get_request_auth_context(
|
||||||
|
request=request,
|
||||||
|
credentials=None,
|
||||||
|
csrf_header="fresh-token",
|
||||||
|
csrf_cookie="stale-token",
|
||||||
|
session_cookie="session-token",
|
||||||
|
session=SimpleNamespace(),
|
||||||
|
)
|
||||||
|
self.assertEqual(context.username, "admin")
|
||||||
|
self.assertEqual(context.role, UserRole.ADMIN)
|
||||||
|
|
||||||
|
def test_csrf_validation_rejects_when_header_does_not_match_any_cookie_value(self) -> None:
|
||||||
|
"""PATCH CSRF validation rejects requests when header token matches no csrf cookie values."""
|
||||||
|
|
||||||
|
request = SimpleNamespace(
|
||||||
|
method="PATCH",
|
||||||
|
headers={"cookie": "dcm_session=session-token; dcm_csrf=stale-token; dcm_csrf=fresh-token"},
|
||||||
|
)
|
||||||
|
resolved_session = SimpleNamespace(
|
||||||
|
id=uuid.uuid4(),
|
||||||
|
expires_at=datetime.now(UTC),
|
||||||
|
user=SimpleNamespace(
|
||||||
|
id=uuid.uuid4(),
|
||||||
|
username="admin",
|
||||||
|
role=UserRole.ADMIN,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
with patch.object(auth_dependency_module, "resolve_auth_session", return_value=resolved_session):
|
||||||
|
with self.assertRaises(HTTPException) as raised:
|
||||||
|
auth_dependency_module.get_request_auth_context(
|
||||||
|
request=request,
|
||||||
|
credentials=None,
|
||||||
|
csrf_header="unknown-token",
|
||||||
|
csrf_cookie="stale-token",
|
||||||
|
session_cookie="session-token",
|
||||||
|
session=SimpleNamespace(),
|
||||||
|
)
|
||||||
|
self.assertEqual(raised.exception.status_code, 403)
|
||||||
|
self.assertEqual(raised.exception.detail, "Invalid CSRF token")
|
||||||
|
|
||||||
|
def test_cookie_auth_accepts_matching_session_among_duplicate_cookie_values(self) -> None:
|
||||||
|
"""Cookie auth accepts the first valid session token among duplicate cookie values."""
|
||||||
|
|
||||||
|
request = SimpleNamespace(
|
||||||
|
method="GET",
|
||||||
|
headers={"cookie": "dcm_session=stale-token; dcm_session=fresh-token"},
|
||||||
|
)
|
||||||
|
resolved_session = SimpleNamespace(
|
||||||
|
id=uuid.uuid4(),
|
||||||
|
expires_at=datetime.now(UTC),
|
||||||
|
user=SimpleNamespace(
|
||||||
|
id=uuid.uuid4(),
|
||||||
|
username="admin",
|
||||||
|
role=UserRole.ADMIN,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
with patch.object(
|
||||||
|
auth_dependency_module,
|
||||||
|
"resolve_auth_session",
|
||||||
|
side_effect=[None, resolved_session],
|
||||||
|
) as resolve_mock:
|
||||||
|
context = auth_dependency_module.get_request_auth_context(
|
||||||
|
request=request,
|
||||||
|
credentials=None,
|
||||||
|
csrf_header=None,
|
||||||
|
csrf_cookie=None,
|
||||||
|
session_cookie="stale-token",
|
||||||
|
session=SimpleNamespace(),
|
||||||
|
)
|
||||||
|
self.assertEqual(context.username, "admin")
|
||||||
|
self.assertEqual(context.role, UserRole.ADMIN)
|
||||||
|
self.assertEqual(resolve_mock.call_count, 2)
|
||||||
|
|
||||||
|
|
||||||
class DocumentCatalogVisibilityTests(unittest.TestCase):
|
class DocumentCatalogVisibilityTests(unittest.TestCase):
|
||||||
"""Verifies predefined tag and path discovery visibility by caller role."""
|
"""Verifies predefined tag and path discovery visibility by caller role."""
|
||||||
@@ -784,22 +875,44 @@ class AuthLoginRouteThrottleTests(unittest.TestCase):
|
|||||||
|
|
||||||
self.commit_count += 1
|
self.commit_count += 1
|
||||||
|
|
||||||
@staticmethod
|
class _ResponseStub:
|
||||||
def _response_stub() -> SimpleNamespace:
|
"""Captures response cookie calls for direct route invocation tests."""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.set_cookie_calls: list[tuple[tuple[object, ...], dict[str, object]]] = []
|
||||||
|
self.delete_cookie_calls: list[tuple[tuple[object, ...], dict[str, object]]] = []
|
||||||
|
|
||||||
|
def set_cookie(self, *args: object, **kwargs: object) -> None:
|
||||||
|
"""Records one set-cookie call."""
|
||||||
|
|
||||||
|
self.set_cookie_calls.append((args, kwargs))
|
||||||
|
|
||||||
|
def delete_cookie(self, *args: object, **kwargs: object) -> None:
|
||||||
|
"""Records one delete-cookie call."""
|
||||||
|
|
||||||
|
self.delete_cookie_calls.append((args, kwargs))
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _response_stub(cls) -> "AuthLoginRouteThrottleTests._ResponseStub":
|
||||||
"""Builds a minimal response object for direct route invocation."""
|
"""Builds a minimal response object for direct route invocation."""
|
||||||
|
|
||||||
return SimpleNamespace(
|
return cls._ResponseStub()
|
||||||
set_cookie=lambda *_args, **_kwargs: None,
|
|
||||||
delete_cookie=lambda *_args, **_kwargs: None,
|
|
||||||
)
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _request_stub(ip_address: str = "203.0.113.2", user_agent: str = "unit-test") -> SimpleNamespace:
|
def _request_stub(
|
||||||
|
ip_address: str = "203.0.113.2",
|
||||||
|
user_agent: str = "unit-test",
|
||||||
|
origin: str | None = None,
|
||||||
|
) -> SimpleNamespace:
|
||||||
"""Builds request-like object containing client host and user-agent header fields."""
|
"""Builds request-like object containing client host and user-agent header fields."""
|
||||||
|
|
||||||
|
headers = {"user-agent": user_agent}
|
||||||
|
if origin:
|
||||||
|
headers["origin"] = origin
|
||||||
return SimpleNamespace(
|
return SimpleNamespace(
|
||||||
client=SimpleNamespace(host=ip_address),
|
client=SimpleNamespace(host=ip_address),
|
||||||
headers={"user-agent": user_agent},
|
headers=headers,
|
||||||
|
url=SimpleNamespace(hostname="api.docs.lan"),
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_login_rejects_when_precheck_reports_active_throttle(self) -> None:
|
def test_login_rejects_when_precheck_reports_active_throttle(self) -> None:
|
||||||
@@ -912,6 +1025,57 @@ class AuthLoginRouteThrottleTests(unittest.TestCase):
|
|||||||
self.assertEqual(raised.exception.detail, auth_routes_module.LOGIN_RATE_LIMITER_UNAVAILABLE_DETAIL)
|
self.assertEqual(raised.exception.detail, auth_routes_module.LOGIN_RATE_LIMITER_UNAVAILABLE_DETAIL)
|
||||||
self.assertEqual(session.commit_count, 0)
|
self.assertEqual(session.commit_count, 0)
|
||||||
|
|
||||||
|
def test_login_sets_host_only_and_parent_domain_cookie_variants(self) -> None:
|
||||||
|
"""Successful login sets a host-only cookie and an optional parent-domain mirror."""
|
||||||
|
|
||||||
|
payload = auth_routes_module.AuthLoginRequest(username="admin", password="correct-password")
|
||||||
|
session = self._SessionStub()
|
||||||
|
response_stub = self._response_stub()
|
||||||
|
fake_user = SimpleNamespace(
|
||||||
|
id=uuid.uuid4(),
|
||||||
|
username="admin",
|
||||||
|
role=UserRole.ADMIN,
|
||||||
|
)
|
||||||
|
fake_session = SimpleNamespace(
|
||||||
|
token="session-token",
|
||||||
|
expires_at=datetime.now(UTC),
|
||||||
|
)
|
||||||
|
fake_settings = SimpleNamespace(
|
||||||
|
auth_cookie_domain="docs.lan",
|
||||||
|
auth_cookie_samesite="none",
|
||||||
|
public_base_url="https://api.docs.lan",
|
||||||
|
)
|
||||||
|
with (
|
||||||
|
patch.object(
|
||||||
|
auth_routes_module,
|
||||||
|
"check_login_throttle",
|
||||||
|
return_value=auth_login_throttle_module.LoginThrottleStatus(
|
||||||
|
is_throttled=False,
|
||||||
|
retry_after_seconds=0,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
patch.object(auth_routes_module, "authenticate_user", return_value=fake_user),
|
||||||
|
patch.object(auth_routes_module, "clear_login_throttle"),
|
||||||
|
patch.object(auth_routes_module, "issue_user_session", return_value=fake_session),
|
||||||
|
patch.object(auth_routes_module, "get_settings", return_value=fake_settings),
|
||||||
|
patch.object(auth_routes_module.secrets, "token_urlsafe", return_value="csrf-token"),
|
||||||
|
):
|
||||||
|
auth_routes_module.login(
|
||||||
|
payload=payload,
|
||||||
|
request=self._request_stub(origin="https://docs.lan"),
|
||||||
|
response=response_stub,
|
||||||
|
session=session,
|
||||||
|
)
|
||||||
|
|
||||||
|
session_cookie_calls = [call for call in response_stub.set_cookie_calls if call[0][0] == auth_routes_module.SESSION_COOKIE_NAME]
|
||||||
|
csrf_cookie_calls = [call for call in response_stub.set_cookie_calls if call[0][0] == auth_routes_module.CSRF_COOKIE_NAME]
|
||||||
|
self.assertEqual(len(session_cookie_calls), 2)
|
||||||
|
self.assertEqual(len(csrf_cookie_calls), 2)
|
||||||
|
self.assertFalse(any("domain" in kwargs and kwargs["domain"] is None for _args, kwargs in session_cookie_calls))
|
||||||
|
self.assertIn("domain", session_cookie_calls[1][1])
|
||||||
|
self.assertEqual(session_cookie_calls[1][1]["domain"], "docs.lan")
|
||||||
|
self.assertEqual(session_cookie_calls[0][1]["samesite"], "lax")
|
||||||
|
|
||||||
|
|
||||||
class ProviderBaseUrlValidationTests(unittest.TestCase):
|
class ProviderBaseUrlValidationTests(unittest.TestCase):
|
||||||
"""Verifies allowlist, scheme, and private-network SSRF protections."""
|
"""Verifies allowlist, scheme, and private-network SSRF protections."""
|
||||||
|
|||||||
@@ -8,6 +8,6 @@ This directory contains technical documentation for DMS.
|
|||||||
- `architecture-overview.md` - backend, frontend, and infrastructure architecture
|
- `architecture-overview.md` - backend, frontend, and infrastructure architecture
|
||||||
- `api-contract.md` - API endpoint contract grouped by route module, including session auth, login throttle responses, role and ownership scope, upload limits, and settings or processing-log security constraints
|
- `api-contract.md` - API endpoint contract grouped by route module, including session auth, login throttle responses, role and ownership scope, upload limits, and settings or processing-log security constraints
|
||||||
- `data-model-reference.md` - database entity definitions and lifecycle states
|
- `data-model-reference.md` - database entity definitions and lifecycle states
|
||||||
- `operations-and-configuration.md` - runtime operations, hardened compose defaults, DEV and LIVE security values, and persisted settings configuration behavior
|
- `operations-and-configuration.md` - runtime operations, hardened compose defaults, DEV and LIVE security values, persisted settings configuration behavior, and frontend Vite host allowlist controls
|
||||||
- `frontend-design-foundation.md` - frontend visual system, tokens, UI implementation rules, authenticated media delivery under session auth, processing-log timeline behavior, and settings helper-copy guidance
|
- `frontend-design-foundation.md` - frontend visual system, tokens, UI implementation rules, authenticated media delivery under session auth, processing-log timeline behavior, and settings helper-copy guidance
|
||||||
- `../.env.example` - repository-level environment template with local defaults and production override guidance
|
- `../.env.example` - repository-level environment template with local defaults and production override guidance
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ Primary implementation modules:
|
|||||||
- Login brute-force protection enforces Redis-backed throttle checks keyed by username and source IP.
|
- Login brute-force protection enforces Redis-backed throttle checks keyed by username and source IP.
|
||||||
- State-changing requests from browser clients must send `x-csrf-token: <dcm_csrf>` in request headers (double-submit pattern).
|
- State-changing requests from browser clients must send `x-csrf-token: <dcm_csrf>` in request headers (double-submit pattern).
|
||||||
- For non-browser API clients, the optional `Authorization: Bearer <token>` path remains supported when the token is sent explicitly.
|
- For non-browser API clients, the optional `Authorization: Bearer <token>` path remains supported when the token is sent explicitly.
|
||||||
- `GET /auth/me` returns current identity and role.
|
- `GET /auth/me` returns current identity, role, and current CSRF token.
|
||||||
- `POST /auth/logout` revokes current session token.
|
- `POST /auth/logout` revokes current session token.
|
||||||
|
|
||||||
Role matrix:
|
Role matrix:
|
||||||
|
|||||||
@@ -10,16 +10,17 @@
|
|||||||
- `worker` (RQ worker via `python -m app.worker.run_worker`)
|
- `worker` (RQ worker via `python -m app.worker.run_worker`)
|
||||||
- `frontend` (Vite React UI)
|
- `frontend` (Vite React UI)
|
||||||
|
|
||||||
Persistent volumes:
|
Persistent host bind mounts (default root `./data`, overridable with `DCM_DATA_DIR`):
|
||||||
- `db-data`
|
- `${DCM_DATA_DIR:-./data}/db-data`
|
||||||
- `redis-data`
|
- `${DCM_DATA_DIR:-./data}/redis-data`
|
||||||
- `dcm-storage`
|
- `${DCM_DATA_DIR:-./data}/storage`
|
||||||
- `typesense-data`
|
- `${DCM_DATA_DIR:-./data}/typesense-data`
|
||||||
|
|
||||||
Reset all persisted runtime data:
|
Reset all persisted runtime data:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose down -v
|
docker compose down
|
||||||
|
rm -rf ${DCM_DATA_DIR:-./data}
|
||||||
```
|
```
|
||||||
|
|
||||||
## Core Commands
|
## Core Commands
|
||||||
@@ -42,25 +43,26 @@ Tail logs:
|
|||||||
docker compose logs -f
|
docker compose logs -f
|
||||||
```
|
```
|
||||||
|
|
||||||
## Frontend Build Network Resilience
|
## Host Bind Mounts
|
||||||
|
|
||||||
The frontend Dockerfile uses `node:22-slim` by default for improved npm network compatibility on IPv4-only Linux hosts.
|
Compose is configured with host bind mounts for persistent data. Ensure host directories exist and are writable by the backend runtime user.
|
||||||
|
|
||||||
The frontend image build supports npm fetch tuning through environment-driven compose build args:
|
Backend and worker run as non-root user `uid=10001` inside containers. Compose bootstraps the storage bind mount through the one-shot `storage-init` service before either process starts. For manual inspection or repair of host-mounted storage paths:
|
||||||
|
|
||||||
- `NPM_REGISTRY` (default `https://registry.npmjs.org/`)
|
|
||||||
- `NPM_FETCH_RETRIES` (default `5`)
|
|
||||||
- `NPM_FETCH_RETRY_MINTIMEOUT` (default `20000`)
|
|
||||||
- `NPM_FETCH_RETRY_MAXTIMEOUT` (default `120000`)
|
|
||||||
- `NPM_FETCH_TIMEOUT` (default `300000`)
|
|
||||||
- `DOCKER_BUILD_NETWORK` (default `default`; set to `host` on Linux hosts when bridge-network npm fetches time out)
|
|
||||||
|
|
||||||
If frontend dependency downloads fail with npm `ETIMEDOUT` during `docker compose build`, keep defaults first, then try:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
DOCKER_BUILD_NETWORK=host docker compose build --no-cache frontend
|
mkdir -p ${DCM_DATA_DIR:-./data}/storage
|
||||||
|
sudo chown -R 10001:10001 ${DCM_DATA_DIR:-./data}/storage
|
||||||
|
sudo chmod -R u+rwX,g+rwX ${DCM_DATA_DIR:-./data}/storage
|
||||||
```
|
```
|
||||||
|
|
||||||
|
If permissions are incorrect, API startup fails with errors similar to:
|
||||||
|
- `PermissionError: [Errno 13] Permission denied: '/data/storage'`
|
||||||
|
- `FileNotFoundError` for `/data/storage/originals`
|
||||||
|
|
||||||
|
## Frontend Build Baseline
|
||||||
|
|
||||||
|
The frontend Dockerfile uses `node:22-slim` with a standard `npm ci --no-audit` install step and no npm-specific build tuning flags.
|
||||||
|
|
||||||
## Authentication Model
|
## Authentication Model
|
||||||
|
|
||||||
- Legacy shared build-time frontend token behavior was removed.
|
- Legacy shared build-time frontend token behavior was removed.
|
||||||
@@ -85,8 +87,9 @@ Use `.env.example` as baseline. The table below documents user-managed settings
|
|||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `APP_ENV` | `development` | `production` |
|
| `APP_ENV` | `development` | `production` |
|
||||||
| `HOST_BIND_IP` | `127.0.0.1` or local LAN bind if needed | `127.0.0.1` (publish behind proxy only) |
|
| `HOST_BIND_IP` | `127.0.0.1` or local LAN bind if needed | `127.0.0.1` (publish behind proxy only) |
|
||||||
| `PUBLIC_BASE_URL` | `http://localhost:8000` | `https://api.example.com` |
|
| `PUBLIC_BASE_URL` | `http://localhost:8000` or same-origin frontend host when proxying API through frontend | `https://app.example.com` when frontend proxies `/api`, or dedicated API origin if you intentionally keep split-origin routing |
|
||||||
| `VITE_API_BASE` | empty for host-derived `http://<frontend-host>:8000/api/v1`, or explicit local URL | `https://api.example.com/api/v1` |
|
| `VITE_API_BASE` | empty to use same-origin `/api/v1` through frontend proxy, or explicit local URL when bypassing proxy | empty or `/api/v1` for same-origin production routing; only use `https://api.example.com/api/v1` when you intentionally keep split-origin frontend/API traffic |
|
||||||
|
| `VITE_ALLOWED_HOSTS` | optional comma-separated hostnames, for example `localhost,docs.lan` | optional comma-separated public frontend hostnames, for example `app.example.com` |
|
||||||
| `CORS_ORIGINS` | `["http://localhost:5173","http://localhost:3000"]` | exact frontend origins only, for example `["https://app.example.com"]` |
|
| `CORS_ORIGINS` | `["http://localhost:5173","http://localhost:3000"]` | exact frontend origins only, for example `["https://app.example.com"]` |
|
||||||
| `REDIS_URL` | `redis://:<password>@redis:6379/0` in isolated local network | `rediss://:<password>@redis.internal:6379/0` |
|
| `REDIS_URL` | `redis://:<password>@redis:6379/0` in isolated local network | `rediss://:<password>@redis.internal:6379/0` |
|
||||||
| `REDIS_SECURITY_MODE` | `compat` or `auto` | `strict` |
|
| `REDIS_SECURITY_MODE` | `compat` or `auto` | `strict` |
|
||||||
@@ -95,6 +98,8 @@ Use `.env.example` as baseline. The table below documents user-managed settings
|
|||||||
| `AUTH_LOGIN_FAILURE_WINDOW_SECONDS` | default `900` | tune to identity-protection policy and support requirements |
|
| `AUTH_LOGIN_FAILURE_WINDOW_SECONDS` | default `900` | tune to identity-protection policy and support requirements |
|
||||||
| `AUTH_LOGIN_LOCKOUT_BASE_SECONDS` | default `30` | tune to identity-protection policy and support requirements |
|
| `AUTH_LOGIN_LOCKOUT_BASE_SECONDS` | default `30` | tune to identity-protection policy and support requirements |
|
||||||
| `AUTH_LOGIN_LOCKOUT_MAX_SECONDS` | default `900` | tune to identity-protection policy and support requirements |
|
| `AUTH_LOGIN_LOCKOUT_MAX_SECONDS` | default `900` | tune to identity-protection policy and support requirements |
|
||||||
|
| `AUTH_COOKIE_DOMAIN` | empty (recommended; API always issues a host-only auth cookie) | optional parent domain only when you explicitly need a mirrored domain cookie, for example `docs.lan` |
|
||||||
|
| `AUTH_COOKIE_SAMESITE` | `auto` | `none` only for truly cross-site frontend/API deployments; keep `auto` for same-site subdomains such as `docs.lan` and `api.docs.lan` |
|
||||||
| `PROVIDER_BASE_URL_ALLOW_HTTP` | `true` only when intentionally testing local HTTP provider endpoints | `false` |
|
| `PROVIDER_BASE_URL_ALLOW_HTTP` | `true` only when intentionally testing local HTTP provider endpoints | `false` |
|
||||||
| `PROVIDER_BASE_URL_ALLOW_PRIVATE_NETWORK` | `true` only for trusted local development targets | `false` |
|
| `PROVIDER_BASE_URL_ALLOW_PRIVATE_NETWORK` | `true` only for trusted local development targets | `false` |
|
||||||
| `PROVIDER_BASE_URL_ALLOWLIST` | allow needed test hosts | explicit production allowlist, for example `["api.openai.com"]` |
|
| `PROVIDER_BASE_URL_ALLOWLIST` | allow needed test hosts | explicit production allowlist, for example `["api.openai.com"]` |
|
||||||
@@ -138,6 +143,18 @@ Recommended LIVE pattern:
|
|||||||
## Frontend Runtime
|
## Frontend Runtime
|
||||||
|
|
||||||
- Frontend no longer consumes `VITE_API_TOKEN`.
|
- Frontend no longer consumes `VITE_API_TOKEN`.
|
||||||
|
- Frontend image target is environment-driven:
|
||||||
|
- `APP_ENV=development` builds the `development` target and runs Vite dev server
|
||||||
|
- `APP_ENV=production` builds the `production` target and serves static assets through unprivileged Nginx
|
||||||
|
- Frontend Docker targets are selected from `APP_ENV`, so use `development` or `production` values.
|
||||||
|
- Production frontend Nginx uses non-root runtime plus `/tmp` temp-path configuration so it can run with container capability dropping enabled.
|
||||||
|
- Vite dev server host allowlist uses the union of:
|
||||||
|
- hostnames extracted from `CORS_ORIGINS`
|
||||||
|
- optional explicit hostnames from `VITE_ALLOWED_HOSTS`
|
||||||
|
- `VITE_ALLOWED_HOSTS` only affects development mode where Vite is running.
|
||||||
|
- API auth cookies support optional domain and SameSite configuration through `AUTH_COOKIE_DOMAIN` and `AUTH_COOKIE_SAMESITE`.
|
||||||
|
- HTTPS cookie security detection falls back to `PUBLIC_BASE_URL` scheme when proxy headers are missing.
|
||||||
|
- CSRF validation accepts header matches against any `dcm_csrf` cookie value in the request, covering stale plus fresh duplicate-cookie transitions.
|
||||||
- Session authentication is cookie-based; browser reloads and new tabs can reuse an active session until it expires or is revoked.
|
- Session authentication is cookie-based; browser reloads and new tabs can reuse an active session until it expires or is revoked.
|
||||||
- Protected media and file download flows still use authenticated fetch plus blob/object URL handling.
|
- Protected media and file download flows still use authenticated fetch plus blob/object URL handling.
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,19 @@
|
|||||||
services:
|
services:
|
||||||
|
storage-init:
|
||||||
|
build:
|
||||||
|
context: ./backend
|
||||||
|
user: "0:0"
|
||||||
|
command:
|
||||||
|
- "sh"
|
||||||
|
- "-c"
|
||||||
|
- >
|
||||||
|
mkdir -p /data/storage/originals /data/storage/derived/previews /data/storage/tmp &&
|
||||||
|
chown -R 10001:10001 /data/storage &&
|
||||||
|
chmod -R u+rwX,g+rwX /data/storage
|
||||||
|
volumes:
|
||||||
|
- ${DCM_DATA_DIR:-./data}/storage:/data/storage
|
||||||
|
restart: "no"
|
||||||
|
|
||||||
db:
|
db:
|
||||||
image: postgres:16-alpine
|
image: postgres:16-alpine
|
||||||
environment:
|
environment:
|
||||||
@@ -6,7 +21,7 @@ services:
|
|||||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD must be set}
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD must be set}
|
||||||
POSTGRES_DB: ${POSTGRES_DB:?POSTGRES_DB must be set}
|
POSTGRES_DB: ${POSTGRES_DB:?POSTGRES_DB must be set}
|
||||||
volumes:
|
volumes:
|
||||||
- db-data:/var/lib/postgresql/data
|
- ${DCM_DATA_DIR:-./data}/db-data:/var/lib/postgresql/data
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:?POSTGRES_USER must be set} -d ${POSTGRES_DB:?POSTGRES_DB must be set}"]
|
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:?POSTGRES_USER must be set} -d ${POSTGRES_DB:?POSTGRES_DB must be set}"]
|
||||||
interval: 10s
|
interval: 10s
|
||||||
@@ -25,18 +40,18 @@ services:
|
|||||||
- "--requirepass"
|
- "--requirepass"
|
||||||
- "${REDIS_PASSWORD:?REDIS_PASSWORD must be set}"
|
- "${REDIS_PASSWORD:?REDIS_PASSWORD must be set}"
|
||||||
volumes:
|
volumes:
|
||||||
- redis-data:/data
|
- ${DCM_DATA_DIR:-./data}/redis-data:/data
|
||||||
networks:
|
networks:
|
||||||
- internal
|
- internal
|
||||||
|
|
||||||
typesense:
|
typesense:
|
||||||
image: typesense/typesense:29.0
|
image: typesense/typesense:30.2.rc6
|
||||||
command:
|
command:
|
||||||
- "--data-dir=/data"
|
- "--data-dir=/data"
|
||||||
- "--api-key=${TYPESENSE_API_KEY:?TYPESENSE_API_KEY must be set}"
|
- "--api-key=${TYPESENSE_API_KEY:?TYPESENSE_API_KEY must be set}"
|
||||||
- "--enable-cors"
|
- "--enable-cors"
|
||||||
volumes:
|
volumes:
|
||||||
- typesense-data:/data
|
- ${DCM_DATA_DIR:-./data}/typesense-data:/data
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
networks:
|
networks:
|
||||||
- internal
|
- internal
|
||||||
@@ -76,20 +91,22 @@ services:
|
|||||||
TYPESENSE_PORT: 8108
|
TYPESENSE_PORT: 8108
|
||||||
TYPESENSE_API_KEY: ${TYPESENSE_API_KEY:?TYPESENSE_API_KEY must be set}
|
TYPESENSE_API_KEY: ${TYPESENSE_API_KEY:?TYPESENSE_API_KEY must be set}
|
||||||
TYPESENSE_COLLECTION_NAME: documents
|
TYPESENSE_COLLECTION_NAME: documents
|
||||||
# ports:
|
# ports:
|
||||||
# - "${HOST_BIND_IP:-127.0.0.1}:8000:8000"
|
# - "${HOST_BIND_IP:-127.0.0.1}:8000:8000"
|
||||||
security_opt:
|
security_opt:
|
||||||
- no-new-privileges:true
|
- no-new-privileges:true
|
||||||
cap_drop:
|
cap_drop:
|
||||||
- ALL
|
- ALL
|
||||||
volumes:
|
volumes:
|
||||||
- ./backend/app:/app/app
|
- ./backend/app:/app/app
|
||||||
- dcm-storage:/data
|
- ${DCM_DATA_DIR:-./data}/storage:/data/storage
|
||||||
depends_on:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
redis:
|
redis:
|
||||||
condition: service_started
|
condition: service_started
|
||||||
|
storage-init:
|
||||||
|
condition: service_completed_successfully
|
||||||
typesense:
|
typesense:
|
||||||
condition: service_started
|
condition: service_started
|
||||||
networks:
|
networks:
|
||||||
@@ -124,7 +141,7 @@ services:
|
|||||||
TYPESENSE_COLLECTION_NAME: documents
|
TYPESENSE_COLLECTION_NAME: documents
|
||||||
volumes:
|
volumes:
|
||||||
- ./backend/app:/app/app
|
- ./backend/app:/app/app
|
||||||
- dcm-storage:/data
|
- ${DCM_DATA_DIR:-./data}/storage:/data/storage
|
||||||
security_opt:
|
security_opt:
|
||||||
- no-new-privileges:true
|
- no-new-privileges:true
|
||||||
cap_drop:
|
cap_drop:
|
||||||
@@ -134,6 +151,8 @@ services:
|
|||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
redis:
|
redis:
|
||||||
condition: service_started
|
condition: service_started
|
||||||
|
storage-init:
|
||||||
|
condition: service_completed_successfully
|
||||||
typesense:
|
typesense:
|
||||||
condition: service_started
|
condition: service_started
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
@@ -143,17 +162,16 @@ services:
|
|||||||
frontend:
|
frontend:
|
||||||
build:
|
build:
|
||||||
context: ./frontend
|
context: ./frontend
|
||||||
network: ${DOCKER_BUILD_NETWORK:-default}
|
target: ${APP_ENV:-development}
|
||||||
args:
|
args:
|
||||||
NPM_REGISTRY: ${NPM_REGISTRY:-https://registry.npmjs.org/}
|
VITE_API_BASE: ${VITE_API_BASE:-}
|
||||||
NPM_FETCH_RETRIES: ${NPM_FETCH_RETRIES:-5}
|
|
||||||
NPM_FETCH_RETRY_MINTIMEOUT: ${NPM_FETCH_RETRY_MINTIMEOUT:-20000}
|
|
||||||
NPM_FETCH_RETRY_MAXTIMEOUT: ${NPM_FETCH_RETRY_MAXTIMEOUT:-120000}
|
|
||||||
NPM_FETCH_TIMEOUT: ${NPM_FETCH_TIMEOUT:-300000}
|
|
||||||
environment:
|
environment:
|
||||||
VITE_API_BASE: ${VITE_API_BASE:-}
|
VITE_API_BASE: ${VITE_API_BASE:-}
|
||||||
# ports:
|
VITE_API_PROXY_TARGET: ${VITE_API_PROXY_TARGET:-http://api:8000}
|
||||||
# - "${HOST_BIND_IP:-127.0.0.1}:5173:5173"
|
CORS_ORIGINS: '${CORS_ORIGINS:-["http://localhost:5173","http://localhost:3000"]}'
|
||||||
|
VITE_ALLOWED_HOSTS: ${VITE_ALLOWED_HOSTS:-}
|
||||||
|
# ports:
|
||||||
|
# - "${HOST_BIND_IP:-127.0.0.1}:5173:5173"
|
||||||
volumes:
|
volumes:
|
||||||
- ./frontend/src:/app/src
|
- ./frontend/src:/app/src
|
||||||
- ./frontend/index.html:/app/index.html
|
- ./frontend/index.html:/app/index.html
|
||||||
@@ -171,12 +189,6 @@ services:
|
|||||||
internal:
|
internal:
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
volumes:
|
|
||||||
db-data:
|
|
||||||
redis-data:
|
|
||||||
dcm-storage:
|
|
||||||
typesense-data:
|
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
internal:
|
internal:
|
||||||
driver: bridge
|
driver: bridge
|
||||||
|
|||||||
@@ -1,22 +1,11 @@
|
|||||||
FROM node:22-slim
|
FROM node:20-slim AS base
|
||||||
|
|
||||||
ARG NPM_REGISTRY=https://registry.npmjs.org/
|
|
||||||
ARG NPM_FETCH_RETRIES=5
|
|
||||||
ARG NPM_FETCH_RETRY_MINTIMEOUT=20000
|
|
||||||
ARG NPM_FETCH_RETRY_MAXTIMEOUT=120000
|
|
||||||
ARG NPM_FETCH_TIMEOUT=300000
|
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
COPY package.json /app/package.json
|
COPY package.json /app/package.json
|
||||||
COPY package-lock.json /app/package-lock.json
|
COPY package-lock.json /app/package-lock.json
|
||||||
RUN npm config set registry "${NPM_REGISTRY}" \
|
RUN npm ci --no-audit \
|
||||||
&& npm config set fetch-retries "${NPM_FETCH_RETRIES}" \
|
&& chown -R node:node /app
|
||||||
&& npm config set fetch-retry-mintimeout "${NPM_FETCH_RETRY_MINTIMEOUT}" \
|
|
||||||
&& npm config set fetch-retry-maxtimeout "${NPM_FETCH_RETRY_MAXTIMEOUT}" \
|
|
||||||
&& npm config set fetch-timeout "${NPM_FETCH_TIMEOUT}" \
|
|
||||||
&& NODE_OPTIONS=--dns-result-order=ipv4first npm ci --no-audit
|
|
||||||
RUN chown -R node:node /app
|
|
||||||
|
|
||||||
COPY --chown=node:node tsconfig.json /app/tsconfig.json
|
COPY --chown=node:node tsconfig.json /app/tsconfig.json
|
||||||
COPY --chown=node:node tsconfig.node.json /app/tsconfig.node.json
|
COPY --chown=node:node tsconfig.node.json /app/tsconfig.node.json
|
||||||
@@ -24,8 +13,32 @@ COPY --chown=node:node vite.config.ts /app/vite.config.ts
|
|||||||
COPY --chown=node:node index.html /app/index.html
|
COPY --chown=node:node index.html /app/index.html
|
||||||
COPY --chown=node:node src /app/src
|
COPY --chown=node:node src /app/src
|
||||||
|
|
||||||
|
FROM base AS development
|
||||||
|
|
||||||
EXPOSE 5173
|
EXPOSE 5173
|
||||||
|
|
||||||
USER node
|
USER node
|
||||||
|
|
||||||
CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0", "--port", "5173"]
|
CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0", "--port", "5173"]
|
||||||
|
|
||||||
|
FROM base AS build
|
||||||
|
|
||||||
|
ARG VITE_API_BASE=
|
||||||
|
ENV VITE_API_BASE=${VITE_API_BASE}
|
||||||
|
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
FROM nginx:1.27-alpine AS production
|
||||||
|
|
||||||
|
COPY nginx-main.conf /etc/nginx/nginx.conf
|
||||||
|
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
COPY --from=build /app/dist /usr/share/nginx/html
|
||||||
|
RUN mkdir -p /tmp/client_temp /tmp/proxy_temp /tmp/fastcgi_temp /tmp/uwsgi_temp /tmp/scgi_temp \
|
||||||
|
&& chown -R 101:101 /tmp /var/log/nginx /usr/share/nginx/html
|
||||||
|
|
||||||
|
EXPOSE 5173
|
||||||
|
|
||||||
|
USER 101:101
|
||||||
|
|
||||||
|
ENTRYPOINT ["nginx"]
|
||||||
|
CMD ["-g", "daemon off;"]
|
||||||
|
|||||||
22
frontend/nginx-main.conf
Normal file
22
frontend/nginx-main.conf
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
worker_processes auto;
|
||||||
|
pid /tmp/nginx.pid;
|
||||||
|
|
||||||
|
events {
|
||||||
|
worker_connections 1024;
|
||||||
|
}
|
||||||
|
|
||||||
|
http {
|
||||||
|
include /etc/nginx/mime.types;
|
||||||
|
default_type application/octet-stream;
|
||||||
|
|
||||||
|
sendfile on;
|
||||||
|
keepalive_timeout 65;
|
||||||
|
|
||||||
|
client_body_temp_path /tmp/client_temp;
|
||||||
|
proxy_temp_path /tmp/proxy_temp;
|
||||||
|
fastcgi_temp_path /tmp/fastcgi_temp;
|
||||||
|
uwsgi_temp_path /tmp/uwsgi_temp;
|
||||||
|
scgi_temp_path /tmp/scgi_temp;
|
||||||
|
|
||||||
|
include /etc/nginx/conf.d/*.conf;
|
||||||
|
}
|
||||||
22
frontend/nginx.conf
Normal file
22
frontend/nginx.conf
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
server {
|
||||||
|
listen 5173;
|
||||||
|
listen [::]:5173;
|
||||||
|
server_name _;
|
||||||
|
client_max_body_size 100m;
|
||||||
|
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
location /api/ {
|
||||||
|
proxy_pass http://api:8000;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
}
|
||||||
|
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,7 +16,7 @@ import type {
|
|||||||
} from '../types';
|
} from '../types';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolves backend base URL from environment with host-derived HTTP fallback.
|
* Resolves backend base URL from environment with same-origin proxy fallback.
|
||||||
*/
|
*/
|
||||||
function resolveApiBase(): string {
|
function resolveApiBase(): string {
|
||||||
const envValue = import.meta.env?.VITE_API_BASE;
|
const envValue = import.meta.env?.VITE_API_BASE;
|
||||||
@@ -27,8 +27,8 @@ function resolveApiBase(): string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof window !== 'undefined' && window.location?.hostname) {
|
if (typeof window !== 'undefined' && window.location?.origin) {
|
||||||
return `${window.location.protocol}//${window.location.hostname}:8000/api/v1`;
|
return '/api/v1';
|
||||||
}
|
}
|
||||||
return 'http://localhost:8000/api/v1';
|
return 'http://localhost:8000/api/v1';
|
||||||
}
|
}
|
||||||
@@ -41,6 +41,7 @@ const API_BASE = resolveApiBase();
|
|||||||
const CSRF_COOKIE_NAME = "dcm_csrf";
|
const CSRF_COOKIE_NAME = "dcm_csrf";
|
||||||
const CSRF_HEADER_NAME = "x-csrf-token";
|
const CSRF_HEADER_NAME = "x-csrf-token";
|
||||||
const CSRF_SAFE_METHODS = new Set(["GET", "HEAD", "OPTIONS"]);
|
const CSRF_SAFE_METHODS = new Set(["GET", "HEAD", "OPTIONS"]);
|
||||||
|
const CSRF_SESSION_STORAGE_KEY = "dcm_csrf_token";
|
||||||
|
|
||||||
type ApiRequestInit = Omit<RequestInit, 'headers'> & { headers?: HeadersInit };
|
type ApiRequestInit = Omit<RequestInit, 'headers'> & { headers?: HeadersInit };
|
||||||
|
|
||||||
@@ -65,7 +66,38 @@ function getCookieValue(name: string): string | undefined {
|
|||||||
* Resolves the runtime CSRF token from browser cookie storage for API requests.
|
* Resolves the runtime CSRF token from browser cookie storage for API requests.
|
||||||
*/
|
*/
|
||||||
function resolveCsrfToken(): string | undefined {
|
function resolveCsrfToken(): string | undefined {
|
||||||
return getCookieValue(CSRF_COOKIE_NAME);
|
const cookieToken = getCookieValue(CSRF_COOKIE_NAME);
|
||||||
|
if (cookieToken) {
|
||||||
|
return cookieToken;
|
||||||
|
}
|
||||||
|
return loadStoredCsrfToken();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loads the runtime CSRF token from browser session storage.
|
||||||
|
*/
|
||||||
|
function loadStoredCsrfToken(): string | undefined {
|
||||||
|
if (typeof window === "undefined") {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const rawValue = window.sessionStorage.getItem(CSRF_SESSION_STORAGE_KEY);
|
||||||
|
const normalizedValue = rawValue?.trim();
|
||||||
|
return normalizedValue ? normalizedValue : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Persists or clears a runtime CSRF token in browser session storage.
|
||||||
|
*/
|
||||||
|
function persistCsrfToken(token: string | undefined | null): void {
|
||||||
|
if (typeof window === "undefined") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const normalizedValue = typeof token === "string" ? token.trim() : "";
|
||||||
|
if (!normalizedValue) {
|
||||||
|
window.sessionStorage.removeItem(CSRF_SESSION_STORAGE_KEY);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
window.sessionStorage.setItem(CSRF_SESSION_STORAGE_KEY, normalizedValue);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -181,7 +213,9 @@ export async function loginWithPassword(username: string, password: string): Pro
|
|||||||
}
|
}
|
||||||
throw new Error('Login failed');
|
throw new Error('Login failed');
|
||||||
}
|
}
|
||||||
return response.json() as Promise<AuthLoginResponse>;
|
const payload = await (response.json() as Promise<AuthLoginResponse>);
|
||||||
|
persistCsrfToken(payload.csrf_token);
|
||||||
|
return payload;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -196,7 +230,9 @@ export async function getCurrentAuthSession(): Promise<AuthSessionInfo> {
|
|||||||
}
|
}
|
||||||
throw new Error('Failed to load authentication session');
|
throw new Error('Failed to load authentication session');
|
||||||
}
|
}
|
||||||
return response.json() as Promise<AuthSessionInfo>;
|
const payload = await (response.json() as Promise<AuthSessionInfo>);
|
||||||
|
persistCsrfToken(payload.csrf_token);
|
||||||
|
return payload;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -206,6 +242,7 @@ export async function logoutCurrentSession(): Promise<void> {
|
|||||||
const response = await apiRequest(`${API_BASE}/auth/logout`, {
|
const response = await apiRequest(`${API_BASE}/auth/logout`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
});
|
});
|
||||||
|
persistCsrfToken(undefined);
|
||||||
if (!response.ok && response.status !== 401) {
|
if (!response.ok && response.status !== 401) {
|
||||||
const detail = await responseErrorDetail(response);
|
const detail = await responseErrorDetail(response);
|
||||||
if (detail) {
|
if (detail) {
|
||||||
|
|||||||
@@ -73,6 +73,7 @@ export interface AuthUser {
|
|||||||
export interface AuthSessionInfo {
|
export interface AuthSessionInfo {
|
||||||
user: AuthUser;
|
user: AuthUser;
|
||||||
expires_at: string;
|
expires_at: string;
|
||||||
|
csrf_token?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,14 +1,93 @@
|
|||||||
/**
|
/**
|
||||||
* Vite configuration for the DMS frontend application.
|
* Vite configuration for the DMS frontend application.
|
||||||
*/
|
*/
|
||||||
import { defineConfig } from 'vite';
|
import { defineConfig, loadEnv } from 'vite';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses a comma-separated environment value into normalized entries.
|
||||||
|
*
|
||||||
|
* @param rawValue Raw comma-separated value.
|
||||||
|
* @returns List of non-empty normalized entries.
|
||||||
|
*/
|
||||||
|
function parseCsvList(rawValue: string | undefined): string[] {
|
||||||
|
if (!rawValue) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return rawValue
|
||||||
|
.split(',')
|
||||||
|
.map((entry) => entry.trim())
|
||||||
|
.filter((entry) => entry.length > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extracts hostnames from CORS origin values.
|
||||||
|
*
|
||||||
|
* @param rawValue JSON array string or comma-separated origin list.
|
||||||
|
* @returns Hostnames parsed from valid origins.
|
||||||
|
*/
|
||||||
|
function parseCorsOriginHosts(rawValue: string | undefined): string[] {
|
||||||
|
if (!rawValue) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
let origins: string[] = [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsedOrigins = JSON.parse(rawValue);
|
||||||
|
if (Array.isArray(parsedOrigins)) {
|
||||||
|
origins = parsedOrigins.filter((entry): entry is string => typeof entry === 'string');
|
||||||
|
} else if (typeof parsedOrigins === 'string') {
|
||||||
|
origins = [parsedOrigins];
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
origins = parseCsvList(rawValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
return origins.flatMap((origin) => {
|
||||||
|
try {
|
||||||
|
const parsedUrl = new URL(origin);
|
||||||
|
return parsedUrl.hostname ? [parsedUrl.hostname] : [];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds the Vite allowed host list from environment-driven inputs.
|
||||||
|
*
|
||||||
|
* @param env Environment variable key-value map.
|
||||||
|
* @returns De-duplicated hostnames, or undefined to keep Vite defaults.
|
||||||
|
*/
|
||||||
|
function buildAllowedHosts(env: Record<string, string>): string[] | undefined {
|
||||||
|
const explicitHosts = parseCsvList(env.VITE_ALLOWED_HOSTS);
|
||||||
|
const corsOriginHosts = parseCorsOriginHosts(env.CORS_ORIGINS);
|
||||||
|
const mergedHosts = Array.from(new Set([...explicitHosts, ...corsOriginHosts]));
|
||||||
|
|
||||||
|
return mergedHosts.length > 0 ? mergedHosts : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Exports frontend build and dev-server settings.
|
* Exports frontend build and dev-server settings.
|
||||||
*/
|
*/
|
||||||
export default defineConfig({
|
export default defineConfig(({ mode }) => {
|
||||||
server: {
|
const env = loadEnv(mode, process.cwd(), '');
|
||||||
host: '0.0.0.0',
|
const allowedHosts = buildAllowedHosts(env);
|
||||||
port: 5173,
|
const apiProxyTarget = env.VITE_API_PROXY_TARGET?.trim() || 'http://localhost:8000';
|
||||||
},
|
|
||||||
|
return {
|
||||||
|
server: {
|
||||||
|
host: '0.0.0.0',
|
||||||
|
port: 5173,
|
||||||
|
proxy: {
|
||||||
|
'/api': {
|
||||||
|
target: apiProxyTarget,
|
||||||
|
changeOrigin: false,
|
||||||
|
secure: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
...(allowedHosts ? { allowedHosts } : {}),
|
||||||
|
},
|
||||||
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user