Merge branch 'main' into sentinel/fix-path-traversal-3335474446649715249
This commit is contained in:
@@ -3,6 +3,7 @@ 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)
|
||||||
@@ -638,6 +639,23 @@ EMBEDDING_MAX_TOKENS=8000
|
|||||||
# Attach PII (IP addresses, user agents) to Sentry events.
|
# Attach PII (IP addresses, user agents) to Sentry events.
|
||||||
# Disable (default) to stay GDPR/CCPA compliant.
|
# Disable (default) to stay GDPR/CCPA compliant.
|
||||||
# SENTRY_SEND_DEFAULT_PII=false
|
# SENTRY_SEND_DEFAULT_PII=false
|
||||||
|
#
|
||||||
|
# --- Browser (JavaScript) SDK ---
|
||||||
|
# The same DSN is reused for the Sentry Browser SDK which is injected into
|
||||||
|
# every rendered page. The DSN is a *public* key and is intentionally
|
||||||
|
# embedded in client-side code.
|
||||||
|
#
|
||||||
|
# Fraction of browser navigations captured for client-side performance tracing.
|
||||||
|
# 0.0 (default) disables browser tracing; 1.0 captures every navigation.
|
||||||
|
# SENTRY_JS_TRACES_SAMPLE_RATE=0.0
|
||||||
|
#
|
||||||
|
# Fraction of browser sessions recorded by Sentry Session Replay.
|
||||||
|
# 0.0 (default) disables session recording; 1.0 records every session.
|
||||||
|
# SENTRY_JS_REPLAY_SESSION_SAMPLE_RATE=0.0
|
||||||
|
#
|
||||||
|
# Fraction of error sessions recorded by Sentry Session Replay.
|
||||||
|
# Defaults to 0.1 (10 %) so errors are captured with replay context.
|
||||||
|
# SENTRY_JS_REPLAY_ON_ERROR_SAMPLE_RATE=0.1
|
||||||
|
|
||||||
# **Mobile App – Push Notifications**
|
# **Mobile App – Push Notifications**
|
||||||
# Push notifications are delivered via Expo's push notification service
|
# Push notifications are delivered via Expo's push notification service
|
||||||
|
|||||||
@@ -200,3 +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
|
||||||
|
|
||||||
|
# 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
|
||||||
@@ -2,3 +2,7 @@
|
|||||||
**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.
|
**Vulnerability:** The generic file utility `hash_file` in `app/utils/file_operations.py` accepted any file path and was vulnerable to reading arbitrary files via path traversal (e.g., `../../../etc/passwd`) or absolute paths if an attacker could control the `filepath` argument.
|
||||||
**Learning:** Naively checking for `".." in path` breaks legitimate relative paths used internally by the application. Blocking absolute paths entirely also breaks functionality. Input validation should occur at the API boundary, but for defense-in-depth, low-level utilities must enforce expected boundaries (e.g., the application's `workdir`).
|
**Learning:** Naively checking for `".." in path` breaks legitimate relative paths used internally by the application. Blocking absolute paths entirely also breaks functionality. Input validation should occur at the API boundary, but for defense-in-depth, low-level utilities must enforce expected boundaries (e.g., the application's `workdir`).
|
||||||
**Prevention:** Use `pathlib.Path.resolve()` on both the target path and the allowed base directory (`settings.workdir`). Ensure the resolved target path is strictly within the allowed boundary using `filepath_obj.relative_to(workdir_obj)`, catching the `ValueError` that is raised when the path is out of bounds. This safely blocks both relative traversal attacks and arbitrary absolute paths.
|
**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-03-19T20:41:16Z
|
2026-03-23T14:11:22Z
|
||||||
|
|||||||
+486
@@ -10,6 +10,492 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
<!-- version list -->
|
<!-- version list -->
|
||||||
|
|
||||||
|
## Unreleased
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
|
||||||
|
- **changelog**: Update changelog [skip ci]
|
||||||
|
([`45d3ac8`](https://github.com/christianlouis/DocuElevate/commit/45d3ac8cf07d39d49930dd6866f76e6015067b08))
|
||||||
|
|
||||||
|
### Testing
|
||||||
|
|
||||||
|
- Add assertions for task enqueuing parameters
|
||||||
|
([`eeae47d`](https://github.com/christianlouis/DocuElevate/commit/eeae47ddec01339421e503ba484157e798750b8a))
|
||||||
|
|
||||||
|
|
||||||
|
## Unreleased
|
||||||
|
|
||||||
|
|
||||||
|
## v0.172.2 (2026-03-23)
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- Adapt TemplateResponse calls to Starlette 1.0 new-style API
|
||||||
|
([`c4e10be`](https://github.com/christianlouis/DocuElevate/commit/c4e10bee5e096e71a5bc4fac4928f69e5c04f2fb))
|
||||||
|
|
||||||
|
- Update test assertions and lint fixes for Starlette 1.0 TemplateResponse API
|
||||||
|
([`93629ff`](https://github.com/christianlouis/DocuElevate/commit/93629ff44083d43f79fdd49431457023e53d13e4))
|
||||||
|
|
||||||
|
- **build**: Remove --omit=dev from npm ci in Dockerfile frontend-builder stage
|
||||||
|
([`b4e0067`](https://github.com/christianlouis/DocuElevate/commit/b4e0067a27e2fb161349bd38c6d3b3f3bcb86972))
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
|
||||||
|
- **changelog**: Update changelog [skip ci]
|
||||||
|
([`0841713`](https://github.com/christianlouis/DocuElevate/commit/084171395d1076c716aa500a516118db49468ff5))
|
||||||
|
|
||||||
|
|
||||||
|
## Unreleased
|
||||||
|
|
||||||
|
|
||||||
|
## v0.172.1 (2026-03-22)
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- Social login providers now work from DB config without restart
|
||||||
|
([`0c7ea67`](https://github.com/christianlouis/DocuElevate/commit/0c7ea6748da554c80ef9af1b709c08aba49174e6))
|
||||||
|
|
||||||
|
|
||||||
|
## v0.172.0 (2026-03-22)
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- **ui**: Migrate Tailwind CSS from v2 CDN to v3 Play CDN (interim step)
|
||||||
|
([`1d7df13`](https://github.com/christianlouis/DocuElevate/commit/1d7df13c943cc9138dc3ed514f9ab81d861bfbac))
|
||||||
|
|
||||||
|
- **ui**: Replace Tailwind CSS CDN with compiled v3 production build
|
||||||
|
([`14b3031`](https://github.com/christianlouis/DocuElevate/commit/14b3031e63e8645c4048dd73a594e9a53a919c17))
|
||||||
|
|
||||||
|
|
||||||
|
## v0.171.3 (2026-03-22)
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- **ui**: Add missing opening script tag in base.html Sentry block
|
||||||
|
([`425472c`](https://github.com/christianlouis/DocuElevate/commit/425472c839b3564c20a29b6e983fa6b9e7d6cf9c))
|
||||||
|
|
||||||
|
|
||||||
|
## v0.171.2 (2026-03-22)
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- **ui**: Fix greyed-out toggle switches on admin connections page
|
||||||
|
([`46772fc`](https://github.com/christianlouis/DocuElevate/commit/46772fc7461f9f8333b399b301ec969f5caa4c1e))
|
||||||
|
|
||||||
|
### Chores
|
||||||
|
|
||||||
|
- Upgrade Sentry Browser SDK CDN bundle from v9.x.x to v10.x.x
|
||||||
|
([`3d0bdf7`](https://github.com/christianlouis/DocuElevate/commit/3d0bdf783649019d631fa0b1156db66e5e3269b4))
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
|
||||||
|
- **changelog**: Update changelog [skip ci]
|
||||||
|
([`25d32a9`](https://github.com/christianlouis/DocuElevate/commit/25d32a9006161b433dc6bbac3810ab560e5e5847))
|
||||||
|
|
||||||
|
- **changelog**: Update changelog [skip ci]
|
||||||
|
([`9822ba5`](https://github.com/christianlouis/DocuElevate/commit/9822ba583d076671692699cd9856d8fbc7d0218d))
|
||||||
|
|
||||||
|
|
||||||
|
## Unreleased
|
||||||
|
|
||||||
|
### Chores
|
||||||
|
|
||||||
|
- Upgrade Sentry Browser SDK CDN bundle from v9.x.x to v10.x.x
|
||||||
|
([`3d0bdf7`](https://github.com/christianlouis/DocuElevate/commit/3d0bdf783649019d631fa0b1156db66e5e3269b4))
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
|
||||||
|
- **changelog**: Update changelog [skip ci]
|
||||||
|
([`9822ba5`](https://github.com/christianlouis/DocuElevate/commit/9822ba583d076671692699cd9856d8fbc7d0218d))
|
||||||
|
|
||||||
|
|
||||||
|
## Unreleased
|
||||||
|
|
||||||
|
### Chores
|
||||||
|
|
||||||
|
- Upgrade Sentry Browser SDK CDN bundle from v9.x.x to v10.x.x
|
||||||
|
([`3d0bdf7`](https://github.com/christianlouis/DocuElevate/commit/3d0bdf783649019d631fa0b1156db66e5e3269b4))
|
||||||
|
|
||||||
|
|
||||||
|
## v0.171.1 (2026-03-22)
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- **admin**: Fix greyed-out QR login toggle on admin connections page
|
||||||
|
([`d4cc44a`](https://github.com/christianlouis/DocuElevate/commit/d4cc44a72f7821360ffce1c9743efb85dbc65f22))
|
||||||
|
|
||||||
|
|
||||||
|
## v0.171.0 (2026-03-22)
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- **ui**: Show file owner, add claim ownership on file summary, detail, and annotations pages
|
||||||
|
([`9458055`](https://github.com/christianlouis/DocuElevate/commit/9458055661e5458256b51cfe1965b0607d6a478e))
|
||||||
|
|
||||||
|
|
||||||
|
## v0.170.0 (2026-03-22)
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- **ui**: Integrate EmbedPDF viewer with annotations panel for bidirectional sync
|
||||||
|
([`9c98a84`](https://github.com/christianlouis/DocuElevate/commit/9c98a8438ab81c70cc497191449378b914775731))
|
||||||
|
|
||||||
|
|
||||||
|
## v0.169.1 (2026-03-22)
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- **api**: Add PUT /api/settings/{key} endpoint and shared credentials for Google/Microsoft social
|
||||||
|
login
|
||||||
|
([`7d6128d`](https://github.com/christianlouis/DocuElevate/commit/7d6128d78f7782d4a687b51220747714ab59df6d))
|
||||||
|
|
||||||
|
|
||||||
|
## v0.169.0 (2026-03-22)
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- **migrations**: Fix down_revision type annotation in 042_add_file_shares
|
||||||
|
([`5f5e18d`](https://github.com/christianlouis/DocuElevate/commit/5f5e18d26165edbeb18be099c23b3178655068f0))
|
||||||
|
|
||||||
|
- **sharing**: Address code review: fix default role, auto-share logic, aria labels
|
||||||
|
([`124b802`](https://github.com/christianlouis/DocuElevate/commit/124b802c8f49a63e032f46cf8e1980e526b4ea89))
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- **sharing**: Add file sharing and role-based access management
|
||||||
|
([`6f2752b`](https://github.com/christianlouis/DocuElevate/commit/6f2752bdf804789e704983a71afe42e04ea852b6))
|
||||||
|
|
||||||
|
|
||||||
|
## v0.168.1 (2026-03-22)
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- **ui**: Styled toggle switches and Dropbox global-credentials field visibility in admin
|
||||||
|
connections
|
||||||
|
([`b2912da`](https://github.com/christianlouis/DocuElevate/commit/b2912da4dcad7bf7f11000a5ba0bc267fd5956cc))
|
||||||
|
|
||||||
|
|
||||||
|
## v0.168.0 (2026-03-22)
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- **ui**: Add Sentry Browser SDK client-side integration
|
||||||
|
([`b202f10`](https://github.com/christianlouis/DocuElevate/commit/b202f10e1a437d0d07c144339ba4f4253e8f645b))
|
||||||
|
|
||||||
|
|
||||||
|
## v0.167.0 (2026-03-22)
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- **auth**: Address code review feedback - accessibility, docstrings, portable test paths
|
||||||
|
([`c17afe8`](https://github.com/christianlouis/DocuElevate/commit/c17afe8c11789cced8e5edde7bd61a5dfbbac9a7))
|
||||||
|
|
||||||
|
- **auth**: Fix SSO auto-login check to use strict boolean comparison and add tests
|
||||||
|
([`840a5bc`](https://github.com/christianlouis/DocuElevate/commit/840a5bcd5b73b0dbed1d16174b937e877352afe0))
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
|
||||||
|
- **auth**: Update documentation for new auth providers and connections page
|
||||||
|
([`0287a16`](https://github.com/christianlouis/DocuElevate/commit/0287a165cfc27bda8166b61f629b103ef3269bb9))
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- **auth**: Add GitHub, Keycloak, Generic OAuth2 social login providers and connections page
|
||||||
|
([`54a0ba1`](https://github.com/christianlouis/DocuElevate/commit/54a0ba1023d8e39fc133a2312beab349181f308b))
|
||||||
|
|
||||||
|
|
||||||
|
## v0.166.1 (2026-03-22)
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- Make back-link text consistent with aria-labels across file views
|
||||||
|
([`d71add1`](https://github.com/christianlouis/DocuElevate/commit/d71add1484d01e4598a61f2e59c106afa8971d6a))
|
||||||
|
|
||||||
|
### Refactoring
|
||||||
|
|
||||||
|
- Initial step - remove comments/annotations from file_detail and file_view templates
|
||||||
|
([`5a3ddcc`](https://github.com/christianlouis/DocuElevate/commit/5a3ddcc1f0d9e2daa674d5098ab1bafe4d2e0bc8))
|
||||||
|
|
||||||
|
- **views**: Split file views into summary, detail, process, and annotations pages
|
||||||
|
([`f852ba9`](https://github.com/christianlouis/DocuElevate/commit/f852ba978343934631f70aa12aec49ec79da8288))
|
||||||
|
|
||||||
|
|
||||||
|
## v0.166.0 (2026-03-22)
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- **comments**: Address code review feedback
|
||||||
|
([`4b64127`](https://github.com/christianlouis/DocuElevate/commit/4b6412734b53a345e41d0531f03ff1c2318e9f55))
|
||||||
|
|
||||||
|
- **ui**: Address code review feedback for comments/annotations UX
|
||||||
|
([`a5a8cd9`](https://github.com/christianlouis/DocuElevate/commit/a5a8cd94c9af17f39a36993c0f419404c60055c3))
|
||||||
|
|
||||||
|
### Code Style
|
||||||
|
|
||||||
|
- Apply ruff auto-fix
|
||||||
|
([`8f09050`](https://github.com/christianlouis/DocuElevate/commit/8f0905033c4cd4269717dde64c4830c482407e41))
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
|
||||||
|
- **changelog**: Update changelog [skip ci]
|
||||||
|
([`c22bb66`](https://github.com/christianlouis/DocuElevate/commit/c22bb66c4b5a0a3554a43f2df90a5be1bee90e3b))
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- **comments**: Add document comments, annotations, and @mention support
|
||||||
|
([`ad795e2`](https://github.com/christianlouis/DocuElevate/commit/ad795e200ad2d4d0fc8381ed2903f4a2a905243f))
|
||||||
|
|
||||||
|
- **ui**: Add comments and annotations UX to file detail page
|
||||||
|
([`a7a8821`](https://github.com/christianlouis/DocuElevate/commit/a7a88218c391ba3642ef60b433e5e6a657bea239))
|
||||||
|
|
||||||
|
### Testing
|
||||||
|
|
||||||
|
- Improve test coverage for app/utils/setup_wizard.py
|
||||||
|
([`49cd41e`](https://github.com/christianlouis/DocuElevate/commit/49cd41e2e4eef6df3f06482a149c41b92c502e7a))
|
||||||
|
|
||||||
|
- **imap**: Improve coverage for imap_tasks.py from 64% to 100%
|
||||||
|
([`9dc1000`](https://github.com/christianlouis/DocuElevate/commit/9dc1000d633ff56791480122eaa875a489ec158d))
|
||||||
|
|
||||||
|
|
||||||
|
## Unreleased
|
||||||
|
|
||||||
|
### Testing
|
||||||
|
|
||||||
|
- Improve test coverage for app/utils/setup_wizard.py
|
||||||
|
([`49cd41e`](https://github.com/christianlouis/DocuElevate/commit/49cd41e2e4eef6df3f06482a149c41b92c502e7a))
|
||||||
|
|
||||||
|
- **imap**: Improve coverage for imap_tasks.py from 64% to 100%
|
||||||
|
([`9dc1000`](https://github.com/christianlouis/DocuElevate/commit/9dc1000d633ff56791480122eaa875a489ec158d))
|
||||||
|
|
||||||
|
|
||||||
|
## v0.165.0 (2026-03-21)
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
|
||||||
|
- **changelog**: Update changelog [skip ci]
|
||||||
|
([`3e6fbb4`](https://github.com/christianlouis/DocuElevate/commit/3e6fbb49c41b57e71091b68c670370340cc2f1ce))
|
||||||
|
|
||||||
|
- **changelog**: Update changelog [skip ci]
|
||||||
|
([`bd26f31`](https://github.com/christianlouis/DocuElevate/commit/bd26f31778da02d0cd439fc1ac445d45aed119d2))
|
||||||
|
|
||||||
|
- **changelog**: Update changelog [skip ci]
|
||||||
|
([`ae8be68`](https://github.com/christianlouis/DocuElevate/commit/ae8be68df9b1b79272ac4b4c1c074f6c58f4c7b9))
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- **tests**: Improve app/auth.py coverage from 70% to 97.74%
|
||||||
|
([`e5feee5`](https://github.com/christianlouis/DocuElevate/commit/e5feee5aae5c0a0ae8e30e8c24a92a29dec4223c))
|
||||||
|
|
||||||
|
### Testing
|
||||||
|
|
||||||
|
- Improve coverage for app/api/files.py from 63% to 97.81%
|
||||||
|
([`0a192ee`](https://github.com/christianlouis/DocuElevate/commit/0a192eeeca9d9bbb19022633926a8f02e3dc2493))
|
||||||
|
|
||||||
|
- Improve coverage for app/views/audit_logs.py from 61% to 100%
|
||||||
|
([`00e0d5f`](https://github.com/christianlouis/DocuElevate/commit/00e0d5fa45714ae76ed3302007b38b14852605d5))
|
||||||
|
|
||||||
|
- Improve test coverage for app/api/imap_profiles.py to 100%
|
||||||
|
([`8cc292c`](https://github.com/christianlouis/DocuElevate/commit/8cc292c6eef0652c9ce03e9f103fc2496cf63e23))
|
||||||
|
|
||||||
|
- Improve test coverage for app/api/sessions.py
|
||||||
|
([`74a8c22`](https://github.com/christianlouis/DocuElevate/commit/74a8c22478e4da40116d26a141d0a09eb9463fd3))
|
||||||
|
|
||||||
|
- Improve test coverage for app/tasks/upload_to_icloud.py to 100%
|
||||||
|
([`982c222`](https://github.com/christianlouis/DocuElevate/commit/982c222717700a3bdb117f33ecb0d83d212e2e98))
|
||||||
|
|
||||||
|
- **convert_to_pdfa**: Assert -- terminates option parsing before file paths
|
||||||
|
([`5b04504`](https://github.com/christianlouis/DocuElevate/commit/5b04504b0eb3919a1d121ee7205f52be102a3c08))
|
||||||
|
|
||||||
|
|
||||||
|
## Unreleased
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
|
||||||
|
- **changelog**: Update changelog [skip ci]
|
||||||
|
([`bd26f31`](https://github.com/christianlouis/DocuElevate/commit/bd26f31778da02d0cd439fc1ac445d45aed119d2))
|
||||||
|
|
||||||
|
- **changelog**: Update changelog [skip ci]
|
||||||
|
([`ae8be68`](https://github.com/christianlouis/DocuElevate/commit/ae8be68df9b1b79272ac4b4c1c074f6c58f4c7b9))
|
||||||
|
|
||||||
|
### Testing
|
||||||
|
|
||||||
|
- Improve coverage for app/api/files.py from 63% to 97.81%
|
||||||
|
([`0a192ee`](https://github.com/christianlouis/DocuElevate/commit/0a192eeeca9d9bbb19022633926a8f02e3dc2493))
|
||||||
|
|
||||||
|
- Improve test coverage for app/api/imap_profiles.py to 100%
|
||||||
|
([`8cc292c`](https://github.com/christianlouis/DocuElevate/commit/8cc292c6eef0652c9ce03e9f103fc2496cf63e23))
|
||||||
|
|
||||||
|
- Improve test coverage for app/api/sessions.py
|
||||||
|
([`74a8c22`](https://github.com/christianlouis/DocuElevate/commit/74a8c22478e4da40116d26a141d0a09eb9463fd3))
|
||||||
|
|
||||||
|
- Improve test coverage for app/tasks/upload_to_icloud.py to 100%
|
||||||
|
([`982c222`](https://github.com/christianlouis/DocuElevate/commit/982c222717700a3bdb117f33ecb0d83d212e2e98))
|
||||||
|
|
||||||
|
- **convert_to_pdfa**: Assert -- terminates option parsing before file paths
|
||||||
|
([`5b04504`](https://github.com/christianlouis/DocuElevate/commit/5b04504b0eb3919a1d121ee7205f52be102a3c08))
|
||||||
|
|
||||||
|
|
||||||
|
## Unreleased
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
|
||||||
|
- **changelog**: Update changelog [skip ci]
|
||||||
|
([`ae8be68`](https://github.com/christianlouis/DocuElevate/commit/ae8be68df9b1b79272ac4b4c1c074f6c58f4c7b9))
|
||||||
|
|
||||||
|
### Testing
|
||||||
|
|
||||||
|
- Improve test coverage for app/api/imap_profiles.py to 100%
|
||||||
|
([`8cc292c`](https://github.com/christianlouis/DocuElevate/commit/8cc292c6eef0652c9ce03e9f103fc2496cf63e23))
|
||||||
|
|
||||||
|
- Improve test coverage for app/api/sessions.py
|
||||||
|
([`74a8c22`](https://github.com/christianlouis/DocuElevate/commit/74a8c22478e4da40116d26a141d0a09eb9463fd3))
|
||||||
|
|
||||||
|
- Improve test coverage for app/tasks/upload_to_icloud.py to 100%
|
||||||
|
([`982c222`](https://github.com/christianlouis/DocuElevate/commit/982c222717700a3bdb117f33ecb0d83d212e2e98))
|
||||||
|
|
||||||
|
- **convert_to_pdfa**: Assert -- terminates option parsing before file paths
|
||||||
|
([`5b04504`](https://github.com/christianlouis/DocuElevate/commit/5b04504b0eb3919a1d121ee7205f52be102a3c08))
|
||||||
|
|
||||||
|
|
||||||
|
## Unreleased
|
||||||
|
|
||||||
|
### Testing
|
||||||
|
|
||||||
|
- **convert_to_pdfa**: Assert -- terminates option parsing before file paths
|
||||||
|
([`5b04504`](https://github.com/christianlouis/DocuElevate/commit/5b04504b0eb3919a1d121ee7205f52be102a3c08))
|
||||||
|
|
||||||
|
|
||||||
|
## v0.164.0 (2026-03-21)
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- Merge main branch and renumber migration 027→037
|
||||||
|
([`204000a`](https://github.com/christianlouis/DocuElevate/commit/204000aabcf94bd9eeff7cb67d8707e9af8b7fa3))
|
||||||
|
|
||||||
|
- Merge main branch and renumber migration 037→040
|
||||||
|
([`e518bce`](https://github.com/christianlouis/DocuElevate/commit/e518bce922524ae8a12372abe582a6feb5efe3aa))
|
||||||
|
|
||||||
|
- **automation**: Address code review - path traversal fix and test marker
|
||||||
|
([`6a83d51`](https://github.com/christianlouis/DocuElevate/commit/6a83d51d888a20045ad2a3e4e68d3205329e3856))
|
||||||
|
|
||||||
|
- **automation**: Register automation task in celery worker and add docs
|
||||||
|
([`ce2a76f`](https://github.com/christianlouis/DocuElevate/commit/ce2a76fb770da3257b31cb5958c888821a23da0c))
|
||||||
|
|
||||||
|
- **docs**: Remove duplicate Further Assistance heading in API.md
|
||||||
|
([`34ff7f8`](https://github.com/christianlouis/DocuElevate/commit/34ff7f8de877cc0a7ae3b000cb3199cf017be16c))
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- **automation**: Add Zapier and Make.com integration
|
||||||
|
([`d167be8`](https://github.com/christianlouis/DocuElevate/commit/d167be827421b3abf19441a2dea052abb486b567))
|
||||||
|
|
||||||
|
|
||||||
|
## v0.163.1 (2026-03-21)
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- **api**: Add missing `import requests` in dropbox.py and onedrive.py to fix ruff F821
|
||||||
|
([`35caf24`](https://github.com/christianlouis/DocuElevate/commit/35caf24e3c0f4034b200038fd507a4747b6785f6))
|
||||||
|
|
||||||
|
### Testing
|
||||||
|
|
||||||
|
- Add -- separator assertions to rclone and ocrmypdf tests
|
||||||
|
([`81484ad`](https://github.com/christianlouis/DocuElevate/commit/81484ad770e859e49da2a4bb5cd0ab01edf89de1))
|
||||||
|
|
||||||
|
|
||||||
|
## v0.163.0 (2026-03-20)
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- **security**: Escape HTML in folder browser to prevent XSS from folder names
|
||||||
|
([`b3a2387`](https://github.com/christianlouis/DocuElevate/commit/b3a238744d1618b7f891b7c43f243dd9099ecf49))
|
||||||
|
|
||||||
|
### Code Style
|
||||||
|
|
||||||
|
- Apply ruff auto-fix
|
||||||
|
([`65cf33c`](https://github.com/christianlouis/DocuElevate/commit/65cf33ce89cfcb7f81f31278a2cf6523c32a4754))
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
|
||||||
|
- Update setup guides and API docs for folder browser and system credentials
|
||||||
|
([`d6c21b8`](https://github.com/christianlouis/DocuElevate/commit/d6c21b8026ccc8fb71ed12394faba08214e0793d))
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- **api**: Add folder browser API endpoints and UI for Dropbox and OneDrive
|
||||||
|
([`a842306`](https://github.com/christianlouis/DocuElevate/commit/a8423064ec72bf8014ca37936960c5da9ddcea1f))
|
||||||
|
|
||||||
|
- **auth**: Default to system-wide app credentials in OAuth wizards for user mode
|
||||||
|
([`23c8c76`](https://github.com/christianlouis/DocuElevate/commit/23c8c76b392a95e723f25f8cb3714d4e5f256b30))
|
||||||
|
|
||||||
|
- **ui**: Replace manual credential fields with OAuth wizard flow for watch folder sources
|
||||||
|
([`ed01952`](https://github.com/christianlouis/DocuElevate/commit/ed0195261032fb92df5f655a73f056ac380fe8b6))
|
||||||
|
|
||||||
|
### Testing
|
||||||
|
|
||||||
|
- Add tests for folder browser APIs and system credentials toggle
|
||||||
|
([`1e1e6e6`](https://github.com/christianlouis/DocuElevate/commit/1e1e6e62807a6d4b7bc84b2d8c967dceb97f1cb6))
|
||||||
|
|
||||||
|
|
||||||
|
## v0.162.0 (2026-03-20)
|
||||||
|
|
||||||
|
|
||||||
|
## v0.161.0 (2026-03-20)
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
|
||||||
|
- Update scaling, health probe, and beat scheduler documentation
|
||||||
|
([`4d019d5`](https://github.com/christianlouis/DocuElevate/commit/4d019d53d9ad161a68639d6ba2109ba3ac77df34))
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- **scaling**: Enable horizontal scaling for API and worker pods
|
||||||
|
([`f75b125`](https://github.com/christianlouis/DocuElevate/commit/f75b12599291050f97aa452d980f17547ddd7bd6))
|
||||||
|
|
||||||
|
|
||||||
|
## v0.160.3 (2026-03-20)
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- **mobile**: Wire i18n reactivity, translate all screens, sync language with server
|
||||||
|
([`3b5ca04`](https://github.com/christianlouis/DocuElevate/commit/3b5ca04ebc8b6dd20f877bc4797e364e0997d840))
|
||||||
|
|
||||||
|
### Chores
|
||||||
|
|
||||||
|
- **mobile**: Upgrade ESLint to v9 with flat config and fix expo-localization version
|
||||||
|
([`0e6a4c5`](https://github.com/christianlouis/DocuElevate/commit/0e6a4c5084b3704653965f0091c36b9c78b8ad60))
|
||||||
|
|
||||||
|
|
||||||
|
## v0.160.2 (2026-03-20)
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- **dropbox**: Fix Invalid redirect_uri error by adding PUBLIC_BASE_URL config and URL-encoding
|
||||||
|
([`5e3e2b1`](https://github.com/christianlouis/DocuElevate/commit/5e3e2b19997d3af6570fbaa1e75a49cbfe6cf78d))
|
||||||
|
|
||||||
|
|
||||||
|
## v0.160.1 (2026-03-20)
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- **mobile**: Update expo-localization version from ~16.0.6 to ~16.1.0
|
||||||
|
([`78c3717`](https://github.com/christianlouis/DocuElevate/commit/78c3717661923b43f1762fa7728c75e803938fb7))
|
||||||
|
|
||||||
|
|
||||||
|
## v0.160.0 (2026-03-20)
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- **mobile**: Address code review feedback - error handling, filename collision, hash display
|
||||||
|
([`6541529`](https://github.com/christianlouis/DocuElevate/commit/65415292507aa37408428400aef7e87e006abfd0))
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- **mobile**: Add pre-login legal pages, multi-image selection, file detail view, search, i18n, HEIC
|
||||||
|
support
|
||||||
|
([`67c17e7`](https://github.com/christianlouis/DocuElevate/commit/67c17e7baa8edf76be394d0aff42c2adeae351e1))
|
||||||
|
|
||||||
|
|
||||||
## v0.159.0 (2026-03-19)
|
## v0.159.0 (2026-03-19)
|
||||||
|
|
||||||
### Code Style
|
### Code Style
|
||||||
|
|||||||
+17
-2
@@ -27,7 +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: Documentation builder ──────────────────────────────────────────
|
# ── Stage 2: Frontend asset builder (Tailwind CSS) ──────────────────────────
|
||||||
|
FROM node:20-alpine AS frontend-builder
|
||||||
|
|
||||||
|
WORKDIR /frontend
|
||||||
|
|
||||||
|
# Install dependencies first (layer-cached unless package.json/lockfile changes)
|
||||||
|
COPY frontend/package.json frontend/package-lock.json ./
|
||||||
|
RUN npm ci
|
||||||
|
|
||||||
|
# Copy source files and compile Tailwind CSS
|
||||||
|
COPY frontend/ ./
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# ── 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
|
||||||
@@ -43,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 3: Runtime image ───────────────────────────────────────────────────
|
# ── Stage 4: Runtime image ───────────────────────────────────────────────────
|
||||||
FROM python:3.14.3-slim
|
FROM python:3.14.3-slim
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
@@ -68,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
|
||||||
|
|||||||
+6
-6
@@ -1,10 +1,10 @@
|
|||||||
DocuElevate Build Information
|
DocuElevate Build Information
|
||||||
==============================
|
==============================
|
||||||
Version: 0.159.0
|
Version: 0.172.2
|
||||||
Build Date: 2026-03-19T20:41:16Z
|
Build Date: 2026-03-23T14:11:22Z
|
||||||
Git Commit: 57f9e90e4501b8caa42b762a21c4c8c349bf5bf6
|
Git Commit: 34457f977509ce145b7411e83982a96b0fd0e33e
|
||||||
Git Short SHA: 57f9e90
|
Git Short SHA: 34457f9
|
||||||
Git Branch: main
|
Git Branch: main
|
||||||
Commit Date: 2026-03-19T21:40:51+01:00
|
Commit Date: 2026-03-23T15:10:59+01:00
|
||||||
Build Timestamp: 2026-03-19T20:41:16Z
|
Build Timestamp: 2026-03-23T14:11:22Z
|
||||||
==============================
|
==============================
|
||||||
|
|||||||
@@ -9,9 +9,12 @@ from fastapi import APIRouter
|
|||||||
from app.api.admin_users import router as admin_users_router
|
from app.api.admin_users import router as admin_users_router
|
||||||
from app.api.api_tokens import router as api_tokens_router
|
from app.api.api_tokens import router as api_tokens_router
|
||||||
from app.api.audit_logs import router as audit_logs_router
|
from app.api.audit_logs import router as audit_logs_router
|
||||||
|
from app.api.automation import router as automation_router
|
||||||
from app.api.azure import router as azure_router
|
from app.api.azure import router as azure_router
|
||||||
from app.api.backup import router as backup_router
|
from app.api.backup import router as backup_router
|
||||||
from app.api.billing import router as billing_router
|
from app.api.billing import router as billing_router
|
||||||
|
from app.api.classification_rules import router as classification_rules_router
|
||||||
|
from app.api.comments import router as comments_router
|
||||||
from app.api.compliance import router as compliance_router
|
from app.api.compliance import router as compliance_router
|
||||||
from app.api.database import router as database_router
|
from app.api.database import router as database_router
|
||||||
from app.api.diagnostic import router as diagnostic_router
|
from app.api.diagnostic import router as diagnostic_router
|
||||||
@@ -43,6 +46,7 @@ from app.api.sessions import router as sessions_router
|
|||||||
from app.api.settings import router as settings_router
|
from app.api.settings import router as settings_router
|
||||||
from app.api.shared_links import public_router as shared_links_public_router
|
from app.api.shared_links import public_router as shared_links_public_router
|
||||||
from app.api.shared_links import router as shared_links_router
|
from app.api.shared_links import router as shared_links_router
|
||||||
|
from app.api.sharing import router as sharing_router
|
||||||
from app.api.similarity import router as similarity_router
|
from app.api.similarity import router as similarity_router
|
||||||
from app.api.subscriptions import router as subscriptions_router
|
from app.api.subscriptions import router as subscriptions_router
|
||||||
from app.api.system_reset import router as system_reset_router
|
from app.api.system_reset import router as system_reset_router
|
||||||
@@ -104,3 +108,7 @@ router.include_router(qr_auth_router)
|
|||||||
router.include_router(compliance_router)
|
router.include_router(compliance_router)
|
||||||
router.include_router(system_reset_router)
|
router.include_router(system_reset_router)
|
||||||
router.include_router(translation_router)
|
router.include_router(translation_router)
|
||||||
|
router.include_router(classification_rules_router)
|
||||||
|
router.include_router(automation_router)
|
||||||
|
router.include_router(comments_router)
|
||||||
|
router.include_router(sharing_router)
|
||||||
|
|||||||
@@ -0,0 +1,311 @@
|
|||||||
|
"""API endpoints for Zapier / Make.com automation integration.
|
||||||
|
|
||||||
|
Provides a REST hooks subscription interface for outgoing triggers and
|
||||||
|
incoming action endpoints that external automation platforms can call.
|
||||||
|
|
||||||
|
Outgoing triggers:
|
||||||
|
External platforms subscribe to DocuElevate events via
|
||||||
|
``POST /api/automation/hooks/subscribe``. When a subscribed event
|
||||||
|
fires, DocuElevate POSTs a flat Zapier-compatible JSON payload to the
|
||||||
|
registered ``target_url``.
|
||||||
|
|
||||||
|
Incoming actions:
|
||||||
|
``POST /api/automation/actions/upload`` allows automation platforms to
|
||||||
|
push documents into DocuElevate for processing.
|
||||||
|
|
||||||
|
Authentication:
|
||||||
|
All endpoints require a valid API token via ``Authorization: Bearer``
|
||||||
|
header.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
from typing import Annotated, Any
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, File, HTTPException, Request, UploadFile, status
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.config import settings
|
||||||
|
from app.database import get_db
|
||||||
|
from app.models import AutomationHook
|
||||||
|
from app.utils.automation_hooks import SAMPLE_PAYLOADS
|
||||||
|
from app.utils.webhook import VALID_EVENTS
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
router = APIRouter(prefix="/automation", tags=["automation"])
|
||||||
|
|
||||||
|
DbSession = Annotated[Session, Depends(get_db)]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Auth helper – require a valid API token (Bearer)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _require_api_user(request: Request) -> dict:
|
||||||
|
"""Ensure the caller is authenticated via session or API token.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
HTTPException: 401 if not authenticated, 403 if automation hooks are disabled.
|
||||||
|
"""
|
||||||
|
if not settings.automation_hooks_enabled:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="Automation hooks are disabled",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check for API-token user first (set by auth middleware)
|
||||||
|
user = getattr(request.state, "api_token_user", None)
|
||||||
|
if user:
|
||||||
|
return user
|
||||||
|
|
||||||
|
# Fall back to session user
|
||||||
|
user = request.session.get("user")
|
||||||
|
if user:
|
||||||
|
return user
|
||||||
|
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="Authentication required (Bearer token or session)",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
AuthUser = Annotated[dict, Depends(_require_api_user)]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Pydantic schemas
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class HookSubscribe(BaseModel):
|
||||||
|
"""Schema for subscribing to automation hook events."""
|
||||||
|
|
||||||
|
target_url: str = Field(..., min_length=1, max_length=2048, description="URL to POST event payloads to")
|
||||||
|
events: list[str] = Field(..., min_length=1, description="Event types to subscribe to")
|
||||||
|
secret: str | None = Field(default=None, max_length=512, description="Optional HMAC-SHA256 signing secret")
|
||||||
|
hook_type: str = Field(
|
||||||
|
default="generic",
|
||||||
|
max_length=50,
|
||||||
|
description="Platform identifier (zapier, make, generic)",
|
||||||
|
)
|
||||||
|
description: str | None = Field(default=None, max_length=500, description="Optional human-readable label")
|
||||||
|
|
||||||
|
|
||||||
|
class HookResponse(BaseModel):
|
||||||
|
"""Schema returned when listing or creating hooks."""
|
||||||
|
|
||||||
|
id: int
|
||||||
|
target_url: str
|
||||||
|
events: list[str]
|
||||||
|
is_active: bool
|
||||||
|
hook_type: str
|
||||||
|
description: str | None
|
||||||
|
has_secret: bool
|
||||||
|
|
||||||
|
model_config = {"from_attributes": True}
|
||||||
|
|
||||||
|
|
||||||
|
class ActionUploadResponse(BaseModel):
|
||||||
|
"""Response after an automation action uploads a document."""
|
||||||
|
|
||||||
|
status: str
|
||||||
|
filename: str
|
||||||
|
task_id: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_events(events: list[str]) -> None:
|
||||||
|
"""Raise 422 if any event name is not recognised."""
|
||||||
|
invalid = set(events) - VALID_EVENTS
|
||||||
|
if invalid:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||||
|
detail=f"Invalid event(s): {', '.join(sorted(invalid))}. Valid: {', '.join(sorted(VALID_EVENTS))}",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _hook_to_response(hook: AutomationHook) -> dict[str, Any]:
|
||||||
|
"""Convert a DB model instance to a response dict."""
|
||||||
|
try:
|
||||||
|
events = json.loads(hook.events)
|
||||||
|
except (json.JSONDecodeError, TypeError):
|
||||||
|
events = []
|
||||||
|
return {
|
||||||
|
"id": hook.id,
|
||||||
|
"target_url": hook.target_url,
|
||||||
|
"events": events,
|
||||||
|
"is_active": hook.is_active,
|
||||||
|
"hook_type": hook.hook_type,
|
||||||
|
"description": hook.description,
|
||||||
|
"has_secret": hook.secret is not None and len(hook.secret) > 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Outgoing triggers – REST hooks subscription endpoints
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/hooks/subscribe",
|
||||||
|
status_code=status.HTTP_201_CREATED,
|
||||||
|
summary="Subscribe to automation events (REST hooks)",
|
||||||
|
)
|
||||||
|
def subscribe_hook(body: HookSubscribe, db: DbSession, user: AuthUser) -> dict[str, Any]:
|
||||||
|
"""Register a new automation hook subscription.
|
||||||
|
|
||||||
|
Zapier and Make.com call this endpoint to subscribe to DocuElevate
|
||||||
|
events. When an event fires, a flat JSON payload is POSTed to
|
||||||
|
``target_url``.
|
||||||
|
"""
|
||||||
|
_validate_events(body.events)
|
||||||
|
|
||||||
|
hook = AutomationHook(
|
||||||
|
target_url=body.target_url,
|
||||||
|
secret=body.secret,
|
||||||
|
events=json.dumps(sorted(body.events)),
|
||||||
|
is_active=True,
|
||||||
|
hook_type=body.hook_type or "generic",
|
||||||
|
description=body.description,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
db.add(hook)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(hook)
|
||||||
|
except Exception:
|
||||||
|
db.rollback()
|
||||||
|
raise
|
||||||
|
|
||||||
|
logger.info("Automation hook %d created (type=%s) for events %s", hook.id, hook.hook_type, body.events)
|
||||||
|
return _hook_to_response(hook)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete(
|
||||||
|
"/hooks/{hook_id}",
|
||||||
|
status_code=status.HTTP_204_NO_CONTENT,
|
||||||
|
summary="Unsubscribe an automation hook",
|
||||||
|
)
|
||||||
|
def unsubscribe_hook(hook_id: int, db: DbSession, user: AuthUser) -> None:
|
||||||
|
"""Remove an automation hook subscription.
|
||||||
|
|
||||||
|
Zapier calls this endpoint when a Zap is turned off or deleted.
|
||||||
|
"""
|
||||||
|
hook = db.query(AutomationHook).filter(AutomationHook.id == hook_id).first()
|
||||||
|
if not hook:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Hook not found")
|
||||||
|
|
||||||
|
try:
|
||||||
|
db.delete(hook)
|
||||||
|
db.commit()
|
||||||
|
except Exception:
|
||||||
|
db.rollback()
|
||||||
|
raise
|
||||||
|
|
||||||
|
logger.info("Automation hook %d deleted", hook_id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/hooks", summary="List automation hook subscriptions")
|
||||||
|
def list_hooks(db: DbSession, user: AuthUser) -> list[dict[str, Any]]:
|
||||||
|
"""Return all active automation hook subscriptions."""
|
||||||
|
hooks = db.query(AutomationHook).order_by(AutomationHook.id).all()
|
||||||
|
return [_hook_to_response(h) for h in hooks]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Outgoing triggers – sample data for Zapier field mapping
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/triggers/sample/{event}", summary="Get sample trigger data")
|
||||||
|
def get_trigger_sample(event: str, user: AuthUser) -> list[dict[str, Any]]:
|
||||||
|
"""Return sample payload data for the given event type.
|
||||||
|
|
||||||
|
Zapier uses this during Zap setup to discover available fields and
|
||||||
|
provide a mapping interface. The response is wrapped in an array
|
||||||
|
as Zapier expects.
|
||||||
|
"""
|
||||||
|
if event not in VALID_EVENTS:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail=f"Unknown event: {event}. Valid: {', '.join(sorted(VALID_EVENTS))}",
|
||||||
|
)
|
||||||
|
|
||||||
|
sample = SAMPLE_PAYLOADS.get(event, {"id": "evt_sample", "event": event, "timestamp": 0})
|
||||||
|
return [sample]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Outgoing triggers – list valid events
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/events", summary="List valid automation event types")
|
||||||
|
def list_events(user: AuthUser) -> list[str]:
|
||||||
|
"""Return the list of valid event types that automation hooks can subscribe to."""
|
||||||
|
return sorted(VALID_EVENTS)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Incoming actions – endpoints that Zapier / Make.com can call
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/actions/upload", summary="Upload a document (incoming action)")
|
||||||
|
def action_upload(
|
||||||
|
request: Request,
|
||||||
|
db: DbSession,
|
||||||
|
user: AuthUser,
|
||||||
|
file: UploadFile = File(...),
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Accept a document upload from an automation platform.
|
||||||
|
|
||||||
|
This endpoint allows Zapier or Make.com to push a document into
|
||||||
|
DocuElevate for processing. The file is saved to the work directory
|
||||||
|
and a background processing task is queued.
|
||||||
|
"""
|
||||||
|
if not file.filename:
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Filename is required")
|
||||||
|
|
||||||
|
# Sanitise filename to prevent path traversal attacks
|
||||||
|
safe_filename = os.path.basename(file.filename)
|
||||||
|
if not safe_filename:
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Filename is required")
|
||||||
|
|
||||||
|
owner_id = user.get("preferred_username") or user.get("email") or user.get("id", "automation")
|
||||||
|
workdir = settings.workdir or tempfile.gettempdir()
|
||||||
|
upload_dir = os.path.join(workdir, "uploads")
|
||||||
|
os.makedirs(upload_dir, exist_ok=True)
|
||||||
|
|
||||||
|
dest_path = os.path.join(upload_dir, safe_filename)
|
||||||
|
try:
|
||||||
|
contents = file.file.read()
|
||||||
|
with open(dest_path, "wb") as f:
|
||||||
|
f.write(contents)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error("Failed to save uploaded file: %s", exc)
|
||||||
|
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Failed to save file")
|
||||||
|
|
||||||
|
# Queue background processing
|
||||||
|
task_id = None
|
||||||
|
try:
|
||||||
|
from app.tasks.process_document import process_document
|
||||||
|
|
||||||
|
result = process_document.delay(dest_path, owner_id)
|
||||||
|
task_id = result.id
|
||||||
|
logger.info("Automation upload queued: file=%s, task=%s, owner=%s", safe_filename, task_id, owner_id)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("Could not queue processing task (Celery may be unavailable): %s", exc)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": "accepted",
|
||||||
|
"filename": safe_filename,
|
||||||
|
"task_id": task_id,
|
||||||
|
}
|
||||||
+1
-1
@@ -260,7 +260,7 @@ async def stripe_webhook(request: Request, db: Session = Depends(get_db)) -> dic
|
|||||||
@require_login
|
@require_login
|
||||||
async def billing_success(request: Request) -> Any:
|
async def billing_success(request: Request) -> Any:
|
||||||
"""Show a success page after a completed Stripe Checkout."""
|
"""Show a success page after a completed Stripe Checkout."""
|
||||||
return _templates.TemplateResponse("billing_success.html", {"request": request})
|
return _templates.TemplateResponse(request, "billing_success.html")
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -0,0 +1,325 @@
|
|||||||
|
"""Classification Rules API endpoints.
|
||||||
|
|
||||||
|
Provides CRUD operations for managing custom document classification rules.
|
||||||
|
System-wide rules (``owner_id IS NULL``) can only be managed by admins.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import Annotated, Any
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.auth import require_login
|
||||||
|
from app.database import get_db
|
||||||
|
from app.models import ClassificationRuleModel
|
||||||
|
from app.utils.classification_rules import (
|
||||||
|
BUILTIN_CATEGORIES,
|
||||||
|
RULE_TYPE_CONTENT,
|
||||||
|
RULE_TYPE_FILENAME,
|
||||||
|
RULE_TYPE_METADATA,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/classification-rules", tags=["classification"])
|
||||||
|
|
||||||
|
DbSession = Annotated[Session, Depends(get_db)]
|
||||||
|
|
||||||
|
_VALID_RULE_TYPES = {RULE_TYPE_FILENAME, RULE_TYPE_CONTENT, RULE_TYPE_METADATA}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _get_user_id(request: Request) -> str:
|
||||||
|
"""Extract the user identifier from the request session."""
|
||||||
|
user = getattr(request.state, "user", None)
|
||||||
|
if user and hasattr(user, "get"):
|
||||||
|
return user.get("sub") or user.get("email") or "anonymous"
|
||||||
|
return "anonymous"
|
||||||
|
|
||||||
|
|
||||||
|
def _is_admin(request: Request) -> bool:
|
||||||
|
"""Check whether the current user is an admin."""
|
||||||
|
user = getattr(request.state, "user", None)
|
||||||
|
if user and hasattr(user, "get"):
|
||||||
|
groups = user.get("groups", [])
|
||||||
|
return "admin" in groups or "Admin" in groups
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Pydantic schemas
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class RuleCreate(BaseModel):
|
||||||
|
"""Schema for creating a classification rule."""
|
||||||
|
|
||||||
|
name: str = Field(..., min_length=1, max_length=255)
|
||||||
|
category: str = Field(..., min_length=1, max_length=100)
|
||||||
|
rule_type: str = Field(..., description="One of: filename_pattern, content_keyword, metadata_match")
|
||||||
|
pattern: str = Field(..., min_length=1, max_length=1000)
|
||||||
|
priority: int = Field(default=0, ge=0, le=1000)
|
||||||
|
case_sensitive: bool = False
|
||||||
|
enabled: bool = True
|
||||||
|
|
||||||
|
|
||||||
|
class RuleUpdate(BaseModel):
|
||||||
|
"""Schema for updating a classification rule."""
|
||||||
|
|
||||||
|
name: str | None = Field(default=None, min_length=1, max_length=255)
|
||||||
|
category: str | None = Field(default=None, min_length=1, max_length=100)
|
||||||
|
rule_type: str | None = Field(default=None)
|
||||||
|
pattern: str | None = Field(default=None, min_length=1, max_length=1000)
|
||||||
|
priority: int | None = Field(default=None, ge=0, le=1000)
|
||||||
|
case_sensitive: bool | None = None
|
||||||
|
enabled: bool | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class RuleResponse(BaseModel):
|
||||||
|
"""Schema for a classification rule response."""
|
||||||
|
|
||||||
|
id: int
|
||||||
|
owner_id: str | None
|
||||||
|
name: str
|
||||||
|
category: str
|
||||||
|
rule_type: str
|
||||||
|
pattern: str
|
||||||
|
priority: int
|
||||||
|
case_sensitive: bool
|
||||||
|
enabled: bool
|
||||||
|
|
||||||
|
model_config = {"from_attributes": True}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Endpoints
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/categories")
|
||||||
|
@require_login
|
||||||
|
async def list_categories(request: Request) -> dict[str, str]:
|
||||||
|
"""Return all built-in classification categories.
|
||||||
|
|
||||||
|
Custom categories created via rules are not included here; they are
|
||||||
|
discovered dynamically when rules are evaluated.
|
||||||
|
"""
|
||||||
|
return BUILTIN_CATEGORIES
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/rule-types")
|
||||||
|
@require_login
|
||||||
|
async def list_rule_types(request: Request) -> list[dict[str, str]]:
|
||||||
|
"""Return the supported rule types with descriptions."""
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"type": RULE_TYPE_FILENAME,
|
||||||
|
"label": "Filename Pattern",
|
||||||
|
"description": "Regex pattern matched against the original filename.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": RULE_TYPE_CONTENT,
|
||||||
|
"label": "Content Keyword",
|
||||||
|
"description": "Pipe-separated keywords matched against the OCR text.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": RULE_TYPE_METADATA,
|
||||||
|
"label": "Metadata Match",
|
||||||
|
"description": "field=value pattern matched against existing AI metadata.",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/")
|
||||||
|
@require_login
|
||||||
|
async def list_rules(request: Request, db: DbSession) -> list[dict[str, Any]]:
|
||||||
|
"""List classification rules visible to the current user.
|
||||||
|
|
||||||
|
Returns both system rules (``owner_id IS NULL``) and the user's own rules.
|
||||||
|
"""
|
||||||
|
user_id = _get_user_id(request)
|
||||||
|
rules = (
|
||||||
|
db.query(ClassificationRuleModel)
|
||||||
|
.filter((ClassificationRuleModel.owner_id.is_(None)) | (ClassificationRuleModel.owner_id == user_id))
|
||||||
|
.order_by(ClassificationRuleModel.priority.desc(), ClassificationRuleModel.id)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"id": r.id,
|
||||||
|
"owner_id": r.owner_id,
|
||||||
|
"name": r.name,
|
||||||
|
"category": r.category,
|
||||||
|
"rule_type": r.rule_type,
|
||||||
|
"pattern": r.pattern,
|
||||||
|
"priority": r.priority,
|
||||||
|
"case_sensitive": r.case_sensitive,
|
||||||
|
"enabled": r.enabled,
|
||||||
|
}
|
||||||
|
for r in rules
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/", status_code=status.HTTP_201_CREATED)
|
||||||
|
@require_login
|
||||||
|
async def create_rule(request: Request, body: RuleCreate, db: DbSession) -> dict[str, Any]:
|
||||||
|
"""Create a new custom classification rule.
|
||||||
|
|
||||||
|
The rule is owned by the current user. Admins may create system-wide
|
||||||
|
rules by setting ``owner_id`` to ``null`` (not yet exposed).
|
||||||
|
"""
|
||||||
|
if body.rule_type not in _VALID_RULE_TYPES:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=f"Invalid rule_type. Must be one of: {', '.join(sorted(_VALID_RULE_TYPES))}",
|
||||||
|
)
|
||||||
|
|
||||||
|
user_id = _get_user_id(request)
|
||||||
|
|
||||||
|
# Check for duplicate name within the user's scope
|
||||||
|
existing = (
|
||||||
|
db.query(ClassificationRuleModel)
|
||||||
|
.filter(ClassificationRuleModel.owner_id == user_id, ClassificationRuleModel.name == body.name)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if existing:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_409_CONFLICT,
|
||||||
|
detail=f"A rule named '{body.name}' already exists.",
|
||||||
|
)
|
||||||
|
|
||||||
|
rule = ClassificationRuleModel(
|
||||||
|
owner_id=user_id,
|
||||||
|
name=body.name,
|
||||||
|
category=body.category,
|
||||||
|
rule_type=body.rule_type,
|
||||||
|
pattern=body.pattern,
|
||||||
|
priority=body.priority,
|
||||||
|
case_sensitive=body.case_sensitive,
|
||||||
|
enabled=body.enabled,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
db.add(rule)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(rule)
|
||||||
|
except Exception:
|
||||||
|
db.rollback()
|
||||||
|
raise
|
||||||
|
|
||||||
|
logger.info("Classification rule created: id=%s, user=%s", rule.id, user_id)
|
||||||
|
return {
|
||||||
|
"id": rule.id,
|
||||||
|
"owner_id": rule.owner_id,
|
||||||
|
"name": rule.name,
|
||||||
|
"category": rule.category,
|
||||||
|
"rule_type": rule.rule_type,
|
||||||
|
"pattern": rule.pattern,
|
||||||
|
"priority": rule.priority,
|
||||||
|
"case_sensitive": rule.case_sensitive,
|
||||||
|
"enabled": rule.enabled,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{rule_id}")
|
||||||
|
@require_login
|
||||||
|
async def get_rule(request: Request, rule_id: int, db: DbSession) -> dict[str, Any]:
|
||||||
|
"""Get a single classification rule by ID."""
|
||||||
|
user_id = _get_user_id(request)
|
||||||
|
rule = db.query(ClassificationRuleModel).filter(ClassificationRuleModel.id == rule_id).first()
|
||||||
|
if rule is None:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Rule not found")
|
||||||
|
|
||||||
|
# Users can see system rules and their own rules
|
||||||
|
if rule.owner_id is not None and rule.owner_id != user_id and not _is_admin(request):
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Rule not found")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"id": rule.id,
|
||||||
|
"owner_id": rule.owner_id,
|
||||||
|
"name": rule.name,
|
||||||
|
"category": rule.category,
|
||||||
|
"rule_type": rule.rule_type,
|
||||||
|
"pattern": rule.pattern,
|
||||||
|
"priority": rule.priority,
|
||||||
|
"case_sensitive": rule.case_sensitive,
|
||||||
|
"enabled": rule.enabled,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{rule_id}")
|
||||||
|
@require_login
|
||||||
|
async def update_rule(request: Request, rule_id: int, body: RuleUpdate, db: DbSession) -> dict[str, Any]:
|
||||||
|
"""Update an existing classification rule.
|
||||||
|
|
||||||
|
Users can only update their own rules. Admins can update any rule.
|
||||||
|
"""
|
||||||
|
user_id = _get_user_id(request)
|
||||||
|
rule = db.query(ClassificationRuleModel).filter(ClassificationRuleModel.id == rule_id).first()
|
||||||
|
if rule is None:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Rule not found")
|
||||||
|
|
||||||
|
if rule.owner_id != user_id and not _is_admin(request):
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Cannot modify this rule")
|
||||||
|
|
||||||
|
if body.rule_type is not None and body.rule_type not in _VALID_RULE_TYPES:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=f"Invalid rule_type. Must be one of: {', '.join(sorted(_VALID_RULE_TYPES))}",
|
||||||
|
)
|
||||||
|
|
||||||
|
update_data = body.model_dump(exclude_unset=True)
|
||||||
|
for field_name, value in update_data.items():
|
||||||
|
setattr(rule, field_name, value)
|
||||||
|
|
||||||
|
try:
|
||||||
|
db.commit()
|
||||||
|
db.refresh(rule)
|
||||||
|
except Exception:
|
||||||
|
db.rollback()
|
||||||
|
raise
|
||||||
|
|
||||||
|
logger.info("Classification rule updated: id=%s, user=%s", rule.id, user_id)
|
||||||
|
return {
|
||||||
|
"id": rule.id,
|
||||||
|
"owner_id": rule.owner_id,
|
||||||
|
"name": rule.name,
|
||||||
|
"category": rule.category,
|
||||||
|
"rule_type": rule.rule_type,
|
||||||
|
"pattern": rule.pattern,
|
||||||
|
"priority": rule.priority,
|
||||||
|
"case_sensitive": rule.case_sensitive,
|
||||||
|
"enabled": rule.enabled,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{rule_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
@require_login
|
||||||
|
async def delete_rule(request: Request, rule_id: int, db: DbSession) -> None:
|
||||||
|
"""Delete a classification rule.
|
||||||
|
|
||||||
|
Users can only delete their own rules. Admins can delete any rule.
|
||||||
|
"""
|
||||||
|
user_id = _get_user_id(request)
|
||||||
|
rule = db.query(ClassificationRuleModel).filter(ClassificationRuleModel.id == rule_id).first()
|
||||||
|
if rule is None:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Rule not found")
|
||||||
|
|
||||||
|
if rule.owner_id != user_id and not _is_admin(request):
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Cannot delete this rule")
|
||||||
|
|
||||||
|
try:
|
||||||
|
db.delete(rule)
|
||||||
|
db.commit()
|
||||||
|
except Exception:
|
||||||
|
db.rollback()
|
||||||
|
raise
|
||||||
|
|
||||||
|
logger.info("Classification rule deleted: id=%s, user=%s", rule_id, user_id)
|
||||||
@@ -0,0 +1,751 @@
|
|||||||
|
"""Document comments and annotations API endpoints.
|
||||||
|
|
||||||
|
Provides CRUD operations for threaded comments on documents,
|
||||||
|
text annotations on PDF pages, and a list of mentionable users
|
||||||
|
for the @mention feature.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
from typing import Annotated, Any
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Body, Depends, HTTPException, Request, status
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.auth import get_current_user_id, require_login
|
||||||
|
from app.database import get_db
|
||||||
|
from app.models import (
|
||||||
|
FILE_SHARE_ROLE_VIEWER,
|
||||||
|
DocumentAnnotation,
|
||||||
|
DocumentComment,
|
||||||
|
FileRecord,
|
||||||
|
FileShare,
|
||||||
|
UserProfile,
|
||||||
|
)
|
||||||
|
from app.utils.user_scope import get_current_owner_id, has_file_role
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
router = APIRouter(tags=["comments"])
|
||||||
|
|
||||||
|
DbSession = Annotated[Session, Depends(get_db)]
|
||||||
|
|
||||||
|
# Constraints
|
||||||
|
MAX_COMMENT_BODY_LENGTH = 10_000
|
||||||
|
MAX_ANNOTATION_CONTENT_LENGTH = 5_000
|
||||||
|
|
||||||
|
# Allowed annotation types
|
||||||
|
ALLOWED_ANNOTATION_TYPES = frozenset({"note", "highlight", "underline", "strikethrough"})
|
||||||
|
|
||||||
|
# Simple pattern for @mentions – matches @username tokens inside comment body
|
||||||
|
_MENTION_PATTERN = re.compile(r"@([\w.\-]+)")
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_mentions(body: str) -> list[str]:
|
||||||
|
"""Extract unique @mentioned usernames from a comment body.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
body: The raw comment text.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A deduplicated list of mentioned usernames (without the ``@`` prefix).
|
||||||
|
"""
|
||||||
|
return list(dict.fromkeys(_MENTION_PATTERN.findall(body)))
|
||||||
|
|
||||||
|
|
||||||
|
def _serialize_comment(c: DocumentComment) -> dict[str, Any]:
|
||||||
|
"""Serialize a DocumentComment to a JSON-friendly dict.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
c: The comment model instance.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A dictionary representation of the comment.
|
||||||
|
"""
|
||||||
|
mentions: list[str] = []
|
||||||
|
if c.mentions:
|
||||||
|
try:
|
||||||
|
mentions = json.loads(c.mentions)
|
||||||
|
except (json.JSONDecodeError, TypeError):
|
||||||
|
pass
|
||||||
|
return {
|
||||||
|
"id": c.id,
|
||||||
|
"file_id": c.file_id,
|
||||||
|
"user_id": c.user_id,
|
||||||
|
"parent_id": c.parent_id,
|
||||||
|
"body": c.body,
|
||||||
|
"mentions": mentions,
|
||||||
|
"is_resolved": c.is_resolved,
|
||||||
|
"created_at": c.created_at.isoformat() if c.created_at else None,
|
||||||
|
"updated_at": c.updated_at.isoformat() if c.updated_at else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _serialize_annotation(a: DocumentAnnotation) -> dict[str, Any]:
|
||||||
|
"""Serialize a DocumentAnnotation to a JSON-friendly dict.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
a: The annotation model instance.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A dictionary representation of the annotation.
|
||||||
|
"""
|
||||||
|
return {
|
||||||
|
"id": a.id,
|
||||||
|
"file_id": a.file_id,
|
||||||
|
"user_id": a.user_id,
|
||||||
|
"page": a.page,
|
||||||
|
"x": a.x,
|
||||||
|
"y": a.y,
|
||||||
|
"width": a.width,
|
||||||
|
"height": a.height,
|
||||||
|
"content": a.content,
|
||||||
|
"annotation_type": a.annotation_type,
|
||||||
|
"color": a.color,
|
||||||
|
"created_at": a.created_at.isoformat() if a.created_at else None,
|
||||||
|
"updated_at": a.updated_at.isoformat() if a.updated_at else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _build_thread_tree(comments: list[DocumentComment]) -> list[dict[str, Any]]:
|
||||||
|
"""Organize a flat list of comments into a threaded tree structure.
|
||||||
|
|
||||||
|
Top-level comments (``parent_id is None``) appear as root nodes.
|
||||||
|
Replies are nested inside their parent's ``replies`` list.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
comments: All comments for a given document, ordered by ``created_at``.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A list of root-level comment dicts, each with a ``replies`` key.
|
||||||
|
"""
|
||||||
|
by_id: dict[int, dict[str, Any]] = {}
|
||||||
|
roots: list[dict[str, Any]] = []
|
||||||
|
|
||||||
|
for c in comments:
|
||||||
|
node = _serialize_comment(c)
|
||||||
|
node["replies"] = []
|
||||||
|
by_id[c.id] = node
|
||||||
|
|
||||||
|
for c in comments:
|
||||||
|
node = by_id[c.id]
|
||||||
|
if c.parent_id and c.parent_id in by_id:
|
||||||
|
by_id[c.parent_id]["replies"].append(node)
|
||||||
|
else:
|
||||||
|
roots.append(node)
|
||||||
|
|
||||||
|
return roots
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Comments endpoints
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/files/{file_id}/comments")
|
||||||
|
@require_login
|
||||||
|
def list_comments(request: Request, file_id: int, db: DbSession):
|
||||||
|
"""List all comments for a document, organized into threads.
|
||||||
|
|
||||||
|
Returns a threaded tree where top-level comments contain nested
|
||||||
|
``replies``. Requires at least viewer access.
|
||||||
|
|
||||||
|
Path Parameters:
|
||||||
|
file_id: The ID of the document.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A dict with ``file_id``, ``comments`` (threaded), and ``total``.
|
||||||
|
"""
|
||||||
|
user_id = get_current_owner_id(request)
|
||||||
|
user = request.session.get("user")
|
||||||
|
is_admin = isinstance(user, dict) and bool(user.get("is_admin"))
|
||||||
|
|
||||||
|
file_record = db.query(FileRecord).filter(FileRecord.id == file_id).first()
|
||||||
|
if not file_record:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="File not found")
|
||||||
|
|
||||||
|
if not is_admin and not has_file_role(file_record, user_id, db, minimum_role=FILE_SHARE_ROLE_VIEWER):
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="File not found")
|
||||||
|
|
||||||
|
comments = (
|
||||||
|
db.query(DocumentComment).filter(DocumentComment.file_id == file_id).order_by(DocumentComment.created_at).all()
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"file_id": file_id,
|
||||||
|
"comments": _build_thread_tree(comments),
|
||||||
|
"total": len(comments),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/files/{file_id}/comments", status_code=status.HTTP_201_CREATED)
|
||||||
|
@require_login
|
||||||
|
def create_comment(
|
||||||
|
request: Request,
|
||||||
|
file_id: int,
|
||||||
|
db: DbSession,
|
||||||
|
body: str = Body(..., embed=True),
|
||||||
|
parent_id: int | None = Body(None, embed=True),
|
||||||
|
):
|
||||||
|
"""Create a new comment on a document.
|
||||||
|
|
||||||
|
Automatically extracts @mentions from the comment body and stores
|
||||||
|
them for later notification or UI highlighting. When multi-user
|
||||||
|
mode is enabled, any mentioned user that does not already have
|
||||||
|
access to the document is automatically granted ``viewer`` access by
|
||||||
|
the file owner so they can read the file and continue the discussion.
|
||||||
|
|
||||||
|
Path Parameters:
|
||||||
|
file_id: The ID of the document to comment on.
|
||||||
|
|
||||||
|
Request body (JSON):
|
||||||
|
body: Comment text (required, max 10 000 characters).
|
||||||
|
parent_id: ID of the parent comment for threaded replies (optional).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The created comment object.
|
||||||
|
"""
|
||||||
|
user_id = get_current_user_id(request)
|
||||||
|
owner_id = get_current_owner_id(request)
|
||||||
|
user = request.session.get("user")
|
||||||
|
is_admin = isinstance(user, dict) and bool(user.get("is_admin"))
|
||||||
|
|
||||||
|
file_record = db.query(FileRecord).filter(FileRecord.id == file_id).first()
|
||||||
|
if not file_record:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="File not found")
|
||||||
|
|
||||||
|
if not is_admin and not has_file_role(file_record, owner_id, db, minimum_role=FILE_SHARE_ROLE_VIEWER):
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="File not found")
|
||||||
|
|
||||||
|
if not isinstance(body, str) or not body.strip():
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||||
|
detail="body is required and must be non-empty",
|
||||||
|
)
|
||||||
|
body = body.strip()
|
||||||
|
if len(body) > MAX_COMMENT_BODY_LENGTH:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||||
|
detail=f"body must be at most {MAX_COMMENT_BODY_LENGTH} characters",
|
||||||
|
)
|
||||||
|
|
||||||
|
if parent_id is not None:
|
||||||
|
parent = (
|
||||||
|
db.query(DocumentComment)
|
||||||
|
.filter(DocumentComment.id == parent_id, DocumentComment.file_id == file_id)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if not parent:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="Parent comment not found",
|
||||||
|
)
|
||||||
|
|
||||||
|
mentions = _extract_mentions(body)
|
||||||
|
|
||||||
|
comment = DocumentComment(
|
||||||
|
file_id=file_id,
|
||||||
|
user_id=user_id,
|
||||||
|
parent_id=parent_id,
|
||||||
|
body=body,
|
||||||
|
mentions=json.dumps(mentions) if mentions else None,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
db.add(comment)
|
||||||
|
db.flush() # write comment so we can get its id before committing
|
||||||
|
|
||||||
|
# Auto-share the file with mentioned users that don't have access yet.
|
||||||
|
# Only do this in multi-user mode and only when the file has an owner
|
||||||
|
# (unowned files are already visible to all authenticated users).
|
||||||
|
if mentions and file_record.owner_id is not None:
|
||||||
|
from app.config import settings as _settings
|
||||||
|
|
||||||
|
if _settings.multi_user_enabled:
|
||||||
|
for mentioned_user in mentions:
|
||||||
|
# Skip the file owner (already has full access) and the commenter
|
||||||
|
# themselves (they already have access to be posting a comment).
|
||||||
|
if mentioned_user in {file_record.owner_id, owner_id}:
|
||||||
|
continue
|
||||||
|
existing_share = (
|
||||||
|
db.query(FileShare)
|
||||||
|
.filter(
|
||||||
|
FileShare.file_id == file_id,
|
||||||
|
FileShare.shared_with_user_id == mentioned_user,
|
||||||
|
)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if not existing_share:
|
||||||
|
auto_share = FileShare(
|
||||||
|
file_id=file_id,
|
||||||
|
owner_id=file_record.owner_id,
|
||||||
|
shared_with_user_id=mentioned_user,
|
||||||
|
role=FILE_SHARE_ROLE_VIEWER,
|
||||||
|
)
|
||||||
|
db.add(auto_share)
|
||||||
|
logger.info(
|
||||||
|
"Auto-shared file_id=%s with mentioned user=%s as viewer",
|
||||||
|
file_id,
|
||||||
|
mentioned_user,
|
||||||
|
)
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
db.refresh(comment)
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception:
|
||||||
|
db.rollback()
|
||||||
|
logger.exception("Failed to create comment on file_id=%s", file_id)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail="Failed to create comment",
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info("Comment created: id=%s, file_id=%s, user=%s", comment.id, file_id, user_id)
|
||||||
|
return _serialize_comment(comment)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/files/{file_id}/comments/{comment_id}")
|
||||||
|
@require_login
|
||||||
|
def update_comment(
|
||||||
|
request: Request,
|
||||||
|
file_id: int,
|
||||||
|
comment_id: int,
|
||||||
|
db: DbSession,
|
||||||
|
body: str = Body(..., embed=True),
|
||||||
|
):
|
||||||
|
"""Update the body of an existing comment.
|
||||||
|
|
||||||
|
Only the comment author may update the comment. Mentions are
|
||||||
|
re-extracted from the updated body.
|
||||||
|
|
||||||
|
Path Parameters:
|
||||||
|
file_id: The ID of the document.
|
||||||
|
comment_id: The ID of the comment to update.
|
||||||
|
|
||||||
|
Request body (JSON):
|
||||||
|
body: New comment text (required).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The updated comment object.
|
||||||
|
"""
|
||||||
|
user_id = get_current_user_id(request)
|
||||||
|
|
||||||
|
comment = (
|
||||||
|
db.query(DocumentComment).filter(DocumentComment.id == comment_id, DocumentComment.file_id == file_id).first()
|
||||||
|
)
|
||||||
|
if not comment:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Comment not found")
|
||||||
|
|
||||||
|
if comment.user_id != user_id:
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="You can only edit your own comments")
|
||||||
|
|
||||||
|
if not isinstance(body, str) or not body.strip():
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||||
|
detail="body is required and must be non-empty",
|
||||||
|
)
|
||||||
|
body = body.strip()
|
||||||
|
if len(body) > MAX_COMMENT_BODY_LENGTH:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||||
|
detail=f"body must be at most {MAX_COMMENT_BODY_LENGTH} characters",
|
||||||
|
)
|
||||||
|
|
||||||
|
mentions = _extract_mentions(body)
|
||||||
|
comment.body = body
|
||||||
|
comment.mentions = json.dumps(mentions) if mentions else None
|
||||||
|
|
||||||
|
try:
|
||||||
|
db.commit()
|
||||||
|
db.refresh(comment)
|
||||||
|
except Exception:
|
||||||
|
db.rollback()
|
||||||
|
logger.exception("Failed to update comment id=%s", comment_id)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail="Failed to update comment",
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info("Comment updated: id=%s, user=%s", comment_id, user_id)
|
||||||
|
return _serialize_comment(comment)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/files/{file_id}/comments/{comment_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
@require_login
|
||||||
|
def delete_comment(request: Request, file_id: int, comment_id: int, db: DbSession):
|
||||||
|
"""Delete a comment.
|
||||||
|
|
||||||
|
Only the comment author may delete the comment. Replies to the
|
||||||
|
deleted comment are **not** removed — they become orphaned root
|
||||||
|
comments so that conversation context is preserved.
|
||||||
|
|
||||||
|
Path Parameters:
|
||||||
|
file_id: The ID of the document.
|
||||||
|
comment_id: The ID of the comment to delete.
|
||||||
|
"""
|
||||||
|
user_id = get_current_user_id(request)
|
||||||
|
|
||||||
|
comment = (
|
||||||
|
db.query(DocumentComment).filter(DocumentComment.id == comment_id, DocumentComment.file_id == file_id).first()
|
||||||
|
)
|
||||||
|
if not comment:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Comment not found")
|
||||||
|
|
||||||
|
if comment.user_id != user_id:
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="You can only delete your own comments")
|
||||||
|
|
||||||
|
try:
|
||||||
|
db.delete(comment)
|
||||||
|
db.commit()
|
||||||
|
except Exception:
|
||||||
|
db.rollback()
|
||||||
|
logger.exception("Failed to delete comment id=%s", comment_id)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail="Failed to delete comment",
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info("Comment deleted: id=%s, user=%s", comment_id, user_id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/files/{file_id}/comments/{comment_id}/resolve")
|
||||||
|
@require_login
|
||||||
|
def resolve_comment(
|
||||||
|
request: Request,
|
||||||
|
file_id: int,
|
||||||
|
comment_id: int,
|
||||||
|
db: DbSession,
|
||||||
|
is_resolved: bool = Body(..., embed=True),
|
||||||
|
):
|
||||||
|
"""Mark a top-level comment thread as resolved or unresolved.
|
||||||
|
|
||||||
|
Path Parameters:
|
||||||
|
file_id: The ID of the document.
|
||||||
|
comment_id: The ID of the comment to resolve / unresolve.
|
||||||
|
|
||||||
|
Request body (JSON):
|
||||||
|
is_resolved: ``true`` to resolve, ``false`` to unresolve.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The updated comment object.
|
||||||
|
"""
|
||||||
|
comment = (
|
||||||
|
db.query(DocumentComment).filter(DocumentComment.id == comment_id, DocumentComment.file_id == file_id).first()
|
||||||
|
)
|
||||||
|
if not comment:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Comment not found")
|
||||||
|
|
||||||
|
comment.is_resolved = is_resolved
|
||||||
|
|
||||||
|
try:
|
||||||
|
db.commit()
|
||||||
|
db.refresh(comment)
|
||||||
|
except Exception:
|
||||||
|
db.rollback()
|
||||||
|
logger.exception("Failed to resolve comment id=%s", comment_id)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail="Failed to update comment",
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info("Comment %s: id=%s", "resolved" if is_resolved else "unresolved", comment_id)
|
||||||
|
return _serialize_comment(comment)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Annotations endpoints
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/files/{file_id}/annotations")
|
||||||
|
@require_login
|
||||||
|
def list_annotations(request: Request, file_id: int, db: DbSession):
|
||||||
|
"""List all annotations for a document.
|
||||||
|
|
||||||
|
Requires at least viewer access.
|
||||||
|
|
||||||
|
Path Parameters:
|
||||||
|
file_id: The ID of the document.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A dict with ``file_id``, ``annotations``, and ``total``.
|
||||||
|
"""
|
||||||
|
user_id = get_current_owner_id(request)
|
||||||
|
user = request.session.get("user")
|
||||||
|
is_admin = isinstance(user, dict) and bool(user.get("is_admin"))
|
||||||
|
|
||||||
|
file_record = db.query(FileRecord).filter(FileRecord.id == file_id).first()
|
||||||
|
if not file_record:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="File not found")
|
||||||
|
|
||||||
|
if not is_admin and not has_file_role(file_record, user_id, db, minimum_role=FILE_SHARE_ROLE_VIEWER):
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="File not found")
|
||||||
|
|
||||||
|
annotations = (
|
||||||
|
db.query(DocumentAnnotation)
|
||||||
|
.filter(DocumentAnnotation.file_id == file_id)
|
||||||
|
.order_by(DocumentAnnotation.page, DocumentAnnotation.created_at)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"file_id": file_id,
|
||||||
|
"annotations": [_serialize_annotation(a) for a in annotations],
|
||||||
|
"total": len(annotations),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/files/{file_id}/annotations", status_code=status.HTTP_201_CREATED)
|
||||||
|
@require_login
|
||||||
|
def create_annotation(
|
||||||
|
request: Request,
|
||||||
|
file_id: int,
|
||||||
|
db: DbSession,
|
||||||
|
page: int = Body(..., embed=True),
|
||||||
|
x: float = Body(..., embed=True),
|
||||||
|
y: float = Body(..., embed=True),
|
||||||
|
content: str = Body(..., embed=True),
|
||||||
|
width: float = Body(0, embed=True),
|
||||||
|
height: float = Body(0, embed=True),
|
||||||
|
annotation_type: str = Body("note", embed=True),
|
||||||
|
color: str | None = Body(None, embed=True),
|
||||||
|
):
|
||||||
|
"""Create a new annotation on a PDF page.
|
||||||
|
|
||||||
|
Path Parameters:
|
||||||
|
file_id: The ID of the document.
|
||||||
|
|
||||||
|
Request body (JSON):
|
||||||
|
page: Page number (1-based, required).
|
||||||
|
x: Horizontal position on the page (required).
|
||||||
|
y: Vertical position on the page (required).
|
||||||
|
content: Annotation text (required, max 5 000 characters).
|
||||||
|
width: Width of the annotation bounding box (default 0).
|
||||||
|
height: Height of the annotation bounding box (default 0).
|
||||||
|
annotation_type: One of ``note``, ``highlight``, ``underline``,
|
||||||
|
``strikethrough`` (default ``note``).
|
||||||
|
color: Optional CSS colour string (e.g. ``#ff0000``).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The created annotation object.
|
||||||
|
"""
|
||||||
|
user_id = get_current_user_id(request)
|
||||||
|
owner_id = get_current_owner_id(request)
|
||||||
|
user = request.session.get("user")
|
||||||
|
is_admin = isinstance(user, dict) and bool(user.get("is_admin"))
|
||||||
|
|
||||||
|
file_record = db.query(FileRecord).filter(FileRecord.id == file_id).first()
|
||||||
|
if not file_record:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="File not found")
|
||||||
|
|
||||||
|
if not is_admin and not has_file_role(file_record, owner_id, db, minimum_role=FILE_SHARE_ROLE_VIEWER):
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="File not found")
|
||||||
|
|
||||||
|
if not isinstance(content, str) or not content.strip():
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||||
|
detail="content is required and must be non-empty",
|
||||||
|
)
|
||||||
|
content = content.strip()
|
||||||
|
if len(content) > MAX_ANNOTATION_CONTENT_LENGTH:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||||
|
detail=f"content must be at most {MAX_ANNOTATION_CONTENT_LENGTH} characters",
|
||||||
|
)
|
||||||
|
|
||||||
|
if page < 1:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||||
|
detail="page must be >= 1",
|
||||||
|
)
|
||||||
|
|
||||||
|
if annotation_type not in ALLOWED_ANNOTATION_TYPES:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||||
|
detail=f"annotation_type must be one of: {', '.join(sorted(ALLOWED_ANNOTATION_TYPES))}",
|
||||||
|
)
|
||||||
|
|
||||||
|
annotation = DocumentAnnotation(
|
||||||
|
file_id=file_id,
|
||||||
|
user_id=user_id,
|
||||||
|
page=page,
|
||||||
|
x=x,
|
||||||
|
y=y,
|
||||||
|
width=width,
|
||||||
|
height=height,
|
||||||
|
content=content,
|
||||||
|
annotation_type=annotation_type,
|
||||||
|
color=color,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
db.add(annotation)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(annotation)
|
||||||
|
except Exception:
|
||||||
|
db.rollback()
|
||||||
|
logger.exception("Failed to create annotation on file_id=%s", file_id)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail="Failed to create annotation",
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info("Annotation created: id=%s, file_id=%s, user=%s", annotation.id, file_id, user_id)
|
||||||
|
return _serialize_annotation(annotation)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/files/{file_id}/annotations/{annotation_id}")
|
||||||
|
@require_login
|
||||||
|
def update_annotation(
|
||||||
|
request: Request,
|
||||||
|
file_id: int,
|
||||||
|
annotation_id: int,
|
||||||
|
db: DbSession,
|
||||||
|
content: str | None = Body(None, embed=True),
|
||||||
|
x: float | None = Body(None, embed=True),
|
||||||
|
y: float | None = Body(None, embed=True),
|
||||||
|
width: float | None = Body(None, embed=True),
|
||||||
|
height: float | None = Body(None, embed=True),
|
||||||
|
annotation_type: str | None = Body(None, embed=True),
|
||||||
|
color: str | None = Body(None, embed=True),
|
||||||
|
):
|
||||||
|
"""Update an existing annotation.
|
||||||
|
|
||||||
|
Only the annotation author may update the annotation.
|
||||||
|
|
||||||
|
Path Parameters:
|
||||||
|
file_id: The ID of the document.
|
||||||
|
annotation_id: The ID of the annotation to update.
|
||||||
|
|
||||||
|
Request body (JSON):
|
||||||
|
Any subset of ``content``, ``x``, ``y``, ``width``, ``height``,
|
||||||
|
``annotation_type``, and ``color``.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The updated annotation object.
|
||||||
|
"""
|
||||||
|
user_id = get_current_user_id(request)
|
||||||
|
|
||||||
|
annotation = (
|
||||||
|
db.query(DocumentAnnotation)
|
||||||
|
.filter(DocumentAnnotation.id == annotation_id, DocumentAnnotation.file_id == file_id)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if not annotation:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Annotation not found")
|
||||||
|
|
||||||
|
if annotation.user_id != user_id:
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="You can only edit your own annotations")
|
||||||
|
|
||||||
|
if content is not None:
|
||||||
|
content = content.strip() if isinstance(content, str) else ""
|
||||||
|
if not content:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||||
|
detail="content must be non-empty",
|
||||||
|
)
|
||||||
|
if len(content) > MAX_ANNOTATION_CONTENT_LENGTH:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||||
|
detail=f"content must be at most {MAX_ANNOTATION_CONTENT_LENGTH} characters",
|
||||||
|
)
|
||||||
|
annotation.content = content
|
||||||
|
|
||||||
|
if x is not None:
|
||||||
|
annotation.x = x
|
||||||
|
if y is not None:
|
||||||
|
annotation.y = y
|
||||||
|
if width is not None:
|
||||||
|
annotation.width = width
|
||||||
|
if height is not None:
|
||||||
|
annotation.height = height
|
||||||
|
if annotation_type is not None:
|
||||||
|
if annotation_type not in ALLOWED_ANNOTATION_TYPES:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||||
|
detail=f"annotation_type must be one of: {', '.join(sorted(ALLOWED_ANNOTATION_TYPES))}",
|
||||||
|
)
|
||||||
|
annotation.annotation_type = annotation_type
|
||||||
|
if color is not None:
|
||||||
|
annotation.color = color
|
||||||
|
|
||||||
|
try:
|
||||||
|
db.commit()
|
||||||
|
db.refresh(annotation)
|
||||||
|
except Exception:
|
||||||
|
db.rollback()
|
||||||
|
logger.exception("Failed to update annotation id=%s", annotation_id)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail="Failed to update annotation",
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info("Annotation updated: id=%s, user=%s", annotation_id, user_id)
|
||||||
|
return _serialize_annotation(annotation)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/files/{file_id}/annotations/{annotation_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
@require_login
|
||||||
|
def delete_annotation(request: Request, file_id: int, annotation_id: int, db: DbSession):
|
||||||
|
"""Delete an annotation.
|
||||||
|
|
||||||
|
Only the annotation author may delete the annotation.
|
||||||
|
|
||||||
|
Path Parameters:
|
||||||
|
file_id: The ID of the document.
|
||||||
|
annotation_id: The ID of the annotation to delete.
|
||||||
|
"""
|
||||||
|
user_id = get_current_user_id(request)
|
||||||
|
|
||||||
|
annotation = (
|
||||||
|
db.query(DocumentAnnotation)
|
||||||
|
.filter(DocumentAnnotation.id == annotation_id, DocumentAnnotation.file_id == file_id)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if not annotation:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Annotation not found")
|
||||||
|
|
||||||
|
if annotation.user_id != user_id:
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="You can only delete your own annotations")
|
||||||
|
|
||||||
|
try:
|
||||||
|
db.delete(annotation)
|
||||||
|
db.commit()
|
||||||
|
except Exception:
|
||||||
|
db.rollback()
|
||||||
|
logger.exception("Failed to delete annotation id=%s", annotation_id)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail="Failed to delete annotation",
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info("Annotation deleted: id=%s, user=%s", annotation_id, user_id)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Mentionable users endpoint
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/users/mentionable")
|
||||||
|
@require_login
|
||||||
|
def list_mentionable_users(request: Request, db: DbSession):
|
||||||
|
"""List users that can be @mentioned in comments.
|
||||||
|
|
||||||
|
Returns all user profiles that are not blocked, sorted by
|
||||||
|
``display_name``.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A list of ``{user_id, display_name}`` objects.
|
||||||
|
"""
|
||||||
|
profiles = db.query(UserProfile).filter(UserProfile.is_blocked.is_(False)).order_by(UserProfile.display_name).all()
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"user_id": p.user_id,
|
||||||
|
"display_name": p.display_name or p.user_id,
|
||||||
|
}
|
||||||
|
for p in profiles
|
||||||
|
]
|
||||||
@@ -21,6 +21,67 @@ _DEFAULT_REDIS_URL = "redis://localhost:6379/0"
|
|||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Unauthenticated probe endpoints for Kubernetes liveness / readiness checks.
|
||||||
|
# These intentionally skip authentication so that kubelet can reach them
|
||||||
|
# without credentials. They live under /diagnostic/healthz/* so that the
|
||||||
|
# existing authenticated /diagnostic/health endpoint is unaffected.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/diagnostic/healthz/live")
|
||||||
|
async def liveness_probe() -> JSONResponse:
|
||||||
|
"""Lightweight liveness probe for Kubernetes.
|
||||||
|
|
||||||
|
Returns **200 OK** as long as the process is running. Kubernetes uses
|
||||||
|
this to decide whether to *restart* the container — it should therefore
|
||||||
|
be as cheap as possible and **never** check external dependencies.
|
||||||
|
|
||||||
|
**Authentication:** None (designed for kubelet probes).
|
||||||
|
"""
|
||||||
|
return JSONResponse(content={"status": "ok"}, status_code=200)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/diagnostic/healthz/ready")
|
||||||
|
async def readiness_probe() -> JSONResponse:
|
||||||
|
"""Readiness probe for Kubernetes.
|
||||||
|
|
||||||
|
Verifies that the application can serve traffic by checking the database
|
||||||
|
and Redis. Kubernetes uses this to decide whether to *route traffic* to
|
||||||
|
the pod.
|
||||||
|
|
||||||
|
Returns **200 OK** when all critical subsystems are reachable, or
|
||||||
|
**503 Service Unavailable** when the database is down.
|
||||||
|
|
||||||
|
**Authentication:** None (designed for kubelet probes).
|
||||||
|
"""
|
||||||
|
checks: dict[str, dict[str, str]] = {}
|
||||||
|
db_ok = False
|
||||||
|
|
||||||
|
# ── Database check ─────────────────────────────────────────────────
|
||||||
|
try:
|
||||||
|
with engine.connect() as conn:
|
||||||
|
conn.execute(text("SELECT 1"))
|
||||||
|
checks["database"] = {"status": "ok"}
|
||||||
|
db_ok = True
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("Readiness probe: database check failed: %s", exc)
|
||||||
|
checks["database"] = {"status": "error", "detail": str(exc)}
|
||||||
|
|
||||||
|
# ── Redis check ────────────────────────────────────────────────────
|
||||||
|
try:
|
||||||
|
redis_url = settings.redis_url or _DEFAULT_REDIS_URL
|
||||||
|
r = redis_lib.from_url(redis_url, socket_connect_timeout=2, socket_timeout=2)
|
||||||
|
r.ping()
|
||||||
|
checks["redis"] = {"status": "ok"}
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("Readiness probe: Redis check failed: %s", exc)
|
||||||
|
checks["redis"] = {"status": "error", "detail": str(exc)}
|
||||||
|
|
||||||
|
http_status = 503 if not db_ok else 200
|
||||||
|
overall = "ready" if db_ok else "not_ready"
|
||||||
|
return JSONResponse(content={"status": overall, "checks": checks}, status_code=http_status)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/diagnostic/health")
|
@router.get("/diagnostic/health")
|
||||||
@require_login
|
@require_login
|
||||||
|
|||||||
+101
-2
@@ -5,8 +5,10 @@ Dropbox API endpoints
|
|||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
from typing import Annotated, Optional
|
from typing import Annotated, Optional
|
||||||
|
from urllib.parse import quote
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
import requests
|
||||||
from fastapi import APIRouter, Depends, Form, HTTPException, Request, status
|
from fastapi import APIRouter, Depends, Form, HTTPException, Request, status
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
@@ -23,6 +25,18 @@ logger = logging.getLogger(__name__)
|
|||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
def _build_dropbox_redirect_uri(request: Request) -> str:
|
||||||
|
"""Build the Dropbox OAuth callback redirect URI.
|
||||||
|
|
||||||
|
Uses ``PUBLIC_BASE_URL`` when configured (recommended for deployments behind
|
||||||
|
a reverse proxy that doesn't forward ``X-Forwarded-Proto``). Falls back to
|
||||||
|
deriving the URI from the incoming request's scheme and host headers.
|
||||||
|
"""
|
||||||
|
if settings.public_base_url:
|
||||||
|
return settings.public_base_url.rstrip("/") + "/dropbox-callback"
|
||||||
|
return f"{request.url.scheme}://{request.url.netloc}/dropbox-callback"
|
||||||
|
|
||||||
|
|
||||||
@router.get("/dropbox/global-authorize-url")
|
@router.get("/dropbox/global-authorize-url")
|
||||||
@require_login
|
@require_login
|
||||||
async def dropbox_global_authorize_url(request: Request):
|
async def dropbox_global_authorize_url(request: Request):
|
||||||
@@ -43,13 +57,13 @@ async def dropbox_global_authorize_url(request: Request):
|
|||||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||||
detail="Global Dropbox credentials are not configured",
|
detail="Global Dropbox credentials are not configured",
|
||||||
)
|
)
|
||||||
redirect_uri = str(request.base_url).rstrip("/") + "/dropbox-callback"
|
redirect_uri = _build_dropbox_redirect_uri(request)
|
||||||
authorize_url = (
|
authorize_url = (
|
||||||
"https://www.dropbox.com/oauth2/authorize"
|
"https://www.dropbox.com/oauth2/authorize"
|
||||||
f"?client_id={settings.dropbox_app_key}"
|
f"?client_id={settings.dropbox_app_key}"
|
||||||
"&response_type=code"
|
"&response_type=code"
|
||||||
"&token_access_type=offline"
|
"&token_access_type=offline"
|
||||||
f"&redirect_uri={redirect_uri}"
|
f"&redirect_uri={quote(redirect_uri, safe='')}"
|
||||||
)
|
)
|
||||||
return {"authorize_url": authorize_url}
|
return {"authorize_url": authorize_url}
|
||||||
|
|
||||||
@@ -285,6 +299,91 @@ async def test_dropbox_token(request: Request):
|
|||||||
return {"status": "error", "message": f"Connection error: {str(e)}"}
|
return {"status": "error", "message": f"Connection error: {str(e)}"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/dropbox/list-folders")
|
||||||
|
@require_login
|
||||||
|
async def list_dropbox_folders(
|
||||||
|
request: Request,
|
||||||
|
access_token: Annotated[str, Form(...)],
|
||||||
|
path: Annotated[str, Form()] = "",
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
List folders in a Dropbox account for the directory selector.
|
||||||
|
|
||||||
|
Accepts an OAuth access token (short-lived) and a path to list.
|
||||||
|
Returns a flat list of folder entries under the given path.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Normalize path: Dropbox API uses "" for root, otherwise "/path"
|
||||||
|
folder_path = path.strip()
|
||||||
|
if folder_path == "/":
|
||||||
|
folder_path = ""
|
||||||
|
elif folder_path and not folder_path.startswith("/"):
|
||||||
|
folder_path = f"/{folder_path}"
|
||||||
|
|
||||||
|
headers = {
|
||||||
|
"Authorization": f"Bearer {access_token}",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
}
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"path": folder_path,
|
||||||
|
"recursive": False,
|
||||||
|
"include_deleted": False,
|
||||||
|
"include_has_explicit_shared_members": False,
|
||||||
|
"include_mounted_folders": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
response = requests.post(
|
||||||
|
"https://api.dropboxapi.com/2/files/list_folder",
|
||||||
|
headers=headers,
|
||||||
|
json=payload,
|
||||||
|
timeout=settings.http_request_timeout,
|
||||||
|
)
|
||||||
|
|
||||||
|
if response.status_code == 401:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="Access token is invalid or expired. Please re-authorize.",
|
||||||
|
)
|
||||||
|
|
||||||
|
if response.status_code != 200:
|
||||||
|
logger.error(f"Dropbox list_folder failed: {response.status_code} {response.text}")
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||||
|
detail=f"Failed to list Dropbox folders: {response.text}",
|
||||||
|
)
|
||||||
|
|
||||||
|
data = response.json()
|
||||||
|
folders = []
|
||||||
|
for entry in data.get("entries", []):
|
||||||
|
if entry.get(".tag") == "folder":
|
||||||
|
folders.append(
|
||||||
|
{
|
||||||
|
"name": entry["name"],
|
||||||
|
"path": entry["path_display"],
|
||||||
|
"id": entry.get("id", ""),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Sort folders alphabetically
|
||||||
|
folders.sort(key=lambda f: f["name"].lower())
|
||||||
|
|
||||||
|
return {
|
||||||
|
"folders": folders,
|
||||||
|
"path": folder_path or "/",
|
||||||
|
"has_more": data.get("has_more", False),
|
||||||
|
}
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception(f"Error listing Dropbox folders: {e}")
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail=f"Failed to list folders: {str(e)}",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/dropbox/save-settings")
|
@router.post("/dropbox/save-settings")
|
||||||
@require_login
|
@require_login
|
||||||
async def save_dropbox_settings(
|
async def save_dropbox_settings(
|
||||||
|
|||||||
+29
-1
@@ -30,7 +30,7 @@ from app.utils.file_queries import apply_status_filter
|
|||||||
from app.utils.file_status import get_files_processing_status
|
from app.utils.file_status import get_files_processing_status
|
||||||
from app.utils.filename_utils import sanitize_filename
|
from app.utils.filename_utils import sanitize_filename
|
||||||
from app.utils.input_validation import validate_search_query, validate_sort_field, validate_sort_order
|
from app.utils.input_validation import validate_search_query, validate_sort_field, validate_sort_order
|
||||||
from app.utils.user_scope import apply_owner_filter, get_current_owner_id
|
from app.utils.user_scope import apply_owner_filter, get_current_owner_id, get_file_role
|
||||||
|
|
||||||
# Set up logging
|
# Set up logging
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -300,6 +300,7 @@ def delete_file_record(request: Request, file_id: int, db: DbSession):
|
|||||||
"""
|
"""
|
||||||
Delete a file record from the database.
|
Delete a file record from the database.
|
||||||
This only removes the database entry, not the actual file.
|
This only removes the database entry, not the actual file.
|
||||||
|
Only the file owner (or an admin) may delete a document.
|
||||||
"""
|
"""
|
||||||
# Check if file deletion is allowed
|
# Check if file deletion is allowed
|
||||||
if not settings.allow_file_delete:
|
if not settings.allow_file_delete:
|
||||||
@@ -314,6 +315,18 @@ def delete_file_record(request: Request, file_id: int, db: DbSession):
|
|||||||
if not file_record:
|
if not file_record:
|
||||||
raise HTTPException(status_code=404, detail=f"File record with ID {file_id} not found")
|
raise HTTPException(status_code=404, detail=f"File record with ID {file_id} not found")
|
||||||
|
|
||||||
|
# Enforce owner-only deletion in multi-user mode
|
||||||
|
user = request.session.get("user")
|
||||||
|
is_admin = isinstance(user, dict) and bool(user.get("is_admin"))
|
||||||
|
if not is_admin:
|
||||||
|
owner_id = get_current_owner_id(request)
|
||||||
|
role = get_file_role(file_record, owner_id, db)
|
||||||
|
if role != "owner":
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=403,
|
||||||
|
detail="Only the file owner can delete this document",
|
||||||
|
)
|
||||||
|
|
||||||
# Log the deletion
|
# Log the deletion
|
||||||
logger.info(f"Deleting file record: ID={file_id}, Filename={file_record.original_filename}")
|
logger.info(f"Deleting file record: ID={file_id}, Filename={file_record.original_filename}")
|
||||||
|
|
||||||
@@ -340,6 +353,7 @@ def bulk_delete_files(request: Request, file_ids: List[int], db: DbSession):
|
|||||||
"""
|
"""
|
||||||
Delete multiple file records from the database.
|
Delete multiple file records from the database.
|
||||||
This only removes the database entries, not the actual files.
|
This only removes the database entries, not the actual files.
|
||||||
|
Only the file owner (or an admin) may delete each document.
|
||||||
"""
|
"""
|
||||||
# Check if file deletion is allowed
|
# Check if file deletion is allowed
|
||||||
if not settings.allow_file_delete:
|
if not settings.allow_file_delete:
|
||||||
@@ -354,6 +368,18 @@ def bulk_delete_files(request: Request, file_ids: List[int], db: DbSession):
|
|||||||
if not file_records:
|
if not file_records:
|
||||||
raise HTTPException(status_code=404, detail="No files found with the provided IDs")
|
raise HTTPException(status_code=404, detail="No files found with the provided IDs")
|
||||||
|
|
||||||
|
# Enforce owner-only deletion in multi-user mode
|
||||||
|
user = request.session.get("user")
|
||||||
|
is_admin = isinstance(user, dict) and bool(user.get("is_admin"))
|
||||||
|
if not is_admin:
|
||||||
|
owner_id = get_current_owner_id(request)
|
||||||
|
non_owner_ids = [f.id for f in file_records if get_file_role(f, owner_id, db) != "owner"]
|
||||||
|
if non_owner_ids:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=403,
|
||||||
|
detail=f"You can only delete files you own. Not owner of file IDs: {non_owner_ids}",
|
||||||
|
)
|
||||||
|
|
||||||
deleted_count = len(file_records)
|
deleted_count = len(file_records)
|
||||||
deleted_ids = [f.id for f in file_records]
|
deleted_ids = [f.id for f in file_records]
|
||||||
|
|
||||||
@@ -1477,6 +1503,8 @@ async def ui_upload(
|
|||||||
".tif",
|
".tif",
|
||||||
".webp",
|
".webp",
|
||||||
".svg",
|
".svg",
|
||||||
|
".heic",
|
||||||
|
".heif",
|
||||||
}:
|
}:
|
||||||
# If it's an image, convert to PDF first
|
# If it's an image, convert to PDF first
|
||||||
task = convert_to_pdf.delay(target_path, original_filename=safe_filename, owner_id=upload_owner_id)
|
task = convert_to_pdf.delay(target_path, original_filename=safe_filename, owner_id=upload_owner_id)
|
||||||
|
|||||||
+8
-11
@@ -608,7 +608,7 @@ def _test_dropbox_connection(config: dict[str, Any] | None, credentials: dict[st
|
|||||||
|
|
||||||
def _test_webdav_connection(config: dict[str, Any] | None, credentials: dict[str, Any] | None) -> dict[str, Any]:
|
def _test_webdav_connection(config: dict[str, Any] | None, credentials: dict[str, Any] | None) -> dict[str, Any]:
|
||||||
"""Test a WebDAV/Nextcloud connection by issuing an HTTP PROPFIND."""
|
"""Test a WebDAV/Nextcloud connection by issuing an HTTP PROPFIND."""
|
||||||
import urllib.request
|
import httpx
|
||||||
|
|
||||||
cfg = config or {}
|
cfg = config or {}
|
||||||
creds = credentials or {}
|
creds = credentials or {}
|
||||||
@@ -635,17 +635,14 @@ def _test_webdav_connection(config: dict[str, Any] | None, credentials: dict[str
|
|||||||
return {"success": False, "message": "URLs pointing to internal or private networks are not allowed"}
|
return {"success": False, "message": "URLs pointing to internal or private networks are not allowed"}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
import base64
|
auth = (username, password) if username and password else None
|
||||||
|
headers = {"Depth": "0"}
|
||||||
|
|
||||||
req = urllib.request.Request(url, method="PROPFIND") # noqa: S310
|
# Use httpx for secure connection testing, avoiding urllib vulnerabilities
|
||||||
if username and password:
|
resp = httpx.request("PROPFIND", url, auth=auth, headers=headers, timeout=10.0, follow_redirects=False)
|
||||||
token = base64.b64encode(f"{username}:{password}".encode()).decode()
|
if resp.status_code < 400:
|
||||||
req.add_header("Authorization", f"Basic {token}")
|
return {"success": True, "message": "WebDAV connection successful"}
|
||||||
req.add_header("Depth", "0")
|
return {"success": False, "message": f"WebDAV returned HTTP {resp.status_code}"}
|
||||||
with urllib.request.urlopen(req, timeout=10) as resp: # noqa: S310
|
|
||||||
if resp.status < 400:
|
|
||||||
return {"success": True, "message": "WebDAV connection successful"}
|
|
||||||
return {"success": False, "message": f"WebDAV returned HTTP {resp.status}"}
|
|
||||||
except Exception as exc: # noqa: BLE001
|
except Exception as exc: # noqa: BLE001
|
||||||
logger.warning("WebDAV connection error for %s: %s", hostname, exc)
|
logger.warning("WebDAV connection error for %s: %s", hostname, exc)
|
||||||
return {"success": False, "message": "WebDAV connection failed — check URL and credentials"}
|
return {"success": False, "message": "WebDAV connection failed — check URL and credentials"}
|
||||||
|
|||||||
@@ -101,9 +101,9 @@ async def signup_page(request: Request) -> Any:
|
|||||||
if not settings.allow_local_signup:
|
if not settings.allow_local_signup:
|
||||||
return RedirectResponse(url="/login?error=Registration+is+not+enabled", status_code=302)
|
return RedirectResponse(url="/login?error=Registration+is+not+enabled", status_code=302)
|
||||||
return templates.TemplateResponse(
|
return templates.TemplateResponse(
|
||||||
|
request,
|
||||||
"signup.html",
|
"signup.html",
|
||||||
{
|
context={
|
||||||
"request": request,
|
|
||||||
"csrf_token": getattr(request.state, "csrf_token", ""),
|
"csrf_token": getattr(request.state, "csrf_token", ""),
|
||||||
"app_version": settings.version,
|
"app_version": settings.version,
|
||||||
},
|
},
|
||||||
@@ -113,16 +113,16 @@ async def signup_page(request: Request) -> Any:
|
|||||||
@router.get("/verify-email-sent", include_in_schema=False)
|
@router.get("/verify-email-sent", include_in_schema=False)
|
||||||
async def verify_email_sent_page(request: Request) -> Any:
|
async def verify_email_sent_page(request: Request) -> Any:
|
||||||
"""Render the verify-email-sent confirmation page."""
|
"""Render the verify-email-sent confirmation page."""
|
||||||
return templates.TemplateResponse("verify_email_sent.html", {"request": request})
|
return templates.TemplateResponse(request, "verify_email_sent.html")
|
||||||
|
|
||||||
|
|
||||||
@router.get("/forgot-username", include_in_schema=False)
|
@router.get("/forgot-username", include_in_schema=False)
|
||||||
async def forgot_username_page(request: Request) -> Any:
|
async def forgot_username_page(request: Request) -> Any:
|
||||||
"""Render the forgot-username page where users can request a username reminder email."""
|
"""Render the forgot-username page where users can request a username reminder email."""
|
||||||
return templates.TemplateResponse(
|
return templates.TemplateResponse(
|
||||||
|
request,
|
||||||
"forgot_username.html",
|
"forgot_username.html",
|
||||||
{
|
context={
|
||||||
"request": request,
|
|
||||||
"csrf_token": getattr(request.state, "csrf_token", ""),
|
"csrf_token": getattr(request.state, "csrf_token", ""),
|
||||||
"app_version": settings.version,
|
"app_version": settings.version,
|
||||||
},
|
},
|
||||||
@@ -133,9 +133,9 @@ async def forgot_username_page(request: Request) -> Any:
|
|||||||
async def forgot_password_page(request: Request) -> Any:
|
async def forgot_password_page(request: Request) -> Any:
|
||||||
"""Render the forgot-password page where users can request a reset email."""
|
"""Render the forgot-password page where users can request a reset email."""
|
||||||
return templates.TemplateResponse(
|
return templates.TemplateResponse(
|
||||||
|
request,
|
||||||
"forgot_password.html",
|
"forgot_password.html",
|
||||||
{
|
context={
|
||||||
"request": request,
|
|
||||||
"csrf_token": getattr(request.state, "csrf_token", ""),
|
"csrf_token": getattr(request.state, "csrf_token", ""),
|
||||||
"app_version": settings.version,
|
"app_version": settings.version,
|
||||||
},
|
},
|
||||||
@@ -147,9 +147,9 @@ async def reset_password_page(request: Request) -> Any:
|
|||||||
"""Render the password reset form page."""
|
"""Render the password reset form page."""
|
||||||
token = request.query_params.get("token", "")
|
token = request.query_params.get("token", "")
|
||||||
return templates.TemplateResponse(
|
return templates.TemplateResponse(
|
||||||
|
request,
|
||||||
"password_reset_form.html",
|
"password_reset_form.html",
|
||||||
{
|
context={
|
||||||
"request": request,
|
|
||||||
"token": token,
|
"token": token,
|
||||||
"csrf_token": getattr(request.state, "csrf_token", ""),
|
"csrf_token": getattr(request.state, "csrf_token", ""),
|
||||||
"app_version": settings.version,
|
"app_version": settings.version,
|
||||||
|
|||||||
@@ -120,6 +120,7 @@ class WhoAmIResponse(BaseModel):
|
|||||||
email: str | None
|
email: str | None
|
||||||
avatar_url: str | None
|
avatar_url: str | None
|
||||||
is_admin: bool
|
is_admin: bool
|
||||||
|
preferred_language: str | None
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -357,4 +358,5 @@ async def whoami(
|
|||||||
"email": email,
|
"email": email,
|
||||||
"avatar_url": avatar_url,
|
"avatar_url": avatar_url,
|
||||||
"is_admin": is_admin,
|
"is_admin": is_admin,
|
||||||
|
"preferred_language": profile.preferred_language if profile else None,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ from datetime import datetime, timedelta
|
|||||||
from typing import Annotated, Optional
|
from typing import Annotated, Optional
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
import requests
|
||||||
from fastapi import APIRouter, Depends, Form, HTTPException, Request, status
|
from fastapi import APIRouter, Depends, Form, HTTPException, Request, status
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
@@ -56,6 +57,7 @@ async def exchange_onedrive_token(
|
|||||||
# Return just what's needed by the frontend
|
# Return just what's needed by the frontend
|
||||||
return {
|
return {
|
||||||
"refresh_token": token_data["refresh_token"],
|
"refresh_token": token_data["refresh_token"],
|
||||||
|
"access_token": token_data.get("access_token", ""),
|
||||||
"expires_in": token_data.get("expires_in", 3600),
|
"expires_in": token_data.get("expires_in", 3600),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -183,6 +185,102 @@ async def test_onedrive_token(request: Request):
|
|||||||
return {"status": "error", "message": f"Connection error: {str(e)}"}
|
return {"status": "error", "message": f"Connection error: {str(e)}"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/onedrive/list-folders")
|
||||||
|
@require_login
|
||||||
|
async def list_onedrive_folders(
|
||||||
|
request: Request,
|
||||||
|
access_token: Annotated[str, Form(...)],
|
||||||
|
path: Annotated[str, Form()] = "",
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
List folders in a OneDrive account for the directory selector.
|
||||||
|
|
||||||
|
Accepts an OAuth access token (short-lived) and a path to list.
|
||||||
|
Returns a flat list of folder entries under the given path.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
folder_path = path.strip().strip("/")
|
||||||
|
|
||||||
|
headers = {
|
||||||
|
"Authorization": f"Bearer {access_token}",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Build the Graph API URL for listing children
|
||||||
|
if not folder_path or folder_path == "root":
|
||||||
|
url = "https://graph.microsoft.com/v1.0/me/drive/root/children"
|
||||||
|
else:
|
||||||
|
url = f"https://graph.microsoft.com/v1.0/me/drive/root:/{folder_path}:/children"
|
||||||
|
|
||||||
|
# Only request folders and minimal fields
|
||||||
|
params = {
|
||||||
|
"$filter": "folder ne null",
|
||||||
|
"$select": "name,id,parentReference,folder",
|
||||||
|
"$top": "200",
|
||||||
|
}
|
||||||
|
|
||||||
|
response = requests.get(
|
||||||
|
url,
|
||||||
|
headers=headers,
|
||||||
|
params=params,
|
||||||
|
timeout=settings.http_request_timeout,
|
||||||
|
)
|
||||||
|
|
||||||
|
if response.status_code == 401:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="Access token is invalid or expired. Please re-authorize.",
|
||||||
|
)
|
||||||
|
|
||||||
|
if response.status_code != 200:
|
||||||
|
logger.error(f"OneDrive list children failed: {response.status_code} {response.text}")
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||||
|
detail=f"Failed to list OneDrive folders: {response.text}",
|
||||||
|
)
|
||||||
|
|
||||||
|
data = response.json()
|
||||||
|
folders = []
|
||||||
|
for item in data.get("value", []):
|
||||||
|
if "folder" in item:
|
||||||
|
parent_path = ""
|
||||||
|
if item.get("parentReference", {}).get("path"):
|
||||||
|
# parentReference.path looks like /drive/root:/some/path
|
||||||
|
raw_parent = item["parentReference"]["path"]
|
||||||
|
prefix = "/drive/root:"
|
||||||
|
if raw_parent.startswith(prefix):
|
||||||
|
parent_path = raw_parent[len(prefix) :]
|
||||||
|
elif raw_parent == "/drive/root":
|
||||||
|
parent_path = ""
|
||||||
|
|
||||||
|
item_path = f"{parent_path}/{item['name']}" if parent_path else f"/{item['name']}"
|
||||||
|
|
||||||
|
folders.append(
|
||||||
|
{
|
||||||
|
"name": item["name"],
|
||||||
|
"path": item_path,
|
||||||
|
"id": item.get("id", ""),
|
||||||
|
"child_count": item.get("folder", {}).get("childCount", 0),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Sort folders alphabetically
|
||||||
|
folders.sort(key=lambda f: f["name"].lower())
|
||||||
|
|
||||||
|
return {
|
||||||
|
"folders": folders,
|
||||||
|
"path": f"/{folder_path}" if folder_path else "/",
|
||||||
|
}
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception(f"Error listing OneDrive folders: {e}")
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail=f"Failed to list folders: {str(e)}",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def format_time_remaining(time_delta):
|
def format_time_remaining(time_delta):
|
||||||
"""Format a timedelta into a human-readable string."""
|
"""Format a timedelta into a human-readable string."""
|
||||||
if time_delta.total_seconds() <= 0:
|
if time_delta.total_seconds() <= 0:
|
||||||
|
|||||||
@@ -117,8 +117,14 @@ PIPELINE_STEP_TYPES: dict[str, dict[str, Any]] = {
|
|||||||
},
|
},
|
||||||
"classify": {
|
"classify": {
|
||||||
"label": "Document Classification",
|
"label": "Document Classification",
|
||||||
"description": "Classify the document type using AI without full metadata extraction.",
|
"description": "Classify the document type using built-in and custom rules (filename patterns, content keywords, metadata matching).",
|
||||||
"config_schema": {},
|
"config_schema": {
|
||||||
|
"use_builtin_rules": {
|
||||||
|
"type": "boolean",
|
||||||
|
"default": True,
|
||||||
|
"description": "Include the pre-built classification rules (invoice, contract, receipt, etc.).",
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ from pydantic import BaseModel, Field
|
|||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.auth import require_login
|
from app.auth import require_login
|
||||||
|
from app.config import settings
|
||||||
from app.database import get_db
|
from app.database import get_db
|
||||||
from app.middleware.audit_log import get_client_ip
|
from app.middleware.audit_log import get_client_ip
|
||||||
from app.utils.session_manager import (
|
from app.utils.session_manager import (
|
||||||
@@ -151,6 +152,11 @@ async def create_challenge(
|
|||||||
displayed to the user. The mobile app scans this QR code and
|
displayed to the user. The mobile app scans this QR code and
|
||||||
calls the ``/claim`` endpoint.
|
calls the ``/claim`` endpoint.
|
||||||
"""
|
"""
|
||||||
|
if not settings.qr_login_enabled:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||||
|
detail="QR login feature is currently disabled. Please contact your administrator to enable it.",
|
||||||
|
)
|
||||||
ip = get_client_ip(request)
|
ip = get_client_ip(request)
|
||||||
challenge = create_qr_challenge(db, owner_id, ip_address=ip)
|
challenge = create_qr_challenge(db, owner_id, ip_address=ip)
|
||||||
|
|
||||||
@@ -187,6 +193,11 @@ async def poll_challenge_status(
|
|||||||
The web UI calls this endpoint every few seconds to check if the
|
The web UI calls this endpoint every few seconds to check if the
|
||||||
mobile app has scanned the QR code and claimed the challenge.
|
mobile app has scanned the QR code and claimed the challenge.
|
||||||
"""
|
"""
|
||||||
|
if not settings.qr_login_enabled:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||||
|
detail="QR login feature is currently disabled. Please contact your administrator to enable it.",
|
||||||
|
)
|
||||||
result = get_challenge_status(db, challenge_id, owner_id)
|
result = get_challenge_status(db, challenge_id, owner_id)
|
||||||
if not result:
|
if not result:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Challenge not found")
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Challenge not found")
|
||||||
@@ -206,6 +217,11 @@ async def claim_challenge(
|
|||||||
serves as proof that the user authorized this login from their web
|
serves as proof that the user authorized this login from their web
|
||||||
session.
|
session.
|
||||||
"""
|
"""
|
||||||
|
if not settings.qr_login_enabled:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||||
|
detail="QR login feature is currently disabled. Please contact your administrator to enable it.",
|
||||||
|
)
|
||||||
ip = get_client_ip(request)
|
ip = get_client_ip(request)
|
||||||
result = claim_qr_challenge(db, body.challenge_token, device_name=body.device_name, ip_address=ip)
|
result = claim_qr_challenge(db, body.challenge_token, device_name=body.device_name, ip_address=ip)
|
||||||
|
|
||||||
|
|||||||
@@ -55,6 +55,12 @@ class SettingUpdate(BaseModel):
|
|||||||
value: Optional[str] = Field(None, description="Setting value (None to delete)")
|
value: Optional[str] = Field(None, description="Setting value (None to delete)")
|
||||||
|
|
||||||
|
|
||||||
|
class SettingValueUpdate(BaseModel):
|
||||||
|
"""Model for updating a setting value by key (key is provided in the URL path)."""
|
||||||
|
|
||||||
|
value: Optional[str] = Field(None, description="Setting value (None to delete)")
|
||||||
|
|
||||||
|
|
||||||
class SettingResponse(BaseModel):
|
class SettingResponse(BaseModel):
|
||||||
"""Model for setting response"""
|
"""Model for setting response"""
|
||||||
|
|
||||||
@@ -323,6 +329,62 @@ async def update_setting(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{key}")
|
||||||
|
async def put_setting(
|
||||||
|
key: str,
|
||||||
|
body: SettingValueUpdate,
|
||||||
|
request: Request,
|
||||||
|
db: DbSession,
|
||||||
|
admin: AdminUser,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Update a specific setting by key (RESTful PUT).
|
||||||
|
|
||||||
|
Accepts a body with only ``value``; the key is taken from the URL path.
|
||||||
|
This is the endpoint used by the admin Connections wizard.
|
||||||
|
Admin only.
|
||||||
|
"""
|
||||||
|
validate_setting_key(key)
|
||||||
|
try:
|
||||||
|
if body.value is not None:
|
||||||
|
is_valid, error_message = validate_setting_value(key, body.value)
|
||||||
|
if not is_valid:
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=error_message)
|
||||||
|
|
||||||
|
user = request.session.get("user", {}) if hasattr(request, "session") else {}
|
||||||
|
changed_by = (
|
||||||
|
user.get("preferred_username") or user.get("username") or user.get("email") or user.get("id") or "admin"
|
||||||
|
)
|
||||||
|
|
||||||
|
success = save_setting_to_db(db, key, body.value, changed_by=changed_by)
|
||||||
|
if not success:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail="Failed to save setting to database",
|
||||||
|
)
|
||||||
|
|
||||||
|
notify_settings_updated()
|
||||||
|
|
||||||
|
metadata = get_setting_metadata(key)
|
||||||
|
restart_required = metadata.get("restart_required", False)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"message": f"Setting '{key}' updated successfully",
|
||||||
|
"restart_required": restart_required,
|
||||||
|
"key": key,
|
||||||
|
"value": body.value,
|
||||||
|
}
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error updating setting {key}: {e}")
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail=f"Failed to update setting: {key}",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{key}")
|
@router.delete("/{key}")
|
||||||
async def delete_setting(key: str, request: Request, db: DbSession, admin: AdminUser):
|
async def delete_setting(key: str, request: Request, db: DbSession, admin: AdminUser):
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -0,0 +1,355 @@
|
|||||||
|
"""File-sharing API endpoints.
|
||||||
|
|
||||||
|
Provides CRUD operations for ``FileShare`` records, which grant named
|
||||||
|
users ``viewer`` or ``editor`` access to a document owned by someone
|
||||||
|
else. Only the file owner may create, update, or revoke shares.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import Annotated, Any
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Body, Depends, HTTPException, Request, status
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.auth import require_login
|
||||||
|
from app.database import get_db
|
||||||
|
from app.models import FILE_SHARE_ROLE_VIEWER, FILE_SHARE_ROLES, FileRecord, FileShare, UserProfile
|
||||||
|
from app.utils.user_scope import get_current_owner_id, get_file_role
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
router = APIRouter(tags=["sharing"])
|
||||||
|
|
||||||
|
DbSession = Annotated[Session, Depends(get_db)]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _serialize_share(share: FileShare) -> dict[str, Any]:
|
||||||
|
"""Serialize a ``FileShare`` to a JSON-friendly dict."""
|
||||||
|
return {
|
||||||
|
"id": share.id,
|
||||||
|
"file_id": share.file_id,
|
||||||
|
"owner_id": share.owner_id,
|
||||||
|
"shared_with_user_id": share.shared_with_user_id,
|
||||||
|
"role": share.role,
|
||||||
|
"created_at": share.created_at.isoformat() if share.created_at else None,
|
||||||
|
"updated_at": share.updated_at.isoformat() if share.updated_at else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _require_owner(file_record: FileRecord, user_id: str | None, db: Session) -> None:
|
||||||
|
"""Raise 403 unless the calling user is the file owner."""
|
||||||
|
if get_file_role(file_record, user_id, db) != "owner":
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="Only the file owner can manage shares",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# List shares
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/files/{file_id}/shares")
|
||||||
|
@require_login
|
||||||
|
def list_shares(request: Request, file_id: int, db: DbSession):
|
||||||
|
"""List all shares for a document.
|
||||||
|
|
||||||
|
Only the file owner (or an admin) may call this endpoint.
|
||||||
|
|
||||||
|
Path Parameters:
|
||||||
|
file_id: The ID of the document.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A list of share objects.
|
||||||
|
"""
|
||||||
|
user_id = get_current_owner_id(request)
|
||||||
|
user = request.session.get("user")
|
||||||
|
is_admin = isinstance(user, dict) and bool(user.get("is_admin"))
|
||||||
|
|
||||||
|
file_record = db.query(FileRecord).filter(FileRecord.id == file_id).first()
|
||||||
|
if not file_record:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="File not found")
|
||||||
|
|
||||||
|
role = get_file_role(file_record, user_id, db)
|
||||||
|
if role is None:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="File not found")
|
||||||
|
|
||||||
|
if role != "owner" and not is_admin:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="Only the file owner can view shares",
|
||||||
|
)
|
||||||
|
|
||||||
|
shares = db.query(FileShare).filter(FileShare.file_id == file_id).all()
|
||||||
|
return [_serialize_share(s) for s in shares]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Create share
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/files/{file_id}/shares", status_code=status.HTTP_201_CREATED)
|
||||||
|
@require_login
|
||||||
|
def create_share(
|
||||||
|
request: Request,
|
||||||
|
file_id: int,
|
||||||
|
db: DbSession,
|
||||||
|
shared_with_user_id: str = Body(..., embed=True),
|
||||||
|
role: str = Body(FILE_SHARE_ROLE_VIEWER, embed=True),
|
||||||
|
):
|
||||||
|
"""Share a document with another user.
|
||||||
|
|
||||||
|
Only the file owner may share the document. Sharing with a user
|
||||||
|
that already has access updates their role instead of creating a
|
||||||
|
duplicate record.
|
||||||
|
|
||||||
|
Path Parameters:
|
||||||
|
file_id: The ID of the document to share.
|
||||||
|
|
||||||
|
Request body (JSON):
|
||||||
|
shared_with_user_id: The stable user identifier of the recipient.
|
||||||
|
role: ``"viewer"`` (default) or ``"editor"``.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The created or updated share object.
|
||||||
|
"""
|
||||||
|
owner_id = get_current_owner_id(request)
|
||||||
|
|
||||||
|
file_record = db.query(FileRecord).filter(FileRecord.id == file_id).first()
|
||||||
|
if not file_record:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="File not found")
|
||||||
|
|
||||||
|
_require_owner(file_record, owner_id, db)
|
||||||
|
|
||||||
|
if role not in FILE_SHARE_ROLES:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||||
|
detail=f"role must be one of: {', '.join(FILE_SHARE_ROLES)}",
|
||||||
|
)
|
||||||
|
|
||||||
|
if not shared_with_user_id or not shared_with_user_id.strip():
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||||
|
detail="shared_with_user_id must be a non-empty string",
|
||||||
|
)
|
||||||
|
shared_with_user_id = shared_with_user_id.strip()
|
||||||
|
|
||||||
|
# Cannot share with yourself
|
||||||
|
if shared_with_user_id == owner_id:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||||
|
detail="You cannot share a file with yourself",
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
existing = (
|
||||||
|
db.query(FileShare)
|
||||||
|
.filter(FileShare.file_id == file_id, FileShare.shared_with_user_id == shared_with_user_id)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
|
||||||
|
if existing:
|
||||||
|
# Update role if different
|
||||||
|
if existing.role != role:
|
||||||
|
existing.role = role
|
||||||
|
db.commit()
|
||||||
|
db.refresh(existing)
|
||||||
|
logger.info(
|
||||||
|
"Share updated: file_id=%s, shared_with=%s, role=%s, by owner=%s",
|
||||||
|
file_id,
|
||||||
|
shared_with_user_id,
|
||||||
|
role,
|
||||||
|
owner_id,
|
||||||
|
)
|
||||||
|
return _serialize_share(existing)
|
||||||
|
|
||||||
|
share = FileShare(
|
||||||
|
file_id=file_id,
|
||||||
|
owner_id=owner_id,
|
||||||
|
shared_with_user_id=shared_with_user_id,
|
||||||
|
role=role,
|
||||||
|
)
|
||||||
|
db.add(share)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(share)
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception:
|
||||||
|
db.rollback()
|
||||||
|
logger.exception("Failed to create share: file_id=%s, shared_with=%s", file_id, shared_with_user_id)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail="Failed to create share",
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"Share created: id=%s, file_id=%s, shared_with=%s, role=%s, by owner=%s",
|
||||||
|
share.id,
|
||||||
|
file_id,
|
||||||
|
shared_with_user_id,
|
||||||
|
role,
|
||||||
|
owner_id,
|
||||||
|
)
|
||||||
|
return _serialize_share(share)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Update share role
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/files/{file_id}/shares/{share_id}")
|
||||||
|
@require_login
|
||||||
|
def update_share(
|
||||||
|
request: Request,
|
||||||
|
file_id: int,
|
||||||
|
share_id: int,
|
||||||
|
db: DbSession,
|
||||||
|
role: str = Body(..., embed=True),
|
||||||
|
):
|
||||||
|
"""Update the role of an existing share.
|
||||||
|
|
||||||
|
Only the file owner may change the role of a share.
|
||||||
|
|
||||||
|
Path Parameters:
|
||||||
|
file_id: The ID of the document.
|
||||||
|
share_id: The ID of the share record to update.
|
||||||
|
|
||||||
|
Request body (JSON):
|
||||||
|
role: New role — ``"viewer"`` or ``"editor"``.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The updated share object.
|
||||||
|
"""
|
||||||
|
owner_id = get_current_owner_id(request)
|
||||||
|
|
||||||
|
file_record = db.query(FileRecord).filter(FileRecord.id == file_id).first()
|
||||||
|
if not file_record:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="File not found")
|
||||||
|
|
||||||
|
_require_owner(file_record, owner_id, db)
|
||||||
|
|
||||||
|
if role not in FILE_SHARE_ROLES:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||||
|
detail=f"role must be one of: {', '.join(FILE_SHARE_ROLES)}",
|
||||||
|
)
|
||||||
|
|
||||||
|
share = db.query(FileShare).filter(FileShare.id == share_id, FileShare.file_id == file_id).first()
|
||||||
|
if not share:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Share not found")
|
||||||
|
|
||||||
|
try:
|
||||||
|
share.role = role
|
||||||
|
db.commit()
|
||||||
|
db.refresh(share)
|
||||||
|
except Exception:
|
||||||
|
db.rollback()
|
||||||
|
logger.exception("Failed to update share: share_id=%s", share_id)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail="Failed to update share",
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info("Share updated: id=%s, file_id=%s, new_role=%s, by owner=%s", share_id, file_id, role, owner_id)
|
||||||
|
return _serialize_share(share)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Revoke share
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/files/{file_id}/shares/{share_id}", status_code=status.HTTP_200_OK)
|
||||||
|
@require_login
|
||||||
|
def revoke_share(request: Request, file_id: int, share_id: int, db: DbSession):
|
||||||
|
"""Revoke a share, removing the user's access.
|
||||||
|
|
||||||
|
Only the file owner may revoke shares.
|
||||||
|
|
||||||
|
Path Parameters:
|
||||||
|
file_id: The ID of the document.
|
||||||
|
share_id: The ID of the share record to delete.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A success message.
|
||||||
|
"""
|
||||||
|
owner_id = get_current_owner_id(request)
|
||||||
|
|
||||||
|
file_record = db.query(FileRecord).filter(FileRecord.id == file_id).first()
|
||||||
|
if not file_record:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="File not found")
|
||||||
|
|
||||||
|
_require_owner(file_record, owner_id, db)
|
||||||
|
|
||||||
|
share = db.query(FileShare).filter(FileShare.id == share_id, FileShare.file_id == file_id).first()
|
||||||
|
if not share:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Share not found")
|
||||||
|
|
||||||
|
try:
|
||||||
|
db.delete(share)
|
||||||
|
db.commit()
|
||||||
|
except Exception:
|
||||||
|
db.rollback()
|
||||||
|
logger.exception("Failed to revoke share: share_id=%s", share_id)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail="Failed to revoke share",
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info("Share revoked: id=%s, file_id=%s, by owner=%s", share_id, file_id, owner_id)
|
||||||
|
return {"status": "success", "message": "Share revoked successfully"}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# List users that the file is already shared with (for the share-picker UI)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/files/{file_id}/shared-with")
|
||||||
|
@require_login
|
||||||
|
def list_shared_with(request: Request, file_id: int, db: DbSession):
|
||||||
|
"""Return the list of users a document is shared with and their roles.
|
||||||
|
|
||||||
|
Accessible to any user that has at least viewer access to the file,
|
||||||
|
so that editors/viewers can see who else has access.
|
||||||
|
|
||||||
|
Path Parameters:
|
||||||
|
file_id: The ID of the document.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A list of ``{share_id, user_id, display_name, role}`` objects.
|
||||||
|
"""
|
||||||
|
user_id = get_current_owner_id(request)
|
||||||
|
user = request.session.get("user")
|
||||||
|
is_admin = isinstance(user, dict) and bool(user.get("is_admin"))
|
||||||
|
|
||||||
|
file_record = db.query(FileRecord).filter(FileRecord.id == file_id).first()
|
||||||
|
if not file_record:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="File not found")
|
||||||
|
|
||||||
|
role = get_file_role(file_record, user_id, db)
|
||||||
|
if role is None and not is_admin:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="File not found")
|
||||||
|
|
||||||
|
shares = db.query(FileShare).filter(FileShare.file_id == file_id).all()
|
||||||
|
|
||||||
|
results = []
|
||||||
|
for s in shares:
|
||||||
|
profile = db.query(UserProfile).filter(UserProfile.user_id == s.shared_with_user_id).first()
|
||||||
|
results.append(
|
||||||
|
{
|
||||||
|
"share_id": s.id,
|
||||||
|
"user_id": s.shared_with_user_id,
|
||||||
|
"display_name": (profile.display_name if profile and profile.display_name else s.shared_with_user_id),
|
||||||
|
"role": s.role,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return results
|
||||||
+275
-77
@@ -45,85 +45,264 @@ OAUTH_PROVIDER_NAME = "Single Sign-On"
|
|||||||
# Social login providers that are enabled and registered
|
# Social login providers that are enabled and registered
|
||||||
SOCIAL_PROVIDERS: dict[str, dict[str, str]] = {}
|
SOCIAL_PROVIDERS: dict[str, dict[str, str]] = {}
|
||||||
|
|
||||||
if AUTH_ENABLED and settings.authentik_client_id and settings.authentik_client_secret:
|
|
||||||
oauth.register(
|
|
||||||
name="authentik",
|
|
||||||
client_id=settings.authentik_client_id,
|
|
||||||
client_secret=settings.authentik_client_secret,
|
|
||||||
server_metadata_url=settings.authentik_config_url,
|
|
||||||
client_kwargs={"scope": "openid profile email"},
|
|
||||||
)
|
|
||||||
OAUTH_CONFIGURED = True
|
|
||||||
OAUTH_PROVIDER_NAME = settings.oauth_provider_name or "Authentik SSO"
|
|
||||||
|
|
||||||
# --- Social Login Providers ---------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
if AUTH_ENABLED and settings.social_auth_google_enabled:
|
# Helpers for dynamic (re-)registration of OAuth providers
|
||||||
if settings.social_auth_google_client_id and settings.social_auth_google_client_secret:
|
# ---------------------------------------------------------------------------
|
||||||
oauth.register(
|
|
||||||
name="google",
|
|
||||||
client_id=settings.social_auth_google_client_id,
|
def _register_oauth_client(name: str, **kwargs: object) -> None:
|
||||||
client_secret=settings.social_auth_google_client_secret,
|
"""Register (or re-register) an authlib OAuth client, clearing any cached instance.
|
||||||
server_metadata_url="https://accounts.google.com/.well-known/openid-configuration",
|
|
||||||
|
authlib caches the constructed client object in ``oauth._clients`` after the
|
||||||
|
first ``register()`` call. Subsequent ``register()`` calls overwrite the
|
||||||
|
registry entry but the stale cached client is still returned by
|
||||||
|
``create_client()`` / ``__getattr__``. Popping the name from ``_clients``
|
||||||
|
before re-registering ensures the new credentials are picked up immediately.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name: Provider name (e.g. ``"google"``, ``"github"``).
|
||||||
|
**kwargs: Keyword arguments forwarded verbatim to ``oauth.register()``.
|
||||||
|
"""
|
||||||
|
oauth._clients.pop(name, None)
|
||||||
|
oauth.register(name, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
def _dropbox_userinfo_compliance_fix(client, user_cls, token, data):
|
||||||
|
"""Normalize Dropbox userinfo response for authlib compatibility.
|
||||||
|
|
||||||
|
Dropbox's /2/users/get_current_account returns a non-standard response
|
||||||
|
format. This compliance fix normalizes the response data — the HTTP
|
||||||
|
method (POST) is handled by authlib's compliance infrastructure.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
client: The OAuth client instance (required by authlib compliance fix interface).
|
||||||
|
user_cls: The user class (required by authlib compliance fix interface).
|
||||||
|
token: The OAuth token dict.
|
||||||
|
data: The raw userinfo response dict from Dropbox.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The normalized userinfo dict with ``sub`` and ``name`` fields.
|
||||||
|
"""
|
||||||
|
# Dropbox returns account_id instead of sub
|
||||||
|
if "account_id" in data and "sub" not in data:
|
||||||
|
data["sub"] = data["account_id"]
|
||||||
|
# Normalize name field
|
||||||
|
name_info = data.get("name", {})
|
||||||
|
if isinstance(name_info, dict) and "display_name" in name_info:
|
||||||
|
data["name"] = name_info["display_name"]
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def _setup_social_providers() -> None:
|
||||||
|
"""Register all configured OAuth / social-login providers from current settings.
|
||||||
|
|
||||||
|
This function is **idempotent**: it clears ``SOCIAL_PROVIDERS``,
|
||||||
|
``OAUTH_CONFIGURED``, and ``OAUTH_PROVIDER_NAME`` before rebuilding them,
|
||||||
|
and calls :func:`_register_oauth_client` (which also clears the authlib
|
||||||
|
client cache) so that credential changes in the database are reflected
|
||||||
|
without an application restart.
|
||||||
|
|
||||||
|
Can safely be called multiple times, e.g. after a settings reload.
|
||||||
|
"""
|
||||||
|
global OAUTH_CONFIGURED, OAUTH_PROVIDER_NAME
|
||||||
|
|
||||||
|
SOCIAL_PROVIDERS.clear()
|
||||||
|
OAUTH_CONFIGURED = False
|
||||||
|
OAUTH_PROVIDER_NAME = "Single Sign-On"
|
||||||
|
|
||||||
|
if not AUTH_ENABLED:
|
||||||
|
return
|
||||||
|
|
||||||
|
# --- Authentik / OIDC ---
|
||||||
|
if settings.authentik_client_id and settings.authentik_client_secret:
|
||||||
|
_register_oauth_client(
|
||||||
|
"authentik",
|
||||||
|
client_id=settings.authentik_client_id,
|
||||||
|
client_secret=settings.authentik_client_secret,
|
||||||
|
server_metadata_url=settings.authentik_config_url,
|
||||||
client_kwargs={"scope": "openid profile email"},
|
client_kwargs={"scope": "openid profile email"},
|
||||||
)
|
)
|
||||||
SOCIAL_PROVIDERS["google"] = {"name": "Google", "icon": "fab fa-google", "color": "red"}
|
OAUTH_CONFIGURED = True
|
||||||
logger.info("Social login provider registered: Google")
|
OAUTH_PROVIDER_NAME = settings.oauth_provider_name or "Authentik SSO"
|
||||||
else:
|
|
||||||
logger.warning("SOCIAL_AUTH_GOOGLE_ENABLED=true but client ID/secret not configured")
|
|
||||||
|
|
||||||
if AUTH_ENABLED and settings.social_auth_microsoft_enabled:
|
# --- Social Login Providers ---
|
||||||
if settings.social_auth_microsoft_client_id and settings.social_auth_microsoft_client_secret:
|
|
||||||
tenant = settings.social_auth_microsoft_tenant or "common"
|
|
||||||
oauth.register(
|
|
||||||
name="microsoft",
|
|
||||||
client_id=settings.social_auth_microsoft_client_id,
|
|
||||||
client_secret=settings.social_auth_microsoft_client_secret,
|
|
||||||
server_metadata_url=f"https://login.microsoftonline.com/{tenant}/v2.0/.well-known/openid-configuration",
|
|
||||||
client_kwargs={"scope": "openid profile email"},
|
|
||||||
)
|
|
||||||
SOCIAL_PROVIDERS["microsoft"] = {"name": "Microsoft", "icon": "fab fa-microsoft", "color": "blue"}
|
|
||||||
logger.info("Social login provider registered: Microsoft (tenant=%s)", tenant)
|
|
||||||
else:
|
|
||||||
logger.warning("SOCIAL_AUTH_MICROSOFT_ENABLED=true but client ID/secret not configured")
|
|
||||||
|
|
||||||
if AUTH_ENABLED and settings.social_auth_apple_enabled:
|
# Google
|
||||||
if settings.social_auth_apple_client_id and settings.social_auth_apple_team_id:
|
if settings.social_auth_google_enabled:
|
||||||
oauth.register(
|
_google_client_id = settings.social_auth_google_client_id
|
||||||
name="apple",
|
_google_client_secret = settings.social_auth_google_client_secret
|
||||||
client_id=settings.social_auth_apple_client_id,
|
if settings.social_auth_google_use_global_credentials and not (_google_client_id and _google_client_secret):
|
||||||
server_metadata_url="https://appleid.apple.com/.well-known/openid-configuration",
|
_google_client_id = settings.google_drive_client_id
|
||||||
client_kwargs={
|
_google_client_secret = settings.google_drive_client_secret
|
||||||
"scope": "openid name email",
|
|
||||||
"response_mode": "form_post",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
SOCIAL_PROVIDERS["apple"] = {"name": "Apple", "icon": "fab fa-apple", "color": "gray"}
|
|
||||||
logger.info("Social login provider registered: Apple")
|
|
||||||
else:
|
|
||||||
logger.warning("SOCIAL_AUTH_APPLE_ENABLED=true but client ID/team ID not configured")
|
|
||||||
|
|
||||||
if AUTH_ENABLED and settings.social_auth_dropbox_enabled:
|
if _google_client_id and _google_client_secret:
|
||||||
# Determine which credentials to use for Dropbox social login
|
_register_oauth_client(
|
||||||
_dropbox_client_id = settings.social_auth_dropbox_client_id
|
"google",
|
||||||
_dropbox_client_secret = settings.social_auth_dropbox_client_secret
|
client_id=_google_client_id,
|
||||||
if settings.social_auth_dropbox_use_global_credentials and not _dropbox_client_id:
|
client_secret=_google_client_secret,
|
||||||
_dropbox_client_id = settings.dropbox_app_key
|
server_metadata_url="https://accounts.google.com/.well-known/openid-configuration",
|
||||||
_dropbox_client_secret = settings.dropbox_app_secret
|
client_kwargs={"scope": "openid profile email"},
|
||||||
|
)
|
||||||
|
SOCIAL_PROVIDERS["google"] = {"name": "Google", "icon": "fab fa-google", "color": "red"}
|
||||||
|
logger.info("Social login provider registered: Google")
|
||||||
|
else:
|
||||||
|
logger.warning("SOCIAL_AUTH_GOOGLE_ENABLED=true but client ID/secret not configured")
|
||||||
|
|
||||||
if _dropbox_client_id and _dropbox_client_secret:
|
# Microsoft
|
||||||
oauth.register(
|
if settings.social_auth_microsoft_enabled:
|
||||||
name="dropbox",
|
_microsoft_client_id = settings.social_auth_microsoft_client_id
|
||||||
client_id=_dropbox_client_id,
|
_microsoft_client_secret = settings.social_auth_microsoft_client_secret
|
||||||
client_secret=_dropbox_client_secret,
|
if settings.social_auth_microsoft_use_global_credentials and not (
|
||||||
authorize_url="https://www.dropbox.com/oauth2/authorize",
|
_microsoft_client_id and _microsoft_client_secret
|
||||||
access_token_url="https://api.dropboxapi.com/oauth2/token",
|
):
|
||||||
userinfo_endpoint="https://api.dropboxapi.com/2/users/get_current_account",
|
_microsoft_client_id = settings.onedrive_client_id
|
||||||
client_kwargs={"token_endpoint_auth_method": "client_secret_post"},
|
_microsoft_client_secret = settings.onedrive_client_secret
|
||||||
)
|
|
||||||
SOCIAL_PROVIDERS["dropbox"] = {"name": "Dropbox", "icon": "fab fa-dropbox", "color": "blue"}
|
if _microsoft_client_id and _microsoft_client_secret:
|
||||||
logger.info("Social login provider registered: Dropbox")
|
tenant = settings.social_auth_microsoft_tenant or "common"
|
||||||
else:
|
_register_oauth_client(
|
||||||
logger.warning("SOCIAL_AUTH_DROPBOX_ENABLED=true but client ID/secret not configured")
|
"microsoft",
|
||||||
|
client_id=_microsoft_client_id,
|
||||||
|
client_secret=_microsoft_client_secret,
|
||||||
|
server_metadata_url=f"https://login.microsoftonline.com/{tenant}/v2.0/.well-known/openid-configuration",
|
||||||
|
client_kwargs={"scope": "openid profile email"},
|
||||||
|
)
|
||||||
|
SOCIAL_PROVIDERS["microsoft"] = {"name": "Microsoft", "icon": "fab fa-microsoft", "color": "blue"}
|
||||||
|
logger.info("Social login provider registered: Microsoft (tenant=%s)", tenant)
|
||||||
|
else:
|
||||||
|
logger.warning("SOCIAL_AUTH_MICROSOFT_ENABLED=true but client ID/secret not configured")
|
||||||
|
|
||||||
|
# Apple
|
||||||
|
if settings.social_auth_apple_enabled:
|
||||||
|
if settings.social_auth_apple_client_id and settings.social_auth_apple_team_id:
|
||||||
|
_register_oauth_client(
|
||||||
|
"apple",
|
||||||
|
client_id=settings.social_auth_apple_client_id,
|
||||||
|
server_metadata_url="https://appleid.apple.com/.well-known/openid-configuration",
|
||||||
|
client_kwargs={
|
||||||
|
"scope": "openid name email",
|
||||||
|
"response_mode": "form_post",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
SOCIAL_PROVIDERS["apple"] = {"name": "Apple", "icon": "fab fa-apple", "color": "gray"}
|
||||||
|
logger.info("Social login provider registered: Apple")
|
||||||
|
else:
|
||||||
|
logger.warning("SOCIAL_AUTH_APPLE_ENABLED=true but client ID/team ID not configured")
|
||||||
|
|
||||||
|
# Dropbox
|
||||||
|
if settings.social_auth_dropbox_enabled:
|
||||||
|
_dropbox_client_id = settings.social_auth_dropbox_client_id
|
||||||
|
_dropbox_client_secret = settings.social_auth_dropbox_client_secret
|
||||||
|
if settings.social_auth_dropbox_use_global_credentials and not (_dropbox_client_id and _dropbox_client_secret):
|
||||||
|
_dropbox_client_id = settings.dropbox_app_key
|
||||||
|
_dropbox_client_secret = settings.dropbox_app_secret
|
||||||
|
|
||||||
|
if _dropbox_client_id and _dropbox_client_secret:
|
||||||
|
_register_oauth_client(
|
||||||
|
"dropbox",
|
||||||
|
client_id=_dropbox_client_id,
|
||||||
|
client_secret=_dropbox_client_secret,
|
||||||
|
authorize_url="https://www.dropbox.com/oauth2/authorize",
|
||||||
|
access_token_url="https://api.dropboxapi.com/oauth2/token",
|
||||||
|
userinfo_endpoint="https://api.dropboxapi.com/2/users/get_current_account",
|
||||||
|
userinfo_compliance_fix=_dropbox_userinfo_compliance_fix,
|
||||||
|
client_kwargs={
|
||||||
|
"token_endpoint_auth_method": "client_secret_post",
|
||||||
|
"token_access_type": "offline",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
SOCIAL_PROVIDERS["dropbox"] = {"name": "Dropbox", "icon": "fab fa-dropbox", "color": "blue"}
|
||||||
|
logger.info("Social login provider registered: Dropbox")
|
||||||
|
else:
|
||||||
|
logger.warning("SOCIAL_AUTH_DROPBOX_ENABLED=true but client ID/secret not configured")
|
||||||
|
|
||||||
|
# GitHub
|
||||||
|
if settings.social_auth_github_enabled:
|
||||||
|
if settings.social_auth_github_client_id and settings.social_auth_github_client_secret:
|
||||||
|
_register_oauth_client(
|
||||||
|
"github",
|
||||||
|
client_id=settings.social_auth_github_client_id,
|
||||||
|
client_secret=settings.social_auth_github_client_secret,
|
||||||
|
authorize_url="https://github.com/login/oauth/authorize",
|
||||||
|
access_token_url="https://github.com/login/oauth/access_token",
|
||||||
|
userinfo_endpoint="https://api.github.com/user",
|
||||||
|
client_kwargs={"scope": "read:user user:email"},
|
||||||
|
)
|
||||||
|
SOCIAL_PROVIDERS["github"] = {"name": "GitHub", "icon": "fab fa-github", "color": "gray"}
|
||||||
|
logger.info("Social login provider registered: GitHub")
|
||||||
|
else:
|
||||||
|
logger.warning("SOCIAL_AUTH_GITHUB_ENABLED=true but client ID/secret not configured")
|
||||||
|
|
||||||
|
# Keycloak
|
||||||
|
if settings.social_auth_keycloak_enabled:
|
||||||
|
_kc_server = settings.social_auth_keycloak_server_url
|
||||||
|
_kc_realm = settings.social_auth_keycloak_realm
|
||||||
|
if (
|
||||||
|
settings.social_auth_keycloak_client_id
|
||||||
|
and settings.social_auth_keycloak_client_secret
|
||||||
|
and _kc_server
|
||||||
|
and _kc_realm
|
||||||
|
):
|
||||||
|
_kc_base = f"{_kc_server.rstrip('/')}/realms/{_kc_realm}"
|
||||||
|
_register_oauth_client(
|
||||||
|
"keycloak",
|
||||||
|
client_id=settings.social_auth_keycloak_client_id,
|
||||||
|
client_secret=settings.social_auth_keycloak_client_secret,
|
||||||
|
server_metadata_url=f"{_kc_base}/.well-known/openid-configuration",
|
||||||
|
client_kwargs={"scope": "openid profile email"},
|
||||||
|
)
|
||||||
|
SOCIAL_PROVIDERS["keycloak"] = {"name": "Keycloak", "icon": "fas fa-key", "color": "gray"}
|
||||||
|
logger.info("Social login provider registered: Keycloak (realm=%s)", _kc_realm)
|
||||||
|
else:
|
||||||
|
logger.warning("SOCIAL_AUTH_KEYCLOAK_ENABLED=true but required settings not configured")
|
||||||
|
|
||||||
|
# Generic OAuth2
|
||||||
|
if settings.social_auth_generic_oauth2_enabled:
|
||||||
|
if (
|
||||||
|
settings.social_auth_generic_oauth2_client_id
|
||||||
|
and settings.social_auth_generic_oauth2_client_secret
|
||||||
|
and settings.social_auth_generic_oauth2_authorize_url
|
||||||
|
and settings.social_auth_generic_oauth2_token_url
|
||||||
|
):
|
||||||
|
_register_oauth_client(
|
||||||
|
"generic_oauth2",
|
||||||
|
client_id=settings.social_auth_generic_oauth2_client_id,
|
||||||
|
client_secret=settings.social_auth_generic_oauth2_client_secret,
|
||||||
|
authorize_url=settings.social_auth_generic_oauth2_authorize_url,
|
||||||
|
access_token_url=settings.social_auth_generic_oauth2_token_url,
|
||||||
|
userinfo_endpoint=settings.social_auth_generic_oauth2_userinfo_url,
|
||||||
|
client_kwargs={"scope": settings.social_auth_generic_oauth2_scope},
|
||||||
|
)
|
||||||
|
_generic_name = settings.social_auth_generic_oauth2_name or "OAuth2"
|
||||||
|
SOCIAL_PROVIDERS["generic_oauth2"] = {
|
||||||
|
"name": _generic_name,
|
||||||
|
"icon": "fas fa-sign-in-alt",
|
||||||
|
"color": "indigo",
|
||||||
|
}
|
||||||
|
logger.info("Social login provider registered: Generic OAuth2")
|
||||||
|
else:
|
||||||
|
logger.warning("SOCIAL_AUTH_GENERIC_OAUTH2_ENABLED=true but required settings not configured")
|
||||||
|
|
||||||
|
|
||||||
|
def refresh_social_providers() -> None:
|
||||||
|
"""Re-register all OAuth providers from the *current* settings object.
|
||||||
|
|
||||||
|
Call this after loading or reloading settings from the database so that
|
||||||
|
providers configured (or updated) through the admin UI take effect
|
||||||
|
immediately — **no application restart required**.
|
||||||
|
|
||||||
|
This function is safe to call multiple times and is idempotent.
|
||||||
|
"""
|
||||||
|
logger.info("Refreshing social login provider registrations from current settings")
|
||||||
|
_setup_social_providers()
|
||||||
|
|
||||||
|
|
||||||
|
# Perform the initial registration from environment / default settings at
|
||||||
|
# import time. The lifespan hook and settings_sync will call
|
||||||
|
# refresh_social_providers() again after DB settings are loaded so that
|
||||||
|
# any providers configured only in the database are also active.
|
||||||
|
_setup_social_providers()
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
@@ -348,13 +527,21 @@ async def login(request: Request):
|
|||||||
get_client_ip(request),
|
get_client_ip(request),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
error = request.query_params.get("error")
|
||||||
|
message = request.query_params.get("message")
|
||||||
|
show_oauth = OAUTH_CONFIGURED
|
||||||
|
|
||||||
|
# SSO Auto Login: redirect directly to SSO provider if configured
|
||||||
|
if show_oauth and settings.sso_auto_login is True and not error and not message:
|
||||||
|
return RedirectResponse(url="/oauth-login", status_code=status.HTTP_302_FOUND)
|
||||||
|
|
||||||
return templates.TemplateResponse(
|
return templates.TemplateResponse(
|
||||||
|
request,
|
||||||
"login.html",
|
"login.html",
|
||||||
{
|
context={
|
||||||
"request": request,
|
"error": error,
|
||||||
"error": request.query_params.get("error"),
|
"message": message,
|
||||||
"message": request.query_params.get("message"),
|
"show_oauth": show_oauth,
|
||||||
"show_oauth": OAUTH_CONFIGURED,
|
|
||||||
"oauth_provider_name": OAUTH_PROVIDER_NAME,
|
"oauth_provider_name": OAUTH_PROVIDER_NAME,
|
||||||
"social_providers": SOCIAL_PROVIDERS,
|
"social_providers": SOCIAL_PROVIDERS,
|
||||||
"app_version": settings.version,
|
"app_version": settings.version,
|
||||||
@@ -440,6 +627,17 @@ def _normalize_social_userinfo(provider: str, token: dict, raw_userinfo: dict |
|
|||||||
"picture": userinfo.get("profile_photo_url", ""),
|
"picture": userinfo.get("profile_photo_url", ""),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if provider == "github":
|
||||||
|
# GitHub returns login, id, name, email, avatar_url
|
||||||
|
email = userinfo.get("email", "")
|
||||||
|
return {
|
||||||
|
"sub": str(userinfo.get("id", "")),
|
||||||
|
"email": email,
|
||||||
|
"name": userinfo.get("name", "") or userinfo.get("login", ""),
|
||||||
|
"preferred_username": userinfo.get("login", email),
|
||||||
|
"picture": userinfo.get("avatar_url", ""),
|
||||||
|
}
|
||||||
|
|
||||||
# Standard OIDC providers (Google, Microsoft, Apple)
|
# Standard OIDC providers (Google, Microsoft, Apple)
|
||||||
return {
|
return {
|
||||||
"sub": userinfo.get("sub", ""),
|
"sub": userinfo.get("sub", ""),
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ from app import tasks # noqa: F401 - Imports app/tasks.py so Celery can registe
|
|||||||
# Import the shared Celery instance
|
# Import the shared Celery instance
|
||||||
from app.celery_app import celery
|
from app.celery_app import celery
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
|
from app.tasks.automation_tasks import deliver_automation_hook_task # noqa: F401
|
||||||
from app.tasks.backup_tasks import cleanup_old_backups, create_backup # noqa: F401
|
from app.tasks.backup_tasks import cleanup_old_backups, create_backup # noqa: F401
|
||||||
from app.tasks.batch_tasks import ( # noqa: F401
|
from app.tasks.batch_tasks import ( # noqa: F401
|
||||||
backfill_missing_metadata,
|
backfill_missing_metadata,
|
||||||
@@ -22,6 +23,7 @@ from app.tasks.batch_tasks import ( # noqa: F401
|
|||||||
sync_search_index,
|
sync_search_index,
|
||||||
)
|
)
|
||||||
from app.tasks.check_credentials import check_credentials
|
from app.tasks.check_credentials import check_credentials
|
||||||
|
from app.tasks.classify_document import classify_document_task # noqa: F401
|
||||||
from app.tasks.compute_embedding import backfill_missing_embeddings, compute_document_embedding # noqa: F401
|
from app.tasks.compute_embedding import backfill_missing_embeddings, compute_document_embedding # noqa: F401
|
||||||
from app.tasks.convert_to_pdf import convert_to_pdf # noqa: F401
|
from app.tasks.convert_to_pdf import convert_to_pdf # noqa: F401
|
||||||
from app.tasks.convert_to_pdfa import convert_to_pdfa # noqa: F401
|
from app.tasks.convert_to_pdfa import convert_to_pdfa # noqa: F401
|
||||||
|
|||||||
+118
@@ -193,6 +193,16 @@ class Settings(BaseSettings):
|
|||||||
google_docai_processor_id: Optional[str] = None
|
google_docai_processor_id: Optional[str] = None
|
||||||
google_docai_location: str = "us" # Processor location, e.g. "us" or "eu"
|
google_docai_location: str = "us" # Processor location, e.g. "us" or "eu"
|
||||||
external_hostname: str = "localhost" # Default to localhost
|
external_hostname: str = "localhost" # Default to localhost
|
||||||
|
public_base_url: Optional[str] = Field(
|
||||||
|
default=None,
|
||||||
|
description=(
|
||||||
|
"The full public base URL of the application, including scheme "
|
||||||
|
"(e.g., 'https://docuelevate.example.com'). "
|
||||||
|
"When set, this overrides the auto-detected URL for OAuth redirect URIs. "
|
||||||
|
"This is required when the application is behind a reverse proxy that does "
|
||||||
|
"not forward X-Forwarded-Proto headers correctly."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Document Translation Settings
|
# Document Translation Settings
|
||||||
@@ -234,6 +244,10 @@ class Settings(BaseSettings):
|
|||||||
"Useful for admin-configured non-standard durations."
|
"Useful for admin-configured non-standard durations."
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
qr_login_enabled: bool = Field(
|
||||||
|
default=True,
|
||||||
|
description="Enable QR code-based login for mobile device authentication (default: True).",
|
||||||
|
)
|
||||||
qr_login_challenge_ttl_seconds: int = Field(
|
qr_login_challenge_ttl_seconds: int = Field(
|
||||||
default=120,
|
default=120,
|
||||||
description="Time-to-live in seconds for QR login challenges (default: 2 minutes).",
|
description="Time-to-live in seconds for QR login challenges (default: 2 minutes).",
|
||||||
@@ -295,12 +309,55 @@ class Settings(BaseSettings):
|
|||||||
authentik_client_secret: Optional[str] = None
|
authentik_client_secret: Optional[str] = None
|
||||||
authentik_config_url: Optional[str] = None
|
authentik_config_url: Optional[str] = None
|
||||||
oauth_provider_name: Optional[str] = None # Name to display for the OAuth provider
|
oauth_provider_name: Optional[str] = None # Name to display for the OAuth provider
|
||||||
|
sso_auto_login: bool = Field(
|
||||||
|
default=False,
|
||||||
|
description=(
|
||||||
|
"Automatically redirect to SSO login when authentication is required. "
|
||||||
|
"When enabled, users are sent directly to the SSO provider instead of "
|
||||||
|
"seeing the login page. Only effective when OIDC is configured."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Keycloak SSO
|
||||||
|
social_auth_keycloak_enabled: bool = False
|
||||||
|
social_auth_keycloak_client_id: Optional[str] = None
|
||||||
|
social_auth_keycloak_client_secret: Optional[str] = None
|
||||||
|
social_auth_keycloak_server_url: Optional[str] = None
|
||||||
|
social_auth_keycloak_realm: Optional[str] = None
|
||||||
|
|
||||||
|
# Generic OAuth2 SSO
|
||||||
|
social_auth_generic_oauth2_enabled: bool = False
|
||||||
|
social_auth_generic_oauth2_client_id: Optional[str] = None
|
||||||
|
social_auth_generic_oauth2_client_secret: Optional[str] = None
|
||||||
|
social_auth_generic_oauth2_authorize_url: Optional[str] = None
|
||||||
|
social_auth_generic_oauth2_token_url: Optional[str] = None
|
||||||
|
social_auth_generic_oauth2_userinfo_url: Optional[str] = None
|
||||||
|
social_auth_generic_oauth2_scope: str = "openid profile email"
|
||||||
|
social_auth_generic_oauth2_name: str = "OAuth2"
|
||||||
|
|
||||||
|
# SAML2 SSO
|
||||||
|
social_auth_saml2_enabled: bool = False
|
||||||
|
social_auth_saml2_entity_id: Optional[str] = None
|
||||||
|
social_auth_saml2_sso_url: Optional[str] = None
|
||||||
|
social_auth_saml2_certificate: Optional[str] = None
|
||||||
|
social_auth_saml2_name: str = "SAML2"
|
||||||
|
|
||||||
# Social Login Providers
|
# Social Login Providers
|
||||||
# Google OAuth2
|
# Google OAuth2
|
||||||
social_auth_google_enabled: bool = False
|
social_auth_google_enabled: bool = False
|
||||||
social_auth_google_client_id: Optional[str] = None
|
social_auth_google_client_id: Optional[str] = None
|
||||||
social_auth_google_client_secret: Optional[str] = None
|
social_auth_google_client_secret: Optional[str] = None
|
||||||
|
social_auth_google_use_global_credentials: bool = Field(
|
||||||
|
default=False,
|
||||||
|
description=(
|
||||||
|
"When True, Google social login uses the global GOOGLE_DRIVE_CLIENT_ID / "
|
||||||
|
"GOOGLE_DRIVE_CLIENT_SECRET credentials (the Google Drive OAuth integration credentials) "
|
||||||
|
"instead of requiring separate SOCIAL_AUTH_GOOGLE_CLIENT_ID / "
|
||||||
|
"SOCIAL_AUTH_GOOGLE_CLIENT_SECRET values. "
|
||||||
|
"Requires SOCIAL_AUTH_GOOGLE_ENABLED=True and the global Google Drive OAuth credentials to be set. "
|
||||||
|
"Default: False."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
# Microsoft OAuth2 (Azure AD / Microsoft Entra ID)
|
# Microsoft OAuth2 (Azure AD / Microsoft Entra ID)
|
||||||
social_auth_microsoft_enabled: bool = False
|
social_auth_microsoft_enabled: bool = False
|
||||||
@@ -315,6 +372,17 @@ class Settings(BaseSettings):
|
|||||||
"Default: common."
|
"Default: common."
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
social_auth_microsoft_use_global_credentials: bool = Field(
|
||||||
|
default=False,
|
||||||
|
description=(
|
||||||
|
"When True, Microsoft social login uses the global ONEDRIVE_CLIENT_ID / "
|
||||||
|
"ONEDRIVE_CLIENT_SECRET credentials (the OneDrive integration credentials) "
|
||||||
|
"instead of requiring separate SOCIAL_AUTH_MICROSOFT_CLIENT_ID / "
|
||||||
|
"SOCIAL_AUTH_MICROSOFT_CLIENT_SECRET values. "
|
||||||
|
"Requires SOCIAL_AUTH_MICROSOFT_ENABLED=True and the global OneDrive credentials to be set. "
|
||||||
|
"Default: False."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
# Apple Sign-In
|
# Apple Sign-In
|
||||||
social_auth_apple_enabled: bool = False
|
social_auth_apple_enabled: bool = False
|
||||||
@@ -338,6 +406,11 @@ class Settings(BaseSettings):
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# GitHub OAuth2
|
||||||
|
social_auth_github_enabled: bool = False
|
||||||
|
social_auth_github_client_id: Optional[str] = None
|
||||||
|
social_auth_github_client_secret: Optional[str] = None
|
||||||
|
|
||||||
# Local user signup
|
# Local user signup
|
||||||
allow_local_signup: bool = Field(
|
allow_local_signup: bool = Field(
|
||||||
default=False,
|
default=False,
|
||||||
@@ -834,6 +907,11 @@ class Settings(BaseSettings):
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Telegram Bot
|
||||||
|
telegram_bot_token: Optional[str] = None
|
||||||
|
telegram_chat_id: Optional[str] = None
|
||||||
|
telegram_enabled: bool = False
|
||||||
|
|
||||||
# Notification settings
|
# Notification settings
|
||||||
notification_urls: Union[List[str], str] = Field(
|
notification_urls: Union[List[str], str] = Field(
|
||||||
default_factory=list,
|
default_factory=list,
|
||||||
@@ -868,6 +946,12 @@ class Settings(BaseSettings):
|
|||||||
description="Enable webhook delivery for document events",
|
description="Enable webhook delivery for document events",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Automation hooks (Zapier / Make.com)
|
||||||
|
automation_hooks_enabled: bool = Field(
|
||||||
|
default=True,
|
||||||
|
description="Enable Zapier / Make.com automation hook subscriptions and delivery",
|
||||||
|
)
|
||||||
|
|
||||||
# ── Backup / restore settings ──────────────────────────────────────────────
|
# ── Backup / restore settings ──────────────────────────────────────────────
|
||||||
backup_enabled: bool = Field(
|
backup_enabled: bool = Field(
|
||||||
default=True,
|
default=True,
|
||||||
@@ -1293,6 +1377,40 @@ class Settings(BaseSettings):
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Observability – Sentry Browser JavaScript SDK (client-side)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# The same SENTRY_DSN is reused for the browser SDK. The DSN is a *public*
|
||||||
|
# key in Sentry's model and is intentionally embedded in client-side code.
|
||||||
|
# All three settings below default to 0.0 / disabled so that operators opt-in
|
||||||
|
# to the level of browser monitoring they want.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
sentry_js_traces_sample_rate: float = Field(
|
||||||
|
default=0.0,
|
||||||
|
description=(
|
||||||
|
"Fraction of browser page-loads captured for client-side performance tracing "
|
||||||
|
"(0.0 – 1.0). 0.0 disables browser tracing; 1.0 captures every navigation. "
|
||||||
|
"Only active when SENTRY_DSN is set."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
sentry_js_replay_session_sample_rate: float = Field(
|
||||||
|
default=0.0,
|
||||||
|
description=(
|
||||||
|
"Fraction of sessions recorded by Sentry Session Replay (0.0 – 1.0). "
|
||||||
|
"0.0 disables session recording; 1.0 records every session. "
|
||||||
|
"Only active when SENTRY_DSN is set."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
sentry_js_replay_on_error_sample_rate: float = Field(
|
||||||
|
default=0.1,
|
||||||
|
description=(
|
||||||
|
"Fraction of sessions with an error that will be recorded by Sentry Session "
|
||||||
|
"Replay (0.0 – 1.0). Defaults to 0.1 (10 %) so that errors are captured "
|
||||||
|
"with replay context even when session-level recording is disabled. "
|
||||||
|
"Only active when SENTRY_DSN is set."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
@model_validator(mode="before")
|
@model_validator(mode="before")
|
||||||
@classmethod
|
@classmethod
|
||||||
def strip_outer_quotes(cls, data: Any) -> Any:
|
def strip_outer_quotes(cls, data: Any) -> Any:
|
||||||
|
|||||||
+16
-5
@@ -189,6 +189,18 @@ async def lifespan(app: FastAPI):
|
|||||||
finally:
|
finally:
|
||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
|
# Re-register OAuth / social-login providers now that DB settings are
|
||||||
|
# loaded. auth.py runs its initial registration at import time (before
|
||||||
|
# the lifespan runs), so providers that are only configured in the
|
||||||
|
# database would not be registered yet. Calling refresh here ensures
|
||||||
|
# they are active immediately on startup without any manual restart.
|
||||||
|
try:
|
||||||
|
from app.auth import refresh_social_providers
|
||||||
|
|
||||||
|
refresh_social_providers()
|
||||||
|
except Exception as e:
|
||||||
|
logging.warning(f"Could not refresh social login providers on startup: {e}")
|
||||||
|
|
||||||
# Initialize Sentry after DB settings are loaded so that values configured
|
# Initialize Sentry after DB settings are loaded so that values configured
|
||||||
# via the database UI (e.g. SENTRY_DSN) are respected in addition to env vars.
|
# via the database UI (e.g. SENTRY_DSN) are respected in addition to env vars.
|
||||||
init_sentry()
|
init_sentry()
|
||||||
@@ -412,15 +424,13 @@ async def http_exception_handler(request: Request, exc: HTTPException):
|
|||||||
# For frontend routes, return appropriate HTML templates
|
# For frontend routes, return appropriate HTML templates
|
||||||
# Handle 404 errors with a custom template
|
# Handle 404 errors with a custom template
|
||||||
if exc.status_code == 404:
|
if exc.status_code == 404:
|
||||||
return _error_templates.TemplateResponse(
|
return _error_templates.TemplateResponse(request, "404.html", status_code=status.HTTP_404_NOT_FOUND)
|
||||||
"404.html", {"request": request}, status_code=status.HTTP_404_NOT_FOUND
|
|
||||||
)
|
|
||||||
|
|
||||||
# For other HTTP errors, we could create specific templates or use a generic one
|
# For other HTTP errors, we could create specific templates or use a generic one
|
||||||
# For now, return a simple error page
|
# For now, return a simple error page
|
||||||
return _error_templates.TemplateResponse(
|
return _error_templates.TemplateResponse(
|
||||||
|
request,
|
||||||
"404.html", # Reuse 404 template for other errors, or create a generic error template
|
"404.html", # Reuse 404 template for other errors, or create a generic error template
|
||||||
{"request": request},
|
|
||||||
status_code=exc.status_code,
|
status_code=exc.status_code,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -440,8 +450,9 @@ async def custom_500_handler(request: Request, exc: Exception):
|
|||||||
|
|
||||||
# Serve the 500 template for non-API routes
|
# Serve the 500 template for non-API routes
|
||||||
return _error_templates.TemplateResponse(
|
return _error_templates.TemplateResponse(
|
||||||
|
request,
|
||||||
"500.html",
|
"500.html",
|
||||||
{"request": request, "exc": exc},
|
context={"exc": exc},
|
||||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
+161
@@ -211,6 +211,28 @@ class WebhookConfig(Base):
|
|||||||
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||||
|
|
||||||
|
|
||||||
|
class AutomationHook(Base):
|
||||||
|
"""Zapier / Make.com compatible webhook subscription for automation triggers.
|
||||||
|
|
||||||
|
External automation platforms subscribe to DocuElevate events via the REST
|
||||||
|
hooks protocol. When an event fires, DocuElevate POSTs a Zapier-compatible
|
||||||
|
flat JSON payload to ``target_url``. The ``hook_type`` field records which
|
||||||
|
platform created the subscription (informational only).
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "automation_hooks"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
target_url = Column(String, nullable=False) # URL to POST events to
|
||||||
|
secret = Column(String, nullable=True) # Optional HMAC-SHA256 signing secret
|
||||||
|
events = Column(Text, nullable=False) # JSON list of subscribed event names
|
||||||
|
is_active = Column(Boolean, default=True, nullable=False)
|
||||||
|
hook_type = Column(String(50), nullable=False, default="generic") # zapier | make | generic
|
||||||
|
description = Column(String, nullable=True) # Optional human-readable label
|
||||||
|
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||||
|
|
||||||
|
|
||||||
class LocalUser(Base):
|
class LocalUser(Base):
|
||||||
"""A locally-registered user authenticated by email and bcrypt password.
|
"""A locally-registered user authenticated by email and bcrypt password.
|
||||||
|
|
||||||
@@ -940,6 +962,51 @@ class ScheduledJob(Base):
|
|||||||
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||||
|
|
||||||
|
|
||||||
|
class ClassificationRuleModel(Base):
|
||||||
|
"""Custom document classification rule.
|
||||||
|
|
||||||
|
Rules are evaluated during the ``classify`` pipeline step to assign a
|
||||||
|
category to a document. System-wide rules have ``owner_id IS NULL``;
|
||||||
|
user-specific rules belong to a single owner.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "classification_rules"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
|
||||||
|
# NULL = system-wide rule visible to all users.
|
||||||
|
owner_id = Column(String, nullable=True, index=True)
|
||||||
|
|
||||||
|
# Human-readable rule name (unique per owner).
|
||||||
|
name = Column(String(255), nullable=False)
|
||||||
|
|
||||||
|
# Target category (e.g. "invoice", "contract", "receipt").
|
||||||
|
category = Column(String(100), nullable=False, index=True)
|
||||||
|
|
||||||
|
# Rule type: "filename_pattern", "content_keyword", or "metadata_match".
|
||||||
|
rule_type = Column(String(50), nullable=False)
|
||||||
|
|
||||||
|
# The matching pattern:
|
||||||
|
# - filename_pattern: a regex
|
||||||
|
# - content_keyword: pipe-separated keywords
|
||||||
|
# - metadata_match: "field=value"
|
||||||
|
pattern = Column(String(1000), nullable=False)
|
||||||
|
|
||||||
|
# Higher priority rules are evaluated first (default 0).
|
||||||
|
priority = Column(Integer, nullable=False, default=0)
|
||||||
|
|
||||||
|
# Whether pattern matching is case-sensitive.
|
||||||
|
case_sensitive = Column(Boolean, nullable=False, default=False)
|
||||||
|
|
||||||
|
# Disabled rules are skipped during classification.
|
||||||
|
enabled = Column(Boolean, nullable=False, default=True)
|
||||||
|
|
||||||
|
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||||
|
|
||||||
|
__table_args__ = (UniqueConstraint("owner_id", "name", name="uq_classification_rules_owner_name"),)
|
||||||
|
|
||||||
|
|
||||||
class MobileDevice(Base):
|
class MobileDevice(Base):
|
||||||
"""Registered mobile device for push notifications.
|
"""Registered mobile device for push notifications.
|
||||||
|
|
||||||
@@ -1125,3 +1192,97 @@ class PipelineRoutingRule(Base):
|
|||||||
|
|
||||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||||
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||||
|
|
||||||
|
|
||||||
|
class DocumentComment(Base):
|
||||||
|
"""Threaded comment on a document.
|
||||||
|
|
||||||
|
Supports threaded replies via ``parent_id`` and @mentions via the
|
||||||
|
``mentions`` column (comma-separated user identifiers).
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "document_comments"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
file_id = Column(Integer, ForeignKey(_FILES_ID_FK), nullable=False, index=True)
|
||||||
|
user_id = Column(String, nullable=False, index=True)
|
||||||
|
parent_id = Column(Integer, ForeignKey("document_comments.id"), nullable=True, index=True)
|
||||||
|
body = Column(Text, nullable=False)
|
||||||
|
mentions = Column(Text, nullable=True)
|
||||||
|
is_resolved = Column(Boolean, nullable=False, default=False, server_default="0")
|
||||||
|
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||||
|
|
||||||
|
|
||||||
|
class DocumentAnnotation(Base):
|
||||||
|
"""Text annotation on a specific page and position of a PDF document.
|
||||||
|
|
||||||
|
Stores the bounding-box coordinates (``x``, ``y``, ``width``,
|
||||||
|
``height``) relative to the page dimensions so that the annotation
|
||||||
|
can be rendered on top of the PDF viewer.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "document_annotations"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
file_id = Column(Integer, ForeignKey(_FILES_ID_FK), nullable=False, index=True)
|
||||||
|
user_id = Column(String, nullable=False, index=True)
|
||||||
|
page = Column(Integer, nullable=False)
|
||||||
|
x = Column(Float, nullable=False)
|
||||||
|
y = Column(Float, nullable=False)
|
||||||
|
width = Column(Float, nullable=False, default=0)
|
||||||
|
height = Column(Float, nullable=False, default=0)
|
||||||
|
content = Column(Text, nullable=False)
|
||||||
|
annotation_type = Column(String(50), nullable=False, default="note", server_default="note")
|
||||||
|
color = Column(String(20), nullable=True)
|
||||||
|
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# File sharing
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# Valid roles for FileShare.role
|
||||||
|
FILE_SHARE_ROLE_VIEWER = "viewer"
|
||||||
|
FILE_SHARE_ROLE_EDITOR = "editor"
|
||||||
|
FILE_SHARE_ROLES = (FILE_SHARE_ROLE_VIEWER, FILE_SHARE_ROLE_EDITOR)
|
||||||
|
|
||||||
|
|
||||||
|
class FileShare(Base):
|
||||||
|
"""Grants a named user access to a ``FileRecord`` owned by someone else.
|
||||||
|
|
||||||
|
The ``owner_id`` column records who created the share (must be the file
|
||||||
|
owner). ``shared_with_user_id`` is the recipient's stable user
|
||||||
|
identifier (the same kind of string used in ``FileRecord.owner_id``).
|
||||||
|
|
||||||
|
Roles
|
||||||
|
-----
|
||||||
|
``viewer`` — can read the file, comments, and annotations; may add
|
||||||
|
comments/annotations; cannot delete or share.
|
||||||
|
``editor`` — all viewer rights plus the ability to edit document
|
||||||
|
metadata; cannot delete or re-share.
|
||||||
|
|
||||||
|
Only the file owner may create, update, or revoke shares.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "file_shares"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
|
||||||
|
# The document being shared.
|
||||||
|
file_id = Column(Integer, ForeignKey(_FILES_ID_FK), nullable=False, index=True)
|
||||||
|
|
||||||
|
# The user who granted the share (must match FileRecord.owner_id).
|
||||||
|
owner_id = Column(String, nullable=False, index=True)
|
||||||
|
|
||||||
|
# The user receiving the share.
|
||||||
|
shared_with_user_id = Column(String, nullable=False, index=True)
|
||||||
|
|
||||||
|
# "viewer" or "editor"
|
||||||
|
role = Column(String(20), nullable=False, default=FILE_SHARE_ROLE_VIEWER)
|
||||||
|
|
||||||
|
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
|
||||||
|
|
||||||
|
__table_args__ = (UniqueConstraint("file_id", "shared_with_user_id", name="uq_file_share_file_user"),)
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
"""Celery task for asynchronous automation hook delivery with retry and backoff.
|
||||||
|
|
||||||
|
Uses :class:`~app.tasks.retry_config.BaseTaskWithRetry` so failed deliveries
|
||||||
|
are automatically retried with exponential backoff (default: 60 s, 300 s,
|
||||||
|
900 s) and ±20 % jitter.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from app.celery_app import celery
|
||||||
|
from app.tasks.retry_config import BaseTaskWithRetry
|
||||||
|
from app.utils.webhook import deliver_webhook
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@celery.task(base=BaseTaskWithRetry, bind=True, name="automation.deliver_hook")
|
||||||
|
def deliver_automation_hook_task(self, url: str, payload: dict[str, Any], secret: str | None = None) -> dict[str, Any]:
|
||||||
|
"""Deliver an automation hook payload to *url* with automatic retries.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
url: Target webhook URL (provided by Zapier / Make.com).
|
||||||
|
payload: The flat Zapier-compatible payload.
|
||||||
|
secret: Optional shared secret for HMAC-SHA256 signing.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A dict with ``status`` and ``url`` on success.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
RuntimeError: Re-raised to trigger Celery retry on delivery failure.
|
||||||
|
"""
|
||||||
|
logger.info(
|
||||||
|
"Delivering automation hook to %s (attempt %d/%d)",
|
||||||
|
url,
|
||||||
|
self.request.retries + 1,
|
||||||
|
self.max_retries + 1,
|
||||||
|
)
|
||||||
|
|
||||||
|
success = deliver_webhook(url, payload, secret)
|
||||||
|
if success:
|
||||||
|
return {"status": "delivered", "url": url}
|
||||||
|
|
||||||
|
raise RuntimeError(f"Automation hook delivery to {url} failed")
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
"""Celery task for rule-based document classification.
|
||||||
|
|
||||||
|
This task is executed as a pipeline step (``step_type="classify"``). It
|
||||||
|
applies built-in and user-defined classification rules against the document's
|
||||||
|
filename, OCR text, and existing AI metadata to assign a ``document_type``
|
||||||
|
category.
|
||||||
|
|
||||||
|
The result is stored in the ``ai_metadata`` JSON blob on the
|
||||||
|
:class:`~app.models.FileRecord` (field ``classification``).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from app.celery_app import celery
|
||||||
|
from app.database import SessionLocal
|
||||||
|
from app.models import ClassificationRuleModel, FileRecord
|
||||||
|
from app.tasks.retry_config import BaseTaskWithRetry
|
||||||
|
from app.utils import log_task_progress
|
||||||
|
from app.utils.classification_rules import (
|
||||||
|
ClassificationResult,
|
||||||
|
classify_document,
|
||||||
|
db_rule_to_engine_rule,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
STEP_NAME = "classify_document"
|
||||||
|
|
||||||
|
|
||||||
|
def _load_custom_rules(owner_id: str | None) -> list[Any]:
|
||||||
|
"""Load enabled custom classification rules from the database.
|
||||||
|
|
||||||
|
Returns engine-level :class:`ClassificationRule` dataclass instances.
|
||||||
|
Rules are loaded in priority-descending order. System rules
|
||||||
|
(``owner_id IS NULL``) and the user's own rules are both included.
|
||||||
|
"""
|
||||||
|
with SessionLocal() as db:
|
||||||
|
query = db.query(ClassificationRuleModel).filter(ClassificationRuleModel.enabled.is_(True))
|
||||||
|
if owner_id:
|
||||||
|
query = query.filter(
|
||||||
|
(ClassificationRuleModel.owner_id.is_(None)) | (ClassificationRuleModel.owner_id == owner_id)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
query = query.filter(ClassificationRuleModel.owner_id.is_(None))
|
||||||
|
rules = query.order_by(ClassificationRuleModel.priority.desc()).all()
|
||||||
|
return [db_rule_to_engine_rule(r) for r in rules]
|
||||||
|
|
||||||
|
|
||||||
|
@celery.task(base=BaseTaskWithRetry, bind=True)
|
||||||
|
def classify_document_task(
|
||||||
|
self: Any,
|
||||||
|
file_id: int,
|
||||||
|
owner_id: str | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Classify a document using rule-based matching.
|
||||||
|
|
||||||
|
This task:
|
||||||
|
1. Loads the :class:`FileRecord` from the database.
|
||||||
|
2. Gathers filename, OCR text, and existing AI metadata.
|
||||||
|
3. Loads built-in + user-defined classification rules.
|
||||||
|
4. Runs the classification engine.
|
||||||
|
5. Persists the result into ``ai_metadata.classification``.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
file_id: Primary key of the :class:`FileRecord` to classify.
|
||||||
|
owner_id: Owner identifier for loading user-specific rules.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with ``category``, ``confidence``, and ``matched_rules``.
|
||||||
|
"""
|
||||||
|
task_id = self.request.id
|
||||||
|
|
||||||
|
log_task_progress(
|
||||||
|
task_id,
|
||||||
|
STEP_NAME,
|
||||||
|
"in_progress",
|
||||||
|
f"Starting classification for file {file_id}",
|
||||||
|
file_id=file_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
with SessionLocal() as db:
|
||||||
|
file_record: FileRecord | None = db.query(FileRecord).filter(FileRecord.id == file_id).first()
|
||||||
|
if file_record is None:
|
||||||
|
log_task_progress(
|
||||||
|
task_id,
|
||||||
|
STEP_NAME,
|
||||||
|
"failure",
|
||||||
|
f"FileRecord {file_id} not found",
|
||||||
|
file_id=file_id,
|
||||||
|
)
|
||||||
|
return {"status": "error", "detail": "File not found"}
|
||||||
|
|
||||||
|
# Gather inputs
|
||||||
|
filename = file_record.original_filename or ""
|
||||||
|
text = file_record.ocr_text or ""
|
||||||
|
existing_metadata: dict[str, Any] = {}
|
||||||
|
if file_record.ai_metadata:
|
||||||
|
try:
|
||||||
|
existing_metadata = json.loads(file_record.ai_metadata)
|
||||||
|
except (json.JSONDecodeError, TypeError):
|
||||||
|
logger.warning("Failed to parse ai_metadata for file %s, starting fresh", file_id)
|
||||||
|
existing_metadata = {}
|
||||||
|
|
||||||
|
# Load custom rules
|
||||||
|
effective_owner = owner_id or file_record.owner_id
|
||||||
|
custom_rules = _load_custom_rules(effective_owner)
|
||||||
|
|
||||||
|
# Run classification engine
|
||||||
|
result: ClassificationResult = classify_document(
|
||||||
|
filename=filename,
|
||||||
|
text=text,
|
||||||
|
metadata=existing_metadata,
|
||||||
|
custom_rules=custom_rules,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Persist result into ai_metadata
|
||||||
|
classification_data = {
|
||||||
|
"category": result.category,
|
||||||
|
"confidence": result.confidence,
|
||||||
|
"matched_rules": [
|
||||||
|
{
|
||||||
|
"rule_name": m.rule_name,
|
||||||
|
"rule_type": m.rule_type,
|
||||||
|
"category": m.category,
|
||||||
|
"confidence": m.confidence,
|
||||||
|
}
|
||||||
|
for m in result.matched_rules
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
existing_metadata["classification"] = classification_data
|
||||||
|
|
||||||
|
# If no document_type was set yet, populate it from the classification
|
||||||
|
if not existing_metadata.get("document_type"):
|
||||||
|
from app.utils.classification_rules import BUILTIN_CATEGORIES
|
||||||
|
|
||||||
|
existing_metadata["document_type"] = BUILTIN_CATEGORIES.get(
|
||||||
|
result.category, result.category.replace("_", " ").title()
|
||||||
|
)
|
||||||
|
|
||||||
|
file_record.ai_metadata = json.dumps(existing_metadata, ensure_ascii=False)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
log_task_progress(
|
||||||
|
task_id,
|
||||||
|
STEP_NAME,
|
||||||
|
"success",
|
||||||
|
f"Classified as '{result.category}' with confidence {result.confidence}",
|
||||||
|
file_id=file_id,
|
||||||
|
detail=f"Matched {len(result.matched_rules)} rule(s)",
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": "success",
|
||||||
|
"category": result.category,
|
||||||
|
"confidence": result.confidence,
|
||||||
|
"matched_rules": len(result.matched_rules),
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception("Classification failed for file %s: %s", file_id, e)
|
||||||
|
log_task_progress(
|
||||||
|
task_id,
|
||||||
|
STEP_NAME,
|
||||||
|
"failure",
|
||||||
|
f"Classification failed: {e}",
|
||||||
|
file_id=file_id,
|
||||||
|
)
|
||||||
|
raise
|
||||||
@@ -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,6 +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",
|
||||||
|
"--",
|
||||||
input_path,
|
input_path,
|
||||||
output_path,
|
output_path,
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -68,6 +68,8 @@ IMAGE_MIME_TYPES: set[str] = {
|
|||||||
"image/tiff",
|
"image/tiff",
|
||||||
"image/webp",
|
"image/webp",
|
||||||
"image/svg+xml",
|
"image/svg+xml",
|
||||||
|
"image/heic",
|
||||||
|
"image/heif",
|
||||||
}
|
}
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -124,6 +126,8 @@ ALLOWED_EXTENSIONS: set[str] = {
|
|||||||
".tif",
|
".tif",
|
||||||
".webp",
|
".webp",
|
||||||
".svg",
|
".svg",
|
||||||
|
".heic",
|
||||||
|
".heif",
|
||||||
# Web
|
# Web
|
||||||
".html",
|
".html",
|
||||||
".htm",
|
".htm",
|
||||||
@@ -234,7 +238,7 @@ FILE_TYPE_CATEGORIES: dict[str, dict] = {
|
|||||||
},
|
},
|
||||||
"images": {
|
"images": {
|
||||||
"label": "Images",
|
"label": "Images",
|
||||||
"description": "Image files (.jpg, .png, .gif, .bmp, .tiff, .webp, .svg)",
|
"description": "Image files (.jpg, .png, .gif, .bmp, .tiff, .webp, .svg, .heic, .heif)",
|
||||||
"mime_types": frozenset(
|
"mime_types": frozenset(
|
||||||
{
|
{
|
||||||
"image/jpeg",
|
"image/jpeg",
|
||||||
@@ -245,6 +249,8 @@ FILE_TYPE_CATEGORIES: dict[str, dict] = {
|
|||||||
"image/tiff",
|
"image/tiff",
|
||||||
"image/webp",
|
"image/webp",
|
||||||
"image/svg+xml",
|
"image/svg+xml",
|
||||||
|
"image/heic",
|
||||||
|
"image/heif",
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
"extensions": frozenset(
|
"extensions": frozenset(
|
||||||
@@ -258,6 +264,8 @@ FILE_TYPE_CATEGORIES: dict[str, dict] = {
|
|||||||
".tif",
|
".tif",
|
||||||
".webp",
|
".webp",
|
||||||
".svg",
|
".svg",
|
||||||
|
".heic",
|
||||||
|
".heif",
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,188 @@
|
|||||||
|
"""Automation hook utilities for Zapier / Make.com integration.
|
||||||
|
|
||||||
|
Provides helpers to build Zapier-compatible flat payloads, query active
|
||||||
|
automation hook subscriptions, and fan-out event delivery to all matching
|
||||||
|
hooks via Celery tasks.
|
||||||
|
|
||||||
|
The payload format is intentionally *flat* (no nested ``data`` key) so that
|
||||||
|
Zapier and Make.com can map fields without JSONPath expressions. An ``id``
|
||||||
|
field is included for Zapier deduplication.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
import uuid
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from app.config import settings
|
||||||
|
from app.database import SessionLocal
|
||||||
|
from app.models import AutomationHook
|
||||||
|
from app.utils.webhook import VALID_EVENTS
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Payload helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def build_zapier_payload(event: str, data: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
"""Build a flat, Zapier-compatible webhook payload.
|
||||||
|
|
||||||
|
Zapier works best with flat JSON objects that include an ``id`` field
|
||||||
|
for deduplication. This function merges event metadata into the
|
||||||
|
top-level object alongside the event-specific *data*.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
event: The event name (e.g. ``document.processed``).
|
||||||
|
data: Event-specific key/value pairs.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A flat dictionary suitable for Zapier / Make.com consumption.
|
||||||
|
"""
|
||||||
|
return {
|
||||||
|
"id": f"evt_{uuid.uuid4().hex[:16]}",
|
||||||
|
"event": event,
|
||||||
|
"timestamp": time.time(),
|
||||||
|
**data,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Sample payloads (used by the /triggers/sample endpoint)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#: Example payloads that Zapier uses for field-mapping during Zap creation.
|
||||||
|
SAMPLE_PAYLOADS: dict[str, dict[str, Any]] = {
|
||||||
|
"document.uploaded": {
|
||||||
|
"id": "evt_sample0001",
|
||||||
|
"event": "document.uploaded",
|
||||||
|
"timestamp": 1710000000.0,
|
||||||
|
"document_id": 42,
|
||||||
|
"filename": "invoice_2024.pdf",
|
||||||
|
"content_type": "application/pdf",
|
||||||
|
"size_bytes": 204800,
|
||||||
|
"owner_id": "user@example.com",
|
||||||
|
},
|
||||||
|
"document.processed": {
|
||||||
|
"id": "evt_sample0002",
|
||||||
|
"event": "document.processed",
|
||||||
|
"timestamp": 1710000060.0,
|
||||||
|
"document_id": 42,
|
||||||
|
"filename": "invoice_2024.pdf",
|
||||||
|
"status": "processed",
|
||||||
|
"title": "Invoice #1234",
|
||||||
|
"owner_id": "user@example.com",
|
||||||
|
},
|
||||||
|
"document.failed": {
|
||||||
|
"id": "evt_sample0003",
|
||||||
|
"event": "document.failed",
|
||||||
|
"timestamp": 1710000120.0,
|
||||||
|
"document_id": 42,
|
||||||
|
"filename": "corrupt.pdf",
|
||||||
|
"status": "failed",
|
||||||
|
"error": "Unable to extract text from document",
|
||||||
|
"owner_id": "user@example.com",
|
||||||
|
},
|
||||||
|
"user.signup": {
|
||||||
|
"id": "evt_sample0004",
|
||||||
|
"event": "user.signup",
|
||||||
|
"timestamp": 1710000180.0,
|
||||||
|
"user_id": "newuser@example.com",
|
||||||
|
"display_name": "Jane Doe",
|
||||||
|
},
|
||||||
|
"user.plan_changed": {
|
||||||
|
"id": "evt_sample0005",
|
||||||
|
"event": "user.plan_changed",
|
||||||
|
"timestamp": 1710000240.0,
|
||||||
|
"user_id": "user@example.com",
|
||||||
|
"old_tier": "free",
|
||||||
|
"new_tier": "pro",
|
||||||
|
},
|
||||||
|
"user.payment_issue": {
|
||||||
|
"id": "evt_sample0006",
|
||||||
|
"event": "user.payment_issue",
|
||||||
|
"timestamp": 1710000300.0,
|
||||||
|
"user_id": "user@example.com",
|
||||||
|
"issue": "Credit card declined",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Database queries
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def get_active_hooks_for_event(event: str) -> list[dict[str, Any]]:
|
||||||
|
"""Return all active automation hooks subscribed to *event*.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
event: The event name to filter on.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A list of dicts with ``id``, ``target_url``, ``secret``, and
|
||||||
|
``events`` keys.
|
||||||
|
"""
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
hooks = db.query(AutomationHook).filter(AutomationHook.is_active.is_(True)).all()
|
||||||
|
result: list[dict[str, Any]] = []
|
||||||
|
for hook in hooks:
|
||||||
|
try:
|
||||||
|
subscribed = json.loads(hook.events)
|
||||||
|
except (json.JSONDecodeError, TypeError):
|
||||||
|
subscribed = []
|
||||||
|
if event in subscribed:
|
||||||
|
result.append(
|
||||||
|
{
|
||||||
|
"id": hook.id,
|
||||||
|
"target_url": hook.target_url,
|
||||||
|
"secret": hook.secret,
|
||||||
|
"events": subscribed,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Dispatch
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def dispatch_automation_hooks(event: str, data: dict[str, Any]) -> None:
|
||||||
|
"""Fan-out an event to all matching active automation hooks.
|
||||||
|
|
||||||
|
Builds a Zapier-compatible flat payload and queues a Celery task for
|
||||||
|
each matching hook so delivery is asynchronous with automatic retries.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
event: Event name (must be in :data:`VALID_EVENTS`).
|
||||||
|
data: Event-specific payload data.
|
||||||
|
"""
|
||||||
|
if not settings.automation_hooks_enabled:
|
||||||
|
return
|
||||||
|
|
||||||
|
if event not in VALID_EVENTS:
|
||||||
|
logger.warning("Ignoring unknown automation hook event: %s", event)
|
||||||
|
return
|
||||||
|
|
||||||
|
hooks = get_active_hooks_for_event(event)
|
||||||
|
if not hooks:
|
||||||
|
logger.debug("No active automation hooks for event %s", event)
|
||||||
|
return
|
||||||
|
|
||||||
|
payload = build_zapier_payload(event, data)
|
||||||
|
|
||||||
|
from app.tasks.automation_tasks import deliver_automation_hook_task
|
||||||
|
|
||||||
|
for hook in hooks:
|
||||||
|
try:
|
||||||
|
deliver_automation_hook_task.delay(hook["target_url"], payload, hook["secret"])
|
||||||
|
logger.debug("Queued automation hook delivery to %s for event %s", hook["target_url"], event)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error("Failed to queue automation hook to %s: %s", hook["target_url"], exc)
|
||||||
@@ -0,0 +1,378 @@
|
|||||||
|
"""
|
||||||
|
Rule-based document classification engine.
|
||||||
|
|
||||||
|
Provides pre-built categories and a rule matcher that classifies documents
|
||||||
|
using filename patterns, content keywords, and metadata fields. Custom
|
||||||
|
rules stored in the database are evaluated alongside the built-in defaults.
|
||||||
|
|
||||||
|
Usage::
|
||||||
|
|
||||||
|
from app.utils.classification_rules import classify_document
|
||||||
|
|
||||||
|
result = classify_document(
|
||||||
|
filename="2024-03-01_Invoice_Acme.pdf",
|
||||||
|
text="Invoice total: $1,234.56",
|
||||||
|
metadata={"absender": "Acme Corp"},
|
||||||
|
custom_rules=custom_rules_from_db,
|
||||||
|
)
|
||||||
|
# result -> ClassificationResult(category="invoice", confidence=85, matched_rules=[...])
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Pre-built categories
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#: Canonical category names recognized by the system. Users may also define
|
||||||
|
#: their own categories via custom rules.
|
||||||
|
BUILTIN_CATEGORIES: dict[str, str] = {
|
||||||
|
"invoice": "Invoice",
|
||||||
|
"contract": "Contract",
|
||||||
|
"receipt": "Receipt",
|
||||||
|
"letter": "Letter",
|
||||||
|
"report": "Report",
|
||||||
|
"bank_statement": "Bank Statement",
|
||||||
|
"tax_document": "Tax Document",
|
||||||
|
"insurance": "Insurance Document",
|
||||||
|
"payslip": "Payslip",
|
||||||
|
"unknown": "Unknown",
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Rule type constants
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
RULE_TYPE_FILENAME = "filename_pattern"
|
||||||
|
RULE_TYPE_CONTENT = "content_keyword"
|
||||||
|
RULE_TYPE_METADATA = "metadata_match"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Data classes
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ClassificationRule:
|
||||||
|
"""A single classification rule."""
|
||||||
|
|
||||||
|
name: str
|
||||||
|
category: str
|
||||||
|
rule_type: str # filename_pattern | content_keyword | metadata_match
|
||||||
|
pattern: str # regex for filename, keyword(s) for content, "field=value" for metadata
|
||||||
|
priority: int = 0 # higher = evaluated first
|
||||||
|
case_sensitive: bool = False
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
if self.rule_type not in (RULE_TYPE_FILENAME, RULE_TYPE_CONTENT, RULE_TYPE_METADATA):
|
||||||
|
raise ValueError(f"Invalid rule_type: {self.rule_type!r}")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class MatchedRule:
|
||||||
|
"""Records which rule matched and why."""
|
||||||
|
|
||||||
|
rule_name: str
|
||||||
|
rule_type: str
|
||||||
|
category: str
|
||||||
|
confidence: int
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ClassificationResult:
|
||||||
|
"""The outcome of running the classification engine on a document."""
|
||||||
|
|
||||||
|
category: str
|
||||||
|
confidence: int # 0 – 100
|
||||||
|
matched_rules: list[MatchedRule] = field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Built-in rules
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
BUILTIN_RULES: list[ClassificationRule] = [
|
||||||
|
# ── Invoice ───────────────────────────────────────────────────────────
|
||||||
|
ClassificationRule("builtin_invoice_filename", "invoice", RULE_TYPE_FILENAME, r"(?i)invoice|rechnung|facture"),
|
||||||
|
ClassificationRule(
|
||||||
|
"builtin_invoice_content",
|
||||||
|
"invoice",
|
||||||
|
RULE_TYPE_CONTENT,
|
||||||
|
"invoice number|invoice total|amount due|rechnung|rechnungsnummer|total amount|bill to",
|
||||||
|
),
|
||||||
|
ClassificationRule("builtin_invoice_metadata", "invoice", RULE_TYPE_METADATA, "document_type=Invoice"),
|
||||||
|
ClassificationRule(
|
||||||
|
"builtin_invoice_kommunikationsart", "invoice", RULE_TYPE_METADATA, "kommunikationsart=Rechnung"
|
||||||
|
),
|
||||||
|
# ── Contract ──────────────────────────────────────────────────────────
|
||||||
|
ClassificationRule("builtin_contract_filename", "contract", RULE_TYPE_FILENAME, r"(?i)contract|vertrag|agreement"),
|
||||||
|
ClassificationRule(
|
||||||
|
"builtin_contract_content",
|
||||||
|
"contract",
|
||||||
|
RULE_TYPE_CONTENT,
|
||||||
|
"hereby agrees|terms and conditions|vertrag|agreement between|party agrees|effective date",
|
||||||
|
),
|
||||||
|
ClassificationRule("builtin_contract_metadata", "contract", RULE_TYPE_METADATA, "document_type=Contract"),
|
||||||
|
ClassificationRule(
|
||||||
|
"builtin_contract_kommunikationsart", "contract", RULE_TYPE_METADATA, "kommunikationsart=Vertrag"
|
||||||
|
),
|
||||||
|
# ── Receipt ───────────────────────────────────────────────────────────
|
||||||
|
ClassificationRule("builtin_receipt_filename", "receipt", RULE_TYPE_FILENAME, r"(?i)receipt|quittung|beleg"),
|
||||||
|
ClassificationRule(
|
||||||
|
"builtin_receipt_content",
|
||||||
|
"receipt",
|
||||||
|
RULE_TYPE_CONTENT,
|
||||||
|
"receipt|quittung|payment received|thank you for your purchase|transaction id",
|
||||||
|
),
|
||||||
|
ClassificationRule("builtin_receipt_metadata", "receipt", RULE_TYPE_METADATA, "document_type=Receipt"),
|
||||||
|
ClassificationRule(
|
||||||
|
"builtin_receipt_kommunikationsart", "receipt", RULE_TYPE_METADATA, "kommunikationsart=Quittung"
|
||||||
|
),
|
||||||
|
# ── Letter ────────────────────────────────────────────────────────────
|
||||||
|
ClassificationRule("builtin_letter_filename", "letter", RULE_TYPE_FILENAME, r"(?i)letter|brief|schreiben"),
|
||||||
|
ClassificationRule(
|
||||||
|
"builtin_letter_content",
|
||||||
|
"letter",
|
||||||
|
RULE_TYPE_CONTENT,
|
||||||
|
"dear sir|dear madam|sehr geehrte|to whom it may concern|sincerely|mit freundlichen",
|
||||||
|
),
|
||||||
|
# ── Report ────────────────────────────────────────────────────────────
|
||||||
|
ClassificationRule("builtin_report_filename", "report", RULE_TYPE_FILENAME, r"(?i)report|bericht"),
|
||||||
|
ClassificationRule(
|
||||||
|
"builtin_report_content",
|
||||||
|
"report",
|
||||||
|
RULE_TYPE_CONTENT,
|
||||||
|
"executive summary|table of contents|annual report|quarterly report|findings",
|
||||||
|
),
|
||||||
|
# ── Bank statement ────────────────────────────────────────────────────
|
||||||
|
ClassificationRule(
|
||||||
|
"builtin_bank_filename",
|
||||||
|
"bank_statement",
|
||||||
|
RULE_TYPE_FILENAME,
|
||||||
|
r"(?i)bank.?statement|kontoauszug",
|
||||||
|
),
|
||||||
|
ClassificationRule(
|
||||||
|
"builtin_bank_content",
|
||||||
|
"bank_statement",
|
||||||
|
RULE_TYPE_CONTENT,
|
||||||
|
"account statement|kontoauszug|opening balance|closing balance|account number",
|
||||||
|
),
|
||||||
|
ClassificationRule(
|
||||||
|
"builtin_bank_kommunikationsart", "bank_statement", RULE_TYPE_METADATA, "kommunikationsart=Kontoauszug"
|
||||||
|
),
|
||||||
|
# ── Tax document ──────────────────────────────────────────────────────
|
||||||
|
ClassificationRule("builtin_tax_filename", "tax_document", RULE_TYPE_FILENAME, r"(?i)tax|steuer|steuerbescheid"),
|
||||||
|
ClassificationRule(
|
||||||
|
"builtin_tax_content",
|
||||||
|
"tax_document",
|
||||||
|
RULE_TYPE_CONTENT,
|
||||||
|
"tax return|steuerbescheid|taxable income|finanzamt|tax assessment",
|
||||||
|
),
|
||||||
|
# ── Insurance ─────────────────────────────────────────────────────────
|
||||||
|
ClassificationRule(
|
||||||
|
"builtin_insurance_filename", "insurance", RULE_TYPE_FILENAME, r"(?i)insurance|versicherung|police"
|
||||||
|
),
|
||||||
|
ClassificationRule(
|
||||||
|
"builtin_insurance_content",
|
||||||
|
"insurance",
|
||||||
|
RULE_TYPE_CONTENT,
|
||||||
|
"insurance policy|versicherung|policennummer|coverage|premium|deductible",
|
||||||
|
),
|
||||||
|
# ── Payslip ───────────────────────────────────────────────────────────
|
||||||
|
ClassificationRule(
|
||||||
|
"builtin_payslip_filename", "payslip", RULE_TYPE_FILENAME, r"(?i)payslip|gehaltsabrechnung|lohnabrechnung"
|
||||||
|
),
|
||||||
|
ClassificationRule(
|
||||||
|
"builtin_payslip_content",
|
||||||
|
"payslip",
|
||||||
|
RULE_TYPE_CONTENT,
|
||||||
|
"gross salary|net salary|gehaltsabrechnung|lohnabrechnung|bruttolohn|nettolohn",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Confidence scoring
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#: Base confidence for each rule type when it matches.
|
||||||
|
_CONFIDENCE_MAP: dict[str, int] = {
|
||||||
|
RULE_TYPE_FILENAME: 60,
|
||||||
|
RULE_TYPE_CONTENT: 70,
|
||||||
|
RULE_TYPE_METADATA: 90,
|
||||||
|
}
|
||||||
|
|
||||||
|
#: Extra confidence per additional matching rule of the same category (capped).
|
||||||
|
_CONFIDENCE_BONUS_PER_EXTRA_RULE = 10
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Matching helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _match_filename(rule: ClassificationRule, filename: str) -> bool:
|
||||||
|
"""Return True if *rule.pattern* (regex) matches anywhere in *filename*."""
|
||||||
|
if not filename:
|
||||||
|
return False
|
||||||
|
flags = 0 if rule.case_sensitive else re.IGNORECASE
|
||||||
|
return bool(re.search(rule.pattern, filename, flags))
|
||||||
|
|
||||||
|
|
||||||
|
def _match_content(rule: ClassificationRule, text: str) -> bool:
|
||||||
|
"""Return True if any keyword in *rule.pattern* appears in *text*.
|
||||||
|
|
||||||
|
Keywords are separated by ``|`` (pipe).
|
||||||
|
"""
|
||||||
|
if not text:
|
||||||
|
return False
|
||||||
|
keywords = [kw.strip() for kw in rule.pattern.split("|") if kw.strip()]
|
||||||
|
text_lower = text if rule.case_sensitive else text.lower()
|
||||||
|
return any((kw if rule.case_sensitive else kw.lower()) in text_lower for kw in keywords)
|
||||||
|
|
||||||
|
|
||||||
|
def _match_metadata(rule: ClassificationRule, metadata: dict[str, Any] | None) -> bool:
|
||||||
|
"""Return True if *rule.pattern* (``field=value``) matches *metadata*.
|
||||||
|
|
||||||
|
Pattern format: ``field_name=expected_value``.
|
||||||
|
"""
|
||||||
|
if not metadata:
|
||||||
|
return False
|
||||||
|
if "=" not in rule.pattern:
|
||||||
|
return False
|
||||||
|
field_name, expected_value = rule.pattern.split("=", 1)
|
||||||
|
actual = metadata.get(field_name.strip())
|
||||||
|
if actual is None:
|
||||||
|
return False
|
||||||
|
if rule.case_sensitive:
|
||||||
|
return str(actual) == expected_value.strip()
|
||||||
|
return str(actual).lower() == expected_value.strip().lower()
|
||||||
|
|
||||||
|
|
||||||
|
_MATCHERS: dict[str, tuple] = {
|
||||||
|
RULE_TYPE_FILENAME: (_match_filename, "filename"),
|
||||||
|
RULE_TYPE_CONTENT: (_match_content, "text"),
|
||||||
|
RULE_TYPE_METADATA: (_match_metadata, "metadata"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _evaluate_rule(
|
||||||
|
rule: ClassificationRule,
|
||||||
|
filename: str,
|
||||||
|
text: str,
|
||||||
|
metadata: dict[str, Any] | None,
|
||||||
|
) -> MatchedRule | None:
|
||||||
|
"""Evaluate a single rule against the document. Return a :class:`MatchedRule` on match."""
|
||||||
|
entry = _MATCHERS.get(rule.rule_type)
|
||||||
|
if entry is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
matcher, arg_key = entry
|
||||||
|
arg_map = {"filename": filename, "text": text, "metadata": metadata}
|
||||||
|
matched = matcher(rule, arg_map[arg_key])
|
||||||
|
|
||||||
|
if matched:
|
||||||
|
return MatchedRule(
|
||||||
|
rule_name=rule.name,
|
||||||
|
rule_type=rule.rule_type,
|
||||||
|
category=rule.category,
|
||||||
|
confidence=_CONFIDENCE_MAP.get(rule.rule_type, 50),
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Public API
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def classify_document(
|
||||||
|
filename: str = "",
|
||||||
|
text: str = "",
|
||||||
|
metadata: dict[str, Any] | None = None,
|
||||||
|
custom_rules: list[ClassificationRule] | None = None,
|
||||||
|
) -> ClassificationResult:
|
||||||
|
"""Classify a document by evaluating built-in and custom rules.
|
||||||
|
|
||||||
|
Rules are evaluated in priority order (highest first, then built-in before
|
||||||
|
custom for the same priority). The category with the most rule matches
|
||||||
|
wins; ties are broken by cumulative confidence.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
filename: Original filename of the document.
|
||||||
|
text: Extracted / OCR text of the document.
|
||||||
|
metadata: Previously-extracted AI metadata dict (e.g. from ``ai_metadata``).
|
||||||
|
custom_rules: Optional list of user-defined :class:`ClassificationRule` objects.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A :class:`ClassificationResult` with the best matching category,
|
||||||
|
overall confidence score, and the list of rules that fired.
|
||||||
|
"""
|
||||||
|
all_rules = list(BUILTIN_RULES)
|
||||||
|
if custom_rules:
|
||||||
|
all_rules.extend(custom_rules)
|
||||||
|
|
||||||
|
# Sort by priority descending (higher priority first)
|
||||||
|
all_rules.sort(key=lambda r: r.priority, reverse=True)
|
||||||
|
|
||||||
|
matches: list[MatchedRule] = []
|
||||||
|
for rule in all_rules:
|
||||||
|
result = _evaluate_rule(rule, filename, text, metadata)
|
||||||
|
if result is not None:
|
||||||
|
matches.append(result)
|
||||||
|
|
||||||
|
if not matches:
|
||||||
|
return ClassificationResult(category="unknown", confidence=0, matched_rules=[])
|
||||||
|
|
||||||
|
# Aggregate by category: pick the one with the most matches, then highest
|
||||||
|
# cumulative confidence as tiebreaker.
|
||||||
|
category_scores: dict[str, list[MatchedRule]] = {}
|
||||||
|
for m in matches:
|
||||||
|
category_scores.setdefault(m.category, []).append(m)
|
||||||
|
|
||||||
|
best_category = max(
|
||||||
|
category_scores,
|
||||||
|
key=lambda cat: (len(category_scores[cat]), sum(m.confidence for m in category_scores[cat])),
|
||||||
|
)
|
||||||
|
|
||||||
|
best_matches = category_scores[best_category]
|
||||||
|
base_confidence = max(m.confidence for m in best_matches)
|
||||||
|
bonus = min(
|
||||||
|
(len(best_matches) - 1) * _CONFIDENCE_BONUS_PER_EXTRA_RULE,
|
||||||
|
100 - base_confidence,
|
||||||
|
)
|
||||||
|
final_confidence = min(base_confidence + bonus, 100)
|
||||||
|
|
||||||
|
return ClassificationResult(
|
||||||
|
category=best_category,
|
||||||
|
confidence=final_confidence,
|
||||||
|
matched_rules=best_matches,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def db_rule_to_engine_rule(db_rule: Any) -> ClassificationRule:
|
||||||
|
"""Convert a database ``ClassificationRuleModel`` row to an engine :class:`ClassificationRule`.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db_rule: A SQLAlchemy model instance with ``name``, ``category``,
|
||||||
|
``rule_type``, ``pattern``, ``priority``, and ``case_sensitive`` attributes.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A :class:`ClassificationRule` dataclass instance.
|
||||||
|
"""
|
||||||
|
return ClassificationRule(
|
||||||
|
name=db_rule.name,
|
||||||
|
category=db_rule.category,
|
||||||
|
rule_type=db_rule.rule_type,
|
||||||
|
pattern=db_rule.pattern,
|
||||||
|
priority=db_rule.priority,
|
||||||
|
case_sensitive=getattr(db_rule, "case_sensitive", False),
|
||||||
|
)
|
||||||
@@ -27,8 +27,8 @@ def is_private_ip(hostname: str) -> bool:
|
|||||||
return True
|
return True
|
||||||
return False
|
return False
|
||||||
except (socket.gaierror, socket.error):
|
except (socket.gaierror, socket.error):
|
||||||
# Cannot resolve - allow for testing/development
|
# Cannot resolve.
|
||||||
# In production, DNS should work properly
|
# Fail securely: block unresolved domains to prevent DNS rebinding
|
||||||
# Log this for debugging
|
# and SSRF bypasses via unresolvable addresses.
|
||||||
logger.warning(f"Could not resolve hostname: {hostname}")
|
logger.warning(f"Could not resolve hostname (blocking securely): {hostname}")
|
||||||
return False # Changed from True to False to allow external domains in tests
|
return True
|
||||||
|
|||||||
@@ -99,6 +99,18 @@ SETTING_METADATA = {
|
|||||||
"required": True, # Required for OAuth redirects and external URLs
|
"required": True, # Required for OAuth redirects and external URLs
|
||||||
"restart_required": True,
|
"restart_required": True,
|
||||||
},
|
},
|
||||||
|
"public_base_url": {
|
||||||
|
"category": "Core",
|
||||||
|
"description": (
|
||||||
|
"Full public base URL including scheme (e.g., https://docuelevate.example.com). "
|
||||||
|
"When set, overrides auto-detected URLs for OAuth redirect URIs. "
|
||||||
|
"Required when behind a reverse proxy that does not forward X-Forwarded-Proto."
|
||||||
|
),
|
||||||
|
"type": "string",
|
||||||
|
"sensitive": False,
|
||||||
|
"required": False,
|
||||||
|
"restart_required": True,
|
||||||
|
},
|
||||||
"debug": {
|
"debug": {
|
||||||
"category": "Core",
|
"category": "Core",
|
||||||
"description": "Enable debug mode for verbose logging",
|
"description": "Enable debug mode for verbose logging",
|
||||||
@@ -194,6 +206,14 @@ SETTING_METADATA = {
|
|||||||
"required": False,
|
"required": False,
|
||||||
"restart_required": True,
|
"restart_required": True,
|
||||||
},
|
},
|
||||||
|
"qr_login_enabled": {
|
||||||
|
"category": "Authentication",
|
||||||
|
"description": "Enable QR code-based login for mobile device authentication.",
|
||||||
|
"type": "boolean",
|
||||||
|
"sensitive": False,
|
||||||
|
"required": False,
|
||||||
|
"restart_required": False,
|
||||||
|
},
|
||||||
"qr_login_challenge_ttl_seconds": {
|
"qr_login_challenge_ttl_seconds": {
|
||||||
"category": "Authentication",
|
"category": "Authentication",
|
||||||
"description": "Time-to-live in seconds for QR login challenges (default 120).",
|
"description": "Time-to-live in seconds for QR login challenges (default 120).",
|
||||||
@@ -250,6 +270,17 @@ SETTING_METADATA = {
|
|||||||
"required": False,
|
"required": False,
|
||||||
"restart_required": True,
|
"restart_required": True,
|
||||||
},
|
},
|
||||||
|
"sso_auto_login": {
|
||||||
|
"category": "Authentication",
|
||||||
|
"description": (
|
||||||
|
"Automatically redirect to SSO login when authentication is required. "
|
||||||
|
"Skips the login page and sends users directly to the configured SSO provider."
|
||||||
|
),
|
||||||
|
"type": "boolean",
|
||||||
|
"sensitive": False,
|
||||||
|
"required": False,
|
||||||
|
"restart_required": False,
|
||||||
|
},
|
||||||
# Social Login Providers
|
# Social Login Providers
|
||||||
"social_auth_google_enabled": {
|
"social_auth_google_enabled": {
|
||||||
"category": "Social Login",
|
"category": "Social Login",
|
||||||
@@ -280,6 +311,20 @@ SETTING_METADATA = {
|
|||||||
"required": False,
|
"required": False,
|
||||||
"restart_required": True,
|
"restart_required": True,
|
||||||
},
|
},
|
||||||
|
"social_auth_google_use_global_credentials": {
|
||||||
|
"category": "Social Login",
|
||||||
|
"description": (
|
||||||
|
"When True, Google social login uses the global GOOGLE_DRIVE_CLIENT_ID / "
|
||||||
|
"GOOGLE_DRIVE_CLIENT_SECRET credentials (the Google Drive OAuth integration) "
|
||||||
|
"instead of requiring separate SOCIAL_AUTH_GOOGLE_CLIENT_ID / "
|
||||||
|
"SOCIAL_AUTH_GOOGLE_CLIENT_SECRET values. "
|
||||||
|
"Requires SOCIAL_AUTH_GOOGLE_ENABLED=True and global Google Drive OAuth credentials to be set."
|
||||||
|
),
|
||||||
|
"type": "boolean",
|
||||||
|
"sensitive": False,
|
||||||
|
"required": False,
|
||||||
|
"restart_required": True,
|
||||||
|
},
|
||||||
"social_auth_microsoft_enabled": {
|
"social_auth_microsoft_enabled": {
|
||||||
"category": "Social Login",
|
"category": "Social Login",
|
||||||
"description": (
|
"description": (
|
||||||
@@ -322,6 +367,20 @@ SETTING_METADATA = {
|
|||||||
"required": False,
|
"required": False,
|
||||||
"restart_required": True,
|
"restart_required": True,
|
||||||
},
|
},
|
||||||
|
"social_auth_microsoft_use_global_credentials": {
|
||||||
|
"category": "Social Login",
|
||||||
|
"description": (
|
||||||
|
"When True, Microsoft social login uses the global ONEDRIVE_CLIENT_ID / "
|
||||||
|
"ONEDRIVE_CLIENT_SECRET credentials (the OneDrive integration credentials) "
|
||||||
|
"instead of requiring separate SOCIAL_AUTH_MICROSOFT_CLIENT_ID / "
|
||||||
|
"SOCIAL_AUTH_MICROSOFT_CLIENT_SECRET values. "
|
||||||
|
"Requires SOCIAL_AUTH_MICROSOFT_ENABLED=True and global OneDrive credentials to be set."
|
||||||
|
),
|
||||||
|
"type": "boolean",
|
||||||
|
"sensitive": False,
|
||||||
|
"required": False,
|
||||||
|
"restart_required": True,
|
||||||
|
},
|
||||||
"social_auth_apple_enabled": {
|
"social_auth_apple_enabled": {
|
||||||
"category": "Social Login",
|
"category": "Social Login",
|
||||||
"description": (
|
"description": (
|
||||||
@@ -410,6 +469,182 @@ SETTING_METADATA = {
|
|||||||
"required": False,
|
"required": False,
|
||||||
"restart_required": True,
|
"restart_required": True,
|
||||||
},
|
},
|
||||||
|
"social_auth_github_enabled": {
|
||||||
|
"category": "Social Login",
|
||||||
|
"description": (
|
||||||
|
"Enable GitHub Sign-In. Requires SOCIAL_AUTH_GITHUB_CLIENT_ID and "
|
||||||
|
"SOCIAL_AUTH_GITHUB_CLIENT_SECRET from GitHub Developer Settings."
|
||||||
|
),
|
||||||
|
"type": "boolean",
|
||||||
|
"sensitive": False,
|
||||||
|
"required": False,
|
||||||
|
"restart_required": True,
|
||||||
|
"help_link": "https://github.com/settings/developers",
|
||||||
|
"help_link_label": "GitHub Developer Settings",
|
||||||
|
},
|
||||||
|
"social_auth_github_client_id": {
|
||||||
|
"category": "Social Login",
|
||||||
|
"description": "GitHub OAuth2 client ID from GitHub Developer Settings.",
|
||||||
|
"type": "string",
|
||||||
|
"sensitive": False,
|
||||||
|
"required": False,
|
||||||
|
"restart_required": True,
|
||||||
|
},
|
||||||
|
"social_auth_github_client_secret": {
|
||||||
|
"category": "Social Login",
|
||||||
|
"description": "GitHub OAuth2 client secret from GitHub Developer Settings.",
|
||||||
|
"type": "string",
|
||||||
|
"sensitive": True,
|
||||||
|
"required": False,
|
||||||
|
"restart_required": True,
|
||||||
|
},
|
||||||
|
# Keycloak SSO
|
||||||
|
"social_auth_keycloak_enabled": {
|
||||||
|
"category": "Social Login",
|
||||||
|
"description": "Enable Keycloak SSO. Requires server URL, realm, client ID, and client secret.",
|
||||||
|
"type": "boolean",
|
||||||
|
"sensitive": False,
|
||||||
|
"required": False,
|
||||||
|
"restart_required": True,
|
||||||
|
},
|
||||||
|
"social_auth_keycloak_client_id": {
|
||||||
|
"category": "Social Login",
|
||||||
|
"description": "Keycloak OAuth2 client ID.",
|
||||||
|
"type": "string",
|
||||||
|
"sensitive": False,
|
||||||
|
"required": False,
|
||||||
|
"restart_required": True,
|
||||||
|
},
|
||||||
|
"social_auth_keycloak_client_secret": {
|
||||||
|
"category": "Social Login",
|
||||||
|
"description": "Keycloak OAuth2 client secret.",
|
||||||
|
"type": "string",
|
||||||
|
"sensitive": True,
|
||||||
|
"required": False,
|
||||||
|
"restart_required": True,
|
||||||
|
},
|
||||||
|
"social_auth_keycloak_server_url": {
|
||||||
|
"category": "Social Login",
|
||||||
|
"description": "Keycloak server base URL (e.g. https://keycloak.example.com).",
|
||||||
|
"type": "string",
|
||||||
|
"sensitive": False,
|
||||||
|
"required": False,
|
||||||
|
"restart_required": True,
|
||||||
|
},
|
||||||
|
"social_auth_keycloak_realm": {
|
||||||
|
"category": "Social Login",
|
||||||
|
"description": "Keycloak realm name.",
|
||||||
|
"type": "string",
|
||||||
|
"sensitive": False,
|
||||||
|
"required": False,
|
||||||
|
"restart_required": True,
|
||||||
|
},
|
||||||
|
# Generic OAuth2 SSO
|
||||||
|
"social_auth_generic_oauth2_enabled": {
|
||||||
|
"category": "Social Login",
|
||||||
|
"description": "Enable a generic OAuth2 SSO provider.",
|
||||||
|
"type": "boolean",
|
||||||
|
"sensitive": False,
|
||||||
|
"required": False,
|
||||||
|
"restart_required": True,
|
||||||
|
},
|
||||||
|
"social_auth_generic_oauth2_client_id": {
|
||||||
|
"category": "Social Login",
|
||||||
|
"description": "Generic OAuth2 client ID.",
|
||||||
|
"type": "string",
|
||||||
|
"sensitive": False,
|
||||||
|
"required": False,
|
||||||
|
"restart_required": True,
|
||||||
|
},
|
||||||
|
"social_auth_generic_oauth2_client_secret": {
|
||||||
|
"category": "Social Login",
|
||||||
|
"description": "Generic OAuth2 client secret.",
|
||||||
|
"type": "string",
|
||||||
|
"sensitive": True,
|
||||||
|
"required": False,
|
||||||
|
"restart_required": True,
|
||||||
|
},
|
||||||
|
"social_auth_generic_oauth2_authorize_url": {
|
||||||
|
"category": "Social Login",
|
||||||
|
"description": "Generic OAuth2 authorization URL.",
|
||||||
|
"type": "string",
|
||||||
|
"sensitive": False,
|
||||||
|
"required": False,
|
||||||
|
"restart_required": True,
|
||||||
|
},
|
||||||
|
"social_auth_generic_oauth2_token_url": {
|
||||||
|
"category": "Social Login",
|
||||||
|
"description": "Generic OAuth2 token endpoint URL.",
|
||||||
|
"type": "string",
|
||||||
|
"sensitive": False,
|
||||||
|
"required": False,
|
||||||
|
"restart_required": True,
|
||||||
|
},
|
||||||
|
"social_auth_generic_oauth2_userinfo_url": {
|
||||||
|
"category": "Social Login",
|
||||||
|
"description": "Generic OAuth2 userinfo endpoint URL.",
|
||||||
|
"type": "string",
|
||||||
|
"sensitive": False,
|
||||||
|
"required": False,
|
||||||
|
"restart_required": True,
|
||||||
|
},
|
||||||
|
"social_auth_generic_oauth2_scope": {
|
||||||
|
"category": "Social Login",
|
||||||
|
"description": "Space-separated list of OAuth2 scopes to request (default: openid profile email).",
|
||||||
|
"type": "string",
|
||||||
|
"sensitive": False,
|
||||||
|
"required": False,
|
||||||
|
"restart_required": True,
|
||||||
|
},
|
||||||
|
"social_auth_generic_oauth2_name": {
|
||||||
|
"category": "Social Login",
|
||||||
|
"description": "Display name for the generic OAuth2 provider button on the login page.",
|
||||||
|
"type": "string",
|
||||||
|
"sensitive": False,
|
||||||
|
"required": False,
|
||||||
|
"restart_required": True,
|
||||||
|
},
|
||||||
|
# SAML2 SSO
|
||||||
|
"social_auth_saml2_enabled": {
|
||||||
|
"category": "Social Login",
|
||||||
|
"description": "Enable SAML2 SSO authentication.",
|
||||||
|
"type": "boolean",
|
||||||
|
"sensitive": False,
|
||||||
|
"required": False,
|
||||||
|
"restart_required": True,
|
||||||
|
},
|
||||||
|
"social_auth_saml2_entity_id": {
|
||||||
|
"category": "Social Login",
|
||||||
|
"description": "SAML2 Identity Provider Entity ID.",
|
||||||
|
"type": "string",
|
||||||
|
"sensitive": False,
|
||||||
|
"required": False,
|
||||||
|
"restart_required": True,
|
||||||
|
},
|
||||||
|
"social_auth_saml2_sso_url": {
|
||||||
|
"category": "Social Login",
|
||||||
|
"description": "SAML2 Identity Provider SSO URL.",
|
||||||
|
"type": "string",
|
||||||
|
"sensitive": False,
|
||||||
|
"required": False,
|
||||||
|
"restart_required": True,
|
||||||
|
},
|
||||||
|
"social_auth_saml2_certificate": {
|
||||||
|
"category": "Social Login",
|
||||||
|
"description": "SAML2 Identity Provider X.509 certificate (PEM format).",
|
||||||
|
"type": "string",
|
||||||
|
"sensitive": True,
|
||||||
|
"required": False,
|
||||||
|
"restart_required": True,
|
||||||
|
},
|
||||||
|
"social_auth_saml2_name": {
|
||||||
|
"category": "Social Login",
|
||||||
|
"description": "Display name for the SAML2 provider.",
|
||||||
|
"type": "string",
|
||||||
|
"sensitive": False,
|
||||||
|
"required": False,
|
||||||
|
"restart_required": True,
|
||||||
|
},
|
||||||
# AI Services
|
# AI Services
|
||||||
"openai_api_key": {
|
"openai_api_key": {
|
||||||
"category": "AI Services",
|
"category": "AI Services",
|
||||||
@@ -1940,6 +2175,30 @@ SETTING_METADATA = {
|
|||||||
"required": False,
|
"required": False,
|
||||||
"restart_required": False,
|
"restart_required": False,
|
||||||
},
|
},
|
||||||
|
"telegram_enabled": {
|
||||||
|
"category": "Notifications",
|
||||||
|
"description": "Enable Telegram bot notifications.",
|
||||||
|
"type": "boolean",
|
||||||
|
"sensitive": False,
|
||||||
|
"required": False,
|
||||||
|
"restart_required": False,
|
||||||
|
},
|
||||||
|
"telegram_bot_token": {
|
||||||
|
"category": "Notifications",
|
||||||
|
"description": "Telegram Bot API token from @BotFather.",
|
||||||
|
"type": "string",
|
||||||
|
"sensitive": True,
|
||||||
|
"required": False,
|
||||||
|
"restart_required": False,
|
||||||
|
},
|
||||||
|
"telegram_chat_id": {
|
||||||
|
"category": "Notifications",
|
||||||
|
"description": "Telegram chat ID to send notifications to.",
|
||||||
|
"type": "string",
|
||||||
|
"sensitive": False,
|
||||||
|
"required": False,
|
||||||
|
"restart_required": False,
|
||||||
|
},
|
||||||
# Notifications Settings
|
# Notifications Settings
|
||||||
"notification_urls": {
|
"notification_urls": {
|
||||||
"category": "Notifications",
|
"category": "Notifications",
|
||||||
@@ -2038,6 +2297,18 @@ SETTING_METADATA = {
|
|||||||
"required": False,
|
"required": False,
|
||||||
"restart_required": False,
|
"restart_required": False,
|
||||||
},
|
},
|
||||||
|
"automation_hooks_enabled": {
|
||||||
|
"category": "Feature Flags",
|
||||||
|
"description": (
|
||||||
|
"Enable Zapier / Make.com automation hook subscriptions and delivery. "
|
||||||
|
"When enabled, external automation platforms can subscribe to DocuElevate events "
|
||||||
|
"via the REST hooks protocol. Default: True."
|
||||||
|
),
|
||||||
|
"type": "boolean",
|
||||||
|
"sensitive": False,
|
||||||
|
"required": False,
|
||||||
|
"restart_required": False,
|
||||||
|
},
|
||||||
"compliance_enabled": {
|
"compliance_enabled": {
|
||||||
"category": "Feature Flags",
|
"category": "Feature Flags",
|
||||||
"description": (
|
"description": (
|
||||||
@@ -2961,6 +3232,43 @@ SETTING_METADATA = {
|
|||||||
"required": False,
|
"required": False,
|
||||||
"restart_required": True,
|
"restart_required": True,
|
||||||
},
|
},
|
||||||
|
"sentry_js_traces_sample_rate": {
|
||||||
|
"category": "Observability",
|
||||||
|
"description": (
|
||||||
|
"Fraction of browser page-loads captured for client-side Sentry performance tracing (0.0–1.0). "
|
||||||
|
"0.0 (default) disables browser tracing; 1.0 captures every navigation. "
|
||||||
|
"Only active when SENTRY_DSN is set."
|
||||||
|
),
|
||||||
|
"type": "float",
|
||||||
|
"sensitive": False,
|
||||||
|
"required": False,
|
||||||
|
"restart_required": True,
|
||||||
|
},
|
||||||
|
"sentry_js_replay_session_sample_rate": {
|
||||||
|
"category": "Observability",
|
||||||
|
"description": (
|
||||||
|
"Fraction of sessions recorded by Sentry Session Replay (0.0–1.0). "
|
||||||
|
"0.0 (default) disables session recording; 1.0 records every session. "
|
||||||
|
"Only active when SENTRY_DSN is set."
|
||||||
|
),
|
||||||
|
"type": "float",
|
||||||
|
"sensitive": False,
|
||||||
|
"required": False,
|
||||||
|
"restart_required": True,
|
||||||
|
},
|
||||||
|
"sentry_js_replay_on_error_sample_rate": {
|
||||||
|
"category": "Observability",
|
||||||
|
"description": (
|
||||||
|
"Fraction of error sessions recorded by Sentry Session Replay (0.0–1.0). "
|
||||||
|
"Defaults to 0.1 (10%) so that errors are captured with replay context "
|
||||||
|
"even when session-level recording is disabled. "
|
||||||
|
"Only active when SENTRY_DSN is set."
|
||||||
|
),
|
||||||
|
"type": "float",
|
||||||
|
"sensitive": False,
|
||||||
|
"required": False,
|
||||||
|
"restart_required": True,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -71,6 +71,16 @@ def notify_settings_updated() -> None:
|
|||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.warning(f"Could not reload in-process settings: {exc}")
|
logger.warning(f"Could not reload in-process settings: {exc}")
|
||||||
|
|
||||||
|
# Re-register OAuth / social-login providers so that any provider whose
|
||||||
|
# credentials were just saved (or updated) in the database is active
|
||||||
|
# immediately on the login page — no restart required.
|
||||||
|
try:
|
||||||
|
from app.auth import refresh_social_providers
|
||||||
|
|
||||||
|
refresh_social_providers()
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning(f"Could not refresh social login providers after settings update: {exc}")
|
||||||
|
|
||||||
# Re-check OCR language availability in the background whenever settings
|
# Re-check OCR language availability in the background whenever settings
|
||||||
# are updated. This ensures that if a user changes tesseract_language or
|
# are updated. This ensures that if a user changes tesseract_language or
|
||||||
# easyocr_languages via the UI, the new language data is downloaded without
|
# easyocr_languages via the UI, the new language data is downloaded without
|
||||||
|
|||||||
+91
-5
@@ -11,14 +11,21 @@ import logging
|
|||||||
|
|
||||||
from fastapi import Request
|
from fastapi import Request
|
||||||
from sqlalchemy import or_
|
from sqlalchemy import or_
|
||||||
from sqlalchemy.orm import Query
|
from sqlalchemy.orm import Query, Session
|
||||||
from sqlalchemy.sql import false
|
from sqlalchemy.sql import false
|
||||||
|
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.models import FileRecord
|
from app.models import FILE_SHARE_ROLE_EDITOR, FILE_SHARE_ROLE_VIEWER, FileRecord, FileShare
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Role hierarchy: higher index = more rights
|
||||||
|
_ROLE_RANK: dict[str, int] = {
|
||||||
|
FILE_SHARE_ROLE_VIEWER: 1,
|
||||||
|
FILE_SHARE_ROLE_EDITOR: 2,
|
||||||
|
"owner": 3,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def _owner_id_from_user(user: dict) -> str | None:
|
def _owner_id_from_user(user: dict) -> str | None:
|
||||||
"""Extract the owner identifier from a user dict.
|
"""Extract the owner identifier from a user dict.
|
||||||
@@ -93,8 +100,9 @@ def apply_owner_filter(query: Query, request: Request) -> Query:
|
|||||||
"""Conditionally filter a ``FileRecord`` query by the current user.
|
"""Conditionally filter a ``FileRecord`` query by the current user.
|
||||||
|
|
||||||
When multi-user mode is enabled, only files whose ``owner_id``
|
When multi-user mode is enabled, only files whose ``owner_id``
|
||||||
matches the authenticated user are returned. Admin users bypass
|
matches the authenticated user are returned, **plus** any files that
|
||||||
the filter and see all documents.
|
have been explicitly shared with the user via ``FileShare``. Admin
|
||||||
|
users bypass the filter and see all documents.
|
||||||
|
|
||||||
When ``unowned_docs_visible_to_all`` is ``True`` (default), documents
|
When ``unowned_docs_visible_to_all`` is ``True`` (default), documents
|
||||||
with ``owner_id IS NULL`` (unclaimed) are also included for every
|
with ``owner_id IS NULL`` (unclaimed) are also included for every
|
||||||
@@ -122,11 +130,89 @@ def apply_owner_filter(query: Query, request: Request) -> Query:
|
|||||||
# No authenticated user — return empty result set
|
# No authenticated user — return empty result set
|
||||||
return query.filter(false())
|
return query.filter(false())
|
||||||
|
|
||||||
# Build filter: user's own documents
|
# Build filter: user's own documents + documents shared with them
|
||||||
conditions = [FileRecord.owner_id == owner_id]
|
conditions = [FileRecord.owner_id == owner_id]
|
||||||
|
|
||||||
|
# Include files explicitly shared with this user
|
||||||
|
from sqlalchemy import select as sa_select
|
||||||
|
|
||||||
|
conditions.append(FileRecord.id.in_(sa_select(FileShare.file_id).where(FileShare.shared_with_user_id == owner_id)))
|
||||||
|
|
||||||
# Optionally include unclaimed (owner_id IS NULL) documents
|
# Optionally include unclaimed (owner_id IS NULL) documents
|
||||||
if settings.unowned_docs_visible_to_all:
|
if settings.unowned_docs_visible_to_all:
|
||||||
conditions.append(FileRecord.owner_id.is_(None))
|
conditions.append(FileRecord.owner_id.is_(None))
|
||||||
|
|
||||||
return query.filter(or_(*conditions))
|
return query.filter(or_(*conditions))
|
||||||
|
|
||||||
|
|
||||||
|
def get_file_role(file_record: FileRecord, user_id: str | None, db: Session) -> str | None:
|
||||||
|
"""Return the effective role a user has on a ``FileRecord``.
|
||||||
|
|
||||||
|
Roles (in descending order of privilege):
|
||||||
|
|
||||||
|
``"owner"`` — the user's ``owner_id`` matches ``file_record.owner_id``,
|
||||||
|
or multi-user mode is disabled (everyone is effectively an
|
||||||
|
owner in single-user mode).
|
||||||
|
``"editor"`` — the user has an explicit ``FileShare`` with role=editor.
|
||||||
|
``"viewer"`` — the user has an explicit ``FileShare`` with role=viewer,
|
||||||
|
or the file is unclaimed (``owner_id IS NULL``) and
|
||||||
|
``unowned_docs_visible_to_all`` is True.
|
||||||
|
``None`` — no access.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
file_record: The ``FileRecord`` to check.
|
||||||
|
user_id: The stable identifier of the requesting user.
|
||||||
|
db: An active SQLAlchemy session.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
One of ``"owner"``, ``"editor"``, ``"viewer"``, or ``None``.
|
||||||
|
"""
|
||||||
|
if not settings.multi_user_enabled:
|
||||||
|
# Single-user mode: full access for everyone
|
||||||
|
return "owner"
|
||||||
|
|
||||||
|
if user_id is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Owner always has full access
|
||||||
|
if file_record.owner_id == user_id:
|
||||||
|
return "owner"
|
||||||
|
|
||||||
|
# Unclaimed document — limited access when setting allows it
|
||||||
|
if file_record.owner_id is None and settings.unowned_docs_visible_to_all:
|
||||||
|
return FILE_SHARE_ROLE_VIEWER
|
||||||
|
|
||||||
|
# Check for an explicit share
|
||||||
|
share = (
|
||||||
|
db.query(FileShare)
|
||||||
|
.filter(FileShare.file_id == file_record.id, FileShare.shared_with_user_id == user_id)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if share:
|
||||||
|
return share.role
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def has_file_role(
|
||||||
|
file_record: FileRecord,
|
||||||
|
user_id: str | None,
|
||||||
|
db: Session,
|
||||||
|
minimum_role: str = FILE_SHARE_ROLE_VIEWER,
|
||||||
|
) -> bool:
|
||||||
|
"""Return ``True`` if the user's effective role meets the minimum required.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
file_record: The document to check.
|
||||||
|
user_id: Requesting user's stable identifier.
|
||||||
|
db: Active SQLAlchemy session.
|
||||||
|
minimum_role: The minimum role required (``"viewer"``, ``"editor"``,
|
||||||
|
or ``"owner"``).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
``True`` when the user's role rank is >= the minimum rank.
|
||||||
|
"""
|
||||||
|
role = get_file_role(file_record, user_id, db)
|
||||||
|
if role is None:
|
||||||
|
return False
|
||||||
|
return _ROLE_RANK.get(role, 0) >= _ROLE_RANK.get(minimum_role, 0)
|
||||||
|
|||||||
+19
-10
@@ -145,6 +145,8 @@ def dispatch_webhook_event(event: str, data: dict[str, Any]) -> None:
|
|||||||
It delegates to :func:`deliver_webhook_task` (Celery) for each matching
|
It delegates to :func:`deliver_webhook_task` (Celery) for each matching
|
||||||
webhook so delivery happens asynchronously with automatic retries.
|
webhook so delivery happens asynchronously with automatic retries.
|
||||||
|
|
||||||
|
Also dispatches to automation hooks (Zapier / Make.com) if enabled.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
event: Event name (must be in :data:`VALID_EVENTS`).
|
event: Event name (must be in :data:`VALID_EVENTS`).
|
||||||
data: Event-specific payload data.
|
data: Event-specific payload data.
|
||||||
@@ -156,16 +158,23 @@ def dispatch_webhook_event(event: str, data: dict[str, Any]) -> None:
|
|||||||
webhooks = get_active_webhooks_for_event(event)
|
webhooks = get_active_webhooks_for_event(event)
|
||||||
if not webhooks:
|
if not webhooks:
|
||||||
logger.debug("No active webhooks for event %s", event)
|
logger.debug("No active webhooks for event %s", event)
|
||||||
return
|
else:
|
||||||
|
payload = build_payload(event, data)
|
||||||
|
|
||||||
payload = build_payload(event, data)
|
# Import here to avoid circular dependency with celery_app
|
||||||
|
from app.tasks.webhook_tasks import deliver_webhook_task
|
||||||
|
|
||||||
# Import here to avoid circular dependency with celery_app
|
for wh in webhooks:
|
||||||
from app.tasks.webhook_tasks import deliver_webhook_task
|
try:
|
||||||
|
deliver_webhook_task.delay(wh["url"], payload, wh["secret"])
|
||||||
|
logger.debug("Queued webhook delivery to %s for event %s", wh["url"], event)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error("Failed to queue webhook to %s: %s", wh["url"], exc)
|
||||||
|
|
||||||
for wh in webhooks:
|
# Also fan-out to Zapier / Make.com automation hooks
|
||||||
try:
|
try:
|
||||||
deliver_webhook_task.delay(wh["url"], payload, wh["secret"])
|
from app.utils.automation_hooks import dispatch_automation_hooks
|
||||||
logger.debug("Queued webhook delivery to %s for event %s", wh["url"], event)
|
|
||||||
except Exception as exc:
|
dispatch_automation_hooks(event, data)
|
||||||
logger.error("Failed to queue webhook to %s: %s", wh["url"], exc)
|
except Exception as exc:
|
||||||
|
logger.error("Failed to dispatch automation hooks for event %s: %s", event, exc)
|
||||||
|
|||||||
+44
-5
@@ -96,6 +96,21 @@ def _inject_global_context(ctx: dict) -> None:
|
|||||||
)
|
)
|
||||||
ctx.setdefault("enable_factory_reset", getattr(settings, "enable_factory_reset", 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")
|
req = ctx.get("request")
|
||||||
if req is not None:
|
if req is not None:
|
||||||
# CSRF token
|
# CSRF token
|
||||||
@@ -147,12 +162,36 @@ def _inject_global_context(ctx: dict) -> None:
|
|||||||
|
|
||||||
|
|
||||||
def template_response_with_version(*args, **kwargs):
|
def template_response_with_version(*args, **kwargs):
|
||||||
"""Wrapper for TemplateResponse to include version and CSRF token in all templates"""
|
"""Wrapper for TemplateResponse to include version and CSRF token in all templates.
|
||||||
# If context dict is provided, add version to it
|
|
||||||
if len(args) >= 2 and isinstance(args[1], dict):
|
Handles both old-style and new-style Starlette TemplateResponse calls:
|
||||||
_inject_global_context(args[1])
|
- Old-style (Starlette <1.0): TemplateResponse(name, {"request": req, ...}, ...)
|
||||||
elif "context" in kwargs and isinstance(kwargs["context"], dict):
|
- New-style (Starlette 1.0+): TemplateResponse(request, name, context={...}, ...)
|
||||||
|
"""
|
||||||
|
if len(args) >= 1 and isinstance(args[0], str):
|
||||||
|
# Old-style call: first positional arg is the template name (string).
|
||||||
|
# Convert to new-style: (request, name, context=..., ...)
|
||||||
|
name = args[0]
|
||||||
|
if len(args) >= 2 and isinstance(args[1], dict):
|
||||||
|
context = args[1]
|
||||||
|
# Old-style may have status_code as 3rd positional arg
|
||||||
|
if len(args) >= 3 and "status_code" not in kwargs:
|
||||||
|
kwargs["status_code"] = args[2]
|
||||||
|
else:
|
||||||
|
context = kwargs.pop("context", {})
|
||||||
|
request_obj = context.pop("request", None)
|
||||||
|
if request_obj is not None:
|
||||||
|
context["request"] = request_obj
|
||||||
|
_inject_global_context(context)
|
||||||
|
if request_obj is not None:
|
||||||
|
return original_template_response(request_obj, name, context=context, **kwargs)
|
||||||
|
return original_template_response(name, context=context, **kwargs)
|
||||||
|
|
||||||
|
# New-style call: (request, name, context=..., ...)
|
||||||
|
if "context" in kwargs and isinstance(kwargs["context"], dict):
|
||||||
_inject_global_context(kwargs["context"])
|
_inject_global_context(kwargs["context"])
|
||||||
|
elif len(args) >= 3 and isinstance(args[2], dict):
|
||||||
|
_inject_global_context(args[2])
|
||||||
return original_template_response(*args, **kwargs)
|
return original_template_response(*args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,19 @@ from app.views.base import APIRouter, Depends, get_db, require_login, settings,
|
|||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
def _get_dropbox_callback_url(request: Request) -> str:
|
||||||
|
"""Return the Dropbox OAuth callback URL.
|
||||||
|
|
||||||
|
Uses ``PUBLIC_BASE_URL`` when configured so that the redirect URI displayed
|
||||||
|
to the user (and registered in the Dropbox developer console) matches the
|
||||||
|
one used in the OAuth authorization request. Falls back to deriving the URL
|
||||||
|
from the incoming request when ``PUBLIC_BASE_URL`` is not set.
|
||||||
|
"""
|
||||||
|
if settings.public_base_url:
|
||||||
|
return settings.public_base_url.rstrip("/") + "/dropbox-callback"
|
||||||
|
return f"{request.url.scheme}://{request.url.netloc}/dropbox-callback"
|
||||||
|
|
||||||
|
|
||||||
@router.get("/dropbox-setup")
|
@router.get("/dropbox-setup")
|
||||||
@require_login
|
@require_login
|
||||||
async def dropbox_setup_page(
|
async def dropbox_setup_page(
|
||||||
@@ -30,6 +43,8 @@ async def dropbox_setup_page(
|
|||||||
path from the integration's existing config is pre-populated; global
|
path from the integration's existing config is pre-populated; global
|
||||||
admin credentials are never exposed in this mode.
|
admin credentials are never exposed in this mode.
|
||||||
"""
|
"""
|
||||||
|
callback_url = _get_dropbox_callback_url(request)
|
||||||
|
|
||||||
if integration_id is not None:
|
if integration_id is not None:
|
||||||
owner_id = get_current_owner_id(request)
|
owner_id = get_current_owner_id(request)
|
||||||
integration = (
|
integration = (
|
||||||
@@ -67,6 +82,7 @@ async def dropbox_setup_page(
|
|||||||
"app_secret_value": "",
|
"app_secret_value": "",
|
||||||
"refresh_token_value": "",
|
"refresh_token_value": "",
|
||||||
"global_creds_available": global_creds_available,
|
"global_creds_available": global_creds_available,
|
||||||
|
"callback_url": callback_url,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -86,6 +102,7 @@ async def dropbox_setup_page(
|
|||||||
"integration_id": integration_id,
|
"integration_id": integration_id,
|
||||||
"integration_name": None,
|
"integration_name": None,
|
||||||
"integration_type": None,
|
"integration_type": None,
|
||||||
|
"callback_url": callback_url,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -116,5 +133,6 @@ async def dropbox_callback(request: Request, code: str = None, error: str = None
|
|||||||
"app_key_value": "", # The callback will prioritize sessionStorage values
|
"app_key_value": "", # The callback will prioritize sessionStorage values
|
||||||
"app_secret_value": "", # The callback will prioritize sessionStorage values
|
"app_secret_value": "", # The callback will prioritize sessionStorage values
|
||||||
"folder_path": "", # The callback will prioritize sessionStorage values
|
"folder_path": "", # The callback will prioritize sessionStorage values
|
||||||
|
"callback_url": _get_dropbox_callback_url(request),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|||||||
+198
-7
@@ -19,6 +19,43 @@ router = APIRouter()
|
|||||||
_FILE_NOT_FOUND = "File not found"
|
_FILE_NOT_FOUND = "File not found"
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_owner_context(request: Request, file_record, db: Session) -> dict:
|
||||||
|
"""Return owner display info and the current user's effective role.
|
||||||
|
|
||||||
|
Returns a dict with:
|
||||||
|
- ``current_user_role``: one of "owner" / "editor" / "viewer" / None
|
||||||
|
- ``owner_display``: human-readable owner string (display_name or user_id)
|
||||||
|
- ``multi_user_enabled``: whether multi-user mode is active
|
||||||
|
"""
|
||||||
|
from app.config import settings
|
||||||
|
from app.models import UserProfile
|
||||||
|
from app.utils.user_scope import get_current_owner_id, get_file_role
|
||||||
|
|
||||||
|
multi_user_enabled = settings.multi_user_enabled
|
||||||
|
|
||||||
|
current_owner_id = get_current_owner_id(request)
|
||||||
|
user_session = request.session.get("user")
|
||||||
|
is_admin = isinstance(user_session, dict) and bool(user_session.get("is_admin"))
|
||||||
|
|
||||||
|
if is_admin:
|
||||||
|
current_user_role: str | None = "owner"
|
||||||
|
else:
|
||||||
|
current_user_role = get_file_role(file_record, current_owner_id, db)
|
||||||
|
|
||||||
|
# Build a human-readable owner label
|
||||||
|
if file_record.owner_id:
|
||||||
|
profile = db.query(UserProfile).filter(UserProfile.user_id == file_record.owner_id).first()
|
||||||
|
owner_display: str | None = profile.display_name if profile and profile.display_name else file_record.owner_id
|
||||||
|
else:
|
||||||
|
owner_display = None # No owner (unowned)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"current_user_role": current_user_role,
|
||||||
|
"owner_display": owner_display,
|
||||||
|
"multi_user_enabled": multi_user_enabled,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/files")
|
@router.get("/files")
|
||||||
@require_login
|
@require_login
|
||||||
def files_page(
|
def files_page(
|
||||||
@@ -206,10 +243,93 @@ def files_page(
|
|||||||
|
|
||||||
@router.get("/files/{file_id}")
|
@router.get("/files/{file_id}")
|
||||||
@require_login
|
@require_login
|
||||||
|
def file_summary_page(request: Request, file_id: int, db: Session = Depends(get_db)):
|
||||||
|
"""
|
||||||
|
Return the file summary page — a concise overview with links to detail, processing, and annotations views.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
|
||||||
|
from app.models import FileRecord
|
||||||
|
|
||||||
|
file_record = db.query(FileRecord).filter(FileRecord.id == file_id).first()
|
||||||
|
|
||||||
|
if not file_record:
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
"file_summary.html",
|
||||||
|
{"request": request, "file": None, "error": f"File with ID {file_id} not found"},
|
||||||
|
)
|
||||||
|
|
||||||
|
from app.config import settings
|
||||||
|
|
||||||
|
workdir = os.path.realpath(settings.workdir)
|
||||||
|
|
||||||
|
def _safe_exists(path: str | None) -> bool:
|
||||||
|
"""Return True only when *path* exists and resides within workdir."""
|
||||||
|
if not path:
|
||||||
|
return False
|
||||||
|
resolved = os.path.realpath(path)
|
||||||
|
try:
|
||||||
|
common = os.path.commonpath([resolved, workdir])
|
||||||
|
except ValueError:
|
||||||
|
return False
|
||||||
|
return common == workdir and os.path.exists(resolved)
|
||||||
|
|
||||||
|
original_file_exists = _safe_exists(file_record.original_file_path)
|
||||||
|
processed_file_exists = _safe_exists(file_record.processed_file_path)
|
||||||
|
|
||||||
|
# Load AI metadata — JSON sidecar file first, then DB column
|
||||||
|
gpt_metadata = None
|
||||||
|
if file_record.processed_file_path:
|
||||||
|
metadata_path = os.path.splitext(os.path.realpath(file_record.processed_file_path))[0] + ".json"
|
||||||
|
if _safe_exists(metadata_path):
|
||||||
|
try:
|
||||||
|
with open(metadata_path, "r", encoding="utf-8") as f:
|
||||||
|
gpt_metadata = json.load(f)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Failed to load metadata sidecar for file {file_id}: {e}")
|
||||||
|
|
||||||
|
if gpt_metadata is None and file_record.ai_metadata:
|
||||||
|
try:
|
||||||
|
gpt_metadata = json.loads(file_record.ai_metadata)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Failed to parse ai_metadata for file {file_id}: {e}")
|
||||||
|
|
||||||
|
# Quick processing status
|
||||||
|
try:
|
||||||
|
from app.utils.step_manager import get_step_summary as _get_step_summary
|
||||||
|
|
||||||
|
step_summary = _get_step_summary(db, file_id)
|
||||||
|
except Exception:
|
||||||
|
step_summary = None
|
||||||
|
|
||||||
|
pipeline_info = _resolve_pipeline(db, file_record)
|
||||||
|
owner_ctx = _resolve_owner_context(request, file_record, db)
|
||||||
|
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
"file_summary.html",
|
||||||
|
{
|
||||||
|
"request": request,
|
||||||
|
"file": file_record,
|
||||||
|
"gpt_metadata": gpt_metadata,
|
||||||
|
"original_file_exists": original_file_exists,
|
||||||
|
"processed_file_exists": processed_file_exists,
|
||||||
|
"step_summary": step_summary,
|
||||||
|
"pipeline_info": pipeline_info,
|
||||||
|
**owner_ctx,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error retrieving file summary {file_id}: {str(e)}")
|
||||||
|
return templates.TemplateResponse("file_summary.html", {"request": request, "file": None, "error": str(e)})
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/files/{file_id}/detail")
|
||||||
|
@require_login
|
||||||
def file_view_page(request: Request, file_id: int, db: Session = Depends(get_db)):
|
def file_view_page(request: Request, file_id: int, db: Session = Depends(get_db)):
|
||||||
"""
|
"""
|
||||||
Return the document view page — document-centric view with metadata, preview, and extracted text.
|
Return the document detail page — document-centric view with metadata, preview, and extracted text.
|
||||||
Process-oriented details are available via /files/{file_id}/detail.
|
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
import json
|
import json
|
||||||
@@ -272,6 +392,7 @@ def file_view_page(request: Request, file_id: int, db: Session = Depends(get_db)
|
|||||||
|
|
||||||
# Resolve the pipeline assigned to this file (explicit or system default)
|
# Resolve the pipeline assigned to this file (explicit or system default)
|
||||||
pipeline_info = _resolve_pipeline(db, file_record)
|
pipeline_info = _resolve_pipeline(db, file_record)
|
||||||
|
owner_ctx = _resolve_owner_context(request, file_record, db)
|
||||||
|
|
||||||
return templates.TemplateResponse(
|
return templates.TemplateResponse(
|
||||||
"file_view.html",
|
"file_view.html",
|
||||||
@@ -283,6 +404,7 @@ def file_view_page(request: Request, file_id: int, db: Session = Depends(get_db)
|
|||||||
"processed_file_exists": processed_file_exists,
|
"processed_file_exists": processed_file_exists,
|
||||||
"step_summary": step_summary,
|
"step_summary": step_summary,
|
||||||
"pipeline_info": pipeline_info,
|
"pipeline_info": pipeline_info,
|
||||||
|
**owner_ctx,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -290,11 +412,11 @@ def file_view_page(request: Request, file_id: int, db: Session = Depends(get_db)
|
|||||||
return templates.TemplateResponse("file_view.html", {"request": request, "file": None, "error": str(e)})
|
return templates.TemplateResponse("file_view.html", {"request": request, "file": None, "error": str(e)})
|
||||||
|
|
||||||
|
|
||||||
@router.get("/files/{file_id}/detail")
|
@router.get("/files/{file_id}/process")
|
||||||
@require_login
|
@require_login
|
||||||
def file_detail_page(request: Request, file_id: int, db: Session = Depends(get_db)):
|
def file_detail_page(request: Request, file_id: int, db: Session = Depends(get_db)):
|
||||||
"""
|
"""
|
||||||
Return the file detail page showing processing history and file information
|
Return the file processing page showing processing history and pipeline information.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
import json
|
import json
|
||||||
@@ -375,6 +497,77 @@ def file_detail_page(request: Request, file_id: int, db: Session = Depends(get_d
|
|||||||
return templates.TemplateResponse("file_detail.html", {"request": request, "file": None, "error": str(e)})
|
return templates.TemplateResponse("file_detail.html", {"request": request, "file": None, "error": str(e)})
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/files/{file_id}/annotations")
|
||||||
|
@require_login
|
||||||
|
def file_annotations_page(request: Request, file_id: int, db: Session = Depends(get_db)):
|
||||||
|
"""
|
||||||
|
Return the comments & annotations page for a file.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
import os
|
||||||
|
|
||||||
|
from app.models import FileRecord
|
||||||
|
|
||||||
|
file_record = db.query(FileRecord).filter(FileRecord.id == file_id).first()
|
||||||
|
|
||||||
|
if not file_record:
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
"file_annotations.html",
|
||||||
|
{"request": request, "file": None, "error": f"File with ID {file_id} not found"},
|
||||||
|
)
|
||||||
|
|
||||||
|
from app.config import settings
|
||||||
|
|
||||||
|
workdir = os.path.realpath(settings.workdir)
|
||||||
|
|
||||||
|
def _safe_exists(path: str | None) -> bool:
|
||||||
|
"""Return True only when *path* exists and resides within workdir."""
|
||||||
|
if not path:
|
||||||
|
return False
|
||||||
|
resolved = os.path.realpath(path)
|
||||||
|
try:
|
||||||
|
common = os.path.commonpath([resolved, workdir])
|
||||||
|
except ValueError:
|
||||||
|
return False
|
||||||
|
return common == workdir and os.path.exists(resolved)
|
||||||
|
|
||||||
|
original_file_exists = _safe_exists(file_record.original_file_path)
|
||||||
|
processed_file_exists = _safe_exists(file_record.processed_file_path)
|
||||||
|
|
||||||
|
# Determine whether the file is a PDF (for EmbedPDF viewer)
|
||||||
|
mime = file_record.mime_type or ""
|
||||||
|
is_pdf = mime == "application/pdf" or (file_record.original_filename or "").lower().endswith(".pdf")
|
||||||
|
|
||||||
|
# Determine the current user's role on this file (and owner display info)
|
||||||
|
owner_ctx = _resolve_owner_context(request, file_record, db)
|
||||||
|
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
"file_annotations.html",
|
||||||
|
{
|
||||||
|
"request": request,
|
||||||
|
"file": file_record,
|
||||||
|
"original_file_exists": original_file_exists,
|
||||||
|
"processed_file_exists": processed_file_exists,
|
||||||
|
"is_pdf": is_pdf,
|
||||||
|
**owner_ctx,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error retrieving annotations for file {file_id}: {str(e)}")
|
||||||
|
return templates.TemplateResponse("file_annotations.html", {"request": request, "file": None, "error": str(e)})
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/files/{file_id}/comments")
|
||||||
|
@require_login
|
||||||
|
def file_comments_redirect(request: Request, file_id: int):
|
||||||
|
"""
|
||||||
|
Redirect /files/{file_id}/comments to /files/{file_id}/annotations.
|
||||||
|
"""
|
||||||
|
from starlette.responses import RedirectResponse
|
||||||
|
|
||||||
|
return RedirectResponse(url=f"/files/{file_id}/annotations", status_code=302)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Pipeline ↔ Celery-log stage mapping
|
# Pipeline ↔ Celery-log stage mapping
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -396,9 +589,7 @@ _STEP_TYPE_TO_STAGES: dict[str, list[str]] = {
|
|||||||
"embed_metadata": ["embed_metadata_into_pdf"],
|
"embed_metadata": ["embed_metadata_into_pdf"],
|
||||||
"compute_embedding": ["compute_embedding"],
|
"compute_embedding": ["compute_embedding"],
|
||||||
"send_to_destinations": ["finalize_document_storage", "send_to_all_destinations"],
|
"send_to_destinations": ["finalize_document_storage", "send_to_all_destinations"],
|
||||||
# "classify" is defined in PIPELINE_STEP_TYPES but has no Celery log stages yet.
|
"classify": ["classify_document"],
|
||||||
# When a classify task is implemented, add its stage key(s) here.
|
|
||||||
"classify": [],
|
|
||||||
}
|
}
|
||||||
|
|
||||||
# These internal bookkeeping stages are always shown in the flow regardless of
|
# These internal bookkeeping stages are always shown in the flow regardless of
|
||||||
|
|||||||
@@ -45,6 +45,9 @@ async def google_drive_setup_page(
|
|||||||
except (json.JSONDecodeError, TypeError):
|
except (json.JSONDecodeError, TypeError):
|
||||||
cfg = {}
|
cfg = {}
|
||||||
folder_id = cfg.get("folder_id", "")
|
folder_id = cfg.get("folder_id", "")
|
||||||
|
# Provide system-wide OAuth credentials when available so users can
|
||||||
|
# authorize without registering their own Google Cloud app.
|
||||||
|
has_system_credentials = bool(settings.google_drive_client_id and settings.google_drive_client_secret)
|
||||||
return templates.TemplateResponse(
|
return templates.TemplateResponse(
|
||||||
"google_drive.html",
|
"google_drive.html",
|
||||||
{
|
{
|
||||||
@@ -58,10 +61,13 @@ async def google_drive_setup_page(
|
|||||||
"use_oauth": True,
|
"use_oauth": True,
|
||||||
"oauth_configured": bool(integration.credentials),
|
"oauth_configured": bool(integration.credentials),
|
||||||
"sa_configured": False,
|
"sa_configured": False,
|
||||||
"client_id": False,
|
"has_system_credentials": has_system_credentials,
|
||||||
"client_id_value": "",
|
"client_id": bool(settings.google_drive_client_id) if has_system_credentials else False,
|
||||||
"client_secret": False,
|
"client_id_value": (settings.google_drive_client_id or "" if has_system_credentials else ""),
|
||||||
"client_secret_value": "",
|
"client_secret": bool(settings.google_drive_client_secret) if has_system_credentials else False,
|
||||||
|
"client_secret_value": (
|
||||||
|
settings.google_drive_client_secret or "" if has_system_credentials else ""
|
||||||
|
),
|
||||||
"refresh_token": False,
|
"refresh_token": False,
|
||||||
"refresh_token_value": "",
|
"refresh_token_value": "",
|
||||||
"has_credentials_json": False,
|
"has_credentials_json": False,
|
||||||
@@ -90,6 +96,7 @@ async def google_drive_setup_page(
|
|||||||
"use_oauth": use_oauth,
|
"use_oauth": use_oauth,
|
||||||
"oauth_configured": oauth_configured,
|
"oauth_configured": oauth_configured,
|
||||||
"sa_configured": sa_configured,
|
"sa_configured": sa_configured,
|
||||||
|
"has_system_credentials": bool(settings.google_drive_client_id and settings.google_drive_client_secret),
|
||||||
"client_id": bool(settings.google_drive_client_id),
|
"client_id": bool(settings.google_drive_client_id),
|
||||||
"client_id_value": settings.google_drive_client_id or "",
|
"client_id_value": settings.google_drive_client_id or "",
|
||||||
"client_secret": bool(settings.google_drive_client_secret),
|
"client_secret": bool(settings.google_drive_client_secret),
|
||||||
|
|||||||
+10
-5
@@ -44,6 +44,9 @@ async def onedrive_setup_page(
|
|||||||
cfg = {}
|
cfg = {}
|
||||||
# Support both "folder_path" (WATCH_FOLDER / ONEDRIVE destination)
|
# Support both "folder_path" (WATCH_FOLDER / ONEDRIVE destination)
|
||||||
folder_path = cfg.get("folder_path", cfg.get("folder", ""))
|
folder_path = cfg.get("folder_path", cfg.get("folder", ""))
|
||||||
|
# Provide system-wide app credentials when available so users can
|
||||||
|
# authorize without registering their own Azure/OneDrive app.
|
||||||
|
has_system_credentials = bool(settings.onedrive_client_id and settings.onedrive_client_secret)
|
||||||
return templates.TemplateResponse(
|
return templates.TemplateResponse(
|
||||||
"onedrive.html",
|
"onedrive.html",
|
||||||
{
|
{
|
||||||
@@ -54,11 +57,12 @@ async def onedrive_setup_page(
|
|||||||
"integration_name": integration.name,
|
"integration_name": integration.name,
|
||||||
"integration_type": integration.integration_type,
|
"integration_type": integration.integration_type,
|
||||||
"folder_path": folder_path,
|
"folder_path": folder_path,
|
||||||
"client_id": False,
|
"has_system_credentials": has_system_credentials,
|
||||||
"client_id_value": "",
|
"client_id": bool(settings.onedrive_client_id) if has_system_credentials else False,
|
||||||
"client_secret": False,
|
"client_id_value": settings.onedrive_client_id or "" if has_system_credentials else "",
|
||||||
"client_secret_value": "",
|
"client_secret": bool(settings.onedrive_client_secret) if has_system_credentials else False,
|
||||||
"tenant_id": "common",
|
"client_secret_value": (settings.onedrive_client_secret or "" if has_system_credentials else ""),
|
||||||
|
"tenant_id": settings.onedrive_tenant_id or "common",
|
||||||
"refresh_token": False,
|
"refresh_token": False,
|
||||||
"refresh_token_value": "",
|
"refresh_token_value": "",
|
||||||
},
|
},
|
||||||
@@ -75,6 +79,7 @@ async def onedrive_setup_page(
|
|||||||
"request": request,
|
"request": request,
|
||||||
"user_mode": False,
|
"user_mode": False,
|
||||||
"is_configured": is_configured,
|
"is_configured": is_configured,
|
||||||
|
"has_system_credentials": bool(settings.onedrive_client_id and settings.onedrive_client_secret),
|
||||||
"client_id": bool(settings.onedrive_client_id),
|
"client_id": bool(settings.onedrive_client_id),
|
||||||
"client_id_value": settings.onedrive_client_id or "",
|
"client_id_value": settings.onedrive_client_id or "",
|
||||||
"client_secret": bool(settings.onedrive_client_secret),
|
"client_secret": bool(settings.onedrive_client_secret),
|
||||||
|
|||||||
@@ -195,6 +195,346 @@ async def credentials_page(request: Request, db: Session = Depends(get_db)):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/admin/connections")
|
||||||
|
@require_login
|
||||||
|
@require_admin_access
|
||||||
|
async def connections_page(request: Request, db: Session = Depends(get_db)):
|
||||||
|
"""
|
||||||
|
Connections management page - admin only.
|
||||||
|
|
||||||
|
Allows administrators to configure external authentication providers,
|
||||||
|
SSO settings, and service integrations through a wizard-like interface.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
db_settings = get_all_settings_from_db(db)
|
||||||
|
|
||||||
|
def _get_effective(key: str):
|
||||||
|
"""Return DB value if present, else fall back to settings attr."""
|
||||||
|
if key in db_settings and db_settings[key] is not None:
|
||||||
|
return db_settings[key]
|
||||||
|
return getattr(settings, key, None)
|
||||||
|
|
||||||
|
def _is_truthy(val) -> bool:
|
||||||
|
if isinstance(val, bool):
|
||||||
|
return val
|
||||||
|
if isinstance(val, str):
|
||||||
|
return val.lower() in ("true", "1", "yes")
|
||||||
|
return bool(val)
|
||||||
|
|
||||||
|
# Build service status list
|
||||||
|
services = []
|
||||||
|
|
||||||
|
# --- SSO (Authentik / OIDC) ---
|
||||||
|
_oidc_linked = bool(_get_effective("authentik_client_id") and _get_effective("authentik_client_secret"))
|
||||||
|
services.append(
|
||||||
|
{
|
||||||
|
"key": "oidc",
|
||||||
|
"name": _get_effective("oauth_provider_name") or "Single Sign-On",
|
||||||
|
"icon": "fas fa-lock",
|
||||||
|
"type": "SSO",
|
||||||
|
"linked": _oidc_linked,
|
||||||
|
"description": "OpenID Connect SSO provider",
|
||||||
|
"settings_keys": [
|
||||||
|
"authentik_client_id",
|
||||||
|
"authentik_client_secret",
|
||||||
|
"authentik_config_url",
|
||||||
|
"oauth_provider_name",
|
||||||
|
],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# --- Google ---
|
||||||
|
_google_id = _get_effective("social_auth_google_client_id")
|
||||||
|
_google_secret = _get_effective("social_auth_google_client_secret")
|
||||||
|
if _is_truthy(_get_effective("social_auth_google_use_global_credentials")) and not (
|
||||||
|
_google_id and _google_secret
|
||||||
|
):
|
||||||
|
_google_id = _google_id or _get_effective("google_drive_client_id")
|
||||||
|
_google_secret = _google_secret or _get_effective("google_drive_client_secret")
|
||||||
|
_google_linked = bool(
|
||||||
|
_is_truthy(_get_effective("social_auth_google_enabled")) and _google_id and _google_secret
|
||||||
|
)
|
||||||
|
services.append(
|
||||||
|
{
|
||||||
|
"key": "google",
|
||||||
|
"name": "Google",
|
||||||
|
"icon": "fab fa-google",
|
||||||
|
"type": "Sign-in authentication",
|
||||||
|
"linked": _google_linked,
|
||||||
|
"description": "Sign-in authentication",
|
||||||
|
"settings_keys": [
|
||||||
|
"social_auth_google_enabled",
|
||||||
|
"social_auth_google_client_id",
|
||||||
|
"social_auth_google_client_secret",
|
||||||
|
"social_auth_google_use_global_credentials",
|
||||||
|
],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# --- GitHub ---
|
||||||
|
_github_linked = bool(
|
||||||
|
_is_truthy(_get_effective("social_auth_github_enabled"))
|
||||||
|
and _get_effective("social_auth_github_client_id")
|
||||||
|
and _get_effective("social_auth_github_client_secret")
|
||||||
|
)
|
||||||
|
services.append(
|
||||||
|
{
|
||||||
|
"key": "github",
|
||||||
|
"name": "GitHub",
|
||||||
|
"icon": "fab fa-github",
|
||||||
|
"type": "Sign-in authentication",
|
||||||
|
"linked": _github_linked,
|
||||||
|
"description": "Sign-in authentication",
|
||||||
|
"settings_keys": [
|
||||||
|
"social_auth_github_enabled",
|
||||||
|
"social_auth_github_client_id",
|
||||||
|
"social_auth_github_client_secret",
|
||||||
|
],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# --- Microsoft ---
|
||||||
|
_ms_id = _get_effective("social_auth_microsoft_client_id")
|
||||||
|
_ms_secret = _get_effective("social_auth_microsoft_client_secret")
|
||||||
|
if _is_truthy(_get_effective("social_auth_microsoft_use_global_credentials")) and not (_ms_id and _ms_secret):
|
||||||
|
_ms_id = _ms_id or _get_effective("onedrive_client_id")
|
||||||
|
_ms_secret = _ms_secret or _get_effective("onedrive_client_secret")
|
||||||
|
_microsoft_linked = bool(_is_truthy(_get_effective("social_auth_microsoft_enabled")) and _ms_id and _ms_secret)
|
||||||
|
services.append(
|
||||||
|
{
|
||||||
|
"key": "microsoft",
|
||||||
|
"name": "Microsoft",
|
||||||
|
"icon": "fab fa-microsoft",
|
||||||
|
"type": "Sign-in authentication",
|
||||||
|
"linked": _microsoft_linked,
|
||||||
|
"description": "Sign-in authentication",
|
||||||
|
"settings_keys": [
|
||||||
|
"social_auth_microsoft_enabled",
|
||||||
|
"social_auth_microsoft_client_id",
|
||||||
|
"social_auth_microsoft_client_secret",
|
||||||
|
"social_auth_microsoft_tenant",
|
||||||
|
"social_auth_microsoft_use_global_credentials",
|
||||||
|
],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# --- Apple ---
|
||||||
|
_apple_linked = bool(
|
||||||
|
_is_truthy(_get_effective("social_auth_apple_enabled"))
|
||||||
|
and _get_effective("social_auth_apple_client_id")
|
||||||
|
and _get_effective("social_auth_apple_team_id")
|
||||||
|
)
|
||||||
|
services.append(
|
||||||
|
{
|
||||||
|
"key": "apple",
|
||||||
|
"name": "Apple",
|
||||||
|
"icon": "fab fa-apple",
|
||||||
|
"type": "Sign-in authentication",
|
||||||
|
"linked": _apple_linked,
|
||||||
|
"description": "Sign-in authentication",
|
||||||
|
"settings_keys": [
|
||||||
|
"social_auth_apple_enabled",
|
||||||
|
"social_auth_apple_client_id",
|
||||||
|
"social_auth_apple_team_id",
|
||||||
|
"social_auth_apple_key_id",
|
||||||
|
"social_auth_apple_private_key",
|
||||||
|
],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# --- Dropbox ---
|
||||||
|
_dbx_id = _get_effective("social_auth_dropbox_client_id")
|
||||||
|
_dbx_secret = _get_effective("social_auth_dropbox_client_secret")
|
||||||
|
if _is_truthy(_get_effective("social_auth_dropbox_use_global_credentials")) and not (_dbx_id and _dbx_secret):
|
||||||
|
_dbx_id = _dbx_id or _get_effective("dropbox_app_key")
|
||||||
|
_dbx_secret = _dbx_secret or _get_effective("dropbox_app_secret")
|
||||||
|
_dropbox_linked = bool(_is_truthy(_get_effective("social_auth_dropbox_enabled")) and _dbx_id and _dbx_secret)
|
||||||
|
services.append(
|
||||||
|
{
|
||||||
|
"key": "dropbox",
|
||||||
|
"name": "Dropbox",
|
||||||
|
"icon": "fab fa-dropbox",
|
||||||
|
"type": "Sign-in authentication",
|
||||||
|
"linked": _dropbox_linked,
|
||||||
|
"description": "Sign-in authentication",
|
||||||
|
"settings_keys": [
|
||||||
|
"social_auth_dropbox_enabled",
|
||||||
|
"social_auth_dropbox_client_id",
|
||||||
|
"social_auth_dropbox_client_secret",
|
||||||
|
"social_auth_dropbox_use_global_credentials",
|
||||||
|
],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# --- Keycloak ---
|
||||||
|
_keycloak_linked = bool(
|
||||||
|
_is_truthy(_get_effective("social_auth_keycloak_enabled"))
|
||||||
|
and _get_effective("social_auth_keycloak_client_id")
|
||||||
|
and _get_effective("social_auth_keycloak_client_secret")
|
||||||
|
and _get_effective("social_auth_keycloak_server_url")
|
||||||
|
and _get_effective("social_auth_keycloak_realm")
|
||||||
|
)
|
||||||
|
services.append(
|
||||||
|
{
|
||||||
|
"key": "keycloak",
|
||||||
|
"name": "Keycloak",
|
||||||
|
"icon": "fas fa-key",
|
||||||
|
"type": "SSO",
|
||||||
|
"linked": _keycloak_linked,
|
||||||
|
"description": "SSO",
|
||||||
|
"settings_keys": [
|
||||||
|
"social_auth_keycloak_enabled",
|
||||||
|
"social_auth_keycloak_client_id",
|
||||||
|
"social_auth_keycloak_client_secret",
|
||||||
|
"social_auth_keycloak_server_url",
|
||||||
|
"social_auth_keycloak_realm",
|
||||||
|
],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# --- Generic OAuth2 ---
|
||||||
|
_generic_oauth2_linked = bool(
|
||||||
|
_is_truthy(_get_effective("social_auth_generic_oauth2_enabled"))
|
||||||
|
and _get_effective("social_auth_generic_oauth2_client_id")
|
||||||
|
and _get_effective("social_auth_generic_oauth2_client_secret")
|
||||||
|
and _get_effective("social_auth_generic_oauth2_authorize_url")
|
||||||
|
and _get_effective("social_auth_generic_oauth2_token_url")
|
||||||
|
)
|
||||||
|
services.append(
|
||||||
|
{
|
||||||
|
"key": "generic_oauth2",
|
||||||
|
"name": "Generic OAuth2",
|
||||||
|
"icon": "fas fa-sign-in-alt",
|
||||||
|
"type": "SSO",
|
||||||
|
"linked": _generic_oauth2_linked,
|
||||||
|
"description": "SSO",
|
||||||
|
"settings_keys": [
|
||||||
|
"social_auth_generic_oauth2_enabled",
|
||||||
|
"social_auth_generic_oauth2_client_id",
|
||||||
|
"social_auth_generic_oauth2_client_secret",
|
||||||
|
"social_auth_generic_oauth2_authorize_url",
|
||||||
|
"social_auth_generic_oauth2_token_url",
|
||||||
|
"social_auth_generic_oauth2_userinfo_url",
|
||||||
|
"social_auth_generic_oauth2_scope",
|
||||||
|
"social_auth_generic_oauth2_name",
|
||||||
|
],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# --- SAML2 ---
|
||||||
|
_saml2_configured = bool(
|
||||||
|
_is_truthy(_get_effective("social_auth_saml2_enabled"))
|
||||||
|
and _get_effective("social_auth_saml2_sso_url")
|
||||||
|
and _get_effective("social_auth_saml2_entity_id")
|
||||||
|
)
|
||||||
|
services.append(
|
||||||
|
{
|
||||||
|
"key": "saml2",
|
||||||
|
"name": settings.social_auth_saml2_name or "SAML2",
|
||||||
|
"icon": "fas fa-id-badge",
|
||||||
|
"type": "SSO (SAML)",
|
||||||
|
"linked": _saml2_configured,
|
||||||
|
"description": "SSO (SAML)",
|
||||||
|
"settings_keys": [
|
||||||
|
"social_auth_saml2_enabled",
|
||||||
|
"social_auth_saml2_entity_id",
|
||||||
|
"social_auth_saml2_sso_url",
|
||||||
|
"social_auth_saml2_certificate",
|
||||||
|
"social_auth_saml2_name",
|
||||||
|
],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# --- SMTP Mail ---
|
||||||
|
_smtp_configured = bool(_get_effective("email_host") and _get_effective("email_username"))
|
||||||
|
services.append(
|
||||||
|
{
|
||||||
|
"key": "smtp",
|
||||||
|
"name": "SMTP Mail",
|
||||||
|
"icon": "fas fa-envelope",
|
||||||
|
"type": "Email Notifications",
|
||||||
|
"linked": _smtp_configured,
|
||||||
|
"description": "Email Notifications",
|
||||||
|
"settings_keys": [
|
||||||
|
"email_host",
|
||||||
|
"email_port",
|
||||||
|
"email_username",
|
||||||
|
"email_password",
|
||||||
|
"email_use_tls",
|
||||||
|
"email_sender",
|
||||||
|
],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# --- Telegram Bot ---
|
||||||
|
_telegram_configured = bool(
|
||||||
|
_is_truthy(_get_effective("telegram_enabled")) and _get_effective("telegram_bot_token")
|
||||||
|
)
|
||||||
|
services.append(
|
||||||
|
{
|
||||||
|
"key": "telegram",
|
||||||
|
"name": "Telegram Bot",
|
||||||
|
"icon": "fab fa-telegram",
|
||||||
|
"type": "Notifications",
|
||||||
|
"linked": _telegram_configured,
|
||||||
|
"description": "Configure Telegram bot connectivity, access controls, and feedback behavior.",
|
||||||
|
"settings_keys": [
|
||||||
|
"telegram_enabled",
|
||||||
|
"telegram_bot_token",
|
||||||
|
"telegram_chat_id",
|
||||||
|
],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Get setting details for the modal forms
|
||||||
|
service_settings = {}
|
||||||
|
for svc in services:
|
||||||
|
svc_settings = []
|
||||||
|
for skey in svc["settings_keys"]:
|
||||||
|
meta = get_setting_metadata(skey)
|
||||||
|
# Get current effective value
|
||||||
|
val = _get_effective(skey)
|
||||||
|
display_val = val
|
||||||
|
if meta.get("sensitive") and val:
|
||||||
|
display_val = mask_sensitive_value(val)
|
||||||
|
svc_settings.append(
|
||||||
|
{
|
||||||
|
"key": skey,
|
||||||
|
"value": val,
|
||||||
|
"display_value": display_val if display_val is not None else "",
|
||||||
|
"metadata": meta,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
service_settings[svc["key"]] = svc_settings
|
||||||
|
|
||||||
|
# Feature toggles
|
||||||
|
sso_auto_login = _is_truthy(_get_effective("sso_auto_login"))
|
||||||
|
qr_login_enabled = _is_truthy(_get_effective("qr_login_enabled"))
|
||||||
|
frontend_url_configured = bool(_get_effective("public_base_url"))
|
||||||
|
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
"admin_connections.html",
|
||||||
|
{
|
||||||
|
"request": request,
|
||||||
|
"services": services,
|
||||||
|
"service_settings": service_settings,
|
||||||
|
"sso_auto_login": sso_auto_login,
|
||||||
|
"oauth_configured": _oidc_linked,
|
||||||
|
"qr_login_enabled": qr_login_enabled,
|
||||||
|
"frontend_url_configured": frontend_url_configured,
|
||||||
|
"app_version": settings.version,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error loading connections page: {e}")
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail="Failed to load connections page",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/admin/settings/audit-log")
|
@router.get("/admin/settings/audit-log")
|
||||||
@require_login
|
@require_login
|
||||||
@require_admin_access
|
@require_admin_access
|
||||||
|
|||||||
+2
-1
@@ -23,6 +23,7 @@ templates = Jinja2Templates(directory=str(_templates_dir))
|
|||||||
async def shared_link_view(request: Request, token: str):
|
async def shared_link_view(request: Request, token: str):
|
||||||
"""Render the public share landing page for a given token."""
|
"""Render the public share landing page for a given token."""
|
||||||
return templates.TemplateResponse(
|
return templates.TemplateResponse(
|
||||||
|
request,
|
||||||
"shared_link_view.html",
|
"shared_link_view.html",
|
||||||
{"request": request, "token": token},
|
context={"token": token},
|
||||||
)
|
)
|
||||||
|
|||||||
+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
|
||||||
|
|||||||
+737
@@ -1404,6 +1404,55 @@ Get the current user's integration quota usage.
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Cloud Provider Folder Browser
|
||||||
|
|
||||||
|
Browse folders in connected cloud storage providers. These endpoints are used by the OAuth callback pages to let users select a target folder after authorization.
|
||||||
|
|
||||||
|
### POST /api/dropbox/list-folders
|
||||||
|
|
||||||
|
List folders in a Dropbox account. Requires a short-lived OAuth access token obtained during the authorization flow.
|
||||||
|
|
||||||
|
**Request (form-data):**
|
||||||
|
|
||||||
|
| Field | Type | Required | Description |
|
||||||
|
|----------------|--------|----------|--------------------------------------|
|
||||||
|
| `access_token` | string | Yes | Dropbox OAuth access token |
|
||||||
|
| `path` | string | No | Folder path to list (default: root) |
|
||||||
|
|
||||||
|
**Response (200):**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"folders": [
|
||||||
|
{ "name": "Documents", "path": "/Documents", "id": "id:abc123" },
|
||||||
|
{ "name": "Photos", "path": "/Photos", "id": "id:def456" }
|
||||||
|
],
|
||||||
|
"path": "/",
|
||||||
|
"has_more": false
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### POST /api/onedrive/list-folders
|
||||||
|
|
||||||
|
List folders in a OneDrive account. Requires a short-lived OAuth access token obtained during the authorization flow.
|
||||||
|
|
||||||
|
**Request (form-data):**
|
||||||
|
|
||||||
|
| Field | Type | Required | Description |
|
||||||
|
|----------------|--------|----------|--------------------------------------|
|
||||||
|
| `access_token` | string | Yes | Microsoft Graph access token |
|
||||||
|
| `path` | string | No | Folder path to list (default: root) |
|
||||||
|
|
||||||
|
**Response (200):**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"folders": [
|
||||||
|
{ "name": "Documents", "path": "/Documents", "id": "abc123", "child_count": 5 },
|
||||||
|
{ "name": "Pictures", "path": "/Pictures", "id": "def456", "child_count": 12 }
|
||||||
|
],
|
||||||
|
"path": "/"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
## Webhooks
|
## Webhooks
|
||||||
|
|
||||||
Manage webhook configurations for notifying external systems when document events occur. All webhook endpoints require admin access.
|
Manage webhook configurations for notifying external systems when document events occur. All webhook endpoints require admin access.
|
||||||
@@ -1594,6 +1643,47 @@ Lightweight endpoint returning the total number of queued + in-progress items. D
|
|||||||
|
|
||||||
## Diagnostic
|
## Diagnostic
|
||||||
|
|
||||||
|
### GET /api/diagnostic/healthz/live
|
||||||
|
|
||||||
|
Lightweight liveness probe for Kubernetes. Returns **200 OK** as long as the process is running. This endpoint does **not** check external dependencies and is intentionally cheap.
|
||||||
|
|
||||||
|
**Authentication:** None (designed for kubelet probes)
|
||||||
|
|
||||||
|
**Response (200 OK):**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "ok"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### GET /api/diagnostic/healthz/ready
|
||||||
|
|
||||||
|
Readiness probe for Kubernetes. Verifies that the application can serve traffic by checking database and Redis connectivity.
|
||||||
|
|
||||||
|
**Authentication:** None (designed for kubelet probes)
|
||||||
|
|
||||||
|
**Response (200 OK) – ready to serve traffic:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "ready",
|
||||||
|
"checks": {
|
||||||
|
"database": {"status": "ok"},
|
||||||
|
"redis": {"status": "ok"}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response (503 Service Unavailable) – database unreachable:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "not_ready",
|
||||||
|
"checks": {
|
||||||
|
"database": {"status": "error", "detail": "..."},
|
||||||
|
"redis": {"status": "ok"}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
### GET /api/diagnostic/health
|
### GET /api/diagnostic/health
|
||||||
|
|
||||||
System health endpoint designed for monitoring tools such as Grafana, Uptime Kuma, Prometheus blackbox exporter, or any HTTP-based health checker.
|
System health endpoint designed for monitoring tools such as Grafana, Uptime Kuma, Prometheus blackbox exporter, or any HTTP-based health checker.
|
||||||
@@ -2252,6 +2342,316 @@ print(response.json())
|
|||||||
```
|
```
|
||||||
|
|
||||||
|
|
||||||
|
## Classification Rules
|
||||||
|
|
||||||
|
The classification rules API lets you manage custom document classification rules. Rules are evaluated during the `classify` pipeline step to assign a category to each document based on filename patterns, content keywords, and metadata fields.
|
||||||
|
|
||||||
|
### Built-in Categories
|
||||||
|
|
||||||
|
```bash
|
||||||
|
GET /api/classification-rules/categories
|
||||||
|
```
|
||||||
|
|
||||||
|
Returns the pre-built classification categories.
|
||||||
|
|
||||||
|
**Response (200):**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"invoice": "Invoice",
|
||||||
|
"contract": "Contract",
|
||||||
|
"receipt": "Receipt",
|
||||||
|
"letter": "Letter",
|
||||||
|
"report": "Report",
|
||||||
|
"bank_statement": "Bank Statement",
|
||||||
|
"tax_document": "Tax Document",
|
||||||
|
"insurance": "Insurance Document",
|
||||||
|
"payslip": "Payslip",
|
||||||
|
"unknown": "Unknown"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Rule Types
|
||||||
|
|
||||||
|
```bash
|
||||||
|
GET /api/classification-rules/rule-types
|
||||||
|
```
|
||||||
|
|
||||||
|
Returns the supported rule types with descriptions.
|
||||||
|
|
||||||
|
**Response (200):**
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"type": "filename_pattern",
|
||||||
|
"label": "Filename Pattern",
|
||||||
|
"description": "Regex pattern matched against the original filename."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "content_keyword",
|
||||||
|
"label": "Content Keyword",
|
||||||
|
"description": "Pipe-separated keywords matched against the OCR text."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "metadata_match",
|
||||||
|
"label": "Metadata Match",
|
||||||
|
"description": "field=value pattern matched against existing AI metadata."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
### List Rules
|
||||||
|
|
||||||
|
```bash
|
||||||
|
GET /api/classification-rules/
|
||||||
|
```
|
||||||
|
|
||||||
|
List all classification rules visible to the current user (system rules + own rules).
|
||||||
|
|
||||||
|
**Response (200):**
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"owner_id": "user@example.com",
|
||||||
|
"name": "German Invoice Filename",
|
||||||
|
"category": "invoice",
|
||||||
|
"rule_type": "filename_pattern",
|
||||||
|
"pattern": "(?i)rechnung",
|
||||||
|
"priority": 10,
|
||||||
|
"case_sensitive": false,
|
||||||
|
"enabled": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
### Create Rule
|
||||||
|
|
||||||
|
```bash
|
||||||
|
POST /api/classification-rules/
|
||||||
|
```
|
||||||
|
|
||||||
|
Create a new custom classification rule.
|
||||||
|
|
||||||
|
**Request:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "German Invoice Filename",
|
||||||
|
"category": "invoice",
|
||||||
|
"rule_type": "filename_pattern",
|
||||||
|
"pattern": "(?i)rechnung",
|
||||||
|
"priority": 10,
|
||||||
|
"case_sensitive": false,
|
||||||
|
"enabled": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| Field | Type | Required | Description |
|
||||||
|
|-------|------|----------|-------------|
|
||||||
|
| `name` | string | Yes | Unique rule name (per user) |
|
||||||
|
| `category` | string | Yes | Target category (e.g. `invoice`, `contract`, or custom) |
|
||||||
|
| `rule_type` | string | Yes | One of: `filename_pattern`, `content_keyword`, `metadata_match` |
|
||||||
|
| `pattern` | string | Yes | Regex (filename), pipe-separated keywords (content), or `field=value` (metadata) |
|
||||||
|
| `priority` | integer | No | Higher priority rules are evaluated first (default: 0) |
|
||||||
|
| `case_sensitive` | boolean | No | Case-sensitive matching (default: false) |
|
||||||
|
| `enabled` | boolean | No | Whether the rule is active (default: true) |
|
||||||
|
|
||||||
|
**Response (201):**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"owner_id": "user@example.com",
|
||||||
|
"name": "German Invoice Filename",
|
||||||
|
"category": "invoice",
|
||||||
|
"rule_type": "filename_pattern",
|
||||||
|
"pattern": "(?i)rechnung",
|
||||||
|
"priority": 10,
|
||||||
|
"case_sensitive": false,
|
||||||
|
"enabled": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Get Rule
|
||||||
|
|
||||||
|
```bash
|
||||||
|
GET /api/classification-rules/{rule_id}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Update Rule
|
||||||
|
|
||||||
|
```bash
|
||||||
|
PUT /api/classification-rules/{rule_id}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Request (partial update):**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"priority": 20,
|
||||||
|
"enabled": false
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Delete Rule
|
||||||
|
|
||||||
|
```bash
|
||||||
|
DELETE /api/classification-rules/{rule_id}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:** `204 No Content`
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
## Automation (Zapier / Make.com)
|
||||||
|
|
||||||
|
Manage automation hook subscriptions for integrating DocuElevate with external platforms like Zapier and Make.com. All endpoints require API token authentication (`Authorization: Bearer <token>`).
|
||||||
|
|
||||||
|
### Supported Events
|
||||||
|
|
||||||
|
The automation system shares event types with the [Webhooks](#webhooks) subsystem:
|
||||||
|
|
||||||
|
| Event | Description |
|
||||||
|
|-------|-------------|
|
||||||
|
| `document.uploaded` | A new document has been ingested |
|
||||||
|
| `document.processed` | A document finished processing successfully |
|
||||||
|
| `document.failed` | Document processing failed |
|
||||||
|
| `user.signup` | A new user account was created |
|
||||||
|
| `user.plan_changed` | A user's subscription plan changed |
|
||||||
|
| `user.payment_issue` | A payment issue was reported for a user |
|
||||||
|
|
||||||
|
### GET /api/automation/events
|
||||||
|
|
||||||
|
List all valid event types that automation hooks can subscribe to.
|
||||||
|
|
||||||
|
**Response (200):**
|
||||||
|
```json
|
||||||
|
["document.failed", "document.processed", "document.uploaded", "user.payment_issue", "user.plan_changed", "user.signup"]
|
||||||
|
```
|
||||||
|
|
||||||
|
### POST /api/automation/hooks/subscribe
|
||||||
|
|
||||||
|
Subscribe to DocuElevate events. Zapier and Make.com call this endpoint to register a webhook URL that receives event notifications.
|
||||||
|
|
||||||
|
**Request:**
|
||||||
|
```bash
|
||||||
|
curl -X POST "http://your-instance/api/automation/hooks/subscribe" \
|
||||||
|
-H "Authorization: Bearer de_your_token_here" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"target_url": "https://hooks.zapier.com/hooks/catch/123456/abcdef/",
|
||||||
|
"events": ["document.processed", "document.uploaded"],
|
||||||
|
"hook_type": "zapier",
|
||||||
|
"secret": "optional-signing-secret",
|
||||||
|
"description": "My Zap for processed documents"
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response (201):**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"target_url": "https://hooks.zapier.com/hooks/catch/123456/abcdef/",
|
||||||
|
"events": ["document.processed", "document.uploaded"],
|
||||||
|
"is_active": true,
|
||||||
|
"hook_type": "zapier",
|
||||||
|
"description": "My Zap for processed documents",
|
||||||
|
"has_secret": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### GET /api/automation/hooks
|
||||||
|
|
||||||
|
List all automation hook subscriptions.
|
||||||
|
|
||||||
|
**Response (200):**
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"target_url": "https://hooks.zapier.com/hooks/catch/123456/abcdef/",
|
||||||
|
"events": ["document.processed", "document.uploaded"],
|
||||||
|
"is_active": true,
|
||||||
|
"hook_type": "zapier",
|
||||||
|
"description": "My Zap for processed documents",
|
||||||
|
"has_secret": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
### DELETE /api/automation/hooks/{hook_id}
|
||||||
|
|
||||||
|
Unsubscribe an automation hook. Zapier calls this when a Zap is turned off or deleted.
|
||||||
|
|
||||||
|
**Response (204):** No content.
|
||||||
|
|
||||||
|
### GET /api/automation/triggers/sample/{event}
|
||||||
|
|
||||||
|
Get sample trigger data for Zapier field mapping. Zapier uses this during Zap setup to discover available fields.
|
||||||
|
|
||||||
|
**Request:**
|
||||||
|
```bash
|
||||||
|
curl "http://your-instance/api/automation/triggers/sample/document.processed" \
|
||||||
|
-H "Authorization: Bearer de_your_token_here"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response (200):**
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"id": "evt_sample0002",
|
||||||
|
"event": "document.processed",
|
||||||
|
"timestamp": 1710000060.0,
|
||||||
|
"document_id": 42,
|
||||||
|
"filename": "invoice_2024.pdf",
|
||||||
|
"status": "processed",
|
||||||
|
"title": "Invoice #1234",
|
||||||
|
"owner_id": "user@example.com"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
### POST /api/automation/actions/upload
|
||||||
|
|
||||||
|
Upload a document from an automation platform. This incoming action endpoint allows Zapier or Make.com to push documents into DocuElevate for processing.
|
||||||
|
|
||||||
|
**Request:**
|
||||||
|
```bash
|
||||||
|
curl -X POST "http://your-instance/api/automation/actions/upload" \
|
||||||
|
-H "Authorization: Bearer de_your_token_here" \
|
||||||
|
-F "file=@/path/to/document.pdf"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response (200):**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "accepted",
|
||||||
|
"filename": "document.pdf",
|
||||||
|
"task_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Zapier-Compatible Payload Format
|
||||||
|
|
||||||
|
When events fire, automation hooks receive a **flat JSON payload** (no nested `data` key) that Zapier and Make.com can easily map:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "evt_a1b2c3d4e5f67890",
|
||||||
|
"event": "document.processed",
|
||||||
|
"timestamp": 1710000060.0,
|
||||||
|
"document_id": 42,
|
||||||
|
"filename": "invoice_2024.pdf",
|
||||||
|
"status": "processed",
|
||||||
|
"title": "Invoice #1234",
|
||||||
|
"owner_id": "user@example.com"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The `id` field is unique per event and is used by Zapier for deduplication. If a `secret` was provided during subscription, an `X-Webhook-Signature` header with an HMAC-SHA256 signature is included.
|
||||||
|
|
||||||
|
### Retry Behavior
|
||||||
|
|
||||||
|
Automation hook deliveries follow the same retry policy as regular webhooks: up to 3 retries with exponential backoff (60 s, 300 s, 900 s) and ±20% jitter.
|
||||||
|
|
||||||
|
|
||||||
## Further Assistance
|
## Further Assistance
|
||||||
|
|
||||||
For additional help with the API, please contact our support team or refer to the [Development Guide](../CONTRIBUTING.md).
|
For additional help with the API, please contact our support team or refer to the [Development Guide](../CONTRIBUTING.md).
|
||||||
@@ -2509,3 +2909,340 @@ Move original files to a reimport folder, wipe everything, and configure the rei
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Comments & Annotations
|
||||||
|
|
||||||
|
Threaded comments and PDF annotations for document collaboration.
|
||||||
|
|
||||||
|
### List Comments
|
||||||
|
|
||||||
|
**GET** `/api/files/{file_id}/comments`
|
||||||
|
|
||||||
|
Returns all comments for a document, organized into a threaded tree.
|
||||||
|
|
||||||
|
**Response (200):**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"file_id": 1,
|
||||||
|
"comments": [
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"file_id": 1,
|
||||||
|
"user_id": "alice",
|
||||||
|
"parent_id": null,
|
||||||
|
"body": "Please review section 3.",
|
||||||
|
"mentions": ["bob"],
|
||||||
|
"is_resolved": false,
|
||||||
|
"created_at": "2026-03-21T12:00:00+00:00",
|
||||||
|
"updated_at": "2026-03-21T12:00:00+00:00",
|
||||||
|
"replies": [
|
||||||
|
{
|
||||||
|
"id": 2,
|
||||||
|
"file_id": 1,
|
||||||
|
"user_id": "bob",
|
||||||
|
"parent_id": 1,
|
||||||
|
"body": "Done!",
|
||||||
|
"mentions": [],
|
||||||
|
"is_resolved": false,
|
||||||
|
"created_at": "2026-03-21T12:05:00+00:00",
|
||||||
|
"updated_at": "2026-03-21T12:05:00+00:00",
|
||||||
|
"replies": []
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"total": 2
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Create Comment
|
||||||
|
|
||||||
|
**POST** `/api/files/{file_id}/comments`
|
||||||
|
|
||||||
|
Create a new comment on a document. @mentions are automatically extracted from the body.
|
||||||
|
|
||||||
|
**Request:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"body": "Hey @bob, please review this section.",
|
||||||
|
"parent_id": null
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response (201):**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": 3,
|
||||||
|
"file_id": 1,
|
||||||
|
"user_id": "alice",
|
||||||
|
"parent_id": null,
|
||||||
|
"body": "Hey @bob, please review this section.",
|
||||||
|
"mentions": ["bob"],
|
||||||
|
"is_resolved": false,
|
||||||
|
"created_at": "2026-03-21T12:10:00+00:00",
|
||||||
|
"updated_at": "2026-03-21T12:10:00+00:00"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Update Comment
|
||||||
|
|
||||||
|
**PUT** `/api/files/{file_id}/comments/{comment_id}`
|
||||||
|
|
||||||
|
Update the body of an existing comment. Only the comment author may update it.
|
||||||
|
|
||||||
|
**Request:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"body": "Updated comment text @charlie"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Delete Comment
|
||||||
|
|
||||||
|
**DELETE** `/api/files/{file_id}/comments/{comment_id}`
|
||||||
|
|
||||||
|
Delete a comment. Only the comment author may delete it.
|
||||||
|
|
||||||
|
**Response:** `204 No Content`
|
||||||
|
|
||||||
|
### Resolve / Unresolve Comment
|
||||||
|
|
||||||
|
**PATCH** `/api/files/{file_id}/comments/{comment_id}/resolve`
|
||||||
|
|
||||||
|
Mark a comment thread as resolved or unresolved.
|
||||||
|
|
||||||
|
**Request:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"is_resolved": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### List Annotations
|
||||||
|
|
||||||
|
**GET** `/api/files/{file_id}/annotations`
|
||||||
|
|
||||||
|
Returns all PDF page annotations for a document, ordered by page then creation time.
|
||||||
|
|
||||||
|
**Response (200):**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"file_id": 1,
|
||||||
|
"annotations": [
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"file_id": 1,
|
||||||
|
"user_id": "alice",
|
||||||
|
"page": 1,
|
||||||
|
"x": 100.0,
|
||||||
|
"y": 200.0,
|
||||||
|
"width": 150.0,
|
||||||
|
"height": 20.0,
|
||||||
|
"content": "Important paragraph",
|
||||||
|
"annotation_type": "highlight",
|
||||||
|
"color": "#ffff00",
|
||||||
|
"created_at": "2026-03-21T12:00:00+00:00",
|
||||||
|
"updated_at": "2026-03-21T12:00:00+00:00"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"total": 1
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Create Annotation
|
||||||
|
|
||||||
|
**POST** `/api/files/{file_id}/annotations`
|
||||||
|
|
||||||
|
Create a new annotation on a PDF page.
|
||||||
|
|
||||||
|
**Request:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"page": 1,
|
||||||
|
"x": 100.0,
|
||||||
|
"y": 200.0,
|
||||||
|
"width": 150.0,
|
||||||
|
"height": 20.0,
|
||||||
|
"content": "Important paragraph",
|
||||||
|
"annotation_type": "highlight",
|
||||||
|
"color": "#ffff00"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Allowed `annotation_type` values: `note`, `highlight`, `underline`, `strikethrough`.
|
||||||
|
|
||||||
|
### Update Annotation
|
||||||
|
|
||||||
|
**PUT** `/api/files/{file_id}/annotations/{annotation_id}`
|
||||||
|
|
||||||
|
Update an existing annotation. Only the annotation author may update it.
|
||||||
|
|
||||||
|
**Request** (all fields optional):
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"content": "Updated note",
|
||||||
|
"color": "#00ff00"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Delete Annotation
|
||||||
|
|
||||||
|
**DELETE** `/api/files/{file_id}/annotations/{annotation_id}`
|
||||||
|
|
||||||
|
Delete an annotation. Only the annotation author may delete it.
|
||||||
|
|
||||||
|
**Response:** `204 No Content`
|
||||||
|
|
||||||
|
### List Mentionable Users
|
||||||
|
|
||||||
|
**GET** `/api/users/mentionable`
|
||||||
|
|
||||||
|
Returns all non-blocked user profiles for the @mention autocomplete.
|
||||||
|
|
||||||
|
**Response (200):**
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{ "user_id": "alice", "display_name": "Alice Anderson" },
|
||||||
|
{ "user_id": "bob", "display_name": "Bob Baker" }
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Sharing & Permissions
|
||||||
|
|
||||||
|
DocuElevate supports per-user document sharing with role-based access control.
|
||||||
|
|
||||||
|
### Roles
|
||||||
|
|
||||||
|
| Role | View | Comment / Annotate | Edit metadata | Delete | Share |
|
||||||
|
|----------|------|--------------------|---------------|--------|-------|
|
||||||
|
| `owner` | ✓ | ✓ | ✓ | ✓ | ✓ |
|
||||||
|
| `editor` | ✓ | ✓ | ✓ | ✗ | ✗ |
|
||||||
|
| `viewer` | ✓ | ✓ | ✗ | ✗ | ✗ |
|
||||||
|
|
||||||
|
- Only the **file owner** can share a document, change roles, or delete the document.
|
||||||
|
- When a user is **@mentioned** in a comment they are automatically granted `viewer` access to the document (multi-user mode only).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### List Shares
|
||||||
|
|
||||||
|
**GET** `/api/files/{file_id}/shares`
|
||||||
|
|
||||||
|
Returns all active shares for a document. Only the file owner (or an admin) may call this endpoint.
|
||||||
|
|
||||||
|
**Response (200):**
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"file_id": 42,
|
||||||
|
"owner_id": "alice",
|
||||||
|
"shared_with_user_id": "bob",
|
||||||
|
"role": "viewer",
|
||||||
|
"created_at": "2026-03-22T10:00:00+00:00",
|
||||||
|
"updated_at": "2026-03-22T10:00:00+00:00"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Error Responses:**
|
||||||
|
- `403`: Not the file owner
|
||||||
|
- `404`: File not found
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Create Share
|
||||||
|
|
||||||
|
**POST** `/api/files/{file_id}/shares`
|
||||||
|
|
||||||
|
Share a document with another user. Only the file owner may call this endpoint. If the user already has a share, their role is updated.
|
||||||
|
|
||||||
|
**Request:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"shared_with_user_id": "bob",
|
||||||
|
"role": "viewer"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`role` must be `"viewer"` (default) or `"editor"`.
|
||||||
|
|
||||||
|
**Response (201):**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"file_id": 42,
|
||||||
|
"owner_id": "alice",
|
||||||
|
"shared_with_user_id": "bob",
|
||||||
|
"role": "viewer",
|
||||||
|
"created_at": "2026-03-22T10:00:00+00:00",
|
||||||
|
"updated_at": "2026-03-22T10:00:00+00:00"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Error Responses:**
|
||||||
|
- `403`: Not the file owner
|
||||||
|
- `422`: Invalid role, empty user ID, or sharing with self
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Update Share Role
|
||||||
|
|
||||||
|
**PUT** `/api/files/{file_id}/shares/{share_id}`
|
||||||
|
|
||||||
|
Change the role of an existing share. Only the file owner may call this endpoint.
|
||||||
|
|
||||||
|
**Request:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"role": "editor"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response (200):** Updated share object.
|
||||||
|
|
||||||
|
**Error Responses:**
|
||||||
|
- `403`: Not the file owner
|
||||||
|
- `404`: Share not found
|
||||||
|
- `422`: Invalid role
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Revoke Share
|
||||||
|
|
||||||
|
**DELETE** `/api/files/{file_id}/shares/{share_id}`
|
||||||
|
|
||||||
|
Remove a user's access to a document. Only the file owner may revoke shares.
|
||||||
|
|
||||||
|
**Response (200):**
|
||||||
|
```json
|
||||||
|
{ "status": "success", "message": "Share revoked successfully" }
|
||||||
|
```
|
||||||
|
|
||||||
|
**Error Responses:**
|
||||||
|
- `403`: Not the file owner
|
||||||
|
- `404`: Share not found
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### List Shared With
|
||||||
|
|
||||||
|
**GET** `/api/files/{file_id}/shared-with`
|
||||||
|
|
||||||
|
Returns who a document is shared with. Accessible to any user with at least `viewer` access (owner, editors, and viewers can all call this).
|
||||||
|
|
||||||
|
**Response (200):**
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"share_id": 1,
|
||||||
|
"user_id": "bob",
|
||||||
|
"display_name": "Bob Baker",
|
||||||
|
"role": "viewer"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ This guide explains how to configure authentication for DocuElevate to secure yo
|
|||||||
| `AUTHENTIK_CLIENT_SECRET` | Client secret for OpenID Connect authentication |
|
| `AUTHENTIK_CLIENT_SECRET` | Client secret for OpenID Connect authentication |
|
||||||
| `AUTHENTIK_CONFIG_URL` | OpenID Connect discovery URL |
|
| `AUTHENTIK_CONFIG_URL` | OpenID Connect discovery URL |
|
||||||
| `OAUTH_PROVIDER_NAME` | Display name for the OAuth provider button |
|
| `OAUTH_PROVIDER_NAME` | Display name for the OAuth provider button |
|
||||||
|
| `SSO_AUTO_LOGIN` | Auto-redirect to SSO login (skips the login page) |
|
||||||
|
|
||||||
For a complete list of configuration options, see the [Configuration Guide](ConfigurationGuide.md).
|
For a complete list of configuration options, see the [Configuration Guide](ConfigurationGuide.md).
|
||||||
|
|
||||||
@@ -24,7 +25,12 @@ DocuElevate supports multiple authentication methods that can be used independen
|
|||||||
|
|
||||||
1. **Simple Authentication** - Basic username/password authentication managed by DocuElevate
|
1. **Simple Authentication** - Basic username/password authentication managed by DocuElevate
|
||||||
2. **OpenID Connect** - Integration with identity providers like Authentik, Keycloak, or Auth0
|
2. **OpenID Connect** - Integration with identity providers like Authentik, Keycloak, or Auth0
|
||||||
3. **Social Login** - Sign in with Google, Microsoft, Apple, or Dropbox accounts (see [Social Login Setup Guide](SocialLoginSetup.md))
|
3. **Social Login** - Sign in with Google, Microsoft, Apple, Dropbox, or GitHub accounts (see [Social Login Setup Guide](SocialLoginSetup.md))
|
||||||
|
4. **Keycloak SSO** - Self-hosted identity management via Keycloak
|
||||||
|
5. **Generic OAuth2** - Any OAuth2-compatible identity provider
|
||||||
|
6. **SAML2** - Enterprise SSO via SAML 2.0
|
||||||
|
|
||||||
|
> **Admin Connections Page:** You can configure all authentication providers through the admin **Connections** page at `/admin/connections`.
|
||||||
|
|
||||||
## Session Security
|
## Session Security
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ Configuration is primarily done through environment variables specified in a `.e
|
|||||||
| `WORKDIR` | Working directory for the application. | `/workdir` |
|
| `WORKDIR` | Working directory for the application. | `/workdir` |
|
||||||
| `GOTENBERG_URL` | Gotenberg PDF processing URL. | `http://gotenberg:3000` |
|
| `GOTENBERG_URL` | Gotenberg PDF processing URL. | `http://gotenberg:3000` |
|
||||||
| `EXTERNAL_HOSTNAME` | The external hostname for the application. | `docuelevate.example.com` |
|
| `EXTERNAL_HOSTNAME` | The external hostname for the application. | `docuelevate.example.com` |
|
||||||
|
| `PUBLIC_BASE_URL` | Full public base URL including scheme (e.g., `https://docuelevate.example.com`). When set, overrides auto-detected URLs used for OAuth redirect URIs. **Required when your reverse proxy does not forward `X-Forwarded-Proto` headers.** | *(not set)* |
|
||||||
| `ALLOW_FILE_DELETE` | Enable file deletion in the web interface (`true`/`false`). | `true` |
|
| `ALLOW_FILE_DELETE` | Enable file deletion in the web interface (`true`/`false`). | `true` |
|
||||||
| `COMPLIANCE_ENABLED` | Enable the compliance templates dashboard (GDPR, HIPAA, SOC 2). | `true` |
|
| `COMPLIANCE_ENABLED` | Enable the compliance templates dashboard (GDPR, HIPAA, SOC 2). | `true` |
|
||||||
| `FACTORY_RESET_ON_STARTUP` | Wipe all user data on every startup (demo/testing). | `false` |
|
| `FACTORY_RESET_ON_STARTUP` | Wipe all user data on every startup (demo/testing). | `false` |
|
||||||
@@ -405,7 +406,7 @@ Credentials are encrypted at rest using Fernet encryption.
|
|||||||
|
|
||||||
### Social Login Providers
|
### Social Login Providers
|
||||||
|
|
||||||
Social login lets users sign in with their existing Google, Microsoft, Apple, or Dropbox accounts. Each provider is independently enabled and configured. For detailed setup instructions see the [Social Login Setup Guide](SocialLoginSetup.md).
|
Social login lets users sign in with their existing Google, Microsoft, Apple, Dropbox, or GitHub accounts. Each provider is independently enabled and configured. For detailed setup instructions see the [Social Login Setup Guide](SocialLoginSetup.md).
|
||||||
|
|
||||||
| **Variable** | **Description** | **Default** |
|
| **Variable** | **Description** | **Default** |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
@@ -424,6 +425,33 @@ Social login lets users sign in with their existing Google, Microsoft, Apple, or
|
|||||||
| `SOCIAL_AUTH_DROPBOX_ENABLED` | Enable Dropbox Sign-In. | `false` |
|
| `SOCIAL_AUTH_DROPBOX_ENABLED` | Enable Dropbox Sign-In. | `false` |
|
||||||
| `SOCIAL_AUTH_DROPBOX_CLIENT_ID` | Dropbox OAuth2 App Key. | *(empty)* |
|
| `SOCIAL_AUTH_DROPBOX_CLIENT_ID` | Dropbox OAuth2 App Key. | *(empty)* |
|
||||||
| `SOCIAL_AUTH_DROPBOX_CLIENT_SECRET` | Dropbox OAuth2 App Secret. | *(empty)* |
|
| `SOCIAL_AUTH_DROPBOX_CLIENT_SECRET` | Dropbox OAuth2 App Secret. | *(empty)* |
|
||||||
|
| `SOCIAL_AUTH_GITHUB_ENABLED` | Enable GitHub Sign-In. | `false` |
|
||||||
|
| `SOCIAL_AUTH_GITHUB_CLIENT_ID` | GitHub OAuth2 client ID from GitHub Developer Settings. | *(empty)* |
|
||||||
|
| `SOCIAL_AUTH_GITHUB_CLIENT_SECRET` | GitHub OAuth2 client secret. | *(empty)* |
|
||||||
|
| `SSO_AUTO_LOGIN` | Automatically redirect to SSO login when authentication is required. | `false` |
|
||||||
|
|
||||||
|
### SSO Providers
|
||||||
|
|
||||||
|
| **Variable** | **Description** | **Default** |
|
||||||
|
|---|---|---|
|
||||||
|
| `SOCIAL_AUTH_KEYCLOAK_ENABLED` | Enable Keycloak SSO. | `false` |
|
||||||
|
| `SOCIAL_AUTH_KEYCLOAK_CLIENT_ID` | Keycloak OAuth2 client ID. | *(empty)* |
|
||||||
|
| `SOCIAL_AUTH_KEYCLOAK_CLIENT_SECRET` | Keycloak OAuth2 client secret. | *(empty)* |
|
||||||
|
| `SOCIAL_AUTH_KEYCLOAK_SERVER_URL` | Keycloak server base URL (e.g. `https://keycloak.example.com`). | *(empty)* |
|
||||||
|
| `SOCIAL_AUTH_KEYCLOAK_REALM` | Keycloak realm name. | *(empty)* |
|
||||||
|
| `SOCIAL_AUTH_GENERIC_OAUTH2_ENABLED` | Enable a generic OAuth2 SSO provider. | `false` |
|
||||||
|
| `SOCIAL_AUTH_GENERIC_OAUTH2_CLIENT_ID` | Generic OAuth2 client ID. | *(empty)* |
|
||||||
|
| `SOCIAL_AUTH_GENERIC_OAUTH2_CLIENT_SECRET` | Generic OAuth2 client secret. | *(empty)* |
|
||||||
|
| `SOCIAL_AUTH_GENERIC_OAUTH2_AUTHORIZE_URL` | Generic OAuth2 authorization URL. | *(empty)* |
|
||||||
|
| `SOCIAL_AUTH_GENERIC_OAUTH2_TOKEN_URL` | Generic OAuth2 token endpoint URL. | *(empty)* |
|
||||||
|
| `SOCIAL_AUTH_GENERIC_OAUTH2_USERINFO_URL` | Generic OAuth2 userinfo endpoint URL. | *(empty)* |
|
||||||
|
| `SOCIAL_AUTH_GENERIC_OAUTH2_SCOPE` | Space-separated list of OAuth2 scopes. | `openid profile email` |
|
||||||
|
| `SOCIAL_AUTH_GENERIC_OAUTH2_NAME` | Display name for the provider button. | `OAuth2` |
|
||||||
|
| `SOCIAL_AUTH_SAML2_ENABLED` | Enable SAML2 SSO authentication. | `false` |
|
||||||
|
| `SOCIAL_AUTH_SAML2_ENTITY_ID` | SAML2 Identity Provider Entity ID. | *(empty)* |
|
||||||
|
| `SOCIAL_AUTH_SAML2_SSO_URL` | SAML2 Identity Provider SSO URL. | *(empty)* |
|
||||||
|
| `SOCIAL_AUTH_SAML2_CERTIFICATE` | SAML2 Identity Provider X.509 certificate (PEM format). | *(empty)* |
|
||||||
|
| `SOCIAL_AUTH_SAML2_NAME` | Display name for the SAML2 provider. | `SAML2` |
|
||||||
|
|
||||||
### Multi-User Mode
|
### Multi-User Mode
|
||||||
|
|
||||||
@@ -764,7 +792,7 @@ SECURITY_HEADER_CSP_VALUE="default-src 'self'; script-src 'self'; style-src 'sel
|
|||||||
SECURITY_HEADER_CSP_VALUE="default-src 'self'; script-src 'self' https://cdn.example.com; style-src 'self' 'unsafe-inline';"
|
SECURITY_HEADER_CSP_VALUE="default-src 'self'; script-src 'self' https://cdn.example.com; style-src 'self' 'unsafe-inline';"
|
||||||
```
|
```
|
||||||
|
|
||||||
**Note:** The default policy includes `'unsafe-inline'` for compatibility with Tailwind CSS and inline JavaScript. For stricter security, use nonces or hashes.
|
**Note:** The default policy includes `'unsafe-inline'` for compatibility with inline JavaScript. Tailwind CSS v3 is compiled at build time into a static file served from `'self'`, so no external style CDN is needed.
|
||||||
|
|
||||||
#### X-Frame-Options
|
#### X-Frame-Options
|
||||||
|
|
||||||
@@ -1374,6 +1402,9 @@ For detailed setup instructions, see the [Amazon S3 Setup Guide](AmazonS3Setup.m
|
|||||||
| `NOTIFY_ON_USER_SIGNUP` | Send admin notification when a new user signs up (`True`/`False`, default `True`) |
|
| `NOTIFY_ON_USER_SIGNUP` | Send admin notification when a new user signs up (`True`/`False`, default `True`) |
|
||||||
| `NOTIFY_ON_PLAN_CHANGE` | Send admin notification when a user changes their subscription plan (`True`/`False`, default `True`) |
|
| `NOTIFY_ON_PLAN_CHANGE` | Send admin notification when a user changes their subscription plan (`True`/`False`, default `True`) |
|
||||||
| `NOTIFY_ON_PAYMENT_ISSUE` | Send admin notification when a payment issue is reported for a user (`True`/`False`, default `True`) |
|
| `NOTIFY_ON_PAYMENT_ISSUE` | Send admin notification when a payment issue is reported for a user (`True`/`False`, default `True`) |
|
||||||
|
| `TELEGRAM_ENABLED` | Enable Telegram bot notifications. | `false` |
|
||||||
|
| `TELEGRAM_BOT_TOKEN` | Telegram Bot API token from @BotFather. | *(empty)* |
|
||||||
|
| `TELEGRAM_CHAT_ID` | Telegram chat ID to send notifications to. | *(empty)* |
|
||||||
|
|
||||||
#### User-Event Notifications
|
#### User-Event Notifications
|
||||||
|
|
||||||
@@ -1453,6 +1484,26 @@ Configurations are stored in the database and managed through the API (see [API
|
|||||||
|
|
||||||
Webhook URLs, secrets, and subscribed events are configured per-webhook via the `/api/webhooks/` endpoints (admin access required). Each delivery includes an optional HMAC-SHA256 signature for verification and is retried with exponential backoff on failure.
|
Webhook URLs, secrets, and subscribed events are configured per-webhook via the `/api/webhooks/` endpoints (admin access required). Each delivery includes an optional HMAC-SHA256 signature for verification and is retried with exponential backoff on failure.
|
||||||
|
|
||||||
|
### Automation Hooks (Zapier / Make.com)
|
||||||
|
|
||||||
|
Automation hooks enable integration with external automation platforms such as
|
||||||
|
[Zapier](https://zapier.com) and [Make.com](https://make.com) (formerly Integromat).
|
||||||
|
|
||||||
|
| **Variable** | **Description** | **Default** |
|
||||||
|
|----------------------------|------------------------------------------------------------------------------------------------|-------------|
|
||||||
|
| `AUTOMATION_HOOKS_ENABLED` | Enable or disable Zapier / Make.com automation hook subscriptions and delivery (`True`/`False`) | `True` |
|
||||||
|
|
||||||
|
When enabled, external platforms can:
|
||||||
|
|
||||||
|
- **Subscribe** to DocuElevate events via `POST /api/automation/hooks/subscribe` (outgoing triggers)
|
||||||
|
- **Send documents** to DocuElevate via `POST /api/automation/actions/upload` (incoming actions)
|
||||||
|
- **Discover fields** via `GET /api/automation/triggers/sample/{event}` (Zapier field mapping)
|
||||||
|
|
||||||
|
Automation hooks share the same event types as webhooks (`document.uploaded`, `document.processed`,
|
||||||
|
`document.failed`, `user.signup`, `user.plan_changed`, `user.payment_issue`) and use a flat
|
||||||
|
Zapier-compatible JSON payload format. See the [API docs](API.md#automation-zapier--makecom) for
|
||||||
|
endpoint details and payload examples.
|
||||||
|
|
||||||
### Backup & Restore
|
### Backup & Restore
|
||||||
|
|
||||||
DocuElevate automatically backs up the database on a scheduled basis.
|
DocuElevate automatically backs up the database on a scheduled basis.
|
||||||
@@ -1554,6 +1605,8 @@ No additional configuration is required — the auto-fill uses the authenticated
|
|||||||
|
|
||||||
DocuElevate integrates with [Sentry](https://sentry.io) for real-time error tracking and performance monitoring. See [SentrySetup.md](./SentrySetup.md) for a full setup guide.
|
DocuElevate integrates with [Sentry](https://sentry.io) for real-time error tracking and performance monitoring. See [SentrySetup.md](./SentrySetup.md) for a full setup guide.
|
||||||
|
|
||||||
|
### Server-side (Python SDK)
|
||||||
|
|
||||||
| Variable | Description | Default |
|
| Variable | Description | Default |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `SENTRY_DSN` | Sentry DSN URL. When set, error reporting and performance tracing are enabled automatically. Leave blank to disable. | *(unset)* |
|
| `SENTRY_DSN` | Sentry DSN URL. When set, error reporting and performance tracing are enabled automatically. Leave blank to disable. | *(unset)* |
|
||||||
@@ -1562,18 +1615,33 @@ DocuElevate integrates with [Sentry](https://sentry.io) for real-time error trac
|
|||||||
| `SENTRY_PROFILES_SAMPLE_RATE` | Fraction of profiled transactions sent to Sentry (0.0 – 1.0). Only active when traces > 0. | `0.0` |
|
| `SENTRY_PROFILES_SAMPLE_RATE` | Fraction of profiled transactions sent to Sentry (0.0 – 1.0). Only active when traces > 0. | `0.0` |
|
||||||
| `SENTRY_SEND_DEFAULT_PII` | Attach PII (IP addresses, user agents) to Sentry events. Disabled by default for GDPR/CCPA compliance. | `false` |
|
| `SENTRY_SEND_DEFAULT_PII` | Attach PII (IP addresses, user agents) to Sentry events. Disabled by default for GDPR/CCPA compliance. | `false` |
|
||||||
|
|
||||||
|
### Browser SDK (JavaScript)
|
||||||
|
|
||||||
|
The Sentry Browser SDK is loaded automatically on every rendered page when `SENTRY_DSN` is set. The same DSN is used for both server and browser — the DSN is a *public* key in Sentry's security model and is intentionally embedded in client-side code.
|
||||||
|
|
||||||
|
| Variable | Description | Default |
|
||||||
|
|---|---|---|
|
||||||
|
| `SENTRY_JS_TRACES_SAMPLE_RATE` | Fraction of browser page-loads captured for client-side performance tracing (0.0 – 1.0). | `0.0` |
|
||||||
|
| `SENTRY_JS_REPLAY_SESSION_SAMPLE_RATE` | Fraction of sessions recorded by [Sentry Session Replay](https://docs.sentry.io/product/session-replay/) (0.0 – 1.0). | `0.0` |
|
||||||
|
| `SENTRY_JS_REPLAY_ON_ERROR_SAMPLE_RATE` | Fraction of error sessions captured with session replay context (0.0 – 1.0). | `0.1` |
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Minimal example
|
# Minimal example (server + browser)
|
||||||
SENTRY_DSN=https://<key>@o<org>.ingest.sentry.io/<project>
|
SENTRY_DSN=https://<key>@o<org>.ingest.sentry.io/<project>
|
||||||
SENTRY_ENVIRONMENT=production
|
SENTRY_ENVIRONMENT=production
|
||||||
|
|
||||||
# Optional tuning
|
# Optional server-side tuning
|
||||||
SENTRY_TRACES_SAMPLE_RATE=0.1
|
SENTRY_TRACES_SAMPLE_RATE=0.1
|
||||||
SENTRY_PROFILES_SAMPLE_RATE=0.0
|
SENTRY_PROFILES_SAMPLE_RATE=0.0
|
||||||
SENTRY_SEND_DEFAULT_PII=false
|
SENTRY_SEND_DEFAULT_PII=false
|
||||||
|
|
||||||
|
# Optional browser-side tuning
|
||||||
|
SENTRY_JS_TRACES_SAMPLE_RATE=0.1
|
||||||
|
SENTRY_JS_REPLAY_SESSION_SAMPLE_RATE=0.0
|
||||||
|
SENTRY_JS_REPLAY_ON_ERROR_SAMPLE_RATE=0.1
|
||||||
```
|
```
|
||||||
|
|
||||||
> **Note:** Sentry is completely opt-in — if `SENTRY_DSN` is not set, the SDK is never initialised and no data leaves your infrastructure.
|
> **Note:** Sentry is completely opt-in — if `SENTRY_DSN` is not set, neither SDK is initialised and no data leaves your infrastructure.
|
||||||
|
|
||||||
## Duplicate Document Detection
|
## Duplicate Document Detection
|
||||||
|
|
||||||
|
|||||||
+10
-6
@@ -349,16 +349,18 @@ workdir:
|
|||||||
|
|
||||||
## Scaling
|
## Scaling
|
||||||
|
|
||||||
|
DocuElevate is designed for horizontal scaling. Both API and worker pods are stateless and can be scaled independently.
|
||||||
|
|
||||||
### Docker Compose
|
### Docker Compose
|
||||||
|
|
||||||
Add more worker containers:
|
Scale workers (task processing) and API pods (request handling) independently:
|
||||||
|
|
||||||
```yaml
|
```bash
|
||||||
worker:
|
docker compose up -d --scale worker=3 --scale api=2
|
||||||
deploy:
|
|
||||||
replicas: 3
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
> **Note:** The `beat` service (Celery Beat scheduler) must always run as exactly **one** instance. Do not scale it. It publishes periodic tasks to the Redis broker; workers pick them up.
|
||||||
|
|
||||||
### Kubernetes / Helm
|
### Kubernetes / Helm
|
||||||
|
|
||||||
Enable HPA:
|
Enable HPA:
|
||||||
@@ -377,13 +379,15 @@ worker:
|
|||||||
maxReplicas: 10
|
maxReplicas: 10
|
||||||
```
|
```
|
||||||
|
|
||||||
|
The Helm chart deploys a separate **beat** pod (always 1 replica, `Recreate` strategy) so that scheduled tasks are never duplicated when workers scale.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Monitoring
|
## Monitoring
|
||||||
|
|
||||||
- **Docker Compose**: `docker-compose logs -f`, `docker stats`
|
- **Docker Compose**: `docker-compose logs -f`, `docker stats`
|
||||||
- **Kubernetes**: `kubectl logs -l app.kubernetes.io/component=api -f`
|
- **Kubernetes**: `kubectl logs -l app.kubernetes.io/component=api -f`
|
||||||
- **Prometheus / Grafana**: Scrape the `/api/health` endpoint for readiness; add custom metrics as needed.
|
- **Prometheus / Grafana**: Scrape the `/api/diagnostic/healthz/ready` endpoint for readiness; add custom metrics as needed.
|
||||||
- **Uptime Kuma**: Set `UPTIME_KUMA_URL` to your push URL for heartbeat monitoring.
|
- **Uptime Kuma**: Set `UPTIME_KUMA_URL` to your push URL for heartbeat monitoring.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
+28
-3
@@ -28,9 +28,10 @@ End users authorize their own Dropbox integration from the **Integrations** dash
|
|||||||
1. Navigate to `/integrations` and click **+ Add Destination** (or **+ Add Source** for Watch Folder).
|
1. Navigate to `/integrations` and click **+ Add Destination** (or **+ Add Source** for Watch Folder).
|
||||||
2. Create a Dropbox destination integration (or a Watch Folder with `source_type = dropbox`).
|
2. Create a Dropbox destination integration (or a Watch Folder with `source_type = dropbox`).
|
||||||
3. Click the **Authorize** button next to the integration — it links directly to the OAuth wizard pre-loaded with your integration's configuration.
|
3. Click the **Authorize** button next to the integration — it links directly to the OAuth wizard pre-loaded with your integration's configuration.
|
||||||
4. Enter your Dropbox App Key and App Secret in the wizard (or use the global admin credentials if pre-configured).
|
4. If the administrator has configured system-wide Dropbox app credentials (`DROPBOX_APP_KEY` / `DROPBOX_APP_SECRET`), the wizard defaults to using them — no need to register your own Dropbox app. Uncheck the toggle to use custom credentials if needed.
|
||||||
5. Click **Start Authentication Flow**, authorize access in Dropbox, and the refresh token is automatically saved to your personal integration record.
|
5. Click **Start Authentication Flow**, authorize access in Dropbox, and the refresh token is automatically saved to your personal integration record.
|
||||||
6. The page redirects back to `/integrations` on success. Re-authorization is available at any time via the **Re-Authorize** button.
|
6. After authorization, an interactive **folder browser** lets you select the target folder directly from your Dropbox — no need to manually type folder paths.
|
||||||
|
7. The page redirects to `/integrations` on success. Re-authorization is available at any time via the **Re-Authorize** button.
|
||||||
|
|
||||||
> **Note:** Your credentials are stored encrypted per-integration and are never mixed with other users' data. Each user can have multiple Dropbox integrations with independent tokens.
|
> **Note:** Your credentials are stored encrypted per-integration and are never mixed with other users' data. Each user can have multiple Dropbox integrations with independent tokens.
|
||||||
|
|
||||||
@@ -128,7 +129,31 @@ If you encounter issues with Dropbox integration:
|
|||||||
1. **Authentication Errors**: Make sure your App Key and App Secret are correct
|
1. **Authentication Errors**: Make sure your App Key and App Secret are correct
|
||||||
2. **Token Expired**: Click "Refresh Token" button on the setup page to obtain a new token
|
2. **Token Expired**: Click "Refresh Token" button on the setup page to obtain a new token
|
||||||
3. **Folder Permissions**: Ensure your app has the correct permissions enabled for file operations
|
3. **Folder Permissions**: Ensure your app has the correct permissions enabled for file operations
|
||||||
4. **Invalid Redirect URI**: Verify that the redirect URI in your app settings matches the one used in the authentication flow
|
4. **Invalid Redirect URI**: See section below for the most common cause and fix.
|
||||||
5. **Rate Limiting**: Dropbox API has rate limits; if exceeded, wait and try again
|
5. **Rate Limiting**: Dropbox API has rate limits; if exceeded, wait and try again
|
||||||
|
|
||||||
|
### Fixing "Invalid redirect_uri" Error
|
||||||
|
|
||||||
|
This error appears on the Dropbox authorization page when the redirect URI in the OAuth request does not match any URI registered in your Dropbox app console.
|
||||||
|
|
||||||
|
**Most common cause**: The application is deployed behind a reverse proxy (Traefik, Nginx, Caddy) that does **not** forward the `X-Forwarded-Proto: https` header to DocuElevate. Without this header, the server cannot determine that it is being accessed over HTTPS and may construct an `http://` redirect URI, while the registered URI in Dropbox is `https://`.
|
||||||
|
|
||||||
|
**Fix**:
|
||||||
|
|
||||||
|
Option 1 – Configure your proxy to forward `X-Forwarded-Proto`:
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
```
|
||||||
|
|
||||||
|
Option 2 – Set `PUBLIC_BASE_URL` in your environment (recommended for most deployments):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
PUBLIC_BASE_URL=https://docuelevate.example.com
|
||||||
|
```
|
||||||
|
|
||||||
|
When `PUBLIC_BASE_URL` is set, DocuElevate uses it directly for all OAuth redirect URIs instead of trying to infer the scheme from request headers. This is the most reliable option.
|
||||||
|
|
||||||
|
After setting `PUBLIC_BASE_URL`, ensure the Dropbox app console redirect URI matches exactly (e.g., `https://docuelevate.example.com/dropbox-callback`). The setup wizard at `/dropbox-setup` will show you the exact URI to register.
|
||||||
|
|
||||||
For more general configuration issues, see the [Configuration Troubleshooting Guide](ConfigurationTroubleshooting.md).
|
For more general configuration issues, see the [Configuration Troubleshooting Guide](ConfigurationTroubleshooting.md).
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ End users can authorize their own Google Drive integration directly from the **I
|
|||||||
1. Navigate to `/integrations` and click **+ Add Destination** (or **+ Add Source** for Watch Folder).
|
1. Navigate to `/integrations` and click **+ Add Destination** (or **+ Add Source** for Watch Folder).
|
||||||
2. Create a Google Drive destination integration (or a Watch Folder with `source_type = google_drive`).
|
2. Create a Google Drive destination integration (or a Watch Folder with `source_type = google_drive`).
|
||||||
3. Click the **Authorize** button — it opens the OAuth wizard pre-loaded with your integration's configuration.
|
3. Click the **Authorize** button — it opens the OAuth wizard pre-loaded with your integration's configuration.
|
||||||
4. Enter your Google OAuth Client ID and Client Secret in the wizard.
|
4. If the administrator has configured system-wide Google Drive app credentials (`GOOGLE_DRIVE_CLIENT_ID` / `GOOGLE_DRIVE_CLIENT_SECRET`), the wizard defaults to using them — no need to register your own Google Cloud app. Uncheck the toggle to use custom credentials if needed.
|
||||||
5. Click **Start Authentication Flow** and authorize access in Google.
|
5. Click **Start Authentication Flow** and authorize access in Google.
|
||||||
6. Credentials are saved automatically to your personal integration record; the page redirects back to `/integrations`.
|
6. Credentials are saved automatically to your personal integration record; the page redirects back to `/integrations`.
|
||||||
7. Re-authorization is available at any time via the **Re-Authorize** button.
|
7. Re-authorization is available at any time via the **Re-Authorize** button.
|
||||||
|
|||||||
@@ -373,6 +373,8 @@ worker:
|
|||||||
replicaCount: 4
|
replicaCount: 4
|
||||||
```
|
```
|
||||||
|
|
||||||
|
> **Beat scheduler:** The Helm chart deploys a dedicated `beat` pod (always exactly 1 replica with `Recreate` strategy) that publishes periodic tasks to the Redis broker. Workers consume these tasks — scaling workers does **not** duplicate scheduled jobs.
|
||||||
|
|
||||||
### Horizontal Pod Autoscaler
|
### Horizontal Pod Autoscaler
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
@@ -433,24 +435,30 @@ externalRedis:
|
|||||||
|
|
||||||
### Kubernetes Probes
|
### Kubernetes Probes
|
||||||
|
|
||||||
The Helm chart configures liveness and readiness probes on the API pods via `/api/health`. Default settings:
|
The Helm chart configures **unauthenticated** liveness and readiness probes on the API pods so kubelet can reach them without credentials. Default settings:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
api:
|
api:
|
||||||
livenessProbe:
|
livenessProbe:
|
||||||
httpGet:
|
httpGet:
|
||||||
path: /api/health
|
path: /api/diagnostic/healthz/live
|
||||||
port: 8000
|
port: 8000
|
||||||
initialDelaySeconds: 30
|
initialDelaySeconds: 30
|
||||||
periodSeconds: 30
|
periodSeconds: 20
|
||||||
readinessProbe:
|
readinessProbe:
|
||||||
httpGet:
|
httpGet:
|
||||||
path: /api/health
|
path: /api/diagnostic/healthz/ready
|
||||||
port: 8000
|
port: 8000
|
||||||
initialDelaySeconds: 10
|
initialDelaySeconds: 15
|
||||||
periodSeconds: 10
|
periodSeconds: 10
|
||||||
```
|
```
|
||||||
|
|
||||||
|
| Endpoint | Auth | Purpose |
|
||||||
|
|----------|------|---------|
|
||||||
|
| `/api/diagnostic/healthz/live` | None | Lightweight liveness check — returns 200 if the process is running |
|
||||||
|
| `/api/diagnostic/healthz/ready` | None | Readiness check — verifies database and Redis connectivity (503 when DB is down) |
|
||||||
|
| `/api/diagnostic/health` | Required | Full health status for monitoring dashboards (Grafana, Uptime Kuma) |
|
||||||
|
|
||||||
### Prometheus Scraping
|
### Prometheus Scraping
|
||||||
|
|
||||||
Add annotations to expose metrics (if using a Prometheus-compatible exporter):
|
Add annotations to expose metrics (if using a Prometheus-compatible exporter):
|
||||||
|
|||||||
+100
-8
@@ -12,9 +12,14 @@ DocuElevate includes a native mobile application for iOS and Android built with
|
|||||||
| Auto-generated API token | ✅ | ✅ |
|
| Auto-generated API token | ✅ | ✅ |
|
||||||
| Camera capture → upload | ✅ | ✅ |
|
| Camera capture → upload | ✅ | ✅ |
|
||||||
| File picker upload | ✅ | ✅ |
|
| File picker upload | ✅ | ✅ |
|
||||||
|
| Multi-image selection from library | ✅ | ✅ |
|
||||||
| Share Sheet / Share Intent | ✅ | ✅ |
|
| Share Sheet / Share Intent | ✅ | ✅ |
|
||||||
| Push notifications | ✅ | ✅ |
|
| Push notifications | ✅ | ✅ |
|
||||||
| Document list | ✅ | ✅ |
|
| Document list with search | ✅ | ✅ |
|
||||||
|
| File detail view with processing logs | ✅ | ✅ |
|
||||||
|
| Pre-login legal pages (GDPR) | ✅ | ✅ |
|
||||||
|
| Localization (EN, DE, ES, FR, IT) | ✅ | ✅ |
|
||||||
|
| Language selection | ✅ | ✅ |
|
||||||
| Dark mode | ✅ | ✅ |
|
| Dark mode | ✅ | ✅ |
|
||||||
|
|
||||||
## Getting Started (Development)
|
## Getting Started (Development)
|
||||||
@@ -171,8 +176,8 @@ curl -X DELETE -H "Authorization: Bearer <token>" https://your-server/api/mobile
|
|||||||
|
|
||||||
1. Open the **Upload** tab.
|
1. Open the **Upload** tab.
|
||||||
2. Tap **Photos**.
|
2. Tap **Photos**.
|
||||||
3. Select an existing photo from the device's photo library.
|
3. Select one or more photos from the device's photo library (multi-selection is supported).
|
||||||
4. The image is uploaded and queued for processing.
|
4. All selected images are uploaded and queued for processing.
|
||||||
|
|
||||||
### File Picker
|
### File Picker
|
||||||
|
|
||||||
@@ -241,6 +246,75 @@ If a file upload fails (e.g. due to network issues or a server error), the faile
|
|||||||
|
|
||||||
The retry re-uses the original file URI so no re-selection is needed.
|
The retry re-uses the original file URI so no re-selection is needed.
|
||||||
|
|
||||||
|
## Document Search
|
||||||
|
|
||||||
|
The **Files** tab includes a search bar at the top that lets users search through their processed documents by filename. Searches are debounced (400ms) to avoid excessive API calls. Clear the search with the ✕ button to return to the full list.
|
||||||
|
|
||||||
|
## File Detail View
|
||||||
|
|
||||||
|
Tapping any document in the **Files** tab opens a detail view showing:
|
||||||
|
|
||||||
|
- **File metadata**: filename, file size, MIME type, upload date, and file hash
|
||||||
|
- **Processing status**: current status with a colour-coded icon
|
||||||
|
- **Processing log**: chronological list of processing steps with individual status indicators and timestamps
|
||||||
|
|
||||||
|
Pull-to-refresh updates the detail view. This replicates the web interface at `/files/{id}` and `/files/{id}/detail` in a mobile-friendly layout.
|
||||||
|
|
||||||
|
## Legal & Compliance
|
||||||
|
|
||||||
|
### GDPR & Apple App Store Compliance
|
||||||
|
|
||||||
|
Privacy Policy, Terms of Service, and Imprint links are accessible **before login** from both the **Welcome Screen** and the **Login Screen**. This ensures compliance with:
|
||||||
|
|
||||||
|
- **GDPR** (General Data Protection Regulation) – users must be able to review the privacy policy before providing personal data
|
||||||
|
- **Apple App Store Review Guidelines** – apps must provide accessible privacy information before account creation
|
||||||
|
|
||||||
|
Post-login, the same links are available in the **Profile** tab under the "Legal" section.
|
||||||
|
|
||||||
|
## Localization (i18n)
|
||||||
|
|
||||||
|
The mobile app supports five languages with automatic device-locale detection:
|
||||||
|
|
||||||
|
| Language | Code | Status |
|
||||||
|
|----------|------|--------|
|
||||||
|
| English | `en` | ✅ Complete |
|
||||||
|
| German (Deutsch) | `de` | ✅ Complete |
|
||||||
|
| Spanish (Español) | `es` | ✅ Complete |
|
||||||
|
| French (Français) | `fr` | ✅ Complete |
|
||||||
|
| Italian (Italiano) | `it` | ✅ Complete |
|
||||||
|
|
||||||
|
### How it works
|
||||||
|
|
||||||
|
Language priority (highest to lowest):
|
||||||
|
|
||||||
|
1. **Server preference** — `preferred_language` returned by `GET /api/mobile/whoami` on login or app resume. Allows a language set on the desktop web interface to propagate to mobile automatically.
|
||||||
|
2. **AsyncStorage** — the last language explicitly selected on the device, used as an offline fallback when the server is unreachable.
|
||||||
|
3. **Device locale** — detected via `expo-localization` on first launch.
|
||||||
|
4. **English** — final fallback when none of the above match a supported locale.
|
||||||
|
|
||||||
|
When a user selects a language on mobile the choice is:
|
||||||
|
- Applied immediately to all screens (via `LocaleContext`)
|
||||||
|
- Persisted locally to AsyncStorage
|
||||||
|
- Synced to the server via `POST /api/i18n/language` (fire-and-forget), so the next desktop login reflects the same preference.
|
||||||
|
|
||||||
|
> **Note**: If the server's preferred language is not supported by the mobile app (e.g. a locale added to the web frontend but not yet translated for mobile), the mobile app falls back to the next priority in the list above.
|
||||||
|
|
||||||
|
### Adding a new language
|
||||||
|
|
||||||
|
1. Create a new translation file in `mobile/src/i18n/` (e.g. `pt.json` for Portuguese)
|
||||||
|
2. Copy the structure from `en.json` and translate all values
|
||||||
|
3. Import the new file in `mobile/src/i18n/index.ts`
|
||||||
|
4. Add it to the `translations` object and `getSupportedLanguages()` array
|
||||||
|
|
||||||
|
## User Settings
|
||||||
|
|
||||||
|
The **Profile** tab includes a **Settings** section where users can:
|
||||||
|
|
||||||
|
- **Change language**: Select from the supported languages (English, German, Spanish, French, Italian)
|
||||||
|
- View server connection details
|
||||||
|
- Access legal documents (Privacy Policy, Terms of Service, Imprint)
|
||||||
|
- Sign out or delete their account
|
||||||
|
|
||||||
## Mobile API Endpoints
|
## Mobile API Endpoints
|
||||||
|
|
||||||
The backend exposes a dedicated `/api/mobile/` namespace:
|
The backend exposes a dedicated `/api/mobile/` namespace:
|
||||||
@@ -251,7 +325,8 @@ The backend exposes a dedicated `/api/mobile/` namespace:
|
|||||||
| `POST` | `/api/mobile/register-device` | Bearer | Register Expo push token |
|
| `POST` | `/api/mobile/register-device` | Bearer | Register Expo push token |
|
||||||
| `GET` | `/api/mobile/devices` | Bearer | List registered devices |
|
| `GET` | `/api/mobile/devices` | Bearer | List registered devices |
|
||||||
| `DELETE` | `/api/mobile/devices/{id}` | Bearer | Deactivate a device |
|
| `DELETE` | `/api/mobile/devices/{id}` | Bearer | Deactivate a device |
|
||||||
| `GET` | `/api/mobile/whoami` | Bearer | Get current user profile |
|
| `GET` | `/api/mobile/whoami` | Bearer | Get current user profile (includes `preferred_language`) |
|
||||||
|
| `POST` | `/api/i18n/language` | Bearer | Sync language preference to server |
|
||||||
|
|
||||||
All other API endpoints (file upload, file listing, etc.) work with Bearer token authentication.
|
All other API endpoints (file upload, file listing, etc.) work with Bearer token authentication.
|
||||||
|
|
||||||
@@ -295,7 +370,7 @@ Re-registering the same token is safe (idempotent).
|
|||||||
|
|
||||||
### GET /api/mobile/whoami
|
### GET /api/mobile/whoami
|
||||||
|
|
||||||
Returns the current user's profile.
|
Returns the current user's profile, including the server-stored language preference.
|
||||||
|
|
||||||
**Response (200):**
|
**Response (200):**
|
||||||
```json
|
```json
|
||||||
@@ -304,10 +379,15 @@ Returns the current user's profile.
|
|||||||
"display_name": "John Doe",
|
"display_name": "John Doe",
|
||||||
"email": "john@example.com",
|
"email": "john@example.com",
|
||||||
"avatar_url": "https://www.gravatar.com/avatar/...",
|
"avatar_url": "https://www.gravatar.com/avatar/...",
|
||||||
"is_admin": false
|
"is_admin": false,
|
||||||
|
"preferred_language": "de"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`preferred_language` is `null` when no preference has been saved. The mobile
|
||||||
|
app applies this value on login / app resume, falling back to AsyncStorage and
|
||||||
|
then the device locale when it is `null` or unsupported.
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
No server-side configuration is required to enable the mobile app. The Expo push notification routing does not need FCM or APNs credentials on the server.
|
No server-side configuration is required to enable the mobile app. The Expo push notification routing does not need FCM or APNs credentials on the server.
|
||||||
@@ -346,8 +426,20 @@ mobile/
|
|||||||
│ ├── LoginScreen.tsx # Server URL + SSO button + QR code scanner
|
│ ├── LoginScreen.tsx # Server URL + SSO button + QR code scanner
|
||||||
│ ├── QRScannerScreen.tsx # Camera-based QR code scanner for login
|
│ ├── QRScannerScreen.tsx # Camera-based QR code scanner for login
|
||||||
│ ├── UploadScreen.tsx # Camera capture + photo library + file picker
|
│ ├── UploadScreen.tsx # Camera capture + photo library + file picker
|
||||||
│ ├── FilesScreen.tsx # Processed document list
|
│ ├── FilesScreen.tsx # Processed document list with search
|
||||||
│ └── ProfileScreen.tsx # User profile + sign out
|
│ ├── FileDetailScreen.tsx # File detail view with processing logs
|
||||||
|
│ ├── ProfileScreen.tsx # User profile + settings + sign out
|
||||||
|
│ └── WelcomeScreen.tsx # Pre-login welcome with legal links
|
||||||
|
├── i18n/ # Localization (i18n)
|
||||||
|
│ ├── index.ts # i18n module (locale detection, t() function)
|
||||||
|
│ ├── en.json # English translations
|
||||||
|
│ ├── de.json # German translations
|
||||||
|
│ ├── es.json # Spanish translations
|
||||||
|
│ ├── fr.json # French translations
|
||||||
|
│ └── it.json # Italian translations
|
||||||
|
├── utils/
|
||||||
|
│ ├── mimeTypes.ts # MIME type mapping for file extensions
|
||||||
|
│ └── normalizeUri.ts # URI normalization for deduplication
|
||||||
└── services/
|
└── services/
|
||||||
└── api.ts # DocuElevate REST API client
|
└── api.ts # DocuElevate REST API client
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -29,9 +29,10 @@ End users authorize their own OneDrive integration from the **Integrations** das
|
|||||||
1. Navigate to `/integrations` and click **+ Add Destination** (or **+ Add Source** for Watch Folder).
|
1. Navigate to `/integrations` and click **+ Add Destination** (or **+ Add Source** for Watch Folder).
|
||||||
2. Create a OneDrive destination integration (or a Watch Folder with `source_type = onedrive`).
|
2. Create a OneDrive destination integration (or a Watch Folder with `source_type = onedrive`).
|
||||||
3. Click the **Authorize** button next to the integration — it links directly to the OAuth wizard pre-loaded with your integration's configuration.
|
3. Click the **Authorize** button next to the integration — it links directly to the OAuth wizard pre-loaded with your integration's configuration.
|
||||||
4. Enter your Azure AD Client ID and Client Secret in the wizard.
|
4. If the administrator has configured system-wide OneDrive app credentials (`ONEDRIVE_CLIENT_ID` / `ONEDRIVE_CLIENT_SECRET`), the wizard defaults to using them — no need to register your own Azure AD app. Uncheck the toggle to use custom credentials if needed.
|
||||||
5. Click **Start Authentication Flow**, authorize access via Microsoft, and the refresh token is automatically saved to your personal integration record.
|
5. Click **Start Authentication Flow**, authorize access via Microsoft, and the refresh token is automatically saved to your personal integration record.
|
||||||
6. The page redirects back to `/integrations` on success. Re-authorization is available at any time via the **Re-Authorize** button.
|
6. After authorization, an interactive **folder browser** lets you select the target folder directly from your OneDrive — no need to manually type folder paths.
|
||||||
|
7. The page redirects to `/integrations` on success. Re-authorization is available at any time via the **Re-Authorize** button.
|
||||||
|
|
||||||
> **Note:** Your credentials are stored encrypted per-integration and are never mixed with other users' data. Each user can have multiple OneDrive integrations with independent tokens.
|
> **Note:** Your credentials are stored encrypted per-integration and are never mixed with other users' data. Each user can have multiple OneDrive integrations with independent tokens.
|
||||||
|
|
||||||
|
|||||||
+39
-16
@@ -34,7 +34,7 @@ Use this checklist to track readiness before going live.
|
|||||||
- [ ] **Redis** — Running and accessible only from internal network
|
- [ ] **Redis** — Running and accessible only from internal network
|
||||||
- [ ] **Meilisearch** — Running and accessible only from internal network
|
- [ ] **Meilisearch** — Running and accessible only from internal network
|
||||||
- [ ] **Worker replicas** — At least 2 workers configured for redundancy
|
- [ ] **Worker replicas** — At least 2 workers configured for redundancy
|
||||||
- [ ] **Monitoring** — `/api/health` polled by uptime checker
|
- [ ] **Monitoring** — `/api/diagnostic/health` polled by uptime checker
|
||||||
- [ ] **Backups** — Automated backup of database, workdir, and Meilisearch data
|
- [ ] **Backups** — Automated backup of database, workdir, and Meilisearch data
|
||||||
- [ ] **Log retention** — Logs shipped to a persistent store or aggregator
|
- [ ] **Log retention** — Logs shipped to a persistent store or aggregator
|
||||||
- [ ] **Secrets management** — API keys not committed to source control
|
- [ ] **Secrets management** — API keys not committed to source control
|
||||||
@@ -157,7 +157,7 @@ Recommended headers to configure at the proxy level:
|
|||||||
|
|
||||||
#### Content-Security-Policy Notes
|
#### Content-Security-Policy Notes
|
||||||
|
|
||||||
DocuElevate's frontend uses Tailwind CSS loaded from CDN in development mode. In production, ensure your CSP allows loading scripts and styles from your configured static file origin. A starting point:
|
DocuElevate's frontend uses Tailwind CSS v3 compiled at Docker build time. No external CDN requests are needed for CSS. In production, your CSP does not need to allow any external style sources beyond your own static file origin. A starting point:
|
||||||
|
|
||||||
```
|
```
|
||||||
Content-Security-Policy: default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:;
|
Content-Security-Policy: default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:;
|
||||||
@@ -285,22 +285,24 @@ For SSO/OIDC (Authentik, Keycloak, Auth0, etc.) see the [Authentication Setup Gu
|
|||||||
|
|
||||||
### Docker Compose
|
### Docker Compose
|
||||||
|
|
||||||
Use the `deploy.replicas` setting (requires Docker Swarm mode) or simply run multiple workers:
|
Scale workers independently:
|
||||||
|
|
||||||
```yaml
|
|
||||||
worker:
|
|
||||||
deploy:
|
|
||||||
replicas: 3
|
|
||||||
```
|
|
||||||
|
|
||||||
Or scale after deployment:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker-compose up -d --scale worker=3
|
docker compose up -d --scale worker=3
|
||||||
```
|
```
|
||||||
|
|
||||||
Each worker processes tasks from the Celery queue independently. Ensure the shared `workdir` volume is accessible from all worker containers.
|
Each worker processes tasks from the Celery queue independently. Ensure the shared `workdir` volume is accessible from all worker containers.
|
||||||
|
|
||||||
|
> **Important:** The `beat` service (Celery Beat scheduler) must always run as exactly **one** instance. It is defined as a dedicated service in `docker-compose.yaml` with a fixed `container_name`. Do not scale it.
|
||||||
|
|
||||||
|
### Scaling the API
|
||||||
|
|
||||||
|
API pods are fully stateless (sessions use encrypted cookies, not server-side state) and can be scaled behind a load balancer:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up -d --scale api=3
|
||||||
|
```
|
||||||
|
|
||||||
### Kubernetes (Helm)
|
### Kubernetes (Helm)
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
@@ -339,11 +341,32 @@ celery -A app.celery_worker worker -Q default,celery --concurrency=2
|
|||||||
|
|
||||||
### Health Check Endpoint
|
### Health Check Endpoint
|
||||||
|
|
||||||
DocuElevate exposes `/api/health` for readiness probing. Configure your uptime monitor to poll this endpoint:
|
DocuElevate exposes three health-related endpoints:
|
||||||
|
|
||||||
|
| Endpoint | Auth | Purpose |
|
||||||
|
|----------|------|---------|
|
||||||
|
| `GET /api/diagnostic/healthz/live` | None | Lightweight liveness probe — returns 200 if the process is running |
|
||||||
|
| `GET /api/diagnostic/healthz/ready` | None | Readiness probe — checks database and Redis (503 when DB is down) |
|
||||||
|
| `GET /api/diagnostic/health` | Required | Full status for monitoring dashboards (Grafana, Uptime Kuma) |
|
||||||
|
|
||||||
|
For **Kubernetes probes**, use the unauthenticated endpoints:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
livenessProbe:
|
||||||
|
httpGet:
|
||||||
|
path: /api/diagnostic/healthz/live
|
||||||
|
port: 8000
|
||||||
|
readinessProbe:
|
||||||
|
httpGet:
|
||||||
|
path: /api/diagnostic/healthz/ready
|
||||||
|
port: 8000
|
||||||
|
```
|
||||||
|
|
||||||
|
For **uptime monitors** (Uptime Kuma, Grafana, etc.), use the authenticated endpoint:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl http://docuelevate.example.com/api/health
|
curl http://docuelevate.example.com/api/diagnostic/health
|
||||||
# Expected: {"status": "ok", ...}
|
# Expected: {"status": "healthy", ...}
|
||||||
```
|
```
|
||||||
|
|
||||||
Set `UPTIME_KUMA_URL` to your Uptime Kuma push URL for heartbeat monitoring:
|
Set `UPTIME_KUMA_URL` to your Uptime Kuma push URL for heartbeat monitoring:
|
||||||
@@ -502,4 +525,4 @@ For a dedicated Kubernetes deployment guide, including architecture diagrams, PV
|
|||||||
|
|
||||||
- **Image Pull Policy**: Use `IfNotPresent` in production with pinned image tags (not `latest`) for reproducible deployments.
|
- **Image Pull Policy**: Use `IfNotPresent` in production with pinned image tags (not `latest`) for reproducible deployments.
|
||||||
|
|
||||||
- **Liveness & Readiness Probes**: Already configured in the Helm chart via `/api/health`. Verify they are tuned to your startup time.
|
- **Liveness & Readiness Probes**: Already configured in the Helm chart via unauthenticated endpoints (`/api/diagnostic/healthz/live` and `/api/diagnostic/healthz/ready`). Verify they are tuned to your startup time.
|
||||||
|
|||||||
+60
-11
@@ -2,13 +2,13 @@
|
|||||||
|
|
||||||
DocuElevate ships with first-class support for [Sentry](https://sentry.io) — an open-source observability platform that provides real-time error tracking and performance monitoring.
|
DocuElevate ships with first-class support for [Sentry](https://sentry.io) — an open-source observability platform that provides real-time error tracking and performance monitoring.
|
||||||
|
|
||||||
When a **Sentry DSN** is configured, every unhandled exception in the FastAPI web process and Celery worker is automatically captured and sent to your Sentry project. Performance transactions (request traces, database queries, background task durations) are also recorded, giving you end-to-end visibility into your deployment.
|
When a **Sentry DSN** is configured, every unhandled exception in the FastAPI web process and Celery worker is automatically captured and sent to your Sentry project. The **Sentry Browser SDK** is also injected into every rendered page, capturing client-side JavaScript errors, browser performance transactions, and (optionally) session replays. Together these give you full-stack, end-to-end visibility into your deployment.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
|
|
||||||
1. **Create a Sentry project** at <https://sentry.io> (or your self-hosted Sentry instance). Choose the **Python** platform.
|
1. **Create a Sentry project** at <https://sentry.io> (or your self-hosted Sentry instance). Choose the **Python** platform (the same project and DSN are used for both the server and browser SDKs).
|
||||||
2. Copy the **DSN** from *Project → Settings → Client Keys (DSN)*. It looks like:
|
2. Copy the **DSN** from *Project → Settings → Client Keys (DSN)*. It looks like:
|
||||||
```
|
```
|
||||||
https://<public_key>@o<org_id>.ingest.sentry.io/<project_id>
|
https://<public_key>@o<org_id>.ingest.sentry.io/<project_id>
|
||||||
@@ -17,12 +17,14 @@ When a **Sentry DSN** is configured, every unhandled exception in the FastAPI we
|
|||||||
```bash
|
```bash
|
||||||
SENTRY_DSN=https://<public_key>@o<org_id>.ingest.sentry.io/<project_id>
|
SENTRY_DSN=https://<public_key>@o<org_id>.ingest.sentry.io/<project_id>
|
||||||
```
|
```
|
||||||
4. Restart DocuElevate. Sentry initialises automatically on startup — you will see a log line confirming activation.
|
4. Restart DocuElevate. Sentry initialises automatically on startup — you will see a log line confirming activation. The Sentry Browser SDK `<script>` tag is automatically injected into every rendered page.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Environment Variables
|
## Environment Variables
|
||||||
|
|
||||||
|
### Server-side (Python SDK)
|
||||||
|
|
||||||
| Variable | Default | Description |
|
| Variable | Default | Description |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `SENTRY_DSN` | *(empty)* | Sentry DSN URL. **Required** to enable Sentry. Leave unset to disable. |
|
| `SENTRY_DSN` | *(empty)* | Sentry DSN URL. **Required** to enable Sentry. Leave unset to disable. |
|
||||||
@@ -31,6 +33,16 @@ When a **Sentry DSN** is configured, every unhandled exception in the FastAPI we
|
|||||||
| `SENTRY_PROFILES_SAMPLE_RATE` | `0.0` | Fraction of profiled transactions sent to Sentry (0.0 – 1.0). Only active when traces > 0. |
|
| `SENTRY_PROFILES_SAMPLE_RATE` | `0.0` | Fraction of profiled transactions sent to Sentry (0.0 – 1.0). Only active when traces > 0. |
|
||||||
| `SENTRY_SEND_DEFAULT_PII` | `false` | Attach PII (IP addresses, user agents) to events. Disable for GDPR / CCPA compliance. |
|
| `SENTRY_SEND_DEFAULT_PII` | `false` | Attach PII (IP addresses, user agents) to events. Disable for GDPR / CCPA compliance. |
|
||||||
|
|
||||||
|
### Browser SDK (JavaScript)
|
||||||
|
|
||||||
|
The Sentry Browser SDK is loaded automatically on every rendered page when `SENTRY_DSN` is set. The DSN is a *public* key in Sentry's security model and is intentionally embedded in client-side code.
|
||||||
|
|
||||||
|
| Variable | Default | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| `SENTRY_JS_TRACES_SAMPLE_RATE` | `0.0` | Fraction of browser page-loads captured for client-side performance tracing (0.0 – 1.0). |
|
||||||
|
| `SENTRY_JS_REPLAY_SESSION_SAMPLE_RATE` | `0.0` | Fraction of sessions recorded by [Sentry Session Replay](https://docs.sentry.io/product/session-replay/) (0.0 – 1.0). |
|
||||||
|
| `SENTRY_JS_REPLAY_ON_ERROR_SAMPLE_RATE` | `0.1` | Fraction of error sessions captured with session replay context (0.0 – 1.0). |
|
||||||
|
|
||||||
All variables can alternatively be managed through the **Settings → Observability** section of the DocuElevate admin UI. Settings stored in the database are applied before Sentry initialises on every startup, so changes made via the UI take effect after a restart without requiring any changes to environment variables or `.env` files.
|
All variables can alternatively be managed through the **Settings → Observability** section of the DocuElevate admin UI. Settings stored in the database are applied before Sentry initialises on every startup, so changes made via the UI take effect after a restart without requiring any changes to environment variables or `.env` files.
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -42,9 +54,13 @@ All variables can alternatively be managed through the **Settings → Observabil
|
|||||||
```bash
|
```bash
|
||||||
SENTRY_DSN=https://<key>@o<org>.ingest.sentry.io/<project>
|
SENTRY_DSN=https://<key>@o<org>.ingest.sentry.io/<project>
|
||||||
SENTRY_ENVIRONMENT=development
|
SENTRY_ENVIRONMENT=development
|
||||||
SENTRY_TRACES_SAMPLE_RATE=1.0 # Capture every request during development
|
SENTRY_TRACES_SAMPLE_RATE=1.0 # Capture every request during development
|
||||||
SENTRY_PROFILES_SAMPLE_RATE=1.0
|
SENTRY_PROFILES_SAMPLE_RATE=1.0
|
||||||
SENTRY_SEND_DEFAULT_PII=true # OK in dev; disable in production
|
SENTRY_SEND_DEFAULT_PII=true # OK in dev; disable in production
|
||||||
|
|
||||||
|
SENTRY_JS_TRACES_SAMPLE_RATE=1.0 # Capture every browser navigation
|
||||||
|
SENTRY_JS_REPLAY_SESSION_SAMPLE_RATE=1.0 # Record every session
|
||||||
|
SENTRY_JS_REPLAY_ON_ERROR_SAMPLE_RATE=1.0
|
||||||
```
|
```
|
||||||
|
|
||||||
### Staging
|
### Staging
|
||||||
@@ -55,6 +71,10 @@ SENTRY_ENVIRONMENT=staging
|
|||||||
SENTRY_TRACES_SAMPLE_RATE=0.5
|
SENTRY_TRACES_SAMPLE_RATE=0.5
|
||||||
SENTRY_PROFILES_SAMPLE_RATE=0.0
|
SENTRY_PROFILES_SAMPLE_RATE=0.0
|
||||||
SENTRY_SEND_DEFAULT_PII=false
|
SENTRY_SEND_DEFAULT_PII=false
|
||||||
|
|
||||||
|
SENTRY_JS_TRACES_SAMPLE_RATE=0.5
|
||||||
|
SENTRY_JS_REPLAY_SESSION_SAMPLE_RATE=0.1
|
||||||
|
SENTRY_JS_REPLAY_ON_ERROR_SAMPLE_RATE=1.0
|
||||||
```
|
```
|
||||||
|
|
||||||
### Production
|
### Production
|
||||||
@@ -62,9 +82,13 @@ SENTRY_SEND_DEFAULT_PII=false
|
|||||||
```bash
|
```bash
|
||||||
SENTRY_DSN=https://<key>@o<org>.ingest.sentry.io/<project>
|
SENTRY_DSN=https://<key>@o<org>.ingest.sentry.io/<project>
|
||||||
SENTRY_ENVIRONMENT=production
|
SENTRY_ENVIRONMENT=production
|
||||||
SENTRY_TRACES_SAMPLE_RATE=0.1 # 10 % sampling keeps quota low
|
SENTRY_TRACES_SAMPLE_RATE=0.1 # 10 % sampling keeps quota low
|
||||||
SENTRY_PROFILES_SAMPLE_RATE=0.0
|
SENTRY_PROFILES_SAMPLE_RATE=0.0
|
||||||
SENTRY_SEND_DEFAULT_PII=false # Default — required for GDPR compliance
|
SENTRY_SEND_DEFAULT_PII=false # Default — required for GDPR compliance
|
||||||
|
|
||||||
|
SENTRY_JS_TRACES_SAMPLE_RATE=0.1
|
||||||
|
SENTRY_JS_REPLAY_SESSION_SAMPLE_RATE=0.0 # Disabled; rely on error-triggered replay
|
||||||
|
SENTRY_JS_REPLAY_ON_ERROR_SAMPLE_RATE=0.1
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -80,6 +104,8 @@ services:
|
|||||||
SENTRY_DSN: "https://<key>@o<org>.ingest.sentry.io/<project>"
|
SENTRY_DSN: "https://<key>@o<org>.ingest.sentry.io/<project>"
|
||||||
SENTRY_ENVIRONMENT: "production"
|
SENTRY_ENVIRONMENT: "production"
|
||||||
SENTRY_TRACES_SAMPLE_RATE: "0.1"
|
SENTRY_TRACES_SAMPLE_RATE: "0.1"
|
||||||
|
SENTRY_JS_TRACES_SAMPLE_RATE: "0.1"
|
||||||
|
SENTRY_JS_REPLAY_ON_ERROR_SAMPLE_RATE: "0.1"
|
||||||
|
|
||||||
worker:
|
worker:
|
||||||
environment:
|
environment:
|
||||||
@@ -88,6 +114,8 @@ services:
|
|||||||
SENTRY_TRACES_SAMPLE_RATE: "0.1"
|
SENTRY_TRACES_SAMPLE_RATE: "0.1"
|
||||||
```
|
```
|
||||||
|
|
||||||
|
> The `worker` service only needs server-side variables — the Browser SDK runs in the user's browser and is configured via the `api` service.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Kubernetes
|
## Kubernetes
|
||||||
@@ -110,6 +138,10 @@ env:
|
|||||||
value: production
|
value: production
|
||||||
- name: SENTRY_TRACES_SAMPLE_RATE
|
- name: SENTRY_TRACES_SAMPLE_RATE
|
||||||
value: "0.1"
|
value: "0.1"
|
||||||
|
- name: SENTRY_JS_TRACES_SAMPLE_RATE
|
||||||
|
value: "0.1"
|
||||||
|
- name: SENTRY_JS_REPLAY_ON_ERROR_SAMPLE_RATE
|
||||||
|
value: "0.1"
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -132,17 +164,24 @@ env:
|
|||||||
|
|
||||||
Log messages at `ERROR` level and above are automatically forwarded to Sentry as events. Messages at `INFO` level are recorded as breadcrumbs (contextual trail leading up to an error).
|
Log messages at `ERROR` level and above are automatically forwarded to Sentry as events. Messages at `INFO` level are recorded as breadcrumbs (contextual trail leading up to an error).
|
||||||
|
|
||||||
|
### Browser (JavaScript SDK)
|
||||||
|
|
||||||
|
- **Client-side errors** — unhandled JavaScript exceptions and Promise rejections are captured automatically with browser context (URL, user agent, breadcrumbs).
|
||||||
|
- **Browser performance** — page load, navigation, and resource timing data is captured as Sentry transactions when `SENTRY_JS_TRACES_SAMPLE_RATE > 0`.
|
||||||
|
- **Session Replay** — screen recordings of user sessions (or just sessions containing errors) can be captured when the relevant replay sample rates are set above `0.0`. Replay data helps reproduce and diagnose hard-to-find UI bugs.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Disabling Sentry
|
## Disabling Sentry
|
||||||
|
|
||||||
Simply leave `SENTRY_DSN` unset (or set it to an empty string). The SDK is never initialised and no data is sent.
|
Simply leave `SENTRY_DSN` unset (or set it to an empty string). Neither the Python SDK nor the Browser SDK `<script>` tag is loaded, and no data is sent.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## SDK Version
|
## SDK Version
|
||||||
|
|
||||||
DocuElevate uses [`sentry-sdk`](https://pypi.org/project/sentry-sdk/) `>=2.20.0,<3.0.0` with the `fastapi`, `celery`, and `sqlalchemy` extras.
|
- **Server:** DocuElevate uses [`sentry-sdk`](https://pypi.org/project/sentry-sdk/) `>=2.20.0,<3.0.0` with the `fastapi`, `celery`, and `sqlalchemy` extras.
|
||||||
|
- **Browser:** The `bundle.tracing.replay.min.js` bundle from the Sentry Browser SDK **v10** is loaded from the official Sentry CDN (`browser.sentry-cdn.com`). The version pin is in `frontend/templates/base.html`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -154,6 +193,13 @@ DocuElevate uses [`sentry-sdk`](https://pypi.org/project/sentry-sdk/) `>=2.20.0,
|
|||||||
2. Check the application logs for the `Sentry initialised` message at startup. If it is absent, the DSN is not being read — confirm the environment variable name is `SENTRY_DSN`, or check the **Settings → Observability** section of the admin UI if you configured it there.
|
2. Check the application logs for the `Sentry initialised` message at startup. If it is absent, the DSN is not being read — confirm the environment variable name is `SENTRY_DSN`, or check the **Settings → Observability** section of the admin UI if you configured it there.
|
||||||
3. Test with `SENTRY_TRACES_SAMPLE_RATE=1.0` so that every request is sent.
|
3. Test with `SENTRY_TRACES_SAMPLE_RATE=1.0` so that every request is sent.
|
||||||
|
|
||||||
|
### No browser errors in Sentry
|
||||||
|
|
||||||
|
1. Confirm `SENTRY_DSN` is set — the Browser SDK `<script>` tag is only injected when the DSN is non-empty.
|
||||||
|
2. Open browser DevTools → Network and verify that the Sentry CDN bundle (`bundle.tracing.replay.min.js`) loads successfully (HTTP 200).
|
||||||
|
3. Open DevTools → Console and run `window.Sentry` — it should be an object if the SDK loaded correctly.
|
||||||
|
4. Check that `SENTRY_JS_TRACES_SAMPLE_RATE` and replay rates are set to values > 0 if you expect performance / replay data (they default to `0.0`).
|
||||||
|
|
||||||
### `sentry-sdk` import error
|
### `sentry-sdk` import error
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -164,11 +210,13 @@ Run `pip install 'sentry-sdk[fastapi,celery,sqlalchemy]'` inside your container,
|
|||||||
|
|
||||||
### PII / GDPR concerns
|
### PII / GDPR concerns
|
||||||
|
|
||||||
By default `SENTRY_SEND_DEFAULT_PII=false`, which prevents IP addresses and user agents from being attached to events. Review Sentry's [data management documentation](https://docs.sentry.io/product/data-management-settings/) and your organisation's privacy policy before enabling PII.
|
By default `SENTRY_SEND_DEFAULT_PII=false`, which prevents IP addresses and user agents from being attached to server-side events. Session Replay data may capture user interactions — review [Sentry's privacy documentation](https://docs.sentry.io/product/session-replay/privacy/) and your organisation's privacy policy before enabling replay.
|
||||||
|
|
||||||
### High Sentry quota usage
|
### High Sentry quota usage
|
||||||
|
|
||||||
Lower `SENTRY_TRACES_SAMPLE_RATE` (e.g. `0.05` for 5 %) or set it to `0.0` to disable performance tracing entirely.
|
- Lower `SENTRY_TRACES_SAMPLE_RATE` (e.g. `0.05` for 5 %) or set it to `0.0` to disable server-side performance tracing.
|
||||||
|
- Set `SENTRY_JS_TRACES_SAMPLE_RATE=0.0` to disable browser performance tracing.
|
||||||
|
- Set `SENTRY_JS_REPLAY_SESSION_SAMPLE_RATE=0.0` and `SENTRY_JS_REPLAY_ON_ERROR_SAMPLE_RATE=0.0` to disable Session Replay entirely.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -179,6 +227,7 @@ Lower `SENTRY_TRACES_SAMPLE_RATE` (e.g. `0.05` for 5 %) or set it to `0.0` to di
|
|||||||
- Use **release tracking**: DocuElevate automatically sets `release` to the current `VERSION` string, enabling you to correlate errors with specific releases.
|
- Use **release tracking**: DocuElevate automatically sets `release` to the current `VERSION` string, enabling you to correlate errors with specific releases.
|
||||||
- Set up **performance baselines** using Sentry's Performance dashboard so that you can detect regressions after deployments.
|
- Set up **performance baselines** using Sentry's Performance dashboard so that you can detect regressions after deployments.
|
||||||
- Review the [Sentry Python documentation](https://docs.sentry.io/platforms/python/) for advanced configuration options such as custom tags, user context, and scrubbing sensitive data.
|
- Review the [Sentry Python documentation](https://docs.sentry.io/platforms/python/) for advanced configuration options such as custom tags, user context, and scrubbing sensitive data.
|
||||||
|
- Review the [Sentry Browser SDK documentation](https://docs.sentry.io/platforms/javascript/) for advanced browser SDK configuration.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
# Social Login Setup Guide
|
# Social Login Setup Guide
|
||||||
|
|
||||||
This guide explains how to configure social login providers (Google, Microsoft, Apple, Dropbox) for DocuElevate. Social login lets your users sign in with their existing accounts, reducing friction and eliminating the need for separate passwords.
|
This guide explains how to configure social login providers (Google, Microsoft, Apple, Dropbox, GitHub) for DocuElevate. Social login lets your users sign in with their existing accounts, reducing friction and eliminating the need for separate passwords.
|
||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
|
|
||||||
DocuElevate supports four social login providers:
|
DocuElevate supports five social login providers, plus additional SSO options:
|
||||||
|
|
||||||
| Provider | Protocol | Best For |
|
| Provider | Protocol | Best For |
|
||||||
|----------|----------|----------|
|
|----------|----------|----------|
|
||||||
@@ -12,9 +12,15 @@ DocuElevate supports four social login providers:
|
|||||||
| **Microsoft** | OAuth2 / OpenID Connect | Microsoft 365 / Azure AD organizations and personal Microsoft accounts |
|
| **Microsoft** | OAuth2 / OpenID Connect | Microsoft 365 / Azure AD organizations and personal Microsoft accounts |
|
||||||
| **Apple** | OAuth2 / OpenID Connect | iOS/macOS users, privacy-focused users |
|
| **Apple** | OAuth2 / OpenID Connect | iOS/macOS users, privacy-focused users |
|
||||||
| **Dropbox** | OAuth2 | Teams already using Dropbox as a storage destination |
|
| **Dropbox** | OAuth2 | Teams already using Dropbox as a storage destination |
|
||||||
|
| **GitHub** | OAuth2 | Developer teams and open-source organizations |
|
||||||
|
| **Keycloak** | OpenID Connect | Self-hosted identity management |
|
||||||
|
| **Generic OAuth2** | OAuth2 | Any OAuth2-compatible identity provider |
|
||||||
|
| **SAML2** | SAML 2.0 | Enterprise identity providers (Okta, ADFS, etc.) |
|
||||||
|
|
||||||
Each provider is **independently enabled** — you can use one, several, or all of them at the same time. Social login works alongside any other DocuElevate authentication method (simple auth, OIDC/Authentik, local signup).
|
Each provider is **independently enabled** — you can use one, several, or all of them at the same time. Social login works alongside any other DocuElevate authentication method (simple auth, OIDC/Authentik, local signup).
|
||||||
|
|
||||||
|
> **Tip:** You can also configure providers through the admin **Connections** page at `/admin/connections`, which provides a wizard-like interface for setting up authentication services.
|
||||||
|
|
||||||
## Prerequisites
|
## Prerequisites
|
||||||
|
|
||||||
Before configuring any social login provider, ensure:
|
Before configuring any social login provider, ensure:
|
||||||
@@ -42,6 +48,7 @@ For example, if your DocuElevate instance is at `https://docuelevate.example.com
|
|||||||
| Microsoft | `https://docuelevate.example.com/social-callback/microsoft` |
|
| Microsoft | `https://docuelevate.example.com/social-callback/microsoft` |
|
||||||
| Apple | `https://docuelevate.example.com/social-callback/apple` |
|
| Apple | `https://docuelevate.example.com/social-callback/apple` |
|
||||||
| Dropbox | `https://docuelevate.example.com/social-callback/dropbox` |
|
| Dropbox | `https://docuelevate.example.com/social-callback/dropbox` |
|
||||||
|
| GitHub | `https://docuelevate.example.com/social-callback/github` |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -257,6 +264,86 @@ docker compose restart api worker
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## GitHub Sign-In
|
||||||
|
|
||||||
|
### 1. Create an OAuth App in GitHub
|
||||||
|
|
||||||
|
1. Go to [GitHub Developer Settings](https://github.com/settings/developers)
|
||||||
|
2. Click **OAuth Apps** → **New OAuth App**
|
||||||
|
3. Fill in the required fields:
|
||||||
|
- **Application name**: `DocuElevate` (or your preferred name)
|
||||||
|
- **Homepage URL**: `https://docuelevate.example.com`
|
||||||
|
- **Authorization callback URL**: `https://docuelevate.example.com/social-callback/github`
|
||||||
|
4. Click **Register application**
|
||||||
|
5. Copy the **Client ID**
|
||||||
|
6. Click **Generate a new client secret** and copy the secret immediately (it won't be shown again)
|
||||||
|
|
||||||
|
### 2. Configure DocuElevate
|
||||||
|
|
||||||
|
Add these environment variables to your `.env` file:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
SOCIAL_AUTH_GITHUB_ENABLED=true
|
||||||
|
SOCIAL_AUTH_GITHUB_CLIENT_ID=your-github-client-id
|
||||||
|
SOCIAL_AUTH_GITHUB_CLIENT_SECRET=your-github-client-secret
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Restart DocuElevate
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose restart api worker
|
||||||
|
```
|
||||||
|
|
||||||
|
### Notes
|
||||||
|
|
||||||
|
- **Scopes requested**: `read:user` and `user:email` — DocuElevate only reads the user's public profile and primary email address
|
||||||
|
- **Organization restrictions**: If your GitHub organization restricts OAuth app access, an organization owner must approve the DocuElevate OAuth app
|
||||||
|
- **Private email**: If a user's email is private on GitHub, DocuElevate will request it via the `user:email` scope
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Keycloak SSO
|
||||||
|
|
||||||
|
### 1. Create a Client in Keycloak
|
||||||
|
|
||||||
|
1. Log in to your Keycloak admin console
|
||||||
|
2. Select (or create) a realm
|
||||||
|
3. Go to **Clients** → **Create client**
|
||||||
|
4. Set **Client type** to `OpenID Connect`
|
||||||
|
5. Set **Client ID** (e.g., `docuelevate`)
|
||||||
|
6. Enable **Client authentication** (confidential)
|
||||||
|
7. Add `https://docuelevate.example.com/social-callback/keycloak` to **Valid redirect URIs**
|
||||||
|
8. Save and copy the **Client secret** from the **Credentials** tab
|
||||||
|
|
||||||
|
### 2. Configure DocuElevate
|
||||||
|
|
||||||
|
```bash
|
||||||
|
SOCIAL_AUTH_KEYCLOAK_ENABLED=true
|
||||||
|
SOCIAL_AUTH_KEYCLOAK_CLIENT_ID=docuelevate
|
||||||
|
SOCIAL_AUTH_KEYCLOAK_CLIENT_SECRET=your-keycloak-client-secret
|
||||||
|
SOCIAL_AUTH_KEYCLOAK_SERVER_URL=https://keycloak.example.com
|
||||||
|
SOCIAL_AUTH_KEYCLOAK_REALM=your-realm-name
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Generic OAuth2 SSO
|
||||||
|
|
||||||
|
For any OAuth2-compatible identity provider not listed above:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
SOCIAL_AUTH_GENERIC_OAUTH2_ENABLED=true
|
||||||
|
SOCIAL_AUTH_GENERIC_OAUTH2_CLIENT_ID=your-client-id
|
||||||
|
SOCIAL_AUTH_GENERIC_OAUTH2_CLIENT_SECRET=your-client-secret
|
||||||
|
SOCIAL_AUTH_GENERIC_OAUTH2_AUTHORIZE_URL=https://idp.example.com/oauth/authorize
|
||||||
|
SOCIAL_AUTH_GENERIC_OAUTH2_TOKEN_URL=https://idp.example.com/oauth/token
|
||||||
|
SOCIAL_AUTH_GENERIC_OAUTH2_USERINFO_URL=https://idp.example.com/oauth/userinfo
|
||||||
|
SOCIAL_AUTH_GENERIC_OAUTH2_SCOPE=openid profile email
|
||||||
|
SOCIAL_AUTH_GENERIC_OAUTH2_NAME=My Identity Provider
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Unified Authentication and Storage
|
## Unified Authentication and Storage
|
||||||
|
|
||||||
One of the key advantages of social login in DocuElevate is the potential for **unified authentication** — using the same identity for both signing in and accessing cloud storage destinations:
|
One of the key advantages of social login in DocuElevate is the potential for **unified authentication** — using the same identity for both signing in and accessing cloud storage destinations:
|
||||||
@@ -266,6 +353,7 @@ One of the key advantages of social login in DocuElevate is the potential for **
|
|||||||
| Google | Google Drive | User already has a Google identity for Drive integration |
|
| Google | Google Drive | User already has a Google identity for Drive integration |
|
||||||
| Microsoft | OneDrive | User already has a Microsoft identity for OneDrive integration |
|
| Microsoft | OneDrive | User already has a Microsoft identity for OneDrive integration |
|
||||||
| Dropbox | Dropbox | User already has a Dropbox identity for Dropbox integration |
|
| Dropbox | Dropbox | User already has a Dropbox identity for Dropbox integration |
|
||||||
|
| GitHub | *(none)* | Developer-friendly authentication for technical teams |
|
||||||
| Apple | *(none)* | Provides a familiar, privacy-respecting login option |
|
| Apple | *(none)* | Provides a familiar, privacy-respecting login option |
|
||||||
|
|
||||||
When a user signs in with a social provider that matches a configured storage destination, the administrator can leverage the same OAuth credentials or simplify the integration setup. Note that the storage integration credentials are configured separately in the admin settings — social login establishes the user's identity, not their storage permissions.
|
When a user signs in with a social provider that matches a configured storage destination, the administrator can leverage the same OAuth credentials or simplify the integration setup. Note that the storage integration credentials are configured separately in the admin settings — social login establishes the user's identity, not their storage permissions.
|
||||||
|
|||||||
+78
-2
@@ -87,7 +87,7 @@ DocuElevate provides multiple convenient ways to upload documents to the system.
|
|||||||
|
|
||||||
#### Supported File Types
|
#### Supported File Types
|
||||||
- **Documents**: PDF, Word (.doc, .docx), Excel (.xls, .xlsx), PowerPoint (.ppt, .pptx)
|
- **Documents**: PDF, Word (.doc, .docx), Excel (.xls, .xlsx), PowerPoint (.ppt, .pptx)
|
||||||
- **Images**: JPEG, PNG, GIF, BMP, TIFF, WebP, SVG
|
- **Images**: JPEG, PNG, GIF, BMP, TIFF, WebP, SVG, HEIC, HEIF
|
||||||
- **Text**: Plain text (.txt), CSV, RTF, HTML, XML, Markdown
|
- **Text**: Plain text (.txt), CSV, RTF, HTML, XML, Markdown
|
||||||
- **Maximum file size**: 500MB per file
|
- **Maximum file size**: 500MB per file
|
||||||
|
|
||||||
@@ -582,7 +582,25 @@ Processing pipelines let you define exactly what happens to your documents when
|
|||||||
| `embed_metadata` | Write extracted metadata into the PDF document properties |
|
| `embed_metadata` | Write extracted metadata into the PDF document properties |
|
||||||
| `compute_embedding` | Compute semantic embeddings for similarity search |
|
| `compute_embedding` | Compute semantic embeddings for similarity search |
|
||||||
| `send_to_destinations` | Upload the processed document to all configured storage destinations |
|
| `send_to_destinations` | Upload the processed document to all configured storage destinations |
|
||||||
| `classify` | Classify the document type with AI |
|
| `classify` | Classify the document type using rules (filename patterns, content keywords, metadata) |
|
||||||
|
|
||||||
|
#### Classify step – rule-based document classification
|
||||||
|
|
||||||
|
The `classify` step assigns a category to each document by evaluating **built-in** and **custom** classification rules. Rules are matched against three signals:
|
||||||
|
|
||||||
|
- **Filename patterns** — regex matched against the original filename (e.g. `(?i)invoice` matches filenames containing "invoice").
|
||||||
|
- **Content keywords** — pipe-separated keywords matched against the OCR text (e.g. `invoice number|amount due`).
|
||||||
|
- **Metadata match** — `field=value` matched against existing AI metadata (e.g. `document_type=Invoice`).
|
||||||
|
|
||||||
|
**Pre-built categories** include: Invoice, Contract, Receipt, Letter, Report, Bank Statement, Tax Document, Insurance, and Payslip. You can also define your own custom categories.
|
||||||
|
|
||||||
|
The classification result is stored in the document's `ai_metadata` under the `classification` key with the matched category, confidence score, and list of matched rules. If no `document_type` was previously set by AI metadata extraction, the classify step will also populate it.
|
||||||
|
|
||||||
|
> **Tip:** Manage custom classification rules via **Settings → Classification Rules** or the `/api/classification-rules/` API. See the [API Documentation](./API.md#classification-rules) for details.
|
||||||
|
|
||||||
|
| Option | Type | Default | Description |
|
||||||
|
|--------|------|---------|-------------|
|
||||||
|
| `use_builtin_rules` | boolean | `true` | Include the pre-built classification rules |
|
||||||
|
|
||||||
#### OCR step options
|
#### OCR step options
|
||||||
|
|
||||||
@@ -687,6 +705,64 @@ You can test your rules without actually routing a document using the
|
|||||||
**evaluate** endpoint (`POST /api/routing-rules/evaluate`). For the full
|
**evaluate** endpoint (`POST /api/routing-rules/evaluate`). For the full
|
||||||
API reference, see [API Documentation](API.md#routing-rules).
|
API reference, see [API Documentation](API.md#routing-rules).
|
||||||
|
|
||||||
|
## Comments & Annotations
|
||||||
|
|
||||||
|
The file detail page includes a **collaboration panel** for threaded
|
||||||
|
comments and PDF annotations, allowing team members to discuss documents
|
||||||
|
directly within DocuElevate.
|
||||||
|
|
||||||
|
### Comments
|
||||||
|
|
||||||
|
The **Comments** panel is on the left side of the collaboration section at
|
||||||
|
the bottom of the file detail page.
|
||||||
|
|
||||||
|
#### Viewing Comments
|
||||||
|
Open any file's detail page (`/files/{id}/detail`). Existing comments load
|
||||||
|
automatically, displayed in a threaded tree — replies are nested under their
|
||||||
|
parent.
|
||||||
|
|
||||||
|
#### Adding a Comment
|
||||||
|
1. Type your comment in the text area at the bottom of the Comments panel.
|
||||||
|
2. Use `@username` to mention another user — an autocomplete dropdown
|
||||||
|
appears as you type after the `@` symbol. Use arrow keys and Enter to
|
||||||
|
select a user.
|
||||||
|
3. Click **Add comment** to post.
|
||||||
|
|
||||||
|
#### Replying to a Comment
|
||||||
|
Click the **Reply** button on any top-level comment. A reply text area
|
||||||
|
appears inline; type your response and click **Reply** to post.
|
||||||
|
|
||||||
|
#### Editing & Deleting
|
||||||
|
You can edit or delete your own comments using the **Edit** and trash
|
||||||
|
buttons. Edits re-extract @mentions automatically.
|
||||||
|
|
||||||
|
#### Resolving Threads
|
||||||
|
Click **Resolve** on a top-level comment to mark the thread as resolved
|
||||||
|
(shown with a green badge). Click **Reopen** to re-open it.
|
||||||
|
|
||||||
|
### Annotations
|
||||||
|
|
||||||
|
The **Annotations** panel is on the right side of the collaboration
|
||||||
|
section.
|
||||||
|
|
||||||
|
#### Adding an Annotation
|
||||||
|
1. Type the annotation content in the text area.
|
||||||
|
2. Set the **Page** number the annotation refers to.
|
||||||
|
3. Choose a **Type**: Note, Highlight, Underline, or Strikethrough.
|
||||||
|
4. Pick a **Color** using the color picker.
|
||||||
|
5. Click **Add annotation** to save.
|
||||||
|
|
||||||
|
#### Editing & Deleting
|
||||||
|
You can edit or delete your own annotations using the pencil and trash
|
||||||
|
buttons. When editing, you can also change the annotation type.
|
||||||
|
|
||||||
|
### @Mention Autocomplete
|
||||||
|
|
||||||
|
When typing `@` followed by characters in the comment input, an
|
||||||
|
autocomplete dropdown shows matching users (sourced from the
|
||||||
|
`/api/users/mentionable` endpoint). Navigate with arrow keys and press
|
||||||
|
Enter or click to insert the mention.
|
||||||
|
|
||||||
## API Access
|
## API Access
|
||||||
|
|
||||||
For programmatic access, DocuElevate provides a comprehensive REST API:
|
For programmatic access, DocuElevate provides a comprehensive REST API:
|
||||||
|
|||||||
@@ -0,0 +1,329 @@
|
|||||||
|
/* frontend/input.css
|
||||||
|
* Tailwind CSS v3 source file.
|
||||||
|
* Edit this file (not static/styles.css) — the compiled output is
|
||||||
|
* generated by running: npm run build (inside the frontend/ directory)
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* ── Tailwind layers ──────────────────────────────────────────────────────── */
|
||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
/* ── Custom utilities ─────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
/* =============================================================
|
||||||
|
ACCESSIBILITY
|
||||||
|
Skip-to-content link, focus indicators, and screen-reader-only
|
||||||
|
utility class following WCAG 2.1 Level AA requirements.
|
||||||
|
============================================================= */
|
||||||
|
|
||||||
|
/* Skip-to-content link: visible only on keyboard focus */
|
||||||
|
.skip-link {
|
||||||
|
position: absolute;
|
||||||
|
left: -9999px;
|
||||||
|
top: auto;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
overflow: hidden;
|
||||||
|
z-index: 9999;
|
||||||
|
padding: 0.75rem 1.5rem;
|
||||||
|
background-color: #1d4ed8;
|
||||||
|
color: #ffffff;
|
||||||
|
font-weight: 600;
|
||||||
|
text-decoration: none;
|
||||||
|
border-radius: 0 0 0.375rem 0;
|
||||||
|
}
|
||||||
|
.skip-link:focus {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: auto;
|
||||||
|
height: auto;
|
||||||
|
outline: 2px solid #2563eb;
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Enhanced focus-visible indicators for keyboard navigation (WCAG 2.4.7) */
|
||||||
|
a:focus-visible,
|
||||||
|
button:focus-visible,
|
||||||
|
input:focus-visible,
|
||||||
|
select:focus-visible,
|
||||||
|
textarea:focus-visible,
|
||||||
|
[tabindex]:focus-visible {
|
||||||
|
outline: 2px solid #2563eb;
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Screen-reader-only utility (visually hidden, accessible to AT) */
|
||||||
|
.sr-only {
|
||||||
|
position: absolute;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
padding: 0;
|
||||||
|
margin: -1px;
|
||||||
|
overflow: hidden;
|
||||||
|
clip: rect(0, 0, 0, 0);
|
||||||
|
white-space: nowrap;
|
||||||
|
border-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
/* Your global overrides can go here if needed */
|
||||||
|
}
|
||||||
|
.material-symbols-light--folder-managed-outline {
|
||||||
|
display: inline-block;
|
||||||
|
width: 96px;
|
||||||
|
height: 96px;
|
||||||
|
--svg: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%23000' d='m17.212 20.404l-.108-.885q-.57-.125-.938-.33q-.368-.204-.7-.577l-.835.334l-.539-.815l.689-.577q-.165-.531-.165-1.035t.165-1.034l-.689-.577l.539-.816l.835.335q.332-.393.7-.588q.369-.195.938-.32l.108-.885h1l.107.885q.57.125.938.32t.7.588l.835-.335l.539.816l-.689.576q.166.531.166 1.035t-.166 1.035l.689.577l-.539.815l-.834-.335q-.333.373-.701.578q-.369.205-.938.33l-.107.885zm.5-1.731q.882 0 1.518-.635q.636-.636.636-1.519t-.636-1.518t-1.518-.636t-1.519.636t-.635 1.518t.635 1.519t1.518.635M4 18V6v4.435V10zm.616 1q-.691 0-1.153-.462T3 17.384V6.616q0-.691.463-1.153T4.615 5h4.981l2 2h7.789q.69 0 1.153.463T21 8.616v2.294q-.238-.152-.479-.265q-.24-.112-.521-.21v-1.82q0-.269-.173-.442T19.385 8h-8.19l-2-2h-4.58q-.269 0-.442.173T4 6.616v10.769q0 .269.173.442t.443.173h6.748q.055.275.131.515t.186.485z'/%3E%3C/svg%3E");
|
||||||
|
background-color: currentColor;
|
||||||
|
-webkit-mask-image: var(--svg);
|
||||||
|
mask-image: var(--svg);
|
||||||
|
-webkit-mask-repeat: no-repeat;
|
||||||
|
mask-repeat: no-repeat;
|
||||||
|
-webkit-mask-size: 100% 100%;
|
||||||
|
mask-size: 100% 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ensure pagination wraps properly on small screens */
|
||||||
|
.pagination {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
.pagination-buttons {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ensure filter items stack on very small screens */
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.filter-group {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.filter-item {
|
||||||
|
min-width: unset;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* =============================================================
|
||||||
|
DARK MODE
|
||||||
|
Activated by "dark" class on <html> element.
|
||||||
|
Toggled by the navbar button; preference stored in localStorage.
|
||||||
|
Falls back to the server-side ui_default_color_scheme setting,
|
||||||
|
then to the OS prefers-color-scheme media query.
|
||||||
|
WCAG AA contrast ratios verified for all text/background pairs.
|
||||||
|
============================================================= */
|
||||||
|
|
||||||
|
/* Tell the browser we support both colour schemes */
|
||||||
|
html { color-scheme: light; }
|
||||||
|
html.dark { color-scheme: dark; }
|
||||||
|
|
||||||
|
/* ---- Base / Body ---- */
|
||||||
|
html.dark body { background-color: #111827; color: #e5e7eb; }
|
||||||
|
html.dark .bg-gray-50 { background-color: #111827; }
|
||||||
|
html.dark .bg-white { background-color: #1f2937; }
|
||||||
|
html.dark .bg-gray-100 { background-color: #374151; }
|
||||||
|
html.dark .bg-gray-200 { background-color: #4b5563; }
|
||||||
|
|
||||||
|
/* ---- Text colours ---- */
|
||||||
|
html.dark .text-gray-900 { color: #f9fafb; }
|
||||||
|
html.dark .text-gray-800 { color: #f3f4f6; }
|
||||||
|
html.dark .text-gray-700 { color: #e5e7eb; }
|
||||||
|
html.dark .text-gray-600 { color: #d1d5db; }
|
||||||
|
html.dark .text-gray-500 { color: #9ca3af; }
|
||||||
|
html.dark .text-gray-400 { color: #9ca3af; }
|
||||||
|
html.dark .text-black { color: #f9fafb; }
|
||||||
|
|
||||||
|
/* ---- Borders ---- */
|
||||||
|
html.dark .border-gray-100 { border-color: #374151; }
|
||||||
|
html.dark .border-gray-200 { border-color: #374151; }
|
||||||
|
html.dark .border-gray-300 { border-color: #4b5563; }
|
||||||
|
html.dark .border-gray-400 { border-color: #6b7280; }
|
||||||
|
html.dark .divide-gray-200 > :not([hidden]) ~ :not([hidden]) { border-color: #374151; }
|
||||||
|
html.dark .divide-gray-100 > :not([hidden]) ~ :not([hidden]) { border-color: #374151; }
|
||||||
|
html.dark .divide-y > :not([hidden]) ~ :not([hidden]) { border-color: #374151; }
|
||||||
|
|
||||||
|
/* ---- Hover states ---- */
|
||||||
|
html.dark .hover\:bg-gray-50:hover { background-color: #374151; }
|
||||||
|
html.dark .hover\:bg-gray-100:hover { background-color: #4b5563; }
|
||||||
|
html.dark .hover\:text-gray-900:hover { color: #f9fafb; }
|
||||||
|
html.dark .hover\:text-gray-700:hover { color: #e5e7eb; }
|
||||||
|
|
||||||
|
/* ---- Shadows (softened for dark mode) ---- */
|
||||||
|
html.dark .shadow,
|
||||||
|
html.dark .shadow-md,
|
||||||
|
html.dark .shadow-sm,
|
||||||
|
html.dark .shadow-lg {
|
||||||
|
box-shadow: 0 1px 3px 0 rgba(0,0,0,0.6), 0 1px 2px 0 rgba(0,0,0,0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Alert / info-banner backgrounds ---- */
|
||||||
|
html.dark .bg-blue-50 { background-color: #1e3a5f; }
|
||||||
|
html.dark .bg-green-50 { background-color: #052e16; }
|
||||||
|
html.dark .bg-red-50 { background-color: #450a0a; }
|
||||||
|
html.dark .bg-yellow-50 { background-color: #451a03; }
|
||||||
|
html.dark .bg-indigo-50 { background-color: #1e1b4b; }
|
||||||
|
html.dark .bg-orange-50 { background-color: #431407; }
|
||||||
|
|
||||||
|
/* ---- Badge / pill backgrounds ---- */
|
||||||
|
html.dark .bg-blue-100 { background-color: #1e3a5f; }
|
||||||
|
html.dark .bg-green-100 { background-color: #052e16; }
|
||||||
|
html.dark .bg-red-100 { background-color: #450a0a; }
|
||||||
|
html.dark .bg-yellow-100 { background-color: #451a03; }
|
||||||
|
html.dark .bg-indigo-100 { background-color: #431407; }
|
||||||
|
html.dark .bg-orange-100 { background-color: #431407; }
|
||||||
|
html.dark .bg-purple-100 { background-color: #2e1065; }
|
||||||
|
|
||||||
|
/* ---- Status / badge text colours ---- */
|
||||||
|
html.dark .text-blue-700 { color: #93c5fd; }
|
||||||
|
html.dark .text-blue-800 { color: #bfdbfe; }
|
||||||
|
html.dark .text-green-700 { color: #86efac; }
|
||||||
|
html.dark .text-green-800 { color: #bbf7d0; }
|
||||||
|
html.dark .text-red-700 { color: #fca5a5; }
|
||||||
|
html.dark .text-red-800 { color: #fecaca; }
|
||||||
|
html.dark .text-yellow-700 { color: #fcd34d; }
|
||||||
|
html.dark .text-yellow-800 { color: #fde68a; }
|
||||||
|
html.dark .text-indigo-700 { color: #a5b4fc; }
|
||||||
|
html.dark .text-indigo-800 { color: #c7d2fe; }
|
||||||
|
html.dark .text-orange-700 { color: #fdba74; }
|
||||||
|
html.dark .text-orange-800 { color: #fed7aa; }
|
||||||
|
html.dark .text-purple-700 { color: #d8b4fe; }
|
||||||
|
html.dark .text-purple-800 { color: #e9d5ff; }
|
||||||
|
|
||||||
|
/* ---- Dropdown / popup menus ---- */
|
||||||
|
html.dark .bg-white.rounded-md.shadow-lg { background-color: #1f2937; }
|
||||||
|
html.dark .ring-black { --tw-ring-color: rgba(0,0,0,0.5); }
|
||||||
|
|
||||||
|
/* ---- Form inputs / selects / textareas ---- */
|
||||||
|
html.dark input:not([type="checkbox"]):not([type="radio"]):not([type="range"]),
|
||||||
|
html.dark select,
|
||||||
|
html.dark textarea {
|
||||||
|
background-color: #374151;
|
||||||
|
border-color: #4b5563;
|
||||||
|
color: #e5e7eb;
|
||||||
|
}
|
||||||
|
html.dark input::placeholder,
|
||||||
|
html.dark textarea::placeholder {
|
||||||
|
color: #9ca3af;
|
||||||
|
}
|
||||||
|
html.dark input:focus:not([type="checkbox"]):not([type="radio"]):not([type="range"]),
|
||||||
|
html.dark select:focus,
|
||||||
|
html.dark textarea:focus {
|
||||||
|
border-color: #60a5fa;
|
||||||
|
outline-color: #60a5fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Table rows ---- */
|
||||||
|
html.dark thead,
|
||||||
|
html.dark .bg-gray-50 thead { background-color: #1f2937; }
|
||||||
|
html.dark thead th { color: #9ca3af; }
|
||||||
|
html.dark tbody tr:hover { background-color: #374151; }
|
||||||
|
|
||||||
|
/* ---- Code / pre ---- */
|
||||||
|
html.dark pre,
|
||||||
|
html.dark code { background-color: #111827; color: #d1d5db; }
|
||||||
|
|
||||||
|
/* ---- Dark-mode toggle button icon colour ---- */
|
||||||
|
html.dark #darkModeToggle { color: #fbbf24; }
|
||||||
|
html.dark #darkModeToggle:hover { background-color: #374151; }
|
||||||
|
|
||||||
|
/* ---- Dark-mode skip-link ---- */
|
||||||
|
html.dark .skip-link { background-color: #2563eb; }
|
||||||
|
html.dark .skip-link:focus { outline-color: #60a5fa; }
|
||||||
|
|
||||||
|
/* ---- Dark-mode focus-visible indicators ---- */
|
||||||
|
html.dark a:focus-visible,
|
||||||
|
html.dark button:focus-visible,
|
||||||
|
html.dark input:focus-visible,
|
||||||
|
html.dark select:focus-visible,
|
||||||
|
html.dark textarea:focus-visible,
|
||||||
|
html.dark [tabindex]:focus-visible {
|
||||||
|
outline-color: #60a5fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Scrollbar (WebKit browsers) ---- */
|
||||||
|
html.dark ::-webkit-scrollbar { width: 8px; height: 8px; }
|
||||||
|
html.dark ::-webkit-scrollbar-track { background: #1f2937; }
|
||||||
|
html.dark ::-webkit-scrollbar-thumb { background: #4b5563; border-radius: 4px; }
|
||||||
|
html.dark ::-webkit-scrollbar-thumb:hover { background: #6b7280; }
|
||||||
|
|
||||||
|
/* ---- Settings page: sidebar active state (dark) ---- */
|
||||||
|
html.dark .bg-blue-50 { background-color: #1e3a5f; }
|
||||||
|
|
||||||
|
/* =============================================================
|
||||||
|
DOC-TOGGLE – cross-browser toggle switch
|
||||||
|
Implemented with custom CSS pseudo-elements so the appearance
|
||||||
|
is consistent across all browsers regardless of Tailwind version.
|
||||||
|
Usage:
|
||||||
|
<label class="doc-toggle">
|
||||||
|
<input type="checkbox" class="sr-only" onchange="...">
|
||||||
|
<span class="doc-toggle-track" aria-hidden="true"></span>
|
||||||
|
<span class="ml-3 ...">Label text</span>
|
||||||
|
</label>
|
||||||
|
============================================================= */
|
||||||
|
|
||||||
|
.doc-toggle {
|
||||||
|
position: relative;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.doc-toggle-track {
|
||||||
|
position: relative;
|
||||||
|
display: inline-block;
|
||||||
|
width: 44px;
|
||||||
|
min-width: 44px;
|
||||||
|
height: 24px;
|
||||||
|
background-color: #e5e7eb; /* gray-200 */
|
||||||
|
border-radius: 9999px;
|
||||||
|
transition: background-color 0.2s ease-in-out;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.doc-toggle-track::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 2px;
|
||||||
|
left: 2px;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
background-color: #ffffff;
|
||||||
|
border: 1px solid #d1d5db; /* gray-300 */
|
||||||
|
border-radius: 9999px;
|
||||||
|
transition: transform 0.2s ease-in-out, border-color 0.2s ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.doc-toggle input[type="checkbox"]:checked + .doc-toggle-track {
|
||||||
|
background-color: #4f46e5; /* indigo-600 */
|
||||||
|
}
|
||||||
|
|
||||||
|
.doc-toggle input[type="checkbox"]:checked + .doc-toggle-track::after {
|
||||||
|
transform: translateX(20px);
|
||||||
|
border-color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.doc-toggle input[type="checkbox"]:focus-visible + .doc-toggle-track {
|
||||||
|
box-shadow: 0 0 0 2px #ffffff, 0 0 0 4px #6366f1; /* ring-2 ring-indigo-500 with offset */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dark mode overrides */
|
||||||
|
html.dark .doc-toggle-track {
|
||||||
|
background-color: #374151; /* gray-700 */
|
||||||
|
}
|
||||||
|
|
||||||
|
html.dark .doc-toggle-track::after {
|
||||||
|
background-color: #ffffff;
|
||||||
|
border-color: #4b5563; /* gray-600 */
|
||||||
|
}
|
||||||
|
|
||||||
|
html.dark .doc-toggle input[type="checkbox"]:checked + .doc-toggle-track {
|
||||||
|
background-color: #4f46e5; /* indigo-600 */
|
||||||
|
}
|
||||||
|
|
||||||
|
html.dark .doc-toggle input[type="checkbox"]:checked + .doc-toggle-track::after {
|
||||||
|
border-color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
html.dark .doc-toggle input[type="checkbox"]:focus-visible + .doc-toggle-track {
|
||||||
|
box-shadow: 0 0 0 2px #111827, 0 0 0 4px #6366f1; /* dark background offset */
|
||||||
|
}
|
||||||
Generated
+1017
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"name": "docuelevate-frontend",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Frontend asset compilation for DocuElevate",
|
||||||
|
"scripts": {
|
||||||
|
"build": "tailwindcss -i input.css -o static/styles.css --minify",
|
||||||
|
"watch": "tailwindcss -i input.css -o static/styles.css --watch"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"tailwindcss": "^3.4.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,307 @@
|
|||||||
|
// frontend/static/js/annotations.js
|
||||||
|
// Annotations panel — CRUD for PDF page annotations
|
||||||
|
|
||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
var _fileId = null;
|
||||||
|
var _currentUserId = null;
|
||||||
|
var _i18n = {};
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Initialisation
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bootstrap the annotations panel.
|
||||||
|
* @param {number} fileId
|
||||||
|
* @param {string} currentUserId
|
||||||
|
* @param {object} i18n
|
||||||
|
*/
|
||||||
|
function initAnnotations(fileId, currentUserId, i18n) {
|
||||||
|
_fileId = fileId;
|
||||||
|
_currentUserId = currentUserId;
|
||||||
|
_i18n = i18n || {};
|
||||||
|
_loadAnnotations();
|
||||||
|
|
||||||
|
// Expose reload function so the EmbedPDF viewer init script can refresh the
|
||||||
|
// list after auto-saving an annotation created inside the viewer.
|
||||||
|
window._reloadAnnotations = _loadAnnotations;
|
||||||
|
|
||||||
|
var form = document.getElementById('annotation-form');
|
||||||
|
if (form) {
|
||||||
|
form.addEventListener('submit', function (e) {
|
||||||
|
e.preventDefault();
|
||||||
|
_createAnnotation();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Data fetching
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function _loadAnnotations() {
|
||||||
|
var container = document.getElementById('annotations-list');
|
||||||
|
if (!container) return;
|
||||||
|
container.innerHTML = '<div class="annotations-loading"><i class="fas fa-spinner fa-spin" aria-hidden="true"></i></div>';
|
||||||
|
|
||||||
|
fetch('/api/files/' + _fileId + '/annotations')
|
||||||
|
.then(function (r) { return r.json(); })
|
||||||
|
.then(function (data) {
|
||||||
|
_renderAnnotations(data.annotations || [], container);
|
||||||
|
})
|
||||||
|
.catch(function () {
|
||||||
|
container.innerHTML = '<p class="annotations-empty">' + (_i18n.empty || 'No annotations yet') + '</p>';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Rendering
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function _renderAnnotations(annotations, container) {
|
||||||
|
container.innerHTML = '';
|
||||||
|
if (!annotations.length) {
|
||||||
|
container.innerHTML = '<p class="annotations-empty"><i class="fas fa-sticky-note" aria-hidden="true"></i> ' +
|
||||||
|
(_i18n.empty || 'No annotations yet') + '</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (var i = 0; i < annotations.length; i++) {
|
||||||
|
container.appendChild(_buildAnnotationNode(annotations[i]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function _buildAnnotationNode(ann) {
|
||||||
|
var div = document.createElement('div');
|
||||||
|
div.className = 'annotation-item';
|
||||||
|
div.setAttribute('data-annotation-id', ann.id);
|
||||||
|
|
||||||
|
// Type badge + color indicator
|
||||||
|
var header = document.createElement('div');
|
||||||
|
header.className = 'annotation-header';
|
||||||
|
|
||||||
|
var typeBadge = document.createElement('span');
|
||||||
|
typeBadge.className = 'annotation-type annotation-type--' + ann.annotation_type;
|
||||||
|
typeBadge.textContent = _i18n['type_' + ann.annotation_type] || ann.annotation_type;
|
||||||
|
|
||||||
|
var pageInfo = document.createElement('button');
|
||||||
|
pageInfo.type = 'button';
|
||||||
|
pageInfo.className = 'annotation-page annotation-page--link';
|
||||||
|
pageInfo.setAttribute('aria-label', (_i18n.go_to_page || 'Go to page') + ' ' + ann.page);
|
||||||
|
pageInfo.title = (_i18n.go_to_page || 'Go to page') + ' ' + ann.page;
|
||||||
|
pageInfo.innerHTML = '<i class="fas fa-file-alt" aria-hidden="true"></i> ' +
|
||||||
|
(_i18n.page || 'Page') + ' ' + ann.page;
|
||||||
|
pageInfo.addEventListener('click', function () {
|
||||||
|
if (typeof window._embedpdfScrollToPage === 'function') {
|
||||||
|
window._embedpdfScrollToPage(ann.page);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
header.appendChild(typeBadge);
|
||||||
|
if (ann.color) {
|
||||||
|
var colorDot = document.createElement('span');
|
||||||
|
colorDot.className = 'annotation-color-dot';
|
||||||
|
colorDot.style.backgroundColor = ann.color;
|
||||||
|
colorDot.setAttribute('aria-label', (_i18n.color || 'Color') + ': ' + ann.color);
|
||||||
|
header.appendChild(colorDot);
|
||||||
|
}
|
||||||
|
header.appendChild(pageInfo);
|
||||||
|
|
||||||
|
div.appendChild(header);
|
||||||
|
|
||||||
|
// Content
|
||||||
|
var content = document.createElement('div');
|
||||||
|
content.className = 'annotation-content';
|
||||||
|
content.id = 'annotation-content-' + ann.id;
|
||||||
|
content.textContent = ann.content;
|
||||||
|
div.appendChild(content);
|
||||||
|
|
||||||
|
// Meta
|
||||||
|
var meta = document.createElement('div');
|
||||||
|
meta.className = 'annotation-meta';
|
||||||
|
|
||||||
|
var author = document.createElement('span');
|
||||||
|
author.className = 'annotation-author';
|
||||||
|
author.textContent = ann.user_id;
|
||||||
|
|
||||||
|
var time = document.createElement('time');
|
||||||
|
time.className = 'annotation-time';
|
||||||
|
time.setAttribute('datetime', ann.created_at);
|
||||||
|
time.textContent = _formatDate(ann.created_at);
|
||||||
|
|
||||||
|
meta.appendChild(author);
|
||||||
|
meta.appendChild(time);
|
||||||
|
div.appendChild(meta);
|
||||||
|
|
||||||
|
// Actions (author only)
|
||||||
|
if (ann.user_id === _currentUserId) {
|
||||||
|
var actions = document.createElement('div');
|
||||||
|
actions.className = 'annotation-actions';
|
||||||
|
|
||||||
|
var editBtn = document.createElement('button');
|
||||||
|
editBtn.type = 'button';
|
||||||
|
editBtn.className = 'annotation-action-btn';
|
||||||
|
editBtn.innerHTML = '<i class="fas fa-edit" aria-hidden="true"></i>';
|
||||||
|
editBtn.setAttribute('aria-label', 'Edit annotation');
|
||||||
|
editBtn.addEventListener('click', function () { _showEditForm(ann); });
|
||||||
|
actions.appendChild(editBtn);
|
||||||
|
|
||||||
|
var deleteBtn = document.createElement('button');
|
||||||
|
deleteBtn.type = 'button';
|
||||||
|
deleteBtn.className = 'annotation-action-btn annotation-action-btn--danger';
|
||||||
|
deleteBtn.innerHTML = '<i class="fas fa-trash" aria-hidden="true"></i>';
|
||||||
|
deleteBtn.setAttribute('aria-label', 'Delete annotation');
|
||||||
|
deleteBtn.addEventListener('click', function () { _deleteAnnotation(ann.id); });
|
||||||
|
actions.appendChild(deleteBtn);
|
||||||
|
|
||||||
|
div.appendChild(actions);
|
||||||
|
}
|
||||||
|
|
||||||
|
return div;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _formatDate(iso) {
|
||||||
|
if (!iso) return '';
|
||||||
|
try {
|
||||||
|
var d = new Date(iso);
|
||||||
|
return d.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' }) +
|
||||||
|
' ' + d.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' });
|
||||||
|
} catch (_e) {
|
||||||
|
return iso;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Actions
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function _createAnnotation() {
|
||||||
|
var content = document.getElementById('annotation-content-input');
|
||||||
|
var page = document.getElementById('annotation-page-input');
|
||||||
|
var type = document.getElementById('annotation-type-input');
|
||||||
|
var color = document.getElementById('annotation-color-input');
|
||||||
|
|
||||||
|
if (!content || !content.value.trim()) return;
|
||||||
|
|
||||||
|
var payload = {
|
||||||
|
content: content.value.trim(),
|
||||||
|
page: parseInt(page ? page.value : '1', 10) || 1,
|
||||||
|
annotation_type: type ? type.value : 'note',
|
||||||
|
color: color ? color.value : null,
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
width: 0,
|
||||||
|
height: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
fetch('/api/files/' + _fileId + '/annotations', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
})
|
||||||
|
.then(function (r) {
|
||||||
|
if (!r.ok) throw new Error('Failed');
|
||||||
|
return r.json();
|
||||||
|
})
|
||||||
|
.then(function () {
|
||||||
|
content.value = '';
|
||||||
|
if (page) page.value = '1';
|
||||||
|
_loadAnnotations();
|
||||||
|
})
|
||||||
|
.catch(function () {});
|
||||||
|
}
|
||||||
|
|
||||||
|
function _deleteAnnotation(annotationId) {
|
||||||
|
if (!window.confirm(_i18n.delete_confirm || 'Are you sure you want to delete this annotation?')) return;
|
||||||
|
|
||||||
|
fetch('/api/files/' + _fileId + '/annotations/' + annotationId, {
|
||||||
|
method: 'DELETE',
|
||||||
|
})
|
||||||
|
.then(function (r) {
|
||||||
|
if (!r.ok) throw new Error('Failed');
|
||||||
|
_loadAnnotations();
|
||||||
|
})
|
||||||
|
.catch(function () {});
|
||||||
|
}
|
||||||
|
|
||||||
|
function _showEditForm(ann) {
|
||||||
|
var contentDiv = document.getElementById('annotation-content-' + ann.id);
|
||||||
|
if (!contentDiv) return;
|
||||||
|
if (contentDiv.querySelector('.annotation-edit-form')) return;
|
||||||
|
|
||||||
|
var originalText = contentDiv.textContent;
|
||||||
|
contentDiv.textContent = '';
|
||||||
|
|
||||||
|
var form = document.createElement('div');
|
||||||
|
form.className = 'annotation-edit-form';
|
||||||
|
|
||||||
|
var textarea = document.createElement('textarea');
|
||||||
|
textarea.className = 'annotation-textarea';
|
||||||
|
textarea.value = ann.content;
|
||||||
|
textarea.rows = 3;
|
||||||
|
textarea.setAttribute('aria-label', 'Edit annotation');
|
||||||
|
|
||||||
|
var typeSelect = document.createElement('select');
|
||||||
|
typeSelect.className = 'annotation-select';
|
||||||
|
typeSelect.setAttribute('aria-label', 'Annotation type');
|
||||||
|
var types = ['note', 'highlight', 'underline', 'strikethrough'];
|
||||||
|
for (var i = 0; i < types.length; i++) {
|
||||||
|
var opt = document.createElement('option');
|
||||||
|
opt.value = types[i];
|
||||||
|
opt.textContent = _i18n['type_' + types[i]] || types[i];
|
||||||
|
if (types[i] === ann.annotation_type) opt.selected = true;
|
||||||
|
typeSelect.appendChild(opt);
|
||||||
|
}
|
||||||
|
|
||||||
|
var btns = document.createElement('div');
|
||||||
|
btns.className = 'annotation-edit-btns';
|
||||||
|
|
||||||
|
var saveBtn = document.createElement('button');
|
||||||
|
saveBtn.type = 'button';
|
||||||
|
saveBtn.className = 'annotation-submit-btn';
|
||||||
|
saveBtn.textContent = _i18n.save || 'Save';
|
||||||
|
saveBtn.addEventListener('click', function () {
|
||||||
|
var newContent = textarea.value.trim();
|
||||||
|
if (!newContent) return;
|
||||||
|
fetch('/api/files/' + _fileId + '/annotations/' + ann.id, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
content: newContent,
|
||||||
|
annotation_type: typeSelect.value,
|
||||||
|
page: ann.page,
|
||||||
|
x: ann.x,
|
||||||
|
y: ann.y,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
.then(function (r) {
|
||||||
|
if (!r.ok) throw new Error('Failed');
|
||||||
|
_loadAnnotations();
|
||||||
|
})
|
||||||
|
.catch(function () {
|
||||||
|
contentDiv.textContent = originalText;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
var cancelBtn = document.createElement('button');
|
||||||
|
cancelBtn.type = 'button';
|
||||||
|
cancelBtn.className = 'annotation-cancel-btn';
|
||||||
|
cancelBtn.textContent = _i18n.cancel || 'Cancel';
|
||||||
|
cancelBtn.addEventListener('click', function () {
|
||||||
|
contentDiv.textContent = originalText;
|
||||||
|
});
|
||||||
|
|
||||||
|
btns.appendChild(saveBtn);
|
||||||
|
btns.appendChild(cancelBtn);
|
||||||
|
|
||||||
|
form.appendChild(textarea);
|
||||||
|
form.appendChild(typeSelect);
|
||||||
|
form.appendChild(btns);
|
||||||
|
contentDiv.appendChild(form);
|
||||||
|
textarea.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Expose
|
||||||
|
window.initAnnotations = initAnnotations;
|
||||||
|
})();
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
/**
|
||||||
|
* claim.js — Claim-ownership UI helper for unowned documents.
|
||||||
|
*
|
||||||
|
* Usage: call initClaimOwnership(fileId, i18n) after DOMContentLoaded.
|
||||||
|
* The i18n object must contain:
|
||||||
|
* confirm, success, failed
|
||||||
|
*/
|
||||||
|
function initClaimOwnership(fileId, i18n) {
|
||||||
|
var btn = document.getElementById('claim-btn');
|
||||||
|
var msg = document.getElementById('claim-msg');
|
||||||
|
if (!btn) return;
|
||||||
|
|
||||||
|
btn.addEventListener('click', function () {
|
||||||
|
if (!confirm(i18n.confirm)) return;
|
||||||
|
btn.disabled = true;
|
||||||
|
fetch('/api/files/' + fileId + '/claim', { method: 'POST' })
|
||||||
|
.then(function (r) { return r.json().then(function (d) { return { ok: r.ok, data: d }; }); })
|
||||||
|
.then(function (result) {
|
||||||
|
if (result.ok || (result.data && result.data.status === 'already_owned')) {
|
||||||
|
if (msg) {
|
||||||
|
msg.textContent = i18n.success;
|
||||||
|
msg.style.color = '#059669';
|
||||||
|
msg.style.display = msg.tagName === 'SPAN' ? 'inline' : 'block';
|
||||||
|
}
|
||||||
|
setTimeout(function () { location.reload(); }, 1200);
|
||||||
|
} else {
|
||||||
|
if (msg) {
|
||||||
|
msg.textContent = (result.data && result.data.detail) || i18n.failed;
|
||||||
|
msg.style.color = '#dc2626';
|
||||||
|
msg.style.display = msg.tagName === 'SPAN' ? 'inline' : 'block';
|
||||||
|
}
|
||||||
|
btn.disabled = false;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(function () {
|
||||||
|
if (msg) {
|
||||||
|
msg.textContent = i18n.failed;
|
||||||
|
msg.style.color = '#dc2626';
|
||||||
|
msg.style.display = msg.tagName === 'SPAN' ? 'inline' : 'block';
|
||||||
|
}
|
||||||
|
btn.disabled = false;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,462 @@
|
|||||||
|
// frontend/static/js/comments.js
|
||||||
|
// Comments panel — threaded comments with @mention autocomplete
|
||||||
|
|
||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
var _fileId = null;
|
||||||
|
var _currentUserId = null;
|
||||||
|
var _i18n = {};
|
||||||
|
var _mentionableUsers = [];
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Initialisation
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bootstrap the comments panel.
|
||||||
|
* @param {number} fileId
|
||||||
|
* @param {string} currentUserId
|
||||||
|
* @param {object} i18n
|
||||||
|
*/
|
||||||
|
function initComments(fileId, currentUserId, i18n) {
|
||||||
|
_fileId = fileId;
|
||||||
|
_currentUserId = currentUserId;
|
||||||
|
_i18n = i18n || {};
|
||||||
|
_loadComments();
|
||||||
|
_loadMentionableUsers();
|
||||||
|
|
||||||
|
var form = document.getElementById('comment-form');
|
||||||
|
if (form) {
|
||||||
|
form.addEventListener('submit', function (e) {
|
||||||
|
e.preventDefault();
|
||||||
|
_submitComment(null);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
var input = document.getElementById('comment-input');
|
||||||
|
if (input) {
|
||||||
|
input.addEventListener('input', function () {
|
||||||
|
_handleMentionInput(this);
|
||||||
|
});
|
||||||
|
input.addEventListener('keydown', function (e) {
|
||||||
|
_handleMentionKeydown(e);
|
||||||
|
});
|
||||||
|
// Close dropdown when clicking outside
|
||||||
|
document.addEventListener('click', function (e) {
|
||||||
|
var dropdown = document.getElementById('mention-dropdown');
|
||||||
|
if (dropdown && !dropdown.contains(e.target) && e.target !== input) {
|
||||||
|
dropdown.classList.add('hidden');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Data fetching
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function _loadComments() {
|
||||||
|
var container = document.getElementById('comments-list');
|
||||||
|
if (!container) return;
|
||||||
|
container.innerHTML = '<div class="comments-loading"><i class="fas fa-spinner fa-spin" aria-hidden="true"></i></div>';
|
||||||
|
|
||||||
|
fetch('/api/files/' + _fileId + '/comments')
|
||||||
|
.then(function (r) { return r.json(); })
|
||||||
|
.then(function (data) {
|
||||||
|
_renderComments(data.comments || [], container);
|
||||||
|
})
|
||||||
|
.catch(function () {
|
||||||
|
container.innerHTML = '<p class="comments-error">' + (_i18n.empty || 'No comments yet') + '</p>';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function _loadMentionableUsers() {
|
||||||
|
fetch('/api/users/mentionable')
|
||||||
|
.then(function (r) { return r.json(); })
|
||||||
|
.then(function (users) {
|
||||||
|
_mentionableUsers = users || [];
|
||||||
|
})
|
||||||
|
.catch(function () {
|
||||||
|
_mentionableUsers = [];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Rendering
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function _renderComments(comments, container) {
|
||||||
|
container.innerHTML = '';
|
||||||
|
if (!comments.length) {
|
||||||
|
container.innerHTML = '<p class="comments-empty"><i class="fas fa-comments" aria-hidden="true"></i> ' +
|
||||||
|
(_i18n.empty || 'No comments yet') + '</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (var i = 0; i < comments.length; i++) {
|
||||||
|
container.appendChild(_buildCommentNode(comments[i], false));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function _buildCommentNode(comment, isReply) {
|
||||||
|
var div = document.createElement('div');
|
||||||
|
div.className = 'comment-item' + (isReply ? ' comment-reply' : '') +
|
||||||
|
(comment.is_resolved ? ' comment-resolved' : '');
|
||||||
|
div.setAttribute('data-comment-id', comment.id);
|
||||||
|
|
||||||
|
// Header
|
||||||
|
var header = document.createElement('div');
|
||||||
|
header.className = 'comment-header';
|
||||||
|
|
||||||
|
var author = document.createElement('span');
|
||||||
|
author.className = 'comment-author';
|
||||||
|
author.textContent = comment.user_id;
|
||||||
|
|
||||||
|
var time = document.createElement('time');
|
||||||
|
time.className = 'comment-time';
|
||||||
|
time.setAttribute('datetime', comment.created_at);
|
||||||
|
time.textContent = _formatDate(comment.created_at);
|
||||||
|
|
||||||
|
header.appendChild(author);
|
||||||
|
header.appendChild(time);
|
||||||
|
|
||||||
|
if (comment.is_resolved) {
|
||||||
|
var badge = document.createElement('span');
|
||||||
|
badge.className = 'comment-resolved-badge';
|
||||||
|
badge.innerHTML = '<i class="fas fa-check-circle" aria-hidden="true"></i> ' + (_i18n.resolved || 'Resolved');
|
||||||
|
header.appendChild(badge);
|
||||||
|
}
|
||||||
|
|
||||||
|
div.appendChild(header);
|
||||||
|
|
||||||
|
// Body
|
||||||
|
var bodyDiv = document.createElement('div');
|
||||||
|
bodyDiv.className = 'comment-body';
|
||||||
|
bodyDiv.id = 'comment-body-' + comment.id;
|
||||||
|
bodyDiv.innerHTML = _renderMentions(comment.body);
|
||||||
|
div.appendChild(bodyDiv);
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
var actions = document.createElement('div');
|
||||||
|
actions.className = 'comment-actions';
|
||||||
|
|
||||||
|
// Reply button (only for top-level)
|
||||||
|
if (!isReply) {
|
||||||
|
var replyBtn = document.createElement('button');
|
||||||
|
replyBtn.type = 'button';
|
||||||
|
replyBtn.className = 'comment-action-btn';
|
||||||
|
replyBtn.innerHTML = '<i class="fas fa-reply" aria-hidden="true"></i> ' + (_i18n.add_reply || 'Reply');
|
||||||
|
replyBtn.setAttribute('aria-label', _i18n.add_reply || 'Reply');
|
||||||
|
replyBtn.addEventListener('click', function () { _showReplyForm(comment.id, div); });
|
||||||
|
actions.appendChild(replyBtn);
|
||||||
|
|
||||||
|
// Resolve / Unresolve
|
||||||
|
var resolveBtn = document.createElement('button');
|
||||||
|
resolveBtn.type = 'button';
|
||||||
|
resolveBtn.className = 'comment-action-btn';
|
||||||
|
if (comment.is_resolved) {
|
||||||
|
resolveBtn.innerHTML = '<i class="fas fa-undo" aria-hidden="true"></i> ' + (_i18n.unresolve || 'Reopen');
|
||||||
|
resolveBtn.setAttribute('aria-label', _i18n.unresolve || 'Reopen');
|
||||||
|
} else {
|
||||||
|
resolveBtn.innerHTML = '<i class="fas fa-check" aria-hidden="true"></i> ' + (_i18n.resolve || 'Resolve');
|
||||||
|
resolveBtn.setAttribute('aria-label', _i18n.resolve || 'Resolve');
|
||||||
|
}
|
||||||
|
resolveBtn.addEventListener('click', function () { _toggleResolve(comment.id, !comment.is_resolved); });
|
||||||
|
actions.appendChild(resolveBtn);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Edit (author only)
|
||||||
|
if (comment.user_id === _currentUserId) {
|
||||||
|
var editBtn = document.createElement('button');
|
||||||
|
editBtn.type = 'button';
|
||||||
|
editBtn.className = 'comment-action-btn';
|
||||||
|
editBtn.innerHTML = '<i class="fas fa-edit" aria-hidden="true"></i> ' + (_i18n.edit || 'Edit');
|
||||||
|
editBtn.setAttribute('aria-label', _i18n.edit || 'Edit');
|
||||||
|
editBtn.addEventListener('click', function () { _showEditForm(comment.id, comment.body, div); });
|
||||||
|
actions.appendChild(editBtn);
|
||||||
|
|
||||||
|
// Delete
|
||||||
|
var deleteBtn = document.createElement('button');
|
||||||
|
deleteBtn.type = 'button';
|
||||||
|
deleteBtn.className = 'comment-action-btn comment-action-btn--danger';
|
||||||
|
deleteBtn.innerHTML = '<i class="fas fa-trash" aria-hidden="true"></i>';
|
||||||
|
deleteBtn.setAttribute('aria-label', 'Delete comment');
|
||||||
|
deleteBtn.addEventListener('click', function () { _deleteComment(comment.id); });
|
||||||
|
actions.appendChild(deleteBtn);
|
||||||
|
}
|
||||||
|
|
||||||
|
div.appendChild(actions);
|
||||||
|
|
||||||
|
// Replies
|
||||||
|
if (comment.replies && comment.replies.length) {
|
||||||
|
var repliesDiv = document.createElement('div');
|
||||||
|
repliesDiv.className = 'comment-replies';
|
||||||
|
for (var j = 0; j < comment.replies.length; j++) {
|
||||||
|
repliesDiv.appendChild(_buildCommentNode(comment.replies[j], true));
|
||||||
|
}
|
||||||
|
div.appendChild(repliesDiv);
|
||||||
|
}
|
||||||
|
|
||||||
|
return div;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _renderMentions(text) {
|
||||||
|
if (!text) return '';
|
||||||
|
// Escape HTML first
|
||||||
|
var escaped = text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||||
|
// Highlight @mentions
|
||||||
|
return escaped.replace(/@([\w.\-]+)/g, '<span class="comment-mention">@$1</span>');
|
||||||
|
}
|
||||||
|
|
||||||
|
function _formatDate(iso) {
|
||||||
|
if (!iso) return '';
|
||||||
|
try {
|
||||||
|
var d = new Date(iso);
|
||||||
|
return d.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' }) +
|
||||||
|
' ' + d.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' });
|
||||||
|
} catch (_e) {
|
||||||
|
return iso;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// Actions
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function _submitComment(parentId) {
|
||||||
|
var inputId = parentId ? 'reply-input-' + parentId : 'comment-input';
|
||||||
|
var input = document.getElementById(inputId);
|
||||||
|
if (!input) return;
|
||||||
|
var body = input.value.trim();
|
||||||
|
if (!body) return;
|
||||||
|
|
||||||
|
var payload = { body: body };
|
||||||
|
if (parentId) payload.parent_id = parentId;
|
||||||
|
|
||||||
|
fetch('/api/files/' + _fileId + '/comments', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
})
|
||||||
|
.then(function (r) {
|
||||||
|
if (!r.ok) throw new Error('Failed');
|
||||||
|
return r.json();
|
||||||
|
})
|
||||||
|
.then(function () {
|
||||||
|
input.value = '';
|
||||||
|
_loadComments();
|
||||||
|
})
|
||||||
|
.catch(function () {
|
||||||
|
// Silently fail — the CSRF wrapper in common.js handles token injection
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function _toggleResolve(commentId, resolve) {
|
||||||
|
fetch('/api/files/' + _fileId + '/comments/' + commentId + '/resolve', {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ is_resolved: resolve }),
|
||||||
|
})
|
||||||
|
.then(function (r) {
|
||||||
|
if (!r.ok) throw new Error('Failed');
|
||||||
|
_loadComments();
|
||||||
|
})
|
||||||
|
.catch(function () {});
|
||||||
|
}
|
||||||
|
|
||||||
|
function _deleteComment(commentId) {
|
||||||
|
if (!window.confirm(_i18n.delete_confirm || 'Are you sure you want to delete this comment?')) return;
|
||||||
|
|
||||||
|
fetch('/api/files/' + _fileId + '/comments/' + commentId, {
|
||||||
|
method: 'DELETE',
|
||||||
|
})
|
||||||
|
.then(function (r) {
|
||||||
|
if (!r.ok) throw new Error('Failed');
|
||||||
|
_loadComments();
|
||||||
|
})
|
||||||
|
.catch(function () {});
|
||||||
|
}
|
||||||
|
|
||||||
|
function _showReplyForm(commentId, containerNode) {
|
||||||
|
// Remove existing reply forms
|
||||||
|
var existing = containerNode.querySelector('.comment-reply-form');
|
||||||
|
if (existing) { existing.remove(); return; }
|
||||||
|
|
||||||
|
var form = document.createElement('div');
|
||||||
|
form.className = 'comment-reply-form';
|
||||||
|
|
||||||
|
var textarea = document.createElement('textarea');
|
||||||
|
textarea.id = 'reply-input-' + commentId;
|
||||||
|
textarea.className = 'comment-textarea';
|
||||||
|
textarea.placeholder = _i18n.reply_placeholder || 'Write a reply...';
|
||||||
|
textarea.rows = 2;
|
||||||
|
textarea.setAttribute('aria-label', _i18n.reply_placeholder || 'Write a reply...');
|
||||||
|
|
||||||
|
var submitBtn = document.createElement('button');
|
||||||
|
submitBtn.type = 'button';
|
||||||
|
submitBtn.className = 'comment-submit-btn';
|
||||||
|
submitBtn.textContent = _i18n.add_reply || 'Reply';
|
||||||
|
submitBtn.addEventListener('click', function () { _submitComment(commentId); });
|
||||||
|
|
||||||
|
form.appendChild(textarea);
|
||||||
|
form.appendChild(submitBtn);
|
||||||
|
|
||||||
|
// Insert before the replies section or at end
|
||||||
|
var repliesDiv = containerNode.querySelector('.comment-replies');
|
||||||
|
if (repliesDiv) {
|
||||||
|
containerNode.insertBefore(form, repliesDiv);
|
||||||
|
} else {
|
||||||
|
containerNode.appendChild(form);
|
||||||
|
}
|
||||||
|
textarea.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function _showEditForm(commentId, currentBody, containerNode) {
|
||||||
|
var bodyDiv = document.getElementById('comment-body-' + commentId);
|
||||||
|
if (!bodyDiv) return;
|
||||||
|
|
||||||
|
// Already editing?
|
||||||
|
if (bodyDiv.querySelector('.comment-edit-form')) return;
|
||||||
|
|
||||||
|
var originalHTML = bodyDiv.innerHTML;
|
||||||
|
bodyDiv.innerHTML = '';
|
||||||
|
|
||||||
|
var form = document.createElement('div');
|
||||||
|
form.className = 'comment-edit-form';
|
||||||
|
|
||||||
|
var textarea = document.createElement('textarea');
|
||||||
|
textarea.className = 'comment-textarea';
|
||||||
|
textarea.value = currentBody;
|
||||||
|
textarea.rows = 3;
|
||||||
|
textarea.setAttribute('aria-label', _i18n.edit || 'Edit');
|
||||||
|
|
||||||
|
var btns = document.createElement('div');
|
||||||
|
btns.className = 'comment-edit-btns';
|
||||||
|
|
||||||
|
var saveBtn = document.createElement('button');
|
||||||
|
saveBtn.type = 'button';
|
||||||
|
saveBtn.className = 'comment-submit-btn';
|
||||||
|
saveBtn.textContent = _i18n.save || 'Save';
|
||||||
|
saveBtn.addEventListener('click', function () {
|
||||||
|
var newBody = textarea.value.trim();
|
||||||
|
if (!newBody) return;
|
||||||
|
fetch('/api/files/' + _fileId + '/comments/' + commentId, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ body: newBody }),
|
||||||
|
})
|
||||||
|
.then(function (r) {
|
||||||
|
if (!r.ok) throw new Error('Failed');
|
||||||
|
_loadComments();
|
||||||
|
})
|
||||||
|
.catch(function () {
|
||||||
|
bodyDiv.innerHTML = originalHTML;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
var cancelBtn = document.createElement('button');
|
||||||
|
cancelBtn.type = 'button';
|
||||||
|
cancelBtn.className = 'comment-cancel-btn';
|
||||||
|
cancelBtn.textContent = _i18n.cancel || 'Cancel';
|
||||||
|
cancelBtn.addEventListener('click', function () {
|
||||||
|
bodyDiv.innerHTML = originalHTML;
|
||||||
|
});
|
||||||
|
btns.appendChild(cancelBtn);
|
||||||
|
form.appendChild(textarea);
|
||||||
|
form.appendChild(btns);
|
||||||
|
bodyDiv.appendChild(form);
|
||||||
|
textarea.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// @mention autocomplete
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function _handleMentionInput(input) {
|
||||||
|
var val = input.value;
|
||||||
|
var cursorPos = input.selectionStart;
|
||||||
|
var textBefore = val.substring(0, cursorPos);
|
||||||
|
var match = textBefore.match(/@([\w.\-]*)$/);
|
||||||
|
|
||||||
|
var dropdown = document.getElementById('mention-dropdown');
|
||||||
|
if (!dropdown) return;
|
||||||
|
|
||||||
|
if (!match) {
|
||||||
|
dropdown.classList.add('hidden');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var query = match[1].toLowerCase();
|
||||||
|
var filtered = _mentionableUsers.filter(function (u) {
|
||||||
|
return u.user_id.toLowerCase().indexOf(query) !== -1 ||
|
||||||
|
(u.display_name && u.display_name.toLowerCase().indexOf(query) !== -1);
|
||||||
|
}).slice(0, 8);
|
||||||
|
|
||||||
|
if (!filtered.length) {
|
||||||
|
dropdown.classList.add('hidden');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
dropdown.innerHTML = '';
|
||||||
|
for (var i = 0; i < filtered.length; i++) {
|
||||||
|
(function (user) {
|
||||||
|
var item = document.createElement('button');
|
||||||
|
item.type = 'button';
|
||||||
|
item.className = 'mention-item';
|
||||||
|
item.setAttribute('role', 'option');
|
||||||
|
item.innerHTML = '<span class="mention-user-id">' + _escapeHtml(user.user_id) + '</span>' +
|
||||||
|
(user.display_name ? '<span class="mention-display-name">' + _escapeHtml(user.display_name) + '</span>' : '');
|
||||||
|
item.addEventListener('click', function () {
|
||||||
|
_insertMention(input, match.index, cursorPos, user.user_id);
|
||||||
|
dropdown.classList.add('hidden');
|
||||||
|
});
|
||||||
|
dropdown.appendChild(item);
|
||||||
|
})(filtered[i]);
|
||||||
|
}
|
||||||
|
dropdown.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
function _handleMentionKeydown(e) {
|
||||||
|
var dropdown = document.getElementById('mention-dropdown');
|
||||||
|
if (!dropdown || dropdown.classList.contains('hidden')) return;
|
||||||
|
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
dropdown.classList.add('hidden');
|
||||||
|
e.preventDefault();
|
||||||
|
} else if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
|
||||||
|
e.preventDefault();
|
||||||
|
var items = dropdown.querySelectorAll('.mention-item');
|
||||||
|
var focused = dropdown.querySelector('.mention-item:focus');
|
||||||
|
var idx = Array.prototype.indexOf.call(items, focused);
|
||||||
|
if (e.key === 'ArrowDown') {
|
||||||
|
idx = (idx + 1) % items.length;
|
||||||
|
} else {
|
||||||
|
idx = idx <= 0 ? items.length - 1 : idx - 1;
|
||||||
|
}
|
||||||
|
items[idx].focus();
|
||||||
|
} else if (e.key === 'Enter' || e.key === 'Tab') {
|
||||||
|
var active = dropdown.querySelector('.mention-item:focus');
|
||||||
|
if (active) {
|
||||||
|
active.click();
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function _insertMention(input, matchStart, cursorPos, userId) {
|
||||||
|
var before = input.value.substring(0, matchStart);
|
||||||
|
var after = input.value.substring(cursorPos);
|
||||||
|
input.value = before + '@' + userId + ' ' + after;
|
||||||
|
var newPos = matchStart + userId.length + 2;
|
||||||
|
input.setSelectionRange(newPos, newPos);
|
||||||
|
input.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function _escapeHtml(str) {
|
||||||
|
return str.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"').replace(/'/g, ''');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Expose
|
||||||
|
window.initComments = initComments;
|
||||||
|
})();
|
||||||
@@ -0,0 +1,224 @@
|
|||||||
|
/**
|
||||||
|
* sharing.js – File sharing management UI.
|
||||||
|
*
|
||||||
|
* Renders the current shares for a document and lets the file owner
|
||||||
|
* add new shares, change roles, or revoke access.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* initSharing(fileId, i18n)
|
||||||
|
*
|
||||||
|
* The i18n object is expected to contain all keys used below.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* global fetch */
|
||||||
|
|
||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
var _fileId = null;
|
||||||
|
var _i18n = {};
|
||||||
|
|
||||||
|
// ── DOM helpers ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function _el(id) {
|
||||||
|
return document.getElementById(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function _esc(str) {
|
||||||
|
return String(str)
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''');
|
||||||
|
}
|
||||||
|
|
||||||
|
function _t(key) {
|
||||||
|
return _i18n[key] || key;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── API helpers ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function _apiUrl(suffix) {
|
||||||
|
return '/api/files/' + _fileId + suffix;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _fetchShares() {
|
||||||
|
return fetch(_apiUrl('/shares'), { credentials: 'same-origin' })
|
||||||
|
.then(function (r) { return r.json(); });
|
||||||
|
}
|
||||||
|
|
||||||
|
function _addShare(userId, role) {
|
||||||
|
return fetch(_apiUrl('/shares'), {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'same-origin',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ shared_with_user_id: userId, role: role }),
|
||||||
|
}).then(function (r) {
|
||||||
|
return r.json().then(function (body) {
|
||||||
|
if (!r.ok) throw new Error((body && body.detail) || r.statusText);
|
||||||
|
return body;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function _updateRole(shareId, role) {
|
||||||
|
return fetch(_apiUrl('/shares/' + shareId), {
|
||||||
|
method: 'PUT',
|
||||||
|
credentials: 'same-origin',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ role: role }),
|
||||||
|
}).then(function (r) {
|
||||||
|
return r.json().then(function (body) {
|
||||||
|
if (!r.ok) throw new Error((body && body.detail) || r.statusText);
|
||||||
|
return body;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function _revokeShare(shareId) {
|
||||||
|
return fetch(_apiUrl('/shares/' + shareId), {
|
||||||
|
method: 'DELETE',
|
||||||
|
credentials: 'same-origin',
|
||||||
|
}).then(function (r) {
|
||||||
|
return r.json().then(function (body) {
|
||||||
|
if (!r.ok) throw new Error((body && body.detail) || r.statusText);
|
||||||
|
return body;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Render ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function _renderShares(shares) {
|
||||||
|
var list = _el('sharing-list');
|
||||||
|
if (!list) return;
|
||||||
|
|
||||||
|
if (!shares || shares.length === 0) {
|
||||||
|
list.innerHTML = '<p style="color:#64748b;font-size:0.875rem;">' + _esc(_t('no_shares')) + '</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var rows = shares.map(function (s) {
|
||||||
|
var roleLabel = s.role === 'editor' ? _t('role_editor') : _t('role_viewer');
|
||||||
|
return (
|
||||||
|
'<div style="display:flex;align-items:center;justify-content:space-between;gap:0.5rem;padding:0.5rem 0;border-bottom:1px solid #f1f5f9;">' +
|
||||||
|
'<span style="font-size:0.875rem;color:#334155;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1;" ' +
|
||||||
|
'aria-label="' + _esc(_t('user_id_label') + ': ' + (s.display_name || s.user_id)) + '" ' +
|
||||||
|
'title="' + _esc(s.user_id) + '">' +
|
||||||
|
_esc(s.display_name || s.user_id) +
|
||||||
|
'</span>' +
|
||||||
|
'<select' +
|
||||||
|
' data-share-id="' + _esc(s.share_id) + '"' +
|
||||||
|
' class="sharing-role-select"' +
|
||||||
|
' aria-label="' + _esc(_t('change_role')) + '"' +
|
||||||
|
' style="padding:0.25rem 0.5rem;border:1px solid #cbd5e1;border-radius:0.25rem;font-size:0.8rem;background:#fff;"' +
|
||||||
|
'>' +
|
||||||
|
'<option value="viewer"' + (s.role === 'viewer' ? ' selected' : '') + '>' + _esc(_t('role_viewer')) + '</option>' +
|
||||||
|
'<option value="editor"' + (s.role === 'editor' ? ' selected' : '') + '>' + _esc(_t('role_editor')) + '</option>' +
|
||||||
|
'</select>' +
|
||||||
|
'<button' +
|
||||||
|
' data-share-id="' + _esc(s.share_id) + '"' +
|
||||||
|
' class="sharing-revoke-btn"' +
|
||||||
|
' aria-label="' + _esc(_t('revoke')) + '"' +
|
||||||
|
' title="' + _esc(_t('revoke')) + '"' +
|
||||||
|
' style="padding:0.25rem 0.5rem;background:#fee2e2;color:#b91c1c;border:1px solid #fca5a5;border-radius:0.25rem;font-size:0.8rem;cursor:pointer;"' +
|
||||||
|
'>' +
|
||||||
|
'<i class="fas fa-user-minus" aria-hidden="true"></i>' +
|
||||||
|
'</button>' +
|
||||||
|
'</div>'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
list.innerHTML = rows.join('');
|
||||||
|
|
||||||
|
// Role change handlers
|
||||||
|
list.querySelectorAll('.sharing-role-select').forEach(function (sel) {
|
||||||
|
sel.addEventListener('change', function () {
|
||||||
|
var shareId = sel.getAttribute('data-share-id');
|
||||||
|
var newRole = sel.value;
|
||||||
|
_updateRole(shareId, newRole)
|
||||||
|
.then(function () { _loadAndRender(); })
|
||||||
|
.catch(function (err) { _showError(err.message); });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Revoke handlers
|
||||||
|
list.querySelectorAll('.sharing-revoke-btn').forEach(function (btn) {
|
||||||
|
btn.addEventListener('click', function () {
|
||||||
|
if (!window.confirm(_t('revoke_confirm'))) return;
|
||||||
|
var shareId = btn.getAttribute('data-share-id');
|
||||||
|
_revokeShare(shareId)
|
||||||
|
.then(function () { _loadAndRender(); })
|
||||||
|
.catch(function (err) { _showError(err.message); });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function _loadAndRender() {
|
||||||
|
var list = _el('sharing-list');
|
||||||
|
if (!list) return;
|
||||||
|
list.innerHTML = '<p style="color:#64748b;font-size:0.875rem;">' + _esc(_t('loading')) + '</p>';
|
||||||
|
_fetchShares()
|
||||||
|
.then(function (data) {
|
||||||
|
// GET /files/{id}/shares returns an array; /files/{id}/shared-with also returns array
|
||||||
|
var shares = Array.isArray(data) ? data : (data.shares || []);
|
||||||
|
// Normalise keys: shares list uses share_id, but the shares endpoint returns id
|
||||||
|
shares = shares.map(function (s) {
|
||||||
|
return {
|
||||||
|
share_id: s.share_id || s.id,
|
||||||
|
user_id: s.user_id || s.shared_with_user_id,
|
||||||
|
display_name: s.display_name || s.shared_with_user_id || s.user_id,
|
||||||
|
role: s.role,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
_renderShares(shares);
|
||||||
|
})
|
||||||
|
.catch(function (err) {
|
||||||
|
if (list) list.innerHTML = '<p style="color:#ef4444;font-size:0.875rem;">' + _esc(err.message) + '</p>';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function _showError(msg) {
|
||||||
|
var el = _el('sharing-form-error');
|
||||||
|
if (!el) return;
|
||||||
|
el.textContent = msg;
|
||||||
|
el.style.display = 'block';
|
||||||
|
setTimeout(function () { el.style.display = 'none'; }, 5000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Init ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function initSharing(fileId, i18n) {
|
||||||
|
_fileId = fileId;
|
||||||
|
_i18n = i18n || {};
|
||||||
|
|
||||||
|
_loadAndRender();
|
||||||
|
|
||||||
|
var form = _el('sharing-form');
|
||||||
|
if (!form) return;
|
||||||
|
|
||||||
|
form.addEventListener('submit', function (e) {
|
||||||
|
e.preventDefault();
|
||||||
|
var userInput = _el('share-user-input');
|
||||||
|
var roleInput = _el('share-role-input');
|
||||||
|
var userId = userInput ? userInput.value.trim() : '';
|
||||||
|
var role = roleInput ? roleInput.value : 'viewer';
|
||||||
|
|
||||||
|
if (!userId) {
|
||||||
|
_showError(_t('error_empty_user'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_addShare(userId, role)
|
||||||
|
.then(function () {
|
||||||
|
if (userInput) userInput.value = '';
|
||||||
|
_loadAndRender();
|
||||||
|
})
|
||||||
|
.catch(function (err) { _showError(err.message); });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Expose
|
||||||
|
window.initSharing = initSharing;
|
||||||
|
})();
|
||||||
+2
-239
File diff suppressed because one or more lines are too long
@@ -0,0 +1,12 @@
|
|||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
module.exports = {
|
||||||
|
content: [
|
||||||
|
'./templates/**/*.html',
|
||||||
|
'./static/js/**/*.js',
|
||||||
|
],
|
||||||
|
darkMode: 'class',
|
||||||
|
theme: {
|
||||||
|
extend: {},
|
||||||
|
},
|
||||||
|
plugins: [],
|
||||||
|
}
|
||||||
@@ -0,0 +1,439 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}{{ _("connections.title") }} - {{ _("app.name") }}{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<main id="main-content" class="max-w-4xl mx-auto px-4 py-8">
|
||||||
|
<div class="mb-8">
|
||||||
|
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">{{ _("connections.title") }}</h1>
|
||||||
|
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">{{ _("connections.description") }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- SSO Auto Login Section -->
|
||||||
|
{% if oauth_configured %}
|
||||||
|
<section class="bg-white dark:bg-gray-800 shadow rounded-lg p-6 mb-6">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">{{ _("connections.sso_auto_login_title") }}</h2>
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">{{ _("connections.sso_auto_login_description") }}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="doc-toggle">
|
||||||
|
<input type="checkbox" id="sso-auto-login-toggle" class="sr-only"
|
||||||
|
{% if sso_auto_login %}checked{% endif %}
|
||||||
|
onchange="toggleSetting('sso_auto_login', this.checked)">
|
||||||
|
<span class="doc-toggle-track" aria-hidden="true"></span>
|
||||||
|
<span class="ml-3 text-sm font-medium text-gray-700 dark:text-gray-300">{{ _("connections.sso_auto_login") }}</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Mobile Phone Upload Section -->
|
||||||
|
<section class="bg-white dark:bg-gray-800 shadow rounded-lg p-6 mb-6">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">{{ _("connections.mobile_upload_title") }}</h2>
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">{{ _("connections.mobile_upload_description") }}</p>
|
||||||
|
{% if not frontend_url_configured %}
|
||||||
|
<p class="text-xs text-amber-600 dark:text-amber-400 mt-2">
|
||||||
|
<i class="fas fa-exclamation-triangle mr-1" aria-hidden="true"></i>
|
||||||
|
{{ _("connections.frontend_url_note") }}
|
||||||
|
</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="doc-toggle">
|
||||||
|
<input type="checkbox" id="qr-upload-toggle" class="sr-only"
|
||||||
|
{% if qr_login_enabled %}checked{% endif %}
|
||||||
|
onchange="toggleSetting('qr_login_enabled', this.checked)">
|
||||||
|
<span class="doc-toggle-track" aria-hidden="true"></span>
|
||||||
|
<span class="ml-3 text-sm font-medium text-gray-700 dark:text-gray-300">{{ _("connections.qr_code_enabled") }}</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Services Section -->
|
||||||
|
<section class="mb-6">
|
||||||
|
<h2 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">{{ _("connections.unlinked_services") }}</h2>
|
||||||
|
<div class="space-y-3">
|
||||||
|
{% for service in services %}
|
||||||
|
<div class="bg-white dark:bg-gray-800 shadow rounded-lg overflow-hidden border border-gray-100 dark:border-gray-700">
|
||||||
|
<div class="p-5 flex flex-col sm:flex-row sm:items-center gap-4">
|
||||||
|
<div class="flex items-center gap-3 flex-1 min-w-0">
|
||||||
|
<div class="flex-shrink-0 w-10 h-10 flex items-center justify-center rounded-lg bg-gray-100 dark:bg-gray-700">
|
||||||
|
<i class="{{ service.icon }} text-lg" aria-hidden="true"></i>
|
||||||
|
</div>
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<h3 class="text-base font-semibold text-gray-900 dark:text-white truncate">{{ service.name }}</h3>
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400">{{ service.description }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
{% if service.linked %}
|
||||||
|
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200">
|
||||||
|
<i class="fas fa-check-circle mr-1" aria-hidden="true"></i> {{ _("connections.linked") }}
|
||||||
|
</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400">
|
||||||
|
{{ _("connections.unlinked") }}
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="inline-flex items-center px-4 py-2 text-sm font-medium rounded-md border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-200 bg-white dark:bg-gray-800 hover:bg-gray-50 dark:hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||||
|
style="min-height:44px; min-width:44px"
|
||||||
|
onclick="openServiceModal('{{ service.key }}')"
|
||||||
|
aria-label="{{ _('connections.configure') }} {{ service.name }}"
|
||||||
|
>
|
||||||
|
<i class="fas fa-cog mr-2" aria-hidden="true"></i>
|
||||||
|
{{ _("connections.configure") }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<p class="text-xs text-gray-400 dark:text-gray-500 mt-4">
|
||||||
|
<i class="fas fa-info-circle mr-1" aria-hidden="true"></i>
|
||||||
|
{{ _("connections.save_note") }}
|
||||||
|
</p>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<!-- Service Configuration Modal -->
|
||||||
|
<div id="service-modal" class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 px-4 hidden" role="dialog" aria-modal="true" aria-labelledby="service-modal-title" aria-describedby="service-modal-description">
|
||||||
|
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-xl w-full max-w-lg overflow-y-auto max-h-[90vh]" onclick="event.stopPropagation()">
|
||||||
|
<div class="px-6 pt-6 pb-2 flex items-center justify-between border-b border-gray-200 dark:border-gray-700">
|
||||||
|
<div>
|
||||||
|
<h2 id="service-modal-title" class="text-lg font-semibold text-gray-900 dark:text-white"></h2>
|
||||||
|
<p id="service-modal-description" class="text-xs text-gray-500 dark:text-gray-400 mt-1">{{ _("connections.save_note") }}</p>
|
||||||
|
</div>
|
||||||
|
<button type="button" onclick="closeServiceModal()" class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 focus:outline-none focus:ring-2 focus:ring-indigo-500 rounded-md p-1" style="min-height:44px; min-width:44px" aria-label="Close">
|
||||||
|
<i class="fas fa-times text-xl" aria-hidden="true"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<form id="service-form" onsubmit="saveServiceSettings(event)">
|
||||||
|
<div id="service-fields" class="px-6 py-4 space-y-4">
|
||||||
|
<!-- Dynamically populated -->
|
||||||
|
</div>
|
||||||
|
<div class="px-6 py-4 border-t border-gray-200 dark:border-gray-700 flex justify-end gap-3">
|
||||||
|
<button type="button" onclick="closeServiceModal()" class="px-4 py-2 text-sm font-medium rounded-md border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700" style="min-height:44px;">
|
||||||
|
{{ _("common.cancel") }}
|
||||||
|
</button>
|
||||||
|
<button type="submit" class="inline-flex items-center px-4 py-2 text-sm font-medium rounded-md bg-indigo-600 hover:bg-indigo-700 text-white focus:outline-none focus:ring-2 focus:ring-indigo-500" style="min-height:44px;">
|
||||||
|
<i class="fas fa-save mr-2" aria-hidden="true"></i>
|
||||||
|
{{ _("common.save") }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Accessible notification banner -->
|
||||||
|
<div id="save-notification" class="fixed top-4 right-4 z-[60] hidden max-w-sm" role="alert" aria-live="assertive">
|
||||||
|
<div id="save-notification-inner" class="rounded-lg shadow-lg px-4 py-3 text-sm font-medium flex items-center gap-2">
|
||||||
|
<i id="save-notification-icon" class="fas" aria-hidden="true"></i>
|
||||||
|
<span id="save-notification-text"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const serviceSettings = {{ service_settings | tojson }};
|
||||||
|
const settingMetadata = {};
|
||||||
|
{% for svc_key, svc_fields in service_settings.items() %}
|
||||||
|
{% for field in svc_fields %}
|
||||||
|
settingMetadata['{{ field.key }}'] = {{ field.metadata | tojson }};
|
||||||
|
{% endfor %}
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
let currentServiceKey = null;
|
||||||
|
|
||||||
|
function openServiceModal(serviceKey) {
|
||||||
|
currentServiceKey = serviceKey;
|
||||||
|
const fields = serviceSettings[serviceKey] || [];
|
||||||
|
const modal = document.getElementById('service-modal');
|
||||||
|
const title = document.getElementById('service-modal-title');
|
||||||
|
const container = document.getElementById('service-fields');
|
||||||
|
|
||||||
|
// Find service name from the page
|
||||||
|
const serviceCards = document.querySelectorAll('[onclick*="' + serviceKey + '"]');
|
||||||
|
let serviceName = serviceKey;
|
||||||
|
if (serviceCards.length > 0) {
|
||||||
|
const card = serviceCards[0].closest('.bg-white, .dark\\:bg-gray-800');
|
||||||
|
if (card) {
|
||||||
|
const h3 = card.querySelector('h3');
|
||||||
|
if (h3) serviceName = h3.textContent.trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
title.textContent = '{{ _("connections.configure") }} ' + serviceName;
|
||||||
|
|
||||||
|
container.innerHTML = '';
|
||||||
|
fields.forEach(function(field) {
|
||||||
|
const meta = field.metadata || {};
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.setAttribute('data-field-key', field.key);
|
||||||
|
|
||||||
|
if (meta.type === 'boolean') {
|
||||||
|
// For boolean fields use a plain paragraph for the title so the toggle's
|
||||||
|
// own <label> wrapper is the only interactive label (no nested <label>s).
|
||||||
|
const titleEl = document.createElement('p');
|
||||||
|
titleEl.className = 'block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1';
|
||||||
|
titleEl.textContent = field.key.replace(/_/g, ' ').replace(/\b\w/g, function(c) { return c.toUpperCase(); });
|
||||||
|
div.appendChild(titleEl);
|
||||||
|
} else {
|
||||||
|
const label = document.createElement('label');
|
||||||
|
label.setAttribute('for', 'field-' + field.key);
|
||||||
|
label.className = 'block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1';
|
||||||
|
label.textContent = field.key.replace(/_/g, ' ').replace(/\b\w/g, function(c) { return c.toUpperCase(); });
|
||||||
|
div.appendChild(label);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (meta.description) {
|
||||||
|
const desc = document.createElement('p');
|
||||||
|
desc.className = 'text-xs text-gray-500 dark:text-gray-400 mb-2';
|
||||||
|
desc.textContent = meta.description;
|
||||||
|
div.appendChild(desc);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (meta.type === 'boolean') {
|
||||||
|
// Styled toggle switch using .doc-toggle CSS class (compatible with Tailwind v2 CDN).
|
||||||
|
const toggleWrapper = document.createElement('label');
|
||||||
|
toggleWrapper.className = 'doc-toggle';
|
||||||
|
toggleWrapper.setAttribute('aria-label', field.key.replace(/_/g, ' ').replace(/\b\w/g, function(c) { return c.toUpperCase(); }));
|
||||||
|
|
||||||
|
const checkbox = document.createElement('input');
|
||||||
|
checkbox.type = 'checkbox';
|
||||||
|
checkbox.id = 'field-' + field.key;
|
||||||
|
checkbox.name = field.key;
|
||||||
|
checkbox.className = 'sr-only';
|
||||||
|
const val = field.value;
|
||||||
|
if (val === true || val === 'true' || val === '1' || val === 'True') {
|
||||||
|
checkbox.checked = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const slider = document.createElement('span');
|
||||||
|
slider.className = 'doc-toggle-track';
|
||||||
|
slider.setAttribute('aria-hidden', 'true');
|
||||||
|
|
||||||
|
toggleWrapper.appendChild(checkbox);
|
||||||
|
toggleWrapper.appendChild(slider);
|
||||||
|
div.appendChild(toggleWrapper);
|
||||||
|
|
||||||
|
// When the Dropbox "use global credentials" toggle changes, update the
|
||||||
|
// visibility of the separate client-id / client-secret fields.
|
||||||
|
if (field.key === 'social_auth_dropbox_use_global_credentials') {
|
||||||
|
checkbox.addEventListener('change', function() {
|
||||||
|
updateDropboxCredentialFieldsVisibility(this.checked);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// When the Google "use global credentials" toggle changes, update visibility.
|
||||||
|
if (field.key === 'social_auth_google_use_global_credentials') {
|
||||||
|
checkbox.addEventListener('change', function() {
|
||||||
|
updateGoogleCredentialFieldsVisibility(this.checked);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// When the Microsoft "use global credentials" toggle changes, update visibility.
|
||||||
|
if (field.key === 'social_auth_microsoft_use_global_credentials') {
|
||||||
|
checkbox.addEventListener('change', function() {
|
||||||
|
updateMicrosoftCredentialFieldsVisibility(this.checked);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const input = document.createElement('input');
|
||||||
|
input.type = meta.sensitive ? 'password' : 'text';
|
||||||
|
input.id = 'field-' + field.key;
|
||||||
|
input.name = field.key;
|
||||||
|
input.className = 'w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 dark:bg-gray-700 dark:text-white text-sm';
|
||||||
|
input.placeholder = meta.sensitive ? '••••••••' : '';
|
||||||
|
// Don't pre-fill sensitive values with masked data
|
||||||
|
if (!meta.sensitive && field.value != null) {
|
||||||
|
input.value = field.value;
|
||||||
|
}
|
||||||
|
div.appendChild(input);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (meta.help_link) {
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = meta.help_link;
|
||||||
|
link.target = '_blank';
|
||||||
|
link.rel = 'noopener noreferrer';
|
||||||
|
link.className = 'text-xs text-indigo-600 hover:text-indigo-500 mt-1 inline-block';
|
||||||
|
link.textContent = meta.help_link_label || 'Documentation';
|
||||||
|
div.appendChild(link);
|
||||||
|
}
|
||||||
|
|
||||||
|
container.appendChild(div);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Apply initial visibility for Dropbox credential fields based on the
|
||||||
|
// current value of the "use global credentials" toggle.
|
||||||
|
if (currentServiceKey === 'dropbox') {
|
||||||
|
const useGlobalField = fields.find(function(f) { return f.key === 'social_auth_dropbox_use_global_credentials'; });
|
||||||
|
if (useGlobalField) {
|
||||||
|
const val = useGlobalField.value;
|
||||||
|
const isGlobal = val === true || val === 'true' || val === '1' || val === 'True';
|
||||||
|
updateDropboxCredentialFieldsVisibility(isGlobal);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Apply initial visibility for Google credential fields.
|
||||||
|
if (currentServiceKey === 'google') {
|
||||||
|
const useGlobalField = fields.find(function(f) { return f.key === 'social_auth_google_use_global_credentials'; });
|
||||||
|
if (useGlobalField) {
|
||||||
|
const val = useGlobalField.value;
|
||||||
|
const isGlobal = val === true || val === 'true' || val === '1' || val === 'True';
|
||||||
|
updateGoogleCredentialFieldsVisibility(isGlobal);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Apply initial visibility for Microsoft credential fields.
|
||||||
|
if (currentServiceKey === 'microsoft') {
|
||||||
|
const useGlobalField = fields.find(function(f) { return f.key === 'social_auth_microsoft_use_global_credentials'; });
|
||||||
|
if (useGlobalField) {
|
||||||
|
const val = useGlobalField.value;
|
||||||
|
const isGlobal = val === true || val === 'true' || val === '1' || val === 'True';
|
||||||
|
updateMicrosoftCredentialFieldsVisibility(isGlobal);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
modal.classList.remove('hidden');
|
||||||
|
// Focus first input
|
||||||
|
setTimeout(function() {
|
||||||
|
const firstInput = container.querySelector('input');
|
||||||
|
if (firstInput) firstInput.focus();
|
||||||
|
}, 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeServiceModal() {
|
||||||
|
document.getElementById('service-modal').classList.add('hidden');
|
||||||
|
currentServiceKey = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveServiceSettings(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!currentServiceKey) return;
|
||||||
|
|
||||||
|
const fields = serviceSettings[currentServiceKey] || [];
|
||||||
|
const updates = {};
|
||||||
|
|
||||||
|
fields.forEach(function(field) {
|
||||||
|
const meta = field.metadata || {};
|
||||||
|
const el = document.getElementById('field-' + field.key);
|
||||||
|
if (!el) return;
|
||||||
|
|
||||||
|
if (meta.type === 'boolean') {
|
||||||
|
updates[field.key] = el.checked ? 'true' : 'false';
|
||||||
|
} else {
|
||||||
|
// Only send non-empty values for sensitive fields (empty = no change)
|
||||||
|
if (meta.sensitive && !el.value) return;
|
||||||
|
updates[field.key] = el.value;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Save each setting via the API
|
||||||
|
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content || '';
|
||||||
|
const promises = Object.entries(updates).map(function([key, value]) {
|
||||||
|
return fetch('/api/settings/' + key, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-CSRF-Token': csrfToken,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ value: value }),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
Promise.all(promises)
|
||||||
|
.then(function(responses) {
|
||||||
|
const allOk = responses.every(function(r) { return r.ok; });
|
||||||
|
if (allOk) {
|
||||||
|
closeServiceModal();
|
||||||
|
location.reload();
|
||||||
|
} else {
|
||||||
|
showNotification('error', 'Some settings failed to save. Please check the values and try again.');
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(function(err) {
|
||||||
|
console.error('Error saving settings:', err);
|
||||||
|
showNotification('error', 'Failed to save settings. Please try again.');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show or hide the Dropbox client-id / client-secret fields depending on
|
||||||
|
// whether the "use global credentials" toggle is enabled.
|
||||||
|
function updateDropboxCredentialFieldsVisibility(useGlobal) {
|
||||||
|
['social_auth_dropbox_client_id', 'social_auth_dropbox_client_secret'].forEach(function(key) {
|
||||||
|
const el = document.querySelector('[data-field-key="' + key + '"]');
|
||||||
|
if (el) {
|
||||||
|
el.style.display = useGlobal ? 'none' : '';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show or hide the Google client-id / client-secret fields depending on
|
||||||
|
// whether the "use global credentials" toggle is enabled.
|
||||||
|
function updateGoogleCredentialFieldsVisibility(useGlobal) {
|
||||||
|
['social_auth_google_client_id', 'social_auth_google_client_secret'].forEach(function(key) {
|
||||||
|
const el = document.querySelector('[data-field-key="' + key + '"]');
|
||||||
|
if (el) {
|
||||||
|
el.style.display = useGlobal ? 'none' : '';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show or hide the Microsoft client-id / client-secret fields depending on
|
||||||
|
// whether the "use global credentials" toggle is enabled.
|
||||||
|
function updateMicrosoftCredentialFieldsVisibility(useGlobal) {
|
||||||
|
['social_auth_microsoft_client_id', 'social_auth_microsoft_client_secret'].forEach(function(key) {
|
||||||
|
const el = document.querySelector('[data-field-key="' + key + '"]');
|
||||||
|
if (el) {
|
||||||
|
el.style.display = useGlobal ? 'none' : '';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleSetting(key, value) {
|
||||||
|
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content || '';
|
||||||
|
fetch('/api/settings/' + key, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-CSRF-Token': csrfToken,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ value: value ? 'true' : 'false' }),
|
||||||
|
}).catch(function(err) {
|
||||||
|
console.error('Error toggling setting:', err);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close modal on Escape
|
||||||
|
document.addEventListener('keydown', function(e) {
|
||||||
|
if (e.key === 'Escape') closeServiceModal();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Close modal on backdrop click
|
||||||
|
document.getElementById('service-modal')?.addEventListener('click', function(e) {
|
||||||
|
if (e.target === this) closeServiceModal();
|
||||||
|
});
|
||||||
|
|
||||||
|
function showNotification(type, message) {
|
||||||
|
var el = document.getElementById('save-notification');
|
||||||
|
var inner = document.getElementById('save-notification-inner');
|
||||||
|
var icon = document.getElementById('save-notification-icon');
|
||||||
|
var text = document.getElementById('save-notification-text');
|
||||||
|
if (!el) return;
|
||||||
|
text.textContent = message;
|
||||||
|
if (type === 'error') {
|
||||||
|
inner.className = 'rounded-lg shadow-lg px-4 py-3 text-sm font-medium flex items-center gap-2 bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200';
|
||||||
|
icon.className = 'fas fa-exclamation-circle';
|
||||||
|
} else {
|
||||||
|
inner.className = 'rounded-lg shadow-lg px-4 py-3 text-sm font-medium flex items-center gap-2 bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200';
|
||||||
|
icon.className = 'fas fa-check-circle';
|
||||||
|
}
|
||||||
|
el.classList.remove('hidden');
|
||||||
|
setTimeout(function() { el.classList.add('hidden'); }, 5000);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@@ -21,8 +21,6 @@
|
|||||||
<!-- Alpine.js moved to head for earlier loading -->
|
<!-- Alpine.js moved to head for earlier loading -->
|
||||||
<script src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js" defer></script>
|
<script src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js" defer></script>
|
||||||
{% block head_css %}
|
{% block head_css %}
|
||||||
<!-- Tailwind CSS and other CSS -->
|
|
||||||
<link href="https://cdn.jsdelivr.net/npm/tailwindcss@2.2.19/dist/tailwind.min.css" rel="stylesheet">
|
|
||||||
<!-- Font Awesome -->
|
<!-- Font Awesome -->
|
||||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css"
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css"
|
||||||
integrity="sha512-DTOQO9RWCH3ppGqcWaEA1BIZOC6xxalwEsw9c2QQeAIftl+Vegovlnee1c9QX4TctnWMn13TZye+giMm8e2LwA=="
|
integrity="sha512-DTOQO9RWCH3ppGqcWaEA1BIZOC6xxalwEsw9c2QQeAIftl+Vegovlnee1c9QX4TctnWMn13TZye+giMm8e2LwA=="
|
||||||
@@ -34,6 +32,34 @@
|
|||||||
{% block head_extra %}{% endblock %}
|
{% block head_extra %}{% endblock %}
|
||||||
<!-- CSRF token for AJAX/fetch requests -->
|
<!-- CSRF token for AJAX/fetch requests -->
|
||||||
<meta name="csrf-token" content="{{ csrf_token | default('', true) }}">
|
<meta name="csrf-token" content="{{ csrf_token | default('', true) }}">
|
||||||
|
{# ── Sentry Browser SDK ─────────────────────────────────────────────────
|
||||||
|
Loaded only when SENTRY_DSN is configured. The DSN is a *public*
|
||||||
|
Sentry key and is intentionally embedded in client-side code.
|
||||||
|
Update the version pin at browser.sentry-cdn.com/releases when
|
||||||
|
upgrading the SDK.
|
||||||
|
──────────────────────────────────────────────────────────────────────── #}
|
||||||
|
{% if sentry_dsn %}
|
||||||
|
<script src="https://browser.sentry-cdn.com/10.45.0/bundle.tracing.replay.feedback.logs.metrics.min.js"
|
||||||
|
integrity="sha384-TCY3xw5Ej940LIWfS6PwhCCBl7lvEsxBpHy+BirF+EycSQUvXbfZsgsLi0oU18yZ"
|
||||||
|
crossorigin="anonymous"
|
||||||
|
></script>
|
||||||
|
<script>
|
||||||
|
if (window.Sentry) {
|
||||||
|
Sentry.init({
|
||||||
|
dsn: {{ sentry_dsn | tojson }},
|
||||||
|
environment: {{ sentry_environment | default("production") | tojson }},
|
||||||
|
release: {{ version | default(None) | tojson }},
|
||||||
|
integrations: [
|
||||||
|
Sentry.browserTracingIntegration(),
|
||||||
|
Sentry.replayIntegration(),
|
||||||
|
],
|
||||||
|
tracesSampleRate: {{ sentry_js_traces_sample_rate | default(0.0) }},
|
||||||
|
replaysSessionSampleRate: {{ sentry_js_replay_session_sample_rate | default(0.0) }},
|
||||||
|
replaysOnErrorSampleRate: {{ sentry_js_replay_on_error_sample_rate | default(0.1) }},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
{% endif %}
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body class="bg-gray-50 min-h-screen flex flex-col"
|
<body class="bg-gray-50 min-h-screen flex flex-col"
|
||||||
@@ -155,6 +181,9 @@
|
|||||||
<a href="/admin/credentials" role="menuitem" class="flex items-center px-4 py-2 text-sm text-gray-700 hover:bg-gray-100">
|
<a href="/admin/credentials" role="menuitem" class="flex items-center px-4 py-2 text-sm text-gray-700 hover:bg-gray-100">
|
||||||
<i class="fas fa-key w-4 mr-2 text-yellow-500" aria-hidden="true"></i> {{ _("nav.credentials") }}
|
<i class="fas fa-key w-4 mr-2 text-yellow-500" aria-hidden="true"></i> {{ _("nav.credentials") }}
|
||||||
</a>
|
</a>
|
||||||
|
<a href="/admin/connections" role="menuitem" class="flex items-center px-4 py-2 text-sm text-gray-700 hover:bg-gray-100">
|
||||||
|
<i class="fas fa-plug w-4 mr-2 text-purple-500" aria-hidden="true"></i> {{ _("nav.connections") }}
|
||||||
|
</a>
|
||||||
<a href="/admin/files" role="menuitem" class="flex items-center px-4 py-2 text-sm text-gray-700 hover:bg-gray-100">
|
<a href="/admin/files" role="menuitem" class="flex items-center px-4 py-2 text-sm text-gray-700 hover:bg-gray-100">
|
||||||
<i class="fas fa-folder-open w-4 mr-2 text-gray-500" aria-hidden="true"></i> {{ _("nav.file_manager") }}
|
<i class="fas fa-folder-open w-4 mr-2 text-gray-500" aria-hidden="true"></i> {{ _("nav.file_manager") }}
|
||||||
</a>
|
</a>
|
||||||
@@ -429,6 +458,9 @@
|
|||||||
<a href="/admin/credentials" class="block px-3 py-3 rounded-md text-base font-medium text-gray-700 hover:text-gray-900 hover:bg-gray-50">
|
<a href="/admin/credentials" class="block px-3 py-3 rounded-md text-base font-medium text-gray-700 hover:text-gray-900 hover:bg-gray-50">
|
||||||
<i class="fas fa-key mr-2 text-yellow-400" aria-hidden="true"></i> {{ _("nav.credentials") }}
|
<i class="fas fa-key mr-2 text-yellow-400" aria-hidden="true"></i> {{ _("nav.credentials") }}
|
||||||
</a>
|
</a>
|
||||||
|
<a href="/admin/connections" class="block px-3 py-3 rounded-md text-base font-medium text-gray-700 hover:text-gray-900 hover:bg-gray-50">
|
||||||
|
<i class="fas fa-plug mr-2 text-purple-400" aria-hidden="true"></i> {{ _("nav.connections") }}
|
||||||
|
</a>
|
||||||
<a href="/admin/files" class="block px-3 py-3 rounded-md text-base font-medium text-gray-700 hover:text-gray-900 hover:bg-gray-50">
|
<a href="/admin/files" class="block px-3 py-3 rounded-md text-base font-medium text-gray-700 hover:text-gray-900 hover:bg-gray-50">
|
||||||
<i class="fas fa-folder-open mr-2 text-gray-400" aria-hidden="true"></i> {{ _("nav.file_manager") }}
|
<i class="fas fa-folder-open mr-2 text-gray-400" aria-hidden="true"></i> {{ _("nav.file_manager") }}
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
|||||||
@@ -104,7 +104,7 @@
|
|||||||
<h3 class="text-xl font-medium mb-4">Step 3: Set OAuth 2 Redirect URI</h3>
|
<h3 class="text-xl font-medium mb-4">Step 3: Set OAuth 2 Redirect URI</h3>
|
||||||
<ol class="list-decimal ml-6 space-y-3">
|
<ol class="list-decimal ml-6 space-y-3">
|
||||||
<li>In your app's settings page, go to the "OAuth 2" section</li>
|
<li>In your app's settings page, go to the "OAuth 2" section</li>
|
||||||
<li>Add a redirect URI: <code class="bg-gray-100 p-1">{{ request.url.scheme }}://{{ request.url.netloc }}/dropbox-callback</code></li>
|
<li>Add a redirect URI: <code class="bg-gray-100 p-1">{{ callback_url }}</code></li>
|
||||||
<li>Click "Add" to save the redirect URI</li>
|
<li>Click "Add" to save the redirect URI</li>
|
||||||
</ol>
|
</ol>
|
||||||
</div>
|
</div>
|
||||||
@@ -298,6 +298,9 @@ DROPBOX_FOLDER={{ folder_path|default('/Documents/Uploads', true) }}</code></pre
|
|||||||
document.addEventListener('DOMContentLoaded', function() {
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
const userMode = {{ 'true' if user_mode else 'false' }};
|
const userMode = {{ 'true' if user_mode else 'false' }};
|
||||||
const globalCredsAvailable = {{ 'true' if global_creds_available else 'false' }};
|
const globalCredsAvailable = {{ 'true' if global_creds_available else 'false' }};
|
||||||
|
// Redirect URI for OAuth: prefer server-provided value (respects PUBLIC_BASE_URL),
|
||||||
|
// fall back to window.location.origin for resilience.
|
||||||
|
const dropboxCallbackUrl = {{ callback_url | tojson }} || (window.location.origin + "/dropbox-callback");
|
||||||
|
|
||||||
// Store integration_id if provided (for per-user OAuth flow)
|
// Store integration_id if provided (for per-user OAuth flow)
|
||||||
const integrationId = "{{ integration_id or '' }}";
|
const integrationId = "{{ integration_id or '' }}";
|
||||||
@@ -390,7 +393,7 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
startAuthFlowBtn.addEventListener('click', function() {
|
startAuthFlowBtn.addEventListener('click', function() {
|
||||||
const appKey = document.getElementById('app-key').value.trim();
|
const appKey = document.getElementById('app-key').value.trim();
|
||||||
const appSecret = appSecretInput.value.trim();
|
const appSecret = appSecretInput.value.trim();
|
||||||
const redirectUri = window.location.origin + "/dropbox-callback";
|
const redirectUri = dropboxCallbackUrl;
|
||||||
|
|
||||||
if (!appKey) {
|
if (!appKey) {
|
||||||
showModal('error', 'Validation Error', 'Please enter your App Key');
|
showModal('error', 'Validation Error', 'Please enter your App Key');
|
||||||
|
|||||||
@@ -59,6 +59,40 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Folder selector (shown after authorization) -->
|
||||||
|
<div id="folder-selector" class="hidden mt-6 p-4 bg-white border border-gray-200 rounded-lg">
|
||||||
|
<h3 class="font-medium text-lg mb-3">
|
||||||
|
<svg class="inline-block h-5 w-5 mr-1 text-gray-500" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
||||||
|
<path fill-rule="evenodd" d="M2 6a2 2 0 012-2h4l2 2h4a2 2 0 012 2v1H8a3 3 0 00-3 3v1.5a1.5 1.5 0 01-3 0V6z" clip-rule="evenodd" />
|
||||||
|
<path d="M6 12a2 2 0 012-2h8a2 2 0 012 2v2a2 2 0 01-2 2H2h2a2 2 0 002-2v-2z" />
|
||||||
|
</svg>
|
||||||
|
Select Folder
|
||||||
|
</h3>
|
||||||
|
<p class="text-sm text-gray-600 mb-3">Browse your Dropbox to select a folder for this integration.</p>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<div id="folder-breadcrumb" class="flex items-center text-sm text-gray-500 mb-2">
|
||||||
|
<button type="button" class="folder-nav-btn text-indigo-600 hover:text-indigo-800 font-medium" data-path="" aria-label="Navigate to root folder">/ Root</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="folder-list" class="border border-gray-200 rounded-md max-h-64 overflow-y-auto">
|
||||||
|
<div class="p-4 text-center text-gray-500">
|
||||||
|
<div class="animate-spin inline-block h-5 w-5 border-2 border-gray-300 border-t-indigo-600 rounded-full mb-2" role="status" aria-label="Loading folders"></div>
|
||||||
|
<p class="text-sm">Loading folders…</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-3 flex items-center gap-3">
|
||||||
|
<label for="selected-folder-path" class="text-sm font-medium text-gray-700 whitespace-nowrap">Selected folder:</label>
|
||||||
|
<input id="selected-folder-path" type="text" class="flex-1 px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 text-sm" placeholder="/" />
|
||||||
|
<button id="save-folder-btn" type="button" class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500">
|
||||||
|
Save Folder
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p id="folder-save-status" class="mt-2 text-sm hidden" aria-live="polite"></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="mt-6 p-4 bg-gray-100 rounded-md">
|
<div class="mt-6 p-4 bg-gray-100 rounded-md">
|
||||||
<h3 class="font-medium text-lg mb-2">Configuration for Worker Nodes</h3>
|
<h3 class="font-medium text-lg mb-2">Configuration for Worker Nodes</h3>
|
||||||
<p class="text-sm text-gray-600 mb-3">
|
<p class="text-sm text-gray-600 mb-3">
|
||||||
@@ -108,7 +142,8 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const redirectUri = window.location.origin + "/dropbox-callback";
|
// Use server-provided callback URL (respects PUBLIC_BASE_URL) with fallback to window.location.origin
|
||||||
|
const redirectUri = {{ callback_url | tojson }} || (window.location.origin + "/dropbox-callback");
|
||||||
|
|
||||||
// Automatically exchange the code for a refresh token
|
// Automatically exchange the code for a refresh token
|
||||||
if (code) {
|
if (code) {
|
||||||
@@ -253,18 +288,21 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
}
|
}
|
||||||
return response.json();
|
return response.json();
|
||||||
}).then(() => {
|
}).then(() => {
|
||||||
// Clean up
|
// Clean up session storage
|
||||||
sessionStorage.removeItem('dropbox_app_key');
|
sessionStorage.removeItem('dropbox_app_key');
|
||||||
sessionStorage.removeItem('dropbox_app_secret');
|
sessionStorage.removeItem('dropbox_app_secret');
|
||||||
sessionStorage.removeItem('dropbox_folder_path');
|
sessionStorage.removeItem('dropbox_folder_path');
|
||||||
sessionStorage.removeItem('oauth_integration_id');
|
sessionStorage.removeItem('oauth_integration_id');
|
||||||
|
sessionStorage.removeItem('dropbox_use_system_creds');
|
||||||
|
|
||||||
// Show brief success then redirect to integrations
|
// Hide processing spinner, show success
|
||||||
document.getElementById('processing-message').innerHTML =
|
|
||||||
'<p class="text-green-600 font-semibold">✓ Dropbox authorized successfully!</p>' +
|
|
||||||
'<p class="text-sm text-gray-500 mt-2">Redirecting to Integrations...</p>';
|
|
||||||
document.querySelector('.animate-spin').parentNode.classList.add('hidden');
|
document.querySelector('.animate-spin').parentNode.classList.add('hidden');
|
||||||
setTimeout(() => { window.location.href = '/integrations'; }, 2000);
|
document.getElementById('processing-message').innerHTML =
|
||||||
|
'<p class="text-green-600 font-semibold">✓ Dropbox authorized successfully!</p>';
|
||||||
|
document.getElementById('success-container').classList.remove('hidden');
|
||||||
|
|
||||||
|
// Show folder browser with the access token
|
||||||
|
initFolderBrowser(data.access_token, integrationId);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -359,6 +397,128 @@ DROPBOX_FOLDER=${folderPath || '/Documents/Uploads'}`;
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Folder browser ────────────────────────────────────────────────
|
||||||
|
function escapeHtml(str) {
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.appendChild(document.createTextNode(str));
|
||||||
|
return div.innerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
function initFolderBrowser(accessToken, integrationId) {
|
||||||
|
const folderSelector = document.getElementById('folder-selector');
|
||||||
|
if (!folderSelector || !integrationId) return;
|
||||||
|
|
||||||
|
folderSelector.classList.remove('hidden');
|
||||||
|
let currentPath = '';
|
||||||
|
|
||||||
|
const folderList = document.getElementById('folder-list');
|
||||||
|
const breadcrumb = document.getElementById('folder-breadcrumb');
|
||||||
|
const selectedInput = document.getElementById('selected-folder-path');
|
||||||
|
const saveBtn = document.getElementById('save-folder-btn');
|
||||||
|
const saveStatus = document.getElementById('folder-save-status');
|
||||||
|
|
||||||
|
function loadFolders(path) {
|
||||||
|
currentPath = path;
|
||||||
|
folderList.innerHTML = '<div class="p-4 text-center text-gray-500"><div class="animate-spin inline-block h-5 w-5 border-2 border-gray-300 border-t-indigo-600 rounded-full mb-2" role="status" aria-label="Loading folders"></div><p class="text-sm">Loading folders…</p></div>';
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('access_token', accessToken);
|
||||||
|
formData.append('path', path);
|
||||||
|
|
||||||
|
fetch('/api/dropbox/list-folders', { method: 'POST', body: formData })
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(data => {
|
||||||
|
if (data.folders && data.folders.length > 0) {
|
||||||
|
folderList.innerHTML = data.folders.map(f =>
|
||||||
|
`<button type="button" class="folder-item w-full text-left px-4 py-3 hover:bg-indigo-50 border-b border-gray-100 flex items-center gap-3 text-sm focus:outline-none focus:bg-indigo-50" data-path="${escapeHtml(f.path)}" aria-label="Open folder ${escapeHtml(f.name)}">` +
|
||||||
|
`<svg class="h-5 w-5 text-yellow-400 flex-shrink-0" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true"><path fill-rule="evenodd" d="M2 6a2 2 0 012-2h4l2 2h4a2 2 0 012 2v1H8a3 3 0 00-3 3v1.5a1.5 1.5 0 01-3 0V6z" clip-rule="evenodd" /><path d="M6 12a2 2 0 012-2h8a2 2 0 012 2v2a2 2 0 01-2 2H2h2a2 2 0 002-2v-2z" /></svg>` +
|
||||||
|
`<span class="font-medium text-gray-700">${escapeHtml(f.name)}</span>` +
|
||||||
|
`</button>`
|
||||||
|
).join('');
|
||||||
|
|
||||||
|
folderList.querySelectorAll('.folder-item').forEach(btn => {
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
const p = btn.getAttribute('data-path');
|
||||||
|
selectedInput.value = p;
|
||||||
|
loadFolders(p);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
folderList.innerHTML = '<div class="p-4 text-center text-gray-400 text-sm">No subfolders found</div>';
|
||||||
|
}
|
||||||
|
updateBreadcrumb(path);
|
||||||
|
})
|
||||||
|
.catch(err => {
|
||||||
|
folderList.innerHTML = `<div class="p-4 text-center text-red-500 text-sm">Failed to load folders: ${escapeHtml(err.message)}</div>`;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateBreadcrumb(path) {
|
||||||
|
const parts = path.split('/').filter(Boolean);
|
||||||
|
let html = '<button type="button" class="folder-nav-btn text-indigo-600 hover:text-indigo-800 font-medium" data-path="" aria-label="Navigate to root folder">/ Root</button>';
|
||||||
|
let accumulated = '';
|
||||||
|
for (const part of parts) {
|
||||||
|
accumulated += '/' + part;
|
||||||
|
html += `<span class="mx-1 text-gray-400">/</span>`;
|
||||||
|
html += `<button type="button" class="folder-nav-btn text-indigo-600 hover:text-indigo-800 font-medium" data-path="${escapeHtml(accumulated)}" aria-label="Navigate to ${escapeHtml(part)}">${escapeHtml(part)}</button>`;
|
||||||
|
}
|
||||||
|
breadcrumb.innerHTML = html;
|
||||||
|
breadcrumb.querySelectorAll('.folder-nav-btn').forEach(btn => {
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
const p = btn.getAttribute('data-path');
|
||||||
|
selectedInput.value = p || '/';
|
||||||
|
loadFolders(p);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save selected folder to integration config
|
||||||
|
saveBtn.addEventListener('click', () => {
|
||||||
|
const folderPath = selectedInput.value.trim() || '/';
|
||||||
|
saveBtn.disabled = true;
|
||||||
|
saveBtn.textContent = 'Saving…';
|
||||||
|
|
||||||
|
// Get the current integration config, update folder_path, then PUT back
|
||||||
|
fetch(`/api/integrations/${integrationId}`)
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(intg => {
|
||||||
|
const cfg = intg.config || {};
|
||||||
|
// Update the correct folder key based on integration type
|
||||||
|
if (cfg.source_type) {
|
||||||
|
cfg.folder_path = folderPath;
|
||||||
|
} else {
|
||||||
|
cfg.folder = folderPath;
|
||||||
|
}
|
||||||
|
return fetch(`/api/integrations/${integrationId}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ config: cfg }),
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.then(r => {
|
||||||
|
if (!r.ok) throw new Error('Failed to save folder');
|
||||||
|
return r.json();
|
||||||
|
})
|
||||||
|
.then(() => {
|
||||||
|
saveStatus.textContent = '✓ Folder saved! Redirecting…';
|
||||||
|
saveStatus.className = 'mt-2 text-sm text-green-600';
|
||||||
|
saveStatus.classList.remove('hidden');
|
||||||
|
saveBtn.textContent = 'Saved ✓';
|
||||||
|
setTimeout(() => { window.location.href = '/integrations'; }, 1500);
|
||||||
|
})
|
||||||
|
.catch(err => {
|
||||||
|
saveStatus.textContent = 'Error: ' + err.message;
|
||||||
|
saveStatus.className = 'mt-2 text-sm text-red-600';
|
||||||
|
saveStatus.classList.remove('hidden');
|
||||||
|
saveBtn.disabled = false;
|
||||||
|
saveBtn.textContent = 'Save Folder';
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Load root folders initially
|
||||||
|
loadFolders('');
|
||||||
|
}
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -0,0 +1,945 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Comments & Annotations - {{ file.original_filename or 'Document' }} - DocuElevate{% endblock %}
|
||||||
|
|
||||||
|
{% block head_extra %}
|
||||||
|
<script src="/static/js/common.js"></script>
|
||||||
|
<style>
|
||||||
|
.annotations-container { max-width: 1200px; margin: 0 auto; }
|
||||||
|
|
||||||
|
/* ── PDF viewer ── */
|
||||||
|
.pdf-viewer-card {
|
||||||
|
background: white; border-radius: 0.5rem;
|
||||||
|
box-shadow: 0 1px 3px rgba(0,0,0,.08);
|
||||||
|
margin-bottom: 1.25rem; overflow: hidden;
|
||||||
|
}
|
||||||
|
.dark .pdf-viewer-card { background: #1f2937; box-shadow: 0 1px 3px rgba(0,0,0,.3); }
|
||||||
|
.pdf-viewer-card-header {
|
||||||
|
display: flex; align-items: center; gap: 0.5rem;
|
||||||
|
padding: 1rem 1.5rem; border-bottom: 1px solid #e5e7eb;
|
||||||
|
font-weight: 700; font-size: 0.95rem; color: #374151;
|
||||||
|
}
|
||||||
|
.dark .pdf-viewer-card-header { border-bottom-color: #374151; color: #d1d5db; }
|
||||||
|
#embedpdf-viewer { width: 100%; height: 600px; }
|
||||||
|
|
||||||
|
/* ── header ── */
|
||||||
|
.annotations-header {
|
||||||
|
display: flex; align-items: flex-start; justify-content: space-between;
|
||||||
|
gap: 1rem; margin-bottom: 1.5rem; flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.annotations-title { font-size: 1.5rem; font-weight: 700; color: #1f2937; line-height: 1.25; }
|
||||||
|
.annotations-subtitle { font-size: 0.85rem; color: #6b7280; margin-top: 0.25rem; }
|
||||||
|
.dark .annotations-title { color: #f3f4f6; }
|
||||||
|
.dark .annotations-subtitle { color: #9ca3af; }
|
||||||
|
|
||||||
|
/* ── error ── */
|
||||||
|
.error-box { background: #fee2e2; border: 1px solid #f87171; color: #b91c1c; padding: 1rem; border-radius: 0.375rem; margin-bottom: 1rem; }
|
||||||
|
|
||||||
|
/* ── card ── */
|
||||||
|
.collab-card {
|
||||||
|
background: white; border-radius: 0.5rem;
|
||||||
|
box-shadow: 0 1px 3px rgba(0,0,0,.08);
|
||||||
|
padding: 1.5rem; margin-bottom: 1.25rem;
|
||||||
|
}
|
||||||
|
.dark .collab-card { background: #1f2937; box-shadow: 0 1px 3px rgba(0,0,0,.3); }
|
||||||
|
|
||||||
|
/* ── Comments panel ───────────────────────────────────────────────────── */
|
||||||
|
.comments-panel, .annotations-panel {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
.panel-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
.panel-header h3 {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
.comments-empty, .annotations-empty {
|
||||||
|
text-align: center;
|
||||||
|
padding: 2rem;
|
||||||
|
color: #718096;
|
||||||
|
}
|
||||||
|
.comments-loading, .annotations-loading {
|
||||||
|
text-align: center;
|
||||||
|
padding: 1.5rem;
|
||||||
|
color: #718096;
|
||||||
|
}
|
||||||
|
.comments-error {
|
||||||
|
text-align: center;
|
||||||
|
padding: 1rem;
|
||||||
|
color: #991B1B;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Individual comment */
|
||||||
|
.comment-item {
|
||||||
|
background-color: #f7fafc;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
padding: 1rem;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
border-left: 3px solid #4299e1;
|
||||||
|
}
|
||||||
|
.dark .comment-item {
|
||||||
|
background-color: #2d3748;
|
||||||
|
border-left-color: #63b3ed;
|
||||||
|
}
|
||||||
|
.comment-item.comment-reply {
|
||||||
|
margin-left: 1.5rem;
|
||||||
|
border-left-color: #a0aec0;
|
||||||
|
background-color: #edf2f7;
|
||||||
|
}
|
||||||
|
.dark .comment-item.comment-reply {
|
||||||
|
background-color: #1a202c;
|
||||||
|
border-left-color: #4a5568;
|
||||||
|
}
|
||||||
|
.comment-item.comment-resolved {
|
||||||
|
opacity: 0.75;
|
||||||
|
border-left-color: #48bb78;
|
||||||
|
}
|
||||||
|
.comment-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.comment-author {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #2d3748;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
.dark .comment-author {
|
||||||
|
color: #e2e8f0;
|
||||||
|
}
|
||||||
|
.comment-time {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: #718096;
|
||||||
|
}
|
||||||
|
.comment-resolved-badge {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: #065F46;
|
||||||
|
background-color: #D1FAE5;
|
||||||
|
padding: 0.125rem 0.5rem;
|
||||||
|
border-radius: 9999px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.dark .comment-resolved-badge {
|
||||||
|
background-color: #065F46;
|
||||||
|
color: #D1FAE5;
|
||||||
|
}
|
||||||
|
.comment-body {
|
||||||
|
color: #4a5568;
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
.dark .comment-body {
|
||||||
|
color: #cbd5e0;
|
||||||
|
}
|
||||||
|
.comment-mention {
|
||||||
|
color: #3182ce;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.dark .comment-mention {
|
||||||
|
color: #63b3ed;
|
||||||
|
}
|
||||||
|
.comment-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.comment-action-btn {
|
||||||
|
background: none;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
color: #718096;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
cursor: pointer;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
min-height: 28px;
|
||||||
|
}
|
||||||
|
.comment-action-btn:hover {
|
||||||
|
background-color: #edf2f7;
|
||||||
|
color: #2d3748;
|
||||||
|
}
|
||||||
|
.dark .comment-action-btn {
|
||||||
|
border-color: #4a5568;
|
||||||
|
color: #a0aec0;
|
||||||
|
}
|
||||||
|
.dark .comment-action-btn:hover {
|
||||||
|
background-color: #4a5568;
|
||||||
|
color: #e2e8f0;
|
||||||
|
}
|
||||||
|
.comment-action-btn--danger:hover {
|
||||||
|
background-color: #FEE2E2;
|
||||||
|
color: #991B1B;
|
||||||
|
border-color: #f56565;
|
||||||
|
}
|
||||||
|
.dark .comment-action-btn--danger:hover {
|
||||||
|
background-color: #742a2a;
|
||||||
|
color: #feb2b2;
|
||||||
|
border-color: #f56565;
|
||||||
|
}
|
||||||
|
.comment-replies {
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Comment form */
|
||||||
|
.comment-form-wrapper {
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
.comment-textarea {
|
||||||
|
width: 100%;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
padding: 0.75rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
resize: vertical;
|
||||||
|
min-height: 60px;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
.dark .comment-textarea {
|
||||||
|
background-color: #2d3748;
|
||||||
|
border-color: #4a5568;
|
||||||
|
color: #e2e8f0;
|
||||||
|
}
|
||||||
|
.comment-textarea:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #4299e1;
|
||||||
|
box-shadow: 0 0 0 2px rgba(66, 153, 225, 0.3);
|
||||||
|
}
|
||||||
|
.comment-submit-btn {
|
||||||
|
background-color: #4299e1;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
min-height: 36px;
|
||||||
|
}
|
||||||
|
.comment-submit-btn:hover {
|
||||||
|
background-color: #3182ce;
|
||||||
|
}
|
||||||
|
.comment-cancel-btn {
|
||||||
|
background-color: #e2e8f0;
|
||||||
|
color: #4a5568;
|
||||||
|
border: none;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
cursor: pointer;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
margin-left: 0.5rem;
|
||||||
|
min-height: 36px;
|
||||||
|
}
|
||||||
|
.dark .comment-cancel-btn {
|
||||||
|
background-color: #4a5568;
|
||||||
|
color: #e2e8f0;
|
||||||
|
}
|
||||||
|
.comment-edit-btns, .annotation-edit-btns {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
.comment-reply-form {
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
padding-top: 0.75rem;
|
||||||
|
border-top: 1px solid #e2e8f0;
|
||||||
|
}
|
||||||
|
.dark .comment-reply-form {
|
||||||
|
border-top-color: #4a5568;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* @mention dropdown */
|
||||||
|
.mention-dropdown-wrapper {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
#mention-dropdown {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 100%;
|
||||||
|
left: 0;
|
||||||
|
width: 280px;
|
||||||
|
max-height: 200px;
|
||||||
|
overflow-y: auto;
|
||||||
|
background-color: white;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||||
|
z-index: 50;
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
}
|
||||||
|
.dark #mention-dropdown {
|
||||||
|
background-color: #2d3748;
|
||||||
|
border-color: #4a5568;
|
||||||
|
}
|
||||||
|
.mention-item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
width: 100%;
|
||||||
|
text-align: left;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
min-height: 44px;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.mention-item:hover, .mention-item:focus {
|
||||||
|
background-color: #edf2f7;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
.dark .mention-item:hover, .dark .mention-item:focus {
|
||||||
|
background-color: #4a5568;
|
||||||
|
}
|
||||||
|
.mention-user-id {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #2d3748;
|
||||||
|
}
|
||||||
|
.dark .mention-user-id {
|
||||||
|
color: #e2e8f0;
|
||||||
|
}
|
||||||
|
.mention-display-name {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: #718096;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Annotations panel ────────────────────────────────────────────────── */
|
||||||
|
.annotation-item {
|
||||||
|
background-color: #f7fafc;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
padding: 1rem;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
border-left: 3px solid #ecc94b;
|
||||||
|
}
|
||||||
|
.dark .annotation-item {
|
||||||
|
background-color: #2d3748;
|
||||||
|
border-left-color: #d69e2e;
|
||||||
|
}
|
||||||
|
.annotation-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.annotation-type {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 0.125rem 0.5rem;
|
||||||
|
border-radius: 9999px;
|
||||||
|
text-transform: capitalize;
|
||||||
|
}
|
||||||
|
.annotation-type--note {
|
||||||
|
background-color: #DBEAFE;
|
||||||
|
color: #1E3A8A;
|
||||||
|
}
|
||||||
|
.annotation-type--highlight {
|
||||||
|
background-color: #FEF3C7;
|
||||||
|
color: #92400E;
|
||||||
|
}
|
||||||
|
.annotation-type--underline {
|
||||||
|
background-color: #D1FAE5;
|
||||||
|
color: #065F46;
|
||||||
|
}
|
||||||
|
.annotation-type--strikethrough {
|
||||||
|
background-color: #FEE2E2;
|
||||||
|
color: #991B1B;
|
||||||
|
}
|
||||||
|
.dark .annotation-type--note {
|
||||||
|
background-color: #1E3A8A;
|
||||||
|
color: #DBEAFE;
|
||||||
|
}
|
||||||
|
.dark .annotation-type--highlight {
|
||||||
|
background-color: #92400E;
|
||||||
|
color: #FEF3C7;
|
||||||
|
}
|
||||||
|
.dark .annotation-type--underline {
|
||||||
|
background-color: #065F46;
|
||||||
|
color: #D1FAE5;
|
||||||
|
}
|
||||||
|
.dark .annotation-type--strikethrough {
|
||||||
|
background-color: #991B1B;
|
||||||
|
color: #FEE2E2;
|
||||||
|
}
|
||||||
|
.annotation-color-dot {
|
||||||
|
display: inline-block;
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 1px solid rgba(0, 0, 0, 0.2);
|
||||||
|
}
|
||||||
|
.annotation-page {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: #718096;
|
||||||
|
}
|
||||||
|
.annotation-page--link {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
padding: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: #718096;
|
||||||
|
text-decoration: none;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
.annotation-page--link:hover {
|
||||||
|
color: #3182ce;
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
.dark .annotation-page--link:hover {
|
||||||
|
color: #63b3ed;
|
||||||
|
}
|
||||||
|
.annotation-content {
|
||||||
|
color: #4a5568;
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
.dark .annotation-content {
|
||||||
|
color: #cbd5e0;
|
||||||
|
}
|
||||||
|
.annotation-meta {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.75rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: #718096;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
.annotation-author {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.annotation-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
.annotation-action-btn {
|
||||||
|
background: none;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
color: #718096;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
cursor: pointer;
|
||||||
|
min-height: 28px;
|
||||||
|
}
|
||||||
|
.annotation-action-btn:hover {
|
||||||
|
background-color: #edf2f7;
|
||||||
|
color: #2d3748;
|
||||||
|
}
|
||||||
|
.dark .annotation-action-btn {
|
||||||
|
border-color: #4a5568;
|
||||||
|
color: #a0aec0;
|
||||||
|
}
|
||||||
|
.dark .annotation-action-btn:hover {
|
||||||
|
background-color: #4a5568;
|
||||||
|
color: #e2e8f0;
|
||||||
|
}
|
||||||
|
.annotation-action-btn--danger:hover {
|
||||||
|
background-color: #FEE2E2;
|
||||||
|
color: #991B1B;
|
||||||
|
border-color: #f56565;
|
||||||
|
}
|
||||||
|
.dark .annotation-action-btn--danger:hover {
|
||||||
|
background-color: #742a2a;
|
||||||
|
color: #feb2b2;
|
||||||
|
}
|
||||||
|
.annotation-textarea {
|
||||||
|
width: 100%;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
padding: 0.75rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
resize: vertical;
|
||||||
|
min-height: 60px;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
.dark .annotation-textarea {
|
||||||
|
background-color: #2d3748;
|
||||||
|
border-color: #4a5568;
|
||||||
|
color: #e2e8f0;
|
||||||
|
}
|
||||||
|
.annotation-textarea:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #ecc94b;
|
||||||
|
box-shadow: 0 0 0 2px rgba(236, 201, 75, 0.3);
|
||||||
|
}
|
||||||
|
.annotation-select {
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
padding: 0.5rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.dark .annotation-select {
|
||||||
|
background-color: #2d3748;
|
||||||
|
border-color: #4a5568;
|
||||||
|
color: #e2e8f0;
|
||||||
|
}
|
||||||
|
.annotation-submit-btn {
|
||||||
|
background-color: #ecc94b;
|
||||||
|
color: #744210;
|
||||||
|
border: none;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
min-height: 36px;
|
||||||
|
}
|
||||||
|
.annotation-submit-btn:hover {
|
||||||
|
background-color: #d69e2e;
|
||||||
|
}
|
||||||
|
.annotation-cancel-btn {
|
||||||
|
background-color: #e2e8f0;
|
||||||
|
color: #4a5568;
|
||||||
|
border: none;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
cursor: pointer;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
margin-left: 0.5rem;
|
||||||
|
min-height: 36px;
|
||||||
|
}
|
||||||
|
.dark .annotation-cancel-btn {
|
||||||
|
background-color: #4a5568;
|
||||||
|
color: #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Annotation form layout */
|
||||||
|
.annotation-form-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
}
|
||||||
|
.annotation-form-grid .form-group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.annotation-form-grid label {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #4a5568;
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
}
|
||||||
|
.dark .annotation-form-grid label {
|
||||||
|
color: #a0aec0;
|
||||||
|
}
|
||||||
|
.annotation-form-grid input,
|
||||||
|
.annotation-form-grid select {
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
padding: 0.5rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
.dark .annotation-form-grid input,
|
||||||
|
.dark .annotation-form-grid select {
|
||||||
|
background-color: #2d3748;
|
||||||
|
border-color: #4a5568;
|
||||||
|
color: #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Collaboration panels grid ────────────────────────────────────────── */
|
||||||
|
.collab-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 1.5rem;
|
||||||
|
}
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.collab-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="annotations-container">
|
||||||
|
|
||||||
|
{% if error %}
|
||||||
|
<div class="error-box"><strong>Error:</strong> {{ error }}</div>
|
||||||
|
{% elif file %}
|
||||||
|
|
||||||
|
<!-- ── Back + header ── -->
|
||||||
|
<div style="display:flex;align-items:center;gap:1rem;margin-bottom:1.5rem;flex-wrap:wrap;">
|
||||||
|
<a href="/files/{{ file.id }}" style="display:inline-flex;align-items:center;gap:0.4rem;color:#3b82f6;font-weight:600;text-decoration:none;" aria-label="Back to File Summary">
|
||||||
|
<i class="fas fa-arrow-left" aria-hidden="true"></i> Back to File Summary
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="annotations-header">
|
||||||
|
<div>
|
||||||
|
<div class="annotations-title">
|
||||||
|
<i class="fas fa-comments" aria-hidden="true" style="color:#10b981;margin-right:0.4rem;"></i>
|
||||||
|
Comments & Annotations
|
||||||
|
</div>
|
||||||
|
<div class="annotations-subtitle">{{ file.original_filename }}</div>
|
||||||
|
{% if multi_user_enabled %}
|
||||||
|
<div class="annotations-subtitle" style="margin-top:0.25rem;">
|
||||||
|
<i class="fas fa-user" aria-hidden="true" style="margin-right:0.25rem;"></i>
|
||||||
|
{{ _("file.owner_label") }}: <strong>{{ owner_display or _("file.owner_unowned") }}</strong>
|
||||||
|
{% if file.owner_id is none %}
|
||||||
|
—
|
||||||
|
<button
|
||||||
|
id="claim-btn"
|
||||||
|
aria-label="{{ _('file.claim_ownership') }}"
|
||||||
|
style="background:#10b981;color:#fff;border:none;border-radius:0.375rem;padding:0.25rem 0.75rem;font-size:0.8rem;font-weight:600;cursor:pointer;"
|
||||||
|
>
|
||||||
|
<i class="fas fa-user-check" aria-hidden="true"></i> {{ _("file.claim_ownership") }}
|
||||||
|
</button>
|
||||||
|
<span id="claim-msg" style="font-size:0.8rem;margin-left:0.5rem;display:none;" role="alert"></span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── PDF Viewer (EmbedPDF) ───────────────────────────────────────────── -->
|
||||||
|
{% if is_pdf and (processed_file_exists or original_file_exists) %}
|
||||||
|
<div class="pdf-viewer-card">
|
||||||
|
<div class="pdf-viewer-card-header">
|
||||||
|
<i class="fas fa-file-pdf" aria-hidden="true" style="color:#ef4444;"></i>
|
||||||
|
Document Viewer
|
||||||
|
</div>
|
||||||
|
<div id="embedpdf-viewer" aria-label="PDF document viewer"></div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- ── Comments & Annotations ──────────────────────────────────────────── -->
|
||||||
|
<div class="collab-card">
|
||||||
|
<div class="collab-grid">
|
||||||
|
<!-- Comments Panel -->
|
||||||
|
<section class="comments-panel" aria-label="{{ _('comments.heading') }}">
|
||||||
|
<div class="panel-header">
|
||||||
|
<h3><i class="fas fa-comments" aria-hidden="true"></i> {{ _("comments.heading") }}</h3>
|
||||||
|
</div>
|
||||||
|
<div id="comments-list" aria-live="polite"></div>
|
||||||
|
|
||||||
|
<!-- New comment form -->
|
||||||
|
<div class="comment-form-wrapper">
|
||||||
|
<form id="comment-form" aria-label="{{ _('comments.add_comment') }}">
|
||||||
|
<div class="mention-dropdown-wrapper">
|
||||||
|
<div id="mention-dropdown" class="hidden" role="listbox" aria-label="{{ _('comments.mention_users') }}"></div>
|
||||||
|
<textarea
|
||||||
|
id="comment-input"
|
||||||
|
class="comment-textarea"
|
||||||
|
placeholder="{{ _('comments.body_placeholder') }}"
|
||||||
|
rows="3"
|
||||||
|
aria-label="{{ _('comments.body_placeholder') }}"
|
||||||
|
maxlength="10000"
|
||||||
|
></textarea>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="comment-submit-btn">
|
||||||
|
<i class="fas fa-paper-plane" aria-hidden="true"></i> {{ _("comments.add_comment") }}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Annotations Panel -->
|
||||||
|
<section class="annotations-panel" aria-label="{{ _('annotations.heading') }}">
|
||||||
|
<div class="panel-header">
|
||||||
|
<h3><i class="fas fa-sticky-note" aria-hidden="true"></i> {{ _("annotations.heading") }}</h3>
|
||||||
|
</div>
|
||||||
|
<div id="annotations-list" aria-live="polite"></div>
|
||||||
|
|
||||||
|
<!-- New annotation form -->
|
||||||
|
<div class="comment-form-wrapper">
|
||||||
|
<form id="annotation-form" aria-label="{{ _('annotations.add') }}">
|
||||||
|
<textarea
|
||||||
|
id="annotation-content-input"
|
||||||
|
class="annotation-textarea"
|
||||||
|
placeholder="{{ _('annotations.content_placeholder') }}"
|
||||||
|
rows="2"
|
||||||
|
aria-label="{{ _('annotations.content_placeholder') }}"
|
||||||
|
maxlength="5000"
|
||||||
|
></textarea>
|
||||||
|
<div class="annotation-form-grid">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="annotation-page-input">{{ _("annotations.page") }}</label>
|
||||||
|
<input type="number" id="annotation-page-input" min="1" value="1" aria-label="{{ _('annotations.page') }}">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="annotation-type-input">{{ _("annotations.type") }}</label>
|
||||||
|
<select id="annotation-type-input" aria-label="Annotation type">
|
||||||
|
<option value="note">{{ _("annotations.type_note") }}</option>
|
||||||
|
<option value="highlight">{{ _("annotations.type_highlight") }}</option>
|
||||||
|
<option value="underline">{{ _("annotations.type_underline") }}</option>
|
||||||
|
<option value="strikethrough">{{ _("annotations.type_strikethrough") }}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="annotation-color-input">{{ _("annotations.color") }}</label>
|
||||||
|
<input type="color" id="annotation-color-input" value="#ffff00" aria-label="{{ _('annotations.color') }}">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="annotation-submit-btn">
|
||||||
|
<i class="fas fa-plus" aria-hidden="true"></i> {{ _("annotations.add") }}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── Sharing & Permissions ──────────────────────────────────────────── -->
|
||||||
|
{% if current_user_role == 'owner' %}
|
||||||
|
<div class="collab-card" id="sharing-panel" style="margin-top:1.5rem;">
|
||||||
|
<div class="panel-header" style="padding:1rem 1.25rem;border-bottom:1px solid #e2e8f0;display:flex;align-items:center;gap:0.5rem;">
|
||||||
|
<h3 style="font-size:1rem;font-weight:600;color:#1e293b;margin:0;">
|
||||||
|
<i class="fas fa-share-alt" aria-hidden="true" style="color:#3b82f6;"></i>
|
||||||
|
{{ _("sharing.heading") }}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<div style="padding:1rem 1.25rem;">
|
||||||
|
<!-- Current shares list -->
|
||||||
|
<div id="sharing-list" aria-live="polite" style="margin-bottom:1rem;">
|
||||||
|
<p style="color:#64748b;font-size:0.875rem;">{{ _("sharing.loading") }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Add share form -->
|
||||||
|
<form id="sharing-form" aria-label="{{ _('sharing.add_share') }}" style="display:flex;gap:0.5rem;flex-wrap:wrap;align-items:flex-end;">
|
||||||
|
<div style="flex:1;min-width:180px;">
|
||||||
|
<label for="share-user-input" style="display:block;font-size:0.8rem;color:#475569;margin-bottom:0.25rem;">
|
||||||
|
{{ _("sharing.user_id_label") }}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="share-user-input"
|
||||||
|
placeholder="{{ _('sharing.user_id_placeholder') }}"
|
||||||
|
style="width:100%;padding:0.5rem 0.75rem;border:1px solid #cbd5e1;border-radius:0.375rem;font-size:0.875rem;"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div style="min-width:120px;">
|
||||||
|
<label for="share-role-input" style="display:block;font-size:0.8rem;color:#475569;margin-bottom:0.25rem;">
|
||||||
|
{{ _("sharing.role_label") }}
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="share-role-input"
|
||||||
|
style="width:100%;padding:0.5rem 0.75rem;border:1px solid #cbd5e1;border-radius:0.375rem;font-size:0.875rem;background:#fff;"
|
||||||
|
>
|
||||||
|
<option value="viewer">{{ _("sharing.role_viewer") }}</option>
|
||||||
|
<option value="editor">{{ _("sharing.role_editor") }}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<button type="submit" style="padding:0.5rem 1rem;background:#3b82f6;color:#fff;border:none;border-radius:0.375rem;font-size:0.875rem;font-weight:600;cursor:pointer;min-height:2.25rem;" aria-label="{{ _('sharing.add_share') }}">
|
||||||
|
<i class="fas fa-user-plus" aria-hidden="true"></i> {{ _("sharing.add_share") }}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<p id="sharing-form-error" style="color:#ef4444;font-size:0.8rem;margin-top:0.5rem;display:none;" role="alert"></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% else %}
|
||||||
|
<!-- file is None without error -->
|
||||||
|
<div class="error-box">Document not found.</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Comments & Annotations JS -->
|
||||||
|
<script src="{{ url_for('static', path='js/comments.js') }}" defer></script>
|
||||||
|
<script src="{{ url_for('static', path='js/annotations.js') }}" defer></script>
|
||||||
|
<script>
|
||||||
|
document.addEventListener('DOMContentLoaded', function () {
|
||||||
|
{% if file %}
|
||||||
|
var fileId = {{ file.id | tojson }};
|
||||||
|
// Detect current user from whoami endpoint
|
||||||
|
fetch('/api/auth/whoami')
|
||||||
|
.then(function (r) { return r.json(); })
|
||||||
|
.then(function (data) {
|
||||||
|
var userId = (data && (data.email || data.preferred_username)) || '';
|
||||||
|
var commentsI18n = {
|
||||||
|
empty: {{ _("comments.empty") | tojson }},
|
||||||
|
add_comment: {{ _("comments.add_comment") | tojson }},
|
||||||
|
add_reply: {{ _("comments.add_reply") | tojson }},
|
||||||
|
edit: {{ _("comments.edit") | tojson }},
|
||||||
|
save: {{ _("comments.save") | tojson }},
|
||||||
|
resolve: {{ _("comments.resolve") | tojson }},
|
||||||
|
resolved: {{ _("comments.resolved") | tojson }},
|
||||||
|
unresolve: {{ _("comments.unresolve") | tojson }},
|
||||||
|
delete_confirm: {{ _("comments.delete_confirm") | tojson }},
|
||||||
|
reply_placeholder: {{ _("comments.reply_placeholder") | tojson }},
|
||||||
|
body_placeholder: {{ _("comments.body_placeholder") | tojson }},
|
||||||
|
mention_users: {{ _("comments.mention_users") | tojson }},
|
||||||
|
cancel: {{ _("common.cancel") | tojson }}
|
||||||
|
};
|
||||||
|
var annotationsI18n = {
|
||||||
|
empty: {{ _("annotations.empty") | tojson }},
|
||||||
|
add: {{ _("annotations.add") | tojson }},
|
||||||
|
save: {{ _("annotations.save") | tojson }},
|
||||||
|
delete_confirm: {{ _("annotations.delete_confirm") | tojson }},
|
||||||
|
page: {{ _("annotations.page") | tojson }},
|
||||||
|
color: {{ _("annotations.color") | tojson }},
|
||||||
|
go_to_page: {{ _("annotations.go_to_page") | tojson }},
|
||||||
|
type_note: {{ _("annotations.type_note") | tojson }},
|
||||||
|
type_highlight: {{ _("annotations.type_highlight") | tojson }},
|
||||||
|
type_underline: {{ _("annotations.type_underline") | tojson }},
|
||||||
|
type_strikethrough: {{ _("annotations.type_strikethrough") | tojson }},
|
||||||
|
cancel: {{ _("common.cancel") | tojson }}
|
||||||
|
};
|
||||||
|
if (typeof initComments === 'function') {
|
||||||
|
initComments(fileId, userId, commentsI18n);
|
||||||
|
}
|
||||||
|
if (typeof initAnnotations === 'function') {
|
||||||
|
initAnnotations(fileId, userId, annotationsI18n);
|
||||||
|
}
|
||||||
|
{% if current_user_role == 'owner' %}
|
||||||
|
if (typeof initSharing === 'function') {
|
||||||
|
initSharing(fileId, {
|
||||||
|
heading: {{ _("sharing.heading") | tojson }},
|
||||||
|
loading: {{ _("sharing.loading") | tojson }},
|
||||||
|
no_shares: {{ _("sharing.no_shares") | tojson }},
|
||||||
|
add_share: {{ _("sharing.add_share") | tojson }},
|
||||||
|
role_viewer: {{ _("sharing.role_viewer") | tojson }},
|
||||||
|
role_editor: {{ _("sharing.role_editor") | tojson }},
|
||||||
|
revoke: {{ _("sharing.revoke") | tojson }},
|
||||||
|
revoke_confirm: {{ _("sharing.revoke_confirm") | tojson }},
|
||||||
|
user_id_label: {{ _("sharing.user_id_label") | tojson }},
|
||||||
|
user_id_placeholder: {{ _("sharing.user_id_placeholder") | tojson }},
|
||||||
|
role_label: {{ _("sharing.role_label") | tojson }},
|
||||||
|
error_empty_user: {{ _("sharing.error_empty_user") | tojson }},
|
||||||
|
change_role: {{ _("sharing.change_role") | tojson }},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
{% endif %}
|
||||||
|
})
|
||||||
|
.catch(function () {
|
||||||
|
// Auth disabled — initialise with empty user
|
||||||
|
if (typeof initComments === 'function') initComments(fileId, '', {});
|
||||||
|
if (typeof initAnnotations === 'function') initAnnotations(fileId, '', {});
|
||||||
|
});
|
||||||
|
{% endif %}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
<!-- Sharing JS -->
|
||||||
|
{% if current_user_role == 'owner' %}
|
||||||
|
<script src="{{ url_for('static', path='js/sharing.js') }}" defer></script>
|
||||||
|
{% endif %}
|
||||||
|
<!-- ── EmbedPDF Viewer init ── -->
|
||||||
|
{% if file and is_pdf and (processed_file_exists or original_file_exists) %}
|
||||||
|
<script async type="module">
|
||||||
|
import EmbedPDF from 'https://cdn.jsdelivr.net/npm/@embedpdf/snippet@2/dist/embedpdf.js';
|
||||||
|
|
||||||
|
const viewerEl = document.getElementById('embedpdf-viewer');
|
||||||
|
if (viewerEl) {
|
||||||
|
const fileId = {{ file.id | tojson }};
|
||||||
|
{% if processed_file_exists %}
|
||||||
|
const pdfUrl = '/api/files/' + fileId + '/preview?version=processed';
|
||||||
|
{% else %}
|
||||||
|
const pdfUrl = '/api/files/' + fileId + '/preview?version=original';
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
const viewer = EmbedPDF.init({
|
||||||
|
type: 'container',
|
||||||
|
target: viewerEl,
|
||||||
|
src: pdfUrl,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (viewer) {
|
||||||
|
viewer.registry.then(function (registry) {
|
||||||
|
// ── Page sync: viewer page change → update annotation form ──────────
|
||||||
|
var scrollPlugin = registry.getPlugin('scroll');
|
||||||
|
if (scrollPlugin) {
|
||||||
|
var scroll = scrollPlugin.provides();
|
||||||
|
scroll.onPageChange(function (event) {
|
||||||
|
var pageInput = document.getElementById('annotation-page-input');
|
||||||
|
if (pageInput) {
|
||||||
|
pageInput.value = String(event.pageNumber);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// Expose scrollToPage so the annotations panel can navigate the viewer
|
||||||
|
window._embedpdfScrollToPage = function (pageNumber) {
|
||||||
|
scroll.scrollToPage({ pageNumber: pageNumber });
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Auto-save: viewer annotation events → DocuElevate API ───────────
|
||||||
|
var annotationPlugin = registry.getPlugin('annotation');
|
||||||
|
if (annotationPlugin) {
|
||||||
|
var annotation = annotationPlugin.provides();
|
||||||
|
annotation.onAnnotationEvent(function (event) {
|
||||||
|
if (event.type !== 'create') return;
|
||||||
|
var ann = event.annotation;
|
||||||
|
var pageIndex = typeof ann.pageIndex === 'number' ? ann.pageIndex
|
||||||
|
: (typeof event.pageIndex === 'number' ? event.pageIndex : 0);
|
||||||
|
var page = pageIndex + 1;
|
||||||
|
var rect = ann.rect || { x: 0, y: 0, width: 0, height: 0 };
|
||||||
|
var color = ann.strokeColor || ann.color || undefined;
|
||||||
|
var content = (ann.contents || '').trim();
|
||||||
|
// Map PDF annotation subtypes to DocuElevate annotation types
|
||||||
|
var typeMap = {
|
||||||
|
highlight: 'highlight',
|
||||||
|
underline: 'underline',
|
||||||
|
strikeout: 'strikethrough',
|
||||||
|
squiggly: 'underline',
|
||||||
|
text: 'note',
|
||||||
|
freetext: 'note',
|
||||||
|
ink: 'note',
|
||||||
|
square: 'note',
|
||||||
|
circle: 'note',
|
||||||
|
};
|
||||||
|
var annType = typeMap[String(ann.type).toLowerCase()] || 'note';
|
||||||
|
if (!content) {
|
||||||
|
var typeLabel = annType.charAt(0).toUpperCase() + annType.slice(1);
|
||||||
|
content = typeLabel + ' \u2014 p.' + page;
|
||||||
|
}
|
||||||
|
var payload = {
|
||||||
|
page: page,
|
||||||
|
x: rect.x || 0,
|
||||||
|
y: rect.y || 0,
|
||||||
|
width: rect.width || 0,
|
||||||
|
height: rect.height || 0,
|
||||||
|
annotation_type: annType,
|
||||||
|
content: content,
|
||||||
|
};
|
||||||
|
if (color) {
|
||||||
|
payload.color = color;
|
||||||
|
}
|
||||||
|
fetch('/api/files/' + fileId + '/annotations', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
})
|
||||||
|
.then(function (r) {
|
||||||
|
if (r.ok && typeof window._reloadAnnotations === 'function') {
|
||||||
|
window._reloadAnnotations();
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(function (err) {
|
||||||
|
console.error('Failed to save viewer annotation:', err);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}).catch(function () {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
{% endif %}
|
||||||
|
{% if multi_user_enabled and file and file.owner_id is none %}
|
||||||
|
<script src="{{ url_for('static', path='js/claim.js') }}" defer></script>
|
||||||
|
<script>
|
||||||
|
document.addEventListener('DOMContentLoaded', function () {
|
||||||
|
initClaimOwnership({{ file.id | tojson }}, {
|
||||||
|
confirm: {{ _("file.claim_ownership_confirm") | tojson }},
|
||||||
|
success: {{ _("file.claim_ownership_success") | tojson }},
|
||||||
|
failed: {{ _("file.claim_ownership_failed") | tojson }}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -0,0 +1,226 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}{{ file.document_title or file.original_filename or 'Document' }} - DocuElevate{% endblock %}
|
||||||
|
|
||||||
|
{% block head_extra %}
|
||||||
|
<script src="/static/js/common.js"></script>
|
||||||
|
<style>
|
||||||
|
.summary-container { max-width: 900px; margin: 0 auto; }
|
||||||
|
|
||||||
|
/* ── header ── */
|
||||||
|
.summary-header {
|
||||||
|
display: flex; align-items: flex-start; justify-content: space-between;
|
||||||
|
gap: 1rem; margin-bottom: 1.5rem; flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.summary-title { font-size: 1.75rem; font-weight: 700; color: #1f2937; line-height: 1.25; }
|
||||||
|
.summary-subtitle { font-size: 0.9rem; color: #6b7280; margin-top: 0.25rem; font-family: monospace; }
|
||||||
|
.dark .summary-title { color: #f3f4f6; }
|
||||||
|
.dark .summary-subtitle { color: #9ca3af; }
|
||||||
|
|
||||||
|
/* ── status pill ── */
|
||||||
|
.status-pill {
|
||||||
|
display: inline-flex; align-items: center; gap: 0.4rem;
|
||||||
|
padding: 0.35rem 0.85rem; border-radius: 9999px;
|
||||||
|
font-size: 0.8rem; font-weight: 600; white-space: nowrap;
|
||||||
|
}
|
||||||
|
.pill-completed { background: #d1fae5; color: #065f46; }
|
||||||
|
.pill-failed { background: #fee2e2; color: #991b1b; }
|
||||||
|
.pill-processing { background: #dbeafe; color: #1e3a8a; }
|
||||||
|
.pill-pending { background: #fef3c7; color: #92400e; }
|
||||||
|
.pill-duplicate { background: #e5e7eb; color: #374151; }
|
||||||
|
|
||||||
|
/* ── cards ── */
|
||||||
|
.summary-card {
|
||||||
|
background: white; border-radius: 0.5rem;
|
||||||
|
box-shadow: 0 1px 3px rgba(0,0,0,.08);
|
||||||
|
padding: 1.5rem; margin-bottom: 1.25rem;
|
||||||
|
}
|
||||||
|
.dark .summary-card { background: #1f2937; box-shadow: 0 1px 3px rgba(0,0,0,.3); }
|
||||||
|
.summary-card-title {
|
||||||
|
font-size: 1rem; font-weight: 700; color: #374151;
|
||||||
|
text-transform: uppercase; letter-spacing: 0.05em;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
.dark .summary-card-title { color: #d1d5db; }
|
||||||
|
|
||||||
|
/* ── info rows ── */
|
||||||
|
.info-row { display: flex; justify-content: space-between; padding: 0.4rem 0; border-bottom: 1px solid #f3f4f6; font-size: 0.875rem; }
|
||||||
|
.info-row:last-child { border-bottom: none; }
|
||||||
|
.info-key { color: #6b7280; }
|
||||||
|
.info-val { color: #1f2937; font-family: monospace; text-align: right; word-break: break-all; max-width: 60%; }
|
||||||
|
.dark .info-row { border-bottom-color: #374151; }
|
||||||
|
.dark .info-key { color: #9ca3af; }
|
||||||
|
.dark .info-val { color: #e5e7eb; }
|
||||||
|
|
||||||
|
/* ── nav cards ── */
|
||||||
|
.nav-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 1rem; margin-bottom: 1.25rem; }
|
||||||
|
@media (max-width: 768px) { .nav-grid { grid-template-columns: 1fr; } }
|
||||||
|
.nav-card {
|
||||||
|
display: flex; flex-direction: column; align-items: center; justify-content: center;
|
||||||
|
background: white; border-radius: 0.5rem; box-shadow: 0 1px 3px rgba(0,0,0,.08);
|
||||||
|
padding: 1.5rem; text-decoration: none; color: #374151;
|
||||||
|
transition: box-shadow 0.15s, transform 0.15s; min-height: 120px;
|
||||||
|
}
|
||||||
|
.nav-card:hover { box-shadow: 0 4px 12px rgba(0,0,0,.15); transform: translateY(-2px); }
|
||||||
|
.dark .nav-card { background: #1f2937; color: #e5e7eb; }
|
||||||
|
.dark .nav-card:hover { box-shadow: 0 4px 12px rgba(0,0,0,.4); }
|
||||||
|
.nav-card i { font-size: 1.5rem; margin-bottom: 0.75rem; }
|
||||||
|
.nav-card-title { font-weight: 700; font-size: 0.95rem; margin-bottom: 0.25rem; }
|
||||||
|
.nav-card-desc { font-size: 0.8rem; color: #6b7280; text-align: center; }
|
||||||
|
.dark .nav-card-desc { color: #9ca3af; }
|
||||||
|
|
||||||
|
/* ── action buttons ── */
|
||||||
|
.action-btn {
|
||||||
|
display: inline-flex; align-items: center; gap: 0.4rem;
|
||||||
|
padding: 0.5rem 1.1rem; border-radius: 0.375rem;
|
||||||
|
font-size: 0.875rem; font-weight: 600; text-decoration: none;
|
||||||
|
cursor: pointer; border: none; transition: filter 0.1s;
|
||||||
|
}
|
||||||
|
.action-btn:hover { filter: brightness(0.92); }
|
||||||
|
.btn-primary { background: #3b82f6; color: white; }
|
||||||
|
.btn-secondary { background: #f3f4f6; color: #374151; }
|
||||||
|
|
||||||
|
/* ── error ── */
|
||||||
|
.error-box { background: #fee2e2; border: 1px solid #f87171; color: #b91c1c; padding: 1rem; border-radius: 0.375rem; margin-bottom: 1rem; }
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="summary-container">
|
||||||
|
|
||||||
|
{% if error %}
|
||||||
|
<div class="error-box"><strong>Error:</strong> {{ error }}</div>
|
||||||
|
{% elif file %}
|
||||||
|
|
||||||
|
<!-- ── Back + header ── -->
|
||||||
|
<a href="/files" style="display:inline-flex;align-items:center;gap:0.4rem;color:#3b82f6;font-weight:600;text-decoration:none;margin-bottom:1.25rem;" aria-label="Back to Files">
|
||||||
|
<i class="fas fa-arrow-left" aria-hidden="true"></i> Back to Files
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<div class="summary-header">
|
||||||
|
<div>
|
||||||
|
<div class="summary-title">
|
||||||
|
{% if gpt_metadata and gpt_metadata.filename %}
|
||||||
|
{{ gpt_metadata.filename | replace('.pdf','') | replace('_',' ') }}
|
||||||
|
{% elif file.document_title %}
|
||||||
|
{{ file.document_title }}
|
||||||
|
{% else %}
|
||||||
|
{{ file.original_filename or '(untitled)' }}
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="summary-subtitle">{{ file.original_filename }}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="display:flex;align-items:center;gap:0.75rem;flex-wrap:wrap;">
|
||||||
|
<!-- Status pill -->
|
||||||
|
{% if file.is_duplicate %}
|
||||||
|
<span class="status-pill pill-duplicate"><i class="fas fa-copy" aria-hidden="true"></i> Duplicate</span>
|
||||||
|
{% elif step_summary %}
|
||||||
|
{% set main_completed = step_summary.main.success + step_summary.main.skipped %}
|
||||||
|
{% if step_summary.main.failure > 0 or step_summary.uploads.failure > 0 %}
|
||||||
|
<span class="status-pill pill-failed"><i class="fas fa-times-circle" aria-hidden="true"></i> Failed</span>
|
||||||
|
{% elif step_summary.main["in_progress"] > 0 or step_summary.uploads["in_progress"] > 0 %}
|
||||||
|
<span class="status-pill pill-processing"><i class="fas fa-circle-notch fa-spin" aria-hidden="true"></i> Processing</span>
|
||||||
|
{% elif step_summary.total_main_steps > 0 and main_completed == step_summary.total_main_steps and step_summary.main.failure == 0 %}
|
||||||
|
<span class="status-pill pill-completed"><i class="fas fa-check-circle" aria-hidden="true"></i> Completed</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="status-pill pill-pending"><i class="fas fa-pause-circle" aria-hidden="true"></i> Pending</span>
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── Navigation cards ── -->
|
||||||
|
<nav class="nav-grid" aria-label="File sections">
|
||||||
|
<a href="/files/{{ file.id }}/detail" class="nav-card">
|
||||||
|
<i class="fas fa-file-alt" aria-hidden="true" style="color:#3b82f6;"></i>
|
||||||
|
<div class="nav-card-title">Document Detail</div>
|
||||||
|
<div class="nav-card-desc">Metadata, preview & extracted text</div>
|
||||||
|
</a>
|
||||||
|
<a href="/files/{{ file.id }}/process" class="nav-card">
|
||||||
|
<i class="fas fa-cogs" aria-hidden="true" style="color:#6366f1;"></i>
|
||||||
|
<div class="nav-card-title">Processing</div>
|
||||||
|
<div class="nav-card-desc">Pipeline status & processing history</div>
|
||||||
|
</a>
|
||||||
|
<a href="/files/{{ file.id }}/annotations" class="nav-card">
|
||||||
|
<i class="fas fa-comments" aria-hidden="true" style="color:#10b981;"></i>
|
||||||
|
<div class="nav-card-title">Comments & Annotations</div>
|
||||||
|
<div class="nav-card-desc">Discussion & document annotations</div>
|
||||||
|
</a>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<!-- ── File info card ── -->
|
||||||
|
<div class="summary-card">
|
||||||
|
<div class="summary-card-title"><i class="fas fa-info-circle" aria-hidden="true" style="color:#6366f1;margin-right:0.4rem;"></i>File Information</div>
|
||||||
|
<div class="info-row"><span class="info-key">File ID</span><span class="info-val">{{ file.id }}</span></div>
|
||||||
|
<div class="info-row"><span class="info-key">Filename</span><span class="info-val">{{ file.original_filename }}</span></div>
|
||||||
|
<div class="info-row"><span class="info-key">Size</span><span class="info-val">{{ (file.file_size / 1024) | round(1) }} KB</span></div>
|
||||||
|
<div class="info-row"><span class="info-key">MIME Type</span><span class="info-val">{{ file.mime_type or 'unknown' }}</span></div>
|
||||||
|
<div class="info-row"><span class="info-key">Created</span><span class="info-val">{{ file.created_at.strftime('%Y-%m-%d %H:%M') if file.created_at else 'N/A' }}</span></div>
|
||||||
|
{% if file.detected_language %}
|
||||||
|
<div class="info-row"><span class="info-key">Language</span><span class="info-val">{{ file.detected_language }}</span></div>
|
||||||
|
{% endif %}
|
||||||
|
{% if pipeline_info %}
|
||||||
|
<div class="info-row"><span class="info-key">Pipeline</span><span class="info-val">{{ pipeline_info.name }}</span></div>
|
||||||
|
{% endif %}
|
||||||
|
{% if file.document_title %}
|
||||||
|
<div class="info-row"><span class="info-key">Document Title</span><span class="info-val">{{ file.document_title }}</span></div>
|
||||||
|
{% endif %}
|
||||||
|
{% if multi_user_enabled %}
|
||||||
|
<div class="info-row">
|
||||||
|
<span class="info-key">{{ _("file.owner_label") }}</span>
|
||||||
|
<span class="info-val">{{ owner_display or _("file.owner_unowned") }}</span>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── Quick actions ── -->
|
||||||
|
<div class="summary-card">
|
||||||
|
<div class="summary-card-title"><i class="fas fa-bolt" aria-hidden="true" style="color:#f59e0b;margin-right:0.4rem;"></i>Quick Actions</div>
|
||||||
|
<div style="display:flex;gap:0.75rem;flex-wrap:wrap;">
|
||||||
|
{% if processed_file_exists %}
|
||||||
|
<a href="/api/files/{{ file.id }}/download?version=processed" class="action-btn btn-primary">
|
||||||
|
<i class="fas fa-download" aria-hidden="true"></i> Download Processed
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
{% if original_file_exists %}
|
||||||
|
<a href="/api/files/{{ file.id }}/download?version=original" class="action-btn btn-secondary">
|
||||||
|
<i class="fas fa-download" aria-hidden="true"></i> Download Original
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
<a href="/files/{{ file.id }}/detail" class="action-btn btn-secondary">
|
||||||
|
<i class="fas fa-eye" aria-hidden="true"></i> View Detail
|
||||||
|
</a>
|
||||||
|
{% if multi_user_enabled and file.owner_id is none %}
|
||||||
|
<button
|
||||||
|
class="action-btn btn-primary"
|
||||||
|
id="claim-btn"
|
||||||
|
aria-label="{{ _('file.claim_ownership') }}"
|
||||||
|
style="background:#10b981;"
|
||||||
|
>
|
||||||
|
<i class="fas fa-user-check" aria-hidden="true"></i> {{ _("file.claim_ownership") }}
|
||||||
|
</button>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<p id="claim-msg" style="margin-top:0.5rem;font-size:0.875rem;display:none;" role="alert"></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% else %}
|
||||||
|
<!-- file is None without error -->
|
||||||
|
<div class="error-box">Document not found.</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if multi_user_enabled and file and file.owner_id is none %}
|
||||||
|
<script src="{{ url_for('static', path='js/claim.js') }}" defer></script>
|
||||||
|
<script>
|
||||||
|
document.addEventListener('DOMContentLoaded', function () {
|
||||||
|
initClaimOwnership({{ file.id | tojson }}, {
|
||||||
|
confirm: {{ _("file.claim_ownership_confirm") | tojson }},
|
||||||
|
success: {{ _("file.claim_ownership_success") | tojson }},
|
||||||
|
failed: {{ _("file.claim_ownership_failed") | tojson }}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
@@ -159,8 +159,8 @@
|
|||||||
{% elif file %}
|
{% elif file %}
|
||||||
|
|
||||||
<!-- ── Back + header ── -->
|
<!-- ── Back + header ── -->
|
||||||
<a href="/files" style="display:inline-flex;align-items:center;gap:0.4rem;color:#3b82f6;font-weight:600;text-decoration:none;margin-bottom:1.25rem;" aria-label="Back to Files">
|
<a href="/files/{{ file.id }}" style="display:inline-flex;align-items:center;gap:0.4rem;color:#3b82f6;font-weight:600;text-decoration:none;margin-bottom:1.25rem;" aria-label="Back to File Summary">
|
||||||
<i class="fas fa-arrow-left" aria-hidden="true"></i> Back to Files
|
<i class="fas fa-arrow-left" aria-hidden="true"></i> Back to File Summary
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
<div class="doc-header">
|
<div class="doc-header">
|
||||||
@@ -195,8 +195,8 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<!-- Process detail link -->
|
<!-- Process detail link -->
|
||||||
<a href="/files/{{ file.id }}/detail" class="action-btn btn-process">
|
<a href="/files/{{ file.id }}/process" class="action-btn btn-process">
|
||||||
<i class="fas fa-cogs" aria-hidden="true"></i> Processing Details
|
<i class="fas fa-cogs" aria-hidden="true"></i> Processing
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -316,6 +316,12 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
{% if multi_user_enabled %}
|
||||||
|
<div class="info-row">
|
||||||
|
<span class="info-key">{{ _("file.owner_label") }}</span>
|
||||||
|
<span class="info-val">{{ owner_display or _("file.owner_unowned") }}</span>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Actions -->
|
<!-- Actions -->
|
||||||
@@ -344,7 +350,18 @@
|
|||||||
<a href="/shared-links?file_id={{ file.id }}" class="action-btn btn-secondary">
|
<a href="/shared-links?file_id={{ file.id }}" class="action-btn btn-secondary">
|
||||||
<i class="fas fa-share-alt" aria-hidden="true"></i> Share
|
<i class="fas fa-share-alt" aria-hidden="true"></i> Share
|
||||||
</a>
|
</a>
|
||||||
|
{% if multi_user_enabled and file.owner_id is none %}
|
||||||
|
<button
|
||||||
|
class="action-btn btn-primary"
|
||||||
|
id="claim-btn"
|
||||||
|
aria-label="{{ _('file.claim_ownership') }}"
|
||||||
|
style="background:#10b981;border:none;cursor:pointer;"
|
||||||
|
>
|
||||||
|
<i class="fas fa-user-check" aria-hidden="true"></i> {{ _("file.claim_ownership") }}
|
||||||
|
</button>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
<p id="claim-msg" style="margin-top:0.5rem;font-size:0.875rem;display:none;" role="alert"></p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -937,6 +954,16 @@
|
|||||||
pdfInit('/api/files/{{ file.id }}/preview?version={{ pv }}');
|
pdfInit('/api/files/{{ file.id }}/preview?version={{ pv }}');
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
{% if multi_user_enabled and file and file.owner_id is none %}
|
||||||
|
initClaimOwnership({{ file.id | tojson }}, {
|
||||||
|
confirm: {{ _("file.claim_ownership_confirm") | tojson }},
|
||||||
|
success: {{ _("file.claim_ownership_success") | tojson }},
|
||||||
|
failed: {{ _("file.claim_ownership_failed") | tojson }}
|
||||||
|
});
|
||||||
|
{% endif %}
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
{% if multi_user_enabled and file and file.owner_id is none %}
|
||||||
|
<script src="{{ url_for('static', path='js/claim.js') }}" defer></script>
|
||||||
|
{% endif %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -904,7 +904,7 @@
|
|||||||
|
|
||||||
function viewFileDetail(fileId, event) {
|
function viewFileDetail(fileId, event) {
|
||||||
if (event) event.stopPropagation();
|
if (event) event.stopPropagation();
|
||||||
window.location.href = `/files/${fileId}/detail`;
|
window.location.href = `/files/${fileId}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Preview modal ──
|
// ── Preview modal ──
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
|||||||
@@ -167,14 +167,31 @@
|
|||||||
<p class="mb-4">Now enter your Client ID and Client Secret below, and we'll help you complete the OAuth flow. You can set the folder ID after authentication is complete.</p>
|
<p class="mb-4">Now enter your Client ID and Client Secret below, and we'll help you complete the OAuth flow. You can set the folder ID after authentication is complete.</p>
|
||||||
|
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<div>
|
{% if user_mode and has_system_credentials %}
|
||||||
<label for="client-id" class="block text-sm font-medium text-gray-700">Client ID</label>
|
<!-- System credentials toggle (user mode only) -->
|
||||||
<input type="text" id="client-id" class="mt-1 block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm" placeholder="Enter your OAuth Client ID" value="{{ client_id_value }}">
|
<div class="bg-green-50 border border-green-200 rounded-md p-4">
|
||||||
|
<label class="flex items-center gap-3 cursor-pointer">
|
||||||
|
<input type="checkbox" id="use-system-creds" checked class="rounded border-gray-300 text-green-600 focus:ring-green-500 h-5 w-5" />
|
||||||
|
<div>
|
||||||
|
<span class="text-sm font-medium text-green-800">Use DocuElevate's app credentials</span>
|
||||||
|
<p class="text-xs text-green-600 mt-0.5">Recommended — authorize with your Google account without needing your own Google Cloud app registration.</p>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<div>
|
<div id="custom-creds-section" {% if user_mode and has_system_credentials %}class="hidden"{% endif %}>
|
||||||
<label for="client-secret" class="block text-sm font-medium text-gray-700">Client Secret</label>
|
<div class="space-y-4">
|
||||||
<input type="password" id="client-secret" class="mt-1 block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm" placeholder="Enter your OAuth Client Secret" value="{{ client_secret_value }}">
|
<div>
|
||||||
|
<label for="client-id" class="block text-sm font-medium text-gray-700">Client ID</label>
|
||||||
|
<input type="text" id="client-id" class="mt-1 block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm" placeholder="Enter your OAuth Client ID" value="{{ client_id_value if not (user_mode and has_system_credentials) else '' }}">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="client-secret" class="block text-sm font-medium text-gray-700">Client Secret</label>
|
||||||
|
<input type="password" id="client-secret" class="mt-1 block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm" placeholder="Enter your OAuth Client Secret" value="{{ client_secret_value if not (user_mode and has_system_credentials) else '' }}">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
@@ -430,6 +447,9 @@ GOOGLE_DRIVE_FOLDER_ID={{ folder_id|default('YOUR_FOLDER_ID', true) }}
|
|||||||
<script>
|
<script>
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
const userMode = {{ 'true' if user_mode else 'false' }};
|
const userMode = {{ 'true' if user_mode else 'false' }};
|
||||||
|
const hasSystemCredentials = {{ 'true' if (user_mode and has_system_credentials) else 'false' }};
|
||||||
|
const systemClientId = {{ (client_id_value or '') | tojson }};
|
||||||
|
const systemClientSecret = {{ (client_secret_value or '') | tojson }};
|
||||||
|
|
||||||
// Store integration_id if provided (for per-user OAuth flow)
|
// Store integration_id if provided (for per-user OAuth flow)
|
||||||
const integrationId = "{{ integration_id or '' }}";
|
const integrationId = "{{ integration_id or '' }}";
|
||||||
@@ -437,6 +457,19 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
sessionStorage.setItem('oauth_integration_id', integrationId);
|
sessionStorage.setItem('oauth_integration_id', integrationId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// System credentials toggle
|
||||||
|
const useSystemCredsCheckbox = document.getElementById('use-system-creds');
|
||||||
|
const customCredsSection = document.getElementById('custom-creds-section');
|
||||||
|
if (useSystemCredsCheckbox && customCredsSection) {
|
||||||
|
useSystemCredsCheckbox.addEventListener('change', function() {
|
||||||
|
if (this.checked) {
|
||||||
|
customCredsSection.classList.add('hidden');
|
||||||
|
} else {
|
||||||
|
customCredsSection.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Form elements
|
// Form elements
|
||||||
const clientIdInput = document.getElementById('client-id');
|
const clientIdInput = document.getElementById('client-id');
|
||||||
const clientSecretInput = document.getElementById('client-secret');
|
const clientSecretInput = document.getElementById('client-secret');
|
||||||
@@ -719,8 +752,10 @@ GOOGLE_DRIVE_FOLDER_ID=${folderId}
|
|||||||
// Start OAuth flow button
|
// Start OAuth flow button
|
||||||
if (startOauthFlowBtn) {
|
if (startOauthFlowBtn) {
|
||||||
startOauthFlowBtn.addEventListener('click', function() {
|
startOauthFlowBtn.addEventListener('click', function() {
|
||||||
const clientId = clientIdInput.value.trim();
|
// Determine which credentials to use
|
||||||
const clientSecret = clientSecretInput.value.trim();
|
const useSystemCreds = hasSystemCredentials && useSystemCredsCheckbox && useSystemCredsCheckbox.checked;
|
||||||
|
const clientId = useSystemCreds ? systemClientId : clientIdInput.value.trim();
|
||||||
|
const clientSecret = useSystemCreds ? systemClientSecret : clientSecretInput.value.trim();
|
||||||
const folderId = folderIdInput ? folderIdInput.value.trim() : '';
|
const folderId = folderIdInput ? folderIdInput.value.trim() : '';
|
||||||
|
|
||||||
if (!clientId) {
|
if (!clientId) {
|
||||||
@@ -736,6 +771,9 @@ GOOGLE_DRIVE_FOLDER_ID=${folderId}
|
|||||||
// Save values to session storage for use after redirect
|
// Save values to session storage for use after redirect
|
||||||
sessionStorage.setItem('google_drive_client_id', clientId);
|
sessionStorage.setItem('google_drive_client_id', clientId);
|
||||||
sessionStorage.setItem('google_drive_client_secret', clientSecret);
|
sessionStorage.setItem('google_drive_client_secret', clientSecret);
|
||||||
|
if (useSystemCreds) {
|
||||||
|
sessionStorage.setItem('google_drive_use_system_creds', 'true');
|
||||||
|
}
|
||||||
|
|
||||||
// In admin mode store folder_id; in user mode the config is already set
|
// In admin mode store folder_id; in user mode the config is already set
|
||||||
if (!userMode && folderId) {
|
if (!userMode && folderId) {
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
|||||||
@@ -130,20 +130,37 @@
|
|||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<div>
|
{% if user_mode and has_system_credentials %}
|
||||||
<label for="client-id" class="block text-sm font-medium text-gray-700">Client ID <span class="text-red-500" aria-hidden="true">*</span></label>
|
<!-- System credentials toggle (user mode only) -->
|
||||||
<input type="text" id="client-id" class="mt-1 block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm" placeholder="Enter your Azure AD application client ID" value="{{ client_id_value }}">
|
<div class="bg-green-50 border border-green-200 rounded-md p-4">
|
||||||
|
<label class="flex items-center gap-3 cursor-pointer">
|
||||||
|
<input type="checkbox" id="use-system-creds" checked class="rounded border-gray-300 text-green-600 focus:ring-green-500 h-5 w-5" />
|
||||||
|
<div>
|
||||||
|
<span class="text-sm font-medium text-green-800">Use DocuElevate's app credentials</span>
|
||||||
|
<p class="text-xs text-green-600 mt-0.5">Recommended — authorize with your OneDrive account without needing your own Azure app registration.</p>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<div>
|
<div id="custom-creds-section" {% if user_mode and has_system_credentials %}class="hidden"{% endif %}>
|
||||||
<label for="client-secret" class="block text-sm font-medium text-gray-700">Client Secret <span class="text-red-500" aria-hidden="true">*</span></label>
|
<div class="space-y-4">
|
||||||
<input type="password" id="client-secret" class="mt-1 block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm" placeholder="Enter your Azure AD application client secret" value="{{ client_secret_value }}">
|
<div>
|
||||||
</div>
|
<label for="client-id" class="block text-sm font-medium text-gray-700">Client ID <span class="text-red-500" aria-hidden="true">*</span></label>
|
||||||
|
<input type="text" id="client-id" class="mt-1 block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm" placeholder="Enter your Azure AD application client ID" value="{{ client_id_value if not (user_mode and has_system_credentials) else '' }}">
|
||||||
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label for="tenant-id" class="block text-sm font-medium text-gray-700">Tenant ID (Optional)</label>
|
<label for="client-secret" class="block text-sm font-medium text-gray-700">Client Secret <span class="text-red-500" aria-hidden="true">*</span></label>
|
||||||
<input type="text" id="tenant-id" class="mt-1 block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm" placeholder="common" value="{{ tenant_id }}">
|
<input type="password" id="client-secret" class="mt-1 block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm" placeholder="Enter your Azure AD application client secret" value="{{ client_secret_value if not (user_mode and has_system_credentials) else '' }}">
|
||||||
<p class="text-xs text-gray-500 mt-1">Use "common" for personal accounts or your organization's Tenant ID for corporate accounts</p>
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="tenant-id" class="block text-sm font-medium text-gray-700">Tenant ID (Optional)</label>
|
||||||
|
<input type="text" id="tenant-id" class="mt-1 block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm" placeholder="common" value="{{ tenant_id }}">
|
||||||
|
<p class="text-xs text-gray-500 mt-1">Use "common" for personal accounts or your organization's Tenant ID for corporate accounts</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% if not user_mode %}
|
{% if not user_mode %}
|
||||||
@@ -289,6 +306,10 @@ ONEDRIVE_FOLDER_PATH={{ folder_path|default('Documents/Uploads', true) }}</code>
|
|||||||
<script>
|
<script>
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
const userMode = {{ 'true' if user_mode else 'false' }};
|
const userMode = {{ 'true' if user_mode else 'false' }};
|
||||||
|
const hasSystemCredentials = {{ 'true' if (user_mode and has_system_credentials) else 'false' }};
|
||||||
|
const systemClientId = {{ (client_id_value or '') | tojson }};
|
||||||
|
const systemClientSecret = {{ (client_secret_value or '') | tojson }};
|
||||||
|
const systemTenantId = {{ (tenant_id or 'common') | tojson }};
|
||||||
|
|
||||||
// Store integration_id if provided (for per-user OAuth flow)
|
// Store integration_id if provided (for per-user OAuth flow)
|
||||||
const integrationId = "{{ integration_id or '' }}";
|
const integrationId = "{{ integration_id or '' }}";
|
||||||
@@ -302,6 +323,19 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
const refreshTokenBtn = document.getElementById('refresh-token-btn');
|
const refreshTokenBtn = document.getElementById('refresh-token-btn');
|
||||||
const tokenStatus = document.getElementById('token-status');
|
const tokenStatus = document.getElementById('token-status');
|
||||||
const clientSecretInput = document.getElementById('client-secret');
|
const clientSecretInput = document.getElementById('client-secret');
|
||||||
|
const useSystemCredsCheckbox = document.getElementById('use-system-creds');
|
||||||
|
const customCredsSection = document.getElementById('custom-creds-section');
|
||||||
|
|
||||||
|
// Toggle custom credentials section visibility
|
||||||
|
if (useSystemCredsCheckbox && customCredsSection) {
|
||||||
|
useSystemCredsCheckbox.addEventListener('change', function() {
|
||||||
|
if (this.checked) {
|
||||||
|
customCredsSection.classList.add('hidden');
|
||||||
|
} else {
|
||||||
|
customCredsSection.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Modal elements
|
// Modal elements
|
||||||
const resultModal = document.getElementById('resultModal');
|
const resultModal = document.getElementById('resultModal');
|
||||||
@@ -351,10 +385,12 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
|
|
||||||
// Start Authentication Flow button click
|
// Start Authentication Flow button click
|
||||||
startAuthFlowBtn.addEventListener('click', function() {
|
startAuthFlowBtn.addEventListener('click', function() {
|
||||||
const clientId = document.getElementById('client-id').value.trim();
|
// Determine which credentials to use
|
||||||
const clientSecret = clientSecretInput.value.trim();
|
const useSystemCreds = hasSystemCredentials && useSystemCredsCheckbox && useSystemCredsCheckbox.checked;
|
||||||
|
const clientId = useSystemCreds ? systemClientId : document.getElementById('client-id').value.trim();
|
||||||
|
const clientSecret = useSystemCreds ? systemClientSecret : clientSecretInput.value.trim();
|
||||||
const redirectUri = window.location.origin + "/onedrive-callback";
|
const redirectUri = window.location.origin + "/onedrive-callback";
|
||||||
const tenantId = document.getElementById('tenant-id').value.trim() || 'common';
|
const tenantId = useSystemCreds ? systemTenantId : (document.getElementById('tenant-id').value.trim() || 'common');
|
||||||
|
|
||||||
if (!clientId) {
|
if (!clientId) {
|
||||||
showModal('error', 'Validation Error', 'Please enter your Client ID');
|
showModal('error', 'Validation Error', 'Please enter your Client ID');
|
||||||
@@ -370,6 +406,9 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
sessionStorage.setItem('onedrive_client_id', clientId);
|
sessionStorage.setItem('onedrive_client_id', clientId);
|
||||||
sessionStorage.setItem('onedrive_client_secret', clientSecret);
|
sessionStorage.setItem('onedrive_client_secret', clientSecret);
|
||||||
sessionStorage.setItem('onedrive_tenant_id', tenantId);
|
sessionStorage.setItem('onedrive_tenant_id', tenantId);
|
||||||
|
if (useSystemCreds) {
|
||||||
|
sessionStorage.setItem('onedrive_use_system_creds', 'true');
|
||||||
|
}
|
||||||
|
|
||||||
// In admin mode also store folder path; in user mode the config is already set
|
// In admin mode also store folder path; in user mode the config is already set
|
||||||
if (!userMode) {
|
if (!userMode) {
|
||||||
|
|||||||
@@ -59,6 +59,40 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Folder selector (shown after authorization) -->
|
||||||
|
<div id="folder-selector" class="hidden mt-6 p-4 bg-white border border-gray-200 rounded-lg">
|
||||||
|
<h3 class="font-medium text-lg mb-3">
|
||||||
|
<svg class="inline-block h-5 w-5 mr-1 text-gray-500" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
||||||
|
<path fill-rule="evenodd" d="M2 6a2 2 0 012-2h4l2 2h4a2 2 0 012 2v1H8a3 3 0 00-3 3v1.5a1.5 1.5 0 01-3 0V6z" clip-rule="evenodd" />
|
||||||
|
<path d="M6 12a2 2 0 012-2h8a2 2 0 012 2v2a2 2 0 01-2 2H2h2a2 2 0 002-2v-2z" />
|
||||||
|
</svg>
|
||||||
|
Select Folder
|
||||||
|
</h3>
|
||||||
|
<p class="text-sm text-gray-600 mb-3">Browse your OneDrive to select a folder for this integration.</p>
|
||||||
|
|
||||||
|
<div class="mb-3">
|
||||||
|
<div id="folder-breadcrumb" class="flex items-center text-sm text-gray-500 mb-2">
|
||||||
|
<button type="button" class="folder-nav-btn text-indigo-600 hover:text-indigo-800 font-medium" data-path="" aria-label="Navigate to root folder">/ Root</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="folder-list" class="border border-gray-200 rounded-md max-h-64 overflow-y-auto">
|
||||||
|
<div class="p-4 text-center text-gray-500">
|
||||||
|
<div class="animate-spin inline-block h-5 w-5 border-2 border-gray-300 border-t-indigo-600 rounded-full mb-2" role="status" aria-label="Loading folders"></div>
|
||||||
|
<p class="text-sm">Loading folders…</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-3 flex items-center gap-3">
|
||||||
|
<label for="selected-folder-path" class="text-sm font-medium text-gray-700 whitespace-nowrap">Selected folder:</label>
|
||||||
|
<input id="selected-folder-path" type="text" class="flex-1 px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 text-sm" placeholder="/" />
|
||||||
|
<button id="save-folder-btn" type="button" class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500">
|
||||||
|
Save Folder
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p id="folder-save-status" class="mt-2 text-sm hidden" aria-live="polite"></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="mt-6 p-4 bg-gray-100 rounded-md">
|
<div class="mt-6 p-4 bg-gray-100 rounded-md">
|
||||||
<h3 class="font-medium text-lg mb-2">Configuration for Worker Nodes</h3>
|
<h3 class="font-medium text-lg mb-2">Configuration for Worker Nodes</h3>
|
||||||
<p class="text-sm text-gray-600 mb-3">
|
<p class="text-sm text-gray-600 mb-3">
|
||||||
@@ -177,19 +211,22 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
}
|
}
|
||||||
return response.json();
|
return response.json();
|
||||||
}).then(() => {
|
}).then(() => {
|
||||||
// Clean up
|
// Clean up session storage
|
||||||
sessionStorage.removeItem('onedrive_client_id');
|
sessionStorage.removeItem('onedrive_client_id');
|
||||||
sessionStorage.removeItem('onedrive_client_secret');
|
sessionStorage.removeItem('onedrive_client_secret');
|
||||||
sessionStorage.removeItem('onedrive_tenant_id');
|
sessionStorage.removeItem('onedrive_tenant_id');
|
||||||
sessionStorage.removeItem('onedrive_folder_path');
|
sessionStorage.removeItem('onedrive_folder_path');
|
||||||
sessionStorage.removeItem('oauth_integration_id');
|
sessionStorage.removeItem('oauth_integration_id');
|
||||||
|
sessionStorage.removeItem('onedrive_use_system_creds');
|
||||||
|
|
||||||
// Show brief success then redirect to integrations
|
// Hide processing spinner, show success
|
||||||
document.getElementById('processing-message').innerHTML =
|
|
||||||
'<p class="text-green-600 font-semibold">✓ OneDrive authorized successfully!</p>' +
|
|
||||||
'<p class="text-sm text-gray-500 mt-2">Redirecting to Integrations...</p>';
|
|
||||||
document.querySelector('.animate-spin').parentNode.classList.add('hidden');
|
document.querySelector('.animate-spin').parentNode.classList.add('hidden');
|
||||||
setTimeout(() => { window.location.href = '/integrations'; }, 2000);
|
document.getElementById('processing-message').innerHTML =
|
||||||
|
'<p class="text-green-600 font-semibold">✓ OneDrive authorized successfully!</p>';
|
||||||
|
document.getElementById('success-container').classList.remove('hidden');
|
||||||
|
|
||||||
|
// Show folder browser with the access token
|
||||||
|
initFolderBrowser(data.access_token, integrationId);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -291,6 +328,120 @@ ONEDRIVE_FOLDER_PATH=${folderPath || 'Documents/Uploads'}`;
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Folder browser ────────────────────────────────────────────────
|
||||||
|
function escapeHtml(str) {
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.appendChild(document.createTextNode(str));
|
||||||
|
return div.innerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
function initFolderBrowser(accessToken, integrationId) {
|
||||||
|
const folderSelector = document.getElementById('folder-selector');
|
||||||
|
if (!folderSelector || !integrationId || !accessToken) return;
|
||||||
|
|
||||||
|
folderSelector.classList.remove('hidden');
|
||||||
|
|
||||||
|
const folderList = document.getElementById('folder-list');
|
||||||
|
const breadcrumb = document.getElementById('folder-breadcrumb');
|
||||||
|
const selectedInput = document.getElementById('selected-folder-path');
|
||||||
|
const saveBtn = document.getElementById('save-folder-btn');
|
||||||
|
const saveStatus = document.getElementById('folder-save-status');
|
||||||
|
|
||||||
|
function loadFolders(path) {
|
||||||
|
folderList.innerHTML = '<div class="p-4 text-center text-gray-500"><div class="animate-spin inline-block h-5 w-5 border-2 border-gray-300 border-t-indigo-600 rounded-full mb-2" role="status" aria-label="Loading folders"></div><p class="text-sm">Loading folders…</p></div>';
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('access_token', accessToken);
|
||||||
|
formData.append('path', path);
|
||||||
|
|
||||||
|
fetch('/api/onedrive/list-folders', { method: 'POST', body: formData })
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(data => {
|
||||||
|
if (data.folders && data.folders.length > 0) {
|
||||||
|
folderList.innerHTML = data.folders.map(f =>
|
||||||
|
`<button type="button" class="folder-item w-full text-left px-4 py-3 hover:bg-indigo-50 border-b border-gray-100 flex items-center gap-3 text-sm focus:outline-none focus:bg-indigo-50" data-path="${escapeHtml(f.path)}" aria-label="Open folder ${escapeHtml(f.name)}">` +
|
||||||
|
`<svg class="h-5 w-5 text-blue-400 flex-shrink-0" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true"><path fill-rule="evenodd" d="M2 6a2 2 0 012-2h4l2 2h4a2 2 0 012 2v1H8a3 3 0 00-3 3v1.5a1.5 1.5 0 01-3 0V6z" clip-rule="evenodd" /><path d="M6 12a2 2 0 012-2h8a2 2 0 012 2v2a2 2 0 01-2 2H2h2a2 2 0 002-2v-2z" /></svg>` +
|
||||||
|
`<span class="font-medium text-gray-700">${escapeHtml(f.name)}</span>` +
|
||||||
|
`</button>`
|
||||||
|
).join('');
|
||||||
|
|
||||||
|
folderList.querySelectorAll('.folder-item').forEach(btn => {
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
const p = btn.getAttribute('data-path');
|
||||||
|
selectedInput.value = p;
|
||||||
|
loadFolders(p.replace(/^\//, ''));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
folderList.innerHTML = '<div class="p-4 text-center text-gray-400 text-sm">No subfolders found</div>';
|
||||||
|
}
|
||||||
|
updateBreadcrumb(path);
|
||||||
|
})
|
||||||
|
.catch(err => {
|
||||||
|
folderList.innerHTML = `<div class="p-4 text-center text-red-500 text-sm">Failed to load folders: ${escapeHtml(err.message)}</div>`;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateBreadcrumb(path) {
|
||||||
|
const parts = path.split('/').filter(Boolean);
|
||||||
|
let html = '<button type="button" class="folder-nav-btn text-indigo-600 hover:text-indigo-800 font-medium" data-path="" aria-label="Navigate to root folder">/ Root</button>';
|
||||||
|
let accumulated = '';
|
||||||
|
for (const part of parts) {
|
||||||
|
accumulated += '/' + part;
|
||||||
|
html += `<span class="mx-1 text-gray-400">/</span>`;
|
||||||
|
html += `<button type="button" class="folder-nav-btn text-indigo-600 hover:text-indigo-800 font-medium" data-path="${escapeHtml(accumulated.replace(/^\//, ''))}" aria-label="Navigate to ${escapeHtml(part)}">${escapeHtml(part)}</button>`;
|
||||||
|
}
|
||||||
|
breadcrumb.innerHTML = html;
|
||||||
|
breadcrumb.querySelectorAll('.folder-nav-btn').forEach(btn => {
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
const p = btn.getAttribute('data-path');
|
||||||
|
selectedInput.value = p ? '/' + p : '/';
|
||||||
|
loadFolders(p);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save selected folder to integration config
|
||||||
|
saveBtn.addEventListener('click', () => {
|
||||||
|
const folderPath = selectedInput.value.trim() || '/';
|
||||||
|
saveBtn.disabled = true;
|
||||||
|
saveBtn.textContent = 'Saving…';
|
||||||
|
|
||||||
|
fetch(`/api/integrations/${integrationId}`)
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(intg => {
|
||||||
|
const cfg = intg.config || {};
|
||||||
|
cfg.folder_path = folderPath;
|
||||||
|
return fetch(`/api/integrations/${integrationId}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ config: cfg }),
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.then(r => {
|
||||||
|
if (!r.ok) throw new Error('Failed to save folder');
|
||||||
|
return r.json();
|
||||||
|
})
|
||||||
|
.then(() => {
|
||||||
|
saveStatus.textContent = '✓ Folder saved! Redirecting…';
|
||||||
|
saveStatus.className = 'mt-2 text-sm text-green-600';
|
||||||
|
saveStatus.classList.remove('hidden');
|
||||||
|
saveBtn.textContent = 'Saved ✓';
|
||||||
|
setTimeout(() => { window.location.href = '/integrations'; }, 1500);
|
||||||
|
})
|
||||||
|
.catch(err => {
|
||||||
|
saveStatus.textContent = 'Error: ' + err.message;
|
||||||
|
saveStatus.className = 'mt-2 text-sm text-red-600';
|
||||||
|
saveStatus.classList.remove('hidden');
|
||||||
|
saveBtn.disabled = false;
|
||||||
|
saveBtn.textContent = 'Save Folder';
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Load root folders initially
|
||||||
|
loadFolders('');
|
||||||
|
}
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
|||||||
@@ -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=="
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>DocuElevate - Create Account</title>
|
<title>DocuElevate - Create Account</title>
|
||||||
<link href="https://cdn.jsdelivr.net/npm/tailwindcss@2.2.19/dist/tailwind.min.css" rel="stylesheet">
|
<link rel="stylesheet" href="/static/styles.css" />
|
||||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css"
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css"
|
||||||
integrity="sha512-DTOQO9RWCH3ppGqcWaEA1BIZOC6xxalwEsw9c2QQeAIftl+Vegovlnee1c9QX4TctnWMn13TZye+giMm8e2LwA=="
|
integrity="sha512-DTOQO9RWCH3ppGqcWaEA1BIZOC6xxalwEsw9c2QQeAIftl+Vegovlnee1c9QX4TctnWMn13TZye+giMm8e2LwA=="
|
||||||
crossorigin="anonymous" referrerpolicy="no-referrer" />
|
crossorigin="anonymous" referrerpolicy="no-referrer" />
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
|||||||
@@ -315,6 +315,21 @@
|
|||||||
"admin_users.total_count_users": "{count} users",
|
"admin_users.total_count_users": "{count} users",
|
||||||
"admin_users.total_no_users": "No users",
|
"admin_users.total_no_users": "No users",
|
||||||
"admin_users.total_one_user": "1 user",
|
"admin_users.total_one_user": "1 user",
|
||||||
|
"annotations.add": "Add annotation",
|
||||||
|
"annotations.color": "Color",
|
||||||
|
"annotations.content_placeholder": "Write an annotation...",
|
||||||
|
"annotations.delete_confirm": "Are you sure you want to delete this annotation?",
|
||||||
|
"annotations.deleted": "Annotation deleted",
|
||||||
|
"annotations.empty": "No annotations yet",
|
||||||
|
"annotations.go_to_page": "Go to page",
|
||||||
|
"annotations.heading": "Annotations",
|
||||||
|
"annotations.page": "Page",
|
||||||
|
"annotations.save": "Save",
|
||||||
|
"annotations.type_highlight": "Highlight",
|
||||||
|
"annotations.type_note": "Note",
|
||||||
|
"annotations.type_strikethrough": "Strikethrough",
|
||||||
|
"annotations.type_underline": "Underline",
|
||||||
|
"annotations.updated": "Annotation updated",
|
||||||
"api_tokens.col_created": "Created",
|
"api_tokens.col_created": "Created",
|
||||||
"api_tokens.col_expires": "Expires",
|
"api_tokens.col_expires": "Expires",
|
||||||
"api_tokens.col_last_ip": "Last IP",
|
"api_tokens.col_last_ip": "Last IP",
|
||||||
@@ -484,6 +499,21 @@
|
|||||||
"billing.success_heading": "You're all set!",
|
"billing.success_heading": "You're all set!",
|
||||||
"billing.success_message": "Your subscription has been activated. Thank you for choosing DocuElevate!",
|
"billing.success_message": "Your subscription has been activated. Thank you for choosing DocuElevate!",
|
||||||
"billing.success_page_title": "DocuElevate - Subscription Activated",
|
"billing.success_page_title": "DocuElevate - Subscription Activated",
|
||||||
|
"comments.add_comment": "Add comment",
|
||||||
|
"comments.add_reply": "Reply",
|
||||||
|
"comments.body_placeholder": "Write a comment... Use @username to mention someone",
|
||||||
|
"comments.delete_confirm": "Are you sure you want to delete this comment?",
|
||||||
|
"comments.deleted": "Comment deleted",
|
||||||
|
"comments.edit": "Edit",
|
||||||
|
"comments.empty": "No comments yet",
|
||||||
|
"comments.heading": "Comments",
|
||||||
|
"comments.mention_users": "Mention users",
|
||||||
|
"comments.reply_placeholder": "Write a reply...",
|
||||||
|
"comments.resolve": "Resolve",
|
||||||
|
"comments.resolved": "Resolved",
|
||||||
|
"comments.save": "Save",
|
||||||
|
"comments.unresolve": "Reopen",
|
||||||
|
"comments.updated": "Comment updated",
|
||||||
"common.actions": "Actions",
|
"common.actions": "Actions",
|
||||||
"common.active": "Active",
|
"common.active": "Active",
|
||||||
"common.all": "All",
|
"common.all": "All",
|
||||||
@@ -588,6 +618,22 @@
|
|||||||
"cookie_policy.s5_p3_pre": "This Cookie Policy is part of and incorporated into our",
|
"cookie_policy.s5_p3_pre": "This Cookie Policy is part of and incorporated into our",
|
||||||
"cookie_policy.s5_privacy_link": "Privacy Notice",
|
"cookie_policy.s5_privacy_link": "Privacy Notice",
|
||||||
"cookie_policy.s5_terms_link": "Terms of Service",
|
"cookie_policy.s5_terms_link": "Terms of Service",
|
||||||
|
"connections.configure": "Configure",
|
||||||
|
"connections.configured": "Connected",
|
||||||
|
"connections.description": "Configure external authentication providers like OAuth2 and SAML.",
|
||||||
|
"connections.frontend_url_note": "Note: Requires Frontend URL to be configured. Configure in System Settings.",
|
||||||
|
"connections.linked": "Linked",
|
||||||
|
"connections.mobile_upload_description": "Allow users to upload files from mobile devices by scanning a QR code.",
|
||||||
|
"connections.mobile_upload_title": "Mobile Phone Upload",
|
||||||
|
"connections.qr_code_enabled": "Enable QR Code Upload",
|
||||||
|
"connections.save_note": "Changes to authentication providers require a restart to take effect.",
|
||||||
|
"connections.sso_auto_login": "Enable SSO Auto Login",
|
||||||
|
"connections.sso_auto_login_description": "Automatically redirect to SSO login when authentication is required.",
|
||||||
|
"connections.sso_auto_login_title": "SSO Auto Login",
|
||||||
|
"connections.title": "Connections",
|
||||||
|
"connections.unconfigure": "Disconnect",
|
||||||
|
"connections.unlinked": "Unlinked",
|
||||||
|
"connections.unlinked_services": "Services",
|
||||||
"credentials.col_action": "Action",
|
"credentials.col_action": "Action",
|
||||||
"credentials.col_credential": "Credential",
|
"credentials.col_credential": "Credential",
|
||||||
"credentials.col_source": "Source",
|
"credentials.col_source": "Source",
|
||||||
@@ -1201,6 +1247,7 @@
|
|||||||
"nav.api_docs": "API Docs",
|
"nav.api_docs": "API Docs",
|
||||||
"nav.api_tokens": "API Tokens",
|
"nav.api_tokens": "API Tokens",
|
||||||
"nav.backup_restore": "Backup & Restore",
|
"nav.backup_restore": "Backup & Restore",
|
||||||
|
"nav.connections": "Connections",
|
||||||
"nav.credentials": "Credentials",
|
"nav.credentials": "Credentials",
|
||||||
"nav.dark_mode": "Dark Mode",
|
"nav.dark_mode": "Dark Mode",
|
||||||
"nav.dashboard": "Dashboard",
|
"nav.dashboard": "Dashboard",
|
||||||
@@ -1697,6 +1744,25 @@
|
|||||||
"shared.table_aria": "Shared links",
|
"shared.table_aria": "Shared links",
|
||||||
"shared.unlimited_placeholder": "Unlimited",
|
"shared.unlimited_placeholder": "Unlimited",
|
||||||
"shared.your_links": "Your Shared Links",
|
"shared.your_links": "Your Shared Links",
|
||||||
|
"sharing.add_share": "Share",
|
||||||
|
"sharing.change_role": "Change role",
|
||||||
|
"sharing.error_empty_user": "Please enter a user ID to share with.",
|
||||||
|
"sharing.heading": "Share with Users",
|
||||||
|
"sharing.loading": "Loading shares…",
|
||||||
|
"sharing.no_shares": "Not shared with anyone yet.",
|
||||||
|
"sharing.revoke": "Revoke access",
|
||||||
|
"sharing.revoke_confirm": "Remove this user's access to the file?",
|
||||||
|
"sharing.role_editor": "Editor",
|
||||||
|
"sharing.role_label": "Role",
|
||||||
|
"sharing.role_viewer": "Viewer",
|
||||||
|
"sharing.user_id_label": "User ID or email",
|
||||||
|
"sharing.user_id_placeholder": "e.g. alice@example.com",
|
||||||
|
"file.owner_label": "Owner",
|
||||||
|
"file.owner_unowned": "Unowned",
|
||||||
|
"file.claim_ownership": "Claim Ownership",
|
||||||
|
"file.claim_ownership_confirm": "Claim this document as yours? You will become the owner and can manage sharing.",
|
||||||
|
"file.claim_ownership_success": "You are now the owner of this document.",
|
||||||
|
"file.claim_ownership_failed": "Could not claim ownership. The document may already have an owner.",
|
||||||
"similarity.backfill_auto": "The background task will compute them automatically every 5 minutes, or you can",
|
"similarity.backfill_auto": "The background task will compute them automatically every 5 minutes, or you can",
|
||||||
"similarity.files_missing_text": "file(s) have OCR text but no embedding yet.",
|
"similarity.files_missing_text": "file(s) have OCR text but no embedding yet.",
|
||||||
"similarity.find_pairs_btn": "Find Pairs",
|
"similarity.find_pairs_btn": "Find Pairs",
|
||||||
@@ -1913,5 +1979,6 @@
|
|||||||
"upload.uploading": "Uploading...",
|
"upload.uploading": "Uploading...",
|
||||||
"upload.url_description": "Enter a direct link to a file (PDF, Office documents, or images)",
|
"upload.url_description": "Enter a direct link to a file (PDF, Office documents, or images)",
|
||||||
"upload.url_label": "File URL",
|
"upload.url_label": "File URL",
|
||||||
"upload.url_placeholder": "https://example.com/document.pdf"
|
"upload.url_placeholder": "https://example.com/document.pdf",
|
||||||
|
"annotations.type": "Type"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -24,7 +24,10 @@ from app.models import ( # noqa: F401
|
|||||||
ApplicationSettings,
|
ApplicationSettings,
|
||||||
AuditLog,
|
AuditLog,
|
||||||
BackupRecord,
|
BackupRecord,
|
||||||
|
ClassificationRuleModel,
|
||||||
ComplianceTemplate,
|
ComplianceTemplate,
|
||||||
|
DocumentAnnotation,
|
||||||
|
DocumentComment,
|
||||||
DocumentMetadata,
|
DocumentMetadata,
|
||||||
FileProcessingStep,
|
FileProcessingStep,
|
||||||
FileRecord,
|
FileRecord,
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
"""Add classification_rules table for custom document classification rules.
|
||||||
|
|
||||||
|
Revision ID: 039_add_classification_rules
|
||||||
|
Revises: 038_add_api_token_expires_at
|
||||||
|
Create Date: 2026-03-17
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Union
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = "039_add_classification_rules"
|
||||||
|
down_revision: Union[str, None] = "038_add_api_token_expires_at"
|
||||||
|
depends_on: Union[str, None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
"""Create classification_rules table."""
|
||||||
|
op.create_table(
|
||||||
|
"classification_rules",
|
||||||
|
sa.Column("id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("owner_id", sa.String(), nullable=True),
|
||||||
|
sa.Column("name", sa.String(255), nullable=False),
|
||||||
|
sa.Column("category", sa.String(100), nullable=False),
|
||||||
|
sa.Column("rule_type", sa.String(50), nullable=False),
|
||||||
|
sa.Column("pattern", sa.String(1000), nullable=False),
|
||||||
|
sa.Column("priority", sa.Integer(), nullable=False, server_default="0"),
|
||||||
|
sa.Column("case_sensitive", sa.Boolean(), nullable=False, server_default="0"),
|
||||||
|
sa.Column("enabled", sa.Boolean(), nullable=False, server_default="1"),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
sa.UniqueConstraint("owner_id", "name", name="uq_classification_rules_owner_name"),
|
||||||
|
)
|
||||||
|
op.create_index("ix_classification_rules_id", "classification_rules", ["id"])
|
||||||
|
op.create_index("ix_classification_rules_owner_id", "classification_rules", ["owner_id"])
|
||||||
|
op.create_index("ix_classification_rules_category", "classification_rules", ["category"])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
"""Drop classification_rules table."""
|
||||||
|
op.drop_index("ix_classification_rules_category", "classification_rules")
|
||||||
|
op.drop_index("ix_classification_rules_owner_id", "classification_rules")
|
||||||
|
op.drop_index("ix_classification_rules_id", "classification_rules")
|
||||||
|
op.drop_table("classification_rules")
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user