Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ffc456ef75 | |||
| 1a195a96bd | |||
| 41d6f682c0 | |||
| 53961eb2c6 |
@@ -3,10 +3,21 @@ 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
|
||||||
@@ -625,9 +636,26 @@ EMBEDDING_MAX_TOKENS=8000
|
|||||||
# Profiling is only active when SENTRY_TRACES_SAMPLE_RATE > 0. Default: 0.0 (disabled).
|
# Profiling is only active when SENTRY_TRACES_SAMPLE_RATE > 0. Default: 0.0 (disabled).
|
||||||
# SENTRY_PROFILES_SAMPLE_RATE=0.0
|
# SENTRY_PROFILES_SAMPLE_RATE=0.0
|
||||||
#
|
#
|
||||||
# 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 --ignore-vuln CVE-2026-4539
|
- run: pip-audit -r requirements.txt --desc on
|
||||||
|
|
||||||
run-tests:
|
run-tests:
|
||||||
name: Execute All Tests (Quick + Integration)
|
name: Execute All Tests (Quick + Integration)
|
||||||
|
|||||||
+3
-2
@@ -200,5 +200,6 @@ 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
|
# Frontend build tooling
|
||||||
|
frontend/node_modules/
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
[submodule "vendor/embed-pdf-viewer"]
|
||||||
|
path = vendor/embed-pdf-viewer
|
||||||
|
url = https://github.com/embedpdf/embed-pdf-viewer.git
|
||||||
+8
-4
@@ -1,4 +1,8 @@
|
|||||||
## 2026-06-01 - [Fix XSS in status_dashboard.html]
|
## 2026-03-20 - Safe Path Traversal Prevention in Low-Level Utilities
|
||||||
**Vulnerability:** A Cross-Site Scripting (XSS) vulnerability existed in `frontend/templates/status_dashboard.html` where untrusted configuration settings (`value`), external service messages (`data.message`), and token expirations (`data.token_info.expires_in_human`) were injected directly into the DOM via `.innerHTML` without sanitization.
|
**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:** Even internal or admin-focused dashboards can be vulnerable if they display external or user-configurable data without escaping. Constructing HTML strings dynamically from unvalidated sources is a common vector for DOM-based XSS.
|
**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:** Always use a sanitization function like `escapeHtml` to escape dangerous characters (`<`, `>`, `&`, `"`, `'`) before assigning dynamic content to `.innerHTML`, or prefer `.textContent` when only plaintext is intended.
|
**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.
|
||||||
|
|||||||
+1
-1
@@ -1 +1 @@
|
|||||||
2026-06-01T03:41:15Z
|
2026-03-23T14:11:22Z
|
||||||
|
|||||||
-542
@@ -10,526 +10,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
<!-- version list -->
|
<!-- version list -->
|
||||||
|
|
||||||
## v0.173.4 (2026-06-01)
|
|
||||||
|
|
||||||
### Bug Fixes
|
|
||||||
|
|
||||||
- Address status dashboard xss review nits
|
|
||||||
([`425805a`](https://github.com/christianlouis/DocuElevate/commit/425805ab23882944aee0cb02b5e497bc536549c0))
|
|
||||||
|
|
||||||
### Build System
|
|
||||||
|
|
||||||
- **deps**: Update redis requirement from >=4.5.0 to >=8.0.0
|
|
||||||
([#904](https://github.com/christianlouis/DocuElevate/pull/904),
|
|
||||||
[`20bde93`](https://github.com/christianlouis/DocuElevate/commit/20bde939eb96ec8e6700b206e046388b6b891e38))
|
|
||||||
|
|
||||||
- **deps-dev**: Update pytest-asyncio requirement
|
|
||||||
([#903](https://github.com/christianlouis/DocuElevate/pull/903),
|
|
||||||
[`4a12559`](https://github.com/christianlouis/DocuElevate/commit/4a125590b9b6cb2f07bae187a9bff708dd92a5c3))
|
|
||||||
|
|
||||||
### Documentation
|
|
||||||
|
|
||||||
- **changelog**: Update changelog [skip ci]
|
|
||||||
([`98252e0`](https://github.com/christianlouis/DocuElevate/commit/98252e06c30bba78520a8460d55f508dbf2bdd47))
|
|
||||||
|
|
||||||
- **changelog**: Update changelog [skip ci]
|
|
||||||
([`8be7965`](https://github.com/christianlouis/DocuElevate/commit/8be7965ed1892978092c3f5e02e6252918c237fd))
|
|
||||||
|
|
||||||
|
|
||||||
## Unreleased
|
|
||||||
|
|
||||||
### Build System
|
|
||||||
|
|
||||||
- **deps**: Update redis requirement from >=4.5.0 to >=8.0.0
|
|
||||||
([#904](https://github.com/christianlouis/DocuElevate/pull/904),
|
|
||||||
[`20bde93`](https://github.com/christianlouis/DocuElevate/commit/20bde939eb96ec8e6700b206e046388b6b891e38))
|
|
||||||
|
|
||||||
- **deps-dev**: Update pytest-asyncio requirement
|
|
||||||
([#903](https://github.com/christianlouis/DocuElevate/pull/903),
|
|
||||||
[`4a12559`](https://github.com/christianlouis/DocuElevate/commit/4a125590b9b6cb2f07bae187a9bff708dd92a5c3))
|
|
||||||
|
|
||||||
### Documentation
|
|
||||||
|
|
||||||
- **changelog**: Update changelog [skip ci]
|
|
||||||
([`8be7965`](https://github.com/christianlouis/DocuElevate/commit/8be7965ed1892978092c3f5e02e6252918c237fd))
|
|
||||||
|
|
||||||
|
|
||||||
## Unreleased
|
|
||||||
|
|
||||||
### Build System
|
|
||||||
|
|
||||||
- **deps-dev**: Update pytest-asyncio requirement
|
|
||||||
([#903](https://github.com/christianlouis/DocuElevate/pull/903),
|
|
||||||
[`4a12559`](https://github.com/christianlouis/DocuElevate/commit/4a125590b9b6cb2f07bae187a9bff708dd92a5c3))
|
|
||||||
|
|
||||||
|
|
||||||
## v0.173.3 (2026-05-31)
|
|
||||||
|
|
||||||
### Bug Fixes
|
|
||||||
|
|
||||||
- Preserve falsy values in escapeHtml
|
|
||||||
([`37c27c0`](https://github.com/christianlouis/DocuElevate/commit/37c27c02139ae4a462e1705bda9360b23eb835b3))
|
|
||||||
|
|
||||||
### Documentation
|
|
||||||
|
|
||||||
- **changelog**: Update changelog [skip ci]
|
|
||||||
([`cc56127`](https://github.com/christianlouis/DocuElevate/commit/cc561277c9d7d0177b4ac63921637659abb66fba))
|
|
||||||
|
|
||||||
- **changelog**: Update changelog [skip ci]
|
|
||||||
([`948d118`](https://github.com/christianlouis/DocuElevate/commit/948d118926be042cc3c2a68f58241cc2fcfa23ef))
|
|
||||||
|
|
||||||
- **changelog**: Update changelog [skip ci]
|
|
||||||
([`65bd6d7`](https://github.com/christianlouis/DocuElevate/commit/65bd6d71d00ff7078a05ce12b00f228405e2ec1e))
|
|
||||||
|
|
||||||
|
|
||||||
## Unreleased
|
|
||||||
|
|
||||||
### Documentation
|
|
||||||
|
|
||||||
- **changelog**: Update changelog [skip ci]
|
|
||||||
([`948d118`](https://github.com/christianlouis/DocuElevate/commit/948d118926be042cc3c2a68f58241cc2fcfa23ef))
|
|
||||||
|
|
||||||
- **changelog**: Update changelog [skip ci]
|
|
||||||
([`65bd6d7`](https://github.com/christianlouis/DocuElevate/commit/65bd6d71d00ff7078a05ce12b00f228405e2ec1e))
|
|
||||||
|
|
||||||
|
|
||||||
## Unreleased
|
|
||||||
|
|
||||||
### Documentation
|
|
||||||
|
|
||||||
- **changelog**: Update changelog [skip ci]
|
|
||||||
([`65bd6d7`](https://github.com/christianlouis/DocuElevate/commit/65bd6d71d00ff7078a05ce12b00f228405e2ec1e))
|
|
||||||
|
|
||||||
|
|
||||||
## Unreleased
|
|
||||||
|
|
||||||
|
|
||||||
## v0.173.2 (2026-05-23)
|
|
||||||
|
|
||||||
### Bug Fixes
|
|
||||||
|
|
||||||
- Resolve dependabot npm alerts
|
|
||||||
([`6fc00b8`](https://github.com/christianlouis/DocuElevate/commit/6fc00b8de10b50b4b2f92f6fadbcf7ebbee7136f))
|
|
||||||
|
|
||||||
### Documentation
|
|
||||||
|
|
||||||
- Extend product roadmap and milestones
|
|
||||||
([`e46f9b9`](https://github.com/christianlouis/DocuElevate/commit/e46f9b9a21e5838f883eca4370445e0f1b57e6c9))
|
|
||||||
|
|
||||||
- **changelog**: Update changelog [skip ci]
|
|
||||||
([`427db10`](https://github.com/christianlouis/DocuElevate/commit/427db102d85fa676dcf01118fd3757fb300b979d))
|
|
||||||
|
|
||||||
|
|
||||||
## Unreleased
|
|
||||||
|
|
||||||
### Documentation
|
|
||||||
|
|
||||||
- Extend product roadmap and milestones
|
|
||||||
([`e46f9b9`](https://github.com/christianlouis/DocuElevate/commit/e46f9b9a21e5838f883eca4370445e0f1b57e6c9))
|
|
||||||
|
|
||||||
|
|
||||||
## v0.173.1 (2026-05-22)
|
|
||||||
|
|
||||||
### Bug Fixes
|
|
||||||
|
|
||||||
- Register Evernote task settings
|
|
||||||
([`1ca7f56`](https://github.com/christianlouis/DocuElevate/commit/1ca7f562ef284d2fcceca84b38661abe9038912a))
|
|
||||||
|
|
||||||
|
|
||||||
## v0.173.0 (2026-05-22)
|
|
||||||
|
|
||||||
### Code Style
|
|
||||||
|
|
||||||
- Apply ruff auto-fix ([#862](https://github.com/christianlouis/DocuElevate/pull/862),
|
|
||||||
[`4b46c4b`](https://github.com/christianlouis/DocuElevate/commit/4b46c4baf890f3179ac060569ce2e076cf0551b0))
|
|
||||||
|
|
||||||
### Features
|
|
||||||
|
|
||||||
- **storage**: Add Evernote destination
|
|
||||||
([#862](https://github.com/christianlouis/DocuElevate/pull/862),
|
|
||||||
[`4b46c4b`](https://github.com/christianlouis/DocuElevate/commit/4b46c4baf890f3179ac060569ce2e076cf0551b0))
|
|
||||||
|
|
||||||
|
|
||||||
## 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
|
## Unreleased
|
||||||
|
|
||||||
### Documentation
|
### Documentation
|
||||||
@@ -584,28 +64,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
## Unreleased
|
## 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)
|
## v0.172.1 (2026-03-22)
|
||||||
|
|
||||||
### Bug Fixes
|
### Bug Fixes
|
||||||
|
|||||||
+8
-10
@@ -27,21 +27,20 @@ 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: Frontend asset builder ─────────────────────────────────────────
|
# ── Stage 2: Frontend asset builder (Tailwind CSS) ──────────────────────────
|
||||||
# Compiles Tailwind CSS (a devDependency) into the minified styles.css.
|
FROM node:20-alpine AS frontend-builder
|
||||||
# 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
|
WORKDIR /frontend
|
||||||
|
|
||||||
|
# Install dependencies first (layer-cached unless package.json/lockfile changes)
|
||||||
COPY frontend/package.json frontend/package-lock.json ./
|
COPY frontend/package.json frontend/package-lock.json ./
|
||||||
RUN npm ci
|
RUN npm ci
|
||||||
|
|
||||||
|
# Copy source files and compile Tailwind CSS
|
||||||
COPY frontend/ ./
|
COPY frontend/ ./
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
# ── Stage 4: Documentation builder ──────────────────────────────────────────
|
# ── Stage 3: Documentation builder ──────────────────────────────────────────
|
||||||
FROM python:3.14.3-slim AS docs-builder
|
FROM python:3.14.3-slim AS docs-builder
|
||||||
|
|
||||||
WORKDIR /docs
|
WORKDIR /docs
|
||||||
@@ -57,7 +56,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 5: Runtime image ───────────────────────────────────────────────────
|
# ── Stage 4: Runtime image ───────────────────────────────────────────────────
|
||||||
FROM python:3.14.3-slim
|
FROM python:3.14.3-slim
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
@@ -82,6 +81,8 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
|||||||
# Copy application code
|
# Copy application code
|
||||||
COPY ./app /app/app
|
COPY ./app /app/app
|
||||||
COPY ./frontend /app/frontend
|
COPY ./frontend /app/frontend
|
||||||
|
# Overlay compiled Tailwind CSS from the frontend build stage
|
||||||
|
COPY --from=frontend-builder /frontend/static/styles.css /app/frontend/static/styles.css
|
||||||
COPY ./migrations /app/migrations
|
COPY ./migrations /app/migrations
|
||||||
COPY ./alembic.ini /app/alembic.ini
|
COPY ./alembic.ini /app/alembic.ini
|
||||||
COPY ./LICENSE /app/LICENSE
|
COPY ./LICENSE /app/LICENSE
|
||||||
@@ -95,9 +96,6 @@ 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
|
||||||
|
|
||||||
|
|||||||
+69
-115
@@ -1,6 +1,6 @@
|
|||||||
# DocuElevate Milestones
|
# DocuElevate Milestones
|
||||||
|
|
||||||
**Last Updated:** 2026-05-23
|
**Last Updated:** 2026-02-08
|
||||||
|
|
||||||
This document outlines the release milestones, versioning strategy, and detailed feature breakdown for DocuElevate.
|
This document outlines the release milestones, versioning strategy, and detailed feature breakdown for DocuElevate.
|
||||||
|
|
||||||
@@ -19,15 +19,17 @@ DocuElevate follows [Semantic Versioning 2.0.0](https://semver.org/):
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Current State (Continuous Releases)
|
## Current Release: v0.5.0 (February 2026)
|
||||||
|
|
||||||
DocuElevate ships continuously via automated semantic versioning. Use **GitHub Releases** for the latest build artifacts and **GitHub Milestones** (below) for roadmap tracking.
|
### Status: Stable
|
||||||
|
- Production-ready document processing
|
||||||
### Last Shipped Milestone: v0.5.0 (Released February 8, 2026)
|
- Multi-provider storage support
|
||||||
- Database-backed settings management with encryption
|
- **Database-backed settings management with encryption**
|
||||||
- Setup wizard for first-time configuration
|
- **Setup wizard for first-time configuration**
|
||||||
- Admin UI for runtime configuration
|
- **Admin UI for runtime configuration**
|
||||||
- Release automation via semantic-release
|
- **Automated semantic versioning and releases**
|
||||||
|
- OAuth2 authentication with admin group support
|
||||||
|
- Basic web UI and REST API
|
||||||
|
|
||||||
### Important Note on Versioning
|
### Important Note on Versioning
|
||||||
As of February 2026, DocuElevate uses **automated semantic versioning**:
|
As of February 2026, DocuElevate uses **automated semantic versioning**:
|
||||||
@@ -128,78 +130,87 @@ As of February 2026, DocuElevate uses **automated semantic versioning**:
|
|||||||
|
|
||||||
## Upcoming Milestones
|
## Upcoming Milestones
|
||||||
|
|
||||||
### v0.6.0 - Clarity: Enhanced Search & UI (Target: July 31, 2026)
|
### v0.6.0 - Enhanced Search & UI Improvements (April 2026)
|
||||||
**Target Date:** July 31, 2026
|
**Target Date:** April 1, 2026
|
||||||
**Status:** 📋 Planned
|
**Status:** 📋 Planned
|
||||||
**Theme:** Search, Discovery, Modern UX
|
**Theme:** User Experience, Search, Performance
|
||||||
**Epic:** #863
|
|
||||||
|
|
||||||
#### Goals
|
#### Goals
|
||||||
- Hybrid discovery: keyword + semantic search, fast filtering, saved searches
|
- Implement full-text search across documents
|
||||||
- Preview-first UX (open, skim, and act quickly)
|
- Responsive mobile interface
|
||||||
- Modern UX polish (accessibility, responsiveness, performance)
|
- Dark mode support
|
||||||
|
- Document preview in browser
|
||||||
|
- Performance optimizations
|
||||||
|
- Improved error handling and user feedback
|
||||||
|
|
||||||
#### Deliverables
|
#### Deliverables
|
||||||
- Semantic search foundation (vectorization + ranking signals)
|
- Full-text search API and UI
|
||||||
- Saved searches / smart views
|
- Advanced filtering capabilities
|
||||||
- In-browser preview + “quick actions” (tag, route, export)
|
- Responsive CSS framework integration
|
||||||
- Bulk operations and pagination improvements
|
- Dark mode toggle
|
||||||
- UX polish (dark mode/accessibility where applicable)
|
- In-browser document viewer
|
||||||
|
- Loading states and progress indicators
|
||||||
|
- Performance benchmarks
|
||||||
|
- Mobile-optimized interface
|
||||||
|
|
||||||
#### Breaking Changes
|
#### Breaking Changes
|
||||||
- Potential pagination/search response changes (must be versioned and documented)
|
- API response format changes for search endpoints (documented)
|
||||||
|
|
||||||
#### Migration Path
|
#### Migration Path
|
||||||
- Version endpoints where needed and keep previous versions working for at least 2 minor milestones
|
- Search endpoint changes will be versioned (/api/v1/search → /api/v2/search)
|
||||||
|
- Old endpoints deprecated but functional for 2 releases
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### v0.7.0 - Conductor: Workflow Automation & Integrations (Target: September 30, 2026)
|
### v0.4.5 - Workflow Automation (June 2026)
|
||||||
**Target Date:** September 30, 2026
|
**Target Date:** June 1, 2026
|
||||||
**Status:** 📋 Planned
|
**Status:** 📋 Planned
|
||||||
**Theme:** Automation, Integration, Webhooks
|
**Theme:** Automation, Integration, Webhooks
|
||||||
**Epic:** #864
|
|
||||||
|
|
||||||
#### Goals
|
#### Goals
|
||||||
- First-class workflow model (steps, state, retries) that matches what the system actually executes
|
- Custom processing pipelines
|
||||||
- Workflow-aware UI status, retries, and observability
|
- Conditional routing based on document type
|
||||||
- Webhooks + event-driven automation foundations
|
- Webhook support for external integrations
|
||||||
|
- Rule-based classification
|
||||||
|
- Scheduled batch processing
|
||||||
|
|
||||||
#### Deliverables
|
#### Deliverables
|
||||||
- Workflow object model and storage
|
- Pipeline configuration UI
|
||||||
- Workflow-aware file detail view + status dashboard
|
- Webhook management interface
|
||||||
- Scheduling primitives (recurring jobs / delayed runs)
|
- Rule engine for document routing
|
||||||
- Webhook system (outbound events + inbound triggers)
|
- Batch processing scheduler
|
||||||
- Integration templates and documentation
|
- Integration examples and templates
|
||||||
|
- Webhook payload documentation
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### v0.8.0 - Signal: AI Quality, RAG, and Multi-language (Target: November 30, 2026)
|
### v0.7.0 - Advanced AI & Multi-language (August 2026)
|
||||||
**Target Date:** November 30, 2026
|
**Target Date:** August 1, 2026
|
||||||
**Status:** 📋 Planned
|
**Status:** 📋 Planned
|
||||||
**Theme:** AI Quality, Retrieval, Internationalization
|
**Theme:** AI Enhancement, Internationalization
|
||||||
**Epic:** #865
|
|
||||||
|
|
||||||
#### Goals
|
#### Goals
|
||||||
- “Chat with Library” foundations (retrieval + UI)
|
- Custom AI model support
|
||||||
- Local AI options for privacy-sensitive setups
|
- Multi-language OCR
|
||||||
- Measurable AI quality (confidence + human review loop)
|
- Document similarity detection
|
||||||
- Expand multilingual capability across OCR + UI
|
- Duplicate detection
|
||||||
|
- UI internationalization (i18n)
|
||||||
|
- API localization
|
||||||
|
|
||||||
#### Deliverables
|
#### Deliverables
|
||||||
- Vector DB integration and embeddings pipeline
|
- Custom model integration API
|
||||||
- Chat UI foundations and retrieval API
|
- Multi-language OCR configuration
|
||||||
- Confidence scoring + human review/edit loop for extracted fields
|
- Similarity algorithm implementation
|
||||||
- Multi-language OCR configuration improvements
|
- Duplicate detection service
|
||||||
- Expanded i18n coverage + localized docs
|
- Translation framework (10+ languages)
|
||||||
|
- Localized documentation
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### v1.0.0 - Summit: Enterprise Edition (Target: March 31, 2027)
|
### v1.0.0 - Enterprise Edition (November 2026)
|
||||||
**Target Date:** March 31, 2027
|
**Target Date:** November 1, 2026
|
||||||
**Status:** 📋 Planned
|
**Status:** 📋 Planned
|
||||||
**Theme:** Enterprise Features, Scalability, Multi-tenancy
|
**Theme:** Enterprise Features, Scalability, Multi-tenancy
|
||||||
**Epic:** #866
|
|
||||||
|
|
||||||
This is our first major release, marking production-ready enterprise capabilities.
|
This is our first major release, marking production-ready enterprise capabilities.
|
||||||
|
|
||||||
@@ -233,60 +244,6 @@ This is our first major release, marking production-ready enterprise capabilitie
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### v2.0.0 - Horizon: Platform Expansion (Target: September 30, 2027)
|
|
||||||
**Target Date:** September 30, 2027
|
|
||||||
**Status:** 📋 Planned
|
|
||||||
**Theme:** Ecosystem, Platform, Distribution
|
|
||||||
**Epic:** #867
|
|
||||||
|
|
||||||
#### Goals
|
|
||||||
- Make DocuElevate extensible by design (plugins + templates)
|
|
||||||
- Expand integrations and developer experience
|
|
||||||
- Harden multi-surface experiences (web, mobile, extension, CLI) as a cohesive product
|
|
||||||
|
|
||||||
#### Deliverables
|
|
||||||
- Plugin system foundations and public extension points
|
|
||||||
- Template library for pipelines/workflows + “starter kits”
|
|
||||||
- Integration hub patterns (webhooks, events, connectors)
|
|
||||||
- SDK + documentation for extensions
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### v2.1.0 - Sentinel: Governance & Policy (Target: March 31, 2028)
|
|
||||||
**Target Date:** March 31, 2028
|
|
||||||
**Status:** 📋 Planned
|
|
||||||
**Theme:** Governance, Compliance, Policy-driven Automation
|
|
||||||
**Epic:** #868
|
|
||||||
|
|
||||||
#### Goals
|
|
||||||
- Make governance first-class (retention, legal hold, PII workflows)
|
|
||||||
- Provide tamper-evident auditing and admin controls
|
|
||||||
- Introduce policy-driven approvals for sensitive automation
|
|
||||||
|
|
||||||
#### Deliverables
|
|
||||||
- Retention policies + legal hold primitives
|
|
||||||
- PII detection + redaction workflows
|
|
||||||
- Tamper-evident audit trails + admin activity feed
|
|
||||||
- Policy-as-code concepts for workflows (with approval gates)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### v3.0.0 - Constellation: Integration Hub & Agent Platform (Target: September 30, 2028)
|
|
||||||
**Target Date:** September 30, 2028
|
|
||||||
**Status:** 📋 Planned
|
|
||||||
**Theme:** Ecosystem, Agents, Interoperability
|
|
||||||
**Epic:** #869
|
|
||||||
|
|
||||||
#### Goals
|
|
||||||
- Make DocuElevate the “system of record” for document intelligence in an organization
|
|
||||||
- Support external automation ecosystems (Zapier/Make/n8n) and agent runtimes
|
|
||||||
- Provide a clean interoperability layer for modern AI tools
|
|
||||||
|
|
||||||
#### Deliverables
|
|
||||||
- DocuElevate MCP server (search, retrieve, summarize, route) and documentation
|
|
||||||
- Connector marketplace concepts (curated + community)
|
|
||||||
- Event stream + webhooks at scale (delivery guarantees, retries, signing)
|
|
||||||
|
|
||||||
## Release Process
|
## Release Process
|
||||||
|
|
||||||
### Automated Semantic Versioning (v0.6.0+)
|
### Automated Semantic Versioning (v0.6.0+)
|
||||||
@@ -298,15 +255,15 @@ Starting with v0.6.0, releases are fully automated using `python-semantic-releas
|
|||||||
4. **Automatic Updates**:
|
4. **Automatic Updates**:
|
||||||
- Updates `VERSION` file
|
- Updates `VERSION` file
|
||||||
- Generates/updates `CHANGELOG.md`
|
- Generates/updates `CHANGELOG.md`
|
||||||
- Creates Git tag (e.g., `v0.173.1`)
|
- Creates Git tag (e.g., `v0.6.0`)
|
||||||
- Creates GitHub Release with notes
|
- Creates GitHub Release with notes
|
||||||
- Triggers Docker image builds
|
- Triggers Docker image builds
|
||||||
5. **No Manual Steps**: VERSION and CHANGELOG are never edited manually
|
5. **No Manual Steps**: VERSION and CHANGELOG are never edited manually
|
||||||
|
|
||||||
### Version Bump Rules
|
### Version Bump Rules
|
||||||
- `feat:` commits → Minor version (e.g., 0.173.1 → 0.174.0)
|
- `feat:` commits → Minor version (0.5.0 → 0.6.0)
|
||||||
- `fix:`, `perf:` → Patch version (e.g., 0.173.1 → 0.173.2)
|
- `fix:`, `perf:` → Patch version (0.5.0 → 0.5.1)
|
||||||
- `feat!:`, `BREAKING CHANGE:` → Major version (e.g., 0.173.1 → 1.0.0)
|
- `feat!:`, `BREAKING CHANGE:` → Major version (0.5.0 → 1.0.0)
|
||||||
- Other types (docs, chore, etc.) → No version bump
|
- Other types (docs, chore, etc.) → No version bump
|
||||||
|
|
||||||
### Pre-release Checklist (Automated)
|
### Pre-release Checklist (Automated)
|
||||||
@@ -340,13 +297,10 @@ Starting with v0.6.0, releases are fully automated using `python-semantic-releas
|
|||||||
| v0.3.2 | 2026-02-06 | Security Updates | Released |
|
| v0.3.2 | 2026-02-06 | Security Updates | Released |
|
||||||
| v0.3.3 | 2026-02-08 | Drag-and-Drop Upload | Released |
|
| v0.3.3 | 2026-02-08 | Drag-and-Drop Upload | Released |
|
||||||
| v0.5.0 | 2026-02-08 | **Settings & Encryption** | **Released** |
|
| v0.5.0 | 2026-02-08 | **Settings & Encryption** | **Released** |
|
||||||
| v0.6.0 | 2026-07-31 | **Clarity:** Search & UX | Planned |
|
| v0.6.0 | 2026-04 | Search & UX | Planned |
|
||||||
| v0.7.0 | 2026-09-30 | **Conductor:** Workflows & Integrations | Planned |
|
| v0.7.0 | 2026-08 | Advanced AI | Planned |
|
||||||
| v0.8.0 | 2026-11-30 | **Signal:** AI Quality, RAG, Multi-language | Planned |
|
| v1.0.0 | 2026-11 | Enterprise | Planned |
|
||||||
| v1.0.0 | 2027-03-31 | **Summit:** Enterprise | Planned |
|
| v2.0.0 | 2027-Q3 | Platform Expansion | Future |
|
||||||
| v2.0.0 | 2027-09-30 | **Horizon:** Platform Expansion | Future |
|
|
||||||
| v2.1.0 | 2028-03-31 | **Sentinel:** Governance & Policy | Future |
|
|
||||||
| v3.0.0 | 2028-09-30 | **Constellation:** Integration Hub & Agents | Future |
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ DocuElevate is an intelligent document processing system that automates the inge
|
|||||||
|
|
||||||
- **AI-Powered Metadata Extraction** — pluggable AI providers including OpenAI, Anthropic Claude, Google Gemini, Ollama (local), OpenRouter, Portkey, and Azure OpenAI via LiteLLM
|
- **AI-Powered Metadata Extraction** — pluggable AI providers including OpenAI, Anthropic Claude, Google Gemini, Ollama (local), OpenRouter, Portkey, and Azure OpenAI via LiteLLM
|
||||||
- **Multi-Engine OCR** — Azure Document Intelligence, Tesseract, EasyOCR, Mistral OCR, Google Cloud Document AI, and AWS Textract with configurable merge strategies
|
- **Multi-Engine OCR** — Azure Document Intelligence, Tesseract, EasyOCR, Mistral OCR, Google Cloud Document AI, and AWS Textract with configurable merge strategies
|
||||||
- **13 Storage Destinations** — Dropbox, Google Drive, OneDrive, Amazon S3, Nextcloud, WebDAV, FTP, SFTP, iCloud Drive, Email (SMTP), Paperless-ngx, Evernote, and Rclone
|
- **12 Storage Destinations** — Dropbox, Google Drive, OneDrive, Amazon S3, Nextcloud, WebDAV, FTP, SFTP, iCloud Drive, Email (SMTP), Paperless-ngx, and Rclone
|
||||||
- **Multi-Channel Ingestion** — web upload, browser extension, mobile app, CLI, REST API, IMAP email, and watched folders (local, cloud, FTP/SFTP)
|
- **Multi-Channel Ingestion** — web upload, browser extension, mobile app, CLI, REST API, IMAP email, and watched folders (local, cloud, FTP/SFTP)
|
||||||
- **Processing Pipelines** — customizable multi-step workflows with conditional routing rules
|
- **Processing Pipelines** — customizable multi-step workflows with conditional routing rules
|
||||||
- **Full-Text Search** — powered by Meilisearch for instant document discovery
|
- **Full-Text Search** — powered by Meilisearch for instant document discovery
|
||||||
@@ -106,7 +106,6 @@ Processed documents are distributed to any combination of configured destination
|
|||||||
| **iCloud Drive** | Apple cloud |
|
| **iCloud Drive** | Apple cloud |
|
||||||
| **Email (SMTP)** | Send as attachment |
|
| **Email (SMTP)** | Send as attachment |
|
||||||
| **Paperless-ngx** | Document management system |
|
| **Paperless-ngx** | Document management system |
|
||||||
| **Evernote** | Notes with PDF attachments |
|
|
||||||
| **Rclone** | 70+ cloud providers via Rclone |
|
| **Rclone** | 70+ cloud providers via Rclone |
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
@@ -246,7 +245,6 @@ See the [Kubernetes Deployment Guide](docs/KubernetesDeployment.md) for full det
|
|||||||
| [Google Drive](docs/GoogleDriveSetup.md) | Google Drive service account / OAuth |
|
| [Google Drive](docs/GoogleDriveSetup.md) | Google Drive service account / OAuth |
|
||||||
| [OneDrive](docs/OneDriveSetup.md) | Microsoft OneDrive setup |
|
| [OneDrive](docs/OneDriveSetup.md) | Microsoft OneDrive setup |
|
||||||
| [Amazon S3](docs/AmazonS3Setup.md) | S3 bucket configuration |
|
| [Amazon S3](docs/AmazonS3Setup.md) | S3 bucket configuration |
|
||||||
| [Evernote](docs/EvernoteSetup.md) | Evernote note creation |
|
|
||||||
| [Authentication](docs/AuthenticationSetup.md) | OAuth2, OIDC, and social login |
|
| [Authentication](docs/AuthenticationSetup.md) | OAuth2, OIDC, and social login |
|
||||||
| [Notifications](docs/NotificationsSetup.md) | Notification backend setup |
|
| [Notifications](docs/NotificationsSetup.md) | Notification backend setup |
|
||||||
|
|
||||||
@@ -329,7 +327,6 @@ The following is a summary of the licenses used by our direct dependencies:
|
|||||||
| pypdf | BSD |
|
| pypdf | BSD |
|
||||||
| Requests | Apache 2.0 |
|
| Requests | Apache 2.0 |
|
||||||
| Dropbox SDK | MIT |
|
| Dropbox SDK | MIT |
|
||||||
| Evernote SDK | BSD |
|
|
||||||
| Azure AI Document Intelligence | MIT |
|
| Azure AI Document Intelligence | MIT |
|
||||||
| Authlib | BSD |
|
| Authlib | BSD |
|
||||||
| Starlette | BSD |
|
| Starlette | BSD |
|
||||||
|
|||||||
+133
-103
@@ -1,139 +1,169 @@
|
|||||||
# DocuElevate Roadmap
|
# DocuElevate Roadmap
|
||||||
|
|
||||||
**Last Updated:** 2026-05-23
|
**Last Updated:** 2026-02-08
|
||||||
**Version:** 2.0
|
**Version:** 1.0
|
||||||
|
|
||||||
## Vision
|
## Vision
|
||||||
|
|
||||||
DocuElevate aims to be the premier open-source intelligent document processing platform, providing seamless integration with cloud storage providers, advanced AI-powered metadata extraction, and enterprise-grade security and scalability.
|
DocuElevate aims to be the premier open-source intelligent document processing platform, providing seamless integration with cloud storage providers, advanced AI-powered metadata extraction, and enterprise-grade security and scalability.
|
||||||
|
|
||||||
## How to Read This Roadmap
|
|
||||||
|
|
||||||
DocuElevate ships frequently (automated semantic versioning), so this roadmap is organized around **milestone outcomes** and **themes**, not exact build numbers.
|
|
||||||
|
|
||||||
- **P0** = required for the milestone to feel “done”
|
|
||||||
- **P1** = strongly desired; may slip if needed
|
|
||||||
- **P2** = nice-to-have / opportunistic
|
|
||||||
|
|
||||||
For the detailed milestone breakdown and target dates, see [MILESTONES.md](MILESTONES.md).
|
|
||||||
|
|
||||||
## Release Naming
|
## Release Naming
|
||||||
|
|
||||||
Each major milestone release carries a codename to anchor key project moments. These names appear in the status dashboard, build metadata, and changelog. For details, see [docs/ReleaseNaming.md](docs/ReleaseNaming.md).
|
Each major milestone release carries a codename to anchor key project moments. These names appear in the status dashboard, build metadata, and changelog. For details, see [docs/ReleaseNaming.md](docs/ReleaseNaming.md).
|
||||||
|
|
||||||
| Milestone | Codename | Theme |
|
| Version Range | Codename | Theme |
|
||||||
|----------|------------------|-------|
|
|---------------|---------------|--------------------------------------------------|
|
||||||
| v0.6.0 | **Clarity** | Search, discovery, and modern UX |
|
| 0.5.x | **Foundation** | Core platform, multi-provider storage, AI, UI |
|
||||||
| v0.7.0 | **Conductor** | Workflows, orchestration, and integrations |
|
| 0.6.x | **Clarity** | Enhanced search, filtering, UI/UX improvements |
|
||||||
| v0.8.0 | **Signal** | AI quality, multilingual, and “Chat with Library” foundations |
|
| 0.7.x | **Conductor** | Workflow automation, pipelines, rule-based logic |
|
||||||
| v1.0.0 | **Summit** | Enterprise readiness (multi-tenancy, RBAC, scaling) |
|
| 1.0.x | **Summit** | Enterprise features, multi-tenancy, RBAC |
|
||||||
| v2.0.0 | **Horizon** | Platform expansion and ecosystem maturity |
|
| 1.1.x | **Bridge** | Collaboration, sharing, analytics |
|
||||||
| v2.1.0+ | **Sentinel** | Governance, compliance, and policy-driven automation |
|
| 2.0.x | **Horizon** | On-premise AI, platform expansion |
|
||||||
| v3.0.0 | **Constellation**| Integration hub, agents, and interoperability |
|
|
||||||
|
|
||||||
## Current Product Capabilities (Today)
|
## Current Status (v0.5.0 "Foundation")
|
||||||
|
|
||||||
### Core Features ✅
|
### Core Features ✅
|
||||||
- Multi-channel ingestion (web upload, IMAP email, watched folders, mobile, CLI, API)
|
- Multi-provider document storage (Dropbox, Google Drive, OneDrive, Nextcloud, S3, etc.)
|
||||||
- Multi-engine OCR + AI extraction with configurable providers
|
- IMAP email integration for document ingestion
|
||||||
- Customizable processing pipelines and routing rules
|
- OCR processing via Azure Document Intelligence
|
||||||
- Full-text search and document discovery
|
- AI-powered metadata extraction via OpenAI
|
||||||
- Multi-destination distribution (cloud providers, DMS, protocols, email)
|
- PDF conversion via Gotenberg
|
||||||
- Admin UI for configuration (database-backed settings, encryption, setup wizard)
|
- Web UI for document upload and management
|
||||||
- Production hardening building blocks (CI/CD, security docs, deployment guides)
|
- **Database-backed settings management with admin UI**
|
||||||
|
- **Fernet encryption for sensitive configuration**
|
||||||
|
- **Setup wizard for first-time installation**
|
||||||
|
- REST API with OpenAPI documentation
|
||||||
|
- Celery-based async task processing
|
||||||
|
- OAuth2 authentication via Authentik with admin group support
|
||||||
|
|
||||||
## Feature Landscape (Themes)
|
## Short-term Goals (Q1-Q2 2026) - v0.4.x to v0.5.x "Foundation"
|
||||||
|
|
||||||
### 1) Search & Discovery
|
### Quality & Stability 🎯
|
||||||
- **P0:** hybrid search (keyword + semantic), fast faceted filtering, saved searches
|
- **Test Coverage** (High Priority)
|
||||||
- **P1:** “explain results” (why a document matched), query suggestions, pinned results
|
- [ ] Achieve 80% code coverage for core modules
|
||||||
- **P2:** entity search (people/companies/amounts/dates) and graph-style exploration
|
- [ ] Add integration tests for all storage providers
|
||||||
|
- [ ] Add end-to-end workflow tests
|
||||||
|
- [ ] Performance benchmarks and load testing
|
||||||
|
|
||||||
### 2) AI Quality & Trust
|
- **Code Quality** (High Priority)
|
||||||
- **P0:** confidence scoring, human review/edit loop, extraction evaluation harness
|
- [ ] Enable strict linting in CI/CD
|
||||||
- **P1:** per-document-type schemas/templates, active learning (feedback improves extraction)
|
- [ ] Refactor large modules for better maintainability
|
||||||
- **P2:** multi-model routing (choose model by cost/latency/accuracy per step)
|
- [ ] Add comprehensive type hints
|
||||||
|
- [ ] Improve error handling and user feedback
|
||||||
|
|
||||||
### 3) Workflow Automation & Orchestration
|
- **Security** (Critical Priority)
|
||||||
- **P0:** first-class workflow model (steps, state, retries), workflow-aware UI status
|
- [x] Fix known vulnerabilities in dependencies
|
||||||
- **P1:** visual workflow builder, scheduling, webhooks, and event-driven triggers
|
- [ ] Implement rate limiting on API endpoints
|
||||||
- **P2:** agentic workflows (“autopilot” suggestions with approval gates)
|
- [ ] Add CSRF protection
|
||||||
|
- [ ] Security audit by external party
|
||||||
|
- [ ] Implement API key rotation
|
||||||
|
- [ ] Add audit logging for sensitive operations
|
||||||
|
|
||||||
### 4) Integrations & Ecosystem (Including MCP)
|
- **Release Automation** (Completed ✅)
|
||||||
- **P0:** stable webhooks + outbound actions (Slack/Teams, email, DMS), bi-directional sync where supported
|
- [x] Implement semantic-release for automated versioning
|
||||||
- **P1:** “Integration Hub” (Zapier/Make/n8n style), connector templates, secrets handling patterns
|
- [x] Add conventional commit validation
|
||||||
- **P2:** **MCP**: ship a DocuElevate MCP server (search, retrieve, summarize, route) + allow MCP tools as pipeline steps
|
- [x] Automate CHANGELOG generation
|
||||||
|
- [x] Integrate Docker builds with releases
|
||||||
|
|
||||||
### 5) Governance, Compliance, and Security
|
### Features - v0.4.0
|
||||||
- **P0:** audit trails, tamper-evident logs, API key lifecycle/rotation, admin activity feed
|
- **Enhanced Search & Filtering** → _preparing for v0.6.0 "Clarity"_
|
||||||
- **P1:** retention policies, legal hold, PII detection + redaction, data residency controls
|
- [ ] Full-text search across documents
|
||||||
- **P2:** compliance packs (SOC2/GDPR/HIPAA), BYOK/KMS integration paths
|
- [ ] Advanced filtering by metadata, tags, date ranges
|
||||||
|
- [ ] Saved search queries
|
||||||
|
- [ ] Bulk operations on search results
|
||||||
|
|
||||||
### 6) Enterprise & Scale
|
- **Improved UI/UX**
|
||||||
- **P0:** multi-tenancy, RBAC, horizontal scaling reference architecture
|
- [ ] Responsive mobile interface
|
||||||
- **P1:** SCIM provisioning, SAML/Okta/Azure AD hardening, quotas/billing at org level
|
- [ ] Dark mode support
|
||||||
- **P2:** multi-region deployment patterns and disaster recovery playbooks
|
- [ ] Document preview in browser
|
||||||
|
- [ ] Drag-and-drop file upload
|
||||||
|
- [ ] Progress indicators for long-running tasks
|
||||||
|
- [ ] Real-time notifications via WebSocket
|
||||||
|
|
||||||
## Release Plan (Extended)
|
### Features - v0.5.0 "Foundation"
|
||||||
|
- **Workflow Automation** → _evolving into v0.7.0 "Conductor"_
|
||||||
|
- [ ] Custom processing pipelines
|
||||||
|
- [ ] Conditional routing based on document type
|
||||||
|
- [ ] Scheduled batch processing
|
||||||
|
- [ ] Webhook support for external integrations
|
||||||
|
- [ ] Rule-based document classification
|
||||||
|
|
||||||
This plan extends the existing milestones with a clearer thematic arc and a forward-looking “beyond v2.0” horizon. Each milestone links to an epic issue that owns scope and sub-issues.
|
- **Advanced AI Features**
|
||||||
|
- [ ] Custom AI models for specialized document types
|
||||||
|
- [ ] Multi-language OCR support
|
||||||
|
- [ ] Document similarity detection
|
||||||
|
- [ ] Automatic duplicate detection
|
||||||
|
- [ ] Intelligent document splitting
|
||||||
|
|
||||||
### v0.6.0 — Clarity (Search & UX)
|
## Medium-term Goals (Q3-Q4 2026) - v1.0.x "Summit"
|
||||||
- **Outcome:** users can reliably find, preview, and act on documents in seconds
|
|
||||||
- **P0:** semantic search + hybrid ranking, saved searches, fast filters, preview-first UX
|
|
||||||
- **P1:** bulk operations, query suggestions, accessibility/dark mode polish
|
|
||||||
- **Tracking:** GitHub milestone `v0.6.0 - Enhanced Search & UI` (epic #863)
|
|
||||||
|
|
||||||
### v0.7.0 — Conductor (Workflows & Integrations)
|
### Enterprise Features - v1.0.0 "Summit"
|
||||||
- **Outcome:** workflows are explicit, inspectable, and automatable end-to-end
|
- **Multi-tenancy**
|
||||||
- **P0:** workflow object model + workflow-aware UI status, retries, pipeline definitions
|
- [ ] Organization/team management
|
||||||
- **P1:** workflow builder, scheduling, inbound/outbound webhooks
|
- [ ] Role-based access control (RBAC)
|
||||||
- **P2:** integration templates + “connector marketplace” concepts
|
- [ ] Per-tenant configuration
|
||||||
- **Tracking:** GitHub milestone `v0.7.0 - Workflow Automation` (epic #864)
|
- [ ] Resource quotas and limits
|
||||||
|
- [ ] Audit logs per organization
|
||||||
|
|
||||||
### v0.8.0 — Signal (AI Quality + “Chat with Library” Foundations)
|
- **Scalability**
|
||||||
- **Outcome:** AI features are measurable, reviewable, and safe to trust
|
- [ ] Horizontal scaling support
|
||||||
- **P0:** vector DB + embeddings pipeline, chat UI foundations, local AI options
|
- [ ] Distributed task processing
|
||||||
- **P1:** confidence scoring and review loop, extraction evaluation harness
|
- [ ] Caching layer (Redis/Memcached)
|
||||||
- **P2:** multilingual UX + localization expansion
|
- [ ] Database connection pooling
|
||||||
- **Tracking:** GitHub milestone `v0.8.0 - Advanced AI & Multi-language` (epic #865)
|
- [ ] Message queue optimization
|
||||||
|
|
||||||
### v1.0.0 — Summit (Enterprise Readiness)
|
- **Advanced Integrations**
|
||||||
- **Outcome:** teams can run DocuElevate with strong isolation, access control, and scale
|
- [ ] Microsoft SharePoint integration
|
||||||
- **P0:** multi-tenancy, RBAC, audit logging, scaling guidance
|
- [ ] Slack/Teams bot integration
|
||||||
- **P1:** SSO hardening (SAML/LDAP), org-level quotas and billing hooks
|
- [ ] Zapier/Make.com integration
|
||||||
- **P2:** enterprise admin experience (policies, approvals, reporting)
|
- [ ] Custom webhook receivers
|
||||||
- **Tracking:** GitHub milestone `v1.0.0 - Enterprise Edition` (epic #866)
|
- [ ] GraphQL API
|
||||||
|
|
||||||
### v2.0.0 — Horizon (Platform Expansion)
|
### Features - v1.1.0 "Bridge"
|
||||||
- **Outcome:** DocuElevate becomes an extensible platform with a thriving ecosystem
|
- **Collaboration**
|
||||||
- **P0:** plugin system foundations, SDK + templates, deeper integrations
|
- [ ] Document sharing with expiring links
|
||||||
- **P1:** marketplace patterns, app distribution, mobile/extension maturity
|
- [ ] Comments and annotations
|
||||||
- **P2:** multi-workspace experiences (personal + org)
|
- [ ] Version history and rollback
|
||||||
- **Tracking:** GitHub milestone `v2.0.0 - Platform Expansion` (epic #867)
|
- [ ] Real-time collaborative editing metadata
|
||||||
|
- [ ] Activity feed
|
||||||
|
|
||||||
### v2.1.0+ — Sentinel (Governance & Policy)
|
- **Reporting & Analytics**
|
||||||
- **Outcome:** governance becomes a first-class layer (policy-driven automation)
|
- [ ] Processing statistics dashboard
|
||||||
- **P0:** retention + legal hold, PII detection/redaction, tamper-evident audit trails
|
- [ ] Storage usage analytics
|
||||||
- **P1:** BYOK/KMS integration patterns, advanced access policies, compliance reporting
|
- [ ] AI confidence scores and accuracy tracking
|
||||||
- **P2:** “policy as code” for workflows + approvals (change management)
|
- [ ] Cost analysis per provider
|
||||||
- **Tracking:** GitHub milestone `v2.1.0 - Governance & Policy (Sentinel)` (epic #868)
|
- [ ] Export reports (PDF, CSV, Excel)
|
||||||
|
|
||||||
### v3.0.0 — Constellation (Integration Hub & Agent Platform)
|
## Long-term Goals (2027+) - v2.0+ "Horizon"
|
||||||
- **Outcome:** DocuElevate plugs into modern automation and AI ecosystems as a first-class system of record
|
|
||||||
- **P0:** MCP server, durable event stream + production-grade webhooks
|
|
||||||
- **P1:** connector templates + curated catalog, agent-friendly permissioning and auditing
|
|
||||||
- **P2:** bring-your-own-agent patterns (sandboxing, scoped credentials)
|
|
||||||
- **Tracking:** GitHub milestone `v3.0.0 - Integration Hub & Agent Platform (Constellation)` (epic #869)
|
|
||||||
|
|
||||||
## Research Bets (Optional / Experimental)
|
### Strategic Initiatives
|
||||||
|
- **On-Premise AI Models**
|
||||||
|
- [ ] Self-hosted OCR (Tesseract, EasyOCR)
|
||||||
|
- [ ] Local LLM integration (Ollama, LLaMA)
|
||||||
|
- [ ] GPU acceleration support
|
||||||
|
- [ ] Model fine-tuning interface
|
||||||
|
- [ ] Hybrid cloud/on-premise processing
|
||||||
|
|
||||||
These are longer-horizon bets that should only be productized if they prove real user value.
|
- **Advanced Document Management**
|
||||||
|
- [ ] Document lifecycle management
|
||||||
|
- [ ] Retention policies and auto-deletion
|
||||||
|
- [ ] Compliance templates (GDPR, HIPAA, SOC2)
|
||||||
|
- [ ] Digital signature support
|
||||||
|
- [ ] Encryption at rest and in transit
|
||||||
|
|
||||||
- Knowledge graph over extracted entities (contracts ↔ vendors ↔ invoices)
|
- **Platform Expansion**
|
||||||
- Auto-generated “case files” (collections) from intent (“tax 2025”, “project alpha”)
|
- [ ] Desktop applications (Electron)
|
||||||
- Privacy-preserving learning (federated patterns) to improve extraction quality
|
- [ ] Mobile apps (iOS/Android)
|
||||||
- Document provenance (signing, attestations) and tamper detection
|
- [ ] Browser extensions
|
||||||
|
- [ ] Command-line interface (CLI)
|
||||||
|
- [ ] VS Code extension for developers
|
||||||
|
|
||||||
|
### Research & Innovation
|
||||||
|
- [ ] Machine learning for custom document types
|
||||||
|
- [ ] Blockchain for document provenance
|
||||||
|
- [ ] Federated learning for privacy-preserving AI
|
||||||
|
- [ ] Edge computing support
|
||||||
|
- [ ] Quantum-resistant encryption
|
||||||
|
|
||||||
## Community & Ecosystem
|
## Community & Ecosystem
|
||||||
|
|
||||||
|
|||||||
+6
-6
@@ -1,10 +1,10 @@
|
|||||||
DocuElevate Build Information
|
DocuElevate Build Information
|
||||||
==============================
|
==============================
|
||||||
Version: 0.173.4
|
Version: 0.172.2
|
||||||
Build Date: 2026-06-01T03:41:15Z
|
Build Date: 2026-03-23T14:11:22Z
|
||||||
Git Commit: 425805ab23882944aee0cb02b5e497bc536549c0
|
Git Commit: 34457f977509ce145b7411e83982a96b0fd0e33e
|
||||||
Git Short SHA: 425805a
|
Git Short SHA: 34457f9
|
||||||
Git Branch: main
|
Git Branch: main
|
||||||
Commit Date: 2026-06-01T05:40:53+02:00
|
Commit Date: 2026-03-23T15:10:59+01:00
|
||||||
Build Timestamp: 2026-06-01T03:41:16Z
|
Build Timestamp: 2026-03-23T14:11:22Z
|
||||||
==============================
|
==============================
|
||||||
|
|||||||
+10
-1
@@ -1,6 +1,6 @@
|
|||||||
# Security Audit Report
|
# Security Audit Report
|
||||||
|
|
||||||
**Date:** 2026-02-12
|
**Date:** 2026-03-23
|
||||||
**Status:** Bandit Security Scan Completed - All Critical/High/Medium Issues Resolved
|
**Status:** Bandit Security Scan Completed - All Critical/High/Medium Issues Resolved
|
||||||
|
|
||||||
## Executive Summary
|
## Executive Summary
|
||||||
@@ -9,6 +9,15 @@ This document tracks security vulnerabilities found in DocuElevate and their rem
|
|||||||
|
|
||||||
## Recent Security Fixes
|
## Recent Security Fixes
|
||||||
|
|
||||||
|
### Insecure API Endpoint Exposing Integration Credentials ✅ FIXED (2026-03-23)
|
||||||
|
**Severity:** HIGH
|
||||||
|
|
||||||
|
**Issue:** The endpoint `GET /api/integrations/{integration_id}/credentials` exposed integration credentials (e.g. passwords, API keys) in plaintext over the API. Although requiring login, this allowed anyone with an active user session to extract the raw credentials. The frontend used this endpoint for testing integration connections.
|
||||||
|
|
||||||
|
**Remediation:**
|
||||||
|
- Removed the `/credentials` endpoint entirely.
|
||||||
|
- Added a new `POST /api/integrations/{integration_id}/test` endpoint that securely runs connection tests server-side without returning the decrypted credentials to the client.
|
||||||
|
|
||||||
### CVE-2023-36464: PyPDF2/pypdf Infinite Loop Vulnerability ✅ FIXED (2026-02-12)
|
### CVE-2023-36464: PyPDF2/pypdf Infinite Loop Vulnerability ✅ FIXED (2026-02-12)
|
||||||
|
|
||||||
**Severity:** Moderate (CVSS: 5.5)
|
**Severity:** Moderate (CVSS: 5.5)
|
||||||
|
|||||||
+2
-13
@@ -25,17 +25,6 @@ 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.
|
||||||
|
|
||||||
@@ -396,14 +385,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).
|
||||||
|
|||||||
+12
-25
@@ -23,17 +23,6 @@ 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(
|
||||||
@@ -373,15 +362,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.
|
||||||
@@ -414,10 +403,9 @@ 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
|
||||||
|
|
||||||
# Best-effort .env file write — failures here are non-fatal
|
# Try to update the .env file, but don't fail if it doesn't exist (for Docker containers)
|
||||||
env_file_written = False
|
if os.path.exists(env_path):
|
||||||
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
|
||||||
@@ -450,13 +438,12 @@ 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")
|
||||||
env_file_written = True
|
except Exception as e:
|
||||||
else:
|
logger.warning(f"Failed to update .env file: {str(e)}, but will continue with in-memory update")
|
||||||
logger.warning(
|
else:
|
||||||
f".env file not found at {env_path}, skipping file update but continuing with in-memory update"
|
logger.warning(
|
||||||
)
|
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:
|
||||||
@@ -494,7 +481,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 env_file_written,
|
"in_memory_only": not os.path.exists(env_path),
|
||||||
}
|
}
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ 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
|
||||||
|
|
||||||
@@ -188,11 +187,6 @@ 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)
|
||||||
|
|||||||
+57
-43
@@ -470,31 +470,6 @@ def delete_integration(
|
|||||||
logger.info("User %s deleted integration %d", owner_id, integration_id)
|
logger.info("User %s deleted integration %d", owner_id, integration_id)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{integration_id}/credentials", summary="Retrieve decrypted credentials for an integration")
|
|
||||||
def get_integration_credentials(
|
|
||||||
integration_id: int,
|
|
||||||
request: Request,
|
|
||||||
db: DbSession,
|
|
||||||
owner_id: CurrentOwner,
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
"""Return the decrypted credentials dict for a saved integration.
|
|
||||||
|
|
||||||
This endpoint is intended for internal use by background tasks that need
|
|
||||||
to authenticate with a third-party service. Treat the response as
|
|
||||||
sensitive — it contains plaintext secrets.
|
|
||||||
"""
|
|
||||||
integration = (
|
|
||||||
db.query(UserIntegration)
|
|
||||||
.filter(UserIntegration.id == integration_id, UserIntegration.owner_id == owner_id)
|
|
||||||
.first()
|
|
||||||
)
|
|
||||||
if not integration:
|
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Integration not found")
|
|
||||||
|
|
||||||
credentials = _decode_credentials(integration.credentials)
|
|
||||||
return {"credentials": credentials or {}}
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Connection test helpers
|
# Connection test helpers
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -515,12 +490,6 @@ 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)
|
||||||
@@ -549,28 +518,17 @@ 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=endpoint_url,
|
endpoint_url=cfg.get("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"}
|
||||||
@@ -679,6 +637,62 @@ _CONNECTION_TESTERS: dict[str, Any] = {
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{integration_id}/test", summary="Test a saved integration connection")
|
||||||
|
def test_saved_integration_connection(
|
||||||
|
integration_id: int,
|
||||||
|
request: Request,
|
||||||
|
db: DbSession,
|
||||||
|
owner_id: CurrentOwner,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Test connection for an already-saved integration."""
|
||||||
|
integration = (
|
||||||
|
db.query(UserIntegration)
|
||||||
|
.filter(UserIntegration.id == integration_id, UserIntegration.owner_id == owner_id)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if not integration:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Integration not found")
|
||||||
|
|
||||||
|
tester = _CONNECTION_TESTERS.get(integration.integration_type)
|
||||||
|
if tester is None:
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"message": f"Connection testing is not yet supported for '{integration.integration_type}'. "
|
||||||
|
"The integration can still be saved and will be validated on first use.",
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
config = json.loads(integration.config) if integration.config else {}
|
||||||
|
except (json.JSONDecodeError, TypeError):
|
||||||
|
logger.warning(
|
||||||
|
"Invalid JSON in integration.config for integration_id=%s, type=%s",
|
||||||
|
integration.id,
|
||||||
|
integration.integration_type,
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"message": (
|
||||||
|
"Saved integration configuration is invalid and cannot be tested. "
|
||||||
|
"Please edit and re-save the integration, then try again."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
credentials = _decode_credentials(integration.credentials) or {}
|
||||||
|
|
||||||
|
try:
|
||||||
|
return tester(config, credentials)
|
||||||
|
except Exception:
|
||||||
|
logger.exception(
|
||||||
|
"Unexpected error testing integration_id=%s, type=%s",
|
||||||
|
integration.id,
|
||||||
|
integration.integration_type,
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"message": "An unexpected error occurred while testing the connection. Please check your configuration.",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/test", summary="Test an integration connection without saving")
|
@router.post("/test", summary="Test an integration connection without saving")
|
||||||
def test_integration_connection(
|
def test_integration_connection(
|
||||||
request: Request,
|
request: Request,
|
||||||
|
|||||||
+2
-13
@@ -25,17 +25,6 @@ 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(
|
||||||
@@ -313,15 +302,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).
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ Public endpoints:
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
from datetime import datetime, time, timedelta, timezone
|
from datetime import datetime, timezone
|
||||||
from typing import Annotated, Any
|
from typing import Annotated, Any
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||||
@@ -207,29 +207,19 @@ def platform_stats(request: Request, db: DbSession, _admin: AdminUser) -> dict[s
|
|||||||
from app.models import FileRecord, UserProfile
|
from app.models import FileRecord, UserProfile
|
||||||
|
|
||||||
today = datetime.now(timezone.utc).date()
|
today = datetime.now(timezone.utc).date()
|
||||||
day_start = datetime.combine(today, time.min, tzinfo=timezone.utc)
|
|
||||||
day_end = day_start + timedelta(days=1)
|
|
||||||
month_start = day_start.replace(day=1)
|
|
||||||
if month_start.month == 12:
|
|
||||||
month_end = month_start.replace(year=month_start.year + 1, month=1)
|
|
||||||
else:
|
|
||||||
month_end = month_start.replace(month=month_start.month + 1)
|
|
||||||
|
|
||||||
# Total files
|
# Total files
|
||||||
total_files: int = db.query(func.count(FileRecord.id)).scalar() or 0
|
total_files: int = db.query(func.count(FileRecord.id)).scalar() or 0
|
||||||
|
|
||||||
# Files today
|
# Files today
|
||||||
files_today: int = (
|
files_today: int = (
|
||||||
db.query(func.count(FileRecord.id))
|
db.query(func.count(FileRecord.id)).filter(func.date(FileRecord.created_at) == today).scalar() or 0
|
||||||
.filter(FileRecord.created_at >= day_start, FileRecord.created_at < day_end)
|
|
||||||
.scalar()
|
|
||||||
or 0
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Files this month
|
# Files this month
|
||||||
files_this_month: int = (
|
files_this_month: int = (
|
||||||
db.query(func.count(FileRecord.id))
|
db.query(func.count(FileRecord.id))
|
||||||
.filter(FileRecord.created_at >= month_start, FileRecord.created_at < month_end)
|
.filter(func.strftime("%Y-%m", FileRecord.created_at) == today.strftime("%Y-%m"))
|
||||||
.scalar()
|
.scalar()
|
||||||
or 0
|
or 0
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -28,10 +28,6 @@ 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"""
|
||||||
|
|
||||||
@@ -110,26 +106,6 @@ 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(
|
||||||
@@ -186,7 +162,6 @@ 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
|
||||||
},
|
},
|
||||||
@@ -276,10 +251,6 @@ 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)}")
|
||||||
|
|||||||
@@ -46,7 +46,6 @@ from app.tasks.translate_to_default_language import translate_to_default_languag
|
|||||||
# Import new send tasks
|
# Import new send tasks
|
||||||
from app.tasks.upload_to_dropbox import upload_to_dropbox # noqa: F401
|
from app.tasks.upload_to_dropbox import upload_to_dropbox # noqa: F401
|
||||||
from app.tasks.upload_to_email import upload_to_email # noqa: F401
|
from app.tasks.upload_to_email import upload_to_email # noqa: F401
|
||||||
from app.tasks.upload_to_evernote import upload_to_evernote # noqa: F401
|
|
||||||
from app.tasks.upload_to_ftp import upload_to_ftp # noqa: F401
|
from app.tasks.upload_to_ftp import upload_to_ftp # noqa: F401
|
||||||
from app.tasks.upload_to_google_drive import upload_to_google_drive # noqa: F401
|
from app.tasks.upload_to_google_drive import upload_to_google_drive # noqa: F401
|
||||||
from app.tasks.upload_to_icloud import upload_to_icloud # noqa: F401
|
from app.tasks.upload_to_icloud import upload_to_icloud # noqa: F401
|
||||||
|
|||||||
@@ -154,17 +154,6 @@ class Settings(BaseSettings):
|
|||||||
# "language": "Language", "correspondent": "Correspondent"}
|
# "language": "Language", "correspondent": "Correspondent"}
|
||||||
paperless_custom_fields_mapping: Optional[str] = None
|
paperless_custom_fields_mapping: Optional[str] = None
|
||||||
|
|
||||||
# Evernote destination settings
|
|
||||||
evernote_enabled: bool = Field(
|
|
||||||
default=True,
|
|
||||||
description="Enable Evernote as an upload destination. Set to False to disable uploads even when credentials are configured.",
|
|
||||||
)
|
|
||||||
evernote_auth_token: Optional[str] = None
|
|
||||||
evernote_sandbox: bool = False
|
|
||||||
evernote_notebook_guid: Optional[str] = None
|
|
||||||
evernote_default_tags: Optional[str] = None
|
|
||||||
evernote_include_metadata: bool = True
|
|
||||||
|
|
||||||
azure_ai_key: str
|
azure_ai_key: str
|
||||||
azure_region: str
|
azure_region: str
|
||||||
azure_endpoint: str
|
azure_endpoint: str
|
||||||
|
|||||||
+2
-8
@@ -294,16 +294,10 @@ async def lifespan(app: FastAPI):
|
|||||||
yield
|
yield
|
||||||
|
|
||||||
# Shutdown: Cleanup tasks
|
# Shutdown: Cleanup tasks
|
||||||
try:
|
logging.info("Application shutting down")
|
||||||
logging.info("Application shutting down")
|
|
||||||
except Exception:
|
|
||||||
_startup_logger.exception("Error during shutdown logging")
|
|
||||||
|
|
||||||
# Send shutdown notification
|
# Send shutdown notification
|
||||||
try:
|
notify_shutdown()
|
||||||
notify_shutdown()
|
|
||||||
except Exception:
|
|
||||||
_startup_logger.exception("Error sending shutdown notification")
|
|
||||||
|
|
||||||
|
|
||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
|
|||||||
@@ -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"}
|
IMAGE_EXTENSIONS = {".jpg", ".jpeg", ".png", ".gif", ".bmp", ".tiff", ".tif", ".webp", ".svg", ".heic", ".heif"}
|
||||||
|
|
||||||
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, periods, and underscores).\n"
|
"(YYYY-MM-DD_DescriptiveTitle, use only letters, numbers, spaces, dashes, 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,7 +18,6 @@ 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
|
||||||
@@ -406,11 +405,6 @@ 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)
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ from app.models import FileRecord, IntegrationDirection, UserIntegration
|
|||||||
from app.tasks.retry_config import BaseTaskWithRetry
|
from app.tasks.retry_config import BaseTaskWithRetry
|
||||||
from app.tasks.upload_to_dropbox import upload_to_dropbox
|
from app.tasks.upload_to_dropbox import upload_to_dropbox
|
||||||
from app.tasks.upload_to_email import upload_to_email
|
from app.tasks.upload_to_email import upload_to_email
|
||||||
from app.tasks.upload_to_evernote import upload_to_evernote
|
|
||||||
from app.tasks.upload_to_ftp import upload_to_ftp
|
from app.tasks.upload_to_ftp import upload_to_ftp
|
||||||
from app.tasks.upload_to_google_drive import upload_to_google_drive
|
from app.tasks.upload_to_google_drive import upload_to_google_drive
|
||||||
from app.tasks.upload_to_icloud import upload_to_icloud
|
from app.tasks.upload_to_icloud import upload_to_icloud
|
||||||
@@ -101,11 +100,6 @@ def _should_upload_to_email():
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _should_upload_to_evernote():
|
|
||||||
token = getattr(settings, "evernote_auth_token", None)
|
|
||||||
return bool(getattr(settings, "evernote_enabled", True) and isinstance(token, str) and token.strip())
|
|
||||||
|
|
||||||
|
|
||||||
def _should_upload_to_onedrive():
|
def _should_upload_to_onedrive():
|
||||||
return bool(
|
return bool(
|
||||||
getattr(settings, "onedrive_enabled", True)
|
getattr(settings, "onedrive_enabled", True)
|
||||||
@@ -157,7 +151,6 @@ def get_configured_services_from_validator():
|
|||||||
"FTP Storage": "ftp",
|
"FTP Storage": "ftp",
|
||||||
"SFTP Storage": "sftp",
|
"SFTP Storage": "sftp",
|
||||||
"Email": "email",
|
"Email": "email",
|
||||||
"Evernote": "evernote",
|
|
||||||
"OneDrive": "onedrive",
|
"OneDrive": "onedrive",
|
||||||
"S3 Storage": "s3",
|
"S3 Storage": "s3",
|
||||||
"SharePoint": "sharepoint",
|
"SharePoint": "sharepoint",
|
||||||
@@ -261,11 +254,6 @@ def send_to_all_destinations(self, file_path: str, use_validator=True, file_id:
|
|||||||
"should_upload": _should_upload_to_email,
|
"should_upload": _should_upload_to_email,
|
||||||
"upload_func": upload_to_email,
|
"upload_func": upload_to_email,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"name": "evernote",
|
|
||||||
"should_upload": _should_upload_to_evernote,
|
|
||||||
"upload_func": upload_to_evernote,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"name": "onedrive",
|
"name": "onedrive",
|
||||||
"should_upload": _should_upload_to_onedrive,
|
"should_upload": _should_upload_to_onedrive,
|
||||||
|
|||||||
@@ -1,242 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
|
|
||||||
import hashlib
|
|
||||||
import json
|
|
||||||
import logging
|
|
||||||
import mimetypes
|
|
||||||
import os
|
|
||||||
from html import escape
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from app.celery_app import celery
|
|
||||||
from app.config import settings
|
|
||||||
from app.tasks.retry_config import UploadTaskWithRetry
|
|
||||||
from app.utils import log_task_progress
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
_UNKNOWN_PLACEHOLDERS = {"", "Unknown", "unknown", "N/A", "n/a", "None", "none", "null"}
|
|
||||||
_MAX_EVERNOTE_TITLE_LENGTH = 255
|
|
||||||
|
|
||||||
|
|
||||||
def _get_evernote_sdk():
|
|
||||||
"""Import the Evernote SDK lazily so the missing dependency error is actionable."""
|
|
||||||
try:
|
|
||||||
from evernote.edam.notestore import NoteStore
|
|
||||||
from evernote.edam.type import ttypes as Types
|
|
||||||
from evernote.edam.userstore import UserStore
|
|
||||||
from thrift.protocol import TBinaryProtocol
|
|
||||||
from thrift.transport import THttpClient
|
|
||||||
except ImportError as exc:
|
|
||||||
raise RuntimeError("Evernote upload requires the evernote3 package. Install requirements.txt again.") from exc
|
|
||||||
return NoteStore, Types, UserStore, TBinaryProtocol, THttpClient
|
|
||||||
|
|
||||||
|
|
||||||
def _build_thrift_client(client_cls, url: str, binary_protocol, http_transport):
|
|
||||||
transport = http_transport.THttpClient(url)
|
|
||||||
protocol = binary_protocol.TBinaryProtocol(transport)
|
|
||||||
return client_cls(protocol)
|
|
||||||
|
|
||||||
|
|
||||||
def _get_note_store(auth_token: str):
|
|
||||||
NoteStore, Types, UserStore, TBinaryProtocol, THttpClient = _get_evernote_sdk()
|
|
||||||
|
|
||||||
base_url = (
|
|
||||||
"https://sandbox.evernote.com" if getattr(settings, "evernote_sandbox", False) else "https://www.evernote.com"
|
|
||||||
)
|
|
||||||
user_store = _build_thrift_client(UserStore.Client, f"{base_url}/edam/user", TBinaryProtocol, THttpClient)
|
|
||||||
user = user_store.getUser(auth_token)
|
|
||||||
shard_id = getattr(user, "shardId", None)
|
|
||||||
if not shard_id:
|
|
||||||
raise RuntimeError("Evernote user response did not include a shard ID")
|
|
||||||
|
|
||||||
note_store_url = f"{base_url}/shard/{shard_id}/notestore"
|
|
||||||
note_store = _build_thrift_client(NoteStore.Client, note_store_url, TBinaryProtocol, THttpClient)
|
|
||||||
return note_store, Types
|
|
||||||
|
|
||||||
|
|
||||||
def _load_metadata(file_path: str) -> dict[str, Any]:
|
|
||||||
"""Load extracted DocuElevate metadata from the companion JSON file, when present."""
|
|
||||||
json_path = os.path.splitext(file_path)[0] + ".json"
|
|
||||||
if not os.path.exists(json_path):
|
|
||||||
return {}
|
|
||||||
|
|
||||||
try:
|
|
||||||
with open(json_path, "r", encoding="utf-8") as metadata_file:
|
|
||||||
data = json.load(metadata_file)
|
|
||||||
except Exception as exc: # noqa: BLE001
|
|
||||||
logger.warning("Failed to load Evernote metadata from %s: %s", json_path, exc)
|
|
||||||
return {}
|
|
||||||
|
|
||||||
return data if isinstance(data, dict) else {}
|
|
||||||
|
|
||||||
|
|
||||||
def _normalize_metadata_value(value: Any) -> str:
|
|
||||||
if value is None:
|
|
||||||
return ""
|
|
||||||
if isinstance(value, (list, tuple, set)):
|
|
||||||
normalized = ", ".join(str(item) for item in value if item is not None)
|
|
||||||
elif isinstance(value, dict):
|
|
||||||
normalized = json.dumps(value, ensure_ascii=False, sort_keys=True)
|
|
||||||
else:
|
|
||||||
normalized = str(value)
|
|
||||||
|
|
||||||
normalized = normalized.strip()
|
|
||||||
return "" if normalized in _UNKNOWN_PLACEHOLDERS else normalized
|
|
||||||
|
|
||||||
|
|
||||||
def _metadata_rows(metadata: dict[str, Any]) -> list[tuple[str, str]]:
|
|
||||||
rows = []
|
|
||||||
for key in sorted(metadata):
|
|
||||||
value = _normalize_metadata_value(metadata[key])
|
|
||||||
if value:
|
|
||||||
rows.append((key, value))
|
|
||||||
return rows
|
|
||||||
|
|
||||||
|
|
||||||
def _extract_tags(metadata: dict[str, Any]) -> list[str]:
|
|
||||||
tags: list[str] = []
|
|
||||||
|
|
||||||
def add_tag(value: Any) -> None:
|
|
||||||
normalized = _normalize_metadata_value(value)
|
|
||||||
if normalized and normalized not in tags:
|
|
||||||
tags.append(normalized)
|
|
||||||
|
|
||||||
default_tags = getattr(settings, "evernote_default_tags", None)
|
|
||||||
if default_tags:
|
|
||||||
for tag in str(default_tags).split(","):
|
|
||||||
add_tag(tag)
|
|
||||||
|
|
||||||
metadata_tags = metadata.get("tags")
|
|
||||||
if isinstance(metadata_tags, str):
|
|
||||||
for tag in metadata_tags.split(","):
|
|
||||||
add_tag(tag)
|
|
||||||
elif isinstance(metadata_tags, (list, tuple, set)):
|
|
||||||
for tag in metadata_tags:
|
|
||||||
add_tag(tag)
|
|
||||||
|
|
||||||
return tags
|
|
||||||
|
|
||||||
|
|
||||||
def _note_title(file_path: str, metadata: dict[str, Any]) -> str:
|
|
||||||
title = (
|
|
||||||
_normalize_metadata_value(metadata.get("title"))
|
|
||||||
or _normalize_metadata_value(metadata.get("filename"))
|
|
||||||
or os.path.basename(file_path)
|
|
||||||
)
|
|
||||||
return title[:_MAX_EVERNOTE_TITLE_LENGTH]
|
|
||||||
|
|
||||||
|
|
||||||
def _build_enml(metadata: dict[str, Any], resource_hash: str, resource_mime: str, include_metadata: bool) -> str:
|
|
||||||
body_parts = ['<?xml version="1.0" encoding="UTF-8"?>']
|
|
||||||
body_parts.append('<!DOCTYPE en-note SYSTEM "http://xml.evernote.com/pub/enml2.dtd">')
|
|
||||||
body_parts.append("<en-note>")
|
|
||||||
|
|
||||||
if include_metadata:
|
|
||||||
rows = _metadata_rows(metadata)
|
|
||||||
if rows:
|
|
||||||
body_parts.append("<div><b>DocuElevate metadata</b></div>")
|
|
||||||
for key, value in rows:
|
|
||||||
body_parts.append(f"<div><b>{escape(key)}:</b> {escape(value)}</div>")
|
|
||||||
body_parts.append("<br/>")
|
|
||||||
|
|
||||||
body_parts.append(f'<en-media type="{escape(resource_mime)}" hash="{resource_hash}"/>')
|
|
||||||
body_parts.append("</en-note>")
|
|
||||||
return "".join(body_parts)
|
|
||||||
|
|
||||||
|
|
||||||
def _create_evernote_note(file_path: str, metadata: dict[str, Any], task_id: str):
|
|
||||||
auth_token = getattr(settings, "evernote_auth_token", None)
|
|
||||||
if not auth_token:
|
|
||||||
raise ValueError("Evernote auth token is not configured (EVERNOTE_AUTH_TOKEN)")
|
|
||||||
|
|
||||||
note_store, Types = _get_note_store(auth_token)
|
|
||||||
|
|
||||||
filename = os.path.basename(file_path)
|
|
||||||
with open(file_path, "rb") as pdf_file:
|
|
||||||
resource_body = pdf_file.read()
|
|
||||||
|
|
||||||
body_hash = hashlib.md5(resource_body).digest() # noqa: S324 - Evernote API requires MD5 resource hashes.
|
|
||||||
body_hash_hex = hashlib.md5(resource_body).hexdigest() # noqa: S324 - Evernote ENML references MD5 hashes.
|
|
||||||
resource_mime = mimetypes.guess_type(filename)[0] or "application/pdf"
|
|
||||||
|
|
||||||
data = Types.Data()
|
|
||||||
data.size = len(resource_body)
|
|
||||||
data.bodyHash = body_hash
|
|
||||||
data.body = resource_body
|
|
||||||
|
|
||||||
resource = Types.Resource()
|
|
||||||
resource.mime = resource_mime
|
|
||||||
resource.data = data
|
|
||||||
resource.attributes = Types.ResourceAttributes(fileName=filename)
|
|
||||||
|
|
||||||
note = Types.Note()
|
|
||||||
note.title = _note_title(file_path, metadata)
|
|
||||||
note.content = _build_enml(
|
|
||||||
metadata,
|
|
||||||
body_hash_hex,
|
|
||||||
resource_mime,
|
|
||||||
include_metadata=getattr(settings, "evernote_include_metadata", True),
|
|
||||||
)
|
|
||||||
note.resources = [resource]
|
|
||||||
|
|
||||||
notebook_guid = getattr(settings, "evernote_notebook_guid", None)
|
|
||||||
if notebook_guid:
|
|
||||||
note.notebookGuid = notebook_guid
|
|
||||||
|
|
||||||
tag_names = _extract_tags(metadata)
|
|
||||||
if tag_names:
|
|
||||||
note.tagNames = tag_names
|
|
||||||
|
|
||||||
created_note = note_store.createNote(auth_token, note)
|
|
||||||
|
|
||||||
logger.info("[%s] Created Evernote note %s for %s", task_id, getattr(created_note, "guid", None), file_path)
|
|
||||||
return created_note
|
|
||||||
|
|
||||||
|
|
||||||
@celery.task(base=UploadTaskWithRetry, bind=True)
|
|
||||||
def upload_to_evernote(self, file_path: str, file_id: int = None):
|
|
||||||
"""
|
|
||||||
Upload a document to Evernote by creating a note with metadata and a PDF attachment.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
file_path: Path to the PDF file to upload
|
|
||||||
file_id: Optional file ID to associate with logs
|
|
||||||
"""
|
|
||||||
task_id = self.request.id
|
|
||||||
filename = os.path.basename(file_path)
|
|
||||||
logger.info("[%s] Starting Evernote upload: %s", task_id, file_path)
|
|
||||||
log_task_progress(
|
|
||||||
task_id, "upload_to_evernote", "in_progress", f"Uploading to Evernote: {filename}", file_id=file_id
|
|
||||||
)
|
|
||||||
|
|
||||||
if not os.path.exists(file_path):
|
|
||||||
error_msg = f"File not found: {file_path}"
|
|
||||||
logger.error("[%s] %s", task_id, error_msg)
|
|
||||||
log_task_progress(task_id, "upload_to_evernote", "failure", error_msg, file_id=file_id)
|
|
||||||
raise FileNotFoundError(error_msg)
|
|
||||||
|
|
||||||
if not getattr(settings, "evernote_auth_token", None):
|
|
||||||
error_msg = "Evernote auth token is not configured (EVERNOTE_AUTH_TOKEN)"
|
|
||||||
logger.error("[%s] %s", task_id, error_msg)
|
|
||||||
log_task_progress(task_id, "upload_to_evernote", "failure", error_msg, file_id=file_id)
|
|
||||||
raise ValueError(error_msg)
|
|
||||||
|
|
||||||
try:
|
|
||||||
metadata = _load_metadata(file_path)
|
|
||||||
created_note = _create_evernote_note(file_path, metadata, task_id)
|
|
||||||
except Exception as exc:
|
|
||||||
error_msg = f"Failed to upload to Evernote: {exc}"
|
|
||||||
logger.error("[%s] %s", task_id, error_msg)
|
|
||||||
log_task_progress(task_id, "upload_to_evernote", "failure", error_msg, file_id=file_id)
|
|
||||||
raise
|
|
||||||
|
|
||||||
note_guid = getattr(created_note, "guid", None)
|
|
||||||
log_task_progress(task_id, "upload_to_evernote", "success", f"Uploaded to Evernote: {note_guid}", file_id=file_id)
|
|
||||||
return {
|
|
||||||
"status": "Completed",
|
|
||||||
"file_path": file_path,
|
|
||||||
"evernote_note_guid": note_guid,
|
|
||||||
"evernote_title": getattr(created_note, "title", None),
|
|
||||||
"evernote_notebook_guid": getattr(created_note, "notebookGuid", None),
|
|
||||||
}
|
|
||||||
@@ -555,8 +555,9 @@ 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,
|
||||||
|
|||||||
@@ -174,21 +174,6 @@ def get_provider_status() -> dict[str, dict[str, object]]:
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
providers["Evernote"] = {
|
|
||||||
"name": "Evernote",
|
|
||||||
"icon": "fa-brands fa-evernote",
|
|
||||||
"configured": bool(getattr(settings, "evernote_auth_token", None)),
|
|
||||||
"enabled": getattr(settings, "evernote_enabled", True),
|
|
||||||
"description": "Create Evernote notes with document metadata and PDF attachments",
|
|
||||||
"details": {
|
|
||||||
"auth_token": mask_sensitive_value(getattr(settings, "evernote_auth_token", None)),
|
|
||||||
"sandbox": getattr(settings, "evernote_sandbox", False),
|
|
||||||
"notebook_guid": getattr(settings, "evernote_notebook_guid", "Not set"),
|
|
||||||
"default_tags": getattr(settings, "evernote_default_tags", "Not set"),
|
|
||||||
"include_metadata": getattr(settings, "evernote_include_metadata", True),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
# Add FTP configuration to providers
|
# Add FTP configuration to providers
|
||||||
providers["FTP Storage"] = {
|
providers["FTP Storage"] = {
|
||||||
"name": "FTP Storage",
|
"name": "FTP Storage",
|
||||||
|
|||||||
@@ -145,12 +145,6 @@ def validate_storage_configs() -> dict[str, list[str]]:
|
|||||||
email_issues.append("DEST_EMAIL_DEFAULT_RECIPIENT is not configured")
|
email_issues.append("DEST_EMAIL_DEFAULT_RECIPIENT is not configured")
|
||||||
issues["email"] = email_issues
|
issues["email"] = email_issues
|
||||||
|
|
||||||
# Validate Evernote
|
|
||||||
evernote_issues = []
|
|
||||||
if not getattr(settings, "evernote_auth_token", None):
|
|
||||||
evernote_issues.append("EVERNOTE_AUTH_TOKEN is not configured")
|
|
||||||
issues["evernote"] = evernote_issues
|
|
||||||
|
|
||||||
# Validate S3
|
# Validate S3
|
||||||
s3_issues = []
|
s3_issues = []
|
||||||
if not getattr(settings, "s3_bucket_name", None):
|
if not getattr(settings, "s3_bucket_name", None):
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import logging
|
|||||||
import re
|
import re
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from sqlalchemy import MetaData, create_engine, func, inspect, select, table
|
from sqlalchemy import MetaData, create_engine, inspect, text
|
||||||
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,9 +89,8 @@ 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
|
||||||
t = table(table_name)
|
quoted_table = conn.dialect.identifier_preparer.quote(table_name)
|
||||||
query = select(func.count()).select_from(t)
|
row = conn.execute(text(f"SELECT COUNT(*) FROM {quoted_table}")).fetchone() # noqa: S608
|
||||||
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,10 +1,11 @@
|
|||||||
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.
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
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__)
|
||||||
|
|
||||||
@@ -33,28 +32,3 @@ def is_private_ip(hostname: str) -> bool:
|
|||||||
# and SSRF bypasses via unresolvable addresses.
|
# and SSRF bypasses via unresolvable addresses.
|
||||||
logger.warning(f"Could not resolve hostname (blocking securely): {hostname}")
|
logger.warning(f"Could not resolve hostname (blocking securely): {hostname}")
|
||||||
return True
|
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))
|
|
||||||
|
|||||||
@@ -1089,55 +1089,6 @@ SETTING_METADATA = {
|
|||||||
"required": False,
|
"required": False,
|
||||||
"restart_required": False,
|
"restart_required": False,
|
||||||
},
|
},
|
||||||
# Storage Providers - Evernote
|
|
||||||
"evernote_enabled": {
|
|
||||||
"category": "Storage Providers",
|
|
||||||
"description": "Enable Evernote as an upload destination. When disabled, no documents will be sent to Evernote even if credentials are configured.",
|
|
||||||
"type": "boolean",
|
|
||||||
"sensitive": False,
|
|
||||||
"required": False,
|
|
||||||
"restart_required": False,
|
|
||||||
},
|
|
||||||
"evernote_auth_token": {
|
|
||||||
"category": "Storage Providers",
|
|
||||||
"description": "Evernote developer token for note creation",
|
|
||||||
"type": "string",
|
|
||||||
"sensitive": True,
|
|
||||||
"required": False,
|
|
||||||
"restart_required": False,
|
|
||||||
},
|
|
||||||
"evernote_sandbox": {
|
|
||||||
"category": "Storage Providers",
|
|
||||||
"description": "Use the Evernote sandbox environment instead of production",
|
|
||||||
"type": "boolean",
|
|
||||||
"sensitive": False,
|
|
||||||
"required": False,
|
|
||||||
"restart_required": False,
|
|
||||||
},
|
|
||||||
"evernote_notebook_guid": {
|
|
||||||
"category": "Storage Providers",
|
|
||||||
"description": "Optional Evernote notebook GUID for uploaded notes",
|
|
||||||
"type": "string",
|
|
||||||
"sensitive": False,
|
|
||||||
"required": False,
|
|
||||||
"restart_required": False,
|
|
||||||
},
|
|
||||||
"evernote_default_tags": {
|
|
||||||
"category": "Storage Providers",
|
|
||||||
"description": "Comma-separated Evernote tags to apply to uploaded notes",
|
|
||||||
"type": "string",
|
|
||||||
"sensitive": False,
|
|
||||||
"required": False,
|
|
||||||
"restart_required": False,
|
|
||||||
},
|
|
||||||
"evernote_include_metadata": {
|
|
||||||
"category": "Storage Providers",
|
|
||||||
"description": "Include extracted document metadata in Evernote note content",
|
|
||||||
"type": "boolean",
|
|
||||||
"sensitive": False,
|
|
||||||
"required": False,
|
|
||||||
"restart_required": False,
|
|
||||||
},
|
|
||||||
# Storage Providers - Google Drive
|
# Storage Providers - Google Drive
|
||||||
"google_drive_enabled": {
|
"google_drive_enabled": {
|
||||||
"category": "Storage Providers",
|
"category": "Storage Providers",
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ At average usage (~40 % of quota) margins improve to 55-65 % after tax.
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
from datetime import date, datetime, time, timedelta, timezone
|
from datetime import date, datetime, timezone
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from sqlalchemy import func
|
from sqlalchemy import func
|
||||||
@@ -317,20 +317,6 @@ def _today_utc() -> date:
|
|||||||
return datetime.now(timezone.utc).date()
|
return datetime.now(timezone.utc).date()
|
||||||
|
|
||||||
|
|
||||||
def _day_bounds_utc(day: date) -> tuple[datetime, datetime]:
|
|
||||||
start = datetime.combine(day, time.min, tzinfo=timezone.utc)
|
|
||||||
return start, start + timedelta(days=1)
|
|
||||||
|
|
||||||
|
|
||||||
def _month_bounds_utc(day: date) -> tuple[datetime, datetime]:
|
|
||||||
start = datetime.combine(day.replace(day=1), time.min, tzinfo=timezone.utc)
|
|
||||||
if start.month == 12:
|
|
||||||
end = start.replace(year=start.year + 1, month=1)
|
|
||||||
else:
|
|
||||||
end = start.replace(month=start.month + 1)
|
|
||||||
return start, end
|
|
||||||
|
|
||||||
|
|
||||||
def _scalar_count(query: Any) -> int:
|
def _scalar_count(query: Any) -> int:
|
||||||
"""Execute a count query and return an int, defaulting to 0 for NULL."""
|
"""Execute a count query and return an int, defaulting to 0 for NULL."""
|
||||||
return query.scalar() or 0
|
return query.scalar() or 0
|
||||||
@@ -349,13 +335,12 @@ def get_today_file_count(db: Session, owner_id: str) -> int:
|
|||||||
"""Files processed by this user today (UTC, not counting duplicates)."""
|
"""Files processed by this user today (UTC, not counting duplicates)."""
|
||||||
from app.models import FileRecord
|
from app.models import FileRecord
|
||||||
|
|
||||||
day_start, day_end = _day_bounds_utc(_today_utc())
|
today = _today_utc()
|
||||||
return _scalar_count(
|
return _scalar_count(
|
||||||
db.query(func.count(FileRecord.id)).filter(
|
db.query(func.count(FileRecord.id)).filter(
|
||||||
FileRecord.owner_id == owner_id,
|
FileRecord.owner_id == owner_id,
|
||||||
FileRecord.is_duplicate.is_(False),
|
FileRecord.is_duplicate.is_(False),
|
||||||
FileRecord.created_at >= day_start,
|
func.date(FileRecord.created_at) == today,
|
||||||
FileRecord.created_at < day_end,
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -364,13 +349,12 @@ def get_month_file_count(db: Session, owner_id: str) -> int:
|
|||||||
"""Files processed by this user this calendar month (UTC, not counting duplicates)."""
|
"""Files processed by this user this calendar month (UTC, not counting duplicates)."""
|
||||||
from app.models import FileRecord
|
from app.models import FileRecord
|
||||||
|
|
||||||
month_start, month_end = _month_bounds_utc(_today_utc())
|
today = _today_utc()
|
||||||
return _scalar_count(
|
return _scalar_count(
|
||||||
db.query(func.count(FileRecord.id)).filter(
|
db.query(func.count(FileRecord.id)).filter(
|
||||||
FileRecord.owner_id == owner_id,
|
FileRecord.owner_id == owner_id,
|
||||||
FileRecord.is_duplicate.is_(False),
|
FileRecord.is_duplicate.is_(False),
|
||||||
FileRecord.created_at >= month_start,
|
func.strftime("%Y-%m", FileRecord.created_at) == today.strftime("%Y-%m"),
|
||||||
FileRecord.created_at < month_end,
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -12,13 +12,11 @@ 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__)
|
||||||
|
|
||||||
@@ -30,11 +28,6 @@ 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(
|
||||||
@@ -135,20 +128,6 @@ 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,13 +18,11 @@ 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__)
|
||||||
|
|
||||||
@@ -42,11 +40,6 @@ 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:
|
||||||
@@ -74,20 +67,6 @@ 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")
|
||||||
|
|
||||||
|
|||||||
@@ -717,7 +717,6 @@ def _compute_processing_flow(logs, pipeline_steps=None):
|
|||||||
"upload_to_ftp": "FTP Storage",
|
"upload_to_ftp": "FTP Storage",
|
||||||
"upload_to_sftp": "SFTP Storage",
|
"upload_to_sftp": "SFTP Storage",
|
||||||
"upload_to_email": "Email",
|
"upload_to_email": "Email",
|
||||||
"upload_to_evernote": "Evernote",
|
|
||||||
"queue_dropbox": "Dropbox",
|
"queue_dropbox": "Dropbox",
|
||||||
"queue_nextcloud": "Nextcloud",
|
"queue_nextcloud": "Nextcloud",
|
||||||
"queue_paperless": "Paperless-ngx",
|
"queue_paperless": "Paperless-ngx",
|
||||||
@@ -728,7 +727,6 @@ def _compute_processing_flow(logs, pipeline_steps=None):
|
|||||||
"queue_ftp": "FTP Storage",
|
"queue_ftp": "FTP Storage",
|
||||||
"queue_sftp": "SFTP Storage",
|
"queue_sftp": "SFTP Storage",
|
||||||
"queue_email": "Email",
|
"queue_email": "Email",
|
||||||
"queue_evernote": "Evernote",
|
|
||||||
}
|
}
|
||||||
|
|
||||||
# Create a map of step names to their log entries
|
# Create a map of step names to their log entries
|
||||||
|
|||||||
+3
-13
@@ -2,7 +2,7 @@
|
|||||||
General routes for the application homepage and basic pages.
|
General routes for the application homepage and basic pages.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from datetime import date, datetime, time, timedelta, timezone
|
from datetime import date, datetime, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from fastapi import Depends, HTTPException, Request
|
from fastapi import Depends, HTTPException, Request
|
||||||
@@ -64,27 +64,17 @@ async def serve_index(request: Request, db: Session = Depends(get_db)):
|
|||||||
|
|
||||||
user = request.session.get("user") or {}
|
user = request.session.get("user") or {}
|
||||||
is_admin = user.get("is_admin", False)
|
is_admin = user.get("is_admin", False)
|
||||||
day_start = datetime.combine(today, time.min, tzinfo=timezone.utc)
|
|
||||||
day_end = day_start + timedelta(days=1)
|
|
||||||
month_start = day_start.replace(day=1)
|
|
||||||
if month_start.month == 12:
|
|
||||||
month_end = month_start.replace(year=month_start.year + 1, month=1)
|
|
||||||
else:
|
|
||||||
month_end = month_start.replace(month=month_start.month + 1)
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
total_files: int = db.query(func.count(FileRecord.id)).scalar() or 0
|
total_files: int = db.query(func.count(FileRecord.id)).scalar() or 0
|
||||||
|
|
||||||
files_today: int = (
|
files_today: int = (
|
||||||
db.query(func.count(FileRecord.id))
|
db.query(func.count(FileRecord.id)).filter(func.date(FileRecord.created_at) == today).scalar() or 0
|
||||||
.filter(FileRecord.created_at >= day_start, FileRecord.created_at < day_end)
|
|
||||||
.scalar()
|
|
||||||
or 0
|
|
||||||
)
|
)
|
||||||
|
|
||||||
files_month: int = (
|
files_month: int = (
|
||||||
db.query(func.count(FileRecord.id))
|
db.query(func.count(FileRecord.id))
|
||||||
.filter(FileRecord.created_at >= month_start, FileRecord.created_at < month_end)
|
.filter(func.strftime("%Y-%m", FileRecord.created_at) == today.strftime("%Y-%m"))
|
||||||
.scalar()
|
.scalar()
|
||||||
or 0
|
or 0
|
||||||
)
|
)
|
||||||
|
|||||||
+25
-4
@@ -3,7 +3,7 @@ services:
|
|||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
container_name: document_api
|
# No container_name — allows `docker compose up --scale api=N`
|
||||||
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
|
||||||
- worker
|
- beat
|
||||||
|
|
||||||
# Mount the shared working directory for data
|
# Mount the shared working directory for data
|
||||||
volumes:
|
volumes:
|
||||||
@@ -34,13 +34,14 @@ services:
|
|||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
container_name: document_worker
|
# No container_name — allows `docker compose up --scale worker=N`
|
||||||
restart: always
|
restart: always
|
||||||
|
|
||||||
# same shared working directory
|
# same shared working directory
|
||||||
working_dir: /workdir
|
working_dir: /workdir
|
||||||
|
|
||||||
command: ["celery", "-A", "app.celery_worker", "worker", "-B", "--loglevel=info", "-Q", "document_processor,default,celery"]
|
# Workers process tasks only — no -B flag (Beat runs in the dedicated beat service)
|
||||||
|
command: ["celery", "-A", "app.celery_worker", "worker", "--loglevel=info", "-Q", "document_processor,default,celery"]
|
||||||
env_file:
|
env_file:
|
||||||
- .env
|
- .env
|
||||||
environment:
|
environment:
|
||||||
@@ -54,6 +55,26 @@ 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
|
||||||
|
|||||||
@@ -1330,19 +1330,6 @@ For detailed setup instructions, see the [Google Drive Setup Guide](GoogleDriveS
|
|||||||
| `DEST_EMAIL_SENDER` | From address for delivered documents (e.g., `"DocuElevate Delivery <docuelevate@example.com>"`). |
|
| `DEST_EMAIL_SENDER` | From address for delivered documents (e.g., `"DocuElevate Delivery <docuelevate@example.com>"`). |
|
||||||
| `DEST_EMAIL_DEFAULT_RECIPIENT` | Fallback recipient email when none is specified for a delivery task. |
|
| `DEST_EMAIL_DEFAULT_RECIPIENT` | Fallback recipient email when none is specified for a delivery task. |
|
||||||
|
|
||||||
### Evernote
|
|
||||||
|
|
||||||
| **Variable** | **Description** |
|
|
||||||
|-------------------------------|---------------------------------------------------------------------|
|
|
||||||
| `EVERNOTE_ENABLED` | Set to `false` to disable Evernote uploads without removing credentials. Default: `true` |
|
|
||||||
| `EVERNOTE_AUTH_TOKEN` | Evernote developer token or OAuth access token used to create notes. |
|
|
||||||
| `EVERNOTE_SANDBOX` | Use Evernote sandbox API endpoints. Default: `false` |
|
|
||||||
| `EVERNOTE_NOTEBOOK_GUID` | Optional target notebook GUID. If omitted, Evernote uses the default notebook. |
|
|
||||||
| `EVERNOTE_DEFAULT_TAGS` | Optional comma-separated tags applied to every created note. |
|
|
||||||
| `EVERNOTE_INCLUDE_METADATA` | Include extracted metadata in the Evernote note body. Default: `true` |
|
|
||||||
|
|
||||||
For detailed setup instructions, see the [Evernote Setup Guide](EvernoteSetup.md).
|
|
||||||
|
|
||||||
### OneDrive / Microsoft Graph
|
### OneDrive / Microsoft Graph
|
||||||
|
|
||||||
| **Variable** | **Description** |
|
| **Variable** | **Description** |
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ DocuElevate is designed to be highly configurable through environment variables,
|
|||||||
- [Dropbox Setup](DropboxSetup.md) - How to set up Dropbox integration
|
- [Dropbox Setup](DropboxSetup.md) - How to set up Dropbox integration
|
||||||
- [OneDrive Setup](OneDriveSetup.md) - How to set up Microsoft OneDrive/Graph integration
|
- [OneDrive Setup](OneDriveSetup.md) - How to set up Microsoft OneDrive/Graph integration
|
||||||
- [Amazon S3 Setup](AmazonS3Setup.md) - How to set up Amazon S3 integration
|
- [Amazon S3 Setup](AmazonS3Setup.md) - How to set up Amazon S3 integration
|
||||||
- [Evernote Setup](EvernoteSetup.md) - How to set up Evernote note creation
|
|
||||||
- [Authentication Setup](AuthenticationSetup.md) - How to set up user authentication
|
- [Authentication Setup](AuthenticationSetup.md) - How to set up user authentication
|
||||||
- [Notifications Setup](NotificationsSetup.md) - How to set up system notifications
|
- [Notifications Setup](NotificationsSetup.md) - How to set up system notifications
|
||||||
|
|
||||||
|
|||||||
@@ -1,51 +0,0 @@
|
|||||||
# Setting up Evernote Integration
|
|
||||||
|
|
||||||
This guide explains how to configure DocuElevate to create Evernote notes for processed documents.
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
The Evernote destination creates one note per processed document. The note contains:
|
|
||||||
|
|
||||||
- A visible metadata section populated from DocuElevate's extracted metadata JSON
|
|
||||||
- The processed PDF attached as an Evernote resource
|
|
||||||
- Optional tags from `EVERNOTE_DEFAULT_TAGS` plus extracted document tags
|
|
||||||
|
|
||||||
## Required Configuration
|
|
||||||
|
|
||||||
| Variable | Description |
|
|
||||||
|----------|-------------|
|
|
||||||
| `EVERNOTE_ENABLED` | Set to `false` to disable Evernote uploads without removing credentials. Default: `true` |
|
|
||||||
| `EVERNOTE_AUTH_TOKEN` | Evernote developer token or OAuth access token with note creation permissions |
|
|
||||||
|
|
||||||
## Optional Configuration
|
|
||||||
|
|
||||||
| Variable | Description |
|
|
||||||
|----------|-------------|
|
|
||||||
| `EVERNOTE_SANDBOX` | Use Evernote sandbox API endpoints. Default: `false` |
|
|
||||||
| `EVERNOTE_NOTEBOOK_GUID` | Target notebook GUID. If omitted, Evernote uses the account default notebook |
|
|
||||||
| `EVERNOTE_DEFAULT_TAGS` | Comma-separated tags to apply to every created note, for example `docuelevate,archive` |
|
|
||||||
| `EVERNOTE_INCLUDE_METADATA` | Include extracted metadata in the note body. Default: `true` |
|
|
||||||
|
|
||||||
## Example
|
|
||||||
|
|
||||||
```dotenv
|
|
||||||
EVERNOTE_ENABLED=true
|
|
||||||
EVERNOTE_AUTH_TOKEN=your-evernote-token
|
|
||||||
EVERNOTE_NOTEBOOK_GUID=optional-notebook-guid
|
|
||||||
EVERNOTE_DEFAULT_TAGS=docuelevate,processed
|
|
||||||
EVERNOTE_INCLUDE_METADATA=true
|
|
||||||
```
|
|
||||||
|
|
||||||
## Metadata and Attachments
|
|
||||||
|
|
||||||
DocuElevate reads the companion metadata file next to the processed PDF, for example `invoice.pdf` and `invoice.json`. Non-empty metadata fields are rendered into the Evernote note body. Values such as `Unknown`, empty strings, and null values are skipped.
|
|
||||||
|
|
||||||
If the metadata contains a `tags` field, those tags are applied to the note together with any tags configured in `EVERNOTE_DEFAULT_TAGS`.
|
|
||||||
|
|
||||||
The processed PDF is attached directly to the note using Evernote's resource model, so it appears as a normal Evernote attachment.
|
|
||||||
|
|
||||||
## Notes
|
|
||||||
|
|
||||||
- Evernote tokens can expire or be revoked. If uploads start failing with authentication errors, generate or refresh the token and update `EVERNOTE_AUTH_TOKEN`.
|
|
||||||
- If `EVERNOTE_NOTEBOOK_GUID` points to a missing or inaccessible notebook, Evernote will reject the note creation request.
|
|
||||||
- Evernote enforces account upload quotas and per-note size limits. Large PDFs may fail if they exceed those limits.
|
|
||||||
@@ -24,7 +24,6 @@ Welcome to the DocuElevate documentation. This directory contains comprehensive
|
|||||||
- [OneDrive Setup](OneDriveSetup.md) - How to set up Microsoft OneDrive/Graph integration
|
- [OneDrive Setup](OneDriveSetup.md) - How to set up Microsoft OneDrive/Graph integration
|
||||||
- [SharePoint Setup](SharePointSetup.md) - How to set up Microsoft SharePoint Online integration
|
- [SharePoint Setup](SharePointSetup.md) - How to set up Microsoft SharePoint Online integration
|
||||||
- [Amazon S3 Setup](AmazonS3Setup.md) - How to set up Amazon S3 integration
|
- [Amazon S3 Setup](AmazonS3Setup.md) - How to set up Amazon S3 integration
|
||||||
- [Evernote Setup](EvernoteSetup.md) - How to set up Evernote note creation
|
|
||||||
- [Authentication Setup](AuthenticationSetup.md) - How to set up user authentication
|
- [Authentication Setup](AuthenticationSetup.md) - How to set up user authentication
|
||||||
- [Notifications Setup](NotificationsSetup.md) - How to set up system notifications
|
- [Notifications Setup](NotificationsSetup.md) - How to set up system notifications
|
||||||
|
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ Settings are organized into logical categories for easy navigation:
|
|||||||
- **Authentication**: Login settings, session secrets, OAuth configuration, admin group
|
- **Authentication**: Login settings, session secrets, OAuth configuration, admin group
|
||||||
- **AI Services**: AI provider selection, model configuration, embeddings, and credentials (OpenAI, Azure, Anthropic, Gemini, Ollama, OpenRouter, Portkey, LiteLLM)
|
- **AI Services**: AI provider selection, model configuration, embeddings, and credentials (OpenAI, Azure, Anthropic, Gemini, Ollama, OpenRouter, Portkey, LiteLLM)
|
||||||
- **OCR Engines**: OCR provider selection and configuration (Tesseract, EasyOCR, Mistral, Google DocAI, AWS Textract)
|
- **OCR Engines**: OCR provider selection and configuration (Tesseract, EasyOCR, Mistral, Google DocAI, AWS Textract)
|
||||||
- **Storage Providers**: Dropbox, Google Drive, OneDrive, SharePoint, S3, FTP, SFTP, WebDAV, Nextcloud, Paperless, Evernote
|
- **Storage Providers**: Dropbox, Google Drive, OneDrive, SharePoint, S3, FTP, SFTP, WebDAV, Nextcloud, Paperless
|
||||||
- **Email**: SMTP configuration for sending emails
|
- **Email**: SMTP configuration for sending emails
|
||||||
- **IMAP**: Email ingestion configuration (supports two mailbox accounts)
|
- **IMAP**: Email ingestion configuration (supports two mailbox accounts)
|
||||||
- **Monitoring**: Uptime Kuma integration
|
- **Monitoring**: Uptime Kuma integration
|
||||||
|
|||||||
@@ -125,7 +125,6 @@ Several cloud storage integrations include their own guided configuration pages
|
|||||||
| Google Drive | `/google-drive-setup` | [GoogleDriveSetup.md](GoogleDriveSetup.md) |
|
| Google Drive | `/google-drive-setup` | [GoogleDriveSetup.md](GoogleDriveSetup.md) |
|
||||||
| OneDrive / SharePoint | `/onedrive-setup` | [OneDriveSetup.md](OneDriveSetup.md) |
|
| OneDrive / SharePoint | `/onedrive-setup` | [OneDriveSetup.md](OneDriveSetup.md) |
|
||||||
| Amazon S3 | Configured via settings | [AmazonS3Setup.md](AmazonS3Setup.md) |
|
| Amazon S3 | Configured via settings | [AmazonS3Setup.md](AmazonS3Setup.md) |
|
||||||
| Evernote | Configured via settings | [EvernoteSetup.md](EvernoteSetup.md) |
|
|
||||||
|
|
||||||
These pages are accessed **after** the main Setup Wizard is complete and are independent wizard flows specific to each integration.
|
These pages are accessed **after** the main Setup Wizard is complete and are independent wizard flows specific to each integration.
|
||||||
|
|
||||||
|
|||||||
Generated
+13
-13
@@ -473,9 +473,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/nanoid": {
|
"node_modules/nanoid": {
|
||||||
"version": "3.3.12",
|
"version": "3.3.11",
|
||||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz",
|
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
|
||||||
"integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==",
|
"integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
@@ -536,9 +536,9 @@
|
|||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
"node_modules/picomatch": {
|
"node_modules/picomatch": {
|
||||||
"version": "2.3.2",
|
"version": "2.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
|
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
|
||||||
"integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
|
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -569,9 +569,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/postcss": {
|
"node_modules/postcss": {
|
||||||
"version": "8.5.15",
|
"version": "8.5.8",
|
||||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz",
|
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz",
|
||||||
"integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==",
|
"integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
@@ -589,7 +589,7 @@
|
|||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"nanoid": "^3.3.12",
|
"nanoid": "^3.3.11",
|
||||||
"picocolors": "^1.1.1",
|
"picocolors": "^1.1.1",
|
||||||
"source-map-js": "^1.2.1"
|
"source-map-js": "^1.2.1"
|
||||||
},
|
},
|
||||||
@@ -974,9 +974,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/tinyglobby/node_modules/picomatch": {
|
"node_modules/tinyglobby/node_modules/picomatch": {
|
||||||
"version": "4.0.4",
|
"version": "4.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
||||||
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
|
|||||||
@@ -293,24 +293,13 @@ function processFiles(files, progressContainer, statusMessage) {
|
|||||||
updateStatus();
|
updateStatus();
|
||||||
}
|
}
|
||||||
|
|
||||||
function _escapeHtml(str) {
|
|
||||||
if (!str) return '';
|
|
||||||
return String(str)
|
|
||||||
.replace(/&/g, '&')
|
|
||||||
.replace(/</g, '<')
|
|
||||||
.replace(/>/g, '>')
|
|
||||||
.replace(/"/g, '"')
|
|
||||||
.replace(/'/g, ''');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Pre-create one progress row per file.
|
// Pre-create one progress row per file.
|
||||||
const queueItems = fileArray.map((file) => {
|
const queueItems = fileArray.map((file) => {
|
||||||
const row = document.createElement('div');
|
const row = document.createElement('div');
|
||||||
row.className = 'flex flex-col mb-2';
|
row.className = 'flex flex-col mb-2';
|
||||||
const safeFileName = _escapeHtml(file.name);
|
|
||||||
row.innerHTML = `
|
row.innerHTML = `
|
||||||
<div class="flex justify-between">
|
<div class="flex justify-between">
|
||||||
<span class="text-sm truncate" title="${safeFileName}">${safeFileName}</span>
|
<span class="text-sm truncate" title="${file.name}">${file.name}</span>
|
||||||
<span class="text-xs text-gray-500">${formatFileSize(file.size)}</span>
|
<span class="text-xs text-gray-500">${formatFileSize(file.size)}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="w-full bg-gray-200 h-2 rounded-full mt-1">
|
<div class="w-full bg-gray-200 h-2 rounded-full mt-1">
|
||||||
@@ -557,3 +546,4 @@ function initDragAndDrop(element, progressContainer, statusMessage, options = {}
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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>{{ _("billing.success_page_title") }}</title>
|
<title>{{ _("billing.success_page_title") }}</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" />
|
||||||
|
|||||||
@@ -1056,7 +1056,7 @@
|
|||||||
|
|
||||||
html += `
|
html += `
|
||||||
<div role="listitem">
|
<div role="listitem">
|
||||||
<a href="/files/${doc.file_id}/detail" aria-label="${title} — ${scorePercent}% similarity (${scoreLabel})" style="text-decoration: none; color: inherit; display: block;">
|
<a href="/files/${doc.file_id}" 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,19 +1091,20 @@
|
|||||||
}
|
}
|
||||||
</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" class="back-button" style="margin-bottom:0;" aria-label="Back to File List">
|
<a href="/files/{{ file.id }}" class="back-button" style="margin-bottom:0;" aria-label="Back to File Summary">
|
||||||
<i class="fas fa-arrow-left" aria-hidden="true"></i>
|
<i class="fas fa-arrow-left" aria-hidden="true"></i>
|
||||||
Back to File List
|
Back to File Summary
|
||||||
</a>
|
</a>
|
||||||
{% if file %}
|
{% if file %}
|
||||||
<a href="/files/{{ file.id }}" class="back-button" style="margin-bottom:0;" aria-label="View document for {{ file.original_filename }}">
|
<a href="/files/{{ file.id }}/detail" class="back-button" style="margin-bottom:0;" aria-label="View document detail for {{ file.original_filename }}">
|
||||||
<i class="fas fa-eye" aria-hidden="true"></i>
|
<i class="fas fa-eye" aria-hidden="true"></i>
|
||||||
View Document
|
Document Detail
|
||||||
</a>
|
</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1538,28 +1538,6 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
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');
|
||||||
@@ -1577,31 +1555,25 @@
|
|||||||
|
|
||||||
list.innerHTML = results.map(hit => {
|
list.innerHTML = results.map(hit => {
|
||||||
const fmt = hit._formatted || {};
|
const fmt = hit._formatted || {};
|
||||||
const titleRaw = fmt.document_title || hit.document_title || hit.original_filename || __i18n.untitled;
|
const title = fmt.document_title || hit.document_title || hit.original_filename || __i18n.untitled;
|
||||||
const filenameRaw = fmt.original_filename || hit.original_filename || '';
|
const filename = fmt.original_filename || hit.original_filename || '';
|
||||||
const snippetRaw = fmt.ocr_text || '';
|
const snippet = fmt.ocr_text || '';
|
||||||
const tagsRaw = Array.isArray(hit.tags) ? hit.tags.join(', ') : (hit.tags || '');
|
const tags = Array.isArray(hit.tags) ? hit.tags.join(', ') : (hit.tags || '');
|
||||||
const docTypeRaw = hit.document_type || '';
|
const docType = 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;">${safeTitle}</div>
|
<div style="font-weight: 600; font-size: 0.9rem; color: #111827;">${title}</div>
|
||||||
${safeFilename ? `<div style="font-size: 0.8rem; color: #6b7280; margin-top: 0.15rem;">${safeFilename}</div>` : ''}
|
${filename ? `<div style="font-size: 0.8rem; color: #6b7280; margin-top: 0.15rem;">${filename}</div>` : ''}
|
||||||
${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>` : ''}
|
${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>` : ''}
|
||||||
${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>` : ''}
|
${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>` : ''}
|
||||||
${safeSnippet ? `<div style="margin-top: 0.4rem; font-size: 0.8rem; color: #374151; white-space: pre-wrap; word-break: break-word;">…${safeSnippet}…</div>` : ''}
|
${snippet ? `<div style="margin-top: 0.4rem; font-size: 0.8rem; color: #374151; white-space: pre-wrap; word-break: break-word;">…${snippet}…</div>` : ''}
|
||||||
</div>
|
</div>
|
||||||
<div style="flex-shrink: 0;">
|
<div style="flex-shrink: 0;">
|
||||||
<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}">
|
<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}">
|
||||||
<i class="fas fa-external-link-alt"></i>
|
<i class="fas fa-external-link-alt"></i>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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 - Forgot Password</title>
|
<title>DocuElevate - Forgot Password</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" />
|
||||||
|
|||||||
@@ -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 - Forgot Username</title>
|
<title>DocuElevate - Forgot Username</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" />
|
||||||
|
|||||||
@@ -1379,27 +1379,15 @@ function integrationsDashboard() {
|
|||||||
async testSavedIntegration(intg) {
|
async testSavedIntegration(intg) {
|
||||||
this.testingId = intg.id;
|
this.testingId = intg.id;
|
||||||
try {
|
try {
|
||||||
// Retrieve saved credentials to test
|
const resp = await fetch(`/api/integrations/${intg.id}/test`, {
|
||||||
const credsResp = await fetch(`/api/integrations/${intg.id}/credentials`);
|
|
||||||
if (!credsResp.ok) {
|
|
||||||
this.showAlert('error', 'Test Failed', 'Could not retrieve saved credentials for testing.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const creds = await credsResp.json();
|
|
||||||
const resp = await fetch('/api/integrations/test', {
|
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: authHeaders(true),
|
headers: authHeaders(true),
|
||||||
body: JSON.stringify({
|
|
||||||
integration_type: intg.integration_type,
|
|
||||||
config: intg.config,
|
|
||||||
credentials: creds,
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
const data = await resp.json();
|
const data = await resp.json();
|
||||||
if (data.success) {
|
if (resp.ok && data.success) {
|
||||||
this.showAlert('success', `${intg.name}: Connection OK`, data.message);
|
this.showAlert('success', `${intg.name}: Connection OK`, data.message);
|
||||||
} else {
|
} else {
|
||||||
this.showAlert('error', `${intg.name}: Connection Failed`, data.message);
|
this.showAlert('error', `${intg.name}: Connection Failed`, data.message || {{ _("integrations.connection_test_failed_fallback")|tojson }});
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.showAlert('error', 'Test Failed', `Network error: ${err.message || 'Unknown error'}`);
|
this.showAlert('error', 'Test Failed', `Network error: ${err.message || 'Unknown error'}`);
|
||||||
|
|||||||
@@ -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>{{ _("app.name") }} - {{ _("auth.login_title") }}</title>
|
<title>{{ _("app.name") }} - {{ _("auth.login_title") }}</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" />
|
||||||
|
|||||||
@@ -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 - Reset Password</title>
|
<title>DocuElevate - Reset Password</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" />
|
||||||
|
|||||||
@@ -298,13 +298,9 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function escapeHtml(str) {
|
function escapeHtml(str) {
|
||||||
if (str === null || str === undefined) return '';
|
const d = document.createElement('div');
|
||||||
return String(str)
|
d.textContent = str;
|
||||||
.replace(/&/g, '&')
|
return d.innerHTML;
|
||||||
.replace(/</g, '<')
|
|
||||||
.replace(/>/g, '>')
|
|
||||||
.replace(/"/g, '"')
|
|
||||||
.replace(/'/g, ''');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -4,8 +4,8 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<title>Shared Document – DocuElevate</title>
|
<title>Shared Document – DocuElevate</title>
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<!-- Tailwind CSS -->
|
<!-- Tailwind CSS v3 (compiled) -->
|
||||||
<link href="https://cdn.jsdelivr.net/npm/tailwindcss@2.2.19/dist/tailwind.min.css" rel="stylesheet" />
|
<link rel="stylesheet" href="/static/styles.css" />
|
||||||
<!-- 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=="
|
||||||
|
|||||||
@@ -393,16 +393,6 @@ const i18nStrings = {
|
|||||||
configureNow: {{ _("status.configure_now") | tojson }},
|
configureNow: {{ _("status.configure_now") | tojson }},
|
||||||
};
|
};
|
||||||
|
|
||||||
function escapeHtml(str) {
|
|
||||||
if (!str) return '';
|
|
||||||
return String(str)
|
|
||||||
.replace(/&/g, '&')
|
|
||||||
.replace(/</g, '<')
|
|
||||||
.replace(/>/g, '>')
|
|
||||||
.replace(/"/g, '"')
|
|
||||||
.replace(/'/g, ''');
|
|
||||||
}
|
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
// Modal elements
|
// Modal elements
|
||||||
const resultModal = document.getElementById('resultModal');
|
const resultModal = document.getElementById('resultModal');
|
||||||
@@ -479,8 +469,9 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
const isSensitive = sensitiveKeys.some(sKey => key.toLowerCase().includes(sKey));
|
const isSensitive = sensitiveKeys.some(sKey => key.toLowerCase().includes(sKey));
|
||||||
|
|
||||||
if (isSensitive && value !== 'Not set' && value !== '') {
|
if (isSensitive && value !== 'Not set' && value !== '') {
|
||||||
|
valueCell.textContent = value.slice(4) + '********' + value.slice(-4);
|
||||||
// For better readability, we can also use HTML to mask the middle part of the string
|
// For better readability, we can also use HTML to mask the middle part of the string
|
||||||
valueCell.innerHTML = escapeHtml(value.slice(0, 4)) + '<span class="text-gray-400">********</span>' + escapeHtml(value.slice(-4));
|
valueCell.innerHTML = value.slice(0, 4) + '<span class="text-gray-400">********</span>' + value.slice(-4);
|
||||||
} else {
|
} else {
|
||||||
valueCell.textContent = value;
|
valueCell.textContent = value;
|
||||||
}
|
}
|
||||||
@@ -595,9 +586,9 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
if (data.status === 'success') {
|
if (data.status === 'success') {
|
||||||
// If there's token info, we need to handle it specially
|
// If there's token info, we need to handle it specially
|
||||||
if (data.token_info && data.token_info.expires_in_human) {
|
if (data.token_info && data.token_info.expires_in_human) {
|
||||||
let message = escapeHtml(data.message || 'Connection successful');
|
let message = data.message || 'Connection successful';
|
||||||
message += `<br><br><div class="bg-blue-50 p-3 rounded mt-2">
|
message += `<br><br><div class="bg-blue-50 p-3 rounded mt-2">
|
||||||
<span class="font-medium">${i18nStrings.tokenValidFor}</span> ${escapeHtml(data.token_info.expires_in_human)}
|
<span class="font-medium">${i18nStrings.tokenValidFor}</span> ${data.token_info.expires_in_human}
|
||||||
</div>`;
|
</div>`;
|
||||||
|
|
||||||
modalTitle.textContent = i18nStrings.testSuccessful;
|
modalTitle.textContent = i18nStrings.testSuccessful;
|
||||||
@@ -656,12 +647,12 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
.then(data => {
|
.then(data => {
|
||||||
if (data.status === 'success') {
|
if (data.status === 'success') {
|
||||||
// Create successful message
|
// Create successful message
|
||||||
let message = escapeHtml(data.message || 'Connection successful');
|
let message = data.message || 'Connection successful';
|
||||||
|
|
||||||
// Add token expiration info if available (especially for Google Drive)
|
// Add token expiration info if available (especially for Google Drive)
|
||||||
if (data.token_info && data.token_info.expires_in_human) {
|
if (data.token_info && data.token_info.expires_in_human) {
|
||||||
message += `<br><br><div class="bg-blue-50 p-3 rounded mt-2">
|
message += `<br><br><div class="bg-blue-50 p-3 rounded mt-2">
|
||||||
<span class="font-medium">${i18nStrings.tokenValidFor}</span> ${escapeHtml(data.token_info.expires_in_human)}
|
<span class="font-medium">${i18nStrings.tokenValidFor}</span> ${data.token_info.expires_in_human}
|
||||||
</div>`;
|
</div>`;
|
||||||
|
|
||||||
// Show the message with HTML
|
// Show the message with HTML
|
||||||
|
|||||||
@@ -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>{{ _("auth.verify_email_page_title") }}</title>
|
<title>{{ _("auth.verify_email_page_title") }}</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" />
|
||||||
|
|||||||
@@ -1107,6 +1107,7 @@
|
|||||||
"integrations.connected": "Connected",
|
"integrations.connected": "Connected",
|
||||||
"integrations.connection_failed": "Connection failed",
|
"integrations.connection_failed": "Connection failed",
|
||||||
"integrations.connection_success": "Connection successful",
|
"integrations.connection_success": "Connection successful",
|
||||||
|
"integrations.connection_test_failed_fallback": "Connection test failed.",
|
||||||
"integrations.delete_confirm_are_you_sure": "Are you sure you want to delete",
|
"integrations.delete_confirm_are_you_sure": "Are you sure you want to delete",
|
||||||
"integrations.delete_confirm_title": "Delete Integration?",
|
"integrations.delete_confirm_title": "Delete Integration?",
|
||||||
"integrations.delete_confirm_undone": "This action cannot be undone.",
|
"integrations.delete_confirm_undone": "This action cannot be undone.",
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
{{- /*
|
||||||
|
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,7 +42,6 @@ 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
|
# Liveness / readiness probes (unauthenticated endpoints for kubelet)
|
||||||
livenessProbe:
|
livenessProbe:
|
||||||
httpGet:
|
httpGet:
|
||||||
path: /api/health
|
path: /api/diagnostic/healthz/live
|
||||||
port: 8000
|
port: 8000
|
||||||
initialDelaySeconds: 30
|
initialDelaySeconds: 30
|
||||||
periodSeconds: 20
|
periodSeconds: 20
|
||||||
@@ -132,7 +132,7 @@ api:
|
|||||||
|
|
||||||
readinessProbe:
|
readinessProbe:
|
||||||
httpGet:
|
httpGet:
|
||||||
path: /api/health
|
path: /api/diagnostic/healthz/ready
|
||||||
port: 8000
|
port: 8000
|
||||||
initialDelaySeconds: 15
|
initialDelaySeconds: 15
|
||||||
periodSeconds: 10
|
periodSeconds: 10
|
||||||
@@ -191,7 +191,36 @@ worker:
|
|||||||
drop: ["ALL"]
|
drop: ["ALL"]
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Shared workdir volume (api + worker mount the same PVC)
|
# Celery Beat scheduler (singleton — always exactly 1 replica)
|
||||||
|
# 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
@@ -1,201 +0,0 @@
|
|||||||
"""
|
|
||||||
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__)
|
|
||||||
@@ -55,7 +55,6 @@ nav:
|
|||||||
- Google Drive: GoogleDriveSetup
|
- Google Drive: GoogleDriveSetup
|
||||||
- OneDrive: OneDriveSetup
|
- OneDrive: OneDriveSetup
|
||||||
- Amazon S3: AmazonS3Setup
|
- Amazon S3: AmazonS3Setup
|
||||||
- Evernote: EvernoteSetup
|
|
||||||
- Authentication: AuthenticationSetup
|
- Authentication: AuthenticationSetup
|
||||||
- Notifications: NotificationsSetup
|
- Notifications: NotificationsSetup
|
||||||
- Security:
|
- Security:
|
||||||
|
|||||||
Generated
+335
-346
File diff suppressed because it is too large
Load Diff
@@ -58,13 +58,6 @@
|
|||||||
"eslint-config-expo": "~10.0.0",
|
"eslint-config-expo": "~10.0.0",
|
||||||
"typescript": "^5.3.0"
|
"typescript": "^5.3.0"
|
||||||
},
|
},
|
||||||
"overrides": {
|
|
||||||
"postcss": "8.5.15",
|
|
||||||
"uuid": "11.1.1",
|
|
||||||
"brace-expansion@^1.1.7": "1.1.13",
|
|
||||||
"brace-expansion@^2.0.2": "2.1.0",
|
|
||||||
"brace-expansion@^5.0.2": "5.0.6"
|
|
||||||
},
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=20.19.4"
|
"node": ">=20.19.4"
|
||||||
},
|
},
|
||||||
|
|||||||
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
[build-system]
|
[build-system]
|
||||||
requires = ["setuptools>=82.0.1", "wheel"]
|
requires = ["setuptools>=45", "wheel"]
|
||||||
build-backend = "setuptools.build_meta"
|
build-backend = "setuptools.build_meta"
|
||||||
|
|
||||||
[project]
|
[project]
|
||||||
|
|||||||
@@ -1,34 +0,0 @@
|
|||||||
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()
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
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()
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
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()
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
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()
|
|
||||||
@@ -4,14 +4,14 @@
|
|||||||
# Testing
|
# Testing
|
||||||
pytest>=8.0.0
|
pytest>=8.0.0
|
||||||
pytest-cov>=4.1.0
|
pytest-cov>=4.1.0
|
||||||
pytest-asyncio>=1.4.0
|
pytest-asyncio>=0.23.0
|
||||||
pytest-mock>=3.12.0
|
pytest-mock>=3.12.0
|
||||||
pytest-timeout>=2.3.0 # Per-test timeout enforcement to prevent CI hangs
|
pytest-timeout>=2.3.0 # Per-test timeout enforcement to prevent CI hangs
|
||||||
httpx>=0.26.0 # For async test client
|
httpx>=0.26.0 # For async test client
|
||||||
testcontainers>=3.7.1 # For integration tests with real containers
|
testcontainers>=3.7.1 # For integration tests with real containers
|
||||||
fpdf2>=2.8.0 # For generating test PDF documents in integration tests
|
fpdf2>=2.8.0 # For generating test PDF documents in integration tests
|
||||||
minio>=7.1.0 # For MinIO/S3 integration tests
|
minio>=7.1.0 # For MinIO/S3 integration tests
|
||||||
redis>=8.0.0 # For Redis integration tests
|
redis>=4.5.0 # For Redis integration tests
|
||||||
boto3>=1.26.0 # For S3 integration tests
|
boto3>=1.26.0 # For S3 integration tests
|
||||||
|
|
||||||
# Code quality
|
# Code quality
|
||||||
@@ -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.5 # For license compliance checking
|
pip-licenses==5.5.1 # For license compliance checking
|
||||||
|
|
||||||
# Release automation
|
# Release automation
|
||||||
python-semantic-release>=9.0.0
|
python-semantic-release>=9.0.0
|
||||||
|
|||||||
+3
-7
@@ -2,9 +2,8 @@ fastapi[all] # Web framework with all extras
|
|||||||
uvicorn # ASGI server
|
uvicorn # ASGI server
|
||||||
celery # Task queue
|
celery # Task queue
|
||||||
redis # Message broker for Celery
|
redis # Message broker for Celery
|
||||||
sqlalchemy # Database ORM
|
sqlalchemy # Database ORM
|
||||||
psycopg[binary]>=3.2,<4.0 # PostgreSQL driver for HA database deployments
|
pydantic # Data validation
|
||||||
pydantic # Data validation
|
|
||||||
cryptography>=41.0.0 # Encryption for sensitive settings in database
|
cryptography>=41.0.0 # Encryption for sensitive settings in database
|
||||||
openai # GPT integration for metadata extraction
|
openai # GPT integration for metadata extraction
|
||||||
pypdf>=3.9.0 # PDF processing for text extraction, metadata editing and rotation (upgraded from PyPDF2 to fix CVE-2023-36464)
|
pypdf>=3.9.0 # PDF processing for text extraction, metadata editing and rotation (upgraded from PyPDF2 to fix CVE-2023-36464)
|
||||||
@@ -38,9 +37,6 @@ paramiko>=3.4.0 # SSH/SFTP implementation for Python (LGPL license)
|
|||||||
# iCloud Drive
|
# iCloud Drive
|
||||||
pyicloud>=2.4.0 # Unofficial Apple iCloud API client (MIT license)
|
pyicloud>=2.4.0 # Unofficial Apple iCloud API client (MIT license)
|
||||||
|
|
||||||
# Evernote
|
|
||||||
evernote3>=1.25.14 # Evernote Cloud API SDK for Python 3 (BSD license)
|
|
||||||
|
|
||||||
# Safe XML parsing (protection against XML bomb / XXE attacks)
|
# Safe XML parsing (protection against XML bomb / XXE attacks)
|
||||||
defusedxml>=0.7.1
|
defusedxml>=0.7.1
|
||||||
|
|
||||||
@@ -55,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,<16.0.0 # Stripe billing SDK (MIT license)
|
stripe>=7.0.0,<15.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
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
Hello
|
|
||||||
@@ -83,6 +83,8 @@ 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,15 +267,6 @@ 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,15 +360,6 @@ 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")
|
||||||
@@ -490,17 +481,18 @@ class TestSaveGoogleDriveSettings:
|
|||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
|
|
||||||
@patch("app.api.google_drive.os")
|
@patch("os.path.exists")
|
||||||
|
@patch("os.path.dirname")
|
||||||
@patch("app.config.settings")
|
@patch("app.config.settings")
|
||||||
def test_save_settings_exception_handling(self, mock_settings, mock_os, client: TestClient):
|
def test_save_settings_exception_handling(self, mock_settings, mock_dirname, mock_exists, client: TestClient):
|
||||||
"""Test that exceptions in .env write are non-fatal — DB write still succeeds."""
|
"""Test exception handling in save settings."""
|
||||||
mock_os.path.exists.side_effect = Exception("Unexpected error")
|
mock_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"})
|
||||||
|
|
||||||
# .env write exception is caught; endpoint succeeds via DB write
|
assert response.status_code == 500
|
||||||
assert response.status_code == 200
|
data = response.json()
|
||||||
assert response.json()["status"] == "success"
|
assert "failed to save" in data["detail"].lower()
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.unit
|
@pytest.mark.unit
|
||||||
|
|||||||
@@ -195,15 +195,6 @@ 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,16 +152,11 @@ 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.
|
||||||
|
|
||||||
@pytest.fixture(autouse=True)
|
Note: the Google Drive save endpoint is named save_google_drive_settings in the
|
||||||
def _admin_override(self):
|
source (app/api/google_drive.py).
|
||||||
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)
|
||||||
@@ -182,7 +177,6 @@ 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,10 +524,7 @@ 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 (
|
with patch("imaplib.IMAP4_SSL", return_value=mock_mail):
|
||||||
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,
|
||||||
@@ -544,10 +541,7 @@ 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 (
|
with patch("imaplib.IMAP4_SSL", side_effect=Exception("auth failed")):
|
||||||
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,
|
||||||
@@ -563,10 +557,7 @@ 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 (
|
with patch("imaplib.IMAP4", side_effect=OSError("connection refused")):
|
||||||
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,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"""Tests for the per-user integrations API (app/api/integrations.py)."""
|
"""Tests for the per-user integrations API (app/api/integrations.py)."""
|
||||||
|
|
||||||
import unittest.mock
|
import unittest.mock
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
@@ -405,32 +406,33 @@ class TestDeleteIntegration:
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.integration
|
@pytest.mark.integration
|
||||||
class TestGetIntegrationCredentials:
|
class TestTestSavedIntegrationConnection:
|
||||||
"""Tests for GET /api/integrations/{id}/credentials."""
|
"""Tests for POST /api/integrations/{id}/test."""
|
||||||
|
|
||||||
|
@patch("app.api.integrations._CONNECTION_TESTERS")
|
||||||
|
def test_test_saved_integration_success(self, mock_testers, int_client):
|
||||||
|
"""Test a saved integration successfully."""
|
||||||
|
mock_tester = MagicMock(return_value={"success": True, "message": "OK"})
|
||||||
|
mock_testers.get.return_value = mock_tester
|
||||||
|
|
||||||
def test_returns_decrypted_credentials(self, int_client):
|
|
||||||
"""Credentials endpoint returns the decrypted dict."""
|
|
||||||
created = int_client.post("/api/integrations/", json=_IMAP_SOURCE).json()
|
created = int_client.post("/api/integrations/", json=_IMAP_SOURCE).json()
|
||||||
resp = int_client.get(f"/api/integrations/{created['id']}/credentials")
|
resp = int_client.post(f"/api/integrations/{created['id']}/test")
|
||||||
assert resp.status_code == 200
|
|
||||||
creds = resp.json()["credentials"]
|
|
||||||
assert creds["password"] == "s3cr3t" # noqa: S105
|
|
||||||
|
|
||||||
def test_returns_empty_dict_when_no_credentials(self, int_client):
|
|
||||||
"""No credentials stored returns empty dict."""
|
|
||||||
payload = dict(_IMAP_SOURCE, credentials=None)
|
|
||||||
created = int_client.post("/api/integrations/", json=payload).json()
|
|
||||||
resp = int_client.get(f"/api/integrations/{created['id']}/credentials")
|
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
assert resp.json()["credentials"] == {}
|
assert resp.json()["success"] is True
|
||||||
|
mock_testers.get.assert_called_with("IMAP")
|
||||||
|
mock_tester.assert_called_once()
|
||||||
|
args = mock_tester.call_args[0]
|
||||||
|
assert args[0]["host"] == "imap.gmail.com"
|
||||||
|
assert args[1]["password"] == "s3cr3t"
|
||||||
|
|
||||||
def test_not_found(self, int_client):
|
def test_not_found(self, int_client):
|
||||||
"""Non-existent integration returns 404."""
|
"""Non-existent integration returns 404."""
|
||||||
resp = int_client.get("/api/integrations/9999/credentials")
|
resp = int_client.post("/api/integrations/9999/test")
|
||||||
assert resp.status_code == 404
|
assert resp.status_code == 404
|
||||||
|
|
||||||
def test_other_users_credentials_returns_404(self, int_client, int_session):
|
def test_other_users_integration_returns_404(self, int_client, int_session):
|
||||||
"""Cannot retrieve another user's credentials."""
|
"""Cannot test another user's integration."""
|
||||||
other_integration = UserIntegration(
|
other_integration = UserIntegration(
|
||||||
owner_id=_OTHER_OWNER,
|
owner_id=_OTHER_OWNER,
|
||||||
direction="SOURCE",
|
direction="SOURCE",
|
||||||
@@ -441,9 +443,47 @@ class TestGetIntegrationCredentials:
|
|||||||
)
|
)
|
||||||
int_session.add(other_integration)
|
int_session.add(other_integration)
|
||||||
int_session.commit()
|
int_session.commit()
|
||||||
resp = int_client.get(f"/api/integrations/{other_integration.id}/credentials")
|
resp = int_client.post(f"/api/integrations/{other_integration.id}/test")
|
||||||
assert resp.status_code == 404
|
assert resp.status_code == 404
|
||||||
|
|
||||||
|
@patch("app.api.integrations._CONNECTION_TESTERS")
|
||||||
|
def test_invalid_json_config_returns_failure(self, mock_testers, int_client, int_session):
|
||||||
|
"""Invalid JSON in config returns a controlled failure response."""
|
||||||
|
mock_tester = MagicMock()
|
||||||
|
mock_testers.get.return_value = mock_tester
|
||||||
|
|
||||||
|
bad_integration = UserIntegration(
|
||||||
|
owner_id=_OWNER,
|
||||||
|
direction="SOURCE",
|
||||||
|
integration_type="IMAP",
|
||||||
|
name="Bad Config",
|
||||||
|
config="not-valid-json{{{",
|
||||||
|
credentials="{}",
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
int_session.add(bad_integration)
|
||||||
|
int_session.commit()
|
||||||
|
|
||||||
|
resp = int_client.post(f"/api/integrations/{bad_integration.id}/test")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
assert data["success"] is False
|
||||||
|
assert "invalid" in data["message"].lower()
|
||||||
|
mock_tester.assert_not_called()
|
||||||
|
|
||||||
|
@patch("app.api.integrations._CONNECTION_TESTERS")
|
||||||
|
def test_tester_raises_exception_returns_failure(self, mock_testers, int_client):
|
||||||
|
"""Tester that raises an exception returns a controlled failure response."""
|
||||||
|
mock_testers.get.return_value = MagicMock(side_effect=ValueError("bad port"))
|
||||||
|
|
||||||
|
created = int_client.post("/api/integrations/", json=_IMAP_SOURCE).json()
|
||||||
|
resp = int_client.post(f"/api/integrations/{created['id']}/test")
|
||||||
|
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
assert data["success"] is False
|
||||||
|
assert "unexpected error" in data["message"].lower()
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.unit
|
@pytest.mark.unit
|
||||||
class TestIntegrationModel:
|
class TestIntegrationModel:
|
||||||
@@ -998,23 +1038,6 @@ 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 = {
|
||||||
@@ -1028,19 +1051,6 @@ 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,15 +342,6 @@ 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,15 +294,6 @@ 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,27 +47,6 @@ 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."""
|
||||||
|
|||||||
+160
-337
@@ -1,368 +1,191 @@
|
|||||||
|
"""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
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Saved searches CRUD tests
|
# Test data constants
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
_OWNER = "test_user@example.com"
|
||||||
|
_OTHER_OWNER = "other_user@example.com"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Shared fixture helpers
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.unit
|
@pytest.fixture()
|
||||||
class TestSavedSearchesCRUD:
|
def int_engine():
|
||||||
"""Tests for saved searches CRUD API endpoints."""
|
"""In-memory SQLite engine for integration tests."""
|
||||||
|
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() == []
|
|
||||||
|
|
||||||
def test_create_saved_search(self, client: TestClient):
|
@pytest.fixture()
|
||||||
"""POST /api/saved-searches creates a new saved search."""
|
def int_session(int_engine):
|
||||||
payload = {
|
"""DB session scoped to one test."""
|
||||||
"name": "My Invoices",
|
Session = sessionmaker(bind=int_engine)
|
||||||
"filters": {"tags": "invoice", "status": "completed"},
|
session = Session()
|
||||||
}
|
yield session
|
||||||
response = client.post("/api/saved-searches", json=payload)
|
session.close()
|
||||||
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"
|
assert data["filters"] == {"tags": "invoice", "document_type": "Invoice"}
|
||||||
assert data["filters"]["status"] == "completed"
|
|
||||||
assert "id" in data
|
assert "id" in data
|
||||||
|
|
||||||
def test_create_and_list_saved_search(self, client: TestClient):
|
def test_create_saved_search_invalid_filters(self, int_client):
|
||||||
"""Creating a saved search makes it appear in the list."""
|
"""Creating with invalid filters returns 422."""
|
||||||
payload = {
|
# Missing filters parameter (or empty after sanitization)
|
||||||
"name": "PDF Files",
|
payload = {"name": "My Invoices", "filters": {}}
|
||||||
"filters": {"mime_type": "application/pdf"},
|
resp = int_client.post("/api/saved-searches", json=payload)
|
||||||
}
|
assert resp.status_code == 422
|
||||||
client.post("/api/saved-searches", json=payload)
|
|
||||||
|
|
||||||
response = client.get("/api/saved-searches")
|
# Invalid filters format
|
||||||
assert response.status_code == 200
|
payload2 = {"name": "My Invoices", "filters": "not_a_dict"}
|
||||||
searches = response.json()
|
resp2 = int_client.post("/api/saved-searches", json=payload2)
|
||||||
assert len(searches) == 1
|
assert resp2.status_code == 422
|
||||||
assert searches[0]["name"] == "PDF Files"
|
|
||||||
|
|
||||||
def test_create_saved_search_missing_name(self, client: TestClient):
|
def test_create_saved_search_duplicate(self, int_client):
|
||||||
"""POST /api/saved-searches without name returns 422."""
|
"""Creating a duplicate named search returns 409."""
|
||||||
payload = {"filters": {"status": "completed"}}
|
payload = {"name": "Duplicate", "filters": {"q": "test"}}
|
||||||
response = client.post("/api/saved-searches", json=payload)
|
int_client.post("/api/saved-searches", json=payload)
|
||||||
assert response.status_code == 422
|
resp = int_client.post("/api/saved-searches", json=payload)
|
||||||
|
assert resp.status_code == 409
|
||||||
|
|
||||||
def test_create_saved_search_empty_filters(self, client: TestClient):
|
def test_create_saved_search_limit(self, int_client, int_session):
|
||||||
"""POST /api/saved-searches with empty filters returns 422."""
|
"""Exceeding MAX_SAVED_SEARCHES_PER_USER returns 409."""
|
||||||
payload = {"name": "Empty", "filters": {}}
|
# Create 50 searches using the API to ensure they are visible
|
||||||
response = client.post("/api/saved-searches", json=payload)
|
for i in range(50):
|
||||||
assert response.status_code == 422
|
resp = int_client.post("/api/saved-searches", json={"name": f"Search LIMIT {i}", "filters": {"q": "test"}})
|
||||||
|
assert resp.status_code == 201
|
||||||
|
|
||||||
def test_create_saved_search_invalid_filter_keys(self, client: TestClient):
|
payload = {"name": "One too many", "filters": {"q": "test"}}
|
||||||
"""POST /api/saved-searches ignores unknown filter keys."""
|
resp = int_client.post("/api/saved-searches", json=payload)
|
||||||
payload = {
|
assert resp.status_code == 409
|
||||||
"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_create_saved_search_only_invalid_keys(self, client: TestClient):
|
def test_update_saved_search(self, int_client):
|
||||||
"""POST with only invalid filter keys returns 422."""
|
"""Update an existing saved search."""
|
||||||
payload = {
|
payload = {"name": "Original Name", "filters": {"q": "test"}}
|
||||||
"name": "All invalid",
|
created = int_client.post("/api/saved-searches", json=payload).json()
|
||||||
"filters": {"bad_key": "value"},
|
search_id = created["id"]
|
||||||
}
|
|
||||||
response = client.post("/api/saved-searches", json=payload)
|
|
||||||
assert response.status_code == 422
|
|
||||||
|
|
||||||
def test_create_duplicate_name(self, client: TestClient):
|
update_payload = {"name": "Updated Name", "filters": {"tags": "new"}}
|
||||||
"""POST /api/saved-searches with duplicate name returns 409."""
|
resp = int_client.put(f"/api/saved-searches/{search_id}", json=update_payload)
|
||||||
payload = {"name": "My Search", "filters": {"status": "completed"}}
|
assert resp.status_code == 200
|
||||||
response1 = client.post("/api/saved-searches", json=payload)
|
data = resp.json()
|
||||||
assert response1.status_code == 201
|
assert data["name"] == "Updated Name"
|
||||||
|
assert data["filters"] == {"tags": "new"}
|
||||||
|
|
||||||
response2 = client.post("/api/saved-searches", json=payload)
|
def test_update_saved_search_not_found(self, int_client):
|
||||||
assert response2.status_code == 409
|
"""Updating a non-existent search returns 404."""
|
||||||
|
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(self, client: TestClient):
|
def test_update_saved_search_duplicate_name(self, int_client):
|
||||||
"""PUT /api/saved-searches/{id} updates the saved search."""
|
"""Updating name to an existing search name returns 409."""
|
||||||
# Create
|
payload1 = {"name": "Search 1", "filters": {"q": "a"}}
|
||||||
create_resp = client.post(
|
payload2 = {"name": "Search 2", "filters": {"q": "b"}}
|
||||||
"/api/saved-searches",
|
int_client.post("/api/saved-searches", json=payload1)
|
||||||
json={"name": "Original", "filters": {"status": "pending"}},
|
created2 = int_client.post("/api/saved-searches", json=payload2).json()
|
||||||
)
|
search2_id = created2["id"]
|
||||||
search_id = create_resp.json()["id"]
|
|
||||||
|
|
||||||
# Update
|
update_payload = {"name": "Search 1"}
|
||||||
update_resp = client.put(
|
resp = int_client.put(f"/api/saved-searches/{search2_id}", json=update_payload)
|
||||||
f"/api/saved-searches/{search_id}",
|
assert resp.status_code == 409
|
||||||
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_update_saved_search_not_found(self, client: TestClient):
|
def test_delete_saved_search(self, int_client, int_session):
|
||||||
"""PUT /api/saved-searches/999 returns 404."""
|
"""Delete an existing search."""
|
||||||
response = client.put(
|
payload = {"name": "To be deleted", "filters": {"q": "test"}}
|
||||||
"/api/saved-searches/999",
|
created = int_client.post("/api/saved-searches", json=payload).json()
|
||||||
json={"name": "Nope", "filters": {"status": "completed"}},
|
search_id = created["id"]
|
||||||
)
|
|
||||||
assert response.status_code == 404
|
|
||||||
|
|
||||||
def test_delete_saved_search(self, client: TestClient):
|
resp = int_client.delete(f"/api/saved-searches/{search_id}")
|
||||||
"""DELETE /api/saved-searches/{id} removes the saved search."""
|
assert resp.status_code == 204
|
||||||
# Create
|
|
||||||
create_resp = client.post(
|
|
||||||
"/api/saved-searches",
|
|
||||||
json={"name": "To Delete", "filters": {"status": "failed"}},
|
|
||||||
)
|
|
||||||
search_id = create_resp.json()["id"]
|
|
||||||
|
|
||||||
# Delete
|
assert int_session.query(SavedSearch).filter(SavedSearch.id == search_id).first() is None
|
||||||
del_resp = client.delete(f"/api/saved-searches/{search_id}")
|
|
||||||
assert del_resp.status_code == 204
|
|
||||||
|
|
||||||
# Verify it's gone
|
def test_delete_saved_search_not_found(self, int_client):
|
||||||
list_resp = client.get("/api/saved-searches")
|
"""Deleting a non-existent search returns 404."""
|
||||||
assert len(list_resp.json()) == 0
|
resp = int_client.delete("/api/saved-searches/999")
|
||||||
|
assert resp.status_code == 404
|
||||||
|
|
||||||
def test_delete_saved_search_not_found(self, client: TestClient):
|
def test_other_users_searches_isolated(self, int_engine, int_session):
|
||||||
"""DELETE /api/saved-searches/999 returns 404."""
|
"""Users only see and can only modify their own saved searches."""
|
||||||
response = client.delete("/api/saved-searches/999")
|
int_session.add(SavedSearch(user_id=_OTHER_OWNER, name="Other Search", filters='{"q": "test"}'))
|
||||||
assert response.status_code == 404
|
int_session.commit()
|
||||||
|
|
||||||
def test_create_name_too_long(self, client: TestClient):
|
client = next(_make_client(int_engine, _OWNER))
|
||||||
"""POST /api/saved-searches with name > 100 chars returns 422."""
|
resp = client.get("/api/saved-searches")
|
||||||
payload = {
|
assert resp.status_code == 200
|
||||||
"name": "x" * 101,
|
assert len(resp.json()) == 0
|
||||||
"filters": {"status": "completed"},
|
|
||||||
}
|
|
||||||
response = client.post("/api/saved-searches", json=payload)
|
|
||||||
assert response.status_code == 422
|
|
||||||
|
|
||||||
def test_saved_search_filters_sanitized(self, client: TestClient):
|
other_search = int_session.query(SavedSearch).first()
|
||||||
"""Saved search filters are sanitized to allowed keys only."""
|
resp = client.put(f"/api/saved-searches/{other_search.id}", json={"name": "Hacked"})
|
||||||
payload = {
|
assert resp.status_code == 404
|
||||||
"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"
|
|
||||||
|
|
||||||
def test_saved_search_with_fulltext_query(self, client: TestClient):
|
resp = client.delete(f"/api/saved-searches/{other_search.id}")
|
||||||
"""Saved search can include full-text query (q) for the search view."""
|
assert resp.status_code == 404
|
||||||
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
|
|
||||||
|
|||||||
@@ -35,7 +35,6 @@ class TestValidateStorageConfigs:
|
|||||||
"google_drive",
|
"google_drive",
|
||||||
"onedrive",
|
"onedrive",
|
||||||
"email",
|
"email",
|
||||||
"evernote",
|
|
||||||
"paperless",
|
"paperless",
|
||||||
"uptime_kuma",
|
"uptime_kuma",
|
||||||
]
|
]
|
||||||
@@ -84,13 +83,6 @@ class TestValidateStorageConfigs:
|
|||||||
assert "DEST_EMAIL_HOST is not configured" in result["email"]
|
assert "DEST_EMAIL_HOST is not configured" in result["email"]
|
||||||
assert "DEST_EMAIL_DEFAULT_RECIPIENT is not configured" in result["email"]
|
assert "DEST_EMAIL_DEFAULT_RECIPIENT is not configured" in result["email"]
|
||||||
|
|
||||||
def test_evernote_missing_token(self):
|
|
||||||
"""Test validation when Evernote destination auth token is missing."""
|
|
||||||
with patch("app.utils.config_validator.validators.settings") as mock_settings:
|
|
||||||
mock_settings.evernote_auth_token = None
|
|
||||||
result = validate_storage_configs()
|
|
||||||
assert "EVERNOTE_AUTH_TOKEN is not configured" in result["evernote"]
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.unit
|
@pytest.mark.unit
|
||||||
class TestValidateEmailConfig:
|
class TestValidateEmailConfig:
|
||||||
|
|||||||
@@ -69,8 +69,9 @@ class TestViewsBase:
|
|||||||
context = {"request": req}
|
context = {"request": req}
|
||||||
template_response_with_version("template.html", context)
|
template_response_with_version("template.html", context)
|
||||||
|
|
||||||
_, kwargs = mock_orig.call_args
|
args, kwargs = mock_orig.call_args
|
||||||
assert kwargs["context"].get("csrf_token") == "my-csrf"
|
context = kwargs.get("context", {})
|
||||||
|
assert 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."""
|
||||||
|
|||||||
@@ -663,7 +663,6 @@ def _all_should_upload_false():
|
|||||||
"ftp",
|
"ftp",
|
||||||
"sftp",
|
"sftp",
|
||||||
"email",
|
"email",
|
||||||
"evernote",
|
|
||||||
"onedrive",
|
"onedrive",
|
||||||
"s3",
|
"s3",
|
||||||
"sharepoint",
|
"sharepoint",
|
||||||
|
|||||||
@@ -55,8 +55,8 @@ class TestDarkModeTemplateInjection:
|
|||||||
|
|
||||||
captured = {}
|
captured = {}
|
||||||
|
|
||||||
def fake_original(request, name, **kw):
|
def fake_original(request_obj, name, context=None, **kw):
|
||||||
captured.update(kw.get("context", {}))
|
captured.update(context or {})
|
||||||
|
|
||||||
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(request, name, **kw):
|
def fake_original(request_obj, name, context=None, **kw):
|
||||||
captured.update(kw.get("context", {}))
|
captured.update(context or {})
|
||||||
|
|
||||||
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}/detail")
|
response = client.get(f"/files/{rec.id}/process")
|
||||||
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}/detail")
|
response = client.get(f"/files/{rec.id}/process")
|
||||||
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/detail")
|
response = client.get("/files/99999/process")
|
||||||
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}/detail")
|
response = client.get(f"/files/{file_record.id}/process")
|
||||||
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/detail")
|
response = client.get("/files/99999/process")
|
||||||
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}/detail")
|
response = client.get(f"/files/{file_record.id}/process")
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
content = response.text
|
content = response.text
|
||||||
# Should show metadata
|
# Should show metadata
|
||||||
|
|||||||
@@ -502,7 +502,6 @@ 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")
|
||||||
@@ -532,7 +531,6 @@ 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):
|
||||||
@@ -611,7 +609,6 @@ 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):
|
||||||
@@ -638,7 +635,6 @@ 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")
|
||||||
@@ -681,15 +677,12 @@ 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(
|
def test_delete_after_process(self, mock_settings, mock_save, mock_load, mock_imap_class, mock_fetch):
|
||||||
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
|
||||||
@@ -927,10 +920,9 @@ 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, _mock_private_ip):
|
def test_handles_fetch_failure(self, mock_load, mock_imap_class):
|
||||||
"""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()
|
||||||
@@ -1030,15 +1022,12 @@ 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(
|
def test_readonly_mode_skips_delete(self, mock_settings, mock_save, mock_load, mock_imap_class, mock_fetch):
|
||||||
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
|
||||||
@@ -1392,11 +1381,10 @@ 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, _mock_private_ip):
|
def test_pull_inbox_search_failed_status(self, mock_settings, mock_imap_class, mock_load):
|
||||||
"""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 = {}
|
||||||
@@ -1443,11 +1431,10 @@ 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, _mock_private_ip):
|
def test_pull_inbox_fetch_failed_status(self, mock_settings, mock_imap_class, mock_load):
|
||||||
"""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,58 +145,6 @@ 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:
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user