Compare commits

..

675 Commits

Author SHA1 Message Date
Christian Krakau-Louis ffc456ef75 Merge pull request #821 from christianlouis/copilot/sub-pr-816
fix(integrations): harden saved-integration test endpoint & resolve merge conflicts
2026-03-23 17:25:53 +01:00
copilot-swe-agent[bot] 1a195a96bd fix: merge main, address code review feedback for security fix PR #816
- Merge origin/main into branch (resolve conflict in integrations_dashboard.html)
- Add defensive JSON parsing with try/except for integration.config
- Wrap tester() call in try/except to prevent 500 errors from bad config
- Add i18n key integrations.connection_test_failed_fallback in en.json
- Reference i18n key in template JS fallback message
- Update SECURITY_AUDIT.md: add fix date (2026-03-23), update doc date
- Remove accidental revert.sh file
- Fix missing MagicMock/patch imports in test file
- Add tests for invalid JSON config and tester exception error paths

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
Agent-Logs-Url: https://github.com/christianlouis/DocuElevate/sessions/daebb70e-059a-4601-8864-88eef49f99cf
2026-03-23 16:21:09 +00:00
copilot-swe-agent[bot] 41d6f682c0 Initial plan 2026-03-23 16:12:43 +00:00
google-labs-jules[bot] 53961eb2c6 🛡️ Sentinel: [HIGH] Fix Insecure API Endpoint Exposing Integration Credentials
Removed the `/api/integrations/{integration_id}/credentials` endpoint which exposed plaintext credentials.
Created a new `/api/integrations/{integration_id}/test` endpoint to securely perform connection tests server-side.
Updated frontend to use the new secure test endpoint.

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-23 15:13:35 +00:00
github-actions[bot] 2732dafba9 chore(release): update build metadata files [skip ci] 2026-03-15 21:39:28 +00:00
semantic-release 987974b317 0.145.2
Automatically generated by python-semantic-release
2026-03-15 21:39:26 +00:00
Christian Krakau-Louis 237af31f5f Merge pull request #691 from christianlouis/copilot/update-user-login-case-sensitivity 2026-03-15 22:39:04 +01:00
copilot-swe-agent[bot] f58b8943fb fix(auth): case-insensitive login + mobile auth debug logging
Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-15 21:15:19 +00:00
github-actions[bot] 95b8c42244 chore(release): update build metadata files [skip ci] 2026-03-15 21:07:52 +00:00
semantic-release 279ae44e29 0.145.1
Automatically generated by python-semantic-release
2026-03-15 21:07:48 +00:00
Christian Krakau-Louis 4b06e850d5 Merge pull request #690 from christianlouis/copilot/fix-ios-build-error
fix(mobile): suppress Node.js [DEP0169] url.parse() deprecation and document Apple session expiry fix
2026-03-15 22:07:25 +01:00
copilot-swe-agent[bot] dd02ff5677 fix(mobile): suppress Node.js url.parse() deprecation and document Apple session fix
- Add NODE_NO_WARNINGS=1 to all eas.json build profiles (development,
  preview, production) to suppress [DEP0169] url.parse() deprecation
  warnings emitted by EAS CLI when the build image's system Node is 22+
- Add NODE_NO_WARNINGS=1 env to both EAS Cloud Workflow jobs
  (.eas/workflows/create-builds.yml) with explanatory comments
- Fix outdated Node.js prerequisite in docs/MobileApp.md (was "18 or
  later", now "20.19.4 or later" with nvm guidance)
- Add troubleshooting sections in docs/MobileApp.md and mobile/README.md
  covering both the "Session expired Local session" error (Apple ID
  session expiry + App Store Connect API key recommendation) and the
  [DEP0169] Node.js deprecation warning

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-15 21:06:07 +00:00
copilot-swe-agent[bot] 4ee43bb9a4 Initial plan 2026-03-15 21:05:04 +00:00
github-actions[bot] 4de574370c docs(changelog): update changelog [skip ci] 2026-03-15 21:01:26 +00:00
Christian Krakau-Louis 02e74185bd Merge pull request #689 from christianlouis/copilot/fix-webdav-localhost-test
test: fix webdav localhost SSRF assertion to match actual error message
2026-03-15 22:01:03 +01:00
copilot-swe-agent[bot] fe48e2aedc test: fix webdav localhost assertion to match actual error message
Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-15 20:49:52 +00:00
copilot-swe-agent[bot] db115a3c5d Initial plan 2026-03-15 20:46:23 +00:00
github-actions[bot] 8fe91e198e chore(release): update build metadata files [skip ci] 2026-03-15 20:46:03 +00:00
copilot-swe-agent[bot] 800d1051a7 Initial plan 2026-03-15 20:46:01 +00:00
semantic-release cb7bb22780 0.145.0
Automatically generated by python-semantic-release
2026-03-15 20:46:00 +00:00
Christian Krakau-Louis 91ff221ccb Merge pull request #688 from christianlouis/copilot/localize-admin-files-page
feat(i18n): localize admin files page
2026-03-15 21:45:40 +01:00
copilot-swe-agent[bot] 2bb47e7631 feat(i18n): localize admin files page (filemanager.html)
Replace all hardcoded English strings in the admin file manager
template with _() translation calls and add 47 new admin_files.*
keys to en.json.

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-15 18:06:27 +00:00
copilot-swe-agent[bot] ad092b8c15 Initial plan 2026-03-15 17:55:49 +00:00
github-actions[bot] e7621f1ffd chore(release): update build metadata files [skip ci] 2026-03-15 17:55:07 +00:00
semantic-release 5c96c76e89 0.144.0
Automatically generated by python-semantic-release
2026-03-15 17:55:05 +00:00
Christian Krakau-Louis 5fdf5365e7 Merge pull request #687 from christianlouis/copilot/update-readme-and-check-docs
docs: rewrite README.md and update UserGuide/Troubleshooting
2026-03-15 18:54:46 +01:00
Christian Krakau-Louis c326bc4899 Merge pull request #686 from christianlouis/copilot/localize-admin-plans-page
feat(i18n): localize admin plans page (/admin/plans)
2026-03-15 18:54:26 +01:00
copilot-swe-agent[bot] de8922bb8e fix(i18n): fix overage buffer sentence spacing in admin_plans template
Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-15 17:53:13 +00:00
copilot-swe-agent[bot] 36aef73969 feat(i18n): localize admin plans page
Add 108 admin_plans.* translation keys to en.json and update
admin_plans.html to use _() for all static text and window.__i18nAdminPlans
for dynamic Alpine.js / JavaScript strings.

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-15 17:51:10 +00:00
copilot-swe-agent[bot] fcf2452b62 docs: rewrite README.md and update UserGuide and Troubleshooting docs
- Rewrite README.md to reflect current state of the project:
  - Updated Overview with all 12 storage, 7 AI, 6 OCR providers
  - Comprehensive Features section (mobile, CLI, browser ext, pipelines, etc.)
  - Updated Workflow with all ingestion channels and distribution targets
  - Expanded Documentation index with all doc links organized by category
  - Updated Tech Stack table (Meilisearch, MkDocs, Expo, etc.)
  - Added Kubernetes/Helm quick start
  - Added status-view screenshot
  - Updated dependency licenses table
- Updated docs/UserGuide.md with cross-references to Mobile App, CLI,
  Browser Extension, and API docs
- Expanded docs/Troubleshooting.md from 175 to 300+ lines with new
  sections for Search, Pipelines, Mobile App, CLI, Performance, and
  updated all existing sections with current information

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-15 17:51:09 +00:00
github-actions[bot] 87a2311234 chore(release): update build metadata files [skip ci] 2026-03-15 17:46:26 +00:00
semantic-release 178ce70a4a 0.143.1
Automatically generated by python-semantic-release
2026-03-15 17:46:23 +00:00
Christian Krakau-Louis f3c12a6122 Merge pull request #685 from christianlouis/copilot/fix-sso-browser-widget-issue
fix(mobile): SSO callback closes browser and delivers token to app; Expo Go support; SafeAreaView deprecation
2026-03-15 18:46:01 +01:00
copilot-swe-agent[bot] 14285567d5 fix(auth): Expo Go support via Linking.createURL; safe token URL construction; clean up return type annotation
Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-15 17:42:36 +00:00
copilot-swe-agent[bot] 474706da44 Initial plan 2026-03-15 17:41:35 +00:00
github-actions[bot] 6ab9d120c4 chore(release): update build metadata files [skip ci] 2026-03-15 17:40:26 +00:00
copilot-swe-agent[bot] 6e86919336 Initial plan 2026-03-15 17:40:23 +00:00
semantic-release c03ac42bff 0.143.0
Automatically generated by python-semantic-release
2026-03-15 17:40:23 +00:00
Christian Krakau-Louis e173059b3f Merge pull request #684 from christianlouis/copilot/localize-admin-users-page
feat(i18n): localize admin users page
2026-03-15 18:40:03 +01:00
copilot-swe-agent[bot] 5c373e7224 fix(auth): store mobile_redirect_uri in session and redirect to deep-link after SSO; fix SafeAreaView deprecation
Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-15 17:35:49 +00:00
copilot-swe-agent[bot] 3fc0d52126 feat(i18n): localize admin users page
Add 119 new admin_users.* translation keys to en.json and update
admin_users.html to use _() for all static HTML strings, inline
Alpine.js x-text translations, and a window.__i18nAdminUsers block
for JavaScript alert/status messages."

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-15 17:34:47 +00:00
copilot-swe-agent[bot] 64adce7445 Initial plan 2026-03-15 17:24:10 +00:00
github-actions[bot] 854b0f16e3 chore(release): update build metadata files [skip ci] 2026-03-15 17:19:42 +00:00
semantic-release 672e66994a 0.142.0
Automatically generated by python-semantic-release
2026-03-15 17:19:39 +00:00
copilot-swe-agent[bot] ad3d7c0934 Initial plan 2026-03-15 17:19:37 +00:00
Christian Krakau-Louis 80fb2add65 Merge pull request #682 from christianlouis/copilot/localize-terms-and-conditions-page
feat(i18n): localize terms and conditions page
2026-03-15 18:19:18 +01:00
github-actions[bot] 14a18fbc3b chore(release): update build metadata files [skip ci] 2026-03-15 16:44:46 +00:00
semantic-release b4eb27b168 0.141.2
Automatically generated by python-semantic-release
2026-03-15 16:44:44 +00:00
Christian Krakau-Louis 0661c46447 Merge pull request #683 from christianlouis/copilot/fix-expo-device-import-error
fix(mobile): align React version with react-native-renderer to resolve version mismatch crash
2026-03-15 17:44:26 +01:00
copilot-swe-agent[bot] 073627884c fix(mobile): align React version with react-native-renderer to fix version mismatch crash
Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-15 16:28:36 +00:00
copilot-swe-agent[bot] c5b08e778d chore: initial plan placeholder
Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-15 16:26:15 +00:00
copilot-swe-agent[bot] 0232d20e42 Initial plan 2026-03-15 16:21:14 +00:00
github-actions[bot] ebb280f685 chore(release): update build metadata files [skip ci] 2026-03-15 16:19:24 +00:00
semantic-release 3f06c1312d 0.141.1
Automatically generated by python-semantic-release
2026-03-15 16:19:22 +00:00
Christian Krakau-Louis e2acbdc7d3 Merge pull request #679 from christianlouis/copilot/fix-typeerror-expo-start
feat(mobile): bootstrap expo-router app/ directory, WelcomeScreen, and web support
2026-03-15 17:19:03 +01:00
copilot-swe-agent[bot] 8c18f0da17 feat(i18n): localize terms and conditions page
Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-15 16:08:59 +00:00
copilot-swe-agent[bot] 82d90b48e2 fix(mobile): correct asset logo path depth and add expo-router app/ routes for web support
- Fix fatal crash: require('../../../assets/logo.png') in LoginScreen and
  WelcomeScreen resolved 3 levels above mobile/src/screens/ — outside the
  mobile/ directory. Changed to ../../assets/logo.png which correctly
  resolves to the existing mobile/assets/logo.png.
- Add expo-router app/ directory (root cause of missing welcome screen and
  web support): app/_layout.tsx, (auth)/, (tabs)/ with all route files
- Add WelcomeScreen.tsx: branded intro screen with feature highlights
- Update LoginScreen/WelcomeScreen to use useRouter() (expo-router style)
- Add react-native-web ~0.20.0 and react-dom 19.2.4 for web channel
- Add expo-device ~7.0.3 (was imported but missing from package.json)
- Remove android.googleServicesFile from app.json (file is gitignored;
  README documents how to restore it for Android FCM builds)
- Add web.bundler: metro and web.output: single to app.json
- Fix aria-hidden to explicit boolean value in WelcomeScreen

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-15 16:02:02 +00:00
github-actions[bot] 4d81d275a6 chore(release): update build metadata files [skip ci] 2026-03-15 16:01:36 +00:00
semantic-release 1780adcffa 0.141.0
Automatically generated by python-semantic-release
2026-03-15 16:01:34 +00:00
copilot-swe-agent[bot] ed76bdfd9b Initial plan 2026-03-15 16:01:32 +00:00
Christian Krakau-Louis 2860bc9024 Merge pull request #681 from christianlouis/copilot/localize-status-page
feat(ui): localize status page
2026-03-15 17:01:15 +01:00
copilot-swe-agent[bot] e46dadbf45 feat(ui): localize status page with i18n translations
Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-15 15:56:55 +00:00
copilot-swe-agent[bot] a2de72ab72 wip: tracking plan
Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-15 15:54:00 +00:00
copilot-swe-agent[bot] 9d5b1e25e2 wip: plan
Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-15 15:49:32 +00:00
github-actions[bot] 3631cd630c chore(release): update build metadata files [skip ci] 2026-03-15 15:44:48 +00:00
copilot-swe-agent[bot] 19b7eb2ae8 Initial plan 2026-03-15 15:44:46 +00:00
semantic-release ce72626121 0.140.0
Automatically generated by python-semantic-release
2026-03-15 15:44:45 +00:00
Christian Krakau-Louis f08c0d6273 Merge pull request #680 from christianlouis/copilot/localize-attribution-page
feat(i18n): localize attribution page
2026-03-15 16:44:25 +01:00
copilot-swe-agent[bot] 246ef70245 feat(i18n): localize attribution page
Add 13 attribution.* keys to en.json and replace all hardcoded
English strings in attribution.html with _() helper calls.
Also adds aria-hidden to the decorative warning SVG icon.

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-15 15:37:47 +00:00
copilot-swe-agent[bot] b4b50e8340 fix(mobile): update Node.js minimum requirement to 20.19.4
metro-config@0.83.3 uses Array.prototype.toReversed() which was added
in Node.js 20.0.0. The README incorrectly stated "Node.js 18+" causing
users to run `npx expo start` with Node 18 and hit:
  TypeError: configs.toReversed is not a function

- Update README.md to say "Node.js 20.19.4+" with nvm hint
- Update package.json engines from >=20.16.0 to >=20.19.4 to match
  metro-config's exact minimum (as declared in package-lock.json)

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-15 15:34:12 +00:00
github-actions[bot] baf760729e chore(release): update build metadata files [skip ci] 2026-03-15 15:32:07 +00:00
semantic-release 5ae36f6890 0.139.0
Automatically generated by python-semantic-release
2026-03-15 15:32:03 +00:00
copilot-swe-agent[bot] 4d305d166e Initial plan 2026-03-15 15:31:58 +00:00
Christian Krakau-Louis 940fe7c357 Merge pull request #678 from christianlouis/copilot/localize-license-page
feat(ui): localize license page
2026-03-15 16:31:43 +01:00
copilot-swe-agent[bot] 0ea652fa53 Initial plan 2026-03-15 15:31:32 +00:00
copilot-swe-agent[bot] 9c73ab8e59 feat(ui): localize license page with i18n translation keys
Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-15 14:11:42 +00:00
github-actions[bot] a6b81f9d42 chore(release): update build metadata files [skip ci] 2026-03-15 14:05:24 +00:00
semantic-release e46303c2f3 0.138.0
Automatically generated by python-semantic-release
2026-03-15 14:05:21 +00:00
copilot-swe-agent[bot] e72836c027 Initial plan 2026-03-15 14:05:19 +00:00
Christian Krakau-Louis 3f5adadd51 Merge pull request #677 from christianlouis/copilot/localize-cookies-policy-page
feat(i18n): localize cookies policy page
2026-03-15 15:04:55 +01:00
github-actions[bot] eaba8ec6a3 chore(release): update build metadata files [skip ci] 2026-03-15 14:04:34 +00:00
semantic-release 98ec537b68 0.137.1
Automatically generated by python-semantic-release
2026-03-15 14:04:31 +00:00
Christian Krakau-Louis 501319c70b Merge pull request #676 from christianlouis/copilot/fix-ios-app-crash
fix(mobile): iOS TurboModule SIGABRT crash + npm install / EAS build failure
2026-03-15 15:04:12 +01:00
copilot-swe-agent[bot] ca962f8040 fix(mobile): add missing react@19.2.4 integrity hash to package-lock.json
EAS builds run `npm ci` which validates integrity hashes for every package.
The react entry was updated to 19.2.4 but its integrity hash was removed,
causing the EAS iOS build to fail at the Install dependencies phase.

- Runs npm install --package-lock-only to regenerate full lockfile
  (also adds react-dom@19.2.4 entry with its integrity hash)
- Patches node_modules/react entry with canonical integrity hash from
  the npm registry: sha512-9nfp...
- Verified with npm ci --dry-run: 938 packages, exit 0

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-15 14:02:25 +00:00
copilot-swe-agent[bot] 01811438dc chore: initial plan for npm install lockfile fix
Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-15 14:00:03 +00:00
copilot-swe-agent[bot] f983b20a8f fix(mobile): fix iOS TurboModule crash and npm install peer dep conflict
- UploadScreen.tsx: replace deprecated ImagePicker.MediaTypeOptions.Images
  with new array syntax ['images'] — the old enum is not accepted by the
  expo-image-picker v17 TurboModule on iOS, causing SIGABRT on Thread 2
- package.json: bump react 19.1.0 → 19.2.4 so react-dom@19.2.4 (peerOptional
  of @expo/metro-runtime) no longer conflicts; update @types/react to ~19.2.0
- package-lock.json: update react entry to 19.2.4 (integrity removed, will be
  regenerated by npm install on the next EAS build)

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-15 13:53:27 +00:00
copilot-swe-agent[bot] 182868c0d7 feat(i18n): localize cookies policy page
Replace all hardcoded English strings in cookies.html with _() i18n
helper calls and add 35 new cookie_policy.* translation keys to
frontend/translations/en.json. Also improve table accessibility with
aria-label and scope attributes."

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-15 13:52:03 +00:00
github-actions[bot] 7707db6dc6 chore(release): update build metadata files [skip ci] 2026-03-15 13:45:05 +00:00
semantic-release b294f11720 0.137.0
Automatically generated by python-semantic-release
2026-03-15 13:45:03 +00:00
copilot-swe-agent[bot] 13d451dc36 Initial plan 2026-03-15 13:45:00 +00:00
Christian Krakau-Louis 6ac9a55c77 Merge pull request #674 from christianlouis/copilot/localize-imprint-page
feat(ui): localize imprint page
2026-03-15 14:44:45 +01:00
copilot-swe-agent[bot] f188e81e3a Initial plan 2026-03-15 13:44:28 +00:00
github-actions[bot] 8a1dc2aa0e docs(changelog): update changelog [skip ci] 2026-03-15 13:42:26 +00:00
Christian Krakau-Louis b0e1fb5305 Merge pull request #675 from christianlouis/copilot/itms-90725-update-sdk-version
chore(mobile): upgrade iOS build image to Xcode 26 for iOS 26 SDK compliance
2026-03-15 14:42:06 +01:00
copilot-swe-agent[bot] 69664ec6cd chore(mobile): update iOS build image to Xcode 26 for iOS 26 SDK compliance
Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-15 11:57:30 +00:00
copilot-swe-agent[bot] 552c4ea819 Initial plan 2026-03-15 11:55:20 +00:00
copilot-swe-agent[bot] 1f6a3a5803 feat(ui): localize imprint page
Add 25 imprint.* translation keys to en.json and update imprint.html
to use _() helpers for all user-visible strings. Static business data
(company name, address, contact details, VAT number) remain hardcoded
as proper nouns that must not be altered by translation.

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-15 10:23:33 +00:00
github-actions[bot] 02c399239c docs(changelog): update changelog [skip ci] 2026-03-15 10:23:25 +00:00
Christian Krakau-Louis d2ec99e7d5 Update eas.json 2026-03-15 11:23:07 +01:00
copilot-swe-agent[bot] 23d952235e Initial plan 2026-03-15 10:17:31 +00:00
github-actions[bot] 8f00f5ddee chore(release): update build metadata files [skip ci] 2026-03-15 10:10:52 +00:00
semantic-release 67a3f96129 0.136.1
Automatically generated by python-semantic-release
2026-03-15 10:10:50 +00:00
Christian Krakau-Louis d352324f58 Merge pull request #672 from christianlouis/copilot/debug-build-archive-error
fix(mobile): resolve iOS build failures caused by Expo SDK 54 precompiled XCFrameworks
2026-03-15 11:10:31 +01:00
Christian Krakau-Louis 9c791c4573 Merge pull request #673 from christianlouis/sentinel-fix-webdav-ssrf-2142784158541650346
🛡️ Sentinel: [HIGH] Fix SSRF in WebDAV connection test
2026-03-15 11:10:07 +01:00
github-actions[bot] b7a195d3bc style: apply ruff auto-fix
- Auto-formatted code with ruff format
- Applied ruff linting fixes with --fix

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-03-15 04:10:28 +00:00
google-labs-jules[bot] 8079db7893 🛡️ Sentinel: [HIGH] Fix SSRF in WebDAV connection test
Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-15 04:10:11 +00:00
copilot-swe-agent[bot] 3372a93f71 fix(mobile): resolve iOS build errors by enabling buildReactNativeFromSource for Expo SDK 54
Expo SDK 54 switched to precompiled React Native XCFrameworks by default
for faster iOS builds. However, the precompiled frameworks do not expose
legacy bridge headers (RCTBridge, RCTViewManager, RCTSurfaceHostingProxyRootView,
RCTPackagerConnection, RCTDevSettings.isDebuggingRemotely, rootViewFactory)
that some native modules (e.g. expo-dev-client) still reference.

Add expo-build-properties (v1.0.10, the SDK 54-compatible version) and
configure buildReactNativeFromSource: true for iOS. This compiles React
Native from source, making all native headers available to linked modules
and resolving the Xcode compilation errors seen in the EAS production build.

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-14 22:50:29 +00:00
github-actions[bot] 7cd6252bf7 chore(release): update build metadata files [skip ci] 2026-03-14 22:42:14 +00:00
semantic-release 5d8a6e26bb 0.136.0
Automatically generated by python-semantic-release
2026-03-14 22:42:11 +00:00
Christian Krakau-Louis d60c3cd303 Merge pull request #671 from christianlouis/copilot/update-logos-and-ux
[WIP] Update mobile app to use DocuElevate's original UX and logos
2026-03-14 23:41:52 +01:00
copilot-swe-agent[bot] 971e578dd7 feat(mobile): update DocuElevate mobile app branding with original logo and UX
- Regenerate all app icons (icon.png, adaptive-icon.png, splash.png,
  favicon.png, notification-icon.png) using the DocuElevate folder+gear
  SVG logo on brand blue (#1e40af) background
- Add assets/logo.png (200×200 circular logo) for the login screen
- Update LoginScreen.tsx to display the DocuElevate logo image above
  the brand name text instead of plain text only
- Replace emoji tab bar icons (⬆️📄👤) with Ionicons vector icons
  (cloud-upload-outline, document-text-outline, person-circle-outline)
  from the already-installed @expo/vector-icons package

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-14 22:41:02 +00:00
github-actions[bot] 0c9938155b chore(release): update build metadata files [skip ci] 2026-03-14 22:39:52 +00:00
semantic-release f3eedce835 0.135.4
Automatically generated by python-semantic-release
2026-03-14 22:39:49 +00:00
Christian Krakau-Louis 833af6f161 Merge pull request #670 from christianlouis/copilot/fix-dependency-version-mismatches
fix(mobile): align Expo SDK 54 dependency versions to pass expo doctor
2026-03-14 23:39:28 +01:00
copilot-swe-agent[bot] 911a995c6a Initial plan 2026-03-14 22:35:55 +00:00
copilot-swe-agent[bot] 15f4069592 Initial plan 2026-03-14 22:33:24 +00:00
copilot-swe-agent[bot] f6e955acce fix(mobile): fix expo doctor dependency version mismatches for SDK 54
Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-14 20:45:03 +00:00
copilot-swe-agent[bot] 9bff4e0d00 Initial plan 2026-03-14 20:40:41 +00:00
github-actions[bot] 256d97136e chore(release): update build metadata files [skip ci] 2026-03-14 20:37:20 +00:00
semantic-release c336bf1eb1 0.135.3
Automatically generated by python-semantic-release
2026-03-14 20:37:18 +00:00
Christian Krakau-Louis c5db5c94e2 Merge pull request #669 from christianlouis/copilot/update-ios-production-build
fix(mobile): update invalid EAS iOS build image to macos-sequoia-15.3-xcode-16.2
2026-03-14 21:36:57 +01:00
copilot-swe-agent[bot] fe66e51996 fix(mobile): update iOS build image from macos-sequoia-15.1-xcode-16.2 to macos-sequoia-15.3-xcode-16.2
Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-14 20:35:50 +00:00
copilot-swe-agent[bot] 2aac612f10 Initial plan 2026-03-14 20:34:53 +00:00
github-actions[bot] defea2eadd chore(release): update build metadata files [skip ci] 2026-03-14 20:32:44 +00:00
semantic-release 24949fd05d 0.135.2
Automatically generated by python-semantic-release
2026-03-14 20:32:41 +00:00
Christian Krakau-Louis b1d6188b5e Merge pull request #668 from christianlouis/copilot/fix-build-failure-expo-modules
fix(mobile): sync app.json and eas.json to expo.dev project credentials
2026-03-14 21:32:04 +01:00
copilot-swe-agent[bot] 5f3d134d7e fix(mobile): sync app.json and eas.json to expo.dev project credentials
Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-14 20:31:35 +00:00
copilot-swe-agent[bot] 7633cdf4e4 fix(mobile): sync app.json slug and owner to expo.dev project
- slug: docuelevate → docuelevate-mobile
- owner: christian-krakau-louis → christianlouis
- projectId 16925679-cb94-411c-83b5-a62c9addb872 already correct

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-14 20:28:52 +00:00
copilot-swe-agent[bot] 4f38075de4 fix(mobile): use org.docuelevate.mobile as app identifier
Update iOS bundleIdentifier and Android package from
com.christianlouis.docuelevatemobile to org.docuelevate.mobile.

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-14 20:26:51 +00:00
copilot-swe-agent[bot] 1aa02aea24 fix(mobile): add missing assets and EAS workflow for automatic builds
- Add mobile/assets/ directory with all required Expo/EAS asset files:
  - icon.png (1024x1024 branded app icon)
  - adaptive-icon.png (1024x1024 transparent fg for Android)
  - splash.png (1284x2778 branded splash screen)
  - favicon.png (48x48 web favicon)
  - notification-icon.png (96x96 white-on-transparent for Android)
  - notification-sound.wav (0.1s silent WAV, 44100 Hz mono)
- Add mobile/.eas/workflows/create-builds.yml for automatic production
  builds triggered on every push to main (per Expo EAS Workflows docs)

The Xcode image pin (macos-sequoia-15.1-xcode-16.2) was already present
in eas.json from the prior fix resolving the XCode 15.4 vs >=16.1 error.

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-14 20:21:44 +00:00
copilot-swe-agent[bot] 958c5950b3 Initial plan 2026-03-14 20:13:04 +00:00
github-actions[bot] 97263cd81a chore(release): update build metadata files [skip ci] 2026-03-14 16:30:33 +00:00
semantic-release 7c7a80ee32 0.135.1
Automatically generated by python-semantic-release
2026-03-14 16:30:30 +00:00
Christian Krakau-Louis c9cee854c3 Merge pull request #667 from christianlouis/copilot/fix-missing-project-id
fix(mobile): resolve EAS CI build failures — app.json wrapper, expo-dev-client upgrade, Xcode image pin
2026-03-14 17:30:07 +01:00
copilot-swe-agent[bot] 3bf9589462 fix(mobile): resolve EAS CI build failures – wrap app.json, update deps, pin Xcode image
Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-14 15:02:08 +00:00
copilot-swe-agent[bot] 0b45fa6130 Initial plan 2026-03-14 14:55:50 +00:00
github-actions[bot] 209f78d740 chore(release): update build metadata files [skip ci] 2026-03-14 14:55:44 +00:00
semantic-release 84663d53e3 0.135.0
Automatically generated by python-semantic-release
2026-03-14 14:55:41 +00:00
Christian Krakau-Louis 0c070bffd8 Merge pull request #657 from christianlouis/copilot/translate-privacy-page
feat(ui): i18n support for the privacy page
2026-03-14 15:55:20 +01:00
copilot-swe-agent[bot] e1941adfc9 feat(ui): add i18n support to privacy page
Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-14 14:49:41 +00:00
github-actions[bot] 6e6f9d83a9 chore(release): update build metadata files [skip ci] 2026-03-14 14:40:22 +00:00
semantic-release 63dcf4a3f6 0.134.5
Automatically generated by python-semantic-release
2026-03-14 14:40:19 +00:00
Christian Krakau-Louis 1f267892f1 Merge pull request #656 from christianlouis/copilot/fix-npm-dependency-conflict
fix(mobile): resolve npm ERESOLVE by bumping react to 19.2.4 and locking deps
2026-03-14 15:39:56 +01:00
copilot-swe-agent[bot] c960d05acc Initial plan 2026-03-14 14:35:46 +00:00
copilot-swe-agent[bot] 8653247e44 fix(mobile): update react to 19.2.4 and add package-lock.json to fix npm ERESOLVE build failure
Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-14 14:32:18 +00:00
copilot-swe-agent[bot] 0cb2f6c362 chore: initial plan for mobile npm peer dep fix
Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-14 14:30:38 +00:00
copilot-swe-agent[bot] e3e93a0487 Initial plan 2026-03-14 14:21:09 +00:00
github-actions[bot] ef4c513908 docs(changelog): update changelog [skip ci] 2026-03-14 14:17:07 +00:00
Christian Krakau-Louis c6642203c0 chore(i18n): auto-update translations via LLM 2026-03-14 14:15:13 +00:00
Christian Krakau-Louis 31218c8860 chore(i18n): auto-update translations via LLM 2026-03-14 14:15:13 +00:00
github-actions[bot] 2cfacb24f0 chore(release): update build metadata files [skip ci] 2026-03-14 14:14:18 +00:00
semantic-release df19da1379 0.134.4
Automatically generated by python-semantic-release
2026-03-14 14:14:15 +00:00
Christian Krakau-Louis 2a84747cd1 Merge pull request #655 from christianlouis/copilot/fix-npm-dependency-conflict
fix(mobile): bump Node to 20.19.4 and fix @react-navigation/native peer dep
2026-03-14 15:13:57 +01:00
copilot-swe-agent[bot] 4c6596b561 fix(mobile): bump Node to 20.19.4 and fix react-navigation peer dependency
Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-14 14:06:52 +00:00
copilot-swe-agent[bot] 5380becac2 Initial plan 2026-03-14 14:04:50 +00:00
github-actions[bot] caf90f8383 chore(release): update build metadata files [skip ci] 2026-03-14 14:00:13 +00:00
semantic-release 4a2d0d8c79 0.134.3
Automatically generated by python-semantic-release
2026-03-14 14:00:10 +00:00
Christian Krakau-Louis eeaf61cc41 Merge pull request #653 from christianlouis/copilot/install-expo-dev-client-manually
fix(mobile): resolve EAS build failures for iOS development and production
2026-03-14 14:59:51 +01:00
copilot-swe-agent[bot] 492b2c643c fix(mobile): add owner field to app.json to match EAS project owner
EAS was failing because the projectId belongs to 'christian-krakau-louis'
but the 'owner' field was not set, causing a mismatch with the logged-in
user. Adding 'owner': 'christian-krakau-louis' resolves the error.

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-14 13:56:57 +00:00
copilot-swe-agent[bot] 6834f3ef1b fix(mobile): add expo-dev-client, pin Node version, and fix EAS build config
- Add expo-dev-client ~5.0.0 to dependencies (required for developmentClient:true profile)
- Add node: 18.19.1 to all EAS build profiles (matches .nvmrc, silences version warning)
- Add appVersionSource: local to cli section in eas.json (silences future-required warning)
- Add ITSAppUsesNonExemptEncryption: false to ios.infoPlist in app.json (eliminates App Store Connect manual config warning)

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-14 13:51:50 +00:00
copilot-swe-agent[bot] 5ff1413adf Initial plan 2026-03-14 13:48:50 +00:00
github-actions[bot] 73c3cb36e8 chore(release): update build metadata files [skip ci] 2026-03-14 13:42:46 +00:00
semantic-release 63316249cf 0.134.2
Automatically generated by python-semantic-release
2026-03-14 13:42:43 +00:00
Christian Krakau-Louis 5e088995da Merge pull request #652 from christianlouis/copilot/fix-expo-sdk-version-mismatch
fix(mobile): upgrade Expo SDK 51 → 54 and set real EAS project ID
2026-03-14 14:42:21 +01:00
copilot-swe-agent[bot] f148785079 fix(mobile): upgrade Expo SDK from 51 to 54 and fix EAS project ID
- Update mobile/app.json: replace YOUR_EAS_PROJECT_ID placeholder with
  the real EAS project ID (8455f39d-ef0d-4850-98e4-f79e388642c1)
- Update mobile/package.json: upgrade all dependencies to Expo SDK 54
  compatible versions (react 19.1.0, react-native 0.81.5, expo ~54.0.0,
  all expo-* packages, react-navigation v6 → v7, @types/react ~19.1.10)
- Update mobile/README.md: remove outdated eas init step, document that
  the EAS project ID is already configured and explain when to update it

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-14 13:28:01 +00:00
copilot-swe-agent[bot] 76ce7dc834 Initial plan 2026-03-14 13:21:42 +00:00
github-actions[bot] 90be14b249 chore(release): update build metadata files [skip ci] 2026-03-14 13:15:25 +00:00
semantic-release 50c046136d 0.134.1
Automatically generated by python-semantic-release
2026-03-14 13:15:22 +00:00
Christian Krakau-Louis c3d1e0161e Merge pull request #651 from christianlouis/copilot/fix-incompatible-node-version
fix(mobile): remove expo-sharing from plugins, align .nvmrc to Node 18 LTS
2026-03-14 14:15:04 +01:00
copilot-swe-agent[bot] 8f9405b5ac fix(mobile): remove expo-sharing from plugins and align .nvmrc with Node 18 LTS
Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-14 13:11:58 +00:00
copilot-swe-agent[bot] fb5d494e42 fix(mobile): relax Node.js engine constraint to >=18.0.0
Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-14 13:05:02 +00:00
copilot-swe-agent[bot] 0b20753225 Initial plan 2026-03-14 13:03:30 +00:00
github-actions[bot] 813820443e chore(release): update build metadata files [skip ci] 2026-03-14 12:49:18 +00:00
semantic-release 35f3d7e712 0.134.0
Automatically generated by python-semantic-release
2026-03-14 12:49:16 +00:00
Christian Krakau-Louis d9b8ef7725 Merge pull request #649 from christianlouis/copilot/update-i18n-en-json
feat(i18n): extract UI text from integrations, profile, and subscription pages; internationalize user menu
2026-03-14 13:48:57 +01:00
copilot-swe-agent[bot] eaa533760d fix(i18n): replace fragment translation keys with placeholder-based complete sentences
- Use {gravatar} placeholder in profile.avatar_upload_hint for proper i18n
- Use {settings_link} placeholder in subscription.single_user_body
- Use {api_tokens_link} placeholder in integrations.webhook_step1
- Add optional chaining in common.js for window.__i18n safety

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-14 12:45:47 +00:00
copilot-swe-agent[bot] 08853e823c fix(ui): restore missing template x-if tag and fix Nextcloud URL label key
- Restore missing '<template x-if="quota.max_sources !== null && quota.max_sources > 0">' wrapping the sources mini usage bar
- Add dedicated 'integrations.nextcloud_url_label' key instead of misusing 'integrations.nextcloud_settings' as a prefix

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-14 12:32:24 +00:00
github-actions[bot] 4eb1e0515a chore(release): update build metadata files [skip ci] 2026-03-14 12:31:51 +00:00
semantic-release 025c2382d8 0.133.7
Automatically generated by python-semantic-release
2026-03-14 12:31:48 +00:00
Christian Krakau-Louis 06109fd14e Merge pull request #650 from christianlouis/copilot/update-glob-dependencies
fix(mobile): resolve EAS build failure due to Node.js engine incompatibility
2026-03-14 13:31:24 +01:00
copilot-swe-agent[bot] 5a025576c4 feat(ui): internationalize profile, subscription, integrations templates and user menu JS
- Add 155 new translation keys to en.json (common, nav, integrations, profile, subscription sections)
- Add window.__i18n script block to base.html for user menu strings
- Replace all hardcoded English strings in common.js with window.__i18n lookups using safe fallback pattern
- Internationalize profile.html (page title, headings, labels, hints, placeholders, theme options)
- Internationalize subscription.html (page title, plan cards, usage stats, plan actions, badges)
- Internationalize integrations_dashboard.html (header, quota bars, modal, all form fields, action buttons, webhook section, delete modal)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-14 12:30:27 +00:00
copilot-swe-agent[bot] 8717538967 fix(mobile): fix EAS build Node.js version incompatibility for iOS and Android
Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-14 12:28:06 +00:00
copilot-swe-agent[bot] d72fcd463f Initial plan 2026-03-14 12:23:34 +00:00
copilot-swe-agent[bot] 36172160ac Initial plan 2026-03-14 12:08:38 +00:00
github-actions[bot] 55b9da50d4 chore(release): update build metadata files [skip ci] 2026-03-14 11:45:44 +00:00
semantic-release cf6c502555 0.133.6
Automatically generated by python-semantic-release
2026-03-14 11:45:41 +00:00
Christian Krakau-Louis 996deb76ff Merge pull request #638 from christianlouis/security-fix-sql-injection-index-mgmt-16594348298290829845
🔒 [security fix] Fix SQL injection in index management queries
2026-03-14 12:45:01 +01:00
Christian Krakau-Louis 51cf0995f7 Merge pull request #637 from christianlouis/test-improvement-api-tokens-revoke-error-paths-8200211521533028876
🧪 [testing improvement] Add error path tests for API token revocation
2026-03-14 12:44:44 +01:00
Christian Krakau-Louis d5fb0da335 Merge pull request #639 from christianlouis/refactor-audit-logs-annotated-b008-6104828608171783200
🧹 Use Annotated pattern in audit_logs.py to resolve Ruff B008
2026-03-14 12:44:26 +01:00
Christian Krakau-Louis 777910b653 Merge pull request #640 from christianlouis/refactor-filename-regex-constant-13933144971632372772
Refactor filename regex to shared constant
2026-03-14 12:44:11 +01:00
Christian Krakau-Louis ee95173de0 Potential fix for pull request finding
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-03-14 12:43:59 +01:00
github-actions[bot] e1ad8eae8c chore(release): update build metadata files [skip ci] 2026-03-14 11:24:54 +00:00
semantic-release 12bea9a861 0.133.5
Automatically generated by python-semantic-release
2026-03-14 11:24:51 +00:00
Christian Krakau-Louis 8ce459fc23 Merge pull request #647 from christianlouis/copilot/add-missing-help-page-texts
fix(ui): add 22 missing help page translation keys to en.json
2026-03-14 12:24:31 +01:00
copilot-swe-agent[bot] 48a93036ca fix(ui): add 22 missing help page translation keys to en.json
Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-14 11:16:06 +00:00
copilot-swe-agent[bot] 4b60e0bd48 Initial plan 2026-03-14 11:09:51 +00:00
github-actions[bot] faecaad306 chore(release): update build metadata files [skip ci] 2026-03-14 10:36:09 +00:00
semantic-release 662de20e89 0.133.4
Automatically generated by python-semantic-release
2026-03-14 10:36:05 +00:00
Christian Krakau-Louis 8487dcd13d Merge pull request #645 from christianlouis/copilot/fix-language-selection-bug
fix(i18n): persist language preference server-side, fix dropdown rendering and avatar 404
2026-03-14 11:35:43 +01:00
copilot-swe-agent[bot] f41ecc9b2c fix(i18n): add noqa comment to intentionally broad exception in hydration helper
Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-14 10:34:31 +00:00
copilot-swe-agent[bot] 1416d901d0 fix(i18n): persist language preference server-side, fix dropdown rendering and avatar 404
- Fix language dropdown in base.html by moving tojson data out of HTML
  attribute into a script tag (prevents quote conflicts breaking Alpine.js)
- Fix avatar placeholder 404 by correcting filename reference from
  avatar-placeholder.svg to default-avatar.svg
- Add session hydration from DB in _inject_global_context() so
  detect_language() uses the stored preference on every request
- Sync session and cookie in PATCH /api/profile when language changes
- Reload page after language change in profile to reflect new locale
- Add tests for session/cookie sync and DB hydration

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-14 10:30:50 +00:00
github-actions[bot] 01739e8c22 docs(changelog): update changelog [skip ci] 2026-03-14 10:25:20 +00:00
Christian Krakau-Louis d09227b61c chore(i18n): auto-update translations via LLM 2026-03-14 10:24:41 +00:00
github-actions[bot] 3da3953a4b chore(release): update build metadata files [skip ci] 2026-03-14 10:19:51 +00:00
semantic-release cb7693c428 0.133.3
Automatically generated by python-semantic-release
2026-03-14 10:19:48 +00:00
Christian Krakau-Louis a8174371f5 Merge pull request #646 from christianlouis/copilot/add-missing-translations-keys
fix(i18n): add missing index page translation keys to en.json
2026-03-14 11:19:28 +01:00
copilot-swe-agent[bot] 1f24b1b95b fix(i18n): add missing index page translation keys to en.json
Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-14 10:14:43 +00:00
copilot-swe-agent[bot] ae8bd0ca47 Initial plan 2026-03-14 10:10:53 +00:00
Christian Krakau-Louis 91855b0763 Merge pull request #643 from christianlouis/copilot/sub-pr-637
test: exercise real auth path in test_revoke_token_unauthenticated
2026-03-14 11:03:55 +01:00
Christian Krakau-Louis 9ded099a82 Merge pull request #644 from christianlouis/copilot/sub-pr-637-again
test: strengthen rollback assertion in test_revoke_token_database_error
2026-03-14 11:03:31 +01:00
copilot-swe-agent[bot] c3e389d957 Initial plan 2026-03-14 10:02:30 +00:00
copilot-swe-agent[bot] 9e9b1fb158 test: strengthen test_revoke_token_database_error to verify rollback is called
Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-14 09:58:46 +00:00
Christian Krakau-Louis db446e9ba3 Merge pull request #642 from christianlouis/copilot/sub-pr-638
test(database): add regression test for quoted-identifier index drop
2026-03-14 10:57:44 +01:00
copilot-swe-agent[bot] 751b16d804 test: clarify caller cleanup responsibility in _make_unauthenticated_client docstring
Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-14 09:55:33 +00:00
copilot-swe-agent[bot] 33eded02b1 test(database): add unit test for quoted identifier in filehash index drop
Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-14 09:54:28 +00:00
copilot-swe-agent[bot] 96ef7d4769 test: exercise real auth path in test_revoke_token_unauthenticated
Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-14 09:54:20 +00:00
copilot-swe-agent[bot] 3f8841a589 Initial plan 2026-03-14 09:49:49 +00:00
copilot-swe-agent[bot] 8656434f51 Initial plan 2026-03-14 09:49:43 +00:00
copilot-swe-agent[bot] 12bf5d9724 Initial plan 2026-03-14 09:48:43 +00:00
github-actions[bot] 9b944d4a87 chore(release): update build metadata files [skip ci] 2026-03-14 09:48:20 +00:00
semantic-release 10edd6c6a5 0.133.2
Automatically generated by python-semantic-release
2026-03-14 09:48:17 +00:00
Christian Krakau-Louis 0ef7bf23d7 Merge pull request #636 from christianlouis/copilot/fix-json-serialization-errors
fix(ui): add missing `suggested_languages` global to error templates
2026-03-14 10:47:57 +01:00
github-actions[bot] de0b674025 style: apply ruff auto-fix
- Auto-formatted code with ruff format
- Applied ruff linting fixes with --fix

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-03-14 09:45:05 +00:00
Christian Krakau-Louis b148675ba8 Potential fix for pull request finding
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-03-14 10:44:14 +01:00
google-labs-jules[bot] 917b282614 Refactor: extract filename regex to shared constant
Move the valid filename regex pattern to a shared constant in app/utils/filename_utils.py and update both the task logic and security tests to use it. This eliminates duplication and ensures consistency across the codebase. Also normalized line endings in app/tasks/extract_metadata_with_gpt.py.

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-14 09:42:40 +00:00
google-labs-jules[bot] f96fcbe6c9 style: use Annotated pattern in audit_logs.py to resolve Ruff B008
Refactor `app/api/audit_logs.py` to use the `Annotated` type hint pattern for FastAPI dependencies (`Depends`) and query parameters (`Query`).

- Resolves B008: Function-call in default argument.
- Improves code maintainability and readability by following modern FastAPI best practices.
- Maintains consistency with other modules in the codebase (e.g., `files.py`, `integrations.py`).
- No changes to API runtime behavior.

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-14 09:42:23 +00:00
github-actions[bot] 0b064b9d20 style: apply ruff auto-fix
- Auto-formatted code with ruff format
- Applied ruff linting fixes with --fix

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-03-14 09:42:23 +00:00
google-labs-jules[bot] 120002b394 fix(database): quote identifiers in index management queries to prevent SQL injection
Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-14 09:42:04 +00:00
google-labs-jules[bot] fdf5053ace test: add error path tests for API token revocation
- Enhanced existing tests for 400 (already revoked) and 404 (not found)
- Added test_revoke_token_unauthenticated (401)
- Added test_revoke_token_database_error (500 + rollback check)
- Added test_revoke_token_invalid_id_format (422)

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-14 09:41:38 +00:00
copilot-swe-agent[bot] 0592c59174 fix(ui): add missing suggested_languages global to error templates
The error templates (404.html, 500.html) extend base.html which uses
{{ suggested_languages | tojson }} in the language selector dropdown.
The _error_templates instance in app/main.py was missing this global,
causing Jinja2 Undefined objects to be passed to the tojson filter,
resulting in "TypeError: Object of type Undefined is not JSON serializable"
errors in 35 tests.

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-14 09:07:17 +00:00
copilot-swe-agent[bot] ac5495134b Initial plan 2026-03-14 08:59:57 +00:00
github-actions[bot] 54585e028e chore(release): update build metadata files [skip ci] 2026-03-14 08:51:33 +00:00
semantic-release 4cf240d478 0.133.1
Automatically generated by python-semantic-release
2026-03-14 08:51:30 +00:00
Christian Krakau-Louis 6c8bb4fec8 Merge pull request #635 from christianlouis/copilot/remove-translation-content-tests
fix(test): remove tests that assert specific translation content
2026-03-14 09:50:58 +01:00
copilot-swe-agent[bot] 08fabe154a fix(test): remove tests that check specific translation content
Remove three tests that asserted exact translated strings for German,
French, and Chinese locales. These tests broke whenever translation
files were updated externally.

Replace content-checking tests with behavioral assertions:
- Translated values are non-empty strings (not the raw key)
- Fallback and None-locale return the English translation
- Placeholder interpolation injects the kwarg value

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-14 08:47:56 +00:00
copilot-swe-agent[bot] f0f9165958 Initial plan 2026-03-14 08:43:40 +00:00
github-actions[bot] 971d450128 docs(changelog): update changelog [skip ci] 2026-03-14 08:38:50 +00:00
Christian Krakau-Louis 0cb6017b50 chore(i18n): auto-update translations via LLM 2026-03-14 08:38:11 +00:00
github-actions[bot] 3e68634681 chore(release): update build metadata files [skip ci] 2026-03-14 08:35:03 +00:00
semantic-release 57f204ece9 0.133.0
Automatically generated by python-semantic-release
2026-03-14 08:35:00 +00:00
Christian Krakau-Louis 64d3f27afe Merge pull request #633 from christianlouis/copilot/enhance-language-selector-menu
feat(ui): smart compact language selector with flags and search
2026-03-14 09:34:42 +01:00
github-actions[bot] bc34ed0104 chore(release): update build metadata files [skip ci] 2026-03-14 08:33:58 +00:00
semantic-release c51ad019d2 0.132.1
Automatically generated by python-semantic-release
2026-03-14 08:33:55 +00:00
Christian Krakau-Louis 74eaf6fcef Merge pull request #631 from christianlouis/copilot/remove-translation-test
test(i18n): stop asserting specific translation content in kwargs interpolation test
2026-03-14 09:33:33 +01:00
Christian Krakau-Louis d97fda4840 Merge pull request #634 from christianlouis/sentinel-fix-sql-injection-db-migrate-454366986287082539
🛡️ Sentinel: [SECURITY] Fix potential SQL injection in db_migrate
2026-03-14 09:33:13 +01:00
Christian Krakau-Louis 3bfa5ad266 Resolve translations: keep main for all except en.json 2026-03-14 08:32:00 +00:00
github-actions[bot] 4a46f372b9 docs(changelog): update changelog [skip ci] 2026-03-14 08:27:44 +00:00
Christian Krakau-Louis 8e865180a5 chore(i18n): auto-update translations via LLM 2026-03-14 08:24:11 +00:00
google-labs-jules[bot] 82b1f89332 fix(security): prevent potential SQL injection in database migration
Replaced manual double-quoting of table names with SQLAlchemy's dialect-specific
identifier preparer in `app/utils/db_migrate.py`. This ensures proper quoting
for any database dialect and acts as a defense-in-depth measure against
SQL injection or syntax errors if a table name contains unexpected characters.

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-14 03:39:53 +00:00
Christian Krakau-Louis 9a480e3d68 chore(i18n): auto-update translations via LLM 2026-03-14 01:34:50 +00:00
copilot-swe-agent[bot] 3557590679 feat(ui): smart compact language selector with search and flags
- Add get_suggested_languages() to i18n.py returning ≤6 ranked
  suggestions (current locale → Accept-Language header → popular
  language fallbacks); refactor _parse_accept_language to share
  a common _parse_accept_language_entries() helper
- Inject suggested_languages into every template context (base.py)
- Redesign nav-bar language dropdown (base.html): button shows
  current-language flag emoji; dropdown lists 5-7 suggestions with
  flags and native names; Alpine.js search input filters all 77
  languages live; footer shows count and Search shortcut
- Add language.search_placeholder and language.no_results keys to
  all 77 translation JSON files (en values; external script
  propagates translations to other locales)
- Remove test_all_languages_have_same_keys (external sync script
  owns key completeness); add TestGetSuggestedLanguages (7 unit
  tests); update test_language_selector_in_nav for new HTML
- Update InternationalizationGuide.md: single-step en.json-only
  workflow for adding new translation keys
- Update .github/copilot-instructions.md: add i18n/l10n section
  documenting the en.json-only rule for future agents

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-13 21:31:16 +00:00
Christian Krakau-Louis f4515aaaca added bg translation 2026-03-13 21:27:13 +00:00
copilot-swe-agent[bot] d27140f809 Initial plan 2026-03-13 21:20:48 +00:00
github-actions[bot] 11c816c145 chore(release): update build metadata files [skip ci] 2026-03-13 18:56:19 +00:00
semantic-release 874dc0cee3 0.132.0
Automatically generated by python-semantic-release
2026-03-13 18:56:16 +00:00
Christian Krakau-Louis ee82d0f5a4 Merge pull request #632 from christianlouis/copilot/update-language-support
feat(i18n): expand language support from 49 to 77 languages
2026-03-13 19:55:55 +01:00
copilot-swe-agent[bot] 59d851272d feat(i18n): update language support to 77 languages
- Remove Russian (ru) per requirement
- Keep Esperanto (eo) per user feedback
- Add 29 new languages: am, az, bn, gu, ha, hi, hy, id, ig, jv, ka,
  kk, km, ml, mn, mr, ms, ne, si, sw, ta, te, th, tl, ur, uz, yo,
  zh-TW, zu
- Create 29 new translation JSON files seeded from en.json
- Remove frontend/translations/ru.json
- Update SUPPORTED_LANGUAGES list and docstring (49 → 77)
- Update tests: count 49→77, code set updated
- Update docs/InternationalizationGuide.md: count and table

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-13 18:36:07 +00:00
copilot-swe-agent[bot] 1563073bd4 Initial plan 2026-03-13 18:27:04 +00:00
copilot-swe-agent[bot] 9b32cbc91b test(i18n): remove translation content assertion from test_translate_with_kwargs_german
Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-13 16:47:48 +00:00
copilot-swe-agent[bot] 6cae9e425f Initial plan 2026-03-13 16:46:26 +00:00
github-actions[bot] 031b51b9b8 chore(release): update build metadata files [skip ci] 2026-03-13 14:54:29 +00:00
semantic-release ed75fc9da1 0.131.0
Automatically generated by python-semantic-release
2026-03-13 14:54:26 +00:00
Christian Krakau-Louis a47db95129 Merge pull request #629 from christianlouis/copilot/update-translation-file-indexing
feat(i18n): index all 49 translation files and add flags/locale formats
2026-03-13 15:54:02 +01:00
copilot-swe-agent[bot] ce4e797bd1 docs(i18n): add comment on Welsh subdivision flag Unicode representation
Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-13 14:07:07 +00:00
copilot-swe-agent[bot] 87d85624be feat(i18n): expand supported languages from 31 to 49 with flags and locale formats
Add 18 previously missing language entries to SUPPORTED_LANGUAGES in
app/utils/i18n.py so every translation JSON file in frontend/translations/
is properly indexed and served by the language selector:

New languages: af, ar, cy, eo, fa, fy, gl, he, ja, kn, ko, li, nds, no, pa, sr, vi, vls

Each new language entry includes:
- Correct ISO 639-1/639-3 code matching its JSON filename
- Native name and display name
- Appropriate country/language flag emoji
- Locale-specific date and number formatting rules in _LOCALE_FORMATS

Also: rename nb "Norwegian" → "Norwegian Bokmål" to distinguish it from no "Norwegian".

Update tests/test_i18n.py:
- Count assertions: 31 → 49
- Expected code set expanded to all 49 codes
- Fix test_unsupported_language_fallback (ja/ko now supported, use xx/yy)

Update docs/InternationalizationGuide.md:
- Language count: 10 → 49
- Full language table with flags, native names, tiers
- Complete file structure listing all 49 JSON files

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-13 14:03:31 +00:00
copilot-swe-agent[bot] 34c5d9e55e Initial plan 2026-03-13 13:55:46 +00:00
github-actions[bot] bd35cdd268 docs(changelog): update changelog [skip ci] 2026-03-13 13:44:34 +00:00
Christian Krakau-Louis b2d4560039 Merge pull request #628 from christianlouis/copilot/update-icelandic-translation
True-up Icelandic translation (is.json) with en.json
2026-03-13 14:43:50 +01:00
github-actions[bot] bb71f0aab8 docs(changelog): update changelog [skip ci] 2026-03-13 13:43:44 +00:00
Christian Krakau-Louis e85acd590d Merge pull request #622 from christianlouis/copilot/update-finnish-translation-file
True-up Finnish translation with en.json
2026-03-13 14:43:24 +01:00
github-actions[bot] 04a81ef7a3 chore(release): update build metadata files [skip ci] 2026-03-13 13:43:14 +00:00
semantic-release 46c78e6cce 0.130.0
Automatically generated by python-semantic-release
2026-03-13 13:43:11 +00:00
Christian Krakau-Louis f2d8f2e129 Merge pull request #618 from christianlouis/copilot/true-up-catalan-translation
feat(i18n): Complete Catalan translation for ca.json
2026-03-13 14:42:52 +01:00
copilot-swe-agent[bot] 217217dd01 feat(i18n): add complete Catalan (ca) translation
Translate all 927 keys from English to Catalan in frontend/translations/ca.json.
Previously the file contained only English placeholder values. All strings
have been translated to proper Catalan, preserving technical terms, product
names, and placeholder variables (e.g. {size}, {year}, {count}).

- 872 out of 927 keys now have Catalan translations
- 55 values intentionally kept unchanged (proper names, product names,
  technical acronyms like IP/ID/ENV, language names in their native form,
  numeric error codes, and words identical in Catalan and English)"

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-13 11:37:29 +00:00
copilot-swe-agent[bot] 8e9e80f175 Initial plan 2026-03-13 11:28:02 +00:00
copilot-swe-agent[bot] a776622b1c Initial plan 2026-03-13 11:27:05 +00:00
copilot-swe-agent[bot] 424da0a343 Initial plan 2026-03-13 11:26:32 +00:00
github-actions[bot] 3fc06ae0b8 chore(release): update build metadata files [skip ci] 2026-03-13 08:34:02 +00:00
semantic-release 7847154da4 0.129.0
Automatically generated by python-semantic-release
2026-03-13 08:33:59 +00:00
Christian Krakau-Louis 3c7f0888de Merge pull request #610 from christianlouis/copilot/ensure-fr-json-translations
fix(i18n): complete French translations and resolve merge conflicts with main
2026-03-13 09:33:37 +01:00
github-actions[bot] 3624ceefe6 chore(release): update build metadata files [skip ci] 2026-03-13 08:33:01 +00:00
semantic-release 952463a8c9 0.128.0
Automatically generated by python-semantic-release
2026-03-13 08:32:58 +00:00
Christian Krakau-Louis 008b356dd0 Merge pull request #612 from christianlouis/copilot/ensure-pl-json-translations
feat(i18n): complete Polish translations in pl.json
2026-03-13 09:32:36 +01:00
github-actions[bot] 96a7231531 chore(release): update build metadata files [skip ci] 2026-03-13 08:32:03 +00:00
semantic-release f0d3e22f53 0.127.0
Automatically generated by python-semantic-release
2026-03-13 08:32:00 +00:00
Christian Krakau-Louis b40b2c3453 Merge pull request #611 from christianlouis/copilot/ensure-es-json-translations
fix(i18n): complete Spanish translations and resolve merge conflicts with main
2026-03-13 09:31:40 +01:00
copilot-swe-agent[bot] c9a650b863 fix(i18n): resolve merge conflicts from main - keep Spanish translations, restore zh translations 2026-03-13 08:05:20 +00:00
GitHub Copilot cd47cf2adf Merge branch 'main' of https://github.com/christianlouis/DocuElevate into copilot/ensure-fr-json-translations
# Conflicts:
#	frontend/translations/fr.json
2026-03-13 08:03:41 +00:00
copilot-swe-agent[bot] 19b9cd8163 fix(i18n): resolve merge conflicts - merge main into branch
Merged main (v0.126.0) into branch. Main added 26 new translation keys
via Crowdin l10n PR. Resolved conflict in pl.json by:
- Keeping all 280 Polish translations from our branch
- Adding Polish translations for 26 new keys from main
2026-03-13 08:01:19 +00:00
github-actions[bot] 6cd76caebb chore(release): update build metadata files [skip ci] 2026-03-13 07:54:42 +00:00
semantic-release 76e2aaa5f1 0.126.0
Automatically generated by python-semantic-release
2026-03-13 07:54:39 +00:00
Christian Krakau-Louis 603bcaad6a Merge pull request #580 from christianlouis/copilot/add-conditional-routing
feat: add conditional routing based on document type and metadata
2026-03-13 08:54:20 +01:00
github-actions[bot] fd4ea5c71b docs(changelog): update changelog [skip ci] 2026-03-13 07:53:19 +00:00
Christian Krakau-Louis f28d7c7068 Merge pull request #613 from christianlouis/l10n_main
New Crowdin updates
2026-03-13 08:52:57 +01:00
Christian Krakau-Louis d631756345 New translations en.json (Portuguese, Brazilian) 2026-03-12 23:45:05 +01:00
Christian Krakau-Louis 3bc57827d9 New translations en.json (Ukrainian) 2026-03-12 23:45:03 +01:00
Christian Krakau-Louis da77f906a5 New translations en.json (Swedish) 2026-03-12 23:45:02 +01:00
Christian Krakau-Louis cfa7fdc68a New translations en.json (Polish) 2026-03-12 23:44:59 +01:00
Christian Krakau-Louis 805e67c622 New translations en.json (Dutch) 2026-03-12 23:44:58 +01:00
Christian Krakau-Louis 564b6872c9 New translations en.json (Italian) 2026-03-12 23:44:56 +01:00
Christian Krakau-Louis 4fb7533ab6 New translations en.json (Finnish) 2026-03-12 23:44:55 +01:00
Christian Krakau-Louis 6b92c91c02 New translations en.json (German) 2026-03-12 23:44:54 +01:00
Christian Krakau-Louis 213c6fc944 New translations en.json (Danish) 2026-03-12 23:44:52 +01:00
Christian Krakau-Louis 6c17e3e482 New translations en.json (Czech) 2026-03-12 23:44:51 +01:00
Christian Krakau-Louis 8408edbb93 New translations en.json (Spanish) 2026-03-12 23:44:50 +01:00
Christian Krakau-Louis 62764ec9ee New translations en.json (French) 2026-03-12 23:44:49 +01:00
Christian Krakau-Louis 15b37950e9 New translations en.json (Limburgish) 2026-03-12 23:35:27 +01:00
Christian Krakau-Louis 75f4d55972 New translations en.json (Low German) 2026-03-12 23:35:26 +01:00
Christian Krakau-Louis f1afd475fc New translations en.json (Kannada) 2026-03-12 23:35:25 +01:00
Christian Krakau-Louis 5046d05dea New translations en.json (Flemish) 2026-03-12 23:35:24 +01:00
Christian Krakau-Louis aa5f497620 New translations en.json (Luxembourgish) 2026-03-12 23:35:23 +01:00
Christian Krakau-Louis fb3d87bd46 New translations en.json (Esperanto) 2026-03-12 23:35:22 +01:00
Christian Krakau-Louis 300cc2a981 New translations en.json (Welsh) 2026-03-12 23:35:21 +01:00
Christian Krakau-Louis 23ee85f6b9 New translations en.json (Latvian) 2026-03-12 23:35:20 +01:00
Christian Krakau-Louis 0da97eb092 New translations en.json (Estonian) 2026-03-12 23:35:19 +01:00
Christian Krakau-Louis 53736a1906 New translations en.json (Persian) 2026-03-12 23:35:18 +01:00
Christian Krakau-Louis 250b008304 New translations en.json (Galician) 2026-03-12 23:35:16 +01:00
Christian Krakau-Louis e5beb48f69 New translations en.json (Vietnamese) 2026-03-12 23:35:15 +01:00
Christian Krakau-Louis f61691b8de New translations en.json (Chinese Simplified) 2026-03-12 23:35:13 +01:00
Christian Krakau-Louis db4e1f7b6d New translations en.json (Ukrainian) 2026-03-12 23:35:11 +01:00
Christian Krakau-Louis 57910b7af5 New translations en.json (Turkish) 2026-03-12 23:35:10 +01:00
Christian Krakau-Louis 7b5a0bbab9 New translations en.json (Swedish) 2026-03-12 23:35:09 +01:00
Christian Krakau-Louis 38e2b77362 New translations en.json (Serbian (Cyrillic)) 2026-03-12 23:35:08 +01:00
Christian Krakau-Louis 5264d6c66c New translations en.json (Slovenian) 2026-03-12 23:35:07 +01:00
Christian Krakau-Louis 62624bc5ff New translations en.json (Slovak) 2026-03-12 23:35:06 +01:00
Christian Krakau-Louis c0fb2c4069 New translations en.json (Russian) 2026-03-12 23:35:05 +01:00
Christian Krakau-Louis 26ce680f03 New translations en.json (Portuguese) 2026-03-12 23:35:04 +01:00
Christian Krakau-Louis 0e3064b4c1 New translations en.json (Polish) 2026-03-12 23:35:02 +01:00
Christian Krakau-Louis 9d3da998df New translations en.json (Punjabi) 2026-03-12 23:35:01 +01:00
Christian Krakau-Louis ec651980a2 New translations en.json (Norwegian) 2026-03-12 23:35:00 +01:00
Christian Krakau-Louis 98567e0261 New translations en.json (Dutch) 2026-03-12 23:34:59 +01:00
Christian Krakau-Louis eb87f4fe9b New translations en.json (Lithuanian) 2026-03-12 23:34:58 +01:00
Christian Krakau-Louis fcbe7880a9 New translations en.json (Korean) 2026-03-12 23:34:57 +01:00
Christian Krakau-Louis cee6697c82 New translations en.json (Japanese) 2026-03-12 23:34:56 +01:00
Christian Krakau-Louis 5b471a606c New translations en.json (Italian) 2026-03-12 23:34:55 +01:00
Christian Krakau-Louis f8c5270681 New translations en.json (Hungarian) 2026-03-12 23:34:54 +01:00
Christian Krakau-Louis bf26c05036 New translations en.json (Hebrew) 2026-03-12 23:34:53 +01:00
Christian Krakau-Louis c2982b8696 New translations en.json (Irish) 2026-03-12 23:34:52 +01:00
Christian Krakau-Louis 2c506da349 New translations en.json (Finnish) 2026-03-12 23:34:51 +01:00
Christian Krakau-Louis a399641508 New translations en.json (Frisian) 2026-03-12 23:34:49 +01:00
Christian Krakau-Louis 34e367cb15 New translations en.json (Greek) 2026-03-12 23:34:48 +01:00
Christian Krakau-Louis d865c6ac20 New translations en.json (German) 2026-03-12 23:34:47 +01:00
Christian Krakau-Louis 16ffa4264e New translations en.json (Danish) 2026-03-12 23:34:46 +01:00
Christian Krakau-Louis 1f8a80c125 New translations en.json (Czech) 2026-03-12 23:34:45 +01:00
Christian Krakau-Louis f78e535cc3 New translations en.json (Catalan) 2026-03-12 23:34:44 +01:00
Christian Krakau-Louis 3abb66659c New translations en.json (Arabic) 2026-03-12 23:34:43 +01:00
Christian Krakau-Louis d0f8e21b41 New translations en.json (Afrikaans) 2026-03-12 23:34:42 +01:00
Christian Krakau-Louis 652c1d630b New translations en.json (Spanish) 2026-03-12 23:34:41 +01:00
Christian Krakau-Louis f65f1840d5 New translations en.json (French) 2026-03-12 23:34:40 +01:00
Christian Krakau-Louis ab97c3a7f2 New translations en.json (Romanian) 2026-03-12 23:34:39 +01:00
github-actions[bot] 95af9ffe5e docs(changelog): update changelog [skip ci] 2026-03-12 22:34:36 +00:00
Christian Krakau-Louis 4458b18530 Add configuration for Crowdin project 2026-03-12 23:34:18 +01:00
github-actions[bot] ca88d7d425 docs(changelog): update changelog [skip ci] 2026-03-12 22:23:25 +00:00
Christian Krakau-Louis 3b0e7b27cc Update Crowdin configuration file 2026-03-12 23:23:04 +01:00
copilot-swe-agent[bot] 5ab7226f1a feat(i18n): add complete Spanish translations for 744 keys in es.json
Translated all UI text, labels, messages, error messages, and descriptions
from English placeholders to proper Spanish. Preserved:
- Brand names (DocuElevate, Dropbox, Google Drive, OneDrive, Amazon S3, etc.)
- Proper names (Christian Krakau-Louis)
- Technical abbreviations (IP, ID, ENV, API, OCR, FTP, SFTP, IMAP, SMTP)
- Native language names (language.* keys)
- Placeholder variables ({name}, {count}, {query}, {year}, etc.)
- Numeric error codes (404, 500)

Result: 901 keys total, 0 missing keys, only 55 language-neutral
same-value keys remaining (all correctly untranslated).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-12 22:14:39 +00:00
copilot-swe-agent[bot] f737f2903a feat(i18n): add missing Polish translations to pl.json
Translated 285 keys that had English values in pl.json. All sections
now have proper Polish translations including auth, error pages, files
UI, help center, index/landing page, integrations, navigation, search,
status, and upload pages. Language names are now in Polish (e.g.,
Angielski for English, Francuski for French).

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-12 22:12:02 +00:00
copilot-swe-agent[bot] f70fa65969 Merge branch 'main' into copilot/add-conditional-routing
Resolve conflicts in app/api/__init__.py and app/models.py.
Renumber migration 027_add_routing_rules → 035_add_routing_rules.
Fix migration chain: down_revision → 034_add_user_profile_settings.
Add PipelineRoutingRule to migrations/env.py.
2026-03-12 22:07:54 +00:00
github-actions[bot] 3201733a50 chore(release): update build metadata files [skip ci] 2026-03-12 22:06:57 +00:00
semantic-release 7cb82bb6ac 0.125.1
Automatically generated by python-semantic-release
2026-03-12 22:06:54 +00:00
Christian Krakau-Louis 2c36b7dc95 Merge pull request #609 from christianlouis/copilot/ensure-de-json-translations
fix(i18n): sync all translation files to match en.json key set
2026-03-12 23:06:30 +01:00
copilot-swe-agent[bot] bb2c3ac759 fix(i18n): use plain plural form for files.files_selected in fr.json
Changed 'fichier(s) sélectionné(s)' to 'fichiers sélectionnés' for
consistency with the English source which uses 'files selected' (plain
plural), not 'file(s) selected'.

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-12 22:05:11 +00:00
copilot-swe-agent[bot] 5de7e01a86 feat(i18n): add complete French translations to fr.json
Translated 677 previously untranslated keys in fr.json from English to French.
The file now has 825 properly translated keys (up from 148), with the
remaining 76 keys intentionally the same as English (brand names like
DocuElevate/Dropbox/Google Drive, language names in their native form,
and technical terms like IP/ID/ENV/404/500 that are identical in French).

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-12 22:03:12 +00:00
copilot-swe-agent[bot] d3349e649e fix(i18n): sync de.json keys with en.json — add 26 missing keys to en.json and all language files
Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-12 21:57:31 +00:00
copilot-swe-agent[bot] fd23c49f20 Initial plan 2026-03-12 21:51:27 +00:00
copilot-swe-agent[bot] 0c9e413cd0 Initial plan 2026-03-12 21:51:21 +00:00
copilot-swe-agent[bot] 2aebf04ff5 Initial plan 2026-03-12 21:51:16 +00:00
copilot-swe-agent[bot] c9dd5cce11 Initial plan 2026-03-12 21:51:10 +00:00
github-actions[bot] 77f392b29b chore(release): update build metadata files [skip ci] 2026-03-12 21:44:46 +00:00
semantic-release 4c81b1bd1f 0.125.0
Automatically generated by python-semantic-release
2026-03-12 21:44:42 +00:00
Christian Krakau-Louis 4345d5128f Merge pull request #607 from christianlouis/copilot/add-user-settings-management
fix(tests): update whoami_handler call sites to pass required db argument
2026-03-12 22:44:13 +01:00
copilot-swe-agent[bot] 270e1a56b5 fix(tests): pass mock db to whoami_handler in test_api_auth_enabled.py
Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-12 17:03:30 +00:00
copilot-swe-agent[bot] 9583d6d96f fix(profile): address code review feedback - early size check, CSRF helper, test constants
Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-12 13:10:46 +00:00
github-actions[bot] 01e27ac905 chore(release): update build metadata files [skip ci] 2026-03-12 13:08:52 +00:00
semantic-release 709964b207 0.124.0
Automatically generated by python-semantic-release
2026-03-12 13:08:48 +00:00
Christian Krakau-Louis 028f592817 Merge pull request #596 from christianlouis/copilot/refactor-template-localization
fix(i18n): wire _ into all Jinja2 instances, sync translation files, fix pipelines.html orphan tag
2026-03-12 14:08:26 +01:00
copilot-swe-agent[bot] b0d6f1ab60 feat(profile): add user self-service profile settings page and API
Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-12 13:00:19 +00:00
copilot-swe-agent[bot] b483ed16fc fix(i18n): register _ in all Jinja2 template instances, sync translation files, fix pipelines.html
- Register _() translation function in app/auth.py, app/api/local_auth.py, and
  app/api/billing.py template env globals to fix jinja2.exceptions.UndefinedError
- Update test_settings_template_has_db_wizard_link to assert i18n key instead of
  literal "DB Wizard" (template now uses {{ _("settings.db_wizard_btn") }})
- Add 418 missing en.json keys to all 30 non-English translation files as English
  fallbacks to fix test_all_languages_have_same_keys
- Fix orphan </template> tag in pipelines.html by adding missing
  <template x-if="pipelineModal.saving"> opening tag (fixes djlint H025)

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-12 12:46:11 +00:00
copilot-swe-agent[bot] 13161994da Initial plan 2026-03-12 12:45:32 +00:00
Christian Krakau-Louis dd7f7dfd97 Merge branch 'main' into copilot/refactor-template-localization 2026-03-12 12:44:33 +01:00
github-actions[bot] 5a546dc0f1 chore(release): update build metadata files [skip ci] 2026-03-12 11:43:33 +00:00
semantic-release 75b522e36c 0.123.1
Automatically generated by python-semantic-release
2026-03-12 11:43:30 +00:00
Christian Krakau-Louis 5d239e904b Merge pull request #606 from christianlouis/copilot/fix-ruff-not-clearing-main
fix(tests): repair two broken tests — missing DB table and MagicMock ip_address
2026-03-12 12:43:08 +01:00
copilot-swe-agent[bot] cdd1524def fix(i18n): restore social providers block in login.html, fix escaped quotes
Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-12 11:39:13 +00:00
copilot-swe-agent[bot] be6b49c872 fix(tests): fix two failing tests - missing DB table and MagicMock IP address
- tests/test_database.py: add user_imap_accounts table to the regression
  test's initial DB setup (migration 022 creates it before rev 026, so it
  must exist for migration 032's ALTER TABLE to succeed)
- tests/test_local_auth.py: configure mock_request.headers.get to return
  None and client=None so get_client_ip() returns "unknown" instead of an
  un-serialisable MagicMock that broke the audit_logs INSERT in
  test_local_login_success and test_local_login_by_email

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-12 10:49:49 +00:00
copilot-swe-agent[bot] 6de11e25bf feat(i18n): localize 404, 500, billing_success, about, verify_email_sent templates
Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-12 10:25:34 +00:00
copilot-swe-agent[bot] 8060a79b9c style: fix ruff formatting in tests/test_imap_profiles.py
Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-12 10:25:32 +00:00
copilot-swe-agent[bot] bc94825877 Initial plan 2026-03-12 10:24:27 +00:00
github-actions[bot] d7d0413bc8 chore(release): update build metadata files [skip ci] 2026-03-12 07:54:06 +00:00
semantic-release d3443b034a 0.123.0
Automatically generated by python-semantic-release
2026-03-12 07:54:04 +00:00
Christian Krakau-Louis 7f644a2388 Merge pull request #604 from christianlouis/copilot/configure-attachment-ingestion
feat(imap): fine-grained attachment ingestion profiles with per-category selection
2026-03-12 08:53:45 +01:00
github-actions[bot] 338f4fa9bd chore(release): update build metadata files [skip ci] 2026-03-12 07:51:39 +00:00
semantic-release 44cebeab2a 0.122.0
Automatically generated by python-semantic-release
2026-03-12 07:51:35 +00:00
Christian Krakau-Louis 87fca21c7f Merge pull request #605 from christianlouis/copilot/enable-disable-storage-locations
feat(storage): explicit enable/disable flag per global storage destination
2026-03-12 08:51:14 +01:00
copilot-swe-agent[bot] 2e087731b9 fix(imap): address code review feedback on ingestion profiles
- Improve warning log in _resolve_categories_for_profile() to include
  exception type name for better troubleshooting
- Add SQLAlchemy IS NULL comment to imap_profiles.py filter
- Pass default_categories from server to template to avoid hardcoded
  category list in JS (now uses {{ default_categories | tojson }})
- Simplify view profiles query (remove redundant unauthenticated path)
- Update docs: ConfigurationGuide.md and EmailIngestion.md with
  full profiles documentation including category table and API reference

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-12 01:19:32 +00:00
copilot-swe-agent[bot] c9f554465d feat(imap): add ImapIngestionProfile model, API, migration and UI
Replaces the simple binary attachment_filter string with a full
ingestion profiles system:

- Add FILE_TYPE_CATEGORIES dict to allowed_types.py (6 categories:
  pdf, office, opendocument, text, web, images) + DEFAULT_CATEGORIES
  / ALL_CATEGORIES + get_allowed_types_for_categories() helper
- Add ImapIngestionProfile model (id, name, description, owner_id,
  allowed_categories JSON, is_builtin)
- Update UserImapAccount: replace attachment_filter string with
  profile_id FK to imap_ingestion_profiles
- Migration 033: creates profiles table, seeds 2 built-in profiles
  (Documents Only, All Files), migrates attachment_filter → profile_id
- New /api/imap-profiles/ CRUD endpoints (list, create, get, update,
  delete) with category validation
- Register imap_profiles router in app/api/__init__.py
- Update imap_tasks.py: replace attachment_filter string param with
  profile-based allowed_categories; add _resolve_categories_for_profile()
- Update imap_accounts.py API to use profile_id instead of attachment_filter
- Update imap_accounts view to pass profiles + categories to template
- Full UI overhaul: profiles panel + profile create/edit modal with
  category checkboxes; profile selector in account modal
- 17 new tests (141 total), all passing

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-12 01:14:19 +00:00
copilot-swe-agent[bot] fd75961580 docs(storage): document explicit enable/disable flags in ConfigurationGuide and .env.demo
Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-12 01:10:31 +00:00
copilot-swe-agent[bot] b72ab3b318 feat(storage): add explicit enable/disable flag for each global storage destination
Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-12 01:04:28 +00:00
copilot-swe-agent[bot] 554bb21d32 feat(imap): add attachment type filter for IMAP ingestion
Add a configurable switch to control which attachment types are ingested
via IMAP. Images are excluded by default; office files and PDFs are ingested.

- Add global `IMAP_ATTACHMENT_FILTER` config setting (default: `documents_only`)
- Add `attachment_filter` column to `UserImapAccount` model for per-user override
- Migration 032 adds the column to `user_imap_accounts` table
- Update `fetch_attachments_and_enqueue()` to respect filter (documents_only/all)
- Update `pull_inbox()`, `_pull_user_imap_accounts()`, and
  `_pull_user_integration_imap()` to pass the resolved filter
- Update IMAP accounts API (schemas, create/update handlers, response serializer)
- Update IMAP accounts UI to show attachment filter dropdown in modal and
  display filter badges on account cards
- Add 6 new tests covering attachment filter behaviour
- Update ConfigurationGuide.md, EmailIngestion.md, and .env.demo

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-12 00:59:54 +00:00
github-actions[bot] 8898d3ccc8 chore(release): update build metadata files [skip ci] 2026-03-12 00:56:46 +00:00
semantic-release 2c53d1cd4f 0.121.1
Automatically generated by python-semantic-release
2026-03-12 00:56:42 +00:00
Christian Krakau-Louis d8957263ee Merge pull request #603 from christianlouis/copilot/fix-duplicate-audit-log-menu
fix: remove duplicate Audit Logs nav entry and record login/logout events in audit log
2026-03-12 01:56:21 +01:00
github-actions[bot] 50ef607293 docs(changelog): update changelog [skip ci] 2026-03-12 00:56:10 +00:00
Christian Krakau-Louis 14b892b6d4 Merge pull request #601 from christianlouis/copilot/improve-code-test-coverage
test: improve coverage for app/utils/user_notification.py from ~50% to 100%
2026-03-12 01:55:49 +01:00
copilot-swe-agent[bot] 78204c2490 test(notifications): improve coverage for user_notification.py to 100%
Add 22 new unit tests in tests/test_user_notification_service.py covering
all previously-uncovered branches in app/utils/user_notification.py:

- create_in_app_notification: exception/rollback path
- _send_email_notification: SMTP success (TLS+creds, no-TLS, no-creds),
  SMTP exception, sender_email fallbacks
- _send_webhook_notification: success with/without secret, HTTP errors
- dispatch_user_notification: email/webhook pref dispatch, no target_id
  skip, inactive target skip, invalid JSON config, null config, outer
  exception handling, push notification sent/exception, unknown channel_type

Combined coverage: 49.52% → 100%

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-12 00:44:59 +00:00
github-actions[bot] 7fb534954e chore(release): update build metadata files [skip ci] 2026-03-12 00:40:23 +00:00
semantic-release 6bc5b19da2 0.121.0
Automatically generated by python-semantic-release
2026-03-12 00:40:20 +00:00
Christian Krakau-Louis 0aa1df3920 Merge pull request #588 from christianlouis/copilot/add-social-logins-configuration
feat(auth): add social login support for Google, Microsoft, Apple, and Dropbox
2026-03-12 01:40:04 +01:00
copilot-swe-agent[bot] 1e8f433419 fix: remove duplicate Audit Logs nav entry and add login/logout audit log events
Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-12 00:39:15 +00:00
copilot-swe-agent[bot] 2dfb96ee44 Initial plan 2026-03-12 00:36:28 +00:00
github-actions[bot] 04cde33d01 docs(changelog): update changelog [skip ci] 2026-03-12 00:32:36 +00:00
Christian Krakau-Louis 3a68eeebba Merge pull request #602 from christianlouis/copilot/improve-test-coverage-audit-service
test: raise audit_service.py coverage from 59% to 100%
2026-03-12 01:32:18 +01:00
copilot-swe-agent[bot] 5e45b68cc1 Initial plan 2026-03-12 00:31:45 +00:00
copilot-swe-agent[bot] 10d415ae0c Initial plan 2026-03-12 00:29:46 +00:00
copilot-swe-agent[bot] 7438551080 test: improve coverage for app/utils/audit_service.py from 59% to 100%
Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-12 00:28:42 +00:00
copilot-swe-agent[bot] 1edb342e57 Initial plan 2026-03-12 00:19:33 +00:00
copilot-swe-agent[bot] 615882adf4 Initial plan 2026-03-12 00:18:59 +00:00
copilot-swe-agent[bot] 8c77982109 feat(i18n): replace hardcoded English text with translation keys in files, settings, and pipelines templates
Replace all hardcoded English strings in three HTML Jinja2 templates with
`{{ _("key") }}` translation function calls, adding 87 new keys to en.json.

files.html:
- Page title, drop overlay, upload modal, error messages
- All filter labels, placeholders, options (status, OCR quality, etc.)
- Saved searches and full-text search UI
- Bulk action buttons, table headers, pagination
- Delete and preview modal text

settings.html:
- Page heading, configuration priority legend
- Header buttons (Wizard, DB Wizard, Export, Audit Log)
- Search bar placeholder and aria-labels
- Sidebar categories, no-results state
- Per-setting labels: required, enable prefix, effective value
- Autocomplete and model picker hints/placeholders
- Revert/save button labels and states

pipelines.html:
- Page title and subtitle
- New Pipeline button, loading state, empty state
- Pipeline card badges (System, Default, Inactive, Disabled)
- Add/Edit step modal fields and labels
- OCR language options and hints
- Cancel/Delete buttons in all modals

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-12 00:17:23 +00:00
github-actions[bot] 7970c1f518 chore(release): update build metadata files [skip ci] 2026-03-12 00:14:59 +00:00
semantic-release bb8f324e90 0.120.0
Automatically generated by python-semantic-release
2026-03-12 00:14:55 +00:00
Christian Krakau-Louis 80dd117ebf Merge pull request #587 from christianlouis/copilot/add-apple-drive-icloud-support
feat(storage): add Apple iCloud Drive storage provider
2026-03-12 01:14:37 +01:00
github-actions[bot] 3a4f7452c0 chore(release): update build metadata files [skip ci] 2026-03-12 00:13:30 +00:00
semantic-release 09c485cede 0.119.0
Automatically generated by python-semantic-release
2026-03-12 00:13:27 +00:00
Christian Krakau-Louis 83e83fff73 Merge pull request #583 from christianlouis/copilot/add-compliance-templates
fix(migrations): merge main and rechain compliance migration as 031
2026-03-12 01:13:08 +01:00
copilot-swe-agent[bot] e94844a55e Merge: prefer rebased local over stale remote 2026-03-12 00:00:52 +00:00
copilot-swe-agent[bot] 054af636e3 fix(migrations): merge main and rechain compliance migration as 031 after 030_add_mobile_devices
Merge main branch into compliance templates feature branch.

Main had advanced with migrations 027-030 (ensure_shared_links,
audit_logs, user_language_preference, mobile_devices) since this
branch forked. Our compliance migration was 027 with down_revision
026, which conflicted with main's 027_ensure_shared_links_table.

Changes:
- Merge main (including i18n, audit logs, mobile, GraphQL features)
- Resolve conflicts in app/api/__init__.py, app/models.py, tests/conftest.py
- Rename 027_add_compliance_templates → 031_add_compliance_templates
- Rechain: down_revision 026_add_scheduled_jobs → 030_add_mobile_devices
- Add ComplianceTemplate to migrations/env.py imports
- Alembic now has single head: 031_add_compliance_templates
2026-03-12 00:00:29 +00:00
copilot-swe-agent[bot] 50af0ea679 fix(tests): add iCloud mocks to all test files and address code review feedback
- Add _should_upload_to_icloud mock to test_coverage_uploads_notification.py
  (_all_should_upload_false helper + 3 inline patch blocks)
- Add cfg.icloud_username/password = None to all onboarding test mocks
- Add iCloud creds to fully-configured onboarding test
- Fix noqa comment accuracy (unofficial third-party, not first-party)
- Replace generic Exception with RuntimeError in upload error handler

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-11 23:59:19 +00:00
github-actions[bot] 78a245d3a5 chore(release): update build metadata files [skip ci] 2026-03-11 23:43:31 +00:00
semantic-release 8b07f7201f 0.118.0
Automatically generated by python-semantic-release
2026-03-11 23:43:28 +00:00
Christian Krakau-Louis 39db5fc564 Merge pull request #600 from christianlouis/copilot/add-graphql-api
feat: add GraphQL API alongside REST at /graphql
2026-03-12 00:43:10 +01:00
copilot-swe-agent[bot] 528f0a624d fix: remove accidental pip artifact file and update docs for iCloud Drive
Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-11 23:38:35 +00:00
copilot-swe-agent[bot] 30f06e0b32 feat(storage): add Apple iCloud Drive storage provider
Add iCloud Drive as a new storage destination using the pyicloud library.
Includes upload task, configuration, user integration handler, provider
status, onboarding support, and comprehensive tests.

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-11 23:38:35 +00:00
copilot-swe-agent[bot] 9bc23aa40b test(tasks): add _should_upload_to_icloud mock to send_to_all tests
Add icloud upload check mock alongside existing _should_upload_to_*
function mocks in all TestSendToAllDestinations test methods.

Changes:
- Import _should_upload_to_icloud from app.tasks.send_to_all
- Add @patch decorator for _should_upload_to_icloud in 9 test methods
- Add mock_icloud parameter to each test method signature
- Set mock_icloud.return_value = False where other mocks are set to False

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-11 23:38:34 +00:00
copilot-swe-agent[bot] 9c71e9aabf Initial plan 2026-03-11 23:38:34 +00:00
github-actions[bot] 40d976ed82 chore(release): update build metadata files [skip ci] 2026-03-11 23:34:40 +00:00
semantic-release 39671ad3b9 0.117.1
Automatically generated by python-semantic-release
2026-03-11 23:34:37 +00:00
Christian Krakau-Louis 1c4a261f01 Merge pull request #599 from christianlouis/copilot/fix-main-test-failures
fix: resolve 47 failing CI tests across i18n, templates, and migrations
2026-03-12 00:34:18 +01:00
copilot-swe-agent[bot] df4c91a586 fix: resolve all 47 failing tests in main
- Fix detect_language() to safely handle unhashable session values and
  requests missing cookies/headers attributes (TypeError + AttributeError)
- Add default English `_()` translation function to Jinja2 template
  environment globals so error pages always have it available
- Fix app/main.py exception handlers to use a dedicated error templates
  instance with `_` registered, keeping it separate from view templates
  to avoid test patches breaking error rendering
- Fix app/views/plans.py to import shared templates from app.views.base
  instead of creating its own Jinja2Templates instance
- Make migration 029_add_user_language_preference idempotent: skip
  ALTER TABLE if user_profiles table does not exist
- Update test_i18n.py expectations to reflect 31 supported languages
- Create 21 missing translation files (nb, da, sv, fi, is, ga, lb, ca,
  cs, sk, hu, sl, hr, ro, bg, el, et, lv, lt, tr, uk) with English
  placeholder translations
- Update de.json with 117 missing translation keys including proper
  German translations
- Update es, fr, it, nl, pl, pt, ru, zh translation files with missing
  keys using English fallbacks

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-11 22:28:01 +00:00
copilot-swe-agent[bot] a41ded535f feat(api): add GraphQL endpoint at /graphql with Strawberry
- Add strawberry-graphql[fastapi] dependency
- Implement GraphQL schema covering documents, pipelines, settings, users
- Enable GraphiQL playground at /graphql
- Mount GraphQL router in main.py
- Add 24 tests for all query types and auth enforcement
- Update docs/API.md with GraphQL documentation section

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-11 22:17:25 +00:00
copilot-swe-agent[bot] 2b834d1405 Initial plan 2026-03-11 22:06:22 +00:00
github-actions[bot] 26a6eb81a1 chore(release): update build metadata files [skip ci] 2026-03-11 21:57:57 +00:00
semantic-release acd1572b53 0.117.0
Automatically generated by python-semantic-release
2026-03-11 21:57:54 +00:00
Christian Krakau-Louis 4389e64269 Merge pull request #590 from christianlouis/copilot/build-mobile-apps
feat(mobile): add iOS/Android app with re-chained migrations and main merge
2026-03-11 22:57:19 +01:00
copilot-swe-agent[bot] 00f5e5bc1a Initial plan 2026-03-11 21:54:05 +00:00
copilot-swe-agent[bot] 6bcabfef77 fix(merge): resolve conflicts in app/api/__init__.py and migrations/env.py against main
Merge main into branch to pull in translation files, audit log templates,
CI workflow updates, and other changes from PRs #595, #597, #598.
Keep mobile_router and MobileDevice additions from this branch.
2026-03-11 21:53:00 +00:00
copilot-swe-agent[bot] 1d5eee4281 fix(migrations): re-chain mobile devices migration against main and restore merge conflicts
Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-11 20:43:29 +00:00
github-actions[bot] ab19ae5706 docs(changelog): update changelog [skip ci] 2026-03-11 19:07:04 +00:00
Christian Krakau-Louis 38452a51ae Merge pull request #598 from christianlouis/copilot/update-translation-files-and-languages
Add 21 new translation files and expand 8 existing ones to full 483-key coverage
2026-03-11 20:06:44 +01:00
github-actions[bot] c5b67fe364 docs(changelog): update changelog [skip ci] 2026-03-11 16:39:29 +00:00
Christian Krakau-Louis 06c728cd21 Merge pull request #595 from christianlouis/copilot/test-docs-updates-language-support
feat(i18n): expand test and documentation coverage from 10 to 31 languages
2026-03-11 17:39:06 +01:00
copilot-swe-agent[bot] bc1c644eec Initial plan 2026-03-11 16:35:37 +00:00
github-actions[bot] eb22a58065 docs(changelog): update changelog [skip ci] 2026-03-11 14:28:17 +00:00
Christian Krakau-Louis ebf1fdb2a6 Merge pull request #597 from christianlouis/copilot/fix-ci-actions-annotations
ci: opt into Node.js 24 for all GitHub Actions workflows
2026-03-11 15:27:50 +01:00
copilot-swe-agent[bot] bea9dae957 feat(i18n): replace hardcoded English text with translation keys in help, index, search templates
Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-11 12:39:20 +00:00
copilot-swe-agent[bot] 64577f2d92 i18n: translate 6 HTML Jinja2 templates with _() calls
Replace all hardcoded English visible text with {{ _("key") }} calls in:
- queue_dashboard.html: headings, stat cards, table headers, empty states
- api_tokens.html: form labels, table headers, status badges, aria-labels
- similarity_dashboard.html: headings, stat labels, controls, empty states
- backup.html: buttons, config labels, table, retention section, confirm dialog
- shared_links.html: form labels/options, table headers, status badges
- duplicates.html: tabs, role tags, near-dup form, explanation bullets

Follows i18n rules: placeholder/aria-label/title attrs translated,
JS strings in <script> left as-is, Alpine.js bindings use Jinja2
server-side evaluation pattern for dynamic translated strings.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-11 12:22:46 +00:00
copilot-swe-agent[bot] fdff970e59 feat(ui): i18n-translate credentials and audit_logs templates
Replace all hardcoded English text in credentials.html and audit_logs.html
with {{ _("key") }} translation function calls.

New keys added:
credentials.*: page_title, title, subtitle, total_credentials, configured,
  not_configured, legend_title, legend_db, legend_env_before, legend_env_after,
  legend_missing, legend_restart, col_credential, col_source, col_action,
  table_for, restart_title, status_missing, source_db_title, source_env_title,
  edit_in_settings, manage_settings, raw_json

audit.*: page_title, title, subtitle, siem_enabled_title, siem_off,
  siem_disabled_title, refresh_label, filter_action, filter_all_actions,
  filter_user, filter_all_users, filter_severity, filter_resource_type,
  filter_resource_placeholder, filters_section_label, col_timestamp,
  col_resource, col_ip, table_label, no_events, no_events_hint,
  pagination_label, prev_label, prev, next_label, next, critical

Reused existing keys: common.description, common.status, common.actions,
  common.edit, common.all, common.info, common.warning, common.error,
  common.refresh, common.details, common.loading

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-11 12:08:14 +00:00
copilot-swe-agent[bot] c306d80755 ci: opt into Node.js 24 for all GitHub Actions workflows
Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-11 11:52:35 +00:00
copilot-swe-agent[bot] 50a4f76f9c Initial plan 2026-03-11 11:50:11 +00:00
copilot-swe-agent[bot] 3f4562df0a Initial plan 2026-03-11 11:47:49 +00:00
copilot-swe-agent[bot] e58706e97f Initial plan 2026-03-11 11:47:35 +00:00
github-actions[bot] 6b31ddac8e chore(release): update build metadata files [skip ci] 2026-03-11 11:44:23 +00:00
semantic-release 691ed13074 0.116.0
Automatically generated by python-semantic-release
2026-03-11 11:44:20 +00:00
Christian Krakau-Louis 9f7d6c8548 Merge pull request #578 from christianlouis/copilot/implement-ui-i18n-support
feat(i18n): expand to 31 European languages, localize all in-product copy, merge with main
2026-03-11 12:43:59 +01:00
Christian Krakau-Louis cbc04977ed Merge branch 'main' into copilot/implement-ui-i18n-support 2026-03-11 12:39:39 +01:00
copilot-swe-agent[bot] 652fad758f feat(i18n): expand to 31 European languages, localize all in-product copy, merge with main
Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-10 22:50:23 +00:00
copilot-swe-agent[bot] a66eb4b2a2 fix: merge main into i18n branch and relink migration to 029
- Resolve conflict in app/api/__init__.py (keep both audit_logs_router and i18n_router)
- Incorporate AuditLog model, audit_service, audit_logs API/views from main
- Relink migration from 026→027 to 028→029 (chain after 028_add_audit_logs)
- Update migrations/env.py with full model import list from main

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-10 21:59:58 +00:00
github-actions[bot] d6fb78715a docs(changelog): update changelog [skip ci] 2026-03-10 21:51:54 +00:00
Christian Krakau-Louis 40274f00d5 Merge pull request #582 from christianlouis/copilot/enhance-code-coverage-100
test(views): boost view module coverage — 27/29 files at 100%
2026-03-10 22:51:33 +01:00
github-actions[bot] 2375758a39 chore(release): update build metadata files [skip ci] 2026-03-10 21:51:21 +00:00
semantic-release 12c70b802a 0.115.0
Automatically generated by python-semantic-release
2026-03-10 21:51:18 +00:00
Christian Krakau-Louis 086793c05a Merge pull request #579 from christianlouis/copilot/add-audit-logging-with-tamper-detection
fix(migrations): rebase audit_logs migration onto current main
2026-03-10 22:51:00 +01:00
Christian Krakau-Louis f7dface365 Merge branch 'main' into copilot/add-audit-logging-with-tamper-detection 2026-03-10 22:41:26 +01:00
copilot-swe-agent[bot] 6e2e4a830f fix(migrations): rebase audit_logs migration onto main's 027_ensure_shared_links_table
- Add 027_ensure_shared_links_table.py from main branch
- Renumber 027_add_audit_logs → 028_add_audit_logs
- Update down_revision to chain from 027_ensure_shared_links_table
- Restore all model imports in migrations/env.py (were dropped in previous PR)
- Restore shared_links in db_migrate.py _TABLE_ORDER

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-10 21:34:15 +00:00
copilot-swe-agent[bot] 4eb04bd7f6 fix(mobile): address code review findings in mobile app config and tests
Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-10 09:57:58 +00:00
copilot-swe-agent[bot] d538c0879d feat(mobile): add iOS/Android mobile app with SSO login, camera upload, and push notifications
Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-10 09:52:02 +00:00
github-actions[bot] ba17067012 chore(release): update build metadata files [skip ci] 2026-03-10 09:28:50 +00:00
semantic-release 45713f2de9 0.114.1
Automatically generated by python-semantic-release
2026-03-10 09:28:47 +00:00
Christian Krakau-Louis 70e5391649 Merge pull request #589 from christianlouis/copilot/fix-expire-stale-shared-links 2026-03-10 10:28:28 +01:00
copilot-swe-agent[bot] a50c3aadf5 Initial plan 2026-03-10 09:27:31 +00:00
copilot-swe-agent[bot] ca84a11284 fix: remove accidental pip artifact file and update docs for iCloud Drive
Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-10 01:03:36 +00:00
copilot-swe-agent[bot] 82d67c56b3 feat(storage): add Apple iCloud Drive storage provider
Add iCloud Drive as a new storage destination using the pyicloud library.
Includes upload task, configuration, user integration handler, provider
status, onboarding support, and comprehensive tests.

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-10 00:43:00 +00:00
copilot-swe-agent[bot] 6e50c61970 test(tasks): add _should_upload_to_icloud mock to send_to_all tests
Add icloud upload check mock alongside existing _should_upload_to_*
function mocks in all TestSendToAllDestinations test methods.

Changes:
- Import _should_upload_to_icloud from app.tasks.send_to_all
- Add @patch decorator for _should_upload_to_icloud in 9 test methods
- Add mock_icloud parameter to each test method signature
- Set mock_icloud.return_value = False where other mocks are set to False

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-10 00:37:24 +00:00
copilot-swe-agent[bot] 6fb1df1aad test(views): boost coverage for 11 view modules toward 100%
Adds tests/test_views_coverage_boost.py with 37 tests covering:
- api_tokens, notifications, shared_links, share, plans views (template rendering)
- imap_accounts view (helper functions + route with mocked owner)
- integrations view (DB queries, tier logic, HTTP/generic exception handling)
- general view (multi-user subscription branch with signed session cookie)
- filemanager view (PB formatting, broken symlink stat errors in scan/walk)
- files view (pipeline step filtering, dedup toggle, ValueError in commonpath)
- help view (no-session branch, logged-in user Zammad widget population)

Coverage improvements (full suite):
- 27 of 29 view modules now at 100% (was 18 of 29)
- imap_accounts: 30.95% → 100%
- integrations: 82.09% → 100%
- filemanager: 96.63% → 100%
- help: 96% → 100%
- plans: 86.67% → 100%
- api_tokens/notifications/shared_links/share: 88-90% → 100%

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-10 00:31:28 +00:00
copilot-swe-agent[bot] 5d716ad78f fix(auth): address code review feedback - sanitize error messages, remove unused import
Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-10 00:27:07 +00:00
copilot-swe-agent[bot] 9c26d412d7 test(auth): add tests for social login and fix existing config validator tests
Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-10 00:24:53 +00:00
copilot-swe-agent[bot] ac6e052788 feat(auth): add social login support for Google, Microsoft, Apple, and Dropbox
Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-10 00:12:15 +00:00
copilot-swe-agent[bot] c8987d966b test(views): add comprehensive coverage tests for 11 view modules
Adds tests/test_views_coverage_boost.py with 35 tests covering:
- api_tokens, notifications, shared_links, share, plans (template render)
- imap_accounts (helper functions + route with owner)
- integrations (DB queries, tier logic, error handling)
- general (multi-user subscription branch)
- filemanager (PB formatting, broken symlink stat errors)
- files (pipeline step filtering, dedup, ValueError in commonpath)
- help (no-session branch)

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-10 00:11:30 +00:00
copilot-swe-agent[bot] 542fb46ee7 fix(compliance): address code review feedback - add aria-busy, input validation, use IntegrityError
Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-10 00:09:08 +00:00
copilot-swe-agent[bot] 491c424580 docs(compliance): add ComplianceGuide.md and update ConfigurationGuide with compliance_enabled setting
Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-10 00:05:44 +00:00
copilot-swe-agent[bot] 289dcc375c fix(db): add migration to create shared_links table for databases that skipped 025
Migration 025_add_shared_links was inserted into the Alembic chain
(between 024_add_api_tokens and 025_add_user_notifications) after some
databases had already been migrated past that point. Those databases
never had the shared_links table created, causing OperationalError when
the expire-shared-links scheduled task runs or when users try to create
shared links.

This commit:
- Adds migration 027_ensure_shared_links_table that idempotently creates
  the table if it doesn't exist
- Updates migrations/env.py to import all models for autogenerate support
- Adds shared_links to db_migrate.py _TABLE_ORDER for proper migration
  ordering
- Adds a regression test verifying the fix

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-10 00:01:33 +00:00
copilot-swe-agent[bot] 666f739f4e feat(compliance): add GDPR, HIPAA, SOC2 compliance templates with one-click apply and dashboard
- Add ComplianceTemplate model in app/models.py
- Create database migration 027_add_compliance_templates
- Add compliance_enabled feature flag to config and settings metadata
- Create compliance_service.py with pre-built template definitions and evaluation
- Create compliance API endpoints (list, get, apply, status, summary)
- Create compliance admin view and dashboard template
- Add compliance link to admin navigation (desktop and mobile)
- Seed compliance templates at application startup
- Add comprehensive tests (29 passing)

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-09 23:55:29 +00:00
copilot-swe-agent[bot] 1e5e35a26a refactor(routing): address code review feedback - simplify list filter, fix docs example
Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-09 23:38:25 +00:00
copilot-swe-agent[bot] e95693d684 docs(routing): add routing rules documentation to API.md and UserGuide.md
Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-09 23:36:13 +00:00
copilot-swe-agent[bot] c286e1b394 test(i18n): add comprehensive tests and documentation for i18n system
- Add 45 tests covering translation files, translate(), Accept-Language
  parsing, language detection, l10n formatters, and API endpoints
- Create InternationalizationGuide.md documentation
- Fix linting issues (E741, PLW2901)

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-09 23:35:32 +00:00
copilot-swe-agent[bot] 40d56f0396 feat(routing): add conditional routing rules for document-to-pipeline assignment
- Add PipelineRoutingRule model to app/models.py
- Add Alembic migration 027_add_routing_rules
- Add routing engine (app/utils/routing_engine.py) with rule evaluation
- Add CRUD API endpoints (app/api/routing_rules.py)
- Register router in app/api/__init__.py
- Add comprehensive tests (73 tests, all passing)

Supported fields: file_type, document_type, category, filename, size, metadata.*
Supported operators: equals, not_equals, contains, not_contains, regex, gt, lt, gte, lte
First-match-wins evaluation with default pipeline fallback

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-09 23:34:37 +00:00
copilot-swe-agent[bot] 653c137222 fix(audit): address code review - header validation, touch targets, env.demo cleanup
Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-09 23:33:43 +00:00
copilot-swe-agent[bot] 3b491ea84c docs(audit): add SIEM configuration to docs, .env.demo, and settings metadata
Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-09 23:29:12 +00:00
copilot-swe-agent[bot] ff76855f29 feat(i18n): add internationalization framework with 10 languages
- Create i18n utility module (app/utils/i18n.py) with translation loading,
  browser language detection, AI fallback, and l10n helpers
- Add JSON translation files for EN, DE, FR, ES, IT, PT, NL, PL, ZH, RU
- Add preferred_language column to UserProfile model with migration
- Register _() translation function as Jinja2 global
- Update base.html with translated navigation, footer, cookie notice
- Add language selector dropdown in nav bar (desktop + mobile)
- Create API endpoints for language preference (POST/GET /api/i18n/)
- Support language detection: user profile > cookie > Accept-Language > default

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-09 23:28:02 +00:00
copilot-swe-agent[bot] 35db9f88de feat(audit): add comprehensive audit logging with SIEM integration
- Add AuditLog model with append-only design (timestamp, user, action, resource, IP, details, severity)
- Add audit_service.py with record/query helpers and SIEM forwarding (Syslog RFC 5424, HTTP/webhook)
- Add /api/audit-logs REST endpoints with filtering and pagination
- Add /admin/audit-logs viewer UI with real-time filters
- Add SIEM config settings (syslog, HTTP for Splunk HEC/Logstash/Grafana Loki)
- Add Alembic migration 027_add_audit_logs
- Add navigation link in admin menu
- Add comprehensive tests (20 tests covering model, service, SIEM, API, view)

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-09 23:18:18 +00:00
copilot-swe-agent[bot] 73295cad33 Initial plan 2026-03-09 23:15:51 +00:00
copilot-swe-agent[bot] b4131e0d19 Initial plan 2026-03-09 23:14:47 +00:00
copilot-swe-agent[bot] 3afd406c59 Initial plan 2026-03-09 23:13:25 +00:00
copilot-swe-agent[bot] 341dad643f Initial plan 2026-03-09 23:06:40 +00:00
copilot-swe-agent[bot] 9ee3249146 Initial plan 2026-03-09 23:06:22 +00:00
copilot-swe-agent[bot] 1875986ce8 Initial plan 2026-03-09 23:05:10 +00:00
copilot-swe-agent[bot] 7685b787f2 Initial plan 2026-03-09 23:04:32 +00:00
copilot-swe-agent[bot] 95f1798908 Initial plan 2026-03-09 23:04:14 +00:00
github-actions[bot] e709f2646c chore(release): update build metadata files [skip ci] 2026-03-09 23:01:00 +00:00
semantic-release d5ed512612 0.114.0
Automatically generated by python-semantic-release
2026-03-09 23:00:57 +00:00
Christian Krakau-Louis 5fd3f0661b Merge pull request #577 from christianlouis/copilot/create-user-auth-workflow
fix(templates): remove orphan `</div>` in google_drive.html breaking HTML accessibility lint
2026-03-10 00:00:39 +01:00
copilot-swe-agent[bot] 256d9cb674 fix(templates): remove orphan </div> in google_drive.html causing HTML accessibility lint failure
The extra </div> at line 260 (before the {% endif %} closing the admin
auth-method block) was introduced when the per-user OAuth wizard PR
restructured the admin section. Removing it balances the div tree so
that the outer container div is properly closed by line 427.

djlint frontend/templates/ --lint now reports 0 errors across 58 files.

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-09 21:46:38 +00:00
github-actions[bot] 5d5784f069 chore(release): update build metadata files [skip ci] 2026-03-09 21:30:44 +00:00
semantic-release 91549c8f5e 0.113.0
Automatically generated by python-semantic-release
2026-03-09 21:30:41 +00:00
Christian Krakau-Louis 4555cdb7df Merge pull request #576 from christianlouis/copilot/add-scheduled-batch-processing
feat: scheduled batch processing — 8 admin-managed jobs, DB-driven beat schedule, 100% coverage
2026-03-09 22:30:20 +01:00
copilot-swe-agent[bot] 0529ae53ff fix: 100% coverage, WCAG accessibility fixes, and documentation updates for per-user OAuth wizards
Test coverage:
- Add config=None branch test for dropbox, onedrive, google_drive views (100% coverage)
- Add WATCH_FOLDER source-type test (folder_path vs folder key)
- Add integration-not-found fallback-to-admin-mode test

WCAG 2.1 AA fixes:
- Add aria-labelledby="modalTitle" to role="dialog" modals in setup templates
- Add aria-hidden="true" to decorative SVGs in callback templates
- Add role="status" aria-label="Loading" to spinner divs
- Add aria-live="polite" to processing-message and success/folder-selection regions
- Add role="alert" aria-live="assertive" to error containers
- Update "Return to Setup" link to preserve integration_id in user mode

Docs: update DropboxSetup.md, GoogleDriveSetup.md, OneDriveSetup.md with per-user OAuth flow section

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-09 20:19:19 +00:00
copilot-swe-agent[bot] ec77c51cb5 fix: code standards audit — 100% coverage, Pydantic v2 model_config, asyncio.run(), targeted edge-case tests
Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-09 19:00:13 +00:00
copilot-swe-agent[bot] 2d01e71afd test(views): add user-mode coverage tests and fix dashboard helper duplication
- Add tests for user-mode with invalid JSON config (covers exception handling path)
- Add tests for user-mode with valid config (verifies folder path pre-population)
- Extract _watchFolderOAuthSource() helper to reduce isOAuthType/oauthLink duplication

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-09 18:32:04 +00:00
copilot-swe-agent[bot] 5bc1fac79c feat(tasks): add 5 additional scheduled jobs (expire_shared_links, prune_processing_logs, prune_old_notifications, backfill_missing_metadata, sync_search_index)
Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-09 18:25:26 +00:00
copilot-swe-agent[bot] c71f33a214 feat(integrations): add per-user OAuth wizard with user-mode for Dropbox, OneDrive, Google Drive
- Add user_mode to dropbox/onedrive/google_drive setup views that loads integration config
- Show user-friendly auth wizard when integration_id is provided (user mode)
- In user mode: show integration name, current folder, back-to-integrations link
- In callback templates: only save credentials (not config) for user integrations
- In integrations dashboard: show Authorize/Re-Authorize button for all OAuth types
- Add WATCH_FOLDER OAuth support: detect source_type in config for auth button
- isOAuthType() and oauthLink() now accept full integration object

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-09 18:24:07 +00:00
copilot-swe-agent[bot] c2d56d7475 feat(tasks): add scheduled batch processing infrastructure (model, migration, tasks, API, view, template)
Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-09 18:01:34 +00:00
copilot-swe-agent[bot] 80b1b01beb Initial plan 2026-03-09 17:58:17 +00:00
copilot-swe-agent[bot] 07d90758b8 Initial plan 2026-03-09 17:52:43 +00:00
github-actions[bot] 04d51bab22 chore(release): update build metadata files [skip ci] 2026-03-09 12:01:14 +00:00
semantic-release 8fa3e9bc4a 0.112.0
Automatically generated by python-semantic-release
2026-03-09 12:01:11 +00:00
Christian Krakau-Louis 2132e7e8d1 Merge pull request #575 from christianlouis/copilot/fix-imap-integration-ui-issues
feat(integrations): IMAP Gmail labels/delete controls, Watch Folder cloud sources, and comprehensive test coverage
2026-03-09 13:00:50 +01:00
copilot-swe-agent[bot] 9a342af609 fix(imap): normalize CRLF line endings to LF in imap_tasks.py
Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-09 09:42:20 +00:00
copilot-swe-agent[bot] 1741ab1011 test(tasks): add comprehensive tests for per-user cloud scan functions and gmail_apply_labels
Add 68 new unit tests covering:
- _scan_user_s3_folder: 10 tests (bucket config, credentials, client creation,
  pagination, download, cache, file types, delete, error handling)
- _scan_user_dropbox_folder: 9 tests (credentials, folder path, auth, listing,
  download, cache, non-file entries, delete, download failure)
- _scan_user_google_drive_folder: 8 tests (credentials JSON, folder ID, auth,
  download, cache, delete, download/listing failures)
- _scan_user_onedrive_folder: 10 tests (credentials, folder path, token exchange,
  download, folders, cache, download URL, delete, download/listing failures)
- _scan_user_nextcloud_folder: 9 tests (settings, PROPFIND, XML parse, download,
  self-entry, cache, delete, download failure, absolute href)
- _scan_user_webdav_folder: 9 tests (URL config, PROPFIND, XML parse, download,
  directories, cache, delete, download failure, absolute href)
- Dispatch tests for google_drive, onedrive, nextcloud, webdav source types
- gmail_apply_labels forwarding and default-to-true in IMAP integration

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-09 09:29:43 +00:00
copilot-swe-agent[bot] 4acad71c8f fix: address code review feedback - fix type hint and rename test
Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-09 02:38:50 +00:00
copilot-swe-agent[bot] 8c62a6b714 test(integrations): add tests for gmail_apply_labels and cloud watch folder dispatch
docs(integrations): update UserGuide and ConfigurationGuide for new IMAP and Watch Folder features

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-09 02:35:30 +00:00
copilot-swe-agent[bot] 79fc11b452 test: add tests for gmail_apply_labels=False and cloud source type dispatching
Add test_gmail_labels_disabled_when_gmail_apply_labels_false to verify
that Gmail star/label operations are skipped when gmail_apply_labels=False.

Add four tests to TestPullUserIntegrationWatchFolders for cloud source
type dispatching: S3, Dropbox, unknown provider, and explicit local type.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-09 02:26:10 +00:00
copilot-swe-agent[bot] e7d1289f1b feat(integrations): add IMAP Gmail labels/delete and Watch Folder cloud source support
- Add delete_after_process and gmail_apply_labels checkboxes to IMAP integration UI
- Add source_type selector (Local/S3/Dropbox/Google Drive/OneDrive/Nextcloud/WebDAV) to Watch Folder UI
- Add per-source-type config and credential fields for watch folders
- Add delete_after_process checkbox to Watch Folder settings
- Fix field name mismatch (path → folder_path) in Watch Folder UI
- Update pull_inbox() to accept and respect gmail_apply_labels parameter
- Update _pull_user_integration_imap() to read gmail_apply_labels from config
- Add per-user cloud scanning functions for S3, Dropbox, Google Drive, OneDrive, Nextcloud, WebDAV
- Update _pull_user_integration_watch_folders() to dispatch based on source_type
- Update IMAP and WATCH_FOLDER config shape documentation in models.py

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-09 02:15:03 +00:00
copilot-swe-agent[bot] 61e402f785 Initial plan 2026-03-09 01:52:58 +00:00
github-actions[bot] 5f548e29fa chore(release): update build metadata files [skip ci] 2026-03-09 01:47:12 +00:00
semantic-release aea9e227db 0.111.1
Automatically generated by python-semantic-release
2026-03-09 01:47:09 +00:00
Christian Krakau-Louis b71b18d184 Merge pull request #574 from christianlouis/copilot/fix-cors-header-test-error
fix(migrations): resolve duplicate Alembic heads causing test INTERNALERROR
2026-03-09 02:46:51 +01:00
copilot-swe-agent[bot] c374ba3ae9 fix(migrations): resolve duplicate Alembic heads causing test failures
Two migration files (025_add_shared_links and 025_add_user_notifications)
both had down_revision pointing to 024_add_api_tokens, creating multiple
migration heads. This caused alembic stamp/upgrade head to fail with
'Multiple heads are present; please specify a single target revision'
whenever init_db() ran (e.g. in TestClient fixtures).

Fix: chain 025_add_user_notifications from 025_add_shared_links so the
migration history is linear:
024_add_api_tokens → 025_add_shared_links → 025_add_user_notifications

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-09 01:11:26 +00:00
copilot-swe-agent[bot] 32e73727ad Initial plan 2026-03-09 00:47:56 +00:00
github-actions[bot] e4157e3373 chore(release): update build metadata files [skip ci] 2026-03-09 00:06:00 +00:00
semantic-release 708962b6a7 0.111.0
Automatically generated by python-semantic-release
2026-03-09 00:05:57 +00:00
Christian Krakau-Louis 0b9bba7158 Merge pull request #562 from christianlouis/copilot/refactor-dynamic-routing-user-destinations
fix(tasks): register upload_to_user_integration in Celery and add handler coverage
2026-03-09 01:05:36 +01:00
github-actions[bot] 39150cb9ab chore(release): update build metadata files [skip ci] 2026-03-08 23:46:58 +00:00
semantic-release ea2e927054 0.110.1
Automatically generated by python-semantic-release
2026-03-08 23:46:54 +00:00
Christian Krakau-Louis e916001e08 Merge pull request #573 from christianlouis/copilot/fix-sentry-settings-startup
fix(sentry): initialize Sentry after DB settings load in lifespan
2026-03-09 00:46:36 +01:00
github-actions[bot] b22f9a6af6 style: apply ruff auto-fix
- Auto-formatted code with ruff format
- Applied ruff linting fixes with --fix

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-03-08 22:40:15 +00:00
copilot-swe-agent[bot] 5fb11b978c fix(tasks): register upload_to_user_integration in celery_worker and add handler tests
- Add `from app.tasks.upload_to_user_integration import upload_to_user_integration`
  to app/celery_worker.py — fixes TestCeleryWorkerConfig test that enforces
  every tasks/ module is registered with Celery.

- Add tests/test_upload_handlers.py with 43 unit tests covering all 11
  per-type upload handler functions (_upload_dropbox, _upload_s3,
  _upload_google_drive, _upload_onedrive, _upload_webdav, _upload_nextcloud,
  _upload_ftp, _upload_sftp, _upload_paperless, _upload_email, _upload_rclone)
  plus 2 additional finalize_document_storage branch tests. All external
  libraries (dropbox, boto3, msal, paramiko, smtplib, subprocess, requests)
  are mocked so tests are hermetic and fast. Coverage on changed files:
  upload_to_user_integration.py 94.71%, finalize_document_storage.py 95.51%
  (both well above the 70% Codecov diff threshold).

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-08 22:21:15 +00:00
copilot-swe-agent[bot] 5864a4ed06 fix(sentry): initialize Sentry after DB settings are loaded in lifespan
Move init_sentry() from module level into the FastAPI lifespan context
manager, immediately after load_settings_from_db() completes. This
ensures that SENTRY_DSN and other Sentry settings configured via the
database admin UI are picked up on every restart.

Also update tests and docs accordingly.

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-08 22:17:18 +00:00
github-actions[bot] a171186f93 chore(release): update build metadata files [skip ci] 2026-03-08 22:13:18 +00:00
semantic-release 50271ff21f 0.110.0
Automatically generated by python-semantic-release
2026-03-08 22:13:16 +00:00
Christian Krakau-Louis 382e082198 Merge pull request #569 from christianlouis/copilot/add-document-sharing-feature
feat: document sharing with expiring links
2026-03-08 23:12:58 +01:00
github-actions[bot] e5d4f8b58f style: apply ruff auto-fix
- Auto-formatted code with ruff format
- Applied ruff linting fixes with --fix

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-03-08 22:12:44 +00:00
Christian Krakau-Louis 201d7c8fdc Merge branch 'main' into copilot/add-document-sharing-feature 2026-03-08 23:12:27 +01:00
github-actions[bot] 84e8ea2fb2 chore(release): update build metadata files [skip ci] 2026-03-08 22:12:01 +00:00
semantic-release 14330d1a83 0.109.0
Automatically generated by python-semantic-release
2026-03-08 22:11:59 +00:00
Christian Krakau-Louis 6376b6d73b Merge pull request #568 from christianlouis/copilot/add-multi-language-ocr-support
feat(ocr): per-pipeline language override for multi-language OCR
2026-03-08 23:11:41 +01:00
github-actions[bot] 2e241dd5f3 chore(release): update build metadata files [skip ci] 2026-03-08 22:11:10 +00:00
semantic-release 5914d9f511 0.108.0
Automatically generated by python-semantic-release
2026-03-08 22:11:07 +00:00
Christian Krakau-Louis 32db11f47f Merge pull request #571 from christianlouis/copilot/build-cli-tool-for-power-users
feat(cli): add `docuelevate` CLI tool for power users
2026-03-08 23:10:49 +01:00
github-actions[bot] 5144f03dc8 chore(release): update build metadata files [skip ci] 2026-03-08 22:10:46 +00:00
semantic-release b701787ef4 0.107.0
Automatically generated by python-semantic-release
2026-03-08 22:10:44 +00:00
Christian Krakau-Louis 0bd8b71438 Merge pull request #567 from christianlouis/copilot/build-notification-system
feat(notifications): per-user notification system with email, webhook, and in-app inbox
2026-03-08 23:10:27 +01:00
copilot-swe-agent[bot] 034876afbb Initial plan 2026-03-08 22:10:10 +00:00
copilot-swe-agent[bot] 06091546b0 fix(sharing): address security review findings
- Use per-password random salt with PBKDF2-HMAC-SHA256 (stored as salt:hash)
- Increase PBKDF2 iterations to 600,000 (OWASP 2023 recommendation)
- Password for downloads now accepted via POST body (never URL query param)
- Fail download request if view count cannot be incremented (prevents bypass)
- Update tests to match new hashing format and POST password download

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-08 21:55:55 +00:00
copilot-swe-agent[bot] a92bf8ec8f chore: remove accidentally committed =8.0.0 file
Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-08 21:52:01 +00:00
copilot-swe-agent[bot] 0f91b8bb7c feat(sharing): add document sharing with expiring links
- Add SharedLink model with token, expiry, view limit, password hash
- Add migration 025_add_shared_links
- Add API endpoints: create, list, revoke (auth) + public info/download
- Add management UI at /shared-links with revoke controls
- Add public share landing page at /share/{token}
- Add Share button on file_view.html
- Add Shared Links to user dropdown in common.js
- Write 35 unit tests covering all scenarios
- Update UserGuide.md with sharing documentation

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-08 21:51:30 +00:00
copilot-swe-agent[bot] a3fd74f117 feat(cli): add docuelevate CLI tool for power users
Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-08 21:50:18 +00:00
copilot-swe-agent[bot] 0b291995b9 fix(ocr): address code review feedback on multi-language OCR
- Fix _get_pipeline_ocr_language: remove redundant `or None` in step_config.get()
- Add Session type hint to _get_pipeline_ocr_language db parameter via TYPE_CHECKING
- Update process_with_ocr to use modern str | None syntax instead of Optional[str]
- Fix test_get_pipeline_ocr_language_explicit_pipeline_takes_priority: properly add
  sys_step to db_session so the system pipeline step is persisted in the test DB

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-08 21:49:49 +00:00
copilot-swe-agent[bot] a2a4c6fc9a feat(ocr): add multi-language OCR support with per-pipeline language override
- Add OCR_LANGUAGES constant (28 languages, EN/DE/FR/ES/IT/PT/RU/ZH/JA/KO/AR/etc.)
- Add TESSERACT_TO_EASYOCR mapping for automatic code translation
- Add optional language constructor arg to TesseractOCRProvider/EasyOCRProvider
- Update get_ocr_providers() to accept and pass per-call language override
- Add language parameter to process_with_ocr Celery task
- Add _get_pipeline_ocr_language() helper to resolve OCR language from pipeline step config
- Update process_document to look up and pass pipeline OCR language to process_with_ocr
- Add ocr_language select config field (28 options) to pipeline OCR step schema
- Add language dropdown to pipeline UI (pipelines.html)
- Update docs/UserGuide.md and docs/API.md with language override documentation
- Add 27 new tests covering language constants, provider overrides, and pipeline lookup

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-08 21:47:12 +00:00
copilot-swe-agent[bot] ae075908d1 docs: add per-user notification system documentation to ConfigurationGuide.md
Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-08 21:44:54 +00:00
copilot-swe-agent[bot] d48e36813e feat(notifications): build per-user notification system (email, webhook, in-app)
Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-08 21:44:01 +00:00
copilot-swe-agent[bot] fcefd0978f feat(notifications): add per-user notification system with inbox, email, and webhook targets
- Add UserNotificationTarget, UserNotificationPreference, InAppNotification models
- Add migration 025_add_user_notifications (tables + indexes)
- Add app/utils/user_notification.py dispatch service
- Add app/api/notifications.py REST endpoints (inbox, targets, preferences)
- Add app/views/notifications.py view route
- Add frontend/templates/notifications_dashboard.html Alpine.js dashboard
- Add bell icon with unread badge in base.html nav (desktop + mobile)
- Register routers in app/api/__init__.py and app/views/__init__.py
- Add 32 unit tests in tests/test_notifications_api.py

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-08 21:34:25 +00:00
copilot-swe-agent[bot] 5a77e36ac6 Initial plan 2026-03-08 21:34:13 +00:00
copilot-swe-agent[bot] 93b3959d2f Initial plan 2026-03-08 21:31:39 +00:00
copilot-swe-agent[bot] b03ea41638 Initial plan 2026-03-08 21:30:08 +00:00
copilot-swe-agent[bot] 01a9511dcf fix(tasks): address code review - SMTP TLS cert validation, rclone input validation, consolidated test fixture
Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-08 21:28:09 +00:00
github-actions[bot] 069433ccbc chore(release): update build metadata files [skip ci] 2026-03-08 21:24:32 +00:00
semantic-release dce75473a5 0.106.0
Automatically generated by python-semantic-release
2026-03-08 21:24:30 +00:00
copilot-swe-agent[bot] 89bf07d2ef feat(tasks): dynamic routing to user-specific destination integrations
- Add app/tasks/upload_to_user_integration.py: new Celery task that
  uploads a processed document to a specific UserIntegration using its
  own per-user config and Fernet-decrypted credentials. Supports all
  DESTINATION types: Dropbox, S3, Google Drive, OneDrive, WebDAV,
  Nextcloud, FTP, SFTP, Paperless-ngx, Email (SMTP), and Rclone.

- Extend app/tasks/send_to_all.py: add send_to_user_destinations task
  (queries active DESTINATION UserIntegrations for an owner and
  dispatches one upload_to_user_integration task per integration) and
  get_user_destination_count helper used by finalize_document_storage.

- Refactor app/tasks/finalize_document_storage.py: after processing,
  look up the document owner; if the owner has active DESTINATION
  integrations route exclusively to those (user-specific routing),
  otherwise fall back to the global send_to_all_destinations.

- Update tests/test_finalize_storage.py: add autouse fixture to prevent
  Redis hangs, update all existing tests with new mock parameters, add
  TestFinalizeDocumentStorageUserRouting class with four new tests that
  validate user-specific vs global routing decisions.

- Add tests/test_user_integration_upload.py: 14 new unit tests covering
  upload_to_user_integration (handler dispatch, error persistence,
  last_used_at update, credential decryption, skip for unknown types)
  and send_to_user_destinations / get_user_destination_count.

- Update docs/StorageArchitecture.md: document the user-specific
  destination routing feature, supported types, multiple-destination
  behaviour, and global fallback semantics.

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-08 21:24:14 +00:00
Christian Krakau-Louis caf860dd10 Merge pull request #565 from christianlouis/copilot/add-sentry-integration
feat(observability): integrate Sentry for error tracking and performance monitoring
2026-03-08 22:24:13 +01:00
copilot-swe-agent[bot] af44af98b8 Initial plan 2026-03-08 21:17:39 +00:00
copilot-swe-agent[bot] 58af9e5a29 feat(observability): add Sentry error and performance monitoring integration
Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-08 21:09:17 +00:00
github-actions[bot] 95fc54a16d chore(release): update build metadata files [skip ci] 2026-03-08 21:07:08 +00:00
semantic-release 8ba5d0c15b 0.105.0
Automatically generated by python-semantic-release
2026-03-08 21:07:06 +00:00
Christian Krakau-Louis 262551cd83 Merge pull request #566 from christianlouis/copilot/add-codecov-integration
feat(ci): add Codecov configuration and setup documentation
2026-03-08 22:06:46 +01:00
copilot-swe-agent[bot] 6d533aae33 feat(ci): integrate Codecov for test coverage analysis and reporting
Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-08 21:00:57 +00:00
copilot-swe-agent[bot] 8fc5a398e3 Initial plan 2026-03-08 20:57:37 +00:00
copilot-swe-agent[bot] b4c6f75786 Initial plan 2026-03-08 20:57:29 +00:00
copilot-swe-agent[bot] 5d3fe88be9 Initial plan 2026-03-08 20:54:08 +00:00
github-actions[bot] 289eb422f3 docs(changelog): update changelog [skip ci] 2026-03-08 20:51:29 +00:00
Christian Krakau-Louis 7acefb5f25 Merge pull request #561 from christianlouis/copilot/update-codeql-configuration
ci: add javascript-typescript back to CodeQL matrix alongside javascript
2026-03-08 21:51:09 +01:00
github-actions[bot] bab5967d88 chore(release): update build metadata files [skip ci] 2026-03-08 20:42:51 +00:00
semantic-release 189613b482 0.104.0
Automatically generated by python-semantic-release
2026-03-08 20:42:48 +00:00
Christian Krakau-Louis 8faf164e9e Merge pull request #560 from christianlouis/copilot/add-webhook-snippet-and-api-tokens
fix(security): resolve CodeQL clear-text logging and weak hashing alerts
2026-03-08 21:42:28 +01:00
github-actions[bot] fc9caf4f9b style: apply ruff auto-fix
- Auto-formatted code with ruff format
- Applied ruff linting fixes with --fix

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-03-08 20:39:10 +00:00
copilot-swe-agent[bot] a7eff2c033 fix(security): address CodeQL clear-text logging and weak hashing alerts
- Convert f-string log interpolation to %s-style formatting in
  app/api/pipelines.py and app/api/saved_searches.py to prevent
  clear-text logging of request-derived data (CodeQL: clear-text
  logging of sensitive information)
- Replace plain hashlib.sha256() with PBKDF2-HMAC-SHA256 via
  hash_token() in app/auth.py for Bearer token verification,
  consistent with how tokens are stored in api_tokens.py (CodeQL:
  use of weak cryptographic hashing on sensitive data)
- Remove redundant {exc} from logger.exception() calls (the
  traceback is already captured by logger.exception())
- Update test to verify PBKDF2 hash instead of plain SHA-256

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-08 19:33:55 +00:00
copilot-swe-agent[bot] 1fe6f86cc0 ci: restore javascript-typescript CodeQL config alongside javascript
Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-08 19:27:01 +00:00
copilot-swe-agent[bot] 54400f8f1c Initial plan 2026-03-08 19:25:17 +00:00
github-actions[bot] 222f3cf4e7 chore(release): update build metadata files [skip ci] 2026-03-08 19:23:38 +00:00
semantic-release fce2b4b01c 0.103.0
Automatically generated by python-semantic-release
2026-03-08 19:23:36 +00:00
Christian Krakau-Louis af696daf55 Merge pull request #558 from christianlouis/copilot/fix-user-specific-tokens
feat(auth): make OAuth credentials user-specific via UserIntegration records
2026-03-08 20:23:18 +01:00
Christian Krakau-Louis 1ce9e97ad0 Merge pull request #559 from christianlouis/copilot/refactor-multi-tenant-polling-engine
feat(tasks): Multi-tenant polling engine for IMAP and watch folder ingestion
2026-03-08 20:23:04 +01:00
Christian Krakau-Louis 52ebbad335 Potential fix for code scanning alert no. 344: Use of a broken or weak cryptographic hashing algorithm on sensitive data
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
2026-03-08 20:22:39 +01:00
github-actions[bot] 4cbda5c228 chore(release): update build metadata files [skip ci] 2026-03-08 18:59:34 +00:00
semantic-release b181799553 0.102.0
Automatically generated by python-semantic-release
2026-03-08 18:59:31 +00:00
Christian Krakau-Louis b7adcb26c7 Merge pull request #557 from christianlouis/copilot/auto-fill-data-for-chat-ticket
feat(support): auto-fill Zammad chat/ticket widgets with user context
2026-03-08 19:59:12 +01:00
copilot-swe-agent[bot] 2f95febf71 fix: correct token_prefix comment to match implementation (12 chars)
Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-08 18:54:55 +00:00
copilot-swe-agent[bot] a9829598c5 docs: add API tokens and webhook ingestion documentation
Update API.md with API token management endpoints, usage examples,
and authentication guide. Update UserGuide.md with webhook ingestion
and API tokens sections.

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-08 18:53:51 +00:00
copilot-swe-agent[bot] fc453be71f docs(config): document per-user IMAP and watch folder integration polling
Update ConfigurationGuide.md to document:
- Per-user IMAP integrations via the Integrations dashboard
- Per-user watch folder integrations with path traversal protection
- Updated unclaimed documents section to clarify attribution behavior

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-08 18:50:49 +00:00
copilot-swe-agent[bot] cfc52fd355 feat(auth): make OAuth credentials user-specific via UserIntegration records
Setup pages now accept an integration_id query param to save OAuth
credentials to the user's personal UserIntegration record instead
of global settings. The integrations dashboard shows an "Authorize"
button for OAuth types (Dropbox, Google Drive, OneDrive) that need
credentials.

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-08 18:48:11 +00:00
copilot-swe-agent[bot] 4f9f458151 test(tasks): add comprehensive tests for multi-tenant polling functions
- Add tests for owner_id passthrough in fetch_attachments_and_enqueue
- Add tests for _pull_user_integration_imap (success, failure, DB error, incomplete config)
- Add tests for _is_safe_watch_path path traversal security
- Add tests for _enqueue_file owner_id forwarding
- Add tests for _scan_user_watch_folder (scan, skip processed, nonexistent dir)
- Add tests for _pull_user_integration_watch_folders (success, unsafe path, failure, DB error)
- Add tests verifying scan_all_watch_folders calls user integration polling
- Add tests verifying pull_all_inboxes calls both legacy and integration polling

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-08 18:43:18 +00:00
copilot-swe-agent[bot] c3bb93c197 feat(api): add personal API tokens and enhance webhook integration UI
- Add ApiToken model with SHA-256 hashed storage and usage tracking
- Create API token CRUD endpoints (POST/GET/DELETE /api/api-tokens/)
- Add Bearer token authentication to require_login decorator
- Exempt Bearer-authenticated requests from CSRF validation
- Add API tokens management page with create/revoke/copy UI
- Enhance webhook integration type with detailed explanation and code snippets
- Add navigation links (desktop + mobile) to API tokens page
- Include 19 tests covering CRUD, auth resolution, and utility functions
- Create migration 024_add_api_tokens

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-08 18:42:55 +00:00
copilot-swe-agent[bot] 73119e0cef feat(auth): save OAuth credentials per-user to UserIntegration records
- Add 'Authorize' button to integration cards for OAuth types without credentials
- Add isOAuthType() helper and update info box in create/edit modal
- Accept integration_id query param in Dropbox, Google Drive, OneDrive setup views
- Store integration_id in sessionStorage on setup pages
- Add per-user flow in OAuth callbacks: PUT credentials to /api/integrations/{id}
- Preserve existing global flow as fallback when no integration_id is present

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-08 18:41:13 +00:00
copilot-swe-agent[bot] 019807d0f5 feat(tasks): refactor IMAP and watch folder polling to support multi-tenant user attribution
- Add owner_id parameter to pull_inbox() and fetch_attachments_and_enqueue()
  to attribute ingested documents to the correct user
- Add _pull_user_integration_imap() to poll IMAP sources from UserIntegration model
- Add _pull_user_integration_watch_folders() to scan watch folders from UserIntegration model
- Add _is_safe_watch_path() for path traversal security on user-configured paths
- Add _scan_user_watch_folder() that passes owner_id to _enqueue_file()
- Update _enqueue_file() to forward owner_id to process_document/convert_to_pdf
- Update celery beat schedule to always enable IMAP and watch folder polling
  (user integrations can exist without system-level config)
- Ensure individual connection failures don't crash the polling loop
- Update existing tests for new function signatures

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-08 18:38:22 +00:00
copilot-swe-agent[bot] 8098856186 test(support): improve Zammad user context tests with meaningful assertions
Enable Zammad settings via mock and verify user data appears in the
rendered HTML. Add tests for chat widget, email-only fallback, and
display_name fallback. Also improve JS variable naming in help.html.

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-08 18:33:39 +00:00
copilot-swe-agent[bot] 209e82f7e5 feat(support): auto-fill Zammad chat and ticket widgets with user context
Pass authenticated user's name, email, and username to Zammad form
and chat widgets. For the ticket form: pre-fill name/email fields
and append a User Context metadata block to the ticket body via
$.ajaxPrefilter. For the chat widget: pass name/email to the
ZammadChat constructor.

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-08 18:27:14 +00:00
copilot-swe-agent[bot] ba387b8e8b fix(ui): correct OAuth setup page links in integrations dashboard
The oauthLink() function in integrations_dashboard.html returned
/dropbox, /google-drive, /onedrive which are not valid routes
(404). The actual view routes are /dropbox-setup,
/google-drive-setup, /onedrive-setup.

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-08 18:21:52 +00:00
copilot-swe-agent[bot] 906c1ca246 Initial plan 2026-03-08 18:20:34 +00:00
copilot-swe-agent[bot] 322aef98ae Initial plan 2026-03-08 18:18:34 +00:00
copilot-swe-agent[bot] dd3002d2c8 Initial plan 2026-03-08 18:17:53 +00:00
copilot-swe-agent[bot] d8d0aedd9e Initial plan 2026-03-08 18:14:30 +00:00
github-actions[bot] 107ac9f4e9 chore(release): update build metadata files [skip ci] 2026-03-08 18:08:32 +00:00
semantic-release 9b4d8e2bfe 0.101.0
Automatically generated by python-semantic-release
2026-03-08 18:08:29 +00:00
Christian Krakau-Louis f714a9ed31 Merge pull request #556 from christianlouis/copilot/feature-universal-dashboard
feat(ui): Universal Sources & Destinations integrations dashboard
2026-03-08 19:08:11 +01:00
github-actions[bot] 13d2230089 chore(release): update build metadata files [skip ci] 2026-03-08 17:58:24 +00:00
semantic-release d846704aa6 0.100.0
Automatically generated by python-semantic-release
2026-03-08 17:58:21 +00:00
Christian Krakau-Louis 58733d6450 Merge pull request #555 from christianlouis/copilot/restructure-help-page
feat(help): replace MkDocs redirect with user-facing Help Center + Zammad integration
2026-03-08 18:58:02 +01:00
copilot-swe-agent[bot] 0edb94158d feat(ui): replace Email Ingestion nav with unified Integrations link
Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-08 17:41:49 +00:00
copilot-swe-agent[bot] e27bacee3b refactor(ui): address code review - optimize queries, extract helpers, improve error handling
Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-08 17:40:23 +00:00
copilot-swe-agent[bot] 883f123138 fix(security): add SRI to jQuery CDN, escape chat ID, use server-side hostname
- Add integrity/crossorigin attributes to jQuery 3.6.0 CDN script
- Use | int filter on zammad_chat_id to prevent XSS
- Replace request.headers.get('host') with settings.external_hostname
  for canonical URL and Open Graph tags to prevent host-header injection

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-08 17:40:15 +00:00
copilot-swe-agent[bot] 351d6f3f4f fix: update docs endpoint test and add Zammad settings metadata
- Update test_docs_endpoint to use new /admin/api-docs path
- Add SETTING_METADATA entries for zammad_url, zammad_chat_enabled,
  zammad_chat_id, zammad_form_enabled, support_email

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-08 17:37:29 +00:00
copilot-swe-agent[bot] 2c652dcc3e feat(ui): add unified Sources & Destinations integrations dashboard
Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-08 17:34:22 +00:00
copilot-swe-agent[bot] af3eed4040 feat(help): restructure /help as user-facing Help Center with Zammad integration
- Replace MkDocs redirect with a branded, SEO-optimised Help Center template
- Add sections: Quick Start, Sources, Destinations, Workflows, FAQ, Support
- Integrate optional Zammad live-chat widget and ticket form
- Add config settings: ZAMMAD_URL, ZAMMAD_CHAT_ENABLED, ZAMMAD_CHAT_ID,
  ZAMMAD_FORM_ENABLED, SUPPORT_EMAIL
- Move MkDocs developer docs from /help to /developer-docs
- Move interactive API docs (Swagger/ReDoc) to /admin/api-docs and /admin/api-redoc
- Add API Docs and Developer Docs links to Admin menu (desktop + mobile)
- Update navigation Help link from /help/ to /help
- Update .env.demo with Zammad configuration examples
- Document new settings in docs/ConfigurationGuide.md
- Rewrite tests to cover new Help Center behaviour

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-08 17:25:34 +00:00
github-actions[bot] c2c7a73dee chore(release): update build metadata files [skip ci] 2026-03-08 17:12:24 +00:00
copilot-swe-agent[bot] f3627581ab Initial plan 2026-03-08 17:12:22 +00:00
semantic-release 0cda2dafdc 0.99.0
Automatically generated by python-semantic-release
2026-03-08 17:12:21 +00:00
Christian Krakau-Louis a7a807bb11 Merge pull request #551 from christianlouis/copilot/verify-stripe-billing-implementation
fix(billing): cast SQLAlchemy Column[str] to str to resolve mypy typeddict-item error
2026-03-08 18:11:58 +01:00
Christian Krakau-Louis 6cdca24333 Potential fix for code scanning alert no. 332: Information exposure through an exception
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
2026-03-08 18:11:38 +01:00
github-actions[bot] b2196a47be chore(release): update build metadata files [skip ci] 2026-03-08 17:11:35 +00:00
semantic-release 6437364800 0.98.0
Automatically generated by python-semantic-release
2026-03-08 17:11:32 +00:00
Christian Krakau-Louis 81ac695c43 Merge pull request #554 from christianlouis/copilot/add-api-subscription-quota-enforcement
feat(integrations): add subscription quota enforcement, connection test, and quota endpoint
2026-03-08 18:11:15 +01:00
copilot-swe-agent[bot] 78c9df4bb0 Initial plan 2026-03-08 17:11:06 +00:00
copilot-swe-agent[bot] c4b4643f6e fix(integrations): sanitize error messages and add SSRF protection for WebDAV test
Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-08 15:57:27 +00:00
copilot-swe-agent[bot] 71350f2ad6 docs(api): add integrations quota enforcement and test endpoint documentation
Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-08 15:54:45 +00:00
copilot-swe-agent[bot] 35abdb0e69 feat(integrations): add quota enforcement, connection test, and quota endpoint
Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-08 15:45:41 +00:00
copilot-swe-agent[bot] 9627667d82 fix(billing): cast plan.name to str to fix mypy typeddict-item error
Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-08 15:33:57 +00:00
copilot-swe-agent[bot] ca9cf868cf Initial plan 2026-03-08 15:29:08 +00:00
Christian Krakau-Louis 48e3552e1a Potential fix for code scanning alert no. 333: Information exposure through an exception
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
2026-03-08 16:27:53 +01:00
copilot-swe-agent[bot] 002cdf312b fix(billing): address code review - remove duplicate mode logic, accessibility improvements, robust test assertions
Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-08 15:18:53 +00:00
github-actions[bot] 98ae72aef2 chore(release): update build metadata files [skip ci] 2026-03-08 15:14:50 +00:00
semantic-release bf43fc8ed2 0.97.0
Automatically generated by python-semantic-release
2026-03-08 15:14:47 +00:00
copilot-swe-agent[bot] 223539bebb feat(billing): expose Stripe price IDs in plan API, add Stripe Setup Wizard and sync endpoints
Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-08 15:14:42 +00:00
Christian Krakau-Louis 89378b2270 Merge pull request #553 from christianlouis/copilot/restrict-status-page-access
feat(status): restrict status page to admin-only, move to admin menu
2026-03-08 16:14:26 +01:00
github-actions[bot] 68c100fc7e chore(release): update build metadata files [skip ci] 2026-03-08 15:13:54 +00:00
semantic-release 8acf76fe93 0.96.0
Automatically generated by python-semantic-release
2026-03-08 15:13:51 +00:00
Christian Krakau-Louis e05718e702 Merge pull request #550 from christianlouis/copilot/add-database-models-for-multi-tenant
feat(models): Generic UserIntegration model for multi-tenant sources/destinations + encrypt IMAP passwords
2026-03-08 16:13:35 +01:00
Christian Krakau-Louis dcf57b0898 Merge pull request #552 from christianlouis/copilot/fix-create-imap-account-button
fix(ui): IMAP "Add Account" button does nothing
2026-03-08 16:13:21 +01:00
copilot-swe-agent[bot] feea051c4f feat(status): restrict status page to admin-only and move to admin menu
Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-08 15:10:46 +00:00
copilot-swe-agent[bot] 8aa8a93a6a fix(ui): move IMAP modals inside Alpine.js x-data scope so create/edit/delete buttons work
Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-08 15:04:23 +00:00
copilot-swe-agent[bot] 17e77fa65a Initial plan 2026-03-08 15:03:39 +00:00
copilot-swe-agent[bot] 743627ecc2 feat(models): add UserIntegration model, encrypt IMAP passwords, add integrations API
- Add IntegrationDirection/IntegrationType constants and UserIntegration SQLAlchemy model
  with owner_id, direction, integration_type, name, config (JSON), credentials (encrypted),
  is_active, last_used_at, last_error, created_at, updated_at fields
- Fix UserImapAccount password encryption: encrypt_value() on create/update,
  decrypt_value() in IMAP tasks and test-connection endpoint (backward compatible)
- Add Alembic migration 023_add_user_integrations
- Add app/api/integrations.py: CRUD + credentials endpoint with owner-scoped access
- Register integrations router in app/api/__init__.py
- Add 39 tests in tests/test_api_integrations.py covering model, CRUD, encryption

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-08 15:02:38 +00:00
copilot-swe-agent[bot] d7448c5c69 Initial plan 2026-03-08 15:02:13 +00:00
copilot-swe-agent[bot] b6a0648366 Initial plan 2026-03-08 15:01:54 +00:00
copilot-swe-agent[bot] 077672d8c1 Initial plan 2026-03-08 14:46:31 +00:00
github-actions[bot] 211d479eb9 chore(release): update build metadata files [skip ci] 2026-03-08 14:45:47 +00:00
semantic-release 3e36f4c888 0.95.0
Automatically generated by python-semantic-release
2026-03-08 14:45:44 +00:00
Christian Krakau-Louis 6175489ba0 Merge pull request #543 from christianlouis/copilot/add-per-user-imap-ingestion
feat(imap): per-user IMAP ingestion accounts with quota enforcement
2026-03-08 15:45:25 +01:00
Christian Krakau-Louis 94ce9b12f6 Merge pull request #549 from christianlouis/copilot/fix-login-issues
fix(auth): CSRF middleware body consumption causes empty form data on login
2026-03-08 15:45:12 +01:00
copilot-swe-agent[bot] 087f3c10e9 fix(auth): cache request body in CSRF middleware to prevent login failures
Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-08 13:48:17 +00:00
copilot-swe-agent[bot] 5acc55ce92 Initial plan 2026-03-08 13:36:41 +00:00
copilot-swe-agent[bot] 73727dc56a fix(imap): address code review feedback - named constants, error context in JS, security docs
Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-08 13:08:31 +00:00
copilot-swe-agent[bot] 43bcfe5653 feat(imap): add per-user IMAP ingestion accounts with quota enforcement
Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-08 13:01:14 +00:00
github-actions[bot] 555b0a6627 chore(release): update build metadata files [skip ci] 2026-03-08 12:59:58 +00:00
semantic-release ba9f32a039 0.94.0
Automatically generated by python-semantic-release
2026-03-08 12:59:54 +00:00
Christian Krakau-Louis a4e4dbc43e Merge pull request #542 from christianlouis/copilot/debug-local-login-issues
feat(auth): add diagnostic debug logging to local login flow
2026-03-08 13:59:35 +01:00
copilot-swe-agent[bot] c5b330cb4e feat(auth): add comprehensive debug logging for local login failures
Add detailed diagnostic log statements throughout the local authentication
path to help identify why valid local user logins are failing.

Changes:
- app/auth.py: log received username, multi_user_enabled status, LocalUser
  DB lookup result, is_active status, password verification outcome, and
  the specific failure reason (empty_username / wrong_password / no_match)
  at every decision point. Also log form keys and Content-Type header on
  empty-username failures to detect Starlette body-consumption issues.
- app/middleware/csrf.py: log Content-Type, form field names, and whether
  the CSRF token was present in _get_submitted_token() to reveal if the
  middleware is consuming form data before the endpoint can read it.
- app/utils/local_auth.py: verify_password() now logs DEBUG on mismatch
  and WARNING (with exception type) on unexpected bcrypt errors instead
  of silently swallowing exceptions.

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-08 12:48:38 +00:00
copilot-swe-agent[bot] f3c85f73c3 Initial plan 2026-03-08 12:47:14 +00:00
copilot-swe-agent[bot] ec51cb0015 Initial plan 2026-03-08 12:38:23 +00:00
509 changed files with 380092 additions and 3819 deletions
+91
View File
@@ -0,0 +1,91 @@
# =============================================================================
# Docker build context exclusions
# Reducing the build context speeds up builds and prevents unnecessary cache
# invalidation when unrelated files change.
# =============================================================================
# ── Version control ──────────────────────────────────────────────────────────
.git
# ── GitHub / CI tooling ──────────────────────────────────────────────────────
.github
# ── IDE / local dev ──────────────────────────────────────────────────────────
.vscode
.jules
# ── Pre-commit / linting config (not needed at runtime) ──────────────────────
.pre-commit-config.yaml
pyproject.toml
codecov.yml
crowdin.yml
# ── Test suite ───────────────────────────────────────────────────────────────
tests/
requirements-dev.txt
coverage.json
COVERAGE_REPORT.md
.coverage
htmlcov/
.pytest_cache/
junit.xml
coverage.xml
# ── Mobile app / browser extension / legacy placeholder ─────────────────────
# backend/ is an empty placeholder directory not part of the Python application
mobile/
browser-extension/
backend/
# ── Helm charts ──────────────────────────────────────────────────────────────
helm/
# ── Scripts (run before Docker build, output files are COPYd separately) ─────
scripts/
# ── Benchmark and one-off utility scripts ────────────────────────────────────
benchmark_*.py
fix_test*.py
run_fast_tests.sh
# ── Root-level Markdown files (docs/ is kept for docs-builder stage) ─────────
# Note: *.md only matches files at the root level, not inside subdirectories
*.md
# ── Python bytecode / compiled artifacts ─────────────────────────────────────
__pycache__/
*.pyc
*.pyo
*.pyd
*.so
*.egg
*.egg-info/
# ── Virtual environments ──────────────────────────────────────────────────────
.venv/
venv/
env/
# ── Environment / secret files ───────────────────────────────────────────────
.env
.env.local
.env.*.local
# ── Runtime state files ───────────────────────────────────────────────────────
*.log
celerybeat-schedule
celerybeat.pid
# ── Build artifacts ───────────────────────────────────────────────────────────
build/
dist/
.cache/
.mypy_cache/
.ruff_cache/
site/
docs_build/
# ── Editor temp files ─────────────────────────────────────────────────────────
*.swp
*.swo
*~
+187
View File
@@ -3,8 +3,42 @@ WORKDIR=/workdir
DATABASE_URL=sqlite:///./app/database.db
REDIS_URL=redis://redis:6379/0
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
ALLOW_FILE_DELETE=true # Allow deletion of file records
COMPLIANCE_ENABLED=true # Enable compliance templates dashboard (GDPR, HIPAA, SOC 2)
# **Database Connection Pool** (PostgreSQL / MySQL only; ignored for SQLite)
# DB_POOL_SIZE=10 # Persistent connections per worker (default: 10)
# DB_MAX_OVERFLOW=20 # Extra connections under burst (default: 20)
# DB_POOL_TIMEOUT=30 # Seconds to wait for a pool connection (default: 30)
# DB_POOL_RECYCLE=1800 # Recycle connections after N seconds (default: 1800)
# **Per-User Upload Rate Limiting** (health-aware, Redis-backed)
# UPLOAD_RATE_LIMIT_PER_USER=20 # Max uploads per user per window (default: 20)
# UPLOAD_RATE_LIMIT_WINDOW=60 # Sliding window in seconds (default: 60)
# **System Reset / Factory Reset**
# FACTORY_RESET_ON_STARTUP=false # Wipe all user data on every startup (demo/testing only)
# ENABLE_FACTORY_RESET=false # Show the System Reset page in admin UI
# **Logging**
# LOG_LEVEL controls the Python root-logger level.
# Accepted values: DEBUG, INFO, WARNING, ERROR, CRITICAL (default: INFO).
# When DEBUG=true and LOG_LEVEL is not set, the level is automatically lowered to DEBUG.
# LOG_LEVEL=INFO
# DEBUG=false
# Log output format: "text" (human-readable, default) or "json" (structured JSON lines).
# Use "json" when shipping logs to Grafana Loki, Splunk, ELK, Datadog, or any SIEM.
# LOG_FORMAT=text
# Forward application logs to a syslog receiver (in addition to stdout).
# Useful for traditional (non-container) deployments and centralised SIEM ingestion.
# LOG_SYSLOG_ENABLED=false
# LOG_SYSLOG_HOST=localhost
# LOG_SYSLOG_PORT=514
# LOG_SYSLOG_PROTOCOL=udp # udp | tcp
# **UI / Appearance**
# Default colour scheme: system (follow OS), light, or dark
@@ -96,6 +130,22 @@ MAX_UPLOAD_SIZE=1073741824
# Allowed request headers (use * to allow all)
# CORS_ALLOWED_HEADERS=*
# **Audit Logging & SIEM Integration** (see docs/ConfigurationGuide.md#audit-logging)
# Enable HTTP request audit logging middleware
AUDIT_LOGGING_ENABLED=true
# Include client IP in audit log entries (disable for GDPR-sensitive deployments)
AUDIT_LOG_INCLUDE_CLIENT_IP=true
# Forward audit events to an external SIEM system (Syslog, Splunk, Logstash, Grafana, etc.)
# AUDIT_SIEM_ENABLED=false
# AUDIT_SIEM_TRANSPORT=syslog # syslog | http
# AUDIT_SIEM_SYSLOG_HOST=localhost
# AUDIT_SIEM_SYSLOG_PORT=514
# AUDIT_SIEM_SYSLOG_PROTOCOL=udp # udp | tcp
# AUDIT_SIEM_HTTP_URL= # e.g. https://splunk:8088/services/collector/event
# AUDIT_SIEM_HTTP_TOKEN= # Bearer / HEC token
# AUDIT_SIEM_HTTP_CUSTOM_HEADERS= # Comma-separated Key:Value pairs
# **Rate Limiting** (see SECURITY_AUDIT.md and docs/API.md)
# Protects against DoS attacks and API abuse by limiting request rates per IP/user
# Enabled by default - highly recommended for production
@@ -125,6 +175,16 @@ AUTH_ENABLED=true
# Generate a secure random string, for example:
# python -c "import secrets; print(secrets.token_hex(32))"
SESSION_SECRET=b39fd43f68d0491ca942f28a16e484b1e763fe9accf4445ca2669a5f3b179eb4
# Session lifetime in days (default: 30). Common values: 30, 60, 90.
# Determines how long a user stays logged in before needing to re-authenticate.
# SESSION_LIFETIME_DAYS=30
# Override with a custom value (takes precedence over SESSION_LIFETIME_DAYS):
# SESSION_LIFETIME_CUSTOM_DAYS=
# Time-to-live in seconds for QR code login challenges (default: 120 = 2 minutes).
# QR_LOGIN_CHALLENGE_TTL_SECONDS=120
ADMIN_USERNAME=admin
ADMIN_PASSWORD=your_secure_password
ADMIN_GROUP_NAME=admin
@@ -158,6 +218,33 @@ AUTHENTIK_CLIENT_SECRET=<yourAuthentikClientSecret>
AUTHENTIK_CONFIG_URL=<ConfigUrlOfYourApp, e.g. https://authentik.example.com/application/o/docuelevate/.well-known/openid-configuration>
OAUTH_PROVIDER_NAME="Authentik SSO"
# **Social Login Providers**
# Enable one or more social login providers to let users sign in with existing accounts.
# Each provider requires separate OAuth credentials. See docs/SocialLoginSetup.md for details.
# Google Sign-In (https://console.cloud.google.com/apis/credentials)
# SOCIAL_AUTH_GOOGLE_ENABLED=false
# SOCIAL_AUTH_GOOGLE_CLIENT_ID=your-google-client-id.apps.googleusercontent.com
# SOCIAL_AUTH_GOOGLE_CLIENT_SECRET=your-google-client-secret
# Microsoft Sign-In / Azure AD (https://portal.azure.com/#blade/Microsoft_AAD_RegisteredApps)
# SOCIAL_AUTH_MICROSOFT_ENABLED=false
# SOCIAL_AUTH_MICROSOFT_CLIENT_ID=your-microsoft-application-id
# SOCIAL_AUTH_MICROSOFT_CLIENT_SECRET=your-microsoft-client-secret
# SOCIAL_AUTH_MICROSOFT_TENANT=common # common | organizations | consumers | <tenant-id>
# Apple Sign-In (https://developer.apple.com/account/resources)
# SOCIAL_AUTH_APPLE_ENABLED=false
# SOCIAL_AUTH_APPLE_CLIENT_ID=com.example.docuelevate
# SOCIAL_AUTH_APPLE_TEAM_ID=ABCDE12345
# SOCIAL_AUTH_APPLE_KEY_ID=FGHIJ67890
# SOCIAL_AUTH_APPLE_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----"
# Dropbox Sign-In (https://www.dropbox.com/developers/apps)
# SOCIAL_AUTH_DROPBOX_ENABLED=false
# SOCIAL_AUTH_DROPBOX_CLIENT_ID=your-dropbox-app-key
# SOCIAL_AUTH_DROPBOX_CLIENT_SECRET=your-dropbox-app-secret
# **AI/ML Services**
# Select your AI provider: openai | azure | anthropic | gemini | ollama | openrouter | portkey | litellm
AI_PROVIDER=openai
@@ -196,6 +283,13 @@ OPENAI_MODEL=gpt-4o-mini
# AZURE_OPENAI_API_VERSION=2024-02-01
# AI_MODEL=gpt-4o # deployment name in Azure
# **Document Translation**
# After processing, documents whose detected language differs from the default
# target language are automatically translated. Only the original and this
# default-language version are persisted; other translations are on-the-fly.
# Users can override this in their profile settings.
# DEFAULT_DOCUMENT_LANGUAGE=en
# Azure Document Intelligence (OCR separate from AI provider above)
# **Email Settings (shared SMTP password reset, verification, and system notifications)**
EMAIL_HOST=smtp.example.com
@@ -210,6 +304,7 @@ EMAIL_SENDER=DocuElevate System <docuelevate@example.com>
# These settings are intentionally separate from the shared EMAIL_* settings above.
# Configuring EMAIL_HOST for password reset / notifications does NOT automatically
# enable the email destination you must set DEST_EMAIL_HOST to activate it.
# DEST_EMAIL_ENABLED=true # Set to false to disable email delivery without removing credentials
DEST_EMAIL_HOST=smtp.example.com
DEST_EMAIL_PORT=587
DEST_EMAIL_USERNAME=docuelevate@example.com
@@ -294,8 +389,15 @@ IMAP2_DELETE_AFTER_PROCESS=false
# Use for pre-production instances that share a mailbox with production.
IMAP_READONLY_MODE=false
# Controls which attachment types are ingested from IMAP emails.
# 'documents_only' (default) PDFs and office files only; images are skipped.
# 'all' all supported file types including images.
# Per-user IMAP accounts can override this global default.
IMAP_ATTACHMENT_FILTER=documents_only
# **Storage/Document Services**
# Amazon S3
# S3_ENABLED=true # Set to false to disable S3 uploads without removing credentials
AWS_REGION=us-east-1
AWS_ACCESS_KEY_ID=AKIAIOSFODNN7EXAMPLE
AWS_SECRET_ACCESS_KEY=wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY
@@ -305,12 +407,14 @@ S3_STORAGE_CLASS=STANDARD
S3_ACL=private
# NextCloud
# NEXTCLOUD_ENABLED=true # Set to false to disable NextCloud uploads without removing credentials
NEXTCLOUD_UPLOAD_URL=https://nextcloud.example.com/remote.php/dav/files/<USERNAME>
NEXTCLOUD_FOLDER="<NEXTCLOUD_FOLDER_PATH>"
NEXTCLOUD_USERNAME=<NEXTCLOUD_USERNAME>
NEXTCLOUD_PASSWORD=<NEXTCLOUD_PASSWORD>
# Paperless-ngx
# PAPERLESS_ENABLED=true # Set to false to disable Paperless uploads without removing credentials
PAPERLESS_HOST=https://paperless.example.com
PAPERLESS_NGX_API_TOKEN=<PAPERLESS_API_TOKEN>
# Optional: Name of the custom field in Paperless-ngx to store the "absender" (sender) value
@@ -327,12 +431,14 @@ PAPERLESS_NGX_API_TOKEN=<PAPERLESS_API_TOKEN>
# PAPERLESS_CUSTOM_FIELDS_MAPPING=
# Dropbox
# DROPBOX_ENABLED=true # Set to false to disable Dropbox uploads without removing credentials
DROPBOX_APP_KEY=<DROPBOX_APP_KEY>
DROPBOX_APP_SECRET=<DROPBOX_APP_SECRET>
DROPBOX_REFRESH_TOKEN=<DROPBOX_REFRESH_TOKEN>
DROPBOX_FOLDER="/Documents/Uploads"
# Google Drive
# GOOGLE_DRIVE_ENABLED=true # Set to false to disable Google Drive uploads without removing credentials
# Service Account Method:
GOOGLE_DRIVE_CREDENTIALS_JSON={"type":"service_account","project_id":"your-project","private_key_id":"key-id","private_key":"-----BEGIN PRIVATE KEY-----\nYOUR_PRIVATE_KEY\n-----END PRIVATE KEY-----\n","client_email":"service-account@project.iam.gserviceaccount.com","client_id":"client-id","auth_uri":"https://accounts.google.com/o/oauth2/auth","token_uri":"https://oauth2.googleapis.com/token","auth_provider_x509_cert_url":"https://www.googleapis.com/oauth2/v1/certs","client_x509_cert_url":"https://www.googleapis.com/robot/v1/metadata/x509/service-account%40project.iam.gserviceaccount.com"}
GOOGLE_DRIVE_FOLDER_ID=<YOUR_FOLDER_ID>
@@ -345,13 +451,24 @@ GOOGLE_DRIVE_CLIENT_SECRET=your-oauth-client-secret # Required for OAuth method
GOOGLE_DRIVE_REFRESH_TOKEN=your-oauth-refresh-token # Required for OAuth method
# OneDrive
# ONEDRIVE_ENABLED=true # Set to false to disable OneDrive uploads without removing credentials
ONEDRIVE_CLIENT_ID=your-client-id
ONEDRIVE_CLIENT_SECRET=your-client-secret
ONEDRIVE_TENANT_ID=common
ONEDRIVE_REFRESH_TOKEN=your-refresh-token
ONEDRIVE_FOLDER_PATH=Documents/Uploads
# SharePoint
SHAREPOINT_CLIENT_ID=your-client-id
SHAREPOINT_CLIENT_SECRET=your-client-secret
SHAREPOINT_TENANT_ID=common
SHAREPOINT_REFRESH_TOKEN=your-refresh-token
SHAREPOINT_SITE_URL=https://tenant.sharepoint.com/sites/sitename
SHAREPOINT_DOCUMENT_LIBRARY=Documents
SHAREPOINT_FOLDER_PATH=Uploads
# WebDAV
# WEBDAV_ENABLED=true # Set to false to disable WebDAV uploads without removing credentials
WEBDAV_URL=https://webdav.example.com/path
WEBDAV_USERNAME=webdav_user
WEBDAV_PASSWORD=your_secure_webdav_password
@@ -359,6 +476,7 @@ WEBDAV_FOLDER=/Documents/Uploads
WEBDAV_VERIFY_SSL=True
# FTP
# FTP_ENABLED=true # Set to false to disable FTP uploads without removing credentials
# Security Note: FTP_USE_TLS=True is strongly recommended for secure connections
# Set FTP_ALLOW_PLAINTEXT=False in production to prevent unencrypted FTP
FTP_HOST=ftp.example.com
@@ -370,6 +488,7 @@ FTP_USE_TLS=True
FTP_ALLOW_PLAINTEXT=True
# SFTP
# SFTP_ENABLED=true # Set to false to disable SFTP uploads without removing credentials
# Security Note: Host key verification is enabled by default (False)
# Only set to True in development/testing environments if needed
# When false, configure SSH known_hosts for proper host key verification
@@ -382,6 +501,16 @@ SFTP_PASSWORD=your_secure_sftp_password
SFTP_FOLDER=/Documents/Uploads
SFTP_DISABLE_HOST_KEY_VERIFICATION=False # Default is False (secure); set to True only for testing
# iCloud Drive
# ICLOUD_ENABLED=true # Set to false to disable iCloud uploads without removing credentials
# Requires an Apple ID with iCloud Drive enabled.
# For accounts with two-factor authentication (most accounts), generate an
# app-specific password at https://appleid.apple.com/account/manage
ICLOUD_USERNAME=your_apple_id@example.com
ICLOUD_PASSWORD=your-app-specific-password
ICLOUD_FOLDER=Documents/Uploads
# ICLOUD_COOKIE_DIRECTORY=/path/to/cookie/dir # Optional: defaults to ~/.pyicloud
# **HTTP Request Settings**
# Timeout for HTTP requests - set higher to handle large PDF files (up to 1GB)
HTTP_REQUEST_TIMEOUT=120 # Timeout in seconds (default: 120 for large file operations)
@@ -478,3 +607,61 @@ EMBEDDING_MODEL=text-embedding-3-small
# Maximum tokens to send to the embedding model. Set below the model's
# context window (e.g. 8000 for an 8192-token model).
EMBEDDING_MAX_TOKENS=8000
# **Support / Help Center Zammad Integration**
# Base URL of your Zammad instance (required for chat and ticket form).
# ZAMMAD_URL=https://zammad.example.com
# Show a live-chat widget on the Help Center page (requires an online Zammad agent).
# ZAMMAD_CHAT_ENABLED=false
# Zammad chat topic ID (see Zammad → Channels → Chat → Topics).
# ZAMMAD_CHAT_ID=1
# Show a "Submit a Ticket" feedback form on the Help Center page.
# ZAMMAD_FORM_ENABLED=false
# Support e-mail address displayed on the Help Center page.
# SUPPORT_EMAIL=support@example.com
# **Observability Sentry Error & Performance Monitoring**
# Sentry DSN obtain from https://sentry.io (Project → Settings → Client Keys).
# Leave commented out (or set to empty) to disable Sentry entirely.
# SENTRY_DSN=https://<key>@o<org>.ingest.sentry.io/<project>
#
# Environment label shown in the Sentry dashboard (e.g. development / staging / production).
# SENTRY_ENVIRONMENT=production
#
# Fraction of requests to capture for performance tracing (0.01.0).
# 0.0 disables tracing; 1.0 captures every request. Default: 0.1 (10 %).
# SENTRY_TRACES_SAMPLE_RATE=0.1
#
# Fraction of profiled transactions to send to Sentry (0.01.0).
# Profiling is only active when SENTRY_TRACES_SAMPLE_RATE > 0. Default: 0.0 (disabled).
# SENTRY_PROFILES_SAMPLE_RATE=0.0
#
# Attach PII (IP addresses, user agents) to Sentry events.
# Disable (default) to stay GDPR/CCPA compliant.
# 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**
# Push notifications are delivered via Expo's push notification service
# (https://expo.dev/notifications) which routes to APNs (iOS) and FCM (Android).
# No additional credentials are required on the server side.
# The mobile app registers its Expo push token via POST /api/mobile/register-device.
#
# To use native FCM/APNs directly (without Expo relay), replace the
# send_expo_push_notification function in app/utils/push_notification.py.
+7
View File
@@ -163,6 +163,13 @@ pytest --tb=short -q
- Keep JavaScript minimal - prefer server-side rendering
- Follow existing template structure and patterns
### Internationalization (i18n) & Localization (l10n)
- **Always** use the `_("key")` helper in Jinja2 templates and `translate("key", locale)` in Python for every user-visible string — never hardcode UI text.
- **Only add new keys to `frontend/translations/en.json`** — that is the one and only file you must touch when introducing new UI strings.
- Do **not** manually edit any non-English translation file (`de.json`, `fr.json`, etc.). An external automation script syncs all other language files from `en.json` automatically.
- Key naming convention: `<section>.<descriptor>` in snake_case, e.g. `language.search_placeholder`, `nav.help`, `common.cancel`.
- The `test_all_languages_have_same_keys` check has been intentionally removed — key completeness across locales is enforced by the external sync script, not by the test suite.
### Testing
- Write tests in `tests/` directory, mirroring `app/` structure
- Use pytest markers: `@pytest.mark.unit`, `@pytest.mark.integration`, etc.
+14 -1
View File
@@ -17,6 +17,7 @@ concurrency:
env:
IMAGE_NAME: christianlouis/docuelevate
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
jobs:
# ══════════════════════════════════════════════════════════════════════════
@@ -43,6 +44,18 @@ jobs:
- run: ruff check app/ tests/
- run: ruff format --check app/ tests/
migration-chain:
name: Alembic Migration Chain Check
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.11"
- name: Validate migration chain
run: python scripts/check_alembic_migrations.py
html-lint:
name: HTML Accessibility Lint
runs-on: ubuntu-latest
@@ -137,7 +150,7 @@ jobs:
build:
name: Build & Push Docker Image
runs-on: ubuntu-latest
needs: [run-tests, mypy, dependency-scan, html-lint]
needs: [run-tests, mypy, dependency-scan, html-lint, migration-chain]
if: github.event_name == 'push'
steps:
- name: Checkout Code
+5
View File
@@ -8,6 +8,9 @@ on:
schedule:
- cron: '37 1 * * 1'
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
jobs:
analyze:
name: Analyze (${{ matrix.language }})
@@ -26,6 +29,8 @@ jobs:
build-mode: none
- language: javascript
build-mode: none
- language: javascript-typescript
build-mode: none
- language: python
build-mode: none
steps:
+3
View File
@@ -12,6 +12,9 @@ permissions:
pull-requests: write
packages: write
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
jobs:
release:
name: Semantic Release
+3
View File
@@ -16,6 +16,9 @@ permissions:
contents: write
pull-requests: write
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
jobs:
ruff-auto-fix:
name: Auto-fix Ruff Issues
+3
View File
@@ -200,3 +200,6 @@ cython_debug/
# Build metadata files - generated at build time
GIT_SHA
RUNTIME_INFO
# Frontend build tooling
frontend/node_modules/
+3
View File
@@ -0,0 +1,3 @@
[submodule "vendor/embed-pdf-viewer"]
path = vendor/embed-pdf-viewer
url = https://github.com/embedpdf/embed-pdf-viewer.git
+8
View File
@@ -0,0 +1,8 @@
## 2026-03-20 - Safe Path Traversal Prevention in Low-Level Utilities
**Vulnerability:** The generic file utility `hash_file` in `app/utils/file_operations.py` accepted any file path and was vulnerable to reading arbitrary files via path traversal (e.g., `../../../etc/passwd`) or absolute paths if an attacker could control the `filepath` argument.
**Learning:** Naively checking for `".." in path` breaks legitimate relative paths used internally by the application. Blocking absolute paths entirely also breaks functionality. Input validation should occur at the API boundary, but for defense-in-depth, low-level utilities must enforce expected boundaries (e.g., the application's `workdir`).
**Prevention:** Use `pathlib.Path.resolve()` on both the target path and the allowed base directory (`settings.workdir`). Ensure the resolved target path is strictly within the allowed boundary using `filepath_obj.relative_to(workdir_obj)`, catching the `ValueError` that is raised when the path is out of bounds. This safely blocks both relative traversal attacks and arbitrary absolute paths.
## 2025-05-18 - [SSRF Bypass via DNS Resolution Failure]
**Vulnerability:** The `is_private_ip` function in `app/utils/network.py` failed open (returned `False`) when a hostname could not be resolved (`socket.gaierror`).
**Learning:** This fail-open pattern was originally added to allow external domains in tests, but in production, it created a severe SSRF risk. An attacker could bypass SSRF protections by providing a URL that fails to resolve during the security check but resolves later (DNS rebinding), or by exploiting internal routing behaviors via unresolvable addresses.
**Prevention:** Always fail securely in network authorization functions. If a domain cannot be resolved to verify its safety, the request must be blocked (`return True` / default-deny). Tests should mock DNS resolution correctly instead of compromising production security logic.
+10
View File
@@ -48,6 +48,16 @@ repos:
.env.demo
)$
# Alembic migration chain validation
- repo: local
hooks:
- id: check-alembic-migrations
name: Check Alembic migration chain
entry: python scripts/check_alembic_migrations.py
language: python
pass_filenames: false
files: ^migrations/versions/.*\.py$
# Conventional commits validation
- repo: https://github.com/compilerla/conventional-pre-commit
rev: v3.0.0
+1 -1
View File
@@ -1 +1 @@
2026-03-08T12:15:34Z
2026-03-23T14:11:22Z
+2546
View File
File diff suppressed because it is too large Load Diff
+52 -19
View File
@@ -1,14 +1,47 @@
# Use multi-stage build for a smaller final image
FROM python:3.14.1 AS builder
# syntax=docker/dockerfile:1
WORKDIR /app
# ── Stage 1: Python dependency builder ──────────────────────────────────────
# Use the same slim variant as the runtime to keep Python versions in sync.
# build-essential + libffi-dev cover the few packages (e.g. cryptography) that
# need a C compiler; they are discarded after this stage.
FROM python:3.14.3-slim AS builder
# Copy requirements first for better layer caching
COPY requirements.txt /app/
RUN pip install --no-cache-dir -r requirements.txt
WORKDIR /build
# ── Documentation build stage ───────────────────────────────────────────────
FROM python:3.14.1-slim AS docs-builder
RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential \
libffi-dev \
&& rm -rf /var/lib/apt/lists/*
# Create an isolated virtual environment so only installed packages are copied
# to the runtime image (no pip, setuptools, or other builder artefacts).
RUN python -m venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH" \
PYTHONDONTWRITEBYTECODE=1 \
PIP_NO_CACHE_DIR=1
COPY requirements.txt /build/
RUN pip install --no-cache-dir -r requirements.txt \
# Remove bytecode and cache to keep the venv lean
&& find /opt/venv -type f -name "*.pyc" -delete \
&& find /opt/venv -type d -name "__pycache__" -exec rm -rf {} + 2>/dev/null || true
# ── 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
WORKDIR /docs
@@ -23,14 +56,13 @@ COPY mkdocs.yml /docs/mkdocs.yml
# Build the static documentation site
RUN mkdocs build --config-file /docs/mkdocs.yml --site-dir /docs/docs_build
# Second stage for the actual runtime
# ── Stage 4: Runtime image ───────────────────────────────────────────────────
FROM python:3.14.3-slim
WORKDIR /app
# Copy installed packages from builder stage
COPY --from=builder /usr/local/lib/python3.14/site-packages /usr/local/lib/python3.14/site-packages
COPY --from=builder /usr/local/bin /usr/local/bin
# Copy only the pre-built virtual environment from the builder
COPY --from=builder /opt/venv /opt/venv
# Install system-level OCR tools required for local OCR workflows:
# tesseract-ocr OCR engine used by pytesseract and ocrmypdf
@@ -49,6 +81,8 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
# Copy application code
COPY ./app /app/app
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 ./alembic.ini /app/alembic.ini
COPY ./LICENSE /app/LICENSE
@@ -62,15 +96,14 @@ COPY ./RUNTIME_INFO /app/RUNTIME_INFO
# Copy the pre-built MkDocs documentation site (served at /help)
COPY --from=docs-builder /docs/docs_build /app/docs_build
# Create runtime_info directory
RUN mkdir -p /app/runtime_info
# Create necessary directories
RUN mkdir -p /workdir
# Create necessary runtime directories in a single layer
RUN mkdir -p /app/runtime_info /workdir
# Set environment variables
ENV PYTHONPATH=/app
ENV PYTHONUNBUFFERED=1
ENV PATH="/opt/venv/bin:$PATH" \
PYTHONPATH=/app \
PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1
# Expose the port the app runs on
EXPOSE 8000
+35 -13
View File
@@ -1,13 +1,31 @@
# syntax=docker/dockerfile:1
# Local development Dockerfile (avoids CI-only build metadata files)
FROM python:3.14.1 AS builder
WORKDIR /app
# ── Stage 1: Python dependency builder ──────────────────────────────────────
FROM python:3.14.3-slim AS builder
COPY requirements.txt /app/
RUN pip install --no-cache-dir -r requirements.txt
WORKDIR /build
# ── Documentation build stage ───────────────────────────────────────────────
FROM python:3.14.1-slim AS docs-builder
RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential \
libffi-dev \
&& rm -rf /var/lib/apt/lists/*
# Create an isolated virtual environment
RUN python -m venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH" \
PYTHONDONTWRITEBYTECODE=1 \
PIP_NO_CACHE_DIR=1
COPY requirements.txt /build/
RUN pip install --no-cache-dir -r requirements.txt \
&& find /opt/venv -type f -name "*.pyc" -delete \
&& find /opt/venv -type d -name "__pycache__" -exec rm -rf {} + 2>/dev/null || true
# ── Stage 2: Documentation builder ──────────────────────────────────────────
FROM python:3.14.3-slim AS docs-builder
WORKDIR /docs
@@ -19,23 +37,25 @@ COPY mkdocs.yml /docs/mkdocs.yml
RUN mkdocs build --config-file /docs/mkdocs.yml --site-dir /docs/docs_build
FROM python:3.14.1-slim
# ── Stage 3: Runtime image ───────────────────────────────────────────────────
FROM python:3.14.3-slim
WORKDIR /app
COPY --from=builder /usr/local/lib/python3.14/site-packages /usr/local/lib/python3.14/site-packages
COPY --from=builder /usr/local/bin /usr/local/bin
COPY --from=builder /opt/venv /opt/venv
# Install system-level OCR tools required for local OCR workflows:
# tesseract-ocr OCR engine used by pytesseract and ocrmypdf
# ghostscript required by ocrmypdf for PDF/PS operations
# poppler-utils provides pdfinfo/pdftoppm used by pdf2image
# unpaper optional deskewing pre-processor used by ocrmypdf
# wget used by ocr_language_manager to download tessdata files
RUN apt-get update && apt-get install -y --no-install-recommends \
tesseract-ocr \
ghostscript \
poppler-utils \
unpaper \
wget \
&& apt-get clean && rm -rf /var/lib/apt/lists/*
COPY ./app /app/app
@@ -53,11 +73,13 @@ COPY --from=docs-builder /docs/docs_build /app/docs_build
RUN echo "local" > /app/GIT_SHA \
&& echo "local" > /app/RUNTIME_INFO
RUN mkdir -p /app/runtime_info
RUN mkdir -p /workdir
# Create necessary runtime directories in a single layer
RUN mkdir -p /app/runtime_info /workdir
ENV PYTHONPATH=/app
ENV PYTHONUNBUFFERED=1
ENV PATH="/opt/venv/bin:$PATH" \
PYTHONPATH=/app \
PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1
EXPOSE 8000
+1 -1
View File
@@ -1 +1 @@
640662e
34457f9
+203 -97
View File
@@ -24,121 +24,154 @@
</div>
<div align="center">
<a href="https://www.docuelevate.org"><img src="frontend/static/hero.png" alt="DocuElevate Logo" width="80%" /></a>
<a href="https://www.docuelevate.org"><img src="frontend/static/hero.png" alt="DocuElevate Hero" width="80%" /></a>
</div>
## Overview
DocuElevate automates the handling, extraction, and processing of documents using a variety of services, including:
DocuElevate is an intelligent document processing system that automates the ingestion, OCR, AI-powered metadata extraction, and distribution of documents. It supports a wide range of AI providers, OCR engines, and cloud storage destinations out of the box.
- **AI Provider** (pluggable OpenAI, Anthropic, Gemini, Ollama, OpenRouter, Portkey, and more) for metadata extraction and text refinement.
- **Dropbox**, **Nextcloud**, and **Google Drive** for file storage and uploads.
- **Paperless NGX** for document indexing and management.
- **Azure Document Intelligence** for OCR on PDFs.
- **Gotenberg** for file-to-PDF conversions.
- **Authentik** for authentication and user management.
**Key capabilities:**
It is designed for flexibility and configurability through environment variables, making it easily customizable for different workflows. The system can fetch documents from multiple IMAP mailboxes, process them (OCR, metadata extraction, PDF conversion), and store them in the desired destinations.
- **AI-Powered Metadata Extraction** — pluggable AI providers including OpenAI, Anthropic Claude, Google Gemini, Ollama (local), OpenRouter, Portkey, and Azure OpenAI via LiteLLM
- **Multi-Engine OCR** — Azure Document Intelligence, Tesseract, EasyOCR, Mistral OCR, Google Cloud Document AI, and AWS Textract with configurable merge strategies
- **12 Storage Destinations** — Dropbox, Google Drive, OneDrive, Amazon S3, Nextcloud, WebDAV, FTP, SFTP, iCloud Drive, Email (SMTP), Paperless-ngx, and Rclone
- **Multi-Channel Ingestion** — web upload, browser extension, mobile app, CLI, REST API, IMAP email, and watched folders (local, cloud, FTP/SFTP)
- **Processing Pipelines** — customizable multi-step workflows with conditional routing rules
- **Full-Text Search** — powered by Meilisearch for instant document discovery
- **Multi-User with SSO** — local accounts, OAuth2/OIDC (Authentik), and social login (Google, Microsoft, Apple, Dropbox)
The project includes a **UI** for uploading and managing files, and an API documentation page is available at `/docs` (powered by **FastAPI**).
## Documentation Index
- [User Guide](docs/UserGuide.md) - How to use DocuElevate
- [Browser Extension Guide](docs/BrowserExtension.md) - Install and use the browser extension
- [API Documentation](docs/API.md) - API reference
- [Deployment Guide](docs/DeploymentGuide.md) - How to deploy DocuElevate
- [Configuration Guide](docs/ConfigurationGuide.md) - Available configuration options
- [Build Metadata](docs/BuildMetadata.md) - Automated version and build information
- [CI/CD Tools Guide](docs/CIToolsGuide.md) - CI/CD pipeline and tool documentation
- [CI Workflow Guide](docs/CIWorkflow.md) - Detailed workflow documentation
- [Development Guide](CONTRIBUTING.md) - How to contribute to DocuElevate
- [Troubleshooting](docs/Troubleshooting.md) - Common issues and solutions
The project ships with a web UI, a REST + GraphQL API, a CLI tool, a native mobile app (iOS & Android), a browser extension, and Helm charts for Kubernetes deployment.
## Screenshots
<div align="center">
<img src="docs/upload-view.png" alt="DocuElevate Upload Interface" width="80%" />
<p><em>Upload interface for adding new documents</em></p>
<p><em>Upload interface — drag-and-drop file upload with real-time progress</em></p>
<img src="docs/files-view.png" alt="DocuElevate Files View" width="80%" />
<p><em>Files view with processed documents and metadata</em></p>
<p><em>Files view processed documents with AI-extracted metadata</em></p>
<img src="docs/status-view.png" alt="DocuElevate Status View" width="80%" />
<p><em>Status view — system health and service monitoring</em></p>
</div>
> **Note:** Screenshots may not reflect the very latest UI. For the most current look, visit [docuelevate.org](https://www.docuelevate.org).
## Workflow Process
DocuElevate follows a streamlined document processing workflow:
## Workflow
<div align="center">
<img src="docs/workflow-diagram.png" alt="DocuElevate Workflow" width="90%" />
</div>
### Document Ingestion
Documents enter DocuElevate through four possible channels:
1. **Web Upload**: Users manually upload files via the web interface
2. **Browser Extension**: Send files directly from your browser with one click
3. **Email Attachments**: Automatic polling of configured IMAP mailboxes (supports multiple accounts)
4. **API**: Direct programmatic uploads via the REST API
### Ingestion
Documents enter DocuElevate through multiple channels:
| Channel | Description |
|---------|-------------|
| **Web Upload** | Drag-and-drop interface with real-time progress (up to 1 GB per file) |
| **Browser Extension** | Clip web pages or send files from Chrome, Firefox, or Edge |
| **Mobile App** | Capture documents with the device camera or upload from the photo library |
| **CLI** | Batch uploads and scripted workflows via the `docuelevate` command-line tool |
| **REST API** | Programmatic uploads with full API-token authentication |
| **Email (IMAP)** | Automatic polling of multiple mailboxes with attachment filtering |
| **Watched Folders** | Monitor local paths, FTP, SFTP, S3, Dropbox, Google Drive, OneDrive, Nextcloud, or WebDAV for new files |
### Processing Pipeline
Every document goes through the following steps:
1. **PDF Conversion**: Non-PDF files are converted to PDF format using Gotenberg
2. **OCR Processing**: Azure Document Intelligence extracts text from images/scans
3. **Metadata Extraction**: The configured AI provider analyzes document content to identify:
- Document type (invoice, receipt, contract, etc.)
- Key entities (dates, names, amounts, account numbers)
- Important data points specific to the document type
4. **Enrichment**: Metadata is attached to the document in a structured format
Each document passes through a configurable set of steps:
1. **PDF Conversion** — Non-PDF files are converted using Gotenberg, with optional PDF/A archival conversion
2. **OCR** — Text extraction via one or more OCR engines (Azure, Tesseract, EasyOCR, Mistral, Google Document AI, AWS Textract) with configurable merge strategies
3. **AI Metadata Extraction** — The configured AI provider classifies the document and extracts structured metadata (type, dates, amounts, entities)
4. **Enrichment** — Metadata is embedded into the PDF and stored alongside the document
5. **Embedding Generation** — Vector embeddings for similarity search and duplicate detection
Steps can be customized using **Pipelines** and **Routing Rules** for conditional processing.
### Distribution
Processed documents with their metadata can be automatically sent to:
- **Dropbox**: For cloud storage and sharing
- **Nextcloud**: For self-hosted file storage
- **Google Drive**: For Google Workspace integration
- **Paperless-NGX**: For advanced document management with search capabilities
Users can choose to send documents to any combination of these destinations through configuration settings or manual selection.
Processed documents are distributed to any combination of configured destinations:
| Destination | Type |
|------------|------|
| **Dropbox** | Cloud storage |
| **Google Drive** | Cloud storage |
| **OneDrive** | Cloud storage |
| **Amazon S3** | Object storage |
| **Nextcloud** | Self-hosted cloud |
| **WebDAV** | Protocol-based |
| **FTP / SFTP** | File transfer |
| **iCloud Drive** | Apple cloud |
| **Email (SMTP)** | Send as attachment |
| **Paperless-ngx** | Document management system |
| **Rclone** | 70+ cloud providers via Rclone |
## Features
- **Intuitive File Upload**:
- Drag-and-drop file upload on both Upload and Files pages—upload anywhere on the Files page
- Real-time upload progress with validation
- Support for PDF, Office documents, images, and more (up to 500MB per file)
- **Browser Extension**:
- Send files directly from your browser to DocuElevate with one click
- Compatible with Chrome, Firefox, Edge, and other Chromium-based browsers
- Context menu integration for quick access
- See [Browser Extension Guide](docs/BrowserExtension.md) for installation and usage
- **Document Upload & Storage**:
- Manual uploads (via API or UI) to Dropbox, Nextcloud, Google Drive, or Paperless
- **OCR Processing (Azure)**:
- Extract text from scanned PDFs using Azure Document Intelligence
- **Metadata Extraction (AI Provider)**:
- Use any supported AI provider (OpenAI, Anthropic, Gemini, Ollama, etc.) to classify, label, or otherwise enrich the text with structured metadata
- **PDF Conversion (Gotenberg)**:
- Convert non-PDF attachments (e.g., Word docs, images) into PDFs
- **Document Management (Paperless NGX)**:
- Store processed documents and metadata in a Paperless NGX instance
- **IMAP Integration**:
- Fetch documents from multiple mailboxes (including Gmail) and automatically enqueue them for processing
- **Authentication**:
- Secure access to the system using **Authentik** for OAuth2-based login
### Document Processing
- **Multi-engine OCR** with quality checks and configurable merge strategies (AI merge, longest, primary)
- **AI metadata extraction** using any supported provider (OpenAI, Anthropic, Gemini, Ollama, OpenRouter, Portkey, Azure OpenAI)
- **PDF conversion** via Gotenberg with optional PDF/A archival format
- **Duplicate detection** — exact (SHA-256) and near-duplicate (content similarity with vector embeddings)
- **Customizable pipelines** — define multi-step processing workflows with conditional routing rules
## Frameworks Used
### Document Management
- **Full-text search** powered by Meilisearch with saved searches
- **File detail view** with metadata, text preview, processing history, and similarity analysis
- **Shared links** for public document access with expiration controls
- **Bulk operations** — reprocess, delete, or reassign documents in batch
- **FastAPI**: High-performance web framework for APIs.
- **Celery**: Task queue for asynchronous processing.
- **Redis**: Message broker and result backend.
- **SQLAlchemy**: ORM for database interactions.
- **Tailwind CSS**: Utility-first CSS framework.
- **Docker**: Containerization for easy deployment.
### Multi-Channel Ingestion
- **Web UI** — drag-and-drop upload with real-time progress
- **Browser extension** — clip web pages or send files from Chrome, Firefox, Edge ([guide](docs/BrowserExtension.md))
- **Mobile app** — iOS and Android with camera capture, push notifications, and SSO ([guide](docs/MobileApp.md))
- **CLI tool** — batch uploads, downloads, search, and API-token management ([guide](docs/CLIGuide.md))
- **REST API & GraphQL** — full programmatic access with Swagger documentation at `/docs`
- **IMAP email** — poll multiple mailboxes with attachment filtering and auto-processing
- **Watched folders** — local filesystem, FTP, SFTP, and cloud storage providers
### Administration
- **Multi-user mode** with per-user document isolation and ownership
- **Subscription & billing** — Stripe integration with configurable plans and quotas
- **Scheduled jobs** — IMAP polling, watched folder scans, automated backups, uptime monitoring
- **Audit logging** with SIEM integration support
- **Compliance templates** — GDPR, HIPAA, SOC 2
- **Admin dashboard** — user management, queue monitoring, credential management, backup/restore
### Authentication & Security
- **Local accounts** with self-service registration and password reset
- **OAuth2/OIDC** via Authentik or any OIDC provider
- **Social login** — Google, Microsoft, Apple, Dropbox
- **API tokens** for CLI, mobile, and automation access
- **Security headers** — HSTS, CSP, X-Frame-Options, X-Content-Type-Options
- **Rate limiting** with configurable per-endpoint controls
### Notifications
- **100+ notification backends** via Apprise — Discord, Telegram, Slack, Microsoft Teams, Email, webhooks, and more
- **Configurable events** — task failures, credential issues, file processed, user signup, payment issues
- **In-app notification inbox** with per-user preferences
- **Webhooks** — push events to external systems with HMAC signature verification and retry
## Tech Stack
| Component | Technology |
|-----------|-----------|
| **Backend** | FastAPI, Celery, Redis, SQLAlchemy, Alembic |
| **Frontend** | Jinja2, Tailwind CSS |
| **Search** | Meilisearch |
| **Mobile** | React Native (Expo) — iOS & Android |
| **AI** | LiteLLM (OpenAI, Anthropic, Gemini, Ollama, OpenRouter, Portkey) |
| **OCR** | Azure Document Intelligence, Tesseract, EasyOCR, Mistral, Google Doc AI, AWS Textract |
| **PDF** | Gotenberg, pypdf |
| **Auth** | Authlib (OAuth2/OIDC), MSAL, social providers |
| **Infrastructure** | Docker, Docker Compose, Helm/Kubernetes |
| **Docs** | MkDocs Material |
## Quick Start
For detailed installation and deployment instructions, please refer to the [Deployment Guide](docs/DeploymentGuide.md).
For detailed installation and deployment instructions, see the [Deployment Guide](docs/DeploymentGuide.md).
```bash
# Clone the repository
@@ -147,20 +180,96 @@ cd DocuElevate
# Configure environment variables
cp .env.demo .env
# Edit .env with your settings
# Edit .env with your settings (see Configuration Guide for all options)
# Run with Docker Compose
docker-compose up -d
docker compose up -d
```
The API will be available at **`http://localhost:8000`**, and the API documentation is available at **`http://localhost:8000/docs`**.
The web UI is available at **`http://localhost:8000`** and the interactive API documentation at **`http://localhost:8000/docs`**.
### Kubernetes / Helm
```bash
helm repo add docuelevate https://christianlouis.github.io/DocuElevate
helm install docuelevate docuelevate/docuelevate -f values.yaml
```
See the [Kubernetes Deployment Guide](docs/KubernetesDeployment.md) for full details.
## Documentation
### Getting Started
| Guide | Description |
|-------|-------------|
| [Setup Wizard](docs/SetupWizard.md) | Interactive first-run setup |
| [User Guide](docs/UserGuide.md) | How to use DocuElevate |
| [Browser Extension](docs/BrowserExtension.md) | Install and use the browser extension |
| [Mobile App](docs/MobileApp.md) | iOS and Android mobile app |
| [CLI Guide](docs/CLIGuide.md) | Command-line tool for automation |
### How-To Guides
| Guide | Description |
|-------|-------------|
| [How-To Overview](docs/HowToGuides.md) | Index of all how-to guides |
| [Email Ingestion](docs/howto/EmailIngestion.md) | Set up IMAP email polling |
| [Watched Folder](docs/howto/WatchedFolderSetup.md) | Monitor local or remote folders |
| [Mobile Scanning](docs/howto/MobileScanning.md) | Scan documents with your phone |
### Reference
| Guide | Description |
|-------|-------------|
| [API Documentation](docs/API.md) | REST & GraphQL API reference |
| [Configuration Guide](docs/ConfigurationGuide.md) | All environment variables |
| [Configuration Master](docs/ConfigurationMaster.md) | Configuration overview |
| [Settings Management](docs/SettingsManagement.md) | Runtime settings UI |
### Deployment & Operations
| Guide | Description |
|-------|-------------|
| [Deployment Guide](docs/DeploymentGuide.md) | Docker Compose deployment |
| [Kubernetes / Helm](docs/KubernetesDeployment.md) | Kubernetes deployment with Helm charts |
| [Production Readiness](docs/ProductionReadiness.md) | Checklist for production environments |
| [Database Configuration](docs/DatabaseConfiguration.md) | Database setup and migration |
| [Backup & Restore](docs/ConfigurationGuide.md#backup--restore) | Automated backup configuration |
### Storage Integration Setup
| Guide | Description |
|-------|-------------|
| [Dropbox](docs/DropboxSetup.md) | Dropbox OAuth setup |
| [Google Drive](docs/GoogleDriveSetup.md) | Google Drive service account / OAuth |
| [OneDrive](docs/OneDriveSetup.md) | Microsoft OneDrive setup |
| [Amazon S3](docs/AmazonS3Setup.md) | S3 bucket configuration |
| [Authentication](docs/AuthenticationSetup.md) | OAuth2, OIDC, and social login |
| [Notifications](docs/NotificationsSetup.md) | Notification backend setup |
### Security & Compliance
| Guide | Description |
|-------|-------------|
| [Credential Rotation](docs/CredentialRotationGuide.md) | Rotate secrets safely |
| [Licensing Compliance](docs/LicensingCompliance.md) | Dependency licenses |
| [Privacy & GDPR](docs/PrivacyCompliance.md) | Privacy compliance |
### Development
| Guide | Description |
|-------|-------------|
| [Contributing](CONTRIBUTING.md) | Code style, commits, and PR process |
| [Troubleshooting](docs/Troubleshooting.md) | Common issues and solutions |
| [Configuration Troubleshooting](docs/ConfigurationTroubleshooting.md) | Configuration-specific issues |
| [Build Metadata](docs/BuildMetadata.md) | Version and build information |
| [Internationalization](docs/InternationalizationGuide.md) | Translation and localization |
## Development & Testing
### Running Tests
DocuElevate includes comprehensive test coverage. To run tests:
```bash
# Install development dependencies
pip install -r requirements-dev.txt
@@ -175,21 +284,21 @@ pytest --cov=app --cov-report=term-missing
pytest -m unit
```
Tests are automatically configured with the necessary environment variables - **no manual setup required!**
Tests are automatically configured with the necessary environment variables **no manual setup required!**
For detailed testing information, including integration tests with Docker and authentication testing, see the [Contributing Guide](CONTRIBUTING.md#running-tests).
For detailed testing information, see the [Contributing Guide](CONTRIBUTING.md#running-tests).
### Contributing
We welcome contributions! Please see [CONTRIBUTING.md](CONTRIBUTING.md) for:
- Code style guidelines
- Code style guidelines (Ruff for formatting and linting)
- Commit message format (Conventional Commits)
- Testing requirements
- Pull request process
## License
This project is licensed under the Apache License 2.0 - see the [LICENSE](LICENSE) file for details.
This project is licensed under the Apache License 2.0 see the [LICENSE](LICENSE) file for details.
## Third-Party Software
@@ -214,13 +323,10 @@ The following is a summary of the licenses used by our direct dependencies:
| Uvicorn | BSD |
| SQLAlchemy | MIT |
| Pydantic | MIT |
| openai | MIT |
| litellm | MIT |
| pypdf | BSD |
| Requests | Apache 2.0 |
| puremagic | MIT |
| filetype | MIT |
| Dropbox | MIT |
| Dropbox SDK | MIT |
| Azure AI Document Intelligence | MIT |
| Authlib | BSD |
| Starlette | BSD |
@@ -229,15 +335,15 @@ The following is a summary of the licenses used by our direct dependencies:
| Microsoft Graph Core | MIT |
| MSAL | MIT |
| Boto3 | Apache 2.0 |
| Paramiko | LGPL-2.1|
| Paramiko | LGPL-2.1 |
| Apprise | MIT |
| Redis | BSD |
| Gotenberg | MIT |
| Redis (py) | BSD |
| Gotenberg Client | MIT |
| Meilisearch | MIT |
For a comprehensive list of all dependencies and their licenses, run:
```
```bash
pip install pip-licenses
pip-licenses
```
+6 -6
View File
@@ -1,10 +1,10 @@
DocuElevate Build Information
==============================
Version: 0.93.1
Build Date: 2026-03-08T12:15:34Z
Git Commit: 640662e9b265d22885adf9cab5699399624bc0a1
Git Short SHA: 640662e
Version: 0.172.2
Build Date: 2026-03-23T14:11:22Z
Git Commit: 34457f977509ce145b7411e83982a96b0fd0e33e
Git Short SHA: 34457f9
Git Branch: main
Commit Date: 2026-03-08T13:15:14+01:00
Build Timestamp: 2026-03-08T12:15:34Z
Commit Date: 2026-03-23T15:10:59+01:00
Build Timestamp: 2026-03-23T14:11:22Z
==============================
+28 -1
View File
@@ -1,6 +1,6 @@
# Security Audit Report
**Date:** 2026-02-12
**Date:** 2026-03-23
**Status:** Bandit Security Scan Completed - All Critical/High/Medium Issues Resolved
## Executive Summary
@@ -9,6 +9,15 @@ This document tracks security vulnerabilities found in DocuElevate and their rem
## Recent Security Fixes
### Insecure API Endpoint Exposing Integration Credentials ✅ FIXED (2026-03-23)
**Severity:** HIGH
**Issue:** The endpoint `GET /api/integrations/{integration_id}/credentials` exposed integration credentials (e.g. passwords, API keys) in plaintext over the API. Although requiring login, this allowed anyone with an active user session to extract the raw credentials. The frontend used this endpoint for testing integration connections.
**Remediation:**
- Removed the `/credentials` endpoint entirely.
- Added a new `POST /api/integrations/{integration_id}/test` endpoint that securely runs connection tests server-side without returning the decrypted credentials to the client.
### CVE-2023-36464: PyPDF2/pypdf Infinite Loop Vulnerability ✅ FIXED (2026-02-12)
**Severity:** Moderate (CVSS: 5.5)
@@ -861,3 +870,21 @@ Security headers implementation is complete and production-ready. The middleware
---
**Next Audit Due:** 2026-05-07 (Quarterly)
## Per-User IMAP Account Passwords (Added 2026-03-08)
### Known Limitation: Plain-text Password Storage
IMAP account passwords in the `user_imap_accounts` table are stored in plain text in the database.
**Risk:** Anyone with direct database access (DBA, backup access) can read IMAP credentials for all users.
**Mitigations in place:**
- Database itself should be protected with appropriate OS-level file permissions (SQLite) or network ACLs (PostgreSQL/MySQL).
- Passwords are never returned in API responses (the `_to_response` serialiser omits them).
- Only the account owner can read or update their own accounts (ownership enforced at the API layer).
- Passwords are never logged.
**Future improvement:** Encrypt IMAP passwords at rest using `cryptography.fernet` (symmetric encryption with the app's `SESSION_SECRET` as key material). This is tracked as a TODO item in `app/api/imap_accounts.py` and should be implemented before this feature is used in high-security environments.
**Recommended admin action:** Use app-specific passwords (Gmail, Outlook) rather than account passwords where possible, so that compromised IMAP credentials can be revoked without affecting the user's primary account.
+1 -1
View File
@@ -1 +1 @@
0.93.1
0.172.2
+44
View File
@@ -7,28 +7,50 @@ import logging
from fastapi import APIRouter
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.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.backup import router as backup_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.database import router as database_router
from app.api.diagnostic import router as diagnostic_router
from app.api.dropbox import router as dropbox_router
from app.api.duplicates import router as duplicates_router
from app.api.files import router as files_router
from app.api.google_drive import router as google_drive_router
from app.api.i18n import router as i18n_router
from app.api.imap_accounts import router as imap_accounts_router
from app.api.imap_profiles import router as imap_profiles_router
from app.api.integrations import router as integrations_router
from app.api.logs import router as logs_router
from app.api.mobile import router as mobile_router
from app.api.notifications import router as notifications_router
from app.api.onboarding import router as onboarding_router
from app.api.onedrive import router as onedrive_router
from app.api.openai import router as openai_router
from app.api.pipelines import router as pipelines_router
from app.api.plans import router as plans_router
from app.api.process import router as process_router
from app.api.profile import router as profile_router
from app.api.qr_auth import router as qr_auth_router
from app.api.queue import router as queue_router
from app.api.routing_rules import router as routing_rules_router
from app.api.saved_searches import router as saved_searches_router
from app.api.scheduled_jobs import router as scheduled_jobs_router
from app.api.search import router as search_router
from app.api.sessions import router as sessions_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 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.subscriptions import router as subscriptions_router
from app.api.system_reset import router as system_reset_router
from app.api.translation import router as translation_router
from app.api.url_upload import router as url_upload_router
# Import all the individual routers
@@ -43,6 +65,7 @@ router = APIRouter()
# Include all the routers
router.include_router(admin_users_router)
router.include_router(api_tokens_router)
router.include_router(user_router)
router.include_router(backup_router)
router.include_router(files_router)
@@ -60,6 +83,8 @@ router.include_router(search_router)
router.include_router(queue_router)
router.include_router(saved_searches_router)
router.include_router(similarity_router)
router.include_router(shared_links_router)
router.include_router(shared_links_public_router)
router.include_router(duplicates_router)
router.include_router(webhooks_router)
router.include_router(database_router)
@@ -68,3 +93,22 @@ router.include_router(plans_router)
router.include_router(onboarding_router)
router.include_router(billing_router)
router.include_router(pipelines_router)
router.include_router(profile_router)
router.include_router(routing_rules_router)
router.include_router(imap_accounts_router)
router.include_router(imap_profiles_router)
router.include_router(integrations_router)
router.include_router(notifications_router)
router.include_router(scheduled_jobs_router)
router.include_router(audit_logs_router)
router.include_router(i18n_router)
router.include_router(mobile_router)
router.include_router(sessions_router)
router.include_router(qr_auth_router)
router.include_router(compliance_router)
router.include_router(system_reset_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)
+321
View File
@@ -0,0 +1,321 @@
"""API endpoints for managing personal API tokens.
Provides CRUD operations so users can create, list, and revoke tokens
that grant programmatic access to the DocuElevate API (e.g. webhook
uploads, scripted integrations).
Tokens use ``secrets.token_urlsafe`` from the Python standard library
(no extra dependencies) and are prefixed with ``de_`` for easy
identification. Only a PBKDF2-HMAC-SHA256 hash is persisted; the
plaintext is returned exactly once at creation time.
"""
import hashlib
import logging
import secrets
from datetime import datetime, timedelta, timezone
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.database import get_db
from app.models import ApiToken
from app.utils.user_scope import get_current_owner_id
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api-tokens", tags=["api-tokens"])
DbSession = Annotated[Session, Depends(get_db)]
# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------
#: Prefix prepended to every generated token for easy identification.
TOKEN_PREFIX = "de_"
#: Number of random bytes for the token body (32 → 43 URL-safe chars).
TOKEN_BYTES = 32
#: PBKDF2 iteration count for hashing API tokens.
TOKEN_HASH_ITERATIONS = 100_000
#: PBKDF2 salt for API token hashing (not secret, but fixed for determinism).
TOKEN_HASH_SALT = b"api-token-v1"
#: Name prefix used for tokens created by the mobile app flow.
MOBILE_TOKEN_PREFIX = "Mobile App"
# ---------------------------------------------------------------------------
# Auth helper
# ---------------------------------------------------------------------------
def _get_owner_id(request: Request) -> str:
"""Return the current user's owner ID, raising 401 if unauthenticated."""
owner_id = get_current_owner_id(request)
if not owner_id:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated")
return owner_id
CurrentOwner = Annotated[str, Depends(_get_owner_id)]
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def generate_api_token() -> str:
"""Generate a new API token with the ``de_`` prefix.
Returns:
A URL-safe random token string, e.g. ``de_Ab3xY…``.
"""
return TOKEN_PREFIX + secrets.token_urlsafe(TOKEN_BYTES)
def hash_token(token: str) -> str:
"""Return a PBKDF2-HMAC-SHA256 hex digest of *token*.
Args:
token: The plaintext API token.
Returns:
64-character lowercase hex string.
"""
dk = hashlib.pbkdf2_hmac(
"sha256",
token.encode("utf-8"),
TOKEN_HASH_SALT,
TOKEN_HASH_ITERATIONS,
)
return dk.hex()
def _token_to_dict(t: ApiToken) -> dict[str, Any]:
"""Convert an ``ApiToken`` ORM instance to a serialisable dict."""
return {
"id": t.id,
"name": t.name,
"token_prefix": t.token_prefix,
"is_active": t.is_active,
"last_used_at": t.last_used_at,
"last_used_ip": t.last_used_ip,
"created_at": t.created_at,
"revoked_at": t.revoked_at,
"expires_at": t.expires_at,
}
# ---------------------------------------------------------------------------
# Pydantic schemas
# ---------------------------------------------------------------------------
class TokenCreate(BaseModel):
"""Schema for creating a new API token."""
name: str = Field(..., min_length=1, max_length=255, description="Human-readable label for the token")
expires_in_days: int | None = Field(
default=None,
ge=1,
le=3650, # Maximum 10 years; keeps tokens from being effectively permanent while allowing long-lived CI/CD tokens.
description="Optional lifetime in days. If omitted the token never expires.",
)
class TokenResponse(BaseModel):
"""Schema returned when listing tokens (plaintext is never included)."""
id: int
name: str
token_prefix: str
is_active: bool
last_used_at: datetime | None
last_used_ip: str | None
created_at: datetime | None
revoked_at: datetime | None
expires_at: datetime | None
model_config = {"from_attributes": True}
class TokenCreatedResponse(TokenResponse):
"""Schema returned once at creation time — includes the full plaintext token."""
token: str = Field(..., description="The full API token. Store it securely — it will not be shown again.")
# ---------------------------------------------------------------------------
# Endpoints
# ---------------------------------------------------------------------------
@router.post("/", status_code=status.HTTP_201_CREATED, response_model=TokenCreatedResponse)
async def create_token(
body: TokenCreate,
owner_id: CurrentOwner,
db: DbSession,
) -> dict[str, Any]:
"""Create a new personal API token.
The full token is returned **only once** in the response. Subsequent
``GET`` requests will only show the prefix for identification.
"""
plaintext = generate_api_token()
token_hash_value = hash_token(plaintext)
prefix = plaintext[:12] # "de_" prefix + 9 random chars = 12 chars total
expires_at = None
if body.expires_in_days is not None:
expires_at = datetime.now(timezone.utc) + timedelta(days=body.expires_in_days)
db_token = ApiToken(
owner_id=owner_id,
name=body.name,
token_hash=token_hash_value,
token_prefix=prefix,
expires_at=expires_at,
)
try:
db.add(db_token)
db.commit()
db.refresh(db_token)
except Exception:
db.rollback()
raise
logger.info("API token created: id=%s owner=%s name=%r", db_token.id, owner_id, body.name)
return {
"id": db_token.id,
"name": db_token.name,
"token_prefix": db_token.token_prefix,
"is_active": db_token.is_active,
"last_used_at": db_token.last_used_at,
"last_used_ip": db_token.last_used_ip,
"created_at": db_token.created_at,
"revoked_at": db_token.revoked_at,
"expires_at": db_token.expires_at,
"token": plaintext,
}
@router.get("/", response_model=list[TokenResponse])
async def list_tokens(
owner_id: CurrentOwner,
db: DbSession,
) -> list[dict[str, Any]]:
"""List non-mobile API tokens for the authenticated user.
Mobile tokens (whose names start with ``"Mobile App"``) are excluded
from this list; they are managed on the dedicated Devices page via
``GET /api/api-tokens/mobile``.
"""
tokens = (
db.query(ApiToken)
.filter(
ApiToken.owner_id == owner_id,
~ApiToken.name.startswith(MOBILE_TOKEN_PREFIX),
)
.order_by(ApiToken.created_at.desc())
.all()
)
return [_token_to_dict(t) for t in tokens]
@router.get("/mobile", response_model=list[TokenResponse])
async def list_mobile_tokens(
owner_id: CurrentOwner,
db: DbSession,
) -> list[dict[str, Any]]:
"""List mobile API tokens for the authenticated user.
Returns tokens whose names start with ``"Mobile App"`` — these are
created via the mobile SSO flow or QR code login.
"""
tokens = (
db.query(ApiToken)
.filter(
ApiToken.owner_id == owner_id,
ApiToken.name.startswith(MOBILE_TOKEN_PREFIX),
)
.order_by(ApiToken.created_at.desc())
.all()
)
return [_token_to_dict(t) for t in tokens]
@router.delete("/{token_id}", status_code=status.HTTP_200_OK)
async def revoke_or_delete_token(
token_id: int,
owner_id: CurrentOwner,
db: DbSession,
) -> dict[str, str]:
"""Revoke or permanently delete an API token.
* **Active token** soft-revoked: the row is kept for audit purposes
but marked inactive with a ``revoked_at`` timestamp.
* **Already-revoked token** hard-deleted: the row is permanently
removed from the database.
"""
db_token = db.query(ApiToken).filter(ApiToken.id == token_id, ApiToken.owner_id == owner_id).first()
if not db_token:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Token not found")
if db_token.is_active:
# Soft-revoke the active token.
try:
db_token.is_active = False
db_token.revoked_at = datetime.now(timezone.utc)
db.commit()
except Exception:
db.rollback()
raise
logger.info("API token revoked: id=%s owner=%s", token_id, owner_id)
return {"detail": "Token revoked"}
# Hard-delete an already-revoked token.
try:
db.delete(db_token)
db.commit()
except Exception:
db.rollback()
raise
logger.info("API token permanently deleted: id=%s owner=%s", token_id, owner_id)
return {"detail": "Token deleted"}
@router.post("/{token_id}/reactivate", status_code=status.HTTP_200_OK, response_model=TokenResponse)
async def reactivate_token(
token_id: int,
owner_id: CurrentOwner,
db: DbSession,
) -> dict[str, Any]:
"""Reactivate a previously revoked API token.
Clears the ``revoked_at`` timestamp and sets ``is_active`` back to
``True``. The token can be used for authentication again immediately.
If the token had an ``expires_at`` in the past the caller should
consider re-creating a new token instead.
"""
db_token = db.query(ApiToken).filter(ApiToken.id == token_id, ApiToken.owner_id == owner_id).first()
if not db_token:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Token not found")
if db_token.is_active:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Token is already active")
try:
db_token.is_active = True
db_token.revoked_at = None
db.commit()
db.refresh(db_token)
except Exception:
db.rollback()
raise
logger.info("API token reactivated: id=%s owner=%s", token_id, owner_id)
return _token_to_dict(db_token)
+117
View File
@@ -0,0 +1,117 @@
"""
Audit log REST API endpoints.
Provides read-only access to the comprehensive audit log for admin users.
Events are append-only — there are no update or delete endpoints.
"""
import logging
from datetime import datetime
from typing import Annotated, Any
from fastapi import APIRouter, Depends, Query, Request
from sqlalchemy.orm import Session
from app.auth import require_login
from app.database import get_db
from app.utils.audit_service import count_events, query_events
logger = logging.getLogger(__name__)
router = APIRouter()
DbSession = Annotated[Session, Depends(get_db)]
@router.get("/audit-logs")
@require_login
async def list_audit_logs(
request: Request,
db: DbSession,
action: Annotated[str | None, Query(description="Filter by action (exact match)")] = None,
user: Annotated[str | None, Query(description="Filter by username")] = None,
resource_type: Annotated[str | None, Query(description="Filter by resource type")] = None,
severity: Annotated[str | None, Query(description="Filter by severity level")] = None,
since: Annotated[datetime | None, Query(description="Only events at or after this ISO-8601 timestamp")] = None,
until: Annotated[datetime | None, Query(description="Only events at or before this ISO-8601 timestamp")] = None,
limit: Annotated[int, Query(ge=1, le=500, description="Max rows to return")] = 50,
offset: Annotated[int, Query(ge=0, description="Rows to skip for pagination")] = 0,
) -> dict[str, Any]:
"""Return audit log entries with optional filtering and pagination.
Requires authentication. Returns events in reverse chronological order.
"""
entries = query_events(
db,
action=action,
user=user,
resource_type=resource_type,
severity=severity,
since=since,
until=until,
limit=limit,
offset=offset,
)
total = count_events(
db,
action=action,
user=user,
resource_type=resource_type,
severity=severity,
since=since,
until=until,
)
return {
"items": [_serialize(e) for e in entries],
"total": total,
"limit": limit,
"offset": offset,
}
@router.get("/audit-logs/actions")
@require_login
async def list_distinct_actions(
request: Request,
db: DbSession,
) -> list[str]:
"""Return the distinct action values present in the audit log."""
from app.models import AuditLog
rows = db.query(AuditLog.action).distinct().order_by(AuditLog.action).all()
return [r[0] for r in rows]
@router.get("/audit-logs/users")
@require_login
async def list_distinct_users(
request: Request,
db: DbSession,
) -> list[str]:
"""Return the distinct user values present in the audit log."""
from app.models import AuditLog
rows = db.query(AuditLog.user).distinct().order_by(AuditLog.user).all()
return [r[0] for r in rows]
# ------------------------------------------------------------------
# Helpers
# ------------------------------------------------------------------
def _serialize(entry) -> dict[str, Any]:
"""Convert an AuditLog row to a JSON-safe dict."""
import json as _json
return {
"id": entry.id,
"timestamp": entry.timestamp.isoformat() if entry.timestamp else None,
"user": entry.user,
"action": entry.action,
"resource_type": entry.resource_type,
"resource_id": entry.resource_id,
"ip_address": entry.ip_address,
"details": _json.loads(entry.details) if entry.details else None,
"severity": entry.severity,
}
+311
View File
@@ -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,
}
+204 -4
View File
@@ -30,6 +30,7 @@ from app.auth import require_login
from app.config import settings
from app.database import get_db
from app.models import SubscriptionPlan, UserProfile
from app.utils.i18n import translate as _translate
from app.utils.user_scope import get_current_owner_id
logger = logging.getLogger(__name__)
@@ -37,6 +38,7 @@ router = APIRouter(prefix="/billing", tags=["billing"])
_templates_dir = pathlib.Path(__file__).parents[2] / "frontend" / "templates"
_templates = Jinja2Templates(directory=str(_templates_dir))
_templates.env.globals["_"] = lambda key, **kwargs: _translate(key, "en", **kwargs)
def _get_stripe() -> stripe.StripeClient | None:
@@ -166,9 +168,8 @@ async def create_checkout_session(
checkout_session = client.checkout.sessions.create(params=session_params)
logger.info(
"Created Stripe checkout session %s for user %s plan %s",
"Created Stripe checkout session %s for plan %s",
checkout_session.id,
owner_id,
body.plan_id,
)
return {"checkout_url": checkout_session.url, "session_id": checkout_session.id}
@@ -211,7 +212,7 @@ async def create_portal_session(
}
)
logger.info("Created Stripe portal session for user %s", owner_id)
logger.info("Created Stripe portal session for user")
return {"portal_url": portal.url}
@@ -259,7 +260,206 @@ async def stripe_webhook(request: Request, db: Session = Depends(get_db)) -> dic
@require_login
async def billing_success(request: Request) -> Any:
"""Show a success page after a completed Stripe Checkout."""
return _templates.TemplateResponse("billing_success.html", {"request": request})
return _templates.TemplateResponse(request, "billing_success.html")
# ---------------------------------------------------------------------------
# Admin: Stripe status + sync helpers
# ---------------------------------------------------------------------------
def _require_admin(request: Request) -> None:
"""Raise 403 if the current session user is not an admin."""
user = request.session.get("user") or {}
if not user.get("is_admin"):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin access required.")
@router.get("/stripe/status", summary="Check Stripe connection and plan sync status (admin only)")
@require_login
async def stripe_status(request: Request, db: Session = Depends(get_db)) -> dict[str, Any]:
"""Return Stripe connection health and per-plan price-ID sync status.
Returns a JSON object with:
- ``configured``: whether STRIPE_SECRET_KEY is set
- ``connection``: ``"ok"`` or an error string (live/test mode label)
- ``mode``: ``"live"`` | ``"test"`` | ``null``
- ``plans``: list of plan objects with ``plan_id``, ``name``,
``stripe_price_id_monthly``, ``stripe_price_id_yearly``, ``synced``
Raises:
403: Not admin.
503: Stripe not configured.
"""
_require_admin(request)
if not settings.stripe_secret_key:
return {
"configured": False,
"connection": "not_configured",
"mode": None,
"plans": [],
}
client = _get_stripe()
# Probe Stripe with a lightweight account fetch
mode: str | None = None
connection_status = "ok"
try:
account = client.accounts.retrieve("me") # type: ignore[arg-type]
livemode = getattr(account, "livemode", None)
if livemode is True:
mode = "live"
elif livemode is False:
mode = "test"
else:
mode = "test" if settings.stripe_secret_key.startswith("sk_test_") else "live"
except Exception:
logger.exception("Stripe connection check failed")
connection_status = "error"
mode = "test" if settings.stripe_secret_key.startswith("sk_test_") else "live"
plans = db.query(SubscriptionPlan).order_by(SubscriptionPlan.sort_order).all()
plan_statuses = []
for plan in plans:
has_monthly = bool(plan.stripe_price_id_monthly)
has_yearly = bool(plan.stripe_price_id_yearly)
is_paid = plan.price_monthly > 0 or plan.price_yearly > 0
synced = (not is_paid) or (has_monthly and (not plan.price_yearly or has_yearly))
plan_statuses.append(
{
"plan_id": plan.plan_id,
"name": plan.name,
"price_monthly": plan.price_monthly,
"price_yearly": plan.price_yearly,
"stripe_price_id_monthly": plan.stripe_price_id_monthly,
"stripe_price_id_yearly": plan.stripe_price_id_yearly,
"synced": synced,
}
)
return {
"configured": True,
"connection": connection_status,
"mode": mode,
"webhook_secret_configured": bool(settings.stripe_webhook_secret),
"plans": plan_statuses,
"webhook_endpoint": str(request.base_url).rstrip("/") + "/api/billing/webhook",
}
@router.post("/stripe/sync-plans", summary="Auto-create Stripe products and prices for all plans (admin only)")
@require_login
async def stripe_sync_plans(request: Request, db: Session = Depends(get_db)) -> dict[str, Any]:
"""Create Stripe Product + Price objects for every paid plan that is missing them.
For each paid plan (``price_monthly > 0``) that lacks a ``stripe_price_id_monthly``,
this endpoint:
1. Creates a Stripe *Product* named after the plan.
2. Creates a Stripe *Price* for the monthly amount.
3. Optionally creates a yearly Price if ``price_yearly > 0``.
4. Persists the resulting ``price_id`` values back into ``SubscriptionPlan``.
Already-synced plans (those that already have ``stripe_price_id_monthly``) are
skipped — existing prices in Stripe are never modified.
Raises:
403: Not admin.
503: Stripe not configured.
"""
_require_admin(request)
client = _get_stripe()
if not client:
raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="Billing is not configured.")
plans = db.query(SubscriptionPlan).order_by(SubscriptionPlan.sort_order).all()
results: list[dict[str, Any]] = []
for plan in plans:
is_paid = plan.price_monthly > 0 or plan.price_yearly > 0
if not is_paid:
results.append({"plan_id": plan.plan_id, "name": plan.name, "status": "skipped_free"})
continue
already_has_monthly = bool(plan.stripe_price_id_monthly)
already_has_yearly = bool(plan.stripe_price_id_yearly)
if already_has_monthly and (not plan.price_yearly or already_has_yearly):
results.append({"plan_id": plan.plan_id, "name": plan.name, "status": "already_synced"})
continue
try:
# Create (or look up) the Stripe Product for this plan
product = client.products.create(
params={
"name": str(plan.name),
"metadata": {"docuelevate_plan_id": plan.plan_id},
}
)
changed = False
# Monthly price
if not already_has_monthly and plan.price_monthly > 0:
monthly_price = client.prices.create(
params={
"product": product.id,
"unit_amount": int(round(plan.price_monthly * 100)),
"currency": "usd",
"recurring": {"interval": "month"},
"metadata": {"docuelevate_plan_id": plan.plan_id, "billing_cycle": "monthly"},
}
)
plan.stripe_price_id_monthly = monthly_price.id
changed = True
# Yearly price
if not already_has_yearly and plan.price_yearly > 0:
yearly_price = client.prices.create(
params={
"product": product.id,
"unit_amount": int(round(plan.price_yearly * 100)),
"currency": "usd",
"recurring": {"interval": "year"},
"metadata": {"docuelevate_plan_id": plan.plan_id, "billing_cycle": "yearly"},
}
)
plan.stripe_price_id_yearly = yearly_price.id
changed = True
if changed:
db.commit()
logger.info(
"Stripe sync: created product/prices for plan %s (product %s)",
plan.plan_id,
product.id,
)
results.append(
{
"plan_id": plan.plan_id,
"name": plan.name,
"status": "created",
"stripe_price_id_monthly": plan.stripe_price_id_monthly,
"stripe_price_id_yearly": plan.stripe_price_id_yearly,
}
)
except Exception as exc:
db.rollback()
logger.error("Stripe sync failed for plan %s: %s", plan.plan_id, exc)
results.append(
{
"plan_id": plan.plan_id,
"name": str(plan.name),
"status": "error",
"detail": str(exc),
}
)
return {"results": results}
def _handle_stripe_event(db: Session, event: Any) -> None:
+325
View File
@@ -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)
+751
View File
@@ -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
]
+183
View File
@@ -0,0 +1,183 @@
"""API endpoints for managing compliance templates (GDPR, HIPAA, SOC2).
All endpoints require admin privileges.
Available routes:
GET /api/compliance/templates list all compliance templates
GET /api/compliance/templates/{name} get a single template with checks
POST /api/compliance/templates/{name}/apply one-click apply a template
GET /api/compliance/templates/{name}/status evaluate compliance status
GET /api/compliance/summary overall compliance dashboard data
"""
import logging
from typing import Annotated, Any
from fastapi import APIRouter, Depends, HTTPException, Request, status
from pydantic import BaseModel
from sqlalchemy.orm import Session
from app.database import get_db
from app.utils.compliance_service import (
COMPLIANCE_TEMPLATES,
apply_template,
evaluate_template_status,
get_all_templates,
get_compliance_summary,
get_template_by_name,
)
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/compliance", tags=["compliance"])
DbSession = Annotated[Session, Depends(get_db)]
# ---------------------------------------------------------------------------
# Authorisation helper
# ---------------------------------------------------------------------------
def _require_admin(request: Request) -> dict:
"""Ensure the caller is an admin; raises HTTP 403 otherwise."""
user = request.session.get("user")
if not user or not user.get("is_admin"):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin access required")
return user
AdminUser = Annotated[dict, Depends(_require_admin)]
# ---------------------------------------------------------------------------
# Pydantic response models
# ---------------------------------------------------------------------------
class CheckResult(BaseModel):
"""Individual compliance check result."""
key: str
label: str
description: str
expected: str
actual: str
passing: bool
class TemplateStatusResponse(BaseModel):
"""Status evaluation for a compliance template."""
status: str
total: int
passed: int
failed: int
check_results: list[CheckResult]
class TemplateResponse(BaseModel):
"""Full compliance template representation."""
id: int
name: str
display_name: str
description: str | None
enabled: bool
status: str
applied_at: str | None
applied_by: str | None
settings: dict[str, str]
checks: list[dict[str, Any]]
check_count: int
class ApplyResponse(BaseModel):
"""Result of applying a compliance template."""
success: bool
template: str | None = None
applied_settings: dict[str, str] | None = None
errors: list[str] | None = None
error: str | None = None
status: TemplateStatusResponse | None = None
class SummaryTemplateResponse(BaseModel):
"""Per-template summary for the compliance dashboard."""
name: str
display_name: str
enabled: bool
status: str
total: int
passed: int
failed: int
applied_at: str | None
applied_by: str | None
class ComplianceSummaryResponse(BaseModel):
"""Overall compliance dashboard summary."""
overall_status: str
total_checks: int
total_passed: int
total_failed: int
templates: list[SummaryTemplateResponse]
# ---------------------------------------------------------------------------
# Endpoints
# ---------------------------------------------------------------------------
@router.get("/templates", response_model=list[TemplateResponse])
async def list_templates(db: DbSession, admin: AdminUser) -> list[dict[str, Any]]:
"""List all compliance templates with their current status."""
return get_all_templates(db)
@router.get("/templates/{name}", response_model=TemplateResponse)
async def get_template(name: str, db: DbSession, admin: AdminUser) -> dict[str, Any]:
"""Get a single compliance template by name."""
templates = get_all_templates(db)
for t in templates:
if t["name"] == name:
return t
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Template '{name}' not found")
@router.post("/templates/{name}/apply", response_model=ApplyResponse)
async def apply_compliance_template(name: str, db: DbSession, admin: AdminUser) -> dict[str, Any]:
"""Apply a compliance template (one-click).
Writes all template settings to the database and evaluates the resulting
compliance status.
"""
if name not in COMPLIANCE_TEMPLATES:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Template '{name}' not found")
template = get_template_by_name(db, name)
if template is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Template '{name}' not found")
admin_email = admin.get("email", "admin")
result = apply_template(db, name, applied_by=admin_email)
if not result.get("success") and result.get("error"):
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=result["error"])
return result
@router.get("/templates/{name}/status", response_model=TemplateStatusResponse)
async def get_template_status(name: str, db: DbSession, admin: AdminUser) -> dict[str, Any]:
"""Evaluate the live compliance status of a template."""
template = get_template_by_name(db, name)
if template is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Template '{name}' not found")
return evaluate_template_status(db, name)
@router.get("/summary", response_model=ComplianceSummaryResponse)
async def compliance_summary(db: DbSession, admin: AdminUser) -> dict[str, Any]:
"""Overall compliance dashboard summary across all templates."""
return get_compliance_summary(db)
+61
View File
@@ -21,6 +21,67 @@ _DEFAULT_REDIS_URL = "redis://localhost:6379/0"
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")
@require_login
+222 -45
View File
@@ -5,7 +5,9 @@ Dropbox API endpoints
import logging
import os
from typing import Annotated, Optional
from urllib.parse import quote
import httpx
import requests
from fastapi import APIRouter, Depends, Form, HTTPException, Request, status
from sqlalchemy.orm import Session
@@ -23,6 +25,93 @@ logger = logging.getLogger(__name__)
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")
@require_login
async def dropbox_global_authorize_url(request: Request):
"""Return the Dropbox OAuth authorization URL using the global app credentials.
This endpoint is used when ``DROPBOX_ALLOW_GLOBAL_CREDENTIALS_FOR_INTEGRATIONS``
is enabled so that users can authorize their personal Dropbox integration without
needing to supply their own app key/secret. Only the public ``app_key`` is
embedded in the URL; the ``app_secret`` is never sent to the browser.
"""
if not settings.dropbox_allow_global_credentials_for_integrations:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Global credentials for integrations are not enabled",
)
if not settings.dropbox_app_key or not settings.dropbox_app_secret:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="Global Dropbox credentials are not configured",
)
redirect_uri = _build_dropbox_redirect_uri(request)
authorize_url = (
"https://www.dropbox.com/oauth2/authorize"
f"?client_id={settings.dropbox_app_key}"
"&response_type=code"
"&token_access_type=offline"
f"&redirect_uri={quote(redirect_uri, safe='')}"
)
return {"authorize_url": authorize_url}
@router.post("/dropbox/exchange-token-global")
@require_login
async def exchange_dropbox_token_global(
request: Request,
code: Annotated[str, Form(...)],
redirect_uri: Annotated[str, Form(...)],
):
"""Exchange an authorization code using the global Dropbox app credentials.
Used when ``DROPBOX_ALLOW_GLOBAL_CREDENTIALS_FOR_INTEGRATIONS`` is enabled so
that the ``app_secret`` is never exposed to the browser. Only the OAuth code
and redirect URI need to be supplied by the client.
"""
if not settings.dropbox_allow_global_credentials_for_integrations:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Global credentials for integrations are not enabled",
)
if not settings.dropbox_app_key or not settings.dropbox_app_secret:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="Global Dropbox credentials are not configured",
)
token_url = "https://api.dropboxapi.com/oauth2/token"
payload = {
"client_id": settings.dropbox_app_key,
"client_secret": settings.dropbox_app_secret,
"code": code,
"redirect_uri": redirect_uri,
"grant_type": "authorization_code",
}
token_data = exchange_oauth_token(provider_name="Dropbox", token_url=token_url, payload=payload)
return {
"refresh_token": token_data["refresh_token"],
"access_token": token_data["access_token"],
"expires_in": token_data.get("expires_in", 14400),
# Return the public app_key so the callback can store it in the integration
"app_key": settings.dropbox_app_key,
}
@router.post("/dropbox/exchange-token")
@require_login
async def exchange_dropbox_token(
@@ -132,57 +221,60 @@ async def test_dropbox_token(request: Request):
"message": "Dropbox credentials are not fully configured",
}
# Check token validity by getting current account info
headers = {"Authorization": f"Bearer {settings.dropbox_refresh_token}"}
response = requests.post(
"https://api.dropboxapi.com/2/users/get_current_account",
headers=headers,
timeout=settings.http_request_timeout,
)
# If token is invalid, try refreshing it
if response.status_code == 401:
logger.info("Dropbox access token invalid or expired, trying to refresh")
# Get a new access token using the refresh token
refresh_url = "https://api.dropbox.com/oauth2/token"
refresh_data = {
"grant_type": "refresh_token",
"refresh_token": settings.dropbox_refresh_token,
"client_id": settings.dropbox_app_key,
"client_secret": settings.dropbox_app_secret,
}
refresh_response = requests.post(refresh_url, data=refresh_data, timeout=settings.http_request_timeout)
if refresh_response.status_code != 200:
logger.error(f"Failed to refresh Dropbox token: {refresh_response.text}")
return {
"status": "error",
"message": "Refresh token has expired or is invalid",
"needs_reauth": True,
}
token_info = refresh_response.json()
access_token = token_info.get("access_token")
# Try again with the new access token
headers = {"Authorization": f"Bearer {access_token}"}
response = requests.post(
async with httpx.AsyncClient() as client:
# Check token validity by getting current account info
headers = {"Authorization": f"Bearer {settings.dropbox_refresh_token}"}
response = await client.post(
"https://api.dropboxapi.com/2/users/get_current_account",
headers=headers,
timeout=settings.http_request_timeout,
)
if response.status_code != 200:
logger.error(f"Dropbox token test failed: {response.status_code} {response.text}")
return {
"status": "error",
"message": f"Token validation failed with status {response.status_code}: {response.text}",
}
# If token is invalid, try refreshing it
if response.status_code == 401:
logger.info("Dropbox access token invalid or expired, trying to refresh")
# Get account info
account_info = response.json()
# Get a new access token using the refresh token
refresh_url = "https://api.dropbox.com/oauth2/token"
refresh_data = {
"grant_type": "refresh_token",
"refresh_token": settings.dropbox_refresh_token,
"client_id": settings.dropbox_app_key,
"client_secret": settings.dropbox_app_secret,
}
refresh_response = await client.post(
refresh_url, data=refresh_data, timeout=settings.http_request_timeout
)
if refresh_response.status_code != 200:
logger.error(f"Failed to refresh Dropbox token: {refresh_response.text}")
return {
"status": "error",
"message": "Refresh token has expired or is invalid",
"needs_reauth": True,
}
token_info = refresh_response.json()
access_token = token_info.get("access_token")
# Try again with the new access token
headers = {"Authorization": f"Bearer {access_token}"}
response = await client.post(
"https://api.dropboxapi.com/2/users/get_current_account",
headers=headers,
timeout=settings.http_request_timeout,
)
if response.status_code != 200:
logger.error(f"Dropbox token test failed: {response.status_code} {response.text}")
return {
"status": "error",
"message": f"Token validation failed with status {response.status_code}: {response.text}",
}
# Get account info
account_info = response.json()
account_email = account_info.get("email", "Unknown account")
account_name = account_info.get("name", {}).get("display_name", "Unknown user")
@@ -207,6 +299,91 @@ async def test_dropbox_token(request: Request):
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")
@require_login
async def save_dropbox_settings(
+28 -23
View File
@@ -73,33 +73,38 @@ def list_duplicate_groups(
groups = []
total_duplicate_files = 0
for filehash in dup_hashes:
# Find the original (non-duplicate) record with this hash
original = (
db.query(FileRecord)
.filter(FileRecord.filehash == filehash, FileRecord.is_duplicate.is_(False))
.order_by(FileRecord.id.asc())
.first()
if dup_hashes:
# Fetch all matching files (both original and duplicates) in a single batch query
all_records = (
db.query(FileRecord).filter(FileRecord.filehash.in_(dup_hashes)).order_by(FileRecord.id.asc()).all()
)
# Find all duplicate records for this hash
duplicates = (
db.query(FileRecord)
.filter(FileRecord.filehash == filehash, FileRecord.is_duplicate.is_(True))
.order_by(FileRecord.id.asc())
.all()
)
# Group records by hash
originals_by_hash = {}
duplicates_by_hash = {h: [] for h in dup_hashes}
total_duplicate_files += len(duplicates)
for record in all_records:
h = record.filehash
if not record.is_duplicate:
# Store only the first original record per hash, matching the old .first() behaviour
if h not in originals_by_hash:
originals_by_hash[h] = record
else:
duplicates_by_hash[h].append(record)
total_duplicate_files += 1
groups.append(
{
"filehash": filehash,
"original": _file_record_to_dict(original) if original else None,
"duplicates": [_file_record_to_dict(d) for d in duplicates],
"duplicate_count": len(duplicates),
}
)
for filehash in dup_hashes:
original = originals_by_hash.get(filehash)
duplicates = duplicates_by_hash.get(filehash, [])
groups.append(
{
"filehash": filehash,
"original": _file_record_to_dict(original) if original else None,
"duplicates": [_file_record_to_dict(d) for d in duplicates],
"duplicate_count": len(duplicates),
}
)
total_pages = (total_groups + per_page - 1) // per_page if total_groups > 0 else 1
+155 -49
View File
@@ -11,6 +11,7 @@ import zipfile
from datetime import datetime, timezone
from typing import Annotated, List, Optional
import aiofiles
from fastapi import APIRouter, Depends, File, HTTPException, Query, Request, UploadFile, status
from fastapi.responses import StreamingResponse
from sqlalchemy import asc, desc
@@ -19,6 +20,7 @@ from sqlalchemy.orm import Session
from app.auth import require_login
from app.config import settings
from app.database import get_db
from app.middleware.upload_rate_limit import require_upload_rate_limit
from app.models import FileProcessingStep, FileRecord, ProcessingLog
from app.tasks.convert_to_pdf import convert_to_pdf
from app.tasks.process_document import process_document
@@ -28,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.filename_utils import sanitize_filename
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
logger = logging.getLogger(__name__)
@@ -298,6 +300,7 @@ def delete_file_record(request: Request, file_id: int, db: DbSession):
"""
Delete a file record from the database.
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
if not settings.allow_file_delete:
@@ -312,6 +315,18 @@ def delete_file_record(request: Request, file_id: int, db: DbSession):
if not file_record:
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
logger.info(f"Deleting file record: ID={file_id}, Filename={file_record.original_filename}")
@@ -338,6 +353,7 @@ def bulk_delete_files(request: Request, file_ids: List[int], db: DbSession):
"""
Delete multiple file records from the database.
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
if not settings.allow_file_delete:
@@ -345,11 +361,25 @@ def bulk_delete_files(request: Request, file_ids: List[int], db: DbSession):
try:
# Find all file records
file_records = db.query(FileRecord).filter(FileRecord.id.in_(file_ids)).all()
query = db.query(FileRecord).filter(FileRecord.id.in_(file_ids))
query = apply_owner_filter(query, request)
file_records = query.all()
if not file_records:
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_ids = [f.id for f in file_records]
@@ -384,7 +414,9 @@ def bulk_reprocess_files(request: Request, file_ids: List[int], db: DbSession):
"""
try:
# Find all file records
file_records = db.query(FileRecord).filter(FileRecord.id.in_(file_ids)).all()
query = db.query(FileRecord).filter(FileRecord.id.in_(file_ids))
query = apply_owner_filter(query, request)
file_records = query.all()
if not file_records:
raise HTTPException(status_code=404, detail="No files found with the provided IDs")
@@ -456,7 +488,9 @@ def bulk_reprocess_files_cloud_ocr(request: Request, file_ids: List[int], db: Db
Useful for re-running OCR on files with poor text quality or missing OCR text.
"""
try:
file_records = db.query(FileRecord).filter(FileRecord.id.in_(file_ids)).all()
query = db.query(FileRecord).filter(FileRecord.id.in_(file_ids))
query = apply_owner_filter(query, request)
file_records = query.all()
if not file_records:
raise HTTPException(status_code=404, detail="No files found with the provided IDs")
@@ -536,7 +570,9 @@ def bulk_download_files(request: Request, file_ids: List[int], db: DbSession):
Files not found on disk are silently skipped.
"""
try:
file_records = db.query(FileRecord).filter(FileRecord.id.in_(file_ids)).all()
query = db.query(FileRecord).filter(FileRecord.id.in_(file_ids))
query = apply_owner_filter(query, request)
file_records = query.all()
if not file_records:
raise HTTPException(status_code=404, detail="No files found with the provided IDs")
@@ -618,7 +654,9 @@ def reprocess_single_file(request: Request, file_id: int, db: DbSession):
"""
try:
# Find the file record
file_record = db.query(FileRecord).filter(FileRecord.id == file_id).first()
query = db.query(FileRecord).filter(FileRecord.id == file_id)
query = apply_owner_filter(query, request)
file_record = query.first()
if not file_record:
raise HTTPException(status_code=404, detail=f"File with ID {file_id} not found")
@@ -674,7 +712,9 @@ def reprocess_with_cloud_ocr(request: Request, file_id: int, db: DbSession):
"""
try:
# Find the file record
file_record = db.query(FileRecord).filter(FileRecord.id == file_id).first()
query = db.query(FileRecord).filter(FileRecord.id == file_id)
query = apply_owner_filter(query, request)
file_record = query.first()
if not file_record:
raise HTTPException(status_code=404, detail=f"File with ID {file_id} not found")
@@ -937,7 +977,9 @@ def retry_subtask(
"""
try:
# Find the file record
file_record = db.query(FileRecord).filter(FileRecord.id == file_id).first()
query = db.query(FileRecord).filter(FileRecord.id == file_id)
query = apply_owner_filter(query, request)
file_record = query.first()
if not file_record:
raise HTTPException(status_code=404, detail=f"File with ID {file_id} not found")
@@ -1079,7 +1121,9 @@ def get_file_preview(
try:
# Find the file record
file_record = db.query(FileRecord).filter(FileRecord.id == file_id).first()
query = db.query(FileRecord).filter(FileRecord.id == file_id)
query = apply_owner_filter(query, request)
file_record = query.first()
if not file_record:
raise HTTPException(status_code=404, detail=f"File with ID {file_id} not found")
@@ -1159,7 +1203,9 @@ def download_file(
try:
# Find the file record
file_record = db.query(FileRecord).filter(FileRecord.id == file_id).first()
query = db.query(FileRecord).filter(FileRecord.id == file_id)
query = apply_owner_filter(query, request)
file_record = query.first()
if not file_record:
raise HTTPException(status_code=404, detail=f"File with ID {file_id} not found")
@@ -1217,9 +1263,79 @@ def download_file(
raise HTTPException(status_code=500, detail=f"Error downloading file: {str(e)}")
async def _save_upload_file_chunks(file: UploadFile, target_path: str, max_size: int) -> int:
"""Save an uploaded file in chunks and enforce the maximum size limit."""
try:
written_size = 0
with open(target_path, "wb") as f:
chunk_size = 65536 # 64 KB chunks
while True:
chunk = await file.read(chunk_size)
if not chunk:
break
written_size += len(chunk)
if written_size > max_size:
# Exceeded limit mid-stream; clean up and reject
f.close()
os.remove(target_path)
raise HTTPException(
status_code=413,
detail=f"File too large: exceeded {max_size} bytes during upload. "
f"See SECURITY_AUDIT.md for configuration details.",
)
f.write(chunk)
return written_size
except HTTPException:
raise
except Exception as e:
if os.path.exists(target_path):
os.remove(target_path)
raise HTTPException(status_code=500, detail=f"Failed to save file: {e}")
def _check_for_exact_duplicate(db: DbSession, target_path: str, safe_filename: str) -> dict | None:
"""Check for an exact duplicate of the uploaded file.
Returns a dict with duplicate info when the file's SHA-256 hash matches an
already-processed document, or ``None`` when no duplicate is found (or
deduplication is disabled).
"""
if not settings.enable_deduplication:
return None
try:
filehash = hash_file(target_path)
existing = (
db.query(FileRecord)
.filter(FileRecord.filehash == filehash, FileRecord.is_duplicate.is_(False))
.order_by(FileRecord.id.asc())
.first()
)
if existing:
logger.info(f"Exact duplicate detected on upload: '{safe_filename}' matches file ID {existing.id}")
return {
"duplicate_type": "exact",
"original_file_id": existing.id,
"original_filename": existing.original_filename,
"message": (
"This file is an exact duplicate of an already-processed document. "
"It has not been queued for processing again."
),
}
except Exception as e:
logger.warning(f"Duplicate check failed for uploaded file '{safe_filename}': {e}")
return None
@router.post("/ui-upload")
@require_login
async def ui_upload(request: Request, db: DbSession, file: UploadFile = File(...)):
async def ui_upload(
request: Request,
db: DbSession,
file: UploadFile = File(...),
_rate_ok: None = Depends(require_upload_rate_limit),
):
"""Endpoint to accept a user-uploaded file and enqueue it for processing."""
workdir = settings.workdir
@@ -1277,7 +1393,7 @@ async def ui_upload(request: Request, db: DbSession, file: UploadFile = File(...
# enforcing the size limit during the read so memory usage stays bounded.
try:
written_size = 0
with open(target_path, "wb") as f:
async with aiofiles.open(target_path, "wb") as f:
chunk_size = 65536 # 64 KB chunks
while True:
chunk = await file.read(chunk_size)
@@ -1286,14 +1402,14 @@ async def ui_upload(request: Request, db: DbSession, file: UploadFile = File(...
written_size += len(chunk)
if written_size > max_size:
# Exceeded limit mid-stream; clean up and reject
f.close()
await f.close()
os.remove(target_path)
raise HTTPException(
status_code=413,
detail=f"File too large: exceeded {max_size} bytes during upload. "
f"See SECURITY_AUDIT.md for configuration details.",
)
f.write(chunk)
await f.write(chunk)
except HTTPException:
raise
except Exception as e:
@@ -1305,6 +1421,25 @@ async def ui_upload(request: Request, db: DbSession, file: UploadFile = File(...
logger.info(f"Saved uploaded file '{safe_filename}' as '{target_filename}'")
file_size = written_size
# ── Early duplicate rejection ──────────────────────────────────────────
# Check for exact duplicates (same SHA-256 hash) BEFORE enqueuing a
# processing task. When deduplication is enabled and the file already
# exists, we skip processing entirely, clean up the temp file, and
# return the existing file's information to the caller.
exact_duplicate = _check_for_exact_duplicate(db, target_path, safe_filename)
if exact_duplicate:
# Remove the just-saved temp file — it's a duplicate.
try:
os.remove(target_path)
except OSError:
pass
return {
"status": "duplicate",
"original_filename": safe_filename,
"stored_filename": target_filename,
"duplicate_of": exact_duplicate,
}
# Determine if the file is a PDF or needs conversion
mime_type, _ = mimetypes.guess_type(target_path)
file_ext = os.path.splitext(target_path)[1].lower()
@@ -1368,6 +1503,8 @@ async def ui_upload(request: Request, db: DbSession, file: UploadFile = File(...
".tif",
".webp",
".svg",
".heic",
".heif",
}:
# 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)
@@ -1381,42 +1518,12 @@ async def ui_upload(request: Request, db: DbSession, file: UploadFile = File(...
logger.warning(f"Unsupported MIME type {mime_type} for {target_path}, attempting conversion")
task = convert_to_pdf.delay(target_path, original_filename=safe_filename, owner_id=upload_owner_id)
# Check for exact duplicates (same SHA-256 hash) before returning.
# This gives the caller an immediate warning without waiting for the pipeline.
# Only performed when deduplication is enabled in settings.
exact_duplicate_warning = None
if settings.enable_deduplication:
try:
filehash = hash_file(target_path)
existing = (
db.query(FileRecord)
.filter(FileRecord.filehash == filehash, FileRecord.is_duplicate.is_(False))
.order_by(FileRecord.id.asc())
.first()
)
if existing:
exact_duplicate_warning = {
"duplicate_type": "exact",
"original_file_id": existing.id,
"original_filename": existing.original_filename,
"message": (
"This file appears to be an exact duplicate of an already-processed document. "
"It will still be queued but will be flagged as a duplicate."
),
}
logger.info(f"Exact duplicate detected on upload: '{safe_filename}' matches file ID {existing.id}")
except Exception as e:
logger.warning(f"Duplicate check failed for uploaded file '{safe_filename}': {e}")
response: dict = {
return {
"task_id": task.id,
"status": "queued",
"original_filename": safe_filename,
"stored_filename": target_filename,
}
if exact_duplicate_warning:
response["duplicate_warning"] = exact_duplicate_warning
return response
# ---------------------------------------------------------------------------
@@ -1458,7 +1565,7 @@ def claim_file(request: Request, file_id: int, db: DbSession):
logger.exception(f"Error claiming file {file_id}: {e}")
raise HTTPException(status_code=500, detail="Failed to claim document")
logger.info(f"File {file_id} claimed by user '{owner_id}'")
logger.info("File %d claimed by user", file_id)
return {"status": "success", "message": "Document claimed successfully", "file_id": file_id, "owner_id": owner_id}
@@ -1498,7 +1605,7 @@ def bulk_claim_files(request: Request, file_ids: list[int], db: DbSession):
logger.exception(f"Error during bulk claim: {e}")
raise HTTPException(status_code=500, detail="Failed to claim documents")
logger.info(f"Bulk claim by '{owner_id}': claimed={claimed}, skipped={[s['file_id'] for s in skipped]}")
logger.info("Bulk claim: claimed=%s, skipped=%s", claimed, [s["file_id"] for s in skipped])
return {
"status": "success",
"claimed_count": len(claimed),
@@ -1551,8 +1658,7 @@ def assign_owner(request: Request, db: DbSession, owner_id: str = Query(...), fi
logger.exception(f"Error assigning owner: {e}")
raise HTTPException(status_code=500, detail="Failed to assign owner")
admin_name = get_current_owner_id(request) or "admin"
logger.info(f"Admin '{admin_name}' assigned owner_id='{owner_id}' to {updated} file(s)")
logger.info("Admin assigned owner to %d file(s)", updated)
return {
"status": "success",
"message": f"Assigned owner to {updated} document(s)",
+1 -1
View File
@@ -363,7 +363,7 @@ def format_time_remaining(time_delta):
@router.post("/google-drive/save-settings")
@require_login
async def save_dropbox_settings(
async def save_google_drive_settings(
request: Request,
refresh_token: Annotated[str, Form(...)],
client_id: Annotated[Optional[str], Form()] = None,
+431
View File
@@ -0,0 +1,431 @@
"""
GraphQL API endpoint for DocuElevate.
Provides a flexible query interface alongside the existing REST API.
Schema covers: documents, pipelines, settings, and users.
Endpoint: /graphql
GraphiQL playground: /graphql (via browser)
"""
from __future__ import annotations
import logging
from datetime import datetime
from typing import Annotated, Any
import strawberry
from fastapi import Depends, Request
from sqlalchemy.orm import Session
from strawberry.fastapi import GraphQLRouter
from app.auth import get_current_user
from app.config import settings
from app.database import get_db
from app.models import ApplicationSettings, FileRecord, Pipeline, PipelineStep, UserProfile
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Strawberry types
# ---------------------------------------------------------------------------
@strawberry.type
class DocumentType:
"""A processed document stored in the system."""
id: int
owner_id: str | None
original_filename: str | None
local_filename: str
file_size: int
mime_type: str | None
document_title: str | None
is_duplicate: bool
ocr_quality_score: int | None
pipeline_id: int | None
created_at: datetime | None
@strawberry.type
class PipelineStepType:
"""A single step within a processing pipeline."""
id: int
pipeline_id: int
position: int
step_type: str
label: str | None
enabled: bool
created_at: datetime | None
@strawberry.type
class PipelineType:
"""A processing pipeline with its ordered steps."""
id: int
owner_id: str | None
name: str
description: str | None
is_default: bool
is_active: bool
steps: list[PipelineStepType]
created_at: datetime | None
updated_at: datetime | None
@strawberry.type
class SettingType:
"""An application configuration setting stored in the database."""
id: int
key: str
value: str | None
created_at: datetime | None
updated_at: datetime | None
@strawberry.type
class UserType:
"""A user profile in the system."""
id: int
user_id: str
display_name: str | None
is_blocked: bool
subscription_tier: str | None
onboarding_completed: bool
created_at: datetime | None
# ---------------------------------------------------------------------------
# Conversion helpers
# ---------------------------------------------------------------------------
def _document_from_record(rec: FileRecord) -> DocumentType:
return DocumentType(
id=rec.id,
owner_id=rec.owner_id,
original_filename=rec.original_filename,
local_filename=rec.local_filename,
file_size=rec.file_size,
mime_type=rec.mime_type,
document_title=rec.document_title,
is_duplicate=rec.is_duplicate,
ocr_quality_score=rec.ocr_quality_score,
pipeline_id=rec.pipeline_id,
created_at=rec.created_at,
)
def _pipeline_step_from_record(step: PipelineStep) -> PipelineStepType:
return PipelineStepType(
id=step.id,
pipeline_id=step.pipeline_id,
position=step.position,
step_type=step.step_type,
label=step.label,
enabled=step.enabled,
created_at=step.created_at,
)
def _pipeline_from_record(pipeline: Pipeline, db: Session) -> PipelineType:
steps = db.query(PipelineStep).filter(PipelineStep.pipeline_id == pipeline.id).order_by(PipelineStep.position).all()
return PipelineType(
id=pipeline.id,
owner_id=pipeline.owner_id,
name=pipeline.name,
description=pipeline.description,
is_default=pipeline.is_default,
is_active=pipeline.is_active,
steps=[_pipeline_step_from_record(s) for s in steps],
created_at=pipeline.created_at,
updated_at=pipeline.updated_at,
)
def _setting_from_record(setting: ApplicationSettings) -> SettingType:
return SettingType(
id=setting.id,
key=setting.key,
value=setting.value,
created_at=setting.created_at,
updated_at=setting.updated_at,
)
def _user_from_profile(profile: UserProfile) -> UserType:
return UserType(
id=profile.id,
user_id=profile.user_id,
display_name=profile.display_name,
is_blocked=profile.is_blocked,
subscription_tier=profile.subscription_tier,
onboarding_completed=profile.onboarding_completed,
created_at=profile.created_at,
)
# ---------------------------------------------------------------------------
# Context helpers
# ---------------------------------------------------------------------------
# Keys that contain sensitive data and must never be returned via GraphQL
_SENSITIVE_SETTING_KEYS: frozenset[str] = frozenset(
{
"openai_api_key",
"azure_ai_key",
"session_secret",
"database_url",
"redis_url",
"dropbox_app_secret",
"dropbox_refresh_token",
"google_drive_credentials_json",
"onedrive_client_secret",
"onedrive_refresh_token",
"smtp_password",
"nextcloud_password",
"s3_secret_access_key",
"ftp_password",
"sftp_password",
"webdav_password",
"stripe_secret_key",
"stripe_webhook_secret",
"sentry_dsn",
"social_auth_google_client_secret",
"social_auth_microsoft_client_secret",
"social_auth_apple_private_key",
"social_auth_dropbox_app_secret",
}
)
def _get_current_user_id(user: dict[str, Any] | None) -> str | None:
"""Extract the stable user identifier from the user dict."""
if not user:
return None
return user.get("preferred_username") or user.get("email") or user.get("id") or None
def _get_db_and_user(info: strawberry.types.Info) -> tuple[Session, dict[str, Any] | None]:
"""Extract the database session and current user from the Strawberry context."""
db: Session = info.context["db"]
user: dict[str, Any] | None = info.context.get("user")
return db, user
def _require_auth(user: dict[str, Any] | None) -> None:
"""Raise an error when authentication is enabled and no valid user is present."""
if settings.auth_enabled and not user:
raise strawberry.exceptions.StrawberryGraphQLError("Authentication required")
def _require_admin(user: dict[str, Any] | None) -> None:
"""Raise an error when the current user is not an admin.
When ``auth_enabled`` is *False* (single-user / development mode) all
callers are implicitly treated as administrators.
"""
if not settings.auth_enabled:
# Single-user mode: no auth, treat caller as admin
return
_require_auth(user)
if not (user and user.get("is_admin")):
raise strawberry.exceptions.StrawberryGraphQLError("Admin access required")
# ---------------------------------------------------------------------------
# Query resolvers
# ---------------------------------------------------------------------------
@strawberry.type
class Query:
"""Root query type for the DocuElevate GraphQL API."""
@strawberry.field(description="List documents, optionally filtered by owner.")
def documents(
self,
info: strawberry.types.Info,
owner_id: str | None = None,
limit: int = 20,
offset: int = 0,
) -> list[DocumentType]:
"""Return a paginated list of documents.
When *auth_enabled* the caller must be authenticated. Non-admin users
receive only their own documents; admins may query any *owner_id*.
"""
db, user = _get_db_and_user(info)
_require_auth(user)
limit = max(1, min(limit, 100))
offset = max(0, offset)
query = db.query(FileRecord)
if settings.auth_enabled and user:
is_admin = user.get("is_admin", False)
current_user_id = _get_current_user_id(user)
if not is_admin:
# Non-admins can only see their own documents
query = query.filter(FileRecord.owner_id == current_user_id)
elif owner_id:
query = query.filter(FileRecord.owner_id == owner_id)
elif owner_id:
query = query.filter(FileRecord.owner_id == owner_id)
records = query.order_by(FileRecord.created_at.desc()).offset(offset).limit(limit).all()
return [_document_from_record(r) for r in records]
@strawberry.field(description="Fetch a single document by ID.")
def document(self, info: strawberry.types.Info, id: int) -> DocumentType | None:
"""Return one document by its primary key, or *null* if not found."""
db, user = _get_db_and_user(info)
_require_auth(user)
rec = db.query(FileRecord).filter(FileRecord.id == id).first()
if rec is None:
return None
if settings.auth_enabled and user:
is_admin = user.get("is_admin", False)
current_user_id = _get_current_user_id(user)
if not is_admin and rec.owner_id != current_user_id:
return None
return _document_from_record(rec)
@strawberry.field(description="List processing pipelines.")
def pipelines(
self,
info: strawberry.types.Info,
owner_id: str | None = None,
limit: int = 20,
offset: int = 0,
) -> list[PipelineType]:
"""Return a paginated list of pipelines."""
db, user = _get_db_and_user(info)
_require_auth(user)
limit = max(1, min(limit, 100))
offset = max(0, offset)
query = db.query(Pipeline)
if settings.auth_enabled and user:
is_admin = user.get("is_admin", False)
current_user_id = _get_current_user_id(user)
if not is_admin:
query = query.filter((Pipeline.owner_id == current_user_id) | (Pipeline.owner_id.is_(None)))
elif owner_id:
query = query.filter(Pipeline.owner_id == owner_id)
elif owner_id:
query = query.filter(Pipeline.owner_id == owner_id)
rows = query.order_by(Pipeline.id).offset(offset).limit(limit).all()
return [_pipeline_from_record(p, db) for p in rows]
@strawberry.field(description="Fetch a single pipeline by ID.")
def pipeline(self, info: strawberry.types.Info, id: int) -> PipelineType | None:
"""Return one pipeline by its primary key, or *null* if not found."""
db, user = _get_db_and_user(info)
_require_auth(user)
row = db.query(Pipeline).filter(Pipeline.id == id).first()
if row is None:
return None
if settings.auth_enabled and user:
is_admin = user.get("is_admin", False)
current_user_id = _get_current_user_id(user)
if not is_admin and row.owner_id is not None and row.owner_id != current_user_id:
return None
return _pipeline_from_record(row, db)
@strawberry.field(description="List non-sensitive application settings (admin only).")
def settings(
self,
info: strawberry.types.Info,
limit: int = 50,
offset: int = 0,
) -> list[SettingType]:
"""Return application settings stored in the database.
Sensitive keys (API secrets, passwords, etc.) are automatically
excluded. Requires admin privileges when auth is enabled.
"""
db, user = _get_db_and_user(info)
_require_admin(user)
limit = max(1, min(limit, 200))
offset = max(0, offset)
rows = (
db.query(ApplicationSettings)
.filter(ApplicationSettings.key.notin_(_SENSITIVE_SETTING_KEYS))
.order_by(ApplicationSettings.key)
.offset(offset)
.limit(limit)
.all()
)
return [_setting_from_record(r) for r in rows]
@strawberry.field(description="List user profiles (admin only).")
def users(
self,
info: strawberry.types.Info,
limit: int = 20,
offset: int = 0,
) -> list[UserType]:
"""Return a paginated list of user profiles. Requires admin privileges."""
db, user = _get_db_and_user(info)
_require_admin(user)
limit = max(1, min(limit, 100))
offset = max(0, offset)
rows = db.query(UserProfile).order_by(UserProfile.user_id).offset(offset).limit(limit).all()
return [_user_from_profile(r) for r in rows]
@strawberry.field(description="Fetch a user profile by user_id (admin only).")
def user(self, info: strawberry.types.Info, user_id: str) -> UserType | None:
"""Return one user profile by *user_id*, or *null* if not found."""
db, user = _get_db_and_user(info)
_require_admin(user)
row = db.query(UserProfile).filter(UserProfile.user_id == user_id).first()
return _user_from_profile(row) if row else None
# ---------------------------------------------------------------------------
# Schema and router
# ---------------------------------------------------------------------------
schema = strawberry.Schema(query=Query)
async def get_graphql_context(
request: Request,
db: Annotated[Session, Depends(get_db)],
) -> dict[str, Any]:
"""Build the per-request context injected into every resolver."""
try:
user = get_current_user(request)
except Exception:
logger.debug("Could not resolve current user for GraphQL context", exc_info=True)
user = None
return {"request": request, "db": db, "user": user}
graphql_router = GraphQLRouter(
schema,
context_getter=get_graphql_context,
graphql_ide="graphiql",
)
+136
View File
@@ -0,0 +1,136 @@
"""API endpoints for internationalization (i18n).
Provides endpoints for:
* Listing available languages
* Getting/setting user language preference (persisted in session + cookie + DB)
"""
from __future__ import annotations
import logging
from fastapi import APIRouter, Depends, Request, Response
from pydantic import BaseModel
from sqlalchemy.orm import Session
from app.database import get_db
from app.models import UserProfile
from app.utils.i18n import (
DEFAULT_LANGUAGE,
SUPPORTED_LANGUAGE_CODES,
SUPPORTED_LANGUAGES,
detect_language,
)
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/i18n", tags=["i18n"])
class LanguageInfo(BaseModel):
"""Schema for a supported language."""
code: str
name: str
native: str
flag: str
class LanguageListResponse(BaseModel):
"""Response for the list-languages endpoint."""
languages: list[LanguageInfo]
current: str
default: str
class SetLanguageRequest(BaseModel):
"""Request body for setting the preferred language."""
language: str
class SetLanguageResponse(BaseModel):
"""Response after changing the language."""
language: str
message: str
@router.get("/languages", response_model=LanguageListResponse)
async def list_languages(request: Request) -> LanguageListResponse:
"""Return all supported UI languages and the current active language."""
current = detect_language(request)
return LanguageListResponse(
languages=[LanguageInfo(**lang) for lang in SUPPORTED_LANGUAGES],
current=current,
default=DEFAULT_LANGUAGE,
)
@router.post("/language", response_model=SetLanguageResponse)
async def set_language(
body: SetLanguageRequest,
request: Request,
response: Response,
db: Session = Depends(get_db),
) -> SetLanguageResponse:
"""Set the preferred UI language.
Persists the choice in:
1. The server-side session
2. A ``docuelevate_lang`` cookie (30-day expiry)
3. The ``UserProfile.preferred_language`` column (if authenticated)
"""
lang = body.language.lower().strip()
if lang not in SUPPORTED_LANGUAGE_CODES:
lang = DEFAULT_LANGUAGE
# 1. Session
if hasattr(request, "session"):
request.session["preferred_language"] = lang
# 2. Cookie (30 days)
response.set_cookie(
key="docuelevate_lang",
value=lang,
max_age=30 * 24 * 60 * 60,
httponly=False,
samesite="lax",
)
# 3. Database (if user is authenticated)
_persist_language_to_profile(request, db, lang)
language_name = next(
(entry["native"] for entry in SUPPORTED_LANGUAGES if entry["code"] == lang),
lang,
)
logger.info("Language preference set to '%s'", lang)
return SetLanguageResponse(
language=lang,
message=f"Language changed to {language_name}",
)
def _persist_language_to_profile(request: Request, db: Session, lang: str) -> None:
"""Write language preference to the UserProfile row, if the user is logged in."""
user_id: str | None = None
if hasattr(request, "session"):
user = request.session.get("user")
if isinstance(user, dict):
user_id = user.get("preferred_username") or user.get("email") or user.get("id")
elif isinstance(user, str):
user_id = user
if not user_id:
return
try:
profile = db.query(UserProfile).filter(UserProfile.user_id == user_id).first()
if profile:
profile.preferred_language = lang # type: ignore[attr-defined]
db.commit()
except Exception:
db.rollback()
logger.debug("Could not persist language preference for user_id=%s", user_id)
+371
View File
@@ -0,0 +1,371 @@
"""API endpoints for managing per-user IMAP ingestion accounts.
Provides CRUD operations for a user's IMAP accounts, quota enforcement
against their subscription plan's ``max_mailboxes`` limit, and a
test-connection endpoint so users can verify credentials before saving.
"""
import imaplib
import logging
from datetime import datetime, timezone
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.database import get_db
from app.models import UserImapAccount
from app.utils.encryption import decrypt_value, encrypt_value
from app.utils.subscription import get_tier, get_user_tier_id
from app.utils.user_scope import get_current_owner_id
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/imap-accounts", tags=["imap-accounts"])
DbSession = Annotated[Session, Depends(get_db)]
# ---------------------------------------------------------------------------
# Auth helpers
# ---------------------------------------------------------------------------
def _get_owner_id(request: Request) -> str:
"""Return the current user's owner ID, raising 401 if unauthenticated."""
owner_id = get_current_owner_id(request)
if owner_id is None:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated")
return owner_id
CurrentOwner = Annotated[str, Depends(_get_owner_id)]
# ---------------------------------------------------------------------------
# Quota helpers
# ---------------------------------------------------------------------------
_FREE_TIER_ID = "free"
def _get_max_mailboxes(tier: dict[str, Any]) -> int | None:
"""Return the maximum number of IMAP accounts allowed by *tier*.
Returns:
``None`` — unlimited (paid tiers with ``max_mailboxes == 0``)
``0`` — no mailboxes allowed (free tier)
positive — the configured limit
"""
tier_id: str = tier.get("id", _FREE_TIER_ID)
max_mb: int = tier.get("max_mailboxes", 0)
# Free tier: 0 means "no access" (not "unlimited")
if tier_id == _FREE_TIER_ID:
return 0
# Paid tiers: 0 means unlimited
if max_mb == 0:
return None
return max_mb
def _check_quota(db: Session, owner_id: str) -> None:
"""Raise 403 if the user has reached their IMAP account quota."""
tier_id = get_user_tier_id(db, owner_id)
tier = get_tier(tier_id, db)
max_mb = _get_max_mailboxes(tier)
if max_mb == 0:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=("Your current plan does not include email ingestion. Upgrade to a paid plan to add IMAP accounts."),
)
if max_mb is not None:
current_count = db.query(UserImapAccount).filter(UserImapAccount.owner_id == owner_id).count()
if current_count >= max_mb:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=(
f"You have reached your plan limit of {max_mb} IMAP account(s). "
"Please delete an existing account or upgrade your plan."
),
)
# ---------------------------------------------------------------------------
# Pydantic schemas
# ---------------------------------------------------------------------------
class ImapAccountCreate(BaseModel):
"""Schema for creating a new IMAP account."""
name: str = Field(..., min_length=1, max_length=255, description="Human-readable label")
host: str = Field(..., min_length=1, max_length=255, description="IMAP server hostname")
port: int = Field(default=993, ge=1, le=65535, description="IMAP server port")
username: str = Field(..., min_length=1, max_length=255, description="IMAP login username")
password: str = Field(..., min_length=1, max_length=1024, description="IMAP login password")
use_ssl: bool = Field(default=True, description="Use SSL/TLS connection")
delete_after_process: bool = Field(default=False, description="Delete emails from mailbox after processing")
is_active: bool = Field(default=True, description="Whether to poll this mailbox")
profile_id: int | None = Field(
default=None,
description=(
"ID of the ImapIngestionProfile that controls which attachment types to ingest. "
"Null inherits the global imap_attachment_filter setting."
),
)
class ImapAccountUpdate(BaseModel):
"""Schema for updating an existing IMAP account (all fields optional)."""
name: str | None = Field(default=None, min_length=1, max_length=255)
host: str | None = Field(default=None, min_length=1, max_length=255)
port: int | None = Field(default=None, ge=1, le=65535)
username: str | None = Field(default=None, min_length=1, max_length=255)
password: str | None = Field(default=None, min_length=1, max_length=1024)
use_ssl: bool | None = None
delete_after_process: bool | None = None
is_active: bool | None = None
profile_id: int | None = Field(
default=None,
description=(
"ID of the ImapIngestionProfile to use. "
"Explicitly sending null clears the override (falls back to global setting)."
),
)
class ImapTestRequest(BaseModel):
"""Schema for testing an IMAP connection without saving it."""
host: str = Field(..., min_length=1, max_length=255)
port: int = Field(default=993, ge=1, le=65535)
username: str = Field(..., min_length=1, max_length=255)
password: str = Field(..., min_length=1, max_length=1024)
use_ssl: bool = Field(default=True)
# ---------------------------------------------------------------------------
# Serialisation helpers
# ---------------------------------------------------------------------------
def _to_response(acct: UserImapAccount) -> dict[str, Any]:
"""Serialize a ``UserImapAccount`` row to a response dict.
Passwords are never included in responses.
"""
return {
"id": acct.id,
"owner_id": acct.owner_id,
"name": acct.name,
"host": acct.host,
"port": acct.port,
"username": acct.username,
"use_ssl": acct.use_ssl,
"delete_after_process": acct.delete_after_process,
"is_active": acct.is_active,
"profile_id": acct.profile_id,
"last_checked_at": acct.last_checked_at.isoformat() if acct.last_checked_at else None,
"last_error": acct.last_error,
"created_at": acct.created_at.isoformat() if acct.created_at else None,
"updated_at": acct.updated_at.isoformat() if acct.updated_at else None,
}
# ---------------------------------------------------------------------------
# Connection test helper
# ---------------------------------------------------------------------------
def _test_imap_connection(host: str, port: int, username: str, password: str, use_ssl: bool) -> dict[str, Any]:
"""Attempt to connect and log in to the IMAP server.
Returns a dict with ``{"success": bool, "message": str}``.
"""
try:
if use_ssl:
mail = imaplib.IMAP4_SSL(host, port)
else:
mail = imaplib.IMAP4(host, port)
mail.login(username, password)
mail.logout()
return {"success": True, "message": "Connection successful"}
except OSError as exc:
logger.warning("IMAP network error for %s@%s: %s", username, host, exc)
return {"success": False, "message": f"Connection error: {exc}"}
except Exception as exc: # noqa: BLE001
logger.warning("IMAP error for %s@%s: %s", username, host, exc)
return {"success": False, "message": f"IMAP error: {exc}"}
# ---------------------------------------------------------------------------
# Endpoints
# ---------------------------------------------------------------------------
@router.get("/", summary="List IMAP accounts for the current user")
def list_imap_accounts(request: Request, db: DbSession, owner_id: CurrentOwner) -> list[dict[str, Any]]:
"""Return all IMAP accounts belonging to the authenticated user."""
accounts = db.query(UserImapAccount).filter(UserImapAccount.owner_id == owner_id).order_by(UserImapAccount.id).all()
return [_to_response(a) for a in accounts]
@router.post("/", status_code=status.HTTP_201_CREATED, summary="Create a new IMAP account")
def create_imap_account(
request: Request, body: ImapAccountCreate, db: DbSession, owner_id: CurrentOwner
) -> dict[str, Any]:
"""Create a new IMAP ingestion account for the current user.
Quota is enforced against the user's subscription plan's ``max_mailboxes``
limit before the account is persisted.
"""
_check_quota(db, owner_id)
acct = UserImapAccount(
owner_id=owner_id,
name=body.name,
host=body.host,
port=body.port,
username=body.username,
password=encrypt_value(body.password),
use_ssl=body.use_ssl,
delete_after_process=body.delete_after_process,
is_active=body.is_active,
profile_id=body.profile_id,
)
try:
db.add(acct)
db.commit()
db.refresh(acct)
except Exception:
db.rollback()
raise
logger.info("User %s created IMAP account %d (%s)", owner_id, acct.id, body.host)
return _to_response(acct)
@router.get("/{account_id}", summary="Get a single IMAP account")
def get_imap_account(account_id: int, request: Request, db: DbSession, owner_id: CurrentOwner) -> dict[str, Any]:
"""Return a single IMAP account by ID (must belong to the current user)."""
acct = (
db.query(UserImapAccount).filter(UserImapAccount.id == account_id, UserImapAccount.owner_id == owner_id).first()
)
if not acct:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="IMAP account not found")
return _to_response(acct)
@router.put("/{account_id}", summary="Update an IMAP account")
def update_imap_account(
account_id: int,
request: Request,
body: ImapAccountUpdate,
db: DbSession,
owner_id: CurrentOwner,
) -> dict[str, Any]:
"""Update an existing IMAP account. Only provided fields are changed."""
acct = (
db.query(UserImapAccount).filter(UserImapAccount.id == account_id, UserImapAccount.owner_id == owner_id).first()
)
if not acct:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="IMAP account not found")
if body.name is not None:
acct.name = body.name
if body.host is not None:
acct.host = body.host
if body.port is not None:
acct.port = body.port
if body.username is not None:
acct.username = body.username
if body.password is not None:
acct.password = encrypt_value(body.password)
if body.use_ssl is not None:
acct.use_ssl = body.use_ssl
if body.delete_after_process is not None:
acct.delete_after_process = body.delete_after_process
if body.is_active is not None:
acct.is_active = body.is_active
# profile_id: update whenever the field is explicitly present in the request payload
# (including sending null to clear the override).
if "profile_id" in body.model_fields_set:
acct.profile_id = body.profile_id
# Reset last_error so the next poll gives a fresh result
acct.last_error = None
acct.updated_at = datetime.now(timezone.utc)
try:
db.commit()
db.refresh(acct)
except Exception:
db.rollback()
raise
logger.info("User %s updated IMAP account %d", owner_id, account_id)
return _to_response(acct)
@router.delete("/{account_id}", status_code=status.HTTP_204_NO_CONTENT, summary="Delete an IMAP account")
def delete_imap_account(account_id: int, request: Request, db: DbSession, owner_id: CurrentOwner) -> None:
"""Delete an IMAP account permanently."""
acct = (
db.query(UserImapAccount).filter(UserImapAccount.id == account_id, UserImapAccount.owner_id == owner_id).first()
)
if not acct:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="IMAP account not found")
try:
db.delete(acct)
db.commit()
except Exception:
db.rollback()
raise
logger.info("User %s deleted IMAP account %d", owner_id, account_id)
@router.post("/{account_id}/test", summary="Test an existing IMAP account's connection")
def test_saved_imap_account(account_id: int, request: Request, db: DbSession, owner_id: CurrentOwner) -> dict[str, Any]:
"""Test the connection for an already-saved IMAP account."""
acct = (
db.query(UserImapAccount).filter(UserImapAccount.id == account_id, UserImapAccount.owner_id == owner_id).first()
)
if not acct:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="IMAP account not found")
return _test_imap_connection(acct.host, acct.port, acct.username, decrypt_value(acct.password), acct.use_ssl)
@router.post("/test", summary="Test an IMAP connection without saving")
def test_imap_connection(request: Request, body: ImapTestRequest, owner_id: CurrentOwner) -> dict[str, Any]:
"""Test IMAP credentials without persisting anything.
Useful for the "Test connection" button in the UI before the user saves
a new account.
"""
return _test_imap_connection(body.host, body.port, body.username, body.password, body.use_ssl)
@router.get("/quota/", summary="Get IMAP account quota information for the current user")
def get_imap_quota(request: Request, db: DbSession, owner_id: CurrentOwner) -> dict[str, Any]:
"""Return the user's current IMAP account usage vs. their plan quota."""
tier_id = get_user_tier_id(db, owner_id)
tier = get_tier(tier_id, db)
max_mb = _get_max_mailboxes(tier)
current_count = db.query(UserImapAccount).filter(UserImapAccount.owner_id == owner_id).count()
return {
"current_count": current_count,
"max_mailboxes": max_mb, # None = unlimited, 0 = not allowed
"can_add": max_mb is None or (max_mb > 0 and current_count < max_mb),
"tier_id": tier_id,
"tier_name": tier.get("name", tier_id),
}
+257
View File
@@ -0,0 +1,257 @@
"""API endpoints for managing IMAP ingestion profiles.
Ingestion profiles allow fine-grained control over which attachment types are
accepted when ingesting emails via IMAP. Each profile carries a list of enabled
file-type categories (e.g. ``["pdf", "office", "images"]``) drawn from the
canonical set defined in :mod:`app.utils.allowed_types`.
Built-in system profiles (``is_builtin=True``) are read-only and cannot be
deleted or modified. Users may create their own profiles which are private to
their ``owner_id``. System-level global profiles (``owner_id=None``) are visible
to all users but can only be created by administrators.
"""
import json
import logging
from datetime import datetime, timezone
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.database import get_db
from app.models import ImapIngestionProfile
from app.utils.allowed_types import FILE_TYPE_CATEGORIES
from app.utils.user_scope import get_current_owner_id
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/imap-profiles", tags=["imap-profiles"])
DbSession = Annotated[Session, Depends(get_db)]
# ---------------------------------------------------------------------------
# Auth helpers
# ---------------------------------------------------------------------------
def _get_owner_id(request: Request) -> str:
"""Return the current user's owner ID, raising 401 if unauthenticated."""
owner_id = get_current_owner_id(request)
if owner_id is None:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated")
return owner_id
CurrentOwner = Annotated[str, Depends(_get_owner_id)]
# ---------------------------------------------------------------------------
# Pydantic schemas
# ---------------------------------------------------------------------------
_VALID_CATEGORIES = set(FILE_TYPE_CATEGORIES.keys())
class ImapProfileCreate(BaseModel):
"""Schema for creating a new ingestion profile."""
name: str = Field(..., min_length=1, max_length=255, description="Human-readable profile name")
description: str | None = Field(default=None, description="Optional description")
allowed_categories: list[str] = Field(
...,
min_length=1,
description=(f"List of enabled file-type category keys. Valid values: {sorted(_VALID_CATEGORIES)}"),
)
class ImapProfileUpdate(BaseModel):
"""Schema for updating an existing profile (all fields optional)."""
name: str | None = Field(default=None, min_length=1, max_length=255)
description: str | None = None
allowed_categories: list[str] | None = Field(default=None, min_length=1)
# ---------------------------------------------------------------------------
# Validation helpers
# ---------------------------------------------------------------------------
def _validate_categories(categories: list[str]) -> list[str]:
"""Raise 422 if any category key is unknown; return the cleaned list."""
unknown = [c for c in categories if c not in _VALID_CATEGORIES]
if unknown:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=f"Unknown category key(s): {unknown}. Valid keys: {sorted(_VALID_CATEGORIES)}",
)
# Deduplicate while preserving order
seen: set[str] = set()
result: list[str] = []
for cat in categories:
if cat not in seen:
seen.add(cat)
result.append(cat)
return result
# ---------------------------------------------------------------------------
# Serialisation
# ---------------------------------------------------------------------------
def _to_response(profile: ImapIngestionProfile) -> dict[str, Any]:
"""Serialize a profile row to a response dict."""
try:
categories = json.loads(profile.allowed_categories)
except (ValueError, TypeError):
categories = []
# Enrich categories with display metadata
categories_detail = [
{
"key": cat,
"label": FILE_TYPE_CATEGORIES[cat]["label"] if cat in FILE_TYPE_CATEGORIES else cat,
"description": FILE_TYPE_CATEGORIES[cat]["description"] if cat in FILE_TYPE_CATEGORIES else "",
}
for cat in categories
]
return {
"id": profile.id,
"name": profile.name,
"description": profile.description,
"owner_id": profile.owner_id,
"allowed_categories": categories,
"categories_detail": categories_detail,
"is_builtin": profile.is_builtin,
"created_at": profile.created_at.isoformat() if profile.created_at else None,
"updated_at": profile.updated_at.isoformat() if profile.updated_at else None,
}
# ---------------------------------------------------------------------------
# Endpoints
# ---------------------------------------------------------------------------
@router.get("/categories", summary="List available file-type categories")
def list_categories(request: Request, owner_id: CurrentOwner) -> list[dict[str, Any]]:
"""Return the full list of file-type categories that can be used in profiles."""
return [
{
"key": key,
"label": info["label"],
"description": info["description"],
}
for key, info in FILE_TYPE_CATEGORIES.items()
]
@router.get("/", summary="List ingestion profiles visible to the current user")
def list_profiles(request: Request, db: DbSession, owner_id: CurrentOwner) -> list[dict[str, Any]]:
"""Return all profiles: system-global (owner_id=NULL) and the user's own profiles."""
profiles = (
db.query(ImapIngestionProfile)
.filter(
# SQLAlchemy requires `== None` for IS NULL comparison in ORM filters
(ImapIngestionProfile.owner_id == None) | (ImapIngestionProfile.owner_id == owner_id) # noqa: E711
)
.order_by(ImapIngestionProfile.is_builtin.desc(), ImapIngestionProfile.id)
.all()
)
return [_to_response(p) for p in profiles]
@router.post("/", status_code=status.HTTP_201_CREATED, summary="Create a new ingestion profile")
def create_profile(request: Request, body: ImapProfileCreate, db: DbSession, owner_id: CurrentOwner) -> dict[str, Any]:
"""Create a new ingestion profile owned by the current user."""
categories = _validate_categories(body.allowed_categories)
profile = ImapIngestionProfile(
name=body.name,
description=body.description,
owner_id=owner_id,
allowed_categories=json.dumps(categories),
is_builtin=False,
)
try:
db.add(profile)
db.commit()
db.refresh(profile)
except Exception:
db.rollback()
raise
logger.info("User %s created IMAP ingestion profile %d ('%s')", owner_id, profile.id, body.name)
return _to_response(profile)
@router.get("/{profile_id}", summary="Get a single ingestion profile")
def get_profile(profile_id: int, request: Request, db: DbSession, owner_id: CurrentOwner) -> dict[str, Any]:
"""Return a single profile by ID. Only the owner or system profiles are accessible."""
profile = db.query(ImapIngestionProfile).filter(ImapIngestionProfile.id == profile_id).first()
if not profile or (profile.owner_id is not None and profile.owner_id != owner_id):
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Ingestion profile not found")
return _to_response(profile)
@router.put("/{profile_id}", summary="Update an ingestion profile")
def update_profile(
profile_id: int,
request: Request,
body: ImapProfileUpdate,
db: DbSession,
owner_id: CurrentOwner,
) -> dict[str, Any]:
"""Update an existing ingestion profile. Built-in profiles cannot be modified."""
profile = db.query(ImapIngestionProfile).filter(ImapIngestionProfile.id == profile_id).first()
if not profile or (profile.owner_id is not None and profile.owner_id != owner_id):
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Ingestion profile not found")
if profile.is_builtin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Built-in profiles cannot be modified.",
)
if body.name is not None:
profile.name = body.name
if "description" in body.model_fields_set:
profile.description = body.description
if body.allowed_categories is not None:
categories = _validate_categories(body.allowed_categories)
profile.allowed_categories = json.dumps(categories)
profile.updated_at = datetime.now(timezone.utc)
try:
db.commit()
db.refresh(profile)
except Exception:
db.rollback()
raise
logger.info("User %s updated IMAP ingestion profile %d", owner_id, profile_id)
return _to_response(profile)
@router.delete("/{profile_id}", status_code=status.HTTP_204_NO_CONTENT, summary="Delete an ingestion profile")
def delete_profile(profile_id: int, request: Request, db: DbSession, owner_id: CurrentOwner) -> None:
"""Delete an ingestion profile. Built-in profiles cannot be deleted."""
profile = db.query(ImapIngestionProfile).filter(ImapIngestionProfile.id == profile_id).first()
if not profile or (profile.owner_id is not None and profile.owner_id != owner_id):
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Ingestion profile not found")
if profile.is_builtin:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Built-in profiles cannot be deleted.",
)
try:
db.delete(profile)
db.commit()
except Exception:
db.rollback()
raise
logger.info("User %s deleted IMAP ingestion profile %d", owner_id, profile_id)
+768
View File
@@ -0,0 +1,768 @@
"""API endpoints for managing per-user integrations (sources and destinations).
Provides CRUD operations for :class:`~app.models.UserIntegration` records.
Each record represents one ingestion source (e.g. IMAP, Watch Folder) or
storage destination (e.g. S3, Dropbox, Google Drive) configured by a user.
Sensitive credentials are encrypted at rest using Fernet symmetric encryption
(keyed from ``SESSION_SECRET``) via :mod:`app.utils.encryption`. Credential
values are **never** returned in API responses.
Subscription quota enforcement
------------------------------
On creation, the endpoint checks the user's subscription tier limits:
* **Destinations** — ``max_storage_destinations`` from the plan.
* **Sources (IMAP)** — ``max_mailboxes`` from the plan.
Exceeding the quota returns HTTP 403 with an actionable error message.
"""
import json
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.database import get_db
from app.models import IntegrationDirection, IntegrationType, UserIntegration
from app.utils.encryption import decrypt_value, encrypt_value
from app.utils.subscription import get_tier, get_user_tier_id
from app.utils.user_scope import get_current_owner_id
# Optional Dropbox SDK — imported at module level so tests can patch it cleanly.
try:
import dropbox as dbx_lib
from dropbox.exceptions import AuthError as _DropboxAuthError
from dropbox.exceptions import BadInputError as _DropboxBadInputError
except ImportError: # pragma: no cover
dbx_lib = None # type: ignore[assignment]
class _DropboxAuthError(Exception): # type: ignore[no-redef]
"""Stub — only used when the dropbox package is missing."""
class _DropboxBadInputError(Exception): # type: ignore[no-redef]
"""Stub — only used when the dropbox package is missing."""
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/integrations", tags=["integrations"])
DbSession = Annotated[Session, Depends(get_db)]
# ---------------------------------------------------------------------------
# Auth helpers
# ---------------------------------------------------------------------------
def _get_owner_id(request: Request) -> str:
"""Return the current user's owner ID, raising 401 if unauthenticated."""
owner_id = get_current_owner_id(request)
if owner_id is None:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated")
return owner_id
CurrentOwner = Annotated[str, Depends(_get_owner_id)]
# ---------------------------------------------------------------------------
# Quota helpers
# ---------------------------------------------------------------------------
_FREE_TIER_ID = "free"
# Source types that consume the mailbox quota
_MAILBOX_SOURCE_TYPES = {IntegrationType.IMAP}
def _get_max_destinations(tier: dict[str, Any]) -> int | None:
"""Return the maximum number of storage destinations allowed by *tier*.
Returns:
``None`` — unlimited (paid tiers with ``max_storage_destinations == 0``)
positive — the configured limit
"""
tier_id: str = tier.get("id", _FREE_TIER_ID)
max_dest: int = tier.get("max_storage_destinations", 0)
# Free tier: the value itself is the limit (e.g. 1)
if tier_id == _FREE_TIER_ID:
return max_dest if max_dest > 0 else 1 # safe default
# Paid tiers: 0 means unlimited
if max_dest == 0:
return None
return max_dest
def _get_max_sources(tier: dict[str, Any]) -> int | None:
"""Return the maximum number of IMAP source integrations allowed by *tier*.
Returns:
``None`` — unlimited (paid tiers with ``max_mailboxes == 0``)
``0`` — no mailboxes allowed (free tier)
positive — the configured limit
"""
tier_id: str = tier.get("id", _FREE_TIER_ID)
max_mb: int = tier.get("max_mailboxes", 0)
# Free tier: 0 means "no access" (not "unlimited")
if tier_id == _FREE_TIER_ID:
return 0
# Paid tiers: 0 means unlimited
if max_mb == 0:
return None
return max_mb
def _check_quota(db: Session, owner_id: str, direction: str, integration_type: str) -> None:
"""Raise 403 if the user has reached their integration quota.
Quota rules:
* DESTINATION integrations are limited by ``max_storage_destinations``.
* SOURCE integrations of type IMAP are limited by ``max_mailboxes``.
* Other SOURCE types (WATCH_FOLDER, WEBHOOK) are not quota-limited yet.
"""
tier_id = get_user_tier_id(db, owner_id)
tier = get_tier(tier_id, db)
if direction == IntegrationDirection.DESTINATION:
max_dest = _get_max_destinations(tier)
if max_dest is not None:
current_count = (
db.query(UserIntegration)
.filter(
UserIntegration.owner_id == owner_id,
UserIntegration.direction == IntegrationDirection.DESTINATION,
)
.count()
)
if current_count >= max_dest:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=(
f"You have reached your plan limit of {max_dest} storage destination(s). "
"Please remove an existing destination or upgrade your plan."
),
)
elif direction == IntegrationDirection.SOURCE and integration_type in _MAILBOX_SOURCE_TYPES:
max_src = _get_max_sources(tier)
if max_src == 0:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Your current plan does not include email ingestion. Upgrade to a paid plan to add IMAP sources.",
)
if max_src is not None:
current_count = (
db.query(UserIntegration)
.filter(
UserIntegration.owner_id == owner_id,
UserIntegration.direction == IntegrationDirection.SOURCE,
UserIntegration.integration_type.in_(list(_MAILBOX_SOURCE_TYPES)),
)
.count()
)
if current_count >= max_src:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=(
f"You have reached your plan limit of {max_src} IMAP source(s). "
"Please remove an existing source or upgrade your plan."
),
)
# ---------------------------------------------------------------------------
# Pydantic schemas
# ---------------------------------------------------------------------------
_VALID_DIRECTIONS = IntegrationDirection.ALL
_VALID_TYPES = IntegrationType.ALL
class IntegrationCreate(BaseModel):
"""Schema for creating a new integration."""
direction: str = Field(..., description="'SOURCE' or 'DESTINATION'")
integration_type: str = Field(..., description="Integration type (e.g. 'IMAP', 'S3', 'DROPBOX')")
name: str = Field(..., min_length=1, max_length=255, description="Human-readable label")
config: dict[str, Any] | None = Field(default=None, description="Non-sensitive configuration (JSON object)")
credentials: dict[str, Any] | None = Field(
default=None, description="Sensitive credentials (JSON object, encrypted at rest)"
)
is_active: bool = Field(default=True, description="Whether the integration is active")
class IntegrationUpdate(BaseModel):
"""Schema for updating an existing integration (all fields optional)."""
name: str | None = Field(default=None, min_length=1, max_length=255)
config: dict[str, Any] | None = None
credentials: dict[str, Any] | None = None
is_active: bool | None = None
class IntegrationTestRequest(BaseModel):
"""Schema for testing an integration connection without saving it."""
integration_type: str = Field(..., description="Integration type (e.g. 'IMAP', 'S3', 'DROPBOX')")
config: dict[str, Any] | None = Field(default=None, description="Non-sensitive configuration")
credentials: dict[str, Any] | None = Field(default=None, description="Credentials for the connection test")
# ---------------------------------------------------------------------------
# Validation helpers
# ---------------------------------------------------------------------------
def _validate_direction(direction: str) -> None:
"""Raise 400 if *direction* is not a known value."""
if direction not in _VALID_DIRECTIONS:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Invalid direction '{direction}'. Must be one of: {sorted(_VALID_DIRECTIONS)}",
)
def _validate_integration_type(integration_type: str) -> None:
"""Raise 400 if *integration_type* is not a known value."""
if integration_type not in _VALID_TYPES:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Invalid integration_type '{integration_type}'. Must be one of: {sorted(_VALID_TYPES)}",
)
# ---------------------------------------------------------------------------
# Serialisation helpers
# ---------------------------------------------------------------------------
def _to_response(integration: UserIntegration) -> dict[str, Any]:
"""Serialise a :class:`UserIntegration` row to a response dict.
Credentials are **never** included; only a boolean flag indicating
whether credentials have been configured is returned.
"""
config_data: dict[str, Any] | None = None
if integration.config:
try:
config_data = json.loads(integration.config)
except (json.JSONDecodeError, TypeError):
config_data = None
return {
"id": integration.id,
"owner_id": integration.owner_id,
"direction": integration.direction,
"integration_type": integration.integration_type,
"name": integration.name,
"config": config_data,
"has_credentials": bool(integration.credentials),
"is_active": integration.is_active,
"last_used_at": integration.last_used_at.isoformat() if integration.last_used_at else None,
"last_error": integration.last_error,
"created_at": integration.created_at.isoformat() if integration.created_at else None,
"updated_at": integration.updated_at.isoformat() if integration.updated_at else None,
}
def _encode_credentials(credentials: dict[str, Any] | None) -> str | None:
"""Serialise *credentials* dict to an encrypted JSON string for storage."""
if not credentials:
return None
plaintext = json.dumps(credentials)
return encrypt_value(plaintext)
def _decode_credentials(stored: str | None) -> dict[str, Any] | None:
"""Decrypt and deserialise stored credentials back to a dict.
Returns ``None`` when *stored* is empty or cannot be decoded.
"""
if not stored:
return None
plaintext = decrypt_value(stored)
if not plaintext:
return None
try:
return json.loads(plaintext)
except (json.JSONDecodeError, TypeError):
logger.error("Failed to decode credentials JSON after decryption")
return None
# ---------------------------------------------------------------------------
# Endpoints
# ---------------------------------------------------------------------------
@router.get("/", summary="List integrations for the current user")
def list_integrations(
request: Request,
db: DbSession,
owner_id: CurrentOwner,
direction: str | None = None,
integration_type: str | None = None,
) -> list[dict[str, Any]]:
"""Return all integrations belonging to the authenticated user.
Optional query-string filters:
- ``direction`` — ``SOURCE`` or ``DESTINATION``
- ``integration_type`` — e.g. ``IMAP``, ``S3``, ``DROPBOX``
"""
query = db.query(UserIntegration).filter(UserIntegration.owner_id == owner_id)
if direction is not None:
_validate_direction(direction)
query = query.filter(UserIntegration.direction == direction)
if integration_type is not None:
_validate_integration_type(integration_type)
query = query.filter(UserIntegration.integration_type == integration_type)
integrations = query.order_by(UserIntegration.id).all()
return [_to_response(i) for i in integrations]
@router.post("/", status_code=status.HTTP_201_CREATED, summary="Create a new integration")
def create_integration(
request: Request,
body: IntegrationCreate,
db: DbSession,
owner_id: CurrentOwner,
) -> dict[str, Any]:
"""Create a new source or destination integration for the current user.
``credentials`` are encrypted at rest using Fernet symmetric encryption
before being persisted and are **never** returned in API responses.
Quota is enforced against the user's subscription plan before the
integration is persisted.
"""
_validate_direction(body.direction)
_validate_integration_type(body.integration_type)
_check_quota(db, owner_id, body.direction, body.integration_type)
integration = UserIntegration(
owner_id=owner_id,
direction=body.direction,
integration_type=body.integration_type,
name=body.name,
config=json.dumps(body.config) if body.config is not None else None,
credentials=_encode_credentials(body.credentials),
is_active=body.is_active,
)
try:
db.add(integration)
db.commit()
db.refresh(integration)
except Exception:
db.rollback()
raise
logger.info(
"User %s created %s integration %d (%s)",
owner_id,
body.direction,
integration.id,
body.integration_type,
)
return _to_response(integration)
@router.get("/{integration_id}", summary="Get a single integration")
def get_integration(
integration_id: int,
request: Request,
db: DbSession,
owner_id: CurrentOwner,
) -> dict[str, Any]:
"""Return a single integration by ID (must belong to the current user)."""
integration = (
db.query(UserIntegration)
.filter(UserIntegration.id == integration_id, UserIntegration.owner_id == owner_id)
.first()
)
if not integration:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Integration not found")
return _to_response(integration)
@router.put("/{integration_id}", summary="Update an integration")
def update_integration(
integration_id: int,
request: Request,
body: IntegrationUpdate,
db: DbSession,
owner_id: CurrentOwner,
) -> dict[str, Any]:
"""Update an existing integration. Only provided fields are changed.
When ``credentials`` is supplied the stored value is replaced in full
with the freshly encrypted version of the new credentials dict.
"""
integration = (
db.query(UserIntegration)
.filter(UserIntegration.id == integration_id, UserIntegration.owner_id == owner_id)
.first()
)
if not integration:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Integration not found")
if body.name is not None:
integration.name = body.name
if body.config is not None:
integration.config = json.dumps(body.config)
if body.credentials is not None:
integration.credentials = _encode_credentials(body.credentials)
if body.is_active is not None:
integration.is_active = body.is_active
# Reset last_error so the next operation gives a fresh result
integration.last_error = None
try:
db.commit()
db.refresh(integration)
except Exception:
db.rollback()
raise
logger.info("User %s updated integration %d", owner_id, integration_id)
return _to_response(integration)
@router.delete("/{integration_id}", status_code=status.HTTP_204_NO_CONTENT, summary="Delete an integration")
def delete_integration(
integration_id: int,
request: Request,
db: DbSession,
owner_id: CurrentOwner,
) -> None:
"""Delete an integration permanently."""
integration = (
db.query(UserIntegration)
.filter(UserIntegration.id == integration_id, UserIntegration.owner_id == owner_id)
.first()
)
if not integration:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Integration not found")
try:
db.delete(integration)
db.commit()
except Exception:
db.rollback()
raise
logger.info("User %s deleted integration %d", owner_id, integration_id)
# ---------------------------------------------------------------------------
# Connection test helpers
# ---------------------------------------------------------------------------
def _test_imap_connection(config: dict[str, Any] | None, credentials: dict[str, Any] | None) -> dict[str, Any]:
"""Test an IMAP connection using the provided config and credentials."""
import imaplib
cfg = config or {}
creds = credentials or {}
host = cfg.get("host", "")
port = int(cfg.get("port", 993))
username = cfg.get("username", "")
password = creds.get("password", "")
use_ssl = cfg.get("use_ssl", True)
if not host or not username or not password:
return {"success": False, "message": "Missing required fields: host, username, and password"}
try:
if use_ssl:
mail = imaplib.IMAP4_SSL(host, port)
else:
mail = imaplib.IMAP4(host, port)
mail.login(username, password)
mail.logout()
return {"success": True, "message": "IMAP connection successful"}
except OSError as exc:
logger.warning("IMAP network error for %s@%s: %s", username, host, exc)
return {"success": False, "message": "IMAP connection failed — check host, port, and network connectivity"}
except Exception as exc: # noqa: BLE001
logger.warning("IMAP error for %s@%s: %s", username, host, exc)
return {"success": False, "message": "IMAP authentication or connection failed"}
def _test_s3_connection(config: dict[str, Any] | None, credentials: dict[str, Any] | None) -> dict[str, Any]:
"""Test an S3 connection by calling HeadBucket."""
try:
import boto3
from botocore.exceptions import BotoCoreError, ClientError
except ImportError:
return {"success": False, "message": "boto3 is not installed"}
cfg = config or {}
creds = credentials or {}
bucket = cfg.get("bucket", "")
region = cfg.get("region", "us-east-1")
if not bucket:
return {"success": False, "message": "Missing required field: bucket"}
try:
client = boto3.client(
"s3",
region_name=region,
aws_access_key_id=creds.get("access_key_id", ""),
aws_secret_access_key=creds.get("secret_access_key", ""),
endpoint_url=cfg.get("endpoint_url"),
)
client.head_bucket(Bucket=bucket)
return {"success": True, "message": f"S3 bucket '{bucket}' is accessible"}
except (BotoCoreError, ClientError) as exc:
logger.warning("S3 connection error for bucket '%s': %s", bucket, exc)
return {"success": False, "message": "S3 connection failed — check bucket name, region, and credentials"}
except Exception as exc: # noqa: BLE001
logger.warning("S3 unexpected error for bucket '%s': %s", bucket, exc)
return {"success": False, "message": "S3 connection failed"}
def _test_dropbox_connection(config: dict[str, Any] | None, credentials: dict[str, Any] | None) -> dict[str, Any]:
"""Test a Dropbox connection by verifying OAuth credentials via the Dropbox API."""
if dbx_lib is None:
return {"success": False, "message": "dropbox package is not installed"} # pragma: no cover
creds = credentials or {}
app_key = creds.get("app_key", "")
app_secret = creds.get("app_secret", "")
refresh_token = creds.get("refresh_token", "")
if not refresh_token:
return {"success": False, "message": "Missing required credential: refresh_token"}
if not app_key or not app_secret:
return {"success": False, "message": "Missing required credentials: app_key and app_secret"}
try:
dbx = dbx_lib.Dropbox(
app_key=app_key,
app_secret=app_secret,
oauth2_refresh_token=refresh_token,
)
account = dbx.users_get_current_account()
display_name = getattr(account, "name", None)
name_str = ""
if display_name:
name_str = f" ({getattr(display_name, 'display_name', '') or ''})"
return {"success": True, "message": f"Dropbox connection successful{name_str}"}
except _DropboxAuthError as exc:
logger.warning("Dropbox auth error: %s", exc)
return {
"success": False,
"message": "Dropbox authentication failed — check app_key, app_secret, and refresh_token",
}
except _DropboxBadInputError as exc:
logger.warning("Dropbox bad input error: %s", exc)
return {"success": False, "message": "Dropbox connection failed — invalid credentials format"}
except Exception as exc: # noqa: BLE001
logger.warning("Dropbox connection error: %s", exc)
return {"success": False, "message": "Dropbox connection failed — check credentials and network connectivity"}
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."""
import httpx
cfg = config or {}
creds = credentials or {}
url = cfg.get("url", "")
username = creds.get("username", "")
password = creds.get("password", "")
if not url:
return {"success": False, "message": "Missing required field: url"}
# Only allow http/https to prevent file:// or other custom scheme attacks
from urllib.parse import urlparse
parsed = urlparse(url)
if parsed.scheme not in ("http", "https"):
return {"success": False, "message": "URL must use http or https scheme"}
# Block requests to private/internal IPs to prevent SSRF
hostname = parsed.hostname or ""
if hostname:
from app.utils.network import is_private_ip
if is_private_ip(hostname):
return {"success": False, "message": "URLs pointing to internal or private networks are not allowed"}
try:
auth = (username, password) if username and password else None
headers = {"Depth": "0"}
# Use httpx for secure connection testing, avoiding urllib vulnerabilities
resp = httpx.request("PROPFIND", url, auth=auth, headers=headers, timeout=10.0, follow_redirects=False)
if resp.status_code < 400:
return {"success": True, "message": "WebDAV connection successful"}
return {"success": False, "message": f"WebDAV returned HTTP {resp.status_code}"}
except Exception as exc: # noqa: BLE001
logger.warning("WebDAV connection error for %s: %s", hostname, exc)
return {"success": False, "message": "WebDAV connection failed — check URL and credentials"}
_CONNECTION_TESTERS: dict[str, Any] = {
IntegrationType.DROPBOX: _test_dropbox_connection,
IntegrationType.IMAP: _test_imap_connection,
IntegrationType.S3: _test_s3_connection,
IntegrationType.WEBDAV: _test_webdav_connection,
IntegrationType.NEXTCLOUD: _test_webdav_connection,
}
# ---------------------------------------------------------------------------
# Test & quota endpoints
# ---------------------------------------------------------------------------
@router.post("/{integration_id}/test", summary="Test a saved integration connection")
def test_saved_integration_connection(
integration_id: int,
request: Request,
db: DbSession,
owner_id: CurrentOwner,
) -> dict[str, Any]:
"""Test connection for an already-saved integration."""
integration = (
db.query(UserIntegration)
.filter(UserIntegration.id == integration_id, UserIntegration.owner_id == owner_id)
.first()
)
if not integration:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Integration not found")
tester = _CONNECTION_TESTERS.get(integration.integration_type)
if tester is None:
return {
"success": False,
"message": f"Connection testing is not yet supported for '{integration.integration_type}'. "
"The integration can still be saved and will be validated on first use.",
}
try:
config = json.loads(integration.config) if integration.config else {}
except (json.JSONDecodeError, TypeError):
logger.warning(
"Invalid JSON in integration.config for integration_id=%s, type=%s",
integration.id,
integration.integration_type,
)
return {
"success": False,
"message": (
"Saved integration configuration is invalid and cannot be tested. "
"Please edit and re-save the integration, then try again."
),
}
credentials = _decode_credentials(integration.credentials) or {}
try:
return tester(config, credentials)
except Exception:
logger.exception(
"Unexpected error testing integration_id=%s, type=%s",
integration.id,
integration.integration_type,
)
return {
"success": False,
"message": "An unexpected error occurred while testing the connection. Please check your configuration.",
}
@router.post("/test", summary="Test an integration connection without saving")
def test_integration_connection(
request: Request,
body: IntegrationTestRequest,
owner_id: CurrentOwner,
) -> dict[str, Any]:
"""Test integration credentials without persisting anything.
Useful for the "Test connection" button in the UI before the user saves
a new integration. Returns ``{"success": bool, "message": str}``.
"""
_validate_integration_type(body.integration_type)
tester = _CONNECTION_TESTERS.get(body.integration_type)
if tester is None:
return {
"success": False,
"message": f"Connection testing is not yet supported for '{body.integration_type}'. "
"The integration can still be saved and will be validated on first use.",
}
return tester(body.config, body.credentials)
@router.get("/quota/", summary="Get integration quota information for the current user")
def get_integration_quota(
request: Request,
db: DbSession,
owner_id: CurrentOwner,
) -> dict[str, Any]:
"""Return the user's current integration usage vs. their plan quota.
Includes separate counts for destinations and IMAP sources.
"""
tier_id = get_user_tier_id(db, owner_id)
tier = get_tier(tier_id, db)
max_dest = _get_max_destinations(tier)
max_src = _get_max_sources(tier)
dest_count = (
db.query(UserIntegration)
.filter(
UserIntegration.owner_id == owner_id,
UserIntegration.direction == IntegrationDirection.DESTINATION,
)
.count()
)
src_count = (
db.query(UserIntegration)
.filter(
UserIntegration.owner_id == owner_id,
UserIntegration.direction == IntegrationDirection.SOURCE,
UserIntegration.integration_type.in_(list(_MAILBOX_SOURCE_TYPES)),
)
.count()
)
return {
"tier_id": tier_id,
"tier_name": tier.get("name", tier_id),
"destinations": {
"current_count": dest_count,
"max_allowed": max_dest,
"can_add": max_dest is None or dest_count < max_dest,
},
"sources": {
"current_count": src_count,
"max_allowed": max_src,
"can_add": max_src is None or (max_src > 0 and src_count < max_src),
},
}
+11 -9
View File
@@ -26,6 +26,7 @@ from starlette.responses import RedirectResponse
from app.config import settings
from app.database import get_db
from app.models import LocalUser, UserProfile
from app.utils.i18n import translate as _translate
from app.utils.local_auth import (
build_session_user,
generate_token,
@@ -41,6 +42,7 @@ router = APIRouter(tags=["local-auth"])
_templates_dir = pathlib.Path(__file__).parents[2] / "frontend" / "templates"
templates = Jinja2Templates(directory=str(_templates_dir))
templates.env.globals["_"] = lambda key, **kwargs: _translate(key, "en", **kwargs)
DbSession = Annotated[Session, Depends(get_db)]
@@ -99,9 +101,9 @@ async def signup_page(request: Request) -> Any:
if not settings.allow_local_signup:
return RedirectResponse(url="/login?error=Registration+is+not+enabled", status_code=302)
return templates.TemplateResponse(
request,
"signup.html",
{
"request": request,
context={
"csrf_token": getattr(request.state, "csrf_token", ""),
"app_version": settings.version,
},
@@ -111,16 +113,16 @@ async def signup_page(request: Request) -> Any:
@router.get("/verify-email-sent", include_in_schema=False)
async def verify_email_sent_page(request: Request) -> Any:
"""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)
async def forgot_username_page(request: Request) -> Any:
"""Render the forgot-username page where users can request a username reminder email."""
return templates.TemplateResponse(
request,
"forgot_username.html",
{
"request": request,
context={
"csrf_token": getattr(request.state, "csrf_token", ""),
"app_version": settings.version,
},
@@ -131,9 +133,9 @@ async def forgot_username_page(request: Request) -> Any:
async def forgot_password_page(request: Request) -> Any:
"""Render the forgot-password page where users can request a reset email."""
return templates.TemplateResponse(
request,
"forgot_password.html",
{
"request": request,
context={
"csrf_token": getattr(request.state, "csrf_token", ""),
"app_version": settings.version,
},
@@ -145,9 +147,9 @@ async def reset_password_page(request: Request) -> Any:
"""Render the password reset form page."""
token = request.query_params.get("token", "")
return templates.TemplateResponse(
request,
"password_reset_form.html",
{
"request": request,
context={
"token": token,
"csrf_token": getattr(request.state, "csrf_token", ""),
"app_version": settings.version,
+362
View File
@@ -0,0 +1,362 @@
"""Mobile app API endpoints.
Provides endpoints specifically designed for the DocuElevate native mobile
app (iOS / Android via React Native / Expo):
* ``POST /mobile/generate-token`` exchange an active session for a
long-lived API token that the mobile app stores securely. The token is
auto-named "Mobile App <device_name>" and is identical to regular API
tokens (Bearer auth works everywhere).
* ``POST /mobile/register-device`` register a push-notification device
token (Expo push token) so the user receives push notifications when
documents finish processing.
* ``GET /mobile/devices`` list registered devices for the current user.
* ``DELETE /mobile/devices/{device_id}`` deactivate a device.
* ``GET /mobile/whoami`` lightweight profile endpoint for the mobile app
to verify authentication state.
"""
import logging
from datetime import datetime, timezone
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.api.api_tokens import generate_api_token, hash_token
from app.auth import require_login
from app.database import get_db
from app.models import ApiToken, MobileDevice
from app.utils.user_scope import get_current_owner_id
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/mobile", tags=["mobile"])
DbSession = Annotated[Session, Depends(get_db)]
# ---------------------------------------------------------------------------
# Auth helper
# ---------------------------------------------------------------------------
def _get_owner_id(request: Request) -> str:
"""Return the current user's owner ID, raising 401 if unauthenticated."""
owner_id = get_current_owner_id(request)
if not owner_id:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated")
return owner_id
CurrentOwner = Annotated[str, Depends(_get_owner_id)]
# ---------------------------------------------------------------------------
# Request / Response schemas
# ---------------------------------------------------------------------------
class GenerateTokenRequest(BaseModel):
"""Request body for auto-generating a mobile app token."""
device_name: str = Field(
default="Mobile App",
min_length=1,
max_length=120,
description="Human-readable device name used to label the token.",
)
class GenerateTokenResponse(BaseModel):
"""Response containing the one-time-visible API token."""
token: str
token_id: int
name: str
created_at: datetime
class RegisterDeviceRequest(BaseModel):
"""Request body for registering a push-notification device token."""
push_token: str = Field(
min_length=1,
max_length=512,
description="Expo push token (ExponentPushToken[…]) obtained from the mobile app.",
)
device_name: str | None = Field(
default=None,
max_length=255,
description="Optional human-readable device name (e.g. 'John's iPhone').",
)
platform: str = Field(
default="ios",
description="Device platform: 'ios', 'android', or 'web'.",
)
class DeviceResponse(BaseModel):
"""Serialised MobileDevice record."""
id: int
device_name: str | None
platform: str
push_token_preview: str
is_active: bool
created_at: datetime
last_seen_at: datetime | None
class WhoAmIResponse(BaseModel):
"""Lightweight profile response for the mobile app."""
owner_id: str
display_name: str | None
email: str | None
avatar_url: str | None
is_admin: bool
preferred_language: str | None
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _device_to_response(device: MobileDevice) -> dict[str, Any]:
"""Convert a MobileDevice ORM object to a serialisable dict."""
# Show only first 20 chars of the push token for security.
token_preview = device.push_token[:20] + "" if len(device.push_token) > 20 else device.push_token
return {
"id": device.id,
"device_name": device.device_name,
"platform": device.platform,
"push_token_preview": token_preview,
"is_active": device.is_active,
"created_at": device.created_at,
"last_seen_at": device.last_seen_at,
}
# ---------------------------------------------------------------------------
# Endpoints
# ---------------------------------------------------------------------------
@router.post("/generate-token", status_code=status.HTTP_201_CREATED, response_model=GenerateTokenResponse)
@require_login
async def generate_mobile_token(
request: Request,
body: GenerateTokenRequest,
owner_id: CurrentOwner,
db: DbSession,
) -> dict[str, Any]:
"""Generate a long-lived API token for the mobile app.
The mobile app calls this endpoint immediately after SSO login to obtain
a Bearer token it can store in the secure keychain. The returned token
is functionally identical to manually-created API tokens and works with
every authenticated endpoint.
The token is shown **exactly once** in the response; subsequent requests
show only the prefix for identification.
"""
token_name = f"Mobile App {body.device_name}"
plaintext = generate_api_token()
token_hash_value = hash_token(plaintext)
prefix = plaintext[:12]
db_token = ApiToken(
owner_id=owner_id,
name=token_name,
token_hash=token_hash_value,
token_prefix=prefix,
)
try:
db.add(db_token)
db.commit()
db.refresh(db_token)
except Exception:
db.rollback()
logger.exception("Failed to create mobile API token for owner_id=%s", owner_id)
raise
logger.info("Mobile API token created: id=%s owner=%s device=%r", db_token.id, owner_id, body.device_name)
return {
"token": plaintext,
"token_id": db_token.id,
"name": token_name,
"created_at": db_token.created_at,
}
@router.post("/register-device", status_code=status.HTTP_201_CREATED, response_model=DeviceResponse)
@require_login
async def register_device(
request: Request,
body: RegisterDeviceRequest,
owner_id: CurrentOwner,
db: DbSession,
) -> dict[str, Any]:
"""Register or refresh a push-notification device token.
If the same ``push_token`` is already registered for this user the
record is reactivated and ``last_seen_at`` is updated rather than
creating a duplicate.
"""
platform = body.platform.lower()
if platform not in {"ios", "android", "web"}:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="platform must be one of: ios, android, web",
)
now = datetime.now(timezone.utc)
# Upsert: reuse existing record if the token is already known.
existing = (
db.query(MobileDevice)
.filter(MobileDevice.owner_id == owner_id, MobileDevice.push_token == body.push_token)
.first()
)
if existing:
existing.is_active = True
existing.last_seen_at = now
if body.device_name:
existing.device_name = body.device_name
try:
db.commit()
db.refresh(existing)
except Exception:
db.rollback()
raise
logger.info("Mobile device refreshed: id=%s owner=%s", existing.id, owner_id)
return _device_to_response(existing)
device = MobileDevice(
owner_id=owner_id,
device_name=body.device_name,
platform=platform,
push_token=body.push_token,
is_active=True,
last_seen_at=now,
)
try:
db.add(device)
db.commit()
db.refresh(device)
except Exception:
db.rollback()
logger.exception("Failed to register mobile device for owner_id=%s", owner_id)
raise
logger.info("Mobile device registered: id=%s owner=%s platform=%s", device.id, owner_id, platform)
return _device_to_response(device)
@router.get("/devices", response_model=list[DeviceResponse])
@require_login
async def list_devices(
request: Request,
owner_id: CurrentOwner,
db: DbSession,
) -> list[dict[str, Any]]:
"""List all registered push-notification devices for the current user."""
devices = (
db.query(MobileDevice).filter(MobileDevice.owner_id == owner_id).order_by(MobileDevice.created_at.desc()).all()
)
return [_device_to_response(d) for d in devices]
@router.delete("/devices/{device_id}", status_code=status.HTTP_200_OK)
@require_login
async def deactivate_device(
request: Request,
device_id: int,
owner_id: CurrentOwner,
db: DbSession,
) -> dict[str, str]:
"""Deactivate or permanently delete a push-notification device registration.
* **Active device** soft-deactivated: the record is kept for audit
purposes but will no longer receive push notifications.
* **Already-inactive device** hard-deleted: the record is permanently
removed from the database.
"""
device = db.get(MobileDevice, device_id)
if not device or device.owner_id != owner_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Device not found")
if device.is_active:
device.is_active = False
try:
db.commit()
except Exception:
db.rollback()
raise
logger.info("Mobile device deactivated: id=%s owner=%s", device_id, owner_id)
return {"detail": "Device deactivated"}
# Hard-delete an already-inactive device.
try:
db.delete(device)
db.commit()
except Exception:
db.rollback()
raise
logger.info("Mobile device permanently deleted: id=%s owner=%s", device_id, owner_id)
return {"detail": "Device deleted"}
@router.get("/whoami", response_model=WhoAmIResponse)
@require_login
async def whoami(
request: Request,
owner_id: CurrentOwner,
db: DbSession,
) -> dict[str, Any]:
"""Return basic profile information for the authenticated user.
The mobile app calls this after token exchange to populate the user
profile screen and verify that the stored token is still valid.
"""
from app.auth import get_gravatar_url
from app.models import LocalUser, UserProfile
profile = db.query(UserProfile).filter(UserProfile.user_id == owner_id).first()
local_user = db.query(LocalUser).filter(LocalUser.email == owner_id).first()
display_name: str | None = None
email: str | None = None
avatar_url: str | None = None
is_admin = False
if profile:
display_name = profile.display_name
if local_user:
email = local_user.email
is_admin = bool(local_user.is_admin)
if not display_name and local_user.display_name:
display_name = local_user.display_name
elif "@" in owner_id:
# SSO users commonly have their email as owner_id
email = owner_id
if email:
avatar_url = get_gravatar_url(email)
return {
"owner_id": owner_id,
"display_name": display_name,
"email": email,
"avatar_url": avatar_url,
"is_admin": is_admin,
"preferred_language": profile.preferred_language if profile else None,
}
+483
View File
@@ -0,0 +1,483 @@
"""API endpoints for per-user notification targets, preferences, and in-app inbox.
Users can define notification targets (email via SMTP, webhook via HTTP POST)
and configure which document events trigger which targets. In-app notifications
are always created and surfaced via the bell icon / inbox endpoints.
"""
import json
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.database import get_db
from app.models import InAppNotification, UserNotificationPreference, UserNotificationTarget
from app.utils.user_notification import USER_EVENT_LABELS
from app.utils.user_scope import get_current_owner_id
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/user-notifications", tags=["user-notifications"])
DbSession = Annotated[Session, Depends(get_db)]
# ---------------------------------------------------------------------------
# Auth helper (mirrors api_tokens.py pattern)
# ---------------------------------------------------------------------------
def _get_owner_id(request: Request) -> str:
"""Return the current user's owner ID, raising 401 if unauthenticated."""
owner_id = get_current_owner_id(request)
if not owner_id:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated")
return owner_id
CurrentOwner = Annotated[str, Depends(_get_owner_id)]
# ---------------------------------------------------------------------------
# Pydantic schemas
# ---------------------------------------------------------------------------
VALID_CHANNEL_TYPES = {"email", "webhook"}
VALID_EVENT_TYPES = set(USER_EVENT_LABELS.keys())
class NotificationTargetCreate(BaseModel):
"""Schema for creating a new notification target."""
channel_type: str = Field(..., pattern="^(email|webhook)$")
name: str = Field(..., min_length=1, max_length=255)
config: dict[str, Any] = Field(default_factory=dict)
is_active: bool = True
class NotificationTargetUpdate(BaseModel):
"""Schema for updating an existing notification target."""
name: str | None = Field(None, min_length=1, max_length=255)
config: dict[str, Any] | None = None
is_active: bool | None = None
class PreferenceItem(BaseModel):
"""A single preference toggle for one event+channel combination."""
is_enabled: bool
target_id: int | None = None
class PreferenceItemFull(BaseModel):
"""Full preference item including event and channel type (used in bulk update)."""
event_type: str
channel_type: str
is_enabled: bool
target_id: int | None = None
class PreferencesUpdate(BaseModel):
"""Bulk preferences update payload — a flat list of preference items."""
preferences: list[PreferenceItemFull]
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _mask_email_config(config: dict[str, Any]) -> dict[str, Any]:
"""Return a copy of an email config dict with the password masked."""
masked = dict(config)
if masked.get("smtp_password"):
masked["smtp_password"] = "****"
return masked
def _target_to_dict(target: UserNotificationTarget) -> dict[str, Any]:
"""Serialize a UserNotificationTarget to a response dict, masking secrets."""
config: dict[str, Any] = {}
if target.config:
try:
config = json.loads(target.config)
except (json.JSONDecodeError, ValueError):
config = {}
if target.channel_type == "email":
config = _mask_email_config(config)
return {
"id": target.id,
"channel_type": target.channel_type,
"name": target.name,
"config": config,
"is_active": target.is_active,
"created_at": target.created_at,
"updated_at": target.updated_at,
}
# ---------------------------------------------------------------------------
# Inbox endpoints
# ---------------------------------------------------------------------------
@router.get("/inbox")
async def list_inbox(
owner_id: CurrentOwner,
db: DbSession,
skip: int = 0,
limit: int = 50,
) -> list[dict[str, Any]]:
"""List in-app notifications for the authenticated user, newest first."""
notifications = (
db.query(InAppNotification)
.filter(InAppNotification.owner_id == owner_id)
.order_by(InAppNotification.created_at.desc())
.offset(skip)
.limit(limit)
.all()
)
return [
{
"id": n.id,
"event_type": n.event_type,
"title": n.title,
"message": n.message,
"is_read": n.is_read,
"file_id": n.file_id,
"created_at": n.created_at,
}
for n in notifications
]
@router.get("/inbox/unread-count")
async def unread_count(
owner_id: CurrentOwner,
db: DbSession,
) -> dict[str, int]:
"""Return the number of unread in-app notifications."""
count = (
db.query(InAppNotification)
.filter(InAppNotification.owner_id == owner_id, InAppNotification.is_read == False) # noqa: E712
.count()
)
return {"count": count}
@router.post("/inbox/{notification_id}/read", status_code=status.HTTP_200_OK)
async def mark_read(
notification_id: int,
owner_id: CurrentOwner,
db: DbSession,
) -> dict[str, str]:
"""Mark a single in-app notification as read."""
notif = (
db.query(InAppNotification)
.filter(InAppNotification.id == notification_id, InAppNotification.owner_id == owner_id)
.first()
)
if not notif:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Notification not found")
try:
notif.is_read = True
db.commit()
except Exception:
db.rollback()
raise
return {"detail": "Marked as read"}
@router.post("/inbox/read-all", status_code=status.HTTP_200_OK)
async def mark_all_read(
owner_id: CurrentOwner,
db: DbSession,
) -> dict[str, str]:
"""Mark all in-app notifications as read for the authenticated user."""
try:
db.query(InAppNotification).filter(
InAppNotification.owner_id == owner_id,
InAppNotification.is_read == False, # noqa: E712
).update({"is_read": True})
db.commit()
except Exception:
db.rollback()
raise
return {"detail": "All notifications marked as read"}
# ---------------------------------------------------------------------------
# Notification target endpoints
# ---------------------------------------------------------------------------
@router.get("/targets")
async def list_targets(
owner_id: CurrentOwner,
db: DbSession,
) -> list[dict[str, Any]]:
"""List all notification targets for the authenticated user."""
targets = (
db.query(UserNotificationTarget)
.filter(UserNotificationTarget.owner_id == owner_id)
.order_by(UserNotificationTarget.created_at.desc())
.all()
)
return [_target_to_dict(t) for t in targets]
@router.post("/targets", status_code=status.HTTP_201_CREATED)
async def create_target(
body: NotificationTargetCreate,
owner_id: CurrentOwner,
db: DbSession,
) -> dict[str, Any]:
"""Create a new notification target (email or webhook)."""
target = UserNotificationTarget(
owner_id=owner_id,
channel_type=body.channel_type,
name=body.name,
config=json.dumps(body.config),
is_active=body.is_active,
)
try:
db.add(target)
db.commit()
db.refresh(target)
except Exception:
db.rollback()
raise
logger.info("Notification target created: id=%s owner=%s type=%s", target.id, owner_id, body.channel_type)
return _target_to_dict(target)
@router.put("/targets/{target_id}", status_code=status.HTTP_200_OK)
async def update_target(
target_id: int,
body: NotificationTargetUpdate,
owner_id: CurrentOwner,
db: DbSession,
) -> dict[str, Any]:
"""Update an existing notification target."""
target = (
db.query(UserNotificationTarget)
.filter(UserNotificationTarget.id == target_id, UserNotificationTarget.owner_id == owner_id)
.first()
)
if not target:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Target not found")
try:
if body.name is not None:
target.name = body.name
if body.config is not None:
# Merge new config over existing, preserving masked password field if unchanged
existing_config: dict[str, Any] = {}
if target.config:
try:
existing_config = json.loads(target.config)
except (json.JSONDecodeError, ValueError):
existing_config = {}
merged = dict(existing_config)
for k, v in body.config.items():
# Skip writing back a masked password placeholder
if k == "smtp_password" and v == "****":
continue
merged[k] = v
target.config = json.dumps(merged)
if body.is_active is not None:
target.is_active = body.is_active
db.commit()
db.refresh(target)
except Exception:
db.rollback()
raise
logger.info("Notification target updated: id=%s owner=%s", target_id, owner_id)
return _target_to_dict(target)
@router.delete("/targets/{target_id}", status_code=status.HTTP_200_OK)
async def delete_target(
target_id: int,
owner_id: CurrentOwner,
db: DbSession,
) -> dict[str, str]:
"""Delete a notification target and its associated preferences."""
target = (
db.query(UserNotificationTarget)
.filter(UserNotificationTarget.id == target_id, UserNotificationTarget.owner_id == owner_id)
.first()
)
if not target:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Target not found")
try:
# Remove any preferences that reference this target
db.query(UserNotificationPreference).filter(
UserNotificationPreference.owner_id == owner_id,
UserNotificationPreference.target_id == target_id,
).delete()
db.delete(target)
db.commit()
except Exception:
db.rollback()
raise
logger.info("Notification target deleted: id=%s owner=%s", target_id, owner_id)
return {"detail": "Target deleted"}
@router.post("/targets/{target_id}/test", status_code=status.HTTP_200_OK)
async def test_target(
target_id: int,
owner_id: CurrentOwner,
db: DbSession,
) -> dict[str, str]:
"""Send a test notification to the specified target."""
target = (
db.query(UserNotificationTarget)
.filter(UserNotificationTarget.id == target_id, UserNotificationTarget.owner_id == owner_id)
.first()
)
if not target:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Target not found")
config: dict[str, Any] = {}
if target.config:
try:
config = json.loads(target.config)
except (json.JSONDecodeError, ValueError):
config = {}
title = "DocuElevate Test Notification"
message = f"This is a test notification from DocuElevate for target '{target.name}'."
if target.channel_type == "email":
from app.utils.user_notification import _send_email_notification
ok = _send_email_notification(config, title, message)
elif target.channel_type == "webhook":
from app.utils.user_notification import _send_webhook_notification
ok = _send_webhook_notification(config, "test", title, message)
else:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Unknown channel type")
if not ok:
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail="Failed to send test notification")
return {"detail": "Test notification sent"}
# ---------------------------------------------------------------------------
# Preferences endpoints
# ---------------------------------------------------------------------------
@router.get("/preferences")
async def get_preferences(
owner_id: CurrentOwner,
db: DbSession,
) -> dict[str, Any]:
"""Return all notification preferences for the authenticated user.
Response structure:
{
"event_types": ["document.processed", "document.failed"],
"event_labels": {"document.processed": "Document Processed", ...},
"preferences": {
"document.processed": {
"in_app": {"is_enabled": true, "target_id": null},
"email": {"is_enabled": false, "target_id": 1},
...
}
}
}
"""
prefs = db.query(UserNotificationPreference).filter(UserNotificationPreference.owner_id == owner_id).all()
# Build nested dict: event_type -> channel_type -> {is_enabled, target_id}
result: dict[str, dict[str, dict[str, Any]]] = {}
for pref in prefs:
result.setdefault(pref.event_type, {})[pref.channel_type] = {
"is_enabled": pref.is_enabled,
"target_id": pref.target_id,
}
return {
"event_types": list(USER_EVENT_LABELS.keys()),
"event_labels": USER_EVENT_LABELS,
"preferences": result,
}
@router.put("/preferences", status_code=status.HTTP_200_OK)
async def update_preferences(
body: PreferencesUpdate,
owner_id: CurrentOwner,
db: DbSession,
) -> dict[str, str]:
"""Bulk upsert notification preferences for the authenticated user.
Validates that any referenced target_id belongs to the requesting user.
"""
# Collect all target IDs referenced in the payload for ownership validation
referenced_target_ids: set[int] = set()
for item in body.preferences:
if item.target_id is not None:
referenced_target_ids.add(item.target_id)
if referenced_target_ids:
owned_ids = {
row.id
for row in db.query(UserNotificationTarget.id)
.filter(
UserNotificationTarget.owner_id == owner_id,
UserNotificationTarget.id.in_(referenced_target_ids),
)
.all()
}
invalid = referenced_target_ids - owned_ids
if invalid:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Invalid or inaccessible target_id(s): {sorted(invalid)}",
)
try:
# Pre-fetch existing preferences for this user to avoid N+1 queries
existing_prefs = (
db.query(UserNotificationPreference).filter(UserNotificationPreference.owner_id == owner_id).all()
)
# Build a fast lookup dictionary keyed by (event_type, channel_type, target_id)
prefs_dict = {(pref.event_type, pref.channel_type, pref.target_id): pref for pref in existing_prefs}
for item in body.preferences:
existing = prefs_dict.get((item.event_type, item.channel_type, item.target_id))
if existing:
existing.is_enabled = item.is_enabled
else:
db.add(
UserNotificationPreference(
owner_id=owner_id,
event_type=item.event_type,
channel_type=item.channel_type,
target_id=item.target_id,
is_enabled=item.is_enabled,
)
)
db.commit()
except Exception:
db.rollback()
raise
logger.info("Notification preferences updated for owner=%s", owner_id)
return {"detail": "Preferences updated"}
+137 -111
View File
@@ -3,10 +3,10 @@ OneDrive API endpoints
"""
import logging
import os
from datetime import datetime, timedelta
from typing import Annotated, Optional
import httpx
import requests
from fastapi import APIRouter, Depends, Form, HTTPException, Request, status
from sqlalchemy.orm import Session
@@ -14,6 +14,7 @@ from sqlalchemy.orm import Session
from app.auth import require_login
from app.config import settings
from app.database import get_db
from app.utils.env_utils import update_env_file
from app.utils.oauth_helper import exchange_oauth_token
from app.utils.settings_service import save_setting_to_db
from app.utils.settings_sync import notify_settings_updated
@@ -56,6 +57,7 @@ async def exchange_onedrive_token(
# Return just what's needed by the frontend
return {
"refresh_token": token_data["refresh_token"],
"access_token": token_data.get("access_token", ""),
"expires_in": token_data.get("expires_in", 3600),
}
@@ -92,17 +94,18 @@ async def test_onedrive_token(request: Request):
"scope": "offline_access Files.ReadWrite",
}
response = requests.post(token_url, data=refresh_data, timeout=settings.http_request_timeout)
async with httpx.AsyncClient(timeout=settings.http_request_timeout) as client:
response = await client.post(token_url, data=refresh_data)
if response.status_code != 200:
logger.error(f"Failed to refresh OneDrive token: {response.text}")
return {
"status": "error",
"message": "Refresh token has expired or is invalid",
"needs_reauth": True,
}
if response.status_code != 200:
logger.error(f"Failed to refresh OneDrive token: {response.text}")
return {
"status": "error",
"message": "Refresh token has expired or is invalid",
"needs_reauth": True,
}
token_data = response.json()
token_data = response.json()
access_token = token_data.get("access_token")
expires_in = token_data.get("expires_in", 3600) # Default to 1 hour if not specified
@@ -115,32 +118,7 @@ async def test_onedrive_token(request: Request):
settings.onedrive_refresh_token = new_refresh_token
# Also try to update .env file if it exists
try:
env_path = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), ".env")
if os.path.exists(env_path):
with open(env_path, "r") as f:
env_lines = f.readlines()
updated_lines = []
updated = False
for line in env_lines:
if line.startswith("ONEDRIVE_REFRESH_TOKEN="):
updated_lines.append(f"ONEDRIVE_REFRESH_TOKEN={new_refresh_token}\n")
updated = True
else:
updated_lines.append(line)
if not updated:
updated_lines.append(f"ONEDRIVE_REFRESH_TOKEN={new_refresh_token}\n")
with open(env_path, "w") as f:
f.writelines(updated_lines)
logger.info("Updated refresh token in .env file")
except Exception as e:
logger.warning(f"Failed to update refresh token in .env file: {e}")
update_env_file({"ONEDRIVE_REFRESH_TOKEN": new_refresh_token})
# Persist the rotated refresh token to the database
try:
@@ -164,17 +142,18 @@ async def test_onedrive_token(request: Request):
user_info_url = "https://graph.microsoft.com/v1.0/me"
headers = {"Authorization": f"Bearer {access_token}"}
user_response = requests.get(user_info_url, headers=headers, timeout=settings.http_request_timeout)
async with httpx.AsyncClient(timeout=settings.http_request_timeout) as client:
user_response = await client.get(user_info_url, headers=headers)
if user_response.status_code != 200:
logger.error(f"OneDrive token test failed: {user_response.status_code} {user_response.text}")
return {
"status": "error",
"message": f"Token validation failed with status {user_response.status_code}: {user_response.text}",
}
if user_response.status_code != 200:
logger.error(f"OneDrive token test failed: {user_response.status_code} {user_response.text}")
return {
"status": "error",
"message": f"Token validation failed with status {user_response.status_code}: {user_response.text}",
}
# Get user info
user_info = user_response.json()
# Get user info
user_info = user_response.json()
display_name = user_info.get("displayName", "Unknown user")
email = user_info.get("userPrincipalName", "Unknown email")
@@ -206,6 +185,102 @@ async def test_onedrive_token(request: Request):
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):
"""Format a timedelta into a human-readable string."""
if time_delta.total_seconds() <= 0:
@@ -246,75 +321,26 @@ async def save_onedrive_settings(
user.get("preferred_username") or user.get("username") or user.get("email") or user.get("id") or "wizard"
)
# Best-effort .env file write
try:
env_path = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), ".env")
if not os.path.exists(env_path):
logger.warning(f".env file not found at {env_path}, skipping file write")
else:
logger.info(f"Updating OneDrive settings in {env_path}")
# Build settings dictionary mapped to database/memory keys
onedrive_settings = {
"onedrive_refresh_token": refresh_token,
"onedrive_client_id": client_id,
"onedrive_client_secret": client_secret,
"onedrive_tenant_id": tenant_id,
"onedrive_folder_path": folder_path,
}
with open(env_path, "r") as f:
env_lines = f.readlines()
# Filter out None values
onedrive_settings = {k: v for k, v in onedrive_settings.items() if v is not None}
onedrive_settings = {"ONEDRIVE_REFRESH_TOKEN": refresh_token}
if client_id:
onedrive_settings["ONEDRIVE_CLIENT_ID"] = client_id
if client_secret:
onedrive_settings["ONEDRIVE_CLIENT_SECRET"] = client_secret
if tenant_id:
onedrive_settings["ONEDRIVE_TENANT_ID"] = tenant_id
if folder_path:
onedrive_settings["ONEDRIVE_FOLDER_PATH"] = folder_path
# Best-effort .env file write using the new utility
env_settings = {k.upper(): v for k, v in onedrive_settings.items()}
update_env_file(env_settings)
updated = set()
new_env_lines = []
for line in env_lines:
stripped_line = line.rstrip()
is_updated = False
for key, value in onedrive_settings.items():
if stripped_line.startswith(f"{key}=") or stripped_line.startswith(f"# {key}="):
new_env_lines.append(f"{key}={value}")
updated.add(key)
is_updated = True
break
if not is_updated:
new_env_lines.append(stripped_line)
for key, value in onedrive_settings.items():
if key not in updated:
new_env_lines.append(f"{key}={value}")
with open(env_path, "w") as f:
f.write("\n".join(new_env_lines) + "\n")
logger.info("Successfully updated OneDrive settings in .env file")
except Exception as env_err:
logger.warning(f"Failed to write .env file (non-fatal): {env_err}")
# Update the settings in memory
if refresh_token:
settings.onedrive_refresh_token = refresh_token
if client_id:
settings.onedrive_client_id = client_id
if client_secret:
settings.onedrive_client_secret = client_secret
if tenant_id:
settings.onedrive_tenant_id = tenant_id
if folder_path:
settings.onedrive_folder_path = folder_path
# Persist to database (primary)
if refresh_token:
save_setting_to_db(db, "onedrive_refresh_token", refresh_token, changed_by=changed_by)
if client_id:
save_setting_to_db(db, "onedrive_client_id", client_id, changed_by=changed_by)
if client_secret:
save_setting_to_db(db, "onedrive_client_secret", client_secret, changed_by=changed_by)
if tenant_id:
save_setting_to_db(db, "onedrive_tenant_id", tenant_id, changed_by=changed_by)
if folder_path:
save_setting_to_db(db, "onedrive_folder_path", folder_path, changed_by=changed_by)
# Update in-memory settings and persist to database dynamically
for key, value in onedrive_settings.items():
setattr(settings, key, value)
save_setting_to_db(db, key, value, changed_by=changed_by)
notify_settings_updated()
+59 -12
View File
@@ -51,7 +51,48 @@ PIPELINE_STEP_TYPES: dict[str, dict[str, Any]] = {
"type": "boolean",
"default": False,
"description": "Always use cloud OCR even if the PDF already has embedded text.",
}
},
"ocr_language": {
"type": "select",
"default": "auto",
"description": (
"Language(s) used for OCR text extraction. Applies to Tesseract and EasyOCR "
"providers; Azure and Mistral perform auto-detection by default. "
"Use Tesseract codes such as 'eng', 'deu', or 'eng+deu' for multi-language "
"documents. 'auto' falls back to the global system setting."
),
"options": [
{"value": "auto", "label": "Auto (use system default)"},
{"value": "ara", "label": "Arabic"},
{"value": "chi_sim", "label": "Chinese (Simplified)"},
{"value": "chi_tra", "label": "Chinese (Traditional)"},
{"value": "ces", "label": "Czech"},
{"value": "dan", "label": "Danish"},
{"value": "nld", "label": "Dutch"},
{"value": "eng", "label": "English"},
{"value": "fin", "label": "Finnish"},
{"value": "fra", "label": "French"},
{"value": "deu", "label": "German"},
{"value": "ell", "label": "Greek"},
{"value": "heb", "label": "Hebrew"},
{"value": "hin", "label": "Hindi"},
{"value": "hun", "label": "Hungarian"},
{"value": "ita", "label": "Italian"},
{"value": "jpn", "label": "Japanese"},
{"value": "kor", "label": "Korean"},
{"value": "nor", "label": "Norwegian"},
{"value": "pol", "label": "Polish"},
{"value": "por", "label": "Portuguese"},
{"value": "ron", "label": "Romanian"},
{"value": "rus", "label": "Russian"},
{"value": "spa", "label": "Spanish"},
{"value": "swe", "label": "Swedish"},
{"value": "tha", "label": "Thai"},
{"value": "tur", "label": "Turkish"},
{"value": "ukr", "label": "Ukrainian"},
{"value": "vie", "label": "Vietnamese"},
],
},
},
},
"extract_metadata": {
@@ -76,8 +117,14 @@ PIPELINE_STEP_TYPES: dict[str, dict[str, Any]] = {
},
"classify": {
"label": "Document Classification",
"description": "Classify the document type using AI without full metadata extraction.",
"config_schema": {},
"description": "Classify the document type using built-in and custom rules (filename patterns, content keywords, metadata matching).",
"config_schema": {
"use_builtin_rules": {
"type": "boolean",
"default": True,
"description": "Include the pre-built classification rules (invoice, contract, receipt, etc.).",
},
},
},
}
@@ -291,15 +338,15 @@ def create_pipeline(request: Request, db: DbSession, body: PipelineCreate) -> di
db.add(pipeline)
db.commit()
db.refresh(pipeline)
except Exception as exc:
except Exception:
db.rollback()
logger.exception(f"Failed to create pipeline user={user_id}: {exc}")
logger.exception("Failed to create pipeline user=%s", user_id)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to create pipeline",
)
logger.info(f"Pipeline created: id={pipeline.id}, owner={user_id}, name={name!r}")
logger.info("Pipeline created: id=%s, owner=%s, name=%r", pipeline.id, user_id, name)
return _serialize_pipeline(pipeline)
@@ -387,15 +434,15 @@ def update_pipeline(pipeline_id: int, request: Request, db: DbSession, body: Pip
try:
db.commit()
db.refresh(pipeline)
except Exception as exc:
except Exception:
db.rollback()
logger.exception(f"Failed to update pipeline id={pipeline_id}: {exc}")
logger.exception("Failed to update pipeline id=%s", pipeline_id)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to update pipeline",
)
logger.info(f"Pipeline updated: id={pipeline_id}, user={user_id}")
logger.info("Pipeline updated: id=%s, user=%s", pipeline_id, user_id)
return _serialize_pipeline(pipeline, include_steps=True, db=db)
@@ -425,15 +472,15 @@ def delete_pipeline(pipeline_id: int, request: Request, db: DbSession) -> None:
db.query(PipelineStep).filter(PipelineStep.pipeline_id == pipeline_id).delete()
db.delete(pipeline)
db.commit()
except Exception as exc:
except Exception:
db.rollback()
logger.exception(f"Failed to delete pipeline id={pipeline_id}: {exc}")
logger.exception("Failed to delete pipeline id=%s", pipeline_id)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to delete pipeline",
)
logger.info(f"Pipeline deleted: id={pipeline_id}, user={user_id}")
logger.info("Pipeline deleted: id=%s, user=%s", pipeline_id, user_id)
# ---------------------------------------------------------------------------
+17 -2
View File
@@ -75,6 +75,8 @@ class PlanUpsert(BaseModel):
sort_order: int = 0
features: list[str] = []
api_access: bool = False
stripe_price_id_monthly: str | None = None
stripe_price_id_yearly: str | None = None
class ReorderBody(BaseModel):
@@ -121,6 +123,8 @@ def _plan_to_response(plan: SubscriptionPlan) -> dict[str, Any]:
"sort_order": plan.sort_order,
"features": features,
"api_access": plan.api_access,
"stripe_price_id_monthly": plan.stripe_price_id_monthly,
"stripe_price_id_yearly": plan.stripe_price_id_yearly,
"created_at": plan.created_at.isoformat() if plan.created_at else None,
"updated_at": plan.updated_at.isoformat() if plan.updated_at else None,
}
@@ -151,6 +155,8 @@ def _apply_body(plan: SubscriptionPlan, body: PlanUpsert) -> None:
plan.sort_order = body.sort_order
plan.features = json.dumps(body.features)
plan.api_access = body.api_access
plan.stripe_price_id_monthly = body.stripe_price_id_monthly or None
plan.stripe_price_id_yearly = body.stripe_price_id_yearly or None
# ---------------------------------------------------------------------------
@@ -190,11 +196,20 @@ def seed_plans(db: DbSession, _admin: AdminUser) -> dict[str, Any]:
def reorder_plans(body: ReorderBody, db: DbSession, _admin: AdminUser) -> dict[str, Any]:
"""Update sort_order for each plan_id in *body.order* (position = index in list)."""
updated = 0
for sort_order, plan_id in enumerate(body.order):
plan = db.query(SubscriptionPlan).filter(SubscriptionPlan.plan_id == plan_id).first()
# Fetch all requested plans in a single query to avoid N+1
plan_ids = body.order
plans = db.query(SubscriptionPlan).filter(SubscriptionPlan.plan_id.in_(plan_ids)).all()
# Build a map for fast O(1) lookup
plan_map = {p.plan_id: p for p in plans}
for sort_order, plan_id in enumerate(plan_ids):
plan = plan_map.get(plan_id)
if plan:
plan.sort_order = sort_order
updated += 1
try:
db.commit()
except Exception:
+358
View File
@@ -0,0 +1,358 @@
"""User self-service profile API.
Provides endpoints for the authenticated user to view and update their own
profile settings without requiring admin access.
Routes:
GET /api/profile — read current user's profile
PATCH /api/profile — update display name, language, theme
POST /api/profile/avatar — upload a new profile picture (JPEG/PNG/GIF/WebP, max 2 MB)
DELETE /api/profile/avatar — remove custom avatar (reverts to Gravatar)
POST /api/profile/change-password — change password (local-auth users only)
"""
from __future__ import annotations
import base64
import logging
from hashlib import md5
from typing import Annotated
from fastapi import APIRouter, Depends, File, HTTPException, Request, Response, UploadFile, 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 LocalUser, UserProfile
from app.utils.i18n import SUPPORTED_LANGUAGE_CODES
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/profile", tags=["profile"])
DbSession = Annotated[Session, Depends(get_db)]
# Maximum avatar upload size: 2 MB
_MAX_AVATAR_BYTES = 2 * 1024 * 1024
# Allowed MIME types for avatar uploads
_ALLOWED_AVATAR_TYPES = {"image/jpeg", "image/png", "image/gif", "image/webp"}
# Valid theme values
_VALID_THEMES = {"light", "dark", "system"}
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _get_user_id(request: Request) -> str:
"""Return the stable user identifier from the session.
Raises HTTP 401 if no user is logged in.
"""
user = request.session.get("user")
if not user or not isinstance(user, dict):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated")
uid = user.get("sub") or user.get("preferred_username") or user.get("email") or user.get("id")
if not uid:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Cannot determine user identity")
return uid
def _gravatar_url(email: str | None) -> str:
"""Generate a Gravatar URL for *email*, falling back to identicon."""
if not email:
return "https://www.gravatar.com/avatar/?d=identicon"
# MD5 used for Gravatar URL generation only — not for security
h = md5(email.strip().lower().encode(), usedforsecurity=False).hexdigest()
return f"https://www.gravatar.com/avatar/{h}?d=identicon"
def _get_or_create_profile(db: Session, user_id: str) -> UserProfile:
"""Return the UserProfile for *user_id*, creating a stub if one doesn't exist."""
profile = db.query(UserProfile).filter(UserProfile.user_id == user_id).first()
if profile is None:
profile = UserProfile(user_id=user_id)
db.add(profile)
try:
db.commit()
db.refresh(profile)
except Exception:
db.rollback()
raise
return profile
# ---------------------------------------------------------------------------
# Pydantic schemas
# ---------------------------------------------------------------------------
class ProfileResponse(BaseModel):
"""Response body for GET /api/profile."""
user_id: str
display_name: str | None
contact_email: str | None
preferred_language: str | None
preferred_theme: str | None
default_document_language: str | None
"""ISO 639-1 code for the user's preferred document translation target language."""
avatar_url: str
"""Gravatar URL or ``data:`` URI for a custom uploaded avatar."""
is_local_user: bool
"""True when the account was created via local email/password sign-up."""
class ProfileUpdateRequest(BaseModel):
"""Request body for PATCH /api/profile."""
display_name: str | None = Field(default=None, max_length=255, description="Human-readable display name")
contact_email: str | None = Field(default=None, max_length=255, description="Contact / notification e-mail")
preferred_language: str | None = Field(default=None, description="ISO 639-1 language code, e.g. 'en', 'de'")
preferred_theme: str | None = Field(default=None, description="Colour scheme: 'light', 'dark', or 'system'")
default_document_language: str | None = Field(
default=None,
description="ISO 639-1 code for the default document translation target language, e.g. 'en', 'de'",
)
class ChangePasswordRequest(BaseModel):
"""Request body for POST /api/profile/change-password."""
current_password: str = Field(..., min_length=1, max_length=128)
new_password: str = Field(..., min_length=8, max_length=128)
new_password_confirm: str = Field(..., min_length=8, max_length=128)
# ---------------------------------------------------------------------------
# Endpoints
# ---------------------------------------------------------------------------
@router.get("", response_model=ProfileResponse)
@require_login
async def get_profile(request: Request, db: DbSession) -> ProfileResponse:
"""Return the current user's profile settings."""
user_id = _get_user_id(request)
profile = _get_or_create_profile(db, user_id)
session_user = request.session.get("user", {})
email = session_user.get("email") if isinstance(session_user, dict) else None
# Determine avatar: prefer stored data, fall back to Gravatar
avatar_url = profile.avatar_data if profile.avatar_data else _gravatar_url(email) # type: ignore[attr-defined]
# Check whether this is a local (email/password) account
is_local = db.query(LocalUser).filter(LocalUser.username == user_id).first() is not None
return ProfileResponse(
user_id=user_id,
display_name=profile.display_name, # type: ignore[arg-type]
contact_email=profile.contact_email, # type: ignore[arg-type]
preferred_language=profile.preferred_language, # type: ignore[arg-type]
preferred_theme=profile.preferred_theme, # type: ignore[arg-type]
default_document_language=profile.default_document_language, # type: ignore[arg-type]
avatar_url=avatar_url,
is_local_user=is_local,
)
@router.patch("", response_model=ProfileResponse)
@require_login
async def update_profile(
body: ProfileUpdateRequest, request: Request, response: Response, db: DbSession
) -> ProfileResponse:
"""Update the current user's editable profile settings."""
user_id = _get_user_id(request)
profile = _get_or_create_profile(db, user_id)
# Validate language code
if body.preferred_language is not None:
lang = body.preferred_language.lower().strip()
if lang and lang not in SUPPORTED_LANGUAGE_CODES:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=f"Unsupported language code: {lang}",
)
profile.preferred_language = lang or None # type: ignore[assignment]
# Keep session and cookie in sync so detect_language() picks up
# the new preference immediately (without a DB round-trip).
if hasattr(request, "session"):
if lang:
request.session["preferred_language"] = lang
else:
request.session.pop("preferred_language", None)
if lang:
response.set_cookie(
key="docuelevate_lang",
value=lang,
max_age=30 * 24 * 60 * 60,
httponly=False,
samesite="lax",
)
else:
response.delete_cookie(key="docuelevate_lang")
# Validate theme
if body.preferred_theme is not None:
theme = body.preferred_theme.lower().strip()
if theme and theme not in _VALID_THEMES:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=f"Invalid theme: {theme}. Must be one of: {', '.join(sorted(_VALID_THEMES))}",
)
profile.preferred_theme = theme or None # type: ignore[assignment]
# Validate default document language
if body.default_document_language is not None:
doc_lang = body.default_document_language.lower().strip()
if doc_lang and doc_lang not in SUPPORTED_LANGUAGE_CODES:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=f"Unsupported language code: {doc_lang}",
)
profile.default_document_language = doc_lang or None # type: ignore[assignment]
if body.display_name is not None:
profile.display_name = body.display_name.strip() or None # type: ignore[assignment]
if body.contact_email is not None:
profile.contact_email = body.contact_email.strip() or None # type: ignore[assignment]
try:
db.commit()
db.refresh(profile)
except Exception:
db.rollback()
raise
session_user = request.session.get("user", {})
email = session_user.get("email") if isinstance(session_user, dict) else None
avatar_url = profile.avatar_data if profile.avatar_data else _gravatar_url(email) # type: ignore[attr-defined]
is_local = db.query(LocalUser).filter(LocalUser.username == user_id).first() is not None
return ProfileResponse(
user_id=user_id,
display_name=profile.display_name, # type: ignore[arg-type]
contact_email=profile.contact_email, # type: ignore[arg-type]
preferred_language=profile.preferred_language, # type: ignore[arg-type]
preferred_theme=profile.preferred_theme, # type: ignore[arg-type]
default_document_language=profile.default_document_language, # type: ignore[arg-type]
avatar_url=avatar_url,
is_local_user=is_local,
)
@router.post("/avatar", status_code=status.HTTP_200_OK)
@require_login
async def upload_avatar(
request: Request,
db: DbSession,
file: UploadFile = File(..., description="Profile picture (JPEG, PNG, GIF or WebP; max 2 MB)"),
) -> dict:
"""Upload a new profile picture.
The image is stored as a base64-encoded data URL in ``UserProfile.avatar_data``.
Accepts JPEG, PNG, GIF, or WebP files up to 2 MB.
"""
user_id = _get_user_id(request)
content_type = (file.content_type or "").lower()
if content_type not in _ALLOWED_AVATAR_TYPES:
raise HTTPException(
status_code=status.HTTP_415_UNSUPPORTED_MEDIA_TYPE,
detail=f"Unsupported image type '{content_type}'. Allowed: JPEG, PNG, GIF, WebP.",
)
# Check declared size first (available when the client sends a Content-Length header)
if file.size is not None and file.size > _MAX_AVATAR_BYTES:
raise HTTPException(
status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
detail="Avatar image must be 2 MB or smaller.",
)
# Read up to one byte past the limit so we can detect oversized uploads
raw = await file.read(_MAX_AVATAR_BYTES + 1)
if len(raw) > _MAX_AVATAR_BYTES:
raise HTTPException(
status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
detail="Avatar image must be 2 MB or smaller.",
)
b64 = base64.b64encode(raw).decode("ascii")
data_url = f"data:{content_type};base64,{b64}"
profile = _get_or_create_profile(db, user_id)
profile.avatar_data = data_url # type: ignore[assignment]
try:
db.commit()
except Exception:
db.rollback()
raise
return {"avatar_url": data_url}
@router.delete("/avatar", status_code=status.HTTP_200_OK)
@require_login
async def delete_avatar(request: Request, db: DbSession) -> dict:
"""Remove the custom avatar and revert to the Gravatar fallback."""
user_id = _get_user_id(request)
profile = _get_or_create_profile(db, user_id)
profile.avatar_data = None # type: ignore[assignment]
try:
db.commit()
except Exception:
db.rollback()
raise
session_user = request.session.get("user", {})
email = session_user.get("email") if isinstance(session_user, dict) else None
return {"avatar_url": _gravatar_url(email)}
@router.post("/change-password", status_code=status.HTTP_200_OK)
@require_login
async def change_password(body: ChangePasswordRequest, request: Request, db: DbSession) -> dict:
"""Change the password for local (email/password) accounts.
Raises 403 if the account is not a local account or the current password is wrong.
Raises 422 if the new passwords do not match.
"""
from app.utils.local_auth import hash_password, verify_password
user_id = _get_user_id(request)
local_user = db.query(LocalUser).filter(LocalUser.username == user_id).first()
if local_user is None:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Password change is only available for local accounts.",
)
if not verify_password(body.current_password, local_user.hashed_password):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Current password is incorrect.",
)
if body.new_password != body.new_password_confirm:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="New passwords do not match.",
)
local_user.hashed_password = hash_password(body.new_password)
try:
db.commit()
except Exception:
db.rollback()
raise
logger.info("Password changed for local user: %s", user_id)
return {"detail": "Password changed successfully."}
+249
View File
@@ -0,0 +1,249 @@
"""QR code login API endpoints for mobile app authentication.
Provides a secure challenge-response flow for logging into the mobile app
by scanning a QR code displayed in the web interface:
1. **Web user** calls ``POST /qr-auth/challenge`` → receives a time-limited
challenge token (encoded in the QR code).
2. **Web UI** polls ``GET /qr-auth/challenge/{id}/status`` to detect when
the mobile app has claimed the challenge.
3. **Mobile app** scans the QR code and calls ``POST /qr-auth/claim`` with
the challenge token + device name → receives an API token.
Security properties:
* Challenges expire after a configurable TTL (default 2 minutes).
* Single-use: once claimed, a challenge cannot be reused (replay-safe).
* Cryptographically random 64-byte tokens.
* IP addresses are logged for audit.
"""
from __future__ import annotations
import base64
import io
import logging
from datetime import datetime
from typing import Annotated, Any
import segno
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.config import settings
from app.database import get_db
from app.middleware.audit_log import get_client_ip
from app.utils.session_manager import (
claim_qr_challenge,
create_qr_challenge,
get_challenge_status,
)
from app.utils.user_scope import get_current_owner_id
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/qr-auth", tags=["qr-auth"])
DbSession = Annotated[Session, Depends(get_db)]
# ---------------------------------------------------------------------------
# Auth helper
# ---------------------------------------------------------------------------
def _get_owner_id(request: Request) -> str:
"""Return the current user's owner ID, raising 401 if unauthenticated."""
owner_id = get_current_owner_id(request)
if not owner_id:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated")
return owner_id
CurrentOwner = Annotated[str, Depends(_get_owner_id)]
# ---------------------------------------------------------------------------
# Request / Response schemas
# ---------------------------------------------------------------------------
class CreateChallengeResponse(BaseModel):
"""Response after creating a QR login challenge."""
challenge_id: int
challenge_token: str
expires_at: datetime
ttl_seconds: int = Field(description="Seconds until the challenge expires (use for client-side countdown).")
qr_payload: str = Field(description="The string to encode in the QR code.")
qr_code_svg: str = Field(description="Base64-encoded SVG data URI of the QR code, ready for use in an <img> src.")
class ChallengeStatusResponse(BaseModel):
"""Response for polling the status of a QR challenge."""
id: int
status: str # "pending", "claimed", "expired", "cancelled"
device_name: str | None = None
claimed_at: datetime | None = None
expires_at: datetime
class ClaimChallengeRequest(BaseModel):
"""Request body for claiming a QR login challenge."""
challenge_token: str = Field(min_length=1, max_length=256)
device_name: str = Field(
default="Mobile App",
min_length=1,
max_length=120,
description="Human-readable device name.",
)
class ClaimChallengeResponse(BaseModel):
"""Response after successfully claiming a QR challenge."""
token: str
token_id: int
name: str
owner_id: str
created_at: datetime
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
# QR code rendering parameters
_QR_ERROR_LEVEL = "M" # Medium error correction (~15% recovery); sufficient for on-screen display
_QR_SCALE = 4 # Each QR module is rendered as 4×4 SVG pixels
def _generate_qr_svg(payload: str) -> str:
"""Generate a QR code for *payload* and return it as a base64 SVG data URI.
Using ``segno`` (pure-Python, no Pillow dependency) and SVG output so the
QR code scales crisply at any resolution without requiring a canvas or any
client-side JavaScript library.
"""
qr = segno.make(payload, error=_QR_ERROR_LEVEL)
buf = io.BytesIO()
qr.save(buf, kind="svg", scale=_QR_SCALE, xmldecl=False, svgclass=None, lineclass=None, omitsize=True)
svg_bytes = buf.getvalue()
return "data:image/svg+xml;base64," + base64.b64encode(svg_bytes).decode("ascii")
# ---------------------------------------------------------------------------
# Endpoints
# ---------------------------------------------------------------------------
@router.post("/challenge", status_code=status.HTTP_201_CREATED, response_model=CreateChallengeResponse)
@require_login
async def create_challenge(
request: Request,
owner_id: CurrentOwner,
db: DbSession,
) -> dict[str, Any]:
"""Create a new QR login challenge.
The returned ``qr_payload`` should be encoded into a QR code and
displayed to the user. The mobile app scans this QR code and
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)
challenge = create_qr_challenge(db, owner_id, ip_address=ip)
# The QR payload is a JSON-like string with enough info for the mobile
# app to know the server URL and challenge token.
base_url = str(request.base_url).rstrip("/")
qr_payload = f"docuelevate://qr-login?token={challenge.challenge_token}&server={base_url}"
# Compute the TTL in seconds so the client can run a countdown timer
# without comparing absolute timestamps (which breaks when client and
# server clocks are out of sync).
ttl_seconds = max(0, int((challenge.expires_at - challenge.created_at).total_seconds()))
return {
"challenge_id": challenge.id,
"challenge_token": challenge.challenge_token,
"expires_at": challenge.expires_at,
"ttl_seconds": ttl_seconds,
"qr_payload": qr_payload,
"qr_code_svg": _generate_qr_svg(qr_payload),
}
@router.get("/challenge/{challenge_id}/status", response_model=ChallengeStatusResponse)
@require_login
async def poll_challenge_status(
request: Request,
challenge_id: int,
owner_id: CurrentOwner,
db: DbSession,
) -> dict[str, Any]:
"""Poll the status of a QR login challenge.
The web UI calls this endpoint every few seconds to check if the
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)
if not result:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Challenge not found")
return result
@router.post("/claim", response_model=ClaimChallengeResponse)
async def claim_challenge(
request: Request,
body: ClaimChallengeRequest,
db: DbSession,
) -> dict[str, Any]:
"""Claim a QR login challenge and receive an API token.
This endpoint is called by the mobile app after scanning a QR code.
It does **not** require authentication — the challenge token itself
serves as proof that the user authorized this login from their web
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)
result = claim_qr_challenge(db, body.challenge_token, device_name=body.device_name, ip_address=ip)
if not result:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Invalid, expired, or already claimed challenge.",
)
try:
from app.utils.audit_service import record_event
record_event(
db,
action="qr_login_claimed",
user=result["owner_id"],
resource_type="session",
ip_address=ip,
details={"device_name": body.device_name, "token_id": result["token_id"]},
severity="info",
)
except Exception:
logger.debug("Failed to write QR login audit event", exc_info=True)
return result
+468
View File
@@ -0,0 +1,468 @@
"""Routing rules API endpoints.
Provides full CRUD for pipeline routing rules that conditionally assign
documents to pipelines based on document properties (file type, category,
metadata fields, size, etc.).
Rules are evaluated in ascending ``position`` order. The first rule whose
condition matches wins and routes the document to the specified target
pipeline. If no rule matches, the caller falls back to the owner's (or
system) default pipeline.
"""
import logging
from typing import Annotated, Any
from fastapi import APIRouter, Body, 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 Pipeline, PipelineRoutingRule
from app.utils.routing_engine import (
BUILTIN_FIELDS,
VALID_OPERATORS,
_evaluate_condition,
_resolve_field,
)
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/routing-rules", tags=["routing-rules"])
DbSession = Annotated[Session, Depends(get_db)]
MAX_RULES_PER_OWNER = 100
MAX_NAME_LENGTH = 255
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _get_user_id(request: Request) -> str:
"""Return the authenticated user identifier."""
user = getattr(request.state, "user", None)
if user:
if isinstance(user, dict):
return user.get("sub", user.get("email", "anonymous"))
return getattr(user, "sub", getattr(user, "email", "anonymous"))
return "anonymous"
def _is_admin(request: Request) -> bool:
"""Return ``True`` when the current user has admin privileges."""
user = getattr(request.state, "user", None)
if not user:
return False
groups = user.get("groups", []) if isinstance(user, dict) else getattr(user, "groups", [])
return "admin" in groups
def _can_access_rule(rule: PipelineRoutingRule, user_id: str, admin: bool) -> bool:
"""Check whether the user is allowed to read this rule."""
if admin:
return True
return rule.owner_id == user_id
def _can_write_rule(rule: PipelineRoutingRule, user_id: str, admin: bool) -> bool:
"""Check whether the user is allowed to modify this rule."""
if rule.owner_id is None:
return admin
return rule.owner_id == user_id
def _validate_field(field: str) -> None:
"""Raise 422 if the field name is invalid."""
if field in BUILTIN_FIELDS:
return
if field.startswith("metadata.") and len(field) > len("metadata."):
return
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=(
f"Invalid field '{field}'. "
f"Valid built-in fields: {sorted(BUILTIN_FIELDS)}. "
"For AI metadata, use 'metadata.<key>'."
),
)
def _validate_operator(operator: str) -> None:
"""Raise 422 if the operator is not recognised."""
if operator not in VALID_OPERATORS:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=f"Invalid operator '{operator}'. Valid operators: {sorted(VALID_OPERATORS)}",
)
def _serialize_rule(rule: PipelineRoutingRule) -> dict[str, Any]:
"""Serialize a routing rule to a JSON-compatible dict."""
return {
"id": rule.id,
"owner_id": rule.owner_id,
"name": rule.name,
"position": rule.position,
"field": rule.field,
"operator": rule.operator,
"value": rule.value,
"target_pipeline_id": rule.target_pipeline_id,
"is_active": rule.is_active,
"created_at": rule.created_at.isoformat() if rule.created_at else None,
"updated_at": rule.updated_at.isoformat() if rule.updated_at else None,
}
# ---------------------------------------------------------------------------
# Pydantic request models
# ---------------------------------------------------------------------------
class RoutingRuleCreate(BaseModel):
"""Request body for creating a routing rule."""
name: str = Field(..., min_length=1, max_length=MAX_NAME_LENGTH)
field: str = Field(..., min_length=1, max_length=255)
operator: str = Field(..., min_length=1, max_length=50)
value: str = Field(..., max_length=1024)
target_pipeline_id: int
position: int | None = None
is_active: bool = True
class RoutingRuleUpdate(BaseModel):
"""Request body for updating a routing rule."""
name: str | None = Field(None, min_length=1, max_length=MAX_NAME_LENGTH)
field: str | None = Field(None, min_length=1, max_length=255)
operator: str | None = Field(None, min_length=1, max_length=50)
value: str | None = Field(None, max_length=1024)
target_pipeline_id: int | None = None
position: int | None = None
is_active: bool | None = None
class RoutingRuleEvaluateRequest(BaseModel):
"""Request body for dry-run rule evaluation."""
file_type: str | None = None
filename: str | None = None
size: int | None = None
document_type: str | None = None
metadata: dict[str, Any] | None = None
# ---------------------------------------------------------------------------
# Endpoints
# ---------------------------------------------------------------------------
@router.get("")
@require_login
def list_routing_rules(request: Request, db: DbSession) -> list[dict[str, Any]]:
"""List all routing rules accessible by the current user.
Returns the user's own rules plus any system-wide rules (``owner_id=NULL``).
Rules are sorted by position.
"""
user_id = _get_user_id(request)
rules = (
db.query(PipelineRoutingRule)
.filter((PipelineRoutingRule.owner_id == user_id) | (PipelineRoutingRule.owner_id.is_(None)))
.order_by(
PipelineRoutingRule.owner_id.is_(None).asc(),
PipelineRoutingRule.position.asc(),
)
.all()
)
return [_serialize_rule(r) for r in rules]
@router.post("", status_code=status.HTTP_201_CREATED)
@require_login
def create_routing_rule(request: Request, db: DbSession, body: RoutingRuleCreate) -> dict[str, Any]:
"""Create a new routing rule for the current user.
Returns:
The created routing rule.
Raises:
HTTPException 422: If the field or operator is invalid.
HTTPException 404: If the target pipeline does not exist.
HTTPException 409: If the maximum number of rules is reached.
"""
user_id = _get_user_id(request)
_validate_field(body.field)
_validate_operator(body.operator)
# Verify target pipeline exists and is accessible.
pipeline = db.query(Pipeline).filter(Pipeline.id == body.target_pipeline_id).first()
if not pipeline:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Target pipeline {body.target_pipeline_id} not found",
)
# Enforce per-owner limit.
count = db.query(PipelineRoutingRule).filter(PipelineRoutingRule.owner_id == user_id).count()
if count >= MAX_RULES_PER_OWNER:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"Maximum of {MAX_RULES_PER_OWNER} routing rules per user reached",
)
# Auto-assign position if not specified.
position = body.position
if position is None:
max_pos = (
db.query(PipelineRoutingRule.position)
.filter(PipelineRoutingRule.owner_id == user_id)
.order_by(PipelineRoutingRule.position.desc())
.first()
)
position = (max_pos[0] + 1) if max_pos else 0
rule = PipelineRoutingRule(
owner_id=user_id,
name=body.name.strip(),
position=position,
field=body.field,
operator=body.operator,
value=body.value,
target_pipeline_id=body.target_pipeline_id,
is_active=body.is_active,
)
try:
db.add(rule)
db.commit()
db.refresh(rule)
except Exception:
db.rollback()
logger.exception("Failed to create routing rule for user=%s", user_id)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to create routing rule",
)
logger.info("Routing rule created: id=%s, user=%s", rule.id, user_id)
return _serialize_rule(rule)
@router.get("/operators")
def list_operators() -> dict[str, Any]:
"""Return the list of supported operators and fields.
This is a public endpoint (no auth required) so that UIs can populate
dropdowns without hard-coding the catalogue.
"""
return {
"operators": sorted(VALID_OPERATORS),
"builtin_fields": sorted(BUILTIN_FIELDS),
"metadata_prefix": "metadata.",
}
@router.post("/evaluate")
@require_login
def evaluate_rules(request: Request, db: DbSession, body: RoutingRuleEvaluateRequest) -> dict[str, Any]:
"""Dry-run rule evaluation against the provided document properties.
Returns the first matching rule and target pipeline (if any), or
indicates that no rule matched (default pipeline will be used).
"""
user_id = _get_user_id(request)
doc_props: dict[str, Any] = {
"file_type": body.file_type,
"filename": body.filename,
"size": body.size,
"document_type": body.document_type,
"metadata": body.metadata or {},
}
rules = (
db.query(PipelineRoutingRule)
.filter(
PipelineRoutingRule.is_active.is_(True),
(PipelineRoutingRule.owner_id == user_id) | (PipelineRoutingRule.owner_id.is_(None)),
)
.order_by(
PipelineRoutingRule.owner_id.is_(None).asc(),
PipelineRoutingRule.position.asc(),
)
.all()
)
for rule in rules:
actual = _resolve_field(rule.field, doc_props)
if _evaluate_condition(actual, rule.operator, rule.value):
pipeline = db.query(Pipeline).filter(Pipeline.id == rule.target_pipeline_id).first()
return {
"matched": True,
"rule": _serialize_rule(rule),
"target_pipeline": {
"id": pipeline.id,
"name": pipeline.name,
"is_active": pipeline.is_active,
}
if pipeline
else None,
}
return {"matched": False, "rule": None, "target_pipeline": None}
@router.put("/reorder")
@require_login
def reorder_routing_rules(
request: Request,
db: DbSession,
rule_ids: list[int] = Body(..., embed=True),
) -> list[dict[str, Any]]:
"""Reorder the caller's routing rules.
Expects a JSON body ``{"rule_ids": [3, 1, 2]}`` where the list
contains the IDs of the caller's rules in the desired order.
"""
user_id = _get_user_id(request)
rules = (
db.query(PipelineRoutingRule)
.filter(PipelineRoutingRule.owner_id == user_id, PipelineRoutingRule.id.in_(rule_ids))
.all()
)
rule_map = {r.id: r for r in rules}
if len(rule_map) != len(rule_ids) or set(rule_map.keys()) != set(rule_ids):
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="rule_ids must contain exactly the IDs of your routing rules",
)
for pos, rid in enumerate(rule_ids):
rule_map[rid].position = pos
try:
db.commit()
except Exception:
db.rollback()
logger.exception("Failed to reorder routing rules for user=%s", user_id)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to reorder routing rules",
)
ordered = sorted(rules, key=lambda r: r.position)
return [_serialize_rule(r) for r in ordered]
@router.get("/{rule_id}")
@require_login
def get_routing_rule(rule_id: int, request: Request, db: DbSession) -> dict[str, Any]:
"""Return a single routing rule by ID."""
user_id = _get_user_id(request)
admin = _is_admin(request)
rule = db.query(PipelineRoutingRule).filter(PipelineRoutingRule.id == rule_id).first()
if not rule or not _can_access_rule(rule, user_id, admin):
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Routing rule not found")
return _serialize_rule(rule)
@router.put("/{rule_id}")
@require_login
def update_routing_rule(rule_id: int, request: Request, db: DbSession, body: RoutingRuleUpdate) -> dict[str, Any]:
"""Update a routing rule.
Only the fields present in the request body are updated.
"""
user_id = _get_user_id(request)
admin = _is_admin(request)
rule = db.query(PipelineRoutingRule).filter(PipelineRoutingRule.id == rule_id).first()
if not rule or not _can_access_rule(rule, user_id, admin):
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Routing rule not found")
if not _can_write_rule(rule, user_id, admin):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Cannot modify this rule")
if body.field is not None:
_validate_field(body.field)
rule.field = body.field
if body.operator is not None:
_validate_operator(body.operator)
rule.operator = body.operator
if body.value is not None:
rule.value = body.value
if body.name is not None:
rule.name = body.name.strip()
if body.target_pipeline_id is not None:
pipeline = db.query(Pipeline).filter(Pipeline.id == body.target_pipeline_id).first()
if not pipeline:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Target pipeline {body.target_pipeline_id} not found",
)
rule.target_pipeline_id = body.target_pipeline_id
if body.position is not None:
rule.position = body.position
if body.is_active is not None:
rule.is_active = body.is_active
try:
db.commit()
db.refresh(rule)
except Exception:
db.rollback()
logger.exception("Failed to update routing rule id=%s", rule_id)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to update routing rule",
)
logger.info("Routing rule updated: id=%s, user=%s", rule_id, user_id)
return _serialize_rule(rule)
@router.delete("/{rule_id}", status_code=status.HTTP_204_NO_CONTENT)
@require_login
def delete_routing_rule(rule_id: int, request: Request, db: DbSession) -> None:
"""Delete a routing rule."""
user_id = _get_user_id(request)
admin = _is_admin(request)
rule = db.query(PipelineRoutingRule).filter(PipelineRoutingRule.id == rule_id).first()
if not rule or not _can_access_rule(rule, user_id, admin):
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Routing rule not found")
if not _can_write_rule(rule, user_id, admin):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Cannot modify this rule")
try:
db.delete(rule)
db.commit()
except Exception:
db.rollback()
logger.exception("Failed to delete routing rule id=%s", rule_id)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to delete routing rule",
)
logger.info("Routing rule deleted: id=%s, user=%s", rule_id, user_id)
+9 -9
View File
@@ -190,15 +190,15 @@ def create_saved_search(
db.add(saved_search)
db.commit()
db.refresh(saved_search)
except Exception as exc:
except Exception:
db.rollback()
logger.exception(f"Failed to create saved search for user={user_id}: {exc}")
logger.exception("Failed to create saved search for user=%s", user_id)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to save search",
)
logger.info(f"Saved search created: user={user_id}, name={name!r}")
logger.info("Saved search created: user=%s, name=%r", user_id, name)
return _serialize_saved_search(saved_search)
@@ -263,15 +263,15 @@ def update_saved_search(
try:
db.commit()
db.refresh(saved_search)
except Exception as exc:
except Exception:
db.rollback()
logger.exception(f"Failed to update saved search id={search_id}, user={user_id}: {exc}")
logger.exception("Failed to update saved search id=%s, user=%s", search_id, user_id)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to update saved search",
)
logger.info(f"Saved search updated: id={search_id}, user={user_id}")
logger.info("Saved search updated: id=%s, user=%s", search_id, user_id)
return _serialize_saved_search(saved_search)
@@ -294,12 +294,12 @@ def delete_saved_search(search_id: int, request: Request, db: DbSession):
try:
db.delete(saved_search)
db.commit()
except Exception as exc:
except Exception:
db.rollback()
logger.exception(f"Failed to delete saved search id={search_id}, user={user_id}: {exc}")
logger.exception("Failed to delete saved search id=%s, user=%s", search_id, user_id)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to delete saved search",
)
logger.info(f"Saved search deleted: id={search_id}, user={user_id}")
logger.info("Saved search deleted: id=%s, user=%s", search_id, user_id)
+350
View File
@@ -0,0 +1,350 @@
"""
Admin API endpoints for managing scheduled batch processing jobs.
All endpoints require admin privileges (checked via session ``is_admin`` flag).
Available routes:
GET /api/admin/scheduled-jobs list all scheduled jobs
PATCH /api/admin/scheduled-jobs/{id} update schedule / enable-disable
POST /api/admin/scheduled-jobs/{id}/run-now trigger a job immediately
"""
import logging
from datetime import datetime, timezone
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.database import get_db
from app.models import ScheduledJob
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/admin/scheduled-jobs", tags=["admin-scheduled-jobs"])
DbSession = Annotated[Session, Depends(get_db)]
# ---------------------------------------------------------------------------
# Authorisation helper
# ---------------------------------------------------------------------------
def _require_admin(request: Request) -> dict:
"""Ensure the caller is an admin; raises HTTP 403 otherwise."""
user = request.session.get("user")
if not user or not user.get("is_admin"):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin access required")
return user
AdminUser = Annotated[dict, Depends(_require_admin)]
# ---------------------------------------------------------------------------
# Pydantic schemas
# ---------------------------------------------------------------------------
class ScheduledJobResponse(BaseModel):
"""Read model for a scheduled job."""
id: int
name: str
display_name: str
description: str | None
task_name: str
enabled: bool
schedule_type: str
cron_minute: str
cron_hour: str
cron_day_of_week: str
cron_day_of_month: str
cron_month_of_year: str
interval_seconds: int | None
last_run_at: datetime | None
last_run_status: str | None
last_run_detail: str | None
created_at: datetime | None
updated_at: datetime | None
model_config = {"from_attributes": True}
class ScheduledJobUpdate(BaseModel):
"""Writable fields for a scheduled job update (all optional)."""
enabled: bool | None = Field(None, description="Whether the job is active")
schedule_type: str | None = Field(None, pattern="^(cron|interval)$", description="'cron' or 'interval'")
cron_minute: str | None = Field(None, max_length=50)
cron_hour: str | None = Field(None, max_length=50)
cron_day_of_week: str | None = Field(None, max_length=50)
cron_day_of_month: str | None = Field(None, max_length=50)
cron_month_of_year: str | None = Field(None, max_length=50)
interval_seconds: int | None = Field(None, ge=60, description="Interval in seconds (min 60)")
# ---------------------------------------------------------------------------
# Default job definitions seeded into the DB on first startup
# ---------------------------------------------------------------------------
DEFAULT_JOBS: list[dict[str, Any]] = [
{
"name": "process-new-documents",
"display_name": "Process New Documents",
"description": (
"Scans for documents that have been uploaded but never processed "
"and queues them through the full processing pipeline. "
"Runs hourly by default."
),
"task_name": "app.tasks.batch_tasks.process_new_documents",
"enabled": True,
"schedule_type": "cron",
"cron_minute": "0",
"cron_hour": "*/1",
"cron_day_of_week": "*",
"cron_day_of_month": "*",
"cron_month_of_year": "*",
"interval_seconds": None,
},
{
"name": "reprocess-failed-documents",
"display_name": "Reprocess Failed Documents",
"description": (
"Finds documents whose last processing attempt failed and re-queues "
"them for reprocessing. Only picks up files that are not currently "
"being processed. Runs every 6 hours by default."
),
"task_name": "app.tasks.batch_tasks.reprocess_failed_documents",
"enabled": True,
"schedule_type": "cron",
"cron_minute": "30",
"cron_hour": "*/6",
"cron_day_of_week": "*",
"cron_day_of_month": "*",
"cron_month_of_year": "*",
"interval_seconds": None,
},
{
"name": "cleanup-temp-files",
"display_name": "Clean Up Temporary Files",
"description": (
"Removes stale files from the workdir/tmp directory. "
"Only files older than 24 hours that are not referenced by any active "
"processing job are deleted. Runs daily at 03:30 UTC by default."
),
"task_name": "app.tasks.batch_tasks.cleanup_temp_files",
"enabled": True,
"schedule_type": "cron",
"cron_minute": "30",
"cron_hour": "3",
"cron_day_of_week": "*",
"cron_day_of_month": "*",
"cron_month_of_year": "*",
"interval_seconds": None,
},
{
"name": "expire-shared-links",
"display_name": "Expire Stale Shared Links",
"description": (
"Marks shared document links as inactive when their expiry time has passed. "
"Access is already blocked at request time, but this task keeps the "
"management UI counts accurate. Runs daily at 01:00 UTC by default."
),
"task_name": "app.tasks.batch_tasks.expire_shared_links",
"enabled": True,
"schedule_type": "cron",
"cron_minute": "0",
"cron_hour": "1",
"cron_day_of_week": "*",
"cron_day_of_month": "*",
"cron_month_of_year": "*",
"interval_seconds": None,
},
{
"name": "prune-processing-logs",
"display_name": "Prune Old Processing Logs",
"description": (
"Deletes processing log entries and settings audit log entries older than "
"30 days to prevent unbounded database growth. "
"Runs weekly on Sunday at 04:00 UTC by default."
),
"task_name": "app.tasks.batch_tasks.prune_processing_logs",
"enabled": True,
"schedule_type": "cron",
"cron_minute": "0",
"cron_hour": "4",
"cron_day_of_week": "0",
"cron_day_of_month": "*",
"cron_month_of_year": "*",
"interval_seconds": None,
},
{
"name": "prune-old-notifications",
"display_name": "Prune Old Notifications",
"description": (
"Deletes read in-app notifications older than 30 days. "
"Unread notifications are never deleted. "
"Runs weekly on Sunday at 04:30 UTC by default."
),
"task_name": "app.tasks.batch_tasks.prune_old_notifications",
"enabled": True,
"schedule_type": "cron",
"cron_minute": "30",
"cron_hour": "4",
"cron_day_of_week": "0",
"cron_day_of_month": "*",
"cron_month_of_year": "*",
"interval_seconds": None,
},
{
"name": "backfill-missing-metadata",
"display_name": "Backfill Missing AI Metadata",
"description": (
"Re-triggers AI metadata extraction for documents that have extracted "
"text but no AI metadata yet (e.g., processed before an AI provider "
"was configured). Processes up to 50 documents per run. "
"Runs every 6 hours by default."
),
"task_name": "app.tasks.batch_tasks.backfill_missing_metadata",
"enabled": True,
"schedule_type": "cron",
"cron_minute": "0",
"cron_hour": "*/6",
"cron_day_of_week": "*",
"cron_day_of_month": "*",
"cron_month_of_year": "*",
"interval_seconds": None,
},
{
"name": "sync-search-index",
"display_name": "Sync Search Index",
"description": (
"Indexes documents that have OCR text or AI metadata but are missing "
"from the Meilisearch search index. Useful after enabling search on "
"an existing installation or after an index rebuild. "
"Processes up to 100 documents per run. "
"Runs hourly by default."
),
"task_name": "app.tasks.batch_tasks.sync_search_index",
"enabled": True,
"schedule_type": "cron",
"cron_minute": "15",
"cron_hour": "*/1",
"cron_day_of_week": "*",
"cron_day_of_month": "*",
"cron_month_of_year": "*",
"interval_seconds": None,
},
]
def seed_default_scheduled_jobs(db: Session) -> None:
"""
Insert the built-in scheduled jobs if they do not already exist.
Called from the FastAPI lifespan handler so the records are available
immediately after the first startup.
"""
for job_data in DEFAULT_JOBS:
existing = db.query(ScheduledJob).filter(ScheduledJob.name == job_data["name"]).first()
if existing is None:
db.add(ScheduledJob(**job_data))
try:
db.commit()
except Exception as exc:
db.rollback()
logger.error("Failed to seed default scheduled jobs: %s", exc)
# ---------------------------------------------------------------------------
# Endpoints
# ---------------------------------------------------------------------------
@router.get("", response_model=list[ScheduledJobResponse])
def list_scheduled_jobs(request: Request, db: DbSession, _admin: AdminUser) -> list[ScheduledJobResponse]:
"""
Return all scheduled jobs ordered by display name.
Requires admin privileges.
"""
jobs = db.query(ScheduledJob).order_by(ScheduledJob.display_name).all()
return jobs # type: ignore[return-value]
@router.patch("/{job_id}", response_model=ScheduledJobResponse)
def update_scheduled_job(
job_id: int,
payload: ScheduledJobUpdate,
request: Request,
db: DbSession,
_admin: AdminUser,
) -> ScheduledJobResponse:
"""
Update schedule configuration or enabled state for a job.
Only the fields included in the request body are modified.
Changes to the Celery Beat schedule take effect after the worker restarts.
Requires admin privileges.
"""
job = db.query(ScheduledJob).filter(ScheduledJob.id == job_id).first()
if job is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Scheduled job not found")
update_data = payload.model_dump(exclude_none=True)
if not update_data:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="No fields to update")
for field, value in update_data.items():
setattr(job, field, value)
job.updated_at = datetime.now(timezone.utc)
try:
db.commit()
db.refresh(job)
except Exception as exc:
db.rollback()
logger.error("Failed to update scheduled job %s: %s", job_id, exc)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to update scheduled job",
) from exc
logger.info("Admin updated scheduled job %s (id=%s): %s", job.name, job_id, update_data)
return job # type: ignore[return-value]
@router.post("/{job_id}/run-now")
def run_scheduled_job_now(
job_id: int,
request: Request,
db: DbSession,
_admin: AdminUser,
) -> dict[str, Any]:
"""
Immediately dispatch the Celery task for the given scheduled job.
The task is sent to the default queue; its result is tracked asynchronously
via the ``last_run_at`` / ``last_run_status`` fields updated by the task
itself.
Requires admin privileges.
"""
job = db.query(ScheduledJob).filter(ScheduledJob.id == job_id).first()
if job is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Scheduled job not found")
from app.celery_app import celery as celery_app
task = celery_app.send_task(job.task_name)
logger.info("Admin triggered scheduled job %s (id=%s) manually, task_id=%s", job.name, job_id, task.id)
return {
"status": "dispatched",
"job_id": job_id,
"job_name": job.name,
"task_id": task.id,
}
+196
View File
@@ -0,0 +1,196 @@
"""API endpoints for managing user sessions.
Provides endpoints for listing active sessions, revoking individual sessions,
and the "log off everywhere" feature that invalidates all sessions and API
tokens across all devices.
"""
from __future__ import annotations
import logging
from datetime import datetime
from typing import Annotated, Any
from fastapi import APIRouter, Depends, HTTPException, Request, status
from pydantic import BaseModel
from sqlalchemy.orm import Session
from app.auth import require_login
from app.database import get_db
from app.middleware.audit_log import get_client_ip
from app.utils.session_manager import (
get_session_lifetime_days,
list_user_sessions,
revoke_all_sessions,
revoke_session,
)
from app.utils.user_scope import get_current_owner_id
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/sessions", tags=["sessions"])
DbSession = Annotated[Session, Depends(get_db)]
# ---------------------------------------------------------------------------
# Auth helper
# ---------------------------------------------------------------------------
def _get_owner_id(request: Request) -> str:
"""Return the current user's owner ID, raising 401 if unauthenticated."""
owner_id = get_current_owner_id(request)
if not owner_id:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated")
return owner_id
CurrentOwner = Annotated[str, Depends(_get_owner_id)]
# ---------------------------------------------------------------------------
# Response schemas
# ---------------------------------------------------------------------------
class SessionResponse(BaseModel):
"""Serialised user session for the management UI."""
id: int
device_info: str | None
ip_address: str | None
created_at: datetime
last_active_at: datetime
expires_at: datetime
is_current: bool = False
class SessionListResponse(BaseModel):
"""Response for listing active sessions."""
sessions: list[SessionResponse]
session_lifetime_days: int
class RevokeAllResponse(BaseModel):
"""Response after revoking all sessions."""
revoked_count: int
message: str
# ---------------------------------------------------------------------------
# Endpoints
# ---------------------------------------------------------------------------
@router.get("/", response_model=SessionListResponse)
@require_login
async def list_sessions(
request: Request,
owner_id: CurrentOwner,
db: DbSession,
) -> dict[str, Any]:
"""List all active sessions for the current user."""
sessions = list_user_sessions(db, owner_id)
# Determine which session is the current one
current_token = request.session.get("_session_token")
session_list = []
for s in sessions:
session_list.append(
{
"id": s.id,
"device_info": s.device_info,
"ip_address": s.ip_address,
"created_at": s.created_at,
"last_active_at": s.last_active_at,
"expires_at": s.expires_at,
"is_current": s.session_token == current_token if current_token else False,
}
)
return {
"sessions": session_list,
"session_lifetime_days": get_session_lifetime_days(),
}
@router.delete("/{session_id}", status_code=status.HTTP_204_NO_CONTENT)
@require_login
async def revoke_single_session(
request: Request,
session_id: int,
owner_id: CurrentOwner,
db: DbSession,
) -> None:
"""Revoke a specific session by ID."""
success = revoke_session(db, session_id, owner_id)
if not success:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Session not found")
try:
from app.utils.audit_service import record_event
record_event(
db,
action="session_revoked",
user=owner_id,
resource_type="session",
resource_id=str(session_id),
ip_address=get_client_ip(request),
severity="info",
)
except Exception:
logger.debug("Failed to write session revocation audit event", exc_info=True)
@router.post("/revoke-all", response_model=RevokeAllResponse)
@require_login
async def revoke_all(
request: Request,
owner_id: CurrentOwner,
db: DbSession,
) -> dict[str, Any]:
"""Revoke all sessions except the current one ("log off everywhere").
Also revokes all active API tokens for the user, which invalidates
mobile app sessions and any programmatic access.
"""
# Find current session to preserve it
current_token = request.session.get("_session_token")
current_session_id = None
if current_token:
from app.models import UserSession
current = db.query(UserSession).filter(UserSession.session_token == current_token).first()
if current:
current_session_id = current.id
count = revoke_all_sessions(
db,
owner_id,
except_session_id=current_session_id,
revoke_api_tokens=True,
)
try:
from app.utils.audit_service import record_event
record_event(
db,
action="revoke_all_sessions",
user=owner_id,
resource_type="session",
ip_address=get_client_ip(request),
details={"revoked_count": count},
severity="warning",
)
except Exception:
logger.debug("Failed to write revoke-all audit event", exc_info=True)
return {
"revoked_count": count,
"message": f"Successfully revoked {count} session(s) and all API tokens.",
}
+62
View File
@@ -55,6 +55,12 @@ class SettingUpdate(BaseModel):
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):
"""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}")
async def delete_setting(key: str, request: Request, db: DbSession, admin: AdminUser):
"""
+502
View File
@@ -0,0 +1,502 @@
"""API endpoints for document sharing via expiring links.
Authenticated users can create time-limited or view-limited shareable
links for their documents. Each link has a cryptographically random
token that forms a public ``/share/<token>`` URL. Optional password
protection is supported; only a PBKDF2-HMAC-SHA256 hash is stored.
Public consumers access files through the ``/share/<token>/download``
and ``/share/<token>/info`` endpoints — no authentication required.
"""
import hashlib
import logging
import os
import secrets
from datetime import datetime, timezone
from typing import Annotated, Any
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
from fastapi.responses import FileResponse
from pydantic import BaseModel, Field, field_validator
from sqlalchemy.orm import Session
from app.database import get_db
from app.models import FileRecord, SharedLink
from app.utils.user_scope import apply_owner_filter, get_current_owner_id
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/shared-links", tags=["shared-links"])
public_router = APIRouter(tags=["shared-links-public"])
DbSession = Annotated[Session, Depends(get_db)]
# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------
#: PBKDF2 iteration count — matches OWASP 2023 recommendation for PBKDF2-HMAC-SHA256.
_PWD_HASH_ITERATIONS = 600_000
#: Length of the random per-password salt in bytes (128-bit entropy).
_PWD_SALT_BYTES = 16
# Valid expiry durations (in hours) presented in the UI.
EXPIRY_OPTIONS: dict[str, int] = {
"1h": 1,
"6h": 6,
"12h": 12,
"24h": 24,
"3d": 72,
"7d": 168,
"14d": 336,
"30d": 720,
}
# ---------------------------------------------------------------------------
# Auth helper
# ---------------------------------------------------------------------------
def _get_owner_id(request: Request) -> str:
"""Return the current user's owner ID, raising 401 if unauthenticated."""
owner_id = get_current_owner_id(request)
if not owner_id:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated")
return owner_id
CurrentOwner = Annotated[str, Depends(_get_owner_id)]
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _generate_token() -> str:
"""Generate a 43-character URL-safe random token."""
return secrets.token_urlsafe(32)
def _hash_password(password: str) -> str:
"""Hash *password* with PBKDF2-HMAC-SHA256 and a random per-password salt.
The returned string uses the format ``{salt_hex}:{dk_hex}`` so that
both the salt and the digest can be recovered from a single column.
Args:
password: Plaintext password string.
Returns:
String in the form ``<32-char salt hex>:<64-char digest hex>``,
totalling 97 characters (well within the 128-char column limit).
"""
salt = secrets.token_bytes(_PWD_SALT_BYTES)
dk = hashlib.pbkdf2_hmac(
"sha256",
password.encode("utf-8"),
salt,
_PWD_HASH_ITERATIONS,
)
return f"{salt.hex()}:{dk.hex()}"
def _verify_password(password: str, stored_hash: str) -> bool:
"""Verify *password* against a hash produced by :func:`_hash_password`.
Uses constant-time comparison to prevent timing attacks.
Args:
password: Plaintext password to check.
stored_hash: The value previously returned by :func:`_hash_password`.
Returns:
``True`` if *password* matches, ``False`` otherwise.
"""
try:
salt_hex, dk_hex = stored_hash.split(":", 1)
salt = bytes.fromhex(salt_hex)
except (ValueError, TypeError):
return False
dk = hashlib.pbkdf2_hmac(
"sha256",
password.encode("utf-8"),
salt,
_PWD_HASH_ITERATIONS,
)
return secrets.compare_digest(dk.hex(), dk_hex)
def _is_link_valid(link: SharedLink) -> bool:
"""Return True when *link* is active, unexpired, and within view limit."""
if not link.is_active:
return False
now = datetime.now(timezone.utc)
if link.expires_at is not None:
exp = link.expires_at
if exp.tzinfo is None:
exp = exp.replace(tzinfo=timezone.utc)
if now > exp:
return False
if link.max_views is not None and link.view_count >= link.max_views:
return False
return True
def _resolve_file_path(file_record: FileRecord) -> str | None:
"""Return the best available file path for *file_record*.
Checks processed path first, then original, then local (tmp) path.
Returns ``None`` when no file exists on disk.
"""
from app.config import settings
workdir = os.path.realpath(settings.workdir)
candidates = [
file_record.processed_file_path,
file_record.original_file_path,
file_record.local_filename,
]
for path in candidates:
if not path:
continue
# Guard against path traversal in DB values.
real = os.path.realpath(path)
if not real.startswith(workdir + os.sep) and real != workdir:
logger.warning("Shared link file path outside workdir rejected: %s", path)
continue
if os.path.exists(real):
return real
return None
# ---------------------------------------------------------------------------
# Pydantic schemas
# ---------------------------------------------------------------------------
class SharedLinkCreate(BaseModel):
"""Schema for creating a new shared link."""
file_id: int = Field(..., description="ID of the file to share")
expires_in_hours: int | None = Field(
None,
ge=1,
le=720,
description="Expiry in hours (1720). NULL means the link never expires.",
)
max_views: int | None = Field(
None,
ge=1,
le=10_000,
description="Maximum number of downloads/views. NULL means unlimited.",
)
password: str | None = Field(
None,
min_length=1,
max_length=128,
description="Optional password protecting the link.",
)
label: str | None = Field(
None,
max_length=255,
description="Optional human-readable label for the link.",
)
@field_validator("expires_in_hours")
@classmethod
def validate_expiry(cls, v: int | None) -> int | None:
if v is not None and v not in range(1, 721):
raise ValueError("expires_in_hours must be between 1 and 720")
return v
class SharedLinkResponse(BaseModel):
"""Shared link info returned to the authenticated owner."""
id: int
token: str
file_id: int
label: str | None
expires_at: datetime | None
max_views: int | None
view_count: int
has_password: bool
is_active: bool
created_at: datetime | None
revoked_at: datetime | None
# Filled in by the endpoint, not stored in DB.
share_url: str = ""
original_filename: str | None = None
model_config = {"from_attributes": True}
class SharedLinkInfoResponse(BaseModel):
"""Public metadata about a shared link (used on the share landing page)."""
token: str
label: str | None
original_filename: str | None
expires_at: datetime | None
max_views: int | None
view_count: int
has_password: bool
is_valid: bool
# ---------------------------------------------------------------------------
# Private (authenticated) endpoints
# ---------------------------------------------------------------------------
@router.post("/", status_code=status.HTTP_201_CREATED, response_model=SharedLinkResponse)
async def create_shared_link(
body: SharedLinkCreate,
request: Request,
owner_id: CurrentOwner,
db: DbSession,
) -> dict[str, Any]:
"""Create a new shareable link for a document.
The caller must own the file (or be in single-user mode).
Returns the full link metadata including the generated token.
"""
# Verify the file exists and belongs to the caller.
q = db.query(FileRecord).filter(FileRecord.id == body.file_id)
q = apply_owner_filter(q, request)
file_record = q.first()
if not file_record:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="File not found")
token = _generate_token()
expires_at = None
if body.expires_in_hours is not None:
expires_at = datetime.now(timezone.utc).replace(microsecond=0)
from datetime import timedelta
expires_at = expires_at + timedelta(hours=body.expires_in_hours)
password_hash = _hash_password(body.password) if body.password else None
db_link = SharedLink(
token=token,
file_id=body.file_id,
owner_id=owner_id,
label=body.label,
expires_at=expires_at,
max_views=body.max_views,
view_count=0,
password_hash=password_hash,
)
try:
db.add(db_link)
db.commit()
db.refresh(db_link)
except Exception:
db.rollback()
raise
logger.info("Shared link created: id=%s owner=%s file_id=%s", db_link.id, owner_id, body.file_id)
base_url = str(request.base_url).rstrip("/")
return _link_to_dict(db_link, base_url, file_record.original_filename)
@router.get("/", response_model=list[SharedLinkResponse])
async def list_shared_links(
request: Request,
owner_id: CurrentOwner,
db: DbSession,
active_only: bool = Query(False, description="When true, only return active (non-revoked) links"),
) -> list[dict[str, Any]]:
"""List all shared links created by the authenticated user."""
q = (
db.query(SharedLink, FileRecord.original_filename)
.outerjoin(FileRecord, SharedLink.file_id == FileRecord.id)
.filter(SharedLink.owner_id == owner_id)
)
if active_only:
q = q.filter(SharedLink.is_active.is_(True))
links_with_filenames = q.order_by(SharedLink.created_at.desc()).all()
base_url = str(request.base_url).rstrip("/")
result = []
for link, filename in links_with_filenames:
result.append(_link_to_dict(link, base_url, filename))
return result
@router.delete("/{link_id}", status_code=status.HTTP_200_OK)
async def revoke_shared_link(
link_id: int,
owner_id: CurrentOwner,
db: DbSession,
) -> dict[str, str]:
"""Revoke (soft-delete) a shared link.
The record is kept for audit purposes but the link immediately
stops working for recipients.
"""
db_link = db.query(SharedLink).filter(SharedLink.id == link_id, SharedLink.owner_id == owner_id).first()
if not db_link:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Shared link not found")
if not db_link.is_active:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Link is already revoked")
try:
db_link.is_active = False
db_link.revoked_at = datetime.now(timezone.utc)
db.commit()
except Exception:
db.rollback()
raise
logger.info("Shared link revoked: id=%s owner=%s", link_id, owner_id)
return {"detail": "Link revoked"}
# ---------------------------------------------------------------------------
# Public endpoints (no authentication required)
# ---------------------------------------------------------------------------
@public_router.get("/share/{token}/info", response_model=SharedLinkInfoResponse)
def get_shared_link_info(
token: str,
db: DbSession,
) -> dict[str, Any]:
"""Return public metadata about a shared link.
Used by the share landing page to decide whether to show a password
prompt or a direct download button. Never returns sensitive data.
"""
link = db.query(SharedLink).filter(SharedLink.token == token).first()
if not link:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Link not found")
file_record = db.query(FileRecord).filter(FileRecord.id == link.file_id).first()
filename = file_record.original_filename if file_record else None
return {
"token": link.token,
"label": link.label,
"original_filename": filename,
"expires_at": link.expires_at,
"max_views": link.max_views,
"view_count": link.view_count,
"has_password": link.password_hash is not None,
"is_valid": _is_link_valid(link),
}
@public_router.get("/share/{token}/download")
def download_via_shared_link(
token: str,
db: DbSession,
) -> FileResponse:
"""Download a file via a shared link that does NOT require a password.
For password-protected links use ``POST /api/share/{token}/download``
with ``{"password": "<value>"}`` in the JSON body instead.
Increments the view counter and validates expiry / view limit before
serving the file.
"""
return _serve_shared_file(token, db, password=None)
class PasswordBody(BaseModel):
"""Request body for password-protected shared link downloads."""
password: str = Field(..., min_length=1, max_length=128, description="Password for the shared link")
@public_router.post("/share/{token}/download")
def download_via_shared_link_with_password(
token: str,
body: PasswordBody,
db: DbSession,
) -> FileResponse:
"""Download a password-protected file via a shared link.
Accepts the password in the JSON request body to avoid it appearing in
server access logs, browser history, or ``Referer`` headers.
"""
return _serve_shared_file(token, db, password=body.password)
def _serve_shared_file(token: str, db: Session, password: str | None) -> FileResponse:
"""Core download logic shared by the GET and POST download endpoints."""
link = db.query(SharedLink).filter(SharedLink.token == token).first()
if not link:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Link not found or expired")
if not _is_link_valid(link):
raise HTTPException(status_code=status.HTTP_410_GONE, detail="Link has expired or reached its view limit")
# Password check
if link.password_hash is not None:
if not password:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="This link requires a password",
)
if not _verify_password(password, link.password_hash):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Incorrect password")
file_record = db.query(FileRecord).filter(FileRecord.id == link.file_id).first()
if not file_record:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="File not found")
file_path = _resolve_file_path(file_record)
if not file_path:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="File not available on disk")
# Increment view count — fail the request if this cannot be persisted so
# that view-limited links are not bypassed during temporary DB outages.
try:
link.view_count = (link.view_count or 0) + 1
db.commit()
except Exception:
db.rollback()
logger.error("Failed to increment view_count for shared link id=%s — aborting download", link.id)
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="Service temporarily unavailable. Please try again.",
)
return FileResponse(
path=file_path,
media_type=file_record.mime_type or "application/octet-stream",
headers={
"Content-Disposition": f'attachment; filename="{file_record.original_filename or "document"}"',
},
)
# ---------------------------------------------------------------------------
# Internal helper
# ---------------------------------------------------------------------------
def _link_to_dict(link: SharedLink, base_url: str, original_filename: str | None) -> dict[str, Any]:
"""Serialise a ``SharedLink`` ORM row to a plain dict."""
return {
"id": link.id,
"token": link.token,
"file_id": link.file_id,
"label": link.label,
"expires_at": link.expires_at,
"max_views": link.max_views,
"view_count": link.view_count,
"has_password": link.password_hash is not None,
"is_active": link.is_active,
"created_at": link.created_at,
"revoked_at": link.revoked_at,
"share_url": f"{base_url}/share/{link.token}",
"original_filename": original_filename,
}
+355
View File
@@ -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
+124
View File
@@ -0,0 +1,124 @@
"""
System reset API endpoints for DocuElevate.
Provides admin-only REST endpoints for:
- Full system reset (wipe all user data)
- Reset with re-import (move originals → reimport folder, wipe, re-ingest)
Both operations require the ``ENABLE_FACTORY_RESET=True`` feature flag and
admin privileges.
"""
import logging
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, Request, status
from pydantic import BaseModel
from sqlalchemy.orm import Session
from app.config import settings
from app.database import get_db
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/admin/system-reset", tags=["system-reset"])
def _require_admin(request: Request) -> dict:
"""Ensure the caller is an admin. Raises 403 otherwise."""
user = request.session.get("user")
if not user or not user.get("is_admin"):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin access required")
return user
AdminUser = Annotated[dict, Depends(_require_admin)]
def _require_feature_enabled() -> None:
"""Raise 404 when the factory-reset feature flag is off."""
if not settings.enable_factory_reset:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="System reset is not enabled. Set ENABLE_FACTORY_RESET=True to activate.",
)
class ResetRequest(BaseModel):
"""Body for system reset endpoints. Requires explicit confirmation."""
confirmation: str
@router.post("/full")
async def full_reset(
body: ResetRequest,
_admin: AdminUser,
db: Session = Depends(get_db),
) -> dict:
"""Wipe all user data (database + work-files).
The caller must send ``{"confirmation": "DELETE"}`` to proceed.
"""
_require_feature_enabled()
if body.confirmation != "DELETE":
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Confirmation required: send {"confirmation": "DELETE"} to proceed.',
)
from app.utils.system_reset import perform_full_reset
try:
result = perform_full_reset(db)
except Exception as exc:
logger.exception("Full system reset failed")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"System reset failed: {exc}",
) from exc
return {"status": "ok", "result": result}
@router.post("/reimport")
async def reset_and_reimport(
body: ResetRequest,
_admin: AdminUser,
db: Session = Depends(get_db),
) -> dict:
"""Move original files to a reimport folder, wipe everything, and
configure the reimport folder as a watch folder for automatic
re-ingestion.
The caller must send ``{"confirmation": "REIMPORT"}`` to proceed.
"""
_require_feature_enabled()
if body.confirmation != "REIMPORT":
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail='Confirmation required: send {"confirmation": "REIMPORT"} to proceed.',
)
from app.utils.system_reset import perform_reset_and_reimport
try:
result = perform_reset_and_reimport(db)
except Exception as exc:
logger.exception("Reset-and-reimport failed")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Reset and reimport failed: {exc}",
) from exc
return {"status": "ok", "result": result}
@router.get("/status")
async def reset_status(_admin: AdminUser) -> dict:
"""Return whether the system reset feature is enabled."""
return {
"enabled": settings.enable_factory_reset,
"factory_reset_on_startup": settings.factory_reset_on_startup,
}
+156
View File
@@ -0,0 +1,156 @@
"""
API endpoints for document translation.
Provides on-the-fly translation via the AI provider and access to the
persisted default-language translation.
"""
import logging
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
from fastapi.responses import JSONResponse
from sqlalchemy.orm import Session
from app.auth import require_login
from app.config import settings
from app.database import get_db
from app.models import FileRecord
from app.utils.ai_provider import get_ai_provider
from app.utils.user_scope import apply_owner_filter
logger = logging.getLogger(__name__)
router = APIRouter()
DbSession = Annotated[Session, Depends(get_db)]
# Maximum characters sent to the AI provider for a single translation request.
_MAX_TRANSLATION_INPUT = 50_000
def _get_file_or_404(db: Session, file_id: int, request: Request) -> FileRecord:
"""Fetch a FileRecord visible to the current user or raise 404."""
query = db.query(FileRecord).filter(FileRecord.id == file_id)
query = apply_owner_filter(query, request)
record = query.first()
if not record:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="File not found")
return record
@router.get("/files/{file_id}/translation/default")
@require_login
def get_default_translation(
request: Request,
file_id: int,
db: DbSession,
) -> JSONResponse:
"""Return the persisted default-language translation for a document.
Returns 404 if no default-language translation has been generated yet
(e.g. because the document is already in the default language).
"""
record = _get_file_or_404(db, file_id, request)
if not record.default_language_text:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="No default-language translation available for this file",
)
return JSONResponse(
content={
"file_id": record.id,
"detected_language": record.detected_language,
"default_language_code": record.default_language_code,
"text": record.default_language_text,
}
)
@router.get("/files/{file_id}/translate")
@require_login
def translate_on_the_fly(
request: Request,
file_id: int,
db: DbSession,
lang: str = Query(..., min_length=2, max_length=10, description="Target language ISO 639-1 code"),
) -> JSONResponse:
"""Translate a document's extracted text into an arbitrary language on the fly.
The translation is generated via the configured AI provider and is **not**
persisted. For the default-language translation, use the
``/files/{file_id}/translation/default`` endpoint instead.
"""
record = _get_file_or_404(db, file_id, request)
source_text = record.ocr_text
if not source_text:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="No extracted text available for this file — translation requires OCR text",
)
# If the requested language matches what is already stored, return it directly.
if record.default_language_code and lang == record.default_language_code and record.default_language_text:
return JSONResponse(
content={
"file_id": record.id,
"source_language": record.detected_language,
"target_language": lang,
"text": record.default_language_text,
"cached": True,
}
)
# If the detected language already matches, return the original text.
detected = record.detected_language
if detected and detected == lang:
return JSONResponse(
content={
"file_id": record.id,
"source_language": detected,
"target_language": lang,
"text": source_text,
"cached": True,
}
)
# Truncate to keep AI costs bounded.
text_to_translate = source_text[:_MAX_TRANSLATION_INPUT]
try:
provider = get_ai_provider()
model = settings.ai_model or settings.openai_model
translated = provider.chat_completion(
messages=[
{
"role": "system",
"content": (
f"You are a professional translator. Translate the following text "
f"into {lang}. Preserve the original formatting, paragraph structure, "
f"and meaning. Do not add any commentary — output ONLY the translated text."
),
},
{"role": "user", "content": text_to_translate},
],
model=model,
temperature=0.3,
)
except Exception as exc:
logger.exception(f"On-the-fly translation failed for file {file_id}: {exc}")
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail="Translation failed — the AI provider returned an error",
)
return JSONResponse(
content={
"file_id": record.id,
"source_language": detected or "unknown",
"target_language": lang,
"text": translated,
"cached": False,
}
)
+71 -90
View File
@@ -2,7 +2,6 @@
API endpoint for processing files from URLs
"""
import ipaddress
import logging
import mimetypes
import os
@@ -10,15 +9,18 @@ import urllib.parse
import uuid
from typing import Optional
import requests
from fastapi import APIRouter, HTTPException, Request
import aiofiles
import httpx
from fastapi import APIRouter, Depends, HTTPException, Request
from pydantic import BaseModel, HttpUrl, field_validator
from app.auth import require_login
from app.config import settings
from app.middleware.upload_rate_limit import require_upload_rate_limit
from app.tasks.process_document import process_document
from app.utils.allowed_types import ALLOWED_MIME_TYPES
from app.utils.filename_utils import sanitize_filename
from app.utils.network import is_private_ip
# Set up logging
logger = logging.getLogger(__name__)
@@ -42,37 +44,6 @@ class URLUploadRequest(BaseModel):
return v
def is_private_ip(hostname: str) -> bool:
"""
Check if a hostname resolves to a private/internal IP address.
Protects against SSRF attacks by blocking access to internal networks.
"""
try:
# Try to parse as IP address directly
ip = ipaddress.ip_address(hostname)
return ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_reserved
except ValueError:
# Not a direct IP, try to resolve hostname
try:
import socket
# Get all IP addresses for this hostname
addr_info = socket.getaddrinfo(hostname, None)
for info in addr_info:
ip_str = info[4][0]
ip = ipaddress.ip_address(ip_str)
# Block if ANY resolved IP is private/internal
if ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_reserved:
return True
return False
except (socket.gaierror, socket.error):
# Cannot resolve - allow for testing/development
# In production, DNS should work properly
# Log this for debugging
logger.warning(f"Could not resolve hostname: {hostname}")
return False # Changed from True to False to allow external domains in tests
def validate_url_safety(url: str) -> None:
"""
Validate that URL is safe to fetch (SSRF protection).
@@ -137,7 +108,11 @@ def validate_file_type(content_type: str, filename: str) -> bool:
@router.post("/process-url")
@require_login
async def process_url(request: Request, url_request: URLUploadRequest):
async def process_url(
request: Request,
url_request: URLUploadRequest,
_rate_ok: None = Depends(require_upload_rate_limit),
):
"""
Download a file from a URL and enqueue it for processing.
@@ -184,67 +159,73 @@ async def process_url(request: Request, url_request: URLUploadRequest):
logger.info(f"Downloading file from URL: {url}")
# Use configured timeout to prevent hanging
response = requests.get(
url,
async with httpx.AsyncClient(
timeout=settings.http_request_timeout,
stream=True, # Stream to handle large files
allow_redirects=True, # Follow redirects
follow_redirects=True,
headers={
"User-Agent": "DocuElevate/1.0", # Identify ourselves
},
)
response.raise_for_status()
) as client:
async with client.stream("GET", url) as response:
response.raise_for_status()
# Validate content type
content_type = response.headers.get("Content-Type", "")
if not validate_file_type(content_type, safe_filename):
raise HTTPException(
status_code=400,
detail=f"Unsupported file type: {content_type}. "
"Supported types: PDF, Office documents, images, plain text",
)
# Validate content type
content_type = response.headers.get("Content-Type", "")
if not validate_file_type(content_type, safe_filename):
raise HTTPException(
status_code=400,
detail=f"Unsupported file type: {content_type}. "
"Supported types: PDF, Office documents, images, plain text",
)
# Check content length before downloading
content_length = response.headers.get("Content-Length")
if content_length:
file_size = int(content_length)
max_size = settings.max_upload_size
if file_size > max_size:
raise HTTPException(
status_code=413,
detail=f"File too large: {file_size} bytes (max {max_size} bytes)",
)
# Generate unique filename
unique_id = str(uuid.uuid4())
if "." in safe_filename:
file_extension = safe_filename.rsplit(".", 1)[1]
target_filename = f"{unique_id}.{file_extension}"
else:
target_filename = unique_id
target_path = os.path.join(settings.workdir, target_filename)
# Download file in chunks to handle large files
downloaded_size = 0
max_size = settings.max_upload_size
with open(target_path, "wb") as f:
for chunk in response.iter_content(chunk_size=8192):
if chunk:
f.write(chunk)
downloaded_size += len(chunk)
# Check size during download
if downloaded_size > max_size:
# Remove partial file
f.close()
os.remove(target_path)
# Check content length before downloading
content_length = response.headers.get("Content-Length")
if content_length:
file_size = int(content_length)
max_size = settings.max_upload_size
if file_size > max_size:
raise HTTPException(
status_code=413,
detail=f"File too large: exceeded {max_size} bytes during download",
detail=f"File too large: {file_size} bytes (max {max_size} bytes)",
)
# Generate unique filename
unique_id = str(uuid.uuid4())
# Check for extension using original_filename to avoid any CodeQL issues
# with safe_filename which is derived from the URL directly.
if "." in original_filename:
_, ext = os.path.splitext(original_filename)
# Strip out the leading dot and any non-alphanumeric chars
clean_ext = "".join(c for c in ext if c.isalnum())
if not clean_ext:
clean_ext = "bin"
target_filename = f"{unique_id}.{clean_ext}"
else:
target_filename = unique_id
target_path = os.path.join(settings.workdir, target_filename)
# Download file in chunks to handle large files
downloaded_size = 0
max_size = settings.max_upload_size
async with aiofiles.open(target_path, "wb") as f:
async for chunk in response.aiter_bytes(chunk_size=8192):
if chunk:
await f.write(chunk)
downloaded_size += len(chunk)
# Check size during download
if downloaded_size > max_size:
# Remove partial file
await f.close()
os.remove(target_path)
raise HTTPException(
status_code=413,
detail=f"File too large: exceeded {max_size} bytes during download",
)
logger.info(f"Downloaded file from URL '{url}' as '{target_filename}' ({downloaded_size} bytes)")
# Enqueue for processing
@@ -258,19 +239,19 @@ async def process_url(request: Request, url_request: URLUploadRequest):
"size": downloaded_size,
}
except requests.exceptions.Timeout:
except httpx.TimeoutException:
logger.error(f"Timeout while downloading file from URL: {url}")
raise HTTPException(status_code=408, detail="Request timeout: server took too long to respond")
except requests.exceptions.ConnectionError as e:
except httpx.ConnectError as e:
logger.error(f"Connection error while downloading file from URL: {url} - {str(e)}")
raise HTTPException(status_code=502, detail=f"Failed to connect to URL: {str(e)}")
except requests.exceptions.HTTPError as e:
except httpx.HTTPStatusError as e:
logger.error(f"HTTP error while downloading file from URL: {url} - {str(e)}")
raise HTTPException(status_code=e.response.status_code, detail=f"HTTP error: {str(e)}")
except requests.exceptions.RequestException as e:
except httpx.RequestError as e:
logger.error(f"Error downloading file from URL: {url} - {str(e)}")
raise HTTPException(status_code=500, detail=f"Failed to download file: {str(e)}")
+20 -7
View File
@@ -12,7 +12,7 @@ from sqlalchemy.orm import Session
from app.auth import require_login
from app.database import get_db
from app.models import FileRecord
from app.models import FileRecord, UserProfile
# Set up logging
logger = logging.getLogger(__name__)
@@ -22,7 +22,7 @@ router = APIRouter()
DbSession = Annotated[Session, Depends(get_db)]
async def whoami_handler(request: Request):
async def whoami_handler(request: Request, db: Session):
"""
Returns user info if logged in, else 401.
"""
@@ -41,20 +41,33 @@ async def whoami_handler(request: Request):
# Add the gravatar URL to the user object instead of creating a new response
user_response = user.copy() # Create a copy to avoid modifying the session
user_response["picture"] = gravatar_url
# Check if the user has a custom avatar stored in their profile
user_id = user.get("sub") or user.get("preferred_username") or user.get("email") or user.get("id")
if user_id:
try:
profile = db.query(UserProfile).filter(UserProfile.user_id == user_id).first()
if profile and profile.avatar_data:
user_response["picture"] = profile.avatar_data
else:
user_response["picture"] = gravatar_url
except Exception:
user_response["picture"] = gravatar_url
else:
user_response["picture"] = gravatar_url
return user_response
# Register the same handler under two different paths
@router.get("/whoami")
async def whoami(request: Request):
return await whoami_handler(request)
async def whoami(request: Request, db: DbSession):
return await whoami_handler(request, db)
@router.get("/auth/whoami")
async def auth_whoami(request: Request):
return await whoami_handler(request)
async def auth_whoami(request: Request, db: DbSession):
return await whoami_handler(request, db)
@router.get("/users/search")
+1013 -46
View File
File diff suppressed because it is too large Load Diff
+78 -3
View File
@@ -1,10 +1,15 @@
# app/celery_app.py
import logging
import os
from celery import Celery
from celery.signals import task_failure
from celery.signals import task_failure, worker_ready
from app.config import settings
logger = logging.getLogger(__name__)
celery = Celery(
"document_processor",
broker=settings.redis_url,
@@ -21,6 +26,72 @@ celery.conf.task_routes = {
"app.tasks.*": {"queue": "document_processor"},
}
# Mapping of document pipeline task names to the positional index of ``file_id``
# in their ``args`` tuple. These indices correspond to the task signatures:
# process_with_ocr(filename, file_id, ...) → index 1
# extract_metadata_with_gpt(filename, text, file_id) → index 2
# embed_metadata_into_pdf(path, text, metadata, file_id) → index 3
# Tasks that always pass ``file_id`` as a keyword argument
# (e.g. ``process_document``, ``finalize_document_storage``) are not listed
# here — their ``file_id`` is found via ``kwargs`` instead.
_FILE_ID_ARG_INDEX: dict[str, int] = {
"app.tasks.process_with_ocr.process_with_ocr": 1,
"app.tasks.extract_metadata_with_gpt.extract_metadata_with_gpt": 2,
"app.tasks.embed_metadata_into_pdf.embed_metadata_into_pdf": 3,
}
def _dispatch_user_failure_notification(sender, exception, args: list | None, kwargs: dict | None) -> None:
"""Best-effort per-user failure notification for document pipeline tasks.
Extracts ``file_id`` from the failed task's arguments, looks up the owning
user from the database, and dispatches a ``document.failed`` notification.
"""
from app.database import SessionLocal
from app.models import FileRecord
from app.utils.user_notification import notify_user_document_failed
task_name = sender.name if sender else ""
if not task_name.startswith("app.tasks."):
return
# 1. Resolve file_id from kwargs or positional args
file_id = (kwargs or {}).get("file_id")
if file_id is None:
idx = _FILE_ID_ARG_INDEX.get(task_name)
if idx is not None and args and len(args) > idx:
val = args[idx]
if isinstance(val, int):
file_id = val
if file_id is None:
return
# 2. Look up owner from the database
with SessionLocal() as db:
record = db.query(FileRecord).filter(FileRecord.id == file_id).first()
if not record or not record.owner_id:
return
owner_id = record.owner_id
filename = record.original_filename or record.local_filename or "unknown"
# 3. Dispatch per-user notification
error_msg = f"{type(exception).__name__}: {exception}" if exception else "Unknown error"
notify_user_document_failed(
owner_id=owner_id,
filename=os.path.basename(filename),
error=error_msg,
file_id=file_id,
)
@worker_ready.connect
def init_sentry_on_worker_ready(**kwargs):
"""Initialise Sentry SDK in the Celery worker process."""
from app.utils.sentry import init_sentry
init_sentry(integrations_extra=["celery"])
@task_failure.connect
def task_failure_handler(
@@ -40,6 +111,10 @@ def task_failure_handler(
kwargs=kwargs or {},
)
except Exception as e:
import logging
logger.exception(f"Failed to send task failure notification: {e}")
logging.exception(f"Failed to send task failure notification: {e}")
# Also dispatch a per-user failure notification for document pipeline tasks
try:
_dispatch_user_failure_notification(sender, exception, args, kwargs)
except Exception:
logger.warning("Could not dispatch per-user failure notification", exc_info=True)
+98 -29
View File
@@ -1,5 +1,7 @@
#!/usr/bin/env python3
import logging
from celery.schedules import crontab
# Ensure tasks are loaded
@@ -8,8 +10,20 @@ from app import tasks # noqa: F401 - Imports app/tasks.py so Celery can registe
# Import the shared Celery instance
from app.celery_app import celery
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.batch_tasks import ( # noqa: F401
backfill_missing_metadata,
cleanup_temp_files,
expire_shared_links,
process_new_documents,
prune_old_notifications,
prune_processing_logs,
reprocess_failed_documents,
sync_search_index,
)
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.convert_to_pdf import convert_to_pdf # noqa: F401
from app.tasks.convert_to_pdfa import convert_to_pdfa # noqa: F401
@@ -27,17 +41,21 @@ from app.tasks.refine_text_with_gpt import refine_text_with_gpt # noqa: F401
from app.tasks.rotate_pdf_pages import rotate_pdf_pages # noqa: F401
from app.tasks.send_to_all import send_to_all_destinations # noqa: F401
from app.tasks.subscription_tasks import apply_pending_subscription_changes_all # noqa: F401
from app.tasks.translate_to_default_language import translate_to_default_language # noqa: F401
# Import new send tasks
from app.tasks.upload_to_dropbox import upload_to_dropbox # noqa: F401
from app.tasks.upload_to_email import upload_to_email # noqa: F401
from app.tasks.upload_to_ftp import upload_to_ftp # noqa: F401
from app.tasks.upload_to_google_drive import upload_to_google_drive # noqa: F401
from app.tasks.upload_to_icloud import upload_to_icloud # noqa: F401
from app.tasks.upload_to_nextcloud import upload_to_nextcloud # noqa: F401
from app.tasks.upload_to_onedrive import upload_to_onedrive # noqa: F401
from app.tasks.upload_to_paperless import upload_to_paperless # noqa: F401
from app.tasks.upload_to_s3 import upload_to_s3 # noqa: F401
from app.tasks.upload_to_sftp import upload_to_sftp # noqa: F401
from app.tasks.upload_to_sharepoint import upload_to_sharepoint # noqa: F401
from app.tasks.upload_to_user_integration import upload_to_user_integration # noqa: F401
from app.tasks.upload_to_webdav import upload_to_webdav # noqa: F401
from app.tasks.upload_with_rclone import send_to_all_rclone_destinations, upload_with_rclone # noqa: F401
from app.tasks.uptime_kuma_tasks import ping_uptime_kuma # noqa: F401
@@ -47,6 +65,8 @@ from app.tasks.webhook_tasks import deliver_webhook_task # noqa: F401
# Register the settings reload signal handler so workers pick up config changes
from app.utils.settings_sync import register_settings_reload_signal
logger = logging.getLogger(__name__)
register_settings_reload_signal()
celery.conf.task_routes = {
@@ -63,15 +83,13 @@ def test_task():
check_credentials.apply_async(countdown=10) # Run 10 seconds after worker starts
celery.conf.beat_schedule = {
"poll-inboxes-every-minute": (
{
"task": "app.tasks.imap_tasks.pull_all_inboxes",
"schedule": crontab(minute="*/1"), # every 1 minute
"options": {"expires": 55}, # Ensure tasks don't pile up
}
if (settings.imap1_host or settings.imap2_host)
else None
),
# IMAP polling — always enabled because per-user IMAP integrations may
# exist in the database even when no system-level IMAP hosts are configured.
"poll-inboxes-every-minute": {
"task": "app.tasks.imap_tasks.pull_all_inboxes",
"schedule": crontab(minute="*/1"), # every 1 minute
"options": {"expires": 55}, # Ensure tasks don't pile up
},
# Add Uptime Kuma ping task if configured
"ping-uptime-kuma": (
{
@@ -100,27 +118,15 @@ celery.conf.beat_schedule = {
"schedule": crontab(minute="*/1"), # Every minute
"options": {"expires": 55}, # Must complete within 55 seconds
},
# Watch folder scanning — polls local paths, FTP, SFTP, and cloud ingest folders.
# Watch folder scanning — always enabled because per-user WATCH_FOLDER
# integrations may exist in the database even when no system-level watch
# folder settings are configured.
# Schedule is controlled by WATCH_FOLDER_POLL_INTERVAL (default: 1 minute).
"scan-watch-folders": (
{
"task": "app.tasks.watch_folder_tasks.scan_all_watch_folders",
"schedule": crontab(minute=f"*/{max(1, settings.watch_folder_poll_interval)}"),
"options": {"expires": 55},
}
if (
settings.watch_folders
or settings.ftp_ingest_enabled
or settings.sftp_ingest_enabled
or settings.dropbox_ingest_enabled
or settings.google_drive_ingest_enabled
or settings.onedrive_ingest_enabled
or settings.nextcloud_ingest_enabled
or settings.s3_ingest_enabled
or settings.webdav_ingest_enabled
)
else None
),
"scan-watch-folders": {
"task": "app.tasks.watch_folder_tasks.scan_all_watch_folders",
"schedule": crontab(minute=f"*/{max(1, settings.watch_folder_poll_interval)}"),
"options": {"expires": 55},
},
# Backfill embeddings for files that were processed before the
# embedding pipeline was enabled, or where the embedding task failed.
"backfill-missing-embeddings": {
@@ -172,3 +178,66 @@ celery.conf.beat_schedule = {
# Remove None entries from beat_schedule
celery.conf.beat_schedule = {k: v for k, v in celery.conf.beat_schedule.items() if v is not None}
# ---------------------------------------------------------------------------
# Load admin-managed scheduled jobs from the database
# ---------------------------------------------------------------------------
# These jobs are defined in the ``scheduled_jobs`` table (seeded by
# ``app.api.scheduled_jobs.seed_default_scheduled_jobs``) and can be
# enabled/disabled and rescheduled via the admin UI at /admin/scheduled-jobs.
# The schedule is read once at worker startup; changes take effect after
# the worker is restarted.
def _load_db_scheduled_jobs() -> None:
"""
Extend ``celery.conf.beat_schedule`` with entries from the ``scheduled_jobs``
database table.
Only rows with ``enabled=True`` are added. Rows whose ``name`` key
already exists in the static schedule (defined above) are skipped so
that hardcoded entries cannot be overridden accidentally.
Failures are logged as warnings and do not prevent the worker from
starting.
"""
try:
from app.database import SessionLocal
from app.models import ScheduledJob
with SessionLocal() as db:
jobs = db.query(ScheduledJob).filter(ScheduledJob.enabled.is_(True)).all()
added = 0
for job in jobs:
if job.name in celery.conf.beat_schedule:
# Static entry takes precedence; skip silently.
continue
if job.schedule_type == "interval" and job.interval_seconds:
from celery.schedules import schedule as interval_schedule
sched = interval_schedule(run_every=job.interval_seconds)
else:
# Default to cron.
sched = crontab(
minute=job.cron_minute,
hour=job.cron_hour,
day_of_week=job.cron_day_of_week,
day_of_month=job.cron_day_of_month,
month_of_year=job.cron_month_of_year,
)
celery.conf.beat_schedule[job.name] = {
"task": job.task_name,
"schedule": sched,
"options": {"expires": 3600},
}
added += 1
logger.info("Loaded %d scheduled job(s) from database into Celery Beat.", added)
except Exception as exc:
logger.warning("Could not load scheduled jobs from database: %s", exc)
_load_db_scheduled_jobs()
+680
View File
@@ -0,0 +1,680 @@
"""DocuElevate command-line interface.
Provides a pipe-friendly CLI for scripting and automation against the
DocuElevate REST API. Authentication is via personal API tokens (the
same tokens managed at ``/api-tokens`` in the web UI).
Usage::
docuelevate --url http://my-instance --token de_xxx list
DOCUELEVATE_URL=http://my-instance DOCUELEVATE_API_TOKEN=de_xxx docuelevate list
Commands
--------
upload Upload one or more local files for processing.
download Download a processed (or original) file by ID.
search Full-text search across all documents.
list List documents with optional filtering.
token Sub-commands: create / list / revoke API tokens.
"""
import json
import sys
from pathlib import Path
from typing import Any
from urllib.parse import unquote
import click
import requests
# ---------------------------------------------------------------------------
# Environment-variable defaults
# ---------------------------------------------------------------------------
ENV_URL = "DOCUELEVATE_URL"
ENV_TOKEN = "DOCUELEVATE_API_TOKEN"
_DEFAULT_URL = "http://localhost:8000"
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _build_headers(token: str) -> dict[str, str]:
"""Return Authorization headers for the given API token."""
return {"Authorization": f"Bearer {token}"}
def _api(
method: str,
base_url: str,
path: str,
token: str,
timeout: int = 60,
**kwargs: Any,
) -> requests.Response:
"""Make an authenticated API request and return the response.
Args:
method: HTTP method (GET, POST, DELETE, …).
base_url: The base URL of the DocuElevate instance.
path: API path starting with ``/``.
token: Plaintext API token.
timeout: Request timeout in seconds (default: 60).
**kwargs: Extra keyword arguments forwarded to :func:`requests.request`.
Returns:
The :class:`requests.Response` object.
Raises:
click.ClickException: On network errors.
"""
url = base_url.rstrip("/") + path
headers = _build_headers(token)
try:
resp = requests.request(method, url, headers=headers, timeout=timeout, **kwargs)
except requests.ConnectionError as exc:
raise click.ClickException(f"Could not connect to {base_url}: {exc}") from exc
except requests.Timeout as exc:
raise click.ClickException(f"Request timed out: {exc}") from exc
return resp
def _require_ok(resp: requests.Response) -> dict[str, Any] | list[Any]:
"""Assert a successful HTTP response and return parsed JSON.
Args:
resp: The response to check.
Returns:
Parsed JSON payload.
Raises:
click.ClickException: If the response status indicates an error.
"""
if resp.status_code >= 400:
try:
detail = resp.json().get("detail", resp.text)
except Exception:
detail = resp.text
raise click.ClickException(f"API error {resp.status_code}: {detail}")
try:
return resp.json()
except Exception:
return {}
def _output(data: Any, fmt: str) -> None:
"""Write *data* to stdout in the requested format.
Args:
data: The value to serialise (dict, list, or primitive).
fmt: Either ``"json"`` (machine-readable) or ``"table"`` (human-readable).
"""
if fmt == "json":
click.echo(json.dumps(data, indent=2, default=str))
else:
_print_table(data)
def _print_table(data: Any) -> None:
"""Pretty-print a list of dicts as a fixed-width table.
Falls back to JSON if the data is not a homogeneous list of dicts.
Args:
data: Data to render.
"""
if isinstance(data, dict):
# Single-object output — print as key: value pairs
for key, value in data.items():
click.echo(f" {key}: {value}")
return
if not isinstance(data, list) or not data:
click.echo(json.dumps(data, indent=2, default=str))
return
if not isinstance(data[0], dict):
for item in data:
click.echo(str(item))
return
# Determine column widths
keys = list(data[0].keys())
widths: dict[str, int] = {k: len(k) for k in keys}
for row in data:
for k in keys:
widths[k] = max(widths[k], len(str(row.get(k, ""))))
header = " ".join(k.upper().ljust(widths[k]) for k in keys)
separator = " ".join("-" * widths[k] for k in keys)
click.echo(header)
click.echo(separator)
for row in data:
click.echo(" ".join(str(row.get(k, "")).ljust(widths[k]) for k in keys))
# ---------------------------------------------------------------------------
# Root command group
# ---------------------------------------------------------------------------
@click.group(context_settings={"help_option_names": ["-h", "--help"]})
@click.option(
"--url",
envvar=ENV_URL,
default=_DEFAULT_URL,
show_default=True,
show_envvar=True,
help="Base URL of the DocuElevate instance.",
metavar="URL",
)
@click.option(
"--token",
envvar=ENV_TOKEN,
default=None,
show_envvar=True,
help="API token (de_…). Required for all commands except help.",
metavar="TOKEN",
)
@click.option(
"--format",
"fmt",
type=click.Choice(["table", "json"], case_sensitive=False),
default="table",
show_default=True,
help="Output format. Use 'json' for machine-readable / pipe-friendly output.",
)
@click.option(
"--timeout",
default=60,
show_default=True,
envvar="DOCUELEVATE_TIMEOUT",
show_envvar=True,
type=int,
help="HTTP request timeout in seconds.",
)
@click.version_option(package_name="docuelevate", prog_name="docuelevate")
@click.pass_context
def cli(ctx: click.Context, url: str, token: str | None, fmt: str, timeout: int) -> None:
"""DocuElevate CLI — interact with DocuElevate from the command line.
Configure the target instance and credentials via options or environment
variables:
\b
DOCUELEVATE_URL Base URL of the instance (default: http://localhost:8000)
DOCUELEVATE_API_TOKEN Personal API token (de_…)
DOCUELEVATE_TIMEOUT HTTP request timeout in seconds (default: 60)
Examples:
\b
# Upload a file
docuelevate --token de_xxx upload report.pdf
\b
# List files as JSON for further processing
docuelevate --token de_xxx --format json list | jq '.[].original_filename'
\b
# Search for invoices
docuelevate --token de_xxx search "invoice amazon"
"""
ctx.ensure_object(dict)
ctx.obj["url"] = url
ctx.obj["token"] = token
ctx.obj["fmt"] = fmt
ctx.obj["timeout"] = timeout
def _get_token(ctx: click.Context) -> str:
"""Return the token from context, raising ClickException if absent.
Args:
ctx: The current Click context.
Returns:
The API token string.
Raises:
click.ClickException: If no token has been provided.
"""
token = ctx.obj.get("token")
if not token:
raise click.ClickException(f"No API token provided. Use --token or set the {ENV_TOKEN} environment variable.")
return token
# ---------------------------------------------------------------------------
# list command
# ---------------------------------------------------------------------------
@cli.command("list")
@click.option("--page", default=1, show_default=True, help="Page number.")
@click.option("--per-page", default=25, show_default=True, help="Items per page (max 200).")
@click.option("--search", default=None, help="Filter by filename substring.")
@click.option("--mime-type", default=None, help="Filter by MIME type (e.g. application/pdf).")
@click.option("--status", "file_status", default=None, help="Filter by status: pending, processing, completed, failed.")
@click.option("--sort-by", default="created_at", show_default=True, help="Sort field.")
@click.option("--sort-order", type=click.Choice(["asc", "desc"]), default="desc", show_default=True)
@click.pass_context
def list_files(
ctx: click.Context,
page: int,
per_page: int,
search: str | None,
mime_type: str | None,
file_status: str | None,
sort_by: str,
sort_order: str,
) -> None:
"""List documents stored in DocuElevate.
Examples:
\b
docuelevate list
docuelevate list --status completed --per-page 10
docuelevate --format json list | jq '.[].original_filename'
"""
token = _get_token(ctx)
url: str = ctx.obj["url"]
fmt: str = ctx.obj["fmt"]
timeout: int = ctx.obj["timeout"]
params: dict[str, Any] = {
"page": page,
"per_page": per_page,
"sort_by": sort_by,
"sort_order": sort_order,
}
if search:
params["search"] = search
if mime_type:
params["mime_type"] = mime_type
if file_status:
params["status"] = file_status
resp = _api("GET", url, "/api/files", token, timeout=timeout, params=params)
payload = _require_ok(resp)
# Extract the list from the paginated response
files: list[dict[str, Any]] = payload.get("files", payload) if isinstance(payload, dict) else payload # type: ignore[assignment]
pagination: dict[str, Any] = payload.get("pagination", {}) if isinstance(payload, dict) else {}
if fmt == "json":
_output(files, fmt)
else:
# Trim fields for readable table
rows = [
{
"id": f.get("id"),
"filename": f.get("original_filename"),
"size": f.get("file_size"),
"status": f.get("status"),
"created_at": str(f.get("created_at", ""))[:19],
}
for f in files
]
_output(rows, fmt)
if pagination:
click.echo(f"\nPage {pagination.get('page')}/{pagination.get('pages')} ({pagination.get('total')} total)")
# ---------------------------------------------------------------------------
# upload command
# ---------------------------------------------------------------------------
@cli.command("upload")
@click.argument("files", nargs=-1, required=True, type=click.Path(exists=True, readable=True))
@click.option(
"--batch-size",
default=5,
show_default=True,
help="Maximum number of concurrent uploads (sequential when 1).",
)
@click.pass_context
def upload_files(ctx: click.Context, files: tuple[str, ...], batch_size: int) -> None:
"""Upload one or more local files for processing.
Supports glob patterns and multiple arguments for batch uploads.
Examples:
\b
docuelevate upload report.pdf
docuelevate upload *.pdf invoice_*.png
docuelevate upload --batch-size 3 /scans/*.pdf
"""
token = _get_token(ctx)
url: str = ctx.obj["url"]
fmt: str = ctx.obj["fmt"]
timeout: int = ctx.obj["timeout"]
results: list[dict[str, Any]] = []
failed = 0
for i, file_path in enumerate(files, 1):
path = Path(file_path)
click.echo(f"[{i}/{len(files)}] Uploading {path.name}", err=True)
try:
with path.open("rb") as fh:
resp = _api(
"POST",
url,
"/api/ui-upload",
token,
timeout=timeout,
files={"file": (path.name, fh)},
)
if resp.status_code >= 400:
try:
detail = resp.json().get("detail", resp.text)
except Exception:
detail = resp.text
click.echo(f" ERROR {resp.status_code}: {detail}", err=True)
results.append({"file": path.name, "status": "error", "detail": detail})
failed += 1
else:
data = resp.json()
results.append({"file": path.name, "status": "queued", **data})
click.echo(f" OK task_id={data.get('task_id', '?')}", err=True)
except click.ClickException:
raise
except Exception as exc:
click.echo(f" ERROR: {exc}", err=True)
results.append({"file": path.name, "status": "error", "detail": str(exc)})
failed += 1
_output(results, fmt)
if failed:
click.echo(f"\n{failed}/{len(files)} upload(s) failed.", err=True)
sys.exit(1)
# ---------------------------------------------------------------------------
# download command
# ---------------------------------------------------------------------------
@cli.command("download")
@click.argument("file_id", type=int)
@click.option(
"--output",
"-o",
default=None,
help="Destination file path. Defaults to the server-provided filename in the current directory.",
type=click.Path(),
)
@click.option(
"--version",
type=click.Choice(["processed", "original"]),
default="processed",
show_default=True,
help="Which version to download.",
)
@click.pass_context
def download_file(ctx: click.Context, file_id: int, output: str | None, version: str) -> None:
"""Download a file by its numeric ID.
Examples:
\b
docuelevate download 42
docuelevate download 42 --version original -o /tmp/orig.pdf
"""
token = _get_token(ctx)
url: str = ctx.obj["url"]
timeout: int = ctx.obj["timeout"]
resp = _api(
"GET",
url,
f"/api/files/{file_id}/download",
token,
timeout=timeout,
params={"version": version},
stream=True,
)
_require_ok(resp)
# Determine output filename
if output:
dest = Path(output)
else:
content_disp = resp.headers.get("content-disposition", "")
filename = f"file_{file_id}"
for raw_part in content_disp.split(";"):
clean = raw_part.strip()
if clean.startswith("filename="):
filename = clean[len("filename=") :].strip('"').strip("'")
break
if clean.startswith("filename*="):
raw = clean[len("filename*=") :]
if raw.upper().startswith("UTF-8''"):
filename = unquote(raw[7:])
break
dest = Path(filename)
with dest.open("wb") as fh:
for chunk in resp.iter_content(chunk_size=65536):
fh.write(chunk)
click.echo(f"Downloaded {dest} ({dest.stat().st_size} bytes)")
# ---------------------------------------------------------------------------
# search command
# ---------------------------------------------------------------------------
@cli.command("search")
@click.argument("query")
@click.option("--mime-type", default=None, help="Filter by MIME type.")
@click.option("--document-type", default=None, help="Filter by document type (e.g. Invoice).")
@click.option("--tags", default=None, help="Filter by tag.")
@click.option("--language", default=None, help="Filter by language code (e.g. en, de).")
@click.option("--page", default=1, show_default=True)
@click.option("--per-page", default=20, show_default=True, help="Results per page (max 100).")
@click.pass_context
def search(
ctx: click.Context,
query: str,
mime_type: str | None,
document_type: str | None,
tags: str | None,
language: str | None,
page: int,
per_page: int,
) -> None:
"""Full-text search across all documents.
Examples:
\b
docuelevate search "invoice amazon"
docuelevate search "contract" --document-type Contract --language en
docuelevate --format json search "receipt" | jq '.[].file_id'
"""
token = _get_token(ctx)
url: str = ctx.obj["url"]
fmt: str = ctx.obj["fmt"]
timeout: int = ctx.obj["timeout"]
params: dict[str, Any] = {"q": query, "page": page, "per_page": per_page}
if mime_type:
params["mime_type"] = mime_type
if document_type:
params["document_type"] = document_type
if tags:
params["tags"] = tags
if language:
params["language"] = language
resp = _api("GET", url, "/api/search", token, timeout=timeout, params=params)
payload = _require_ok(resp)
results: list[dict[str, Any]] = (
payload.get("results", payload) if isinstance(payload, dict) else payload # type: ignore[assignment]
)
total: int = payload.get("total", len(results)) if isinstance(payload, dict) else len(results)
pages: int = payload.get("pages", 1) if isinstance(payload, dict) else 1
if fmt == "json":
_output(results, fmt)
else:
rows = [
{
"file_id": r.get("file_id"),
"filename": r.get("original_filename"),
"type": r.get("document_type"),
"tags": ",".join(r.get("tags") or []),
}
for r in results
]
_output(rows, fmt)
click.echo(f"\nPage {page}/{pages} ({total} total results)")
# ---------------------------------------------------------------------------
# token sub-group
# ---------------------------------------------------------------------------
@cli.group("token")
@click.pass_context
def token_group(ctx: click.Context) -> None:
"""Manage personal API tokens.
Tokens can be created, listed, and revoked. Token rotation is achieved
by creating a new token before revoking the old one.
Examples:
\b
docuelevate token create "CI Pipeline"
docuelevate token list
docuelevate token revoke 3
"""
@token_group.command("create")
@click.argument("name")
@click.pass_context
def token_create(ctx: click.Context, name: str) -> None:
"""Create a new personal API token.
The full token value is printed exactly once. Store it securely.
Examples:
\b
docuelevate token create "My script"
docuelevate --format json token create "CI" | jq -r '.token'
"""
token = _get_token(ctx)
url: str = ctx.obj["url"]
fmt: str = ctx.obj["fmt"]
timeout: int = ctx.obj["timeout"]
resp = _api("POST", url, "/api/api-tokens/", token, timeout=timeout, json={"name": name})
payload = _require_ok(resp)
if fmt == "json":
_output(payload, fmt)
else:
if not isinstance(payload, dict):
raise click.ClickException("Unexpected API response format.")
click.echo("Token created successfully:")
click.echo(f" ID: {payload.get('id')}")
click.echo(f" Name: {payload.get('name')}")
click.echo(f" Prefix: {payload.get('token_prefix')}")
click.echo(f" Token: {payload.get('token')}")
click.echo()
click.echo("Store this token securely — it will not be shown again.", err=True)
@token_group.command("list")
@click.pass_context
def token_list(ctx: click.Context) -> None:
"""List all your API tokens (active and revoked).
Examples:
\b
docuelevate token list
docuelevate --format json token list | jq '.[] | select(.is_active)'
"""
token = _get_token(ctx)
url: str = ctx.obj["url"]
fmt: str = ctx.obj["fmt"]
timeout: int = ctx.obj["timeout"]
resp = _api("GET", url, "/api/api-tokens/", token, timeout=timeout)
payload = _require_ok(resp)
if fmt == "json":
_output(payload, fmt)
else:
if not isinstance(payload, list):
raise click.ClickException("Unexpected API response format.")
rows = [
{
"id": t.get("id"),
"name": t.get("name"),
"prefix": t.get("token_prefix"),
"active": t.get("is_active"),
"last_used": str(t.get("last_used_at") or "never")[:19],
"created": str(t.get("created_at") or "")[:19],
}
for t in payload
]
_output(rows, fmt)
@token_group.command("revoke")
@click.argument("token_id", type=int)
@click.option("--yes", "-y", is_flag=True, help="Skip confirmation prompt.")
@click.pass_context
def token_revoke(ctx: click.Context, token_id: int, yes: bool) -> None:
"""Revoke an API token by its numeric ID.
The token is soft-deleted (kept for audit) but immediately invalidated.
Examples:
\b
docuelevate token revoke 3
docuelevate token revoke 3 --yes
"""
token = _get_token(ctx)
url: str = ctx.obj["url"]
timeout: int = ctx.obj["timeout"]
if not yes:
click.confirm(f"Revoke token {token_id}?", abort=True)
resp = _api("DELETE", url, f"/api/api-tokens/{token_id}", token, timeout=timeout)
_require_ok(resp)
click.echo(f"Token {token_id} revoked.")
# ---------------------------------------------------------------------------
# Entry point
# ---------------------------------------------------------------------------
def main() -> None:
"""Entry point for the ``docuelevate`` console script."""
cli(auto_envvar_prefix="DOCUELEVATE") # type: ignore[call-arg]
if __name__ == "__main__":
main()
+498 -1
View File
@@ -13,6 +13,24 @@ class Settings(BaseSettings):
database_url: str
redis_url: str
# Database connection-pool tuning (ignored for SQLite, which uses NullPool).
db_pool_size: int = Field(
default=10,
description="Number of persistent connections kept in the pool per worker process.",
)
db_max_overflow: int = Field(
default=20,
description="Additional connections allowed beyond db_pool_size under burst load.",
)
db_pool_timeout: int = Field(
default=30,
description="Seconds to wait for a connection from the pool before raising a TimeoutError.",
)
db_pool_recycle: int = Field(
default=1800,
description="Recycle (close and reopen) connections after this many seconds to avoid stale connections.",
)
openai_api_key: str
openai_base_url: str = "https://api.openai.com/v1" # Default to OpenAI's endpoint
openai_model: str = "gpt-4o-mini" # Default model
@@ -48,19 +66,86 @@ class Settings(BaseSettings):
workdir: str
debug: bool = False # Default to False
# Logging level for the application. Accepts standard Python level names:
# DEBUG, INFO, WARNING, ERROR, CRITICAL. When *debug* is True and
# *log_level* has not been explicitly set, the effective level is forced to
# DEBUG so that all ``logger.debug()`` calls produce output.
log_level: str = Field(
default="INFO",
description=(
"Python logging level for the application root logger. "
"Accepts: DEBUG, INFO, WARNING, ERROR, CRITICAL. "
"When DEBUG=True and LOG_LEVEL is not explicitly set, "
"the effective level is automatically lowered to DEBUG."
),
)
# Log output format. ``text`` is the human-readable default.
# ``json`` emits one JSON object per line, ideal for log collectors
# (Promtail, Fluentd, Filebeat, Datadog agent) and SIEM ingestion.
log_format: str = Field(
default="text",
description=(
"Log output format: 'text' (human-readable, default) or "
"'json' (structured JSON lines for SIEM / log aggregation)."
),
)
# Optional syslog forwarding for application logs (not just audit events).
# When enabled, a Python SysLogHandler is added to the root logger so that
# every log message is also sent to the configured syslog receiver.
log_syslog_enabled: bool = Field(
default=False,
description="Forward application logs to a syslog receiver in addition to stdout.",
)
log_syslog_host: str = Field(
default="localhost",
description="Hostname or IP of the syslog receiver for application logs.",
)
log_syslog_port: int = Field(
default=514,
description="Port of the syslog receiver for application logs.",
)
log_syslog_protocol: str = Field(
default="udp",
description="Protocol for syslog transport: 'udp' or 'tcp'.",
)
# Making Dropbox optional
dropbox_enabled: bool = Field(
default=True,
description="Enable Dropbox as an upload destination. Set to False to disable uploads even when credentials are configured.",
)
dropbox_app_key: Optional[str] = None
dropbox_app_secret: Optional[str] = None
dropbox_folder: Optional[str] = None
dropbox_refresh_token: Optional[str] = None
dropbox_allow_global_credentials_for_integrations: bool = Field(
default=False,
description=(
"When True, users may authorize their personal Dropbox integrations using the global "
"DROPBOX_APP_KEY / DROPBOX_APP_SECRET credentials configured by the admin, without "
"needing to create their own Dropbox app. The Dropbox OAuth flow is initiated "
"server-side so the app secret is never exposed to the browser. "
"Default: False (each user must supply their own app credentials)."
),
)
# Making Nextcloud optional
nextcloud_enabled: bool = Field(
default=True,
description="Enable Nextcloud as an upload destination. Set to False to disable uploads even when credentials are configured.",
)
nextcloud_upload_url: Optional[str] = None
nextcloud_username: Optional[str] = None
nextcloud_password: Optional[str] = None
nextcloud_folder: Optional[str] = None
# Making Paperless optional
paperless_enabled: bool = Field(
default=True,
description="Enable Paperless-ngx as an upload destination. Set to False to disable uploads even when credentials are configured.",
)
paperless_ngx_api_token: Optional[str] = None
paperless_host: Optional[str] = None
paperless_custom_field_absender: Optional[str] = None # Name of the "absender" custom field in Paperless
@@ -108,12 +193,65 @@ class Settings(BaseSettings):
google_docai_processor_id: Optional[str] = None
google_docai_location: str = "us" # Processor location, e.g. "us" or "eu"
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
# ---------------------------------------------------------------------------
# Default target language for automatic document translation (ISO 639-1 code).
# After OCR / metadata extraction, if the detected document language differs
# from this value the system translates the extracted text into this language
# and stores it alongside the original. Other language translations are
# generated on the fly via the AI provider and are NOT persisted.
# Per-user overrides are stored in UserProfile.default_document_language.
default_document_language: str = Field(
default="en",
description=(
"ISO 639-1 language code for the default translation target "
"(e.g. 'en', 'de', 'fr'). Documents whose detected language "
"differs are automatically translated into this language after "
"processing. Default: 'en' (English)."
),
)
# Authentication settings
auth_enabled: bool = True # Default to enabled
admin_username: Optional[str] = None
admin_password: Optional[str] = None
session_secret: Optional[str] = None
session_lifetime_days: int = Field(
default=30,
description=(
"Session lifetime in days. Common values: 30, 60, 90. "
"Determines how long a user stays logged in before being required to re-authenticate. "
"Applies to both browser sessions and the session cookie max_age."
),
)
session_lifetime_custom_days: int | None = Field(
default=None,
description=(
"Override session_lifetime_days with a custom value. "
"When set, this takes precedence over session_lifetime_days. "
"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(
default=120,
description="Time-to-live in seconds for QR login challenges (default: 2 minutes).",
)
admin_group_name: str = "admin"
# Multi-user settings
@@ -166,11 +304,112 @@ class Settings(BaseSettings):
),
)
# Authentik
# Authentik / Generic OIDC
authentik_client_id: Optional[str] = None
authentik_client_secret: Optional[str] = None
authentik_config_url: Optional[str] = None
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
# Google OAuth2
social_auth_google_enabled: bool = False
social_auth_google_client_id: 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)
social_auth_microsoft_enabled: bool = False
social_auth_microsoft_client_id: Optional[str] = None
social_auth_microsoft_client_secret: Optional[str] = None
social_auth_microsoft_tenant: str = Field(
default="common",
description=(
"Azure AD tenant ID or one of 'common', 'organizations', 'consumers'. "
"Use 'common' to allow any Microsoft account and any Azure AD org. "
"Use a specific tenant ID (GUID) to restrict to a single organization. "
"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
social_auth_apple_enabled: bool = False
social_auth_apple_client_id: Optional[str] = None
social_auth_apple_team_id: Optional[str] = None
social_auth_apple_key_id: Optional[str] = None
social_auth_apple_private_key: Optional[str] = None
# Dropbox OAuth2
social_auth_dropbox_enabled: bool = False
social_auth_dropbox_client_id: Optional[str] = None
social_auth_dropbox_client_secret: Optional[str] = None
social_auth_dropbox_use_global_credentials: bool = Field(
default=False,
description=(
"When True, Dropbox social login uses the global DROPBOX_APP_KEY / DROPBOX_APP_SECRET "
"credentials (the storage integration credentials) instead of requiring separate "
"SOCIAL_AUTH_DROPBOX_CLIENT_ID / SOCIAL_AUTH_DROPBOX_CLIENT_SECRET values. "
"Requires SOCIAL_AUTH_DROPBOX_ENABLED=True and the global Dropbox app credentials to be set. "
"Default: False."
),
)
# 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
allow_local_signup: bool = Field(
@@ -394,6 +633,10 @@ class Settings(BaseSettings):
imap2_delete_after_process: bool = False
# Google Drive settings
google_drive_enabled: bool = Field(
default=True,
description="Enable Google Drive as an upload destination. Set to False to disable uploads even when credentials are configured.",
)
google_drive_credentials_json: Optional[str] = ""
google_drive_folder_id: Optional[str] = ""
google_drive_delegate_to: Optional[str] = "" # Optional delegated user email
@@ -405,6 +648,10 @@ class Settings(BaseSettings):
google_drive_refresh_token: Optional[str] = ""
# WebDAV settings
webdav_enabled: bool = Field(
default=True,
description="Enable WebDAV as an upload destination. Set to False to disable uploads even when credentials are configured.",
)
webdav_url: Optional[str] = None
webdav_username: Optional[str] = None
webdav_password: Optional[str] = None
@@ -412,6 +659,10 @@ class Settings(BaseSettings):
webdav_verify_ssl: bool = True
# FTP settings
ftp_enabled: bool = Field(
default=True,
description="Enable FTP as an upload destination. Set to False to disable uploads even when credentials are configured.",
)
ftp_host: Optional[str] = None
ftp_port: Optional[int] = 21
ftp_username: Optional[str] = None
@@ -421,6 +672,10 @@ class Settings(BaseSettings):
ftp_allow_plaintext: bool = True # Default to allowing plaintext fallback
# SFTP settings
sftp_enabled: bool = Field(
default=True,
description="Enable SFTP as an upload destination. Set to False to disable uploads even when credentials are configured.",
)
sftp_host: Optional[str] = None
sftp_port: Optional[int] = 22
sftp_username: Optional[str] = None
@@ -442,6 +697,10 @@ class Settings(BaseSettings):
email_default_recipient: Optional[str] = None
# Email destination settings (dedicated SMTP for document delivery decoupled from shared email above)
dest_email_enabled: bool = Field(
default=True,
description="Enable Email as an upload destination. Set to False to disable document delivery via email even when credentials are configured.",
)
dest_email_host: Optional[str] = None
dest_email_port: Optional[int] = 587
dest_email_username: Optional[str] = None
@@ -451,13 +710,30 @@ class Settings(BaseSettings):
dest_email_default_recipient: Optional[str] = None # Fallback recipient for document delivery
# OneDrive settings
onedrive_enabled: bool = Field(
default=True,
description="Enable OneDrive as an upload destination. Set to False to disable uploads even when credentials are configured.",
)
onedrive_client_id: Optional[str] = None
onedrive_client_secret: Optional[str] = None
onedrive_tenant_id: Optional[str] = "common" # Default to "common" for personal accounts
onedrive_refresh_token: Optional[str] = None # Required for personal accounts
onedrive_folder_path: Optional[str] = None
# SharePoint settings
sharepoint_client_id: Optional[str] = None
sharepoint_client_secret: Optional[str] = None
sharepoint_tenant_id: Optional[str] = "common"
sharepoint_refresh_token: Optional[str] = None
sharepoint_site_url: Optional[str] = None # e.g. https://tenant.sharepoint.com/sites/sitename
sharepoint_document_library: Optional[str] = "Documents" # Document library name
sharepoint_folder_path: Optional[str] = None # Subfolder inside the library
# AWS S3 settings
s3_enabled: bool = Field(
default=True,
description="Enable Amazon S3 as an upload destination. Set to False to disable uploads even when credentials are configured.",
)
aws_access_key_id: Optional[str] = None
aws_secret_access_key: Optional[str] = None
aws_region: Optional[str] = "us-east-1" # Default region
@@ -466,6 +742,16 @@ class Settings(BaseSettings):
s3_storage_class: Optional[str] = "STANDARD" # Default storage class
s3_acl: Optional[str] = "private" # Default ACL
# iCloud Drive settings
icloud_enabled: bool = Field(
default=True,
description="Enable iCloud Drive as an upload destination. Set to False to disable uploads even when credentials are configured.",
)
icloud_username: Optional[str] = None # Apple ID email address
icloud_password: Optional[str] = None # App-specific password (required for 2FA accounts)
icloud_folder: Optional[str] = None # Target folder path in iCloud Drive (e.g. "Documents/Uploads")
icloud_cookie_directory: Optional[str] = None # Directory for session cookies (default: ~/.pyicloud)
# Uptime Kuma settings
uptime_kuma_url: Optional[str] = None
uptime_kuma_ping_interval: int = 5 # Default ping interval in minutes
@@ -484,6 +770,33 @@ class Settings(BaseSettings):
# Feature flags
allow_file_delete: bool = True # Default to allowing file deletion from database
compliance_enabled: bool = Field(
default=True,
description=(
"Enable the compliance templates dashboard (GDPR, HIPAA, SOC 2). "
"When enabled, admins can view compliance status and apply "
"pre-built regulatory configurations. Default: True."
),
)
# System reset / factory reset settings
factory_reset_on_startup: bool = Field(
default=False,
description=(
"When enabled, DocuElevate wipes all user data (database rows and "
"work-files on disk) on every startup so the instance always comes "
"up in a clean, fresh state. Useful for demo or testing environments. "
"Default: False."
),
)
enable_factory_reset: bool = Field(
default=False,
description=(
"Show the 'System Reset' page in the admin UI. When enabled, "
"administrators can trigger a full data wipe or a wipe-and-reimport "
"directly from the web interface. Default: False."
),
)
# PDF/A archival conversion settings
enable_pdfa_conversion: bool = Field(
@@ -557,6 +870,17 @@ class Settings(BaseSettings):
),
)
imap_attachment_filter: str = Field(
default="documents_only",
description=(
"Controls which attachment types are ingested from IMAP emails. "
"Accepted values: "
"'documents_only' ingest only PDFs and office files (Word, Excel, PowerPoint, ODT, etc.); "
"'all' ingest all supported file types including images. "
"This is the global default; individual user IMAP accounts can override it."
),
)
# Batch processing settings
processall_throttle_threshold: int = Field(
default=20,
@@ -583,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_urls: Union[List[str], str] = Field(
default_factory=list,
@@ -617,6 +946,12 @@ class Settings(BaseSettings):
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_enabled: bool = Field(
default=True,
@@ -849,6 +1184,49 @@ class Settings(BaseSettings):
),
)
# SIEM / External Audit Log Forwarding
# Forward audit events to external SIEM systems for centralised monitoring.
audit_siem_enabled: bool = Field(
default=False,
description="Enable forwarding of audit events to an external SIEM system.",
)
audit_siem_transport: str = Field(
default="syslog",
description=(
"Transport used to forward audit events. "
"Options: 'syslog' (RFC 5424 over UDP/TCP), 'http' (JSON POST to a webhook URL, "
"compatible with Splunk HEC, Logstash HTTP input, Grafana Loki, etc.)."
),
)
audit_siem_syslog_host: str = Field(
default="localhost",
description="Hostname or IP of the syslog receiver.",
)
audit_siem_syslog_port: int = Field(
default=514,
description="Port of the syslog receiver.",
)
audit_siem_syslog_protocol: str = Field(
default="udp",
description="Protocol for syslog transport: 'udp' or 'tcp'.",
)
audit_siem_http_url: str = Field(
default="",
description=(
"HTTP endpoint URL for SIEM webhook delivery. "
"Supports Splunk HEC (https://splunk:8088/services/collector/event), "
"Logstash HTTP input, Grafana Loki push API, or any JSON-accepting endpoint."
),
)
audit_siem_http_token: str = Field(
default="",
description="Bearer / HEC token included in the Authorization header of SIEM HTTP requests.",
)
audit_siem_http_custom_headers: str = Field(
default="",
description="Comma-separated 'Key:Value' pairs of extra headers for SIEM HTTP requests.",
)
# UI / Appearance
ui_default_color_scheme: str = Field(
default="system",
@@ -859,6 +1237,20 @@ class Settings(BaseSettings):
),
)
# Per-user upload rate limiting (health-aware, Redis-backed sliding window)
upload_rate_limit_per_user: int = Field(
default=20,
description=(
"Maximum number of file uploads allowed per user within the sliding window. "
"The effective limit may be reduced dynamically when the system is under heavy load "
"(high queue depth or CPU usage). Set to 0 to disable per-user upload rate limiting."
),
)
upload_rate_limit_window: int = Field(
default=60,
description="Sliding window size in seconds for per-user upload rate limiting (default: 60).",
)
# Rate Limiting Configuration (see SECURITY_AUDIT.md and docs/API.md)
# Protects against DoS attacks and API abuse
rate_limiting_enabled: bool = Field(
@@ -914,6 +1306,111 @@ class Settings(BaseSettings):
description="Allowed request headers for CORS. Use ['*'] to allow all headers.",
)
# ---------------------------------------------------------------------------
# Support / Help Center Zammad integration
# ---------------------------------------------------------------------------
zammad_url: Optional[str] = Field(
default=None,
description=(
"Base URL of your Zammad instance (e.g. https://zammad.example.com). "
"Required for the chat widget and feedback form on the Help Center page."
),
)
zammad_chat_enabled: bool = Field(
default=False,
description="Show the Zammad live-chat widget on the Help Center page.",
)
zammad_chat_id: int = Field(
default=1,
description="Zammad chat topic ID to use for the live-chat widget.",
)
zammad_form_enabled: bool = Field(
default=False,
description="Show the Zammad feedback / ticket form on the Help Center page.",
)
support_email: Optional[str] = Field(
default=None,
description="Support e-mail address displayed on the Help Center page.",
)
# ---------------------------------------------------------------------------
# Observability Sentry error & performance monitoring
# ---------------------------------------------------------------------------
sentry_dsn: Optional[str] = Field(
default=None,
description=(
"Sentry Data Source Name (DSN). When set, error reporting and "
"performance tracing are enabled automatically. Leave blank (or unset) "
"to disable Sentry entirely."
),
)
sentry_environment: str = Field(
default="production",
description=(
"Environment tag sent to Sentry (e.g. 'development', 'staging', 'production'). "
"Helps you filter events in the Sentry dashboard."
),
)
sentry_traces_sample_rate: float = Field(
default=0.1,
description=(
"Fraction of transactions to capture for performance monitoring (0.01.0). "
"Set to 0.0 to disable tracing, 1.0 to capture every transaction. "
"Values above 0 may increase Sentry quota usage."
),
)
sentry_profiles_sample_rate: float = Field(
default=0.0,
description=(
"Fraction of profiled transactions to send to Sentry (0.01.0). "
"Profiling is only active when traces_sample_rate > 0. "
"Defaults to 0.0 (disabled) to minimise overhead."
),
)
sentry_send_default_pii: bool = Field(
default=False,
description=(
"Whether to attach personally identifiable information (PII) such as "
"IP addresses and user agents to Sentry events. Disabled by default "
"for privacy compliance (GDPR / CCPA). Enable only if your Sentry "
"project is configured to handle PII."
),
)
# ---------------------------------------------------------------------------
# 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")
@classmethod
def strip_outer_quotes(cls, data: Any) -> Any:
+40 -4
View File
@@ -10,6 +10,7 @@ from typing import Any
from sqlalchemy import create_engine, exc
from sqlalchemy.engine.url import make_url
from sqlalchemy.orm import Session, declarative_base, sessionmaker
from sqlalchemy.pool import NullPool, QueuePool
from app.config import settings
@@ -17,9 +18,37 @@ logger = logging.getLogger(__name__)
Base = declarative_base()
# Parse the DATABASE_URL
# ---------------------------------------------------------------------------
# Engine construction
# ---------------------------------------------------------------------------
DB_URL = settings.database_url
engine = create_engine(DB_URL, connect_args={"check_same_thread": False})
_parsed_url = make_url(DB_URL)
_connect_args: dict[str, Any] = {}
_engine_kwargs: dict[str, Any] = {
"pool_pre_ping": True, # detect stale / dropped connections before use
}
if _parsed_url.get_backend_name() == "sqlite":
# SQLite does not benefit from connection pooling and is prone to
# QueuePool exhaustion under concurrent access. NullPool opens a fresh
# connection for each request and closes it immediately afterwards,
# completely avoiding the "QueuePool limit reached" TimeoutError.
_connect_args["check_same_thread"] = False
_engine_kwargs["poolclass"] = NullPool
else:
# PostgreSQL / MySQL — use a bounded QueuePool with configurable limits.
_engine_kwargs["poolclass"] = QueuePool
_engine_kwargs.update(
{
"pool_size": settings.db_pool_size,
"max_overflow": settings.db_max_overflow,
"pool_timeout": settings.db_pool_timeout,
"pool_recycle": settings.db_pool_recycle,
}
)
engine = create_engine(DB_URL, connect_args=_connect_args, **_engine_kwargs)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
@@ -210,8 +239,10 @@ def _run_schema_migrations(engine: Any) -> None:
if unique_filehash_indexes:
logger.info("Migrating files: dropping unique index on 'filehash'")
with engine.begin() as conn:
preparer = conn.dialect.identifier_preparer
for index in unique_filehash_indexes:
conn.execute(text(f"DROP INDEX IF EXISTS {index['name']}"))
quoted_idx = preparer.quote(index["name"])
conn.execute(text(f"DROP INDEX IF EXISTS {quoted_idx}"))
logger.info("Migration complete: unique index on 'filehash' removed")
except Exception as exc:
logger.warning(f"Skipping filehash unique index drop: {exc}")
@@ -263,12 +294,17 @@ def _ensure_indexes(engine: Any, inspector: Any) -> None:
table_names = inspector.get_table_names()
columns_by_table: dict[str, set[str]] = {}
with engine.begin() as conn:
preparer = conn.dialect.identifier_preparer
for idx_name, table, column in _PERF_INDEXES:
if table in table_names:
if table not in columns_by_table:
columns_by_table[table] = {col["name"] for col in inspector.get_columns(table)}
if column in columns_by_table[table]:
conn.execute(text(f"CREATE INDEX IF NOT EXISTS {idx_name} ON {table} ({column})"))
# SECURITY: Quoted identifiers to prevent SQL injection during index creation
quoted_idx = preparer.quote(idx_name)
quoted_table = preparer.quote(table)
quoted_col = preparer.quote(column)
conn.execute(text(f"CREATE INDEX IF NOT EXISTS {quoted_idx} ON {quoted_table} ({quoted_col})"))
logger.info("Performance indexes ensured")
+210 -13
View File
@@ -1,8 +1,11 @@
#!/usr/bin/env python3
import json as _json_mod
import logging
import os
import pathlib
from contextlib import asynccontextmanager
from datetime import datetime as _dt
from datetime import timezone as _tz
from fastapi import FastAPI, HTTPException, Request, status
from fastapi.middleware.cors import CORSMiddleware
@@ -16,6 +19,7 @@ from starlette.middleware.trustedhost import TrustedHostMiddleware
from uvicorn.middleware.proxy_headers import ProxyHeadersMiddleware
from app.api import router as api_router
from app.api.graphql_api import graphql_router
from app.api.local_auth import router as local_auth_router
from app.auth import router as auth_router
from app.config import settings
@@ -27,6 +31,7 @@ from app.middleware.request_size_limit import RequestSizeLimitMiddleware
from app.middleware.security_headers import SecurityHeadersMiddleware
from app.utils.config_validator import check_all_configs
from app.utils.notification import init_apprise, notify_shutdown, notify_startup
from app.utils.sentry import init_sentry
# Import the routers - now using views directly instead of frontend
from app.views import router as frontend_router
@@ -34,6 +39,114 @@ from app.views import router as frontend_router
# Explicitly include the files router
from app.views.files import router as files_router
# ---------------------------------------------------------------------------
# Configure Python root logging level early so that *all* loggers (including
# those already created via ``logging.getLogger(__name__)`` in other modules)
# respect the configured level.
#
# Standard behaviour (matches Django, Flask, 12-factor conventions):
# • ``LOG_LEVEL`` env var takes precedence when explicitly set.
# • When ``DEBUG=True`` and ``LOG_LEVEL`` is **not** set, the effective
# level is automatically lowered to ``DEBUG``.
# • Default (neither flag set): ``INFO``.
#
# ``LOG_FORMAT=json`` enables structured JSON lines on stdout, suitable for
# Promtail, Fluentd, Filebeat, Datadog, Splunk UF, or any log collector.
#
# ``LOG_SYSLOG_ENABLED=true`` adds a Python SysLogHandler so that every log
# message is also forwarded to the configured syslog receiver — useful for
# traditional (non-container) deployments and centralised SIEM ingestion.
#
# Noisy third-party loggers (httpx, httpcore, authlib, etc.) are pinned to
# WARNING when the app-level is DEBUG to keep output useful.
# ---------------------------------------------------------------------------
_explicit_log_level = os.environ.get("LOG_LEVEL")
if settings.debug and _explicit_log_level is None:
_effective_level = "DEBUG"
else:
_effective_level = settings.log_level.upper()
_effective_level_int = getattr(logging, _effective_level, logging.INFO)
class _JsonFormatter(logging.Formatter):
"""Emit one JSON object per log line for machine consumption.
Fields emitted: ``timestamp``, ``level``, ``logger``, ``message``,
``module``, ``funcName``, ``lineno``, and when present ``exc_info``.
Compatible with Grafana Loki, Splunk, ELK, Datadog, and most SIEM tools.
"""
def format(self, record: logging.LogRecord) -> str:
log_entry: dict = {
"timestamp": _dt.fromtimestamp(record.created, tz=_tz.utc).isoformat(),
"level": record.levelname,
"logger": record.name,
"message": record.getMessage(),
"module": record.module,
"funcName": record.funcName,
"lineno": record.lineno,
}
if record.exc_info and record.exc_info[1] is not None:
log_entry["exc_info"] = self.formatException(record.exc_info)
return _json_mod.dumps(log_entry, default=str)
# Choose formatter based on LOG_FORMAT setting
if settings.log_format.lower() == "json":
_handler = logging.StreamHandler()
_handler.setFormatter(_JsonFormatter())
logging.root.handlers = [_handler]
logging.root.setLevel(_effective_level_int)
else:
logging.basicConfig(
level=_effective_level_int,
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
force=True,
)
# Optional: forward application logs to a syslog receiver
if settings.log_syslog_enabled:
import logging.handlers as _lh
import socket as _socket
_proto = settings.log_syslog_protocol.lower()
_socktype = _socket.SOCK_STREAM if _proto == "tcp" else _socket.SOCK_DGRAM
_syslog_handler = _lh.SysLogHandler(
address=(settings.log_syslog_host, settings.log_syslog_port),
socktype=_socktype,
)
_syslog_handler.setLevel(_effective_level_int)
# Use the same formatter as stdout (text or JSON)
if settings.log_format.lower() == "json":
_syslog_handler.setFormatter(_JsonFormatter())
else:
_syslog_handler.setFormatter(logging.Formatter("%(name)s - %(levelname)s - %(message)s"))
logging.root.addHandler(_syslog_handler)
# Keep noisy third-party loggers quiet at DEBUG level
if _effective_level_int <= logging.DEBUG:
for _noisy in (
"httpx",
"httpcore",
"authlib",
"urllib3",
"hpack",
"multipart",
"watchfiles",
):
logging.getLogger(_noisy).setLevel(logging.WARNING)
_startup_logger = logging.getLogger(__name__)
_startup_logger.info(
"Root logging level set to %s (debug=%s, format=%s, syslog=%s)",
_effective_level,
settings.debug,
settings.log_format,
settings.log_syslog_enabled,
)
# Load configuration from .env for the session key
config = Config(".env")
# Use settings.session_secret which has proper validation
@@ -57,6 +170,12 @@ async def lifespan(app: FastAPI):
# Startup: Initialize database
init_db() # Create tables if they don't exist
# Factory reset on startup — wipe all user data before anything else
if settings.factory_reset_on_startup:
from app.utils.system_reset import perform_startup_reset
perform_startup_reset()
# Load settings from database after DB initialization
from app.database import SessionLocal
from app.utils.config_loader import load_settings_from_db
@@ -70,6 +189,22 @@ async def lifespan(app: FastAPI):
finally:
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
# via the database UI (e.g. SENTRY_DSN) are respected in addition to env vars.
init_sentry()
# Ensure OCR language data is available (background download, non-blocking)
from app.utils.ocr_language_manager import ensure_ocr_languages_async
@@ -127,6 +262,34 @@ async def lifespan(app: FastAPI):
except Exception:
logging.debug("Default pipeline seeding skipped — DB may not be ready yet") # noqa: S110
# Seed the default scheduled batch processing jobs so they appear in the
# admin UI (/admin/scheduled-jobs) on first startup.
try:
from app.api.scheduled_jobs import seed_default_scheduled_jobs as _seed_jobs
from app.database import SessionLocal as _SessionLocal # noqa: F811
_db_jobs = _SessionLocal()
try:
_seed_jobs(_db_jobs)
finally:
_db_jobs.close()
except Exception:
logging.debug("Scheduled jobs seeding skipped — DB may not be ready yet") # noqa: S110
# Seed the built-in compliance templates (GDPR, HIPAA, SOC2) so they
# are available in the admin compliance dashboard on first startup.
try:
from app.database import SessionLocal as _SessionLocal # noqa: F811
from app.utils.compliance_service import seed_compliance_templates as _seed_compliance
_db_compliance = _SessionLocal()
try:
_seed_compliance(_db_compliance)
finally:
_db_compliance.close()
except Exception:
logging.debug("Compliance template seeding skipped — DB may not be ready yet") # noqa: S110
# Application is now running
yield
@@ -137,7 +300,12 @@ async def lifespan(app: FastAPI):
notify_shutdown()
app = FastAPI(title="DocuElevate", lifespan=lifespan)
app = FastAPI(
title="DocuElevate",
lifespan=lifespan,
docs_url="/admin/api-docs",
redoc_url="/admin/api-redoc",
)
# Initialize rate limiter and attach to app state
limiter = create_limiter(redis_url=settings.redis_url, enabled=settings.rate_limiting_enabled)
@@ -168,8 +336,19 @@ app.add_middleware(CSRFMiddleware, config=settings)
# See SECURITY_AUDIT.md Infrastructure Security section
app.add_middleware(AuditLogMiddleware, config=settings)
# 3) Session Middleware (for request.session to work)
app.add_middleware(SessionMiddleware, secret_key=SESSION_SECRET)
def _get_session_max_age() -> int:
"""Compute session max-age at startup time."""
try:
from app.utils.session_manager import get_session_max_age_seconds
return get_session_max_age_seconds()
except Exception:
return 30 * 86400 # 30 days default fallback
app.add_middleware(SessionMiddleware, secret_key=SESSION_SECRET, max_age=_get_session_max_age())
# 3a) CORS Middleware - handles cross-origin requests and preflight (OPTIONS) responses.
# Disabled by default: set CORS_ENABLED=True only when NOT using a reverse proxy
@@ -202,17 +381,36 @@ if os.path.exists(static_dir):
else:
print(f"WARNING: Static directory not found at {static_dir}. Static files will not be served.")
# Mount the built MkDocs documentation site at /help/
# Mount the built MkDocs developer documentation at /developer-docs/
# These docs target administrators and developers, not end-users.
# The user-facing Help Center is served by the /help view instead.
# The docs are pre-built into docs_build/ during the Docker image build.
# When running locally, run `mkdocs build` from the repo root first.
docs_build_dir = pathlib.Path(__file__).parents[1] / "docs_build"
if os.path.exists(docs_build_dir):
app.mount("/help", StaticFiles(directory=str(docs_build_dir), html=True), name="help_docs")
app.mount("/developer-docs", StaticFiles(directory=str(docs_build_dir), html=True), name="developer_docs")
else:
print(f"INFO: Help docs not found at {docs_build_dir}. Run 'mkdocs build' to generate them.")
print(f"INFO: Developer docs not found at {docs_build_dir}. Run 'mkdocs build' to generate them.")
# Custom exception handlers that return JSON for API routes and HTML for frontend routes
# These use their own separate templates instance so that patches in tests on individual
# view modules do not affect the error handler rendering.
_error_templates_dir = pathlib.Path(__file__).parents[1] / "frontend" / "templates"
_error_templates = Jinja2Templates(directory=str(_error_templates_dir))
# Register the i18n translate helper as a global so error templates can use {{ _("key") }}.
# Error pages use the default language (English); request-specific locale is not needed here.
from app.utils.i18n import SUPPORTED_LANGUAGES as _SUPPORTED_LANGUAGES # noqa: E402
from app.utils.i18n import get_suggested_languages as _get_suggested_languages # noqa: E402
from app.utils.i18n import translate as _translate_fn # noqa: E402
_error_templates.env.globals["_"] = lambda key, **kwargs: _translate_fn(key, "en", **kwargs)
_error_templates.env.globals["min"] = min
_error_templates.env.globals["max"] = max
_error_templates.env.globals["supported_languages"] = _SUPPORTED_LANGUAGES
_error_templates.env.globals["suggested_languages"] = _get_suggested_languages("en", "")
@app.exception_handler(HTTPException)
async def http_exception_handler(request: Request, exc: HTTPException):
"""
@@ -224,17 +422,15 @@ async def http_exception_handler(request: Request, exc: HTTPException):
return JSONResponse(status_code=exc.status_code, content={"detail": exc.detail})
# For frontend routes, return appropriate HTML templates
templates = Jinja2Templates(directory=str(static_dir.parent / "templates"))
# Handle 404 errors with a custom template
if exc.status_code == 404:
return templates.TemplateResponse("404.html", {"request": request}, status_code=status.HTTP_404_NOT_FOUND)
return _error_templates.TemplateResponse(request, "404.html", status_code=status.HTTP_404_NOT_FOUND)
# For other HTTP errors, we could create specific templates or use a generic one
# For now, return a simple error page
return templates.TemplateResponse(
return _error_templates.TemplateResponse(
request,
"404.html", # Reuse 404 template for other errors, or create a generic error template
{"request": request},
status_code=exc.status_code,
)
@@ -253,10 +449,10 @@ async def custom_500_handler(request: Request, exc: Exception):
)
# Serve the 500 template for non-API routes
templates = Jinja2Templates(directory=str(static_dir.parent / "templates"))
return templates.TemplateResponse(
return _error_templates.TemplateResponse(
request,
"500.html",
{"request": request, "exc": exc},
context={"exc": exc},
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
)
@@ -272,3 +468,4 @@ app.include_router(files_router) # Explicitly include the files router
app.include_router(auth_router)
app.include_router(local_auth_router)
app.include_router(api_router, prefix="/api")
app.include_router(graphql_router, prefix="/graphql")
+30
View File
@@ -20,6 +20,9 @@ How it works:
Exempt paths (CSRF is not checked even for state-changing methods):
- ``/oauth-callback`` OAuth 2.0 callback; protected by the ``state`` parameter.
- ``/api/qr-auth/claim`` Called by the unauthenticated mobile app; the
cryptographically-random, single-use challenge token provides equivalent
protection.
"""
import logging
@@ -39,6 +42,10 @@ CSRF_PROTECTED_METHODS = {"POST", "PUT", "DELETE", "PATCH"}
# their own replay-protection mechanism).
CSRF_EXEMPT_PATHS = {
"/oauth-callback",
# The mobile app calls this endpoint without a browser session/CSRF token.
# The cryptographically-random, single-use challenge token already provides
# equivalent protection against cross-site request forgery.
"/api/qr-auth/claim",
}
@@ -109,6 +116,13 @@ class CSRFMiddleware(BaseHTTPMiddleware):
# Validate for state-changing methods on non-exempt paths.
if request.method in CSRF_PROTECTED_METHODS and request.url.path not in CSRF_EXEMPT_PATHS:
# Bearer-authenticated requests (API tokens) are exempt from CSRF
# because the token itself acts as proof of intent — it cannot be
# injected by a cross-site request from a browser.
auth_header = request.headers.get("authorization", "")
if auth_header.startswith("Bearer "):
return await call_next(request)
submitted_token = await self._get_submitted_token(request)
if not submitted_token or not secrets.compare_digest(csrf_token, submitted_token):
logger.warning(f"[SECURITY] CSRF_VALIDATION_FAILED method={request.method} path={request.url.path}")
@@ -148,10 +162,26 @@ class CSRFMiddleware(BaseHTTPMiddleware):
# 2. For URL-encoded form bodies only (plain HTML form submissions).
content_type = request.headers.get("content-type", "")
logger.debug("CSRF: content_type=%r method=%s path=%s", content_type, request.method, request.url.path)
if "application/x-www-form-urlencoded" in content_type:
try:
# Cache the raw body bytes before parsing the form. Starlette's
# BaseHTTPMiddleware uses _CachedRequest.wrapped_receive to relay
# the body to downstream handlers. When form() is called it
# internally uses stream() which sets _stream_consumed=True but
# does NOT populate _body. wrapped_receive then sees a consumed
# stream and forwards an empty body, so the auth endpoint gets
# form_keys=[]. Calling body() first stores the bytes in _body;
# wrapped_receive detects this and replays the real body to any
# downstream handler (e.g. the /auth endpoint).
await request.body()
form = await request.form()
token = form.get("csrf_token")
logger.debug(
"CSRF: form_keys=%s csrf_token_present=%s",
list(form.keys()),
bool(token),
)
if token:
return str(token)
except Exception as exc:
+290
View File
@@ -0,0 +1,290 @@
"""Per-user, health-aware upload rate limiter for DocuElevate.
This module provides a FastAPI dependency that enforces per-user upload rate
limits using a Redis-backed sliding window counter. The effective limit is
dynamically reduced when the system is under heavy load (high Celery queue
depth or elevated CPU load average), ensuring the server remains responsive
to all users even during bulk-upload scenarios.
Usage in an endpoint::
from app.middleware.upload_rate_limit import require_upload_rate_limit
@router.post("/ui-upload")
@require_login
async def ui_upload(
request: Request,
_rate_ok: None = Depends(require_upload_rate_limit),
...
):
...
See ``docs/ConfigurationGuide.md`` for the configuration options
(``UPLOAD_RATE_LIMIT_PER_USER``, ``UPLOAD_RATE_LIMIT_WINDOW``).
"""
from __future__ import annotations
import logging
import os
import time
from typing import Any
import redis
from fastapi import HTTPException, Request, status
from app.config import settings
from app.utils.user_scope import get_current_owner_id
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Redis key prefix
# ---------------------------------------------------------------------------
_KEY_PREFIX = "docuelevate:upload_rate"
# ---------------------------------------------------------------------------
# Health-check queue names (Celery defaults used by DocuElevate)
# ---------------------------------------------------------------------------
_CELERY_QUEUES = ("document_processor", "default", "celery")
# ---------------------------------------------------------------------------
# Singleton Redis client (lazy-initialised; fail-open when unavailable)
# ---------------------------------------------------------------------------
_redis_client: redis.Redis | None = None
def _get_redis() -> redis.Redis | None:
"""Return a shared Redis client, or *None* when Redis is unavailable."""
global _redis_client
if _redis_client is not None:
return _redis_client
try:
_redis_client = redis.Redis.from_url(
settings.redis_url,
decode_responses=True,
socket_connect_timeout=2,
socket_timeout=2,
)
# Quick connectivity check raises on failure.
_redis_client.ping()
return _redis_client
except Exception: # noqa: BLE001
logger.debug("Redis unavailable for upload rate limiter falling back to allow-all", exc_info=True)
_redis_client = None
return None
# ---------------------------------------------------------------------------
# Health metrics helpers
# ---------------------------------------------------------------------------
def _get_queue_depth(r: redis.Redis) -> int:
"""Return the total number of pending tasks across all Celery queues."""
total = 0
for queue_name in _CELERY_QUEUES:
try:
total += r.llen(queue_name)
except Exception: # noqa: BLE001, S110
logger.debug("Could not read queue length for %r", queue_name, exc_info=True)
return total
def _get_cpu_load_ratio() -> float:
"""Return the 1-minute load average divided by the number of CPU cores.
Returns ``0.0`` on platforms that do not support :func:`os.getloadavg`
(e.g. Windows) so that the limiter never penalises on those systems.
"""
try:
load_1m = os.getloadavg()[0]
cpu_count = os.cpu_count() or 1
return load_1m / cpu_count
except (OSError, AttributeError):
return 0.0
def compute_effective_limit(
base_limit: int,
queue_depth: int = 0,
cpu_load_ratio: float = 0.0,
) -> tuple[int, float, str]:
"""Compute the effective upload rate limit based on system health.
The function applies a *reduction factor* (``0.0 < factor 1.0``) to the
configured base limit. Both queue depth and CPU load contribute
independently; the lowest factor wins.
Args:
base_limit: The configured maximum uploads per window.
queue_depth: Total pending tasks in Celery queues.
cpu_load_ratio: 1-minute load average divided by CPU count.
Returns:
A 3-tuple of ``(effective_limit, factor, reason)`` where *reason*
is a human-readable tag for logging.
"""
factor = 1.0
reason = "normal"
# --- Queue-depth thresholds ---
if queue_depth > 200:
factor, reason = min(factor, 0.10), f"critical_queue({queue_depth})"
elif queue_depth > 100:
factor, reason = min(factor, 0.25), f"high_queue({queue_depth})"
elif queue_depth > 50:
factor, reason = min(factor, 0.50), f"moderate_queue({queue_depth})"
# --- CPU-load thresholds ---
if cpu_load_ratio > 3.0:
new_factor = 0.10
if new_factor < factor:
factor, reason = new_factor, f"critical_cpu({cpu_load_ratio:.1f})"
elif cpu_load_ratio > 2.0:
new_factor = 0.25
if new_factor < factor:
factor, reason = new_factor, f"high_cpu({cpu_load_ratio:.1f})"
elif cpu_load_ratio > 1.5:
new_factor = 0.50
if new_factor < factor:
factor, reason = new_factor, f"moderate_cpu({cpu_load_ratio:.1f})"
effective = max(1, int(base_limit * factor))
return effective, factor, reason
# ---------------------------------------------------------------------------
# Core sliding-window check (Redis sorted set)
# ---------------------------------------------------------------------------
def _check_and_record(
r: redis.Redis,
user_id: str,
window: int,
effective_limit: int,
) -> dict[str, Any] | None:
"""Atomically check the user's upload count and record the new upload.
Uses a Redis sorted set where each member is a unique timestamp-based ID
and the score is the Unix timestamp. Entries older than *window* seconds
are pruned on every call so the set never grows unbounded.
Returns:
``None`` if the request is allowed, or a ``dict`` with ``count``,
``limit``, and ``retry_after`` if the limit is exceeded.
"""
key = f"{_KEY_PREFIX}:{user_id}"
now = time.time()
window_start = now - window
pipe = r.pipeline(transaction=True)
# 1. Remove entries outside the window
pipe.zremrangebyscore(key, "-inf", window_start)
# 2. Count current entries
pipe.zcard(key)
# 3. Retrieve the oldest entry's score (to compute retry_after)
pipe.zrange(key, 0, 0, withscores=True)
results = pipe.execute()
current_count: int = results[1]
oldest_entries: list = results[2]
if current_count >= effective_limit:
# Compute how long until the oldest entry expires from the window.
if oldest_entries:
oldest_score = oldest_entries[0][1]
retry_after = max(1, int((oldest_score + window) - now))
else:
retry_after = max(1, window // 2)
return {
"count": current_count,
"limit": effective_limit,
"retry_after": retry_after,
}
# 4. Record this upload (unique member = timestamp with random suffix)
member = f"{now}:{os.urandom(4).hex()}"
pipe2 = r.pipeline(transaction=True)
pipe2.zadd(key, {member: now})
pipe2.expire(key, window + 60) # TTL slightly longer than window
pipe2.execute()
return None
# ---------------------------------------------------------------------------
# FastAPI dependency
# ---------------------------------------------------------------------------
async def require_upload_rate_limit(request: Request) -> None:
"""FastAPI dependency that enforces per-user upload rate limits.
The dependency is designed to **fail open**: if Redis is unavailable the
request is allowed through so that uploads are never blocked by a
monitoring outage.
Raises:
HTTPException: 429 Too Many Requests when the per-user upload limit
is exceeded. The ``Retry-After`` header indicates how many
seconds the client should wait before retrying.
"""
r = _get_redis()
if r is None:
# Redis unavailable fail open.
return
# Identify the user (owner_id for multi-user, IP fallback).
user_id = get_current_owner_id(request)
if not user_id:
user_id = f"ip:{request.client.host}" if request.client else "ip:unknown"
base_limit: int = settings.upload_rate_limit_per_user
window: int = settings.upload_rate_limit_window
# Gather health metrics and compute effective limit.
try:
queue_depth = _get_queue_depth(r)
except Exception: # noqa: BLE001
queue_depth = 0
cpu_load_ratio = _get_cpu_load_ratio()
effective_limit, factor, health_reason = compute_effective_limit(base_limit, queue_depth, cpu_load_ratio)
# Sliding-window check.
try:
rejection = _check_and_record(r, user_id, window, effective_limit)
except Exception as exc: # noqa: BLE001
logger.warning("Upload rate-limit check failed (allowing request): %s", exc)
return
if rejection is not None:
retry_after = rejection["retry_after"]
logger.warning(
"Upload rate limit exceeded: user=%s count=%d/%d window=%ds health=%s retry_after=%ds",
user_id,
rejection["count"],
rejection["limit"],
window,
health_reason,
retry_after,
)
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail=(
f"Upload rate limit exceeded ({rejection['count']}/{rejection['limit']} "
f"in {window}s). Retry after {retry_after}s."
),
headers={"Retry-After": str(retry_after)},
)
if factor < 1.0:
logger.info(
"Upload allowed with reduced limit: user=%s effective=%d/%d health=%s",
user_id,
effective_limit,
base_limit,
health_reason,
)
+868
View File
@@ -7,6 +7,7 @@ from app.database import Base
# Foreign key constants
_FILES_ID_FK = "files.id"
_PIPELINES_ID_FK = "pipelines.id"
_ROUTING_RULES_TABLE = "pipeline_routing_rules"
class DocumentMetadata(Base):
@@ -83,6 +84,19 @@ class FileRecord(Base):
# Processing pipeline assigned to this file (NULL = use system default)
pipeline_id = Column(Integer, ForeignKey(_PIPELINES_ID_FK), nullable=True, index=True)
# Detected document language (ISO 639-1 code, e.g. "de", "en", "fr")
# Extracted from AI metadata during processing; cached here for fast access.
detected_language = Column(String(10), nullable=True)
# Default-language translation of the extracted text.
# Stored when the detected language differs from the user's/system default
# document language. Only the original text and this translation are persisted;
# other languages are translated on the fly via the AI provider.
default_language_text = Column(Text, nullable=True)
# ISO 639-1 code of the default-language translation stored above (e.g. "en").
default_language_code = Column(String(10), nullable=True)
# Timestamp when we inserted this record
created_at = Column(DateTime(timezone=True), server_default=func.now(), index=True)
@@ -146,6 +160,27 @@ class SettingsAuditLog(Base):
action = Column(String, nullable=False) # "update" or "delete"
class AuditLog(Base):
"""Comprehensive audit log for compliance tracking.
Records all significant actions: login/logout, document CRUD, settings
changes, and administrative operations. Rows are append-only; the API
and service layer never update or delete entries.
"""
__tablename__ = "audit_logs"
id = Column(Integer, primary_key=True, index=True)
timestamp = Column(DateTime(timezone=True), server_default=func.now(), nullable=False, index=True)
user = Column(String, nullable=False, index=True) # Username or "anonymous" / "system"
action = Column(String, nullable=False, index=True) # e.g. "login", "document.create", "settings.update"
resource_type = Column(String, nullable=True, index=True) # e.g. "document", "user", "settings"
resource_id = Column(String, nullable=True) # ID of the affected resource
ip_address = Column(String, nullable=True) # Client IP address
details = Column(Text, nullable=True) # JSON-encoded extra context
severity = Column(String(16), nullable=False, server_default="info") # info / warning / error / critical
class SavedSearch(Base):
"""User-defined saved search filters for quick access to frequently used filter combinations."""
@@ -176,6 +211,28 @@ class WebhookConfig(Base):
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):
"""A locally-registered user authenticated by email and bcrypt password.
@@ -256,6 +313,23 @@ class UserProfile(Base):
preferred_destination = Column(String(50), nullable=True)
stripe_customer_id = Column(String(64), nullable=True)
# UI language preference for i18n (ISO 639-1 code, e.g. "en", "de", "fr")
# NULL means "auto-detect from browser Accept-Language header"
preferred_language = Column(String(10), nullable=True)
# Default document language for translated versions (ISO 639-1 code).
# When a document's detected language differs from this value, the system
# automatically generates and stores a translation into this language.
# NULL means "use the global DEFAULT_DOCUMENT_LANGUAGE setting".
default_document_language = Column(String(10), nullable=True)
# UI colour scheme preference: "light" | "dark" | "system" (NULL = "system")
preferred_theme = Column(String(10), nullable=True)
# Custom profile avatar stored as a base64 data-URL (e.g. "data:image/png;base64,...")
# NULL means use the Gravatar fallback derived from the user's e-mail address.
avatar_data = Column(Text, 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())
@@ -376,6 +450,103 @@ class PipelineStep(Base):
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
class ImapIngestionProfile(Base):
"""Named ingestion profile controlling which attachment types are accepted from IMAP emails.
Profiles group file-type categories (e.g. "pdf", "office", "images") so users
can precisely control what gets ingested from each mailbox.
System-provided built-in profiles (``is_builtin=True``) are seeded by the
migration and cannot be deleted or renamed. Users may create their own profiles
(``owner_id`` set to their identifier) or rely on the global system profiles
(``owner_id=None``).
``allowed_categories`` stores a JSON list of category strings, e.g.::
'["pdf", "office", "opendocument", "text", "web"]'
Valid category names are defined in ``app.utils.allowed_types.FILE_TYPE_CATEGORIES``.
"""
__tablename__ = "imap_ingestion_profiles"
id = Column(Integer, primary_key=True, index=True)
# Human-readable profile name (e.g. "Documents Only", "Documents + Images")
name = Column(String(255), nullable=False)
# Optional description shown in the UI
description = Column(Text, nullable=True)
# Owner of this profile. NULL = global/system profile available to all users.
owner_id = Column(String, nullable=True, index=True)
# JSON-encoded list of enabled category keys. Example: '["pdf","office","text"]'
# See FILE_TYPE_CATEGORIES in app/utils/allowed_types.py for valid values.
allowed_categories = Column(Text, nullable=False, default='["pdf","office","opendocument","text","web"]')
# Built-in system profiles that cannot be deleted or modified via the API.
is_builtin = Column(Boolean, nullable=False, default=False)
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
class UserImapAccount(Base):
"""Per-user IMAP ingestion account.
Each row represents one IMAP mailbox that a user wants DocuElevate to
poll for document attachments. The periodic ``pull_all_inboxes`` Celery
task iterates over all active accounts and processes any new emails.
Quota enforcement: the user's subscription plan's ``max_mailboxes`` field
controls how many accounts a user may configure (0 = unlimited for paid
plans; free tier is not permitted any accounts).
"""
__tablename__ = "user_imap_accounts"
id = Column(Integer, primary_key=True, index=True)
# Stable owner identifier — matches FileRecord.owner_id
owner_id = Column(String, nullable=False, index=True)
# Human-readable label chosen by the user (e.g. "Work Gmail", "Scanner mailbox")
name = Column(String(255), nullable=False)
# IMAP connection settings
host = Column(String(255), nullable=False)
port = Column(Integer, nullable=False, default=993)
username = Column(String(255), nullable=False)
# Password stored encrypted using Fernet symmetric encryption via
# app.utils.encryption.encrypt_value / decrypt_value (keyed from SESSION_SECRET).
# New records are always encrypted; legacy plaintext records are transparently
# handled by decrypt_value which returns the value unchanged when no "enc:" prefix
# is present.
password = Column(String(1024), nullable=False)
use_ssl = Column(Boolean, nullable=False, default=True)
# Processing options
# When True, emails are deleted from the mailbox after their attachments are processed
delete_after_process = Column(Boolean, nullable=False, default=False)
# Optional reference to an ImapIngestionProfile.
# NULL means "use the global imap_attachment_filter setting" (system default).
profile_id = Column(Integer, ForeignKey("imap_ingestion_profiles.id"), nullable=True)
# When False the account is not polled by the periodic task (but not deleted)
is_active = Column(Boolean, nullable=False, default=True)
# Last time this mailbox was successfully polled
last_checked_at = Column(DateTime(timezone=True), nullable=True)
# Last error message if the most recent poll failed (NULL = last poll succeeded)
last_error = Column(Text, 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())
class BackupRecord(Base):
"""Tracks database backup files and their retention metadata.
@@ -418,3 +589,700 @@ class BackupRecord(Base):
remote_path = Column(String(1024), nullable=True)
created_at = Column(DateTime(timezone=True), server_default=func.now(), index=True)
# ---------------------------------------------------------------------------
# Integration direction / type constants
# ---------------------------------------------------------------------------
class IntegrationDirection:
"""Direction of data flow for a UserIntegration."""
SOURCE = "SOURCE"
DESTINATION = "DESTINATION"
ALL = {SOURCE, DESTINATION}
class IntegrationType:
"""Supported integration types for UserIntegration."""
# Source integrations (ingestion)
IMAP = "IMAP"
WATCH_FOLDER = "WATCH_FOLDER"
WEBHOOK = "WEBHOOK"
# Destination integrations (storage / output)
S3 = "S3"
DROPBOX = "DROPBOX"
GOOGLE_DRIVE = "GOOGLE_DRIVE"
ONEDRIVE = "ONEDRIVE"
WEBDAV = "WEBDAV"
NEXTCLOUD = "NEXTCLOUD"
FTP = "FTP"
SFTP = "SFTP"
EMAIL = "EMAIL"
PAPERLESS = "PAPERLESS"
RCLONE = "RCLONE"
SHAREPOINT = "SHAREPOINT"
ICLOUD = "ICLOUD"
ALL = {
IMAP,
WATCH_FOLDER,
WEBHOOK,
S3,
DROPBOX,
GOOGLE_DRIVE,
ONEDRIVE,
WEBDAV,
NEXTCLOUD,
FTP,
SFTP,
EMAIL,
PAPERLESS,
RCLONE,
SHAREPOINT,
ICLOUD,
}
class UserIntegration(Base):
"""Generic per-user integration record (source or destination).
Replaces ad-hoc per-integration-type tables with a single, extensible
model that supports any combination of ingestion sources and storage
destinations without schema changes when new integrations are added.
``config`` holds non-sensitive connection settings as a JSON string
(e.g. host, port, bucket name, folder path).
``credentials`` holds sensitive secrets (passwords, tokens, API keys)
as a Fernet-encrypted JSON string. Always use
``app.utils.encryption.encrypt_value`` / ``decrypt_value`` when
writing / reading this field.
Example config + credentials shapes by integration type:
IMAP:
config = {"host": "imap.example.com", "port": 993,
"username": "user@example.com", "use_ssl": true,
"delete_after_process": false,
"gmail_apply_labels": true}
credentials = {"password": "secret"}
WATCH_FOLDER (local):
config = {"source_type": "local",
"folder_path": "/data/inbox",
"delete_after_process": false}
WATCH_FOLDER (s3):
config = {"source_type": "s3", "bucket": "my-bucket",
"region": "us-east-1", "prefix": "inbox/",
"endpoint_url": null, "delete_after_process": false}
credentials = {"access_key_id": "AKI…", "secret_access_key": ""}
WATCH_FOLDER (dropbox):
config = {"source_type": "dropbox",
"folder_path": "/Inbox/Scanner",
"delete_after_process": false}
credentials = {"refresh_token": "", "app_key": "",
"app_secret": ""}
WATCH_FOLDER (google_drive):
config = {"source_type": "google_drive",
"folder_id": "1abc…",
"delete_after_process": false}
credentials = {"credentials_json": "{…service-account…}"}
WATCH_FOLDER (onedrive):
config = {"source_type": "onedrive",
"folder_path": "/Documents/Inbox",
"delete_after_process": false}
credentials = {"refresh_token": "", "client_id": "",
"client_secret": ""}
WATCH_FOLDER (nextcloud):
config = {"source_type": "nextcloud",
"url": "https://cloud.example.com",
"folder_path": "/Documents/Inbox",
"delete_after_process": false}
credentials = {"username": "user", "password": "secret"}
WATCH_FOLDER (webdav):
config = {"source_type": "webdav",
"url": "https://webdav.example.com/dav/",
"folder_path": "/remote.php/webdav/Inbox",
"delete_after_process": false}
credentials = {"username": "user", "password": "secret"}
S3:
config = {"bucket": "my-bucket", "region": "us-east-1",
"endpoint_url": null, "folder_prefix": ""}
credentials = {"access_key_id": "AKI…", "secret_access_key": ""}
DROPBOX:
config = {"folder": "/DocuElevate"}
credentials = {"refresh_token": "", "app_key": "",
"app_secret": ""}
GOOGLE_DRIVE:
config = {"folder_id": "1abc…"}
credentials = {"credentials_json": "{…service-account or OAuth…}"}
WEBDAV / NEXTCLOUD:
config = {"url": "https://cloud.example.com/dav/",
"folder": "/Documents"}
credentials = {"username": "user", "password": "secret"}
"""
__tablename__ = "user_integrations"
id = Column(Integer, primary_key=True, index=True)
# Stable owner identifier — matches FileRecord.owner_id / UserImapAccount.owner_id
owner_id = Column(String, nullable=False, index=True)
# "SOURCE" or "DESTINATION" (see IntegrationDirection)
direction = Column(String(20), nullable=False, index=True)
# One of the IntegrationType constants (e.g. "IMAP", "S3", "DROPBOX")
integration_type = Column(String(50), nullable=False, index=True)
# Human-readable label chosen by the user (e.g. "Work Gmail", "S3 Archive")
name = Column(String(255), nullable=False)
# Non-sensitive connection configuration (JSON string)
config = Column(Text, nullable=True)
# Sensitive credentials — always stored encrypted via encrypt_value()
credentials = Column(Text, nullable=True)
# When False the integration is not polled / used by background tasks
is_active = Column(Boolean, nullable=False, default=True)
# Timestamp of the last successful use of this integration
last_used_at = Column(DateTime(timezone=True), nullable=True)
# Last error message if the most recent operation failed (NULL = last op succeeded)
last_error = Column(Text, 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())
class ApiToken(Base):
"""Personal API token for programmatic access.
Users can create multiple tokens, each with a human-readable name.
Only the SHA-256 hash of the token is stored; the plaintext is shown
exactly once at creation time. A short prefix (first 8 chars) is
persisted for easy identification in the UI.
Usage tracking records the timestamp and IP address of the most
recent request that used the token.
"""
__tablename__ = "api_tokens"
id = Column(Integer, primary_key=True, index=True)
# Stable owner identifier — matches FileRecord.owner_id / UserIntegration.owner_id
owner_id = Column(String, nullable=False, index=True)
# Human-readable label chosen by the user (e.g. "CI Pipeline", "Webhook Upload")
name = Column(String(255), nullable=False)
# SHA-256 hex digest of the full token value
token_hash = Column(String(64), nullable=False, unique=True, index=True)
# First 12 characters of the token for display (e.g. "de_Ab3xY7kL…")
token_prefix = Column(String(16), nullable=False)
# Usage tracking
last_used_at = Column(DateTime(timezone=True), nullable=True)
last_used_ip = Column(String(45), nullable=True) # IPv6 max length
is_active = Column(Boolean, nullable=False, default=True)
created_at = Column(DateTime(timezone=True), server_default=func.now())
revoked_at = Column(DateTime(timezone=True), nullable=True)
# Optional expiry: if set, the token is rejected after this timestamp.
expires_at = Column(DateTime(timezone=True), nullable=True)
class SharedLink(Base):
"""Shareable, time-limited or view-limited document link.
A ``SharedLink`` grants unauthenticated access to one ``FileRecord``
via a cryptographically random URL token. The link may optionally
expire after a given datetime, be limited to a fixed number of views,
and require a password. Only a PBKDF2-HMAC-SHA256 hash of the
password is stored.
Owners can view and revoke their active links from the management UI.
"""
__tablename__ = "shared_links"
id = Column(Integer, primary_key=True, index=True)
# Unique URL-safe token — forms the public /share/<token> URL.
token = Column(String(64), nullable=False, unique=True, index=True)
# File this link grants access to.
file_id = Column(Integer, ForeignKey(_FILES_ID_FK), nullable=False, index=True)
# Owner who created the link (matches FileRecord.owner_id).
owner_id = Column(String, nullable=False, index=True)
# Optional human-readable description chosen by the creator.
label = Column(String(255), nullable=True)
# Time-based expiration (NULL = never expires).
expires_at = Column(DateTime(timezone=True), nullable=True)
# View-count limit (NULL = unlimited).
max_views = Column(Integer, nullable=True)
# Cumulative view count (incremented on every successful access).
view_count = Column(Integer, nullable=False, default=0)
# Optional password protection — stores PBKDF2-HMAC-SHA256 hex digest.
password_hash = Column(String(128), nullable=True)
# Whether the link is still valid (set to False to revoke immediately).
is_active = Column(Boolean, nullable=False, default=True)
created_at = Column(DateTime(timezone=True), server_default=func.now())
revoked_at = Column(DateTime(timezone=True), nullable=True)
class UserNotificationTarget(Base):
"""Per-user notification target (email or webhook channel)."""
__tablename__ = "user_notification_targets"
id = Column(Integer, primary_key=True, index=True)
owner_id = Column(String, nullable=False, index=True)
channel_type = Column(String(20), nullable=False) # "email" or "webhook"
name = Column(String(255), nullable=False) # Human-readable label
config = Column(Text, nullable=True) # JSON: smtp config or webhook url
is_active = 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())
class UserNotificationPreference(Base):
"""Mapping: which user events trigger which notification channel."""
__tablename__ = "user_notification_preferences"
id = Column(Integer, primary_key=True, index=True)
owner_id = Column(String, nullable=False, index=True)
event_type = Column(String(50), nullable=False) # "document.processed", "document.failed"
channel_type = Column(String(20), nullable=False) # "in_app", "email", "webhook"
target_id = Column(Integer, nullable=True) # NULL = in_app, else UserNotificationTarget.id
is_enabled = Column(Boolean, nullable=False, default=True)
__table_args__ = (UniqueConstraint("owner_id", "event_type", "channel_type", "target_id"),)
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
class InAppNotification(Base):
"""In-app notification record for the bell icon / inbox."""
__tablename__ = "in_app_notifications"
id = Column(Integer, primary_key=True, index=True)
owner_id = Column(String, nullable=False, index=True)
event_type = Column(String(50), nullable=False) # "document.processed", "document.failed"
title = Column(String(255), nullable=False)
message = Column(Text, nullable=True)
is_read = Column(Boolean, nullable=False, default=False, index=True)
file_id = Column(Integer, nullable=True) # Optional link to FileRecord
created_at = Column(DateTime(timezone=True), server_default=func.now(), index=True)
class ScheduledJob(Base):
"""
Configuration record for an admin-managed scheduled batch processing job.
Each row represents one recurring job entry. The Celery Beat schedule is
built from these rows at worker startup; changes take effect after the
worker process is restarted.
Schedule types
--------------
- ``"cron"`` standard cron expression fields (minute/hour/)
- ``"interval"`` fixed interval in seconds (e.g. 3600 for hourly)
"""
__tablename__ = "scheduled_jobs"
id = Column(Integer, primary_key=True, index=True)
# Unique machine-readable key used as the Celery Beat schedule entry name.
name = Column(String(100), unique=True, nullable=False, index=True)
# Human-readable display name shown in the admin UI.
display_name = Column(String(255), nullable=False)
# Short description of what the job does.
description = Column(Text, nullable=True)
# Fully-qualified Celery task name, e.g. "app.tasks.batch_tasks.process_new_documents".
task_name = Column(String(255), nullable=False)
# Whether the job is active. Inactive jobs are excluded from the beat schedule.
enabled = Column(Boolean, nullable=False, default=True)
# Schedule type: "cron" or "interval".
schedule_type = Column(String(20), nullable=False, default="cron")
# --- Cron fields (used when schedule_type == "cron") ---
cron_minute = Column(String(50), nullable=False, default="0")
cron_hour = Column(String(50), nullable=False, default="*")
cron_day_of_week = Column(String(50), nullable=False, default="*")
cron_day_of_month = Column(String(50), nullable=False, default="*")
cron_month_of_year = Column(String(50), nullable=False, default="*")
# --- Interval field (used when schedule_type == "interval") ---
# Interval in seconds; e.g. 3600 = hourly, 86400 = daily.
interval_seconds = Column(Integer, nullable=True)
# Timestamps populated by the worker after each execution.
last_run_at = Column(DateTime(timezone=True), nullable=True)
last_run_status = Column(String(20), nullable=True) # "success", "failed", "running"
last_run_detail = Column(Text, nullable=True) # Brief result summary or error
created_at = Column(DateTime(timezone=True), server_default=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):
"""Registered mobile device for push notifications.
Stores the push token (Expo push token, FCM token, or APNs token) for a
specific user device so that document-processing events can be forwarded
as push notifications to the native mobile app.
"""
__tablename__ = "mobile_devices"
id = Column(Integer, primary_key=True, index=True)
# User that owns this device registration.
owner_id = Column(String, nullable=False, index=True)
# Human-readable name the user gave this device (e.g. "John's iPhone").
device_name = Column(String(255), nullable=True)
# Platform: "ios", "android", or "web".
platform = Column(String(20), nullable=False, default="ios")
# Expo push token (ExponentPushToken[…]) or raw FCM/APNs token.
push_token = Column(String(512), nullable=False)
# Whether push notifications are enabled for this device.
is_active = Column(Boolean, nullable=False, default=True)
# Timestamps.
created_at = Column(DateTime(timezone=True), server_default=func.now())
last_seen_at = Column(DateTime(timezone=True), nullable=True)
__table_args__ = (UniqueConstraint("owner_id", "push_token", name="uq_mobile_device_owner_token"),)
class UserSession(Base):
"""Server-side session tracking for invalidation and device management.
Each row represents an active browser or app session. The ``session_token``
is stored in the user's cookie and validated on every authenticated request.
Revoking a row (``is_revoked=True``) immediately terminates that session
on the next request.
"""
__tablename__ = "user_sessions"
id = Column(Integer, primary_key=True, index=True)
# Cryptographically random token stored in the session cookie.
session_token = Column(String(128), unique=True, nullable=False, index=True)
# Stable owner identifier — matches FileRecord.owner_id.
user_id = Column(String, nullable=False, index=True)
# Client metadata for display in the session management UI.
ip_address = Column(String(45), nullable=True)
user_agent = Column(String(512), nullable=True)
device_info = Column(String(255), nullable=True)
is_revoked = Column(Boolean, nullable=False, default=False)
created_at = Column(DateTime(timezone=True), server_default=func.now())
last_active_at = Column(DateTime(timezone=True), server_default=func.now())
expires_at = Column(DateTime(timezone=True), nullable=False)
revoked_at = Column(DateTime(timezone=True), nullable=True)
class QRLoginChallenge(Base):
"""Time-limited QR code login challenge for mobile app authentication.
A logged-in web user generates a challenge that produces a QR code. The
mobile app scans the QR code and calls the claim endpoint with the
``challenge_token``. The server verifies the challenge is still valid,
unclaimed, and unexpired, then issues an API token for the mobile app.
Security properties:
* Time-bound (default 2 minutes).
* Single-use (``is_claimed`` prevents replay).
* Cryptographically random 64-byte token.
* Bound to the creating user only that user's mobile device receives a
token.
"""
__tablename__ = "qr_login_challenges"
id = Column(Integer, primary_key=True, index=True)
# Cryptographically random token encoded in the QR code.
challenge_token = Column(String(128), unique=True, nullable=False, index=True)
# The user who created this challenge (from the web session).
user_id = Column(String, nullable=False, index=True)
# Whether the challenge has been successfully claimed by a mobile app.
is_claimed = Column(Boolean, nullable=False, default=False)
# Whether the challenge has been explicitly cancelled or expired.
is_cancelled = Column(Boolean, nullable=False, default=False)
# IP address of the web client that created the challenge.
created_by_ip = Column(String(45), nullable=True)
# IP address of the mobile client that claimed the challenge.
claimed_by_ip = Column(String(45), nullable=True)
# Device name provided by the mobile app when claiming.
device_name = Column(String(255), nullable=True)
# The API token ID that was issued to the mobile app (for audit trail).
issued_token_id = Column(Integer, nullable=True)
created_at = Column(DateTime(timezone=True), server_default=func.now())
expires_at = Column(DateTime(timezone=True), nullable=False)
claimed_at = Column(DateTime(timezone=True), nullable=True)
class ComplianceTemplate(Base):
"""Pre-built compliance configuration templates (GDPR, HIPAA, SOC2).
Each row represents an applied compliance template. The ``settings_json``
column stores the concrete setting key/value pairs that were written when
the template was applied. ``status`` tracks the current compliance posture.
"""
__tablename__ = "compliance_templates"
id = Column(Integer, primary_key=True, index=True)
name = Column(String(50), unique=True, nullable=False, index=True) # GDPR, HIPAA, SOC2
display_name = Column(String(100), nullable=False)
description = Column(Text, nullable=True)
settings_json = Column(Text, nullable=False, default="{}") # JSON of applied settings
enabled = Column(Boolean, nullable=False, default=False)
status = Column(String(20), nullable=False, default="not_applied") # not_applied, compliant, partial, non_compliant
applied_at = Column(DateTime(timezone=True), nullable=True)
applied_by = Column(String(255), 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())
class PipelineRoutingRule(Base):
"""Conditional routing rule that assigns documents to pipelines.
Rules are evaluated in ascending ``position`` order for a given owner.
The first rule whose condition matches the document properties wins and
the document is routed to ``target_pipeline_id``. If no rule matches,
the caller falls back to the owner's (or system) default pipeline.
Supported fields:
file_type, document_type, category, filename, size, and any key
inside the AI-extracted metadata JSON (prefixed ``metadata.``).
Supported operators:
equals, not_equals, contains, not_contains, regex, gt, lt, gte, lte.
"""
__tablename__ = _ROUTING_RULES_TABLE
id = Column(Integer, primary_key=True, index=True)
# Owner of this rule. NULL = system-wide rule (admin only).
owner_id = Column(String, nullable=True, index=True)
# Human-readable label for the rule.
name = Column(String(255), nullable=False)
# Evaluation order (lower = earlier). First matching rule wins.
position = Column(Integer, nullable=False, default=0)
# The document property to evaluate.
# Built-in: file_type, document_type, category, filename, size.
# For AI metadata fields, use the "metadata.<key>" prefix.
field = Column(String(255), nullable=False)
# Comparison operator.
operator = Column(String(50), nullable=False)
# Value to compare against (always stored as text; cast as needed).
value = Column(String(1024), nullable=False)
# Target pipeline when the condition matches.
target_pipeline_id = Column(Integer, ForeignKey(_PIPELINES_ID_FK), nullable=False, index=True)
# Soft-disable without deleting.
is_active = 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())
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"),)
+44
View File
@@ -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")
+669
View File
@@ -0,0 +1,669 @@
"""
Scheduled batch processing tasks for DocuElevate.
This module provides Celery tasks that can be scheduled via Celery Beat
and managed through the admin UI (``/admin/scheduled-jobs``):
Core batch jobs
---------------
- ``process_new_documents`` Queue documents that have never been processed.
- ``reprocess_failed_documents`` Re-queue documents whose processing failed.
- ``cleanup_temp_files`` Remove stale files from the ``workdir/tmp`` directory.
Maintenance / housekeeping jobs
--------------------------------
- ``expire_shared_links`` Auto-revoke SharedLinks whose ``expires_at`` has passed.
- ``prune_processing_logs`` Delete old rows from ``processing_logs`` and
``settings_audit_log`` to prevent unbounded table growth.
- ``prune_old_notifications`` Delete old read ``in_app_notifications`` rows.
- ``backfill_missing_metadata`` Re-trigger AI metadata extraction for completed files
that have OCR text but no ``ai_metadata``.
- ``sync_search_index`` Index documents in Meilisearch that have OCR text /
metadata but are not yet in the search index.
Each task records its execution result back to the ``ScheduledJob`` table so
the admin UI can display last-run times and statuses.
"""
import json
import logging
import os
from datetime import datetime, timedelta, timezone
from pathlib import Path
from app.celery_app import celery
from app.config import settings
from app.database import SessionLocal
from app.models import (
FileProcessingStep,
FileRecord,
InAppNotification,
ProcessingLog,
ScheduledJob,
SettingsAuditLog,
SharedLink,
)
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Internal helpers
# ---------------------------------------------------------------------------
_PROCESSING_STEPS = {
"create_file_record",
"check_text",
"extract_text",
"process_with_ocr",
"process_with_azure_document_intelligence",
"extract_metadata_with_gpt",
"embed_metadata_into_pdf",
"finalize_document_storage",
"send_to_all_destinations",
}
def _update_job_status(job_name: str, status: str, detail: str) -> None:
"""Persist run status back to the ScheduledJob row for display in the UI."""
try:
with SessionLocal() as db:
job = db.query(ScheduledJob).filter(ScheduledJob.name == job_name).first()
if job:
job.last_run_at = datetime.now(timezone.utc)
job.last_run_status = status
job.last_run_detail = detail
db.commit()
except Exception as exc: # pragma: no cover best-effort status update
logger.warning("Could not update ScheduledJob status for %s: %s", job_name, exc)
# ---------------------------------------------------------------------------
# Task: process new (unprocessed) documents
# ---------------------------------------------------------------------------
@celery.task(name="app.tasks.batch_tasks.process_new_documents")
def process_new_documents() -> dict:
"""
Queue all documents that have never been processed.
A document is considered *new* when it has no ``FileProcessingStep`` rows
that match the core pipeline steps. The task loads each qualifying
``FileRecord``, verifies that the original file still exists on disk, and
dispatches ``process_document`` for each one.
Returns a summary dict with ``queued`` and ``skipped`` counts.
"""
from app.tasks.process_document import process_document # avoid circular import
job_name = "process-new-documents"
logger.info("[batch] Starting process_new_documents task")
try:
with SessionLocal() as db:
# Files that already have at least one processing step recorded.
processed_file_ids = (
db.query(FileProcessingStep.file_id)
.filter(FileProcessingStep.step_name.in_(_PROCESSING_STEPS))
.distinct()
.subquery()
)
# Candidate files: non-duplicate records with no processing steps yet.
candidates = (
db.query(FileRecord)
.filter(FileRecord.is_duplicate.is_(False))
.filter(~FileRecord.id.in_(db.query(processed_file_ids.c.file_id)))
.all()
)
queued = 0
skipped = 0
for record in candidates:
if not record.local_filename or not os.path.exists(record.local_filename):
logger.warning(
"[batch] Skipping file_id=%s — local file not found: %s",
record.id,
record.local_filename,
)
skipped += 1
continue
process_document.delay(
record.local_filename,
original_filename=record.original_filename,
file_id=record.id,
owner_id=record.owner_id,
)
queued += 1
detail = f"Queued {queued} document(s) for processing; skipped {skipped} (file not on disk)."
logger.info("[batch] process_new_documents: %s", detail)
_update_job_status(job_name, "success", detail)
return {"queued": queued, "skipped": skipped}
except Exception as exc:
detail = f"Error: {exc}"
logger.error("[batch] process_new_documents failed: %s", exc, exc_info=True)
_update_job_status(job_name, "failed", detail)
return {"queued": 0, "skipped": 0, "error": str(exc)}
# ---------------------------------------------------------------------------
# Task: reprocess failed documents
# ---------------------------------------------------------------------------
@celery.task(name="app.tasks.batch_tasks.reprocess_failed_documents")
def reprocess_failed_documents() -> dict:
"""
Re-queue documents whose most-recent processing attempt failed.
Only files that have at least one ``FileProcessingStep`` with
``status == "failure"`` **and** no currently ``in_progress`` steps are
selected so that actively-running jobs are not interrupted.
Returns a summary dict with ``queued`` and ``skipped`` counts.
"""
from app.tasks.process_document import process_document # avoid circular import
job_name = "reprocess-failed-documents"
logger.info("[batch] Starting reprocess_failed_documents task")
try:
with SessionLocal() as db:
# Files with at least one failed step.
failed_file_ids = (
db.query(FileProcessingStep.file_id)
.filter(FileProcessingStep.step_name.in_(_PROCESSING_STEPS))
.filter(FileProcessingStep.status == "failure")
.distinct()
.subquery()
)
# Exclude files that are currently being processed.
in_progress_file_ids = (
db.query(FileProcessingStep.file_id)
.filter(FileProcessingStep.status == "in_progress")
.distinct()
.subquery()
)
candidates = (
db.query(FileRecord)
.filter(FileRecord.is_duplicate.is_(False))
.filter(FileRecord.id.in_(db.query(failed_file_ids.c.file_id)))
.filter(~FileRecord.id.in_(db.query(in_progress_file_ids.c.file_id)))
.all()
)
queued = 0
skipped = 0
for record in candidates:
if not record.local_filename or not os.path.exists(record.local_filename):
logger.warning(
"[batch] Skipping file_id=%s — local file not found: %s",
record.id,
record.local_filename,
)
skipped += 1
continue
process_document.delay(
record.local_filename,
original_filename=record.original_filename,
file_id=record.id,
owner_id=record.owner_id,
)
queued += 1
detail = f"Re-queued {queued} failed document(s); skipped {skipped} (file not on disk)."
logger.info("[batch] reprocess_failed_documents: %s", detail)
_update_job_status(job_name, "success", detail)
return {"queued": queued, "skipped": skipped}
except Exception as exc:
detail = f"Error: {exc}"
logger.error("[batch] reprocess_failed_documents failed: %s", exc, exc_info=True)
_update_job_status(job_name, "failed", detail)
return {"queued": 0, "skipped": 0, "error": str(exc)}
# ---------------------------------------------------------------------------
# Task: clean up temporary files
# ---------------------------------------------------------------------------
#: Files in ``workdir/tmp`` that are older than this threshold are deleted.
_TEMP_FILE_MAX_AGE_HOURS: int = 24
@celery.task(name="app.tasks.batch_tasks.cleanup_temp_files")
def cleanup_temp_files(max_age_hours: int = _TEMP_FILE_MAX_AGE_HOURS) -> dict:
"""
Delete stale files from the ``workdir/tmp`` directory.
A file is considered stale when **both** of the following are true:
1. Its modification time is older than *max_age_hours* (default 24 h).
2. No ``FileRecord.local_filename`` points to it **or** the file is not
referenced by any active in-progress processing step.
This prevents accidental deletion of files that are being actively
processed by the pipeline.
Args:
max_age_hours: Minimum age (in hours) before a temp file is eligible
for deletion. Defaults to 24.
Returns:
A summary dict with ``deleted`` and ``skipped`` counts.
"""
job_name = "cleanup-temp-files"
logger.info("[batch] Starting cleanup_temp_files (max_age_hours=%s)", max_age_hours)
tmp_dir = Path(settings.workdir) / "tmp"
if not tmp_dir.exists():
detail = "workdir/tmp does not exist; nothing to clean."
logger.info("[batch] cleanup_temp_files: %s", detail)
_update_job_status(job_name, "success", detail)
return {"deleted": 0, "skipped": 0}
cutoff = datetime.now(timezone.utc) - timedelta(hours=max_age_hours)
deleted = 0
skipped = 0
errors = 0
try:
with SessionLocal() as db:
# Collect filenames actively referenced by in-progress processing steps.
in_progress_filenames: set[str] = set()
in_progress_records = (
db.query(FileRecord.local_filename)
.join(FileProcessingStep, FileProcessingStep.file_id == FileRecord.id)
.filter(FileProcessingStep.status == "in_progress")
.distinct()
.all()
)
for row in in_progress_records:
if row.local_filename:
in_progress_filenames.add(os.path.basename(row.local_filename))
# Also collect all filenames referenced by FileRecord.local_filename
# that point into workdir/tmp (files still in the tmp pipeline).
active_tmp_filenames: set[str] = set()
tmp_dir_str = str(tmp_dir.resolve())
active_records = (
db.query(FileRecord.local_filename).filter(FileRecord.local_filename.like(f"{tmp_dir_str}%")).all()
)
for row in active_records:
if row.local_filename:
active_tmp_filenames.add(os.path.basename(row.local_filename))
protected_basenames = in_progress_filenames | active_tmp_filenames
for entry in tmp_dir.iterdir():
if not entry.is_file():
continue
# Check modification time.
try:
mtime = datetime.fromtimestamp(entry.stat().st_mtime, tz=timezone.utc)
except OSError: # pragma: no cover only reachable if file vanishes between iterdir() and stat()
skipped += 1
continue
if mtime >= cutoff:
skipped += 1
continue
if entry.name in protected_basenames:
logger.debug("[batch] cleanup_temp_files: keeping protected file %s", entry.name)
skipped += 1
continue
try:
entry.unlink()
logger.debug("[batch] cleanup_temp_files: deleted %s", entry)
deleted += 1
except OSError as exc:
logger.warning("[batch] cleanup_temp_files: could not delete %s: %s", entry, exc)
errors += 1
detail = f"Deleted {deleted} stale temp file(s); skipped {skipped} (too new or protected); {errors} error(s)."
status = "failed" if errors and not deleted else "success"
logger.info("[batch] cleanup_temp_files: %s", detail)
_update_job_status(job_name, status, detail)
return {"deleted": deleted, "skipped": skipped, "errors": errors}
except Exception as exc:
detail = f"Error: {exc}"
logger.error("[batch] cleanup_temp_files failed: %s", exc, exc_info=True)
_update_job_status(job_name, "failed", detail)
return {"deleted": 0, "skipped": 0, "errors": 1, "error": str(exc)}
# ---------------------------------------------------------------------------
# Task: expire stale shared links
# ---------------------------------------------------------------------------
@celery.task(name="app.tasks.batch_tasks.expire_shared_links")
def expire_shared_links() -> dict:
"""
Auto-revoke SharedLinks whose ``expires_at`` timestamp has passed.
The ``_is_link_valid`` helper in the shared-links API already blocks
access at request time, but the database rows remain flagged as
``is_active=True``. This task sweeps those rows and sets
``is_active=False`` + ``revoked_at`` so the management UI reflects
the true state and counts are accurate.
Returns a summary dict with ``revoked`` count.
"""
job_name = "expire-shared-links"
logger.info("[batch] Starting expire_shared_links task")
try:
now = datetime.now(timezone.utc)
with SessionLocal() as db:
stale = (
db.query(SharedLink)
.filter(
SharedLink.is_active.is_(True),
SharedLink.expires_at.isnot(None),
SharedLink.expires_at < now,
)
.all()
)
for link in stale:
link.is_active = False
link.revoked_at = now
db.commit()
revoked = len(stale)
detail = f"Revoked {revoked} expired shared link(s)."
logger.info("[batch] expire_shared_links: %s", detail)
_update_job_status(job_name, "success", detail)
return {"revoked": revoked}
except Exception as exc:
detail = f"Error: {exc}"
logger.error("[batch] expire_shared_links failed: %s", exc, exc_info=True)
_update_job_status(job_name, "failed", detail)
return {"revoked": 0, "error": str(exc)}
# ---------------------------------------------------------------------------
# Task: prune old processing logs
# ---------------------------------------------------------------------------
#: Default retention period for processing logs and audit log rows.
_LOG_RETENTION_DAYS: int = 30
@celery.task(name="app.tasks.batch_tasks.prune_processing_logs")
def prune_processing_logs(retention_days: int = _LOG_RETENTION_DAYS) -> dict:
"""
Delete ``processing_logs`` and ``settings_audit_log`` rows older than
*retention_days* (default 30) to prevent unbounded table growth.
Rows for the most recent *retention_days* days are kept so that recent
activity is still visible in the logs/audit UI.
Args:
retention_days: Number of days of history to keep (default 30).
Returns:
A summary dict with ``processing_logs_deleted`` and
``audit_log_deleted`` counts.
"""
job_name = "prune-processing-logs"
logger.info("[batch] Starting prune_processing_logs (retention_days=%s)", retention_days)
cutoff = datetime.now(timezone.utc) - timedelta(days=retention_days)
try:
with SessionLocal() as db:
pl_deleted = db.query(ProcessingLog).filter(ProcessingLog.timestamp < cutoff).delete()
al_deleted = db.query(SettingsAuditLog).filter(SettingsAuditLog.changed_at < cutoff).delete()
db.commit()
detail = (
f"Deleted {pl_deleted} processing log row(s) and "
f"{al_deleted} settings audit log row(s) older than {retention_days} days."
)
logger.info("[batch] prune_processing_logs: %s", detail)
_update_job_status(job_name, "success", detail)
return {"processing_logs_deleted": pl_deleted, "audit_log_deleted": al_deleted}
except Exception as exc:
detail = f"Error: {exc}"
logger.error("[batch] prune_processing_logs failed: %s", exc, exc_info=True)
_update_job_status(job_name, "failed", detail)
return {"processing_logs_deleted": 0, "audit_log_deleted": 0, "error": str(exc)}
# ---------------------------------------------------------------------------
# Task: prune old in-app notifications
# ---------------------------------------------------------------------------
#: Default retention period for read notifications.
_NOTIFICATION_RETENTION_DAYS: int = 30
@celery.task(name="app.tasks.batch_tasks.prune_old_notifications")
def prune_old_notifications(retention_days: int = _NOTIFICATION_RETENTION_DAYS) -> dict:
"""
Delete ``in_app_notifications`` rows that are already read and older than
*retention_days* days (default 30) to prevent unbounded table growth.
Unread notifications are always kept regardless of age so users do not
miss important alerts.
Args:
retention_days: Number of days of read-notification history to keep
(default 30).
Returns:
A summary dict with ``deleted`` count.
"""
job_name = "prune-old-notifications"
logger.info("[batch] Starting prune_old_notifications (retention_days=%s)", retention_days)
cutoff = datetime.now(timezone.utc) - timedelta(days=retention_days)
try:
with SessionLocal() as db:
deleted = (
db.query(InAppNotification)
.filter(
InAppNotification.is_read.is_(True),
InAppNotification.created_at < cutoff,
)
.delete()
)
db.commit()
detail = f"Deleted {deleted} old read notification(s) older than {retention_days} days."
logger.info("[batch] prune_old_notifications: %s", detail)
_update_job_status(job_name, "success", detail)
return {"deleted": deleted}
except Exception as exc:
detail = f"Error: {exc}"
logger.error("[batch] prune_old_notifications failed: %s", exc, exc_info=True)
_update_job_status(job_name, "failed", detail)
return {"deleted": 0, "error": str(exc)}
# ---------------------------------------------------------------------------
# Task: backfill missing AI metadata
# ---------------------------------------------------------------------------
#: Maximum number of files to process per backfill run.
_METADATA_BACKFILL_BATCH_SIZE: int = 50
@celery.task(name="app.tasks.batch_tasks.backfill_missing_metadata")
def backfill_missing_metadata(batch_size: int = _METADATA_BACKFILL_BATCH_SIZE) -> dict:
"""
Re-trigger AI metadata extraction for documents that have OCR text but
no ``ai_metadata``.
This handles the common case where a document was processed before the AI
metadata extraction step was configured (e.g., before an OpenAI API key
was added), or where the extraction previously failed.
Only files that are **not** currently in-progress and have non-empty
``ocr_text`` are selected. A configurable *batch_size* caps the number
of tasks queued per run to avoid overwhelming the AI provider.
Args:
batch_size: Maximum number of files to queue per run (default 50).
Returns:
A summary dict with ``queued`` count.
"""
from app.tasks.extract_metadata_with_gpt import extract_metadata_with_gpt # avoid circular import
job_name = "backfill-missing-metadata"
logger.info("[batch] Starting backfill_missing_metadata (batch_size=%s)", batch_size)
try:
with SessionLocal() as db:
# Files currently being processed — skip them.
in_progress_file_ids = (
db.query(FileProcessingStep.file_id)
.filter(FileProcessingStep.status == "in_progress")
.distinct()
.subquery()
)
candidates = (
db.query(FileRecord)
.filter(FileRecord.is_duplicate.is_(False))
.filter(FileRecord.ocr_text.isnot(None))
.filter(FileRecord.ocr_text != "")
.filter((FileRecord.ai_metadata.is_(None)) | (FileRecord.ai_metadata == ""))
.filter(~FileRecord.id.in_(db.query(in_progress_file_ids.c.file_id)))
.limit(batch_size)
.all()
)
queued = 0
for record in candidates:
filename = record.local_filename or record.original_filename or f"file_{record.id}"
extract_metadata_with_gpt.delay(
filename,
record.ocr_text,
file_id=record.id,
)
queued += 1
detail = f"Queued {queued} document(s) for AI metadata backfill."
logger.info("[batch] backfill_missing_metadata: %s", detail)
_update_job_status(job_name, "success", detail)
return {"queued": queued}
except Exception as exc:
detail = f"Error: {exc}"
logger.error("[batch] backfill_missing_metadata failed: %s", exc, exc_info=True)
_update_job_status(job_name, "failed", detail)
return {"queued": 0, "error": str(exc)}
# ---------------------------------------------------------------------------
# Task: sync Meilisearch search index
# ---------------------------------------------------------------------------
#: Maximum documents to index per sync run.
_SEARCH_SYNC_BATCH_SIZE: int = 100
@celery.task(name="app.tasks.batch_tasks.sync_search_index")
def sync_search_index(batch_size: int = _SEARCH_SYNC_BATCH_SIZE) -> dict:
"""
Index documents in Meilisearch that have OCR text or AI metadata but are
not yet present in the search index.
This is useful after:
- Enabling Meilisearch for the first time on an existing installation.
- Recovering from a Meilisearch index wipe or migration.
- Documents processed before search indexing was added to the pipeline.
The task queries the Meilisearch index for existing document IDs, then
finds ``FileRecord`` rows that have processable content (``ocr_text`` or
``ai_metadata``) but are absent from the index, and re-indexes them.
A configurable *batch_size* caps the number of documents indexed per run.
Args:
batch_size: Maximum number of documents to index per run (default 100).
Returns:
A summary dict with ``indexed`` and ``skipped`` counts.
"""
from app.utils.meilisearch_client import get_meilisearch_client, index_document
job_name = "sync-search-index"
logger.info("[batch] Starting sync_search_index (batch_size=%s)", batch_size)
client = get_meilisearch_client()
if client is None:
detail = "Meilisearch is not configured; skipping search index sync."
logger.info("[batch] sync_search_index: %s", detail)
_update_job_status(job_name, "success", detail)
return {"indexed": 0, "skipped": 0, "reason": "meilisearch_not_configured"}
try:
# Fetch the set of file_ids already in the Meilisearch index.
index = client.get_index(settings.meilisearch_index_name)
# Fetch up to 10 000 IDs — sufficient to determine gaps for most installs.
existing_result = index.get_documents({"fields": ["file_id"], "limit": 10000})
existing_ids: set[int] = {doc["file_id"] for doc in existing_result.results if "file_id" in doc}
except Exception as exc:
detail = f"Error fetching existing Meilisearch IDs: {exc}"
logger.error("[batch] sync_search_index: %s", detail)
_update_job_status(job_name, "failed", detail)
return {"indexed": 0, "skipped": 0, "error": str(exc)}
try:
with SessionLocal() as db:
# Files with indexable content that are not already in the index.
candidates = (
db.query(FileRecord)
.filter(FileRecord.is_duplicate.is_(False))
.filter(
(FileRecord.ocr_text.isnot(None) & (FileRecord.ocr_text != ""))
| (FileRecord.ai_metadata.isnot(None) & (FileRecord.ai_metadata != ""))
)
.filter(~FileRecord.id.in_(existing_ids) if existing_ids else True) # type: ignore[arg-type]
.limit(batch_size)
.all()
)
indexed = 0
skipped = 0
for record in candidates:
metadata: dict = {}
if record.ai_metadata:
try:
metadata = json.loads(record.ai_metadata)
except (json.JSONDecodeError, ValueError):
pass
success = index_document(record, record.ocr_text or "", metadata)
if success:
indexed += 1
else:
skipped += 1
detail = f"Indexed {indexed} document(s) into Meilisearch; {skipped} skipped (indexing error)."
logger.info("[batch] sync_search_index: %s", detail)
_update_job_status(job_name, "success", detail)
return {"indexed": indexed, "skipped": skipped}
except Exception as exc:
detail = f"Error: {exc}"
logger.error("[batch] sync_search_index failed: %s", exc, exc_info=True)
_update_job_status(job_name, "failed", detail)
return {"indexed": 0, "skipped": 0, "error": str(exc)}
+174
View File
@@ -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
+1 -1
View File
@@ -205,7 +205,7 @@ def convert_to_pdf(
".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"}
+1
View File
@@ -78,6 +78,7 @@ def _convert_pdf_to_pdfa(input_path: str, output_path: str, pdfa_format: str = "
output_type,
"--quiet",
"--invalidate-digital-signatures",
"--",
input_path,
output_path,
]
+24
View File
@@ -216,6 +216,30 @@ def embed_metadata_into_pdf(self, local_file_path: str, extracted_text: str, met
except Exception as search_exc:
logger.warning(f"[{task_id}] Meilisearch indexing failed (non-fatal): {search_exc}")
# Cache the detected language on the FileRecord and trigger
# default-language translation when the document is in a
# different language.
detected_lang = metadata.get("language") if metadata else None
if detected_lang and extracted_text:
try:
file_record.detected_language = detected_lang
db.commit()
from app.tasks.translate_to_default_language import translate_to_default_language
translate_to_default_language.delay(
file_id,
extracted_text,
detected_lang,
owner_id=file_record.owner_id,
)
logger.info(
f"[{task_id}] Queued default-language translation for file {file_id} "
f"(detected: {detected_lang})"
)
except Exception as trans_exc:
logger.warning(f"[{task_id}] Could not queue translation task (non-fatal): {trans_exc}")
# Persist the metadata into a JSON file with the same base name.
# Include file path references for traceability
logger.info(f"[{task_id}] Persisting metadata to JSON")
+192 -191
View File
@@ -1,191 +1,192 @@
#!/usr/bin/env python3
import json
import logging
import os
import re
# Import the shared Celery instance
from app.celery_app import celery
from app.config import settings
from app.database import SessionLocal
from app.models import FileRecord
from app.tasks.embed_metadata_into_pdf import embed_metadata_into_pdf
from app.tasks.retry_config import BaseTaskWithRetry
from app.utils import log_task_progress
from app.utils.ai_provider import get_ai_provider
logger = logging.getLogger(__name__)
def extract_json_from_text(text):
"""
Try to extract a JSON object from the text.
- First, check for a JSON block inside triple backticks.
- If not found, try to extract text from the first '{' to the last '}'.
"""
pattern = r"```(?:json)?\s*(\{.*?\})\s*```"
match = re.search(pattern, text, re.DOTALL)
if match:
return match.group(1)
else:
start = text.find("{")
end = text.rfind("}")
if start != -1 and end != -1 and end > start:
return text[start : end + 1]
return None
@celery.task(base=BaseTaskWithRetry, bind=True)
def extract_metadata_with_gpt(self, filename: str, cleaned_text: str, file_id: int = None):
"""
Uses OpenAI to classify document metadata.
Args:
filename: Can be either a basename (e.g., "file.pdf") or a full path (e.g., "/workdir/processed/file.pdf")
cleaned_text: The extracted text from the document
file_id: Optional file ID for tracking
"""
task_id = self.request.id
logger.info(f"[{task_id}] Starting metadata extraction for: {filename}")
log_task_progress(
task_id,
"extract_metadata_with_gpt",
"in_progress",
f"Extracting metadata for {os.path.basename(filename)}",
file_id=file_id,
)
# Get file_id from database if not provided
if file_id is None:
tmp_dir = os.path.join(settings.workdir, "tmp")
# Handle both basename and full path
if os.path.isabs(filename):
file_path = filename
else:
file_path = os.path.join(tmp_dir, filename)
if os.path.exists(file_path):
with SessionLocal() as db:
file_record = db.query(FileRecord).filter_by(local_filename=file_path).first()
if file_record:
file_id = file_record.id
prompt = (
"You are a specialized document analyzer trained to extract structured metadata from documents.\n"
"Your task is to analyze the given text and return a well-structured JSON object.\n\n"
"Extract and return the following fields:\n"
"1. **filename**: Machine-readable filename "
"(YYYY-MM-DD_DescriptiveTitle, use only letters, numbers, periods, and underscores).\n"
'2. **empfaenger**: The recipient, or "Unknown" if not found.\n'
'3. **absender**: The sender, or "Unknown" if not found.\n'
"4. **correspondent**: The entity or company that issued the document "
'(shortest possible name, e.g., "Amazon" instead of "Amazon EU SARL, German branch").\n'
"5. **kommunikationsart**: One of [Behoerdlicher_Brief, Rechnung, Kontoauszug, Vertrag, "
"Quittung, Privater_Brief, Einladung, Gewerbliche_Korrespondenz, Newsletter, Werbung, Sonstiges].\n"
"6. **kommunikationskategorie**: One of [Amtliche_Postbehoerdliche_Dokumente, "
"Finanz_und_Vertragsdokumente, Geschaeftliche_Kommunikation, "
"Private_Korrespondenz, Sonstige_Informationen].\n"
"7. **document_type**: Precise classification (e.g., Invoice, Contract, Information, Unknown).\n"
"8. **tags**: A list of up to 4 relevant thematic keywords.\n"
'9. **language**: Detected document language (ISO 639-1 code, e.g., "de" or "en").\n'
"10. **title**: A human-readable title summarizing the document content.\n"
"11. **confidence_score**: A numeric value (0-100) indicating the confidence level "
"of the extracted metadata.\n"
"12. **reference_number**: Extracted invoice/order/reference number if available.\n"
"13. **monetary_amounts**: A list of key monetary values detected in the document.\n\n"
"### Important Rules:\n"
"- **OCR Correction**: Assume the text has been corrected for OCR errors.\n"
"- **Tagging**: Max 4 tags, avoiding generic or overly specific terms.\n"
"- **Title**: Concise, no addresses, and contains key identifying features.\n"
"- **Date Selection**: Use the most relevant date if multiple are found.\n"
"- **Output Language**: Maintain the document's original language.\n\n"
f"Extracted text:\n{cleaned_text}\n\n"
"Return only valid JSON with no additional commentary.\n"
)
try:
logger.info(f"[{task_id}] Sending classification request for {filename}...")
log_task_progress(task_id, "call_ai_provider", "in_progress", "Calling AI provider API", file_id=file_id)
provider = get_ai_provider()
model = settings.ai_model or settings.openai_model
content = provider.chat_completion(
messages=[
{"role": "system", "content": "You are an intelligent document classifier."},
{"role": "user", "content": prompt},
],
model=model,
temperature=0,
)
logger.info(f"[{task_id}] Raw classification response for {filename}: {content[:200]}...")
log_task_progress(
task_id,
"call_ai_provider",
"success",
"Received AI provider response",
file_id=file_id,
detail=f"Raw classification response:\n{content}",
)
json_text = extract_json_from_text(content)
if not json_text:
logger.error(f"[{task_id}] Could not find valid JSON in GPT response for {filename}.")
log_task_progress(
task_id,
"extract_metadata_with_gpt",
"failure",
"Invalid JSON in response",
file_id=file_id,
detail=f"Could not parse valid JSON from GPT response.\nRaw response:\n{content}",
)
return {}
metadata = json.loads(json_text)
# SECURITY: Validate filename format from GPT to prevent path traversal
# The prompt requests filenames with only letters, numbers, periods, and underscores
# Enforce this constraint to prevent malicious filenames
suggested_filename = metadata.get("filename", "")
if suggested_filename:
# Check if filename contains only safe characters AND explicitly check for ".."
# Defense in depth: While the regex [\w\-\. ]+ already excludes / and \,
# we explicitly reject ".." to guard against:
# 1. Potential locale-specific \w behavior
# 2. Files literally named ".." which are valid but problematic
# 3. Future code changes that might relax the regex
if not re.match(r"^[\w\-\. ]+$", suggested_filename) or ".." in suggested_filename:
logger.warning(f"[{task_id}] Invalid filename format from GPT: '{suggested_filename}', using fallback")
# Reset to empty to trigger fallback to original filename
metadata["filename"] = ""
logger.info(f"[{task_id}] Extracted metadata: {metadata}")
log_task_progress(
task_id,
"parse_metadata",
"success",
f"Parsed metadata: {list(metadata.keys())}",
file_id=file_id,
detail=f"Extracted metadata:\n{json.dumps(metadata, ensure_ascii=False, indent=2)}",
)
# Trigger the next step: embedding metadata into the PDF
# Pass the filename (can be basename or full path) so embed_metadata_into_pdf can find the file on disk
logger.info(f"[{task_id}] Queueing metadata embedding task")
log_task_progress(
task_id, "extract_metadata_with_gpt", "success", "Metadata extracted, queuing embed task", file_id=file_id
)
embed_metadata_into_pdf.delay(filename, cleaned_text, metadata, file_id)
return {"s3_file": os.path.basename(filename), "metadata": metadata}
except Exception as e:
logger.exception(f"[{task_id}] AI provider classification failed for {filename}: {e}")
log_task_progress(
task_id,
"extract_metadata_with_gpt",
"failure",
f"Exception: {str(e)}",
file_id=file_id,
detail=f"AI provider classification failed for {filename}.\nException: {str(e)}",
)
return {}
#!/usr/bin/env python3
import json
import logging
import os
import re
# Import the shared Celery instance
from app.celery_app import celery
from app.config import settings
from app.database import SessionLocal
from app.models import FileRecord
from app.tasks.embed_metadata_into_pdf import embed_metadata_into_pdf
from app.tasks.retry_config import BaseTaskWithRetry
from app.utils import log_task_progress
from app.utils.ai_provider import get_ai_provider
from app.utils.filename_utils import VALID_FILENAME_RE
logger = logging.getLogger(__name__)
def extract_json_from_text(text):
"""
Try to extract a JSON object from the text.
- First, check for a JSON block inside triple backticks.
- If not found, try to extract text from the first '{' to the last '}'.
"""
pattern = r"```(?:json)?\s*(\{.*?\})\s*```"
match = re.search(pattern, text, re.DOTALL)
if match:
return match.group(1)
else:
start = text.find("{")
end = text.rfind("}")
if start != -1 and end != -1 and end > start:
return text[start : end + 1]
return None
@celery.task(base=BaseTaskWithRetry, bind=True)
def extract_metadata_with_gpt(self, filename: str, cleaned_text: str, file_id: int = None):
"""
Uses OpenAI to classify document metadata.
Args:
filename: Can be either a basename (e.g., "file.pdf") or a full path (e.g., "/workdir/processed/file.pdf")
cleaned_text: The extracted text from the document
file_id: Optional file ID for tracking
"""
task_id = self.request.id
logger.info(f"[{task_id}] Starting metadata extraction for: {filename}")
log_task_progress(
task_id,
"extract_metadata_with_gpt",
"in_progress",
f"Extracting metadata for {os.path.basename(filename)}",
file_id=file_id,
)
# Get file_id from database if not provided
if file_id is None:
tmp_dir = os.path.join(settings.workdir, "tmp")
# Handle both basename and full path
if os.path.isabs(filename):
file_path = filename
else:
file_path = os.path.join(tmp_dir, filename)
if os.path.exists(file_path):
with SessionLocal() as db:
file_record = db.query(FileRecord).filter_by(local_filename=file_path).first()
if file_record:
file_id = file_record.id
prompt = (
"You are a specialized document analyzer trained to extract structured metadata from documents.\n"
"Your task is to analyze the given text and return a well-structured JSON object.\n\n"
"Extract and return the following fields:\n"
"1. **filename**: Machine-readable filename "
"(YYYY-MM-DD_DescriptiveTitle, use only letters, numbers, spaces, dashes, periods, and underscores).\n"
'2. **empfaenger**: The recipient, or "Unknown" if not found.\n'
'3. **absender**: The sender, or "Unknown" if not found.\n'
"4. **correspondent**: The entity or company that issued the document "
'(shortest possible name, e.g., "Amazon" instead of "Amazon EU SARL, German branch").\n'
"5. **kommunikationsart**: One of [Behoerdlicher_Brief, Rechnung, Kontoauszug, Vertrag, "
"Quittung, Privater_Brief, Einladung, Gewerbliche_Korrespondenz, Newsletter, Werbung, Sonstiges].\n"
"6. **kommunikationskategorie**: One of [Amtliche_Postbehoerdliche_Dokumente, "
"Finanz_und_Vertragsdokumente, Geschaeftliche_Kommunikation, "
"Private_Korrespondenz, Sonstige_Informationen].\n"
"7. **document_type**: Precise classification (e.g., Invoice, Contract, Information, Unknown).\n"
"8. **tags**: A list of up to 4 relevant thematic keywords.\n"
'9. **language**: Detected document language (ISO 639-1 code, e.g., "de" or "en").\n'
"10. **title**: A human-readable title summarizing the document content.\n"
"11. **confidence_score**: A numeric value (0-100) indicating the confidence level "
"of the extracted metadata.\n"
"12. **reference_number**: Extracted invoice/order/reference number if available.\n"
"13. **monetary_amounts**: A list of key monetary values detected in the document.\n\n"
"### Important Rules:\n"
"- **OCR Correction**: Assume the text has been corrected for OCR errors.\n"
"- **Tagging**: Max 4 tags, avoiding generic or overly specific terms.\n"
"- **Title**: Concise, no addresses, and contains key identifying features.\n"
"- **Date Selection**: Use the most relevant date if multiple are found.\n"
"- **Output Language**: Maintain the document's original language.\n\n"
f"Extracted text:\n{cleaned_text}\n\n"
"Return only valid JSON with no additional commentary.\n"
)
try:
logger.info(f"[{task_id}] Sending classification request for {filename}...")
log_task_progress(task_id, "call_ai_provider", "in_progress", "Calling AI provider API", file_id=file_id)
provider = get_ai_provider()
model = settings.ai_model or settings.openai_model
content = provider.chat_completion(
messages=[
{"role": "system", "content": "You are an intelligent document classifier."},
{"role": "user", "content": prompt},
],
model=model,
temperature=0,
)
logger.info(f"[{task_id}] Raw classification response for {filename}: {content[:200]}...")
log_task_progress(
task_id,
"call_ai_provider",
"success",
"Received AI provider response",
file_id=file_id,
detail=f"Raw classification response:\n{content}",
)
json_text = extract_json_from_text(content)
if not json_text:
logger.error(f"[{task_id}] Could not find valid JSON in GPT response for {filename}.")
log_task_progress(
task_id,
"extract_metadata_with_gpt",
"failure",
"Invalid JSON in response",
file_id=file_id,
detail=f"Could not parse valid JSON from GPT response.\nRaw response:\n{content}",
)
return {}
metadata = json.loads(json_text)
# SECURITY: Validate filename format from GPT to prevent path traversal
# The prompt requests filenames with only letters, numbers, periods, and underscores
# Enforce this constraint to prevent malicious filenames
suggested_filename = metadata.get("filename", "")
if suggested_filename:
# Check if filename contains only safe characters AND explicitly check for ".."
# Defense in depth: While the regex VALID_FILENAME_PATTERN already excludes / and \,
# we explicitly reject ".." to guard against:
# 1. Potential locale-specific \w behavior
# 2. Files literally named ".." which are valid but problematic
# 3. Future code changes that might relax the regex
if not VALID_FILENAME_RE.match(suggested_filename) or ".." in suggested_filename:
logger.warning(f"[{task_id}] Invalid filename format from GPT: '{suggested_filename}', using fallback")
# Reset to empty to trigger fallback to original filename
metadata["filename"] = ""
logger.info(f"[{task_id}] Extracted metadata: {metadata}")
log_task_progress(
task_id,
"parse_metadata",
"success",
f"Parsed metadata: {list(metadata.keys())}",
file_id=file_id,
detail=f"Extracted metadata:\n{json.dumps(metadata, ensure_ascii=False, indent=2)}",
)
# Trigger the next step: embedding metadata into the PDF
# Pass the filename (can be basename or full path) so embed_metadata_into_pdf can find the file on disk
logger.info(f"[{task_id}] Queueing metadata embedding task")
log_task_progress(
task_id, "extract_metadata_with_gpt", "success", "Metadata extracted, queuing embed task", file_id=file_id
)
embed_metadata_into_pdf.delay(filename, cleaned_text, metadata, file_id)
return {"s3_file": os.path.basename(filename), "metadata": metadata}
except Exception as e:
logger.exception(f"[{task_id}] AI provider classification failed for {filename}: {e}")
log_task_progress(
task_id,
"extract_metadata_with_gpt",
"failure",
f"Exception: {str(e)}",
file_id=file_id,
detail=f"AI provider classification failed for {filename}.\nException: {str(e)}",
)
return {}
+63 -18
View File
@@ -10,14 +10,20 @@ from app.database import SessionLocal
from app.models import FileRecord
from app.tasks.retry_config import BaseTaskWithRetry
# Import the aggregator task and validator
from app.tasks.send_to_all import get_configured_services_from_validator, send_to_all_destinations
# Import the aggregator tasks and validator
from app.tasks.send_to_all import (
get_configured_services_from_validator,
get_user_destination_count,
send_to_all_destinations,
send_to_user_destinations,
)
# Import database and logging utils from main
from app.utils import log_task_progress
# Import notification utility
# Import notification utilities
from app.utils.notification import notify_file_processed
from app.utils.user_notification import notify_user_document_processed
logger = logging.getLogger(__name__)
@@ -26,8 +32,16 @@ logger = logging.getLogger(__name__)
def finalize_document_storage(self, original_file: str, processed_file: str, metadata: dict, file_id: int = None):
"""
Final storage step after embedding metadata.
We will now call 'send_to_all_destinations' to push the final PDF to Dropbox/Nextcloud/Paperless.
After uploading, send a notification about the processed file.
Routes the processed document to the appropriate destination(s):
1. If the document has an identified owner and that owner has active
DESTINATION UserIntegrations, the file is uploaded to each of those
integrations (user-specific routing).
2. Otherwise the file is forwarded to the globally-configured destinations
via :func:`send_to_all_destinations` (system-wide fallback).
After queuing uploads, optional PDF/A archival conversion and embedding
computation are triggered, and a completion notification is sent.
"""
task_id = self.request.id
logger.info(f"[{task_id}] Finalizing document storage for {processed_file}")
@@ -41,7 +55,8 @@ def finalize_document_storage(self, original_file: str, processed_file: str, met
file_id=file_id,
)
# Get file_id from database if not provided (fallback logic)
# 2. Resolve file_id and owner_id from the database
owner_id = None
if file_id is None:
with SessionLocal() as db:
# Only as a last resort, try to find by exact match on local_filename
@@ -49,32 +64,52 @@ def finalize_document_storage(self, original_file: str, processed_file: str, met
file_record = db.query(FileRecord).filter(FileRecord.local_filename == tmp_path).first()
if file_record:
file_id = file_record.id
owner_id = file_record.owner_id
else:
with SessionLocal() as db:
file_record = db.query(FileRecord).filter(FileRecord.id == file_id).first()
if file_record:
owner_id = file_record.owner_id
# 2. Determine Configured Destinations
# This is needed for the notification message later
# 3. Determine configured destinations for notification
configured_destinations = []
try:
configured_services = get_configured_services_from_validator()
# Get list of service names that are configured
for service_name, is_configured in configured_services.items():
if is_configured:
# Format service names for display
display_name = service_name.replace("_", " ").title()
configured_destinations.append(display_name)
except Exception as e:
logger.warning(f"[WARNING] Could not determine configured destinations: {e}")
configured_destinations = ["configured destinations"]
# 3. Queue Uploads
logger.info(f"[{task_id}] Queueing uploads to all destinations")
# 4. Queue Uploads — prefer user-specific destinations when available
log_task_progress(
task_id, "finalize_document_storage", "success", "Queuing uploads to destinations", file_id=file_id
)
# Note: send_to_all_destinations is asynchronous and queues upload tasks
send_to_all_destinations.delay(processed_file, True, file_id)
user_dest_count = 0
if owner_id:
try:
user_dest_count = get_user_destination_count(owner_id)
except Exception as e:
logger.warning("[%s] Could not query user destination count for owner=%s: %s", task_id, owner_id, e)
# 3a. Trigger PDF/A archival conversion if enabled (from feature branch)
if owner_id and user_dest_count > 0:
# User has configured their own destinations → use those exclusively
logger.info(
"[%s] Routing to %d user-specific destination(s) for owner=%s",
task_id,
user_dest_count,
owner_id,
)
send_to_user_destinations.delay(processed_file, owner_id, file_id)
else:
# No user-specific destinations → fall back to global configuration
logger.info("[%s] No user-specific destinations found; using global destinations", task_id)
send_to_all_destinations.delay(processed_file, True, file_id)
# 4a. Trigger PDF/A archival conversion if enabled
if settings.enable_pdfa_conversion:
try:
from app.tasks.convert_to_pdfa import convert_to_pdfa
@@ -84,7 +119,7 @@ def finalize_document_storage(self, original_file: str, processed_file: str, met
except Exception as e:
logger.warning(f"[{task_id}] Could not queue PDF/A conversion: {e}")
# 3b. Queue embedding computation (from main branch)
# 4b. Queue embedding computation
if file_id is not None:
try:
from app.tasks.compute_embedding import compute_document_embedding
@@ -94,9 +129,8 @@ def finalize_document_storage(self, original_file: str, processed_file: str, met
except Exception as e:
logger.warning(f"[{task_id}] Could not queue embedding task: {e}")
# 4. Send Notification
# 5. Send Notification
try:
# Get file information
file_size = os.path.getsize(processed_file) if os.path.exists(processed_file) else 0
filename = os.path.basename(processed_file)
@@ -106,4 +140,15 @@ def finalize_document_storage(self, original_file: str, processed_file: str, met
except Exception as e:
logger.warning(f"[WARNING] Failed to send file processed notification: {e}")
# 6. Send per-user notification
if owner_id:
try:
notify_user_document_processed(
owner_id=owner_id,
filename=os.path.basename(processed_file),
file_id=file_id,
)
except Exception as e:
logger.warning(f"[WARNING] Failed to send per-user processed notification: {e}")
return {"status": "Completed", "file": processed_file}
+670 -407
View File
File diff suppressed because it is too large Load Diff
+83 -4
View File
@@ -1,10 +1,14 @@
#!/usr/bin/env python3
from __future__ import annotations
import json
import logging
import mimetypes
import os
import shutil
import uuid
from typing import TYPE_CHECKING
import pypdf # Upgraded from PyPDF2 to fix CVE-2023-36464
from pypdf.errors import PdfReadError
@@ -12,7 +16,7 @@ from pypdf.errors import PdfReadError
from app.celery_app import celery
from app.config import settings
from app.database import SessionLocal
from app.models import FileRecord
from app.models import FileRecord, Pipeline, PipelineStep
from app.tasks.extract_metadata_with_gpt import extract_metadata_with_gpt
from app.tasks.process_with_ocr import process_with_ocr
from app.tasks.retry_config import BaseTaskWithRetry
@@ -20,9 +24,75 @@ from app.utils import get_unique_filepath_with_counter, hash_file, log_task_prog
from app.utils.step_manager import initialize_file_steps
from app.utils.text_quality import check_text_quality, detect_pdf_text_source
if TYPE_CHECKING:
from sqlalchemy.orm import Session
logger = logging.getLogger(__name__)
def _get_pipeline_ocr_language(db: "Session", file_record: FileRecord, owner_id: str | None) -> str | None:
"""Look up the OCR language override from the file's pipeline OCR step config.
Resolution order:
1. Explicit pipeline assigned to the file (``file_record.pipeline_id``).
2. User's own default pipeline (``owner_id``, ``is_default=True``).
3. System default pipeline (``owner_id=NULL``, ``is_default=True``).
Returns the ``ocr_language`` value from the pipeline's OCR step config, or
``None`` when no override is configured.
"""
pipeline = None
if file_record.pipeline_id:
pipeline = db.query(Pipeline).filter(Pipeline.id == file_record.pipeline_id).first()
if pipeline is None and owner_id:
pipeline = (
db.query(Pipeline)
.filter(
Pipeline.owner_id == owner_id,
Pipeline.is_default.is_(True),
Pipeline.is_active.is_(True),
)
.first()
)
if pipeline is None:
pipeline = (
db.query(Pipeline)
.filter(
Pipeline.owner_id.is_(None),
Pipeline.is_default.is_(True),
Pipeline.is_active.is_(True),
)
.first()
)
if pipeline is None:
return None
ocr_step = (
db.query(PipelineStep)
.filter(
PipelineStep.pipeline_id == pipeline.id,
PipelineStep.step_type == "ocr",
PipelineStep.enabled.is_(True),
)
.first()
)
if ocr_step is None or not ocr_step.config:
return None
try:
step_config = json.loads(ocr_step.config)
lang = step_config.get("ocr_language")
# "auto" is treated as no override
return lang if lang and lang != "auto" else None
except Exception:
return None
@celery.task(base=BaseTaskWithRetry, bind=True)
def process_document(
self,
@@ -109,6 +179,7 @@ def process_document(
)
# Acquire DB session in the task
ocr_language: str | None = None # Pipeline OCR language override resolved inside DB session
with SessionLocal() as db:
# When file_id is provided, we are reprocessing an existing file.
# Skip the duplicate check and reuse the existing record.
@@ -305,6 +376,14 @@ def process_document(
new_record.local_filename = new_local_path
db.commit()
# Look up pipeline OCR language override before the session closes.
# This reads the OCR step config from the file's assigned pipeline (or
# the user/system default pipeline) so the language is available when
# dispatching process_with_ocr below.
ocr_language = _get_pipeline_ocr_language(db, new_record, owner_id)
if ocr_language:
logger.info(f"[{task_id}] Pipeline OCR language override: {ocr_language!r}")
# Store file_id before session closes to avoid DetachedInstanceError
file_id = new_record.id
@@ -334,7 +413,7 @@ def process_document(
"Queued for forced OCR processing",
file_id=file_id,
)
process_with_ocr.delay(new_filename, file_id)
process_with_ocr.delay(new_filename, file_id, language=ocr_language)
return {"file": new_local_path, "status": "Queued for forced OCR", "file_id": file_id}
# If the file is not a PDF, skip embedded text check and convert to PDF first
@@ -491,7 +570,7 @@ def process_document(
"Queued for OCR (text quality too low)",
file_id=file_id,
)
process_with_ocr.delay(new_filename, file_id, extracted_text)
process_with_ocr.delay(new_filename, file_id, extracted_text, language=ocr_language)
return {
"file": new_local_path,
"status": "Queued for OCR (poor embedded text quality)",
@@ -564,5 +643,5 @@ def process_document(
"Queued for OCR processing",
file_id=file_id,
)
process_with_ocr.delay(new_filename, file_id)
process_with_ocr.delay(new_filename, file_id, language=ocr_language)
return {"file": new_local_path, "status": "Queued for OCR", "file_id": file_id}
+19 -5
View File
@@ -17,7 +17,6 @@ task with a multi-engine OCR pipeline that:
import logging
import os
from typing import Optional
from app.celery_app import celery
from app.config import settings
@@ -33,7 +32,13 @@ logger = logging.getLogger(__name__)
@celery.task(base=OcrTaskWithRetry, bind=True)
def process_with_ocr(self, filename: str, file_id: Optional[int] = None, original_text: Optional[str] = None):
def process_with_ocr(
self,
filename: str,
file_id: int | None = None,
original_text: str | None = None,
language: str | None = None,
):
"""Run the configured OCR providers on *filename* and continue the pipeline.
When multiple OCR providers are configured the results are merged using the
@@ -47,6 +52,10 @@ def process_with_ocr(self, filename: str, file_id: Optional[int] = None, origina
filename: Base name of the file inside ``<workdir>/tmp/``.
file_id: Optional database record ID passed through to downstream tasks.
original_text: Optional original embedded text for head-to-head comparison.
language: Optional Tesseract-style language code(s) (e.g. ``"eng+deu"``)
to override the global OCR language settings for this specific run.
Pass ``None`` or ``"auto"`` to use the global settings. This
enables per-pipeline language configuration.
"""
task_id = self.request.id
log_task_progress(
@@ -62,7 +71,7 @@ def process_with_ocr(self, filename: str, file_id: Optional[int] = None, origina
if not os.path.exists(tmp_file_path):
raise FileNotFoundError(f"Local file not found: {tmp_file_path}")
providers = get_ocr_providers()
providers = get_ocr_providers(language=language)
provider_names = [p.name for p in providers]
logger.info(f"[{task_id}] Running {len(providers)} OCR provider(s): {provider_names}")
@@ -122,7 +131,12 @@ def process_with_ocr(self, filename: str, file_id: Optional[int] = None, origina
# PDF with ocrmypdf to embed an invisible text layer so the output is
# selectable/searchable in PDF viewers.
if searchable_pdf_path is None:
lang = getattr(settings, "tesseract_language", None) or "eng"
# Use the per-call language override; fall back to global setting
embed_lang = (
language
if language and language != "auto"
else (getattr(settings, "tesseract_language", None) or "eng")
)
log_task_progress(
task_id,
"embed_text_layer",
@@ -130,7 +144,7 @@ def process_with_ocr(self, filename: str, file_id: Optional[int] = None, origina
"Embedding searchable text layer into PDF",
file_id=file_id,
)
embedded = embed_text_layer(tmp_file_path, tmp_file_path, language=lang)
embedded = embed_text_layer(tmp_file_path, tmp_file_path, language=embed_lang)
if embedded:
searchable_pdf_path = tmp_file_path
log_task_progress(
+201 -13
View File
@@ -6,17 +6,19 @@ import os
from app.celery_app import celery
from app.config import settings
from app.database import SessionLocal
from app.models import FileRecord
from app.models import FileRecord, IntegrationDirection, UserIntegration
from app.tasks.retry_config import BaseTaskWithRetry
from app.tasks.upload_to_dropbox import upload_to_dropbox
from app.tasks.upload_to_email import upload_to_email
from app.tasks.upload_to_ftp import upload_to_ftp
from app.tasks.upload_to_google_drive import upload_to_google_drive
from app.tasks.upload_to_icloud import upload_to_icloud
from app.tasks.upload_to_nextcloud import upload_to_nextcloud
from app.tasks.upload_to_onedrive import upload_to_onedrive
from app.tasks.upload_to_paperless import upload_to_paperless
from app.tasks.upload_to_s3 import upload_to_s3
from app.tasks.upload_to_sftp import upload_to_sftp
from app.tasks.upload_to_sharepoint import upload_to_sharepoint
from app.tasks.upload_to_webdav import upload_to_webdav
from app.utils.config_validator import get_provider_status
from app.utils.logging import log_task_progress
@@ -25,18 +27,32 @@ logger = logging.getLogger(__name__)
def _should_upload_to_dropbox():
return bool(settings.dropbox_app_key and settings.dropbox_app_secret and settings.dropbox_refresh_token)
return bool(
getattr(settings, "dropbox_enabled", True)
and settings.dropbox_app_key
and settings.dropbox_app_secret
and settings.dropbox_refresh_token
)
def _should_upload_to_nextcloud():
return bool(settings.nextcloud_upload_url and settings.nextcloud_username and settings.nextcloud_password)
return bool(
getattr(settings, "nextcloud_enabled", True)
and settings.nextcloud_upload_url
and settings.nextcloud_username
and settings.nextcloud_password
)
def _should_upload_to_paperless():
return bool(settings.paperless_ngx_api_token and settings.paperless_host)
return bool(
getattr(settings, "paperless_enabled", True) and settings.paperless_ngx_api_token and settings.paperless_host
)
def _should_upload_to_google_drive():
if not getattr(settings, "google_drive_enabled", True):
return False
# Check for OAuth configuration
if getattr(settings, "google_drive_use_oauth", False):
return bool(
@@ -51,20 +67,33 @@ def _should_upload_to_google_drive():
def _should_upload_to_webdav():
return bool(settings.webdav_url and settings.webdav_username and settings.webdav_password)
return bool(
getattr(settings, "webdav_enabled", True)
and settings.webdav_url
and settings.webdav_username
and settings.webdav_password
)
def _should_upload_to_ftp():
return bool(settings.ftp_host and settings.ftp_username and settings.ftp_password)
return bool(
getattr(settings, "ftp_enabled", True) and settings.ftp_host and settings.ftp_username and settings.ftp_password
)
def _should_upload_to_sftp():
return bool(settings.sftp_host and settings.sftp_username and (settings.sftp_password or settings.sftp_private_key))
return bool(
getattr(settings, "sftp_enabled", True)
and settings.sftp_host
and settings.sftp_username
and (settings.sftp_password or settings.sftp_private_key)
)
def _should_upload_to_email():
return bool(
settings.dest_email_host
getattr(settings, "dest_email_enabled", True)
and settings.dest_email_host
and settings.dest_email_username
and settings.dest_email_password
and settings.dest_email_default_recipient
@@ -72,18 +101,44 @@ def _should_upload_to_email():
def _should_upload_to_onedrive():
return bool(settings.onedrive_client_id and settings.onedrive_client_secret and settings.onedrive_refresh_token)
return bool(
getattr(settings, "onedrive_enabled", True)
and settings.onedrive_client_id
and settings.onedrive_client_secret
and settings.onedrive_refresh_token
)
def _should_upload_to_s3():
return bool(settings.s3_bucket_name and settings.aws_access_key_id and settings.aws_secret_access_key)
return bool(
getattr(settings, "s3_enabled", True)
and settings.s3_bucket_name
and settings.aws_access_key_id
and settings.aws_secret_access_key
)
def _should_upload_to_icloud():
return bool(getattr(settings, "icloud_enabled", True) and settings.icloud_username and settings.icloud_password)
def _should_upload_to_sharepoint():
return bool(
settings.sharepoint_client_id
and settings.sharepoint_client_secret
and settings.sharepoint_site_url
and (
settings.sharepoint_refresh_token
or (settings.sharepoint_tenant_id and settings.sharepoint_tenant_id != "common")
)
)
def get_configured_services_from_validator():
"""
Use the config validator to determine which services are configured properly.
Use the config validator to determine which services are configured and enabled.
Returns a dictionary with service names as keys and boolean values indicating
whether they're properly configured.
whether they're properly configured AND explicitly enabled.
"""
providers = get_provider_status()
@@ -98,12 +153,15 @@ def get_configured_services_from_validator():
"Email": "email",
"OneDrive": "onedrive",
"S3 Storage": "s3",
"SharePoint": "sharepoint",
"iCloud Drive": "icloud",
}
result = {}
for provider_name, internal_name in service_map.items():
if provider_name in providers:
result[internal_name] = providers[provider_name].get("configured", False)
provider = providers[provider_name]
result[internal_name] = provider.get("configured", False) and provider.get("enabled", True)
return result
@@ -206,6 +264,16 @@ def send_to_all_destinations(self, file_path: str, use_validator=True, file_id:
"should_upload": _should_upload_to_s3,
"upload_func": upload_to_s3,
},
{
"name": "sharepoint",
"should_upload": _should_upload_to_sharepoint,
"upload_func": upload_to_sharepoint,
},
{
"name": "icloud",
"should_upload": _should_upload_to_icloud,
"upload_func": upload_to_icloud,
},
]
# Optionally get configuration status from validator
@@ -261,3 +329,123 @@ def send_to_all_destinations(self, file_path: str, use_validator=True, file_id:
log_task_progress(task_id, "send_to_all_destinations", "success", f"Queued {queued_count} uploads", file_id=file_id)
return {"status": "Queued", "file_path": file_path, "tasks": results}
@celery.task(base=BaseTaskWithRetry, bind=True)
def send_to_user_destinations(self, file_path: str, owner_id: str, file_id: int | None = None):
"""Dispatch uploads to all active DESTINATION UserIntegrations for *owner_id*.
This is the user-specific counterpart of :func:`send_to_all_destinations`.
It queries the ``user_integrations`` table for records where:
* ``owner_id`` matches the document owner,
* ``direction == "DESTINATION"``, and
* ``is_active == True``.
One :func:`upload_to_user_integration` Celery task is queued for each
matching integration so that uploads proceed asynchronously and
independently.
Args:
file_path: Absolute path to the processed document file.
owner_id: The stable user identifier from ``FileRecord.owner_id``.
file_id: Optional ``FileRecord.id`` used for progress logging.
Returns:
A dict summarising how many integrations were queued.
"""
from app.tasks.upload_to_user_integration import upload_to_user_integration
task_id = self.request.id
filename = os.path.basename(file_path)
if not os.path.exists(file_path):
error_msg = f"File not found: {file_path}"
logger.error("[%s] %s", task_id, error_msg)
log_task_progress(task_id, "send_to_user_destinations", "failure", error_msg, file_id=file_id)
raise FileNotFoundError(error_msg)
logger.info("[%s] Sending %s to user destinations for owner=%s", task_id, filename, owner_id)
log_task_progress(
task_id,
"send_to_user_destinations",
"in_progress",
f"Distributing {filename} to user integrations",
file_id=file_id,
)
with SessionLocal() as db:
integrations = (
db.query(UserIntegration)
.filter(
UserIntegration.owner_id == owner_id,
UserIntegration.direction == IntegrationDirection.DESTINATION,
UserIntegration.is_active.is_(True),
)
.all()
)
# Snapshot the IDs so we don't keep the session open
integration_ids = [(i.id, i.name, i.integration_type) for i in integrations]
queued = 0
task_results: dict[str, str] = {}
for int_id, int_name, int_type in integration_ids:
logger.info("[%s] Queueing upload for integration %d (%s '%s')", task_id, int_id, int_type, int_name)
log_task_progress(
task_id,
f"queue_user_integration_{int_id}",
"in_progress",
f"Queueing upload to {int_type} '{int_name}'",
file_id=file_id,
)
try:
celery_task = upload_to_user_integration.delay(file_path, int_id, file_id)
task_results[f"integration_{int_id}_task_id"] = celery_task.id
queued += 1
log_task_progress(
task_id,
f"queue_user_integration_{int_id}",
"success",
f"Queued upload to {int_type} '{int_name}'",
file_id=file_id,
)
except Exception as exc: # noqa: BLE001
error_msg = str(exc)
logger.error("[%s] Failed to queue upload for integration %d: %s", task_id, int_id, error_msg)
task_results[f"integration_{int_id}_error"] = error_msg
log_task_progress(
task_id,
f"queue_user_integration_{int_id}",
"failure",
f"Failed to queue {int_type} '{int_name}': {error_msg}",
file_id=file_id,
)
logger.info("[%s] Queued %d user-integration upload(s) for owner=%s", task_id, queued, owner_id)
log_task_progress(
task_id,
"send_to_user_destinations",
"success",
f"Queued {queued} user-integration upload(s)",
file_id=file_id,
)
return {"status": "Queued", "file_path": file_path, "queued": queued, "tasks": task_results}
def get_user_destination_count(owner_id: str) -> int:
"""Return the number of active DESTINATION integrations for *owner_id*.
A count of zero means no user-specific destinations are configured and
the caller should fall back to the global :func:`send_to_all_destinations`.
"""
with SessionLocal() as db:
return (
db.query(UserIntegration)
.filter(
UserIntegration.owner_id == owner_id,
UserIntegration.direction == IntegrationDirection.DESTINATION,
UserIntegration.is_active.is_(True),
)
.count()
)
+141
View File
@@ -0,0 +1,141 @@
#!/usr/bin/env python3
"""Celery task to translate extracted document text into the default target language.
This task is triggered after metadata extraction when the detected document
language differs from the user's (or system) default document language. The
translated text is persisted in ``FileRecord.default_language_text`` so that
users can always read a reference copy in their preferred language.
Other ad-hoc translations are generated on the fly via the ``/api/files/{id}/translate``
endpoint and are NOT persisted.
"""
import logging
from app.celery_app import celery
from app.config import settings
from app.database import SessionLocal
from app.models import FileRecord, UserProfile
from app.tasks.retry_config import BaseTaskWithRetry
from app.utils import log_task_progress
from app.utils.ai_provider import get_ai_provider
logger = logging.getLogger(__name__)
def _resolve_default_language(owner_id: str | None) -> str:
"""Return the default document language for the given owner.
Resolution order:
1. ``UserProfile.default_document_language`` (per-user override)
2. ``settings.default_document_language`` (global setting)
"""
if owner_id:
with SessionLocal() as db:
profile = db.query(UserProfile).filter_by(user_id=owner_id).first()
if profile and profile.default_document_language:
return profile.default_document_language
return settings.default_document_language
@celery.task(base=BaseTaskWithRetry, bind=True)
def translate_to_default_language(
self,
file_id: int,
extracted_text: str,
detected_language: str,
owner_id: str | None = None,
) -> dict:
"""Translate *extracted_text* into the default document language and persist the result.
Args:
file_id: Primary key of the :class:`FileRecord`.
extracted_text: The OCR / refined text in the document's original language.
detected_language: ISO 639-1 code of the document's detected language.
owner_id: Owner identifier used to resolve per-user language preference.
Returns:
A dict with ``status``, ``target_language``, and the translated text length.
"""
task_id = self.request.id
target_language = _resolve_default_language(owner_id)
# Nothing to do when the document is already in the target language.
if detected_language == target_language:
logger.info(
f"[{task_id}] Document {file_id} already in target language '{target_language}', skipping translation"
)
log_task_progress(
task_id,
"translate_to_default_language",
"skipped",
f"Document already in {target_language}",
file_id=file_id,
)
return {"status": "skipped", "reason": "already_in_target_language"}
logger.info(f"[{task_id}] Translating document {file_id} from '{detected_language}' to '{target_language}'")
log_task_progress(
task_id,
"translate_to_default_language",
"in_progress",
f"Translating from {detected_language} to {target_language}",
file_id=file_id,
)
try:
provider = get_ai_provider()
model = settings.ai_model or settings.openai_model
translated_text = provider.chat_completion(
messages=[
{
"role": "system",
"content": (
f"You are a professional translator. Translate the following text "
f"from {detected_language} to {target_language}. "
f"Preserve the original formatting, paragraph structure, and meaning. "
f"Do not add any commentary or explanation — output ONLY the translated text."
),
},
{"role": "user", "content": extracted_text},
],
model=model,
temperature=0.3,
)
# Persist the translation.
with SessionLocal() as db:
record = db.query(FileRecord).filter_by(id=file_id).first()
if record:
record.default_language_text = translated_text
record.default_language_code = target_language
record.detected_language = detected_language
db.commit()
logger.info(
f"[{task_id}] Stored default-language translation ({len(translated_text)} chars) for file {file_id}"
)
log_task_progress(
task_id,
"translate_to_default_language",
"success",
f"Translated {len(extracted_text)}{len(translated_text)} chars ({detected_language}{target_language})",
file_id=file_id,
)
return {
"status": "success",
"target_language": target_language,
"translated_length": len(translated_text),
}
except Exception as exc:
logger.exception(f"[{task_id}] Translation failed for file {file_id}: {exc}")
log_task_progress(
task_id,
"translate_to_default_language",
"failure",
f"Exception: {exc}",
file_id=file_id,
)
raise
+34 -5
View File
@@ -11,6 +11,7 @@ from email.mime.image import MIMEImage
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
import pypdf
from jinja2 import Environment, FileSystemLoader, select_autoescape
from app.celery_app import celery
@@ -23,6 +24,15 @@ logger = logging.getLogger(__name__)
# Constants
_LOGO_FILENAME = "logo.png"
# Mapping from PDF metadata keys (with leading slash stripped) to application-specific names.
# This mirrors the inverse of the mapping used in app/tasks/embed_metadata_into_pdf.py.
_PDF_METADATA_KEY_MAP = {
"Title": "filename",
"Author": "absender",
"Subject": "document_type",
"Keywords": "tags",
}
def get_email_template(template_name="default.html"):
"""
@@ -63,9 +73,12 @@ def extract_metadata_from_file(file_path):
"""
Try to extract metadata from a file using several methods:
1. Check for a .json metadata file with the same name
2. Extract metadata from PDF if it's embedded
2. Extract embedded metadata from PDF using pypdf
Returns a dictionary of metadata or None if not found
JSON metadata takes precedence; embedded PDF metadata fills in any missing
fields using the application's standard key mapping (e.g., /Title → filename).
Returns a dictionary of metadata (may be empty if none found).
"""
metadata = {}
@@ -76,12 +89,28 @@ def extract_metadata_from_file(file_path):
with open(metadata_path, "r", encoding="utf-8") as f:
metadata = json.load(f)
logger.info(f"Loaded metadata from external JSON file: {metadata_path}")
return metadata
except Exception as e:
logger.warning(f"Failed to load metadata from JSON file: {str(e)}")
# TODO: For PDF files, try to extract embedded metadata using PyPDF2
# This would require additional dependencies, so for now we'll just check for external JSON
# Try to extract embedded metadata from PDF
if file_path.lower().endswith(".pdf") and os.path.exists(file_path):
try:
with open(file_path, "rb") as f:
pdf_reader = pypdf.PdfReader(f)
pdf_metadata = pdf_reader.metadata
if pdf_metadata:
for key, value in pdf_metadata.items():
# Remove the leading slash from PDF metadata keys (e.g., '/Title' -> 'Title')
clean_key = key[1:] if key.startswith("/") else key
# Map to application-specific key names where possible
mapped_key = _PDF_METADATA_KEY_MAP.get(clean_key, clean_key)
# Only set if not already present (JSON metadata takes precedence)
if mapped_key not in metadata:
metadata[mapped_key] = str(value)
logger.info(f"Extracted embedded metadata from PDF: {file_path}")
except Exception as e:
logger.warning(f"Failed to extract metadata from PDF {file_path}: {str(e)}")
return metadata
+177
View File
@@ -0,0 +1,177 @@
#!/usr/bin/env python3
"""Upload files to Apple iCloud Drive via the pyicloud library.
This module uses the ``pyicloud`` library to authenticate with Apple's iCloud
service and upload files to iCloud Drive. Because Apple does not offer a public
REST API for iCloud Drive, this integration relies on the *unofficial*
reverse-engineered protocol implemented by ``pyicloud``.
Requirements
~~~~~~~~~~~~
* An Apple ID with iCloud Drive enabled.
* An **app-specific password** generated at https://appleid.apple.com (required
when two-factor authentication is active which is the default for all modern
Apple IDs).
* The ``pyicloud`` Python package (``pip install pyicloud``).
Configuration
~~~~~~~~~~~~~
Set the following environment variables (or ``app/config.py`` fields):
* ``ICLOUD_USERNAME`` Apple ID email address.
* ``ICLOUD_PASSWORD`` App-specific password.
* ``ICLOUD_FOLDER`` Target folder path inside iCloud Drive, using ``/`` as
the separator (e.g. ``Documents/Uploads``). The folder is created
automatically if it does not exist.
* ``ICLOUD_COOKIE_DIRECTORY`` (Optional) Directory for persisting session
cookies so that re-authentication is avoided between task runs. Defaults to
``~/.pyicloud``.
"""
import logging
import os
from app.celery_app import celery
from app.config import settings
from app.tasks.retry_config import UploadTaskWithRetry
from app.utils import log_task_progress
logger = logging.getLogger(__name__)
def _get_icloud_api(
username: str,
password: str,
cookie_directory: str | None = None,
):
"""Return an authenticated ``PyiCloudService`` instance.
Args:
username: Apple ID email address.
password: App-specific password.
cookie_directory: Optional directory for session cookies.
Returns:
An authenticated ``PyiCloudService`` instance.
Raises:
ImportError: If ``pyicloud`` is not installed.
ValueError: If authentication fails or 2FA is required interactively.
"""
from pyicloud import PyiCloudService # noqa: S404 unofficial third-party iCloud client
kwargs: dict = {}
if cookie_directory:
kwargs["cookie_directory"] = cookie_directory
api = PyiCloudService(username, password, **kwargs)
# If 2SA/2FA is required the user must use an app-specific password instead.
if api.requires_2sa or api.requires_2fa:
raise ValueError(
"iCloud account requires two-factor authentication. "
"Please generate an app-specific password at https://appleid.apple.com "
"and use it as ICLOUD_PASSWORD."
)
return api
def _navigate_to_folder(drive_root, folder_path: str):
"""Navigate into (or create) the folder hierarchy described by *folder_path*.
Args:
drive_root: The iCloud Drive root node (``api.drive``).
folder_path: ``/``-separated path such as ``Documents/Uploads``.
Returns:
The drive node representing the target folder.
"""
node = drive_root
if not folder_path:
return node
parts = [p for p in folder_path.strip("/").split("/") if p]
for part in parts:
children = {child.name: child for child in node.dir()}
if part in children:
node = children[part]
else:
# Create the missing folder
node = node.mkdir(part)
return node
@celery.task(base=UploadTaskWithRetry, bind=True)
def upload_to_icloud(self, file_path: str, file_id: int = None, folder_override: str = None):
"""Upload a file to Apple iCloud Drive.
Args:
file_path: Local path to the file to upload.
file_id: Optional ``FileRecord.id`` for progress logging.
folder_override: If provided, overrides the default ``ICLOUD_FOLDER``
setting for this upload.
"""
task_id = self.request.id
logger.info(f"[{task_id}] Starting iCloud Drive upload: {file_path}")
log_task_progress(
task_id,
"upload_to_icloud",
"in_progress",
f"Uploading to iCloud Drive: {os.path.basename(file_path)}",
file_id=file_id,
)
# ------------------------------------------------------------------
# Validate inputs
# ------------------------------------------------------------------
if not os.path.exists(file_path):
error_msg = f"File not found: {file_path}"
logger.error(f"[{task_id}] {error_msg}")
log_task_progress(task_id, "upload_to_icloud", "failure", error_msg, file_id=file_id)
raise FileNotFoundError(error_msg)
if not settings.icloud_username or not settings.icloud_password:
error_msg = "iCloud credentials are not configured (ICLOUD_USERNAME / ICLOUD_PASSWORD)"
logger.error(f"[{task_id}] {error_msg}")
log_task_progress(task_id, "upload_to_icloud", "failure", error_msg, file_id=file_id)
raise ValueError(error_msg)
filename = os.path.basename(file_path)
target_folder = folder_override if folder_override is not None else (settings.icloud_folder or "")
# ------------------------------------------------------------------
# Authenticate & upload
# ------------------------------------------------------------------
try:
api = _get_icloud_api(
settings.icloud_username,
settings.icloud_password,
settings.icloud_cookie_directory,
)
folder_node = _navigate_to_folder(api.drive, target_folder)
with open(file_path, "rb") as fh:
folder_node.upload(fh)
logger.info(f"[{task_id}] Successfully uploaded {filename} to iCloud Drive folder '{target_folder}'")
log_task_progress(
task_id,
"upload_to_icloud",
"success",
f"Uploaded to iCloud Drive: {filename}",
file_id=file_id,
)
return {
"status": "Completed",
"file": file_path,
"icloud_folder": target_folder or "/",
}
except Exception as e:
error_msg = f"Error uploading {filename} to iCloud Drive: {e}"
logger.error(f"[{task_id}] {error_msg}")
log_task_progress(task_id, "upload_to_icloud", "failure", error_msg, file_id=file_id)
raise RuntimeError(error_msg) from e
+338
View File
@@ -0,0 +1,338 @@
#!/usr/bin/env python3
"""Upload documents to Microsoft SharePoint via the Microsoft Graph API.
This module authenticates using MSAL (same OAuth2 flow as OneDrive) and
uploads files to a configurable SharePoint Online document library using
the chunked upload session approach for reliability with large files.
Key differences from the OneDrive provider:
- Uses ``/sites/{siteId}/drives/{driveId}`` instead of ``/me/drive``
- Requires a SharePoint site URL to resolve the site and drive IDs
- Targets a named document library (default: ``Documents``)
"""
import logging
import os
import time
import urllib.parse
import msal
import requests
from app.celery_app import celery
from app.config import settings
from app.tasks.retry_config import UploadTaskWithRetry
from app.utils import log_task_progress
logger = logging.getLogger(__name__)
def get_sharepoint_token() -> str:
"""Acquire a Microsoft Graph API access token for SharePoint.
Uses MSAL ``ConfidentialClientApplication`` with the refresh-token flow
(delegated permissions) or the client-credentials flow (application
permissions) depending on configuration.
Returns:
A valid access token string.
Raises:
ValueError: When required settings are missing or token acquisition fails.
"""
if not settings.sharepoint_client_id or not settings.sharepoint_client_secret:
raise ValueError("SharePoint client ID and client secret must be configured")
tenant = settings.sharepoint_tenant_id or "common"
logger.info("Using SharePoint tenant: %s", tenant)
scopes = ["https://graph.microsoft.com/.default"]
if settings.sharepoint_refresh_token:
app = msal.ConfidentialClientApplication(
client_id=settings.sharepoint_client_id,
client_credential=settings.sharepoint_client_secret,
authority=f"https://login.microsoftonline.com/{tenant}",
)
logger.info("Attempting to acquire SharePoint token using refresh token")
token_response = app.acquire_token_by_refresh_token(
refresh_token=settings.sharepoint_refresh_token, scopes=scopes
)
if "access_token" not in token_response:
error = token_response.get("error", "")
error_desc = token_response.get("error_description", "Unknown error")
logger.error("Failed to get SharePoint access token: %s - %s", error, error_desc)
raise ValueError(f"Failed to get SharePoint access token: {error} - {error_desc}")
if "refresh_token" in token_response:
settings.sharepoint_refresh_token = token_response["refresh_token"]
logger.info("Updated SharePoint refresh token in memory")
return token_response["access_token"]
elif settings.sharepoint_tenant_id and settings.sharepoint_tenant_id != "common":
authority = f"https://login.microsoftonline.com/{settings.sharepoint_tenant_id}"
app = msal.ConfidentialClientApplication(
client_id=settings.sharepoint_client_id,
client_credential=settings.sharepoint_client_secret,
authority=authority,
)
token_response = app.acquire_token_for_client(scopes=scopes)
if "access_token" not in token_response:
error = token_response.get("error", "")
error_desc = token_response.get("error_description", "Unknown error")
raise ValueError(f"Failed to get SharePoint access token: {error} - {error_desc}")
return token_response["access_token"]
else:
raise ValueError("For SharePoint, either a refresh token or a non-'common' tenant ID is required")
def resolve_sharepoint_drive(access_token: str, site_url: str, library_name: str) -> tuple[str, str]:
"""Resolve the Graph API site ID and drive ID for a SharePoint site.
Args:
access_token: Valid Microsoft Graph API token.
site_url: Full SharePoint site URL, e.g.
``https://tenant.sharepoint.com/sites/sitename``.
library_name: Display name of the document library (e.g. ``Documents``).
Returns:
A ``(site_id, drive_id)`` tuple.
Raises:
ValueError: When the site URL cannot be parsed.
RuntimeError: When the Graph API call fails.
"""
parsed = urllib.parse.urlparse(site_url)
hostname = parsed.hostname
site_path = parsed.path.rstrip("/")
if not hostname or not site_path:
raise ValueError(
f"Invalid SharePoint site URL '{site_url}'. Expected format: https://tenant.sharepoint.com/sites/sitename"
)
headers = {"Authorization": f"Bearer {access_token}"}
# Resolve site ID
site_api_url = f"https://graph.microsoft.com/v1.0/sites/{hostname}:{site_path}"
logger.info("Resolving SharePoint site: %s", site_api_url)
resp = requests.get(site_api_url, headers=headers, timeout=settings.http_request_timeout)
if resp.status_code != 200:
raise RuntimeError(f"Failed to resolve SharePoint site: {resp.status_code} - {resp.text}")
site_id = resp.json()["id"]
logger.info("Resolved SharePoint site ID: %s", site_id)
# Resolve drive ID from the document library name
drives_url = f"https://graph.microsoft.com/v1.0/sites/{site_id}/drives"
resp = requests.get(drives_url, headers=headers, timeout=settings.http_request_timeout)
if resp.status_code != 200:
raise RuntimeError(f"Failed to list SharePoint drives: {resp.status_code} - {resp.text}")
drives = resp.json().get("value", [])
drive_id = None
for drive in drives:
if drive.get("name", "").lower() == library_name.lower():
drive_id = drive["id"]
break
if not drive_id:
available = [d.get("name") for d in drives]
raise RuntimeError(f"Document library '{library_name}' not found on site. Available libraries: {available}")
logger.info("Resolved SharePoint drive ID: %s (library: %s)", drive_id, library_name)
return site_id, drive_id
def create_sharepoint_upload_session(
filename: str, folder_path: str | None, drive_id: str, site_id: str, access_token: str
) -> str:
"""Create a resumable upload session on a SharePoint document library.
Args:
filename: Name of the file to upload.
folder_path: Optional subfolder path inside the library.
drive_id: Graph API drive ID of the document library.
site_id: Graph API site ID.
access_token: Valid access token.
Returns:
The upload session URL for chunked PUT requests.
Raises:
RuntimeError: When session creation fails.
"""
base_url = f"https://graph.microsoft.com/v1.0/sites/{site_id}/drives/{drive_id}"
if folder_path:
folder_path = folder_path.strip("/")
path_components = folder_path.split("/")
encoded_path = "/".join(urllib.parse.quote(component) for component in path_components)
encoded_filename = urllib.parse.quote(filename)
item_path = f"/root:/{encoded_path}/{encoded_filename}:/createUploadSession"
else:
encoded_filename = urllib.parse.quote(filename)
item_path = f"/root:/{encoded_filename}:/createUploadSession"
url = f"{base_url}{item_path}"
request_body = {"item": {"@microsoft.graph.conflictBehavior": "replace"}}
headers = {"Authorization": f"Bearer {access_token}", "Content-Type": "application/json"}
logger.info("Creating SharePoint upload session for %s at path %s", filename, folder_path)
response = requests.post(url, headers=headers, json=request_body, timeout=settings.http_request_timeout)
if response.status_code == 200:
upload_url = response.json().get("uploadUrl")
logger.info("SharePoint upload session created for %s", filename)
return upload_url
else:
raise RuntimeError(f"Failed to create SharePoint upload session: {response.status_code} - {response.text}")
def upload_large_file_sharepoint(file_path: str, upload_url: str) -> dict:
"""Upload a file to SharePoint using a chunked upload session.
Args:
file_path: Local path to the file.
upload_url: The upload session URL from ``create_sharepoint_upload_session``.
Returns:
The Graph API response dict containing file metadata.
Raises:
RuntimeError: When a chunk upload fails after retries.
"""
file_size = os.path.getsize(file_path)
chunk_size = 10 * 1024 * 1024 # 10 MB
response = None
with open(file_path, "rb") as f:
chunk_number = 0
while True:
chunk = f.read(chunk_size)
if not chunk:
break
chunk_start = chunk_number * chunk_size
chunk_end = chunk_start + len(chunk) - 1
content_range = f"bytes {chunk_start}-{chunk_end}/{file_size}"
headers = {"Content-Length": str(len(chunk)), "Content-Range": content_range}
max_retries = 3
retry_delay = 2
for attempt in range(max_retries):
try:
response = requests.put(
upload_url, headers=headers, data=chunk, timeout=settings.http_request_timeout
)
if response.status_code in (201, 202):
break
else:
logger.warning(
"SharePoint chunk upload failed (attempt %d): %d", attempt + 1, response.status_code
)
if attempt < max_retries - 1:
time.sleep(retry_delay * (attempt + 1))
except Exception as e:
logger.warning("SharePoint chunk upload error (attempt %d): %s", attempt + 1, str(e))
if attempt < max_retries - 1:
time.sleep(retry_delay * (attempt + 1))
if response is None or response.status_code not in (201, 202):
status = response.status_code if response else "no response"
text = response.text if response else ""
raise RuntimeError(f"Failed to upload chunk after {max_retries} attempts: {status} - {text}")
chunk_number += 1
return response.json() if response else {}
@celery.task(base=UploadTaskWithRetry, bind=True)
def upload_to_sharepoint(self, file_path: str, file_id: int = None, folder_override: str = None):
"""Upload a file to SharePoint Online.
Args:
file_path: Path to the file to upload.
file_id: Optional file ID to associate with logs.
folder_override: Optional folder path override.
Returns:
A dict with upload status and file details.
Raises:
FileNotFoundError: When the file does not exist.
ValueError: When SharePoint is not configured.
RuntimeError: When the upload fails.
"""
task_id = self.request.id
logger.info("[%s] Starting SharePoint upload: %s", task_id, file_path)
log_task_progress(
task_id,
"upload_to_sharepoint",
"in_progress",
f"Uploading to SharePoint: {os.path.basename(file_path)}",
file_id=file_id,
)
if not os.path.exists(file_path):
error_msg = f"File not found: {file_path}"
logger.error("[%s] %s", task_id, error_msg)
log_task_progress(task_id, "upload_to_sharepoint", "failure", error_msg, file_id=file_id)
raise FileNotFoundError(error_msg)
filename = os.path.basename(file_path)
if not settings.sharepoint_client_id:
error_msg = "SharePoint client ID is not configured"
logger.error("[%s] %s", task_id, error_msg)
log_task_progress(task_id, "upload_to_sharepoint", "failure", error_msg, file_id=file_id)
raise ValueError(error_msg)
if not settings.sharepoint_site_url:
error_msg = "SharePoint site URL is not configured"
logger.error("[%s] %s", task_id, error_msg)
log_task_progress(task_id, "upload_to_sharepoint", "failure", error_msg, file_id=file_id)
raise ValueError(error_msg)
try:
access_token = get_sharepoint_token()
library_name = settings.sharepoint_document_library or "Documents"
site_id, drive_id = resolve_sharepoint_drive(access_token, settings.sharepoint_site_url, library_name)
folder_path = folder_override if folder_override is not None else settings.sharepoint_folder_path
upload_url = create_sharepoint_upload_session(filename, folder_path, drive_id, site_id, access_token)
result = upload_large_file_sharepoint(file_path, upload_url)
web_url = result.get("webUrl", "Not available")
logger.info("[%s] Successfully uploaded %s to SharePoint", task_id, filename)
logger.info("[%s] File accessible at: %s", task_id, web_url)
log_task_progress(
task_id, "upload_to_sharepoint", "success", f"Uploaded to SharePoint: {filename}", file_id=file_id
)
return {
"status": "Completed",
"file_path": file_path,
"sharepoint_path": f"{folder_path or ''}/{filename}",
"web_url": web_url,
}
except Exception as e:
error_msg = f"Failed to upload {filename} to SharePoint: {str(e)}"
logger.error("[%s] %s", task_id, error_msg)
log_task_progress(task_id, "upload_to_sharepoint", "failure", error_msg, file_id=file_id)
raise RuntimeError(error_msg) from e
+872
View File
@@ -0,0 +1,872 @@
#!/usr/bin/env python3
"""
Upload dispatcher for user-specific destination integrations.
This module provides a Celery task that uploads a processed document to a
specific ``UserIntegration`` record using that integration's own stored
config and decrypted credentials instead of the global application settings.
It is the per-destination counterpart of :func:`send_to_all_destinations`
and is dispatched by :func:`send_to_user_destinations` once per active
DESTINATION integration that belongs to the document's owner.
Credential shapes per integration type (mirrors the UserIntegration docstring):
DROPBOX credentials = {"refresh_token", "app_key", "app_secret"}
config = {"folder": "/DocuElevate"}
S3 credentials = {"access_key_id", "secret_access_key"}
config = {"bucket", "region", "endpoint_url", "folder_prefix"}
GOOGLE_DRIVE
OAuth credentials = {"client_id", "client_secret", "refresh_token"}
config = {"folder_id"}
SA credentials = {"credentials_json"}
config = {"folder_id"}
ONEDRIVE credentials = {"client_id", "client_secret", "refresh_token"}
config = {"folder_path", "tenant_id"}
WEBDAV /
NEXTCLOUD credentials = {"username", "password"}
config = {"url", "folder"}
FTP credentials = {"password"}
config = {"host", "username", "port", "folder", "use_tls"}
SFTP credentials = {"password"} or {"private_key"}
config = {"host", "username", "port", "folder"}
EMAIL credentials = {"password"}
config = {"host", "username", "port", "recipient",
"use_tls", "sender_name"}
PAPERLESS credentials = {"api_token"}
config = {"host"}
RCLONE credentials = {"rclone_conf"} (full rclone config file text)
config = {"remote": "myremote:", "folder": "dest/path"}
"""
import ftplib # nosec B402
import json
import logging
import os
import subprocess # nosec B404
from datetime import datetime, timezone
from typing import Any
from urllib.parse import urljoin
from app.celery_app import celery
from app.database import SessionLocal
from app.models import IntegrationType, UserIntegration
from app.tasks.retry_config import UploadTaskWithRetry
from app.utils.encryption import decrypt_value
from app.utils.logging import log_task_progress
logger = logging.getLogger(__name__)
# Maximum characters to store in UserIntegration.last_error
_MAX_ERROR_LENGTH = 500
# ---------------------------------------------------------------------------
# Per-type upload helpers
# ---------------------------------------------------------------------------
def _upload_dropbox(file_path: str, cfg: dict[str, Any], creds: dict[str, Any], task_id: str) -> dict[str, Any]:
"""Upload *file_path* to Dropbox using per-user OAuth credentials."""
import dropbox
app_key = creds.get("app_key") or ""
app_secret = creds.get("app_secret") or ""
refresh_token = creds.get("refresh_token") or ""
if not (app_key and app_secret and refresh_token):
raise ValueError("Dropbox integration is missing app_key, app_secret or refresh_token in credentials")
dbx = dropbox.Dropbox(app_key=app_key, app_secret=app_secret, oauth2_refresh_token=refresh_token)
remote_folder = cfg.get("folder", "/DocuElevate").rstrip("/")
filename = os.path.basename(file_path)
remote_path = f"{remote_folder}/{filename}"
if not remote_path.startswith("/"):
remote_path = "/" + remote_path
file_size = os.path.getsize(file_path)
with open(file_path, "rb") as fh:
if file_size > 10 * 1024 * 1024:
chunk_size = 4 * 1024 * 1024
session_start = dbx.files_upload_session_start(fh.read(chunk_size))
cursor = dropbox.files.UploadSessionCursor(session_start.session_id, fh.tell())
while fh.tell() < file_size:
if (file_size - fh.tell()) <= chunk_size:
dbx.files_upload_session_finish(
fh.read(chunk_size),
cursor,
dropbox.files.CommitInfo(path=remote_path, mode=dropbox.files.WriteMode.overwrite),
)
else:
dbx.files_upload_session_append_v2(fh.read(chunk_size), cursor)
cursor.offset = fh.tell()
else:
dbx.files_upload(fh.read(), remote_path, mode=dropbox.files.WriteMode.overwrite)
logger.info("[%s] Dropbox upload complete: %s", task_id, remote_path)
return {"status": "Completed", "dropbox_path": remote_path}
def _upload_s3(file_path: str, cfg: dict[str, Any], creds: dict[str, Any], task_id: str) -> dict[str, Any]:
"""Upload *file_path* to Amazon S3 (or S3-compatible) using per-user credentials."""
import boto3
from botocore.exceptions import ClientError
bucket = cfg.get("bucket") or ""
region = cfg.get("region") or "us-east-1"
endpoint_url = cfg.get("endpoint_url") or None
folder_prefix = cfg.get("folder_prefix") or ""
storage_class = cfg.get("storage_class") or "STANDARD"
access_key = creds.get("access_key_id") or ""
secret_key = creds.get("secret_access_key") or ""
if not bucket:
raise ValueError("S3 integration is missing bucket in config")
if not (access_key and secret_key):
raise ValueError("S3 integration is missing access_key_id or secret_access_key in credentials")
client_kwargs: dict[str, Any] = {
"region_name": region,
"aws_access_key_id": access_key,
"aws_secret_access_key": secret_key,
}
if endpoint_url:
client_kwargs["endpoint_url"] = endpoint_url
s3 = boto3.client("s3", **client_kwargs)
filename = os.path.basename(file_path)
s3_key = f"{folder_prefix.rstrip('/')}/{filename}" if folder_prefix else filename
try:
s3.upload_file(file_path, bucket, s3_key, ExtraArgs={"StorageClass": storage_class})
except ClientError as exc:
raise RuntimeError(f"S3 upload failed: {exc}") from exc
logger.info("[%s] S3 upload complete: s3://%s/%s", task_id, bucket, s3_key)
return {"status": "Completed", "s3_bucket": bucket, "s3_key": s3_key}
def _upload_google_drive(file_path: str, cfg: dict[str, Any], creds: dict[str, Any], task_id: str) -> dict[str, Any]:
"""Upload *file_path* to Google Drive using per-user OAuth or service-account credentials."""
from googleapiclient.discovery import build
from googleapiclient.http import MediaFileUpload
folder_id = cfg.get("folder_id") or ""
filename = os.path.basename(file_path)
# Prefer OAuth credentials (client_id + client_secret + refresh_token)
client_id = creds.get("client_id") or ""
client_secret = creds.get("client_secret") or ""
refresh_token = creds.get("refresh_token") or ""
credentials_json = creds.get("credentials_json") or ""
if client_id and client_secret and refresh_token:
from google.auth.transport.requests import Request
from google.oauth2.credentials import Credentials as OAuthCredentials
google_creds = OAuthCredentials(
None,
refresh_token=refresh_token,
token_uri="https://oauth2.googleapis.com/token",
client_id=client_id,
client_secret=client_secret,
scopes=["https://www.googleapis.com/auth/drive.file"],
)
google_creds.refresh(Request())
service = build("drive", "v3", credentials=google_creds)
elif credentials_json:
from google.oauth2.service_account import Credentials as SACredentials
creds_dict = json.loads(credentials_json)
sa_creds = SACredentials.from_service_account_info(creds_dict, scopes=["https://www.googleapis.com/auth/drive"])
service = build("drive", "v3", credentials=sa_creds)
else:
raise ValueError("Google Drive integration requires either OAuth credentials or credentials_json")
file_metadata: dict[str, Any] = {"name": filename}
if folder_id:
file_metadata["parents"] = [folder_id]
media = MediaFileUpload(file_path, mimetype="application/pdf", resumable=True)
file_obj = service.files().create(body=file_metadata, media_body=media, fields="id,name,webViewLink").execute()
gdrive_id = file_obj.get("id")
web_link = file_obj.get("webViewLink")
logger.info("[%s] Google Drive upload complete: %s (%s)", task_id, gdrive_id, web_link)
return {"status": "Completed", "google_drive_file_id": gdrive_id, "web_link": web_link}
def _upload_onedrive(file_path: str, cfg: dict[str, Any], creds: dict[str, Any], task_id: str) -> dict[str, Any]:
"""Upload *file_path* to OneDrive using per-user MSAL credentials."""
import urllib.parse
import msal
import requests as _requests
client_id = creds.get("client_id") or ""
client_secret = creds.get("client_secret") or ""
refresh_token = creds.get("refresh_token") or ""
tenant = cfg.get("tenant_id") or "common"
folder_path = cfg.get("folder_path") or ""
if not (client_id and client_secret):
raise ValueError("OneDrive integration is missing client_id or client_secret in credentials")
scopes = ["https://graph.microsoft.com/.default"]
msal_app = msal.ConfidentialClientApplication(
client_id=client_id,
client_credential=client_secret,
authority=f"https://login.microsoftonline.com/{tenant}",
)
if refresh_token:
token_resp = msal_app.acquire_token_by_refresh_token(refresh_token=refresh_token, scopes=scopes)
else:
token_resp = msal_app.acquire_token_for_client(scopes=scopes)
if "access_token" not in token_resp:
raise ValueError(f"OneDrive token acquisition failed: {token_resp.get('error_description', 'unknown')}")
access_token = token_resp["access_token"]
filename = os.path.basename(file_path)
# Build upload-session URL
if folder_path:
folder_path = folder_path.strip("/")
encoded_path = "/".join(urllib.parse.quote(p) for p in folder_path.split("/"))
encoded_file = urllib.parse.quote(filename)
item_path = f"/root:/{encoded_path}/{encoded_file}:/createUploadSession"
else:
encoded_file = urllib.parse.quote(filename)
item_path = f"/root:/{encoded_file}:/createUploadSession"
session_url = f"https://graph.microsoft.com/v1.0/me/drive{item_path}"
headers = {"Authorization": f"Bearer {access_token}", "Content-Type": "application/json"}
resp = _requests.post(
session_url, headers=headers, json={"item": {"@microsoft.graph.conflictBehavior": "replace"}}, timeout=30
)
resp.raise_for_status()
upload_url = resp.json()["uploadUrl"]
file_size = os.path.getsize(file_path)
chunk_size = 10 * 1024 * 1024
with open(file_path, "rb") as fh:
chunk_num = 0
while True:
chunk = fh.read(chunk_size)
if not chunk:
break
start = chunk_num * chunk_size
end = start + len(chunk) - 1
upload_headers = {
"Content-Length": str(len(chunk)),
"Content-Range": f"bytes {start}-{end}/{file_size}",
}
upload_resp = _requests.put(upload_url, headers=upload_headers, data=chunk, timeout=120)
if upload_resp.status_code not in (201, 202):
raise RuntimeError(f"OneDrive chunk upload failed: {upload_resp.status_code}")
chunk_num += 1
logger.info("[%s] OneDrive upload complete: %s/%s", task_id, folder_path, filename)
return {"status": "Completed", "onedrive_folder": folder_path, "filename": filename}
def _upload_webdav(file_path: str, cfg: dict[str, Any], creds: dict[str, Any], task_id: str) -> dict[str, Any]:
"""Upload *file_path* to a WebDAV server using per-user credentials."""
import requests as _requests
url = cfg.get("url") or ""
folder = cfg.get("folder") or ""
username = creds.get("username") or ""
password = creds.get("password") or ""
verify_ssl = cfg.get("verify_ssl", True)
if not url:
raise ValueError("WebDAV integration is missing url in config")
filename = os.path.basename(file_path)
folder = folder.lstrip("/")
target = urljoin(url.rstrip("/") + "/", folder)
if not target.endswith("/"):
target += "/"
dest = urljoin(target, filename)
with open(file_path, "rb") as fh:
resp = _requests.put(dest, auth=(username, password), data=fh, verify=verify_ssl, timeout=120)
if resp.status_code not in (200, 201, 204):
raise RuntimeError(f"WebDAV upload failed: {resp.status_code} {resp.text[:200]}")
logger.info("[%s] WebDAV upload complete: %s", task_id, dest)
return {"status": "Completed", "webdav_url": dest}
def _upload_nextcloud(file_path: str, cfg: dict[str, Any], creds: dict[str, Any], task_id: str) -> dict[str, Any]:
"""Upload *file_path* to Nextcloud (WebDAV) using per-user credentials."""
# Nextcloud uses WebDAV under the hood; reuse the WebDAV helper.
return _upload_webdav(file_path, cfg, creds, task_id)
def _upload_ftp(file_path: str, cfg: dict[str, Any], creds: dict[str, Any], task_id: str) -> dict[str, Any]:
"""Upload *file_path* to an FTP/FTPS server using per-user credentials."""
host = cfg.get("host") or ""
port = int(cfg.get("port") or 21)
username = cfg.get("username") or ""
folder = cfg.get("folder") or ""
use_tls = cfg.get("use_tls", True)
password = creds.get("password") or ""
filename = os.path.basename(file_path)
if not host:
raise ValueError("FTP integration is missing host in config")
ftp: ftplib.FTP
if use_tls:
ftp = ftplib.FTP_TLS() # nosec B321 # noqa: S321
ftp.connect(host=host, port=port)
ftp.login(user=username, passwd=password)
ftp.prot_p()
else:
ftp = ftplib.FTP() # nosec B321 # noqa: S321
ftp.connect(host=host, port=port)
ftp.login(user=username, passwd=password)
if folder:
folder_stripped = folder.lstrip("/")
try:
ftp.cwd(folder_stripped)
except ftplib.error_perm:
parts = folder_stripped.split("/")
current = ""
for part in parts:
if not part:
continue
current += f"/{part}"
try:
ftp.cwd(current)
except ftplib.error_perm:
ftp.mkd(current)
ftp.cwd(current)
with open(file_path, "rb") as fh:
ftp.storbinary(f"STOR {filename}", fh)
ftp.quit()
logger.info("[%s] FTP upload complete: %s/%s", task_id, host, filename)
return {"status": "Completed", "ftp_host": host, "filename": filename}
def _upload_sftp(file_path: str, cfg: dict[str, Any], creds: dict[str, Any], task_id: str) -> dict[str, Any]:
"""Upload *file_path* to an SFTP server using per-user credentials."""
import paramiko
host = cfg.get("host") or ""
port = int(cfg.get("port") or 22)
username = cfg.get("username") or ""
folder = cfg.get("folder") or ""
password = creds.get("password") or ""
private_key_text = creds.get("private_key") or ""
filename = os.path.basename(file_path)
if not host:
raise ValueError("SFTP integration is missing host in config")
ssh = paramiko.SSHClient()
ssh.load_system_host_keys()
ssh.set_missing_host_key_policy(paramiko.RejectPolicy())
connect_kwargs: dict[str, Any] = {"hostname": host, "port": port, "username": username}
if private_key_text:
import io
pkey = paramiko.RSAKey.from_private_key(io.StringIO(private_key_text))
connect_kwargs["pkey"] = pkey
elif password:
connect_kwargs["password"] = password
else:
raise ValueError("SFTP integration requires password or private_key in credentials")
ssh.connect(**connect_kwargs)
sftp = ssh.open_sftp()
remote_path = f"{folder.rstrip('/')}/{filename}" if folder else filename
if folder and folder.startswith("/") and not remote_path.startswith("/"):
remote_path = "/" + remote_path
sftp.put(file_path, remote_path)
sftp.close()
ssh.close()
logger.info("[%s] SFTP upload complete: %s:%s", task_id, host, remote_path)
return {"status": "Completed", "sftp_host": host, "sftp_path": remote_path}
def _upload_paperless(file_path: str, cfg: dict[str, Any], creds: dict[str, Any], task_id: str) -> dict[str, Any]:
"""Upload *file_path* to a Paperless-ngx instance using per-user API token."""
import time
import requests as _requests
host = (cfg.get("host") or "").rstrip("/")
api_token = creds.get("api_token") or ""
filename = os.path.basename(file_path)
if not host:
raise ValueError("Paperless integration is missing host in config")
if not api_token:
raise ValueError("Paperless integration is missing api_token in credentials")
headers = {"Authorization": f"Token {api_token}"}
post_url = f"{host}/api/documents/post_document/"
with open(file_path, "rb") as fh:
resp = _requests.post(
post_url,
headers=headers,
files={"document": (filename, fh, "application/pdf")},
data={"title": filename},
timeout=120,
)
resp.raise_for_status()
raw_task_id = resp.text.strip().strip('"').strip("'")
# Poll for completion (up to 30 s)
task_url = f"{host}/api/tasks/"
doc_id = None
for _ in range(10):
time.sleep(3)
try:
poll_resp = _requests.get(task_url, headers=headers, params={"task_id": raw_task_id}, timeout=30)
poll_resp.raise_for_status()
tasks_data = poll_resp.json()
if isinstance(tasks_data, dict) and "results" in tasks_data:
tasks_data = tasks_data["results"]
if tasks_data:
info = tasks_data[0]
status = info.get("status")
if status == "SUCCESS":
doc_id = info.get("related_document")
break
elif status == "FAILURE":
raise RuntimeError(f"Paperless processing failed: {info.get('result')}")
except RuntimeError:
raise
except Exception as poll_exc:
logger.warning("[%s] Paperless poll error: %s", task_id, poll_exc)
logger.info("[%s] Paperless upload complete: doc_id=%s", task_id, doc_id)
return {"status": "Completed", "paperless_host": host, "paperless_document_id": doc_id}
def _upload_email(file_path: str, cfg: dict[str, Any], creds: dict[str, Any], task_id: str) -> dict[str, Any]:
"""Send *file_path* as an email attachment using per-user SMTP credentials."""
import smtplib
from email.mime.application import MIMEApplication
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
host = cfg.get("host") or ""
port = int(cfg.get("port") or 587)
username = cfg.get("username") or ""
recipient = cfg.get("recipient") or ""
use_tls = cfg.get("use_tls", True)
sender_name = cfg.get("sender_name") or "DocuElevate"
password = creds.get("password") or ""
filename = os.path.basename(file_path)
if not (host and recipient):
raise ValueError("Email integration is missing host or recipient in config")
msg = MIMEMultipart()
msg["From"] = f"{sender_name} <{username}>" if username else sender_name
msg["To"] = recipient
msg["Subject"] = f"Document: {filename}"
msg.attach(MIMEText(f"Please find the attached document: {filename}", "plain"))
with open(file_path, "rb") as fh:
part = MIMEApplication(fh.read(), Name=filename)
part["Content-Disposition"] = f'attachment; filename="{filename}"'
msg.attach(part)
if use_tls:
import ssl
tls_context = ssl.create_default_context()
with smtplib.SMTP(host, port, timeout=30) as smtp:
smtp.starttls(context=tls_context)
if username and password:
smtp.login(username, password)
smtp.sendmail(msg["From"], [recipient], msg.as_string())
else:
# Plaintext SMTP — only use when explicitly configured and TLS is unavailable.
# Credentials and content will be transmitted without encryption.
with smtplib.SMTP(host, port, timeout=30) as smtp: # nosec B608
if username and password:
smtp.login(username, password)
smtp.sendmail(msg["From"], [recipient], msg.as_string())
logger.info("[%s] Email upload complete: sent to %s", task_id, recipient)
return {"status": "Completed", "recipient": recipient}
def _upload_rclone(file_path: str, cfg: dict[str, Any], creds: dict[str, Any], task_id: str) -> dict[str, Any]:
"""Copy *file_path* to an rclone remote using per-user rclone config."""
import re
import tempfile
remote = cfg.get("remote") or ""
folder = cfg.get("folder") or ""
rclone_conf_text = creds.get("rclone_conf") or ""
filename = os.path.basename(file_path)
if not remote:
raise ValueError("Rclone integration is missing remote in config")
if not rclone_conf_text:
raise ValueError("Rclone integration is missing rclone_conf in credentials")
# Validate remote and folder to prevent shell metacharacter injection.
# rclone remote names are alphanumeric + hyphens/underscores followed by ':'.
# folder paths must not contain shell-dangerous characters.
_SAFE_REMOTE_RE = re.compile(r"^[A-Za-z0-9_\-]+:(/[A-Za-z0-9_.@\-/ ]*)?$")
_SAFE_FOLDER_RE = re.compile(r"^[A-Za-z0-9_.@\-/ ]*$")
if not _SAFE_REMOTE_RE.match(remote):
raise ValueError(f"Rclone remote contains unsafe characters: {remote!r}")
if folder and not _SAFE_FOLDER_RE.match(folder):
raise ValueError(f"Rclone folder contains unsafe characters: {folder!r}")
# Write the user's rclone config to a temp file so we don't touch the system config
with tempfile.NamedTemporaryFile(mode="w", suffix=".conf", delete=False) as tmp_conf:
tmp_conf.write(rclone_conf_text)
conf_path = tmp_conf.name
dest = f"{remote.rstrip('/')}/{folder.strip('/')}/{filename}" if folder else f"{remote.rstrip('/')}/{filename}"
dest = dest.replace("//", "/")
try:
# SECURITY: Separate options from positional arguments using -- to prevent command injection
result = subprocess.run( # nosec B603 # noqa: S603 S607
["rclone", "copyto", f"--config={conf_path}", "--", file_path, dest], # noqa: S603 S607
capture_output=True,
text=True,
timeout=300,
check=False,
)
if result.returncode != 0:
raise RuntimeError(f"rclone exited {result.returncode}: {result.stderr[:300]}")
finally:
os.unlink(conf_path)
logger.info("[%s] Rclone upload complete: %s", task_id, dest)
return {"status": "Completed", "rclone_dest": dest}
def _upload_sharepoint(file_path: str, cfg: dict[str, Any], creds: dict[str, Any], task_id: str) -> dict[str, Any]:
"""Upload *file_path* to SharePoint using per-user MSAL credentials."""
import urllib.parse
import msal
import requests as _requests
client_id = creds.get("client_id") or ""
client_secret = creds.get("client_secret") or ""
refresh_token = creds.get("refresh_token") or ""
tenant = cfg.get("tenant_id") or "common"
site_url = cfg.get("site_url") or ""
library_name = cfg.get("document_library") or "Documents"
folder_path = cfg.get("folder_path") or ""
if not (client_id and client_secret):
raise ValueError("SharePoint integration is missing client_id or client_secret in credentials")
if not site_url:
raise ValueError("SharePoint integration is missing site_url in config")
scopes = ["https://graph.microsoft.com/.default"]
msal_app = msal.ConfidentialClientApplication(
client_id=client_id,
client_credential=client_secret,
authority=f"https://login.microsoftonline.com/{tenant}",
)
if refresh_token:
token_resp = msal_app.acquire_token_by_refresh_token(refresh_token=refresh_token, scopes=scopes)
else:
token_resp = msal_app.acquire_token_for_client(scopes=scopes)
if "access_token" not in token_resp:
raise ValueError(f"SharePoint token acquisition failed: {token_resp.get('error_description', 'unknown')}")
access_token = token_resp["access_token"]
headers = {"Authorization": f"Bearer {access_token}"}
# Resolve site ID
parsed = urllib.parse.urlparse(site_url)
hostname = parsed.hostname
site_path = parsed.path.rstrip("/")
if not hostname or not site_path:
raise ValueError(f"Invalid SharePoint site URL: {site_url}")
resp = _requests.get(f"https://graph.microsoft.com/v1.0/sites/{hostname}:{site_path}", headers=headers, timeout=30)
resp.raise_for_status()
site_id = resp.json()["id"]
# Resolve drive ID
resp = _requests.get(f"https://graph.microsoft.com/v1.0/sites/{site_id}/drives", headers=headers, timeout=30)
resp.raise_for_status()
drive_id = None
for drive in resp.json().get("value", []):
if drive.get("name", "").lower() == library_name.lower():
drive_id = drive["id"]
break
if not drive_id:
raise RuntimeError(f"Document library '{library_name}' not found on SharePoint site")
filename = os.path.basename(file_path)
# Build upload-session URL
base_url = f"https://graph.microsoft.com/v1.0/sites/{site_id}/drives/{drive_id}"
if folder_path:
folder_path = folder_path.strip("/")
encoded_path = "/".join(urllib.parse.quote(p) for p in folder_path.split("/"))
encoded_file = urllib.parse.quote(filename)
item_path = f"/root:/{encoded_path}/{encoded_file}:/createUploadSession"
else:
encoded_file = urllib.parse.quote(filename)
item_path = f"/root:/{encoded_file}:/createUploadSession"
session_url = f"{base_url}{item_path}"
session_headers = {"Authorization": f"Bearer {access_token}", "Content-Type": "application/json"}
resp = _requests.post(
session_url,
headers=session_headers,
json={"item": {"@microsoft.graph.conflictBehavior": "replace"}},
timeout=30,
)
resp.raise_for_status()
upload_url = resp.json()["uploadUrl"]
file_size = os.path.getsize(file_path)
chunk_size = 10 * 1024 * 1024
with open(file_path, "rb") as fh:
chunk_num = 0
while True:
chunk = fh.read(chunk_size)
if not chunk:
break
start = chunk_num * chunk_size
end = start + len(chunk) - 1
upload_headers = {
"Content-Length": str(len(chunk)),
"Content-Range": f"bytes {start}-{end}/{file_size}",
}
upload_resp = _requests.put(upload_url, headers=upload_headers, data=chunk, timeout=120)
if upload_resp.status_code not in (201, 202):
raise RuntimeError(f"SharePoint chunk upload failed: {upload_resp.status_code}")
chunk_num += 1
logger.info("[%s] SharePoint upload complete: %s/%s", task_id, folder_path, filename)
return {"status": "Completed", "sharepoint_folder": folder_path, "filename": filename}
def _upload_icloud(file_path: str, cfg: dict[str, Any], creds: dict[str, Any], task_id: str) -> dict[str, Any]:
"""Upload *file_path* to iCloud Drive using per-user credentials.
Expected *cfg* keys:
* ``folder`` target folder path inside iCloud Drive (e.g. ``Documents/Uploads``).
* ``cookie_directory`` (optional) path for session cookie persistence.
Expected *creds* keys:
* ``username`` Apple ID email address.
* ``password`` app-specific password.
"""
from app.tasks.upload_to_icloud import _get_icloud_api, _navigate_to_folder
username = creds.get("username") or ""
password = creds.get("password") or ""
folder = cfg.get("folder") or ""
cookie_directory = cfg.get("cookie_directory") or None
if not username or not password:
raise ValueError("iCloud integration is missing username or password in credentials")
api = _get_icloud_api(username, password, cookie_directory)
folder_node = _navigate_to_folder(api.drive, folder)
with open(file_path, "rb") as fh:
folder_node.upload(fh)
logger.info("[%s] iCloud Drive upload complete: folder=%s", task_id, folder or "/")
return {"status": "Completed", "icloud_folder": folder or "/"}
# Map IntegrationType → upload helper
_UPLOAD_HANDLERS = {
IntegrationType.DROPBOX: _upload_dropbox,
IntegrationType.S3: _upload_s3,
IntegrationType.GOOGLE_DRIVE: _upload_google_drive,
IntegrationType.ONEDRIVE: _upload_onedrive,
IntegrationType.WEBDAV: _upload_webdav,
IntegrationType.NEXTCLOUD: _upload_nextcloud,
IntegrationType.FTP: _upload_ftp,
IntegrationType.SFTP: _upload_sftp,
IntegrationType.PAPERLESS: _upload_paperless,
IntegrationType.EMAIL: _upload_email,
IntegrationType.RCLONE: _upload_rclone,
IntegrationType.SHAREPOINT: _upload_sharepoint,
IntegrationType.ICLOUD: _upload_icloud,
}
# ---------------------------------------------------------------------------
# Celery task
# ---------------------------------------------------------------------------
@celery.task(base=UploadTaskWithRetry, bind=True)
def upload_to_user_integration(self, file_path: str, integration_id: int, file_id: int | None = None) -> dict[str, Any]:
"""Upload *file_path* to the destination described by the given UserIntegration record.
This task is dispatched once per active DESTINATION UserIntegration that
belongs to a document's owner. Credentials are decrypted at runtime so
they never travel across the Celery message bus in plaintext.
Args:
file_path: Absolute path to the processed document file.
integration_id: Primary key of the ``UserIntegration`` record.
file_id: Optional ``FileRecord.id`` used for progress logging.
Returns:
A dict with at least ``{"status": "Completed", ...}`` on success.
Raises:
FileNotFoundError: When *file_path* does not exist.
ValueError: When the integration record is not found or has missing config.
RuntimeError: When the underlying upload operation fails.
"""
task_id = self.request.id
filename = os.path.basename(file_path)
log_task_progress(
task_id,
f"upload_to_user_integration_{integration_id}",
"in_progress",
f"Uploading {filename} to integration {integration_id}",
file_id=file_id,
)
if not os.path.exists(file_path):
error_msg = f"File not found: {file_path}"
logger.error("[%s] %s", task_id, error_msg)
log_task_progress(
task_id, f"upload_to_user_integration_{integration_id}", "failure", error_msg, file_id=file_id
)
raise FileNotFoundError(error_msg)
with SessionLocal() as db:
integration: UserIntegration | None = (
db.query(UserIntegration).filter(UserIntegration.id == integration_id).first()
)
if integration is None:
error_msg = f"UserIntegration {integration_id} not found"
logger.error("[%s] %s", task_id, error_msg)
log_task_progress(
task_id, f"upload_to_user_integration_{integration_id}", "failure", error_msg, file_id=file_id
)
raise ValueError(error_msg)
itype = integration.integration_type
int_name = integration.name
owner_id = integration.owner_id
# Parse config (non-sensitive) and decrypt credentials (sensitive)
try:
cfg: dict[str, Any] = json.loads(integration.config) if integration.config else {}
except json.JSONDecodeError as exc:
raise ValueError(f"Integration {integration_id} has invalid JSON in config: {exc}") from exc
try:
raw_creds = decrypt_value(integration.credentials) if integration.credentials else None
creds: dict[str, Any] = json.loads(raw_creds) if raw_creds else {}
except json.JSONDecodeError as exc:
raise ValueError(f"Integration {integration_id} has invalid JSON in credentials: {exc}") from exc
handler = _UPLOAD_HANDLERS.get(itype)
if handler is None:
error_msg = f"No upload handler registered for integration type '{itype}' (integration {integration_id})"
logger.warning("[%s] %s", task_id, error_msg)
log_task_progress(
task_id, f"upload_to_user_integration_{integration_id}", "skipped", error_msg, file_id=file_id
)
return {"status": "Skipped", "reason": error_msg}
logger.info(
"[%s] Uploading %s via %s integration '%s' (id=%d, owner=%s)",
task_id,
filename,
itype,
int_name,
integration_id,
owner_id,
)
try:
result = handler(file_path, cfg, creds, task_id)
# Update last_used_at on success
with SessionLocal() as db:
integ = db.query(UserIntegration).filter(UserIntegration.id == integration_id).first()
if integ:
integ.last_used_at = datetime.now(timezone.utc)
integ.last_error = None
db.commit()
log_task_progress(
task_id,
f"upload_to_user_integration_{integration_id}",
"success",
f"Uploaded to {itype} '{int_name}': {filename}",
file_id=file_id,
)
return result
except Exception as exc:
error_msg = str(exc)[:_MAX_ERROR_LENGTH]
logger.error(
"[%s] Upload to integration %d (%s '%s') failed: %s",
task_id,
integration_id,
itype,
int_name,
error_msg,
)
# Persist error for operator visibility
try:
with SessionLocal() as db:
integ = db.query(UserIntegration).filter(UserIntegration.id == integration_id).first()
if integ:
integ.last_used_at = datetime.now(timezone.utc)
integ.last_error = error_msg
db.commit()
except Exception as db_exc: # noqa: BLE001
logger.warning("[%s] Could not persist last_error for integration %d: %s", task_id, integration_id, db_exc)
log_task_progress(
task_id,
f"upload_to_user_integration_{integration_id}",
"failure",
f"Upload to {itype} '{int_name}' failed: {error_msg}",
file_id=file_id,
)
raise
+3 -3
View File
@@ -55,12 +55,12 @@ def upload_with_rclone(self, file_path: str, destination: str):
try:
# Ensure the remote path exists (create folders if needed)
mkdir_cmd = ["rclone", "mkdir", "--config", rclone_config_path, destination]
mkdir_cmd = ["rclone", "mkdir", "--config", rclone_config_path, "--", destination]
subprocess.run(mkdir_cmd, check=True, capture_output=True) # noqa: S603
# Construct the upload command
upload_cmd = ["rclone", "copy", "--config", rclone_config_path, file_path, destination, "--progress"]
upload_cmd = ["rclone", "copy", "--config", rclone_config_path, "--progress", "--", file_path, destination]
log_task_progress(task_id, "rclone_upload", "in_progress", f"Executing rclone copy to {destination}")
@@ -71,7 +71,7 @@ def upload_with_rclone(self, file_path: str, destination: str):
if result.returncode == 0:
# Try to get a public link if possible
try:
link_cmd = ["rclone", "link", "--config", rclone_config_path, f"{destination}/{filename}"]
link_cmd = ["rclone", "link", "--config", rclone_config_path, "--", f"{destination}/{filename}"]
link_result = subprocess.run(link_cmd, capture_output=True, text=True, check=False) # noqa: S603
public_url = link_result.stdout.strip() if link_result.returncode == 0 else None
except (subprocess.SubprocessError, OSError) as e:
File diff suppressed because it is too large Load Diff
+162
View File
@@ -68,6 +68,8 @@ IMAGE_MIME_TYPES: set[str] = {
"image/tiff",
"image/webp",
"image/svg+xml",
"image/heic",
"image/heif",
}
# ---------------------------------------------------------------------------
@@ -124,6 +126,8 @@ ALLOWED_EXTENSIONS: set[str] = {
".tif",
".webp",
".svg",
".heic",
".heif",
# Web
".html",
".htm",
@@ -131,3 +135,161 @@ ALLOWED_EXTENSIONS: set[str] = {
".md",
".markdown",
}
# ---------------------------------------------------------------------------
# Fine-grained file-type categories used by IMAP ingestion profiles.
# Each category groups related MIME types and extensions so that users can
# enable/disable a logical collection of formats (e.g. "images") rather than
# having to manage individual MIME strings.
# ---------------------------------------------------------------------------
FILE_TYPE_CATEGORIES: dict[str, dict] = {
"pdf": {
"label": "PDF",
"description": "PDF documents (.pdf)",
"mime_types": frozenset({"application/pdf"}),
"extensions": frozenset({".pdf"}),
},
"office": {
"label": "Microsoft Office",
"description": "Word, Excel and PowerPoint files (.doc, .docx, .xls, .xlsx, .ppt, .pptx, …)",
"mime_types": frozenset(
{
"application/msword",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
"application/vnd.openxmlformats-officedocument.wordprocessingml.template",
"application/vnd.ms-word.document.macroEnabled.12",
"application/vnd.ms-word.template.macroEnabled.12",
"application/vnd.ms-excel",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"application/vnd.openxmlformats-officedocument.spreadsheetml.template",
"application/vnd.ms-excel.sheet.macroEnabled.12",
"application/vnd.ms-excel.sheet.binary.macroEnabled.12",
"application/vnd.ms-powerpoint",
"application/vnd.openxmlformats-officedocument.presentationml.presentation",
"application/vnd.openxmlformats-officedocument.presentationml.template",
"application/vnd.openxmlformats-officedocument.presentationml.slideshow",
"application/vnd.ms-powerpoint.presentation.macroEnabled.12",
}
),
"extensions": frozenset(
{
".doc",
".docx",
".docm",
".dot",
".dotx",
".dotm",
".xls",
".xlsx",
".xlsm",
".xlsb",
".xlt",
".xltx",
".xlw",
".ppt",
".pptx",
".pptm",
".pps",
".ppsx",
".pot",
".potx",
}
),
},
"opendocument": {
"label": "OpenDocument (LibreOffice)",
"description": "LibreOffice / OpenOffice files (.odt, .ods, .odp, …)",
"mime_types": frozenset(
{
"application/vnd.oasis.opendocument.text",
"application/vnd.oasis.opendocument.spreadsheet",
"application/vnd.oasis.opendocument.presentation",
"application/vnd.oasis.opendocument.graphics",
"application/vnd.oasis.opendocument.formula",
}
),
"extensions": frozenset({".odt", ".ods", ".odp", ".odg", ".odf"}),
},
"text": {
"label": "Text & Data",
"description": "Plain text, CSV and RTF files (.txt, .csv, .rtf)",
"mime_types": frozenset(
{
"text/plain",
"text/csv",
"application/rtf",
"text/rtf",
}
),
"extensions": frozenset({".txt", ".csv", ".rtf"}),
},
"web": {
"label": "Web & Markup",
"description": "HTML and Markdown files (.html, .htm, .md, .markdown)",
"mime_types": frozenset(
{
"text/html",
"text/markdown",
"text/x-markdown",
}
),
"extensions": frozenset({".html", ".htm", ".md", ".markdown"}),
},
"images": {
"label": "Images",
"description": "Image files (.jpg, .png, .gif, .bmp, .tiff, .webp, .svg, .heic, .heif)",
"mime_types": frozenset(
{
"image/jpeg",
"image/jpg",
"image/png",
"image/gif",
"image/bmp",
"image/tiff",
"image/webp",
"image/svg+xml",
"image/heic",
"image/heif",
}
),
"extensions": frozenset(
{
".jpg",
".jpeg",
".png",
".gif",
".bmp",
".tiff",
".tif",
".webp",
".svg",
".heic",
".heif",
}
),
},
}
# Default categories for the "documents only" built-in profile (no images)
DEFAULT_CATEGORIES: list[str] = ["pdf", "office", "opendocument", "text", "web"]
# All categories including images
ALL_CATEGORIES: list[str] = ["pdf", "office", "opendocument", "text", "web", "images"]
def get_allowed_types_for_categories(
categories: list[str],
) -> tuple[frozenset[str], frozenset[str]]:
"""Return ``(mime_types, extensions)`` for the given category list.
Unknown category names are silently ignored so that future categories
don't break existing profiles.
"""
mime_types: set[str] = set()
extensions: set[str] = set()
for cat in categories:
info = FILE_TYPE_CATEGORIES.get(cat)
if info:
mime_types |= info["mime_types"]
extensions |= info["extensions"]
return frozenset(mime_types), frozenset(extensions)
+331
View File
@@ -0,0 +1,331 @@
"""
Comprehensive audit-event service for DocuElevate.
Provides helpers to **record** audit events (append-only database writes)
and to optionally **forward** them to external SIEM systems.
Supported SIEM transports:
* **Syslog** RFC 5424 structured-data messages over UDP or TCP.
* **HTTP** JSON POST payloads compatible with Splunk HEC, Logstash
HTTP input, Grafana Loki push API, and any generic webhook endpoint.
"""
import json
import logging
import re
import socket
import threading
from datetime import datetime, timezone
from typing import Any
import httpx
from fastapi import Request
from sqlalchemy.orm import Session
from app.config import settings
from app.middleware.audit_log import get_client_ip, get_username
from app.models import AuditLog
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Public helpers
# ---------------------------------------------------------------------------
def record_event(
db: Session,
*,
action: str,
user: str = "system",
resource_type: str | None = None,
resource_id: str | None = None,
ip_address: str | None = None,
details: dict[str, Any] | None = None,
severity: str = "info",
) -> AuditLog:
"""Persist an audit event and optionally forward it to SIEM.
Args:
db: Active SQLAlchemy session.
action: Short action identifier (e.g. ``"login"``, ``"document.create"``).
user: Username performing the action.
resource_type: Category of the affected resource (``"document"``, ``"user"`` ).
resource_id: Identifier of the affected resource.
ip_address: Client IP address (``None`` when not applicable).
details: Arbitrary key/value context serialised as JSON.
severity: One of ``info``, ``warning``, ``error``, ``critical``.
Returns:
The newly created :class:`AuditLog` row.
"""
details_json = json.dumps(details, default=str) if details else None
entry = AuditLog(
user=user,
action=action,
resource_type=resource_type,
resource_id=str(resource_id) if resource_id is not None else None,
ip_address=ip_address,
details=details_json,
severity=severity,
)
db.add(entry)
db.commit()
db.refresh(entry)
# Fire-and-forget SIEM forwarding in a background thread so we never
# block the request path.
if settings.audit_siem_enabled:
payload = _build_siem_payload(entry)
thread = threading.Thread(target=_forward_to_siem, args=(payload,), daemon=True)
thread.start()
return entry
def record_event_from_request(
db: Session,
request: Request,
*,
action: str,
resource_type: str | None = None,
resource_id: str | None = None,
details: dict[str, Any] | None = None,
severity: str = "info",
) -> AuditLog:
"""Convenience wrapper that extracts user and IP from a :class:`Request`.
Args:
db: Active SQLAlchemy session.
request: The current HTTP request.
action: Short action identifier.
resource_type: Category of the affected resource.
resource_id: Identifier of the affected resource.
details: Arbitrary key/value context serialised as JSON.
severity: One of ``info``, ``warning``, ``error``, ``critical``.
Returns:
The newly created :class:`AuditLog` row.
"""
return record_event(
db,
action=action,
user=get_username(request),
resource_type=resource_type,
resource_id=resource_id,
ip_address=get_client_ip(request),
details=details,
severity=severity,
)
def query_events(
db: Session,
*,
action: str | None = None,
user: str | None = None,
resource_type: str | None = None,
severity: str | None = None,
since: datetime | None = None,
until: datetime | None = None,
limit: int = 200,
offset: int = 0,
) -> list[AuditLog]:
"""Query audit log entries with optional filtering.
Args:
db: Active SQLAlchemy session.
action: Filter by action string (exact match).
user: Filter by username (exact match).
resource_type: Filter by resource type (exact match).
severity: Filter by severity level (exact match).
since: Only events at or after this timestamp.
until: Only events at or before this timestamp.
limit: Maximum number of rows to return.
offset: Number of rows to skip (for pagination).
Returns:
List of :class:`AuditLog` rows ordered by *timestamp descending*.
"""
q = db.query(AuditLog)
if action:
q = q.filter(AuditLog.action == action)
if user:
q = q.filter(AuditLog.user == user)
if resource_type:
q = q.filter(AuditLog.resource_type == resource_type)
if severity:
q = q.filter(AuditLog.severity == severity)
if since:
q = q.filter(AuditLog.timestamp >= since)
if until:
q = q.filter(AuditLog.timestamp <= until)
return q.order_by(AuditLog.timestamp.desc()).offset(offset).limit(limit).all()
def count_events(
db: Session,
*,
action: str | None = None,
user: str | None = None,
resource_type: str | None = None,
severity: str | None = None,
since: datetime | None = None,
until: datetime | None = None,
) -> int:
"""Return the total count of events matching the given filters.
Args:
db: Active SQLAlchemy session.
action: Filter by action string.
user: Filter by username.
resource_type: Filter by resource type.
severity: Filter by severity level.
since: Only events at or after this timestamp.
until: Only events at or before this timestamp.
Returns:
Integer count.
"""
q = db.query(AuditLog)
if action:
q = q.filter(AuditLog.action == action)
if user:
q = q.filter(AuditLog.user == user)
if resource_type:
q = q.filter(AuditLog.resource_type == resource_type)
if severity:
q = q.filter(AuditLog.severity == severity)
if since:
q = q.filter(AuditLog.timestamp >= since)
if until:
q = q.filter(AuditLog.timestamp <= until)
return q.count()
# ---------------------------------------------------------------------------
# SIEM forwarding internals
# ---------------------------------------------------------------------------
_SYSLOG_FACILITY_LOCAL0 = 16
_SYSLOG_SEVERITY_MAP = {
"info": 6,
"warning": 4,
"error": 3,
"critical": 2,
}
def _build_siem_payload(entry: AuditLog) -> dict[str, Any]:
"""Convert an :class:`AuditLog` row into a plain dict for SIEM delivery."""
ts = entry.timestamp if entry.timestamp else datetime.now(timezone.utc)
return {
"id": entry.id,
"timestamp": ts.isoformat(),
"user": entry.user,
"action": entry.action,
"resource_type": entry.resource_type,
"resource_id": entry.resource_id,
"ip_address": entry.ip_address,
"details": entry.details,
"severity": entry.severity,
"source": "docuelevate",
}
def _forward_to_siem(payload: dict[str, Any]) -> None:
"""Route a SIEM payload to the configured transport."""
transport = settings.audit_siem_transport.lower()
try:
if transport == "syslog":
_send_syslog(payload)
elif transport == "http":
_send_http(payload)
else:
logger.warning("Unknown SIEM transport %r; skipping forwarding", transport)
except Exception:
logger.exception("Failed to forward audit event to SIEM (%s)", transport)
def _send_syslog(payload: dict[str, Any]) -> None:
"""Send a RFC 5424 syslog message to the configured receiver."""
severity_num = _SYSLOG_SEVERITY_MAP.get(payload.get("severity", "info"), 6)
priority = _SYSLOG_FACILITY_LOCAL0 * 8 + severity_num
ts = payload.get("timestamp", datetime.now(timezone.utc).isoformat())
hostname = socket.gethostname()
app_name = "docuelevate"
msg_id = payload.get("action", "-")
# Structured data (SD) element with key event fields.
sd = (
f'[docuelevate@0 user="{payload.get("user", "-")}" '
f'action="{payload.get("action", "-")}" '
f'resource_type="{payload.get("resource_type", "-")}" '
f'resource_id="{payload.get("resource_id", "-")}" '
f'ip="{payload.get("ip_address", "-")}"]'
)
message = json.dumps(payload, default=str)
syslog_msg = f"<{priority}>1 {ts} {hostname} {app_name} - {msg_id} {sd} {message}"
proto = settings.audit_siem_syslog_protocol.lower()
host = settings.audit_siem_syslog_host
port = settings.audit_siem_syslog_port
if proto == "tcp":
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
sock.settimeout(5)
sock.connect((host, port))
sock.sendall(syslog_msg.encode("utf-8"))
else:
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock:
sock.settimeout(5)
sock.sendto(syslog_msg.encode("utf-8"), (host, port))
logger.debug("Syslog audit event sent to %s:%s (%s)", host, port, proto)
def _send_http(payload: dict[str, Any]) -> None:
"""POST a JSON audit event to the configured HTTP endpoint."""
url = settings.audit_siem_http_url
if not url:
logger.warning("SIEM HTTP URL not configured; skipping HTTP forwarding")
return
headers: dict[str, str] = {"Content-Type": "application/json"}
token = settings.audit_siem_http_token
if token:
headers["Authorization"] = f"Bearer {token}"
# Parse custom headers (comma-separated "Key:Value" pairs).
# Reject headers that could override security-critical ones already set,
# and validate that header names contain only RFC 7230 token characters.
_PROTECTED_HEADERS = {"authorization", "content-type", "host"}
_VALID_HEADER_NAME = re.compile(r"^[A-Za-z0-9!#$%&'*+\-.^_`|~]+$")
raw_custom = settings.audit_siem_http_custom_headers
if raw_custom:
for raw_pair in raw_custom.split(","):
pair = raw_pair.strip()
if ":" in pair:
k, _, v = pair.partition(":")
name = k.strip()
if not name or not _VALID_HEADER_NAME.match(name):
logger.warning("Skipping invalid SIEM custom header name: %r", name)
continue
if name.lower() in _PROTECTED_HEADERS:
logger.warning("Skipping protected SIEM custom header: %r", name)
continue
headers[name] = v.strip()
# Wrap in Splunk HEC-style envelope when URL contains ``/services/collector``.
body: dict[str, Any]
if "/services/collector" in url:
body = {"event": payload, "sourcetype": "docuelevate:audit", "source": "docuelevate"}
else:
body = payload
with httpx.Client(timeout=10) as client:
resp = client.post(url, json=body, headers=headers)
resp.raise_for_status()
logger.debug("HTTP audit event forwarded to %s (status %s)", url, resp.status_code)
+188
View File
@@ -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)
+378
View File
@@ -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),
)
+433
View File
@@ -0,0 +1,433 @@
"""Compliance service for managing GDPR, HIPAA, and SOC2 compliance templates.
Provides pre-built compliance configurations that can be applied with one click
to ensure the DocuElevate instance meets regulatory requirements.
"""
import json
import logging
from datetime import datetime, timezone
from typing import Any
from sqlalchemy.orm import Session
from app.models import ComplianceTemplate
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Pre-built compliance template definitions
# ---------------------------------------------------------------------------
COMPLIANCE_TEMPLATES: dict[str, dict[str, Any]] = {
"gdpr": {
"display_name": "GDPR (General Data Protection Regulation)",
"description": (
"European Union regulation for data protection and privacy. "
"Enforces data minimisation, encryption at rest, audit logging, "
"and limits PII exposure in telemetry."
),
"settings": {
"auth_enabled": "True",
"sentry_send_default_pii": "False",
"security_headers_enabled": "True",
"security_header_hsts_enabled": "True",
"security_header_csp_enabled": "True",
"security_header_x_frame_options_enabled": "True",
"enable_deduplication": "True",
},
"checks": [
{
"key": "auth_enabled",
"expected": "True",
"label": "Authentication enabled",
"description": "User authentication must be enabled to control access to personal data.",
},
{
"key": "sentry_send_default_pii",
"expected": "False",
"label": "PII excluded from telemetry",
"description": "Personally identifiable information must not be sent to external monitoring services.",
},
{
"key": "security_headers_enabled",
"expected": "True",
"label": "Security headers enabled",
"description": "HTTP security headers protect against common web vulnerabilities.",
},
{
"key": "security_header_hsts_enabled",
"expected": "True",
"label": "HSTS enabled",
"description": "HTTP Strict Transport Security ensures encrypted connections.",
},
{
"key": "security_header_csp_enabled",
"expected": "True",
"label": "Content Security Policy enabled",
"description": "CSP headers prevent cross-site scripting and data injection attacks.",
},
{
"key": "security_header_x_frame_options_enabled",
"expected": "True",
"label": "Clickjacking protection enabled",
"description": "X-Frame-Options header prevents clickjacking attacks.",
},
{
"key": "enable_deduplication",
"expected": "True",
"label": "Deduplication enabled",
"description": "Data minimisation: avoid storing duplicate documents.",
},
],
},
"hipaa": {
"display_name": "HIPAA (Health Insurance Portability and Accountability Act)",
"description": (
"United States regulation for protecting health information. "
"Requires strong access controls, audit trails, encryption, "
"and strict session management."
),
"settings": {
"auth_enabled": "True",
"multi_user_enabled": "True",
"sentry_send_default_pii": "False",
"security_headers_enabled": "True",
"security_header_hsts_enabled": "True",
"security_header_csp_enabled": "True",
"security_header_x_frame_options_enabled": "True",
"enable_deduplication": "True",
},
"checks": [
{
"key": "auth_enabled",
"expected": "True",
"label": "Authentication enabled",
"description": "Access controls are required to protect electronic Protected Health Information (ePHI).",
},
{
"key": "multi_user_enabled",
"expected": "True",
"label": "Multi-user mode enabled",
"description": "Individual user accounts required for access accountability.",
},
{
"key": "sentry_send_default_pii",
"expected": "False",
"label": "PII excluded from telemetry",
"description": "Protected Health Information must not be sent to external services.",
},
{
"key": "security_headers_enabled",
"expected": "True",
"label": "Security headers enabled",
"description": "Security headers protect ePHI during transmission.",
},
{
"key": "security_header_hsts_enabled",
"expected": "True",
"label": "HSTS enabled",
"description": "Encrypted transport required for all ePHI transmissions.",
},
{
"key": "security_header_csp_enabled",
"expected": "True",
"label": "Content Security Policy enabled",
"description": "CSP prevents injection attacks that could expose ePHI.",
},
{
"key": "security_header_x_frame_options_enabled",
"expected": "True",
"label": "Clickjacking protection enabled",
"description": "Prevents embedding the application in unauthorized frames.",
},
{
"key": "enable_deduplication",
"expected": "True",
"label": "Deduplication enabled",
"description": "Minimise data footprint for ePHI.",
},
],
},
"soc2": {
"display_name": "SOC 2 (Service Organization Control 2)",
"description": (
"Trust Service Criteria framework for service organisations. "
"Focuses on security, availability, processing integrity, "
"confidentiality, and privacy."
),
"settings": {
"auth_enabled": "True",
"multi_user_enabled": "True",
"sentry_send_default_pii": "False",
"security_headers_enabled": "True",
"security_header_hsts_enabled": "True",
"security_header_csp_enabled": "True",
"security_header_x_frame_options_enabled": "True",
"enable_deduplication": "True",
},
"checks": [
{
"key": "auth_enabled",
"expected": "True",
"label": "Authentication enabled",
"description": "Logical access controls required (CC6.1).",
},
{
"key": "multi_user_enabled",
"expected": "True",
"label": "Multi-user mode enabled",
"description": "Individual user accounts for access management (CC6.2).",
},
{
"key": "sentry_send_default_pii",
"expected": "False",
"label": "PII excluded from telemetry",
"description": "Confidential information must not leak to external services (CC6.7).",
},
{
"key": "security_headers_enabled",
"expected": "True",
"label": "Security headers enabled",
"description": "Protection against common web threats (CC6.6).",
},
{
"key": "security_header_hsts_enabled",
"expected": "True",
"label": "HSTS enabled",
"description": "Encrypted transport in transit (CC6.7).",
},
{
"key": "security_header_csp_enabled",
"expected": "True",
"label": "Content Security Policy enabled",
"description": "Application-level security controls (CC6.6).",
},
{
"key": "security_header_x_frame_options_enabled",
"expected": "True",
"label": "Clickjacking protection enabled",
"description": "UI redress attack prevention (CC6.6).",
},
{
"key": "enable_deduplication",
"expected": "True",
"label": "Deduplication enabled",
"description": "Data integrity through deduplication (PI1.1).",
},
],
},
}
def seed_compliance_templates(db: Session) -> None:
"""Create or update the built-in compliance template rows.
Called once at application startup to ensure the ``compliance_templates``
table always contains the latest definitions.
"""
for name, defn in COMPLIANCE_TEMPLATES.items():
existing = db.query(ComplianceTemplate).filter(ComplianceTemplate.name == name).first()
if existing is None:
template = ComplianceTemplate(
name=name,
display_name=defn["display_name"],
description=defn["description"],
settings_json=json.dumps(defn["settings"]),
enabled=False,
status="not_applied",
)
db.add(template)
logger.info(f"Seeded compliance template: {name}")
else:
# Update display_name and description if changed, but preserve user state
existing.display_name = defn["display_name"]
existing.description = defn["description"]
try:
db.commit()
except Exception:
db.rollback()
logger.exception("Failed to seed compliance templates")
def get_all_templates(db: Session) -> list[dict[str, Any]]:
"""Return all compliance templates with their current status."""
templates = db.query(ComplianceTemplate).order_by(ComplianceTemplate.name).all()
result = []
for t in templates:
defn = COMPLIANCE_TEMPLATES.get(t.name, {})
checks = defn.get("checks", [])
result.append(
{
"id": t.id,
"name": t.name,
"display_name": t.display_name,
"description": t.description,
"enabled": t.enabled,
"status": t.status,
"applied_at": t.applied_at.isoformat() if t.applied_at else None,
"applied_by": t.applied_by,
"settings": json.loads(t.settings_json) if t.settings_json else {},
"checks": checks,
"check_count": len(checks),
}
)
return result
def get_template_by_name(db: Session, name: str) -> ComplianceTemplate | None:
"""Retrieve a single compliance template by name."""
return db.query(ComplianceTemplate).filter(ComplianceTemplate.name == name).first()
def evaluate_template_status(db: Session, name: str) -> dict[str, Any]:
"""Evaluate the compliance status of a template against live settings.
Returns a dict with ``status``, ``total``, ``passed``, ``failed``, and
a list of individual ``check_results``.
"""
from app.config import settings as app_settings
from app.utils.settings_service import get_all_settings_from_db
defn = COMPLIANCE_TEMPLATES.get(name)
if defn is None:
return {"status": "unknown", "total": 0, "passed": 0, "failed": 0, "check_results": []}
db_settings = get_all_settings_from_db(db)
checks = defn.get("checks", [])
results: list[dict[str, Any]] = []
passed = 0
for check in checks:
key = check["key"]
expected = check["expected"]
# Resolve effective value: DB > config object
if key in db_settings and db_settings[key] is not None:
actual = str(db_settings[key])
else:
actual = str(getattr(app_settings, key, ""))
is_passing = actual.lower() == expected.lower()
if is_passing:
passed += 1
results.append(
{
"key": key,
"label": check["label"],
"description": check["description"],
"expected": expected,
"actual": actual,
"passing": is_passing,
}
)
total = len(checks)
if passed == total:
status = "compliant"
elif passed > 0:
status = "partial"
else:
status = "non_compliant"
return {
"status": status,
"total": total,
"passed": passed,
"failed": total - passed,
"check_results": results,
}
def apply_template(db: Session, name: str, applied_by: str = "admin") -> dict[str, Any]:
"""Apply a compliance template by writing its settings to the database.
Returns a summary of what was applied.
"""
from app.utils.settings_service import save_setting_to_db
defn = COMPLIANCE_TEMPLATES.get(name)
if defn is None:
return {"success": False, "error": f"Unknown template: {name}"}
template = get_template_by_name(db, name)
if template is None:
return {"success": False, "error": f"Template not found in database: {name}"}
applied_settings: dict[str, str] = {}
errors: list[str] = []
for key, value in defn["settings"].items():
try:
save_setting_to_db(db, key, value, changed_by=f"compliance:{name}")
applied_settings[key] = value
except Exception as e:
errors.append(f"{key}: {e}")
logger.error(f"Failed to apply compliance setting {key}={value}: {e}")
# Update the template record
now = datetime.now(timezone.utc)
template.enabled = True
template.settings_json = json.dumps(applied_settings)
template.applied_at = now
template.applied_by = applied_by
# Evaluate and store status
eval_result = evaluate_template_status(db, name)
template.status = eval_result["status"]
try:
db.commit()
except Exception:
db.rollback()
logger.exception(f"Failed to update compliance template record: {name}")
return {"success": False, "error": "Database commit failed"}
logger.info(f"Applied compliance template '{name}' by {applied_by}: {len(applied_settings)} settings written")
return {
"success": len(errors) == 0,
"template": name,
"applied_settings": applied_settings,
"errors": errors,
"status": eval_result,
}
def get_compliance_summary(db: Session) -> dict[str, Any]:
"""Return a high-level compliance dashboard summary across all templates."""
templates = db.query(ComplianceTemplate).order_by(ComplianceTemplate.name).all()
summary: list[dict[str, Any]] = []
total_checks = 0
total_passed = 0
for t in templates:
eval_result = evaluate_template_status(db, t.name)
total_checks += eval_result["total"]
total_passed += eval_result["passed"]
summary.append(
{
"name": t.name,
"display_name": t.display_name,
"enabled": t.enabled,
"status": eval_result["status"],
"total": eval_result["total"],
"passed": eval_result["passed"],
"failed": eval_result["failed"],
"applied_at": t.applied_at.isoformat() if t.applied_at else None,
"applied_by": t.applied_by,
}
)
overall = "compliant" if total_checks > 0 and total_passed == total_checks else "non_compliant"
if 0 < total_passed < total_checks:
overall = "partial"
return {
"overall_status": overall,
"total_checks": total_checks,
"total_passed": total_passed,
"total_failed": total_checks - total_passed,
"templates": summary,
}
+47 -10
View File
@@ -144,7 +144,7 @@ def get_provider_status() -> dict[str, dict[str, object]]:
and getattr(settings, "dropbox_app_secret", None)
and getattr(settings, "dropbox_refresh_token", None)
),
"enabled": True,
"enabled": getattr(settings, "dropbox_enabled", True),
"description": "Upload files to Dropbox cloud storage",
"details": {
"folder": getattr(settings, "dropbox_folder", "Not set"),
@@ -161,7 +161,7 @@ def get_provider_status() -> dict[str, dict[str, object]]:
"configured": bool(
getattr(settings, "dest_email_host", None) and getattr(settings, "dest_email_default_recipient", None)
),
"enabled": True,
"enabled": getattr(settings, "dest_email_enabled", True),
"description": "Send documents via email",
"details": {
"host": getattr(settings, "dest_email_host", "Not set"),
@@ -183,7 +183,7 @@ def get_provider_status() -> dict[str, dict[str, object]]:
and getattr(settings, "ftp_username", None)
and getattr(settings, "ftp_password", None)
),
"enabled": True,
"enabled": getattr(settings, "ftp_enabled", True),
"description": "Upload files to FTP server",
"details": {
"host": getattr(settings, "ftp_host", "Not set"),
@@ -214,7 +214,7 @@ def get_provider_status() -> dict[str, dict[str, object]]:
"name": "Google Drive",
"icon": "fa-brands fa-google-drive",
"configured": is_configured and bool(getattr(settings, "google_drive_folder_id", None)),
"enabled": True,
"enabled": getattr(settings, "google_drive_enabled", True),
"description": "Store documents in Google Drive",
"details": {
"auth_type": "OAuth" if use_oauth else "Service Account",
@@ -250,7 +250,7 @@ def get_provider_status() -> dict[str, dict[str, object]]:
and getattr(settings, "nextcloud_username", None)
and getattr(settings, "nextcloud_password", None)
),
"enabled": True,
"enabled": getattr(settings, "nextcloud_enabled", True),
"description": "Store documents in NextCloud",
"details": {
"url": getattr(settings, "nextcloud_upload_url", "Not set"),
@@ -270,7 +270,7 @@ def get_provider_status() -> dict[str, dict[str, object]]:
and getattr(settings, "onedrive_client_secret", None)
and getattr(settings, "onedrive_refresh_token", None)
),
"enabled": True,
"enabled": getattr(settings, "onedrive_enabled", True),
"description": "Store documents in Microsoft OneDrive",
"details": {
"client_id": getattr(settings, "onedrive_client_id", "Not set"),
@@ -288,7 +288,7 @@ def get_provider_status() -> dict[str, dict[str, object]]:
"configured": bool(
getattr(settings, "paperless_host", None) and getattr(settings, "paperless_ngx_api_token", None)
),
"enabled": True,
"enabled": getattr(settings, "paperless_enabled", True),
"description": "Document management system for digital archives",
"details": {
"host": getattr(settings, "paperless_host", "Not set"),
@@ -296,6 +296,28 @@ def get_provider_status() -> dict[str, dict[str, object]]:
},
}
# Check SharePoint configuration
providers["SharePoint"] = {
"name": "SharePoint",
"icon": "fa-brands fa-microsoft",
"configured": bool(
getattr(settings, "sharepoint_client_id", None)
and getattr(settings, "sharepoint_client_secret", None)
and getattr(settings, "sharepoint_site_url", None)
),
"enabled": True,
"description": "Store documents in Microsoft SharePoint Online",
"details": {
"client_id": getattr(settings, "sharepoint_client_id", "Not set"),
"client_secret": mask_sensitive_value(getattr(settings, "sharepoint_client_secret", None)),
"tenant_id": getattr(settings, "sharepoint_tenant_id", "Not set"),
"refresh_token": mask_sensitive_value(getattr(settings, "sharepoint_refresh_token", None)),
"site_url": getattr(settings, "sharepoint_site_url", "Not set"),
"document_library": getattr(settings, "sharepoint_document_library", "Not set"),
"folder_path": getattr(settings, "sharepoint_folder_path", "Not set"),
},
}
# Check S3 configuration
providers["S3 Storage"] = {
"name": "S3 Storage",
@@ -305,7 +327,7 @@ def get_provider_status() -> dict[str, dict[str, object]]:
and getattr(settings, "aws_access_key_id", None)
and getattr(settings, "aws_secret_access_key", None)
),
"enabled": True,
"enabled": getattr(settings, "s3_enabled", True),
"description": "Store documents in S3-compatible object storage",
"details": {
"bucket": getattr(settings, "s3_bucket_name", "Not set"),
@@ -327,7 +349,7 @@ def get_provider_status() -> dict[str, dict[str, object]]:
and getattr(settings, "sftp_username", None)
and (getattr(settings, "sftp_password", None) or getattr(settings, "sftp_private_key", None))
),
"enabled": True,
"enabled": getattr(settings, "sftp_enabled", True),
"description": "Upload files to SFTP server",
"details": {
"host": getattr(settings, "sftp_host", "Not set"),
@@ -362,7 +384,7 @@ def get_provider_status() -> dict[str, dict[str, object]]:
and getattr(settings, "webdav_username", None)
and getattr(settings, "webdav_password", None)
),
"enabled": True,
"enabled": getattr(settings, "webdav_enabled", True),
"description": "Store documents on WebDAV servers",
"details": {
"url": getattr(settings, "webdav_url", "Not set"),
@@ -373,4 +395,19 @@ def get_provider_status() -> dict[str, dict[str, object]]:
},
}
# Check iCloud Drive configuration
providers["iCloud Drive"] = {
"name": "iCloud Drive",
"icon": "fa-brands fa-apple",
"configured": bool(getattr(settings, "icloud_username", None) and getattr(settings, "icloud_password", None)),
"enabled": getattr(settings, "icloud_enabled", True),
"description": "Store documents in Apple iCloud Drive",
"details": {
"username": getattr(settings, "icloud_username", "Not set"),
"password": mask_sensitive_value(getattr(settings, "icloud_password", None)),
"folder": getattr(settings, "icloud_folder", "Not set"),
"cookie_directory": getattr(settings, "icloud_cookie_directory", "Not set"),
},
}
return providers
+32 -2
View File
@@ -59,13 +59,43 @@ def validate_auth_config() -> list[str]:
and getattr(settings, "authentik_config_url", None)
)
if not using_simple_auth and not using_oidc:
issues.append("Neither simple authentication nor OIDC are properly configured")
# Check if any social login provider is enabled
using_social_login = any(
getattr(settings, f"social_auth_{p}_enabled", False) for p in ("google", "microsoft", "apple", "dropbox")
)
if not using_simple_auth and not using_oidc and not using_social_login:
issues.append("Neither simple authentication, OIDC, nor social login are properly configured")
# If using OIDC, check for provider name
if using_oidc and not getattr(settings, "oauth_provider_name", None):
issues.append("OAUTH_PROVIDER_NAME is not configured but OIDC is enabled")
# Validate individual social login provider configs
if getattr(settings, "social_auth_google_enabled", False):
if not getattr(settings, "social_auth_google_client_id", None):
issues.append("SOCIAL_AUTH_GOOGLE_CLIENT_ID is required when Google login is enabled")
if not getattr(settings, "social_auth_google_client_secret", None):
issues.append("SOCIAL_AUTH_GOOGLE_CLIENT_SECRET is required when Google login is enabled")
if getattr(settings, "social_auth_microsoft_enabled", False):
if not getattr(settings, "social_auth_microsoft_client_id", None):
issues.append("SOCIAL_AUTH_MICROSOFT_CLIENT_ID is required when Microsoft login is enabled")
if not getattr(settings, "social_auth_microsoft_client_secret", None):
issues.append("SOCIAL_AUTH_MICROSOFT_CLIENT_SECRET is required when Microsoft login is enabled")
if getattr(settings, "social_auth_apple_enabled", False):
if not getattr(settings, "social_auth_apple_client_id", None):
issues.append("SOCIAL_AUTH_APPLE_CLIENT_ID is required when Apple login is enabled")
if not getattr(settings, "social_auth_apple_team_id", None):
issues.append("SOCIAL_AUTH_APPLE_TEAM_ID is required when Apple login is enabled")
if getattr(settings, "social_auth_dropbox_enabled", False):
if not getattr(settings, "social_auth_dropbox_client_id", None):
issues.append("SOCIAL_AUTH_DROPBOX_CLIENT_ID is required when Dropbox login is enabled")
if not getattr(settings, "social_auth_dropbox_client_secret", None):
issues.append("SOCIAL_AUTH_DROPBOX_CLIENT_SECRET is required when Dropbox login is enabled")
return issues
+8 -1
View File
@@ -12,6 +12,7 @@ The utility:
"""
import logging
import re
from typing import Any
from sqlalchemy import MetaData, create_engine, inspect, text
@@ -32,8 +33,10 @@ _TABLE_ORDER = [
"processing_logs",
"application_settings",
"settings_audit_log",
"audit_logs",
"saved_searches",
"webhook_configs",
"shared_links",
]
@@ -82,8 +85,12 @@ def preview_migration(source_url: str) -> dict[str, Any]:
total = 0
with src_engine.connect() as conn:
for table_name in tables:
if not re.match(r"^[a-zA-Z0-9_]+$", table_name):
logger.warning(f"Skipping table with invalid name format: {table_name}")
continue
# table_name is safe — sourced from inspect().get_table_names(), not user input
row = conn.execute(text(f'SELECT COUNT(*) FROM "{table_name}"')).fetchone() # noqa: S608
quoted_table = conn.dialect.identifier_preparer.quote(table_name)
row = conn.execute(text(f"SELECT COUNT(*) FROM {quoted_table}")).fetchone() # noqa: S608
count = row[0] if row else 0
result.append({"name": table_name, "row_count": count})
total += count
+55
View File
@@ -0,0 +1,55 @@
import logging
import os
from typing import Dict
logger = logging.getLogger(__name__)
def update_env_file(settings_to_update: Dict[str, str]) -> bool:
"""
Updates the .env file with the given settings (best-effort).
Creates or modifies existing keys.
Args:
settings_to_update: A dictionary mapping uppercase env var names to their new string values.
Returns:
True if the file was successfully updated, False otherwise.
"""
try:
env_path = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), ".env")
if not os.path.exists(env_path):
logger.warning(f".env file not found at {env_path}, skipping file write")
return False
logger.info(f"Updating settings in {env_path}")
with open(env_path, "r") as f:
env_lines = f.readlines()
updated = set()
new_env_lines = []
for line in env_lines:
stripped_line = line.rstrip()
is_updated = False
for key, value in settings_to_update.items():
if stripped_line.startswith(f"{key}=") or stripped_line.startswith(f"# {key}="):
new_env_lines.append(f"{key}={value}")
updated.add(key)
is_updated = True
break
if not is_updated:
new_env_lines.append(stripped_line)
for key, value in settings_to_update.items():
if key not in updated:
new_env_lines.append(f"{key}={value}")
with open(env_path, "w") as f:
f.write("\n".join(new_env_lines) + "\n")
logger.info("Successfully updated settings in .env file")
return True
except Exception as env_err:
logger.warning(f"Failed to write .env file (non-fatal): {env_err}")
return False
+12 -1
View File
@@ -7,8 +7,19 @@ def hash_file(filepath: str | Path, chunk_size: int = 65536) -> str:
Returns the SHA-256 hash of the file at 'filepath'.
Reads the file in chunks to handle large files efficiently.
"""
from app.config import settings
filepath_obj = Path(filepath).resolve()
workdir_obj = Path(settings.workdir).resolve()
# Security check: Ensure the resolved path is strictly within the allowed workdir
try:
filepath_obj.relative_to(workdir_obj)
except ValueError:
raise FileNotFoundError(f"Access denied: path traversal attempt or file outside workdir '{filepath}'")
sha256 = hashlib.sha256()
with open(filepath, "rb") as f:
with open(filepath_obj, "rb") as f:
while True:
data = f.read(chunk_size)
if not data:
+5
View File
@@ -8,6 +8,11 @@ from pathlib import Path
logger = logging.getLogger(__name__)
# Pattern for valid filenames (alphanumeric, dash, underscore, period, and space)
# Used for validating GPT-provided filenames and other inputs
VALID_FILENAME_PATTERN = r"^[\w\-\. ]+$"
VALID_FILENAME_RE = re.compile(VALID_FILENAME_PATTERN)
def get_unique_filename(original_path: str, check_exists_func: Callable[[str], bool] | None = None) -> str:
"""
+767
View File
@@ -0,0 +1,767 @@
"""Internationalization (i18n) and localization (l10n) utilities.
Provides a JSON-based translation system for the DocuElevate UI with:
* **77 supported languages** covering European, Asian, Middle-Eastern, African, and other languages
* Browser ``Accept-Language`` detection with cookie & user-profile persistence
* AI-powered fallback translation via the configured LLM provider
* Locale-aware date, number, and file-size formatting helpers
* Jinja2 integration via a ``_()`` global function
Language resolution order:
1. User profile ``preferred_language`` (persisted in DB)
2. ``docuelevate_lang`` cookie
3. ``Accept-Language`` HTTP header
4. Default (``en``)
"""
from __future__ import annotations
import json
import logging
from datetime import date, datetime
from functools import lru_cache
from pathlib import Path
from typing import Any
from starlette.requests import Request
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Supported languages (ordered by priority)
# ---------------------------------------------------------------------------
SUPPORTED_LANGUAGES: list[dict[str, str]] = [
# --- Tier 1: Primary European languages ---
# flag: lowercase ISO 3166-1 alpha-2 country code used with the flag-icons CSS library
# (e.g. "gb" → <span class="fi fi-gb">). Regional codes like "gb-wls" are also supported.
{"code": "en", "name": "English", "native": "English", "flag": "gb"},
{"code": "de", "name": "German", "native": "Deutsch", "flag": "de"},
{"code": "fr", "name": "French", "native": "Français", "flag": "fr"},
{"code": "es", "name": "Spanish", "native": "Español", "flag": "es"},
{"code": "it", "name": "Italian", "native": "Italiano", "flag": "it"},
{"code": "pt", "name": "Portuguese", "native": "Português", "flag": "pt"},
# --- Tier 2: Western & Northern European ---
{"code": "nl", "name": "Dutch", "native": "Nederlands", "flag": "nl"},
{"code": "nb", "name": "Norwegian Bokmål", "native": "Norsk bokmål", "flag": "no"},
{"code": "no", "name": "Norwegian", "native": "Norsk", "flag": "no"},
{"code": "da", "name": "Danish", "native": "Dansk", "flag": "dk"},
{"code": "sv", "name": "Swedish", "native": "Svenska", "flag": "se"},
{"code": "fi", "name": "Finnish", "native": "Suomi", "flag": "fi"},
{"code": "is", "name": "Icelandic", "native": "Íslenska", "flag": "is"},
{"code": "ga", "name": "Irish", "native": "Gaeilge", "flag": "ie"},
{"code": "lb", "name": "Luxembourgish", "native": "Lëtzebuergesch", "flag": "lu"},
{"code": "ca", "name": "Catalan", "native": "Català", "flag": "es"}, # no dedicated ISO flag; use Spain
{"code": "cy", "name": "Welsh", "native": "Cymraeg", "flag": "gb-wls"}, # flag-icons GB region code
{"code": "fy", "name": "Western Frisian", "native": "Frysk", "flag": "nl"},
{"code": "gl", "name": "Galician", "native": "Galego", "flag": "es"},
{"code": "li", "name": "Limburgish", "native": "Limburgs", "flag": "nl"},
{"code": "vls", "name": "Flemish", "native": "West-Vlams", "flag": "be"},
{"code": "nds", "name": "Low German", "native": "Plattdüütsch", "flag": "de"},
# --- Tier 3: Central & Eastern European ---
{"code": "pl", "name": "Polish", "native": "Polski", "flag": "pl"},
{"code": "cs", "name": "Czech", "native": "Čeština", "flag": "cz"},
{"code": "sk", "name": "Slovak", "native": "Slovenčina", "flag": "sk"},
{"code": "hu", "name": "Hungarian", "native": "Magyar", "flag": "hu"},
{"code": "sl", "name": "Slovenian", "native": "Slovenščina", "flag": "si"},
{"code": "hr", "name": "Croatian", "native": "Hrvatski", "flag": "hr"},
{"code": "ro", "name": "Romanian", "native": "Română", "flag": "ro"},
{"code": "bg", "name": "Bulgarian", "native": "Български", "flag": "bg"},
{"code": "el", "name": "Greek", "native": "Ελληνικά", "flag": "gr"},
{"code": "et", "name": "Estonian", "native": "Eesti", "flag": "ee"},
{"code": "lv", "name": "Latvian", "native": "Latviešu", "flag": "lv"},
{"code": "lt", "name": "Lithuanian", "native": "Lietuvių", "flag": "lt"},
{"code": "sr", "name": "Serbian", "native": "Српски", "flag": "rs"},
# --- Tier 4: Non-EU European, Middle Eastern & African ---
{"code": "tr", "name": "Turkish", "native": "Türkçe", "flag": "tr"},
{"code": "uk", "name": "Ukrainian", "native": "Українська", "flag": "ua"},
{"code": "he", "name": "Hebrew", "native": "עברית", "flag": "il"},
{"code": "ar", "name": "Arabic", "native": "العربية", "flag": "sa"},
{"code": "fa", "name": "Persian", "native": "فارسی", "flag": "ir"},
{"code": "af", "name": "Afrikaans", "native": "Afrikaans", "flag": "za"},
# --- Tier 5: Asian languages ---
{"code": "zh", "name": "Chinese", "native": "中文", "flag": "cn"},
{"code": "zh-TW", "name": "Traditional Chinese", "native": "繁體中文", "flag": "tw"},
{"code": "ja", "name": "Japanese", "native": "日本語", "flag": "jp"},
{"code": "ko", "name": "Korean", "native": "한국어", "flag": "kr"},
{"code": "vi", "name": "Vietnamese", "native": "Tiếng Việt", "flag": "vn"},
{"code": "pa", "name": "Punjabi", "native": "ਪੰਜਾਬੀ", "flag": "in"},
{"code": "kn", "name": "Kannada", "native": "ಕನ್ನಡ", "flag": "in"},
{"code": "hi", "name": "Hindi", "native": "हिन्दी", "flag": "in"},
{"code": "bn", "name": "Bengali", "native": "বাংলা", "flag": "bd"},
{"code": "gu", "name": "Gujarati", "native": "ગુજરાતી", "flag": "in"},
{"code": "ml", "name": "Malayalam", "native": "മലയാളം", "flag": "in"},
{"code": "mr", "name": "Marathi", "native": "मराठी", "flag": "in"},
{"code": "ta", "name": "Tamil", "native": "தமிழ்", "flag": "in"},
{"code": "te", "name": "Telugu", "native": "తెలుగు", "flag": "in"},
{"code": "ur", "name": "Urdu", "native": "اردو", "flag": "pk"},
{"code": "si", "name": "Sinhala", "native": "සිංහල", "flag": "lk"},
{"code": "ne", "name": "Nepali", "native": "नेपाली", "flag": "np"},
{"code": "th", "name": "Thai", "native": "ไทย", "flag": "th"},
{"code": "km", "name": "Khmer", "native": "ខ្មែរ", "flag": "kh"},
{"code": "id", "name": "Indonesian", "native": "Bahasa Indonesia", "flag": "id"},
{"code": "ms", "name": "Malay", "native": "Bahasa Melayu", "flag": "my"},
{"code": "jv", "name": "Javanese", "native": "Basa Jawa", "flag": "id"},
{"code": "tl", "name": "Tagalog", "native": "Filipino", "flag": "ph"},
{"code": "mn", "name": "Mongolian", "native": "Монгол", "flag": "mn"},
{"code": "kk", "name": "Kazakh", "native": "Қазақ тілі", "flag": "kz"},
{"code": "uz", "name": "Uzbek", "native": "Oʻzbekcha", "flag": "uz"},
{"code": "az", "name": "Azerbaijani", "native": "Azərbaycan dili", "flag": "az"},
{"code": "hy", "name": "Armenian", "native": "Հայերեն", "flag": "am"},
{"code": "ka", "name": "Georgian", "native": "ქართული", "flag": "ge"},
# --- Tier 6: African languages ---
{"code": "sw", "name": "Swahili", "native": "Kiswahili", "flag": "ke"},
{"code": "am", "name": "Amharic", "native": "አማርኛ", "flag": "et"},
{"code": "ha", "name": "Hausa", "native": "Hausa", "flag": "ng"},
{"code": "yo", "name": "Yoruba", "native": "Yorùbá", "flag": "ng"},
{"code": "ig", "name": "Igbo", "native": "Igbo", "flag": "ng"},
{"code": "zu", "name": "Zulu", "native": "isiZulu", "flag": "za"},
# --- Tier 7: Constructed & other languages ---
{"code": "eo", "name": "Esperanto", "native": "Esperanto", "flag": "un"}, # UN flag for international language
]
SUPPORTED_LANGUAGE_CODES: set[str] = {lang["code"] for lang in SUPPORTED_LANGUAGES}
DEFAULT_LANGUAGE = "en"
# Lookup map for fast code → language-dict resolution
_LANG_CODE_MAP: dict[str, dict[str, str]] = {lang["code"]: lang for lang in SUPPORTED_LANGUAGES}
# Global-usage order used to fill remaining slots in the smart suggestions list
_POPULAR_LANGUAGE_CODES: list[str] = ["en", "zh", "es", "ar", "fr", "de", "ja", "pt", "hi", "ko"]
# ---------------------------------------------------------------------------
# Translation file loading
# ---------------------------------------------------------------------------
_TRANSLATIONS_DIR = Path(__file__).resolve().parent.parent.parent / "frontend" / "translations"
_translation_cache: dict[str, dict[str, str]] = {}
def _load_translations(locale: str) -> dict[str, str]:
"""Load the translation JSON file for *locale*, with caching."""
if locale in _translation_cache:
return _translation_cache[locale]
filepath = _TRANSLATIONS_DIR / f"{locale}.json"
if not filepath.is_file():
logger.warning("Translation file not found for locale '%s'", locale)
_translation_cache[locale] = {}
return {}
try:
data: dict[str, str] = json.loads(filepath.read_text(encoding="utf-8"))
_translation_cache[locale] = data
return data
except (json.JSONDecodeError, OSError):
logger.exception("Failed to load translations for '%s'", locale)
_translation_cache[locale] = {}
return {}
def reload_translations() -> None:
"""Clear the translation cache so files are re-read on next access."""
_translation_cache.clear()
# ---------------------------------------------------------------------------
# Core translation function
# ---------------------------------------------------------------------------
def translate(key: str, locale: str | None = None, **kwargs: Any) -> str:
"""Return the translated string for *key* in *locale*.
Falls back through:
1. Requested *locale*
2. English (``en``)
3. The raw key itself (to keep the UI functional)
Positional placeholders ``{0}``, ``{1}`` or named placeholders
``{name}`` in the translated string are interpolated via *kwargs*.
"""
locale = locale if locale and locale in SUPPORTED_LANGUAGE_CODES else DEFAULT_LANGUAGE
translations = _load_translations(locale)
value = translations.get(key)
# Fallback to English
if value is None and locale != DEFAULT_LANGUAGE:
en_translations = _load_translations(DEFAULT_LANGUAGE)
value = en_translations.get(key)
# Fallback to key itself
if value is None:
value = key
if kwargs:
try:
value = value.format(**kwargs)
except (KeyError, IndexError):
pass # Return unformatted string rather than crash
return value
# ---------------------------------------------------------------------------
# AI fallback translation (best-effort, non-blocking)
# ---------------------------------------------------------------------------
_ai_translation_cache: dict[tuple[str, str], str] = {}
def translate_with_ai_fallback(text: str, target_locale: str) -> str:
"""Translate *text* using the configured AI provider as a fallback.
Returns the original *text* unchanged when:
* The target locale is English (source language)
* The AI provider is unavailable or returns an error
* The translation has already been cached
Results are cached in-memory for the lifetime of the process.
"""
if target_locale == DEFAULT_LANGUAGE or target_locale not in SUPPORTED_LANGUAGE_CODES:
return text
cache_key = (text, target_locale)
if cache_key in _ai_translation_cache:
return _ai_translation_cache[cache_key]
target_name = next(
(lang["name"] for lang in SUPPORTED_LANGUAGES if lang["code"] == target_locale),
target_locale,
)
try:
from litellm import completion # type: ignore[import-untyped]
from app.config import settings
model = getattr(settings, "ai_model", None) or getattr(settings, "openai_model", "gpt-4o-mini")
response = completion(
model=model,
messages=[
{
"role": "system",
"content": (
f"You are a professional translator. Translate the following UI text "
f"from English to {target_name}. Return ONLY the translated text, "
f"nothing else. Keep any HTML tags, placeholders like {{name}}, "
f"and special characters intact."
),
},
{"role": "user", "content": text},
],
max_tokens=256,
temperature=0.1,
)
translated = response.choices[0].message.content.strip()
_ai_translation_cache[cache_key] = translated
return translated
except Exception:
logger.debug("AI fallback translation failed for '%s'%s", text[:50], target_locale)
return text
# ---------------------------------------------------------------------------
# Language detection
# ---------------------------------------------------------------------------
def detect_language(request: Request) -> str:
"""Determine the preferred UI language from the request context.
Resolution order:
1. ``preferred_language`` stored in the user session
2. ``docuelevate_lang`` cookie
3. ``Accept-Language`` HTTP header (best match)
4. Default ``en``
"""
# 1. User session preference
if hasattr(request, "session"):
session_lang = request.session.get("preferred_language")
if isinstance(session_lang, str) and session_lang in SUPPORTED_LANGUAGE_CODES:
return session_lang
# 2. Cookie
if hasattr(request, "cookies"):
cookie_lang = request.cookies.get("docuelevate_lang")
if isinstance(cookie_lang, str) and cookie_lang in SUPPORTED_LANGUAGE_CODES:
return cookie_lang
# 3. Accept-Language header
accept = ""
if hasattr(request, "headers"):
accept = request.headers.get("accept-language", "")
lang = _parse_accept_language(accept)
if lang:
return lang
return DEFAULT_LANGUAGE
def _parse_accept_language_entries(header: str) -> list[tuple[float, str]]:
"""Parse an ``Accept-Language`` header into quality-sorted ``(q, tag)`` pairs."""
if not header:
return []
entries: list[tuple[float, str]] = []
for raw_part in header.split(","):
part = raw_part.strip()
if not part:
continue
if ";q=" in part:
lang_tag, _, q_str = part.partition(";q=")
try:
quality = float(q_str.strip())
except ValueError:
quality = 0.0
else:
lang_tag = part
quality = 1.0
entries.append((quality, lang_tag.strip().lower()))
entries.sort(key=lambda e: e[0], reverse=True)
return entries
def _parse_accept_language(header: str) -> str | None:
"""Extract the best matching language from an ``Accept-Language`` header.
Parses quality values and returns the highest-priority match among
:data:`SUPPORTED_LANGUAGE_CODES`, or ``None`` if nothing matches.
"""
for _quality, tag in _parse_accept_language_entries(header):
code = tag.split("-")[0]
if code in SUPPORTED_LANGUAGE_CODES:
return code
return None
# Maximum number of languages shown in the compact nav-bar dropdown
_SUGGESTED_LANGUAGES_MAX = 6
def get_suggested_languages(current_locale: str, accept_language_header: str = "") -> list[dict[str, str]]:
"""Return up to :data:`_SUGGESTED_LANGUAGES_MAX` suggested languages for the compact picker.
Selection priority:
1. The currently active language (always included first).
2. Languages listed in the browser's ``Accept-Language`` header.
3. Popular global languages (by estimated speaker count) as fillers.
The resulting list is de-duplicated and capped at
:data:`_SUGGESTED_LANGUAGES_MAX` entries.
"""
candidates: list[str] = []
# 1. Active locale first
if current_locale in SUPPORTED_LANGUAGE_CODES:
candidates.append(current_locale)
# 2. Browser preferences
for _quality, tag in _parse_accept_language_entries(accept_language_header):
if len(candidates) >= _SUGGESTED_LANGUAGES_MAX:
break
code = tag.split("-")[0]
if code in SUPPORTED_LANGUAGE_CODES and code not in candidates:
candidates.append(code)
# 3. Popular language fillers
for code in _POPULAR_LANGUAGE_CODES:
if len(candidates) >= _SUGGESTED_LANGUAGES_MAX:
break
if code not in candidates and code in SUPPORTED_LANGUAGE_CODES:
candidates.append(code)
return [_LANG_CODE_MAP[c] for c in candidates[:_SUGGESTED_LANGUAGES_MAX] if c in _LANG_CODE_MAP]
# ---------------------------------------------------------------------------
# Localization helpers (l10n)
# ---------------------------------------------------------------------------
# Locale-specific formatting rules for date/number display
_LOCALE_FORMATS: dict[str, dict[str, Any]] = {
"en": {
"date": "%B %d, %Y",
"date_short": "%m/%d/%Y",
"datetime": "%B %d, %Y %I:%M %p",
"thousands_sep": ",",
"decimal_sep": ".",
},
"de": {
"date": "%d. %B %Y",
"date_short": "%d.%m.%Y",
"datetime": "%d. %B %Y %H:%M",
"thousands_sep": ".",
"decimal_sep": ",",
},
"fr": {
"date": "%d %B %Y",
"date_short": "%d/%m/%Y",
"datetime": "%d %B %Y %H:%M",
"thousands_sep": "\u202f",
"decimal_sep": ",",
},
"es": {
"date": "%d de %B de %Y",
"date_short": "%d/%m/%Y",
"datetime": "%d de %B de %Y %H:%M",
"thousands_sep": ".",
"decimal_sep": ",",
},
"it": {
"date": "%d %B %Y",
"date_short": "%d/%m/%Y",
"datetime": "%d %B %Y %H:%M",
"thousands_sep": ".",
"decimal_sep": ",",
},
"pt": {
"date": "%d de %B de %Y",
"date_short": "%d/%m/%Y",
"datetime": "%d de %B de %Y %H:%M",
"thousands_sep": ".",
"decimal_sep": ",",
},
"nl": {
"date": "%d %B %Y",
"date_short": "%d-%m-%Y",
"datetime": "%d %B %Y %H:%M",
"thousands_sep": ".",
"decimal_sep": ",",
},
"nb": {
"date": "%d. %B %Y",
"date_short": "%d.%m.%Y",
"datetime": "%d. %B %Y %H:%M",
"thousands_sep": "\u00a0",
"decimal_sep": ",",
},
"da": {
"date": "%d. %B %Y",
"date_short": "%d.%m.%Y",
"datetime": "%d. %B %Y %H:%M",
"thousands_sep": ".",
"decimal_sep": ",",
},
"sv": {
"date": "%d %B %Y",
"date_short": "%Y-%m-%d",
"datetime": "%d %B %Y %H:%M",
"thousands_sep": "\u00a0",
"decimal_sep": ",",
},
"fi": {
"date": "%d. %B %Y",
"date_short": "%d.%m.%Y",
"datetime": "%d. %B %Y %H:%M",
"thousands_sep": "\u00a0",
"decimal_sep": ",",
},
"is": {
"date": "%d. %B %Y",
"date_short": "%d.%m.%Y",
"datetime": "%d. %B %Y %H:%M",
"thousands_sep": ".",
"decimal_sep": ",",
},
"ga": {
"date": "%d %B %Y",
"date_short": "%d/%m/%Y",
"datetime": "%d %B %Y %H:%M",
"thousands_sep": ",",
"decimal_sep": ".",
},
"lb": {
"date": "%d. %B %Y",
"date_short": "%d.%m.%Y",
"datetime": "%d. %B %Y %H:%M",
"thousands_sep": ".",
"decimal_sep": ",",
},
"ca": {
"date": "%d de %B de %Y",
"date_short": "%d/%m/%Y",
"datetime": "%d de %B de %Y %H:%M",
"thousands_sep": ".",
"decimal_sep": ",",
},
"pl": {
"date": "%d %B %Y",
"date_short": "%d.%m.%Y",
"datetime": "%d %B %Y %H:%M",
"thousands_sep": "\u00a0",
"decimal_sep": ",",
},
"cs": {
"date": "%d. %B %Y",
"date_short": "%d.%m.%Y",
"datetime": "%d. %B %Y %H:%M",
"thousands_sep": "\u00a0",
"decimal_sep": ",",
},
"sk": {
"date": "%d. %B %Y",
"date_short": "%d.%m.%Y",
"datetime": "%d. %B %Y %H:%M",
"thousands_sep": "\u00a0",
"decimal_sep": ",",
},
"hu": {
"date": "%Y. %B %d.",
"date_short": "%Y.%m.%d.",
"datetime": "%Y. %B %d. %H:%M",
"thousands_sep": "\u00a0",
"decimal_sep": ",",
},
"sl": {
"date": "%d. %B %Y",
"date_short": "%d.%m.%Y",
"datetime": "%d. %B %Y %H:%M",
"thousands_sep": ".",
"decimal_sep": ",",
},
"hr": {
"date": "%d. %B %Y.",
"date_short": "%d.%m.%Y.",
"datetime": "%d. %B %Y. %H:%M",
"thousands_sep": ".",
"decimal_sep": ",",
},
"ro": {
"date": "%d %B %Y",
"date_short": "%d.%m.%Y",
"datetime": "%d %B %Y %H:%M",
"thousands_sep": ".",
"decimal_sep": ",",
},
"bg": {
"date": "%d %B %Y",
"date_short": "%d.%m.%Y",
"datetime": "%d %B %Y %H:%M",
"thousands_sep": "\u00a0",
"decimal_sep": ",",
},
"el": {
"date": "%d %B %Y",
"date_short": "%d/%m/%Y",
"datetime": "%d %B %Y %H:%M",
"thousands_sep": ".",
"decimal_sep": ",",
},
"et": {
"date": "%d. %B %Y",
"date_short": "%d.%m.%Y",
"datetime": "%d. %B %Y %H:%M",
"thousands_sep": "\u00a0",
"decimal_sep": ",",
},
"lv": {
"date": "%Y. gada %d. %B",
"date_short": "%d.%m.%Y.",
"datetime": "%Y. gada %d. %B %H:%M",
"thousands_sep": "\u00a0",
"decimal_sep": ",",
},
"lt": {
"date": "%Y m. %B %d d.",
"date_short": "%Y-%m-%d",
"datetime": "%Y m. %B %d d. %H:%M",
"thousands_sep": "\u00a0",
"decimal_sep": ",",
},
"tr": {
"date": "%d %B %Y",
"date_short": "%d.%m.%Y",
"datetime": "%d %B %Y %H:%M",
"thousands_sep": ".",
"decimal_sep": ",",
},
"uk": {
"date": "%d %B %Y",
"date_short": "%d.%m.%Y",
"datetime": "%d %B %Y %H:%M",
"thousands_sep": "\u00a0",
"decimal_sep": ",",
},
"zh": {
"date": "%Y年%m月%d",
"date_short": "%Y/%m/%d",
"datetime": "%Y年%m月%d%H:%M",
"thousands_sep": ",",
"decimal_sep": ".",
},
"ru": {
"date": "%d %B %Y",
"date_short": "%d.%m.%Y",
"datetime": "%d %B %Y %H:%M",
"thousands_sep": "\u00a0",
"decimal_sep": ",",
},
# --- New languages ---
"no": {
"date": "%d. %B %Y",
"date_short": "%d.%m.%Y",
"datetime": "%d. %B %Y %H:%M",
"thousands_sep": "\u00a0",
"decimal_sep": ",",
},
"cy": {
"date": "%d %B %Y",
"date_short": "%d/%m/%Y",
"datetime": "%d %B %Y %H:%M",
"thousands_sep": ",",
"decimal_sep": ".",
},
"fy": {
"date": "%d %B %Y",
"date_short": "%d-%m-%Y",
"datetime": "%d %B %Y %H:%M",
"thousands_sep": ".",
"decimal_sep": ",",
},
"gl": {
"date": "%d de %B de %Y",
"date_short": "%d/%m/%Y",
"datetime": "%d de %B de %Y %H:%M",
"thousands_sep": ".",
"decimal_sep": ",",
},
"li": {
"date": "%d %B %Y",
"date_short": "%d-%m-%Y",
"datetime": "%d %B %Y %H:%M",
"thousands_sep": ".",
"decimal_sep": ",",
},
"vls": {
"date": "%d %B %Y",
"date_short": "%d/%m/%Y",
"datetime": "%d %B %Y %H:%M",
"thousands_sep": ".",
"decimal_sep": ",",
},
"nds": {
"date": "%d. %B %Y",
"date_short": "%d.%m.%Y",
"datetime": "%d. %B %Y %H:%M",
"thousands_sep": ".",
"decimal_sep": ",",
},
"sr": {
"date": "%d. %B %Y.",
"date_short": "%d.%m.%Y.",
"datetime": "%d. %B %Y. %H:%M",
"thousands_sep": ".",
"decimal_sep": ",",
},
"he": {
"date": "%d %B %Y",
"date_short": "%d/%m/%Y",
"datetime": "%d %B %Y %H:%M",
"thousands_sep": ",",
"decimal_sep": ".",
},
"ar": {
"date": "%d %B %Y",
"date_short": "%Y/%m/%d",
"datetime": "%d %B %Y %H:%M",
"thousands_sep": ",",
"decimal_sep": ".",
},
"fa": {
"date": "%d %B %Y",
"date_short": "%Y/%m/%d",
"datetime": "%d %B %Y %H:%M",
"thousands_sep": ",",
"decimal_sep": ".",
},
"af": {
"date": "%d %B %Y",
"date_short": "%Y/%m/%d",
"datetime": "%d %B %Y %H:%M",
"thousands_sep": "\u00a0",
"decimal_sep": ",",
},
"ja": {
"date": "%Y年%m月%d",
"date_short": "%Y/%m/%d",
"datetime": "%Y年%m月%d%H:%M",
"thousands_sep": ",",
"decimal_sep": ".",
},
"ko": {
"date": "%Y년 %m월 %d",
"date_short": "%Y.%m.%d",
"datetime": "%Y년 %m월 %d%H:%M",
"thousands_sep": ",",
"decimal_sep": ".",
},
"vi": {
"date": "ngày %d tháng %m năm %Y",
"date_short": "%d/%m/%Y",
"datetime": "ngày %d tháng %m năm %Y %H:%M",
"thousands_sep": ".",
"decimal_sep": ",",
},
"pa": {
"date": "%d %B %Y",
"date_short": "%d/%m/%Y",
"datetime": "%d %B %Y %H:%M",
"thousands_sep": ",",
"decimal_sep": ".",
},
"kn": {
"date": "%d %B %Y",
"date_short": "%d/%m/%Y",
"datetime": "%d %B %Y %H:%M",
"thousands_sep": ",",
"decimal_sep": ".",
},
"eo": {
"date": "%d-a de %B %Y",
"date_short": "%Y-%m-%d",
"datetime": "%d-a de %B %Y %H:%M",
"thousands_sep": "\u00a0",
"decimal_sep": ",",
},
}
def format_date(value: date | datetime | None, locale: str = DEFAULT_LANGUAGE, short: bool = False) -> str:
"""Format a date/datetime value according to the locale conventions."""
if value is None:
return ""
fmt_key = "date_short" if short else "date"
fmt = _LOCALE_FORMATS.get(locale, _LOCALE_FORMATS[DEFAULT_LANGUAGE])[fmt_key]
return value.strftime(fmt)
def format_datetime(value: datetime | None, locale: str = DEFAULT_LANGUAGE) -> str:
"""Format a datetime value according to the locale conventions."""
if value is None:
return ""
fmt = _LOCALE_FORMATS.get(locale, _LOCALE_FORMATS[DEFAULT_LANGUAGE])["datetime"]
return value.strftime(fmt)
def format_number(value: int | float, locale: str = DEFAULT_LANGUAGE) -> str:
"""Format a number with locale-appropriate thousand separators."""
lf = _LOCALE_FORMATS.get(locale, _LOCALE_FORMATS[DEFAULT_LANGUAGE])
if isinstance(value, float):
int_part, _, dec_part = f"{value:,.2f}".partition(".")
formatted_int = int_part.replace(",", lf["thousands_sep"])
return f"{formatted_int}{lf['decimal_sep']}{dec_part}"
return f"{value:,}".replace(",", lf["thousands_sep"])
@lru_cache(maxsize=32)
def get_language_info(code: str) -> dict[str, str] | None:
"""Return the metadata dict for a supported language code, or ``None``."""
for lang in SUPPORTED_LANGUAGES:
if lang["code"] == code:
return lang
return None
+6 -2
View File
@@ -32,8 +32,12 @@ def hash_password(plain: str) -> str:
def verify_password(plain: str, hashed: str) -> bool:
"""Return True when *plain* matches the stored bcrypt *hashed* string."""
try:
return bcrypt.checkpw(plain.encode("utf-8"), hashed.encode("utf-8"))
except Exception:
result = bcrypt.checkpw(plain.encode("utf-8"), hashed.encode("utf-8"))
if not result:
logger.debug("verify_password: mismatch password_provided=%s", bool(plain))
return result
except Exception as exc:
logger.warning("verify_password: exception type=%s msg=%s", type(exc).__name__, exc)
return False
+34
View File
@@ -0,0 +1,34 @@
import ipaddress
import logging
import socket
logger = logging.getLogger(__name__)
def is_private_ip(hostname: str) -> bool:
"""
Check if a hostname resolves to a private/internal IP address.
Protects against SSRF attacks by blocking access to internal networks.
"""
try:
# Try to parse as IP address directly
ip = ipaddress.ip_address(hostname)
return ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_reserved
except ValueError:
# Not a direct IP, try to resolve hostname
try:
# Get all IP addresses for this hostname
addr_info = socket.getaddrinfo(hostname, None)
for info in addr_info:
ip_str = info[4][0]
ip = ipaddress.ip_address(ip_str)
# Block if ANY resolved IP is private/internal
if ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_reserved:
return True
return False
except (socket.gaierror, socket.error):
# Cannot resolve.
# Fail securely: block unresolved domains to prevent DNS rebinding
# and SSRF bypasses via unresolvable addresses.
logger.warning(f"Could not resolve hostname (blocking securely): {hostname}")
return True

Some files were not shown because too many files have changed in this diff Show More