From 3c9a9fc060b900529d7876b3d44f13e8487c3c85 Mon Sep 17 00:00:00 2001 From: Nils Schneider Date: Wed, 17 Dec 2025 13:37:31 +0100 Subject: [PATCH] Initial commit --- .github/workflows/release.yaml | 146 + .gitignore | 27 + DEVELOPMENT.md | 107 + LICENSE | 661 + README.md | 48 + backend/Cargo.lock | 4449 ++++++ backend/Cargo.toml | 99 + backend/Dockerfile | 143 + backend/build.rs | 123 + backend/diesel.toml | 6 + .../202510300000_initial_schema/down.sql | 25 + .../202510300000_initial_schema/up.sql | 295 + .../down.sql | 2 + .../up.sql | 2 + .../202510310000_create_magic_tokens/down.sql | 2 + .../202510310000_create_magic_tokens/up.sql | 18 + .../202510310100_split_schemas/down.sql | 28 + .../202510310100_split_schemas/up.sql | 45 + .../202510310200_enable_rls/down.sql | 51 + .../migrations/202510310200_enable_rls/up.sql | 69 + .../down.sql | 5 + .../up.sql | 15 + .../202511010000_api_tokens/down.sql | 31 + .../migrations/202511010000_api_tokens/up.sql | 28 + .../down.sql | 26 + .../up.sql | 26 + .../down.sql | 13 + .../up.sql | 13 + .../202511010003_capability_sets/down.sql | 28 + .../202511010003_capability_sets/up.sql | 158 + .../202511010004_purge_document_jobs/down.sql | 1 + .../202511010004_purge_document_jobs/up.sql | 8 + .../down.sql | 5 + .../up.sql | 37 + .../down.sql | 13 + .../up.sql | 14 + .../down.sql | 4 + .../up.sql | 46 + .../down.sql | 9 + .../up.sql | 22 + .../down.sql | 24 + .../up.sql | 27 + .../down.sql | 3 + .../up.sql | 3 + .../down.sql | 3 + .../202511300000_rename_ocr_text_asset/up.sql | 3 + .../down.sql | 1 + .../202511300100_add_title_trgm_index/up.sql | 6 + backend/postgres-init/01_create_app_roles.sql | 21 + backend/resources/issued_at_months.yaml | 648 + backend/src/auth/api_tokens.rs | 324 + backend/src/auth/capability_guard.rs | 120 + backend/src/auth/capability_sets.rs | 323 + backend/src/auth/jwt.rs | 248 + backend/src/auth/mod.rs | 221 + backend/src/auth/passkeys.rs | 592 + backend/src/auth/password.rs | 24 + backend/src/bin/admin.rs | 888 ++ backend/src/bin/openapi_dump.rs | 8 + backend/src/bin/webdav.rs | 28 + backend/src/bin/worker.rs | 21 + backend/src/config.rs | 298 + backend/src/db.rs | 51 + backend/src/documents/asset.rs | 256 + backend/src/documents/correspondents.rs | 121 + backend/src/documents/folders.rs | 21 + backend/src/documents/metadata.rs | 52 + backend/src/documents/mod.rs | 10 + backend/src/documents/ordering.rs | 58 + backend/src/documents/relations.rs | 33 + backend/src/documents/search.rs | 304 + backend/src/documents/tags.rs | 80 + backend/src/error.rs | 128 + backend/src/http/mod.rs | 1 + backend/src/http/responders.rs | 157 + backend/src/issued_at.rs | 167 + backend/src/jobs.rs | 133 + backend/src/lib.rs | 22 + backend/src/main.rs | 28 + backend/src/migrations.rs | 3 + backend/src/models.rs | 775 ++ backend/src/openapi.rs | 135 + backend/src/routes/auth.rs | 260 + backend/src/routes/capability_sets.rs | 134 + backend/src/routes/correspondents.rs | 312 + backend/src/routes/documents.rs | 1152 ++ backend/src/routes/folders.rs | 248 + backend/src/routes/health.rs | 46 + backend/src/routes/mod.rs | 496 + backend/src/routes/profile.rs | 150 + backend/src/routes/tags.rs | 358 + backend/src/routes/tenants.rs | 154 + backend/src/routes/webdav/mod.rs | 846 ++ backend/src/s3.rs | 34 + backend/src/schema.rs | 334 + backend/src/services/auth.rs | 884 ++ backend/src/services/capability_sets.rs | 293 + backend/src/services/correspondents.rs | 261 + backend/src/services/documents.rs | 1618 +++ backend/src/services/folders.rs | 635 + backend/src/services/helpers.rs | 25 + backend/src/services/mod.rs | 9 + backend/src/services/profile.rs | 222 + backend/src/services/tags.rs | 166 + backend/src/services/tenants.rs | 260 + backend/src/state.rs | 107 + backend/src/storage.rs | 195 + backend/src/tenants.rs | 245 + backend/src/test_support/mod.rs | 935 ++ backend/src/utils/bootstrap.rs | 14 + backend/src/utils/db.rs | 26 + backend/src/utils/error.rs | 32 + backend/src/utils/http.rs | 53 + backend/src/utils/json.rs | 25 + backend/src/utils/mod.rs | 12 + backend/src/utils/named_entity.rs | 28 + backend/src/utils/setops.rs | 41 + backend/src/utils/storage_paths.rs | 95 + backend/src/utils/text.rs | 31 + backend/src/utils/time.rs | 13 + backend/src/utils/tracing.rs | 14 + backend/src/utils/validation.rs | 10 + backend/src/workers/analyze.rs | 308 + backend/src/workers/common.rs | 73 + backend/src/workers/index.rs | 79 + backend/src/workers/issued_at.rs | 695 + backend/src/workers/issued_at_months.rs | 15 + backend/src/workers/mod.rs | 209 + backend/src/workers/ocr.rs | 424 + backend/src/workers/purge.rs | 298 + backend/src/workers/taskflow/document.rs | 225 + backend/src/workers/taskflow/mod.rs | 85 + backend/src/workers/tenants.rs | 640 + backend/src/workers/thumbnails.rs | 647 + backend/tests/api_tokens_flow.rs | 402 + backend/tests/auth_flow.rs | 745 + backend/tests/capability_guards_flow.rs | 104 + backend/tests/capability_sets_flow.rs | 114 + backend/tests/common/mod.rs | 1 + backend/tests/correspondents_flow.rs | 275 + backend/tests/data/issued_at_cases.yaml | 584 + backend/tests/documents_flow.rs | 2071 +++ backend/tests/download_flow.rs | 84 + backend/tests/folders_flow.rs | 562 + backend/tests/tags_flow.rs | 271 + backend/tests/tenant_deletion.rs | 715 + backend/tests/tenants_flow.rs | 51 + branding/README.md | 4 + branding/logo.afphoto | Bin 0 -> 2108915 bytes docker-compose.dev.yml | 95 + docker-compose.test.yml | 58 + docker-compose.yml | 182 + docs/api.txt | 71 + docs/api_responses.md | 17 + docs/capability_sets.md | 100 + docs/desktopworkspace.md | 15 + docs/deskview.png | Bin 0 -> 2867186 bytes docs/detailview.png | Bin 0 -> 1501510 bytes docs/document-model.md | 92 + docs/documentview.png | Bin 0 -> 699745 bytes docs/jobs.md | 40 + docs/s3_cors_presign.md | 56 + docs/screenshot.png | Bin 0 -> 1149897 bytes docs/testing.md | 27 + frontend/.gitignore | 3 + frontend/Dockerfile | 27 + frontend/README.md | 56 + frontend/babel.config.js | 17 + frontend/docker-entrypoint.sh | 59 + frontend/eslint.config.mjs | 86 + frontend/package-lock.json | 11580 ++++++++++++++++ frontend/package.json | 48 + frontend/src/app/DocumentsRoute.tsx | 112 + frontend/src/app/DropOverlay.tsx | 16 + frontend/src/app/LoginRoute.tsx | 535 + frontend/src/app/PanelManagerContext.tsx | 406 + frontend/src/app/SettingsRoute.tsx | 127 + frontend/src/app/UploadQueueOverlay.tsx | 208 + .../src/app/WorkspaceSelectionContext.tsx | 20 + frontend/src/app/entryKey.ts | 25 + frontend/src/app/useDetailPanel.ts | 52 + frontend/src/app/useDocumentSelection.ts | 287 + frontend/src/app/useDocumentViewer.ts | 151 + frontend/src/app/useDocumentsPreferences.ts | 114 + frontend/src/app/useDocumentsSearch.ts | 298 + frontend/src/app/useDocumentsShell.ts | 49 + frontend/src/app/useManagementModals.tsx | 227 + frontend/src/app/useWorkspaceSelection.ts | 117 + frontend/src/app/useWorkspaceSurface.tsx | 238 + frontend/src/app/workspaceUtils.ts | 64 + frontend/src/assets/folder.svg | 11 + frontend/src/assets/logo.webp | Bin 0 -> 107358 bytes frontend/src/assets/logo_small.webp | Bin 0 -> 5048 bytes frontend/src/assets/papercorner.svg | 19 + frontend/src/components/BreadcrumbTrail.tsx | 421 + frontend/src/components/PanelHeader.tsx | 63 + frontend/src/components/QuickAddMenu.tsx | 277 + .../src/components/StatusToastOverlay.tsx | 83 + frontend/src/components/classNames.ts | 2 + frontend/src/components/icons.tsx | 206 + frontend/src/components/useFloatingMenu.ts | 309 + frontend/src/constants/app.ts | 4 + frontend/src/constants/colors.ts | 1 + frontend/src/constants/desktop.ts | 3 + frontend/src/constants/documents.ts | 20 + frontend/src/constants/layout.ts | 23 + frontend/src/constants/preview.ts | 32 + frontend/src/constants/settings.ts | 28 + frontend/src/constants/sidebar.ts | 12 + frontend/src/constants/ui.ts | 7 + frontend/src/constants/workspace.ts | 10 + .../correspondents/CorrespondentsPanel.tsx | 237 + .../components/DesktopDocumentCard.tsx | 135 + .../desktop/components/DesktopPreviewCard.tsx | 71 + .../desktop/components/DesktopWorkspace.tsx | 456 + .../desktop/components/workspace-cards.css | 74 + .../desktop/components/workspace-items.css | 130 + .../desktop/components/workspace-layout.css | 34 + .../src/desktop/hooks/usePreviewMetadata.ts | 108 + .../interactions/PointerTrackingContext.tsx | 30 + .../desktop/interactions/useCardPointer.ts | 277 + frontend/src/desktop/logic/CardDragLogic.ts | 78 + frontend/src/desktop/logic/CardPhysics.ts | 359 + frontend/src/desktop/logic/LayoutSystem.ts | 571 + frontend/src/desktop/logic/db.ts | 164 + frontend/src/desktop/utils/layoutUtils.ts | 87 + frontend/src/documents/CorrespondentLinks.tsx | 72 + .../src/documents/DocumentThumbnailImage.tsx | 188 + frontend/src/documents/DocumentsManager.ts | 239 + frontend/src/documents/DocumentsView.tsx | 144 + frontend/src/documents/FoldersManager.ts | 389 + .../components/DocumentDownloadLink.tsx | 40 + .../documents/components/DocumentEntry.tsx | 38 + .../src/documents/components/DocumentTags.tsx | 79 + .../components/DocumentsGridCard.tsx | 139 + .../components/DocumentsGridContainer.tsx | 37 + .../components/DocumentsListContainer.tsx | 35 + .../documents/components/DocumentsListRow.tsx | 157 + .../components/EditableEntryTitle.tsx | 67 + .../components/EntryCorrespondents.tsx | 27 + .../src/documents/components/EntryShell.tsx | 66 + .../src/documents/components/FolderEntry.tsx | 35 + .../components/InlineRenameInput.tsx | 80 + .../documents/components/TagRemovalZone.css | 31 + .../documents/components/TagRemovalZone.tsx | 70 + .../context/DocumentsAssetContext.tsx | 10 + .../context/DocumentsCommandContext.tsx | 38 + .../context/DocumentsFilterContext.tsx | 32 + .../context/DocumentsViewStateContext.tsx | 17 + frontend/src/documents/correspondents.ts | 27 + frontend/src/documents/data/useAuthManager.ts | 73 + .../documents/data/useBulkDocumentActions.ts | 98 + .../src/documents/data/useCorrespondents.ts | 139 + .../data/useDocumentCorrespondentMutations.ts | 190 + .../data/useDocumentMoveMutations.ts | 177 + .../documents/data/useDocumentMutations.ts | 475 + .../documents/data/useDocumentTagMutations.ts | 153 + frontend/src/documents/data/useDocuments.ts | 79 + .../documents/data/useDocumentsWorkspace.ts | 1041 ++ frontend/src/documents/data/useTags.ts | 144 + .../src/documents/data/useTenantManager.ts | 97 + .../documents/data/useWorkspaceViewData.ts | 81 + frontend/src/documents/documentActions.ts | 15 + .../features/folders/useFolderItemLogic.ts | 122 + .../features/folders/useFolderTree.ts | 205 + .../features/folders/useFolderTreeActions.ts | 396 + .../features/renaming/useInlineRename.ts | 164 + .../selection/SelectionAssignmentMenu.tsx | 369 + .../selection/SelectionFloatingActions.tsx | 526 + .../selection/SelectionFolderMenu.tsx | 302 + .../features/selection/SelectionSummary.tsx | 62 + .../selection/useDocumentsSelection.ts | 155 + .../features/selection/useEntryPointer.ts | 74 + .../selection/useWorkspaceSelectionSync.ts | 62 + .../documents/features/tagging/tagTransfer.ts | 224 + .../upload/useDocumentDragHandlers.ts | 413 + .../features/upload/useDocumentUploads.ts | 514 + .../documents/features/upload/useFileDrop.ts | 94 + .../interactions/useTagInteractions.ts | 261 + frontend/src/documents/logic/breadcrumbs.ts | 38 + .../documents/logic/useDocumentItemLogic.ts | 100 + .../documents/logic/useDocumentViewLogic.ts | 53 + .../documents/logic/useDocumentsNavigation.ts | 232 + .../src/documents/panel/DocumentsPanel.tsx | 322 + .../documents/panel/DocumentsPanelHeader.tsx | 72 + .../src/documents/panel/DocumentsToolbar.tsx | 154 + .../documents/panel/SortFieldQuickMenu.tsx | 57 + .../panel/useDocumentsContextValues.ts | 201 + frontend/src/documents/styles/controls.css | 106 + .../src/documents/styles/drag-preview.css | 76 + frontend/src/documents/styles/listing.css | 816 ++ .../src/documents/styles/panel-sections.css | 157 + .../src/documents/styles/shared-snippets.css | 21 + .../documents/styles/tags-correspondents.css | 173 + .../src/documents/types/workspaceTypes.ts | 63 + frontend/src/folders/FolderManagerContext.tsx | 57 + frontend/src/hooks/useApiError.ts | 50 + frontend/src/hooks/useNotifyApiError.ts | 18 + frontend/src/index.html | 11 + frontend/src/index.tsx | 116 + frontend/src/lib/api/api.ts | 8 + frontend/src/lib/api/apiClient.ts | 446 + frontend/src/lib/api/apiTypes.ts | 113 + frontend/src/lib/assets/AssetManager.ts | 175 + .../src/lib/assets/CorrespondentManager.ts | 144 + frontend/src/lib/assets/TagManager.ts | 161 + frontend/src/lib/context/ApiContext.tsx | 38 + frontend/src/lib/context/AppShellContext.ts | 5 + .../src/lib/context/DocumentOpenContext.tsx | 63 + .../src/lib/context/StatusToastContext.tsx | 115 + frontend/src/lib/store/appState.tsx | 271 + frontend/src/login/LoginView.tsx | 176 + frontend/src/login/login.css | 186 + frontend/src/settings/SettingsModal.tsx | 127 + .../components/CapabilityDropdown.tsx | 196 + .../settings/sections/ApiTokensSection.tsx | 482 + .../sections/CapabilitySetsSection.tsx | 643 + .../src/settings/sections/PasskeysSection.tsx | 165 + frontend/src/settings/settings.css | 337 + frontend/src/settings/useApiTokens.ts | 186 + frontend/src/settings/useCapabilities.ts | 45 + frontend/src/settings/useCapabilitySets.ts | 225 + frontend/src/settings/usePasskeys.ts | 230 + frontend/src/sidebar/Sidebar.tsx | 137 + frontend/src/sidebar/SidebarContext.tsx | 290 + .../components/SidebarCorrespondentList.tsx | 114 + .../sidebar/components/SidebarFolderList.tsx | 177 + .../sidebar/components/SidebarFolderNode.tsx | 169 + .../src/sidebar/components/SidebarHeader.tsx | 176 + .../src/sidebar/components/SidebarMenu.tsx | 316 + .../src/sidebar/components/SidebarSearch.tsx | 51 + .../src/sidebar/components/SidebarTagList.tsx | 137 + frontend/src/sidebar/sidebar.css | 722 + frontend/src/styles/base/controls.css | 107 + frontend/src/styles/base/iconography.css | 56 + frontend/src/styles/base/text-button.css | 14 + frontend/src/styles/base/theme.css | 242 + .../src/styles/components/resize-handle.css | 45 + frontend/src/styles/forms/elements.css | 46 + frontend/src/styles/index.css | 24 + .../styles/layout/panel-header-controls.css | 43 + frontend/src/styles/layout/responsive.css | 17 + frontend/src/styles/layout/structure.css | 94 + frontend/src/styles/modals/panel-modals.css | 44 + frontend/src/styles/modals/status-drop.css | 75 + frontend/src/styles/status-toast.css | 86 + frontend/src/styles/uploads/overlay.css | 161 + frontend/src/tags/TagsPanel.tsx | 353 + frontend/src/types/assets.ts | 15 + frontend/src/types/common.ts | 4 + frontend/src/types/documents.ts | 92 + frontend/src/types/identifiers.ts | 12 + frontend/src/types/svg.d.ts | 6 + frontend/src/utils/colors.ts | 355 + frontend/src/utils/createSafeContext.ts | 25 + frontend/src/utils/date.ts | 53 + frontend/src/utils/format.ts | 14 + frontend/src/utils/math.ts | 9 + frontend/src/utils/webauthn.ts | 227 + frontend/src/viewer/DocumentViewerLayout.tsx | 121 + frontend/src/viewer/DocumentViewerPanel.tsx | 429 + .../src/viewer/FullscreenPreviewContext.tsx | 63 + frontend/src/viewer/MediaViewer.tsx | 117 + frontend/src/viewer/PdfViewer.tsx | 902 ++ frontend/src/viewer/UnifiedDocumentViewer.tsx | 59 + .../viewer/components/DocumentInfoPanel.tsx | 500 + .../components/DocumentSummarySection.tsx | 795 ++ .../components/FullscreenPreviewOverlay.tsx | 163 + frontend/src/viewer/fullscreen-preview.css | 99 + frontend/src/viewer/logic/documentSummary.ts | 100 + .../src/viewer/logic/useDetailWorkspace.ts | 204 + frontend/src/viewer/styles/detail-panels.css | 845 ++ frontend/src/viewer/styles/viewer.css | 645 + frontend/src/viewer/useViewerLayoutMode.ts | 78 + frontend/tsconfig.json | 17 + frontend/webpack.config.js | 78 + k8s/papercrate/.helmignore | 9 + k8s/papercrate/Chart.yaml | 5 + k8s/papercrate/templates/NOTES.txt | 6 + k8s/papercrate/templates/_helpers.tpl | 71 + .../templates/backend-deployment.yaml | 83 + k8s/papercrate/templates/backend-secret.yaml | 27 + k8s/papercrate/templates/backend-service.yaml | 17 + .../templates/frontend-deployment.yaml | 71 + .../templates/frontend-service.yaml | 19 + k8s/papercrate/templates/ingress.yaml | 43 + k8s/papercrate/templates/migrate-job.yaml | 94 + k8s/papercrate/templates/migrate-secret.yaml | 27 + .../templates/postgres-configmap.yaml | 65 + k8s/papercrate/templates/postgres-secret.yaml | 13 + .../templates/postgres-service.yaml | 19 + .../templates/postgres-statefulset.yaml | 112 + .../templates/quickwit-deployment.yaml | 103 + .../templates/quickwit-service.yaml | 19 + .../templates/webdav-deployment.yaml | 91 + k8s/papercrate/templates/webdav-service.yaml | 19 + .../templates/worker-deployment.yaml | 86 + k8s/papercrate/values.yaml | 164 + papercrate.tmux | 11 + 399 files changed, 80777 insertions(+) create mode 100644 .github/workflows/release.yaml create mode 100644 .gitignore create mode 100644 DEVELOPMENT.md create mode 100644 LICENSE create mode 100644 README.md create mode 100644 backend/Cargo.lock create mode 100644 backend/Cargo.toml create mode 100644 backend/Dockerfile create mode 100644 backend/build.rs create mode 100644 backend/diesel.toml create mode 100644 backend/migrations/202510300000_initial_schema/down.sql create mode 100644 backend/migrations/202510300000_initial_schema/up.sql create mode 100644 backend/migrations/202510300001_rename_document_uploaded_at/down.sql create mode 100644 backend/migrations/202510300001_rename_document_uploaded_at/up.sql create mode 100644 backend/migrations/202510310000_create_magic_tokens/down.sql create mode 100644 backend/migrations/202510310000_create_magic_tokens/up.sql create mode 100644 backend/migrations/202510310100_split_schemas/down.sql create mode 100644 backend/migrations/202510310100_split_schemas/up.sql create mode 100644 backend/migrations/202510310200_enable_rls/down.sql create mode 100644 backend/migrations/202510310200_enable_rls/up.sql create mode 100644 backend/migrations/202510310201_enable_webdav_token_rls/down.sql create mode 100644 backend/migrations/202510310201_enable_webdav_token_rls/up.sql create mode 100644 backend/migrations/202511010000_api_tokens/down.sql create mode 100644 backend/migrations/202511010000_api_tokens/up.sql create mode 100644 backend/migrations/202511010001_rename_refresh_tokens_to_user_sessions/down.sql create mode 100644 backend/migrations/202511010001_rename_refresh_tokens_to_user_sessions/up.sql create mode 100644 backend/migrations/202511010002_set_assigned_by_on_delete_set_null/down.sql create mode 100644 backend/migrations/202511010002_set_assigned_by_on_delete_set_null/up.sql create mode 100644 backend/migrations/202511010003_capability_sets/down.sql create mode 100644 backend/migrations/202511010003_capability_sets/up.sql create mode 100644 backend/migrations/202511010004_purge_document_jobs/down.sql create mode 100644 backend/migrations/202511010004_purge_document_jobs/up.sql create mode 100644 backend/migrations/202511010005_documents_ordering_indexes/down.sql create mode 100644 backend/migrations/202511010005_documents_ordering_indexes/up.sql create mode 100644 backend/migrations/202511110000_make_jobs_tenant_nullable/down.sql create mode 100644 backend/migrations/202511110000_make_jobs_tenant_nullable/up.sql create mode 100644 backend/migrations/202511110100_add_tenant_capability_enum/down.sql create mode 100644 backend/migrations/202511110100_add_tenant_capability_enum/up.sql create mode 100644 backend/migrations/202511110200_owner_tenant_capabilities/down.sql create mode 100644 backend/migrations/202511110200_owner_tenant_capabilities/up.sql create mode 100644 backend/migrations/202511120000_collapse_asset_objects/down.sql create mode 100644 backend/migrations/202511120000_collapse_asset_objects/up.sql create mode 100644 backend/migrations/202511130000_rename_document_content_type/down.sql create mode 100644 backend/migrations/202511130000_rename_document_content_type/up.sql create mode 100644 backend/migrations/202511300000_rename_ocr_text_asset/down.sql create mode 100644 backend/migrations/202511300000_rename_ocr_text_asset/up.sql create mode 100644 backend/migrations/202511300100_add_title_trgm_index/down.sql create mode 100644 backend/migrations/202511300100_add_title_trgm_index/up.sql create mode 100644 backend/postgres-init/01_create_app_roles.sql create mode 100644 backend/resources/issued_at_months.yaml create mode 100644 backend/src/auth/api_tokens.rs create mode 100644 backend/src/auth/capability_guard.rs create mode 100644 backend/src/auth/capability_sets.rs create mode 100644 backend/src/auth/jwt.rs create mode 100644 backend/src/auth/mod.rs create mode 100644 backend/src/auth/passkeys.rs create mode 100644 backend/src/auth/password.rs create mode 100644 backend/src/bin/admin.rs create mode 100644 backend/src/bin/openapi_dump.rs create mode 100644 backend/src/bin/webdav.rs create mode 100644 backend/src/bin/worker.rs create mode 100644 backend/src/config.rs create mode 100644 backend/src/db.rs create mode 100644 backend/src/documents/asset.rs create mode 100644 backend/src/documents/correspondents.rs create mode 100644 backend/src/documents/folders.rs create mode 100644 backend/src/documents/metadata.rs create mode 100644 backend/src/documents/mod.rs create mode 100644 backend/src/documents/ordering.rs create mode 100644 backend/src/documents/relations.rs create mode 100644 backend/src/documents/search.rs create mode 100644 backend/src/documents/tags.rs create mode 100644 backend/src/error.rs create mode 100644 backend/src/http/mod.rs create mode 100644 backend/src/http/responders.rs create mode 100644 backend/src/issued_at.rs create mode 100644 backend/src/jobs.rs create mode 100644 backend/src/lib.rs create mode 100644 backend/src/main.rs create mode 100644 backend/src/migrations.rs create mode 100644 backend/src/models.rs create mode 100644 backend/src/openapi.rs create mode 100644 backend/src/routes/auth.rs create mode 100644 backend/src/routes/capability_sets.rs create mode 100644 backend/src/routes/correspondents.rs create mode 100644 backend/src/routes/documents.rs create mode 100644 backend/src/routes/folders.rs create mode 100644 backend/src/routes/health.rs create mode 100644 backend/src/routes/mod.rs create mode 100644 backend/src/routes/profile.rs create mode 100644 backend/src/routes/tags.rs create mode 100644 backend/src/routes/tenants.rs create mode 100644 backend/src/routes/webdav/mod.rs create mode 100644 backend/src/s3.rs create mode 100644 backend/src/schema.rs create mode 100644 backend/src/services/auth.rs create mode 100644 backend/src/services/capability_sets.rs create mode 100644 backend/src/services/correspondents.rs create mode 100644 backend/src/services/documents.rs create mode 100644 backend/src/services/folders.rs create mode 100644 backend/src/services/helpers.rs create mode 100644 backend/src/services/mod.rs create mode 100644 backend/src/services/profile.rs create mode 100644 backend/src/services/tags.rs create mode 100644 backend/src/services/tenants.rs create mode 100644 backend/src/state.rs create mode 100644 backend/src/storage.rs create mode 100644 backend/src/tenants.rs create mode 100644 backend/src/test_support/mod.rs create mode 100644 backend/src/utils/bootstrap.rs create mode 100644 backend/src/utils/db.rs create mode 100644 backend/src/utils/error.rs create mode 100644 backend/src/utils/http.rs create mode 100644 backend/src/utils/json.rs create mode 100644 backend/src/utils/mod.rs create mode 100644 backend/src/utils/named_entity.rs create mode 100644 backend/src/utils/setops.rs create mode 100644 backend/src/utils/storage_paths.rs create mode 100644 backend/src/utils/text.rs create mode 100644 backend/src/utils/time.rs create mode 100644 backend/src/utils/tracing.rs create mode 100644 backend/src/utils/validation.rs create mode 100644 backend/src/workers/analyze.rs create mode 100644 backend/src/workers/common.rs create mode 100644 backend/src/workers/index.rs create mode 100644 backend/src/workers/issued_at.rs create mode 100644 backend/src/workers/issued_at_months.rs create mode 100644 backend/src/workers/mod.rs create mode 100644 backend/src/workers/ocr.rs create mode 100644 backend/src/workers/purge.rs create mode 100644 backend/src/workers/taskflow/document.rs create mode 100644 backend/src/workers/taskflow/mod.rs create mode 100644 backend/src/workers/tenants.rs create mode 100644 backend/src/workers/thumbnails.rs create mode 100644 backend/tests/api_tokens_flow.rs create mode 100644 backend/tests/auth_flow.rs create mode 100644 backend/tests/capability_guards_flow.rs create mode 100644 backend/tests/capability_sets_flow.rs create mode 100644 backend/tests/common/mod.rs create mode 100644 backend/tests/correspondents_flow.rs create mode 100644 backend/tests/data/issued_at_cases.yaml create mode 100644 backend/tests/documents_flow.rs create mode 100644 backend/tests/download_flow.rs create mode 100644 backend/tests/folders_flow.rs create mode 100644 backend/tests/tags_flow.rs create mode 100644 backend/tests/tenant_deletion.rs create mode 100644 backend/tests/tenants_flow.rs create mode 100644 branding/README.md create mode 100644 branding/logo.afphoto create mode 100644 docker-compose.dev.yml create mode 100644 docker-compose.test.yml create mode 100644 docker-compose.yml create mode 100644 docs/api.txt create mode 100644 docs/api_responses.md create mode 100644 docs/capability_sets.md create mode 100644 docs/desktopworkspace.md create mode 100644 docs/deskview.png create mode 100644 docs/detailview.png create mode 100644 docs/document-model.md create mode 100644 docs/documentview.png create mode 100644 docs/jobs.md create mode 100644 docs/s3_cors_presign.md create mode 100644 docs/screenshot.png create mode 100644 docs/testing.md create mode 100644 frontend/.gitignore create mode 100644 frontend/Dockerfile create mode 100644 frontend/README.md create mode 100644 frontend/babel.config.js create mode 100644 frontend/docker-entrypoint.sh create mode 100644 frontend/eslint.config.mjs create mode 100644 frontend/package-lock.json create mode 100644 frontend/package.json create mode 100644 frontend/src/app/DocumentsRoute.tsx create mode 100644 frontend/src/app/DropOverlay.tsx create mode 100644 frontend/src/app/LoginRoute.tsx create mode 100644 frontend/src/app/PanelManagerContext.tsx create mode 100644 frontend/src/app/SettingsRoute.tsx create mode 100644 frontend/src/app/UploadQueueOverlay.tsx create mode 100644 frontend/src/app/WorkspaceSelectionContext.tsx create mode 100644 frontend/src/app/entryKey.ts create mode 100644 frontend/src/app/useDetailPanel.ts create mode 100644 frontend/src/app/useDocumentSelection.ts create mode 100644 frontend/src/app/useDocumentViewer.ts create mode 100644 frontend/src/app/useDocumentsPreferences.ts create mode 100644 frontend/src/app/useDocumentsSearch.ts create mode 100644 frontend/src/app/useDocumentsShell.ts create mode 100644 frontend/src/app/useManagementModals.tsx create mode 100644 frontend/src/app/useWorkspaceSelection.ts create mode 100644 frontend/src/app/useWorkspaceSurface.tsx create mode 100644 frontend/src/app/workspaceUtils.ts create mode 100644 frontend/src/assets/folder.svg create mode 100644 frontend/src/assets/logo.webp create mode 100644 frontend/src/assets/logo_small.webp create mode 100644 frontend/src/assets/papercorner.svg create mode 100644 frontend/src/components/BreadcrumbTrail.tsx create mode 100644 frontend/src/components/PanelHeader.tsx create mode 100644 frontend/src/components/QuickAddMenu.tsx create mode 100644 frontend/src/components/StatusToastOverlay.tsx create mode 100644 frontend/src/components/classNames.ts create mode 100644 frontend/src/components/icons.tsx create mode 100644 frontend/src/components/useFloatingMenu.ts create mode 100644 frontend/src/constants/app.ts create mode 100644 frontend/src/constants/colors.ts create mode 100644 frontend/src/constants/desktop.ts create mode 100644 frontend/src/constants/documents.ts create mode 100644 frontend/src/constants/layout.ts create mode 100644 frontend/src/constants/preview.ts create mode 100644 frontend/src/constants/settings.ts create mode 100644 frontend/src/constants/sidebar.ts create mode 100644 frontend/src/constants/ui.ts create mode 100644 frontend/src/constants/workspace.ts create mode 100644 frontend/src/correspondents/CorrespondentsPanel.tsx create mode 100644 frontend/src/desktop/components/DesktopDocumentCard.tsx create mode 100644 frontend/src/desktop/components/DesktopPreviewCard.tsx create mode 100644 frontend/src/desktop/components/DesktopWorkspace.tsx create mode 100644 frontend/src/desktop/components/workspace-cards.css create mode 100644 frontend/src/desktop/components/workspace-items.css create mode 100644 frontend/src/desktop/components/workspace-layout.css create mode 100644 frontend/src/desktop/hooks/usePreviewMetadata.ts create mode 100644 frontend/src/desktop/interactions/PointerTrackingContext.tsx create mode 100644 frontend/src/desktop/interactions/useCardPointer.ts create mode 100644 frontend/src/desktop/logic/CardDragLogic.ts create mode 100644 frontend/src/desktop/logic/CardPhysics.ts create mode 100644 frontend/src/desktop/logic/LayoutSystem.ts create mode 100644 frontend/src/desktop/logic/db.ts create mode 100644 frontend/src/desktop/utils/layoutUtils.ts create mode 100644 frontend/src/documents/CorrespondentLinks.tsx create mode 100644 frontend/src/documents/DocumentThumbnailImage.tsx create mode 100644 frontend/src/documents/DocumentsManager.ts create mode 100644 frontend/src/documents/DocumentsView.tsx create mode 100644 frontend/src/documents/FoldersManager.ts create mode 100644 frontend/src/documents/components/DocumentDownloadLink.tsx create mode 100644 frontend/src/documents/components/DocumentEntry.tsx create mode 100644 frontend/src/documents/components/DocumentTags.tsx create mode 100644 frontend/src/documents/components/DocumentsGridCard.tsx create mode 100644 frontend/src/documents/components/DocumentsGridContainer.tsx create mode 100644 frontend/src/documents/components/DocumentsListContainer.tsx create mode 100644 frontend/src/documents/components/DocumentsListRow.tsx create mode 100644 frontend/src/documents/components/EditableEntryTitle.tsx create mode 100644 frontend/src/documents/components/EntryCorrespondents.tsx create mode 100644 frontend/src/documents/components/EntryShell.tsx create mode 100644 frontend/src/documents/components/FolderEntry.tsx create mode 100644 frontend/src/documents/components/InlineRenameInput.tsx create mode 100644 frontend/src/documents/components/TagRemovalZone.css create mode 100644 frontend/src/documents/components/TagRemovalZone.tsx create mode 100644 frontend/src/documents/context/DocumentsAssetContext.tsx create mode 100644 frontend/src/documents/context/DocumentsCommandContext.tsx create mode 100644 frontend/src/documents/context/DocumentsFilterContext.tsx create mode 100644 frontend/src/documents/context/DocumentsViewStateContext.tsx create mode 100644 frontend/src/documents/correspondents.ts create mode 100644 frontend/src/documents/data/useAuthManager.ts create mode 100644 frontend/src/documents/data/useBulkDocumentActions.ts create mode 100644 frontend/src/documents/data/useCorrespondents.ts create mode 100644 frontend/src/documents/data/useDocumentCorrespondentMutations.ts create mode 100644 frontend/src/documents/data/useDocumentMoveMutations.ts create mode 100644 frontend/src/documents/data/useDocumentMutations.ts create mode 100644 frontend/src/documents/data/useDocumentTagMutations.ts create mode 100644 frontend/src/documents/data/useDocuments.ts create mode 100644 frontend/src/documents/data/useDocumentsWorkspace.ts create mode 100644 frontend/src/documents/data/useTags.ts create mode 100644 frontend/src/documents/data/useTenantManager.ts create mode 100644 frontend/src/documents/data/useWorkspaceViewData.ts create mode 100644 frontend/src/documents/documentActions.ts create mode 100644 frontend/src/documents/features/folders/useFolderItemLogic.ts create mode 100644 frontend/src/documents/features/folders/useFolderTree.ts create mode 100644 frontend/src/documents/features/folders/useFolderTreeActions.ts create mode 100644 frontend/src/documents/features/renaming/useInlineRename.ts create mode 100644 frontend/src/documents/features/selection/SelectionAssignmentMenu.tsx create mode 100644 frontend/src/documents/features/selection/SelectionFloatingActions.tsx create mode 100644 frontend/src/documents/features/selection/SelectionFolderMenu.tsx create mode 100644 frontend/src/documents/features/selection/SelectionSummary.tsx create mode 100644 frontend/src/documents/features/selection/useDocumentsSelection.ts create mode 100644 frontend/src/documents/features/selection/useEntryPointer.ts create mode 100644 frontend/src/documents/features/selection/useWorkspaceSelectionSync.ts create mode 100644 frontend/src/documents/features/tagging/tagTransfer.ts create mode 100644 frontend/src/documents/features/upload/useDocumentDragHandlers.ts create mode 100644 frontend/src/documents/features/upload/useDocumentUploads.ts create mode 100644 frontend/src/documents/features/upload/useFileDrop.ts create mode 100644 frontend/src/documents/interactions/useTagInteractions.ts create mode 100644 frontend/src/documents/logic/breadcrumbs.ts create mode 100644 frontend/src/documents/logic/useDocumentItemLogic.ts create mode 100644 frontend/src/documents/logic/useDocumentViewLogic.ts create mode 100644 frontend/src/documents/logic/useDocumentsNavigation.ts create mode 100644 frontend/src/documents/panel/DocumentsPanel.tsx create mode 100644 frontend/src/documents/panel/DocumentsPanelHeader.tsx create mode 100644 frontend/src/documents/panel/DocumentsToolbar.tsx create mode 100644 frontend/src/documents/panel/SortFieldQuickMenu.tsx create mode 100644 frontend/src/documents/panel/useDocumentsContextValues.ts create mode 100644 frontend/src/documents/styles/controls.css create mode 100644 frontend/src/documents/styles/drag-preview.css create mode 100644 frontend/src/documents/styles/listing.css create mode 100644 frontend/src/documents/styles/panel-sections.css create mode 100644 frontend/src/documents/styles/shared-snippets.css create mode 100644 frontend/src/documents/styles/tags-correspondents.css create mode 100644 frontend/src/documents/types/workspaceTypes.ts create mode 100644 frontend/src/folders/FolderManagerContext.tsx create mode 100644 frontend/src/hooks/useApiError.ts create mode 100644 frontend/src/hooks/useNotifyApiError.ts create mode 100644 frontend/src/index.html create mode 100644 frontend/src/index.tsx create mode 100644 frontend/src/lib/api/api.ts create mode 100644 frontend/src/lib/api/apiClient.ts create mode 100644 frontend/src/lib/api/apiTypes.ts create mode 100644 frontend/src/lib/assets/AssetManager.ts create mode 100644 frontend/src/lib/assets/CorrespondentManager.ts create mode 100644 frontend/src/lib/assets/TagManager.ts create mode 100644 frontend/src/lib/context/ApiContext.tsx create mode 100644 frontend/src/lib/context/AppShellContext.ts create mode 100644 frontend/src/lib/context/DocumentOpenContext.tsx create mode 100644 frontend/src/lib/context/StatusToastContext.tsx create mode 100644 frontend/src/lib/store/appState.tsx create mode 100644 frontend/src/login/LoginView.tsx create mode 100644 frontend/src/login/login.css create mode 100644 frontend/src/settings/SettingsModal.tsx create mode 100644 frontend/src/settings/components/CapabilityDropdown.tsx create mode 100644 frontend/src/settings/sections/ApiTokensSection.tsx create mode 100644 frontend/src/settings/sections/CapabilitySetsSection.tsx create mode 100644 frontend/src/settings/sections/PasskeysSection.tsx create mode 100644 frontend/src/settings/settings.css create mode 100644 frontend/src/settings/useApiTokens.ts create mode 100644 frontend/src/settings/useCapabilities.ts create mode 100644 frontend/src/settings/useCapabilitySets.ts create mode 100644 frontend/src/settings/usePasskeys.ts create mode 100644 frontend/src/sidebar/Sidebar.tsx create mode 100644 frontend/src/sidebar/SidebarContext.tsx create mode 100644 frontend/src/sidebar/components/SidebarCorrespondentList.tsx create mode 100644 frontend/src/sidebar/components/SidebarFolderList.tsx create mode 100644 frontend/src/sidebar/components/SidebarFolderNode.tsx create mode 100644 frontend/src/sidebar/components/SidebarHeader.tsx create mode 100644 frontend/src/sidebar/components/SidebarMenu.tsx create mode 100644 frontend/src/sidebar/components/SidebarSearch.tsx create mode 100644 frontend/src/sidebar/components/SidebarTagList.tsx create mode 100644 frontend/src/sidebar/sidebar.css create mode 100644 frontend/src/styles/base/controls.css create mode 100644 frontend/src/styles/base/iconography.css create mode 100644 frontend/src/styles/base/text-button.css create mode 100644 frontend/src/styles/base/theme.css create mode 100644 frontend/src/styles/components/resize-handle.css create mode 100644 frontend/src/styles/forms/elements.css create mode 100644 frontend/src/styles/index.css create mode 100644 frontend/src/styles/layout/panel-header-controls.css create mode 100644 frontend/src/styles/layout/responsive.css create mode 100644 frontend/src/styles/layout/structure.css create mode 100644 frontend/src/styles/modals/panel-modals.css create mode 100644 frontend/src/styles/modals/status-drop.css create mode 100644 frontend/src/styles/status-toast.css create mode 100644 frontend/src/styles/uploads/overlay.css create mode 100644 frontend/src/tags/TagsPanel.tsx create mode 100644 frontend/src/types/assets.ts create mode 100644 frontend/src/types/common.ts create mode 100644 frontend/src/types/documents.ts create mode 100644 frontend/src/types/identifiers.ts create mode 100644 frontend/src/types/svg.d.ts create mode 100644 frontend/src/utils/colors.ts create mode 100644 frontend/src/utils/createSafeContext.ts create mode 100644 frontend/src/utils/date.ts create mode 100644 frontend/src/utils/format.ts create mode 100644 frontend/src/utils/math.ts create mode 100644 frontend/src/utils/webauthn.ts create mode 100644 frontend/src/viewer/DocumentViewerLayout.tsx create mode 100644 frontend/src/viewer/DocumentViewerPanel.tsx create mode 100644 frontend/src/viewer/FullscreenPreviewContext.tsx create mode 100644 frontend/src/viewer/MediaViewer.tsx create mode 100644 frontend/src/viewer/PdfViewer.tsx create mode 100644 frontend/src/viewer/UnifiedDocumentViewer.tsx create mode 100644 frontend/src/viewer/components/DocumentInfoPanel.tsx create mode 100644 frontend/src/viewer/components/DocumentSummarySection.tsx create mode 100644 frontend/src/viewer/components/FullscreenPreviewOverlay.tsx create mode 100644 frontend/src/viewer/fullscreen-preview.css create mode 100644 frontend/src/viewer/logic/documentSummary.ts create mode 100644 frontend/src/viewer/logic/useDetailWorkspace.ts create mode 100644 frontend/src/viewer/styles/detail-panels.css create mode 100644 frontend/src/viewer/styles/viewer.css create mode 100644 frontend/src/viewer/useViewerLayoutMode.ts create mode 100644 frontend/tsconfig.json create mode 100644 frontend/webpack.config.js create mode 100644 k8s/papercrate/.helmignore create mode 100644 k8s/papercrate/Chart.yaml create mode 100644 k8s/papercrate/templates/NOTES.txt create mode 100644 k8s/papercrate/templates/_helpers.tpl create mode 100644 k8s/papercrate/templates/backend-deployment.yaml create mode 100644 k8s/papercrate/templates/backend-secret.yaml create mode 100644 k8s/papercrate/templates/backend-service.yaml create mode 100644 k8s/papercrate/templates/frontend-deployment.yaml create mode 100644 k8s/papercrate/templates/frontend-service.yaml create mode 100644 k8s/papercrate/templates/ingress.yaml create mode 100644 k8s/papercrate/templates/migrate-job.yaml create mode 100644 k8s/papercrate/templates/migrate-secret.yaml create mode 100644 k8s/papercrate/templates/postgres-configmap.yaml create mode 100644 k8s/papercrate/templates/postgres-secret.yaml create mode 100644 k8s/papercrate/templates/postgres-service.yaml create mode 100644 k8s/papercrate/templates/postgres-statefulset.yaml create mode 100644 k8s/papercrate/templates/quickwit-deployment.yaml create mode 100644 k8s/papercrate/templates/quickwit-service.yaml create mode 100644 k8s/papercrate/templates/webdav-deployment.yaml create mode 100644 k8s/papercrate/templates/webdav-service.yaml create mode 100644 k8s/papercrate/templates/worker-deployment.yaml create mode 100644 k8s/papercrate/values.yaml create mode 100644 papercrate.tmux diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml new file mode 100644 index 0000000..cee57d7 --- /dev/null +++ b/.github/workflows/release.yaml @@ -0,0 +1,146 @@ +name: ci + +on: + push: + branches: + - main + - staging + - dev + tags: + - '*' + +jobs: + docker: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + strategy: + matrix: + include: + - service: frontend + context: frontend + dockerfile: frontend/Dockerfile + - service: backend + context: backend + dockerfile: backend/Dockerfile + + steps: + - name: Checkout with submodules + uses: actions/checkout@v4 + with: + submodules: recursive + + - name: Derive repository metadata + id: repo_meta + run: | + repo="${GITHUB_REPOSITORY}" + owner="${repo%%/*}" + name="${repo##*/}" + echo "repo_owner=$owner" >> "$GITHUB_OUTPUT" + echo "repo_name=$name" >> "$GITHUB_OUTPUT" + + - name: Compute base tag + id: compute_tag + env: + GITHUB_SHA: ${{ github.sha }} + GITHUB_REF_TYPE: ${{ github.ref_type }} + GITHUB_REF_NAME: ${{ github.ref_name }} + run: | + sha="${GITHUB_SHA}" + ref_type="${GITHUB_REF_TYPE}" + ref_name="${GITHUB_REF_NAME}" + + short="${sha:0:7}" + tag="$short" + + if [ "$ref_type" = "tag" ]; then + tag="$ref_name" + elif [ "$ref_name" = "dev" ]; then + tag="${tag}-dev" + elif [ "$ref_name" = "staging" ]; then + tag="${tag}-staging" + fi + + echo "base_tag=$tag" >> "$GITHUB_OUTPUT" + + - name: Login to local registry + if: ${{ vars.REGISTRY_URL != '' }} + uses: docker/login-action@v2 + with: + registry: ${{ vars.REGISTRY_URL }} + username: ${{ vars.REGISTRY_USER }} + password: ${{ secrets.REGISTRY_PASSWORD }} + + - name: Login to GHCR + uses: docker/login-action@v2 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ github.token }} + + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Determine branch alias tag + id: branch_alias + env: + REF_NAME: ${{ github.ref_name }} + run: | + alias="" + case "${REF_NAME}" in + dev) alias="latest-dev" ;; + staging) alias="latest-staging" ;; + main) alias="latest" ;; + esac + echo "alias=$alias" >> "$GITHUB_OUTPUT" + + - name: Assemble image tags + id: tag_list + env: + REGISTRY_URL: ${{ vars.REGISTRY_URL }} + REPO_NAME: ${{ steps.repo_meta.outputs.repo_name }} + SERVICE: ${{ matrix.service }} + BASE_TAG: ${{ steps.compute_tag.outputs.base_tag }} + GIT_SHA: ${{ github.sha }} + BRANCH_ALIAS: ${{ steps.branch_alias.outputs.alias }} + shell: bash + run: | + tags="" + + if [ -n "${REGISTRY_URL}" ]; then + repo_tag="${REGISTRY_URL}/${REPO_NAME}-${SERVICE}" + tags="${tags}${repo_tag}:${BASE_TAG}\n${repo_tag}:${GIT_SHA}" + fi + + ghcr_tag="ghcr.io/papercrate-dms/${REPO_NAME}-${SERVICE}" + if [ -n "${tags}" ]; then + tags="${tags}\n" + fi + tags="${tags}${ghcr_tag}:${BASE_TAG}\n${ghcr_tag}:${GIT_SHA}" + + if [ -n "${BRANCH_ALIAS}" ]; then + if [ -n "${REGISTRY_URL}" ]; then + tags="${tags}\n${repo_tag}:${BRANCH_ALIAS}" + fi + tags="${tags}\n${ghcr_tag}:${BRANCH_ALIAS}" + fi + + export TAGS="${tags}" + + python -c 'import os; tags=[t.strip() for t in os.environ["TAGS"].split("\\n") if t.strip()]; print("tags=" + ",".join(tags))' | tee -a "$GITHUB_OUTPUT" + + - name: Build and Push ${{ matrix.service }} Image + uses: docker/build-push-action@v6 + with: + context: ${{ matrix.context }} + file: ${{ matrix.dockerfile }} + platforms: linux/amd64,linux/arm64 + push: true + provenance: false + tags: ${{ steps.tag_list.outputs.tags }} + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..54f87dc --- /dev/null +++ b/.gitignore @@ -0,0 +1,27 @@ +# macOS +.DS_Store + +# Rust +/backend/target/ +/backend/.env +/backend/.env.* +/backend/.cargo/ +backend/libpdfium.* + +# Node/Frontend +/frontend/node_modules/ +/frontend/dist/ +/frontend/.env +/frontend/.env.* + +# Logs and temp +*.log +*.tmp +*.swp + +# Docker artifacts +*.pid + +# Environment overrides +.env +.env.local diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md new file mode 100644 index 0000000..874afbe --- /dev/null +++ b/DEVELOPMENT.md @@ -0,0 +1,107 @@ +# Development + +This document collects runtime assumptions and workflows for local development, +integration testing, and infrastructure automation. + +## Local Development + +Use the provided `papercrate.tmux` to spin up the full stack in one tmux session: + +```bash +tmux -f papercrate.tmux attach +``` + +This creates windows for the compose stack, frontend dev server, backend API, and +background worker using the repository-relative paths defined in the tmux file. +Detach with `Ctrl+b d` and reattach later with the same command. + +The development Postgres container now seeds two database roles: + +- `papercrate_app_login` (password `papercrate_app`) is used by the backend and + is subject to row-level security policies. +- `papercrate` remains the owner role for running Diesel migrations or other + maintenance tasks. + +When connecting manually to inspect RLS behaviour, switch to the application +role with `SET ROLE papercrate_app_login;` before querying tenant tables. + +## Backend Integration Tests + +Integration tests require a running Postgres instance (and, optionally, Quickwit +for OCR indexing). The repository includes a lightweight compose file for local +runs: + +```bash +docker compose -f docker-compose.test.yml up -d +export TEST_DATABASE_URL=postgres://papercrate:papercrate_test@localhost:5433/papercrate_test +# optional, enables Quickwit indexing jobs +export QUICKWIT_ENDPOINT=http://localhost:7280 +export QUICKWIT_INDEX=documents +cargo test +``` + +Stop the database when you are done: + +```bash +docker compose -f docker-compose.test.yml down +``` + +The compose service uses tmpfs storage, giving each test run a clean database. + +## Runtime Dependencies + +- `ocrmypdf` (optional but recommended): Used by the OCR worker to extract text + from PDFs when no embedded text layer is available. Ensure it is installed and + available on the worker hosts if OCR is desired. +- Quickwit (optional): The Quickwit indexer is used to ingest extracted text for + search. Set `QUICKWIT_ENDPOINT` and `QUICKWIT_INDEX` in the environment when + running workers if you want indexing jobs to run. The local compose file starts + a Quickwit instance on `http://localhost:7280` and seeds the `documents` index + automatically. + +## Configuration + +The backend reads its settings from environment variables. In particular: + +- `DATABASE_URL` – connection string for the primary Postgres database (required). +- `DATABASE_MAX_POOL_SIZE` – optional override for the r2d2 connection pool size. + Defaults to `2`; increase it in staging/production to match expected concurrency. +- `PROXY_DOWNLOADS` – set to `true` when the object store is only reachable from + the backend network. When enabled, `/api/download/{token}` and asset-object fetches + stream bytes through the API instead of redirecting clients to S3/Hetzner. + +On startup each binary logs the effective configuration with secrets redacted +(for example, the database password is masked). This makes it easier to confirm +runtime settings in staging without exposing credentials. + +## Running Migrations in Kubernetes + +The backend container image ships the `diesel` CLI, so schema migrations can be +executed as a short-lived Job (or Helm hook) before rolling out new pods. Example +manifest: + +```yaml +apiVersion: batch/v1 +kind: Job +metadata: + name: papercrate-migrate +spec: + template: + spec: + restartPolicy: OnFailure + containers: + - name: migrate + image: ghcr.io/example/papercrate-backend: + command: ["/usr/local/bin/diesel", "migration", "run"] + env: + - name: DATABASE_URL + valueFrom: + secretKeyRef: + name: papercrate-db + key: DATABASE_URL +``` + +Run the Job manually (`kubectl apply -f migrate-job.yaml`) or configure it as a +Helm pre-install/pre-upgrade hook so migrations run automatically on each +deployment. Once the Job succeeds, deploy/update the backend `Deployment` as +usual. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..be3f7b2 --- /dev/null +++ b/LICENSE @@ -0,0 +1,661 @@ + GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +our General Public Licenses are intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + Developers that use our General Public Licenses protect your rights +with two steps: (1) assert copyright on the software, and (2) offer +you this License which gives you legal permission to copy, distribute +and/or modify the software. + + A secondary benefit of defending all users' freedom is that +improvements made in alternate versions of the program, if they +receive widespread use, become available for other developers to +incorporate. Many developers of free software are heartened and +encouraged by the resulting cooperation. However, in the case of +software used on network servers, this result may fail to come about. +The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its +source code to the public. + + The GNU Affero General Public License is designed specifically to +ensure that, in such cases, the modified source code becomes available +to the community. It requires the operator of a network server to +provide the source code of the modified version running there to the +users of that server. Therefore, public use of a modified version, on +a publicly accessible server, gives the public access to the source +code of the modified version. + + An older license, called the Affero General Public License and +published by Affero, was designed to accomplish similar goals. This is +a different license, not a version of the Affero GPL, but Affero has +released a new version of the Affero GPL which permits relicensing under +this license. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU Affero General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Remote Network Interaction; Use with the GNU General Public License. + + Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users +interacting with it remotely through a computer network (if your version +supports such interaction) an opportunity to receive the Corresponding +Source of your version by providing access to the Corresponding Source +from a network server at no charge, through some standard or customary +means of facilitating copying of software. This Corresponding Source +shall include the Corresponding Source for any work covered by version 3 +of the GNU General Public License that is incorporated pursuant to the +following paragraph. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the work with which it is combined will remain governed by version +3 of the GNU General Public License. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU Affero General Public License from time to time. Such new versions +will be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU Affero General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU Affero General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU Affero General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If your software can interact with users remotely through a computer +network, you should also make sure that it provides a way for users to +get its source. For example, if your program is a web application, its +interface could display a "Source" link that leads users to an archive +of the code. There are many ways you could offer source, and different +solutions will be better for different programs; see section 13 for the +specific requirements. + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU AGPL, see +. diff --git a/README.md b/README.md new file mode 100644 index 0000000..537e08e --- /dev/null +++ b/README.md @@ -0,0 +1,48 @@ +# Papercrate + +![Papercrate document workspace](docs/screenshot.png) + +## Single-Host Deployment (Docker Compose) + +For self-hosting (for example on a Raspberry Pi), use the production-oriented +`docker-compose.yml`. Define the required secrets in a `.env` file alongside the +compose file before starting the stack: + +```bash +cat <<'EOF' > .env +POSTGRES_PASSWORD=change-me +MINIO_ROOT_PASSWORD=change-me-too +JWT_SECRET=generate-a-long-random-string +WEBAUTHN_RP_ID=papercrate.local +WEBAUTHN_ORIGIN=http://papercrate.local:8080 +# Optional overrides +# CORS_ALLOWED_ORIGIN=http://papercrate.local:8080 +# REFRESH_COOKIE_SECURE=true +EOF + +docker compose build +docker compose up -d +``` + +**Important:** the WebAuthn settings must match the public URL clients will use. +The relying party (RP) identifier is the bare host name, while the origin must +include scheme and port. Adjust the values above if you serve Papercrate from a +different host, domain, or HTTPS endpoint—otherwise passkey registration and +login will fail. + +The compose file builds the backend and frontend images locally, then launches +Postgres, MinIO, Quickwit, the API, background worker, WebDAV endpoint, and the +SPA frontend. Once the containers report healthy, visit `http://:8080` +and use the passkey signup flow to provision the first tenant/user. Upgrades are +as simple as `git pull` followed by `docker compose up -d`. + +## Screenshots + +![Document detail view](docs/detailview.png) +![Desk overview](docs/deskview.png) +![Document view](docs/documentview.png) + +--- + +For development workflows (local stack, integration tests, migrations, and +configuration details) see [DEVELOPMENT.md](./DEVELOPMENT.md). diff --git a/backend/Cargo.lock b/backend/Cargo.lock new file mode 100644 index 0000000..06b92ce --- /dev/null +++ b/backend/Cargo.lock @@ -0,0 +1,4449 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anstream" +version = "0.6.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" + +[[package]] +name = "anstyle-parse" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e231f6134f61b71076a3eab506c379d4f36122f2af15a9ff04415ea4c3339e2" +dependencies = [ + "windows-sys 0.60.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e0633414522a32ffaac8ac6cc8f748e090c5717661fddeea04219e2344f5f2a" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.60.2", +] + +[[package]] +name = "anyhow" +version = "1.0.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" + +[[package]] +name = "argon2" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" +dependencies = [ + "base64ct", + "blake2", + "cpufeatures", + "password-hash", +] + +[[package]] +name = "asn1-rs" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5493c3bedbacf7fd7382c6346bbd66687d12bbaad3a89a2d2c303ee6cf20b048" +dependencies = [ + "asn1-rs-derive", + "asn1-rs-impl", + "displaydoc", + "nom", + "num-traits", + "rusticata-macros", + "thiserror 1.0.69", + "time", +] + +[[package]] +name = "asn1-rs-derive" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "965c2d33e53cb6b267e148a4cb0760bc01f4904c1cd4bb4002a085bb016d1490" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.109", + "synstructure", +] + +[[package]] +name = "asn1-rs-impl" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.109", +] + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.109", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "attohttpc" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16e2cdb6d5ed835199484bb92bb8b3edd526effe995c61732580439c1a67e2e9" +dependencies = [ + "base64 0.22.1", + "http", + "log", + "native-tls", + "rustls", + "serde", + "serde_json", + "url", + "webpki-roots", +] + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "aws-creds" +version = "0.39.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b13804829a843b3f26e151c97acbb315ee1177a2724690edfcd28f1894146200" +dependencies = [ + "attohttpc", + "home", + "log", + "quick-xml", + "rust-ini", + "serde", + "thiserror 2.0.17", + "time", + "url", +] + +[[package]] +name = "aws-lc-rs" +version = "1.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879b6c89592deb404ba4dc0ae6b58ffd1795c78991cbb5b8bc441c48a070440d" +dependencies = [ + "aws-lc-sys", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.32.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "107a4e9d9cab9963e04e84bb8dee0e25f2a987f9a8bad5ed054abd439caa8f8c" +dependencies = [ + "bindgen", + "cc", + "cmake", + "dunce", + "fs_extra", +] + +[[package]] +name = "aws-region" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5532f65342f789f9c1b7078ea9c9cd9293cd62dcc284fa99adc4a1c9ba43469c" +dependencies = [ + "thiserror 2.0.17", +] + +[[package]] +name = "axum" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a18ed336352031311f4e0b4dd2ff392d4fbb370777c9d18d7fc9d7359f73871" +dependencies = [ + "axum-core", + "bytes", + "form_urlencoded", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "multer", + "percent-encoding", + "pin-project-lite", + "serde_core", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59446ce19cd142f8833f856eb31f3eb097812d1479ab224f54d72428ca21ea22" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-extra" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5136e6c5e7e7978fe23e9876fb924af2c0f84c72127ac6ac17e7c46f457d362c" +dependencies = [ + "axum", + "axum-core", + "bytes", + "futures-core", + "futures-util", + "headers", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "base16ct" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" + +[[package]] +name = "base64" +version = "0.21.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64ct" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55248b47b0caf0546f7988906588779981c43bb1bc9d0c44087278f80cdb44ba" + +[[package]] +name = "base64urlsafedata" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "215ee31f8a88f588c349ce2d20108b2ed96089b96b9c2b03775dc35dd72938e8" +dependencies = [ + "base64 0.21.7", + "pastey", + "serde", +] + +[[package]] +name = "bindgen" +version = "0.72.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895" +dependencies = [ + "bitflags", + "cexpr", + "clang-sys", + "itertools 0.13.0", + "log", + "prettyplease", + "proc-macro2", + "quote", + "regex", + "rustc-hash", + "shlex", + "syn 2.0.109", +] + +[[package]] +name = "bitflags" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" + +[[package]] +name = "blake2" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" +dependencies = [ + "digest", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bumpalo" +version = "3.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43" + +[[package]] +name = "bytemuck" +version = "1.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbdf580320f38b612e485521afda1ee26d10cc9884efaaa750d383e13e3c5f4" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + +[[package]] +name = "bytes" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a" + +[[package]] +name = "castaway" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" +dependencies = [ + "rustversion", +] + +[[package]] +name = "cc" +version = "1.2.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35900b6c8d709fb1d854671ae27aeaa9eec2f8b01b364e1619a40da3e6fe2afe" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cexpr" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" +dependencies = [ + "nom", +] + +[[package]] +name = "cfb" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d38f2da7a0a2c4ccf0065be06397cc26a81f4e528be095826eee9d4adbb8c60f" +dependencies = [ + "byteorder", + "fnv", + "uuid", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "chrono" +version = "0.4.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "145052bdd345b87320e369255277e3fb5152762ad123a901ef5c262dd38fe8d2" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link 0.2.1", +] + +[[package]] +name = "chrono-tz" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d59ae0466b83e838b81a54256c39d5d7c20b9d7daa10510a242d9b75abd5936e" +dependencies = [ + "chrono", + "chrono-tz-build", + "phf", +] + +[[package]] +name = "chrono-tz-build" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "433e39f13c9a060046954e0592a8d0a4bcb1040125cbf91cb8ee58964cfb350f" +dependencies = [ + "parse-zoneinfo", + "phf", + "phf_codegen", +] + +[[package]] +name = "clang-sys" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b023947811758c97c59bf9d1c188fd619ad4718dcaa767947df1cadb14f39f4" +dependencies = [ + "glob", + "libc", + "libloading 0.8.9", +] + +[[package]] +name = "clap" +version = "4.5.51" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c26d721170e0295f191a69bd9a1f93efcdb0aff38684b61ab5750468972e5f5" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.5.51" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75835f0c7bf681bfd05abe44e965760fea999a5286c6eb2d59883634fd02011a" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.5.49" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a0b5487afeab2deb2ff4e03a807ad1a03ac532ff5a2cee5d86884440c7f7671" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.109", +] + +[[package]] +name = "clap_lex" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1d728cc89cf3aee9ff92b05e62b19ee65a02b5702cff7d5a377e32c6ae29d8d" + +[[package]] +name = "cmake" +version = "0.1.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7caa3f9de89ddbe2c607f4101924c5abec803763ae9534e4f4d7d8f84aa81f0" +dependencies = [ + "cc", +] + +[[package]] +name = "colorchoice" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" + +[[package]] +name = "compact_str" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f86b9c4c00838774a6d902ef931eff7470720c51d90c2e32cfe15dc304737b3f" +dependencies = [ + "castaway", + "cfg-if", + "itoa", + "ryu", + "static_assertions", +] + +[[package]] +name = "console_error_panic_hook" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a06aeb73f470f66dcdbf7223caeebb85984942f22f1adb2a088cf9668146bbbc" +dependencies = [ + "cfg-if", + "wasm-bindgen", +] + +[[package]] +name = "console_log" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be8aed40e4edbf4d3b4431ab260b63fdc40f5780a4766824329ea0f1eefe3c0f" +dependencies = [ + "log", + "web-sys", +] + +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "const-random" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87e00182fe74b066627d63b85fd550ac2998d4b0bd86bfed477a0ae4c7c71359" +dependencies = [ + "const-random-macro", +] + +[[package]] +name = "const-random-macro" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9d839f2a20b0aee515dc581a6172f2321f96cab76c1a38a4c584a194955390e" +dependencies = [ + "getrandom 0.2.16", + "once_cell", + "tiny-keccak", +] + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + +[[package]] +name = "crypto-bigint" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" +dependencies = [ + "generic-array", + "rand_core 0.6.4", + "subtle", + "zeroize", +] + +[[package]] +name = "crypto-common" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures", + "curve25519-dalek-derive", + "digest", + "fiat-crypto", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.109", +] + +[[package]] +name = "darling" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cdf337090841a411e2a7f3deb9187445851f91b309c0c0a29e05f74a00a48c0" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1247195ecd7e3c85f83c8d2a366e4210d588e802133e1e355180a9870b517ea4" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.109", +] + +[[package]] +name = "darling_macro" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81" +dependencies = [ + "darling_core", + "quote", + "syn 2.0.109", +] + +[[package]] +name = "data-encoding" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a2330da5de22e8a3cb63252ce2abb30116bf5265e89c0e01bc17015ce30a476" + +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "pem-rfc7468", + "zeroize", +] + +[[package]] +name = "der-parser" +version = "9.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cd0a5c643689626bec213c4d8bd4d96acc8ffdb4ad4bb6bc16abf27d5f4b553" +dependencies = [ + "asn1-rs", + "displaydoc", + "nom", + "num-bigint", + "num-traits", + "rusticata-macros", +] + +[[package]] +name = "deranged" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ececcb659e7ba858fb4f10388c250a7252eb0a27373f1a72b8748afdd248e587" +dependencies = [ + "powerfmt", + "serde_core", +] + +[[package]] +name = "diesel" +version = "2.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e7624a3bb9fffd82fff016be9a7f163d20e5a89eb8d28f9daaa6b30fff37500" +dependencies = [ + "bitflags", + "byteorder", + "chrono", + "diesel_derives", + "downcast-rs", + "itoa", + "pq-sys", + "r2d2", + "serde_json", + "uuid", +] + +[[package]] +name = "diesel_derives" +version = "2.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9daac6489a36e42570da165a10c424f3edcefdff70c5fd55e1847c23f3dd7562" +dependencies = [ + "diesel_table_macro_syntax", + "dsl_auto_type", + "proc-macro2", + "quote", + "syn 2.0.109", +] + +[[package]] +name = "diesel_migrations" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee060f709c3e3b1cadd83fcd0f61711f7a8cf493348f758d3a1c1147d70b3c97" +dependencies = [ + "diesel", + "migrations_internals", + "migrations_macros", +] + +[[package]] +name = "diesel_table_macro_syntax" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe2444076b48641147115697648dc743c2c00b61adade0f01ce67133c7babe8c" +dependencies = [ + "syn 2.0.109", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "const-oid", + "crypto-common", + "subtle", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.109", +] + +[[package]] +name = "dlv-list" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "442039f5147480ba31067cb00ada1adae6892028e40e45fc5de7b7df6dcc1b5f" +dependencies = [ + "const-random", +] + +[[package]] +name = "dotenv" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77c90badedccf4105eca100756a0b1289e191f6fcbdadd3cee1d2f614f97da8f" + +[[package]] +name = "downcast-rs" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "117240f60069e65410b3ae1bb213295bd828f707b5bec6596a1afc8793ce0cbc" + +[[package]] +name = "dsl_auto_type" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd122633e4bef06db27737f21d3738fb89c8f6d5360d6d9d7635dda142a7757e" +dependencies = [ + "darling", + "either", + "heck", + "proc-macro2", + "quote", + "syn 2.0.109", +] + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "ecdsa" +version = "0.16.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" +dependencies = [ + "der", + "digest", + "elliptic-curve", + "rfc6979", + "signature", + "spki", +] + +[[package]] +name = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "pkcs8", + "signature", +] + +[[package]] +name = "ed25519-dalek" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" +dependencies = [ + "curve25519-dalek", + "ed25519", + "serde", + "sha2", + "subtle", + "zeroize", +] + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + +[[package]] +name = "elliptic-curve" +version = "0.13.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" +dependencies = [ + "base16ct", + "crypto-bigint", + "digest", + "ff", + "generic-array", + "group", + "hkdf", + "pem-rfc7468", + "pkcs8", + "rand_core 0.6.4", + "sec1", + "subtle", + "zeroize", +] + +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "envy" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f47e0157f2cb54f5ae1bd371b30a2ae4311e1c028f575cd4e81de7353215965" +dependencies = [ + "serde", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "ff" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" +dependencies = [ + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + +[[package]] +name = "find-msvc-tools" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52051878f80a721bb68ebfbc930e07b65ba72f2da88968ea5c06fd6ca3d3a127" + +[[package]] +name = "flate2" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfe33edd8e85a12a67454e37f8c75e730830d83e313556ab9ebf9ee7fbeb3bfb" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + +[[package]] +name = "futures-channel" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" + +[[package]] +name = "futures-io" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" + +[[package]] +name = "futures-macro" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.109", +] + +[[package]] +name = "futures-sink" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" + +[[package]] +name = "futures-task" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" + +[[package]] +name = "futures-util" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" +dependencies = [ + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "pin-utils", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bb6743198531e02858aeaea5398fcc883e71851fcbcb5a2f773e2fb6cb1edf2" +dependencies = [ + "typenum", + "version_check", + "zeroize", +] + +[[package]] +name = "getrandom" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi", + "wasip2", + "wasm-bindgen", +] + +[[package]] +name = "glob" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" + +[[package]] +name = "group" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" +dependencies = [ + "ff", + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "zerocopy", +] + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" + +[[package]] +name = "hashbrown" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5419bdc4f6a9207fbeba6d11b604d481addf78ecd10c11ad51e76c2f6482748d" + +[[package]] +name = "headers" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3314d5adb5d94bcdf56771f2e50dbbc80bb4bdf88967526706205ac9eff24eb" +dependencies = [ + "base64 0.22.1", + "bytes", + "headers-core", + "http", + "httpdate", + "mime", + "sha1", +] + +[[package]] +name = "headers-core" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54b4a22553d4242c49fddb9ba998a99962b5cc6f22cb5a3482bec22522403ce4" +dependencies = [ + "http", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + +[[package]] +name = "home" +version = "0.5.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "http" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4a85d31aea989eead29a3aaf9e1115a180df8282431156e533de47660892565" +dependencies = [ + "bytes", + "fnv", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hyper" +version = "1.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb3aa54a13a0dfe7fbe3a59e0c76093041720fdc77b110cc0fc260fafb4dc51e" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "pin-utils", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots", +] + +[[package]] +name = "hyper-tls" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" +dependencies = [ + "bytes", + "http-body-util", + "hyper", + "hyper-util", + "native-tls", + "tokio", + "tokio-native-tls", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c6995591a8f1380fcb4ba966a252a4b29188d51d2b89e3a252f5305be65aea8" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.64" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33e57f83510bb73707521ebaffa789ec8caf86f9657cad665b092b581d40e9fb" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core 0.62.2", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" +dependencies = [ + "displaydoc", + "potential_utf", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" + +[[package]] +name = "icu_properties" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e93fcd3157766c0c8da2f8cff6ce651a31f0810eaa1c51ec363ef790bbb5fb99" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02845b3647bb045f1100ecd6480ff52f34c35f82d9880e029d329c21d1054899" + +[[package]] +name = "icu_provider" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "image" +version = "0.25.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "529feb3e6769d234375c4cf1ee2ce713682b8e76538cb13f9fc23e1400a591e7" +dependencies = [ + "bytemuck", + "byteorder-lite", + "image-webp", + "moxcms", + "num-traits", + "png", + "zune-core", + "zune-jpeg", +] + +[[package]] +name = "image-webp" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3" +dependencies = [ + "byteorder-lite", + "quick-error", +] + +[[package]] +name = "indexmap" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6717a8d2a5a929a1a2eb43a12812498ed141a0bcfb7e8f7844fbdbe4303bba9f" +dependencies = [ + "equivalent", + "hashbrown 0.16.0", + "serde", + "serde_core", +] + +[[package]] +name = "infer" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a588916bfdfd92e71cacef98a63d9b1f0d74d6599980d11894290e7ddefffcf7" +dependencies = [ + "cfb", +] + +[[package]] +name = "ipnet" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" + +[[package]] +name = "iri-string" +version = "0.7.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f867b9d1d896b67beb18518eda36fdb77a32ea590de864f1325b294a6d14397" +dependencies = [ + "memchr", + "serde", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" + +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom 0.3.4", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.82" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b011eec8cc36da2aab2d5cff675ec18454fad408585853910a202391cf9f8e65" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "jsonwebtoken" +version = "10.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c76e1c7d7df3e34443b3621b459b066a7b79644f059fc8b2db7070c825fd417e" +dependencies = [ + "base64 0.22.1", + "ed25519-dalek", + "getrandom 0.2.16", + "hmac", + "js-sys", + "p256", + "p384", + "pem", + "rand 0.8.5", + "rsa", + "serde", + "serde_json", + "sha2", + "signature", + "simple_asn1", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +dependencies = [ + "spin", +] + +[[package]] +name = "libc" +version = "0.2.177" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2874a2af47a2325c2001a6e6fad9b16a53b802102b528163885171cf92b15976" + +[[package]] +name = "libloading" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if", + "windows-link 0.2.1", +] + +[[package]] +name = "libloading" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "754ca22de805bb5744484a5b151a9e1a8e837d5dc232c2d7d8c2e3492edc8b60" +dependencies = [ + "cfg-if", + "windows-link 0.2.1", +] + +[[package]] +name = "libm" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9fbbcab51052fe104eb5e5d351cf728d30a5be1fe14d9be8a3b097481fb97de" + +[[package]] +name = "linux-raw-sys" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" + +[[package]] +name = "litemap" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34080505efa8e45a4b816c349525ebe327ceaa8559756f0356cba97ef3bf7432" + +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + +[[package]] +name = "maybe-async" +version = "0.2.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cf92c10c7e361d6b99666ec1c6f9805b0bea2c3bd8c78dc6fe98ac5bd78db11" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.109", +] + +[[package]] +name = "maybe-owned" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4facc753ae494aeb6e3c22f839b158aebd4f9270f55cd3c79906c45476c47ab4" + +[[package]] +name = "md5" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae960838283323069879657ca3de837e9f7bbb4c7bf6ea7f1b290d5e9476d2e0" + +[[package]] +name = "memchr" +version = "2.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" + +[[package]] +name = "migrations_internals" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36c791ecdf977c99f45f23280405d7723727470f6689a5e6dbf513ac547ae10d" +dependencies = [ + "serde", + "toml", +] + +[[package]] +name = "migrations_macros" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36fc5ac76be324cfd2d3f2cf0fdf5d5d3c4f14ed8aaebadb09e304ba42282703" +dependencies = [ + "migrations_internals", + "proc-macro2", + "quote", +] + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + +[[package]] +name = "minidom" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e394a0e3c7ccc2daea3dffabe82f09857b6b510cb25af87d54bf3e910ac1642d" +dependencies = [ + "rxml", +] + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69d83b0086dc8ecf3ce9ae2874b2d1290252e2a30720bea58a5c6639b0092873" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "moxcms" +version = "0.7.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fbdd3d7436f8b5e892b8b7ea114271ff0fa00bc5acae845d53b07d498616ef6" +dependencies = [ + "num-traits", + "pxfm", +] + +[[package]] +name = "multer" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83e87776546dc87511aa5ee218730c92b666d7264ab6ed41f9d215af9cd5224b" +dependencies = [ + "bytes", + "encoding_rs", + "futures-util", + "http", + "httparse", + "memchr", + "mime", + "spin", + "version_check", +] + +[[package]] +name = "native-tls" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87de3442987e9dbec73158d5c715e7ad9072fda936bb03d19d7fa10e00520f0e" +dependencies = [ + "libc", + "log", + "openssl", + "openssl-probe", + "openssl-sys", + "schannel", + "security-framework", + "security-framework-sys", + "tempfile", +] + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "ntapi" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8a3895c6391c39d7fe7ebc444a87eb2991b2a0bc718fdabd071eec617fc68e4" +dependencies = [ + "winapi", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "num-bigint" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-bigint-dig" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc84195820f291c7697304f3cbdadd1cb7199c0efc917ff5eafd71225c136151" +dependencies = [ + "byteorder", + "lazy_static", + "libm", + "num-integer", + "num-iter", + "num-traits", + "rand 0.8.5", + "smallvec", + "zeroize", +] + +[[package]] +name = "num-conv" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" +dependencies = [ + "autocfg", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", + "libm", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags", +] + +[[package]] +name = "objc2-io-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33fafba39597d6dc1fb709123dfa8289d39406734be322956a69f0931c73bb15" +dependencies = [ + "libc", + "objc2-core-foundation", +] + +[[package]] +name = "oid-registry" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8d8034d9489cdaf79228eb9f6a3b8d7bb32ba00d6645ebd48eef4077ceb5bd9" +dependencies = [ + "asn1-rs", +] + +[[package]] +name = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "openssl" +version = "0.10.75" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08838db121398ad17ab8531ce9de97b244589089e290a384c900cb9ff7434328" +dependencies = [ + "bitflags", + "cfg-if", + "foreign-types", + "libc", + "once_cell", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.109", +] + +[[package]] +name = "openssl-probe" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" + +[[package]] +name = "openssl-sys" +version = "0.9.111" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82cab2d520aa75e3c58898289429321eb788c3106963d0dc886ec7a5f4adc321" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "ordered-float" +version = "2.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68f19d67e5a2795c94e73e0bb1cc1a7edeb2e28efd39e2e1c9b7a40c1108b11c" +dependencies = [ + "num-traits", +] + +[[package]] +name = "ordered-multimap" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49203cdcae0030493bad186b28da2fa25645fa276a51b6fec8010d281e02ef79" +dependencies = [ + "dlv-list", + "hashbrown 0.14.5", +] + +[[package]] +name = "p256" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2", +] + +[[package]] +name = "p384" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe42f1670a52a47d448f14b6a5c61dd78fce51856e68edaa38f7ae3a46b8d6b6" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primeorder", + "sha2", +] + +[[package]] +name = "papercrate" +version = "0.1.0" +dependencies = [ + "anyhow", + "argon2", + "async-trait", + "axum", + "axum-extra", + "base64 0.22.1", + "bytes", + "chrono", + "chrono-tz", + "clap", + "diesel", + "diesel_migrations", + "dotenv", + "envy", + "futures-util", + "hex", + "hmac", + "http-body-util", + "hyper", + "image", + "infer", + "jsonwebtoken", + "mime_guess", + "once_cell", + "pdfium-render", + "percent-encoding", + "quick-xml", + "rand 0.9.2", + "regex", + "reqwest", + "rust-s3", + "serde", + "serde-aux", + "serde_bytes", + "serde_cbor_2", + "serde_json", + "serde_yaml", + "sha2", + "tempfile", + "thiserror 2.0.17", + "tokio", + "tower", + "tower-http", + "tracing", + "tracing-subscriber", + "url", + "utoipa", + "uuid", + "webauthn-rs", + "webauthn-rs-core", +] + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link 0.2.1", +] + +[[package]] +name = "parse-zoneinfo" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f2a05b18d44e2957b88f96ba460715e295bc1d7510468a2f3d3b44535d26c24" +dependencies = [ + "regex", +] + +[[package]] +name = "password-hash" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" +dependencies = [ + "base64ct", + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "pastey" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec" + +[[package]] +name = "pdfium-render" +version = "0.8.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c8bfacaecc7ff9bcc2f23fed64336830f823b13548a20083f3d0b0164537d2c" +dependencies = [ + "bitflags", + "bytemuck", + "bytes", + "chrono", + "console_error_panic_hook", + "console_log", + "image", + "itertools 0.14.0", + "js-sys", + "libloading 0.9.0", + "log", + "maybe-owned", + "once_cell", + "utf16string", + "vecmath", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "pem" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" +dependencies = [ + "base64 0.22.1", + "serde_core", +] + +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "phf" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" +dependencies = [ + "phf_shared", +] + +[[package]] +name = "phf_codegen" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a" +dependencies = [ + "phf_generator", + "phf_shared", +] + +[[package]] +name = "phf_generator" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" +dependencies = [ + "phf_shared", + "rand 0.8.5", +] + +[[package]] +name = "phf_shared" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" +dependencies = [ + "siphasher", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "piston-float" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad78bf43dcf80e8f950c92b84f938a0fc7590b7f6866fbcbeca781609c115590" + +[[package]] +name = "pkcs1" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" +dependencies = [ + "der", + "pkcs8", + "spki", +] + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + +[[package]] +name = "pkg-config" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" + +[[package]] +name = "png" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97baced388464909d42d89643fe4361939af9b7ce7a31ee32a168f832a70f2a0" +dependencies = [ + "bitflags", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + +[[package]] +name = "potential_utf" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "pq-sys" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "574ddd6a267294433f140b02a726b0640c43cf7c6f717084684aaa3b285aba61" +dependencies = [ + "libc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn 2.0.109", +] + +[[package]] +name = "primeorder" +version = "0.13.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6" +dependencies = [ + "elliptic-curve", +] + +[[package]] +name = "proc-macro-error" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c" +dependencies = [ + "proc-macro-error-attr", + "proc-macro2", + "quote", + "syn 1.0.109", + "version_check", +] + +[[package]] +name = "proc-macro-error-attr" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869" +dependencies = [ + "proc-macro2", + "quote", + "version_check", +] + +[[package]] +name = "proc-macro2" +version = "1.0.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ee95bc4ef87b8d5ba32e8b7714ccc834865276eab0aed5c9958d00ec45f49e8" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "pxfm" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3cbdf373972bf78df4d3b518d07003938e2c7d1fb5891e55f9cb6df57009d84" +dependencies = [ + "num-traits", +] + +[[package]] +name = "quick-error" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" + +[[package]] +name = "quick-xml" +version = "0.38.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42a232e7487fc2ef313d96dde7948e7a3c05101870d8985e4fd8d26aedd27b89" +dependencies = [ + "memchr", + "serde", +] + +[[package]] +name = "quinn" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror 2.0.17", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1906b49b0c3bc04b5fe5d86a77925ae6524a19b816ae38ce1e426255f1d8a31" +dependencies = [ + "bytes", + "getrandom 0.3.4", + "lru-slab", + "rand 0.9.2", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.17", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.60.2", +] + +[[package]] +name = "quote" +version = "1.0.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a338cc41d27e6cc6dce6cefc13a0729dfbb81c262b1f519331575dd80ef3067f" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r2d2" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51de85fb3fb6524929c8a2eb85e6b6d363de4e8c48f9e2c2eac4944abc181c93" +dependencies = [ + "log", + "parking_lot", + "scheduled-thread-pool", +] + +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.3", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.3", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.16", +] + +[[package]] +name = "rand_core" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "99d9a13982dcf210057a8a78572b2217b667c3beacbf3a0d8b454f6f82837d38" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "regex" +version = "1.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843bc0191f75f3e22651ae5f1e72939ab2f72a4bc30fa80a066bd66edefc24d4" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5276caf25ac86c8d810222b3dbb938e512c55c6831a10f3e6ed1c93b84041f1c" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" + +[[package]] +name = "reqwest" +version = "0.12.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d0946410b9f7b082a427e4ef5c8ff541a88b357bc6c637c40db3a68ac70a36f" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-tls", + "hyper-util", + "js-sys", + "log", + "native-tls", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-native-tls", + "tokio-rustls", + "tokio-util", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams", + "web-sys", + "webpki-roots", +] + +[[package]] +name = "rfc6979" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" +dependencies = [ + "hmac", + "subtle", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.16", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rsa" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78928ac1ed176a5ca1d17e578a1825f3d81ca54cf41053a592584b020cfd691b" +dependencies = [ + "const-oid", + "digest", + "num-bigint-dig", + "num-integer", + "num-traits", + "pkcs1", + "pkcs8", + "rand_core 0.6.4", + "signature", + "spki", + "subtle", + "zeroize", +] + +[[package]] +name = "rust-ini" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "796e8d2b6696392a43bea58116b667fb4c29727dc5abd27d6acf338bb4f688c7" +dependencies = [ + "cfg-if", + "ordered-multimap", +] + +[[package]] +name = "rust-s3" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94f9b973bd4097f5bb47e5827dcb9fb5dc17e93879e46badc27d2a4e9a4e5588" +dependencies = [ + "async-trait", + "aws-creds", + "aws-region", + "base64 0.22.1", + "bytes", + "cfg-if", + "futures-util", + "hex", + "hmac", + "http", + "log", + "maybe-async", + "md5", + "minidom", + "percent-encoding", + "quick-xml", + "reqwest", + "serde", + "serde_derive", + "serde_json", + "sha2", + "sysinfo", + "thiserror 2.0.17", + "time", + "tokio", + "tokio-stream", + "url", +] + +[[package]] +name = "rustc-hash" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rusticata-macros" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632" +dependencies = [ + "nom", +] + +[[package]] +name = "rustix" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd15f8a2c5551a84d56efdc1cd049089e409ac19a3072d5037a17fd70719ff3e" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "533f54bc6a7d4f647e46ad909549eda97bf5afc1585190ef692b4286b198bd8f" +dependencies = [ + "aws-lc-rs", + "log", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94182ad936a0c91c324cd46c6511b9510ed16af436d7b5bab34beab0afd55f7a" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ffdfa2f5286e2247234e03f680868ac2815974dc39e00ea15adc445d0aafe52" +dependencies = [ + "aws-lc-rs", + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "rxml" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65bc94b580d0f5a6b7a2d604e597513d3c673154b52ddeccd1d5c32360d945ee" +dependencies = [ + "bytes", + "rxml_validation", +] + +[[package]] +name = "rxml_validation" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "826e80413b9a35e9d33217b3dcac04cf95f6559d15944b93887a08be5496c4a4" +dependencies = [ + "compact_str", +] + +[[package]] +name = "ryu" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" + +[[package]] +name = "schannel" +version = "0.1.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891d81b926048e76efe18581bf793546b4c0eaf8448d72be8de2bbee5fd166e1" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "scheduled-thread-pool" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3cbc66816425a074528352f5789333ecff06ca41b36b0b0efdfbb29edc391a19" +dependencies = [ + "parking_lot", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "sec1" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" +dependencies = [ + "base16ct", + "der", + "generic-array", + "pkcs8", + "subtle", + "zeroize", +] + +[[package]] +name = "security-framework" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" +dependencies = [ + "bitflags", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc1f0cbffaac4852523ce30d8bd3c5cdc873501d96ff467ca09b6767bb8cd5c0" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "semver" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde-aux" +version = "4.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "207f67b28fe90fb596503a9bf0bf1ea5e831e21307658e177c5dfcdfc3ab8a0a" +dependencies = [ + "chrono", + "serde", + "serde-value", + "serde_json", +] + +[[package]] +name = "serde-value" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3a1a3341211875ef120e117ea7fd5228530ae7e7036a779fdc9117be6b3282c" +dependencies = [ + "ordered-float", + "serde", +] + +[[package]] +name = "serde_bytes" +version = "0.11.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5d440709e79d88e51ac01c4b72fc6cb7314017bb7da9eeff678aa94c10e3ea8" +dependencies = [ + "serde", + "serde_core", +] + +[[package]] +name = "serde_cbor_2" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34aec2709de9078e077090abd848e967abab63c9fb3fdb5d4799ad359d8d482c" +dependencies = [ + "half", + "serde", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.109", +] + +[[package]] +name = "serde_json" +version = "1.0.145" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "402a6f66d8c709116cf22f558eab210f5a50187f702eb4d7e5ef38d9a7f1c79c" +dependencies = [ + "itoa", + "memchr", + "ryu", + "serde", + "serde_core", +] + +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + +[[package]] +name = "serde_spanned" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e24345aa0fe688594e73770a5f6d1b216508b4f93484c0026d521acd30134392" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "serde_yaml" +version = "0.9.34+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" +dependencies = [ + "indexmap", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "signal-hook-registry" +version = "1.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a4719bff48cee6b39d12c020eeb490953ad2443b7055bd0b21fca26bd8c28b" +dependencies = [ + "libc", +] + +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest", + "rand_core 0.6.4", +] + +[[package]] +name = "simd-adler32" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d66dc143e6b11c1eddc06d5c423cfc97062865baf299914ab64caa38182078fe" + +[[package]] +name = "simple_asn1" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "297f631f50729c8c99b84667867963997ec0b50f32b2a7dbcab828ef0541e8bb" +dependencies = [ + "num-bigint", + "num-traits", + "thiserror 2.0.17", + "time", +] + +[[package]] +name = "siphasher" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56199f7ddabf13fe5074ce809e7d3f42b42ae711800501b5b16ea82ad029c39d" + +[[package]] +name = "slab" +version = "0.4.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a2ae44ef20feb57a68b23d846850f861394c2e02dc425a50098ae8c90267589" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "socket2" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17129e116933cf371d018bb80ae557e889637989d8638274fb25622827b03881" +dependencies = [ + "libc", + "windows-sys 0.60.2", +] + +[[package]] +name = "spin" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f17c7e013e88258aa9543dcbe81aca68a667a9ac37cd69c9fbc07858bfe0e2f" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.109", +] + +[[package]] +name = "sysinfo" +version = "0.37.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16607d5caffd1c07ce073528f9ed972d88db15dd44023fa57142963be3feb11f" +dependencies = [ + "libc", + "memchr", + "ntapi", + "objc2-core-foundation", + "objc2-io-kit", + "windows", +] + +[[package]] +name = "tempfile" +version = "3.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d31c77bdf42a745371d260a26ca7163f1e0924b64afa0b688e61b5a9fa02f16" +dependencies = [ + "fastrand", + "getrandom 0.3.4", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f63587ca0f12b72a0600bcba1d40081f830876000bb46dd2337a3051618f4fc8" +dependencies = [ + "thiserror-impl 2.0.17", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.109", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.109", +] + +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "time" +version = "0.3.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e7d9e3bb61134e77bde20dd4825b97c010155709965fedf0f49bb138e52a9d" +dependencies = [ + "deranged", + "itoa", + "num-conv", + "powerfmt", + "serde", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40868e7c1d2f0b8d73e4a8c7f0ff63af4f6d19be117e90bd73eb1d62cf831c6b" + +[[package]] +name = "time-macros" +version = "0.2.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30cfb0125f12d9c277f35663a0a33f8c30190f4e4574868a330595412d34ebf3" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tiny-keccak" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c9d3793400a45f954c52e73d068316d76b6f4e36977e3fcebb13a2721e80237" +dependencies = [ + "crunchy", +] + +[[package]] +name = "tinystr" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa5fdc3bce6191a1dbc8c02d5c8bffcf557bafa17c124c5264a458f1b0613fa" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff360e02eab121e0bc37a2d3b4d4dc622e6eda3a8e5253d5435ecf5bd4c68408" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.109", +] + +[[package]] +name = "tokio-native-tls" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" +dependencies = [ + "native-tls", + "tokio", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-stream" +version = "0.1.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eca58d7bba4a75707817a2c44174253f9236b2d5fbd055602e9d5c07c139a047" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2efa149fe76073d6e8fd97ef4f4eca7b67f599660115591483572e406e165594" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "toml" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0dc8b1fb61449e27716ec0e1bdf0f6b8f3e8f6b05391e8497b8b6d7804ea6d8" +dependencies = [ + "serde_core", + "serde_spanned", + "toml_datetime", + "toml_parser", + "winnow", +] + +[[package]] +name = "toml_datetime" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2cdb639ebbc97961c51720f858597f7f24c4fc295327923af55b74c3c724533" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_parser" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0cbe268d35bdb4bb5a56a2de88d0ad0eb70af5384a99d648cd4b3d04039800e" +dependencies = [ + "winnow", +] + +[[package]] +name = "tower" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d039ad9159c98b70ecfd540b2573b97f7f52c3e8d9f8ad57a24b916a536975f9" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-http" +version = "0.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adc82fd73de2a9722ac5da747f12383d2bfdb93591ee6c58486e0097890f05f2" +dependencies = [ + "bitflags", + "bytes", + "futures-util", + "http", + "http-body", + "iri-string", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81383ab64e72a7a8b8e13130c49e3dab29def6d0c7d76a03087b3cf71c5c6903" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.109", +] + +[[package]] +name = "tracing-core" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d12581f227e93f094d3af2ae690a574abb8a2b9b7a96e7cfe9647b2b617678" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2054a14f5307d601f88daf0553e1cbf472acc4f2c51afab632431cdcd72124d5" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "typenum" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" + +[[package]] +name = "unicase" +version = "2.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b844d17643ee918803943289730bec8aac480150456169e647ed0b576ba539" + +[[package]] +name = "unicode-ident" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" + +[[package]] +name = "unsafe-libyaml" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08bc136a29a3d1758e07a9cca267be308aeebf5cfd5a10f3f67ab2097683ef5b" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf16string" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b62a1e85e12d5d712bf47a85f426b73d303e2d00a90de5f3004df3596e9d216" +dependencies = [ + "byteorder", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "utoipa" +version = "4.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5afb1a60e207dca502682537fefcfd9921e71d0b83e9576060f09abc6efab23" +dependencies = [ + "indexmap", + "serde", + "serde_json", + "utoipa-gen", +] + +[[package]] +name = "utoipa-gen" +version = "4.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20c24e8ab68ff9ee746aad22d39b5535601e6416d1b0feeabf78be986a5c4392" +dependencies = [ + "proc-macro-error", + "proc-macro2", + "quote", + "syn 2.0.109", + "uuid", +] + +[[package]] +name = "uuid" +version = "1.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f87b8aa10b915a06587d0dec516c282ff295b475d94abf425d62b57710070a2" +dependencies = [ + "getrandom 0.3.4", + "js-sys", + "serde", + "wasm-bindgen", +] + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "vecmath" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "956ae1e0d85bca567dee1dcf87fb1ca2e792792f66f87dced8381f99cd91156a" +dependencies = [ + "piston-float", +] + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.1+wasi-0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.105" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da95793dfc411fbbd93f5be7715b0578ec61fe87cb1a42b12eb625caa5c5ea60" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "551f88106c6d5e7ccc7cd9a16f312dd3b5d36ea8b4954304657d5dfba115d4a0" +dependencies = [ + "cfg-if", + "js-sys", + "once_cell", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.105" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04264334509e04a7bf8690f2384ef5265f05143a4bff3889ab7a3269adab59c2" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.105" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "420bc339d9f322e562942d52e115d57e950d12d88983a14c79b86859ee6c7ebc" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.109", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.105" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76f218a38c84bcb33c25ec7059b07847d465ce0e0a76b995e134a45adcb6af76" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-streams" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "web-sys" +version = "0.3.82" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a1f95c0d03a47f4ae1f7a64643a6bb97465d9b740f0fa8f90ea33915c99a9a1" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webauthn-attestation-ca" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f77a2892ec44032e6c48dad9aad1b05fada09c346ada11d8d32db119b4b4f205" +dependencies = [ + "base64urlsafedata", + "openssl", + "openssl-sys", + "serde", + "tracing", + "uuid", +] + +[[package]] +name = "webauthn-rs" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb7c3a2f9c8bddd524e47bbd427bcf3a28aa074de55d74470b42a91a41937b8e" +dependencies = [ + "base64urlsafedata", + "serde", + "tracing", + "url", + "uuid", + "webauthn-rs-core", +] + +[[package]] +name = "webauthn-rs-core" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f1d80f3146382529fe70a3ab5d0feb2413a015204ed7843f9377cd39357fc4" +dependencies = [ + "base64 0.21.7", + "base64urlsafedata", + "der-parser", + "hex", + "nom", + "openssl", + "openssl-sys", + "rand 0.8.5", + "rand_chacha 0.3.1", + "serde", + "serde_cbor_2", + "serde_json", + "thiserror 1.0.69", + "tracing", + "url", + "uuid", + "webauthn-attestation-ca", + "webauthn-rs-proto", + "x509-parser", +] + +[[package]] +name = "webauthn-rs-proto" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e786894f89facb9aaf1c5f6559670236723c98382e045521c76f3d5ca5047bd" +dependencies = [ + "base64 0.21.7", + "base64urlsafedata", + "serde", + "serde_json", + "url", +] + +[[package]] +name = "webpki-roots" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2878ef029c47c6e8cf779119f20fcf52bde7ad42a731b2a304bc221df17571e" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows" +version = "0.61.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893" +dependencies = [ + "windows-collections", + "windows-core 0.61.2", + "windows-future", + "windows-link 0.1.3", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3beeceb5e5cfd9eb1d76b381630e82c4241ccd0d27f1a39ed41b2760b255c5e8" +dependencies = [ + "windows-core 0.61.2", +] + +[[package]] +name = "windows-core" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link 0.1.3", + "windows-result 0.3.4", + "windows-strings 0.4.2", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link 0.2.1", + "windows-result 0.4.1", + "windows-strings 0.5.1", +] + +[[package]] +name = "windows-future" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e" +dependencies = [ + "windows-core 0.61.2", + "windows-link 0.1.3", + "windows-threading", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.109", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.109", +] + +[[package]] +name = "windows-link" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-numerics" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1" +dependencies = [ + "windows-core 0.61.2", + "windows-link 0.1.3", +] + +[[package]] +name = "windows-result" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-strings" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link 0.2.1", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows-threading" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b66463ad2e0ea3bbf808b7f1d371311c80e115c0b71d60efc142cafbcfb057a6" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "winnow" +version = "0.7.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21a0236b59786fed61e2a80582dd500fe61f18b5dca67a4a067d0bc9039339cf" + +[[package]] +name = "wit-bindgen" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" + +[[package]] +name = "writeable" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" + +[[package]] +name = "x509-parser" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fcbc162f30700d6f3f82a24bf7cc62ffe7caea42c0b2cba8bf7f3ae50cf51f69" +dependencies = [ + "asn1-rs", + "data-encoding", + "der-parser", + "lazy_static", + "nom", + "oid-registry", + "rusticata-macros", + "thiserror 1.0.69", + "time", +] + +[[package]] +name = "yoke" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.109", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0894878a5fa3edfd6da3f88c4805f4c8558e2b996227a3d864f47fe11e38282c" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88d2b8d9c68ad2b9e4340d7832716a4d21a22a1154777ad56ea55c51a9cf3831" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.109", +] + +[[package]] +name = "zerofrom" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.109", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" + +[[package]] +name = "zerotrie" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.109", +] + +[[package]] +name = "zune-core" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f423a2c17029964870cfaabb1f13dfab7d092a62a29a89264f4d36990ca414a" + +[[package]] +name = "zune-jpeg" +version = "0.4.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29ce2c8a9384ad323cf564b67da86e21d3cfdff87908bc1223ed5c99bc792713" +dependencies = [ + "zune-core", +] diff --git a/backend/Cargo.toml b/backend/Cargo.toml new file mode 100644 index 0000000..e02976b --- /dev/null +++ b/backend/Cargo.toml @@ -0,0 +1,99 @@ +[package] +name = "papercrate" +version = "0.1.0" +edition = "2021" + +[dependencies] +# Web framework +axum = { version = "0.8", features = ["multipart"] } +tokio = { version = "1.48", features = ["full"] } +tower = { version = "0.5", features = ["make", "util"] } +tower-http = { version = "0.6", features = ["cors", "trace"] } +axum-extra = { version = "0.12", features = ["typed-header"] } + +# Database +diesel = { version = "2.3.3", features = ["postgres", "uuid", "chrono", "serde_json", "r2d2"] } +diesel_migrations = "2.1" +uuid = { version = "1.6", features = ["v4", "serde"] } +chrono = { version = "0.4", features = ["serde"] } + +# S3 +rust-s3 = { version = "0.37", features = ["with-tokio", "tokio-rustls-tls"] } + +# Serialization +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +envy = "0.4" +serde-aux = "4.4" + +# Utilities +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } +dotenv = "0.15" +sha2 = "0.10" +hex = "0.4" +hmac = "0.12" +bytes = "1.5" +async-trait = "0.1" +image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp"] } +pdfium-render = "0.8.36" +mime_guess = "2.0" +tempfile = "3.10" +reqwest = { version = "0.12.24", default-features = false, features = ["json", "rustls-tls", "stream"] } +percent-encoding = "2.3" +base64 = "0.22" +quick-xml = "0.38" +futures-util = "0.3" +url = "2.5" +once_cell = "1.19" +regex = "1.11" +infer = "0.19" +utoipa = { version = "4.2", default-features = false, features = ["chrono", "uuid", "preserve_order"] } +clap = { version = "4.5", features = ["derive"] } + +# Error handling +thiserror = "2.0" +anyhow = "1.0" + +# Authentication & security +argon2 = "0.5" +jsonwebtoken = { version = "10", features = ["rust_crypto"] } +webauthn-rs = { version = "0.5", features = ["danger-allow-state-serialisation", "danger-credential-internals"] } +serde_bytes = "0.11" +serde_cbor_2 = "0.13" + +# Misc +rand = "0.9" +hyper = "1.2" +http-body-util = "0.1" +chrono-tz = "0.8" + +[dev-dependencies] +once_cell = "1.19" +webauthn-rs-core = "0.5" +serde_yaml = "0.9" + +[build-dependencies] +serde = { version = "1.0", features = ["derive"] } +serde_yaml = "0.9" +serde_json = "1.0" +regex = "1.11" + +[[bin]] +name = "backend" +path = "src/main.rs" +[[bin]] +name = "worker" +path = "src/bin/worker.rs" + +[[bin]] +name = "webdav" +path = "src/bin/webdav.rs" + +[[bin]] +name = "admin" +path = "src/bin/admin.rs" + +[[bin]] +name = "openapi-dump" +path = "src/bin/openapi_dump.rs" diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..ffce2ca --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,143 @@ +# syntax=docker/dockerfile:1.7 + +ARG RUST_VERSION=1 +FROM --platform=$BUILDPLATFORM rust:${RUST_VERSION}-slim AS builder +ARG TARGETARCH +ENV TARGETARCH=${TARGETARCH} +WORKDIR /app + +RUN cat <<'SCRIPT' >/usr/local/bin/resolve-target.sh +#!/bin/sh +set -e +case "$1" in + amd64) + echo x86_64-unknown-linux-gnu + ;; + arm64) + echo aarch64-unknown-linux-gnu + ;; + *) + echo "Unsupported TARGETARCH: $1" >&2 + exit 1 + ;; +esac +SCRIPT +RUN chmod +x /usr/local/bin/resolve-target.sh + +ENV PKG_CONFIG_ALLOW_CROSS=1 \ + CC_aarch64_unknown_linux_gnu=aarch64-linux-gnu-gcc \ + CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER=aarch64-linux-gnu-gcc \ + PKG_CONFIG_LIBDIR_aarch64_unknown_linux_gnu=/usr/lib/aarch64-linux-gnu/pkgconfig \ + PKG_CONFIG_PATH_aarch64_unknown_linux_gnu=/usr/lib/aarch64-linux-gnu/pkgconfig \ + PKG_CONFIG_SYSROOT_DIR_aarch64_unknown_linux_gnu=/usr/aarch64-linux-gnu \ + OPENSSL_DIR_aarch64_unknown_linux_gnu=/usr/lib/aarch64-linux-gnu \ + OPENSSL_LIB_DIR_aarch64_unknown_linux_gnu=/usr/lib/aarch64-linux-gnu \ + OPENSSL_INCLUDE_DIR_aarch64_unknown_linux_gnu=/usr/include/aarch64-linux-gnu \ + CC_x86_64_unknown_linux_gnu=x86_64-linux-gnu-gcc \ + CARGO_TARGET_X86_64_UNKNOWN_LINUX_GNU_LINKER=x86_64-linux-gnu-gcc \ + PKG_CONFIG_LIBDIR_x86_64_unknown_linux_gnu=/usr/lib/x86_64-linux-gnu/pkgconfig \ + PKG_CONFIG_PATH_x86_64_unknown_linux_gnu=/usr/lib/x86_64-linux-gnu/pkgconfig \ + OPENSSL_DIR_x86_64_unknown_linux_gnu=/usr/lib/x86_64-linux-gnu \ + OPENSSL_LIB_DIR_x86_64_unknown_linux_gnu=/usr/lib/x86_64-linux-gnu \ + OPENSSL_INCLUDE_DIR_x86_64_unknown_linux_gnu=/usr/include/x86_64-linux-gnu + +RUN set -eux; \ + case "${TARGETARCH}" in \ + amd64) TARGET_DEB_ARCH=amd64 ;; \ + arm64) TARGET_DEB_ARCH=arm64 ;; \ + *) echo "Unsupported TARGETARCH: ${TARGETARCH}" >&2; exit 1 ;; \ + esac; \ + BUILD_DEB_ARCH="$(dpkg --print-architecture)"; \ + if [ "${TARGET_DEB_ARCH}" != "${BUILD_DEB_ARCH}" ]; then \ + dpkg --add-architecture "${TARGET_DEB_ARCH}"; \ + fi; \ + apt-get update; \ + apt-get install -y --no-install-recommends \ + build-essential \ + pkg-config \ + curl \ + libssl-dev \ + libpq-dev \ + libjpeg-dev \ + libpng-dev \ + zlib1g-dev; \ + if [ "${TARGET_DEB_ARCH}" != "${BUILD_DEB_ARCH}" ]; then \ + case "${TARGETARCH}" in \ + arm64) CROSS_GCC=gcc-aarch64-linux-gnu ;; \ + amd64) CROSS_GCC=gcc-x86-64-linux-gnu ;; \ + esac; \ + apt-get install -y --no-install-recommends \ + "${CROSS_GCC}" \ + "libc6-dev:${TARGET_DEB_ARCH}" \ + "libssl-dev:${TARGET_DEB_ARCH}" \ + "libpq-dev:${TARGET_DEB_ARCH}" \ + "libjpeg-dev:${TARGET_DEB_ARCH}" \ + "libpng-dev:${TARGET_DEB_ARCH}" \ + "zlib1g-dev:${TARGET_DEB_ARCH}"; \ + fi; \ + rm -rf /var/lib/apt/lists/* + +COPY Cargo.toml Cargo.lock build.rs ./ +COPY src ./src +COPY migrations ./migrations +COPY tests ./tests +COPY resources ./resources +COPY diesel.toml ./ + +RUN set -eux; \ + TARGET="$(/usr/local/bin/resolve-target.sh "${TARGETARCH}")"; \ + rustup target add "${TARGET}"; \ + cargo build --release --target "${TARGET}" --bin backend --bin worker --bin webdav --bin admin; \ + mkdir -p /artifacts; \ + for bin in backend worker webdav admin; do \ + cp "target/${TARGET}/release/${bin}" "/artifacts/${bin}"; \ + done + +RUN set -eux; \ + case "${TARGETARCH}" in \ + amd64) pdfium_package=pdfium-linux-x64.tgz ;; \ + arm64) pdfium_package=pdfium-linux-arm64.tgz ;; \ + *) echo "Unsupported TARGETARCH: ${TARGETARCH}" >&2; exit 1 ;; \ + esac; \ + curl -fsSL "https://github.com/bblanchon/pdfium-binaries/releases/latest/download/${pdfium_package}" -o /tmp/pdfium.tgz; \ + mkdir -p /tmp/pdfium; \ + tar -xzf /tmp/pdfium.tgz -C /tmp/pdfium --strip-components=1; \ + pdfium_so="$(find /tmp/pdfium -name libpdfium.so -type f | head -n1)"; \ + [ -n "${pdfium_so}" ]; \ + cp "${pdfium_so}" /artifacts/libpdfium.so; \ + rm -rf /tmp/pdfium.tgz /tmp/pdfium + +FROM --platform=$TARGETPLATFORM debian:trixie-slim AS runtime +ARG TARGETARCH +WORKDIR /app + +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + ca-certificates \ + curl \ + libssl3 \ + libpq5 \ + libjpeg62-turbo \ + libpng16-16 \ + ocrmypdf \ + tesseract-ocr \ + ghostscript \ + qpdf \ + ffmpeg \ + && rm -rf /var/lib/apt/lists/* \ + && mkdir -p /usr/local/lib \ + && useradd --system --create-home --uid 10001 appuser + +COPY --from=builder /artifacts/backend /usr/local/bin/papercrate-backend +COPY --from=builder /artifacts/worker /usr/local/bin/papercrate-worker +COPY --from=builder /artifacts/webdav /usr/local/bin/papercrate-webdav +COPY --from=builder /artifacts/admin /usr/local/bin/papercrate-admin +COPY --from=builder /artifacts/libpdfium.so /usr/local/lib/libpdfium.so +RUN ldconfig +COPY migrations ./migrations + +ENV RUST_LOG=info +USER appuser +EXPOSE 3000 + +ENTRYPOINT ["/usr/local/bin/papercrate-backend"] diff --git a/backend/build.rs b/backend/build.rs new file mode 100644 index 0000000..e040bf0 --- /dev/null +++ b/backend/build.rs @@ -0,0 +1,123 @@ +use std::env; +use std::fs; +use std::path::PathBuf; + +use regex::escape; +use serde::Deserialize; + +#[derive(Deserialize)] +struct CaseSuite { + cases: Vec, +} + +#[derive(Deserialize)] +struct CaseName { + name: String, +} + +#[derive(Deserialize)] +struct MonthSuite { + months: Vec, +} + +#[derive(Deserialize)] +struct MonthDefinition { + name: String, + month: u32, + #[serde(default)] + locales: Vec, +} + +fn sanitize(name: &str) -> String { + let mut out = String::with_capacity(name.len()); + for ch in name.chars() { + if ch.is_ascii_alphanumeric() { + out.push(ch); + } else { + out.push('_'); + } + } + if out.is_empty() { + "case".to_string() + } else if out.chars().next().unwrap().is_ascii_digit() { + format!("_{}", out) + } else { + out + } +} + +fn quote(value: &str) -> String { + serde_json::to_string(value).expect("string literal") +} + +fn generate_tests() -> Result> { + let yaml_path = PathBuf::from("tests/data/issued_at_cases.yaml"); + let contents = fs::read_to_string(&yaml_path)?; + let suite: CaseSuite = serde_yaml::from_str(&contents)?; + + let mut output = + String::from("#[cfg(test)]\npub mod issued_at_generated_tests {\n use super::*;\n"); + + for case in suite.cases { + let ident = sanitize(&case.name); + output.push_str(&format!( + " #[test]\n fn {}() {{\n run_named_case(\"{}\");\n }}\n", + ident, case.name + )); + } + + output.push_str("}\n"); + Ok(output) +} + +fn generate_months() -> Result> { + let yaml_path = PathBuf::from("resources/issued_at_months.yaml"); + let contents = fs::read_to_string(&yaml_path)?; + let suite: MonthSuite = serde_yaml::from_str(&contents)?; + + let mut pattern_parts = Vec::with_capacity(suite.months.len()); + let mut entries = String::new(); + for entry in &suite.months { + pattern_parts.push(escape(&entry.name)); + let locales_literal = if entry.locales.is_empty() { + "&[]".to_string() + } else { + let joined = entry + .locales + .iter() + .map(|loc| quote(loc)) + .collect::>() + .join(", "); + format!("&[{}]", joined) + }; + entries.push_str(&format!( + " MonthVariant {{ name: {}, month: {}, locales: {} }},\n", + quote(&entry.name), + entry.month, + locales_literal + )); + } + + let pattern_literal = quote(&pattern_parts.join("|")); + let output = format!( + "pub(super) static MONTH_VARIANTS: &[MonthVariant] = &[\n{entries}];\n\n", + entries = entries + ) + &format!( + "pub(super) const MONTH_PATTERN: &str = {};\n", + pattern_literal + ); + Ok(output) +} + +fn main() -> Result<(), Box> { + println!("cargo:rerun-if-changed=tests/data/issued_at_cases.yaml"); + println!("cargo:rerun-if-changed=resources/issued_at_months.yaml"); + + let out_dir = PathBuf::from(env::var("OUT_DIR")?); + fs::write( + out_dir.join("issued_at_generated_tests.rs"), + generate_tests()?, + )?; + fs::write(out_dir.join("issued_at_months.rs"), generate_months()?)?; + Ok(()) +} diff --git a/backend/diesel.toml b/backend/diesel.toml new file mode 100644 index 0000000..c3b66ee --- /dev/null +++ b/backend/diesel.toml @@ -0,0 +1,6 @@ +[print_schema] +file = "src/schema.rs" +custom_type_derives = ["diesel::query_builder::QueryId", "Clone"] + +[migrations_directory] +dir = "migrations" \ No newline at end of file diff --git a/backend/migrations/202510300000_initial_schema/down.sql b/backend/migrations/202510300000_initial_schema/down.sql new file mode 100644 index 0000000..bf79af8 --- /dev/null +++ b/backend/migrations/202510300000_initial_schema/down.sql @@ -0,0 +1,25 @@ +DROP TRIGGER IF EXISTS trg_jobs_updated_at ON jobs; +DROP FUNCTION IF EXISTS touch_jobs_updated_at(); + +DROP TABLE IF EXISTS webauthn_challenges; +DROP TABLE IF EXISTS user_passkeys; +DROP TABLE IF EXISTS webdav_tokens; +ALTER TABLE documents DROP CONSTRAINT IF EXISTS documents_current_version_fk; +DROP TABLE IF EXISTS document_asset_objects; +DROP TABLE IF EXISTS document_assets; +DROP TABLE IF EXISTS document_versions; +DROP TABLE IF EXISTS document_tags; +DROP TABLE IF EXISTS document_correspondents; +DROP TABLE IF EXISTS correspondents; +DROP TABLE IF EXISTS documents; +DROP TABLE IF EXISTS folders; +DROP TABLE IF EXISTS tags; +DROP TABLE IF EXISTS jobs; +DROP TABLE IF EXISTS refresh_tokens; +DROP TABLE IF EXISTS user_memberships; +DROP TABLE IF EXISTS users; +DROP TABLE IF EXISTS tenants; + +DROP TYPE IF EXISTS tenant_status; + +DROP EXTENSION IF EXISTS "pgcrypto"; diff --git a/backend/migrations/202510300000_initial_schema/up.sql b/backend/migrations/202510300000_initial_schema/up.sql new file mode 100644 index 0000000..c6d4296 --- /dev/null +++ b/backend/migrations/202510300000_initial_schema/up.sql @@ -0,0 +1,295 @@ +CREATE EXTENSION IF NOT EXISTS "pgcrypto"; + +CREATE TYPE tenant_status AS ENUM ('creating', 'active', 'suspended', 'deleting', 'error'); + +CREATE TABLE tenants ( + id UUID PRIMARY KEY, + name TEXT NOT NULL, + storage_root TEXT, + quickwit_index TEXT, + config JSONB NOT NULL DEFAULT '{}'::jsonb, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + status tenant_status NOT NULL, + created_by UUID +); + +CREATE UNIQUE INDEX tenants_storage_root_unique + ON tenants (storage_root) + WHERE storage_root IS NOT NULL; + +CREATE UNIQUE INDEX tenants_quickwit_index_unique + ON tenants (quickwit_index) + WHERE quickwit_index IS NOT NULL; + +CREATE TABLE users ( + id UUID PRIMARY KEY, + username VARCHAR(100) NOT NULL UNIQUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE user_memberships ( + id UUID PRIMARY KEY, + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE (user_id, tenant_id) +); + +CREATE INDEX user_memberships_tenant_id_idx ON user_memberships(tenant_id); +CREATE INDEX user_memberships_user_id_idx ON user_memberships(user_id); + +CREATE TABLE folders ( + id UUID PRIMARY KEY, + name VARCHAR(255) NOT NULL, + parent_id UUID REFERENCES folders(id) ON DELETE SET NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + tenant_id UUID NOT NULL REFERENCES tenants(id) +); + +CREATE INDEX idx_folders_parent ON folders(parent_id); +CREATE INDEX folders_tenant_id_idx ON folders(tenant_id); +CREATE UNIQUE INDEX folders_tenant_parent_name_unique_idx + ON folders ( + tenant_id, + COALESCE(parent_id, '00000000-0000-0000-0000-000000000000'::uuid), + name + ); + +CREATE TABLE documents ( + id UUID PRIMARY KEY, + filename VARCHAR(255) NOT NULL, + original_name VARCHAR(255) NOT NULL, + content_type VARCHAR(100), + folder_id UUID REFERENCES folders(id) ON DELETE SET NULL, + uploaded_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + deleted_at TIMESTAMPTZ, + metadata JSONB NOT NULL DEFAULT '{}'::jsonb, + issued_at TIMESTAMPTZ, + title VARCHAR(255) NOT NULL, + current_version_id UUID NOT NULL, + tenant_id UUID NOT NULL REFERENCES tenants(id) +); + +CREATE INDEX idx_documents_folder ON documents(folder_id); +CREATE INDEX idx_documents_deleted_at ON documents(deleted_at); +CREATE INDEX documents_tenant_id_idx ON documents(tenant_id); +CREATE INDEX idx_documents_current_version_id ON documents(current_version_id); +CREATE INDEX idx_documents_folder_title + ON documents ( + COALESCE(folder_id, '00000000-0000-0000-0000-000000000000'::uuid), + title + ) + WHERE deleted_at IS NULL; + +CREATE UNIQUE INDEX documents_tenant_folder_filename_unique + ON documents ( + tenant_id, + COALESCE(folder_id, '00000000-0000-0000-0000-000000000000'::uuid), + filename + ) + WHERE deleted_at IS NULL; + +CREATE TABLE document_versions ( + id UUID PRIMARY KEY, + document_id UUID NOT NULL REFERENCES documents(id) ON DELETE CASCADE, + version_number INT NOT NULL, + s3_key VARCHAR(500) NOT NULL, + size_bytes BIGINT NOT NULL, + checksum VARCHAR(64) NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + metadata JSONB NOT NULL DEFAULT '{}'::jsonb, + tenant_id UUID NOT NULL REFERENCES tenants(id), + CONSTRAINT document_versions_unique_version UNIQUE (document_id, version_number) +); + +CREATE INDEX idx_document_versions_document ON document_versions(document_id); +CREATE INDEX document_versions_tenant_id_idx ON document_versions(tenant_id); + +ALTER TABLE documents + ADD CONSTRAINT documents_current_version_fk + FOREIGN KEY (current_version_id) + REFERENCES document_versions(id) + DEFERRABLE INITIALLY DEFERRED; + +CREATE TABLE tags ( + id UUID PRIMARY KEY, + label VARCHAR(100) NOT NULL, + color VARCHAR(7), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + tenant_id UUID NOT NULL REFERENCES tenants(id) +); + +CREATE UNIQUE INDEX tags_tenant_label_unique ON tags(tenant_id, label); +CREATE INDEX tags_tenant_id_idx ON tags(tenant_id); + +CREATE TABLE document_tags ( + document_id UUID NOT NULL REFERENCES documents(id) ON DELETE CASCADE, + tag_id UUID NOT NULL REFERENCES tags(id) ON DELETE CASCADE, + assigned_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + assigned_by UUID REFERENCES users(id), + tenant_id UUID NOT NULL REFERENCES tenants(id), + PRIMARY KEY (document_id, tag_id) +); + +CREATE INDEX idx_document_tags_tag ON document_tags(tag_id); +CREATE INDEX document_tags_tenant_id_idx ON document_tags(tenant_id); + +CREATE TABLE correspondents ( + id UUID PRIMARY KEY, + name VARCHAR(255) NOT NULL, + metadata JSONB NOT NULL DEFAULT '{}'::jsonb, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + tenant_id UUID NOT NULL REFERENCES tenants(id) +); + +CREATE UNIQUE INDEX correspondents_tenant_name_unique + ON correspondents (tenant_id, name); +CREATE INDEX correspondents_tenant_id_idx ON correspondents(tenant_id); + +CREATE TABLE document_correspondents ( + document_id UUID NOT NULL REFERENCES documents(id) ON DELETE CASCADE, + correspondent_id UUID NOT NULL REFERENCES correspondents(id) ON DELETE CASCADE, + assigned_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + assigned_by UUID REFERENCES users(id), + tenant_id UUID NOT NULL REFERENCES tenants(id), + PRIMARY KEY (document_id, correspondent_id) +); + +CREATE INDEX idx_document_correspondents_document ON document_correspondents(document_id); +CREATE INDEX idx_document_correspondents_correspondent ON document_correspondents(correspondent_id); +CREATE INDEX document_correspondents_tenant_id_idx ON document_correspondents(tenant_id); + +CREATE TABLE document_assets ( + id UUID PRIMARY KEY, + document_version_id UUID NOT NULL REFERENCES document_versions(id) ON DELETE CASCADE, + asset_type TEXT NOT NULL, + mime_type TEXT NOT NULL, + metadata JSONB NOT NULL DEFAULT '{}'::jsonb, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + cardinality INTEGER, + tenant_id UUID NOT NULL REFERENCES tenants(id), + CONSTRAINT document_assets_unique UNIQUE (document_version_id, asset_type), + CONSTRAINT document_assets_cardinality_positive CHECK (cardinality IS NULL OR cardinality >= 1) +); + +CREATE INDEX idx_document_assets_version ON document_assets(document_version_id); +CREATE INDEX idx_document_assets_type ON document_assets(asset_type); +CREATE INDEX document_assets_tenant_id_idx ON document_assets(tenant_id); + +CREATE TABLE document_asset_objects ( + id UUID PRIMARY KEY, + asset_id UUID NOT NULL REFERENCES document_assets(id) ON DELETE CASCADE, + ordinal INTEGER NOT NULL, + s3_key TEXT NOT NULL, + metadata JSONB NOT NULL DEFAULT '{}'::jsonb, + tenant_id UUID NOT NULL REFERENCES tenants(id), + CONSTRAINT document_asset_objects_ordinal_positive CHECK (ordinal >= 1), + CONSTRAINT document_asset_objects_asset_ordinal_unique UNIQUE (asset_id, ordinal) +); + +CREATE INDEX idx_document_asset_objects_asset_ordinal + ON document_asset_objects(asset_id, ordinal); +CREATE INDEX document_asset_objects_tenant_id_idx ON document_asset_objects(tenant_id); + +CREATE TABLE jobs ( + id UUID PRIMARY KEY, + job_type TEXT NOT NULL, + payload JSONB NOT NULL, + status TEXT NOT NULL DEFAULT 'queued', + attempts INTEGER NOT NULL DEFAULT 0, + run_after TIMESTAMPTZ NOT NULL DEFAULT NOW(), + last_error TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + tenant_id UUID NOT NULL REFERENCES tenants(id), + CONSTRAINT jobs_status_check CHECK (status IN ('queued', 'processing', 'succeeded', 'failed')) +); + +CREATE INDEX idx_jobs_status_run_after ON jobs(status, run_after); +CREATE INDEX idx_jobs_job_type ON jobs(job_type); +CREATE INDEX jobs_tenant_id_idx ON jobs(tenant_id); + +CREATE OR REPLACE FUNCTION touch_jobs_updated_at() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = NOW(); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER trg_jobs_updated_at +BEFORE UPDATE ON jobs +FOR EACH ROW +EXECUTE FUNCTION touch_jobs_updated_at(); + +CREATE TABLE refresh_tokens ( + id UUID PRIMARY KEY, + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + token_hash TEXT NOT NULL, + issued_at TIMESTAMPTZ NOT NULL, + expires_at TIMESTAMPTZ NOT NULL, + revoked_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + tenant_id UUID NOT NULL REFERENCES tenants(id) +); + +CREATE INDEX idx_refresh_tokens_user_id ON refresh_tokens(user_id); +CREATE INDEX idx_refresh_tokens_token_hash ON refresh_tokens(token_hash); +CREATE INDEX refresh_tokens_tenant_id_idx ON refresh_tokens(tenant_id); + +CREATE TABLE webdav_tokens ( + id UUID PRIMARY KEY, + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + token_prefix TEXT NOT NULL, + token_hash TEXT NOT NULL, + label TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + last_used_at TIMESTAMPTZ, + expires_at TIMESTAMPTZ, + revoked_at TIMESTAMPTZ +); + +CREATE UNIQUE INDEX webdav_tokens_token_prefix_key ON webdav_tokens(token_prefix); +CREATE INDEX webdav_tokens_user_tenant_idx ON webdav_tokens(user_id, tenant_id); + +CREATE TABLE user_passkeys ( + id UUID PRIMARY KEY, + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + credential_id BYTEA NOT NULL UNIQUE, + public_key BYTEA NOT NULL, + credential JSONB NOT NULL, + sign_count BIGINT NOT NULL, + transports TEXT[] NOT NULL DEFAULT '{}'::text[], + aaguid UUID, + nickname TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + last_used_at TIMESTAMPTZ, + revoked_at TIMESTAMPTZ, + revoked_by UUID, + revoked_reason TEXT +); + +CREATE INDEX user_passkeys_user_id_idx ON user_passkeys(user_id); + +CREATE TABLE webauthn_challenges ( + id UUID PRIMARY KEY, + user_id UUID REFERENCES users(id) ON DELETE CASCADE, + purpose TEXT NOT NULL, + challenge BYTEA NOT NULL, + state BYTEA NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + expires_at TIMESTAMPTZ NOT NULL, + CONSTRAINT webauthn_challenges_purpose_check CHECK (purpose IN ('registration', 'authentication')) +); + +CREATE INDEX webauthn_challenges_user_id_idx ON webauthn_challenges(user_id); +CREATE INDEX webauthn_challenges_expires_at_idx ON webauthn_challenges(expires_at); diff --git a/backend/migrations/202510300001_rename_document_uploaded_at/down.sql b/backend/migrations/202510300001_rename_document_uploaded_at/down.sql new file mode 100644 index 0000000..9a70eb4 --- /dev/null +++ b/backend/migrations/202510300001_rename_document_uploaded_at/down.sql @@ -0,0 +1,2 @@ +ALTER TABLE documents + RENAME COLUMN created_at TO uploaded_at; diff --git a/backend/migrations/202510300001_rename_document_uploaded_at/up.sql b/backend/migrations/202510300001_rename_document_uploaded_at/up.sql new file mode 100644 index 0000000..7fbf576 --- /dev/null +++ b/backend/migrations/202510300001_rename_document_uploaded_at/up.sql @@ -0,0 +1,2 @@ +ALTER TABLE documents + RENAME COLUMN uploaded_at TO created_at; diff --git a/backend/migrations/202510310000_create_magic_tokens/down.sql b/backend/migrations/202510310000_create_magic_tokens/down.sql new file mode 100644 index 0000000..cdbb5ed --- /dev/null +++ b/backend/migrations/202510310000_create_magic_tokens/down.sql @@ -0,0 +1,2 @@ +DROP TABLE magic_tokens; +DROP TYPE magic_token_kind; diff --git a/backend/migrations/202510310000_create_magic_tokens/up.sql b/backend/migrations/202510310000_create_magic_tokens/up.sql new file mode 100644 index 0000000..736674e --- /dev/null +++ b/backend/migrations/202510310000_create_magic_tokens/up.sql @@ -0,0 +1,18 @@ +CREATE TYPE magic_token_kind AS ENUM ('email_login', 'demo_login'); + +CREATE TABLE magic_tokens ( + id UUID PRIMARY KEY, + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + kind magic_token_kind NOT NULL, + token_hash VARCHAR NOT NULL UNIQUE, + metadata JSONB NOT NULL DEFAULT '{}'::jsonb, + expires_at TIMESTAMPTZ NOT NULL, + max_uses INTEGER, + used_count INTEGER NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + created_by UUID REFERENCES users(id) ON DELETE SET NULL, + last_used_at TIMESTAMPTZ +); + +CREATE INDEX magic_tokens_token_hash_idx ON magic_tokens (token_hash); +CREATE INDEX magic_tokens_expires_at_idx ON magic_tokens (expires_at); diff --git a/backend/migrations/202510310100_split_schemas/down.sql b/backend/migrations/202510310100_split_schemas/down.sql new file mode 100644 index 0000000..4c93eda --- /dev/null +++ b/backend/migrations/202510310100_split_schemas/down.sql @@ -0,0 +1,28 @@ +-- Move tables and types back to the public schema +ALTER TABLE tenant.webdav_tokens SET SCHEMA public; +ALTER TABLE tenant.user_memberships SET SCHEMA public; +ALTER TABLE tenant.refresh_tokens SET SCHEMA public; +ALTER TABLE tenant.tags SET SCHEMA public; +ALTER TABLE tenant.document_correspondents SET SCHEMA public; +ALTER TABLE tenant.document_tags SET SCHEMA public; +ALTER TABLE tenant.document_asset_objects SET SCHEMA public; +ALTER TABLE tenant.document_assets SET SCHEMA public; +ALTER TABLE tenant.document_versions SET SCHEMA public; +ALTER TABLE tenant.documents SET SCHEMA public; +ALTER TABLE tenant.folders SET SCHEMA public; +ALTER TABLE tenant.correspondents SET SCHEMA public; + +ALTER FUNCTION shared.touch_jobs_updated_at() SET SCHEMA public; + +ALTER TABLE shared.magic_tokens SET SCHEMA public; +ALTER TABLE shared.jobs SET SCHEMA public; +ALTER TABLE shared.webauthn_challenges SET SCHEMA public; +ALTER TABLE shared.user_passkeys SET SCHEMA public; +ALTER TABLE shared.users SET SCHEMA public; +ALTER TABLE shared.tenants SET SCHEMA public; + +ALTER TYPE shared.magic_token_kind SET SCHEMA public; +ALTER TYPE shared.tenant_status SET SCHEMA public; + +DROP SCHEMA IF EXISTS tenant CASCADE; +DROP SCHEMA IF EXISTS shared CASCADE; diff --git a/backend/migrations/202510310100_split_schemas/up.sql b/backend/migrations/202510310100_split_schemas/up.sql new file mode 100644 index 0000000..5ab9471 --- /dev/null +++ b/backend/migrations/202510310100_split_schemas/up.sql @@ -0,0 +1,45 @@ +CREATE SCHEMA IF NOT EXISTS shared; +CREATE SCHEMA IF NOT EXISTS tenant; + +-- Move global types and tables into the shared schema +ALTER TYPE tenant_status SET SCHEMA shared; +ALTER TYPE magic_token_kind SET SCHEMA shared; + +ALTER TABLE tenants SET SCHEMA shared; +ALTER TABLE users SET SCHEMA shared; +ALTER TABLE user_passkeys SET SCHEMA shared; +ALTER TABLE webauthn_challenges SET SCHEMA shared; +ALTER TABLE jobs SET SCHEMA shared; +ALTER TABLE magic_tokens SET SCHEMA shared; + +ALTER FUNCTION touch_jobs_updated_at() SET SCHEMA shared; + +-- Move tenant-scoped tables into the tenant schema +ALTER TABLE correspondents SET SCHEMA tenant; +ALTER TABLE folders SET SCHEMA tenant; +ALTER TABLE documents SET SCHEMA tenant; +ALTER TABLE document_versions SET SCHEMA tenant; +ALTER TABLE document_assets SET SCHEMA tenant; +ALTER TABLE document_asset_objects SET SCHEMA tenant; +ALTER TABLE document_tags SET SCHEMA tenant; +ALTER TABLE document_correspondents SET SCHEMA tenant; +ALTER TABLE tags SET SCHEMA tenant; +ALTER TABLE refresh_tokens SET SCHEMA tenant; +ALTER TABLE user_memberships SET SCHEMA tenant; +ALTER TABLE webdav_tokens SET SCHEMA tenant; + +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'papercrate_app') THEN + RETURN; + END IF; + + GRANT USAGE ON SCHEMA shared TO papercrate_app; + GRANT USAGE ON SCHEMA tenant TO papercrate_app; + GRANT SELECT ON ALL TABLES IN SCHEMA shared TO papercrate_app; + GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA tenant TO papercrate_app; + + ALTER DEFAULT PRIVILEGES IN SCHEMA shared GRANT SELECT ON TABLES TO papercrate_app; + ALTER DEFAULT PRIVILEGES IN SCHEMA tenant GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO papercrate_app; +END +$$; diff --git a/backend/migrations/202510310200_enable_rls/down.sql b/backend/migrations/202510310200_enable_rls/down.sql new file mode 100644 index 0000000..be3675d --- /dev/null +++ b/backend/migrations/202510310200_enable_rls/down.sql @@ -0,0 +1,51 @@ +DROP POLICY IF EXISTS tenant_membership_select_policy ON tenant.user_memberships; +ALTER TABLE tenant.user_memberships NO FORCE ROW LEVEL SECURITY; +ALTER TABLE tenant.user_memberships DISABLE ROW LEVEL SECURITY; + +DROP POLICY IF EXISTS tenant_isolation_policy ON tenant.webdav_tokens; +ALTER TABLE tenant.webdav_tokens NO FORCE ROW LEVEL SECURITY; +ALTER TABLE tenant.webdav_tokens DISABLE ROW LEVEL SECURITY; + +DROP POLICY IF EXISTS tenant_isolation_policy ON tenant.tags; +ALTER TABLE tenant.tags NO FORCE ROW LEVEL SECURITY; +ALTER TABLE tenant.tags DISABLE ROW LEVEL SECURITY; + +DROP POLICY IF EXISTS tenant_refresh_token_policy ON tenant.refresh_tokens; +ALTER TABLE tenant.refresh_tokens NO FORCE ROW LEVEL SECURITY; +ALTER TABLE tenant.refresh_tokens DISABLE ROW LEVEL SECURITY; + +DROP POLICY IF EXISTS tenant_isolation_policy ON tenant.folders; +ALTER TABLE tenant.folders NO FORCE ROW LEVEL SECURITY; +ALTER TABLE tenant.folders DISABLE ROW LEVEL SECURITY; + +DROP POLICY IF EXISTS tenant_isolation_policy ON tenant.documents; +ALTER TABLE tenant.documents NO FORCE ROW LEVEL SECURITY; +ALTER TABLE tenant.documents DISABLE ROW LEVEL SECURITY; + +DROP POLICY IF EXISTS tenant_isolation_policy ON tenant.document_versions; +ALTER TABLE tenant.document_versions NO FORCE ROW LEVEL SECURITY; +ALTER TABLE tenant.document_versions DISABLE ROW LEVEL SECURITY; + +DROP POLICY IF EXISTS tenant_isolation_policy ON tenant.document_tags; +ALTER TABLE tenant.document_tags NO FORCE ROW LEVEL SECURITY; +ALTER TABLE tenant.document_tags DISABLE ROW LEVEL SECURITY; + +DROP POLICY IF EXISTS tenant_isolation_policy ON tenant.document_correspondents; +ALTER TABLE tenant.document_correspondents NO FORCE ROW LEVEL SECURITY; +ALTER TABLE tenant.document_correspondents DISABLE ROW LEVEL SECURITY; + +DROP POLICY IF EXISTS tenant_isolation_policy ON tenant.document_assets; +ALTER TABLE tenant.document_assets NO FORCE ROW LEVEL SECURITY; +ALTER TABLE tenant.document_assets DISABLE ROW LEVEL SECURITY; + +DROP POLICY IF EXISTS tenant_isolation_policy ON tenant.document_asset_objects; +ALTER TABLE tenant.document_asset_objects NO FORCE ROW LEVEL SECURITY; +ALTER TABLE tenant.document_asset_objects DISABLE ROW LEVEL SECURITY; + +DROP POLICY IF EXISTS tenant_isolation_policy ON tenant.correspondents; +ALTER TABLE tenant.correspondents NO FORCE ROW LEVEL SECURITY; +ALTER TABLE tenant.correspondents DISABLE ROW LEVEL SECURITY; + +DROP FUNCTION IF EXISTS shared.current_refresh_token_hash(); +DROP FUNCTION IF EXISTS shared.current_user_id(); +DROP FUNCTION IF EXISTS shared.current_tenant_id(); diff --git a/backend/migrations/202510310200_enable_rls/up.sql b/backend/migrations/202510310200_enable_rls/up.sql new file mode 100644 index 0000000..2a7550f --- /dev/null +++ b/backend/migrations/202510310200_enable_rls/up.sql @@ -0,0 +1,69 @@ +CREATE OR REPLACE FUNCTION shared.current_tenant_id() RETURNS uuid AS $$ + SELECT CASE + WHEN setting IS NULL OR setting = '' THEN NULL + ELSE setting::uuid + END + FROM (SELECT current_setting('papercrate.tenant_id', true) AS setting) s; +$$ LANGUAGE SQL STABLE; + +CREATE OR REPLACE FUNCTION shared.current_user_id() RETURNS uuid AS $$ + SELECT CASE + WHEN setting IS NULL OR setting = '' THEN NULL + ELSE setting::uuid + END + FROM (SELECT current_setting('papercrate.user_id', true) AS setting) s; +$$ LANGUAGE SQL STABLE; + +CREATE OR REPLACE FUNCTION shared.current_refresh_token_hash() RETURNS text AS $$ + SELECT NULLIF(current_setting('papercrate.refresh_token_hash', true), '') +$$ LANGUAGE SQL STABLE; + +-- Helper to create tenant isolation policy +CREATE OR REPLACE FUNCTION shared.ensure_tenant_policy(table_reg regclass) RETURNS void AS $$ +BEGIN + EXECUTE format('ALTER TABLE %s ENABLE ROW LEVEL SECURITY', table_reg); + EXECUTE format('ALTER TABLE %s FORCE ROW LEVEL SECURITY', table_reg); + EXECUTE format( + 'CREATE POLICY tenant_isolation_policy ON %s USING (tenant_id = shared.current_tenant_id()) WITH CHECK (tenant_id = shared.current_tenant_id())', + table_reg + ); +END; +$$ LANGUAGE plpgsql; + +SELECT shared.ensure_tenant_policy('tenant.correspondents'); +SELECT shared.ensure_tenant_policy('tenant.document_asset_objects'); +SELECT shared.ensure_tenant_policy('tenant.document_assets'); +SELECT shared.ensure_tenant_policy('tenant.document_correspondents'); +SELECT shared.ensure_tenant_policy('tenant.document_tags'); +SELECT shared.ensure_tenant_policy('tenant.document_versions'); +SELECT shared.ensure_tenant_policy('tenant.documents'); +SELECT shared.ensure_tenant_policy('tenant.folders'); +SELECT shared.ensure_tenant_policy('tenant.tags'); +SELECT shared.ensure_tenant_policy('tenant.webdav_tokens'); + +-- user_memberships has a special read policy to allow tenant discovery during login +ALTER TABLE tenant.user_memberships ENABLE ROW LEVEL SECURITY; +ALTER TABLE tenant.user_memberships FORCE ROW LEVEL SECURITY; +CREATE POLICY tenant_membership_select_policy ON tenant.user_memberships + USING ( + tenant_id = shared.current_tenant_id() + OR ( + shared.current_user_id() IS NOT NULL + AND user_id = shared.current_user_id() + ) + ) + WITH CHECK (tenant_id = shared.current_tenant_id()); + +ALTER TABLE tenant.refresh_tokens ENABLE ROW LEVEL SECURITY; +ALTER TABLE tenant.refresh_tokens FORCE ROW LEVEL SECURITY; +CREATE POLICY tenant_refresh_token_policy ON tenant.refresh_tokens + USING ( + tenant_id = shared.current_tenant_id() + OR ( + shared.current_refresh_token_hash() IS NOT NULL + AND token_hash = shared.current_refresh_token_hash() + ) + ) + WITH CHECK (tenant_id = shared.current_tenant_id()); + +DROP FUNCTION shared.ensure_tenant_policy(regclass); diff --git a/backend/migrations/202510310201_enable_webdav_token_rls/down.sql b/backend/migrations/202510310201_enable_webdav_token_rls/down.sql new file mode 100644 index 0000000..56e8426 --- /dev/null +++ b/backend/migrations/202510310201_enable_webdav_token_rls/down.sql @@ -0,0 +1,5 @@ +DROP POLICY IF EXISTS tenant_webdav_token_policy ON tenant.webdav_tokens; +ALTER TABLE tenant.webdav_tokens NO FORCE ROW LEVEL SECURITY; +ALTER TABLE tenant.webdav_tokens DISABLE ROW LEVEL SECURITY; + +DROP FUNCTION IF EXISTS shared.current_webdav_token_prefix(); diff --git a/backend/migrations/202510310201_enable_webdav_token_rls/up.sql b/backend/migrations/202510310201_enable_webdav_token_rls/up.sql new file mode 100644 index 0000000..290ff75 --- /dev/null +++ b/backend/migrations/202510310201_enable_webdav_token_rls/up.sql @@ -0,0 +1,15 @@ +CREATE OR REPLACE FUNCTION shared.current_webdav_token_prefix() RETURNS text AS $$ + SELECT NULLIF(current_setting('papercrate.webdav_token_prefix', true), '') +$$ LANGUAGE SQL STABLE; + +ALTER TABLE tenant.webdav_tokens ENABLE ROW LEVEL SECURITY; +ALTER TABLE tenant.webdav_tokens FORCE ROW LEVEL SECURITY; +CREATE POLICY tenant_webdav_token_policy ON tenant.webdav_tokens + USING ( + tenant_id = shared.current_tenant_id() + OR ( + shared.current_webdav_token_prefix() IS NOT NULL + AND token_prefix = shared.current_webdav_token_prefix() + ) + ) + WITH CHECK (tenant_id = shared.current_tenant_id()); diff --git a/backend/migrations/202511010000_api_tokens/down.sql b/backend/migrations/202511010000_api_tokens/down.sql new file mode 100644 index 0000000..04636f2 --- /dev/null +++ b/backend/migrations/202511010000_api_tokens/down.sql @@ -0,0 +1,31 @@ +DROP POLICY IF EXISTS tenant_api_token_policy ON tenant.api_tokens; +DROP FUNCTION IF EXISTS shared.current_api_token_prefix(); + +ALTER TABLE tenant.api_tokens DISABLE ROW LEVEL SECURITY; +ALTER TABLE tenant.api_tokens NO FORCE ROW LEVEL SECURITY; + +ALTER TABLE tenant.api_tokens + DROP COLUMN IF EXISTS capabilities; + +DROP TYPE IF EXISTS shared.api_token_capability; + +ALTER TABLE tenant.api_tokens RENAME TO webdav_tokens; +ALTER INDEX tenant.api_tokens_token_prefix_key RENAME TO webdav_tokens_token_prefix_key; +ALTER INDEX tenant.api_tokens_user_tenant_idx RENAME TO webdav_tokens_user_tenant_idx; + +CREATE OR REPLACE FUNCTION shared.current_webdav_token_prefix() RETURNS text AS $$ + SELECT NULLIF(current_setting('papercrate.webdav_token_prefix', true), '') +$$ LANGUAGE SQL STABLE; + +ALTER TABLE tenant.webdav_tokens ENABLE ROW LEVEL SECURITY; +ALTER TABLE tenant.webdav_tokens FORCE ROW LEVEL SECURITY; + +CREATE POLICY tenant_webdav_token_policy ON tenant.webdav_tokens + USING ( + tenant_id = shared.current_tenant_id() + OR ( + shared.current_webdav_token_prefix() IS NOT NULL + AND token_prefix = shared.current_webdav_token_prefix() + ) + ) + WITH CHECK (tenant_id = shared.current_tenant_id()); diff --git a/backend/migrations/202511010000_api_tokens/up.sql b/backend/migrations/202511010000_api_tokens/up.sql new file mode 100644 index 0000000..66d09fb --- /dev/null +++ b/backend/migrations/202511010000_api_tokens/up.sql @@ -0,0 +1,28 @@ +ALTER TABLE tenant.webdav_tokens RENAME TO api_tokens; +ALTER INDEX tenant.webdav_tokens_token_prefix_key RENAME TO api_tokens_token_prefix_key; +ALTER INDEX tenant.webdav_tokens_user_tenant_idx RENAME TO api_tokens_user_tenant_idx; + +DROP POLICY IF EXISTS tenant_webdav_token_policy ON tenant.api_tokens; +DROP FUNCTION IF EXISTS shared.current_webdav_token_prefix(); + +CREATE TYPE shared.api_token_capability AS ENUM ('api', 'webdav'); + +ALTER TABLE tenant.api_tokens + ADD COLUMN capabilities shared.api_token_capability[] NOT NULL DEFAULT ARRAY['webdav']::shared.api_token_capability[]; + +ALTER TABLE tenant.api_tokens ENABLE ROW LEVEL SECURITY; +ALTER TABLE tenant.api_tokens FORCE ROW LEVEL SECURITY; + +CREATE OR REPLACE FUNCTION shared.current_api_token_prefix() RETURNS text AS $$ + SELECT NULLIF(current_setting('papercrate.api_token_prefix', true), '') +$$ LANGUAGE SQL STABLE; + +CREATE POLICY tenant_api_token_policy ON tenant.api_tokens + USING ( + tenant_id = shared.current_tenant_id() + OR ( + shared.current_api_token_prefix() IS NOT NULL + AND token_prefix = shared.current_api_token_prefix() + ) + ) + WITH CHECK (tenant_id = shared.current_tenant_id()); diff --git a/backend/migrations/202511010001_rename_refresh_tokens_to_user_sessions/down.sql b/backend/migrations/202511010001_rename_refresh_tokens_to_user_sessions/down.sql new file mode 100644 index 0000000..5a16912 --- /dev/null +++ b/backend/migrations/202511010001_rename_refresh_tokens_to_user_sessions/down.sql @@ -0,0 +1,26 @@ +CREATE OR REPLACE FUNCTION shared.current_refresh_token_hash() RETURNS text AS $$ + SELECT NULLIF(current_setting('papercrate.refresh_token_hash', true), '') +$$ LANGUAGE SQL STABLE; + +ALTER POLICY tenant_user_session_policy ON tenant.user_sessions + USING ( + tenant_id = shared.current_tenant_id() + OR ( + shared.current_refresh_token_hash() IS NOT NULL + AND token_hash = shared.current_refresh_token_hash() + ) + ); + +ALTER POLICY tenant_user_session_policy ON tenant.user_sessions + WITH CHECK (tenant_id = shared.current_tenant_id()); + +ALTER POLICY tenant_user_session_policy ON tenant.user_sessions + RENAME TO tenant_refresh_token_policy; + +ALTER INDEX tenant.idx_user_sessions_user_id RENAME TO idx_refresh_tokens_user_id; +ALTER INDEX tenant.idx_user_sessions_token_hash RENAME TO idx_refresh_tokens_token_hash; +ALTER INDEX tenant.user_sessions_tenant_id_idx RENAME TO refresh_tokens_tenant_id_idx; + +ALTER TABLE tenant.user_sessions RENAME TO refresh_tokens; + +DROP FUNCTION IF EXISTS shared.current_user_session_hash(); diff --git a/backend/migrations/202511010001_rename_refresh_tokens_to_user_sessions/up.sql b/backend/migrations/202511010001_rename_refresh_tokens_to_user_sessions/up.sql new file mode 100644 index 0000000..a6bb642 --- /dev/null +++ b/backend/migrations/202511010001_rename_refresh_tokens_to_user_sessions/up.sql @@ -0,0 +1,26 @@ +ALTER TABLE tenant.refresh_tokens RENAME TO user_sessions; + +ALTER INDEX tenant.idx_refresh_tokens_user_id RENAME TO idx_user_sessions_user_id; +ALTER INDEX tenant.idx_refresh_tokens_token_hash RENAME TO idx_user_sessions_token_hash; +ALTER INDEX tenant.refresh_tokens_tenant_id_idx RENAME TO user_sessions_tenant_id_idx; + +ALTER POLICY tenant_refresh_token_policy ON tenant.user_sessions + RENAME TO tenant_user_session_policy; + +CREATE OR REPLACE FUNCTION shared.current_user_session_hash() RETURNS text AS $$ + SELECT NULLIF(current_setting('papercrate.user_session_hash', true), '') +$$ LANGUAGE SQL STABLE; + +ALTER POLICY tenant_user_session_policy ON tenant.user_sessions + USING ( + tenant_id = shared.current_tenant_id() + OR ( + shared.current_user_session_hash() IS NOT NULL + AND token_hash = shared.current_user_session_hash() + ) + ); + +ALTER POLICY tenant_user_session_policy ON tenant.user_sessions + WITH CHECK (tenant_id = shared.current_tenant_id()); + +DROP FUNCTION IF EXISTS shared.current_refresh_token_hash(); diff --git a/backend/migrations/202511010002_set_assigned_by_on_delete_set_null/down.sql b/backend/migrations/202511010002_set_assigned_by_on_delete_set_null/down.sql new file mode 100644 index 0000000..58f08d3 --- /dev/null +++ b/backend/migrations/202511010002_set_assigned_by_on_delete_set_null/down.sql @@ -0,0 +1,13 @@ +ALTER TABLE tenant.document_tags + DROP CONSTRAINT IF EXISTS document_tags_assigned_by_fkey, + ADD CONSTRAINT document_tags_assigned_by_fkey + FOREIGN KEY (assigned_by) + REFERENCES shared.users (id) + ON DELETE NO ACTION; + +ALTER TABLE tenant.document_correspondents + DROP CONSTRAINT IF EXISTS document_correspondents_assigned_by_fkey, + ADD CONSTRAINT document_correspondents_assigned_by_fkey + FOREIGN KEY (assigned_by) + REFERENCES shared.users (id) + ON DELETE NO ACTION; diff --git a/backend/migrations/202511010002_set_assigned_by_on_delete_set_null/up.sql b/backend/migrations/202511010002_set_assigned_by_on_delete_set_null/up.sql new file mode 100644 index 0000000..cbdf1db --- /dev/null +++ b/backend/migrations/202511010002_set_assigned_by_on_delete_set_null/up.sql @@ -0,0 +1,13 @@ +ALTER TABLE tenant.document_tags + DROP CONSTRAINT IF EXISTS document_tags_assigned_by_fkey, + ADD CONSTRAINT document_tags_assigned_by_fkey + FOREIGN KEY (assigned_by) + REFERENCES shared.users (id) + ON DELETE SET NULL; + +ALTER TABLE tenant.document_correspondents + DROP CONSTRAINT IF EXISTS document_correspondents_assigned_by_fkey, + ADD CONSTRAINT document_correspondents_assigned_by_fkey + FOREIGN KEY (assigned_by) + REFERENCES shared.users (id) + ON DELETE SET NULL; diff --git a/backend/migrations/202511010003_capability_sets/down.sql b/backend/migrations/202511010003_capability_sets/down.sql new file mode 100644 index 0000000..d20033c --- /dev/null +++ b/backend/migrations/202511010003_capability_sets/down.sql @@ -0,0 +1,28 @@ +CREATE TYPE api_token_capability AS ENUM ('api', 'webdav'); + +ALTER TABLE tenant.api_tokens + ADD COLUMN capabilities api_token_capability[] NOT NULL DEFAULT ARRAY[]::api_token_capability[]; + +UPDATE tenant.api_tokens t +SET capabilities = ARRAY['api']::api_token_capability[] +FROM tenant.capability_sets cs +WHERE t.capability_set_id = cs.id + AND cs.slug = 'owner'; + +UPDATE tenant.api_tokens t +SET capabilities = ARRAY['webdav']::api_token_capability[] +FROM tenant.capability_sets cs +WHERE t.capability_set_id = cs.id + AND cs.slug = 'webdav' + AND (t.capabilities IS NULL OR array_length(t.capabilities, 1) = 0); + +ALTER TABLE tenant.api_tokens + DROP COLUMN capability_set_id; + +ALTER TABLE tenant.user_memberships + DROP COLUMN capability_set_id; + +DROP TABLE IF EXISTS tenant.capability_set_capabilities; +DROP TABLE IF EXISTS tenant.capability_sets; + +DROP TYPE IF EXISTS api_capability; diff --git a/backend/migrations/202511010003_capability_sets/up.sql b/backend/migrations/202511010003_capability_sets/up.sql new file mode 100644 index 0000000..f3bb977 --- /dev/null +++ b/backend/migrations/202511010003_capability_sets/up.sql @@ -0,0 +1,158 @@ +CREATE TYPE api_capability AS ENUM ( + 'documents:read', + 'documents:edit', + 'documents:write', + 'documents:upload', + 'folders:read', + 'folders:edit', + 'folders:write', + 'tags:read', + 'tags:edit', + 'tags:write', + 'correspondents:read', + 'correspondents:edit', + 'correspondents:write', + 'profile:read', + 'profile:write', + 'webdav:read', + 'webdav:write', + 'capability_sets:read', + 'capability_sets:write' +); + +CREATE TABLE tenant.capability_sets ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + tenant_id UUID NOT NULL REFERENCES shared.tenants(id) ON DELETE CASCADE, + slug TEXT NOT NULL, + cap_version INT NOT NULL DEFAULT 1, + is_system BOOLEAN NOT NULL DEFAULT FALSE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE (tenant_id, slug) +); + +CREATE TABLE tenant.capability_set_capabilities ( + capability_set_id UUID NOT NULL REFERENCES tenant.capability_sets(id) ON DELETE CASCADE, + capability api_capability NOT NULL, + PRIMARY KEY (capability_set_id, capability) +); + +ALTER TABLE tenant.api_tokens + ADD COLUMN capability_set_id UUID REFERENCES tenant.capability_sets(id); + +ALTER TABLE tenant.user_memberships + ADD COLUMN capability_set_id UUID REFERENCES tenant.capability_sets(id); + +WITH owner_sets AS ( + INSERT INTO tenant.capability_sets (tenant_id, slug, is_system) + SELECT id, 'owner', TRUE + FROM shared.tenants + RETURNING id, tenant_id +), +user_sets AS ( + INSERT INTO tenant.capability_sets (tenant_id, slug, is_system) + SELECT id, 'user', TRUE + FROM shared.tenants + RETURNING id, tenant_id +), +readonly_sets AS ( + INSERT INTO tenant.capability_sets (tenant_id, slug, is_system) + SELECT id, 'readonly', TRUE + FROM shared.tenants + RETURNING id, tenant_id +), +webdav_sets AS ( + INSERT INTO tenant.capability_sets (tenant_id, slug, is_system) + SELECT id, 'webdav', TRUE + FROM shared.tenants + RETURNING id, tenant_id +) +INSERT INTO tenant.capability_set_capabilities (capability_set_id, capability) +SELECT set_id, + capability +FROM ( + SELECT os.id AS set_id, + UNNEST(ARRAY[ + 'documents:read'::api_capability, + 'documents:edit'::api_capability, + 'documents:write'::api_capability, + 'documents:upload'::api_capability, + 'folders:read'::api_capability, + 'folders:edit'::api_capability, + 'folders:write'::api_capability, + 'tags:read'::api_capability, + 'tags:edit'::api_capability, + 'tags:write'::api_capability, + 'correspondents:read'::api_capability, + 'correspondents:edit'::api_capability, + 'correspondents:write'::api_capability, + 'profile:read'::api_capability, + 'profile:write'::api_capability, + 'webdav:read'::api_capability, + 'webdav:write'::api_capability, + 'capability_sets:read'::api_capability, + 'capability_sets:write'::api_capability + ]) AS capability + FROM owner_sets os + UNION ALL + SELECT us.id, + UNNEST(ARRAY[ + 'documents:read'::api_capability, + 'documents:edit'::api_capability, + 'documents:write'::api_capability, + 'documents:upload'::api_capability, + 'folders:read'::api_capability, + 'folders:edit'::api_capability, + 'folders:write'::api_capability, + 'tags:read'::api_capability, + 'tags:edit'::api_capability, + 'tags:write'::api_capability, + 'correspondents:read'::api_capability, + 'correspondents:edit'::api_capability, + 'correspondents:write'::api_capability, + 'profile:read'::api_capability, + 'profile:write'::api_capability + ]) AS capability + FROM user_sets us + UNION ALL + SELECT rs.id, + UNNEST(ARRAY[ + 'documents:read'::api_capability, + 'folders:read'::api_capability, + 'tags:read'::api_capability, + 'correspondents:read'::api_capability, + 'webdav:read'::api_capability + ]) AS capability + FROM readonly_sets rs + UNION ALL + SELECT ws.id, + UNNEST(ARRAY['webdav:read'::api_capability]) AS capability + FROM webdav_sets ws +) seeded; + +UPDATE tenant.user_memberships um +SET capability_set_id = cs.id +FROM tenant.capability_sets cs +WHERE cs.tenant_id = um.tenant_id + AND cs.slug = 'owner'; + +UPDATE tenant.api_tokens t +SET capability_set_id = cs.id +FROM tenant.capability_sets cs +WHERE cs.tenant_id = t.tenant_id + AND cs.slug = 'owner'; + +UPDATE tenant.api_tokens t +SET capability_set_id = cs.id +FROM tenant.capability_sets cs +WHERE cs.tenant_id = t.tenant_id + AND cs.slug = 'webdav' + AND t.capability_set_id IS NULL; + +ALTER TABLE tenant.api_tokens + ALTER COLUMN capability_set_id SET NOT NULL; + +ALTER TABLE tenant.api_tokens + DROP COLUMN capabilities; + +DROP TYPE IF EXISTS api_token_capability; diff --git a/backend/migrations/202511010004_purge_document_jobs/down.sql b/backend/migrations/202511010004_purge_document_jobs/down.sql new file mode 100644 index 0000000..0bba65c --- /dev/null +++ b/backend/migrations/202511010004_purge_document_jobs/down.sql @@ -0,0 +1 @@ +DROP INDEX IF EXISTS shared.jobs_purge_document_pending_unique; diff --git a/backend/migrations/202511010004_purge_document_jobs/up.sql b/backend/migrations/202511010004_purge_document_jobs/up.sql new file mode 100644 index 0000000..240b72c --- /dev/null +++ b/backend/migrations/202511010004_purge_document_jobs/up.sql @@ -0,0 +1,8 @@ +CREATE UNIQUE INDEX jobs_purge_document_pending_unique + ON shared.jobs ( + tenant_id, + ((payload ->> 'document_id')::uuid) + ) + WHERE job_type = 'purge-document' + AND payload ? 'document_id' + AND status IN ('queued', 'processing'); diff --git a/backend/migrations/202511010005_documents_ordering_indexes/down.sql b/backend/migrations/202511010005_documents_ordering_indexes/down.sql new file mode 100644 index 0000000..d357cf1 --- /dev/null +++ b/backend/migrations/202511010005_documents_ordering_indexes/down.sql @@ -0,0 +1,5 @@ +DROP INDEX IF EXISTS tenant.idx_documents_tenant_folder_updated_at; +DROP INDEX IF EXISTS tenant.idx_documents_tenant_folder_created_at; +DROP INDEX IF EXISTS tenant.idx_documents_tenant_folder_issued_at; +DROP INDEX IF EXISTS tenant.idx_documents_tenant_folder_title_order; +DROP COLLATION IF EXISTS unicode_ci; diff --git a/backend/migrations/202511010005_documents_ordering_indexes/up.sql b/backend/migrations/202511010005_documents_ordering_indexes/up.sql new file mode 100644 index 0000000..2f936f2 --- /dev/null +++ b/backend/migrations/202511010005_documents_ordering_indexes/up.sql @@ -0,0 +1,37 @@ +CREATE COLLATION IF NOT EXISTS unicode_ci + (provider = icu, locale = 'und-u-ks-level2'); + +CREATE INDEX idx_documents_tenant_folder_title_order + ON tenant.documents ( + tenant_id, + COALESCE(folder_id, '00000000-0000-0000-0000-000000000000'::uuid), + title COLLATE "unicode_ci" + ) + WHERE deleted_at IS NULL; + +CREATE INDEX idx_documents_tenant_folder_issued_at + ON tenant.documents ( + tenant_id, + COALESCE(folder_id, '00000000-0000-0000-0000-000000000000'::uuid), + issued_at, + title COLLATE "unicode_ci" + ) + WHERE deleted_at IS NULL; + +CREATE INDEX idx_documents_tenant_folder_created_at + ON tenant.documents ( + tenant_id, + COALESCE(folder_id, '00000000-0000-0000-0000-000000000000'::uuid), + created_at, + title COLLATE "unicode_ci" + ) + WHERE deleted_at IS NULL; + +CREATE INDEX idx_documents_tenant_folder_updated_at + ON tenant.documents ( + tenant_id, + COALESCE(folder_id, '00000000-0000-0000-0000-000000000000'::uuid), + updated_at, + title COLLATE "unicode_ci" + ) + WHERE deleted_at IS NULL; diff --git a/backend/migrations/202511110000_make_jobs_tenant_nullable/down.sql b/backend/migrations/202511110000_make_jobs_tenant_nullable/down.sql new file mode 100644 index 0000000..8172ced --- /dev/null +++ b/backend/migrations/202511110000_make_jobs_tenant_nullable/down.sql @@ -0,0 +1,13 @@ +ALTER TABLE shared.jobs + DROP CONSTRAINT jobs_tenant_id_fkey; + +ALTER TABLE shared.jobs + ALTER COLUMN tenant_id SET NOT NULL; + +ALTER TABLE shared.jobs + ADD CONSTRAINT jobs_tenant_id_fkey + FOREIGN KEY (tenant_id) + REFERENCES shared.tenants(id); + +ALTER TABLE shared.jobs + DROP COLUMN result; diff --git a/backend/migrations/202511110000_make_jobs_tenant_nullable/up.sql b/backend/migrations/202511110000_make_jobs_tenant_nullable/up.sql new file mode 100644 index 0000000..86be1e8 --- /dev/null +++ b/backend/migrations/202511110000_make_jobs_tenant_nullable/up.sql @@ -0,0 +1,14 @@ +ALTER TABLE shared.jobs + ALTER COLUMN tenant_id DROP NOT NULL; + +ALTER TABLE shared.jobs + DROP CONSTRAINT jobs_tenant_id_fkey; + +ALTER TABLE shared.jobs + ADD CONSTRAINT jobs_tenant_id_fkey + FOREIGN KEY (tenant_id) + REFERENCES shared.tenants(id) + ON DELETE SET NULL; + +ALTER TABLE shared.jobs + ADD COLUMN result JSONB; diff --git a/backend/migrations/202511110100_add_tenant_capability_enum/down.sql b/backend/migrations/202511110100_add_tenant_capability_enum/down.sql new file mode 100644 index 0000000..c7d48e0 --- /dev/null +++ b/backend/migrations/202511110100_add_tenant_capability_enum/down.sql @@ -0,0 +1,4 @@ +-- diesel:run_in_transaction = false + +-- Enum values cannot be removed safely; this down migration intentionally left empty. +SELECT 1; diff --git a/backend/migrations/202511110100_add_tenant_capability_enum/up.sql b/backend/migrations/202511110100_add_tenant_capability_enum/up.sql new file mode 100644 index 0000000..0063e29 --- /dev/null +++ b/backend/migrations/202511110100_add_tenant_capability_enum/up.sql @@ -0,0 +1,46 @@ +-- diesel:run_in_transaction = false + +DO $$ +BEGIN + BEGIN + EXECUTE 'ALTER TYPE tenant.api_capability ADD VALUE ''tenants:write'''; + EXCEPTION + WHEN undefined_object THEN + BEGIN + EXECUTE 'ALTER TYPE api_capability ADD VALUE ''tenants:write'''; + EXCEPTION + WHEN duplicate_object THEN NULL; + END; + WHEN duplicate_object THEN NULL; + END; +END $$; + +DO $$ +BEGIN + BEGIN + EXECUTE 'ALTER TYPE tenant.api_capability ADD VALUE ''tenants:reset'''; + EXCEPTION + WHEN undefined_object THEN + BEGIN + EXECUTE 'ALTER TYPE api_capability ADD VALUE ''tenants:reset'''; + EXCEPTION + WHEN duplicate_object THEN NULL; + END; + WHEN duplicate_object THEN NULL; + END; +END $$; + +DO $$ +BEGIN + BEGIN + EXECUTE 'ALTER TYPE tenant.api_capability ADD VALUE ''tenants:delete'''; + EXCEPTION + WHEN undefined_object THEN + BEGIN + EXECUTE 'ALTER TYPE api_capability ADD VALUE ''tenants:delete'''; + EXCEPTION + WHEN duplicate_object THEN NULL; + END; + WHEN duplicate_object THEN NULL; + END; +END $$; diff --git a/backend/migrations/202511110200_owner_tenant_capabilities/down.sql b/backend/migrations/202511110200_owner_tenant_capabilities/down.sql new file mode 100644 index 0000000..ed3e808 --- /dev/null +++ b/backend/migrations/202511110200_owner_tenant_capabilities/down.sql @@ -0,0 +1,9 @@ +-- diesel:run_in_transaction = false + +DELETE FROM tenant.capability_set_capabilities +WHERE capability IN ( + 'tenants:write'::api_capability, + 'tenants:reset'::api_capability, + 'tenants:delete'::api_capability + ) + AND capability_set_id IN (SELECT id FROM tenant.capability_sets WHERE slug = 'owner'); diff --git a/backend/migrations/202511110200_owner_tenant_capabilities/up.sql b/backend/migrations/202511110200_owner_tenant_capabilities/up.sql new file mode 100644 index 0000000..24cabab --- /dev/null +++ b/backend/migrations/202511110200_owner_tenant_capabilities/up.sql @@ -0,0 +1,22 @@ +-- diesel:run_in_transaction = false + +WITH owner_sets AS ( + SELECT id FROM tenant.capability_sets WHERE slug = 'owner' +) +INSERT INTO tenant.capability_set_capabilities (capability_set_id, capability) +SELECT id, 'tenants:write'::api_capability FROM owner_sets +ON CONFLICT DO NOTHING; + +WITH owner_sets AS ( + SELECT id FROM tenant.capability_sets WHERE slug = 'owner' +) +INSERT INTO tenant.capability_set_capabilities (capability_set_id, capability) +SELECT id, 'tenants:reset'::api_capability FROM owner_sets +ON CONFLICT DO NOTHING; + +WITH owner_sets AS ( + SELECT id FROM tenant.capability_sets WHERE slug = 'owner' +) +INSERT INTO tenant.capability_set_capabilities (capability_set_id, capability) +SELECT id, 'tenants:delete'::api_capability FROM owner_sets +ON CONFLICT DO NOTHING; diff --git a/backend/migrations/202511120000_collapse_asset_objects/down.sql b/backend/migrations/202511120000_collapse_asset_objects/down.sql new file mode 100644 index 0000000..6c3a430 --- /dev/null +++ b/backend/migrations/202511120000_collapse_asset_objects/down.sql @@ -0,0 +1,24 @@ +CREATE TABLE tenant.document_asset_objects ( + id UUID PRIMARY KEY, + asset_id UUID NOT NULL REFERENCES tenant.document_assets(id) ON DELETE CASCADE, + ordinal INT NOT NULL, + s3_key TEXT NOT NULL, + metadata JSONB NOT NULL DEFAULT '{}'::jsonb, + tenant_id UUID NOT NULL REFERENCES shared.tenants(id), + CONSTRAINT document_asset_objects_ordinal_positive CHECK (ordinal >= 1), + CONSTRAINT document_asset_objects_asset_ordinal_unique UNIQUE (asset_id, ordinal) +); + +CREATE INDEX idx_document_asset_objects_asset_ordinal + ON tenant.document_asset_objects(asset_id, ordinal); + +CREATE INDEX document_asset_objects_tenant_id_idx ON tenant.document_asset_objects(tenant_id); + +ALTER TABLE tenant.document_assets ADD COLUMN cardinality INT; +UPDATE tenant.document_assets SET cardinality = 1; + +INSERT INTO tenant.document_asset_objects (id, asset_id, ordinal, s3_key, metadata, tenant_id) +SELECT gen_random_uuid(), id, 1, s3_key, metadata, tenant_id +FROM tenant.document_assets; + +ALTER TABLE tenant.document_assets DROP COLUMN s3_key; diff --git a/backend/migrations/202511120000_collapse_asset_objects/up.sql b/backend/migrations/202511120000_collapse_asset_objects/up.sql new file mode 100644 index 0000000..cf26ae4 --- /dev/null +++ b/backend/migrations/202511120000_collapse_asset_objects/up.sql @@ -0,0 +1,27 @@ +-- Prevent concurrent inserts/updates during backfill. +LOCK TABLE tenant.document_asset_objects IN ACCESS EXCLUSIVE MODE; +LOCK TABLE tenant.document_assets IN ACCESS EXCLUSIVE MODE; + +ALTER TABLE tenant.document_assets ADD COLUMN s3_key TEXT; + +UPDATE tenant.document_assets AS da +SET s3_key = o.s3_key, + metadata = COALESCE(da.metadata, '{}'::jsonb) || COALESCE(o.metadata, '{}'::jsonb) +FROM tenant.document_asset_objects AS o +WHERE o.asset_id = da.id + AND o.ordinal = 1; + +DELETE FROM tenant.document_asset_objects WHERE ordinal <> 1; + +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM tenant.document_assets WHERE s3_key IS NULL) THEN + RAISE EXCEPTION 'cannot drop document_asset_objects: some assets are missing a populated ordinal 1 object (s3_key null)'; + END IF; +END +$$; + +ALTER TABLE tenant.document_assets ALTER COLUMN s3_key SET NOT NULL; +ALTER TABLE tenant.document_assets DROP COLUMN cardinality; + +DROP TABLE tenant.document_asset_objects; diff --git a/backend/migrations/202511130000_rename_document_content_type/down.sql b/backend/migrations/202511130000_rename_document_content_type/down.sql new file mode 100644 index 0000000..09336bc --- /dev/null +++ b/backend/migrations/202511130000_rename_document_content_type/down.sql @@ -0,0 +1,3 @@ +-- Revert column rename. +ALTER TABLE tenant.documents + RENAME COLUMN mime_type TO content_type; diff --git a/backend/migrations/202511130000_rename_document_content_type/up.sql b/backend/migrations/202511130000_rename_document_content_type/up.sql new file mode 100644 index 0000000..7d84272 --- /dev/null +++ b/backend/migrations/202511130000_rename_document_content_type/up.sql @@ -0,0 +1,3 @@ +-- Rename document content_type column to mime_type for consistency with API. +ALTER TABLE tenant.documents + RENAME COLUMN content_type TO mime_type; diff --git a/backend/migrations/202511300000_rename_ocr_text_asset/down.sql b/backend/migrations/202511300000_rename_ocr_text_asset/down.sql new file mode 100644 index 0000000..9c89d15 --- /dev/null +++ b/backend/migrations/202511300000_rename_ocr_text_asset/down.sql @@ -0,0 +1,3 @@ +UPDATE tenant.document_assets +SET asset_type = 'ocr-text' +WHERE asset_type = 'text-content'; diff --git a/backend/migrations/202511300000_rename_ocr_text_asset/up.sql b/backend/migrations/202511300000_rename_ocr_text_asset/up.sql new file mode 100644 index 0000000..4a7bf6e --- /dev/null +++ b/backend/migrations/202511300000_rename_ocr_text_asset/up.sql @@ -0,0 +1,3 @@ +UPDATE tenant.document_assets +SET asset_type = 'text-content' +WHERE asset_type = 'ocr-text'; diff --git a/backend/migrations/202511300100_add_title_trgm_index/down.sql b/backend/migrations/202511300100_add_title_trgm_index/down.sql new file mode 100644 index 0000000..ab856cb --- /dev/null +++ b/backend/migrations/202511300100_add_title_trgm_index/down.sql @@ -0,0 +1 @@ +DROP INDEX IF EXISTS tenant.idx_documents_title_trgm; diff --git a/backend/migrations/202511300100_add_title_trgm_index/up.sql b/backend/migrations/202511300100_add_title_trgm_index/up.sql new file mode 100644 index 0000000..ff1f0a6 --- /dev/null +++ b/backend/migrations/202511300100_add_title_trgm_index/up.sql @@ -0,0 +1,6 @@ +CREATE EXTENSION IF NOT EXISTS pg_trgm; + +CREATE INDEX idx_documents_title_trgm + ON tenant.documents + USING gin (title gin_trgm_ops) + WHERE deleted_at IS NULL; diff --git a/backend/postgres-init/01_create_app_roles.sql b/backend/postgres-init/01_create_app_roles.sql new file mode 100644 index 0000000..c6a8202 --- /dev/null +++ b/backend/postgres-init/01_create_app_roles.sql @@ -0,0 +1,21 @@ +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'papercrate_app') THEN + CREATE ROLE papercrate_app NOLOGIN; + END IF; + + IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'papercrate_app_login') THEN + CREATE ROLE papercrate_app_login LOGIN PASSWORD 'papercrate_app'; + GRANT papercrate_app TO papercrate_app_login; + END IF; +END +$$; + +-- Ensure the login role inherits and uses a sensible search path by default +ALTER ROLE papercrate_app_login INHERIT; +ALTER ROLE papercrate_app_login SET search_path = 'tenant, shared, public'; + +GRANT CONNECT ON DATABASE papercrate TO papercrate_app; +GRANT CONNECT ON DATABASE papercrate TO papercrate_app_login; +GRANT USAGE ON SCHEMA public TO papercrate_app; +GRANT USAGE ON SCHEMA public TO papercrate_app_login; diff --git a/backend/resources/issued_at_months.yaml b/backend/resources/issued_at_months.yaml new file mode 100644 index 0000000..28c727d --- /dev/null +++ b/backend/resources/issued_at_months.yaml @@ -0,0 +1,648 @@ +months: + - name: "january" + month: 1 + locales: + - "en" + - name: "jan" + month: 1 + locales: + - "en" + - name: "janvier" + month: 1 + locales: + - "fr" + - name: "janv" + month: 1 + locales: + - "fr" + - name: "januar" + month: 1 + locales: + - "de" + - name: "janu\u00e1r" + month: 1 + locales: + - "hu" + - name: "leden" + month: 1 + locales: + - "cs" + - name: "sije\u010danj" + month: 1 + locales: + - "hr" + - name: "sijecanj" + month: 1 + locales: + - "hr" + - name: "stycze\u0144" + month: 1 + locales: + - "pl" + - name: "styczen" + month: 1 + locales: + - "pl" + - name: "ocak" + month: 1 + locales: + - "tr" + - name: "february" + month: 2 + locales: + - "en" + - name: "feb" + month: 2 + locales: + - "en" + - name: "f\u00e9vrier" + month: 2 + locales: + - "fr" + - name: "fevrier" + month: 2 + locales: + - "fr" + - name: "f\u00e9vr" + month: 2 + locales: + - "fr" + - name: "fevr" + month: 2 + locales: + - "fr" + - name: "februar" + month: 2 + locales: + - "de" + - name: "\u00fanor" + month: 2 + locales: + - "cs" + - name: "unor" + month: 2 + locales: + - "cs" + - name: "\u00fanora" + month: 2 + locales: + - "cs" + - name: "unora" + month: 2 + locales: + - "cs" + - name: "velja\u010da" + month: 2 + locales: + - "hr" + - name: "veljaca" + month: 2 + locales: + - "hr" + - name: "velja\u010de" + month: 2 + locales: + - "hr" + - name: "veljace" + month: 2 + locales: + - "hr" + - name: "luty" + month: 2 + locales: + - "pl" + - name: "\u015fubat" + month: 2 + locales: + - "tr" + - name: "subat" + month: 2 + locales: + - "tr" + - name: "march" + month: 3 + locales: + - "en" + - name: "mar" + month: 3 + locales: + - "en" + - name: "m\u00e4rz" + month: 3 + locales: + - "de" + - name: "maerz" + month: 3 + locales: + - "de" + - name: "mars" + month: 3 + locales: + - "fr" + - name: "m\u00e4r" + month: 3 + locales: + - "de" + - name: "marz" + month: 3 + locales: + - "de" + - name: "b\u0159ezen" + month: 3 + locales: + - "cs" + - name: "brezen" + month: 3 + locales: + - "cs" + - name: "b\u0159ezna" + month: 3 + locales: + - "cs" + - name: "brezna" + month: 3 + locales: + - "cs" + - name: "o\u017eujak" + month: 3 + locales: + - "hr" + - name: "ozujak" + month: 3 + locales: + - "hr" + - name: "o\u017eujka" + month: 3 + locales: + - "hr" + - name: "ozujka" + month: 3 + locales: + - "hr" + - name: "marzec" + month: 3 + locales: + - "pl" + - name: "mart" + month: 3 + locales: + - "sr" + - "bs" + - name: "m\u00e1rcius" + month: 3 + locales: + - "hu" + - name: "martie" + month: 3 + locales: + - "ro" + - name: "april" + month: 4 + locales: + - "en" + - name: "apr" + month: 4 + locales: + - "en" + - name: "avril" + month: 4 + locales: + - "fr" + - name: "abril" + month: 4 + locales: + - "es" + - "pt" + - name: "duben" + month: 4 + locales: + - "cs" + - name: "travanj" + month: 4 + locales: + - "hr" + - name: "nisan" + month: 4 + locales: + - "tr" + - name: "kwiecie\u0144" + month: 4 + locales: + - "pl" + - name: "kwiecien" + month: 4 + locales: + - "pl" + - name: "aprile" + month: 4 + locales: + - "it" + - name: "may" + month: 5 + locales: + - "en" + - name: "mai" + month: 5 + locales: + - "fr" + - "de" + - name: "mayo" + month: 5 + locales: + - "es" + - name: "kv\u011bten" + month: 5 + locales: + - "cs" + - name: "kveten" + month: 5 + locales: + - "cs" + - name: "kv\u011btna" + month: 5 + locales: + - "cs" + - name: "kvetna" + month: 5 + locales: + - "cs" + - name: "svibanj" + month: 5 + locales: + - "hr" + - name: "maj" + month: 5 + locales: + - "pl" + - "bs" + - "sr" + - "hr" + - name: "may\u0131s" + month: 5 + locales: + - "tr" + - name: "mayis" + month: 5 + locales: + - "tr" + - name: "maggio" + month: 5 + locales: + - "it" + - name: "m\u00e1j" + month: 5 + locales: + - "hu" + - "sk" + - name: "june" + month: 6 + locales: + - "en" + - name: "jun" + month: 6 + locales: + - "en" + - "de" + - name: "juin" + month: 6 + locales: + - "fr" + - name: "junio" + month: 6 + locales: + - "es" + - name: "juni" + month: 6 + locales: + - "de" + - name: "\u010derven" + month: 6 + locales: + - "cs" + - name: "cerven" + month: 6 + locales: + - "cs" + - name: "lipanj" + month: 6 + locales: + - "hr" + - name: "haziran" + month: 6 + locales: + - "tr" + - name: "czerwiec" + month: 6 + locales: + - "pl" + - name: "j\u00fanius" + month: 6 + locales: + - "hu" + - name: "giugno" + month: 6 + locales: + - "it" + - name: "july" + month: 7 + locales: + - "en" + - name: "jul" + month: 7 + locales: + - "en" + - "de" + - name: "juillet" + month: 7 + locales: + - "fr" + - name: "julio" + month: 7 + locales: + - "es" + - name: "temmuz" + month: 7 + locales: + - "tr" + - name: "\u010dervenec" + month: 7 + locales: + - "cs" + - name: "cervenec" + month: 7 + locales: + - "cs" + - name: "srpanj" + month: 7 + locales: + - "hr" + - name: "lipiec" + month: 7 + locales: + - "pl" + - name: "luglio" + month: 7 + locales: + - "it" + - name: "j\u00falius" + month: 7 + locales: + - "hu" + - name: "august" + month: 8 + locales: + - "en" + - name: "aug" + month: 8 + locales: + - "en" + - "de" + - name: "ao\u00fbt" + month: 8 + locales: + - "fr" + - name: "aout" + month: 8 + locales: + - "fr" + - name: "agosto" + month: 8 + locales: + - "es" + - "pt" + - "it" + - name: "a\u011fustos" + month: 8 + locales: + - "tr" + - name: "agustos" + month: 8 + locales: + - "tr" + - name: "kolovoz" + month: 8 + locales: + - "hr" + - name: "srpen" + month: 8 + locales: + - "cs" + - name: "sierpie\u0144" + month: 8 + locales: + - "pl" + - name: "sierpien" + month: 8 + locales: + - "pl" + - name: "augustus" + month: 8 + locales: + - "nl" + - name: "agost" + month: 8 + locales: + - "it" + - "ca" + - name: "september" + month: 9 + locales: + - "en" + - name: "sept" + month: 9 + locales: + - "en" + - "fr" + - name: "sep" + month: 9 + locales: + - "en" + - name: "septembre" + month: 9 + locales: + - "fr" + - name: "septiembre" + month: 9 + locales: + - "es" + - name: "eyl\u00fcl" + month: 9 + locales: + - "tr" + - name: "eylul" + month: 9 + locales: + - "tr" + - name: "z\u00e1\u0159\u00ed" + month: 9 + locales: + - "cs" + - name: "zari" + month: 9 + locales: + - "cs" + - name: "rujan" + month: 9 + locales: + - "hr" + - name: "wrzesie\u0144" + month: 9 + locales: + - "pl" + - name: "wrzesien" + month: 9 + locales: + - "pl" + - name: "septembrie" + month: 9 + locales: + - "ro" + - name: "settembre" + month: 9 + locales: + - "it" + - name: "october" + month: 10 + locales: + - "en" + - name: "oct" + month: 10 + locales: + - "en" + - name: "oktober" + month: 10 + locales: + - "de" + - name: "okt\u00f3ber" + month: 10 + locales: + - "hu" + - name: "octobre" + month: 10 + locales: + - "fr" + - name: "octubre" + month: 10 + locales: + - "es" + - name: "\u0159\u00edjen" + month: 10 + locales: + - "cs" + - name: "rijen" + month: 10 + locales: + - "hr" + - name: "pa\u017adziernik" + month: 10 + locales: + - "pl" + - name: "pazdziernik" + month: 10 + locales: + - "pl" + - name: "ekim" + month: 10 + locales: + - "tr" + - name: "octombrie" + month: 10 + locales: + - "ro" + - name: "november" + month: 11 + locales: + - "en" + - name: "nov" + month: 11 + locales: + - "en" + - name: "novembre" + month: 11 + locales: + - "fr" + - name: "noviembre" + month: 11 + locales: + - "es" + - name: "studeni" + month: 11 + locales: + - "hr" + - name: "kas\u0131m" + month: 11 + locales: + - "tr" + - name: "kasim" + month: 11 + locales: + - "tr" + - name: "listopad" + month: 11 + locales: + - "pl" + - "cs" + - name: "novembro" + month: 11 + locales: + - "pt" + - name: "listopadu" + month: 11 + locales: + - "cs" + - name: "noiembrie" + month: 11 + locales: + - "ro" + - name: "december" + month: 12 + locales: + - "en" + - name: "dec" + month: 12 + locales: + - "en" + - name: "dezember" + month: 12 + locales: + - "de" + - name: "d\u00e9cembre" + month: 12 + locales: + - "fr" + - name: "decembre" + month: 12 + locales: + - "fr" + - name: "prosinec" + month: 12 + locales: + - "cs" + - name: "prosinac" + month: 12 + locales: + - "hr" + - name: "grudzie\u0144" + month: 12 + locales: + - "pl" + - name: "grudzien" + month: 12 + locales: + - "pl" + - name: "grudnia" + month: 12 + locales: + - "pl" + - name: "aral\u0131k" + month: 12 + locales: + - "tr" + - name: "aralik" + month: 12 + locales: + - "tr" + - name: "decembrie" + month: 12 + locales: + - "ro" diff --git a/backend/src/auth/api_tokens.rs b/backend/src/auth/api_tokens.rs new file mode 100644 index 0000000..5a75704 --- /dev/null +++ b/backend/src/auth/api_tokens.rs @@ -0,0 +1,324 @@ +use argon2::{ + password_hash::{rand_core::OsRng as PasswordHashOsRng, PasswordHasher, SaltString}, + Argon2, +}; +use chrono::{NaiveDateTime, Utc}; +use diesel::prelude::*; +use rand::{rngs::OsRng, TryRngCore}; +use uuid::Uuid; + +use crate::{ + auth::capability_sets::{load_capabilities_for_set, load_capability_set}, + error::AppError, + models::{ApiCapability, ApiToken, CapabilitySet, NewApiToken}, + schema::api_tokens, + state::PgPooledConnection, + tenants::{apply_api_token_prefix, clear_api_token_prefix}, +}; + +use crate::schema::api_tokens::dsl as api_tokens_dsl; + +const TOKEN_PREFIX_LENGTH: usize = 12; +const TOKEN_SECRET_LENGTH: usize = 32; + +/// Represents a newly issued API token and the raw secret that was generated for it. +pub struct IssuedApiToken { + pub token: String, + pub record: ApiToken, +} + +/// Creates a new API token for the supplied user/tenant combination. +pub fn create_api_token( + conn: &mut PgPooledConnection, + user_id: Uuid, + tenant_id: Uuid, + label: Option, + expires_at: Option, + capability_set_id: Uuid, +) -> Result { + let capability_set = + validate_capability_set_belongs_to_tenant(conn, capability_set_id, tenant_id)?; + + let raw_secret = generate_secret()?; + let token_prefix = raw_secret[..TOKEN_PREFIX_LENGTH].to_string(); + let token_hash = hash_secret(&raw_secret)?; + let new_token = NewApiToken { + id: Uuid::new_v4(), + user_id, + tenant_id, + token_prefix, + token_hash, + label, + expires_at, + capability_set_id: capability_set.id, + }; + + let record = diesel::insert_into(api_tokens::table) + .values(&new_token) + .get_result::(conn)?; + + Ok(IssuedApiToken { + token: raw_secret, + record, + }) +} + +/// Lists API tokens belonging to a user within an optional tenant scope. +pub fn list_api_tokens( + conn: &mut PgPooledConnection, + user_id: Uuid, + tenant_id: Option, +) -> Result, AppError> { + let mut query = api_tokens::table + .filter(api_tokens::user_id.eq(user_id)) + .into_boxed(); + + if let Some(tenant_id) = tenant_id { + query = query.filter(api_tokens::tenant_id.eq(tenant_id)); + } + + let tokens = query + .order(api_tokens::created_at.asc()) + .load::(conn)?; + + Ok(tokens) +} + +/// Regenerates the secret value for an API token. +pub fn regenerate_api_token( + conn: &mut PgPooledConnection, + token_id: Uuid, + user_id: Uuid, + tenant_id: Option, +) -> Result { + let record = find_user_token(conn, token_id, user_id, tenant_id)?; + + if record.revoked_at.is_some() { + return Err(AppError::bad_request( + "cannot regenerate a revoked API token", + )); + } + + let raw_secret = generate_secret()?; + let token_prefix = raw_secret[..TOKEN_PREFIX_LENGTH].to_string(); + let token_hash = hash_secret(&raw_secret)?; + + let updated = diesel::update(api_tokens::table.find(record.id)) + .set(( + api_tokens::token_prefix.eq(&token_prefix), + api_tokens::token_hash.eq(&token_hash), + api_tokens::last_used_at.eq::>(None), + )) + .get_result::(conn)?; + + Ok(IssuedApiToken { + token: raw_secret, + record: updated, + }) +} + +/// Attempts to resolve an API token by its secret value while ensuring it provides the +/// requested capability. +pub fn find_active_token_by_secret( + conn: &mut PgPooledConnection, + tenant_id: Option, + secret: &str, + required_capability: Option, +) -> Result, AppError> { + if secret.len() < TOKEN_PREFIX_LENGTH { + return Ok(None); + } + + let prefix = &secret[..TOKEN_PREFIX_LENGTH]; + let candidates = with_api_token_prefix(conn, prefix, |conn| { + let mut query = api_tokens::table + .filter(api_tokens::token_prefix.eq(prefix)) + .filter(api_tokens::revoked_at.is_null()) + .into_boxed(); + + let now = Utc::now().naive_utc(); + query = query.filter( + api_tokens::expires_at + .is_null() + .or(api_tokens::expires_at.gt(now)), + ); + + if let Some(tenant_id) = tenant_id { + query = query.filter(api_tokens::tenant_id.eq(tenant_id)); + } + + query.load::(conn).map_err(AppError::from) + })?; + + for token in candidates { + if let Some(required) = required_capability { + let capabilities = load_capabilities_for_set(conn, token.capability_set_id)?; + if !capabilities.contains(&required) { + continue; + } + } + + if verify_token_secret(secret, &token.token_hash)? { + return Ok(Some(token)); + } + } + + Ok(None) +} + +/// Revokes an API token belonging to the specified user. +pub fn revoke_api_token( + conn: &mut PgPooledConnection, + token_id: Uuid, + user_id: Uuid, +) -> Result<(), AppError> { + let token = find_user_token(conn, token_id, user_id, None)?; + + diesel::update(api_tokens::table.find(token.id)) + .set(api_tokens::revoked_at.eq(Utc::now().naive_utc())) + .execute(conn)?; + + Ok(()) +} + +/// Updates the last-used timestamp for a token. +pub fn touch_api_token(conn: &mut PgPooledConnection, token_id: Uuid) -> Result<(), AppError> { + diesel::update(api_tokens::table.filter(api_tokens::id.eq(token_id))) + .set(api_tokens::last_used_at.eq(Utc::now().naive_utc())) + .execute(conn)?; + Ok(()) +} + +/// Verifies a secret against its stored hash representation. +pub fn verify_token_secret(secret: &str, token_hash: &str) -> Result { + crate::auth::password::verify_password(secret, token_hash).map_err(|err| { + tracing::error!(error = ?err, "failed to verify token"); + AppError::internal("failed to verify token") + }) +} + +fn find_user_token( + conn: &mut PgPooledConnection, + token_id: Uuid, + user_id: Uuid, + tenant_id: Option, +) -> Result { + let mut query = api_tokens_dsl::api_tokens + .filter(api_tokens_dsl::id.eq(token_id)) + .filter(api_tokens_dsl::user_id.eq(user_id)) + .into_boxed(); + + if let Some(tid) = tenant_id { + query = query.filter(api_tokens_dsl::tenant_id.eq(tid)); + } + + query + .first::(conn) + .optional() + .map_err(AppError::from)? + .ok_or_else(AppError::not_found) +} + +fn validate_capability_set_belongs_to_tenant( + conn: &mut PgPooledConnection, + capability_set_id: Uuid, + tenant_id: Uuid, +) -> Result { + let capability_set = load_capability_set(conn, capability_set_id)?; + if capability_set.tenant_id != tenant_id { + return Err(AppError::bad_request( + "capability set does not belong to the tenant", + )); + } + + Ok(capability_set) +} + +fn with_api_token_prefix( + conn: &mut PgPooledConnection, + prefix: &str, + operation: F, +) -> Result +where + F: FnOnce(&mut PgPooledConnection) -> Result, +{ + apply_api_token_prefix(conn, prefix)?; + let operation_result = operation(conn); + let clear_result = clear_api_token_prefix(conn); + + if let Err(err) = clear_result { + return Err(err); + } + + operation_result +} + +fn generate_secret() -> Result { + let mut buffer = [0u8; TOKEN_SECRET_LENGTH]; + OsRng.try_fill_bytes(&mut buffer).map_err(|err| { + tracing::error!(error = ?err, "failed to generate token"); + AppError::internal("failed to generate token") + })?; + Ok(hex::encode(buffer)) +} + +fn hash_secret(secret: &str) -> Result { + let mut salt_rng = PasswordHashOsRng; + let salt = SaltString::generate(&mut salt_rng); + let hash = Argon2::default() + .hash_password(secret.as_bytes(), &salt) + .map_err(|err| { + tracing::error!(error = ?err, "failed to hash token"); + AppError::internal("failed to hash token") + })?; + Ok(hash.to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::auth::capability_sets::{ + compute_slug, normalize_capabilities, owner_capabilities, webdav_capabilities, + }; + + #[test] + fn generated_secret_has_expected_length() { + let secret = generate_secret().unwrap(); + assert_eq!(secret.len(), TOKEN_SECRET_LENGTH * 2); + } + + #[test] + fn hash_and_verify_secret_round_trip() { + let secret = generate_secret().unwrap(); + let hash = hash_secret(&secret).unwrap(); + assert!(verify_token_secret(&secret, &hash).unwrap()); + assert!(!verify_token_secret("wrong", &hash).unwrap()); + } + + #[test] + fn normalize_capabilities_deduplicates() { + let mut caps = owner_capabilities().to_vec(); + caps.push(ApiCapability::DocumentsRead); + let normalized = normalize_capabilities(caps).unwrap(); + assert_eq!(normalized.len(), owner_capabilities().len()); + } + + #[test] + fn normalize_capabilities_rejects_empty() { + assert!(normalize_capabilities(Vec::new()).is_err()); + } + + #[test] + fn compute_slug_matches_system_sets() { + let owner_slug = compute_slug(owner_capabilities()); + assert_eq!(owner_slug, "owner"); + + let webdav_slug = compute_slug(webdav_capabilities()); + assert_eq!(webdav_slug, "webdav"); + } + + #[test] + fn prefix_length_is_less_than_secret_length() { + assert!(TOKEN_PREFIX_LENGTH < TOKEN_SECRET_LENGTH * 2); + } +} diff --git a/backend/src/auth/capability_guard.rs b/backend/src/auth/capability_guard.rs new file mode 100644 index 0000000..9abea57 --- /dev/null +++ b/backend/src/auth/capability_guard.rs @@ -0,0 +1,120 @@ +use std::sync::Arc; + +use axum::{ + http::{Request, StatusCode}, + response::IntoResponse, +}; +use tower::{Layer, Service}; + +use crate::{auth::AuthenticatedUser, error::AppError, models::ApiCapability}; + +#[derive(Clone, Copy)] +pub enum CapabilityStrategy { + All, + Any, +} + +#[derive(Clone)] +pub struct RequireCapabilitiesLayer { + required: Arc>, + strategy: CapabilityStrategy, +} + +impl RequireCapabilitiesLayer { + pub fn all(caps: I) -> Self + where + I: IntoIterator, + { + Self { + required: Arc::new(caps.into_iter().collect()), + strategy: CapabilityStrategy::All, + } + } + + pub fn any(caps: I) -> Self + where + I: IntoIterator, + { + Self { + required: Arc::new(caps.into_iter().collect()), + strategy: CapabilityStrategy::Any, + } + } +} + +impl Layer for RequireCapabilitiesLayer { + type Service = RequireCapabilities; + + fn layer(&self, inner: S) -> Self::Service { + RequireCapabilities { + inner, + required: Arc::clone(&self.required), + strategy: self.strategy, + } + } +} + +#[derive(Clone)] +pub struct RequireCapabilities { + inner: S, + required: Arc>, + strategy: CapabilityStrategy, +} + +impl Service> for RequireCapabilities +where + S: Service, Response = axum::response::Response> + Send, + S::Future: Send + 'static, + B: Send + 'static, +{ + type Response = S::Response; + type Error = S::Error; + type Future = std::pin::Pin< + Box> + Send>, + >; + + fn poll_ready( + &mut self, + cx: &mut std::task::Context<'_>, + ) -> std::task::Poll> { + self.inner.poll_ready(cx) + } + + fn call(&mut self, req: Request) -> Self::Future { + if self.required.is_empty() { + let fut = self.inner.call(req); + return Box::pin(async move { fut.await }); + } + + let (parts, body) = req.into_parts(); + let user = match parts.extensions.get::() { + Some(user) => user, + None => { + let response = AppError::unauthorized().into_response(); + return Box::pin(async move { Ok(response) }); + } + }; + + let allowed = match self.strategy { + CapabilityStrategy::All => self + .required + .iter() + .all(|cap| user.capabilities.contains(cap)), + CapabilityStrategy::Any => self + .required + .iter() + .any(|cap| user.capabilities.contains(cap)), + }; + + if !allowed { + let response = AppError::new(StatusCode::FORBIDDEN, "missing required capability") + .with_code("missing_capability") + .into_response(); + return Box::pin(async move { Ok(response) }); + } + + let req = Request::from_parts(parts, body); + let fut = self.inner.call(req); + Box::pin(async move { fut.await }) + } +} diff --git a/backend/src/auth/capability_sets.rs b/backend/src/auth/capability_sets.rs new file mode 100644 index 0000000..e759a82 --- /dev/null +++ b/backend/src/auth/capability_sets.rs @@ -0,0 +1,323 @@ +use chrono::Utc; +use diesel::{pg::Pg, prelude::*, Connection}; +use sha2::{Digest, Sha256}; +use uuid::Uuid; + +use crate::{ + error::AppError, + models::{ApiCapability, CapabilitySet, NewCapabilitySet, NewCapabilitySetCapability}, + schema::{ + capability_set_capabilities, capability_set_capabilities::dsl as csc_dsl, capability_sets, + capability_sets::dsl as cs_dsl, + }, +}; + +const OWNER_CAPABILITIES: [ApiCapability; 22] = [ + ApiCapability::CorrespondentsEdit, + ApiCapability::CorrespondentsRead, + ApiCapability::CorrespondentsWrite, + ApiCapability::DocumentsEdit, + ApiCapability::DocumentsRead, + ApiCapability::DocumentsUpload, + ApiCapability::DocumentsWrite, + ApiCapability::FoldersEdit, + ApiCapability::FoldersRead, + ApiCapability::FoldersWrite, + ApiCapability::ProfileRead, + ApiCapability::ProfileWrite, + ApiCapability::TagsEdit, + ApiCapability::TagsRead, + ApiCapability::TagsWrite, + ApiCapability::WebdavRead, + ApiCapability::WebdavWrite, + ApiCapability::CapabilitySetsRead, + ApiCapability::CapabilitySetsWrite, + ApiCapability::TenantsWrite, + ApiCapability::TenantsReset, + ApiCapability::TenantsDelete, +]; + +const USER_CAPABILITIES: [ApiCapability; 16] = [ + ApiCapability::CorrespondentsEdit, + ApiCapability::CorrespondentsRead, + ApiCapability::CorrespondentsWrite, + ApiCapability::DocumentsEdit, + ApiCapability::DocumentsRead, + ApiCapability::DocumentsUpload, + ApiCapability::DocumentsWrite, + ApiCapability::FoldersEdit, + ApiCapability::FoldersRead, + ApiCapability::FoldersWrite, + ApiCapability::ProfileRead, + ApiCapability::ProfileWrite, + ApiCapability::TagsEdit, + ApiCapability::TagsRead, + ApiCapability::TagsWrite, + ApiCapability::WebdavRead, +]; + +const READONLY_CAPABILITIES: [ApiCapability; 5] = [ + ApiCapability::CorrespondentsRead, + ApiCapability::DocumentsRead, + ApiCapability::FoldersRead, + ApiCapability::TagsRead, + ApiCapability::WebdavRead, +]; + +const WEBDAV_CAPABILITIES: [ApiCapability; 1] = [ApiCapability::WebdavRead]; + +pub fn owner_capabilities() -> &'static [ApiCapability] { + &OWNER_CAPABILITIES +} + +pub fn user_capabilities() -> &'static [ApiCapability] { + &USER_CAPABILITIES +} + +pub fn readonly_capabilities() -> &'static [ApiCapability] { + &READONLY_CAPABILITIES +} + +pub fn webdav_capabilities() -> &'static [ApiCapability] { + &WEBDAV_CAPABILITIES +} + +pub fn is_system_slug(slug: &str) -> bool { + matches!(slug, "owner" | "user" | "readonly" | "webdav") +} + +pub fn create_capability_set( + conn: &mut C, + tenant_id: Uuid, + slug: &str, + capabilities: Vec, +) -> Result +where + C: Connection + diesel::connection::LoadConnection, +{ + let normalized = normalize_capabilities(capabilities)?; + + conn.transaction::(|conn| { + if cs_dsl::capability_sets + .filter(cs_dsl::tenant_id.eq(tenant_id)) + .filter(cs_dsl::slug.eq(slug)) + .first::(conn) + .optional() + .map_err(AppError::from)? + .is_some() + { + return Err(AppError::conflict("capability set slug already exists")); + } + + let set = NewCapabilitySet { + id: Uuid::new_v4(), + tenant_id, + slug: slug.to_owned(), + cap_version: 1, + is_system: false, + }; + + diesel::insert_into(capability_sets::table) + .values(&set) + .execute(conn) + .map_err(AppError::from)?; + + persist_capabilities(conn, set.id, &normalized)?; + + capability_sets::table + .find(set.id) + .first::(conn) + .map_err(AppError::from) + }) +} + +pub fn normalize_capabilities( + mut capabilities: Vec, +) -> Result, AppError> { + if capabilities.is_empty() { + return Err(AppError::bad_request("at least one capability is required")); + } + + capabilities.sort_by(|a, b| a.as_str().cmp(b.as_str())); + capabilities.dedup(); + Ok(capabilities) +} + +pub fn load_capabilities_for_set( + conn: &mut C, + capability_set_id: Uuid, +) -> Result, AppError> +where + C: Connection + diesel::connection::LoadConnection, +{ + let mut capabilities: Vec = csc_dsl::capability_set_capabilities + .filter(csc_dsl::capability_set_id.eq(capability_set_id)) + .select(csc_dsl::capability) + .load(conn) + .map_err(AppError::from)?; + + capabilities.sort_by(|a, b| a.as_str().cmp(b.as_str())); + Ok(capabilities) +} + +pub fn ensure_capability_set( + conn: &mut C, + tenant_id: Uuid, + capabilities: &[ApiCapability], +) -> Result +where + C: Connection + diesel::connection::LoadConnection, +{ + if capabilities.is_empty() { + return Err(AppError::bad_request("at least one capability is required")); + } + + let slug = compute_slug(capabilities); + conn.transaction::(|conn| { + if let Some(existing) = cs_dsl::capability_sets + .filter(cs_dsl::tenant_id.eq(tenant_id)) + .filter(cs_dsl::slug.eq(&slug)) + .first::(conn) + .optional() + .map_err(AppError::from)? + { + ensure_capability_membership(conn, &existing, capabilities)?; + return Ok(existing); + } + + let set = NewCapabilitySet { + id: Uuid::new_v4(), + tenant_id, + slug: slug.clone(), + cap_version: 1, + is_system: matches!(slug.as_str(), "owner" | "user" | "readonly" | "webdav"), + }; + + diesel::insert_into(capability_sets::table) + .values(&set) + .execute(conn) + .map_err(AppError::from)?; + + persist_capabilities(conn, set.id, capabilities)?; + + Ok(capability_sets::table + .find(set.id) + .first::(conn) + .map_err(AppError::from)?) + }) +} + +pub fn refresh_capability_set( + conn: &mut C, + set: &CapabilitySet, + capabilities: &[ApiCapability], +) -> Result +where + C: Connection + diesel::connection::LoadConnection, +{ + conn.transaction::(|conn| { + diesel::delete( + csc_dsl::capability_set_capabilities.filter(csc_dsl::capability_set_id.eq(set.id)), + ) + .execute(conn) + .map_err(AppError::from)?; + + persist_capabilities(conn, set.id, capabilities)?; + + diesel::update(capability_sets::table.find(set.id)) + .set(( + cs_dsl::cap_version.eq(set.cap_version + 1), + cs_dsl::updated_at.eq(Utc::now().naive_utc()), + )) + .execute(conn) + .map_err(AppError::from)?; + + capability_sets::table + .find(set.id) + .first::(conn) + .map_err(AppError::from) + }) +} + +pub fn load_capability_set(conn: &mut C, id: Uuid) -> Result +where + C: Connection + diesel::connection::LoadConnection, +{ + capability_sets::table + .find(id) + .first::(conn) + .map_err(AppError::from) +} + +pub fn compute_slug(capabilities: &[ApiCapability]) -> String { + if capabilities == owner_capabilities() { + return "owner".to_string(); + } + + if capabilities == user_capabilities() { + return "user".to_string(); + } + + if capabilities == readonly_capabilities() { + return "readonly".to_string(); + } + + if capabilities == webdav_capabilities() { + return "webdav".to_string(); + } + + let joined = capabilities + .iter() + .map(|cap| cap.as_str()) + .collect::>() + .join(","); + + let digest = Sha256::digest(joined.as_bytes()); + let hex = hex::encode(digest); + format!("caps-{}", &hex[..12]) +} + +fn ensure_capability_membership( + conn: &mut C, + set: &CapabilitySet, + desired: &[ApiCapability], +) -> Result<(), AppError> +where + C: Connection + diesel::connection::LoadConnection, +{ + let current = load_capabilities_for_set(conn, set.id)?; + if current == desired { + return Ok(()); + } + + let _ = refresh_capability_set(conn, set, desired)?; + Ok(()) +} + +fn persist_capabilities( + conn: &mut C, + set_id: Uuid, + capabilities: &[ApiCapability], +) -> Result<(), AppError> +where + C: Connection + diesel::connection::LoadConnection, +{ + if capabilities.is_empty() { + return Err(AppError::bad_request("at least one capability is required")); + } + + let records: Vec = capabilities + .iter() + .map(|cap| NewCapabilitySetCapability { + capability_set_id: set_id, + capability: *cap, + }) + .collect(); + + diesel::insert_into(capability_set_capabilities::table) + .values(&records) + .execute(conn) + .map_err(AppError::from)?; + + Ok(()) +} diff --git a/backend/src/auth/jwt.rs b/backend/src/auth/jwt.rs new file mode 100644 index 0000000..6c4f496 --- /dev/null +++ b/backend/src/auth/jwt.rs @@ -0,0 +1,248 @@ +use anyhow::Result; +use chrono::{Duration, Utc}; +use jsonwebtoken::{decode, encode, DecodingKey, EncodingKey, Header, Validation}; +use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; +use uuid::Uuid; + +use crate::config::AppConfig; + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, ToSchema)] +#[serde(rename_all = "snake_case")] +pub enum PrincipalKind { + UserSession, + ApiToken, +} + +#[derive(Debug, Clone)] +pub struct AccessTokenContext { + pub user_id: Uuid, + pub tenant_id: Uuid, + pub username: String, + pub principal_kind: PrincipalKind, + pub principal_id: Uuid, + pub capability_set_id: Uuid, + pub cap_version: i32, +} + +#[derive(Clone)] +pub struct JwtService { + encoding: EncodingKey, + decoding: DecodingKey, + issuer: String, + audience: String, + expiry: Duration, + download_audience: String, + download_expiry: Duration, + selector_audience: String, + selector_expiry: Duration, + signup_audience: String, + signup_expiry: Duration, +} + +impl JwtService { + pub fn from_config(config: &AppConfig) -> Result { + let access_expiry = Duration::minutes(config.jwt_expiry_minutes); + + Ok(Self { + encoding: EncodingKey::from_secret(config.jwt_secret.as_bytes()), + decoding: DecodingKey::from_secret(config.jwt_secret.as_bytes()), + issuer: config.jwt_issuer.clone(), + audience: config.jwt_audience.clone(), + expiry: access_expiry, + download_audience: config.download_token_audience.clone(), + download_expiry: Duration::minutes(config.download_token_expiry_minutes), + selector_audience: format!("{}:tenant-selector", config.jwt_audience), + selector_expiry: access_expiry, + signup_audience: format!("{}:signup", config.jwt_audience), + signup_expiry: Duration::minutes(15), + }) + } + + pub fn generate_token(&self, context: AccessTokenContext) -> Result { + let now = Utc::now(); + let exp = now + self.expiry; + let claims = Claims { + sub: context.user_id, + tenant_id: context.tenant_id, + username: context.username, + principal_kind: context.principal_kind, + principal_id: context.principal_id, + capability_set_id: context.capability_set_id, + cap_version: context.cap_version, + iss: self.issuer.clone(), + aud: self.audience.clone(), + iat: now.timestamp() as usize, + exp: exp.timestamp() as usize, + }; + + Ok(encode(&Header::default(), &claims, &self.encoding)?) + } + + pub fn verify_token(&self, token: &str) -> Result { + let mut validation = Validation::default(); + validation.set_audience(&[self.audience.clone()]); + validation.set_issuer(&[self.issuer.clone()]); + let data = decode::(token, &self.decoding, &validation)?; + Ok(data.claims) + } + + pub fn generate_download_token( + &self, + document_id: Uuid, + version_id: Uuid, + user_id: Uuid, + tenant_id: Uuid, + ) -> Result { + let now = Utc::now(); + let exp = now + self.download_expiry; + let claims = DownloadClaims { + subject: DownloadSubject::Document { + doc_id: document_id, + version_id, + }, + user_id, + tenant_id, + iss: self.issuer.clone(), + aud: self.download_audience.clone(), + iat: now.timestamp() as usize, + exp: exp.timestamp() as usize, + }; + + Ok(encode(&Header::default(), &claims, &self.encoding)?) + } + + pub fn generate_asset_download_token( + &self, + asset_id: Uuid, + user_id: Uuid, + tenant_id: Uuid, + ) -> Result { + let now = Utc::now(); + let exp = now + self.download_expiry; + let claims = DownloadClaims { + subject: DownloadSubject::Asset { asset_id }, + user_id, + tenant_id, + iss: self.issuer.clone(), + aud: self.download_audience.clone(), + iat: now.timestamp() as usize, + exp: exp.timestamp() as usize, + }; + + Ok(encode(&Header::default(), &claims, &self.encoding)?) + } + + pub fn verify_download_token(&self, token: &str) -> Result { + let mut validation = Validation::default(); + validation.set_audience(&[self.download_audience.clone()]); + validation.set_issuer(&[self.issuer.clone()]); + let data = decode::(token, &self.decoding, &validation)?; + Ok(data.claims) + } + + pub fn generate_tenant_selector_token(&self, user_id: Uuid) -> Result { + let now = Utc::now(); + let exp = now + self.selector_expiry; + let claims = TenantSelectionClaims { + sub: user_id, + iss: self.issuer.clone(), + aud: self.selector_audience.clone(), + iat: now.timestamp() as usize, + exp: exp.timestamp() as usize, + }; + + Ok(encode(&Header::default(), &claims, &self.encoding)?) + } + + pub fn verify_tenant_selector_token(&self, token: &str) -> Result { + let mut validation = Validation::default(); + validation.set_audience(&[self.selector_audience.clone()]); + validation.set_issuer(&[self.issuer.clone()]); + let data = decode::(token, &self.decoding, &validation)?; + Ok(data.claims) + } + + pub fn generate_signup_token( + &self, + user_id: Uuid, + challenge_id: Uuid, + username: String, + ) -> Result { + let now = Utc::now(); + let exp = now + self.signup_expiry; + let claims = SignupClaims { + sub: user_id, + challenge_id, + username, + iss: self.issuer.clone(), + aud: self.signup_audience.clone(), + iat: now.timestamp() as usize, + exp: exp.timestamp() as usize, + }; + + Ok(encode(&Header::default(), &claims, &self.encoding)?) + } + + pub fn verify_signup_token(&self, token: &str) -> Result { + let mut validation = Validation::default(); + validation.set_audience(&[self.signup_audience.clone()]); + validation.set_issuer(&[self.issuer.clone()]); + let data = decode::(token, &self.decoding, &validation)?; + Ok(data.claims) + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Claims { + pub sub: Uuid, + pub tenant_id: Uuid, + pub username: String, + pub principal_kind: PrincipalKind, + pub principal_id: Uuid, + pub capability_set_id: Uuid, + pub cap_version: i32, + pub iss: String, + pub aud: String, + pub iat: usize, + pub exp: usize, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "scope", rename_all = "snake_case")] +pub enum DownloadSubject { + Document { doc_id: Uuid, version_id: Uuid }, + Asset { asset_id: Uuid }, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DownloadClaims { + #[serde(flatten)] + pub subject: DownloadSubject, + pub user_id: Uuid, + pub tenant_id: Uuid, + pub iss: String, + pub aud: String, + pub iat: usize, + pub exp: usize, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TenantSelectionClaims { + pub sub: Uuid, + pub iss: String, + pub aud: String, + pub iat: usize, + pub exp: usize, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SignupClaims { + pub sub: Uuid, + pub challenge_id: Uuid, + pub username: String, + pub iss: String, + pub aud: String, + pub iat: usize, + pub exp: usize, +} diff --git a/backend/src/auth/mod.rs b/backend/src/auth/mod.rs new file mode 100644 index 0000000..9218f61 --- /dev/null +++ b/backend/src/auth/mod.rs @@ -0,0 +1,221 @@ +pub mod api_tokens; +pub mod capability_guard; +pub mod capability_sets; +pub mod jwt; +pub mod passkeys; +pub mod password; + +use std::sync::{Arc, Mutex}; + +use axum::{ + extract::FromRequestParts, + http::{request::Parts, StatusCode}, +}; +use axum_extra::headers::{authorization::Bearer, Authorization}; +use axum_extra::TypedHeader; +use diesel::{pg::PgConnection, prelude::*}; +use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; + +use crate::{ + auth::capability_sets::{load_capabilities_for_set, load_capability_set}, + error::{AppError, AppResult}, + models::{ApiCapability, TenantStatus}, + schema::tenants::dsl as tenant_dsl, + state::{AppState, PgPooledConnection}, +}; +use uuid::Uuid; + +use crate::auth::jwt::PrincipalKind; + +#[derive(Debug, Clone)] +pub struct TenantMembershipUser { + pub user_id: Uuid, +} + +impl FromRequestParts for TenantMembershipUser { + type Rejection = AppError; + + #[allow(refining_impl_trait)] + fn from_request_parts<'a>( + parts: &'a mut Parts, + state: &AppState, + ) -> impl std::future::Future> + Send + 'a { + let state = state.clone(); + async move { + let TypedHeader(Authorization(bearer)) = + TypedHeader::>::from_request_parts(parts, &state) + .await + .map_err(|_| AppError::unauthorized())?; + + if let Ok(claims) = state.jwt.verify_token(bearer.token()) { + return Ok(Self { + user_id: claims.sub, + }); + } + + let selector = state + .jwt + .verify_tenant_selector_token(bearer.token()) + .map_err(|_| AppError::unauthorized())?; + + Ok(Self { + user_id: selector.sub, + }) + } + } +} + +#[derive(Clone)] +pub struct TenantConnectionHolder { + inner: Arc>>, +} + +impl TenantConnectionHolder { + pub fn new(conn: PgPooledConnection) -> Self { + Self { + inner: Arc::new(Mutex::new(Some(conn))), + } + } + + pub fn into_conn(self) -> Option { + self.inner.lock().ok()?.take() + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +pub struct AuthenticatedUser { + pub user_id: uuid::Uuid, + pub username: String, + pub tenant_id: uuid::Uuid, + pub principal_kind: PrincipalKind, + pub principal_id: Uuid, + pub capability_set_id: Uuid, + pub cap_version: i32, + pub capabilities: Vec, +} + +impl FromRequestParts for AuthenticatedUser { + type Rejection = AppError; + + #[allow(refining_impl_trait)] + fn from_request_parts<'a>( + parts: &'a mut Parts, + state: &AppState, + ) -> impl std::future::Future> + Send + 'a { + let state = state.clone(); + async move { + if let Some(user) = parts.extensions.get::() { + return Ok(user.clone()); + } + + let TypedHeader(Authorization(bearer)) = + TypedHeader::>::from_request_parts(parts, &state) + .await + .map_err(|_| AppError::unauthorized())?; + + let claims = state + .jwt + .verify_token(bearer.token()) + .map_err(|_| AppError::unauthorized())?; + + let mut tenant_conn = state.db_for_tenant(claims.tenant_id)?; + let capability_set = load_capability_set(&mut tenant_conn, claims.capability_set_id) + .map_err(|_| AppError::unauthorized())?; + + if capability_set.cap_version != claims.cap_version { + return Err(AppError::unauthorized()); + } + + let capabilities = load_capabilities_for_set(&mut tenant_conn, capability_set.id) + .map_err(|_| AppError::unauthorized())?; + + let user = AuthenticatedUser { + user_id: claims.sub, + username: claims.username, + tenant_id: claims.tenant_id, + principal_kind: claims.principal_kind, + principal_id: claims.principal_id, + capability_set_id: claims.capability_set_id, + cap_version: claims.cap_version, + capabilities, + }; + + parts.extensions.insert(user.clone()); + parts + .extensions + .insert(TenantConnectionHolder::new(tenant_conn)); + + Ok(user) + } + } +} + +pub struct TenantScopedConn { + pub conn: PgPooledConnection, + pub tenant_id: Uuid, + pub user_id: Uuid, + pub user: AuthenticatedUser, +} + +impl TenantScopedConn { + pub fn conn(&mut self) -> &mut PgPooledConnection { + &mut self.conn + } +} + +impl FromRequestParts for TenantScopedConn { + type Rejection = AppError; + + #[allow(refining_impl_trait)] + fn from_request_parts<'a>( + parts: &'a mut Parts, + state: &AppState, + ) -> impl std::future::Future> + Send + 'a { + let state = state.clone(); + async move { + let user = AuthenticatedUser::from_request_parts(parts, &state).await?; + let tenant_id = user.tenant_id; + let mut conn = if let Some(holder) = parts.extensions.remove::() + { + holder + .into_conn() + .ok_or_else(|| AppError::internal("tenant connection unavailable"))? + } else { + state.db_for_tenant(tenant_id)? + }; + + ensure_active_tenant_with_conn(&mut conn, tenant_id)?; + + Ok(Self { + conn, + tenant_id, + user_id: user.user_id, + user, + }) + } + } +} + +pub(crate) fn ensure_active_tenant(state: &AppState, tenant_id: Uuid) -> AppResult<()> { + let mut conn = state.db_unscoped()?; + ensure_active_tenant_with_conn(&mut conn, tenant_id) +} + +pub(crate) fn ensure_active_tenant_with_conn( + conn: &mut PgConnection, + tenant_id: Uuid, +) -> AppResult<()> { + use tenant_dsl::tenants; + + let status: TenantStatus = tenants + .find(tenant_id) + .select(tenant_dsl::status) + .first(conn)?; + + if status != TenantStatus::Active { + return Err(AppError::new(StatusCode::FORBIDDEN, "tenant is not active")); + } + + Ok(()) +} diff --git a/backend/src/auth/passkeys.rs b/backend/src/auth/passkeys.rs new file mode 100644 index 0000000..8de961f --- /dev/null +++ b/backend/src/auth/passkeys.rs @@ -0,0 +1,592 @@ +use std::sync::Arc; + +use anyhow::{Context, Result}; +use chrono::{Duration as ChronoDuration, NaiveDateTime, Utc}; +use diesel::{dsl::count_star, prelude::*}; +use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; +use uuid::Uuid; +use webauthn_rs::prelude::{Credential, *}; + +use crate::{ + config::AppConfig, + error::{AppError, AppResult}, + models::{NewUserPasskey, NewWebauthnChallenge, User, UserPasskey, WebauthnChallenge}, + schema::{user_passkeys::dsl as passkey_dsl, webauthn_challenges::dsl as challenge_dsl}, +}; + +const PURPOSE_REGISTRATION: &str = "registration"; +const PURPOSE_AUTHENTICATION: &str = "authentication"; +const DEFAULT_CHALLENGE_TTL_MINUTES: i64 = 10; + +#[derive(Clone)] +pub struct PasskeyService { + webauthn: Arc, + challenge_ttl: ChronoDuration, +} + +pub struct PreparedPasskey { + pub id: Uuid, + pub credential_id: Vec, + pub public_key: Vec, + pub credential: serde_json::Value, + pub sign_count: i64, + pub transports: Vec>, + pub aaguid: Option, +} + +impl PreparedPasskey { + pub fn into_new_user_passkey(self, user_id: Uuid, nickname: Option) -> NewUserPasskey { + NewUserPasskey { + id: self.id, + user_id, + credential_id: self.credential_id, + public_key: self.public_key, + credential: self.credential, + sign_count: self.sign_count, + transports: self.transports, + aaguid: self.aaguid, + nickname, + } + } +} + +#[derive(Debug, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct RegistrationChallengeResponse { + pub challenge_id: Uuid, + #[serde(flatten)] + #[schema(value_type = Object)] + pub challenge: CreationChallengeResponse, +} + +#[derive(Debug, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct AuthenticationChallengeResponse { + pub challenge_id: Uuid, + #[serde(flatten)] + #[schema(value_type = Object)] + pub challenge: RequestChallengeResponse, +} + +#[derive(Debug, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct PasskeySummary { + pub id: Uuid, + pub nickname: Option, + pub created_at: NaiveDateTime, + pub last_used_at: Option, + pub transports: Vec, + pub revoked_at: Option, + pub revoked_reason: Option, +} + +impl PasskeyService { + pub fn try_new(config: &AppConfig) -> Result> { + let rp_id = match config.webauthn_rp_id.as_deref().map(str::trim) { + Some(rp_id) if !rp_id.is_empty() => rp_id, + _ => return Ok(None), + }; + let rp_origin = match config.webauthn_origin.as_ref().map(|s| s.trim()) { + Some(origin) if !origin.is_empty() => origin, + _ => return Ok(None), + }; + + let origin = Url::parse(rp_origin).context("invalid webauthn_origin")?; + + let builder = WebauthnBuilder::new(rp_id, &origin) + .context("failed to initialise WebAuthn builder")? + .rp_name(&config.webauthn_rp_name) + .allow_subdomains(false) + .allow_any_port(false); + + let webauthn = builder + .build() + .context("failed to build WebAuthn instance")?; + + Ok(Some(Self { + webauthn: Arc::new(webauthn), + challenge_ttl: ChronoDuration::minutes(DEFAULT_CHALLENGE_TTL_MINUTES), + })) + } + + fn prune_expired(&self, conn: &mut PgConnection) { + let now = Utc::now().naive_utc(); + let _ = diesel::delete( + challenge_dsl::webauthn_challenges.filter(challenge_dsl::expires_at.le(now)), + ) + .execute(conn); + } + + fn begin_registration( + &self, + conn: &mut PgConnection, + user_id: Uuid, + username: &str, + challenge_user_id: Option, + exclude: Option>, + ) -> AppResult { + self.prune_expired(conn); + + let (challenge, state) = self + .webauthn + .start_passkey_registration(user_id, username, username, exclude) + .map_err(|err| { + tracing::error!(error = %err, "failed to start passkey registration"); + AppError::internal("failed to start passkey registration") + })?; + + let challenge_id = Uuid::new_v4(); + let expires_at = (Utc::now() + self.challenge_ttl).naive_utc(); + let challenge_bytes: Vec = challenge.public_key.challenge.clone().into(); + let state_bytes = serde_json::to_vec(&state) + .context("failed to encode passkey registration state") + .map_err(AppError::internal)?; + + let record = NewWebauthnChallenge { + id: challenge_id, + user_id: challenge_user_id, + purpose: PURPOSE_REGISTRATION.to_string(), + challenge: challenge_bytes, + state: state_bytes, + expires_at, + }; + + diesel::insert_into(challenge_dsl::webauthn_challenges) + .values(&record) + .execute(conn)?; + + Ok(RegistrationChallengeResponse { + challenge_id, + challenge, + }) + } + + pub fn start_registration( + &self, + conn: &mut PgConnection, + user: &User, + ) -> AppResult { + let existing: Vec = passkey_dsl::user_passkeys + .filter(passkey_dsl::user_id.eq(user.id)) + .filter(passkey_dsl::revoked_at.is_null()) + .load(conn)?; + + let exclude = if existing.is_empty() { + None + } else { + Some( + existing + .iter() + .map(|pk| CredentialID::from(pk.credential_id.clone())) + .collect(), + ) + }; + + self.begin_registration(conn, user.id, &user.username, Some(user.id), exclude) + } + + pub fn start_signup_registration( + &self, + conn: &mut PgConnection, + user_id: Uuid, + username: &str, + ) -> AppResult { + self.begin_registration(conn, user_id, username, None, None) + } + + fn complete_registration( + &self, + conn: &mut PgConnection, + challenge_id: Uuid, + credential: &RegisterPublicKeyCredential, + expected_user: Option, + ) -> AppResult { + let record: WebauthnChallenge = challenge_dsl::webauthn_challenges + .find(challenge_id) + .first(conn) + .map_err(|err| { + if matches!(err, diesel::result::Error::NotFound) { + AppError::bad_request("challenge not found") + } else { + AppError::from(err) + } + })?; + + if record.purpose != PURPOSE_REGISTRATION { + return Err(AppError::bad_request("challenge is not for registration")); + } + + if let Some(expected) = expected_user { + if record.user_id != Some(expected) { + return Err(AppError::unauthorized()); + } + } else if record.user_id.is_some() { + return Err(AppError::bad_request( + "unexpected user context for signup registration", + )); + } + + if record.expires_at < Utc::now().naive_utc() { + diesel::delete(challenge_dsl::webauthn_challenges.find(challenge_id)).execute(conn)?; + return Err(AppError::bad_request("challenge expired")); + } + + let state: PasskeyRegistration = serde_json::from_slice(&record.state) + .context("failed to decode registration state") + .map_err(AppError::internal)?; + + let passkey = self + .webauthn + .finish_passkey_registration(credential, &state) + .map_err(|err| { + tracing::warn!(error = %err, "passkey registration validation failed"); + AppError::bad_request("invalid passkey attestation") + })?; + + let credential_struct: Credential = passkey.clone().into(); + let credential_id_vec: Vec = credential_struct.cred_id.clone().into(); + + let duplicate = passkey_dsl::user_passkeys + .filter(passkey_dsl::credential_id.eq(&credential_id_vec)) + .first::(conn) + .optional()?; + if duplicate.is_some() { + diesel::delete(challenge_dsl::webauthn_challenges.find(challenge_id)).execute(conn)?; + return Err(AppError::conflict("credential already registered")); + } + + let public_key_bytes = serde_cbor_2::to_vec(&credential_struct.cred) + .context("failed to encode credential public key") + .map_err(AppError::internal)?; + + let transports: Vec> = credential_struct + .transports + .clone() + .unwrap_or_default() + .into_iter() + .map(|transport| Some(transport.as_ref().to_string())) + .collect(); + + let aaguid = match credential_struct.attestation.metadata { + AttestationMetadata::Packed { aaguid } | AttestationMetadata::Tpm { aaguid, .. } => { + Some(aaguid) + } + _ => None, + }; + + let credential_json = serde_json::to_value(&passkey) + .context("failed to serialise passkey") + .map_err(AppError::internal)?; + + diesel::delete(challenge_dsl::webauthn_challenges.find(challenge_id)).execute(conn)?; + + Ok(PreparedPasskey { + id: Uuid::new_v4(), + credential_id: credential_id_vec, + public_key: public_key_bytes, + credential: credential_json, + sign_count: credential_struct.counter as i64, + transports, + aaguid, + }) + } + + pub fn finish_registration( + &self, + conn: &mut PgConnection, + user: &User, + challenge_id: Uuid, + credential: RegisterPublicKeyCredential, + nickname: Option, + ) -> AppResult { + let prepared = + self.complete_registration(conn, challenge_id, &credential, Some(user.id))?; + + let new_passkey = prepared.into_new_user_passkey(user.id, nickname); + + diesel::insert_into(passkey_dsl::user_passkeys) + .values(&new_passkey) + .execute(conn)?; + + let created: UserPasskey = passkey_dsl::user_passkeys + .find(new_passkey.id) + .select(UserPasskey::as_select()) + .first(conn)?; + + Ok(created) + } + + pub fn start_authentication( + &self, + conn: &mut PgConnection, + user: &User, + ) -> AppResult { + self.prune_expired(conn); + + let stored: Vec = passkey_dsl::user_passkeys + .filter(passkey_dsl::user_id.eq(user.id)) + .filter(passkey_dsl::revoked_at.is_null()) + .select(UserPasskey::as_select()) + .load(conn)?; + + if stored.is_empty() { + return Err(AppError::bad_request("no passkeys registered")); + } + + let mut passkeys = Vec::with_capacity(stored.len()); + for pk in &stored { + let passkey: Passkey = serde_json::from_value(pk.credential.clone()) + .context("failed to parse stored passkey") + .map_err(AppError::internal)?; + passkeys.push(passkey); + } + + let (challenge, state) = self + .webauthn + .start_passkey_authentication(&passkeys) + .map_err(|err| { + tracing::error!(error = %err, "failed to start passkey authentication"); + AppError::internal("failed to start passkey authentication") + })?; + + let challenge_id = Uuid::new_v4(); + let expires_at = (Utc::now() + self.challenge_ttl).naive_utc(); + let challenge_bytes: Vec = challenge.public_key.challenge.clone().into(); + let state_bytes = serde_json::to_vec(&state) + .context("failed to encode authentication state") + .map_err(AppError::internal)?; + + let record = NewWebauthnChallenge { + id: challenge_id, + user_id: Some(user.id), + purpose: PURPOSE_AUTHENTICATION.to_string(), + challenge: challenge_bytes, + state: state_bytes, + expires_at, + }; + + diesel::insert_into(challenge_dsl::webauthn_challenges) + .values(&record) + .execute(conn)?; + + Ok(AuthenticationChallengeResponse { + challenge_id, + challenge, + }) + } + + pub fn list_for_user( + &self, + conn: &mut PgConnection, + user_id: Uuid, + ) -> AppResult> { + let passkeys: Vec = passkey_dsl::user_passkeys + .filter(passkey_dsl::user_id.eq(user_id)) + .order(passkey_dsl::created_at.asc()) + .select(UserPasskey::as_select()) + .load(conn)?; + + Ok(passkeys.into_iter().map(PasskeySummary::from).collect()) + } + + pub fn active_passkey_count(&self, conn: &mut PgConnection, user_id: Uuid) -> AppResult { + let count: i64 = passkey_dsl::user_passkeys + .filter(passkey_dsl::user_id.eq(user_id)) + .filter(passkey_dsl::revoked_at.is_null()) + .select(count_star()) + .first(conn)?; + Ok(count) + } + + pub fn consume_signup_challenge( + &self, + conn: &mut PgConnection, + challenge_id: Uuid, + credential: &RegisterPublicKeyCredential, + ) -> AppResult { + self.complete_registration(conn, challenge_id, credential, None) + } + + pub fn revoke_passkey( + &self, + conn: &mut PgConnection, + user_id: Uuid, + passkey_id: Uuid, + reason: Option, + ) -> AppResult<()> { + let now = Utc::now().naive_utc(); + let updated = diesel::update( + passkey_dsl::user_passkeys + .filter(passkey_dsl::id.eq(passkey_id)) + .filter(passkey_dsl::user_id.eq(user_id)) + .filter(passkey_dsl::revoked_at.is_null()), + ) + .set(( + passkey_dsl::revoked_at.eq(Some(now)), + passkey_dsl::revoked_reason.eq(reason), + passkey_dsl::updated_at.eq(now), + )) + .execute(conn)?; + + if updated == 0 { + return Err(AppError::not_found()); + } + + Ok(()) + } + + pub fn finish_authentication( + &self, + conn: &mut PgConnection, + challenge_id: Uuid, + credential: PublicKeyCredential, + ) -> AppResult<(User, UserPasskey, AuthenticationResult)> { + let record: WebauthnChallenge = challenge_dsl::webauthn_challenges + .find(challenge_id) + .first(conn) + .map_err(|err| { + if matches!(err, diesel::result::Error::NotFound) { + AppError::bad_request("challenge not found") + } else { + AppError::from(err) + } + })?; + + if record.purpose != PURPOSE_AUTHENTICATION { + return Err(AppError::bad_request("challenge is not for authentication")); + } + + let user_id = record + .user_id + .ok_or_else(|| AppError::bad_request("challenge missing user context"))?; + + if record.expires_at < Utc::now().naive_utc() { + diesel::delete(challenge_dsl::webauthn_challenges.find(challenge_id)).execute(conn)?; + return Err(AppError::bad_request("challenge expired")); + } + + let state: PasskeyAuthentication = serde_json::from_slice(&record.state) + .context("failed to decode authentication state") + .map_err(AppError::internal)?; + + let auth_result = self + .webauthn + .finish_passkey_authentication(&credential, &state) + .map_err(|err| { + tracing::warn!(error = %err, "passkey authentication failed"); + AppError::unauthorized() + })?; + + let credential_id_vec: Vec = auth_result.cred_id().clone().into(); + + let mut passkey: UserPasskey = passkey_dsl::user_passkeys + .filter(passkey_dsl::user_id.eq(user_id)) + .filter(passkey_dsl::credential_id.eq(&credential_id_vec)) + .filter(passkey_dsl::revoked_at.is_null()) + .select(UserPasskey::as_select()) + .first(conn) + .map_err(|err| { + if matches!(err, diesel::result::Error::NotFound) { + AppError::unauthorized() + } else { + AppError::from(err) + } + })?; + + let mut passkey_model: Passkey = serde_json::from_value(passkey.credential.clone()) + .context("failed to parse stored passkey") + .map_err(AppError::internal)?; + + if auth_result.needs_update() { + let _ = passkey_model.update_credential(&auth_result); + } + + let credential_struct: Credential = passkey_model.clone().into(); + let public_key_bytes = serde_cbor_2::to_vec(&credential_struct.cred) + .context("failed to encode credential public key") + .map_err(AppError::internal)?; + + let transports: Vec> = credential_struct + .transports + .clone() + .unwrap_or_default() + .into_iter() + .map(|transport| Some(transport.as_ref().to_string())) + .collect(); + + let credential_json = serde_json::to_value(&passkey_model) + .context("failed to serialise passkey") + .map_err(AppError::internal)?; + + let now = Utc::now().naive_utc(); + diesel::update(passkey_dsl::user_passkeys.find(passkey.id)) + .set(( + passkey_dsl::sign_count.eq(auth_result.counter() as i64), + passkey_dsl::transports.eq(&transports), + passkey_dsl::credential.eq(credential_json.clone()), + passkey_dsl::public_key.eq(public_key_bytes), + passkey_dsl::last_used_at.eq(Some(now)), + passkey_dsl::updated_at.eq(now), + )) + .execute(conn)?; + + passkey.sign_count = auth_result.counter() as i64; + passkey.transports = transports; + passkey.credential = credential_json; + passkey.last_used_at = Some(now); + passkey.updated_at = now; + + diesel::delete(challenge_dsl::webauthn_challenges.find(challenge_id)).execute(conn)?; + + let user = crate::schema::users::table + .find(user_id) + .first::(conn)?; + + Ok((user, passkey, auth_result)) + } +} + +impl From for PasskeySummary { + fn from(passkey: UserPasskey) -> Self { + let transports = passkey + .transports + .into_iter() + .filter_map(|value| value) + .collect(); + + Self { + id: passkey.id, + nickname: passkey.nickname, + created_at: passkey.created_at, + last_used_at: passkey.last_used_at, + transports, + revoked_at: passkey.revoked_at, + revoked_reason: passkey.revoked_reason, + } + } +} + +#[derive(Debug, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct PasskeyRegistrationFinishPayload { + pub challenge_id: Uuid, + #[schema(value_type = Object)] + pub credential: RegisterPublicKeyCredential, + #[serde(default)] + pub nickname: Option, +} + +#[derive(Debug, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct PasskeyLoginStartPayload { + pub username: String, +} + +#[derive(Debug, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct PasskeyLoginFinishPayload { + pub challenge_id: Uuid, + #[schema(value_type = Object)] + pub credential: PublicKeyCredential, +} diff --git a/backend/src/auth/password.rs b/backend/src/auth/password.rs new file mode 100644 index 0000000..a79ceed --- /dev/null +++ b/backend/src/auth/password.rs @@ -0,0 +1,24 @@ +use anyhow::{anyhow, Result}; +use argon2::{ + password_hash::{ + rand_core::OsRng as PasswordHashOsRng, PasswordHash, PasswordHasher, PasswordVerifier, + SaltString, + }, + Argon2, +}; + +pub fn verify_password(password: &str, password_hash: &str) -> Result { + let parsed_hash = PasswordHash::new(password_hash).map_err(|err| anyhow!(err))?; + Ok(Argon2::default() + .verify_password(password.as_bytes(), &parsed_hash) + .is_ok()) +} + +pub fn hash_password(password: &str) -> Result { + let mut rng = PasswordHashOsRng; + let salt = SaltString::generate(&mut rng); + let hash = Argon2::default() + .hash_password(password.as_bytes(), &salt) + .map_err(|err| anyhow!(err))?; + Ok(hash.to_string()) +} diff --git a/backend/src/bin/admin.rs b/backend/src/bin/admin.rs new file mode 100644 index 0000000..a8d174c --- /dev/null +++ b/backend/src/bin/admin.rs @@ -0,0 +1,888 @@ +use std::sync::Arc; + +use anyhow::{anyhow, bail, Context, Result}; +use chrono::{Duration as ChronoDuration, Utc}; +use clap::{Parser, Subcommand, ValueEnum}; +use diesel::{dsl::exists, pg::PgConnection, prelude::*, select}; +use diesel_migrations::MigrationHarness; +use rand::{rngs::OsRng, TryRngCore}; +use reqwest::{Client, Method, StatusCode}; +use sha2::{Digest, Sha256}; +use tokio::task; +use uuid::Uuid; + +use papercrate::{ + auth::capability_sets::{ensure_capability_set, owner_capabilities}, + config::{redact_database_url, AppConfig}, + db::{self, PgPool}, + documents::search::ensure_quickwit_index, + jobs::{enqueue_job, JOB_ANALYZE_DOCUMENT, JOB_DELETE_TENANT}, + migrations::MIGRATIONS, + models::{ + DocumentAsset, MagicToken, MagicTokenKind, NewUser, NewUserMembership, Tenant, + TenantStatus, User, + }, + s3, + schema::{document_assets, documents, magic_tokens, tenants, user_memberships, users}, + storage::{ObjectStorage, S3Storage, TenantStorage}, + tenants::{apply_tenant_guc, clear_tenant_context, TenantService}, + utils::{text::normalize_identifier, tracing::init_tracing}, + workers::tenants::{build_delete_proof_message, sign_delete_proof, DeleteAction}, +}; + +#[derive(Parser)] +#[command( + name = "papercrate-admin", + version, + about = "Papercrate administration utility" +)] +struct Cli { + #[command(subcommand)] + command: Command, +} + +#[derive(Subcommand)] +enum Command { + CreateUser { + username: String, + }, + ListUsers, + DeleteUser { + username: String, + }, + CreateTenant { + name: String, + #[arg(long = "storage-root")] + storage_root: Option, + #[arg(long = "quickwit-index")] + quickwit_index: Option, + }, + DeleteTenant { + tenant_id: Uuid, + #[arg(long = "tenant-name")] + tenant_name: String, + }, + ResetTenant { + tenant_id: Uuid, + #[arg(long = "tenant-name")] + tenant_name: String, + #[arg(long = "final-status", value_enum, default_value_t = TenantFinalStatusArg::Active)] + final_status: TenantFinalStatusArg, + }, + AddUserToTenant { + username: String, + tenant_id: Uuid, + }, + RemoveUserFromTenant { + username: String, + tenant_id: Uuid, + }, + ReanalyzeDocuments { + tenant_id: Uuid, + }, + ListTenants, + DeleteAssets { + tenant_id: Uuid, + #[arg(long = "asset-type")] + asset_type: Option, + #[arg(long = "all", help = "Confirm deleting every asset for the tenant")] + delete_all: bool, + }, + QuickwitCreate { + tenant_id: Uuid, + }, + QuickwitDelete { + tenant_id: Uuid, + }, + EnqueueDeleteTenant { + tenant_id: Uuid, + #[arg(long = "tenant-name")] + tenant_name: String, + #[arg(long = "remove-tenant")] + remove_tenant: bool, + #[arg(long = "final-status", value_enum)] + final_status: Option, + }, + MagicToken { + username: String, + #[arg(long = "ttl-minutes", default_value_t = 10)] + ttl_minutes: i64, + #[arg( + long = "max-uses", + value_name = "MAX_USES", + help = "Maximum number of uses before the token is rejected (default: unlimited)" + )] + max_uses: Option, + #[arg(long = "kind", value_enum, default_value_t = MagicTokenKindArg::EmailLogin)] + kind: MagicTokenKindArg, + }, + MigrateDatabase { + #[arg( + long = "database-url", + value_name = "URL", + help = "Override the migrations database URL (defaults to MIGRATIONS_DATABASE_URL or DATABASE_URL)" + )] + database_url: Option, + }, +} + +#[derive(Copy, Clone, Debug, ValueEnum)] +enum MagicTokenKindArg { + #[value(name = "email_login")] + EmailLogin, + #[value(name = "demo_login")] + DemoLogin, +} + +impl From for MagicTokenKind { + fn from(value: MagicTokenKindArg) -> Self { + match value { + MagicTokenKindArg::EmailLogin => MagicTokenKind::EmailLogin, + MagicTokenKindArg::DemoLogin => MagicTokenKind::DemoLogin, + } + } +} + +#[derive(Copy, Clone, Debug, ValueEnum)] +enum TenantFinalStatusArg { + Active, + Suspended, +} + +impl TenantFinalStatusArg { + fn as_str(&self) -> &'static str { + match self { + TenantFinalStatusArg::Active => "active", + TenantFinalStatusArg::Suspended => "suspended", + } + } +} + +#[tokio::main] +async fn main() -> Result<()> { + init_tracing("info"); + let cli = Cli::parse(); + let config = AppConfig::load_and_log("admin")?; + let pool = db::init_pool_with_size(&config.database_url, config.database_max_pool_size)?; + + match cli.command { + Command::CreateUser { username } => create_user(&pool, &username)?, + Command::ListUsers => list_users(&pool)?, + Command::DeleteUser { username } => delete_user(&pool, &username)?, + Command::CreateTenant { + name, + storage_root, + quickwit_index, + } => create_tenant(&pool, &name, storage_root, quickwit_index)?, + Command::DeleteTenant { + tenant_id, + tenant_name, + } => delete_tenant(&config, &pool, tenant_id, &tenant_name)?, + Command::ResetTenant { + tenant_id, + tenant_name, + final_status, + } => reset_tenant(&config, &pool, tenant_id, &tenant_name, final_status)?, + Command::AddUserToTenant { + username, + tenant_id, + } => add_user_to_tenant(&pool, &username, tenant_id)?, + Command::RemoveUserFromTenant { + username, + tenant_id, + } => remove_user_from_tenant(&pool, &username, tenant_id)?, + Command::ReanalyzeDocuments { tenant_id } => reanalyze_documents(&pool, tenant_id)?, + Command::ListTenants => list_tenants(&pool)?, + Command::DeleteAssets { + tenant_id, + asset_type, + delete_all, + } => { + let asset_type = asset_type.as_deref(); + if asset_type.is_none() && !delete_all { + bail!("refusing to delete all assets without --all confirmation"); + } + + delete_assets_for_tenant(&config, &pool, tenant_id, asset_type).await? + } + Command::QuickwitCreate { tenant_id } => { + quickwit_index(&config, &pool, tenant_id, Method::POST).await? + } + Command::QuickwitDelete { tenant_id } => { + quickwit_index(&config, &pool, tenant_id, Method::DELETE).await? + } + Command::EnqueueDeleteTenant { + tenant_id, + tenant_name, + remove_tenant, + final_status, + } => enqueue_delete_tenant_job( + &config, + &pool, + tenant_id, + &tenant_name, + remove_tenant, + final_status, + )?, + Command::MagicToken { + username, + ttl_minutes, + max_uses, + kind, + } => { + create_magic_token(&pool, &username, ttl_minutes, max_uses, kind.into())?; + } + Command::MigrateDatabase { database_url } => { + let url = database_url.unwrap_or_else(|| config.migrations_database_url().to_string()); + migrate_database(url).await?; + } + } + + Ok(()) +} + +async fn migrate_database(database_url: String) -> Result<()> { + let redacted = redact_database_url(&database_url); + tracing::info!(database_url = %redacted, "running pending migrations"); + + let result = task::spawn_blocking(move || -> Result<()> { + let mut conn = + PgConnection::establish(&database_url).context("failed to connect to database")?; + conn.run_pending_migrations(MIGRATIONS) + .map_err(|err| anyhow!("failed to run migrations: {err}"))?; + Ok(()) + }) + .await + .context("migration task panicked")?; + + result?; + tracing::info!(database_url = %redacted, "migrations completed"); + Ok(()) +} + +fn create_user(pool: &PgPool, username: &str) -> Result<()> { + let username = normalize_identifier( + username, + 100, + "username must not be empty", + "username must not exceed 100 characters", + Some("username may only contain printable characters"), + |ch| !ch.is_control(), + ) + .map_err(|err| anyhow!("{:?}", err))?; + + let mut conn = pool.get().context("failed to get database connection")?; + let exists: bool = + select(exists(users::table.filter(users::username.eq(&username)))).get_result(&mut conn)?; + if exists { + bail!("user '{}' already exists", username); + } + + let new_user = NewUser { + id: Uuid::new_v4(), + username: username.clone(), + }; + + diesel::insert_into(users::table) + .values(&new_user) + .execute(&mut conn)?; + + println!("created user '{}' (id: {})", username, new_user.id); + Ok(()) +} + +fn list_users(pool: &PgPool) -> Result<()> { + let mut conn = pool.get().context("failed to get database connection")?; + + let users_list: Vec = users::table.order(users::username.asc()).load(&mut conn)?; + if users_list.is_empty() { + println!("No users found."); + return Ok(()); + } + + for user in users_list { + let memberships: Vec = user_memberships::table + .inner_join(tenants::table) + .filter(user_memberships::user_id.eq(user.id)) + .select(tenants::name) + .order(tenants::name.asc()) + .load(&mut conn)?; + + if memberships.is_empty() { + println!("{} ({})", user.username, user.id); + } else { + println!( + "{} ({}) -> {}", + user.username, + user.id, + memberships.join(", ") + ); + } + } + + Ok(()) +} + +fn delete_user(pool: &PgPool, username: &str) -> Result<()> { + let mut conn = pool.get().context("failed to get database connection")?; + + let user: User = users::table + .filter(users::username.eq(username)) + .first(&mut conn) + .optional()? + .ok_or_else(|| anyhow!("user '{}' not found", username))?; + + let has_memberships: bool = select(exists( + user_memberships::table.filter(user_memberships::user_id.eq(user.id)), + )) + .get_result(&mut conn)?; + if has_memberships { + bail!( + "user '{}' is still a member of one or more tenants; remove memberships first", + username + ); + } + + diesel::delete(users::table.filter(users::id.eq(user.id))).execute(&mut conn)?; + + println!("deleted user '{}'", username); + Ok(()) +} + +fn create_tenant( + pool: &PgPool, + name: &str, + storage_root_arg: Option, + quickwit_index_arg: Option, +) -> Result<()> { + let service = TenantService::new(pool.clone()); + let tenant = service + .create_tenant( + name, + storage_root_arg.as_deref(), + quickwit_index_arg.as_deref(), + TenantStatus::Creating, + &[], + None, + ) + .map_err(|err| anyhow!(format!("{err:?}")))?; + + let storage_root = tenant.storage_root.as_deref().unwrap_or(""); + let quickwit_index = tenant.quickwit_index.as_deref().unwrap_or(""); + + println!( + "created tenant '{}' with id {}, storage_root '{}', quickwit_index '{}', status '{}'", + tenant.name, + tenant.id, + storage_root, + quickwit_index, + tenant.status.as_str() + ); + Ok(()) +} + +fn create_magic_token( + pool: &PgPool, + username: &str, + ttl_minutes: i64, + max_uses: Option, + kind: MagicTokenKind, +) -> Result<()> { + let mut conn = pool.get().context("failed to get database connection")?; + + let user: User = users::table + .filter(users::username.eq(username)) + .first(&mut conn) + .with_context(|| format!("user '{}' not found", username))?; + + let raw_token = generate_random_token(); + let token_hash = hash_token(&raw_token); + let expires_at = Utc::now() + ChronoDuration::minutes(ttl_minutes); + + let new_token = MagicToken { + id: Uuid::new_v4(), + user_id: user.id, + kind, + token_hash, + metadata: serde_json::json!({}), + expires_at: expires_at.naive_utc(), + max_uses, + used_count: 0, + created_at: Utc::now().naive_utc(), + created_by: None, + last_used_at: None, + }; + + diesel::insert_into(magic_tokens::table) + .values(&new_token) + .execute(&mut conn)?; + + println!( + "Magic token created for '{}' (kind: {}, expires_at: {}, max_uses: {})", + username, + kind.as_str(), + expires_at, + max_uses.map_or("∞".to_string(), |v| v.to_string()) + ); + println!("Token: {}", raw_token); + + Ok(()) +} + +fn generate_random_token() -> String { + let mut bytes = [0u8; 32]; + OsRng + .try_fill_bytes(&mut bytes) + .expect("failed to read random bytes"); + hex::encode(bytes) +} + +fn hash_token(token: &str) -> String { + let mut hasher = Sha256::new(); + hasher.update(token.as_bytes()); + hex::encode(hasher.finalize()) +} + +fn delete_tenant( + config: &AppConfig, + pool: &PgPool, + tenant_id: Uuid, + tenant_name: &str, +) -> Result<()> { + enqueue_delete_tenant_job_internal(config, pool, tenant_id, tenant_name, true, None)?; + println!( + "delete job enqueued; tenant '{}' will be permanently removed", + tenant_name + ); + Ok(()) +} + +fn reset_tenant( + config: &AppConfig, + pool: &PgPool, + tenant_id: Uuid, + tenant_name: &str, + final_status: TenantFinalStatusArg, +) -> Result<()> { + enqueue_delete_tenant_job_internal( + config, + pool, + tenant_id, + tenant_name, + false, + Some(final_status), + )?; + println!( + "reset job enqueued; tenant '{}' will be wiped and set to {}", + tenant_name, + final_status.as_str() + ); + Ok(()) +} + +fn enqueue_delete_tenant_job_internal( + config: &AppConfig, + pool: &PgPool, + tenant_id: Uuid, + expected_name: &str, + remove_tenant: bool, + final_status: Option, +) -> Result<()> { + let mut conn = pool.get().context("failed to get database connection")?; + + let tenant: Tenant = tenants::table + .find(tenant_id) + .first(&mut conn) + .optional()? + .ok_or_else(|| anyhow!("tenant '{}' not found", tenant_id))?; + + apply_tenant_guc(&mut conn, tenant.id) + .map_err(|err| anyhow!("failed to set tenant context for {}: {err:?}", tenant.name))?; + + if tenant.name != expected_name { + bail!( + "tenant name mismatch: expected '{}', database has '{}'", + expected_name, + tenant.name + ); + } + + diesel::update(tenants::table.find(tenant.id)) + .set(tenants::status.eq(TenantStatus::Deleting)) + .execute(&mut conn)?; + + let nonce = generate_random_token(); + let issued_at = Utc::now(); + let issued_at_str = issued_at.to_rfc3339(); + let action = if remove_tenant { + DeleteAction::Delete + } else { + DeleteAction::Reset + }; + let resolved_final_status = if remove_tenant { + None + } else { + Some( + final_status + .unwrap_or(TenantFinalStatusArg::Suspended) + .as_str(), + ) + }; + + let message = build_delete_proof_message( + tenant.id, + expected_name, + action, + &nonce, + &issued_at_str, + resolved_final_status, + ); + let signature = sign_delete_proof(&config.jwt_secret, &message).map_err(|err| anyhow!(err))?; + + let mut payload = serde_json::json!({ + "remove_tenant": remove_tenant, + "tenant_name": tenant.name.clone(), + "action": action.as_str(), + "nonce": nonce, + "issued_at": issued_at_str, + "signature": signature, + }); + if let Some(status) = resolved_final_status { + payload["final_status"] = serde_json::json!(status); + } + + enqueue_job(&mut conn, tenant.id, JOB_DELETE_TENANT, payload, None)?; + let status_label = if remove_tenant { + "deleted" + } else { + final_status.map(|s| s.as_str()).unwrap_or("suspended") + }; + println!( + "delete-tenant job enqueued for '{}' (remove_tenant={}, final_status={})", + tenant.name, remove_tenant, status_label + ); + + clear_tenant_context(&mut conn).map_err(|err| { + anyhow!( + "failed to clear tenant context for {}: {err:?}", + tenant.name + ) + })?; + + Ok(()) +} + +fn enqueue_delete_tenant_job( + config: &AppConfig, + pool: &PgPool, + tenant_id: Uuid, + tenant_name: &str, + remove_tenant: bool, + final_status: Option, +) -> Result<()> { + enqueue_delete_tenant_job_internal( + config, + pool, + tenant_id, + tenant_name, + remove_tenant, + final_status, + ) +} + +fn add_user_to_tenant(pool: &PgPool, username: &str, tenant_id: Uuid) -> Result<()> { + let mut conn = pool.get().context("failed to get database connection")?; + + let user: User = users::table + .filter(users::username.eq(username)) + .first(&mut conn) + .optional()? + .ok_or_else(|| anyhow!("user '{}' not found", username))?; + + let tenant: Tenant = tenants::table + .find(tenant_id) + .first(&mut conn) + .optional()? + .ok_or_else(|| anyhow!("tenant '{}' not found", tenant_id))?; + + let owner_capability_set_id = ensure_capability_set(&mut conn, tenant.id, owner_capabilities()) + .map_err(|err| anyhow!("failed to ensure owner capability set: {:?}", err))? + .id; + + let membership = NewUserMembership { + id: Uuid::new_v4(), + user_id: user.id, + tenant_id: tenant.id, + capability_set_id: Some(owner_capability_set_id), + }; + + diesel::insert_into(user_memberships::table) + .values(&membership) + .on_conflict((user_memberships::user_id, user_memberships::tenant_id)) + .do_nothing() + .execute(&mut conn)?; + + println!("added user '{}' to tenant '{}'", username, tenant.name); + Ok(()) +} + +fn remove_user_from_tenant(pool: &PgPool, username: &str, tenant_id: Uuid) -> Result<()> { + let mut conn = pool.get().context("failed to get database connection")?; + + let user: User = users::table + .filter(users::username.eq(username)) + .first(&mut conn) + .optional()? + .ok_or_else(|| anyhow!("user '{}' not found", username))?; + + let tenant: Tenant = tenants::table + .find(tenant_id) + .first(&mut conn) + .optional()? + .ok_or_else(|| anyhow!("tenant '{}' not found", tenant_id))?; + + let removed = diesel::delete( + user_memberships::table + .filter(user_memberships::user_id.eq(user.id)) + .filter(user_memberships::tenant_id.eq(tenant.id)), + ) + .execute(&mut conn)?; + + if removed == 0 { + println!( + "user '{}' was not a member of tenant '{}'", + username, tenant.name + ); + } else { + println!("removed user '{}' from tenant '{}'", username, tenant.name); + } + Ok(()) +} + +fn reanalyze_documents(pool: &PgPool, tenant_id: Uuid) -> Result<()> { + let mut conn = pool.get().context("failed to get database connection")?; + + let tenant: Tenant = tenants::table + .find(tenant_id) + .first(&mut conn) + .optional()? + .ok_or_else(|| anyhow!("tenant '{}' not found", tenant_id))?; + + let targets: Vec<(Uuid, Uuid)> = documents::table + .filter(documents::tenant_id.eq(tenant.id)) + .filter(documents::deleted_at.is_null()) + .select((documents::id, documents::current_version_id)) + .load(&mut conn)?; + + if targets.is_empty() { + println!("tenant '{}' has no active documents", tenant.name); + return Ok(()); + } + + let mut queued = 0usize; + for (document_id, version_id) in targets { + enqueue_job( + &mut conn, + tenant.id, + JOB_ANALYZE_DOCUMENT, + serde_json::json!({ + "document_id": document_id, + "document_version_id": version_id, + "force": true, + }), + None, + ) + .map_err(|err| anyhow!("failed to enqueue analyze job: {}", err))?; + queued += 1; + } + + println!( + "queued {} documents for re-analysis in tenant '{}'", + queued, tenant.name + ); + Ok(()) +} + +fn list_tenants(pool: &PgPool) -> Result<()> { + let mut conn = pool.get().context("failed to get database connection")?; + let tenants: Vec = tenants::table + .order(tenants::name.asc()) + .load(&mut conn) + .context("failed to load tenants")?; + + if tenants.is_empty() { + println!("No tenants found."); + return Ok(()); + } + + for tenant in tenants { + println!("{} {}", tenant.id, tenant.name); + } + + Ok(()) +} + +async fn delete_assets_for_tenant( + config: &AppConfig, + pool: &PgPool, + tenant_id: Uuid, + asset_type: Option<&str>, +) -> Result<()> { + let bucket = s3::build_bucket(config)?; + let storage: Arc = Arc::new(S3Storage::new(bucket)); + + let mut conn = pool.get().context("failed to get database connection")?; + let tenant: Tenant = tenants::table + .find(tenant_id) + .first(&mut conn) + .optional()? + .ok_or_else(|| anyhow!("tenant '{}' not found", tenant_id))?; + + apply_tenant_guc(&mut conn, tenant.id) + .map_err(|err| anyhow!("failed to set tenant context for {}: {err:?}", tenant.name))?; + + let result = async { + let tenant_storage = TenantStorage::new(Arc::clone(&storage), &tenant) + .with_context(|| format!("missing storage root for tenant {}", tenant.name))?; + + let mut asset_query = document_assets::table + .filter(document_assets::tenant_id.eq(tenant.id)) + .into_boxed(); + + if let Some(asset_type) = asset_type { + asset_query = asset_query.filter(document_assets::asset_type.eq(asset_type)); + } + + let assets: Vec = asset_query + .load(&mut conn) + .with_context(|| format!("failed to load assets for tenant {}", tenant.name))?; + + if assets.is_empty() { + match asset_type { + Some(asset_type) => { + println!("Tenant {}: no assets of type '{}'", tenant.name, asset_type) + } + None => println!("Tenant {}: no assets", tenant.name), + } + return Ok(()); + } + + match asset_type { + Some(asset_type) => println!( + "Tenant {} ({}): deleting {} '{}' assets…", + tenant.name, + tenant.id, + assets.len(), + asset_type + ), + None => println!( + "Tenant {} ({}): deleting {} assets…", + tenant.name, + tenant.id, + assets.len() + ), + } + + let asset_ids: Vec = assets.iter().map(|asset| asset.id).collect(); + + for asset in &assets { + if let Err(err) = tenant_storage.delete_object(&asset.s3_key).await { + eprintln!( + "Failed to delete object {} (tenant {}): {err}", + asset.s3_key, tenant.name + ); + } + } + + diesel::delete( + document_assets::table + .filter(document_assets::tenant_id.eq(tenant.id)) + .filter(document_assets::id.eq_any(&asset_ids)), + ) + .execute(&mut conn) + .with_context(|| format!("failed to remove asset records for tenant {}", tenant.name))?; + + println!("Tenant {}: asset records deleted.", tenant.name); + Ok(()) + } + .await; + + clear_tenant_context(&mut conn).map_err(|err| { + anyhow!( + "failed to clear tenant context for {}: {err:?}", + tenant.name + ) + })?; + + result +} + +async fn quickwit_index( + config: &AppConfig, + pool: &PgPool, + tenant_id: Uuid, + method: Method, +) -> Result<()> { + let endpoint = config + .quickwit_endpoint + .as_ref() + .ok_or_else(|| anyhow!("quickwit endpoint not configured"))?; + + let mut conn = pool.get().context("failed to get database connection")?; + let tenant: Tenant = tenants::table + .find(tenant_id) + .first(&mut conn) + .optional()? + .ok_or_else(|| anyhow!("tenant '{}' not found", tenant_id))?; + + let client = Client::new(); + let index_id = format!("documents-{}", tenant.id); + let base_endpoint = endpoint.trim_end_matches('/'); + + match method { + Method::POST => { + ensure_quickwit_index(&client, base_endpoint, &index_id) + .await + .context("failed to ensure quickwit index")?; + + diesel::update(tenants::table.filter(tenants::id.eq(tenant.id))) + .set(tenants::quickwit_index.eq(Some(index_id.clone()))) + .execute(&mut conn) + .context("failed to update tenant quickwit_index")?; + + println!( + "Tenant '{}' quickwit index set to '{}'.", + tenant.name, index_id + ); + } + Method::DELETE => { + let response = client + .delete(format!("{}/api/v1/indexes/{}", base_endpoint, index_id)) + .send() + .await + .context("failed to send delete index request")?; + + match response.status() { + status if status.is_success() || status == StatusCode::NOT_FOUND => { + diesel::update(tenants::table.filter(tenants::id.eq(tenant.id))) + .set(tenants::quickwit_index.eq::>(None)) + .execute(&mut conn) + .context("failed to clear tenant quickwit_index")?; + + println!("Tenant '{}' quickwit index cleared.", tenant.name); + } + status => { + let body = response.text().await.unwrap_or_default(); + bail!( + "quickwit delete index failed with status {}: {}", + status, + body + ); + } + } + } + _ => unreachable!(), + } + + Ok(()) +} diff --git a/backend/src/bin/openapi_dump.rs b/backend/src/bin/openapi_dump.rs new file mode 100644 index 0000000..0ad06a4 --- /dev/null +++ b/backend/src/bin/openapi_dump.rs @@ -0,0 +1,8 @@ +use papercrate::openapi::ApiDoc; +use utoipa::OpenApi; + +fn main() { + let spec = ApiDoc::openapi(); + let json = serde_json::to_string_pretty(&spec).expect("serialize openapi"); + println!("{}", json); +} diff --git a/backend/src/bin/webdav.rs b/backend/src/bin/webdav.rs new file mode 100644 index 0000000..8d5a85c --- /dev/null +++ b/backend/src/bin/webdav.rs @@ -0,0 +1,28 @@ +use std::net::SocketAddr; + +use tokio::net::TcpListener; +use tower::make::Shared; + +use papercrate::{routes::webdav, utils::bootstrap::init_component}; + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + let state = init_component("webdav", None).await?; + let webdav_host = state.config.webdav_host.clone(); + let webdav_port = state.config.webdav_port; + tracing::info!( + component = "webdav", + webdav_host = %webdav_host, + webdav_port, + "starting webdav server" + ); + + let listen_addr: SocketAddr = format!("{}:{}", webdav_host, webdav_port).parse()?; + let router = webdav::create_router().with_state(state.as_ref().clone()); + + let listener = TcpListener::bind(listen_addr).await?; + tracing::info!("listening for WebDAV on {}", listen_addr); + + axum::serve(listener, Shared::new(router)).await?; + Ok(()) +} diff --git a/backend/src/bin/worker.rs b/backend/src/bin/worker.rs new file mode 100644 index 0000000..decc3c1 --- /dev/null +++ b/backend/src/bin/worker.rs @@ -0,0 +1,21 @@ +use std::time::Duration; + +use tokio::signal; + +use papercrate::{default_handlers, utils::bootstrap::init_component, Worker}; + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + let state = init_component("worker", Some(1)).await?; + tracing::info!(component = "worker", "starting worker process"); + let worker = Worker::new(state, default_handlers(), Duration::from_secs(2)); + + tokio::select! { + _ = worker.run() => {} + _ = signal::ctrl_c() => { + tracing::info!("worker received shutdown signal"); + } + } + + Ok(()) +} diff --git a/backend/src/config.rs b/backend/src/config.rs new file mode 100644 index 0000000..81d0cc5 --- /dev/null +++ b/backend/src/config.rs @@ -0,0 +1,298 @@ +use anyhow::{Context, Result}; +use url::Url; + +use serde::de::Deserializer; +use serde::Deserialize; +use serde_aux::field_attributes::deserialize_bool_from_anything; + +use crate::db::DEFAULT_MAX_POOL_SIZE; + +#[derive(Clone, Debug, Deserialize)] +pub struct AppConfig { + pub database_url: String, + #[serde(default)] + pub migrations_database_url: Option, + #[serde(default = "default_database_max_pool_size")] + pub database_max_pool_size: u32, + #[serde(default = "default_server_host")] + pub server_host: String, + #[serde(default = "default_server_port")] + pub server_port: u16, + #[serde(default = "default_webdav_host")] + pub webdav_host: String, + #[serde(default = "default_webdav_port")] + pub webdav_port: u16, + pub jwt_secret: String, + #[serde(default = "default_jwt_issuer")] + pub jwt_issuer: String, + #[serde(default = "default_jwt_audience")] + pub jwt_audience: String, + #[serde(default = "default_jwt_expiry_minutes")] + pub jwt_expiry_minutes: i64, + #[serde(default = "default_download_token_audience")] + pub download_token_audience: String, + #[serde(default = "default_download_token_expiry_minutes")] + pub download_token_expiry_minutes: i64, + #[serde(default = "default_refresh_token_expiry_days")] + pub refresh_token_expiry_days: i64, + #[serde( + default = "default_refresh_cookie_secure", + deserialize_with = "deserialize_bool_from_anything" + )] + pub refresh_cookie_secure: bool, + #[serde(default)] + pub refresh_cookie_domain: Option, + #[serde(default)] + pub cors_allowed_origin: Option, + #[serde(default, deserialize_with = "deserialize_bool_from_anything")] + pub proxy_downloads: bool, + #[serde(default)] + pub aws_endpoint_url: Option, + #[serde(default)] + pub aws_access_key_id: Option, + #[serde(default)] + pub aws_secret_access_key: Option, + #[serde(default = "default_aws_region")] + pub aws_region: String, + pub s3_bucket: String, + #[serde(default)] + pub quickwit_endpoint: Option, + #[serde(default)] + pub quickwit_index: Option, + #[serde(default = "default_worker_max_document_bytes")] + pub worker_max_document_bytes: u64, + #[serde(default = "default_upload_body_limit_bytes")] + pub upload_body_limit_bytes: u64, + #[serde(default = "default_service_timezone")] + pub service_timezone: String, + #[serde(default = "default_issued_at_date_order")] + pub issued_at_date_order: String, + #[serde(default)] + pub issued_at_filename_date_order: Option, + #[serde(default, deserialize_with = "deserialize_string_list")] + pub issued_at_date_parser_locales: Vec, + #[serde(default, deserialize_with = "deserialize_string_list")] + pub issued_at_ignore_dates: Vec, + #[serde(default)] + pub webauthn_rp_id: Option, + #[serde(default)] + pub webauthn_origin: Option, + #[serde(default = "default_webauthn_rp_name")] + pub webauthn_rp_name: String, +} + +impl AppConfig { + pub fn load_and_log(component: &str) -> Result { + dotenv::dotenv().ok(); + let config = Self::from_env()?; + tracing::info!( + component, + database_url = %config.redacted_database_url(), + migrations_database_url = %config.redacted_migrations_database_url(), + pool_size = config.database_max_pool_size, + quickwit_enabled = config.quickwit_endpoint.is_some(), + passkeys_enabled = config.webauthn_origin.is_some(), + s3_bucket = %config.s3_bucket, + worker_max_document_bytes = config.worker_max_document_bytes, + upload_body_limit_bytes = config.upload_body_limit_bytes, + proxy_downloads = config.proxy_downloads, + "loaded backend configuration" + ); + Ok(config) + } + + pub fn from_env() -> Result { + let config: AppConfig = envy::from_env() + .context("failed to parse application configuration from environment")?; + Ok(config.normalize()) + } + + pub fn redacted_database_url(&self) -> String { + redact_database_url(&self.database_url) + } + + pub fn redacted_migrations_database_url(&self) -> String { + redact_database_url(self.migrations_database_url()) + } + + pub fn migrations_database_url(&self) -> &str { + if let Some(ref url) = self.migrations_database_url { + url + } else { + &self.database_url + } + } +} + +impl AppConfig { + fn normalize(mut self) -> Self { + if self.webdav_host.is_empty() { + self.webdav_host = self.server_host.clone(); + } + + if self.webauthn_rp_id.is_none() { + self.webauthn_rp_id = Some(self.server_host.clone()); + } + + if self.webauthn_origin.is_none() { + let scheme = if self.server_host == "127.0.0.1" || self.server_host == "localhost" { + "http" + } else { + "https" + }; + self.webauthn_origin = Some(format!( + "{scheme}://{}:{}", + self.server_host, self.server_port + )); + } + self + } +} + +fn default_database_max_pool_size() -> u32 { + DEFAULT_MAX_POOL_SIZE +} + +fn default_server_host() -> String { + "127.0.0.1".to_string() +} + +fn default_server_port() -> u16 { + 3000 +} + +fn default_webdav_host() -> String { + String::new() +} + +fn default_webdav_port() -> u16 { + 3001 +} + +fn default_jwt_issuer() -> String { + "papercrate".to_string() +} + +fn default_jwt_audience() -> String { + "papercrate-clients".to_string() +} + +fn default_jwt_expiry_minutes() -> i64 { + 60 +} + +fn default_download_token_audience() -> String { + "papercrate-download".to_string() +} + +fn default_download_token_expiry_minutes() -> i64 { + 60 +} + +fn default_refresh_token_expiry_days() -> i64 { + 30 +} + +fn default_refresh_cookie_secure() -> bool { + false +} + +fn default_aws_region() -> String { + "us-east-1".to_string() +} + +fn default_worker_max_document_bytes() -> u64 { + 200 * 1024 * 1024 +} + +fn default_upload_body_limit_bytes() -> u64 { + 128 * 1024 * 1024 +} + +fn default_service_timezone() -> String { + "UTC".to_string() +} + +fn default_issued_at_date_order() -> String { + "DMY".to_string() +} + +fn deserialize_string_list<'de, D>(deserializer: D) -> Result, D::Error> +where + D: Deserializer<'de>, +{ + #[derive(Deserialize)] + #[serde(untagged)] + enum Helper { + List(Vec), + Single(String), + } + + let helper = Option::::deserialize(deserializer)?; + let mut values = Vec::new(); + + if let Some(helper) = helper { + match helper { + Helper::List(list) => { + for entry in list { + let trimmed = entry.trim(); + if !trimmed.is_empty() { + values.push(trimmed.to_string()); + } + } + } + Helper::Single(value) => { + for part in value.split(',') { + let trimmed = part.trim(); + if !trimmed.is_empty() { + values.push(trimmed.to_string()); + } + } + } + } + } + + Ok(values) +} + +fn default_webauthn_rp_name() -> String { + "Papercrate".to_string() +} + +pub fn redact_database_url(raw: &str) -> String { + match Url::parse(raw) { + Ok(mut parsed) => { + if parsed.password().is_some() { + let _ = parsed.set_password(Some("*****")); + parsed.to_string() + } else { + raw.to_string() + } + } + Err(_) => "***".to_string(), + } +} + +#[cfg(test)] +mod tests { + use super::redact_database_url; + + #[test] + fn redacts_password_in_database_url() { + let redacted = redact_database_url("postgres://user:secret@localhost/db"); + assert!(redacted.contains("postgres://user:*****@")); + assert!(!redacted.contains("secret")); + } + + #[test] + fn handles_url_without_password() { + let redacted = redact_database_url("postgres://localhost/db"); + assert_eq!(redacted, "postgres://localhost/db"); + } + + #[test] + fn falls_back_when_parse_fails() { + let redacted = redact_database_url("not a url"); + assert_eq!(redacted, "***"); + } +} diff --git a/backend/src/db.rs b/backend/src/db.rs new file mode 100644 index 0000000..7798c4c --- /dev/null +++ b/backend/src/db.rs @@ -0,0 +1,51 @@ +use std::time::Duration; + +use diesel::pg::PgConnection; +use diesel::r2d2::{ConnectionManager, CustomizeConnection, Pool}; +use diesel::RunQueryDsl; + +pub type PgPool = Pool>; + +pub const DEFAULT_MAX_POOL_SIZE: u32 = 2; + +#[derive(Debug)] +struct SchemaCustomizer; + +impl CustomizeConnection for SchemaCustomizer { + fn on_acquire(&self, conn: &mut PgConnection) -> Result<(), diesel::r2d2::Error> { + diesel::sql_query( + "SELECT set_config('search_path', ( + SELECT string_agg(schema_name, ', ') + FROM ( + SELECT 'tenant' AS schema_name WHERE EXISTS ( + SELECT 1 FROM pg_namespace WHERE nspname = 'tenant' + ) + UNION ALL + SELECT 'shared' AS schema_name WHERE EXISTS ( + SELECT 1 FROM pg_namespace WHERE nspname = 'shared' + ) + UNION ALL + SELECT 'public' AS schema_name + ) AS schemas + ), false)", + ) + .execute(conn) + .map(|_| ()) + .map_err(diesel::r2d2::Error::QueryError) + } +} + +pub fn init_pool(database_url: &str) -> anyhow::Result { + init_pool_with_size(database_url, DEFAULT_MAX_POOL_SIZE) +} + +pub fn init_pool_with_size(database_url: &str, max_size: u32) -> anyhow::Result { + let manager = ConnectionManager::::new(database_url); + let pool_size = max_size.max(1); + let pool = Pool::builder() + .max_size(pool_size) + .connection_timeout(Duration::from_secs(10)) + .connection_customizer(Box::new(SchemaCustomizer)) + .build(manager)?; + Ok(pool) +} diff --git a/backend/src/documents/asset.rs b/backend/src/documents/asset.rs new file mode 100644 index 0000000..4ee4788 --- /dev/null +++ b/backend/src/documents/asset.rs @@ -0,0 +1,256 @@ +use std::collections::HashMap; +use std::path::Path as FsPath; + +use chrono::{Duration as ChronoDuration, Utc}; +use diesel::prelude::*; +use serde::Serialize; +use serde_json::Value; +use utoipa::ToSchema; +use uuid::Uuid; + +use crate::error::{AppError, AppResult}; +use crate::models::{Document, DocumentAsset, DocumentVersion}; +use crate::schema::{document_assets, document_versions}; +use crate::state::{AppState, PgPooledConnection}; +use crate::utils::{http::inline_content_disposition, time::to_iso}; + +#[derive(Serialize, Clone, ToSchema)] +pub struct DownloadLink { + pub url: String, + pub expires_at: i64, +} + +#[derive(Serialize, Clone, ToSchema)] +pub struct DocumentAssetResponse { + pub id: Uuid, + pub asset_type: String, + pub mime_type: String, + #[schema(value_type = Object)] + pub metadata: Value, + #[serde(skip_serializing_if = "Option::is_none")] + #[schema(nullable)] + pub download: Option, +} + +#[derive(Serialize, ToSchema)] +pub struct DocumentAssetDetailResponse { + pub id: Uuid, + pub asset_type: String, + pub mime_type: String, + #[schema(value_type = Object)] + pub metadata: Value, + pub created_at: String, + #[serde(skip_serializing_if = "Option::is_none")] + #[schema(nullable)] + pub download: Option, +} + +#[derive(Serialize, Clone, ToSchema)] +pub struct DocumentVersionResponse { + pub id: Uuid, + pub version_number: i32, + pub size_bytes: i64, + pub checksum: String, + pub created_at: String, + #[schema(value_type = Object)] + pub metadata: Value, +} + +#[derive(Serialize, Clone, ToSchema)] +pub struct DocumentVersionDetailResponse { + #[serde(flatten)] + pub version: DocumentVersionResponse, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub assets: Vec, + pub download: DownloadLink, +} + +pub fn build_download_link( + state: &AppState, + document: &Document, + version_id: Uuid, + user_id: Uuid, +) -> AppResult { + state + .jwt + .generate_download_token(document.id, version_id, user_id, document.tenant_id) + .map_err(|err| { + tracing::error!(error = ?err, "failed to generate download token"); + AppError::internal("failed to generate download token") + }) + .and_then(|token| { + let expires_at = Utc::now() + .checked_add_signed(ChronoDuration::minutes( + state.config.download_token_expiry_minutes, + )) + .ok_or_else(|| AppError::internal("failed to compute download expiry"))? + .timestamp_millis(); + + Ok(DownloadLink { + url: format!("/api/download/{token}"), + expires_at, + }) + }) +} + +pub fn to_version_response(version: DocumentVersion) -> DocumentVersionResponse { + DocumentVersionResponse { + id: version.id, + version_number: version.version_number, + size_bytes: version.size_bytes, + checksum: version.checksum, + created_at: to_iso(version.created_at), + metadata: version.metadata, + } +} + +pub fn to_asset_summary(asset: DocumentAsset) -> DocumentAssetResponse { + DocumentAssetResponse { + id: asset.id, + asset_type: asset.asset_type, + mime_type: asset.mime_type, + metadata: asset.metadata, + download: None, + } +} + +pub fn to_asset_detail_response( + asset: DocumentAsset, + download: Option, +) -> DocumentAssetDetailResponse { + DocumentAssetDetailResponse { + id: asset.id, + asset_type: asset.asset_type, + mime_type: asset.mime_type, + metadata: asset.metadata, + created_at: to_iso(asset.created_at), + download, + } +} + +pub fn asset_disposition(asset: &DocumentAsset) -> Option { + let filename = asset.asset_type.clone(); + inline_content_disposition(&filename) +} + +pub fn delete_asset( + conn: &mut PgPooledConnection, + tenant_id: Uuid, + asset_id: Uuid, +) -> AppResult<()> { + diesel::delete( + document_assets::table + .filter(document_assets::id.eq(asset_id)) + .filter(document_assets::tenant_id.eq(tenant_id)), + ) + .execute(conn)?; + + Ok(()) +} + +pub fn load_asset_responses_with_conn( + conn: &mut PgPooledConnection, + tenant_id: Uuid, + version_id: Uuid, +) -> AppResult> { + let assets: Vec = document_assets::table + .filter(document_assets::document_version_id.eq(version_id)) + .filter(document_assets::tenant_id.eq(tenant_id)) + .order(document_assets::created_at.asc()) + .load(conn)?; + + Ok(assets.into_iter().map(to_asset_summary).collect()) +} + +pub fn load_primary_assets( + conn: &mut PgPooledConnection, + documents: &[Document], +) -> AppResult)>> { + if documents.is_empty() { + return Ok(HashMap::new()); + } + + let mut doc_to_version: HashMap = HashMap::with_capacity(documents.len()); + let mut version_ids: Vec = Vec::with_capacity(documents.len()); + for doc in documents { + doc_to_version.insert(doc.id, doc.current_version_id); + version_ids.push(doc.current_version_id); + } + + version_ids.sort(); + version_ids.dedup(); + + let versions: Vec = document_versions::table + .filter(document_versions::id.eq_any(&version_ids)) + .load(conn)?; + + let mut version_map: HashMap = HashMap::new(); + for version in versions { + version_map.insert(version.id, version); + } + + let assets: Vec = document_assets::table + .filter(document_assets::document_version_id.eq_any(&version_ids)) + .order(( + document_assets::document_version_id.asc(), + document_assets::created_at.asc(), + )) + .load(conn)?; + + let mut assets_by_version: HashMap> = HashMap::new(); + for asset in assets { + let version_id = asset.document_version_id; + let response = to_asset_summary(asset); + assets_by_version + .entry(version_id) + .or_default() + .push(response); + } + + let mut result: HashMap)> = + HashMap::with_capacity(doc_to_version.len()); + for (doc_id, version_id) in doc_to_version { + if let Some(version) = version_map.remove(&version_id) { + let assets = assets_by_version.remove(&version_id).unwrap_or_default(); + result.insert(doc_id, (to_version_response(version), assets)); + } + } + + Ok(result) +} + +pub fn derive_document_title(original: &str) -> String { + let trimmed = original.trim(); + if trimmed.is_empty() { + return "Document".to_string(); + } + + let stem = FsPath::new(trimmed) + .file_stem() + .and_then(|s| s.to_str()) + .map(|s| s.trim()) + .filter(|s| !s.is_empty()) + .map(|s| s.to_string()); + + stem.unwrap_or_else(|| trimmed.to_string()) +} + +pub fn filename_with_retained_extension(title: &str, current_filename: &str) -> String { + let extension = FsPath::new(current_filename) + .extension() + .and_then(|ext| ext.to_str()); + + if let Some(ext) = extension { + if title + .rsplit_once('.') + .map(|(_, existing_ext)| existing_ext.eq_ignore_ascii_case(ext)) + .unwrap_or(false) + { + title.to_string() + } else { + format!("{title}.{ext}") + } + } else { + title.to_string() + } +} diff --git a/backend/src/documents/correspondents.rs b/backend/src/documents/correspondents.rs new file mode 100644 index 0000000..5c9976e --- /dev/null +++ b/backend/src/documents/correspondents.rs @@ -0,0 +1,121 @@ +use std::collections::HashMap; + +use chrono::Utc; +use diesel::prelude::*; +use serde::Serialize; +use serde_json::Value; +use utoipa::ToSchema; +use uuid::Uuid; + +use crate::error::{AppError, AppResult}; +use crate::models::{Correspondent, DocumentCorrespondent, NewDocumentCorrespondent}; +use crate::schema::{correspondents, document_correspondents, documents}; +use crate::utils::time::to_iso; + +#[derive(Serialize, Clone, ToSchema)] +pub struct DocumentCorrespondentResponse { + pub id: Uuid, + pub name: String, + #[schema(value_type = Object)] + pub metadata: Value, + pub assigned_at: String, +} + +pub fn normalize_correspondent_ids(ids: &[Uuid]) -> AppResult> { + let mut unique: Vec = ids.iter().copied().collect(); + unique.sort_unstable(); + unique.dedup(); + + if unique.is_empty() { + return Err(AppError::bad_request( + "assignments must contain at least one correspondent", + )); + } + + Ok(unique) +} + +pub fn insert_document_correspondents( + conn: &mut PgConnection, + tenant_id: Uuid, + document_id: Uuid, + user_id: Uuid, + correspondent_ids: &[Uuid], +) -> AppResult { + let ids = normalize_correspondent_ids(correspondent_ids)?; + + let existing: Vec = correspondents::table + .filter(correspondents::id.eq_any(&ids)) + .filter(correspondents::tenant_id.eq(tenant_id)) + .select(correspondents::id) + .load(conn)?; + + if existing.len() != ids.len() { + return Err(AppError::bad_request( + "one or more correspondents do not exist", + )); + } + + let new_rows: Vec = ids + .into_iter() + .map(|correspondent_id| NewDocumentCorrespondent { + document_id, + correspondent_id, + assigned_by: Some(user_id), + tenant_id, + }) + .collect(); + + if new_rows.is_empty() { + return Ok(0); + } + + let inserted = diesel::insert_into(document_correspondents::table) + .values(&new_rows) + .on_conflict_do_nothing() + .execute(conn)?; + + if inserted > 0 { + diesel::update( + documents::table + .find(document_id) + .filter(documents::tenant_id.eq(tenant_id)), + ) + .set(documents::updated_at.eq(Utc::now().naive_utc())) + .execute(conn)?; + } + + Ok(inserted) +} + +pub fn load_correspondents_for_documents( + conn: &mut PgConnection, + document_ids: &[Uuid], +) -> AppResult>> { + if document_ids.is_empty() { + return Ok(HashMap::new()); + } + + let rows: Vec<(DocumentCorrespondent, Correspondent)> = document_correspondents::table + .inner_join(correspondents::table) + .filter(document_correspondents::document_id.eq_any(document_ids)) + .order(( + document_correspondents::document_id.asc(), + document_correspondents::assigned_at.asc(), + )) + .load(conn)?; + + let mut map: HashMap> = HashMap::new(); + for (assignment, correspondent) in rows { + map.entry(assignment.document_id) + .or_default() + .push(DocumentCorrespondentResponse { + id: correspondent.id, + name: correspondent.name, + metadata: correspondent.metadata, + assigned_at: to_iso(assignment.assigned_at), + }); + } + + Ok(map) +} diff --git a/backend/src/documents/folders.rs b/backend/src/documents/folders.rs new file mode 100644 index 0000000..ee25e0c --- /dev/null +++ b/backend/src/documents/folders.rs @@ -0,0 +1,21 @@ +use diesel::dsl::exists; +use diesel::prelude::*; +use uuid::Uuid; + +use crate::error::AppResult; +use crate::schema::folders; +use crate::utils::validation::ensure_exists; + +pub fn ensure_folder_exists_on_conn( + conn: &mut PgConnection, + tenant_id: Uuid, + folder_id: Uuid, +) -> AppResult<()> { + let exists: bool = diesel::select(exists( + folders::table + .filter(folders::id.eq(folder_id)) + .filter(folders::tenant_id.eq(tenant_id)), + )) + .get_result(conn)?; + ensure_exists(exists, "folder") +} diff --git a/backend/src/documents/metadata.rs b/backend/src/documents/metadata.rs new file mode 100644 index 0000000..88e3400 --- /dev/null +++ b/backend/src/documents/metadata.rs @@ -0,0 +1,52 @@ +use serde_json::{map::Entry, Map, Value}; + +use crate::error::{AppError, AppResult}; + +pub fn merge_document_metadata(existing: Value, updates: Value) -> AppResult { + let mut base = match existing { + Value::Object(map) => map, + Value::Null => Map::new(), + _ => { + return Err(AppError::bad_request( + "existing metadata is not an object; set replace=true to overwrite", + )); + } + }; + + let incoming = match updates { + Value::Object(map) => map, + _ => { + return Err(AppError::bad_request( + "metadata value must be a JSON object when replace is false", + )); + } + }; + + merge_metadata_maps(&mut base, incoming); + Ok(Value::Object(base)) +} + +fn merge_metadata_maps(target: &mut Map, updates: Map) { + for (key, value) in updates { + match target.entry(key) { + Entry::Occupied(mut entry) => { + let existing = entry.get_mut(); + match value { + Value::Object(update_map) => { + if let Value::Object(existing_map) = existing { + merge_metadata_maps(existing_map, update_map); + } else { + *existing = Value::Object(update_map); + } + } + other => { + *existing = other; + } + } + } + Entry::Vacant(entry) => { + entry.insert(value); + } + } + } +} diff --git a/backend/src/documents/mod.rs b/backend/src/documents/mod.rs new file mode 100644 index 0000000..97e08cb --- /dev/null +++ b/backend/src/documents/mod.rs @@ -0,0 +1,10 @@ +pub mod asset; +pub mod correspondents; +pub mod folders; +pub mod metadata; +pub mod ordering; +pub mod relations; +pub mod search; +pub mod tags; + +pub use ordering::{DocumentSortField, SortDirection}; diff --git a/backend/src/documents/ordering.rs b/backend/src/documents/ordering.rs new file mode 100644 index 0000000..1b895a2 --- /dev/null +++ b/backend/src/documents/ordering.rs @@ -0,0 +1,58 @@ +use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; + +pub const UNICODE_COLLATION_NAME: &str = "unicode_ci"; +pub const UNICODE_COLLATION_LOCALE: &str = "und-u-ks-level2"; + +const TITLE_ASC: &str = "title COLLATE \"unicode_ci\" ASC"; +const TITLE_DESC: &str = "title COLLATE \"unicode_ci\" DESC"; +const ISSUED_AT_ASC: &str = "issued_at ASC NULLS LAST"; +const ISSUED_AT_DESC: &str = "issued_at DESC NULLS LAST"; +const CREATED_AT_ASC: &str = "created_at ASC"; +const CREATED_AT_DESC: &str = "created_at DESC"; +const UPDATED_AT_ASC: &str = "updated_at ASC"; +const UPDATED_AT_DESC: &str = "updated_at DESC"; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize, Serialize, ToSchema)] +#[serde(rename_all = "snake_case")] +pub enum DocumentSortField { + Title, + IssuedAt, + CreatedAt, + UpdatedAt, +} + +impl Default for DocumentSortField { + fn default() -> Self { + DocumentSortField::Title + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize, Serialize, ToSchema)] +#[serde(rename_all = "snake_case")] +pub enum SortDirection { + Asc, + Desc, +} + +impl Default for SortDirection { + fn default() -> Self { + SortDirection::Asc + } +} + +pub fn ordering_clauses( + field: DocumentSortField, + direction: SortDirection, +) -> (&'static str, Option<&'static str>) { + match (field, direction) { + (DocumentSortField::Title, SortDirection::Asc) => (TITLE_ASC, None), + (DocumentSortField::Title, SortDirection::Desc) => (TITLE_DESC, None), + (DocumentSortField::IssuedAt, SortDirection::Asc) => (ISSUED_AT_ASC, Some(TITLE_ASC)), + (DocumentSortField::IssuedAt, SortDirection::Desc) => (ISSUED_AT_DESC, Some(TITLE_ASC)), + (DocumentSortField::CreatedAt, SortDirection::Asc) => (CREATED_AT_ASC, Some(TITLE_ASC)), + (DocumentSortField::CreatedAt, SortDirection::Desc) => (CREATED_AT_DESC, Some(TITLE_ASC)), + (DocumentSortField::UpdatedAt, SortDirection::Asc) => (UPDATED_AT_ASC, Some(TITLE_ASC)), + (DocumentSortField::UpdatedAt, SortDirection::Desc) => (UPDATED_AT_DESC, Some(TITLE_ASC)), + } +} diff --git a/backend/src/documents/relations.rs b/backend/src/documents/relations.rs new file mode 100644 index 0000000..22ae92a --- /dev/null +++ b/backend/src/documents/relations.rs @@ -0,0 +1,33 @@ +use std::collections::HashMap; + +use uuid::Uuid; + +use crate::documents::correspondents::{ + load_correspondents_for_documents, DocumentCorrespondentResponse, +}; +use crate::documents::tags::load_tags_for_documents; +use crate::error::AppResult; +use crate::models::Tag; +use crate::state::PgPooledConnection; + +/// Loads tags and correspondents for the provided documents in a single pass. +pub fn load_tags_and_correspondents( + conn: &mut PgPooledConnection, + document_ids: &[Uuid], +) -> AppResult, Vec)>> { + if document_ids.is_empty() { + return Ok(HashMap::new()); + } + + let tags_map = load_tags_for_documents(conn, document_ids)?; + let mut correspondents_map = load_correspondents_for_documents(conn, document_ids)?; + + let mut result = HashMap::with_capacity(document_ids.len()); + for id in document_ids { + let tags = tags_map.get(id).cloned().unwrap_or_default(); + let correspondents = correspondents_map.remove(id).unwrap_or_default(); + result.insert(*id, (tags, correspondents)); + } + + Ok(result) +} diff --git a/backend/src/documents/search.rs b/backend/src/documents/search.rs new file mode 100644 index 0000000..f238af7 --- /dev/null +++ b/backend/src/documents/search.rs @@ -0,0 +1,304 @@ +use std::collections::HashSet; + +use anyhow::{anyhow, bail, Result}; +use reqwest::{Client, StatusCode}; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; +use tracing::{debug, error}; +use uuid::Uuid; + +use crate::models::{Document, DocumentVersion}; + +pub const QUICKWIT_MAX_HITS: usize = 200; + +pub fn build_quickwit_query(input: &str) -> Option { + let tokens: Vec = input + .split_whitespace() + .filter(|token| !token.is_empty()) + .map(|token| { + let normalized = token.to_lowercase(); + escape_quickwit_token(&normalized) + }) + .collect(); + + if tokens.is_empty() { + return None; + } + + let parts: Vec = tokens + .into_iter() + .map(|token| format!("(title:{token} OR text:{token})")) + .collect(); + + Some(parts.join(" AND ")) +} + +pub fn escape_quickwit_token(token: &str) -> String { + let mut escaped = String::with_capacity(token.len()); + for ch in token.chars() { + match ch { + '+' | '-' | '&' | '|' | '!' | '(' | ')' | '{' | '}' | '[' | ']' | '^' | '"' | '~' + | '*' | '?' | ':' | '\\' | '/' => { + escaped.push('\\'); + escaped.push(ch); + } + _ => escaped.push(ch), + } + } + escaped +} + +pub async fn quickwit_search( + endpoint: &str, + index: &str, + tenant_id: Uuid, + query: &str, +) -> Result> { + let tenant_clause = format!("tenant_id:{}", tenant_id); + let quickwit_query = match build_quickwit_query(query) { + Some(q) => { + debug!(%query, quickwit_query = %q, "built quickwit search query"); + format!("{} AND ({})", tenant_clause, q) + } + None => { + debug!(%query, "quickwit search skipped because query produced no tokens"); + return Ok(vec![]); + } + }; + + let client = Client::new(); + let url = format!("{}/api/v1/{}/search", endpoint.trim_end_matches('/'), index); + + let payload = json!({ + "query": quickwit_query, + "max_hits": QUICKWIT_MAX_HITS, + }); + + debug!(%url, payload = %payload, "sending quickwit search request"); + let response = client.post(url).json(&payload).send().await?; + if !response.status().is_success() { + let status = response.status(); + let body = response.text().await.unwrap_or_default(); + error!(%status, body = %body, "quickwit search request failed"); + return Err(anyhow!( + "quickwit search failed with status {status}: {body}" + )); + } + + let data: QuickwitSearchResponse = response.json().await?; + debug!("quickwit search response parsed successfully"); + let QuickwitSearchResponse { hits } = data; + + let total_hits = hits.len(); + let mut seen = HashSet::new(); + let mut doc_ids = Vec::with_capacity(total_hits); + + for hit in hits { + if let Some(doc_id) = extract_document_id(&hit) { + if seen.insert(doc_id) { + doc_ids.push(doc_id); + } + } + } + + debug!( + total_hits = total_hits, + unique_ids = doc_ids.len(), + "quickwit search completed" + ); + Ok(doc_ids) +} + +pub fn quickwit_index_template(index_id: &str) -> Value { + json!({ + "version": "0.8", + "index_id": index_id, + "doc_mapping": { + "tokenizers": [ + { + "name": "substring", + "type": "ngram", + "min_gram": 2, + "max_gram": 20, + "prefix_only": false + } + ], + "field_mappings": [ + { "name": "tenant_id", "type": "text", "stored": true }, + { "name": "document_id", "type": "text", "stored": true }, + { "name": "version_id", "type": "text", "stored": true }, + { "name": "title", "type": "text", "tokenizer": "substring", "stored": true }, + { "name": "text", "type": "text", "tokenizer": "substring", "record": "position" } + ] + }, + "search_settings": { + "default_search_fields": ["title", "text"] + } + }) +} + +pub async fn ensure_quickwit_index(client: &Client, endpoint: &str, index_id: &str) -> Result<()> { + let base = endpoint.trim_end_matches('/'); + let create_url = format!("{}/api/v1/indexes", base); + let payload = quickwit_index_template(index_id); + + let response = client.post(&create_url).json(&payload).send().await?; + match response.status() { + status if status.is_success() => Ok(()), + StatusCode::CONFLICT => { + let lookup_url = format!("{}/api/v1/indexes/{}", base, index_id); + let lookup = client.get(&lookup_url).send().await?; + if lookup.status().is_success() { + Ok(()) + } else { + let status = lookup.status(); + let body = lookup.text().await.unwrap_or_default(); + bail!("quickwit index lookup failed with status {status}: {body}"); + } + } + status => { + let body = response.text().await.unwrap_or_default(); + bail!("quickwit create index failed with status {status}: {body}"); + } + } +} + +pub async fn delete_quickwit_index(client: &Client, endpoint: &str, index_id: &str) -> Result<()> { + let base = endpoint.trim_end_matches('/'); + let url = format!("{}/api/v1/indexes/{}", base, index_id); + let response = client.delete(&url).send().await?; + match response.status() { + status if status.is_success() => Ok(()), + StatusCode::NOT_FOUND => Ok(()), + status => { + let body = response.text().await.unwrap_or_default(); + bail!("quickwit delete index failed with status {status}: {body}"); + } + } +} + +pub fn extract_document_id(hit: &Value) -> Option { + for key in ["_source", "source", "fields", "stored_fields"] { + if let Some(value) = hit.get(key) { + if let Some(uuid) = extract_uuid_from_value(value) { + return Some(uuid); + } + } + } + + if let Some(value) = hit.get("document_id") { + if let Some(uuid) = extract_uuid_from_value(value) { + return Some(uuid); + } + } + + None +} + +pub fn extract_uuid_from_value(value: &Value) -> Option { + if let Some(obj) = value.as_object() { + if let Some(inner) = obj.get("document_id") { + return parse_uuid_value(inner); + } + } + + if let Some(arr) = value.as_array() { + for item in arr { + if let Some(uuid) = extract_uuid_from_value(item) { + return Some(uuid); + } + } + } + + parse_uuid_value(value) +} + +pub fn parse_uuid_value(value: &Value) -> Option { + if let Some(s) = value.as_str() { + return Uuid::parse_str(s).ok(); + } + + if let Some(arr) = value.as_array() { + for item in arr { + if let Some(uuid) = parse_uuid_value(item) { + return Some(uuid); + } + } + } + + None +} + +#[derive(Deserialize)] +struct QuickwitSearchResponse { + #[serde(default)] + hits: Vec, +} + +#[derive(Serialize)] +pub struct QuickwitIngestRecord { + pub document_id: Uuid, + pub version_id: Uuid, + pub tenant_id: Uuid, + pub title: String, + pub text: String, +} + +pub fn build_quickwit_ingest_record( + document: &Document, + version: &DocumentVersion, + tenant_id: Uuid, + text: &str, +) -> QuickwitIngestRecord { + QuickwitIngestRecord { + document_id: document.id, + version_id: version.id, + tenant_id, + title: document.title.to_lowercase(), + text: text.to_lowercase(), + } +} + +pub async fn quickwit_ingest( + client: &Client, + endpoint: &str, + index: &str, + records: &[QuickwitIngestRecord], +) -> Result<()> { + if records.is_empty() { + return Ok(()); + } + + let url = format!( + "{}/api/v1/{}/ingest?commit=auto", + endpoint.trim_end_matches('/'), + index + ); + + let mut body = String::new(); + for record in records { + let line = serde_json::to_string(record)?; + body.push_str(&line); + body.push('\n'); + } + + debug!(%url, lines = records.len(), "sending quickwit ingest request"); + let response = client + .post(url) + .header("content-type", "application/x-ndjson") + .body(body) + .send() + .await?; + + if !response.status().is_success() { + let status = response.status(); + let body = response.text().await.unwrap_or_default(); + error!(%status, %body, "quickwit ingest request failed"); + return Err(anyhow!( + "quickwit ingest failed with status {status}: {body}" + )); + } + + debug!("quickwit ingest request succeeded"); + Ok(()) +} diff --git a/backend/src/documents/tags.rs b/backend/src/documents/tags.rs new file mode 100644 index 0000000..2a35890 --- /dev/null +++ b/backend/src/documents/tags.rs @@ -0,0 +1,80 @@ +use std::collections::HashMap; + +use diesel::prelude::*; +use uuid::Uuid; + +use crate::error::{AppError, AppResult}; +use crate::models::{Document, NewDocumentTag, Tag}; +use crate::schema::{document_tags, tags}; + +pub fn assign_tags( + conn: &mut PgConnection, + tenant_id: Uuid, + document: &Document, + raw_tag_ids: &[Uuid], + assigned_by: Option, +) -> AppResult { + if raw_tag_ids.is_empty() { + return Ok(0); + } + + let mut tag_ids: Vec = raw_tag_ids.iter().copied().collect(); + tag_ids.sort_unstable(); + tag_ids.dedup(); + + if tag_ids.is_empty() { + return Ok(0); + } + + let existing: Vec = tags::table + .filter(tags::id.eq_any(&tag_ids)) + .filter(tags::tenant_id.eq(tenant_id)) + .select(tags::id) + .load(conn)?; + + if existing.len() != tag_ids.len() { + return Err(AppError::bad_request("one or more tags do not exist")); + } + + let new_tags: Vec = tag_ids + .into_iter() + .map(|tag_id| NewDocumentTag { + document_id: document.id, + tag_id, + assigned_by, + tenant_id, + }) + .collect(); + + if new_tags.is_empty() { + return Ok(0); + } + + let inserted = diesel::insert_into(document_tags::table) + .values(&new_tags) + .on_conflict_do_nothing() + .execute(conn)?; + + Ok(inserted) +} + +pub fn load_tags_for_documents( + conn: &mut PgConnection, + document_ids: &[Uuid], +) -> AppResult>> { + if document_ids.is_empty() { + return Ok(HashMap::new()); + } + + let rows: Vec<(Uuid, Tag)> = document_tags::table + .inner_join(tags::table) + .filter(document_tags::document_id.eq_any(document_ids)) + .select((document_tags::document_id, tags::all_columns)) + .load(conn)?; + + let mut map: HashMap> = HashMap::new(); + for (doc_id, tag) in rows { + map.entry(doc_id).or_default().push(tag); + } + Ok(map) +} diff --git a/backend/src/error.rs b/backend/src/error.rs new file mode 100644 index 0000000..3c4c855 --- /dev/null +++ b/backend/src/error.rs @@ -0,0 +1,128 @@ +use axum::{ + http::StatusCode, + response::{IntoResponse, Response}, + Json, +}; +use serde::Serialize; +use serde_json::Value; +use std::fmt::{self, Display}; +use utoipa::ToSchema; + +pub type AppResult = Result; + +#[derive(Debug)] +pub struct AppError { + status: StatusCode, + message: String, + code: Option, + details: Option, +} + +impl AppError { + pub fn new(status: StatusCode, message: impl Into) -> Self { + Self { + status, + message: message.into(), + code: None, + details: None, + } + } + + pub fn bad_request(message: impl Into) -> Self { + Self::new(StatusCode::BAD_REQUEST, message) + } + + pub fn conflict(message: impl Into) -> Self { + Self::new(StatusCode::CONFLICT, message) + } + + pub fn unauthorized() -> Self { + Self::new(StatusCode::UNAUTHORIZED, "unauthorized") + } + + pub fn forbidden(message: impl Into) -> Self { + Self::new(StatusCode::FORBIDDEN, message) + } + + pub fn not_found() -> Self { + Self::new(StatusCode::NOT_FOUND, "resource not found") + } + + pub fn internal(error: E) -> Self { + Self::new(StatusCode::INTERNAL_SERVER_ERROR, error.to_string()) + } + + pub fn with_code(mut self, code: impl Into) -> Self { + self.code = Some(code.into()); + self + } + + pub fn with_details(mut self, details: Value) -> Self { + self.details = Some(details); + self + } +} + +impl fmt::Display for AppError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}: {}", self.status, self.message) + } +} + +impl IntoResponse for AppError { + fn into_response(self) -> Response { + let status = self.status; + let body = Json(ApiErrorResponse { + error: self.message, + code: self.code, + details: self.details, + }); + (status, body).into_response() + } +} + +#[derive(Serialize, ToSchema)] +pub struct ApiErrorResponse { + error: String, + #[serde(skip_serializing_if = "Option::is_none")] + code: Option, + #[serde(skip_serializing_if = "Option::is_none")] + details: Option, +} + +impl From for AppError { + fn from(value: diesel::result::Error) -> Self { + match value { + diesel::result::Error::NotFound => AppError::not_found(), + other => { + let message = format!("database operation failed: {other}"); + tracing::error!(error = ?other, message); + AppError::internal(message) + } + } + } +} + +impl From for AppError { + fn from(value: jsonwebtoken::errors::Error) -> Self { + AppError::internal(value) + } +} + +impl From for AppError { + fn from(value: anyhow::Error) -> Self { + AppError::internal(value) + } +} + +impl From for AppError { + fn from(value: std::io::Error) -> Self { + AppError::internal(value) + } +} + +impl From for AppError { + fn from(value: serde_json::Error) -> Self { + AppError::internal(value) + } +} diff --git a/backend/src/http/mod.rs b/backend/src/http/mod.rs new file mode 100644 index 0000000..3e7b0d6 --- /dev/null +++ b/backend/src/http/mod.rs @@ -0,0 +1 @@ +pub mod responders; diff --git a/backend/src/http/responders.rs b/backend/src/http/responders.rs new file mode 100644 index 0000000..2d30d02 --- /dev/null +++ b/backend/src/http/responders.rs @@ -0,0 +1,157 @@ +use axum::{ + http::StatusCode, + response::{IntoResponse, Response}, + Json, +}; +use serde::Serialize; + +use crate::error::{AppError, AppResult}; + +/// Helper trait to convert error-centric results into the application's error type. +pub trait IntoAppResult { + fn into_app_result(self) -> AppResult; +} + +impl IntoAppResult for Result +where + AppError: From, +{ + fn into_app_result(self) -> AppResult { + self.map_err(AppError::from) + } +} + +/// Extension helpers for optional values to map them into `AppResult`. +pub trait OptionAppResultExt { + fn or_not_found(self) -> AppResult; + fn or_bad_request(self, message: impl Into) -> AppResult; +} + +impl OptionAppResultExt for Option { + fn or_not_found(self) -> AppResult { + self.ok_or_else(AppError::not_found) + } + + fn or_bad_request(self, message: impl Into) -> AppResult { + self.ok_or_else(|| AppError::bad_request(message)) + } +} + +/// Provides helpers for statements returning number of affected rows. +pub trait RowsAffectedExt: Sized { + fn or_error(self, error: AppError) -> AppResult; + fn or_not_found(self) -> AppResult { + self.or_error(AppError::not_found()) + } +} + +impl RowsAffectedExt for usize { + fn or_error(self, error: AppError) -> AppResult { + if self == 0 { + Err(error) + } else { + Ok(self) + } + } +} + +/// Wrapper providing a consistent JSON response with a status code. +pub struct JsonResponse { + status: StatusCode, + payload: T, +} + +impl JsonResponse { + pub fn new(status: StatusCode, payload: T) -> Self { + Self { status, payload } + } + + pub fn ok(payload: T) -> Self { + Self::new(StatusCode::OK, payload) + } + + pub fn created(payload: T) -> Self { + Self::new(StatusCode::CREATED, payload) + } + + pub fn accepted(payload: T) -> Self { + Self::new(StatusCode::ACCEPTED, payload) + } + + pub fn into_inner(self) -> T { + self.payload + } + + pub fn as_inner(&self) -> &T { + &self.payload + } +} + +impl From for JsonResponse { + fn from(value: T) -> Self { + Self::ok(value) + } +} + +impl IntoResponse for JsonResponse +where + T: Serialize, +{ + fn into_response(self) -> Response { + (self.status, Json(self.payload)).into_response() + } +} + +/// Helper for returning empty responses with a status code. +pub fn empty(status: StatusCode) -> AppResult { + Ok(status) +} + +/// Helper for returning `204 No Content`. +pub fn no_content() -> AppResult { + empty(StatusCode::NO_CONTENT) +} + +/// Helper for returning JSON payloads with `200 OK`. +pub fn ok_json(value: T) -> AppResult> +where + T: Serialize, +{ + Ok(JsonResponse::ok(value)) +} + +/// Helper for returning JSON payloads with `201 Created`. +pub fn created_json(value: T) -> AppResult> +where + T: Serialize, +{ + Ok(JsonResponse::created(value)) +} + +/// Helper for returning JSON payloads with `202 Accepted`. +pub fn accepted_json(value: T) -> AppResult> +where + T: Serialize, +{ + Ok(JsonResponse::accepted(value)) +} + +/// Standard wrapper for paginated responses. +#[derive(Serialize)] +pub struct PaginatedResponse +where + T: Serialize, + M: Serialize, +{ + pub data: T, + pub meta: M, +} + +pub fn paginated_json(data: T, meta: M) -> AppResult>> +where + T: Serialize, + M: Serialize, +{ + let payload = PaginatedResponse { data, meta }; + Ok(JsonResponse::ok(payload)) +} diff --git a/backend/src/issued_at.rs b/backend/src/issued_at.rs new file mode 100644 index 0000000..f70487e --- /dev/null +++ b/backend/src/issued_at.rs @@ -0,0 +1,167 @@ +use std::collections::HashSet; + +use chrono::{DateTime, Datelike, NaiveDate, TimeZone, Utc}; +use chrono_tz::Tz; +use once_cell::sync::Lazy; +use tracing::warn; + +use crate::config::AppConfig; + +#[derive(Clone, Copy, Debug)] +pub enum DateOrder { + Dmy, + Mdy, + Ymd, +} + +impl DateOrder { + pub fn parse(value: &str) -> Self { + Self::try_parse(value).unwrap_or(DateOrder::Dmy) + } + + pub fn try_parse(value: &str) -> Option { + match value.trim().to_ascii_uppercase().as_str() { + "YMD" => Some(DateOrder::Ymd), + "MDY" => Some(DateOrder::Mdy), + "DMY" => Some(DateOrder::Dmy), + _ => None, + } + } +} + +static MIN_ISSUED_AT_DATE: Lazy = + Lazy::new(|| NaiveDate::from_ymd_opt(1901, 1, 1).expect("valid minimum issued_at date")); + +#[derive(Clone, Debug)] +pub struct IssuedAtSettings { + pub timezone: Tz, + pub date_order: DateOrder, + pub filename_date_order: Option, + pub locales: HashSet, + pub ignore_dates: HashSet, + pub min_date: NaiveDate, +} + +impl IssuedAtSettings { + pub fn from_config(config: &AppConfig) -> Self { + let timezone = config.service_timezone.parse::().unwrap_or_else(|_| { + warn!( + timezone = %config.service_timezone, + "invalid service timezone configured; falling back to UTC" + ); + chrono_tz::UTC + }); + + let date_order = DateOrder::parse(&config.issued_at_date_order); + let filename_date_order = + config + .issued_at_filename_date_order + .as_deref() + .and_then(|value| { + DateOrder::try_parse(value).or_else(|| { + warn!(value, "invalid issued_at filename date order; ignoring"); + None + }) + }); + + let locales = config + .issued_at_date_parser_locales + .iter() + .filter_map(|value| { + let trimmed = value.trim(); + if trimmed.is_empty() { + None + } else { + Some(trimmed.to_ascii_lowercase()) + } + }) + .collect::>(); + + // Ignore dates are evaluated after normalizing candidate timestamps to + // the configured service timezone, so administrators should provide + // local calendar dates rather than UTC midnights. + let ignore_dates = config + .issued_at_ignore_dates + .iter() + .filter_map(|value| { + let trimmed = value.trim(); + if trimmed.is_empty() { + return None; + } + match NaiveDate::parse_from_str(trimmed, "%Y-%m-%d") { + Ok(date) => Some(date), + Err(err) => { + warn!(value = trimmed, error = %err, "invalid issued_at ignore date"); + None + } + } + }) + .collect(); + + Self { + timezone, + date_order, + filename_date_order, + locales, + ignore_dates, + min_date: *MIN_ISSUED_AT_DATE, + } + } + + /// Returns the immutable (lowercase) locale allowlist supplied via config. + pub fn locales(&self) -> &HashSet { + &self.locales + } + + /// Returns the immutable set of local-calendar dates that should be ignored. + pub fn ignore_dates(&self) -> &HashSet { + &self.ignore_dates + } + + /// Returns the configured service timezone (copy type). + pub fn timezone(&self) -> Tz { + self.timezone + } + + pub fn date_order(&self) -> DateOrder { + self.date_order + } + + pub fn filename_date_order(&self) -> Option { + self.filename_date_order + } + + pub fn normalize_naive( + &self, + date: NaiveDate, + now_utc: chrono::DateTime, + ) -> Option> { + if !self.is_valid_with_now(date, now_utc) { + return None; + } + self.timezone + .with_ymd_and_hms(date.year(), date.month(), date.day(), 0, 0, 0) + .earliest() + .map(|dt| dt.with_timezone(&Utc)) + } + + pub fn normalize_datetime( + &self, + dt: chrono::DateTime, + now_utc: chrono::DateTime, + ) -> Option> { + let local_date = dt.with_timezone(&self.timezone).date_naive(); + self.normalize_naive(local_date, now_utc) + } + + fn is_valid_with_now(&self, date: NaiveDate, now_utc: chrono::DateTime) -> bool { + if date < self.min_date { + return false; + } + let now_local = now_utc.with_timezone(&self.timezone).date_naive(); + if date > now_local { + return false; + } + !self.ignore_dates.contains(&date) + } +} diff --git a/backend/src/jobs.rs b/backend/src/jobs.rs new file mode 100644 index 0000000..9d1df2a --- /dev/null +++ b/backend/src/jobs.rs @@ -0,0 +1,133 @@ +use std::time::Duration; + +use chrono::{Duration as ChronoDuration, NaiveDateTime, Utc}; +use diesel::pg::PgConnection; +use diesel::prelude::*; +use serde_json::Value; +use thiserror::Error; +use uuid::Uuid; + +use crate::models::{Job, NewJob}; +use crate::schema::jobs; + +pub const STATUS_QUEUED: &str = "queued"; +pub const STATUS_PROCESSING: &str = "processing"; +pub const STATUS_SUCCEEDED: &str = "succeeded"; +pub const STATUS_FAILED: &str = "failed"; + +pub const JOB_ANALYZE_DOCUMENT: &str = "analyze-document"; +pub const JOB_GENERATE_THUMBNAILS: &str = "generate-thumbnails"; +pub const JOB_GENERATE_OCR_TEXT: &str = "generate-ocr-text"; +pub const JOB_PROVISION_TENANT: &str = "provision-tenant"; +pub const JOB_PURGE_DOCUMENT: &str = "purge-document"; +pub const JOB_DELETE_TENANT: &str = "delete-tenant"; + +#[derive(Debug, Error)] +pub enum JobQueueError { + #[error("database error: {0}")] + Database(#[from] diesel::result::Error), +} + +pub type JobQueueResult = Result; + +pub fn enqueue_job( + conn: &mut PgConnection, + tenant_id: Uuid, + job_type: &str, + payload: Value, + run_after: Option, +) -> JobQueueResult { + let new_job = NewJob { + id: Uuid::new_v4(), + job_type: job_type.to_string(), + payload, + status: STATUS_QUEUED.to_string(), + run_after: run_after.unwrap_or_else(|| Utc::now().naive_utc()), + tenant_id, + }; + + diesel::insert_into(jobs::table) + .values(&new_job) + .execute(conn)?; + + let job = jobs::table.find(new_job.id).first(conn)?; + Ok(job) +} + +pub fn reserve_job(conn: &mut PgConnection, job_types: &[&str]) -> JobQueueResult> { + let now = Utc::now().naive_utc(); + + conn.transaction(|conn| { + let job_opt = jobs::table + .filter(jobs::status.eq(STATUS_QUEUED)) + .filter(jobs::run_after.le(now)) + .filter(jobs::job_type.eq_any(job_types)) + .order(jobs::run_after.asc()) + .for_update() + .skip_locked() + .first::(conn) + .optional()?; + + if let Some(job) = job_opt { + diesel::update(jobs::table.find(job.id)) + .set(( + jobs::status.eq(STATUS_PROCESSING), + jobs::attempts.eq(job.attempts + 1), + jobs::updated_at.eq(now), + )) + .execute(conn)?; + + let refreshed = jobs::table.find(job.id).first(conn)?; + Ok::, diesel::result::Error>(Some(refreshed)) + } else { + Ok::, diesel::result::Error>(None) + } + }) + .map_err(JobQueueError::from) +} + +pub fn mark_job_succeeded(conn: &mut PgConnection, job_id: Uuid) -> JobQueueResult<()> { + diesel::update(jobs::table.find(job_id)) + .set(( + jobs::status.eq(STATUS_SUCCEEDED), + jobs::last_error.eq::>(None), + jobs::updated_at.eq(Utc::now().naive_utc()), + )) + .execute(conn)?; + Ok(()) +} + +pub fn retry_job_after( + conn: &mut PgConnection, + job_id: Uuid, + delay: Duration, + error_message: &str, +) -> JobQueueResult<()> { + let next_run = Utc::now() + + ChronoDuration::from_std(delay).unwrap_or_else(|_| ChronoDuration::seconds(30)); + + diesel::update(jobs::table.find(job_id)) + .set(( + jobs::status.eq(STATUS_QUEUED), + jobs::run_after.eq(next_run.naive_utc()), + jobs::last_error.eq(Some(error_message.to_string())), + jobs::updated_at.eq(Utc::now().naive_utc()), + )) + .execute(conn)?; + Ok(()) +} + +pub fn mark_job_failed( + conn: &mut PgConnection, + job_id: Uuid, + error_message: &str, +) -> JobQueueResult<()> { + diesel::update(jobs::table.find(job_id)) + .set(( + jobs::status.eq(STATUS_FAILED), + jobs::last_error.eq(Some(error_message.to_string())), + jobs::updated_at.eq(Utc::now().naive_utc()), + )) + .execute(conn)?; + Ok(()) +} diff --git a/backend/src/lib.rs b/backend/src/lib.rs new file mode 100644 index 0000000..fc18edd --- /dev/null +++ b/backend/src/lib.rs @@ -0,0 +1,22 @@ +pub mod auth; +pub mod config; +pub mod db; +pub mod documents; +pub mod error; +pub mod http; +pub mod issued_at; +pub mod jobs; +pub mod models; +pub mod openapi; +pub mod routes; +pub mod s3; +pub mod schema; +pub mod services; +pub mod state; +pub mod storage; +pub mod tenants; +pub mod utils; +pub mod workers; +pub use workers::{default_handlers, Worker}; +pub mod migrations; +pub mod test_support; diff --git a/backend/src/main.rs b/backend/src/main.rs new file mode 100644 index 0000000..212c59e --- /dev/null +++ b/backend/src/main.rs @@ -0,0 +1,28 @@ +use std::net::SocketAddr; + +use tokio::net::TcpListener; +use tower::make::Shared; + +use papercrate::{routes, utils::bootstrap::init_component}; + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + let state = init_component("api", None).await?; + let server_host = state.config.server_host.clone(); + let server_port = state.config.server_port; + tracing::info!( + component = "api", + server_host = %server_host, + server_port, + "starting api server" + ); + + let router = routes::create_router(state.as_ref().clone()); + + let addr: SocketAddr = format!("{}:{}", server_host, server_port).parse()?; + let listener = TcpListener::bind(addr).await?; + tracing::info!("listening on {}", addr); + + axum::serve(listener, Shared::new(router)).await?; + Ok(()) +} diff --git a/backend/src/migrations.rs b/backend/src/migrations.rs new file mode 100644 index 0000000..d10cb83 --- /dev/null +++ b/backend/src/migrations.rs @@ -0,0 +1,3 @@ +use diesel_migrations::{embed_migrations, EmbeddedMigrations}; + +pub const MIGRATIONS: EmbeddedMigrations = embed_migrations!("migrations"); diff --git a/backend/src/models.rs b/backend/src/models.rs new file mode 100644 index 0000000..c850062 --- /dev/null +++ b/backend/src/models.rs @@ -0,0 +1,775 @@ +use chrono::NaiveDateTime; +use diesel::deserialize::FromSql; +use diesel::pg::{Pg, PgValue}; +use diesel::prelude::*; +use diesel::serialize::{IsNull, Output, ToSql}; +use diesel::{deserialize, serialize, AsExpression, FromSqlRow}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::fmt; +use std::io::Write; +use std::str; +use uuid::Uuid; + +use utoipa::ToSchema; + +use crate::schema::sql_types::{ + ApiCapability as ApiCapabilitySql, MagicTokenKind as MagicTokenKindSql, + TenantStatus as TenantStatusSql, +}; +use crate::schema::*; + +#[derive(Debug, Clone, Queryable, Identifiable, Associations)] +#[diesel(table_name = user_memberships)] +#[diesel(belongs_to(User, foreign_key = user_id))] +#[diesel(belongs_to(Tenant, foreign_key = tenant_id))] +pub struct UserMembership { + pub id: Uuid, + pub user_id: Uuid, + pub tenant_id: Uuid, + pub created_at: NaiveDateTime, + pub updated_at: NaiveDateTime, + pub capability_set_id: Option, +} + +#[derive(Debug, Insertable)] +#[diesel(table_name = user_memberships)] +pub struct NewUserMembership { + pub id: Uuid, + pub user_id: Uuid, + pub tenant_id: Uuid, + pub capability_set_id: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, AsExpression, FromSqlRow)] +#[diesel(sql_type = TenantStatusSql)] +pub enum TenantStatus { + Creating, + Active, + Suspended, + Deleting, + Error, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, AsExpression, FromSqlRow)] +#[diesel(sql_type = MagicTokenKindSql)] +pub enum MagicTokenKind { + EmailLogin, + DemoLogin, +} + +#[derive( + Debug, + Clone, + Copy, + PartialEq, + Eq, + Hash, + AsExpression, + FromSqlRow, + Serialize, + Deserialize, + ToSchema, +)] +#[diesel(sql_type = ApiCapabilitySql)] +pub enum ApiCapability { + #[serde(rename = "documents:read")] + DocumentsRead, + #[serde(rename = "documents:edit")] + DocumentsEdit, + #[serde(rename = "documents:write")] + DocumentsWrite, + #[serde(rename = "documents:upload")] + DocumentsUpload, + #[serde(rename = "folders:read")] + FoldersRead, + #[serde(rename = "folders:edit")] + FoldersEdit, + #[serde(rename = "folders:write")] + FoldersWrite, + #[serde(rename = "tags:read")] + TagsRead, + #[serde(rename = "tags:edit")] + TagsEdit, + #[serde(rename = "tags:write")] + TagsWrite, + #[serde(rename = "correspondents:read")] + CorrespondentsRead, + #[serde(rename = "correspondents:edit")] + CorrespondentsEdit, + #[serde(rename = "correspondents:write")] + CorrespondentsWrite, + #[serde(rename = "profile:read")] + ProfileRead, + #[serde(rename = "profile:write")] + ProfileWrite, + #[serde(rename = "webdav:read")] + WebdavRead, + #[serde(rename = "webdav:write")] + WebdavWrite, + #[serde(rename = "capability_sets:read")] + CapabilitySetsRead, + #[serde(rename = "capability_sets:write")] + CapabilitySetsWrite, + #[serde(rename = "tenants:write")] + TenantsWrite, + #[serde(rename = "tenants:reset")] + TenantsReset, + #[serde(rename = "tenants:delete")] + TenantsDelete, +} + +impl MagicTokenKind { + pub fn as_str(&self) -> &'static str { + match self { + MagicTokenKind::EmailLogin => "email_login", + MagicTokenKind::DemoLogin => "demo_login", + } + } + + pub fn variants() -> &'static [&'static str] { + &["email_login", "demo_login"] + } +} + +impl ApiCapability { + pub fn as_str(&self) -> &'static str { + match self { + ApiCapability::DocumentsRead => "documents:read", + ApiCapability::DocumentsEdit => "documents:edit", + ApiCapability::DocumentsWrite => "documents:write", + ApiCapability::DocumentsUpload => "documents:upload", + ApiCapability::FoldersRead => "folders:read", + ApiCapability::FoldersEdit => "folders:edit", + ApiCapability::FoldersWrite => "folders:write", + ApiCapability::TagsRead => "tags:read", + ApiCapability::TagsEdit => "tags:edit", + ApiCapability::TagsWrite => "tags:write", + ApiCapability::CorrespondentsRead => "correspondents:read", + ApiCapability::CorrespondentsEdit => "correspondents:edit", + ApiCapability::CorrespondentsWrite => "correspondents:write", + ApiCapability::ProfileRead => "profile:read", + ApiCapability::ProfileWrite => "profile:write", + ApiCapability::WebdavRead => "webdav:read", + ApiCapability::WebdavWrite => "webdav:write", + ApiCapability::CapabilitySetsRead => "capability_sets:read", + ApiCapability::CapabilitySetsWrite => "capability_sets:write", + ApiCapability::TenantsWrite => "tenants:write", + ApiCapability::TenantsReset => "tenants:reset", + ApiCapability::TenantsDelete => "tenants:delete", + } + } + + pub fn variants() -> &'static [&'static str] { + &[ + "documents:read", + "documents:edit", + "documents:write", + "documents:upload", + "folders:read", + "folders:edit", + "folders:write", + "tags:read", + "tags:edit", + "tags:write", + "correspondents:read", + "correspondents:edit", + "correspondents:write", + "profile:read", + "profile:write", + "webdav:read", + "webdav:write", + "capability_sets:read", + "capability_sets:write", + "tenants:write", + "tenants:reset", + "tenants:delete", + ] + } +} + +impl fmt::Display for MagicTokenKind { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.as_str()) + } +} + +impl fmt::Display for ApiCapability { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.as_str()) + } +} + +impl ToSql for MagicTokenKind { + fn to_sql<'b>(&'b self, out: &mut Output<'b, '_, Pg>) -> serialize::Result { + out.write_all(self.as_str().as_bytes())?; + Ok(IsNull::No) + } +} + +impl ToSql for ApiCapability { + fn to_sql<'b>(&'b self, out: &mut Output<'b, '_, Pg>) -> serialize::Result { + out.write_all(self.as_str().as_bytes())?; + Ok(IsNull::No) + } +} + +impl FromSql for MagicTokenKind { + fn from_sql(bytes: PgValue<'_>) -> deserialize::Result { + match std::str::from_utf8(bytes.as_bytes())? { + "email_login" => Ok(MagicTokenKind::EmailLogin), + "demo_login" => Ok(MagicTokenKind::DemoLogin), + other => Err(Box::new(std::io::Error::new( + std::io::ErrorKind::InvalidData, + format!("invalid magic_token_kind '{other}'"), + ))), + } + } +} + +impl FromSql for ApiCapability { + fn from_sql(bytes: PgValue<'_>) -> deserialize::Result { + match std::str::from_utf8(bytes.as_bytes())? { + "documents:read" => Ok(ApiCapability::DocumentsRead), + "documents:edit" => Ok(ApiCapability::DocumentsEdit), + "documents:write" => Ok(ApiCapability::DocumentsWrite), + "documents:upload" => Ok(ApiCapability::DocumentsUpload), + "folders:read" => Ok(ApiCapability::FoldersRead), + "folders:edit" => Ok(ApiCapability::FoldersEdit), + "folders:write" => Ok(ApiCapability::FoldersWrite), + "tags:read" => Ok(ApiCapability::TagsRead), + "tags:edit" => Ok(ApiCapability::TagsEdit), + "tags:write" => Ok(ApiCapability::TagsWrite), + "correspondents:read" => Ok(ApiCapability::CorrespondentsRead), + "correspondents:edit" => Ok(ApiCapability::CorrespondentsEdit), + "correspondents:write" => Ok(ApiCapability::CorrespondentsWrite), + "profile:read" => Ok(ApiCapability::ProfileRead), + "profile:write" => Ok(ApiCapability::ProfileWrite), + "webdav:read" => Ok(ApiCapability::WebdavRead), + "webdav:write" => Ok(ApiCapability::WebdavWrite), + "capability_sets:read" => Ok(ApiCapability::CapabilitySetsRead), + "capability_sets:write" => Ok(ApiCapability::CapabilitySetsWrite), + "tenants:write" => Ok(ApiCapability::TenantsWrite), + "tenants:reset" => Ok(ApiCapability::TenantsReset), + "tenants:delete" => Ok(ApiCapability::TenantsDelete), + other => Err(Box::new(std::io::Error::new( + std::io::ErrorKind::InvalidData, + format!("invalid api_capability '{other}'"), + ))), + } + } +} + +impl str::FromStr for MagicTokenKind { + type Err = &'static str; + + fn from_str(value: &str) -> Result { + match value { + "email_login" => Ok(MagicTokenKind::EmailLogin), + "demo_login" => Ok(MagicTokenKind::DemoLogin), + _ => Err("unsupported magic token kind"), + } + } +} + +impl str::FromStr for ApiCapability { + type Err = &'static str; + + fn from_str(value: &str) -> Result { + match value { + "documents:read" => Ok(ApiCapability::DocumentsRead), + "documents:edit" => Ok(ApiCapability::DocumentsEdit), + "documents:write" => Ok(ApiCapability::DocumentsWrite), + "documents:upload" => Ok(ApiCapability::DocumentsUpload), + "folders:read" => Ok(ApiCapability::FoldersRead), + "folders:edit" => Ok(ApiCapability::FoldersEdit), + "folders:write" => Ok(ApiCapability::FoldersWrite), + "tags:read" => Ok(ApiCapability::TagsRead), + "tags:edit" => Ok(ApiCapability::TagsEdit), + "tags:write" => Ok(ApiCapability::TagsWrite), + "correspondents:read" => Ok(ApiCapability::CorrespondentsRead), + "correspondents:edit" => Ok(ApiCapability::CorrespondentsEdit), + "correspondents:write" => Ok(ApiCapability::CorrespondentsWrite), + "profile:read" => Ok(ApiCapability::ProfileRead), + "profile:write" => Ok(ApiCapability::ProfileWrite), + "webdav:read" => Ok(ApiCapability::WebdavRead), + "webdav:write" => Ok(ApiCapability::WebdavWrite), + "capability_sets:read" => Ok(ApiCapability::CapabilitySetsRead), + "capability_sets:write" => Ok(ApiCapability::CapabilitySetsWrite), + "tenants:write" => Ok(ApiCapability::TenantsWrite), + "tenants:reset" => Ok(ApiCapability::TenantsReset), + "tenants:delete" => Ok(ApiCapability::TenantsDelete), + _ => Err("unsupported api capability"), + } + } +} + +impl TenantStatus { + pub fn as_str(&self) -> &'static str { + match self { + TenantStatus::Creating => "creating", + TenantStatus::Active => "active", + TenantStatus::Suspended => "suspended", + TenantStatus::Deleting => "deleting", + TenantStatus::Error => "error", + } + } + + pub fn from_str(value: &str) -> Option { + match value { + "creating" => Some(TenantStatus::Creating), + "active" => Some(TenantStatus::Active), + "suspended" => Some(TenantStatus::Suspended), + "deleting" => Some(TenantStatus::Deleting), + "error" => Some(TenantStatus::Error), + _ => None, + } + } +} + +impl fmt::Display for TenantStatus { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.as_str()) + } +} + +impl ToSql for TenantStatus { + fn to_sql<'b>(&'b self, out: &mut Output<'b, '_, Pg>) -> serialize::Result { + out.write_all(self.as_str().as_bytes())?; + Ok(IsNull::No) + } +} + +impl FromSql for TenantStatus { + fn from_sql(bytes: PgValue<'_>) -> deserialize::Result { + let value = str::from_utf8(bytes.as_bytes()) + .map_err(|err| Box::::from(err))?; + TenantStatus::from_str(value).ok_or_else(|| { + Box::::from(std::io::Error::new( + std::io::ErrorKind::InvalidData, + format!("invalid tenant status '{value}'"), + )) + }) + } +} + +#[derive(Debug, Clone, Queryable, Identifiable)] +#[diesel(table_name = tenants)] +#[diesel(primary_key(id))] +pub struct Tenant { + pub id: Uuid, + pub name: String, + pub storage_root: Option, + pub quickwit_index: Option, + pub config: Value, + pub created_at: NaiveDateTime, + pub updated_at: NaiveDateTime, + pub status: TenantStatus, + pub created_by: Option, +} + +#[derive(Debug, Clone, Queryable, Identifiable)] +#[diesel(table_name = users)] +pub struct User { + pub id: Uuid, + pub username: String, + pub created_at: NaiveDateTime, + pub updated_at: NaiveDateTime, +} + +#[derive(Debug, Insertable)] +#[diesel(table_name = users)] +pub struct NewUser { + pub id: Uuid, + pub username: String, +} + +#[derive(Debug, Clone, Queryable, Identifiable, Associations, Selectable)] +#[diesel(table_name = user_passkeys)] +#[diesel(belongs_to(User))] +pub struct UserPasskey { + pub id: Uuid, + pub user_id: Uuid, + pub credential_id: Vec, + pub public_key: Vec, + pub credential: serde_json::Value, + pub sign_count: i64, + pub transports: Vec>, + pub aaguid: Option, + pub nickname: Option, + pub created_at: NaiveDateTime, + pub updated_at: NaiveDateTime, + pub last_used_at: Option, + pub revoked_at: Option, + pub revoked_by: Option, + pub revoked_reason: Option, +} + +#[derive(Debug, Insertable)] +#[diesel(table_name = user_passkeys)] +pub struct NewUserPasskey { + pub id: Uuid, + pub user_id: Uuid, + pub credential_id: Vec, + pub public_key: Vec, + pub credential: serde_json::Value, + pub sign_count: i64, + pub transports: Vec>, + pub aaguid: Option, + pub nickname: Option, +} + +#[derive(Debug, Clone, Queryable, Identifiable, Associations)] +#[diesel(table_name = webauthn_challenges)] +#[diesel(belongs_to(User))] +pub struct WebauthnChallenge { + pub id: Uuid, + pub user_id: Option, + pub purpose: String, + pub challenge: Vec, + pub state: Vec, + pub created_at: NaiveDateTime, + pub expires_at: NaiveDateTime, +} + +#[derive(Debug, Insertable)] +#[diesel(table_name = webauthn_challenges)] +pub struct NewWebauthnChallenge { + pub id: Uuid, + pub user_id: Option, + pub purpose: String, + pub challenge: Vec, + pub state: Vec, + pub expires_at: NaiveDateTime, +} + +#[derive(Debug, Clone, Queryable, Identifiable, Associations)] +#[diesel(table_name = api_tokens)] +#[diesel(belongs_to(User))] +#[diesel(belongs_to(Tenant))] +pub struct ApiToken { + pub id: Uuid, + pub user_id: Uuid, + pub tenant_id: Uuid, + pub token_prefix: String, + pub token_hash: String, + pub label: Option, + pub created_at: NaiveDateTime, + pub last_used_at: Option, + pub expires_at: Option, + pub revoked_at: Option, + pub capability_set_id: Uuid, +} + +#[derive(Debug, Clone, Queryable, Identifiable, Associations)] +#[diesel(table_name = capability_sets)] +#[diesel(belongs_to(Tenant))] +pub struct CapabilitySet { + pub id: Uuid, + pub tenant_id: Uuid, + pub slug: String, + pub cap_version: i32, + pub is_system: bool, + pub created_at: NaiveDateTime, + pub updated_at: NaiveDateTime, +} + +#[derive(Debug, Insertable)] +#[diesel(table_name = capability_sets)] +pub struct NewCapabilitySet { + pub id: Uuid, + pub tenant_id: Uuid, + pub slug: String, + pub cap_version: i32, + pub is_system: bool, +} + +#[derive(Debug, Clone, Queryable, Identifiable, Associations)] +#[diesel(table_name = capability_set_capabilities)] +#[diesel(primary_key(capability_set_id, capability))] +#[diesel(belongs_to(CapabilitySet, foreign_key = capability_set_id))] +pub struct CapabilitySetCapability { + pub capability_set_id: Uuid, + pub capability: ApiCapability, +} + +#[derive(Debug, Insertable)] +#[diesel(table_name = capability_set_capabilities)] +pub struct NewCapabilitySetCapability { + pub capability_set_id: Uuid, + pub capability: ApiCapability, +} + +#[derive(Debug, Insertable)] +#[diesel(table_name = api_tokens)] +pub struct NewApiToken { + pub id: Uuid, + pub user_id: Uuid, + pub tenant_id: Uuid, + pub token_prefix: String, + pub token_hash: String, + pub label: Option, + pub expires_at: Option, + pub capability_set_id: Uuid, +} + +#[derive(Debug, Clone, Queryable, Identifiable)] +#[diesel(table_name = folders)] +pub struct Folder { + pub id: Uuid, + pub name: String, + pub parent_id: Option, + pub created_at: NaiveDateTime, + pub updated_at: NaiveDateTime, + pub tenant_id: Uuid, +} + +#[derive(Debug, Insertable)] +#[diesel(table_name = folders)] +pub struct NewFolder { + pub id: Uuid, + pub name: String, + pub parent_id: Option, + pub tenant_id: Uuid, +} + +#[derive(Debug, Clone, Queryable, Identifiable, Associations)] +#[diesel(table_name = documents)] +#[diesel(belongs_to(Folder, foreign_key = folder_id))] +pub struct Document { + pub id: Uuid, + pub filename: String, + pub original_name: String, + pub mime_type: Option, + pub folder_id: Option, + pub created_at: NaiveDateTime, + pub updated_at: NaiveDateTime, + pub deleted_at: Option, + pub metadata: serde_json::Value, + pub issued_at: Option, + pub title: String, + pub current_version_id: Uuid, + pub tenant_id: Uuid, +} + +#[derive(Debug, Insertable)] +#[diesel(table_name = documents)] +pub struct NewDocument { + pub id: Uuid, + pub filename: String, + pub original_name: String, + pub mime_type: Option, + pub folder_id: Option, + pub current_version_id: Uuid, + pub metadata: serde_json::Value, + pub issued_at: Option, + pub title: String, + pub tenant_id: Uuid, +} + +#[derive(Debug, Clone, Queryable, Identifiable, Insertable)] +#[diesel(table_name = magic_tokens)] +pub struct MagicToken { + pub id: Uuid, + pub user_id: Uuid, + pub kind: MagicTokenKind, + pub token_hash: String, + pub metadata: serde_json::Value, + pub expires_at: NaiveDateTime, + pub max_uses: Option, + pub used_count: i32, + pub created_at: NaiveDateTime, + pub created_by: Option, + pub last_used_at: Option, +} + +#[derive(Debug, Clone, Queryable, Identifiable, Associations)] +#[diesel(table_name = document_versions)] +#[diesel(belongs_to(Document))] +pub struct DocumentVersion { + pub id: Uuid, + pub document_id: Uuid, + pub version_number: i32, + pub s3_key: String, + pub size_bytes: i64, + pub checksum: String, + pub created_at: NaiveDateTime, + pub metadata: serde_json::Value, + pub tenant_id: Uuid, +} + +#[derive(Debug, Insertable)] +#[diesel(table_name = document_versions)] +pub struct NewDocumentVersion { + pub id: Uuid, + pub document_id: Uuid, + pub version_number: i32, + pub s3_key: String, + pub size_bytes: i64, + pub checksum: String, + pub metadata: serde_json::Value, + pub tenant_id: Uuid, +} + +#[derive(Debug, Clone, Queryable, Identifiable, Associations)] +#[diesel(table_name = document_assets)] +#[diesel(belongs_to(DocumentVersion, foreign_key = document_version_id))] +pub struct DocumentAsset { + pub id: Uuid, + pub document_version_id: Uuid, + pub asset_type: String, + pub mime_type: String, + pub metadata: serde_json::Value, + pub created_at: NaiveDateTime, + pub s3_key: String, + pub tenant_id: Uuid, +} + +#[derive(Debug, Insertable)] +#[diesel(table_name = document_assets)] +pub struct NewDocumentAsset { + pub id: Uuid, + pub document_version_id: Uuid, + pub asset_type: String, + pub mime_type: String, + pub metadata: serde_json::Value, + pub s3_key: String, + pub tenant_id: Uuid, +} + +#[derive(Debug, Clone, Queryable, Identifiable)] +#[diesel(table_name = jobs)] +pub struct Job { + pub id: Uuid, + pub job_type: String, + pub payload: serde_json::Value, + pub status: String, + pub attempts: i32, + pub run_after: NaiveDateTime, + pub last_error: Option, + pub created_at: NaiveDateTime, + pub updated_at: NaiveDateTime, + pub tenant_id: Option, + pub result: Option, +} + +#[derive(Debug, Insertable)] +#[diesel(table_name = jobs)] +pub struct NewJob { + pub id: Uuid, + pub job_type: String, + pub payload: serde_json::Value, + pub status: String, + pub run_after: NaiveDateTime, + pub tenant_id: Uuid, +} + +#[derive(Debug, Clone, Queryable, Identifiable)] +#[diesel(table_name = tags)] +pub struct Tag { + pub id: Uuid, + pub label: String, + pub color: Option, + pub created_at: NaiveDateTime, + pub tenant_id: Uuid, +} + +#[derive(Debug, Insertable)] +#[diesel(table_name = tags)] +pub struct NewTag { + pub id: Uuid, + pub label: String, + pub color: Option, + pub tenant_id: Uuid, +} + +#[allow(dead_code)] +#[derive(Debug, Clone, Queryable, Associations)] +#[diesel(table_name = document_tags)] +#[diesel(belongs_to(Document))] +#[diesel(belongs_to(Tag))] +#[diesel(primary_key(document_id, tag_id))] +pub struct DocumentTag { + pub document_id: Uuid, + pub tag_id: Uuid, + pub assigned_at: NaiveDateTime, + pub assigned_by: Option, + pub tenant_id: Uuid, +} + +#[derive(Debug, Insertable)] +#[diesel(table_name = document_tags)] +pub struct NewDocumentTag { + pub document_id: Uuid, + pub tag_id: Uuid, + pub assigned_by: Option, + pub tenant_id: Uuid, +} + +#[derive(Debug, Clone, Queryable, Identifiable)] +#[diesel(table_name = correspondents)] +pub struct Correspondent { + pub id: Uuid, + pub name: String, + pub metadata: serde_json::Value, + pub created_at: NaiveDateTime, + pub updated_at: NaiveDateTime, + pub tenant_id: Uuid, +} + +#[derive(Debug, Insertable)] +#[diesel(table_name = correspondents)] +pub struct NewCorrespondent { + pub id: Uuid, + pub name: String, + pub metadata: serde_json::Value, + pub tenant_id: Uuid, +} + +#[derive(Debug, Clone, Queryable, Associations)] +#[diesel(table_name = document_correspondents)] +#[diesel(belongs_to(Document))] +#[diesel(belongs_to(Correspondent))] +#[diesel(primary_key(document_id, correspondent_id))] +pub struct DocumentCorrespondent { + pub document_id: Uuid, + pub correspondent_id: Uuid, + pub assigned_at: NaiveDateTime, + pub assigned_by: Option, + pub tenant_id: Uuid, +} + +#[derive(Debug, Insertable)] +#[diesel(table_name = document_correspondents)] +pub struct NewDocumentCorrespondent { + pub document_id: Uuid, + pub correspondent_id: Uuid, + pub assigned_by: Option, + pub tenant_id: Uuid, +} + +#[derive(Debug, Clone, Queryable, Identifiable, Associations)] +#[diesel(table_name = user_sessions)] +#[diesel(belongs_to(User))] +pub struct UserSession { + pub id: Uuid, + pub user_id: Uuid, + pub token_hash: String, + pub issued_at: NaiveDateTime, + pub expires_at: NaiveDateTime, + pub revoked_at: Option, + pub created_at: NaiveDateTime, + pub updated_at: NaiveDateTime, + pub tenant_id: Uuid, +} + +#[derive(Debug, Insertable)] +#[diesel(table_name = user_sessions)] +pub struct NewUserSession { + pub id: Uuid, + pub user_id: Uuid, + pub token_hash: String, + pub issued_at: NaiveDateTime, + pub expires_at: NaiveDateTime, + pub tenant_id: Uuid, +} diff --git a/backend/src/openapi.rs b/backend/src/openapi.rs new file mode 100644 index 0000000..0466ce8 --- /dev/null +++ b/backend/src/openapi.rs @@ -0,0 +1,135 @@ +use utoipa::openapi::{self, tag::TagBuilder, InfoBuilder}; +use utoipa::OpenApi; + +pub struct ApiDoc; + +impl OpenApi for ApiDoc { + fn openapi() -> openapi::OpenApi { + let mut doc = crate::routes::health::HealthApiDoc::openapi(); + doc.merge(crate::routes::auth::AuthApiDoc::openapi()); + doc.merge(crate::routes::documents::DocumentsApiDoc::openapi()); + doc.merge(crate::routes::folders::FoldersApiDoc::openapi()); + doc.merge(crate::routes::tags::TagsApiDoc::openapi()); + doc.merge(crate::routes::correspondents::CorrespondentsApiDoc::openapi()); + doc.merge(crate::routes::profile::ProfileApiDoc::openapi()); + doc.merge(crate::routes::capability_sets::CapabilitySetsApiDoc::openapi()); + doc.merge(crate::routes::tenants::TenantsApiDoc::openapi()); + + doc.info = InfoBuilder::new() + .title("Papercrate API") + .version(env!("CARGO_PKG_VERSION")) + .build(); + + doc.tags = Some(vec![ + TagBuilder::new() + .name("Health") + .description(Some("Service health")) + .build(), + TagBuilder::new() + .name("Auth") + .description(Some("Authentication")) + .build(), + TagBuilder::new() + .name("Documents") + .description(Some("Document management")) + .build(), + TagBuilder::new() + .name("Assets") + .description(Some("Document assets")) + .build(), + TagBuilder::new() + .name("Folders") + .description(Some("Folder management")) + .build(), + TagBuilder::new() + .name("Tags") + .description(Some("Tag catalog")) + .build(), + TagBuilder::new() + .name("Correspondents") + .description(Some("Correspondent catalog")) + .build(), + TagBuilder::new() + .name("Profile") + .description(Some("User profile and WebDAV tokens")) + .build(), + TagBuilder::new() + .name("Capability Sets") + .description(Some("Capability set management")) + .build(), + TagBuilder::new() + .name("Tenants") + .description(Some("Tenant catalog")) + .build(), + ]); + + doc + } +} + +pub mod schemas { + pub use crate::auth::passkeys::{ + AuthenticationChallengeResponse, PasskeyLoginFinishPayload, PasskeyLoginStartPayload, + PasskeyRegistrationFinishPayload, PasskeySummary, RegistrationChallengeResponse, + }; + pub use crate::auth::AuthenticatedUser; + pub use crate::documents::asset::{ + DocumentAssetDetailResponse, DocumentAssetResponse, DocumentVersionDetailResponse, + DocumentVersionResponse, + }; + pub use crate::documents::correspondents::DocumentCorrespondentResponse; + pub use crate::error::ApiErrorResponse; + pub use crate::models::ApiCapability; + pub use crate::routes::correspondents::{ + CorrespondentSummary, CreateCorrespondentRequest, + UpdateCorrespondentRequest, + }; + pub use crate::routes::documents::{ + AssetRequestQuery, DocumentCheckQuery, MoveDocumentRequest, RestoreDocumentRequest, + UploadDocumentForm, + }; + pub use crate::routes::folders::FolderContentsResponse; + pub use crate::routes::tags::{CreateTagRequest, TagCatalogEntry, UpdateTagRequest}; + pub use crate::services::auth::{ + ApiTokenExchangeRequest, LoginRequest, LoginResponse, LoginResponseVariants, + SignupFinishRequest, SignupStartRequest, SignupStartResponse, TenantListResponse, + TenantSelectionRequest, TenantSelectionResponse, TenantSnippet, + }; + pub use crate::services::capability_sets::{ + CapabilitySetResponse, CreateCapabilitySetRequest, UpdateCapabilitySetRequest, + }; + pub use crate::services::correspondents::{ + AssignCorrespondentsRequest, BulkCorrespondentAction, BulkCorrespondentResponse, + BulkCorrespondentsRequest, CorrespondentAssignmentInput, + }; + pub use crate::services::documents::{ + BulkMoveRequest, BulkMoveResponse, BulkReanalyzeResponse, BulkReanalyzeSelectionRequest, + DocumentCheckResponse, DocumentDetailResponse, DocumentListQuery, DocumentMetadataUpdate, + DocumentResponse, DocumentStatusFilter, TagResponse, UpdateDocumentRequest, + }; + pub use crate::services::folders::{ + CreateFolderRequest, EnsureFolderPathRequest, FolderContentsQuery, FolderInfo, + UpdateFolderRequest, + }; + pub use crate::services::profile::{ + ApiTokenCreatedResponse, ApiTokenResponse, CreateApiTokenRequest, RevokePasskeyQuery, + }; + pub use crate::services::tags::{ + AssignTagsRequest, BulkTagAction, BulkTagRequest, BulkTagResponse, + }; + pub use crate::services::tenants::{ + TenantUserListResponse, TenantUserSummary, UpdateTenantRequest, UpdateTenantUserRequest, + }; +} + +#[cfg(test)] +mod tests { + use super::ApiDoc; + use utoipa::OpenApi; + + #[test] + fn openapi_serializes() { + let spec = ApiDoc::openapi(); + let _ = serde_json::to_string(&spec).expect("serialize openapi"); + } +} diff --git a/backend/src/routes/auth.rs b/backend/src/routes/auth.rs new file mode 100644 index 0000000..5531c56 --- /dev/null +++ b/backend/src/routes/auth.rs @@ -0,0 +1,260 @@ +use axum::{ + extract::State, + http::{HeaderMap, StatusCode}, + response::Response, + Json, +}; +use axum_extra::{ + headers::{authorization::Bearer, Authorization, Cookie}, + typed_header::TypedHeader, +}; +use utoipa::OpenApi; + +use crate::{ + auth::{ + passkeys::{ + AuthenticationChallengeResponse, PasskeyLoginFinishPayload, PasskeyLoginStartPayload, + PasskeyRegistrationFinishPayload, PasskeySummary, RegistrationChallengeResponse, + }, + AuthenticatedUser, TenantScopedConn, + }, + error::{AppError, AppResult}, + http::responders::JsonResponse, + services::auth::{ + ApiTokenExchangeRequest, AuthService, LoginRequest, LoginResponse, LoginResponseVariants, + SignupFinishRequest, SignupStartRequest, SignupStartResponse, TenantListResponse, + TenantSelectionRequest, TenantSelectionResponse, TenantSnippet, SESSION_COOKIE_NAME, + }, + state::AppState, +}; + +#[derive(OpenApi)] +#[openapi( + paths( + login, + api_token_exchange, + signup_start, + signup_finish, + refresh, + logout, + me, + select_tenant, + passkey_register_start, + passkey_register_finish, + passkey_login_start, + passkey_login_finish, + ), + components(schemas( + LoginRequest, + ApiTokenExchangeRequest, + SignupStartRequest, + SignupStartResponse, + SignupFinishRequest, + LoginResponse, + LoginResponseVariants, + TenantSnippet, + TenantSelectionResponse, + TenantSelectionRequest, + TenantListResponse, + crate::auth::AuthenticatedUser, + crate::auth::passkeys::RegistrationChallengeResponse, + crate::auth::passkeys::AuthenticationChallengeResponse, + crate::auth::passkeys::PasskeySummary, + crate::auth::passkeys::PasskeyRegistrationFinishPayload, + crate::auth::passkeys::PasskeyLoginStartPayload, + crate::auth::passkeys::PasskeyLoginFinishPayload, + crate::models::ApiCapability, + )) +)] +pub struct AuthApiDoc; + +#[utoipa::path( + post, + path = "/api/auth/login", + request_body = LoginRequest, + responses( + (status = 200, description = "Login succeeded", body = LoginResponseVariants), + (status = 401, description = "Invalid credentials") + ), + tag = "Auth" +)] +pub async fn login( + State(state): State, + Json(payload): Json, +) -> AppResult { + AuthService::new(&state).login(payload) +} + +#[utoipa::path( + post, + path = "/api/auth/exchange-api-token", + request_body = ApiTokenExchangeRequest, + responses((status = 200, description = "Access token issued", body = LoginResponse)), + tag = "Auth" +)] +pub async fn api_token_exchange( + State(state): State, + Json(payload): Json, +) -> AppResult> { + AuthService::new(&state).exchange_api_token(payload) +} + +#[utoipa::path( + post, + path = "/api/auth/signup/start", + request_body = SignupStartRequest, + responses( + (status = 200, description = "Signup challenge created", body = SignupStartResponse), + (status = 400, description = "Invalid signup request"), + (status = 409, description = "Username already exists") + ), + tag = "Auth" +)] +pub async fn signup_start( + State(state): State, + Json(payload): Json, +) -> AppResult> { + AuthService::new(&state).signup_start(payload) +} + +#[utoipa::path( + post, + path = "/api/auth/signup/finish", + request_body = SignupFinishRequest, + responses( + (status = 200, description = "Signup completed", body = LoginResponseVariants), + (status = 400, description = "Invalid signup completion"), + (status = 409, description = "Username already exists") + ), + tag = "Auth" +)] +pub async fn signup_finish( + State(state): State, + Json(payload): Json, +) -> AppResult { + AuthService::new(&state).signup_finish(payload) +} + +#[utoipa::path( + post, + path = "/api/auth/refresh", + responses( + (status = 200, description = "Refreshed access token", body = LoginResponse), + (status = 401, description = "Missing or invalid refresh token") + ), + tag = "Auth" +)] +pub async fn refresh( + State(state): State, + jar: Option>, +) -> AppResult { + let cookies = jar.ok_or_else(AppError::unauthorized)?; + let refresh_value = cookies + .get(SESSION_COOKIE_NAME) + .ok_or_else(AppError::unauthorized)?; + + AuthService::new(&state).refresh(refresh_value) +} + +#[utoipa::path( + post, + path = "/api/auth/select-tenant", + request_body = TenantSelectionRequest, + responses((status = 200, description = "Tenant selected", body = LoginResponse)), + tag = "Auth" +)] +pub async fn select_tenant( + State(state): State, + TypedHeader(Authorization(bearer)): TypedHeader>, + Json(payload): Json, +) -> AppResult { + AuthService::new(&state).select_tenant(bearer.token(), payload.tenant_id) +} + +#[utoipa::path( + post, + path = "/api/auth/logout", + responses((status = 204, description = "Session revoked")), + tag = "Auth" +)] +pub async fn logout( + State(state): State, + TenantScopedConn { mut conn, user, .. }: TenantScopedConn, + jar: Option>, +) -> AppResult<(HeaderMap, StatusCode)> { + let refresh_cookie = jar.as_ref().and_then(|cookies| { + cookies + .get(SESSION_COOKIE_NAME) + .map(|value| value.to_owned()) + }); + AuthService::new(&state).logout(&mut conn, &user, refresh_cookie.as_deref()) +} + +#[utoipa::path( + get, + path = "/api/auth/me", + responses((status = 200, description = "Current session", body = crate::auth::AuthenticatedUser)), + tag = "Auth" +)] +pub async fn me(user: AuthenticatedUser) -> Json { + Json(user) +} + +#[utoipa::path( + post, + path = "/api/auth/passkeys/register/start", + responses((status = 200, body = crate::auth::passkeys::RegistrationChallengeResponse)), + tag = "Auth" +)] +pub async fn passkey_register_start( + State(state): State, + user: AuthenticatedUser, +) -> AppResult> { + AuthService::new(&state).passkey_register_start(user) +} + +#[utoipa::path( + post, + path = "/api/auth/passkeys/register/finish", + request_body = crate::auth::passkeys::PasskeyRegistrationFinishPayload, + responses((status = 201, body = crate::auth::passkeys::PasskeySummary)), + tag = "Auth" +)] +pub async fn passkey_register_finish( + State(state): State, + user: AuthenticatedUser, + Json(payload): Json, +) -> AppResult> { + AuthService::new(&state).passkey_register_finish(user, payload) +} + +#[utoipa::path( + post, + path = "/api/auth/passkeys/login/start", + request_body = crate::auth::passkeys::PasskeyLoginStartPayload, + responses((status = 200, body = crate::auth::passkeys::AuthenticationChallengeResponse)), + tag = "Auth" +)] +pub async fn passkey_login_start( + State(state): State, + Json(payload): Json, +) -> AppResult> { + AuthService::new(&state).passkey_login_start(&payload.username) +} + +#[utoipa::path( + post, + path = "/api/auth/passkeys/login/finish", + request_body = crate::auth::passkeys::PasskeyLoginFinishPayload, + responses( + (status = 200, description = "Passkey login successful", body = LoginResponseVariants), + (status = 401, description = "Authentication failed") + ), + tag = "Auth" +)] +pub async fn passkey_login_finish( + State(state): State, + Json(payload): Json, +) -> AppResult { + AuthService::new(&state).passkey_login_finish(payload) +} diff --git a/backend/src/routes/capability_sets.rs b/backend/src/routes/capability_sets.rs new file mode 100644 index 0000000..74ab95b --- /dev/null +++ b/backend/src/routes/capability_sets.rs @@ -0,0 +1,134 @@ +use axum::{extract::Path, http::StatusCode, Json}; +use utoipa::OpenApi; +use uuid::Uuid; + +use crate::{ + auth::TenantScopedConn, + error::AppResult, + http::responders::JsonResponse, + services::capability_sets::{ + CapabilitySetResponse, CapabilitySetService, CreateCapabilitySetRequest, + UpdateCapabilitySetRequest, + }, +}; + +#[utoipa::path( + get, + path = "/api/capability-sets", + responses((status = 200, body = [CapabilitySetResponse])), + tag = "Capability Sets" +)] +pub async fn list_capability_sets( + TenantScopedConn { + mut conn, + tenant_id, + .. + }: TenantScopedConn, +) -> AppResult>> { + CapabilitySetService::new().list(&mut conn, tenant_id) +} + +#[utoipa::path( + get, + path = "/api/capabilities", + responses((status = 200, body = [crate::models::ApiCapability])), + tag = "Capability Sets" +)] +pub async fn list_capabilities( + TenantScopedConn { .. }: TenantScopedConn, +) -> AppResult>> { + CapabilitySetService::new().list_capabilities() +} + +#[utoipa::path( + get, + path = "/api/capability-sets/{id}", + params(("id" = Uuid, Path, description = "Capability set ID")), + responses((status = 200, body = CapabilitySetResponse), (status = 404, description = "Not found")), + tag = "Capability Sets" +)] +pub async fn get_capability_set( + TenantScopedConn { + mut conn, + tenant_id, + .. + }: TenantScopedConn, + Path(id): Path, +) -> AppResult> { + CapabilitySetService::new().get(&mut conn, tenant_id, id) +} + +#[utoipa::path( + post, + path = "/api/capability-sets", + request_body = CreateCapabilitySetRequest, + responses((status = 201, body = CapabilitySetResponse)), + tag = "Capability Sets" +)] +pub async fn create_capability_set( + TenantScopedConn { + mut conn, + tenant_id, + .. + }: TenantScopedConn, + Json(payload): Json, +) -> AppResult> { + CapabilitySetService::new().create(&mut conn, tenant_id, payload) +} + +#[utoipa::path( + patch, + path = "/api/capability-sets/{id}", + params(("id" = Uuid, Path, description = "Capability set ID")), + request_body = UpdateCapabilitySetRequest, + responses((status = 200, body = CapabilitySetResponse)), + tag = "Capability Sets" +)] +pub async fn update_capability_set( + TenantScopedConn { + mut conn, + tenant_id, + .. + }: TenantScopedConn, + Path(id): Path, + Json(payload): Json, +) -> AppResult> { + CapabilitySetService::new().update(&mut conn, tenant_id, id, payload) +} + +#[utoipa::path( + delete, + path = "/api/capability-sets/{id}", + params(("id" = Uuid, Path, description = "Capability set ID")), + responses((status = 204), (status = 409, description = "Set in use")), + tag = "Capability Sets" +)] +pub async fn delete_capability_set( + TenantScopedConn { + mut conn, + tenant_id, + .. + }: TenantScopedConn, + Path(id): Path, +) -> AppResult { + CapabilitySetService::new().delete(&mut conn, tenant_id, id) +} + +#[derive(OpenApi)] +#[openapi( + paths( + crate::routes::capability_sets::list_capability_sets, + crate::routes::capability_sets::list_capabilities, + crate::routes::capability_sets::get_capability_set, + crate::routes::capability_sets::create_capability_set, + crate::routes::capability_sets::update_capability_set, + crate::routes::capability_sets::delete_capability_set, + ), + components(schemas( + crate::models::ApiCapability, + crate::services::capability_sets::CapabilitySetResponse, + crate::services::capability_sets::CreateCapabilitySetRequest, + crate::services::capability_sets::UpdateCapabilitySetRequest, + )) +)] +pub struct CapabilitySetsApiDoc; diff --git a/backend/src/routes/correspondents.rs b/backend/src/routes/correspondents.rs new file mode 100644 index 0000000..be3f8c6 --- /dev/null +++ b/backend/src/routes/correspondents.rs @@ -0,0 +1,312 @@ +use std::collections::HashMap; + +use axum::{extract::Path, http::StatusCode, Json}; +use chrono::Utc; +use diesel::{dsl::count_star, prelude::*, result::DatabaseErrorKind, PgConnection}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use utoipa::ToSchema; +use uuid::Uuid; + +use crate::{ + auth::TenantScopedConn, + error::{AppError, AppResult}, + http::responders::{no_content, ok_json, IntoAppResult, JsonResponse, RowsAffectedExt}, + models::{Correspondent, NewCorrespondent}, + schema::{correspondents, document_correspondents}, + utils::{ + named_entity::{ensure_name_available, normalize_name}, + time::to_iso, + }, +}; + +#[derive(Serialize, ToSchema)] +pub struct CorrespondentSummary { + pub id: Uuid, + pub name: String, + #[schema(value_type = Object)] + pub metadata: Value, + pub created_at: String, + pub updated_at: String, + pub usage_count: i64, +} + +#[derive(Deserialize, ToSchema)] +pub struct CreateCorrespondentRequest { + pub name: String, + #[serde(default)] + #[schema(nullable, value_type = Object)] + pub metadata: Option, +} + +#[derive(Deserialize, ToSchema)] +pub struct UpdateCorrespondentRequest { + #[schema(nullable)] + pub name: Option, + #[schema(nullable, value_type = Object)] + pub metadata: Option, +} + +#[derive(AsChangeset, Default)] +#[diesel(table_name = correspondents)] +struct CorrespondentChangeset<'a> { + name: Option<&'a str>, + metadata: Option<&'a Value>, +} + +#[utoipa::path( + get, + path = "/api/correspondents", + responses((status = 200, description = "Correspondents", body = [CorrespondentSummary])), + tag = "Correspondents" +)] +pub async fn list_correspondents( + TenantScopedConn { + mut conn, + tenant_id, + .. + }: TenantScopedConn, +) -> AppResult>> { + let correspondents_list: Vec = correspondents::table + .filter(correspondents::tenant_id.eq(tenant_id)) + .order(correspondents::name.asc()) + .load(&mut conn)?; + + let usage_rows: Vec<(Uuid, i64)> = document_correspondents::table + .filter(document_correspondents::tenant_id.eq(tenant_id)) + .group_by(document_correspondents::correspondent_id) + .select((document_correspondents::correspondent_id, count_star())) + .load(&mut conn)?; + + let mut usage_map: HashMap = HashMap::new(); + for (correspondent_id, count) in usage_rows { + usage_map.insert(correspondent_id, count); + } + + let mut response = Vec::with_capacity(correspondents_list.len()); + for correspondent in correspondents_list { + let total = usage_map.remove(&correspondent.id).unwrap_or(0); + response.push(build_summary(correspondent, total)); + } + + ok_json(response) +} + +#[utoipa::path( + post, + path = "/api/correspondents", + request_body = CreateCorrespondentRequest, + responses((status = 200, description = "Correspondent created", body = CorrespondentSummary)), + tag = "Correspondents" +)] +pub async fn create_correspondent( + TenantScopedConn { + mut conn, + tenant_id, + .. + }: TenantScopedConn, + Json(payload): Json, +) -> AppResult> { + let name = normalize_name(&payload.name, || { + AppError::bad_request("name must not be empty") + })?; + + let metadata_value = normalize_metadata(payload.metadata); + let new_id = Uuid::new_v4(); + let new_correspondent = NewCorrespondent { + id: new_id, + name: name.clone(), + metadata: metadata_value, + tenant_id, + }; + + match diesel::insert_into(correspondents::table) + .values(&new_correspondent) + .execute(&mut conn) + { + Ok(_) => {} + Err(diesel::result::Error::DatabaseError(DatabaseErrorKind::UniqueViolation, _)) => { + return Err(AppError::bad_request("correspondent name already exists")); + } + Err(err) => return Err(AppError::from(err)), + } + + let correspondent: Correspondent = correspondents::table + .find(new_id) + .filter(correspondents::tenant_id.eq(tenant_id)) + .first(&mut conn) + .into_app_result()?; + + ok_json(build_summary(correspondent, 0)) +} + +#[utoipa::path( + patch, + path = "/api/correspondents/{id}", + params(("id" = Uuid, Path, description = "Correspondent ID")), + request_body = UpdateCorrespondentRequest, + responses((status = 200, description = "Correspondent updated", body = CorrespondentSummary)), + tag = "Correspondents" +)] +pub async fn update_correspondent( + Path(correspondent_id): Path, + TenantScopedConn { + mut conn, + tenant_id, + .. + }: TenantScopedConn, + Json(payload): Json, +) -> AppResult> { + let existing: Correspondent = correspondents::table + .find(correspondent_id) + .filter(correspondents::tenant_id.eq(tenant_id)) + .first(&mut conn) + .into_app_result()?; + + let mut new_name: Option = None; + if let Some(ref candidate) = payload.name { + let normalized = normalize_name(candidate, || { + AppError::bad_request("name must not be empty") + })?; + if normalized != existing.name { + ensure_name_available( + || { + correspondents::table + .filter(correspondents::name.eq(&normalized)) + .filter(correspondents::id.ne(correspondent_id)) + .filter(correspondents::tenant_id.eq(tenant_id)) + .first::(&mut conn) + .optional() + }, + || AppError::bad_request("correspondent name already exists"), + )?; + new_name = Some(normalized); + } + } + + let mut new_metadata: Option = None; + if let Some(metadata) = payload.metadata.clone() { + let candidate = normalize_metadata(Some(metadata)); + if candidate != existing.metadata { + new_metadata = Some(candidate); + } + } + + if new_name.is_none() && new_metadata.is_none() { + let usage = load_usage_for_correspondent(&mut conn, tenant_id, correspondent_id)?; + return ok_json(build_summary(existing.clone(), usage)); + } + + let mut changeset = CorrespondentChangeset::default(); + if let Some(ref name) = new_name { + changeset.name = Some(name.as_str()); + } + if let Some(ref metadata) = new_metadata { + changeset.metadata = Some(metadata); + } + + let now = Utc::now().naive_utc(); + diesel::update( + correspondents::table + .find(correspondent_id) + .filter(correspondents::tenant_id.eq(tenant_id)), + ) + .set((&changeset, correspondents::updated_at.eq(now))) + .execute(&mut conn) + .into_app_result()? + .or_not_found()?; + + let updated: Correspondent = correspondents::table + .find(correspondent_id) + .filter(correspondents::tenant_id.eq(tenant_id)) + .first(&mut conn) + .into_app_result()?; + let usage = load_usage_for_correspondent(&mut conn, tenant_id, correspondent_id)?; + ok_json(build_summary(updated, usage)) +} + +#[utoipa::path( + delete, + path = "/api/correspondents/{id}", + params(("id" = Uuid, Path, description = "Correspondent ID")), + responses((status = 204, description = "Correspondent deleted")), + tag = "Correspondents" +)] +pub async fn delete_correspondent( + Path(correspondent_id): Path, + TenantScopedConn { + mut conn, + tenant_id, + .. + }: TenantScopedConn, +) -> AppResult { + let usage: i64 = document_correspondents::table + .filter(document_correspondents::tenant_id.eq(tenant_id)) + .filter(document_correspondents::correspondent_id.eq(correspondent_id)) + .select(count_star()) + .first(&mut conn)?; + + if usage > 0 { + return Err(AppError::bad_request( + "cannot delete correspondent that is still assigned to documents", + )); + } + + diesel::delete( + correspondents::table + .filter(correspondents::id.eq(correspondent_id)) + .filter(correspondents::tenant_id.eq(tenant_id)), + ) + .execute(&mut conn) + .into_app_result()? + .or_not_found()?; + no_content() +} + +fn build_summary(correspondent: Correspondent, usage_count: i64) -> CorrespondentSummary { + CorrespondentSummary { + id: correspondent.id, + name: correspondent.name, + metadata: correspondent.metadata, + created_at: to_iso(correspondent.created_at), + updated_at: to_iso(correspondent.updated_at), + usage_count, + } +} + +fn normalize_metadata(input: Option) -> Value { + match input { + None | Some(Value::Null) => Value::Object(Default::default()), + Some(value) => value, + } +} + +fn load_usage_for_correspondent( + conn: &mut PgConnection, + tenant_id: Uuid, + correspondent_id: Uuid, +) -> AppResult { + let total: i64 = document_correspondents::table + .filter(document_correspondents::correspondent_id.eq(correspondent_id)) + .filter(document_correspondents::tenant_id.eq(tenant_id)) + .select(count_star()) + .get_result(conn)?; + + Ok(total) +} + +#[derive(utoipa::OpenApi)] +#[openapi( + paths( + crate::routes::correspondents::list_correspondents, + crate::routes::correspondents::create_correspondent, + crate::routes::correspondents::update_correspondent, + crate::routes::correspondents::delete_correspondent + ), + components(schemas( + crate::routes::correspondents::CorrespondentSummary, + crate::routes::correspondents::CreateCorrespondentRequest, + crate::routes::correspondents::UpdateCorrespondentRequest + )) +)] +pub struct CorrespondentsApiDoc; diff --git a/backend/src/routes/documents.rs b/backend/src/routes/documents.rs new file mode 100644 index 0000000..46419e9 --- /dev/null +++ b/backend/src/routes/documents.rs @@ -0,0 +1,1152 @@ +use std::{collections::HashSet, time::Duration}; + +use axum::body::Body; +use axum::extract::{Json, Multipart, Path, Query, State}; +use axum::http::{header, HeaderMap, HeaderValue, StatusCode}; +use axum::response::{IntoResponse, Response}; +use chrono::{DateTime, NaiveDateTime, Utc}; +use diesel::dsl::exists; +use diesel::{prelude::*, select}; +use futures_util::StreamExt; +use serde::Deserialize; +use serde_json::Value; +use tracing::{error, info}; +use utoipa::{IntoParams, ToSchema}; +use uuid::Uuid; + +use crate::auth::{ensure_active_tenant_with_conn, jwt::DownloadSubject, TenantScopedConn}; +use crate::documents::asset::{ + asset_disposition, DocumentAssetDetailResponse, DocumentAssetResponse, + DocumentVersionDetailResponse, DocumentVersionResponse, DownloadLink, +}; +#[allow(unused_imports)] +use crate::error::ApiErrorResponse; +use crate::error::{AppError, AppResult}; +use crate::http::responders::{accepted_json, created_json, no_content, ok_json, JsonResponse}; +use crate::models::{Document, DocumentAsset, DocumentVersion}; +use crate::schema::{ + document_assets, document_versions, documents, user_sessions::dsl as session_dsl, +}; +use crate::services::correspondents::{ + AssignCorrespondentsRequest, BulkCorrespondentAction, BulkCorrespondentResponse, + BulkCorrespondentsRequest, CorrespondentAssignmentInput, CorrespondentsService, +}; +use crate::services::documents::{ + BulkMoveRequest, BulkMoveResponse, BulkReanalyzeResponse, BulkReanalyzeSelectionRequest, + DocumentCheckResponse, DocumentDetailResponse, DocumentListQuery, DocumentMetadataUpdate, + DocumentResponse, DocumentUploadOutcome, DocumentUploadRequest, DocumentsService, +}; +use crate::services::tags::{ + AssignTagsRequest, BulkTagAction, BulkTagRequest, BulkTagResponse, TagsService, +}; +use crate::state::AppState; +use crate::storage::TenantStorage; +use crate::utils::{error::StorageResultExt, http::inline_content_disposition}; + +const PRESIGNED_URL_EXPIRY_SECONDS: u64 = 300; + +#[derive(Deserialize, IntoParams, ToSchema)] +#[into_params(parameter_in = Query)] +pub struct AssetRequestQuery { + #[serde(default)] + #[schema(default = false)] + pub force: bool, +} + +#[derive(Deserialize, IntoParams, ToSchema)] +#[into_params(parameter_in = Query)] +pub struct DocumentCheckQuery { + pub checksum: String, +} + +#[derive(ToSchema)] +pub struct UploadDocumentForm { + #[schema(value_type = String, format = Binary)] + pub file: String, + #[schema(nullable)] + pub folder_id: Option, + #[schema(nullable, value_type = Object)] + pub metadata: Option, + #[schema(nullable)] + pub title: Option, + #[schema(nullable, value_type = Vec)] + pub tag_ids: Option>, + #[schema(nullable, value_type = Vec)] + pub correspondents: Option>, + #[schema(nullable, example = "2024-01-01T00:00:00Z")] + pub issued_at: Option, + #[schema(nullable, default = true)] + pub skip_existing: Option, +} + +#[derive(Deserialize, ToSchema)] +pub struct MoveDocumentRequest { + pub folder_id: Option, +} + +#[derive(Deserialize, ToSchema)] +pub struct RestoreDocumentRequest { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub folder_id: Option, +} + +#[utoipa::path( + get, + path = "/api/documents", + params(DocumentListQuery), + responses((status = 200, description = "List documents", body = [DocumentResponse])), + tag = "Documents" +)] +pub async fn list_documents( + State(state): State, + Query(params): Query, + TenantScopedConn { + mut conn, + tenant_id, + user_id, + .. + }: TenantScopedConn, +) -> AppResult>> { + let service = DocumentsService::new(&state); + let documents = service + .list_documents(&mut conn, tenant_id, user_id, params) + .await?; + ok_json(documents) +} + +#[utoipa::path( + get, + path = "/api/documents/check", + params(DocumentCheckQuery), + responses((status = 200, description = "Checksum lookup", body = DocumentCheckResponse)), + tag = "Documents" +)] +pub async fn check_document( + State(state): State, + Query(query): Query, + TenantScopedConn { + mut conn, + tenant_id, + .. + }: TenantScopedConn, +) -> AppResult> { + let checksum_raw = query.checksum.trim(); + if checksum_raw.is_empty() { + return Err(AppError::bad_request("checksum must not be empty")); + } + + let checksum = checksum_raw.to_ascii_lowercase(); + if !checksum.chars().all(|ch| ch.is_ascii_hexdigit()) { + return Err(AppError::bad_request( + "checksum must be a hex-encoded string", + )); + } + + let service = DocumentsService::new(&state); + let response = service.check_document(&mut conn, tenant_id, &checksum)?; + ok_json(response) +} + +#[utoipa::path( + get, + path = "/api/documents/{id}", + params(("id" = Uuid, Path, description = "Document ID")), + responses((status = 200, description = "Document detail", body = DocumentDetailResponse)), + tag = "Documents" +)] +pub async fn get_document( + State(state): State, + Path(document_id): Path, + TenantScopedConn { + mut conn, + tenant_id, + user_id, + .. + }: TenantScopedConn, +) -> AppResult> { + let service = DocumentsService::new(&state); + let detail = service + .get_document_detail(&mut conn, tenant_id, user_id, document_id) + .await?; + ok_json(detail) +} + +#[utoipa::path( + post, + path = "/api/documents", + request_body = UploadDocumentForm, + responses( + (status = 201, description = "Document created", body = DocumentDetailResponse), + (status = 200, description = "Existing document reused", body = DocumentDetailResponse), + ( + status = 409, + description = "Document with identical contents already exists", + body = ApiErrorResponse + ) + ), + tag = "Documents" +)] +pub async fn upload_document( + State(state): State, + TenantScopedConn { + mut conn, + tenant_id, + user_id, + .. + }: TenantScopedConn, + mut multipart: Multipart, +) -> AppResult> { + let tenant_id = tenant_id; + let user_id = user_id; + let mut file_bytes: Option> = None; + let mut original_name: Option = None; + let mut mime_type: Option = None; + let mut folder_id: Option = None; + let mut metadata: Value = Value::Object(Default::default()); + let mut tag_ids: Vec = Vec::new(); + let mut correspondents: Vec = Vec::new(); + let mut issued_at_override: Option = None; + let mut skip_if_existing = true; + let mut title_override: Option = None; + + while let Some(field) = multipart.next_field().await.map_err(|err| { + let msg = format!("invalid multipart data: {err}"); + error!(error = %err, "invalid multipart data"); + AppError::bad_request(msg) + })? { + let name = field.name().map(|n| n.to_string()); + match name.as_deref() { + Some("file") => { + let file_name = field.file_name().map(|n| n.to_string()); + original_name = file_name.clone(); + mime_type = field.content_type().map(|mime| mime.to_string()); + let data = field.bytes().await.map_err(|err| { + let msg = format!("failed to read file bytes: {err}"); + error!(error = %err, "failed to read file bytes"); + AppError::bad_request(msg) + })?; + file_bytes = Some(data.to_vec()); + } + Some("folder_id") => { + let value = field.text().await.map_err(|err| { + let msg = format!("invalid folder id: {err}"); + error!(error = %err, "invalid folder id"); + AppError::bad_request(msg) + })?; + if !value.trim().is_empty() { + let parsed = Uuid::parse_str(value.trim()) + .map_err(|_| AppError::bad_request("folder_id must be a valid UUID"))?; + folder_id = Some(parsed); + } + } + Some("metadata") => { + let value = field.text().await.map_err(|err| { + let msg = format!("invalid metadata: {err}"); + error!(error = %err, "invalid metadata payload"); + AppError::bad_request(msg) + })?; + metadata = serde_json::from_str(&value).map_err(|err| { + let msg = format!("metadata must be valid JSON: {err}"); + error!(error = %err, "metadata parse failure"); + AppError::bad_request(msg) + })?; + } + Some("title") => { + let value = field.text().await.map_err(|err| { + let msg = format!("invalid title: {err}"); + error!(error = %err, "invalid title payload"); + AppError::bad_request(msg) + })?; + let trimmed = value.trim(); + if !trimmed.is_empty() { + title_override = Some(trimmed.to_string()); + } + } + Some("tag_ids") => { + let value = field.text().await.map_err(|err| { + let msg = format!("invalid tag_ids: {err}"); + error!(error = %err, "invalid tag_ids payload"); + AppError::bad_request(msg) + })?; + let parsed: Vec = serde_json::from_str(&value).map_err(|err| { + let msg = format!("tag_ids must be a JSON array of UUID strings: {err}"); + error!(error = %err, "invalid tag_ids json"); + AppError::bad_request(msg) + })?; + let mut set = HashSet::new(); + for raw in parsed { + let trimmed = raw.trim(); + if trimmed.is_empty() { + continue; + } + let uuid = Uuid::parse_str(trimmed) + .map_err(|_| AppError::bad_request("tag_ids must contain valid UUIDs"))?; + set.insert(uuid); + } + tag_ids = set.into_iter().collect(); + } + Some("correspondents") => { + let value = field.text().await.map_err(|err| { + let msg = format!("invalid correspondents: {err}"); + error!(error = %err, "invalid correspondents payload"); + AppError::bad_request(msg) + })?; + correspondents = serde_json::from_str(&value).map_err(|err| { + let msg = format!( + "correspondents must be a JSON array of {{correspondent_id}} objects: {err}" + ); + error!(error = %err, "invalid correspondents json"); + AppError::bad_request(msg) + })?; + } + Some("issued_at") => { + let value = field.text().await.map_err(|err| { + let msg = format!("invalid issued_at: {err}"); + error!(error = %err, "invalid issued_at payload"); + AppError::bad_request(msg) + })?; + let trimmed = value.trim(); + if !trimmed.is_empty() { + let parsed = DateTime::parse_from_rfc3339(trimmed).map_err(|err| { + let msg = format!("issued_at must be an RFC3339 timestamp: {err}"); + error!(error = %err, "invalid issued_at format"); + AppError::bad_request(msg) + })?; + issued_at_override = Some(parsed.naive_utc()); + } + } + Some("skip_existing") => { + let value = field.text().await.map_err(|err| { + let msg = format!("invalid skip_existing flag: {err}"); + error!(error = %err, "invalid skip_existing payload"); + AppError::bad_request(msg) + })?; + skip_if_existing = matches!( + value.trim().to_ascii_lowercase().as_str(), + "1" | "true" | "yes" + ); + } + _ => {} + } + } + + let file_bytes = file_bytes.ok_or_else(|| { + error!("upload rejected: missing file field"); + AppError::bad_request("file field is required") + })?; + + if file_bytes.is_empty() { + error!("upload rejected: empty file payload"); + return Err(AppError::bad_request("file field must not be empty")); + } + let original_name = original_name.ok_or_else(|| { + error!("upload rejected: missing original filename"); + AppError::bad_request("filename is required") + })?; + let original_name_for_log = original_name.clone(); + + let request = DocumentUploadRequest { + bytes: file_bytes, + original_name, + mime_type, + folder_id, + metadata, + title_override, + tag_ids, + correspondents, + issued_at_override, + skip_if_existing, + }; + + let service = DocumentsService::new(&state); + let outcome = match service + .upload_document(&mut conn, tenant_id, user_id, request) + .await + { + Ok(outcome) => outcome, + Err(err) => { + error!(error = ?err, original_name = %original_name_for_log, "document upload failed"); + return Err(err); + } + }; + + let response = match outcome { + DocumentUploadOutcome::Created(detail) => { + info!( + document_id = %detail.document.id, + original_name = %detail.document.original_name, + created = true, + reused_existing = false, + "document upload succeeded", + ); + created_json(detail)? + } + DocumentUploadOutcome::Reused(detail) => { + info!( + document_id = %detail.document.id, + original_name = %detail.document.original_name, + created = false, + reused_existing = true, + "document upload succeeded", + ); + ok_json(detail)? + } + }; + + Ok(response) +} + +#[utoipa::path( + post, + path = "/api/documents/{id}/assets", + params(("id" = Uuid, Path, description = "Document ID"), AssetRequestQuery), + responses((status = 202, description = "Asset generation requested")), + tag = "Assets" +)] +pub async fn request_document_assets( + State(state): State, + Path(document_id): Path, + Query(query): Query, + TenantScopedConn { + mut conn, + tenant_id, + .. + }: TenantScopedConn, +) -> AppResult { + let service = DocumentsService::new(&state); + service.request_document_assets(&mut conn, tenant_id, document_id, query.force)?; + Ok(StatusCode::ACCEPTED) +} + +#[utoipa::path( + post, + path = "/api/documents/bulk/reanalyze", + request_body = BulkReanalyzeSelectionRequest, + responses((status = 200, description = "Reanalyze queued", body = BulkReanalyzeResponse)), + tag = "Documents" +)] +pub async fn reanalyze_selected_documents( + State(state): State, + TenantScopedConn { + mut conn, + tenant_id, + .. + }: TenantScopedConn, + Json(payload): Json, +) -> AppResult> { + let BulkReanalyzeSelectionRequest { + mut document_ids, + force, + } = payload; + + let service = DocumentsService::new(&state); + let queued = service.reanalyze_documents(&mut conn, tenant_id, &mut document_ids, force)?; + accepted_json(BulkReanalyzeResponse { queued }) +} + +#[utoipa::path( + get, + path = "/api/documents/{id}/assets", + params(("id" = Uuid, Path, description = "Document ID")), + responses((status = 200, description = "Document assets", body = [DocumentAssetResponse])), + tag = "Assets" +)] +pub async fn list_document_assets( + State(state): State, + Path(document_id): Path, + TenantScopedConn { + mut conn, + tenant_id, + user_id, + .. + }: TenantScopedConn, +) -> AppResult>> { + let service = DocumentsService::new(&state); + let assets = service + .list_document_assets(&mut conn, tenant_id, user_id, document_id) + .await?; + ok_json(assets) +} + +#[utoipa::path( + get, + path = "/api/assets/{asset_id}", + params(("asset_id" = Uuid, Path, description = "Asset ID")), + responses((status = 200, description = "Asset detail", body = DocumentAssetDetailResponse)), + tag = "Assets" +)] +pub async fn get_document_asset( + State(state): State, + Path(asset_id): Path, + TenantScopedConn { + conn, + tenant_id, + user_id, + .. + }: TenantScopedConn, +) -> AppResult> { + let service = DocumentsService::new(&state); + let detail = service + .get_document_asset(conn, tenant_id, user_id, asset_id) + .await?; + ok_json(detail) +} + +#[utoipa::path( + post, + path = "/api/documents/{id}/download", + params(("id" = Uuid, Path, description = "Document ID")), + responses((status = 200, description = "Download link for current version", body = DownloadLink)), + tag = "Documents" +)] +pub async fn refresh_document_download( + State(state): State, + Path(document_id): Path, + TenantScopedConn { + mut conn, + tenant_id, + user_id, + .. + }: TenantScopedConn, +) -> AppResult> { + let service = DocumentsService::new(&state); + let link = service + .get_document_download_link(&mut conn, tenant_id, user_id, document_id) + .await?; + ok_json(link) +} + +#[utoipa::path( + post, + path = "/api/documents/{id}/versions/{version_id}/download", + params( + ("id" = Uuid, Path, description = "Document ID"), + ("version_id" = Uuid, Path, description = "Version ID") + ), + responses((status = 200, description = "Download link for version", body = DownloadLink)), + tag = "Documents" +)] +pub async fn refresh_document_version_download( + State(state): State, + Path((document_id, version_id)): Path<(Uuid, Uuid)>, + TenantScopedConn { + mut conn, + tenant_id, + user_id, + .. + }: TenantScopedConn, +) -> AppResult> { + let service = DocumentsService::new(&state); + let link = service + .get_document_version_download_link(&mut conn, tenant_id, user_id, document_id, version_id) + .await?; + ok_json(link) +} + +#[utoipa::path( + post, + path = "/api/assets/{asset_id}/download", + params(("asset_id" = Uuid, Path, description = "Asset ID")), + responses((status = 200, description = "Asset download link", body = DownloadLink)), + tag = "Assets" +)] +pub async fn refresh_asset_download( + State(state): State, + Path(asset_id): Path, + TenantScopedConn { + mut conn, + tenant_id, + user_id, + .. + }: TenantScopedConn, +) -> AppResult> { + let service = DocumentsService::new(&state); + let link = service + .get_asset_download_link(&mut conn, tenant_id, user_id, asset_id) + .await?; + ok_json(link) +} + +#[utoipa::path( + get, + path = "/api/documents/{id}/versions", + params(("id" = Uuid, Path, description = "Document ID")), + responses((status = 200, description = "Document versions", body = [DocumentVersionResponse])), + tag = "Documents" +)] +pub async fn list_document_versions( + State(state): State, + Path(document_id): Path, + TenantScopedConn { + mut conn, + tenant_id, + .. + }: TenantScopedConn, +) -> AppResult>> { + let service = DocumentsService::new(&state); + let versions = service.list_document_versions(&mut conn, tenant_id, document_id)?; + ok_json(versions) +} + +#[utoipa::path( + get, + path = "/api/documents/{id}/versions/{version_id}", + params( + ("id" = Uuid, Path, description = "Document ID"), + ("version_id" = Uuid, Path, description = "Version ID"), + ), + responses((status = 200, description = "Document version detail", body = DocumentVersionDetailResponse)), + tag = "Documents" +)] +pub async fn get_document_version( + State(state): State, + Path((document_id, version_id)): Path<(Uuid, Uuid)>, + TenantScopedConn { + mut conn, + tenant_id, + user_id, + .. + }: TenantScopedConn, +) -> AppResult> { + let service = DocumentsService::new(&state); + let detail = service + .get_document_version(&mut conn, tenant_id, user_id, document_id, version_id) + .await?; + ok_json(detail) +} + +#[utoipa::path( + get, + path = "/api/download/{token}", + params(("token" = String, Path, description = "Download token")), + responses((status = 200, description = "Proxied download stream or redirect")), + tag = "Documents" +)] +pub async fn download_with_token( + State(state): State, + Path(token): Path, + headers: HeaderMap, +) -> AppResult { + let claims = state + .jwt + .verify_download_token(&token) + .map_err(|_| AppError::unauthorized())?; + + let mut conn = state.db_for_tenant(claims.tenant_id)?; + ensure_active_tenant_with_conn(&mut conn, claims.tenant_id)?; + + let now = Utc::now().naive_utc(); + let has_active_refresh: bool = select(exists( + session_dsl::user_sessions + .filter(session_dsl::user_id.eq(claims.user_id)) + .filter(session_dsl::tenant_id.eq(claims.tenant_id)) + .filter(session_dsl::revoked_at.is_null()) + .filter(session_dsl::expires_at.gt(now)), + )) + .get_result(&mut conn)?; + + if !has_active_refresh { + return Err(AppError::unauthorized()); + } + + match &claims.subject { + DownloadSubject::Document { doc_id, version_id } => { + let doc_id = *doc_id; + let version_id = *version_id; + let doc: Document = documents::table + .find(doc_id) + .filter(documents::tenant_id.eq(claims.tenant_id)) + .first(&mut conn)?; + if doc.deleted_at.is_some() { + return Err(AppError::not_found()); + } + + let version: DocumentVersion = document_versions::table + .find(version_id) + .filter(document_versions::document_id.eq(doc_id)) + .first(&mut conn)?; + + drop(conn); + + let storage = state.storage_for_tenant(claims.tenant_id)?; + let disposition = inline_content_disposition(&doc.filename); + + if !state.config.proxy_downloads { + let presigned_url = storage + .presign_get_object( + &version.s3_key, + Duration::from_secs(PRESIGNED_URL_EXPIRY_SECONDS), + disposition.as_deref(), + ) + .await + .storage_context("failed to generate download URL")?; + + return Ok(axum::response::Redirect::temporary(&presigned_url).into_response()); + } + + proxy_storage_object( + storage, + &version.s3_key, + disposition.as_deref(), + headers.get(header::RANGE).cloned(), + doc.mime_type.as_deref(), + Some(version.id.to_string()), + ) + .await + } + DownloadSubject::Asset { asset_id } => { + let asset: DocumentAsset = document_assets::table + .find(*asset_id) + .filter(document_assets::tenant_id.eq(claims.tenant_id)) + .first(&mut conn)?; + + drop(conn); + + let storage = state.storage_for_tenant(claims.tenant_id)?; + let disposition = asset_disposition(&asset); + + if !state.config.proxy_downloads { + let presigned_url = storage + .presign_get_object( + &asset.s3_key, + Duration::from_secs(PRESIGNED_URL_EXPIRY_SECONDS), + disposition.as_deref(), + ) + .await + .storage_context("failed to generate download URL")?; + + return Ok(axum::response::Redirect::temporary(&presigned_url).into_response()); + } + + proxy_storage_object( + storage, + &asset.s3_key, + disposition.as_deref(), + headers.get(header::RANGE).cloned(), + Some(asset.mime_type.as_str()), + Some(asset.id.to_string()), + ) + .await + } + } +} + +#[utoipa::path( + post, + path = "/api/documents/{id}/trash", + params(("id" = Uuid, Path, description = "Document ID")), + responses((status = 204, description = "Document deleted")), + tag = "Documents" +)] +pub async fn trash_document( + State(state): State, + Path(document_id): Path, + TenantScopedConn { + mut conn, + tenant_id, + .. + }: TenantScopedConn, +) -> AppResult { + let service = DocumentsService::new(&state); + service.trash_document(&mut conn, tenant_id, document_id)?; + Ok(StatusCode::NO_CONTENT) +} + +async fn proxy_storage_object( + storage: TenantStorage, + key: &str, + response_disposition: Option<&str>, + range_header: Option, + fallback_content_type: Option<&str>, + etag: Option, +) -> AppResult { + let url = storage + .presign_get_object( + key, + Duration::from_secs(PRESIGNED_URL_EXPIRY_SECONDS), + response_disposition, + ) + .await + .storage_context("failed to generate download URL")?; + + let client = reqwest::Client::new(); + let mut request = client.get(url.clone()); + if let Some(range) = range_header { + request = request.header(header::RANGE, range); + } + + let upstream = request.send().await.map_err(|err| { + tracing::error!(error = ?err, "failed to fetch document stream"); + AppError::internal("failed to fetch document stream") + })?; + + let status = + StatusCode::from_u16(upstream.status().as_u16()).unwrap_or(StatusCode::BAD_GATEWAY); + if !(status.is_success() || status == StatusCode::PARTIAL_CONTENT) { + tracing::error!(status = %status, "upstream download returned error status"); + return Err(AppError::internal("failed to fetch document stream")); + } + + let mut builder = Response::builder().status(status); + + if let Some(content_type) = upstream.headers().get(header::CONTENT_TYPE) { + builder = builder.header(header::CONTENT_TYPE, content_type); + } else if let Some(fallback) = fallback_content_type { + builder = builder.header(header::CONTENT_TYPE, fallback); + } + + if let Some(content_length) = upstream.headers().get(header::CONTENT_LENGTH) { + builder = builder.header(header::CONTENT_LENGTH, content_length); + } + + if let Some(range) = upstream.headers().get(header::CONTENT_RANGE) { + builder = builder.header(header::CONTENT_RANGE, range); + } + + builder = builder.header("Accept-Ranges", "bytes"); + + if let Some(disposition) = response_disposition { + builder = builder.header(header::CONTENT_DISPOSITION, disposition); + } + + if let Some(etag_value) = etag { + builder = builder.header(header::ETAG, format!("\"{}\"", etag_value)); + } + + let stream = upstream + .bytes_stream() + .map(|chunk| chunk.map_err(|err| std::io::Error::new(std::io::ErrorKind::Other, err))); + let body = Body::from_stream(stream); + + builder.body(body).map_err(|err| { + tracing::error!(error = ?err, "failed to build proxied response"); + AppError::internal("failed to build proxied response") + }) +} + +#[utoipa::path( + delete, + path = "/api/documents/{id}", + params(("id" = Uuid, Path, description = "Document ID")), + responses((status = 202, description = "Document purge scheduled")), + tag = "Documents" +)] +pub async fn delete_document( + State(state): State, + Path(document_id): Path, + TenantScopedConn { + mut conn, + tenant_id, + .. + }: TenantScopedConn, +) -> AppResult { + let service = DocumentsService::new(&state); + service.delete_document(&mut conn, tenant_id, document_id)?; + + Ok(StatusCode::ACCEPTED) +} + +#[utoipa::path( + patch, + path = "/api/documents/{id}", + params(("id" = Uuid, Path, description = "Document ID")), + request_body = crate::services::documents::UpdateDocumentRequest, + responses((status = 200, description = "Updated document", body = DocumentDetailResponse)), + tag = "Documents" +)] +pub async fn update_document( + State(state): State, + Path(document_id): Path, + TenantScopedConn { + mut conn, + tenant_id, + user_id, + .. + }: TenantScopedConn, + Json(payload): Json, +) -> AppResult> { + let service = DocumentsService::new(&state); + let detail = service + .update_document(&mut conn, tenant_id, user_id, document_id, payload) + .await?; + ok_json(detail) +} + +#[utoipa::path( + post, + path = "/api/documents/{id}/restore", + params(("id" = Uuid, Path, description = "Document ID")), + request_body = RestoreDocumentRequest, + responses((status = 204, description = "Document restored")), + tag = "Documents" +)] +pub async fn restore_document( + State(state): State, + Path(document_id): Path, + TenantScopedConn { + mut conn, + tenant_id, + .. + }: TenantScopedConn, + Json(payload): Json, +) -> AppResult { + let service = DocumentsService::new(&state); + service.restore_document(&mut conn, tenant_id, document_id, payload.folder_id)?; + Ok(StatusCode::NO_CONTENT) +} + +#[utoipa::path( + patch, + path = "/api/documents/{id}/folder", + params(("id" = Uuid, Path, description = "Document ID")), + request_body = MoveDocumentRequest, + responses((status = 204, description = "Document moved")), + tag = "Documents" +)] +pub async fn move_document( + State(state): State, + Path(document_id): Path, + TenantScopedConn { + mut conn, + tenant_id, + .. + }: TenantScopedConn, + Json(payload): Json, +) -> AppResult { + let service = DocumentsService::new(&state); + service.move_document(&mut conn, tenant_id, document_id, payload.folder_id)?; + + Ok(StatusCode::NO_CONTENT) +} + +#[utoipa::path( + post, + path = "/api/documents/bulk/move", + request_body = BulkMoveRequest, + responses((status = 200, description = "Bulk move outcome", body = BulkMoveResponse)), + tag = "Documents" +)] +pub async fn bulk_move_documents( + State(state): State, + TenantScopedConn { + mut conn, + tenant_id, + .. + }: TenantScopedConn, + Json(payload): Json, +) -> AppResult> { + let BulkMoveRequest { + document_ids, + folder_id, + } = payload; + + let service = DocumentsService::new(&state); + let updated = service.bulk_move_documents(&mut conn, tenant_id, document_ids, folder_id)?; + ok_json(BulkMoveResponse { updated }) +} + +#[utoipa::path( + post, + path = "/api/documents/{id}/correspondents", + params(("id" = Uuid, Path, description = "Document ID")), + request_body = AssignCorrespondentsRequest, + responses((status = 204, description = "Correspondents assigned")), + tag = "Documents" +)] +pub async fn assign_correspondents( + State(state): State, + Path(document_id): Path, + TenantScopedConn { + mut conn, + tenant_id, + user_id, + .. + }: TenantScopedConn, + Json(payload): Json, +) -> AppResult { + let service = CorrespondentsService::new(&state); + service.assign_to_document(&mut conn, tenant_id, user_id, document_id, &payload)?; + no_content() +} + +#[utoipa::path( + post, + path = "/api/documents/bulk/correspondents", + request_body = BulkCorrespondentsRequest, + responses((status = 200, description = "Bulk correspondents outcome", body = BulkCorrespondentResponse)), + tag = "Documents" +)] +pub async fn bulk_assign_correspondents( + State(state): State, + TenantScopedConn { + mut conn, + tenant_id, + user_id, + .. + }: TenantScopedConn, + Json(payload): Json, +) -> AppResult> { + let service = CorrespondentsService::new(&state); + let response = service.bulk_update(&mut conn, tenant_id, user_id, payload)?; + ok_json(response) +} + +#[utoipa::path( + delete, + path = "/api/documents/{id}/correspondents/{correspondent_id}", + params( + ("id" = Uuid, Path, description = "Document ID"), + ("correspondent_id" = Uuid, Path, description = "Correspondent ID") + ), + responses((status = 204, description = "Correspondent removed")), + tag = "Documents" +)] +pub async fn remove_correspondent( + State(state): State, + Path((document_id, correspondent_id)): Path<(Uuid, Uuid)>, + TenantScopedConn { + mut conn, + tenant_id, + .. + }: TenantScopedConn, +) -> AppResult { + let service = CorrespondentsService::new(&state); + service.remove_from_document(&mut conn, tenant_id, document_id, correspondent_id)?; + no_content() +} + +#[utoipa::path( + post, + path = "/api/documents/{id}/tags", + params(("id" = Uuid, Path, description = "Document ID")), + request_body = AssignTagsRequest, + responses((status = 204, description = "Tags assigned")), + tag = "Documents" +)] +pub async fn assign_tags( + State(state): State, + Path(document_id): Path, + TenantScopedConn { + mut conn, + tenant_id, + user_id, + .. + }: TenantScopedConn, + Json(payload): Json, +) -> AppResult { + let service = TagsService::new(&state); + service.assign_to_document(&mut conn, tenant_id, user_id, document_id, &payload.tag_ids)?; + Ok(StatusCode::NO_CONTENT) +} + +#[utoipa::path( + post, + path = "/api/documents/bulk/tags", + request_body = BulkTagRequest, + responses((status = 200, description = "Bulk tag outcome", body = BulkTagResponse)), + tag = "Documents" +)] +pub async fn bulk_update_tags( + State(state): State, + TenantScopedConn { + mut conn, + tenant_id, + user_id, + .. + }: TenantScopedConn, + Json(payload): Json, +) -> AppResult> { + let service = TagsService::new(&state); + let response = service.bulk_update(&mut conn, tenant_id, user_id, payload)?; + ok_json(response) +} + +#[utoipa::path( + delete, + path = "/api/documents/{id}/tags/{tag_id}", + params( + ("id" = Uuid, Path, description = "Document ID"), + ("tag_id" = Uuid, Path, description = "Tag ID") + ), + responses((status = 204, description = "Tag removed")), + tag = "Documents" +)] +pub async fn remove_tag( + Path((document_id, tag_id)): Path<(Uuid, Uuid)>, + State(state): State, + TenantScopedConn { + mut conn, + tenant_id, + .. + }: TenantScopedConn, +) -> AppResult { + let service = TagsService::new(&state); + service.remove_from_document(&mut conn, tenant_id, document_id, tag_id)?; + no_content() +} + +#[derive(utoipa::OpenApi)] +#[openapi( + paths( + crate::routes::documents::list_documents, + crate::routes::documents::check_document, + crate::routes::documents::upload_document, + crate::routes::documents::get_document, + crate::routes::documents::refresh_document_download, + crate::routes::documents::update_document, + crate::routes::documents::trash_document, + crate::routes::documents::delete_document, + crate::routes::documents::restore_document, + crate::routes::documents::download_with_token, + crate::routes::documents::move_document, + crate::routes::documents::assign_tags, + crate::routes::documents::remove_tag, + crate::routes::documents::bulk_move_documents, + crate::routes::documents::bulk_update_tags, + crate::routes::documents::bulk_assign_correspondents, + crate::routes::documents::assign_correspondents, + crate::routes::documents::remove_correspondent, + crate::routes::documents::reanalyze_selected_documents, + crate::routes::documents::list_document_assets, + crate::routes::documents::request_document_assets, + crate::routes::documents::get_document_asset, + crate::routes::documents::list_document_versions, + crate::routes::documents::get_document_version, + crate::routes::documents::refresh_document_version_download, + crate::routes::documents::refresh_asset_download, + ), + components(schemas( + crate::services::documents::DocumentListQuery, + crate::services::documents::DocumentStatusFilter, + crate::routes::documents::AssetRequestQuery, + crate::routes::documents::DocumentCheckQuery, + crate::routes::documents::DocumentCheckResponse, + crate::services::documents::DocumentResponse, + crate::services::documents::DocumentDetailResponse, + crate::routes::documents::DocumentMetadataUpdate, + crate::services::documents::TagResponse, + crate::routes::documents::CorrespondentAssignmentInput, + crate::routes::documents::AssignCorrespondentsRequest, + crate::routes::documents::BulkCorrespondentAction, + crate::routes::documents::BulkCorrespondentsRequest, + crate::routes::documents::BulkCorrespondentResponse, + crate::routes::documents::BulkMoveRequest, + crate::routes::documents::BulkMoveResponse, + crate::routes::documents::BulkTagAction, + crate::routes::documents::BulkTagRequest, + crate::routes::documents::BulkTagResponse, + crate::routes::documents::AssignTagsRequest, + crate::routes::documents::MoveDocumentRequest, + crate::routes::documents::BulkReanalyzeSelectionRequest, + crate::routes::documents::BulkReanalyzeResponse, + crate::routes::documents::UploadDocumentForm, + crate::documents::asset::DocumentVersionResponse, + crate::documents::asset::DocumentVersionDetailResponse, + crate::documents::asset::DocumentAssetResponse, + crate::documents::asset::DocumentAssetDetailResponse, + crate::documents::asset::DownloadLink, + crate::documents::correspondents::DocumentCorrespondentResponse, + crate::error::ApiErrorResponse, + )) +)] +pub struct DocumentsApiDoc; diff --git a/backend/src/routes/folders.rs b/backend/src/routes/folders.rs new file mode 100644 index 0000000..2a374d2 --- /dev/null +++ b/backend/src/routes/folders.rs @@ -0,0 +1,248 @@ +use axum::{ + extract::{Json, Path, Query, State}, + http::StatusCode, +}; +use utoipa::OpenApi; +use uuid::Uuid; + +use crate::{ + auth::TenantScopedConn, + error::{AppError, AppResult}, + http::responders::{created_json, no_content, ok_json, JsonResponse}, + services::folders::{ + CreateFolderRequest, EnsureFolderPathRequest, FolderContentsData, FolderContentsQuery, + FolderInfo, FolderService, FolderTreeNode, UpdateFolderRequest, + }, + state::AppState, +}; + +use crate::services::documents::DocumentResponse; + +#[derive(utoipa::ToSchema, serde::Serialize)] +pub struct FolderResponse { + pub folder: FolderInfo, +} + +#[derive(utoipa::ToSchema, serde::Serialize)] +pub struct FolderContentsResponse { + #[schema(nullable)] + pub folder: Option, + pub subfolders: Vec, + pub documents: Vec, +} + +#[utoipa::path( + get, + path = "/api/folders/{id}", + params(("id" = Uuid, Path, description = "Folder ID")), + responses((status = 200, description = "Folder detail", body = FolderResponse)), + tag = "Folders" +)] +pub async fn get_folder( + State(state): State, + Path(folder_id): Path, + TenantScopedConn { + mut conn, + tenant_id, + .. + }: TenantScopedConn, +) -> AppResult> { + let service = FolderService::new(&state); + let folder = service.get_folder(&mut conn, tenant_id, folder_id)?; + ok_json(FolderResponse { folder }) +} + +#[utoipa::path( + post, + path = "/api/folders/path", + request_body = EnsureFolderPathRequest, + responses((status = 200, description = "Folder path ensured", body = FolderResponse)), + tag = "Folders" +)] +pub async fn ensure_folder_path( + State(state): State, + TenantScopedConn { + mut conn, + tenant_id, + .. + }: TenantScopedConn, + Json(payload): Json, +) -> AppResult> { + let service = FolderService::new(&state); + let folder = service.ensure_folder_path(&mut conn, tenant_id, payload)?; + ok_json(FolderResponse { folder }) +} + +#[utoipa::path( + post, + path = "/api/folders", + request_body = CreateFolderRequest, + responses( + (status = 201, description = "Folder created", body = FolderResponse), + (status = 200, description = "Folder already existed", body = FolderResponse) + ), + tag = "Folders" +)] +pub async fn create_folder( + State(state): State, + TenantScopedConn { + mut conn, + tenant_id, + .. + }: TenantScopedConn, + Json(payload): Json, +) -> AppResult> { + let service = FolderService::new(&state); + let (folder, created) = service.create_folder(&mut conn, tenant_id, payload)?; + let response = FolderResponse { folder }; + if created { + created_json(response) + } else { + ok_json(response) + } +} + +#[utoipa::path( + get, + path = "/api/folders/{id}/contents", + params(("id" = String, Path, description = "Folder ID or 'root'"), FolderContentsQuery), + responses((status = 200, description = "Folder contents", body = FolderContentsResponse)), + tag = "Folders" +)] +pub async fn list_folder_contents( + State(state): State, + Path(folder_identifier): Path, + Query(query): Query, + TenantScopedConn { + mut conn, + tenant_id, + user_id, + .. + }: TenantScopedConn, +) -> AppResult> { + let FolderContentsQuery { + include_documents, + sort, + dir, + } = query; + + let folder_id = if folder_identifier.eq_ignore_ascii_case("root") { + None + } else { + Some( + Uuid::parse_str(&folder_identifier) + .map_err(|_| AppError::bad_request("folder identifier must be 'root' or a UUID"))?, + ) + }; + + let service = FolderService::new(&state); + let FolderContentsData { + folder, + subfolders, + documents, + } = service.list_folder_contents( + &mut conn, + tenant_id, + folder_id, + sort, + dir, + include_documents, + )?; + + let documents = if include_documents { + service.hydrate_documents(&mut conn, tenant_id, user_id, documents)? + } else { + Vec::new() + }; + + ok_json(FolderContentsResponse { + folder, + subfolders, + documents, + }) +} + +#[utoipa::path( + get, + path = "/api/folders/tree", + responses((status = 200, description = "Folder hierarchy", body = [FolderTreeNode])), + tag = "Folders" +)] +pub async fn list_folder_tree( + State(state): State, + TenantScopedConn { + mut conn, + tenant_id, + .. + }: TenantScopedConn, +) -> AppResult>> { + let service = FolderService::new(&state); + let tree = service.list_folder_tree(&mut conn, tenant_id)?; + ok_json(tree) +} + +#[utoipa::path( + delete, + path = "/api/folders/{id}", + params(("id" = Uuid, Path, description = "Folder ID")), + responses((status = 204, description = "Folder deleted")), + tag = "Folders" +)] +pub async fn delete_folder( + State(state): State, + Path(folder_id): Path, + TenantScopedConn { + mut conn, + tenant_id, + .. + }: TenantScopedConn, +) -> AppResult { + FolderService::new(&state).delete_folder(&mut conn, tenant_id, folder_id)?; + no_content() +} + +#[utoipa::path( + patch, + path = "/api/folders/{id}", + params(("id" = Uuid, Path, description = "Folder ID")), + request_body = UpdateFolderRequest, + responses((status = 204, description = "Folder updated")), + tag = "Folders" +)] +pub async fn update_folder( + State(state): State, + Path(folder_id): Path, + TenantScopedConn { + mut conn, + tenant_id, + .. + }: TenantScopedConn, + Json(payload): Json, +) -> AppResult { + FolderService::new(&state).update_folder(&mut conn, tenant_id, folder_id, payload)?; + no_content() +} + +#[derive(OpenApi)] +#[openapi( + paths( + crate::routes::folders::create_folder, + crate::routes::folders::ensure_folder_path, + crate::routes::folders::get_folder, + crate::routes::folders::list_folder_contents, + crate::routes::folders::list_folder_tree, + crate::routes::folders::delete_folder, + crate::routes::folders::update_folder + ), + components(schemas( + crate::services::folders::CreateFolderRequest, + crate::services::folders::EnsureFolderPathRequest, + crate::routes::folders::FolderResponse, + crate::services::folders::FolderInfo, + crate::services::folders::FolderContentsQuery, + crate::routes::folders::FolderContentsResponse, + crate::services::folders::FolderTreeNode, + crate::services::folders::UpdateFolderRequest + )) +)] +pub struct FoldersApiDoc; diff --git a/backend/src/routes/health.rs b/backend/src/routes/health.rs new file mode 100644 index 0000000..b5759b5 --- /dev/null +++ b/backend/src/routes/health.rs @@ -0,0 +1,46 @@ +use axum::{extract::State, http::StatusCode, response::Json}; +use diesel::RunQueryDsl; +use serde_json::json; + +use crate::state::AppState; + +#[derive(utoipa::OpenApi)] +#[openapi(paths(crate::routes::health::health_check))] +pub struct HealthApiDoc; + +#[utoipa::path( + get, + path = "/api/health", + responses((status = 200, description = "Service is healthy")), + tag = "Health" +)] +pub async fn health_check(State(state): State) -> (StatusCode, Json) { + let database_ok = match state.db_unscoped() { + Ok(mut conn) => diesel::sql_query("SELECT 1") + .execute(&mut conn) + .map(|_| true) + .unwrap_or_else(|err| { + tracing::error!(error = ?err, "health check database ping failed"); + false + }), + Err(err) => { + tracing::error!(error = ?err, "health check database connection failed"); + false + } + }; + + let status = if database_ok { + StatusCode::OK + } else { + StatusCode::SERVICE_UNAVAILABLE + }; + + let payload = json!({ + "status": if database_ok { "ok" } else { "error" }, + "checks": { + "database": if database_ok { "ok" } else { "unavailable" } + } + }); + + (status, Json(payload)) +} diff --git a/backend/src/routes/mod.rs b/backend/src/routes/mod.rs new file mode 100644 index 0000000..147c024 --- /dev/null +++ b/backend/src/routes/mod.rs @@ -0,0 +1,496 @@ +use axum::http::HeaderValue; +use axum::{ + extract::DefaultBodyLimit, + middleware, + response::{Html, Json}, + routing::{delete, get, patch, post}, + Router, +}; +use std::sync::Arc; +use tower_http::{ + cors::{AllowOrigin, CorsLayer}, + trace::{DefaultMakeSpan, DefaultOnFailure, DefaultOnResponse, TraceLayer}, +}; +use utoipa::OpenApi; + +use crate::{ + auth::{capability_guard::RequireCapabilitiesLayer, AuthenticatedUser}, + models::ApiCapability, + openapi::ApiDoc, + state::AppState, +}; + +pub mod auth; +pub mod capability_sets; +pub mod correspondents; +pub mod documents; +pub mod folders; +pub mod health; +pub mod profile; +pub mod tags; +pub mod tenants; +pub mod webdav; + +pub fn create_router(state: AppState) -> Router<()> { + let cors = if let Some(origins) = state.config.cors_allowed_origin.as_ref() { + let headers: Vec = origins + .split(',') + .filter_map(|value| { + let trimmed = value.trim(); + (!trimmed.is_empty()).then(|| { + trimmed + .parse::() + .expect("invalid CORS allowed origin") + }) + }) + .collect(); + + let allow_origin = AllowOrigin::list(headers); + + CorsLayer::new() + .allow_origin(allow_origin) + .allow_methods(tower_http::cors::AllowMethods::mirror_request()) + .allow_headers(tower_http::cors::AllowHeaders::mirror_request()) + .allow_credentials(true) + } else { + CorsLayer::new() + .allow_origin(AllowOrigin::mirror_request()) + .allow_methods(tower_http::cors::AllowMethods::mirror_request()) + .allow_headers(tower_http::cors::AllowHeaders::mirror_request()) + .allow_credentials(true) + }; + + let auth_routes = Router::new() + .route("/signup/start", post(auth::signup_start)) + .route("/signup/finish", post(auth::signup_finish)) + .route("/login", post(auth::login)) + .route("/exchange-api-token", post(auth::api_token_exchange)) + .route("/refresh", post(auth::refresh)) + .route("/logout", post(auth::logout)) + .route("/select-tenant", post(auth::select_tenant)) + .route( + "/passkeys/register/start", + post(auth::passkey_register_start), + ) + .route( + "/passkeys/register/finish", + post(auth::passkey_register_finish), + ) + .route("/passkeys/login/start", post(auth::passkey_login_start)) + .route("/passkeys/login/finish", post(auth::passkey_login_finish)) + .route("/me", get(auth::me)); + + let documents_routes = Router::new() + .route( + "/check", + get(documents::check_document).layer(RequireCapabilitiesLayer::all([ + ApiCapability::DocumentsRead, + ])), + ) + .route( + "/", + get(documents::list_documents).layer(RequireCapabilitiesLayer::all([ + ApiCapability::DocumentsRead, + ])), + ) + .route( + "/", + post(documents::upload_document).layer(RequireCapabilitiesLayer::all([ + ApiCapability::DocumentsWrite, + ApiCapability::DocumentsUpload, + ])), + ) + .route( + "/bulk/move", + post(documents::bulk_move_documents).layer(RequireCapabilitiesLayer::all([ + ApiCapability::DocumentsEdit, + ])), + ) + .route( + "/bulk/tags", + post(documents::bulk_update_tags).layer(RequireCapabilitiesLayer::all([ + ApiCapability::DocumentsEdit, + ])), + ) + .route( + "/bulk/correspondents", + post(documents::bulk_assign_correspondents).layer(RequireCapabilitiesLayer::all([ + ApiCapability::DocumentsEdit, + ])), + ) + .route( + "/bulk/reanalyze", + post(documents::reanalyze_selected_documents).layer(RequireCapabilitiesLayer::all([ + ApiCapability::DocumentsWrite, + ])), + ) + .route( + "/{id}", + get(documents::get_document).layer(RequireCapabilitiesLayer::all([ + ApiCapability::DocumentsRead, + ])), + ) + .route( + "/{id}/download", + post(documents::refresh_document_download).layer(RequireCapabilitiesLayer::all([ + ApiCapability::DocumentsRead, + ])), + ) + .route( + "/{id}/trash", + post(documents::trash_document).layer(RequireCapabilitiesLayer::all([ + ApiCapability::DocumentsWrite, + ])), + ) + .route( + "/{id}", + delete(documents::delete_document).layer(RequireCapabilitiesLayer::all([ + ApiCapability::DocumentsWrite, + ])), + ) + .route( + "/{id}", + patch(documents::update_document).layer(RequireCapabilitiesLayer::all([ + ApiCapability::DocumentsEdit, + ])), + ) + .route( + "/{id}/assets", + get(documents::list_document_assets).layer(RequireCapabilitiesLayer::all([ + ApiCapability::DocumentsRead, + ])), + ) + .route( + "/{id}/assets", + post(documents::request_document_assets).layer(RequireCapabilitiesLayer::all([ + ApiCapability::DocumentsWrite, + ])), + ) + .route( + "/{id}/folder", + patch(documents::move_document).layer(RequireCapabilitiesLayer::all([ + ApiCapability::DocumentsEdit, + ])), + ) + .route( + "/{id}/versions", + get(documents::list_document_versions).layer(RequireCapabilitiesLayer::all([ + ApiCapability::DocumentsRead, + ])), + ) + .route( + "/{id}/versions/{version_id}", + get(documents::get_document_version).layer(RequireCapabilitiesLayer::all([ + ApiCapability::DocumentsRead, + ])), + ) + .route( + "/{id}/versions/{version_id}/download", + post(documents::refresh_document_version_download).layer( + RequireCapabilitiesLayer::all([ApiCapability::DocumentsRead]), + ), + ) + .route( + "/{id}/restore", + post(documents::restore_document).layer(RequireCapabilitiesLayer::all([ + ApiCapability::DocumentsEdit, + ])), + ) + .route( + "/{id}/tags", + post(documents::assign_tags).layer(RequireCapabilitiesLayer::all([ + ApiCapability::DocumentsEdit, + ])), + ) + .route( + "/{id}/tags/{tag_id}", + delete(documents::remove_tag).layer(RequireCapabilitiesLayer::all([ + ApiCapability::DocumentsEdit, + ])), + ) + .route( + "/{id}/correspondents", + post(documents::assign_correspondents).layer(RequireCapabilitiesLayer::all([ + ApiCapability::DocumentsEdit, + ])), + ) + .route( + "/{id}/correspondents/{correspondent_id}", + delete(documents::remove_correspondent).layer(RequireCapabilitiesLayer::all([ + ApiCapability::DocumentsEdit, + ])), + ); + + let download_routes = + Router::new().route("/api/download/{token}", get(documents::download_with_token)); + + let folders_routes = Router::new() + .route( + "/", + post(folders::create_folder) + .layer(RequireCapabilitiesLayer::all([ApiCapability::FoldersWrite])), + ) + .route( + "/path", + post(folders::ensure_folder_path) + .layer(RequireCapabilitiesLayer::all([ApiCapability::FoldersWrite])), + ) + .route( + "/tree", + get(folders::list_folder_tree) + .layer(RequireCapabilitiesLayer::all([ApiCapability::FoldersRead])), + ) + .route( + "/{id}", + get(folders::get_folder) + .layer(RequireCapabilitiesLayer::all([ApiCapability::FoldersRead])), + ) + .route( + "/{id}", + delete(folders::delete_folder) + .layer(RequireCapabilitiesLayer::all([ApiCapability::FoldersWrite])), + ) + .route( + "/{id}", + patch(folders::update_folder) + .layer(RequireCapabilitiesLayer::all([ApiCapability::FoldersEdit])), + ) + .route( + "/{id}/contents", + get(folders::list_folder_contents) + .layer(RequireCapabilitiesLayer::all([ApiCapability::FoldersRead])), + ); + + let tags_routes = Router::new() + .route( + "/", + get(tags::list_tags).layer(RequireCapabilitiesLayer::all([ApiCapability::TagsRead])), + ) + .route( + "/", + post(tags::create_tag).layer(RequireCapabilitiesLayer::all([ApiCapability::TagsWrite])), + ) + .route( + "/{id}", + patch(tags::update_tag).layer(RequireCapabilitiesLayer::all([ApiCapability::TagsEdit])), + ) + .route( + "/{id}", + delete(tags::delete_tag) + .layer(RequireCapabilitiesLayer::all([ApiCapability::TagsWrite])), + ); + + let correspondents_routes = Router::new() + .route( + "/", + get(correspondents::list_correspondents).layer(RequireCapabilitiesLayer::all([ + ApiCapability::CorrespondentsRead, + ])), + ) + .route( + "/", + post(correspondents::create_correspondent).layer(RequireCapabilitiesLayer::all([ + ApiCapability::CorrespondentsWrite, + ])), + ) + .route( + "/{id}", + patch(correspondents::update_correspondent).layer(RequireCapabilitiesLayer::all([ + ApiCapability::CorrespondentsEdit, + ])), + ) + .route( + "/{id}", + delete(correspondents::delete_correspondent).layer(RequireCapabilitiesLayer::all([ + ApiCapability::CorrespondentsWrite, + ])), + ); + + let profile_routes = Router::new() + .route( + "/api-tokens", + get(profile::list_api_tokens) + .layer(RequireCapabilitiesLayer::all([ApiCapability::ProfileRead])), + ) + .route( + "/api-tokens", + post(profile::create_api_token) + .layer(RequireCapabilitiesLayer::all([ApiCapability::ProfileWrite])), + ) + .route( + "/api-tokens/{id}/regenerate", + post(profile::regenerate_api_token) + .layer(RequireCapabilitiesLayer::all([ApiCapability::ProfileWrite])), + ) + .route( + "/api-tokens/{id}", + delete(profile::delete_api_token) + .layer(RequireCapabilitiesLayer::all([ApiCapability::ProfileWrite])), + ) + .route( + "/passkeys", + get(profile::list_passkeys) + .layer(RequireCapabilitiesLayer::all([ApiCapability::ProfileRead])), + ) + .route( + "/passkeys/{id}", + delete(profile::delete_passkey) + .layer(RequireCapabilitiesLayer::all([ApiCapability::ProfileWrite])), + ); + + let capability_sets_routes = Router::new() + .route( + "/", + get(capability_sets::list_capability_sets).layer(RequireCapabilitiesLayer::all([ + ApiCapability::CapabilitySetsRead, + ])), + ) + .route( + "/", + post(capability_sets::create_capability_set).layer(RequireCapabilitiesLayer::all([ + ApiCapability::CapabilitySetsWrite, + ])), + ) + .route( + "/{id}", + get(capability_sets::get_capability_set).layer(RequireCapabilitiesLayer::all([ + ApiCapability::CapabilitySetsRead, + ])), + ) + .route( + "/{id}", + patch(capability_sets::update_capability_set).layer(RequireCapabilitiesLayer::all([ + ApiCapability::CapabilitySetsWrite, + ])), + ) + .route( + "/{id}", + delete(capability_sets::delete_capability_set).layer(RequireCapabilitiesLayer::all([ + ApiCapability::CapabilitySetsWrite, + ])), + ); + + let capabilities_routes = Router::new().route( + "/", + get(capability_sets::list_capabilities).layer(RequireCapabilitiesLayer::all([ + ApiCapability::CapabilitySetsRead, + ])), + ); + + let protected_state = state.clone(); + let assets_routes = Router::new() + .route( + "/{asset_id}", + get(documents::get_document_asset).layer(RequireCapabilitiesLayer::all([ + ApiCapability::DocumentsRead, + ])), + ) + .route( + "/{asset_id}/download", + post(documents::refresh_asset_download).layer(RequireCapabilitiesLayer::all([ + ApiCapability::DocumentsRead, + ])), + ); + + let manage_tenants_layer = RequireCapabilitiesLayer::all([ApiCapability::TenantsWrite]); + let tenants_routes = Router::new() + .route("/", get(tenants::list_tenants)) + .route("/{tenant_id}", get(tenants::get_tenant)) + .route( + "/{tenant_id}", + patch(tenants::update_tenant).layer(manage_tenants_layer.clone()), + ) + .route( + "/{tenant_id}/users", + get(tenants::list_tenant_users).layer(manage_tenants_layer.clone()), + ) + .route( + "/{tenant_id}/users/{user_id}", + get(tenants::get_tenant_user).layer(manage_tenants_layer.clone()), + ) + .route( + "/{tenant_id}/users/{user_id}", + patch(tenants::update_tenant_user).layer(manage_tenants_layer.clone()), + ) + .route( + "/{tenant_id}/users/{user_id}", + delete(tenants::delete_tenant_user).layer(manage_tenants_layer.clone()), + ); + + let protected_routes = Router::new() + .nest("/api/documents", documents_routes) + .nest("/api/folders", folders_routes) + .nest("/api/tags", tags_routes) + .nest("/api/correspondents", correspondents_routes) + .nest("/api/profile", profile_routes) + .nest("/api/capability-sets", capability_sets_routes) + .nest("/api/capabilities", capabilities_routes) + .nest("/api/assets", assets_routes) + .nest("/api/tenants", tenants_routes) + .layer(middleware::from_extractor_with_state::(protected_state)); + + let upload_limit = state.config.upload_body_limit_bytes; + + let openapi_spec = Arc::new(ApiDoc::openapi()); + let docs_router = Router::new() + .route( + "/api/docs", + get(move || async { Html(render_swagger_ui("/api/docs/openapi.json")) }), + ) + .route( + "/api/docs/openapi.json", + get({ + let spec = openapi_spec.clone(); + move || async move { Json((*spec).clone()) } + }), + ); + + Router::new() + .merge(download_routes) + .merge(protected_routes) + .merge(docs_router) + .nest("/api/auth", auth_routes) + .route("/api/health", get(health::health_check)) + .with_state(state) + .layer(cors) + .layer(DefaultBodyLimit::max( + usize::try_from(upload_limit).unwrap_or(usize::MAX), + )) + .layer( + TraceLayer::new_for_http() + .make_span_with(DefaultMakeSpan::new().level(tracing::Level::INFO)) + .on_response(DefaultOnResponse::new().level(tracing::Level::INFO)) + .on_failure(DefaultOnFailure::new().level(tracing::Level::ERROR)), + ) +} + +fn render_swagger_ui(spec_url: &str) -> String { + format!( + r#" + + + + Papercrate API Docs + + + + +
+ + + +"# + ) +} diff --git a/backend/src/routes/profile.rs b/backend/src/routes/profile.rs new file mode 100644 index 0000000..f023079 --- /dev/null +++ b/backend/src/routes/profile.rs @@ -0,0 +1,150 @@ +use axum::{ + extract::{Path, Query, State}, + http::StatusCode, + Json, +}; +use utoipa::OpenApi; +use uuid::Uuid; + +use crate::{ + auth::{passkeys::PasskeySummary, TenantScopedConn}, + error::AppResult, + http::responders::JsonResponse, + services::profile::{ + ApiTokenCreatedResponse, ApiTokenResponse, CreateApiTokenRequest, ProfileService, + RevokePasskeyQuery, + }, + state::AppState, +}; + +#[utoipa::path( + get, + path = "/api/profile/passkeys", + responses((status = 200, description = "List registered passkeys", body = [PasskeySummary])), + tag = "Profile" +)] +pub async fn list_passkeys( + State(state): State, + TenantScopedConn { + mut conn, user_id, .. + }: TenantScopedConn, +) -> AppResult>> { + ProfileService::new(&state).list_passkeys(&mut conn, user_id) +} + +#[utoipa::path( + get, + path = "/api/profile/api-tokens", + responses((status = 200, description = "List API tokens", body = [ApiTokenResponse])), + tag = "Profile" +)] +pub async fn list_api_tokens( + State(state): State, + TenantScopedConn { + mut conn, + tenant_id, + user_id, + .. + }: TenantScopedConn, +) -> AppResult>> { + ProfileService::new(&state).list_api_tokens(&mut conn, tenant_id, user_id) +} + +#[utoipa::path( + post, + path = "/api/profile/api-tokens", + request_body = CreateApiTokenRequest, + responses((status = 201, description = "API token created", body = ApiTokenCreatedResponse)), + tag = "Profile" +)] +pub async fn create_api_token( + State(state): State, + TenantScopedConn { + mut conn, + tenant_id, + user_id, + .. + }: TenantScopedConn, + Json(payload): Json, +) -> AppResult> { + ProfileService::new(&state).create_api_token(&mut conn, tenant_id, user_id, payload) +} + +#[utoipa::path( + post, + path = "/api/profile/api-tokens/{id}/regenerate", + params(("id" = Uuid, Path, description = "API token ID")), + responses((status = 200, description = "API token regenerated", body = ApiTokenCreatedResponse)), + tag = "Profile" +)] +pub async fn regenerate_api_token( + State(state): State, + TenantScopedConn { + mut conn, + tenant_id, + user_id, + .. + }: TenantScopedConn, + Path(token_id): Path, +) -> AppResult> { + ProfileService::new(&state).regenerate_api_token(&mut conn, tenant_id, user_id, token_id) +} + +#[utoipa::path( + delete, + path = "/api/profile/api-tokens/{id}", + params(("id" = Uuid, Path, description = "API token ID")), + responses((status = 204, description = "API token revoked")), + tag = "Profile" +)] +pub async fn delete_api_token( + State(state): State, + TenantScopedConn { + mut conn, user_id, .. + }: TenantScopedConn, + Path(token_id): Path, +) -> AppResult { + ProfileService::new(&state).delete_api_token(&mut conn, user_id, token_id) +} + +#[utoipa::path( + delete, + path = "/api/profile/passkeys/{id}", + params( + ("id" = Uuid, Path, description = "Passkey ID"), + ("reason" = Option, Query, description = "Optional reason for revoking the passkey") + ), + responses((status = 204, description = "Passkey revoked")), + tag = "Profile" +)] +pub async fn delete_passkey( + State(state): State, + TenantScopedConn { + mut conn, user_id, .. + }: TenantScopedConn, + Path(passkey_id): Path, + Query(query): Query, +) -> AppResult { + ProfileService::new(&state).delete_passkey(&mut conn, user_id, passkey_id, query.reason) +} + +#[derive(OpenApi)] +#[openapi( + paths( + crate::routes::profile::list_api_tokens, + crate::routes::profile::create_api_token, + crate::routes::profile::regenerate_api_token, + crate::routes::profile::delete_api_token, + crate::routes::profile::list_passkeys, + crate::routes::profile::delete_passkey + ), + components(schemas( + crate::models::ApiCapability, + crate::services::profile::ApiTokenResponse, + crate::services::profile::ApiTokenCreatedResponse, + crate::services::profile::CreateApiTokenRequest, + crate::services::profile::RevokePasskeyQuery, + crate::auth::passkeys::PasskeySummary + )) +)] +pub struct ProfileApiDoc; diff --git a/backend/src/routes/tags.rs b/backend/src/routes/tags.rs new file mode 100644 index 0000000..b95dd8e --- /dev/null +++ b/backend/src/routes/tags.rs @@ -0,0 +1,358 @@ +use axum::{extract::Path, http::StatusCode, Json}; +use diesel::{dsl::count_star, prelude::*}; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use utoipa::ToSchema; +use uuid::Uuid; + +use crate::{ + auth::TenantScopedConn, + error::{AppError, AppResult}, + http::responders::{no_content, ok_json, IntoAppResult, JsonResponse, RowsAffectedExt}, + models::{NewTag, Tag}, + schema::{document_tags, tags}, + utils::{ + json::deserialize_patch_field, + named_entity::{ensure_name_available, normalize_name}, + }, +}; + +#[derive(Deserialize, ToSchema)] +pub struct CreateTagRequest { + pub label: String, + #[schema(nullable)] + pub color: Option, +} + +#[derive(AsChangeset, Default)] +#[diesel(table_name = tags)] +struct UpdateTagChangeset<'a> { + label: Option<&'a str>, + color: Option>, +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn update_tag_request_deserializes_null_fields() { + let request: UpdateTagRequest = serde_json::from_value(json!({ + "label": null, + "color": null + })) + .unwrap(); + assert!(matches!(request.label, Some(None))); + assert!(matches!(request.color, Some(None))); + } + + #[test] + fn update_tag_request_omitted_fields_are_none() { + let request: UpdateTagRequest = serde_json::from_value(json!({})).unwrap(); + assert!(request.label.is_none()); + assert!(request.color.is_none()); + } +} + +#[derive(Serialize, ToSchema)] +pub struct TagCatalogEntry { + pub id: Uuid, + pub label: String, + #[schema(nullable)] + pub color: Option, + pub usage_count: i64, +} + +#[derive(Debug, Default, Deserialize, ToSchema)] +pub struct UpdateTagRequest { + #[serde(default, deserialize_with = "deserialize_patch_field")] + #[schema(nullable, value_type = Option)] + pub label: Option>, + #[serde(default, deserialize_with = "deserialize_patch_field")] + #[schema(nullable, value_type = Option)] + pub color: Option>, +} + +#[utoipa::path( + get, + path = "/api/tags", + responses((status = 200, description = "Tags", body = [TagCatalogEntry])), + tag = "Tags" +)] +pub async fn list_tags( + TenantScopedConn { + mut conn, + tenant_id, + .. + }: TenantScopedConn, +) -> AppResult>> { + let tag_list: Vec = tags::table + .filter(tags::tenant_id.eq(tenant_id)) + .order(tags::label.asc()) + .load(&mut conn)?; + + let usage_rows: Vec<(Uuid, i64)> = document_tags::table + .filter(document_tags::tenant_id.eq(tenant_id)) + .group_by(document_tags::tag_id) + .select((document_tags::tag_id, count_star())) + .load(&mut conn)?; + + let usage_map: HashMap = usage_rows.into_iter().collect(); + + let response: Vec = tag_list + .into_iter() + .map(|tag| TagCatalogEntry { + id: tag.id, + label: tag.label, + color: tag.color, + usage_count: *usage_map.get(&tag.id).unwrap_or(&0), + }) + .collect(); + + ok_json(response) +} + +#[utoipa::path( + post, + path = "/api/tags", + request_body = CreateTagRequest, + responses((status = 200, description = "Tag created", body = TagCatalogEntry)), + tag = "Tags" +)] +pub async fn create_tag( + TenantScopedConn { + mut conn, + tenant_id, + .. + }: TenantScopedConn, + Json(payload): Json, +) -> AppResult> { + let label = normalize_name(&payload.label, || { + AppError::bad_request("label must not be empty") + })?; + + let new_tag = NewTag { + id: Uuid::new_v4(), + label: label.clone(), + color: payload.color, + tenant_id, + }; + + match diesel::insert_into(tags::table) + .values(&new_tag) + .execute(&mut conn) + { + Ok(_) => {} + Err(diesel::result::Error::DatabaseError( + diesel::result::DatabaseErrorKind::UniqueViolation, + _, + )) => { + return Err(AppError::bad_request("tag label already exists")); + } + Err(err) => return Err(AppError::from(err)), + } + + let tag: Tag = tags::table + .find(new_tag.id) + .filter(tags::tenant_id.eq(tenant_id)) + .first(&mut conn) + .into_app_result()?; + + ok_json(TagCatalogEntry { + id: tag.id, + label: tag.label, + color: tag.color, + usage_count: 0, + }) +} + +#[utoipa::path( + patch, + path = "/api/tags/{id}", + params(("id" = Uuid, Path, description = "Tag ID")), + request_body = UpdateTagRequest, + responses((status = 200, description = "Tag updated", body = TagCatalogEntry)), + tag = "Tags" +)] +pub async fn update_tag( + Path(tag_id): Path, + TenantScopedConn { + mut conn, + tenant_id, + .. + }: TenantScopedConn, + Json(payload): Json, +) -> AppResult> { + let existing: Tag = tags::table + .find(tag_id) + .filter(tags::tenant_id.eq(tenant_id)) + .first(&mut conn) + .into_app_result()?; + let UpdateTagRequest { label, color } = payload; + + if label.is_none() && color.is_none() { + let usage_count: i64 = document_tags::table + .filter(document_tags::tag_id.eq(tag_id)) + .select(count_star()) + .first(&mut conn)?; + return ok_json(TagCatalogEntry { + id: existing.id, + label: existing.label.clone(), + color: existing.color.clone(), + usage_count, + }); + } + + let mut new_label: Option = None; + let mut label_changed = false; + match label { + None => {} + Some(None) => { + return Err(AppError::bad_request("label cannot be null")); + } + Some(Some(value)) => { + let normalized = + normalize_name(&value, || AppError::bad_request("label must not be empty"))?; + if normalized != existing.label { + ensure_name_available( + || { + tags::table + .filter(tags::label.eq(&normalized)) + .filter(tags::id.ne(tag_id)) + .filter(tags::tenant_id.eq(tenant_id)) + .first::(&mut conn) + .optional() + }, + || AppError::bad_request("tag label already exists"), + )?; + new_label = Some(normalized); + label_changed = true; + } + } + } + + let mut color_change: Option> = None; + let mut color_changed = false; + match color { + None => {} + Some(None) => { + color_change = Some(None); + color_changed = true; + } + Some(Some(value)) => { + let trimmed = value.trim(); + if trimmed.is_empty() { + return Err(AppError::bad_request("color must not be empty")); + } + if existing.color.as_deref() != Some(trimmed) { + color_change = Some(Some(trimmed.to_string())); + color_changed = true; + } + } + } + + if !label_changed && !color_changed { + let usage_count: i64 = document_tags::table + .filter(document_tags::tag_id.eq(tag_id)) + .filter(document_tags::tenant_id.eq(tenant_id)) + .select(count_star()) + .first(&mut conn)?; + return ok_json(TagCatalogEntry { + id: existing.id, + label: existing.label.clone(), + color: existing.color.clone(), + usage_count, + }); + } + + let changeset = UpdateTagChangeset { + label: new_label.as_deref(), + color: color_change + .as_ref() + .map(|opt| opt.as_ref().map(|value| value.as_str())), + }; + + diesel::update( + tags::table + .find(tag_id) + .filter(tags::tenant_id.eq(tenant_id)), + ) + .set(&changeset) + .execute(&mut conn) + .into_app_result()? + .or_not_found()?; + + let updated: Tag = tags::table + .find(tag_id) + .filter(tags::tenant_id.eq(tenant_id)) + .first(&mut conn) + .into_app_result()?; + let usage_count: i64 = document_tags::table + .filter(document_tags::tag_id.eq(tag_id)) + .filter(document_tags::tenant_id.eq(tenant_id)) + .select(count_star()) + .first(&mut conn)?; + + ok_json(TagCatalogEntry { + id: updated.id, + label: updated.label, + color: updated.color, + usage_count, + }) +} + +#[utoipa::path( + delete, + path = "/api/tags/{id}", + params(("id" = Uuid, Path, description = "Tag ID")), + responses((status = 204, description = "Tag deleted")), + tag = "Tags" +)] +pub async fn delete_tag( + Path(tag_id): Path, + TenantScopedConn { + mut conn, + tenant_id, + .. + }: TenantScopedConn, +) -> AppResult { + let usage: i64 = document_tags::table + .filter(document_tags::tag_id.eq(tag_id)) + .filter(document_tags::tenant_id.eq(tenant_id)) + .select(count_star()) + .first(&mut conn)?; + + if usage > 0 { + return Err(AppError::bad_request( + "cannot delete tag that is still assigned to documents", + )); + } + + diesel::delete( + tags::table + .find(tag_id) + .filter(tags::tenant_id.eq(tenant_id)), + ) + .execute(&mut conn) + .into_app_result()? + .or_not_found()?; + + no_content() +} + +#[derive(utoipa::OpenApi)] +#[openapi( + paths( + crate::routes::tags::list_tags, + crate::routes::tags::create_tag, + crate::routes::tags::update_tag, + crate::routes::tags::delete_tag + ), + components(schemas( + crate::routes::tags::CreateTagRequest, + crate::routes::tags::TagCatalogEntry, + crate::routes::tags::UpdateTagRequest + )) +)] +pub struct TagsApiDoc; diff --git a/backend/src/routes/tenants.rs b/backend/src/routes/tenants.rs new file mode 100644 index 0000000..da79041 --- /dev/null +++ b/backend/src/routes/tenants.rs @@ -0,0 +1,154 @@ +use axum::extract::{Path, State}; +use axum::{http::StatusCode, Json}; +use uuid::Uuid; + +use crate::auth::{AuthenticatedUser, TenantMembershipUser}; +use crate::error::AppResult; +use crate::http::responders::JsonResponse; +use crate::services::auth::{AuthService, TenantSnippet}; +use crate::services::tenants::{ + TenantApiService, TenantUserListResponse, TenantUserSummary, UpdateTenantRequest, + UpdateTenantUserRequest, +}; +use crate::state::AppState; + +#[utoipa::path( + get, + path = "/api/tenants", + responses((status = 200, body = [TenantSnippet], description = "Tenant memberships for the current user")), + tag = "Tenants" +)] +pub async fn list_tenants( + State(state): State, + user: TenantMembershipUser, +) -> AppResult>> { + let response = AuthService::new(&state).list_tenants(user.user_id)?; + Ok(Json(response.into_inner().tenants)) +} + +#[utoipa::path( + get, + path = "/api/tenants/{tenant_id}", + params(("tenant_id" = Uuid, Path, description = "Tenant identifier")), + responses((status = 200, body = TenantSnippet, description = "Tenant details")), + tag = "Tenants" +)] +pub async fn get_tenant( + State(state): State, + Path(tenant_id): Path, + user: TenantMembershipUser, +) -> AppResult> { + AuthService::new(&state).get_tenant(user.user_id, tenant_id) +} + +#[utoipa::path( + patch, + path = "/api/tenants/{tenant_id}", + params(("tenant_id" = Uuid, Path, description = "Tenant identifier")), + request_body = UpdateTenantRequest, + responses((status = 200, body = TenantSnippet, description = "Updated tenant")), + tag = "Tenants" +)] +pub async fn update_tenant( + State(state): State, + Path(tenant_id): Path, + user: AuthenticatedUser, + Json(payload): Json, +) -> AppResult> { + TenantApiService::new(&state).update_name(user, tenant_id, payload) +} + +#[utoipa::path( + get, + path = "/api/tenants/{tenant_id}/users", + params(("tenant_id" = Uuid, Path, description = "Tenant ID")), + responses((status = 200, body = [TenantUserSummary], description = "All users for the tenant")), + tag = "Tenants" +)] +pub async fn list_tenant_users( + State(state): State, + Path(tenant_id): Path, + user: AuthenticatedUser, +) -> AppResult>> { + let response = TenantApiService::new(&state).list_users(&user, tenant_id)?; + Ok(Json(response.into_inner().users)) +} + +#[utoipa::path( + get, + path = "/api/tenants/{tenant_id}/users/{user_id}", + params( + ("tenant_id" = Uuid, Path, description = "Tenant ID"), + ("user_id" = Uuid, Path, description = "User ID") + ), + responses((status = 200, body = TenantUserSummary, description = "Tenant user details")), + tag = "Tenants" +)] +pub async fn get_tenant_user( + State(state): State, + Path((tenant_id, target_user_id)): Path<(Uuid, Uuid)>, + user: AuthenticatedUser, +) -> AppResult> { + TenantApiService::new(&state).get_user(&user, tenant_id, target_user_id) +} + +#[utoipa::path( + patch, + path = "/api/tenants/{tenant_id}/users/{user_id}", + params( + ("tenant_id" = Uuid, Path, description = "Tenant ID"), + ("user_id" = Uuid, Path, description = "User ID") + ), + request_body = UpdateTenantUserRequest, + responses((status = 200, body = TenantUserSummary, description = "Updated tenant user")), + tag = "Tenants" +)] +pub async fn update_tenant_user( + State(state): State, + Path((tenant_id, target_user_id)): Path<(Uuid, Uuid)>, + user: AuthenticatedUser, + Json(payload): Json, +) -> AppResult> { + TenantApiService::new(&state).update_user(&user, tenant_id, target_user_id, payload) +} + +#[utoipa::path( + delete, + path = "/api/tenants/{tenant_id}/users/{user_id}", + params( + ("tenant_id" = Uuid, Path, description = "Tenant ID"), + ("user_id" = Uuid, Path, description = "User ID") + ), + responses((status = 204, description = "Membership removed")), + tag = "Tenants" +)] +pub async fn delete_tenant_user( + State(state): State, + Path((tenant_id, target_user_id)): Path<(Uuid, Uuid)>, + user: AuthenticatedUser, +) -> AppResult { + TenantApiService::new(&state).remove_user(&user, tenant_id, target_user_id)?; + Ok(StatusCode::NO_CONTENT) +} + +#[derive(utoipa::OpenApi)] +#[openapi( + paths( + list_tenants, + get_tenant, + update_tenant, + list_tenant_users, + get_tenant_user, + update_tenant_user, + delete_tenant_user, + ), + components(schemas( + crate::services::auth::TenantListResponse, + crate::services::auth::TenantSnippet, + UpdateTenantRequest, + UpdateTenantUserRequest, + TenantUserListResponse, + TenantUserSummary, + )) +)] +pub struct TenantsApiDoc; diff --git a/backend/src/routes/webdav/mod.rs b/backend/src/routes/webdav/mod.rs new file mode 100644 index 0000000..5b14b61 --- /dev/null +++ b/backend/src/routes/webdav/mod.rs @@ -0,0 +1,846 @@ +use std::time::Duration; + +use axum::body::Body; +use axum::extract::State; +use axum::http::{header, HeaderMap, Method, StatusCode}; +use axum::response::Response; +use axum::Router; +use base64::engine::general_purpose::STANDARD as BASE64; +use base64::Engine; +use diesel::prelude::*; +use diesel::OptionalExtension; +use diesel::PgConnection; +use futures_util::StreamExt; +use percent_encoding::{percent_decode_str, utf8_percent_encode, NON_ALPHANUMERIC}; +use quick_xml::events::{BytesDecl, BytesEnd, BytesStart, BytesText, Event}; +use quick_xml::Writer; +use uuid::Uuid; + +use crate::auth::{ + api_tokens::{find_active_token_by_secret, touch_api_token}, + ensure_active_tenant_with_conn, +}; +use crate::error::{AppError, AppResult}; +use crate::models::{ApiCapability, Document, DocumentVersion, Folder, User}; +use crate::schema::{ + document_versions::dsl as document_versions_dsl, documents::dsl as documents_dsl, + folders::dsl as folders_dsl, user_memberships::dsl as memberships_dsl, users::dsl as users_dsl, +}; +use crate::state::{AppState, PgPooledConnection}; +use crate::tenants::{apply_tenant_guc, apply_user_guc, clear_user_guc}; +use crate::utils::{error::StorageResultExt, http::inline_content_disposition, time::to_http_date}; + +const REALM: &str = "Papercrate WebDAV"; +const DOWNLOAD_URL_TTL_SECONDS: u64 = 300; + +struct WebDavContext { + tenant_id: Uuid, + _user_id: Uuid, + _username: String, + conn: PgPooledConnection, +} + +pub fn create_router() -> Router { + Router::new().fallback(webdav_entrypoint) +} + +async fn webdav_entrypoint( + State(state): State, + req: axum::http::Request, +) -> Result { + let method = req.method().clone(); + let headers = req.headers().clone(); + let path = req.uri().path().trim_start_matches('/').to_string(); + + tracing::debug!(method = %method, %path, "webdav entrypoint" ); + + match method { + ref m if m == Method::OPTIONS => Ok(handle_options()), + ref m if m == Method::GET => handle_get_or_head(&state, &path, headers, Method::GET).await, + ref m if m == Method::HEAD => { + handle_get_or_head(&state, &path, headers, Method::HEAD).await + } + _ => { + if method.as_str() == "PROPFIND" { + handle_propfind(&state, &path, headers).await + } else { + Ok(method_not_allowed()) + } + } + } +} + +async fn handle_propfind( + state: &AppState, + path: &str, + headers: HeaderMap, +) -> Result { + let mut context = match authenticate(state, &headers)? { + Some(user) => user, + None => return Ok(unauthorized_response()), + }; + + let depth = match parse_depth(&headers) { + Ok(value) => value, + Err(response) => return Ok(response), + }; + + let segments = parse_segments(path)?; + + let tenant_id = context.tenant_id; + + let resources = if segments.is_empty() { + let contents = fetch_folder_contents(&mut context.conn, tenant_id, None)?; + build_resources_for_folder(None, &[], &contents, depth) + } else { + let resolution = match resolve_path(&mut context.conn, tenant_id, &segments)? { + Some(resolved) => resolved, + None => return Ok(not_found_response()), + }; + + match resolution { + ResolvedPath::Folder { folder, chain } => { + let contents = + fetch_folder_contents(&mut context.conn, tenant_id, Some(folder.id))?; + build_resources_for_folder(Some(&folder), &chain, &contents, depth) + } + ResolvedPath::Document { + document, + version, + chain, + } => build_resources_for_document(&chain, &document, &version), + } + }; + + let body = render_multistatus(&resources).map_err(|err| { + tracing::error!(error = ?err, "failed to render WebDAV response"); + AppError::internal("failed to render WebDAV response") + })?; + + let response = Response::builder() + .status(multi_status()) + .header(header::CONTENT_TYPE, "application/xml; charset=utf-8") + .body(Body::from(body)) + .expect("valid response"); + + Ok(response) +} + +async fn handle_get_or_head( + state: &AppState, + path: &str, + headers: HeaderMap, + method: Method, +) -> Result { + let mut context = match authenticate(state, &headers)? { + Some(user) => user, + None => return Ok(unauthorized_response()), + }; + + let tenant_id = context.tenant_id; + let segments = parse_segments(path)?; + if segments.is_empty() { + return Ok(method_not_allowed()); + } + + let resolution = match resolve_path(&mut context.conn, tenant_id, &segments)? { + Some(resolved) => resolved, + None => return Ok(not_found_response()), + }; + + let (document, version, chain) = match resolution { + ResolvedPath::Document { + document, + version, + chain, + } => (document, version, chain), + _ => return Ok(method_not_allowed()), + }; + + stream_document(state, &document, &version, &chain, headers, method).await +} + +fn handle_options() -> Response { + Response::builder() + .status(StatusCode::OK) + .header("DAV", "1,2") + .header(header::ALLOW, "OPTIONS, PROPFIND, GET, HEAD") + .header("Accept-Ranges", "bytes") + .body(Body::empty()) + .expect("valid OPTIONS response") +} + +fn method_not_allowed() -> Response { + Response::builder() + .status(StatusCode::METHOD_NOT_ALLOWED) + .body(Body::empty()) + .expect("valid response") +} + +fn not_found_response() -> Response { + Response::builder() + .status(StatusCode::NOT_FOUND) + .body(Body::empty()) + .expect("valid response") +} + +fn unauthorized_response() -> Response { + Response::builder() + .status(StatusCode::UNAUTHORIZED) + .header( + header::WWW_AUTHENTICATE, + format!("Basic realm=\"{REALM}\", charset=\"UTF-8\""), + ) + .body(Body::empty()) + .expect("valid response") +} + +fn multi_status() -> StatusCode { + StatusCode::from_u16(207).expect("valid multi-status") +} + +fn parse_depth(headers: &HeaderMap) -> Result { + match headers.get("Depth") { + None => Ok(1), + Some(value) => match value.to_str() { + Ok("0") => Ok(0), + Ok("1") => Ok(1), + Ok("infinity") => Err(Response::builder() + .status(StatusCode::FORBIDDEN) + .body(Body::empty()) + .expect("valid response")), + _ => Err(Response::builder() + .status(StatusCode::BAD_REQUEST) + .body(Body::empty()) + .expect("valid response")), + }, + } +} + +fn parse_segments(path: &str) -> AppResult> { + if path.trim_matches('/').is_empty() { + return Ok(vec![]); + } + + let segments = path + .split('/') + .filter(|segment| !segment.is_empty()) + .map(|segment| { + percent_decode_str(segment) + .decode_utf8() + .map(|cow| cow.into_owned()) + .map_err(|_| AppError::bad_request("invalid UTF-8 in path")) + }) + .collect::, _>>()?; + + Ok(segments) +} + +fn fetch_folder_contents( + conn: &mut PgPooledConnection, + tenant_id: Uuid, + folder_id: Option, +) -> AppResult { + let folder = match folder_id { + Some(id) => Some( + folders_dsl::folders + .filter(folders_dsl::tenant_id.eq(tenant_id)) + .find(id) + .first::(conn)?, + ), + None => None, + }; + + let subfolders: Vec = match folder_id { + Some(id) => folders_dsl::folders + .filter(folders_dsl::tenant_id.eq(tenant_id)) + .filter(folders_dsl::parent_id.eq(Some(id))) + .order(folders_dsl::name.asc()) + .load(conn)?, + None => folders_dsl::folders + .filter(folders_dsl::tenant_id.eq(tenant_id)) + .filter(folders_dsl::parent_id.is_null()) + .order(folders_dsl::name.asc()) + .load(conn)?, + }; + + let mut docs_query = documents_dsl::documents + .filter(documents_dsl::deleted_at.is_null()) + .filter(documents_dsl::tenant_id.eq(tenant_id)) + .into_boxed(); + + docs_query = match folder_id { + Some(id) => docs_query.filter(documents_dsl::folder_id.eq(Some(id))), + None => docs_query.filter(documents_dsl::folder_id.is_null()), + }; + + let documents: Vec = docs_query + .order(documents_dsl::created_at.desc()) + .load(conn)?; + + let version_ids: Vec = documents.iter().map(|doc| doc.current_version_id).collect(); + let versions: Vec = if version_ids.is_empty() { + Vec::new() + } else { + document_versions_dsl::document_versions + .filter(document_versions_dsl::id.eq_any(&version_ids)) + .load(conn)? + }; + + let mut version_map = versions + .into_iter() + .map(|version| (version.id, version)) + .collect::>(); + + let mut entries = Vec::with_capacity(documents.len()); + for document in documents { + if let Some(version) = version_map.remove(&document.current_version_id) { + entries.push(DocumentEntry { document, version }); + } + } + + Ok(WebDavFolderContents { + _folder: folder, + subfolders, + documents: entries, + }) +} + +async fn stream_document( + state: &AppState, + document: &Document, + version: &DocumentVersion, + _chain: &[String], + headers: HeaderMap, + method: Method, +) -> Result { + let range_header = headers.get(header::RANGE).cloned(); + + let storage = state.storage_for_tenant(document.tenant_id)?; + + let url = storage + .presign_get_object( + &version.s3_key, + Duration::from_secs(DOWNLOAD_URL_TTL_SECONDS), + None, + ) + .await + .storage_context("failed to presign document download")?; + + let client = reqwest::Client::new(); + let mut request = client.request(method.clone(), url.clone()); + + if let Some(range) = range_header.clone() { + request = request.header(header::RANGE, range.clone()); + } + + let upstream = request.send().await.map_err(|err| { + tracing::error!(error = ?err, "failed to fetch document stream"); + AppError::internal("failed to fetch document stream") + })?; + + let status = + StatusCode::from_u16(upstream.status().as_u16()).unwrap_or(StatusCode::BAD_GATEWAY); + + if !(status.is_success() || status == StatusCode::PARTIAL_CONTENT) { + tracing::error!(status = %status, "upstream download returned error status"); + return Err(AppError::internal("failed to fetch document stream")); + } + + let mut builder = Response::builder().status(status); + + if let Some(content_type) = upstream.headers().get(header::CONTENT_TYPE) { + builder = builder.header(header::CONTENT_TYPE, content_type); + } else if let Some(ref typ) = document.mime_type { + builder = builder.header(header::CONTENT_TYPE, typ); + } + + if let Some(content_length) = upstream.headers().get(header::CONTENT_LENGTH) { + builder = builder.header(header::CONTENT_LENGTH, content_length); + } + + if let Some(range) = upstream.headers().get(header::CONTENT_RANGE) { + builder = builder.header(header::CONTENT_RANGE, range); + } + + builder = builder.header("Accept-Ranges", "bytes"); + + if let Some(disposition) = inline_content_disposition(&document.filename) { + builder = builder.header(header::CONTENT_DISPOSITION, disposition); + } + + builder = builder.header(header::ETAG, format!("\"{}\"", version.id)); + + if method == Method::HEAD { + return builder.body(Body::empty()).map_err(|err| { + tracing::error!(error = ?err, "failed to build WebDAV response"); + AppError::internal("failed to build WebDAV response") + }); + } + + let stream = upstream + .bytes_stream() + .map(|chunk| chunk.map_err(|err| std::io::Error::new(std::io::ErrorKind::Other, err))); + let body = Body::from_stream(stream); + + builder.body(body).map_err(|err| { + tracing::error!(error = ?err, "failed to build WebDAV response"); + AppError::internal("failed to build WebDAV response") + }) +} + +fn authenticate(state: &AppState, headers: &HeaderMap) -> Result, AppError> { + tracing::debug!("webdav authenticate invoked"); + let authorization = match headers.get(header::AUTHORIZATION) { + Some(value) => match value.to_str() { + Ok(header) if header.starts_with("Basic ") => { + tracing::debug!("authorization header present"); + &header[6..] + } + Ok(other) => { + tracing::warn!(header = %other, "non-basic authorization header"); + return Ok(None); + } + Err(err) => { + tracing::warn!(error = %err, "invalid authorization header"); + return Ok(None); + } + }, + None => { + tracing::debug!("no authorization header"); + return Ok(None); + } + }; + + let decoded = match BASE64.decode(authorization) { + Ok(bytes) => bytes, + Err(err) => { + tracing::warn!(error = %err, "failed to decode basic credentials"); + return Ok(None); + } + }; + + let credential_str = match String::from_utf8(decoded) { + Ok(value) => value, + Err(err) => { + tracing::warn!(error = %err, "invalid utf-8 basic credentials"); + return Ok(None); + } + }; + + let (presented_username, secret) = match credential_str.split_once(':') { + Some((username, secret)) if !username.is_empty() => (username, secret), + _ => return Ok(None), + }; + + tracing::debug!(presented_username = %presented_username, "attempting webdav login"); + let mut conn = state.db_unscoped()?; + + let token = match find_active_token_by_secret( + &mut conn, + None, + secret, + Some(ApiCapability::WebdavRead), + )? { + Some(token) => token, + None => { + tracing::warn!(presented_username = %presented_username, "webdav token invalid or expired"); + return Ok(None); + } + }; + + let user: User = match users_dsl::users.find(token.user_id).first(&mut conn) { + Ok(user) => user, + Err(diesel::result::Error::NotFound) => { + tracing::warn!( + presented_username = %presented_username, + user_id = %token.user_id, + "webdav token user missing" + ); + return Ok(None); + } + Err(err) => return Err(AppError::from(err)), + }; + + apply_user_guc(&mut conn, user.id)?; + + let membership_exists = memberships_dsl::user_memberships + .filter(memberships_dsl::user_id.eq(user.id)) + .filter(memberships_dsl::tenant_id.eq(token.tenant_id)) + .select(memberships_dsl::tenant_id) + .first::(&mut conn) + .optional()?; + + clear_user_guc(&mut conn)?; + + let tenant_id = match membership_exists { + Some(id) => id, + None => { + tracing::warn!( + presented_username = %presented_username, + username = %user.username, + tenant_id = %token.tenant_id, + "webdav token tenant membership missing" + ); + return Ok(None); + } + }; + + if let Err(err) = ensure_active_tenant_with_conn(&mut conn, tenant_id) { + tracing::warn!(tenant_id = %tenant_id, error = ?err, "webdav tenant not active"); + return Ok(None); + } + + apply_tenant_guc(&mut conn, tenant_id)?; + touch_api_token(&mut conn, token.id)?; + + tracing::debug!( + presented_username = %presented_username, + username = %user.username, + tenant_id = %tenant_id, + token_id = %token.id, + "webdav token login success" + ); + Ok(Some(WebDavContext { + tenant_id, + _user_id: user.id, + _username: user.username, + conn, + })) +} + +fn build_resources_for_folder( + folder: Option<&Folder>, + chain: &[String], + contents: &WebDavFolderContents, + depth: u8, +) -> Vec { + let mut resources = Vec::new(); + + let display_name = folder + .map(|folder| folder.name.clone()) + .unwrap_or_else(|| chain.last().cloned().unwrap_or_else(|| "/".to_string())); + + let href = build_href(chain, true); + let last_modified = folder.map(|folder| to_http_date(folder.updated_at)); + + resources.push(DavResource { + href, + display_name, + is_collection: true, + content_length: None, + mime_type: None, + last_modified, + }); + + if depth == 0 { + return resources; + } + + for subfolder in &contents.subfolders { + let mut child_chain = chain.to_vec(); + child_chain.push(subfolder.name.clone()); + resources.push(DavResource { + href: build_href(&child_chain, true), + display_name: subfolder.name.clone(), + is_collection: true, + content_length: None, + mime_type: None, + last_modified: Some(to_http_date(subfolder.updated_at)), + }); + } + + for entry in &contents.documents { + let mut child_chain = chain.to_vec(); + child_chain.push(entry.document.filename.clone()); + resources.push(document_to_resource( + &child_chain, + &entry.document, + &entry.version, + )); + } + + resources +} + +fn build_resources_for_document( + chain: &[String], + document: &Document, + version: &DocumentVersion, +) -> Vec { + vec![document_to_resource(chain, document, version)] +} + +fn document_to_resource( + chain: &[String], + document: &Document, + version: &DocumentVersion, +) -> DavResource { + let href = build_href(chain, false); + + DavResource { + href, + display_name: document.title.clone(), + is_collection: false, + content_length: Some(version.size_bytes), + mime_type: document.mime_type.clone(), + last_modified: Some(to_http_date(document.updated_at)), + } +} + +fn build_href(names: &[String], is_collection: bool) -> String { + if names.is_empty() { + return "/".to_string(); + } + + let encoded = names + .iter() + .map(|name| utf8_percent_encode(name, NON_ALPHANUMERIC).to_string()) + .collect::>(); + + let mut path = format!("/{}", encoded.join("/")); + if is_collection && !path.ends_with('/') { + path.push('/'); + } + path +} + +fn render_multistatus(resources: &[DavResource]) -> Result, quick_xml::Error> { + let mut writer = Writer::new(Vec::new()); + writer.write_event(Event::Decl(BytesDecl::new("1.0", Some("UTF-8"), None)))?; + + let mut multistatus = BytesStart::new("D:multistatus"); + multistatus.push_attribute(("xmlns:D", "DAV:")); + writer.write_event(Event::Start(multistatus))?; + + for resource in resources { + writer.write_event(Event::Start(BytesStart::new("D:response")))?; + + writer.write_event(Event::Start(BytesStart::new("D:href")))?; + writer.write_event(Event::Text(BytesText::new(&resource.href)))?; + writer.write_event(Event::End(BytesEnd::new("D:href")))?; + + writer.write_event(Event::Start(BytesStart::new("D:propstat")))?; + writer.write_event(Event::Start(BytesStart::new("D:prop")))?; + + writer.write_event(Event::Start(BytesStart::new("D:displayname")))?; + writer.write_event(Event::Text(BytesText::new(&resource.display_name)))?; + writer.write_event(Event::End(BytesEnd::new("D:displayname")))?; + + writer.write_event(Event::Start(BytesStart::new("D:resourcetype")))?; + if resource.is_collection { + writer.write_event(Event::Empty(BytesStart::new("D:collection")))?; + } + writer.write_event(Event::End(BytesEnd::new("D:resourcetype")))?; + + if let Some(length) = resource.content_length { + writer.write_event(Event::Start(BytesStart::new("D:getcontentlength")))?; + writer.write_event(Event::Text(BytesText::new(&length.to_string())))?; + writer.write_event(Event::End(BytesEnd::new("D:getcontentlength")))?; + } + + if let Some(content_type) = &resource.mime_type { + writer.write_event(Event::Start(BytesStart::new("D:getcontenttype")))?; + writer.write_event(Event::Text(BytesText::new(content_type)))?; + writer.write_event(Event::End(BytesEnd::new("D:getcontenttype")))?; + } + + if let Some(last_modified) = &resource.last_modified { + writer.write_event(Event::Start(BytesStart::new("D:getlastmodified")))?; + writer.write_event(Event::Text(BytesText::new(last_modified)))?; + writer.write_event(Event::End(BytesEnd::new("D:getlastmodified")))?; + } + + writer.write_event(Event::End(BytesEnd::new("D:prop")))?; + + writer.write_event(Event::Start(BytesStart::new("D:status")))?; + writer.write_event(Event::Text(BytesText::new("HTTP/1.1 200 OK")))?; + writer.write_event(Event::End(BytesEnd::new("D:status")))?; + + writer.write_event(Event::End(BytesEnd::new("D:propstat")))?; + writer.write_event(Event::End(BytesEnd::new("D:response")))?; + } + + writer.write_event(Event::End(BytesEnd::new("D:multistatus")))?; + Ok(writer.into_inner()) +} + +struct WebDavFolderContents { + _folder: Option, + subfolders: Vec, + documents: Vec, +} + +struct DocumentEntry { + document: Document, + version: DocumentVersion, +} + +struct DavResource { + href: String, + display_name: String, + is_collection: bool, + content_length: Option, + mime_type: Option, + last_modified: Option, +} +enum ResolvedPath { + Folder { + folder: Folder, + chain: Vec, + }, + Document { + document: Document, + version: DocumentVersion, + chain: Vec, + }, +} + +fn resolve_path( + conn: &mut PgPooledConnection, + tenant_id: Uuid, + segments: &[String], +) -> AppResult> { + let mut parent_id: Option = None; + let mut chain: Vec = Vec::new(); + let mut current_folder: Option = None; + + for (index, segment) in segments.iter().enumerate() { + let is_last = index == segments.len() - 1; + + if let Some(folder) = find_folder_by_name(conn, tenant_id, parent_id, segment)? { + chain.push(folder.name.clone()); + if is_last { + return Ok(Some(ResolvedPath::Folder { folder, chain })); + } + parent_id = Some(folder.id); + current_folder = Some(folder); + continue; + } + + if is_last { + if let Some((document, version)) = + find_document_by_filename(conn, tenant_id, parent_id, segment)? + { + chain.push(document.filename.clone()); + return Ok(Some(ResolvedPath::Document { + document, + version, + chain, + })); + } + } + + if let Ok(uuid) = Uuid::parse_str(segment) { + if let Some(folder) = find_folder_by_id(conn, tenant_id, uuid)? { + if folder.parent_id != parent_id { + return Ok(None); + } + chain.push(folder.name.clone()); + if is_last { + return Ok(Some(ResolvedPath::Folder { folder, chain })); + } + parent_id = Some(folder.id); + current_folder = Some(folder); + continue; + } + + if let Some((document, version)) = find_document_by_id(conn, tenant_id, uuid)? { + if document.folder_id != parent_id { + return Ok(None); + } + chain.push(document.filename.clone()); + return Ok(Some(ResolvedPath::Document { + document, + version, + chain, + })); + } + } + + return Ok(None); + } + + Ok(current_folder.map(|folder| ResolvedPath::Folder { folder, chain })) +} + +fn find_folder_by_name( + conn: &mut PgConnection, + tenant_id: Uuid, + parent_id: Option, + name: &str, +) -> AppResult> { + let mut query = folders_dsl::folders + .filter(folders_dsl::tenant_id.eq(tenant_id)) + .into_boxed(); + + query = match parent_id { + Some(parent) => query.filter(folders_dsl::parent_id.eq(Some(parent))), + None => query.filter(folders_dsl::parent_id.is_null()), + }; + + Ok(query + .filter(folders_dsl::name.eq(name)) + .first::(conn) + .optional()?) +} + +fn find_folder_by_id( + conn: &mut PgConnection, + tenant_id: Uuid, + folder_id: Uuid, +) -> AppResult> { + Ok(folders_dsl::folders + .filter(folders_dsl::tenant_id.eq(tenant_id)) + .find(folder_id) + .first::(conn) + .optional()?) +} + +fn find_document_by_filename( + conn: &mut PgConnection, + tenant_id: Uuid, + parent_id: Option, + filename: &str, +) -> AppResult> { + let mut query = documents_dsl::documents + .filter(documents_dsl::deleted_at.is_null()) + .filter(documents_dsl::tenant_id.eq(tenant_id)) + .filter(documents_dsl::filename.eq(filename)) + .into_boxed(); + + query = match parent_id { + Some(parent) => query.filter(documents_dsl::folder_id.eq(Some(parent))), + None => query.filter(documents_dsl::folder_id.is_null()), + }; + + if let Some(document) = query.first::(conn).optional()? { + let version = document_versions_dsl::document_versions + .find(document.current_version_id) + .first::(conn)?; + return Ok(Some((document, version))); + } + + Ok(None) +} + +fn find_document_by_id( + conn: &mut PgConnection, + tenant_id: Uuid, + document_id: Uuid, +) -> AppResult> { + if let Some(document) = documents_dsl::documents + .filter(documents_dsl::deleted_at.is_null()) + .filter(documents_dsl::tenant_id.eq(tenant_id)) + .find(document_id) + .first::(conn) + .optional()? + { + let version = document_versions_dsl::document_versions + .find(document.current_version_id) + .first::(conn)?; + return Ok(Some((document, version))); + } + + Ok(None) +} diff --git a/backend/src/s3.rs b/backend/src/s3.rs new file mode 100644 index 0000000..9791146 --- /dev/null +++ b/backend/src/s3.rs @@ -0,0 +1,34 @@ +use anyhow::{anyhow, Context, Result}; +use s3::{bucket::Bucket, creds::Credentials, region::Region}; + +use crate::config::AppConfig; + +pub fn build_bucket(config: &AppConfig) -> Result { + let region = if let Some(endpoint) = &config.aws_endpoint_url { + Region::Custom { + region: config.aws_region.clone(), + endpoint: endpoint.clone(), + } + } else { + config + .aws_region + .parse::() + .context("invalid AWS region")? + }; + + let credentials = if let (Some(access_key), Some(secret_key)) = ( + config.aws_access_key_id.as_deref(), + config.aws_secret_access_key.as_deref(), + ) { + Credentials::new(Some(access_key), Some(secret_key), None, None, None) + .context("failed to create static AWS credentials")? + } else { + Credentials::default().context("failed to load AWS credentials")? + }; + + let bucket = Bucket::new(&config.s3_bucket, region, credentials) + .map_err(|err| anyhow!("failed to create S3 bucket client: {err}"))?; + let bucket = bucket.with_path_style(); + + Ok(*bucket) +} diff --git a/backend/src/schema.rs b/backend/src/schema.rs new file mode 100644 index 0000000..c21d80e --- /dev/null +++ b/backend/src/schema.rs @@ -0,0 +1,334 @@ +// @generated automatically by Diesel CLI. + +pub mod sql_types { + #[derive(diesel::query_builder::QueryId, Clone, diesel::sql_types::SqlType)] + #[diesel(postgres_type(name = "magic_token_kind"))] + pub struct MagicTokenKind; + + #[derive(diesel::query_builder::QueryId, Clone, diesel::sql_types::SqlType)] + #[diesel(postgres_type(name = "tenant_status"))] + pub struct TenantStatus; + + #[derive(diesel::query_builder::QueryId, Clone, diesel::sql_types::SqlType)] + #[diesel(postgres_type(name = "api_capability"))] + pub struct ApiCapability; +} + +diesel::table! { + correspondents (id) { + id -> Uuid, + #[max_length = 255] + name -> Varchar, + metadata -> Jsonb, + created_at -> Timestamptz, + updated_at -> Timestamptz, + tenant_id -> Uuid, + } +} + +diesel::table! { + document_assets (id) { + id -> Uuid, + document_version_id -> Uuid, + asset_type -> Text, + mime_type -> Text, + metadata -> Jsonb, + created_at -> Timestamptz, + s3_key -> Text, + tenant_id -> Uuid, + } +} + +diesel::table! { + document_correspondents (document_id, correspondent_id) { + document_id -> Uuid, + correspondent_id -> Uuid, + assigned_at -> Timestamptz, + assigned_by -> Nullable, + tenant_id -> Uuid, + } +} + +diesel::table! { + document_tags (document_id, tag_id) { + document_id -> Uuid, + tag_id -> Uuid, + assigned_at -> Timestamptz, + assigned_by -> Nullable, + tenant_id -> Uuid, + } +} + +diesel::table! { + document_versions (id) { + id -> Uuid, + document_id -> Uuid, + version_number -> Int4, + #[max_length = 500] + s3_key -> Varchar, + size_bytes -> Int8, + #[max_length = 64] + checksum -> Varchar, + created_at -> Timestamptz, + metadata -> Jsonb, + tenant_id -> Uuid, + } +} + +diesel::table! { + documents (id) { + id -> Uuid, + #[max_length = 255] + filename -> Varchar, + #[max_length = 255] + original_name -> Varchar, + #[max_length = 100] + mime_type -> Nullable, + folder_id -> Nullable, + created_at -> Timestamptz, + updated_at -> Timestamptz, + deleted_at -> Nullable, + metadata -> Jsonb, + issued_at -> Nullable, + #[max_length = 255] + title -> Varchar, + current_version_id -> Uuid, + tenant_id -> Uuid, + } +} + +diesel::table! { + folders (id) { + id -> Uuid, + #[max_length = 255] + name -> Varchar, + parent_id -> Nullable, + created_at -> Timestamptz, + updated_at -> Timestamptz, + tenant_id -> Uuid, + } +} + +diesel::table! { + jobs (id) { + id -> Uuid, + job_type -> Text, + payload -> Jsonb, + status -> Text, + attempts -> Int4, + run_after -> Timestamptz, + last_error -> Nullable, + created_at -> Timestamptz, + updated_at -> Timestamptz, + tenant_id -> Nullable, + result -> Nullable, + } +} + +diesel::table! { + use diesel::sql_types::*; + use super::sql_types::MagicTokenKind; + + magic_tokens (id) { + id -> Uuid, + user_id -> Uuid, + kind -> MagicTokenKind, + token_hash -> Varchar, + metadata -> Jsonb, + expires_at -> Timestamptz, + max_uses -> Nullable, + used_count -> Int4, + created_at -> Timestamptz, + created_by -> Nullable, + last_used_at -> Nullable, + } +} + +diesel::table! { + user_sessions (id) { + id -> Uuid, + user_id -> Uuid, + token_hash -> Text, + issued_at -> Timestamptz, + expires_at -> Timestamptz, + revoked_at -> Nullable, + created_at -> Timestamptz, + updated_at -> Timestamptz, + tenant_id -> Uuid, + } +} + +diesel::table! { + tags (id) { + id -> Uuid, + #[max_length = 100] + label -> Varchar, + #[max_length = 7] + color -> Nullable, + created_at -> Timestamptz, + tenant_id -> Uuid, + } +} + +diesel::table! { + use diesel::sql_types::*; + use super::sql_types::TenantStatus; + + tenants (id) { + id -> Uuid, + name -> Text, + storage_root -> Nullable, + quickwit_index -> Nullable, + config -> Jsonb, + created_at -> Timestamptz, + updated_at -> Timestamptz, + status -> TenantStatus, + created_by -> Nullable, + } +} + +diesel::table! { + user_memberships (id) { + id -> Uuid, + user_id -> Uuid, + tenant_id -> Uuid, + created_at -> Timestamptz, + updated_at -> Timestamptz, + capability_set_id -> Nullable, + } +} + +diesel::table! { + user_passkeys (id) { + id -> Uuid, + user_id -> Uuid, + credential_id -> Bytea, + public_key -> Bytea, + credential -> Jsonb, + sign_count -> Int8, + transports -> Array>, + aaguid -> Nullable, + nickname -> Nullable, + created_at -> Timestamptz, + updated_at -> Timestamptz, + last_used_at -> Nullable, + revoked_at -> Nullable, + revoked_by -> Nullable, + revoked_reason -> Nullable, + } +} + +diesel::table! { + users (id) { + id -> Uuid, + #[max_length = 100] + username -> Varchar, + created_at -> Timestamptz, + updated_at -> Timestamptz, + } +} + +diesel::table! { + webauthn_challenges (id) { + id -> Uuid, + user_id -> Nullable, + purpose -> Text, + challenge -> Bytea, + state -> Bytea, + created_at -> Timestamptz, + expires_at -> Timestamptz, + } +} + +diesel::table! { + use diesel::sql_types::*; + + capability_sets (id) { + id -> Uuid, + tenant_id -> Uuid, + slug -> Text, + cap_version -> Int4, + is_system -> Bool, + created_at -> Timestamptz, + updated_at -> Timestamptz, + } +} + +diesel::table! { + use diesel::sql_types::*; + use super::sql_types::ApiCapability; + + capability_set_capabilities (capability_set_id, capability) { + capability_set_id -> Uuid, + capability -> ApiCapability, + } +} + +diesel::table! { + api_tokens (id) { + id -> Uuid, + user_id -> Uuid, + tenant_id -> Uuid, + token_prefix -> Text, + token_hash -> Text, + label -> Nullable, + created_at -> Timestamptz, + last_used_at -> Nullable, + expires_at -> Nullable, + revoked_at -> Nullable, + capability_set_id -> Uuid, + } +} + +diesel::joinable!(correspondents -> tenants (tenant_id)); +diesel::joinable!(capability_set_capabilities -> capability_sets (capability_set_id)); +diesel::joinable!(capability_sets -> tenants (tenant_id)); +diesel::joinable!(document_assets -> document_versions (document_version_id)); +diesel::joinable!(document_assets -> tenants (tenant_id)); +diesel::joinable!(document_correspondents -> correspondents (correspondent_id)); +diesel::joinable!(document_correspondents -> documents (document_id)); +diesel::joinable!(document_correspondents -> tenants (tenant_id)); +diesel::joinable!(document_correspondents -> users (assigned_by)); +diesel::joinable!(document_tags -> documents (document_id)); +diesel::joinable!(document_tags -> tags (tag_id)); +diesel::joinable!(document_tags -> tenants (tenant_id)); +diesel::joinable!(document_tags -> users (assigned_by)); +diesel::joinable!(document_versions -> tenants (tenant_id)); +diesel::joinable!(documents -> folders (folder_id)); +diesel::joinable!(documents -> tenants (tenant_id)); +diesel::joinable!(folders -> tenants (tenant_id)); +diesel::joinable!(jobs -> tenants (tenant_id)); +diesel::joinable!(magic_tokens -> users (user_id)); +diesel::joinable!(user_sessions -> tenants (tenant_id)); +diesel::joinable!(user_sessions -> users (user_id)); +diesel::joinable!(tags -> tenants (tenant_id)); +diesel::joinable!(user_memberships -> capability_sets (capability_set_id)); +diesel::joinable!(user_memberships -> tenants (tenant_id)); +diesel::joinable!(user_memberships -> users (user_id)); +diesel::joinable!(user_passkeys -> users (user_id)); +diesel::joinable!(webauthn_challenges -> users (user_id)); +diesel::joinable!(api_tokens -> tenants (tenant_id)); +diesel::joinable!(api_tokens -> capability_sets (capability_set_id)); +diesel::joinable!(api_tokens -> users (user_id)); + +diesel::allow_tables_to_appear_in_same_query!( + api_tokens, + correspondents, + capability_set_capabilities, + capability_sets, + document_assets, + document_correspondents, + document_tags, + document_versions, + documents, + folders, + jobs, + magic_tokens, + user_sessions, + tags, + tenants, + user_memberships, + user_passkeys, + users, + webauthn_challenges, +); diff --git a/backend/src/services/auth.rs b/backend/src/services/auth.rs new file mode 100644 index 0000000..bb82af6 --- /dev/null +++ b/backend/src/services/auth.rs @@ -0,0 +1,884 @@ +use axum::http::{header::SET_COOKIE, HeaderMap, HeaderValue, StatusCode}; +use axum::response::{IntoResponse, Response}; +use chrono::{Duration as ChronoDuration, TimeZone, Utc}; +use diesel::{pg::PgConnection, prelude::*, Connection, OptionalExtension}; +use rand::{rngs::OsRng, TryRngCore}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use utoipa::ToSchema; +use uuid::Uuid; +use webauthn_rs::prelude::RegisterPublicKeyCredential; + +use crate::auth::{ + api_tokens::{find_active_token_by_secret, touch_api_token}, + capability_sets::load_capability_set, + jwt::{AccessTokenContext, PrincipalKind}, + passkeys::{ + AuthenticationChallengeResponse, PasskeyLoginFinishPayload, + PasskeyRegistrationFinishPayload, PasskeySummary, RegistrationChallengeResponse, + }, + AuthenticatedUser, +}; +use crate::error::{AppError, AppResult}; +use crate::http::responders::{ok_json, JsonResponse}; +use crate::models::{ + MagicToken, MagicTokenKind, NewUser, NewUserSession, TenantStatus, User, UserMembership, + UserSession, +}; +use crate::schema::{ + magic_tokens::dsl as magic_dsl, + tenants::dsl as tenant_dsl, + user_memberships::dsl as memberships_dsl, + user_passkeys::dsl as passkey_dsl, + user_sessions::{self, dsl as session_dsl}, + users::dsl, +}; +use crate::state::{AppState, PgPooledConnection}; +use crate::tenants::{ + apply_tenant_guc, apply_user_guc, apply_user_session_hash, clear_user_guc, + clear_user_session_hash, +}; +use crate::utils::text::normalize_identifier; + +pub const SESSION_COOKIE_NAME: &str = "refresh_token"; + +#[derive(Deserialize, ToSchema)] +pub struct LoginRequest { + #[serde(default)] + pub username: String, + #[serde(default)] + #[schema(nullable)] + pub password: Option, + #[serde(default)] + #[schema(nullable)] + pub magic_token: Option, + #[serde(default)] + #[schema(nullable)] + pub preferred_tenant_id: Option, +} + +#[derive(Debug, Deserialize, ToSchema)] +pub struct ApiTokenExchangeRequest { + pub api_token: String, +} + +#[derive(Serialize, Deserialize, ToSchema, Clone)] +pub struct LoginResponse { + pub access_token: String, + pub token_type: String, + pub expires_in: i64, + pub tenant: TenantSnippet, +} + +#[derive(Serialize, Deserialize, ToSchema, Clone)] +pub struct TenantSnippet { + pub id: Uuid, + pub name: String, +} + +#[derive(Serialize, Deserialize, ToSchema, Clone)] +pub struct TenantSelectionResponse { + pub access_token: String, + pub tenants: Vec, +} + +#[derive(Serialize, Deserialize, ToSchema, Clone)] +pub struct TenantListResponse { + pub tenants: Vec, +} + +#[derive(Deserialize, ToSchema)] +pub struct TenantSelectionRequest { + pub tenant_id: Uuid, +} + +#[derive(Deserialize, ToSchema)] +pub struct SignupStartRequest { + pub username: String, +} + +#[derive(Serialize, Deserialize, ToSchema)] +pub struct SignupStartResponse { + pub signup_token: String, + pub challenge: RegistrationChallengeResponse, +} + +#[derive(Deserialize, ToSchema)] +pub struct SignupFinishRequest { + pub signup_token: String, + #[schema(value_type = Object)] + pub credential: RegisterPublicKeyCredential, + #[schema(nullable)] + pub nickname: Option, +} + +#[derive(Serialize, Deserialize, ToSchema)] +#[serde(untagged)] +pub enum LoginResponseVariants { + Token(LoginResponse), + Selection(TenantSelectionResponse), +} + +pub struct AuthService<'a> { + state: &'a AppState, +} + +impl<'a> AuthService<'a> { + pub fn new(state: &'a AppState) -> Self { + Self { state } + } + + pub fn login(&self, payload: LoginRequest) -> AppResult { + let magic_token = payload + .magic_token + .as_ref() + .map(|value| value.trim()) + .filter(|value| !value.is_empty()); + + if magic_token.is_none() { + if payload.password.is_some() { + return Err(AppError::bad_request( + "password authentication is no longer supported", + )); + } + + return Err(AppError::bad_request( + "magic_token is required for passwordless login", + )); + } + + let token_value = magic_token.unwrap(); + + let mut conn = self.state.db_unscoped()?; + let username_hint = payload.username.trim(); + let preferred_tenant_id = payload.preferred_tenant_id; + + self.magic_token_login( + &mut conn, + token_value, + (!username_hint.is_empty()).then_some(username_hint), + preferred_tenant_id, + ) + } + + pub fn exchange_api_token( + &self, + payload: ApiTokenExchangeRequest, + ) -> AppResult> { + let secret = payload.api_token.trim(); + if secret.is_empty() { + return Err(AppError::bad_request("api_token must not be empty")); + } + + let mut conn = self.state.db_unscoped()?; + + let token = find_active_token_by_secret(&mut conn, None, secret, None)? + .ok_or_else(AppError::unauthorized)?; + + let user: User = dsl::users.find(token.user_id).first(&mut conn)?; + + apply_user_guc(&mut conn, user.id)?; + let membership = memberships_dsl::user_memberships + .filter(memberships_dsl::user_id.eq(user.id)) + .filter(memberships_dsl::tenant_id.eq(token.tenant_id)) + .first::(&mut conn) + .optional()?; + clear_user_guc(&mut conn)?; + + let membership = membership.ok_or_else(AppError::unauthorized)?; + + let membership_capability_set = membership.capability_set_id.ok_or_else(|| { + AppError::new( + StatusCode::FORBIDDEN, + "membership has no capability set assigned", + ) + })?; + + let token_capability_set = load_capability_set(&mut conn, token.capability_set_id)?; + let _membership_set = load_capability_set(&mut conn, membership_capability_set)?; + + apply_tenant_guc(&mut conn, token.tenant_id)?; + touch_api_token(&mut conn, token.id)?; + + let access_token = self + .state + .jwt + .generate_token(AccessTokenContext { + user_id: user.id, + tenant_id: token.tenant_id, + username: user.username.clone(), + principal_kind: PrincipalKind::ApiToken, + principal_id: token.id, + capability_set_id: token_capability_set.id, + cap_version: token_capability_set.cap_version, + }) + .map_err(AppError::from)?; + + let tenant_name: String = tenant_dsl::tenants + .find(token.tenant_id) + .select(tenant_dsl::name) + .first(&mut conn) + .map_err(AppError::from)?; + + ok_json(LoginResponse { + access_token, + token_type: "Bearer".to_string(), + expires_in: self.state.config.jwt_expiry_minutes * 60, + tenant: TenantSnippet { + id: token.tenant_id, + name: tenant_name, + }, + }) + } + + pub fn signup_start( + &self, + payload: SignupStartRequest, + ) -> AppResult> { + let username = normalize_username(&payload.username)?; + + let mut conn = self.state.db_unscoped()?; + let exists: bool = dsl::users + .filter(dsl::username.eq(&username)) + .first::(&mut conn) + .optional()? + .is_some(); + if exists { + return Err(AppError::conflict("username already exists")); + } + + let user_id = Uuid::new_v4(); + let challenge = self + .state + .passkeys + .as_ref() + .ok_or_else(|| AppError::bad_request("passkey support is disabled"))? + .start_signup_registration(&mut conn, user_id, username.as_str())?; + + let signup_token = self + .state + .jwt + .generate_signup_token(user_id, challenge.challenge_id, username.clone()) + .map_err(AppError::from)?; + + ok_json(SignupStartResponse { + signup_token, + challenge, + }) + } + + pub fn signup_finish(&self, payload: SignupFinishRequest) -> AppResult { + let claims = self + .state + .jwt + .verify_signup_token(&payload.signup_token) + .map_err(|_| AppError::unauthorized())?; + + let mut conn = self.state.db_unscoped()?; + + let exists: bool = dsl::users + .filter(dsl::username.eq(&claims.username)) + .first::(&mut conn) + .optional()? + .is_some(); + if exists { + return Err(AppError::conflict("username already exists")); + } + + let service = self + .state + .passkeys + .as_ref() + .ok_or_else(|| AppError::bad_request("passkey support is disabled"))?; + + let prepared_passkey = service.consume_signup_challenge( + &mut conn, + claims.challenge_id, + &payload.credential, + )?; + + let state_clone = self.state.clone(); + let response = conn.transaction::(|conn| { + insert_user(conn, claims.sub, &claims.username)?; + + let tenant = state_clone.tenants.create_tenant_with_conn( + conn, + &claims.username, + None, + None, + TenantStatus::Creating, + &[claims.sub], + Some(claims.sub), + )?; + + let passkey_insert = + prepared_passkey.into_new_user_passkey(claims.sub, payload.nickname.clone()); + + diesel::insert_into(passkey_dsl::user_passkeys) + .values(&passkey_insert) + .execute(conn) + .map_err(AppError::from)?; + + let user: User = dsl::users.find(claims.sub).first(conn)?; + self.issue_session(conn, &user, tenant.id) + })?; + + Ok(response) + } + + pub fn refresh(&self, refresh_value: &str) -> AppResult { + let hashed = hash_session_token(refresh_value); + let mut conn = self.state.db_unscoped()?; + let now = Utc::now(); + let now_naive = now.naive_utc(); + + apply_user_session_hash(&mut conn, &hashed)?; + let token = match session_dsl::user_sessions + .filter(session_dsl::token_hash.eq(&hashed)) + .filter(session_dsl::revoked_at.is_null()) + .filter(session_dsl::expires_at.gt(now_naive)) + .first::(&mut conn) + { + Ok(token) => token, + Err(diesel::result::Error::NotFound) => return Err(AppError::unauthorized()), + Err(err) => return Err(AppError::from(err)), + }; + + clear_user_session_hash(&mut conn)?; + apply_tenant_guc(&mut conn, token.tenant_id)?; + clear_user_guc(&mut conn)?; + + diesel::update(session_dsl::user_sessions.filter(session_dsl::id.eq(token.id))) + .set(( + session_dsl::revoked_at.eq(now_naive), + session_dsl::updated_at.eq(now_naive), + )) + .execute(&mut conn)?; + + let user: User = dsl::users + .find(token.user_id) + .first(&mut conn) + .map_err(AppError::from)?; + + self.issue_session(&mut conn, &user, token.tenant_id) + } + + pub fn select_tenant(&self, token: &str, tenant_id: Uuid) -> AppResult { + let user_id = match self.state.jwt.verify_tenant_selector_token(token) { + Ok(claims) => claims.sub, + Err(_) => self + .state + .jwt + .verify_token(token) + .map(|claims| claims.sub) + .map_err(|_| AppError::unauthorized())?, + }; + + let mut conn = self.state.db_unscoped()?; + apply_user_guc(&mut conn, user_id)?; + + let membership_exists = memberships_dsl::user_memberships + .filter(memberships_dsl::user_id.eq(user_id)) + .filter(memberships_dsl::tenant_id.eq(tenant_id)) + .select(memberships_dsl::tenant_id) + .first::(&mut conn) + .optional()?; + + clear_user_guc(&mut conn)?; + + if membership_exists.is_none() { + return Err(AppError::unauthorized()); + } + + let user: User = dsl::users + .find(user_id) + .first(&mut conn) + .map_err(AppError::from)?; + + self.issue_session(&mut conn, &user, tenant_id) + } + + pub fn logout( + &self, + conn: &mut PgPooledConnection, + user: &AuthenticatedUser, + refresh_cookie: Option<&str>, + ) -> AppResult<(HeaderMap, StatusCode)> { + let now = Utc::now().naive_utc(); + + let revoked = if let Some(value) = refresh_cookie { + let hashed = hash_session_token(value); + diesel::update( + session_dsl::user_sessions + .filter(session_dsl::token_hash.eq(hashed)) + .filter(session_dsl::user_id.eq(user.user_id)) + .filter(session_dsl::revoked_at.is_null()), + ) + .set(( + session_dsl::revoked_at.eq(now), + session_dsl::updated_at.eq(now), + )) + .execute(conn)? + } else { + 0 + }; + + if revoked == 0 { + diesel::update( + session_dsl::user_sessions + .filter(session_dsl::user_id.eq(user.user_id)) + .filter(session_dsl::tenant_id.eq(user.tenant_id)) + .filter(session_dsl::revoked_at.is_null()), + ) + .set(( + session_dsl::revoked_at.eq(now), + session_dsl::updated_at.eq(now), + )) + .execute(conn)?; + } + + let mut headers = HeaderMap::new(); + headers.insert(SET_COOKIE, build_clear_session_cookie(self.state)); + Ok((headers, StatusCode::NO_CONTENT)) + } + + pub fn list_tenants(&self, user_id: Uuid) -> AppResult> { + let mut conn = self.state.db_unscoped()?; + apply_user_guc(&mut conn, user_id)?; + + let tenant_ids: Vec = memberships_dsl::user_memberships + .filter(memberships_dsl::user_id.eq(user_id)) + .select(memberships_dsl::tenant_id) + .load(&mut conn)?; + + clear_user_guc(&mut conn)?; + + let mut tenants = Vec::with_capacity(tenant_ids.len()); + for tenant_id in tenant_ids { + let (name, status): (String, TenantStatus) = tenant_dsl::tenants + .find(tenant_id) + .select((tenant_dsl::name, tenant_dsl::status)) + .first(&mut conn) + .map_err(AppError::from)?; + if status != TenantStatus::Active { + continue; + } + tenants.push(TenantSnippet { + id: tenant_id, + name, + }); + } + + ok_json(TenantListResponse { tenants }) + } + + pub fn get_tenant( + &self, + user_id: Uuid, + tenant_id: Uuid, + ) -> AppResult> { + let mut conn = self.state.db_unscoped()?; + apply_user_guc(&mut conn, user_id)?; + + let is_member: bool = diesel::select(diesel::dsl::exists( + memberships_dsl::user_memberships + .filter(memberships_dsl::user_id.eq(user_id)) + .filter(memberships_dsl::tenant_id.eq(tenant_id)), + )) + .get_result(&mut conn)?; + + clear_user_guc(&mut conn)?; + + if !is_member { + return Err(AppError::not_found()); + } + + let (name, status): (String, TenantStatus) = tenant_dsl::tenants + .find(tenant_id) + .select((tenant_dsl::name, tenant_dsl::status)) + .first(&mut conn) + .map_err(AppError::from)?; + + if status != TenantStatus::Active { + return Err(AppError::not_found()); + } + + ok_json(TenantSnippet { + id: tenant_id, + name, + }) + } + + pub fn passkey_register_start( + &self, + user: AuthenticatedUser, + ) -> AppResult> { + let service = self + .state + .passkeys + .as_ref() + .ok_or_else(|| AppError::bad_request("passkey support is disabled"))?; + + let mut conn = self.state.db_unscoped()?; + let current_user: User = dsl::users.find(user.user_id).first(&mut conn)?; + let challenge = service.start_registration(&mut conn, ¤t_user)?; + ok_json(challenge) + } + + pub fn passkey_register_finish( + &self, + user: AuthenticatedUser, + payload: PasskeyRegistrationFinishPayload, + ) -> AppResult> { + let service = self + .state + .passkeys + .as_ref() + .ok_or_else(|| AppError::bad_request("passkey support is disabled"))?; + + let mut conn = self.state.db_unscoped()?; + let current_user: User = dsl::users.find(user.user_id).first(&mut conn)?; + + let PasskeyRegistrationFinishPayload { + challenge_id, + credential, + nickname, + } = payload; + + let passkey = service.finish_registration( + &mut conn, + ¤t_user, + challenge_id, + credential, + nickname, + )?; + + ok_json(PasskeySummary::from(passkey)) + } + + pub fn passkey_login_start( + &self, + username: &str, + ) -> AppResult> { + let username = normalize_username(username)?; + + let service = self + .state + .passkeys + .as_ref() + .ok_or_else(|| AppError::bad_request("passkey support is disabled"))?; + + let mut conn = self.state.db_unscoped()?; + let user: User = dsl::users + .filter(dsl::username.eq(&username)) + .first(&mut conn)?; + + let challenge = service.start_authentication(&mut conn, &user)?; + ok_json(challenge) + } + + pub fn passkey_login_finish(&self, payload: PasskeyLoginFinishPayload) -> AppResult { + let service = self + .state + .passkeys + .as_ref() + .ok_or_else(|| AppError::bad_request("passkey support is disabled"))?; + + let mut conn = self.state.db_unscoped()?; + let (user, _passkey, auth_result) = + service.finish_authentication(&mut conn, payload.challenge_id, payload.credential)?; + + if !auth_result.user_verified() { + return Err(AppError::unauthorized()); + } + + self.complete_login(&mut conn, &user, None) + } + + fn complete_login( + &self, + conn: &mut PgConnection, + user: &User, + preferred_tenant_id: Option, + ) -> AppResult { + apply_user_guc(conn, user.id)?; + let tenant_ids: Vec = memberships_dsl::user_memberships + .filter(memberships_dsl::user_id.eq(user.id)) + .select(memberships_dsl::tenant_id) + .load(conn)?; + clear_user_guc(conn)?; + + if tenant_ids.is_empty() { + return Err(AppError::unauthorized()); + } + + let mut active_tenants = Vec::new(); + for tenant_id in tenant_ids { + let (name, status): (String, TenantStatus) = tenant_dsl::tenants + .find(tenant_id) + .select((tenant_dsl::name, tenant_dsl::status)) + .first(conn) + .map_err(AppError::from)?; + if status == TenantStatus::Active { + active_tenants.push((tenant_id, name)); + } + } + + if active_tenants.is_empty() { + return Err(AppError::new( + StatusCode::FORBIDDEN, + "no active tenants available", + )); + } + + if let Some(preferred_id) = preferred_tenant_id { + if active_tenants.iter().any(|(id, _)| *id == preferred_id) { + return self.issue_session(conn, user, preferred_id); + } + } + + if active_tenants.len() == 1 { + return self.issue_session(conn, user, active_tenants[0].0); + } + + let selection_token = self + .state + .jwt + .generate_tenant_selector_token(user.id) + .map_err(AppError::from)?; + + let tenants = active_tenants + .into_iter() + .map(|(id, name)| TenantSnippet { id, name }) + .collect(); + + let response = ok_json(LoginResponseVariants::Selection(TenantSelectionResponse { + access_token: selection_token, + tenants, + }))?; + + Ok(response.into_response()) + } + + fn magic_token_login( + &self, + conn: &mut PgConnection, + token_value: &str, + username_hint: Option<&str>, + preferred_tenant_id: Option, + ) -> AppResult { + if token_value.is_empty() { + return Err(AppError::bad_request("magic_token must not be empty")); + } + + let token_hash = hash_magic_token(token_value); + let now = Utc::now(); + let now_naive = now.naive_utc(); + + conn.transaction::(|conn| { + let magic = magic_dsl::magic_tokens + .filter(magic_dsl::token_hash.eq(&token_hash)) + .filter(magic_dsl::expires_at.gt(now_naive)) + .first::(conn) + .map_err(|err| match err { + diesel::result::Error::NotFound => AppError::unauthorized(), + _ => AppError::from(err), + })?; + + if let Some(limit) = magic.max_uses { + if magic.used_count >= limit { + return Err(AppError::unauthorized()); + } + } + + match magic.kind { + MagicTokenKind::EmailLogin | MagicTokenKind::DemoLogin => {} + } + + let user: User = dsl::users + .find(magic.user_id) + .first(conn) + .map_err(AppError::from)?; + + if let Some(expected) = username_hint { + if expected != user.username { + return Err(AppError::unauthorized()); + } + } + + diesel::update(magic_dsl::magic_tokens.filter(magic_dsl::id.eq(magic.id))) + .set(( + magic_dsl::used_count.eq(magic.used_count + 1), + magic_dsl::last_used_at.eq(Some(now_naive)), + )) + .execute(conn)?; + + self.complete_login(conn, &user, preferred_tenant_id) + }) + } + + fn issue_session( + &self, + conn: &mut PgConnection, + user: &User, + tenant_id: Uuid, + ) -> AppResult { + crate::auth::ensure_active_tenant_with_conn(conn, tenant_id)?; + apply_tenant_guc(conn, tenant_id)?; + clear_user_guc(conn)?; + clear_user_session_hash(conn)?; + + let membership: UserMembership = memberships_dsl::user_memberships + .filter(memberships_dsl::user_id.eq(user.id)) + .filter(memberships_dsl::tenant_id.eq(tenant_id)) + .first(conn)?; + + let capability_set_id = membership.capability_set_id.ok_or_else(|| { + AppError::new( + StatusCode::FORBIDDEN, + "membership has no capability set assigned", + ) + })?; + + let capability_set = load_capability_set(conn, capability_set_id)?; + + let now = Utc::now(); + let session_id = Uuid::new_v4(); + let access_token = self + .state + .jwt + .generate_token(AccessTokenContext { + user_id: user.id, + tenant_id, + username: user.username.clone(), + principal_kind: PrincipalKind::UserSession, + principal_id: session_id, + capability_set_id, + cap_version: capability_set.cap_version, + }) + .map_err(AppError::from)?; + + let tenant_name: String = tenant_dsl::tenants + .find(tenant_id) + .select(tenant_dsl::name) + .first(conn) + .map_err(AppError::from)?; + + let session_value = generate_session_token(); + let session_hash = hash_session_token(&session_value); + let refresh_expires_at = + now + ChronoDuration::days(self.state.config.refresh_token_expiry_days); + + let new_session = NewUserSession { + id: session_id, + user_id: user.id, + token_hash: session_hash, + issued_at: now.naive_utc(), + expires_at: refresh_expires_at.naive_utc(), + tenant_id, + }; + + diesel::insert_into(user_sessions::table) + .values(&new_session) + .execute(conn)?; + + let json = ok_json(LoginResponseVariants::Token(LoginResponse { + access_token, + token_type: "Bearer".to_string(), + expires_in: self.state.config.jwt_expiry_minutes * 60, + tenant: TenantSnippet { + id: tenant_id, + name: tenant_name, + }, + }))?; + + let mut response = json.into_response(); + + response.headers_mut().insert( + SET_COOKIE, + build_session_cookie(self.state, &session_value, refresh_expires_at), + ); + + Ok(response) + } +} + +fn insert_user(conn: &mut PgConnection, id: Uuid, username: &str) -> AppResult<()> { + let new_user = NewUser { + id, + username: username.to_string(), + }; + + diesel::insert_into(dsl::users) + .values(&new_user) + .execute(conn) + .map(|_| ()) + .map_err(AppError::from) +} + +fn hash_session_token(token: &str) -> String { + let mut hasher = Sha256::new(); + hasher.update(token.as_bytes()); + hex::encode(hasher.finalize()) +} + +fn hash_magic_token(token: &str) -> String { + hash_session_token(token) +} + +fn generate_session_token() -> String { + let mut bytes = [0u8; 32]; + OsRng + .try_fill_bytes(&mut bytes) + .expect("failed to read random bytes"); + hex::encode(bytes) +} + +fn build_cookie( + state: &AppState, + token: Option<&str>, + expires_at: Option>, + max_age: i64, +) -> HeaderValue { + let mut parts = vec![format!("{}={}", SESSION_COOKIE_NAME, token.unwrap_or(""))]; + parts.push("Path=/".into()); + parts.push("HttpOnly".into()); + parts.push("SameSite=Strict".into()); + parts.push(format!("Max-Age={}", max_age)); + if let Some(expires) = expires_at { + parts.push(format!("Expires={}", expires.to_rfc2822())); + } + if state.config.refresh_cookie_secure { + parts.push("Secure".into()); + } + if let Some(domain) = &state.config.refresh_cookie_domain { + parts.push(format!("Domain={}", domain)); + } + + HeaderValue::from_str(&parts.join("; ")).expect("valid session cookie") +} + +fn build_session_cookie( + state: &AppState, + token: &str, + expires_at: chrono::DateTime, +) -> HeaderValue { + let max_age = ChronoDuration::days(state.config.refresh_token_expiry_days).num_seconds(); + build_cookie(state, Some(token), Some(expires_at), max_age) +} + +fn build_clear_session_cookie(state: &AppState) -> HeaderValue { + let epoch = Utc.timestamp_opt(0, 0).single().unwrap(); + build_cookie(state, None, Some(epoch), 0) +} + +fn normalize_username(value: &str) -> AppResult { + normalize_identifier( + value, + 100, + "username must not be empty", + "username must not exceed 100 characters", + Some("username may only contain printable characters"), + |ch| !ch.is_control(), + ) +} diff --git a/backend/src/services/capability_sets.rs b/backend/src/services/capability_sets.rs new file mode 100644 index 0000000..6c1a105 --- /dev/null +++ b/backend/src/services/capability_sets.rs @@ -0,0 +1,293 @@ +use axum::http::StatusCode; +use chrono::Utc; +use diesel::{pg::PgConnection, prelude::*, Connection, OptionalExtension}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use crate::{ + auth::capability_sets::{ + compute_slug, create_capability_set as create_capability_set_record, is_system_slug, + load_capabilities_for_set, normalize_capabilities, refresh_capability_set, + }, + error::{AppError, AppResult}, + http::responders::{ + created_json, no_content, ok_json, IntoAppResult, JsonResponse, RowsAffectedExt, + }, + models::{ApiCapability, CapabilitySet}, + schema::{ + api_tokens, + capability_sets::{self, dsl as cs_dsl}, + user_memberships, + }, + utils::text::normalize_identifier, +}; + +#[derive(Serialize, utoipa::ToSchema)] +pub struct CapabilitySetResponse { + pub id: Uuid, + pub slug: String, + pub is_system: bool, + pub cap_version: i32, + pub capabilities: Vec, +} + +#[derive(Deserialize, utoipa::ToSchema)] +pub struct CreateCapabilitySetRequest { + #[serde(default)] + #[serde(rename = "slug")] + pub slug: Option, + pub capabilities: Vec, +} + +#[derive(Deserialize, utoipa::ToSchema)] +pub struct UpdateCapabilitySetRequest { + #[serde(default)] + #[serde(rename = "slug")] + pub slug: Option, + #[serde(default)] + pub capabilities: Option>, +} + +pub struct CapabilitySetService; + +impl CapabilitySetService { + pub fn new() -> Self { + Self + } + + pub fn list( + &self, + conn: &mut PgConnection, + tenant_id: Uuid, + ) -> AppResult>> { + let sets = cs_dsl::capability_sets + .filter(cs_dsl::tenant_id.eq(tenant_id)) + .order(cs_dsl::slug.asc()) + .load::(conn)?; + + let mut responses = Vec::with_capacity(sets.len()); + for set in sets { + let capabilities = load_capabilities_for_set(conn, set.id)?; + responses.push(to_response(set, capabilities)); + } + + ok_json(responses) + } + + pub fn list_capabilities(&self) -> AppResult>> { + let capabilities = ApiCapability::variants() + .iter() + .map(|value| value.parse::().expect("valid capability")) + .collect(); + ok_json(capabilities) + } + + pub fn get( + &self, + conn: &mut PgConnection, + tenant_id: Uuid, + id: Uuid, + ) -> AppResult> { + let set = cs_dsl::capability_sets + .filter(cs_dsl::tenant_id.eq(tenant_id)) + .find(id) + .first::(conn) + .into_app_result()?; + + let capabilities = load_capabilities_for_set(conn, set.id)?; + ok_json(to_response(set, capabilities)) + } + + pub fn create( + &self, + conn: &mut PgConnection, + tenant_id: Uuid, + payload: CreateCapabilitySetRequest, + ) -> AppResult> { + let original_caps = payload.capabilities; + let normalized_caps = normalize_capabilities(original_caps.clone())?; + if normalized_caps.is_empty() { + return Err(AppError::bad_request("at least one capability is required")); + } + + let slug = if let Some(raw) = payload.slug { + let normalized = normalize_slug(&raw)?; + if is_system_slug(&normalized) { + return Err(AppError::conflict("slug is reserved")); + } + normalized + } else { + let generated = compute_slug(&normalized_caps); + if is_system_slug(&generated) { + return Err(AppError::conflict( + "capabilities match a reserved system capability set", + )); + } + generated + }; + + let set = create_capability_set_record(conn, tenant_id, &slug, original_caps)?; + let response = to_response(set, normalized_caps); + + created_json(response) + } + + pub fn update( + &self, + conn: &mut PgConnection, + tenant_id: Uuid, + id: Uuid, + payload: UpdateCapabilitySetRequest, + ) -> AppResult> { + let set = cs_dsl::capability_sets + .filter(cs_dsl::tenant_id.eq(tenant_id)) + .find(id) + .first::(conn) + .into_app_result()?; + + if set.is_system { + if payload.slug.is_some() || payload.capabilities.is_some() { + return Err(AppError::conflict( + "system capability sets cannot be modified", + )); + } + let capabilities = load_capabilities_for_set(conn, set.id)?; + return ok_json(to_response(set, capabilities)); + } + + let set = conn.transaction::(|conn| { + let mut working = set.clone(); + + if let Some(slug) = &payload.slug { + let normalized = normalize_slug(slug)?; + if is_system_slug(&normalized) { + return Err(AppError::conflict("slug is reserved")); + } + + if cs_dsl::capability_sets + .filter(cs_dsl::tenant_id.eq(tenant_id)) + .filter(cs_dsl::slug.eq(&normalized)) + .filter(cs_dsl::id.ne(working.id)) + .first::(conn) + .optional() + .into_app_result()? + .is_some() + { + return Err(AppError::conflict("slug already exists")); + } + + diesel::update(cs_dsl::capability_sets.find(working.id)) + .set(( + cs_dsl::slug.eq(&normalized), + cs_dsl::updated_at.eq(Utc::now().naive_utc()), + )) + .execute(conn) + .into_app_result()?; + + working.slug = normalized; + } + + if let Some(capabilities) = &payload.capabilities { + let normalized = normalize_capabilities(capabilities.clone())?; + if normalized.is_empty() { + return Err(AppError::bad_request("at least one capability is required")); + } + + let updated = refresh_capability_set(conn, &working, &normalized)?; + working = updated; + } + + capability_sets::table + .find(working.id) + .first::(conn) + .into_app_result() + })?; + + let capabilities = load_capabilities_for_set(conn, set.id)?; + ok_json(to_response(set, capabilities)) + } + + pub fn delete( + &self, + conn: &mut PgConnection, + tenant_id: Uuid, + id: Uuid, + ) -> AppResult { + let set = cs_dsl::capability_sets + .filter(cs_dsl::tenant_id.eq(tenant_id)) + .find(id) + .first::(conn) + .into_app_result()?; + + if set.is_system { + return Err(AppError::conflict( + "system capability sets cannot be deleted", + )); + } + + let in_use_memberships: i64 = user_memberships::table + .filter(user_memberships::capability_set_id.eq(Some(set.id))) + .count() + .get_result(conn)?; + + if in_use_memberships > 0 { + return Err(AppError::conflict( + "capability set is assigned to user memberships", + )); + } + + let in_use_tokens: i64 = api_tokens::table + .filter(api_tokens::capability_set_id.eq(set.id)) + .count() + .get_result(conn)?; + + if in_use_tokens > 0 { + return Err(AppError::conflict( + "capability set is assigned to API tokens", + )); + } + + diesel::delete(cs_dsl::capability_sets.find(set.id)) + .execute(conn) + .into_app_result()? + .or_not_found()?; + + no_content() + } +} + +fn normalize_slug(value: &str) -> AppResult { + let base = normalize_identifier( + value, + 64, + "slug must not be empty", + "slug must not exceed 64 characters", + Some("slug may only contain alphanumeric characters, hyphen, underscore, or whitespace"), + |ch| ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' || ch.is_whitespace(), + )?; + + let mut normalized = String::with_capacity(base.len()); + for ch in base.chars() { + if ch.is_whitespace() { + normalized.push('-'); + } else { + normalized.push(ch.to_ascii_lowercase()); + } + } + + if normalized.is_empty() { + return Err(AppError::bad_request("slug must not be empty")); + } + + Ok(normalized) +} + +fn to_response(set: CapabilitySet, capabilities: Vec) -> CapabilitySetResponse { + CapabilitySetResponse { + id: set.id, + slug: set.slug, + is_system: set.is_system, + cap_version: set.cap_version, + capabilities, + } +} diff --git a/backend/src/services/correspondents.rs b/backend/src/services/correspondents.rs new file mode 100644 index 0000000..5d84d52 --- /dev/null +++ b/backend/src/services/correspondents.rs @@ -0,0 +1,261 @@ +use chrono::Utc; +use diesel::{dsl::not, prelude::*, Connection}; +use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; +use uuid::Uuid; + +use crate::documents::correspondents::{ + insert_document_correspondents, normalize_correspondent_ids, +}; +use crate::error::{AppError, AppResult}; +use crate::schema::{document_correspondents, documents}; +use crate::services::helpers::load_active_document; +use crate::state::{AppState, PgPooledConnection}; +use crate::utils::db::validate_bulk_ids; + +#[derive(Serialize, ToSchema)] +pub struct BulkCorrespondentResponse { + pub assigned: usize, + pub removed: usize, +} + +#[derive(Deserialize, ToSchema)] +pub struct CorrespondentAssignmentInput { + pub correspondent_id: Uuid, +} + +#[derive(Deserialize, ToSchema)] +pub struct AssignCorrespondentsRequest { + pub assignments: Vec, + #[serde(default)] + #[schema(default = false)] + pub replace: bool, +} + +#[derive(Deserialize, Copy, Clone, PartialEq, Eq, ToSchema)] +#[serde(rename_all = "lowercase")] +pub enum BulkCorrespondentAction { + Add, + Remove, +} + +fn default_bulk_correspondent_action() -> BulkCorrespondentAction { + BulkCorrespondentAction::Add +} + +#[derive(Deserialize, ToSchema)] +pub struct BulkCorrespondentsRequest { + pub document_ids: Vec, + pub assignments: Vec, + #[serde(default = "default_bulk_correspondent_action")] + pub action: BulkCorrespondentAction, +} + +pub struct CorrespondentsService<'a> { + _state: &'a AppState, +} + +impl<'a> CorrespondentsService<'a> { + pub fn new(state: &'a AppState) -> Self { + Self { _state: state } + } + + pub fn assign_to_document( + &self, + conn: &mut PgPooledConnection, + tenant_id: Uuid, + user_id: Uuid, + document_id: Uuid, + request: &AssignCorrespondentsRequest, + ) -> AppResult<()> { + if request.assignments.is_empty() { + return Err(AppError::bad_request("assignments must not be empty")); + } + + let raw_ids: Vec = request + .assignments + .iter() + .map(|assignment| assignment.correspondent_id) + .collect(); + let correspondent_ids = normalize_correspondent_ids(&raw_ids)?; + + conn.transaction::<_, AppError, _>(|conn| { + let document = load_active_document(conn, tenant_id, document_id)?; + + let mut updated = false; + if request.replace { + let base = document_correspondents::table + .filter(document_correspondents::document_id.eq(document_id)) + .filter(document_correspondents::tenant_id.eq(tenant_id)); + + let removed = if correspondent_ids.is_empty() { + diesel::delete(base).execute(conn)? + } else { + diesel::delete(base.filter(not( + document_correspondents::correspondent_id.eq_any(&correspondent_ids), + ))) + .execute(conn)? + }; + + if removed > 0 { + updated = true; + } + } + + let inserted = insert_document_correspondents( + conn, + tenant_id, + document.id, + user_id, + &correspondent_ids, + )?; + + if inserted > 0 { + updated = true; + } + + if updated && inserted == 0 { + diesel::update( + documents::table + .find(document_id) + .filter(documents::tenant_id.eq(tenant_id)), + ) + .set(documents::updated_at.eq(Utc::now().naive_utc())) + .execute(conn)?; + } + + Ok(()) + }) + } + + pub fn bulk_update( + &self, + conn: &mut PgPooledConnection, + tenant_id: Uuid, + user_id: Uuid, + mut payload: BulkCorrespondentsRequest, + ) -> AppResult { + if payload.assignments.is_empty() { + return Err(AppError::bad_request("assignments must not be empty")); + } + + validate_bulk_ids(&mut payload.document_ids, "document_ids")?; + + let raw_ids: Vec = payload + .assignments + .iter() + .map(|assignment| assignment.correspondent_id) + .collect(); + let correspondent_ids = normalize_correspondent_ids(&raw_ids)?; + + let action = payload.action; + + conn.transaction::<_, AppError, _>(|conn| { + let docs: Vec<(Uuid, Option)> = documents::table + .filter(documents::id.eq_any(&payload.document_ids)) + .filter(documents::tenant_id.eq(tenant_id)) + .select((documents::id, documents::deleted_at)) + .load(conn)?; + + if docs.len() != payload.document_ids.len() { + return Err(AppError::bad_request( + "one or more documents do not exist or are inaccessible", + )); + } + + if docs.iter().any(|(_, deleted)| deleted.is_some()) { + return Err(AppError::bad_request( + "cannot assign correspondents to deleted documents", + )); + } + + match action { + BulkCorrespondentAction::Add => { + let mut assigned_total = 0; + for (doc_id, _) in &docs { + assigned_total += insert_document_correspondents( + conn, + tenant_id, + *doc_id, + user_id, + &correspondent_ids, + )?; + } + + Ok(BulkCorrespondentResponse { + assigned: assigned_total, + removed: 0, + }) + } + BulkCorrespondentAction::Remove => { + if correspondent_ids.is_empty() { + return Ok(BulkCorrespondentResponse { + assigned: 0, + removed: 0, + }); + } + + let removed = diesel::delete( + document_correspondents::table + .filter( + document_correspondents::document_id.eq_any(&payload.document_ids), + ) + .filter(document_correspondents::tenant_id.eq(tenant_id)) + .filter( + document_correspondents::correspondent_id + .eq_any(&correspondent_ids), + ), + ) + .execute(conn)?; + + if removed > 0 { + diesel::update( + documents::table + .filter(documents::id.eq_any(&payload.document_ids)) + .filter(documents::tenant_id.eq(tenant_id)), + ) + .set(documents::updated_at.eq(Utc::now().naive_utc())) + .execute(conn)?; + } + + Ok(BulkCorrespondentResponse { + assigned: 0, + removed, + }) + } + } + }) + } + + pub fn remove_from_document( + &self, + conn: &mut PgPooledConnection, + tenant_id: Uuid, + document_id: Uuid, + correspondent_id: Uuid, + ) -> AppResult<()> { + load_active_document(conn, tenant_id, document_id)?; + + let deleted = diesel::delete( + document_correspondents::table + .filter(document_correspondents::document_id.eq(document_id)) + .filter(document_correspondents::tenant_id.eq(tenant_id)) + .filter(document_correspondents::correspondent_id.eq(correspondent_id)), + ) + .execute(conn)?; + + if deleted == 0 { + return Err(AppError::not_found()); + } + + diesel::update( + documents::table + .find(document_id) + .filter(documents::tenant_id.eq(tenant_id)), + ) + .set(documents::updated_at.eq(Utc::now().naive_utc())) + .execute(conn)?; + + Ok(()) + } +} diff --git a/backend/src/services/documents.rs b/backend/src/services/documents.rs new file mode 100644 index 0000000..d4fdaf2 --- /dev/null +++ b/backend/src/services/documents.rs @@ -0,0 +1,1618 @@ +use std::collections::{HashMap, HashSet}; + +use chrono::{DateTime, Duration as ChronoDuration, NaiveDateTime, Utc}; +use diesel::{ + dsl::{exists, not, sql}, + prelude::*, + result::DatabaseErrorKind, + sql_types::Text, + Connection, +}; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Map, Value}; +use sha2::{Digest, Sha256}; +use tracing::{debug, error, info, warn}; +use utoipa::{IntoParams, ToSchema}; +use uuid::Uuid; + +use crate::documents::{ + asset::{ + build_download_link, derive_document_title, filename_with_retained_extension, + to_asset_detail_response, to_version_response, DocumentAssetDetailResponse, + DocumentAssetResponse, DocumentVersionDetailResponse, DocumentVersionResponse, + DownloadLink, + }, + correspondents::{ + insert_document_correspondents, normalize_correspondent_ids, DocumentCorrespondentResponse, + }, + folders::ensure_folder_exists_on_conn, + metadata::merge_document_metadata, + ordering::{ordering_clauses, DocumentSortField, SortDirection}, + relations::load_tags_and_correspondents, + search::quickwit_search, + tags::assign_tags as assign_tags_to_document, +}; +use crate::error::{AppError, AppResult}; +use crate::jobs::{enqueue_job, JobQueueError, JOB_ANALYZE_DOCUMENT, JOB_PURGE_DOCUMENT}; +use crate::models::{ + Document, DocumentAsset, DocumentVersion, NewDocument, NewDocumentVersion, Tag, +}; +use crate::schema::{ + document_assets, document_correspondents, document_tags, document_versions, documents, folders, +}; +use crate::services::{ + correspondents::CorrespondentAssignmentInput, folders::gather_descendant_folder_ids, + helpers::load_active_document, +}; +use crate::state::{AppState, PgPooledConnection}; +use crate::utils::{ + db::validate_bulk_ids, error::StorageResultExt, http::inline_content_disposition, + json::classify_nullable, json::NullableValue, setops::intersect_option_sets, + setops::load_linked_doc_ids, storage_paths::document_version_object_key, time::to_iso, +}; + +#[derive(Deserialize, IntoParams, ToSchema, Clone)] +#[into_params(parameter_in = Query)] +pub struct DocumentListQuery { + #[schema(nullable)] + pub folder_id: Option, + #[serde(default)] + #[schema(nullable)] + pub include_descendants: Option, + pub query: Option, + pub tags: Option, + pub correspondents: Option, + #[serde(default = "default_document_status_filter")] + #[schema(default = "active")] + pub status: DocumentStatusFilter, + #[serde(default)] + #[schema(default = "title")] + pub sort: DocumentSortField, + #[serde(default)] + #[schema(default = "asc")] + pub dir: SortDirection, +} + +fn default_document_status_filter() -> DocumentStatusFilter { + DocumentStatusFilter::Active +} + +#[derive(Clone, Copy, Deserialize, Serialize, ToSchema)] +#[serde(rename_all = "lowercase")] +pub enum DocumentStatusFilter { + Active, + Deleted, + All, +} + +#[derive(Serialize, ToSchema)] +pub struct TagResponse { + pub id: Uuid, + pub label: String, + #[schema(nullable)] + pub color: Option, +} + +impl From for TagResponse { + fn from(tag: Tag) -> Self { + Self { + id: tag.id, + label: tag.label, + color: tag.color, + } + } +} + +#[derive(Serialize, ToSchema)] +pub struct DocumentCheckResponse { + pub exists: bool, + #[serde(skip_serializing_if = "Option::is_none")] + #[schema(nullable)] + pub document_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[schema(nullable)] + pub title: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[schema(nullable)] + pub filename: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[schema(nullable)] + pub version_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[schema(nullable)] + pub version_number: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[schema(nullable)] + pub created_at: Option, +} + +#[derive(Serialize, ToSchema)] +pub struct DocumentResponse { + pub id: Uuid, + pub filename: String, + pub title: String, + pub original_name: String, + #[schema(nullable)] + pub mime_type: Option, + #[schema(nullable)] + pub folder_id: Option, + pub created_at: String, + pub updated_at: String, + #[schema(nullable)] + pub deleted_at: Option, + #[schema(nullable)] + pub issued_at: Option, + #[schema(value_type = Object)] + pub metadata: Value, + pub tags: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub correspondents: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + #[schema(nullable)] + pub current_version: Option, +} + +#[derive(Serialize, ToSchema)] +pub struct DocumentDetailResponse { + pub document: DocumentResponse, +} + +#[derive(Deserialize, ToSchema)] +pub struct DocumentMetadataUpdate { + #[schema(value_type = Object)] + pub value: Value, + #[serde(default)] + #[schema(default = false)] + pub replace: bool, +} + +#[derive(Default, AsChangeset)] +#[diesel(table_name = documents)] +struct DocumentUpdateChangeset { + title: Option, + filename: Option, + issued_at: Option>, + metadata: Option, + updated_at: Option, +} + +struct DocumentUpdatePlan { + changeset: DocumentUpdateChangeset, +} + +#[derive(Deserialize, ToSchema)] +pub struct UpdateDocumentRequest { + #[serde(default)] + pub title: Option, + #[serde(default)] + #[schema(nullable, value_type = Option)] + pub issued_at: Option, + #[serde(default)] + #[schema(nullable, value_type = Object)] + pub metadata: Option, +} + +#[derive(Deserialize, ToSchema)] +pub struct BulkMoveRequest { + pub document_ids: Vec, + #[schema(nullable)] + pub folder_id: Option, +} + +#[derive(Serialize, ToSchema)] +pub struct BulkMoveResponse { + pub updated: usize, +} + +#[derive(Deserialize, ToSchema)] +pub struct BulkReanalyzeSelectionRequest { + pub document_ids: Vec, + #[serde(default = "default_true")] + #[schema(default = true)] + pub force: bool, +} + +#[derive(Serialize, ToSchema)] +pub struct BulkReanalyzeResponse { + pub queued: usize, +} + +fn default_true() -> bool { + true +} + +pub struct DocumentUploadRequest { + pub bytes: Vec, + pub original_name: String, + pub mime_type: Option, + pub folder_id: Option, + pub metadata: Value, + pub title_override: Option, + pub tag_ids: Vec, + pub correspondents: Vec, + pub issued_at_override: Option, + pub skip_if_existing: bool, +} + +pub enum DocumentUploadOutcome { + Created(DocumentDetailResponse), + Reused(DocumentDetailResponse), +} + +pub struct DocumentsService<'a> { + state: &'a AppState, +} + +impl<'a> DocumentsService<'a> { + pub fn new(state: &'a AppState) -> Self { + Self { state } + } + + fn build_document_update_plan( + document: &Document, + payload: &Map, + ) -> AppResult { + let title = match payload.get("title") { + None | Some(Value::Null) => None, + Some(Value::String(value)) => Some(value.clone()), + Some(_) => return Err(AppError::bad_request("title must be a string")), + }; + + let issued_at_class = + classify_nullable(payload.get("issued_at")).map_err(AppError::bad_request)?; + + let metadata = match payload.get("metadata") { + None | Some(Value::Null) => None, + Some(value) => Some( + serde_json::from_value::(value.clone()).map_err(|err| { + AppError::bad_request(format!("invalid metadata payload: {err}")) + })?, + ), + }; + + let mut changes = DocumentUpdateChangeset::default(); + let mut has_changes = false; + + if let Some(ref candidate) = title { + let trimmed = candidate.trim(); + if trimmed.is_empty() { + return Err(AppError::bad_request("title must not be empty")); + } + if trimmed != document.title { + let new_title = trimmed.to_string(); + let new_filename = filename_with_retained_extension(&new_title, &document.filename); + changes.title = Some(new_title); + if new_filename != document.filename { + changes.filename = Some(new_filename); + } + has_changes = true; + } + } + + match issued_at_class { + NullableValue::Omitted => {} + NullableValue::Null => { + if document.issued_at.is_some() { + changes.issued_at = Some(None); + has_changes = true; + } + } + NullableValue::String(raw) => { + let trimmed = raw.trim(); + if trimmed.is_empty() { + return Err(AppError::bad_request("issued_at must not be empty")); + } + let parsed = DateTime::parse_from_rfc3339(trimmed).map_err(|err| { + let msg = format!("issued_at must be an RFC3339 timestamp: {err}"); + AppError::bad_request(msg) + })?; + let normalized = Some(parsed.naive_utc()); + if document.issued_at != normalized { + changes.issued_at = Some(normalized); + has_changes = true; + } + } + } + + if let Some(metadata_update) = metadata { + let next_metadata = if metadata_update.replace { + metadata_update.value + } else { + merge_document_metadata(document.metadata.clone(), metadata_update.value)? + }; + + if document.metadata != next_metadata { + changes.metadata = Some(next_metadata); + has_changes = true; + } + } + + if !has_changes { + return Err(AppError::bad_request("no changes provided")); + } + + Ok(DocumentUpdatePlan { + changeset: changes, + }) + } + + pub async fn get_document_detail( + &self, + conn: &mut PgPooledConnection, + tenant_id: Uuid, + user_id: Uuid, + document_id: Uuid, + ) -> AppResult { + let doc = load_active_document(conn, tenant_id, document_id)?; + + let current_version: DocumentVersion = document_versions::table + .find(doc.current_version_id) + .first(conn)?; + + let tags_and_correspondents = load_tags_and_correspondents(conn, &[document_id])?; + let (tags, correspondents) = tags_and_correspondents + .get(&document_id) + .cloned() + .unwrap_or_else(|| (Vec::new(), Vec::new())); + + let assets = self.load_asset_responses(conn, tenant_id, current_version.id, user_id)?; + let download = build_download_link(self.state, &doc, current_version.id, user_id)?; + let current_version_data = Some((to_version_response(current_version), assets, download)); + + let response = + self.to_document_response(user_id, doc, tags, correspondents, current_version_data)?; + + Ok(DocumentDetailResponse { document: response }) + } + + pub fn check_document( + &self, + conn: &mut PgPooledConnection, + tenant_id: Uuid, + checksum_hex: &str, + ) -> AppResult { + let record: Option<(Document, DocumentVersion)> = documents::table + .inner_join( + document_versions::table + .on(document_versions::id.eq(documents::current_version_id)), + ) + .filter(documents::tenant_id.eq(tenant_id)) + .filter(document_versions::checksum.eq(checksum_hex)) + .select((documents::all_columns, document_versions::all_columns)) + .first::<(Document, DocumentVersion)>(conn) + .optional()?; + + if let Some((document, version)) = record { + Ok(DocumentCheckResponse { + exists: true, + document_id: Some(document.id), + title: Some(document.title), + filename: Some(document.filename), + version_id: Some(version.id), + version_number: Some(version.version_number), + created_at: Some(to_iso(document.created_at)), + }) + } else { + Ok(DocumentCheckResponse { + exists: false, + document_id: None, + title: None, + filename: None, + version_id: None, + version_number: None, + created_at: None, + }) + } + } + + pub async fn list_documents( + &self, + conn: &mut PgPooledConnection, + tenant_id: Uuid, + user_id: Uuid, + params: DocumentListQuery, + ) -> AppResult> { + let DocumentListQuery { + folder_id, + include_descendants, + query, + tags, + correspondents, + status, + sort, + dir, + } = params; + + let mut docs_query = documents::table + .filter(documents::tenant_id.eq(tenant_id)) + .into_boxed(); + + match status { + DocumentStatusFilter::Active => { + docs_query = docs_query.filter(documents::deleted_at.is_null()); + } + DocumentStatusFilter::Deleted => { + docs_query = docs_query.filter(documents::deleted_at.is_not_null()); + } + DocumentStatusFilter::All => {} + } + + let search_text = query + .as_ref() + .map(|s| s.trim()) + .filter(|s| !s.is_empty()) + .map(|s| s.to_owned()); + let tags_param = tags + .as_ref() + .map(|s| s.trim()) + .filter(|s| !s.is_empty()) + .map(|s| s.to_owned()); + let correspondents_param = correspondents + .as_ref() + .map(|s| s.trim()) + .filter(|s| !s.is_empty()) + .map(|s| s.to_owned()); + let include_descendants = include_descendants.unwrap_or(true); + + match (folder_id, include_descendants) { + (Some(folder_id), true) => { + let descendant_ids = gather_descendant_folder_ids(conn, tenant_id, folder_id)?; + docs_query = docs_query.filter(documents::folder_id.eq_any(descendant_ids)); + } + (Some(folder_id), false) => { + docs_query = docs_query.filter(documents::folder_id.eq(Some(folder_id))); + } + (None, false) => { + docs_query = docs_query.filter(documents::folder_id.is_null()); + } + (None, true) => {} + } + + let mut filter_ids: Option> = None; + let mut quickwit_order: Option> = None; + + if let Some(query_str) = search_text.as_ref() { + debug!(query = %query_str, "performing hybrid document search"); + + // 1. Quickwit Search + let endpoint = self + .state + .config + .quickwit_endpoint + .as_ref() + .ok_or_else(|| AppError::internal("quickwit endpoint not configured"))?; + let tenant = self.state.tenants.get_by_id(tenant_id)?; + let index = tenant + .quickwit_index + .as_ref() + .ok_or_else(|| AppError::internal("quickwit index not configured for tenant"))?; + + let quickwit_ids = quickwit_search(endpoint, index, tenant_id, query_str) + .await + .map_err(|err| { + error!(error = ?err, "quickwit search failed"); + AppError::internal("quickwit search failed") + })?; + + // 2. Postgres Title Search + let postgres_ids: Vec = documents::table + .filter(documents::tenant_id.eq(tenant_id)) + .filter(documents::deleted_at.is_null()) + .filter(documents::title.ilike(format!("%{}%", query_str))) + .select(documents::id) + .load(conn)?; + + // 3. Combine Results + let mut combined_ids = quickwit_ids.clone(); + let quickwit_set: HashSet = quickwit_ids.iter().cloned().collect(); + + for id in postgres_ids { + if !quickwit_set.contains(&id) { + combined_ids.push(id); + } + } + + if combined_ids.is_empty() { + return Ok(Vec::new()); + } + + quickwit_order = Some(combined_ids.clone()); + let set: HashSet = combined_ids.into_iter().collect(); + filter_ids = intersect_option_sets(filter_ids, set); + } + + if let Some(tags_param) = tags_param.as_ref() { + if tags_param.trim().eq_ignore_ascii_case("none") { + let docs_without_tags: Vec = documents::table + .filter(documents::tenant_id.eq(tenant_id)) + .filter(documents::deleted_at.is_null()) + .filter(not(exists( + document_tags::table.filter(document_tags::document_id.eq(documents::id)), + ))) + .select(documents::id) + .load(conn)?; + + let docs_set: HashSet = docs_without_tags.into_iter().collect(); + + if docs_set.is_empty() { + return Ok(Vec::new()); + } + + filter_ids = intersect_option_sets(filter_ids, docs_set); + } else { + let tag_ids: Result, _> = tags_param + .split(',') + .map(|s| Uuid::parse_str(s.trim())) + .collect(); + + if let Ok(ids) = tag_ids { + if !ids.is_empty() { + let matching_doc_ids = load_linked_doc_ids(conn, &ids, |conn, tag_id| { + let docs_for_tag: Vec = document_tags::table + .filter(document_tags::tag_id.eq(tag_id)) + .select(document_tags::document_id) + .load(conn) + .map_err(AppError::from)?; + Ok(docs_for_tag.into_iter().collect()) + })?; + + if matching_doc_ids.is_empty() { + return Ok(Vec::new()); + } + + filter_ids = intersect_option_sets(filter_ids, matching_doc_ids); + } + } + } + } + + if let Some(correspondents_param) = correspondents_param.as_ref() { + let correspondent_ids: Result, _> = correspondents_param + .split(',') + .map(|s| Uuid::parse_str(s.trim())) + .collect(); + + if let Ok(ids) = correspondent_ids { + if !ids.is_empty() { + let matching_doc_ids = + load_linked_doc_ids(conn, &ids, |conn, correspondent_id| { + let docs_for_correspondent: Vec = document_correspondents::table + .filter( + document_correspondents::correspondent_id.eq(correspondent_id), + ) + .select(document_correspondents::document_id) + .load(conn) + .map_err(AppError::from)?; + Ok(docs_for_correspondent.into_iter().collect()) + })?; + + if matching_doc_ids.is_empty() { + return Ok(Vec::new()); + } + + filter_ids = intersect_option_sets(filter_ids, matching_doc_ids); + } + } + } + + if let Some(filter_ids) = filter_ids { + docs_query = docs_query.filter(documents::id.eq_any(filter_ids)); + } + + let (primary_sql, secondary_sql) = ordering_clauses(sort, dir); + docs_query = docs_query.order(sql::(primary_sql)); + if let Some(second) = secondary_sql { + docs_query = docs_query.then_order_by(sql::(second)); + } + + let docs: Vec = docs_query.load(conn)?; + let mut responses = self.hydrate_documents(conn, tenant_id, user_id, docs)?; + + if let Some(order) = quickwit_order { + let order_map: HashMap = order + .into_iter() + .enumerate() + .map(|(idx, id)| (id, idx)) + .collect(); + responses.sort_by_key(|doc| order_map.get(&doc.id).copied().unwrap_or(usize::MAX)); + } + + Ok(responses) + } + + pub fn hydrate_documents( + &self, + conn: &mut PgPooledConnection, + tenant_id: Uuid, + user_id: Uuid, + docs: Vec, + ) -> AppResult> { + if docs.is_empty() { + return Ok(Vec::new()); + } + + let doc_ids: Vec = docs.iter().map(|doc| doc.id).collect(); + let mut relations = load_tags_and_correspondents(conn, &doc_ids)?; + let mut doc_to_version: HashMap = HashMap::with_capacity(doc_ids.len()); + let mut version_ids: Vec = Vec::with_capacity(doc_ids.len()); + for doc in &docs { + doc_to_version.insert(doc.id, doc.current_version_id); + version_ids.push(doc.current_version_id); + } + + version_ids.sort(); + version_ids.dedup(); + + let versions: Vec = document_versions::table + .filter(document_versions::id.eq_any(&version_ids)) + .filter(document_versions::tenant_id.eq(tenant_id)) + .load(conn)?; + + let mut version_map: HashMap = HashMap::new(); + for version in versions { + version_map.insert(version.id, version); + } + + let assets: Vec = document_assets::table + .filter(document_assets::document_version_id.eq_any(&version_ids)) + .filter(document_assets::tenant_id.eq(tenant_id)) + .order(( + document_assets::document_version_id.asc(), + document_assets::created_at.asc(), + )) + .load(conn)?; + + let mut assets_by_version: HashMap> = HashMap::new(); + for asset in assets { + let version_id = asset.document_version_id; + let response = self.asset_response(asset, tenant_id, user_id)?; + assets_by_version + .entry(version_id) + .or_default() + .push(response); + } + + docs.into_iter() + .map(|doc| { + let (tags, correspondents) = relations + .remove(&doc.id) + .unwrap_or_else(|| (Vec::new(), Vec::new())); + let current_version = doc_to_version + .get(&doc.id) + .and_then(|version_id| version_map.remove(version_id)) + .map(|version| -> AppResult<_> { + let assets = assets_by_version.remove(&version.id).unwrap_or_default(); + let download = build_download_link(self.state, &doc, version.id, user_id)?; + Ok((to_version_response(version), assets, download)) + }) + .transpose()?; + self.to_document_response(user_id, doc, tags, correspondents, current_version) + }) + .collect() + } + + pub async fn upload_document( + &self, + conn: &mut PgPooledConnection, + tenant_id: Uuid, + user_id: Uuid, + request: DocumentUploadRequest, + ) -> AppResult { + let DocumentUploadRequest { + bytes, + original_name, + mime_type, + folder_id, + metadata, + title_override, + tag_ids, + correspondents, + issued_at_override, + skip_if_existing, + } = request; + + if let Some(folder) = folder_id { + ensure_folder_exists_on_conn(conn, tenant_id, folder)?; + } + + let doc_id = Uuid::new_v4(); + let version_id = Uuid::new_v4(); + let version_number = 1; + let derived_title = title_override + .as_ref() + .map(|value| value.trim()) + .filter(|value| !value.is_empty()) + .map(|value| value.to_string()) + .unwrap_or_else(|| derive_document_title(&original_name)); + let stored_filename = filename_with_retained_extension(&derived_title, &original_name); + + let checksum = Sha256::digest(&bytes); + let checksum_hex = hex::encode(checksum); + let size_bytes = bytes.len() as i64; + let s3_key = document_version_object_key(doc_id, version_number, version_id); + + if let Some(reused) = self + .try_reuse_existing_document( + conn, + tenant_id, + user_id, + &checksum_hex, + issued_at_override, + skip_if_existing, + &tag_ids, + &correspondents, + ) + .await? + { + return Ok(DocumentUploadOutcome::Reused(reused)); + } + + let content_disposition = inline_content_disposition(&stored_filename); + + let storage = self.state.storage_for_tenant(tenant_id)?; + + storage + .put_object( + &s3_key, + bytes.clone(), + mime_type.clone(), + content_disposition.clone(), + ) + .await + .storage_context("failed to store document")?; + + let metadata_value = if metadata.is_null() { + Value::Object(Default::default()) + } else { + metadata + }; + + let (document, version) = match conn.transaction(|conn| { + let new_document = NewDocument { + id: doc_id, + filename: stored_filename.clone(), + original_name: original_name.clone(), + mime_type: mime_type.clone(), + folder_id, + current_version_id: version_id, + metadata: metadata_value.clone(), + issued_at: issued_at_override, + title: derived_title.clone(), + tenant_id, + }; + diesel::insert_into(documents::table) + .values(&new_document) + .execute(conn)?; + + let new_version = NewDocumentVersion { + id: version_id, + document_id: doc_id, + version_number, + s3_key: s3_key.clone(), + size_bytes, + checksum: checksum_hex.clone(), + metadata: Value::Object(Default::default()), + tenant_id, + }; + + diesel::insert_into(document_versions::table) + .values(&new_version) + .execute(conn)?; + + let document: Document = documents::table.find(doc_id).first(conn)?; + let version: DocumentVersion = document_versions::table.find(version_id).first(conn)?; + + Ok::<_, diesel::result::Error>((document, version)) + }) { + Ok(result) => result, + Err(diesel::result::Error::DatabaseError(DatabaseErrorKind::UniqueViolation, _)) => { + return Err(AppError::conflict( + "another document in this folder already uses that filename", + ) + .with_code("duplicate_filename")) + } + Err(err) => return Err(AppError::from(err)), + }; + + let detail = { + assign_tags_to_document(conn, tenant_id, &document, &tag_ids, Some(user_id))?; + + if !correspondents.is_empty() { + let raw_ids: Vec = correspondents + .iter() + .map(|assignment| assignment.correspondent_id) + .collect(); + let correspondent_ids = normalize_correspondent_ids(&raw_ids)?; + insert_document_correspondents( + conn, + tenant_id, + document.id, + user_id, + &correspondent_ids, + )?; + } + + let tags_and_correspondents = load_tags_and_correspondents(conn, &[doc_id])?; + let (tags, correspondents) = tags_and_correspondents + .get(&doc_id) + .cloned() + .unwrap_or_else(|| (Vec::new(), Vec::new())); + + let download = build_download_link(self.state, &document, version.id, user_id)?; + + DocumentDetailResponse { + document: self.to_document_response( + user_id, + document, + tags, + correspondents, + Some((to_version_response(version.clone()), Vec::new(), download)), + )?, + } + }; + + if let Err(err) = enqueue_job( + conn, + tenant_id, + JOB_ANALYZE_DOCUMENT, + json!({ + "document_id": doc_id, + "document_version_id": version.id, + "force": false, + }), + None, + ) { + warn!(document_id = %doc_id, error = %err, "failed to enqueue analyze job"); + } + + Ok(DocumentUploadOutcome::Created(detail)) + } + + pub fn request_document_assets( + &self, + conn: &mut PgPooledConnection, + tenant_id: Uuid, + document_id: Uuid, + force: bool, + ) -> AppResult<()> { + let document = load_active_document(conn, tenant_id, document_id)?; + + enqueue_job( + conn, + tenant_id, + JOB_ANALYZE_DOCUMENT, + json!({ + "document_id": document_id, + "document_version_id": document.current_version_id, + "force": force, + }), + None, + ) + .map_err(|err| { + error!(error = ?err, "failed to enqueue analyze job"); + AppError::internal("failed to enqueue analyze job") + })?; + + Ok(()) + } + + pub fn reanalyze_documents( + &self, + conn: &mut PgPooledConnection, + tenant_id: Uuid, + document_ids: &mut Vec, + force: bool, + ) -> AppResult { + validate_bulk_ids(document_ids, "document_ids")?; + + let targets: Vec<(Uuid, Uuid)> = documents::table + .filter(documents::id.eq_any(&*document_ids)) + .filter(documents::deleted_at.is_null()) + .filter(documents::tenant_id.eq(tenant_id)) + .select((documents::id, documents::current_version_id)) + .load(conn)?; + + if targets.len() != document_ids.len() { + return Err(AppError::bad_request( + "one or more documents do not exist or are inaccessible", + )); + } + + let mut queued = 0usize; + for (document_id, version_id) in targets { + enqueue_job( + conn, + tenant_id, + JOB_ANALYZE_DOCUMENT, + json!({ + "document_id": document_id, + "document_version_id": version_id, + "force": force, + }), + None, + ) + .map_err(|err| { + error!(error = ?err, "failed to enqueue analyze job"); + AppError::internal("failed to enqueue analyze job") + })?; + queued += 1; + } + + Ok(queued) + } + + pub async fn list_document_assets( + &self, + conn: &mut PgPooledConnection, + tenant_id: Uuid, + user_id: Uuid, + document_id: Uuid, + ) -> AppResult> { + let document = load_active_document(conn, tenant_id, document_id)?; + + let version_id = document.current_version_id; + let assets = document_assets::table + .filter(document_assets::document_version_id.eq(version_id)) + .filter(document_assets::tenant_id.eq(tenant_id)) + .order(document_assets::created_at.asc()) + .load::(conn)?; + + let mut responses = Vec::with_capacity(assets.len()); + for asset in assets { + responses.push(self.asset_response(asset, tenant_id, user_id)?); + } + + Ok(responses) + } + + pub async fn get_document_asset( + &self, + mut conn: PgPooledConnection, + tenant_id: Uuid, + user_id: Uuid, + asset_id: Uuid, + ) -> AppResult { + let asset: DocumentAsset = match document_assets::table + .find(asset_id) + .filter(document_assets::tenant_id.eq(tenant_id)) + .first(&mut conn) + .optional()? + { + Some(asset) => asset, + None => return Err(AppError::not_found()), + }; + + drop(conn); + + let download = self.asset_download_link(asset.id, tenant_id, user_id)?; + + Ok(to_asset_detail_response(asset, Some(download))) + } + + pub async fn get_document_download_link( + &self, + conn: &mut PgPooledConnection, + tenant_id: Uuid, + user_id: Uuid, + document_id: Uuid, + ) -> AppResult { + let document = load_active_document(conn, tenant_id, document_id)?; + let version: DocumentVersion = document_versions::table + .find(document.current_version_id) + .filter(document_versions::tenant_id.eq(tenant_id)) + .first(conn)?; + + build_download_link(self.state, &document, version.id, user_id) + } + + pub async fn get_document_version_download_link( + &self, + conn: &mut PgPooledConnection, + tenant_id: Uuid, + user_id: Uuid, + document_id: Uuid, + version_id: Uuid, + ) -> AppResult { + let document = load_active_document(conn, tenant_id, document_id)?; + + let version: Option = document_versions::table + .find(version_id) + .filter(document_versions::document_id.eq(document_id)) + .filter(document_versions::tenant_id.eq(tenant_id)) + .first(conn) + .optional()?; + + let Some(version) = version else { + return Err(AppError::not_found()); + }; + + build_download_link(self.state, &document, version.id, user_id) + } + + pub async fn get_asset_download_link( + &self, + conn: &mut PgPooledConnection, + tenant_id: Uuid, + user_id: Uuid, + asset_id: Uuid, + ) -> AppResult { + let asset: Option = document_assets::table + .find(asset_id) + .filter(document_assets::tenant_id.eq(tenant_id)) + .first(conn) + .optional()?; + + let Some(asset) = asset else { + return Err(AppError::not_found()); + }; + + self.asset_download_link(asset.id, tenant_id, user_id) + } + + pub fn list_document_versions( + &self, + conn: &mut PgPooledConnection, + tenant_id: Uuid, + document_id: Uuid, + ) -> AppResult> { + load_active_document(conn, tenant_id, document_id)?; + + let versions: Vec = document_versions::table + .filter(document_versions::document_id.eq(document_id)) + .filter(document_versions::tenant_id.eq(tenant_id)) + .order(document_versions::version_number.asc()) + .load(conn)?; + + Ok(versions.into_iter().map(to_version_response).collect()) + } + + pub async fn get_document_version( + &self, + conn: &mut PgPooledConnection, + tenant_id: Uuid, + user_id: Uuid, + document_id: Uuid, + version_id: Uuid, + ) -> AppResult { + let document = load_active_document(conn, tenant_id, document_id)?; + + let version: DocumentVersion = document_versions::table + .find(version_id) + .filter(document_versions::document_id.eq(document_id)) + .filter(document_versions::tenant_id.eq(tenant_id)) + .first(conn)?; + + let assets = self.load_asset_responses(conn, tenant_id, version.id, user_id)?; + let download = build_download_link(self.state, &document, version.id, user_id)?; + let version_core = to_version_response(version); + + Ok(DocumentVersionDetailResponse { + version: version_core, + assets, + download, + }) + } + + pub fn trash_document( + &self, + conn: &mut PgPooledConnection, + tenant_id: Uuid, + document_id: Uuid, + ) -> AppResult<()> { + conn.transaction::<_, AppError, _>(|conn| { + let document: Document = documents::table + .find(document_id) + .filter(documents::tenant_id.eq(tenant_id)) + .for_update() + .first(conn) + .optional()? + .ok_or_else(AppError::not_found)?; + + if document.deleted_at.is_some() { + return Err(AppError::conflict("document already trashed")); + } + + let now = Utc::now().naive_utc(); + diesel::update( + documents::table + .find(document_id) + .filter(documents::tenant_id.eq(tenant_id)), + ) + .set(( + documents::deleted_at.eq(Some(now)), + documents::updated_at.eq(now), + )) + .execute(conn)?; + + Ok(()) + }) + } + + pub fn delete_document( + &self, + conn: &mut PgPooledConnection, + tenant_id: Uuid, + document_id: Uuid, + ) -> AppResult<()> { + conn.transaction::<_, AppError, _>(|conn| { + let document = documents::table + .find(document_id) + .filter(documents::tenant_id.eq(tenant_id)) + .for_update() + .first::(conn) + .optional()? + .ok_or_else(AppError::not_found)?; + + if document.deleted_at.is_none() { + return Err(AppError::conflict( + "document must be trashed before permanent deletion", + )); + } + + let payload = json!({ "document_id": document_id }); + match enqueue_job(conn, tenant_id, JOB_PURGE_DOCUMENT, payload, None) { + Ok(_) => Ok(()), + Err(JobQueueError::Database(diesel::result::Error::DatabaseError( + DatabaseErrorKind::UniqueViolation, + _, + ))) => Ok(()), + Err(JobQueueError::Database(err)) => Err(AppError::from(err)), + } + }) + } + + pub async fn update_document( + &self, + conn: &mut PgPooledConnection, + tenant_id: Uuid, + user_id: Uuid, + document_id: Uuid, + payload: Value, + ) -> AppResult { + let mut document: Document = documents::table + .find(document_id) + .filter(documents::tenant_id.eq(tenant_id)) + .first(conn)?; + if document.deleted_at.is_some() { + return Err(AppError::not_found()); + } + + let payload_obj = payload + .as_object() + .ok_or_else(|| AppError::bad_request("request body must be a JSON object"))?; + let DocumentUpdatePlan { mut changeset } = + Self::build_document_update_plan(&document, payload_obj)?; + + let now = Utc::now().naive_utc(); + changeset.updated_at = Some(now); + + let target = documents::table + .find(document_id) + .filter(documents::tenant_id.eq(tenant_id)); + + let update_result = diesel::update(target).set(&changeset); + + match update_result.execute(conn) { + Ok(_) => {} + Err(diesel::result::Error::DatabaseError(DatabaseErrorKind::UniqueViolation, _)) => { + return Err(AppError::conflict( + "another document in this folder already uses that filename", + ) + .with_code("duplicate_filename")); + } + Err(err) => return Err(AppError::from(err)), + } + + document = documents::table + .find(document_id) + .filter(documents::tenant_id.eq(tenant_id)) + .first(conn)?; + + let current_version: DocumentVersion = document_versions::table + .find(document.current_version_id) + .first(conn)?; + + let tags_and_correspondents = load_tags_and_correspondents(conn, &[document_id])?; + let version_id = current_version.id; + let assets = self.load_asset_responses(conn, tenant_id, version_id, user_id)?; + let version_response = to_version_response(current_version); + let download = build_download_link(self.state, &document, version_id, user_id)?; + let (tags, correspondents) = tags_and_correspondents + .get(&document_id) + .cloned() + .unwrap_or_else(|| (Vec::new(), Vec::new())); + + let document_response = self.to_document_response( + user_id, + document, + tags, + correspondents, + Some((version_response, assets, download)), + )?; + + Ok(DocumentDetailResponse { + document: document_response, + }) + } + + pub fn restore_document( + &self, + conn: &mut PgPooledConnection, + tenant_id: Uuid, + document_id: Uuid, + folder_id: Option, + ) -> AppResult<()> { + let mut document: Document = documents::table + .find(document_id) + .filter(documents::tenant_id.eq(tenant_id)) + .first(conn)?; + + if document.deleted_at.is_none() { + return Ok(()); + } + + if let Some(folder_id) = folder_id { + ensure_folder_exists_on_conn(conn, tenant_id, folder_id)?; + document.folder_id = Some(folder_id); + } else if let Some(existing_folder) = document.folder_id { + let exists: bool = diesel::select(exists( + folders::table + .filter(folders::id.eq(existing_folder)) + .filter(folders::tenant_id.eq(tenant_id)), + )) + .get_result(conn)?; + + if !exists { + document.folder_id = None; + } + } + + let now = Utc::now().naive_utc(); + diesel::update( + documents::table + .find(document_id) + .filter(documents::tenant_id.eq(tenant_id)), + ) + .set(( + documents::deleted_at.eq::>(None), + documents::folder_id.eq(document.folder_id), + documents::updated_at.eq(now), + )) + .execute(conn)?; + + Ok(()) + } + + pub fn move_document( + &self, + conn: &mut PgPooledConnection, + tenant_id: Uuid, + document_id: Uuid, + folder_id: Option, + ) -> AppResult<()> { + if let Some(folder_id) = folder_id { + ensure_folder_exists_on_conn(conn, tenant_id, folder_id)?; + } + + let now = Utc::now().naive_utc(); + diesel::update( + documents::table + .find(document_id) + .filter(documents::tenant_id.eq(tenant_id)), + ) + .set(( + documents::folder_id.eq(folder_id), + documents::updated_at.eq(now), + )) + .execute(conn)?; + + Ok(()) + } + + pub fn bulk_move_documents( + &self, + conn: &mut PgPooledConnection, + tenant_id: Uuid, + mut document_ids: Vec, + folder_id: Option, + ) -> AppResult { + if document_ids.is_empty() { + return Err(AppError::bad_request("document_ids must not be empty")); + } + + document_ids.sort(); + document_ids.dedup(); + + if let Some(target_folder) = folder_id { + ensure_folder_exists_on_conn(conn, tenant_id, target_folder)?; + } + + let existing: Vec<(Uuid, Option)> = documents::table + .filter(documents::id.eq_any(&document_ids)) + .filter(documents::tenant_id.eq(tenant_id)) + .select((documents::id, documents::deleted_at)) + .load(conn)?; + + if existing.len() != document_ids.len() { + return Err(AppError::bad_request( + "one or more documents do not exist or are inaccessible", + )); + } + + if existing.iter().any(|(_, deleted)| deleted.is_some()) { + return Err(AppError::bad_request("cannot move deleted documents")); + } + + let now = Utc::now().naive_utc(); + let updated = match diesel::update( + documents::table + .filter(documents::id.eq_any(&document_ids)) + .filter(documents::tenant_id.eq(tenant_id)), + ) + .set(( + documents::folder_id.eq(folder_id), + documents::updated_at.eq(now), + )) + .execute(conn) + { + Ok(value) => value, + Err(diesel::result::Error::DatabaseError(kind, info)) => { + error!( + ?kind, + detail = ?info.details(), + constraint = info.constraint_name(), + tenant_id = %tenant_id, + target_folder = folder_id.map(|id| id.to_string()), + "bulk move update failed" + ); + let message = info + .constraint_name() + .map(|name| format!("constraint {name} prevented moving documents")) + .unwrap_or_else(|| "unable to move documents due to a constraint".to_string()); + return Err(AppError::conflict(message)); + } + Err(err) => { + error!( + ?err, + tenant_id = %tenant_id, + target_folder = folder_id.map(|id| id.to_string()), + "bulk move update failed" + ); + return Err(AppError::from(err)); + } + }; + + Ok(updated) + } + + fn to_document_response( + &self, + _user_id: Uuid, + doc: Document, + tags: Vec, + correspondents: Vec, + current_version: Option<( + DocumentVersionResponse, + Vec, + DownloadLink, + )>, + ) -> AppResult { + let current_version = match current_version { + Some((version, assets, download)) => Some(DocumentVersionDetailResponse { + version, + assets, + download, + }), + None => None, + }; + + Ok(DocumentResponse { + id: doc.id, + filename: doc.filename, + title: doc.title, + original_name: doc.original_name, + mime_type: doc.mime_type, + folder_id: doc.folder_id, + created_at: to_iso(doc.created_at), + updated_at: to_iso(doc.updated_at), + deleted_at: doc.deleted_at.map(to_iso), + issued_at: doc.issued_at.map(to_iso), + metadata: doc.metadata, + tags: tags.into_iter().map(TagResponse::from).collect(), + correspondents, + current_version, + }) + } + + async fn try_reuse_existing_document( + &self, + conn: &mut PgPooledConnection, + tenant_id: Uuid, + user_id: Uuid, + checksum_hex: &str, + issued_at_override: Option, + skip_if_existing: bool, + tag_ids: &[Uuid], + correspondents: &[CorrespondentAssignmentInput], + ) -> AppResult> { + let existing = documents::table + .inner_join( + document_versions::table + .on(document_versions::id.eq(documents::current_version_id)), + ) + .filter(documents::tenant_id.eq(tenant_id)) + .filter(document_versions::checksum.eq(checksum_hex)) + .select((documents::all_columns, document_versions::all_columns)) + .first::<(Document, DocumentVersion)>(conn) + .optional()?; + + let Some((mut document, version)) = existing else { + return Ok(None); + }; + + if skip_if_existing { + info!( + document_id = %document.id, + checksum = %checksum_hex, + "upload rejected because document already exists", + ); + + let mut message = String::from("a document with the same contents already exists"); + let mut details = json!({ + "conflict_document_id": document.id, + }); + + if let Some(deleted_at) = document.deleted_at { + message.push_str(". Note: the existing document is currently in the trash."); + if let Some(obj) = details.as_object_mut() { + obj.insert("conflict_document_in_trash".to_string(), Value::Bool(true)); + obj.insert( + "conflict_document_deleted_at".to_string(), + Value::String(to_iso(deleted_at)), + ); + } + } + + return Err(AppError::conflict(message) + .with_code("duplicate_document") + .with_details(details)); + } + + if let Some(issued_at) = issued_at_override { + if document.issued_at != Some(issued_at) { + diesel::update( + documents::table + .find(document.id) + .filter(documents::tenant_id.eq(tenant_id)), + ) + .set(( + documents::issued_at.eq(Some(issued_at)), + documents::updated_at.eq(Utc::now().naive_utc()), + )) + .execute(conn)?; + document.issued_at = Some(issued_at); + } + } + + assign_tags_to_document(conn, tenant_id, &document, tag_ids, Some(user_id))?; + + if !correspondents.is_empty() { + let raw_ids: Vec = correspondents + .iter() + .map(|assignment| assignment.correspondent_id) + .collect(); + let correspondent_ids = normalize_correspondent_ids(&raw_ids)?; + insert_document_correspondents( + conn, + tenant_id, + document.id, + user_id, + &correspondent_ids, + )?; + } + + if document.deleted_at.is_some() { + let now = Utc::now().naive_utc(); + diesel::update(documents::table.find(document.id)) + .set(( + documents::deleted_at.eq(None::), + documents::updated_at.eq(now), + )) + .execute(conn)?; + document.deleted_at = None; + document.updated_at = now; + } + + let relations = load_tags_and_correspondents(conn, &[document.id])?; + let (tags, correspondents_list) = relations + .get(&document.id) + .cloned() + .unwrap_or_else(|| (Vec::new(), Vec::new())); + + let assets = self.load_asset_responses(conn, tenant_id, version.id, user_id)?; + let download = build_download_link(self.state, &document, version.id, user_id)?; + let version_response = to_version_response(version.clone()); + + info!( + document_id = %document.id, + checksum = %checksum_hex, + "upload deduplicated existing document" + ); + + let detail = DocumentDetailResponse { + document: self.to_document_response( + user_id, + document, + tags, + correspondents_list, + Some((version_response, assets, download)), + )?, + }; + + Ok(Some(detail)) + } + + fn asset_download_link( + &self, + asset_id: Uuid, + tenant_id: Uuid, + user_id: Uuid, + ) -> AppResult { + let token = self + .state + .jwt + .generate_asset_download_token(asset_id, user_id, tenant_id) + .map_err(|err| { + error!(error = ?err, "failed to issue asset download token"); + AppError::internal("failed to issue asset download token") + })?; + + let expires_at = Utc::now() + .checked_add_signed(ChronoDuration::minutes( + self.state.config.download_token_expiry_minutes, + )) + .ok_or_else(|| AppError::internal("failed to compute download expiry"))? + .timestamp_millis(); + + Ok(DownloadLink { + url: format!("/api/download/{token}"), + expires_at, + }) + } + + fn asset_response( + &self, + asset: DocumentAsset, + tenant_id: Uuid, + user_id: Uuid, + ) -> AppResult { + let download = self.asset_download_link(asset.id, tenant_id, user_id)?; + + Ok(DocumentAssetResponse { + id: asset.id, + asset_type: asset.asset_type, + mime_type: asset.mime_type, + metadata: asset.metadata, + download: Some(download), + }) + } + + fn load_asset_responses( + &self, + conn: &mut PgPooledConnection, + tenant_id: Uuid, + version_id: Uuid, + user_id: Uuid, + ) -> AppResult> { + let assets: Vec = document_assets::table + .filter(document_assets::document_version_id.eq(version_id)) + .filter(document_assets::tenant_id.eq(tenant_id)) + .order(document_assets::created_at.asc()) + .load(conn)?; + + assets + .into_iter() + .map(|asset| self.asset_response(asset, tenant_id, user_id)) + .collect() + } +} diff --git a/backend/src/services/folders.rs b/backend/src/services/folders.rs new file mode 100644 index 0000000..b64f798 --- /dev/null +++ b/backend/src/services/folders.rs @@ -0,0 +1,635 @@ +use std::collections::HashMap; + +use diesel::pg::PgConnection; +use diesel::{ + dsl::{exists, sql}, + prelude::*, + sql_types::Text, + Connection, +}; +use serde::{Deserialize, Serialize}; +use utoipa::{IntoParams, ToSchema}; +use uuid::Uuid; + +use crate::documents::ordering::{ordering_clauses, DocumentSortField, SortDirection}; +use crate::error::{AppError, AppResult}; +use crate::http::responders::{IntoAppResult, RowsAffectedExt}; +use crate::models::{Document, Folder, NewFolder}; +use crate::schema::{documents, folders}; +use crate::services::documents::{DocumentResponse, DocumentsService}; +use crate::state::{AppState, PgPooledConnection}; +use crate::utils::{json::deserialize_patch_field, text::normalize_identifier, time::to_iso}; + +const MAX_FOLDER_NAME_LEN: usize = 255; + +#[derive(Deserialize, ToSchema)] +pub struct CreateFolderRequest { + pub name: String, + #[schema(nullable)] + pub parent_id: Option, +} + +#[derive(Deserialize, ToSchema)] +pub struct EnsureFolderPathRequest { + #[schema(nullable)] + pub parent_id: Option, + pub segments: Vec, +} + +#[derive(Serialize, ToSchema, Clone, Debug)] +pub struct FolderInfo { + pub id: Uuid, + pub name: String, + #[schema(nullable)] + pub parent_id: Option, + pub created_at: String, + pub updated_at: String, +} + +#[derive(Clone, Serialize, ToSchema)] +pub struct FolderTreeNode { + pub id: Uuid, + pub name: String, + #[schema(nullable)] + pub parent_id: Option, + pub created_at: String, + pub updated_at: String, + #[serde(default)] + pub children: Vec, +} + +#[derive(Deserialize, IntoParams, ToSchema)] +#[into_params(parameter_in = Query)] +pub struct FolderContentsQuery { + #[serde(default = "default_include_documents")] + #[schema(default = true)] + pub include_documents: bool, + #[serde(default)] + #[schema(default = "title")] + pub sort: DocumentSortField, + #[serde(default)] + #[schema(default = "asc")] + pub dir: SortDirection, +} + +#[derive(Default, Deserialize, ToSchema)] +pub struct UpdateFolderRequest { + #[serde(default, deserialize_with = "deserialize_patch_field")] + #[schema(nullable, value_type = Option)] + pub parent_id: Option>, + #[serde(default, deserialize_with = "deserialize_patch_field")] + #[schema(nullable)] + pub name: Option>, +} + +pub struct FolderContentsData { + pub folder: Option, + pub subfolders: Vec, + pub documents: Vec, +} + +pub struct FolderService<'a> { + state: &'a AppState, +} + +impl<'a> FolderService<'a> { + pub fn new(state: &'a AppState) -> Self { + Self { state } + } + + pub fn get_folder( + &self, + conn: &mut PgPooledConnection, + tenant_id: Uuid, + folder_id: Uuid, + ) -> AppResult { + let folder: Folder = folders::table + .find(folder_id) + .filter(folders::tenant_id.eq(tenant_id)) + .first(conn)?; + Ok(folder_to_info(folder)) + } + + pub fn ensure_folder_path( + &self, + conn: &mut PgPooledConnection, + tenant_id: Uuid, + payload: EnsureFolderPathRequest, + ) -> AppResult { + if payload.segments.is_empty() { + return Err(AppError::bad_request("segments must not be empty")); + } + + let folder = conn.transaction::(|conn| { + let mut current_parent = payload.parent_id; + let mut last_folder: Option = None; + + for raw_name in &payload.segments { + let name = normalize_folder_name(raw_name, "folder names must not be empty")?; + + let existing: Option = if let Some(parent_id) = current_parent { + folders::table + .filter(folders::tenant_id.eq(tenant_id)) + .filter(folders::parent_id.eq(Some(parent_id))) + .filter(folders::name.eq(&name)) + .first(conn) + .optional()? + } else { + folders::table + .filter(folders::tenant_id.eq(tenant_id)) + .filter(folders::parent_id.is_null()) + .filter(folders::name.eq(&name)) + .first(conn) + .optional()? + }; + + let folder = if let Some(folder) = existing { + folder + } else { + let new_folder = NewFolder { + id: Uuid::new_v4(), + name: name.clone(), + parent_id: current_parent, + tenant_id, + }; + + let inserted_id: Option = diesel::insert_into(folders::table) + .values(&new_folder) + .on_conflict_do_nothing() + .returning(folders::id) + .get_result(conn) + .optional()?; + + if let Some(id) = inserted_id { + folders::table + .find(id) + .filter(folders::tenant_id.eq(tenant_id)) + .first(conn)? + } else if let Some(parent_id) = current_parent { + folders::table + .filter(folders::tenant_id.eq(tenant_id)) + .filter(folders::parent_id.eq(Some(parent_id))) + .filter(folders::name.eq(&name)) + .first(conn)? + } else { + folders::table + .filter(folders::tenant_id.eq(tenant_id)) + .filter(folders::parent_id.is_null()) + .filter(folders::name.eq(&name)) + .first(conn)? + } + }; + + current_parent = Some(folder.id); + last_folder = Some(folder); + } + + last_folder.ok_or_else(|| AppError::internal("failed to resolve folder path")) + })?; + + Ok(folder_to_info(folder)) + } + + pub fn create_folder( + &self, + conn: &mut PgPooledConnection, + tenant_id: Uuid, + payload: CreateFolderRequest, + ) -> AppResult<(FolderInfo, bool)> { + let name = normalize_folder_name(&payload.name, "name must not be empty")?; + + let existing: Option = if let Some(parent_id) = payload.parent_id { + folders::table + .filter(folders::tenant_id.eq(tenant_id)) + .filter(folders::parent_id.eq(Some(parent_id))) + .filter(folders::name.eq(&name)) + .first(conn) + .optional()? + } else { + folders::table + .filter(folders::tenant_id.eq(tenant_id)) + .filter(folders::parent_id.is_null()) + .filter(folders::name.eq(&name)) + .first(conn) + .optional()? + }; + + let (folder, created) = if let Some(folder) = existing { + (folder, false) + } else { + let new_folder = NewFolder { + id: Uuid::new_v4(), + name: name.clone(), + parent_id: payload.parent_id, + tenant_id, + }; + + let inserted_id: Option = diesel::insert_into(folders::table) + .values(&new_folder) + .on_conflict_do_nothing() + .returning(folders::id) + .get_result(conn) + .optional()?; + + if let Some(id) = inserted_id { + ( + folders::table + .find(id) + .filter(folders::tenant_id.eq(tenant_id)) + .first(conn)?, + true, + ) + } else if let Some(parent_id) = payload.parent_id { + ( + folders::table + .filter(folders::tenant_id.eq(tenant_id)) + .filter(folders::parent_id.eq(Some(parent_id))) + .filter(folders::name.eq(&name)) + .first(conn)?, + false, + ) + } else { + ( + folders::table + .filter(folders::tenant_id.eq(tenant_id)) + .filter(folders::parent_id.is_null()) + .filter(folders::name.eq(&name)) + .first(conn)?, + false, + ) + } + }; + + Ok((folder_to_info(folder), created)) + } + + pub fn list_folder_contents( + &self, + conn: &mut PgPooledConnection, + tenant_id: Uuid, + folder_id: Option, + sort: DocumentSortField, + dir: SortDirection, + include_documents: bool, + ) -> AppResult { + let folder = match folder_id { + Some(id) => Some(folder_to_info( + folders::table + .find(id) + .filter(folders::tenant_id.eq(tenant_id)) + .first::(conn)?, + )), + None => None, + }; + + let child_folders: Vec = if let Some(parent_id) = folder_id { + folders::table + .filter(folders::parent_id.eq(parent_id)) + .filter(folders::tenant_id.eq(tenant_id)) + .order(sql::("name COLLATE \"unicode_ci\" ASC")) + .load(conn)? + } else { + folders::table + .filter(folders::parent_id.is_null()) + .filter(folders::tenant_id.eq(tenant_id)) + .order(sql::("name COLLATE \"unicode_ci\" ASC")) + .load(conn)? + }; + + let subfolders = child_folders.into_iter().map(folder_to_info).collect(); + + let documents = if include_documents { + let mut docs_query = documents::table + .filter(documents::deleted_at.is_null()) + .filter(documents::tenant_id.eq(tenant_id)) + .into_boxed(); + + let (primary_sql, secondary_sql) = ordering_clauses(sort, dir); + docs_query = docs_query.order(sql::(primary_sql)); + if let Some(second) = secondary_sql { + docs_query = docs_query.then_order_by(sql::(second)); + } + + if let Some(current_folder) = folder_id { + docs_query + .filter(documents::folder_id.eq(current_folder)) + .load::(conn)? + } else { + docs_query + .filter(documents::folder_id.is_null()) + .load::(conn)? + } + } else { + Vec::new() + }; + + Ok(FolderContentsData { + folder, + subfolders, + documents, + }) + } + + pub fn list_folder_tree( + &self, + conn: &mut PgPooledConnection, + tenant_id: Uuid, + ) -> AppResult> { + let folders: Vec = folders::table + .filter(folders::tenant_id.eq(tenant_id)) + .order(sql::("name COLLATE \"unicode_ci\" ASC")) + .load(conn)?; + + let mut node_map: HashMap = HashMap::with_capacity(folders.len()); + let mut children_map: HashMap> = HashMap::new(); + let mut roots: Vec = Vec::new(); + + for folder in folders { + let id = folder.id; + let parent_id = folder.parent_id; + let node = FolderTreeNode { + id, + name: folder.name, + parent_id, + created_at: to_iso(folder.created_at), + updated_at: to_iso(folder.updated_at), + children: Vec::new(), + }; + + if let Some(parent) = parent_id { + children_map.entry(parent).or_default().push(id); + } else { + roots.push(id); + } + + node_map.insert(id, node); + } + + fn build_node( + id: Uuid, + nodes: &HashMap, + child_map: &HashMap>, + ) -> FolderTreeNode { + let mut node = nodes.get(&id).cloned().expect("folder node must exist"); + + if let Some(children) = child_map.get(&id) { + node.children = children + .iter() + .map(|child_id| build_node(*child_id, nodes, child_map)) + .collect(); + } + + node + } + + let tree = roots + .iter() + .map(|root_id| build_node(*root_id, &node_map, &children_map)) + .collect(); + + Ok(tree) + } + + pub fn delete_folder( + &self, + conn: &mut PgPooledConnection, + tenant_id: Uuid, + folder_id: Uuid, + ) -> AppResult<()> { + conn.transaction::<_, AppError, _>(|conn| { + folders::table + .find(folder_id) + .filter(folders::tenant_id.eq(tenant_id)) + .first::(conn)?; + + let has_child_folders: bool = diesel::select(exists( + folders::table + .filter(folders::parent_id.eq(Some(folder_id))) + .filter(folders::tenant_id.eq(tenant_id)), + )) + .get_result(conn)?; + + if has_child_folders { + return Err(AppError::bad_request( + "folder must be empty before deletion", + )); + } + + let has_documents: bool = diesel::select(exists( + documents::table + .filter(documents::folder_id.eq(Some(folder_id))) + .filter(documents::tenant_id.eq(tenant_id)) + .filter(documents::deleted_at.is_null()), + )) + .get_result(conn)?; + + if has_documents { + return Err(AppError::bad_request( + "folder must be empty before deletion", + )); + } + + diesel::delete( + folders::table + .filter(folders::id.eq(folder_id)) + .filter(folders::tenant_id.eq(tenant_id)), + ) + .execute(conn) + .into_app_result()? + .or_not_found()?; + + Ok(()) + }) + } + + pub fn update_folder( + &self, + conn: &mut PgPooledConnection, + tenant_id: Uuid, + folder_id: Uuid, + payload: UpdateFolderRequest, + ) -> AppResult<()> { + conn.transaction::<_, AppError, _>(|conn| { + let folder: Folder = folders::table + .find(folder_id) + .filter(folders::tenant_id.eq(tenant_id)) + .first(conn)?; + + let mut next_parent = folder.parent_id; + let mut parent_changed = false; + match payload.parent_id { + None => {} + Some(None) => { + if folder.parent_id.is_some() { + parent_changed = true; + } + next_parent = None; + } + Some(Some(parent_id)) => { + if parent_id == folder_id { + return Err(AppError::bad_request("folder cannot be its own parent")); + } + + folders::table + .find(parent_id) + .filter(folders::tenant_id.eq(tenant_id)) + .first::(conn)?; + + if folder.parent_id != Some(parent_id) { + let descendant_ids = + gather_descendant_folder_ids(conn, tenant_id, folder_id)?; + if descendant_ids.contains(&parent_id) { + return Err(AppError::bad_request( + "cannot move folder into itself or a descendant", + )); + } + parent_changed = true; + } + + next_parent = Some(parent_id); + } + } + + let mut new_name = folder.name.clone(); + let mut name_changed = false; + match payload.name { + None => {} + Some(None) => { + return Err(AppError::bad_request("name cannot be null")); + } + Some(Some(value)) => { + let normalized = normalize_folder_name(&value, "name must not be empty")?; + + if normalized != folder.name { + new_name = normalized; + name_changed = true; + } + } + } + + if !parent_changed && !name_changed { + return Ok(()); + } + + let conflict = if let Some(parent_id) = next_parent { + folders::table + .filter(folders::parent_id.eq(Some(parent_id))) + .filter(folders::name.eq(&new_name)) + .filter(folders::id.ne(folder_id)) + .filter(folders::tenant_id.eq(tenant_id)) + .first::(conn) + .optional()? + } else { + folders::table + .filter(folders::parent_id.is_null()) + .filter(folders::name.eq(&new_name)) + .filter(folders::id.ne(folder_id)) + .filter(folders::tenant_id.eq(tenant_id)) + .first::(conn) + .optional()? + }; + + if conflict.is_some() { + return Err(AppError::bad_request( + "a folder with the same name already exists in the target", + )); + } + + diesel::update( + folders::table + .find(folder_id) + .filter(folders::tenant_id.eq(tenant_id)), + ) + .set(( + folders::parent_id.eq(next_parent), + folders::name.eq(&new_name), + )) + .execute(conn) + .into_app_result()? + .or_not_found()?; + + Ok(()) + }) + } + + pub fn hydrate_documents( + &self, + conn: &mut PgPooledConnection, + tenant_id: Uuid, + user_id: Uuid, + docs: Vec, + ) -> AppResult> { + DocumentsService::new(self.state).hydrate_documents(conn, tenant_id, user_id, docs) + } +} + +pub fn gather_descendant_folder_ids( + conn: &mut PgConnection, + tenant_id: Uuid, + folder_id: Uuid, +) -> AppResult> { + let mut ids = vec![folder_id]; + let mut queue = vec![folder_id]; + + while let Some(current) = queue.pop() { + let child_ids: Vec = folders::table + .filter(folders::parent_id.eq(Some(current))) + .filter(folders::tenant_id.eq(tenant_id)) + .select(folders::id) + .load(conn)?; + queue.extend(child_ids.iter().copied()); + ids.extend(child_ids); + } + + Ok(ids) +} + +fn folder_to_info(folder: Folder) -> FolderInfo { + FolderInfo { + id: folder.id, + name: folder.name, + parent_id: folder.parent_id, + created_at: to_iso(folder.created_at), + updated_at: to_iso(folder.updated_at), + } +} + +fn normalize_folder_name(value: &str, empty_message: &str) -> AppResult { + normalize_identifier( + value, + MAX_FOLDER_NAME_LEN, + empty_message, + "folder name must not exceed 255 characters", + Some("folder name may only contain printable characters"), + |ch| !ch.is_control(), + ) +} + +const fn default_include_documents() -> bool { + true +} + +#[cfg(test)] +mod tests { + use super::UpdateFolderRequest; + use serde_json::json; + + #[test] + fn update_folder_request_deserializes_null_parent() { + let req: UpdateFolderRequest = + serde_json::from_value(json!({ "parent_id": null })).unwrap(); + assert!(matches!(req.parent_id, Some(None))); + } + + #[test] + fn update_folder_request_deserializes_absent_parent() { + let req: UpdateFolderRequest = serde_json::from_value(json!({})).unwrap(); + assert!(req.parent_id.is_none()); + } + + #[test] + fn update_folder_request_deserializes_null_name() { + let req: UpdateFolderRequest = serde_json::from_value(json!({ "name": null })).unwrap(); + assert!(matches!(req.name, Some(None))); + } +} diff --git a/backend/src/services/helpers.rs b/backend/src/services/helpers.rs new file mode 100644 index 0000000..50c0052 --- /dev/null +++ b/backend/src/services/helpers.rs @@ -0,0 +1,25 @@ +use diesel::prelude::*; +use uuid::Uuid; + +use crate::error::{AppError, AppResult}; +use crate::models::Document; +use crate::schema::documents; +use crate::state::PgPooledConnection; + +/// Load a document that belongs to the tenant and is not soft-deleted. +pub fn load_active_document( + conn: &mut PgPooledConnection, + tenant_id: Uuid, + document_id: Uuid, +) -> AppResult { + let doc: Document = documents::table + .find(document_id) + .filter(documents::tenant_id.eq(tenant_id)) + .first(conn)?; + + if doc.deleted_at.is_some() { + return Err(AppError::not_found()); + } + + Ok(doc) +} diff --git a/backend/src/services/mod.rs b/backend/src/services/mod.rs new file mode 100644 index 0000000..d8a266b --- /dev/null +++ b/backend/src/services/mod.rs @@ -0,0 +1,9 @@ +pub mod auth; +pub mod capability_sets; +pub mod correspondents; +pub mod documents; +pub mod folders; +pub mod helpers; +pub mod profile; +pub mod tags; +pub mod tenants; diff --git a/backend/src/services/profile.rs b/backend/src/services/profile.rs new file mode 100644 index 0000000..b5ea4dc --- /dev/null +++ b/backend/src/services/profile.rs @@ -0,0 +1,222 @@ +use axum::http::StatusCode; +use chrono::{DateTime, NaiveDateTime}; +use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; +use uuid::Uuid; + +use crate::auth::{ + api_tokens::{ + create_api_token as issue_token, list_api_tokens as load_tokens, + regenerate_api_token as rotate_token, revoke_api_token as revoke_token, + }, + capability_sets::load_capability_set, + passkeys::PasskeySummary, +}; +use crate::error::{AppError, AppResult}; +use crate::http::responders::{created_json, no_content, ok_json, JsonResponse}; +use crate::models::ApiToken; +use crate::state::{AppState, PgPooledConnection}; +use crate::utils::time::to_iso; + +#[derive(Debug, Serialize, ToSchema)] +pub struct ApiTokenResponse { + pub id: Uuid, + pub tenant_id: Uuid, + #[schema(nullable)] + pub label: Option, + pub capability_set_id: Uuid, + pub created_at: String, + #[schema(nullable)] + pub last_used_at: Option, + #[schema(nullable)] + pub expires_at: Option, + #[schema(nullable)] + pub revoked_at: Option, +} + +#[derive(Debug, Serialize, ToSchema)] +pub struct ApiTokenCreatedResponse { + pub token: String, + pub token_info: ApiTokenResponse, +} + +#[derive(Debug, Deserialize, ToSchema)] +pub struct CreateApiTokenRequest { + #[schema(nullable)] + pub label: Option, + #[schema(nullable)] + pub expires_at: Option, + pub capability_set_id: Uuid, +} + +#[derive(Debug, Deserialize, ToSchema)] +pub struct RevokePasskeyQuery { + #[serde(default)] + #[schema(nullable)] + pub reason: Option, +} + +pub struct ProfileService<'a> { + state: &'a AppState, +} + +impl<'a> ProfileService<'a> { + pub fn new(state: &'a AppState) -> Self { + Self { state } + } + + pub fn list_passkeys( + &self, + conn: &mut PgPooledConnection, + user_id: Uuid, + ) -> AppResult>> { + let service = self + .state + .passkeys + .as_ref() + .ok_or_else(|| AppError::bad_request("passkey support is disabled"))?; + + let passkeys = service.list_for_user(conn, user_id)?; + ok_json(passkeys) + } + + pub fn list_api_tokens( + &self, + conn: &mut PgPooledConnection, + tenant_id: Uuid, + user_id: Uuid, + ) -> AppResult>> { + let tokens = load_tokens(conn, user_id, Some(tenant_id))?; + let responses = tokens.into_iter().map(api_token_to_response).collect(); + ok_json(responses) + } + + pub fn create_api_token( + &self, + conn: &mut PgPooledConnection, + tenant_id: Uuid, + user_id: Uuid, + payload: CreateApiTokenRequest, + ) -> AppResult> { + let expires_at = payload + .expires_at + .as_ref() + .map(|value| parse_timestamp(value)) + .transpose()?; + + let capability_set_id = + validate_capability_set(conn, tenant_id, payload.capability_set_id)?; + + let issued = issue_token( + conn, + user_id, + tenant_id, + payload.label.clone(), + expires_at, + capability_set_id, + )?; + + let token_info = api_token_to_response(issued.record); + + let response = ApiTokenCreatedResponse { + token: issued.token, + token_info, + }; + + created_json(response) + } + + pub fn regenerate_api_token( + &self, + conn: &mut PgPooledConnection, + tenant_id: Uuid, + user_id: Uuid, + token_id: Uuid, + ) -> AppResult> { + let issued = rotate_token(conn, token_id, user_id, Some(tenant_id))?; + let token_info = api_token_to_response(issued.record); + ok_json(ApiTokenCreatedResponse { + token: issued.token, + token_info, + }) + } + + pub fn delete_api_token( + &self, + conn: &mut PgPooledConnection, + user_id: Uuid, + token_id: Uuid, + ) -> AppResult { + revoke_token(conn, token_id, user_id)?; + no_content() + } + + pub fn delete_passkey( + &self, + conn: &mut PgPooledConnection, + user_id: Uuid, + passkey_id: Uuid, + reason: Option, + ) -> AppResult { + let service = self + .state + .passkeys + .as_ref() + .ok_or_else(|| AppError::bad_request("passkey support is disabled"))?; + + let active_count = service.active_passkey_count(conn, user_id)?; + if active_count <= 1 { + return Err(AppError::bad_request( + "cannot revoke the last remaining passkey", + )); + } + + service.revoke_passkey(conn, user_id, passkey_id, reason)?; + no_content() + } +} + +fn api_token_to_response(token: ApiToken) -> ApiTokenResponse { + let ApiToken { + id, + tenant_id, + label, + created_at, + last_used_at, + expires_at, + revoked_at, + capability_set_id, + .. + } = token; + + ApiTokenResponse { + id, + tenant_id, + label, + capability_set_id, + created_at: to_iso(created_at), + last_used_at: last_used_at.map(to_iso), + expires_at: expires_at.map(to_iso), + revoked_at: revoked_at.map(to_iso), + } +} + +fn parse_timestamp(value: &str) -> AppResult { + let dt = DateTime::parse_from_rfc3339(value) + .map_err(|_| AppError::bad_request("invalid expires_at timestamp"))?; + Ok(dt.naive_utc()) +} + +fn validate_capability_set( + conn: &mut PgPooledConnection, + tenant_id: Uuid, + capability_set_id: Uuid, +) -> AppResult { + let set = load_capability_set(conn, capability_set_id)?; + if set.tenant_id != tenant_id { + return Err(AppError::bad_request( + "capability set does not belong to the tenant", + )); + } + Ok(set.id) +} diff --git a/backend/src/services/tags.rs b/backend/src/services/tags.rs new file mode 100644 index 0000000..595464c --- /dev/null +++ b/backend/src/services/tags.rs @@ -0,0 +1,166 @@ +use diesel::prelude::*; +use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; +use uuid::Uuid; + +use crate::documents::tags::assign_tags as assign_tags_to_document; +use crate::error::{AppError, AppResult}; +use crate::models::{Document, NewDocumentTag, Tag}; +use crate::schema::{document_tags, documents, tags}; +use crate::state::{AppState, PgPooledConnection}; +use crate::utils::db::validate_bulk_ids; + +#[derive(Deserialize, ToSchema)] +pub struct AssignTagsRequest { + pub tag_ids: Vec, +} + +#[derive(Deserialize, Copy, Clone, PartialEq, Eq, ToSchema)] +#[serde(rename_all = "snake_case")] +pub enum BulkTagAction { + Add, + Remove, +} + +#[derive(Deserialize, ToSchema)] +pub struct BulkTagRequest { + pub document_ids: Vec, + pub tag_ids: Vec, + pub action: BulkTagAction, +} + +#[derive(Serialize, ToSchema)] +pub struct BulkTagResponse { + pub added: usize, + pub removed: usize, +} + +pub struct TagsService<'a> { + _state: &'a AppState, +} + +impl<'a> TagsService<'a> { + pub fn new(state: &'a AppState) -> Self { + Self { _state: state } + } + + pub fn assign_to_document( + &self, + conn: &mut PgPooledConnection, + tenant_id: Uuid, + user_id: Uuid, + document_id: Uuid, + tag_ids: &[Uuid], + ) -> AppResult<()> { + if tag_ids.is_empty() { + return Err(AppError::bad_request("tag_ids must not be empty")); + } + + let document: Document = documents::table + .find(document_id) + .filter(documents::tenant_id.eq(tenant_id)) + .first(conn)?; + + assign_tags_to_document(conn, tenant_id, &document, tag_ids, Some(user_id))?; + Ok(()) + } + + pub fn bulk_update( + &self, + conn: &mut PgPooledConnection, + tenant_id: Uuid, + user_id: Uuid, + mut payload: BulkTagRequest, + ) -> AppResult { + validate_bulk_ids(&mut payload.document_ids, "document_ids")?; + validate_bulk_ids(&mut payload.tag_ids, "tag_ids")?; + + let docs: Vec<(Uuid, Option)> = documents::table + .filter(documents::id.eq_any(&payload.document_ids)) + .filter(documents::tenant_id.eq(tenant_id)) + .select((documents::id, documents::deleted_at)) + .load(conn)?; + + if docs.len() != payload.document_ids.len() { + return Err(AppError::bad_request( + "one or more documents do not exist or are inaccessible", + )); + } + + if docs.iter().any(|(_, deleted)| deleted.is_some()) { + return Err(AppError::bad_request( + "cannot assign or remove tags from deleted documents", + )); + } + + let existing_tags: Vec = tags::table + .filter(tags::id.eq_any(&payload.tag_ids)) + .filter(tags::tenant_id.eq(tenant_id)) + .load(conn)?; + + if existing_tags.len() != payload.tag_ids.len() { + return Err(AppError::bad_request("one or more tags do not exist")); + } + + match payload.action { + BulkTagAction::Add => { + let mut inserts = + Vec::with_capacity(payload.document_ids.len() * payload.tag_ids.len()); + for doc_id in &payload.document_ids { + for tag_id in &payload.tag_ids { + inserts.push(NewDocumentTag { + document_id: *doc_id, + tag_id: *tag_id, + assigned_by: Some(user_id), + tenant_id, + }); + } + } + + let added = if inserts.is_empty() { + 0 + } else { + diesel::insert_into(document_tags::table) + .values(&inserts) + .on_conflict_do_nothing() + .execute(conn)? + }; + + Ok(BulkTagResponse { added, removed: 0 }) + } + BulkTagAction::Remove => { + let removed = diesel::delete( + document_tags::table + .filter(document_tags::document_id.eq_any(&payload.document_ids)) + .filter(document_tags::tenant_id.eq(tenant_id)) + .filter(document_tags::tag_id.eq_any(&payload.tag_ids)), + ) + .execute(conn)?; + + Ok(BulkTagResponse { added: 0, removed }) + } + } + } + + pub fn remove_from_document( + &self, + conn: &mut PgPooledConnection, + tenant_id: Uuid, + document_id: Uuid, + tag_id: Uuid, + ) -> AppResult<()> { + let deleted = diesel::delete( + document_tags::table + .filter(document_tags::document_id.eq(document_id)) + .filter(document_tags::tenant_id.eq(tenant_id)) + .filter(document_tags::tag_id.eq(tag_id)), + ) + .execute(conn)?; + + if deleted == 0 { + return Err(AppError::not_found()); + } + + Ok(()) + } +} diff --git a/backend/src/services/tenants.rs b/backend/src/services/tenants.rs new file mode 100644 index 0000000..42109e5 --- /dev/null +++ b/backend/src/services/tenants.rs @@ -0,0 +1,260 @@ +use chrono::Utc; +use diesel::prelude::*; +use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; +use uuid::Uuid; + +use crate::auth::AuthenticatedUser; +use crate::error::{AppError, AppResult}; +use crate::http::responders::{ok_json, JsonResponse}; +use crate::models::ApiCapability; +use crate::schema::{ + capability_sets::dsl as cs_dsl, user_memberships::dsl as memberships_dsl, + user_sessions::dsl as session_dsl, users::dsl as users_dsl, +}; +use crate::state::{AppState, PgPooledConnection}; + +#[derive(Deserialize, ToSchema)] +pub struct UpdateTenantRequest { + #[schema(example = "Acme Inc.")] + pub name: String, +} + +#[derive(Deserialize, ToSchema)] +pub struct UpdateTenantUserRequest { + #[schema(example = "a2f1bc73-4c90-4bb9-9da9-1c5d04be12ac")] + pub capability_set_id: Uuid, +} + +#[derive(Serialize, ToSchema)] +#[schema(example = json!({ + "user_id": "11111111-2222-3333-4444-555555555555", + "username": "cfo", + "capability_set_id": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", + "capability_set_slug": "owner" +}))] +pub struct TenantUserSummary { + pub user_id: Uuid, + pub username: String, + pub capability_set_id: Option, + pub capability_set_slug: Option, +} + +#[derive(Serialize, ToSchema)] +#[schema(example = json!({ + "users": [ + { + "user_id": "11111111-2222-3333-4444-555555555555", + "username": "alice", + "capability_set_id": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", + "capability_set_slug": "owner" + }, + { + "user_id": "66666666-7777-8888-9999-000000000000", + "username": "bob", + "capability_set_id": "bbbbbbbb-cccc-dddd-eeee-ffffffffffff", + "capability_set_slug": "user" + } + ] +}))] +pub struct TenantUserListResponse { + pub users: Vec, +} + +pub struct TenantApiService<'a> { + state: &'a AppState, +} + +impl<'a> TenantApiService<'a> { + pub fn new(state: &'a AppState) -> Self { + Self { state } + } + + pub fn update_name( + &self, + user: AuthenticatedUser, + tenant_id: Uuid, + payload: UpdateTenantRequest, + ) -> AppResult> { + self.ensure_can_manage(&user, tenant_id)?; + let tenant = self.state.tenants.update_name(tenant_id, &payload.name)?; + ok_json(crate::services::auth::TenantSnippet { + id: tenant.id, + name: tenant.name, + }) + } + + pub fn list_users( + &self, + user: &AuthenticatedUser, + tenant_id: Uuid, + ) -> AppResult> { + self.ensure_can_manage(user, tenant_id)?; + let mut conn = self.state.db_for_tenant(tenant_id)?; + let rows: Vec<(Uuid, String, Option, Option)> = + memberships_dsl::user_memberships + .inner_join(users_dsl::users.on(users_dsl::id.eq(memberships_dsl::user_id))) + .left_join( + cs_dsl::capability_sets + .on(cs_dsl::id.nullable().eq(memberships_dsl::capability_set_id)), + ) + .select(( + users_dsl::id, + users_dsl::username, + memberships_dsl::capability_set_id, + cs_dsl::slug.nullable(), + )) + .order(users_dsl::username.asc()) + .load(&mut conn)?; + + let users = rows + .into_iter() + .map( + |(user_id, username, capability_set_id, capability_set_slug)| TenantUserSummary { + user_id, + username, + capability_set_id, + capability_set_slug, + }, + ) + .collect(); + + ok_json(TenantUserListResponse { users }) + } + + pub fn get_user( + &self, + user: &AuthenticatedUser, + tenant_id: Uuid, + target_user_id: Uuid, + ) -> AppResult> { + self.ensure_can_manage(user, tenant_id)?; + let mut conn = self.state.db_for_tenant(tenant_id)?; + let summary = self.load_membership_summary(&mut conn, tenant_id, target_user_id)?; + ok_json(summary) + } + + pub fn update_user( + &self, + user: &AuthenticatedUser, + tenant_id: Uuid, + target_user_id: Uuid, + payload: UpdateTenantUserRequest, + ) -> AppResult> { + self.ensure_can_manage(user, tenant_id)?; + let mut conn = self.state.db_for_tenant(tenant_id)?; + + let capability_set_id = self.resolve_capability_set_id(&mut conn, tenant_id, &payload)?; + + let updated = diesel::update( + memberships_dsl::user_memberships + .filter(memberships_dsl::tenant_id.eq(tenant_id)) + .filter(memberships_dsl::user_id.eq(target_user_id)), + ) + .set(( + memberships_dsl::capability_set_id.eq(Some(capability_set_id)), + memberships_dsl::updated_at.eq(Utc::now().naive_utc()), + )) + .execute(&mut conn)?; + + if updated == 0 { + return Err(AppError::not_found()); + } + + let summary = self.load_membership_summary(&mut conn, tenant_id, target_user_id)?; + ok_json(summary) + } + + pub fn remove_user( + &self, + user: &AuthenticatedUser, + tenant_id: Uuid, + target_user_id: Uuid, + ) -> AppResult<()> { + self.ensure_can_manage(user, tenant_id)?; + let mut conn = self.state.db_for_tenant(tenant_id)?; + + let removed = diesel::delete( + memberships_dsl::user_memberships + .filter(memberships_dsl::tenant_id.eq(tenant_id)) + .filter(memberships_dsl::user_id.eq(target_user_id)), + ) + .execute(&mut conn)?; + + if removed == 0 { + return Err(AppError::not_found()); + } + + diesel::delete( + session_dsl::user_sessions + .filter(session_dsl::tenant_id.eq(tenant_id)) + .filter(session_dsl::user_id.eq(target_user_id)), + ) + .execute(&mut conn)?; + + Ok(()) + } + + fn ensure_can_manage(&self, user: &AuthenticatedUser, tenant_id: Uuid) -> AppResult<()> { + if user.tenant_id != tenant_id { + return Err(AppError::forbidden("cannot manage another tenant")); + } + + if !user.capabilities.contains(&ApiCapability::TenantsWrite) { + return Err(AppError::forbidden("missing tenants:write capability")); + } + + Ok(()) + } + + fn resolve_capability_set_id( + &self, + conn: &mut PgPooledConnection, + tenant_id: Uuid, + payload: &UpdateTenantUserRequest, + ) -> AppResult { + let exists = cs_dsl::capability_sets + .filter(cs_dsl::tenant_id.eq(tenant_id)) + .filter(cs_dsl::id.eq(payload.capability_set_id)) + .select(cs_dsl::id) + .first::(conn) + .optional()?; + exists.ok_or_else(AppError::not_found) + } + + fn load_membership_summary( + &self, + conn: &mut PgPooledConnection, + tenant_id: Uuid, + user_id: Uuid, + ) -> AppResult { + let row = memberships_dsl::user_memberships + .filter(memberships_dsl::tenant_id.eq(tenant_id)) + .filter(memberships_dsl::user_id.eq(user_id)) + .inner_join(users_dsl::users.on(users_dsl::id.eq(memberships_dsl::user_id))) + .left_join( + cs_dsl::capability_sets + .on(cs_dsl::id.nullable().eq(memberships_dsl::capability_set_id)), + ) + .select(( + users_dsl::id, + users_dsl::username, + memberships_dsl::capability_set_id, + cs_dsl::slug.nullable(), + )) + .first::<(Uuid, String, Option, Option)>(conn) + .optional()?; + + match row { + Some((user_id, username, capability_set_id, capability_set_slug)) => { + Ok(TenantUserSummary { + user_id, + username, + capability_set_id, + capability_set_slug, + }) + } + None => Err(AppError::not_found()), + } + } +} diff --git a/backend/src/state.rs b/backend/src/state.rs new file mode 100644 index 0000000..484fcbe --- /dev/null +++ b/backend/src/state.rs @@ -0,0 +1,107 @@ +use std::sync::Arc; + +use diesel::{ + pg::PgConnection, + r2d2::{ConnectionManager, PooledConnection}, +}; +use uuid::Uuid; + +use crate::{ + auth::{jwt::JwtService, passkeys::PasskeyService}, + config::AppConfig, + db::PgPool, + error::{AppError, AppResult}, + issued_at::IssuedAtSettings, + storage::{ObjectStorage, TenantStorage}, + tenants::{apply_tenant_guc, clear_tenant_context, clear_user_guc, TenantService}, +}; + +pub type PgPooledConnection = PooledConnection>; + +#[derive(Clone)] +pub struct AppState { + pub pool: PgPool, + pub config: Arc, + storage: Arc, + pub jwt: JwtService, + pub tenants: TenantService, + pub passkeys: Option, + issued_at: Arc, +} + +impl AppState { + pub async fn initialize( + config: AppConfig, + pool_size_override: Option, + ) -> anyhow::Result { + let pool_size = pool_size_override.unwrap_or(config.database_max_pool_size); + let pool = crate::db::init_pool_with_size(&config.database_url, pool_size)?; + let bucket = crate::s3::build_bucket(&config)?; + let storage = Arc::new(crate::storage::S3Storage::new(bucket)); + let jwt = crate::auth::jwt::JwtService::from_config(&config)?; + + Ok(Self::new(pool, config, storage, jwt)) + } + + pub fn new( + pool: PgPool, + config: AppConfig, + storage: Arc, + jwt: JwtService, + ) -> Self { + let issued_at = Arc::new(IssuedAtSettings::from_config(&config)); + let config = Arc::new(config); + let tenants = TenantService::new(pool.clone()); + + let passkeys = match PasskeyService::try_new(&config) { + Ok(service) => service, + Err(err) => { + tracing::warn!(error = ?err, "passkey service disabled due to configuration"); + None + } + }; + + Self { + pool, + config, + storage, + jwt, + tenants, + passkeys, + issued_at, + } + } + + pub fn db_for_tenant(&self, tenant_id: Uuid) -> AppResult { + debug_assert!(!tenant_id.is_nil(), "nil tenant_id passed to db_for_tenant"); + let mut conn = self.db_unscoped()?; + let conn_ptr = &*conn as *const _; + tracing::trace!(target = "db_pool", ?conn_ptr, tenant_id = %tenant_id, "apply tenant context"); + apply_tenant_guc(&mut conn, tenant_id)?; + clear_user_guc(&mut conn)?; + Ok(conn) + } + + pub(crate) fn db_unscoped(&self) -> AppResult { + let mut conn = self.pool.get().map_err(|err| { + tracing::error!(error = ?err, "database pool error"); + AppError::internal("database pool error") + })?; + let conn_ptr = &*conn as *const _; + tracing::trace!(target = "db_pool", ?conn_ptr, "acquired connection"); + clear_tenant_context(&mut conn)?; + Ok(conn) + } + + pub fn storage_for_tenant(&self, tenant_id: Uuid) -> AppResult { + let tenant = self.tenants.get_by_id(tenant_id)?; + TenantStorage::new(self.storage.clone(), &tenant).map_err(|err| { + tracing::error!(error = ?err, "tenant storage error"); + AppError::internal("tenant storage error") + }) + } + + pub fn issued_at_settings(&self) -> Arc { + self.issued_at.clone() + } +} diff --git a/backend/src/storage.rs b/backend/src/storage.rs new file mode 100644 index 0000000..c70291b --- /dev/null +++ b/backend/src/storage.rs @@ -0,0 +1,195 @@ +use std::collections::HashMap; +use std::sync::Arc; +use std::time::Duration; + +use anyhow::{anyhow, Context, Result}; +use async_trait::async_trait; +use s3::bucket::Bucket; + +use crate::models::Tenant; + +#[async_trait] +pub trait ObjectStorage: Send + Sync + 'static { + async fn put_object( + &self, + key: &str, + bytes: Vec, + content_type: Option, + content_disposition: Option, + ) -> Result<()>; + + async fn presign_get_object( + &self, + key: &str, + expires_in: Duration, + response_content_disposition: Option<&str>, + ) -> Result; + + async fn get_object(&self, key: &str) -> Result>; + + async fn get_object_range(&self, key: &str, start: u64, end: Option) -> Result>; + + async fn delete_object(&self, key: &str) -> Result<()>; +} + +pub struct S3Storage { + bucket: Bucket, +} + +impl S3Storage { + pub fn new(bucket: Bucket) -> Self { + Self { bucket } + } + + fn default_content_type(content_type: Option) -> String { + content_type.unwrap_or_else(|| "application/octet-stream".to_string()) + } +} + +#[async_trait] +impl ObjectStorage for S3Storage { + async fn put_object( + &self, + key: &str, + bytes: Vec, + content_type: Option, + content_disposition: Option, + ) -> Result<()> { + let mut builder = self + .bucket + .put_object_builder(key, &bytes) + .with_content_type(Self::default_content_type(content_type)); + + if let Some(disposition) = content_disposition { + builder = builder + .with_content_disposition(disposition) + .context("invalid content disposition header")?; + } + + builder + .execute() + .await + .context("failed to upload object to S3")?; + + Ok(()) + } + + async fn presign_get_object( + &self, + key: &str, + expires_in: Duration, + response_content_disposition: Option<&str>, + ) -> Result { + let expiry_secs = + u32::try_from(expires_in.as_secs()).context("presign expiry exceeds u32 range")?; + + let mut queries = HashMap::new(); + if let Some(value) = response_content_disposition { + queries.insert( + "response-content-disposition".to_string(), + value.to_string(), + ); + } + + self.bucket + .presign_get(key, expiry_secs, (!queries.is_empty()).then_some(queries)) + .await + .context("failed to generate presigned download URL") + } + + async fn get_object(&self, key: &str) -> Result> { + let data = self + .bucket + .get_object(key) + .await + .context("failed to download object from S3")?; + Ok(data.into_bytes().to_vec()) + } + + async fn get_object_range(&self, key: &str, start: u64, end: Option) -> Result> { + let data = self + .bucket + .get_object_range(key, start, end) + .await + .context("failed to download ranged object from S3")?; + Ok(data.into_bytes().to_vec()) + } + + async fn delete_object(&self, key: &str) -> Result<()> { + self.bucket + .delete_object(key) + .await + .context("failed to delete object from S3")?; + Ok(()) + } +} +#[derive(Clone)] +pub struct TenantStorage { + inner: Arc, + root: String, +} + +impl TenantStorage { + pub fn new(inner: Arc, tenant: &Tenant) -> Result { + let root = tenant + .storage_root + .as_ref() + .ok_or_else(|| anyhow!("tenant {} missing storage_root", tenant.id))? + .to_owned(); + + Ok(Self { inner, root }) + } + + fn qualify(&self, key: &str) -> String { + format!("{}{}", self.root, key) + } + + pub fn root_prefix(&self) -> &str { + &self.root + } + + pub async fn put_object( + &self, + key: &str, + bytes: Vec, + content_type: Option, + content_disposition: Option, + ) -> Result<()> { + let qualified = self.qualify(key); + self.inner + .put_object(&qualified, bytes, content_type, content_disposition) + .await + } + + pub async fn presign_get_object( + &self, + key: &str, + expires_in: Duration, + response_content_disposition: Option<&str>, + ) -> Result { + let qualified = self.qualify(key); + self.inner + .presign_get_object(&qualified, expires_in, response_content_disposition) + .await + } + + pub async fn get_object(&self, key: &str) -> Result> { + let qualified = self.qualify(key); + self.inner.get_object(&qualified).await + } + + pub async fn get_object_range( + &self, + key: &str, + start: u64, + end: Option, + ) -> Result> { + let qualified = self.qualify(key); + self.inner.get_object_range(&qualified, start, end).await + } + + pub async fn delete_object(&self, key: &str) -> Result<()> { + let qualified = self.qualify(key); + self.inner.delete_object(&qualified).await + } +} diff --git a/backend/src/tenants.rs b/backend/src/tenants.rs new file mode 100644 index 0000000..8e8cf69 --- /dev/null +++ b/backend/src/tenants.rs @@ -0,0 +1,245 @@ +use diesel::{pg::PgConnection, prelude::*, sql_types::Text}; +use serde_json::json; +use uuid::Uuid; + +use crate::{ + db::PgPool, + error::{AppError, AppResult}, + jobs::{enqueue_job, JOB_PROVISION_TENANT}, + models::{Tenant, TenantStatus}, + schema::tenants::dsl, + utils::text::normalize_identifier, +}; + +pub struct TenantRepository; + +impl TenantRepository { + pub fn get_by_id(conn: &mut PgConnection, tenant_id: Uuid) -> AppResult { + dsl::tenants.find(tenant_id).first(conn).map_err(Into::into) + } + + pub fn get_by_name(conn: &mut PgConnection, name: &str) -> AppResult { + dsl::tenants + .filter(dsl::name.eq(name)) + .first(conn) + .map_err(Into::into) + } + + pub fn update_name(conn: &mut PgConnection, tenant_id: Uuid, name: &str) -> AppResult { + diesel::update(dsl::tenants.find(tenant_id)) + .set(dsl::name.eq(name)) + .execute(conn)?; + Self::get_by_id(conn, tenant_id) + } +} + +#[derive(Clone)] +pub struct TenantService { + pool: PgPool, +} + +impl TenantService { + pub fn new(pool: PgPool) -> Self { + Self { pool } + } + + pub fn get_by_id(&self, tenant_id: Uuid) -> AppResult { + let tenant = self.load(|conn| TenantRepository::get_by_id(conn, tenant_id))?; + Ok(tenant) + } + + pub fn get_by_name(&self, name: &str) -> AppResult { + let name_owned = name.to_owned(); + let tenant = self.load(|conn| TenantRepository::get_by_name(conn, &name_owned))?; + Ok(tenant) + } + + pub fn create_tenant( + &self, + name: &str, + storage_root: Option<&str>, + quickwit_index: Option<&str>, + status: TenantStatus, + initial_members: &[Uuid], + created_by: Option, + ) -> AppResult { + let mut conn = self.pool.get().map_err(|err| { + tracing::error!(error = ?err, "database pool error"); + AppError::internal("database pool error") + })?; + self.create_tenant_with_conn( + &mut conn, + name, + storage_root, + quickwit_index, + status, + initial_members, + created_by, + ) + } + + fn load(&self, loader: F) -> AppResult + where + F: FnOnce(&mut PgConnection) -> AppResult, + { + let mut conn = self.pool.get().map_err(|err| { + tracing::error!(error = ?err, "database pool error"); + AppError::internal("database pool error") + })?; + let tenant = loader(&mut conn)?; + Ok(tenant) + } + pub fn create_tenant_with_conn( + &self, + conn: &mut PgConnection, + name: &str, + storage_root: Option<&str>, + quickwit_index: Option<&str>, + status: TenantStatus, + initial_members: &[Uuid], + created_by: Option, + ) -> AppResult { + let name = name.trim(); + if name.is_empty() { + return Err(AppError::bad_request("tenant name must not be empty")); + } + + let name = normalize_tenant_name(name)?; + + let id = Uuid::new_v4(); + let storage_root = normalize_storage_root(storage_root, id); + let quickwit_index = normalize_quickwit_index(quickwit_index, id); + + diesel::insert_into(dsl::tenants) + .values(( + dsl::id.eq(id), + dsl::name.eq(&name), + dsl::storage_root.eq(Some(storage_root.clone())), + dsl::quickwit_index.eq(Some(quickwit_index.clone())), + dsl::config.eq(json!({})), + dsl::status.eq(status), + dsl::created_by.eq(created_by), + )) + .execute(conn)?; + + if status == TenantStatus::Creating { + let payload = json!({ + "members": initial_members, + }); + + enqueue_job(conn, id, JOB_PROVISION_TENANT, payload, None).map_err(|err| { + tracing::error!(error = ?err, tenant_id = %id, "failed to enqueue tenant provisioning job"); + AppError::internal("failed to enqueue tenant provisioning job") + })?; + } + + TenantRepository::get_by_id(conn, id) + } + + pub fn update_name(&self, tenant_id: Uuid, name: &str) -> AppResult { + let mut conn = self.pool.get().map_err(|err| { + tracing::error!(error = ?err, "database pool error"); + AppError::internal("database pool error") + })?; + + let normalized = normalize_tenant_name(name)?; + TenantRepository::update_name(&mut conn, tenant_id, &normalized) + } +} + +pub fn apply_tenant_guc(conn: &mut PgConnection, tenant_id: Uuid) -> AppResult<()> { + diesel::sql_query("SELECT set_config('papercrate.tenant_id', $1, false)") + .bind::(tenant_id.to_string()) + .execute(conn) + .map(|_| ()) + .map_err(AppError::from) +} + +pub fn apply_user_guc(conn: &mut PgConnection, user_id: Uuid) -> AppResult<()> { + diesel::sql_query("SELECT set_config('papercrate.user_id', $1, false)") + .bind::(user_id.to_string()) + .execute(conn) + .map(|_| ()) + .map_err(AppError::from) +} + +pub fn clear_tenant_context(conn: &mut PgConnection) -> AppResult<()> { + diesel::sql_query( + "SELECT \ + set_config('papercrate.tenant_id', '', false), \ + set_config('papercrate.user_id', '', false), \ + set_config('papercrate.user_session_hash', '', false), \ + set_config('papercrate.api_token_prefix', '', false)", + ) + .execute(conn) + .map(|_| ()) + .map_err(AppError::from) +} + +pub fn clear_user_guc(conn: &mut PgConnection) -> AppResult<()> { + diesel::sql_query("SELECT set_config('papercrate.user_id', '', false)") + .execute(conn) + .map(|_| ()) + .map_err(AppError::from) +} + +pub fn apply_user_session_hash(conn: &mut PgConnection, hash: &str) -> AppResult<()> { + diesel::sql_query("SELECT set_config('papercrate.user_session_hash', $1, false)") + .bind::(hash) + .execute(conn) + .map(|_| ()) + .map_err(AppError::from) +} + +pub fn clear_user_session_hash(conn: &mut PgConnection) -> AppResult<()> { + diesel::sql_query("SELECT set_config('papercrate.user_session_hash', '', false)") + .execute(conn) + .map(|_| ()) + .map_err(AppError::from) +} + +pub fn apply_api_token_prefix(conn: &mut PgConnection, prefix: &str) -> AppResult<()> { + diesel::sql_query("SELECT set_config('papercrate.api_token_prefix', $1, false)") + .bind::(prefix) + .execute(conn) + .map(|_| ()) + .map_err(AppError::from) +} + +fn normalize_tenant_name(value: &str) -> AppResult { + normalize_identifier( + value, + 255, + "tenant name must not be empty", + "tenant name must not exceed 255 characters", + Some("tenant name may only contain printable characters"), + |ch| !ch.is_control(), + ) +} + +pub fn clear_api_token_prefix(conn: &mut PgConnection) -> AppResult<()> { + diesel::sql_query("SELECT set_config('papercrate.api_token_prefix', '', false)") + .execute(conn) + .map(|_| ()) + .map_err(AppError::from) +} + +fn normalize_storage_root(raw: Option<&str>, tenant_id: Uuid) -> String { + match raw.map(str::trim) { + Some(root) if !root.is_empty() => { + let mut owned = root.to_owned(); + if !owned.ends_with('/') { + owned.push('/'); + } + owned + } + _ => format!("tenants/{tenant_id}/"), + } +} + +fn normalize_quickwit_index(raw: Option<&str>, tenant_id: Uuid) -> String { + match raw.map(str::trim) { + Some(value) if !value.is_empty() => value.to_owned(), + _ => format!("documents-{tenant_id}"), + } +} diff --git a/backend/src/test_support/mod.rs b/backend/src/test_support/mod.rs new file mode 100644 index 0000000..50b8f38 --- /dev/null +++ b/backend/src/test_support/mod.rs @@ -0,0 +1,935 @@ +use std::collections::HashMap; +use std::env; +use std::sync::Arc; +use std::time::Duration; + +use crate::auth::capability_sets::{ + ensure_capability_set, owner_capabilities, readonly_capabilities, user_capabilities, + webdav_capabilities, +}; +use crate::auth::jwt::{AccessTokenContext, JwtService, PrincipalKind}; +use crate::config::AppConfig; +use crate::db::{self, PgPool}; +use crate::migrations::MIGRATIONS; +use crate::models::{ + Job, NewUser, NewUserMembership, NewUserPasskey, NewUserSession, Tenant, TenantStatus, User, + UserMembership, +}; +use crate::routes; +use crate::schema::user_sessions::dsl as session_dsl; +use crate::state::AppState; +use crate::storage::ObjectStorage; +use anyhow::{anyhow, ensure, Context, Result}; +use async_trait::async_trait; +use axum::body::Body; +use axum::http::{header, Method, Request}; +use axum::Router; +use chrono::{Duration as ChronoDuration, Utc}; +use diesel::connection::SimpleConnection; +use diesel::prelude::*; +use diesel::OptionalExtension; +use diesel::PgConnection; +use diesel_migrations::MigrationHarness; +use http_body_util::BodyExt; +use once_cell::sync::Lazy; +use rand::{rngs::OsRng, TryRngCore}; +use serde::Serialize; +use serde_json::{self, json}; +use sha2::{Digest, Sha256}; +use tokio::sync::Mutex; +use tower::util::ServiceExt; +use uuid::Uuid; + +const RESET_DATABASE_SQL: &str = "DROP SCHEMA IF EXISTS tenant CASCADE;\n\ + DROP SCHEMA IF EXISTS shared CASCADE;\n\ + DROP SCHEMA IF EXISTS public CASCADE;\n\ + CREATE SCHEMA public;\n\ + GRANT ALL ON SCHEMA public TO public;"; + +static DB_LOCK: Lazy> = Lazy::new(|| Mutex::new(())); + +const TEST_TENANT_NAME: &str = "test_tenant"; + +#[derive(Clone, Copy, Debug)] +pub enum TestUserRole { + Owner, + Member, + WebDav, +} + +#[derive(Clone)] +pub struct StoredObject { + pub key: String, + pub bytes: Vec, + pub content_type: Option, + pub content_disposition: Option, +} + +#[derive(Default)] +pub struct FakeStorage { + objects: Mutex>, +} + +#[async_trait] +impl ObjectStorage for FakeStorage { + async fn put_object( + &self, + key: &str, + bytes: Vec, + content_type: Option, + content_disposition: Option, + ) -> Result<()> { + let stored = StoredObject { + key: key.to_string(), + bytes, + content_type, + content_disposition, + }; + let mut guard = self.objects.lock().await; + guard.insert(stored.key.clone(), stored); + Ok(()) + } + + async fn presign_get_object( + &self, + key: &str, + expires_in: Duration, + _response_content_disposition: Option<&str>, + ) -> Result { + let guard = self.objects.lock().await; + ensure!(guard.contains_key(key), "object {key} missing"); + Ok(format!( + "https://fake-storage/{key}?expires_in={}", + expires_in.as_secs() + )) + } + + async fn get_object(&self, key: &str) -> Result> { + let guard = self.objects.lock().await; + guard + .get(key) + .map(|obj| obj.bytes.clone()) + .ok_or_else(|| anyhow!("object {key} missing")) + } + + async fn get_object_range( + &self, + key: &str, + start: u64, + end: Option, + ) -> Result> { + let guard = self.objects.lock().await; + let bytes = guard + .get(key) + .map(|obj| obj.bytes.clone()) + .ok_or_else(|| anyhow!("object {key} missing"))?; + + let start_idx = start as usize; + let end_idx = end.map(|idx| idx.saturating_add(1) as usize).unwrap_or(bytes.len()); + if start_idx >= bytes.len() { + return Ok(Vec::new()); + } + Ok(bytes[start_idx..end_idx.min(bytes.len())].to_vec()) + } + + async fn delete_object(&self, key: &str) -> Result<()> { + let mut guard = self.objects.lock().await; + guard.remove(key); + Ok(()) + } +} + +impl FakeStorage { + pub async fn get(&self, key: &str) -> Option { + let guard = self.objects.lock().await; + guard.get(key).cloned() + } + + pub async fn object_count(&self) -> usize { + let guard = self.objects.lock().await; + guard.len() + } + + pub async fn object_count_with_prefix(&self, prefix: &str) -> usize { + let guard = self.objects.lock().await; + guard.keys().filter(|key| key.starts_with(prefix)).count() + } + + pub async fn contains_key(&self, key: &str) -> bool { + let guard = self.objects.lock().await; + guard.contains_key(key) + } + + pub async fn keys_with_prefix(&self, prefix: &str) -> Vec { + let guard = self.objects.lock().await; + guard + .keys() + .filter(|key| key.starts_with(prefix)) + .cloned() + .collect() + } +} + +pub struct TestApp { + pub state: AppState, + router: Router, + storage: Arc, +} + +impl TestApp { + pub async fn new() -> Result { + Self::with_config(|_| {}).await + } + + pub async fn with_config(configure: F) -> Result + where + F: FnOnce(&mut AppConfig), + { + let database_url = env::var("TEST_DATABASE_URL") + .context("TEST_DATABASE_URL must be set for integration tests")?; + + let mut config = AppConfig { + database_url: database_url.clone(), + migrations_database_url: None, + database_max_pool_size: db::DEFAULT_MAX_POOL_SIZE, + server_host: "127.0.0.1".to_string(), + server_port: 0, + webdav_host: "127.0.0.1".to_string(), + webdav_port: 0, + jwt_secret: "test-secret".to_string(), + jwt_issuer: "test-issuer".to_string(), + jwt_audience: "test-audience".to_string(), + jwt_expiry_minutes: 60, + download_token_audience: "test-download".to_string(), + download_token_expiry_minutes: 60, + refresh_token_expiry_days: 30, + refresh_cookie_secure: false, + refresh_cookie_domain: None, + cors_allowed_origin: None, + proxy_downloads: false, + aws_endpoint_url: None, + aws_access_key_id: None, + aws_secret_access_key: None, + aws_region: "us-east-1".to_string(), + s3_bucket: "test-bucket".to_string(), + quickwit_endpoint: None, + quickwit_index: None, + worker_max_document_bytes: 200 * 1024 * 1024, + upload_body_limit_bytes: 128 * 1024 * 1024, + service_timezone: "UTC".to_string(), + issued_at_date_order: "DMY".to_string(), + issued_at_filename_date_order: None, + issued_at_date_parser_locales: Vec::new(), + issued_at_ignore_dates: Vec::new(), + webauthn_rp_id: Some("localhost".to_string()), + webauthn_origin: Some("http://localhost".to_string()), + webauthn_rp_name: "Papercrate".to_string(), + }; + + configure(&mut config); + + let pool = db::init_pool_with_size(&config.database_url, config.database_max_pool_size)?; + prepare_database(&pool).await?; + + let storage = Arc::new(FakeStorage::default()); + let storage_for_state: Arc = storage.clone(); + let jwt = JwtService::from_config(&config)?; + let state = AppState::new(pool.clone(), config, storage_for_state, jwt); + let router = routes::create_router(state.clone()); + + let app = Self { + state, + router, + storage, + }; + + app.ensure_default_tenant().await?; + + Ok(app) + } + + pub async fn cleanup(&self) -> Result<()> { + let pool = self.state.pool.clone(); + let _ = tokio::task::spawn_blocking(move || -> Result<()> { + let mut conn = pool + .get() + .map_err(|err| anyhow!("failed to get cleanup connection: {err}"))?; + truncate_all(&mut conn)?; + Ok(()) + }) + .await + .context("cleanup task panicked")?; + + self.ensure_default_tenant().await?; + Ok(()) + } + + pub async fn tenant_id(&self) -> Result { + self.ensure_default_tenant().await + } + + pub fn storage(&self) -> Arc { + self.storage.clone() + } + + pub async fn storage_key_for(&self, key: &str) -> Result { + self.ensure_default_tenant().await?; + let tenant = self + .state + .tenants + .get_by_name(TEST_TENANT_NAME) + .map_err(|err| anyhow!("default tenant not found: {:?}", err))?; + let root = tenant + .storage_root + .clone() + .ok_or_else(|| anyhow!("default tenant missing storage root"))?; + Ok(format!("{}{}", root, key)) + } + + pub async fn insert_user(&self, username: &str, role: TestUserRole) -> Result { + let username = username.to_string(); + let tenant_id = self.ensure_default_tenant().await?; + let user_id = self + .with_conn(move |conn| { + let user = NewUser { + id: Uuid::new_v4(), + username, + }; + diesel::insert_into(crate::schema::users::table) + .values(&user) + .execute(conn) + .context("failed to insert user")?; + + let capabilities = match role { + TestUserRole::Owner => owner_capabilities(), + TestUserRole::Member => user_capabilities(), + TestUserRole::WebDav => webdav_capabilities(), + }; + + let capability_set = ensure_capability_set(conn, tenant_id, capabilities) + .map_err(|err| anyhow!("failed to ensure capability set: {:?}", err))?; + + let membership = NewUserMembership { + id: Uuid::new_v4(), + user_id: user.id, + tenant_id, + capability_set_id: Some(capability_set.id), + }; + + diesel::insert_into(crate::schema::user_memberships::table) + .values(&membership) + .execute(conn) + .context("failed to insert user membership")?; + Ok(user.id) + }) + .await?; + + Ok(user_id) + } + + pub async fn insert_passkey(&self, user_id: Uuid, nickname: Option<&str>) -> Result { + let passkey_id = Uuid::new_v4(); + let nickname = nickname.map(|value| value.to_string()); + self.with_conn(move |conn| { + let credential_id = passkey_id.as_bytes().to_vec(); + let public_key = passkey_id.as_bytes().iter().copied().collect::>(); + let passkey = NewUserPasskey { + id: passkey_id, + user_id, + credential_id, + public_key, + credential: json!({ "dummy": passkey_id.to_string() }), + sign_count: 0, + transports: vec![Some("usb".to_string())], + aaguid: None, + nickname, + }; + + diesel::insert_into(crate::schema::user_passkeys::table) + .values(&passkey) + .execute(conn) + .context("failed to insert passkey")?; + + Ok(passkey_id) + }) + .await + } + + async fn ensure_default_tenant(&self) -> Result { + let name_value = TEST_TENANT_NAME.to_string(); + let quickwit_enabled = self.state.config.quickwit_endpoint.is_some(); + let tenant_id = self + .with_conn(move |conn| { + use crate::schema::tenants::dsl as tenants_dsl; + + let existing = tenants_dsl::tenants + .filter(tenants_dsl::name.eq(&name_value)) + .first::(conn) + .optional() + .context("failed to load default tenant")?; + + let tenant_id = if let Some(current) = existing { + let desired_root = current + .storage_root + .clone() + .filter(|root| root.ends_with('/')) + .unwrap_or_else(|| format!("test-tenants/{}/", current.id)); + + if current.storage_root.as_deref() != Some(desired_root.as_str()) { + diesel::update(tenants_dsl::tenants.filter(tenants_dsl::id.eq(current.id))) + .set(tenants_dsl::storage_root.eq(Some(desired_root))) + .execute(conn) + .context("failed to update default tenant storage root")?; + } + + current.id + } else { + let new_id = Uuid::new_v4(); + let root = format!("test-tenants/{}/", new_id); + let quickwit_value = if quickwit_enabled { + Some(format!("documents-{}", new_id)) + } else { + None + }; + + diesel::insert_into(tenants_dsl::tenants) + .values(( + tenants_dsl::id.eq(new_id), + tenants_dsl::name.eq(&name_value), + tenants_dsl::storage_root.eq(Some(root)), + tenants_dsl::quickwit_index.eq(quickwit_value), + tenants_dsl::status.eq(TenantStatus::Active), + )) + .execute(conn) + .context("failed to insert default tenant")?; + + new_id + }; + + Ok(tenant_id) + }) + .await?; + + let mut conn = self + .state + .db_for_tenant(tenant_id) + .map_err(|err| anyhow!("failed to scope tenant connection: {err:?}"))?; + + ensure_capability_set(&mut conn, tenant_id, owner_capabilities()) + .map_err(|err| anyhow!("ensure owner capability set: {err:?}"))?; + ensure_capability_set(&mut conn, tenant_id, user_capabilities()) + .map_err(|err| anyhow!("ensure user capability set: {err:?}"))?; + ensure_capability_set(&mut conn, tenant_id, readonly_capabilities()) + .map_err(|err| anyhow!("ensure readonly capability set: {err:?}"))?; + ensure_capability_set(&mut conn, tenant_id, webdav_capabilities()) + .map_err(|err| anyhow!("ensure webdav capability set: {err:?}"))?; + + Ok(tenant_id) + } + + pub async fn login_token(&self, username: &str, _password: &str) -> Result { + let (access_token, _, _) = self.create_session(username).await?; + Ok(access_token) + } + + pub async fn create_session(&self, username: &str) -> Result<(String, String, Uuid)> { + let username = username.to_string(); + let state = self.state.clone(); + self.with_conn(move |conn| { + use crate::schema::capability_sets::dsl as capability_sets_dsl; + use crate::schema::tenants::dsl as tenants_dsl; + use crate::schema::user_memberships::dsl as memberships_dsl; + use crate::schema::users::dsl as users_dsl; + + let user: User = users_dsl::users + .filter(users_dsl::username.eq(&username)) + .first(conn)?; + + let membership: UserMembership = memberships_dsl::user_memberships + .filter(memberships_dsl::user_id.eq(user.id)) + .first(conn)?; + + let tenant: Tenant = tenants_dsl::tenants + .find(membership.tenant_id) + .first(conn)?; + + let capability_set_id = membership + .capability_set_id + .ok_or_else(|| anyhow!("membership missing capability set"))?; + + let cap_version = capability_sets_dsl::capability_sets + .find(capability_set_id) + .select(capability_sets_dsl::cap_version) + .first::(conn)?; + + let now = Utc::now(); + let session_id = Uuid::new_v4(); + let access_token = state + .jwt + .generate_token(AccessTokenContext { + user_id: user.id, + tenant_id: tenant.id, + username: user.username.clone(), + principal_kind: PrincipalKind::UserSession, + principal_id: session_id, + capability_set_id, + cap_version, + }) + .map_err(|err| anyhow!(err))?; + + let session_value = generate_session_token(); + let session_hash = hash_session_token(&session_value); + let refresh_expires_at = + now + ChronoDuration::days(state.config.refresh_token_expiry_days); + + let new_session = NewUserSession { + id: session_id, + user_id: user.id, + token_hash: session_hash, + issued_at: now.naive_utc(), + expires_at: refresh_expires_at.naive_utc(), + tenant_id: tenant.id, + }; + + diesel::insert_into(session_dsl::user_sessions) + .values(&new_session) + .execute(conn)?; + + let cookie = format!("refresh_token={session_value}"); + Ok((access_token, cookie, tenant.id)) + }) + .await + } + + pub async fn clear_jobs(&self) -> Result<()> { + self.with_conn(|conn| { + use crate::schema::jobs::dsl::jobs as jobs_table; + diesel::delete(jobs_table) + .execute(conn) + .context("failed to clear jobs")?; + Ok(()) + }) + .await + } + + pub async fn jobs_by_type(&self, ty: &str) -> Result> { + let ty = ty.to_string(); + self.with_conn(move |conn| { + use crate::schema::jobs::dsl::{job_type as job_type_col, jobs as jobs_table}; + let rows = jobs_table + .filter(job_type_col.eq(&ty)) + .load::(conn) + .context("failed to load jobs")?; + Ok(rows) + }) + .await + } + + pub async fn post_json( + &self, + path: &str, + payload: &T, + token: Option<&str>, + ) -> Result> { + self.post_json_with_cookie(path, payload, token, None).await + } + + pub async fn post_json_with_cookie( + &self, + path: &str, + payload: &T, + token: Option<&str>, + cookie: Option<&str>, + ) -> Result> { + let body = serde_json::to_vec(payload)?; + let mut builder = Request::builder() + .method(Method::POST) + .uri(path) + .header("content-type", "application/json"); + if let Some(token) = token { + builder = builder.header("authorization", format!("Bearer {token}")); + } + if let Some(cookie) = cookie { + builder = builder.header(header::COOKIE, cookie); + } + let request = builder.body(Body::from(body))?; + Ok(self + .router + .clone() + .oneshot(request) + .await + .expect("infallible response")) + } + + pub async fn patch_json( + &self, + path: &str, + payload: &T, + token: Option<&str>, + ) -> Result> { + let body = serde_json::to_vec(payload)?; + let mut builder = Request::builder() + .method(Method::PATCH) + .uri(path) + .header("content-type", "application/json"); + if let Some(token) = token { + builder = builder.header("authorization", format!("Bearer {token}")); + } + let request = builder.body(Body::from(body))?; + Ok(self + .router + .clone() + .oneshot(request) + .await + .expect("infallible response")) + } + + pub async fn get(&self, path: &str, token: Option<&str>) -> Result> { + let mut builder = Request::builder().method(Method::GET).uri(path); + if let Some(token) = token { + builder = builder.header("authorization", format!("Bearer {token}")); + } + let request = builder.body(Body::empty())?; + Ok(self + .router + .clone() + .oneshot(request) + .await + .expect("infallible response")) + } + + pub async fn delete(&self, path: &str, token: Option<&str>) -> Result> { + let builder = Request::builder().method(Method::DELETE).uri(path); + let builder = if let Some(token) = token { + builder.header("authorization", format!("Bearer {token}")) + } else { + builder + }; + let request = builder.body(Body::empty())?; + Ok(self + .router + .clone() + .oneshot(request) + .await + .expect("infallible response")) + } + + pub async fn upload_document( + &self, + path: &str, + filename: &str, + content_type: &str, + data: &[u8], + folder_id: Option, + token: &str, + ) -> Result> { + let extras = UploadExtras::empty(); + self.upload_document_with_extras( + path, + filename, + content_type, + data, + folder_id, + extras, + token, + ) + .await + } + + pub async fn upload_document_with_options( + &self, + path: &str, + filename: &str, + content_type: &str, + data: &[u8], + folder_id: Option, + title: Option<&str>, + metadata_json: Option<&str>, + token: &str, + ) -> Result> { + let extras = UploadExtras { + title, + metadata_json, + tag_ids_json: None, + correspondents_json: None, + issued_at: None, + skip_existing: None, + }; + self.upload_document_with_extras( + path, + filename, + content_type, + data, + folder_id, + extras, + token, + ) + .await + } + + pub async fn upload_document_with_extras( + &self, + path: &str, + filename: &str, + content_type: &str, + data: &[u8], + folder_id: Option, + extras: UploadExtras<'_>, + token: &str, + ) -> Result> { + let boundary = format!("boundary-{}", Uuid::new_v4()); + let mut body = Vec::new(); + body.extend(format!("--{boundary}\r\n").as_bytes()); + body.extend( + format!( + "Content-Disposition: form-data; name=\"file\"; filename=\"{}\"\r\n", + filename + ) + .as_bytes(), + ); + body.extend(format!("Content-Type: {}\r\n\r\n", content_type).as_bytes()); + body.extend(data); + body.extend(b"\r\n"); + + if let Some(folder) = folder_id { + body.extend(format!("--{boundary}\r\n").as_bytes()); + body.extend(b"Content-Disposition: form-data; name=\"folder_id\"\r\n\r\n"); + body.extend(folder.to_string().as_bytes()); + body.extend(b"\r\n"); + } + + if let Some(title_value) = extras.title { + body.extend(format!("--{boundary}\r\n").as_bytes()); + body.extend(b"Content-Disposition: form-data; name=\"title\"\r\n\r\n"); + body.extend(title_value.as_bytes()); + body.extend(b"\r\n"); + } + + if let Some(metadata_value) = extras.metadata_json { + body.extend(format!("--{boundary}\r\n").as_bytes()); + body.extend(b"Content-Disposition: form-data; name=\"metadata\"\r\n\r\n"); + body.extend(metadata_value.as_bytes()); + body.extend(b"\r\n"); + } + + if let Some(tag_ids_value) = extras.tag_ids_json { + body.extend(format!("--{boundary}\r\n").as_bytes()); + body.extend(b"Content-Disposition: form-data; name=\"tag_ids\"\r\n\r\n"); + body.extend(tag_ids_value.as_bytes()); + body.extend(b"\r\n"); + } + + if let Some(correspondents_value) = extras.correspondents_json { + body.extend(format!("--{boundary}\r\n").as_bytes()); + body.extend(b"Content-Disposition: form-data; name=\"correspondents\"\r\n\r\n"); + body.extend(correspondents_value.as_bytes()); + body.extend(b"\r\n"); + } + + if let Some(issued_at_value) = extras.issued_at { + body.extend(format!("--{boundary}\r\n").as_bytes()); + body.extend(b"Content-Disposition: form-data; name=\"issued_at\"\r\n\r\n"); + body.extend(issued_at_value.as_bytes()); + body.extend(b"\r\n"); + } + + if let Some(skip_flag) = extras.skip_existing { + body.extend(format!("--{boundary}\r\n").as_bytes()); + body.extend(b"Content-Disposition: form-data; name=\"skip_existing\"\r\n\r\n"); + body.extend(if skip_flag { + b"true".as_ref() + } else { + b"false".as_ref() + }); + body.extend(b"\r\n"); + } + + body.extend(format!("--{boundary}--\r\n").as_bytes()); + + let builder = Request::builder() + .method(Method::POST) + .uri(path) + .header( + "content-type", + format!("multipart/form-data; boundary={boundary}"), + ) + .header("authorization", format!("Bearer {token}")); + + let request = builder.body(Body::from(body))?; + Ok(self + .router + .clone() + .oneshot(request) + .await + .expect("infallible response")) + } + + pub async fn with_conn(&self, f: F) -> Result + where + F: FnOnce(&mut PgConnection) -> Result + Send + 'static, + T: Send + 'static, + { + let pool = self.state.pool.clone(); + tokio::task::spawn_blocking(move || { + let mut conn = pool + .get() + .map_err(|err| anyhow!("failed to get database connection: {err}"))?; + f(&mut conn) + }) + .await + .context("connection task panicked")? + } +} + +pub struct UploadExtras<'a> { + pub title: Option<&'a str>, + pub metadata_json: Option<&'a str>, + pub tag_ids_json: Option<&'a str>, + pub correspondents_json: Option<&'a str>, + pub issued_at: Option<&'a str>, + pub skip_existing: Option, +} + +impl<'a> UploadExtras<'a> { + pub fn empty() -> Self { + Self { + title: None, + metadata_json: None, + tag_ids_json: None, + correspondents_json: None, + issued_at: None, + skip_existing: None, + } + } +} + +pub async fn acquire_db_lock() -> tokio::sync::MutexGuard<'static, ()> { + DB_LOCK.lock().await +} + +pub async fn body_to_vec(body: Body) -> Result> { + let collected = body + .collect() + .await + .map_err(|err| anyhow!("failed to read response body: {err}"))?; + Ok(collected.to_bytes().to_vec()) +} + +#[cfg(test)] +mod helper_tests { + use super::*; + + #[tokio::test] + async fn create_session_and_login_token_provide_access() -> Result<()> { + let _lock = acquire_db_lock().await; + let app = TestApp::new().await?; + + let username = "helper-login"; + let password = "irrelevant"; + app.insert_user(username, TestUserRole::Owner).await?; + + let (access, refresh, refresh_id) = app.create_session(username).await?; + assert!(!access.is_empty(), "access token should not be empty"); + assert!(!refresh.is_empty(), "refresh token should not be empty"); + assert_ne!( + refresh_id, + Uuid::nil(), + "refresh token id should be assigned" + ); + + let bearer = app.login_token(username, password).await?; + assert!(!bearer.is_empty(), "login_token must yield bearer"); + + app.cleanup().await?; + Ok(()) + } + + #[tokio::test] + async fn insert_passkey_and_upload_with_options_succeeds() -> Result<()> { + let _lock = acquire_db_lock().await; + let app = TestApp::new().await?; + let username = "helper-passkey"; + let password = "unused"; + let user_id = app.insert_user(username, TestUserRole::Owner).await?; + + let passkey_id = app.insert_passkey(user_id, Some("Laptop")).await?; + assert_ne!(passkey_id, Uuid::nil()); + + let bearer = app.login_token(username, password).await?; + let response = app + .upload_document_with_options( + "/api/documents", + "helper.txt", + "text/plain", + b"helper-content", + None, + Some("Helper Note"), + Some("{\"category\":\"note\"}"), + &bearer, + ) + .await?; + assert!(response.status().is_success()); + + app.cleanup().await?; + Ok(()) + } +} + +async fn prepare_database(pool: &PgPool) -> Result<()> { + let pool = pool.clone(); + tokio::task::spawn_blocking(move || -> Result<()> { + let mut conn = pool + .get() + .map_err(|err| anyhow!("failed to acquire connection: {err}"))?; + conn.batch_execute(RESET_DATABASE_SQL) + .map_err(|err| anyhow!("failed to reset schema: {err}"))?; + conn.batch_execute("DROP TABLE IF EXISTS __diesel_schema_migrations;") + .map_err(|err| anyhow!("failed to drop diesel schema table: {err}"))?; + conn.run_pending_migrations(MIGRATIONS) + .map_err(|err| anyhow!("failed to run migrations: {err}"))?; + truncate_all(&mut conn)?; + Ok(()) + }) + .await + .context("migration task panicked")? +} + +fn truncate_all(conn: &mut PgConnection) -> Result<()> { + conn.batch_execute( + "TRUNCATE TABLE \ + tenant.document_assets, \ + tenant.document_correspondents, \ + tenant.correspondents, \ + tenant.document_tags, \ + tenant.document_versions, \ + tenant.documents, \ + tenant.folders, \ + shared.jobs, \ + tenant.user_sessions, \ + tenant.tags, \ + tenant.api_tokens, \ + shared.webauthn_challenges, \ + shared.user_passkeys, \ + tenant.user_memberships, \ + shared.users, \ + shared.magic_tokens, \ + shared.tenants \ + RESTART IDENTITY CASCADE;", + ) + .context("failed to truncate tables")?; + Ok(()) +} + +fn generate_session_token() -> String { + let mut bytes = [0u8; 32]; + OsRng + .try_fill_bytes(&mut bytes) + .expect("failed to read random bytes"); + hex::encode(bytes) +} + +fn hash_session_token(value: &str) -> String { + let mut hasher = Sha256::new(); + hasher.update(value.as_bytes()); + hex::encode(hasher.finalize()) +} diff --git a/backend/src/utils/bootstrap.rs b/backend/src/utils/bootstrap.rs new file mode 100644 index 0000000..2549572 --- /dev/null +++ b/backend/src/utils/bootstrap.rs @@ -0,0 +1,14 @@ +use std::sync::Arc; + +use anyhow::Result; + +use crate::{config::AppConfig, state::AppState, utils::tracing::init_tracing}; + +/// Initialize tracing, load configuration, and build the shared `AppState`. +/// Optionally override the connection pool size for lightweight components. +pub async fn init_component(name: &str, pool_override: Option) -> Result> { + init_tracing("info"); + let config = AppConfig::load_and_log(name)?; + let state = AppState::initialize(config, pool_override).await?; + Ok(Arc::new(state)) +} diff --git a/backend/src/utils/db.rs b/backend/src/utils/db.rs new file mode 100644 index 0000000..2c8ba96 --- /dev/null +++ b/backend/src/utils/db.rs @@ -0,0 +1,26 @@ +use diesel::pg::PgConnection; +use uuid::Uuid; + +use crate::{ + error::{AppError, AppResult}, + state::AppState, +}; + +impl AppState { + pub fn with_tenant_conn(&self, tenant_id: Uuid, f: F) -> AppResult + where + F: FnOnce(&mut PgConnection) -> AppResult, + { + let mut conn = self.db_for_tenant(tenant_id)?; + f(&mut conn) + } +} + +pub fn validate_bulk_ids(ids: &mut Vec, label: &str) -> AppResult<()> { + if ids.is_empty() { + return Err(AppError::bad_request(format!("{label} must not be empty"))); + } + ids.sort_unstable(); + ids.dedup(); + Ok(()) +} diff --git a/backend/src/utils/error.rs b/backend/src/utils/error.rs new file mode 100644 index 0000000..6aeca61 --- /dev/null +++ b/backend/src/utils/error.rs @@ -0,0 +1,32 @@ +use diesel::result::Error as DieselError; + +use crate::error::{AppError, AppResult}; + +pub trait DbResultExt { + fn db_context(self, context: &'static str) -> AppResult; +} + +impl DbResultExt for Result { + fn db_context(self, context: &'static str) -> AppResult { + self.map_err(|err| match err { + DieselError::NotFound => AppError::not_found(), + other => { + tracing::error!(error = ?other, "{context}"); + AppError::internal(context) + } + }) + } +} + +pub trait StorageResultExt { + fn storage_context(self, context: &'static str) -> AppResult; +} + +impl StorageResultExt for Result { + fn storage_context(self, context: &'static str) -> AppResult { + self.map_err(|err| { + tracing::error!(error = ?err, "{context}"); + AppError::internal(context) + }) + } +} diff --git a/backend/src/utils/http.rs b/backend/src/utils/http.rs new file mode 100644 index 0000000..119d633 --- /dev/null +++ b/backend/src/utils/http.rs @@ -0,0 +1,53 @@ +use percent_encoding::{utf8_percent_encode, NON_ALPHANUMERIC}; + +/// Build an inline `Content-Disposition` header value for a given filename. +pub fn inline_content_disposition(filename: &str) -> Option { + if filename.is_empty() { + return None; + } + + let sanitized: String = filename + .chars() + .map(|ch| match ch { + '"' | '\\' => '_', + c if !c.is_ascii() => '_', + _ => ch, + }) + .collect(); + let encoded = utf8_percent_encode(filename, NON_ALPHANUMERIC); + + Some(format!( + "inline; filename=\"{}\"; filename*=UTF-8''{}", + sanitized, encoded + )) +} + +#[cfg(test)] +mod tests { + use super::*; + use reqwest::header::HeaderValue; + + #[test] + fn test_inline_content_disposition_header_validity() { + // Test with a filename containing non-ASCII characters + let filename = "Täst.pdf"; + let disposition = inline_content_disposition(filename).unwrap(); + println!("Disposition: {}", disposition); + + // This should fail if the sanitized part contains non-ASCII characters + // and we try to create a HeaderValue from it. + let result = HeaderValue::from_str(&disposition); + + if let Ok(val) = result { + // Check if to_str succeeds (it should now!) + let to_str_res = val.to_str(); + assert!(to_str_res.is_ok(), "HeaderValue::to_str should succeed for sanitized filename"); + + let disposition_str = to_str_res.unwrap(); + assert!(disposition_str.contains("filename=\"T_st.pdf\""), "Filename should be sanitized"); + assert!(disposition_str.contains("filename*=UTF-8''T%C3%A4st%2Epdf"), "UTF-8 filename should be preserved"); + } else { + panic!("HeaderValue rejected the string: {:?}", result.err()); + } + } +} diff --git a/backend/src/utils/json.rs b/backend/src/utils/json.rs new file mode 100644 index 0000000..544ab7b --- /dev/null +++ b/backend/src/utils/json.rs @@ -0,0 +1,25 @@ +use serde::Deserialize; +use serde_json::Value; + +pub enum NullableValue { + Omitted, + Null, + String(String), +} + +pub fn classify_nullable(optional_value: Option<&Value>) -> Result { + match optional_value { + None => Ok(NullableValue::Omitted), + Some(Value::Null) => Ok(NullableValue::Null), + Some(Value::String(s)) => Ok(NullableValue::String(s.to_owned())), + Some(other) => Err(format!("expected string or null, got {other}")), + } +} + +pub fn deserialize_patch_field<'de, D, T>(deserializer: D) -> Result>, D::Error> +where + D: serde::Deserializer<'de>, + T: Deserialize<'de>, +{ + Option::::deserialize(deserializer).map(Some) +} diff --git a/backend/src/utils/mod.rs b/backend/src/utils/mod.rs new file mode 100644 index 0000000..09e6f47 --- /dev/null +++ b/backend/src/utils/mod.rs @@ -0,0 +1,12 @@ +pub mod bootstrap; +pub mod db; +pub mod error; +pub mod http; +pub mod json; +pub mod named_entity; +pub mod setops; +pub mod storage_paths; +pub mod text; +pub mod time; +pub mod tracing; +pub mod validation; diff --git a/backend/src/utils/named_entity.rs b/backend/src/utils/named_entity.rs new file mode 100644 index 0000000..13f8954 --- /dev/null +++ b/backend/src/utils/named_entity.rs @@ -0,0 +1,28 @@ +use diesel::QueryResult; + +use crate::error::{AppError, AppResult}; + +/// Trim and validate a user-supplied entity name, returning an owned String. +/// +/// The `on_empty` closure is only invoked when the trimmed name is empty, giving +/// callers control over the concrete error that should be surfaced. +pub fn normalize_name(raw: &str, on_empty: impl Fn() -> AppError) -> AppResult { + let trimmed = raw.trim(); + if trimmed.is_empty() { + return Err(on_empty()); + } + Ok(trimmed.to_string()) +} + +/// Ensure that no conflicting entity exists by executing the provided query +/// closure. If a record is returned, the `on_duplicate` closure is evaluated to +/// produce the appropriate error. +pub fn ensure_name_available( + query: impl FnOnce() -> QueryResult>, + on_duplicate: impl Fn() -> AppError, +) -> AppResult<()> { + if query()?.is_some() { + return Err(on_duplicate()); + } + Ok(()) +} diff --git a/backend/src/utils/setops.rs b/backend/src/utils/setops.rs new file mode 100644 index 0000000..91c8eb0 --- /dev/null +++ b/backend/src/utils/setops.rs @@ -0,0 +1,41 @@ +use std::collections::HashSet; +use std::hash::Hash; + +use uuid::Uuid; + +use crate::error::AppResult; +use crate::state::PgPooledConnection; + +/// Intersect an optional base set with a new set, returning the resulting option. +pub fn intersect_option_sets(base: Option>, next: HashSet) -> Option> +where + T: Eq + Hash + Copy, +{ + Some(match base { + Some(existing) => existing.intersection(&next).copied().collect(), + None => next, + }) +} + +/// Iteratively intersect documents linked via a join table loader. +pub fn load_linked_doc_ids( + conn: &mut PgPooledConnection, + ids: &[Uuid], + mut loader: F, +) -> AppResult> +where + F: FnMut(&mut PgPooledConnection, Uuid) -> AppResult>, +{ + let mut current: Option> = None; + + for id in ids { + let docs_set = loader(conn, *id)?; + current = intersect_option_sets(current, docs_set); + + if current.as_ref().is_some_and(|set| set.is_empty()) { + break; + } + } + + Ok(current.unwrap_or_default()) +} diff --git a/backend/src/utils/storage_paths.rs b/backend/src/utils/storage_paths.rs new file mode 100644 index 0000000..2db3e2c --- /dev/null +++ b/backend/src/utils/storage_paths.rs @@ -0,0 +1,95 @@ +//! Document storage path helpers. +//! +//! NOTE: The path layout produced here is part of the durable storage contract. +//! External systems (presigned URLs, lifecycle jobs, migrations) expect the +//! `documents/{document_id}/...` structure to remain stable. Coordinate before +//! changing any of these helpers to avoid breaking compatibility with existing +//! objects. + +use uuid::Uuid; + +const DOCUMENTS_PREFIX: &str = "documents"; + +/// Returns the root prefix for all objects belonging to a document. +pub fn document_prefix(document_id: Uuid) -> String { + format!("{DOCUMENTS_PREFIX}/{document_id}") +} + +/// Returns the prefix for a specific document version (without the object id). +pub fn document_version_prefix(document_id: Uuid, version_number: i32) -> String { + format!("{}/v{}", document_prefix(document_id), version_number) +} + +/// Returns the storage key for a stored document version blob. +pub fn document_version_object_key( + document_id: Uuid, + version_number: i32, + version_id: Uuid, +) -> String { + format!( + "{}/{}", + document_version_prefix(document_id, version_number), + version_id + ) +} + +fn document_asset_prefix(document_id: Uuid, version_number: i32) -> String { + format!( + "{}/assets", + document_version_prefix(document_id, version_number) + ) +} + +fn document_asset_type_prefix(document_id: Uuid, version_number: i32, asset_type: &str) -> String { + format!( + "{}/{}", + document_asset_prefix(document_id, version_number), + asset_type + ) +} + +/// Returns the storage key for an asset (single object). +pub fn document_asset_key( + document_id: Uuid, + version_number: i32, + asset_type: &str, + asset_id: Uuid, +) -> String { + format!( + "{}/{}", + document_asset_type_prefix(document_id, version_number, asset_type), + asset_id + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn generates_expected_paths() { + let document_id = Uuid::nil(); + let version_id = Uuid::nil(); + let asset_id = Uuid::nil(); + + assert_eq!( + document_prefix(document_id), + format!("documents/{document_id}") + ); + + assert_eq!( + document_version_prefix(document_id, 3), + format!("documents/{document_id}/v3") + ); + + assert_eq!( + document_version_object_key(document_id, 3, version_id), + format!("documents/{document_id}/v3/{version_id}") + ); + + assert_eq!( + document_asset_key(document_id, 3, "thumbnail", asset_id), + format!("documents/{document_id}/v3/assets/thumbnail/{asset_id}") + ); + } +} diff --git a/backend/src/utils/text.rs b/backend/src/utils/text.rs new file mode 100644 index 0000000..67785ee --- /dev/null +++ b/backend/src/utils/text.rs @@ -0,0 +1,31 @@ +use crate::error::{AppError, AppResult}; + +/// Normalizes an identifier-like user input by trimming, enforcing length, and validating characters. +pub fn normalize_identifier( + value: &str, + max_len: usize, + empty_message: &str, + length_message: &str, + invalid_message: Option<&str>, + mut validator: F, +) -> AppResult +where + F: FnMut(char) -> bool, +{ + let trimmed = value.trim(); + if trimmed.is_empty() { + return Err(AppError::bad_request(empty_message)); + } + + if trimmed.len() > max_len { + return Err(AppError::bad_request(length_message)); + } + + if let Some(msg) = invalid_message { + if !trimmed.chars().all(|ch| validator(ch)) { + return Err(AppError::bad_request(msg)); + } + } + + Ok(trimmed.to_string()) +} diff --git a/backend/src/utils/time.rs b/backend/src/utils/time.rs new file mode 100644 index 0000000..2489dfa --- /dev/null +++ b/backend/src/utils/time.rs @@ -0,0 +1,13 @@ +use chrono::{DateTime, NaiveDateTime, Utc}; + +/// Format a timestamp as RFC3339 using UTC. +pub fn to_iso(dt: NaiveDateTime) -> String { + DateTime::::from_naive_utc_and_offset(dt, Utc).to_rfc3339() +} + +/// Format a timestamp for HTTP headers (RFC 7231 date). +pub fn to_http_date(dt: NaiveDateTime) -> String { + DateTime::::from_naive_utc_and_offset(dt, Utc) + .format("%a, %d %b %Y %H:%M:%S GMT") + .to_string() +} diff --git a/backend/src/utils/tracing.rs b/backend/src/utils/tracing.rs new file mode 100644 index 0000000..2884b46 --- /dev/null +++ b/backend/src/utils/tracing.rs @@ -0,0 +1,14 @@ +use tracing_subscriber::EnvFilter; + +/// Initialize tracing with an optional default level. +/// +/// Falls back to `default_level` when `RUST_LOG` is not provided. +pub fn init_tracing(default_level: &str) { + let filter = + EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new(default_level)); + tracing_subscriber::fmt() + .with_env_filter(filter) + .with_target(true) + .compact() + .init(); +} diff --git a/backend/src/utils/validation.rs b/backend/src/utils/validation.rs new file mode 100644 index 0000000..33f6aba --- /dev/null +++ b/backend/src/utils/validation.rs @@ -0,0 +1,10 @@ +use crate::error::{AppError, AppResult}; + +/// Ensure an entity exists, returning a bad request error when it does not. +pub fn ensure_exists(exists: bool, entity: &str) -> AppResult<()> { + if exists { + Ok(()) + } else { + Err(AppError::bad_request(format!("{entity} does not exist"))) + } +} diff --git a/backend/src/workers/analyze.rs b/backend/src/workers/analyze.rs new file mode 100644 index 0000000..5bbdd6f --- /dev/null +++ b/backend/src/workers/analyze.rs @@ -0,0 +1,308 @@ +use std::{collections::HashSet, sync::Arc, time::Duration}; + +use async_trait::async_trait; +use diesel::prelude::*; +use infer; +use serde::Deserialize; +use tokio::task; +use uuid::Uuid; + +use crate::{ + auth::ensure_active_tenant, jobs::JOB_ANALYZE_DOCUMENT, models::Document, state::AppState, + storage::TenantStorage, +}; + +use super::{ + index::IndexDocumentTask, + issued_at::DetermineIssuedAtTask, + job_execution_from_task_error, + ocr::{GenerateOcrTask, TEXT_CONTENT_ASSET_TYPE}, + taskflow::{ + document::DocumentVersionTaskContext, BoxedTask, Task, TaskError, TaskExecutor, + TaskPlanner, TaskResult, + }, + thumbnails::GenerateThumbnailsTask, + JobExecution, JobHandler, +}; + +#[derive(Debug, Deserialize)] +struct AnalyzePayload { + document_id: Uuid, + document_version_id: Uuid, + #[serde(default)] + force: bool, +} + +pub struct AnalyzeDocumentJob; + +impl AnalyzeDocumentJob { + pub fn new() -> Self { + Self + } +} + +#[async_trait] +impl JobHandler for AnalyzeDocumentJob { + fn job_type(&self) -> &'static str { + JOB_ANALYZE_DOCUMENT + } + + async fn handle( + &self, + state: Arc, + job: crate::models::Job, + storage: TenantStorage, + ) -> JobExecution { + let tenant_id = match job.tenant_id { + Some(id) => id, + None => { + return JobExecution::Failed { + error: "job is no longer associated with a tenant".to_string(), + } + } + }; + + if let Err(err) = ensure_active_tenant(&state, tenant_id) { + return JobExecution::Failed { + error: err.to_string(), + }; + } + + let payload: AnalyzePayload = match serde_json::from_value(job.payload.clone()) { + Ok(payload) => payload, + Err(err) => { + return JobExecution::Failed { + error: format!("invalid analyze payload: {err}"), + } + } + }; + + let mut context = DocumentVersionTaskContext::new( + job.id, + JOB_ANALYZE_DOCUMENT, + tenant_id, + payload.document_id, + payload.document_version_id, + payload.force, + state.config.worker_max_document_bytes, + state.clone(), + storage, + ); + + let planner = AnalyzePlanner::new(payload.force, state.clone()); + match TaskExecutor::run(&planner, &mut context).await { + Ok(()) => JobExecution::Success, + Err(err) => job_execution_from_task_error(err), + } + } +} + +struct AnalyzePlanner { + force: bool, + state: Arc, +} + +const MIME_SNIFF_BYTES: usize = 8192; + +impl AnalyzePlanner { + fn new(force: bool, state: Arc) -> Self { + Self { force, state } + } +} + +#[async_trait] +impl TaskPlanner for AnalyzePlanner { + async fn plan( + &self, + ctx: &mut DocumentVersionTaskContext, + ) -> TaskResult>> { + let document = ctx.document().await?.clone(); + let mut tasks: Vec> = Vec::new(); + + tasks.push(Box::new(EnsureMimeTask)); + + let (thumbnail_supported, _) = determine_thumbnail_support(&document); + if thumbnail_supported { + tasks.push(Box::new(GenerateThumbnailsTask::new(self.force))); + } + + let existing_ocr = ctx.asset(TEXT_CONTENT_ASSET_TYPE).await?.is_some(); + let mut should_index = existing_ocr; + + if document_supports_ocr(&document) { + if self.force || !existing_ocr { + tasks.push(Box::new(GenerateOcrTask::new( + self.force, + self.state.clone(), + ))); + should_index = true; + } + } + + tasks.push(Box::new(DetermineIssuedAtTask::new())); + + if should_index { + tasks.push(Box::new(IndexDocumentTask::new())); + } + + Ok(tasks) + } +} + +struct EnsureMimeTask; + +#[async_trait] +impl Task for EnsureMimeTask { + fn name(&self) -> &'static str { + "ensure-mime-type" + } + + async fn execute(&self, ctx: &mut DocumentVersionTaskContext) -> TaskResult<()> { + let document = ctx.document().await?.clone(); + let current = document.mime_type.clone(); + + let guessed = guess_mime_type(ctx, &document).await?; + let desired = match guessed { + Some(mime) if current.as_deref() != Some(mime.as_str()) => Some(mime), + _ => None, + }; + + if let Some(new_mime) = desired { + update_document_mime(ctx, document.id, new_mime).await?; + } + + Ok(()) + } +} + +async fn guess_mime_type( + ctx: &mut DocumentVersionTaskContext, + document: &Document, +) -> TaskResult> { + let bytes = ctx.object_head(MIME_SNIFF_BYTES).await?; + Ok(sniff_mime(&bytes, &document.original_name)) +} + +fn sniff_mime(bytes: &[u8], original_name: &str) -> Option { + if let Some(kind) = infer::get(bytes) { + return Some(kind.mime_type().to_string()); + } + + mime_guess::from_path(original_name) + .first_raw() + .map(|value| value.to_string()) +} + +async fn update_document_mime( + ctx: &mut DocumentVersionTaskContext, + document_id: Uuid, + mime_type: String, +) -> TaskResult<()> { + let tenant_id = ctx.tenant_id(); + let state = ctx.state().clone(); + let mime_type_clone = mime_type.clone(); + + task::spawn_blocking(move || -> Result<(), String> { + let mut conn = state + .db_for_tenant(tenant_id) + .map_err(|err| format!("{err:?}"))?; + + diesel::update( + crate::schema::documents::table.filter(crate::schema::documents::id.eq(document_id)), + ) + .set(crate::schema::documents::mime_type.eq(Some(mime_type_clone))) + .execute(&mut conn) + .map_err(|err| format!("{err:?}")) + .map(|_| ()) + }) + .await + .map_err(|err| { + TaskError::retry( + Duration::from_secs(60), + format!("mime update task panicked: {err}"), + ) + })? + .map_err(|err| TaskError::retry(Duration::from_secs(30), err))?; + + ctx.set_document_mime(Some(mime_type)); + Ok(()) +} + +pub(crate) fn determine_thumbnail_support(document: &Document) -> (bool, Option) { + let supported_mimes: HashSet<&'static str> = [ + "image/jpeg", + "image/png", + "image/gif", + "image/tiff", + "image/bmp", + "image/webp", + "application/pdf", + "video/mp4", + "video/quicktime", + "video/webm", + "video/x-msvideo", + "video/x-ms-wmv", + "video/x-matroska", + ] + .into_iter() + .collect(); + + if let Some(ref mime_type) = document.mime_type { + if supported_mimes.contains(mime_type.as_str()) { + return (true, None); + } + } + + if let Some(ext) = document + .original_name + .rsplit('.') + .next() + .map(|ext| ext.to_ascii_lowercase()) + { + let supported_exts = [ + "jpg", "jpeg", "png", "gif", "tif", "tiff", "bmp", "webp", "pdf", "mp4", "m4v", "mov", + "webm", "mkv", "avi", "wmv", + ]; + if supported_exts.contains(&ext.as_str()) { + return (true, None); + } + } + + ( + false, + Some("content type not supported for thumbnails".into()), + ) +} + +fn document_supports_ocr(document: &Document) -> bool { + document + .mime_type + .as_deref() + .map(|mime| mime.eq_ignore_ascii_case("application/pdf")) + .unwrap_or_else(|| { + document + .original_name + .rsplit('.') + .next() + .map(|ext| ext.eq_ignore_ascii_case("pdf")) + .unwrap_or(false) + }) +} + +#[cfg(test)] +mod tests { + use super::sniff_mime; + + #[test] + fn sniff_mime_prefers_magic_bytes() { + const PNG_HEADER: [u8; 8] = [0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A]; + let mime = sniff_mime(&PNG_HEADER, "file.txt"); + assert_eq!(mime.as_deref(), Some("image/png")); + } + + #[test] + fn sniff_mime_falls_back_to_extension() { + let mime = sniff_mime(b"not enough to detect", "video.mp4"); + assert_eq!(mime.as_deref(), Some("video/mp4")); + } +} diff --git a/backend/src/workers/common.rs b/backend/src/workers/common.rs new file mode 100644 index 0000000..fddacbb --- /dev/null +++ b/backend/src/workers/common.rs @@ -0,0 +1,73 @@ +use std::collections::HashMap; + +use diesel::{prelude::*, PgConnection}; +use uuid::Uuid; + +use crate::models::{Document, DocumentAsset, DocumentVersion}; +use crate::schema::{document_assets, document_versions, documents}; +use crate::state::AppState; + +pub(crate) struct LoadedDocumentVersion { + pub document: Document, + pub version: DocumentVersion, +} + +pub(crate) fn load_document_version( + state: &AppState, + tenant_id: Uuid, + document_id: Uuid, + version_id: Uuid, +) -> Result { + let mut conn = state + .db_for_tenant(tenant_id) + .map_err(|err| format!("{err:?}"))?; + + let version: DocumentVersion = document_versions::table + .find(version_id) + .first(&mut conn) + .map_err(|err| format!("{err:?}"))?; + + if version.document_id != document_id { + return Err("document/version mismatch".into()); + } + + let document: Document = documents::table + .find(document_id) + .first(&mut conn) + .map_err(|err| format!("{err:?}"))?; + + Ok(LoadedDocumentVersion { document, version }) +} + +pub struct LoadedAsset { + pub asset: DocumentAsset, +} + +pub(crate) fn load_version_assets( + conn: &mut PgConnection, + tenant_id: Uuid, + version_id: Uuid, + asset_types: &[&str], +) -> Result, String> { + let mut query = document_assets::table + .filter(document_assets::document_version_id.eq(version_id)) + .filter(document_assets::tenant_id.eq(tenant_id)) + .into_boxed(); + + if !asset_types.is_empty() { + let types: Vec = asset_types.iter().map(|ty| (*ty).to_string()).collect(); + query = query.filter(document_assets::asset_type.eq_any(types)); + } + + let assets: Vec = query + .order(document_assets::created_at.asc()) + .load(conn) + .map_err(|err| format!("{err:?}"))?; + + let mut result = HashMap::with_capacity(assets.len()); + for asset in assets { + result.insert(asset.asset_type.clone(), LoadedAsset { asset }); + } + + Ok(result) +} diff --git a/backend/src/workers/index.rs b/backend/src/workers/index.rs new file mode 100644 index 0000000..09bd082 --- /dev/null +++ b/backend/src/workers/index.rs @@ -0,0 +1,79 @@ +use std::time::Duration; + +use async_trait::async_trait; +use reqwest::Client; + +use crate::documents::search::{build_quickwit_ingest_record, quickwit_ingest}; + +use super::{ + ocr::TEXT_CONTENT_ASSET_TYPE, + taskflow::{document::DocumentVersionTaskContext, Task, TaskError, TaskResult}, +}; + +pub struct IndexDocumentTask; + +impl IndexDocumentTask { + pub fn new() -> Self { + Self + } +} + +#[async_trait] +impl Task for IndexDocumentTask { + fn name(&self) -> &'static str { + "index-document-text" + } + + async fn execute(&self, ctx: &mut DocumentVersionTaskContext) -> TaskResult<()> { + let state = ctx.state().clone(); + let quickwit_endpoint = state + .config + .quickwit_endpoint + .clone() + .ok_or_else(|| TaskError::fail("quickwit endpoint missing"))?; + + let tenant = state.tenants.get_by_id(ctx.tenant_id()).map_err(|err| { + TaskError::retry( + Duration::from_secs(30), + format!("failed to load tenant: {err:?}"), + ) + })?; + + let quickwit_index = tenant + .quickwit_index + .clone() + .ok_or_else(|| TaskError::fail("tenant quickwit index not configured"))?; + + let asset = ctx + .asset(TEXT_CONTENT_ASSET_TYPE) + .await? + .ok_or_else(|| TaskError::fail("missing OCR text asset"))?; + + let s3_key = asset.asset.s3_key.clone(); + let bytes = ctx.storage().get_object(&s3_key).await.map_err(|err| { + TaskError::retry( + Duration::from_secs(30), + format!("failed to download ocr text: {err}"), + ) + })?; + + let text = String::from_utf8(bytes) + .map_err(|err| TaskError::fail(format!("ocr text not valid UTF-8: {err}")))?; + + if text.trim().is_empty() { + return Err(TaskError::fail("ocr text empty")); + } + + let document = ctx.document().await?.clone(); + let version = ctx.version().await?.clone(); + + let record = build_quickwit_ingest_record(&document, &version, ctx.tenant_id(), &text); + let client = Client::new(); + + quickwit_ingest(&client, &quickwit_endpoint, &quickwit_index, &[record]) + .await + .map_err(|err| TaskError::retry(Duration::from_secs(30), err.to_string()))?; + + Ok(()) + } +} diff --git a/backend/src/workers/issued_at.rs b/backend/src/workers/issued_at.rs new file mode 100644 index 0000000..05805c9 --- /dev/null +++ b/backend/src/workers/issued_at.rs @@ -0,0 +1,695 @@ +use std::time::Duration; + +use async_trait::async_trait; +use chrono::{DateTime, Datelike, NaiveDate, NaiveDateTime, TimeZone, Utc}; +use diesel::prelude::*; +use once_cell::sync::Lazy; +use regex::Match; +use regex::Regex; +use serde_json::Value; +use tracing::{info, warn}; +use uuid::Uuid; + +use crate::issued_at::{DateOrder, IssuedAtSettings}; +use crate::schema::documents::dsl as documents_dsl; +use crate::workers::ocr::TEXT_CONTENT_ASSET_TYPE; +use crate::workers::taskflow::document::DocumentVersionTaskContext; +use crate::workers::taskflow::{Task, TaskContext, TaskError, TaskResult}; + +#[path = "issued_at_months.rs"] +mod issued_at_months; + +const MAX_FILENAME_CHARS: usize = 256; +const MAX_TEXT_CHARS: usize = 50_000; +const DATE_SEP_PATTERN: &str = r"[\s._/\-]+"; + +static YMD_RE: Lazy = + Lazy::new(|| Regex::new(r"(?u)\b(\d{4})[./-](\d{1,2})[./-](\d{1,2})\b").expect("ymd regex")); + +static NUMERIC_RE: Lazy = Lazy::new(|| { + Regex::new(r"(?u)\b(\d{1,2})[./-](\d{1,2})[./-](\d{2,4})\b").expect("numeric regex") +}); + +static DAY_MONTH_RE: Lazy = Lazy::new(|| { + Regex::new(&format!( + r"(?u)\b(\d{{1,2}})(?:st|nd|rd|th)?{SEP}({MONTH_PATTERN}){SEP}(\d{{2,4}})\b", + SEP = DATE_SEP_PATTERN, + MONTH_PATTERN = month_pattern() + )) + .expect("day month regex") +}); + +static MONTH_DAY_RE: Lazy = Lazy::new(|| { + Regex::new(&format!( + r"(?u)\b({MONTH_PATTERN}){SEP}(\d{{1,2}})(?:st|nd|rd|th)?(?:,)?{SEP}(\d{{2,4}})\b", + SEP = DATE_SEP_PATTERN, + MONTH_PATTERN = month_pattern() + )) + .expect("month day regex") +}); + +static MONTH_YEAR_RE: Lazy = Lazy::new(|| { + Regex::new(&format!( + r"(?u)\b({MONTH_PATTERN})[\s._-]*(\d{{4}})\b", + MONTH_PATTERN = month_pattern() + )) + .expect("month year regex") +}); + +fn month_pattern() -> &'static str { + issued_at_months::pattern() +} + +pub struct DetermineIssuedAtTask; + +impl DetermineIssuedAtTask { + pub fn new() -> Self { + Self + } +} + +#[async_trait] +impl Task for DetermineIssuedAtTask { + fn name(&self) -> &'static str { + "determine-issued-at" + } + + async fn execute(&self, ctx: &mut DocumentVersionTaskContext) -> TaskResult<()> { + let document = ctx.document().await?.clone(); + if document.issued_at.is_some() { + return Ok(()); + } + + let version = ctx.version().await?.clone(); + let settings = ctx.state().issued_at_settings(); + let now_utc = Utc::now(); + + let parser_hint = parser_supplied_date(&document.metadata, &version.metadata, &settings) + .and_then(|dt| settings.normalize_datetime(dt, now_utc)); + + let filename_candidate = settings.filename_date_order().and_then(|order| { + let normalized = normalize_content(&document.original_name, MAX_FILENAME_CHARS); + find_date_in_text(&normalized, order, &settings, now_utc) + }); + + let text_candidate = load_document_text(ctx).await?.and_then(|text| { + let normalized = normalize_content(&text, MAX_TEXT_CHARS); + find_date_in_text(&normalized, settings.date_order(), &settings, now_utc) + }); + + if let Some((final_date, source)) = parser_hint + .map(|dt| (dt, IssuedAtSource::Parser)) + .or_else(|| filename_candidate.map(|dt| (dt, IssuedAtSource::Filename))) + .or_else(|| text_candidate.map(|dt| (dt, IssuedAtSource::Text))) + { + persist_issued_at(ctx, document.id, final_date.naive_utc()).await?; + info!( + job_id = %ctx.job_id(), + document_id = %document.id, + issued_at = %final_date, + source = source.as_ref(), + "issued_at determined" + ); + } else { + info!( + job_id = %ctx.job_id(), + document_id = %document.id, + "no issued_at signals discovered; leaving unset" + ); + } + + Ok(()) + } +} + +async fn persist_issued_at( + ctx: &DocumentVersionTaskContext, + document_id: Uuid, + issued_at: NaiveDateTime, +) -> TaskResult<()> { + let tenant_id = ctx.tenant_id(); + let state = ctx.state().clone(); + let result = tokio::task::spawn_blocking(move || -> Result<(), String> { + let mut conn = state + .db_for_tenant(tenant_id) + .map_err(|err| format!("failed to scope connection: {err:?}"))?; + diesel::update( + documents_dsl::documents + .filter(documents_dsl::tenant_id.eq(tenant_id)) + .filter(documents_dsl::id.eq(document_id)), + ) + .set(documents_dsl::issued_at.eq(issued_at)) + .execute(&mut conn) + .map_err(|err| format!("failed to update issued_at: {err}"))?; + Ok(()) + }) + .await + .map_err(|err| { + TaskError::retry( + Duration::from_secs(30), + format!("issued_at task panicked: {err}"), + ) + })?; + result.map_err(|err| TaskError::retry(Duration::from_secs(30), err))?; + Ok(()) +} + +fn parser_supplied_date( + document_meta: &Value, + version_meta: &Value, + settings: &IssuedAtSettings, +) -> Option> { + metadata_datetime(document_meta) + .or_else(|| metadata_datetime(version_meta)) + .and_then(|raw| parse_hint_datetime(raw, settings)) +} + +fn metadata_datetime(value: &Value) -> Option<&str> { + match value { + Value::String(s) => Some(s.as_str()), + Value::Object(map) => { + for key in [ + "issued_at_override", + "issued_at", + "source_date", + "created_at", + ] { + if let Some(Value::String(s)) = map.get(key) { + return Some(s.as_str()); + } + } + if let Some(Value::Object(parser)) = map.get("parser") { + if let Some(Value::String(s)) = parser.get("issued_at") { + return Some(s.as_str()); + } + } + None + } + _ => None, + } +} + +fn parse_hint_datetime(raw: &str, settings: &IssuedAtSettings) -> Option> { + if let Ok(dt) = DateTime::parse_from_rfc3339(raw) { + return Some(dt.with_timezone(&Utc)); + } + + if let Ok(date) = NaiveDate::parse_from_str(raw, "%Y-%m-%d") { + return settings + .timezone() + .with_ymd_and_hms(date.year(), date.month(), date.day(), 0, 0, 0) + .single() + .map(|dt| dt.with_timezone(&Utc)); + } + + None +} + +fn normalize_content(input: &str, limit: usize) -> String { + let truncated: String = input.chars().take(limit).collect(); + let mut normalized = String::with_capacity(truncated.len()); + for ch in truncated.chars() { + if ch.is_control() { + normalized.push(' '); + } else { + normalized.extend(ch.to_lowercase()); + } + } + normalized +} + +fn find_date_in_text( + text: &str, + order: DateOrder, + settings: &IssuedAtSettings, + now_utc: DateTime, +) -> Option> { + collect_matches_with_spans(text, order, settings, now_utc) + .into_iter() + .map(|(_, dt)| dt) + .next() +} + +fn collect_matches_with_spans( + text: &str, + order: DateOrder, + settings: &IssuedAtSettings, + now_utc: DateTime, +) -> Vec<(usize, DateTime)> { + let mut matches: Vec<(usize, usize, DateTime)> = Vec::new(); + let mut push_date = |span: Option, date: NaiveDate| { + if let Some(dt) = settings.normalize_naive(date, now_utc) { + if let Some(span) = span { + let start = span.start(); + let end = span.end(); + if let Some(existing) = + matches + .iter_mut() + .find(|(existing_start, existing_end, _)| { + *existing_start != usize::MAX + && start < *existing_end + && *existing_start < end + }) + { + let existing_len = existing.1.saturating_sub(existing.0); + let new_len = end.saturating_sub(start); + if new_len > existing_len { + *existing = (start, end, dt); + } + return; + } + matches.push((start, end, dt)); + } else { + matches.push((usize::MAX, usize::MAX, dt)); + } + } + }; + + for caps in YMD_RE.captures_iter(text) { + let (Some(year_match), Some(month_match), Some(day_match)) = + (caps.get(1), caps.get(2), caps.get(3)) + else { + continue; + }; + let year = match year_match.as_str().parse::() { + Ok(value) => value, + Err(_) => continue, + }; + let month = match month_match.as_str().parse::() { + Ok(value) => value, + Err(_) => continue, + }; + let day = match day_match.as_str().parse::() { + Ok(value) => value, + Err(_) => continue, + }; + if let Some(date) = NaiveDate::from_ymd_opt(year, month, day) { + push_date(caps.get(0), date); + } + } + + for caps in NUMERIC_RE.captures_iter(text) { + let (Some(first_match), Some(second_match), Some(year_match)) = + (caps.get(1), caps.get(2), caps.get(3)) + else { + continue; + }; + let first = match first_match.as_str().parse::() { + Ok(value) => value, + Err(_) => continue, + }; + let second = match second_match.as_str().parse::() { + Ok(value) => value, + Err(_) => continue, + }; + let year_raw = year_match.as_str(); + let mut year = match year_raw.parse::() { + Ok(value) => value, + Err(_) => continue, + }; + if year_raw.len() == 2 { + year += if year >= 70 { 1900 } else { 2000 }; + } + // NUMERIC_RE always captures a day-first form (dd[sep]mm[sep]yy(yy)); + // YMD layouts are handled earlier by YMD_RE, so YMD here is treated the + // same as DMY to avoid mis-parsing strings like 01-07-2024. + let (day, month) = match order { + DateOrder::Dmy | DateOrder::Ymd => (first, second), + DateOrder::Mdy => (second, first), + }; + if let Some(date) = NaiveDate::from_ymd_opt(year, month, day) { + push_date(caps.get(0), date); + } + } + + for caps in DAY_MONTH_RE.captures_iter(text) { + let (Some(day_match), Some(month_match), Some(year_match)) = + (caps.get(1), caps.get(2), caps.get(3)) + else { + continue; + }; + let day_str = day_match + .as_str() + .trim_matches(|c: char| !c.is_ascii_digit()); + let day = match day_str.parse::() { + Ok(value) => value, + Err(_) => continue, + }; + let Some(month) = month_name_to_number(month_match.as_str(), settings) else { + continue; + }; + if year_match.as_str().len() < 3 { + continue; + } + let Some(year) = normalize_year(year_match.as_str()) else { + continue; + }; + if let Some(date) = NaiveDate::from_ymd_opt(year, month, day) { + push_date(caps.get(0), date); + } + } + + for caps in MONTH_DAY_RE.captures_iter(text) { + let (Some(month_match), Some(day_match), Some(year_match)) = + (caps.get(1), caps.get(2), caps.get(3)) + else { + continue; + }; + let Some(month) = month_name_to_number(month_match.as_str(), settings) else { + continue; + }; + let day_str = day_match + .as_str() + .trim_matches(|c: char| !c.is_ascii_digit()); + let day = match day_str.parse::() { + Ok(value) => value, + Err(_) => continue, + }; + let Some(year) = normalize_year(year_match.as_str()) else { + continue; + }; + if let Some(date) = NaiveDate::from_ymd_opt(year, month, day) { + push_date(caps.get(0), date); + } + } + + for caps in MONTH_YEAR_RE.captures_iter(text) { + let (Some(month_match), Some(year_match)) = (caps.get(1), caps.get(2)) else { + continue; + }; + let Some(month) = month_name_to_number(month_match.as_str(), settings) else { + continue; + }; + let year = match year_match.as_str().parse::() { + Ok(value) => value, + Err(_) => continue, + }; + if let Some(date) = NaiveDate::from_ymd_opt(year, month, 1) { + push_date(caps.get(0), date); + } + } + + matches.sort_by_key(|(start, _, _)| *start); + matches + .into_iter() + .map(|(start, _, dt)| (start, dt)) + .collect() +} + +fn normalize_year(raw: &str) -> Option { + if raw.len() == 2 { + let mut year = raw.parse::().ok()?; + year += if year >= 70 { 1900 } else { 2000 }; + Some(year) + } else { + raw.parse::().ok() + } +} + +fn month_name_to_number(value: &str, settings: &IssuedAtSettings) -> Option { + let normalized = value.trim(); + let variant = issued_at_months::variants() + .iter() + .find(|entry| entry.name == normalized)?; + if settings.locales().is_empty() || variant.locales.is_empty() { + return Some(variant.month); + } + if variant + .locales + .iter() + .any(|locale| settings.locales().contains(*locale)) + { + Some(variant.month) + } else { + None + } +} + +async fn load_document_text(ctx: &mut DocumentVersionTaskContext) -> TaskResult> { + let object_key = { + let asset = ctx.asset(TEXT_CONTENT_ASSET_TYPE).await?; + asset.map(|a| a.asset.s3_key.clone()) + }; + + let Some(key) = object_key else { + return Ok(None); + }; + + match ctx.storage().get_object(&key).await { + Ok(bytes) => match String::from_utf8(bytes) { + Ok(mut text) => { + if text.len() > MAX_TEXT_CHARS { + text.truncate(MAX_TEXT_CHARS); + } + Ok(Some(text)) + } + Err(err) => { + warn!(error = %err, "ocr text asset not valid utf-8"); + Ok(None) + } + }, + Err(err) => { + warn!(error = %err, "failed to download ocr text for issued_at extractor"); + Ok(None) + } + } +} + +#[derive(Copy, Clone)] +enum IssuedAtSource { + Parser, + Filename, + Text, +} + +impl IssuedAtSource { + fn as_ref(&self) -> &'static str { + match self { + IssuedAtSource::Parser => "parser", + IssuedAtSource::Filename => "filename", + IssuedAtSource::Text => "text", + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::AppConfig; + use serde::Deserialize; + use serde_yaml::Value as YamlValue; + + use once_cell::sync::Lazy; + + #[derive(Clone, Deserialize)] + struct CaseSuite { + cases: Vec, + } + + #[derive(Clone, Deserialize)] + struct CaseDefinition { + name: String, + parser: String, + #[serde(default)] + filename: Option, + #[serde(default)] + content: Option, + #[serde(default)] + settings: CaseSettings, + expected: ExpectedCase, + } + + #[derive(Clone, Default, Deserialize)] + struct CaseSettings { + #[serde(rename = "DATE_PARSER_LANGUAGES", default)] + date_parser_languages: Vec, + #[serde(rename = "FILENAME_DATE_ORDER")] + filename_date_order: Option, + #[serde(rename = "DATE_ORDER")] + date_order: Option, + #[serde(rename = "IGNORE_DATES", default)] + ignore_dates: Vec, + } + + #[derive(Clone, Deserialize)] + struct ExpectedCase { + mode: ExpectedMode, + #[serde(default)] + value: Option, + } + + #[derive(Clone, Deserialize, PartialEq)] + #[serde(rename_all = "lowercase")] + enum ExpectedMode { + None, + Single, + Multiple, + } + + static CASES: Lazy = Lazy::new(|| { + let raw = include_str!("../../tests/data/issued_at_cases.yaml"); + serde_yaml::from_str(raw).expect("failed to parse issued_at cases") + }); + + pub(crate) fn run_named_case(name: &str) { + let case = CASES + .cases + .iter() + .find(|case| case.name == name) + .unwrap_or_else(|| panic!("case '{}' not found", name)) + .clone(); + run_case(case); + } + + fn run_case(case: CaseDefinition) { + let mut config = base_config(); + if let Some(order) = case.settings.date_order { + config.issued_at_date_order = order; + } + config.issued_at_filename_date_order = case.settings.filename_date_order; + config.issued_at_date_parser_locales = case.settings.date_parser_languages; + config.issued_at_ignore_dates = case.settings.ignore_dates; + + let settings = IssuedAtSettings::from_config(&config); + let now_utc = Utc.with_ymd_and_hms(2025, 1, 1, 0, 0, 0).single().unwrap(); + let mut matches = Vec::new(); + + if let Some(filename) = &case.filename { + if let Some(order) = settings.filename_date_order() { + let normalized = normalize_content(filename, MAX_FILENAME_CHARS); + matches.extend( + collect_matches_with_spans(&normalized, order, &settings, now_utc) + .into_iter() + .map(|(_, dt)| dt.date_naive()), + ); + } + } + + if let Some(content) = &case.content { + let normalized = normalize_content(content, MAX_TEXT_CHARS); + matches.extend( + collect_matches_with_spans(&normalized, settings.date_order(), &settings, now_utc) + .into_iter() + .map(|(_, dt)| dt.date_naive()), + ); + } + + let actual: Vec = matches + .into_iter() + .map(|date| date.format("%Y-%m-%d").to_string()) + .collect(); + + match case.parser.as_str() { + "parse_date" => match case.expected.mode { + ExpectedMode::None => assert!( + actual.is_empty(), + "case '{}' expected no matches, got {:?}", + case.name, + actual + ), + ExpectedMode::Single => { + let expected = case.expected.single_value(); + assert!( + expected.is_some(), + "case '{}' is missing expected single value", + case.name + ); + assert_eq!( + actual.first(), + expected.as_ref(), + "case '{}' single mismatch", + case.name + ); + } + ExpectedMode::Multiple => panic!( + "case '{}' declares parse_date but expects multiple results", + case.name + ), + }, + "parse_date_generator" => { + let expected = case.expected.multiple_values().unwrap_or_default(); + assert_eq!(expected, actual, "case '{}' multiple mismatch", case.name); + } + other => panic!("unsupported parser '{}' in case {}", other, case.name), + } + } + + impl ExpectedCase { + fn single_value(&self) -> Option { + match self.value.as_ref()? { + YamlValue::String(value) => Some(value.clone()), + other => Some(other.as_str()?.to_string()), + } + } + + fn multiple_values(&self) -> Option> { + let list = match self.value.as_ref()? { + YamlValue::Sequence(seq) => seq, + _ => return None, + }; + Some( + list.iter() + .filter_map(|value| value.as_str().map(|s| s.to_string())) + .collect(), + ) + } + } + + fn base_config() -> AppConfig { + AppConfig { + database_url: "postgres://test".to_string(), + migrations_database_url: None, + database_max_pool_size: 5, + server_host: "127.0.0.1".to_string(), + server_port: 0, + webdav_host: "127.0.0.1".to_string(), + webdav_port: 0, + jwt_secret: "secret".to_string(), + jwt_issuer: "issuer".to_string(), + jwt_audience: "audience".to_string(), + jwt_expiry_minutes: 60, + download_token_audience: "download".to_string(), + download_token_expiry_minutes: 60, + refresh_token_expiry_days: 30, + refresh_cookie_secure: false, + refresh_cookie_domain: None, + cors_allowed_origin: None, + proxy_downloads: false, + aws_endpoint_url: None, + aws_access_key_id: None, + aws_secret_access_key: None, + aws_region: "us-east-1".to_string(), + s3_bucket: "bucket".to_string(), + quickwit_endpoint: None, + quickwit_index: None, + worker_max_document_bytes: 100 * 1024 * 1024, + upload_body_limit_bytes: 64 * 1024 * 1024, + service_timezone: "UTC".to_string(), + issued_at_date_order: "DMY".to_string(), + issued_at_filename_date_order: None, + issued_at_date_parser_locales: Vec::new(), + issued_at_ignore_dates: Vec::new(), + webauthn_rp_id: Some("localhost".to_string()), + webauthn_origin: Some("http://localhost".to_string()), + webauthn_rp_name: "Papercrate".to_string(), + } + } + + include!(concat!(env!("OUT_DIR"), "/issued_at_generated_tests.rs")); + + #[test] + fn month_name_lookup_handles_turkish_variants() { + let settings = IssuedAtSettings::from_config(&base_config()); + assert_eq!(month_name_to_number("şubat", &settings), Some(2)); + assert_eq!(month_name_to_number("subat", &settings), Some(2)); + } + + #[test] + fn locale_filter_limits_month_names() { + let mut config = base_config(); + config.issued_at_date_parser_locales = vec!["tr".into()]; + let settings = IssuedAtSettings::from_config(&config); + assert_eq!(month_name_to_number("january", &settings), None); + assert_eq!(month_name_to_number("şubat", &settings), Some(2)); + } +} diff --git a/backend/src/workers/issued_at_months.rs b/backend/src/workers/issued_at_months.rs new file mode 100644 index 0000000..dc648a3 --- /dev/null +++ b/backend/src/workers/issued_at_months.rs @@ -0,0 +1,15 @@ +pub(super) struct MonthVariant { + pub name: &'static str, + pub month: u32, + pub locales: &'static [&'static str], +} + +include!(concat!(env!("OUT_DIR"), "/issued_at_months.rs")); + +pub(super) fn pattern() -> &'static str { + MONTH_PATTERN +} + +pub(super) fn variants() -> &'static [MonthVariant] { + MONTH_VARIANTS +} diff --git a/backend/src/workers/mod.rs b/backend/src/workers/mod.rs new file mode 100644 index 0000000..7ab5910 --- /dev/null +++ b/backend/src/workers/mod.rs @@ -0,0 +1,209 @@ +use std::{collections::HashMap, sync::Arc, time::Duration}; + +use anyhow::Error as AnyhowError; +use async_trait::async_trait; +use tokio::time::sleep; +use tracing::{error, info, warn}; + +use crate::{ + jobs::{mark_job_failed, mark_job_succeeded, reserve_job, retry_job_after, JobQueueError}, + models::Job, + state::AppState, + storage::TenantStorage, +}; +use taskflow::TaskError; + +pub mod analyze; +pub mod common; +pub mod index; +pub mod issued_at; +pub mod ocr; +pub mod purge; +pub mod taskflow; +pub mod tenants; +pub mod thumbnails; + +use tenants::{DeleteTenantJob, ProvisionTenantJob}; + +#[derive(Debug)] +pub enum JobExecution { + Success, + Retry { delay: Duration, error: String }, + Failed { error: String }, +} + +#[async_trait] +pub trait JobHandler: Send + Sync { + fn job_type(&self) -> &'static str; + async fn handle(&self, state: Arc, job: Job, storage: TenantStorage) -> JobExecution; +} + +pub struct Worker { + state: Arc, + handlers: HashMap<&'static str, Arc>, + poll_interval: Duration, +} + +impl Worker { + pub fn new( + state: Arc, + handlers: Vec>, + poll_interval: Duration, + ) -> Self { + let map = handlers + .into_iter() + .map(|handler| (handler.job_type(), handler)) + .collect(); + Self { + state, + handlers: map, + poll_interval, + } + } + + pub async fn run(&self) { + info!("worker started"); + loop { + match self.tick().await { + Ok(true) => {} + Ok(false) => sleep(self.poll_interval).await, + Err(err) => { + error!(error = %err, "worker tick failed"); + sleep(self.poll_interval).await; + } + } + } + } + + async fn tick(&self) -> Result { + let job_types: Vec<&str> = self.handlers.keys().copied().collect(); + if job_types.is_empty() { + return Ok(false); + } + + let mut conn = match self.state.db_unscoped() { + Ok(conn) => conn, + Err(err) => { + error!(?err, "failed to obtain database connection in worker"); + return Ok(false); + } + }; + + let job_opt = reserve_job(&mut conn, &job_types)?; + drop(conn); + + if let Some(job) = job_opt { + let tenant_id = match job.tenant_id { + Some(id) => id, + None => { + warn!(job_id = %job.id, job_type = %job.job_type, "job detached from tenant; marking failed"); + if let Ok(mut conn) = self.state.db_unscoped() { + let _ = + mark_job_failed(&mut conn, job.id, "job detached from tenant context"); + } + return Ok(true); + } + }; + + if let Some(handler) = self.handlers.get(job.job_type.as_str()) { + let execution = match self.state.storage_for_tenant(tenant_id) { + Ok(storage) => { + handler + .handle(self.state.clone(), job.clone(), storage) + .await + } + Err(err) => { + error!(job_id = %job.id, error = ?err, "failed to load tenant storage for job"); + JobExecution::Failed { + error: format!("tenant storage unavailable: {err:?}"), + } + } + }; + match execution { + JobExecution::Success => { + if let Ok(mut conn) = self.state.db_unscoped() { + mark_job_succeeded(&mut conn, job.id)?; + info!(job_id = %job.id, job_type = %job.job_type, "job completed successfully"); + } else { + error!("failed to mark job succeeded due to pool error"); + } + } + JobExecution::Retry { delay, error } => { + warn!(job_id = %job.id, job_type = %job.job_type, %error, "job will retry"); + if let Ok(mut conn) = self.state.db_unscoped() { + retry_job_after(&mut conn, job.id, delay, &error)?; + } else { + error!("failed to requeue job for retry due to pool error"); + } + } + JobExecution::Failed { error } => { + error!(job_id = %job.id, job_type = %job.job_type, %error, "job failed"); + if let Ok(mut conn) = self.state.db_unscoped() { + mark_job_failed(&mut conn, job.id, &error)?; + } else { + error!("failed to mark job failed due to pool error"); + } + } + } + } else { + error!(job_type = %job.job_type, "no handler registered for job type"); + if let Ok(mut conn) = self.state.db_unscoped() { + mark_job_failed(&mut conn, job.id, "no handler registered")?; + } else { + error!("failed to mark job failed for missing handler due to pool error"); + } + } + Ok(true) + } else { + Ok(false) + } + } +} + +pub fn default_handlers() -> Vec> { + vec![ + Arc::new(analyze::AnalyzeDocumentJob::new()), + Arc::new(purge::PurgeDocumentJob::new()), + Arc::new(ProvisionTenantJob::new()), + Arc::new(DeleteTenantJob::new()), + ] +} + +pub(crate) fn job_execution_from_task_error(error: TaskError) -> JobExecution { + match error { + TaskError::Fail { error } => JobExecution::Failed { error }, + TaskError::Retry { delay, error } => JobExecution::Retry { delay, error }, + } +} + +pub(crate) fn check_worker_document_limit( + size_bytes: i64, + limit_bytes: u64, +) -> Result<(), (u64, u64)> { + let size = size_bytes.max(0) as u64; + if size > limit_bytes { + Err((size, limit_bytes)) + } else { + Ok(()) + } +} + +pub(crate) enum FetchVersionError { + TooLarge { size: u64, limit: u64 }, + Storage(AnyhowError), +} + +pub(crate) async fn fetch_version_object( + version: &crate::models::DocumentVersion, + storage: &TenantStorage, + s3_key: &str, + limit_bytes: u64, +) -> Result, FetchVersionError> { + check_worker_document_limit(version.size_bytes, limit_bytes) + .map_err(|(size, limit)| FetchVersionError::TooLarge { size, limit })?; + + storage + .get_object(s3_key) + .await + .map_err(FetchVersionError::Storage) +} diff --git a/backend/src/workers/ocr.rs b/backend/src/workers/ocr.rs new file mode 100644 index 0000000..d6dd8ff --- /dev/null +++ b/backend/src/workers/ocr.rs @@ -0,0 +1,424 @@ +use std::{ + fmt, fs, + io::{ErrorKind, Write}, + process::Command, + sync::Arc, + time::Duration, +}; + +use async_trait::async_trait; +use chrono::Utc; +use diesel::{pg::upsert::excluded, prelude::*}; +use pdfium_render::prelude::*; +use serde_json::json; +use tempfile::NamedTempFile; +use tokio::task; +use tracing::{info, warn}; +use uuid::Uuid; + +use crate::{ + documents::asset::delete_asset, + error::AppResult, + models::{Document, DocumentAsset, DocumentVersion, NewDocumentAsset}, + schema::document_assets, + state::AppState, + utils::storage_paths::document_asset_key, +}; + +use super::taskflow::{ + document::DocumentVersionTaskContext, Task, TaskContext, TaskError, TaskResult, +}; + +pub const TEXT_CONTENT_ASSET_TYPE: &str = "text-content"; +const MIN_TEXT_LENGTH: usize = 50; + +pub struct GenerateOcrTask { + force: bool, + state: Arc, +} + +impl GenerateOcrTask { + pub fn new(force: bool, state: Arc) -> Self { + Self { force, state } + } +} + +#[async_trait] +impl Task for GenerateOcrTask { + fn name(&self) -> &'static str { + "generate-ocr-text" + } + + async fn execute(&self, ctx: &mut DocumentVersionTaskContext) -> TaskResult<()> { + let context = build_ocr_context(ctx, self.force).await?; + + if context.skip { + info!(job_id = %ctx.job_id(), "ocr already present; skipping"); + return Ok(()); + } + + let bytes = ctx.buffered_object().await?.to_vec(); + let meta = PdfDocumentMeta { + mime_type: context.document.mime_type.clone(), + original_name: context.document.original_name.clone(), + }; + + let generation = task::spawn_blocking(move || generate_ocr_text(&meta, &bytes)) + .await + .map_err(|err| { + TaskError::retry( + Duration::from_secs(60), + format!("ocr text task panicked: {err}"), + ) + })?; + + let Some(generation) = generation else { + warn!(job_id = %ctx.job_id(), "no text extracted from document; failing job"); + return Err(TaskError::fail("no text extracted and OCR unavailable")); + }; + + remove_existing_ocr_asset(ctx, &context).await; + + let asset_id = Uuid::new_v4(); + let s3_key = document_asset_key( + context.document.id, + context.version.version_number, + TEXT_CONTENT_ASSET_TYPE, + asset_id, + ); + + ctx.storage() + .put_object( + &s3_key, + generation.text.into_bytes(), + Some("text/plain; charset=utf-8".into()), + None, + ) + .await + .map_err(|err| TaskError::retry(Duration::from_secs(30), err.to_string()))?; + + let state = self.state.clone(); + task::spawn_blocking(move || { + persist_ocr_metadata(state, &context, asset_id, &s3_key, generation.source) + }) + .await + .map_err(|err| { + TaskError::retry( + Duration::from_secs(60), + format!("ocr metadata task panicked: {err}"), + ) + })? + .map_err(|err| TaskError::retry(Duration::from_secs(30), err))?; + + ctx.invalidate_asset_cache(); + + Ok(()) + } +} + +struct OcrContext { + document: Document, + version: DocumentVersion, + existing_asset: Option, + skip: bool, +} + +async fn build_ocr_context( + ctx: &mut DocumentVersionTaskContext, + force: bool, +) -> TaskResult { + let document = ctx.document().await?.clone(); + let version = ctx.version().await?.clone(); + let asset = ctx.asset(TEXT_CONTENT_ASSET_TYPE).await?; + + let existing_asset = asset.map(|asset| asset.asset.clone()); + + if !document_is_pdf(&document) { + return Ok(OcrContext { + document, + version, + existing_asset, + skip: true, + }); + } + + let skip = existing_asset.is_some() && !force; + + Ok(OcrContext { + document, + version, + existing_asset, + skip, + }) +} + +async fn remove_existing_ocr_asset(ctx: &DocumentVersionTaskContext, context: &OcrContext) { + if let Some(existing_asset) = &context.existing_asset { + if let Err(err) = ctx.storage().delete_object(&existing_asset.s3_key).await { + warn!( + job_id = %ctx.job_id(), + error = %err, + s3_key = %existing_asset.s3_key, + "failed to delete existing ocr asset object" + ); + } + + let tenant_id = context.document.tenant_id; + let asset_id = existing_asset.id; + let state = ctx.state().clone(); + match task::spawn_blocking(move || -> AppResult<()> { + let mut conn = state.db_for_tenant(tenant_id)?; + delete_asset(&mut conn, tenant_id, asset_id) + }) + .await + { + Ok(Ok(())) => {} + Ok(Err(err)) => { + warn!( + job_id = %ctx.job_id(), + error = ?err, + asset_id = %asset_id, + "failed to remove ocr asset metadata after deletion" + ); + } + Err(join_err) => { + warn!( + job_id = %ctx.job_id(), + error = %join_err, + asset_id = %asset_id, + "failed to remove ocr asset metadata: task panicked" + ); + } + } + } +} + +fn persist_ocr_metadata( + state: Arc, + context: &OcrContext, + asset_id: Uuid, + s3_key: &str, + source: OcrSource, +) -> Result<(), String> { + let tenant_id = context.document.tenant_id; + let mut conn = state + .db_for_tenant(tenant_id) + .map_err(|err| format!("{err:?}"))?; + + let document_version_id = context.version.id; + let existing_asset = context.existing_asset.as_ref().map(|asset| asset.id); + + if let Some(existing_asset) = existing_asset { + diesel::delete( + document_assets::table + .filter(document_assets::id.eq(existing_asset)) + .filter(document_assets::tenant_id.eq(tenant_id)), + ) + .execute(&mut conn) + .map_err(|err| format!("{err:?}"))?; + } + + let metadata = json!({ + "source": source.to_string(), + "generated_at": Utc::now().to_rfc3339(), + }); + + let new_asset = NewDocumentAsset { + id: asset_id, + document_version_id, + asset_type: TEXT_CONTENT_ASSET_TYPE.to_string(), + mime_type: "text/plain".to_string(), + metadata, + s3_key: s3_key.to_string(), + tenant_id, + }; + + diesel::insert_into(document_assets::table) + .values(&new_asset) + .on_conflict(( + document_assets::document_version_id, + document_assets::asset_type, + )) + .do_update() + .set(( + document_assets::mime_type.eq(excluded(document_assets::mime_type)), + document_assets::metadata.eq(excluded(document_assets::metadata)), + document_assets::s3_key.eq(excluded(document_assets::s3_key)), + document_assets::id.eq(excluded(document_assets::id)), + )) + .execute(&mut conn) + .map_err(|err| format!("{err:?}"))?; + + Ok(()) +} + +fn generate_ocr_text(meta: &PdfDocumentMeta, bytes: &[u8]) -> Option { + if !document_meta_is_pdf(meta) { + return None; + } + + if let Ok(text) = extract_pdf_text(bytes) { + if text.trim().chars().count() >= MIN_TEXT_LENGTH { + return Some(OcrGeneration { + text, + source: OcrSource::PdfText, + }); + } + } + + match run_ocr(bytes) { + Ok(Some(text)) => Some(OcrGeneration { + text, + source: OcrSource::Ocr, + }), + Ok(None) => None, + Err(OcrError::BinaryMissing) => { + warn!("ocrmypdf binary not found; OCR unavailable"); + None + } + Err(err) => { + warn!(error = %err, "ocr command failed"); + None + } + } +} + +struct PdfDocumentMeta { + mime_type: Option, + original_name: String, +} + +struct OcrGeneration { + text: String, + source: OcrSource, +} + +#[derive(Clone, Copy)] +enum OcrSource { + PdfText, + Ocr, +} + +impl fmt::Display for OcrSource { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + OcrSource::PdfText => write!(f, "pdf-text"), + OcrSource::Ocr => write!(f, "ocr"), + } + } +} + +#[derive(Debug)] +enum OcrError { + BinaryMissing, + Failed(String), +} + +impl fmt::Display for OcrError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + OcrError::BinaryMissing => write!(f, "ocrmypdf binary not found"), + OcrError::Failed(msg) => write!(f, "ocr failed: {msg}"), + } + } +} + +fn extract_pdf_text(bytes: &[u8]) -> Result { + let pdfium = Pdfium::default(); + let document = pdfium + .load_pdf_from_byte_slice(bytes, None) + .map_err(|err| format!("load pdf: {err}"))?; + + let mut combined = String::new(); + let pages = document.pages(); + for page_index in 0..pages.len() { + let page = pages + .get(page_index) + .map_err(|err| format!("load page {page_index}: {err}"))?; + if let Ok(page_text) = page.text() { + for segment in page_text.segments().iter() { + combined.push_str(&segment.text()); + combined.push('\n'); + } + }; + } + + Ok(combined) +} + +fn run_ocr(bytes: &[u8]) -> Result, OcrError> { + let mut input = NamedTempFile::new().map_err(|err| OcrError::Failed(err.to_string()))?; + input + .write_all(bytes) + .map_err(|err| OcrError::Failed(err.to_string()))?; + input + .flush() + .map_err(|err| OcrError::Failed(err.to_string()))?; + + let output_pdf = NamedTempFile::new().map_err(|err| OcrError::Failed(err.to_string()))?; + let sidecar = NamedTempFile::new().map_err(|err| OcrError::Failed(err.to_string()))?; + + let status = Command::new("ocrmypdf") + .arg("--sidecar") + .arg(sidecar.path()) + .arg("--skip-text") + .arg(input.path()) + .arg(output_pdf.path()) + .output(); + + match status { + Ok(output) => { + if !output.status.success() { + return Err(OcrError::Failed(format!( + "ocrmypdf failed: exit={} stderr={}", + output.status, + String::from_utf8_lossy(&output.stderr) + ))); + } + + let text = fs::read_to_string(sidecar.path()) + .map_err(|err| OcrError::Failed(err.to_string()))?; + if text.trim().chars().count() >= MIN_TEXT_LENGTH { + Ok(Some(text)) + } else { + Ok(None) + } + } + Err(err) => { + if err.kind() == ErrorKind::NotFound { + Err(OcrError::BinaryMissing) + } else { + Err(OcrError::Failed(err.to_string())) + } + } + } +} + +fn document_meta_is_pdf(meta: &PdfDocumentMeta) -> bool { + if let Some(mime_type) = &meta.mime_type { + if mime_type.eq_ignore_ascii_case("application/pdf") { + return true; + } + } + + meta.original_name + .rsplit('.') + .next() + .map(|ext| ext.eq_ignore_ascii_case("pdf")) + .unwrap_or(false) +} + +fn document_is_pdf(document: &Document) -> bool { + document + .mime_type + .as_deref() + .map(|mime| mime.eq_ignore_ascii_case("application/pdf")) + .unwrap_or_else(|| { + document + .original_name + .rsplit('.') + .next() + .map(|ext| ext.eq_ignore_ascii_case("pdf")) + .unwrap_or(false) + }) +} diff --git a/backend/src/workers/purge.rs b/backend/src/workers/purge.rs new file mode 100644 index 0000000..4d4e305 --- /dev/null +++ b/backend/src/workers/purge.rs @@ -0,0 +1,298 @@ +use std::collections::HashSet; +use std::sync::Arc; +use std::time::Duration; + +use async_trait::async_trait; +use diesel::prelude::*; +use diesel::result::Error as DieselError; +use serde::Deserialize; +use uuid::Uuid; + +use crate::auth::ensure_active_tenant; +use crate::jobs::JOB_PURGE_DOCUMENT; +use crate::models::{Document, DocumentVersion}; +use crate::schema::{document_assets, document_versions}; +use crate::state::AppState; +use crate::storage::TenantStorage; + +use super::{ + job_execution_from_task_error, + taskflow::{BoxedTask, Task, TaskContext, TaskError, TaskExecutor, TaskPlanner, TaskResult}, + JobExecution, JobHandler, +}; + +#[derive(Debug, Deserialize)] +struct PurgeDocumentPayload { + document_id: Uuid, +} + +#[derive(Debug)] +struct PurgeContext { + document_id: Uuid, + version_keys: Vec, + asset_keys: Vec, +} + +pub struct PurgeDocumentJob; + +impl PurgeDocumentJob { + pub fn new() -> Self { + Self + } +} + +#[async_trait] +impl JobHandler for PurgeDocumentJob { + fn job_type(&self) -> &'static str { + JOB_PURGE_DOCUMENT + } + + async fn handle( + &self, + state: Arc, + job: crate::models::Job, + storage: TenantStorage, + ) -> JobExecution { + let tenant_id = match job.tenant_id { + Some(id) => id, + None => { + return JobExecution::Failed { + error: "job is no longer associated with a tenant".to_string(), + } + } + }; + + if let Err(err) = ensure_active_tenant(&state, tenant_id) { + return JobExecution::Failed { + error: err.to_string(), + }; + } + + let payload: PurgeDocumentPayload = match serde_json::from_value(job.payload.clone()) { + Ok(payload) => payload, + Err(err) => { + return JobExecution::Failed { + error: format!("invalid purge payload: {err}"), + }; + } + }; + + let mut context = PurgeTaskContext::new( + job.id, + JOB_PURGE_DOCUMENT, + tenant_id, + payload.document_id, + state.clone(), + storage, + ); + + let planner = PurgePlanner; + match TaskExecutor::run(&planner, &mut context).await { + Ok(()) => JobExecution::Success, + Err(err) => job_execution_from_task_error(err), + } + } +} + +struct PurgeTaskContext { + job_id: Uuid, + job_type: &'static str, + tenant_id: Uuid, + document_id: Uuid, + state: Arc, + storage: TenantStorage, +} + +impl PurgeTaskContext { + fn new( + job_id: Uuid, + job_type: &'static str, + tenant_id: Uuid, + document_id: Uuid, + state: Arc, + storage: TenantStorage, + ) -> Self { + Self { + job_id, + job_type, + tenant_id, + document_id, + state, + storage, + } + } +} + +impl TaskContext for PurgeTaskContext { + fn job_id(&self) -> Uuid { + self.job_id + } + + fn job_type(&self) -> &'static str { + self.job_type + } +} + +struct PurgePlanner; + +#[async_trait] +impl TaskPlanner for PurgePlanner { + async fn plan( + &self, + _ctx: &mut PurgeTaskContext, + ) -> TaskResult>> { + Ok(vec![Box::new(PurgeTask)]) + } +} + +struct PurgeTask; + +#[async_trait] +impl Task for PurgeTask { + fn name(&self) -> &'static str { + "purge-document" + } + + async fn execute(&self, ctx: &mut PurgeTaskContext) -> TaskResult<()> { + let tenant_id = ctx.tenant_id; + let document_id = ctx.document_id; + let state = ctx.state.clone(); + + let preparation = tokio::task::spawn_blocking(move || { + prepare_purge_context(state, tenant_id, document_id) + }) + .await + .map_err(|err| { + TaskError::retry( + Duration::from_secs(60), + format!("purge preparation panicked: {err}"), + ) + })?; + + let Some(context) = + preparation.map_err(|err| TaskError::retry(Duration::from_secs(30), err))? + else { + return Ok(()); + }; + + delete_storage_objects(&ctx.storage, &context) + .await + .map_err(|err| TaskError::retry(Duration::from_secs(30), err))?; + + let state = ctx.state.clone(); + tokio::task::spawn_blocking(move || finalize_purge(state, tenant_id, context.document_id)) + .await + .map_err(|err| { + TaskError::retry( + Duration::from_secs(60), + format!("purge finalize panicked: {err}"), + ) + })? + .map_err(|err| TaskError::retry(Duration::from_secs(30), err))?; + + Ok(()) + } +} + +fn prepare_purge_context( + state: Arc, + tenant_id: Uuid, + document_id: Uuid, +) -> Result, String> { + let mut conn = state + .db_for_tenant(tenant_id) + .map_err(|err| format!("failed to scope tenant connection: {err:?}"))?; + + conn.transaction(|conn| { + use crate::schema::documents::dsl as doc_dsl; + + let doc_opt = doc_dsl::documents + .filter(doc_dsl::tenant_id.eq(tenant_id)) + .find(document_id) + .for_update() + .first::(conn) + .optional()?; + + let Some(document) = doc_opt else { + return Ok(None); + }; + + if document.deleted_at.is_none() { + return Ok(None); + } + + let versions: Vec = document_versions::table + .filter(document_versions::document_id.eq(document_id)) + .filter(document_versions::tenant_id.eq(tenant_id)) + .load(conn)?; + + let version_keys: Vec = versions + .iter() + .map(|version| version.s3_key.clone()) + .collect(); + let version_ids: Vec = versions.iter().map(|version| version.id).collect(); + + let asset_keys = if version_ids.is_empty() { + Vec::new() + } else { + document_assets::table + .filter(document_assets::document_version_id.eq_any(&version_ids)) + .filter(document_assets::tenant_id.eq(tenant_id)) + .select(document_assets::s3_key) + .load(conn)? + }; + + Ok(Some(PurgeContext { + document_id, + version_keys, + asset_keys, + })) + }) + .map_err(|err: DieselError| format!("failed to prepare purge: {err}")) +} + +async fn delete_storage_objects( + storage: &TenantStorage, + context: &PurgeContext, +) -> Result<(), String> { + let mut keys = HashSet::new(); + keys.extend(context.version_keys.iter().cloned()); + keys.extend(context.asset_keys.iter().cloned()); + + for key in keys { + if let Err(err) = storage.delete_object(&key).await { + return Err(format!("failed to delete object {}: {err:?}", key)); + } + } + + Ok(()) +} + +fn finalize_purge(state: Arc, tenant_id: Uuid, document_id: Uuid) -> Result<(), String> { + let mut conn = state + .db_for_tenant(tenant_id) + .map_err(|err| format!("failed to scope tenant connection: {err:?}"))?; + + conn.transaction(|conn| { + use crate::schema::documents::dsl as doc_dsl; + + let doc_opt = doc_dsl::documents + .filter(doc_dsl::tenant_id.eq(tenant_id)) + .find(document_id) + .for_update() + .first::(conn) + .optional()?; + + let Some(document) = doc_opt else { + return Ok(()); + }; + + if document.deleted_at.is_none() { + return Ok(()); + } + + diesel::delete(doc_dsl::documents.filter(doc_dsl::id.eq(document_id))).execute(conn)?; + Ok(()) + }) + .map_err(|err: DieselError| format!("failed to finalize purge: {err}")) +} diff --git a/backend/src/workers/taskflow/document.rs b/backend/src/workers/taskflow/document.rs new file mode 100644 index 0000000..54f76ca --- /dev/null +++ b/backend/src/workers/taskflow/document.rs @@ -0,0 +1,225 @@ +use std::collections::HashMap; +use std::sync::Arc; +use std::time::Duration; + +use tokio::task; +use uuid::Uuid; + +use crate::models::{Document, DocumentVersion}; +use crate::state::AppState; +use crate::storage::TenantStorage; +use crate::workers::common::{load_document_version, load_version_assets, LoadedAsset}; +use crate::workers::{check_worker_document_limit, fetch_version_object, FetchVersionError}; + +use super::{TaskContext, TaskError, TaskResult}; + +const BLOCKING_RETRY_DELAY: Duration = Duration::from_secs(60); +const DEFAULT_RETRY_DELAY: Duration = Duration::from_secs(30); + +pub struct DocumentVersionTaskContext { + job_id: Uuid, + job_type: &'static str, + tenant_id: Uuid, + document_id: Uuid, + document_version_id: Uuid, + force: bool, + max_document_bytes: u64, + state: Arc, + storage: TenantStorage, + document: Option, + version: Option, + assets: Option>, // keyed by asset_type + object_bytes: Option>, +} + +impl DocumentVersionTaskContext { + #[allow(clippy::too_many_arguments)] + pub fn new( + job_id: Uuid, + job_type: &'static str, + tenant_id: Uuid, + document_id: Uuid, + document_version_id: Uuid, + force: bool, + max_document_bytes: u64, + state: Arc, + storage: TenantStorage, + ) -> Self { + Self { + job_id, + job_type, + tenant_id, + document_id, + document_version_id, + force, + max_document_bytes, + state, + storage, + document: None, + version: None, + assets: None, + object_bytes: None, + } + } + + pub fn tenant_id(&self) -> Uuid { + self.tenant_id + } + + pub fn document_id(&self) -> Uuid { + self.document_id + } + + pub fn version_id(&self) -> Uuid { + self.document_version_id + } + + pub fn force(&self) -> bool { + self.force + } + + pub fn storage(&self) -> &TenantStorage { + &self.storage + } + + pub fn invalidate_asset_cache(&mut self) { + self.assets = None; + } + + pub fn set_document_mime(&mut self, mime: Option) { + if let Some(document) = self.document.as_mut() { + document.mime_type = mime; + } + } + + pub fn state(&self) -> &Arc { + &self.state + } + + pub fn max_document_bytes(&self) -> u64 { + self.max_document_bytes + } + + pub async fn document(&mut self) -> TaskResult<&Document> { + self.ensure_document_loaded().await?; + Ok(self.document.as_ref().expect("document hydrated")) + } + + pub async fn version(&mut self) -> TaskResult<&DocumentVersion> { + self.ensure_document_loaded().await?; + Ok(self.version.as_ref().expect("version hydrated")) + } + + pub async fn assets(&mut self) -> TaskResult<&HashMap> { + if self.assets.is_none() { + let tenant_id = self.tenant_id; + let version_id = self.document_version_id; + let state = self.state.clone(); + let result = task::spawn_blocking(move || { + let mut conn = state + .db_for_tenant(tenant_id) + .map_err(|err| format!("failed to scope tenant connection: {err:?}"))?; + load_version_assets(&mut conn, tenant_id, version_id, &[]) + }) + .await + .map_err(|err| { + TaskError::retry( + BLOCKING_RETRY_DELAY, + format!("asset load task panicked: {err}"), + ) + })? + .map_err(|err| TaskError::retry(DEFAULT_RETRY_DELAY, err))?; + self.assets = Some(result); + } + Ok(self.assets.as_ref().expect("asset map hydrated")) + } + + pub async fn asset(&mut self, asset_type: &str) -> TaskResult> { + let assets = self.assets().await?; + Ok(assets.get(asset_type)) + } + + pub async fn buffered_object(&mut self) -> TaskResult<&[u8]> { + if self.object_bytes.is_some() { + return Ok(self.object_bytes.as_deref().expect("bytes present")); + } + let version = self.version().await?.clone(); + let bytes = fetch_version_object( + &version, + &self.storage, + &version.s3_key, + self.max_document_bytes, + ) + .await + .map_err(|err| match err { + FetchVersionError::TooLarge { size, limit } => TaskError::fail(format!( + "document size {size} bytes exceeds worker limit of {limit} bytes" + )), + FetchVersionError::Storage(err) => TaskError::retry( + DEFAULT_RETRY_DELAY, + format!("failed to fetch object: {err}"), + ), + })?; + self.object_bytes = Some(bytes); + Ok(self.object_bytes.as_deref().expect("bytes hydrated")) + } + + pub async fn object_head(&mut self, max_bytes: usize) -> TaskResult> { + let version = self.version().await?.clone(); + check_worker_document_limit(version.size_bytes, self.max_document_bytes).map_err( + |(size, limit)| { + TaskError::fail(format!( + "document size {size} bytes exceeds worker limit of {limit} bytes" + )) + }, + )?; + + let end = max_bytes.saturating_sub(1) as u64; + self.storage + .get_object_range(&version.s3_key, 0, Some(end)) + .await + .map_err(|err| { + TaskError::retry( + DEFAULT_RETRY_DELAY, + format!("failed to fetch ranged object: {err}"), + ) + }) + } + + async fn ensure_document_loaded(&mut self) -> TaskResult<()> { + if self.document.is_some() && self.version.is_some() { + return Ok(()); + } + + let tenant_id = self.tenant_id; + let document_id = self.document_id; + let version_id = self.document_version_id; + let state = self.state.clone(); + + let loaded = task::spawn_blocking(move || { + load_document_version(state.as_ref(), tenant_id, document_id, version_id) + }) + .await + .map_err(|err| { + TaskError::retry( + BLOCKING_RETRY_DELAY, + format!("document load task panicked: {err}"), + ) + })? + .map_err(|err| TaskError::fail(format!("failed to load document context: {err}")))?; + + self.document = Some(loaded.document); + self.version = Some(loaded.version); + Ok(()) + } +} + +impl TaskContext for DocumentVersionTaskContext { + fn job_id(&self) -> Uuid { + self.job_id + } + + fn job_type(&self) -> &'static str { + self.job_type + } +} diff --git a/backend/src/workers/taskflow/mod.rs b/backend/src/workers/taskflow/mod.rs new file mode 100644 index 0000000..9fc3831 --- /dev/null +++ b/backend/src/workers/taskflow/mod.rs @@ -0,0 +1,85 @@ +use std::time::Duration; + +use async_trait::async_trait; +use thiserror::Error; +use tracing::info; +use uuid::Uuid; + +pub mod document; + +pub type TaskResult = Result; + +#[derive(Debug, Error)] +pub enum TaskError { + #[error("{error}")] + Fail { error: String }, + #[error("{error}")] + Retry { delay: Duration, error: String }, +} + +impl TaskError { + pub fn fail(error: impl Into) -> Self { + Self::Fail { + error: error.into(), + } + } + + pub fn retry(delay: Duration, error: impl Into) -> Self { + Self::Retry { + delay, + error: error.into(), + } + } +} + +pub trait TaskContext: Send + Sync { + fn job_id(&self) -> Uuid; + fn job_type(&self) -> &'static str; +} + +#[async_trait] +pub trait Task: Send + Sync +where + Ctx: TaskContext, +{ + fn name(&self) -> &'static str; + async fn execute(&self, ctx: &mut Ctx) -> TaskResult<()>; +} + +pub type BoxedTask = Box + Send + Sync>; + +#[async_trait] +pub trait TaskPlanner: Send + Sync +where + Ctx: TaskContext, +{ + async fn plan(&self, ctx: &mut Ctx) -> TaskResult>>; +} + +pub struct TaskExecutor; + +impl TaskExecutor { + pub async fn run(planner: &P, ctx: &mut C) -> TaskResult<()> + where + P: TaskPlanner, + C: TaskContext, + { + let tasks = planner.plan(ctx).await?; + for task in tasks { + info!( + job_id = %ctx.job_id(), + job_type = ctx.job_type(), + task = task.name(), + "starting job task" + ); + task.execute(ctx).await?; + info!( + job_id = %ctx.job_id(), + job_type = ctx.job_type(), + task = task.name(), + "finished job task" + ); + } + Ok(()) + } +} diff --git a/backend/src/workers/tenants.rs b/backend/src/workers/tenants.rs new file mode 100644 index 0000000..2a235f9 --- /dev/null +++ b/backend/src/workers/tenants.rs @@ -0,0 +1,640 @@ +use std::sync::Arc; +use std::time::Duration; + +use async_trait::async_trait; +use chrono::{DateTime, Utc}; +use diesel::prelude::*; +use diesel::sql_types::Jsonb; +use hmac::{Hmac, Mac}; +use reqwest::Client; +use serde::Deserialize; +use serde_json::json; +use tracing::warn; +use uuid::Uuid; + +use sha2::Sha256; + +use crate::auth::capability_sets::{ + ensure_capability_set, owner_capabilities, readonly_capabilities, user_capabilities, + webdav_capabilities, +}; +use crate::documents::search::{delete_quickwit_index, ensure_quickwit_index}; +use crate::jobs::{JOB_DELETE_TENANT, JOB_PROVISION_TENANT}; +use crate::models::{NewUserMembership, Tenant, TenantStatus}; +use crate::schema::{ + api_tokens, correspondents, document_assets, document_correspondents, document_tags, + document_versions, documents, folders, tags, tenants, user_memberships, user_sessions, +}; +use crate::state::AppState; +use crate::tenants::TenantRepository; +use crate::workers::{ + job_execution_from_task_error, + taskflow::{BoxedTask, Task, TaskContext, TaskError, TaskExecutor, TaskPlanner, TaskResult}, + JobExecution, JobHandler, +}; + +type HmacSha256 = Hmac; +const DELETE_PROOF_TTL_SECONDS: i64 = 300; +const DELETE_PROOF_VERSION: &str = "v1"; + +pub struct ProvisionTenantJob; + +impl ProvisionTenantJob { + pub fn new() -> Self { + Self + } +} + +#[async_trait] +impl JobHandler for ProvisionTenantJob { + fn job_type(&self) -> &'static str { + JOB_PROVISION_TENANT + } + + async fn handle( + &self, + state: Arc, + job: crate::models::Job, + _storage: crate::storage::TenantStorage, + ) -> JobExecution { + let tenant_id = match job.tenant_id { + Some(id) => id, + None => { + return JobExecution::Failed { + error: "provision job is missing tenant context".to_string(), + } + } + }; + + let members = ProvisionPayload::from_job(&job).unwrap_or_default(); + let mut context = ProvisionContext::new( + job.id, + JOB_PROVISION_TENANT, + tenant_id, + state.clone(), + members, + ); + + let planner = ProvisionPlanner; + match TaskExecutor::run(&planner, &mut context).await { + Ok(()) => JobExecution::Success, + Err(err) => job_execution_from_task_error(err), + } + } +} + +struct ProvisionContext { + job_id: Uuid, + job_type: &'static str, + tenant_id: Uuid, + state: Arc, + members: Vec, +} + +impl ProvisionContext { + fn new( + job_id: Uuid, + job_type: &'static str, + tenant_id: Uuid, + state: Arc, + members: Vec, + ) -> Self { + Self { + job_id, + job_type, + tenant_id, + state, + members, + } + } +} + +impl TaskContext for ProvisionContext { + fn job_id(&self) -> Uuid { + self.job_id + } + + fn job_type(&self) -> &'static str { + self.job_type + } +} + +struct ProvisionPlanner; + +#[async_trait] +impl TaskPlanner for ProvisionPlanner { + async fn plan( + &self, + _ctx: &mut ProvisionContext, + ) -> TaskResult>> { + Ok(vec![Box::new(ProvisionTask)]) + } +} + +struct ProvisionTask; + +#[async_trait] +impl Task for ProvisionTask { + fn name(&self) -> &'static str { + "provision-tenant" + } + + async fn execute(&self, ctx: &mut ProvisionContext) -> TaskResult<()> { + let mut conn = ctx + .state + .db_unscoped() + .map_err(|err| TaskError::retry(Duration::from_secs(30), format!("{err:?}")))?; + + let tenant = TenantRepository::get_by_id(&mut conn, ctx.tenant_id).map_err(|err| { + TaskError::fail(format!("tenant not found for provisioning: {err:?}")) + })?; + drop(conn); + + let mut conn = ctx + .state + .db_for_tenant(tenant.id) + .map_err(|err| TaskError::retry(Duration::from_secs(30), format!("{err:?}")))?; + + if tenant.status == TenantStatus::Active { + warn!( + job_id = %ctx.job_id(), + tenant_id = %tenant.id, + "tenant already active; skipping provisioning" + ); + return Ok(()); + } + + if tenant.status != TenantStatus::Creating { + return Err(TaskError::fail(format!( + "tenant status '{}' not eligible for provisioning", + tenant.status.as_str() + ))); + } + + let endpoint = ctx + .state + .config + .quickwit_endpoint + .as_ref() + .map(|value| value.trim_end_matches('/').to_owned()) + .ok_or_else(|| { + TaskError::retry(Duration::from_secs(30), "quickwit endpoint not configured") + })?; + + let index_id = tenant + .quickwit_index + .as_deref() + .map(str::to_owned) + .unwrap_or_else(|| format!("documents-{}", tenant.id)); + + let client = Client::new(); + ensure_quickwit_index(&client, &endpoint, &index_id) + .await + .map_err(|err| TaskError::retry(Duration::from_secs(30), err.to_string()))?; + + let owner_capability_set_id = + ensure_capability_set(&mut conn, tenant.id, owner_capabilities()) + .map_err(|_| { + TaskError::retry(Duration::from_secs(30), "owner capability set unavailable") + })? + .id; + + ensure_capability_set(&mut conn, tenant.id, user_capabilities()).map_err(|_| { + TaskError::retry(Duration::from_secs(30), "user capability set unavailable") + })?; + ensure_capability_set(&mut conn, tenant.id, readonly_capabilities()).map_err(|_| { + TaskError::retry( + Duration::from_secs(30), + "readonly capability set unavailable", + ) + })?; + ensure_capability_set(&mut conn, tenant.id, webdav_capabilities()).map_err(|_| { + TaskError::retry(Duration::from_secs(30), "webdav capability set unavailable") + })?; + + for member in &ctx.members { + let new_membership = NewUserMembership { + id: Uuid::new_v4(), + user_id: *member, + tenant_id: tenant.id, + capability_set_id: Some(owner_capability_set_id), + }; + + if let Err(err) = diesel::insert_into(user_memberships::table) + .values(&new_membership) + .on_conflict((user_memberships::user_id, user_memberships::tenant_id)) + .do_nothing() + .execute(&mut conn) + { + warn!( + job_id = %ctx.job_id(), + tenant_id = %tenant.id, + user_id = %member, + error = %err, + "failed to assign initial membership" + ); + } + } + + diesel::update(tenants::table.find(tenant.id)) + .set(( + tenants::status.eq(TenantStatus::Active), + tenants::quickwit_index.eq(Some(index_id)), + tenants::updated_at.eq(Utc::now().naive_utc()), + )) + .execute(&mut conn) + .map_err(|err| { + TaskError::retry( + Duration::from_secs(30), + format!("failed to update tenant status: {err}"), + ) + })?; + + Ok(()) + } +} + +async fn delete_tenant( + state: &Arc, + storage: crate::storage::TenantStorage, + tenant_id: Uuid, + current_job_id: Uuid, + payload: &DeleteTenantPayload, +) -> Result<(), String> { + let remove_tenant = payload.remove_tenant; + let tenant = { + let mut conn = state + .db_unscoped() + .map_err(|err| format!("failed to get db connection: {err:?}"))?; + TenantRepository::get_by_id(&mut conn, tenant_id) + .map_err(|err| format!("tenant lookup failed: {err:?}"))? + }; + + if tenant.name != payload.tenant_name { + return Err(format!( + "tenant name mismatch: expected '{}', got '{}'", + payload.tenant_name, tenant.name + )); + } + + if tenant.status != TenantStatus::Deleting { + return Err(format!( + "tenant status '{}' not eligible for deletion", + tenant.status.as_str() + )); + } + + match (payload_action_applicable(remove_tenant), payload.action) { + (DeleteAction::Delete, DeleteAction::Delete) + | (DeleteAction::Reset, DeleteAction::Reset) => {} + _ => { + return Err("delete payload action mismatch".into()); + } + } + + let issued_at = DateTime::parse_from_rfc3339(&payload.issued_at) + .map_err(|_| "invalid issued_at timestamp".to_string())? + .with_timezone(&Utc); + if (Utc::now() - issued_at).num_seconds().abs() > DELETE_PROOF_TTL_SECONDS { + return Err("delete confirmation expired".into()); + } + + let resolved_final_status = if remove_tenant { + None + } else { + Some(payload.final_status.unwrap_or(FinalTenantStatus::Suspended)) + }; + let final_status_str = resolved_final_status.map(|s| s.as_str()); + + let message = build_delete_proof_message( + tenant_id, + &tenant.name, + payload.action, + &payload.nonce, + &payload.issued_at, + final_status_str, + ); + + verify_delete_proof(&state.config.jwt_secret, &message, &payload.signature)?; + + let object_keys = { + let mut conn = state + .db_for_tenant(tenant_id) + .map_err(|err| format!("failed to scope tenant connection: {err:?}"))?; + collect_object_keys(&mut conn) + .map_err(|err| format!("failed to collect storage keys: {err}"))? + }; + + delete_storage_objects(&storage, &object_keys) + .await + .map_err(|err| format!("failed to delete storage objects: {err}"))?; + + reset_quickwit_index(state, &tenant, remove_tenant) + .await + .map_err(|err| format!("quickwit cleanup failed: {err}"))?; + + { + let mut conn = state + .db_for_tenant(tenant_id) + .map_err(|err| format!("failed to scope tenant connection: {err:?}"))?; + delete_tenant_rows(&mut conn, tenant_id, remove_tenant) + .map_err(|err| format!("tenant data cleanup failed: {err}"))?; + } + + { + let mut conn = state + .db_unscoped() + .map_err(|err| format!("failed to get db connection: {err:?}"))?; + + let detach_result = json!({ + "tenant": { + "id": tenant.id, + "name": tenant.name, + }, + "action": payload.action.as_str(), + "remove_tenant": remove_tenant, + "final_status": final_status_str, + "timestamp": Utc::now().to_rfc3339(), + }); + + diesel::sql_query( + "UPDATE jobs \ + SET tenant_id = NULL, \ + result = jsonb_set(COALESCE(result, '{}'::jsonb), '{detached_tenant}', $3::jsonb, true) \ + WHERE tenant_id = $1 AND id <> $2", + ) + .bind::(tenant_id) + .bind::(current_job_id) + .bind::(detach_result) + .execute(&mut conn) + .map_err(|err| format!("failed to detach tenant jobs: {err}"))?; + + if remove_tenant { + diesel::delete(tenants::table.find(tenant_id)) + .execute(&mut conn) + .map_err(|err| format!("failed to delete tenant row: {err}"))?; + } else { + let new_status = match resolved_final_status.unwrap_or(FinalTenantStatus::Suspended) { + FinalTenantStatus::Active => TenantStatus::Active, + FinalTenantStatus::Suspended => TenantStatus::Suspended, + }; + diesel::update(tenants::table.find(tenant_id)) + .set(( + tenants::status.eq(new_status), + tenants::updated_at.eq(Utc::now().naive_utc()), + )) + .execute(&mut conn) + .map_err(|err| format!("failed to update tenant status: {err}"))?; + } + } + + Ok(()) +} + +struct TenantObjectKeys { + version_keys: Vec, + asset_keys: Vec, +} + +fn collect_object_keys(conn: &mut PgConnection) -> Result { + let version_keys = document_versions::table + .select(document_versions::s3_key) + .load::(conn)?; + let asset_keys = document_assets::table + .select(document_assets::s3_key) + .load::(conn)?; + + Ok(TenantObjectKeys { + version_keys, + asset_keys, + }) +} + +async fn delete_storage_objects( + storage: &crate::storage::TenantStorage, + keys: &TenantObjectKeys, +) -> Result<(), String> { + for key in keys.version_keys.iter().chain(keys.asset_keys.iter()) { + storage + .delete_object(key) + .await + .map_err(|err| format!("failed to delete object '{key}': {err}"))?; + } + Ok(()) +} + +async fn reset_quickwit_index( + state: &Arc, + tenant: &Tenant, + remove_index: bool, +) -> Result<(), String> { + let endpoint = match state.config.quickwit_endpoint.as_ref() { + Some(endpoint) => endpoint, + None => return Ok(()), + }; + + let index_id = match tenant.quickwit_index.as_deref() { + Some(index) => index, + None => return Ok(()), + }; + + let client = Client::new(); + delete_quickwit_index(&client, endpoint, index_id) + .await + .map_err(|err| format!("quickwit delete failed: {err}"))?; + + if !remove_index { + ensure_quickwit_index(&client, endpoint, index_id) + .await + .map_err(|err| format!("quickwit ensure failed: {err}"))?; + } + + Ok(()) +} + +fn delete_tenant_rows( + conn: &mut PgConnection, + tenant_id: Uuid, + remove_memberships: bool, +) -> Result<(), diesel::result::Error> { + conn.transaction(|conn| { + diesel::delete(document_assets::table.filter(document_assets::tenant_id.eq(tenant_id))) + .execute(conn)?; + diesel::delete( + document_correspondents::table.filter(document_correspondents::tenant_id.eq(tenant_id)), + ) + .execute(conn)?; + diesel::delete(document_tags::table.filter(document_tags::tenant_id.eq(tenant_id))) + .execute(conn)?; + diesel::delete(document_versions::table.filter(document_versions::tenant_id.eq(tenant_id))) + .execute(conn)?; + diesel::delete(documents::table.filter(documents::tenant_id.eq(tenant_id))) + .execute(conn)?; + diesel::delete(folders::table.filter(folders::tenant_id.eq(tenant_id))).execute(conn)?; + diesel::delete(correspondents::table.filter(correspondents::tenant_id.eq(tenant_id))) + .execute(conn)?; + diesel::delete(tags::table.filter(tags::tenant_id.eq(tenant_id))).execute(conn)?; + diesel::delete(user_sessions::table.filter(user_sessions::tenant_id.eq(tenant_id))) + .execute(conn)?; + diesel::delete(api_tokens::table.filter(api_tokens::tenant_id.eq(tenant_id))) + .execute(conn)?; + if remove_memberships { + diesel::delete( + user_memberships::table.filter(user_memberships::tenant_id.eq(tenant_id)), + ) + .execute(conn)?; + } + Ok(()) + }) +} + +#[derive(Deserialize, Default)] +struct ProvisionPayload { + #[serde(default)] + members: Vec, +} + +impl ProvisionPayload { + fn from_job(job: &crate::models::Job) -> Option> { + serde_json::from_value(job.payload.clone()) + .map(|payload: ProvisionPayload| payload.members) + .ok() + } +} +pub struct DeleteTenantJob; + +impl DeleteTenantJob { + pub fn new() -> Self { + Self + } +} + +pub fn build_delete_proof_message( + tenant_id: Uuid, + tenant_name: &str, + action: DeleteAction, + nonce: &str, + issued_at: &str, + final_status: Option<&str>, +) -> String { + let status = final_status.unwrap_or("none"); + format!( + "{}|{}|{}|{}|{}|{}|{}", + DELETE_PROOF_VERSION, + tenant_id, + tenant_name, + action.as_str(), + nonce, + issued_at, + status + ) +} + +pub fn sign_delete_proof(secret: &str, message: &str) -> Result { + let mut mac = HmacSha256::new_from_slice(secret.as_bytes()) + .map_err(|err| format!("failed to init hmac: {err}"))?; + mac.update(message.as_bytes()); + let bytes = mac.finalize().into_bytes(); + Ok(hex::encode(bytes)) +} + +fn verify_delete_proof(secret: &str, message: &str, signature: &str) -> Result<(), String> { + let signature_bytes = hex::decode(signature) + .map_err(|_| "invalid delete proof signature encoding".to_string())?; + + let mut mac = HmacSha256::new_from_slice(secret.as_bytes()) + .map_err(|err| format!("failed to init hmac: {err}"))?; + mac.update(message.as_bytes()); + mac.verify_slice(&signature_bytes) + .map_err(|_| "delete proof signature mismatch".to_string()) +} + +#[derive(Debug, Deserialize)] +struct DeleteTenantPayload { + #[serde(default)] + remove_tenant: bool, + #[serde(default)] + final_status: Option, + tenant_name: String, + action: DeleteAction, + nonce: String, + issued_at: String, + signature: String, +} + +#[derive(Debug, Deserialize, Clone, Copy)] +#[serde(rename_all = "lowercase")] +enum FinalTenantStatus { + Active, + Suspended, +} + +impl FinalTenantStatus { + fn as_str(&self) -> &'static str { + match self { + FinalTenantStatus::Active => "active", + FinalTenantStatus::Suspended => "suspended", + } + } +} + +#[derive(Debug, Deserialize, Clone, Copy, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum DeleteAction { + Delete, + Reset, +} + +impl DeleteAction { + pub fn as_str(&self) -> &'static str { + match self { + DeleteAction::Delete => "delete", + DeleteAction::Reset => "reset", + } + } +} + +fn payload_action_applicable(remove_tenant: bool) -> DeleteAction { + if remove_tenant { + DeleteAction::Delete + } else { + DeleteAction::Reset + } +} + +#[async_trait] +impl JobHandler for DeleteTenantJob { + fn job_type(&self) -> &'static str { + JOB_DELETE_TENANT + } + + async fn handle( + &self, + state: Arc, + job: crate::models::Job, + storage: crate::storage::TenantStorage, + ) -> JobExecution { + let payload: DeleteTenantPayload = match serde_json::from_value(job.payload.clone()) { + Ok(payload) => payload, + Err(err) => { + return JobExecution::Failed { + error: format!("invalid delete tenant payload: {err}"), + } + } + }; + + let tenant_id = match job.tenant_id { + Some(id) => id, + None => { + return JobExecution::Failed { + error: "delete job is missing tenant context".to_string(), + } + } + }; + + match delete_tenant(&state, storage, tenant_id, job.id, &payload).await { + Ok(()) => JobExecution::Success, + Err(err) => JobExecution::Failed { error: err }, + } + } +} diff --git a/backend/src/workers/thumbnails.rs b/backend/src/workers/thumbnails.rs new file mode 100644 index 0000000..1c053e2 --- /dev/null +++ b/backend/src/workers/thumbnails.rs @@ -0,0 +1,647 @@ +use std::{convert::TryInto, io::Cursor, panic, process::Stdio, sync::Arc, time::Duration}; + +use async_trait::async_trait; +use chrono::Utc; +use diesel::{pg::upsert::excluded, prelude::*}; +use image::{GenericImageView, ImageFormat, ImageReader}; +use pdfium_render::prelude::*; +use serde::Deserialize; +use serde_json::{Map, Value}; +use tokio::{process::Command, task, time::timeout}; +use tracing::{info, warn}; +use uuid::Uuid; + +use crate::{ + documents::asset::delete_asset, + error::AppResult, + models::{Document, DocumentAsset, DocumentVersion, NewDocumentAsset}, + schema::{document_assets, document_versions}, + state::AppState, + utils::storage_paths::document_asset_key, + workers::check_worker_document_limit, +}; + +use super::{ + analyze::determine_thumbnail_support, + taskflow::{document::DocumentVersionTaskContext, Task, TaskContext, TaskError, TaskResult}, +}; + +pub const THUMBNAIL_WIDTH: u32 = 512; +pub const THUMBNAIL_HEIGHT: u32 = 512; +const RENDER_WIDTH: u32 = THUMBNAIL_WIDTH * 4; +const RENDER_HEIGHT: u32 = THUMBNAIL_HEIGHT * 4; +const VIDEO_PRESIGN_TTL: Duration = Duration::from_secs(300); +const FFMPEG_TIMEOUT: Duration = Duration::from_secs(30); +pub const THUMBNAIL_ASSET_TYPE: &str = "thumbnail"; + +pub struct GenerateThumbnailsTask { + force: bool, +} + +impl GenerateThumbnailsTask { + pub fn new(force: bool) -> Self { + Self { force } + } +} + +#[async_trait] +impl Task for GenerateThumbnailsTask { + fn name(&self) -> &'static str { + "generate-thumbnails" + } + + async fn execute(&self, ctx: &mut DocumentVersionTaskContext) -> TaskResult<()> { + let context = build_thumbnail_context(ctx, self.force).await?; + + if context.skip { + info!(job_id = %ctx.job_id(), "thumbnails already exist; skipping"); + return Ok(()); + } + + let generation = if document_is_video(&context.document) { + generate_video_thumbnail(ctx, &context).await? + } else { + let bytes = ctx.buffered_object().await?; + generate_thumbnails(&context.document, bytes).map_err(TaskError::fail)? + }; + + if let Some(page_count) = generation.page_count { + let state = ctx.state().clone(); + let tenant_id = context.tenant_id; + let document_id = context.document.id; + let version_id = context.version.id; + task::spawn_blocking(move || { + persist_document_page_count(state, tenant_id, document_id, version_id, page_count) + }) + .await + .map_err(|err| { + TaskError::retry( + Duration::from_secs(60), + format!("page count task panicked: {err}"), + ) + })? + .map_err(|err| TaskError::retry(Duration::from_secs(30), err))?; + } + + remove_existing_thumbnail_assets(ctx, &context).await; + + let thumbnail_asset_id = Uuid::new_v4(); + + let thumbnail_persistence = upload_generated_asset( + ctx, + &context, + THUMBNAIL_ASSET_TYPE, + thumbnail_asset_id, + &generation.thumbnail, + ) + .await?; + + let asset_persistences = vec![thumbnail_persistence]; + + let state = ctx.state().clone(); + let tenant_id = context.document.tenant_id; + let version_id = context.version.id; + task::spawn_blocking(move || { + persist_assets_metadata(state, tenant_id, version_id, &asset_persistences) + }) + .await + .map_err(|err| { + TaskError::retry( + Duration::from_secs(60), + format!("thumbnail metadata task panicked: {err}"), + ) + })? + .map_err(|err| TaskError::retry(Duration::from_secs(30), err))?; + + ctx.invalidate_asset_cache(); + Ok(()) + } +} + +async fn build_thumbnail_context( + ctx: &mut DocumentVersionTaskContext, + force: bool, +) -> TaskResult { + let document = ctx.document().await?.clone(); + let version = ctx.version().await?.clone(); + let tenant_id = ctx.tenant_id(); + + let (supported, _) = determine_thumbnail_support(&document); + if !supported { + return Ok(ThumbnailContext { + document, + version, + existing_thumbnail: None, + skip: true, + tenant_id, + }); + } + + let assets = ctx.assets().await?; + let existing_thumbnail = assets + .get(THUMBNAIL_ASSET_TYPE) + .map(|entry| entry.asset.clone()); + + let skip = existing_thumbnail.is_some() && !force; + + Ok(ThumbnailContext { + document, + version, + existing_thumbnail, + skip, + tenant_id, + }) +} + +async fn remove_existing_thumbnail_assets( + ctx: &DocumentVersionTaskContext, + context: &ThumbnailContext, +) { + if let Some(existing_thumbnail) = &context.existing_thumbnail { + delete_asset_object(ctx, existing_thumbnail).await; + } +} + +async fn delete_asset_object(ctx: &DocumentVersionTaskContext, asset: &DocumentAsset) { + if let Err(err) = ctx.storage().delete_object(&asset.s3_key).await { + warn!( + job_id = %ctx.job_id(), + error = %err, + s3_key = %asset.s3_key, + "failed to delete existing asset object" + ); + } + + let tenant_id = ctx.tenant_id(); + let asset_id = asset.id; + let state = ctx.state().clone(); + match task::spawn_blocking(move || -> AppResult<()> { + let mut conn = state.db_for_tenant(tenant_id)?; + delete_asset(&mut conn, tenant_id, asset_id) + }) + .await + { + Ok(Ok(())) => {} + Ok(Err(err)) => { + warn!( + job_id = %ctx.job_id(), + asset_id = %asset_id, + error = ?err, + "failed to delete asset metadata" + ); + } + Err(join_err) => { + warn!( + job_id = %ctx.job_id(), + asset_id = %asset_id, + error = %join_err, + "failed to delete asset metadata task panicked" + ); + } + } +} + +async fn upload_generated_asset( + ctx: &DocumentVersionTaskContext, + context: &ThumbnailContext, + asset_type: &str, + asset_id: Uuid, + asset: &GeneratedAsset, +) -> TaskResult { + let image = &asset.image; + + let s3_key = document_asset_key( + context.document.id, + context.version.version_number, + asset_type, + asset_id, + ); + + ctx.storage() + .put_object( + &s3_key, + image.image_bytes.clone(), + Some("image/webp".into()), + None, + ) + .await + .map_err(|err| TaskError::retry(Duration::from_secs(30), err.to_string()))?; + + Ok(AssetPersistence { + asset_type: asset_type.to_string(), + asset_id, + s3_key, + width: image.width, + height: image.height, + }) +} + +struct ThumbnailContext { + document: Document, + version: DocumentVersion, + existing_thumbnail: Option, + skip: bool, + tenant_id: Uuid, +} + +struct GeneratedImage { + image_bytes: Vec, + width: Option, + height: Option, +} + +struct GeneratedAsset { + image: GeneratedImage, +} + +struct GeneratedAssets { + thumbnail: GeneratedAsset, + page_count: Option, +} + +struct AssetPersistence { + asset_type: String, + asset_id: Uuid, + s3_key: String, + width: Option, + height: Option, +} + +fn generate_thumbnails(document: &Document, bytes: &[u8]) -> Result { + if document_is_pdf(document) { + let pdf_assets = generate_pdf_assets(bytes)?; + Ok(GeneratedAssets { + thumbnail: pdf_assets.thumbnail, + page_count: Some(pdf_assets.page_count), + }) + } else { + let thumbnail = generate_image_assets(bytes)?; + Ok(GeneratedAssets { + thumbnail, + page_count: None, + }) + } +} + +async fn generate_video_thumbnail( + ctx: &DocumentVersionTaskContext, + context: &ThumbnailContext, +) -> TaskResult { + if let Err((size, limit)) = + check_worker_document_limit(context.version.size_bytes, ctx.max_document_bytes()) + { + return Err(TaskError::fail(format!( + "document size {size} bytes exceeds worker limit of {limit} bytes" + ))); + } + + let presigned_url = ctx + .storage() + .presign_get_object(&context.version.s3_key, VIDEO_PRESIGN_TTL, None) + .await + .map_err(|err| { + TaskError::retry( + Duration::from_secs(30), + format!("failed to presign video for thumbnail: {err}"), + ) + })?; + + let probe = probe_video_metadata(&presigned_url) + .await + .map_err(TaskError::fail)?; + let timestamp = pick_thumbnail_timestamp(probe.duration); + + let frame_bytes = extract_video_frame(&presigned_url, timestamp) + .await + .map_err(TaskError::fail)?; + + let thumbnail = generate_image_assets(&frame_bytes).map_err(TaskError::fail)?; + + Ok(GeneratedAssets { + thumbnail, + page_count: None, + }) +} + +fn generate_image_assets(bytes: &[u8]) -> Result { + let reader = ImageReader::new(Cursor::new(bytes)) + .with_guessed_format() + .map_err(|err| err.to_string())?; + let image = reader.decode().map_err(|err| err.to_string())?; + + let render_image = if image.width() > RENDER_WIDTH || image.height() > RENDER_HEIGHT { + image.thumbnail(RENDER_WIDTH, RENDER_HEIGHT) + } else { + image.clone() + }; + + let thumbnail_image = + if render_image.width() > THUMBNAIL_WIDTH || render_image.height() > THUMBNAIL_HEIGHT { + render_image.thumbnail(THUMBNAIL_WIDTH, THUMBNAIL_HEIGHT) + } else { + render_image.clone() + }; + + let thumbnail = encode_dynamic_image(thumbnail_image)?; + + Ok(GeneratedAsset { image: thumbnail }) +} + +struct PdfGeneratedAssets { + thumbnail: GeneratedAsset, + page_count: u32, +} + +fn generate_pdf_assets(bytes: &[u8]) -> Result { + let pdfium = panic::catch_unwind(|| Pdfium::default()) + .map_err(|_| "failed to initialize PDFium".to_string())?; + + let document = pdfium + .load_pdf_from_byte_slice(bytes, None) + .map_err(|err| format!("load pdf: {err}"))?; + + let pages = document.pages(); + let total_pages = pages.len() as usize; + if total_pages == 0 { + return Err("pdf has no pages".to_string()); + } + + let render_config = PdfRenderConfig::new() + .set_target_width(RENDER_WIDTH as i32) + .set_maximum_height(RENDER_HEIGHT as i32) + .render_form_data(true) + .rotate_if_landscape(PdfPageRenderRotation::None, true); + + let first_page = pages.get(0).map_err(|err| format!("load page 0: {err}"))?; + + let bitmap = first_page + .render_with_config(&render_config) + .map_err(|err| format!("render pdf page 0: {err}"))?; + + let render_buffer = bitmap.as_image().to_rgb8(); + let render_image = image::DynamicImage::ImageRgb8(render_buffer); + + let thumbnail_image = + if render_image.width() > THUMBNAIL_WIDTH || render_image.height() > THUMBNAIL_HEIGHT { + render_image.thumbnail(THUMBNAIL_WIDTH, THUMBNAIL_HEIGHT) + } else { + render_image.clone() + }; + + let page_count: u32 = total_pages + .try_into() + .map_err(|_| "page count exceeds supported range".to_string())?; + + Ok(PdfGeneratedAssets { + thumbnail: GeneratedAsset { + image: encode_dynamic_image(thumbnail_image)?, + }, + page_count, + }) +} + +#[derive(Deserialize)] +struct FfprobeOutput { + format: Option, +} + +#[derive(Deserialize)] +struct FfprobeFormat { + duration: Option, +} + +struct VideoProbe { + duration: Option, +} + +async fn probe_video_metadata(url: &str) -> Result { + let mut cmd = Command::new("ffprobe"); + cmd.arg("-v") + .arg("error") + .arg("-show_entries") + .arg("format=duration") + .arg("-of") + .arg("json") + .arg(url) + .stdout(Stdio::piped()); + + let output = timeout(FFMPEG_TIMEOUT, cmd.output()) + .await + .map_err(|_| "ffprobe timed out".to_string())? + .map_err(|err| format!("ffprobe failed to start: {err}"))?; + + if !output.status.success() { + return Err(format!( + "ffprobe exited with status {}: {}", + output.status, + String::from_utf8_lossy(&output.stderr) + )); + } + + let parsed: FfprobeOutput = serde_json::from_slice(&output.stdout) + .map_err(|err| format!("failed to parse ffprobe output: {err}"))?; + + let duration = parsed + .format + .and_then(|format| format.duration) + .and_then(|dur| dur.parse::().ok()); + + Ok(VideoProbe { duration }) +} + +fn pick_thumbnail_timestamp(duration: Option) -> f64 { + if let Some(duration) = duration { + if duration.is_finite() && duration > 0.0 { + let target = duration * 0.2; + let end = (duration - 1.0).max(0.0); + return target.max(2.0).min(end).max(0.0); + } + } + 2.0 +} + +async fn extract_video_frame(url: &str, timestamp_secs: f64) -> Result, String> { + let timestamp_arg = format!("{timestamp_secs:.3}"); + + let mut cmd = Command::new("ffmpeg"); + cmd.arg("-hide_banner") + .arg("-loglevel") + .arg("error") + .arg("-nostdin") + .arg("-ss") + .arg(timestamp_arg) + .arg("-i") + .arg(url) + .arg("-frames:v") + .arg("1") + .arg("-f") + .arg("image2pipe") + .arg("-vcodec") + .arg("png") + .arg("-") + .stdout(Stdio::piped()); + + let output = timeout(FFMPEG_TIMEOUT, cmd.output()) + .await + .map_err(|_| "ffmpeg timed out".to_string())? + .map_err(|err| format!("ffmpeg failed to start: {err}"))?; + + if !output.status.success() { + return Err(format!( + "ffmpeg exited with status {}: {}", + output.status, + String::from_utf8_lossy(&output.stderr) + )); + } + + if output.stdout.is_empty() { + return Err("ffmpeg produced no frame data".to_string()); + } + + Ok(output.stdout) +} + +fn encode_dynamic_image(image: image::DynamicImage) -> Result { + let (width, height) = image.dimensions(); + let mut cursor = Cursor::new(Vec::new()); + image + .write_to(&mut cursor, ImageFormat::WebP) + .map_err(|err| err.to_string())?; + Ok(GeneratedImage { + image_bytes: cursor.into_inner(), + width: Some(width as i32), + height: Some(height as i32), + }) +} + +fn persist_assets_metadata( + state: Arc, + tenant_id: Uuid, + version_id: Uuid, + assets: &[AssetPersistence], +) -> Result<(), String> { + let mut conn = state + .db_for_tenant(tenant_id) + .map_err(|err| format!("{err:?}"))?; + + for asset in assets { + let mut metadata_map = Map::new(); + if let Some(width) = asset.width { + metadata_map.insert("width".to_string(), Value::from(width)); + } + if let Some(height) = asset.height { + metadata_map.insert("height".to_string(), Value::from(height)); + } + metadata_map.insert( + "generated_at".to_string(), + Value::from(Utc::now().to_rfc3339()), + ); + + let new_asset = NewDocumentAsset { + id: asset.asset_id, + document_version_id: version_id, + asset_type: asset.asset_type.clone(), + mime_type: "image/webp".to_string(), + metadata: Value::Object(metadata_map), + s3_key: asset.s3_key.clone(), + tenant_id, + }; + + diesel::insert_into(document_assets::table) + .values(&new_asset) + .on_conflict(( + document_assets::document_version_id, + document_assets::asset_type, + )) + .do_update() + .set(( + document_assets::mime_type.eq(excluded(document_assets::mime_type)), + document_assets::metadata.eq(excluded(document_assets::metadata)), + document_assets::s3_key.eq(excluded(document_assets::s3_key)), + )) + .execute(&mut conn) + .map_err(|err| format!("{err:?}"))?; + } + + Ok(()) +} + +fn persist_document_page_count( + state: Arc, + tenant_id: Uuid, + document_id: Uuid, + document_version_id: Uuid, + page_count: u32, +) -> Result<(), String> { + let mut conn = state + .db_for_tenant(tenant_id) + .map_err(|err| format!("{err:?}"))?; + + let existing_metadata: Value = document_versions::table + .filter(document_versions::id.eq(document_version_id)) + .filter(document_versions::document_id.eq(document_id)) + .filter(document_versions::tenant_id.eq(tenant_id)) + .select(document_versions::metadata) + .first(&mut conn) + .map_err(|err| format!("{err:?}"))?; + + let updated = match existing_metadata { + Value::Object(mut map) => { + map.insert("page_count".to_string(), Value::from(page_count)); + Value::Object(map) + } + _ => { + let mut map = Map::new(); + map.insert("page_count".to_string(), Value::from(page_count)); + Value::Object(map) + } + }; + + diesel::update( + document_versions::table + .filter(document_versions::id.eq(document_version_id)) + .filter(document_versions::document_id.eq(document_id)) + .filter(document_versions::tenant_id.eq(tenant_id)), + ) + .set(document_versions::metadata.eq(updated)) + .execute(&mut conn) + .map_err(|err| format!("{err:?}"))?; + + Ok(()) +} + +fn document_is_video(document: &Document) -> bool { + const VIDEO_MIME_TYPES: [&str; 6] = [ + "video/mp4", + "video/quicktime", + "video/webm", + "video/x-msvideo", + "video/x-ms-wmv", + "video/x-matroska", + ]; + if let Some(mime) = document.mime_type.as_deref() { + if VIDEO_MIME_TYPES + .iter() + .any(|candidate| mime.eq_ignore_ascii_case(candidate)) + { + return true; + } + } + + false +} + +fn document_is_pdf(document: &Document) -> bool { + document + .mime_type + .as_deref() + .map(|mime| mime.eq_ignore_ascii_case("application/pdf")) + .unwrap_or_else(|| { + document + .original_name + .rsplit('.') + .next() + .map(|ext| ext.eq_ignore_ascii_case("pdf")) + .unwrap_or(false) + }) +} diff --git a/backend/tests/api_tokens_flow.rs b/backend/tests/api_tokens_flow.rs new file mode 100644 index 0000000..032f23d --- /dev/null +++ b/backend/tests/api_tokens_flow.rs @@ -0,0 +1,402 @@ +use anyhow::{Context, Result}; +use axum::body::Body; +use axum::http::{header, Method, Request, StatusCode}; +use base64::engine::general_purpose::STANDARD as BASE64; +use base64::Engine; +use diesel::prelude::*; +use diesel::OptionalExtension; +use papercrate::auth::capability_sets; +use papercrate::models::{ApiCapability, ApiToken}; +use papercrate::routes::webdav; +use papercrate::schema::api_tokens; +use papercrate::schema::capability_sets::dsl as capability_sets_dsl; +use papercrate::test_support::{acquire_db_lock, body_to_vec, TestApp, TestUserRole}; +use serde::Deserialize; +use serde_json::json; +use tower::ServiceExt; +use uuid::Uuid; + +const LEGACY_WEBDAV_CAPS: &[&str] = &[ + "documents:edit", + "documents:read", + "documents:upload", + "documents:write", + "folders:edit", + "folders:read", + "folders:write", + "webdav:read", +]; + +const READ_ONLY_CAPS: &[&str] = &["documents:read"]; +const LIMITED_WEBDAV_CAPS: &[&str] = &["documents:read", "webdav:read"]; + +#[derive(Debug, Deserialize)] +struct TokenInfo { + id: Uuid, + label: Option, + last_used_at: Option, + revoked_at: Option, + capability_set_id: Uuid, +} + +#[derive(Debug, Deserialize)] +struct CreateTokenResponse { + token: String, + #[serde(rename = "token_info")] + info: TokenInfo, +} + +#[derive(Debug, Deserialize)] +struct LoginResponseView { + access_token: String, + token_type: String, + expires_in: i64, + tenant: TenantView, +} + +#[derive(Debug, Deserialize)] +struct TenantView { + id: Uuid, + name: String, +} + +#[tokio::test] +async fn api_token_crud_flow() -> Result<()> { + let _guard = acquire_db_lock().await; + let app = TestApp::new().await?; + + let username = "alice"; + let password = "correct horse battery"; + app.insert_user(username, TestUserRole::Owner).await?; + let access_token = app.login_token(username, password).await?; + + let legacy_set_id = + ensure_capability_set_slug(&app, &access_token, "legacy_webdav", LEGACY_WEBDAV_CAPS) + .await?; + + let created = create_token(&app, &access_token, Some("dav"), legacy_set_id, None).await?; + let token_id = created.info.id; + assert_eq!(created.info.label.as_deref(), Some("dav")); + assert!(created.info.last_used_at.is_none()); + assert_eq!(created.info.capability_set_id, legacy_set_id); + + let regenerated = regenerate_token(&app, &access_token, token_id).await?; + assert_eq!(regenerated.info.id, token_id); + assert_ne!(regenerated.token, created.token); + assert!(regenerated.info.last_used_at.is_none()); + + let listed = list_tokens(&app, &access_token).await?; + assert_eq!(listed.len(), 1); + assert_eq!(listed[0].id, token_id); + assert_eq!(listed[0].capability_set_id, legacy_set_id); + + let tenant_id_for_token = app + .with_conn(move |conn| { + let tenant_id = api_tokens::table + .find(token_id) + .select(api_tokens::tenant_id) + .first::(conn)?; + Ok::<_, anyhow::Error>(tenant_id) + }) + .await?; + + let readonly_set_id = + ensure_capability_set_slug(&app, &access_token, "readonly", READ_ONLY_CAPS).await?; + + let readonly_token = + create_token(&app, &access_token, Some("readonly"), readonly_set_id, None).await?; + let readonly_exchange = exchange_token(&app, &readonly_token.token).await?; + assert_eq!(readonly_exchange.tenant.id, tenant_id_for_token); + delete_token(&app, &access_token, readonly_token.info.id).await?; + + let exchange = exchange_token(&app, ®enerated.token).await?; + assert_eq!(exchange.token_type, "Bearer"); + assert!(!exchange.access_token.is_empty()); + assert!(exchange.expires_in > 0); + assert_eq!(exchange.tenant.id, tenant_id_for_token); + assert!(!exchange.tenant.name.is_empty()); + + delete_token(&app, &access_token, token_id).await?; + + let listed_after = list_tokens(&app, &access_token).await?; + let revoked_entry = listed_after + .iter() + .find(|entry| entry.id == token_id) + .expect("revoked token still listed"); + assert!(revoked_entry.revoked_at.is_some()); + + app.cleanup().await?; + Ok(()) +} + +#[tokio::test] +async fn webdav_basic_auth_uses_api_tokens() -> Result<()> { + let _guard = acquire_db_lock().await; + let app = TestApp::new().await?; + + let username = "bruce"; + let password = "wayne"; + app.insert_user(username, TestUserRole::Owner).await?; + let access_token = app.login_token(username, password).await?; + + let legacy_set_id = + ensure_capability_set_slug(&app, &access_token, "legacy_webdav", LEGACY_WEBDAV_CAPS) + .await?; + + let created = create_token(&app, &access_token, Some("webdav"), legacy_set_id, None).await?; + let token_id = created.info.id; + + let router = webdav::create_router().with_state(app.state.clone()); + let original_secret = created.token.clone(); + let auth_header = format!( + "Basic {}", + BASE64.encode(format!("{}:{}", username, original_secret)) + ); + + let propfind = Method::from_bytes(b"PROPFIND")?; + let success_request = Request::builder() + .method(propfind.clone()) + .uri("/") + .header(header::AUTHORIZATION, auth_header.clone()) + .header("depth", "0") + .body(Body::empty())?; + let response = router.clone().oneshot(success_request).await?; + assert_eq!(response.status(), StatusCode::MULTI_STATUS); + + let used = app + .with_conn(move |conn| { + let record = api_tokens::table.find(token_id).first::(conn)?; + Ok::<_, anyhow::Error>(record.last_used_at) + }) + .await?; + assert!(used.is_some()); + + let regenerated = regenerate_token(&app, &access_token, token_id).await?; + assert_ne!(regenerated.token, original_secret); + + let unused_after_regen = app + .with_conn(move |conn| { + let record = api_tokens::table.find(token_id).first::(conn)?; + Ok::<_, anyhow::Error>(record.last_used_at) + }) + .await?; + assert!(unused_after_regen.is_none()); + + let old_secret_request = Request::builder() + .method(propfind.clone()) + .uri("/") + .header( + header::AUTHORIZATION, + format!( + "Basic {}", + BASE64.encode(format!("{}:{}", username, original_secret)) + ), + ) + .header("depth", "0") + .body(Body::empty())?; + let old_secret_response = router.clone().oneshot(old_secret_request).await?; + assert_eq!(old_secret_response.status(), StatusCode::UNAUTHORIZED); + + let new_secret_header = format!( + "Basic {}", + BASE64.encode(format!("{}:{}", username, regenerated.token)) + ); + + let success_request = Request::builder() + .method(propfind.clone()) + .uri("/") + .header(header::AUTHORIZATION, new_secret_header.clone()) + .header("depth", "0") + .body(Body::empty())?; + let response = router.clone().oneshot(success_request).await?; + assert_eq!(response.status(), StatusCode::MULTI_STATUS); + + // Token without webdav_read cannot authenticate. + let read_only_set_id = + ensure_capability_set_slug(&app, &access_token, "documents_read", READ_ONLY_CAPS).await?; + + let limited_token = + create_token(&app, &access_token, Some("limited"), read_only_set_id, None).await?; + assert_eq!(limited_token.info.capability_set_id, read_only_set_id); + + let limited_header = format!( + "Basic {}", + BASE64.encode(format!("{}:{}", username, limited_token.token)) + ); + + let limited_request = Request::builder() + .method(propfind.clone()) + .uri("/") + .header(header::AUTHORIZATION, limited_header.clone()) + .header("depth", "0") + .body(Body::empty())?; + let limited_response = router.clone().oneshot(limited_request).await?; + assert_eq!(limited_response.status(), StatusCode::UNAUTHORIZED); + + let limited_set_id = ensure_capability_set_slug( + &app, + &access_token, + "documents_read_webdav", + LIMITED_WEBDAV_CAPS, + ) + .await?; + + let upgraded_token = create_token( + &app, + &access_token, + Some("limited-webdav"), + limited_set_id, + None, + ) + .await?; + assert_eq!(upgraded_token.info.capability_set_id, limited_set_id); + + let upgraded_request = Request::builder() + .method(propfind.clone()) + .uri("/") + .header( + header::AUTHORIZATION, + format!( + "Basic {}", + BASE64.encode(format!("{}:{}", username, upgraded_token.token)) + ), + ) + .header("depth", "0") + .body(Body::empty())?; + let upgraded_response = router.clone().oneshot(upgraded_request).await?; + assert_eq!(upgraded_response.status(), StatusCode::MULTI_STATUS); + + delete_token(&app, &access_token, token_id).await?; + + let failure_request = Request::builder() + .method(propfind) + .uri("/") + .header(header::AUTHORIZATION, new_secret_header) + .header("depth", "0") + .body(Body::empty())?; + let failure_response = router.oneshot(failure_request).await?; + assert_eq!(failure_response.status(), StatusCode::UNAUTHORIZED); + + app.cleanup().await?; + Ok(()) +} + +async fn create_token( + app: &TestApp, + access_token: &str, + label: Option<&str>, + capability_set_id: Uuid, + expires_at: Option<&str>, +) -> Result { + let mut payload = json!({ + "capability_set_id": capability_set_id, + }); + + if let Some(label) = label { + payload["label"] = json!(label); + } + + if let Some(expires) = expires_at { + payload["expires_at"] = json!(expires); + } + + let response = app + .post_json("/api/profile/api-tokens", &payload, Some(access_token)) + .await?; + assert_eq!(response.status(), StatusCode::CREATED); + let body = body_to_vec(response.into_body()).await?; + Ok(serde_json::from_slice(&body)?) +} + +async fn regenerate_token( + app: &TestApp, + access_token: &str, + token_id: Uuid, +) -> Result { + let response = app + .post_json( + &format!("/api/profile/api-tokens/{token_id}/regenerate"), + &json!({}), + Some(access_token), + ) + .await?; + assert_eq!(response.status(), StatusCode::OK); + let body = body_to_vec(response.into_body()).await?; + Ok(serde_json::from_slice(&body)?) +} + +async fn list_tokens(app: &TestApp, access_token: &str) -> Result> { + let response = app + .get("/api/profile/api-tokens", Some(access_token)) + .await?; + assert_eq!(response.status(), StatusCode::OK); + let body = body_to_vec(response.into_body()).await?; + Ok(serde_json::from_slice(&body)?) +} + +async fn delete_token(app: &TestApp, access_token: &str, token_id: Uuid) -> Result<()> { + let response = app + .delete( + &format!("/api/profile/api-tokens/{token_id}"), + Some(access_token), + ) + .await?; + assert_eq!(response.status(), StatusCode::NO_CONTENT); + Ok(()) +} + +async fn ensure_capability_set_slug( + app: &TestApp, + access_token: &str, + slug: &str, + capabilities: &[&str], +) -> Result { + let claims = app + .state + .jwt + .verify_token(access_token) + .context("failed to decode access token claims")?; + let tenant_id = claims.tenant_id; + let slug = slug.to_string(); + let desired_capabilities = capabilities + .iter() + .map(|value| { + value + .parse::() + .map_err(|err| anyhow::anyhow!("invalid capability '{value}': {err}")) + }) + .collect::>>()?; + + let caps_for_insert = desired_capabilities.clone(); + app.with_conn(move |conn| { + if let Some(existing) = capability_sets_dsl::capability_sets + .filter(capability_sets_dsl::tenant_id.eq(tenant_id)) + .filter(capability_sets_dsl::slug.eq(&slug)) + .select(capability_sets_dsl::id) + .first::(conn) + .optional()? + { + return Ok(existing); + } + + let created = + capability_sets::create_capability_set(conn, tenant_id, &slug, caps_for_insert) + .map_err(|err| { + anyhow::anyhow!("failed to create capability set '{slug}': {err:?}") + })?; + Ok(created.id) + }) + .await +} +async fn exchange_token(app: &TestApp, api_token: &str) -> Result { + let response = app + .post_json( + "/api/auth/exchange-api-token", + &json!({ "api_token": api_token }), + None, + ) + .await?; + assert_eq!(response.status(), StatusCode::OK); + let body = body_to_vec(response.into_body()).await?; + Ok(serde_json::from_slice(&body)?) +} diff --git a/backend/tests/auth_flow.rs b/backend/tests/auth_flow.rs new file mode 100644 index 0000000..bfdf6f2 --- /dev/null +++ b/backend/tests/auth_flow.rs @@ -0,0 +1,745 @@ +use anyhow::{anyhow, Context, Result}; +use axum::http::{header::SET_COOKIE, StatusCode}; +use chrono::{Duration as ChronoDuration, Utc}; +use diesel::prelude::*; +use papercrate::auth::capability_sets::{ensure_capability_set, owner_capabilities}; +use papercrate::auth::jwt::{AccessTokenContext, PrincipalKind}; +use papercrate::auth::passkeys::{ + PasskeyLoginFinishPayload, PasskeyLoginStartPayload, PasskeyRegistrationFinishPayload, + RegistrationChallengeResponse, +}; +use papercrate::models::{ + MagicToken, MagicTokenKind, NewUserMembership, NewUserSession, TenantStatus, UserPasskey, +}; +use papercrate::openapi::schemas::PasskeySummary; +use papercrate::schema::{ + capability_sets, magic_tokens::dsl as magic_dsl, tenants, tenants::dsl as tenant_dsl, + user_memberships, user_sessions, users, +}; +use papercrate::test_support::{acquire_db_lock, body_to_vec, TestApp, TestUserRole}; +use rand::{rngs::OsRng, TryRngCore}; +use serde::Deserialize; +use serde_json::json; +use sha2::{Digest, Sha256}; +use uuid::Uuid; +use webauthn_rs_core::proto::{ + AuthenticatorAssertionResponseRaw, AuthenticatorAttestationResponseRaw, PublicKeyCredential, + RegisterPublicKeyCredential, +}; + +#[derive(Deserialize)] +struct AuthenticatedUser { + username: String, +} + +#[derive(Deserialize)] +struct ApiErrorResponse { + error: String, + #[serde(default)] + _code: Option, +} + +#[derive(Deserialize)] +struct LoginTenant { + id: Uuid, + name: String, +} + +#[derive(Deserialize)] +struct LoginResponse { + access_token: String, + tenant: LoginTenant, +} + +#[derive(Deserialize)] +struct SignupStartResponse { + signup_token: String, + challenge: RegistrationChallengeResponse, +} + +#[derive(Deserialize)] +struct TenantSummary { + id: Uuid, + name: String, +} + +#[tokio::test] +async fn login_and_me_roundtrip() -> Result<()> { + let _lock = acquire_db_lock().await; + let app = TestApp::new().await?; + + let password = "s3cret"; + app.insert_user("alice", TestUserRole::Owner).await?; + + let (login, _) = login_with_session(&app, "alice", password).await?; + + let response = app.get("/api/auth/me", Some(&login.access_token)).await?; + assert_eq!(response.status(), StatusCode::OK); + let body = body_to_vec(response.into_body()).await?; + let user: AuthenticatedUser = serde_json::from_slice(&body)?; + + assert_eq!(user.username, "alice"); + + app.cleanup().await?; + Ok(()) +} + +#[tokio::test] +async fn login_rejects_unknown_user() -> Result<()> { + let _lock = acquire_db_lock().await; + let app = TestApp::new().await?; + + let payload = json!({ "username": "ghost", "password": "nope" }); + let response = app.post_json("/api/auth/login", &payload, None).await?; + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + let body = body_to_vec(response.into_body()).await?; + let err: ApiErrorResponse = serde_json::from_slice(&body)?; + assert_eq!(err.error, "password authentication is no longer supported"); + + app.cleanup().await?; + Ok(()) +} + +#[tokio::test] +async fn signup_start_and_finish_require_valid_passkey() -> Result<()> { + let _lock = acquire_db_lock().await; + let app = TestApp::new().await?; + + let payload = json!({ "username": "signup-user" }); + + let response = app + .post_json("/api/auth/signup/start", &payload, None) + .await?; + assert_eq!(response.status(), StatusCode::OK); + let body = body_to_vec(response.into_body()).await?; + let start: SignupStartResponse = serde_json::from_slice(&body)?; + assert!(!start.signup_token.is_empty()); + assert_ne!(start.challenge.challenge_id, Uuid::nil()); + + app.with_conn(|conn| { + let exists: bool = diesel::select(diesel::dsl::exists( + users::table.filter(users::username.eq("signup-user")), + )) + .get_result(conn)?; + assert!(!exists); + Ok(()) + }) + .await?; + + let finish_payload = json!({ + "signup_token": start.signup_token, + "credential": fake_register_credential(), + }); + let finish_response = app + .post_json("/api/auth/signup/finish", &finish_payload, None) + .await?; + assert_eq!(finish_response.status(), StatusCode::BAD_REQUEST); + + app.cleanup().await?; + Ok(()) +} + +#[tokio::test] +async fn passkey_register_start_creates_challenge() -> Result<()> { + let _lock = acquire_db_lock().await; + let app = TestApp::new().await?; + + let password = "secret"; + app.insert_user("passkey-user", TestUserRole::Owner).await?; + + let (login, _) = login_with_session(&app, "passkey-user", password).await?; + + let response = app + .post_json( + "/api/auth/passkeys/register/start", + &json!({}), + Some(&login.access_token), + ) + .await?; + + assert_eq!(response.status(), StatusCode::OK); + let body = body_to_vec(response.into_body()).await?; + let challenge: RegistrationChallengeResponse = serde_json::from_slice(&body)?; + assert_ne!(challenge.challenge_id, Uuid::nil()); + let challenge_id = challenge.challenge_id; + + app.with_conn(move |conn| { + use diesel::dsl::{exists, select}; + use papercrate::schema::webauthn_challenges::dsl; + + let exists: bool = select(exists( + dsl::webauthn_challenges.filter(dsl::id.eq(challenge_id)), + )) + .get_result(conn)?; + assert!(exists, "challenge not persisted"); + Ok(()) + }) + .await?; + + app.cleanup().await?; + Ok(()) +} + +#[tokio::test] +async fn passkey_register_finish_rejects_unknown_challenge() -> Result<()> { + let _lock = acquire_db_lock().await; + let app = TestApp::new().await?; + + let password = "secret"; + app.insert_user("passkey-register", TestUserRole::Owner) + .await?; + let (login, _) = login_with_session(&app, "passkey-register", password).await?; + + let payload = PasskeyRegistrationFinishPayload { + challenge_id: Uuid::new_v4(), + credential: fake_register_credential(), + nickname: None, + }; + + let response = app + .post_json( + "/api/auth/passkeys/register/finish", + &payload, + Some(&login.access_token), + ) + .await?; + + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + + app.cleanup().await?; + Ok(()) +} + +#[tokio::test] +async fn passkey_login_start_requires_passkey() -> Result<()> { + let _lock = acquire_db_lock().await; + let app = TestApp::new().await?; + + let _password = "secret"; + app.insert_user("passkey-login", TestUserRole::Owner) + .await?; + + let payload = PasskeyLoginStartPayload { + username: "passkey-login".to_string(), + }; + + let response = app + .post_json("/api/auth/passkeys/login/start", &payload, None) + .await?; + + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + + app.cleanup().await?; + Ok(()) +} + +#[tokio::test] +async fn passkey_login_start_unknown_user_returns_not_found() -> Result<()> { + let _lock = acquire_db_lock().await; + let app = TestApp::new().await?; + + let payload = PasskeyLoginStartPayload { + username: "nobody".to_string(), + }; + + let response = app + .post_json("/api/auth/passkeys/login/start", &payload, None) + .await?; + + assert_eq!(response.status(), StatusCode::NOT_FOUND); + + app.cleanup().await?; + Ok(()) +} + +#[tokio::test] +async fn passkey_login_finish_rejects_invalid_challenge() -> Result<()> { + let _lock = acquire_db_lock().await; + let app = TestApp::new().await?; + + let payload = PasskeyLoginFinishPayload { + challenge_id: Uuid::new_v4(), + credential: fake_authentication_credential(), + }; + + let response = app + .post_json("/api/auth/passkeys/login/finish", &payload, None) + .await?; + + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + + app.cleanup().await?; + Ok(()) +} + +#[tokio::test] +async fn list_passkeys_returns_entries() -> Result<()> { + let _lock = acquire_db_lock().await; + let app = TestApp::new().await?; + + let password = "secret"; + let user_id = app + .insert_user("passkey-owner", TestUserRole::Owner) + .await?; + app.insert_passkey(user_id, Some("Laptop")).await?; + + let (session, _) = login_with_session(&app, "passkey-owner", password).await?; + + let response = app + .get("/api/profile/passkeys", Some(&session.access_token)) + .await?; + assert_eq!(response.status(), StatusCode::OK); + + let body = body_to_vec(response.into_body()).await?; + let summaries: Vec = serde_json::from_slice(&body)?; + assert_eq!(summaries.len(), 1); + assert_eq!(summaries[0].nickname.as_deref(), Some("Laptop")); + assert!(summaries[0].revoked_at.is_none()); + + app.cleanup().await?; + Ok(()) +} + +#[tokio::test] +async fn delete_passkey_soft_revokes() -> Result<()> { + let _lock = acquire_db_lock().await; + let app = TestApp::new().await?; + + let password = "secret"; + let user_id = app + .insert_user("passkey-delete", TestUserRole::Owner) + .await?; + let passkey_id = app.insert_passkey(user_id, Some("Phone")).await?; + app.insert_passkey(user_id, Some("Backup")).await?; + let (session, _) = login_with_session(&app, "passkey-delete", password).await?; + + let response = app + .delete( + &format!("/api/profile/passkeys/{}?reason=lost", passkey_id), + Some(&session.access_token), + ) + .await?; + assert_eq!(response.status(), StatusCode::NO_CONTENT); + + app.with_conn(move |conn| { + use papercrate::schema::user_passkeys::dsl as passkey_dsl; + + let record = passkey_dsl::user_passkeys + .find(passkey_id) + .first::(conn)?; + assert!(record.revoked_at.is_some()); + assert_eq!(record.revoked_reason.as_deref(), Some("lost")); + Ok(()) + }) + .await?; + + app.cleanup().await?; + Ok(()) +} + +#[tokio::test] +async fn delete_passkey_prevents_last() -> Result<()> { + let _lock = acquire_db_lock().await; + let app = TestApp::new().await?; + + let password = "secret"; + let user_id = app + .insert_user("passkey-guard", TestUserRole::Owner) + .await?; + let first_id = app.insert_passkey(user_id, Some("Key A")).await?; + let last_id = app.insert_passkey(user_id, Some("Key B")).await?; + let (session, _) = login_with_session(&app, "passkey-guard", password).await?; + + let response = app + .delete( + &format!("/api/profile/passkeys/{}", first_id), + Some(&session.access_token), + ) + .await?; + assert_eq!(response.status(), StatusCode::NO_CONTENT); + + let block_response = app + .delete( + &format!("/api/profile/passkeys/{}", last_id), + Some(&session.access_token), + ) + .await?; + assert_eq!(block_response.status(), StatusCode::BAD_REQUEST); + + app.cleanup().await?; + Ok(()) +} + +fn fake_register_credential() -> RegisterPublicKeyCredential { + RegisterPublicKeyCredential { + id: "fake-passkey".to_string(), + raw_id: vec![1, 2, 3, 4].into(), + response: AuthenticatorAttestationResponseRaw { + attestation_object: vec![5, 6, 7, 8].into(), + client_data_json: vec![9, 10, 11, 12].into(), + transports: None, + }, + type_: "public-key".to_string(), + extensions: Default::default(), + } +} + +fn fake_authentication_credential() -> PublicKeyCredential { + PublicKeyCredential { + id: "fake-auth".to_string(), + raw_id: vec![1, 2, 3].into(), + response: AuthenticatorAssertionResponseRaw { + authenticator_data: vec![4, 5, 6].into(), + client_data_json: vec![7, 8, 9].into(), + signature: vec![10, 11, 12].into(), + user_handle: None, + }, + extensions: Default::default(), + type_: "public-key".to_string(), + } +} + +#[tokio::test] +async fn login_rejects_invalid_password() -> Result<()> { + let _lock = acquire_db_lock().await; + let app = TestApp::new().await?; + + let _password = "valid"; + app.insert_user("robin", TestUserRole::Owner).await?; + + let payload = json!({ "username": "robin", "password": "wrong" }); + let response = app.post_json("/api/auth/login", &payload, None).await?; + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + let body = body_to_vec(response.into_body()).await?; + let err: ApiErrorResponse = serde_json::from_slice(&body)?; + assert_eq!(err.error, "password authentication is no longer supported"); + + app.cleanup().await?; + Ok(()) +} + +#[tokio::test] +async fn refresh_rotates_refresh_token() -> Result<()> { + let _lock = acquire_db_lock().await; + let app = TestApp::new().await?; + + let password = "rotate"; + app.insert_user("rita", TestUserRole::Owner).await?; + + let (login, refresh_cookie) = login_with_session(&app, "rita", password).await?; + + let response = app + .post_json_with_cookie("/api/auth/refresh", &json!({}), None, Some(&refresh_cookie)) + .await?; + assert_eq!(response.status(), StatusCode::OK); + let new_cookie = extract_refresh_cookie(response.headers())?; + let body = body_to_vec(response.into_body()).await?; + let refreshed: LoginResponse = serde_json::from_slice(&body)?; + assert_eq!(refreshed.tenant.name, login.tenant.name); + + let me_response = app + .get("/api/auth/me", Some(&refreshed.access_token)) + .await?; + assert_eq!(me_response.status(), StatusCode::OK); + + let retry = app + .post_json_with_cookie("/api/auth/refresh", &json!({}), None, Some(&refresh_cookie)) + .await?; + assert_eq!(retry.status(), StatusCode::UNAUTHORIZED); + + // new cookie should differ from old to avoid reuse + assert_ne!(new_cookie, refresh_cookie); + + app.cleanup().await?; + Ok(()) +} + +#[tokio::test] +async fn logout_revokes_refresh_token() -> Result<()> { + let _lock = acquire_db_lock().await; + let app = TestApp::new().await?; + + let password = "logout"; + app.insert_user("logan", TestUserRole::Owner).await?; + + let (login, refresh_cookie) = login_with_session(&app, "logan", password).await?; + + let response = app + .post_json_with_cookie( + "/api/auth/logout", + &json!({}), + Some(&login.access_token), + Some(&refresh_cookie), + ) + .await?; + assert_eq!(response.status(), StatusCode::NO_CONTENT); + let cleared_cookie = extract_refresh_cookie(response.headers())?; + assert!(cleared_cookie.ends_with("=")); + + let after_logout = app + .post_json_with_cookie("/api/auth/refresh", &json!({}), None, Some(&refresh_cookie)) + .await?; + assert_eq!(after_logout.status(), StatusCode::UNAUTHORIZED); + + app.cleanup().await?; + Ok(()) +} + +#[tokio::test] +async fn me_requires_authentication() -> Result<()> { + let _lock = acquire_db_lock().await; + let app = TestApp::new().await?; + + let unauthenticated = app.get("/api/auth/me", None).await?; + assert_eq!(unauthenticated.status(), StatusCode::UNAUTHORIZED); + + let invalid = app.get("/api/auth/me", Some("invalid")).await?; + assert_eq!(invalid.status(), StatusCode::UNAUTHORIZED); + + app.cleanup().await?; + Ok(()) +} + +#[tokio::test] +async fn login_returns_tenant_selection_when_multiple_memberships() -> Result<()> { + let _lock = acquire_db_lock().await; + let app = TestApp::new().await?; + + let password = "multipass"; + let user_id = app.insert_user("multipass", TestUserRole::Owner).await?; + + let secondary_name = "secondary".to_string(); + let name_for_insert = secondary_name.clone(); + let secondary_id = Uuid::new_v4(); + app.with_conn(move |conn| { + diesel::insert_into(tenants::table) + .values(( + tenants::id.eq(secondary_id), + tenants::name.eq(&name_for_insert), + tenants::status.eq(TenantStatus::Active), + )) + .execute(conn)?; + + let owner_capability_set_id = + ensure_capability_set(conn, secondary_id, owner_capabilities()) + .map_err(|err| anyhow!("failed to ensure owner capability set: {:?}", err))? + .id; + + let membership = NewUserMembership { + id: Uuid::new_v4(), + user_id, + tenant_id: secondary_id, + capability_set_id: Some(owner_capability_set_id), + }; + + diesel::insert_into(user_memberships::table) + .values(&membership) + .execute(conn)?; + Ok(()) + }) + .await?; + + let (login, refresh_cookie) = login_with_session(&app, "multipass", password).await?; + + let tenants_response = app.get("/api/tenants", Some(&login.access_token)).await?; + assert_eq!(tenants_response.status(), StatusCode::OK); + let tenants_body = body_to_vec(tenants_response.into_body()).await?; + let tenant_list: Vec = serde_json::from_slice(&tenants_body)?; + assert!(tenant_list.len() >= 2); + + let secondary = tenant_list + .iter() + .find(|tenant| tenant.name == secondary_name) + .map(|t| t.id) + .context("secondary tenant missing from listing")?; + + let select_response = app + .post_json_with_cookie( + "/api/auth/select-tenant", + &json!({ "tenant_id": secondary }), + Some(&login.access_token), + Some(&refresh_cookie), + ) + .await?; + assert_eq!(select_response.status(), StatusCode::OK); + let select_body = body_to_vec(select_response.into_body()).await?; + let rotated: LoginResponse = serde_json::from_slice(&select_body)?; + assert_eq!(rotated.tenant.id, secondary); + assert_eq!(rotated.tenant.name, secondary_name); + + app.cleanup().await?; + Ok(()) +} + +async fn login_with_session( + app: &TestApp, + username: &str, + _password: &str, +) -> Result<(LoginResponse, String)> { + let username = username.to_string(); + let state = app.state.clone(); + app.with_conn(move |conn| { + use papercrate::schema::user_memberships::dsl as memberships_dsl; + use papercrate::schema::users::dsl as users_dsl; + + let user: papercrate::models::User = users_dsl::users + .filter(users_dsl::username.eq(&username)) + .first(conn)?; + + let membership: papercrate::models::UserMembership = memberships_dsl::user_memberships + .filter(memberships_dsl::user_id.eq(user.id)) + .first(conn)?; + + let tenant: papercrate::models::Tenant = + tenants::table.find(membership.tenant_id).first(conn)?; + + let capability_set_id = membership + .capability_set_id + .ok_or_else(|| anyhow!("membership missing capability set"))?; + + let cap_version = capability_sets::table + .find(capability_set_id) + .select(capability_sets::cap_version) + .first::(conn)?; + + let now = Utc::now(); + let session_id = Uuid::new_v4(); + let access_token = state + .jwt + .generate_token(AccessTokenContext { + user_id: user.id, + tenant_id: tenant.id, + username: user.username.clone(), + principal_kind: PrincipalKind::UserSession, + principal_id: session_id, + capability_set_id, + cap_version, + }) + .map_err(|err| anyhow!(err))?; + + let session_value = generate_session_token(); + let session_hash = hash_session_token(&session_value); + let refresh_expires_at = now + ChronoDuration::days(state.config.refresh_token_expiry_days); + + let new_session = NewUserSession { + id: session_id, + user_id: user.id, + token_hash: session_hash, + issued_at: now.naive_utc(), + expires_at: refresh_expires_at.naive_utc(), + tenant_id: tenant.id, + }; + + diesel::insert_into(user_sessions::table) + .values(&new_session) + .execute(conn)?; + + let login = LoginResponse { + access_token, + tenant: LoginTenant { + id: tenant.id, + name: tenant.name.clone(), + }, + }; + + let cookie = format!("refresh_token={session_value}"); + Ok((login, cookie)) + }) + .await +} + +fn extract_refresh_cookie(headers: &axum::http::HeaderMap) -> Result { + let header_value = headers + .get(SET_COOKIE) + .context("missing set-cookie header")? + .to_str() + .context("invalid set-cookie header")?; + let cookie = header_value + .split(';') + .next() + .context("set-cookie missing cookie value")? + .to_string(); + Ok(cookie) +} + +fn generate_session_token() -> String { + let mut bytes = [0u8; 32]; + OsRng + .try_fill_bytes(&mut bytes) + .expect("failed to read random bytes"); + hex::encode(bytes) +} + +fn hash_session_token(value: &str) -> String { + let mut hasher = Sha256::new(); + hasher.update(value.as_bytes()); + hex::encode(hasher.finalize()) +} +#[tokio::test] +async fn tenant_selection_excludes_inactive_tenants() -> Result<()> { + let _lock = acquire_db_lock().await; + let app = TestApp::new().await?; + + let user_id = app.insert_user("tenant-user", TestUserRole::Owner).await?; + + let tenant_id = app + .with_conn(|conn| { + let tenant: papercrate::models::Tenant = tenant_dsl::tenants + .filter(tenant_dsl::name.eq("test_tenant")) + .first(conn)?; + Ok::<_, anyhow::Error>(tenant.id) + }) + .await?; + + app.with_conn(move |conn| { + diesel::update(tenant_dsl::tenants.find(tenant_id)) + .set(tenant_dsl::status.eq(TenantStatus::Suspended)) + .execute(conn)?; + Ok(()) + }) + .await?; + + let magic_value = "tenant-status-token"; + let token_hash = { + let mut hasher = Sha256::new(); + hasher.update(magic_value.as_bytes()); + hex::encode(hasher.finalize()) + }; + + let user_id_for_token = user_id; + app.with_conn(move |conn| { + let token = MagicToken { + id: Uuid::new_v4(), + user_id: user_id_for_token, + kind: MagicTokenKind::EmailLogin, + token_hash, + metadata: json!({}), + expires_at: (Utc::now() + ChronoDuration::hours(1)).naive_utc(), + max_uses: None, + used_count: 0, + created_at: Utc::now().naive_utc(), + created_by: None, + last_used_at: None, + }; + + diesel::insert_into(magic_dsl::magic_tokens) + .values(&token) + .execute(conn)?; + Ok(()) + }) + .await?; + + let payload = json!({ + "username": "tenant-user", + "magic_token": magic_value, + }); + + let response = app.post_json("/api/auth/login", &payload, None).await?; + assert_eq!(response.status(), StatusCode::FORBIDDEN); + let body = body_to_vec(response.into_body()).await?; + let err: ApiErrorResponse = serde_json::from_slice(&body)?; + assert_eq!(err.error, "no active tenants available"); + + app.cleanup().await?; + Ok(()) +} diff --git a/backend/tests/capability_guards_flow.rs b/backend/tests/capability_guards_flow.rs new file mode 100644 index 0000000..8072b1a --- /dev/null +++ b/backend/tests/capability_guards_flow.rs @@ -0,0 +1,104 @@ +use anyhow::{anyhow, Result}; +use axum::http::StatusCode; +use diesel::prelude::*; +use papercrate::models::ApiCapability; +use papercrate::test_support::{acquire_db_lock, body_to_vec, TestApp, TestUserRole}; +use serde_json::json; + +async fn set_user_capabilities( + app: &TestApp, + user_id: uuid::Uuid, + caps: &[ApiCapability], +) -> Result<()> { + let capabilities = caps.to_vec(); + app.with_conn(move |conn| { + use papercrate::schema::user_memberships::dsl as memberships_dsl; + + let membership = memberships_dsl::user_memberships + .filter(memberships_dsl::user_id.eq(user_id)) + .first::(conn)?; + + let capability_set = papercrate::auth::capability_sets::ensure_capability_set( + conn, + membership.tenant_id, + &capabilities, + ) + .map_err(|err| anyhow!("failed to ensure capability set: {:?}", err))?; + + diesel::update(memberships_dsl::user_memberships.find(membership.id)) + .set(memberships_dsl::capability_set_id.eq(Some(capability_set.id))) + .execute(conn)?; + + Ok(()) + }) + .await +} + +#[tokio::test] +async fn documents_routes_enforce_capabilities() -> Result<()> { + let _lock = acquire_db_lock().await; + let app = TestApp::new().await?; + + let password = "limited-docs"; + let user_id = app.insert_user("limited-docs", TestUserRole::Owner).await?; + set_user_capabilities(&app, user_id, &[ApiCapability::DocumentsRead]).await?; + + let token = app.login_token("limited-docs", password).await?; + + let list = app.get("/api/documents", Some(&token)).await?; + assert_eq!(list.status(), StatusCode::OK); + + let upload = app + .upload_document( + "/api/documents", + "limited.txt", + "text/plain", + b"limited", + None, + &token, + ) + .await?; + assert_eq!(upload.status(), StatusCode::FORBIDDEN); + let upload_body = body_to_vec(upload.into_body()).await?; + assert!(String::from_utf8_lossy(&upload_body).contains("missing")); + + let capability_sets = app.get("/api/capability-sets", Some(&token)).await?; + assert_eq!(capability_sets.status(), StatusCode::FORBIDDEN); + let caps_body = body_to_vec(capability_sets.into_body()).await?; + assert!(String::from_utf8_lossy(&caps_body).contains("missing")); + + app.cleanup().await?; + Ok(()) +} + +#[tokio::test] +async fn capability_set_routes_require_write_privilege() -> Result<()> { + let _lock = acquire_db_lock().await; + let app = TestApp::new().await?; + + let password = "caps-reader"; + let user_id = app.insert_user("caps-reader", TestUserRole::Member).await?; + set_user_capabilities(&app, user_id, &[ApiCapability::CapabilitySetsRead]).await?; + + let token = app.login_token("caps-reader", password).await?; + + let list = app.get("/api/capability-sets", Some(&token)).await?; + assert_eq!(list.status(), StatusCode::OK); + + let create = app + .post_json( + "/api/capability-sets", + &json!({ + "slug": "should-fail", + "capabilities": ["documents:read"] + }), + Some(&token), + ) + .await?; + assert_eq!(create.status(), StatusCode::FORBIDDEN); + let create_body = body_to_vec(create.into_body()).await?; + assert!(String::from_utf8_lossy(&create_body).contains("missing")); + + app.cleanup().await?; + Ok(()) +} diff --git a/backend/tests/capability_sets_flow.rs b/backend/tests/capability_sets_flow.rs new file mode 100644 index 0000000..a4ebadd --- /dev/null +++ b/backend/tests/capability_sets_flow.rs @@ -0,0 +1,114 @@ +use anyhow::Result; +use axum::http::StatusCode; +use papercrate::test_support::{acquire_db_lock, body_to_vec, TestApp, TestUserRole}; +use serde::Deserialize; +use serde_json::json; +use uuid::Uuid; + +#[derive(Deserialize)] +struct CapabilitySetResponse { + id: Uuid, + slug: String, + cap_version: i32, + #[allow(dead_code)] + is_system: bool, + capabilities: Vec, +} + +#[tokio::test] +async fn capability_set_crud_flow() -> Result<()> { + let _lock = acquire_db_lock().await; + let app = TestApp::new().await?; + + let password = "caps-admin"; + app.insert_user("caps", TestUserRole::Owner).await?; + let token = app.login_token("caps", password).await?; + + // Initial list should contain system sets. + let initial = app.get("/api/capability-sets", Some(&token)).await?; + assert_eq!(initial.status(), StatusCode::OK); + let initial_body = body_to_vec(initial.into_body()).await?; + let sets: Vec = serde_json::from_slice(&initial_body)?; + let owner_id = sets + .iter() + .find(|set| set.slug == "owner") + .map(|set| set.id) + .expect("owner set present"); + assert!(sets.iter().any(|set| set.slug == "user")); + assert!(sets.iter().any(|set| set.slug == "readonly")); + assert!(sets.iter().any(|set| set.slug == "webdav")); + + let capabilities_resp = app.get("/api/capabilities", Some(&token)).await?; + assert_eq!(capabilities_resp.status(), StatusCode::OK); + let capabilities_body = body_to_vec(capabilities_resp.into_body()).await?; + let capabilities: Vec = serde_json::from_slice(&capabilities_body)?; + assert!(capabilities.contains(&"documents:read".to_string())); + assert!(capabilities.contains(&"capability_sets:write".to_string())); + assert_eq!( + capabilities.len(), + papercrate::models::ApiCapability::variants().len() + ); + + // Create a new capability set. + let create = app + .post_json( + "/api/capability-sets", + &json!({ + "slug": "api_readonly", + "capabilities": ["documents:read", "capability_sets:read"] + }), + Some(&token), + ) + .await?; + assert_eq!(create.status(), StatusCode::CREATED); + let create_body = body_to_vec(create.into_body()).await?; + let created: CapabilitySetResponse = serde_json::from_slice(&create_body)?; + assert_eq!(created.slug, "api_readonly"); + assert!(created.capabilities.contains(&"documents:read".to_string())); + assert!(created + .capabilities + .contains(&"capability_sets:read".to_string())); + + // Update capabilities. + let update = app + .patch_json( + &format!("/api/capability-sets/{}", created.id), + &json!({ + "capabilities": ["documents:read", "documents:edit"], + }), + Some(&token), + ) + .await?; + assert_eq!(update.status(), StatusCode::OK); + let update_body = body_to_vec(update.into_body()).await?; + let updated: CapabilitySetResponse = serde_json::from_slice(&update_body)?; + assert_eq!(updated.cap_version, created.cap_version + 1); + assert!(updated.capabilities.contains(&"documents:edit".to_string())); + assert!(!updated + .capabilities + .contains(&"capability_sets:read".to_string())); + + // Attempt to delete system set should conflict. + let delete_owner = app + .delete(&format!("/api/capability-sets/{}", owner_id), Some(&token)) + .await?; + assert_eq!(delete_owner.status(), StatusCode::CONFLICT); + + // Delete custom set succeeds. + let delete = app + .delete( + &format!("/api/capability-sets/{}", created.id), + Some(&token), + ) + .await?; + assert_eq!(delete.status(), StatusCode::NO_CONTENT); + + let final_list = app.get("/api/capability-sets", Some(&token)).await?; + assert_eq!(final_list.status(), StatusCode::OK); + let final_body = body_to_vec(final_list.into_body()).await?; + let final_sets: Vec = serde_json::from_slice(&final_body)?; + assert!(!final_sets.iter().any(|set| set.slug == "api_readonly")); + + app.cleanup().await?; + Ok(()) +} diff --git a/backend/tests/common/mod.rs b/backend/tests/common/mod.rs new file mode 100644 index 0000000..690c28d --- /dev/null +++ b/backend/tests/common/mod.rs @@ -0,0 +1 @@ +pub use papercrate::test_support::*; diff --git a/backend/tests/correspondents_flow.rs b/backend/tests/correspondents_flow.rs new file mode 100644 index 0000000..ff057d7 --- /dev/null +++ b/backend/tests/correspondents_flow.rs @@ -0,0 +1,275 @@ +use anyhow::Result; +use papercrate::test_support::{acquire_db_lock, body_to_vec, TestApp, TestUserRole}; +use serde::Deserialize; +use serde_json::json; +use uuid::Uuid; + +#[derive(Deserialize)] +struct DocumentDetail { + document: DocumentSummary, +} + +#[derive(Deserialize)] +struct DocumentSummary { + id: Uuid, + #[serde(rename = "title")] + _title: String, + #[serde(default)] + correspondents: Vec, +} + +#[derive(Deserialize)] +struct DocumentCorrespondentSummary { + id: Uuid, + name: String, +} + +#[derive(Deserialize)] +struct CorrespondentSummary { + id: Uuid, +} + +#[derive(Deserialize)] +struct BulkCorrespondentResult { + assigned: usize, + removed: usize, +} + +struct TestContext { + app: TestApp, + token: String, + document_ids: Vec, + sender_id: Uuid, + receiver_id: Uuid, +} + +impl TestContext { + const SENDER_NAME: &'static str = "Acme Corp"; + const RECEIVER_NAME: &'static str = "Bank Ltd"; + + async fn new(prefix: &str) -> Result { + let app = TestApp::new().await?; + let username = format!("{prefix}_user"); + let password = format!("{prefix}_pw"); + app.insert_user(&username, TestUserRole::Owner).await?; + let token = app.login_token(&username, &password).await?; + + let first_id = + upload_document(&app, &token, &format!("{prefix}-one.txt"), b"letter one").await?; + let second_id = + upload_document(&app, &token, &format!("{prefix}-two.txt"), b"letter two").await?; + let sender_id = create_correspondent(&app, &token, Self::SENDER_NAME).await?; + let receiver_id = create_correspondent(&app, &token, Self::RECEIVER_NAME).await?; + + Ok(Self { + app, + token, + document_ids: vec![first_id, second_id], + sender_id, + receiver_id, + }) + } + + async fn assign(&self, correspondent_ids: &[Uuid]) -> Result { + self.assign_with_action(correspondent_ids, None).await + } + + async fn assign_with_action( + &self, + correspondent_ids: &[Uuid], + action: Option<&str>, + ) -> Result { + let assignments: Vec<_> = correspondent_ids + .iter() + .map(|id| json!({ "correspondent_id": id })) + .collect(); + + let mut payload = json!({ + "document_ids": self.document_ids, + "assignments": assignments, + }); + + if let Some(action) = action { + if let Some(obj) = payload.as_object_mut() { + obj.insert("action".to_string(), json!(action)); + } + } + + let response = self + .app + .post_json( + "/api/documents/bulk/correspondents", + &payload, + Some(&self.token), + ) + .await?; + assert!(response.status().is_success()); + let body = body_to_vec(response.into_body()).await?; + Ok(serde_json::from_slice(&body)?) + } + + async fn fetch_correspondents( + &self, + document_id: Uuid, + ) -> Result> { + let detail = fetch_document_detail(&self.app, &self.token, document_id).await?; + Ok(detail.document.correspondents) + } + + async fn create_correspondent(&self, name: &str) -> Result { + create_correspondent(&self.app, &self.token, name).await + } +} + +#[tokio::test] +async fn bulk_assign_correspondents_adds_new_links() -> Result<()> { + let _lock = acquire_db_lock().await; + let context = TestContext::new("corresp_add").await?; + + let result = context + .assign(&[context.sender_id, context.receiver_id]) + .await?; + assert_eq!(result.assigned, 4); + assert_eq!(result.removed, 0); + + for doc_id in &context.document_ids { + let correspondents = context.fetch_correspondents(*doc_id).await?; + let names: Vec<_> = correspondents + .iter() + .map(|entry| entry.name.as_str()) + .collect(); + assert!(names.contains(&TestContext::SENDER_NAME)); + assert!(names.contains(&TestContext::RECEIVER_NAME)); + let ids: Vec<_> = correspondents.iter().map(|entry| entry.id).collect(); + assert!(ids.contains(&context.sender_id)); + assert!(ids.contains(&context.receiver_id)); + } + + context.app.cleanup().await?; + Ok(()) +} + +#[tokio::test] +async fn bulk_assign_correspondents_is_idempotent() -> Result<()> { + let _lock = acquire_db_lock().await; + let context = TestContext::new("corresp_idempotent").await?; + + context + .assign(&[context.sender_id, context.receiver_id]) + .await?; + let repeat = context + .assign(&[context.sender_id, context.receiver_id]) + .await?; + assert_eq!(repeat.assigned, 0); + assert_eq!(repeat.removed, 0); + + context.app.cleanup().await?; + Ok(()) +} + +#[tokio::test] +async fn bulk_remove_correspondents_detaches_links() -> Result<()> { + let _lock = acquire_db_lock().await; + let context = TestContext::new("corresp_remove").await?; + + context + .assign(&[context.sender_id, context.receiver_id]) + .await?; + let removal = context + .assign_with_action(&[context.sender_id], Some("remove")) + .await?; + assert_eq!(removal.assigned, 0); + assert_eq!(removal.removed, 2); + + for doc_id in &context.document_ids { + let correspondents = context.fetch_correspondents(*doc_id).await?; + assert_eq!(correspondents.len(), 1); + let entry = &correspondents[0]; + assert_eq!(entry.id, context.receiver_id); + assert_eq!(entry.name, TestContext::RECEIVER_NAME); + } + + context.app.cleanup().await?; + Ok(()) +} + +#[tokio::test] +async fn bulk_assign_correspondents_appends_new_entries() -> Result<()> { + let _lock = acquire_db_lock().await; + let context = TestContext::new("corresp_append").await?; + + context + .assign(&[context.sender_id, context.receiver_id]) + .await?; + context + .assign_with_action(&[context.sender_id], Some("remove")) + .await?; + + let charlie_name = "Charlie"; + let charlie_id = context.create_correspondent(charlie_name).await?; + let add_result = context.assign(&[charlie_id]).await?; + assert_eq!(add_result.assigned, 2); + assert_eq!(add_result.removed, 0); + + for doc_id in &context.document_ids { + let correspondents = context.fetch_correspondents(*doc_id).await?; + assert_eq!(correspondents.len(), 2); + let ids: Vec<_> = correspondents.iter().map(|entry| entry.id).collect(); + assert!(ids.contains(&context.receiver_id)); + assert!(ids.contains(&charlie_id)); + let names: Vec<_> = correspondents + .iter() + .map(|entry| entry.name.as_str()) + .collect(); + assert!(names.contains(&TestContext::RECEIVER_NAME)); + assert!(names.contains(&charlie_name)); + } + + context.app.cleanup().await?; + Ok(()) +} + +async fn upload_document( + app: &TestApp, + token: &str, + filename: &str, + contents: &[u8], +) -> Result { + let response = app + .upload_document( + "/api/documents", + filename, + "text/plain", + contents, + None, + token, + ) + .await?; + assert!(response.status().is_success()); + let body = body_to_vec(response.into_body()).await?; + let detail: DocumentDetail = serde_json::from_slice(&body)?; + Ok(detail.document.id) +} + +async fn create_correspondent(app: &TestApp, token: &str, name: &str) -> Result { + let response = app + .post_json("/api/correspondents", &json!({ "name": name }), Some(token)) + .await?; + assert!(response.status().is_success()); + let body = body_to_vec(response.into_body()).await?; + let summary: CorrespondentSummary = serde_json::from_slice(&body)?; + Ok(summary.id) +} + +async fn fetch_document_detail( + app: &TestApp, + token: &str, + document_id: Uuid, +) -> Result { + let response = app + .get(&format!("/api/documents/{document_id}"), Some(token)) + .await?; + assert!(response.status().is_success()); + let body = body_to_vec(response.into_body()).await?; + Ok(serde_json::from_slice(&body)?) +} diff --git a/backend/tests/data/issued_at_cases.yaml b/backend/tests/data/issued_at_cases.yaml new file mode 100644 index 0000000..f00aa4d --- /dev/null +++ b/backend/tests/data/issued_at_cases.yaml @@ -0,0 +1,584 @@ +# Derived test cases from the Paperless-ngx project (https://github.com/paperless-ngx/paperless-ngx). +# Copyright (c) Paperless-ngx contributors, licensed under the GNU GPL-3.0. +cases: + - name: date_format_1 + parser: parse_date + filename: null + content: "lorem ipsum 130218 lorem ipsum" + settings: {} + expected: + mode: none + + - name: date_format_2 + parser: parse_date + filename: null + content: "lorem ipsum 2018 lorem ipsum" + settings: {} + expected: + mode: none + + - name: date_format_3 + parser: parse_date + filename: null + content: "lorem ipsum 20180213 lorem ipsum" + settings: {} + expected: + mode: none + + - name: date_format_4 + parser: parse_date + filename: null + content: "lorem ipsum 13.02.2018 lorem ipsum" + settings: {} + expected: + mode: single + value: 2018-02-13 + + - name: date_format_5 + parser: parse_date + filename: null + content: "lorem ipsum 130218, 2018, 20180213 and lorem 13.02.2018 lorem ipsum" + settings: {} + expected: + mode: single + value: 2018-02-13 + + - name: date_format_6 + parser: parse_date + filename: null + content: | + lorem ipsum + Wohnort + 3100 + IBAN + AT87 4534 + 1234 + 1234 5678 + BIC + lorem ipsum + settings: {} + expected: + mode: none + + - name: date_format_7 + parser: parse_date + filename: null + content: | + lorem ipsum + März 2019 + lorem ipsum + settings: + DATE_PARSER_LANGUAGES: + - de + expected: + mode: single + value: 2019-03-01 + + - name: date_format_8 + parser: parse_date + filename: null + content: | + lorem ipsum + Wohnort + 3100 + IBAN + AT87 4534 + 1234 + 1234 5678 + BIC + lorem ipsum + März 2020 + settings: + DATE_PARSER_LANGUAGES: + - de + expected: + mode: single + value: 2020-03-01 + + - name: date_format_9 + parser: parse_date + filename: null + content: | + lorem ipsum + 27. Nullmonth 2020 + März 2020 + lorem ipsum + settings: + DATE_PARSER_LANGUAGES: + - de + expected: + mode: single + value: 2020-03-01 + + - name: date_format_10 + parser: parse_date + filename: null + content: "Customer Number Currency 22-MAR-2022 Credit Card 1934829304" + settings: {} + expected: + mode: single + value: 2022-03-22 + + - name: date_format_11 + parser: parse_date + filename: null + content: "Customer Number Currency 22 MAR 2022 Credit Card 1934829304" + settings: {} + expected: + mode: single + value: 2022-03-22 + + - name: date_format_12 + parser: parse_date + filename: null + content: "Customer Number Currency 22/MAR/2022 Credit Card 1934829304" + settings: {} + expected: + mode: single + value: 2022-03-22 + + - name: date_format_13 + parser: parse_date + filename: null + content: "Customer Number Currency 22.MAR.2022 Credit Card 1934829304" + settings: {} + expected: + mode: single + value: 2022-03-22 + + - name: date_format_14 + parser: parse_date + filename: null + content: "Customer Number Currency 22.MAR 2022 Credit Card 1934829304" + settings: {} + expected: + mode: single + value: 2022-03-22 + + - name: date_format_15 + parser: parse_date + filename: null + content: "Customer Number Currency 22.MAR.22 Credit Card 1934829304" + settings: {} + expected: + mode: none + + - name: date_format_16 + parser: parse_date + filename: null + content: "Customer Number Currency 22.MAR,22 Credit Card 1934829304" + settings: {} + expected: + mode: none + + - name: date_format_17 + parser: parse_date + filename: null + content: "Customer Number Currency 22,MAR,2022 Credit Card 1934829304" + settings: {} + expected: + mode: none + + - name: date_format_18 + parser: parse_date + filename: null + content: "Customer Number Currency 22 MAR,2022 Credit Card 1934829304" + settings: {} + expected: + mode: none + + - name: date_format_19 + parser: parse_date + filename: null + content: "Customer Number Currency 21st MAR 2022 Credit Card 1934829304" + settings: {} + expected: + mode: single + value: 2022-03-21 + + - name: date_format_20 + parser: parse_date + filename: null + content: "Customer Number Currency 22nd March 2022 Credit Card 1934829304" + settings: {} + expected: + mode: single + value: 2022-03-22 + + - name: date_format_21 + parser: parse_date + filename: null + content: "Customer Number Currency 2nd MAR 2022 Credit Card 1934829304" + settings: {} + expected: + mode: single + value: 2022-03-02 + + - name: date_format_22 + parser: parse_date + filename: null + content: "Customer Number Currency 23rd MAR 2022 Credit Card 1934829304" + settings: {} + expected: + mode: single + value: 2022-03-23 + + - name: date_format_23 + parser: parse_date + filename: null + content: "Customer Number Currency 24th MAR 2022 Credit Card 1934829304" + settings: {} + expected: + mode: single + value: 2022-03-24 + + - name: date_format_24 + parser: parse_date + filename: null + content: "Customer Number Currency 21-MAR-2022 Credit Card 1934829304" + settings: {} + expected: + mode: single + value: 2022-03-21 + + - name: date_format_25 + parser: parse_date + filename: null + content: "Customer Number Currency 25TH MAR 2022 Credit Card 1934829304" + settings: {} + expected: + mode: single + value: 2022-03-25 + + - name: date_format_26 + parser: parse_date + filename: null + content: "CHASE 0 September 25, 2019 JPMorgan Chase Bank, NA. P0 Box 182051" + settings: {} + expected: + mode: single + value: 2019-09-25 + + - name: numeric_mdy_slash + parser: parse_date + filename: null + content: "03/17/2008" + settings: + DATE_ORDER: MDY + expected: + mode: single + value: 2008-03-17 + + - name: crazy_date_past + parser: parse_date + filename: null + content: "01-07-0590 00:00:00" + settings: {} + expected: + mode: none + + - name: crazy_date_future + parser: parse_date + filename: null + content: "01-07-2350 00:00:00" + settings: {} + expected: + mode: none + + - name: crazy_date_with_spaces + parser: parse_date + filename: null + content: "20 408000l 2475" + settings: {} + expected: + mode: none + + - name: utf_month_names_decembre + parser: parse_date + filename: null + content: "13 décembre 2023" + settings: + DATE_PARSER_LANGUAGES: + - fr + - de + - hr + - cs + - pl + - tr + expected: + mode: single + value: 2023-12-13 + + - name: utf_month_names_aout + parser: parse_date + filename: null + content: "13 août 2022" + settings: + DATE_PARSER_LANGUAGES: + - fr + - de + - hr + - cs + - pl + - tr + expected: + mode: single + value: 2022-08-13 + + - name: utf_month_names_marz + parser: parse_date + filename: null + content: "11 März 2020" + settings: + DATE_PARSER_LANGUAGES: + - fr + - de + - hr + - cs + - pl + - tr + expected: + mode: single + value: 2020-03-11 + + - name: utf_month_names_ozujka + parser: parse_date + filename: null + content: "17. ožujka 2018." + settings: + DATE_PARSER_LANGUAGES: + - fr + - de + - hr + - cs + - pl + - tr + expected: + mode: single + value: 2018-03-17 + + - name: utf_month_names_veljace + parser: parse_date + filename: null + content: "1. veljače 2016." + settings: + DATE_PARSER_LANGUAGES: + - fr + - de + - hr + - cs + - pl + - tr + expected: + mode: single + value: 2016-02-01 + + - name: utf_month_names_unora + parser: parse_date + filename: null + content: "15. února 1985" + settings: + DATE_PARSER_LANGUAGES: + - fr + - de + - hr + - cs + - pl + - tr + expected: + mode: single + value: 1985-02-15 + + - name: utf_month_names_zari + parser: parse_date + filename: null + content: "30. září 2011" + settings: + DATE_PARSER_LANGUAGES: + - fr + - de + - hr + - cs + - pl + - tr + expected: + mode: single + value: 2011-09-30 + + - name: utf_month_names_kvetna + parser: parse_date + filename: null + content: "28. května 1990" + settings: + DATE_PARSER_LANGUAGES: + - fr + - de + - hr + - cs + - pl + - tr + expected: + mode: single + value: 1990-05-28 + + - name: utf_month_names_grudzien + parser: parse_date + filename: null + content: "1. grudzień 1997" + settings: + DATE_PARSER_LANGUAGES: + - fr + - de + - hr + - cs + - pl + - tr + expected: + mode: single + value: 1997-12-01 + + - name: utf_month_names_subat + parser: parse_date + filename: null + content: "17 Şubat 2024" + settings: + DATE_PARSER_LANGUAGES: + - fr + - de + - hr + - cs + - pl + - tr + expected: + mode: single + value: 2024-02-17 + + - name: utf_month_names_agustos + parser: parse_date + filename: null + content: "30 Ağustos 2012" + settings: + DATE_PARSER_LANGUAGES: + - fr + - de + - hr + - cs + - pl + - tr + expected: + mode: single + value: 2012-08-30 + + - name: utf_month_names_eylul + parser: parse_date + filename: null + content: "17 Eylül 2000" + settings: + DATE_PARSER_LANGUAGES: + - fr + - de + - hr + - cs + - pl + - tr + expected: + mode: single + value: 2000-09-17 + + - name: utf_month_names_oktober + parser: parse_date + filename: null + content: "5. október 1992" + settings: + DATE_PARSER_LANGUAGES: + - fr + - de + - hr + - cs + - pl + - tr + - hu + expected: + mode: single + value: 1992-10-05 + + - name: multiple_dates + parser: parse_date_generator + filename: null + content: | + This text has multiple dates. + For example 02.02.2018, 22 July 2022 and December 2021. + But not 24-12-9999 because it's in the future... + settings: {} + expected: + mode: multiple + value: + - 2018-02-02 + - 2022-07-22 + - 2021-12-01 + + - name: filename_date_parse_valid_ymd + parser: parse_date + filename: /tmp/Scan-2022-04-01.pdf + content: "No date in here" + settings: + FILENAME_DATE_ORDER: YMD + expected: + mode: single + value: 2022-04-01 + + - name: filename_date_parse_valid_dmy + parser: parse_date + filename: /tmp/Scan-10.01.2021.pdf + content: "No date in here" + settings: + FILENAME_DATE_ORDER: DMY + expected: + mode: single + value: 2021-01-10 + + - name: filename_date_parse_invalid + parser: parse_date + filename: "/tmp/20 408000l 2475 - test.pdf" + content: "No date in here" + settings: + FILENAME_DATE_ORDER: YMD + expected: + mode: none + + - name: filename_date_ignored_use_content + parser: parse_date + filename: /tmp/Scan-2022-04-01.pdf + content: "The matching date is 24.03.2022" + settings: + FILENAME_DATE_ORDER: YMD + IGNORE_DATES: + - 2022-04-01 + expected: + mode: single + value: 2022-03-24 + + - name: ignored_dates_default_order + parser: parse_date + filename: null + content: "lorem ipsum 110319, 20200117 and lorem 13.02.2018 lorem ipsum" + settings: + IGNORE_DATES: + - 2019-11-03 + - 2020-01-17 + expected: + mode: single + value: 2018-02-13 + + - name: ignored_dates_order_ymd + parser: parse_date + filename: null + content: "lorem ipsum 190311, 20200117 and lorem 13.02.2018 lorem ipsum" + settings: + FILENAME_DATE_ORDER: YMD + IGNORE_DATES: + - 2019-11-03 + - 2020-01-17 + expected: + mode: single + value: 2018-02-13 diff --git a/backend/tests/documents_flow.rs b/backend/tests/documents_flow.rs new file mode 100644 index 0000000..8e2ad0f --- /dev/null +++ b/backend/tests/documents_flow.rs @@ -0,0 +1,2071 @@ +use anyhow::{anyhow, Result}; +use axum::http::StatusCode; +use diesel::prelude::*; +use papercrate::test_support::{acquire_db_lock, body_to_vec, TestApp, TestUserRole, UploadExtras}; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; +use uuid::Uuid; + +use papercrate::jobs::{mark_job_succeeded, JOB_PURGE_DOCUMENT}; +use papercrate::models::{Job, NewDocumentAsset}; +use papercrate::schema::document_assets; +use papercrate::workers::{purge::PurgeDocumentJob, JobExecution, JobHandler}; +use std::sync::Arc; +#[derive(Deserialize)] +struct DocumentDetail { + document: DocumentInfo, +} + +#[derive(Deserialize)] +struct ApiErrorResponse { + error: String, + #[serde(default)] + code: Option, + #[serde(default)] + details: Option, +} + +#[derive(Deserialize)] +struct DocumentInfo { + id: Uuid, + title: String, + filename: String, + original_name: String, + #[serde(default)] + folder_id: Option, + deleted_at: Option, + issued_at: Option, + metadata: Value, + tags: Vec, + #[serde(default)] + current_version: Option, +} + +#[derive(Deserialize)] +struct DocumentVersionPayload { + id: Uuid, + version_number: i32, + size_bytes: i64, + download: DownloadLinkPayload, + #[serde(default)] + assets: Vec, +} + +#[derive(Deserialize)] +struct DownloadLinkPayload { + url: String, + expires_at: i64, +} + +#[derive(Deserialize)] +struct DocumentVersionListItem { + id: Uuid, + version_number: i32, +} + +#[allow(dead_code)] +#[derive(Deserialize)] +struct DocumentAssetInfo { + id: Uuid, + asset_type: String, +} + +#[derive(Deserialize)] +struct AssetProxyDetail { + id: Uuid, + download: Option, +} + +#[derive(Deserialize)] +struct DocumentListItem { + id: Uuid, + #[serde(default)] + current_version: Option, +} + +#[derive(Deserialize)] +struct BulkReanalyze { + queued: usize, +} + +#[derive(Deserialize)] +struct BulkMoveResult { + updated: usize, +} + +#[derive(Deserialize)] +struct BulkTagResult { + added: usize, + removed: usize, +} + +#[derive(Deserialize)] +struct TagSummary { + label: String, +} + +#[derive(Deserialize)] +struct AnalyzeJobPayload { + document_id: Uuid, + document_version_id: Uuid, + #[serde(default)] + force: bool, +} + +#[derive(Deserialize)] +struct FolderResponse { + folder: FolderInfo, +} + +#[derive(Deserialize)] +struct FolderInfo { + id: Uuid, +} + +#[derive(Deserialize)] +struct FolderContents { + documents: Vec, +} + +#[derive(Deserialize)] +struct TagResponse { + id: Uuid, +} + +#[derive(Serialize)] +struct BulkMoveRequest<'a> { + document_ids: &'a [Uuid], + folder_id: Option, +} + +#[derive(Serialize)] +struct BulkTagRequest<'a> { + document_ids: &'a [Uuid], + tag_ids: &'a [Uuid], + action: &'a str, +} + +#[derive(Serialize)] +struct CreateFolderRequest<'a> { + name: &'a str, + parent_id: Option, +} + +#[derive(Serialize)] +struct CreateTagPayload<'a> { + label: &'a str, + color: Option<&'a str>, +} + +#[tokio::test] +async fn upload_and_list_document() -> Result<()> { + let _lock = acquire_db_lock().await; + let app = TestApp::new().await?; + + let password = "passw0rd"; + app.insert_user("dana", TestUserRole::Owner).await?; + let token = app.login_token("dana", password).await?; + + let file_bytes = b"example document body".to_vec(); + let upload = app + .upload_document( + "/api/documents", + "doc.txt", + "text/plain", + &file_bytes, + None, + &token, + ) + .await?; + { + let status = upload.status(); + assert!( + status == StatusCode::OK + || status == StatusCode::CREATED + || status == StatusCode::NO_CONTENT + ); + } + let body = body_to_vec(upload.into_body()).await?; + let detail: DocumentDetail = serde_json::from_slice(&body)?; + + assert_eq!(detail.document.original_name, "doc.txt"); + assert_eq!(detail.document.title, "doc"); + assert_eq!(detail.document.deleted_at, None); + assert!(detail.document.issued_at.is_none()); + assert!(detail.document.tags.is_empty()); + let current_version = detail + .document + .current_version + .as_ref() + .expect("current version detail"); + assert!(current_version.download.url.starts_with("/api/download/")); + assert!(current_version.download.expires_at > 0); + assert_eq!(current_version.version_number, 1); + assert_eq!(current_version.size_bytes, file_bytes.len() as i64); + assert!(current_version.assets.is_empty()); + + assert_eq!(app.storage().object_count().await, 1); + + let response = app.get("/api/documents", Some(&token)).await?; + { + let status = response.status(); + assert!( + status == StatusCode::OK + || status == StatusCode::CREATED + || status == StatusCode::NO_CONTENT + ); + } + let body = body_to_vec(response.into_body()).await?; + let mut list: Vec = serde_json::from_slice(&body)?; + assert_eq!(list.len(), 1); + let item = list.pop().unwrap(); + assert_eq!(item.id, detail.document.id); + assert_eq!( + item.current_version + .as_ref() + .map(|version| version.version_number), + Some(1) + ); + assert!(item + .current_version + .as_ref() + .expect("list current version") + .download + .url + .starts_with("/api/download/")); + + let redirect = app.get(¤t_version.download.url, None).await?; + assert_eq!(redirect.status(), StatusCode::TEMPORARY_REDIRECT); + let location = redirect + .headers() + .get("location") + .expect("redirect location header"); + let location = location.to_str().expect("location header utf8"); + assert!(!location.is_empty()); + + app.cleanup().await?; + Ok(()) +} + +#[tokio::test] +async fn upload_document_with_custom_title_sets_filename() -> Result<()> { + let _lock = acquire_db_lock().await; + let app = TestApp::new().await?; + + let password = "passw0rd"; + app.insert_user("nora", TestUserRole::Owner).await?; + let token = app.login_token("nora", password).await?; + + let file_bytes = b"example contract body".to_vec(); + let title = "Vendor Contract"; + let original_filename = "scan.pdf"; + + let upload = app + .upload_document_with_options( + "/api/documents", + original_filename, + "application/pdf", + &file_bytes, + None, + Some(title), + None, + &token, + ) + .await?; + { + let status = upload.status(); + assert!( + status == StatusCode::OK + || status == StatusCode::CREATED + || status == StatusCode::NO_CONTENT + ); + } + let body = body_to_vec(upload.into_body()).await?; + let detail: DocumentDetail = serde_json::from_slice(&body)?; + + assert_eq!(detail.document.title, title); + assert_eq!(detail.document.filename, format!("{title}.pdf")); + assert_eq!(detail.document.original_name, original_filename); + + app.cleanup().await?; + Ok(()) +} + +#[tokio::test] +async fn asset_detail_uses_proxy_urls_when_configured() -> Result<()> { + let _lock = acquire_db_lock().await; + let app = TestApp::with_config(|config| config.proxy_downloads = true).await?; + let tenant_id = app.tenant_id().await?; + + let username = "proxy-assets"; + let password = "secret"; + app.insert_user(username, TestUserRole::Owner).await?; + let token = app.login_token(username, password).await?; + + let upload = app + .upload_document( + "/api/documents", + "proxy.pdf", + "application/pdf", + b"dummy", + None, + &token, + ) + .await?; + assert_eq!(upload.status(), StatusCode::CREATED); + let body = body_to_vec(upload.into_body()).await?; + let detail: DocumentDetail = serde_json::from_slice(&body)?; + let document = detail.document; + let version = document + .current_version + .as_ref() + .ok_or_else(|| anyhow!("current version missing"))?; + + let mut conn = app + .state + .db_for_tenant(tenant_id) + .map_err(|err| anyhow!("tenant connection: {err:?}"))?; + + let asset_id = Uuid::new_v4(); + let s3_key = "objects/preview.png".to_string(); + + diesel::insert_into(document_assets::table) + .values(&NewDocumentAsset { + id: asset_id, + document_version_id: version.id, + asset_type: "preview".to_string(), + mime_type: "image/png".to_string(), + metadata: json!({}), + s3_key: s3_key.clone(), + tenant_id, + }) + .execute(&mut conn)?; + + drop(conn); + + let response = app + .get(&format!("/api/assets/{asset_id}"), Some(&token)) + .await?; + assert_eq!(response.status(), StatusCode::OK); + let body = body_to_vec(response.into_body()).await?; + let asset_detail: AssetProxyDetail = serde_json::from_slice(&body)?; + assert_eq!(asset_detail.id, asset_id); + let download = asset_detail + .download + .as_ref() + .ok_or_else(|| anyhow!("missing download link"))?; + assert!(download.url.starts_with("/api/download/")); + assert!(download.expires_at > 0); + + app.cleanup().await?; + Ok(()) +} + +#[tokio::test] +async fn document_list_sorting_controls() -> Result<()> { + let _lock = acquire_db_lock().await; + let app = TestApp::new().await?; + + let password = "passw0rd"; + app.insert_user("sorting", TestUserRole::Owner).await?; + let token = app.login_token("sorting", password).await?; + + let first = app + .upload_document_with_options( + "/api/documents", + "alpha.txt", + "text/plain", + b"alpha", + None, + Some("Alpha"), + None, + &token, + ) + .await?; + assert!(first.status().is_success()); + let first_body = body_to_vec(first.into_body()).await?; + let first_doc: DocumentDetail = serde_json::from_slice(&first_body)?; + + let second = app + .upload_document_with_options( + "/api/documents", + "zulu.txt", + "text/plain", + b"zulu", + None, + Some("Zulu"), + None, + &token, + ) + .await?; + assert!(second.status().is_success()); + let second_body = body_to_vec(second.into_body()).await?; + let second_doc: DocumentDetail = serde_json::from_slice(&second_body)?; + + // Default sort should be title ASC => Alpha first. + let default_resp = app.get("/api/documents", Some(&token)).await?; + assert_eq!(default_resp.status(), StatusCode::OK); + let default_body = body_to_vec(default_resp.into_body()).await?; + let default_list: Vec = serde_json::from_slice(&default_body)?; + assert_eq!(default_list.len(), 2); + assert_eq!(default_list[0].id, first_doc.document.id); + assert_eq!(default_list[1].id, second_doc.document.id); + + // Sort by created_at DESC, expecting most recent (second) first. + let created_desc = app + .get("/api/documents?sort=created_at&dir=desc", Some(&token)) + .await?; + assert_eq!(created_desc.status(), StatusCode::OK); + let created_body = body_to_vec(created_desc.into_body()).await?; + let created_list: Vec = serde_json::from_slice(&created_body)?; + assert_eq!(created_list.len(), 2); + assert_eq!(created_list[0].id, second_doc.document.id); + assert_eq!(created_list[1].id, first_doc.document.id); + + // Folder contents respects the same parameters. + let folder_resp = app + .get( + "/api/folders/root/contents?sort=created_at&dir=desc", + Some(&token), + ) + .await?; + assert_eq!(folder_resp.status(), StatusCode::OK); + let folder_body = body_to_vec(folder_resp.into_body()).await?; + let folder_contents: FolderContents = serde_json::from_slice(&folder_body)?; + assert_eq!(folder_contents.documents.len(), 2); + assert_eq!(folder_contents.documents[0].id, second_doc.document.id); + assert_eq!(folder_contents.documents[1].id, first_doc.document.id); + + app.cleanup().await?; + Ok(()) +} + +#[tokio::test] +async fn duplicate_and_restore_document() -> Result<()> { + let _lock = acquire_db_lock().await; + let app = TestApp::new().await?; + + let password = "pass1234"; + app.insert_user("sam", TestUserRole::Owner).await?; + let token = app.login_token("sam", password).await?; + + let payload = b"same bytes".to_vec(); + let first = app + .upload_document( + "/api/documents", + "dup.bin", + "application/octet-stream", + &payload, + None, + &token, + ) + .await?; + let first_status = first.status(); + let first_body = body_to_vec(first.into_body()).await?; + assert!( + first_status == StatusCode::OK + || first_status == StatusCode::CREATED + || first_status == StatusCode::NO_CONTENT, + "unexpected first upload status {} with body {}", + first_status, + String::from_utf8_lossy(&first_body) + ); + let first_detail: DocumentDetail = serde_json::from_slice(&first_body)?; + + let second = app + .upload_document( + "/api/documents", + "dup.bin", + "application/octet-stream", + &payload, + None, + &token, + ) + .await?; + let second_status = second.status(); + let second_body = body_to_vec(second.into_body()).await?; + assert_eq!(second_status, StatusCode::CONFLICT); + let second_error: ApiErrorResponse = serde_json::from_slice(&second_body)?; + assert_eq!(second_error.code.as_deref(), Some("duplicate_document")); + let conflict_id = second_error + .details + .as_ref() + .and_then(|details| details.get("conflict_document_id")) + .and_then(|value| value.as_str()) + .and_then(|value| Uuid::parse_str(value).ok()) + .expect("conflict_document_id present"); + assert_eq!(conflict_id, first_detail.document.id); + assert_eq!(app.storage().object_count().await, 1); + + let delete = app + .post_json( + &format!("/api/documents/{}/trash", first_detail.document.id), + &json!({}), + Some(&token), + ) + .await?; + assert_eq!(delete.status(), StatusCode::NO_CONTENT); + + let trashed_conflict = app + .upload_document( + "/api/documents", + "dup.bin", + "application/octet-stream", + &payload, + None, + &token, + ) + .await?; + assert_eq!(trashed_conflict.status(), StatusCode::CONFLICT); + let trashed_body = body_to_vec(trashed_conflict.into_body()).await?; + let trashed_error: ApiErrorResponse = serde_json::from_slice(&trashed_body)?; + assert_eq!(trashed_error.code.as_deref(), Some("duplicate_document")); + assert!(trashed_error.error.contains("trash")); + let trashed_details = trashed_error.details.as_ref().expect("details present"); + assert_eq!( + trashed_details + .get("conflict_document_in_trash") + .and_then(|value| value.as_bool()), + Some(true) + ); + + let third = app + .upload_document_with_extras( + "/api/documents", + "dup.bin", + "application/octet-stream", + &payload, + None, + UploadExtras { + title: None, + metadata_json: None, + tag_ids_json: None, + correspondents_json: None, + issued_at: None, + skip_existing: Some(false), + }, + &token, + ) + .await?; + let third_status = third.status(); + let third_body = body_to_vec(third.into_body()).await?; + assert!( + third_status == StatusCode::OK + || third_status == StatusCode::CREATED + || third_status == StatusCode::NO_CONTENT, + "unexpected third upload status {} with body {}", + third_status, + String::from_utf8_lossy(&third_body) + ); + let third_detail: DocumentDetail = serde_json::from_slice(&third_body)?; + + assert_eq!(third_detail.document.id, first_detail.document.id); + assert_eq!(third_detail.document.deleted_at, None); + assert!(third_detail + .document + .current_version + .as_ref() + .expect("third current version") + .assets + .is_empty()); + assert_eq!(app.storage().object_count().await, 1); + + app.cleanup().await?; + Ok(()) +} + +#[tokio::test] +async fn upload_skips_existing_when_requested() -> Result<()> { + let _lock = acquire_db_lock().await; + let app = TestApp::new().await?; + + let password = "skip-doc"; + app.insert_user("skip", TestUserRole::Owner).await?; + let token = app.login_token("skip", password).await?; + + let primary_tag_payload = CreateTagPayload { + label: "primary", + color: None, + }; + let primary_tag_resp = app + .post_json("/api/tags", &primary_tag_payload, Some(&token)) + .await?; + { + let status = primary_tag_resp.status(); + assert!( + status == StatusCode::OK + || status == StatusCode::CREATED + || status == StatusCode::NO_CONTENT + ); + } + let primary_tag_body = body_to_vec(primary_tag_resp.into_body()).await?; + let primary_tag: TagResponse = serde_json::from_slice(&primary_tag_body)?; + + let payload = b"identical document payload"; + let primary_tag_ids = format!("[\"{}\"]", primary_tag.id); + let extras = UploadExtras { + title: Some("Original"), + metadata_json: None, + tag_ids_json: Some(primary_tag_ids.as_str()), + correspondents_json: None, + issued_at: None, + skip_existing: None, + }; + + let first_upload = app + .upload_document_with_extras( + "/api/documents", + "original.pdf", + "application/pdf", + payload, + None, + extras, + &token, + ) + .await?; + { + let status = first_upload.status(); + assert!( + status == StatusCode::OK + || status == StatusCode::CREATED + || status == StatusCode::NO_CONTENT + ); + } + let first_body = body_to_vec(first_upload.into_body()).await?; + let first_detail: DocumentDetail = serde_json::from_slice(&first_body)?; + + let alt_tag_payload = CreateTagPayload { + label: "alternate", + color: None, + }; + let alt_tag_resp = app + .post_json("/api/tags", &alt_tag_payload, Some(&token)) + .await?; + { + let status = alt_tag_resp.status(); + assert!( + status == StatusCode::OK + || status == StatusCode::CREATED + || status == StatusCode::NO_CONTENT + ); + } + let alt_tag_body = body_to_vec(alt_tag_resp.into_body()).await?; + let alt_tag: TagResponse = serde_json::from_slice(&alt_tag_body)?; + + let alt_tag_ids = format!("[\"{}\"]", alt_tag.id); + let skip_extras = UploadExtras { + title: Some("Updated"), + metadata_json: None, + tag_ids_json: Some(alt_tag_ids.as_str()), + correspondents_json: None, + issued_at: None, + skip_existing: Some(true), + }; + + let skip_resp = app + .upload_document_with_extras( + "/api/documents", + "ignored.pdf", + "application/pdf", + payload, + None, + skip_extras, + &token, + ) + .await?; + assert_eq!(skip_resp.status(), StatusCode::CONFLICT); + let skip_body = body_to_vec(skip_resp.into_body()).await?; + let skip_error: ApiErrorResponse = serde_json::from_slice(&skip_body)?; + assert_eq!(skip_error.code.as_deref(), Some("duplicate_document")); + let conflict_id = skip_error + .details + .as_ref() + .and_then(|details| details.get("conflict_document_id")) + .and_then(|value| value.as_str()) + .and_then(|value| Uuid::parse_str(value).ok()) + .expect("conflict_document_id present"); + assert_eq!(conflict_id, first_detail.document.id); + + let fetch = app + .get( + &format!("/api/documents/{}", first_detail.document.id), + Some(&token), + ) + .await?; + { + let status = fetch.status(); + assert!( + status == StatusCode::OK + || status == StatusCode::CREATED + || status == StatusCode::NO_CONTENT + ); + } + let fetch_body = body_to_vec(fetch.into_body()).await?; + let fetched: DocumentDetail = serde_json::from_slice(&fetch_body)?; + + assert_eq!(fetched.document.id, first_detail.document.id); + assert_eq!(fetched.document.title, first_detail.document.title); + assert_eq!(fetched.document.tags.len(), 1); + assert_eq!(fetched.document.tags[0].label, "primary"); + + app.cleanup().await?; + Ok(()) +} + +#[tokio::test] +async fn filter_documents_without_tags() -> Result<()> { + let _lock = acquire_db_lock().await; + let app = TestApp::new().await?; + + let password = "tagfilter"; + app.insert_user("tagfilter", TestUserRole::Owner).await?; + let token = app.login_token("tagfilter", password).await?; + + // Create a tag and upload a document that uses it. + let tag_payload = CreateTagPayload { + label: "with-tag", + color: None, + }; + let tag_resp = app + .post_json("/api/tags", &tag_payload, Some(&token)) + .await?; + assert!(tag_resp.status().is_success()); + let tag_body = body_to_vec(tag_resp.into_body()).await?; + let tag: TagResponse = serde_json::from_slice(&tag_body)?; + + let tag_json = format!("[\"{}\"]", tag.id); + let tagged_upload = app + .upload_document_with_extras( + "/api/documents", + "with-tag.txt", + "text/plain", + b"tagged", + None, + UploadExtras { + title: Some("With Tag"), + metadata_json: None, + tag_ids_json: Some(tag_json.as_str()), + correspondents_json: None, + issued_at: None, + skip_existing: None, + }, + &token, + ) + .await?; + assert!(tagged_upload.status().is_success()); + + let untagged_upload = app + .upload_document( + "/api/documents", + "without-tag.txt", + "text/plain", + b"untagged", + None, + &token, + ) + .await?; + assert!(untagged_upload.status().is_success()); + let untagged_body = body_to_vec(untagged_upload.into_body()).await?; + let untagged_detail: DocumentDetail = serde_json::from_slice(&untagged_body)?; + + // Sanity: both documents appear in the default listing. + let all_resp = app.get("/api/documents", Some(&token)).await?; + assert_eq!(all_resp.status(), StatusCode::OK); + let all_body = body_to_vec(all_resp.into_body()).await?; + let all_docs: Vec = serde_json::from_slice(&all_body)?; + assert_eq!(all_docs.len(), 2); + + // Filter for documents without tags. + let none_resp = app.get("/api/documents?tags=none", Some(&token)).await?; + assert_eq!(none_resp.status(), StatusCode::OK); + let none_body = body_to_vec(none_resp.into_body()).await?; + let none_docs: Vec = serde_json::from_slice(&none_body)?; + assert_eq!(none_docs.len(), 1); + assert_eq!(none_docs[0].id, untagged_detail.document.id); + + app.cleanup().await?; + Ok(()) +} + +#[tokio::test] +async fn bulk_move_documents_to_folder() -> Result<()> { + let _lock = acquire_db_lock().await; + let app = TestApp::new().await?; + + let password = "bulkmove"; + app.insert_user("mover", TestUserRole::Owner).await?; + let token = app.login_token("mover", password).await?; + + let alpha = app + .upload_document( + "/api/documents", + "alpha.txt", + "text/plain", + b"alpha", + None, + &token, + ) + .await?; + { + let status = alpha.status(); + assert!( + status == StatusCode::OK + || status == StatusCode::CREATED + || status == StatusCode::NO_CONTENT + ); + } + let alpha_body = body_to_vec(alpha.into_body()).await?; + let alpha_detail: DocumentDetail = serde_json::from_slice(&alpha_body)?; + + let beta = app + .upload_document( + "/api/documents", + "beta.txt", + "text/plain", + b"beta", + None, + &token, + ) + .await?; + { + let status = beta.status(); + assert!(status.is_success(), "status was {}", status); + } + let beta_body = body_to_vec(beta.into_body()).await?; + let beta_detail: DocumentDetail = serde_json::from_slice(&beta_body)?; + + let folder_resp = app + .post_json( + "/api/folders", + &CreateFolderRequest { + name: "Archives", + parent_id: None, + }, + Some(&token), + ) + .await?; + { + let status = folder_resp.status(); + assert!(status.is_success(), "status was {}", status); + } + let folder_body = body_to_vec(folder_resp.into_body()).await?; + let folder: FolderResponse = serde_json::from_slice(&folder_body)?; + + let move_resp = app + .post_json( + "/api/documents/bulk/move", + &BulkMoveRequest { + document_ids: &[alpha_detail.document.id, beta_detail.document.id], + folder_id: Some(folder.folder.id), + }, + Some(&token), + ) + .await?; + let move_status = move_resp.status(); + let move_body = body_to_vec(move_resp.into_body()).await?; + assert!( + move_status.is_success(), + "status was {} body {}", + move_status, + String::from_utf8_lossy(&move_body) + ); + let result: BulkMoveResult = serde_json::from_slice(&move_body)?; + assert_eq!(result.updated, 2); + + let folder_contents = app + .get( + &format!("/api/folders/{}/contents", folder.folder.id), + Some(&token), + ) + .await?; + { + let status = folder_contents.status(); + assert!(status.is_success(), "status was {}", status); + } + let folder_body = body_to_vec(folder_contents.into_body()).await?; + let folder_docs: FolderContents = serde_json::from_slice(&folder_body)?; + let moved_ids: Vec<_> = folder_docs.documents.iter().map(|doc| doc.id).collect(); + assert!(moved_ids.contains(&alpha_detail.document.id)); + assert!(moved_ids.contains(&beta_detail.document.id)); + + let root_contents = app.get("/api/folders/root/contents", Some(&token)).await?; + let root_body = body_to_vec(root_contents.into_body()).await?; + let root_docs: FolderContents = serde_json::from_slice(&root_body)?; + assert!(root_docs + .documents + .iter() + .all(|doc| doc.id != alpha_detail.document.id && doc.id != beta_detail.document.id)); + + app.cleanup().await?; + Ok(()) +} + +#[tokio::test] +async fn bulk_add_tags_for_selection() -> Result<()> { + let _lock = acquire_db_lock().await; + let app = TestApp::new().await?; + + let password = "bulktags"; + app.insert_user("tagger", TestUserRole::Owner).await?; + let token = app.login_token("tagger", password).await?; + + let first = app + .upload_document( + "/api/documents", + "notes.txt", + "text/plain", + b"notes", + None, + &token, + ) + .await?; + { + let status = first.status(); + assert!( + status == StatusCode::OK + || status == StatusCode::CREATED + || status == StatusCode::NO_CONTENT + ); + } + let first_body = body_to_vec(first.into_body()).await?; + let first_detail: DocumentDetail = serde_json::from_slice(&first_body)?; + + let second = app + .upload_document( + "/api/documents", + "report.txt", + "text/plain", + b"report", + None, + &token, + ) + .await?; + { + let status = second.status(); + assert!( + status == StatusCode::OK + || status == StatusCode::CREATED + || status == StatusCode::NO_CONTENT + ); + } + let second_body = body_to_vec(second.into_body()).await?; + let second_detail: DocumentDetail = serde_json::from_slice(&second_body)?; + + let urgent_tag = app + .post_json( + "/api/tags", + &CreateTagPayload { + label: "Urgent", + color: None, + }, + Some(&token), + ) + .await?; + { + let status = urgent_tag.status(); + assert!( + status == StatusCode::OK + || status == StatusCode::CREATED + || status == StatusCode::NO_CONTENT + ); + } + let urgent_body = body_to_vec(urgent_tag.into_body()).await?; + let urgent: TagResponse = serde_json::from_slice(&urgent_body)?; + + let review_tag = app + .post_json( + "/api/tags", + &CreateTagPayload { + label: "Review", + color: None, + }, + Some(&token), + ) + .await?; + { + let status = review_tag.status(); + assert!( + status == StatusCode::OK + || status == StatusCode::CREATED + || status == StatusCode::NO_CONTENT + ); + } + let review_body = body_to_vec(review_tag.into_body()).await?; + let review: TagResponse = serde_json::from_slice(&review_body)?; + + let add_resp = app + .post_json( + "/api/documents/bulk/tags", + &BulkTagRequest { + document_ids: &[first_detail.document.id, second_detail.document.id], + tag_ids: &[urgent.id, review.id], + action: "add", + }, + Some(&token), + ) + .await?; + { + let status = add_resp.status(); + assert!( + status == StatusCode::OK + || status == StatusCode::CREATED + || status == StatusCode::NO_CONTENT + ); + } + let add_body = body_to_vec(add_resp.into_body()).await?; + let add_result: BulkTagResult = serde_json::from_slice(&add_body)?; + assert_eq!(add_result.added, 4); + + for doc_id in [&first_detail.document.id, &second_detail.document.id] { + let refreshed = app + .get(&format!("/api/documents/{}", doc_id), Some(&token)) + .await?; + { + let status = refreshed.status(); + assert!(status == StatusCode::OK || status == StatusCode::CREATED); + } + let refreshed_body = body_to_vec(refreshed.into_body()).await?; + let detail: DocumentDetail = serde_json::from_slice(&refreshed_body)?; + let labels: Vec<_> = detail + .document + .tags + .iter() + .map(|tag| tag.label.as_str()) + .collect(); + assert!(labels.contains(&"Urgent")); + assert!(labels.contains(&"Review")); + } + + app.cleanup().await?; + Ok(()) +} + +#[tokio::test] +async fn bulk_remove_tags_from_selection() -> Result<()> { + let _lock = acquire_db_lock().await; + let app = TestApp::new().await?; + + let password = "bulktagremove"; + app.insert_user("tagrem", TestUserRole::Owner).await?; + let token = app.login_token("tagrem", password).await?; + + let first = app + .upload_document( + "/api/documents", + "meeting-notes.txt", + "text/plain", + b"notes", + None, + &token, + ) + .await?; + let first_body = body_to_vec(first.into_body()).await?; + let first_detail: DocumentDetail = serde_json::from_slice(&first_body)?; + + let second = app + .upload_document( + "/api/documents", + "draft.txt", + "text/plain", + b"draft", + None, + &token, + ) + .await?; + let second_body = body_to_vec(second.into_body()).await?; + let second_detail: DocumentDetail = serde_json::from_slice(&second_body)?; + + let urgent_tag = app + .post_json( + "/api/tags", + &CreateTagPayload { + label: "Urgent", + color: None, + }, + Some(&token), + ) + .await?; + let urgent_body = body_to_vec(urgent_tag.into_body()).await?; + let urgent: TagResponse = serde_json::from_slice(&urgent_body)?; + + let review_tag = app + .post_json( + "/api/tags", + &CreateTagPayload { + label: "Review", + color: None, + }, + Some(&token), + ) + .await?; + let review_body = body_to_vec(review_tag.into_body()).await?; + let review: TagResponse = serde_json::from_slice(&review_body)?; + + let seed_resp = app + .post_json( + "/api/documents/bulk/tags", + &BulkTagRequest { + document_ids: &[first_detail.document.id, second_detail.document.id], + tag_ids: &[urgent.id, review.id], + action: "add", + }, + Some(&token), + ) + .await?; + assert!(seed_resp.status().is_success()); + + let remove_resp = app + .post_json( + "/api/documents/bulk/tags", + &BulkTagRequest { + document_ids: &[first_detail.document.id, second_detail.document.id], + tag_ids: &[urgent.id], + action: "remove", + }, + Some(&token), + ) + .await?; + assert!(remove_resp.status().is_success()); + let remove_body = body_to_vec(remove_resp.into_body()).await?; + let remove_result: BulkTagResult = serde_json::from_slice(&remove_body)?; + assert_eq!(remove_result.removed, 2); + assert_eq!(remove_result.added, 0); + + for doc_id in [&first_detail.document.id, &second_detail.document.id] { + let refreshed = app + .get(&format!("/api/documents/{}", doc_id), Some(&token)) + .await?; + let refreshed_body = body_to_vec(refreshed.into_body()).await?; + let detail: DocumentDetail = serde_json::from_slice(&refreshed_body)?; + let labels: Vec<_> = detail + .document + .tags + .iter() + .map(|tag| tag.label.as_str()) + .collect(); + assert!(!labels.contains(&"Urgent")); + assert!(labels.contains(&"Review")); + } + + let idempotent_resp = app + .post_json( + "/api/documents/bulk/tags", + &BulkTagRequest { + document_ids: &[first_detail.document.id, second_detail.document.id], + tag_ids: &[urgent.id], + action: "remove", + }, + Some(&token), + ) + .await?; + assert!(idempotent_resp.status().is_success()); + let idempotent_body = body_to_vec(idempotent_resp.into_body()).await?; + let idempotent_result: BulkTagResult = serde_json::from_slice(&idempotent_body)?; + assert_eq!(idempotent_result.removed, 0); + assert_eq!(idempotent_result.added, 0); + + app.cleanup().await?; + Ok(()) +} + +#[tokio::test] +async fn bulk_reanalyze_selected_documents() -> Result<()> { + let _lock = acquire_db_lock().await; + let app = TestApp::new().await?; + + let password = "subsetrean"; + app.insert_user("subset", TestUserRole::Owner).await?; + let token = app.login_token("subset", password).await?; + + app.clear_jobs().await?; + + let first = app + .upload_document( + "/api/documents", + "doc-one.txt", + "text/plain", + b"one", + None, + &token, + ) + .await?; + let first_body = body_to_vec(first.into_body()).await?; + let first_detail: DocumentDetail = serde_json::from_slice(&first_body)?; + + let second = app + .upload_document( + "/api/documents", + "doc-two.txt", + "text/plain", + b"two", + None, + &token, + ) + .await?; + let second_body = body_to_vec(second.into_body()).await?; + let second_detail: DocumentDetail = serde_json::from_slice(&second_body)?; + + let third = app + .upload_document( + "/api/documents", + "doc-three.txt", + "text/plain", + b"three", + None, + &token, + ) + .await?; + let third_body = body_to_vec(third.into_body()).await?; + let third_detail: DocumentDetail = serde_json::from_slice(&third_body)?; + + app.clear_jobs().await?; + + let response = app + .post_json( + "/api/documents/bulk/reanalyze", + &serde_json::json!({ + "document_ids": [ + first_detail.document.id, + third_detail.document.id + ], + "force": true + }), + Some(&token), + ) + .await?; + assert_eq!(response.status(), StatusCode::ACCEPTED); + let body = body_to_vec(response.into_body()).await?; + let bulk: BulkReanalyze = serde_json::from_slice(&body)?; + assert_eq!(bulk.queued, 2); + + let jobs = app.jobs_by_type("analyze-document").await?; + assert_eq!(jobs.len(), 2); + let mut payload_docs = Vec::new(); + for job in jobs { + let payload: AnalyzeJobPayload = serde_json::from_value(job.payload)?; + assert!(payload.force); + payload_docs.push((payload.document_id, payload.document_version_id)); + } + + assert!(payload_docs + .iter() + .all(|(doc_id, _)| *doc_id != second_detail.document.id)); + + let mut expected = vec![ + ( + first_detail.document.id, + first_detail + .document + .current_version + .as_ref() + .expect("first current version") + .id, + ), + ( + third_detail.document.id, + third_detail + .document + .current_version + .as_ref() + .expect("third current version") + .id, + ), + ]; + payload_docs.sort(); + expected.sort(); + assert_eq!(payload_docs, expected); + + app.cleanup().await?; + Ok(()) +} + +#[tokio::test] +async fn patch_document_updates_title_and_handles_conflict() -> Result<()> { + let _lock = acquire_db_lock().await; + let app = TestApp::new().await?; + + let password = "patch-title"; + app.insert_user("editor", TestUserRole::Owner).await?; + let token = app.login_token("editor", password).await?; + + let first_upload = app + .upload_document( + "/api/documents", + "report.pdf", + "application/pdf", + b"fake pdf contents", + None, + &token, + ) + .await?; + let first_body = body_to_vec(first_upload.into_body()).await?; + let mut first_detail: DocumentDetail = serde_json::from_slice(&first_body)?; + + let update = app + .patch_json( + &format!("/api/documents/{}", first_detail.document.id), + &json!({ + "title": "Quarterly Summary" + }), + Some(&token), + ) + .await?; + assert_eq!(update.status(), StatusCode::OK); + let update_body = body_to_vec(update.into_body()).await?; + first_detail = serde_json::from_slice(&update_body)?; + assert_eq!(first_detail.document.title, "Quarterly Summary"); + assert_eq!(first_detail.document.filename, "Quarterly Summary.pdf"); + + let second_upload = app + .upload_document( + "/api/documents", + "notes.pdf", + "application/pdf", + b"other pdf", + None, + &token, + ) + .await?; + let second_body = body_to_vec(second_upload.into_body()).await?; + let second_detail: DocumentDetail = serde_json::from_slice(&second_body)?; + + let conflict = app + .patch_json( + &format!("/api/documents/{}", second_detail.document.id), + &json!({ + "title": "Quarterly Summary" + }), + Some(&token), + ) + .await?; + assert_eq!(conflict.status(), StatusCode::CONFLICT); + let conflict_body = body_to_vec(conflict.into_body()).await?; + let conflict_json: ApiErrorResponse = serde_json::from_slice(&conflict_body)?; + assert_eq!(conflict_json.code.as_deref(), Some("duplicate_filename")); + + app.cleanup().await?; + Ok(()) +} + +#[tokio::test] +async fn patch_document_updates_and_clears_issued_at() -> Result<()> { + let _lock = acquire_db_lock().await; + let app = TestApp::new().await?; + + let password = "patch-issued"; + app.insert_user("scheduler", TestUserRole::Owner).await?; + let token = app.login_token("scheduler", password).await?; + + let upload = app + .upload_document( + "/api/documents", + "invoice.txt", + "text/plain", + b"invoice contents", + None, + &token, + ) + .await?; + let body = body_to_vec(upload.into_body()).await?; + let detail: DocumentDetail = serde_json::from_slice(&body)?; + assert!(detail.document.issued_at.is_none()); + + let issued_at = "2021-02-03T04:05:06Z"; + let response = app + .patch_json( + &format!("/api/documents/{}", detail.document.id), + &json!({ + "issued_at": issued_at + }), + Some(&token), + ) + .await?; + assert_eq!(response.status(), StatusCode::OK); + let body = body_to_vec(response.into_body()).await?; + let patched: DocumentDetail = serde_json::from_slice(&body)?; + assert_eq!( + patched.document.issued_at.as_deref(), + Some("2021-02-03T04:05:06+00:00") + ); + + let cleared = app + .patch_json( + &format!("/api/documents/{}", detail.document.id), + &json!({ + "issued_at": null + }), + Some(&token), + ) + .await?; + let cleared_status = cleared.status(); + let cleared_body = body_to_vec(cleared.into_body()).await?; + assert!( + cleared_status == StatusCode::OK, + "clear issued_at failed status {} body {}", + cleared_status, + String::from_utf8_lossy(&cleared_body) + ); + let cleared_detail: DocumentDetail = serde_json::from_slice(&cleared_body)?; + assert!(cleared_detail.document.issued_at.is_none()); + + app.cleanup().await?; + Ok(()) +} + +#[tokio::test] +async fn patch_document_metadata_merge_and_replace() -> Result<()> { + let _lock = acquire_db_lock().await; + let app = TestApp::new().await?; + + let password = "patch-meta"; + app.insert_user("curator", TestUserRole::Owner).await?; + let token = app.login_token("curator", password).await?; + + let initial_metadata = r#"{"existing":{"keep":true},"other":1}"#; + let upload = app + .upload_document_with_options( + "/api/documents", + "meta.txt", + "text/plain", + b"meta", + None, + None, + Some(initial_metadata), + &token, + ) + .await?; + let upload_body = body_to_vec(upload.into_body()).await?; + let detail: DocumentDetail = serde_json::from_slice(&upload_body)?; + assert_eq!( + detail.document.metadata, + json!({ + "existing": {"keep": true}, + "other": 1 + }) + ); + + let merge_response = app + .patch_json( + &format!("/api/documents/{}", detail.document.id), + &json!({ + "metadata": { + "value": { + "existing": {"update": 5}, + "added": "new" + } + } + }), + Some(&token), + ) + .await?; + assert_eq!(merge_response.status(), StatusCode::OK); + let merge_body = body_to_vec(merge_response.into_body()).await?; + let merged: DocumentDetail = serde_json::from_slice(&merge_body)?; + assert_eq!( + merged.document.metadata, + json!({ + "existing": {"keep": true, "update": 5}, + "other": 1, + "added": "new" + }) + ); + + let replace_response = app + .patch_json( + &format!("/api/documents/{}", detail.document.id), + &json!({ + "metadata": { + "replace": true, + "value": {"fresh": true} + } + }), + Some(&token), + ) + .await?; + assert_eq!(replace_response.status(), StatusCode::OK); + let replace_body = body_to_vec(replace_response.into_body()).await?; + let replaced: DocumentDetail = serde_json::from_slice(&replace_body)?; + assert_eq!(replaced.document.metadata, json!({"fresh": true})); + + app.cleanup().await?; + Ok(()) +} + +#[tokio::test] +async fn patch_document_validation_errors() -> Result<()> { + let _lock = acquire_db_lock().await; + let app = TestApp::new().await?; + + let password = "patch-errors"; + app.insert_user("auditor", TestUserRole::Owner).await?; + let token = app.login_token("auditor", password).await?; + + let upload = app + .upload_document( + "/api/documents", + "errors.txt", + "text/plain", + b"errors", + None, + &token, + ) + .await?; + let body = body_to_vec(upload.into_body()).await?; + let detail: DocumentDetail = serde_json::from_slice(&body)?; + + let empty_title = app + .patch_json( + &format!("/api/documents/{}", detail.document.id), + &json!({ "title": " " }), + Some(&token), + ) + .await?; + assert_eq!(empty_title.status(), StatusCode::BAD_REQUEST); + let title_body = body_to_vec(empty_title.into_body()).await?; + let title_error: ApiErrorResponse = serde_json::from_slice(&title_body)?; + assert_eq!(title_error.error, "title must not be empty"); + + let empty_issued = app + .patch_json( + &format!("/api/documents/{}", detail.document.id), + &json!({ "issued_at": "" }), + Some(&token), + ) + .await?; + assert_eq!(empty_issued.status(), StatusCode::BAD_REQUEST); + let issued_body = body_to_vec(empty_issued.into_body()).await?; + let issued_error: ApiErrorResponse = serde_json::from_slice(&issued_body)?; + assert_eq!(issued_error.error, "issued_at must not be empty"); + + let invalid_merge = app + .patch_json( + &format!("/api/documents/{}", detail.document.id), + &json!({ + "metadata": { + "value": 5 + } + }), + Some(&token), + ) + .await?; + assert_eq!(invalid_merge.status(), StatusCode::BAD_REQUEST); + let merge_body = body_to_vec(invalid_merge.into_body()).await?; + let merge_error: ApiErrorResponse = serde_json::from_slice(&merge_body)?; + assert_eq!( + merge_error.error, + "metadata value must be a JSON object when replace is false" + ); + + let replace_scalar = app + .patch_json( + &format!("/api/documents/{}", detail.document.id), + &json!({ + "metadata": { + "replace": true, + "value": 5 + } + }), + Some(&token), + ) + .await?; + assert_eq!(replace_scalar.status(), StatusCode::OK); + let replace_body = body_to_vec(replace_scalar.into_body()).await?; + let replace_detail: DocumentDetail = serde_json::from_slice(&replace_body)?; + assert_eq!(replace_detail.document.metadata, json!(5)); + + let merge_after_scalar = app + .patch_json( + &format!("/api/documents/{}", detail.document.id), + &json!({ + "metadata": { + "value": { "new": 1 } + } + }), + Some(&token), + ) + .await?; + assert_eq!(merge_after_scalar.status(), StatusCode::BAD_REQUEST); + let merge_after_body = body_to_vec(merge_after_scalar.into_body()).await?; + let merge_after_error: ApiErrorResponse = serde_json::from_slice(&merge_after_body)?; + assert_eq!( + merge_after_error.error, + "existing metadata is not an object; set replace=true to overwrite" + ); + + let malformed_timestamp = app + .patch_json( + &format!("/api/documents/{}", detail.document.id), + &json!({ "issued_at": "not-a-timestamp" }), + Some(&token), + ) + .await?; + assert_eq!(malformed_timestamp.status(), StatusCode::BAD_REQUEST); + let malformed_body = body_to_vec(malformed_timestamp.into_body()).await?; + let malformed_error: ApiErrorResponse = serde_json::from_slice(&malformed_body)?; + assert!( + malformed_error + .error + .starts_with("issued_at must be an RFC3339 timestamp"), + "unexpected error: {}", + malformed_error.error + ); + + app.cleanup().await?; + Ok(()) +} + +#[tokio::test] +async fn patch_document_updates_multiple_fields() -> Result<()> { + let _lock = acquire_db_lock().await; + let app = TestApp::new().await?; + + let password = "patch-multi"; + app.insert_user("planner", TestUserRole::Owner).await?; + let token = app.login_token("planner", password).await?; + + let upload = app + .upload_document( + "/api/documents", + "multi.pdf", + "application/pdf", + b"multi", + None, + &token, + ) + .await?; + let body = body_to_vec(upload.into_body()).await?; + let detail: DocumentDetail = serde_json::from_slice(&body)?; + + let response = app + .patch_json( + &format!("/api/documents/{}", detail.document.id), + &json!({ + "title": "Annual Report", + "issued_at": "2022-05-01T12:00:00Z", + "metadata": { + "value": { + "department": "finance", + "year": 2022 + } + } + }), + Some(&token), + ) + .await?; + assert_eq!(response.status(), StatusCode::OK); + let response_body = body_to_vec(response.into_body()).await?; + let updated: DocumentDetail = serde_json::from_slice(&response_body)?; + assert_eq!(updated.document.title, "Annual Report"); + assert_eq!(updated.document.filename, "Annual Report.pdf"); + assert_eq!( + updated.document.issued_at.as_deref(), + Some("2022-05-01T12:00:00+00:00") + ); + assert_eq!( + updated.document.metadata, + json!({ + "department": "finance", + "year": 2022 + }) + ); + + app.cleanup().await?; + Ok(()) +} + +#[tokio::test] +async fn list_documents_by_status_filter() -> Result<()> { + let _lock = acquire_db_lock().await; + let app = TestApp::new().await?; + + let password = "statusfilter"; + app.insert_user("statususer", TestUserRole::Owner).await?; + let token = app.login_token("statususer", password).await?; + + let upload = app + .upload_document( + "/api/documents", + "trash.txt", + "text/plain", + b"trash", + None, + &token, + ) + .await?; + let body = body_to_vec(upload.into_body()).await?; + let detail: DocumentDetail = serde_json::from_slice(&body)?; + + let delete_resp = app + .post_json( + &format!("/api/documents/{}/trash", detail.document.id), + &json!({}), + Some(&token), + ) + .await?; + assert_eq!(delete_resp.status(), StatusCode::NO_CONTENT); + + let active_resp = app.get("/api/documents", Some(&token)).await?; + assert!(active_resp.status().is_success()); + let active_body = body_to_vec(active_resp.into_body()).await?; + let active_docs: Vec = serde_json::from_slice(&active_body)?; + assert!(active_docs.iter().all(|doc| doc.id != detail.document.id)); + + let deleted_resp = app + .get("/api/documents?status=deleted", Some(&token)) + .await?; + assert!(deleted_resp.status().is_success()); + let deleted_body = body_to_vec(deleted_resp.into_body()).await?; + let deleted_docs: Vec = serde_json::from_slice(&deleted_body)?; + assert!(deleted_docs.iter().any(|doc| doc.id == detail.document.id)); + + let all_resp = app.get("/api/documents?status=all", Some(&token)).await?; + assert!(all_resp.status().is_success()); + let all_body = body_to_vec(all_resp.into_body()).await?; + let all_docs: Vec = serde_json::from_slice(&all_body)?; + assert!(all_docs.iter().any(|doc| doc.id == detail.document.id)); + + app.cleanup().await?; + Ok(()) +} + +#[tokio::test] +async fn trash_document_requires_active_state() -> Result<()> { + let _lock = acquire_db_lock().await; + let app = TestApp::new().await?; + + let password = "trashstate"; + app.insert_user("trashstate", TestUserRole::Owner).await?; + let token = app.login_token("trashstate", password).await?; + + let upload = app + .upload_document( + "/api/documents", + "trash-once.txt", + "text/plain", + b"trash", + None, + &token, + ) + .await?; + let body = body_to_vec(upload.into_body()).await?; + let detail: DocumentDetail = serde_json::from_slice(&body)?; + + let first = app + .post_json( + &format!("/api/documents/{}/trash", detail.document.id), + &json!({}), + Some(&token), + ) + .await?; + assert_eq!(first.status(), StatusCode::NO_CONTENT); + + let second = app + .post_json( + &format!("/api/documents/{}/trash", detail.document.id), + &json!({}), + Some(&token), + ) + .await?; + assert_eq!(second.status(), StatusCode::CONFLICT); + + app.cleanup().await?; + Ok(()) +} +#[tokio::test] +async fn purge_document_removes_data() -> Result<()> { + let _lock = acquire_db_lock().await; + let app = TestApp::new().await?; + + let password = "purge"; + app.insert_user("purger", TestUserRole::Owner).await?; + let token = app.login_token("purger", password).await?; + + let upload = app + .upload_document( + "/api/documents", + "purge.bin", + "application/octet-stream", + b"permanent", + None, + &token, + ) + .await?; + let body = body_to_vec(upload.into_body()).await?; + let detail: DocumentDetail = serde_json::from_slice(&body)?; + let document_id = detail.document.id; + + assert_eq!(app.storage().object_count().await, 1); + + let trash_resp = app + .post_json( + &format!("/api/documents/{}/trash", document_id), + &json!({}), + Some(&token), + ) + .await?; + assert_eq!(trash_resp.status(), StatusCode::NO_CONTENT); + + let delete_resp = app + .delete(&format!("/api/documents/{}", document_id), Some(&token)) + .await?; + assert_eq!(delete_resp.status(), StatusCode::ACCEPTED); + + let duplicate_delete = app + .delete(&format!("/api/documents/{}", document_id), Some(&token)) + .await?; + assert_eq!(duplicate_delete.status(), StatusCode::ACCEPTED); + + let purge_job_count: i64 = app + .with_conn(|conn| { + use diesel::dsl::count_star; + use diesel::prelude::*; + use papercrate::schema::jobs::dsl::*; + + let count: i64 = jobs + .filter(job_type.eq(JOB_PURGE_DOCUMENT)) + .select(count_star()) + .get_result(conn)?; + Ok(count) + }) + .await?; + assert_eq!(purge_job_count, 1); + + let job: Job = app + .with_conn(|conn| { + use diesel::prelude::*; + use papercrate::schema::jobs::dsl::*; + + let job = jobs + .filter(job_type.eq(JOB_PURGE_DOCUMENT)) + .order(created_at.desc()) + .first(conn)?; + Ok(job) + }) + .await?; + + let handler = PurgeDocumentJob::new(); + let state = Arc::new(app.state.clone()); + let storage = app + .state + .storage_for_tenant(job.tenant_id.expect("job should have tenant")) + .map_err(|err| anyhow!("tenant storage unavailable: {err:?}"))?; + let execution = handler.handle(state, job.clone(), storage).await; + assert!(matches!(execution, JobExecution::Success)); + + app.with_conn(move |conn| { + mark_job_succeeded(conn, job.id)?; + Ok(()) + }) + .await?; + + let fetch = app + .get(&format!("/api/documents/{}", document_id), Some(&token)) + .await?; + assert_eq!(fetch.status(), StatusCode::NOT_FOUND); + + assert_eq!(app.storage().object_count().await, 0); + + app.cleanup().await?; + Ok(()) +} + +#[tokio::test] +async fn delete_document_requires_trash() -> Result<()> { + let _lock = acquire_db_lock().await; + let app = TestApp::new().await?; + + let password = "conflict"; + app.insert_user("conflict-user", TestUserRole::Owner) + .await?; + let token = app.login_token("conflict-user", password).await?; + + let upload = app + .upload_document( + "/api/documents", + "conflict.bin", + "application/octet-stream", + b"restore", + None, + &token, + ) + .await?; + let body = body_to_vec(upload.into_body()).await?; + let detail: DocumentDetail = serde_json::from_slice(&body)?; + + let delete_resp = app + .delete( + &format!("/api/documents/{}", detail.document.id), + Some(&token), + ) + .await?; + assert_eq!(delete_resp.status(), StatusCode::CONFLICT); + + app.cleanup().await?; + Ok(()) +} + +#[tokio::test] +async fn restore_document_to_original_and_custom_folder() -> Result<()> { + let _lock = acquire_db_lock().await; + let app = TestApp::new().await?; + + let password = "restoretest"; + app.insert_user("restorer", TestUserRole::Owner).await?; + let token = app.login_token("restorer", password).await?; + + let upload = app + .upload_document( + "/api/documents", + "to-restore.txt", + "text/plain", + b"restore", + None, + &token, + ) + .await?; + let body = body_to_vec(upload.into_body()).await?; + let detail: DocumentDetail = serde_json::from_slice(&body)?; + + let delete_resp = app + .post_json( + &format!("/api/documents/{}/trash", detail.document.id), + &json!({}), + Some(&token), + ) + .await?; + assert_eq!(delete_resp.status(), StatusCode::NO_CONTENT); + + let restore_resp = app + .post_json( + &format!("/api/documents/{}/restore", detail.document.id), + &serde_json::json!({}), + Some(&token), + ) + .await?; + assert_eq!(restore_resp.status(), StatusCode::NO_CONTENT); + + let fetched = app + .get( + &format!("/api/documents/{}", detail.document.id), + Some(&token), + ) + .await?; + assert!(fetched.status().is_success()); + let fetched_body = body_to_vec(fetched.into_body()).await?; + let fetched_detail: DocumentDetail = serde_json::from_slice(&fetched_body)?; + assert!(fetched_detail.document.deleted_at.is_none()); + + let folder_resp = app + .post_json( + "/api/folders", + &CreateFolderRequest { + name: "Restored", + parent_id: None, + }, + Some(&token), + ) + .await?; + assert!(folder_resp.status().is_success()); + let folder_body = body_to_vec(folder_resp.into_body()).await?; + let folder: FolderResponse = serde_json::from_slice(&folder_body)?; + + let delete_again = app + .post_json( + &format!("/api/documents/{}/trash", detail.document.id), + &json!({}), + Some(&token), + ) + .await?; + assert_eq!(delete_again.status(), StatusCode::NO_CONTENT); + + let restore_custom = app + .post_json( + &format!("/api/documents/{}/restore", detail.document.id), + &serde_json::json!({ + "folder_id": folder.folder.id + }), + Some(&token), + ) + .await?; + assert_eq!(restore_custom.status(), StatusCode::NO_CONTENT); + + let fetched_custom = app + .get( + &format!("/api/documents/{}", detail.document.id), + Some(&token), + ) + .await?; + let fetched_custom_body = body_to_vec(fetched_custom.into_body()).await?; + let fetched_custom_detail: DocumentDetail = serde_json::from_slice(&fetched_custom_body)?; + assert_eq!( + fetched_custom_detail.document.folder_id, + Some(folder.folder.id) + ); + assert!(fetched_custom_detail.document.deleted_at.is_none()); + + app.cleanup().await?; + Ok(()) +} + +#[tokio::test] +async fn list_document_versions_and_fetch_detail() -> Result<()> { + let _lock = acquire_db_lock().await; + let app = TestApp::new().await?; + + let password = "versionlist"; + app.insert_user("versions", TestUserRole::Owner).await?; + let token = app.login_token("versions", password).await?; + + let upload = app + .upload_document( + "/api/documents", + "versioned.txt", + "text/plain", + b"versioned", + None, + &token, + ) + .await?; + let upload_body = body_to_vec(upload.into_body()).await?; + let detail: DocumentDetail = serde_json::from_slice(&upload_body)?; + + let version_id = detail + .document + .current_version + .as_ref() + .expect("current version") + .id; + + let list_resp = app + .get( + &format!("/api/documents/{}/versions", detail.document.id), + Some(&token), + ) + .await?; + assert!(list_resp.status().is_success()); + let list_body = body_to_vec(list_resp.into_body()).await?; + let versions: Vec = serde_json::from_slice(&list_body)?; + assert_eq!(versions.len(), 1); + assert_eq!(versions[0].id, version_id); + assert_eq!(versions[0].version_number, 1); + + let detail_resp = app + .get( + &format!( + "/api/documents/{}/versions/{}", + detail.document.id, version_id + ), + Some(&token), + ) + .await?; + assert!(detail_resp.status().is_success()); + let detail_body = body_to_vec(detail_resp.into_body()).await?; + let version_detail: DocumentVersionPayload = serde_json::from_slice(&detail_body)?; + assert_eq!(version_detail.id, version_id); + assert!(version_detail.download.url.starts_with("/api/download/")); + assert!(version_detail.download.expires_at > 0); + assert!(version_detail.assets.is_empty()); + + app.cleanup().await?; + Ok(()) +} diff --git a/backend/tests/download_flow.rs b/backend/tests/download_flow.rs new file mode 100644 index 0000000..148fcf6 --- /dev/null +++ b/backend/tests/download_flow.rs @@ -0,0 +1,84 @@ +use anyhow::Result; +use axum::http::StatusCode; +use papercrate::test_support::{acquire_db_lock, body_to_vec, TestApp, TestUserRole}; +use serde::Deserialize; + +#[derive(Clone, Deserialize)] +struct DocumentDetail { + document: DocumentInfo, +} + +#[derive(Clone, Deserialize)] +struct DocumentInfo { + current_version: Option, +} + +#[derive(Clone, Deserialize)] +struct DocumentVersion { + download: DownloadLink, +} + +#[derive(Clone, Deserialize)] +struct DownloadLink { + url: String, + expires_at: i64, +} + +#[tokio::test] +async fn document_download_redirects_when_proxy_disabled() -> Result<()> { + let _lock = acquire_db_lock().await; + let app = TestApp::new().await?; + + let username = "download-user"; + let password = "secret"; + app.insert_user(username, TestUserRole::Owner).await?; + let token = app.login_token(username, password).await?; + + let upload = app + .upload_document( + "/api/documents", + "download.pdf", + "application/pdf", + b"dummy", + None, + &token, + ) + .await?; + assert_eq!(upload.status(), StatusCode::CREATED); + let body = body_to_vec(upload.into_body()).await?; + let detail: DocumentDetail = serde_json::from_slice(&body)?; + let download_link = detail + .document + .current_version + .as_ref() + .expect("missing version") + .download + .clone(); + assert!(download_link.expires_at > 0); + let download_path = download_link.url.clone(); + + let redirect = app.get(&download_path, None).await?; + assert_eq!(redirect.status(), StatusCode::TEMPORARY_REDIRECT); + let location = redirect + .headers() + .get("location") + .expect("redirect location header") + .to_str() + .expect("location utf8"); + assert!(location.starts_with("https://fake-storage/")); + + app.cleanup().await?; + Ok(()) +} + +#[tokio::test] +async fn download_with_invalid_token_is_rejected() -> Result<()> { + let _lock = acquire_db_lock().await; + let app = TestApp::new().await?; + + let response = app.get("/api/download/not-a-token", None).await?; + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + + app.cleanup().await?; + Ok(()) +} diff --git a/backend/tests/folders_flow.rs b/backend/tests/folders_flow.rs new file mode 100644 index 0000000..a6808ae --- /dev/null +++ b/backend/tests/folders_flow.rs @@ -0,0 +1,562 @@ +use anyhow::Result; +use axum::http::StatusCode; +use papercrate::test_support::{acquire_db_lock, body_to_vec, TestApp, TestUserRole}; +use serde::Deserialize; +use serde::Serialize; +use serde_json::json; +use uuid::Uuid; + +#[derive(Deserialize)] +struct FolderResponse { + folder: FolderInfo, +} + +#[derive(Deserialize)] +struct FolderInfo { + id: Uuid, + name: String, + parent_id: Option, +} + +#[derive(Deserialize)] +struct FolderContents { + folder: Option, + subfolders: Vec, + documents: Vec, +} + +#[derive(Deserialize)] +struct DocSummary { + id: Uuid, +} + +#[derive(Deserialize)] +struct FolderTreeNodeResponse { + name: String, + children: Vec, +} + +#[derive(Serialize)] +struct CreateFolder<'a> { + name: &'a str, + parent_id: Option, +} + +#[derive(Serialize)] +struct EnsureFolderPath<'a> { + parent_id: Option, + segments: &'a [&'a str], +} + +#[derive(Serialize)] +struct MoveDocumentRequest { + folder_id: Option, +} + +#[derive(Deserialize)] +struct DocumentDetail { + document: DocSummary, +} + +#[tokio::test] +async fn folder_move_and_delete_flow() -> Result<()> { + let _lock = acquire_db_lock().await; + let app = TestApp::new().await?; + + let password = "folderpass"; + app.insert_user("folder-admin", TestUserRole::Owner).await?; + let token = app.login_token("folder-admin", password).await?; + + let folder_resp = app + .post_json( + "/api/folders", + &CreateFolder { + name: "Projects", + parent_id: None, + }, + Some(&token), + ) + .await?; + assert_eq!(folder_resp.status(), StatusCode::CREATED); + let folder_body = body_to_vec(folder_resp.into_body()).await?; + let folder: FolderResponse = serde_json::from_slice(&folder_body)?; + + let upload = app + .upload_document( + "/api/documents", + "plan.pdf", + "application/pdf", + b"dummy", + None, + &token, + ) + .await?; + let upload_body = body_to_vec(upload.into_body()).await?; + let detail: DocumentDetail = serde_json::from_slice(&upload_body)?; + + let move_resp = app + .patch_json( + &format!("/api/documents/{}/folder", detail.document.id), + &MoveDocumentRequest { + folder_id: Some(folder.folder.id), + }, + Some(&token), + ) + .await?; + assert_eq!(move_resp.status(), StatusCode::NO_CONTENT); + + let contents = app + .get( + &format!("/api/folders/{}/contents", folder.folder.id), + Some(&token), + ) + .await?; + assert_eq!(contents.status(), StatusCode::OK); + let contents_body = body_to_vec(contents.into_body()).await?; + let contents: FolderContents = serde_json::from_slice(&contents_body)?; + assert_eq!(contents.documents.len(), 1); + assert_eq!(contents.documents[0].id, detail.document.id); + + let root_contents = app.get("/api/folders/root/contents", Some(&token)).await?; + let root_body = body_to_vec(root_contents.into_body()).await?; + let root: FolderContents = serde_json::from_slice(&root_body)?; + assert!(root + .documents + .iter() + .all(|doc| doc.id != detail.document.id)); + + let delete_attempt = app + .delete(&format!("/api/folders/{}", folder.folder.id), Some(&token)) + .await?; + assert_eq!(delete_attempt.status(), StatusCode::BAD_REQUEST); + + let move_back = app + .patch_json( + &format!("/api/documents/{}/folder", detail.document.id), + &MoveDocumentRequest { folder_id: None }, + Some(&token), + ) + .await?; + assert_eq!(move_back.status(), StatusCode::NO_CONTENT); + + let delete = app + .delete(&format!("/api/folders/{}", folder.folder.id), Some(&token)) + .await?; + assert_eq!(delete.status(), StatusCode::NO_CONTENT); + + app.cleanup().await?; + Ok(()) +} + +#[tokio::test] +async fn folder_tree_lists_hierarchy() -> Result<()> { + let _lock = acquire_db_lock().await; + let app = TestApp::new().await?; + + let password = "folderpass"; + app.insert_user("folder-tree", TestUserRole::Owner).await?; + let token = app.login_token("folder-tree", password).await?; + + let alpha_resp = app + .post_json( + "/api/folders", + &CreateFolder { + name: "Alpha", + parent_id: None, + }, + Some(&token), + ) + .await?; + assert_eq!(alpha_resp.status(), StatusCode::CREATED); + let alpha_body = body_to_vec(alpha_resp.into_body()).await?; + let alpha: FolderResponse = serde_json::from_slice(&alpha_body)?; + + let archive_resp = app + .post_json( + "/api/folders", + &CreateFolder { + name: "Archive", + parent_id: Some(alpha.folder.id), + }, + Some(&token), + ) + .await?; + assert_eq!(archive_resp.status(), StatusCode::CREATED); + + let beta_resp = app + .post_json( + "/api/folders", + &CreateFolder { + name: "Beta", + parent_id: None, + }, + Some(&token), + ) + .await?; + assert_eq!(beta_resp.status(), StatusCode::CREATED); + + let tree_resp = app.get("/api/folders/tree", Some(&token)).await?; + assert_eq!(tree_resp.status(), StatusCode::OK); + let tree_body = body_to_vec(tree_resp.into_body()).await?; + let tree: Vec = serde_json::from_slice(&tree_body)?; + + assert_eq!(tree.len(), 2); + assert_eq!(tree[0].name, "Alpha"); + assert_eq!(tree[0].children.len(), 1); + assert_eq!(tree[0].children[0].name, "Archive"); + assert!(tree[0].children[0].children.is_empty()); + + assert_eq!(tree[1].name, "Beta"); + assert!(tree[1].children.is_empty()); + + app.cleanup().await?; + Ok(()) +} + +#[tokio::test] +async fn update_folder_parent_to_root() -> Result<()> { + let _lock = acquire_db_lock().await; + let app = TestApp::new().await?; + + let password = "rootpass"; + app.insert_user("root-admin", TestUserRole::Owner).await?; + let token = app.login_token("root-admin", password).await?; + + // Create a parent folder under root + let parent_resp = app + .post_json( + "/api/folders", + &CreateFolder { + name: "Parent", + parent_id: None, + }, + Some(&token), + ) + .await?; + assert_eq!(parent_resp.status(), StatusCode::CREATED); + let parent_body = body_to_vec(parent_resp.into_body()).await?; + let parent: FolderResponse = serde_json::from_slice(&parent_body)?; + + // Create a child folder inside the parent + let child_resp = app + .post_json( + "/api/folders", + &CreateFolder { + name: "Child", + parent_id: Some(parent.folder.id), + }, + Some(&token), + ) + .await?; + assert_eq!(child_resp.status(), StatusCode::CREATED); + let child_body = body_to_vec(child_resp.into_body()).await?; + let child: FolderResponse = serde_json::from_slice(&child_body)?; + + // Move the child back to the root by setting parent_id to null + let update_resp = app + .patch_json( + &format!("/api/folders/{}", child.folder.id), + &json!({ "parent_id": null }), + Some(&token), + ) + .await?; + assert_eq!(update_resp.status(), StatusCode::NO_CONTENT); + + // Fetch the child folder and ensure parent_id is now null + let updated_resp = app + .get(&format!("/api/folders/{}", child.folder.id), Some(&token)) + .await?; + assert_eq!(updated_resp.status(), StatusCode::OK); + let updated_body = body_to_vec(updated_resp.into_body()).await?; + let updated_folder: FolderResponse = serde_json::from_slice(&updated_body)?; + assert!(updated_folder.folder.parent_id.is_none()); + + // Root contents should include the child folder by name + let root_contents = app.get("/api/folders/root/contents", Some(&token)).await?; + assert_eq!(root_contents.status(), StatusCode::OK); + let root_body = body_to_vec(root_contents.into_body()).await?; + let root: FolderContents = serde_json::from_slice(&root_body)?; + assert!(root + .subfolders + .iter() + .any(|folder| folder.id == child.folder.id)); + + app.cleanup().await?; + Ok(()) +} + +#[tokio::test] +async fn ensure_path_creates_nested_folders() -> Result<()> { + let _lock = acquire_db_lock().await; + let app = TestApp::new().await?; + + let password = "pathpass"; + app.insert_user("path-admin", TestUserRole::Owner).await?; + let token = app.login_token("path-admin", password).await?; + + let base_path = EnsureFolderPath { + parent_id: None, + segments: &["Team", "Engineering", "Backend"], + }; + let first_resp = app + .post_json("/api/folders/path", &base_path, Some(&token)) + .await?; + assert!(first_resp.status().is_success()); + let first_body = body_to_vec(first_resp.into_body()).await?; + let first_folder: FolderResponse = serde_json::from_slice(&first_body)?; + + let second_resp = app + .post_json("/api/folders/path", &base_path, Some(&token)) + .await?; + assert!(second_resp.status().is_success()); + let second_body = body_to_vec(second_resp.into_body()).await?; + let second_folder: FolderResponse = serde_json::from_slice(&second_body)?; + assert_eq!(second_folder.folder.id, first_folder.folder.id); + + let engineering_resp = app + .post_json( + "/api/folders/path", + &EnsureFolderPath { + parent_id: None, + segments: &["Team", "Engineering"], + }, + Some(&token), + ) + .await?; + assert_eq!(engineering_resp.status(), StatusCode::OK); + let engineering_body = body_to_vec(engineering_resp.into_body()).await?; + let engineering_folder: FolderResponse = serde_json::from_slice(&engineering_body)?; + assert_ne!(engineering_folder.folder.id, first_folder.folder.id); + + let infra_resp = app + .post_json( + "/api/folders/path", + &EnsureFolderPath { + parent_id: Some(engineering_folder.folder.id), + segments: &["Infrastructure"], + }, + Some(&token), + ) + .await?; + assert_eq!(infra_resp.status(), StatusCode::OK); + let infra_body = body_to_vec(infra_resp.into_body()).await?; + let infra_folder: FolderResponse = serde_json::from_slice(&infra_body)?; + assert_ne!(infra_folder.folder.id, engineering_folder.folder.id); + + let infra_dupe_resp = app + .post_json( + "/api/folders/path", + &EnsureFolderPath { + parent_id: Some(engineering_folder.folder.id), + segments: &["Infrastructure"], + }, + Some(&token), + ) + .await?; + assert_eq!(infra_dupe_resp.status(), StatusCode::OK); + let infra_dupe_body = body_to_vec(infra_dupe_resp.into_body()).await?; + let infra_dupe_folder: FolderResponse = serde_json::from_slice(&infra_dupe_body)?; + assert_eq!(infra_dupe_folder.folder.id, infra_folder.folder.id); + + app.cleanup().await?; + Ok(()) +} + +#[tokio::test] +async fn create_folder_is_idempotent() -> Result<()> { + let _lock = acquire_db_lock().await; + let app = TestApp::new().await?; + + let password = "idempotent"; + app.insert_user("folders-idem", TestUserRole::Owner).await?; + let token = app.login_token("folders-idem", password).await?; + + let payload = CreateFolder { + name: "Archive", + parent_id: None, + }; + + let first_resp = app + .post_json("/api/folders", &payload, Some(&token)) + .await?; + assert_eq!(first_resp.status(), StatusCode::CREATED); + let first_body = body_to_vec(first_resp.into_body()).await?; + let first_folder: FolderResponse = serde_json::from_slice(&first_body)?; + + let second_resp = app + .post_json("/api/folders", &payload, Some(&token)) + .await?; + assert_eq!(second_resp.status(), StatusCode::OK); + let second_body = body_to_vec(second_resp.into_body()).await?; + let second_folder: FolderResponse = serde_json::from_slice(&second_body)?; + + assert_eq!(first_folder.folder.id, second_folder.folder.id); + + let root_contents = app.get("/api/folders/root/contents", Some(&token)).await?; + let root_body = body_to_vec(root_contents.into_body()).await?; + let root: FolderContents = serde_json::from_slice(&root_body)?; + let occurrences = root + .subfolders + .iter() + .filter(|folder| folder.id == first_folder.folder.id) + .count(); + assert_eq!(occurrences, 1); + + app.cleanup().await?; + Ok(()) +} + +#[tokio::test] +async fn ensure_folder_path_is_idempotent() -> Result<()> { + let _lock = acquire_db_lock().await; + let app = TestApp::new().await?; + + let password = "pathpass"; + app.insert_user("path-admin", TestUserRole::Owner).await?; + let token = app.login_token("path-admin", password).await?; + + let segments = ["500 Immobilien", "501 Kreuzweg 2", "501.01 Rechtliches"]; + let payload = EnsureFolderPath { + parent_id: None, + segments: &segments, + }; + + let first_resp = app + .post_json("/api/folders/path", &payload, Some(&token)) + .await?; + assert!(first_resp.status().is_success()); + let first_body = body_to_vec(first_resp.into_body()).await?; + let first_folder: FolderResponse = serde_json::from_slice(&first_body)?; + + let second_resp = app + .post_json("/api/folders/path", &payload, Some(&token)) + .await?; + assert!(second_resp.status().is_success()); + let second_body = body_to_vec(second_resp.into_body()).await?; + let second_folder: FolderResponse = serde_json::from_slice(&second_body)?; + + assert_eq!(first_folder.folder.id, second_folder.folder.id); + + // Verify intermediate folders are not duplicated + let root_contents = app.get("/api/folders/root/contents", Some(&token)).await?; + let root_body = body_to_vec(root_contents.into_body()).await?; + let root: FolderContents = serde_json::from_slice(&root_body)?; + let root_occurrences = root + .subfolders + .iter() + .filter(|folder| folder.name == segments[0]) + .count(); + assert_eq!(root_occurrences, 1); + + let level_one = root + .subfolders + .iter() + .find(|folder| folder.name == segments[0]) + .map(|folder| folder.id) + .expect("root segment not created"); + + let level_one_contents = app + .get( + &format!("/api/folders/{}/contents", level_one), + Some(&token), + ) + .await?; + let level_one_body = body_to_vec(level_one_contents.into_body()).await?; + let level_one_folders: FolderContents = serde_json::from_slice(&level_one_body)?; + let level_one_occurrences = level_one_folders + .subfolders + .iter() + .filter(|folder| folder.name == segments[1]) + .count(); + assert_eq!(level_one_occurrences, 1); + + app.cleanup().await?; + Ok(()) +} + +#[tokio::test] +async fn folder_rename_updates_name_and_child_paths() -> Result<()> { + let _lock = acquire_db_lock().await; + let app = TestApp::new().await?; + + let password = "renamepass"; + app.insert_user("rename-admin", TestUserRole::Owner).await?; + let token = app.login_token("rename-admin", password).await?; + + let parent_resp = app + .post_json( + "/api/folders", + &CreateFolder { + name: "Projects", + parent_id: None, + }, + Some(&token), + ) + .await?; + assert_eq!(parent_resp.status(), StatusCode::CREATED); + let parent_body = body_to_vec(parent_resp.into_body()).await?; + let parent: FolderResponse = serde_json::from_slice(&parent_body)?; + + let child_resp = app + .post_json( + "/api/folders", + &CreateFolder { + name: "Q1", + parent_id: Some(parent.folder.id), + }, + Some(&token), + ) + .await?; + assert_eq!(child_resp.status(), StatusCode::CREATED); + let child_body = body_to_vec(child_resp.into_body()).await?; + let child: FolderResponse = serde_json::from_slice(&child_body)?; + + let rename_resp = app + .patch_json( + &format!("/api/folders/{}", parent.folder.id), + &json!({ "name": "Archive" }), + Some(&token), + ) + .await?; + assert_eq!(rename_resp.status(), StatusCode::NO_CONTENT); + + let root_contents = app.get("/api/folders/root/contents", Some(&token)).await?; + assert_eq!(root_contents.status(), StatusCode::OK); + let root_body = body_to_vec(root_contents.into_body()).await?; + let root: FolderContents = serde_json::from_slice(&root_body)?; + let renamed = root + .subfolders + .iter() + .find(|f| f.id == parent.folder.id) + .expect("renamed folder present"); + assert_eq!(renamed.name, "Archive"); + + let folders_only = app + .get( + &format!( + "/api/folders/{}/contents?include_documents=false", + parent.folder.id + ), + Some(&token), + ) + .await?; + assert_eq!(folders_only.status(), StatusCode::OK); + let folders_only_body = body_to_vec(folders_only.into_body()).await?; + let folders_only_contents: FolderContents = serde_json::from_slice(&folders_only_body)?; + assert!(folders_only_contents.documents.is_empty()); + + let child_contents = app + .get( + &format!("/api/folders/{}/contents", child.folder.id), + Some(&token), + ) + .await?; + assert_eq!(child_contents.status(), StatusCode::OK); + let child_contents_body = body_to_vec(child_contents.into_body()).await?; + let child_details: FolderContents = serde_json::from_slice(&child_contents_body)?; + let child_folder = child_details.folder.expect("child folder info"); + assert_eq!(child_folder.name, "Q1"); + + app.cleanup().await?; + Ok(()) +} diff --git a/backend/tests/tags_flow.rs b/backend/tests/tags_flow.rs new file mode 100644 index 0000000..2103072 --- /dev/null +++ b/backend/tests/tags_flow.rs @@ -0,0 +1,271 @@ +use anyhow::{anyhow, Result}; +use axum::http::StatusCode; +use diesel::prelude::*; +use papercrate::auth::capability_sets::{ensure_capability_set, owner_capabilities}; +use papercrate::models::{NewUser, NewUserMembership, Tag, TenantStatus}; +use papercrate::schema::{ + tags::dsl as tags_dsl, tenants::dsl as tenants_dsl, user_memberships::dsl as memberships_dsl, + users::dsl as users_dsl, +}; +use papercrate::test_support::{acquire_db_lock, body_to_vec, TestApp, TestUserRole}; +use serde::Deserialize; +use serde::Serialize; +use uuid::Uuid; + +#[derive(Serialize)] +struct CreateTagPayload<'a> { + label: &'a str, + color: Option<&'a str>, +} + +#[derive(Deserialize)] +struct DocumentDetail { + document: DocumentInfo, +} + +#[derive(Deserialize)] +struct DocumentInfo { + id: Uuid, + tags: Vec, +} + +#[derive(Deserialize)] +struct TagInfo { + label: String, + #[allow(dead_code)] + color: Option, +} + +#[derive(Deserialize)] +struct TagResponse { + id: Uuid, + label: String, + color: Option, + usage_count: i64, +} + +#[derive(Serialize)] +struct AssignTagsRequest { + tag_ids: Vec, +} + +#[tokio::test] +async fn tag_assignment_flow() -> Result<()> { + let _lock = acquire_db_lock().await; + let app = TestApp::new().await?; + + let password = "tagpass"; + app.insert_user("tagger", TestUserRole::Owner).await?; + let token = app.login_token("tagger", password).await?; + + let upload = app + .upload_document( + "/api/documents", + "tagged.txt", + "text/plain", + b"tag me", + None, + &token, + ) + .await?; + assert_eq!(upload.status(), StatusCode::CREATED); + let upload_body = body_to_vec(upload.into_body()).await?; + let detail: DocumentDetail = serde_json::from_slice(&upload_body)?; + + let create_tag = app + .post_json( + "/api/tags", + &CreateTagPayload { + label: "Important", + color: Some("#FF0000"), + }, + Some(&token), + ) + .await?; + assert_eq!(create_tag.status(), StatusCode::OK); + let body = body_to_vec(create_tag.into_body()).await?; + let tag: TagResponse = serde_json::from_slice(&body)?; + assert_eq!(tag.label, "Important"); + assert_eq!(tag.color.as_deref(), Some("#FF0000")); + assert_eq!(tag.usage_count, 0); + + let update = app + .patch_json( + &format!("/api/tags/{}", tag.id), + &serde_json::json!({ + "label": "Critical", + "color": "#00FF00" + }), + Some(&token), + ) + .await?; + let updated_status = update.status(); + let updated_body = body_to_vec(update.into_body()).await?; + if updated_status != StatusCode::OK { + panic!( + "update tag failed: {}", + String::from_utf8_lossy(&updated_body) + ); + } + let updated: TagResponse = serde_json::from_slice(&updated_body)?; + assert_eq!(updated.label, "Critical"); + assert_eq!(updated.color.as_deref(), Some("#00FF00")); + assert_eq!(updated.usage_count, 0); + + let clear_color = app + .patch_json( + &format!("/api/tags/{}", tag.id), + &serde_json::json!({ + "color": null + }), + Some(&token), + ) + .await?; + let cleared_status = clear_color.status(); + let cleared_body = body_to_vec(clear_color.into_body()).await?; + if cleared_status != StatusCode::OK { + panic!( + "clear color failed: {}", + String::from_utf8_lossy(&cleared_body) + ); + } + let cleared: TagResponse = serde_json::from_slice(&cleared_body)?; + assert_eq!(cleared.color, None); + assert_eq!(cleared.usage_count, 0); + + let assign = app + .post_json( + &format!("/api/documents/{}/tags", detail.document.id), + &AssignTagsRequest { + tag_ids: vec![tag.id], + }, + Some(&token), + ) + .await?; + assert_eq!(assign.status(), StatusCode::NO_CONTENT); + + let refreshed = app + .get( + &format!("/api/documents/{}", detail.document.id), + Some(&token), + ) + .await?; + assert_eq!(refreshed.status(), StatusCode::OK); + let refreshed_body = body_to_vec(refreshed.into_body()).await?; + let refreshed_detail: DocumentDetail = serde_json::from_slice(&refreshed_body)?; + assert_eq!(refreshed_detail.document.tags.len(), 1); + assert_eq!(refreshed_detail.document.tags[0].label, "Critical"); + + let remove = app + .delete( + &format!("/api/documents/{}/tags/{}", detail.document.id, tag.id), + Some(&token), + ) + .await?; + assert_eq!(remove.status(), StatusCode::NO_CONTENT); + + let final_check = app + .get( + &format!("/api/documents/{}", detail.document.id), + Some(&token), + ) + .await?; + let final_body = body_to_vec(final_check.into_body()).await?; + let final_detail: DocumentDetail = serde_json::from_slice(&final_body)?; + assert!(final_detail.document.tags.is_empty()); + + app.cleanup().await?; + Ok(()) +} + +#[tokio::test] +async fn tags_are_isolated_between_tenants() -> Result<()> { + let _lock = acquire_db_lock().await; + let app = TestApp::new().await?; + + let password_a = "tenant-a"; + app.insert_user("alice", TestUserRole::Owner).await?; + let token_a = app.login_token("alice", password_a).await?; + + let shared_label = "Shared Label"; + + let create_a = app + .post_json( + "/api/tags", + &CreateTagPayload { + label: shared_label, + color: Some("#123456"), + }, + Some(&token_a), + ) + .await?; + assert_eq!(create_a.status(), StatusCode::OK); + + let tenant_b_id = Uuid::new_v4(); + let user_b_id = Uuid::new_v4(); + app.with_conn(move |conn| { + let storage_root = format!("test-tenants/{tenant_b_id}/"); + diesel::insert_into(tenants_dsl::tenants) + .values(( + tenants_dsl::id.eq(tenant_b_id), + tenants_dsl::name.eq("tenant-b"), + tenants_dsl::storage_root.eq(Some(storage_root)), + tenants_dsl::status.eq(TenantStatus::Active), + )) + .execute(conn)?; + + let new_user = NewUser { + id: user_b_id, + username: "bob".to_string(), + }; + diesel::insert_into(users_dsl::users) + .values(&new_user) + .execute(conn)?; + + let owner_capability_set_id = + ensure_capability_set(conn, tenant_b_id, owner_capabilities()) + .map_err(|err| anyhow!("failed to ensure owner capability set: {:?}", err))? + .id; + + let membership = NewUserMembership { + id: Uuid::new_v4(), + user_id: user_b_id, + tenant_id: tenant_b_id, + capability_set_id: Some(owner_capability_set_id), + }; + diesel::insert_into(memberships_dsl::user_memberships) + .values(&membership) + .execute(conn)?; + + Ok::<_, anyhow::Error>(()) + }) + .await?; + + let token_b = app.login_token("bob", "").await?; + + let create_b = app + .post_json( + "/api/tags", + &CreateTagPayload { + label: shared_label, + color: Some("#654321"), + }, + Some(&token_b), + ) + .await?; + assert_eq!(create_b.status(), StatusCode::OK); + + app.with_conn(move |conn| { + let tags: Vec = tags_dsl::tags + .filter(tags_dsl::label.eq(shared_label)) + .order(tags_dsl::tenant_id.asc()) + .load(conn)?; + + assert_eq!(tags.len(), 2); + assert_ne!(tags[0].tenant_id, tags[1].tenant_id); + Ok::<_, anyhow::Error>(()) + }) + .await?; + + Ok(()) +} diff --git a/backend/tests/tenant_deletion.rs b/backend/tests/tenant_deletion.rs new file mode 100644 index 0000000..1f4aab5 --- /dev/null +++ b/backend/tests/tenant_deletion.rs @@ -0,0 +1,715 @@ +use anyhow::{anyhow, bail, Result}; +use axum::http::StatusCode; +use chrono::{Duration as ChronoDuration, Utc}; +use diesel::dsl::{count_star, exists, select}; +use diesel::prelude::*; +use papercrate::jobs::{ + enqueue_job, mark_job_failed, mark_job_succeeded, JOB_DELETE_TENANT, STATUS_FAILED, + STATUS_SUCCEEDED, +}; +use papercrate::models::TenantStatus; +use papercrate::schema::{documents, jobs, tenants, user_memberships}; +use papercrate::test_support::{acquire_db_lock, body_to_vec, TestApp, TestUserRole}; +use papercrate::workers::tenants::{ + build_delete_proof_message, sign_delete_proof, DeleteAction, DeleteTenantJob, +}; +use papercrate::workers::{JobExecution, JobHandler}; +use serde_json::{json, Value}; +use std::sync::Arc; +use uuid::Uuid; + +#[tokio::test] +async fn delete_tenant_job_keeps_tenant_when_requested() -> Result<()> { + let _lock = acquire_db_lock().await; + let app = TestApp::new().await?; + + let password = "delete-keep"; + app.insert_user("tenant-keep", TestUserRole::Owner).await?; + let token = app.login_token("tenant-keep", password).await?; + + upload_fixture(&app, &token, "keep.pdf", b"keep").await?; + + let tenant_id = default_tenant_id(&app)?; + set_tenant_status(&app, tenant_id, TenantStatus::Deleting).await?; + assert_tenant_status(&app, tenant_id, TenantStatus::Deleting).await?; + + let storage = tenant_storage(&app, tenant_id)?; + let storage_prefix = storage.root_prefix().to_string(); + let before = tenant_snapshot(&app, tenant_id, &storage_prefix).await?; + assert_eq!(before.doc_count, 1); + assert!(before.membership_count > 0); + assert!(!before.storage_keys.is_empty()); + assert_storage_keys_present(&app, &before.storage_keys).await?; + + let job = enqueue_delete_job(&app, tenant_id, false).await?; + let job_id = job.id; + let handler = DeleteTenantJob::new(); + let state = Arc::new(app.state.clone()); + let execution = handler.handle(state, job, storage).await; + assert_job_success(&execution); + record_job_outcome(&app, job_id, &execution).await?; + assert_eq!(fetch_job_status(&app, job_id).await?, STATUS_SUCCEEDED); + + let after = tenant_snapshot(&app, tenant_id, &storage_prefix).await?; + assert_eq!(after.doc_count, 0); + assert!(after.membership_count > 0); + assert_storage_keys_absent(&app, &before.storage_keys).await?; + assert_eq!(storage_object_count(&app, &storage_prefix).await?, 0); + assert_eq!( + fetch_tenant_status(&app, tenant_id).await?, + TenantStatus::Suspended + ); + assert!(tenant_exists(&app, tenant_id).await?); + + app.cleanup().await?; + Ok(()) +} + +#[tokio::test] +async fn delete_tenant_job_can_reset_tenant_to_active() -> Result<()> { + let _lock = acquire_db_lock().await; + let app = TestApp::new().await?; + + let password = "delete-reset"; + app.insert_user("tenant-reset", TestUserRole::Owner).await?; + let token = app.login_token("tenant-reset", password).await?; + upload_fixture(&app, &token, "reset.pdf", b"reset").await?; + + let tenant_id = default_tenant_id(&app)?; + set_tenant_status(&app, tenant_id, TenantStatus::Deleting).await?; + assert_tenant_status(&app, tenant_id, TenantStatus::Deleting).await?; + + let storage = tenant_storage(&app, tenant_id)?; + let storage_prefix = storage.root_prefix().to_string(); + let before = tenant_snapshot(&app, tenant_id, &storage_prefix).await?; + assert_eq!(before.doc_count, 1); + assert!(before.membership_count > 0); + assert!(!before.storage_keys.is_empty()); + assert_storage_keys_present(&app, &before.storage_keys).await?; + + let job = enqueue_delete_job_with_status(&app, tenant_id, false, Some("active")).await?; + let job_id = job.id; + let handler = DeleteTenantJob::new(); + let state = Arc::new(app.state.clone()); + let execution = handler.handle(state, job, storage).await; + assert_job_success(&execution); + record_job_outcome(&app, job_id, &execution).await?; + assert_eq!(fetch_job_status(&app, job_id).await?, STATUS_SUCCEEDED); + + let after = tenant_snapshot(&app, tenant_id, &storage_prefix).await?; + assert_eq!(after.doc_count, 0); + assert!(after.membership_count > 0); + assert_storage_keys_absent(&app, &before.storage_keys).await?; + assert_eq!(storage_object_count(&app, &storage_prefix).await?, 0); + assert_eq!( + fetch_tenant_status(&app, tenant_id).await?, + TenantStatus::Active + ); + + app.cleanup().await?; + Ok(()) +} + +#[tokio::test] +async fn delete_tenant_job_removes_tenant_entirely() -> Result<()> { + let _lock = acquire_db_lock().await; + let app = TestApp::new().await?; + + let password = "delete-remove"; + app.insert_user("tenant-remove", TestUserRole::Owner) + .await?; + let token = app.login_token("tenant-remove", password).await?; + + upload_fixture(&app, &token, "remove-1.pdf", b"remove-1").await?; + upload_fixture(&app, &token, "remove-2.pdf", b"remove-2").await?; + + let tenant_id = default_tenant_id(&app)?; + set_tenant_status(&app, tenant_id, TenantStatus::Deleting).await?; + assert_tenant_status(&app, tenant_id, TenantStatus::Deleting).await?; + + let storage = tenant_storage(&app, tenant_id)?; + let storage_prefix = storage.root_prefix().to_string(); + let before = tenant_snapshot(&app, tenant_id, &storage_prefix).await?; + assert!(before.doc_count >= 2); + assert!(before.membership_count > 0); + assert!(before.storage_keys.len() >= 2); + assert_storage_keys_present(&app, &before.storage_keys).await?; + + let job = enqueue_delete_job(&app, tenant_id, true).await?; + let job_id = job.id; + let handler = DeleteTenantJob::new(); + let state = Arc::new(app.state.clone()); + let execution = handler.handle(state, job, storage).await; + assert_job_success(&execution); + record_job_outcome(&app, job_id, &execution).await?; + assert_eq!(fetch_job_status(&app, job_id).await?, STATUS_SUCCEEDED); + + let after = tenant_snapshot(&app, tenant_id, &storage_prefix).await?; + assert_eq!(after.doc_count, 0); + assert_eq!(after.membership_count, 0); + assert!(after.storage_keys.is_empty()); + assert_storage_keys_absent(&app, &before.storage_keys).await?; + assert_eq!(storage_object_count(&app, &storage_prefix).await?, 0); + assert!(!tenant_exists(&app, tenant_id).await?); + + app.cleanup().await?; + Ok(()) +} + +#[tokio::test] +async fn delete_tenant_job_rejects_invalid_signature() -> Result<()> { + let _lock = acquire_db_lock().await; + let app = TestApp::new().await?; + + let password = "delete-invalid"; + app.insert_user("tenant-invalid", TestUserRole::Owner) + .await?; + let token = app.login_token("tenant-invalid", password).await?; + + upload_fixture(&app, &token, "invalid.pdf", b"invalid").await?; + + let tenant_id = default_tenant_id(&app)?; + set_tenant_status(&app, tenant_id, TenantStatus::Deleting).await?; + assert_tenant_status(&app, tenant_id, TenantStatus::Deleting).await?; + + let storage = tenant_storage(&app, tenant_id)?; + let storage_prefix = storage.root_prefix().to_string(); + let before = tenant_snapshot(&app, tenant_id, &storage_prefix).await?; + assert_eq!(before.doc_count, 1); + assert!(before.membership_count > 0); + assert!(!before.storage_keys.is_empty()); + assert_storage_keys_present(&app, &before.storage_keys).await?; + + let job = enqueue_delete_job_with_invalid_signature(&app, tenant_id, false).await?; + let job_id = job.id; + let handler = DeleteTenantJob::new(); + let state = Arc::new(app.state.clone()); + let execution = handler.handle(state, job, storage).await; + match execution { + JobExecution::Failed { ref error } => { + assert!(error.contains("signature"), "unexpected error: {error}"); + } + _ => bail!("delete job should fail when signature is invalid"), + } + record_job_outcome(&app, job_id, &execution).await?; + assert_eq!(fetch_job_status(&app, job_id).await?, STATUS_FAILED); + + let after = tenant_snapshot(&app, tenant_id, &storage_prefix).await?; + assert_eq!(after.doc_count, before.doc_count); + assert_eq!(after.membership_count, before.membership_count); + assert_storage_keys_present(&app, &before.storage_keys).await?; + assert_eq!( + storage_object_count(&app, &storage_prefix).await?, + before.storage_keys.len() + ); + assert_eq!( + fetch_tenant_status(&app, tenant_id).await?, + TenantStatus::Deleting + ); + + app.cleanup().await?; + Ok(()) +} + +#[tokio::test] +async fn delete_tenant_job_rejects_invalid_final_status() -> Result<()> { + let _lock = acquire_db_lock().await; + let app = TestApp::new().await?; + + let password = "delete-invalid-status"; + app.insert_user("tenant-invalid-status", TestUserRole::Owner) + .await?; + let token = app.login_token("tenant-invalid-status", password).await?; + + upload_fixture(&app, &token, "invalid-status.pdf", b"payload").await?; + + let tenant_id = default_tenant_id(&app)?; + set_tenant_status(&app, tenant_id, TenantStatus::Deleting).await?; + assert_tenant_status(&app, tenant_id, TenantStatus::Deleting).await?; + + let storage = tenant_storage(&app, tenant_id)?; + let storage_prefix = storage.root_prefix().to_string(); + let before = tenant_snapshot(&app, tenant_id, &storage_prefix).await?; + assert_eq!(before.doc_count, 1); + assert!(before.membership_count > 0); + + let job = enqueue_delete_job_with_overrides( + &app, + tenant_id, + false, + None, + PayloadOverrides { + final_status: Some("weird"), + ..PayloadOverrides::default() + }, + ) + .await?; + let job_id = job.id; + let handler = DeleteTenantJob::new(); + let state = Arc::new(app.state.clone()); + let execution = handler.handle(state, job, storage).await; + match execution { + JobExecution::Failed { ref error } => { + assert!(error.contains("payload"), "unexpected error: {error}"); + } + _ => bail!("delete job should fail when final_status is invalid"), + } + record_job_outcome(&app, job_id, &execution).await?; + assert_eq!(fetch_job_status(&app, job_id).await?, STATUS_FAILED); + + let after = tenant_snapshot(&app, tenant_id, &storage_prefix).await?; + assert_eq!(after.doc_count, before.doc_count); + assert_eq!(after.membership_count, before.membership_count); + assert_storage_keys_present(&app, &before.storage_keys).await?; + assert_eq!( + fetch_tenant_status(&app, tenant_id).await?, + TenantStatus::Deleting + ); + + app.cleanup().await?; + Ok(()) +} + +#[tokio::test] +async fn delete_tenant_job_rejects_malformed_issued_at() -> Result<()> { + let _lock = acquire_db_lock().await; + let app = TestApp::new().await?; + + let password = "delete-bad-issued-at"; + app.insert_user("tenant-issued", TestUserRole::Owner) + .await?; + let token = app.login_token("tenant-issued", password).await?; + + upload_fixture(&app, &token, "issued.pdf", b"issued").await?; + + let tenant_id = default_tenant_id(&app)?; + set_tenant_status(&app, tenant_id, TenantStatus::Deleting).await?; + assert_tenant_status(&app, tenant_id, TenantStatus::Deleting).await?; + + let storage = tenant_storage(&app, tenant_id)?; + let job = enqueue_delete_job_with_overrides( + &app, + tenant_id, + false, + None, + PayloadOverrides { + issued_at: Some("definitely-not-time"), + ..PayloadOverrides::default() + }, + ) + .await?; + let job_id = job.id; + let handler = DeleteTenantJob::new(); + let state = Arc::new(app.state.clone()); + let execution = handler.handle(state, job, storage).await; + match execution { + JobExecution::Failed { ref error } => { + assert!(error.contains("issued_at"), "unexpected error: {error}"); + } + _ => bail!("delete job should fail when issued_at is malformed"), + } + record_job_outcome(&app, job_id, &execution).await?; + assert_eq!(fetch_job_status(&app, job_id).await?, STATUS_FAILED); + + app.cleanup().await?; + Ok(()) +} + +#[tokio::test] +async fn delete_tenant_job_rejects_stale_confirmation() -> Result<()> { + let _lock = acquire_db_lock().await; + let app = TestApp::new().await?; + + let password = "delete-stale"; + app.insert_user("tenant-stale", TestUserRole::Owner).await?; + let token = app.login_token("tenant-stale", password).await?; + + upload_fixture(&app, &token, "stale.pdf", b"stale").await?; + + let tenant_id = default_tenant_id(&app)?; + set_tenant_status(&app, tenant_id, TenantStatus::Deleting).await?; + assert_tenant_status(&app, tenant_id, TenantStatus::Deleting).await?; + + let storage = tenant_storage(&app, tenant_id)?; + let stale_time = (Utc::now() - ChronoDuration::minutes(10)).to_rfc3339(); + let job = enqueue_delete_job_with_overrides( + &app, + tenant_id, + false, + None, + PayloadOverrides { + issued_at: Some(&stale_time), + ..PayloadOverrides::default() + }, + ) + .await?; + let job_id = job.id; + let handler = DeleteTenantJob::new(); + let state = Arc::new(app.state.clone()); + let execution = handler.handle(state, job, storage).await; + match execution { + JobExecution::Failed { ref error } => { + assert!(error.contains("expired"), "unexpected error: {error}"); + } + _ => bail!("delete job should fail when confirmation is stale"), + } + record_job_outcome(&app, job_id, &execution).await?; + assert_eq!(fetch_job_status(&app, job_id).await?, STATUS_FAILED); + + app.cleanup().await?; + Ok(()) +} + +async fn upload_fixture(app: &TestApp, token: &str, filename: &str, contents: &[u8]) -> Result<()> { + let response = app + .upload_document( + "/api/documents", + filename, + "application/pdf", + contents, + None, + token, + ) + .await?; + assert_eq!(response.status(), StatusCode::CREATED); + body_to_vec(response.into_body()).await?; + Ok(()) +} + +fn default_tenant_id(app: &TestApp) -> Result { + Ok(app + .state + .tenants + .get_by_name("test_tenant") + .map_err(|err| anyhow!("tenant lookup failed: {err:?}"))? + .id) +} + +async fn set_tenant_status(app: &TestApp, tenant_id: Uuid, status: TenantStatus) -> Result<()> { + app.with_conn(move |conn| { + diesel::update(tenants::table.find(tenant_id)) + .set(tenants::status.eq(status)) + .execute(conn)?; + Ok(()) + }) + .await +} + +async fn assert_tenant_status( + app: &TestApp, + tenant_id: Uuid, + expected: TenantStatus, +) -> Result<()> { + let status = fetch_tenant_status(app, tenant_id).await?; + assert_eq!(status, expected); + Ok(()) +} + +async fn tenant_snapshot( + app: &TestApp, + tenant_id: Uuid, + storage_prefix: &str, +) -> Result { + let (doc_count, membership_count) = app + .with_conn(move |conn| { + let doc_count: i64 = documents::table + .filter(documents::tenant_id.eq(tenant_id)) + .select(count_star()) + .get_result(conn)?; + let membership_count: i64 = user_memberships::table + .filter(user_memberships::tenant_id.eq(tenant_id)) + .select(count_star()) + .get_result(conn)?; + Ok::<_, anyhow::Error>((doc_count, membership_count)) + }) + .await?; + + let storage_keys = app.storage().keys_with_prefix(storage_prefix).await; + + Ok(TenantSnapshot { + doc_count, + membership_count, + storage_keys, + }) +} + +async fn tenant_exists(app: &TestApp, tenant_id: Uuid) -> Result { + app.with_conn(move |conn| { + let exists_value: bool = + select(exists(tenants::table.filter(tenants::id.eq(tenant_id)))).get_result(conn)?; + Ok(exists_value) + }) + .await +} + +async fn fetch_tenant_status(app: &TestApp, tenant_id: Uuid) -> Result { + app.with_conn(move |conn| { + tenants::table + .find(tenant_id) + .select(tenants::status) + .first(conn) + .map_err(Into::into) + }) + .await +} + +async fn record_job_outcome(app: &TestApp, job_id: Uuid, execution: &JobExecution) -> Result<()> { + match execution { + JobExecution::Success => { + app.with_conn(move |conn| { + mark_job_succeeded(conn, job_id) + .map_err(|err| anyhow!("mark succeeded failed: {err}")) + }) + .await? + } + JobExecution::Failed { error } => { + let error = error.clone(); + app.with_conn(move |conn| { + mark_job_failed(conn, job_id, &error) + .map_err(|err| anyhow!("mark failed failed: {err}")) + }) + .await? + } + JobExecution::Retry { .. } => bail!("retry outcome not expected in tenant deletion tests"), + } + Ok(()) +} + +async fn fetch_job_status(app: &TestApp, job_id: Uuid) -> Result { + app.with_conn(move |conn| { + jobs::table + .find(job_id) + .select(jobs::status) + .first::(conn) + .map_err(Into::into) + }) + .await +} + +fn tenant_storage(app: &TestApp, tenant_id: Uuid) -> Result { + app.state + .storage_for_tenant(tenant_id) + .map_err(|err| anyhow!("storage unavailable: {err:?}")) +} + +async fn storage_object_count(app: &TestApp, prefix: &str) -> Result { + Ok(app.storage().object_count_with_prefix(prefix).await) +} + +async fn assert_storage_keys_present(app: &TestApp, keys: &[String]) -> Result<()> { + let storage = app.storage(); + for key in keys { + assert!( + storage.contains_key(key).await, + "expected storage object '{}' to exist", + key + ); + } + Ok(()) +} + +async fn assert_storage_keys_absent(app: &TestApp, keys: &[String]) -> Result<()> { + let storage = app.storage(); + for key in keys { + assert!( + !storage.contains_key(key).await, + "expected storage object '{}' to be deleted", + key + ); + } + Ok(()) +} + +async fn enqueue_delete_job( + app: &TestApp, + tenant_id: Uuid, + remove_tenant: bool, +) -> Result { + enqueue_delete_job_with_status(app, tenant_id, remove_tenant, None).await +} + +async fn enqueue_delete_job_with_status( + app: &TestApp, + tenant_id: Uuid, + remove_tenant: bool, + final_status: Option<&'static str>, +) -> Result { + enqueue_delete_job_with_overrides( + app, + tenant_id, + remove_tenant, + final_status, + PayloadOverrides::default(), + ) + .await +} + +async fn enqueue_delete_job_with_invalid_signature( + app: &TestApp, + tenant_id: Uuid, + remove_tenant: bool, +) -> Result { + let mut job = enqueue_delete_job_with_overrides( + app, + tenant_id, + remove_tenant, + None, + PayloadOverrides::default(), + ) + .await?; + + let job_id = job.id; + let mut payload = job.payload.clone(); + payload["signature"] = json!("deadbeefdeadbeefdeadbeefdeadbeef"); + let payload_for_db = payload.clone(); + + app.with_conn(move |conn| { + diesel::update(jobs::table.find(job_id)) + .set(jobs::payload.eq(payload_for_db)) + .execute(conn)?; + Ok(()) + }) + .await?; + + job.payload = payload; + Ok(job) +} + +async fn enqueue_delete_job_with_overrides( + app: &TestApp, + tenant_id: Uuid, + remove_tenant: bool, + final_status: Option<&str>, + overrides: PayloadOverrides<'_>, +) -> Result { + let secret = app.state.config.jwt_secret.clone(); + let overrides_owned = PayloadOverridesOwned::from(overrides); + let requested_final_status = final_status.map(|value| value.to_string()); + + app.with_conn(move |conn| { + let tenant_name: String = tenants::table + .find(tenant_id) + .select(tenants::name) + .first(conn) + .map_err(|err| anyhow!("tenant lookup failed: {err}"))?; + + let payload = build_signed_delete_payload( + tenant_id, + &tenant_name, + remove_tenant, + requested_final_status.as_deref(), + &secret, + overrides_owned.as_borrowed(), + )?; + + enqueue_job(conn, tenant_id, JOB_DELETE_TENANT, payload, None) + .map_err(|err| anyhow!("enqueue failed: {err}")) + }) + .await +} + +fn build_signed_delete_payload( + tenant_id: Uuid, + tenant_name: &str, + remove_tenant: bool, + requested_final_status: Option<&str>, + secret: &str, + overrides: PayloadOverrides<'_>, +) -> Result { + let action = if remove_tenant { + DeleteAction::Delete + } else { + DeleteAction::Reset + }; + + let nonce = overrides + .nonce + .map(|value| value.to_string()) + .unwrap_or_else(|| format!("test-delete-nonce-{}", Uuid::new_v4())); + let issued_at = overrides + .issued_at + .map(|value| value.to_string()) + .unwrap_or_else(|| Utc::now().to_rfc3339()); + let payload_final_status = if remove_tenant { + None + } else if let Some(value) = overrides.final_status { + Some(value.to_string()) + } else { + Some(requested_final_status.unwrap_or("suspended").to_string()) + }; + + let message = build_delete_proof_message( + tenant_id, + tenant_name, + action, + &nonce, + &issued_at, + payload_final_status.as_deref(), + ); + let signature = sign_delete_proof(secret, &message) + .map_err(|err| anyhow!("failed to sign delete proof: {err}"))?; + + let mut payload = json!({ + "remove_tenant": remove_tenant, + "tenant_name": tenant_name, + "action": action.as_str(), + "nonce": nonce, + "issued_at": issued_at, + "signature": signature, + }); + if let Some(status) = payload_final_status { + payload["final_status"] = json!(status); + } + + Ok(payload) +} + +struct TenantSnapshot { + doc_count: i64, + membership_count: i64, + storage_keys: Vec, +} + +#[derive(Default)] +struct PayloadOverrides<'a> { + final_status: Option<&'a str>, + issued_at: Option<&'a str>, + nonce: Option<&'a str>, +} + +fn assert_job_success(execution: &JobExecution) { + match execution { + JobExecution::Success => {} + JobExecution::Failed { error } => panic!("delete job failed unexpectedly: {error}"), + JobExecution::Retry { error, .. } => { + panic!("delete job asked for retry unexpectedly: {error}") + } + } +} + +#[derive(Default, Clone)] +struct PayloadOverridesOwned { + final_status: Option, + issued_at: Option, + nonce: Option, +} + +impl<'a> From> for PayloadOverridesOwned { + fn from(value: PayloadOverrides<'a>) -> Self { + Self { + final_status: value.final_status.map(|s| s.to_string()), + issued_at: value.issued_at.map(|s| s.to_string()), + nonce: value.nonce.map(|s| s.to_string()), + } + } +} + +impl PayloadOverridesOwned { + fn as_borrowed(&self) -> PayloadOverrides<'_> { + PayloadOverrides { + final_status: self.final_status.as_deref(), + issued_at: self.issued_at.as_deref(), + nonce: self.nonce.as_deref(), + } + } +} diff --git a/backend/tests/tenants_flow.rs b/backend/tests/tenants_flow.rs new file mode 100644 index 0000000..c1bd1b1 --- /dev/null +++ b/backend/tests/tenants_flow.rs @@ -0,0 +1,51 @@ +use anyhow::Result; +use axum::http::StatusCode; +use chrono::Utc; +use diesel::prelude::*; +use papercrate::models::TenantStatus; +use papercrate::schema::tenants::dsl as tenants_dsl; +use papercrate::test_support::{acquire_db_lock, TestApp, TestUserRole}; +use serde_json::json; +use uuid::Uuid; + +#[tokio::test] +async fn tenant_management_is_scoped_to_memberships() -> Result<()> { + let _lock = acquire_db_lock().await; + let app = TestApp::new().await?; + + let username = "tenant-owner"; + app.insert_user(username, TestUserRole::Owner).await?; + let token = app.login_token(username, "irrelevant").await?; + + let other_tenant_id = app + .with_conn(|conn| { + let other_id = Uuid::new_v4(); + let now = Utc::now().naive_utc(); + diesel::insert_into(tenants_dsl::tenants) + .values(( + tenants_dsl::id.eq(other_id), + tenants_dsl::name.eq(format!("foreign-{other_id}")), + tenants_dsl::storage_root.eq(Some(format!("test-tenants/{other_id}/"))), + tenants_dsl::quickwit_index.eq(None::), + tenants_dsl::config.eq(json!({})), + tenants_dsl::created_at.eq(now), + tenants_dsl::updated_at.eq(now), + tenants_dsl::status.eq(TenantStatus::Active), + tenants_dsl::created_by.eq(None::), + )) + .execute(conn)?; + Ok(other_id) + }) + .await?; + + let response = app + .patch_json( + &format!("/api/tenants/{other_tenant_id}"), + &json!({ "name": "should-not-work" }), + Some(&token), + ) + .await?; + + assert_eq!(response.status(), StatusCode::FORBIDDEN); + Ok(()) +} diff --git a/branding/README.md b/branding/README.md new file mode 100644 index 0000000..d11468a --- /dev/null +++ b/branding/README.md @@ -0,0 +1,4 @@ +# Branding Assets + +- `logo.afphoto`: Affinity Photo source for the Papercrate logo. +- Export updated web assets to `frontend/src/assets/logo.webp` and `frontend/src/assets/logo_small.webp` to keep the UI logos in sync. diff --git a/branding/logo.afphoto b/branding/logo.afphoto new file mode 100644 index 0000000000000000000000000000000000000000..67c7d26e678a0ed8386ee2920539101d5d614791 GIT binary patch literal 2108915 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

SPM4hR|}5q z2J+pTih%cDGcYuc0UTc2h;M}3L=26w!qp!p@)YoAQc*b)yrrtgtOv#$BXRx%Q-K44 zegQtBf)_%duNn1L8a1o;bu2mDF&Zt`_XO1dq08CD{?MtD2fmTN$4Cr4z< zC2mpe*$Rpcd8t;57)u+8*NC`AL=-Lzapn((?GACL(l(1me09a;iZsqD6I|uqqs}Tf=tEjq5EPiB!rE&$C$?t-ZcQZ!HF_dGIEcrMrkLxFY7{g$`t6- z@Dj_LS0=k2bLH3pT@CY%x6}3p`5q$aiiRBJCviGfK08qd-BFIhBD?c4+s&eMN6b0s z_~Po$X{@B%19X5JK2?B1kEhCeTo&YUdtBCFc8wTyD&&d^g z2Nd{o1+rB_D7Usi6F@XlB|yKE4}8CHGN8k{<`USokL?zwL^oWkL@P#|F8h`?WZuPw zxl_Uy;~K_g>;rK%k!}Duwc^nk$9P{X91><8-NO=YG#b~b35R-TOSXlE%|VML?dE+V zyT*%(ud!UXCgfGFIMPJsP1l~zi=lj)XKe`V-Z^KI?33cm8>U%JA`*|zi z%S3OiI=U&qOsL&1VjDfZ>2B%M>B`_0;52EKgn1#yt4_xed}W zhK)>d#F4`)_$O}M@QZhkwZQO{zY1nc?o`ehwE*qm(2e){dLoK$K2|*g`>cOW!Mu$D z(xZpf7law0JY+10oOn)hz{RAyJK<2drfZdBA`PcaGDM&a04t9=ghQ#O0249CGM&N! z@(D?QtO@)yp_^o=z^yzK@(qMQ?iv(|Ais6BayXooXbliSNw=|EqQq>oH7YE@dy&c@ zt{qP!9bz3YH%H^jVRRd4+x)B!i6>e3DK^sm%85ayZi$GzW~S_wR0MR^)koMpovyNo z8(M>z23}vV)zTrekDDh-v^V#jk!Lp4@^#*_oFB0Vu9ZlLSb`RX6m=YhrHUmW^I7-g zBFP!5YV21z#uVu6q^y-H(Y4GVqVmHeUWSeXS^QK^ts0HuRTs!oY>mFR~6mzzS;ct$K>jLm(5sRs(Hwu*?^st*(rg)dg zU9#&z=VI%G8$eErcFa1%+mE)#j{sXb06##$zl&acVP0$3NHnnvToD}Y#a&FFR4LMD zK!wcaegN>s*ii?`bw0XYh*op%cACYTI39pYkV@Rv9(9z9XzxgXsq3yqWn+d#fu8Lq-4;&YbtidyJ?<97JZ?YATI>%vF z!Yey@#uSbQEw4qDks!fY z*CVVmxC%8cEe<>>T>|X__d%t2EN~*;2t6rqrX60P5Ll8<0p-v)vCD)+j4l)H5u`Bp z4(kNVY+GctQj^k>pq`0JWuv5OF&Negg_O%(6Y%>4iNho(||9;?^!go`uT_Tj`f)r|3$dyO~5} z`d|yB6$4t8{jz4{?(hm`nxM{tbq3iQH*3WLr(zdB2B7Tg%Z)M#AcpZj>Y7`uH~pm zlz8A{Xo0Q4c@#T4o0NKY?vE-05DTucOh@?D#^!4ywyAAVgf2pzQEvW}!(e&vBuF=* zf;b^(ONe^6q`dX+h}t~Jo9Mo(Uvv<0cV@Ny$1STK`PZ}-xtJ)(gO=r7F@|Ql@SK^m zRqF{%G>&MREh%a^yeVxB+COzUR-`uxIk7Wdoa*h#UYEbIF)}*tsilcKHHgz>G4w>o zBdkSE1d{L4KQ0tyxx5r^I?P*kKG!d2xj8IaMEVbI+E=Eo(e~NcUKdf@fdVUh*IM;_ zg~wR0mde6~Q?0JKa2y)>whc4hd13j&Pw0}QafFBDs`xSB$!~U#W&9*to#)p#ZY%D2 z;FWU4u_T5IgZjYh%=R-%3Flz@lLyZZY5(k^)_7S9baX&X>^bg3qyQ`tx11G5T_tXd z9!f13Y#5Uli@-|6D{O_EM#lM3pFmS+aY%W*L3V+tjvWEPs5}~TKP1C zJ!;a~2)of{o1rkaod8D>T-Q6((*vI5(?JB|yZ8s!l>Lecg|5K9Id`70rZ1aO!3PESQO`ldW7`A}+q6VWqwwwugBF{(7Dmnf zkR=ltq7Lp$Lao_Bto!yJ-*jzf%r|?u*$I6&h+15e1m#UcRuhMPS4XH_6d%J-OxYgi zYc8ba$+J2z-JkVPhY?#he8i0u+kkHF@lv(=aT5JaBL@MSv{6dL&MS3Z9ix!28+Se+ zhpaK#b)mG^v2oGZgdBcgi^M%e+*Pf7N%Ra=*OX)2+g=Z-2C_$2GM>u2Bjy2dEYAQ?{Kpn^{1YFWD zDD8t@lAxKn81lZtGHMgCl9}g&4p~OpM-I7+ZGQFHZ!(nGk4=TbT)_ghUFoXA%Ahoa-Jqb!p;Y@m#siQEp4~y4U zm_p!|w7GDdkOiGL9OjUz`U9K*J?Ejt7tLCllfo+io+U^~H;w#AX+ReV9}m?Q*U(Sw zi*t)5IHtC=1yYDp*F&3hf2@MPPS6NE{B^^yopN++F~qF|{b+mk)|4QzAnaDEuPd5A z$}bI8%)ugR3#wIzgLM+9DH11G19*?51#Pgl4BQ2BJh5APNrIr(E#0c*L?!nzQb9@W z0{bs0G(;iKS|{Enq60e+cP19ERyXb#^#_|GSgJRR5$)=rTt!e^T7)mEq#`eiA14)u zJ05}^zzXT8hHPs}a!>ACc2nNC7_C?X$tK8=AYPomX7NsiwG;GvVGcJ?mAjIY)W`1WvIxJ0O$iBw1&eHV3{dIR{ zVZwFdGRm~%TI5IK7WV|{^(mcigWc0>G?qe^f~Xg>d~2fKyU47MG$j+@U31z!Yi zsTGEI%aNEliIwPD` z!WL$CaxOi?>eBI{=e%$u^i@^=xR`%}V8Ac+-2M=y`=&mzqs2XIpAkLLX2D53>1B81 z>a8{`0Q#6St?P{X5#5)S?8l}0Rt*Xq3PbR&p^H9Lw|VOjI|#D8yGrE5x{ljDA)$un zRKv7W&xAavq0lr6;$L{vdb8E5$rVV5X#IiiF!nw_T5 z^BuGhSXzBax~wheFU2|xtrVoUrMiNJQg1qRl>EnNLa5J9}_N>h(o5qd>k1u#1+0wIqO3v{yRe=%@9|m%~U1E zUDQ&-D94y=qBkIN*L^3wlc(T0UjStadSPmyFoW_o#2xIy%=_Gc!DuN+DZWzbDpYFU z1nfF72wx!WFmFM;if^2$pw}Cx@=f!O$vLc1e1DXwLIima&u*U_+;2K3DZXu6n^oOu znc4%!+Oi_vk8n{efw%*z`np9A1{l$5Wd6DK;KejE2+_AKMALn~vUc@c64tfOc2PA~ zg}dcriBmMZEDlXHtJi|c28GV`!Y=T%^f&vBt9ix&VhJ`y8qkMDHOHDTDU7s9`-0UN z)XG}`*^|2|@g;HOIw110s?8jaJq|o1T!8wBHe>noi8nlI4t!n^rn`(MFG-Nk;6Y@; z@okB}&LsUJjPG?~1Q(FCWTO}|DAzC_C2A>Ok!vAe@qjmsXf3}EvSQ2NHGwR)u7(MHUh6{eH7(UN5@X`#hZ(4GqS0`0lfq5QDzU|yAj*9(^t`iARO)G0QKGz z;H~hr)>qhdyKxZQb}>+U7`nS}v5=x=S1+!>kwtfkV7Gh+z8e7~ZT}K6@!ed!c2Hg7 zytn#AV=sg^z+2mXrfW!j0wmy-Fh(&L!b?DphM|zxYVb^+0lpjITSF4mn!ys{Z7e1alJ2^+)}Ud%SUKIydr!zVrpz6yf#!u-U0qb zk(K4(17TTaIB*#_t8*F{hqVe_1(%h5(tO~nxt6pGaJY~#tj6Dscnot1Ck|nT6T<_f zRD{pr8DTfV+^~^2Ct-9L$u$5U8U8Zc@Jqr}dmwlV{GVYBn1c9j1Qc%${>ad0w~?ol z3ebh*&BT1TEBHaV=#~o40*gWmLL5FH&`QO0^r$Qg@#NtN?-cRf2r^q1Vh}`UvMt0_ zq=c|h#0$pKRtsbBTo@|aSVq=5l^nOxTA~ohbiOZWnkMI65%LdFga&)`;9IqsaG# zEm75kd5tW)Osvj_EweNK^<9Iyj)Om}N!WddA*=@gLnzW!(T)YmExcD9Dd-4Y-pI?| z+Xhw>wz?dh^I*4D>DQw9S9r6h;No zL|g$@Uh4tdNwar*Om_25#+yQR(mN(DhLv+Om@i(_fm`;*Sj#-#i8a4w5(WcD>iqCyylO{T z-bZ)BRSG@{7Y7Z1H~`n2*CmG9?f^%M@7h9mf$;+^kHM=kH!CG60(>A$Lucum z>b>FZyh_n6(;bi|p$pZeL&01}E&!g)HUxKp9(C)u)hTSE9kB(R#PS9Sf9mjXU4TCz zfoZFJx3C_uOkW#m8?KPva#XPz)IJ!MHO^mP=;|kc>^SBvOB_V zqz_gP;VoG2X&_jx)*;#hmP&Wv@%U;OtL?;JqxlXU)c1Ob80X;|q*CwT089MAxZuu0`qlnXZ_Q1@odn$QlltxHRmsH5NW13q{2GncKi`==Ad6r2E1sOwg?Qf8va?7& zu?3<8W+stM+;Vd6y5eY4OV6uI)+y>wb)UIxw$yDG;LZ>o!`OWf=Ar1c!2ftR@MQ94 zwoNCJ#0j)qTq(p~*hx2}ai^8*+G4C!J%UyvCz(>|OG6$@%N+a46`~_jP=TR%y?6k> zKe(MI1bh)}3v`9O+w9?60Pg^-7TedAw2n|Sb?vIvzb;%HYL&-9ZV#!XCz)6B`{r@u z!stdVMQTNGcXa7wRcHg@Jg?U5VRp_cg)Of+R6$&y00NozRdHljN`s2C;))vw|KO33 z72<`l#AU7PdcAl|d$@OT)hL7QTKkh%qt&v(*!9>bfr)f!u5d{qx!yWvVTPW}vgm2Q znXF4pX!HqFMK2zIIncbg0)}^YdRm^vDRe~GW08Y$7Y4m9+2FoVTHzGah5<&{8BLZ+ zPqa;FtusZCtEU~l-0F|eF>zbKF?S`l<-%+o-`-0UF;_V#c0>Y)t+bhxy7j@>U`Ep# z@JXYFaIL}w_f|k1{7TEAqIbD7-yILlR_oVe*X5=PBANRVo*Ba9Ojtrlym>o|aY^2x zE%K>C175G1wChT02{1c%nkd{HR@|D`gzGbRaF_MQz$b(5TNt44D;IPr#hShqs%Umzyj`SHaHjVYswQ`Pq2Nuqi@iSVE;qqmMLIGz z+`QVZ+s_|&ht~Q8VGG7qjag>rtZsL6SSS@S{BvbXRm=%kA)#!FP^c~N>^fsiFYQ#| zIKeIR*7(86F4=B?3R_oXZOAH6jabVMOoF?ql|HENZ;=^dmLb5W`83UmsJGIAF3 z3U&OEHK5%eDxAyhZ%&jnj(iIc5H^oG8xoTDSKwjP8NC^XXk0+_oyDv(B|8@aQR^}>z29!1X!jQI<*#;-0z?hpLzHvQ3n`m@88%l3U zMv(JT?_6$5J9#DPLl2rfjQ5ifFL)Vyhd@*KL_aPh3*s-r7EyEJBX=to6S0qD*O_$e zGHj_?7epqqjIzLkGlNFBb`LLX5tya5ysx%#|kkJ~dt zZCZin|Nv%oYD?6w|aGsR1fuZ_TVeek^j0dbW8Y9S%mS{ho3ilE(E#KDD?orLT> ztAVvq$;(x-hMFU$3%+CQF&h}%NIe82`m_Y>CV(EXA_SF?o>&baa*5KaHZnrt;xYvw z3FM516vCyW+>j`gO{gilOKrR8z|~TbAL0OB$@;qdB&do97a$o*>+U?Fq|m<623H;) z&}57=xt#z^M>!;Phn(W+FM~1rfyhBKt*MaETQ9yXP5gI#od zjz_ZY!Wwp4aiLbfKULg~TbF>c$>p+@F zAO$iw^^RehHx=u+M9V0FHn{OZ*9(6~ANf!vo@=8?Jc@JlG);6E@1V0_vX8C&xL7#k zOu;m=A7lfEQ*i5YlH!+EnZq2VT_0htH2ectILIwWA_{nc+|$GyEQ5~<(-%1Gj}vPL z3}es*nGW~@;v!rYxNA{OS_V%ab#&R_3+kZ{CcYTnN+ig18_Zdln8HO;htSkz`vnA$ z6=Hja5JQ<)OLp%NH$dCRNJILVT>&Y=+u>WY*5M%ww*x!@2`k#2JrpKHWD)f7G>NY$ z0*SH`Se*>r%34qZ6Y1gtr$ib>Oar9>B=fxPt^)R$hq4wbu0vj)v0K7t1KY_&VE6^*>&)wexr zY)^%rl9!u31f9fM7Vyw6gm*^C7vsV~}-BjK$ zq9)@*a;{gDxt(rZv#B%aeF(5_+>x7NZ3 zNY!W;k*Dy|fe-OnuBr9*`X2Fi%&h>TZ9nNegob9MaSC%Zgb#9&y4sUxmTZeLN^{l_ zSCWdS$#V!_Bi2y(EU>*`H~CG-19R5{U2Ju{n-C5-2Z^Q5Od&qSOw@QigyX5(!I2ty zOFS#Zq1?5%{jyTFthd>2m>vAF^CZu2f=OIGv}K|B41lL{HW1=Qm~b7}a~Pg_6CJ_B zMD%1xrNdctCtrNwO1n+liZBE~8|dfp26J$&co>Eh(2w9nvezr^UC*c;pZ_AJTF27AOoG20G`2 zBHY2cd}THFYV&1K_$uFxb6!6oJsa00ft&L)`I8Wb&oN#m%OF3v;m>KzXP~IMC_Q&V zsm)$HxkB?U(xy_zsdog|Kxf^n)LX(PsQ>JNA;0w%@=eg2k4u!vh%hXl&vYQeGmv!O ziFF!>-Gp_w7Q5ffxY|_Ub~>SVnVb?EAkJfNu577CRrk}|iZ#neM8L-*(1<{SWIc6` z6NzOqY&%w>nd?2pEc5Ic->I{K=9znCjieLl2h2>=JN6@a5f5hO6oJJvL1O~Z@onn` zOL}~gc7QAvycJw}*7KNpR_eaQJbMeYnBqu280HG&qdqfo9$*CLxQHcR$utfi1>Y&Q z*%F$!h})Z`qI(v1fQHmXA}6%_!9AmA(T=%R>hyCFSL~-bkNO@tw~4^&*40jcf|Zj&Y$eWDM!Taibj&clUrm*#f_jixR{;FCSHxhl?qBV=72+l6l8 zJQ>odihf!C>gw|QVivPu4aT8^M4J!`s6m9UENZS5ydywHrPimYdH6HGHT%pPK%kF1ClJh-JMP38n;*$& zgsPySl+8~%>z{Mn3$4vY4{&?GzuG+YjK9}L_#g6~_9A@6o)LGBKpNsQ<4K$+@>6-o z1Y%jw4CTp-nalaY*ASX3jp=4i?@%*QkEqw!Y%vIS-$sj*wJ+avK`O6DM#Kriiz%0x z5E8GQpP=}}M9}vyGh!|^+?(Cl$2vqc%NW|Wj;Z7eZz?@4-*b$uYWY@G7~xo1=$ESoSqwPUC>PgXbA0 zKDSEg4pC<9r`ibEQr69`0(Sv>DUZ%OroeM0=9jOG@2{xp{4s?EWSm)I=mEXYQGTkY@>!T1u{#+q6|rzJBGBIt|^e)-309%WS{n)+poL>nC2Zezd2I!i((l$b>c8$ z6GjWhWMj%I#_^GH*$j$2CG*`$A#*6(y@ll_*y zMU1StO=V821KV$Ya@HhE!@QLT1Uq=%10%VX(P?lh)^OTxuu4^~UPl6UuaM2;pwPz4 zOh;ANF7wzG2y4!n5Df&i06GUcCC!2+hrv)L>9Y7D-WRnNVWk}sTTXJwdH#iypmejn zb0n#~#x5UL+RqG^Oxhmd9oI#)ohX5eDE>Z{*>dMPm1nk}h+~>pwuP8A=OJH05=_`f zyh4!J^bmN%V6FM?x>B|AUlZ<^RE{vseHugE;kXa>urQi&7S~X6=+hV#k=h5{+#^Gb^Ceo|e{km5Y#eeheSra>c5bi<( zM|lgJnWW^5`E}Exk&gPRu>r23cnzuDcB2jbPXKH@}ZX^EDhZTr>Hq6n|YJV#}Q* zTDKT|f{mq$A9RJSS}~2cJ!)GFiKQWp6sxHdM3IQqe@l5B#z`K%?z&?(&o~;|rQ`_# zH*b}ezW8)x4ZA!9Vqw$ZA`h`xqgk9r8P&oy;3N`ij=&@TAh``rRhnmqhShllj|2|0``I{+>C0N z1o%l0Ih?{<*%S}EyGLP(VKz5F-HPzwHjKAQ7>9O7T!$~9H9{2gRoN(+N%%2#3XSX7o#J(o{3%Z1i5w25O^23Fm8OM zceZGJ4C7dAgQqz=nyL@66@!~oC3ZOy?a`v}3`od46muAtV(zFG_fUMMEIJt?fV>rq3mB{JZg|!q z#dTagT9lEvfIn7h%lskIm$->0Ns$cNhduvliF$jA)3dTp{uSOn?M(kh%yd5em z99G$p`3NGX;8OV^J6maEKuJ(@z%G>mvOQR_IOj;;Ojro#optE0n(h+b2{RlWOhV zv_gyv+9T0SBJ=M03mF7ryVuQ|nvnIf#YA(unzc7nb@eBEiKa5Ga`=2BORC2@fJMMQicRg%Z^^0Kc4w!d`G_V-@rk!8jSl7uO&u zZ6h$Jav35Ik8bW<)Dn&#)gVAbA$!FKkhn6V;o}OuIBx^5oR~_-IQ(GsL>-0kGOUkH zMAF(e=kCMg$%BI5ppcCECir>cu6#%2vFb9{Xj~E2 zcJ7?y+vwAdtcjKv9|!OZuziOQ*$O_e!JP6iox?9?-w2^AUOU3i6JA~-iD6b$x(6T? zh+%g7N+E4Kx}2NhJPzIyDjaN2fYWWahpE@aoPf>^E)l;Pp}Fl};gJ(KS_zA5RvfZZ zksrjmuq7^~w6(ZG;jHIHuXQUJY-@P+++}QlxLEcUbAV(k0NN-~>d+dK5gMK=ksQs~@m<4YVe?di9 z-w3?H<%JEz@SGOKZA0Kq`t@MrKS&jaz7y}zO?v6!DB zlwC|7wCXw|W+U>bxyN8eGIm*Hw8+0~PkDr8fU;zmY^?=x1Y9Dn#d8C8LQgCb{D%GU zfb#jq3HQ3Dc<*32=wCUiQvi5wZivURAhSx z2nK3XYgx#nYeqIlVh%L}Srv|scUo-Uq?MKe7K$k@`|#ql4L>Mxi{xK+z+@HJ6^r`o zJWxOskadZnl`cN27DXy*H&YsqGVY8-$&_u{MyOOKit?Db!+F3B5H--c{j+`_z4?-e z=1}r!)si|9Ul}npppu^sxTV4}K^4>>cdDTs522BoICsY=zn!y)u&2H2_|Lgbe@iB< z#UJH?RXuxzd_-er>V|$5;(YvV3KFR54bCuxgB52DK=&SVDd^$(D8 zS)75Ah%q{)Cla!czlVFv39_7>5Fn9Pk`Yyi(79!htKn2J}l*FQnT z8}yKrvc_QaN2?v#!o7aLG1z|)jUKV(;doZ7l-+P(sDYpj zOH93Etect)gAE_t2A=7wlkH|E9I7*B zx<&~p=}tCg6CHc=O~sYOU8y&=1{T-c14DJlWz^kYD7q6rr#5BXfS}n=J$`W3!7(ah zshN~eVyO1+8wZnClE+I+4_&|7Zg z;mY*}A*pZ}H{A(CIE0>IR}VhgyU_N-SoOE`LzvBPMz}8g4zzp22-hI&1LVO5(l>4h ze9!DAAspQ#Z!JzYxEkagygs*1bjvoJEt@@Q>ehb$q&x#{qaY6aF#Dx&C=iObN!svc zjXQ1A37yaqfm=OEu^{GpUNfv;d_rzk)oP!MX_hG)kPLq)#T>lD&!Pzqa|CA3TE+=- z*lo`8k)b}h_-rMrYIjS!!*B$l){=2H4z=26u6B&*chy-)s}3+{f8R>dan?bd<2lzQnbS9GIYE`-|;jc;r3fjHf6iz$c6 z335)T)yyM^yxb#*Bh#T9X6SV6Vs5H zkP+k*p@EpYh?>?Z&^x3yxFOX%#Gt?r*-3MZ@=3R5R!ScNPml((V~|IwFY%-9=_n!@ zhm{fJkSV8~(?ng)WN8yqLDFSwwz0(ZR$I$lxh?m$YNu~_+%ugP=OO>IU&%?(!zj+h zOUegw-s1r16+~Y$1-0PRU0H`MW~|ISVwsB~#-d&AO$C&edmueDxK^AfN zu-QNknQ$jM4Uv=Z6Ey^?DB%R{J~A)ioHw-&1HTnN;;QjudF49nD%l;+^Tzq+u6uxM zQ2U|ST%h9>o1x`W*`g;9SLBQjQkOm;ENL!jU_?n}fl%X@1SJmp13i8{0TJ##fCg_L zzy_}mGXeAbl5kk8BpfCu;lpJbVV1>zl-K8{A|vySE7Pa=ZazigE}Ar^+W0k|3`*C{h%&z(iq>z{lPbxMBAKIB@#_ zHg|oPN!QFT>6*n#y5_Nx?wNdpizat+(_=_3J8bBEd;7q0ZJe)L)8s#>7+pxWWh=5# z;gDo^^vf)`KhGw#LK!8#7I%cOibR4T3i7kU5~G#~M~Gz_5DoSTe)#$VH|kzM2W=m~ z#;yl5(XPWxv}=AzmMvD2b&qvm-(yc~Tr7!|hwoXs_ix*$oqGeV?64t}JXWOho*&z; z>`Jmcy-uyTdJCttKpR3}h(JVSH8SZiwMYd+)4j_CB||h1i~@TFKYM)v9CR5Qd2VSfOb_K75TD~k|_0463p23iG;c;bK z7QI;}ZLip(o6mS!^V?x!tnf$<7a|l8R)|+NKpBFmur%01IP&Wk0O|DxMwoj6AFN&{ z)4gw#DId6r)DC76{ReqDw1@t<}8}vHCJN!1WTm0InYxJV9d+bWyMQRiAD6fG~Oe)`WGw*Hnq&sU{w1K}f z%&=q}TzP6BQE_@okkI&|lnCp)qO!8VyfG9$lb8}ViIY80-3Q2ms10i*fKJxXJsZTg*nG zYs^NGi_Auev&??Nb!LsYvB1i3b8+Q-gHe@)n=w_Cs{tLP!(msy;~@u!0%1E>jhKD4 zOunq(C|7mlDo$TDsvsbe4yj-H-$YGMu(q~G9wR;j;?*7A+yT@=s-9s=j?i2K6djdIgy#Nq${aYrL zid*NjO52KQl{FbrD{4BRpJ_#)KhTuq!9Zhz$C=jTPEzejog&+(+@RZ(w|ZQawRasB zGjraSu#ir7*ifXL*)I&M|EZ+~FFRw!bzn zYI3+RW@@@IVPQNoTUBXKRfp}F+PpncnYi!iD)%C)%Do0AaWA2$+bal?_5^s^dI2Ed zdbJpLS<-6QWkKUnhvm!&-IcN@byUQ#&?O1uLU%*#%N&a_Fmjn>V&Edn#<(e-m0251 zJA=l@hNjH@)`N}b`mHQpPlYG#o8Gd0(V4gZ@e21hR(!v~>Dx;<%JvFM1t#oz0zPWJ zfDmrI+!nJRaa+oYz-b|C0+&T>3Y--%EpVS+VBm~=k$JQ6WyXyKn3*=surq9vV`$VE z$g^Y=t7O_vaEMQpNs(5*EgVHs|O^8>Swi;h&*j9X@S|Ebuch?`aNqZnNYd?bP+IN_;_7j$%{eq&n zp1==UFF-_F-({=`8yB%CY+Asqux0V$!bYVl44V_KGHgG(&aBPwLZi0gD@~dvSdT^- z)}J+s^<{`-{a9dG55{-ace&O2EOJ{vrH$*G^62`bx4b^cY})5^QTq;6)1CqoUcbP| zt&13xH7;ON*0y|QS;NxhWsM5gm^CI`X4ZOiol%?7l_pJvSC6Lo)t^y<^=6J?J=uM% z7ZW7wzxd30u6J6GRc7m_xNv03 zRligLs(+erqNjpT)MqsS>c0}?^kxM-^lkwx^mYIs^w$~%J^%lb^#42f{&#^GB-rx+ z@%24F@c!o~-2?fE`w~6eUWmuq5A8tvA`*4|kq%ssl(W{Kz%lEaZo+z~5L`Xg2df^d z161#o;Y3eXfv8`D@YByJ7|}-w*z{Wf6nb*afiLX;h4B4P8qQ^;kY&J=cy_A2x!jKMUd1 zyKzYB?JSt+s}6eluK^JK8GwWy{s$w@z!r4rS)G$V?CLPu3qius*lr&>hnM>_1FwX zJr9AWPam-9^#C|P^Y;q~xqksOD8GPC&ktZyhmJH!VmZ9!UG_m3E;dWHT&T|ATdvB?TeQZ$Te`xoTEM)ZTFAEMw4`0JX=#%((?Z7trbTL`wPg!5 zwPk#>w56TF#1_5=BeoC@xS*x+BQS#b3)ook3wYZ21SpUE1%Uk$tGO#otmc+{Qq9ST zQZ<(u#cGZb%2iw-lq|UU6D~LPldm=Yi5QypiCLNS$yylQ6F9CmD0Nn5P^M2{QMgB1 zpX8q@Dee#_rr0eAQ8842Q{^!Lg({jKK@b<;fQ%de04I_EK(ViRAn&PEaA--V1j&l> z5RxTL5kxBr34{wu2*fLo4kXNu4TNlN3DTGDn!ogZU`P2T#+hPnvpJ)7!tG5 zmJ&G66q7!iB`1gxgrGzkxPii1BOwZmkD!N+kKhH78VQqqjfKI!M#8%HI!W=xeFCFv zyJJQd7ljP3?W2q@?O+Ts?4FD=>sm&dR6L^%Z%adNj#>k+?e0d^EDlH4OKk_4imZnl zsVj(`r>Y3z2BsvF42G7V7;wWR#)q(k$5)U;34jTlU;=b#>;Y)#>j6|K_mX7eB$H%g zC7EL49i3rf6r5t>$4xSRaMKLD$Ens`ce-WADCVl%6nJ@671cRB7F;&D7vnLu7;LPv z8uTSE9E3nuJRUtX1=)09q6iEDB9QQ?sqkY-01Pdz2(Y0#Wc zC$JcbLRdB|K|JYL5rOE(jR818iUSsx@IJr>Zoei17{?w$ z_{R2u$ktYI*w&6bd|MkH#-;0sb!PV+DlRKlyFYrIP5my2^OkTV|hE8j8ax zu@#X+S}-_8MAe8f!AM6B0zo*K93ZixN&+3rF$i`Rdjli_djcNXeE<&HzAa=A&mF{G z_YU$m_h#`o_n!2tb0hlUvHSj_X?UQOj2`5I!-l+8dp}R9i|@L-N4~YV$w9C+OvWj) zPRk;$6)SM0V%(sBWTVIeq8CsY0Bvev;1hXf!Oy)%u+!HU_@M3saLD%VAaCj$h@KSw zV-AhKm_y^1^C&!VPIdq7VbyZJu32s8dGX(w%N2B2t%iGdd5M5+bB*^fw#i7UGE7dR zE)+0ws9Nx-uoOc{grgKwBABr3@}Puc-yq1aPq0((3xqiL0XSfLcM!D}4up-3|8QUR zWVo$*m~Cu)(Y<{OzP;?dxcJPLpT%T)yX&>ftyjQ4yl7#e8(m`Kn46=e)fuHH($^*p zohlVUE<~+xVnIpMN`)fHFcO9qdj}>6dj&juy#WzrcM$Uz4&>~O|3G{7YM{G%o9J)c z5gmRLrpau+H5&bPx5wwW+zX!Dui5%w-V`wL&8|?A>`ig=3N6w_64+%95?3gUbdchV zvSGjiO$np7Y zNLI7^%x`oYEgz@ZcCYv?h~>@^q1C!09A4mMS=^x~mRjQsr7+4KJ6f7cLQq{wv2l{* zqCtuAjD@4aJ_1o-&!8vX8^Gb+LBQxY5O5m)1Dw|50aojIe%JBIuls!m$Ju}U9(qph z8?(=XU-J48>wPyuuXD;cXdkE99AS&BHN_hz#v+CEbXhvdv68G30?P4Ahb6`SLQ-Kb zq3Ew?(DUvOi~#onI#~O*kZ#)@g!_gA;lkrTw()oitsHKt-Fq)?>uka9ADRz5IDU%} zzh^TeHoTUMX5pD}Qo0JWIzSm&ZHGLT${LZB@PdrOgXM??M`}@whbF{6gVJC>p~&7h z@NxGCa=dIIS-Cq%cJ2;>rN4n(?eG{@zjvk;(Cv5~To+~y&wbm(vEJA5s}@gY$L7o^ zmz_e6s`ujT&VED7Z7>KEnqre8FvTfAT8UhAs1VD5cq;5QECu!yjQIKnKJ5O04!0Lz zQ|;G8taY{!t6y7470?}IO6aa3J#=?U@9fB_qWWHSJOf@M#d})IuiLAMHH$r?-}4K( zYug4|-rUDl+#ixCG(spzVun|QxD3DWSQVlX5jEIzX!`3b813~Ae$qVx653vX4YgMn znFg+jND0?OqKNDv&_(tRyvOwlyhycW9wnMFdRZo%YVvj~3U55i0;~4Mh>FEF zXi~TluQ|Gtsx?0vO97dJj*0{e>fV|DZ&vPkY7wY9tAsA>eZqDfPa(TY zXW4>#dyz_;mr)wy>xH`NJ&CJ&|KVxgV?b*65QM~i0v~EGz=O1}nLorqo z*FwAm&W8AhTMzz)+Yj*On-FhgoDpu~T#{^{9g`|O4+^wRl|o0(T4~#QwU|N2U%cef zWUAQSJyV&yAM%>_JXrHS1XR4wkfiP*41xOuKha)*hiE^i$uE)f3^Rdy0cP@MQCtVb5P`H(CpIXMZDc3@|D$~9?EP3&~Ep+Iq7dJ0f%-Pgi=Btj}U$v$C zCNpKh=sIZIpG9wkYco z?KJZOwM6@Z2bl)uPEjpPT_Kwoxj#2DZ+mfO*4T7t%*J zf%_3z-kw8LwwG`M+9Lp}>ji+Al7>Y3Y4$`4rHqP{37Hlt5V9`O8f0FeEy}z=InBP{ zS)PHpgG38sx2PtD&Twr^9AFz6H~O2IHMd+Ev$9=nP zQqEd`V#Ha`gfrGViSX*9KxFk)9;A9K4oiJk2BUtgLQucS%0L-P|*0pkm(6oUEX36Am$)SDuh zC^y0|Qf+-@4_+h%Jm`YrBj_>XA9(Sj1_EVYLm{xgGJ$f1O)|v_ z`$CEp7IVtgwQ&lTb#RInl`e&A%9ip49ZMO*<4QTJqe@wevy%eH_9n&7ENu$)YivsQ zii?W5sj3Rxgp(CN9HT4u3q4pS6WCx$0ieNWjh`R~5C9N5%>?Mc*ayHs@54-Zbb*-w z;qv}K!u3^ggzGzLq|19~0j)#}6?Z-Ln zOo$#;m=VAuEhURPQcW&5C_Pcp;3rBA0X9m449H-E$^j3XXAs~t_6BeOdjcNPef-Cz z8T?0P*&B$8v9}6OGPq=lvbSN1vvxhjn%eF8b}p-+8+%#gd9N+3*eHxHlukxD&aK9~ z3@t|=mRJu&r7a}-yK&;!{w_%ZAg#KsPtk4;UyR1ljaGK}k!#jUiow)M^%XBueka!nxW=|Z zv8xP5rIS|;6+cxwZfsZrVq^l+jVKj_RBWjLWQk@%5M%$K1Yy5`M7$^9f!z1us7L*9 z*r9(n0Pg@_2-X2w2HO242Ilqs2}=3&2(NOrVb?826r;I{Y2NA>&q5{p^5`xN+uArG zzs^={K7qx65p;E*GaB?78?Jazdv3I_0lyNXx7rFTMc3ByQ z*6A4))}lrUtQkWg2@w$hoj3e9Eu+M3q**$1fjp40S~+TccaY>wDH~w)`({Z zH{n`AM>rRdFb?kHlaJ|ddOCitzt`ZuJ+gKXkUK?0l-@By!o^K$im7F~kP`FMA++@( ziH_BZ&k;_WTS6#Jnz1=z>@yS@_7hC>^$mK|J%JD6{_jSb2egsq18b^|jS}arj#=E#G5{`TWXUzrt>Hka3!KuZ)v>Ofj}*Ib({f^M?tq6-|Dw zI;reHS$e@)f=u%vNwL>(MA%a>QSTf0pnC!z`2O!k*$il-Y!0kZHVf7;n+H{t&x4cY zbl~00_WMJ_`R40AJ+|AAP1YkUd+Cba>z^bwTBs>Ux7dQLP4Wg+nI;hpWv8}k8bwU zbEgU0=XRnQtoP{6)-S2kz!Y+Jh%(CD9(h!qSteNmyZkiDn$!|B1vzHNX|Wfvbl7u9 z66`As&3gwx<(|NYaPNoX3;(IK^H$^~I`_X0D!e1MJKK0m^5AfKHt)QguB z+v@bAS?sR-a_5}YYM+a_yTTh~aEn2%(j=Qag+)?H+Ir+-qKeT>4^(20!&KOJPy*~L z5XpN7LE}Cg+&w=WT>9UQZ5_~twhxd-77&m#EBFVQE$lgj#bm?7o#u3z#g|{p zuxj;m&`Iq>(&z@8fUO-)xgtZ{^5k_NbA)x+2Tc|BIZuMUho!#Wg3-HwP$JwD0P*by z>pHB2_8e72dk*TNJ%<$1?jlNQ&x~5~IZiX@ht*E_A6CVB&UH~{hiQDtW*ydUJBVF% zuB5H5FA7;(;1w-1!Y@%^g=Uhp2z#Nb!Jb4EVE=)s-d{LsQ}6U0(?q@gJ|08FCeY^2SVST00?j2BkDNk5ry32kyknAfwwvDakpZ= zgKmbL1zin2%sL$J$GaVDM5qrm;B*Liens+Scbj0v{AVzlm%dLk^po`=fbhj^0r7?RTc10iou07SRXtJL#>XQ}7ojx+wlZUy`Y-3&MoxgPHs zaYEcE9?Oj0JAu{v zBCUGgW5w5pfP(iJl+3*Yp>9tA1h>E2d;_uTd;_Ut;SNGKqb)>kM;pi+lJ<-@CTtXN zP1KZfPSA{WP|AgOpL}0*idU~{1)I)hk%qlp>a1fiZFlK2WO46aD^1=*jqUYEUG&}u zYu<~9a`zcdkb4J0+&UF)A#^O*L*{0ziOA_#6M-YL=Hcc<{ld*jniKBHc`^=4nQ<=4 z*w9W&_%Dx2cP_PpWm~mK$J;J-TCkWlX!%Up+`8XlgZEKpdOgyYy#L9H_cv1OKEo2< zI}qCT1VC(iB6Bd*MdoCri^SzX8-WXg4&t^1eWUFOnuQw_v*sI=@?>0;F{7Lm{;+P! z7x1pimJYpgeN(ka%~&sW-L4q7DSxJ{?%Z>^v3sercb^mn?}4=3{moRm&%gxM_BGB& z;&7Oe!2K{6aZ6(U!4}0lgDpx~MOzedrrYOu5>5)=C^rR+I7g)`XlF%R$HQWcOSeSR zRxfp7tr$0}*Nhq7xbK2P_fu)?UP-Lo2W_o;94d3Kp~>4jFrn=Ugvj=AJIG1ie2|Z{ zAt4W0dotFs_Bn>ZCPkd0O$v|cHYF_iM&;WGSEY+6XJtD`cLm#bm!≫{shzztoMd zV%)M@GiFe5-z7)xsn*cF5?Q(r>O%K8RpVZR)3$eDBHI%PfnD+jM6ASZi1-K_6LAo= zPw|a4Dg2AIDPa_BQ^1&QRKAjKRlJRGR=D@LD_X)iELT4}Ez~(47ic^Bg^pYm!nPn^;9Ly`YWKYUdv~z@7k&A#YQ;wXd#aJHx4Cw zyb3&hwn2z~T!2E)27o{e-v=PX@B@$`3GbJ#*gccgxo@f(_fAyc{)vj)M@?aSDJg0{ z6$S09pzM09C%GQW$*tdFO6$8$ko93mUVRx6R=*Y$)ywgm=%FHt`t63Eo*dyr-!>4T zuLB^!#PAEyG5H0o@GRpy14TAR12yJGBQ=&zGbL6|LlqY8tH8Sb)mOI1@}l-yUC^Fu z%dYpzitEF!)_SrkvmPx9tZ!?|>gkZCdZ|!T&m}?BlWlf-w}cM8-GGDsu%-YJ%1>a1 z=O2I~hn%1nsyII{Rd9P>tlr*WuH4jMu-eLDwAjL8w$`{~y40{_zR;#+!p!i@jFHKm zF%x4;qvmDSziEN>c2rvZv}vmE8ZGtZ9-;p2A*b()kfBE;z(5P-H_&6nH}C?-UtlSQ znpov(T4)7J>SrY@x_3nj3dh2gr_R!aN7nLX2i_7^r{yv>N9|G;m-hwDZ7vMk8rv8+ zGcz-HVPHK~E35BDMWrz#DWQS)1flV>!8vnxaG^}400m51`~x_0)F?<{1%QUde)5Ty zxADnVw-5>!_YX?fb}!18*8K^FMN1L0il>lOxmC=hRx4?Fd05))YxN3j-CN=os)o$L9eJn`sc-F!l#R)O#U9zSuR0e6=-+ zfUzHmfUygSh^_5K$kOm5X67@JwDC7dTllI(j+>_h?<%*%3SDF3l_PU`6#w1|KMB}ah_L6$7&zyeHzo5`NR&S8JxX5Q0x#2DM-aYJnGVu#qg zQpec*o}*0em*XrR*RkeC>v&U#chH%$JfLBz53JS>2srvCBpsJ#L|!JAL=a1hN#hBs zCz(A=pDi{FLBYWw2Z<2~H#D$J7@^rq;A!k1_zd<3ZuI&<8=7f=Hd37ZwP<1XN6F&s z@6IC4&yNLL9nJ!7@7$s;4t@b;XJK5;QyJgyU5#@!4o94BjR&AvS&&7muOpa6Sxj7v zfO?YSf{~IU6@YX|!N8+J42B-cz5-BU4*}?}KX4Q89pMaRNV*lez+^L|ylgAHTe=bY zUB(5*G41@&nNhy*<~6SE^s1?zWicKLcXkej{jROXVcMCFNi47*o<~_nY}`aCA);aF z#}gY9F}BzQZKmN+0m|Dk-Q45Sc@E~ixoBKNqmij4rsGADR*)Ves)R7PVX9FD$4CVj z5kr=}hoQzk!%$&AL1?c(aO3X#sERKBq>C*G^B6G+^B5Niql$rr)cf2JTgXQA0+JKQ zd|t*i91AHwODq4pcAEDnT#89HwHGP4#%k11+TuZk2P;RE9w!%Bc9J&9j4+byK@=(W z8;S;d3MTXxUCgzNE%vgFEbLZC&Aw`5qy5S_*r!$G`}-cc1IL19LbV-y7}wJ(+SAm> z!Y>}Bqf~A)(~V7qi>@*mI+na{C>g?vAw}pZ1(%-_muE;ILG~h!?mdU2z@EYoygz`` z?iX9U!Hg~7Uq=>t-N#Nj*AY_gZ8V&R7wvmx#CTmjnD&GB@qv7DZlR#Ms;CIggPf$! zS)zDr>zv_b=Hdra*bFC0SSzX&MQwg5!9>~fC^hysNQZrgB*4A`(Yrr@!|q$G$&4)4 zWXBcl@uR0({3zKbJ0iNzi+}I+U9JZwT<_8S$acWF7Z7oMHGB+57c;AMk}TlfG;e&N zttcWT#sW%HRwtIEsY@|KPLaLO(qfOJWY~95`s*tYwHI5Q(~K<6X-5|5^y3P2`mqwd zc2q>W7X#ILcY2$T&<+3dGtUj@U_0YlD@e&t5m8j{9%ID8UGe}^L!smoqct_ZA7u z^?Mx+NC;mW{-xoId*VGwHrG#+uNJbf;w{>Ui=zxOwx-#HOAM1r)fQ!!rmDy@M^TM^ zP}9B7SsLtZmh63nqO}WNzSx4FH?j!tIkEurIc|dOjgR2JMMGZj?l(Rpl%E&>*z3qW zavYQGpq^AdjG_Q9=P5o4kV66$u9F$}tU+lwx1P>9E&n66`%J@%0so z)cpY*b+72+%eUy_YiDfX^gDCYOxHv5tw1S;@mfZslO#SJO{#oMOI4yI_yqHO5?e4{S9MNgkcy z6F9cJl{2$MFI`}YW~j98{ZiFoZz77Y-+}t;KQztz3PtJu08YBs7T#fG4-;Qt6%$`r z9}{0tEAN|BOhm})X5k&1LNIX4qL2QKF?Ww`u+Qoq<8R!^*2>pJS_e2~&W%t@8k%7k zu&~24R$PZYlT~3qG$q*YMEUg}n)Lb#Md@qeVGJuHVvK4eVhpO~VGL>Kp=XqZVWrf? zAmda9V4<~!y-%w{t_S|XR+o>I`E?U7HZG-#$A`r(?(YkmnxGl6F~T0Ji?DaX8tjXz z0DGP(dk?}X-d8X}_Xlv$ef6oonva4L9dRp-4_`tZa`I#Xz1MIcH0{f>ez~1P}ukWd<_aL6$ zeFdX)e*ov)Pb&v$NHGU#L{kXX$a@i31Mfzlj5;8IoOVI{B=LmU6Qe}vg4HAOdNv6g z?qy;|i=lwQXewB%I}7wq{sj(>uZ&q*U%w^h*F$yr^+#EKy^j^W2LaXYD;S-ln}If< zC;)Bbx%j(rXXEci-H*Q(bVB?x?S|MR@rdvxLXFG^r%2%Ts}i=}wuyNSh7uN&sbsb4 zEYaxOOWd5C%o!VB&sA3MrNHd{(N?|pxtjMOpw@i_BXfUXgt@U#9im?L8Mq$5(ltSiC?d1quVG4F^Tp)|?cuexMC&pI);yHUbro{F|>#v-Mzy~x?Y z$)LH-doMJ4KPAT3BX!aHA1u8-L{z%3U{vl8j41ax?qJk)*wNtAQOBd6L#_zli@73v zopeU_EbETUAMcRpDdHu87eaLf{!-C~#aN`*bCkp) z@2AN2`XsP<|C9CZLr9(b3P$AqzzA}`$D{7XoCn>GJsxsJ_GZW(nXiyLGT)p-qE|_m zB>rfZgM@S00SI|gHsYRO&bqUkYebnY}?ZSkJ!Os`i8 zi}yfU?Owzbxu;-6_CdEp-a`(EJVzW7d5kzDdY*Dg^fc#^#3|{NydUe9xE1f1unqH? zqywW)%=RgivD_-<%hp!OlD%1^T)qq4)I3JbZk-0~?cG5B(DC9$f|D(dg|$RlKN?g5`8xVPH%pIp@$b>(Ers4D6;qk)W}?;nIb!*p&A>f zsS*pPu?q9{S7F;83k=(9eNp=@uV~NJh1Yv=(e+?jZ@n0nT5ncW)~iW<^>0mBy&aNN zUlmH~y*fa>xr2y49-%`2M<4-~#b2OD<|p6+hFshWR$SXl799GE7F_wu)*BCo%k3Mc zt8E*`iw!FVY>hf5ObtqA49%{LnOU5fG_o}`Y+z=692Z!RXQkC!pP+j0kx_5%>FMJh zKJ%z@8j5j2k$J$xPs&ii`jWdI%{%ffLOE z5R3i#u!xvCu*g`N&j=Y>?}*vCjpWRXPLft0D_I+Vm$dO&CU9S>6FKf36gw=O6zEjV z3f4|+%hxR{%$W>IjNVmJn82hGVi-x}gweFX!Agt*4w48_P{GBQ1|TH+1tbdlM;~N! zL?2~xz#nFM^dD&PI1sh>I>?&4Jj9I+E&`X<8lf{&liZ2#m`J^DPp;RqDc?LVEa$hY zEP7*5S^}$*wlo$AanZaXVFgA64orSL&|t&Lgb&9cdpg+o+&kR%&OPGf;U9ExZIC;}a#q<-5O*u82Ea69itdOWv7o^|^nNQYMB(JV~K=2e!H7)Mf1c;uW&f+UCNMireP zPckD)Q1&2%6nhOufjxsAdk;w$>1i1kd8r8(si+wT38+cuDA&AORBuWV#huteZi=tn zy5by%x`3bSX83X6ZY18R^{_M}Bhq5ZYsn51R!fllJn{GhI(p&dhe)$0F$CF*AVTan zAo=wScIy3MloG`vUS%d^UFG8A9cADnRB{eUeOyOo7s-=fL$75S5S!_qOFQf8I~IlB zxEPRmYd1R4%6!nE@-i}nh%3kwqbC|!ijq9Z2pw7WJw}cF&5>cxA*tRo*x~CfIsdSi zoPt%%%|j_>g3oc1Zn`nrVCi@?x#U4kAu;-ve?-}gS`%6x`7jv`D&D8wMYCisH zGYbK`m-WCfC48{_h%Q_!@^|?lm(j0ojXZ2aFC)Wrot)A)7c$h)Z2Y())8V8l3x<^_ zsT5zBnl5{z6OcWSQer=1Nf2{0KF-wC6KiVjfi)xPVNFE37}Jl>Tq*BcL#qGu7RQ73 zLhm7=9m~k*mP%Ukc9|-sa4K%BsmU<%H6|m(+UZ)B0k~7W5+)T4IHP!4* z%XM3GaSg{DWXmi0-u1}0ULYfyj%djHIrec0FW**0PjQ|k4eHz`5I8o^CTU`wRK&J0 zyKGreo{@@z?3J7rdm$&oUgznr=b$vd`goo z_Bv30JqIOu&wyv%TXK%~E;+~aOigiJQpj6G8nVs8R!uqP4KXW7a3U2=x~ zFgL-0nVa82Ov>;5QW3mJImkao*2|QP@wBF4`t9i_rpJJD0dtV6_aa<6MkiCYM=Eq> zk6qNh9Lt1hHTG9kiha}6VLv1#*pra5_Z&{hdj>r6zVjPs$@wL`S|AHTUcFRL(YuVMI=^=`z!b|C2Lpb~H$ViaqeB9^Zw{s@s zsQq&*7kDMQM(8E3tgwt3m|?%QRoFja3HC)+fBjAsJ^L8xiJgq}ykz;Ih=_140z%_q>3x$qY7*0#sM;viCez z@}9#9cvZ8J1vLd@i|Gr-7SS7woY5SMl+qlDjZ__oh*lnjf>|Ge_AU@Vd3eYkn|$P5 zRxdew-%!9)y((5Z`j)yjyBM`IzcgNAf4x*!Uw^b!?|H1^J%w;64-A{z91R#6y{8)6>yf_XeGk^U-*BSc zGvIOeVl-A`;51ia-85KX+deBS+h>7Ydo3_(zx5^UxxDgvuP(d(i%YH#({k&{u+sXo zsP+tU%KGF zU%cMBV7}b6V8Gn4VZz#~V#L^_V#d_)#+0SekvThqGowbPcE&BNte@lh>Y-LyefG(z z2OA0XX&#(@-T^~D*nj~T$N&a_czy#ia9G(?zN+aeVAOJzu&LOJm=tVfOzOQt7TsPk zgJQ9q_1T66MB8D>|i;cLUzenE8TO@Jis}ee~bcr4Kt_idY2Zef7o3f3D zT?xxsbxETwm62-+^@1lzqJkaq8p@U`*VT59D@7tkP-`l~)2j|g7 zU-x+1L-&A_lYPkPg@4e&yFu*SSRr}qX%Xr94vBRPw}jg*dlIg4QAyWHagn1Xi4n{a z8uNHW;!CH94^wJHupmSU2AdUQFyut`5_k^#LO~jCg!US5gYp`2f^Hjff$AG{e`XwZ zetZtRx_3vEp6x-^oBqJM?S^#GZAZl6uqNp%))aZ&(-uLqBrcCtCc1Q*;Gsgp21F7f z9zw|2@<9i)@8Gl8OYkY|?=RvO1z*T1^1+}>jKiqAvt!&HqGv!0$26pZUmMoHEDkN- zl!sT34#-%oRz%HzF_B-dqzKMMQF$CYafK7Ah{}wgA0|OY?Btk2f+vNU5kHdshoQq> z!Vh5|xq=SzzM>BDZ-w3C9t%9jcMB+E+J#h+48w}}m7yKX*7*8KbCBnAKGf^9BJ(iV zl0j;glgBrzD4bqSQ)Db9HTm&V1msDHpch_%f-w6Xp~gPM5n(SOXt2M`?3}BFL}6!H zh~lo&o+6J@FGW;xt%CYkW`SJ=%IM#DI9(!pwAcNksB9BloC7fzmPG(?D zIq`7>WrRu6Q4K6dLn+Ld7^3WZj1+qjCjxs3L4rLerexk`W@a7dqNd$upeCN?-e&ZY z-C3n9qtHTrJ;RDv7U{t$3_bcC4M4SA4n}U<4^29>BQeOnnE05=N}>b`N(Ysvq!wV7 zj57NoBgpBc~|nlGe@k5R+^O~O zF=iITiI`T6Dqm47zGO9N_E9G$`=q7CKB$SXziIO8C6oZ{RaEkYSxokQTSy9aTNny{ zSM&*rJ>!IDPPJkCa-2Aa^fm(Cv66ScDdwd6&NBsAE=CPJwj4#+$Z$YO%X)!jYzl># zEvL(ViV3n;VoK~wI1TnUO)4t&$}A@5WEYZrV;6|^GK)VuTE*P2-N_D=W0nc~BEfgV zL~WzvAC)rmoyXZCJl8@;o?8tib7C+)+q68lm{Dn#@q(i4shk}96HfQO1eBm>r8~`H z@||`O8Be=Vl&@O^%HJyJ^f`)m-Hv(w8CU{~h zsKjk?c1f$cJo6PL*;hd|_DfKRy--wOf8*rWODOTzdseF5D=5`&7m;hX3&yry3qtpv z3Ol?HMSL!psjlB8$AFNEUc<~lEF+7!wDJaThoVSU&GU&=jMEAo7bciBE6OxsQ;@w? z)MC#Rb=V6@0rodeDJ;)=D=5!$7n0_=i^sIRg`xUSg&%tm#r%EGWX~NZ&Gt$~upo*+ zZDNc#RdNT}E;ET&O*2Y$>@tg8S7jJBEJ-wCRgV4Dlw!{$W!RgT0!(2^rmLtN*IP)6 z`&vkr2U*oNkpgFE)BiALCUjZDUDkDpt!M=VeDjvU-DGtM!oPcCb%suo9Nw+)3;(4B95bXD( zejP0G#yebMc2kVfMO(BY6FM4%urSf#o+4dRK0wP32=Up7WD}WdqD& zXC@d%Ele;?msem9wdL0nY4P8@~)LZS;(ak z3HYtyXyBJ={68P9&W^qoi8Ed@qKFH`mo@o{ef8{15D!u_SZvo_4Pzt z^j^dh-*rW0rxix!Wpzenq;&>mBbG*F;kL$OVD|=N-xr5ryxaqDoesiRb{lzj<4elZ zd6exOAC^2eFR?j_Ym3Uxs*Fm@>I}+A>x@apD-FqewgzP3)`p{C_XeZiH%B6!-%%JR z_W)$0gYcQ#M&4fdk}_2uWlKlLlGkPj!*Df9R`Z@*(Jz@d_NiJ&)F;!8&MjFx6NoeQ~ zFE}t$SpdNh7y%B@0FqvECN?ucNUAtL*C4miJdj<~+G4mrDd2VH%m_-7OWYLE$D?D6q z!b1aw8do}q!0a*XDE4=~7<7nl8Fhzi8Fz;08F__e8c;%U4XGg51{JV-!|IpKk*!mA zbme$H!tJ&pWHVTjHFpZiJWmM=VOohXkyblgRFnt-MMnq^Jfe)iNpVI54`h$Q=ibv- z;6ch+<*Fri^D7)kX4)Ya$OLi|CcXE!5Hg2Uc&4?RGoPaWx=pbk~u27YfQC z9#R%aGZS4rqk2AZ@Q@&)1W4z_mmi=f3?v}ab* ztYQl(X5nr8y5JMHFv^8g8tFee9Bw(3k-;rg6v#TID=ET?q}W&rN@4`*#|D-o zpw2WxLXrK>(P57v=&&c$taAEkRypUI*Uk54HgnC%y*zh*E5|ALNbm}CRx(V<=(RtGHWQ zJqs_fp7qOYiumOg1v}HbiPq#X$(&`%aEi5~mIWM9DueG&4#yz;EXbqSD@o_{$_b4% zsU|_RqJlixLXxq?s;Cpq)6ryqq@>vUGzs%I%7FrPmXE zDdr?=qA$TH-jG=qbi}EQL3^DJN4DFK&99je8F*bwfS7F=X;KDt<4V|x6=JlIF#9N_ z#~z6ZvF~XT>@f%t_Aww6voanZuQCcDu`&1}wJ+Y2Ul!=faSAb}TC=;UCy7n*mne0S z=yyk>axJ%m#g>i8j=3@jS3xQEN>PM;k5gce!Ng!M z!%=>&(YSZMvDkOE5!kn;vG@0m;SStuaW4Er!QT%pyN-~I*UHU6X(tS~-HRT48;vF1 zFc(s+S}C^NRb_^0lbSpe7UkGmMcw-oQiC!c-|HHU@HLJ_dHcrTUc3e$-<}M(-LFL& zun@(#kn!@XSlO73^bDM8(s<*s5OP*)(Igz^IVHQbnI-Sa@{1c)3sC2k%&H|5vW#_!PkCl5!b6sq2}YfBnP%|{N8UE zUK@M(-9ZY`bBj!pE|Yxn1&gdQ-FoB_hs8*y%qp?Rsxs`CtN{BSD8L@WkYEo3Qv4_5 zQ4DB<-&sSWY`(ixugEsEj!ySRp#2|Zdj7QdIj!wd1 zj#R2$ja=lk7Ri)d-Fqyn!hY!r@I_>JFvcU;kVfNIkwzmwIRkN=2_w+l*@CY2L;;7l zz+BT~NRs<%C~^aJ3{DGw@X;X}X{#wR;l~!ISicsx%xxu(G0Q^iv#3J3=CEx5Fi4 zv%8dQ*j)=<7owOmEyF%5Yp`d^^6Pt`{(1~Vf_)3gZsUu{E+h;{?j?-IE(#Zi-xMqY zc~4J!c}3@3?IIKYhC!*8&u|>mZyavzJ^Z48L*R0QO2}!3pV_g)EOlRoV$QeJv+c8X12j7S~Ef{c435J(7XnFEv~?xY0F-@%~V0DMWF(6yF!Ix zcZH|nmjw#JaEngAv5U>R`32@&9fK0?p5ds5-#`@0c>rqZLF7<7k~7>Nlx{I=*647cF?6T8r~mtS1&p<_(O z*EAg4?;D8a_zuACJcxYChXg&>2Zg)#cLmQ35R6(FV4AHjzrLxf-t$QL^%#l(`)arf z8P!{*Y|6D_Hr-k=lV-1+MXy)TqEswtc~UHEcz9Xb?C7?<&AD}%gG>98w>CD$N~U$j z%f-}}qkwuhk%!*zMnb!j_Qb>il| zK6&d>p~P*WQRcFBQtYg6RG?9{E7!WPE#9`OFyyhLH0>%9z1?U-uNVS?7|jL=jMz}H zU>E!AR}E(bJQiC*-d06f=OtyKtM!mMv;tA)vIGVzF)~0X zg5*O7&oVYTk3IVZRB;UhnuvxWy_aK55yvvBhx`nyA(#f1uvnqd|#~oewd(kkGO0J@hd4ZqF)Z+p{{UR&kX) ztH?r@S8N~0EVzz*3oqmMML3ZQgKTJ>aSpt@q2^QL;cjOQp@)^46hcd2nKawc(xU06 ziI1Th994!$aP~kY9{UeKialpsGrI}b%xbbXx0z|qF6O!OYgtCYSBg`NCBrJtk6jk( z#p;W8VI7Tl-iY&=0A!7@b&}QFc1lbRrIP5nwSDLcYZC5%V~nJgpA{z-|kCu%C} zKL{cAevn)lbx5v^IOI15J5u~2J!x)nrev!yTk3W4nR}Pp&b~(}3qr%V8IANY9-3w= zBs8i|ONzif_4sT{g5jkMX)_I%l4Kv%l-LtF5%wQU9QK2bTN{9mTpRL6Z;iGiKO+r! zhH;i`voKfgb@G{gmRl5rj?)&5gK{-6^~!$05Njm~63r6wB=3nv7P%!)GHE9+doQNP zUh3(7*e5b}a~Ljmb?_~EbGRABHqwr08D_}03o>OLMHo}ia*IN7k@~{&uMP(bw_1=L zUNIv~wpTc+RH0gMv1{54v(`kJ=F6$EpNcxvkyyy@xF7O702BEga>X)^H6xpb`BDBt zEa~Uzy*#|kc1AKvU3B{0-Jp?<15%_6w&O|o3I>&|REjKkP?%uao+8nJK`r)EQieUz z(_sH$$gtmW_cQaj|CN0d8lHR54c9u_h;bWd#d-|!E&(8 z(M;G>;*P|4o5wxw24YB$`{=9Fcf|evZma_hWt0sYSA-cYGr5y09Iu--2=hFD__cKk zk&kU6QEz2B`C?U8nN~%1fum|PBSv-asj3KdB(n8*+}nCU4Bzx0fAT*ba(dy7cHg3m zv!LV(@qJU%`}o39YkA`iwcH{2W(kCDW@&`oW!WU0HA#hf^~eRTYO#!16=GjyHP{nD z3HBd|3VR)iX!?#jn(v39+RumJ+i(Y6ZxIKa9w|fphsmN`s6tb$_+oO~NP|)8n4?e* z(uW-_vWOc@k_o>BIi-r_xFzmNvCNp&VPAEXL$U0qBeDGVQfP4l9w&*p*>lg(cV% zMG4|i6c6T56chG9~AcF5pgJj6L;Ldk2?zaOB{XV zOd9j_CJeY6#fr9EMd#YyB6I8qW0DIT zZ>0s;6GeR+Z7_NtaVUB(ZxDtxY50vdW6;l?F68SJD&FuFo$b9AnddqT%CCI}qxCPx zpq=24Ih!F8^B7{3?AKuyIxfR8Wm|*2)z)86Bo)|yC?f19X*6~#X()O(XApupWBiFb zU)0MfSj^L&p7HjI&bMBROf?-wrI;?GQESIR2>tW12Pb&sY*q+GD^`ew&Wlh?S(jjM z#TAGtb&Bf?%BX0pbAnXrYsP1?fSCT?JD6Sptc z$=kR3B(5ukBDc+pLYJMBa%Xj;B8{S5$eyN$c57^rJ9X7a9y)qNTDDVS<=>cWxnxnk)T=7x zxuh=dvYj)2Qylt6pa@=4SWvK}$At+kz?5&`0giFx{qZ=Ue&8I@K5q`GovUM7*Y2p& zuRN~vsShn2FNk;TR)i;iDJgTIqO|c$WU-&!lu?w*5QcLnLX;dJDA>3nBE)6Scy>`; zEWfZSl3!dC#W1kEe3!=30% zG3Qy6yjk8fXOcP7m*P$~r8k8e(z@cUSocD2(9A|%9_6EO477w2FXze&FA+FF$U?CU{M!lsUonD|PIo^hZG+8SVqs!QkW}lVx*f%*H z_BtjM`xq)V1dNS;FhVDHN zcw%@5TXC%8yokP0ZhX@yKhk5ABmY|Pl!Pg?n0L&)&c{YJ7n^=vIclJ#gfI~^&5)u! zkpc_e(j}NRr^hs1QFl2a|D%mK?r}~`>nJbAZIm7DGRl#IEqKa13N5CiWgZGk#Wol% z>Rvm3xTSC`IXkt0(rwzb0(GMN!Uold#!CvZZ-Of9b%^dgpW#@@+mH+deYoaPPMqT? zGycsmLn5}|DH}PpnU#}J&lHGmGHmpL*&s5va)Cq*mBPvQ3A0KRD$+|^6yq5$s>7ZM zs<77~LhOkE7NU#D3DHB;g>N6`dp$>ak#L3?l92^pIjOnb?1YrEh=KSPgQUF$P#jP6 zC(bUgySTe8ZVSO3g1fs1cL)$1lHe|jJ6YV_Em?fg;10oqTL>ZY{pL8W``7=j>V9`q z(=}7mHT~Y}e%;gE^WNu8?5!s<`16n|Z5kA44(n$dR_Md6HmgWKhZ#{lYU;%(Vf@`x z`33r-xEmcRJ0Bk*F_V-aG?kVPds3gB;dM?z)mGz8^k~t>JLOCL>EnwTXoTh78fF+& z8Kzoo*AUN!nURqbZ7(SR*HuKzzz|9YtDrBo#>ox*?`AxPbV9sBxL=tXb33XoYOU%T z7A^xQMW|RxgliQ&Fgat)$56+!=Uyil*sbhLPbUh%ny=0dX*YcFG`Bt~ee-SOR6a{e z!Td_t-pomw9?ZDv1LIW5UMXT2lk^d%K5}e~_Tul1o5_0ztQZRbBTSe1WsuO~vXK%L z;uitdUctPo)iveOplGjxXLmyc{&rY|h~mDuvSsV!;HQ?XT}QDg)?KKS{R87)H%0kX zyFVRvoL&(l5<=)X!W_}_mM$#w4XV*PbUzROshSxov0v2P5`9&Hu2;iH(zN%7ysxK& zdOW;AvO}*Y;72P(RZzu1C*@e3{}*7cqbW!;iBGlE)Ca;SP7L{y@2e8#89ajbxoBwj zFs$@iqm+rRCo+@b+1Z;enK56~^l*N*1zddTHM*TmdE6IMgQ;ELg1erxM=-)yktXMJ zoEox094A$5hQ7O-ypS5$XR~Bbqx-vYj|IICc?lfP%K|ORCNm!m1vgbz>E_OXEhavjVos7T}FjNe^Z=Iw&OfF^o`|b z%pO?&w6<+pHjwKo3VrwDhe&flShgSMFVZn<+|5is*;ECX593!kEF!^TDvOVi0>8N` z#${@v?O&kN1*iI?8jkR3^u5YsKjfLo3WcGv{mEWwNG5VsF;rPrL(6k<#v~T-=e!|o zQs+T_sy^uVEDBCS8?X3TALZmz-r_wl7mWY(;TggGnW~qX^@&4c)dJ4>*Eom=-9VQg zEc``;h^o_4b5ZLyeqo2I%>f%DYMM6vgi^3_;@_#WU2wk5rB4yfg2+tjCH3*dQ?dYy zw6gf(4Q8-DT&cT_FNTzaKQpD>=k9#$WFVS=J}iO8piQB~>=Mp3f)IYTfYSaU;}YMx z=3}k-l~1tmbr#LB@Ko9(l_N@)(q2u0+){m%R9913vr}7rjEj=zfbAWN9LZq#u=M6Y zWH$>I+m&nUr;u|WZNnQ5?%_9Zsxwhk%KPW>Cx(AK{Rg>)x-w~Senz$nrx;PK!}^{^ zhHZ4p(Z*5eL7RFB$q|Kc?WmB6Zv0!q1v`SHu%1V~j~7=^?oFR8Dopo0x*LJf^kkln ztn{Lwg8U2@E=#ES;QHOyBhI6@c5l>r5;Cw!>lTtWm!)UPmTegnE7l3*D7qrD2!4cQ z-Mp8WN{SVkOG$n%0}kuR&M62gEJ<^Lc?bo#9#B?H{ULhara7#jq@AGt3{_Mwq4@^W zkwqH9V8`3>mxG!3nva`nS5{PhoKVHMZr(h=?!bRM&u_W{Ag=6MuIs)S7p6{}V0x~X zn5Eu2U65_p98D)C>PwG{>nk(xO^jXU`Ca2-LJhAFFCG&QZO&Jwb81s?&Cmv))9)yI zFU(*E&6OwdM)k|#0LS=ouJaZGF7?YoJ>;;)Len-34aq_MU>>#0iUW`GOk z??J!#3_!njC*>PX6XfgHnFQ+~&)Y>Z%&jmVFvD6|x?ZAixC-8^U*n?^KtnC z^FOr7?UZy#&Bw^|^^_>Hynj$7I3`Fw^^m7zwUWN9HI27iZ-XqDs!=`4DJT5%e?sF~ z$p1A1I>V1|NHg%Wjw0AP1|no;vLZePwh?lNl+AN>p^~|V5z+mhRbd+2Lld1Y70H!P zYGd+8!#PUvB3$iTSQ9gVU7k7fG{tGHE>*G7AS~GF#o@9cPvt*6Y1Pb0Ph4{XZ3UjJ~$hOp5lb zj_Y75*2n)zj{kTQ{J(bfo23f5sFN8gL8mD=qo7kVCBN-(_|N;Xq+DLB9*>POC5;@I zX_IBET_rXdmqC^4&p*k;m{XbT+66+kZ4L<;ENXCCak~mtA|{1s2o~@tj(|Zr=yE=t z8JtQZ;x?bh$P7xR;5BbC-OmRqu~$2>RXHA4upHSRmL2@l;>~y4+^n5D@x|kNJ`5Dv zZF(V8;H<(9{yJV2>!d6~{&+mrnH}S3q{K>w`Tc0L!%vkL&B?}CcfO+Y{q3?Jy} zOX!Ft#KoDty}T5oA%KGsFoB2&otXDJLQVmMOUY~5qFV~Y#3o@;jVA;QM}hHagXM>Oh05L!iLP97S8k(^1C!MI1 z&8=ocS>_u9L6UEaZ5h7$0>t+Vt?52`{CBBAE~ctt9F&l!uWQcD-xhwH_zsHwx$Qq>8+UsP0}Ta9L2hOm5*+m3 z_vZ(ji&MjWZFOY@8Htf0zHWBr5~W$ueh!9;LJsmZiiGoNIs~8bv5Jd`#6+3o!=Edt z3Ox>wTEM2qrW6>1Mf_YXdk8?(vxY#$Y!GM>03Z;p=I7tFKY#z*@#*Bh-~AQ-8%jvH z_xEo^gKzNPAOHM)`}h5qzXanWdth%A66gLMeMt1rCnC|^7CidoQJtO$F$_W~z7op# zQxaMZd;%D~C)4CA>5zM4qHk#F{ww2l zicyx6{8qKoT{1cMNA3UE!?=0&RQV4;$QL+w`El6R{B{sgd#~N~WJL;|JbrRr(P_31 z2{JZ-tWplf*3GO|#EB985vef%X@1y+2OhY(O93-m@E-Co5w^SC(_o#g)L3Hb*T-^> zKqIw(&uFrr>hU^J=?dO=gBxPQgZAr3V^fXxl3MmEvK~edtsR+Zq^Dq#c~+Ec<9C=q|-c1SZ~{EIbmL4!p}&d z5c0I}eV?+r$nr&MsT&t(+dWaRL6cS`hxC&5mwfE0kY-3TdY0sMpW3J;z9J1KmWKsV zU?x;iNYL`k1$_g3&^0ryxotb~+#6@MD1MdsH37{jA_^R}2Cce%{i>*le_&1HYm{8r zIvx!ZiT%EIwsE|qH&!WE@`#I%r4OY}yXFA!)@4{r>^3IUn-n9;8{IpcmS_DU^l0gL z>rntN<&$QgGi%9>mMA2Pu$dqiXq<17*mYK~*fXu;dyD3Us}O-YAOniP4L0zAUw*=S zzrPNI^cmgqlsT*f3~K9z1+@3ab&i?)+J`YlaL3ovHdu2v1276Nm@gfp%S5<~9w#G| zfX4hn`{$V7iuR1T!#T_AG}pf1nK|oI?h3bHzk(jn;)hs@@aGbkwY_@cWL|BG|MNkI z#Em8`+y#btaKYY!a2;0G7Y$0?CpW{n^c^M`cJdRPkP?aSTy9MyU~1B^XJc;C3>@bU z3y@Vw?v0P%#!8Wck<~C$GpGN`*y0;^Q~StD^cymAPzU|&2_II^7-NX7DLR5OqYwrt zqcF59H9YDH~rCSNV|w>z$pUCZEi$|jK}=Es7Dh;=Mlhb3~!;J=DOl<)Vr zu80SIDNJKLX`-NpR#z)LG zS9Z0hM3GjE#Fp4g+=?mh?;!wnD@m5K06)Dko%WL2+gZE zqMl!uG#sYOqcNJXJjp8BnwX7o>M|q`>Jl8oahd}FQJbrI@8k|6g-76YH9;!jpA=R7 z$98LqQCAdR`NYf}d!7dRI1S)N9F9{XZ~)hI6xoeALBCv#{$RD%+tNQ)aa`NreFh@C ztNk@sXGr~VBlMvWqwhxrmjR1KEys%m@=bO#48&tYjwyJ6r@>H5y<5GZ6dk(}^ezJO zl#Olbu99|sh{I+`wX)BHFa!~(2Wl)AScP9()uSviujBYT#XuGrSUZ8wHrvXv)Vv zzKkRMZexzPzey}!x2gzmN5M+zmGWmki%OXLU84I1OifHS|0K`qsn!!jt}KoMkt|iW zSwNTqRZwP<@<;!o0GcrGsEp6r@TDfy%PPMq4-|3VXt)p6drtS^7zfLM!?9xPuDDMA z6dnp%*gf6!X_Vnd6K+z*eBnwZtyj*1!7kkNS^BeFlh6V6_%g&^VkUQFIUCzuM7aEAC-cK5iP;N?rx$c#gY}`H-Qk- z1U#c3msg{`Ewf0x!x3mb^F<|UIQyNFUr1e7y}l_RGOxzhj2Hn0p`nF8f7bu=Tn9i& zI!fY)2E>=&|sO4HVHm+`Yrh; z6!1gZrWnI@F3^R=n5833IRN)=@rQY-NS(aDPJL$!SHroMJ!PjlN!`16DDT12=P>4^{I`&;(^0@N*u4^JGNg3vO^vR=`bBV6I zO;f)f($CyZMxyT>#$meuI{NVP77*~*7W}3K(8OSQ`)s2I_W(yBC0+&IBL&QJT8AXe z#v_BCN8#v!2J6ple_?W<*SkU6rcI+(zKz>tYrZ7iR6dN|AN$;;ukTjXJat}4_hdG{ z@T=mE={2|dq+#|}@~id7j}7zVZd7{7VUFMADhEyrq+FQV?^;2*r>qb6-fDJoxv{wq z0=Vu2Qs0GYGONi3(36Z%OZIPy=yO}hGeQ(wA0HoZub0?lR^yLHm-vp-qjFHf45(Z# z;txm`8i2shf-pAeni92}$v|J7vFQmMd(syf`p0@PFAEI*)d-EK9W{{Xj@EgzWJpo0 zOH%+=TudsJeGq#&{EhYQ_l`DPaLLh1Y zRroXyeK_k(D~+=~%sVToiFL_*PDw5bhEXmGeuXvWA+HzX=@s8c2R480&{#po)I0mZ z>zcwBF}l$Pe1{Aomgjo>fhC-P_2AD7xB4 z_Ao1PUd&o1`P)^IywQ-YFd7ipA|`LWN5z<_IEfW}a|IgGpBc}XC%!eB;?dLQ4@SDq zskHc^lc0d+r|PeU63vGaDQ+WS6){au*bU3-4MB`AP{791DU(HP<7Mo}8Mg5+RJwSX za~OALOmdDG?L1~!M`*`S z1lJvb*7{cQ_*iB)H!;li16HWO{U$|e#;jXfoUm4q%OIGP?^x_Giy>-(hj6f_ObKpu zbfM*ax)mJ$CNT~2J1Y8ZRFUdJ`^?6@2kp(6^|V((vy?@)TO}asR?S)N3uerT!4PUKuIDF`Ht;bsC|64bhjQ%Q{fz zBU`4YY#q&WlfxCy{U&gURRsv&&?*$!@<&=C0_lPmSr3Abs!nY1j($cg!BT*Kd%A%% z$oOv@z1SkQv%8hlf3D~edGj=v5|rpiRw^~54t3p+Hcde26yMm6Dx(&7ql&8bD0XLy4FL&ou zDPUaVQS-+;9piD?BQzMQyVM&CZF|e6s282 zE(X)GzV%*)s7pBH4UwW=M{%Xmfr&&tN?E&X%D792Q|{n#G+WVg?>N%GB9G+ir7-z) zdXeloNPm9Oe%`7o3aq>W8%i6uSSs&YDz;;N5=|8A*Ic|AY%4B4=}v3G7nb>Mb_nd9 zZ9>c`s`F8`{SkdfOhVPPl4`|xa$|NhYr=)7xCo5Y!dTKRe*70r=eqXH_>yxQgbr6A zmNdYW1?s*4E$j&Wbx<#_qw`wjvHp!tn&5%f<{EodC`+J}X&J?aW zGnc4ksd23F+m=7j?EQ>~T0ub!k}5Vg{UbK_bS6w9xu{~6kOvq7716&}R)vd>$72kl zV|Ki~!>>v2Mrf|S)BlNWNuL@1Ug}$P?uD}5X&;>Oqe%EDS{ITT)WV%c^)Xq>?RN1F z6>))C0p=lwGc*}9%NeGt$HEWz5`H;E_-3muEea>x6R+0rw@2&lJ7F`(VDtE%NH{Jd z%#fukzlz?Ty+O&4=XlsNPHkz!K4G13lLRT_S>Jl?jO#XSt(RVi#D@4IDSxdX?M`!Z zl=NP(Br)^_!7{wYY+$nzLqQ?On|bDB!|6s-Bjpz)94sCrF{;GsIhpLq^xQ@eM(e(( zpVLL92HgoG$|*nsPZMW&F&C67UOwwJhh?u*`?P(|PgRh0dUSWViTedxMFtg=!Qu}8xdmsxn* zk!dQK?yu4+4L&ibm%W#R3!o8m))BfWqI`8Hb?j|3>4p_JMS~Mex;&DsPRv=}ES2Ra zQ7+JSK!#t&2Qmyx5`3XUU90c(Qycu4i(MWZVeA|#POSl52Fd!jUI11`uO~7I`=Qzr zm(fC#Jx0d&Cb``c^zH{BCU+28K`j^9aH^+L7jQFyiz%+ArSnwA-r zoIV;ksiVSr=R{vw&SGgvD=2*RQ*~$b_#=z!^()i^9U+QVIZ*rn%W&BC+q2$LlJcb* z3S&F`G*h@9jX7>&kXr0Z@lFw7Nh$9&F234#=d^hg`Kp$YgG^1 z#HMGLNNy3Q)5L5dYB`T^EUIL>y>H08d;STtGB&hNuz(WsiVG9zTcdqy*`Dh-<+!j$FF(r&o(#KyXzGU=^xm51X!kImmcB% zLv#7ucma@}dmHxY6|kgEll;24FY7Qqyq+k)p;>jk)yF6jKe@g>^Wgks)Ox4Ls3A!q z(f_r8!w!vBqx(0MzdLBqYnE<(rzydTXMKIe`uUQ*zG56>b!D_RhWM)Ph3tIV6;0a2we`%3=jSu(JG8X=qRz3)v#yN(KANgl zv?jHoBzEV!-g$i@WHn7G6)TPWqkY05=7AUE{+;$#CjH0$ou;o$l2-gXEzASIN(3X` z6fYiaR||=;ibcSeB9%oQR(5i%tghBpSG;_zu2xr90&I8a zfB0-W`+WKSG46NK54szi-B0~D0q!FGV(+br+r2&;{`gvr)3J!xcZyHvm-5)F`enP4 zegBLT{tMjk4$$@z?RxrFI&bV#;^&F+4yN+IW5JAQ>SeY#+)+Fi`Cl+CRMq$>CBrBrj($ODLo*EHp2>0?&iGSx5{mlN$o-c&adr z%_js+H-`nyHa|kTnv%jVL?8ofQF|AS;NetX*-K_H>R%tE2Q6@Q6)5||`j2(efkEpZ zwhyly9^UrIq>>RFj%#C~Tb_l>B8}!SK{op=;j-kZq!?}9VYG-C^uzGRLKE~pFIBv! zHh?g+fvp{_^~xULu%!T;>3D}xi7qI62^EY?m zi^}_a(#?-bq+^ZvXxJ+fiFqLC0WG%$%d{_Ai*i^Aw+58k`hnPT zDonSx=+1jDqibo>k*C^MgGT!>N3?L5$5;5R#c7wn#bt+w#Vz?(TX%NO$Mm+Rt4vn! zLav0r(f8RwGDHMl6u!U73!)NuhndEZ9(~5eY0;-KlO<(i7fUKf(oyk@D3y^cCa2K1 z&vFHc$rLU(V)s-m)831gDMkStWoo}Ag!xXCKPl=s->Nap?Pk6#>}8MK>R^vCZ)Q(W zkv0oMB^-)su`sB`P%Q3;7QIVd&%HY^JAEQnlo7^WxoQ>hf7_r|`E!WeAEatwm<#pP zQ;qCUI)>aoRxqg+8MwoHEADEdR@e zL&kh&QFqrUk^b7N^ZlPouv58q8tccAFD`GXICjU9RfZ02)Bejml%L%wV@r}a+1U{Wjf%hR47rUE5-d2#-1%58T{sxD~%e4O9 z6eS0Gd@;pj{xyyke?{X?yu~g;^ZPksfyga)&M1_E3u^>Mz)IT4o99xrDFyhcm=KvU zsW-;DxSu%7C`(@QGKfdkf7jBlg)@}DZY*n^D zQ%E*I4?L|XODEJ0HJXQ^8OgP?*F`zW7-CI2)KDj8n`&VgI67Ybn*Ws-Gi0<_7YWg* z)mYbLM-h=`5!>@nV&xzSE1b*F)3&h|X*x)2EO^P9L~Eh^x{0LknZ`5M@0t~?vnSK6 zgK-^X41n#D1wqQgE=<&*ig*`HI8&7pqmm-Jd|i+-ItKGbg1!p>Dq;GoimEJ28g2eo zDLv6z0Rzc$7L%9xWP z!c9^o*{E`X;6<)afEg3rJ=U8I%u2<6M3yT}xONjKnnbTLrk{Qa#Kn;sA2-(-Cg6}U zHd;@mo-7b6m*>#VUQI6T1${q6V=_a=F?fIUxAm#;QgmZykRI0z9u&bOStP(2Vk1rc=a#xc#VwMZ7bA(L z-GFKnwoJ=j`YY&b2TGVRZ-B1yp04i?J0LCLgz9GFxFI}1oV&swX=Xbcp@r>kF2dcw zxN)hu)#|!`iOKF_hJ{T%YRv8Z#B}w5>>PSSIz8;{ntg(~T%(5DpqWfgHQxll3x}kk5h744KUz0U&%no=52);mA&EUAY z^1Y~$^A5I6nP#mi$k4!bmEsjHnnWJy1~4r}nbx12n#9lQH|fqTHQT(1YD05@D8P%jxu!VZU~L}+^kFj>EmlAB5ff07$; z>dqM1Xgmsk1hB?+2xiePf7A@nwHKgDJh9h*NCvUGnqg1rx2{bsH7{ux@#@X2HsIzr z8y6S0>gN@)6){N~v9Ov4$&o_T55Mv%q!x+bOLy6nD#qR;kFZJ&=k{BU3vq3mT6K}` zB^zw_P4+?4UPm<>DYVi!0AEs1pz5L!e;RbLL@K8Q5G*9z4oFZR2l@)bMB2V<7~N+l zWeJERDO6(Bf1V~aD9nQuu?gF9@arS=gx2%XRd3#ie8r1`Q}g4gm246^GUA!j%QX@;#I0>(kaCjN{9dAf-I!BhgVcx4wE#J zon|NShUb5M6YeiTB<4&qE0OhsZoEHjkE}m|Y22#}B0x;|YMM_rnnz)euWM$C5letM zj%_@MwhU4j(Q}}oWS7KiI!0kpmn6`lO66+m<2}IWxUV;n>BN%8IyuRpoRFiyhUUby zQG4iT7iC_&1Xm9{#ukXnUKB8!UcglrHU!~nYZ?tAPTxwoQ(<7=47cm4p! zFn=`FL!8K$G_PL$>l-Bfb7?{NNGHdA;!8sgt}KHY7cjzd41`^4PRAl(q#lG(r(l~= z^;v;Uy&yp+nTeuWQ_OF4c!c8Y803|aY}_Ca(FkK+pVW^^h8T&y*c0O z3zO`*E`hCqL)$G#F8`Uwmeq+TCzEC}Q+Z6Z`Mxgc>u>);Z3X8et0Bj)0@a~k(h=(a zBpoPXtH!u0AhIjwhG0rLF;;AROo{tghO~QCO$(Z{8_XD+O(>iW!*uA(L(ogR0RpPs zm_OIbfhMY}gESfbH}MWT{Y<7z+jPBsd)f)v`I78L=9dUv;nqxf)={sitV=60PU=7t zg!!Ihus}q2rJABOMU}ze3$?N^+f=@XbgIoi%xE0?d;ofdVUX_w?&tL$R_hiz3(fLb z^MOr{vHr#VL5^GPioi~_H*4op;A&xz z74ego2*mld#E;NyGWboL62`XC)LBlkW!)MT_Bf)8^ULsqa3R4b(tJa{H=3XA7%(N? zy{Foq!K$j2V;-<*98ZfU$)+-(Ko3r%Sdq(z~S9sCn)@-d})?swN(5CB2G0Q;m zgjWK#adrC+V#A3^Lj7!+tOGnCvuTf@Qk@S8Vak*qG}`tW zh?teY3xVE9%`?v60Kl(rHamXqT&;guar$ZHN&01lcf-mvb9+U(b*DOsJbX!s1bJ3Q zH1D^7Yue`uVeyAoAr~;*@Mx*vLRhIq66d&o|;vJ~3YMQ>rHaG-CZV!@4n&l}x@>Yp)NR;_#Ly8$4bBEgNe?B)AuoR-r$^W( zq}1lXK)3-#kAQ&8APSsQ%7oN<%JT5K4NPk3{+9@sTy}%2bD=h?BH37W{Un;(|EaS8mPEw9YTxI!BvUK}vz{=wzCtbSrf4rUt%m2WR#-h-O#)4XA^8upjB>|E3 zl5*kCX?mrY@c3L?)z_aL(%UE7ZbAW>SotU2$N#;ljTHm2X}2@k#eK4q6`gKl;hRZ+ zAM(Mb6o&Q2bew)+XS+Ze<$!K0dy_4)fKD4LlgFkNpS8Xoca`nV%D}6b`W16)n#P0K z1)w$k)y{XSYhZCe6n(#Zs8v~e(}JPsS@m?s;@g5#bv(cN)n3#0z4YiU@1a*@h){GO zL-@qn@K0TdKSV2>YtFxqetAS$ez;2c-LB;m(N#y`GQAJ-6m5o+-xIsyHK(uMFu7he zWsqNsxUO6;If%M7YY49nnx8uj`t{QHz52b~>b{sai0=Nn>KY63hd;I1@mslf5KGP9 zk!bWEDCLMyhsY$9=%hpC25-JPtwf!0@uj&K&Uq9=tCViP~nrgH}e zpkYA~nX`(A!Fxpoc@_5-8emM_R=!x2aul&COW*NU5U|bc{R0SjM>&D_;pia({zS_! z(PN|TjE-C7M?Y_)UabK#lu#3>SzL&SFiOS7ZSpmJff~N^j#y2>#Od*Z@!Yogbf7hi zw*XtIKVhS~0!fQpzi_B7m6V$Ob_aIXv&CDeMfS3*+KA2pLfoebW%4H0+J5Q_g%K|i z2psqiJW)wTs`4!f%V7vw$x?IdVJ^#}Tg#eKKLMGBkrZ$|(C|BL81y8w4~rlmfj(-Z|~LUCzvSZp|#aP7wF}=871|BwYUuj`--0BDFp|zH9Y( zmxbG0@eqUj=#f;NerQH$*7pjPu^7%NSq4p}TFm2jRMi~KW1LJBmvm!fYgCQS6J{c` z+l&^74lYDeDPPA4;93#b=Sk8H^M#1LwSDq99sc9FG7NG1G)&1$2D)S9OT6$4-aeT+ zQJB0{mmMG^`urSAr{|70QVNURh(djn6r#6W0m~a^Gm4!mFeKsf&}%o`L|E$vhelQs zuaQPhZ4-mJ0#u6)?~{3JFY)Az?+W3jtn=(G>igOyWTP}<+{waD#?VePV~$sCk-a+E zv+juScf^Yf&uDy{|AJED77q0|f{kJF+XL5x-A#n?1DHAo-#oRRox?gEZ zrR@|izC>GLRH`Nhou56$Wep8?xXKPC zsaZciLtCJ&fWtSW`tACK`tg1>s{~CD=4LrOu^nt@SeZ7)Ho=gl7AMP=fEHy5+{fDA z_My3VRS;2(dQ6NzRZ&o1BP#nPts0g}@sfF>oA92sV8AVhKv?#wZ(f6DKgOCG#P_gB z7+;#p#yW0I4W|rq5p;OU&xZrVx+J0qJ+j7aO+eI%=45VS=2n~WS&;*J8(Db@f@-=d zF^q$B6k$gW;(JARlsjItWphFozX;OSzqB5>u0K)8$S7q{@E>aoNrAwsXk9zR;hrp6 zXdX*Q44cAyl5j6WZL~g2aUzs4`WS*5n>5y?k&}2@w#Ei`s|C0?orzg#?3ARzJxNih zm&7NP4{@{+J1<%Lis|M+81VTKRk8gKq4?LKIMqI|jO?oGPeQZ6qbQc0R6TWZit^motrZ0xwUwmv zGSRoNX&Y?n)r7uM9D@~fV;1hS<+2Ocr9Johh23~lXfH9R@0>Pr-Ki4kqn}8BQv~(J z!(v;DN8FQVPbcBuejv9r2tMSE)99Vv_r3P*sn8f1YU2gs=LA@ZnXa<#d#g=EDOAxW z;}?0e%1dq;ONUjZCpz(M@lDCi0BdV-EG{9o~4cBeM^T_fvZ5_ZPI+297bO}bkW_i7hSqTl4Tr5l^?lxfa{*TZw>SeRexKHQ37FVvj>f}HyX4n3}g{(Es2cNZX2G=3) z7Gw5YOYu{xF(vtn$uEoitZRNRB-p0LR>$^`l1uaqzDuktwuVZRj1k)5D#R`~W7C*- z^GYJk>>c{z&PLN>RkmG3JbTo*q($Yy zUwsCAaw9>#)3vtR7}|J#WZrHq%A`iHCRMaO)n6KaC#asJR>a`9{rG zu8mk76F!}JBFlU#O-nT|U7}9PUv{py3V2L|i*(Yjt7Sb#4bxnzjX%ID5|8PYUwjRR ze$#RRehsS%27kLA3EDsKs;b$Bij!?&Sue-TYSov(w{_xKe-`(BRmJr-2*sTfm)Z>V$&7Fqd%C7d0J{pU~>ZriD9 ztKF1YOq;rf!*vNQEIt8=!I7Fm21=%}S`=2rq;@t|A**?j1Bj?IJ_Q2im-q_rP82iu ziqe%A?6RFUYW!wo%CRmq^`^?CPlT{eNc2gD)i(L)|95tnzW_`g_2cl+)pn09net@ zLUdwiKc0^FvXtqD*V`xGMKF;39&JdDMuPJ68-7Lb--rPms$C+_ZvNFPk@1gK%EYhf z_;SKSZSF8EbH_+uYmyuF0L&bQ0;29>3X;YtUD#+{^Lk}ADJ5fO0Bl^G)7cSiU}H=T zs6kG?ViC1?CM{8j3}C8Xm$h08J-5*WETG`8yH|nd!VAQ+EyezUn3mK%T*1fN?IiOM zO;_4d@>qBTlqHE^;oEkkh-VFvAH&C~(-mmkBnf>Gld{XURZQvN4agY1U48P)kFrA8 z`{%c&7#D)XlUZ!6B-N;C4%N5xRMR z>~o=u$Agf2w>aEp^2wXI5-TLVGsXM_6;` zKDA5sf+f_nFYMBFe`;vLkz4sgE{yE0O^e+>qlT@(jC>%G7}|2I9D1Gv-Yis)O@uG2 zC+MX5^8%2rR~6rDo=v6Zu3*5T9##L=ZD6xvq+aV+zj<{(H|FK87N9@S5P5Bt-5eTM z$vs<~!-IW0;6dljuFO46hi0aAJ>@{uOM~W4-W<+N69hIKL}`R$$CLo|TS9RaOYg<^ zSkA#iiXBbw*nDdlWv9i_Xuv%4uE= zI#!0SWr!t-U1EL%bKx2rv2l&s1*wufNkI$#;Mfk^+F(hAMpJ<2Za4Gjb_;k%`WuM{ zHmg6@rwl*;oa+gdUG>*MyXebr?eoIXjksM6U&iz%ROP=Fi6G7E#;&LtFoRYe;h}Rf zzNS*V3tECsfwn=qe0vf>>O^1VNE)`mnze5jIT99AydPWx6L`N}V~h`Op|1zNIsNxK z#eA<3fIhHQ|FOS#s^5aEuYb&~-Gvqo>jitr2&TEj+RlmF$kSRN0#f;|@=s^a;q;N9?eycZB-Y1tiIDH0f4v zVHQO9OU3z$I={Z&MXp#C3iMeV3I}#u_yqQ^JTJYRew-!x@A-{Mx%An)=3TFYHJOrQ zkR1_}SenG~R%{rfyap7l(?Uv@0z5?h8j7RW`f088S@xG)v(SDu7>buTq0PO+Eg(Ur z$f^{_l>+5^b%}Q(!1lCySWpYzEky^47l_Uw(VJD0>W=d z*_3+!g4gHPKX*QChZXbVHE~{Zzr*nbm+;R%b>)vu#PH_A4)jZ=6|{a;-r?(ogcge< zu7Eh%30AZe)u8d&T23E- zH~pKru94#n{T>;M$=~ft19BEf?vB}hMe|_PA*XQb!;H9|DM6>Ab z@MfQB!^XOjdSc7aKIntnzh28CH~OAmn^wB6>^whT=NMmxxb`;Xb)Dzz_$`U_d7xYM zyjH9U1wY6DMi^}F2ZP>`ExcO%C1(uvE)3|ftS)B#;p+S-e}(MPlV~+jXCJMcTJWy- z7JETHceK)HHee|l{bRc7CU#{zo6i{CByz(zCA#B^63b(=k!Y{OfN7?YWq3!9@v=B- z=nK2|6HxPCiqG8#`}qz3!bv}kD1+#1u%7MyHBOVjW^!6m^VkL7ZQOy$_&wpu-h1Ck zBGLIi;L*n48Lo$WRQsX}7Axb;`{D|lj{ICgT!CIhiPH4o9=R>@wQNP&BB}@bkguz7 zFbLWZw}lwQYarR5ucZDa#s#+hQq-0rpni2Y0U?NI^+Ag;T|{>t&FCwl(opwx!gW=_ za8!XY@d*+W0nCXYkmXn*StPk4AW{@D1wII)X2?6_4?}8C+yJDCB*2dzo3u|`sp5P! zV{UuiRUmS(8}hCQ4c7eAZofI`iM{Ui=;#mlov72_7~(n&kSBJufU6z_1VQc?i`L1( zg;Aq~$!LR@IF9D2!v^wp*QbMesTqd%R^R~F>0GhHRfIT^*7FBw?_NfsW87cj>BcjQ z%G%9tprvd!0+?PABkn(-@qGr^?^DC*>DAAfIqzQDgyC=G`W$H){_Hc}98m5#7_S86 zbT?vl4EPu=+>f)e;A|VGz7%9T zaK$+fbk;YHxO}C8?ZXaD9uSf~0UFKAaecl?9CydIuS!#+uQe1%t_+|?^VP%2-@^-uB5AqH3J#+Kbc-o*ane4Ac%LufBY!D;u7U9wDv;m6A zK!m@NF4Y8O+=mmGZ5dKLcG(b?o~<)zMFaRmZZ&r-2fflx~M zho*EAP)zA0V3ShHzaFKQeltR3?x8T{#A`+MtSe2u5Z7Ta|(=NwY zR;xo3BcA?Hq%g8u0mFyQ#f_V<7(KL5GK>rq!^t=;s*GcT%QvoM-j*(~PKGz&N_;&56-c5565a_KKyE5zal%>%(Mq{ca*&HD>!{drPHdQORtp$s z6oYv#+-`(KpcQEe&%YZRK>Jv5(4aS>L#SP< zhYxb2A4=UckfN%2ma>**n!pffb^N(B#q zMmj(I?dT-!gW(xM&x8fkJJ$=Vb0I)Qym`nlVhwbI2{Q_zD#9#3yx4jKQDf@xbjOn4 z>JOng5l3{+Mm*VKmB6x5i7@+&FyNon3H|{g!ay4CK%7?_^mzF$Cu!g1B>m%n69ixb zn;-zQXqNsV(KPkrv5CqzgL5@61qD~T*9x$6sGcR@K768J(*Vli%rgTFu}ls$xFSV$ zP$iP~U;;cPGE}#^WG79;ltea;x5Tow&&Q>Ry&9Jz@>pb+u#441F}KRO0uDq47i=9oWt?63 zv>~>MK?m35h8|Um9)vUrk|rU-JB7l-wu+^u%QehRt_X~zdgh`KX&wr_3w_>$F0Yno z@w)2WN)daP@|EucO4zRZn1jHS68ywEo zHE`&Iu8Uz1Iwyxi=7t;=iQ9pAdExXO!TMtNp9WZ`ejHSV2BwMf z91NkU(y#=i#KbG~$9R3{9&e7CK>)TP;)VfTR+i@OlA zNZ^TZ#hOO~73toM!r*tCGy^Y^77V$2g<|ejz0%z&Q@abrA@4F(*t}X zYSrDXSKIZvY!`H0HVwEg+Xh>=jRUO`+C0$efbD}VG;AVlDZo~O78tgxRfX+pF=4w| zMcOWwkG6Z2!|htlaJyA2-Y!)v+>KI|yHBEYm&wB2MXJoZLy3I%r?2no$obtHw7zQt zws&i~?ru$&+}(n$T@SWy+6P)U?E|c<1_D+IX(D8$Nh=|%g0!2}K-$d$AnjsdNxN82 z((cufw0jjq?OMsI-KrXEm&yg(l`_$Gp<3Op6DizX0-d`@n00rE5%2y0=3O1Syo-~T zcWuV*ZVg!7tpR(xTQIeob>?$rlo_v!+(d-Z_Xy%NCeUTrbER#DEb zm6EetHRSA4&7xf@9B5Z6M%s-^p?04{tlg&4wyUJ!?H*~$T_H!h>mz7)b?oXc&RX5I zDXY6RVsf`8tnF^W)NYOYa}(qK+?=>TH?0@wX7ocVNCF5O2rZ=mZp4he+cfZ`*7`C>pHOX)X({UI;H z+^L~0cWP+Kr4ssatAc1;s|hIX70~L<`uT9Td@kIs9?c8JBYDMk7BAUO;Wg9wyJW<{VMpb>3;uqH&ArfouF8aN=s zG;%(F9@O#Rd4Q)A*8v|+S_gnOVH^~0uQ*WDHf`{@&<2&`ETFlzOorzI;-$Pk5*F_x z$KxRK0h%(Z4$zcQL0~}{6&MT5sF+l6Hr1m7G^rRAq(zaSVC^XtO`DSkG;mB8(8wum zqNzigoZ!xgaRR*|!U_5&^i2ThQzt>84x0ptH)9e!Zn4f#YMIVxKH*H~IRa>S>!tLm zy#A6EALPdC5b`pje35X#nwJO%uyJL8K-*RWX56;2DB!kb#LU}Pjs)PeRwN*YB_aVj ztI`DSs5m5$7v+fn-xFp6e@u}H2qGmWU|1BGfZ~L_1RQzZ5}3RpOCWQ5l|bi734q>1 z0EctHgt+F)>o-~PL~J|{AYY@&r`U42ys4GI1rDtPFL7pPzQ~z*0V8*o+_2smgbj2s!1@3P>f(2mk7g18u5v_oG>Sbf)3i43pHC~ zI=nQ7^d2#$<79b#3uVP0sqsI2JdPyq;>y1UKzEza&0QySavnUXgF6$&F3w97ySOA# z?%;Mr!FzKNWv^{Sls>i7D1c`kq!5x_ka7sdIc1TnlFB2NV--rNLo1m~e^)w-?66?? zVawv-226{oHcCtDL0V$Jp|WD_5*jaL_GOPejVXWP%cnB1cZ1~Vc_1V&PXZ!(d0&d? zgC7W1@OFX3ln`BVR zb0RAA2IW+TEQ*M&m6Q_~NpZ0a6dG@2cCC*hZ*e?X>q5)J4)CNQ-1q~$IQ#(9qwBOLhSM@z5dY2V)1}9*G@?d8x9Cbsu;<#$}kDZ0q!KnTGj;b1V{wg|0;* z8mtD1h&t^VIW3YyBBJL^$%z`RB`;1|a$}t&vulG8xkk3hH8ifgO%di<7Z``bWl>0Y zTNDz0@Pd#K4Gh9UF)#=Vo4_a}oP}{X7$Zg^V2K!weXBAc^+;({+HI(jDd%~ELYpRz z&$CG#8^Ri8KWG)snnwBgXho_EQUXYAh>M%9A~v*5MrQXRLS!2sOuoTE<(n5>t|2kN zHm4oD1A1c_4ND-wcnE?CMMMftC?b+Mp_u68#3G`P6O4v4P&^WzKp`2}s=~6bbcJSK zhZdc6pDr%Ial+W3Rw?5HnPUypuEQIwRDe22EZxbj2x{v=0|)GftTrmhkb!_K8Ao=? zI5oD6vtrCOuV&sMz594xR!Kw>S|t-j5CN%Zfe1)N0z^PAMiBw22tI@}fOl9m*vG>ih{8o2!4xxb1XD~!5l}A|F+jCsJOI^_aSEy> zqYzX~#T%)ch%8hq6vD242BNlR==&g|L!JiMQ994q5N??zR?#F;z=kcZIDs|j!o|~` z3>!poHD-6paunHG?QrtY4lM83`0|d6GV7FJU?0>w5QnP`(!3QCM;N7`C;|x1MGZi3 zHktv!*=U87v#|(ig~c2oJQH1%f*O(tp+Runhyl>uVZitO>D3 z6D+g}Rm_0uYoWtvE=CZZvl>Ziux?D*=th@&XpotQg#rJ-axjqTeL{w-6~erUBZOX1 z6v4yi;szc*8yN`wY{U}!*?1%rlyQfL42dvAIT)JwAUfDe3gWL6#OYqB>8c$l>T4W@ z)e>r!t}4bJVPR|?mbek+m!iiJU5g?;Xfd4BXwkSbQjIYG#4s}uiVF^My$6A~2Z3Jg zP~}A&LBoc`5j1R26hXs=#SS)XXna7!hQ=)!HZ&&5(1DSM1`mlcHjEyk;1FU^L@4Ut zsVM87=;*6m1`s9UI!#rqVWz$ygKVwAl?W?41y~{olAVeqK58zc*l@+zvXYE454C9E zp%NNAw0al%aJ538S6eiBNw^@2IKl-{#St!uE_!g`17idiJ~Vdu9Bo|kNxInMGejbd z3#W!EKvxH0Gq?h#ZWzHg+QG!#22mAq7D!*PX@;OtCYh?EOR?1ll^|@9Bs>#Me%Mr4 zvGH;N=B5`0oRlJhk4AWKQRC(dlZ~!V`9o#3uHZ`cOjHU8|P>av&zyQUW}&GMXY5qiDV=4$+K7)UjEj5C$iT!4ni#{E6s%!FOsw2Hpoy8*>>n=xFQg z(Btg#gOIPsQY2P}sZW;pT(3-ki~WKlcA|i-mI(*6Q?M6komNTEDuKA#BMnzeq&5{U*_o=B`>i2~7xn$z?GwY0GffUiz#>05xk3z4g~?Bqi;$cMGC60lW^BG-&up(+H_%3% zUMM3rNB^%pxhnP>Cds!XJ`0 z25(H_0CZ8g!_S1}?4AhBA9tslL*_&&7;(eksRWGz=o2tX4ppueKWu3V3?XL7ZuI~j zwpIsl##k$;*-HIjO9kR;ojP2tl7*{9s=V8##Oq@4M!p!lkTMAGV+}$M9&Zpn;FyE( zi=*t|6USM>A&x!(cRT{|lL0w|Z-r!$yjV&kcB__4m+;+SY?AlF5y~BkM=5l#kyYYQC$X@7 zu;g-vK@$uZX9Y4@k{1quLe#)OQeUV9LUbQ6=vf>6AV*B})?{(G+o{jHZOXjcrpLRD zBD_8PuHp~Bn>fVp9wPAz;ZRAR1&2-e%osx9(_%PfugPH*y&;EJ@MICQ*wIMbLf1mE zi(CmtFmJ7nW7a}M24L2K(f}||Oa-(oIUSf<^qhbx5CaB9cpE$bdgD-nj@gE5!v%V4 zsw&*=Q{~-0G2Sgz;61W;4Uz2K!X|rnaEcxYgI4l17|e3_z#x~qFoR$0rVNIuLozsq z?gwF+xg3Q9#K|x;;7tW00&F7`6-ZNMWT0&H@qsW5k`iobR#s5;n5ltNVTCkEd=^Lu zlDlwu(k|Saug+U@m3eETBJWlT@ougBZWO#LIOXmJX1Tk6U+hZw3q!{NAQ`zcfCR`5 z0GL1=7C;5!m;f#SHzcruHy(lyxY-bdfXxNr8MPCI6QH>sQcxBm@q#c8j2duNcEMPN(rvaN6ybjo=;CZm7BrlDckh~Alc<6*CwYJ3ByWz{*&Vt3Dc z*j=+6cehLi-W^+!cf&^PT`$zWyA=v}tvm;B6y@P%LQK3yevB7ruJP)iDc&0}!)wzO zcx$@!ZVi{+t(!pY(ov9hfMoJ}A3zBADGb63jw)yJi`xzii0vSdG^;tHHWz+o=1dRl0QAp{uvg1-#}Seg%)O0OteH z;jyHC6BI(OlhBU)6g1;L1);c66I!nn5b34*$#AWFDBP@`%Dctmc)NHI@7Ipt71Qav zWIA{EEJyCD;jrDdo3a~c^L6WLuCCtp=>lqxuHh4I<2fTxJ_8g!01m$;^`V&fA~dkv ztEMFviwVfhTDozymRQ^^rPteq6ykmzakybdo|mjB^O_M!-ZUY`yB1`4-GBgZ+>hR+ z>zTWEI&60jhwK8{aNWWhs=KINx{?ds%yl%d@C9^uFR4GJ#3PaMK72HAzplVsuq!Ax z%u2`=tGZFb7}bgr%BEgPIExBFLK+kY64sn7jNsOkIYQjhqzH3Kj3U?_8H$is^cO;( z5MBs@Kye@x@}z;Vhy(TkWX;tFm)fQ@6&6Z^;Tr@ztPPX;S4un+86RZ!btu^&gEO!Q zHb4UlvjG~|6AjY7erTZPMI!^YE)*HKZM9+mrv(B7IjjvB)LD66fJcRSfnHSP1$|G7 z7X&U1T0n@zXF(znodu6SY!+1Nj9IW*gH^$&mZ^dc16e>i4w==rT1@_r1~Ww8y|jiok_7be=ko);(sep{Rj z1Y%V-P*~;I0AiG31C3064M-Z#zF?A(~k(={i%v>A=0Mx||P=-zp3YogNA7t#{W&nWqb^?Gswp9T7)JOpc zJktOGAz3FGL@-P;j9ZXpAgvMusDug>U^6LifX|>e0yyZ56#!yeWx(n~1;D;DCdD^c zWW04EyOs!%wLGA_j4mH*=I>6i^8=2D|&k~tnlTXu<~ce zYDJKa^ok*zhAW9}o>&^uGP6jINou+DTHHe6YLLr?r@by6jO4zQ`kaAjwaE%o;-fGv z*6~7Pn<=tulMuNEh?8q@X!#WZyj2b1ummD|h9Df-E35zsU*P~s_zIyU=_~w^#BXp$ zlEA5J~;~p%t*Ek`vO_3(w%wE}MhM2V_7I+85%^}Db0uMk&3uXZq3+NHZ7^O!b zBaj}0iZpr@BBJn-SlH@=QLyv}q+f?1m3p5-GV3;nbi!2@0Znr}5*l`xq!bHq39Hkc z6B0yiP+I7ONx7kon$lw+DMq$Y(qx<8i$cVXdpt?sIl0XqK4z52pf`zA#hj@hUVD(1Kr`7M?n??-Q{d(8{`iVWr;yn zxde&CM&%t@VFagy2G1Ci*)5fmA`3Nfa?TMe>-gw$j*T+wq(Jje>K!PKiJPD4=`76dcM$k5kGBlHgQ1_(IG z9I9xGvanx-JyKBZ0f~_$M?{B@*%2W%S4oykl%&c#M7q4g1AupAB=8T3`-Biz8}xZ8 zIS^4j&_K9hg@->2 zEkNXH!bp{)oFPK3(T0gI!yGQG`gq(}qWj?k2u;Wl8?7QyJ}R>1o*)3sqr-rKTBJGX z^&T{OwL_h^a)Z=O%MDaDFEwDz#B6{SGcyY-X66xAP0SsvoRu$HJ0(kqxbVpOx?vH- z2oHuGN`MHwa3Xb&Q-v#CWs4GQk1$w_9nzSA^@l^2sZMst1T`N_Zn%P2SxHEkfqW!z zP>l*6N^u`*z1krUS6jq+D>o>$V5va}&r1zVJ2N3Lt)R)pw1Q?5Q%_7Drk|EFPBBcL zNaXEavgIp1VEb#>ntw0(+obGTO5n_{#g5%|5 ziFexIr??lxU#z)_CS zamIM!2NxlWBT#xVpb)*;$dVJLW6bs{hZ!x^3^dv&6(DGrUauCZ^lFPR+^v%3t=u33 zspSS4NG~V{2#$#1J1+5PAUN z!VlDR1|9`f9B`JdJxBhdsS_h;n_90n ziS%lXKJRvk@=|he;k0st3#gVFTu8mN;4>A|01K*_R4lM!7O~)Jx#JUdGR6fGkSH*m zXnfVMA`z9t3&IZ`X8dUw#gW$;Vm1y_RR|iS2qa#IB%nz7slZ|+_d?9h*$f13#9~BX z!^J{_Z54^DeOkTRCDFS*^1Rz7$veqGXDcNKovxJ|bc(RdpaSfr844pVuTY4+L_(oN z2!W>P*SQWMwu(UUVfg-xt34qvQH`pnACElsA%ZEv5Ed}$p>|Lt zmrBBq+UeB^BfU0P8*WWhg-q0>vqa zVHB?*PEo{ictlZ4p^wEbgE$($1j29>O^444t-o+z`Bq%O9Bq0&XAqj_9 z3_UpHLWt(@3!wpqV+OMrmJw`nI6&YK!~y_$BqHEfL($A$3&sWVN;o=T*BS{y90*DY z+B9HRa7KYM129QX53&+5h!6$H;U?XMULm*8+f%0D;%s$Zn<~p&W5sxDum&$86vBI0 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∓?%x^TN?_FZKSuhhu+@aP43(3o-Oa& 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%0AE=DC~g|yZ>Zgu^(;@WLCB}vli!D^#P>6UaA^7x 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νwts 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%0A+E-;3RfuKEZG{Y3Fif+U0;g7cmOu$%PCty!uE4 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�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

+H^Nif=Ot)Z0UIHH-RC?0 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 z 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