Compare commits

..

6 Commits

12 changed files with 318 additions and 83 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
@@ -24,6 +27,8 @@ 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: # 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_DOMAIN=docs.lan
# AUTH_COOKIE_SAMESITE=auto # AUTH_COOKIE_SAMESITE=auto
@@ -42,8 +47,11 @@ 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"]
# Used at build time for production frontend image, and at runtime in development. # 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.
VITE_API_PROXY_TARGET=http://localhost:8000
# Development-only Vite host allowlist override. # Development-only Vite host allowlist override.
VITE_ALLOWED_HOSTS= VITE_ALLOWED_HOSTS=

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

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

@@ -107,12 +107,16 @@ 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()
@@ -127,7 +131,15 @@ def get_request_auth_context(
): ):
_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

@@ -90,12 +90,49 @@ def _resolve_cookie_domain() -> str | None:
return configured_domain return configured_domain
def _resolve_cookie_samesite(secure_cookie: bool) -> str: def _resolve_cookie_domains() -> tuple[str | None, ...]:
"""Returns cookie SameSite mode with secure-aware defaults for browser compatibility.""" """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
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() configured_mode = get_settings().auth_cookie_samesite.strip().lower()
if configured_mode in {"strict", "lax", "none"}: if configured_mode in {"strict", "lax"}:
return configured_mode return configured_mode
if configured_mode == "none":
return "lax" if _request_matches_cookie_domain(request) else "none"
return "none" if secure_cookie else "lax" return "none" if secure_cookie else "lax"
@@ -107,30 +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)
cookie_domain = _resolve_cookie_domain() same_site_mode = _resolve_cookie_samesite(request, secure)
same_site_mode = _resolve_cookie_samesite(secure) for cookie_domain in _resolve_cookie_domains():
response.set_cookie( cookie_kwargs = {
SESSION_COOKIE_NAME, "value": session_token,
value=session_token, "max_age": expires_seconds,
max_age=expires_seconds, "httponly": True,
httponly=True, "secure": secure,
secure=secure, "samesite": same_site_mode,
samesite=same_site_mode, "path": "/",
path="/", }
domain=cookie_domain, 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:
@@ -138,18 +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
cookie_domain = _resolve_cookie_domain() same_site_mode = _resolve_cookie_samesite(request, secure)
same_site_mode = _resolve_cookie_samesite(secure) for cookie_domain in _resolve_cookie_domains():
response.set_cookie( cookie_kwargs = {
CSRF_COOKIE_NAME, "value": csrf_token,
value=csrf_token, "max_age": _session_cookie_ttl_seconds(expires_at),
max_age=_session_cookie_ttl_seconds(expires_at), "httponly": False,
httponly=False, "secure": secure,
secure=secure, "samesite": same_site_mode,
samesite=same_site_mode, "path": "/",
path="/", }
domain=cookie_domain, 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:
@@ -157,9 +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
cookie_domain = _resolve_cookie_domain() for cookie_domain in _resolve_cookie_domains():
response.delete_cookie(SESSION_COOKIE_NAME, path="/", domain=cookie_domain) delete_kwargs = {"path": "/"}
response.delete_cookie(CSRF_COOKIE_NAME, path="/", domain=cookie_domain) 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)
@@ -241,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,
) )

View File

@@ -478,6 +478,39 @@ class AuthDependencyTests(unittest.TestCase):
self.assertEqual(raised.exception.status_code, 403) self.assertEqual(raised.exception.status_code, 403)
self.assertEqual(raised.exception.detail, "Invalid CSRF token") 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."""
@@ -842,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:
@@ -970,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

@@ -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,6 +43,22 @@ Tail logs:
docker compose logs -f docker compose logs -f
``` ```
## Host Bind Mounts
Compose is configured with host bind mounts for persistent data. Ensure host directories exist and are writable by the backend runtime user.
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:
```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
```
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 ## 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. The frontend Dockerfile uses `node:22-slim` with a standard `npm ci --no-audit` install step and no npm-specific build tuning flags.
@@ -70,8 +87,8 @@ 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` (build-time value for production frontend image) | | `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` | | `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` |
@@ -81,8 +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 (host-only cookies) | parent frontend/API domain for split hosts, for example `docs.lan` | | `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` for cross-origin frontend/API deployments, `lax` or `strict` for same-origin | | `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"]` |

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
@@ -148,10 +167,11 @@ services:
VITE_API_BASE: ${VITE_API_BASE:-} VITE_API_BASE: ${VITE_API_BASE:-}
environment: environment:
VITE_API_BASE: ${VITE_API_BASE:-} VITE_API_BASE: ${VITE_API_BASE:-}
VITE_API_PROXY_TARGET: ${VITE_API_PROXY_TARGET:-http://api:8000}
CORS_ORIGINS: '${CORS_ORIGINS:-["http://localhost:5173","http://localhost:3000"]}' CORS_ORIGINS: '${CORS_ORIGINS:-["http://localhost:5173","http://localhost:3000"]}'
VITE_ALLOWED_HOSTS: ${VITE_ALLOWED_HOSTS:-} VITE_ALLOWED_HOSTS: ${VITE_ALLOWED_HOSTS:-}
# ports: # ports:
# - "${HOST_BIND_IP:-127.0.0.1}:5173:5173" # - "${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
@@ -169,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

@@ -2,10 +2,20 @@ server {
listen 5173; listen 5173;
listen [::]:5173; listen [::]:5173;
server_name _; server_name _;
client_max_body_size 100m;
root /usr/share/nginx/html; root /usr/share/nginx/html;
index index.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 / { location / {
try_files $uri $uri/ /index.html; 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';
} }

View File

@@ -74,11 +74,19 @@ function buildAllowedHosts(env: Record<string, string>): string[] | undefined {
export default defineConfig(({ mode }) => { export default defineConfig(({ mode }) => {
const env = loadEnv(mode, process.cwd(), ''); const env = loadEnv(mode, process.cwd(), '');
const allowedHosts = buildAllowedHosts(env); const allowedHosts = buildAllowedHosts(env);
const apiProxyTarget = env.VITE_API_PROXY_TARGET?.trim() || 'http://localhost:8000';
return { return {
server: { server: {
host: '0.0.0.0', host: '0.0.0.0',
port: 5173, port: 5173,
proxy: {
'/api': {
target: apiProxyTarget,
changeOrigin: false,
secure: false,
},
},
...(allowedHosts ? { allowedHosts } : {}), ...(allowedHosts ? { allowedHosts } : {}),
}, },
}; };