Stabilize API routing, CORS, and settings save behavior
This commit is contained in:
@@ -116,9 +116,9 @@ cd frontend && npm run preview
|
|||||||
Main runtime variables are defined in `docker-compose.yml`:
|
Main runtime variables are defined in `docker-compose.yml`:
|
||||||
|
|
||||||
- API and worker: `DATABASE_URL`, `REDIS_URL`, `REDIS_SECURITY_MODE`, `REDIS_TLS_MODE`, `STORAGE_ROOT`, `PUBLIC_BASE_URL`, `CORS_ORIGINS`, `ALLOW_DEVELOPMENT_ANONYMOUS_USER_ACCESS`, `TYPESENSE_*`, `APP_SETTINGS_ENCRYPTION_KEY`
|
- API and worker: `DATABASE_URL`, `REDIS_URL`, `REDIS_SECURITY_MODE`, `REDIS_TLS_MODE`, `STORAGE_ROOT`, `PUBLIC_BASE_URL`, `CORS_ORIGINS`, `ALLOW_DEVELOPMENT_ANONYMOUS_USER_ACCESS`, `TYPESENSE_*`, `APP_SETTINGS_ENCRYPTION_KEY`
|
||||||
- Frontend: optional `VITE_API_BASE`, optional `VITE_API_TOKEN` compatibility fallback, optional `VITE_DEV_PROXY_TARGET`
|
- Frontend: optional `VITE_API_BASE`, optional `VITE_API_TOKEN` compatibility fallback
|
||||||
|
|
||||||
When `VITE_API_BASE` is unset, the frontend uses relative `/api/v1` paths via Vite proxy.
|
When `VITE_API_BASE` is unset, the frontend uses `http://<current-hostname>:8000/api/v1`.
|
||||||
|
|
||||||
Application settings saved from the UI persist at:
|
Application settings saved from the UI persist at:
|
||||||
|
|
||||||
|
|||||||
@@ -18,16 +18,7 @@ from app.services.typesense_index import ensure_typesense_collection
|
|||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
UPLOAD_ENDPOINT_PATH = "/api/v1/documents/upload"
|
UPLOAD_ENDPOINT_PATH = "/api/v1/documents/upload"
|
||||||
UPLOAD_ENDPOINT_METHOD = "POST"
|
UPLOAD_ENDPOINT_METHOD = "POST"
|
||||||
DEVELOPMENT_CORS_PRIVATE_ORIGIN_REGEX = (
|
CORS_HTTP_ORIGIN_REGEX = r"^https?://[^/]+$"
|
||||||
r"^https?://("
|
|
||||||
r"localhost"
|
|
||||||
r"|127(?:\.\d{1,3}){3}"
|
|
||||||
r"|0\.0\.0\.0"
|
|
||||||
r"|10(?:\.\d{1,3}){3}"
|
|
||||||
r"|192\.168(?:\.\d{1,3}){2}"
|
|
||||||
r"|172\.(?:1[6-9]|2\d|3[0-1])(?:\.\d{1,3}){2}"
|
|
||||||
r")(?::\d+)?$"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _is_upload_size_guard_target(request: Request) -> bool:
|
def _is_upload_size_guard_target(request: Request) -> bool:
|
||||||
@@ -44,14 +35,10 @@ def create_app() -> FastAPI:
|
|||||||
"""Builds and configures the FastAPI application instance."""
|
"""Builds and configures the FastAPI application instance."""
|
||||||
|
|
||||||
app = FastAPI(title="DCM DMS API", version="0.1.0")
|
app = FastAPI(title="DCM DMS API", version="0.1.0")
|
||||||
app_env = settings.app_env.strip().lower()
|
|
||||||
allow_origin_regex = (
|
|
||||||
DEVELOPMENT_CORS_PRIVATE_ORIGIN_REGEX if app_env in {"development", "dev"} else None
|
|
||||||
)
|
|
||||||
app.add_middleware(
|
app.add_middleware(
|
||||||
CORSMiddleware,
|
CORSMiddleware,
|
||||||
allow_origins=settings.cors_origins,
|
allow_origins=settings.cors_origins,
|
||||||
allow_origin_regex=allow_origin_regex,
|
allow_origin_regex=CORS_HTTP_ORIGIN_REGEX,
|
||||||
allow_credentials=True,
|
allow_credentials=True,
|
||||||
allow_methods=["*"],
|
allow_methods=["*"],
|
||||||
allow_headers=["*"],
|
allow_headers=["*"],
|
||||||
|
|||||||
@@ -116,9 +116,8 @@ Frontend runtime API target:
|
|||||||
- `VITE_API_BASE` in `docker-compose.yml` frontend service (optional override)
|
- `VITE_API_BASE` in `docker-compose.yml` frontend service (optional override)
|
||||||
- `VITE_API_TOKEN` in `docker-compose.yml` frontend service (optional compatibility fallback only)
|
- `VITE_API_TOKEN` in `docker-compose.yml` frontend service (optional compatibility fallback only)
|
||||||
|
|
||||||
When `VITE_API_BASE` is unset, frontend API helpers call relative `/api/v1` paths and the Vite dev server proxy forwards requests to `VITE_DEV_PROXY_TARGET` (defaults to `http://api:8000` in docker-compose).
|
When `VITE_API_BASE` is unset, frontend API helpers resolve to:
|
||||||
|
- `http://<current-frontend-hostname>:8000/api/v1`
|
||||||
This avoids browser cross-origin/CORS failures for LAN-hosted development.
|
|
||||||
|
|
||||||
Frontend API authentication behavior:
|
Frontend API authentication behavior:
|
||||||
- `frontend/src/lib/api.ts` resolves bearer tokens at request time in this order:
|
- `frontend/src/lib/api.ts` resolves bearer tokens at request time in this order:
|
||||||
@@ -167,13 +166,14 @@ Retention settings are used by worker cleanup and by `POST /api/v1/processing/lo
|
|||||||
- `documents` endpoints: user token or admin token
|
- `documents` endpoints: user token or admin token
|
||||||
- `settings` and `processing/logs` endpoints: admin token only
|
- `settings` and `processing/logs` endpoints: admin token only
|
||||||
- Development environments can allow tokenless user-role access for document/search routes via `ALLOW_DEVELOPMENT_ANONYMOUS_USER_ACCESS=true`; production remains token-enforced.
|
- Development environments can allow tokenless user-role access for document/search routes via `ALLOW_DEVELOPMENT_ANONYMOUS_USER_ACCESS=true`; production remains token-enforced.
|
||||||
- Development CORS allows localhost and RFC1918 private-network origins via regex in addition to explicit `CORS_ORIGINS`, so LAN-hosted frontend access remains functional.
|
- CORS allows HTTP and HTTPS origins by regex in addition to explicit `CORS_ORIGINS`, so LAN and public-domain frontend origins are accepted.
|
||||||
- Authentication fails closed when `ADMIN_API_TOKEN` is not configured and admin access is requested.
|
- Authentication fails closed when `ADMIN_API_TOKEN` is not configured and admin access is requested.
|
||||||
- Document preview endpoint blocks inline rendering for script-capable MIME types and forces attachment responses for active content.
|
- Document preview endpoint blocks inline rendering for script-capable MIME types and forces attachment responses for active content.
|
||||||
- Provider base URLs are validated on settings updates and before outbound model calls:
|
- Provider base URLs are validated on settings updates and before outbound model calls:
|
||||||
- allowlist enforcement (`PROVIDER_BASE_URL_ALLOWLIST`)
|
- optional allowlist enforcement (`PROVIDER_BASE_URL_ALLOWLIST`)
|
||||||
- scheme restrictions (`https` by default)
|
- optional scheme restrictions (`PROVIDER_BASE_URL_ALLOW_HTTP`)
|
||||||
- local/private-network blocking and per-request DNS revalidation checks for outbound runtime calls, including OCR provider path
|
- optional private-network restrictions (`PROVIDER_BASE_URL_ALLOW_PRIVATE_NETWORK`)
|
||||||
|
- per-request DNS revalidation checks for outbound runtime calls, including OCR provider path
|
||||||
- Upload and archive safety guards are enforced:
|
- Upload and archive safety guards are enforced:
|
||||||
- `POST /api/v1/documents/upload` requires `Content-Length` and enforces file-count, per-file size, and total request size limits
|
- `POST /api/v1/documents/upload` requires `Content-Length` and enforces file-count, per-file size, and total request size limits
|
||||||
- `OPTIONS /api/v1/documents/upload` CORS preflight is excluded from `Content-Length` enforcement
|
- `OPTIONS /api/v1/documents/upload` CORS preflight is excluded from `Content-Length` enforcement
|
||||||
|
|||||||
@@ -47,9 +47,9 @@ services:
|
|||||||
ADMIN_API_TOKEN: ${ADMIN_API_TOKEN:?ADMIN_API_TOKEN must be set}
|
ADMIN_API_TOKEN: ${ADMIN_API_TOKEN:?ADMIN_API_TOKEN must be set}
|
||||||
USER_API_TOKEN: ${USER_API_TOKEN:?USER_API_TOKEN must be set}
|
USER_API_TOKEN: ${USER_API_TOKEN:?USER_API_TOKEN must be set}
|
||||||
APP_SETTINGS_ENCRYPTION_KEY: ${APP_SETTINGS_ENCRYPTION_KEY:?APP_SETTINGS_ENCRYPTION_KEY must be set}
|
APP_SETTINGS_ENCRYPTION_KEY: ${APP_SETTINGS_ENCRYPTION_KEY:?APP_SETTINGS_ENCRYPTION_KEY must be set}
|
||||||
PROVIDER_BASE_URL_ALLOWLIST: '${PROVIDER_BASE_URL_ALLOWLIST:-["api.openai.com"]}'
|
PROVIDER_BASE_URL_ALLOWLIST: '${PROVIDER_BASE_URL_ALLOWLIST:-[]}'
|
||||||
PROVIDER_BASE_URL_ALLOW_HTTP: ${PROVIDER_BASE_URL_ALLOW_HTTP:-false}
|
PROVIDER_BASE_URL_ALLOW_HTTP: ${PROVIDER_BASE_URL_ALLOW_HTTP:-true}
|
||||||
PROVIDER_BASE_URL_ALLOW_PRIVATE_NETWORK: ${PROVIDER_BASE_URL_ALLOW_PRIVATE_NETWORK:-false}
|
PROVIDER_BASE_URL_ALLOW_PRIVATE_NETWORK: ${PROVIDER_BASE_URL_ALLOW_PRIVATE_NETWORK:-true}
|
||||||
OCR_LANGUAGES: eng,deu
|
OCR_LANGUAGES: eng,deu
|
||||||
PUBLIC_BASE_URL: ${PUBLIC_BASE_URL:-http://localhost:8000}
|
PUBLIC_BASE_URL: ${PUBLIC_BASE_URL:-http://localhost:8000}
|
||||||
CORS_ORIGINS: '${CORS_ORIGINS:-["http://localhost:5173","http://localhost:3000"]}'
|
CORS_ORIGINS: '${CORS_ORIGINS:-["http://localhost:5173","http://localhost:3000"]}'
|
||||||
@@ -90,9 +90,9 @@ services:
|
|||||||
ADMIN_API_TOKEN: ${ADMIN_API_TOKEN:?ADMIN_API_TOKEN must be set}
|
ADMIN_API_TOKEN: ${ADMIN_API_TOKEN:?ADMIN_API_TOKEN must be set}
|
||||||
USER_API_TOKEN: ${USER_API_TOKEN:?USER_API_TOKEN must be set}
|
USER_API_TOKEN: ${USER_API_TOKEN:?USER_API_TOKEN must be set}
|
||||||
APP_SETTINGS_ENCRYPTION_KEY: ${APP_SETTINGS_ENCRYPTION_KEY:?APP_SETTINGS_ENCRYPTION_KEY must be set}
|
APP_SETTINGS_ENCRYPTION_KEY: ${APP_SETTINGS_ENCRYPTION_KEY:?APP_SETTINGS_ENCRYPTION_KEY must be set}
|
||||||
PROVIDER_BASE_URL_ALLOWLIST: '${PROVIDER_BASE_URL_ALLOWLIST:-["api.openai.com"]}'
|
PROVIDER_BASE_URL_ALLOWLIST: '${PROVIDER_BASE_URL_ALLOWLIST:-[]}'
|
||||||
PROVIDER_BASE_URL_ALLOW_HTTP: ${PROVIDER_BASE_URL_ALLOW_HTTP:-false}
|
PROVIDER_BASE_URL_ALLOW_HTTP: ${PROVIDER_BASE_URL_ALLOW_HTTP:-true}
|
||||||
PROVIDER_BASE_URL_ALLOW_PRIVATE_NETWORK: ${PROVIDER_BASE_URL_ALLOW_PRIVATE_NETWORK:-false}
|
PROVIDER_BASE_URL_ALLOW_PRIVATE_NETWORK: ${PROVIDER_BASE_URL_ALLOW_PRIVATE_NETWORK:-true}
|
||||||
OCR_LANGUAGES: eng,deu
|
OCR_LANGUAGES: eng,deu
|
||||||
PUBLIC_BASE_URL: http://localhost:8000
|
PUBLIC_BASE_URL: http://localhost:8000
|
||||||
TYPESENSE_PROTOCOL: http
|
TYPESENSE_PROTOCOL: http
|
||||||
@@ -120,7 +120,6 @@ services:
|
|||||||
context: ./frontend
|
context: ./frontend
|
||||||
environment:
|
environment:
|
||||||
VITE_API_BASE: ${VITE_API_BASE:-}
|
VITE_API_BASE: ${VITE_API_BASE:-}
|
||||||
VITE_DEV_PROXY_TARGET: ${VITE_DEV_PROXY_TARGET:-http://api:8000}
|
|
||||||
VITE_API_TOKEN: ${VITE_API_TOKEN:-}
|
VITE_API_TOKEN: ${VITE_API_TOKEN:-}
|
||||||
ports:
|
ports:
|
||||||
- "${HOST_BIND_IP:-127.0.0.1}:5173:5173"
|
- "${HOST_BIND_IP:-127.0.0.1}:5173:5173"
|
||||||
|
|||||||
@@ -97,11 +97,11 @@ async function runApiTests(): Promise<void> {
|
|||||||
assert(await thumbnail.text() === 'preview-bytes', 'Thumbnail blob bytes mismatch');
|
assert(await thumbnail.text() === 'preview-bytes', 'Thumbnail blob bytes mismatch');
|
||||||
assert(await preview.text() === 'preview-bytes', 'Preview blob bytes mismatch');
|
assert(await preview.text() === 'preview-bytes', 'Preview blob bytes mismatch');
|
||||||
assert(
|
assert(
|
||||||
requestUrls[0] === '/api/v1/documents/doc-1/thumbnail',
|
requestUrls[0] === 'http://localhost:8000/api/v1/documents/doc-1/thumbnail',
|
||||||
`Unexpected thumbnail URL ${requestUrls[0]}`,
|
`Unexpected thumbnail URL ${requestUrls[0]}`,
|
||||||
);
|
);
|
||||||
assert(
|
assert(
|
||||||
requestUrls[1] === '/api/v1/documents/doc-1/preview',
|
requestUrls[1] === 'http://localhost:8000/api/v1/documents/doc-1/preview',
|
||||||
`Unexpected preview URL ${requestUrls[1]}`,
|
`Unexpected preview URL ${requestUrls[1]}`,
|
||||||
);
|
);
|
||||||
assert(requestAuthHeaders[0] === null, `Expected no auth header for thumbnail request, got "${requestAuthHeaders[0]}"`);
|
assert(requestAuthHeaders[0] === null, `Expected no auth header for thumbnail request, got "${requestAuthHeaders[0]}"`);
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import type {
|
|||||||
} from '../types';
|
} from '../types';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolves backend base URL from environment with same-origin proxy fallback.
|
* Resolves backend base URL from environment with host-derived HTTP fallback.
|
||||||
*/
|
*/
|
||||||
function resolveApiBase(): string {
|
function resolveApiBase(): string {
|
||||||
const envValue = import.meta.env?.VITE_API_BASE;
|
const envValue = import.meta.env?.VITE_API_BASE;
|
||||||
@@ -25,7 +25,10 @@ function resolveApiBase(): string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return '/api/v1';
|
if (typeof window !== 'undefined' && window.location?.hostname) {
|
||||||
|
return `${window.location.protocol}//${window.location.hostname}:8000/api/v1`;
|
||||||
|
}
|
||||||
|
return 'http://localhost:8000/api/v1';
|
||||||
}
|
}
|
||||||
|
|
||||||
const API_BASE = resolveApiBase();
|
const API_BASE = resolveApiBase();
|
||||||
@@ -54,6 +57,8 @@ let runtimeTokenResolver: ApiTokenResolver | null = null;
|
|||||||
|
|
||||||
type ApiRequestInit = Omit<RequestInit, 'headers'> & { headers?: HeadersInit };
|
type ApiRequestInit = Omit<RequestInit, 'headers'> & { headers?: HeadersInit };
|
||||||
|
|
||||||
|
type ApiErrorPayload = { detail?: string } | null;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Normalizes candidate token values by trimming whitespace and filtering non-string values.
|
* Normalizes candidate token values by trimming whitespace and filtering non-string values.
|
||||||
*/
|
*/
|
||||||
@@ -163,6 +168,21 @@ function apiRequest(input: string, init: ApiRequestInit = {}): Promise<Response>
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extracts backend error detail text from JSON payloads when available.
|
||||||
|
*/
|
||||||
|
async function responseErrorDetail(response: Response): Promise<string> {
|
||||||
|
try {
|
||||||
|
const payload = (await response.json()) as ApiErrorPayload;
|
||||||
|
if (payload && typeof payload.detail === 'string' && payload.detail.trim()) {
|
||||||
|
return payload.detail.trim();
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Encodes query parameters while skipping undefined and null values.
|
* Encodes query parameters while skipping undefined and null values.
|
||||||
*/
|
*/
|
||||||
@@ -607,7 +627,8 @@ export async function updateAppSettings(payload: AppSettingsUpdate): Promise<App
|
|||||||
body: JSON.stringify(payload),
|
body: JSON.stringify(payload),
|
||||||
});
|
});
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error('Failed to update settings');
|
const detail = await responseErrorDetail(response);
|
||||||
|
throw new Error(detail ? `Failed to update settings: ${detail}` : 'Failed to update settings');
|
||||||
}
|
}
|
||||||
return response.json() as Promise<AppSettings>;
|
return response.json() as Promise<AppSettings>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,11 +10,5 @@ export default defineConfig({
|
|||||||
server: {
|
server: {
|
||||||
host: '0.0.0.0',
|
host: '0.0.0.0',
|
||||||
port: 5173,
|
port: 5173,
|
||||||
proxy: {
|
|
||||||
'/api/v1': {
|
|
||||||
target: process.env.VITE_DEV_PROXY_TARGET ?? 'http://localhost:8000',
|
|
||||||
changeOrigin: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user