Compare commits
173 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8e744c076d | |||
| cc494c6937 | |||
| e2fa96318f | |||
| 416c3c4758 | |||
| 1c7ef28c5d | |||
| 1a0218799b | |||
| 048f28a671 | |||
| b818f07c80 | |||
| 06507ed8bf | |||
| 044a9a86d6 | |||
| 62d4ca6367 | |||
| 871f788f0b | |||
| 18f5596b01 | |||
| afb8b367ee | |||
| 58b14ae769 | |||
| 23c5bac666 | |||
| d925dc5cd3 | |||
| b8ddd2f8d2 | |||
| 301ca9d186 | |||
| 3bd8a52ea2 | |||
| 789e8c6236 | |||
| a3ea215a1c | |||
| c6e0b80bec | |||
| e86e1b9f13 | |||
| 46a9a30af0 | |||
| bdfa3ba1e0 | |||
| 8295279ec9 | |||
| 152ee15b06 | |||
| a75e8b9297 | |||
| ee664f83fb | |||
| 91ef089aa7 | |||
| 925864ddca | |||
| 57db4c7c82 | |||
| 35752c9092 | |||
| 9b9882c4d6 | |||
| 6a77533795 | |||
| 69053bfb08 | |||
| f1cf5d0e76 | |||
| c547ad1acc | |||
| 1625896e30 | |||
| 470f08d893 | |||
| a57766ed7e | |||
| 76f202f7f1 | |||
| 33484b236a | |||
| 6927e7643f | |||
| aeb50c21d2 | |||
| 45e41338dc | |||
| f0d3563029 | |||
| 12a35f9b30 | |||
| 4136033bf0 | |||
| 48331f6e91 | |||
| cafc0e4523 | |||
| a2c9915908 | |||
| c3124b08bd | |||
| 4faba2ec08 | |||
| 2f3c22000c | |||
| aca12858c1 | |||
| ae524bb94e | |||
| 5af4dbcb25 | |||
| 94a090da77 | |||
| 3fd8b32724 | |||
| 7f20c903ef | |||
| 8fcc223ef1 | |||
| ce050b542a | |||
| 114b69a8c2 | |||
| f6591d10fc | |||
| c26c376e2e | |||
| 627a8579de | |||
| f041f28d9f | |||
| 7dec570ce6 | |||
| c7d3ec57c3 | |||
| 11a49eb7fd | |||
| 527fb666d0 | |||
| 07bdee94b0 | |||
| b7e33af669 | |||
| 965647940b | |||
| 2f5e2a0fcd | |||
| c9bb2b6807 | |||
| c03ce8cdb2 | |||
| 0b8f967eb5 | |||
| 315d85c443 | |||
| f8f95085fc | |||
| 7bd9d20091 | |||
| c61afb2c33 | |||
| dd5603bdd0 | |||
| 0c3ee6f484 | |||
| 6f510d5a2d | |||
| 2014a93c1b | |||
| 3be93be35a | |||
| 4cac9fbe9b | |||
| bcdbf9d178 | |||
| fc1365dfec | |||
| 52e8e535ff | |||
| ef5528dcef | |||
| ea2dad0c08 | |||
| 8e26e3aaa7 | |||
| 2a5296d7e7 | |||
| a052b2fbe0 | |||
| a384b222f1 | |||
| 326adb1858 | |||
| e330a611d0 | |||
| 15dd1a8471 | |||
| 2ee6bfc7ea | |||
| 248619d91e | |||
| 8984d4da70 | |||
| 0596206e17 | |||
| 26963a8464 | |||
| 1e7f2275d3 | |||
| 88368f7f76 | |||
| 7fbcf5c593 | |||
| 01c04c20ce | |||
| cfcce57e35 | |||
| 10297ede37 | |||
| 78bd5b5904 | |||
| 9153b1f7f0 | |||
| f9b4975093 | |||
| 47595818b4 | |||
| bad369548b | |||
| 7ea8b17fd2 | |||
| cc5e879ea9 | |||
| 7490462c67 | |||
| c25e1b0e21 | |||
| a10f8e628e | |||
| 1018ea17d9 | |||
| 7c1967b728 | |||
| 06b0fced38 | |||
| 341839fe5e | |||
| 28d4bced0c | |||
| d22175310a | |||
| 7755f5a1ed | |||
| cee6d6d4e1 | |||
| 0497fbbbad | |||
| 57795ee487 | |||
| a4bd1d7178 | |||
| d94e9ca4bc | |||
| 82c6915c42 | |||
| d71945b7b9 | |||
| 91f36e0d5a | |||
| 1e69c55947 | |||
| 9b748db4d4 | |||
| eeae47ddec | |||
| be500e1a2b | |||
| 45d3ac8cf0 | |||
| 4df4673628 | |||
| 9642020887 | |||
| 89dec45062 | |||
| 34457f9775 | |||
| b0fe1a014a | |||
| 1d9bd15a70 | |||
| b50a534454 | |||
| 80de3b6743 | |||
| 8b4280d5dd | |||
| 93629ff440 | |||
| c4e10bee5e | |||
| 084171395d | |||
| 958b195e79 | |||
| c5ef1ec50c | |||
| b4e0067a27 | |||
| 6188003897 | |||
| ef897f660d | |||
| 6cb9feacab | |||
| 76c0e91500 | |||
| 0c7ea6748d | |||
| 78077fa8c7 | |||
| 242846aa9c | |||
| 868613ac49 | |||
| 33a0e49acd | |||
| 14b3031e63 | |||
| 1d7df13c94 | |||
| ce4bca0186 | |||
| 4b07e996ad | |||
| 720c9c11b0 | |||
| 48a303d498 |
@@ -3,21 +3,10 @@ WORKDIR=/workdir
|
|||||||
DATABASE_URL=sqlite:///./app/database.db
|
DATABASE_URL=sqlite:///./app/database.db
|
||||||
REDIS_URL=redis://redis:6379/0
|
REDIS_URL=redis://redis:6379/0
|
||||||
EXTERNAL_HOSTNAME=docuelevate.example.com
|
EXTERNAL_HOSTNAME=docuelevate.example.com
|
||||||
# PUBLIC_BASE_URL=https://docuelevate.example.com # Full URL with scheme; required when X-Forwarded-Proto is not forwarded by your proxy
|
|
||||||
GOTENBERG_URL=http://gotenberg:3000
|
GOTENBERG_URL=http://gotenberg:3000
|
||||||
ALLOW_FILE_DELETE=true # Allow deletion of file records
|
ALLOW_FILE_DELETE=true # Allow deletion of file records
|
||||||
COMPLIANCE_ENABLED=true # Enable compliance templates dashboard (GDPR, HIPAA, SOC 2)
|
COMPLIANCE_ENABLED=true # Enable compliance templates dashboard (GDPR, HIPAA, SOC 2)
|
||||||
|
|
||||||
# **Database Connection Pool** (PostgreSQL / MySQL only; ignored for SQLite)
|
|
||||||
# DB_POOL_SIZE=10 # Persistent connections per worker (default: 10)
|
|
||||||
# DB_MAX_OVERFLOW=20 # Extra connections under burst (default: 20)
|
|
||||||
# DB_POOL_TIMEOUT=30 # Seconds to wait for a pool connection (default: 30)
|
|
||||||
# DB_POOL_RECYCLE=1800 # Recycle connections after N seconds (default: 1800)
|
|
||||||
|
|
||||||
# **Per-User Upload Rate Limiting** (health-aware, Redis-backed)
|
|
||||||
# UPLOAD_RATE_LIMIT_PER_USER=20 # Max uploads per user per window (default: 20)
|
|
||||||
# UPLOAD_RATE_LIMIT_WINDOW=60 # Sliding window in seconds (default: 60)
|
|
||||||
|
|
||||||
# **System Reset / Factory Reset**
|
# **System Reset / Factory Reset**
|
||||||
# FACTORY_RESET_ON_STARTUP=false # Wipe all user data on every startup (demo/testing only)
|
# FACTORY_RESET_ON_STARTUP=false # Wipe all user data on every startup (demo/testing only)
|
||||||
# ENABLE_FACTORY_RESET=false # Show the System Reset page in admin UI
|
# ENABLE_FACTORY_RESET=false # Show the System Reset page in admin UI
|
||||||
@@ -639,23 +628,6 @@ EMBEDDING_MAX_TOKENS=8000
|
|||||||
# Attach PII (IP addresses, user agents) to Sentry events.
|
# Attach PII (IP addresses, user agents) to Sentry events.
|
||||||
# Disable (default) to stay GDPR/CCPA compliant.
|
# Disable (default) to stay GDPR/CCPA compliant.
|
||||||
# SENTRY_SEND_DEFAULT_PII=false
|
# SENTRY_SEND_DEFAULT_PII=false
|
||||||
#
|
|
||||||
# --- Browser (JavaScript) SDK ---
|
|
||||||
# The same DSN is reused for the Sentry Browser SDK which is injected into
|
|
||||||
# every rendered page. The DSN is a *public* key and is intentionally
|
|
||||||
# embedded in client-side code.
|
|
||||||
#
|
|
||||||
# Fraction of browser navigations captured for client-side performance tracing.
|
|
||||||
# 0.0 (default) disables browser tracing; 1.0 captures every navigation.
|
|
||||||
# SENTRY_JS_TRACES_SAMPLE_RATE=0.0
|
|
||||||
#
|
|
||||||
# Fraction of browser sessions recorded by Sentry Session Replay.
|
|
||||||
# 0.0 (default) disables session recording; 1.0 records every session.
|
|
||||||
# SENTRY_JS_REPLAY_SESSION_SAMPLE_RATE=0.0
|
|
||||||
#
|
|
||||||
# Fraction of error sessions recorded by Sentry Session Replay.
|
|
||||||
# Defaults to 0.1 (10 %) so errors are captured with replay context.
|
|
||||||
# SENTRY_JS_REPLAY_ON_ERROR_SAMPLE_RATE=0.1
|
|
||||||
|
|
||||||
# **Mobile App – Push Notifications**
|
# **Mobile App – Push Notifications**
|
||||||
# Push notifications are delivered via Expo's push notification service
|
# Push notifications are delivered via Expo's push notification service
|
||||||
|
|||||||
@@ -100,7 +100,7 @@ jobs:
|
|||||||
python-version: "3.11"
|
python-version: "3.11"
|
||||||
cache: 'pip'
|
cache: 'pip'
|
||||||
- run: pip install pip-audit>=2.7.0
|
- run: pip install pip-audit>=2.7.0
|
||||||
- run: pip-audit -r requirements.txt --desc on
|
- run: pip-audit -r requirements.txt --desc on --ignore-vuln CVE-2026-4539
|
||||||
|
|
||||||
run-tests:
|
run-tests:
|
||||||
name: Execute All Tests (Quick + Integration)
|
name: Execute All Tests (Quick + Integration)
|
||||||
|
|||||||
@@ -200,3 +200,5 @@ cython_debug/
|
|||||||
# Build metadata files - generated at build time
|
# Build metadata files - generated at build time
|
||||||
GIT_SHA
|
GIT_SHA
|
||||||
RUNTIME_INFO
|
RUNTIME_INFO
|
||||||
|
node_modules
|
||||||
|
frontend/node_modules
|
||||||
|
|||||||
@@ -1,3 +0,0 @@
|
|||||||
[submodule "vendor/embed-pdf-viewer"]
|
|
||||||
path = vendor/embed-pdf-viewer
|
|
||||||
url = https://github.com/embedpdf/embed-pdf-viewer.git
|
|
||||||
@@ -6,3 +6,25 @@
|
|||||||
**Vulnerability:** The `_test_webdav_connection` function used `urllib.request.urlopen`, which natively supports dangerous schemes like `file://` or `ftp://` and follows redirects by default, potentially allowing SSRF bypasses or Local File Inclusion.
|
**Vulnerability:** The `_test_webdav_connection` function used `urllib.request.urlopen`, which natively supports dangerous schemes like `file://` or `ftp://` and follows redirects by default, potentially allowing SSRF bypasses or Local File Inclusion.
|
||||||
**Learning:** `urllib.request` should be avoided for user-supplied URLs. Even when URL schemes are manually validated, `urllib`'s default redirect following behavior can bypass SSRF protections (e.g. redirecting to `127.0.0.1`).
|
**Learning:** `urllib.request` should be avoided for user-supplied URLs. Even when URL schemes are manually validated, `urllib`'s default redirect following behavior can bypass SSRF protections (e.g. redirecting to `127.0.0.1`).
|
||||||
**Prevention:** Use a modern, safer HTTP client like `httpx` with `follow_redirects=False` when testing user-provided URLs.
|
**Prevention:** Use a modern, safer HTTP client like `httpx` with `follow_redirects=False` when testing user-provided URLs.
|
||||||
|
|
||||||
|
## 2026-03-20 - Safe Path Traversal Prevention in Low-Level Utilities
|
||||||
|
**Vulnerability:** The generic file utility `hash_file` in `app/utils/file_operations.py` accepted any file path and was vulnerable to reading arbitrary files via path traversal (e.g., `../../../etc/passwd`) or absolute paths if an attacker could control the `filepath` argument.
|
||||||
|
**Learning:** Naively checking for `".." in path` breaks legitimate relative paths used internally by the application. Blocking absolute paths entirely also breaks functionality. Input validation should occur at the API boundary, but for defense-in-depth, low-level utilities must enforce expected boundaries (e.g., the application's `workdir`).
|
||||||
|
**Prevention:** Use `pathlib.Path.resolve()` on both the target path and the allowed base directory (`settings.workdir`). Ensure the resolved target path is strictly within the allowed boundary using `filepath_obj.relative_to(workdir_obj)`, catching the `ValueError` that is raised when the path is out of bounds. This safely blocks both relative traversal attacks and arbitrary absolute paths.
|
||||||
|
## 2025-05-18 - [SSRF Bypass via DNS Resolution Failure]
|
||||||
|
**Vulnerability:** The `is_private_ip` function in `app/utils/network.py` failed open (returned `False`) when a hostname could not be resolved (`socket.gaierror`).
|
||||||
|
**Learning:** This fail-open pattern was originally added to allow external domains in tests, but in production, it created a severe SSRF risk. An attacker could bypass SSRF protections by providing a URL that fails to resolve during the security check but resolves later (DNS rebinding), or by exploiting internal routing behaviors via unresolvable addresses.
|
||||||
|
**Prevention:** Always fail securely in network authorization functions. If a domain cannot be resolved to verify its safety, the request must be blocked (`return True` / default-deny). Tests should mock DNS resolution correctly instead of compromising production security logic.
|
||||||
|
## 2026-03-26 - SSRF in Integration Connection Tests
|
||||||
|
**Vulnerability:** The `_test_imap_connection` and `_test_s3_connection` functions in `app/api/integrations.py` did not validate user-provided `host` and `endpoint_url` variables against `is_private_ip()`. This allowed an attacker to test the presence of internal IMAP servers or direct S3 SDK API calls to internal infrastructure via SSRF.
|
||||||
|
**Learning:** Any time a new generic connection or integration test is added, SSRF validation may be forgotten if the core network utility (`is_private_ip`) is not systematically applied to all outbound network operations, regardless of the protocol (e.g., IMAP, S3).
|
||||||
|
**Prevention:** Establish a pattern where any user-configurable host or endpoint URL is immediately passed through the centralized `is_private_ip` validation function before any network call or third-party client initialization.
|
||||||
|
|
||||||
|
## 2024-05-27 - SSRF Bypass via HTTP Redirects
|
||||||
|
**Vulnerability:** In `app/api/url_upload.py`, the `validate_url_safety` function was correctly verifying the initially requested URL to prevent fetching internal IPs or cloud metadata endpoints. However, the subsequent `httpx.AsyncClient` was configured with `follow_redirects=True` without validating the destination of those redirects. An attacker could bypass SSRF protections by providing a URL to an attacker-controlled server that responds with a 301/302 redirect pointing to an internal target (e.g., `http://127.0.0.1` or `http://169.254.169.254`).
|
||||||
|
**Learning:** Checking the URL before sending the request is insufficient if the HTTP client automatically follows redirects. The target of every single redirect must be subject to the same strict validation as the initial request.
|
||||||
|
**Prevention:** Avoid `follow_redirects=True` for user-provided URLs when possible. If redirects must be followed, attach an event hook (e.g., `event_hooks={"response": [hook_function]}`) to the `httpx` client to intercept the response, calculate the redirect destination from the `Location` header, and run the URL safety validation logic before the redirect is actually followed.
|
||||||
|
## 2026-03-27 - SSRF Bypass via HTTP Redirects in httpx
|
||||||
|
**Vulnerability:** The `/process-url` endpoint used `httpx.AsyncClient(follow_redirects=True)` after validating the initial user-provided URL against SSRF protections. However, it did not validate the target URLs of any subsequent HTTP redirects, allowing an attacker to provide a safe URL that redirects to an internal/private IP, bypassing the security check.
|
||||||
|
**Learning:** Initial URL validation is insufficient when the HTTP client is configured to follow redirects automatically. The client must be explicitly configured to validate every redirect target.
|
||||||
|
**Prevention:** When using `httpx.AsyncClient(follow_redirects=True)` for user-provided URLs, always implement a redirect validator hook function (e.g., using `event_hooks={'response': [validate_redirect]}`) that resolves the `Location` header and passes it through the same SSRF validation logic before the redirect is followed.
|
||||||
|
|||||||
+1
-1
@@ -1 +1 @@
|
|||||||
2026-03-22T17:37:50Z
|
2026-05-17T14:20:08Z
|
||||||
|
|||||||
+483
@@ -10,6 +10,489 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
<!-- version list -->
|
<!-- version list -->
|
||||||
|
|
||||||
|
## v0.172.12 (2026-05-17)
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- Validate webhook targets before delivery
|
||||||
|
([#846](https://github.com/christianlouis/DocuElevate/pull/846),
|
||||||
|
[`e2fa963`](https://github.com/christianlouis/DocuElevate/commit/e2fa96318f5bd45607baa0fe08a0bf14e1ca83d4))
|
||||||
|
|
||||||
|
### Testing
|
||||||
|
|
||||||
|
- Cover webhook SSRF validation ([#846](https://github.com/christianlouis/DocuElevate/pull/846),
|
||||||
|
[`e2fa963`](https://github.com/christianlouis/DocuElevate/commit/e2fa96318f5bd45607baa0fe08a0bf14e1ca83d4))
|
||||||
|
|
||||||
|
|
||||||
|
## v0.172.11 (2026-05-17)
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- Escape search result template values
|
||||||
|
([#853](https://github.com/christianlouis/DocuElevate/pull/853),
|
||||||
|
[`1a02187`](https://github.com/christianlouis/DocuElevate/commit/1a0218799b9a1eb4154e2f4fbb2572cb3922106a))
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
|
||||||
|
- **changelog**: Update changelog [skip ci]
|
||||||
|
([`048f28a`](https://github.com/christianlouis/DocuElevate/commit/048f28a6717fa7f5cf4b235f9142e625e80e5d59))
|
||||||
|
|
||||||
|
|
||||||
|
## Unreleased
|
||||||
|
|
||||||
|
|
||||||
|
## v0.172.10 (2026-05-17)
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- **url-upload**: Handle unsafe redirects as client errors
|
||||||
|
([`871f788`](https://github.com/christianlouis/DocuElevate/commit/871f788f0bd782ba8ad3a7d70e5cd4ccd24f749b))
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
|
||||||
|
- **changelog**: Update changelog [skip ci]
|
||||||
|
([`58b14ae`](https://github.com/christianlouis/DocuElevate/commit/58b14ae769b85e25290126256de936743609af06))
|
||||||
|
|
||||||
|
|
||||||
|
## Unreleased
|
||||||
|
|
||||||
|
|
||||||
|
## v0.172.9 (2026-04-07)
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- **api**: Resolve merge conflicts, add type safety for endpoint_url in S3 connection test
|
||||||
|
([`57db4c7`](https://github.com/christianlouis/DocuElevate/commit/57db4c7c82f4a8df2e7e5e5505e1d5c01768fc16))
|
||||||
|
|
||||||
|
### Chores
|
||||||
|
|
||||||
|
- **ci**: Ignore CVE-2026-4539 in pip-audit until pygments releases a fix
|
||||||
|
([`6927e76`](https://github.com/christianlouis/DocuElevate/commit/6927e7643f9cbe1664f4a1a093511df5b079ed0a))
|
||||||
|
|
||||||
|
### Code Style
|
||||||
|
|
||||||
|
- Apply ruff auto-fix
|
||||||
|
([`8295279`](https://github.com/christianlouis/DocuElevate/commit/8295279ec93570da4eb0445ede8084d1eb2aba99))
|
||||||
|
|
||||||
|
- Sort imports in test_url_upload.py
|
||||||
|
([`bdfa3ba`](https://github.com/christianlouis/DocuElevate/commit/bdfa3ba1e0a5702414e3b449fbde6a6d3149557a))
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
|
||||||
|
- **changelog**: Update changelog [skip ci]
|
||||||
|
([`c6e0b80`](https://github.com/christianlouis/DocuElevate/commit/c6e0b80becab81a75aea4ee78f5aaf8b6ac54854))
|
||||||
|
|
||||||
|
- **changelog**: Update changelog [skip ci]
|
||||||
|
([`9b9882c`](https://github.com/christianlouis/DocuElevate/commit/9b9882c4d62691d0ddd20444e3b77bfe6eecc8c3))
|
||||||
|
|
||||||
|
- **changelog**: Update changelog [skip ci]
|
||||||
|
([`69053bf`](https://github.com/christianlouis/DocuElevate/commit/69053bfb08d3e2f12a86878044667ac500888837))
|
||||||
|
|
||||||
|
- **changelog**: Update changelog [skip ci]
|
||||||
|
([`76f202f`](https://github.com/christianlouis/DocuElevate/commit/76f202f7f1b94e39a4e79cd984770310599405bf))
|
||||||
|
|
||||||
|
### Testing
|
||||||
|
|
||||||
|
- Add coverage for url_upload redirect SSRF bypass prevention hook
|
||||||
|
([`152ee15`](https://github.com/christianlouis/DocuElevate/commit/152ee15b06ebf7beb6216423b4c8d93ec2243165))
|
||||||
|
|
||||||
|
- Add tests for SSRF validation in integrations
|
||||||
|
([`470f08d`](https://github.com/christianlouis/DocuElevate/commit/470f08d89322f2904b78a8b0f820973611486c26))
|
||||||
|
|
||||||
|
|
||||||
|
## Unreleased
|
||||||
|
|
||||||
|
### Chores
|
||||||
|
|
||||||
|
- **ci**: Ignore CVE-2026-4539 in pip-audit until pygments releases a fix
|
||||||
|
([`6927e76`](https://github.com/christianlouis/DocuElevate/commit/6927e7643f9cbe1664f4a1a093511df5b079ed0a))
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
|
||||||
|
- **changelog**: Update changelog [skip ci]
|
||||||
|
([`9b9882c`](https://github.com/christianlouis/DocuElevate/commit/9b9882c4d62691d0ddd20444e3b77bfe6eecc8c3))
|
||||||
|
|
||||||
|
- **changelog**: Update changelog [skip ci]
|
||||||
|
([`69053bf`](https://github.com/christianlouis/DocuElevate/commit/69053bfb08d3e2f12a86878044667ac500888837))
|
||||||
|
|
||||||
|
- **changelog**: Update changelog [skip ci]
|
||||||
|
([`76f202f`](https://github.com/christianlouis/DocuElevate/commit/76f202f7f1b94e39a4e79cd984770310599405bf))
|
||||||
|
|
||||||
|
### Testing
|
||||||
|
|
||||||
|
- Add tests for SSRF validation in integrations
|
||||||
|
([`470f08d`](https://github.com/christianlouis/DocuElevate/commit/470f08d89322f2904b78a8b0f820973611486c26))
|
||||||
|
|
||||||
|
|
||||||
|
## Unreleased
|
||||||
|
|
||||||
|
### Chores
|
||||||
|
|
||||||
|
- **ci**: Ignore CVE-2026-4539 in pip-audit until pygments releases a fix
|
||||||
|
([`6927e76`](https://github.com/christianlouis/DocuElevate/commit/6927e7643f9cbe1664f4a1a093511df5b079ed0a))
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
|
||||||
|
- **changelog**: Update changelog [skip ci]
|
||||||
|
([`69053bf`](https://github.com/christianlouis/DocuElevate/commit/69053bfb08d3e2f12a86878044667ac500888837))
|
||||||
|
|
||||||
|
- **changelog**: Update changelog [skip ci]
|
||||||
|
([`76f202f`](https://github.com/christianlouis/DocuElevate/commit/76f202f7f1b94e39a4e79cd984770310599405bf))
|
||||||
|
|
||||||
|
### Testing
|
||||||
|
|
||||||
|
- Add tests for SSRF validation in integrations
|
||||||
|
([`470f08d`](https://github.com/christianlouis/DocuElevate/commit/470f08d89322f2904b78a8b0f820973611486c26))
|
||||||
|
|
||||||
|
|
||||||
|
## Unreleased
|
||||||
|
|
||||||
|
### Chores
|
||||||
|
|
||||||
|
- **ci**: Ignore CVE-2026-4539 in pip-audit until pygments releases a fix
|
||||||
|
([`6927e76`](https://github.com/christianlouis/DocuElevate/commit/6927e7643f9cbe1664f4a1a093511df5b079ed0a))
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
|
||||||
|
- **changelog**: Update changelog [skip ci]
|
||||||
|
([`76f202f`](https://github.com/christianlouis/DocuElevate/commit/76f202f7f1b94e39a4e79cd984770310599405bf))
|
||||||
|
|
||||||
|
|
||||||
|
## Unreleased
|
||||||
|
|
||||||
|
### Chores
|
||||||
|
|
||||||
|
- **ci**: Ignore CVE-2026-4539 in pip-audit until pygments releases a fix
|
||||||
|
([`6927e76`](https://github.com/christianlouis/DocuElevate/commit/6927e7643f9cbe1664f4a1a093511df5b079ed0a))
|
||||||
|
|
||||||
|
|
||||||
|
## v0.172.8 (2026-03-25)
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- **api**: Track env_file_written accurately in save_google_drive_settings
|
||||||
|
([`4136033`](https://github.com/christianlouis/DocuElevate/commit/4136033bf0e580cbabe811084ab47ea0d6af8f9a))
|
||||||
|
|
||||||
|
- **tests**: Add admin override fixture to TestSaveDropboxSettings
|
||||||
|
([`cafc0e4`](https://github.com/christianlouis/DocuElevate/commit/cafc0e45230ffea096664c1947ac20753b63f8e9))
|
||||||
|
|
||||||
|
- **tests**: Restore correct route URLs and fix auth/exception handling broken by d221753
|
||||||
|
([`48331f6`](https://github.com/christianlouis/DocuElevate/commit/48331f6e91e6c0dae31ab3be31f9da1eccd0a549))
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
|
||||||
|
- **changelog**: Update changelog [skip ci]
|
||||||
|
([`c3124b0`](https://github.com/christianlouis/DocuElevate/commit/c3124b08bd48faa32e76d21d744c9902214048a7))
|
||||||
|
|
||||||
|
|
||||||
|
## Unreleased
|
||||||
|
|
||||||
|
|
||||||
|
## v0.172.7 (2026-03-24)
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- **api**: Remove duplicate Depends from AdminUser parameters in dropbox, onedrive, google_drive
|
||||||
|
([`7f20c90`](https://github.com/christianlouis/DocuElevate/commit/7f20c903ef23e138518e715c7c7a0297d3e46ff8))
|
||||||
|
|
||||||
|
- **dockerfile**: Add frontend-builder stage to compile Tailwind CSS
|
||||||
|
([`3fd8b32`](https://github.com/christianlouis/DocuElevate/commit/3fd8b32724e3d390ff723e5b090a5603c3b1fc93))
|
||||||
|
|
||||||
|
- **main**: Replace silent except-pass with exception logging to fix S110
|
||||||
|
([`8fcc223`](https://github.com/christianlouis/DocuElevate/commit/8fcc223ef19cf609d8413fb6091eabfa0b34d4a6))
|
||||||
|
|
||||||
|
|
||||||
|
## v0.172.6 (2026-03-24)
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- Resolve multiple test failures in imap_tasks, main lifespan, and API settings endpoints
|
||||||
|
([`f041f28`](https://github.com/christianlouis/DocuElevate/commit/f041f28d9f64011df52506ead4bbc87d0797c20e))
|
||||||
|
|
||||||
|
- Restore all code deleted/truncated by d2217531 Jules SSRF commit
|
||||||
|
([`c7d3ec5`](https://github.com/christianlouis/DocuElevate/commit/c7d3ec57c3aca4faeaa0ad2fdbde3a1f770b86a5))
|
||||||
|
|
||||||
|
- **migrations**: Restore accidentally deleted migration files 038-042
|
||||||
|
([`11a49eb`](https://github.com/christianlouis/DocuElevate/commit/11a49eb7fd2218062922a9b8bf01b9a91572bea7))
|
||||||
|
|
||||||
|
- **tasks**: Add -- end-of-options separator to ocrmypdf command in convert_to_pdfa
|
||||||
|
([`7dec570`](https://github.com/christianlouis/DocuElevate/commit/7dec570ce6ae40b934ad075bc06f4cf1dfd9ff2e))
|
||||||
|
|
||||||
|
### Code Style
|
||||||
|
|
||||||
|
- Apply ruff auto-fix
|
||||||
|
([`627a857`](https://github.com/christianlouis/DocuElevate/commit/627a8579def3a6a9d4f78da6469cfde889154402))
|
||||||
|
|
||||||
|
|
||||||
|
## v0.172.5 (2026-03-23)
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- **main**: Suppress S110 ruff warnings with noqa comments for intentional try-except-pass
|
||||||
|
([`0b8f967`](https://github.com/christianlouis/DocuElevate/commit/0b8f967eb5e304155752b4492584d4a7509a454c))
|
||||||
|
|
||||||
|
- **settings**: Move os.path.exists inside try block in update_env_file so exceptions are non-fatal
|
||||||
|
([`c9bb2b6`](https://github.com/christianlouis/DocuElevate/commit/c9bb2b6807b371d04edff12d16f838f411b60514))
|
||||||
|
|
||||||
|
### Testing
|
||||||
|
|
||||||
|
- **google_drive**: Fix exception handling test to expect non-fatal 200 like OneDrive equivalent
|
||||||
|
([`2f5e2a0`](https://github.com/christianlouis/DocuElevate/commit/2f5e2a0fcdd9f9532fc55c6d7ce1675b8be3d3e8))
|
||||||
|
|
||||||
|
- **main,imap**: Fix failing IMAP tests and add coverage for shutdown exception paths
|
||||||
|
([`c03ce8c`](https://github.com/christianlouis/DocuElevate/commit/c03ce8cdb2e7849361ea50db888b7e3080eaafcd))
|
||||||
|
|
||||||
|
|
||||||
|
## v0.172.4 (2026-03-23)
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- Resolve failing tests in main
|
||||||
|
([`3be93be`](https://github.com/christianlouis/DocuElevate/commit/3be93be35a1564cb6009c5b7d2229820c2b8fafd))
|
||||||
|
|
||||||
|
- **api/dropbox**: _require_admin bypasses auth when AUTH_ENABLED=False,
|
||||||
|
([`3be93be`](https://github.com/christianlouis/DocuElevate/commit/3be93be35a1564cb6009c5b7d2229820c2b8fafd))
|
||||||
|
|
||||||
|
### Chores
|
||||||
|
|
||||||
|
- Simplify and fix naming for save settings endpoints
|
||||||
|
([`341839f`](https://github.com/christianlouis/DocuElevate/commit/341839fe5edafa3451f89e2bb57092882d8fd6f0))
|
||||||
|
|
||||||
|
- Simplify and fix naming for save settings endpoints
|
||||||
|
([`57795ee`](https://github.com/christianlouis/DocuElevate/commit/57795ee4871bb0bb0727037a889542bf46a8bb9e))
|
||||||
|
|
||||||
|
### Code Style
|
||||||
|
|
||||||
|
- Apply ruff auto-fix
|
||||||
|
([`2014a93`](https://github.com/christianlouis/DocuElevate/commit/2014a93c1ba4f41b8cfb589584be8d39baeaffe1))
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
|
||||||
|
- **changelog**: Update changelog [skip ci]
|
||||||
|
([`bcdbf9d`](https://github.com/christianlouis/DocuElevate/commit/bcdbf9d17885ab3f8750d8426c0ee9f181ced736))
|
||||||
|
|
||||||
|
|
||||||
|
## Unreleased
|
||||||
|
|
||||||
|
### Chores
|
||||||
|
|
||||||
|
- Simplify and fix naming for save settings endpoints
|
||||||
|
([`341839f`](https://github.com/christianlouis/DocuElevate/commit/341839fe5edafa3451f89e2bb57092882d8fd6f0))
|
||||||
|
|
||||||
|
- Simplify and fix naming for save settings endpoints
|
||||||
|
([`57795ee`](https://github.com/christianlouis/DocuElevate/commit/57795ee4871bb0bb0727037a889542bf46a8bb9e))
|
||||||
|
|
||||||
|
|
||||||
|
## v0.172.3 (2026-03-23)
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- Improve join_url - use walrus op, remove posixpath.normpath
|
||||||
|
([`15dd1a8`](https://github.com/christianlouis/DocuElevate/commit/15dd1a847133aa02aedf65e7fc75d857151cc26e))
|
||||||
|
|
||||||
|
### Code Style
|
||||||
|
|
||||||
|
- Apply ruff auto-fix
|
||||||
|
([`b50a534`](https://github.com/christianlouis/DocuElevate/commit/b50a534454f0432e2ada8140e0090535b7c97051))
|
||||||
|
|
||||||
|
- Apply ruff auto-fix
|
||||||
|
([`326adb1`](https://github.com/christianlouis/DocuElevate/commit/326adb185853e17ac02d30b1bcce33b3a1cf4c5c))
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
|
||||||
|
- **changelog**: Update changelog [skip ci]
|
||||||
|
([`248619d`](https://github.com/christianlouis/DocuElevate/commit/248619d91e91aa9c5660267813367e4cd6f5040f))
|
||||||
|
|
||||||
|
- **changelog**: Update changelog [skip ci]
|
||||||
|
([`26963a8`](https://github.com/christianlouis/DocuElevate/commit/26963a84643c8c5caeb8536ed4dc55302a517adf))
|
||||||
|
|
||||||
|
- **changelog**: Update changelog [skip ci]
|
||||||
|
([`78bd5b5`](https://github.com/christianlouis/DocuElevate/commit/78bd5b5904d41d77d8df2a0e3978f630be080f0f))
|
||||||
|
|
||||||
|
- **changelog**: Update changelog [skip ci]
|
||||||
|
([`cc5e879`](https://github.com/christianlouis/DocuElevate/commit/cc5e879ea98507ec5656cce7162a69d385ee00f2))
|
||||||
|
|
||||||
|
- **changelog**: Update changelog [skip ci]
|
||||||
|
([`0497fbb`](https://github.com/christianlouis/DocuElevate/commit/0497fbbbad71fd728e528498508bbfc7802dab70))
|
||||||
|
|
||||||
|
- **changelog**: Update changelog [skip ci]
|
||||||
|
([`45d3ac8`](https://github.com/christianlouis/DocuElevate/commit/45d3ac8cf07d39d49930dd6866f76e6015067b08))
|
||||||
|
|
||||||
|
### Testing
|
||||||
|
|
||||||
|
- Add assertions for task enqueuing parameters
|
||||||
|
([`eeae47d`](https://github.com/christianlouis/DocuElevate/commit/eeae47ddec01339421e503ba484157e798750b8a))
|
||||||
|
|
||||||
|
|
||||||
|
## Unreleased
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
|
||||||
|
- **changelog**: Update changelog [skip ci]
|
||||||
|
([`26963a8`](https://github.com/christianlouis/DocuElevate/commit/26963a84643c8c5caeb8536ed4dc55302a517adf))
|
||||||
|
|
||||||
|
- **changelog**: Update changelog [skip ci]
|
||||||
|
([`78bd5b5`](https://github.com/christianlouis/DocuElevate/commit/78bd5b5904d41d77d8df2a0e3978f630be080f0f))
|
||||||
|
|
||||||
|
- **changelog**: Update changelog [skip ci]
|
||||||
|
([`cc5e879`](https://github.com/christianlouis/DocuElevate/commit/cc5e879ea98507ec5656cce7162a69d385ee00f2))
|
||||||
|
|
||||||
|
- **changelog**: Update changelog [skip ci]
|
||||||
|
([`0497fbb`](https://github.com/christianlouis/DocuElevate/commit/0497fbbbad71fd728e528498508bbfc7802dab70))
|
||||||
|
|
||||||
|
- **changelog**: Update changelog [skip ci]
|
||||||
|
([`45d3ac8`](https://github.com/christianlouis/DocuElevate/commit/45d3ac8cf07d39d49930dd6866f76e6015067b08))
|
||||||
|
|
||||||
|
### Testing
|
||||||
|
|
||||||
|
- Add assertions for task enqueuing parameters
|
||||||
|
([`eeae47d`](https://github.com/christianlouis/DocuElevate/commit/eeae47ddec01339421e503ba484157e798750b8a))
|
||||||
|
|
||||||
|
|
||||||
|
## Unreleased
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
|
||||||
|
- **changelog**: Update changelog [skip ci]
|
||||||
|
([`78bd5b5`](https://github.com/christianlouis/DocuElevate/commit/78bd5b5904d41d77d8df2a0e3978f630be080f0f))
|
||||||
|
|
||||||
|
- **changelog**: Update changelog [skip ci]
|
||||||
|
([`cc5e879`](https://github.com/christianlouis/DocuElevate/commit/cc5e879ea98507ec5656cce7162a69d385ee00f2))
|
||||||
|
|
||||||
|
- **changelog**: Update changelog [skip ci]
|
||||||
|
([`0497fbb`](https://github.com/christianlouis/DocuElevate/commit/0497fbbbad71fd728e528498508bbfc7802dab70))
|
||||||
|
|
||||||
|
- **changelog**: Update changelog [skip ci]
|
||||||
|
([`45d3ac8`](https://github.com/christianlouis/DocuElevate/commit/45d3ac8cf07d39d49930dd6866f76e6015067b08))
|
||||||
|
|
||||||
|
### Testing
|
||||||
|
|
||||||
|
- Add assertions for task enqueuing parameters
|
||||||
|
([`eeae47d`](https://github.com/christianlouis/DocuElevate/commit/eeae47ddec01339421e503ba484157e798750b8a))
|
||||||
|
|
||||||
|
|
||||||
|
## Unreleased
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
|
||||||
|
- **changelog**: Update changelog [skip ci]
|
||||||
|
([`cc5e879`](https://github.com/christianlouis/DocuElevate/commit/cc5e879ea98507ec5656cce7162a69d385ee00f2))
|
||||||
|
|
||||||
|
- **changelog**: Update changelog [skip ci]
|
||||||
|
([`0497fbb`](https://github.com/christianlouis/DocuElevate/commit/0497fbbbad71fd728e528498508bbfc7802dab70))
|
||||||
|
|
||||||
|
- **changelog**: Update changelog [skip ci]
|
||||||
|
([`45d3ac8`](https://github.com/christianlouis/DocuElevate/commit/45d3ac8cf07d39d49930dd6866f76e6015067b08))
|
||||||
|
|
||||||
|
### Testing
|
||||||
|
|
||||||
|
- Add assertions for task enqueuing parameters
|
||||||
|
([`eeae47d`](https://github.com/christianlouis/DocuElevate/commit/eeae47ddec01339421e503ba484157e798750b8a))
|
||||||
|
|
||||||
|
|
||||||
|
## Unreleased
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
|
||||||
|
- **changelog**: Update changelog [skip ci]
|
||||||
|
([`0497fbb`](https://github.com/christianlouis/DocuElevate/commit/0497fbbbad71fd728e528498508bbfc7802dab70))
|
||||||
|
|
||||||
|
- **changelog**: Update changelog [skip ci]
|
||||||
|
([`45d3ac8`](https://github.com/christianlouis/DocuElevate/commit/45d3ac8cf07d39d49930dd6866f76e6015067b08))
|
||||||
|
|
||||||
|
### Testing
|
||||||
|
|
||||||
|
- Add assertions for task enqueuing parameters
|
||||||
|
([`eeae47d`](https://github.com/christianlouis/DocuElevate/commit/eeae47ddec01339421e503ba484157e798750b8a))
|
||||||
|
|
||||||
|
|
||||||
|
## Unreleased
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
|
||||||
|
- **changelog**: Update changelog [skip ci]
|
||||||
|
([`45d3ac8`](https://github.com/christianlouis/DocuElevate/commit/45d3ac8cf07d39d49930dd6866f76e6015067b08))
|
||||||
|
|
||||||
|
### Testing
|
||||||
|
|
||||||
|
- Add assertions for task enqueuing parameters
|
||||||
|
([`eeae47d`](https://github.com/christianlouis/DocuElevate/commit/eeae47ddec01339421e503ba484157e798750b8a))
|
||||||
|
|
||||||
|
|
||||||
|
## Unreleased
|
||||||
|
|
||||||
|
|
||||||
|
## v0.172.2 (2026-03-23)
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- Adapt TemplateResponse calls to Starlette 1.0 new-style API
|
||||||
|
([`c4e10be`](https://github.com/christianlouis/DocuElevate/commit/c4e10bee5e096e71a5bc4fac4928f69e5c04f2fb))
|
||||||
|
|
||||||
|
- Update test assertions and lint fixes for Starlette 1.0 TemplateResponse API
|
||||||
|
([`93629ff`](https://github.com/christianlouis/DocuElevate/commit/93629ff44083d43f79fdd49431457023e53d13e4))
|
||||||
|
|
||||||
|
- **build**: Remove --omit=dev from npm ci in Dockerfile frontend-builder stage
|
||||||
|
([`b4e0067`](https://github.com/christianlouis/DocuElevate/commit/b4e0067a27e2fb161349bd38c6d3b3f3bcb86972))
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
|
||||||
|
- **changelog**: Update changelog [skip ci]
|
||||||
|
([`0841713`](https://github.com/christianlouis/DocuElevate/commit/084171395d1076c716aa500a516118db49468ff5))
|
||||||
|
|
||||||
|
|
||||||
|
## Unreleased
|
||||||
|
|
||||||
|
|
||||||
|
## v0.172.2 (2026-03-23)
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- Adapt TemplateResponse calls to Starlette 1.0 new-style API
|
||||||
|
([`c4e10be`](https://github.com/christianlouis/DocuElevate/commit/c4e10bee5e096e71a5bc4fac4928f69e5c04f2fb))
|
||||||
|
|
||||||
|
- Update test assertions and lint fixes for Starlette 1.0 TemplateResponse API
|
||||||
|
([`93629ff`](https://github.com/christianlouis/DocuElevate/commit/93629ff44083d43f79fdd49431457023e53d13e4))
|
||||||
|
|
||||||
|
- **build**: Remove --omit=dev from npm ci in Dockerfile frontend-builder stage
|
||||||
|
([`b4e0067`](https://github.com/christianlouis/DocuElevate/commit/b4e0067a27e2fb161349bd38c6d3b3f3bcb86972))
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
|
||||||
|
- **changelog**: Update changelog [skip ci]
|
||||||
|
([`0841713`](https://github.com/christianlouis/DocuElevate/commit/084171395d1076c716aa500a516118db49468ff5))
|
||||||
|
|
||||||
|
|
||||||
|
## Unreleased
|
||||||
|
|
||||||
|
|
||||||
|
## v0.172.1 (2026-03-22)
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- Social login providers now work from DB config without restart
|
||||||
|
([`0c7ea67`](https://github.com/christianlouis/DocuElevate/commit/0c7ea6748da554c80ef9af1b709c08aba49174e6))
|
||||||
|
|
||||||
|
|
||||||
|
## v0.172.0 (2026-03-22)
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- **ui**: Migrate Tailwind CSS from v2 CDN to v3 Play CDN (interim step)
|
||||||
|
([`1d7df13`](https://github.com/christianlouis/DocuElevate/commit/1d7df13c943cc9138dc3ed514f9ab81d861bfbac))
|
||||||
|
|
||||||
|
- **ui**: Replace Tailwind CSS CDN with compiled v3 production build
|
||||||
|
([`14b3031`](https://github.com/christianlouis/DocuElevate/commit/14b3031e63e8645c4048dd73a594e9a53a919c17))
|
||||||
|
|
||||||
|
|
||||||
|
## v0.171.3 (2026-03-22)
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- **ui**: Add missing opening script tag in base.html Sentry block
|
||||||
|
([`425472c`](https://github.com/christianlouis/DocuElevate/commit/425472c839b3564c20a29b6e983fa6b9e7d6cf9c))
|
||||||
|
|
||||||
|
|
||||||
## v0.171.2 (2026-03-22)
|
## v0.171.2 (2026-03-22)
|
||||||
|
|
||||||
### Bug Fixes
|
### Bug Fixes
|
||||||
|
|||||||
+19
-2
@@ -27,7 +27,21 @@ RUN pip install --no-cache-dir -r requirements.txt \
|
|||||||
&& find /opt/venv -type f -name "*.pyc" -delete \
|
&& find /opt/venv -type f -name "*.pyc" -delete \
|
||||||
&& find /opt/venv -type d -name "__pycache__" -exec rm -rf {} + 2>/dev/null || true
|
&& find /opt/venv -type d -name "__pycache__" -exec rm -rf {} + 2>/dev/null || true
|
||||||
|
|
||||||
# ── Stage 2: Documentation builder ──────────────────────────────────────────
|
# ── Stage 2: Frontend asset builder ─────────────────────────────────────────
|
||||||
|
# Compiles Tailwind CSS (a devDependency) into the minified styles.css.
|
||||||
|
# npm ci installs ALL deps (including devDependencies) so the tailwindcss CLI
|
||||||
|
# is available; using --omit=dev would cause 'tailwindcss: not found'.
|
||||||
|
FROM node:20-slim AS frontend-builder
|
||||||
|
|
||||||
|
WORKDIR /frontend
|
||||||
|
|
||||||
|
COPY frontend/package.json frontend/package-lock.json ./
|
||||||
|
RUN npm ci
|
||||||
|
|
||||||
|
COPY frontend/ ./
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# ── Stage 4: Documentation builder ──────────────────────────────────────────
|
||||||
FROM python:3.14.3-slim AS docs-builder
|
FROM python:3.14.3-slim AS docs-builder
|
||||||
|
|
||||||
WORKDIR /docs
|
WORKDIR /docs
|
||||||
@@ -43,7 +57,7 @@ COPY mkdocs.yml /docs/mkdocs.yml
|
|||||||
# Build the static documentation site
|
# Build the static documentation site
|
||||||
RUN mkdocs build --config-file /docs/mkdocs.yml --site-dir /docs/docs_build
|
RUN mkdocs build --config-file /docs/mkdocs.yml --site-dir /docs/docs_build
|
||||||
|
|
||||||
# ── Stage 3: Runtime image ───────────────────────────────────────────────────
|
# ── Stage 5: Runtime image ───────────────────────────────────────────────────
|
||||||
FROM python:3.14.3-slim
|
FROM python:3.14.3-slim
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
@@ -81,6 +95,9 @@ COPY ./RUNTIME_INFO /app/RUNTIME_INFO
|
|||||||
# Copy the pre-built MkDocs documentation site (served at /help)
|
# Copy the pre-built MkDocs documentation site (served at /help)
|
||||||
COPY --from=docs-builder /docs/docs_build /app/docs_build
|
COPY --from=docs-builder /docs/docs_build /app/docs_build
|
||||||
|
|
||||||
|
# Copy the compiled Tailwind CSS (built in the frontend-builder stage)
|
||||||
|
COPY --from=frontend-builder /frontend/static/styles.css /app/frontend/static/styles.css
|
||||||
|
|
||||||
# Create necessary runtime directories in a single layer
|
# Create necessary runtime directories in a single layer
|
||||||
RUN mkdir -p /app/runtime_info /workdir
|
RUN mkdir -p /app/runtime_info /workdir
|
||||||
|
|
||||||
|
|||||||
+6
-6
@@ -1,10 +1,10 @@
|
|||||||
DocuElevate Build Information
|
DocuElevate Build Information
|
||||||
==============================
|
==============================
|
||||||
Version: 0.171.2
|
Version: 0.172.12
|
||||||
Build Date: 2026-03-22T17:37:50Z
|
Build Date: 2026-05-17T14:20:08Z
|
||||||
Git Commit: 3e1b35293006b76737c7d2edfc50a14b003ef266
|
Git Commit: e2fa96318f5bd45607baa0fe08a0bf14e1ca83d4
|
||||||
Git Short SHA: 3e1b352
|
Git Short SHA: e2fa963
|
||||||
Git Branch: main
|
Git Branch: main
|
||||||
Commit Date: 2026-03-22T18:37:25+01:00
|
Commit Date: 2026-05-17T16:19:41+02:00
|
||||||
Build Timestamp: 2026-03-22T17:37:50Z
|
Build Timestamp: 2026-05-17T14:20:08Z
|
||||||
==============================
|
==============================
|
||||||
|
|||||||
+1
-1
@@ -260,7 +260,7 @@ async def stripe_webhook(request: Request, db: Session = Depends(get_db)) -> dic
|
|||||||
@require_login
|
@require_login
|
||||||
async def billing_success(request: Request) -> Any:
|
async def billing_success(request: Request) -> Any:
|
||||||
"""Show a success page after a completed Stripe Checkout."""
|
"""Show a success page after a completed Stripe Checkout."""
|
||||||
return _templates.TemplateResponse("billing_success.html", {"request": request})
|
return _templates.TemplateResponse(request, "billing_success.html")
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
+13
-2
@@ -25,6 +25,17 @@ logger = logging.getLogger(__name__)
|
|||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
def _require_admin(request: Request) -> dict:
|
||||||
|
"""Ensure the caller is an admin. Raises 403 otherwise."""
|
||||||
|
user = request.session.get("user")
|
||||||
|
if not user or not user.get("is_admin"):
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin access required")
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
AdminUser = Annotated[dict, Depends(_require_admin)]
|
||||||
|
|
||||||
|
|
||||||
def _build_dropbox_redirect_uri(request: Request) -> str:
|
def _build_dropbox_redirect_uri(request: Request) -> str:
|
||||||
"""Build the Dropbox OAuth callback redirect URI.
|
"""Build the Dropbox OAuth callback redirect URI.
|
||||||
|
|
||||||
@@ -385,14 +396,14 @@ async def list_dropbox_folders(
|
|||||||
|
|
||||||
|
|
||||||
@router.post("/dropbox/save-settings")
|
@router.post("/dropbox/save-settings")
|
||||||
@require_login
|
|
||||||
async def save_dropbox_settings(
|
async def save_dropbox_settings(
|
||||||
request: Request,
|
request: Request,
|
||||||
refresh_token: Annotated[str, Form(...)],
|
refresh_token: Annotated[str, Form(...)],
|
||||||
|
_admin: AdminUser,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
app_key: Annotated[Optional[str], Form()] = None,
|
app_key: Annotated[Optional[str], Form()] = None,
|
||||||
app_secret: Annotated[Optional[str], Form()] = None,
|
app_secret: Annotated[Optional[str], Form()] = None,
|
||||||
folder_path: Annotated[Optional[str], Form()] = None,
|
folder_path: Annotated[Optional[str], Form()] = None,
|
||||||
db: Session = Depends(get_db),
|
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
Save Dropbox settings to database (primary) and .env file (best-effort).
|
Save Dropbox settings to database (primary) and .env file (best-effort).
|
||||||
|
|||||||
+25
-12
@@ -23,6 +23,17 @@ logger = logging.getLogger(__name__)
|
|||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
def _require_admin(request: Request) -> dict:
|
||||||
|
"""Ensure the caller is an admin. Raises 403 otherwise."""
|
||||||
|
user = request.session.get("user")
|
||||||
|
if not user or not user.get("is_admin"):
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin access required")
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
AdminUser = Annotated[dict, Depends(_require_admin)]
|
||||||
|
|
||||||
|
|
||||||
@router.post("/google-drive/exchange-token")
|
@router.post("/google-drive/exchange-token")
|
||||||
@require_login
|
@require_login
|
||||||
async def exchange_google_drive_token(
|
async def exchange_google_drive_token(
|
||||||
@@ -362,15 +373,15 @@ def format_time_remaining(time_delta):
|
|||||||
|
|
||||||
|
|
||||||
@router.post("/google-drive/save-settings")
|
@router.post("/google-drive/save-settings")
|
||||||
@require_login
|
|
||||||
async def save_google_drive_settings(
|
async def save_google_drive_settings(
|
||||||
request: Request,
|
request: Request,
|
||||||
refresh_token: Annotated[str, Form(...)],
|
refresh_token: Annotated[str, Form(...)],
|
||||||
|
_admin: AdminUser,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
client_id: Annotated[Optional[str], Form()] = None,
|
client_id: Annotated[Optional[str], Form()] = None,
|
||||||
client_secret: Annotated[Optional[str], Form()] = None,
|
client_secret: Annotated[Optional[str], Form()] = None,
|
||||||
folder_id: Annotated[Optional[str], Form()] = None,
|
folder_id: Annotated[Optional[str], Form()] = None,
|
||||||
use_oauth: Annotated[str, Form()] = "true",
|
use_oauth: Annotated[str, Form()] = "true",
|
||||||
db: Session = Depends(get_db),
|
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
Save Google Drive settings to the .env file (best-effort) and persist to database.
|
Save Google Drive settings to the .env file (best-effort) and persist to database.
|
||||||
@@ -403,9 +414,10 @@ async def save_google_drive_settings(
|
|||||||
if folder_id:
|
if folder_id:
|
||||||
drive_settings["GOOGLE_DRIVE_FOLDER_ID"] = folder_id
|
drive_settings["GOOGLE_DRIVE_FOLDER_ID"] = folder_id
|
||||||
|
|
||||||
# Try to update the .env file, but don't fail if it doesn't exist (for Docker containers)
|
# Best-effort .env file write — failures here are non-fatal
|
||||||
if os.path.exists(env_path):
|
env_file_written = False
|
||||||
try:
|
try:
|
||||||
|
if os.path.exists(env_path):
|
||||||
logger.info(f"Updating Google Drive settings in {env_path}")
|
logger.info(f"Updating Google Drive settings in {env_path}")
|
||||||
|
|
||||||
# Read the current .env file
|
# Read the current .env file
|
||||||
@@ -438,12 +450,13 @@ async def save_google_drive_settings(
|
|||||||
f.write("\n".join(new_env_lines) + "\n")
|
f.write("\n".join(new_env_lines) + "\n")
|
||||||
|
|
||||||
logger.info("Successfully updated Google Drive settings in .env file")
|
logger.info("Successfully updated Google Drive settings in .env file")
|
||||||
except Exception as e:
|
env_file_written = True
|
||||||
logger.warning(f"Failed to update .env file: {str(e)}, but will continue with in-memory update")
|
else:
|
||||||
else:
|
logger.warning(
|
||||||
logger.warning(
|
f".env file not found at {env_path}, skipping file update but continuing with in-memory update"
|
||||||
f".env file not found at {env_path}, skipping file update but continuing with in-memory update"
|
)
|
||||||
)
|
except Exception as env_err:
|
||||||
|
logger.warning(f"Failed to write .env file (non-fatal): {env_err}")
|
||||||
|
|
||||||
# Update the settings in memory (this always happens)
|
# Update the settings in memory (this always happens)
|
||||||
if refresh_token:
|
if refresh_token:
|
||||||
@@ -481,7 +494,7 @@ async def save_google_drive_settings(
|
|||||||
return {
|
return {
|
||||||
"status": "success",
|
"status": "success",
|
||||||
"message": "Google Drive settings have been saved",
|
"message": "Google Drive settings have been saved",
|
||||||
"in_memory_only": not os.path.exists(env_path),
|
"in_memory_only": not env_file_written,
|
||||||
}
|
}
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ from sqlalchemy.orm import Session
|
|||||||
from app.database import get_db
|
from app.database import get_db
|
||||||
from app.models import UserImapAccount
|
from app.models import UserImapAccount
|
||||||
from app.utils.encryption import decrypt_value, encrypt_value
|
from app.utils.encryption import decrypt_value, encrypt_value
|
||||||
|
from app.utils.network import is_private_ip
|
||||||
from app.utils.subscription import get_tier, get_user_tier_id
|
from app.utils.subscription import get_tier, get_user_tier_id
|
||||||
from app.utils.user_scope import get_current_owner_id
|
from app.utils.user_scope import get_current_owner_id
|
||||||
|
|
||||||
@@ -187,6 +188,11 @@ def _test_imap_connection(host: str, port: int, username: str, password: str, us
|
|||||||
|
|
||||||
Returns a dict with ``{"success": bool, "message": str}``.
|
Returns a dict with ``{"success": bool, "message": str}``.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
# Security: Prevent SSRF by blocking connections to internal IPs
|
||||||
|
if is_private_ip(host):
|
||||||
|
logger.warning("SSRF blocked: Attempt to connect to private IP %s", host)
|
||||||
|
return {"success": False, "message": "Connection error: Invalid hostname or IP address"}
|
||||||
try:
|
try:
|
||||||
if use_ssl:
|
if use_ssl:
|
||||||
mail = imaplib.IMAP4_SSL(host, port)
|
mail = imaplib.IMAP4_SSL(host, port)
|
||||||
|
|||||||
+18
-1
@@ -515,6 +515,12 @@ def _test_imap_connection(config: dict[str, Any] | None, credentials: dict[str,
|
|||||||
if not host or not username or not password:
|
if not host or not username or not password:
|
||||||
return {"success": False, "message": "Missing required fields: host, username, and password"}
|
return {"success": False, "message": "Missing required fields: host, username, and password"}
|
||||||
|
|
||||||
|
from app.utils.network import is_private_ip
|
||||||
|
|
||||||
|
if is_private_ip(host):
|
||||||
|
logger.warning("SSRF blocked: Attempt to connect to private IP %s", host)
|
||||||
|
return {"success": False, "message": "Connection error: Invalid hostname or IP address"}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if use_ssl:
|
if use_ssl:
|
||||||
mail = imaplib.IMAP4_SSL(host, port)
|
mail = imaplib.IMAP4_SSL(host, port)
|
||||||
@@ -543,17 +549,28 @@ def _test_s3_connection(config: dict[str, Any] | None, credentials: dict[str, An
|
|||||||
creds = credentials or {}
|
creds = credentials or {}
|
||||||
bucket = cfg.get("bucket", "")
|
bucket = cfg.get("bucket", "")
|
||||||
region = cfg.get("region", "us-east-1")
|
region = cfg.get("region", "us-east-1")
|
||||||
|
endpoint_url = cfg.get("endpoint_url")
|
||||||
|
|
||||||
if not bucket:
|
if not bucket:
|
||||||
return {"success": False, "message": "Missing required field: bucket"}
|
return {"success": False, "message": "Missing required field: bucket"}
|
||||||
|
|
||||||
|
if endpoint_url:
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
from app.utils.network import is_private_ip
|
||||||
|
|
||||||
|
parsed_url = urlparse(endpoint_url)
|
||||||
|
if parsed_url.hostname and is_private_ip(parsed_url.hostname):
|
||||||
|
logger.warning("SSRF blocked: Attempt to connect to private IP via S3 endpoint %s", endpoint_url)
|
||||||
|
return {"success": False, "message": "Connection error: Invalid endpoint URL or private IP"}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
client = boto3.client(
|
client = boto3.client(
|
||||||
"s3",
|
"s3",
|
||||||
region_name=region,
|
region_name=region,
|
||||||
aws_access_key_id=creds.get("access_key_id", ""),
|
aws_access_key_id=creds.get("access_key_id", ""),
|
||||||
aws_secret_access_key=creds.get("secret_access_key", ""),
|
aws_secret_access_key=creds.get("secret_access_key", ""),
|
||||||
endpoint_url=cfg.get("endpoint_url"),
|
endpoint_url=endpoint_url,
|
||||||
)
|
)
|
||||||
client.head_bucket(Bucket=bucket)
|
client.head_bucket(Bucket=bucket)
|
||||||
return {"success": True, "message": f"S3 bucket '{bucket}' is accessible"}
|
return {"success": True, "message": f"S3 bucket '{bucket}' is accessible"}
|
||||||
|
|||||||
@@ -101,9 +101,9 @@ async def signup_page(request: Request) -> Any:
|
|||||||
if not settings.allow_local_signup:
|
if not settings.allow_local_signup:
|
||||||
return RedirectResponse(url="/login?error=Registration+is+not+enabled", status_code=302)
|
return RedirectResponse(url="/login?error=Registration+is+not+enabled", status_code=302)
|
||||||
return templates.TemplateResponse(
|
return templates.TemplateResponse(
|
||||||
|
request,
|
||||||
"signup.html",
|
"signup.html",
|
||||||
{
|
context={
|
||||||
"request": request,
|
|
||||||
"csrf_token": getattr(request.state, "csrf_token", ""),
|
"csrf_token": getattr(request.state, "csrf_token", ""),
|
||||||
"app_version": settings.version,
|
"app_version": settings.version,
|
||||||
},
|
},
|
||||||
@@ -113,16 +113,16 @@ async def signup_page(request: Request) -> Any:
|
|||||||
@router.get("/verify-email-sent", include_in_schema=False)
|
@router.get("/verify-email-sent", include_in_schema=False)
|
||||||
async def verify_email_sent_page(request: Request) -> Any:
|
async def verify_email_sent_page(request: Request) -> Any:
|
||||||
"""Render the verify-email-sent confirmation page."""
|
"""Render the verify-email-sent confirmation page."""
|
||||||
return templates.TemplateResponse("verify_email_sent.html", {"request": request})
|
return templates.TemplateResponse(request, "verify_email_sent.html")
|
||||||
|
|
||||||
|
|
||||||
@router.get("/forgot-username", include_in_schema=False)
|
@router.get("/forgot-username", include_in_schema=False)
|
||||||
async def forgot_username_page(request: Request) -> Any:
|
async def forgot_username_page(request: Request) -> Any:
|
||||||
"""Render the forgot-username page where users can request a username reminder email."""
|
"""Render the forgot-username page where users can request a username reminder email."""
|
||||||
return templates.TemplateResponse(
|
return templates.TemplateResponse(
|
||||||
|
request,
|
||||||
"forgot_username.html",
|
"forgot_username.html",
|
||||||
{
|
context={
|
||||||
"request": request,
|
|
||||||
"csrf_token": getattr(request.state, "csrf_token", ""),
|
"csrf_token": getattr(request.state, "csrf_token", ""),
|
||||||
"app_version": settings.version,
|
"app_version": settings.version,
|
||||||
},
|
},
|
||||||
@@ -133,9 +133,9 @@ async def forgot_username_page(request: Request) -> Any:
|
|||||||
async def forgot_password_page(request: Request) -> Any:
|
async def forgot_password_page(request: Request) -> Any:
|
||||||
"""Render the forgot-password page where users can request a reset email."""
|
"""Render the forgot-password page where users can request a reset email."""
|
||||||
return templates.TemplateResponse(
|
return templates.TemplateResponse(
|
||||||
|
request,
|
||||||
"forgot_password.html",
|
"forgot_password.html",
|
||||||
{
|
context={
|
||||||
"request": request,
|
|
||||||
"csrf_token": getattr(request.state, "csrf_token", ""),
|
"csrf_token": getattr(request.state, "csrf_token", ""),
|
||||||
"app_version": settings.version,
|
"app_version": settings.version,
|
||||||
},
|
},
|
||||||
@@ -147,9 +147,9 @@ async def reset_password_page(request: Request) -> Any:
|
|||||||
"""Render the password reset form page."""
|
"""Render the password reset form page."""
|
||||||
token = request.query_params.get("token", "")
|
token = request.query_params.get("token", "")
|
||||||
return templates.TemplateResponse(
|
return templates.TemplateResponse(
|
||||||
|
request,
|
||||||
"password_reset_form.html",
|
"password_reset_form.html",
|
||||||
{
|
context={
|
||||||
"request": request,
|
|
||||||
"token": token,
|
"token": token,
|
||||||
"csrf_token": getattr(request.state, "csrf_token", ""),
|
"csrf_token": getattr(request.state, "csrf_token", ""),
|
||||||
"app_version": settings.version,
|
"app_version": settings.version,
|
||||||
|
|||||||
+13
-2
@@ -25,6 +25,17 @@ logger = logging.getLogger(__name__)
|
|||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
def _require_admin(request: Request) -> dict:
|
||||||
|
"""Ensure the caller is an admin. Raises 403 otherwise."""
|
||||||
|
user = request.session.get("user")
|
||||||
|
if not user or not user.get("is_admin"):
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin access required")
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
AdminUser = Annotated[dict, Depends(_require_admin)]
|
||||||
|
|
||||||
|
|
||||||
@router.post("/onedrive/exchange-token")
|
@router.post("/onedrive/exchange-token")
|
||||||
@require_login
|
@require_login
|
||||||
async def exchange_onedrive_token(
|
async def exchange_onedrive_token(
|
||||||
@@ -302,15 +313,15 @@ def format_time_remaining(time_delta):
|
|||||||
|
|
||||||
|
|
||||||
@router.post("/onedrive/save-settings")
|
@router.post("/onedrive/save-settings")
|
||||||
@require_login
|
|
||||||
async def save_onedrive_settings(
|
async def save_onedrive_settings(
|
||||||
request: Request,
|
request: Request,
|
||||||
refresh_token: Annotated[str, Form(...)],
|
refresh_token: Annotated[str, Form(...)],
|
||||||
|
_admin: AdminUser,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
client_id: Annotated[Optional[str], Form()] = None,
|
client_id: Annotated[Optional[str], Form()] = None,
|
||||||
client_secret: Annotated[Optional[str], Form()] = None,
|
client_secret: Annotated[Optional[str], Form()] = None,
|
||||||
tenant_id: Annotated[str, Form()] = "common",
|
tenant_id: Annotated[str, Form()] = "common",
|
||||||
folder_path: Annotated[Optional[str], Form()] = None,
|
folder_path: Annotated[Optional[str], Form()] = None,
|
||||||
db: Session = Depends(get_db),
|
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
Saves to database (primary) and .env file (best-effort).
|
Saves to database (primary) and .env file (best-effort).
|
||||||
|
|||||||
@@ -28,6 +28,10 @@ logger = logging.getLogger(__name__)
|
|||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
class UnsafeRedirectError(httpx.RequestError):
|
||||||
|
"""Raised when a redirect target fails URL safety checks."""
|
||||||
|
|
||||||
|
|
||||||
class URLUploadRequest(BaseModel):
|
class URLUploadRequest(BaseModel):
|
||||||
"""Request model for URL-based file upload"""
|
"""Request model for URL-based file upload"""
|
||||||
|
|
||||||
@@ -106,6 +110,26 @@ def validate_file_type(content_type: str, filename: str) -> bool:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
async def verify_redirect(response: httpx.Response) -> None:
|
||||||
|
"""
|
||||||
|
Event hook to intercept redirects and validate the new destination URL.
|
||||||
|
Prevents SSRF bypasses via redirects to internal networks or metadata endpoints.
|
||||||
|
"""
|
||||||
|
if response.status_code in (301, 302, 303, 307, 308):
|
||||||
|
location = response.headers.get("Location")
|
||||||
|
if location:
|
||||||
|
# Resolve relative redirects
|
||||||
|
new_url = str(response.url.join(location))
|
||||||
|
# Validate the new URL
|
||||||
|
try:
|
||||||
|
validate_url_safety(new_url)
|
||||||
|
except HTTPException as e:
|
||||||
|
raise UnsafeRedirectError(
|
||||||
|
f"Redirect to unsafe URL blocked: {e.detail}",
|
||||||
|
request=response.request,
|
||||||
|
) from e
|
||||||
|
|
||||||
|
|
||||||
@router.post("/process-url")
|
@router.post("/process-url")
|
||||||
@require_login
|
@require_login
|
||||||
async def process_url(
|
async def process_url(
|
||||||
@@ -162,6 +186,7 @@ async def process_url(
|
|||||||
async with httpx.AsyncClient(
|
async with httpx.AsyncClient(
|
||||||
timeout=settings.http_request_timeout,
|
timeout=settings.http_request_timeout,
|
||||||
follow_redirects=True,
|
follow_redirects=True,
|
||||||
|
event_hooks={"response": [verify_redirect]},
|
||||||
headers={
|
headers={
|
||||||
"User-Agent": "DocuElevate/1.0", # Identify ourselves
|
"User-Agent": "DocuElevate/1.0", # Identify ourselves
|
||||||
},
|
},
|
||||||
@@ -251,6 +276,10 @@ async def process_url(
|
|||||||
logger.error(f"HTTP error while downloading file from URL: {url} - {str(e)}")
|
logger.error(f"HTTP error while downloading file from URL: {url} - {str(e)}")
|
||||||
raise HTTPException(status_code=e.response.status_code, detail=f"HTTP error: {str(e)}")
|
raise HTTPException(status_code=e.response.status_code, detail=f"HTTP error: {str(e)}")
|
||||||
|
|
||||||
|
except UnsafeRedirectError as e:
|
||||||
|
logger.warning(f"Unsafe redirect blocked while downloading file from URL: {url} - {str(e)}")
|
||||||
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
|
|
||||||
except httpx.RequestError as e:
|
except httpx.RequestError as e:
|
||||||
logger.error(f"Error downloading file from URL: {url} - {str(e)}")
|
logger.error(f"Error downloading file from URL: {url} - {str(e)}")
|
||||||
raise HTTPException(status_code=500, detail=f"Failed to download file: {str(e)}")
|
raise HTTPException(status_code=500, detail=f"Failed to download file: {str(e)}")
|
||||||
|
|||||||
+221
-149
@@ -45,78 +45,27 @@ OAUTH_PROVIDER_NAME = "Single Sign-On"
|
|||||||
# Social login providers that are enabled and registered
|
# Social login providers that are enabled and registered
|
||||||
SOCIAL_PROVIDERS: dict[str, dict[str, str]] = {}
|
SOCIAL_PROVIDERS: dict[str, dict[str, str]] = {}
|
||||||
|
|
||||||
if AUTH_ENABLED and settings.authentik_client_id and settings.authentik_client_secret:
|
|
||||||
oauth.register(
|
|
||||||
name="authentik",
|
|
||||||
client_id=settings.authentik_client_id,
|
|
||||||
client_secret=settings.authentik_client_secret,
|
|
||||||
server_metadata_url=settings.authentik_config_url,
|
|
||||||
client_kwargs={"scope": "openid profile email"},
|
|
||||||
)
|
|
||||||
OAUTH_CONFIGURED = True
|
|
||||||
OAUTH_PROVIDER_NAME = settings.oauth_provider_name or "Authentik SSO"
|
|
||||||
|
|
||||||
# --- Social Login Providers ---------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
if AUTH_ENABLED and settings.social_auth_google_enabled:
|
# Helpers for dynamic (re-)registration of OAuth providers
|
||||||
# Determine which credentials to use for Google social login
|
# ---------------------------------------------------------------------------
|
||||||
_google_client_id = settings.social_auth_google_client_id
|
|
||||||
_google_client_secret = settings.social_auth_google_client_secret
|
|
||||||
if settings.social_auth_google_use_global_credentials and not (_google_client_id and _google_client_secret):
|
|
||||||
_google_client_id = settings.google_drive_client_id
|
|
||||||
_google_client_secret = settings.google_drive_client_secret
|
|
||||||
|
|
||||||
if _google_client_id and _google_client_secret:
|
|
||||||
oauth.register(
|
|
||||||
name="google",
|
|
||||||
client_id=_google_client_id,
|
|
||||||
client_secret=_google_client_secret,
|
|
||||||
server_metadata_url="https://accounts.google.com/.well-known/openid-configuration",
|
|
||||||
client_kwargs={"scope": "openid profile email"},
|
|
||||||
)
|
|
||||||
SOCIAL_PROVIDERS["google"] = {"name": "Google", "icon": "fab fa-google", "color": "red"}
|
|
||||||
logger.info("Social login provider registered: Google")
|
|
||||||
else:
|
|
||||||
logger.warning("SOCIAL_AUTH_GOOGLE_ENABLED=true but client ID/secret not configured")
|
|
||||||
|
|
||||||
if AUTH_ENABLED and settings.social_auth_microsoft_enabled:
|
def _register_oauth_client(name: str, **kwargs: object) -> None:
|
||||||
# Determine which credentials to use for Microsoft social login
|
"""Register (or re-register) an authlib OAuth client, clearing any cached instance.
|
||||||
_microsoft_client_id = settings.social_auth_microsoft_client_id
|
|
||||||
_microsoft_client_secret = settings.social_auth_microsoft_client_secret
|
|
||||||
if settings.social_auth_microsoft_use_global_credentials and not (
|
|
||||||
_microsoft_client_id and _microsoft_client_secret
|
|
||||||
):
|
|
||||||
_microsoft_client_id = settings.onedrive_client_id
|
|
||||||
_microsoft_client_secret = settings.onedrive_client_secret
|
|
||||||
|
|
||||||
if _microsoft_client_id and _microsoft_client_secret:
|
authlib caches the constructed client object in ``oauth._clients`` after the
|
||||||
tenant = settings.social_auth_microsoft_tenant or "common"
|
first ``register()`` call. Subsequent ``register()`` calls overwrite the
|
||||||
oauth.register(
|
registry entry but the stale cached client is still returned by
|
||||||
name="microsoft",
|
``create_client()`` / ``__getattr__``. Popping the name from ``_clients``
|
||||||
client_id=_microsoft_client_id,
|
before re-registering ensures the new credentials are picked up immediately.
|
||||||
client_secret=_microsoft_client_secret,
|
|
||||||
server_metadata_url=f"https://login.microsoftonline.com/{tenant}/v2.0/.well-known/openid-configuration",
|
|
||||||
client_kwargs={"scope": "openid profile email"},
|
|
||||||
)
|
|
||||||
SOCIAL_PROVIDERS["microsoft"] = {"name": "Microsoft", "icon": "fab fa-microsoft", "color": "blue"}
|
|
||||||
logger.info("Social login provider registered: Microsoft (tenant=%s)", tenant)
|
|
||||||
else:
|
|
||||||
logger.warning("SOCIAL_AUTH_MICROSOFT_ENABLED=true but client ID/secret not configured")
|
|
||||||
|
|
||||||
if AUTH_ENABLED and settings.social_auth_apple_enabled:
|
Args:
|
||||||
if settings.social_auth_apple_client_id and settings.social_auth_apple_team_id:
|
name: Provider name (e.g. ``"google"``, ``"github"``).
|
||||||
oauth.register(
|
**kwargs: Keyword arguments forwarded verbatim to ``oauth.register()``.
|
||||||
name="apple",
|
"""
|
||||||
client_id=settings.social_auth_apple_client_id,
|
oauth._clients.pop(name, None)
|
||||||
server_metadata_url="https://appleid.apple.com/.well-known/openid-configuration",
|
oauth.register(name, **kwargs)
|
||||||
client_kwargs={
|
|
||||||
"scope": "openid name email",
|
|
||||||
"response_mode": "form_post",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
SOCIAL_PROVIDERS["apple"] = {"name": "Apple", "icon": "fab fa-apple", "color": "gray"}
|
|
||||||
logger.info("Social login provider registered: Apple")
|
|
||||||
else:
|
|
||||||
logger.warning("SOCIAL_AUTH_APPLE_ENABLED=true but client ID/team ID not configured")
|
|
||||||
|
|
||||||
|
|
||||||
def _dropbox_userinfo_compliance_fix(client, user_cls, token, data):
|
def _dropbox_userinfo_compliance_fix(client, user_cls, token, data):
|
||||||
@@ -145,92 +94,215 @@ def _dropbox_userinfo_compliance_fix(client, user_cls, token, data):
|
|||||||
return data
|
return data
|
||||||
|
|
||||||
|
|
||||||
if AUTH_ENABLED and settings.social_auth_dropbox_enabled:
|
def _setup_social_providers() -> None:
|
||||||
# Determine which credentials to use for Dropbox social login
|
"""Register all configured OAuth / social-login providers from current settings.
|
||||||
_dropbox_client_id = settings.social_auth_dropbox_client_id
|
|
||||||
_dropbox_client_secret = settings.social_auth_dropbox_client_secret
|
|
||||||
if settings.social_auth_dropbox_use_global_credentials and not (_dropbox_client_id and _dropbox_client_secret):
|
|
||||||
_dropbox_client_id = settings.dropbox_app_key
|
|
||||||
_dropbox_client_secret = settings.dropbox_app_secret
|
|
||||||
|
|
||||||
if _dropbox_client_id and _dropbox_client_secret:
|
This function is **idempotent**: it clears ``SOCIAL_PROVIDERS``,
|
||||||
oauth.register(
|
``OAUTH_CONFIGURED``, and ``OAUTH_PROVIDER_NAME`` before rebuilding them,
|
||||||
name="dropbox",
|
and calls :func:`_register_oauth_client` (which also clears the authlib
|
||||||
client_id=_dropbox_client_id,
|
client cache) so that credential changes in the database are reflected
|
||||||
client_secret=_dropbox_client_secret,
|
without an application restart.
|
||||||
authorize_url="https://www.dropbox.com/oauth2/authorize",
|
|
||||||
access_token_url="https://api.dropboxapi.com/oauth2/token",
|
|
||||||
userinfo_endpoint="https://api.dropboxapi.com/2/users/get_current_account",
|
|
||||||
userinfo_compliance_fix=_dropbox_userinfo_compliance_fix,
|
|
||||||
client_kwargs={
|
|
||||||
"token_endpoint_auth_method": "client_secret_post",
|
|
||||||
"token_access_type": "offline",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
SOCIAL_PROVIDERS["dropbox"] = {"name": "Dropbox", "icon": "fab fa-dropbox", "color": "blue"}
|
|
||||||
logger.info("Social login provider registered: Dropbox")
|
|
||||||
else:
|
|
||||||
logger.warning("SOCIAL_AUTH_DROPBOX_ENABLED=true but client ID/secret not configured")
|
|
||||||
|
|
||||||
if AUTH_ENABLED and settings.social_auth_github_enabled:
|
Can safely be called multiple times, e.g. after a settings reload.
|
||||||
if settings.social_auth_github_client_id and settings.social_auth_github_client_secret:
|
"""
|
||||||
oauth.register(
|
global OAUTH_CONFIGURED, OAUTH_PROVIDER_NAME
|
||||||
name="github",
|
|
||||||
client_id=settings.social_auth_github_client_id,
|
|
||||||
client_secret=settings.social_auth_github_client_secret,
|
|
||||||
authorize_url="https://github.com/login/oauth/authorize",
|
|
||||||
access_token_url="https://github.com/login/oauth/access_token",
|
|
||||||
userinfo_endpoint="https://api.github.com/user",
|
|
||||||
client_kwargs={"scope": "read:user user:email"},
|
|
||||||
)
|
|
||||||
SOCIAL_PROVIDERS["github"] = {"name": "GitHub", "icon": "fab fa-github", "color": "gray"}
|
|
||||||
logger.info("Social login provider registered: GitHub")
|
|
||||||
else:
|
|
||||||
logger.warning("SOCIAL_AUTH_GITHUB_ENABLED=true but client ID/secret not configured")
|
|
||||||
|
|
||||||
if AUTH_ENABLED and settings.social_auth_keycloak_enabled:
|
SOCIAL_PROVIDERS.clear()
|
||||||
_kc_server = settings.social_auth_keycloak_server_url
|
OAUTH_CONFIGURED = False
|
||||||
_kc_realm = settings.social_auth_keycloak_realm
|
OAUTH_PROVIDER_NAME = "Single Sign-On"
|
||||||
if (
|
|
||||||
settings.social_auth_keycloak_client_id
|
if not AUTH_ENABLED:
|
||||||
and settings.social_auth_keycloak_client_secret
|
return
|
||||||
and _kc_server
|
|
||||||
and _kc_realm
|
# --- Authentik / OIDC ---
|
||||||
):
|
if settings.authentik_client_id and settings.authentik_client_secret:
|
||||||
_kc_base = f"{_kc_server.rstrip('/')}/realms/{_kc_realm}"
|
_register_oauth_client(
|
||||||
oauth.register(
|
"authentik",
|
||||||
name="keycloak",
|
client_id=settings.authentik_client_id,
|
||||||
client_id=settings.social_auth_keycloak_client_id,
|
client_secret=settings.authentik_client_secret,
|
||||||
client_secret=settings.social_auth_keycloak_client_secret,
|
server_metadata_url=settings.authentik_config_url,
|
||||||
server_metadata_url=f"{_kc_base}/.well-known/openid-configuration",
|
|
||||||
client_kwargs={"scope": "openid profile email"},
|
client_kwargs={"scope": "openid profile email"},
|
||||||
)
|
)
|
||||||
SOCIAL_PROVIDERS["keycloak"] = {"name": "Keycloak", "icon": "fas fa-key", "color": "gray"}
|
OAUTH_CONFIGURED = True
|
||||||
logger.info("Social login provider registered: Keycloak (realm=%s)", _kc_realm)
|
OAUTH_PROVIDER_NAME = settings.oauth_provider_name or "Authentik SSO"
|
||||||
else:
|
|
||||||
logger.warning("SOCIAL_AUTH_KEYCLOAK_ENABLED=true but required settings not configured")
|
|
||||||
|
|
||||||
if AUTH_ENABLED and settings.social_auth_generic_oauth2_enabled:
|
# --- Social Login Providers ---
|
||||||
if (
|
|
||||||
settings.social_auth_generic_oauth2_client_id
|
# Google
|
||||||
and settings.social_auth_generic_oauth2_client_secret
|
if settings.social_auth_google_enabled:
|
||||||
and settings.social_auth_generic_oauth2_authorize_url
|
_google_client_id = settings.social_auth_google_client_id
|
||||||
and settings.social_auth_generic_oauth2_token_url
|
_google_client_secret = settings.social_auth_google_client_secret
|
||||||
):
|
if settings.social_auth_google_use_global_credentials and not (_google_client_id and _google_client_secret):
|
||||||
oauth.register(
|
_google_client_id = settings.google_drive_client_id
|
||||||
name="generic_oauth2",
|
_google_client_secret = settings.google_drive_client_secret
|
||||||
client_id=settings.social_auth_generic_oauth2_client_id,
|
|
||||||
client_secret=settings.social_auth_generic_oauth2_client_secret,
|
if _google_client_id and _google_client_secret:
|
||||||
authorize_url=settings.social_auth_generic_oauth2_authorize_url,
|
_register_oauth_client(
|
||||||
access_token_url=settings.social_auth_generic_oauth2_token_url,
|
"google",
|
||||||
userinfo_endpoint=settings.social_auth_generic_oauth2_userinfo_url,
|
client_id=_google_client_id,
|
||||||
client_kwargs={"scope": settings.social_auth_generic_oauth2_scope},
|
client_secret=_google_client_secret,
|
||||||
)
|
server_metadata_url="https://accounts.google.com/.well-known/openid-configuration",
|
||||||
_generic_name = settings.social_auth_generic_oauth2_name or "OAuth2"
|
client_kwargs={"scope": "openid profile email"},
|
||||||
SOCIAL_PROVIDERS["generic_oauth2"] = {"name": _generic_name, "icon": "fas fa-sign-in-alt", "color": "indigo"}
|
)
|
||||||
logger.info("Social login provider registered: Generic OAuth2 (%s)", _generic_name)
|
SOCIAL_PROVIDERS["google"] = {"name": "Google", "icon": "fab fa-google", "color": "red"}
|
||||||
else:
|
logger.info("Social login provider registered: Google")
|
||||||
logger.warning("SOCIAL_AUTH_GENERIC_OAUTH2_ENABLED=true but required settings not configured")
|
else:
|
||||||
|
logger.warning("SOCIAL_AUTH_GOOGLE_ENABLED=true but client ID/secret not configured")
|
||||||
|
|
||||||
|
# Microsoft
|
||||||
|
if settings.social_auth_microsoft_enabled:
|
||||||
|
_microsoft_client_id = settings.social_auth_microsoft_client_id
|
||||||
|
_microsoft_client_secret = settings.social_auth_microsoft_client_secret
|
||||||
|
if settings.social_auth_microsoft_use_global_credentials and not (
|
||||||
|
_microsoft_client_id and _microsoft_client_secret
|
||||||
|
):
|
||||||
|
_microsoft_client_id = settings.onedrive_client_id
|
||||||
|
_microsoft_client_secret = settings.onedrive_client_secret
|
||||||
|
|
||||||
|
if _microsoft_client_id and _microsoft_client_secret:
|
||||||
|
tenant = settings.social_auth_microsoft_tenant or "common"
|
||||||
|
_register_oauth_client(
|
||||||
|
"microsoft",
|
||||||
|
client_id=_microsoft_client_id,
|
||||||
|
client_secret=_microsoft_client_secret,
|
||||||
|
server_metadata_url=f"https://login.microsoftonline.com/{tenant}/v2.0/.well-known/openid-configuration",
|
||||||
|
client_kwargs={"scope": "openid profile email"},
|
||||||
|
)
|
||||||
|
SOCIAL_PROVIDERS["microsoft"] = {"name": "Microsoft", "icon": "fab fa-microsoft", "color": "blue"}
|
||||||
|
logger.info("Social login provider registered: Microsoft (tenant=%s)", tenant)
|
||||||
|
else:
|
||||||
|
logger.warning("SOCIAL_AUTH_MICROSOFT_ENABLED=true but client ID/secret not configured")
|
||||||
|
|
||||||
|
# Apple
|
||||||
|
if settings.social_auth_apple_enabled:
|
||||||
|
if settings.social_auth_apple_client_id and settings.social_auth_apple_team_id:
|
||||||
|
_register_oauth_client(
|
||||||
|
"apple",
|
||||||
|
client_id=settings.social_auth_apple_client_id,
|
||||||
|
server_metadata_url="https://appleid.apple.com/.well-known/openid-configuration",
|
||||||
|
client_kwargs={
|
||||||
|
"scope": "openid name email",
|
||||||
|
"response_mode": "form_post",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
SOCIAL_PROVIDERS["apple"] = {"name": "Apple", "icon": "fab fa-apple", "color": "gray"}
|
||||||
|
logger.info("Social login provider registered: Apple")
|
||||||
|
else:
|
||||||
|
logger.warning("SOCIAL_AUTH_APPLE_ENABLED=true but client ID/team ID not configured")
|
||||||
|
|
||||||
|
# Dropbox
|
||||||
|
if settings.social_auth_dropbox_enabled:
|
||||||
|
_dropbox_client_id = settings.social_auth_dropbox_client_id
|
||||||
|
_dropbox_client_secret = settings.social_auth_dropbox_client_secret
|
||||||
|
if settings.social_auth_dropbox_use_global_credentials and not (_dropbox_client_id and _dropbox_client_secret):
|
||||||
|
_dropbox_client_id = settings.dropbox_app_key
|
||||||
|
_dropbox_client_secret = settings.dropbox_app_secret
|
||||||
|
|
||||||
|
if _dropbox_client_id and _dropbox_client_secret:
|
||||||
|
_register_oauth_client(
|
||||||
|
"dropbox",
|
||||||
|
client_id=_dropbox_client_id,
|
||||||
|
client_secret=_dropbox_client_secret,
|
||||||
|
authorize_url="https://www.dropbox.com/oauth2/authorize",
|
||||||
|
access_token_url="https://api.dropboxapi.com/oauth2/token",
|
||||||
|
userinfo_endpoint="https://api.dropboxapi.com/2/users/get_current_account",
|
||||||
|
userinfo_compliance_fix=_dropbox_userinfo_compliance_fix,
|
||||||
|
client_kwargs={
|
||||||
|
"token_endpoint_auth_method": "client_secret_post",
|
||||||
|
"token_access_type": "offline",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
SOCIAL_PROVIDERS["dropbox"] = {"name": "Dropbox", "icon": "fab fa-dropbox", "color": "blue"}
|
||||||
|
logger.info("Social login provider registered: Dropbox")
|
||||||
|
else:
|
||||||
|
logger.warning("SOCIAL_AUTH_DROPBOX_ENABLED=true but client ID/secret not configured")
|
||||||
|
|
||||||
|
# GitHub
|
||||||
|
if settings.social_auth_github_enabled:
|
||||||
|
if settings.social_auth_github_client_id and settings.social_auth_github_client_secret:
|
||||||
|
_register_oauth_client(
|
||||||
|
"github",
|
||||||
|
client_id=settings.social_auth_github_client_id,
|
||||||
|
client_secret=settings.social_auth_github_client_secret,
|
||||||
|
authorize_url="https://github.com/login/oauth/authorize",
|
||||||
|
access_token_url="https://github.com/login/oauth/access_token",
|
||||||
|
userinfo_endpoint="https://api.github.com/user",
|
||||||
|
client_kwargs={"scope": "read:user user:email"},
|
||||||
|
)
|
||||||
|
SOCIAL_PROVIDERS["github"] = {"name": "GitHub", "icon": "fab fa-github", "color": "gray"}
|
||||||
|
logger.info("Social login provider registered: GitHub")
|
||||||
|
else:
|
||||||
|
logger.warning("SOCIAL_AUTH_GITHUB_ENABLED=true but client ID/secret not configured")
|
||||||
|
|
||||||
|
# Keycloak
|
||||||
|
if settings.social_auth_keycloak_enabled:
|
||||||
|
_kc_server = settings.social_auth_keycloak_server_url
|
||||||
|
_kc_realm = settings.social_auth_keycloak_realm
|
||||||
|
if (
|
||||||
|
settings.social_auth_keycloak_client_id
|
||||||
|
and settings.social_auth_keycloak_client_secret
|
||||||
|
and _kc_server
|
||||||
|
and _kc_realm
|
||||||
|
):
|
||||||
|
_kc_base = f"{_kc_server.rstrip('/')}/realms/{_kc_realm}"
|
||||||
|
_register_oauth_client(
|
||||||
|
"keycloak",
|
||||||
|
client_id=settings.social_auth_keycloak_client_id,
|
||||||
|
client_secret=settings.social_auth_keycloak_client_secret,
|
||||||
|
server_metadata_url=f"{_kc_base}/.well-known/openid-configuration",
|
||||||
|
client_kwargs={"scope": "openid profile email"},
|
||||||
|
)
|
||||||
|
SOCIAL_PROVIDERS["keycloak"] = {"name": "Keycloak", "icon": "fas fa-key", "color": "gray"}
|
||||||
|
logger.info("Social login provider registered: Keycloak (realm=%s)", _kc_realm)
|
||||||
|
else:
|
||||||
|
logger.warning("SOCIAL_AUTH_KEYCLOAK_ENABLED=true but required settings not configured")
|
||||||
|
|
||||||
|
# Generic OAuth2
|
||||||
|
if settings.social_auth_generic_oauth2_enabled:
|
||||||
|
if (
|
||||||
|
settings.social_auth_generic_oauth2_client_id
|
||||||
|
and settings.social_auth_generic_oauth2_client_secret
|
||||||
|
and settings.social_auth_generic_oauth2_authorize_url
|
||||||
|
and settings.social_auth_generic_oauth2_token_url
|
||||||
|
):
|
||||||
|
_register_oauth_client(
|
||||||
|
"generic_oauth2",
|
||||||
|
client_id=settings.social_auth_generic_oauth2_client_id,
|
||||||
|
client_secret=settings.social_auth_generic_oauth2_client_secret,
|
||||||
|
authorize_url=settings.social_auth_generic_oauth2_authorize_url,
|
||||||
|
access_token_url=settings.social_auth_generic_oauth2_token_url,
|
||||||
|
userinfo_endpoint=settings.social_auth_generic_oauth2_userinfo_url,
|
||||||
|
client_kwargs={"scope": settings.social_auth_generic_oauth2_scope},
|
||||||
|
)
|
||||||
|
_generic_name = settings.social_auth_generic_oauth2_name or "OAuth2"
|
||||||
|
SOCIAL_PROVIDERS["generic_oauth2"] = {
|
||||||
|
"name": _generic_name,
|
||||||
|
"icon": "fas fa-sign-in-alt",
|
||||||
|
"color": "indigo",
|
||||||
|
}
|
||||||
|
logger.info("Social login provider registered: Generic OAuth2")
|
||||||
|
else:
|
||||||
|
logger.warning("SOCIAL_AUTH_GENERIC_OAUTH2_ENABLED=true but required settings not configured")
|
||||||
|
|
||||||
|
|
||||||
|
def refresh_social_providers() -> None:
|
||||||
|
"""Re-register all OAuth providers from the *current* settings object.
|
||||||
|
|
||||||
|
Call this after loading or reloading settings from the database so that
|
||||||
|
providers configured (or updated) through the admin UI take effect
|
||||||
|
immediately — **no application restart required**.
|
||||||
|
|
||||||
|
This function is safe to call multiple times and is idempotent.
|
||||||
|
"""
|
||||||
|
logger.info("Refreshing social login provider registrations from current settings")
|
||||||
|
_setup_social_providers()
|
||||||
|
|
||||||
|
|
||||||
|
# Perform the initial registration from environment / default settings at
|
||||||
|
# import time. The lifespan hook and settings_sync will call
|
||||||
|
# refresh_social_providers() again after DB settings are loaded so that
|
||||||
|
# any providers configured only in the database are also active.
|
||||||
|
_setup_social_providers()
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
@@ -464,9 +536,9 @@ async def login(request: Request):
|
|||||||
return RedirectResponse(url="/oauth-login", status_code=status.HTTP_302_FOUND)
|
return RedirectResponse(url="/oauth-login", status_code=status.HTTP_302_FOUND)
|
||||||
|
|
||||||
return templates.TemplateResponse(
|
return templates.TemplateResponse(
|
||||||
|
request,
|
||||||
"login.html",
|
"login.html",
|
||||||
{
|
context={
|
||||||
"request": request,
|
|
||||||
"error": error,
|
"error": error,
|
||||||
"message": message,
|
"message": message,
|
||||||
"show_oauth": show_oauth,
|
"show_oauth": show_oauth,
|
||||||
|
|||||||
+24
-7
@@ -189,6 +189,18 @@ async def lifespan(app: FastAPI):
|
|||||||
finally:
|
finally:
|
||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
|
# Re-register OAuth / social-login providers now that DB settings are
|
||||||
|
# loaded. auth.py runs its initial registration at import time (before
|
||||||
|
# the lifespan runs), so providers that are only configured in the
|
||||||
|
# database would not be registered yet. Calling refresh here ensures
|
||||||
|
# they are active immediately on startup without any manual restart.
|
||||||
|
try:
|
||||||
|
from app.auth import refresh_social_providers
|
||||||
|
|
||||||
|
refresh_social_providers()
|
||||||
|
except Exception as e:
|
||||||
|
logging.warning(f"Could not refresh social login providers on startup: {e}")
|
||||||
|
|
||||||
# Initialize Sentry after DB settings are loaded so that values configured
|
# Initialize Sentry after DB settings are loaded so that values configured
|
||||||
# via the database UI (e.g. SENTRY_DSN) are respected in addition to env vars.
|
# via the database UI (e.g. SENTRY_DSN) are respected in addition to env vars.
|
||||||
init_sentry()
|
init_sentry()
|
||||||
@@ -282,10 +294,16 @@ async def lifespan(app: FastAPI):
|
|||||||
yield
|
yield
|
||||||
|
|
||||||
# Shutdown: Cleanup tasks
|
# Shutdown: Cleanup tasks
|
||||||
logging.info("Application shutting down")
|
try:
|
||||||
|
logging.info("Application shutting down")
|
||||||
|
except Exception:
|
||||||
|
_startup_logger.exception("Error during shutdown logging")
|
||||||
|
|
||||||
# Send shutdown notification
|
# Send shutdown notification
|
||||||
notify_shutdown()
|
try:
|
||||||
|
notify_shutdown()
|
||||||
|
except Exception:
|
||||||
|
_startup_logger.exception("Error sending shutdown notification")
|
||||||
|
|
||||||
|
|
||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
@@ -412,15 +430,13 @@ async def http_exception_handler(request: Request, exc: HTTPException):
|
|||||||
# For frontend routes, return appropriate HTML templates
|
# For frontend routes, return appropriate HTML templates
|
||||||
# Handle 404 errors with a custom template
|
# Handle 404 errors with a custom template
|
||||||
if exc.status_code == 404:
|
if exc.status_code == 404:
|
||||||
return _error_templates.TemplateResponse(
|
return _error_templates.TemplateResponse(request, "404.html", status_code=status.HTTP_404_NOT_FOUND)
|
||||||
"404.html", {"request": request}, status_code=status.HTTP_404_NOT_FOUND
|
|
||||||
)
|
|
||||||
|
|
||||||
# For other HTTP errors, we could create specific templates or use a generic one
|
# For other HTTP errors, we could create specific templates or use a generic one
|
||||||
# For now, return a simple error page
|
# For now, return a simple error page
|
||||||
return _error_templates.TemplateResponse(
|
return _error_templates.TemplateResponse(
|
||||||
|
request,
|
||||||
"404.html", # Reuse 404 template for other errors, or create a generic error template
|
"404.html", # Reuse 404 template for other errors, or create a generic error template
|
||||||
{"request": request},
|
|
||||||
status_code=exc.status_code,
|
status_code=exc.status_code,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -440,8 +456,9 @@ async def custom_500_handler(request: Request, exc: Exception):
|
|||||||
|
|
||||||
# Serve the 500 template for non-API routes
|
# Serve the 500 template for non-API routes
|
||||||
return _error_templates.TemplateResponse(
|
return _error_templates.TemplateResponse(
|
||||||
|
request,
|
||||||
"500.html",
|
"500.html",
|
||||||
{"request": request, "exc": exc},
|
context={"exc": exc},
|
||||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -205,7 +205,7 @@ def convert_to_pdf(
|
|||||||
".pdf", # PDF (already in PDF format but can be processed)
|
".pdf", # PDF (already in PDF format but can be processed)
|
||||||
}
|
}
|
||||||
|
|
||||||
IMAGE_EXTENSIONS = {".jpg", ".jpeg", ".png", ".gif", ".bmp", ".tiff", ".tif", ".webp", ".svg", ".heic", ".heif"}
|
IMAGE_EXTENSIONS = {".jpg", ".jpeg", ".png", ".gif", ".bmp", ".tiff", ".tif", ".webp", ".svg"}
|
||||||
|
|
||||||
HTML_EXTENSIONS = {".html", ".htm"}
|
HTML_EXTENSIONS = {".html", ".htm"}
|
||||||
|
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ def _convert_pdf_to_pdfa(input_path: str, output_path: str, pdfa_format: str = "
|
|||||||
output_type,
|
output_type,
|
||||||
"--quiet",
|
"--quiet",
|
||||||
"--invalidate-digital-signatures",
|
"--invalidate-digital-signatures",
|
||||||
"--",
|
"--", # end-of-options separator: prevents file paths from being interpreted as options
|
||||||
input_path,
|
input_path,
|
||||||
output_path,
|
output_path,
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -76,7 +76,7 @@ def extract_metadata_with_gpt(self, filename: str, cleaned_text: str, file_id: i
|
|||||||
"Your task is to analyze the given text and return a well-structured JSON object.\n\n"
|
"Your task is to analyze the given text and return a well-structured JSON object.\n\n"
|
||||||
"Extract and return the following fields:\n"
|
"Extract and return the following fields:\n"
|
||||||
"1. **filename**: Machine-readable filename "
|
"1. **filename**: Machine-readable filename "
|
||||||
"(YYYY-MM-DD_DescriptiveTitle, use only letters, numbers, spaces, dashes, periods, and underscores).\n"
|
"(YYYY-MM-DD_DescriptiveTitle, use only letters, numbers, periods, and underscores).\n"
|
||||||
'2. **empfaenger**: The recipient, or "Unknown" if not found.\n'
|
'2. **empfaenger**: The recipient, or "Unknown" if not found.\n'
|
||||||
'3. **absender**: The sender, or "Unknown" if not found.\n'
|
'3. **absender**: The sender, or "Unknown" if not found.\n'
|
||||||
"4. **correspondent**: The entity or company that issued the document "
|
"4. **correspondent**: The entity or company that issued the document "
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ from app.utils.allowed_types import (
|
|||||||
DEFAULT_CATEGORIES,
|
DEFAULT_CATEGORIES,
|
||||||
get_allowed_types_for_categories,
|
get_allowed_types_for_categories,
|
||||||
)
|
)
|
||||||
|
from app.utils.network import is_private_ip
|
||||||
|
|
||||||
# Database session for per-user IMAP accounts (imported lazily to avoid circular imports)
|
# Database session for per-user IMAP accounts (imported lazily to avoid circular imports)
|
||||||
_db_session_factory = None
|
_db_session_factory = None
|
||||||
@@ -405,6 +406,11 @@ def pull_inbox(
|
|||||||
)
|
)
|
||||||
processed_emails = load_processed_emails()
|
processed_emails = load_processed_emails()
|
||||||
|
|
||||||
|
# Security: Prevent SSRF by blocking connections to internal IPs
|
||||||
|
if is_private_ip(host):
|
||||||
|
logger.warning("SSRF blocked: Attempt to pull mailbox from private IP %s", host)
|
||||||
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
mail = imaplib.IMAP4_SSL(host, port) if use_ssl else imaplib.IMAP4(host, port)
|
mail = imaplib.IMAP4_SSL(host, port) if use_ssl else imaplib.IMAP4(host, port)
|
||||||
mail.login(username, password)
|
mail.login(username, password)
|
||||||
|
|||||||
@@ -555,9 +555,8 @@ def _upload_rclone(file_path: str, cfg: dict[str, Any], creds: dict[str, Any], t
|
|||||||
dest = dest.replace("//", "/")
|
dest = dest.replace("//", "/")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# SECURITY: Separate options from positional arguments using -- to prevent command injection
|
|
||||||
result = subprocess.run( # nosec B603 # noqa: S603 S607
|
result = subprocess.run( # nosec B603 # noqa: S603 S607
|
||||||
["rclone", "copyto", f"--config={conf_path}", "--", file_path, dest], # noqa: S603 S607
|
["rclone", "copyto", f"--config={conf_path}", file_path, dest], # noqa: S603 S607
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
text=True,
|
text=True,
|
||||||
timeout=300,
|
timeout=300,
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import logging
|
|||||||
import re
|
import re
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from sqlalchemy import MetaData, create_engine, inspect, text
|
from sqlalchemy import MetaData, create_engine, func, inspect, select, table
|
||||||
from sqlalchemy.engine import Engine
|
from sqlalchemy.engine import Engine
|
||||||
from sqlalchemy.engine.url import make_url
|
from sqlalchemy.engine.url import make_url
|
||||||
from sqlalchemy.orm import sessionmaker
|
from sqlalchemy.orm import sessionmaker
|
||||||
@@ -89,8 +89,9 @@ def preview_migration(source_url: str) -> dict[str, Any]:
|
|||||||
logger.warning(f"Skipping table with invalid name format: {table_name}")
|
logger.warning(f"Skipping table with invalid name format: {table_name}")
|
||||||
continue
|
continue
|
||||||
# table_name is safe — sourced from inspect().get_table_names(), not user input
|
# table_name is safe — sourced from inspect().get_table_names(), not user input
|
||||||
quoted_table = conn.dialect.identifier_preparer.quote(table_name)
|
t = table(table_name)
|
||||||
row = conn.execute(text(f"SELECT COUNT(*) FROM {quoted_table}")).fetchone() # noqa: S608
|
query = select(func.count()).select_from(t)
|
||||||
|
row = conn.execute(query).fetchone()
|
||||||
count = row[0] if row else 0
|
count = row[0] if row else 0
|
||||||
result.append({"name": table_name, "row_count": count})
|
result.append({"name": table_name, "row_count": count})
|
||||||
total += count
|
total += count
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
from typing import Dict
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
def update_env_file(settings_to_update: Dict[str, str]) -> bool:
|
def update_env_file(settings_to_update: dict[str, str]) -> bool:
|
||||||
"""
|
"""
|
||||||
Updates the .env file with the given settings (best-effort).
|
Updates the .env file with the given settings (best-effort).
|
||||||
Creates or modifies existing keys.
|
Creates or modifies existing keys.
|
||||||
|
|||||||
@@ -7,8 +7,19 @@ def hash_file(filepath: str | Path, chunk_size: int = 65536) -> str:
|
|||||||
Returns the SHA-256 hash of the file at 'filepath'.
|
Returns the SHA-256 hash of the file at 'filepath'.
|
||||||
Reads the file in chunks to handle large files efficiently.
|
Reads the file in chunks to handle large files efficiently.
|
||||||
"""
|
"""
|
||||||
|
from app.config import settings
|
||||||
|
|
||||||
|
filepath_obj = Path(filepath).resolve()
|
||||||
|
workdir_obj = Path(settings.workdir).resolve()
|
||||||
|
|
||||||
|
# Security check: Ensure the resolved path is strictly within the allowed workdir
|
||||||
|
try:
|
||||||
|
filepath_obj.relative_to(workdir_obj)
|
||||||
|
except ValueError:
|
||||||
|
raise FileNotFoundError(f"Access denied: path traversal attempt or file outside workdir '{filepath}'")
|
||||||
|
|
||||||
sha256 = hashlib.sha256()
|
sha256 = hashlib.sha256()
|
||||||
with open(filepath, "rb") as f:
|
with open(filepath_obj, "rb") as f:
|
||||||
while True:
|
while True:
|
||||||
data = f.read(chunk_size)
|
data = f.read(chunk_size)
|
||||||
if not data:
|
if not data:
|
||||||
|
|||||||
+31
-5
@@ -1,6 +1,7 @@
|
|||||||
import ipaddress
|
import ipaddress
|
||||||
import logging
|
import logging
|
||||||
import socket
|
import socket
|
||||||
|
from urllib.parse import urlsplit, urlunsplit
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -27,8 +28,33 @@ def is_private_ip(hostname: str) -> bool:
|
|||||||
return True
|
return True
|
||||||
return False
|
return False
|
||||||
except (socket.gaierror, socket.error):
|
except (socket.gaierror, socket.error):
|
||||||
# Cannot resolve - allow for testing/development
|
# Cannot resolve.
|
||||||
# In production, DNS should work properly
|
# Fail securely: block unresolved domains to prevent DNS rebinding
|
||||||
# Log this for debugging
|
# and SSRF bypasses via unresolvable addresses.
|
||||||
logger.warning(f"Could not resolve hostname: {hostname}")
|
logger.warning(f"Could not resolve hostname (blocking securely): {hostname}")
|
||||||
return False # Changed from True to False to allow external domains in tests
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def join_url(base: str, *parts: str) -> str:
|
||||||
|
"""
|
||||||
|
Safely join a base URL with one or more path parts.
|
||||||
|
|
||||||
|
Uses urllib.parse to correctly handle scheme/netloc/query/fragment so that
|
||||||
|
only the path component is modified. Leading and trailing slashes are
|
||||||
|
stripped from each part before joining, preventing double-slash sequences
|
||||||
|
at segment boundaries without touching the scheme separator or query string.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
join_url("https://example.com/dav/", "/remote/", "file.pdf")
|
||||||
|
-> "https://example.com/dav/remote/file.pdf"
|
||||||
|
"""
|
||||||
|
parsed = urlsplit(base)
|
||||||
|
# Strip each part once and filter out empty segments; use walrus operator
|
||||||
|
# to avoid calling strip twice per iteration.
|
||||||
|
stripped_parts = [s for p in parts if (s := p.strip("/"))]
|
||||||
|
base_path = parsed.path.rstrip("/")
|
||||||
|
new_path = base_path + "/" + "/".join(stripped_parts) if stripped_parts else base_path
|
||||||
|
# Ensure path is non-empty so the reconstructed URL is valid.
|
||||||
|
if not new_path:
|
||||||
|
new_path = "/"
|
||||||
|
return urlunsplit((parsed.scheme, parsed.netloc, new_path, parsed.query, parsed.fragment))
|
||||||
|
|||||||
@@ -71,6 +71,16 @@ def notify_settings_updated() -> None:
|
|||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.warning(f"Could not reload in-process settings: {exc}")
|
logger.warning(f"Could not reload in-process settings: {exc}")
|
||||||
|
|
||||||
|
# Re-register OAuth / social-login providers so that any provider whose
|
||||||
|
# credentials were just saved (or updated) in the database is active
|
||||||
|
# immediately on the login page — no restart required.
|
||||||
|
try:
|
||||||
|
from app.auth import refresh_social_providers
|
||||||
|
|
||||||
|
refresh_social_providers()
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning(f"Could not refresh social login providers after settings update: {exc}")
|
||||||
|
|
||||||
# Re-check OCR language availability in the background whenever settings
|
# Re-check OCR language availability in the background whenever settings
|
||||||
# are updated. This ensures that if a user changes tesseract_language or
|
# are updated. This ensures that if a user changes tesseract_language or
|
||||||
# easyocr_languages via the UI, the new language data is downloaded without
|
# easyocr_languages via the UI, the new language data is downloaded without
|
||||||
|
|||||||
@@ -12,11 +12,13 @@ import smtplib
|
|||||||
from email.mime.multipart import MIMEMultipart
|
from email.mime.multipart import MIMEMultipart
|
||||||
from email.mime.text import MIMEText
|
from email.mime.text import MIMEText
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
from app.database import SessionLocal
|
from app.database import SessionLocal
|
||||||
from app.models import InAppNotification, UserNotificationPreference, UserNotificationTarget
|
from app.models import InAppNotification, UserNotificationPreference, UserNotificationTarget
|
||||||
|
from app.utils.network import is_private_ip
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -28,6 +30,11 @@ USER_EVENT_LABELS: dict[str, str] = {
|
|||||||
EVENT_DOCUMENT_PROCESSED: "Document Processed",
|
EVENT_DOCUMENT_PROCESSED: "Document Processed",
|
||||||
EVENT_DOCUMENT_FAILED: "Document Processing Failed",
|
EVENT_DOCUMENT_FAILED: "Document Processing Failed",
|
||||||
}
|
}
|
||||||
|
METADATA_ENDPOINTS = {
|
||||||
|
"169.254.169.254",
|
||||||
|
"169.254.169.253",
|
||||||
|
"metadata.google.internal",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def create_in_app_notification(
|
def create_in_app_notification(
|
||||||
@@ -128,6 +135,20 @@ def _send_webhook_notification(target_config: dict[str, Any], event_type: str, t
|
|||||||
logger.warning("Webhook notification target missing url")
|
logger.warning("Webhook notification target missing url")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
parsed_url = urlparse(url)
|
||||||
|
if parsed_url.scheme not in {"http", "https"}:
|
||||||
|
logger.warning("Webhook notification to %s blocked: invalid scheme %s", url, parsed_url.scheme)
|
||||||
|
return False
|
||||||
|
|
||||||
|
hostname = parsed_url.hostname
|
||||||
|
if not hostname:
|
||||||
|
logger.warning("Webhook notification to %s blocked: missing hostname", url)
|
||||||
|
return False
|
||||||
|
|
||||||
|
if hostname in METADATA_ENDPOINTS or is_private_ip(hostname):
|
||||||
|
logger.warning("Webhook notification to %s blocked: private or metadata endpoint", url)
|
||||||
|
return False
|
||||||
|
|
||||||
payload = {
|
payload = {
|
||||||
"event": event_type,
|
"event": event_type,
|
||||||
"title": title,
|
"title": title,
|
||||||
|
|||||||
@@ -18,11 +18,13 @@ import json
|
|||||||
import logging
|
import logging
|
||||||
import time
|
import time
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
|
|
||||||
from app.database import SessionLocal
|
from app.database import SessionLocal
|
||||||
from app.models import WebhookConfig
|
from app.models import WebhookConfig
|
||||||
|
from app.utils.network import is_private_ip
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -40,6 +42,11 @@ VALID_EVENTS: frozenset[str] = frozenset(
|
|||||||
|
|
||||||
#: Timeout (seconds) for outgoing webhook HTTP requests.
|
#: Timeout (seconds) for outgoing webhook HTTP requests.
|
||||||
WEBHOOK_TIMEOUT = 10
|
WEBHOOK_TIMEOUT = 10
|
||||||
|
METADATA_ENDPOINTS = {
|
||||||
|
"169.254.169.254",
|
||||||
|
"169.254.169.253",
|
||||||
|
"metadata.google.internal",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def compute_signature(payload_bytes: bytes, secret: str) -> str:
|
def compute_signature(payload_bytes: bytes, secret: str) -> str:
|
||||||
@@ -67,6 +74,20 @@ def deliver_webhook(url: str, payload: dict[str, Any], secret: str | None = None
|
|||||||
Returns:
|
Returns:
|
||||||
``True`` when the remote server responds with a 2xx status.
|
``True`` when the remote server responds with a 2xx status.
|
||||||
"""
|
"""
|
||||||
|
parsed_url = urlparse(url)
|
||||||
|
if parsed_url.scheme not in {"http", "https"}:
|
||||||
|
logger.warning("Webhook to %s blocked: invalid scheme %s", url, parsed_url.scheme)
|
||||||
|
return False
|
||||||
|
|
||||||
|
hostname = parsed_url.hostname
|
||||||
|
if not hostname:
|
||||||
|
logger.warning("Webhook to %s blocked: missing hostname", url)
|
||||||
|
return False
|
||||||
|
|
||||||
|
if hostname in METADATA_ENDPOINTS or is_private_ip(hostname):
|
||||||
|
logger.warning("Webhook to %s blocked: private or metadata endpoint", url)
|
||||||
|
return False
|
||||||
|
|
||||||
body = json.dumps(payload, default=str, sort_keys=True)
|
body = json.dumps(payload, default=str, sort_keys=True)
|
||||||
body_bytes = body.encode("utf-8")
|
body_bytes = body.encode("utf-8")
|
||||||
|
|
||||||
|
|||||||
+29
-5
@@ -162,12 +162,36 @@ def _inject_global_context(ctx: dict) -> None:
|
|||||||
|
|
||||||
|
|
||||||
def template_response_with_version(*args, **kwargs):
|
def template_response_with_version(*args, **kwargs):
|
||||||
"""Wrapper for TemplateResponse to include version and CSRF token in all templates"""
|
"""Wrapper for TemplateResponse to include version and CSRF token in all templates.
|
||||||
# If context dict is provided, add version to it
|
|
||||||
if len(args) >= 2 and isinstance(args[1], dict):
|
Handles both old-style and new-style Starlette TemplateResponse calls:
|
||||||
_inject_global_context(args[1])
|
- Old-style (Starlette <1.0): TemplateResponse(name, {"request": req, ...}, ...)
|
||||||
elif "context" in kwargs and isinstance(kwargs["context"], dict):
|
- New-style (Starlette 1.0+): TemplateResponse(request, name, context={...}, ...)
|
||||||
|
"""
|
||||||
|
if len(args) >= 1 and isinstance(args[0], str):
|
||||||
|
# Old-style call: first positional arg is the template name (string).
|
||||||
|
# Convert to new-style: (request, name, context=..., ...)
|
||||||
|
name = args[0]
|
||||||
|
if len(args) >= 2 and isinstance(args[1], dict):
|
||||||
|
context = args[1]
|
||||||
|
# Old-style may have status_code as 3rd positional arg
|
||||||
|
if len(args) >= 3 and "status_code" not in kwargs:
|
||||||
|
kwargs["status_code"] = args[2]
|
||||||
|
else:
|
||||||
|
context = kwargs.pop("context", {})
|
||||||
|
request_obj = context.pop("request", None)
|
||||||
|
if request_obj is not None:
|
||||||
|
context["request"] = request_obj
|
||||||
|
_inject_global_context(context)
|
||||||
|
if request_obj is not None:
|
||||||
|
return original_template_response(request_obj, name, context=context, **kwargs)
|
||||||
|
return original_template_response(name, context=context, **kwargs)
|
||||||
|
|
||||||
|
# New-style call: (request, name, context=..., ...)
|
||||||
|
if "context" in kwargs and isinstance(kwargs["context"], dict):
|
||||||
_inject_global_context(kwargs["context"])
|
_inject_global_context(kwargs["context"])
|
||||||
|
elif len(args) >= 3 and isinstance(args[2], dict):
|
||||||
|
_inject_global_context(args[2])
|
||||||
return original_template_response(*args, **kwargs)
|
return original_template_response(*args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+57
-12
@@ -206,8 +206,6 @@ async def connections_page(request: Request, db: Session = Depends(get_db)):
|
|||||||
SSO settings, and service integrations through a wizard-like interface.
|
SSO settings, and service integrations through a wizard-like interface.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
from app.auth import OAUTH_CONFIGURED, SOCIAL_PROVIDERS
|
|
||||||
|
|
||||||
db_settings = get_all_settings_from_db(db)
|
db_settings = get_all_settings_from_db(db)
|
||||||
|
|
||||||
def _get_effective(key: str):
|
def _get_effective(key: str):
|
||||||
@@ -227,13 +225,14 @@ async def connections_page(request: Request, db: Session = Depends(get_db)):
|
|||||||
services = []
|
services = []
|
||||||
|
|
||||||
# --- SSO (Authentik / OIDC) ---
|
# --- SSO (Authentik / OIDC) ---
|
||||||
|
_oidc_linked = bool(_get_effective("authentik_client_id") and _get_effective("authentik_client_secret"))
|
||||||
services.append(
|
services.append(
|
||||||
{
|
{
|
||||||
"key": "oidc",
|
"key": "oidc",
|
||||||
"name": settings.oauth_provider_name or "Single Sign-On",
|
"name": _get_effective("oauth_provider_name") or "Single Sign-On",
|
||||||
"icon": "fas fa-lock",
|
"icon": "fas fa-lock",
|
||||||
"type": "SSO",
|
"type": "SSO",
|
||||||
"linked": OAUTH_CONFIGURED,
|
"linked": _oidc_linked,
|
||||||
"description": "OpenID Connect SSO provider",
|
"description": "OpenID Connect SSO provider",
|
||||||
"settings_keys": [
|
"settings_keys": [
|
||||||
"authentik_client_id",
|
"authentik_client_id",
|
||||||
@@ -245,13 +244,23 @@ async def connections_page(request: Request, db: Session = Depends(get_db)):
|
|||||||
)
|
)
|
||||||
|
|
||||||
# --- Google ---
|
# --- Google ---
|
||||||
|
_google_id = _get_effective("social_auth_google_client_id")
|
||||||
|
_google_secret = _get_effective("social_auth_google_client_secret")
|
||||||
|
if _is_truthy(_get_effective("social_auth_google_use_global_credentials")) and not (
|
||||||
|
_google_id and _google_secret
|
||||||
|
):
|
||||||
|
_google_id = _google_id or _get_effective("google_drive_client_id")
|
||||||
|
_google_secret = _google_secret or _get_effective("google_drive_client_secret")
|
||||||
|
_google_linked = bool(
|
||||||
|
_is_truthy(_get_effective("social_auth_google_enabled")) and _google_id and _google_secret
|
||||||
|
)
|
||||||
services.append(
|
services.append(
|
||||||
{
|
{
|
||||||
"key": "google",
|
"key": "google",
|
||||||
"name": "Google",
|
"name": "Google",
|
||||||
"icon": "fab fa-google",
|
"icon": "fab fa-google",
|
||||||
"type": "Sign-in authentication",
|
"type": "Sign-in authentication",
|
||||||
"linked": "google" in SOCIAL_PROVIDERS,
|
"linked": _google_linked,
|
||||||
"description": "Sign-in authentication",
|
"description": "Sign-in authentication",
|
||||||
"settings_keys": [
|
"settings_keys": [
|
||||||
"social_auth_google_enabled",
|
"social_auth_google_enabled",
|
||||||
@@ -263,13 +272,18 @@ async def connections_page(request: Request, db: Session = Depends(get_db)):
|
|||||||
)
|
)
|
||||||
|
|
||||||
# --- GitHub ---
|
# --- GitHub ---
|
||||||
|
_github_linked = bool(
|
||||||
|
_is_truthy(_get_effective("social_auth_github_enabled"))
|
||||||
|
and _get_effective("social_auth_github_client_id")
|
||||||
|
and _get_effective("social_auth_github_client_secret")
|
||||||
|
)
|
||||||
services.append(
|
services.append(
|
||||||
{
|
{
|
||||||
"key": "github",
|
"key": "github",
|
||||||
"name": "GitHub",
|
"name": "GitHub",
|
||||||
"icon": "fab fa-github",
|
"icon": "fab fa-github",
|
||||||
"type": "Sign-in authentication",
|
"type": "Sign-in authentication",
|
||||||
"linked": "github" in SOCIAL_PROVIDERS,
|
"linked": _github_linked,
|
||||||
"description": "Sign-in authentication",
|
"description": "Sign-in authentication",
|
||||||
"settings_keys": [
|
"settings_keys": [
|
||||||
"social_auth_github_enabled",
|
"social_auth_github_enabled",
|
||||||
@@ -280,13 +294,19 @@ async def connections_page(request: Request, db: Session = Depends(get_db)):
|
|||||||
)
|
)
|
||||||
|
|
||||||
# --- Microsoft ---
|
# --- Microsoft ---
|
||||||
|
_ms_id = _get_effective("social_auth_microsoft_client_id")
|
||||||
|
_ms_secret = _get_effective("social_auth_microsoft_client_secret")
|
||||||
|
if _is_truthy(_get_effective("social_auth_microsoft_use_global_credentials")) and not (_ms_id and _ms_secret):
|
||||||
|
_ms_id = _ms_id or _get_effective("onedrive_client_id")
|
||||||
|
_ms_secret = _ms_secret or _get_effective("onedrive_client_secret")
|
||||||
|
_microsoft_linked = bool(_is_truthy(_get_effective("social_auth_microsoft_enabled")) and _ms_id and _ms_secret)
|
||||||
services.append(
|
services.append(
|
||||||
{
|
{
|
||||||
"key": "microsoft",
|
"key": "microsoft",
|
||||||
"name": "Microsoft",
|
"name": "Microsoft",
|
||||||
"icon": "fab fa-microsoft",
|
"icon": "fab fa-microsoft",
|
||||||
"type": "Sign-in authentication",
|
"type": "Sign-in authentication",
|
||||||
"linked": "microsoft" in SOCIAL_PROVIDERS,
|
"linked": _microsoft_linked,
|
||||||
"description": "Sign-in authentication",
|
"description": "Sign-in authentication",
|
||||||
"settings_keys": [
|
"settings_keys": [
|
||||||
"social_auth_microsoft_enabled",
|
"social_auth_microsoft_enabled",
|
||||||
@@ -299,13 +319,18 @@ async def connections_page(request: Request, db: Session = Depends(get_db)):
|
|||||||
)
|
)
|
||||||
|
|
||||||
# --- Apple ---
|
# --- Apple ---
|
||||||
|
_apple_linked = bool(
|
||||||
|
_is_truthy(_get_effective("social_auth_apple_enabled"))
|
||||||
|
and _get_effective("social_auth_apple_client_id")
|
||||||
|
and _get_effective("social_auth_apple_team_id")
|
||||||
|
)
|
||||||
services.append(
|
services.append(
|
||||||
{
|
{
|
||||||
"key": "apple",
|
"key": "apple",
|
||||||
"name": "Apple",
|
"name": "Apple",
|
||||||
"icon": "fab fa-apple",
|
"icon": "fab fa-apple",
|
||||||
"type": "Sign-in authentication",
|
"type": "Sign-in authentication",
|
||||||
"linked": "apple" in SOCIAL_PROVIDERS,
|
"linked": _apple_linked,
|
||||||
"description": "Sign-in authentication",
|
"description": "Sign-in authentication",
|
||||||
"settings_keys": [
|
"settings_keys": [
|
||||||
"social_auth_apple_enabled",
|
"social_auth_apple_enabled",
|
||||||
@@ -318,13 +343,19 @@ async def connections_page(request: Request, db: Session = Depends(get_db)):
|
|||||||
)
|
)
|
||||||
|
|
||||||
# --- Dropbox ---
|
# --- Dropbox ---
|
||||||
|
_dbx_id = _get_effective("social_auth_dropbox_client_id")
|
||||||
|
_dbx_secret = _get_effective("social_auth_dropbox_client_secret")
|
||||||
|
if _is_truthy(_get_effective("social_auth_dropbox_use_global_credentials")) and not (_dbx_id and _dbx_secret):
|
||||||
|
_dbx_id = _dbx_id or _get_effective("dropbox_app_key")
|
||||||
|
_dbx_secret = _dbx_secret or _get_effective("dropbox_app_secret")
|
||||||
|
_dropbox_linked = bool(_is_truthy(_get_effective("social_auth_dropbox_enabled")) and _dbx_id and _dbx_secret)
|
||||||
services.append(
|
services.append(
|
||||||
{
|
{
|
||||||
"key": "dropbox",
|
"key": "dropbox",
|
||||||
"name": "Dropbox",
|
"name": "Dropbox",
|
||||||
"icon": "fab fa-dropbox",
|
"icon": "fab fa-dropbox",
|
||||||
"type": "Sign-in authentication",
|
"type": "Sign-in authentication",
|
||||||
"linked": "dropbox" in SOCIAL_PROVIDERS,
|
"linked": _dropbox_linked,
|
||||||
"description": "Sign-in authentication",
|
"description": "Sign-in authentication",
|
||||||
"settings_keys": [
|
"settings_keys": [
|
||||||
"social_auth_dropbox_enabled",
|
"social_auth_dropbox_enabled",
|
||||||
@@ -336,13 +367,20 @@ async def connections_page(request: Request, db: Session = Depends(get_db)):
|
|||||||
)
|
)
|
||||||
|
|
||||||
# --- Keycloak ---
|
# --- Keycloak ---
|
||||||
|
_keycloak_linked = bool(
|
||||||
|
_is_truthy(_get_effective("social_auth_keycloak_enabled"))
|
||||||
|
and _get_effective("social_auth_keycloak_client_id")
|
||||||
|
and _get_effective("social_auth_keycloak_client_secret")
|
||||||
|
and _get_effective("social_auth_keycloak_server_url")
|
||||||
|
and _get_effective("social_auth_keycloak_realm")
|
||||||
|
)
|
||||||
services.append(
|
services.append(
|
||||||
{
|
{
|
||||||
"key": "keycloak",
|
"key": "keycloak",
|
||||||
"name": "Keycloak",
|
"name": "Keycloak",
|
||||||
"icon": "fas fa-key",
|
"icon": "fas fa-key",
|
||||||
"type": "SSO",
|
"type": "SSO",
|
||||||
"linked": "keycloak" in SOCIAL_PROVIDERS,
|
"linked": _keycloak_linked,
|
||||||
"description": "SSO",
|
"description": "SSO",
|
||||||
"settings_keys": [
|
"settings_keys": [
|
||||||
"social_auth_keycloak_enabled",
|
"social_auth_keycloak_enabled",
|
||||||
@@ -355,13 +393,20 @@ async def connections_page(request: Request, db: Session = Depends(get_db)):
|
|||||||
)
|
)
|
||||||
|
|
||||||
# --- Generic OAuth2 ---
|
# --- Generic OAuth2 ---
|
||||||
|
_generic_oauth2_linked = bool(
|
||||||
|
_is_truthy(_get_effective("social_auth_generic_oauth2_enabled"))
|
||||||
|
and _get_effective("social_auth_generic_oauth2_client_id")
|
||||||
|
and _get_effective("social_auth_generic_oauth2_client_secret")
|
||||||
|
and _get_effective("social_auth_generic_oauth2_authorize_url")
|
||||||
|
and _get_effective("social_auth_generic_oauth2_token_url")
|
||||||
|
)
|
||||||
services.append(
|
services.append(
|
||||||
{
|
{
|
||||||
"key": "generic_oauth2",
|
"key": "generic_oauth2",
|
||||||
"name": "Generic OAuth2",
|
"name": "Generic OAuth2",
|
||||||
"icon": "fas fa-sign-in-alt",
|
"icon": "fas fa-sign-in-alt",
|
||||||
"type": "SSO",
|
"type": "SSO",
|
||||||
"linked": "generic_oauth2" in SOCIAL_PROVIDERS,
|
"linked": _generic_oauth2_linked,
|
||||||
"description": "SSO",
|
"description": "SSO",
|
||||||
"settings_keys": [
|
"settings_keys": [
|
||||||
"social_auth_generic_oauth2_enabled",
|
"social_auth_generic_oauth2_enabled",
|
||||||
@@ -474,7 +519,7 @@ async def connections_page(request: Request, db: Session = Depends(get_db)):
|
|||||||
"services": services,
|
"services": services,
|
||||||
"service_settings": service_settings,
|
"service_settings": service_settings,
|
||||||
"sso_auto_login": sso_auto_login,
|
"sso_auto_login": sso_auto_login,
|
||||||
"oauth_configured": OAUTH_CONFIGURED,
|
"oauth_configured": _oidc_linked,
|
||||||
"qr_login_enabled": qr_login_enabled,
|
"qr_login_enabled": qr_login_enabled,
|
||||||
"frontend_url_configured": frontend_url_configured,
|
"frontend_url_configured": frontend_url_configured,
|
||||||
"app_version": settings.version,
|
"app_version": settings.version,
|
||||||
|
|||||||
+2
-1
@@ -23,6 +23,7 @@ templates = Jinja2Templates(directory=str(_templates_dir))
|
|||||||
async def shared_link_view(request: Request, token: str):
|
async def shared_link_view(request: Request, token: str):
|
||||||
"""Render the public share landing page for a given token."""
|
"""Render the public share landing page for a given token."""
|
||||||
return templates.TemplateResponse(
|
return templates.TemplateResponse(
|
||||||
|
request,
|
||||||
"shared_link_view.html",
|
"shared_link_view.html",
|
||||||
{"request": request, "token": token},
|
context={"token": token},
|
||||||
)
|
)
|
||||||
|
|||||||
+4
-25
@@ -3,7 +3,7 @@ services:
|
|||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
# No container_name — allows `docker compose up --scale api=N`
|
container_name: document_api
|
||||||
restart: always
|
restart: always
|
||||||
|
|
||||||
# We'll keep the code in /app, but set working_dir to the shared data directory
|
# We'll keep the code in /app, but set working_dir to the shared data directory
|
||||||
@@ -24,7 +24,7 @@ services:
|
|||||||
|
|
||||||
depends_on:
|
depends_on:
|
||||||
- redis
|
- redis
|
||||||
- beat
|
- worker
|
||||||
|
|
||||||
# Mount the shared working directory for data
|
# Mount the shared working directory for data
|
||||||
volumes:
|
volumes:
|
||||||
@@ -34,14 +34,13 @@ services:
|
|||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
# No container_name — allows `docker compose up --scale worker=N`
|
container_name: document_worker
|
||||||
restart: always
|
restart: always
|
||||||
|
|
||||||
# same shared working directory
|
# same shared working directory
|
||||||
working_dir: /workdir
|
working_dir: /workdir
|
||||||
|
|
||||||
# Workers process tasks only — no -B flag (Beat runs in the dedicated beat service)
|
command: ["celery", "-A", "app.celery_worker", "worker", "-B", "--loglevel=info", "-Q", "document_processor,default,celery"]
|
||||||
command: ["celery", "-A", "app.celery_worker", "worker", "--loglevel=info", "-Q", "document_processor,default,celery"]
|
|
||||||
env_file:
|
env_file:
|
||||||
- .env
|
- .env
|
||||||
environment:
|
environment:
|
||||||
@@ -55,26 +54,6 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- /var/docparse/workdir:/workdir
|
- /var/docparse/workdir:/workdir
|
||||||
|
|
||||||
# Dedicated Celery Beat scheduler — exactly one instance must run at all times.
|
|
||||||
# Beat publishes periodic tasks to the Redis broker; workers pick them up.
|
|
||||||
# Do NOT scale this service (replicas must stay at 1).
|
|
||||||
beat:
|
|
||||||
build:
|
|
||||||
context: .
|
|
||||||
dockerfile: Dockerfile
|
|
||||||
container_name: document_beat
|
|
||||||
restart: always
|
|
||||||
working_dir: /workdir
|
|
||||||
command: ["celery", "-A", "app.celery_worker", "beat", "--loglevel=info"]
|
|
||||||
env_file:
|
|
||||||
- .env
|
|
||||||
environment:
|
|
||||||
- PYTHONPATH=/app
|
|
||||||
depends_on:
|
|
||||||
- redis
|
|
||||||
volumes:
|
|
||||||
- /var/docparse/workdir:/workdir
|
|
||||||
|
|
||||||
gotenberg:
|
gotenberg:
|
||||||
image: gotenberg/gotenberg:latest
|
image: gotenberg/gotenberg:latest
|
||||||
container_name: gotenberg
|
container_name: gotenberg
|
||||||
|
|||||||
@@ -792,7 +792,7 @@ SECURITY_HEADER_CSP_VALUE="default-src 'self'; script-src 'self'; style-src 'sel
|
|||||||
SECURITY_HEADER_CSP_VALUE="default-src 'self'; script-src 'self' https://cdn.example.com; style-src 'self' 'unsafe-inline';"
|
SECURITY_HEADER_CSP_VALUE="default-src 'self'; script-src 'self' https://cdn.example.com; style-src 'self' 'unsafe-inline';"
|
||||||
```
|
```
|
||||||
|
|
||||||
**Note:** The default policy includes `'unsafe-inline'` for compatibility with Tailwind CSS and inline JavaScript. For stricter security, use nonces or hashes.
|
**Note:** The default policy includes `'unsafe-inline'` for compatibility with inline JavaScript. Tailwind CSS v3 is compiled at build time into a static file served from `'self'`, so no external style CDN is needed.
|
||||||
|
|
||||||
#### X-Frame-Options
|
#### X-Frame-Options
|
||||||
|
|
||||||
|
|||||||
@@ -157,7 +157,7 @@ Recommended headers to configure at the proxy level:
|
|||||||
|
|
||||||
#### Content-Security-Policy Notes
|
#### Content-Security-Policy Notes
|
||||||
|
|
||||||
DocuElevate's frontend uses Tailwind CSS loaded from CDN in development mode. In production, ensure your CSP allows loading scripts and styles from your configured static file origin. A starting point:
|
DocuElevate's frontend uses Tailwind CSS v3 compiled at Docker build time. No external CDN requests are needed for CSS. In production, your CSP does not need to allow any external style sources beyond your own static file origin. A starting point:
|
||||||
|
|
||||||
```
|
```
|
||||||
Content-Security-Policy: default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:;
|
Content-Security-Policy: default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:;
|
||||||
|
|||||||
@@ -0,0 +1,329 @@
|
|||||||
|
/* frontend/input.css
|
||||||
|
* Tailwind CSS v3 source file.
|
||||||
|
* Edit this file (not static/styles.css) — the compiled output is
|
||||||
|
* generated by running: npm run build (inside the frontend/ directory)
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* ── Tailwind layers ──────────────────────────────────────────────────────── */
|
||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
/* ── Custom utilities ─────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
/* =============================================================
|
||||||
|
ACCESSIBILITY
|
||||||
|
Skip-to-content link, focus indicators, and screen-reader-only
|
||||||
|
utility class following WCAG 2.1 Level AA requirements.
|
||||||
|
============================================================= */
|
||||||
|
|
||||||
|
/* Skip-to-content link: visible only on keyboard focus */
|
||||||
|
.skip-link {
|
||||||
|
position: absolute;
|
||||||
|
left: -9999px;
|
||||||
|
top: auto;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
overflow: hidden;
|
||||||
|
z-index: 9999;
|
||||||
|
padding: 0.75rem 1.5rem;
|
||||||
|
background-color: #1d4ed8;
|
||||||
|
color: #ffffff;
|
||||||
|
font-weight: 600;
|
||||||
|
text-decoration: none;
|
||||||
|
border-radius: 0 0 0.375rem 0;
|
||||||
|
}
|
||||||
|
.skip-link:focus {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: auto;
|
||||||
|
height: auto;
|
||||||
|
outline: 2px solid #2563eb;
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Enhanced focus-visible indicators for keyboard navigation (WCAG 2.4.7) */
|
||||||
|
a:focus-visible,
|
||||||
|
button:focus-visible,
|
||||||
|
input:focus-visible,
|
||||||
|
select:focus-visible,
|
||||||
|
textarea:focus-visible,
|
||||||
|
[tabindex]:focus-visible {
|
||||||
|
outline: 2px solid #2563eb;
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Screen-reader-only utility (visually hidden, accessible to AT) */
|
||||||
|
.sr-only {
|
||||||
|
position: absolute;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
padding: 0;
|
||||||
|
margin: -1px;
|
||||||
|
overflow: hidden;
|
||||||
|
clip: rect(0, 0, 0, 0);
|
||||||
|
white-space: nowrap;
|
||||||
|
border-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
/* Your global overrides can go here if needed */
|
||||||
|
}
|
||||||
|
.material-symbols-light--folder-managed-outline {
|
||||||
|
display: inline-block;
|
||||||
|
width: 96px;
|
||||||
|
height: 96px;
|
||||||
|
--svg: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%23000' d='m17.212 20.404l-.108-.885q-.57-.125-.938-.33q-.368-.204-.7-.577l-.835.334l-.539-.815l.689-.577q-.165-.531-.165-1.035t.165-1.034l-.689-.577l.539-.816l.835.335q.332-.393.7-.588q.369-.195.938-.32l.108-.885h1l.107.885q.57.125.938.32t.7.588l.835-.335l.539.816l-.689.576q.166.531.166 1.035t-.166 1.035l.689.577l-.539.815l-.834-.335q-.333.373-.701.578q-.369.205-.938.33l-.107.885zm.5-1.731q.882 0 1.518-.635q.636-.636.636-1.519t-.636-1.518t-1.518-.636t-1.519.636t-.635 1.518t.635 1.519t1.518.635M4 18V6v4.435V10zm.616 1q-.691 0-1.153-.462T3 17.384V6.616q0-.691.463-1.153T4.615 5h4.981l2 2h7.789q.69 0 1.153.463T21 8.616v2.294q-.238-.152-.479-.265q-.24-.112-.521-.21v-1.82q0-.269-.173-.442T19.385 8h-8.19l-2-2h-4.58q-.269 0-.442.173T4 6.616v10.769q0 .269.173.442t.443.173h6.748q.055.275.131.515t.186.485z'/%3E%3C/svg%3E");
|
||||||
|
background-color: currentColor;
|
||||||
|
-webkit-mask-image: var(--svg);
|
||||||
|
mask-image: var(--svg);
|
||||||
|
-webkit-mask-repeat: no-repeat;
|
||||||
|
mask-repeat: no-repeat;
|
||||||
|
-webkit-mask-size: 100% 100%;
|
||||||
|
mask-size: 100% 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ensure pagination wraps properly on small screens */
|
||||||
|
.pagination {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
.pagination-buttons {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ensure filter items stack on very small screens */
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.filter-group {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.filter-item {
|
||||||
|
min-width: unset;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* =============================================================
|
||||||
|
DARK MODE
|
||||||
|
Activated by "dark" class on <html> element.
|
||||||
|
Toggled by the navbar button; preference stored in localStorage.
|
||||||
|
Falls back to the server-side ui_default_color_scheme setting,
|
||||||
|
then to the OS prefers-color-scheme media query.
|
||||||
|
WCAG AA contrast ratios verified for all text/background pairs.
|
||||||
|
============================================================= */
|
||||||
|
|
||||||
|
/* Tell the browser we support both colour schemes */
|
||||||
|
html { color-scheme: light; }
|
||||||
|
html.dark { color-scheme: dark; }
|
||||||
|
|
||||||
|
/* ---- Base / Body ---- */
|
||||||
|
html.dark body { background-color: #111827; color: #e5e7eb; }
|
||||||
|
html.dark .bg-gray-50 { background-color: #111827; }
|
||||||
|
html.dark .bg-white { background-color: #1f2937; }
|
||||||
|
html.dark .bg-gray-100 { background-color: #374151; }
|
||||||
|
html.dark .bg-gray-200 { background-color: #4b5563; }
|
||||||
|
|
||||||
|
/* ---- Text colours ---- */
|
||||||
|
html.dark .text-gray-900 { color: #f9fafb; }
|
||||||
|
html.dark .text-gray-800 { color: #f3f4f6; }
|
||||||
|
html.dark .text-gray-700 { color: #e5e7eb; }
|
||||||
|
html.dark .text-gray-600 { color: #d1d5db; }
|
||||||
|
html.dark .text-gray-500 { color: #9ca3af; }
|
||||||
|
html.dark .text-gray-400 { color: #9ca3af; }
|
||||||
|
html.dark .text-black { color: #f9fafb; }
|
||||||
|
|
||||||
|
/* ---- Borders ---- */
|
||||||
|
html.dark .border-gray-100 { border-color: #374151; }
|
||||||
|
html.dark .border-gray-200 { border-color: #374151; }
|
||||||
|
html.dark .border-gray-300 { border-color: #4b5563; }
|
||||||
|
html.dark .border-gray-400 { border-color: #6b7280; }
|
||||||
|
html.dark .divide-gray-200 > :not([hidden]) ~ :not([hidden]) { border-color: #374151; }
|
||||||
|
html.dark .divide-gray-100 > :not([hidden]) ~ :not([hidden]) { border-color: #374151; }
|
||||||
|
html.dark .divide-y > :not([hidden]) ~ :not([hidden]) { border-color: #374151; }
|
||||||
|
|
||||||
|
/* ---- Hover states ---- */
|
||||||
|
html.dark .hover\:bg-gray-50:hover { background-color: #374151; }
|
||||||
|
html.dark .hover\:bg-gray-100:hover { background-color: #4b5563; }
|
||||||
|
html.dark .hover\:text-gray-900:hover { color: #f9fafb; }
|
||||||
|
html.dark .hover\:text-gray-700:hover { color: #e5e7eb; }
|
||||||
|
|
||||||
|
/* ---- Shadows (softened for dark mode) ---- */
|
||||||
|
html.dark .shadow,
|
||||||
|
html.dark .shadow-md,
|
||||||
|
html.dark .shadow-sm,
|
||||||
|
html.dark .shadow-lg {
|
||||||
|
box-shadow: 0 1px 3px 0 rgba(0,0,0,0.6), 0 1px 2px 0 rgba(0,0,0,0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Alert / info-banner backgrounds ---- */
|
||||||
|
html.dark .bg-blue-50 { background-color: #1e3a5f; }
|
||||||
|
html.dark .bg-green-50 { background-color: #052e16; }
|
||||||
|
html.dark .bg-red-50 { background-color: #450a0a; }
|
||||||
|
html.dark .bg-yellow-50 { background-color: #451a03; }
|
||||||
|
html.dark .bg-indigo-50 { background-color: #1e1b4b; }
|
||||||
|
html.dark .bg-orange-50 { background-color: #431407; }
|
||||||
|
|
||||||
|
/* ---- Badge / pill backgrounds ---- */
|
||||||
|
html.dark .bg-blue-100 { background-color: #1e3a5f; }
|
||||||
|
html.dark .bg-green-100 { background-color: #052e16; }
|
||||||
|
html.dark .bg-red-100 { background-color: #450a0a; }
|
||||||
|
html.dark .bg-yellow-100 { background-color: #451a03; }
|
||||||
|
html.dark .bg-indigo-100 { background-color: #431407; }
|
||||||
|
html.dark .bg-orange-100 { background-color: #431407; }
|
||||||
|
html.dark .bg-purple-100 { background-color: #2e1065; }
|
||||||
|
|
||||||
|
/* ---- Status / badge text colours ---- */
|
||||||
|
html.dark .text-blue-700 { color: #93c5fd; }
|
||||||
|
html.dark .text-blue-800 { color: #bfdbfe; }
|
||||||
|
html.dark .text-green-700 { color: #86efac; }
|
||||||
|
html.dark .text-green-800 { color: #bbf7d0; }
|
||||||
|
html.dark .text-red-700 { color: #fca5a5; }
|
||||||
|
html.dark .text-red-800 { color: #fecaca; }
|
||||||
|
html.dark .text-yellow-700 { color: #fcd34d; }
|
||||||
|
html.dark .text-yellow-800 { color: #fde68a; }
|
||||||
|
html.dark .text-indigo-700 { color: #a5b4fc; }
|
||||||
|
html.dark .text-indigo-800 { color: #c7d2fe; }
|
||||||
|
html.dark .text-orange-700 { color: #fdba74; }
|
||||||
|
html.dark .text-orange-800 { color: #fed7aa; }
|
||||||
|
html.dark .text-purple-700 { color: #d8b4fe; }
|
||||||
|
html.dark .text-purple-800 { color: #e9d5ff; }
|
||||||
|
|
||||||
|
/* ---- Dropdown / popup menus ---- */
|
||||||
|
html.dark .bg-white.rounded-md.shadow-lg { background-color: #1f2937; }
|
||||||
|
html.dark .ring-black { --tw-ring-color: rgba(0,0,0,0.5); }
|
||||||
|
|
||||||
|
/* ---- Form inputs / selects / textareas ---- */
|
||||||
|
html.dark input:not([type="checkbox"]):not([type="radio"]):not([type="range"]),
|
||||||
|
html.dark select,
|
||||||
|
html.dark textarea {
|
||||||
|
background-color: #374151;
|
||||||
|
border-color: #4b5563;
|
||||||
|
color: #e5e7eb;
|
||||||
|
}
|
||||||
|
html.dark input::placeholder,
|
||||||
|
html.dark textarea::placeholder {
|
||||||
|
color: #9ca3af;
|
||||||
|
}
|
||||||
|
html.dark input:focus:not([type="checkbox"]):not([type="radio"]):not([type="range"]),
|
||||||
|
html.dark select:focus,
|
||||||
|
html.dark textarea:focus {
|
||||||
|
border-color: #60a5fa;
|
||||||
|
outline-color: #60a5fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Table rows ---- */
|
||||||
|
html.dark thead,
|
||||||
|
html.dark .bg-gray-50 thead { background-color: #1f2937; }
|
||||||
|
html.dark thead th { color: #9ca3af; }
|
||||||
|
html.dark tbody tr:hover { background-color: #374151; }
|
||||||
|
|
||||||
|
/* ---- Code / pre ---- */
|
||||||
|
html.dark pre,
|
||||||
|
html.dark code { background-color: #111827; color: #d1d5db; }
|
||||||
|
|
||||||
|
/* ---- Dark-mode toggle button icon colour ---- */
|
||||||
|
html.dark #darkModeToggle { color: #fbbf24; }
|
||||||
|
html.dark #darkModeToggle:hover { background-color: #374151; }
|
||||||
|
|
||||||
|
/* ---- Dark-mode skip-link ---- */
|
||||||
|
html.dark .skip-link { background-color: #2563eb; }
|
||||||
|
html.dark .skip-link:focus { outline-color: #60a5fa; }
|
||||||
|
|
||||||
|
/* ---- Dark-mode focus-visible indicators ---- */
|
||||||
|
html.dark a:focus-visible,
|
||||||
|
html.dark button:focus-visible,
|
||||||
|
html.dark input:focus-visible,
|
||||||
|
html.dark select:focus-visible,
|
||||||
|
html.dark textarea:focus-visible,
|
||||||
|
html.dark [tabindex]:focus-visible {
|
||||||
|
outline-color: #60a5fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Scrollbar (WebKit browsers) ---- */
|
||||||
|
html.dark ::-webkit-scrollbar { width: 8px; height: 8px; }
|
||||||
|
html.dark ::-webkit-scrollbar-track { background: #1f2937; }
|
||||||
|
html.dark ::-webkit-scrollbar-thumb { background: #4b5563; border-radius: 4px; }
|
||||||
|
html.dark ::-webkit-scrollbar-thumb:hover { background: #6b7280; }
|
||||||
|
|
||||||
|
/* ---- Settings page: sidebar active state (dark) ---- */
|
||||||
|
html.dark .bg-blue-50 { background-color: #1e3a5f; }
|
||||||
|
|
||||||
|
/* =============================================================
|
||||||
|
DOC-TOGGLE – cross-browser toggle switch
|
||||||
|
Implemented with custom CSS pseudo-elements so the appearance
|
||||||
|
is consistent across all browsers regardless of Tailwind version.
|
||||||
|
Usage:
|
||||||
|
<label class="doc-toggle">
|
||||||
|
<input type="checkbox" class="sr-only" onchange="...">
|
||||||
|
<span class="doc-toggle-track" aria-hidden="true"></span>
|
||||||
|
<span class="ml-3 ...">Label text</span>
|
||||||
|
</label>
|
||||||
|
============================================================= */
|
||||||
|
|
||||||
|
.doc-toggle {
|
||||||
|
position: relative;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.doc-toggle-track {
|
||||||
|
position: relative;
|
||||||
|
display: inline-block;
|
||||||
|
width: 44px;
|
||||||
|
min-width: 44px;
|
||||||
|
height: 24px;
|
||||||
|
background-color: #e5e7eb; /* gray-200 */
|
||||||
|
border-radius: 9999px;
|
||||||
|
transition: background-color 0.2s ease-in-out;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.doc-toggle-track::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 2px;
|
||||||
|
left: 2px;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
background-color: #ffffff;
|
||||||
|
border: 1px solid #d1d5db; /* gray-300 */
|
||||||
|
border-radius: 9999px;
|
||||||
|
transition: transform 0.2s ease-in-out, border-color 0.2s ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.doc-toggle input[type="checkbox"]:checked + .doc-toggle-track {
|
||||||
|
background-color: #4f46e5; /* indigo-600 */
|
||||||
|
}
|
||||||
|
|
||||||
|
.doc-toggle input[type="checkbox"]:checked + .doc-toggle-track::after {
|
||||||
|
transform: translateX(20px);
|
||||||
|
border-color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.doc-toggle input[type="checkbox"]:focus-visible + .doc-toggle-track {
|
||||||
|
box-shadow: 0 0 0 2px #ffffff, 0 0 0 4px #6366f1; /* ring-2 ring-indigo-500 with offset */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dark mode overrides */
|
||||||
|
html.dark .doc-toggle-track {
|
||||||
|
background-color: #374151; /* gray-700 */
|
||||||
|
}
|
||||||
|
|
||||||
|
html.dark .doc-toggle-track::after {
|
||||||
|
background-color: #ffffff;
|
||||||
|
border-color: #4b5563; /* gray-600 */
|
||||||
|
}
|
||||||
|
|
||||||
|
html.dark .doc-toggle input[type="checkbox"]:checked + .doc-toggle-track {
|
||||||
|
background-color: #4f46e5; /* indigo-600 */
|
||||||
|
}
|
||||||
|
|
||||||
|
html.dark .doc-toggle input[type="checkbox"]:checked + .doc-toggle-track::after {
|
||||||
|
border-color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
html.dark .doc-toggle input[type="checkbox"]:focus-visible + .doc-toggle-track {
|
||||||
|
box-shadow: 0 0 0 2px #111827, 0 0 0 4px #6366f1; /* dark background offset */
|
||||||
|
}
|
||||||
Generated
+1017
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"name": "docuelevate-frontend",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Frontend asset compilation for DocuElevate",
|
||||||
|
"scripts": {
|
||||||
|
"build": "tailwindcss -i input.css -o static/styles.css --minify",
|
||||||
|
"watch": "tailwindcss -i input.css -o static/styles.css --watch"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"tailwindcss": "^3.4.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
+2
-317
File diff suppressed because one or more lines are too long
@@ -0,0 +1,12 @@
|
|||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
module.exports = {
|
||||||
|
content: [
|
||||||
|
'./templates/**/*.html',
|
||||||
|
'./static/js/**/*.js',
|
||||||
|
],
|
||||||
|
darkMode: 'class',
|
||||||
|
theme: {
|
||||||
|
extend: {},
|
||||||
|
},
|
||||||
|
plugins: [],
|
||||||
|
}
|
||||||
@@ -21,8 +21,6 @@
|
|||||||
<!-- Alpine.js moved to head for earlier loading -->
|
<!-- Alpine.js moved to head for earlier loading -->
|
||||||
<script src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js" defer></script>
|
<script src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js" defer></script>
|
||||||
{% block head_css %}
|
{% block head_css %}
|
||||||
<!-- Tailwind CSS and other CSS -->
|
|
||||||
<link href="https://cdn.jsdelivr.net/npm/tailwindcss@2.2.19/dist/tailwind.min.css" rel="stylesheet">
|
|
||||||
<!-- Font Awesome -->
|
<!-- Font Awesome -->
|
||||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css"
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css"
|
||||||
integrity="sha512-DTOQO9RWCH3ppGqcWaEA1BIZOC6xxalwEsw9c2QQeAIftl+Vegovlnee1c9QX4TctnWMn13TZye+giMm8e2LwA=="
|
integrity="sha512-DTOQO9RWCH3ppGqcWaEA1BIZOC6xxalwEsw9c2QQeAIftl+Vegovlnee1c9QX4TctnWMn13TZye+giMm8e2LwA=="
|
||||||
|
|||||||
@@ -1056,7 +1056,7 @@
|
|||||||
|
|
||||||
html += `
|
html += `
|
||||||
<div role="listitem">
|
<div role="listitem">
|
||||||
<a href="/files/${doc.file_id}" aria-label="${title} — ${scorePercent}% similarity (${scoreLabel})" style="text-decoration: none; color: inherit; display: block;">
|
<a href="/files/${doc.file_id}/detail" aria-label="${title} — ${scorePercent}% similarity (${scoreLabel})" style="text-decoration: none; color: inherit; display: block;">
|
||||||
<div style="display: flex; align-items: center; gap: 1rem; padding: 0.75rem 1rem; background-color: #f7fafc; border-radius: 0.5rem; border: 1px solid #e2e8f0; transition: border-color 0.2s; cursor: pointer;" onmouseover="this.style.borderColor='#4299e1'" onmouseout="this.style.borderColor='#e2e8f0'">
|
<div style="display: flex; align-items: center; gap: 1rem; padding: 0.75rem 1rem; background-color: #f7fafc; border-radius: 0.5rem; border: 1px solid #e2e8f0; transition: border-color 0.2s; cursor: pointer;" onmouseover="this.style.borderColor='#4299e1'" onmouseout="this.style.borderColor='#e2e8f0'">
|
||||||
<div style="flex-shrink: 0; width: 48px; height: 48px; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-weight: 700; font-size: 0.875rem; color: white; background-color: ${scorePercent >= 80 ? '#48bb78' : scorePercent >= 50 ? '#ecc94b' : '#718096'};" aria-hidden="true">
|
<div style="flex-shrink: 0; width: 48px; height: 48px; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-weight: 700; font-size: 0.875rem; color: white; background-color: ${scorePercent >= 80 ? '#48bb78' : scorePercent >= 50 ? '#ecc94b' : '#718096'};" aria-hidden="true">
|
||||||
${scorePercent}%
|
${scorePercent}%
|
||||||
@@ -1091,20 +1091,19 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="detail-container">
|
<div class="detail-container">
|
||||||
<div style="display:flex;align-items:center;gap:1rem;margin-bottom:1.5rem;flex-wrap:wrap;">
|
<div style="display:flex;align-items:center;gap:1rem;margin-bottom:1.5rem;flex-wrap:wrap;">
|
||||||
<a href="/files/{{ file.id }}" class="back-button" style="margin-bottom:0;" aria-label="Back to File Summary">
|
<a href="/files" class="back-button" style="margin-bottom:0;" aria-label="Back to File List">
|
||||||
<i class="fas fa-arrow-left" aria-hidden="true"></i>
|
<i class="fas fa-arrow-left" aria-hidden="true"></i>
|
||||||
Back to File Summary
|
Back to File List
|
||||||
</a>
|
</a>
|
||||||
{% if file %}
|
{% if file %}
|
||||||
<a href="/files/{{ file.id }}/detail" class="back-button" style="margin-bottom:0;" aria-label="View document detail for {{ file.original_filename }}">
|
<a href="/files/{{ file.id }}" class="back-button" style="margin-bottom:0;" aria-label="View document for {{ file.original_filename }}">
|
||||||
<i class="fas fa-eye" aria-hidden="true"></i>
|
<i class="fas fa-eye" aria-hidden="true"></i>
|
||||||
Document Detail
|
View Document
|
||||||
</a>
|
</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1538,6 +1538,28 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function escapeHtml(str) {
|
||||||
|
if (!str) return '';
|
||||||
|
return String(str)
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''');
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizeHighlight(html) {
|
||||||
|
if (!html) return '';
|
||||||
|
let safe = String(html)
|
||||||
|
.replace(/<mark>/gi, '\x00MARK_OPEN\x00')
|
||||||
|
.replace(/<\/mark>/gi, '\x00MARK_CLOSE\x00');
|
||||||
|
safe = escapeHtml(safe);
|
||||||
|
safe = safe
|
||||||
|
.replace(/\x00MARK_OPEN\x00/g, '<mark>')
|
||||||
|
.replace(/\x00MARK_CLOSE\x00/g, '</mark>');
|
||||||
|
return safe;
|
||||||
|
}
|
||||||
|
|
||||||
function renderSearchResults(data, q) {
|
function renderSearchResults(data, q) {
|
||||||
const panel = document.getElementById('search-results-panel');
|
const panel = document.getElementById('search-results-panel');
|
||||||
const list = document.getElementById('search-results-list');
|
const list = document.getElementById('search-results-list');
|
||||||
@@ -1555,25 +1577,31 @@
|
|||||||
|
|
||||||
list.innerHTML = results.map(hit => {
|
list.innerHTML = results.map(hit => {
|
||||||
const fmt = hit._formatted || {};
|
const fmt = hit._formatted || {};
|
||||||
const title = fmt.document_title || hit.document_title || hit.original_filename || __i18n.untitled;
|
const titleRaw = fmt.document_title || hit.document_title || hit.original_filename || __i18n.untitled;
|
||||||
const filename = fmt.original_filename || hit.original_filename || '';
|
const filenameRaw = fmt.original_filename || hit.original_filename || '';
|
||||||
const snippet = fmt.ocr_text || '';
|
const snippetRaw = fmt.ocr_text || '';
|
||||||
const tags = Array.isArray(hit.tags) ? hit.tags.join(', ') : (hit.tags || '');
|
const tagsRaw = Array.isArray(hit.tags) ? hit.tags.join(', ') : (hit.tags || '');
|
||||||
const docType = hit.document_type || '';
|
const docTypeRaw = hit.document_type || '';
|
||||||
|
|
||||||
|
const safeTitle = fmt.document_title ? sanitizeHighlight(titleRaw) : escapeHtml(titleRaw);
|
||||||
|
const safeFilename = escapeHtml(filenameRaw);
|
||||||
|
const safeSnippet = sanitizeHighlight(snippetRaw);
|
||||||
|
const safeTags = escapeHtml(tagsRaw);
|
||||||
|
const safeDocType = escapeHtml(docTypeRaw);
|
||||||
|
|
||||||
return `<div style="padding: 0.75rem 1rem; border-bottom: 1px solid #f3f4f6; display: flex; gap: 0.75rem; align-items: flex-start;">
|
return `<div style="padding: 0.75rem 1rem; border-bottom: 1px solid #f3f4f6; display: flex; gap: 0.75rem; align-items: flex-start;">
|
||||||
<div style="flex-shrink: 0; color: #3b82f6; font-size: 1.25rem; padding-top: 0.1rem;">
|
<div style="flex-shrink: 0; color: #3b82f6; font-size: 1.25rem; padding-top: 0.1rem;">
|
||||||
<i class="fas fa-file-pdf"></i>
|
<i class="fas fa-file-pdf"></i>
|
||||||
</div>
|
</div>
|
||||||
<div style="flex: 1; min-width: 0;">
|
<div style="flex: 1; min-width: 0;">
|
||||||
<div style="font-weight: 600; font-size: 0.9rem; color: #111827;">${title}</div>
|
<div style="font-weight: 600; font-size: 0.9rem; color: #111827;">${safeTitle}</div>
|
||||||
${filename ? `<div style="font-size: 0.8rem; color: #6b7280; margin-top: 0.15rem;">${filename}</div>` : ''}
|
${safeFilename ? `<div style="font-size: 0.8rem; color: #6b7280; margin-top: 0.15rem;">${safeFilename}</div>` : ''}
|
||||||
${docType ? `<span style="display: inline-block; margin-top: 0.25rem; padding: 0.1rem 0.5rem; background: #eff6ff; color: #1d4ed8; border-radius: 9999px; font-size: 0.75rem;">${docType}</span>` : ''}
|
${safeDocType ? `<span style="display: inline-block; margin-top: 0.25rem; padding: 0.1rem 0.5rem; background: #eff6ff; color: #1d4ed8; border-radius: 9999px; font-size: 0.75rem;">${safeDocType}</span>` : ''}
|
||||||
${tags ? `<span style="display: inline-block; margin-top: 0.25rem; margin-left: 0.25rem; padding: 0.1rem 0.5rem; background: #f0fdf4; color: #15803d; border-radius: 9999px; font-size: 0.75rem;">${tags}</span>` : ''}
|
${safeTags ? `<span style="display: inline-block; margin-top: 0.25rem; margin-left: 0.25rem; padding: 0.1rem 0.5rem; background: #f0fdf4; color: #15803d; border-radius: 9999px; font-size: 0.75rem;">${safeTags}</span>` : ''}
|
||||||
${snippet ? `<div style="margin-top: 0.4rem; font-size: 0.8rem; color: #374151; white-space: pre-wrap; word-break: break-word;">…${snippet}…</div>` : ''}
|
${safeSnippet ? `<div style="margin-top: 0.4rem; font-size: 0.8rem; color: #374151; white-space: pre-wrap; word-break: break-word;">…${safeSnippet}…</div>` : ''}
|
||||||
</div>
|
</div>
|
||||||
<div style="flex-shrink: 0;">
|
<div style="flex-shrink: 0;">
|
||||||
<a href="/files/${hit.file_id}" style="padding: 0.25rem 0.6rem; background: #f3f4f6; color: #374151; border-radius: 0.25rem; font-size: 0.8rem; text-decoration: none; white-space: nowrap;" title="${__i18n.viewFile}">
|
<a href="/files/${escapeHtml(hit.file_id)}" style="padding: 0.25rem 0.6rem; background: #f3f4f6; color: #374151; border-radius: 0.25rem; font-size: 0.8rem; text-decoration: none; white-space: nowrap;" title="${__i18n.viewFile}">
|
||||||
<i class="fas fa-external-link-alt"></i>
|
<i class="fas fa-external-link-alt"></i>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1392,7 +1392,7 @@ function integrationsDashboard() {
|
|||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
integration_type: intg.integration_type,
|
integration_type: intg.integration_type,
|
||||||
config: intg.config,
|
config: intg.config,
|
||||||
credentials: creds.credentials,
|
credentials: creds,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
const data = await resp.json();
|
const data = await resp.json();
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>DocuElevate - Create Account</title>
|
<title>DocuElevate - Create Account</title>
|
||||||
<link href="https://cdn.jsdelivr.net/npm/tailwindcss@2.2.19/dist/tailwind.min.css" rel="stylesheet">
|
<link rel="stylesheet" href="/static/styles.css" />
|
||||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css"
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css"
|
||||||
integrity="sha512-DTOQO9RWCH3ppGqcWaEA1BIZOC6xxalwEsw9c2QQeAIftl+Vegovlnee1c9QX4TctnWMn13TZye+giMm8e2LwA=="
|
integrity="sha512-DTOQO9RWCH3ppGqcWaEA1BIZOC6xxalwEsw9c2QQeAIftl+Vegovlnee1c9QX4TctnWMn13TZye+giMm8e2LwA=="
|
||||||
crossorigin="anonymous" referrerpolicy="no-referrer" />
|
crossorigin="anonymous" referrerpolicy="no-referrer" />
|
||||||
|
|||||||
@@ -1,83 +0,0 @@
|
|||||||
{{- /*
|
|
||||||
Celery Beat scheduler — publishes periodic tasks to the broker.
|
|
||||||
Exactly ONE replica must run; never scale this deployment.
|
|
||||||
*/ -}}
|
|
||||||
apiVersion: apps/v1
|
|
||||||
kind: Deployment
|
|
||||||
metadata:
|
|
||||||
name: {{ include "docuelevate.fullname" . }}-beat
|
|
||||||
namespace: {{ .Release.Namespace }}
|
|
||||||
labels:
|
|
||||||
{{- include "docuelevate.labels" . | nindent 4 }}
|
|
||||||
app.kubernetes.io/component: beat
|
|
||||||
spec:
|
|
||||||
replicas: 1
|
|
||||||
strategy:
|
|
||||||
type: Recreate # Prevent two Beat instances from running simultaneously
|
|
||||||
selector:
|
|
||||||
matchLabels:
|
|
||||||
{{- include "docuelevate.selectorLabels" . | nindent 6 }}
|
|
||||||
app.kubernetes.io/component: beat
|
|
||||||
template:
|
|
||||||
metadata:
|
|
||||||
labels:
|
|
||||||
{{- include "docuelevate.selectorLabels" . | nindent 8 }}
|
|
||||||
app.kubernetes.io/component: beat
|
|
||||||
{{- with .Values.beat.podAnnotations }}
|
|
||||||
annotations:
|
|
||||||
{{- toYaml . | nindent 8 }}
|
|
||||||
{{- end }}
|
|
||||||
spec:
|
|
||||||
serviceAccountName: {{ include "docuelevate.serviceAccountName" . }}
|
|
||||||
{{- with .Values.imagePullSecrets }}
|
|
||||||
imagePullSecrets:
|
|
||||||
{{- toYaml . | nindent 8 }}
|
|
||||||
{{- end }}
|
|
||||||
{{- with .Values.beat.podSecurityContext }}
|
|
||||||
securityContext:
|
|
||||||
{{- toYaml . | nindent 8 }}
|
|
||||||
{{- end }}
|
|
||||||
containers:
|
|
||||||
- name: beat
|
|
||||||
image: {{ include "docuelevate.image" . }}
|
|
||||||
imagePullPolicy: {{ .Values.image.pullPolicy }}
|
|
||||||
command:
|
|
||||||
- celery
|
|
||||||
- -A
|
|
||||||
- app.celery_worker
|
|
||||||
- beat
|
|
||||||
- --loglevel=info
|
|
||||||
envFrom:
|
|
||||||
- configMapRef:
|
|
||||||
name: {{ include "docuelevate.fullname" . }}-config
|
|
||||||
- secretRef:
|
|
||||||
name: {{ include "docuelevate.fullname" . }}-secret
|
|
||||||
{{- with .Values.beat.securityContext }}
|
|
||||||
securityContext:
|
|
||||||
{{- toYaml . | nindent 12 }}
|
|
||||||
{{- end }}
|
|
||||||
resources:
|
|
||||||
{{- toYaml .Values.beat.resources | nindent 12 }}
|
|
||||||
volumeMounts:
|
|
||||||
- name: workdir
|
|
||||||
mountPath: /workdir
|
|
||||||
volumes:
|
|
||||||
- name: workdir
|
|
||||||
{{- if .Values.workdir.persistence.enabled }}
|
|
||||||
persistentVolumeClaim:
|
|
||||||
claimName: {{ .Values.workdir.persistence.existingClaim | default (printf "%s-workdir" (include "docuelevate.fullname" .)) }}
|
|
||||||
{{- else }}
|
|
||||||
emptyDir: {}
|
|
||||||
{{- end }}
|
|
||||||
{{- with .Values.beat.nodeSelector }}
|
|
||||||
nodeSelector:
|
|
||||||
{{- toYaml . | nindent 8 }}
|
|
||||||
{{- end }}
|
|
||||||
{{- with .Values.beat.affinity }}
|
|
||||||
affinity:
|
|
||||||
{{- toYaml . | nindent 8 }}
|
|
||||||
{{- end }}
|
|
||||||
{{- with .Values.beat.tolerations }}
|
|
||||||
tolerations:
|
|
||||||
{{- toYaml . | nindent 8 }}
|
|
||||||
{{- end }}
|
|
||||||
@@ -42,6 +42,7 @@ spec:
|
|||||||
- -A
|
- -A
|
||||||
- app.celery_worker
|
- app.celery_worker
|
||||||
- worker
|
- worker
|
||||||
|
- -B
|
||||||
- --loglevel=info
|
- --loglevel=info
|
||||||
- -Q
|
- -Q
|
||||||
- document_processor,default,celery
|
- document_processor,default,celery
|
||||||
|
|||||||
@@ -121,10 +121,10 @@ api:
|
|||||||
type: ClusterIP
|
type: ClusterIP
|
||||||
port: 8000
|
port: 8000
|
||||||
|
|
||||||
# Liveness / readiness probes (unauthenticated endpoints for kubelet)
|
# Liveness / readiness probes
|
||||||
livenessProbe:
|
livenessProbe:
|
||||||
httpGet:
|
httpGet:
|
||||||
path: /api/diagnostic/healthz/live
|
path: /api/health
|
||||||
port: 8000
|
port: 8000
|
||||||
initialDelaySeconds: 30
|
initialDelaySeconds: 30
|
||||||
periodSeconds: 20
|
periodSeconds: 20
|
||||||
@@ -132,7 +132,7 @@ api:
|
|||||||
|
|
||||||
readinessProbe:
|
readinessProbe:
|
||||||
httpGet:
|
httpGet:
|
||||||
path: /api/diagnostic/healthz/ready
|
path: /api/health
|
||||||
port: 8000
|
port: 8000
|
||||||
initialDelaySeconds: 15
|
initialDelaySeconds: 15
|
||||||
periodSeconds: 10
|
periodSeconds: 10
|
||||||
@@ -191,36 +191,7 @@ worker:
|
|||||||
drop: ["ALL"]
|
drop: ["ALL"]
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Celery Beat scheduler (singleton — always exactly 1 replica)
|
# Shared workdir volume (api + worker mount the same PVC)
|
||||||
# Beat publishes periodic tasks; workers consume them from the broker.
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
beat:
|
|
||||||
resources:
|
|
||||||
requests:
|
|
||||||
cpu: 100m
|
|
||||||
memory: 256Mi
|
|
||||||
limits:
|
|
||||||
cpu: 500m
|
|
||||||
memory: 512Mi
|
|
||||||
|
|
||||||
podAnnotations: {}
|
|
||||||
nodeSelector: {}
|
|
||||||
tolerations: []
|
|
||||||
affinity: {}
|
|
||||||
|
|
||||||
podSecurityContext:
|
|
||||||
runAsNonRoot: true
|
|
||||||
runAsUser: 1000
|
|
||||||
fsGroup: 1000
|
|
||||||
|
|
||||||
securityContext:
|
|
||||||
allowPrivilegeEscalation: false
|
|
||||||
readOnlyRootFilesystem: false
|
|
||||||
capabilities:
|
|
||||||
drop: ["ALL"]
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Shared workdir volume (api + worker + beat mount the same PVC)
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
workdir:
|
workdir:
|
||||||
persistence:
|
persistence:
|
||||||
|
|||||||
+201
@@ -0,0 +1,201 @@
|
|||||||
|
"""
|
||||||
|
Base setup for views, containing shared functionality and imports.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Request # noqa: F401
|
||||||
|
from fastapi.templating import Jinja2Templates
|
||||||
|
from sqlalchemy.orm import Session # noqa: F401
|
||||||
|
|
||||||
|
from app.auth import require_login # noqa: F401
|
||||||
|
from app.config import settings
|
||||||
|
from app.database import SessionLocal, get_db # noqa: F401
|
||||||
|
from app.models import UserProfile
|
||||||
|
from app.utils.i18n import (
|
||||||
|
SUPPORTED_LANGUAGES,
|
||||||
|
detect_language,
|
||||||
|
format_date,
|
||||||
|
format_datetime,
|
||||||
|
format_number,
|
||||||
|
get_suggested_languages,
|
||||||
|
translate,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Set up Jinja2 templates
|
||||||
|
templates_dir = Path(__file__).parent.parent.parent / "frontend" / "templates"
|
||||||
|
templates = Jinja2Templates(directory=str(templates_dir))
|
||||||
|
|
||||||
|
# Add Python built-in functions to Jinja2 template globals
|
||||||
|
templates.env.globals["min"] = min
|
||||||
|
templates.env.globals["max"] = max
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# i18n Jinja2 integration
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# The _() function is available in every template to translate UI strings.
|
||||||
|
# Usage: {{ _("nav.dashboard") }} or {{ _("upload.max_size", size="10 MB") }}
|
||||||
|
# The locale is automatically resolved from the request context.
|
||||||
|
# A default English implementation is registered as a global so error handlers
|
||||||
|
# that don't go through _inject_global_context still have the function available.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
templates.env.globals["supported_languages"] = SUPPORTED_LANGUAGES
|
||||||
|
templates.env.globals["_"] = lambda key, **kwargs: translate(key, "en", **kwargs)
|
||||||
|
|
||||||
|
# Customize Jinja2Templates to include app_version in all templates
|
||||||
|
original_template_response = templates.TemplateResponse
|
||||||
|
|
||||||
|
|
||||||
|
def _hydrate_language_from_db(request: Request, session_user: object) -> None:
|
||||||
|
"""Load the user's preferred language from the DB into the session.
|
||||||
|
|
||||||
|
Called once per session when ``preferred_language`` is not yet in the
|
||||||
|
session. A lightweight DB query fetches the stored preference so that
|
||||||
|
:func:`detect_language` picks it up from the session on all subsequent
|
||||||
|
requests without further DB access.
|
||||||
|
"""
|
||||||
|
from app.utils.i18n import SUPPORTED_LANGUAGE_CODES
|
||||||
|
|
||||||
|
user_id: str | None = None
|
||||||
|
if isinstance(session_user, dict):
|
||||||
|
user_id = (
|
||||||
|
session_user.get("sub")
|
||||||
|
or session_user.get("preferred_username")
|
||||||
|
or session_user.get("email")
|
||||||
|
or session_user.get("id")
|
||||||
|
)
|
||||||
|
elif isinstance(session_user, str):
|
||||||
|
user_id = session_user
|
||||||
|
|
||||||
|
if not user_id:
|
||||||
|
return
|
||||||
|
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
profile = db.query(UserProfile).filter(UserProfile.user_id == user_id).first()
|
||||||
|
if profile and profile.preferred_language and profile.preferred_language in SUPPORTED_LANGUAGE_CODES:
|
||||||
|
request.session["preferred_language"] = profile.preferred_language
|
||||||
|
except Exception: # noqa: BLE001 — intentionally broad; DB may be temporarily unavailable
|
||||||
|
logger.debug("Could not hydrate language preference for user_id=%s", user_id)
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
def _inject_global_context(ctx: dict) -> None:
|
||||||
|
"""Inject shared global variables into every template context dict."""
|
||||||
|
ctx.setdefault("version", settings.version)
|
||||||
|
ctx.setdefault("release_name", getattr(settings, "release_name", None))
|
||||||
|
ctx.setdefault("ui_default_color_scheme", getattr(settings, "ui_default_color_scheme", "system"))
|
||||||
|
ctx.setdefault("multi_user_enabled", getattr(settings, "multi_user_enabled", False))
|
||||||
|
ctx.setdefault("auth_enabled", getattr(settings, "auth_enabled", True))
|
||||||
|
ctx.setdefault(
|
||||||
|
"allow_signup",
|
||||||
|
getattr(settings, "multi_user_enabled", False) and getattr(settings, "allow_local_signup", False),
|
||||||
|
)
|
||||||
|
ctx.setdefault("enable_factory_reset", getattr(settings, "enable_factory_reset", False))
|
||||||
|
|
||||||
|
# Sentry Browser SDK config (injected into every page so the JS SDK can initialise)
|
||||||
|
# Normalize empty-string DSN to None so the {% if sentry_dsn %} template guard works correctly.
|
||||||
|
_raw_dsn = getattr(settings, "sentry_dsn", None)
|
||||||
|
ctx.setdefault("sentry_dsn", _raw_dsn if _raw_dsn else None)
|
||||||
|
ctx.setdefault("sentry_environment", getattr(settings, "sentry_environment", "production"))
|
||||||
|
ctx.setdefault("sentry_js_traces_sample_rate", getattr(settings, "sentry_js_traces_sample_rate", 0.0))
|
||||||
|
ctx.setdefault(
|
||||||
|
"sentry_js_replay_session_sample_rate",
|
||||||
|
getattr(settings, "sentry_js_replay_session_sample_rate", 0.0),
|
||||||
|
)
|
||||||
|
ctx.setdefault(
|
||||||
|
"sentry_js_replay_on_error_sample_rate",
|
||||||
|
getattr(settings, "sentry_js_replay_on_error_sample_rate", 0.1),
|
||||||
|
)
|
||||||
|
|
||||||
|
req = ctx.get("request")
|
||||||
|
if req is not None:
|
||||||
|
# CSRF token
|
||||||
|
if hasattr(req, "state") and hasattr(req.state, "csrf_token"):
|
||||||
|
ctx.setdefault("csrf_token", req.state.csrf_token)
|
||||||
|
# Determine whether the current visitor is authenticated
|
||||||
|
session_user = None
|
||||||
|
if hasattr(req, "session"):
|
||||||
|
session_user = req.session.get("user")
|
||||||
|
# When auth is disabled every visitor is effectively "logged in"
|
||||||
|
ctx.setdefault("is_logged_in", not getattr(settings, "auth_enabled", True) or session_user is not None)
|
||||||
|
|
||||||
|
# --- Hydrate session language from DB (once per session) ---
|
||||||
|
# If the session doesn't have a preferred_language yet but the user
|
||||||
|
# is logged in, load the stored preference from the database so that
|
||||||
|
# detect_language() picks it up from the session on this and all
|
||||||
|
# subsequent requests.
|
||||||
|
if hasattr(req, "session") and "preferred_language" not in req.session and session_user is not None:
|
||||||
|
_hydrate_language_from_db(req, session_user)
|
||||||
|
|
||||||
|
# --- i18n: detect language and register template helpers ---
|
||||||
|
current_locale = detect_language(req)
|
||||||
|
ctx.setdefault("current_locale", current_locale)
|
||||||
|
|
||||||
|
# Smart language suggestions for the compact nav-bar dropdown (5-7 languages)
|
||||||
|
accept_header = req.headers.get("accept-language", "") if hasattr(req, "headers") else ""
|
||||||
|
ctx.setdefault("suggested_languages", get_suggested_languages(current_locale, accept_header))
|
||||||
|
|
||||||
|
def _translate(key: str, **kwargs: object) -> str:
|
||||||
|
return translate(key, current_locale, **kwargs)
|
||||||
|
|
||||||
|
def _format_date(value: object, short: bool = False) -> str:
|
||||||
|
return format_date(value, current_locale, short=short) # type: ignore[arg-type]
|
||||||
|
|
||||||
|
def _format_datetime(value: object) -> str:
|
||||||
|
return format_datetime(value, current_locale) # type: ignore[arg-type]
|
||||||
|
|
||||||
|
def _format_number(value: object) -> str:
|
||||||
|
return format_number(value, current_locale) # type: ignore[arg-type]
|
||||||
|
|
||||||
|
ctx.setdefault("_", _translate)
|
||||||
|
ctx.setdefault("format_date_l10n", _format_date)
|
||||||
|
ctx.setdefault("format_datetime_l10n", _format_datetime)
|
||||||
|
ctx.setdefault("format_number_l10n", _format_number)
|
||||||
|
else:
|
||||||
|
ctx.setdefault("is_logged_in", not getattr(settings, "auth_enabled", True))
|
||||||
|
ctx.setdefault("current_locale", "en")
|
||||||
|
ctx.setdefault("_", lambda key, **kw: translate(key, "en", **kw))
|
||||||
|
|
||||||
|
|
||||||
|
def template_response_with_version(*args, **kwargs):
|
||||||
|
"""Wrapper for TemplateResponse to include version and CSRF token in all templates.
|
||||||
|
|
||||||
|
Handles both old-style and new-style Starlette TemplateResponse calls:
|
||||||
|
- Old-style (Starlette <1.0): TemplateResponse(name, {"request": req, ...}, ...)
|
||||||
|
- New-style (Starlette 1.0+): TemplateResponse(request, name, context={...}, ...)
|
||||||
|
"""
|
||||||
|
if len(args) >= 1 and isinstance(args[0], str):
|
||||||
|
# Old-style call: first positional arg is the template name (string).
|
||||||
|
# Convert to new-style: (request, name, context=..., ...)
|
||||||
|
name = args[0]
|
||||||
|
if len(args) >= 2 and isinstance(args[1], dict):
|
||||||
|
context = args[1]
|
||||||
|
# Old-style may have status_code as 3rd positional arg
|
||||||
|
if len(args) >= 3 and "status_code" not in kwargs:
|
||||||
|
kwargs["status_code"] = args[2]
|
||||||
|
else:
|
||||||
|
context = kwargs.pop("context", {})
|
||||||
|
request_obj = context.pop("request", None)
|
||||||
|
if request_obj is not None:
|
||||||
|
context["request"] = request_obj
|
||||||
|
_inject_global_context(context)
|
||||||
|
if request_obj is not None:
|
||||||
|
return original_template_response(request_obj, name, context=context, **kwargs)
|
||||||
|
return original_template_response(name, context=context, **kwargs)
|
||||||
|
|
||||||
|
# New-style call: (request, name, context=..., ...)
|
||||||
|
if "context" in kwargs and isinstance(kwargs["context"], dict):
|
||||||
|
_inject_global_context(kwargs["context"])
|
||||||
|
elif len(args) >= 3 and isinstance(args[2], dict):
|
||||||
|
_inject_global_context(args[2])
|
||||||
|
return original_template_response(*args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
templates.TemplateResponse = template_response_with_version
|
||||||
|
|
||||||
|
# Set up logging
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
[build-system]
|
[build-system]
|
||||||
requires = ["setuptools>=45", "wheel"]
|
requires = ["setuptools>=82.0.1", "wheel"]
|
||||||
build-backend = "setuptools.build_meta"
|
build-backend = "setuptools.build_meta"
|
||||||
|
|
||||||
[project]
|
[project]
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import sys
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
from fastapi.templating import Jinja2Templates
|
||||||
|
|
||||||
|
import os
|
||||||
|
# We don't really need a real path, but let's mock it
|
||||||
|
os.makedirs("templates", exist_ok=True)
|
||||||
|
with open("templates/files.html", "w") as f:
|
||||||
|
f.write("Hello")
|
||||||
|
|
||||||
|
templates = Jinja2Templates(directory="templates")
|
||||||
|
original_template_response = templates.TemplateResponse
|
||||||
|
|
||||||
|
def template_response_with_version(*args, **kwargs):
|
||||||
|
if len(args) == 2 and isinstance(args[0], str) and isinstance(args[1], dict):
|
||||||
|
context = args[1]
|
||||||
|
request = context.get("request")
|
||||||
|
if request is not None:
|
||||||
|
# THIS IS MY FIX
|
||||||
|
print("Running fix logic")
|
||||||
|
return original_template_response(request=request, name=args[0], context=context, **kwargs)
|
||||||
|
|
||||||
|
print("Running original fallback logic")
|
||||||
|
return original_template_response(*args, **kwargs)
|
||||||
|
|
||||||
|
templates.TemplateResponse = template_response_with_version
|
||||||
|
|
||||||
|
req = MagicMock()
|
||||||
|
try:
|
||||||
|
templates.TemplateResponse("files.html", {"request": req})
|
||||||
|
print("SUCCESS")
|
||||||
|
except Exception as e:
|
||||||
|
import traceback
|
||||||
|
traceback.print_exc()
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import sys
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
from fastapi.templating import Jinja2Templates
|
||||||
|
|
||||||
|
import os
|
||||||
|
os.makedirs("templates", exist_ok=True)
|
||||||
|
with open("templates/files.html", "w") as f:
|
||||||
|
f.write("Hello")
|
||||||
|
|
||||||
|
templates = Jinja2Templates(directory="templates")
|
||||||
|
original_template_response = templates.TemplateResponse
|
||||||
|
|
||||||
|
def template_response_with_version(*args, **kwargs):
|
||||||
|
if len(args) == 2 and isinstance(args[0], str) and isinstance(args[1], dict):
|
||||||
|
context = args[1]
|
||||||
|
request = context.get("request")
|
||||||
|
if request is not None:
|
||||||
|
# THIS IS MY FIX
|
||||||
|
print("Running fix logic")
|
||||||
|
return original_template_response(request=request, name=args[0], context=context, **kwargs)
|
||||||
|
|
||||||
|
print("Running original fallback logic", args, kwargs)
|
||||||
|
return original_template_response(*args, **kwargs)
|
||||||
|
|
||||||
|
templates.TemplateResponse = template_response_with_version
|
||||||
|
|
||||||
|
req = MagicMock()
|
||||||
|
try:
|
||||||
|
templates.TemplateResponse(request=req, name="files.html", context={"request": req})
|
||||||
|
print("SUCCESS")
|
||||||
|
except Exception as e:
|
||||||
|
import traceback
|
||||||
|
traceback.print_exc()
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import sys
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
from fastapi.templating import Jinja2Templates
|
||||||
|
|
||||||
|
import os
|
||||||
|
os.makedirs("templates", exist_ok=True)
|
||||||
|
with open("templates/files.html", "w") as f:
|
||||||
|
f.write("Hello")
|
||||||
|
|
||||||
|
templates = Jinja2Templates(directory="templates")
|
||||||
|
original_template_response = templates.TemplateResponse
|
||||||
|
|
||||||
|
def template_response_with_version(*args, **kwargs):
|
||||||
|
if len(args) == 2 and isinstance(args[0], str) and isinstance(args[1], dict):
|
||||||
|
context = args[1]
|
||||||
|
request = context.get("request")
|
||||||
|
if request is not None:
|
||||||
|
# THIS IS MY FIX
|
||||||
|
print("Running fix logic")
|
||||||
|
return original_template_response(request=request, name=args[0], context=context, **kwargs)
|
||||||
|
|
||||||
|
print("Running original fallback logic", args, kwargs)
|
||||||
|
return original_template_response(*args, **kwargs)
|
||||||
|
|
||||||
|
templates.TemplateResponse = template_response_with_version
|
||||||
|
|
||||||
|
req = MagicMock()
|
||||||
|
try:
|
||||||
|
templates.TemplateResponse("files.html", {"request": req}, status_code=200)
|
||||||
|
print("SUCCESS")
|
||||||
|
except Exception as e:
|
||||||
|
import traceback
|
||||||
|
traceback.print_exc()
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import sys
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
from fastapi.templating import Jinja2Templates
|
||||||
|
|
||||||
|
import os
|
||||||
|
os.makedirs("templates", exist_ok=True)
|
||||||
|
with open("templates/files.html", "w") as f:
|
||||||
|
f.write("Hello")
|
||||||
|
|
||||||
|
templates = Jinja2Templates(directory="templates")
|
||||||
|
original_template_response = templates.TemplateResponse
|
||||||
|
|
||||||
|
def template_response_with_version(*args, **kwargs):
|
||||||
|
print("ARGS:", args)
|
||||||
|
print("KWARGS:", kwargs)
|
||||||
|
if len(args) == 2 and isinstance(args[0], str) and isinstance(args[1], dict):
|
||||||
|
context = args[1]
|
||||||
|
request = context.get("request")
|
||||||
|
if request is not None:
|
||||||
|
# THIS IS MY FIX
|
||||||
|
print("Running fix logic")
|
||||||
|
return original_template_response(request=request, name=args[0], context=context, **kwargs)
|
||||||
|
|
||||||
|
print("Running original fallback logic", args, kwargs)
|
||||||
|
return original_template_response(*args, **kwargs)
|
||||||
|
|
||||||
|
templates.TemplateResponse = template_response_with_version
|
||||||
|
|
||||||
|
req = MagicMock()
|
||||||
|
try:
|
||||||
|
templates.TemplateResponse("files.html", context={"request": req})
|
||||||
|
print("SUCCESS")
|
||||||
|
except Exception as e:
|
||||||
|
import traceback
|
||||||
|
traceback.print_exc()
|
||||||
@@ -34,7 +34,7 @@ pip-audit>=2.7.0 # Dependency vulnerability scanning against OSV/PyPA advisory
|
|||||||
pre-commit>=3.6.0
|
pre-commit>=3.6.0
|
||||||
|
|
||||||
# License compliance
|
# License compliance
|
||||||
pip-licenses==5.5.1 # For license compliance checking
|
pip-licenses==5.5.5 # For license compliance checking
|
||||||
|
|
||||||
# Release automation
|
# Release automation
|
||||||
python-semantic-release>=9.0.0
|
python-semantic-release>=9.0.0
|
||||||
|
|||||||
+1
-1
@@ -51,7 +51,7 @@ pytesseract>=0.3.10 # Python wrapper for Tesseract OCR
|
|||||||
pdf2image>=1.17.0 # Convert PDF pages to images (used by Tesseract and EasyOCR providers)
|
pdf2image>=1.17.0 # Convert PDF pages to images (used by Tesseract and EasyOCR providers)
|
||||||
ocrmypdf>=16.0.0,<18.0.0 # Post-processing: embeds searchable text layers into PDFs via Tesseract
|
ocrmypdf>=16.0.0,<18.0.0 # Post-processing: embeds searchable text layers into PDFs via Tesseract
|
||||||
meilisearch>=0.31.0 # Full-text search engine client
|
meilisearch>=0.31.0 # Full-text search engine client
|
||||||
stripe>=7.0.0,<15.0.0 # Stripe billing SDK (MIT license)
|
stripe>=7.0.0,<16.0.0 # Stripe billing SDK (MIT license)
|
||||||
|
|
||||||
# Error and performance monitoring
|
# Error and performance monitoring
|
||||||
sentry-sdk[fastapi,celery,sqlalchemy]>=2.20.0,<3.0.0
|
sentry-sdk[fastapi,celery,sqlalchemy]>=2.20.0,<3.0.0
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
Hello
|
||||||
@@ -83,8 +83,6 @@ class TestGotenbergCoverageDocuments:
|
|||||||
".tif",
|
".tif",
|
||||||
".webp",
|
".webp",
|
||||||
".svg",
|
".svg",
|
||||||
".heic",
|
|
||||||
".heif",
|
|
||||||
}
|
}
|
||||||
_html_extensions = {".html", ".htm"}
|
_html_extensions = {".html", ".htm"}
|
||||||
_markdown_extensions = {".md", ".markdown"}
|
_markdown_extensions = {".md", ".markdown"}
|
||||||
|
|||||||
@@ -267,6 +267,15 @@ class TestTestDropboxToken:
|
|||||||
class TestSaveDropboxSettings:
|
class TestSaveDropboxSettings:
|
||||||
"""Tests for save_dropbox_settings endpoint."""
|
"""Tests for save_dropbox_settings endpoint."""
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _admin_override(self):
|
||||||
|
from app.api.dropbox import _require_admin
|
||||||
|
from app.main import app as fastapi_app
|
||||||
|
|
||||||
|
fastapi_app.dependency_overrides[_require_admin] = lambda: {"is_admin": True}
|
||||||
|
yield
|
||||||
|
fastapi_app.dependency_overrides.pop(_require_admin, None)
|
||||||
|
|
||||||
@patch("app.api.dropbox.settings")
|
@patch("app.api.dropbox.settings")
|
||||||
def test_save_settings_env_not_found(self, mock_settings, client):
|
def test_save_settings_env_not_found(self, mock_settings, client):
|
||||||
"""Test that missing .env file is non-fatal — DB write still succeeds."""
|
"""Test that missing .env file is non-fatal — DB write still succeeds."""
|
||||||
|
|||||||
@@ -360,6 +360,15 @@ class TestFormatTimeRemaining:
|
|||||||
class TestSaveGoogleDriveSettings:
|
class TestSaveGoogleDriveSettings:
|
||||||
"""Tests for POST /google-drive/save-settings endpoint."""
|
"""Tests for POST /google-drive/save-settings endpoint."""
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _admin_override(self):
|
||||||
|
from app.api.google_drive import _require_admin
|
||||||
|
from app.main import app as fastapi_app
|
||||||
|
|
||||||
|
fastapi_app.dependency_overrides[_require_admin] = lambda: {"is_admin": True}
|
||||||
|
yield
|
||||||
|
fastapi_app.dependency_overrides.pop(_require_admin, None)
|
||||||
|
|
||||||
@patch("builtins.open", new_callable=mock_open, read_data="# Existing config\n")
|
@patch("builtins.open", new_callable=mock_open, read_data="# Existing config\n")
|
||||||
@patch("os.path.exists")
|
@patch("os.path.exists")
|
||||||
@patch("os.path.dirname")
|
@patch("os.path.dirname")
|
||||||
@@ -481,18 +490,17 @@ class TestSaveGoogleDriveSettings:
|
|||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
|
|
||||||
@patch("os.path.exists")
|
@patch("app.api.google_drive.os")
|
||||||
@patch("os.path.dirname")
|
|
||||||
@patch("app.config.settings")
|
@patch("app.config.settings")
|
||||||
def test_save_settings_exception_handling(self, mock_settings, mock_dirname, mock_exists, client: TestClient):
|
def test_save_settings_exception_handling(self, mock_settings, mock_os, client: TestClient):
|
||||||
"""Test exception handling in save settings."""
|
"""Test that exceptions in .env write are non-fatal — DB write still succeeds."""
|
||||||
mock_exists.side_effect = Exception("Unexpected error")
|
mock_os.path.exists.side_effect = Exception("Unexpected error")
|
||||||
|
|
||||||
response = client.post("/api/google-drive/save-settings", data={"refresh_token": "token", "use_oauth": "true"})
|
response = client.post("/api/google-drive/save-settings", data={"refresh_token": "token", "use_oauth": "true"})
|
||||||
|
|
||||||
assert response.status_code == 500
|
# .env write exception is caught; endpoint succeeds via DB write
|
||||||
data = response.json()
|
assert response.status_code == 200
|
||||||
assert "failed to save" in data["detail"].lower()
|
assert response.json()["status"] == "success"
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.unit
|
@pytest.mark.unit
|
||||||
|
|||||||
@@ -195,6 +195,15 @@ class TestGetGoogleDriveTokenInfo:
|
|||||||
class TestSaveGoogleDriveSettings:
|
class TestSaveGoogleDriveSettings:
|
||||||
"""Test save_google_drive_settings endpoint edge cases."""
|
"""Test save_google_drive_settings endpoint edge cases."""
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _admin_override(self):
|
||||||
|
from app.api.google_drive import _require_admin
|
||||||
|
from app.main import app as fastapi_app
|
||||||
|
|
||||||
|
fastapi_app.dependency_overrides[_require_admin] = lambda: {"is_admin": True}
|
||||||
|
yield
|
||||||
|
fastapi_app.dependency_overrides.pop(_require_admin, None)
|
||||||
|
|
||||||
@patch("app.api.google_drive.settings")
|
@patch("app.api.google_drive.settings")
|
||||||
@patch("os.path.exists")
|
@patch("os.path.exists")
|
||||||
def test_save_settings_env_file_not_exists(self, mock_exists, mock_settings, client: TestClient):
|
def test_save_settings_env_file_not_exists(self, mock_exists, mock_settings, client: TestClient):
|
||||||
|
|||||||
@@ -152,11 +152,16 @@ class TestGetTokenInfoCredentialsBranches:
|
|||||||
|
|
||||||
@pytest.mark.unit
|
@pytest.mark.unit
|
||||||
class TestSaveGoogleDriveSettingsFalsyFields:
|
class TestSaveGoogleDriveSettingsFalsyFields:
|
||||||
"""Cover branches 395->397, 449->451, 468->470 in save_google_drive_settings.
|
"""Cover branches 395->397, 449->451, 468->470 in save_google_drive_settings."""
|
||||||
|
|
||||||
Note: the Google Drive save endpoint is named save_google_drive_settings in the
|
@pytest.fixture(autouse=True)
|
||||||
source (app/api/google_drive.py).
|
def _admin_override(self):
|
||||||
"""
|
from app.api.google_drive import _require_admin
|
||||||
|
from app.main import app as fastapi_app
|
||||||
|
|
||||||
|
fastapi_app.dependency_overrides[_require_admin] = lambda: {"is_admin": True}
|
||||||
|
yield
|
||||||
|
fastapi_app.dependency_overrides.pop(_require_admin, None)
|
||||||
|
|
||||||
@patch("app.api.google_drive.settings")
|
@patch("app.api.google_drive.settings")
|
||||||
@patch("os.path.exists", return_value=False)
|
@patch("os.path.exists", return_value=False)
|
||||||
@@ -177,6 +182,7 @@ class TestSaveGoogleDriveSettingsFalsyFields:
|
|||||||
with patch("app.api.google_drive.notify_settings_updated"):
|
with patch("app.api.google_drive.notify_settings_updated"):
|
||||||
result = await save_google_drive_settings(
|
result = await save_google_drive_settings(
|
||||||
request=mock_request,
|
request=mock_request,
|
||||||
|
_admin={"is_admin": True},
|
||||||
refresh_token="", # falsy → branches 395->397 and 449->451
|
refresh_token="", # falsy → branches 395->397 and 449->451
|
||||||
client_id="cid",
|
client_id="cid",
|
||||||
client_secret=None,
|
client_secret=None,
|
||||||
|
|||||||
@@ -524,7 +524,10 @@ class TestTestImapConnection:
|
|||||||
from app.api.imap_accounts import _test_imap_connection
|
from app.api.imap_accounts import _test_imap_connection
|
||||||
|
|
||||||
mock_mail = MagicMock()
|
mock_mail = MagicMock()
|
||||||
with patch("imaplib.IMAP4_SSL", return_value=mock_mail):
|
with (
|
||||||
|
patch("app.api.imap_accounts.is_private_ip", return_value=False),
|
||||||
|
patch("imaplib.IMAP4_SSL", return_value=mock_mail),
|
||||||
|
):
|
||||||
result = _test_imap_connection(
|
result = _test_imap_connection(
|
||||||
"imap.example.com",
|
"imap.example.com",
|
||||||
993,
|
993,
|
||||||
@@ -541,7 +544,10 @@ class TestTestImapConnection:
|
|||||||
"""An exception raised by IMAP4_SSL returns success=False."""
|
"""An exception raised by IMAP4_SSL returns success=False."""
|
||||||
from app.api.imap_accounts import _test_imap_connection
|
from app.api.imap_accounts import _test_imap_connection
|
||||||
|
|
||||||
with patch("imaplib.IMAP4_SSL", side_effect=Exception("auth failed")):
|
with (
|
||||||
|
patch("app.api.imap_accounts.is_private_ip", return_value=False),
|
||||||
|
patch("imaplib.IMAP4_SSL", side_effect=Exception("auth failed")),
|
||||||
|
):
|
||||||
result = _test_imap_connection(
|
result = _test_imap_connection(
|
||||||
"imap.example.com",
|
"imap.example.com",
|
||||||
993,
|
993,
|
||||||
@@ -557,7 +563,10 @@ class TestTestImapConnection:
|
|||||||
"""An OSError returns success=False with a network error message."""
|
"""An OSError returns success=False with a network error message."""
|
||||||
from app.api.imap_accounts import _test_imap_connection
|
from app.api.imap_accounts import _test_imap_connection
|
||||||
|
|
||||||
with patch("imaplib.IMAP4", side_effect=OSError("connection refused")):
|
with (
|
||||||
|
patch("app.api.imap_accounts.is_private_ip", return_value=False),
|
||||||
|
patch("imaplib.IMAP4", side_effect=OSError("connection refused")),
|
||||||
|
):
|
||||||
result = _test_imap_connection(
|
result = _test_imap_connection(
|
||||||
"bad-host",
|
"bad-host",
|
||||||
143,
|
143,
|
||||||
|
|||||||
@@ -998,6 +998,23 @@ class TestConnectionTestEndpoint:
|
|||||||
assert data["success"] is False
|
assert data["success"] is False
|
||||||
assert "Missing" in data["message"]
|
assert "Missing" in data["message"]
|
||||||
|
|
||||||
|
def test_test_imap_blocks_private_ip(self, int_client):
|
||||||
|
"""IMAP test with private IP returns failure (SSRF protection)."""
|
||||||
|
payload = {
|
||||||
|
"integration_type": "IMAP",
|
||||||
|
"config": {
|
||||||
|
"host": "127.0.0.1",
|
||||||
|
"port": 993,
|
||||||
|
"username": "user",
|
||||||
|
},
|
||||||
|
"credentials": {"password": "pass"},
|
||||||
|
}
|
||||||
|
resp = int_client.post("/api/integrations/test", json=payload)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
assert data["success"] is False
|
||||||
|
assert "Invalid hostname or IP address" in data["message"]
|
||||||
|
|
||||||
def test_test_s3_missing_bucket(self, int_client):
|
def test_test_s3_missing_bucket(self, int_client):
|
||||||
"""S3 test with missing bucket returns failure."""
|
"""S3 test with missing bucket returns failure."""
|
||||||
payload = {
|
payload = {
|
||||||
@@ -1011,6 +1028,19 @@ class TestConnectionTestEndpoint:
|
|||||||
assert data["success"] is False
|
assert data["success"] is False
|
||||||
assert "bucket" in data["message"].lower()
|
assert "bucket" in data["message"].lower()
|
||||||
|
|
||||||
|
def test_test_s3_blocks_private_ip(self, int_client):
|
||||||
|
"""S3 test with private IP endpoint returns failure (SSRF protection)."""
|
||||||
|
payload = {
|
||||||
|
"integration_type": "S3",
|
||||||
|
"config": {"bucket": "my-bucket", "endpoint_url": "http://127.0.0.1:9000"},
|
||||||
|
"credentials": {"access_key_id": "AKIA", "secret_access_key": "secret"},
|
||||||
|
}
|
||||||
|
resp = int_client.post("/api/integrations/test", json=payload)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
assert data["success"] is False
|
||||||
|
assert "Invalid endpoint URL or private IP" in data["message"]
|
||||||
|
|
||||||
def test_test_webdav_missing_url(self, int_client):
|
def test_test_webdav_missing_url(self, int_client):
|
||||||
"""WebDAV test with missing URL returns failure."""
|
"""WebDAV test with missing URL returns failure."""
|
||||||
payload = {
|
payload = {
|
||||||
|
|||||||
@@ -342,6 +342,15 @@ class TestFormatTimeRemaining:
|
|||||||
class TestSaveOneDriveSettings:
|
class TestSaveOneDriveSettings:
|
||||||
"""Tests for POST /onedrive/save-settings endpoint."""
|
"""Tests for POST /onedrive/save-settings endpoint."""
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _admin_override(self):
|
||||||
|
from app.api.onedrive import _require_admin
|
||||||
|
from app.main import app as fastapi_app
|
||||||
|
|
||||||
|
fastapi_app.dependency_overrides[_require_admin] = lambda: {"is_admin": True}
|
||||||
|
yield
|
||||||
|
fastapi_app.dependency_overrides.pop(_require_admin, None)
|
||||||
|
|
||||||
@patch("builtins.open", new_callable=mock_open, read_data="# Existing config\n")
|
@patch("builtins.open", new_callable=mock_open, read_data="# Existing config\n")
|
||||||
@patch("os.path.exists")
|
@patch("os.path.exists")
|
||||||
@patch("os.path.dirname")
|
@patch("os.path.dirname")
|
||||||
|
|||||||
@@ -294,6 +294,15 @@ class TestTokenRotationEnvAppendLine:
|
|||||||
class TestSaveSettingsException:
|
class TestSaveSettingsException:
|
||||||
"""Cover lines 324-326: save_onedrive_settings outer exception handler."""
|
"""Cover lines 324-326: save_onedrive_settings outer exception handler."""
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _admin_override(self):
|
||||||
|
from app.api.onedrive import _require_admin
|
||||||
|
from app.main import app as fastapi_app
|
||||||
|
|
||||||
|
fastapi_app.dependency_overrides[_require_admin] = lambda: {"is_admin": True}
|
||||||
|
yield
|
||||||
|
fastapi_app.dependency_overrides.pop(_require_admin, None)
|
||||||
|
|
||||||
def test_save_settings_outer_exception(self, client: TestClient):
|
def test_save_settings_outer_exception(self, client: TestClient):
|
||||||
"""Trigger the outer exception handler in save_onedrive_settings."""
|
"""Trigger the outer exception handler in save_onedrive_settings."""
|
||||||
with patch("app.api.onedrive.notify_settings_updated", side_effect=Exception("Unexpected boom")):
|
with patch("app.api.onedrive.notify_settings_updated", side_effect=Exception("Unexpected boom")):
|
||||||
|
|||||||
@@ -47,6 +47,27 @@ class TestProcessEndpoints:
|
|||||||
data = response.json()
|
data = response.json()
|
||||||
assert data["task_id"] == "test-task-id"
|
assert data["task_id"] == "test-task-id"
|
||||||
assert data["status"] == "queued"
|
assert data["status"] == "queued"
|
||||||
|
mock_task.delay.assert_called_once_with(str(test_file))
|
||||||
|
|
||||||
|
def test_send_to_dropbox_endpoint_file_not_found(self, client):
|
||||||
|
"""Test POST /api/send_to_dropbox/ directly mapping to endpoint name with non-existent file."""
|
||||||
|
response = client.post("/api/send_to_dropbox/?file_path=nonexistent_endpoint.pdf")
|
||||||
|
assert response.status_code == 400
|
||||||
|
|
||||||
|
def test_send_to_dropbox_endpoint_success(self, client, tmp_path):
|
||||||
|
"""Test POST /api/send_to_dropbox/ directly mapping to endpoint name with existing file."""
|
||||||
|
test_file = tmp_path / "processed" / "test_endpoint.pdf"
|
||||||
|
test_file.parent.mkdir(parents=True)
|
||||||
|
test_file.write_text("test content endpoint")
|
||||||
|
|
||||||
|
with patch("app.api.process.upload_to_dropbox") as mock_task:
|
||||||
|
mock_task.delay.return_value = Mock(id="test-task-id-endpoint")
|
||||||
|
response = client.post(f"/api/send_to_dropbox/?file_path={test_file}")
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["task_id"] == "test-task-id-endpoint"
|
||||||
|
assert data["status"] == "queued"
|
||||||
|
mock_task.delay.assert_called_once_with(str(test_file))
|
||||||
|
|
||||||
def test_send_to_paperless_file_not_found(self, client):
|
def test_send_to_paperless_file_not_found(self, client):
|
||||||
"""Test POST /api/send_to_paperless/ with non-existent file."""
|
"""Test POST /api/send_to_paperless/ with non-existent file."""
|
||||||
|
|||||||
+337
-160
@@ -1,191 +1,368 @@
|
|||||||
"""Tests for the saved searches API (app/api/saved_searches.py)."""
|
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
from sqlalchemy import create_engine
|
|
||||||
from sqlalchemy.orm import sessionmaker
|
|
||||||
from sqlalchemy.pool import StaticPool
|
|
||||||
|
|
||||||
from app.database import Base, get_db
|
|
||||||
from app.models import SavedSearch
|
from app.models import SavedSearch
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Test data constants
|
# Saved searches CRUD tests
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
_OWNER = "test_user@example.com"
|
|
||||||
_OTHER_OWNER = "other_user@example.com"
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Shared fixture helpers
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture()
|
@pytest.mark.unit
|
||||||
def int_engine():
|
class TestSavedSearchesCRUD:
|
||||||
"""In-memory SQLite engine for integration tests."""
|
"""Tests for saved searches CRUD API endpoints."""
|
||||||
engine = create_engine(
|
|
||||||
"sqlite:///:memory:",
|
|
||||||
connect_args={"check_same_thread": False},
|
|
||||||
poolclass=StaticPool,
|
|
||||||
)
|
|
||||||
Base.metadata.create_all(bind=engine)
|
|
||||||
yield engine
|
|
||||||
Base.metadata.drop_all(bind=engine)
|
|
||||||
|
|
||||||
|
def test_list_saved_searches_empty(self, client: TestClient):
|
||||||
|
"""GET /api/saved-searches returns empty list when no searches exist."""
|
||||||
|
response = client.get("/api/saved-searches")
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json() == []
|
||||||
|
|
||||||
@pytest.fixture()
|
def test_create_saved_search(self, client: TestClient):
|
||||||
def int_session(int_engine):
|
"""POST /api/saved-searches creates a new saved search."""
|
||||||
"""DB session scoped to one test."""
|
payload = {
|
||||||
Session = sessionmaker(bind=int_engine)
|
"name": "My Invoices",
|
||||||
session = Session()
|
"filters": {"tags": "invoice", "status": "completed"},
|
||||||
yield session
|
}
|
||||||
session.close()
|
response = client.post("/api/saved-searches", json=payload)
|
||||||
|
assert response.status_code == 201
|
||||||
|
data = response.json()
|
||||||
def _make_client(int_engine, owner_id: str = _OWNER):
|
|
||||||
"""Return a TestClient with *owner_id* injected as the authenticated user."""
|
|
||||||
from unittest.mock import patch
|
|
||||||
|
|
||||||
from app.main import app
|
|
||||||
|
|
||||||
def override_db():
|
|
||||||
Session = sessionmaker(bind=int_engine)
|
|
||||||
session = Session()
|
|
||||||
try:
|
|
||||||
yield session
|
|
||||||
finally:
|
|
||||||
session.close()
|
|
||||||
|
|
||||||
app.dependency_overrides[get_db] = override_db
|
|
||||||
with patch("app.api.saved_searches._get_user_id", return_value=owner_id):
|
|
||||||
with TestClient(app, base_url="http://localhost", raise_server_exceptions=False) as client:
|
|
||||||
yield client
|
|
||||||
app.dependency_overrides.clear()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture()
|
|
||||||
def int_client(int_engine):
|
|
||||||
"""TestClient authenticated as _OWNER."""
|
|
||||||
yield from _make_client(int_engine, _OWNER)
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# CRUD tests
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.integration
|
|
||||||
class TestSavedSearchesAPI:
|
|
||||||
"""Tests for Saved Searches endpoints."""
|
|
||||||
|
|
||||||
def test_list_saved_searches_empty(self, int_client):
|
|
||||||
"""No saved searches returns empty list."""
|
|
||||||
resp = int_client.get("/api/saved-searches")
|
|
||||||
assert resp.status_code == 200
|
|
||||||
assert resp.json() == []
|
|
||||||
|
|
||||||
def test_create_saved_search(self, int_client):
|
|
||||||
"""Create a saved search and verify the response."""
|
|
||||||
payload = {"name": "My Invoices", "filters": {"tags": "invoice", "document_type": "Invoice"}}
|
|
||||||
resp = int_client.post("/api/saved-searches", json=payload)
|
|
||||||
assert resp.status_code == 201
|
|
||||||
data = resp.json()
|
|
||||||
assert data["name"] == "My Invoices"
|
assert data["name"] == "My Invoices"
|
||||||
assert data["filters"] == {"tags": "invoice", "document_type": "Invoice"}
|
assert data["filters"]["tags"] == "invoice"
|
||||||
|
assert data["filters"]["status"] == "completed"
|
||||||
assert "id" in data
|
assert "id" in data
|
||||||
|
|
||||||
def test_create_saved_search_invalid_filters(self, int_client):
|
def test_create_and_list_saved_search(self, client: TestClient):
|
||||||
"""Creating with invalid filters returns 422."""
|
"""Creating a saved search makes it appear in the list."""
|
||||||
# Missing filters parameter (or empty after sanitization)
|
payload = {
|
||||||
payload = {"name": "My Invoices", "filters": {}}
|
"name": "PDF Files",
|
||||||
resp = int_client.post("/api/saved-searches", json=payload)
|
"filters": {"mime_type": "application/pdf"},
|
||||||
assert resp.status_code == 422
|
}
|
||||||
|
client.post("/api/saved-searches", json=payload)
|
||||||
|
|
||||||
# Invalid filters format
|
response = client.get("/api/saved-searches")
|
||||||
payload2 = {"name": "My Invoices", "filters": "not_a_dict"}
|
assert response.status_code == 200
|
||||||
resp2 = int_client.post("/api/saved-searches", json=payload2)
|
searches = response.json()
|
||||||
assert resp2.status_code == 422
|
assert len(searches) == 1
|
||||||
|
assert searches[0]["name"] == "PDF Files"
|
||||||
|
|
||||||
def test_create_saved_search_duplicate(self, int_client):
|
def test_create_saved_search_missing_name(self, client: TestClient):
|
||||||
"""Creating a duplicate named search returns 409."""
|
"""POST /api/saved-searches without name returns 422."""
|
||||||
payload = {"name": "Duplicate", "filters": {"q": "test"}}
|
payload = {"filters": {"status": "completed"}}
|
||||||
int_client.post("/api/saved-searches", json=payload)
|
response = client.post("/api/saved-searches", json=payload)
|
||||||
resp = int_client.post("/api/saved-searches", json=payload)
|
assert response.status_code == 422
|
||||||
assert resp.status_code == 409
|
|
||||||
|
|
||||||
def test_create_saved_search_limit(self, int_client, int_session):
|
def test_create_saved_search_empty_filters(self, client: TestClient):
|
||||||
"""Exceeding MAX_SAVED_SEARCHES_PER_USER returns 409."""
|
"""POST /api/saved-searches with empty filters returns 422."""
|
||||||
# Create 50 searches using the API to ensure they are visible
|
payload = {"name": "Empty", "filters": {}}
|
||||||
for i in range(50):
|
response = client.post("/api/saved-searches", json=payload)
|
||||||
resp = int_client.post("/api/saved-searches", json={"name": f"Search LIMIT {i}", "filters": {"q": "test"}})
|
assert response.status_code == 422
|
||||||
assert resp.status_code == 201
|
|
||||||
|
|
||||||
payload = {"name": "One too many", "filters": {"q": "test"}}
|
def test_create_saved_search_invalid_filter_keys(self, client: TestClient):
|
||||||
resp = int_client.post("/api/saved-searches", json=payload)
|
"""POST /api/saved-searches ignores unknown filter keys."""
|
||||||
assert resp.status_code == 409
|
payload = {
|
||||||
|
"name": "With unknown keys",
|
||||||
|
"filters": {"invalid_key": "value", "status": "completed"},
|
||||||
|
}
|
||||||
|
response = client.post("/api/saved-searches", json=payload)
|
||||||
|
assert response.status_code == 201
|
||||||
|
data = response.json()
|
||||||
|
# Only valid filter key should remain
|
||||||
|
assert "invalid_key" not in data["filters"]
|
||||||
|
assert data["filters"]["status"] == "completed"
|
||||||
|
|
||||||
def test_update_saved_search(self, int_client):
|
def test_create_saved_search_only_invalid_keys(self, client: TestClient):
|
||||||
"""Update an existing saved search."""
|
"""POST with only invalid filter keys returns 422."""
|
||||||
payload = {"name": "Original Name", "filters": {"q": "test"}}
|
payload = {
|
||||||
created = int_client.post("/api/saved-searches", json=payload).json()
|
"name": "All invalid",
|
||||||
search_id = created["id"]
|
"filters": {"bad_key": "value"},
|
||||||
|
}
|
||||||
|
response = client.post("/api/saved-searches", json=payload)
|
||||||
|
assert response.status_code == 422
|
||||||
|
|
||||||
update_payload = {"name": "Updated Name", "filters": {"tags": "new"}}
|
def test_create_duplicate_name(self, client: TestClient):
|
||||||
resp = int_client.put(f"/api/saved-searches/{search_id}", json=update_payload)
|
"""POST /api/saved-searches with duplicate name returns 409."""
|
||||||
assert resp.status_code == 200
|
payload = {"name": "My Search", "filters": {"status": "completed"}}
|
||||||
data = resp.json()
|
response1 = client.post("/api/saved-searches", json=payload)
|
||||||
assert data["name"] == "Updated Name"
|
assert response1.status_code == 201
|
||||||
assert data["filters"] == {"tags": "new"}
|
|
||||||
|
|
||||||
def test_update_saved_search_not_found(self, int_client):
|
response2 = client.post("/api/saved-searches", json=payload)
|
||||||
"""Updating a non-existent search returns 404."""
|
assert response2.status_code == 409
|
||||||
update_payload = {"name": "Updated Name"}
|
|
||||||
resp = int_client.put("/api/saved-searches/999", json=update_payload)
|
|
||||||
assert resp.status_code == 404
|
|
||||||
|
|
||||||
def test_update_saved_search_duplicate_name(self, int_client):
|
def test_update_saved_search(self, client: TestClient):
|
||||||
"""Updating name to an existing search name returns 409."""
|
"""PUT /api/saved-searches/{id} updates the saved search."""
|
||||||
payload1 = {"name": "Search 1", "filters": {"q": "a"}}
|
# Create
|
||||||
payload2 = {"name": "Search 2", "filters": {"q": "b"}}
|
create_resp = client.post(
|
||||||
int_client.post("/api/saved-searches", json=payload1)
|
"/api/saved-searches",
|
||||||
created2 = int_client.post("/api/saved-searches", json=payload2).json()
|
json={"name": "Original", "filters": {"status": "pending"}},
|
||||||
search2_id = created2["id"]
|
)
|
||||||
|
search_id = create_resp.json()["id"]
|
||||||
|
|
||||||
update_payload = {"name": "Search 1"}
|
# Update
|
||||||
resp = int_client.put(f"/api/saved-searches/{search2_id}", json=update_payload)
|
update_resp = client.put(
|
||||||
assert resp.status_code == 409
|
f"/api/saved-searches/{search_id}",
|
||||||
|
json={"name": "Updated", "filters": {"status": "completed"}},
|
||||||
|
)
|
||||||
|
assert update_resp.status_code == 200
|
||||||
|
data = update_resp.json()
|
||||||
|
assert data["name"] == "Updated"
|
||||||
|
assert data["filters"]["status"] == "completed"
|
||||||
|
|
||||||
def test_delete_saved_search(self, int_client, int_session):
|
def test_update_saved_search_not_found(self, client: TestClient):
|
||||||
"""Delete an existing search."""
|
"""PUT /api/saved-searches/999 returns 404."""
|
||||||
payload = {"name": "To be deleted", "filters": {"q": "test"}}
|
response = client.put(
|
||||||
created = int_client.post("/api/saved-searches", json=payload).json()
|
"/api/saved-searches/999",
|
||||||
search_id = created["id"]
|
json={"name": "Nope", "filters": {"status": "completed"}},
|
||||||
|
)
|
||||||
|
assert response.status_code == 404
|
||||||
|
|
||||||
resp = int_client.delete(f"/api/saved-searches/{search_id}")
|
def test_delete_saved_search(self, client: TestClient):
|
||||||
assert resp.status_code == 204
|
"""DELETE /api/saved-searches/{id} removes the saved search."""
|
||||||
|
# Create
|
||||||
|
create_resp = client.post(
|
||||||
|
"/api/saved-searches",
|
||||||
|
json={"name": "To Delete", "filters": {"status": "failed"}},
|
||||||
|
)
|
||||||
|
search_id = create_resp.json()["id"]
|
||||||
|
|
||||||
assert int_session.query(SavedSearch).filter(SavedSearch.id == search_id).first() is None
|
# Delete
|
||||||
|
del_resp = client.delete(f"/api/saved-searches/{search_id}")
|
||||||
|
assert del_resp.status_code == 204
|
||||||
|
|
||||||
def test_delete_saved_search_not_found(self, int_client):
|
# Verify it's gone
|
||||||
"""Deleting a non-existent search returns 404."""
|
list_resp = client.get("/api/saved-searches")
|
||||||
resp = int_client.delete("/api/saved-searches/999")
|
assert len(list_resp.json()) == 0
|
||||||
assert resp.status_code == 404
|
|
||||||
|
|
||||||
def test_other_users_searches_isolated(self, int_engine, int_session):
|
def test_delete_saved_search_not_found(self, client: TestClient):
|
||||||
"""Users only see and can only modify their own saved searches."""
|
"""DELETE /api/saved-searches/999 returns 404."""
|
||||||
int_session.add(SavedSearch(user_id=_OTHER_OWNER, name="Other Search", filters='{"q": "test"}'))
|
response = client.delete("/api/saved-searches/999")
|
||||||
int_session.commit()
|
assert response.status_code == 404
|
||||||
|
|
||||||
client = next(_make_client(int_engine, _OWNER))
|
def test_create_name_too_long(self, client: TestClient):
|
||||||
resp = client.get("/api/saved-searches")
|
"""POST /api/saved-searches with name > 100 chars returns 422."""
|
||||||
assert resp.status_code == 200
|
payload = {
|
||||||
assert len(resp.json()) == 0
|
"name": "x" * 101,
|
||||||
|
"filters": {"status": "completed"},
|
||||||
|
}
|
||||||
|
response = client.post("/api/saved-searches", json=payload)
|
||||||
|
assert response.status_code == 422
|
||||||
|
|
||||||
other_search = int_session.query(SavedSearch).first()
|
def test_saved_search_filters_sanitized(self, client: TestClient):
|
||||||
resp = client.put(f"/api/saved-searches/{other_search.id}", json={"name": "Hacked"})
|
"""Saved search filters are sanitized to allowed keys only."""
|
||||||
assert resp.status_code == 404
|
payload = {
|
||||||
|
"name": "Sanitized",
|
||||||
|
"filters": {
|
||||||
|
"search": "invoice",
|
||||||
|
"mime_type": "application/pdf",
|
||||||
|
"date_from": "2026-01-01",
|
||||||
|
"date_to": "2026-12-31",
|
||||||
|
"storage_provider": "dropbox",
|
||||||
|
"tags": "invoice,amazon",
|
||||||
|
"sort_by": "created_at",
|
||||||
|
"sort_order": "desc",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
response = client.post("/api/saved-searches", json=payload)
|
||||||
|
assert response.status_code == 201
|
||||||
|
data = response.json()
|
||||||
|
assert len(data["filters"]) == 8
|
||||||
|
assert data["filters"]["search"] == "invoice"
|
||||||
|
assert data["filters"]["tags"] == "invoice,amazon"
|
||||||
|
|
||||||
resp = client.delete(f"/api/saved-searches/{other_search.id}")
|
def test_saved_search_with_fulltext_query(self, client: TestClient):
|
||||||
assert resp.status_code == 404
|
"""Saved search can include full-text query (q) for the search view."""
|
||||||
|
payload = {
|
||||||
|
"name": "Invoice Search",
|
||||||
|
"filters": {"q": "invoice total amount", "document_type": "Invoice"},
|
||||||
|
}
|
||||||
|
response = client.post("/api/saved-searches", json=payload)
|
||||||
|
assert response.status_code == 201
|
||||||
|
data = response.json()
|
||||||
|
assert data["filters"]["q"] == "invoice total amount"
|
||||||
|
assert data["filters"]["document_type"] == "Invoice"
|
||||||
|
|
||||||
|
def test_saved_search_content_finding_filters(self, client: TestClient):
|
||||||
|
"""Saved search accepts content-finding filter keys (language, sender, text_quality)."""
|
||||||
|
payload = {
|
||||||
|
"name": "German Invoices",
|
||||||
|
"filters": {
|
||||||
|
"q": "rechnung",
|
||||||
|
"language": "de",
|
||||||
|
"sender": "ACME GmbH",
|
||||||
|
"text_quality": "high",
|
||||||
|
"tags": "invoice",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
response = client.post("/api/saved-searches", json=payload)
|
||||||
|
assert response.status_code == 201
|
||||||
|
data = response.json()
|
||||||
|
assert data["filters"]["q"] == "rechnung"
|
||||||
|
assert data["filters"]["language"] == "de"
|
||||||
|
assert data["filters"]["sender"] == "ACME GmbH"
|
||||||
|
assert data["filters"]["text_quality"] == "high"
|
||||||
|
assert data["filters"]["tags"] == "invoice"
|
||||||
|
|
||||||
|
def test_create_saved_search_max_limit(self, client: TestClient, db_session, mocker):
|
||||||
|
"""POST /api/saved-searches returns 409 when max limit is reached."""
|
||||||
|
from app.api.saved_searches import MAX_SAVED_SEARCHES_PER_USER
|
||||||
|
|
||||||
|
user_id = "test_user"
|
||||||
|
mocker.patch("app.api.saved_searches._get_user_id", return_value=user_id)
|
||||||
|
|
||||||
|
for i in range(MAX_SAVED_SEARCHES_PER_USER):
|
||||||
|
search = SavedSearch(user_id=user_id, name=f"Search {i}", filters="""{"tags": "invoice"}""")
|
||||||
|
db_session.add(search)
|
||||||
|
db_session.commit()
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"name": "One More",
|
||||||
|
"filters": {"tags": "invoice"},
|
||||||
|
}
|
||||||
|
response = client.post("/api/saved-searches", json=payload)
|
||||||
|
assert response.status_code == 409
|
||||||
|
|
||||||
|
def test_create_saved_search_db_error(self, client: TestClient, mocker):
|
||||||
|
"""POST /api/saved-searches handles db.commit errors gracefully."""
|
||||||
|
mocker.patch("sqlalchemy.orm.Session.commit", side_effect=Exception("DB Error"))
|
||||||
|
payload = {
|
||||||
|
"name": "Fail Me",
|
||||||
|
"filters": {"tags": "invoice"},
|
||||||
|
}
|
||||||
|
response = client.post("/api/saved-searches", json=payload)
|
||||||
|
assert response.status_code == 500
|
||||||
|
|
||||||
|
def test_update_saved_search_db_error(self, client: TestClient, mocker):
|
||||||
|
"""PUT /api/saved-searches/{id} handles db.commit errors gracefully."""
|
||||||
|
# Create a search first
|
||||||
|
payload = {
|
||||||
|
"name": "Update Target",
|
||||||
|
"filters": {"tags": "invoice"},
|
||||||
|
}
|
||||||
|
response = client.post("/api/saved-searches", json=payload)
|
||||||
|
search_id = response.json()["id"]
|
||||||
|
|
||||||
|
mocker.patch("sqlalchemy.orm.Session.commit", side_effect=Exception("DB Error"))
|
||||||
|
update_payload = {"name": "New Name"}
|
||||||
|
response = client.put(f"/api/saved-searches/{search_id}", json=update_payload)
|
||||||
|
assert response.status_code == 500
|
||||||
|
|
||||||
|
def test_delete_saved_search_db_error(self, client: TestClient, mocker):
|
||||||
|
"""DELETE /api/saved-searches/{id} handles db.commit errors gracefully."""
|
||||||
|
# Create a search first
|
||||||
|
payload = {
|
||||||
|
"name": "Delete Target",
|
||||||
|
"filters": {"tags": "invoice"},
|
||||||
|
}
|
||||||
|
response = client.post("/api/saved-searches", json=payload)
|
||||||
|
search_id = response.json()["id"]
|
||||||
|
|
||||||
|
mocker.patch("sqlalchemy.orm.Session.commit", side_effect=Exception("DB Error"))
|
||||||
|
response = client.delete(f"/api/saved-searches/{search_id}")
|
||||||
|
assert response.status_code == 500
|
||||||
|
|
||||||
|
def test_update_saved_search_name_conflict(self, client: TestClient):
|
||||||
|
"""PUT /api/saved-searches/{id} returns 409 when the new name conflicts with an existing search."""
|
||||||
|
# Create search 1
|
||||||
|
payload1 = {"name": "Search One", "filters": {"tags": "invoice"}}
|
||||||
|
client.post("/api/saved-searches", json=payload1)
|
||||||
|
|
||||||
|
# Create search 2
|
||||||
|
payload2 = {"name": "Search Two", "filters": {"status": "completed"}}
|
||||||
|
response2 = client.post("/api/saved-searches", json=payload2)
|
||||||
|
search2_id = response2.json()["id"]
|
||||||
|
|
||||||
|
# Try to update search 2 to have name "Search One"
|
||||||
|
update_payload = {"name": "Search One"}
|
||||||
|
response = client.put(f"/api/saved-searches/{search2_id}", json=update_payload)
|
||||||
|
assert response.status_code == 409
|
||||||
|
|
||||||
|
def test_update_saved_search_empty_filters(self, client: TestClient):
|
||||||
|
"""PUT /api/saved-searches/{id} returns 422 if filters are empty or invalid."""
|
||||||
|
payload = {"name": "Search XYZ", "filters": {"tags": "invoice"}}
|
||||||
|
response = client.post("/api/saved-searches", json=payload)
|
||||||
|
search_id = response.json()["id"]
|
||||||
|
|
||||||
|
# Empty filters
|
||||||
|
update_payload = {"filters": {}}
|
||||||
|
response = client.put(f"/api/saved-searches/{search_id}", json=update_payload)
|
||||||
|
assert response.status_code == 422
|
||||||
|
|
||||||
|
# Invalid keys
|
||||||
|
update_payload = {"filters": {"invalid_key": "value"}}
|
||||||
|
response = client.put(f"/api/saved-searches/{search_id}", json=update_payload)
|
||||||
|
assert response.status_code == 422
|
||||||
|
|
||||||
|
def test_update_saved_search_invalid_name(self, client: TestClient):
|
||||||
|
"""PUT /api/saved-searches/{id} returns 422 if name is invalid or too long."""
|
||||||
|
payload = {"name": "Search XYZ", "filters": {"tags": "invoice"}}
|
||||||
|
response = client.post("/api/saved-searches", json=payload)
|
||||||
|
search_id = response.json()["id"]
|
||||||
|
|
||||||
|
# Empty name
|
||||||
|
update_payload = {"name": ""}
|
||||||
|
response = client.put(f"/api/saved-searches/{search_id}", json=update_payload)
|
||||||
|
assert response.status_code == 422
|
||||||
|
|
||||||
|
# Too long name
|
||||||
|
update_payload = {"name": "A" * 101}
|
||||||
|
response = client.put(f"/api/saved-searches/{search_id}", json=update_payload)
|
||||||
|
assert response.status_code == 422
|
||||||
|
|
||||||
|
def test_update_saved_search_same_name(self, client: TestClient):
|
||||||
|
"""PUT /api/saved-searches/{id} with the same name does not trigger duplicate check error."""
|
||||||
|
# Create a search
|
||||||
|
payload = {"name": "Same Name", "filters": {"tags": "invoice"}}
|
||||||
|
response = client.post("/api/saved-searches", json=payload)
|
||||||
|
search_id = response.json()["id"]
|
||||||
|
|
||||||
|
# Update with the exact same name
|
||||||
|
update_payload = {"name": "Same Name"}
|
||||||
|
response = client.put(f"/api/saved-searches/{search_id}", json=update_payload)
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json()["name"] == "Same Name"
|
||||||
|
|
||||||
|
def test_get_user_id_branches_real(self, client: TestClient):
|
||||||
|
from app.api.saved_searches import _get_user_id
|
||||||
|
|
||||||
|
# We need a mock request
|
||||||
|
class MockRequest:
|
||||||
|
session = {}
|
||||||
|
state = type("obj", (object,), {"user": None})
|
||||||
|
|
||||||
|
req = MockRequest()
|
||||||
|
assert _get_user_id(req) == "anonymous"
|
||||||
|
|
||||||
|
req.session["user"] = {"preferred_username": "pref"}
|
||||||
|
assert _get_user_id(req) == "pref"
|
||||||
|
|
||||||
|
req.session["user"] = {"email": "em@il.com"}
|
||||||
|
assert _get_user_id(req) == "em@il.com"
|
||||||
|
|
||||||
|
req.session["user"] = {"name": "named"}
|
||||||
|
assert _get_user_id(req) == "named"
|
||||||
|
|
||||||
|
req.session["user"] = {}
|
||||||
|
assert _get_user_id(req) == "anonymous"
|
||||||
|
|
||||||
|
def test_validate_filters_not_dict(self, client: TestClient):
|
||||||
|
"""POST /api/saved-searches with non-dict filters returns 422."""
|
||||||
|
payload = {
|
||||||
|
"name": "Invalid Filters",
|
||||||
|
"filters": "not a dict",
|
||||||
|
}
|
||||||
|
response = client.post("/api/saved-searches", json=payload)
|
||||||
|
assert response.status_code == 422
|
||||||
|
|
||||||
|
def test_create_saved_search_non_dict_filters(self, client: TestClient):
|
||||||
|
payload = {"name": "Test", "filters": []}
|
||||||
|
response = client.post("/api/saved-searches", json=payload)
|
||||||
|
assert response.status_code == 422
|
||||||
|
|
||||||
|
def test_update_saved_search_non_dict_filters(self, client: TestClient):
|
||||||
|
payload = {"name": "Test", "filters": {"tags": "invoice"}}
|
||||||
|
response = client.post("/api/saved-searches", json=payload)
|
||||||
|
search_id = response.json()["id"]
|
||||||
|
|
||||||
|
update_payload = {"filters": []}
|
||||||
|
response = client.put(f"/api/saved-searches/{search_id}", json=update_payload)
|
||||||
|
assert response.status_code == 422
|
||||||
|
|||||||
+3
-3
@@ -430,8 +430,8 @@ class TestLoginFunction:
|
|||||||
# Verify TemplateResponse was called with correct context
|
# Verify TemplateResponse was called with correct context
|
||||||
mock_templates.TemplateResponse.assert_called_once()
|
mock_templates.TemplateResponse.assert_called_once()
|
||||||
call_args = mock_templates.TemplateResponse.call_args
|
call_args = mock_templates.TemplateResponse.call_args
|
||||||
assert call_args[0][0] == "login.html"
|
assert call_args[0][1] == "login.html"
|
||||||
context = call_args[0][1]
|
context = call_args.kwargs["context"]
|
||||||
assert context["error"] == "Test error"
|
assert context["error"] == "Test error"
|
||||||
assert context["message"] == "Test message"
|
assert context["message"] == "Test message"
|
||||||
|
|
||||||
@@ -450,7 +450,7 @@ class TestLoginFunction:
|
|||||||
|
|
||||||
mock_templates.TemplateResponse.assert_called_once()
|
mock_templates.TemplateResponse.assert_called_once()
|
||||||
call_args = mock_templates.TemplateResponse.call_args
|
call_args = mock_templates.TemplateResponse.call_args
|
||||||
context = call_args[0][1]
|
context = call_args.kwargs["context"]
|
||||||
assert context["error"] is None
|
assert context["error"] is None
|
||||||
assert context["message"] is None
|
assert context["message"] is None
|
||||||
|
|
||||||
|
|||||||
@@ -281,7 +281,7 @@ class TestLoginEndpoint:
|
|||||||
# Verify template was rendered with OAuth enabled
|
# Verify template was rendered with OAuth enabled
|
||||||
mock_templates.TemplateResponse.assert_called_once()
|
mock_templates.TemplateResponse.assert_called_once()
|
||||||
call_args = mock_templates.TemplateResponse.call_args
|
call_args = mock_templates.TemplateResponse.call_args
|
||||||
context = call_args[0][1]
|
context = call_args.kwargs["context"]
|
||||||
assert context["show_oauth"] is True
|
assert context["show_oauth"] is True
|
||||||
assert context["oauth_provider_name"] == "Test SSO"
|
assert context["oauth_provider_name"] == "Test SSO"
|
||||||
|
|
||||||
|
|||||||
+169
-2
@@ -268,8 +268,6 @@ class TestConnectionsPageRoute:
|
|||||||
patch("app.views.settings.get_all_settings_from_db", return_value={}),
|
patch("app.views.settings.get_all_settings_from_db", return_value={}),
|
||||||
patch("app.views.settings.templates") as mock_templates,
|
patch("app.views.settings.templates") as mock_templates,
|
||||||
patch("app.views.settings.SETTING_METADATA", {}),
|
patch("app.views.settings.SETTING_METADATA", {}),
|
||||||
patch("app.auth.OAUTH_CONFIGURED", False),
|
|
||||||
patch("app.auth.SOCIAL_PROVIDERS", {}),
|
|
||||||
patch("app.views.settings.get_setting_metadata", return_value={}),
|
patch("app.views.settings.get_setting_metadata", return_value={}),
|
||||||
):
|
):
|
||||||
mock_templates.TemplateResponse.return_value = "response"
|
mock_templates.TemplateResponse.return_value = "response"
|
||||||
@@ -296,6 +294,175 @@ class TestConnectionsPageRoute:
|
|||||||
assert "smtp" in service_keys
|
assert "smtp" in service_keys
|
||||||
assert "telegram" in service_keys
|
assert "telegram" in service_keys
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_connections_page_linked_status_from_db(self):
|
||||||
|
"""Linked status is derived from DB/effective settings, not SOCIAL_PROVIDERS."""
|
||||||
|
from app.views.settings import connections_page
|
||||||
|
|
||||||
|
mock_request = MagicMock()
|
||||||
|
mock_request.session = {"user": {"is_admin": True}}
|
||||||
|
mock_db = MagicMock()
|
||||||
|
|
||||||
|
# Simulate GitHub configured only in DB (not in SOCIAL_PROVIDERS yet)
|
||||||
|
db_values = {
|
||||||
|
"social_auth_github_enabled": "true",
|
||||||
|
"social_auth_github_client_id": "gh-id",
|
||||||
|
"social_auth_github_client_secret": "gh-secret",
|
||||||
|
}
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("app.views.settings.get_all_settings_from_db", return_value=db_values),
|
||||||
|
patch("app.views.settings.templates") as mock_templates,
|
||||||
|
patch("app.views.settings.SETTING_METADATA", {}),
|
||||||
|
patch("app.views.settings.get_setting_metadata", return_value={}),
|
||||||
|
):
|
||||||
|
mock_templates.TemplateResponse.return_value = "response"
|
||||||
|
await connections_page(mock_request, db=mock_db)
|
||||||
|
|
||||||
|
context = mock_templates.TemplateResponse.call_args[0][1]
|
||||||
|
services_by_key = {s["key"]: s for s in context["services"]}
|
||||||
|
|
||||||
|
# GitHub should be linked because DB values say so
|
||||||
|
assert services_by_key["github"]["linked"] is True
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_connections_page_unlinked_when_credentials_missing(self):
|
||||||
|
"""Provider is unlinked when enabled=true but credentials are absent."""
|
||||||
|
from app.views.settings import connections_page
|
||||||
|
|
||||||
|
mock_request = MagicMock()
|
||||||
|
mock_request.session = {"user": {"is_admin": True}}
|
||||||
|
mock_db = MagicMock()
|
||||||
|
|
||||||
|
# enabled but no credentials
|
||||||
|
db_values = {"social_auth_github_enabled": "true"}
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("app.views.settings.get_all_settings_from_db", return_value=db_values),
|
||||||
|
patch("app.views.settings.templates") as mock_templates,
|
||||||
|
patch("app.views.settings.SETTING_METADATA", {}),
|
||||||
|
patch("app.views.settings.get_setting_metadata", return_value={}),
|
||||||
|
):
|
||||||
|
mock_templates.TemplateResponse.return_value = "response"
|
||||||
|
await connections_page(mock_request, db=mock_db)
|
||||||
|
|
||||||
|
context = mock_templates.TemplateResponse.call_args[0][1]
|
||||||
|
services_by_key = {s["key"]: s for s in context["services"]}
|
||||||
|
assert services_by_key["github"]["linked"] is False
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_connections_page_oidc_linked_from_db(self):
|
||||||
|
"""OIDC linked status derives from DB effective settings."""
|
||||||
|
from app.views.settings import connections_page
|
||||||
|
|
||||||
|
mock_request = MagicMock()
|
||||||
|
mock_request.session = {"user": {"is_admin": True}}
|
||||||
|
mock_db = MagicMock()
|
||||||
|
|
||||||
|
db_values = {
|
||||||
|
"authentik_client_id": "my-client-id",
|
||||||
|
"authentik_client_secret": "my-secret",
|
||||||
|
"oauth_provider_name": "My SSO",
|
||||||
|
}
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("app.views.settings.get_all_settings_from_db", return_value=db_values),
|
||||||
|
patch("app.views.settings.templates") as mock_templates,
|
||||||
|
patch("app.views.settings.SETTING_METADATA", {}),
|
||||||
|
patch("app.views.settings.get_setting_metadata", return_value={}),
|
||||||
|
):
|
||||||
|
mock_templates.TemplateResponse.return_value = "response"
|
||||||
|
await connections_page(mock_request, db=mock_db)
|
||||||
|
|
||||||
|
context = mock_templates.TemplateResponse.call_args[0][1]
|
||||||
|
services_by_key = {s["key"]: s for s in context["services"]}
|
||||||
|
assert services_by_key["oidc"]["linked"] is True
|
||||||
|
assert services_by_key["oidc"]["name"] == "My SSO"
|
||||||
|
# oauth_configured template var should also reflect the DB state
|
||||||
|
assert context["oauth_configured"] is True
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
class TestRefreshSocialProviders:
|
||||||
|
"""Tests for the refresh_social_providers() mechanism."""
|
||||||
|
|
||||||
|
def test_refresh_social_providers_exists(self):
|
||||||
|
"""refresh_social_providers is importable from app.auth."""
|
||||||
|
from app.auth import refresh_social_providers
|
||||||
|
|
||||||
|
assert callable(refresh_social_providers)
|
||||||
|
|
||||||
|
def test_refresh_social_providers_clears_and_repopulates(self):
|
||||||
|
"""After refresh, SOCIAL_PROVIDERS reflects current settings."""
|
||||||
|
import app.auth as auth_module
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch.object(auth_module, "AUTH_ENABLED", True),
|
||||||
|
patch.object(auth_module, "settings") as mock_settings,
|
||||||
|
):
|
||||||
|
mock_settings.authentik_client_id = None
|
||||||
|
mock_settings.authentik_client_secret = None
|
||||||
|
mock_settings.social_auth_google_enabled = True
|
||||||
|
mock_settings.social_auth_google_client_id = "gid"
|
||||||
|
mock_settings.social_auth_google_client_secret = "gsecret"
|
||||||
|
mock_settings.social_auth_google_use_global_credentials = False
|
||||||
|
# All other providers disabled
|
||||||
|
for attr in (
|
||||||
|
"social_auth_microsoft_enabled",
|
||||||
|
"social_auth_apple_enabled",
|
||||||
|
"social_auth_dropbox_enabled",
|
||||||
|
"social_auth_github_enabled",
|
||||||
|
"social_auth_keycloak_enabled",
|
||||||
|
"social_auth_generic_oauth2_enabled",
|
||||||
|
):
|
||||||
|
setattr(mock_settings, attr, False)
|
||||||
|
|
||||||
|
with patch.object(auth_module, "_register_oauth_client"):
|
||||||
|
auth_module._setup_social_providers()
|
||||||
|
|
||||||
|
assert "google" in auth_module.SOCIAL_PROVIDERS
|
||||||
|
assert auth_module.OAUTH_CONFIGURED is False
|
||||||
|
|
||||||
|
def test_refresh_clears_previous_providers(self):
|
||||||
|
"""Providers removed from settings are cleared after refresh."""
|
||||||
|
import app.auth as auth_module
|
||||||
|
|
||||||
|
# Pre-populate with a stale entry
|
||||||
|
auth_module.SOCIAL_PROVIDERS["stale_provider"] = {"name": "Stale", "icon": "", "color": ""}
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch.object(auth_module, "AUTH_ENABLED", True),
|
||||||
|
patch.object(auth_module, "settings") as mock_settings,
|
||||||
|
):
|
||||||
|
mock_settings.authentik_client_id = None
|
||||||
|
mock_settings.authentik_client_secret = None
|
||||||
|
for attr in (
|
||||||
|
"social_auth_google_enabled",
|
||||||
|
"social_auth_microsoft_enabled",
|
||||||
|
"social_auth_apple_enabled",
|
||||||
|
"social_auth_dropbox_enabled",
|
||||||
|
"social_auth_github_enabled",
|
||||||
|
"social_auth_keycloak_enabled",
|
||||||
|
"social_auth_generic_oauth2_enabled",
|
||||||
|
):
|
||||||
|
setattr(mock_settings, attr, False)
|
||||||
|
|
||||||
|
with patch.object(auth_module, "_register_oauth_client"):
|
||||||
|
auth_module._setup_social_providers()
|
||||||
|
|
||||||
|
assert "stale_provider" not in auth_module.SOCIAL_PROVIDERS
|
||||||
|
|
||||||
|
def test_register_oauth_client_clears_cache(self):
|
||||||
|
"""_register_oauth_client removes the cached client before re-registering."""
|
||||||
|
import app.auth as auth_module
|
||||||
|
|
||||||
|
# Inject a fake cached client
|
||||||
|
auth_module.oauth._clients["test_provider"] = object()
|
||||||
|
|
||||||
|
with patch.object(auth_module.oauth, "register"):
|
||||||
|
auth_module._register_oauth_client("test_provider", client_id="x", client_secret="y")
|
||||||
|
assert "test_provider" not in auth_module.oauth._clients
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.unit
|
@pytest.mark.unit
|
||||||
class TestTranslationKeys:
|
class TestTranslationKeys:
|
||||||
|
|||||||
@@ -520,14 +520,14 @@ class TestURLUploadAdditionalCoverage:
|
|||||||
assert exc_info.value.status_code == 400
|
assert exc_info.value.status_code == 400
|
||||||
|
|
||||||
def test_is_private_ip_unresolvable_hostname(self):
|
def test_is_private_ip_unresolvable_hostname(self):
|
||||||
"""Cover DNS resolution failure branch (lines 67-72)."""
|
"""Cover DNS resolution failure branch blocking unresolvable domains."""
|
||||||
import socket as _socket
|
import socket as _socket
|
||||||
|
|
||||||
from app.utils.network import is_private_ip
|
from app.utils.network import is_private_ip
|
||||||
|
|
||||||
with patch("socket.getaddrinfo", side_effect=_socket.gaierror("nope")):
|
with patch("socket.getaddrinfo", side_effect=_socket.gaierror("nope")):
|
||||||
result = is_private_ip("nonexistent.invalid.hostname.test")
|
result = is_private_ip("nonexistent.invalid.hostname.test")
|
||||||
assert result is False
|
assert result is True # Fail securely by returning True
|
||||||
|
|
||||||
def test_is_private_ip_hostname_resolves_to_private(self):
|
def test_is_private_ip_hostname_resolves_to_private(self):
|
||||||
"""Cover branch where hostname resolves to a private IP (line 64-65)."""
|
"""Cover branch where hostname resolves to a private IP (line 64-65)."""
|
||||||
|
|||||||
@@ -69,8 +69,8 @@ class TestViewsBase:
|
|||||||
context = {"request": req}
|
context = {"request": req}
|
||||||
template_response_with_version("template.html", context)
|
template_response_with_version("template.html", context)
|
||||||
|
|
||||||
args, _ = mock_orig.call_args
|
_, kwargs = mock_orig.call_args
|
||||||
assert args[1].get("csrf_token") == "my-csrf"
|
assert kwargs["context"].get("csrf_token") == "my-csrf"
|
||||||
|
|
||||||
def test_kwargs_context_no_request(self):
|
def test_kwargs_context_no_request(self):
|
||||||
"""Test kwargs context path when request is not in context."""
|
"""Test kwargs context path when request is not in context."""
|
||||||
|
|||||||
@@ -55,8 +55,8 @@ class TestDarkModeTemplateInjection:
|
|||||||
|
|
||||||
captured = {}
|
captured = {}
|
||||||
|
|
||||||
def fake_original(name, ctx, **kw):
|
def fake_original(request, name, **kw):
|
||||||
captured.update(ctx)
|
captured.update(kw.get("context", {}))
|
||||||
|
|
||||||
with patch("app.views.base.original_template_response", side_effect=fake_original):
|
with patch("app.views.base.original_template_response", side_effect=fake_original):
|
||||||
mock_request = MagicMock()
|
mock_request = MagicMock()
|
||||||
@@ -73,8 +73,8 @@ class TestDarkModeTemplateInjection:
|
|||||||
|
|
||||||
captured = {}
|
captured = {}
|
||||||
|
|
||||||
def fake_original(name, ctx, **kw):
|
def fake_original(request, name, **kw):
|
||||||
captured.update(ctx)
|
captured.update(kw.get("context", {}))
|
||||||
|
|
||||||
with patch("app.views.base.original_template_response", side_effect=fake_original):
|
with patch("app.views.base.original_template_response", side_effect=fake_original):
|
||||||
mock_request = MagicMock()
|
mock_request = MagicMock()
|
||||||
|
|||||||
@@ -335,7 +335,7 @@ class TestFileDetailBottomPreview:
|
|||||||
pdf.write_bytes(b"%PDF-1.4")
|
pdf.write_bytes(b"%PDF-1.4")
|
||||||
rec = _create_file_record(db_session, file_path=str(pdf), processed_path=str(pdf))
|
rec = _create_file_record(db_session, file_path=str(pdf), processed_path=str(pdf))
|
||||||
|
|
||||||
response = client.get(f"/files/{rec.id}/process")
|
response = client.get(f"/files/{rec.id}/detail")
|
||||||
html = response.text
|
html = response.text
|
||||||
assert f"/api/files/{rec.id}/download" in html
|
assert f"/api/files/{rec.id}/download" in html
|
||||||
|
|
||||||
@@ -350,7 +350,7 @@ class TestFileDetailBottomPreview:
|
|||||||
file_path=str(img),
|
file_path=str(img),
|
||||||
)
|
)
|
||||||
|
|
||||||
response = client.get(f"/files/{rec.id}/process")
|
response = client.get(f"/files/{rec.id}/detail")
|
||||||
html = response.text
|
html = response.text
|
||||||
assert f"/api/files/{rec.id}/preview?version=original" in html
|
assert f"/api/files/{rec.id}/preview?version=original" in html
|
||||||
|
|
||||||
|
|||||||
@@ -514,7 +514,7 @@ class TestFileDetailView:
|
|||||||
|
|
||||||
def test_file_detail_view_nonexistent(self, client: TestClient):
|
def test_file_detail_view_nonexistent(self, client: TestClient):
|
||||||
"""Test file detail view for nonexistent file."""
|
"""Test file detail view for nonexistent file."""
|
||||||
response = client.get("/files/99999/process")
|
response = client.get("/files/99999/detail")
|
||||||
assert response.status_code == 200 # Returns page with error message
|
assert response.status_code == 200 # Returns page with error message
|
||||||
assert b"not found" in response.content.lower()
|
assert b"not found" in response.content.lower()
|
||||||
|
|
||||||
|
|||||||
@@ -142,7 +142,7 @@ class TestFileDetailPage:
|
|||||||
db_session.commit()
|
db_session.commit()
|
||||||
|
|
||||||
# Test file detail page
|
# Test file detail page
|
||||||
response = client.get(f"/files/{file_record.id}/process")
|
response = client.get(f"/files/{file_record.id}/detail")
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
content = response.text
|
content = response.text
|
||||||
assert "test.pdf" in content
|
assert "test.pdf" in content
|
||||||
@@ -150,7 +150,7 @@ class TestFileDetailPage:
|
|||||||
def test_file_detail_page_with_missing_file(self, client: TestClient, db_session):
|
def test_file_detail_page_with_missing_file(self, client: TestClient, db_session):
|
||||||
"""Test file detail page with non-existent file"""
|
"""Test file detail page with non-existent file"""
|
||||||
# Try to access non-existent file
|
# Try to access non-existent file
|
||||||
response = client.get("/files/99999/process")
|
response = client.get("/files/99999/detail")
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
content = response.text
|
content = response.text
|
||||||
assert "not found" in content.lower()
|
assert "not found" in content.lower()
|
||||||
@@ -232,7 +232,7 @@ class TestFileDetailPage:
|
|||||||
db_session.commit()
|
db_session.commit()
|
||||||
|
|
||||||
# Test file detail page
|
# Test file detail page
|
||||||
response = client.get(f"/files/{file_record.id}/process")
|
response = client.get(f"/files/{file_record.id}/detail")
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
content = response.text
|
content = response.text
|
||||||
# Should show metadata
|
# Should show metadata
|
||||||
|
|||||||
@@ -0,0 +1,145 @@
|
|||||||
|
"""Tests for frontend build configuration and Docker build consistency.
|
||||||
|
|
||||||
|
Validates that the frontend build toolchain (Tailwind CSS) is correctly
|
||||||
|
configured in package.json and that the Dockerfile installs all required
|
||||||
|
dependencies for the build step.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
# Resolve the project root from the test file location
|
||||||
|
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
FRONTEND_DIR = PROJECT_ROOT / "frontend"
|
||||||
|
DOCKERFILE_PATH = PROJECT_ROOT / "Dockerfile"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
class TestFrontendPackageJson:
|
||||||
|
"""Validate frontend/package.json structure and scripts."""
|
||||||
|
|
||||||
|
def test_package_json_exists(self) -> None:
|
||||||
|
"""package.json must exist in the frontend directory."""
|
||||||
|
pkg_path = FRONTEND_DIR / "package.json"
|
||||||
|
assert pkg_path.exists(), "frontend/package.json not found"
|
||||||
|
|
||||||
|
def test_package_json_is_valid_json(self) -> None:
|
||||||
|
"""package.json must be parseable JSON."""
|
||||||
|
pkg_path = FRONTEND_DIR / "package.json"
|
||||||
|
data = json.loads(pkg_path.read_text(encoding="utf-8"))
|
||||||
|
assert isinstance(data, dict), "package.json must be a JSON object"
|
||||||
|
|
||||||
|
def test_build_script_defined(self) -> None:
|
||||||
|
"""A 'build' script must be defined in package.json."""
|
||||||
|
pkg_path = FRONTEND_DIR / "package.json"
|
||||||
|
data = json.loads(pkg_path.read_text(encoding="utf-8"))
|
||||||
|
scripts = data.get("scripts", {})
|
||||||
|
assert "build" in scripts, "Missing 'build' script in package.json"
|
||||||
|
|
||||||
|
def test_build_script_uses_tailwindcss(self) -> None:
|
||||||
|
"""The build script must invoke the tailwindcss CLI."""
|
||||||
|
pkg_path = FRONTEND_DIR / "package.json"
|
||||||
|
data = json.loads(pkg_path.read_text(encoding="utf-8"))
|
||||||
|
build_cmd = data["scripts"]["build"]
|
||||||
|
assert "tailwindcss" in build_cmd, f"Build script does not reference tailwindcss: {build_cmd}"
|
||||||
|
|
||||||
|
def test_tailwindcss_listed_as_dependency(self) -> None:
|
||||||
|
"""tailwindcss must be listed in dependencies or devDependencies."""
|
||||||
|
pkg_path = FRONTEND_DIR / "package.json"
|
||||||
|
data = json.loads(pkg_path.read_text(encoding="utf-8"))
|
||||||
|
deps = data.get("dependencies", {})
|
||||||
|
dev_deps = data.get("devDependencies", {})
|
||||||
|
all_deps = {**deps, **dev_deps}
|
||||||
|
assert "tailwindcss" in all_deps, "tailwindcss is not listed in dependencies or devDependencies"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
class TestFrontendBuildAssets:
|
||||||
|
"""Validate that required frontend build source files exist."""
|
||||||
|
|
||||||
|
def test_input_css_exists(self) -> None:
|
||||||
|
"""The Tailwind CSS input file must exist."""
|
||||||
|
input_css = FRONTEND_DIR / "input.css"
|
||||||
|
assert input_css.exists(), "frontend/input.css not found"
|
||||||
|
|
||||||
|
def test_input_css_has_tailwind_directives(self) -> None:
|
||||||
|
"""input.css must include Tailwind CSS directives."""
|
||||||
|
input_css = FRONTEND_DIR / "input.css"
|
||||||
|
content = input_css.read_text(encoding="utf-8")
|
||||||
|
assert "@tailwind base" in content, "Missing @tailwind base directive"
|
||||||
|
assert "@tailwind components" in content, "Missing @tailwind components directive"
|
||||||
|
assert "@tailwind utilities" in content, "Missing @tailwind utilities directive"
|
||||||
|
|
||||||
|
def test_tailwind_config_exists(self) -> None:
|
||||||
|
"""tailwind.config.js must exist in the frontend directory."""
|
||||||
|
config_path = FRONTEND_DIR / "tailwind.config.js"
|
||||||
|
assert config_path.exists(), "frontend/tailwind.config.js not found"
|
||||||
|
|
||||||
|
def test_package_lock_exists(self) -> None:
|
||||||
|
"""package-lock.json must exist for reproducible installs."""
|
||||||
|
lock_path = FRONTEND_DIR / "package-lock.json"
|
||||||
|
assert lock_path.exists(), "frontend/package-lock.json not found"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
class TestDockerfileFrontendBuilder:
|
||||||
|
"""Validate the Dockerfile frontend-builder stage installs build dependencies."""
|
||||||
|
|
||||||
|
def test_dockerfile_exists(self) -> None:
|
||||||
|
"""Production Dockerfile must exist at the project root."""
|
||||||
|
assert DOCKERFILE_PATH.exists(), "Dockerfile not found at project root"
|
||||||
|
|
||||||
|
def test_dockerfile_has_frontend_builder_stage(self) -> None:
|
||||||
|
"""Dockerfile must define a frontend-builder stage."""
|
||||||
|
content = DOCKERFILE_PATH.read_text(encoding="utf-8")
|
||||||
|
assert "AS frontend-builder" in content, "Dockerfile does not define a frontend-builder stage"
|
||||||
|
|
||||||
|
def test_dockerfile_npm_ci_does_not_omit_dev(self) -> None:
|
||||||
|
"""npm ci must NOT use --omit=dev in the frontend-builder stage.
|
||||||
|
|
||||||
|
The tailwindcss CLI is a devDependency required at build time.
|
||||||
|
Using --omit=dev would skip installing it, causing the build to
|
||||||
|
fail with 'tailwindcss: not found'.
|
||||||
|
"""
|
||||||
|
content = DOCKERFILE_PATH.read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
# Extract the frontend-builder stage content
|
||||||
|
# Look for the stage start and the next stage (or end of file)
|
||||||
|
stage_pattern = re.compile(
|
||||||
|
r"FROM\s+\S+\s+AS\s+frontend-builder\b(.*?)(?=FROM\s|\Z)",
|
||||||
|
re.DOTALL,
|
||||||
|
)
|
||||||
|
match = stage_pattern.search(content)
|
||||||
|
assert match is not None, "Could not find frontend-builder stage in Dockerfile"
|
||||||
|
|
||||||
|
stage_content = match.group(1)
|
||||||
|
assert "--omit=dev" not in stage_content, (
|
||||||
|
"Dockerfile frontend-builder stage uses 'npm ci --omit=dev' which "
|
||||||
|
"excludes tailwindcss (a devDependency) needed for the build step. "
|
||||||
|
"Use 'npm ci' instead to install all dependencies."
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_dockerfile_runs_npm_build(self) -> None:
|
||||||
|
"""Dockerfile frontend-builder stage must run npm run build."""
|
||||||
|
content = DOCKERFILE_PATH.read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
stage_pattern = re.compile(
|
||||||
|
r"FROM\s+\S+\s+AS\s+frontend-builder\b(.*?)(?=FROM\s|\Z)",
|
||||||
|
re.DOTALL,
|
||||||
|
)
|
||||||
|
match = stage_pattern.search(content)
|
||||||
|
assert match is not None, "Could not find frontend-builder stage in Dockerfile"
|
||||||
|
|
||||||
|
stage_content = match.group(1)
|
||||||
|
assert "npm run build" in stage_content, "Dockerfile frontend-builder stage does not run 'npm run build'"
|
||||||
|
|
||||||
|
def test_dockerfile_copies_compiled_css(self) -> None:
|
||||||
|
"""Dockerfile must copy the compiled styles.css from the frontend-builder stage."""
|
||||||
|
content = DOCKERFILE_PATH.read_text(encoding="utf-8")
|
||||||
|
assert "COPY --from=frontend-builder" in content, (
|
||||||
|
"Dockerfile does not copy assets from the frontend-builder stage"
|
||||||
|
)
|
||||||
|
assert "styles.css" in content, "Dockerfile does not reference the compiled styles.css"
|
||||||
@@ -502,6 +502,7 @@ class TestPullAllInboxes:
|
|||||||
class TestPullInbox:
|
class TestPullInbox:
|
||||||
"""Tests for pull_inbox function."""
|
"""Tests for pull_inbox function."""
|
||||||
|
|
||||||
|
@patch("app.tasks.imap_tasks.is_private_ip", new=lambda _: False)
|
||||||
@patch("app.tasks.imap_tasks.imaplib.IMAP4_SSL")
|
@patch("app.tasks.imap_tasks.imaplib.IMAP4_SSL")
|
||||||
@patch("app.tasks.imap_tasks.load_processed_emails")
|
@patch("app.tasks.imap_tasks.load_processed_emails")
|
||||||
@patch("app.tasks.imap_tasks.save_processed_emails")
|
@patch("app.tasks.imap_tasks.save_processed_emails")
|
||||||
@@ -531,6 +532,7 @@ class TestPullInbox:
|
|||||||
mock_mail.close.assert_called_once()
|
mock_mail.close.assert_called_once()
|
||||||
mock_mail.logout.assert_called_once()
|
mock_mail.logout.assert_called_once()
|
||||||
|
|
||||||
|
@patch("app.tasks.imap_tasks.is_private_ip", new=lambda _: False)
|
||||||
@patch("app.tasks.imap_tasks.imaplib.IMAP4")
|
@patch("app.tasks.imap_tasks.imaplib.IMAP4")
|
||||||
@patch("app.tasks.imap_tasks.load_processed_emails")
|
@patch("app.tasks.imap_tasks.load_processed_emails")
|
||||||
def test_non_ssl_connection(self, mock_load, mock_imap_class):
|
def test_non_ssl_connection(self, mock_load, mock_imap_class):
|
||||||
@@ -609,6 +611,7 @@ class TestPullInbox:
|
|||||||
# Should select INBOX as fallback
|
# Should select INBOX as fallback
|
||||||
assert any(call_args[0][0] == "INBOX" for call_args in mock_mail.select.call_args_list)
|
assert any(call_args[0][0] == "INBOX" for call_args in mock_mail.select.call_args_list)
|
||||||
|
|
||||||
|
@patch("app.tasks.imap_tasks.is_private_ip", new=lambda _: False)
|
||||||
@patch("app.tasks.imap_tasks.imaplib.IMAP4_SSL")
|
@patch("app.tasks.imap_tasks.imaplib.IMAP4_SSL")
|
||||||
@patch("app.tasks.imap_tasks.load_processed_emails")
|
@patch("app.tasks.imap_tasks.load_processed_emails")
|
||||||
def test_search_failure_handling(self, mock_load, mock_imap_class):
|
def test_search_failure_handling(self, mock_load, mock_imap_class):
|
||||||
@@ -635,6 +638,7 @@ class TestPullInbox:
|
|||||||
mock_mail.close.assert_called_once()
|
mock_mail.close.assert_called_once()
|
||||||
mock_mail.logout.assert_called_once()
|
mock_mail.logout.assert_called_once()
|
||||||
|
|
||||||
|
@patch("app.tasks.imap_tasks.is_private_ip", new=lambda _: False)
|
||||||
@patch("app.tasks.imap_tasks.fetch_attachments_and_enqueue")
|
@patch("app.tasks.imap_tasks.fetch_attachments_and_enqueue")
|
||||||
@patch("app.tasks.imap_tasks.imaplib.IMAP4_SSL")
|
@patch("app.tasks.imap_tasks.imaplib.IMAP4_SSL")
|
||||||
@patch("app.tasks.imap_tasks.load_processed_emails")
|
@patch("app.tasks.imap_tasks.load_processed_emails")
|
||||||
@@ -677,12 +681,15 @@ class TestPullInbox:
|
|||||||
mock_mail.store.assert_called_with(b"1", "-FLAGS", "\\Seen")
|
mock_mail.store.assert_called_with(b"1", "-FLAGS", "\\Seen")
|
||||||
mock_save.assert_called()
|
mock_save.assert_called()
|
||||||
|
|
||||||
|
@patch("app.tasks.imap_tasks.is_private_ip", return_value=False)
|
||||||
@patch("app.tasks.imap_tasks.fetch_attachments_and_enqueue")
|
@patch("app.tasks.imap_tasks.fetch_attachments_and_enqueue")
|
||||||
@patch("app.tasks.imap_tasks.imaplib.IMAP4_SSL")
|
@patch("app.tasks.imap_tasks.imaplib.IMAP4_SSL")
|
||||||
@patch("app.tasks.imap_tasks.load_processed_emails")
|
@patch("app.tasks.imap_tasks.load_processed_emails")
|
||||||
@patch("app.tasks.imap_tasks.save_processed_emails")
|
@patch("app.tasks.imap_tasks.save_processed_emails")
|
||||||
@patch("app.tasks.imap_tasks.settings")
|
@patch("app.tasks.imap_tasks.settings")
|
||||||
def test_delete_after_process(self, mock_settings, mock_save, mock_load, mock_imap_class, mock_fetch):
|
def test_delete_after_process(
|
||||||
|
self, mock_settings, mock_save, mock_load, mock_imap_class, mock_fetch, _mock_private_ip
|
||||||
|
):
|
||||||
"""Test deleting messages after processing."""
|
"""Test deleting messages after processing."""
|
||||||
mock_settings.workdir = "/tmp"
|
mock_settings.workdir = "/tmp"
|
||||||
mock_settings.imap_readonly_mode = False
|
mock_settings.imap_readonly_mode = False
|
||||||
@@ -920,9 +927,10 @@ class TestPullInbox:
|
|||||||
# Should not process the message
|
# Should not process the message
|
||||||
mock_mail.store.assert_not_called()
|
mock_mail.store.assert_not_called()
|
||||||
|
|
||||||
|
@patch("app.tasks.imap_tasks.is_private_ip", return_value=False)
|
||||||
@patch("app.tasks.imap_tasks.imaplib.IMAP4_SSL")
|
@patch("app.tasks.imap_tasks.imaplib.IMAP4_SSL")
|
||||||
@patch("app.tasks.imap_tasks.load_processed_emails")
|
@patch("app.tasks.imap_tasks.load_processed_emails")
|
||||||
def test_handles_fetch_failure(self, mock_load, mock_imap_class):
|
def test_handles_fetch_failure(self, mock_load, mock_imap_class, _mock_private_ip):
|
||||||
"""Test handling of message fetch failure."""
|
"""Test handling of message fetch failure."""
|
||||||
mock_load.return_value = {}
|
mock_load.return_value = {}
|
||||||
mock_mail = MagicMock()
|
mock_mail = MagicMock()
|
||||||
@@ -1022,12 +1030,15 @@ class TestPullInbox:
|
|||||||
# Processed emails cache should still be updated
|
# Processed emails cache should still be updated
|
||||||
mock_save.assert_called()
|
mock_save.assert_called()
|
||||||
|
|
||||||
|
@patch("app.tasks.imap_tasks.is_private_ip", return_value=False)
|
||||||
@patch("app.tasks.imap_tasks.fetch_attachments_and_enqueue")
|
@patch("app.tasks.imap_tasks.fetch_attachments_and_enqueue")
|
||||||
@patch("app.tasks.imap_tasks.imaplib.IMAP4_SSL")
|
@patch("app.tasks.imap_tasks.imaplib.IMAP4_SSL")
|
||||||
@patch("app.tasks.imap_tasks.load_processed_emails")
|
@patch("app.tasks.imap_tasks.load_processed_emails")
|
||||||
@patch("app.tasks.imap_tasks.save_processed_emails")
|
@patch("app.tasks.imap_tasks.save_processed_emails")
|
||||||
@patch("app.tasks.imap_tasks.settings")
|
@patch("app.tasks.imap_tasks.settings")
|
||||||
def test_readonly_mode_skips_delete(self, mock_settings, mock_save, mock_load, mock_imap_class, mock_fetch):
|
def test_readonly_mode_skips_delete(
|
||||||
|
self, mock_settings, mock_save, mock_load, mock_imap_class, mock_fetch, _mock_private_ip
|
||||||
|
):
|
||||||
"""Test that readonly mode skips deletion even when delete_after_process is True."""
|
"""Test that readonly mode skips deletion even when delete_after_process is True."""
|
||||||
mock_settings.workdir = "/tmp"
|
mock_settings.workdir = "/tmp"
|
||||||
mock_settings.imap_readonly_mode = True
|
mock_settings.imap_readonly_mode = True
|
||||||
@@ -1381,10 +1392,11 @@ class TestAcquireReleaseLockEdgeCases:
|
|||||||
class TestPullInboxEdgeCases:
|
class TestPullInboxEdgeCases:
|
||||||
"""Test edge cases for pull_inbox function."""
|
"""Test edge cases for pull_inbox function."""
|
||||||
|
|
||||||
|
@patch("app.tasks.imap_tasks.is_private_ip", return_value=False)
|
||||||
@patch("app.tasks.imap_tasks.load_processed_emails")
|
@patch("app.tasks.imap_tasks.load_processed_emails")
|
||||||
@patch("app.tasks.imap_tasks.imaplib.IMAP4_SSL")
|
@patch("app.tasks.imap_tasks.imaplib.IMAP4_SSL")
|
||||||
@patch("app.tasks.imap_tasks.settings")
|
@patch("app.tasks.imap_tasks.settings")
|
||||||
def test_pull_inbox_search_failed_status(self, mock_settings, mock_imap_class, mock_load):
|
def test_pull_inbox_search_failed_status(self, mock_settings, mock_imap_class, mock_load, _mock_private_ip):
|
||||||
"""Test pull_inbox when search returns non-OK status."""
|
"""Test pull_inbox when search returns non-OK status."""
|
||||||
mock_settings.workdir = "/tmp"
|
mock_settings.workdir = "/tmp"
|
||||||
mock_load.return_value = {}
|
mock_load.return_value = {}
|
||||||
@@ -1431,10 +1443,11 @@ class TestPullInboxEdgeCases:
|
|||||||
# Should skip processing since no Message-ID
|
# Should skip processing since no Message-ID
|
||||||
mock_fetch.assert_not_called()
|
mock_fetch.assert_not_called()
|
||||||
|
|
||||||
|
@patch("app.tasks.imap_tasks.is_private_ip", return_value=False)
|
||||||
@patch("app.tasks.imap_tasks.load_processed_emails")
|
@patch("app.tasks.imap_tasks.load_processed_emails")
|
||||||
@patch("app.tasks.imap_tasks.imaplib.IMAP4_SSL")
|
@patch("app.tasks.imap_tasks.imaplib.IMAP4_SSL")
|
||||||
@patch("app.tasks.imap_tasks.settings")
|
@patch("app.tasks.imap_tasks.settings")
|
||||||
def test_pull_inbox_fetch_failed_status(self, mock_settings, mock_imap_class, mock_load):
|
def test_pull_inbox_fetch_failed_status(self, mock_settings, mock_imap_class, mock_load, _mock_private_ip):
|
||||||
"""Test pull_inbox when fetch returns non-OK status."""
|
"""Test pull_inbox when fetch returns non-OK status."""
|
||||||
mock_settings.workdir = "/tmp"
|
mock_settings.workdir = "/tmp"
|
||||||
mock_load.return_value = {}
|
mock_load.return_value = {}
|
||||||
|
|||||||
@@ -145,6 +145,58 @@ class TestLifespanEvents:
|
|||||||
# load_settings_from_db must also have been called
|
# load_settings_from_db must also have been called
|
||||||
mock_load_settings.assert_called_once()
|
mock_load_settings.assert_called_once()
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_lifespan_shutdown_logging_exception_is_silenced(self):
|
||||||
|
"""Exceptions raised by logging.info during shutdown are silently ignored."""
|
||||||
|
|
||||||
|
def _raise_on_shutdown(msg, *args, **kwargs):
|
||||||
|
if "shutting down" in str(msg):
|
||||||
|
raise OSError("stream closed")
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("app.database.init_db"),
|
||||||
|
patch("app.database.SessionLocal") as mock_session_cls,
|
||||||
|
patch("app.utils.config_loader.load_settings_from_db"),
|
||||||
|
patch("app.utils.config_validator.dump_all_settings"),
|
||||||
|
patch("app.utils.config_validator.check_all_configs", return_value={"email": [], "storage": {}}),
|
||||||
|
patch("app.utils.notification.init_apprise"),
|
||||||
|
patch("app.utils.notification.notify_startup"),
|
||||||
|
patch("app.utils.notification.notify_shutdown"),
|
||||||
|
patch("app.main.init_sentry"),
|
||||||
|
patch("app.main.logging.info", side_effect=_raise_on_shutdown),
|
||||||
|
):
|
||||||
|
mock_db = MagicMock()
|
||||||
|
mock_session_cls.return_value = mock_db
|
||||||
|
|
||||||
|
from app.main import app, lifespan
|
||||||
|
|
||||||
|
# Should complete without raising despite the logging error
|
||||||
|
async with lifespan(app):
|
||||||
|
pass
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_lifespan_shutdown_notify_exception_is_silenced(self):
|
||||||
|
"""Exceptions raised by notify_shutdown during shutdown are silently ignored."""
|
||||||
|
with (
|
||||||
|
patch("app.database.init_db"),
|
||||||
|
patch("app.database.SessionLocal") as mock_session_cls,
|
||||||
|
patch("app.utils.config_loader.load_settings_from_db"),
|
||||||
|
patch("app.utils.config_validator.dump_all_settings"),
|
||||||
|
patch("app.utils.config_validator.check_all_configs", return_value={"email": [], "storage": {}}),
|
||||||
|
patch("app.utils.notification.init_apprise"),
|
||||||
|
patch("app.utils.notification.notify_startup"),
|
||||||
|
patch("app.main.notify_shutdown", side_effect=OSError("stream closed")),
|
||||||
|
patch("app.main.init_sentry"),
|
||||||
|
):
|
||||||
|
mock_db = MagicMock()
|
||||||
|
mock_session_cls.return_value = mock_db
|
||||||
|
|
||||||
|
from app.main import app, lifespan
|
||||||
|
|
||||||
|
# Should complete without raising despite the notify_shutdown error
|
||||||
|
async with lifespan(app):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.unit
|
@pytest.mark.unit
|
||||||
class TestExceptionHandlers:
|
class TestExceptionHandlers:
|
||||||
|
|||||||
@@ -345,7 +345,7 @@ class TestLoginPageSocialProviders:
|
|||||||
|
|
||||||
mock_templates.TemplateResponse.assert_called_once()
|
mock_templates.TemplateResponse.assert_called_once()
|
||||||
call_args = mock_templates.TemplateResponse.call_args
|
call_args = mock_templates.TemplateResponse.call_args
|
||||||
context = call_args[0][1]
|
context = call_args.kwargs["context"]
|
||||||
assert context["social_providers"] == mock_providers
|
assert context["social_providers"] == mock_providers
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@@ -371,7 +371,7 @@ class TestLoginPageSocialProviders:
|
|||||||
|
|
||||||
mock_templates.TemplateResponse.assert_called_once()
|
mock_templates.TemplateResponse.assert_called_once()
|
||||||
call_args = mock_templates.TemplateResponse.call_args
|
call_args = mock_templates.TemplateResponse.call_args
|
||||||
context = call_args[0][1]
|
context = call_args.kwargs["context"]
|
||||||
assert context["social_providers"] == {}
|
assert context["social_providers"] == {}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -769,11 +769,6 @@ class TestUploadRclone:
|
|||||||
cmd = mock_run.call_args[0][0]
|
cmd = mock_run.call_args[0][0]
|
||||||
assert cmd[0] == "rclone"
|
assert cmd[0] == "rclone"
|
||||||
assert cmd[1] == "copyto"
|
assert cmd[1] == "copyto"
|
||||||
# SECURITY: Verify `--` end-of-options separator is present and precedes
|
|
||||||
# the file path and destination to prevent option/argument injection.
|
|
||||||
assert "--" in cmd
|
|
||||||
fp_index = next(i for i, v in enumerate(cmd) if v == fp)
|
|
||||||
assert cmd.index("--") < fp_index
|
|
||||||
|
|
||||||
def test_raises_on_rclone_nonzero_exit(self, tmp_path):
|
def test_raises_on_rclone_nonzero_exit(self, tmp_path):
|
||||||
fp = str(tmp_path / "doc.pdf")
|
fp = str(tmp_path / "doc.pdf")
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.tasks.upload_to_nextcloud import upload_to_nextcloud
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_settings(tmp_path):
|
||||||
|
with patch("app.tasks.upload_to_nextcloud.settings") as mock:
|
||||||
|
mock.nextcloud_upload_url = "http://nextcloud.local/"
|
||||||
|
mock.nextcloud_username = "testuser"
|
||||||
|
mock.nextcloud_password = "testpassword"
|
||||||
|
mock.nextcloud_folder = "uploads"
|
||||||
|
mock.workdir = str(tmp_path)
|
||||||
|
mock.http_request_timeout = 30
|
||||||
|
yield mock
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_requests():
|
||||||
|
with patch("app.tasks.upload_to_nextcloud.requests") as mock:
|
||||||
|
# Mock PROPFIND to always return false (file doesn't exist)
|
||||||
|
mock.request.return_value = MagicMock(text="<response></response>")
|
||||||
|
|
||||||
|
# Mock PUT to return success
|
||||||
|
put_response = MagicMock()
|
||||||
|
put_response.status_code = 201
|
||||||
|
mock.put.return_value = put_response
|
||||||
|
yield mock
|
||||||
|
|
||||||
|
|
||||||
|
def test_upload_to_nextcloud_url_construction(tmp_path, mock_settings, mock_requests):
|
||||||
|
file_path = str(tmp_path / "test_file.txt")
|
||||||
|
|
||||||
|
# Create dummy file
|
||||||
|
with open(file_path, "w") as f:
|
||||||
|
f.write("test content")
|
||||||
|
|
||||||
|
# Call the task directly
|
||||||
|
with patch("celery.app.task.Task.request", new_callable=MagicMock) as mock_req:
|
||||||
|
mock_req.id = "test-task-123"
|
||||||
|
result = upload_to_nextcloud(file_path)
|
||||||
|
|
||||||
|
assert result["status"] == "Completed"
|
||||||
|
assert result["nextcloud_path"] == "uploads/test_file.txt"
|
||||||
|
|
||||||
|
# Verify requests.put was called with the correct URL
|
||||||
|
mock_requests.put.assert_called_once()
|
||||||
|
args, kwargs = mock_requests.put.call_args
|
||||||
|
url = args[0]
|
||||||
|
assert url == "http://nextcloud.local/uploads/test_file.txt"
|
||||||
@@ -465,6 +465,19 @@ class TestURLUploadEndpoint:
|
|||||||
data = response.json()
|
data = response.json()
|
||||||
assert "Failed to download file" in data["detail"]
|
assert "Failed to download file" in data["detail"]
|
||||||
|
|
||||||
|
@patch("app.api.url_upload.httpx.AsyncClient.stream")
|
||||||
|
def test_process_url_unsafe_redirect_returns_400(self, mock_stream, client):
|
||||||
|
"""Test unsafe redirects are reported as a client error instead of HTTP 500."""
|
||||||
|
from app.api.url_upload import UnsafeRedirectError
|
||||||
|
|
||||||
|
mock_stream.side_effect = UnsafeRedirectError("Redirect to unsafe URL blocked: Unsafe URL")
|
||||||
|
|
||||||
|
response = client.post("/api/process-url", json={"url": "https://example.com/file.pdf"})
|
||||||
|
|
||||||
|
assert response.status_code == 400
|
||||||
|
data = response.json()
|
||||||
|
assert "Redirect to unsafe URL blocked" in data["detail"]
|
||||||
|
|
||||||
@patch("app.api.url_upload.httpx.AsyncClient.stream")
|
@patch("app.api.url_upload.httpx.AsyncClient.stream")
|
||||||
def test_process_url_oserror_during_save(self, mock_stream, client, tmp_path, monkeypatch):
|
def test_process_url_oserror_during_save(self, mock_stream, client, tmp_path, monkeypatch):
|
||||||
"""Test handling of OSError when saving file"""
|
"""Test handling of OSError when saving file"""
|
||||||
@@ -698,6 +711,18 @@ class TestURLUploadCoverageGaps:
|
|||||||
assert result is False
|
assert result is False
|
||||||
mock_getaddrinfo.assert_called_once()
|
mock_getaddrinfo.assert_called_once()
|
||||||
|
|
||||||
|
@patch("app.utils.network.socket.getaddrinfo")
|
||||||
|
def test_is_private_ip_unresolvable_hostname_fails_securely(self, mock_getaddrinfo):
|
||||||
|
"""Test that unresolvable hostnames fail securely by blocking access."""
|
||||||
|
import socket
|
||||||
|
|
||||||
|
from app.utils.network import is_private_ip
|
||||||
|
|
||||||
|
mock_getaddrinfo.side_effect = socket.gaierror("Name or service not known")
|
||||||
|
|
||||||
|
result = is_private_ip("unresolvable.example.internal")
|
||||||
|
assert result is True # Fails securely
|
||||||
|
|
||||||
@patch("socket.getaddrinfo")
|
@patch("socket.getaddrinfo")
|
||||||
def test_is_private_ip_hostname_resolves_multiple_ips_all_public(self, mock_getaddrinfo):
|
def test_is_private_ip_hostname_resolves_multiple_ips_all_public(self, mock_getaddrinfo):
|
||||||
"""Test hostname with multiple public IPs returns False (covers 65->61 loop branch)"""
|
"""Test hostname with multiple public IPs returns False (covers 65->61 loop branch)"""
|
||||||
@@ -867,3 +892,48 @@ class TestURLUploadCoverageGaps:
|
|||||||
# Generic exception (not HTTPException/OSError/RequestException) is caught and returns 500
|
# Generic exception (not HTTPException/OSError/RequestException) is caught and returns 500
|
||||||
assert response.status_code == 500
|
assert response.status_code == 500
|
||||||
assert "Unexpected error" in response.json()["detail"]
|
assert "Unexpected error" in response.json()["detail"]
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_verify_redirect_allows_safe_url(self):
|
||||||
|
"""Test verify_redirect allows safe redirects (lines 115, 118, 120-121)"""
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from app.api.url_upload import verify_redirect
|
||||||
|
|
||||||
|
req = httpx.Request("GET", "http://example.com")
|
||||||
|
resp = httpx.Response(301, headers={"Location": "https://google.com"}, request=req)
|
||||||
|
|
||||||
|
# Should not raise any exception
|
||||||
|
await verify_redirect(resp)
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@patch("app.api.url_upload.validate_url_safety")
|
||||||
|
async def test_verify_redirect_blocks_unsafe_url(self, mock_validate):
|
||||||
|
"""Test verify_redirect blocks unsafe redirects (lines 122-125)"""
|
||||||
|
import httpx
|
||||||
|
from fastapi import HTTPException
|
||||||
|
|
||||||
|
from app.api.url_upload import verify_redirect
|
||||||
|
|
||||||
|
mock_validate.side_effect = HTTPException(status_code=400, detail="Unsafe URL")
|
||||||
|
|
||||||
|
req = httpx.Request("GET", "http://example.com")
|
||||||
|
resp = httpx.Response(301, headers={"Location": "http://127.0.0.1"}, request=req)
|
||||||
|
|
||||||
|
with pytest.raises(httpx.RequestError) as exc_info:
|
||||||
|
await verify_redirect(resp)
|
||||||
|
|
||||||
|
assert "Redirect to unsafe URL blocked" in str(exc_info.value)
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_verify_redirect_ignores_non_redirects(self):
|
||||||
|
"""Test verify_redirect ignores 200 OK responses"""
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from app.api.url_upload import verify_redirect
|
||||||
|
|
||||||
|
req = httpx.Request("GET", "http://example.com")
|
||||||
|
resp = httpx.Response(200, request=req)
|
||||||
|
|
||||||
|
# Should not raise any exception and should ignore missing Location header
|
||||||
|
await verify_redirect(resp)
|
||||||
|
|||||||
@@ -235,7 +235,10 @@ class TestSendWebhookNotification:
|
|||||||
mock_response.status_code = 200
|
mock_response.status_code = 200
|
||||||
mock_response.raise_for_status = MagicMock()
|
mock_response.raise_for_status = MagicMock()
|
||||||
|
|
||||||
with patch("app.utils.user_notification.httpx.post", return_value=mock_response) as mock_post:
|
with (
|
||||||
|
patch("app.utils.user_notification.is_private_ip", return_value=False),
|
||||||
|
patch("app.utils.user_notification.httpx.post", return_value=mock_response) as mock_post,
|
||||||
|
):
|
||||||
result = _send_webhook_notification(
|
result = _send_webhook_notification(
|
||||||
{"url": "https://hook.example.com/test", "secret": "mysecret"},
|
{"url": "https://hook.example.com/test", "secret": "mysecret"},
|
||||||
"document.processed",
|
"document.processed",
|
||||||
@@ -256,7 +259,10 @@ class TestSendWebhookNotification:
|
|||||||
mock_response.status_code = 200
|
mock_response.status_code = 200
|
||||||
mock_response.raise_for_status = MagicMock()
|
mock_response.raise_for_status = MagicMock()
|
||||||
|
|
||||||
with patch("app.utils.user_notification.httpx.post", return_value=mock_response) as mock_post:
|
with (
|
||||||
|
patch("app.utils.user_notification.is_private_ip", return_value=False),
|
||||||
|
patch("app.utils.user_notification.httpx.post", return_value=mock_response) as mock_post,
|
||||||
|
):
|
||||||
result = _send_webhook_notification(
|
result = _send_webhook_notification(
|
||||||
{"url": "https://hook.example.com/test"},
|
{"url": "https://hook.example.com/test"},
|
||||||
"document.failed",
|
"document.failed",
|
||||||
@@ -272,9 +278,12 @@ class TestSendWebhookNotification:
|
|||||||
"""_send_webhook_notification returns False when httpx raises."""
|
"""_send_webhook_notification returns False when httpx raises."""
|
||||||
from app.utils.user_notification import _send_webhook_notification
|
from app.utils.user_notification import _send_webhook_notification
|
||||||
|
|
||||||
with patch(
|
with (
|
||||||
"app.utils.user_notification.httpx.post",
|
patch("app.utils.user_notification.is_private_ip", return_value=False),
|
||||||
side_effect=Exception("connection error"),
|
patch(
|
||||||
|
"app.utils.user_notification.httpx.post",
|
||||||
|
side_effect=Exception("connection error"),
|
||||||
|
),
|
||||||
):
|
):
|
||||||
result = _send_webhook_notification(
|
result = _send_webhook_notification(
|
||||||
{"url": "https://hook.example.com/test"},
|
{"url": "https://hook.example.com/test"},
|
||||||
@@ -300,7 +309,10 @@ class TestSendWebhookNotification:
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
with patch("app.utils.user_notification.httpx.post", return_value=mock_response):
|
with (
|
||||||
|
patch("app.utils.user_notification.is_private_ip", return_value=False),
|
||||||
|
patch("app.utils.user_notification.httpx.post", return_value=mock_response),
|
||||||
|
):
|
||||||
result = _send_webhook_notification(
|
result = _send_webhook_notification(
|
||||||
{"url": "https://hook.example.com/test"},
|
{"url": "https://hook.example.com/test"},
|
||||||
"document.processed",
|
"document.processed",
|
||||||
@@ -310,6 +322,69 @@ class TestSendWebhookNotification:
|
|||||||
|
|
||||||
assert result is False
|
assert result is False
|
||||||
|
|
||||||
|
def test_blocks_private_webhook_target(self):
|
||||||
|
"""Webhook delivery is skipped for private network targets."""
|
||||||
|
from app.utils.user_notification import _send_webhook_notification
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("app.utils.user_notification.is_private_ip", return_value=True),
|
||||||
|
patch("app.utils.user_notification.httpx.post") as mock_post,
|
||||||
|
):
|
||||||
|
result = _send_webhook_notification(
|
||||||
|
{"url": "https://10.0.0.5/test"},
|
||||||
|
"document.processed",
|
||||||
|
"T",
|
||||||
|
"M",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result is False
|
||||||
|
mock_post.assert_not_called()
|
||||||
|
|
||||||
|
def test_blocks_metadata_webhook_target(self):
|
||||||
|
"""Webhook delivery is skipped for cloud metadata endpoints."""
|
||||||
|
from app.utils.user_notification import _send_webhook_notification
|
||||||
|
|
||||||
|
with patch("app.utils.user_notification.httpx.post") as mock_post:
|
||||||
|
result = _send_webhook_notification(
|
||||||
|
{"url": "http://169.254.169.254/latest/meta-data"},
|
||||||
|
"document.processed",
|
||||||
|
"T",
|
||||||
|
"M",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result is False
|
||||||
|
mock_post.assert_not_called()
|
||||||
|
|
||||||
|
def test_blocks_invalid_webhook_scheme(self):
|
||||||
|
"""Webhook delivery is skipped for unsupported URL schemes."""
|
||||||
|
from app.utils.user_notification import _send_webhook_notification
|
||||||
|
|
||||||
|
with patch("app.utils.user_notification.httpx.post") as mock_post:
|
||||||
|
result = _send_webhook_notification(
|
||||||
|
{"url": "file:///etc/passwd"},
|
||||||
|
"document.processed",
|
||||||
|
"T",
|
||||||
|
"M",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result is False
|
||||||
|
mock_post.assert_not_called()
|
||||||
|
|
||||||
|
def test_blocks_webhook_without_hostname(self):
|
||||||
|
"""Webhook delivery is skipped when the URL has no hostname."""
|
||||||
|
from app.utils.user_notification import _send_webhook_notification
|
||||||
|
|
||||||
|
with patch("app.utils.user_notification.httpx.post") as mock_post:
|
||||||
|
result = _send_webhook_notification(
|
||||||
|
{"url": "https:///missing-host"},
|
||||||
|
"document.processed",
|
||||||
|
"T",
|
||||||
|
"M",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result is False
|
||||||
|
mock_post.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# dispatch_user_notification – preference loop
|
# dispatch_user_notification – preference loop
|
||||||
|
|||||||
@@ -81,6 +81,7 @@ class TestDeliverWebhook:
|
|||||||
|
|
||||||
def test_success_returns_true(self, mocker):
|
def test_success_returns_true(self, mocker):
|
||||||
"""A 200 response returns True."""
|
"""A 200 response returns True."""
|
||||||
|
mocker.patch("app.utils.webhook.is_private_ip", return_value=False)
|
||||||
mock_post = mocker.patch("app.utils.webhook.requests.post")
|
mock_post = mocker.patch("app.utils.webhook.requests.post")
|
||||||
mock_post.return_value = MagicMock(ok=True, status_code=200)
|
mock_post.return_value = MagicMock(ok=True, status_code=200)
|
||||||
|
|
||||||
@@ -90,6 +91,7 @@ class TestDeliverWebhook:
|
|||||||
|
|
||||||
def test_non_2xx_returns_false(self, mocker):
|
def test_non_2xx_returns_false(self, mocker):
|
||||||
"""A non-2xx response returns False."""
|
"""A non-2xx response returns False."""
|
||||||
|
mocker.patch("app.utils.webhook.is_private_ip", return_value=False)
|
||||||
mock_post = mocker.patch("app.utils.webhook.requests.post")
|
mock_post = mocker.patch("app.utils.webhook.requests.post")
|
||||||
mock_post.return_value = MagicMock(ok=False, status_code=500)
|
mock_post.return_value = MagicMock(ok=False, status_code=500)
|
||||||
|
|
||||||
@@ -100,6 +102,7 @@ class TestDeliverWebhook:
|
|||||||
"""A network error returns False."""
|
"""A network error returns False."""
|
||||||
import requests
|
import requests
|
||||||
|
|
||||||
|
mocker.patch("app.utils.webhook.is_private_ip", return_value=False)
|
||||||
mocker.patch("app.utils.webhook.requests.post", side_effect=requests.ConnectionError("fail"))
|
mocker.patch("app.utils.webhook.requests.post", side_effect=requests.ConnectionError("fail"))
|
||||||
|
|
||||||
result = deliver_webhook("https://example.com/hook", {"event": "test"})
|
result = deliver_webhook("https://example.com/hook", {"event": "test"})
|
||||||
@@ -107,6 +110,7 @@ class TestDeliverWebhook:
|
|||||||
|
|
||||||
def test_signature_header_included_when_secret(self, mocker):
|
def test_signature_header_included_when_secret(self, mocker):
|
||||||
"""X-Webhook-Signature header is present when a secret is supplied."""
|
"""X-Webhook-Signature header is present when a secret is supplied."""
|
||||||
|
mocker.patch("app.utils.webhook.is_private_ip", return_value=False)
|
||||||
mock_post = mocker.patch("app.utils.webhook.requests.post")
|
mock_post = mocker.patch("app.utils.webhook.requests.post")
|
||||||
mock_post.return_value = MagicMock(ok=True, status_code=200)
|
mock_post.return_value = MagicMock(ok=True, status_code=200)
|
||||||
|
|
||||||
@@ -118,6 +122,7 @@ class TestDeliverWebhook:
|
|||||||
|
|
||||||
def test_no_signature_header_without_secret(self, mocker):
|
def test_no_signature_header_without_secret(self, mocker):
|
||||||
"""X-Webhook-Signature header is absent when no secret is supplied."""
|
"""X-Webhook-Signature header is absent when no secret is supplied."""
|
||||||
|
mocker.patch("app.utils.webhook.is_private_ip", return_value=False)
|
||||||
mock_post = mocker.patch("app.utils.webhook.requests.post")
|
mock_post = mocker.patch("app.utils.webhook.requests.post")
|
||||||
mock_post.return_value = MagicMock(ok=True, status_code=200)
|
mock_post.return_value = MagicMock(ok=True, status_code=200)
|
||||||
|
|
||||||
@@ -126,6 +131,43 @@ class TestDeliverWebhook:
|
|||||||
headers = call_kwargs.kwargs.get("headers") or call_kwargs[1].get("headers")
|
headers = call_kwargs.kwargs.get("headers") or call_kwargs[1].get("headers")
|
||||||
assert "X-Webhook-Signature" not in headers
|
assert "X-Webhook-Signature" not in headers
|
||||||
|
|
||||||
|
def test_private_target_is_blocked(self, mocker):
|
||||||
|
"""Private network webhook targets are not called."""
|
||||||
|
mocker.patch("app.utils.webhook.is_private_ip", return_value=True)
|
||||||
|
mock_post = mocker.patch("app.utils.webhook.requests.post")
|
||||||
|
|
||||||
|
result = deliver_webhook("https://10.0.0.5/hook", {"event": "test"})
|
||||||
|
|
||||||
|
assert result is False
|
||||||
|
mock_post.assert_not_called()
|
||||||
|
|
||||||
|
def test_metadata_target_is_blocked(self, mocker):
|
||||||
|
"""Cloud metadata webhook targets are not called."""
|
||||||
|
mock_post = mocker.patch("app.utils.webhook.requests.post")
|
||||||
|
|
||||||
|
result = deliver_webhook("http://169.254.169.254/latest/meta-data", {"event": "test"})
|
||||||
|
|
||||||
|
assert result is False
|
||||||
|
mock_post.assert_not_called()
|
||||||
|
|
||||||
|
def test_invalid_scheme_is_blocked(self, mocker):
|
||||||
|
"""Unsupported webhook URL schemes are not called."""
|
||||||
|
mock_post = mocker.patch("app.utils.webhook.requests.post")
|
||||||
|
|
||||||
|
result = deliver_webhook("file:///etc/passwd", {"event": "test"})
|
||||||
|
|
||||||
|
assert result is False
|
||||||
|
mock_post.assert_not_called()
|
||||||
|
|
||||||
|
def test_missing_hostname_is_blocked(self, mocker):
|
||||||
|
"""Webhook URLs without a hostname are not called."""
|
||||||
|
mock_post = mocker.patch("app.utils.webhook.requests.post")
|
||||||
|
|
||||||
|
result = deliver_webhook("https:///missing-host", {"event": "test"})
|
||||||
|
|
||||||
|
assert result is False
|
||||||
|
mock_post.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Unit tests – get_active_webhooks_for_event (DB)
|
# Unit tests – get_active_webhooks_for_event (DB)
|
||||||
|
|||||||
@@ -129,6 +129,15 @@ class TestSetupWizardUndoSkip:
|
|||||||
class TestDropboxSaveSettingsDbPersist:
|
class TestDropboxSaveSettingsDbPersist:
|
||||||
"""Unit tests for save_dropbox_settings DB persistence."""
|
"""Unit tests for save_dropbox_settings DB persistence."""
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _admin_override(self):
|
||||||
|
from app.api.dropbox import _require_admin
|
||||||
|
from app.main import app as fastapi_app
|
||||||
|
|
||||||
|
fastapi_app.dependency_overrides[_require_admin] = lambda: {"is_admin": True}
|
||||||
|
yield
|
||||||
|
fastapi_app.dependency_overrides.pop(_require_admin, None)
|
||||||
|
|
||||||
@patch("app.api.dropbox.settings")
|
@patch("app.api.dropbox.settings")
|
||||||
@patch("app.api.dropbox.notify_settings_updated")
|
@patch("app.api.dropbox.notify_settings_updated")
|
||||||
@patch("app.api.dropbox.save_setting_to_db")
|
@patch("app.api.dropbox.save_setting_to_db")
|
||||||
@@ -268,6 +277,15 @@ class TestGoogleDriveUpdateSettingsDbPersist:
|
|||||||
class TestOneDriveSaveSettingsDbPersist:
|
class TestOneDriveSaveSettingsDbPersist:
|
||||||
"""Unit tests for save_onedrive_settings DB persistence."""
|
"""Unit tests for save_onedrive_settings DB persistence."""
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _admin_override(self):
|
||||||
|
from app.api.onedrive import _require_admin
|
||||||
|
from app.main import app as fastapi_app
|
||||||
|
|
||||||
|
fastapi_app.dependency_overrides[_require_admin] = lambda: {"is_admin": True}
|
||||||
|
yield
|
||||||
|
fastapi_app.dependency_overrides.pop(_require_admin, None)
|
||||||
|
|
||||||
@patch("app.api.onedrive.settings")
|
@patch("app.api.onedrive.settings")
|
||||||
@patch("app.api.onedrive.notify_settings_updated")
|
@patch("app.api.onedrive.notify_settings_updated")
|
||||||
@patch("app.api.onedrive.save_setting_to_db")
|
@patch("app.api.onedrive.save_setting_to_db")
|
||||||
|
|||||||
Vendored
-1
Submodule vendor/embed-pdf-viewer deleted from aa45d6ef07
Reference in New Issue
Block a user