Compare commits

...

18 Commits

Author SHA1 Message Date
60ce69e115 Try a unified api endpoint 2026-03-17 17:27:22 -03:00
d6d0735ff8 Fix cookie not accepted in safari 2026-03-17 16:57:51 -03:00
72088dba9a Fix folder permissions 2026-03-17 16:37:59 -03:00
6f1fffd6e8 Update Typesense 2026-03-17 16:23:14 -03:00
490cbbb812 Normalize compose host bind mount paths 2026-03-02 22:11:33 -03:00
4fe22e3539 Document bind-mount permissions and ignore runtime data tree 2026-03-02 18:58:19 -03:00
3f7cdee995 Update cookie 2026-03-02 18:23:48 -03:00
1a04b23e89 Fix CSRF validation for duplicate cookie values on PATCH 2026-03-02 18:09:27 -03:00
2a5dfc3713 flush 2026-03-02 17:57:59 -03:00
1cd7d6541d update dockerfile 2026-03-02 17:53:26 -03:00
ec6a20ebd1 Stabilize auth cookies for proxied split-domain deployments 2026-03-02 17:50:16 -03:00
83d6a4f367 Remove frontend npm tuning and keep standard install path 2026-03-02 17:31:34 -03:00
8cf3748015 Revert "Harden frontend npm install against transient registry timeouts"
This reverts commit daa11cb768.
2026-03-02 16:58:01 -03:00
daa11cb768 Harden frontend npm install against transient registry timeouts 2026-03-02 16:57:25 -03:00
8f2c357bfc Run production frontend Nginx unprivileged under dropped caps 2026-03-02 16:41:20 -03:00
d50169b883 Serve production frontend via Nginx static build 2026-03-02 15:50:34 -03:00
b5b74845f2 Switch frontend container to production-aware runtime mode 2026-03-02 15:41:39 -03:00
0acce2e260 Wire Vite allowed hosts to env for Docker frontend 2026-03-02 15:37:39 -03:00
19 changed files with 646 additions and 129 deletions

View File

@@ -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
View File

@@ -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

View File

@@ -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/`).

View File

@@ -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.

View File

@@ -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()

View File

@@ -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,
) )

View File

@@ -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

View File

@@ -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):

View File

@@ -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."""

View File

@@ -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

View File

@@ -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:

View File

@@ -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.

View File

@@ -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

View File

@@ -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
View 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
View 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;
}
}

View File

@@ -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) {

View File

@@ -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;
} }
/** /**

View File

@@ -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 } : {}),
},
};
}); });