553 Commits
Author SHA1 Message Date
nils 6ec18900fe update workspace view data hook.
ci / docker (backend, backend/Dockerfile, backend) (push) Failing after 14s
ci / docker (frontend, frontend/Dockerfile, frontend) (push) Failing after 13s
2025-12-14 03:23:12 +01:00
nils b6857bd0b3 feat: optimize frontend document panel rendering and folder creation. 2025-12-12 03:47:50 +01:00
nils 6ba8084caa feat: Refactor DocumentsPanel to use a shared application shell context for state management 2025-12-12 02:57:10 +01:00
nils 34e6c3037f feat: Add document update functionality to frontend 2025-12-12 02:11:24 +01:00
nils b10404f0f3 feat: add frontend folder creation functionality. 2025-12-12 01:51:33 +01:00
nils d281364aef feat: refactor document filtering logic to useWorkspaceViewData. 2025-12-12 00:51:26 +01:00
nils b2a355312f feat: update frontend data management and types. 2025-12-11 21:27:09 +01:00
nils e8636ce7da feat: Replaced document preview with a new fullscreen viewer 2025-12-11 00:44:11 +01:00
nils f6bd6e2feb fix 2025-12-10 14:38:34 +01:00
nils 8cc5ed854a fix 2025-12-10 03:57:43 +01:00
nils c8cc4b4b7c feat: adjust frontend document navigation and opening contexts. 2025-12-10 03:56:22 +01:00
nils ae002b99f0 feat: refactor frontend sidepanel to detailPanel naming. 2025-12-10 03:56:17 +01:00
nils 9d13365348 feat: update frontend components for integration. 2025-12-10 03:55:44 +01:00
nils e367006a6c fix desktop opendetail panel on enter 2025-12-10 03:54:00 +01:00
nils d15d5388fa cleanup bulk actions 2025-12-10 03:06:14 +01:00
nils b9f0278329 feat: Centralize document preview logic into useDocumentPreview and add correspondentLookupByName to useCorrespondents 2025-12-10 02:33:22 +01:00
nils ed8450c0e2 refactor: streamline hook integration and context object creation in useDocumentsWorkspace by directly using hook return values and consolidating related state. 2025-12-10 02:07:44 +01:00
nils f0b58317b9 refactor: restructure useAppShell context into nested objects 2025-12-10 01:48:46 +01:00
nils 82dcd6d33c feat: enhance frontend tag operations with UI notifications. 2025-12-10 00:01:33 +01:00
nils 1d8d55144f feat: Implement asynchronous tag operations with user feedback 2025-12-09 23:13:18 +01:00
nils 3ccc1f6698 feat: Refactor tag and correspondent management to use canonical types and dedicated managers. 2025-12-09 18:56:11 +01:00
nils ce821c1817 feat: update frontend document management components. 2025-12-09 13:49:07 +01:00
nils 8a16293f0e feat: refine frontend folder management and display by merging folder tree data. 2025-12-09 12:37:27 +01:00
nils 463046c970 layout fixes 2025-12-09 12:04:44 +01:00
nils 116e839f67 less useMemo 2025-12-09 02:04:39 +01:00
nils 24950e1a12 simplify frontend document and folder memoization. 2025-12-09 02:03:46 +01:00
nils 1d127e2122 feat: Add TagRemovalZone component and refactor drag-drop for tag management 2025-12-09 01:30:31 +01:00
nils aaa4a9b577 feat: Prevent tag transfers on folders 2025-12-09 01:10:20 +01:00
nils ebc7ce67a1 feat: Refactor tag interaction handling to use unified handlers 2025-12-09 01:00:54 +01:00
nils 4f28230243 reverse toast order 2025-12-09 00:24:59 +01:00
nils a8c133bbf9 feat: refactor frontend document tag display by removing EntryTags. 2025-12-09 00:21:15 +01:00
nils f3292211a8 feat: remove unused canvas focus request from tag interactions. 2025-12-09 00:17:16 +01:00
nils 99fa12a1be feat: refactor frontend tag interaction handlers 2025-12-09 00:03:55 +01:00
nils c38d88c209 feat: Implement tag drag-and-drop functionality 2025-12-08 23:49:50 +01:00
nils 757913cf00 feat: Implement new drag-and-drop system for documents and tags, including workspace-level handling and improved visual feedback. 2025-12-08 23:30:18 +01:00
nils a850f70e24 feat: refactor folder refresh with toast notifications and console logging for toasts. 2025-12-08 19:20:36 +01:00
nils dbff980792 fix 2025-12-08 16:31:59 +01:00
nils 04a9316c8e npm 2025-12-08 16:27:20 +01:00
nils 3a0ef7867e feat: Add document move and tag mutations, refine document upload logic 2025-12-08 16:27:13 +01:00
nils 5632d2252a feat: refactor DesktopWorkspace to use new contexts for asset and tag interactions 2025-12-08 12:40:25 +01:00
nils 73468bbb02 refactor(documents): encapsulate panel logic and state into dedicated React contexts 2025-12-08 12:31:30 +01:00
nils 44cf4603fe fix tag dragging 2025-12-08 12:30:44 +01:00
nils 608239e412 feat: Refactor frontend authentication and tenant switching to use global state 2025-12-08 01:19:52 +01:00
nils 5840c9d1d0 feat: Refactor frontend hooks to use useNavigate and useAppDispatch directly 2025-12-08 00:05:06 +01:00
nils d46db2c701 refactor: centralize API error notifications with a new useNotifyApiError hook 2025-12-07 23:55:47 +01:00
nils 595c170c00 refactor: centralize frontend status message handling with useStatusToast 2025-12-07 23:46:10 +01:00
nils b29e856225 feat: refactor frontend document sorting preferences and sidebar utilities. 2025-12-07 22:55:26 +01:00
nils 53664f2421 refactor: Replace useWorkspaceBreadcrumbs hook with resolveBreadcrumbs utility 2025-12-07 22:10:34 +01:00
nils d62f462129 fix 2025-12-07 20:45:01 +01:00
nils 2d5cc1fd7c fix 2025-12-07 01:35:21 +01:00
nils 632665b0ed simplify shit 2025-12-07 01:31:34 +01:00
nils 47599aba4a feat: Refactor folder tree management to use FoldersManager and remove onToggle functionality 2025-12-07 01:12:28 +01:00
nils bd1cdc0817 feat: Refactor sidebar props to simplify data flow 2025-12-06 23:04:49 +01:00
nils 54d72138d2 feat: Add an Ollama-based PDF OCR script and refactor frontend folder tree management with a new FoldersManager and useSyncExternalStore. 2025-12-06 22:40:11 +01:00
nils c88c3c13d6 feat: Introduce FoldersManager and useFolderData to manage and display folder contents, including documents and subfolders. 2025-12-06 22:06:53 +01:00
nils 9b79115170 drop useMemo 2025-12-05 01:58:19 +01:00
nils 66a0be42bd cleanup 2025-12-05 00:41:00 +01:00
nils b8a23c7ec4 cleanup 2025-12-05 00:32:45 +01:00
nils 65ad797378 refine viewer detail panel logic and styling. 2025-12-05 00:14:25 +01:00
nils 36c8e4cbaa cleanup 2025-12-04 23:55:04 +01:00
nils 86b9ac2ab6 feat: Add document download link component, refactor download URL resolution with expiration checks 2025-12-04 23:38:17 +01:00
nils 22e39420bb feat: update frontend for document handling and styling. 2025-12-04 23:26:22 +01:00
nils 2389c6f4bc feat: update document workspace and taxonomy data hooks. 2025-12-04 23:10:12 +01:00
nils 8aa1872ea5 refactor: Reorganize frontend by moving UI components, hooks, and utilities to new components, logic, features, and lib directories 2025-12-04 23:05:35 +01:00
nils 128714a1e0 improve Content-Disposition header generation for non-ASCII characters. 2025-12-04 22:21:49 +01:00
nils 56e45561d6 fix 2025-12-04 22:19:49 +01:00
nils 4308ff220b refactor: Decouple frontend API calls from a shared client instance 2025-12-04 21:59:39 +01:00
nils d06fc133e7 refactor 2025-12-04 17:20:45 +01:00
nils 834d4f641b move date formatting to a utility. 2025-12-04 16:21:00 +01:00
nils 2353f114f6 fix 2025-12-04 14:48:20 +01:00
nils dd521f9f1d simplify 2025-12-04 14:37:09 +01:00
nils 34230cda21 fix 2025-12-04 13:22:04 +01:00
nils 0a67d88ff2 fix 2025-12-04 13:17:31 +01:00
nils e8f578eb48 feat: standardize correspondent and tag types with usage count 2025-12-04 13:11:52 +01:00
nils 225f810513 fix api in backend 2025-12-04 13:10:32 +01:00
nils b5d0a8cb66 refactor 2025-12-04 12:43:10 +01:00
nils 2ea74816fb feat: Refactor utility and component exports, update dependencies, and add Knip for unused code detection. 2025-12-04 11:58:04 +01:00
nils c3375b714d remove geometry.ts 2025-12-04 11:10:48 +01:00
nils f57bb9f6eb introduce a unified document viewer component. 2025-12-04 02:21:11 +01:00
nils cbd463e1d9 cleanup 2025-12-04 01:34:26 +01:00
nils 3f7c294948 feat: Remove documentLink prop and onNavigatorSnapshot logic, and add openDetailPanel and closeDetailPanel to document shell. 2025-12-04 01:32:28 +01:00
nils 8bb9f9950a feat: refactor document opening/preview logic to support preview/sidepanel actions. 2025-12-04 01:02:54 +01:00
nils 447b012e48 feat: Introduce DocumentOpenContext for document activation in the frontend 2025-12-04 00:57:54 +01:00
nils 65555c18e8 cleanup 2025-12-04 00:29:32 +01:00
nils 9f88823315 Remove document link and download URL management from frontend components 2025-12-04 00:23:27 +01:00
nils 4b02d1c7d5 cleanup 2025-12-04 00:09:32 +01:00
nils c6a627af8a feat: Add Download type and document download field, unify preview zoom styling 2025-12-03 23:56:35 +01:00
nils dad6f1e0b8 refactor document viewer UI buttons and props. 2025-12-03 22:48:18 +01:00
nils e46318aee5 feat: Add OCR script for PDFs using Ollama and refine document preview navigation and zoom behavior. 2025-12-03 17:07:51 +01:00
nils 186743ee37 feat: frontend status toast overlay, while refactoring document store usage. 2025-12-03 15:16:26 +01:00
nils 4337575b17 improve desktop layout rendering readiness. 2025-12-03 14:30:56 +01:00
nils e66451afbd feat: Introduce a generic inline rename input component for document editing 2025-12-03 12:00:57 +01:00
nils e1dd1990d7 refactor: Remove redundant max-width and max-height properties from preview zoom elements. 2025-12-02 15:16:39 +01:00
nils c0093edc09 refactor: remove openDocumentPreview and onDocumentOpen props from document panel components and hooks. 2025-12-02 14:59:23 +01:00
nils cf41148942 refactor: consolidate message display options into a shared MessageOptions interface. 2025-12-02 13:10:25 +01:00
nils ebe8b163b9 refactor: Consolidate mutation options, simplify tag attachment and removal logic, and remove unused tag drop handler. 2025-12-02 13:01:14 +01:00
nils 5ffe9e4d27 refactor: improve document preview type checking by replacing ts-ignore with explicit type checks. 2025-12-01 23:51:37 +01:00
nils 601ced5981 feat: Add an icon size slider component and implement dynamic icon sizing for document views, while refactoring the documents panel layout. 2025-12-01 23:42:43 +01:00
nils 033026f57c feat: Introduce configurable icon and card sizes for document views and desktop workspace. 2025-12-01 11:45:58 +01:00
nils fea1bf68c5 refactor: extract common resize handle styles into a reusable component. 2025-12-01 00:07:15 +01:00
nils 406ce1eb70 feat: add import for usePanelManager hook 2025-11-30 23:59:27 +01:00
nils 6e9decfbec fix: Stop enqueuing document analysis job when only title changes 2025-11-30 23:55:41 +01:00
nils 9aa16b0729 feat: implement hybrid document search combining Quickwit results with PostgreSQL pg_trgm title search, supported by a new index. 2025-11-30 23:50:38 +01:00
nils f4b564b9e1 refactor: use ref to access latest focused document ID in applySelectedFolder callback to stabilize dependencies 2025-11-30 23:32:00 +01:00
nils 6519345414 feat: Rename ocr-text asset type to text-content across the application 2025-11-30 03:24:24 +01:00
nils 45bdc7ed8e configure Nginx to serve mjs files. 2025-11-30 01:29:53 +01:00
nils 9cf63ece72 refactor: enhance content loading and cancellation with AbortController and improved state reset logic. 2025-11-29 14:27:35 +01:00
nils 79aa359c1f refactor: lazy load document content only when its tab is active and separate content state reset logic 2025-11-28 21:55:47 +01:00
nils b9019d48fc refactor: Separate content loading logic into distinct effects for state reset and lazy loading, and update overlay text color variable. 2025-11-28 21:55:20 +01:00
nils 70f2a576e1 fix: improve BreadcrumbTrail truncation and layout within flexible panel headers by adjusting CSS and width calculation constants. 2025-11-28 21:48:45 +01:00
nils c53bea7093 feat: Implement keyboard navigation for desktop layout items with occlusion and Z-order awareness. 2025-11-28 21:06:48 +01:00
nils d7811b7c7a refactor: Adjust floating panel positioning and styling by removing absolute positioning, adding backdrop blur, and refining width and margin properties. 2025-11-28 20:53:44 +01:00
nils 41555bb57d feat: Add shift-click additive selection for documents in grid view 2025-11-28 20:50:24 +01:00
nils 0b45eeb2d4 feat: enhance keyboard navigation with grid support, refined selection, and arrow key navigation. 2025-11-28 20:32:16 +01:00
nils 852ab511ab style 2025-11-28 20:19:04 +01:00
nils e0d63d94b4 feat: Enable moving and dragging selected folders, including mixed document and folder selections, with updated drag preview visuals and minor UI styling. 2025-11-28 20:12:56 +01:00
nils 4fd1b712fa feat: Sort document tags alphabetically and prevent hover/focus on empty folder menu items. 2025-11-28 19:46:25 +01:00
nils 34604c5842 refactor: Extract folder assignment logic into a new SelectionFolderMenu component, adding new icons and updating related styles. 2025-11-28 19:28:12 +01:00
nils 970490d9c8 refactor: remove unused code and parameters from drag logic and document viewer 2025-11-28 09:11:11 +01:00
nils 65ad1f6cfa feat: Introduce MediaViewer component to centralize media rendering and refactor document preview and zoom overlay to use it, along with new CSS padding variables. 2025-11-28 09:08:28 +01:00
nils 0b4a9441d0 feat: Add UI and logic to handle password-protected PDF documents. 2025-11-28 08:37:04 +01:00
nils 268488d93d fix: Clamp leader rotation limits to prevent extreme angles and add a safety check for inverted limits. 2025-11-28 01:27:57 +01:00
nils 7c03eecd30 fix: ensure card detaches from leader and clears followers when starting a drag, and centralize stack property recalculations. 2025-11-28 01:19:28 +01:00
nils 635747a1ee feat: Sort selected workspace items by Z-index to ensure consistent order. 2025-11-28 01:13:53 +01:00
nils e512c8215e feat: Implement enhanced card physics with rotational dynamics, mass scaling, stacking behavior, and drag torque. 2025-11-28 01:10:57 +01:00
nils e164a88a97 feat: introduce CardPhysics class to encapsulate card drag and physics state from LayoutCard. 2025-11-27 20:37:20 +01:00
nils 9bf9550b03 feat: Refactor tag drag and drop interactions 2025-11-27 17:59:28 +01:00
nils 68ed888172 refactor: begin spatial workspace architecture refactor with design document and updated pointer event handling for document activation. 2025-11-27 01:57:38 +01:00
nils 82243b4170 feat: Introduce PreviewContext and document a new spatial workspace architecture. 2025-11-27 01:14:18 +01:00
nils 9ba581f64e feat: add keyboard navigation for document views, externalize document preview handling, and include physics model documentation. 2025-11-27 01:06:31 +01:00
nils 3588fde3a2 larger 2025-11-27 00:19:39 +01:00
nils 844e0ed355 feat: Implement multi-touch drag and long-press stack selection with pointer tracking and physics model documentation. 2025-11-26 22:42:28 +01:00
nils 07e28b3f8d feat: Add initial random rotation for cards, introduce a PointerTrackingContext for pointer management, and include a design document for spatial workspace architecture. 2025-11-26 22:42:16 +01:00
nils 0c6504ef1c feat: Introduce spatial workspace architecture design document, enhance layout store item synchronization, and prevent metadata fetch retries. 2025-11-26 22:03:09 +01:00
nils e36732cc2d feat: introduce a new layered spatial workspace architecture design, implement initial card positioning logic, and add container size observation for the layout system. 2025-11-26 21:54:11 +01:00
nils ee1c5dc606 feat: add spatial workspace refactor design document and implement dirty tracking for layout persistence. 2025-11-26 20:39:49 +01:00
nils 39e3ac2a87 feat: Implement layout persistence for card positions and rotation, loading from and saving to the database. 2025-11-26 20:24:00 +01:00
nils 6157a4f571 docs: Add design document for spatial workspace architecture refactor and fix card center calculation in layout system. 2025-11-26 18:09:36 +01:00
nils 9a3ed74230 refactor: centralize multi-card bring-to-front logic and document new spatial workspace architecture. 2025-11-26 17:52:14 +01:00
nils 835dcc4faa feat: Add physics model design document, implement multi-card snapping and layout system improvements, and refine pointer event handling. 2025-11-26 17:30:46 +01:00
nils 155d8c5c4a feat: Add spatial workspace architecture design document and refactor card drag logic to use new LayoutCard drag state management. 2025-11-26 17:00:56 +01:00
nils 81194032e0 refactor: Introduce a layered architecture for card interactions, separating drag logic, and selection handling. 2025-11-26 15:46:04 +01:00
nils d44c33dd69 feat: Introduce useCardPointer for card-specific pointer events, add spatial capabilities to LayoutSystem 2025-11-26 14:02:56 +01:00
nils bbf7d23c96 feat: Introduce new LayoutSystem and spatial workspace architecture design document, refactor document card and workspace to use it, and simplify pointer event handling. 2025-11-26 13:39:19 +01:00
nils 27eea08605 refactor: replace workspace engine and drag/pointer logic with a new layout system and physics model.refactor: replace the workspace engine and drag/pointer logic with a new layout system. 2025-11-26 12:28:02 +01:00
nils e5e71cc5f8 cleanup 2025-11-26 11:00:21 +01:00
nils 0a4a3ae35c refactor: Decompose document and folder views into modular components and hooks, replacing monolithic list/grid implementations. 2025-11-26 01:50:53 +01:00
nils 342714528a refactor: Introduce dedicated Document and Asset types and migrate codebase from DocumentLike. 2025-11-26 00:38:01 +01:00
nils 93dcde471f feat: Refactor document drag initiation and state management with new session handling and geometry utilities. (slightly broken) 2025-11-26 00:03:59 +01:00
nils 757abe9a0f video thumbnails 2025-11-25 22:34:05 +01:00
nils c4603a0b3a feat: Implement Z-index-aware click behavior for selected documents and add group gravitation to document drag. 2025-11-25 21:55:52 +01:00
nils 45ff3fb9ac feat: introduce physics-based dynamic rotation for dragged documents by adding angular velocity and mass properties, and refine group drag selection logic. 2025-11-25 21:26:10 +01:00
nils 5accfb4339 refactor: make draggedDocIds a required property in PointerDownOptions and simplify handlePointerDown logic accordingly 2025-11-25 21:04:54 +01:00
nils e0fce40bc3 refactor: Unify document selection and drag interactions by replacing stack-specific callbacks and simplifying drag state. 2025-11-25 20:54:53 +01:00
nils 13c87f4b07 refactor: Simplify folder ancestor expansion logic and refine onCreateFolder parentId type. 2025-11-25 15:21:45 +01:00
nils d6206786f7 feat: Rework sidebar with dedicated components for menu, folders, tags, correspondents, and search, including tenant switching and theme controls. 2025-11-25 15:09:20 +01:00
nils 2c42e63bab refactor: Remove neutral contrast theme adjustment and improve floating menu event handling by delaying listeners and checking for unmounted elements. 2025-11-25 14:12:53 +01:00
nils 30602e6b66 feat: Propagate tenantId to document workspace and panel props. 2025-11-25 13:11:01 +01:00
nils 226d99b6bf refactor: Consolidate tag drag/drop handlers into a single tagInteractions prop and streamline prop inheritance in DesktopWorkspace. 2025-11-25 12:50:03 +01:00
nils 5191d50d42 refactor: Consolidate document view props into a shared interface and refine tag drop payload handling. 2025-11-25 12:11:42 +01:00
nils 9c116cfe8c refactor: Move DocumentsListProps to DocumentsPanel and implement desk view selection and ID generation. 2025-11-25 11:55:55 +01:00
nils f4c0af4730 feat: add Papercrate logo source and branding assets documentation 2025-11-25 10:17:56 +01:00
nils fe80ccc046 refactor: unify document panel empty state rendering and view mode logic, adjust related styles 2025-11-25 10:13:46 +01:00
nils ccc22beda2 refactor: extract various constants 2025-11-25 10:10:42 +01:00
nils 4d30e47ee8 fix 2025-11-25 00:42:32 +01:00
nils 81dd7b80d4 fix 2025-11-25 00:21:27 +01:00
nils 049c3f4a9a remove old logo 2025-11-25 00:12:53 +01:00
nils cc168b3460 clean 2025-11-25 00:08:18 +01:00
nils 9ef63a87cb refactor: centralize documents route configuration into new useDocumentsShell hook and export related types. 2025-11-24 23:55:37 +01:00
nils 70e7bda87e refactor: introduce dedicated identifier types for improved clarity and type safety 2025-11-24 23:47:36 +01:00
nils 22f0c040a2 refactor: Standardize document and folder identification with a new entryKey module. 2025-11-24 23:13:11 +01:00
nils 096c377897 refactor: Consolidate workspace utilities into new workspaceUtils.ts with typed entry keys, replacing appLayoutUtils.ts, and update related components. 2025-11-24 22:38:01 +01:00
nils deec2fe69d refactor: enforce setAuthToken to strictly require a token, add /notes to gitignore, and include logo asset. 2025-11-24 21:25:45 +01:00
nils 8c16730d31 docs: Add code quality instructions to README, ignore notes directory, and include project logo. 2025-11-24 21:23:55 +01:00
nils f5ca74b63c refactor: Delete isPlainObject type guard and replace its usages with direct typeof checks. 2025-11-24 21:22:38 +01:00
nils 63be4663ab remove isStringValue 2025-11-24 21:03:06 +01:00
nils d470bc8bf1 Merge remote-tracking branch 'ui/ui' into dev 2025-11-24 20:53:41 +01:00
nils 8aab635b13 simplify 2025-11-24 20:49:22 +01:00
nils 46fa63af8a cleanup 2025-11-24 20:42:43 +01:00
nils 4c8ff39305 cleanup 2025-11-24 20:19:44 +01:00
nils 66848e8526 css 2025-11-23 13:43:01 +01:00
nils 71f9330c67 cleanup 2025-11-23 13:22:00 +01:00
nils 85afff2c19 cleanup 2025-11-23 00:11:18 +01:00
nils 014f489857 ai-shit 2025-11-22 18:19:55 +01:00
nils d3c76bc303 apiClient 2025-11-22 15:30:08 +01:00
nils 530218a4b8 mimetype 2025-11-22 14:43:05 +01:00
nils 5498cf4342 apiclient 2025-11-22 04:22:18 +01:00
nils 138e46a286 content_type -> mime_type 2025-11-22 04:00:21 +01:00
nils 41005390e8 fix 2025-11-22 03:14:12 +01:00
nils 79578e5daa fix 2025-11-22 02:57:44 +01:00
nils 132d9e3d02 fix 2025-11-22 02:41:14 +01:00
nils 7bee56b960 fix 2025-11-22 01:17:25 +01:00
nils ab3faad6af fix 2025-11-22 00:32:42 +01:00
nils c50372af06 download url 2025-11-22 00:28:11 +01:00
nils dd7dae5f9d folder in summary 2025-11-21 15:41:55 +01:00
nils 5871c7ae6e DownloadLink 2025-11-21 13:53:13 +01:00
nils bd55b784d7 Merge remote-tracking branch 'ui/ui' into dev 2025-11-20 16:57:49 +01:00
nils 3ce6b0ce93 reduce to 1 object per asset 2025-11-20 16:54:12 +01:00
nils a08e975da1 cleanup 2025-11-20 02:36:32 +01:00
nils 78e6d3e431 assets without objects 2025-11-20 02:32:11 +01:00
nils 0eb4c0a294 Remove asset/document hydration pipeline and introduce client-side cache updates for correspondents, tags, and search results 2025-11-20 02:18:33 +01:00
nils b76f1df27f documentsmanager 2025-11-19 23:36:04 +01:00
nils e1b47ed153 search cleanup 2025-11-19 01:25:56 +01:00
nils 6923a68837 selection cleanup 2025-11-19 00:57:06 +01:00
nils 1764f24243 cleanup 2025-11-19 00:44:47 +01:00
nils 3e21e72953 cleanup 2025-11-18 14:17:53 +01:00
nils ea6e42175a buttons 2025-11-18 13:47:43 +01:00
nils 0c37ef5578 cleanup 2025-11-18 13:16:35 +01:00
nils 0b0a1a88fd cleanup 2025-11-18 00:41:49 +01:00
nils bc9be96123 theme 2025-11-17 23:32:26 +01:00
nils 5fe11f18ac tag colors 2025-11-17 16:49:32 +01:00
nils 61573431e3 physics 2025-11-17 01:46:01 +01:00
nils 42733a9a92 velocity based queue 2025-11-17 01:01:59 +01:00
nils c56058b939 queue 2025-11-17 00:43:43 +01:00
nils 5ffbcc47e3 lazy pdf rendering 2025-11-17 00:22:20 +01:00
nils 16c28bcba1 clean 2025-11-16 21:28:47 +01:00
nils fa6b4b94f6 no prev/next anymore 2025-11-16 21:25:39 +01:00
nils 1f2c62241c cleanup 2025-11-16 21:08:07 +01:00
nils 4b9f74d5a8 more pdfjs 2025-11-16 20:13:47 +01:00
nils dae66703f2 pdfjs 2025-11-16 11:36:11 +01:00
nils 42f3917881 no preview assets anymore 2025-11-16 01:46:58 +01:00
nils 7e0768a09a summary 2025-11-16 00:39:37 +01:00
nils d0379c57ce fuck 2025-11-15 04:11:22 +01:00
nils bd3eab8b40 cleanup 2025-11-14 19:46:18 +01:00
nils a3c5494bf7 refactor 2025-11-14 02:35:17 +01:00
nils 6f4ff68062 style 2025-11-14 02:23:07 +01:00
nils 2966e25646 logo 2025-11-14 01:58:57 +01:00
nils f5b807edc5 fix 2025-11-14 01:54:38 +01:00
nils 2effdff990 logo theme 2025-11-14 01:48:41 +01:00
nils a6d2c115fe fix 2025-11-14 01:48:31 +01:00
nils 59a0514541 tsc 2025-11-13 15:15:19 +01:00
nils eb20d0bc5e fix 2025-11-13 15:03:35 +01:00
nils 62e44b5375 clean 2025-11-13 14:07:31 +01:00
nils 18558ab087 fix 2025-11-13 13:32:26 +01:00
nils dfbd47f219 fix 2025-11-13 13:22:10 +01:00
nils 98165f71b0 links 2025-11-13 12:40:38 +01:00
nils f7251b8ce3 fix and new logo 2025-11-13 12:12:25 +01:00
nils 6fe7bbbbb5 lint 2025-11-13 02:49:37 +01:00
nils 1e740ea024 fix 2025-11-13 02:43:42 +01:00
nils 6d55e61cee typescript 2025-11-13 02:37:16 +01:00
nils ada089c05b typescript 2025-11-13 01:07:55 +01:00
nils 39787f9552 issued at cases license 2025-11-12 18:29:10 +01:00
nils b812d748ea cleanup 2025-11-12 17:02:27 +01:00
nils 190bd7d85c Merge remote-tracking branch 'ui/ui' into dev 2025-11-12 15:06:16 +01:00
nils d2e27c12a2 panel 2025-11-12 15:06:04 +01:00
nils e66b530f24 fix 2025-11-12 12:02:21 +01:00
nils 85ed89cf19 Merge remote-tracking branch 'ui/ui' into dev 2025-11-12 11:32:01 +01:00
nils 9cea0161d7 fix icon on firefox 2025-11-12 11:31:54 +01:00
nils 3042dacc00 left aligned grid 2025-11-12 11:26:06 +01:00
nils 3424691e4c theme 2025-11-12 02:50:20 +01:00
nils 363ed2e836 Merge remote-tracking branch 'ui/ui' into dev 2025-11-12 02:42:42 +01:00
nils 900ec18a7f fix 2025-11-12 02:42:37 +01:00
nils 13e103f382 Merge remote-tracking branch 'ui/ui' into dev 2025-11-12 02:34:17 +01:00
nils 32ef3ced59 panels 2025-11-12 02:24:46 +01:00
nils 44c982f015 document viewer 2025-11-12 02:24:46 +01:00
nils 762e4ee315 fix 2025-11-12 02:24:46 +01:00
nils d42192b2f1 fix 2025-11-12 02:24:46 +01:00
nils 06683e2a07 cleanup 2025-11-12 02:24:46 +01:00
nils 5653828ff3 api change 2025-11-12 02:24:31 +01:00
nils 84734cca2f tenants 2025-11-12 02:16:20 +01:00
nils b987bdacfe theme 2025-11-11 22:38:40 +01:00
nils 1111784caa selection fix 2025-11-11 21:50:08 +01:00
nils 8e7a2f3d6c desaturate theme a bit 2025-11-11 21:41:49 +01:00
nils cf070d35f0 Merge remote-tracking branch 'ui/ui' into dev 2025-11-11 18:03:50 +01:00
nils e019cc2cee update dockerfile and k8s 2025-11-11 18:03:43 +01:00
nils 2a87dad0ab fix 2025-11-11 16:55:08 +01:00
nils d46951d12e fix 2025-11-11 16:22:50 +01:00
nils 8feec04601 helm migration 2025-11-11 16:06:44 +01:00
nils 1193c71579 tag picker 2025-11-11 15:59:17 +01:00
nils 31db4ede38 admin migrate-database 2025-11-11 15:54:57 +01:00
nils 6f34a0a4fa fmt 2025-11-11 15:50:51 +01:00
nils cd9a8a0204 fix 2025-11-11 15:31:42 +01:00
nils edcf996d12 Merge remote-tracking branch 'ui/ui' into dev 2025-11-11 15:29:07 +01:00
nils 5333de0e89 cleanup 2025-11-11 15:23:07 +01:00
nils 1ee3d1cc5e fix 2025-11-11 15:18:06 +01:00
nils a8589a864a unify detailpanel and documentviewer 2025-11-11 14:45:19 +01:00
nils 7f371b2ab6 fix 2025-11-11 13:27:07 +01:00
nils 8751637b92 tenant deletion/reset 2025-11-11 12:17:24 +01:00
nils 10f9f3be2f guts 2025-11-11 12:01:22 +01:00
nils 2dd786ce14 hydration 2025-11-11 10:50:57 +01:00
nils 6ae497323e viewer 2025-11-11 10:09:15 +01:00
nils 4f29ff73c6 tenant gate active 2025-11-11 02:45:05 +01:00
nils ac9e688e7a /api/download 2025-11-11 02:19:39 +01:00
nils 3fe325c4ce fix 2025-11-11 01:57:00 +01:00
nils 425e03650f cleanup 2025-11-11 01:51:42 +01:00
nils 4ce4bd1a4b better date parsing 2025-11-11 01:36:17 +01:00
nils 26798ac430 fix 2025-11-11 00:48:13 +01:00
nils 70b9443e66 outline 2025-11-11 00:48:07 +01:00
nils 8788cd3e25 taskflow 2025-11-10 23:06:53 +01:00
nils 8dddb0b940 fix api spec 2025-11-10 22:45:39 +01:00
nils 992d7038e5 restore drag preview 2025-11-10 22:39:29 +01:00
nils 0a1416ef92 pixelpeeping 2025-11-10 22:08:46 +01:00
nils 8820ff1c15 fixes 2025-11-10 21:27:04 +01:00
nils ab81e76206 tweak 2025-11-10 15:01:20 +01:00
nils ef9bec3334 update deps 2025-11-10 14:46:34 +01:00
nils 8094ac3190 Merge remote-tracking branch 'ui/ui' into dev 2025-11-10 13:46:30 +01:00
nils 7376a5dd4d remove desktop help modal 2025-11-10 13:46:21 +01:00
nils d43885aa48 fix 2025-11-10 13:35:36 +01:00
nils 6eb50c0555 modal 2025-11-10 13:34:12 +01:00
nils 9252883a2b fix 2025-11-10 12:58:56 +01:00
nils fe2cb99651 fix 2025-11-10 12:54:12 +01:00
nils bf1e5cd107 style 2025-11-10 12:50:28 +01:00
nils 9757fd0c10 slightly warmer theme 2025-11-10 12:14:52 +01:00
nils 288c53364e fix 2025-11-10 12:14:40 +01:00
nils 218f2f2475 upload button tweaks 2025-11-10 11:34:45 +01:00
nils 585caaee58 refactor styles 2025-11-10 11:30:16 +01:00
nils 578dfd8998 update rust deps 2025-11-10 10:51:57 +01:00
nils b4a1821a55 upload queue 2025-11-10 02:05:17 +01:00
nils ac24297a7b upload button 2025-11-10 00:32:41 +01:00
nils 20cf597c5c refactor 2025-11-09 20:21:46 +01:00
nils 737c44da12 caching 2025-11-09 19:50:55 +01:00
nils bfab2f2cdc refactor 2025-11-09 19:50:16 +01:00
nils fed3a1a32f fix schema 2025-11-09 19:49:34 +01:00
nils cbdb180423 fix 2025-11-09 19:26:24 +01:00
nils 6d0d453aa1 Merge remote-tracking branch 'ui/ui' into dev 2025-11-09 19:10:02 +01:00
nils 3c1c910dc9 cleanup 2025-11-09 19:08:24 +01:00
nils 45ad2d4730 build on github 2025-11-09 19:03:03 +01:00
nils 12733e1879 crosscompile 2025-11-09 18:35:31 +01:00
nils da0f2ae8ab tests 2025-11-09 18:20:29 +01:00
nils 14b4dd434c multi-arch build 2025-11-09 18:11:16 +01:00
nils bc33f68209 cleanup 2025-11-09 13:03:44 +01:00
nils 81571e4c64 proxy downloads 2025-11-09 12:01:19 +01:00
nils d24d2c9249 rust-s3 2025-11-08 02:03:52 +01:00
nils 4bc7357e41 ux 2025-11-07 23:48:25 +01:00
nils 8dd6be2a5d cleanup 2025-11-07 22:44:55 +01:00
nils 08cf8bd50f tweak 2025-11-07 22:26:27 +01:00
nils 0bd54ef941 simplify previewzoomoverlay clicks 2025-11-07 22:22:41 +01:00
nils 7617d99ab2 trash 2025-11-07 12:46:46 +01:00
nils 06ac9fc87e fix 2025-11-07 12:44:44 +01:00
nils ae1884045d tag drop works 2025-11-07 12:00:28 +01:00
nils 9bf1fc1983 fix
ci / docker (frontend, frontend/Dockerfile, frontend) (push) Failing after 49s
ci / docker (backend, backend/Dockerfile, backend) (push) Failing after 17m1s
2025-11-07 11:29:48 +01:00
nils 30b90a5bf5 detailspanel, selection handling, single document details panel 2025-11-07 10:46:04 +01:00
nils 5b88ecf2dd debounce 2025-11-06 23:11:22 +01:00
nils 7f53bc900a refactor 2025-11-06 22:52:25 +01:00
nils ef2ed2dc58 refactor 2025-11-06 22:21:27 +01:00
nils 07169e853b fix 2025-11-06 21:40:36 +01:00
nils 5562c271e1 fix 2025-11-06 21:29:31 +01:00
nils 66173f87be fix 2025-11-06 20:56:21 +01:00
nils 3f49e26d19 work 2025-11-06 14:43:43 +01:00
nils a6f79dbc75 refactor 2025-11-06 12:26:29 +01:00
nils 28b9112781 settings 2025-11-06 11:41:28 +01:00
nils ebed18afef fix 2025-11-06 09:55:32 +01:00
nils cedbe99513 fix 2025-11-06 01:54:47 +01:00
nils 82ae18b4b9 sorting 2025-11-06 01:46:33 +01:00
nils a16f1501d3 folders-move 2025-11-06 00:58:17 +01:00
nils 4027ac66cb folder tree 2025-11-06 00:51:38 +01:00
nils 911051e75d order 2025-11-05 23:01:42 +01:00
nils c193b30c8e capsets 2025-11-05 22:15:27 +01:00
nils e9c5f27b17 capsapi 2025-11-05 15:21:02 +01:00
nils a75281aa13 readonly role 2025-11-05 14:10:57 +01:00
nils 7b6afa1128 fix 2025-11-05 12:47:31 +01:00
nils 45759e6ca4 zindex 2025-11-05 12:38:56 +01:00
nils 75a03de4e3 docs 2025-11-05 12:34:56 +01:00
nils 480bc20ae7 caps and delete 2025-11-05 12:32:10 +01:00
nils 934fb6b689 desktop redo 2025-11-05 12:29:30 +01:00
nils 928753add8 foo 2025-11-05 02:31:21 +01:00
nils ad54cecbac fix 2025-11-04 14:18:49 +01:00
nils 82b33a4d50 style 2025-11-04 14:18:41 +01:00
nils dd378b3ad8 desktop 2025-11-04 11:37:01 +01:00
nils f523e5651b fix 2025-11-04 00:48:37 +01:00
nils 01fd774b18 ui 2025-11-04 00:36:44 +01:00
nils 9ad704f50a floating selection box 2025-11-03 23:10:03 +01:00
nils 8d4adbea42 style 2025-11-03 10:45:16 +01:00
nils bfd49cd7b7 fix 2025-11-03 02:47:06 +01:00
nils 209ac191c9 cleanup 2025-11-03 02:34:12 +01:00
nils 152f824e2e selection 2025-11-03 02:31:46 +01:00
nils 1c261bf0a2 fix 2025-11-03 01:47:15 +01:00
nils 1cbbbeb6b0 fix 2025-11-03 00:32:59 +01:00
nils a819790899 fix 2025-11-03 00:17:28 +01:00
nils 2ee69406b6 stuff 2025-11-03 00:17:11 +01:00
nils c85cff720b on delete 2025-11-02 23:17:59 +01:00
nils 5c0f350296 idbdatabase 2025-11-02 22:33:27 +01:00
nils 626b6c16e0 fix 2025-11-02 22:25:00 +01:00
nils 74aacbc1eb fix 2025-11-02 22:08:15 +01:00
nils 3c24b890a4 fix 2025-11-02 21:38:11 +01:00
nils 4ec19dbd70 fix
ci / docker (frontend, frontend/Dockerfile, frontend) (push) Failing after 15m41s
ci / docker (backend, backend/Dockerfile, backend) (push) Failing after 15m46s
2025-11-02 15:56:12 +01:00
nils f8ab674b84 spacebar 2025-11-02 15:44:09 +01:00
nils 866a31b89d persistence 2025-11-02 15:34:26 +01:00
nils 4f296ac1e3 fix 2025-11-02 15:20:41 +01:00
nils d6a6d1233c various 2025-11-02 15:03:23 +01:00
nils 8f86a71c39 fix 2025-11-02 14:27:13 +01:00
nils 1cddc3c373 fix 2025-11-02 14:20:11 +01:00
nils bd2669af4d viewer 2025-11-02 14:12:40 +01:00
nils 65495a07b2 fix 2025-11-02 14:08:08 +01:00
nils a30b6eaa02 backend 409 on document conflict 2025-11-02 13:22:16 +01:00
nils 9ddaa59155 desktop selection 2025-11-02 13:12:36 +01:00
nils 5bf64fc5f3 icons 2025-11-02 12:22:02 +01:00
nils 824db2087c dbltap 2025-11-02 12:17:32 +01:00
nils 1135873968 ui 2025-11-02 12:12:11 +01:00
nils e7e7881772 playful 2025-11-02 04:40:07 +01:00
nils f7b274c1ec cors 2025-11-02 02:57:02 +01:00
nils b561f46d2c content disposition 2025-11-02 02:26:40 +01:00
nils b9c95437d2 details 2025-11-02 02:05:05 +01:00
nils 7d8595b63f rename 2025-11-02 01:18:24 +01:00
nils 32d627fd6d fix 2025-11-02 00:42:47 +01:00
nils 09430c6f40 refactor 2025-11-02 00:35:32 +01:00
nils 49998fa23d DocumentsPanel 2025-11-02 00:23:25 +01:00
nils 14077d26d7 style 2025-11-01 23:50:30 +01:00
nils 40832ff1c3 styling 2025-11-01 23:49:04 +01:00
nils 0b8f856876 folder-selection 2025-11-01 23:37:12 +01:00
nils 16ac16cd30 ui 2025-11-01 22:49:50 +01:00
nils 326a6042ec api-tokens 2025-11-01 22:46:04 +01:00
nils 8efe2d19ba user_sessions 2025-11-01 22:45:46 +01:00
nils 1c5b920e13 gitea-workflow 2025-11-01 21:43:32 +01:00
nils 00f5d3adea api-tokens 2025-11-01 21:42:52 +01:00
nils f6ceff5444 selection 2025-11-01 20:28:00 +01:00
nils 9559a1b5e2 fix 2025-11-01 19:59:39 +01:00
nils e752361f39 sidebar 2025-11-01 19:50:29 +01:00
nils 41c9ae30e9 fix 2025-11-01 19:45:16 +01:00
nils 03bc311403 detailpanel 2025-11-01 19:39:51 +01:00
nils 366f716b1c more screenshots 2025-11-01 15:13:47 +01:00
nils 3731fe8e40 clean 2025-11-01 15:01:35 +01:00
nils 3c416600a6 screenshot 2025-11-01 14:57:37 +01:00
nils 6f6ee59461 fix 2025-11-01 13:59:46 +01:00
nils 9aed7ec524 fix 2025-11-01 13:51:43 +01:00
nils 816c880e8e cleanup 2025-11-01 13:40:10 +01:00
nils 816910ca40 fix 2025-11-01 04:29:08 +01:00
nils 6eb3f8a3d9 detailpanel 2025-11-01 04:22:51 +01:00
nils 303ece0529 detailpanel 2025-11-01 04:16:31 +01:00
nils fd13edae6d build
ci / docker (frontend, frontend/Dockerfile, frontend) (push) Successful in 16m11s
ci / docker (backend, backend/Dockerfile, backend) (push) Successful in 16m16s
2025-11-01 02:52:04 +01:00
nils 09492f452f Revert "build"
This reverts commit b6bc5391cf.
2025-11-01 02:07:58 +01:00
nils b6bc5391cf build 2025-11-01 02:00:16 +01:00
nils ac91efc421 foo 2025-11-01 01:46:14 +01:00
nils 2fba3236ad Add GHCR build/publish workflow 2025-11-01 00:34:51 +01:00
nils 18352caabe webdav token regeneration 2025-10-31 20:37:07 +01:00
nils e9731ec691 webdav fix 2025-10-31 20:10:42 +01:00
nils 175387f0d3 rls 2025-10-31 19:13:11 +01:00
nils 2d8a4432cc style 2025-10-31 18:19:52 +01:00
nils c146084f75 fix 2025-10-31 17:44:33 +01:00
nils 1fcd265eb9 breadcrumbs 2025-10-31 17:43:00 +01:00
nils 1a1e9a80c3 breadcrumbs 2025-10-31 17:24:39 +01:00
nils d14d263e8f health 2025-10-31 15:55:55 +01:00
nils 48bed9a9fe magic token login 2025-10-31 15:16:30 +01:00
nils 91dd3289a9 deskview 2025-10-31 14:27:30 +01:00
nils b969cae524 screenshot 2025-10-31 13:52:48 +01:00
nils aaf9fd1bce fix 2025-10-31 13:44:26 +01:00
nils b254c651a7 fixes 2025-10-31 13:39:06 +01:00
nils 3af656b09f modalheader 2025-10-31 13:19:39 +01:00
nils 321b0d84ae panelheader 2025-10-31 13:12:09 +01:00
nils 6b4a4af5f1 fix 2025-10-31 12:56:17 +01:00
nils faf8fec9c7 revert document types 2025-10-31 12:56:09 +01:00
nils 0a87f33d89 style 2025-10-31 02:34:11 +01:00
nils eacab2ced6 cleanup 2025-10-31 02:19:38 +01:00
nils f796932cd8 cleanup 2025-10-31 02:06:38 +01:00
nils 17db827e3a cleanup 2025-10-31 01:52:51 +01:00
nils 722c2f9a5c tests 2025-10-31 01:26:24 +01:00
nils fa43bc749e cleanup 2025-10-31 01:19:01 +01:00
nils 5b5916ff92 openapi cleanup 2025-10-31 00:39:22 +01:00
nils 5ca7dc9678 cleanup 2025-10-31 00:38:52 +01:00
nils 39d1d80383 cleanup 2025-10-31 00:28:48 +01:00
nils 813ce24aeb document types 2025-10-31 00:13:16 +01:00
nils 3941ec61d3 rename 2025-10-30 23:12:30 +01:00
nils 4a3cb63263 created_at swagger 2025-10-30 23:03:57 +01:00
nils 5386724e0d docs 2025-10-30 22:18:30 +01:00
nils 85f3d90329 frontend cleanup 2025-10-30 18:17:01 +01:00
nils bb34a47fa8 squash database migrations 2025-10-30 17:53:00 +01:00
nils 7fae0e44ac fix 2025-10-30 16:56:23 +01:00
nils 80d21ed0a1 deployment 2025-10-30 16:21:52 +01:00
nils 67fd787a14 cleanup 2025-10-30 14:41:15 +01:00
nils 86be6256a4 fix 2025-10-30 02:45:54 +01:00
nils c65a3d2c40 hue control 2025-10-30 02:38:48 +01:00
nils 9fe97f2b8a move folder creation to sidebar 2025-10-30 02:18:33 +01:00
nils e5d44c13b4 folder create idempotency 2025-10-30 02:15:53 +01:00
nils f89c6bb259 no-password 2025-10-30 01:43:07 +01:00
nils b9966f82a0 frontend signup 2025-10-30 01:33:31 +01:00
nils 84f4cd05f4 backend signup 2025-10-30 01:15:26 +01:00
nils 4d14d5a3d4 passkeys 2025-10-30 00:22:01 +01:00
nils d72792fe0a loginview 2025-10-29 23:39:49 +01:00
nils 48da6b9c67 menu 2025-10-29 23:30:07 +01:00
nils e3f8590ba2 colors 2025-10-29 23:20:47 +01:00
nils 384483b14c colors 2025-10-29 23:10:51 +01:00
nils eb3eae3fac upload limit 2025-10-29 22:49:48 +01:00
nils dfa2c1c0e2 limit 2025-10-29 22:47:38 +01:00
nils 22f4d42c19 fixes 2025-10-29 22:46:44 +01:00
nils 4a116e013c fix 2025-10-29 22:38:07 +01:00
nils 5fe0da1a65 tenants 2025-10-29 22:27:32 +01:00
nils 7a6ac5f0be fix 2025-10-29 22:19:54 +01:00
nils 15f79d3d8b no hover 2025-10-29 22:14:18 +01:00
nils 05914b147d fix 2025-10-29 22:08:45 +01:00
nils abc0116945 linting, tenant slug -> name 2025-10-29 18:26:59 +01:00
nils b6bcb72165 linting 2025-10-29 18:03:21 +01:00
nils ca30b20873 feat(auth): add WebAuthn passkey support with migrations, routes, and tests 2025-10-29 17:50:14 +01:00
nils ec6e7f04f2 frontend lint 2025-10-29 17:40:11 +01:00
nils 1cc0aafc1a backend signup 2025-10-29 15:09:16 +01:00
nils abe3fc777c fix 2025-10-29 12:32:42 +01:00
nils 6354f7e189 quickwit 2025-10-29 12:08:33 +01:00
nils 3731b90f6c frontend 2025-10-29 12:08:01 +01:00
nils 433485bcf0 cleanup 2025-10-29 12:07:40 +01:00
nils f1f2dee701 frontend 2025-10-29 12:02:24 +01:00
nils 2a17f2d333 backend dry 2025-10-29 11:52:19 +01:00
nils 788858f0c1 worker: ocr upload as utf8 2025-10-29 11:16:22 +01:00
nils ee8955574f error handling 2025-10-29 11:14:19 +01:00
nils ac27a9ab36 tests 2025-10-29 10:46:21 +01:00
nils 2de3dc30bf frontend 2025-10-29 10:42:44 +01:00
nils 4d30c4c58f label 2025-10-29 10:24:18 +01:00
nils 0052dff119 serve api on same origin at /api 2025-10-29 01:52:48 +01:00
nils 3bdca88144 stuff 2025-10-29 00:53:59 +01:00
nils 4b18429945 work 2025-10-29 00:40:13 +01:00
nils d9b2297eca backend: webdav tokens 2025-10-29 00:12:58 +01:00
nils b4282c2b4b frontend cleanup 2025-10-29 00:02:44 +01:00
nils 8554fbd6e5 frontend 2025-10-28 23:32:46 +01:00
nils 550bb7f0eb fix 2025-10-28 13:53:29 +01:00
nils cc37fdde9a fix 2025-10-28 13:08:44 +01:00
nils e351638e52 frontend 2025-10-28 12:34:43 +01:00
nils 90a642c1c5 desktop view 2025-10-28 12:17:20 +01:00
nils e150b81190 rename skeuo to desktop 2025-10-28 10:41:56 +01:00
nils 94578475ea appconfig 2025-10-28 02:52:41 +01:00
nils 92c491b742 auth tests 2025-10-28 02:27:10 +01:00
nils eb93a2131f refactor 2025-10-28 02:05:38 +01:00
nils 1e177580c9 stuff 2025-10-28 01:38:19 +01:00
nils 7df9a6415a refactor 2025-10-28 01:18:35 +01:00
nils 1179f4fcd4 frontend tweaks, preview workspace 2025-10-28 00:43:54 +01:00
nils 709d32050e backend tracing 2025-10-28 00:30:45 +01:00
nils b9d84fa72b remove download endpoint 2025-10-28 00:17:57 +01:00
nils 7abc10acde backend 2025-10-28 00:01:40 +01:00
nils 9be0e3b5c4 frontend 2025-10-28 00:01:16 +01:00
nils ed0f0fb759 backend 2025-10-27 22:45:16 +01:00
nils 2d80515ad5 backend 2025-10-27 22:25:43 +01:00
nils 50125f4659 document updates 2025-10-27 22:06:33 +01:00
nils a6da34740f drag thumbs 2025-10-27 21:20:54 +01:00
nils 7ed06ccdcf drag 2025-10-27 21:06:29 +01:00
nils db07e0debb increase icon size in grid view 2025-10-27 20:55:29 +01:00
nils a0be094cbd table 2025-10-27 20:53:51 +01:00
nils 7366d2e16b frontend: tags 2025-10-27 20:29:21 +01:00
nils 82aa8948cf tenants 2025-10-27 11:32:22 +01:00
nils 84a3a9a5b5 skeuo aspect ratio 2025-10-27 01:55:37 +01:00
nils b260065245 frontend: folders 2025-10-27 01:31:06 +01:00
nils 264316eb29 Merge remote-tracking branch 'origin/frontend'
ci / docker (backend, backend/Dockerfile, backend) (push) Successful in 18m23s
ci / docker (frontend, frontend/Dockerfile, frontend) (push) Successful in 18m2s
2025-10-27 00:22:00 +01:00
nils c625c80958 folders
ci / docker (backend, backend/Dockerfile, backend) (push) Has been cancelled
ci / docker (frontend, frontend/Dockerfile, frontend) (push) Has been cancelled
2025-10-27 00:20:53 +01:00
nils 6a04d29eed metadata 2025-10-27 00:17:15 +01:00
nils 10e28cc5e2 previewzoomoverlay 2025-10-27 00:14:10 +01:00
nils 970678986a sidebar shadow 2025-10-26 23:15:12 +01:00
nils 40e45f3a01 previewzoomoverlay 2025-10-26 21:01:48 +01:00
nils 518b79bec6 stuff 2025-10-26 20:29:43 +01:00
nils 01fb4e308a cleanup 2025-10-26 19:46:32 +01:00
nils b6300ffa30 panel actions 2025-10-26 19:14:35 +01:00
nils b163a07e84 lazy-thumbnails 2025-10-26 10:36:47 +01:00
nils 157f03b655 Skip uploads of existing documents and support checksum preflight in importer 2025-10-26 02:56:35 +02:00
nils 42a3a53314 panning 2025-10-26 02:52:26 +02:00
nils 9b7ca3d692 409 on duplicate 2025-10-26 02:33:30 +02:00
nils e51a59a829 upload with more data 2025-10-26 02:28:02 +02:00
nils 4a06fa82eb cache warming 2025-10-26 02:19:19 +02:00
nils 80238bb7a1 zoom 2025-10-26 02:05:31 +02:00
nils dcbd46531e preview zoom 2025-10-26 01:45:37 +02:00
nils 0864b39336 panel actions 2025-10-26 01:04:21 +02:00
nils 2a0c96bb4c detail panel 2025-10-26 00:28:40 +02:00
nils c53213a357 cleanup 2025-10-25 23:49:51 +02:00
nils 62cadbcfa0 openapi 2025-10-25 23:46:01 +02:00
nils d7aefc4110 k8s: disable index creation job 2025-10-25 13:57:56 +02:00
nils e972a8dddb fix folder move to root 2025-10-25 02:11:05 +02:00
nils dc633783e0 frontend 2025-10-24 15:13:37 +02:00
nils e33ab71fac move to sidebar 2025-10-24 14:07:09 +02:00
nils 88b9375a4e foo 2025-10-24 12:55:21 +02:00
400 changed files with 62504 additions and 20734 deletions
-53
View File
@@ -1,53 +0,0 @@
name: ci
on:
push:
branches:
- main
- staging
tags:
- '*'
jobs:
docker:
runs-on: ubuntu-latest
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: Login to Docker Registry
uses: docker/login-action@v2
with:
registry: ${{ vars.REGISTRY_URL }}
username: ${{ vars.REGISTRY_USER }}
password: ${{ secrets.REGISTRY_PASSWORD }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
with:
driver: remote
endpoint: ${{ env.BUILDKIT_ARM64_ENDPOINT }}
- name: Build and Push ${{ matrix.service }} Image
uses: docker/build-push-action@v6
with:
context: ${{ matrix.context }}
file: ${{ matrix.dockerfile }}
platforms: linux/arm64
push: true
provenance: false
tags: |
${{ vars.REGISTRY_URL }}/${{ gitea.repository }}-${{ matrix.service }}:${{ gitea.ref_type == 'tag' && gitea.ref_name || (gitea.ref_name == 'main' && 'latest' || gitea.ref_name) }}
${{ vars.REGISTRY_URL }}/${{ gitea.repository }}-${{ matrix.service }}:${{ gitea.sha }}
+146
View File
@@ -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
+107
View File
@@ -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:<TAG>
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.
+36 -61
View File
@@ -1,73 +1,48 @@
# Papercrate # Papercrate
## Local Development ![Papercrate document workspace](docs/screenshot.png)
Use the provided `papercrate.tmux` to spin up the full stack in one tmux session: ## 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 ```bash
tmux -f papercrate.tmux attach 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
``` ```
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. **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.
## Backend Integration Tests 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://<host>: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`.
Integration tests require a running Postgres instance (and, optionally, Quickwit for OCR indexing). The repository includes a lightweight compose file for local runs: ## Screenshots
```bash ![Document detail view](docs/detailview.png)
docker compose -f docker-compose.test.yml up -d ![Desk overview](docs/deskview.png)
export TEST_DATABASE_URL=postgres://papercrate:papercrate_test@localhost:5433/papercrate_test ![Document view](docs/documentview.png)
# 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 For development workflows (local stack, integration tests, migrations, and
docker compose -f docker-compose.test.yml down configuration details) see [DEVELOPMENT.md](./DEVELOPMENT.md).
```
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 (see `backend/.env` for local defaults). 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.
On startup each binary logs the effective configuration with secrets redacted (for example, the database password is masked). This makes it easier to confirm the 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:<TAG>
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.
+1518 -1134
View File
File diff suppressed because it is too large Load Diff
+55 -19
View File
@@ -1,30 +1,30 @@
[package] [package]
name = "backend" name = "papercrate"
version = "0.1.0" version = "0.1.0"
edition = "2021" edition = "2021"
[dependencies] [dependencies]
# Web framework # Web framework
axum = { version = "0.7", features = ["multipart"] } axum = { version = "0.8", features = ["multipart"] }
tokio = { version = "1", features = ["full"] } tokio = { version = "1.48", features = ["full"] }
tower = { version = "0.4", features = ["make", "util"] } tower = { version = "0.5", features = ["make", "util"] }
tower-http = { version = "0.6", features = ["cors", "trace"] } tower-http = { version = "0.6", features = ["cors", "trace"] }
axum-extra = { version = "0.9", features = ["typed-header"] } axum-extra = { version = "0.12", features = ["typed-header"] }
# Database # Database
diesel = { version = "2.1", features = ["postgres", "uuid", "chrono", "serde_json", "r2d2"] } diesel = { version = "2.3.3", features = ["postgres", "uuid", "chrono", "serde_json", "r2d2"] }
diesel_migrations = "2.1" diesel_migrations = "2.1"
uuid = { version = "1.6", features = ["v4", "serde"] } uuid = { version = "1.6", features = ["v4", "serde"] }
chrono = { version = "0.4", features = ["serde"] } chrono = { version = "0.4", features = ["serde"] }
# S3 # S3
aws-config = "1.1" rust-s3 = { version = "0.37", features = ["with-tokio", "tokio-rustls-tls"] }
aws-sdk-s3 = "1.14"
aws-credential-types = "1.2"
# Serialization # Serialization
serde = { version = "1.0", features = ["derive"] } serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0" serde_json = "1.0"
envy = "0.4"
serde-aux = "4.4"
# Utilities # Utilities
tracing = "0.1" tracing = "0.1"
@@ -32,32 +32,68 @@ tracing-subscriber = { version = "0.3", features = ["env-filter"] }
dotenv = "0.15" dotenv = "0.15"
sha2 = "0.10" sha2 = "0.10"
hex = "0.4" hex = "0.4"
hmac = "0.12"
bytes = "1.5" bytes = "1.5"
async-trait = "0.1" async-trait = "0.1"
image = { version = "0.25", default-features = false, features = ["png", "jpeg"] } image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp"] }
pdfium-render = "0.8" pdfium-render = "0.8.36"
mime_guess = "2.0" mime_guess = "2.0"
tempfile = "3.10" tempfile = "3.10"
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls", "stream"] } reqwest = { version = "0.12.24", default-features = false, features = ["json", "rustls-tls", "stream"] }
percent-encoding = "2.3" percent-encoding = "2.3"
base64 = "0.21" base64 = "0.22"
quick-xml = "0.32" quick-xml = "0.38"
futures-util = "0.3" futures-util = "0.3"
url = "2.5" url = "2.5"
once_cell = "1.19" 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 # Error handling
thiserror = "1.0" thiserror = "2.0"
anyhow = "1.0" anyhow = "1.0"
# Authentication & security # Authentication & security
argon2 = "0.5" argon2 = "0.5"
jsonwebtoken = "9" 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 # Misc
rand = "0.8" rand = "0.9"
hyper = "1.2"
http-body-util = "0.1"
chrono-tz = "0.8"
[dev-dependencies] [dev-dependencies]
once_cell = "1.19" once_cell = "1.19"
hyper = "1.2" webauthn-rs-core = "0.5"
http-body-util = "0.1" 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"
+102 -24
View File
@@ -1,29 +1,114 @@
# syntax=docker/dockerfile:1 # syntax=docker/dockerfile:1.7
FROM rust:1-slim AS builder ARG RUST_VERSION=1
FROM --platform=$BUILDPLATFORM rust:${RUST_VERSION}-slim AS builder
ARG TARGETARCH
ENV TARGETARCH=${TARGETARCH}
WORKDIR /app WORKDIR /app
RUN apt-get update \ RUN cat <<'SCRIPT' >/usr/local/bin/resolve-target.sh
&& apt-get install -y --no-install-recommends \ #!/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 \ build-essential \
pkg-config \ pkg-config \
curl \
libssl-dev \ libssl-dev \
libpq-dev \ libpq-dev \
libjpeg-dev \ libjpeg-dev \
libpng-dev \ libpng-dev \
curl \ zlib1g-dev; \
&& rm -rf /var/lib/apt/lists/* 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 ./ COPY Cargo.toml Cargo.lock build.rs ./
COPY src ./src COPY src ./src
COPY migrations ./migrations COPY migrations ./migrations
COPY tests ./tests COPY tests ./tests
COPY resources ./resources
COPY diesel.toml ./ COPY diesel.toml ./
RUN cargo build --release --bin backend --bin worker --bin webdav --bin admin RUN set -eux; \
RUN cargo install diesel_cli --no-default-features --features postgres 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
FROM debian:trixie-slim AS runtime 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 WORKDIR /app
RUN apt-get update \ RUN apt-get update \
@@ -38,25 +123,18 @@ RUN apt-get update \
tesseract-ocr \ tesseract-ocr \
ghostscript \ ghostscript \
qpdf \ qpdf \
ffmpeg \
&& rm -rf /var/lib/apt/lists/* \ && rm -rf /var/lib/apt/lists/* \
&& mkdir -p /usr/local/lib \ && mkdir -p /usr/local/lib \
&& curl -fsSL https://github.com/bblanchon/pdfium-binaries/releases/latest/download/pdfium-linux-arm64.tgz -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}" ] \
&& mv "${pdfium_so}" /usr/local/lib/libpdfium.so \
&& ldconfig \
&& rm -rf /tmp/pdfium.tgz /tmp/pdfium \
&& useradd --system --create-home --uid 10001 appuser && useradd --system --create-home --uid 10001 appuser
COPY --from=builder /app/target/release/backend /usr/local/bin/papercrate-backend COPY --from=builder /artifacts/backend /usr/local/bin/papercrate-backend
COPY --from=builder /app/target/release/worker /usr/local/bin/papercrate-worker COPY --from=builder /artifacts/worker /usr/local/bin/papercrate-worker
COPY --from=builder /app/target/release/webdav /usr/local/bin/papercrate-webdav COPY --from=builder /artifacts/webdav /usr/local/bin/papercrate-webdav
COPY --from=builder /app/target/release/admin /usr/local/bin/papercrate-admin COPY --from=builder /artifacts/admin /usr/local/bin/papercrate-admin
COPY --from=builder /usr/local/cargo/bin/diesel /usr/local/bin/diesel COPY --from=builder /artifacts/libpdfium.so /usr/local/lib/libpdfium.so
RUN ldconfig
COPY migrations ./migrations COPY migrations ./migrations
COPY diesel.toml ./
ENV RUST_LOG=info ENV RUST_LOG=info
USER appuser USER appuser
+123
View File
@@ -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<CaseName>,
}
#[derive(Deserialize)]
struct CaseName {
name: String,
}
#[derive(Deserialize)]
struct MonthSuite {
months: Vec<MonthDefinition>,
}
#[derive(Deserialize)]
struct MonthDefinition {
name: String,
month: u32,
#[serde(default)]
locales: Vec<String>,
}
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<String, Box<dyn std::error::Error>> {
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<String, Box<dyn std::error::Error>> {
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::<Vec<_>>()
.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<dyn std::error::Error>> {
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(())
}
@@ -1,8 +1,10 @@
DROP TRIGGER IF EXISTS trg_jobs_updated_at ON jobs; DROP TRIGGER IF EXISTS trg_jobs_updated_at ON jobs;
DROP FUNCTION IF EXISTS touch_jobs_updated_at(); 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; ALTER TABLE documents DROP CONSTRAINT IF EXISTS documents_current_version_fk;
DROP TABLE IF EXISTS document_asset_objects; DROP TABLE IF EXISTS document_asset_objects;
DROP TABLE IF EXISTS document_assets; DROP TABLE IF EXISTS document_assets;
DROP TABLE IF EXISTS document_versions; DROP TABLE IF EXISTS document_versions;
@@ -18,4 +20,6 @@ DROP TABLE IF EXISTS user_memberships;
DROP TABLE IF EXISTS users; DROP TABLE IF EXISTS users;
DROP TABLE IF EXISTS tenants; DROP TABLE IF EXISTS tenants;
DROP TYPE IF EXISTS tenant_status;
DROP EXTENSION IF EXISTS "pgcrypto"; DROP EXTENSION IF EXISTS "pgcrypto";
@@ -1,14 +1,17 @@
CREATE EXTENSION IF NOT EXISTS "pgcrypto"; CREATE EXTENSION IF NOT EXISTS "pgcrypto";
CREATE TYPE tenant_status AS ENUM ('creating', 'active', 'suspended', 'deleting', 'error');
CREATE TABLE tenants ( CREATE TABLE tenants (
id UUID PRIMARY KEY, id UUID PRIMARY KEY,
slug TEXT NOT NULL UNIQUE, name TEXT NOT NULL,
storage_root TEXT, storage_root TEXT,
quickwit_index TEXT, quickwit_index TEXT,
status TEXT NOT NULL DEFAULT 'active',
config JSONB NOT NULL DEFAULT '{}'::jsonb, config JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_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 CREATE UNIQUE INDEX tenants_storage_root_unique
@@ -22,7 +25,6 @@ CREATE UNIQUE INDEX tenants_quickwit_index_unique
CREATE TABLE users ( CREATE TABLE users (
id UUID PRIMARY KEY, id UUID PRIMARY KEY,
username VARCHAR(100) NOT NULL UNIQUE, username VARCHAR(100) NOT NULL UNIQUE,
password_hash VARCHAR(255) NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
); );
@@ -31,14 +33,13 @@ CREATE TABLE user_memberships (
id UUID PRIMARY KEY, id UUID PRIMARY KEY,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
role TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (user_id, tenant_id) UNIQUE (user_id, tenant_id)
); );
CREATE INDEX user_memberships_tenant_id_idx ON user_memberships (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 INDEX user_memberships_user_id_idx ON user_memberships(user_id);
CREATE TABLE folders ( CREATE TABLE folders (
id UUID PRIMARY KEY, id UUID PRIMARY KEY,
@@ -51,8 +52,12 @@ CREATE TABLE folders (
CREATE INDEX idx_folders_parent ON folders(parent_id); CREATE INDEX idx_folders_parent ON folders(parent_id);
CREATE INDEX folders_tenant_id_idx ON folders(tenant_id); CREATE INDEX folders_tenant_id_idx ON folders(tenant_id);
CREATE UNIQUE INDEX folders_parent_name_unique_idx CREATE UNIQUE INDEX folders_tenant_parent_name_unique_idx
ON folders (COALESCE(parent_id, '00000000-0000-0000-0000-000000000000'::uuid), name); ON folders (
tenant_id,
COALESCE(parent_id, '00000000-0000-0000-0000-000000000000'::uuid),
name
);
CREATE TABLE documents ( CREATE TABLE documents (
id UUID PRIMARY KEY, id UUID PRIMARY KEY,
@@ -70,11 +75,10 @@ CREATE TABLE documents (
tenant_id UUID NOT NULL REFERENCES tenants(id) tenant_id UUID NOT NULL REFERENCES tenants(id)
); );
CREATE INDEX idx_documents_folder ON documents (folder_id); CREATE INDEX idx_documents_folder ON documents(folder_id);
CREATE INDEX idx_documents_deleted_at ON documents (deleted_at); CREATE INDEX idx_documents_deleted_at ON documents(deleted_at);
CREATE INDEX documents_tenant_id_idx ON documents (tenant_id); 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_current_version_id ON documents(current_version_id);
CREATE INDEX idx_documents_folder_title CREATE INDEX idx_documents_folder_title
ON documents ( ON documents (
COALESCE(folder_id, '00000000-0000-0000-0000-000000000000'::uuid), COALESCE(folder_id, '00000000-0000-0000-0000-000000000000'::uuid),
@@ -82,8 +86,9 @@ CREATE INDEX idx_documents_folder_title
) )
WHERE deleted_at IS NULL; WHERE deleted_at IS NULL;
CREATE UNIQUE INDEX documents_unique_folder_filename CREATE UNIQUE INDEX documents_tenant_folder_filename_unique
ON documents ( ON documents (
tenant_id,
COALESCE(folder_id, '00000000-0000-0000-0000-000000000000'::uuid), COALESCE(folder_id, '00000000-0000-0000-0000-000000000000'::uuid),
filename filename
) )
@@ -97,14 +102,13 @@ CREATE TABLE document_versions (
size_bytes BIGINT NOT NULL, size_bytes BIGINT NOT NULL,
checksum VARCHAR(64) NOT NULL, checksum VARCHAR(64) NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
operations_summary JSONB NOT NULL DEFAULT '{}'::jsonb,
metadata JSONB NOT NULL DEFAULT '{}'::jsonb, metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
tenant_id UUID NOT NULL REFERENCES tenants(id), tenant_id UUID NOT NULL REFERENCES tenants(id),
CONSTRAINT document_versions_unique_version UNIQUE (document_id, version_number) CONSTRAINT document_versions_unique_version UNIQUE (document_id, version_number)
); );
CREATE INDEX idx_document_versions_document ON document_versions (document_id); CREATE INDEX idx_document_versions_document ON document_versions(document_id);
CREATE INDEX document_versions_tenant_id_idx ON document_versions (tenant_id); CREATE INDEX document_versions_tenant_id_idx ON document_versions(tenant_id);
ALTER TABLE documents ALTER TABLE documents
ADD CONSTRAINT documents_current_version_fk ADD CONSTRAINT documents_current_version_fk
@@ -114,13 +118,14 @@ ALTER TABLE documents
CREATE TABLE tags ( CREATE TABLE tags (
id UUID PRIMARY KEY, id UUID PRIMARY KEY,
label VARCHAR(100) NOT NULL UNIQUE, label VARCHAR(100) NOT NULL,
color VARCHAR(7), color VARCHAR(7),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
tenant_id UUID NOT NULL REFERENCES tenants(id) tenant_id UUID NOT NULL REFERENCES tenants(id)
); );
CREATE INDEX tags_tenant_id_idx ON tags (tenant_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 ( CREATE TABLE document_tags (
document_id UUID NOT NULL REFERENCES documents(id) ON DELETE CASCADE, document_id UUID NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
@@ -131,8 +136,8 @@ CREATE TABLE document_tags (
PRIMARY KEY (document_id, tag_id) PRIMARY KEY (document_id, tag_id)
); );
CREATE INDEX idx_document_tags_tag ON document_tags (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 INDEX document_tags_tenant_id_idx ON document_tags(tenant_id);
CREATE TABLE correspondents ( CREATE TABLE correspondents (
id UUID PRIMARY KEY, id UUID PRIMARY KEY,
@@ -140,27 +145,25 @@ CREATE TABLE correspondents (
metadata JSONB NOT NULL DEFAULT '{}'::jsonb, metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
tenant_id UUID NOT NULL REFERENCES tenants(id), tenant_id UUID NOT NULL REFERENCES tenants(id)
CONSTRAINT correspondents_name_unique UNIQUE (name)
); );
CREATE INDEX correspondents_tenant_id_idx ON correspondents (tenant_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 ( CREATE TABLE document_correspondents (
document_id UUID NOT NULL REFERENCES documents(id) ON DELETE CASCADE, document_id UUID NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
correspondent_id UUID NOT NULL REFERENCES correspondents(id) ON DELETE CASCADE, correspondent_id UUID NOT NULL REFERENCES correspondents(id) ON DELETE CASCADE,
role VARCHAR(32) NOT NULL,
assigned_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), assigned_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
assigned_by UUID REFERENCES users(id), assigned_by UUID REFERENCES users(id),
tenant_id UUID NOT NULL REFERENCES tenants(id), tenant_id UUID NOT NULL REFERENCES tenants(id),
PRIMARY KEY (document_id, correspondent_id, role), PRIMARY KEY (document_id, correspondent_id)
CONSTRAINT document_correspondents_role_check CHECK (role IN ('sender', 'receiver', 'other'))
); );
CREATE INDEX idx_document_correspondents_document ON document_correspondents (document_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 idx_document_correspondents_correspondent ON document_correspondents(correspondent_id);
CREATE INDEX idx_document_correspondents_role ON document_correspondents (role); CREATE INDEX document_correspondents_tenant_id_idx ON document_correspondents(tenant_id);
CREATE INDEX document_correspondents_tenant_id_idx ON document_correspondents (tenant_id);
CREATE TABLE document_assets ( CREATE TABLE document_assets (
id UUID PRIMARY KEY, id UUID PRIMARY KEY,
@@ -175,9 +178,9 @@ CREATE TABLE document_assets (
CONSTRAINT document_assets_cardinality_positive CHECK (cardinality IS NULL OR cardinality >= 1) 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_version ON document_assets(document_version_id);
CREATE INDEX idx_document_assets_type ON document_assets (asset_type); CREATE INDEX idx_document_assets_type ON document_assets(asset_type);
CREATE INDEX document_assets_tenant_id_idx ON document_assets (tenant_id); CREATE INDEX document_assets_tenant_id_idx ON document_assets(tenant_id);
CREATE TABLE document_asset_objects ( CREATE TABLE document_asset_objects (
id UUID PRIMARY KEY, id UUID PRIMARY KEY,
@@ -191,10 +194,8 @@ CREATE TABLE document_asset_objects (
); );
CREATE INDEX idx_document_asset_objects_asset_ordinal CREATE INDEX idx_document_asset_objects_asset_ordinal
ON document_asset_objects (asset_id, ordinal); ON document_asset_objects(asset_id, ordinal);
CREATE INDEX document_asset_objects_tenant_id_idx ON document_asset_objects(tenant_id);
CREATE INDEX document_asset_objects_tenant_id_idx
ON document_asset_objects (tenant_id);
CREATE TABLE jobs ( CREATE TABLE jobs (
id UUID PRIMARY KEY, id UUID PRIMARY KEY,
@@ -210,9 +211,9 @@ CREATE TABLE jobs (
CONSTRAINT jobs_status_check CHECK (status IN ('queued', 'processing', 'succeeded', 'failed')) 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_status_run_after ON jobs(status, run_after);
CREATE INDEX idx_jobs_job_type ON jobs (job_type); CREATE INDEX idx_jobs_job_type ON jobs(job_type);
CREATE INDEX jobs_tenant_id_idx ON jobs (tenant_id); CREATE INDEX jobs_tenant_id_idx ON jobs(tenant_id);
CREATE OR REPLACE FUNCTION touch_jobs_updated_at() CREATE OR REPLACE FUNCTION touch_jobs_updated_at()
RETURNS TRIGGER AS $$ RETURNS TRIGGER AS $$
@@ -239,6 +240,56 @@ CREATE TABLE refresh_tokens (
tenant_id UUID NOT NULL REFERENCES tenants(id) 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_user_id ON refresh_tokens(user_id);
CREATE INDEX idx_refresh_tokens_token_hash ON refresh_tokens (token_hash); 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 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);
@@ -0,0 +1,2 @@
ALTER TABLE documents
RENAME COLUMN created_at TO uploaded_at;
@@ -0,0 +1,2 @@
ALTER TABLE documents
RENAME COLUMN uploaded_at TO created_at;
@@ -0,0 +1,2 @@
DROP TABLE magic_tokens;
DROP TYPE magic_token_kind;
@@ -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);
@@ -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;
@@ -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
$$;
@@ -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();
@@ -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);
@@ -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();
@@ -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());
@@ -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());
@@ -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());
@@ -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();
@@ -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();
@@ -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;
@@ -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;
@@ -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;
@@ -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;
@@ -0,0 +1 @@
DROP INDEX IF EXISTS shared.jobs_purge_document_pending_unique;
@@ -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');
@@ -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;
@@ -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;
@@ -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;
@@ -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;
@@ -0,0 +1,4 @@
-- diesel:run_in_transaction = false
-- Enum values cannot be removed safely; this down migration intentionally left empty.
SELECT 1;
@@ -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 $$;
@@ -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');
@@ -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;
@@ -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;
@@ -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;
@@ -0,0 +1,3 @@
-- Revert column rename.
ALTER TABLE tenant.documents
RENAME COLUMN mime_type TO content_type;
@@ -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;
@@ -0,0 +1,3 @@
UPDATE tenant.document_assets
SET asset_type = 'ocr-text'
WHERE asset_type = 'text-content';
@@ -0,0 +1,3 @@
UPDATE tenant.document_assets
SET asset_type = 'text-content'
WHERE asset_type = 'ocr-text';
@@ -0,0 +1 @@
DROP INDEX IF EXISTS tenant.idx_documents_title_trgm;
@@ -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;
@@ -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;
+648
View File
@@ -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"
+324
View File
@@ -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<String>,
expires_at: Option<NaiveDateTime>,
capability_set_id: Uuid,
) -> Result<IssuedApiToken, AppError> {
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::<ApiToken>(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<Uuid>,
) -> Result<Vec<ApiToken>, 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::<ApiToken>(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<Uuid>,
) -> Result<IssuedApiToken, AppError> {
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::<Option<NaiveDateTime>>(None),
))
.get_result::<ApiToken>(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<Uuid>,
secret: &str,
required_capability: Option<ApiCapability>,
) -> Result<Option<ApiToken>, 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::<ApiToken>(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<bool, AppError> {
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<Uuid>,
) -> Result<ApiToken, AppError> {
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::<ApiToken>(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<CapabilitySet, AppError> {
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<T, F>(
conn: &mut PgPooledConnection,
prefix: &str,
operation: F,
) -> Result<T, AppError>
where
F: FnOnce(&mut PgPooledConnection) -> Result<T, AppError>,
{
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<String, AppError> {
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<String, AppError> {
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);
}
}
+120
View File
@@ -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<Vec<ApiCapability>>,
strategy: CapabilityStrategy,
}
impl RequireCapabilitiesLayer {
pub fn all<I>(caps: I) -> Self
where
I: IntoIterator<Item = ApiCapability>,
{
Self {
required: Arc::new(caps.into_iter().collect()),
strategy: CapabilityStrategy::All,
}
}
pub fn any<I>(caps: I) -> Self
where
I: IntoIterator<Item = ApiCapability>,
{
Self {
required: Arc::new(caps.into_iter().collect()),
strategy: CapabilityStrategy::Any,
}
}
}
impl<S> Layer<S> for RequireCapabilitiesLayer {
type Service = RequireCapabilities<S>;
fn layer(&self, inner: S) -> Self::Service {
RequireCapabilities {
inner,
required: Arc::clone(&self.required),
strategy: self.strategy,
}
}
}
#[derive(Clone)]
pub struct RequireCapabilities<S> {
inner: S,
required: Arc<Vec<ApiCapability>>,
strategy: CapabilityStrategy,
}
impl<S, B> Service<Request<B>> for RequireCapabilities<S>
where
S: Service<Request<B>, 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<dyn std::future::Future<Output = Result<Self::Response, Self::Error>> + Send>,
>;
fn poll_ready(
&mut self,
cx: &mut std::task::Context<'_>,
) -> std::task::Poll<Result<(), Self::Error>> {
self.inner.poll_ready(cx)
}
fn call(&mut self, req: Request<B>) -> 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::<AuthenticatedUser>() {
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 })
}
}
+323
View File
@@ -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<C>(
conn: &mut C,
tenant_id: Uuid,
slug: &str,
capabilities: Vec<ApiCapability>,
) -> Result<CapabilitySet, AppError>
where
C: Connection<Backend = Pg> + diesel::connection::LoadConnection,
{
let normalized = normalize_capabilities(capabilities)?;
conn.transaction::<CapabilitySet, AppError, _>(|conn| {
if cs_dsl::capability_sets
.filter(cs_dsl::tenant_id.eq(tenant_id))
.filter(cs_dsl::slug.eq(slug))
.first::<CapabilitySet>(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::<CapabilitySet>(conn)
.map_err(AppError::from)
})
}
pub fn normalize_capabilities(
mut capabilities: Vec<ApiCapability>,
) -> Result<Vec<ApiCapability>, 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<C>(
conn: &mut C,
capability_set_id: Uuid,
) -> Result<Vec<ApiCapability>, AppError>
where
C: Connection<Backend = Pg> + diesel::connection::LoadConnection,
{
let mut capabilities: Vec<ApiCapability> = 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<C>(
conn: &mut C,
tenant_id: Uuid,
capabilities: &[ApiCapability],
) -> Result<CapabilitySet, AppError>
where
C: Connection<Backend = Pg> + 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::<CapabilitySet, AppError, _>(|conn| {
if let Some(existing) = cs_dsl::capability_sets
.filter(cs_dsl::tenant_id.eq(tenant_id))
.filter(cs_dsl::slug.eq(&slug))
.first::<CapabilitySet>(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::<CapabilitySet>(conn)
.map_err(AppError::from)?)
})
}
pub fn refresh_capability_set<C>(
conn: &mut C,
set: &CapabilitySet,
capabilities: &[ApiCapability],
) -> Result<CapabilitySet, AppError>
where
C: Connection<Backend = Pg> + diesel::connection::LoadConnection,
{
conn.transaction::<CapabilitySet, AppError, _>(|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::<CapabilitySet>(conn)
.map_err(AppError::from)
})
}
pub fn load_capability_set<C>(conn: &mut C, id: Uuid) -> Result<CapabilitySet, AppError>
where
C: Connection<Backend = Pg> + diesel::connection::LoadConnection,
{
capability_sets::table
.find(id)
.first::<CapabilitySet>(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::<Vec<_>>()
.join(",");
let digest = Sha256::digest(joined.as_bytes());
let hex = hex::encode(digest);
format!("caps-{}", &hex[..12])
}
fn ensure_capability_membership<C>(
conn: &mut C,
set: &CapabilitySet,
desired: &[ApiCapability],
) -> Result<(), AppError>
where
C: Connection<Backend = Pg> + 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<C>(
conn: &mut C,
set_id: Uuid,
capabilities: &[ApiCapability],
) -> Result<(), AppError>
where
C: Connection<Backend = Pg> + diesel::connection::LoadConnection,
{
if capabilities.is_empty() {
return Err(AppError::bad_request("at least one capability is required"));
}
let records: Vec<NewCapabilitySetCapability> = 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(())
}
+113 -7
View File
@@ -2,10 +2,29 @@ use anyhow::Result;
use chrono::{Duration, Utc}; use chrono::{Duration, Utc};
use jsonwebtoken::{decode, encode, DecodingKey, EncodingKey, Header, Validation}; use jsonwebtoken::{decode, encode, DecodingKey, EncodingKey, Header, Validation};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
use uuid::Uuid; use uuid::Uuid;
use crate::config::AppConfig; 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)] #[derive(Clone)]
pub struct JwtService { pub struct JwtService {
encoding: EncodingKey, encoding: EncodingKey,
@@ -17,30 +36,40 @@ pub struct JwtService {
download_expiry: Duration, download_expiry: Duration,
selector_audience: String, selector_audience: String,
selector_expiry: Duration, selector_expiry: Duration,
signup_audience: String,
signup_expiry: Duration,
} }
impl JwtService { impl JwtService {
pub fn from_config(config: &AppConfig) -> Result<Self> { pub fn from_config(config: &AppConfig) -> Result<Self> {
let access_expiry = Duration::minutes(config.jwt_expiry_minutes);
Ok(Self { Ok(Self {
encoding: EncodingKey::from_secret(config.jwt_secret.as_bytes()), encoding: EncodingKey::from_secret(config.jwt_secret.as_bytes()),
decoding: DecodingKey::from_secret(config.jwt_secret.as_bytes()), decoding: DecodingKey::from_secret(config.jwt_secret.as_bytes()),
issuer: config.jwt_issuer.clone(), issuer: config.jwt_issuer.clone(),
audience: config.jwt_audience.clone(), audience: config.jwt_audience.clone(),
expiry: Duration::minutes(config.jwt_expiry_minutes), expiry: access_expiry,
download_audience: config.download_token_audience.clone(), download_audience: config.download_token_audience.clone(),
download_expiry: Duration::minutes(config.download_token_expiry_minutes), download_expiry: Duration::minutes(config.download_token_expiry_minutes),
selector_audience: format!("{}:tenant-selector", config.jwt_audience), selector_audience: format!("{}:tenant-selector", config.jwt_audience),
selector_expiry: Duration::minutes(15), selector_expiry: access_expiry,
signup_audience: format!("{}:signup", config.jwt_audience),
signup_expiry: Duration::minutes(15),
}) })
} }
pub fn generate_token(&self, user_id: Uuid, tenant_id: Uuid, username: &str) -> Result<String> { pub fn generate_token(&self, context: AccessTokenContext) -> Result<String> {
let now = Utc::now(); let now = Utc::now();
let exp = now + self.expiry; let exp = now + self.expiry;
let claims = Claims { let claims = Claims {
sub: user_id, sub: context.user_id,
tenant_id, tenant_id: context.tenant_id,
username: username.to_owned(), 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(), iss: self.issuer.clone(),
aud: self.audience.clone(), aud: self.audience.clone(),
iat: now.timestamp() as usize, iat: now.timestamp() as usize,
@@ -61,13 +90,38 @@ impl JwtService {
pub fn generate_download_token( pub fn generate_download_token(
&self, &self,
document_id: Uuid, document_id: Uuid,
version_id: Uuid,
user_id: Uuid, user_id: Uuid,
tenant_id: Uuid, tenant_id: Uuid,
) -> Result<String> { ) -> Result<String> {
let now = Utc::now(); let now = Utc::now();
let exp = now + self.download_expiry; let exp = now + self.download_expiry;
let claims = DownloadClaims { let claims = DownloadClaims {
subject: DownloadSubject::Document {
doc_id: document_id, 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<String> {
let now = Utc::now();
let exp = now + self.download_expiry;
let claims = DownloadClaims {
subject: DownloadSubject::Asset { asset_id },
user_id, user_id,
tenant_id, tenant_id,
iss: self.issuer.clone(), iss: self.issuer.clone(),
@@ -108,6 +162,35 @@ impl JwtService {
let data = decode::<TenantSelectionClaims>(token, &self.decoding, &validation)?; let data = decode::<TenantSelectionClaims>(token, &self.decoding, &validation)?;
Ok(data.claims) Ok(data.claims)
} }
pub fn generate_signup_token(
&self,
user_id: Uuid,
challenge_id: Uuid,
username: String,
) -> Result<String> {
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<SignupClaims> {
let mut validation = Validation::default();
validation.set_audience(&[self.signup_audience.clone()]);
validation.set_issuer(&[self.issuer.clone()]);
let data = decode::<SignupClaims>(token, &self.decoding, &validation)?;
Ok(data.claims)
}
} }
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
@@ -115,15 +198,27 @@ pub struct Claims {
pub sub: Uuid, pub sub: Uuid,
pub tenant_id: Uuid, pub tenant_id: Uuid,
pub username: String, pub username: String,
pub principal_kind: PrincipalKind,
pub principal_id: Uuid,
pub capability_set_id: Uuid,
pub cap_version: i32,
pub iss: String, pub iss: String,
pub aud: String, pub aud: String,
pub iat: usize, pub iat: usize,
pub exp: 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)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DownloadClaims { pub struct DownloadClaims {
pub doc_id: Uuid, #[serde(flatten)]
pub subject: DownloadSubject,
pub user_id: Uuid, pub user_id: Uuid,
pub tenant_id: Uuid, pub tenant_id: Uuid,
pub iss: String, pub iss: String,
@@ -140,3 +235,14 @@ pub struct TenantSelectionClaims {
pub iat: usize, pub iat: usize,
pub exp: 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,
}
+147 -14
View File
@@ -1,38 +1,116 @@
pub mod api_tokens;
pub mod capability_guard;
pub mod capability_sets;
pub mod jwt; pub mod jwt;
pub mod passkeys;
pub mod password; pub mod password;
use axum::{async_trait, extract::FromRequestParts, http::request::Parts}; use std::sync::{Arc, Mutex};
use axum::{
extract::FromRequestParts,
http::{request::Parts, StatusCode},
};
use axum_extra::headers::{authorization::Bearer, Authorization}; use axum_extra::headers::{authorization::Bearer, Authorization};
use axum_extra::TypedHeader; use axum_extra::TypedHeader;
use diesel::{pg::PgConnection, prelude::*};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
use crate::{ use crate::{
error::AppError, 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}, state::{AppState, PgPooledConnection},
}; };
use uuid::Uuid; use uuid::Uuid;
#[derive(Debug, Clone, Serialize, Deserialize)] use crate::auth::jwt::PrincipalKind;
#[derive(Debug, Clone)]
pub struct TenantMembershipUser {
pub user_id: Uuid,
}
impl FromRequestParts<AppState> for TenantMembershipUser {
type Rejection = AppError;
#[allow(refining_impl_trait)]
fn from_request_parts<'a>(
parts: &'a mut Parts,
state: &AppState,
) -> impl std::future::Future<Output = Result<Self, Self::Rejection>> + Send + 'a {
let state = state.clone();
async move {
let TypedHeader(Authorization(bearer)) =
TypedHeader::<Authorization<Bearer>>::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<Mutex<Option<PgPooledConnection>>>,
}
impl TenantConnectionHolder {
pub fn new(conn: PgPooledConnection) -> Self {
Self {
inner: Arc::new(Mutex::new(Some(conn))),
}
}
pub fn into_conn(self) -> Option<PgPooledConnection> {
self.inner.lock().ok()?.take()
}
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct AuthenticatedUser { pub struct AuthenticatedUser {
pub user_id: uuid::Uuid, pub user_id: uuid::Uuid,
pub username: String, pub username: String,
pub tenant_id: uuid::Uuid, 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<ApiCapability>,
} }
#[async_trait]
impl FromRequestParts<AppState> for AuthenticatedUser { impl FromRequestParts<AppState> for AuthenticatedUser {
type Rejection = AppError; type Rejection = AppError;
async fn from_request_parts( #[allow(refining_impl_trait)]
parts: &mut Parts, fn from_request_parts<'a>(
parts: &'a mut Parts,
state: &AppState, state: &AppState,
) -> Result<Self, Self::Rejection> { ) -> impl std::future::Future<Output = Result<Self, Self::Rejection>> + Send + 'a {
let state = state.clone();
async move {
if let Some(user) = parts.extensions.get::<AuthenticatedUser>() { if let Some(user) = parts.extensions.get::<AuthenticatedUser>() {
return Ok(user.clone()); return Ok(user.clone());
} }
let TypedHeader(Authorization(bearer)) = let TypedHeader(Authorization(bearer)) =
TypedHeader::<Authorization<Bearer>>::from_request_parts(parts, state) TypedHeader::<Authorization<Bearer>>::from_request_parts(parts, &state)
.await .await
.map_err(|_| AppError::unauthorized())?; .map_err(|_| AppError::unauthorized())?;
@@ -41,16 +119,36 @@ impl FromRequestParts<AppState> for AuthenticatedUser {
.verify_token(bearer.token()) .verify_token(bearer.token())
.map_err(|_| AppError::unauthorized())?; .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 { let user = AuthenticatedUser {
user_id: claims.sub, user_id: claims.sub,
username: claims.username, username: claims.username,
tenant_id: claims.tenant_id, 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(user.clone());
parts
.extensions
.insert(TenantConnectionHolder::new(tenant_conn));
Ok(user) Ok(user)
} }
}
} }
pub struct TenantScopedConn { pub struct TenantScopedConn {
@@ -66,17 +164,28 @@ impl TenantScopedConn {
} }
} }
#[async_trait]
impl FromRequestParts<AppState> for TenantScopedConn { impl FromRequestParts<AppState> for TenantScopedConn {
type Rejection = AppError; type Rejection = AppError;
async fn from_request_parts( #[allow(refining_impl_trait)]
parts: &mut Parts, fn from_request_parts<'a>(
parts: &'a mut Parts,
state: &AppState, state: &AppState,
) -> Result<Self, Self::Rejection> { ) -> impl std::future::Future<Output = Result<Self, Self::Rejection>> + Send + 'a {
let user = AuthenticatedUser::from_request_parts(parts, state).await?; let state = state.clone();
async move {
let user = AuthenticatedUser::from_request_parts(parts, &state).await?;
let tenant_id = user.tenant_id; let tenant_id = user.tenant_id;
let conn = state.db_for_tenant(tenant_id)?; let mut conn = if let Some(holder) = parts.extensions.remove::<TenantConnectionHolder>()
{
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 { Ok(Self {
conn, conn,
@@ -85,4 +194,28 @@ impl FromRequestParts<AppState> for TenantScopedConn {
user, 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(())
} }
+592
View File
@@ -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<Webauthn>,
challenge_ttl: ChronoDuration,
}
pub struct PreparedPasskey {
pub id: Uuid,
pub credential_id: Vec<u8>,
pub public_key: Vec<u8>,
pub credential: serde_json::Value,
pub sign_count: i64,
pub transports: Vec<Option<String>>,
pub aaguid: Option<Uuid>,
}
impl PreparedPasskey {
pub fn into_new_user_passkey(self, user_id: Uuid, nickname: Option<String>) -> 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<String>,
pub created_at: NaiveDateTime,
pub last_used_at: Option<NaiveDateTime>,
pub transports: Vec<String>,
pub revoked_at: Option<NaiveDateTime>,
pub revoked_reason: Option<String>,
}
impl PasskeyService {
pub fn try_new(config: &AppConfig) -> Result<Option<Self>> {
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<Uuid>,
exclude: Option<Vec<CredentialID>>,
) -> AppResult<RegistrationChallengeResponse> {
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<u8> = 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<RegistrationChallengeResponse> {
let existing: Vec<UserPasskey> = 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<RegistrationChallengeResponse> {
self.begin_registration(conn, user_id, username, None, None)
}
fn complete_registration(
&self,
conn: &mut PgConnection,
challenge_id: Uuid,
credential: &RegisterPublicKeyCredential,
expected_user: Option<Uuid>,
) -> AppResult<PreparedPasskey> {
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<u8> = credential_struct.cred_id.clone().into();
let duplicate = passkey_dsl::user_passkeys
.filter(passkey_dsl::credential_id.eq(&credential_id_vec))
.first::<UserPasskey>(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<Option<String>> = 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<String>,
) -> AppResult<UserPasskey> {
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<AuthenticationChallengeResponse> {
self.prune_expired(conn);
let stored: Vec<UserPasskey> = 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<u8> = 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<Vec<PasskeySummary>> {
let passkeys: Vec<UserPasskey> = 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<i64> {
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<PreparedPasskey> {
self.complete_registration(conn, challenge_id, credential, None)
}
pub fn revoke_passkey(
&self,
conn: &mut PgConnection,
user_id: Uuid,
passkey_id: Uuid,
reason: Option<String>,
) -> 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<u8> = 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<Option<String>> = 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::<User>(conn)?;
Ok((user, passkey, auth_result))
}
}
impl From<UserPasskey> 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<String>,
}
#[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,
}
+13 -1
View File
@@ -1,6 +1,9 @@
use anyhow::{anyhow, Result}; use anyhow::{anyhow, Result};
use argon2::{ use argon2::{
password_hash::{PasswordHash, PasswordVerifier}, password_hash::{
rand_core::OsRng as PasswordHashOsRng, PasswordHash, PasswordHasher, PasswordVerifier,
SaltString,
},
Argon2, Argon2,
}; };
@@ -10,3 +13,12 @@ pub fn verify_password(password: &str, password_hash: &str) -> Result<bool> {
.verify_password(password.as_bytes(), &parsed_hash) .verify_password(password.as_bytes(), &parsed_hash)
.is_ok()) .is_ok())
} }
pub fn hash_password(password: &str) -> Result<String> {
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())
}
+543 -362
View File
File diff suppressed because it is too large Load Diff
+8
View File
@@ -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);
}
+1 -1
View File
@@ -3,7 +3,7 @@ use std::net::SocketAddr;
use tokio::net::TcpListener; use tokio::net::TcpListener;
use tower::make::Shared; use tower::make::Shared;
use backend::{routes::webdav, utils::bootstrap::init_component}; use papercrate::{routes::webdav, utils::bootstrap::init_component};
#[tokio::main] #[tokio::main]
async fn main() -> anyhow::Result<()> { async fn main() -> anyhow::Result<()> {
+1 -1
View File
@@ -2,7 +2,7 @@ use std::time::Duration;
use tokio::signal; use tokio::signal;
use backend::{default_handlers, utils::bootstrap::init_component, Worker}; use papercrate::{default_handlers, utils::bootstrap::init_component, Worker};
#[tokio::main] #[tokio::main]
async fn main() -> anyhow::Result<()> { async fn main() -> anyhow::Result<()> {
+212 -79
View File
@@ -1,36 +1,84 @@
use std::env;
use anyhow::{Context, Result}; use anyhow::{Context, Result};
use url::Url; 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; use crate::db::DEFAULT_MAX_POOL_SIZE;
#[derive(Clone, Debug)] #[derive(Clone, Debug, Deserialize)]
pub struct AppConfig { pub struct AppConfig {
pub database_url: String, pub database_url: String,
#[serde(default)]
pub migrations_database_url: Option<String>,
#[serde(default = "default_database_max_pool_size")]
pub database_max_pool_size: u32, pub database_max_pool_size: u32,
#[serde(default = "default_server_host")]
pub server_host: String, pub server_host: String,
#[serde(default = "default_server_port")]
pub server_port: u16, pub server_port: u16,
#[serde(default = "default_webdav_host")]
pub webdav_host: String, pub webdav_host: String,
#[serde(default = "default_webdav_port")]
pub webdav_port: u16, pub webdav_port: u16,
pub jwt_secret: String, pub jwt_secret: String,
#[serde(default = "default_jwt_issuer")]
pub jwt_issuer: String, pub jwt_issuer: String,
#[serde(default = "default_jwt_audience")]
pub jwt_audience: String, pub jwt_audience: String,
#[serde(default = "default_jwt_expiry_minutes")]
pub jwt_expiry_minutes: i64, pub jwt_expiry_minutes: i64,
#[serde(default = "default_download_token_audience")]
pub download_token_audience: String, pub download_token_audience: String,
#[serde(default = "default_download_token_expiry_minutes")]
pub download_token_expiry_minutes: i64, pub download_token_expiry_minutes: i64,
#[serde(default = "default_refresh_token_expiry_days")]
pub refresh_token_expiry_days: i64, pub refresh_token_expiry_days: i64,
#[serde(
default = "default_refresh_cookie_secure",
deserialize_with = "deserialize_bool_from_anything"
)]
pub refresh_cookie_secure: bool, pub refresh_cookie_secure: bool,
#[serde(default)]
pub refresh_cookie_domain: Option<String>, pub refresh_cookie_domain: Option<String>,
#[serde(default)]
pub cors_allowed_origin: Option<String>, pub cors_allowed_origin: Option<String>,
#[serde(default, deserialize_with = "deserialize_bool_from_anything")]
pub proxy_downloads: bool,
#[serde(default)]
pub aws_endpoint_url: Option<String>, pub aws_endpoint_url: Option<String>,
#[serde(default)]
pub aws_access_key_id: Option<String>, pub aws_access_key_id: Option<String>,
#[serde(default)]
pub aws_secret_access_key: Option<String>, pub aws_secret_access_key: Option<String>,
#[serde(default = "default_aws_region")]
pub aws_region: String, pub aws_region: String,
pub s3_bucket: String, pub s3_bucket: String,
#[serde(default)]
pub quickwit_endpoint: Option<String>, pub quickwit_endpoint: Option<String>,
#[serde(default)]
pub quickwit_index: Option<String>, pub quickwit_index: Option<String>,
pub default_tenant_slug: String, #[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<String>,
#[serde(default, deserialize_with = "deserialize_string_list")]
pub issued_at_date_parser_locales: Vec<String>,
#[serde(default, deserialize_with = "deserialize_string_list")]
pub issued_at_ignore_dates: Vec<String>,
#[serde(default)]
pub webauthn_rp_id: Option<String>,
#[serde(default)]
pub webauthn_origin: Option<String>,
#[serde(default = "default_webauthn_rp_name")]
pub webauthn_rp_name: String,
} }
impl AppConfig { impl AppConfig {
@@ -40,101 +88,186 @@ impl AppConfig {
tracing::info!( tracing::info!(
component, component,
database_url = %config.redacted_database_url(), database_url = %config.redacted_database_url(),
migrations_database_url = %config.redacted_migrations_database_url(),
pool_size = config.database_max_pool_size, pool_size = config.database_max_pool_size,
quickwit_enabled = config.quickwit_endpoint.is_some(), quickwit_enabled = config.quickwit_endpoint.is_some(),
passkeys_enabled = config.webauthn_origin.is_some(),
s3_bucket = %config.s3_bucket, 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" "loaded backend configuration"
); );
Ok(config) Ok(config)
} }
pub fn from_env() -> Result<Self> { pub fn from_env() -> Result<Self> {
let database_url = env::var("DATABASE_URL").context("DATABASE_URL must be set")?; let config: AppConfig = envy::from_env()
let database_max_pool_size = env::var("DATABASE_MAX_POOL_SIZE") .context("failed to parse application configuration from environment")?;
.ok() Ok(config.normalize())
.and_then(|value| value.parse().ok())
.unwrap_or(DEFAULT_MAX_POOL_SIZE);
let server_host = env::var("SERVER_HOST").unwrap_or_else(|_| "127.0.0.1".to_string());
let server_port = env::var("SERVER_PORT")
.unwrap_or_else(|_| "3000".to_string())
.parse()
.context("SERVER_PORT must be a valid u16")?;
let webdav_host = env::var("WEBDAV_HOST").unwrap_or_else(|_| server_host.clone());
let webdav_port = env::var("WEBDAV_PORT")
.unwrap_or_else(|_| "3001".to_string())
.parse()
.context("WEBDAV_PORT must be a valid u16")?;
let jwt_secret = env::var("JWT_SECRET").context("JWT_SECRET must be set")?;
let jwt_issuer = env::var("JWT_ISSUER").unwrap_or_else(|_| "papercrate".to_string());
let jwt_audience =
env::var("JWT_AUDIENCE").unwrap_or_else(|_| "papercrate-clients".to_string());
let jwt_expiry_minutes = env::var("JWT_EXPIRY_MINUTES")
.unwrap_or_else(|_| "60".to_string())
.parse()
.context("JWT_EXPIRY_MINUTES must be an integer")?;
let download_token_audience = env::var("DOWNLOAD_TOKEN_AUDIENCE")
.unwrap_or_else(|_| "papercrate-download".to_string());
let download_token_expiry_minutes = env::var("DOWNLOAD_TOKEN_EXPIRY_MINUTES")
.unwrap_or_else(|_| "60".to_string())
.parse()
.context("DOWNLOAD_TOKEN_EXPIRY_MINUTES must be an integer")?;
let refresh_token_expiry_days = env::var("REFRESH_TOKEN_EXPIRY_DAYS")
.unwrap_or_else(|_| "30".to_string())
.parse()
.context("REFRESH_TOKEN_EXPIRY_DAYS must be an integer")?;
let refresh_cookie_secure = env::var("REFRESH_COOKIE_SECURE")
.map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
.unwrap_or(false);
let refresh_cookie_domain = env::var("REFRESH_COOKIE_DOMAIN").ok();
let cors_allowed_origin = env::var("CORS_ALLOWED_ORIGIN").ok();
let aws_endpoint_url = env::var("AWS_ENDPOINT_URL").ok();
let aws_access_key_id = env::var("AWS_ACCESS_KEY_ID").ok();
let aws_secret_access_key = env::var("AWS_SECRET_ACCESS_KEY").ok();
let aws_region = env::var("AWS_REGION").unwrap_or_else(|_| "us-east-1".to_string());
let s3_bucket = env::var("S3_BUCKET").context("S3_BUCKET must be set")?;
let quickwit_endpoint = env::var("QUICKWIT_ENDPOINT").ok();
let quickwit_index = env::var("QUICKWIT_INDEX").ok();
let default_tenant_slug =
env::var("DEFAULT_TENANT_SLUG").unwrap_or_else(|_| "admin".to_string());
Ok(Self {
database_url,
database_max_pool_size,
server_host,
server_port,
webdav_host,
webdav_port,
jwt_secret,
jwt_issuer,
jwt_audience,
jwt_expiry_minutes,
download_token_audience,
download_token_expiry_minutes,
refresh_token_expiry_days,
refresh_cookie_secure,
refresh_cookie_domain,
cors_allowed_origin,
aws_endpoint_url,
aws_access_key_id,
aws_secret_access_key,
aws_region,
s3_bucket,
quickwit_endpoint,
quickwit_index,
default_tenant_slug,
})
} }
pub fn redacted_database_url(&self) -> String { pub fn redacted_database_url(&self) -> String {
redact_database_url(&self.database_url) 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
}
}
} }
fn redact_database_url(raw: &str) -> String { 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<Vec<String>, D::Error>
where
D: Deserializer<'de>,
{
#[derive(Deserialize)]
#[serde(untagged)]
enum Helper {
List(Vec<String>),
Single(String),
}
let helper = Option::<Helper>::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) { match Url::parse(raw) {
Ok(mut parsed) => { Ok(mut parsed) => {
if parsed.password().is_some() {
let _ = parsed.set_password(Some("*****")); let _ = parsed.set_password(Some("*****"));
parsed.to_string() parsed.to_string()
} else {
raw.to_string()
}
} }
Err(_) => "***".to_string(), Err(_) => "***".to_string(),
} }
+30 -1
View File
@@ -1,12 +1,40 @@
use std::time::Duration; use std::time::Duration;
use diesel::pg::PgConnection; use diesel::pg::PgConnection;
use diesel::r2d2::{ConnectionManager, Pool}; use diesel::r2d2::{ConnectionManager, CustomizeConnection, Pool};
use diesel::RunQueryDsl;
pub type PgPool = Pool<ConnectionManager<PgConnection>>; pub type PgPool = Pool<ConnectionManager<PgConnection>>;
pub const DEFAULT_MAX_POOL_SIZE: u32 = 2; pub const DEFAULT_MAX_POOL_SIZE: u32 = 2;
#[derive(Debug)]
struct SchemaCustomizer;
impl CustomizeConnection<PgConnection, diesel::r2d2::Error> 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<PgPool> { pub fn init_pool(database_url: &str) -> anyhow::Result<PgPool> {
init_pool_with_size(database_url, DEFAULT_MAX_POOL_SIZE) init_pool_with_size(database_url, DEFAULT_MAX_POOL_SIZE)
} }
@@ -17,6 +45,7 @@ pub fn init_pool_with_size(database_url: &str, max_size: u32) -> anyhow::Result<
let pool = Pool::builder() let pool = Pool::builder()
.max_size(pool_size) .max_size(pool_size)
.connection_timeout(Duration::from_secs(10)) .connection_timeout(Duration::from_secs(10))
.connection_customizer(Box::new(SchemaCustomizer))
.build(manager)?; .build(manager)?;
Ok(pool) Ok(pool)
} }
+256
View File
@@ -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<DownloadLink>,
}
#[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<DownloadLink>,
}
#[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<DocumentAssetResponse>,
pub download: DownloadLink,
}
pub fn build_download_link(
state: &AppState,
document: &Document,
version_id: Uuid,
user_id: Uuid,
) -> AppResult<DownloadLink> {
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<DownloadLink>,
) -> 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<String> {
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<Vec<DocumentAssetResponse>> {
let assets: Vec<DocumentAsset> = 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<HashMap<Uuid, (DocumentVersionResponse, Vec<DocumentAssetResponse>)>> {
if documents.is_empty() {
return Ok(HashMap::new());
}
let mut doc_to_version: HashMap<Uuid, Uuid> = HashMap::with_capacity(documents.len());
let mut version_ids: Vec<Uuid> = 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<DocumentVersion> = document_versions::table
.filter(document_versions::id.eq_any(&version_ids))
.load(conn)?;
let mut version_map: HashMap<Uuid, DocumentVersion> = HashMap::new();
for version in versions {
version_map.insert(version.id, version);
}
let assets: Vec<DocumentAsset> = 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<Uuid, Vec<DocumentAssetResponse>> = 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<Uuid, (DocumentVersionResponse, Vec<DocumentAssetResponse>)> =
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()
}
}
+121
View File
@@ -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<Vec<Uuid>> {
let mut unique: Vec<Uuid> = 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<usize> {
let ids = normalize_correspondent_ids(correspondent_ids)?;
let existing: Vec<Uuid> = 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<NewDocumentCorrespondent> = 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<HashMap<Uuid, Vec<DocumentCorrespondentResponse>>> {
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<Uuid, Vec<DocumentCorrespondentResponse>> = 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)
}
+21
View File
@@ -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")
}
+52
View File
@@ -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<Value> {
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<String, Value>, updates: Map<String, Value>) {
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);
}
}
}
}
+10
View File
@@ -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};
+58
View File
@@ -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)),
}
}
+33
View File
@@ -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<HashMap<Uuid, (Vec<Tag>, Vec<DocumentCorrespondentResponse>)>> {
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)
}
+304
View File
@@ -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<String> {
let tokens: Vec<String> = 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<String> = 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<Vec<Uuid>> {
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<Uuid> {
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<Uuid> {
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<Uuid> {
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<Value>,
}
#[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(())
}
+80
View File
@@ -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<Uuid>,
) -> AppResult<usize> {
if raw_tag_ids.is_empty() {
return Ok(0);
}
let mut tag_ids: Vec<Uuid> = raw_tag_ids.iter().copied().collect();
tag_ids.sort_unstable();
tag_ids.dedup();
if tag_ids.is_empty() {
return Ok(0);
}
let existing: Vec<Uuid> = 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<NewDocumentTag> = 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<HashMap<Uuid, Vec<Tag>>> {
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<Uuid, Vec<Tag>> = HashMap::new();
for (doc_id, tag) in rows {
map.entry(doc_id).or_default().push(tag);
}
Ok(map)
}
+45 -5
View File
@@ -4,7 +4,9 @@ use axum::{
Json, Json,
}; };
use serde::Serialize; use serde::Serialize;
use std::fmt::Display; use serde_json::Value;
use std::fmt::{self, Display};
use utoipa::ToSchema;
pub type AppResult<T> = Result<T, AppError>; pub type AppResult<T> = Result<T, AppError>;
@@ -12,6 +14,8 @@ pub type AppResult<T> = Result<T, AppError>;
pub struct AppError { pub struct AppError {
status: StatusCode, status: StatusCode,
message: String, message: String,
code: Option<String>,
details: Option<Value>,
} }
impl AppError { impl AppError {
@@ -19,6 +23,8 @@ impl AppError {
Self { Self {
status, status,
message: message.into(), message: message.into(),
code: None,
details: None,
} }
} }
@@ -26,10 +32,18 @@ impl AppError {
Self::new(StatusCode::BAD_REQUEST, message) Self::new(StatusCode::BAD_REQUEST, message)
} }
pub fn conflict(message: impl Into<String>) -> Self {
Self::new(StatusCode::CONFLICT, message)
}
pub fn unauthorized() -> Self { pub fn unauthorized() -> Self {
Self::new(StatusCode::UNAUTHORIZED, "unauthorized") Self::new(StatusCode::UNAUTHORIZED, "unauthorized")
} }
pub fn forbidden(message: impl Into<String>) -> Self {
Self::new(StatusCode::FORBIDDEN, message)
}
pub fn not_found() -> Self { pub fn not_found() -> Self {
Self::new(StatusCode::NOT_FOUND, "resource not found") Self::new(StatusCode::NOT_FOUND, "resource not found")
} }
@@ -37,28 +51,54 @@ impl AppError {
pub fn internal<E: Display>(error: E) -> Self { pub fn internal<E: Display>(error: E) -> Self {
Self::new(StatusCode::INTERNAL_SERVER_ERROR, error.to_string()) Self::new(StatusCode::INTERNAL_SERVER_ERROR, error.to_string())
} }
pub fn with_code(mut self, code: impl Into<String>) -> 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 { impl IntoResponse for AppError {
fn into_response(self) -> Response { fn into_response(self) -> Response {
let status = self.status; let status = self.status;
let body = Json(ErrorResponse { let body = Json(ApiErrorResponse {
error: self.message, error: self.message,
code: self.code,
details: self.details,
}); });
(status, body).into_response() (status, body).into_response()
} }
} }
#[derive(Serialize)] #[derive(Serialize, ToSchema)]
struct ErrorResponse { pub struct ApiErrorResponse {
error: String, error: String,
#[serde(skip_serializing_if = "Option::is_none")]
code: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
details: Option<Value>,
} }
impl From<diesel::result::Error> for AppError { impl From<diesel::result::Error> for AppError {
fn from(value: diesel::result::Error) -> Self { fn from(value: diesel::result::Error) -> Self {
match value { match value {
diesel::result::Error::NotFound => AppError::not_found(), diesel::result::Error::NotFound => AppError::not_found(),
_ => AppError::internal(value), other => {
let message = format!("database operation failed: {other}");
tracing::error!(error = ?other, message);
AppError::internal(message)
}
} }
} }
} }
+1
View File
@@ -0,0 +1 @@
pub mod responders;
+157
View File
@@ -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<T> {
fn into_app_result(self) -> AppResult<T>;
}
impl<T, E> IntoAppResult<T> for Result<T, E>
where
AppError: From<E>,
{
fn into_app_result(self) -> AppResult<T> {
self.map_err(AppError::from)
}
}
/// Extension helpers for optional values to map them into `AppResult`.
pub trait OptionAppResultExt<T> {
fn or_not_found(self) -> AppResult<T>;
fn or_bad_request(self, message: impl Into<String>) -> AppResult<T>;
}
impl<T> OptionAppResultExt<T> for Option<T> {
fn or_not_found(self) -> AppResult<T> {
self.ok_or_else(AppError::not_found)
}
fn or_bad_request(self, message: impl Into<String>) -> AppResult<T> {
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<usize>;
fn or_not_found(self) -> AppResult<usize> {
self.or_error(AppError::not_found())
}
}
impl RowsAffectedExt for usize {
fn or_error(self, error: AppError) -> AppResult<usize> {
if self == 0 {
Err(error)
} else {
Ok(self)
}
}
}
/// Wrapper providing a consistent JSON response with a status code.
pub struct JsonResponse<T> {
status: StatusCode,
payload: T,
}
impl<T> JsonResponse<T> {
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<T> From<T> for JsonResponse<T> {
fn from(value: T) -> Self {
Self::ok(value)
}
}
impl<T> IntoResponse for JsonResponse<T>
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<StatusCode> {
Ok(status)
}
/// Helper for returning `204 No Content`.
pub fn no_content() -> AppResult<StatusCode> {
empty(StatusCode::NO_CONTENT)
}
/// Helper for returning JSON payloads with `200 OK`.
pub fn ok_json<T>(value: T) -> AppResult<JsonResponse<T>>
where
T: Serialize,
{
Ok(JsonResponse::ok(value))
}
/// Helper for returning JSON payloads with `201 Created`.
pub fn created_json<T>(value: T) -> AppResult<JsonResponse<T>>
where
T: Serialize,
{
Ok(JsonResponse::created(value))
}
/// Helper for returning JSON payloads with `202 Accepted`.
pub fn accepted_json<T>(value: T) -> AppResult<JsonResponse<T>>
where
T: Serialize,
{
Ok(JsonResponse::accepted(value))
}
/// Standard wrapper for paginated responses.
#[derive(Serialize)]
pub struct PaginatedResponse<T, M>
where
T: Serialize,
M: Serialize,
{
pub data: T,
pub meta: M,
}
pub fn paginated_json<T, M>(data: T, meta: M) -> AppResult<JsonResponse<PaginatedResponse<T, M>>>
where
T: Serialize,
M: Serialize,
{
let payload = PaginatedResponse { data, meta };
Ok(JsonResponse::ok(payload))
}
+167
View File
@@ -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<Self> {
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<NaiveDate> =
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<DateOrder>,
pub locales: HashSet<String>,
pub ignore_dates: HashSet<NaiveDate>,
pub min_date: NaiveDate,
}
impl IssuedAtSettings {
pub fn from_config(config: &AppConfig) -> Self {
let timezone = config.service_timezone.parse::<Tz>().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::<HashSet<_>>();
// 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<String> {
&self.locales
}
/// Returns the immutable set of local-calendar dates that should be ignored.
pub fn ignore_dates(&self) -> &HashSet<NaiveDate> {
&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<DateOrder> {
self.filename_date_order
}
pub fn normalize_naive(
&self,
date: NaiveDate,
now_utc: chrono::DateTime<Utc>,
) -> Option<DateTime<Utc>> {
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<Utc>,
now_utc: chrono::DateTime<Utc>,
) -> Option<DateTime<Utc>> {
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<Utc>) -> 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)
}
}
+3 -1
View File
@@ -18,7 +18,9 @@ pub const STATUS_FAILED: &str = "failed";
pub const JOB_ANALYZE_DOCUMENT: &str = "analyze-document"; pub const JOB_ANALYZE_DOCUMENT: &str = "analyze-document";
pub const JOB_GENERATE_THUMBNAILS: &str = "generate-thumbnails"; pub const JOB_GENERATE_THUMBNAILS: &str = "generate-thumbnails";
pub const JOB_GENERATE_OCR_TEXT: &str = "generate-ocr-text"; pub const JOB_GENERATE_OCR_TEXT: &str = "generate-ocr-text";
pub const JOB_INDEX_DOCUMENT_TEXT: &str = "index-document-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)] #[derive(Debug, Error)]
pub enum JobQueueError { pub enum JobQueueError {
+7
View File
@@ -1,15 +1,22 @@
pub mod auth; pub mod auth;
pub mod config; pub mod config;
pub mod db; pub mod db;
pub mod documents;
pub mod error; pub mod error;
pub mod http;
pub mod issued_at;
pub mod jobs; pub mod jobs;
pub mod models; pub mod models;
pub mod openapi;
pub mod routes; pub mod routes;
pub mod s3; pub mod s3;
pub mod schema; pub mod schema;
pub mod services;
pub mod state; pub mod state;
pub mod storage; pub mod storage;
pub mod tenants; pub mod tenants;
pub mod utils; pub mod utils;
pub mod workers; pub mod workers;
pub use workers::{default_handlers, Worker}; pub use workers::{default_handlers, Worker};
pub mod migrations;
pub mod test_support;
+1 -1
View File
@@ -3,7 +3,7 @@ use std::net::SocketAddr;
use tokio::net::TcpListener; use tokio::net::TcpListener;
use tower::make::Shared; use tower::make::Shared;
use backend::{routes, utils::bootstrap::init_component}; use papercrate::{routes, utils::bootstrap::init_component};
#[tokio::main] #[tokio::main]
async fn main() -> anyhow::Result<()> { async fn main() -> anyhow::Result<()> {
+3
View File
@@ -0,0 +1,3 @@
use diesel_migrations::{embed_migrations, EmbeddedMigrations};
pub const MIGRATIONS: EmbeddedMigrations = embed_migrations!("migrations");
+489 -44
View File
@@ -1,7 +1,22 @@
use chrono::NaiveDateTime; use chrono::NaiveDateTime;
use diesel::deserialize::FromSql;
use diesel::pg::{Pg, PgValue};
use diesel::prelude::*; 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 uuid::Uuid;
use utoipa::ToSchema;
use crate::schema::sql_types::{
ApiCapability as ApiCapabilitySql, MagicTokenKind as MagicTokenKindSql,
TenantStatus as TenantStatusSql,
};
use crate::schema::*; use crate::schema::*;
#[derive(Debug, Clone, Queryable, Identifiable, Associations)] #[derive(Debug, Clone, Queryable, Identifiable, Associations)]
@@ -12,9 +27,9 @@ pub struct UserMembership {
pub id: Uuid, pub id: Uuid,
pub user_id: Uuid, pub user_id: Uuid,
pub tenant_id: Uuid, pub tenant_id: Uuid,
pub role: String,
pub created_at: NaiveDateTime, pub created_at: NaiveDateTime,
pub updated_at: NaiveDateTime, pub updated_at: NaiveDateTime,
pub capability_set_id: Option<Uuid>,
} }
#[derive(Debug, Insertable)] #[derive(Debug, Insertable)]
@@ -23,7 +38,319 @@ pub struct NewUserMembership {
pub id: Uuid, pub id: Uuid,
pub user_id: Uuid, pub user_id: Uuid,
pub tenant_id: Uuid, pub tenant_id: Uuid,
pub role: String, pub capability_set_id: Option<Uuid>,
}
#[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<MagicTokenKindSql, Pg> 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<ApiCapabilitySql, Pg> 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<MagicTokenKindSql, Pg> for MagicTokenKind {
fn from_sql(bytes: PgValue<'_>) -> deserialize::Result<Self> {
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<ApiCapabilitySql, Pg> for ApiCapability {
fn from_sql(bytes: PgValue<'_>) -> deserialize::Result<Self> {
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<Self, Self::Err> {
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<Self, Self::Err> {
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<Self> {
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<TenantStatusSql, Pg> 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<TenantStatusSql, Pg> for TenantStatus {
fn from_sql(bytes: PgValue<'_>) -> deserialize::Result<Self> {
let value = str::from_utf8(bytes.as_bytes())
.map_err(|err| Box::<dyn std::error::Error + Send + Sync>::from(err))?;
TenantStatus::from_str(value).ok_or_else(|| {
Box::<dyn std::error::Error + Send + Sync>::from(std::io::Error::new(
std::io::ErrorKind::InvalidData,
format!("invalid tenant status '{value}'"),
))
})
}
} }
#[derive(Debug, Clone, Queryable, Identifiable)] #[derive(Debug, Clone, Queryable, Identifiable)]
@@ -31,13 +358,14 @@ pub struct NewUserMembership {
#[diesel(primary_key(id))] #[diesel(primary_key(id))]
pub struct Tenant { pub struct Tenant {
pub id: Uuid, pub id: Uuid,
pub slug: String, pub name: String,
pub storage_root: Option<String>, pub storage_root: Option<String>,
pub quickwit_index: Option<String>, pub quickwit_index: Option<String>,
pub status: String, pub config: Value,
pub config: serde_json::Value,
pub created_at: NaiveDateTime, pub created_at: NaiveDateTime,
pub updated_at: NaiveDateTime, pub updated_at: NaiveDateTime,
pub status: TenantStatus,
pub created_by: Option<Uuid>,
} }
#[derive(Debug, Clone, Queryable, Identifiable)] #[derive(Debug, Clone, Queryable, Identifiable)]
@@ -45,7 +373,6 @@ pub struct Tenant {
pub struct User { pub struct User {
pub id: Uuid, pub id: Uuid,
pub username: String, pub username: String,
pub password_hash: String,
pub created_at: NaiveDateTime, pub created_at: NaiveDateTime,
pub updated_at: NaiveDateTime, pub updated_at: NaiveDateTime,
} }
@@ -55,7 +382,135 @@ pub struct User {
pub struct NewUser { pub struct NewUser {
pub id: Uuid, pub id: Uuid,
pub username: String, pub username: String,
pub password_hash: 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<u8>,
pub public_key: Vec<u8>,
pub credential: serde_json::Value,
pub sign_count: i64,
pub transports: Vec<Option<String>>,
pub aaguid: Option<Uuid>,
pub nickname: Option<String>,
pub created_at: NaiveDateTime,
pub updated_at: NaiveDateTime,
pub last_used_at: Option<NaiveDateTime>,
pub revoked_at: Option<NaiveDateTime>,
pub revoked_by: Option<Uuid>,
pub revoked_reason: Option<String>,
}
#[derive(Debug, Insertable)]
#[diesel(table_name = user_passkeys)]
pub struct NewUserPasskey {
pub id: Uuid,
pub user_id: Uuid,
pub credential_id: Vec<u8>,
pub public_key: Vec<u8>,
pub credential: serde_json::Value,
pub sign_count: i64,
pub transports: Vec<Option<String>>,
pub aaguid: Option<Uuid>,
pub nickname: Option<String>,
}
#[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<Uuid>,
pub purpose: String,
pub challenge: Vec<u8>,
pub state: Vec<u8>,
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<Uuid>,
pub purpose: String,
pub challenge: Vec<u8>,
pub state: Vec<u8>,
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<String>,
pub created_at: NaiveDateTime,
pub last_used_at: Option<NaiveDateTime>,
pub expires_at: Option<NaiveDateTime>,
pub revoked_at: Option<NaiveDateTime>,
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<String>,
pub expires_at: Option<NaiveDateTime>,
pub capability_set_id: Uuid,
} }
#[derive(Debug, Clone, Queryable, Identifiable)] #[derive(Debug, Clone, Queryable, Identifiable)]
@@ -85,9 +540,9 @@ pub struct Document {
pub id: Uuid, pub id: Uuid,
pub filename: String, pub filename: String,
pub original_name: String, pub original_name: String,
pub content_type: Option<String>, pub mime_type: Option<String>,
pub folder_id: Option<Uuid>, pub folder_id: Option<Uuid>,
pub uploaded_at: NaiveDateTime, pub created_at: NaiveDateTime,
pub updated_at: NaiveDateTime, pub updated_at: NaiveDateTime,
pub deleted_at: Option<NaiveDateTime>, pub deleted_at: Option<NaiveDateTime>,
pub metadata: serde_json::Value, pub metadata: serde_json::Value,
@@ -103,7 +558,7 @@ pub struct NewDocument {
pub id: Uuid, pub id: Uuid,
pub filename: String, pub filename: String,
pub original_name: String, pub original_name: String,
pub content_type: Option<String>, pub mime_type: Option<String>,
pub folder_id: Option<Uuid>, pub folder_id: Option<Uuid>,
pub current_version_id: Uuid, pub current_version_id: Uuid,
pub metadata: serde_json::Value, pub metadata: serde_json::Value,
@@ -112,6 +567,22 @@ pub struct NewDocument {
pub tenant_id: Uuid, 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<i32>,
pub used_count: i32,
pub created_at: NaiveDateTime,
pub created_by: Option<Uuid>,
pub last_used_at: Option<NaiveDateTime>,
}
#[derive(Debug, Clone, Queryable, Identifiable, Associations)] #[derive(Debug, Clone, Queryable, Identifiable, Associations)]
#[diesel(table_name = document_versions)] #[diesel(table_name = document_versions)]
#[diesel(belongs_to(Document))] #[diesel(belongs_to(Document))]
@@ -123,7 +594,6 @@ pub struct DocumentVersion {
pub size_bytes: i64, pub size_bytes: i64,
pub checksum: String, pub checksum: String,
pub created_at: NaiveDateTime, pub created_at: NaiveDateTime,
pub operations_summary: serde_json::Value,
pub metadata: serde_json::Value, pub metadata: serde_json::Value,
pub tenant_id: Uuid, pub tenant_id: Uuid,
} }
@@ -137,7 +607,6 @@ pub struct NewDocumentVersion {
pub s3_key: String, pub s3_key: String,
pub size_bytes: i64, pub size_bytes: i64,
pub checksum: String, pub checksum: String,
pub operations_summary: serde_json::Value,
pub metadata: serde_json::Value, pub metadata: serde_json::Value,
pub tenant_id: Uuid, pub tenant_id: Uuid,
} }
@@ -152,7 +621,7 @@ pub struct DocumentAsset {
pub mime_type: String, pub mime_type: String,
pub metadata: serde_json::Value, pub metadata: serde_json::Value,
pub created_at: NaiveDateTime, pub created_at: NaiveDateTime,
pub cardinality: Option<i32>, pub s3_key: String,
pub tenant_id: Uuid, pub tenant_id: Uuid,
} }
@@ -164,30 +633,7 @@ pub struct NewDocumentAsset {
pub asset_type: String, pub asset_type: String,
pub mime_type: String, pub mime_type: String,
pub metadata: serde_json::Value, pub metadata: serde_json::Value,
pub cardinality: Option<i32>,
pub tenant_id: Uuid,
}
#[derive(Debug, Clone, Queryable, Identifiable, Associations)]
#[diesel(table_name = document_asset_objects)]
#[diesel(belongs_to(DocumentAsset, foreign_key = asset_id))]
pub struct DocumentAssetObject {
pub id: Uuid,
pub asset_id: Uuid,
pub ordinal: i32,
pub s3_key: String, pub s3_key: String,
pub metadata: serde_json::Value,
pub tenant_id: Uuid,
}
#[derive(Debug, Insertable)]
#[diesel(table_name = document_asset_objects)]
pub struct NewDocumentAssetObject {
pub id: Uuid,
pub asset_id: Uuid,
pub ordinal: i32,
pub s3_key: String,
pub metadata: serde_json::Value,
pub tenant_id: Uuid, pub tenant_id: Uuid,
} }
@@ -203,7 +649,8 @@ pub struct Job {
pub last_error: Option<String>, pub last_error: Option<String>,
pub created_at: NaiveDateTime, pub created_at: NaiveDateTime,
pub updated_at: NaiveDateTime, pub updated_at: NaiveDateTime,
pub tenant_id: Uuid, pub tenant_id: Option<Uuid>,
pub result: Option<serde_json::Value>,
} }
#[derive(Debug, Insertable)] #[derive(Debug, Insertable)]
@@ -283,11 +730,10 @@ pub struct NewCorrespondent {
#[diesel(table_name = document_correspondents)] #[diesel(table_name = document_correspondents)]
#[diesel(belongs_to(Document))] #[diesel(belongs_to(Document))]
#[diesel(belongs_to(Correspondent))] #[diesel(belongs_to(Correspondent))]
#[diesel(primary_key(document_id, correspondent_id, role))] #[diesel(primary_key(document_id, correspondent_id))]
pub struct DocumentCorrespondent { pub struct DocumentCorrespondent {
pub document_id: Uuid, pub document_id: Uuid,
pub correspondent_id: Uuid, pub correspondent_id: Uuid,
pub role: String,
pub assigned_at: NaiveDateTime, pub assigned_at: NaiveDateTime,
pub assigned_by: Option<Uuid>, pub assigned_by: Option<Uuid>,
pub tenant_id: Uuid, pub tenant_id: Uuid,
@@ -298,15 +744,14 @@ pub struct DocumentCorrespondent {
pub struct NewDocumentCorrespondent { pub struct NewDocumentCorrespondent {
pub document_id: Uuid, pub document_id: Uuid,
pub correspondent_id: Uuid, pub correspondent_id: Uuid,
pub role: String,
pub assigned_by: Option<Uuid>, pub assigned_by: Option<Uuid>,
pub tenant_id: Uuid, pub tenant_id: Uuid,
} }
#[derive(Debug, Clone, Queryable, Identifiable, Associations)] #[derive(Debug, Clone, Queryable, Identifiable, Associations)]
#[diesel(table_name = refresh_tokens)] #[diesel(table_name = user_sessions)]
#[diesel(belongs_to(User))] #[diesel(belongs_to(User))]
pub struct RefreshToken { pub struct UserSession {
pub id: Uuid, pub id: Uuid,
pub user_id: Uuid, pub user_id: Uuid,
pub token_hash: String, pub token_hash: String,
@@ -319,8 +764,8 @@ pub struct RefreshToken {
} }
#[derive(Debug, Insertable)] #[derive(Debug, Insertable)]
#[diesel(table_name = refresh_tokens)] #[diesel(table_name = user_sessions)]
pub struct NewRefreshToken { pub struct NewUserSession {
pub id: Uuid, pub id: Uuid,
pub user_id: Uuid, pub user_id: Uuid,
pub token_hash: String, pub token_hash: String,
+135
View File
@@ -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");
}
}
+209 -296
View File
@@ -1,347 +1,260 @@
use axum::{ use axum::{
extract::State, extract::State,
http::{header::SET_COOKIE, HeaderMap, HeaderValue, StatusCode}, http::{HeaderMap, StatusCode},
response::{IntoResponse, Response}, response::Response,
Json, Json,
}; };
use axum_extra::{ use axum_extra::{
headers::{authorization::Bearer, Authorization, Cookie}, headers::{authorization::Bearer, Authorization, Cookie},
typed_header::TypedHeader, typed_header::TypedHeader,
}; };
use chrono::{Duration as ChronoDuration, Utc}; use utoipa::OpenApi;
use diesel::{pg::PgConnection, prelude::*};
use rand::{rngs::OsRng, RngCore};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use uuid::Uuid;
use crate::{ use crate::{
auth::{password, AuthenticatedUser}, auth::{
passkeys::{
AuthenticationChallengeResponse, PasskeyLoginFinishPayload, PasskeyLoginStartPayload,
PasskeyRegistrationFinishPayload, PasskeySummary, RegistrationChallengeResponse,
},
AuthenticatedUser, TenantScopedConn,
},
error::{AppError, AppResult}, error::{AppError, AppResult},
models::{NewRefreshToken, RefreshToken, Tenant, User, UserMembership}, http::responders::JsonResponse,
schema::{ services::auth::{
refresh_tokens, tenants::dsl as tenant_dsl, user_memberships::dsl as memberships_dsl, ApiTokenExchangeRequest, AuthService, LoginRequest, LoginResponse, LoginResponseVariants,
users::dsl, SignupFinishRequest, SignupStartRequest, SignupStartResponse, TenantListResponse,
TenantSelectionRequest, TenantSelectionResponse, TenantSnippet, SESSION_COOKIE_NAME,
}, },
state::AppState, state::AppState,
}; };
use crate::schema::refresh_tokens::dsl as refresh_dsl; #[derive(OpenApi)]
#[openapi(
const REFRESH_COOKIE_NAME: &str = "refresh_token"; paths(
login,
#[derive(Deserialize)] api_token_exchange,
pub struct LoginRequest { signup_start,
pub username: String, signup_finish,
pub password: String, refresh,
#[serde(default)] logout,
pub preferred_tenant_slug: Option<String>, me,
} select_tenant,
passkey_register_start,
#[derive(Serialize)] passkey_register_finish,
pub struct LoginResponse { passkey_login_start,
pub access_token: String, passkey_login_finish,
pub token_type: String, ),
pub expires_in: i64, components(schemas(
} LoginRequest,
ApiTokenExchangeRequest,
#[derive(Serialize)] SignupStartRequest,
pub struct TenantSummary { SignupStartResponse,
pub tenant_id: Uuid, SignupFinishRequest,
pub slug: String, LoginResponse,
} LoginResponseVariants,
TenantSnippet,
#[derive(Serialize)] TenantSelectionResponse,
pub struct TenantSelectionResponse { TenantSelectionRequest,
pub selection_token: String, TenantListResponse,
pub tenants: Vec<TenantSummary>, crate::auth::AuthenticatedUser,
} crate::auth::passkeys::RegistrationChallengeResponse,
crate::auth::passkeys::AuthenticationChallengeResponse,
#[derive(Deserialize)] crate::auth::passkeys::PasskeySummary,
pub struct TenantSelectionRequest { crate::auth::passkeys::PasskeyRegistrationFinishPayload,
pub tenant_id: Uuid, 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( pub async fn login(
State(state): State<AppState>, State(state): State<AppState>,
Json(payload): Json<LoginRequest>, Json(payload): Json<LoginRequest>,
) -> AppResult<Response> { ) -> AppResult<Response> {
let mut conn = state.db_unscoped()?; AuthService::new(&state).login(payload)
let user: User = dsl::users
.filter(dsl::username.eq(&payload.username))
.first(&mut conn)?;
let valid = password::verify_password(&payload.password, &user.password_hash)
.map_err(|_| AppError::unauthorized())?;
if !valid {
return Err(AppError::unauthorized());
}
let memberships: Vec<(UserMembership, Tenant)> = memberships_dsl::user_memberships
.inner_join(tenant_dsl::tenants)
.filter(memberships_dsl::user_id.eq(user.id))
.load(&mut conn)?;
if memberships.is_empty() {
return Err(AppError::unauthorized());
}
let preferred_slug = payload
.preferred_tenant_slug
.as_ref()
.map(|slug| slug.trim().to_string())
.filter(|slug| !slug.is_empty());
if let Some(tenant) = preferred_slug.as_ref().and_then(|slug| {
memberships
.iter()
.find(|(_, tenant)| tenant.slug.eq_ignore_ascii_case(slug))
}) {
return issue_session(&state, &mut conn, &user, tenant.1.id);
}
if memberships.len() == 1 {
let tenant_id = memberships[0].1.id;
return issue_session(&state, &mut conn, &user, tenant_id);
}
let selection_token = state
.jwt
.generate_tenant_selector_token(user.id)
.map_err(AppError::from)?;
let tenants = memberships
.into_iter()
.map(|(_, tenant)| TenantSummary {
tenant_id: tenant.id,
slug: tenant.slug,
})
.collect();
let response = Json(TenantSelectionResponse {
selection_token,
tenants,
})
.into_response();
Ok(response)
} }
#[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<AppState>,
Json(payload): Json<ApiTokenExchangeRequest>,
) -> AppResult<JsonResponse<LoginResponse>> {
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<AppState>,
Json(payload): Json<SignupStartRequest>,
) -> AppResult<JsonResponse<SignupStartResponse>> {
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<AppState>,
Json(payload): Json<SignupFinishRequest>,
) -> AppResult<Response> {
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( pub async fn refresh(
State(state): State<AppState>, State(state): State<AppState>,
jar: Option<TypedHeader<Cookie>>, jar: Option<TypedHeader<Cookie>>,
) -> AppResult<Response> { ) -> AppResult<Response> {
let cookies = jar.ok_or_else(AppError::unauthorized)?; let cookies = jar.ok_or_else(AppError::unauthorized)?;
let refresh_value = cookies let refresh_value = cookies
.get(REFRESH_COOKIE_NAME) .get(SESSION_COOKIE_NAME)
.ok_or_else(AppError::unauthorized)?; .ok_or_else(AppError::unauthorized)?;
let hashed = hash_refresh_token(refresh_value); AuthService::new(&state).refresh(refresh_value)
let mut conn = state.db_unscoped()?;
let now = Utc::now();
let now_naive = now.naive_utc();
let token = match refresh_dsl::refresh_tokens
.filter(refresh_dsl::token_hash.eq(&hashed))
.filter(refresh_dsl::revoked_at.is_null())
.filter(refresh_dsl::expires_at.gt(now_naive))
.first::<RefreshToken>(&mut conn)
{
Ok(token) => token,
Err(diesel::result::Error::NotFound) => return Err(AppError::unauthorized()),
Err(err) => return Err(AppError::from(err)),
};
diesel::update(refresh_dsl::refresh_tokens.filter(refresh_dsl::id.eq(token.id)))
.set((
refresh_dsl::revoked_at.eq(now_naive),
refresh_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)?;
issue_session(&state, &mut conn, &user, token.tenant_id)
} }
#[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( pub async fn select_tenant(
State(state): State<AppState>, State(state): State<AppState>,
TypedHeader(Authorization(bearer)): TypedHeader<Authorization<Bearer>>, TypedHeader(Authorization(bearer)): TypedHeader<Authorization<Bearer>>,
Json(payload): Json<TenantSelectionRequest>, Json(payload): Json<TenantSelectionRequest>,
) -> AppResult<Response> { ) -> AppResult<Response> {
let claims = state AuthService::new(&state).select_tenant(bearer.token(), payload.tenant_id)
.jwt
.verify_tenant_selector_token(bearer.token())
.map_err(|_| AppError::unauthorized())?;
let mut conn = state.db_unscoped()?;
let membership_exists = memberships_dsl::user_memberships
.filter(memberships_dsl::user_id.eq(claims.sub))
.filter(memberships_dsl::tenant_id.eq(payload.tenant_id))
.inner_join(tenant_dsl::tenants)
.select(memberships_dsl::id)
.first::<Uuid>(&mut conn)
.optional()?;
if membership_exists.is_none() {
return Err(AppError::unauthorized());
}
let user: User = dsl::users
.find(claims.sub)
.first(&mut conn)
.map_err(AppError::from)?;
issue_session(&state, &mut conn, &user, payload.tenant_id)
} }
#[utoipa::path(
post,
path = "/api/auth/logout",
responses((status = 204, description = "Session revoked")),
tag = "Auth"
)]
pub async fn logout( pub async fn logout(
State(state): State<AppState>, State(state): State<AppState>,
user: AuthenticatedUser, TenantScopedConn { mut conn, user, .. }: TenantScopedConn,
jar: Option<TypedHeader<Cookie>>, jar: Option<TypedHeader<Cookie>>,
) -> AppResult<(HeaderMap, StatusCode)> { ) -> AppResult<(HeaderMap, StatusCode)> {
let mut conn = state.db_unscoped()?; let refresh_cookie = jar.as_ref().and_then(|cookies| {
let now = Utc::now().naive_utc(); cookies
let mut rows_affected = 0; .get(SESSION_COOKIE_NAME)
.map(|value| value.to_owned())
if let Some(cookies) = jar { });
if let Some(value) = cookies.get(REFRESH_COOKIE_NAME) { AuthService::new(&state).logout(&mut conn, &user, refresh_cookie.as_deref())
let hashed = hash_refresh_token(value);
rows_affected = diesel::update(
refresh_dsl::refresh_tokens
.filter(refresh_dsl::token_hash.eq(hashed))
.filter(refresh_dsl::user_id.eq(user.user_id))
.filter(refresh_dsl::revoked_at.is_null()),
)
.set((
refresh_dsl::revoked_at.eq(now),
refresh_dsl::updated_at.eq(now),
))
.execute(&mut conn)
.unwrap_or(0);
}
}
if rows_affected == 0 {
let _ = diesel::update(
refresh_dsl::refresh_tokens
.filter(refresh_dsl::user_id.eq(user.user_id))
.filter(refresh_dsl::revoked_at.is_null()),
)
.set((
refresh_dsl::revoked_at.eq(now),
refresh_dsl::updated_at.eq(now),
))
.execute(&mut conn);
}
let mut headers = HeaderMap::new();
headers.insert(SET_COOKIE, build_clear_refresh_cookie(&state));
Ok((headers, StatusCode::NO_CONTENT))
} }
#[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<AuthenticatedUser> { pub async fn me(user: AuthenticatedUser) -> Json<AuthenticatedUser> {
Json(user) Json(user)
} }
fn issue_session( #[utoipa::path(
state: &AppState, post,
conn: &mut PgConnection, path = "/api/auth/passkeys/register/start",
user: &User, responses((status = 200, body = crate::auth::passkeys::RegistrationChallengeResponse)),
tenant_id: Uuid, tag = "Auth"
)]
pub async fn passkey_register_start(
State(state): State<AppState>,
user: AuthenticatedUser,
) -> AppResult<JsonResponse<RegistrationChallengeResponse>> {
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<AppState>,
user: AuthenticatedUser,
Json(payload): Json<PasskeyRegistrationFinishPayload>,
) -> AppResult<JsonResponse<PasskeySummary>> {
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<AppState>,
Json(payload): Json<PasskeyLoginStartPayload>,
) -> AppResult<JsonResponse<AuthenticationChallengeResponse>> {
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<AppState>,
Json(payload): Json<PasskeyLoginFinishPayload>,
) -> AppResult<Response> { ) -> AppResult<Response> {
let now = Utc::now(); AuthService::new(&state).passkey_login_finish(payload)
let access_token = state
.jwt
.generate_token(user.id, tenant_id, &user.username)
.map_err(AppError::from)?;
let refresh_value = generate_refresh_token();
let refresh_hash = hash_refresh_token(&refresh_value);
let refresh_expires_at = now + ChronoDuration::days(state.config.refresh_token_expiry_days);
let new_refresh = NewRefreshToken {
id: Uuid::new_v4(),
user_id: user.id,
token_hash: refresh_hash,
issued_at: now.naive_utc(),
expires_at: refresh_expires_at.naive_utc(),
tenant_id,
};
diesel::insert_into(refresh_tokens::table)
.values(&new_refresh)
.execute(conn)?;
let mut response = Json(LoginResponse {
access_token,
token_type: "Bearer".to_string(),
expires_in: state.config.jwt_expiry_minutes * 60,
})
.into_response();
response.headers_mut().insert(
SET_COOKIE,
build_refresh_cookie(state, &refresh_value, refresh_expires_at),
);
Ok(response)
}
fn hash_refresh_token(token: &str) -> String {
let mut hasher = Sha256::new();
hasher.update(token.as_bytes());
hex::encode(hasher.finalize())
}
fn generate_refresh_token() -> String {
let mut bytes = [0u8; 32];
OsRng.fill_bytes(&mut bytes);
hex::encode(bytes)
}
fn build_refresh_cookie(
state: &AppState,
token: &str,
expires_at: chrono::DateTime<Utc>,
) -> HeaderValue {
let max_age = ChronoDuration::days(state.config.refresh_token_expiry_days).num_seconds();
let mut parts = vec![format!("{}={}", REFRESH_COOKIE_NAME, token)];
parts.push("Path=/".into());
parts.push("HttpOnly".into());
parts.push("SameSite=Strict".into());
parts.push(format!("Max-Age={}", max_age));
parts.push(format!("Expires={}", expires_at.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 refresh cookie")
}
fn build_clear_refresh_cookie(state: &AppState) -> HeaderValue {
let mut parts = vec![format!("{}=", REFRESH_COOKIE_NAME)];
parts.push("Path=/".into());
parts.push("HttpOnly".into());
parts.push("SameSite=Strict".into());
parts.push("Max-Age=0".into());
parts.push("Expires=Thu, 01 Jan 1970 00:00:00 GMT".into());
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 refresh cookie")
} }
+134
View File
@@ -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<JsonResponse<Vec<CapabilitySetResponse>>> {
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<JsonResponse<Vec<crate::models::ApiCapability>>> {
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<Uuid>,
) -> AppResult<JsonResponse<CapabilitySetResponse>> {
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<CreateCapabilitySetRequest>,
) -> AppResult<JsonResponse<CapabilitySetResponse>> {
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<Uuid>,
Json(payload): Json<UpdateCapabilitySetRequest>,
) -> AppResult<JsonResponse<CapabilitySetResponse>> {
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<Uuid>,
) -> AppResult<StatusCode> {
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;
+105 -83
View File
@@ -1,50 +1,49 @@
use std::collections::{BTreeMap, HashMap}; use std::collections::HashMap;
use axum::{extract::Path, http::StatusCode, Json}; use axum::{extract::Path, http::StatusCode, Json};
use chrono::Utc; use chrono::Utc;
use diesel::{dsl::count_star, prelude::*, result::DatabaseErrorKind, PgConnection}; use diesel::{dsl::count_star, prelude::*, result::DatabaseErrorKind, PgConnection};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_json::Value; use serde_json::Value;
use utoipa::ToSchema;
use uuid::Uuid; use uuid::Uuid;
use crate::{ use crate::{
auth::TenantScopedConn, auth::TenantScopedConn,
error::{AppError, AppResult}, error::{AppError, AppResult},
http::responders::{no_content, ok_json, IntoAppResult, JsonResponse, RowsAffectedExt},
models::{Correspondent, NewCorrespondent}, models::{Correspondent, NewCorrespondent},
schema::{correspondents, document_correspondents}, schema::{correspondents, document_correspondents},
utils::{ utils::{
db::{no_content, EnsureEntity, IntoJsonResponse}, named_entity::{ensure_name_available, normalize_name},
time::to_iso, time::to_iso,
}, },
}; };
#[derive(Serialize)] #[derive(Serialize, ToSchema)]
pub struct CorrespondentUsage {
pub total: i64,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub by_role: BTreeMap<String, i64>,
}
#[derive(Serialize)]
pub struct CorrespondentSummary { pub struct CorrespondentSummary {
pub id: Uuid, pub id: Uuid,
pub name: String, pub name: String,
#[schema(value_type = Object)]
pub metadata: Value, pub metadata: Value,
pub created_at: String, pub created_at: String,
pub updated_at: String, pub updated_at: String,
pub usage: CorrespondentUsage, pub usage_count: i64,
} }
#[derive(Deserialize)] #[derive(Deserialize, ToSchema)]
pub struct CreateCorrespondentRequest { pub struct CreateCorrespondentRequest {
pub name: String, pub name: String,
#[serde(default)] #[serde(default)]
#[schema(nullable, value_type = Object)]
pub metadata: Option<Value>, pub metadata: Option<Value>,
} }
#[derive(Deserialize)] #[derive(Deserialize, ToSchema)]
pub struct UpdateCorrespondentRequest { pub struct UpdateCorrespondentRequest {
#[schema(nullable)]
pub name: Option<String>, pub name: Option<String>,
#[schema(nullable, value_type = Object)]
pub metadata: Option<Value>, pub metadata: Option<Value>,
} }
@@ -55,48 +54,51 @@ struct CorrespondentChangeset<'a> {
metadata: Option<&'a Value>, metadata: Option<&'a Value>,
} }
#[utoipa::path(
get,
path = "/api/correspondents",
responses((status = 200, description = "Correspondents", body = [CorrespondentSummary])),
tag = "Correspondents"
)]
pub async fn list_correspondents( pub async fn list_correspondents(
TenantScopedConn { TenantScopedConn {
mut conn, mut conn,
tenant_id, tenant_id,
.. ..
}: TenantScopedConn, }: TenantScopedConn,
) -> AppResult<Json<Vec<CorrespondentSummary>>> { ) -> AppResult<JsonResponse<Vec<CorrespondentSummary>>> {
let correspondents_list: Vec<Correspondent> = correspondents::table let correspondents_list: Vec<Correspondent> = correspondents::table
.filter(correspondents::tenant_id.eq(tenant_id)) .filter(correspondents::tenant_id.eq(tenant_id))
.order(correspondents::name.asc()) .order(correspondents::name.asc())
.load(&mut conn)?; .load(&mut conn)?;
let usage_rows: Vec<(Uuid, String, i64)> = document_correspondents::table let usage_rows: Vec<(Uuid, i64)> = document_correspondents::table
.filter(document_correspondents::tenant_id.eq(tenant_id)) .filter(document_correspondents::tenant_id.eq(tenant_id))
.group_by(( .group_by(document_correspondents::correspondent_id)
document_correspondents::correspondent_id, .select((document_correspondents::correspondent_id, count_star()))
document_correspondents::role,
))
.select((
document_correspondents::correspondent_id,
document_correspondents::role,
count_star(),
))
.load(&mut conn)?; .load(&mut conn)?;
let mut usage_map: HashMap<Uuid, BTreeMap<String, i64>> = HashMap::new(); let mut usage_map: HashMap<Uuid, i64> = HashMap::new();
for (correspondent_id, role, count) in usage_rows { for (correspondent_id, count) in usage_rows {
usage_map usage_map.insert(correspondent_id, count);
.entry(correspondent_id)
.or_default()
.insert(role, count);
} }
let mut response = Vec::with_capacity(correspondents_list.len()); let mut response = Vec::with_capacity(correspondents_list.len());
for correspondent in correspondents_list { for correspondent in correspondents_list {
let role_counts = usage_map.remove(&correspondent.id).unwrap_or_default(); let total = usage_map.remove(&correspondent.id).unwrap_or(0);
response.push(build_summary(correspondent, role_counts)); response.push(build_summary(correspondent, total));
} }
response.into_json() 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( pub async fn create_correspondent(
TenantScopedConn { TenantScopedConn {
mut conn, mut conn,
@@ -104,17 +106,16 @@ pub async fn create_correspondent(
.. ..
}: TenantScopedConn, }: TenantScopedConn,
Json(payload): Json<CreateCorrespondentRequest>, Json(payload): Json<CreateCorrespondentRequest>,
) -> AppResult<Json<CorrespondentSummary>> { ) -> AppResult<JsonResponse<CorrespondentSummary>> {
let name = payload.name.trim(); let name = normalize_name(&payload.name, || {
if name.is_empty() { AppError::bad_request("name must not be empty")
return Err(AppError::bad_request("name must not be empty")); })?;
}
let metadata_value = normalize_metadata(payload.metadata); let metadata_value = normalize_metadata(payload.metadata);
let new_id = Uuid::new_v4(); let new_id = Uuid::new_v4();
let new_correspondent = NewCorrespondent { let new_correspondent = NewCorrespondent {
id: new_id, id: new_id,
name: name.to_string(), name: name.clone(),
metadata: metadata_value, metadata: metadata_value,
tenant_id, tenant_id,
}; };
@@ -134,11 +135,19 @@ pub async fn create_correspondent(
.find(new_id) .find(new_id)
.filter(correspondents::tenant_id.eq(tenant_id)) .filter(correspondents::tenant_id.eq(tenant_id))
.first(&mut conn) .first(&mut conn)
.one()?; .into_app_result()?;
build_summary(correspondent, BTreeMap::new()).into_json() 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( pub async fn update_correspondent(
Path(correspondent_id): Path<Uuid>, Path(correspondent_id): Path<Uuid>,
TenantScopedConn { TenantScopedConn {
@@ -147,30 +156,31 @@ pub async fn update_correspondent(
.. ..
}: TenantScopedConn, }: TenantScopedConn,
Json(payload): Json<UpdateCorrespondentRequest>, Json(payload): Json<UpdateCorrespondentRequest>,
) -> AppResult<Json<CorrespondentSummary>> { ) -> AppResult<JsonResponse<CorrespondentSummary>> {
let existing: Correspondent = correspondents::table let existing: Correspondent = correspondents::table
.find(correspondent_id) .find(correspondent_id)
.filter(correspondents::tenant_id.eq(tenant_id)) .filter(correspondents::tenant_id.eq(tenant_id))
.first(&mut conn) .first(&mut conn)
.one()?; .into_app_result()?;
let mut new_name: Option<String> = None; let mut new_name: Option<String> = None;
if let Some(ref candidate) = payload.name { if let Some(ref candidate) = payload.name {
let trimmed = candidate.trim(); let normalized = normalize_name(candidate, || {
if trimmed.is_empty() { AppError::bad_request("name must not be empty")
return Err(AppError::bad_request("name must not be empty")); })?;
} if normalized != existing.name {
if trimmed != existing.name { ensure_name_available(
let duplicate = correspondents::table || {
.filter(correspondents::name.eq(trimmed)) correspondents::table
.filter(correspondents::name.eq(&normalized))
.filter(correspondents::id.ne(correspondent_id)) .filter(correspondents::id.ne(correspondent_id))
.filter(correspondents::tenant_id.eq(tenant_id)) .filter(correspondents::tenant_id.eq(tenant_id))
.first::<Correspondent>(&mut conn) .first::<Correspondent>(&mut conn)
.optional()?; .optional()
if duplicate.is_some() { },
return Err(AppError::bad_request("correspondent name already exists")); || AppError::bad_request("correspondent name already exists"),
} )?;
new_name = Some(trimmed.to_string()); new_name = Some(normalized);
} }
} }
@@ -184,7 +194,7 @@ pub async fn update_correspondent(
if new_name.is_none() && new_metadata.is_none() { if new_name.is_none() && new_metadata.is_none() {
let usage = load_usage_for_correspondent(&mut conn, tenant_id, correspondent_id)?; let usage = load_usage_for_correspondent(&mut conn, tenant_id, correspondent_id)?;
return build_summary(existing.clone(), usage).into_json(); return ok_json(build_summary(existing.clone(), usage));
} }
let mut changeset = CorrespondentChangeset::default(); let mut changeset = CorrespondentChangeset::default();
@@ -202,17 +212,26 @@ pub async fn update_correspondent(
.filter(correspondents::tenant_id.eq(tenant_id)), .filter(correspondents::tenant_id.eq(tenant_id)),
) )
.set((&changeset, correspondents::updated_at.eq(now))) .set((&changeset, correspondents::updated_at.eq(now)))
.execute(&mut conn)?; .execute(&mut conn)
.into_app_result()?
.or_not_found()?;
let updated: Correspondent = correspondents::table let updated: Correspondent = correspondents::table
.find(correspondent_id) .find(correspondent_id)
.filter(correspondents::tenant_id.eq(tenant_id)) .filter(correspondents::tenant_id.eq(tenant_id))
.first(&mut conn) .first(&mut conn)
.one()?; .into_app_result()?;
let usage = load_usage_for_correspondent(&mut conn, tenant_id, correspondent_id)?; let usage = load_usage_for_correspondent(&mut conn, tenant_id, correspondent_id)?;
build_summary(updated, usage).into_json() 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( pub async fn delete_correspondent(
Path(correspondent_id): Path<Uuid>, Path(correspondent_id): Path<Uuid>,
TenantScopedConn { TenantScopedConn {
@@ -233,33 +252,25 @@ pub async fn delete_correspondent(
)); ));
} }
let deleted = diesel::delete( diesel::delete(
correspondents::table correspondents::table
.filter(correspondents::id.eq(correspondent_id)) .filter(correspondents::id.eq(correspondent_id))
.filter(correspondents::tenant_id.eq(tenant_id)), .filter(correspondents::tenant_id.eq(tenant_id)),
) )
.execute(&mut conn)?; .execute(&mut conn)
if deleted == 0 { .into_app_result()?
return Err(AppError::not_found()); .or_not_found()?;
}
no_content() no_content()
} }
fn build_summary( fn build_summary(correspondent: Correspondent, usage_count: i64) -> CorrespondentSummary {
correspondent: Correspondent,
role_counts: BTreeMap<String, i64>,
) -> CorrespondentSummary {
let total = role_counts.values().copied().sum();
CorrespondentSummary { CorrespondentSummary {
id: correspondent.id, id: correspondent.id,
name: correspondent.name, name: correspondent.name,
metadata: correspondent.metadata, metadata: correspondent.metadata,
created_at: to_iso(correspondent.created_at), created_at: to_iso(correspondent.created_at),
updated_at: to_iso(correspondent.updated_at), updated_at: to_iso(correspondent.updated_at),
usage: CorrespondentUsage { usage_count,
total,
by_role: role_counts,
},
} }
} }
@@ -274,17 +285,28 @@ fn load_usage_for_correspondent(
conn: &mut PgConnection, conn: &mut PgConnection,
tenant_id: Uuid, tenant_id: Uuid,
correspondent_id: Uuid, correspondent_id: Uuid,
) -> AppResult<BTreeMap<String, i64>> { ) -> AppResult<i64> {
let rows: Vec<(String, i64)> = document_correspondents::table let total: i64 = document_correspondents::table
.filter(document_correspondents::correspondent_id.eq(correspondent_id)) .filter(document_correspondents::correspondent_id.eq(correspondent_id))
.filter(document_correspondents::tenant_id.eq(tenant_id)) .filter(document_correspondents::tenant_id.eq(tenant_id))
.group_by(document_correspondents::role) .select(count_star())
.select((document_correspondents::role, count_star())) .get_result(conn)?;
.load(conn)?;
let mut map = BTreeMap::new(); Ok(total)
for (role, count) in rows {
map.insert(role, count);
}
Ok(map)
} }
#[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;
File diff suppressed because it is too large Load Diff
+155 -355
View File
@@ -2,170 +2,113 @@ use axum::{
extract::{Json, Path, Query, State}, extract::{Json, Path, Query, State},
http::StatusCode, http::StatusCode,
}; };
use diesel::{dsl::exists, prelude::*, PgConnection}; use utoipa::OpenApi;
use serde::{Deserialize, Serialize};
use uuid::Uuid; use uuid::Uuid;
use crate::models::{Document, Folder, NewFolder};
use crate::schema::{documents, folders};
use crate::state::AppState;
use crate::{ use crate::{
auth::TenantScopedConn, auth::TenantScopedConn,
error::{AppError, AppResult}, 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 super::documents::{ use crate::services::documents::DocumentResponse;
load_correspondents_for_documents, load_primary_assets, load_tags_for_documents,
to_document_response, DocumentResponse,
};
use crate::utils::time::to_iso;
#[derive(Deserialize)] #[derive(utoipa::ToSchema, serde::Serialize)]
pub struct CreateFolderRequest {
pub name: String,
pub parent_id: Option<Uuid>,
}
#[derive(Deserialize)]
pub struct EnsureFolderPathRequest {
pub parent_id: Option<Uuid>,
pub segments: Vec<String>,
}
#[derive(Deserialize)]
pub struct UpdateFolderRequest {
#[serde(default)]
pub parent_id: Option<Option<Uuid>>,
pub name: Option<String>,
}
#[derive(Serialize)]
pub struct FolderResponse { pub struct FolderResponse {
pub folder: FolderInfo, pub folder: FolderInfo,
} }
#[derive(Serialize)] #[derive(utoipa::ToSchema, serde::Serialize)]
pub struct FolderContentsResponse { pub struct FolderContentsResponse {
#[schema(nullable)]
pub folder: Option<FolderInfo>, pub folder: Option<FolderInfo>,
pub subfolders: Vec<FolderInfo>, pub subfolders: Vec<FolderInfo>,
pub documents: Vec<DocumentResponse>, pub documents: Vec<DocumentResponse>,
} }
#[derive(Deserialize)] #[utoipa::path(
pub struct FolderContentsQuery { get,
#[serde(default = "default_include_documents")] path = "/api/folders/{id}",
pub include_documents: bool, params(("id" = Uuid, Path, description = "Folder ID")),
} responses((status = 200, description = "Folder detail", body = FolderResponse)),
tag = "Folders"
const fn default_include_documents() -> bool { )]
true pub async fn get_folder(
} State(state): State<AppState>,
Path(folder_id): Path<Uuid>,
#[derive(Serialize)] TenantScopedConn {
pub struct FolderInfo { mut conn,
pub id: Uuid, tenant_id,
pub name: String, ..
pub parent_id: Option<Uuid>, }: TenantScopedConn,
pub created_at: String, ) -> AppResult<JsonResponse<FolderResponse>> {
pub updated_at: String, 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( pub async fn ensure_folder_path(
State(state): State<AppState>,
TenantScopedConn { TenantScopedConn {
mut conn, mut conn,
tenant_id, tenant_id,
.. ..
}: TenantScopedConn, }: TenantScopedConn,
Json(payload): Json<EnsureFolderPathRequest>, Json(payload): Json<EnsureFolderPathRequest>,
) -> AppResult<Json<FolderResponse>> { ) -> AppResult<JsonResponse<FolderResponse>> {
if payload.segments.is_empty() { let service = FolderService::new(&state);
return Err(AppError::bad_request("segments must not be empty")); let folder = service.ensure_folder_path(&mut conn, tenant_id, payload)?;
} ok_json(FolderResponse { folder })
let target_folder = conn.transaction::<Folder, AppError, _>(|conn| {
let mut current_parent = payload.parent_id;
let mut last_folder: Option<Folder> = None;
for raw_name in &payload.segments {
let name = raw_name.trim();
if name.is_empty() {
return Err(AppError::bad_request("folder names must not be empty"));
}
let existing: Option<Folder> = if let Some(parent_id) = current_parent {
folders::table
.filter(folders::parent_id.eq(Some(parent_id)))
.filter(folders::name.eq(name))
.filter(folders::tenant_id.eq(tenant_id))
.first(conn)
.optional()?
} else {
folders::table
.filter(folders::parent_id.is_null())
.filter(folders::name.eq(name))
.filter(folders::tenant_id.eq(tenant_id))
.first(conn)
.optional()?
};
let folder = if let Some(folder) = existing {
folder
} else {
let new_folder = NewFolder {
id: Uuid::new_v4(),
name: name.to_string(),
parent_id: current_parent,
tenant_id,
};
diesel::insert_into(folders::table)
.values(&new_folder)
.execute(conn)?;
folders::table.find(new_folder.id).first(conn)?
};
current_parent = Some(folder.id);
last_folder = Some(folder);
}
last_folder.ok_or_else(|| AppError::internal("failed to resolve folder path".to_string()))
})?;
Ok(Json(FolderResponse {
folder: folder_to_info(target_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( pub async fn create_folder(
State(state): State<AppState>,
TenantScopedConn { TenantScopedConn {
mut conn, mut conn,
tenant_id, tenant_id,
.. ..
}: TenantScopedConn, }: TenantScopedConn,
Json(payload): Json<CreateFolderRequest>, Json(payload): Json<CreateFolderRequest>,
) -> AppResult<Json<FolderResponse>> { ) -> AppResult<JsonResponse<FolderResponse>> {
if payload.name.trim().is_empty() { let service = FolderService::new(&state);
return Err(AppError::bad_request("name must not be empty")); let (folder, created) = service.create_folder(&mut conn, tenant_id, payload)?;
let response = FolderResponse { folder };
if created {
created_json(response)
} else {
ok_json(response)
} }
let new_folder = NewFolder {
id: Uuid::new_v4(),
name: payload.name.trim().to_string(),
parent_id: payload.parent_id,
tenant_id,
};
diesel::insert_into(folders::table)
.values(&new_folder)
.execute(&mut conn)?;
let folder: Folder = folders::table.find(new_folder.id).first(&mut conn)?;
Ok(Json(FolderResponse {
folder: folder_to_info(folder),
}))
} }
#[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( pub async fn list_folder_contents(
State(state): State<AppState>, State(state): State<AppState>,
Path(folder_identifier): Path<String>, Path(folder_identifier): Path<String>,
@@ -176,7 +119,13 @@ pub async fn list_folder_contents(
user_id, user_id,
.. ..
}: TenantScopedConn, }: TenantScopedConn,
) -> AppResult<Json<FolderContentsResponse>> { ) -> AppResult<JsonResponse<FolderContentsResponse>> {
let FolderContentsQuery {
include_documents,
sort,
dir,
} = query;
let folder_id = if folder_identifier.eq_ignore_ascii_case("root") { let folder_id = if folder_identifier.eq_ignore_ascii_case("root") {
None None
} else { } else {
@@ -186,82 +135,61 @@ pub async fn list_folder_contents(
) )
}; };
let folder = match folder_id { let service = FolderService::new(&state);
Some(id) => Some(folder_to_info( let FolderContentsData {
folders::table folder,
.find(id) subfolders,
.filter(folders::tenant_id.eq(tenant_id)) documents,
.first::<Folder>(&mut conn)?, } = service.list_folder_contents(
)), &mut conn,
None => None, tenant_id,
}; folder_id,
sort,
dir,
include_documents,
)?;
let child_folders: Vec<Folder> = if let Some(parent_id) = folder_id { let documents = if include_documents {
folders::table service.hydrate_documents(&mut conn, tenant_id, user_id, documents)?
.filter(folders::parent_id.eq(parent_id))
.filter(folders::tenant_id.eq(tenant_id))
.order(folders::name.asc())
.load(&mut conn)?
} else {
folders::table
.filter(folders::parent_id.is_null())
.filter(folders::tenant_id.eq(tenant_id))
.order(folders::name.asc())
.load(&mut conn)?
};
let subfolders = child_folders.into_iter().map(folder_to_info).collect();
let documents = if query.include_documents {
let docs_query = documents::table
.filter(documents::deleted_at.is_null())
.filter(documents::tenant_id.eq(tenant_id))
.order(documents::uploaded_at.desc());
let docs: Vec<Document> = if let Some(current_folder) = folder_id {
docs_query
.filter(documents::folder_id.eq(current_folder))
.load(&mut conn)?
} else {
docs_query
.filter(documents::folder_id.is_null())
.load(&mut conn)?
};
let doc_ids: Vec<Uuid> = docs.iter().map(|doc| doc.id).collect();
let tags_map = load_tags_for_documents(&mut conn, &doc_ids)?;
let mut correspondents_map = load_correspondents_for_documents(&mut conn, &doc_ids)?;
drop(conn);
let primary_versions = load_primary_assets(&state, tenant_id, &docs).await?;
let mut documents = Vec::with_capacity(doc_ids.len());
for doc in docs {
let tags = tags_map.get(&doc.id).cloned();
let correspondents = correspondents_map.remove(&doc.id).unwrap_or_default();
let current_version = primary_versions.get(&doc.id).cloned();
documents.push(to_document_response(
&state,
user_id,
doc,
tags,
correspondents,
current_version,
)?);
}
documents
} else { } else {
Vec::new() Vec::new()
}; };
Ok(Json(FolderContentsResponse { ok_json(FolderContentsResponse {
folder, folder,
subfolders, subfolders,
documents, 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<AppState>,
TenantScopedConn {
mut conn,
tenant_id,
..
}: TenantScopedConn,
) -> AppResult<JsonResponse<Vec<FolderTreeNode>>> {
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( pub async fn delete_folder(
State(state): State<AppState>,
Path(folder_id): Path<Uuid>, Path(folder_id): Path<Uuid>,
TenantScopedConn { TenantScopedConn {
mut conn, mut conn,
@@ -269,53 +197,20 @@ pub async fn delete_folder(
.. ..
}: TenantScopedConn, }: TenantScopedConn,
) -> AppResult<StatusCode> { ) -> AppResult<StatusCode> {
conn.transaction::<_, AppError, _>(|conn| { FolderService::new(&state).delete_folder(&mut conn, tenant_id, folder_id)?;
folders::table no_content()
.find(folder_id)
.filter(folders::tenant_id.eq(tenant_id))
.first::<Folder>(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)?;
Ok(())
})?;
Ok(StatusCode::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( pub async fn update_folder(
State(state): State<AppState>,
Path(folder_id): Path<Uuid>, Path(folder_id): Path<Uuid>,
TenantScopedConn { TenantScopedConn {
mut conn, mut conn,
@@ -324,125 +219,30 @@ pub async fn update_folder(
}: TenantScopedConn, }: TenantScopedConn,
Json(payload): Json<UpdateFolderRequest>, Json(payload): Json<UpdateFolderRequest>,
) -> AppResult<StatusCode> { ) -> AppResult<StatusCode> {
conn.transaction::<(), AppError, _>(|conn| { FolderService::new(&state).update_folder(&mut conn, tenant_id, folder_id, payload)?;
let folder: Folder = folders::table no_content()
.find(folder_id) }
.filter(folders::tenant_id.eq(tenant_id))
.first(conn)?;
let mut next_parent = folder.parent_id; #[derive(OpenApi)]
let mut parent_changed = false; #[openapi(
paths(
if let Some(parent_request) = payload.parent_id { crate::routes::folders::create_folder,
if parent_request == Some(folder_id) { crate::routes::folders::ensure_folder_path,
return Err(AppError::bad_request("folder cannot be its own parent")); crate::routes::folders::get_folder,
} crate::routes::folders::list_folder_contents,
crate::routes::folders::list_folder_tree,
if let Some(parent_id) = parent_request { crate::routes::folders::delete_folder,
let _parent: Folder = folders::table crate::routes::folders::update_folder
.find(parent_id) ),
.filter(folders::tenant_id.eq(tenant_id)) components(schemas(
.first(conn)?; crate::services::folders::CreateFolderRequest,
crate::services::folders::EnsureFolderPathRequest,
let descendant_ids = gather_descendant_folder_ids(conn, tenant_id, folder_id)?; crate::routes::folders::FolderResponse,
if descendant_ids.contains(&parent_id) { crate::services::folders::FolderInfo,
return Err(AppError::bad_request( crate::services::folders::FolderContentsQuery,
"cannot move folder into itself or a descendant", crate::routes::folders::FolderContentsResponse,
)); crate::services::folders::FolderTreeNode,
} crate::services::folders::UpdateFolderRequest
}
parent_changed = parent_request != folder.parent_id;
next_parent = parent_request;
}
let mut new_name = folder.name.clone();
let mut name_changed = false;
if let Some(name) = payload.name {
let trimmed = name.trim();
if trimmed.is_empty() {
return Err(AppError::bad_request("name must not be empty"));
}
if trimmed != folder.name {
new_name = trimmed.to_string();
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::<Folder>(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::<Folder>(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)?; )]
pub struct FoldersApiDoc;
Ok(())
})?;
Ok(StatusCode::NO_CONTENT)
}
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),
}
}
pub(super) fn gather_descendant_folder_ids(
conn: &mut PgConnection,
tenant_id: Uuid,
folder_id: Uuid,
) -> AppResult<Vec<Uuid>> {
let mut ids = vec![folder_id];
let mut queue = vec![folder_id];
while let Some(current) = queue.pop() {
let child_ids: Vec<Uuid> = 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)
}
+43 -3
View File
@@ -1,6 +1,46 @@
use axum::{http::StatusCode, response::Json}; use axum::{extract::State, http::StatusCode, response::Json};
use diesel::RunQueryDsl;
use serde_json::json; use serde_json::json;
pub async fn health_check() -> (StatusCode, Json<serde_json::Value>) { use crate::state::AppState;
(StatusCode::OK, Json(json!({ "status": "ok" })))
#[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<AppState>) -> (StatusCode, Json<serde_json::Value>) {
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))
} }
+395 -39
View File
@@ -2,19 +2,33 @@ use axum::http::HeaderValue;
use axum::{ use axum::{
extract::DefaultBodyLimit, extract::DefaultBodyLimit,
middleware, middleware,
response::{Html, Json},
routing::{delete, get, patch, post}, routing::{delete, get, patch, post},
Router, Router,
}; };
use tower_http::cors::{AllowOrigin, CorsLayer}; use std::sync::Arc;
use tower_http::{
cors::{AllowOrigin, CorsLayer},
trace::{DefaultMakeSpan, DefaultOnFailure, DefaultOnResponse, TraceLayer},
};
use utoipa::OpenApi;
use crate::{auth::AuthenticatedUser, state::AppState}; use crate::{
auth::{capability_guard::RequireCapabilitiesLayer, AuthenticatedUser},
models::ApiCapability,
openapi::ApiDoc,
state::AppState,
};
pub mod auth; pub mod auth;
pub mod capability_sets;
pub mod correspondents; pub mod correspondents;
pub mod documents; pub mod documents;
pub mod folders; pub mod folders;
pub mod health; pub mod health;
pub mod profile;
pub mod tags; pub mod tags;
pub mod tenants;
pub mod webdav; pub mod webdav;
pub fn create_router(state: AppState) -> Router<()> { pub fn create_router(state: AppState) -> Router<()> {
@@ -47,94 +61,436 @@ pub fn create_router(state: AppState) -> Router<()> {
}; };
let auth_routes = Router::new() 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("/login", post(auth::login))
.route("/exchange-api-token", post(auth::api_token_exchange))
.route("/refresh", post(auth::refresh)) .route("/refresh", post(auth::refresh))
.route("/logout", post(auth::logout)) .route("/logout", post(auth::logout))
.route("/select-tenant", post(auth::select_tenant)) .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)); .route("/me", get(auth::me));
let documents_routes = Router::new() let documents_routes = Router::new()
.route( .route(
"/check",
get(documents::check_document).layer(RequireCapabilitiesLayer::all([
ApiCapability::DocumentsRead,
])),
)
.route(
"/", "/",
get(documents::list_documents).post(documents::upload_document), 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/move", post(documents::bulk_move_documents))
.route("/bulk/tags", post(documents::bulk_update_tags))
.route( .route(
"/bulk/correspondents", "/bulk/correspondents",
post(documents::bulk_assign_correspondents), post(documents::bulk_assign_correspondents).layer(RequireCapabilitiesLayer::all([
ApiCapability::DocumentsEdit,
])),
) )
.route( .route(
"/bulk/reanalyze", "/bulk/reanalyze",
post(documents::reanalyze_selected_documents), post(documents::reanalyze_selected_documents).layer(RequireCapabilitiesLayer::all([
ApiCapability::DocumentsWrite,
])),
) )
.route( .route(
"/:id", "/{id}",
get(documents::get_document) get(documents::get_document).layer(RequireCapabilitiesLayer::all([
.delete(documents::delete_document) ApiCapability::DocumentsRead,
.patch(documents::update_document), ])),
)
.route("/:id/download", get(documents::download_document))
.route(
"/:id/assets",
get(documents::list_document_assets).post(documents::request_document_assets),
)
.route("/:id/folder", patch(documents::move_document))
.route("/:id/tags", post(documents::assign_tags))
.route("/:id/tags/:tag_id", delete(documents::remove_tag))
.route(
"/:id/correspondents",
post(documents::assign_correspondents),
) )
.route( .route(
"/:id/correspondents/:correspondent_id", "/{id}/download",
delete(documents::remove_correspondent), 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 = let download_routes =
Router::new().route("/download/:token", get(documents::download_with_token)); Router::new().route("/api/download/{token}", get(documents::download_with_token));
let folders_routes = Router::new() let folders_routes = Router::new()
.route("/", post(folders::create_folder))
.route("/path", post(folders::ensure_folder_path))
.route( .route(
"/:id", "/",
delete(folders::delete_folder).patch(folders::update_folder), post(folders::create_folder)
.layer(RequireCapabilitiesLayer::all([ApiCapability::FoldersWrite])),
) )
.route("/:id/contents", get(folders::list_folder_contents)); .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() let tags_routes = Router::new()
.route("/", get(tags::list_tags).post(tags::create_tag)) .route(
.route("/:id", patch(tags::update_tag).delete(tags::delete_tag)); "/",
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() let correspondents_routes = Router::new()
.route( .route(
"/", "/",
get(correspondents::list_correspondents).post(correspondents::create_correspondent), get(correspondents::list_correspondents).layer(RequireCapabilitiesLayer::all([
ApiCapability::CorrespondentsRead,
])),
) )
.route( .route(
"/:id", "/",
patch(correspondents::update_correspondent) post(correspondents::create_correspondent).layer(RequireCapabilitiesLayer::all([
.delete(correspondents::delete_correspondent), 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 protected_state = state.clone();
let assets_routes = Router::new().route("/:asset_id", get(documents::get_document_asset)); 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() let protected_routes = Router::new()
.nest("/api/documents", documents_routes) .nest("/api/documents", documents_routes)
.nest("/api/folders", folders_routes) .nest("/api/folders", folders_routes)
.nest("/api/tags", tags_routes) .nest("/api/tags", tags_routes)
.nest("/api/correspondents", correspondents_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/assets", assets_routes)
.nest("/api/tenants", tenants_routes)
.layer(middleware::from_extractor_with_state::<AuthenticatedUser, _>(protected_state)); .layer(middleware::from_extractor_with_state::<AuthenticatedUser, _>(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() Router::new()
.merge(download_routes) .merge(download_routes)
.merge(protected_routes) .merge(protected_routes)
.merge(docs_router)
.nest("/api/auth", auth_routes) .nest("/api/auth", auth_routes)
.route("/api/health", get(health::health_check)) .route("/api/health", get(health::health_check))
.with_state(state) .with_state(state)
.layer(cors) .layer(cors)
.layer(DefaultBodyLimit::max(1024 * 1024 * 512)) .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#"<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Papercrate API Docs</title>
<link rel="stylesheet" href="https://unpkg.com/swagger-ui-dist@5/swagger-ui.css" />
<style>
html {{ box-sizing: border-box; font-family: sans-serif; }}
*, *:before, *:after {{ box-sizing: inherit; }}
body {{ margin: 0; background: #fafafa; }}
</style>
</head>
<body>
<div id="swagger-ui"></div>
<script src="https://unpkg.com/swagger-ui-dist@5/swagger-ui-bundle.js"></script>
<script>
window.addEventListener('load', () => {{
window.ui = SwaggerUIBundle({{
url: '{spec_url}',
dom_id: '#swagger-ui',
deepLinking: true,
}});
}});
</script>
</body>
</html>"#
)
} }
+150
View File
@@ -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<AppState>,
TenantScopedConn {
mut conn, user_id, ..
}: TenantScopedConn,
) -> AppResult<JsonResponse<Vec<PasskeySummary>>> {
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<AppState>,
TenantScopedConn {
mut conn,
tenant_id,
user_id,
..
}: TenantScopedConn,
) -> AppResult<JsonResponse<Vec<ApiTokenResponse>>> {
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<AppState>,
TenantScopedConn {
mut conn,
tenant_id,
user_id,
..
}: TenantScopedConn,
Json(payload): Json<CreateApiTokenRequest>,
) -> AppResult<JsonResponse<ApiTokenCreatedResponse>> {
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<AppState>,
TenantScopedConn {
mut conn,
tenant_id,
user_id,
..
}: TenantScopedConn,
Path(token_id): Path<Uuid>,
) -> AppResult<JsonResponse<ApiTokenCreatedResponse>> {
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<AppState>,
TenantScopedConn {
mut conn, user_id, ..
}: TenantScopedConn,
Path(token_id): Path<Uuid>,
) -> AppResult<StatusCode> {
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<String>, Query, description = "Optional reason for revoking the passkey")
),
responses((status = 204, description = "Passkey revoked")),
tag = "Profile"
)]
pub async fn delete_passkey(
State(state): State<AppState>,
TenantScopedConn {
mut conn, user_id, ..
}: TenantScopedConn,
Path(passkey_id): Path<Uuid>,
Query(query): Query<RevokePasskeyQuery>,
) -> AppResult<StatusCode> {
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;
+143 -63
View File
@@ -1,20 +1,26 @@
use crate::utils::json::{classify_nullable, NullableValue};
use axum::{extract::Path, http::StatusCode, Json}; use axum::{extract::Path, http::StatusCode, Json};
use diesel::{dsl::count_star, prelude::*}; use diesel::{dsl::count_star, prelude::*};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::collections::HashMap; use std::collections::HashMap;
use utoipa::ToSchema;
use uuid::Uuid; use uuid::Uuid;
use crate::auth::TenantScopedConn; use crate::{
use crate::error::{AppError, AppResult}; auth::TenantScopedConn,
use crate::models::{NewTag, Tag}; error::{AppError, AppResult},
use crate::schema::{document_tags, tags}; http::responders::{no_content, ok_json, IntoAppResult, JsonResponse, RowsAffectedExt},
use crate::utils::db::{no_content, EnsureEntity, IntoJsonResponse}; models::{NewTag, Tag},
schema::{document_tags, tags},
utils::{
json::deserialize_patch_field,
named_entity::{ensure_name_available, normalize_name},
},
};
#[derive(Deserialize)] #[derive(Deserialize, ToSchema)]
pub struct CreateTagRequest { pub struct CreateTagRequest {
pub label: String, pub label: String,
#[schema(nullable)]
pub color: Option<String>, pub color: Option<String>,
} }
@@ -25,21 +31,62 @@ struct UpdateTagChangeset<'a> {
color: Option<Option<&'a str>>, color: Option<Option<&'a str>>,
} }
#[derive(Serialize)] #[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 struct TagCatalogEntry {
pub id: Uuid, pub id: Uuid,
pub label: String, pub label: String,
#[schema(nullable)]
pub color: Option<String>, pub color: Option<String>,
pub usage_count: i64, pub usage_count: i64,
} }
#[derive(Debug, Default, Deserialize, ToSchema)]
pub struct UpdateTagRequest {
#[serde(default, deserialize_with = "deserialize_patch_field")]
#[schema(nullable, value_type = Option<String>)]
pub label: Option<Option<String>>,
#[serde(default, deserialize_with = "deserialize_patch_field")]
#[schema(nullable, value_type = Option<String>)]
pub color: Option<Option<String>>,
}
#[utoipa::path(
get,
path = "/api/tags",
responses((status = 200, description = "Tags", body = [TagCatalogEntry])),
tag = "Tags"
)]
pub async fn list_tags( pub async fn list_tags(
TenantScopedConn { TenantScopedConn {
mut conn, mut conn,
tenant_id, tenant_id,
.. ..
}: TenantScopedConn, }: TenantScopedConn,
) -> AppResult<Json<Vec<TagCatalogEntry>>> { ) -> AppResult<JsonResponse<Vec<TagCatalogEntry>>> {
let tag_list: Vec<Tag> = tags::table let tag_list: Vec<Tag> = tags::table
.filter(tags::tenant_id.eq(tenant_id)) .filter(tags::tenant_id.eq(tenant_id))
.order(tags::label.asc()) .order(tags::label.asc())
@@ -63,9 +110,16 @@ pub async fn list_tags(
}) })
.collect(); .collect();
response.into_json() 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( pub async fn create_tag(
TenantScopedConn { TenantScopedConn {
mut conn, mut conn,
@@ -73,14 +127,14 @@ pub async fn create_tag(
.. ..
}: TenantScopedConn, }: TenantScopedConn,
Json(payload): Json<CreateTagRequest>, Json(payload): Json<CreateTagRequest>,
) -> AppResult<Json<TagCatalogEntry>> { ) -> AppResult<JsonResponse<TagCatalogEntry>> {
if payload.label.trim().is_empty() { let label = normalize_name(&payload.label, || {
return Err(AppError::bad_request("label must not be empty")); AppError::bad_request("label must not be empty")
} })?;
let new_tag = NewTag { let new_tag = NewTag {
id: Uuid::new_v4(), id: Uuid::new_v4(),
label: payload.label.trim().to_string(), label: label.clone(),
color: payload.color, color: payload.color,
tenant_id, tenant_id,
}; };
@@ -103,17 +157,24 @@ pub async fn create_tag(
.find(new_tag.id) .find(new_tag.id)
.filter(tags::tenant_id.eq(tenant_id)) .filter(tags::tenant_id.eq(tenant_id))
.first(&mut conn) .first(&mut conn)
.one()?; .into_app_result()?;
TagCatalogEntry { ok_json(TagCatalogEntry {
id: tag.id, id: tag.id,
label: tag.label, label: tag.label,
color: tag.color, color: tag.color,
usage_count: 0, usage_count: 0,
} })
.into_json()
} }
#[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( pub async fn update_tag(
Path(tag_id): Path<Uuid>, Path(tag_id): Path<Uuid>,
TenantScopedConn { TenantScopedConn {
@@ -121,55 +182,51 @@ pub async fn update_tag(
tenant_id, tenant_id,
.. ..
}: TenantScopedConn, }: TenantScopedConn,
Json(body): Json<Value>, Json(payload): Json<UpdateTagRequest>,
) -> AppResult<Json<TagCatalogEntry>> { ) -> AppResult<JsonResponse<TagCatalogEntry>> {
let existing: Tag = tags::table let existing: Tag = tags::table
.find(tag_id) .find(tag_id)
.filter(tags::tenant_id.eq(tenant_id)) .filter(tags::tenant_id.eq(tenant_id))
.first(&mut conn) .first(&mut conn)
.one()?; .into_app_result()?;
let label_class = classify_nullable(body.get("label")).map_err(AppError::bad_request)?; let UpdateTagRequest { label, color } = payload;
let color_class = classify_nullable(body.get("color")).map_err(AppError::bad_request)?;
if matches!(label_class, NullableValue::Omitted) if label.is_none() && color.is_none() {
&& matches!(color_class, NullableValue::Omitted)
{
let usage_count: i64 = document_tags::table let usage_count: i64 = document_tags::table
.filter(document_tags::tag_id.eq(tag_id)) .filter(document_tags::tag_id.eq(tag_id))
.select(count_star()) .select(count_star())
.first(&mut conn)?; .first(&mut conn)?;
return TagCatalogEntry { return ok_json(TagCatalogEntry {
id: existing.id, id: existing.id,
label: existing.label.clone(), label: existing.label.clone(),
color: existing.color.clone(), color: existing.color.clone(),
usage_count, usage_count,
} });
.into_json();
} }
let mut new_label: Option<String> = None; let mut new_label: Option<String> = None;
let mut label_changed = false; let mut label_changed = false;
match label_class { match label {
NullableValue::Omitted => {} None => {}
NullableValue::Null => { Some(None) => {
return Err(AppError::bad_request("label cannot be null")); return Err(AppError::bad_request("label cannot be null"));
} }
NullableValue::String(value) => { Some(Some(value)) => {
let trimmed = value.trim(); let normalized =
if trimmed.is_empty() { normalize_name(&value, || AppError::bad_request("label must not be empty"))?;
return Err(AppError::bad_request("label must not be empty")); if normalized != existing.label {
} ensure_name_available(
if trimmed != existing.label { || {
let duplicate = tags::table tags::table
.filter(tags::label.eq(trimmed)) .filter(tags::label.eq(&normalized))
.filter(tags::id.ne(tag_id)) .filter(tags::id.ne(tag_id))
.filter(tags::tenant_id.eq(tenant_id)) .filter(tags::tenant_id.eq(tenant_id))
.first::<Tag>(&mut conn) .first::<Tag>(&mut conn)
.optional()?; .optional()
if duplicate.is_some() { },
return Err(AppError::bad_request("tag label already exists")); || AppError::bad_request("tag label already exists"),
} )?;
new_label = Some(trimmed.to_string()); new_label = Some(normalized);
label_changed = true; label_changed = true;
} }
} }
@@ -177,13 +234,13 @@ pub async fn update_tag(
let mut color_change: Option<Option<String>> = None; let mut color_change: Option<Option<String>> = None;
let mut color_changed = false; let mut color_changed = false;
match color_class { match color {
NullableValue::Omitted => {} None => {}
NullableValue::Null => { Some(None) => {
color_change = Some(None); color_change = Some(None);
color_changed = true; color_changed = true;
} }
NullableValue::String(value) => { Some(Some(value)) => {
let trimmed = value.trim(); let trimmed = value.trim();
if trimmed.is_empty() { if trimmed.is_empty() {
return Err(AppError::bad_request("color must not be empty")); return Err(AppError::bad_request("color must not be empty"));
@@ -201,12 +258,12 @@ pub async fn update_tag(
.filter(document_tags::tenant_id.eq(tenant_id)) .filter(document_tags::tenant_id.eq(tenant_id))
.select(count_star()) .select(count_star())
.first(&mut conn)?; .first(&mut conn)?;
return Ok(Json(TagCatalogEntry { return ok_json(TagCatalogEntry {
id: existing.id, id: existing.id,
label: existing.label.clone(), label: existing.label.clone(),
color: existing.color.clone(), color: existing.color.clone(),
usage_count, usage_count,
})); });
} }
let changeset = UpdateTagChangeset { let changeset = UpdateTagChangeset {
@@ -222,28 +279,36 @@ pub async fn update_tag(
.filter(tags::tenant_id.eq(tenant_id)), .filter(tags::tenant_id.eq(tenant_id)),
) )
.set(&changeset) .set(&changeset)
.execute(&mut conn)?; .execute(&mut conn)
.into_app_result()?
.or_not_found()?;
let updated: Tag = tags::table let updated: Tag = tags::table
.find(tag_id) .find(tag_id)
.filter(tags::tenant_id.eq(tenant_id)) .filter(tags::tenant_id.eq(tenant_id))
.first(&mut conn) .first(&mut conn)
.one()?; .into_app_result()?;
let usage_count: i64 = document_tags::table let usage_count: i64 = document_tags::table
.filter(document_tags::tag_id.eq(tag_id)) .filter(document_tags::tag_id.eq(tag_id))
.filter(document_tags::tenant_id.eq(tenant_id)) .filter(document_tags::tenant_id.eq(tenant_id))
.select(count_star()) .select(count_star())
.first(&mut conn)?; .first(&mut conn)?;
TagCatalogEntry { ok_json(TagCatalogEntry {
id: updated.id, id: updated.id,
label: updated.label, label: updated.label,
color: updated.color, color: updated.color,
usage_count, usage_count,
} })
.into_json()
} }
#[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( pub async fn delete_tag(
Path(tag_id): Path<Uuid>, Path(tag_id): Path<Uuid>,
TenantScopedConn { TenantScopedConn {
@@ -264,15 +329,30 @@ pub async fn delete_tag(
)); ));
} }
let deleted = diesel::delete( diesel::delete(
tags::table tags::table
.find(tag_id) .find(tag_id)
.filter(tags::tenant_id.eq(tenant_id)), .filter(tags::tenant_id.eq(tenant_id)),
) )
.execute(&mut conn)?; .execute(&mut conn)
if deleted == 0 { .into_app_result()?
return Err(AppError::not_found()); .or_not_found()?;
}
no_content() 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;
+154
View File
@@ -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<AppState>,
user: TenantMembershipUser,
) -> AppResult<Json<Vec<TenantSnippet>>> {
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<AppState>,
Path(tenant_id): Path<Uuid>,
user: TenantMembershipUser,
) -> AppResult<JsonResponse<TenantSnippet>> {
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<AppState>,
Path(tenant_id): Path<Uuid>,
user: AuthenticatedUser,
Json(payload): Json<UpdateTenantRequest>,
) -> AppResult<JsonResponse<TenantSnippet>> {
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<AppState>,
Path(tenant_id): Path<Uuid>,
user: AuthenticatedUser,
) -> AppResult<Json<Vec<TenantUserSummary>>> {
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<AppState>,
Path((tenant_id, target_user_id)): Path<(Uuid, Uuid)>,
user: AuthenticatedUser,
) -> AppResult<JsonResponse<TenantUserSummary>> {
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<AppState>,
Path((tenant_id, target_user_id)): Path<(Uuid, Uuid)>,
user: AuthenticatedUser,
Json(payload): Json<UpdateTenantUserRequest>,
) -> AppResult<JsonResponse<TenantUserSummary>> {
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<AppState>,
Path((tenant_id, target_user_id)): Path<(Uuid, Uuid)>,
user: AuthenticatedUser,
) -> AppResult<StatusCode> {
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;
+120 -163
View File
@@ -8,6 +8,7 @@ use axum::Router;
use base64::engine::general_purpose::STANDARD as BASE64; use base64::engine::general_purpose::STANDARD as BASE64;
use base64::Engine; use base64::Engine;
use diesel::prelude::*; use diesel::prelude::*;
use diesel::OptionalExtension;
use diesel::PgConnection; use diesel::PgConnection;
use futures_util::StreamExt; use futures_util::StreamExt;
use percent_encoding::{percent_decode_str, utf8_percent_encode, NON_ALPHANUMERIC}; use percent_encoding::{percent_decode_str, utf8_percent_encode, NON_ALPHANUMERIC};
@@ -15,31 +16,28 @@ use quick_xml::events::{BytesDecl, BytesEnd, BytesStart, BytesText, Event};
use quick_xml::Writer; use quick_xml::Writer;
use uuid::Uuid; use uuid::Uuid;
use crate::auth::password; 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::error::{AppError, AppResult};
use crate::models::{Document, DocumentVersion, Folder, User}; use crate::models::{ApiCapability, Document, DocumentVersion, Folder, User};
use crate::schema::{ use crate::schema::{
document_versions::dsl as document_versions_dsl, documents::dsl as documents_dsl, document_versions::dsl as document_versions_dsl, documents::dsl as documents_dsl,
folders::dsl as folders_dsl, tenants::dsl as tenant_dsl, folders::dsl as folders_dsl, user_memberships::dsl as memberships_dsl, users::dsl as users_dsl,
user_memberships::dsl as memberships_dsl, users::dsl as users_dsl,
}; };
use crate::state::AppState; use crate::state::{AppState, PgPooledConnection};
use crate::utils::{http::inline_content_disposition, time::to_http_date}; 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 REALM: &str = "Papercrate WebDAV";
const DOWNLOAD_URL_TTL_SECONDS: u64 = 300; const DOWNLOAD_URL_TTL_SECONDS: u64 = 300;
#[derive(Clone, Debug)]
struct TenantEntry {
tenant_id: Uuid,
slug: String,
}
#[derive(Clone, Debug)]
struct WebDavContext { struct WebDavContext {
tenant_id: Uuid,
_user_id: Uuid, _user_id: Uuid,
_username: String, _username: String,
tenants: Vec<TenantEntry>, conn: PgPooledConnection,
} }
pub fn create_router() -> Router<AppState> { pub fn create_router() -> Router<AppState> {
@@ -77,7 +75,7 @@ async fn handle_propfind(
path: &str, path: &str,
headers: HeaderMap, headers: HeaderMap,
) -> Result<Response, AppError> { ) -> Result<Response, AppError> {
let context = match authenticate(state, &headers)? { let mut context = match authenticate(state, &headers)? {
Some(user) => user, Some(user) => user,
None => return Ok(unauthorized_response()), None => return Ok(unauthorized_response()),
}; };
@@ -89,35 +87,21 @@ async fn handle_propfind(
let segments = parse_segments(path)?; let segments = parse_segments(path)?;
let resources = if segments.is_empty() { let tenant_id = context.tenant_id;
build_account_root_resources(&context.tenants, depth)
} else {
let (requested_slug, remainder) = segments.split_first().unwrap();
let tenant_entry = match context
.tenants
.iter()
.find(|entry| entry.slug.eq_ignore_ascii_case(requested_slug))
{
Some(entry) => TenantEntry {
tenant_id: entry.tenant_id,
slug: entry.slug.clone(),
},
None => return Ok(not_found_response()),
};
let resolution = match resolve_path(state, &tenant_entry, remainder)? { 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, Some(resolved) => resolved,
None => return Ok(not_found_response()), None => return Ok(not_found_response()),
}; };
match resolution { match resolution {
ResolvedPath::TenantRoot { chain } => {
let contents = fetch_folder_contents(state, tenant_entry.tenant_id, None)?;
build_resources_for_folder(None, &chain, &contents, depth)
}
ResolvedPath::Folder { folder, chain } => { ResolvedPath::Folder { folder, chain } => {
let contents = let contents =
fetch_folder_contents(state, tenant_entry.tenant_id, Some(folder.id))?; fetch_folder_contents(&mut context.conn, tenant_id, Some(folder.id))?;
build_resources_for_folder(Some(&folder), &chain, &contents, depth) build_resources_for_folder(Some(&folder), &chain, &contents, depth)
} }
ResolvedPath::Document { ResolvedPath::Document {
@@ -128,8 +112,10 @@ async fn handle_propfind(
} }
}; };
let body = render_multistatus(&resources) let body = render_multistatus(&resources).map_err(|err| {
.map_err(|err| AppError::internal(format!("failed to render WebDAV response: {err}")))?; tracing::error!(error = ?err, "failed to render WebDAV response");
AppError::internal("failed to render WebDAV response")
})?;
let response = Response::builder() let response = Response::builder()
.status(multi_status()) .status(multi_status())
@@ -146,34 +132,18 @@ async fn handle_get_or_head(
headers: HeaderMap, headers: HeaderMap,
method: Method, method: Method,
) -> Result<Response, AppError> { ) -> Result<Response, AppError> {
let context = match authenticate(state, &headers)? { let mut context = match authenticate(state, &headers)? {
Some(user) => user, Some(user) => user,
None => return Ok(unauthorized_response()), None => return Ok(unauthorized_response()),
}; };
let tenant_id = context.tenant_id;
let segments = parse_segments(path)?; let segments = parse_segments(path)?;
let (requested_slug, remainder) = match segments.split_first() { if segments.is_empty() {
Some(values) => values,
None => return Ok(method_not_allowed()),
};
let tenant_entry = match context
.tenants
.iter()
.find(|entry| entry.slug.eq_ignore_ascii_case(requested_slug))
{
Some(entry) => TenantEntry {
tenant_id: entry.tenant_id,
slug: entry.slug.clone(),
},
None => return Ok(not_found_response()),
};
if remainder.is_empty() {
return Ok(method_not_allowed()); return Ok(method_not_allowed());
} }
let resolution = match resolve_path(state, &tenant_entry, remainder)? { let resolution = match resolve_path(&mut context.conn, tenant_id, &segments)? {
Some(resolved) => resolved, Some(resolved) => resolved,
None => return Ok(not_found_response()), None => return Ok(not_found_response()),
}; };
@@ -267,18 +237,16 @@ fn parse_segments(path: &str) -> AppResult<Vec<String>> {
} }
fn fetch_folder_contents( fn fetch_folder_contents(
state: &AppState, conn: &mut PgPooledConnection,
tenant_id: Uuid, tenant_id: Uuid,
folder_id: Option<Uuid>, folder_id: Option<Uuid>,
) -> AppResult<WebDavFolderContents> { ) -> AppResult<WebDavFolderContents> {
let mut conn = state.db_for_tenant(tenant_id)?;
let folder = match folder_id { let folder = match folder_id {
Some(id) => Some( Some(id) => Some(
folders_dsl::folders folders_dsl::folders
.filter(folders_dsl::tenant_id.eq(tenant_id)) .filter(folders_dsl::tenant_id.eq(tenant_id))
.find(id) .find(id)
.first::<Folder>(&mut conn)?, .first::<Folder>(conn)?,
), ),
None => None, None => None,
}; };
@@ -288,12 +256,12 @@ fn fetch_folder_contents(
.filter(folders_dsl::tenant_id.eq(tenant_id)) .filter(folders_dsl::tenant_id.eq(tenant_id))
.filter(folders_dsl::parent_id.eq(Some(id))) .filter(folders_dsl::parent_id.eq(Some(id)))
.order(folders_dsl::name.asc()) .order(folders_dsl::name.asc())
.load(&mut conn)?, .load(conn)?,
None => folders_dsl::folders None => folders_dsl::folders
.filter(folders_dsl::tenant_id.eq(tenant_id)) .filter(folders_dsl::tenant_id.eq(tenant_id))
.filter(folders_dsl::parent_id.is_null()) .filter(folders_dsl::parent_id.is_null())
.order(folders_dsl::name.asc()) .order(folders_dsl::name.asc())
.load(&mut conn)?, .load(conn)?,
}; };
let mut docs_query = documents_dsl::documents let mut docs_query = documents_dsl::documents
@@ -307,8 +275,8 @@ fn fetch_folder_contents(
}; };
let documents: Vec<Document> = docs_query let documents: Vec<Document> = docs_query
.order(documents_dsl::uploaded_at.desc()) .order(documents_dsl::created_at.desc())
.load(&mut conn)?; .load(conn)?;
let version_ids: Vec<Uuid> = documents.iter().map(|doc| doc.current_version_id).collect(); let version_ids: Vec<Uuid> = documents.iter().map(|doc| doc.current_version_id).collect();
let versions: Vec<DocumentVersion> = if version_ids.is_empty() { let versions: Vec<DocumentVersion> = if version_ids.is_empty() {
@@ -316,7 +284,7 @@ fn fetch_folder_contents(
} else { } else {
document_versions_dsl::document_versions document_versions_dsl::document_versions
.filter(document_versions_dsl::id.eq_any(&version_ids)) .filter(document_versions_dsl::id.eq_any(&version_ids))
.load(&mut conn)? .load(conn)?
}; };
let mut version_map = versions let mut version_map = versions
@@ -354,9 +322,10 @@ async fn stream_document(
.presign_get_object( .presign_get_object(
&version.s3_key, &version.s3_key,
Duration::from_secs(DOWNLOAD_URL_TTL_SECONDS), Duration::from_secs(DOWNLOAD_URL_TTL_SECONDS),
None,
) )
.await .await
.map_err(|err| AppError::internal(format!("failed to presign document download: {err}")))?; .storage_context("failed to presign document download")?;
let client = reqwest::Client::new(); let client = reqwest::Client::new();
let mut request = client.request(method.clone(), url.clone()); let mut request = client.request(method.clone(), url.clone());
@@ -365,25 +334,24 @@ async fn stream_document(
request = request.header(header::RANGE, range.clone()); request = request.header(header::RANGE, range.clone());
} }
let upstream = request let upstream = request.send().await.map_err(|err| {
.send() tracing::error!(error = ?err, "failed to fetch document stream");
.await AppError::internal("failed to fetch document stream")
.map_err(|err| AppError::internal(format!("failed to fetch document stream: {err}")))?; })?;
let status = let status =
StatusCode::from_u16(upstream.status().as_u16()).unwrap_or(StatusCode::BAD_GATEWAY); StatusCode::from_u16(upstream.status().as_u16()).unwrap_or(StatusCode::BAD_GATEWAY);
if !(status.is_success() || status == StatusCode::PARTIAL_CONTENT) { if !(status.is_success() || status == StatusCode::PARTIAL_CONTENT) {
return Err(AppError::internal(format!( tracing::error!(status = %status, "upstream download returned error status");
"upstream download returned status {status}" return Err(AppError::internal("failed to fetch document stream"));
)));
} }
let mut builder = Response::builder().status(status); let mut builder = Response::builder().status(status);
if let Some(content_type) = upstream.headers().get(header::CONTENT_TYPE) { if let Some(content_type) = upstream.headers().get(header::CONTENT_TYPE) {
builder = builder.header(header::CONTENT_TYPE, content_type); builder = builder.header(header::CONTENT_TYPE, content_type);
} else if let Some(ref typ) = document.content_type { } else if let Some(ref typ) = document.mime_type {
builder = builder.header(header::CONTENT_TYPE, typ); builder = builder.header(header::CONTENT_TYPE, typ);
} }
@@ -404,9 +372,10 @@ async fn stream_document(
builder = builder.header(header::ETAG, format!("\"{}\"", version.id)); builder = builder.header(header::ETAG, format!("\"{}\"", version.id));
if method == Method::HEAD { if method == Method::HEAD {
return builder return builder.body(Body::empty()).map_err(|err| {
.body(Body::empty()) tracing::error!(error = ?err, "failed to build WebDAV response");
.map_err(|err| AppError::internal(format!("failed to build response: {err}"))); AppError::internal("failed to build WebDAV response")
});
} }
let stream = upstream let stream = upstream
@@ -414,9 +383,10 @@ async fn stream_document(
.map(|chunk| chunk.map_err(|err| std::io::Error::new(std::io::ErrorKind::Other, err))); .map(|chunk| chunk.map_err(|err| std::io::Error::new(std::io::ErrorKind::Other, err)));
let body = Body::from_stream(stream); let body = Body::from_stream(stream);
builder builder.body(body).map_err(|err| {
.body(body) tracing::error!(error = ?err, "failed to build WebDAV response");
.map_err(|err| AppError::internal(format!("failed to build response: {err}"))) AppError::internal("failed to build WebDAV response")
})
} }
fn authenticate(state: &AppState, headers: &HeaderMap) -> Result<Option<WebDavContext>, AppError> { fn authenticate(state: &AppState, headers: &HeaderMap) -> Result<Option<WebDavContext>, AppError> {
@@ -458,55 +428,84 @@ fn authenticate(state: &AppState, headers: &HeaderMap) -> Result<Option<WebDavCo
} }
}; };
let (username, password) = match credential_str.split_once(':') { let (presented_username, secret) = match credential_str.split_once(':') {
Some((username, password)) if !username.is_empty() => (username, password), Some((username, secret)) if !username.is_empty() => (username, secret),
_ => return Ok(None), _ => return Ok(None),
}; };
tracing::debug!(%username, "attempting webdav login"); tracing::debug!(presented_username = %presented_username, "attempting webdav login");
let mut conn = state.db_unscoped()?; let mut conn = state.db_unscoped()?;
let user: User = match users_dsl::users let token = match find_active_token_by_secret(
.filter(users_dsl::username.eq(username)) &mut conn,
.first(&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, Ok(user) => user,
Err(diesel::result::Error::NotFound) => { Err(diesel::result::Error::NotFound) => {
tracing::warn!(%username, "webdav user not found"); tracing::warn!(
presented_username = %presented_username,
user_id = %token.user_id,
"webdav token user missing"
);
return Ok(None); return Ok(None);
} }
Err(err) => return Err(AppError::from(err)), Err(err) => return Err(AppError::from(err)),
}; };
let valid = password::verify_password(password, &user.password_hash) apply_user_guc(&mut conn, user.id)?;
.map_err(|_| AppError::internal("failed to verify password"))?;
if !valid { let membership_exists = memberships_dsl::user_memberships
tracing::warn!(%username, "webdav password invalid");
return Ok(None);
}
let tenant_rows: Vec<(Uuid, String)> = memberships_dsl::user_memberships
.inner_join(tenant_dsl::tenants)
.filter(memberships_dsl::user_id.eq(user.id)) .filter(memberships_dsl::user_id.eq(user.id))
.select((tenant_dsl::id, tenant_dsl::slug)) .filter(memberships_dsl::tenant_id.eq(token.tenant_id))
.load(&mut conn)?; .select(memberships_dsl::tenant_id)
.first::<Uuid>(&mut conn)
.optional()?;
if tenant_rows.is_empty() { clear_user_guc(&mut conn)?;
tracing::warn!(%username, "webdav user has no tenant memberships");
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); return Ok(None);
} }
let tenants: Vec<TenantEntry> = tenant_rows apply_tenant_guc(&mut conn, tenant_id)?;
.into_iter() touch_api_token(&mut conn, token.id)?;
.map(|(tenant_id, slug)| TenantEntry { tenant_id, slug })
.collect();
tracing::debug!(%username, tenant_count = tenants.len(), "webdav login success"); tracing::debug!(
presented_username = %presented_username,
username = %user.username,
tenant_id = %tenant_id,
token_id = %token.id,
"webdav token login success"
);
Ok(Some(WebDavContext { Ok(Some(WebDavContext {
tenant_id,
_user_id: user.id, _user_id: user.id,
_username: user.username, _username: user.username,
tenants, conn,
})) }))
} }
@@ -530,7 +529,7 @@ fn build_resources_for_folder(
display_name, display_name,
is_collection: true, is_collection: true,
content_length: None, content_length: None,
content_type: None, mime_type: None,
last_modified, last_modified,
}); });
@@ -546,7 +545,7 @@ fn build_resources_for_folder(
display_name: subfolder.name.clone(), display_name: subfolder.name.clone(),
is_collection: true, is_collection: true,
content_length: None, content_length: None,
content_type: None, mime_type: None,
last_modified: Some(to_http_date(subfolder.updated_at)), last_modified: Some(to_http_date(subfolder.updated_at)),
}); });
} }
@@ -564,37 +563,6 @@ fn build_resources_for_folder(
resources resources
} }
fn build_account_root_resources(tenants: &[TenantEntry], depth: u8) -> Vec<DavResource> {
let mut resources = Vec::new();
resources.push(DavResource {
href: "/".to_string(),
display_name: "/".to_string(),
is_collection: true,
content_length: None,
content_type: None,
last_modified: None,
});
if depth == 0 {
return resources;
}
for tenant in tenants {
let href = build_href(&[tenant.slug.clone()], true);
resources.push(DavResource {
href,
display_name: tenant.slug.clone(),
is_collection: true,
content_length: None,
content_type: None,
last_modified: None,
});
}
resources
}
fn build_resources_for_document( fn build_resources_for_document(
chain: &[String], chain: &[String],
document: &Document, document: &Document,
@@ -615,7 +583,7 @@ fn document_to_resource(
display_name: document.title.clone(), display_name: document.title.clone(),
is_collection: false, is_collection: false,
content_length: Some(version.size_bytes), content_length: Some(version.size_bytes),
content_type: document.content_type.clone(), mime_type: document.mime_type.clone(),
last_modified: Some(to_http_date(document.updated_at)), last_modified: Some(to_http_date(document.updated_at)),
} }
} }
@@ -671,7 +639,7 @@ fn render_multistatus(resources: &[DavResource]) -> Result<Vec<u8>, quick_xml::E
writer.write_event(Event::End(BytesEnd::new("D:getcontentlength")))?; writer.write_event(Event::End(BytesEnd::new("D:getcontentlength")))?;
} }
if let Some(content_type) = &resource.content_type { if let Some(content_type) = &resource.mime_type {
writer.write_event(Event::Start(BytesStart::new("D:getcontenttype")))?; writer.write_event(Event::Start(BytesStart::new("D:getcontenttype")))?;
writer.write_event(Event::Text(BytesText::new(content_type)))?; writer.write_event(Event::Text(BytesText::new(content_type)))?;
writer.write_event(Event::End(BytesEnd::new("D:getcontenttype")))?; writer.write_event(Event::End(BytesEnd::new("D:getcontenttype")))?;
@@ -713,13 +681,10 @@ struct DavResource {
display_name: String, display_name: String,
is_collection: bool, is_collection: bool,
content_length: Option<i64>, content_length: Option<i64>,
content_type: Option<String>, mime_type: Option<String>,
last_modified: Option<String>, last_modified: Option<String>,
} }
enum ResolvedPath { enum ResolvedPath {
TenantRoot {
chain: Vec<String>,
},
Folder { Folder {
folder: Folder, folder: Folder,
chain: Vec<String>, chain: Vec<String>,
@@ -732,24 +697,18 @@ enum ResolvedPath {
} }
fn resolve_path( fn resolve_path(
state: &AppState, conn: &mut PgPooledConnection,
tenant: &TenantEntry, tenant_id: Uuid,
segments: &[String], segments: &[String],
) -> AppResult<Option<ResolvedPath>> { ) -> AppResult<Option<ResolvedPath>> {
let mut conn = state.db_for_tenant(tenant.tenant_id)?;
let mut parent_id: Option<Uuid> = None; let mut parent_id: Option<Uuid> = None;
let mut chain: Vec<String> = vec![tenant.slug.clone()]; let mut chain: Vec<String> = Vec::new();
let mut current_folder: Option<Folder> = None; let mut current_folder: Option<Folder> = None;
if segments.is_empty() {
return Ok(Some(ResolvedPath::TenantRoot { chain }));
}
for (index, segment) in segments.iter().enumerate() { for (index, segment) in segments.iter().enumerate() {
let is_last = index == segments.len() - 1; let is_last = index == segments.len() - 1;
if let Some(folder) = find_folder_by_name(&mut conn, tenant.tenant_id, parent_id, segment)? if let Some(folder) = find_folder_by_name(conn, tenant_id, parent_id, segment)? {
{
chain.push(folder.name.clone()); chain.push(folder.name.clone());
if is_last { if is_last {
return Ok(Some(ResolvedPath::Folder { folder, chain })); return Ok(Some(ResolvedPath::Folder { folder, chain }));
@@ -761,7 +720,7 @@ fn resolve_path(
if is_last { if is_last {
if let Some((document, version)) = if let Some((document, version)) =
find_document_by_filename(&mut conn, tenant.tenant_id, parent_id, segment)? find_document_by_filename(conn, tenant_id, parent_id, segment)?
{ {
chain.push(document.filename.clone()); chain.push(document.filename.clone());
return Ok(Some(ResolvedPath::Document { return Ok(Some(ResolvedPath::Document {
@@ -773,7 +732,7 @@ fn resolve_path(
} }
if let Ok(uuid) = Uuid::parse_str(segment) { if let Ok(uuid) = Uuid::parse_str(segment) {
if let Some(folder) = find_folder_by_id(&mut conn, tenant.tenant_id, uuid)? { if let Some(folder) = find_folder_by_id(conn, tenant_id, uuid)? {
if folder.parent_id != parent_id { if folder.parent_id != parent_id {
return Ok(None); return Ok(None);
} }
@@ -786,9 +745,7 @@ fn resolve_path(
continue; continue;
} }
if let Some((document, version)) = if let Some((document, version)) = find_document_by_id(conn, tenant_id, uuid)? {
find_document_by_id(&mut conn, tenant.tenant_id, uuid)?
{
if document.folder_id != parent_id { if document.folder_id != parent_id {
return Ok(None); return Ok(None);
} }
+25 -29
View File
@@ -1,38 +1,34 @@
use anyhow::Result; use anyhow::{anyhow, Context, Result};
use aws_config::meta::region::RegionProviderChain; use s3::{bucket::Bucket, creds::Credentials, region::Region};
use aws_credential_types::Credentials;
use aws_sdk_s3::{
config::{Builder as S3ConfigBuilder, Region},
Client as S3Client,
};
use crate::config::AppConfig; use crate::config::AppConfig;
pub async fn build_client(config: &AppConfig) -> Result<S3Client> { pub fn build_bucket(config: &AppConfig) -> Result<Bucket> {
let region = Region::new(config.aws_region.clone()); let region = if let Some(endpoint) = &config.aws_endpoint_url {
let region_provider = RegionProviderChain::first_try(Some(region)) Region::Custom {
.or_default_provider() region: config.aws_region.clone(),
.or_else("us-east-1"); endpoint: endpoint.clone(),
#[allow(deprecated)]
let mut loader = aws_config::from_env().region(region_provider);
if let Some(endpoint) = &config.aws_endpoint_url {
loader = loader.endpoint_url(endpoint);
} }
} else {
config
.aws_region
.parse::<Region>()
.context("invalid AWS region")?
};
if let (Some(access_key), Some(secret_key)) = ( let credentials = if let (Some(access_key), Some(secret_key)) = (
config.aws_access_key_id.clone(), config.aws_access_key_id.as_deref(),
config.aws_secret_access_key.clone(), config.aws_secret_access_key.as_deref(),
) { ) {
let credentials = Credentials::new(access_key, secret_key, None, None, "static"); Credentials::new(Some(access_key), Some(secret_key), None, None, None)
loader = loader.credentials_provider(credentials); .context("failed to create static AWS credentials")?
} } else {
Credentials::default().context("failed to load AWS credentials")?
};
let base_config = loader.load().await; let bucket = Bucket::new(&config.s3_bucket, region, credentials)
let s3_config = S3ConfigBuilder::from(&base_config) .map_err(|err| anyhow!("failed to create S3 bucket client: {err}"))?;
.force_path_style(true) let bucket = bucket.with_path_style();
.build();
Ok(S3Client::from_conf(s3_config)) Ok(*bucket)
} }
+137 -31
View File
@@ -1,5 +1,19 @@
// @generated automatically by Diesel CLI. // @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! { diesel::table! {
correspondents (id) { correspondents (id) {
id -> Uuid, id -> Uuid,
@@ -12,17 +26,6 @@ diesel::table! {
} }
} }
diesel::table! {
document_asset_objects (id) {
id -> Uuid,
asset_id -> Uuid,
ordinal -> Int4,
s3_key -> Text,
metadata -> Jsonb,
tenant_id -> Uuid,
}
}
diesel::table! { diesel::table! {
document_assets (id) { document_assets (id) {
id -> Uuid, id -> Uuid,
@@ -31,17 +34,15 @@ diesel::table! {
mime_type -> Text, mime_type -> Text,
metadata -> Jsonb, metadata -> Jsonb,
created_at -> Timestamptz, created_at -> Timestamptz,
cardinality -> Nullable<Int4>, s3_key -> Text,
tenant_id -> Uuid, tenant_id -> Uuid,
} }
} }
diesel::table! { diesel::table! {
document_correspondents (document_id, correspondent_id, role) { document_correspondents (document_id, correspondent_id) {
document_id -> Uuid, document_id -> Uuid,
correspondent_id -> Uuid, correspondent_id -> Uuid,
#[max_length = 32]
role -> Varchar,
assigned_at -> Timestamptz, assigned_at -> Timestamptz,
assigned_by -> Nullable<Uuid>, assigned_by -> Nullable<Uuid>,
tenant_id -> Uuid, tenant_id -> Uuid,
@@ -69,7 +70,6 @@ diesel::table! {
#[max_length = 64] #[max_length = 64]
checksum -> Varchar, checksum -> Varchar,
created_at -> Timestamptz, created_at -> Timestamptz,
operations_summary -> Jsonb,
metadata -> Jsonb, metadata -> Jsonb,
tenant_id -> Uuid, tenant_id -> Uuid,
} }
@@ -83,9 +83,9 @@ diesel::table! {
#[max_length = 255] #[max_length = 255]
original_name -> Varchar, original_name -> Varchar,
#[max_length = 100] #[max_length = 100]
content_type -> Nullable<Varchar>, mime_type -> Nullable<Varchar>,
folder_id -> Nullable<Uuid>, folder_id -> Nullable<Uuid>,
uploaded_at -> Timestamptz, created_at -> Timestamptz,
updated_at -> Timestamptz, updated_at -> Timestamptz,
deleted_at -> Nullable<Timestamptz>, deleted_at -> Nullable<Timestamptz>,
metadata -> Jsonb, metadata -> Jsonb,
@@ -120,12 +120,32 @@ diesel::table! {
last_error -> Nullable<Text>, last_error -> Nullable<Text>,
created_at -> Timestamptz, created_at -> Timestamptz,
updated_at -> Timestamptz, updated_at -> Timestamptz,
tenant_id -> Uuid, tenant_id -> Nullable<Uuid>,
result -> Nullable<Jsonb>,
} }
} }
diesel::table! { diesel::table! {
refresh_tokens (id) { 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<Int4>,
used_count -> Int4,
created_at -> Timestamptz,
created_by -> Nullable<Uuid>,
last_used_at -> Nullable<Timestamptz>,
}
}
diesel::table! {
user_sessions (id) {
id -> Uuid, id -> Uuid,
user_id -> Uuid, user_id -> Uuid,
token_hash -> Text, token_hash -> Text,
@@ -151,15 +171,19 @@ diesel::table! {
} }
diesel::table! { diesel::table! {
use diesel::sql_types::*;
use super::sql_types::TenantStatus;
tenants (id) { tenants (id) {
id -> Uuid, id -> Uuid,
slug -> Text, name -> Text,
storage_root -> Nullable<Text>, storage_root -> Nullable<Text>,
quickwit_index -> Nullable<Text>, quickwit_index -> Nullable<Text>,
status -> Text,
config -> Jsonb, config -> Jsonb,
created_at -> Timestamptz, created_at -> Timestamptz,
updated_at -> Timestamptz, updated_at -> Timestamptz,
status -> TenantStatus,
created_by -> Nullable<Uuid>,
} }
} }
@@ -168,9 +192,29 @@ diesel::table! {
id -> Uuid, id -> Uuid,
user_id -> Uuid, user_id -> Uuid,
tenant_id -> Uuid, tenant_id -> Uuid,
role -> Text,
created_at -> Timestamptz, created_at -> Timestamptz,
updated_at -> Timestamptz, updated_at -> Timestamptz,
capability_set_id -> Nullable<Uuid>,
}
}
diesel::table! {
user_passkeys (id) {
id -> Uuid,
user_id -> Uuid,
credential_id -> Bytea,
public_key -> Bytea,
credential -> Jsonb,
sign_count -> Int8,
transports -> Array<Nullable<Text>>,
aaguid -> Nullable<Uuid>,
nickname -> Nullable<Text>,
created_at -> Timestamptz,
updated_at -> Timestamptz,
last_used_at -> Nullable<Timestamptz>,
revoked_at -> Nullable<Timestamptz>,
revoked_by -> Nullable<Uuid>,
revoked_reason -> Nullable<Text>,
} }
} }
@@ -179,16 +223,66 @@ diesel::table! {
id -> Uuid, id -> Uuid,
#[max_length = 100] #[max_length = 100]
username -> Varchar, username -> Varchar,
#[max_length = 255]
password_hash -> Varchar,
created_at -> Timestamptz, created_at -> Timestamptz,
updated_at -> Timestamptz, updated_at -> Timestamptz,
} }
} }
diesel::table! {
webauthn_challenges (id) {
id -> Uuid,
user_id -> Nullable<Uuid>,
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<Text>,
created_at -> Timestamptz,
last_used_at -> Nullable<Timestamptz>,
expires_at -> Nullable<Timestamptz>,
revoked_at -> Nullable<Timestamptz>,
capability_set_id -> Uuid,
}
}
diesel::joinable!(correspondents -> tenants (tenant_id)); diesel::joinable!(correspondents -> tenants (tenant_id));
diesel::joinable!(document_asset_objects -> document_assets (asset_id)); diesel::joinable!(capability_set_capabilities -> capability_sets (capability_set_id));
diesel::joinable!(document_asset_objects -> tenants (tenant_id)); diesel::joinable!(capability_sets -> tenants (tenant_id));
diesel::joinable!(document_assets -> document_versions (document_version_id)); diesel::joinable!(document_assets -> document_versions (document_version_id));
diesel::joinable!(document_assets -> tenants (tenant_id)); diesel::joinable!(document_assets -> tenants (tenant_id));
diesel::joinable!(document_correspondents -> correspondents (correspondent_id)); diesel::joinable!(document_correspondents -> correspondents (correspondent_id));
@@ -204,15 +298,24 @@ diesel::joinable!(documents -> folders (folder_id));
diesel::joinable!(documents -> tenants (tenant_id)); diesel::joinable!(documents -> tenants (tenant_id));
diesel::joinable!(folders -> tenants (tenant_id)); diesel::joinable!(folders -> tenants (tenant_id));
diesel::joinable!(jobs -> tenants (tenant_id)); diesel::joinable!(jobs -> tenants (tenant_id));
diesel::joinable!(refresh_tokens -> tenants (tenant_id)); diesel::joinable!(magic_tokens -> users (user_id));
diesel::joinable!(refresh_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!(tags -> tenants (tenant_id));
diesel::joinable!(user_memberships -> capability_sets (capability_set_id));
diesel::joinable!(user_memberships -> tenants (tenant_id)); diesel::joinable!(user_memberships -> tenants (tenant_id));
diesel::joinable!(user_memberships -> users (user_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!( diesel::allow_tables_to_appear_in_same_query!(
api_tokens,
correspondents, correspondents,
document_asset_objects, capability_set_capabilities,
capability_sets,
document_assets, document_assets,
document_correspondents, document_correspondents,
document_tags, document_tags,
@@ -220,9 +323,12 @@ diesel::allow_tables_to_appear_in_same_query!(
documents, documents,
folders, folders,
jobs, jobs,
refresh_tokens, magic_tokens,
user_sessions,
tags, tags,
tenants, tenants,
user_memberships, user_memberships,
user_passkeys,
users, users,
webauthn_challenges,
); );
+884
View File
@@ -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<String>,
#[serde(default)]
#[schema(nullable)]
pub magic_token: Option<String>,
#[serde(default)]
#[schema(nullable)]
pub preferred_tenant_id: Option<Uuid>,
}
#[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<TenantSnippet>,
}
#[derive(Serialize, Deserialize, ToSchema, Clone)]
pub struct TenantListResponse {
pub tenants: Vec<TenantSnippet>,
}
#[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<String>,
}
#[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<Response> {
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<JsonResponse<LoginResponse>> {
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::<UserMembership>(&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<JsonResponse<SignupStartResponse>> {
let username = normalize_username(&payload.username)?;
let mut conn = self.state.db_unscoped()?;
let exists: bool = dsl::users
.filter(dsl::username.eq(&username))
.first::<User>(&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<Response> {
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::<User>(&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::<Response, AppError, _>(|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<Response> {
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::<UserSession>(&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<Response> {
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::<Uuid>(&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<JsonResponse<TenantListResponse>> {
let mut conn = self.state.db_unscoped()?;
apply_user_guc(&mut conn, user_id)?;
let tenant_ids: Vec<Uuid> = 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<JsonResponse<TenantSnippet>> {
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<JsonResponse<RegistrationChallengeResponse>> {
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, &current_user)?;
ok_json(challenge)
}
pub fn passkey_register_finish(
&self,
user: AuthenticatedUser,
payload: PasskeyRegistrationFinishPayload,
) -> AppResult<JsonResponse<PasskeySummary>> {
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,
&current_user,
challenge_id,
credential,
nickname,
)?;
ok_json(PasskeySummary::from(passkey))
}
pub fn passkey_login_start(
&self,
username: &str,
) -> AppResult<JsonResponse<AuthenticationChallengeResponse>> {
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<Response> {
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<Uuid>,
) -> AppResult<Response> {
apply_user_guc(conn, user.id)?;
let tenant_ids: Vec<Uuid> = 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<Uuid>,
) -> AppResult<Response> {
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::<Response, AppError, _>(|conn| {
let magic = magic_dsl::magic_tokens
.filter(magic_dsl::token_hash.eq(&token_hash))
.filter(magic_dsl::expires_at.gt(now_naive))
.first::<MagicToken>(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<Response> {
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<chrono::DateTime<Utc>>,
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<Utc>,
) -> 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<String> {
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(),
)
}
+293
View File
@@ -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<ApiCapability>,
}
#[derive(Deserialize, utoipa::ToSchema)]
pub struct CreateCapabilitySetRequest {
#[serde(default)]
#[serde(rename = "slug")]
pub slug: Option<String>,
pub capabilities: Vec<ApiCapability>,
}
#[derive(Deserialize, utoipa::ToSchema)]
pub struct UpdateCapabilitySetRequest {
#[serde(default)]
#[serde(rename = "slug")]
pub slug: Option<String>,
#[serde(default)]
pub capabilities: Option<Vec<ApiCapability>>,
}
pub struct CapabilitySetService;
impl CapabilitySetService {
pub fn new() -> Self {
Self
}
pub fn list(
&self,
conn: &mut PgConnection,
tenant_id: Uuid,
) -> AppResult<JsonResponse<Vec<CapabilitySetResponse>>> {
let sets = cs_dsl::capability_sets
.filter(cs_dsl::tenant_id.eq(tenant_id))
.order(cs_dsl::slug.asc())
.load::<CapabilitySet>(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<JsonResponse<Vec<ApiCapability>>> {
let capabilities = ApiCapability::variants()
.iter()
.map(|value| value.parse::<ApiCapability>().expect("valid capability"))
.collect();
ok_json(capabilities)
}
pub fn get(
&self,
conn: &mut PgConnection,
tenant_id: Uuid,
id: Uuid,
) -> AppResult<JsonResponse<CapabilitySetResponse>> {
let set = cs_dsl::capability_sets
.filter(cs_dsl::tenant_id.eq(tenant_id))
.find(id)
.first::<CapabilitySet>(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<JsonResponse<CapabilitySetResponse>> {
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<JsonResponse<CapabilitySetResponse>> {
let set = cs_dsl::capability_sets
.filter(cs_dsl::tenant_id.eq(tenant_id))
.find(id)
.first::<CapabilitySet>(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::<CapabilitySet, AppError, _>(|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::<CapabilitySet>(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::<CapabilitySet>(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<StatusCode> {
let set = cs_dsl::capability_sets
.filter(cs_dsl::tenant_id.eq(tenant_id))
.find(id)
.first::<CapabilitySet>(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<String> {
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<ApiCapability>) -> CapabilitySetResponse {
CapabilitySetResponse {
id: set.id,
slug: set.slug,
is_system: set.is_system,
cap_version: set.cap_version,
capabilities,
}
}
+261
View File
@@ -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<CorrespondentAssignmentInput>,
#[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<Uuid>,
pub assignments: Vec<CorrespondentAssignmentInput>,
#[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<Uuid> = 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<BulkCorrespondentResponse> {
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<Uuid> = 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<chrono::NaiveDateTime>)> = 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(())
}
}
File diff suppressed because it is too large Load Diff
+635
View File
@@ -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<Uuid>,
}
#[derive(Deserialize, ToSchema)]
pub struct EnsureFolderPathRequest {
#[schema(nullable)]
pub parent_id: Option<Uuid>,
pub segments: Vec<String>,
}
#[derive(Serialize, ToSchema, Clone, Debug)]
pub struct FolderInfo {
pub id: Uuid,
pub name: String,
#[schema(nullable)]
pub parent_id: Option<Uuid>,
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<Uuid>,
pub created_at: String,
pub updated_at: String,
#[serde(default)]
pub children: Vec<FolderTreeNode>,
}
#[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<Uuid>)]
pub parent_id: Option<Option<Uuid>>,
#[serde(default, deserialize_with = "deserialize_patch_field")]
#[schema(nullable)]
pub name: Option<Option<String>>,
}
pub struct FolderContentsData {
pub folder: Option<FolderInfo>,
pub subfolders: Vec<FolderInfo>,
pub documents: Vec<Document>,
}
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<FolderInfo> {
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<FolderInfo> {
if payload.segments.is_empty() {
return Err(AppError::bad_request("segments must not be empty"));
}
let folder = conn.transaction::<Folder, AppError, _>(|conn| {
let mut current_parent = payload.parent_id;
let mut last_folder: Option<Folder> = None;
for raw_name in &payload.segments {
let name = normalize_folder_name(raw_name, "folder names must not be empty")?;
let existing: Option<Folder> = 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<Uuid> = 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<Folder> = 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<Uuid> = 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<Uuid>,
sort: DocumentSortField,
dir: SortDirection,
include_documents: bool,
) -> AppResult<FolderContentsData> {
let folder = match folder_id {
Some(id) => Some(folder_to_info(
folders::table
.find(id)
.filter(folders::tenant_id.eq(tenant_id))
.first::<Folder>(conn)?,
)),
None => None,
};
let child_folders: Vec<Folder> = 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::<Text>("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::<Text>("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::<Text>(primary_sql));
if let Some(second) = secondary_sql {
docs_query = docs_query.then_order_by(sql::<Text>(second));
}
if let Some(current_folder) = folder_id {
docs_query
.filter(documents::folder_id.eq(current_folder))
.load::<Document>(conn)?
} else {
docs_query
.filter(documents::folder_id.is_null())
.load::<Document>(conn)?
}
} else {
Vec::new()
};
Ok(FolderContentsData {
folder,
subfolders,
documents,
})
}
pub fn list_folder_tree(
&self,
conn: &mut PgPooledConnection,
tenant_id: Uuid,
) -> AppResult<Vec<FolderTreeNode>> {
let folders: Vec<Folder> = folders::table
.filter(folders::tenant_id.eq(tenant_id))
.order(sql::<Text>("name COLLATE \"unicode_ci\" ASC"))
.load(conn)?;
let mut node_map: HashMap<Uuid, FolderTreeNode> = HashMap::with_capacity(folders.len());
let mut children_map: HashMap<Uuid, Vec<Uuid>> = HashMap::new();
let mut roots: Vec<Uuid> = 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<Uuid, FolderTreeNode>,
child_map: &HashMap<Uuid, Vec<Uuid>>,
) -> 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::<Folder>(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::<Folder>(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::<Folder>(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::<Folder>(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<Document>,
) -> AppResult<Vec<DocumentResponse>> {
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<Vec<Uuid>> {
let mut ids = vec![folder_id];
let mut queue = vec![folder_id];
while let Some(current) = queue.pop() {
let child_ids: Vec<Uuid> = 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<String> {
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)));
}
}
+25
View File
@@ -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<Document> {
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)
}
+9
View File
@@ -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;

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