Compare commits

482 Commits
dev ... main

Author SHA1 Message Date
Leon Bösche
111e403ebd idle 2026-02-07 23:54:51 +01:00
Leon Bösche
d4e0b1b27f Refactor moveOrgFileHandler to retrieve source and target files by path, improving error handling for file not found scenarios 2026-02-07 23:45:45 +01:00
Leon Bösche
b95f3c22be Update package versions and SHA256 checksums in pubspec.lock 2026-02-07 23:40:31 +01:00
Leon Bösche
b1ac8ce102 Improve error handling in WebDAV file move operations and ensure target directory exists 2026-02-07 23:35:48 +01:00
Leon Bösche
6dad9432d0 Add UpdateFilePath method to update file name and path while preserving ID 2026-02-07 23:14:38 +01:00
Leon Bösche
48b5210e8d Update binary file in go_cloud/api 2026-02-05 16:18:12 +01:00
Leon Bösche
85fed3d1d9 Update project name in CMakeLists.txt and correct description in pubspec.yaml 2026-02-01 08:40:39 +01:00
Leon Bösche
14b5eb1c31 Enhance auto-deploy script for Flutter frontend by ensuring a clean build environment and fetching packages 2026-02-01 01:19:45 +01:00
Leon Bösche
c0bbf378f3 Fix package name in pubspec.yaml and update import path for audio_player_bar 2026-02-01 01:13:38 +01:00
Leon Bösche
65fce91a58 idle 2026-02-01 01:10:50 +01:00
Leon Bösche
bdf545ff87 idle 2026-02-01 01:06:56 +01:00
Leon Bösche
a3a596bbdb Improve avatar download and verification handling with context cancellation and exponential backoff 2026-02-01 00:50:50 +01:00
Leon Bösche
96a044450f Enhance authentication error handling with specific error codes and inline validation feedback 2026-02-01 00:24:19 +01:00
Leon Bösche
17ac65a493 Refactor avatar upload and download handlers to use internal WebDAV client for server-to-server operations 2026-01-31 23:48:08 +01:00
Leon Bösche
9ac649105a Refactor WOPI access checks to prioritize organization membership over user ownership 2026-01-31 23:45:07 +01:00
Leon Bösche
048356dddf Refactor ensureParent method to simplify MKCOL URL construction 2026-01-31 23:37:16 +01:00
Leon Bösche
367afab430 Add diagnostics for MKCOL failures in ensureParent method 2026-01-31 23:17:48 +01:00
Leon Bösche
75a796a43d Limit file name and type display to a single line for better layout consistency 2026-01-31 23:12:44 +01:00
Leon Bösche
43776b123c idleee 2026-01-31 22:12:43 +01:00
Leon Bösche
bf78943f3d Increase avatar upload progress indicator size for better visibility 2026-01-31 22:11:40 +01:00
Leon Bösche
98db5f2e12 idle3000 2026-01-31 22:05:14 +01:00
Leon Bösche
257db646a6 idle 2026-01-31 22:01:10 +01:00
Leon Bösche
ae100c0b34 idle 2026-01-31 21:58:28 +01:00
Leon Bösche
80841e409f Refactor project metadata and improve audio player bar responsiveness 2026-01-31 21:56:10 +01:00
Leon Bösche
d1b2a25bf7 Increase avatar icon size in account settings dialog for better visibility 2026-01-31 19:29:53 +01:00
Leon Bösche
94e9036e87 Increase avatar download timeout and retries; add verification for uploaded avatars with fallback caching 2026-01-31 18:44:52 +01:00
Leon Bösche
33f977293d idle 2026-01-31 18:24:52 +01:00
Leon Bösche
ff4c9bb26c Enhance avatar upload handling by providing immediate preview data and improving cache write logic 2026-01-31 18:09:07 +01:00
Leon Bösche
6085409bad Enhance avatar handling by implementing download retries with backoff and adding timeout configuration 2026-01-31 17:48:30 +01:00
Leon Bösche
cf71b3c495 Refactor delete account button styles to use ButtonStyle for improved customization and consistency 2026-01-30 14:03:01 +01:00
Leon Bösche
4dd36fe98a Refactor button styles in account settings dialog to use ButtonStyle for improved customization and consistency 2026-01-30 14:00:00 +01:00
Leon Bösche
1bc1dd8460 Enhance avatar caching by adding versioning support and improving cache read/write logic 2026-01-30 13:41:17 +01:00
Leon Bösche
87bf4b8ca3 Add defensive check for profile update response and set content type in user profile handler 2026-01-30 13:24:43 +01:00
Leon Bösche
07975c4fbe Improve avatar cache handling by adding fallback directory creation and enhancing read logic 2026-01-29 23:12:12 +01:00
Leon Bösche
a2884a9891 Add avatar caching functionality and update config for cache directory 2026-01-29 23:00:59 +01:00
Leon Bösche
00a585e2c1 Fix avatar URL token handling and improve user avatar download timeout management 2026-01-29 22:55:46 +01:00
Leon Bösche
9b6f5c960a Enhance user profile handling by fetching full profile data and updating avatar URLs in AuthBloc and related components 2026-01-29 22:42:36 +01:00
Leon Bösche
c27f4be6cb Refactor change detection logic in display name listener for better readability 2026-01-29 22:04:14 +01:00
Leon Bösche
36de8c2313 Fix avatar and display name update issues
- Remove avatar handling from profile update to prevent overwriting DB with display URL
- Skip ensureParent for .avatars to speed up upload
- Add change detection for display name save button
- Update API client to not send avatarUrl in profile update
2026-01-29 22:03:36 +01:00
Leon Bösche
b30b8eb934 Fix JWT validation method name 2026-01-29 21:20:01 +01:00
Leon Bösche
cabb330966 Fix JWTManager access in getUserAvatarHandler 2026-01-29 21:19:15 +01:00
Leon Bösche
7a3abe9fa2 Add secure token-based auth for avatar GET 2026-01-29 21:14:50 +01:00
Leon Bösche
bd56e398e5 Remove auth from avatar GET and always allow save profile 2026-01-29 21:13:40 +01:00
Leon Bösche
def7626b37 Fix avatar URL to full URL and increase WebDAV timeout to 60s with ensureParent enabled 2026-01-29 21:04:13 +01:00
Leon Bösche
38c4d071c9 Revert ensureParent for .avatars to avoid MKCOL timeouts 2026-01-29 20:56:32 +01:00
Leon Bösche
8bf6bde38d Fix display name input field and enable ensureParent for avatar uploads
- Remove controller text reset in BlocBuilder to allow typing
- Always call ensureParent in WebDAV upload to create .avatars folder if needed
2026-01-29 20:28:55 +01:00
Leon Bösche
04f08c1b1c Refactor profile update logic and remove debug prints 2026-01-29 20:19:20 +01:00
Leon Bösche
e26f39ee5b Fix profile avatar and display name issues
- Increase Dio receiveTimeout to 120s for file uploads
- Reduce WebDAV client timeout to 30s
- Add cache-busting v parameter to avatar URLs
- Add hasChanges logic to disable Save button when no changes made
2026-01-29 20:18:11 +01:00
Leon Bösche
bb7957cdde Update avatar URL in auth state after successful upload 2026-01-29 12:48:39 +01:00
Leon Bösche
7741bd5ccc Add updated_at column to users table and remove debug logging from profile update handler 2026-01-29 12:39:59 +01:00
Leon Bösche
2678ea2e8a Add debug logging to profile update handlers and increase WebDAV client timeout to 120 seconds 2026-01-29 12:29:38 +01:00
Leon Bösche
cd2cf7fb06 Skip ensuring parent collections for hidden avatar folders during upload to avoid MKCOL timeouts 2026-01-29 12:16:04 +01:00
Leon Bösche
cd34bcddc9 Increase WebDAV client HTTP timeout from 30 to 60 seconds 2026-01-29 12:16:00 +01:00
Leon Bösche
ed86765321 Fix avatar upload path by adding a leading dot to the avatars directory 2026-01-29 12:08:32 +01:00
Leon Bösche
2f96d35657 Update migration steps to reflect correct total count and improve clarity 2026-01-29 10:56:53 +01:00
Leon Bösche
88a69fdaaf Update migration steps to reflect new order and add avatar URL migration 2026-01-29 10:56:03 +01:00
Leon Bösche
d7045aba2a Add save button to AccountSettingsDialog for profile updates 2026-01-29 10:47:50 +01:00
Leon Bösche
071f32ddea Add avatar_url column to users table and create migration scripts for adding and removing it 2026-01-29 10:45:08 +01:00
Leon Bösche
688cec90a8 Refactor WebDAV client to expose BaseURL and update avatar upload logic for improved URL handling 2026-01-29 10:30:24 +01:00
Leon Bösche
11daed18d7 Refactor updateUserProfile method to require displayName and simplify data construction in ApiClient
Add GET route for user avatar retrieval and update CORS settings in routes.go
Implement getUserAvatarHandler to serve user avatars from storage
2026-01-29 10:12:20 +01:00
Leon Bösche
5a62121591 Enhance avatar upload functionality with progress tracking in AccountSettingsDialog 2026-01-29 04:03:48 +01:00
Leon Bösche
b55d277406 Refactor API client logging and enhance user data handling in AccountSettingsDialog 2026-01-29 04:00:33 +01:00
Leon Bösche
49d8d2ea7b Add debug logging for PUT requests and profile updates in AccountSettingsDialog 2026-01-29 03:53:04 +01:00
Leon Bösche
62a23b5fb0 Fix ensureParent method to correctly handle baseURL and cur path separators 2026-01-29 03:48:04 +01:00
Leon Bösche
b6a9e2aa54 Add CORS support for user profile and account routes in the API 2026-01-29 00:59:22 +01:00
Leon Bösche
fcfcc3e127 Delay showing profile update messages until after the build cycle to ensure proper UI updates 2026-01-29 00:55:11 +01:00
Leon Bösche
64de972713 idle 2026-01-29 00:52:58 +01:00
Leon Bösche
1df16f0fe2 Fix ensureParent method to correctly construct MKCOL URLs for empty paths 2026-01-29 00:52:21 +01:00
Leon Bösche
e611c03625 Remove 'Active Links' section from account settings dialog for a cleaner layout 2026-01-29 00:37:07 +01:00
Leon Bösche
03329c10ed Refactor account settings dialog to load user data based on auth state changes and improve error handling with snack bars 2026-01-29 00:35:00 +01:00
Leon Bösche
db86c985f5 Enhance avatar upload functionality to support web by ensuring file bytes are available 2026-01-29 00:31:33 +01:00
Leon Bösche
c9d1af8067 Adjust spacing in account settings dialog for improved layout 2026-01-29 00:28:01 +01:00
Leon Bösche
9001a43375 Refactor account settings dialog for improved clarity and layout, including updated plan descriptions and new upgrade options 2026-01-29 00:16:25 +01:00
Leon Bösche
54d077fcb8 Rename 'Danger Zone' to 'Delete My Data' for improved clarity in account settings dialog 2026-01-29 00:12:24 +01:00
Leon Bösche
2623b6818f Refactor prefix trimming in publicFileDownloadHandler for improved clarity 2026-01-29 00:00:14 +01:00
Leon Bösche
f4f80b9ed7 Remove blurHash references from User model and related components 2026-01-28 23:59:15 +01:00
Leon Bösche
de26b280d0 Add account deletion functionality with confirmation dialog 2026-01-28 23:52:18 +01:00
Leon Bösche
48a45b60fa Adjust logout button position for improved layout in account settings dialog 2026-01-28 23:47:34 +01:00
Leon Bösche
7f668b51f9 Implement folder download as zip in publicFileDownloadHandler 2026-01-28 23:43:02 +01:00
Leon Bösche
03d8a03f7c idle 2026-01-28 03:23:47 +01:00
Leon Bösche
d8285c772e Adjust spacing in account settings dialog for improved layout 2026-01-28 03:23:20 +01:00
Leon Bösche
4519cacb44 idle 2026-01-27 09:38:08 +01:00
Leon Bösche
857a6c7bcf Adjust button width and add spacing in account settings dialog for improved layout 2026-01-27 09:31:21 +01:00
Leon Bösche
ebe2887d7c Refactor profile tab layout: replace SingleChildScrollView with Stack for improved logout button positioning and enhance avatar display 2026-01-27 09:22:44 +01:00
Leon Bösche
262ce18902 idle 2026-01-27 09:20:16 +01:00
Leon Bösche
94ff7f1007 Enhance UI of account settings dialog: update text fields with improved styling and container backgrounds 2026-01-27 08:51:30 +01:00
Leon Bösche
425bfcb495 Refactor user profile update handler to support optional email field and dynamic query construction 2026-01-27 08:47:21 +01:00
Leon Bösche
8400e97d17 Implement user profile management: add update profile functionality, avatar upload, and change password features 2026-01-27 03:28:27 +01:00
Leon Bösche
770a818f74 Add EUROSCALE_DEPLOYMENT_BLUEPRINT.md to .gitignore 2026-01-27 02:31:52 +01:00
Leon Bösche
e53731fd75 idle 2026-01-27 02:15:12 +01:00
Leon Bösche
d73311e74c idle 2026-01-27 02:01:22 +01:00
Leon Bösche
2a4955b934 Add login page and implement session redirection on authentication 2026-01-27 01:50:52 +01:00
Leon Bösche
06ece6dc1b Enhance security architecture and guidelines across documentation and middleware; implement input validation, logging improvements, and security headers in API handlers. 2026-01-27 01:40:36 +01:00
Leon Bösche
abc60399d8 Add folder type checks in viewer handlers to prevent folder viewing 2026-01-26 04:17:38 +01:00
Leon Bösche
5dd6d79d4c Refine CORS settings in file download handlers and update viewer behavior for mobile and authenticated web 2026-01-25 20:24:07 +01:00
Leon Bösche
ad882ae509 Update CORS handling for web image viewing and adjust host assignment in publicFileShareHandler 2026-01-25 20:06:38 +01:00
Leon Bösche
ce29077e8c Add folder type checks in publicFileDownloadHandler and publicFileViewHandler 2026-01-25 20:00:31 +01:00
Leon Bösche
7c86be2098 Enhance image handling in FileViewerDispatch for web and mobile, adding error handling and CORS support 2026-01-25 19:51:18 +01:00
Leon Bösche
38bf8013cd Update error message for file download failure in publicFileViewHandler 2026-01-25 19:47:59 +01:00
Leon Bösche
bdc971927f Add image file handling in PublicFileViewer and update publicFileShareHandler for image viewing 2026-01-25 19:39:33 +01:00
Leon Bösche
c91eaa7db9 Update Collabora URL generation in publicFileShareHandler for improved document viewing 2026-01-25 19:30:53 +01:00
Leon Bösche
3854234a3c Update WOPI source URL for document viewing in publicFileShareHandler 2026-01-25 19:22:26 +01:00
Leon Bösche
0617d258f6 idle 2026-01-25 19:18:24 +01:00
Leon Bösche
f353e634d2 idle 2026-01-25 19:11:04 +01:00
Leon Bösche
b041cd5440 Set initial location for GoRouter based on web platform and clean up unused imports in PublicFileViewer 2026-01-25 18:59:28 +01:00
Leon Bösche
3fb556c8f1 Refactor URL generation in file sharing handlers to remove hash fragment 2026-01-25 18:50:02 +01:00
Leon Bösche
904e909ce1 Set initial location for GoRouter based on web URL hash 2026-01-25 18:37:01 +01:00
Leon Bösche
2205536549 idle 2026-01-25 18:28:39 +01:00
Leon Bösche
0b07522d7c Set URL strategy for web support in main.dart 2026-01-25 18:19:20 +01:00
Leon Bösche
d471634f30 Remove unused web URL strategy configuration from main.dart 2026-01-25 17:16:39 +01:00
Leon Bösche
9aa1667e9c Implement authentication check and redirect for internal file access in PublicFileViewer 2026-01-25 17:11:58 +01:00
Leon Bösche
565b9fed6f Fix GoRouter initialLocation for web share links 2026-01-25 16:57:51 +01:00
Leon Bösche
7a58769139 Enable path-based URL routing for clean share links 2026-01-25 16:45:33 +01:00
Leon Bösche
a451ae8a98 Adjust padding and icon size for download button in PublicFileViewer 2026-01-25 16:40:02 +01:00
Leon Bösche
1c1606d61d Remove duplicate file info header in public file viewer 2026-01-25 16:32:32 +01:00
Leon Bösche
6121acdc4b Adjust padding for leading ModernGlassButton in PublicFileViewer 2026-01-25 16:25:44 +01:00
Leon Bösche
26fa1712ec Fix WebDAV 504 by using internal Nextcloud URL 2026-01-25 16:23:18 +01:00
Leon Bösche
9bc03f6db8 Add file viewer dispatch for handling multiple file types and extend download timeout 2026-01-25 16:14:03 +01:00
Leon Bösche
d5aecdfba8 idle 2026-01-25 16:04:45 +01:00
Leon Bösche
5fa436f204 idle 2026-01-25 16:03:57 +01:00
Leon Bösche
bd4796116e Refactor ModernGlassButton to accept customizable padding 2026-01-25 15:52:05 +01:00
Leon Bösche
0aea602122 Implement public WOPI routes for shared files and integrate Collabora for document viewing 2026-01-25 15:47:59 +01:00
Leon Bösche
7582f27899 Add DOCX viewer support in public file viewer 2026-01-25 15:44:32 +01:00
Leon Bösche
532848ebdf Fix PDF loading by preserving Nextcloud headers
- Revert to synchronous download but keep 5-minute timeout
- Copy Content-Length and other headers from Nextcloud response
- Ensures PDF viewer gets proper content metadata
- Maintains streaming for audio/video with headers
2026-01-25 15:42:10 +01:00
Leon Bösche
387f39cbcc Fix public file streaming with async download
- Use io.Pipe for immediate response headers
- Start WebDAV download in goroutine to avoid blocking
- Stream content as it becomes available
- Prevents client timeouts on slow downloads
- Maintains CORS and MIME type headers
2026-01-25 15:40:04 +01:00
Leon Bösche
4d1e83e9e7 Fix public file download timeout
- Increase timeout for WebDAV downloads from default to 5 minutes
- Prevents 504 Gateway Timeout errors when downloading large files
- Uses context.WithTimeout for better control over download duration
2026-01-25 15:26:09 +01:00
Leon Bösche
a88121d465 Fix PDF loading in public file viewer
- Change PDF viewer to use SfPdfViewer.network instead of loading bytes
- Remove _loadPdfBytes method and _pdfBytes variable
- Use direct network loading for better performance and reliability
- Add onDocumentLoadFailed callback for error handling
- Remove unused dart:typed_data import
2026-01-25 15:23:01 +01:00
Leon Bösche
86f0cb188e Fix public file viewer compilation errors and add PDF styling
- Add missing imports for SfPdfViewer and Uint8List
- Fix _initializeVideoPlayer method declaration
- Correct SfTheme import to use syncfusion_flutter_core/theme.dart
- Restore PDF bytes loading via API for public shares
- Add SfTheme wrapper for PDF viewer styling
2026-01-25 15:13:01 +01:00
Leon Bösche
92bafe57fb Frontend: restore PDF bytes loading for public viewer to fix loading, add SfTheme for app styling consistency 2026-01-25 14:43:44 +01:00
Leon Bösche
b25dd0a10c Remove unused import of 'dart:typed_data' in public_file_viewer.dart 2026-01-25 14:17:28 +01:00
Leon Bösche
0d41cdeebf Frontend: unify PDF viewer to use network URL like internal viewer, remove bytes loading hack 2026-01-25 14:16:28 +01:00
Leon Bösche
083ab8c95d Revert "Restore Traefik dynamic public_share config and copy it during auto-deploy"
This reverts commit 1eae6d5713.
2026-01-25 14:15:48 +01:00
Leon Bösche
62d976eae5 Revert "Remove CORS middleware from public share configuration"
This reverts commit 13a5310ba5.
2026-01-25 14:15:43 +01:00
Leon Bösche
13a5310ba5 Remove CORS middleware from public share configuration 2026-01-25 04:02:15 +01:00
Leon Bösche
1eae6d5713 Restore Traefik dynamic public_share config and copy it during auto-deploy 2026-01-25 03:54:39 +01:00
Leon Bösche
0311122602 Revert API base URL to go.b0esche.cloud to fix login 2026-01-25 03:47:03 +01:00
Leon Bösche
93bc0e582a Change API base URL to www.b0esche.cloud and revert host to www for public URLs 2026-01-25 03:34:17 +01:00
Leon Bösche
d3c174f623 Fix public share URLs to use request host instead of hardcoded www.b0esche.cloud 2026-01-25 03:33:28 +01:00
Leon Bösche
5de1ab0b18 Refactor download button: adjust size and padding for improved layout 2026-01-25 03:32:35 +01:00
Leon Bösche
15de51feb8 Enhance download button: center icon and increase size for better visibility 2026-01-25 03:25:06 +01:00
Leon Bösche
08fc0906c0 Add range request support to publicFileViewHandler for video/audio seeking 2026-01-25 03:23:59 +01:00
Leon Bösche
e7b222bc7d Force correct Content-Type for public files and add OPTIONS handlers for CORS 2026-01-25 03:22:39 +01:00
Leon Bösche
1f3b70ba74 Fix shared audio/video viewer: add CORS and Content-Type headers to public endpoints 2026-01-25 02:47:39 +01:00
Leon Bösche
290556e602 Fix public file viewer: adjust padding for improved layout 2026-01-25 02:30:33 +01:00
Leon Bösche
927d35c984 Fix video view factory: format code for better readability 2026-01-25 02:29:58 +01:00
Leon Bösche
aec4fd0272 Fix public file share handler: include audio MIME types for inline viewing 2026-01-25 02:29:53 +01:00
Leon Bösche
91a9759874 Fix file viewer: increase download button width for better accessibility 2026-01-25 02:20:17 +01:00
Leon Bösche
cb560366b8 Fix audio player: streamline setUrl method call for web implementation 2026-01-25 02:00:59 +01:00
Leon Bösche
132f0cae6c Fix audio player: use explicit platform-specific imports and dynamic typing 2026-01-25 01:59:46 +01:00
Leon Bösche
aa0983eba3 Fix audio source setting: add ignore comments for undefined method and getter 2026-01-25 01:43:09 +01:00
Leon Bösche
143675bad0 Fix web build: add ignore comments for conditional import methods 2026-01-25 01:42:27 +01:00
Leon Bösche
edf83de94c Fix web build: use dynamic cast for Blob arguments 2026-01-25 01:37:22 +01:00
Leon Bösche
edced8825d Fix flutter analyze errors: correct Blob constructor usage and dispose method 2026-01-25 01:31:33 +01:00
Leon Bösche
88f1f5d87e Fix download button centering and width, fix audio/video loading by using blob URLs for web 2026-01-25 01:29:05 +01:00
Leon Bösche
9286fe4dd8 idle0119 2026-01-25 01:19:51 +01:00
Leon Bösche
f346b628ed Fix audio player bar height: center it instead of expanding 2026-01-25 01:19:37 +01:00
Leon Bösche
b819fee208 Fix download button: show only download icon without text 2026-01-25 01:16:38 +01:00
Leon Bösche
cbb18854da idleee 2026-01-25 01:09:25 +01:00
Leon Bösche
bb8cb5a23d Fix PDF loading: fetch PDF bytes directly and use SfPdfViewer.memory() 2026-01-25 01:08:47 +01:00
Leon Bösche
73db23f590 Enhance close button in PublicFileViewer: add custom button style to remove splash effect 2026-01-25 01:05:29 +01:00
Leon Bösche
8fd0ded519 Reorder PublicFileViewer header: download button left, close button right, add 4px top padding 2026-01-25 00:57:55 +01:00
Leon Bösche
c29bd89a0a idle 2026-01-25 00:45:26 +01:00
Leon Bösche
02e4eeec07 Enhance PublicFileViewer: add PDF/video viewing, ModernGlassButton, and improved layout 2026-01-25 00:44:40 +01:00
Leon Bösche
d482c533d7 Refactor initialLocation formatting in GoRouter for improved readability 2026-01-25 00:34:34 +01:00
Leon Bösche
db331ef4ca Fix share link routing: add initialLocation to GoRouter and fix download URL host 2026-01-25 00:34:16 +01:00
Leon Bösche
f44d64b7ad Fix share URL host to always use www.b0esche.cloud instead of r.Host 2026-01-25 00:27:58 +01:00
Leon Bösche
7ed915555b Update file share link host to use 'www' for consistency 2026-01-25 00:20:32 +01:00
Leon Bösche
a321104b4c Update file share link URLs to remove 'public' segment for consistency 2026-01-25 00:02:59 +01:00
Leon Bösche
119e8e0736 Remove unused _revokeShareLink method and update share link message for clarity 2026-01-24 23:54:07 +01:00
Leon Bösche
e4931d4e03 Refactor ShareFileDialog: reduce maxLines for share URL input and streamline button layout for link creation 2026-01-24 23:51:29 +01:00
Leon Bösche
ea5c297641 Implement automatic share link creation and logging for file sharing; make org_id nullable in file_share_links 2026-01-24 23:45:08 +01:00
Leon Bösche
82eba17a82 Enhance file sharing functionality: infer org_id when not provided, update share link responses to include shareUrl 2026-01-24 23:16:51 +01:00
Leon Bösche
421e95d83b Fix nullable org_id handling in public share handlers 2026-01-24 22:56:10 +01:00
Leon Bösche
cca21c09d5 Allow NULL org_id in file_share_links for personal file sharing, update model and handlers 2026-01-24 22:54:50 +01:00
Leon Bösche
c7aab0b026 Fix file sharing: add backend routes for /orgs/files/{fileId}/share, update frontend ShareFileDialog to use correct paths and improve UI 2026-01-24 22:47:27 +01:00
Leon Bösche
228a5c9644 Implement user-specific share link management for files 2026-01-24 22:32:58 +01:00
Leon Bösche
acfd882bba Refactor ShareFileDialog header to improve layout and ensure text overflow handling 2026-01-24 22:21:50 +01:00
Leon Bösche
5f7d831bdd Update share button icon to use send_outlined for improved clarity 2026-01-24 22:14:53 +01:00
Leon Bösche
1cf778366f Enhance file sharing handlers to support user ownership checks and improve error handling 2026-01-24 22:13:23 +01:00
Leon Bösche
d8133347f0 Refactor ShareFileDialog to improve UI layout, error handling, and share link management 2026-01-24 21:53:13 +01:00
Leon Bösche
b703a209d0 Refactor share file dialog to auto-create share link and simplify error handling 2026-01-24 21:49:55 +01:00
Leon Bösche
896f475b03 Refactor file sharing UI to use TextField for share link display and improve error handling 2026-01-24 21:38:29 +01:00
Leon Bösche
49c578cef2 Remove backup of file_share_links table migration 2026-01-24 21:22:39 +01:00
Leon Bösche
44081099c4 Update migration steps to include file share links in the migration process 2026-01-24 21:20:06 +01:00
Leon Bösche
828b63c8c8 Refactor file download handling to simplify browser download process and ensure proper content disposition for file downloads 2026-01-24 21:11:25 +01:00
Leon Bösche
6bbdc157cb Implement file sharing functionality with public share links and associated API endpoints 2026-01-24 21:06:18 +01:00
Leon Bösche
4770380e38 Refactor role selection dropdown to use Material widget for improved styling and tap target size 2026-01-24 06:18:09 +01:00
Leon Bösche
a9c49a0282 Enhance role selection dropdown with custom InkWell and update dropdown color 2026-01-24 05:59:18 +01:00
Leon Bösche
692e7767f3 Enhance role selection dropdown styling in organization settings dialog 2026-01-24 05:49:05 +01:00
Leon Bösche
b3d1e40130 Add spacing between role selection and dropdown in organization settings dialog 2026-01-24 05:46:44 +01:00
Leon Bösche
cd3c91f93e Refactor invite tab layout and enhance user suggestions dropdown functionality 2026-01-24 05:27:51 +01:00
Leon Bösche
41898dfcc7 Refactor invite tab to improve layout and remove unused variables 2026-01-24 05:19:28 +01:00
Leon Bösche
17cc47f22d Add maxLines and overflow properties to file name display for better UI handling 2026-01-24 05:14:41 +01:00
Leon Bösche
deb8b50bb9 Fix suggestions positioning using CompositedTransformFollower to properly position dropdown below TextField 2026-01-24 05:06:30 +01:00
Leon Bösche
65ad05ac76 Refactor invitation and invite link sections for improved readability and layout consistency 2026-01-24 04:55:02 +01:00
Leon Bösche
3a80ad4f15 Implement overlay dropdown for username suggestions to prevent pushing content down 2026-01-24 04:54:42 +01:00
Leon Bösche
032b287548 Increase width of ModernGlassButton for improved layout consistency 2026-01-24 04:50:00 +01:00
Leon Bösche
4f9230cdc2 Fix TextField not accepting input by moving TextEditingController to state instead of recreating on every build 2026-01-24 04:39:58 +01:00
Leon Bösche
be09b5830e Refactor Send Invitation button to use fixed width for better layout consistency 2026-01-24 04:38:43 +01:00
Leon Bösche
5cf3b1d997 Use content_copy icon for copy link button; fix missing buttons in personal workspace by granting full permissions for orgId empty 2026-01-24 04:36:40 +01:00
Leon Bösche
4786e5b5d9 Change regenerate button to use refresh icon instead of text 2026-01-24 04:30:15 +01:00
Leon Bösche
c0a3e4d8c3 Add text overflow handling for invite link in Organization Settings dialog 2026-01-24 04:29:02 +01:00
Leon Bösche
56f3de5d0d Fix LoadPermissions event usage: add import and correct constructor call 2026-01-24 04:27:43 +01:00
Leon Bösche
d387f6c4d3 Fix missing buttons in personal workspace by reloading permissions on org change; fix untypeable TextField by removing Container wrapper and using InputDecoration borders 2026-01-24 04:27:09 +01:00
Leon Bösche
9654497a2b Reorder invite tab: move invite link above user search fields; style TextField and Dropdown with app theme colors to avoid purple borders 2026-01-24 04:04:29 +01:00
Leon Bösche
01d3ef8a46 Fix type error in ModernGlassButton onPressed: provide always non-null callback with permission check inside 2026-01-24 03:51:51 +01:00
Leon Bösche
1f7f4f33cc Make invite form always visible in Organization Settings dialog, with send button enabled only if user has manage permission; reposition copy link button next to invite link text for sleeker layout 2026-01-24 03:49:58 +01:00
Leon Bösche
c1c8f837a8 Add null check in ApiClient.getList to handle cases where response.data is null, returning empty list instead of throwing cast error 2026-01-24 03:39:31 +01:00
Leon Bösche
f53424aaa4 Format CircularProgressIndicator for better readability in Organization Settings dialog 2026-01-24 03:36:26 +01:00
Leon Bösche
aaa0062568 Fix CircularProgressIndicator color in Organization Settings dialog to use AppTheme.accentColor 2026-01-24 03:35:58 +01:00
Leon Bösche
b57b1ab030 Fix package name to use underscore instead of dot notation 2026-01-24 03:25:42 +01:00
Leon Bösche
d7ae944aee Fix backend to return empty slices instead of null for JSON encoding
- Initialize slices as empty in database query functions to prevent json.Encode(nil) returning 'null'
- This fixes Flutter Dio parsing 'null' as null, causing cast to List to fail
2026-01-24 03:21:30 +01:00
Leon Bösche
5012c9a1b5 Update package name to use dot notation and add icons asset path 2026-01-24 02:36:33 +01:00
Leon Bösche
c3e3356574 Refactor user struct and user info handling for improved readability 2026-01-24 02:30:34 +01:00
Leon Bösche
c7e740e732 Fix Organization Management dialog type errors
- Fix JSON key mismatch in Flutter models: change 'User' to 'user' in Member and JoinRequest fromJson
- Update backend userInfo to send displayName as *string (null when empty)
- Add json tags to database User struct for consistent lowercase keys
- Handle displayName nullability in backend handlers
2026-01-24 02:30:15 +01:00
Leon Bösche
273976a69e Add onTap handler to 'Add' navigation button in HomePage 2026-01-24 01:33:11 +01:00
Leon Bösche
3dd4a9b692 Update header text in organization settings dialog to include organization name 2026-01-24 01:31:25 +01:00
Leon Bösche
c675cb9125 Update icon for Manage button to use rounded accounts icon 2026-01-24 01:19:43 +01:00
Leon Bösche
76ad2f4581 Update header text in organization settings dialog to 'Manage Organization' 2026-01-24 01:17:08 +01:00
Leon Bösche
4c8ef754c0 Add initial state emission for empty organization ID in permissions loading 2026-01-24 00:35:38 +01:00
Leon Bösche
25b053ee13 Improve error handling and logging in data loading for organization settings dialog 2026-01-24 00:24:43 +01:00
Leon Bösche
960d2c8805 Refactor button color logic and improve readability in home page and organization settings dialog 2026-01-24 00:22:38 +01:00
Leon Bösche
56d8054d90 Fix Manage button visibility
- Change icon from Icons.settings to Icons.admin_panel_settings for better recognition
- Use AppTheme.primaryText (white) instead of secondaryText (white70) for action buttons
- Improve visibility of the Manage button in the navigation bar
2026-01-24 00:21:15 +01:00
Leon Bösche
b4e9829f04 Fix splash effects on all buttons in organization settings dialog
- Add splashColor: Colors.transparent and highlightColor: Colors.transparent to all IconButtons
- Add ButtonStyle with NoSplash.splashFactory and transparent overlayColor to all TextButtons
- Updated close button, remove member button, cancel invitation button, and accept/reject buttons
- Maintains consistent button behavior across the entire app
2026-01-24 00:02:22 +01:00
Leon Bösche
48c9c19a64 Fix settings button and dialog tab buttons
- Change 'Settings' button to 'Manage' with proper icon visibility
- Only show Manage button when in an organization (selectedOrg != null)
- Replace TabBar with custom animated tab buttons that match app styling
- Add smooth animations and visual feedback to tab buttons
- Maintain consistent splash effects across the dialog
2026-01-24 00:00:04 +01:00
Leon Bösche
e10e499b6c Backend: Fix organization API endpoints and RBAC
- Fix member list API response format to match frontend expectations
- Fix join requests API response format
- Add proper JSON tags to Invitation struct
- Grant OrgManage permission to admin role for proper RBAC

These changes ensure frontend-backend API contracts are aligned and admins can manage organizations.
2026-01-23 23:48:10 +01:00
Leon Bösche
bfe2d1d521 Fix string concatenation for error messages in audio playback handling 2026-01-23 23:40:30 +01:00
Leon Bösche
26cbe83d66 Add JoinPage feature with invite token handling and update routing 2026-01-23 23:39:59 +01:00
Leon Bösche
98e7bbdb9e Refactor code for improved readability and consistency in multiple files 2026-01-23 23:21:46 +01:00
Leon Bösche
20bc0ac757 Implement complete Organizations feature with RBAC
- Add owner/admin/member roles with proper permissions
- Implement invite links and join requests system
- Add organization settings dialog with member management
- Create database migrations for invitations and invite links
- Update backend API with org management endpoints
- Fix compilation errors and audit logging
- Update frontend models and API integration
2026-01-23 23:21:23 +01:00
Leon Bösche
a03b0dfe33 idle 2026-01-21 14:54:47 +01:00
Leon Bösche
73e757a86b Adjust audio player bar positioning for improved layout 2026-01-19 23:12:46 +01:00
Leon Bösche
6c4a5555f3 letzte heute 2026-01-17 04:17:53 +01:00
Leon Bösche
d41566f4dc Adjust audio player bar positioning and width for improved layout 2026-01-17 04:12:40 +01:00
Leon Bösche
800d0c3020 Adjust audio player bar positioning for improved layout 2026-01-17 04:04:03 +01:00
Leon Bösche
968065a0a3 idle 2026-01-17 03:55:48 +01:00
Leon Bösche
cb5f4d0e83 Remove debug print statements from audio player for cleaner code. deploy now 2026-01-17 03:45:07 +01:00
Leon Bösche
898922efd1 Refactor audio player to manage subscriptions more effectively and ensure proper cleanup 2026-01-17 03:40:56 +01:00
Leon Bösche
014b77a27e idle3 2026-01-17 03:39:07 +01:00
Leon Bösche
ab6c6b2ff8 idle2 2026-01-17 03:38:07 +01:00
Leon Bösche
bdba13cb60 idle 2026-01-17 03:32:22 +01:00
Leon Bösche
0c1e470779 Replace close button with a simple 'x' text for improved clarity and simplicity 2026-01-17 03:31:13 +01:00
Leon Bösche
0d88d8e58e Increase right padding of audio player bar to avoid overlapping navigation buttons 2026-01-17 03:30:19 +01:00
Leon Bösche
3e7cc12b32 Increase right padding of audio player bar to avoid overlapping navigation buttons 2026-01-17 03:14:41 +01:00
Leon Bösche
d806fb806c Adjust audio player bar width to 24% for improved layout 2026-01-17 03:08:16 +01:00
Leon Bösche
25790fd157 Adjust audio player bar positioning for improved layout and avoid overlapping navigation buttons 2026-01-17 03:02:11 +01:00
Leon Bösche
00b4436013 Adjust audio player bar positioning and width for improved layout; add authentication token retrieval in file download URL generation 2026-01-17 03:02:04 +01:00
Leon Bösche
0a23133043 Refactor file URL generation to use file path instead of file ID and adjust audio player bar width for improved layout 2026-01-17 02:55:34 +01:00
Leon Bösche
66ec456102 Update url_launcher_web version to 2.4.2 and sha256 checksum in pubspec.lock 2026-01-17 02:53:50 +01:00
Leon Bösche
1c1e6a570c Fix audio player state access for conditional imports to ensure compatibility across platforms 2026-01-17 02:46:36 +01:00
Leon Bösche
8a1660b781 Add web audio player implementation with enhanced stream handling and error management 2026-01-17 02:40:58 +01:00
Leon Bösche
d9a651b375 Enhance audio file handling with improved error logging and UI adjustments for audio player bar positioning 2026-01-17 02:31:33 +01:00
Leon Bösche
979091f975 jetzaber 2026-01-17 02:20:10 +01:00
Leon Bösche
712abbb34f Refactor HomePage layout to center title and align navigation buttons, enhancing visual structure 2026-01-17 01:58:27 +01:00
Leon Bösche
a219b8c1a2 Refactor HomePage layout to separate title and audio player bar, enhancing clarity and organization 2026-01-17 01:51:43 +01:00
Leon Bösche
2c565c3b50 Refactor HomePage layout to improve audio bar integration and navigation button arrangement 2026-01-17 01:41:58 +01:00
Leon Bösche
6ee244b829 Refactor HomePage layout by simplifying padding and removing duplicate title widget 2026-01-17 01:34:02 +01:00
Leon Bösche
893526eeac Enhance audio player functionality by auto-playing on load and resetting position at end 2026-01-17 01:27:03 +01:00
Leon Bösche
c2919facfd Refactor HomePage layout for improved audio player visibility and navigation button arrangement 2026-01-17 01:23:39 +01:00
Leon Bösche
c6eb497bfa idle01 2026-01-16 20:53:55 +01:00
Leon Bösche
072564fb0f Refactor HomePage layout to improve audio player integration and enhance UI responsiveness 2026-01-16 16:09:07 +01:00
Leon Bösche
0b2a9bad2f Add audio file selection callback and integrate audio player bar in file explorer and home page 2026-01-16 15:49:33 +01:00
Leon Bösche
13c5aed435 Enhance PDF viewer theme by updating progress bar and scroll status styles 2026-01-16 15:38:40 +01:00
Leon Bösche
2cdc55ba2f Add audio player functionality to file explorer and integrate just_audio package 2026-01-16 15:33:21 +01:00
Leon Bösche
b27cc5eaf0 Add SfTheme wrapper for PDF viewer and update syncfusion_flutter_core dependency 2026-01-16 15:01:51 +01:00
Leon Bösche
b006187320 Refactor button overlay color handling in document viewer, file explorer, and home page for improved state management 2026-01-16 14:43:29 +01:00
Leon Bösche
c2db24133b Enhance button styles by setting transparent overlay color in document viewer 2026-01-16 14:14:36 +01:00
Leon Bösche
46cf6531d9 Refactor dialog background color and button styles in document viewer and home page 2026-01-16 14:04:48 +01:00
Leon Bösche
8dcf1fab1e idle7000 2026-01-16 02:52:17 +01:00
Leon Bösche
f94f36350f idle6000 2026-01-16 02:45:44 +01:00
Leon Bösche
4285baecbf idle 2026-01-16 02:31:55 +01:00
Leon Bösche
fc96a6a8e0 Disable hyperlink navigation and dialog in document viewer for enhanced user experience 2026-01-16 02:25:24 +01:00
Leon Bösche
1fa76638a6 Disable hyperlink navigation in document viewer; update dependencies in pubspec.lock 2026-01-15 22:36:30 +01:00
Leon Bösche
344395cb2d Add url_launcher dependency and implement hyperlink handling in document viewer 2026-01-15 22:28:36 +01:00
Leon Bösche
2aaf611edb Enhance button styling in breadcrumb navigation for improved usability 2026-01-15 13:48:11 +01:00
Leon Bösche
9614c0e950 Refactor moveUserFileHandler to simplify source and target file retrieval; improve error handling for missing files. 2026-01-15 13:39:47 +01:00
Leon Bösche
e97587634e Add file type labeling in file explorer; categorize files as Video, Track, Photo, Document, or File based on extensions. 2026-01-14 18:58:50 +01:00
Leon Bösche
3dc551b383 Refactor file size formatting for improved readability 2026-01-14 18:47:44 +01:00
Leon Bösche
3619dd2234 idle 2026-01-14 18:47:29 +01:00
Leon Bösche
453b60032c Implement folder move restrictions in file explorer and backend; prevent moving a folder into itself or its own subfolder. 2026-01-14 18:26:02 +01:00
Leon Bösche
0f2aa9c49f Enhance video file handling in file explorer; implement viewer session for authenticated URL and improve error handling for missing file ID. 2026-01-14 18:10:40 +01:00
Leon Bösche
92a33adae5 Enhance video file detection in file explorer; format video extensions list and improve video URL retrieval for video viewer. 2026-01-14 17:56:14 +01:00
Leon Bösche
187f238e02 Add video file detection in file explorer; open video files in video viewer on tap 2026-01-14 17:56:09 +01:00
Leon Bösche
70b5b1a4f3 Refactor file download handlers to utilize getMimeType function for content type determination, enhancing support for various file formats. 2026-01-14 17:47:28 +01:00
Leon Bösche
3d5593ca61 Fix error handling in file explorer by updating DioError to DioException for improved error message extraction 2026-01-14 17:32:03 +01:00
Leon Bösche
2428dae1cc Refactor document editing functionality to use document viewer; remove legacy editor implementation and streamline user experience. 2026-01-14 17:30:25 +01:00
Leon Bösche
a5dd8d8f39 Refactor video viewer to use HTML5 video element and remove legacy web implementation; enhance content type handling for various video formats in download handlers. 2026-01-14 12:41:56 +01:00
Leon Bösche
5434a9b39d Enhance WOPI file info handler to include user-friendly name and ensure last modified time is set correctly 2026-01-14 12:29:24 +01:00
Leon Bösche
fc06f5e36a Enhance editor logging for file sessions in routes and update video URL handling in file explorer 2026-01-14 12:23:31 +01:00
Leon Bösche
40f7eeee09 Ensure folderPath ends with / for proper relative path calculation in ZIP downloads 2026-01-14 12:11:25 +01:00
Leon Bösche
de720cbdcb Add user file editor endpoint and enhance WOPI handlers with logging 2026-01-14 12:09:25 +01:00
Leon Bösche
aea5ba9e58 Add functionality to download folders as ZIP archives for both org and user files 2026-01-14 12:02:20 +01:00
Leon Bösche
c38ae1bd78 Refactor video viewer to remove web-specific handling and improve error messaging 2026-01-14 11:52:28 +01:00
Leon Bösche
3757368a00 Refactor DOCX file generation to simplify path handling and enhance document styles 2026-01-14 11:48:07 +01:00
Leon Bösche
5cb86d4fde Refactor document creation error handling to provide more informative error messages 2026-01-14 04:22:13 +01:00
Leon Bösche
94cb7bc99f Handle personal workspace case by adjusting orgId for document creation 2026-01-14 03:56:19 +01:00
Leon Bösche
efa7e66b36 idle 2026-01-14 03:10:12 +01:00
Leon Bösche
3382e5496e Refactor file retrieval and annotation methods to simplify path generation and improve error handling 2026-01-14 03:09:08 +01:00
Leon Bösche
95b0ff51b6 Fix package name formatting in pubspec.yaml and update import path in injection.dart 2026-01-14 00:30:38 +01:00
Leon Bösche
07304c3f73 Add web-specific video viewer and update video player initialization 2026-01-14 00:02:54 +01:00
Leon Bösche
f2cefee7e4 Add video playback feature with video viewer and file URL retrieval 2026-01-13 23:50:28 +01:00
Leon Bösche
768f61337b Add document creation feature with snackbar notifications and file service integration 2026-01-13 23:27:04 +01:00
Leon Bösche
744fbf87f5 Refactor blob creation in file download to enhance readability 2026-01-13 23:16:25 +01:00
Leon Bösche
bc9c7a06c8 Fix download: fetch file with auth header, create blob for proper download 2026-01-13 23:10:47 +01:00
Leon Bösche
46dd299229 Refactor file download logic to improve authentication handling and code readability 2026-01-13 22:42:08 +01:00
Leon Bösche
03962d5a80 Enhance file download functionality with snackbar notifications and authentication token handling 2026-01-13 22:41:56 +01:00
Leon Bösche
847a8de414 Refactor moveUserFileHandler to store user files directly in WebDAV root, removing unnecessary path prefix 2026-01-13 22:31:58 +01:00
Leon Bösche
c00c1e273d Add context.mounted checks in file explorer for safer file and folder operations 2026-01-13 22:26:45 +01:00
Leon Bösche
47e94995b5 Refactor viewmodels and enhance security documentation; remove unused viewmodels, add path sanitization, and implement rate limiting 2026-01-13 22:11:02 +01:00
Leon Bösche
804e994e76 Add architecture, deployment, and development documentation for b0esche.cloud 2026-01-13 19:34:46 +01:00
Leon Bösche
294b28d1a8 Add docs, scripts, and update README
- Added docs/AUTH.md with authentication system documentation
- Added server scripts (auto-deploy, backup, monitor, webhook-server)
- Updated README with deployment info and project structure
- Added gitignore for backup archives
2026-01-13 19:28:16 +01:00
Leon Bösche
233f1dd315 Refactor upload snackbar code for improved readability and maintainability 2026-01-13 16:50:09 +01:00
Leon Bösche
d2c26e6203 Style upload progress snackbar with accent color (nyan) 2026-01-13 16:49:17 +01:00
Leon Bösche
37e0520af0 Add file metadata display in viewer and upload progress snackbar
- Backend: Add modified_by column to files table
- Backend: Track who modified files via WOPI PutFile
- Backend: Return fileInfo (name, size, lastModified, modifiedByName) in view response
- Flutter: Update DocumentCapabilities model with FileInfo
- Flutter: Display actual last modified date and user in document viewer
- Flutter: Show upload progress snackbar with percentage that auto-dismisses on completion
2026-01-13 16:48:25 +01:00
Leon Bösche
6ce43a3c9b Add last modified tracking: show 'Last modified: date by username' in document viewer
- Added modified_by column to files table
- Updated WOPI PutFile to track who modified the file
- Updated view handlers to return file metadata (name, size, lastModified, modifiedByName)
- Updated Flutter models and UI to display last modified info
2026-01-13 16:45:57 +01:00
Leon Bösche
6943e95479 Fix WOPI: Use correct WebDAV path for org files (/orgs/{orgId}/ prefix) 2026-01-13 16:10:57 +01:00
Leon Bösche
2e1096a9ad Add id attribute to form field to fix autofill warning 2026-01-13 15:48:20 +01:00
Leon Bösche
8d9db29db2 Fix WOPI: Use config for Nextcloud URL instead of hardcoded nc.b0esche.cloud 2026-01-13 15:25:04 +01:00
Leon Bösche
afeb7a35ad Replace deprecated dart:html with package:web
- Updated document_viewer.dart to use web.HTMLIFrameElement/HTMLDivElement
- Updated file_explorer.dart to use web.HTMLAnchorElement for downloads
- Added web and http packages to pubspec.yaml
2026-01-13 14:52:12 +01:00
Leon Bösche
70039a8288 Fix Collabora WOPI: Put WOPISrc in URL query param, not form body 2026-01-13 14:50:24 +01:00
Leon Bösche
634aa521bd Refactor index.html: Clean up preload link formatting and improve console warning suppression 2026-01-13 14:38:59 +01:00
Leon Bösche
749672509b Fix Collabora: Fetch versioned editor URL from discovery endpoint
- Added getCollaboraEditorURL() to fetch correct /browser/{version}/cool.html path
- Cache discovery response for 5 minutes to avoid repeated requests
- Fixed 'Invalid URI' error caused by version mismatch
2026-01-13 14:38:08 +01:00
Leon Bösche
20e9ae3e4d Suppress Flutter web deprecation warnings (v8BreakIterator, async XMLHttpRequest) 2026-01-13 14:34:18 +01:00
Leon Bösche
3738b346e3 Remove docker-compose-server.yml from .gitignore 2026-01-13 14:05:06 +01:00
Leon Bösche
0aa281ac09 Add .gitignore for docker-compose-server.yml and update WOPISrc URL handling in collaboraProxyHandler 2026-01-13 14:01:02 +01:00
Leon Bösche
8baaad2c08 Fix Collabora form submission: remove iframe target and use _self to bypass CSP 2026-01-13 12:59:49 +01:00
Leon Bösche
19825763ad Fix Collabora endpoint: use /browser/dist/cool.html instead of /loleaflet/dist/loleaflet.html 2026-01-13 12:46:56 +01:00
Leon Bösche
c16cd49237 Use /loleaflet/dist/loleaflet.html - standard Collabora Online WOPI endpoint 2026-01-13 00:28:34 +01:00
Leon Bösche
57eea172a2 Revert to form POST submission to /cool/dist/cool.html (correct WOPI pattern)
- Collabora Online requires POST request with WOPISrc in form body
- GET requests with query parameters cause 400 Bad Request
- Using modern /cool/ endpoint with form submission to iframe
2026-01-13 00:13:21 +01:00
Leon Bösche
d255f40a8c Try Collabora path /cool/dist/cool.html (newer naming convention) 2026-01-12 17:25:53 +01:00
Leon Bösche
d59a655f0f Fix Collabora path from /loleaflet to /lool (correct endpoint) 2026-01-12 17:11:50 +01:00
Leon Bösche
509983b4ff Fix: Update Collabora proxy to load editor in iframe with WOPISrc in query string 2026-01-12 16:58:39 +01:00
Leon Bösche
5fb08d8831 Improve Collabora proxy: add load event listener and relax iframe sandbox for form submission
- Use window load event to ensure form element exists before submission
- Add allow-popups-to-escape-sandbox for cross-origin form POSTs
- Add allow-presentation for better compatibility
- Improved error logging for debugging form submission issues
2026-01-12 16:51:16 +01:00
Leon Bösche
18f5b3f98b Fix: Use form POST submission for Collabora (not GET query string)
- Collabora requires POST request to /loleaflet/dist/loleaflet.html
- WOPISrc must be in request body as form parameter
- Form targets iframe by name for proper document loading
- Matches WOPI/Collabora standard integration pattern
2026-01-12 16:46:11 +01:00
Leon Bösche
d58137716f Fix: Collabora proxy should load iframe with WOPISrc in query string, not form submission
- Changed from form POST submission to direct iframe load
- WOPISrc goes in Collabora URL query string, not as form parameter
- This matches Collabora's expected request format for WOPI integration
2026-01-12 16:40:35 +01:00
Leon Bösche
18138dde01 Fix: Remove X-Frame-Options header from Collabora proxy to allow iframe loading
- The collaboraProxyHandler endpoint is intentionally meant to load in an iframe
- X-Frame-Options: SAMEORIGIN was blocking cross-origin iframe loads
- Removed the header to allow the Flutter web frontend to embed the proxy page
2026-01-12 16:34:05 +01:00
Leon Bösche
6db2bf077d Fix: Clean up whitespace in document viewer and WOPI handler files 2026-01-12 16:10:40 +01:00
Leon Bösche
42ee3057bf Fix: Collabora proxy handler - create WOPI session inline instead of calling non-existent function 2026-01-12 16:10:04 +01:00
Leon Bösche
350eb27e30 Clarify Collabora proxy token handling for iframe cross-origin requests 2026-01-12 16:08:35 +01:00
Leon Bösche
2ded9b00f9 idle 2026-01-12 16:00:27 +01:00
Leon Bösche
2daa9e9855 Fix: Use JavaScript in head to submit form for Collabora POST
- Inject script element into head that creates and submits form
- Script executes immediately when loaded, doesn't rely on Future callbacks
- Form targets iframe by name, WOPISrc in body not query params
- This ensures form submission happens reliably before any async operations
2026-01-12 15:58:08 +01:00
Leon Bösche
2fa5f0441a Fix: Create form in parent document to POST to Collabora iframe
- Data URL iframe form submission was blocked by CORS
- New approach: create iframe in viewport, form in document body
- Form targets iframe by name, submits WOPISrc to loleaflet.html via POST
- Form submission happens in parent document context, not sandboxed iframe
- This avoids CORS issues and ensures form actually submits
2026-01-12 15:53:05 +01:00
Leon Bösche
64690231c2 Fix: Refactor Collabora iframe creation for improved readability and maintainability 2026-01-12 15:44:57 +01:00
Leon Bösche
0ee3b32ef3 Fix: Use data URL iframe with auto-submitting form for Collabora POST
- Create iframe with data URL containing HTML page
- HTML page has form that auto-submits to loleaflet.html with WOPISrc in body
- This ensures POST request instead of GET
- Form submits within iframe context, not affected by parent page JavaScript
2026-01-12 15:34:43 +01:00
Leon Bösche
99419748bb Fix: Pass WOPISrc directly to form submission, don't build URL with query params
- Remove URL building with query parameters that was causing GET requests
- Pass WOPISrc directly to _buildCollaboraIframe function
- JavaScript now creates form with POST method and WOPISrc in body
- This ensures Collabora receives POST not GET request
2026-01-12 15:28:50 +01:00
Leon Bösche
89f55471ce Fix: Use JavaScript form submission for Collabora POST request
- Collabora expects POST to loleaflet.html with WOPISrc in body, not GET
- Previous Dart form.submit() wasn't executing reliably
- Use ScriptElement to inject and execute form submission JavaScript
- This bypasses Dart's HTML layer limitations and ensures form POSTs properly
- Unique viewType names with timestamp to avoid registration conflicts
2026-01-12 15:22:40 +01:00
Leon Bösche
ef60983534 Fix: Refactor Collabora iframe setup for improved readability and maintainability 2026-01-12 10:26:25 +01:00
Leon Bösche
8e06e7e17d Fix: Update Collabora iframe to use POST method for WOPISrc submission 2026-01-12 10:26:17 +01:00
Leon Bösche
0b822af438 Fix: Use proper URL building for Collabora WOPISrc parameter
- Changed from manual string concatenation to Uri.parse().replace(queryParameters: ...)
- This properly encodes the WOPISrc while maintaining encoding through iframe.src
- Fixes Collabora 'WOPISrc validation error: unencoded WOPISrc' warning
- Uri API ensures query parameters are properly percent-encoded
2026-01-12 10:12:58 +01:00
Leon Bösche
69cf328972 Fix Collabora 400 error by properly URL-encoding WOPISrc parameter
- WOPISrc is a full URL that needs URL encoding when passed as query param
- Use Uri.encodeComponent() to properly encode the WOPISrc value
- Simplify iframe setup to just use the properly constructed URL
- This fixes the 400 Bad Request from Collabora when loading documents
2026-01-12 09:50:41 +01:00
Leon Bösche
181fb0af93 Fix Collabora iframe loading and sandbox security
- Reverted to direct iframe src approach instead of form submission
- This avoids 'Form submission canceled' error
- Removed allow-same-origin from sandbox to improve security
- Added allow-popups-to-escape-sandbox for Collabora functionality
- Use direct URL with WOPISrc parameter instead of POST form
2026-01-12 01:47:52 +01:00
Leon Bösche
7bd1ab16da Fix Collabora Online loading using form-based POST instead of URL params
- Collabora's loleaflet.html expects WOPISrc as POST parameter, not URL query param
- Changed from iframe with src= to form submission approach
- Extract WOPISrc from URL and pass as hidden form input
- This avoids 400 Bad Request from Collabora when using GET with URL params
2026-01-12 01:40:10 +01:00
Leon Bösche
d52307c792 Implement Collabora Online iframe viewer with WOPI integration
- Added dart:html and dart:ui_web imports for iframe support
- Created _buildCollaboraIframe() method to register and display iframe
- Set proper sandbox and allow attributes for Collabora functionality
- Full-screen iframe embedding of Collabora Online document editor
- Replaces placeholder UI with actual document viewing capability
2026-01-12 01:30:53 +01:00
Leon Bösche
dbbad29f2f Fix: Standardize formatting of WOPICheckFileInfoResponse struct fields 2026-01-12 01:18:32 +01:00
Leon Bösche
ec25f06ea3 Fix: Refactor code for improved readability and consistency in document viewer and signup form 2026-01-12 01:18:08 +01:00
Leon Bösche
36db2daabd Fix: Clean up wopi.go file completely 2026-01-12 01:17:41 +01:00
Leon Bösche
ff51ef8a71 Fix: Complete WOPI integration - resolve all compilation errors
Go backend:
- Clean up corrupted wopi.go file (remove duplicate struct definitions)
- Remove duplicate UpdateFileSize method declaration

Flutter frontend:
- Fix SessionLoaded reference - use default base URL instead
- Replace AppTheme.primary with AppTheme.accentColor
- Remove unused local variables from file_browser_bloc
2026-01-12 01:16:07 +01:00
Leon Bösche
83f0fa0ecb Fix: Resolve Go and Flutter compilation errors
Go backend:
- Fix WOPI models file (remove duplicate package declaration and syntax errors)
- Add GetOrgMember method to database as alias for GetUserMembership
- Add UpdateFileSize method to database
- Remove unused net/url import from wopi_handlers.go
- Fix field names in WOPI struct initializers (match JSON tags)

Flutter frontend:
- Remove webview_flutter import (use simpler placeholder for now)
- Fix _createWOPISession to safely access SessionBloc state
- Replace WebViewController usage with placeholder UI
- Remove unused _generateRandomHex methods from login/signup forms
- Add missing mimeType parameter to DocumentCapabilities in mock repository
- Remove unused local variables in file_browser_bloc
2026-01-12 01:13:40 +01:00
Leon Bösche
1b20fe8b7f Implement WOPI integration for Collabora Online
- Add WOPI models (CheckFileInfoResponse, PutFileResponse, LockInfo)
- Implement WOPI handlers: CheckFileInfo, GetFile, PutFile, Lock operations
- Add file locking mechanism to prevent concurrent editing conflicts
- Add WOPI session endpoint for generating access tokens
- Add UpdateFileSize method to database
- Add WOPI routes (/wopi/files/* endpoints)
- Update Flutter document viewer to load Collabora via WOPI WOPISrc URL
- Implement WebView integration for Collabora Online viewer
- Add comprehensive logging for WOPI operations [WOPI-TOKEN], [WOPI-REQUEST], [WOPI-STORAGE], [WOPI-LOCK]
2026-01-12 01:08:22 +01:00
Leon Bösche
3e0094b11c Fix: Remove WebView from Collabora implementation and fix font loading errors
- Reverted WebViewWidget approach that was causing null check errors
- Show placeholder UI for Office documents until proper WOPI support is implemented
- Fixed web/index.html to only load PixelatedElegance font that actually exists
- Removed references to non-existent fonts causing OTS parsing errors
2026-01-12 00:56:38 +01:00
Leon Bösche
f0fe439c3b Implement Collabora Online viewer for Office documents (DOCX, XLSX, etc) 2026-01-12 00:46:45 +01:00
Leon Bösche
d9c8c1e1f3 STYLE: Refactor code for improved readability and consistency 2026-01-12 00:39:36 +01:00
Leon Bösche
31ab3aad45 Fix Uri parsing errors in document viewer - viewUrl is already a Uri 2026-01-12 00:29:05 +01:00
Leon Bösche
923b0ede86 Multi-format document viewer: add image/text/office file type support 2026-01-12 00:22:12 +01:00
Leon Bösche
80411d9231 FIX: Properly detect if target path is a folder when moving files 2026-01-12 00:09:39 +01:00
Leon Bösche
675c2bf95d FEATURE: Add user file move endpoint and support both personal and org workspace moves 2026-01-12 00:01:47 +01:00
Leon Bösche
6ffe9aa4df FIX: Create new file record at destination when moving file 2026-01-11 23:47:14 +01:00
Leon Bösche
1680914017 FIX: Simplify move handler and fix API call 2026-01-11 23:39:15 +01:00
Leon Bösche
60df1a38ff FIX: Remove unused variables in move handler 2026-01-11 23:32:26 +01:00
Leon Bösche
af5c8f0e72 FIX: Implement WebDAV Move and simplified move file handler 2026-01-11 23:14:45 +01:00
Leon Bösche
0378a0748a FEATURE: Implement file drag-and-drop move functionality 2026-01-11 23:10:14 +01:00
Leon Bösche
c330381281 FIX: Handle NULL transports array with custom StringArray type 2026-01-11 22:53:24 +01:00
Leon Bösche
6a4cd8c672 FIX: Handle NULL transports in credentials using pq.StringArray 2026-01-11 22:46:49 +01:00
Leon Bösche
09d16abcd5 FIX: Sort folders before files in file explorer 2026-01-11 22:12:04 +01:00
Leon Bösche
330bd86b96 REFINE: Remove debug print statements from various blocs and services for cleaner code 2026-01-11 21:54:11 +01:00
Leon Bösche
d6277f5469 FIX: Add PDF.js library to web/index.html for SfPdfViewer web support 2026-01-11 18:13:36 +01:00
Leon Bösche
c8eb0aefe3 Add Authorization header to modal PDF viewer 2026-01-11 18:02:24 +01:00
Leon Bösche
17d10e5815 Remove invalid custom HTTP client - use query parameter token for SfPdfViewer 2026-01-11 17:54:01 +01:00
Leon Bösche
ef737429d6 FIX: Use Authorization header for PDF viewer instead of query parameter token 2026-01-11 17:52:08 +01:00
Leon Bösche
2129d72a1f FIX: Extend JWT token expiration to 24 hours for document viewer sessions
- Add GenerateWithDuration method to JWT manager to support custom expiration times
- Update viewerHandler and userViewerHandler to generate viewer-specific tokens with 24-hour expiration
- This fixes the issue where PDF viewer fails due to token expiration within 15 minutes
- Add [VIEWER-SESSION] logging to track viewer session creation
- Tokens now remain valid long enough for users to view, navigate, and interact with PDFs
2026-01-11 17:39:12 +01:00
Leon Bösche
3d80072e7b Add AUTH-TOKEN logging to middleware for debugging token extraction 2026-01-11 17:31:17 +01:00
Leon Bösche
5ef5623c8d feat: update WebDAV download handler to support range requests and improve response handling 2026-01-11 14:38:03 +01:00
Leon Bösche
b2e5eef66f feat: enhance CORS middleware to support dynamic allowed headers and ensure uniqueness 2026-01-11 14:25:07 +01:00
Leon Bösche
68270b6906 idle 2026-01-11 05:42:00 +01:00
Leon Bösche
9d466fd63a feat: add ownerId to Organization and update related database queries; enhance CORS middleware for origin validation 2026-01-11 05:33:16 +01:00
Leon Bösche
619b2fe23c fix: enforce workspace isolation logging 2026-01-11 05:01:52 +01:00
Leon Bösche
ac1bd2749c Increase top padding for responsive design on home page 2026-01-11 04:46:18 +01:00
Leon Bösche
a3a9360110 Enhance JWT token retrieval in viewer and auth middleware for improved security and flexibility 2026-01-11 04:34:14 +01:00
Leon Bösche
f0d6d0b8e1 Add NoSplash effect to TextButton styles for improved UX 2026-01-11 04:27:18 +01:00
Leon Bösche
b09cdde8d3 Reload file explorer on org change to avoid stale items\n\n- Add didUpdateWidget in FileExplorer to ResetFileBrowser and LoadDirectory when orgId changes\n- Ensures org-created folders/files don’t appear in Personal after switching 2026-01-11 04:21:33 +01:00
Leon Bösche
e5aee55dca Increase top padding for responsive design on home page 2026-01-11 04:04:23 +01:00
Leon Bösche
615c92dc5f Refactor context value assignment in Auth middleware for improved readability 2026-01-11 04:02:04 +01:00
Leon Bösche
bd6dd68f0b Fix file browser state persistence and PDF viewer loading
- Clear file lists in ResetFileBrowser to prevent org files showing in personal workspace
- Include JWT token as query parameter in PDF download URL for viewer compatibility
- Remove Authorization header from SfPdfViewer (browser security restrictions)
- Fix mock repository EditorSession to include required token parameter
2026-01-11 03:40:44 +01:00
Leon Bösche
e9517a5a4d Fix context key type mismatch causing org files 500 error
- Export ContextKey type and context keys from middleware package
- Use exported keys (UserKey, SessionKey, TokenKey, OrgKey) in handlers
- Fixes panic: interface conversion: interface {} is nil, not uuid.UUID
- The middleware was setting context with contextKey type but handlers
  were retrieving with string type, causing nil value lookup failure
2026-01-11 03:30:31 +01:00
Leon Bösche
39e0eb0efd Fix Personal button - create separate button instead of fake Organization object 2026-01-11 03:03:07 +01:00
Leon Bösche
a51c0e070c Add Personal tab and fix org selection + API error handling 2026-01-11 03:01:58 +01:00
Leon Bösche
56526c8fc5 Fix org creation dialog - use parent context for BlocProvider access 2026-01-11 02:50:14 +01:00
Leon Bösche
1151abab28 Restore PixelatedElegance brand font and fix button callback error 2026-01-11 02:20:26 +01:00
Leon Bösche
35b2095544 Fix syntax errors from literal \n characters 2026-01-11 02:18:36 +01:00
Leon Bösche
7259aa41fd Fix org creation dialog, document viewer colors, and font errors 2026-01-11 02:14:51 +01:00
Leon Bösche
9952323252 Refactor debug logging for organization creation and API client methods 2026-01-11 01:58:37 +01:00
Leon Bösche
d3ed7fd4f3 Add comprehensive debug logging for password flow and org creation diagnostics 2026-01-11 01:45:59 +01:00
Leon Bösche
fd4224d1da Fix Nextcloud user creation password encoding
- Replace manual form encoding with url.Values.Encode() for proper URL encoding
- Fixes issue where passwords with special characters were malformed
- Ensures password sent to Nextcloud matches password stored in database
- This fixes WebDAV authentication failures for auto-provisioned users
2026-01-11 01:30:40 +01:00
Leon Bösche
ed22c5eda4 Fix file upload timeout and UX issues
- Increase Dio receiveTimeout from 10s to 60s to allow file uploads and org creation
  to complete (Nextcloud user provisioning takes 5-7s)
- Hide 'Empty Folder' text and back button in root directory (main folder)
- Back button and empty message now only show in actual subdirectories
2026-01-11 01:19:00 +01:00
Leon Bösche
acb9b5f71c Fix formatting issues in Nextcloud user creation and WebDAV client methods 2026-01-11 00:57:17 +01:00
Leon Bösche
e34d09f762 Remove unused encoding/json import 2026-01-11 00:48:04 +01:00
Leon Bösche
9b03695d61 Fix Nextcloud user creation and WebDAV URL construction
- Fix CreateNextcloudUser to use form-encoded POST (OCS API requirement)
- Fix WebDAV URL construction to avoid double slashes
- Apply fix to Upload, Download, and Delete methods
2026-01-11 00:45:58 +01:00
Leon Bösche
e36a4e5785 Refactor organization creation logic for improved readability and maintainability 2026-01-11 00:44:23 +01:00
Leon Bösche
7cf55325d4 Fix org creation from initial state and wrong password error handling
- Organization creation now works even before orgs are loaded (fixes state guard)
- Org creation UI now preserves state and shows inline error messages
- Wrong password login no longer triggers global logout; shows inline error instead
- ApiClient now excludes /auth/ endpoints from global 401 session expiry
2026-01-11 00:28:02 +01:00
Leon Bösche
6186c4c779 Add WebDAV upload URL debug logging 2026-01-10 23:43:12 +01:00
Leon Bösche
9b10b1f6f1 Add debug logging to NewUserWebDAVClient to diagnose URL construction 2026-01-10 23:40:27 +01:00
Leon Bösche
0ce9185373 Fix NewUserWebDAVClient to strip path from base URL before constructing user-specific WebDAV URL 2026-01-10 23:24:24 +01:00
Leon Bösche
4a4e03e041 Fix getUserWebDAVClient to use actual username and COALESCE for NULL handling 2026-01-10 23:16:02 +01:00
Leon Bösche
18600a6bc1 Implement user provisioning for Nextcloud accounts and enhance WebDAV client handling 2026-01-10 22:58:35 +01:00
Leon Bösche
2f20241ba6 Fix file deletion logic to trigger Equatable change detection 2026-01-10 22:16:04 +01:00
Leon Bösche
7aaca1d1f4 Add logging for configuration loading and WebDAV client initialization 2026-01-10 22:05:07 +01:00
Leon Bösche
185cbc83b9 Remove unused os import and enforce exclusive use of Nextcloud WebDAV storage in file handlers 2026-01-10 21:46:16 +01:00
Leon Bösche
e64925b438 Refactor file handling to exclusively use Nextcloud WebDAV storage, removing local fallback logic 2026-01-10 21:46:12 +01:00
Leon Bösche
6c864612db Add comprehensive upload/download debugging to fix file storage issues 2026-01-10 21:11:53 +01:00
Leon Bösche
288363d2da Add CORS expose headers for PDF viewer compatibility 2026-01-10 19:16:23 +01:00
Leon Bösche
54fa429e3e Fix folder sorting and organization creation provider issue 2026-01-10 19:06:18 +01:00
Leon Bösche
0f13b6c01d Fix HTTPS scheme detection using X-Forwarded-Proto header 2026-01-10 15:58:14 +01:00
Leon Bösche
f372172898 Fix document viewer: add dynamic base URL and debug logging for file downloads 2026-01-10 05:21:43 +01:00
Leon Bösche
fa6ba846d8 Remove mock authentication and file repository implementations to streamline codebase 2026-01-10 05:06:15 +01:00
Leon Bösche
1366807b25 Refactor EditorSessionBloc to improve readability of emit statements for active and read-only states 2026-01-10 05:05:11 +01:00
Leon Bösche
22868b2958 Enhance EditorSession state to include token in active and read-only states; update editor handler to use new Collabora URL 2026-01-10 05:05:04 +01:00
Leon Bösche
84c7ed0815 Refactor EditorSession model to include token in props and update JSON parsing; simplify route handlers by removing JWT manager parameter 2026-01-10 05:02:07 +01:00
Leon Bösche
941d8bf736 Add JWT token handling to document viewer and related components 2026-01-10 05:00:18 +01:00
Leon Bösche
b381a46483 Refactor authentication handling in HTTP routes to utilize middleware for user ID extraction and improve download URL encoding 2026-01-10 04:48:28 +01:00
Leon Bösche
5669385616 Update go.mod and go.sum to include additional dependencies for improved functionality 2026-01-10 03:51:07 +01:00
Leon Bösche
0797b407a5 Refactor file download handlers to implement local storage fallback and enhance organization creation with slug generation 2026-01-10 03:47:35 +01:00
Leon Bösche
f3656fdbd0 Normalize folder names to prevent leading/trailing slashes in folder creation 2026-01-10 03:40:46 +01:00
Leon Bösche
687c7a5a61 Update download URLs in viewer handlers to use the correct domain 2026-01-10 03:04:29 +01:00
Leon Bösche
6a3a2f6701 Add session token handling for PDF viewer in DocumentViewerModal and DocumentViewer 2026-01-10 02:09:52 +01:00
Leon Bösche
f86c44224e Refactor GetFileByID method to improve readability by removing unnecessary blank lines 2026-01-10 02:06:10 +01:00
Leon Bösche
7f6e7f7a10 Add GetFileByID method and enhance viewer handlers for file metadata retrieval 2026-01-10 02:06:03 +01:00
Leon Bösche
cadf504643 Enhance session restoration and add user file viewer endpoint 2026-01-10 01:39:15 +01:00
Leon Bösche
1ceb27dea8 Improve folder path construction logic to handle root and parent paths correctly 2026-01-10 01:08:04 +01:00
Leon Bösche
11436e93c5 Implement CORS middleware with configurable allowed origins and update config structure 2026-01-10 01:06:37 +01:00
Leon Bösche
7f6fe23219 Refactor folder creation logic to reload directory from backend and normalize parent path 2026-01-10 00:57:09 +01:00
Leon Bösche
c8cd82edb4 idle 2026-01-10 00:48:35 +01:00
Leon Bösche
ff370af5a1 Update API binary for improved functionality 2026-01-10 00:39:43 +01:00
Leon Bösche
ca39b3dee4 Add file ID support to FileItem and update related components for consistency 2026-01-10 00:26:34 +01:00
Leon Bösche
260b8b180e idle4000 2026-01-09 23:57:29 +01:00
Leon Bösche
4f67ead22d Add detailed logging for file uploads and handle upload errors in UI 2026-01-09 23:57:28 +01:00
Leon Bösche
14a86b8ae1 Add JSON tags to Organization struct fields for API compatibility 2026-01-09 23:22:26 +01:00
Leon Bösche
708d4ca790 Add error handling for organization loading in HomePage 2026-01-09 23:14:45 +01:00
Leon Bösche
aac6d2eb46 Refactor file download URL construction to use ApiClient's base URL and ensure consistent remote path for user files 2026-01-09 23:01:11 +01:00
Leon Bösche
d20840f4a6 Refactor PermissionBloc to allow all permissions for authenticated users 2026-01-09 22:53:33 +01:00
Leon Bösche
a1ff88bfd9 Refactor FileExplorer and HomePage to use dynamic orgId instead of hardcoded values 2026-01-09 22:44:45 +01:00
Leon Bösche
cfeae0a199 idle300 2026-01-09 22:11:15 +01:00
Leon Bösche
bb33ad1241 Refactor HomePage to use MultiBlocProvider for better state management and lifecycle handling 2026-01-09 22:11:14 +01:00
Leon Bösche
3ec4f9d331 Fix missing OrganizationBloc provider and add OrgApi to DI 2026-01-09 21:50:44 +01:00
Leon Bösche
2a70212123 Remove token refresh logic - no refresh endpoint available 2026-01-09 21:40:06 +01:00
Leon Bösche
b3b31f9c4c Remove auto-refresh attempt on 401 - /auth/refresh endpoint doesn't exist 2026-01-09 21:37:36 +01:00
Leon Bösche
e6c87f6044 Fix personal workspace routing - use empty orgId for /user/files endpoint 2026-01-09 20:49:00 +01:00
Leon Bösche
6866f7fdab Fix GetUserFiles SQL parameter order 2026-01-09 20:31:05 +01:00
Leon Bösche
8114a3746b Fix context key mismatch - use typed contextKey consistently 2026-01-09 20:26:55 +01:00
Leon Bösche
a9d205f454 Run go mod tidy to fix dependencies 2026-01-09 20:06:25 +01:00
Leon Bösche
a7b29c990b Add missing golang.org/x/sys dependency to go.sum 2026-01-09 20:04:54 +01:00
Leon Bösche
9daccbae82 Fix auth for 1.0.0: add logout endpoint, fix JWT claims consistency, add session revocation 2026-01-09 19:53:09 +01:00
Leon Bösche
2ab0786e30 fixed login form height 2026-01-09 19:36:43 +01:00
Leon Bösche
7489c7b1e7 changes 2026-01-09 19:14:58 +01:00
Leon Bösche
f18e779375 work 2026-01-09 18:58:09 +01:00
Leon Bösche
2a62e13fc7 refactor: reorder dependency registration for clarity 2026-01-09 18:26:13 +01:00
Leon Bösche
e16b1bb083 personal workspace backend flush 2026-01-09 17:32:16 +01:00
Leon Bösche
ebb97f4f39 idle 2026-01-09 17:01:41 +01:00
Leon Bösche
e9df8f7d9f idle 2026-01-09 17:01:41 +01:00
Leon Bösche
6a0c5780fd adaptive title 2026-01-09 16:36:51 +01:00
Leon Bösche
2876d9980f icons 3 2026-01-08 22:42:18 +01:00
Leon Bösche
332b89e348 icon launch 2026-01-08 22:34:22 +01:00
Leon Bösche
b99898815a idle 2026-01-08 22:21:24 +01:00
Leon Bösche
bd5c424786 logo setup 2026-01-08 22:18:20 +01:00
Leon Bösche
5caf3f6b62 idle 2026-01-08 22:09:52 +01:00
Leon Bösche
b18a171ac2 idle 2026-01-08 22:09:45 +01:00
Leon Bösche
37e1c1a616 Add session persistence with shared_preferences - maintain login across page refreshes 2026-01-08 22:08:23 +01:00
Leon Bösche
6a01fe84ac Fix AuthAuthenticated email access 2026-01-08 21:42:04 +01:00
Leon Bösche
7adde54a41 Support personal workspace without requiring organization 2026-01-08 21:40:55 +01:00
Leon Bösche
1eb8781550 Add CORS middleware to handle browser preflight requests 2026-01-08 21:32:34 +01:00
Leon Bösche
352e3ee6c5 idle 2026-01-08 20:45:23 +01:00
Leon Bösche
1930eb37fb Fix chi router middleware ordering - move auth middleware to protected routes subrouter 2026-01-08 20:40:07 +01:00
Leon Bösche
912fc99e9e idle 2026-01-08 20:29:22 +01:00
113 changed files with 19436 additions and 2680 deletions

3
.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
*.tar.gz
*_bak.tar.gz
EUROSCALE_DEPLOYMENT_BLUEPRINT.md

224
README.md
View File

@@ -1,149 +1,173 @@
# b0esche.cloud
A self-hosted, SaaS-style document platform with Go backend and Flutter web frontend.
A self-hosted, SaaS-style cloud storage and document platform with a Go backend and Flutter web frontend.
🌐 **Live:** [b0esche.cloud](https://b0esche.cloud)
## Architecture
```
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ Flutter Web │────▶│ Go Backend │────▶│ PostgreSQL │
│ (b0esche_cloud)│ │ (go_cloud) │ │ │
└─────────────────┘ └────────┬────────┘ └─────────────────┘
┌────────────┼────────────┐
▼ ▼ ▼
┌──────────┐ ┌──────────┐ ┌──────────┐
│Nextcloud │ │Collabora │ │ Traefik │
│(Storage) │ │ (Office) │ │ (Proxy) │
└──────────┘ └──────────┘ └──────────┘
```
## Project Structure
- `go_cloud/`: Go backend (control plane) with REST API
- `b0esche_cloud/`: Flutter web frontend with BLoC architecture
- Supporting services: Nextcloud (storage), Collabora (editing), PostgreSQL (database)
```
b0esche_cloud/
├── b0esche_cloud/ # Flutter web frontend
│ ├── lib/
│ │ ├── blocs/ # BLoC state management
│ │ ├── models/ # Data models
│ │ ├── pages/ # UI pages
│ │ ├── repositories/ # Data repositories
│ │ ├── services/ # API services
│ │ ├── theme/ # App theming
│ │ └── widgets/ # Reusable widgets
│ └── web/ # Web assets
├── go_cloud/ # Go backend
│ ├── cmd/api/ # Main entry point
│ ├── internal/
│ │ ├── auth/ # Authentication (OIDC, Passkeys)
│ │ ├── files/ # File management
│ │ ├── org/ # Organization management
│ │ ├── storage/ # Nextcloud/WebDAV integration
│ │ ├── http/ # HTTP handlers & WOPI
│ │ └── ...
│ ├── migrations/ # Database migrations
│ └── pkg/jwt/ # JWT utilities
├── scripts/ # Deployment & operations scripts
└── docs/ # Documentation
└── AUTH.md # Authentication system docs
```
## Features
- 🔐 **Authentication**: OIDC via Nextcloud + WebAuthn Passkeys
- 📁 **File Management**: Upload, download, organize files
- 👥 **Organizations**: Multi-tenant with roles (Owner, Admin, Member)
- 📝 **Document Viewing**: PDF viewer, Office document preview
- 🔄 **Real-time Sync**: Nextcloud/WebDAV backend storage
- 🚀 **Auto-deployment**: Daily 3AM deployments via GitLab webhooks
## Prerequisites
- Go 1.21+
- Flutter 3.10+
- Docker and Docker Compose
- PostgreSQL (or Docker)
- Nextcloud instance
- Collabora Online instance
- Docker & Docker Compose
- PostgreSQL 15+
## Local Development Setup
## Local Development
### 1. Start Supporting Services
Use Docker Compose to start PostgreSQL, Nextcloud, and Collabora:
### Quick Start
```bash
docker-compose up -d db nextcloud collabora
# Start everything
./scripts/dev-all.sh
```
### 2. Backend Setup
### Manual Setup
**Backend:**
```bash
cd go_cloud
cp .env.example .env
# Edit .env with your configuration (DB URL, Nextcloud URL, etc.)
# Edit .env with your configuration
go run ./cmd/api
```
Or use the provided script:
```bash
./scripts/dev-backend.sh
```
### 3. Frontend Setup
**Frontend:**
```bash
cd b0esche_cloud
flutter pub get
flutter run -d chrome
```
Or use the script:
```bash
./scripts/dev-frontend.sh
```
### 4. Full Development Environment
To start everything:
```bash
./scripts/dev-all.sh
```
This will bring up all services, backend, and frontend.
## Configuration
### Backend (.env)
### Backend Environment Variables
Copy `go_cloud/.env.example` to `go_cloud/.env` and fill in:
| Variable | Description |
|----------|-------------|
| `SERVER_ADDR` | Server address (default: `:8080`) |
| `DATABASE_URL` | PostgreSQL connection string |
| `JWT_SECRET` | Secret for JWT signing |
| `OIDC_ISSUER_URL` | OIDC provider URL |
| `OIDC_CLIENT_ID` | OIDC client ID |
| `OIDC_CLIENT_SECRET` | OIDC client secret |
| `NEXTCLOUD_URL` | Nextcloud instance URL |
| `NEXTCLOUD_USERNAME` | Nextcloud admin username |
| `NEXTCLOUD_PASSWORD` | Nextcloud admin password |
| `COLLABORA_URL` | Collabora Online URL |
- `DATABASE_URL`: PostgreSQL connection string
- `JWT_SECRET`: Random secret for JWT signing
- `OIDC_*`: OIDC provider settings
- `NEXTCLOUD_*`: Nextcloud API settings
- `COLLABORA_*`: Collabora settings
## Production Deployment
### Frontend
The project runs on a VPS with Docker containers behind Traefik reverse proxy.
The frontend uses build-time environment variables for API base URL. For dev, it's hardcoded in `ApiClient` constructor.
### Services & Domains
For production builds, update accordingly.
| Domain | Service |
|--------|---------|
| `www.b0esche.cloud` | Flutter Web (Nginx) |
| `go.b0esche.cloud` | Go API Backend |
| `storage.b0esche.cloud` | Nextcloud (Storage + OIDC) |
| `of.b0esche.cloud` | Collabora Online (Office) |
## Running Tests
### Server Directory Structure
### Backend
```bash
cd go_cloud
go test ./...
```
/opt/
├── traefik/ # Reverse proxy + SSL
├── go/ # Go backend + PostgreSQL
├── flutter/ # Flutter web build + Nginx
├── scripts/ # Operations scripts
└── auto-deploy/ # Auto-deployment workspace
```
### Frontend
### Server Scripts
```bash
cd b0esche_cloud
flutter test
```
| Script | Description |
|--------|-------------|
| `auto-deploy.sh` | Daily automated deployment (runs at 3AM) |
| `deploy-now.sh` | Trigger immediate deployment |
| `backup.sh` | Full backup (DB, configs, volumes) |
| `monitor.sh` | Health monitoring & alerts |
| `webhook-server.py` | GitLab webhook receiver |
## Building for Production
### Backend
## Tech Stack
```bash
cd go_cloud
go build -o bin/api ./cmd/api
```
| Component | Technology |
|-----------|------------|
| Frontend | Flutter Web, BLoC |
| Backend | Go, Chi Router |
| Database | PostgreSQL |
| Storage | Nextcloud (WebDAV) |
| Office | Collabora Online |
| Auth | OIDC, WebAuthn |
| Proxy | Traefik |
| CI/CD | GitLab + Webhooks |
### Frontend
## Documentation
```bash
cd b0esche_cloud
flutter build web
```
## Database Migrations
Migrations are in `go_cloud/migrations/`.
To apply:
```bash
# Dev
go run github.com/pressly/goose/v3/cmd/goose@latest postgres "$DATABASE_URL" up
# Production
# Use your deployment tool to run the migration command
```
## Backup Strategy
- **Database**: Regular PostgreSQL dumps of orgs, memberships, activities
- **Files**: Nextcloud/S3 backups handled at storage layer
- **Recovery**: Restore DB, then files; Go control plane is stateless
## Contributing
1. Clone the repo
2. Follow local setup
3. Make changes
4. Run tests
5. Submit PR
| Document | Description |
|----------|-------------|
| [ARCHITECTURE.md](docs/ARCHITECTURE.md) | System architecture, components, data flows |
| [API.md](docs/API.md) | Complete API endpoint reference |
| [AUTH.md](docs/AUTH.md) | Authentication system (Passkeys, OIDC, roles) |
| [SECURITY.md](docs/SECURITY.md) | Security architecture, hardening, best practices |
| [DEVELOPMENT.md](docs/DEVELOPMENT.md) | Local setup, coding conventions, testing |
| [DEPLOYMENT.md](docs/DEPLOYMENT.md) | Production deployment, operations, troubleshooting |
## License
[License here]
Private project - All rights reserved

View File

@@ -1,3 +1,3 @@
# b0esche_cloud
A new Flutter project.
b0esche secure cloud

Binary file not shown.

After

Width:  |  Height:  |  Size: 589 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 574 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 731 KiB

View File

@@ -1,10 +1,12 @@
import 'package:bloc/bloc.dart';
import '../session/session_bloc.dart';
import '../session/session_event.dart';
import '../session/session_state.dart';
import 'auth_event.dart';
import 'auth_state.dart';
import '../../services/api_client.dart';
import '../../models/api_error.dart';
import '../../models/user.dart';
class AuthBloc extends Bloc<AuthEvent, AuthState> {
final ApiClient apiClient;
@@ -21,6 +23,7 @@ class AuthBloc extends Bloc<AuthEvent, AuthState> {
on<AuthenticationResponseSubmitted>(_onAuthenticationResponseSubmitted);
on<LogoutRequested>(_onLogoutRequested);
on<CheckAuthRequested>(_onCheckAuthRequested);
on<UpdateUserProfile>(_onUpdateUserProfile);
}
Future<void> _onSignupStarted(
@@ -53,7 +56,8 @@ class AuthBloc extends Bloc<AuthEvent, AuthState> {
add(RegistrationChallengeRequested(userId: userId));
} catch (e) {
final errorMessage = _extractErrorMessage(e);
emit(AuthFailure(errorMessage));
final code = e is ApiError ? e.code : null;
emit(AuthFailure(errorMessage, code: code));
}
}
@@ -79,7 +83,8 @@ class AuthBloc extends Bloc<AuthEvent, AuthState> {
);
} catch (e) {
final errorMessage = _extractErrorMessage(e);
emit(AuthFailure(errorMessage));
final code = e is ApiError ? e.code : null;
emit(AuthFailure(errorMessage, code: code));
}
}
@@ -107,17 +112,33 @@ class AuthBloc extends Bloc<AuthEvent, AuthState> {
sessionBloc.add(SessionStarted(token));
emit(
AuthAuthenticated(
token: token,
userId: user['id'],
username: user['username'],
email: user['email'],
),
);
// Fetch full profile and include it in state when possible
try {
final profile = await apiClient.getUserProfile();
final fullUser = profile.isNotEmpty ? User.fromJson(profile) : null;
emit(
AuthAuthenticated(
token: token,
userId: user['id'],
username: user['username'],
email: user['email'],
user: fullUser,
),
);
} catch (e) {
emit(
AuthAuthenticated(
token: token,
userId: user['id'],
username: user['username'],
email: user['email'],
),
);
}
} catch (e) {
final errorMessage = _extractErrorMessage(e);
emit(AuthFailure(errorMessage));
final code = e is ApiError ? e.code : null;
emit(AuthFailure(errorMessage, code: code));
}
}
@@ -130,7 +151,8 @@ class AuthBloc extends Bloc<AuthEvent, AuthState> {
add(AuthenticationChallengeRequested(username: event.username));
} catch (e) {
final errorMessage = _extractErrorMessage(e);
emit(AuthFailure(errorMessage));
final code = e is ApiError ? e.code : null;
emit(AuthFailure(errorMessage, code: code));
}
}
@@ -159,7 +181,8 @@ class AuthBloc extends Bloc<AuthEvent, AuthState> {
);
} catch (e) {
final errorMessage = _extractErrorMessage(e);
emit(AuthFailure(errorMessage));
final code = e is ApiError ? e.code : null;
emit(AuthFailure(errorMessage, code: code));
}
}
@@ -187,17 +210,33 @@ class AuthBloc extends Bloc<AuthEvent, AuthState> {
sessionBloc.add(SessionStarted(token));
emit(
AuthAuthenticated(
token: token,
userId: user['id'],
username: user['username'],
email: user['email'],
),
);
// Fetch full profile and include it in state when possible
try {
final profile = await apiClient.getUserProfile();
final fullUser = profile.isNotEmpty ? User.fromJson(profile) : null;
emit(
AuthAuthenticated(
token: token,
userId: user['id'],
username: user['username'],
email: user['email'],
user: fullUser,
),
);
} catch (e) {
emit(
AuthAuthenticated(
token: token,
userId: user['id'],
username: user['username'],
email: user['email'],
),
);
}
} catch (e) {
final errorMessage = _extractErrorMessage(e);
emit(AuthFailure(errorMessage));
final code = e is ApiError ? e.code : null;
emit(AuthFailure(errorMessage, code: code));
}
}
@@ -227,17 +266,34 @@ class AuthBloc extends Bloc<AuthEvent, AuthState> {
final user = response['user'];
sessionBloc.add(SessionStarted(token));
emit(
AuthAuthenticated(
token: token,
userId: user['id'],
username: user['username'],
email: user['email'],
),
);
// Fetch full profile and include it in state when possible
try {
final profile = await apiClient.getUserProfile();
final fullUser = profile.isNotEmpty ? User.fromJson(profile) : null;
emit(
AuthAuthenticated(
token: token,
userId: user['id'],
username: user['username'],
email: user['email'],
user: fullUser,
),
);
} catch (e) {
emit(
AuthAuthenticated(
token: token,
userId: user['id'],
username: user['username'],
email: user['email'],
),
);
}
} catch (e) {
final errorMessage = _extractErrorMessage(e);
emit(AuthFailure(errorMessage));
final code = e is ApiError ? e.code : null;
emit(AuthFailure(errorMessage, code: code));
}
}
@@ -252,7 +308,72 @@ class AuthBloc extends Bloc<AuthEvent, AuthState> {
CheckAuthRequested event,
Emitter<AuthState> emit,
) async {
// Check if token is valid in SessionBloc
emit(AuthUnauthenticated());
// Check if session is active from persistent storage
final sessionState = sessionBloc.state;
if (sessionState is SessionActive) {
// Try to fetch full profile immediately so UI can show avatar/displayName
try {
final profile = await apiClient.getUserProfile();
final fullUser = profile.isNotEmpty ? User.fromJson(profile) : null;
emit(
AuthAuthenticated(
token: sessionState.token,
userId: fullUser?.id ?? '',
username: fullUser?.username ?? '',
email: fullUser?.email ?? '',
user: fullUser,
),
);
} catch (e) {
// Fall back to minimal authenticated state if profile fetch fails
emit(
AuthAuthenticated(
token: sessionState.token,
userId: '',
username: '',
email: '',
),
);
}
} else {
emit(AuthUnauthenticated());
}
}
Future<void> _onUpdateUserProfile(
UpdateUserProfile event,
Emitter<AuthState> emit,
) async {
if (state is AuthAuthenticated) {
final currentState = state as AuthAuthenticated;
// Try to reload profile from backend to ensure we have canonical avatar URL (with token+version)
try {
final profile = await apiClient.getUserProfile();
final fullUser = profile.isNotEmpty
? User.fromJson(profile)
: event.updatedUser;
emit(
AuthAuthenticated(
token: currentState.token,
userId: fullUser.id,
username: fullUser.username,
email: fullUser.email,
user: fullUser,
),
);
} catch (e) {
// Fallback to using the provided updatedUser
emit(
AuthAuthenticated(
token: currentState.token,
userId: event.updatedUser.id,
username: event.updatedUser.username,
email: event.updatedUser.email,
user: event.updatedUser,
),
);
}
}
}
}

View File

@@ -1,4 +1,5 @@
import 'package:equatable/equatable.dart';
import '../../models/user.dart';
abstract class AuthEvent extends Equatable {
const AuthEvent();
@@ -135,3 +136,12 @@ class PasswordLoginRequested extends AuthEvent {
@override
List<Object> get props => [username, password];
}
class UpdateUserProfile extends AuthEvent {
final User updatedUser;
const UpdateUserProfile(this.updatedUser);
@override
List<Object> get props => [updatedUser];
}

View File

@@ -1,4 +1,5 @@
import 'package:equatable/equatable.dart';
import '../../models/user.dart';
abstract class AuthState extends Equatable {
const AuthState();
@@ -68,25 +69,28 @@ class AuthAuthenticated extends AuthState {
final String userId;
final String username;
final String email;
final User? user;
const AuthAuthenticated({
required this.token,
required this.userId,
required this.username,
required this.email,
this.user,
});
@override
List<Object> get props => [token, userId, username, email];
List<Object?> get props => [token, userId, username, email, user];
}
class AuthFailure extends AuthState {
final String error;
final String? code;
const AuthFailure(this.error);
const AuthFailure(this.error, {this.code});
@override
List<Object> get props => [error];
List<Object?> get props => [error, code];
}
class AuthUnauthenticated extends AuthState {

View File

@@ -34,6 +34,8 @@ class DocumentViewerBloc
DocumentViewerReady(
viewUrl: session.viewUrl,
caps: session.capabilities,
token: session.token,
fileInfo: session.fileInfo,
),
);
_expiryTimer = Timer(

View File

@@ -15,11 +15,23 @@ class DocumentViewerLoading extends DocumentViewerState {}
class DocumentViewerReady extends DocumentViewerState {
final Uri viewUrl;
final DocumentCapabilities caps;
final String token;
final FileInfo? fileInfo;
const DocumentViewerReady({required this.viewUrl, required this.caps});
const DocumentViewerReady({
required this.viewUrl,
required this.caps,
required this.token,
this.fileInfo,
});
@override
List<Object> get props => [viewUrl, caps];
List<Object> get props => [
viewUrl,
caps,
token,
if (fileInfo != null) fileInfo!,
];
}
class DocumentViewerError extends DocumentViewerState {

View File

@@ -52,9 +52,13 @@ class EditorSessionBloc extends Bloc<EditorSessionEvent, EditorSessionState> {
return;
}
if (!session.readOnly) {
emit(EditorSessionActive(editUrl: session.editUrl));
emit(
EditorSessionActive(editUrl: session.editUrl, token: session.token),
);
} else {
emit(EditorSessionReadOnly(viewUrl: session.editUrl));
emit(
EditorSessionReadOnly(viewUrl: session.editUrl, token: session.token),
);
}
_expiryTimer = Timer(
session.expiresAt.difference(DateTime.now()),

View File

@@ -13,20 +13,22 @@ class EditorSessionStarting extends EditorSessionState {}
class EditorSessionActive extends EditorSessionState {
final Uri editUrl;
final String token;
const EditorSessionActive({required this.editUrl});
const EditorSessionActive({required this.editUrl, required this.token});
@override
List<Object> get props => [editUrl];
List<Object> get props => [editUrl, token];
}
class EditorSessionReadOnly extends EditorSessionState {
final Uri viewUrl;
final String token;
const EditorSessionReadOnly({required this.viewUrl});
const EditorSessionReadOnly({required this.viewUrl, required this.token});
@override
List<Object> get props => [viewUrl];
List<Object> get props => [viewUrl, token];
}
class EditorSessionFailed extends EditorSessionState {

View File

@@ -123,24 +123,8 @@ class FileBrowserBloc extends Bloc<FileBrowserEvent, FileBrowserState> {
event.parentPath,
event.folderName,
);
// Add the new folder to local state if in current directory
if (event.parentPath == _currentPath) {
final newFolder = FileItem(
name: event.folderName,
path: '${event.parentPath}/${event.folderName}',
type: FileType.folder,
size: 0,
lastModified: DateTime.now(),
);
_currentFiles.add(newFolder);
_currentFiles = _sortFiles(_currentFiles, _sortBy, _isAscending);
_filteredFiles = _currentFiles
.where((f) => f.name.toLowerCase().contains(_currentFilter))
.toList();
_emitLoadedState(emit);
} else {
add(LoadDirectory(orgId: event.orgId, path: event.parentPath));
}
// Reload directory to get the folder with proper ID from backend
add(LoadDirectory(orgId: event.orgId, path: event.parentPath));
} catch (e) {
emit(DirectoryError(_getErrorMessage(e)));
}
@@ -192,7 +176,8 @@ class FileBrowserBloc extends Bloc<FileBrowserEvent, FileBrowserState> {
void _onDeleteFile(DeleteFile event, Emitter<FileBrowserState> emit) async {
try {
await _fileService.deleteFile(event.orgId, event.path);
_currentFiles.removeWhere((f) => f.path == event.path);
// Create new list to trigger Equatable change detection
_currentFiles = _currentFiles.where((f) => f.path != event.path).toList();
_filteredFiles = _currentFiles
.where((f) => f.name.toLowerCase().contains(_currentFilter))
.toList();
@@ -206,9 +191,9 @@ class FileBrowserBloc extends Bloc<FileBrowserEvent, FileBrowserState> {
ResetFileBrowser event,
Emitter<FileBrowserState> emit,
) {
emit(DirectoryInitial());
_currentOrgId = '';
_currentPath = '/';
_currentFiles = [];
_filteredFiles = [];
_currentFilter = '';
_currentPage = 1;
_pageSize = 20;
@@ -277,6 +262,12 @@ class FileBrowserBloc extends Bloc<FileBrowserEvent, FileBrowserState> {
) {
final sorted = List<FileItem>.from(files);
sorted.sort((a, b) {
// Always put folders first, then files
if (a.type != b.type) {
return a.type == FileType.folder ? -1 : 1;
}
// Within the same type (both folders or both files), sort by the selected criterion
switch (sortBy) {
case 'name':
return isAscending
@@ -291,12 +282,7 @@ class FileBrowserBloc extends Bloc<FileBrowserEvent, FileBrowserState> {
? a.size.compareTo(b.size)
: b.size.compareTo(a.size);
case 'type':
// Folders before files if ascending, else files before folders
int typeCompare = isAscending
? a.type.index.compareTo(b.type.index)
: b.type.index.compareTo(a.type.index);
if (typeCompare != 0) return typeCompare;
// Within same type, sort by name
// Already handled above (folders vs files)
return isAscending
? a.name.compareTo(b.name)
: b.name.compareTo(a.name);

View File

@@ -62,7 +62,14 @@ class CreateFolder extends FileBrowserEvent {
List<Object> get props => [orgId, parentPath, folderName];
}
class ResetFileBrowser extends FileBrowserEvent {}
class ResetFileBrowser extends FileBrowserEvent {
final String nextOrgId;
const ResetFileBrowser(this.nextOrgId);
@override
List<Object> get props => [nextOrgId];
}
class LoadPage extends FileBrowserEvent {
final int page;

View File

@@ -73,10 +73,18 @@ class OrganizationBloc extends Bloc<OrganizationEvent, OrganizationState> {
) {
final currentState = state;
if (currentState is OrganizationLoaded) {
final selected = currentState.organizations.firstWhere(
(org) => org.id == event.orgId,
orElse: () => currentState.selectedOrg!,
);
Organization? selected;
if (event.orgId.isEmpty) {
// Personal workspace - set to null to indicate no org selected
selected = null;
} else {
selected = currentState.organizations.firstWhere(
(org) => org.id == event.orgId,
orElse: () => currentState.selectedOrg!,
);
}
emit(
OrganizationLoaded(
organizations: currentState.organizations,
@@ -86,7 +94,7 @@ class OrganizationBloc extends Bloc<OrganizationEvent, OrganizationState> {
);
// Reset all dependent blocs
permissionBloc.add(PermissionsReset());
fileBrowserBloc.add(ResetFileBrowser());
fileBrowserBloc.add(ResetFileBrowser(event.orgId));
uploadBloc.add(ResetUploads());
// Load permissions for the selected org
permissionBloc.add(LoadPermissions(event.orgId));
@@ -97,59 +105,72 @@ class OrganizationBloc extends Bloc<OrganizationEvent, OrganizationState> {
CreateOrganization event,
Emitter<OrganizationState> emit,
) async {
final currentState = state;
if (currentState is OrganizationLoaded) {
final name = event.name.trim();
if (name.isEmpty) {
final name = event.name.trim();
if (name.isEmpty) {
// Try to preserve current state if possible
if (state is OrganizationLoaded) {
emit(
OrganizationLoaded(
organizations: currentState.organizations,
selectedOrg: currentState.selectedOrg,
organizations: (state as OrganizationLoaded).organizations,
selectedOrg: (state as OrganizationLoaded).selectedOrg,
isLoading: false,
error: 'Organization name cannot be empty',
),
);
return;
}
if (currentState.organizations.any((org) => org.name == name)) {
return;
}
// Get existing organizations list
List<Organization> existingOrgs = [];
Organization? selectedOrg;
if (state is OrganizationLoaded) {
existingOrgs = (state as OrganizationLoaded).organizations;
selectedOrg = (state as OrganizationLoaded).selectedOrg;
// Check for duplicate name (client-side validation)
if (existingOrgs.any((org) => org.name == name)) {
emit(
OrganizationLoaded(
organizations: currentState.organizations,
selectedOrg: currentState.selectedOrg,
organizations: existingOrgs,
selectedOrg: selectedOrg,
isLoading: false,
error: 'Organization with this name already exists',
),
);
return;
}
}
// Set loading state
emit(
OrganizationLoaded(
organizations: existingOrgs,
selectedOrg: selectedOrg,
isLoading: true,
),
);
try {
final newOrg = await orgApi.createOrganization(name);
final updatedOrgs = [...existingOrgs, newOrg];
emit(OrganizationLoaded(organizations: updatedOrgs, selectedOrg: newOrg));
// Reset blocs and load permissions for new org
permissionBloc.add(PermissionsReset());
fileBrowserBloc.add(ResetFileBrowser(newOrg.id));
uploadBloc.add(ResetUploads());
permissionBloc.add(LoadPermissions(newOrg.id));
} catch (e) {
emit(
OrganizationLoaded(
organizations: currentState.organizations,
selectedOrg: currentState.selectedOrg,
isLoading: true,
organizations: existingOrgs,
selectedOrg: selectedOrg,
isLoading: false,
error: _getErrorMessage(e),
),
);
try {
final newOrg = await orgApi.createOrganization(name);
final updatedOrgs = [...currentState.organizations, newOrg];
emit(
OrganizationLoaded(organizations: updatedOrgs, selectedOrg: newOrg),
);
// Reset blocs and load permissions for new org
permissionBloc.add(PermissionsReset());
fileBrowserBloc.add(ResetFileBrowser());
uploadBloc.add(ResetUploads());
permissionBloc.add(LoadPermissions(newOrg.id));
} catch (e) {
emit(
OrganizationLoaded(
organizations: currentState.organizations,
selectedOrg: currentState.selectedOrg,
isLoading: false,
error: _getErrorMessage(e),
),
);
}
}
}
}

View File

@@ -1,9 +1,13 @@
import 'package:bloc/bloc.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'permission_event.dart';
import 'permission_state.dart';
import '../../services/api_client.dart';
class PermissionBloc extends Bloc<PermissionEvent, PermissionState> {
PermissionBloc() : super(PermissionInitial()) {
final ApiClient apiClient;
PermissionBloc(this.apiClient) : super(PermissionInitial()) {
on<LoadPermissions>(_onLoadPermissions);
on<PermissionsReset>(_onPermissionsReset);
}
@@ -12,19 +16,36 @@ class PermissionBloc extends Bloc<PermissionEvent, PermissionState> {
LoadPermissions event,
Emitter<PermissionState> emit,
) async {
if (event.orgId.isEmpty) {
// Personal workspace - assume full permissions
final capabilities = Capabilities(
canRead: true,
canWrite: true,
canShare: true,
canAdmin: true,
canAnnotate: true,
canEdit: true,
);
emit(PermissionLoaded(capabilities));
return;
}
emit(PermissionLoading());
// Simulate loading permissions from backend for orgId
await Future.delayed(const Duration(seconds: 1));
// Mock capabilities based on orgId
final capabilities = Capabilities(
canRead: true,
canWrite: event.orgId == 'org1', // Only admin for personal
canShare: event.orgId == 'org1',
canAdmin: event.orgId == 'org1',
canAnnotate: true,
canEdit: true,
);
emit(PermissionLoaded(capabilities));
try {
final response = await apiClient.getRaw(
'/orgs/${event.orgId}/permissions',
);
final capabilities = Capabilities(
canRead: response['canRead'] ?? false,
canWrite: response['canWrite'] ?? false,
canShare: response['canShare'] ?? false,
canAdmin: response['canAdmin'] ?? false,
canAnnotate: response['canAnnotate'] ?? false,
canEdit: response['canEdit'] ?? false,
);
emit(PermissionLoaded(capabilities));
} catch (e) {
emit(PermissionDenied(e.toString()));
}
}
void _onPermissionsReset(

View File

@@ -1,42 +1,104 @@
import 'dart:async';
import 'package:bloc/bloc.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'session_event.dart';
import 'session_state.dart';
class SessionBloc extends Bloc<SessionEvent, SessionState> {
Timer? _expiryTimer;
static const String _tokenKey = 'auth_token';
static const String _expiryKey = 'auth_expiry';
SessionBloc() : super(SessionInitial()) {
on<SessionStarted>(_onSessionStarted);
on<SessionExpired>(_onSessionExpired);
on<SessionRefreshed>(_onSessionRefreshed);
on<SessionEnded>(_onSessionEnded);
on<SessionRestored>(_onSessionRestored);
}
void _onSessionStarted(SessionStarted event, Emitter<SessionState> emit) {
void _onSessionStarted(
SessionStarted event,
Emitter<SessionState> emit,
) async {
final expiresAt = DateTime.now().add(
const Duration(minutes: 15),
); // Match Go
// Save token to persistent storage
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_tokenKey, event.token);
await prefs.setString(_expiryKey, expiresAt.toIso8601String());
emit(SessionActive(token: event.token, expiresAt: expiresAt));
_startExpiryTimer(expiresAt);
}
void _onSessionExpired(SessionExpired event, Emitter<SessionState> emit) {
_expiryTimer?.cancel();
_clearStoredSession();
emit(SessionExpiredState());
}
void _onSessionRefreshed(SessionRefreshed event, Emitter<SessionState> emit) {
void _onSessionRefreshed(
SessionRefreshed event,
Emitter<SessionState> emit,
) async {
final expiresAt = DateTime.now().add(const Duration(minutes: 15));
// Update stored token
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_tokenKey, event.newToken);
await prefs.setString(_expiryKey, expiresAt.toIso8601String());
emit(SessionActive(token: event.newToken, expiresAt: expiresAt));
_startExpiryTimer(expiresAt);
}
void _onSessionEnded(SessionEnded event, Emitter<SessionState> emit) {
_expiryTimer?.cancel();
_clearStoredSession();
emit(SessionInitial());
}
void _onSessionRestored(SessionRestored event, Emitter<SessionState> emit) {
final expiresAt = event.expiresAt;
final now = DateTime.now();
// Check if token is still valid
if (expiresAt.isAfter(now)) {
emit(SessionActive(token: event.token, expiresAt: expiresAt));
_startExpiryTimer(expiresAt);
} else {
// Token expired, clear it
_clearStoredSession();
emit(SessionInitial());
}
}
Future<void> _clearStoredSession() async {
final prefs = await SharedPreferences.getInstance();
await prefs.remove(_tokenKey);
await prefs.remove(_expiryKey);
}
static Future<void> restoreSession(SessionBloc bloc) async {
final prefs = await SharedPreferences.getInstance();
final token = prefs.getString(_tokenKey);
final expiryStr = prefs.getString(_expiryKey);
if (token != null && expiryStr != null) {
try {
final expiresAt = DateTime.parse(expiryStr);
bloc.add(SessionRestored(token: token, expiresAt: expiresAt));
} catch (e) {
// Invalid stored data, clear it
await prefs.remove(_tokenKey);
await prefs.remove(_expiryKey);
}
}
}
void _startExpiryTimer(DateTime expiresAt) {
_expiryTimer?.cancel();
final duration = expiresAt.difference(DateTime.now());

View File

@@ -28,3 +28,13 @@ class SessionRefreshed extends SessionEvent {
}
class SessionEnded extends SessionEvent {}
class SessionRestored extends SessionEvent {
final String token;
final DateTime expiresAt;
const SessionRestored({required this.token, required this.expiresAt});
@override
List<Object> get props => [token, expiresAt];
}

View File

@@ -26,6 +26,7 @@ class UploadBloc extends Bloc<UploadEvent, UploadState> {
try {
// Simulate upload
await _fileRepository.uploadFile(event.orgId, file);
add(UploadCompleted(file));
} catch (e) {
add(UploadFailed(fileName: file.name, error: e.toString()));

View File

@@ -1,28 +1,29 @@
import 'package:b0esche_cloud/services/api_client.dart';
import 'services/api_client.dart';
import 'package:get_it/get_it.dart';
import 'blocs/session/session_bloc.dart';
import 'repositories/auth_repository.dart';
import 'repositories/file_repository.dart';
import 'repositories/mock_auth_repository.dart';
import 'repositories/mock_file_repository.dart';
import 'repositories/http_auth_repository.dart';
import 'repositories/http_file_repository.dart';
import 'services/auth_service.dart';
import 'services/file_service.dart';
import 'viewmodels/login_view_model.dart';
import 'viewmodels/file_explorer_view_model.dart';
import 'services/org_api.dart';
final getIt = GetIt.instance;
void configureDependencies() {
// Register repositories
getIt.registerSingleton<AuthRepository>(MockAuthRepository());
getIt.registerSingleton<FileRepository>(MockFileRepository());
void configureDependencies(SessionBloc sessionBloc) {
// Register ApiClient first
final apiClient = ApiClient(sessionBloc);
getIt.registerSingleton<ApiClient>(apiClient);
// Register repositories (HTTP-backed)
getIt.registerSingleton<AuthRepository>(HttpAuthRepository(apiClient));
getIt.registerSingleton<FileRepository>(
HttpFileRepository(FileService(apiClient)),
);
// Register services
getIt.registerSingleton<AuthService>(AuthService(getIt<AuthRepository>()));
getIt.registerSingleton<FileService>(FileService(getIt<ApiClient>()));
// Register viewmodels
getIt.registerSingleton<LoginViewModel>(LoginViewModel(getIt<AuthService>()));
getIt.registerSingleton<FileExplorerViewModel>(
FileExplorerViewModel(getIt<FileService>()),
);
getIt.registerSingleton<OrgApi>(OrgApi(getIt<ApiClient>()));
}

View File

@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart';
import 'blocs/auth/auth_bloc.dart';
import 'blocs/auth/auth_event.dart';
import 'blocs/session/session_bloc.dart';
import 'blocs/activity/activity_bloc.dart';
import 'services/api_client.dart';
@@ -10,11 +11,20 @@ import 'pages/home_page.dart';
import 'pages/file_explorer.dart';
import 'pages/document_viewer.dart';
import 'pages/editor_page.dart';
import 'pages/join_page.dart';
import 'pages/login_page.dart';
import 'blocs/session/session_state.dart';
import 'pages/public_file_viewer.dart';
import 'theme/app_theme.dart';
import 'injection.dart';
import 'package:flutter/foundation.dart' show kIsWeb;
final GoRouter _router = GoRouter(
initialLocation: kIsWeb ? Uri.base.path : '/',
routes: [
GoRoute(path: '/', builder: (context, state) => const HomePage()),
GoRoute(path: '/login', builder: (context, state) => const LoginPage()),
GoRoute(
path: '/viewer/:orgId/:fileId',
builder: (context, state) => DocumentViewer(
@@ -34,6 +44,16 @@ final GoRouter _router = GoRouter(
builder: (context, state) =>
FileExplorer(orgId: state.pathParameters['orgId']!),
),
GoRoute(
path: '/join',
builder: (context, state) =>
JoinPage(token: state.uri.queryParameters['token'] ?? ''),
),
GoRoute(
path: '/share/:token',
builder: (context, state) =>
PublicFileViewer(token: state.pathParameters['token']!),
),
],
);
@@ -41,29 +61,93 @@ void main() {
runApp(const MainApp());
}
class MainApp extends StatelessWidget {
class MainApp extends StatefulWidget {
const MainApp({super.key});
@override
State<MainApp> createState() => _MainAppState();
}
class _MainAppState extends State<MainApp> {
final _sessionBloc = SessionBloc();
late final AuthBloc _authBloc;
late final Future<void> _restoreFuture;
@override
void initState() {
super.initState();
// Configure DI first
configureDependencies(_sessionBloc);
// Create AuthBloc first
_authBloc = AuthBloc(
apiClient: ApiClient(_sessionBloc),
sessionBloc: _sessionBloc,
);
// Restore session and then check auth
_restoreFuture = SessionBloc.restoreSession(_sessionBloc).then((_) {
// After session is restored, check if we should auto-authenticate
if (mounted) {
_authBloc.add(const CheckAuthRequested());
}
});
}
@override
Widget build(BuildContext context) {
return MultiBlocProvider(
providers: [
BlocProvider<SessionBloc>(create: (_) => SessionBloc()),
BlocProvider<AuthBloc>(
create: (context) => AuthBloc(
apiClient: ApiClient(context.read<SessionBloc>()),
sessionBloc: context.read<SessionBloc>(),
),
),
BlocProvider<SessionBloc>.value(value: _sessionBloc),
BlocProvider<AuthBloc>.value(value: _authBloc),
BlocProvider<ActivityBloc>(
create: (context) =>
ActivityBloc(ActivityApi(ApiClient(context.read<SessionBloc>()))),
ActivityBloc(ActivityApi(ApiClient(_sessionBloc))),
),
],
child: MaterialApp.router(
routerConfig: _router,
theme: AppTheme.darkTheme,
child: FutureBuilder<void>(
future: _restoreFuture,
builder: (context, snapshot) {
if (snapshot.connectionState != ConnectionState.done) {
return MaterialApp(
theme: AppTheme.darkTheme,
home: const Scaffold(
body: Center(
child: CircularProgressIndicator(
valueColor: AlwaysStoppedAnimation<Color>(
AppTheme.accentColor,
),
),
),
),
);
}
return MaterialApp.router(
routerConfig: _router,
theme: AppTheme.darkTheme,
builder: (context, child) {
return BlocListener<SessionBloc, SessionState>(
listener: (context, state) {
if (state is SessionExpiredState) {
final currentLocation = GoRouterState.of(
context,
).uri.toString();
context.go('/login?redirect=$currentLocation');
}
},
child: child!,
);
},
);
},
),
);
}
@override
void dispose() {
_authBloc.close();
_sessionBloc.close();
super.dispose();
}
}

View File

@@ -1,24 +1,64 @@
import 'package:equatable/equatable.dart';
class FileInfo extends Equatable {
final String name;
final int size;
final DateTime? lastModified;
final String? modifiedByName;
const FileInfo({
required this.name,
required this.size,
this.lastModified,
this.modifiedByName,
});
@override
List<Object?> get props => [name, size, lastModified, modifiedByName];
factory FileInfo.fromJson(Map<String, dynamic> json) {
return FileInfo(
name: json['name'] ?? '',
size: json['size'] ?? 0,
lastModified: json['lastModified'] != null
? DateTime.tryParse(json['lastModified'])
: null,
modifiedByName: json['modifiedByName'],
);
}
}
class DocumentCapabilities extends Equatable {
final bool canEdit;
final bool canAnnotate;
final bool isPdf;
final String mimeType;
const DocumentCapabilities({
required this.canEdit,
required this.canAnnotate,
required this.isPdf,
required this.mimeType,
});
@override
List<Object?> get props => [canEdit, canAnnotate, isPdf];
List<Object?> get props => [canEdit, canAnnotate, isPdf, mimeType];
factory DocumentCapabilities.fromJson(Map<String, dynamic> json) {
return DocumentCapabilities(
canEdit: json['canEdit'],
canAnnotate: json['canAnnotate'],
isPdf: json['isPdf'],
mimeType: json['mimeType'] ?? 'application/octet-stream',
);
}
bool get isImage => mimeType.startsWith('image/');
bool get isText => mimeType.startsWith('text/');
bool get isOffice =>
mimeType.contains('word') ||
mimeType.contains('spreadsheet') ||
mimeType.contains('presentation');
bool get isVideo => mimeType.startsWith('video/');
bool get isAudio => mimeType.startsWith('audio/');
}

View File

@@ -2,21 +2,24 @@ import 'package:equatable/equatable.dart';
class EditorSession extends Equatable {
final Uri editUrl;
final String token;
final bool readOnly;
final DateTime expiresAt;
const EditorSession({
required this.editUrl,
required this.token,
required this.readOnly,
required this.expiresAt,
});
@override
List<Object?> get props => [editUrl, readOnly, expiresAt];
List<Object?> get props => [editUrl, token, readOnly, expiresAt];
factory EditorSession.fromJson(Map<String, dynamic> json) {
return EditorSession(
editUrl: Uri.parse(json['editUrl']),
token: json['token'] ?? '',
readOnly: json['readOnly'],
expiresAt: DateTime.parse(json['expiresAt']),
);

View File

@@ -1,26 +1,34 @@
import 'package:equatable/equatable.dart';
import 'dart:typed_data';
enum FileType { folder, file }
class FileItem extends Equatable {
final String? id;
final String name;
final String path;
final FileType type;
final int size; // in bytes, 0 for folders
final DateTime lastModified;
final String? localPath; // optional local file path for uploads
final Uint8List? bytes; // optional file bytes for web/desktop uploads
const FileItem({
this.id,
required this.name,
required this.path,
required this.type,
this.size = 0,
required this.lastModified,
this.localPath,
this.bytes,
});
@override
List<Object?> get props => [name, path, type, size, lastModified];
List<Object?> get props => [id, name, path, type, size, lastModified];
FileItem copyWith({
String? id,
String? name,
String? path,
FileType? type,
@@ -28,6 +36,7 @@ class FileItem extends Equatable {
DateTime? lastModified,
}) {
return FileItem(
id: id ?? this.id,
name: name ?? this.name,
path: path ?? this.path,
type: type ?? this.type,

View File

@@ -0,0 +1,96 @@
import 'user.dart';
class Member {
final String userId;
final String orgId;
final String role;
final DateTime createdAt;
final User user;
const Member({
required this.userId,
required this.orgId,
required this.role,
required this.createdAt,
required this.user,
});
factory Member.fromJson(Map<String, dynamic> json) {
return Member(
userId: json['UserID'] ?? json['userId'],
orgId: json['OrgID'] ?? json['orgId'],
role: json['Role'] ?? json['role'],
createdAt: DateTime.parse(json['CreatedAt'] ?? json['createdAt']),
user: User.fromJson(json['user']),
);
}
}
class Invitation {
final String id;
final String orgId;
final String invitedBy;
final String username;
final String role;
final DateTime createdAt;
final DateTime expiresAt;
final DateTime? acceptedAt;
const Invitation({
required this.id,
required this.orgId,
required this.invitedBy,
required this.username,
required this.role,
required this.createdAt,
required this.expiresAt,
this.acceptedAt,
});
factory Invitation.fromJson(Map<String, dynamic> json) {
return Invitation(
id: json['id'],
orgId: json['orgId'],
invitedBy: json['invitedBy'],
username: json['username'],
role: json['role'],
createdAt: DateTime.parse(json['createdAt']),
expiresAt: DateTime.parse(json['expiresAt']),
acceptedAt: json['acceptedAt'] != null
? DateTime.parse(json['acceptedAt'])
: null,
);
}
}
class JoinRequest {
final String id;
final String orgId;
final String userId;
final String? inviteToken;
final DateTime requestedAt;
final String status;
final User user;
const JoinRequest({
required this.id,
required this.orgId,
required this.userId,
this.inviteToken,
required this.requestedAt,
required this.status,
required this.user,
});
factory JoinRequest.fromJson(Map<String, dynamic> json) {
return JoinRequest(
id: json['ID'] ?? json['id'],
orgId: json['OrgID'] ?? json['orgId'],
userId: json['UserID'] ?? json['userId'],
inviteToken: json['InviteToken'] ?? json['inviteToken'],
requestedAt: DateTime.parse(json['RequestedAt'] ?? json['requestedAt']),
status: json['Status'] ?? json['status'],
user: User.fromJson(json['user']),
);
}
}

View File

@@ -5,6 +5,7 @@ class User extends Equatable {
final String username;
final String email;
final String? displayName;
final String? avatarUrl;
final DateTime createdAt;
final DateTime? lastLoginAt;
@@ -13,6 +14,7 @@ class User extends Equatable {
required this.username,
required this.email,
this.displayName,
this.avatarUrl,
required this.createdAt,
this.lastLoginAt,
});
@@ -23,6 +25,7 @@ class User extends Equatable {
username,
email,
displayName,
avatarUrl,
createdAt,
lastLoginAt,
];
@@ -32,6 +35,7 @@ class User extends Equatable {
String? username,
String? email,
String? displayName,
String? avatarUrl,
DateTime? createdAt,
DateTime? lastLoginAt,
}) {
@@ -40,6 +44,7 @@ class User extends Equatable {
username: username ?? this.username,
email: email ?? this.email,
displayName: displayName ?? this.displayName,
avatarUrl: avatarUrl ?? this.avatarUrl,
createdAt: createdAt ?? this.createdAt,
lastLoginAt: lastLoginAt ?? this.lastLoginAt,
);
@@ -51,6 +56,7 @@ class User extends Equatable {
username: json['username'] as String,
email: json['email'] as String,
displayName: json['displayName'] as String?,
avatarUrl: json['avatarUrl'] as String?,
createdAt: DateTime.parse(
json['createdAt'] as String? ?? DateTime.now().toIso8601String(),
),
@@ -66,6 +72,7 @@ class User extends Equatable {
'username': username,
'email': email,
'displayName': displayName,
'avatarUrl': avatarUrl,
'createdAt': createdAt.toIso8601String(),
'lastLoginAt': lastLoginAt?.toIso8601String(),
};

View File

@@ -6,16 +6,24 @@ class ViewerSession extends Equatable {
final DocumentCapabilities capabilities;
final String token;
final DateTime expiresAt;
final FileInfo? fileInfo;
const ViewerSession({
required this.viewUrl,
required this.capabilities,
required this.token,
required this.expiresAt,
this.fileInfo,
});
@override
List<Object?> get props => [viewUrl, capabilities, token, expiresAt];
List<Object?> get props => [
viewUrl,
capabilities,
token,
expiresAt,
fileInfo,
];
factory ViewerSession.fromJson(Map<String, dynamic> json) {
return ViewerSession(
@@ -23,6 +31,9 @@ class ViewerSession extends Equatable {
capabilities: DocumentCapabilities.fromJson(json['capabilities']),
token: json['token'],
expiresAt: DateTime.parse(json['expiresAt']),
fileInfo: json['fileInfo'] != null
? FileInfo.fromJson(json['fileInfo'])
: null,
);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -8,6 +8,161 @@ import '../services/file_service.dart';
import '../injection.dart';
import 'package:go_router/go_router.dart';
// Modal version for overlay display
class EditorPageModal extends StatefulWidget {
final String orgId;
final String fileId;
final VoidCallback onClose;
const EditorPageModal({
super.key,
required this.orgId,
required this.fileId,
required this.onClose,
});
@override
State<EditorPageModal> createState() => _EditorPageModalState();
}
class _EditorPageModalState extends State<EditorPageModal> {
late EditorSessionBloc _editorBloc;
@override
void initState() {
super.initState();
_editorBloc = EditorSessionBloc(getIt<FileService>());
_editorBloc.add(
EditorSessionStarted(orgId: widget.orgId, fileId: widget.fileId),
);
}
@override
Widget build(BuildContext context) {
return BlocProvider.value(
value: _editorBloc,
child: Column(
children: [
// Custom AppBar
Container(
height: 56,
decoration: BoxDecoration(
color: AppTheme.primaryBackground.withValues(alpha: 0.9),
border: Border(
bottom: BorderSide(
color: AppTheme.accentColor.withValues(alpha: 0.3),
),
),
),
child: Row(
children: [
const SizedBox(width: 16),
const Text(
'Document Editor',
style: TextStyle(
color: AppTheme.primaryText,
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
const Spacer(),
IconButton(
icon: const Icon(Icons.close, color: AppTheme.primaryText),
onPressed: () {
_editorBloc.add(EditorSessionEnded());
widget.onClose();
},
),
const SizedBox(width: 8),
],
),
),
// Editor content
Expanded(
child: BlocBuilder<EditorSessionBloc, EditorSessionState>(
builder: (context, state) {
if (state is EditorSessionStarting) {
return const Center(child: CircularProgressIndicator());
}
if (state is EditorSessionFailed) {
return Center(
child: Text(
'Error: ${state.message}',
style: const TextStyle(color: AppTheme.primaryText),
),
);
}
if (state is EditorSessionActive) {
return Container(
color: AppTheme.secondaryText,
child: Center(
child: Text(
'Collabora Editor Active\\n(URL: ${state.editUrl})',
textAlign: TextAlign.center,
style: const TextStyle(color: AppTheme.primaryText),
),
),
);
}
if (state is EditorSessionReadOnly) {
return Container(
color: AppTheme.secondaryText,
child: Center(
child: Text(
'Read Only Mode\\n(URL: ${state.viewUrl})',
textAlign: TextAlign.center,
style: const TextStyle(color: AppTheme.primaryText),
),
),
);
}
if (state is EditorSessionExpired) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Text(
'Editing session expired.',
style: TextStyle(color: AppTheme.primaryText),
),
const SizedBox(height: 16),
ElevatedButton(
onPressed: () {
_editorBloc.add(
EditorSessionStarted(
orgId: widget.orgId,
fileId: widget.fileId,
),
);
},
child: const Text('Reopen'),
),
],
),
);
}
return const Center(
child: Text(
'No editor session',
style: TextStyle(color: AppTheme.primaryText),
),
);
},
),
),
],
),
);
}
@override
void dispose() {
_editorBloc.close();
super.dispose();
}
}
// Original page version (for routing if needed)
class EditorPage extends StatefulWidget {
final String orgId;
final String fileId;

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,137 @@
import 'package:flutter/material.dart';
import '../blocs/organization/organization_state.dart';
import '../services/org_api.dart';
import '../services/api_client.dart';
import '../theme/app_theme.dart';
import '../theme/modern_glass_button.dart';
import 'package:get_it/get_it.dart';
class JoinPage extends StatefulWidget {
final String token;
const JoinPage({super.key, required this.token});
@override
State<JoinPage> createState() => _JoinPageState();
}
class _JoinPageState extends State<JoinPage> {
Organization? _org;
bool _isLoading = true;
String? _error;
bool _isJoining = false;
@override
void initState() {
super.initState();
_fetchOrg();
}
Future<void> _fetchOrg() async {
if (widget.token.isEmpty) {
setState(() {
_error = 'Invalid invite link';
_isLoading = false;
});
return;
}
try {
// Assume we have a method to get org by token
// For now, since not implemented, use ApiClient directly
final apiClient = GetIt.I<ApiClient>();
final result = await apiClient.get(
'/join?token=${widget.token}',
fromJson: (data) => Organization.fromJson(data),
);
setState(() {
_org = result;
_isLoading = false;
});
} catch (e) {
setState(() {
_error = 'Invalid or expired invite link';
_isLoading = false;
});
}
}
Future<void> _joinOrg() async {
if (_org == null) return;
setState(() => _isJoining = true);
try {
final orgApi = GetIt.I<OrgApi>();
await orgApi.createJoinRequest(_org!.id, inviteToken: widget.token);
// Navigate to home or show success
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Join request sent successfully')),
);
Navigator.of(context).pushReplacementNamed('/'); // Assuming home is /
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text('Failed to join: $e')));
}
} finally {
setState(() => _isJoining = false);
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: AppTheme.primaryBackground,
appBar: AppBar(
title: const Text('Join Organization'),
backgroundColor: AppTheme.secondaryBackground,
),
body: Center(
child: _isLoading
? const CircularProgressIndicator()
: _error != null
? Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(_error!, style: TextStyle(color: AppTheme.errorColor)),
const SizedBox(height: 16),
ModernGlassButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Go Back'),
),
],
)
: _org != null
? Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
'Join ${_org!.name}?',
style: TextStyle(
color: AppTheme.primaryText,
fontSize: 24,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 16),
Text(
'You have been invited to join this organization.',
style: TextStyle(color: AppTheme.secondaryText),
),
const SizedBox(height: 32),
_isJoining
? const CircularProgressIndicator()
: ModernGlassButton(
onPressed: _joinOrg,
child: const Text('Join Organization'),
),
],
)
: const Text('Organization not found'),
),
);
}
}

View File

@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart';
import 'dart:math';
import 'dart:convert';
import '../blocs/auth/auth_bloc.dart';
import '../blocs/auth/auth_event.dart';
import '../blocs/auth/auth_state.dart';
@@ -12,8 +13,13 @@ import '../theme/modern_glass_button.dart';
class LoginForm extends StatefulWidget {
final ValueChanged<bool>? onSignupModeChanged;
final ValueChanged<bool>? onPasswordModeChanged;
const LoginForm({super.key, this.onSignupModeChanged});
const LoginForm({
super.key,
this.onSignupModeChanged,
this.onPasswordModeChanged,
});
@override
State<LoginForm> createState() => _LoginFormState();
@@ -26,6 +32,12 @@ class _LoginFormState extends State<LoginForm> {
bool _usePasskey = true;
bool _isSignup = false;
// UI error state for inline validation
String? _usernameErrorText;
String? _passwordErrorText;
bool _usernameHasError = false;
bool _passwordHasError = false;
@override
void dispose() {
_usernameController.dispose();
@@ -34,10 +46,10 @@ class _LoginFormState extends State<LoginForm> {
super.dispose();
}
String _generateRandomHex(int bytes) {
String _generateRandomBase64(int bytes) {
final random = Random();
final values = List<int>.generate(bytes, (i) => random.nextInt(256));
return values.map((v) => v.toRadixString(16).padLeft(2, '0')).join();
return base64.encode(values);
}
Future<void> _handleAuthentication(
@@ -47,7 +59,7 @@ class _LoginFormState extends State<LoginForm> {
try {
final credentialId = state.credentialIds.isNotEmpty
? state.credentialIds.first
: _generateRandomHex(64);
: _generateRandomBase64(64);
if (context.mounted) {
context.read<AuthBloc>().add(
@@ -55,10 +67,10 @@ class _LoginFormState extends State<LoginForm> {
username: _usernameController.text,
challenge: state.challenge,
credentialId: credentialId,
authenticatorData: _generateRandomHex(37),
authenticatorData: _generateRandomBase64(37),
clientDataJSON:
'{"type":"webauthn.get","challenge":"${state.challenge}","origin":"https://b0esche.cloud"}',
signature: _generateRandomHex(128),
signature: _generateRandomBase64(128),
),
);
}
@@ -76,8 +88,8 @@ class _LoginFormState extends State<LoginForm> {
RegistrationChallengeReceived state,
) async {
try {
final credentialId = _generateRandomHex(64);
final publicKey = _generateRandomHex(91);
final credentialId = _generateRandomBase64(64);
final publicKey = _generateRandomBase64(91);
if (context.mounted) {
context.read<AuthBloc>().add(
@@ -88,7 +100,7 @@ class _LoginFormState extends State<LoginForm> {
publicKey: publicKey,
clientDataJSON:
'{"type":"webauthn.create","challenge":"${state.challenge}","origin":"https://b0esche.cloud"}',
attestationObject: _generateRandomHex(128),
attestationObject: _generateRandomBase64(128),
),
);
}
@@ -106,6 +118,12 @@ class _LoginFormState extends State<LoginForm> {
_passwordController.clear();
_displayNameController.clear();
_usePasskey = true;
// Clear inline error state
_usernameHasError = false;
_passwordHasError = false;
_usernameErrorText = null;
_passwordErrorText = null;
}
void _setSignupMode(bool isSignup) {
@@ -118,71 +136,70 @@ class _LoginFormState extends State<LoginForm> {
return BlocListener<AuthBloc, AuthState>(
listener: (context, state) {
if (state is AuthFailure) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(state.error)));
// Handle specific credential errors inline
if (state.code == 'INVALID_PASSWORD' ||
state.error.toLowerCase().contains('incorrect')) {
setState(() {
_passwordHasError = true;
_passwordErrorText = 'incorrect password';
_passwordController.clear();
// clear username error if any
_usernameHasError = false;
_usernameErrorText = null;
});
} else if (state.code == 'INVALID_CREDENTIALS' ||
state.error.toLowerCase().contains('invalid credentials')) {
setState(() {
// Border both fields red but show the helper text only under the password field
_usernameHasError = true;
_passwordHasError = true;
_usernameErrorText = null;
_passwordErrorText = 'invalid credentials';
_usernameController.clear();
_passwordController.clear();
});
} else {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(state.error)));
}
} else if (state is AuthenticationChallengeReceived) {
_handleAuthentication(context, state);
} else if (state is RegistrationChallengeReceived) {
_handleRegistration(context, state);
} else if (state is AuthAuthenticated) {
context.read<SessionBloc>().add(SessionStarted(state.token));
context.go('/');
final redirect = GoRouterState.of(
context,
).uri.queryParameters['redirect'];
context.go(redirect ?? '/');
}
},
child: Center(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: AnimatedSwitcher(
duration: const Duration(milliseconds: 400),
transitionBuilder: (child, animation) {
return FadeTransition(opacity: animation, child: child);
},
child: SingleChildScrollView(
key: ValueKey<bool>(_isSignup),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Text(
_isSignup ? 'create account' : 'sign in',
style: const TextStyle(
fontSize: 24,
color: AppTheme.primaryText,
),
),
const SizedBox(height: 24),
Container(
decoration: BoxDecoration(
color: AppTheme.primaryBackground.withValues(alpha: 0.5),
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: AppTheme.accentColor.withValues(alpha: 0.3),
child: AnimatedSize(
duration: const Duration(milliseconds: 300),
curve: Curves.easeInOut,
child: AnimatedSwitcher(
duration: const Duration(milliseconds: 400),
transitionBuilder: (child, animation) {
return FadeTransition(opacity: animation, child: child);
},
child: SingleChildScrollView(
key: ValueKey('${_isSignup}_$_usePasskey'),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Text(
_isSignup ? 'Create Account' : 'Sign In',
style: const TextStyle(
fontSize: 24,
color: AppTheme.primaryText,
),
),
child: TextField(
controller: _usernameController,
textInputAction: TextInputAction.next,
keyboardType: TextInputType.text,
cursorColor: AppTheme.accentColor,
decoration: InputDecoration(
hintText: 'username',
hintStyle: TextStyle(color: AppTheme.secondaryText),
contentPadding: const EdgeInsets.all(12),
border: InputBorder.none,
prefixIcon: Icon(
Icons.person_outline,
color: AppTheme.primaryText,
size: 20,
),
),
style: const TextStyle(color: AppTheme.primaryText),
),
),
const SizedBox(height: 16),
if (!_isSignup && _usePasskey)
const SizedBox.shrink()
else
const SizedBox(height: 24),
Container(
decoration: BoxDecoration(
color: AppTheme.primaryBackground.withValues(
@@ -190,109 +207,161 @@ class _LoginFormState extends State<LoginForm> {
),
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: AppTheme.accentColor.withValues(alpha: 0.3),
color: _usernameHasError
? Colors.red
: AppTheme.accentColor.withValues(alpha: 0.3),
),
),
child: TextField(
controller: _passwordController,
controller: _usernameController,
textInputAction: TextInputAction.next,
keyboardType: TextInputType.visiblePassword,
obscureText: true,
cursorColor: AppTheme.accentColor,
decoration: InputDecoration(
hintText: 'password',
hintStyle: TextStyle(color: AppTheme.secondaryText),
contentPadding: const EdgeInsets.all(12),
border: InputBorder.none,
prefixIcon: Icon(
Icons.lock_outline,
color: AppTheme.primaryText,
size: 20,
),
),
style: const TextStyle(color: AppTheme.primaryText),
),
),
if (!_isSignup && _usePasskey)
const SizedBox.shrink()
else
const SizedBox(height: 16),
if (_isSignup)
Container(
decoration: BoxDecoration(
color: AppTheme.primaryBackground.withValues(
alpha: 0.5,
),
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: AppTheme.accentColor.withValues(alpha: 0.3),
),
),
child: TextField(
controller: _displayNameController,
textInputAction: TextInputAction.done,
keyboardType: TextInputType.text,
cursorColor: AppTheme.accentColor,
onChanged: (_) {
if (_usernameHasError || _usernameErrorText != null) {
setState(() {
_usernameHasError = false;
_usernameErrorText = null;
});
}
},
decoration: InputDecoration(
hintText: 'display name (optional)',
hintText: 'username',
hintStyle: TextStyle(color: AppTheme.secondaryText),
contentPadding: const EdgeInsets.all(12),
border: InputBorder.none,
prefixIcon: Icon(
Icons.badge_outlined,
Icons.person_outline,
color: AppTheme.primaryText,
size: 20,
),
),
style: const TextStyle(color: AppTheme.primaryText),
),
)
else
const SizedBox.shrink(),
if (_isSignup)
const SizedBox(height: 16)
else
const SizedBox.shrink(),
SizedBox(
width: 150,
child: BlocBuilder<AuthBloc, AuthState>(
builder: (context, state) {
return ModernGlassButton(
isLoading: state is AuthLoading,
onPressed: () {
if (_usernameController.text.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Username is required'),
),
);
return;
),
if (_usernameErrorText != null)
Padding(
padding: const EdgeInsets.only(top: 6, left: 8),
child: Text(
_usernameErrorText!,
style: const TextStyle(
color: Colors.red,
fontSize: 12,
),
),
),
const SizedBox(height: 16),
if (!_isSignup && _usePasskey)
const SizedBox.shrink()
else
Container(
decoration: BoxDecoration(
color: AppTheme.primaryBackground.withValues(
alpha: 0.5,
),
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: _passwordHasError
? Colors.red
: AppTheme.accentColor.withValues(alpha: 0.3),
),
),
child: TextField(
controller: _passwordController,
textInputAction: TextInputAction.next,
keyboardType: TextInputType.visiblePassword,
obscureText: true,
cursorColor: AppTheme.accentColor,
onChanged: (_) {
if (_passwordHasError ||
_passwordErrorText != null) {
setState(() {
_passwordHasError = false;
_passwordErrorText = null;
});
}
if (_isSignup) {
if (_passwordController.text.isEmpty) {
},
decoration: InputDecoration(
hintText: 'password',
hintStyle: TextStyle(color: AppTheme.secondaryText),
contentPadding: const EdgeInsets.all(12),
border: InputBorder.none,
prefixIcon: Icon(
Icons.lock_outline,
color: AppTheme.primaryText,
size: 20,
),
),
style: const TextStyle(color: AppTheme.primaryText),
),
),
if (_passwordErrorText != null)
Padding(
padding: const EdgeInsets.only(top: 6, left: 8),
child: Text(
_passwordErrorText!,
style: const TextStyle(
color: Colors.red,
fontSize: 12,
),
),
),
if (!_isSignup && _usePasskey)
const SizedBox.shrink()
else
const SizedBox(height: 16),
if (_isSignup)
Container(
decoration: BoxDecoration(
color: AppTheme.primaryBackground.withValues(
alpha: 0.5,
),
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: AppTheme.accentColor.withValues(alpha: 0.3),
),
),
child: TextField(
controller: _displayNameController,
textInputAction: TextInputAction.done,
keyboardType: TextInputType.text,
cursorColor: AppTheme.accentColor,
decoration: InputDecoration(
hintText: 'display name (optional)',
hintStyle: TextStyle(color: AppTheme.secondaryText),
contentPadding: const EdgeInsets.all(12),
border: InputBorder.none,
prefixIcon: Icon(
Icons.badge_outlined,
color: AppTheme.primaryText,
size: 20,
),
),
style: const TextStyle(color: AppTheme.primaryText),
),
)
else
const SizedBox.shrink(),
if (_isSignup)
const SizedBox(height: 16)
else
const SizedBox.shrink(),
SizedBox(
width: 150,
child: BlocBuilder<AuthBloc, AuthState>(
builder: (context, state) {
return ModernGlassButton(
isLoading: state is AuthLoading,
onPressed: () {
if (_usernameController.text.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Password is required'),
content: Text('Username is required'),
),
);
return;
}
context.read<AuthBloc>().add(
SignupStarted(
username: _usernameController.text,
email: _usernameController.text,
displayName: _displayNameController.text,
password: _passwordController.text,
),
);
} else {
if (_usePasskey) {
context.read<AuthBloc>().add(
LoginRequested(
username: _usernameController.text,
),
);
} else {
if (_isSignup) {
if (_passwordController.text.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
@@ -302,91 +371,125 @@ class _LoginFormState extends State<LoginForm> {
return;
}
context.read<AuthBloc>().add(
PasswordLoginRequested(
SignupStarted(
username: _usernameController.text,
email: _usernameController.text,
displayName: _displayNameController.text,
password: _passwordController.text,
),
);
} else {
if (_usePasskey) {
context.read<AuthBloc>().add(
LoginRequested(
username: _usernameController.text,
),
);
} else {
if (_passwordController.text.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Password is required'),
),
);
return;
}
context.read<AuthBloc>().add(
PasswordLoginRequested(
username: _usernameController.text,
password: _passwordController.text,
),
);
}
}
}
},
child: Text(_isSignup ? 'create' : 'sign in'),
);
},
},
child: Text(_isSignup ? 'create' : 'sign in'),
);
},
),
),
),
const SizedBox(height: 16),
if (_isSignup)
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
'already have an account?',
style: TextStyle(color: AppTheme.secondaryText),
),
const SizedBox(width: 8),
GestureDetector(
onTap: () {
_resetForm();
_setSignupMode(false);
},
child: Text(
'sign in',
style: TextStyle(
color: AppTheme.accentColor,
decoration: TextDecoration.underline,
const SizedBox(height: 16),
if (_isSignup)
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
'already have an account?',
style: TextStyle(color: AppTheme.secondaryText),
),
const SizedBox(width: 8),
GestureDetector(
onTap: () {
_resetForm();
_setSignupMode(false);
},
child: Text(
'sign in',
style: TextStyle(
color: AppTheme.accentColor,
decoration: TextDecoration.underline,
),
),
),
),
],
)
else
Column(
children: [
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
GestureDetector(
onTap: () =>
setState(() => _usePasskey = !_usePasskey),
child: Text(
_usePasskey ? 'use password' : 'use passkey',
style: TextStyle(
color: AppTheme.accentColor,
decoration: TextDecoration.underline,
fontSize: 12,
],
)
else
Column(
children: [
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
GestureDetector(
onTap: () {
setState(() {
_usePasskey = !_usePasskey;
// Clear password errors when switching modes
_passwordHasError = false;
_passwordErrorText = null;
});
widget.onPasswordModeChanged?.call(
!_usePasskey,
);
},
child: Text(
_usePasskey ? 'use password' : 'use passkey',
style: TextStyle(
color: AppTheme.accentColor,
decoration: TextDecoration.underline,
fontSize: 12,
),
),
),
),
],
),
const SizedBox(height: 16),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
'don\'t have an account?',
style: TextStyle(color: AppTheme.secondaryText),
),
const SizedBox(width: 8),
GestureDetector(
onTap: () {
_resetForm();
_setSignupMode(true);
},
child: Text(
'create one',
style: TextStyle(
color: AppTheme.accentColor,
decoration: TextDecoration.underline,
],
),
const SizedBox(height: 16),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
'don\'t have an account?',
style: TextStyle(color: AppTheme.secondaryText),
),
const SizedBox(width: 8),
GestureDetector(
onTap: () {
_resetForm();
_setSignupMode(true);
},
child: Text(
'create one',
style: TextStyle(
color: AppTheme.accentColor,
decoration: TextDecoration.underline,
),
),
),
),
],
),
],
),
],
],
),
],
),
],
),
),
),
),

View File

@@ -0,0 +1,27 @@
import 'package:flutter/material.dart';
import './login_form.dart';
class LoginPage extends StatelessWidget {
const LoginPage({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
body: Container(
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [Color(0xFF1a1a2e), Color(0xFF16213e), Color(0xFF0f3460)],
),
),
child: const Center(
child: SingleChildScrollView(
padding: EdgeInsets.all(16),
child: LoginForm(),
),
),
),
);
}
}

View File

@@ -0,0 +1,433 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:web/web.dart' as web;
import 'dart:ui_web' as ui_web;
import 'package:video_player/video_player.dart';
import 'package:flutter/foundation.dart' show kIsWeb;
import '../theme/app_theme.dart';
import '../services/api_client.dart';
import '../injection.dart';
import '../theme/modern_glass_button.dart';
import '../widgets/file_viewer_dispatch.dart';
class PublicFileViewer extends StatefulWidget {
final String token;
const PublicFileViewer({super.key, required this.token});
@override
State<PublicFileViewer> createState() => _PublicFileViewerState();
}
class _PublicFileViewerState extends State<PublicFileViewer> {
bool _isLoading = true;
String? _error;
Map<String, dynamic>? _fileData;
VideoPlayerController? _videoController;
String? _videoViewType;
String? _docxViewType;
@override
void initState() {
super.initState();
_loadFileData();
}
@override
void dispose() {
_videoController?.dispose();
super.dispose();
}
Future<void> _loadFileData() async {
try {
final apiClient = getIt<ApiClient>();
final response = await apiClient.getRaw('/public/share/${widget.token}');
setState(() {
_fileData = response;
_isLoading = false;
});
// Initialize video player if it's a video file
if (_isVideoFile()) {
await _initializeVideoPlayer();
}
} catch (e) {
setState(() {
_error = 'This link is invalid or has expired.';
_isLoading = false;
});
}
}
Future<void> _initializeVideoPlayer() async {
if (!kIsWeb) {
// For mobile, use VideoPlayerController
final url = _fileData?['viewUrl'] ?? _fileData?['downloadUrl'];
if (url != null) {
_videoController = VideoPlayerController.networkUrl(Uri.parse(url));
await _videoController!.initialize();
setState(() {});
}
} else {
// For web, use HTML video element
final url = _fileData?['viewUrl'] ?? _fileData?['downloadUrl'];
if (url != null) {
_videoViewType = 'public-video-viewer-${widget.token.hashCode}';
_registerVideoViewFactory(url);
setState(() {});
}
}
}
void _registerVideoViewFactory(String videoUrl) {
ui_web.platformViewRegistry.registerViewFactory(_videoViewType!, (
int viewId,
) {
final videoElement = web.HTMLVideoElement()
..src = videoUrl
..controls = true
..autoplay = false
..crossOrigin = 'anonymous'
..style.width = '100%'
..style.height = '100%'
..style.objectFit = 'contain';
videoElement.onError.listen((event) {
if (mounted) {
setState(() {
_error =
'Video format not supported or could not be loaded. Please download the file.';
});
}
});
return videoElement;
});
}
String? _getViewUrl() {
return _fileData?['viewUrl'] ?? _fileData?['downloadUrl'];
}
String? _getDownloadUrl() {
return _fileData?['downloadUrl'];
}
bool _isVideoFile() {
final mimeType = _fileData?['capabilities']?['mimeType'] ?? '';
return mimeType.toString().startsWith('video/');
}
bool _isAudioFile() {
final mimeType = _fileData?['capabilities']?['mimeType'] ?? '';
return mimeType.toString().startsWith('audio/');
}
bool _isImageFile() {
final mimeType = _fileData?['capabilities']?['mimeType'] ?? '';
return mimeType.toString().startsWith('image/');
}
bool _isPdfFile() {
final mimeType = _fileData?['capabilities']?['mimeType'] ?? '';
return mimeType == 'application/pdf' ||
(_fileData?['capabilities']?['isPdf'] ?? false);
}
bool _isDocumentFile() {
final mimeType = _fileData?['capabilities']?['mimeType'] ?? '';
return mimeType ==
'application/vnd.openxmlformats-officedocument.wordprocessingml.document' ||
mimeType == 'application/msword' ||
mimeType.toString().contains('document');
}
void _downloadFile() {
final downloadUrl = _getDownloadUrl();
if (downloadUrl != null) {
// Trigger download directly in browser
final anchor = web.HTMLAnchorElement()
..href = downloadUrl
..download = _fileData!['fileName'] ?? 'download';
anchor.click();
}
}
Widget _buildFilePreview() {
final viewUrl = _getViewUrl();
if (viewUrl == null) return const SizedBox();
if (_isPdfFile()) {
return Expanded(
child: FileViewerDispatch.buildFileViewer(
context,
viewUrl,
_fileData?['capabilities']?['mimeType'],
fileName: _fileData!['fileName'],
viewerId: 'public-pdf-${widget.token.hashCode}',
),
);
} else if (_isVideoFile()) {
if (kIsWeb && _videoViewType != null) {
// Use HTML video element for web
return Expanded(
child: Container(
color: Colors.black,
child: HtmlElementView(viewType: _videoViewType!),
),
);
} else if (!kIsWeb && _videoController != null) {
// Use VideoPlayer for mobile
return Expanded(
child: AspectRatio(
aspectRatio: _videoController!.value.aspectRatio,
child: VideoPlayer(_videoController!),
),
);
} else if (_error != null) {
return Expanded(
child: Center(
child: Text(
_error!,
style: TextStyle(color: AppTheme.primaryText),
textAlign: TextAlign.center,
),
),
);
} else {
return const Expanded(
child: Center(
child: CircularProgressIndicator(
valueColor: AlwaysStoppedAnimation<Color>(AppTheme.accentColor),
),
),
);
}
} else if (_isAudioFile()) {
return FileViewerDispatch.buildFileViewer(
context,
viewUrl,
_fileData?['capabilities']?['mimeType'],
fileName: _fileData!['fileName'],
viewerId: 'public-audio-${widget.token.hashCode}',
);
} else if (_isImageFile()) {
return Expanded(
child: FileViewerDispatch.buildFileViewer(
context,
viewUrl,
_fileData?['capabilities']?['mimeType'],
fileName: _fileData!['fileName'],
viewerId: 'public-image-${widget.token.hashCode}',
),
);
} else if (_isDocumentFile()) {
if (kIsWeb) {
// Use Collabora viewer for web
_docxViewType ??= 'public-docx-viewer-${widget.token.hashCode}';
ui_web.platformViewRegistry.registerViewFactory(_docxViewType!, (
int viewId,
) {
final iframeElement = web.HTMLIFrameElement()
..src = viewUrl
..style.width = '100%'
..style.height = '100%'
..style.border = 'none';
iframeElement.onError.listen((event) {
if (mounted) {
setState(() {
_error =
'Document could not be loaded. Please download the file.';
});
}
});
return iframeElement;
});
return Expanded(
child: Container(
color: Colors.white,
child: HtmlElementView(viewType: _docxViewType!),
),
);
} else {
return Expanded(
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.description,
size: 80,
color: AppTheme.primaryText.withValues(alpha: 0.7),
),
const SizedBox(height: 16),
Text(
'Document Preview',
style: TextStyle(
color: AppTheme.primaryText,
fontSize: 18,
fontWeight: FontWeight.w500,
),
),
const SizedBox(height: 8),
Text(
'This document type requires download to view',
style: TextStyle(color: AppTheme.secondaryText, fontSize: 14),
textAlign: TextAlign.center,
),
],
),
),
);
}
} else {
return Expanded(
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.insert_drive_file,
size: 80,
color: AppTheme.primaryText.withValues(alpha: 0.7),
),
const SizedBox(height: 16),
Text(
'File Preview',
style: TextStyle(
color: AppTheme.primaryText,
fontSize: 18,
fontWeight: FontWeight.w500,
),
),
const SizedBox(height: 8),
Text(
'This file type requires download to view',
style: TextStyle(color: AppTheme.secondaryText, fontSize: 14),
textAlign: TextAlign.center,
),
],
),
),
);
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: AppTheme.primaryBackground,
appBar: AppBar(
backgroundColor: AppTheme.primaryBackground,
elevation: 0,
leading: _fileData != null
? Padding(
padding: const EdgeInsets.only(left: 6, top: 6, bottom: 6),
child: ModernGlassButton(
onPressed: _downloadFile,
padding: EdgeInsets.zero,
showShadows: false,
child: SizedBox(
width: 104,
child: const Center(child: Icon(Icons.download, size: 26)),
),
),
)
: null,
title: Text(
_fileData?['fileName'] ?? 'Shared File',
style: TextStyle(color: AppTheme.primaryText),
),
actions: [
IconButton(
icon: const Icon(Icons.close, color: AppTheme.primaryText),
style: ButtonStyle(
splashFactory: NoSplash.splashFactory,
overlayColor: WidgetStateProperty.resolveWith<Color?>((
Set<WidgetState> states,
) {
if (states.contains(WidgetState.pressed)) {
return Colors.transparent;
}
return null;
}),
),
onPressed: () => context.go('/'),
),
],
),
body: _isLoading
? const Center(
child: CircularProgressIndicator(
valueColor: AlwaysStoppedAnimation<Color>(AppTheme.accentColor),
),
)
: _error != null
? Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Card(
color: AppTheme.primaryBackground,
elevation: 4,
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.link_off, size: 64, color: Colors.red[400]),
const SizedBox(height: 16),
Text(
_error!,
style: TextStyle(
color: AppTheme.primaryText,
fontSize: 18,
),
textAlign: TextAlign.center,
),
],
),
),
),
),
)
: _fileData != null
? Column(
children: [
// File content
Expanded(child: _buildFilePreview()),
// Video controls (if video)
if (_isVideoFile() && _videoController != null)
Container(
padding: const EdgeInsets.all(16),
color: AppTheme.primaryBackground,
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
ModernGlassButton(
onPressed: () {
setState(() {
_videoController!.value.isPlaying
? _videoController!.pause()
: _videoController!.play();
});
},
child: Icon(
_videoController!.value.isPlaying
? Icons.pause
: Icons.play_arrow,
),
),
],
),
),
],
)
: const SizedBox(),
);
}
}

View File

@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart';
import 'dart:math';
import 'dart:convert';
import '../blocs/auth/auth_bloc.dart';
import '../blocs/auth/auth_event.dart';
import '../blocs/auth/auth_state.dart';
@@ -28,10 +29,10 @@ class _SignupFormState extends State<SignupForm> {
super.dispose();
}
String _generateRandomHex(int bytes) {
String _generateRandomBase64(int bytes) {
final random = Random();
final values = List<int>.generate(bytes, (i) => random.nextInt(256));
return values.map((v) => v.toRadixString(16).padLeft(2, '0')).join();
return base64.encode(values);
}
Future<void> _handleRegistration(
@@ -41,8 +42,8 @@ class _SignupFormState extends State<SignupForm> {
try {
// Simulate WebAuthn registration by generating fake credential data
// In a real implementation, this would call native WebAuthn APIs
final credentialId = _generateRandomHex(64);
final publicKey = _generateRandomHex(91); // EC2 public key size
final credentialId = _generateRandomBase64(64);
final publicKey = _generateRandomBase64(91); // EC2 public key size
if (context.mounted) {
context.read<AuthBloc>().add(
@@ -53,7 +54,7 @@ class _SignupFormState extends State<SignupForm> {
publicKey: publicKey,
clientDataJSON:
'{"type":"webauthn.create","challenge":"${state.challenge}","origin":"https://b0esche.cloud"}',
attestationObject: _generateRandomHex(128),
attestationObject: _generateRandomBase64(128),
),
);
}

View File

@@ -0,0 +1,101 @@
import 'package:flutter/material.dart';
import 'dart:ui_web' as ui_web; // <-- new
import 'package:web/web.dart' as web;
import '../theme/app_theme.dart';
class VideoViewer extends StatefulWidget {
final String videoUrl;
final String fileName;
const VideoViewer({
super.key,
required this.videoUrl,
required this.fileName,
});
@override
State<VideoViewer> createState() => _VideoViewerState();
}
class _VideoViewerState extends State<VideoViewer> {
bool _hasError = false;
late String _viewType;
@override
void initState() {
super.initState();
_viewType = 'video-viewer-${widget.videoUrl.hashCode}';
_registerViewFactory();
}
void _registerViewFactory() {
ui_web.platformViewRegistry.registerViewFactory(_viewType, (int viewId) {
final videoElement = web.HTMLVideoElement()
..src = widget.videoUrl
..controls = true
..autoplay = false
..crossOrigin = 'anonymous'
..style.width = '100%'
..style.height = '100%'
..style.objectFit = 'contain';
videoElement.onError.listen((event) {
if (mounted) {
setState(() => _hasError = true);
}
});
return videoElement;
});
}
@override
Widget build(BuildContext context) {
return Dialog(
backgroundColor: Colors.transparent,
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 700, maxHeight: 500),
child: Container(
decoration: AppTheme.glassDecoration,
padding: const EdgeInsets.all(16),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Row(
children: [
Expanded(
child: Text(
widget.fileName,
style: const TextStyle(
color: AppTheme.primaryText,
fontWeight: FontWeight.bold,
fontSize: 18,
),
overflow: TextOverflow.ellipsis,
),
),
IconButton(
icon: const Icon(Icons.close, color: AppTheme.primaryText),
onPressed: () => Navigator.of(context).pop(),
),
],
),
const SizedBox(height: 16),
_hasError
? const Text(
'File type not supported or video could not be loaded',
style: TextStyle(color: AppTheme.primaryText),
)
: Expanded(
child: Container(
color: Colors.black,
child: HtmlElementView(viewType: _viewType),
),
),
],
),
),
),
);
}
}

View File

@@ -0,0 +1,46 @@
import '../models/user.dart';
import '../repositories/auth_repository.dart';
import '../services/api_client.dart';
class HttpAuthRepository implements AuthRepository {
final ApiClient _apiClient;
HttpAuthRepository(this._apiClient);
@override
Future<User> login(String email, String password) async {
final res = await _apiClient.post(
'/auth/password-login',
data: {'username': email, 'password': password},
fromJson: (d) {
final user = d['user'];
return User(
id: user['id'].toString(),
username: user['username'] ?? user['email'],
email: user['email'],
createdAt: DateTime.parse(
user['createdAt'] ?? DateTime.now().toIso8601String(),
),
);
},
);
return res;
}
@override
Future<User?> getCurrentUser() async {
// No refresh endpoint available - rely on SessionBloc for token management
// If token is stored and valid, SessionBloc will restore it
// If API calls return 401, session will expire automatically
return null;
}
@override
Future<void> logout() async {
try {
// Call backend to revoke session
await _apiClient.post('/auth/logout', fromJson: (d) => null);
} catch (_) {
// Ignore logout errors - clear local session regardless
}
}
}

View File

@@ -0,0 +1,93 @@
import '../models/file_item.dart';
import '../models/viewer_session.dart';
import '../models/editor_session.dart';
import '../models/annotation.dart';
import '../repositories/file_repository.dart';
import '../services/file_service.dart';
class HttpFileRepository implements FileRepository {
final FileService _fileService;
HttpFileRepository(this._fileService);
@override
Future<List<FileItem>> getFiles(String orgId, String path) async {
return await _fileService.getFiles(orgId, path);
}
@override
Future<FileItem?> getFile(String orgId, String path) async {
// Not implemented in API yet; fallback to listing
final files = await getFiles(orgId, path);
for (final f in files) {
if (f.path == path) return f;
}
return null;
}
@override
Future<void> uploadFile(String orgId, FileItem file) async {
await _fileService.uploadFile(orgId, file);
}
@override
Future<void> deleteFile(String orgId, String path) async {
await _fileService.deleteFile(orgId, path);
}
@override
Future<void> createFolder(
String orgId,
String parentPath,
String folderName,
) async {
await _fileService.createFolder(orgId, parentPath, folderName);
}
@override
Future<void> moveFile(
String orgId,
String sourcePath,
String targetPath,
) async {
await _fileService.moveFile(orgId, sourcePath, targetPath);
}
@override
Future<void> renameFile(String orgId, String path, String newName) async {
throw UnimplementedError();
}
@override
Future<List<FileItem>> searchFiles(String orgId, String query) async {
// Not yet parameterized on API side; fallback to client-side filter
final files = await getFiles(orgId, '/');
return files
.where((f) => f.name.toLowerCase().contains(query.toLowerCase()))
.toList();
}
@override
Future<ViewerSession> requestViewerSession(
String orgId,
String fileId,
) async {
return await _fileService.requestViewerSession(orgId, fileId);
}
@override
Future<EditorSession> requestEditorSession(
String orgId,
String fileId,
) async {
return await _fileService.requestEditorSession(orgId, fileId);
}
@override
Future<void> saveAnnotations(
String orgId,
String fileId,
List<Annotation> annotations,
) async {
await _fileService.saveAnnotations(orgId, fileId, annotations);
}
}

View File

@@ -1,30 +0,0 @@
import '../models/user.dart';
import '../repositories/auth_repository.dart';
class MockAuthRepository implements AuthRepository {
@override
Future<User> login(String email, String password) async {
await Future.delayed(const Duration(seconds: 1));
if (email.isNotEmpty && password.isNotEmpty) {
return User(
id: 'mock-user-id',
username: 'mockuser',
email: email,
createdAt: DateTime.now(),
);
} else {
throw Exception('Invalid credentials');
}
}
@override
Future<void> logout() async {
// Mock logout
}
@override
Future<User?> getCurrentUser() async {
// Mock: return null or a user
return null;
}
}

View File

@@ -1,268 +0,0 @@
import '../models/file_item.dart';
import '../models/viewer_session.dart';
import '../models/editor_session.dart';
import '../models/annotation.dart';
import '../models/document_capabilities.dart';
import '../models/api_error.dart';
import '../repositories/file_repository.dart';
import 'package:path/path.dart' as p;
class MockFileRepository implements FileRepository {
final Map<String, List<FileItem>> _orgFiles = {};
List<FileItem> _getFilesForOrg(String orgId) {
if (!_orgFiles.containsKey(orgId)) {
// Initialize with different files per org
if (orgId == 'org1') {
_orgFiles[orgId] = [
FileItem(
name: 'Personal Documents',
path: '/Personal Documents',
type: FileType.folder,
lastModified: DateTime.now(),
),
FileItem(
name: 'Photos',
path: '/Photos',
type: FileType.folder,
lastModified: DateTime.now(),
),
FileItem(
name: 'resume.pdf',
path: '/resume.pdf',
type: FileType.file,
size: 1024,
lastModified: DateTime.now(),
),
FileItem(
name: 'notes.txt',
path: '/notes.txt',
type: FileType.file,
size: 256,
lastModified: DateTime.now(),
),
];
} else if (orgId == 'org2') {
_orgFiles[orgId] = [
FileItem(
name: 'Company Reports',
path: '/Company Reports',
type: FileType.folder,
lastModified: DateTime.now(),
),
FileItem(
name: 'annual_report.pdf',
path: '/annual_report.pdf',
type: FileType.file,
size: 2048,
lastModified: DateTime.now(),
),
FileItem(
name: 'presentation.pptx',
path: '/presentation.pptx',
type: FileType.file,
size: 4096,
lastModified: DateTime.now(),
),
];
} else if (orgId == 'org3') {
_orgFiles[orgId] = [
FileItem(
name: 'Project Code',
path: '/Project Code',
type: FileType.folder,
lastModified: DateTime.now(),
),
FileItem(
name: 'side_project.dart',
path: '/side_project.dart',
type: FileType.file,
size: 512,
lastModified: DateTime.now(),
),
];
} else {
// Default for new orgs
_orgFiles[orgId] = [];
}
}
return _orgFiles[orgId]!;
}
@override
Future<List<FileItem>> getFiles(String orgId, String path) async {
await Future.delayed(const Duration(seconds: 1));
final files = _getFilesForOrg(orgId);
if (path == '/') {
return files.where((f) => !f.path.substring(1).contains('/')).toList();
} else {
return files
.where((f) => f.path.startsWith('$path/') && f.path != path)
.toList();
}
}
@override
Future<FileItem?> getFile(String orgId, String path) async {
final files = _getFilesForOrg(orgId);
final index = files.indexWhere((f) => f.path == path);
return index != -1 ? files[index] : null;
}
@override
Future<EditorSession> requestEditorSession(
String orgId,
String fileId,
) async {
await Future.delayed(const Duration(seconds: 1));
// Mock: determine editability
final isEditable =
fileId.endsWith('.docx') ||
fileId.endsWith('.xlsx') ||
fileId.endsWith('.pptx');
final editUrl = Uri.parse(
'https://office.b0esche.cloud/editor/$orgId/$fileId?editable=$isEditable',
);
final expiresAt = DateTime.now().add(const Duration(minutes: 30));
return EditorSession(
editUrl: editUrl,
readOnly: !isEditable,
expiresAt: expiresAt,
);
}
@override
Future<void> deleteFile(String orgId, String path) async {
final files = _getFilesForOrg(orgId);
files.removeWhere((f) => f.path == path);
}
@override
Future<void> createFolder(
String orgId,
String parentPath,
String folderName,
) async {
await Future.delayed(const Duration(seconds: 1));
final normalizedName = folderName.startsWith('/')
? folderName.substring(1)
: folderName;
final newPath = parentPath == '/'
? '/$normalizedName'
: '$parentPath/$normalizedName';
final files = _getFilesForOrg(orgId);
files.add(
FileItem(
name: normalizedName,
path: newPath,
type: FileType.folder,
lastModified: DateTime.now(),
),
);
}
@override
Future<void> moveFile(
String orgId,
String sourcePath,
String targetPath,
) async {
await Future.delayed(const Duration(seconds: 1));
final files = _getFilesForOrg(orgId);
final fileIndex = files.indexWhere((f) => f.path == sourcePath);
if (fileIndex != -1) {
final file = files[fileIndex];
final newName = file.path.split('/').last;
final newPath = targetPath == '/' ? '/$newName' : '$targetPath/$newName';
files[fileIndex] = FileItem(
name: file.name,
path: newPath,
type: file.type,
size: file.size,
lastModified: DateTime.now(),
);
}
}
@override
Future<void> renameFile(String orgId, String path, String newName) async {
await Future.delayed(const Duration(seconds: 1));
final files = _getFilesForOrg(orgId);
final fileIndex = files.indexWhere((f) => f.path == path);
if (fileIndex != -1) {
final file = files[fileIndex];
final parentPath = p.dirname(path);
final newPath = parentPath == '.' ? '/$newName' : '$parentPath/$newName';
files[fileIndex] = FileItem(
name: newName,
path: newPath,
type: file.type,
size: file.size,
lastModified: DateTime.now(),
);
}
}
@override
Future<List<FileItem>> searchFiles(String orgId, String query) async {
await Future.delayed(const Duration(seconds: 1));
final files = _getFilesForOrg(orgId);
return files
.where((f) => f.name.toLowerCase().contains(query.toLowerCase()))
.toList();
}
@override
Future<void> uploadFile(String orgId, FileItem file) async {
await Future.delayed(const Duration(seconds: 1));
final files = _getFilesForOrg(orgId);
files.add(file);
}
@override
Future<ViewerSession> requestViewerSession(
String orgId,
String fileId,
) async {
await Future.delayed(const Duration(seconds: 1));
if (fileId.contains('forbidden')) {
throw ApiError(
code: 'permission_denied',
message: 'Access denied',
status: 403,
);
}
if (fileId.contains('notfound')) {
throw ApiError(code: 'not_found', message: 'File not found', status: 404);
}
// Mock: assume fileId is path, determine if PDF
final isPdf = fileId.endsWith('.pdf');
final caps = DocumentCapabilities(
canEdit: !isPdf && (fileId.endsWith('.docx') || fileId.endsWith('.xlsx')),
canAnnotate: isPdf,
isPdf: isPdf,
);
// Mock URL
final viewUrl = Uri.parse(
'https://office.b0esche.cloud/viewer/$orgId/$fileId',
);
final token = 'mock-viewer-token';
final expiresAt = DateTime.now().add(const Duration(minutes: 30));
return ViewerSession(
viewUrl: viewUrl,
capabilities: caps,
token: token,
expiresAt: expiresAt,
);
}
@override
Future<void> saveAnnotations(
String orgId,
String fileId,
List<Annotation> annotations,
) async {
await Future.delayed(const Duration(seconds: 2));
// Mock: just delay, assume success
}
}

View File

@@ -1,4 +1,5 @@
import 'package:dio/dio.dart';
import 'package:http_parser/http_parser.dart';
import '../models/api_error.dart';
import '../blocs/session/session_bloc.dart';
import '../blocs/session/session_event.dart';
@@ -13,7 +14,9 @@ class ApiClient {
BaseOptions(
baseUrl: baseUrl,
connectTimeout: const Duration(seconds: 10),
receiveTimeout: const Duration(seconds: 10),
receiveTimeout: const Duration(
seconds: 120,
), // Increased for file uploads and org operations
),
);
@@ -29,23 +32,13 @@ class ApiClient {
},
onError: (error, handler) async {
if (error.response?.statusCode == 401) {
// Try refresh
final refreshSuccess = await _tryRefreshToken();
if (refreshSuccess) {
// Retry the request
final token = _getCurrentToken();
if (token != null) {
error.requestOptions.headers['Authorization'] = 'Bearer $token';
try {
final response = await _dio.fetch(error.requestOptions);
return handler.resolve(response);
} catch (e) {
// If retry fails, proceed to error
}
}
final path = error.requestOptions.path;
// Do not expire session for auth endpoints; show inline error instead
final isAuthEndpoint = path.startsWith('/auth/');
if (!isAuthEndpoint) {
// Session expired, trigger logout
_sessionBloc.add(SessionExpired());
}
// If refresh failed, logout
_sessionBloc.add(SessionExpired());
}
return handler.next(error);
},
@@ -53,6 +46,10 @@ class ApiClient {
);
}
String get baseUrl => _dio.options.baseUrl;
String? get currentToken => _getCurrentToken();
String? _getCurrentToken() {
// Get from SessionBloc state
final state = _sessionBloc.state;
@@ -62,20 +59,6 @@ class ApiClient {
return null;
}
Future<bool> _tryRefreshToken() async {
try {
final response = await _dio.post('/auth/refresh');
if (response.statusCode == 200) {
final newToken = response.data['token'];
_sessionBloc.add(SessionRefreshed(newToken));
return true;
}
} catch (e) {
// Refresh failed
}
return false;
}
Future<T> get<T>(
String path, {
Map<String, dynamic>? queryParameters,
@@ -89,6 +72,18 @@ class ApiClient {
}
}
Future<Map<String, dynamic>> getRaw(
String path, {
Map<String, dynamic>? queryParameters,
}) async {
try {
final response = await _dio.get(path, queryParameters: queryParameters);
return response.data;
} on DioException catch (e) {
throw _handleError(e);
}
}
Future<T> post<T>(
String path, {
dynamic data,
@@ -96,12 +91,77 @@ class ApiClient {
}) async {
try {
final response = await _dio.post(path, data: data);
return fromJson(response.data);
} on DioException catch (e) {
throw _handleError(e);
}
}
Future<List<int>> getBytes(String path) async {
try {
final response = await _dio.get(
path,
options: Options(responseType: ResponseType.bytes),
);
return response.data;
} on DioException catch (e) {
throw _handleError(e);
}
}
Future<Map<String, dynamic>> postRaw(String path, {dynamic data}) async {
try {
final response = await _dio.post(path, data: data);
return response.data;
} on DioException catch (e) {
throw _handleError(e);
}
}
Future<T> patch<T>(
String path, {
dynamic data,
required T Function(dynamic data) fromJson,
}) async {
try {
final response = await _dio.patch(path, data: data);
return fromJson(response.data);
} on DioException catch (e) {
throw _handleError(e);
}
}
Future<T> put<T>(
String path, {
dynamic data,
required T Function(dynamic data) fromJson,
}) async {
try {
final response = await _dio.put(path, data: data);
return fromJson(response.data);
} on DioException catch (e) {
throw _handleError(e);
}
}
Future<Map<String, dynamic>> putRaw(String path, {dynamic data}) async {
try {
final response = await _dio.put(path, data: data);
return response.data;
} on DioException catch (e) {
throw _handleError(e);
}
}
Future<void> delete(String path) async {
try {
await _dio.delete(path);
} on DioException catch (e) {
throw _handleError(e);
}
}
Future<List<T>> getList<T>(
String path, {
Map<String, dynamic>? queryParameters,
@@ -109,7 +169,69 @@ class ApiClient {
}) async {
try {
final response = await _dio.get(path, queryParameters: queryParameters);
return (response.data as List).map(fromJson).toList();
final data = response.data;
if (data == null) return [];
return (data as List).map(fromJson).toList();
} on DioException catch (e) {
throw _handleError(e);
}
}
// User profile methods
Future<Map<String, dynamic>> getUserProfile() async {
return getRaw('/user/profile');
}
Future<Map<String, dynamic>> updateUserProfile({
required String displayName,
String? email,
}) async {
final data = <String, dynamic>{'displayName': displayName};
if (email != null) data['email'] = email;
return putRaw('/user/profile', data: data);
}
Future<Map<String, dynamic>> changePassword({
required String currentPassword,
required String newPassword,
}) async {
return postRaw(
'/user/change-password',
data: {'currentPassword': currentPassword, 'newPassword': newPassword},
);
}
// Avatar upload
Future<Map<String, dynamic>> uploadAvatar(
List<int> imageBytes,
String filename, {
ProgressCallback? onSendProgress,
}) async {
final formData = FormData.fromMap({
'avatar': MultipartFile.fromBytes(
imageBytes,
filename: filename,
contentType: MediaType('image', filename.split('.').last),
),
});
try {
final response = await _dio.post(
'/user/avatar',
data: formData,
onSendProgress: onSendProgress,
);
return response.data;
} on DioException catch (e) {
throw _handleError(e);
}
}
Future<Map<String, dynamic>> deleteAccount() async {
try {
final response = await _dio.delete('/user/account');
return response.data;
} on DioException catch (e) {
throw _handleError(e);
}
@@ -131,8 +253,17 @@ class ApiClient {
);
}
String code = data?['code'] ?? 'UNKNOWN';
String message = data?['message'] ?? 'Unknown error';
// Only try to extract code/message if data is a Map
String code = 'UNKNOWN';
String message = 'Unknown error';
if (data is Map<String, dynamic>) {
code = data['code'] ?? 'UNKNOWN';
message = data['message'] ?? 'Unknown error';
} else if (data != null) {
message = data.toString();
}
return ApiError(code: code, message: message, status: status);
}
}

View File

@@ -1,21 +1,44 @@
import 'dart:typed_data';
import '../models/file_item.dart';
import '../models/viewer_session.dart';
import '../models/editor_session.dart';
import '../models/annotation.dart';
import 'api_client.dart';
import 'package:dio/dio.dart';
import 'package:archive/archive.dart';
class FileService {
final ApiClient _apiClient;
FileService(this._apiClient);
String get baseUrl => _apiClient.baseUrl;
Future<List<FileItem>> getFiles(String orgId, String path) async {
if (path.isEmpty) {
throw Exception('Path cannot be empty');
}
final pathParam = {'path': path};
if (orgId.isEmpty) {
return await _apiClient.getList(
'/user/files',
queryParameters: pathParam,
fromJson: (data) => FileItem(
id: data['id'],
name: data['name'],
path: data['path'],
type: data['type'] == 'file' ? FileType.file : FileType.folder,
size: data['size'],
lastModified: DateTime.parse(data['lastModified']),
),
);
}
return await _apiClient.getList(
'/orgs/$orgId/files',
queryParameters: pathParam,
fromJson: (data) => FileItem(
id: data['id'],
name: data['name'],
path: data['path'],
type: data['type'] == 'file' ? FileType.file : FileType.folder,
@@ -30,11 +53,85 @@ class FileService {
}
Future<void> uploadFile(String orgId, FileItem file) async {
throw UnimplementedError();
// If bytes or localPath available, send multipart upload with field 'file'
final Map<String, dynamic> fields = {'path': file.path};
FormData formData;
if (file.bytes != null) {
formData = FormData.fromMap({
...fields,
'file': MultipartFile.fromBytes(file.bytes!, filename: file.name),
});
} else if (file.localPath != null) {
formData = FormData.fromMap({
...fields,
'file': MultipartFile.fromFile(file.localPath!, filename: file.name),
});
} else {
// Fallback to metadata-only create (folders or client that can't send file content)
final data = {
'name': file.name,
'path': file.path,
'type': file.type == FileType.file ? 'file' : 'folder',
'size': file.size,
};
if (orgId.isEmpty) {
await _apiClient.post('/user/files', data: data, fromJson: (d) => null);
return;
}
await _apiClient.post(
'/orgs/$orgId/files',
data: data,
fromJson: (d) => null,
);
return;
}
final endpoint = orgId.isEmpty ? '/user/files' : '/orgs/$orgId/files';
await _apiClient.post(endpoint, data: formData, fromJson: (d) => null);
}
Future<void> deleteFile(String orgId, String path) async {
throw UnimplementedError();
final data = {'path': path};
if (orgId.isEmpty) {
await _apiClient.post(
'/user/files/delete',
data: data,
fromJson: (d) => null,
);
return;
}
await _apiClient.post(
'/orgs/$orgId/files/delete',
data: data,
fromJson: (d) => null,
);
}
Future<String> getDownloadUrl(String orgId, String path) async {
// Return the download URL for the file
if (orgId.isEmpty) {
return '/user/files/download?path=${Uri.encodeComponent(path)}';
}
return '/orgs/$orgId/files/download?path=${Uri.encodeComponent(path)}';
}
Future<String> getFileUrl({
required String orgId,
required String filePath,
String? fileName,
}) async {
// Get authentication token
final token = _apiClient.currentToken;
if (token == null) {
throw Exception('No authentication token available');
}
// Return the full download URL with token
final path = orgId.isEmpty
? '/user/files/download?path=${Uri.encodeComponent(filePath)}&token=${Uri.encodeComponent(token)}'
: '/orgs/$orgId/files/download?path=${Uri.encodeComponent(filePath)}&token=${Uri.encodeComponent(token)}';
return '$baseUrl$path';
}
Future<void> createFolder(
@@ -42,7 +139,41 @@ class FileService {
String parentPath,
String folderName,
) async {
throw UnimplementedError();
// Normalize folder name to avoid accidental leading slashes creating double-slash paths
final normalizedName = folderName
.replaceAll(RegExp(r'^/+'), '')
.replaceAll(RegExp(r'/+$'), '');
if (normalizedName.isEmpty) {
throw Exception('Folder name cannot be empty');
}
// Construct proper path: /parent/folder or /folder for root
String path;
if (parentPath == '/') {
path = '/$normalizedName';
} else {
// Ensure parentPath doesn't end with / before appending
final cleanParent = parentPath.endsWith('/')
? parentPath.substring(0, parentPath.length - 1)
: parentPath;
path = '$cleanParent/$normalizedName';
}
final data = {
'name': normalizedName,
'path': path,
'type': 'folder',
'size': 0,
};
if (orgId.isEmpty) {
await _apiClient.post('/user/files', data: data, fromJson: (d) => null);
return;
}
await _apiClient.post(
'/orgs/$orgId/files',
data: data,
fromJson: (d) => null,
);
}
Future<void> moveFile(
@@ -50,7 +181,14 @@ class FileService {
String sourcePath,
String targetPath,
) async {
throw UnimplementedError();
final endpoint = orgId.isEmpty
? '/user/files/move'
: '/orgs/$orgId/files/move';
await _apiClient.post(
endpoint,
data: {'sourcePath': sourcePath, 'targetPath': targetPath},
fromJson: (d) => null,
);
}
Future<void> renameFile(String orgId, String path, String newName) async {
@@ -65,11 +203,14 @@ class FileService {
String orgId,
String fileId,
) async {
if (orgId.isEmpty || fileId.isEmpty) {
throw Exception('OrgId and fileId cannot be empty');
if (fileId.isEmpty) {
throw Exception('fileId cannot be empty');
}
final path = orgId.isEmpty
? '/user/files/$fileId/view'
: '/orgs/$orgId/files/$fileId/view';
return await _apiClient.get(
'/orgs/$orgId/files/$fileId/view',
path,
fromJson: (data) => ViewerSession.fromJson(data),
);
}
@@ -78,11 +219,14 @@ class FileService {
String orgId,
String fileId,
) async {
if (orgId.isEmpty || fileId.isEmpty) {
throw Exception('OrgId and fileId cannot be empty');
if (fileId.isEmpty) {
throw Exception('fileId cannot be empty');
}
final path = orgId.isEmpty
? '/user/files/$fileId/edit'
: '/orgs/$orgId/files/$fileId/edit';
return await _apiClient.get(
'/orgs/$orgId/files/$fileId/edit',
path,
fromJson: (data) => EditorSession.fromJson(data),
);
}
@@ -92,11 +236,14 @@ class FileService {
String fileId,
List<Annotation> annotations,
) async {
if (orgId.isEmpty || fileId.isEmpty) {
throw Exception('OrgId and fileId cannot be empty');
if (fileId.isEmpty) {
throw Exception('fileId cannot be empty');
}
final path = orgId.isEmpty
? '/user/files/$fileId/annotations'
: '/orgs/$orgId/files/$fileId/annotations';
await _apiClient.post(
'/orgs/$orgId/files/$fileId/annotations',
path,
data: {
'annotations': annotations.map((a) => a.toJson()).toList(),
'baseVersionId': '1', // mock
@@ -104,4 +251,149 @@ class FileService {
fromJson: (data) => null,
);
}
/// Creates an empty .docx document and returns the created file's ID
Future<String> createDocument(
String orgId,
String parentPath,
String fileName,
) async {
// Ensure filename has .docx extension
final docxName = fileName.endsWith('.docx') ? fileName : '$fileName.docx';
// Generate minimal valid DOCX file (Office Open XML format)
final bytes = _generateEmptyDocx();
// Send parent directory as 'path' parameter
final formData = FormData.fromMap({
'path': parentPath,
'file': MultipartFile.fromBytes(bytes, filename: docxName),
});
final endpoint = orgId.isEmpty ? '/user/files' : '/orgs/$orgId/files';
final response = await _apiClient.post(
endpoint,
data: formData,
fromJson: (d) => d as Map<String, dynamic>,
);
// Return the file ID from the response
return response['id'] as String;
}
/// Generates a minimal valid DOCX file (empty document)
Uint8List _generateEmptyDocx() {
final archive = Archive();
// [Content_Types].xml - defines content types
const contentTypes =
'''<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">
<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>
<Default Extension="xml" ContentType="application/xml"/>
<Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/>
<Override PartName="/word/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.styles+xml"/>
</Types>''';
archive.addFile(
ArchiveFile(
'[Content_Types].xml',
contentTypes.length,
Uint8List.fromList(contentTypes.codeUnits),
),
);
// _rels/.rels - root relationships
const rootRels = '''<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/>
</Relationships>''';
archive.addFile(
ArchiveFile(
'_rels/.rels',
rootRels.length,
Uint8List.fromList(rootRels.codeUnits),
),
);
// word/document.xml - the actual document content (empty)
const documentXml =
'''<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:w14="http://schemas.microsoft.com/office/word/2010/wordml" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" mc:Ignorable="w14">
<w:body>
<w:p>
<w:pPr>
<w:pStyle w:val="Normal"/>
</w:pPr>
</w:p>
<w:sectPr>
<w:pgSz w:w="12240" w:h="15840"/>
<w:pgMar w:top="1440" w:right="1440" w:bottom="1440" w:left="1440" w:header="720" w:footer="720" w:gutter="0"/>
</w:sectPr>
</w:body>
</w:document>''';
archive.addFile(
ArchiveFile(
'word/document.xml',
documentXml.length,
Uint8List.fromList(documentXml.codeUnits),
),
);
// word/styles.xml - document styles
const stylesXml = '''<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<w:styles xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" xmlns:w14="http://schemas.microsoft.com/office/word/2010/wordml" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" mc:Ignorable="w14">
<w:docDefaults>
<w:rPrDefault>
<w:rPr>
<w:rFonts w:ascii="Calibri" w:hAnsi="Calibri" w:cs="Calibri" w:eastAsia="Calibri"/>
<w:sz w:val="22"/>
<w:szCs w:val="22"/>
<w:lang w:val="en-US" w:eastAsia="en-US" w:bidi="ar-SA"/>
</w:rPr>
</w:rPrDefault>
<w:pPrDefault>
<w:pPr>
<w:spacing w:after="160" w:before="0" w:line="259" w:lineRule="auto"/>
</w:pPr>
</w:pPrDefault>
</w:docDefaults>
<w:style w:type="paragraph" w:default="1" w:styleId="Normal">
<w:name w:val="Normal"/>
<w:qFormat/>
<w:pPr>
<w:spacing w:after="160" w:before="0" w:line="259" w:lineRule="auto"/>
</w:pPr>
<w:rPr>
<w:rFonts w:ascii="Calibri" w:hAnsi="Calibri" w:cs="Calibri" w:eastAsia="Calibri"/>
<w:sz w:val="22"/>
<w:szCs w:val="22"/>
<w:lang w:val="en-US" w:eastAsia="en-US" w:bidi="ar-SA"/>
</w:rPr>
</w:style>
</w:styles>''';
archive.addFile(
ArchiveFile(
'word/styles.xml',
stylesXml.length,
Uint8List.fromList(stylesXml.codeUnits),
),
);
// word/_rels/document.xml.rels - document relationships (empty but required)
const docRels = '''<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
</Relationships>''';
archive.addFile(
ArchiveFile(
'word/_rels/document.xml.rels',
docRels.length,
Uint8List.fromList(docRels.codeUnits),
),
);
// Encode as ZIP
final zipEncoder = ZipEncoder();
final zipData = zipEncoder.encode(archive);
return Uint8List.fromList(zipData);
}
}

View File

@@ -1,5 +1,8 @@
import '../blocs/organization/organization_state.dart';
import '../models/organization.dart';
import '../models/user.dart';
import 'api_client.dart';
import 'dart:developer' as developer;
class OrgApi {
final ApiClient _apiClient;
@@ -14,10 +17,127 @@ class OrgApi {
}
Future<Organization> createOrganization(String name) async {
return await _apiClient.post(
'/orgs',
data: {'name': name},
fromJson: (data) => Organization.fromJson(data),
developer.log('POST /orgs with payload: {"name": "$name"}', name: 'OrgApi');
try {
final result = await _apiClient.post(
'/orgs',
data: {'name': name},
fromJson: (data) => Organization.fromJson(data),
);
return result;
} catch (e) {
rethrow;
}
}
Future<List<Member>> getMembers(String orgId) async {
return await _apiClient.getList(
'/orgs/$orgId/members',
fromJson: (data) => Member.fromJson(data),
);
}
Future<void> updateMemberRole(
String orgId,
String userId,
String role,
) async {
await _apiClient.patch(
'/orgs/$orgId/members/$userId',
data: {'role': role},
fromJson: (data) => data,
);
}
Future<void> removeMember(String orgId, String userId) async {
await _apiClient.delete('/orgs/$orgId/members/$userId');
}
Future<List<User>> searchUsers(String orgId, String query) async {
return await _apiClient.getList(
'/orgs/$orgId/users/search?q=$query',
fromJson: (data) => User.fromJson(data),
);
}
Future<Invitation> createInvitation(
String orgId,
String username,
String role,
) async {
final result = await _apiClient.post(
'/orgs/$orgId/invitations',
data: {'username': username, 'role': role},
fromJson: (data) => Invitation.fromJson(data),
);
return result;
}
Future<List<Invitation>> getInvitations(String orgId) async {
return await _apiClient.getList(
'/orgs/$orgId/invitations',
fromJson: (data) => Invitation.fromJson(data),
);
}
Future<void> cancelInvitation(String orgId, String invitationId) async {
await _apiClient.delete('/orgs/$orgId/invitations/$invitationId');
}
Future<JoinRequest> createJoinRequest(
String orgId, {
String? inviteToken,
}) async {
final data = {'orgId': orgId};
if (inviteToken != null) {
data['inviteToken'] = inviteToken;
}
final result = await _apiClient.post(
'/join-requests',
data: data,
fromJson: (data) => JoinRequest.fromJson(data),
);
return result;
}
Future<List<JoinRequest>> getJoinRequests(String orgId) async {
return await _apiClient.getList(
'/orgs/$orgId/join-requests',
fromJson: (data) => JoinRequest.fromJson(data),
);
}
Future<void> acceptJoinRequest(
String orgId,
String requestId,
String role,
) async {
await _apiClient.post(
'/orgs/$orgId/join-requests/$requestId/accept',
data: {'role': role},
fromJson: (data) => null,
);
}
Future<void> rejectJoinRequest(String orgId, String requestId) async {
await _apiClient.post(
'/orgs/$orgId/join-requests/$requestId/reject',
fromJson: (data) => null,
);
}
Future<String?> getInviteLink(String orgId) async {
final result = await _apiClient.getRaw('/orgs/$orgId/invite-link');
return result['inviteLink'] as String?;
}
Future<String> regenerateInviteLink(String orgId) async {
final result = await _apiClient.post(
'/orgs/$orgId/invite-link/regenerate',
fromJson: (data) => data,
);
return result['inviteLink'] as String;
}
}

View File

@@ -2,9 +2,11 @@ import 'package:flutter/material.dart';
class AppTheme {
static const Color primaryBackground = Colors.black;
static const Color secondaryBackground = Colors.grey;
static const Color accentColor = Color.fromARGB(255, 100, 200, 255);
static const Color secondaryText = Colors.white70;
static const Color primaryText = Colors.white;
static const Color errorColor = Colors.redAccent;
static const Color glassBackground = Colors.white;
static const double glassOpacity = 0.1;
static const double glassBlur = 10;

View File

@@ -6,11 +6,15 @@ class ModernGlassButton extends StatefulWidget {
final VoidCallback onPressed;
final Widget child;
final bool isLoading;
final EdgeInsets padding;
final bool showShadows;
const ModernGlassButton({
required this.onPressed,
required this.child,
this.isLoading = false,
this.padding = const EdgeInsets.symmetric(horizontal: 24, vertical: 8),
this.showShadows = true,
super.key,
});
@@ -61,34 +65,32 @@ class _ModernGlassButtonState extends State<ModernGlassButton>
child: Stack(
children: [
// Shadow layer
Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
boxShadow: [
BoxShadow(
color: AppTheme.accentColor.withValues(alpha: 0.3),
blurRadius: _isHovered ? 24 : 12,
spreadRadius: _isHovered ? 2 : 0,
offset: const Offset(0, 8),
),
BoxShadow(
color: AppTheme.accentColor.withValues(alpha: 0.1),
blurRadius: 20,
spreadRadius: 5,
),
],
if (widget.showShadows)
Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
boxShadow: [
BoxShadow(
color: AppTheme.accentColor.withValues(alpha: 0.3),
blurRadius: _isHovered ? 24 : 12,
spreadRadius: _isHovered ? 2 : 0,
offset: const Offset(0, 8),
),
BoxShadow(
color: AppTheme.accentColor.withValues(alpha: 0.1),
blurRadius: 20,
spreadRadius: 5,
),
],
),
),
),
// Glass button with gradient
ClipRRect(
borderRadius: BorderRadius.circular(12),
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 15, sigmaY: 15),
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 24,
vertical: 8,
),
padding: widget.padding,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
gradient: LinearGradient(

View File

@@ -1,56 +0,0 @@
import 'package:flutter/foundation.dart';
import '../models/file_item.dart';
import '../services/file_service.dart';
class FileExplorerViewModel extends ChangeNotifier {
final FileService _fileService;
FileExplorerViewModel(this._fileService);
List<FileItem> _files = [];
bool _isLoading = false;
String? _error;
String _currentPath = '/';
List<FileItem> get files => _files;
bool get isLoading => _isLoading;
String? get error => _error;
String get currentPath => _currentPath;
Future<void> loadFiles([String? path]) async {
_isLoading = true;
_error = null;
if (path != null) _currentPath = path;
notifyListeners();
try {
_files = await _fileService.getFiles("", _currentPath);
} catch (e) {
_error = e.toString();
_files = [];
} finally {
_isLoading = false;
notifyListeners();
}
}
Future<void> uploadFile(FileItem file) async {
try {
await _fileService.uploadFile("", file);
await loadFiles(); // Reload files
} catch (e) {
_error = e.toString();
notifyListeners();
}
}
Future<void> deleteFile(String path) async {
try {
await _fileService.deleteFile("", path);
await loadFiles(); // Reload files
} catch (e) {
_error = e.toString();
notifyListeners();
}
}
}

View File

@@ -1,65 +0,0 @@
import 'package:flutter/foundation.dart';
import '../models/user.dart';
import '../services/auth_service.dart';
class LoginViewModel extends ChangeNotifier {
final AuthService _authService;
LoginViewModel(this._authService);
bool _isLoading = false;
String? _error;
User? _currentUser;
bool get isLoading => _isLoading;
String? get error => _error;
User? get currentUser => _currentUser;
bool get isLoggedIn => _currentUser != null;
Future<void> login(String email, String password) async {
_isLoading = true;
_error = null;
notifyListeners();
try {
_currentUser = await _authService.login(email, password);
_error = null;
} catch (e) {
_error = e.toString();
_currentUser = null;
} finally {
_isLoading = false;
notifyListeners();
}
}
Future<void> logout() async {
_isLoading = true;
notifyListeners();
try {
await _authService.logout();
_currentUser = null;
_error = null;
} catch (e) {
_error = e.toString();
} finally {
_isLoading = false;
notifyListeners();
}
}
Future<void> checkCurrentUser() async {
_isLoading = true;
notifyListeners();
try {
_currentUser = await _authService.getCurrentUser();
} catch (e) {
_error = e.toString();
} finally {
_isLoading = false;
notifyListeners();
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,312 @@
import 'package:flutter/material.dart';
import 'package:flutter/foundation.dart' show kIsWeb;
import 'dart:async';
import '../theme/app_theme.dart';
import '../theme/modern_glass_button.dart';
import 'package:just_audio/just_audio.dart' as just_audio;
import 'web_audio_player.dart' as web_audio;
class AudioPlayerBar extends StatefulWidget {
final String fileName;
final String fileUrl;
final String? mimeType;
final VoidCallback? onClose;
const AudioPlayerBar({
super.key,
required this.fileName,
required this.fileUrl,
this.mimeType,
this.onClose,
});
@override
State<AudioPlayerBar> createState() => _AudioPlayerBarState();
}
class _AudioPlayerBarState extends State<AudioPlayerBar>
with SingleTickerProviderStateMixin {
dynamic _audioPlayer;
late AnimationController _iconController;
Duration _duration = Duration.zero;
Duration _position = Duration.zero;
bool _isPlaying = false;
bool _isLoading = true;
StreamSubscription? _positionSubscription;
StreamSubscription? _durationSubscription;
StreamSubscription? _playingSubscription;
StreamSubscription? _errorSubscription;
@override
void initState() {
super.initState();
_audioPlayer = kIsWeb ? web_audio.AudioPlayer() : just_audio.AudioPlayer();
_iconController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 250),
);
_initAudio();
}
@override
void dispose() {
_iconController.dispose();
_positionSubscription?.cancel();
_durationSubscription?.cancel();
_playingSubscription?.cancel();
_errorSubscription?.cancel();
_audioPlayer.dispose();
super.dispose();
}
String? _errorMsg;
Future<void> _initAudio() async {
try {
if (kIsWeb) {
await _audioPlayer.setUrl(widget.fileUrl, mimeType: widget.mimeType);
_durationSubscription = _audioPlayer.durationStream.listen((d) {
if (d != null) {
setState(() {
_duration = d;
_isLoading = false;
});
}
});
_positionSubscription = _audioPlayer.positionStream.listen((pos) {
setState(() {
_position = pos;
});
});
_playingSubscription = _audioPlayer.playingStream.listen((playing) {
setState(() {
_isPlaying = playing;
});
if (playing) {
if (mounted) _iconController.forward();
} else {
if (mounted) _iconController.reverse();
}
});
_errorSubscription = _audioPlayer.errorStream.listen((error) {
setState(() {
_errorMsg = error.toString();
_isLoading = false;
});
});
await _audioPlayer.play();
} else {
await _audioPlayer.setAudioSource(
just_audio.AudioSource.uri(Uri.parse(widget.fileUrl)),
);
_audioPlayer.durationStream.firstWhere((d) => d != null).then((
d,
) async {
setState(() {
_duration = d ?? Duration.zero;
_isLoading = false;
});
try {
await _audioPlayer.play();
Future.delayed(const Duration(milliseconds: 100), () {
final player = _audioPlayer as dynamic;
if (player.playerState?.playing == true) {
if (mounted) _iconController.forward();
} else {
setState(() {
_errorMsg = 'Audio could not be played.';
});
}
});
} catch (e) {
setState(() {
_errorMsg =
'Audio playback error: ${e is Exception ? e.toString() : 'Unknown error'}';
});
}
});
_audioPlayer.positionStream.listen((pos) {
setState(() {
_position = pos;
});
});
final player = _audioPlayer as dynamic;
player.playerStateStream.listen((state) {
setState(() {
_isPlaying = state.playing;
});
if (state.playing) {
if (mounted) _iconController.forward();
} else {
if (mounted) _iconController.reverse();
}
});
}
} catch (e) {
setState(() {
_isLoading = false;
_errorMsg =
'Audio load error: ${e is Exception ? e.toString() : 'Unknown error'}';
});
}
}
void _handlePlayPause() {
if (_isPlaying) {
_audioPlayer.pause();
_iconController.reverse();
} else {
// If at end, seek to start
if (_position >= _duration && _duration > Duration.zero) {
_audioPlayer.seek(Duration.zero);
}
_audioPlayer.play();
_iconController.forward();
}
}
@override
Widget build(BuildContext context) {
final double screenwidth = MediaQuery.of(context).size.width;
return AnimatedContainer(
duration: const Duration(milliseconds: 300),
height: 48,
width: screenwidth > 420 ? screenwidth * 0.2 : screenwidth * 0.85,
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: AppTheme.glassDecoration.copyWith(
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: AppTheme.accentColor.withValues(alpha: 0.15),
blurRadius: 12,
offset: const Offset(0, 2),
),
],
),
child: Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
ModernGlassButton(
onPressed: _isLoading ? () {} : _handlePlayPause,
child: Transform.translate(
offset: const Offset(0, -4),
child: AnimatedIcon(
icon: AnimatedIcons.play_pause,
progress: _iconController,
color: AppTheme.primaryText,
size: 22,
),
),
),
const SizedBox(width: 10),
// File name and slider
Expanded(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
widget.fileName,
style: const TextStyle(
color: AppTheme.primaryText,
fontWeight: FontWeight.w600,
fontSize: 14,
),
overflow: TextOverflow.ellipsis,
),
SizedBox(
height: 18,
child: SliderTheme(
data: SliderTheme.of(context).copyWith(
trackHeight: 2.2,
thumbShape: const RoundSliderThumbShape(
enabledThumbRadius: 6,
),
overlayShape: SliderComponentShape.noOverlay,
activeTrackColor: AppTheme.accentColor,
inactiveTrackColor: AppTheme.accentColor.withValues(
alpha: 0.18,
),
),
child: Slider(
min: 0,
max: _duration.inMilliseconds.toDouble(),
value: _position.inMilliseconds
.clamp(0, _duration.inMilliseconds)
.toDouble(),
onChanged: _isLoading
? null
: (value) {
_audioPlayer.seek(
Duration(milliseconds: value.toInt()),
);
},
activeColor: AppTheme.accentColor,
inactiveColor: AppTheme.accentColor.withValues(
alpha: 0.18,
),
),
),
),
if (_errorMsg != null)
Padding(
padding: const EdgeInsets.only(top: 2.0),
child: Text(
_errorMsg!,
style: const TextStyle(
color: Colors.red,
fontSize: 12,
fontWeight: FontWeight.w500,
),
),
),
],
),
),
const SizedBox(width: 10),
Text(
'${_formatDuration(_position)} / ${_formatDuration(_duration)}',
style: const TextStyle(
color: AppTheme.secondaryText,
fontSize: 12,
fontWeight: FontWeight.w500,
),
),
if (widget.onClose != null)
Padding(
padding: const EdgeInsets.only(left: 8.0),
child: GestureDetector(
onTap: widget.onClose!,
child: const Text(
'×',
style: TextStyle(
color: AppTheme.primaryText,
fontSize: 20,
fontWeight: FontWeight.bold,
),
),
),
),
],
),
);
}
// Utility to format duration
String _formatDuration(Duration d) {
String twoDigits(int n) => n.toString().padLeft(2, '0');
final minutes = twoDigits(d.inMinutes.remainder(60));
final seconds = twoDigits(d.inSeconds.remainder(60));
return '$minutes:$seconds';
}
}

View File

@@ -0,0 +1,188 @@
import 'package:flutter/material.dart';
import 'package:syncfusion_flutter_pdfviewer/pdfviewer.dart';
import 'package:syncfusion_flutter_core/theme.dart';
import 'package:video_player/video_player.dart';
import 'package:flutter/foundation.dart' show kIsWeb;
import 'package:web/web.dart' as web;
import 'dart:ui_web' as ui_web;
import '../theme/app_theme.dart';
import 'audio_player_bar.dart';
class FileViewerDispatch {
static Widget buildFileViewer(
BuildContext context,
String url,
String mimeType, {
String? token,
String? fileName,
required String viewerId,
void Function(PdfHyperlinkClickedDetails)? onHyperlinkClicked,
}) {
final headers = token != null
? {'Authorization': 'Bearer $token'}
: <String, String>{};
if (mimeType == 'application/pdf') {
return SfTheme(
data: SfThemeData(
pdfViewerThemeData: SfPdfViewerThemeData(
backgroundColor: AppTheme.primaryBackground,
progressBarColor: AppTheme.accentColor,
scrollStatusStyle: PdfScrollStatusStyle(
backgroundColor: AppTheme.primaryBackground,
),
scrollHeadStyle: PdfScrollHeadStyle(
backgroundColor: AppTheme.accentColor,
),
),
),
child: SfPdfViewer.network(
url,
headers: headers,
canShowScrollHead: false,
canShowScrollStatus: false,
enableDoubleTapZooming: true,
enableTextSelection: false,
onHyperlinkClicked: onHyperlinkClicked,
onDocumentLoadFailed: (details) {
// Handle error
},
),
);
} else if (mimeType.startsWith('video/')) {
if (kIsWeb) {
// Use HTML video element for web
ui_web.platformViewRegistry.registerViewFactory(viewerId, (int viewId) {
final videoElement = web.HTMLVideoElement()
..src = url
..controls = true
..autoplay = false
..crossOrigin = 'anonymous'
..style.width = '100%'
..style.height = '100%'
..style.objectFit = 'contain';
// Add headers if token
if (token != null) {
// For web, headers are not directly supported, but since it's public or auth, assume ok
}
videoElement.onError.listen((event) {
// Handle error
});
return videoElement;
});
return Container(
color: Colors.black,
child: HtmlElementView(viewType: viewerId),
);
} else {
// Use VideoPlayer for mobile
final controller = VideoPlayerController.networkUrl(Uri.parse(url));
return FutureBuilder<void>(
future: controller.initialize(),
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.done) {
return AspectRatio(
aspectRatio: controller.value.aspectRatio,
child: VideoPlayer(controller),
);
} else {
return const Center(child: CircularProgressIndicator());
}
},
);
}
} else if (mimeType.startsWith('audio/')) {
return Center(
child: AudioPlayerBar(
fileName: fileName ?? 'Audio',
fileUrl: url,
mimeType: mimeType,
),
);
} else if (mimeType.startsWith('image/')) {
Widget child;
if (kIsWeb && token == null) {
// Use HTML img element for web public shares to handle CORS
ui_web.platformViewRegistry.registerViewFactory(viewerId, (int viewId) {
final imgElement = web.HTMLImageElement()
..src = url
..style.width = '100%'
..style.height = '100%'
..style.objectFit = 'contain'
..crossOrigin = 'anonymous';
imgElement.onError.listen((event) {
// Handle error
});
return imgElement;
});
child = HtmlElementView(viewType: viewerId);
} else {
// For mobile or authenticated web, use Image.network with headers
child = Image.network(
url,
headers: headers,
fit: BoxFit.contain,
errorBuilder: (context, error, stackTrace) {
return Center(
child: Text(
'Failed to load image',
style: TextStyle(color: Colors.red[400]),
),
);
},
);
}
Widget viewerChild = child;
if (!(kIsWeb && token == null)) {
// Use InteractiveViewer for mobile or authenticated web
viewerChild = InteractiveViewer(
minScale: 0.5,
maxScale: 4.0,
child: child,
);
}
return Container(color: AppTheme.primaryBackground, child: viewerChild);
} else {
return Container(
color: AppTheme.primaryBackground,
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.description,
size: 80,
color: AppTheme.primaryText.withValues(alpha: 0.7),
),
const SizedBox(height: 16),
Text(
'File type not supported for preview',
style: TextStyle(color: AppTheme.primaryText, fontSize: 18),
),
const SizedBox(height: 16),
ElevatedButton(
onPressed: () {
// Download
final anchor = web.HTMLAnchorElement()
..href = url
..download = fileName ?? 'download';
anchor.click();
},
child: const Text('Download File'),
),
],
),
),
);
}
}
}

View File

@@ -0,0 +1,722 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'dart:developer' as developer;
import '../blocs/organization/organization_state.dart';
import '../blocs/permission/permission_state.dart';
import '../models/organization.dart';
import '../models/user.dart';
import '../services/org_api.dart';
import '../theme/app_theme.dart';
import '../theme/modern_glass_button.dart';
class OrganizationSettingsDialog extends StatefulWidget {
final Organization organization;
final PermissionState permissionState;
final OrgApi orgApi;
const OrganizationSettingsDialog({
super.key,
required this.organization,
required this.permissionState,
required this.orgApi,
});
@override
State<OrganizationSettingsDialog> createState() =>
_OrganizationSettingsDialogState();
}
class _OrganizationSettingsDialogState
extends State<OrganizationSettingsDialog> {
int _selectedTabIndex = 0;
List<Member> _members = [];
List<Invitation> _invitations = [];
List<JoinRequest> _joinRequests = [];
String? _inviteLink;
bool _isLoading = false;
String? _error;
List<User> _userSuggestions = [];
late final TextEditingController usernameController;
final LayerLink _layerLink = LayerLink();
@override
void initState() {
super.initState();
usernameController = TextEditingController();
_loadData();
}
@override
void dispose() {
usernameController.dispose();
super.dispose();
}
Future<void> _loadData() async {
if (!mounted) return;
setState(() => _isLoading = true);
String? error;
List<Member> members = [];
List<Invitation> invitations = [];
List<JoinRequest> joinRequests = [];
String? inviteLink;
try {
members = await widget.orgApi.getMembers(widget.organization.id);
} catch (e) {
developer.log(
'Error loading members: $e',
name: 'OrganizationSettingsDialog',
);
error ??= 'Failed to load members: $e';
}
try {
invitations = await widget.orgApi.getInvitations(widget.organization.id);
} catch (e) {
developer.log(
'Error loading invitations: $e',
name: 'OrganizationSettingsDialog',
);
error ??= 'Failed to load invitations: $e';
}
try {
joinRequests = await widget.orgApi.getJoinRequests(
widget.organization.id,
);
} catch (e) {
developer.log(
'Error loading join requests: $e',
name: 'OrganizationSettingsDialog',
);
error ??= 'Failed to load join requests: $e';
}
try {
inviteLink = await widget.orgApi.getInviteLink(widget.organization.id);
} catch (e) {
developer.log(
'Error loading invite link: $e',
name: 'OrganizationSettingsDialog',
);
error ??= 'Failed to load invite link: $e';
}
if (!mounted) return;
setState(() {
_members = members;
_invitations = invitations;
_joinRequests = joinRequests;
_inviteLink = inviteLink;
_isLoading = false;
_error = error;
});
}
Future<void> _updateMemberRole(String userId, String newRole) async {
try {
await widget.orgApi.updateMemberRole(
widget.organization.id,
userId,
newRole,
);
await _loadData(); // Refresh
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text('Failed to update role: $e')));
}
}
Future<void> _removeMember(String userId) async {
try {
await widget.orgApi.removeMember(widget.organization.id, userId);
await _loadData(); // Refresh
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text('Failed to remove member: $e')));
}
}
Future<void> _inviteUser(String username, String role) async {
try {
await widget.orgApi.createInvitation(
widget.organization.id,
username,
role,
);
await _loadData(); // Refresh
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text('Failed to send invitation: $e')));
}
}
Future<void> _cancelInvitation(String invitationId) async {
try {
await widget.orgApi.cancelInvitation(
widget.organization.id,
invitationId,
);
await _loadData(); // Refresh
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Failed to cancel invitation: $e')),
);
}
}
Future<void> _acceptJoinRequest(String requestId, String role) async {
try {
await widget.orgApi.acceptJoinRequest(
widget.organization.id,
requestId,
role,
);
await _loadData(); // Refresh
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text('Failed to accept request: $e')));
}
}
Future<void> _rejectJoinRequest(String requestId) async {
try {
await widget.orgApi.rejectJoinRequest(widget.organization.id, requestId);
await _loadData(); // Refresh
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text('Failed to reject request: $e')));
}
}
Future<void> _regenerateInviteLink() async {
try {
final newLink = await widget.orgApi.regenerateInviteLink(
widget.organization.id,
);
setState(() => _inviteLink = newLink);
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text('Failed to regenerate link: $e')));
}
}
void _copyInviteLink() {
if (_inviteLink != null) {
Clipboard.setData(
ClipboardData(text: 'https://b0esche.cloud/join?token=$_inviteLink'),
);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Invite link copied to clipboard')),
);
}
}
bool get _canManage =>
widget.permissionState is PermissionLoaded &&
(widget.permissionState as PermissionLoaded).capabilities.canAdmin;
@override
Widget build(BuildContext context) {
return Dialog(
backgroundColor: AppTheme.primaryBackground,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
child: Container(
width: 600,
height: 500,
padding: const EdgeInsets.all(24),
child: Column(
children: [
// Header
Row(
children: [
Text(
'Manage ${widget.organization.name}',
style: TextStyle(
color: AppTheme.primaryText,
fontSize: 24,
fontWeight: FontWeight.bold,
),
),
const Spacer(),
IconButton(
onPressed: () => Navigator.of(context).pop(),
icon: Icon(Icons.close, color: AppTheme.secondaryText),
splashColor: Colors.transparent,
highlightColor: Colors.transparent,
),
],
),
const SizedBox(height: 16),
// Custom Tabs
Row(
children: [
_buildTabButton('Members', 0),
_buildTabButton('Invite', 1),
_buildTabButton('Requests', 2),
],
),
const SizedBox(height: 16),
// Tab content
Expanded(
child: _isLoading
? Center(
child: CircularProgressIndicator(
color: AppTheme.accentColor,
),
)
: _error != null
? Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
_error!,
style: TextStyle(color: AppTheme.errorColor),
),
const SizedBox(height: 16),
ModernGlassButton(
onPressed: _loadData,
child: const Text('Retry'),
),
],
),
)
: _buildTabContent(),
),
],
),
),
);
}
Widget _buildTabButton(String text, int index) {
final isSelected = _selectedTabIndex == index;
return Expanded(
child: GestureDetector(
onTap: () => setState(() => _selectedTabIndex = index),
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 8),
margin: const EdgeInsets.symmetric(horizontal: 2),
decoration: BoxDecoration(
color: isSelected
? AppTheme.accentColor.withValues(alpha: 0.15)
: Colors.transparent,
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: isSelected
? AppTheme.accentColor
: AppTheme.secondaryText.withValues(alpha: 0.3),
width: 1.5,
),
boxShadow: isSelected
? [
BoxShadow(
color: AppTheme.accentColor.withValues(alpha: 0.3),
blurRadius: 8,
spreadRadius: 1,
),
]
: null,
),
child: AnimatedDefaultTextStyle(
duration: const Duration(milliseconds: 200),
style: TextStyle(
color: isSelected ? AppTheme.accentColor : AppTheme.secondaryText,
fontWeight: isSelected ? FontWeight.bold : FontWeight.normal,
fontSize: 14,
),
child: Text(text, textAlign: TextAlign.center),
),
),
),
);
}
Widget _buildTabContent() {
switch (_selectedTabIndex) {
case 0:
return _buildMembersTab();
case 1:
return _buildInviteTab();
case 2:
return _buildRequestsTab();
default:
return _buildMembersTab();
}
}
Widget _buildMembersTab() {
return ListView.builder(
itemCount: _members.length,
itemBuilder: (context, index) {
final member = _members[index];
return ListTile(
title: Text(
member.user.displayName ?? member.user.username,
style: TextStyle(color: AppTheme.primaryText),
),
subtitle: Text(
member.role,
style: TextStyle(color: AppTheme.secondaryText),
),
trailing: _canManage
? Row(
mainAxisSize: MainAxisSize.min,
children: [
if (member.role != 'owner')
DropdownButton<String>(
value: member.role,
items: ['admin', 'member'].map((role) {
return DropdownMenuItem(
value: role,
child: Text(role),
);
}).toList(),
onChanged: (newRole) {
if (newRole != null && newRole != member.role) {
_updateMemberRole(member.userId, newRole);
}
},
),
if (member.role != 'owner')
IconButton(
icon: Icon(
Icons.remove_circle,
color: AppTheme.errorColor,
),
onPressed: () => _removeMember(member.userId),
splashColor: Colors.transparent,
highlightColor: Colors.transparent,
),
],
)
: null,
);
},
);
}
Widget _buildInviteTab() {
String selectedRole = 'member';
return Stack(
children: [
Column(
children: [
// Pending invitations
if (_invitations.isNotEmpty) ...[
Text(
'Pending Invitations',
style: TextStyle(
color: AppTheme.primaryText,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
SizedBox(
height: 150,
child: ListView.builder(
itemCount: _invitations.length,
itemBuilder: (context, index) {
final inv = _invitations[index];
return ListTile(
title: Text(
inv.username,
style: TextStyle(color: AppTheme.primaryText),
),
subtitle: Text(
'Role: ${inv.role}',
style: TextStyle(color: AppTheme.secondaryText),
),
trailing: IconButton(
icon: Icon(Icons.cancel, color: AppTheme.errorColor),
onPressed: () => _cancelInvitation(inv.id),
splashColor: Colors.transparent,
highlightColor: Colors.transparent,
),
);
},
),
),
],
// Invite link section
if (_inviteLink != null) ...[
const Divider(),
Text(
'Invite Link',
style: TextStyle(
color: AppTheme.primaryText,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
Row(
children: [
Expanded(
child: Text(
'https://b0esche.cloud/join?token=${_inviteLink ?? ''}',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(color: AppTheme.secondaryText),
),
),
const SizedBox(width: 16),
ModernGlassButton(
onPressed: _copyInviteLink,
child: const Icon(Icons.content_copy),
),
if (_canManage) ...[
const SizedBox(width: 16),
ModernGlassButton(
onPressed: _regenerateInviteLink,
child: const Icon(Icons.refresh),
),
],
],
),
] else if (_canManage) ...[
const Divider(),
const Text('No invite link available'),
],
// Invite form
const Divider(),
Text(
'Invite New User',
style: TextStyle(
color: AppTheme.primaryText,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 16),
CompositedTransformTarget(
link: _layerLink,
child: TextField(
controller: usernameController,
cursorColor: AppTheme.accentColor,
decoration: InputDecoration(
hintText: 'username',
hintStyle: TextStyle(color: AppTheme.secondaryText),
contentPadding: const EdgeInsets.all(12),
border: OutlineInputBorder(
borderSide: BorderSide(
color: AppTheme.accentColor.withValues(alpha: 0.3),
),
),
enabledBorder: OutlineInputBorder(
borderSide: BorderSide(
color: AppTheme.accentColor.withValues(alpha: 0.3),
),
),
focusedBorder: OutlineInputBorder(
borderSide: BorderSide(color: AppTheme.accentColor),
),
),
style: TextStyle(color: AppTheme.primaryText),
onChanged: (value) async {
if (value.length > 2) {
try {
_userSuggestions = await widget.orgApi.searchUsers(
widget.organization.id,
value,
);
} catch (e) {
_userSuggestions = [];
}
setState(() {});
} else {
_userSuggestions = [];
setState(() {});
}
},
),
),
const SizedBox(height: 16),
Theme(
data: Theme.of(context).copyWith(
splashFactory: NoSplash.splashFactory,
buttonTheme: ButtonThemeData(splashColor: Colors.transparent),
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
highlightColor: Colors.transparent,
splashColor: Colors.transparent,
),
child: DropdownButtonFormField<String>(
initialValue: selectedRole,
items: ['admin', 'member'].map((role) {
return DropdownMenuItem(
value: role,
child: Material(
type: MaterialType.transparency,
child: Text(role),
),
);
}).toList(),
dropdownColor: AppTheme.primaryBackground,
onChanged: (value) => selectedRole = value ?? 'member',
decoration: InputDecoration(
labelText: 'Role',
labelStyle: TextStyle(color: AppTheme.secondaryText),
border: OutlineInputBorder(
borderSide: BorderSide(
color: AppTheme.accentColor.withValues(alpha: 0.3),
),
),
focusedBorder: OutlineInputBorder(
borderSide: BorderSide(color: AppTheme.accentColor),
),
enabledBorder: OutlineInputBorder(
borderSide: BorderSide(
color: AppTheme.accentColor.withValues(alpha: 0.3),
),
),
),
),
),
const SizedBox(height: 16),
SizedBox(
width: 240,
child: ModernGlassButton(
onPressed: () {
if (_canManage) {
final username = usernameController.text.trim();
if (username.isNotEmpty) {
_inviteUser(username, selectedRole);
usernameController.clear();
}
} else {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text(
'You do not have permission to send invitations',
),
),
);
}
},
child: const Text('Send Invitation'),
),
),
],
),
if (_userSuggestions.isNotEmpty)
CompositedTransformFollower(
link: _layerLink,
offset: const Offset(0, 48),
child: Container(
width: 300,
height: 100,
decoration: BoxDecoration(
color: AppTheme.primaryBackground,
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: AppTheme.accentColor.withValues(alpha: 0.3),
),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.2),
blurRadius: 8,
offset: const Offset(0, 4),
),
],
),
child: ListView.builder(
itemCount: _userSuggestions.length,
itemBuilder: (context, index) {
final user = _userSuggestions[index];
return ListTile(
title: Text(
user.displayName ?? user.username,
style: TextStyle(color: AppTheme.primaryText),
),
onTap: () {
usernameController.text = user.username;
setState(() => _userSuggestions = []);
},
);
},
),
),
),
],
);
}
Widget _buildRequestsTab() {
return ListView.builder(
itemCount: _joinRequests.length,
itemBuilder: (context, index) {
final req = _joinRequests[index];
return ListTile(
title: Text(
req.user.displayName ?? req.user.username,
style: TextStyle(color: AppTheme.primaryText),
),
subtitle: Text(
'Requested to join',
style: TextStyle(color: AppTheme.secondaryText),
),
trailing: _canManage
? Row(
mainAxisSize: MainAxisSize.min,
children: [
TextButton(
style: ButtonStyle(
splashFactory: NoSplash.splashFactory,
overlayColor: WidgetStateProperty.resolveWith<Color?>((
Set<WidgetState> states,
) {
if (states.contains(WidgetState.pressed)) {
return Colors.transparent;
}
return null;
}),
),
onPressed: () => _acceptJoinRequest(req.id, 'member'),
child: const Text('Accept'),
),
TextButton(
style: ButtonStyle(
splashFactory: NoSplash.splashFactory,
overlayColor: WidgetStateProperty.resolveWith<Color?>((
Set<WidgetState> states,
) {
if (states.contains(WidgetState.pressed)) {
return Colors.transparent;
}
return null;
}),
),
onPressed: () => _rejectJoinRequest(req.id),
child: Text(
'Reject',
style: TextStyle(color: AppTheme.errorColor),
),
),
],
)
: null,
);
},
);
}
}

View File

@@ -0,0 +1,273 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../services/api_client.dart';
import '../models/api_error.dart';
import '../theme/app_theme.dart';
import '../theme/modern_glass_button.dart';
import '../injection.dart';
class ShareFileDialog extends StatefulWidget {
final String orgId;
final String fileId;
final String fileName;
const ShareFileDialog({
super.key,
required this.orgId,
required this.fileId,
required this.fileName,
});
@override
State<ShareFileDialog> createState() => _ShareFileDialogState();
}
class _ShareFileDialogState extends State<ShareFileDialog> {
bool _isLoading = true;
String? _shareUrl;
String? _error;
@override
void initState() {
super.initState();
_loadShareLink();
}
Future<void> _loadShareLink() async {
setState(() {
_isLoading = true;
_error = null;
});
try {
final apiClient = getIt<ApiClient>();
final path = widget.orgId.isEmpty || widget.orgId == 'personal'
? '/orgs/files/${widget.fileId}/share'
: '/orgs/${widget.orgId}/files/${widget.fileId}/share';
final response = await apiClient.getRaw(path);
setState(() {
_shareUrl = response['shareUrl'];
_isLoading = false;
});
} catch (e) {
if (e is ApiError && e.status == 404) {
// No link exists, create one automatically
setState(() {
_isLoading = true; // Keep loading for creation
});
try {
final apiClient = getIt<ApiClient>();
final path = widget.orgId.isEmpty || widget.orgId == 'personal'
? '/orgs/files/${widget.fileId}/share'
: '/orgs/${widget.orgId}/files/${widget.fileId}/share';
final response = await apiClient.postRaw(path, data: {});
setState(() {
_shareUrl = response['shareUrl'];
_isLoading = false;
});
} catch (createError) {
setState(() {
_error = createError is ApiError
? createError.message
: 'Failed to create share link';
_isLoading = false;
});
}
return;
}
setState(() {
_error = 'Failed to load share link';
_isLoading = false;
});
}
}
Future<void> _createShareLink() async {
setState(() {
_isLoading = true;
_error = null;
});
try {
final apiClient = getIt<ApiClient>();
final path = widget.orgId.isEmpty || widget.orgId == 'personal'
? '/orgs/files/${widget.fileId}/share'
: '/orgs/${widget.orgId}/files/${widget.fileId}/share';
final response = await apiClient.postRaw(path, data: {});
setState(() {
_shareUrl = response['shareUrl'];
_isLoading = false;
});
} catch (e) {
setState(() {
_error = e is ApiError ? e.message : 'Failed to create share link';
_isLoading = false;
});
}
}
void _copyToClipboard() {
if (_shareUrl != null) {
Clipboard.setData(ClipboardData(text: _shareUrl!));
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('Copied!')));
}
}
@override
Widget build(BuildContext context) {
return Dialog(
backgroundColor: AppTheme.primaryBackground,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
child: Container(
width: 500,
constraints: const BoxConstraints(maxHeight: 400),
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Header
Row(
children: [
Expanded(
child: Text(
'Share "${widget.fileName}"',
style: TextStyle(
color: AppTheme.primaryText,
fontSize: 20,
fontWeight: FontWeight.bold,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
const SizedBox(width: 16),
IconButton(
onPressed: () => Navigator.of(context).pop(),
icon: Icon(Icons.close, color: AppTheme.secondaryText),
splashColor: Colors.transparent,
highlightColor: Colors.transparent,
),
],
),
const SizedBox(height: 16),
if (_isLoading)
const Center(
child: CircularProgressIndicator(
valueColor: AlwaysStoppedAnimation<Color>(
AppTheme.accentColor,
),
),
)
else if (_error != null)
Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
_error!,
style: TextStyle(color: AppTheme.errorColor),
textAlign: TextAlign.center,
),
const SizedBox(height: 16),
SizedBox(
width: 160,
child: ModernGlassButton(
onPressed: _loadShareLink,
child: const Text('Retry'),
),
),
],
),
)
else
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (_shareUrl != null) ...[
Text(
'Anyone with this link can view and download the file.',
style: TextStyle(color: AppTheme.secondaryText),
),
const SizedBox(height: 16),
Row(
children: [
Expanded(
child: TextField(
controller: TextEditingController(text: _shareUrl),
readOnly: true,
maxLines: 1,
style: TextStyle(color: AppTheme.primaryText),
decoration: InputDecoration(
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide(
color: AppTheme.secondaryText.withValues(
alpha: 0.3,
),
),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide(
color: AppTheme.secondaryText.withValues(
alpha: 0.3,
),
),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide(
color: AppTheme.accentColor,
),
),
contentPadding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 8,
),
),
),
),
const SizedBox(width: 16),
ModernGlassButton(
onPressed: _copyToClipboard,
child: const Icon(Icons.content_copy),
),
],
),
const SizedBox(height: 16),
] else ...[
Text(
'No share link yet. Create a public, read-only link for this file.',
style: TextStyle(color: AppTheme.secondaryText),
),
const SizedBox(height: 16),
ModernGlassButton(
onPressed: _createShareLink,
isLoading: _isLoading,
child: _isLoading
? const SizedBox(
height: 16,
width: 16,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation<Color>(
AppTheme.accentColor,
),
),
)
: const Text('Create link'),
),
],
],
),
],
),
),
);
}
}

View File

@@ -0,0 +1,141 @@
import 'package:web/web.dart' as web;
import 'dart:async';
import 'dart:typed_data';
import '../services/api_client.dart';
import '../injection.dart';
class AudioPlayer {
web.HTMLAudioElement? _audioElement;
String? _blobUrl;
final StreamController<Duration> _positionController =
StreamController<Duration>.broadcast();
final StreamController<Duration> _durationController =
StreamController<Duration>.broadcast();
final StreamController<bool> _playingController =
StreamController<bool>.broadcast();
final StreamController<String> _errorController =
StreamController<String>.broadcast();
// Store subscriptions for cleanup
StreamSubscription? _durationSubscription;
StreamSubscription? _positionSubscription;
StreamSubscription? _playSubscription;
StreamSubscription? _pauseSubscription;
StreamSubscription? _endedSubscription;
StreamSubscription? _errorSubscription;
Stream<Duration> get positionStream => _positionController.stream;
Stream<Duration> get durationStream => _durationController.stream;
Stream<bool> get playingStream => _playingController.stream;
Stream<String> get errorStream => _errorController.stream;
void _disposeSubscriptions() {
_durationSubscription?.cancel();
_positionSubscription?.cancel();
_playSubscription?.cancel();
_pauseSubscription?.cancel();
_endedSubscription?.cancel();
_errorSubscription?.cancel();
_durationSubscription = null;
_positionSubscription = null;
_playSubscription = null;
_pauseSubscription = null;
_endedSubscription = null;
_errorSubscription = null;
}
Future<void> setUrl(String url, {String? mimeType}) async {
// Clean up any existing subscriptions
_disposeSubscriptions();
try {
final apiClient = getIt<ApiClient>();
final path = url.replaceFirst(apiClient.baseUrl, '');
final bytes = await apiClient.getBytes(path);
final blob = web.Blob(
[Uint8List.fromList(bytes)] as dynamic,
web.BlobPropertyBag(type: mimeType ?? 'audio/mpeg'),
);
final blobUrl = web.URL.createObjectURL(blob);
_audioElement = web.HTMLAudioElement();
_audioElement!.src = blobUrl;
_audioElement!.crossOrigin = 'anonymous'; // Handle CORS
// Set up event listeners and store subscriptions
_durationSubscription = _audioElement!.onLoadedMetadata.listen((_) {
if (_audioElement != null) {
_durationController.add(
Duration(milliseconds: (_audioElement!.duration * 1000).toInt()),
);
}
});
_positionSubscription = _audioElement!.onTimeUpdate.listen((_) {
if (_audioElement != null) {
_positionController.add(
Duration(milliseconds: (_audioElement!.currentTime * 1000).toInt()),
);
}
});
_playSubscription = _audioElement!.onPlay.listen((_) {
_playingController.add(true);
});
_pauseSubscription = _audioElement!.onPause.listen((_) {
_playingController.add(false);
});
_endedSubscription = _audioElement!.onEnded.listen((_) {
_playingController.add(false);
});
_errorSubscription = _audioElement!.onError.listen((_) {
_errorController.add('Failed to load audio');
});
// Load the audio
_audioElement!.load();
} catch (e) {
_errorController.add('Error initializing audio: $e');
}
}
Future<void> play() async {
try {
if (_audioElement != null) {
// The play() method returns a JSPromise, but we can call it without await
// since we're not depending on the promise resolution for our logic
_audioElement!.play();
}
} catch (e) {
_errorController.add('Error playing audio: $e');
}
}
Future<void> pause() async {
_audioElement?.pause();
}
Future<void> seek(Duration position) async {
if (_audioElement != null) {
_audioElement!.currentTime = position.inMilliseconds / 1000;
}
}
void dispose() {
_disposeSubscriptions();
_audioElement?.pause();
if (_blobUrl != null) {
web.URL.revokeObjectURL(_blobUrl!);
_blobUrl = null;
}
_audioElement = null;
_positionController.close();
_durationController.close();
_playingController.close();
_errorController.close();
}
}

View File

@@ -5,30 +5,34 @@
import FlutterMacOS
import Foundation
import audio_session
import connectivity_plus
import desktop_drop
import device_info_plus
import file_picker
import flutter_secure_storage_darwin
import irondash_engine_context
import path_provider_foundation
import just_audio
import shared_preferences_foundation
import sqflite_darwin
import super_native_extensions
import syncfusion_pdfviewer_macos
import url_launcher_macos
import video_player_avfoundation
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
AudioSessionPlugin.register(with: registry.registrar(forPlugin: "AudioSessionPlugin"))
ConnectivityPlusPlugin.register(with: registry.registrar(forPlugin: "ConnectivityPlusPlugin"))
DesktopDropPlugin.register(with: registry.registrar(forPlugin: "DesktopDropPlugin"))
DeviceInfoPlusMacosPlugin.register(with: registry.registrar(forPlugin: "DeviceInfoPlusMacosPlugin"))
FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin"))
FlutterSecureStorageDarwinPlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStorageDarwinPlugin"))
IrondashEngineContextPlugin.register(with: registry.registrar(forPlugin: "IrondashEngineContextPlugin"))
PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin"))
JustAudioPlugin.register(with: registry.registrar(forPlugin: "JustAudioPlugin"))
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin"))
SuperNativeExtensionsPlugin.register(with: registry.registrar(forPlugin: "SuperNativeExtensionsPlugin"))
SyncfusionFlutterPdfViewerPlugin.register(with: registry.registrar(forPlugin: "SyncfusionFlutterPdfViewerPlugin"))
UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin"))
FVPVideoPlayerPlugin.register(with: registry.registrar(forPlugin: "FVPVideoPlayerPlugin"))
}

View File

@@ -1,22 +1,14 @@
# Generated by pub
# See https://dart.dev/tools/pub/glossary#lockfile
packages:
_fe_analyzer_shared:
dependency: transitive
archive:
dependency: "direct main"
description:
name: _fe_analyzer_shared
sha256: "0b2f2bd91ba804e53a61d757b986f89f1f9eaed5b11e4b2f5a2468d86d6c9fc7"
name: archive
sha256: "2fde1607386ab523f7a36bb3e7edb43bd58e6edaf2ffb29d8a6d578b297fdbbd"
url: "https://pub.dev"
source: hosted
version: "67.0.0"
analyzer:
dependency: transitive
description:
name: analyzer
sha256: "37577842a27e4338429a1cbc32679d508836510b056f1eedf0c8d20e39c1383d"
url: "https://pub.dev"
source: hosted
version: "6.4.1"
version: "4.0.7"
args:
dependency: transitive
description:
@@ -33,94 +25,22 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.13.0"
audio_session:
dependency: transitive
description:
name: audio_session
sha256: "8f96a7fecbb718cb093070f868b4cdcb8a9b1053dce342ff8ab2fde10eb9afb7"
url: "https://pub.dev"
source: hosted
version: "0.2.2"
bloc:
dependency: "direct main"
description:
name: bloc
sha256: a2cebb899f91d36eeeaa55c7b20b5915db5a9df1b8fd4a3c9c825e22e474537d
sha256: a48653a82055a900b88cd35f92429f068c5a8057ae9b136d197b3d56c57efb81
url: "https://pub.dev"
source: hosted
version: "9.1.0"
bloc_test:
dependency: "direct dev"
description:
name: bloc_test
sha256: "1dd549e58be35148bc22a9135962106aa29334bc1e3f285994946a1057b29d7b"
url: "https://pub.dev"
source: hosted
version: "10.0.0"
boolean_selector:
dependency: transitive
description:
name: boolean_selector
sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea"
url: "https://pub.dev"
source: hosted
version: "2.1.2"
build:
dependency: transitive
description:
name: build
sha256: "80184af8b6cb3e5c1c4ec6d8544d27711700bc3e6d2efad04238c7b5290889f0"
url: "https://pub.dev"
source: hosted
version: "2.4.1"
build_config:
dependency: transitive
description:
name: build_config
sha256: "4ae2de3e1e67ea270081eaee972e1bd8f027d459f249e0f1186730784c2e7e33"
url: "https://pub.dev"
source: hosted
version: "1.1.2"
build_daemon:
dependency: transitive
description:
name: build_daemon
sha256: bf05f6e12cfea92d3c09308d7bcdab1906cd8a179b023269eed00c071004b957
url: "https://pub.dev"
source: hosted
version: "4.1.1"
build_resolvers:
dependency: transitive
description:
name: build_resolvers
sha256: "339086358431fa15d7eca8b6a36e5d783728cf025e559b834f4609a1fcfb7b0a"
url: "https://pub.dev"
source: hosted
version: "2.4.2"
build_runner:
dependency: "direct dev"
description:
name: build_runner
sha256: "028819cfb90051c6b5440c7e574d1896f8037e3c96cf17aaeb054c9311cfbf4d"
url: "https://pub.dev"
source: hosted
version: "2.4.13"
build_runner_core:
dependency: transitive
description:
name: build_runner_core
sha256: f8126682b87a7282a339b871298cc12009cb67109cfa1614d6436fb0289193e0
url: "https://pub.dev"
source: hosted
version: "7.3.2"
built_collection:
dependency: transitive
description:
name: built_collection
sha256: "376e3dd27b51ea877c28d525560790aee2e6fbb5f20e2f85d5081027d94e2100"
url: "https://pub.dev"
source: hosted
version: "5.1.1"
built_value:
dependency: transitive
description:
name: built_value
sha256: "426cf75afdb23aa74bd4e471704de3f9393f3c7b04c1e2d9c6f1073ae0b8b139"
url: "https://pub.dev"
source: hosted
version: "8.12.1"
version: "9.2.0"
cached_network_image:
dependency: "direct main"
description:
@@ -153,22 +73,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.4.0"
checked_yaml:
dependency: transitive
description:
name: checked_yaml
sha256: "959525d3162f249993882720d52b7e0c833978df229be20702b33d48d91de70f"
url: "https://pub.dev"
source: hosted
version: "2.0.4"
cli_config:
dependency: transitive
description:
name: cli_config
sha256: ac20a183a07002b700f0c25e61b7ee46b23c309d76ab7b7640a028f18e4d99ec
url: "https://pub.dev"
source: hosted
version: "0.2.0"
clock:
dependency: transitive
description:
@@ -177,14 +81,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.1.2"
code_builder:
code_assets:
dependency: transitive
description:
name: code_builder
sha256: "11654819532ba94c34de52ff5feb52bd81cba1de00ef2ed622fd50295f9d4243"
name: code_assets
sha256: "83ccdaa064c980b5596c35dd64a8d3ecc68620174ab9b90b6343b753aa721687"
url: "https://pub.dev"
source: hosted
version: "4.11.0"
version: "1.0.0"
collection:
dependency: "direct main"
description:
@@ -217,22 +121,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "3.1.2"
coverage:
dependency: transitive
description:
name: coverage
sha256: "5da775aa218eaf2151c721b16c01c7676fbfdd99cebba2bf64e8b807a28ff94d"
url: "https://pub.dev"
source: hosted
version: "1.15.0"
cross_file:
dependency: transitive
description:
name: cross_file
sha256: "701dcfc06da0882883a2657c445103380e53e647060ad8d9dfb710c100996608"
sha256: "28bb3ae56f117b5aec029d702a90f57d285cd975c3c5c281eaca38dbc47c5937"
url: "https://pub.dev"
source: hosted
version: "0.3.5+1"
version: "0.3.5+2"
crypto:
dependency: transitive
description:
@@ -241,22 +137,22 @@ packages:
url: "https://pub.dev"
source: hosted
version: "3.0.7"
dart_style:
csslib:
dependency: transitive
description:
name: dart_style
sha256: "99e066ce75c89d6b29903d788a7bb9369cf754f7b24bf70bf4b6d6d6b26853b9"
name: csslib
sha256: "09bad715f418841f976c77db72d5398dc1253c21fb9c0c7f0b0b985860b2d58e"
url: "https://pub.dev"
source: hosted
version: "2.3.6"
version: "1.0.2"
dbus:
dependency: transitive
description:
name: dbus
sha256: "79e0c23480ff85dc68de79e2cd6334add97e48f7f4865d17686dd6ea81a47e8c"
sha256: d0c98dcd4f5169878b6cf8f6e0a52403a9dff371a3e2f019697accbf6f44a270
url: "https://pub.dev"
source: hosted
version: "0.7.11"
version: "0.7.12"
desktop_drop:
dependency: "direct main"
description:
@@ -281,22 +177,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "7.0.3"
diff_match_patch:
dependency: transitive
description:
name: diff_match_patch
sha256: "2efc9e6e8f449d0abe15be240e2c2a3bcd977c8d126cfd70598aee60af35c0a4"
url: "https://pub.dev"
source: hosted
version: "0.4.1"
dio:
dependency: "direct main"
description:
name: dio
sha256: d90ee57923d1828ac14e492ca49440f65477f4bb1263575900be731a3dac66a9
sha256: b9d46faecab38fc8cc286f80bc4d61a3bb5d4ac49e51ed877b4d6706efe57b25
url: "https://pub.dev"
source: hosted
version: "5.9.0"
version: "5.9.1"
dio_web_adapter:
dependency: transitive
description:
@@ -309,26 +197,18 @@ packages:
dependency: "direct main"
description:
name: equatable
sha256: "567c64b3cb4cf82397aac55f4f0cbd3ca20d77c6c03bedbc4ceaddc08904aef7"
sha256: "3e0141505477fd8ad55d6eb4e7776d3fe8430be8e497ccb1521370c3f21a3e2b"
url: "https://pub.dev"
source: hosted
version: "2.0.7"
fake_async:
dependency: transitive
description:
name: fake_async
sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44"
url: "https://pub.dev"
source: hosted
version: "1.3.3"
version: "2.0.8"
ffi:
dependency: transitive
description:
name: ffi
sha256: "289279317b4b16eb2bb7e271abccd4bf84ec9bdcbe999e278a94b804f5630418"
sha256: d07d37192dbf97461359c1518788f203b0c9102cfd2c35a716b823741219542c
url: "https://pub.dev"
source: hosted
version: "2.1.4"
version: "2.1.5"
file:
dependency: transitive
description:
@@ -341,10 +221,10 @@ packages:
dependency: "direct main"
description:
name: file_picker
sha256: "7872545770c277236fd32b022767576c562ba28366204ff1a5628853cf8f2200"
sha256: "57d9a1dd5063f85fa3107fb42d1faffda52fdc948cefd5fe5ea85267a5fc7343"
url: "https://pub.dev"
source: hosted
version: "10.3.7"
version: "10.3.10"
fixnum:
dependency: transitive
description:
@@ -402,10 +282,10 @@ packages:
dependency: "direct dev"
description:
name: flutter_lints
sha256: "3105dc8492f6183fb076ccf1f351ac3d60564bff92e20bfc4af9cc1651f4e7e1"
sha256: "5398f14efa795ffb7a33e9b6a08798b26a180edac4ad7db3f231e40f82ce11e1"
url: "https://pub.dev"
source: hosted
version: "6.0.0"
version: "5.0.0"
flutter_plugin_android_lifecycle:
dependency: transitive
description:
@@ -478,24 +358,11 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.2.3"
flutter_test:
dependency: "direct dev"
description: flutter
source: sdk
version: "0.0.0"
flutter_web_plugins:
dependency: transitive
dependency: "direct main"
description: flutter
source: sdk
version: "0.0.0"
frontend_server_client:
dependency: transitive
description:
name: frontend_server_client
sha256: f64a0333a82f30b0cca061bc3d143813a486dc086b574bfb233b7c1372427694
url: "https://pub.dev"
source: hosted
version: "4.0.0"
get_it:
dependency: "direct main"
description:
@@ -516,18 +383,10 @@ packages:
dependency: "direct main"
description:
name: go_router
sha256: eff94d2a6fc79fa8b811dde79c7549808c2346037ee107a1121b4a644c745f2a
sha256: "7974313e217a7771557add6ff2238acb63f635317c35fa590d348fb238f00896"
url: "https://pub.dev"
source: hosted
version: "17.0.1"
graphs:
dependency: transitive
description:
name: graphs
sha256: "741bbf84165310a68ff28fe9e727332eef1407342fca52759cb21ad8177bb8d0"
url: "https://pub.dev"
source: hosted
version: "2.3.2"
version: "17.1.0"
hive:
dependency: "direct main"
description:
@@ -544,38 +403,46 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.1.0"
hive_generator:
dependency: "direct dev"
hooks:
dependency: transitive
description:
name: hive_generator
sha256: "06cb8f58ace74de61f63500564931f9505368f45f98958bd7a6c35ba24159db4"
name: hooks
sha256: "7a08a0d684cb3b8fb604b78455d5d352f502b68079f7b80b831c62220ab0a4f6"
url: "https://pub.dev"
source: hosted
version: "2.0.1"
http:
version: "1.0.1"
html:
dependency: transitive
description:
name: html
sha256: "6d1264f2dffa1b1101c25a91dff0dc2daee4c18e87cd8538729773c073dbf602"
url: "https://pub.dev"
source: hosted
version: "0.15.6"
http:
dependency: "direct main"
description:
name: http
sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412"
url: "https://pub.dev"
source: hosted
version: "1.6.0"
http_multi_server:
dependency: transitive
description:
name: http_multi_server
sha256: aa6199f908078bb1c5efb8d8638d4ae191aac11b311132c3ef48ce352fb52ef8
url: "https://pub.dev"
source: hosted
version: "3.2.2"
http_parser:
dependency: transitive
dependency: "direct main"
description:
name: http_parser
sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571"
url: "https://pub.dev"
source: hosted
version: "4.1.2"
image:
dependency: "direct main"
description:
name: image
sha256: "492bd52f6c4fbb6ee41f781ff27765ce5f627910e1e0cbecfa3d9add5562604c"
url: "https://pub.dev"
source: hosted
version: "4.7.2"
infinite_scroll_pagination:
dependency: "direct main"
description:
@@ -588,18 +455,10 @@ packages:
dependency: "direct main"
description:
name: injectable
sha256: "8fc24421cfeff76d1d38484d8b9617beeb54a58b6edfd002b10cc896b8b8f3fe"
sha256: "32b36a9d87f18662bee0b1951b81f47a01f2bf28cd6ea94f60bc5453c7bf598c"
url: "https://pub.dev"
source: hosted
version: "2.7.1+2"
injectable_generator:
dependency: "direct dev"
description:
name: injectable_generator
sha256: af403d76c7b18b4217335e0075e950cd0579fd7f8d7bd47ee7c85ada31680ba1
url: "https://pub.dev"
source: hosted
version: "2.6.2"
version: "2.7.1+4"
intl:
dependency: "direct main"
description:
@@ -608,14 +467,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.20.2"
io:
dependency: transitive
description:
name: io
sha256: dfd5a80599cf0165756e3181807ed3e77daf6dd4137caaad72d0b7931597650b
url: "https://pub.dev"
source: hosted
version: "1.0.5"
irondash_engine_context:
dependency: transitive
description:
@@ -632,62 +483,38 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.7.0"
js:
just_audio:
dependency: "direct main"
description:
name: just_audio
sha256: "9694e4734f515f2a052493d1d7e0d6de219ee0427c7c29492e246ff32a219908"
url: "https://pub.dev"
source: hosted
version: "0.10.5"
just_audio_platform_interface:
dependency: transitive
description:
name: js
sha256: "53385261521cc4a0c4658fd0ad07a7d14591cf8fc33abbceae306ddb974888dc"
name: just_audio_platform_interface
sha256: "2532c8d6702528824445921c5ff10548b518b13f808c2e34c2fd54793b999a6a"
url: "https://pub.dev"
source: hosted
version: "0.7.2"
json_annotation:
dependency: transitive
version: "4.6.0"
just_audio_web:
dependency: "direct main"
description:
name: json_annotation
sha256: "1ce844379ca14835a50d2f019a3099f419082cfdd231cd86a142af94dd5c6bb1"
name: just_audio_web
sha256: "6ba8a2a7e87d57d32f0f7b42856ade3d6a9fbe0f1a11fabae0a4f00bb73f0663"
url: "https://pub.dev"
source: hosted
version: "4.9.0"
json_serializable:
dependency: "direct dev"
description:
name: json_serializable
sha256: ea1432d167339ea9b5bb153f0571d0039607a873d6e04e0117af043f14a1fd4b
url: "https://pub.dev"
source: hosted
version: "6.8.0"
leak_tracker:
dependency: transitive
description:
name: leak_tracker
sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de"
url: "https://pub.dev"
source: hosted
version: "11.0.2"
leak_tracker_flutter_testing:
dependency: transitive
description:
name: leak_tracker_flutter_testing
sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1"
url: "https://pub.dev"
source: hosted
version: "3.0.10"
leak_tracker_testing:
dependency: transitive
description:
name: leak_tracker_testing
sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1"
url: "https://pub.dev"
source: hosted
version: "3.0.2"
version: "0.4.16"
lints:
dependency: transitive
description:
name: lints
sha256: a5e2b223cb7c9c8efdc663ef484fdd95bb243bff242ef5b13e26883547fce9a0
sha256: c35bb79562d980e9a453fc715854e1ed39e24e7d0297a880ef54e17f9874a9d7
url: "https://pub.dev"
source: hosted
version: "6.0.0"
version: "5.1.1"
logger:
dependency: "direct main"
description:
@@ -704,14 +531,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.3.0"
matcher:
dependency: transitive
description:
name: matcher
sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2
url: "https://pub.dev"
source: hosted
version: "0.12.17"
material_color_utilities:
dependency: transitive
description:
@@ -736,22 +555,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.0.0"
mockito:
dependency: "direct dev"
description:
name: mockito
sha256: "6841eed20a7befac0ce07df8116c8b8233ed1f4486a7647c7fc5a02ae6163917"
url: "https://pub.dev"
source: hosted
version: "5.4.4"
mocktail:
native_toolchain_c:
dependency: transitive
description:
name: mocktail
sha256: "890df3f9688106f25755f26b1c60589a92b3ab91a22b8b224947ad041bf172d8"
name: native_toolchain_c
sha256: "89e83885ba09da5fdf2cdacc8002a712ca238c28b7f717910b34bcd27b0d03ac"
url: "https://pub.dev"
source: hosted
version: "1.0.4"
version: "0.17.4"
nested:
dependency: transitive
description:
@@ -768,14 +579,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.5.0"
node_preamble:
objective_c:
dependency: transitive
description:
name: node_preamble
sha256: "6e7eac89047ab8a8d26cf16127b5ed26de65209847630400f9aefd7cd5c730db"
name: objective_c
sha256: "100a1c87616ab6ed41ec263b083c0ef3261ee6cd1dc3b0f35f8ddfa4f996fe52"
url: "https://pub.dev"
source: hosted
version: "2.0.2"
version: "9.3.0"
octo_image:
dependency: transitive
description:
@@ -784,14 +595,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.1.0"
package_config:
dependency: transitive
description:
name: package_config
sha256: f096c55ebb7deb7e384101542bfba8c52696c1b56fca2eb62827989ef2353bbc
url: "https://pub.dev"
source: hosted
version: "2.2.0"
path:
dependency: "direct main"
description:
@@ -828,10 +631,10 @@ packages:
dependency: transitive
description:
name: path_provider_foundation
sha256: "6d13aece7b3f5c5a9731eaf553ff9dcbc2eff41087fd2df587fd0fed9a3eb0c4"
sha256: "2a376b7d6392d80cd3705782d2caa734ca4727776db0b6ec36ef3f1855197699"
url: "https://pub.dev"
source: hosted
version: "2.5.1"
version: "2.6.0"
path_provider_linux:
dependency: transitive
description:
@@ -888,14 +691,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.1.8"
pool:
posix:
dependency: transitive
description:
name: pool
sha256: "978783255c543aa3586a1b3c21f6e9d720eb315376a915872c61ef8b5c20177d"
name: posix
sha256: "6323a5b0fa688b6a010df4905a56b00181479e6d10534cecfecede2aa55add61"
url: "https://pub.dev"
source: hosted
version: "1.5.2"
version: "6.0.3"
provider:
dependency: "direct main"
description:
@@ -912,22 +715,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.2.0"
pubspec_parse:
dependency: transitive
description:
name: pubspec_parse
sha256: "0560ba233314abbed0a48a2956f7f022cce7c3e1e73df540277da7544cad4082"
url: "https://pub.dev"
source: hosted
version: "1.5.0"
recase:
dependency: transitive
description:
name: recase
sha256: e4eb4ec2dcdee52dcf99cb4ceabaffc631d7424ee55e56f280bc039737f89213
url: "https://pub.dev"
source: hosted
version: "4.1.0"
rxdart:
dependency: transitive
description:
@@ -948,10 +735,10 @@ packages:
dependency: transitive
description:
name: shared_preferences_android
sha256: "83af5c682796c0f7719c2bbf74792d113e40ae97981b8f266fa84574573556bc"
sha256: cbc40be9be1c5af4dab4d6e0de4d5d3729e6f3d65b89d21e1815d57705644a6f
url: "https://pub.dev"
source: hosted
version: "2.4.18"
version: "2.4.20"
shared_preferences_foundation:
dependency: transitive
description:
@@ -992,38 +779,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.4.1"
shelf:
dependency: transitive
description:
name: shelf
sha256: e7dd780a7ffb623c57850b33f43309312fc863fb6aa3d276a754bb299839ef12
url: "https://pub.dev"
source: hosted
version: "1.4.2"
shelf_packages_handler:
dependency: transitive
description:
name: shelf_packages_handler
sha256: "89f967eca29607c933ba9571d838be31d67f53f6e4ee15147d5dc2934fee1b1e"
url: "https://pub.dev"
source: hosted
version: "3.0.2"
shelf_static:
dependency: transitive
description:
name: shelf_static
sha256: c87c3875f91262785dade62d135760c2c69cb217ac759485334c5857ad89f6e3
url: "https://pub.dev"
source: hosted
version: "1.1.3"
shelf_web_socket:
dependency: transitive
description:
name: shelf_web_socket
sha256: cc36c297b52866d203dbf9332263c94becc2fe0ceaa9681d07b6ef9807023b67
url: "https://pub.dev"
source: hosted
version: "2.0.1"
sky_engine:
dependency: transitive
description: flutter
@@ -1037,46 +792,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.2.12"
source_gen:
dependency: transitive
description:
name: source_gen
sha256: "14658ba5f669685cd3d63701d01b31ea748310f7ab854e471962670abcf57832"
url: "https://pub.dev"
source: hosted
version: "1.5.0"
source_helper:
dependency: transitive
description:
name: source_helper
sha256: "86d247119aedce8e63f4751bd9626fc9613255935558447569ad42f9f5b48b3c"
url: "https://pub.dev"
source: hosted
version: "1.3.5"
source_map_stack_trace:
dependency: transitive
description:
name: source_map_stack_trace
sha256: c0713a43e323c3302c2abe2a1cc89aa057a387101ebd280371d6a6c9fa68516b
url: "https://pub.dev"
source: hosted
version: "2.1.2"
source_maps:
dependency: transitive
description:
name: source_maps
sha256: "190222579a448b03896e0ca6eca5998fa810fda630c1d65e2f78b3f638f54812"
url: "https://pub.dev"
source: hosted
version: "0.10.13"
source_span:
dependency: transitive
description:
name: source_span
sha256: "254ee5351d6cb365c859e20ee823c3bb479bf4a293c22d17a9f1bf144ce86f7c"
sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab"
url: "https://pub.dev"
source: hosted
version: "1.10.1"
version: "1.10.2"
sqflite:
dependency: transitive
description:
@@ -1117,30 +840,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.4.0"
stack_trace:
dependency: transitive
description:
name: stack_trace
sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1"
url: "https://pub.dev"
source: hosted
version: "1.12.1"
stream_channel:
dependency: transitive
description:
name: stream_channel
sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d"
url: "https://pub.dev"
source: hosted
version: "2.1.4"
stream_transform:
dependency: transitive
description:
name: stream_transform
sha256: ad47125e588cfd37a9a7f86c7d6356dde8dfe89d071d293f80ca9e9273a33871
url: "https://pub.dev"
source: hosted
version: "2.1.1"
string_scanner:
dependency: transitive
description:
@@ -1166,7 +865,7 @@ packages:
source: hosted
version: "0.9.1"
syncfusion_flutter_core:
dependency: transitive
dependency: "direct main"
description:
name: syncfusion_flutter_core
sha256: e1fdfcc3ed7e1f040ba95838780b2eb1857e3e5eccb817fbe94ea2b09c35eac4
@@ -1253,38 +952,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.2.2"
test:
dependency: transitive
description:
name: test
sha256: "75906bf273541b676716d1ca7627a17e4c4070a3a16272b7a3dc7da3b9f3f6b7"
url: "https://pub.dev"
source: hosted
version: "1.26.3"
test_api:
dependency: transitive
description:
name: test_api
sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55
url: "https://pub.dev"
source: hosted
version: "0.7.7"
test_core:
dependency: transitive
description:
name: test_core
sha256: "0cc24b5ff94b38d2ae73e1eb43cc302b77964fbf67abad1e296025b78deb53d0"
url: "https://pub.dev"
source: hosted
version: "0.6.12"
timing:
dependency: transitive
description:
name: timing
sha256: "62ee18aca144e4a9f29d212f5a4c6a053be252b895ab14b5821996cff4ed90fe"
url: "https://pub.dev"
source: hosted
version: "1.0.2"
typed_data:
dependency: transitive
description:
@@ -1302,7 +969,7 @@ packages:
source: hosted
version: "1.1.0"
url_launcher:
dependency: transitive
dependency: "direct main"
description:
name: url_launcher
sha256: f6a7e5c4835bb4e3026a04793a4199ca2d14c739ec378fdfe23fc8075d0439f8
@@ -1353,10 +1020,10 @@ packages:
dependency: transitive
description:
name: url_launcher_web
sha256: "4bd2b7b4dc4d4d0b94e5babfffbca8eac1a126c7f3d6ecbc1a11013faa3abba2"
sha256: d0412fcf4c6b31ecfdb7762359b7206ffba3bbffd396c6d9f9c4616ece476c1f
url: "https://pub.dev"
source: hosted
version: "2.4.1"
version: "2.4.2"
url_launcher_windows:
dependency: transitive
description:
@@ -1393,10 +1060,10 @@ packages:
dependency: transitive
description:
name: vector_graphics_compiler
sha256: d354a7ec6931e6047785f4db12a1f61ec3d43b207fc0790f863818543f8ff0dc
sha256: "201e876b5d52753626af64b6359cd13ac6011b80728731428fd34bc840f71c9b"
url: "https://pub.dev"
source: hosted
version: "1.1.19"
version: "1.1.20"
vector_math:
dependency: transitive
description:
@@ -1405,54 +1072,54 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.2.0"
vm_service:
dependency: transitive
video_player:
dependency: "direct main"
description:
name: vm_service
sha256: "45caa6c5917fa127b5dbcfbd1fa60b14e583afdc08bfc96dda38886ca252eb60"
name: video_player
sha256: "096bc28ce10d131be80dfb00c223024eb0fba301315a406728ab43dd99c45bdf"
url: "https://pub.dev"
source: hosted
version: "15.0.2"
watcher:
version: "2.10.1"
video_player_android:
dependency: transitive
description:
name: watcher
sha256: f52385d4f73589977c80797e60fe51014f7f2b957b5e9a62c3f6ada439889249
name: video_player_android
sha256: ee4fd520b0cafa02e4a867a0f882092e727cdaa1a2d24762171e787f8a502b0a
url: "https://pub.dev"
source: hosted
version: "1.2.0"
version: "2.9.1"
video_player_avfoundation:
dependency: transitive
description:
name: video_player_avfoundation
sha256: f46e9e20f1fe429760cf4dc118761336320d1bec0f50d255930c2355f2defb5b
url: "https://pub.dev"
source: hosted
version: "2.9.1"
video_player_platform_interface:
dependency: transitive
description:
name: video_player_platform_interface
sha256: "57c5d73173f76d801129d0531c2774052c5a7c11ccb962f1830630decd9f24ec"
url: "https://pub.dev"
source: hosted
version: "6.6.0"
video_player_web:
dependency: transitive
description:
name: video_player_web
sha256: "9f3c00be2ef9b76a95d94ac5119fb843dca6f2c69e6c9968f6f2b6c9e7afbdeb"
url: "https://pub.dev"
source: hosted
version: "2.4.0"
web:
dependency: transitive
dependency: "direct main"
description:
name: web
sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a"
url: "https://pub.dev"
source: hosted
version: "1.1.1"
web_socket:
dependency: transitive
description:
name: web_socket
sha256: "34d64019aa8e36bf9842ac014bb5d2f5586ca73df5e4d9bf5c936975cae6982c"
url: "https://pub.dev"
source: hosted
version: "1.0.1"
web_socket_channel:
dependency: transitive
description:
name: web_socket_channel
sha256: d645757fb0f4773d602444000a8131ff5d48c9e47adfe9772652dd1a4f2d45c8
url: "https://pub.dev"
source: hosted
version: "3.0.3"
webkit_inspection_protocol:
dependency: transitive
description:
name: webkit_inspection_protocol
sha256: "87d3f2333bb240704cd3f1c6b5b7acd8a10e7f0bc28c28dcf14e782014f4a572"
url: "https://pub.dev"
source: hosted
version: "1.2.1"
win32:
dependency: transitive
description:
@@ -1495,4 +1162,4 @@ packages:
version: "3.1.3"
sdks:
dart: ">=3.10.4 <4.0.0"
flutter: ">=3.35.1"
flutter: ">=3.38.4"

View File

@@ -1,5 +1,5 @@
name: b0esche_cloud
description: "A new Flutter project."
name: b0esche
description: "b0esche secure cloud"
publish_to: "none"
version: 0.1.0
@@ -16,6 +16,7 @@ dependencies:
# Networking
dio: ^5.3.2
http_parser: ^4.0.2
# Routing
go_router: ^17.0.1
@@ -48,6 +49,7 @@ dependencies:
path_provider: ^2.1.2
connectivity_plus: ^7.0.0
provider: ^6.1.1
url_launcher: ^6.2.2
file_picker: ^10.3.7
flutter_dropzone: ^4.0.0
desktop_drop: ^0.7.0
@@ -55,27 +57,28 @@ dependencies:
infinite_scroll_pagination: ^5.1.1
collection: ^1.18.0
syncfusion_flutter_pdfviewer: ^31.1.21
web: ^1.1.0
http: ^1.2.0
archive: ^4.0.4
# Video Playback
video_player: ^2.8.2
syncfusion_flutter_core: ^31.2.18
just_audio_web: ^0.4.16
just_audio: ^0.10.5
flutter_web_plugins:
sdk: flutter
image: ^4.7.2
dev_dependencies:
flutter_test:
sdk: flutter
flutter_lints: ^6.0.0
# Code Generation
build_runner: ^2.4.6
json_serializable: ^6.7.1
injectable_generator: ^2.4.1
hive_generator: ^2.0.1
# Testing
mockito: ^5.4.4
bloc_test: ^10.0.0
flutter_lints: ^5.0.0
flutter:
uses-material-design: true
assets:
- assets/fonts/
- assets/icons/.
fonts:
- family: PixelatedElegance

View File

@@ -1,117 +0,0 @@
import 'package:bloc_test/bloc_test.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mockito/mockito.dart';
import 'package:b0esche_cloud/blocs/document_viewer/document_viewer_bloc.dart';
import 'package:b0esche_cloud/blocs/document_viewer/document_viewer_event.dart';
import 'package:b0esche_cloud/blocs/document_viewer/document_viewer_state.dart';
import 'package:b0esche_cloud/services/file_service.dart';
import 'package:b0esche_cloud/models/viewer_session.dart';
import 'package:b0esche_cloud/models/document_capabilities.dart';
import 'package:b0esche_cloud/models/api_error.dart';
class MockFileService extends Mock implements FileService {
Future<ViewerSession>? _viewerResponse;
void setViewerResponse(Future<ViewerSession> response) {
_viewerResponse = response;
}
void resetMock() {
_viewerResponse = null;
}
@override
Future<ViewerSession> requestViewerSession(String orgId, String fileId) {
return _viewerResponse ??
super.noSuchMethod(
Invocation.method(#requestViewerSession, [orgId, fileId]),
returnValue: Future.value(null),
);
}
}
// @override
// Future<ViewerSession> requestViewerSession(String orgId, String fileId) {
// return _viewerResponse ??
// super.noSuchMethod(
// Invocation.method(#requestViewerSession, [orgId, fileId]),
// returnValue: Future.value(null),
// );
// }
// }
void main() {
late MockFileService mockFileService;
setUp(() {
mockFileService = MockFileService();
});
tearDown(() {
reset(mockFileService);
mockFileService.resetMock();
});
group('DocumentViewerBloc', () {
blocTest<DocumentViewerBloc, DocumentViewerState>(
'emits [DocumentViewerLoading, DocumentViewerError] when DocumentOpened fails',
build: () {
mockFileService.setViewerResponse(
Future.error(
ApiError(
code: 'server_error',
message: 'Server error',
status: 500,
),
),
);
return DocumentViewerBloc(mockFileService);
},
act: (bloc) => bloc.add(DocumentOpened(orgId: 'org1', fileId: 'file1')),
expect: () => [
DocumentViewerLoading(),
DocumentViewerReady(
viewUrl: Uri.parse('https://example.com/view'),
caps: DocumentCapabilities(
canEdit: true,
canAnnotate: false,
isPdf: false,
),
),
],
);
blocTest<DocumentViewerBloc, DocumentViewerState>(
'emits [DocumentViewerLoading, DocumentViewerReady] when DocumentOpened succeeds',
build: () {
mockFileService.setViewerResponse(
Future.value(
ViewerSession(
viewUrl: Uri.parse('https://example.com/view'),
capabilities: DocumentCapabilities(
canEdit: true,
canAnnotate: false,
isPdf: false,
),
token: 'mock-token',
expiresAt: DateTime.now().add(const Duration(minutes: 30)),
),
),
);
return DocumentViewerBloc(mockFileService);
},
act: (bloc) => bloc.add(DocumentOpened(orgId: 'org1', fileId: 'file1')),
expect: () => [
DocumentViewerLoading(),
DocumentViewerError(message: 'Failed to open document: Server error'),
],
);
blocTest<DocumentViewerBloc, DocumentViewerState>(
'emits [DocumentViewerInitial] when DocumentClosed',
build: () => DocumentViewerBloc(mockFileService),
act: (bloc) => bloc.add(DocumentClosed()),
expect: () => [DocumentViewerInitial()],
);
});
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 917 B

After

Width:  |  Height:  |  Size: 1008 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.1 KiB

After

Width:  |  Height:  |  Size: 147 KiB

View File

@@ -19,7 +19,7 @@
<meta charset="UTF-8">
<meta content="IE=Edge" http-equiv="X-UA-Compatible">
<meta name="description" content="A new Flutter project.">
<meta name="description" content="b0esche secure cloud">
<!-- iOS meta tags & icons -->
<meta name="mobile-web-app-capable" content="yes">
@@ -30,31 +30,42 @@
<!-- Favicon -->
<link rel="icon" type="image/png" href="favicon.png" />
<!-- Preload fonts -->
<link rel="preload" href="assets/fonts/veteran-typewriter/veteran_typewriter.ttf" as="font" type="font/ttf"
crossorigin>
<link rel="preload" href="assets/fonts/animal-park/animal_park.otf" as="font" type="font/otf" crossorigin>
<link rel="preload" href="assets/fonts/renoire-demo/renoire_demo.otf" as="font" type="font/otf" crossorigin>
<!-- Preload PixelatedElegance brand font -->
<link rel="preload" href="assets/fonts/pixelated-elegance/PixelatedEleganceRegular-ovyAA.ttf" as="font"
type="font/ttf" crossorigin>
<style>
@font-face {
font-family: 'VeteranTypewriter';
src: url('assets/fonts/veteran-typewriter/veteran_typewriter.ttf') format('truetype');
}
@font-face {
font-family: 'AnimalPark';
src: url('assets/fonts/animal-park/animal_park.otf') format('opentype');
}
@font-face {
font-family: 'RenoireDemo';
src: url('assets/fonts/renoire-demo/renoire_demo.otf') format('opentype');
font-family: 'PixelatedElegance';
src: url('assets/fonts/pixelated-elegance/PixelatedEleganceRegular-ovyAA.ttf') format('truetype');
}
</style>
<title>b0esche_cloud</title>
<link rel="manifest" href="manifest.json">
<!-- PDF.js library for SfPdfViewer on web - loaded asynchronously to avoid sync XHR warnings -->
<script type="module">
(async () => {
const pdfjsLib = await import('https://cdnjs.cloudflare.com/ajax/libs/pdf.js/4.9.155/pdf.min.mjs');
pdfjsLib.GlobalWorkerOptions.workerSrc = "https://cdnjs.cloudflare.com/ajax/libs/pdf.js/4.9.155/pdf.worker.min.mjs";
window.pdfjsLib = pdfjsLib;
})();
</script>
<!-- Suppress v8BreakIterator deprecation warning (Flutter framework issue) -->
<script>
// This is a known Flutter web issue - the framework uses v8BreakIterator for feature detection
// The warning can be safely ignored as Flutter handles the fallback to Intl.Segmenter internally
// See: https://github.com/nickvds/my-public/issues
const originalWarn = console.warn;
console.warn = function (...args) {
if (args[0] && typeof args[0] === 'string' && args[0].includes('v8BreakIterator')) {
return; // Suppress this specific warning
}
originalWarn.apply(console, args);
};
</script>
</head>
<body>

View File

@@ -1,11 +1,11 @@
{
"name": "b0esche_cloud",
"short_name": "b0esche_cloud",
"name": "b0esche.cloud",
"short_name": "b0esche",
"start_url": ".",
"display": "standalone",
"background_color": "#0175C2",
"theme_color": "#0175C2",
"description": "A new Flutter project.",
"description": "b0esche secure cloud",
"orientation": "portrait-primary",
"prefer_related_applications": false,
"icons": [
@@ -32,4 +32,4 @@
"purpose": "maskable"
}
]
}
}

View File

@@ -1,6 +1,6 @@
# Project-level configuration.
cmake_minimum_required(VERSION 3.14)
project(b0esche_cloud LANGUAGES CXX)
project(b0esche.cloud LANGUAGES CXX)
# The name of the executable created for the application. Change this to change
# the on-disk name of your application.

596
docs/API.md Normal file
View File

@@ -0,0 +1,596 @@
# b0esche.cloud API Reference
Base URL: `https://go.b0esche.cloud`
## Authentication
All authenticated endpoints require a JWT token in the Authorization header:
```
Authorization: Bearer <token>
```
---
## Health Check
### GET /health
Check if the API is running.
**Response:**
```json
{
"status": "ok",
"timestamp": "2026-01-13T19:00:00Z"
}
```
---
## Authentication Endpoints
### Passkey Registration
#### POST /auth/passkey/register/start
Start passkey registration for a new user.
**Request Body:**
```json
{
"username": "johndoe"
}
```
**Response:**
```json
{
"publicKey": {
"challenge": "base64-encoded-challenge",
"rp": {
"name": "b0esche.cloud",
"id": "www.b0esche.cloud"
},
"user": {
"id": "base64-user-id",
"name": "johndoe",
"displayName": "johndoe"
},
"pubKeyCredParams": [...],
"timeout": 300000,
"attestation": "none"
}
}
```
#### POST /auth/passkey/register/verify
Complete passkey registration.
**Request Body:**
```json
{
"username": "johndoe",
"credential": {
"id": "credential-id",
"rawId": "base64-raw-id",
"type": "public-key",
"response": {
"clientDataJSON": "base64-client-data",
"attestationObject": "base64-attestation"
}
}
}
```
**Response:**
```json
{
"user": {
"id": "uuid",
"username": "johndoe"
},
"token": "jwt-token"
}
```
### Passkey Login
#### POST /auth/passkey/login/start
Start passkey authentication.
**Request Body:**
```json
{
"username": "johndoe"
}
```
**Response:**
```json
{
"publicKey": {
"challenge": "base64-challenge",
"timeout": 300000,
"rpId": "www.b0esche.cloud",
"allowCredentials": [...]
}
}
```
#### POST /auth/passkey/login/verify
Complete passkey authentication.
**Request Body:**
```json
{
"username": "johndoe",
"credential": {
"id": "credential-id",
"rawId": "base64-raw-id",
"type": "public-key",
"response": {
"clientDataJSON": "base64-client-data",
"authenticatorData": "base64-auth-data",
"signature": "base64-signature"
}
}
}
```
**Response:**
```json
{
"user": {
"id": "uuid",
"username": "johndoe",
"role": "user"
},
"token": "jwt-token"
}
```
### Device Management
#### GET /auth/passkey/devices
List user's registered passkeys.
**Response:**
```json
{
"devices": [
{
"id": "uuid",
"credentialId": "credential-id",
"deviceLabel": "MacBook Pro",
"createdAt": "2026-01-01T00:00:00Z",
"lastUsedAt": "2026-01-13T19:00:00Z",
"backupEligible": true
}
]
}
```
#### POST /auth/passkey/devices/add
Add a new passkey to existing account.
#### DELETE /auth/passkey/devices/{passkeyId}
Remove a passkey from account.
### Recovery Codes
#### POST /auth/recovery/codes/generate
Generate new recovery codes (invalidates old ones).
**Response:**
```json
{
"codes": [
"XXXX-XXXX-XXXX",
"YYYY-YYYY-YYYY",
...
],
"expiresAt": "2027-01-13T00:00:00Z"
}
```
#### POST /auth/recovery/codes/use
Use a recovery code to authenticate.
**Request Body:**
```json
{
"username": "johndoe",
"code": "XXXX-XXXX-XXXX"
}
```
### Password (Optional Fallback)
#### POST /auth/password/add
Add password to account.
**Request Body:**
```json
{
"password": "secure-password"
}
```
#### DELETE /auth/password/remove
Remove password from account.
---
## User Endpoints
### GET /api/me
Get current user profile.
**Response:**
```json
{
"id": "uuid",
"username": "johndoe",
"email": "john@example.com",
"displayName": "John Doe",
"role": "user",
"createdAt": "2026-01-01T00:00:00Z"
}
```
### PATCH /api/me
Update user profile.
**Request Body:**
```json
{
"displayName": "John D.",
"email": "newemail@example.com"
}
```
---
## Organization Endpoints
### GET /api/organizations
List user's organizations.
**Response:**
```json
{
"organizations": [
{
"id": "uuid",
"name": "My Team",
"slug": "my-team",
"role": "owner",
"memberCount": 5,
"createdAt": "2026-01-01T00:00:00Z"
}
]
}
```
### POST /api/organizations
Create a new organization.
**Request Body:**
```json
{
"name": "My New Team",
"slug": "my-new-team"
}
```
### GET /api/organizations/{orgId}
Get organization details.
### PATCH /api/organizations/{orgId}
Update organization.
### DELETE /api/organizations/{orgId}
Delete organization (owner only).
### GET /api/organizations/{orgId}/members
List organization members.
**Response:**
```json
{
"members": [
{
"id": "uuid",
"userId": "user-uuid",
"username": "johndoe",
"displayName": "John Doe",
"role": "owner",
"joinedAt": "2026-01-01T00:00:00Z"
}
]
}
```
### POST /api/organizations/{orgId}/members
Add member to organization.
**Request Body:**
```json
{
"userId": "user-uuid",
"role": "member"
}
```
### PATCH /api/organizations/{orgId}/members/{memberId}
Update member role.
### DELETE /api/organizations/{orgId}/members/{memberId}
Remove member from organization.
---
## File Endpoints
### GET /api/files
List files in a directory.
**Query Parameters:**
| Parameter | Type | Description |
|-----------|------|-------------|
| `path` | string | Directory path (default: `/`) |
| `orgId` | string | Organization ID (optional) |
**Response:**
```json
{
"files": [
{
"id": "uuid",
"name": "document.pdf",
"path": "/documents/document.pdf",
"type": "file",
"mimeType": "application/pdf",
"size": 1048576,
"createdAt": "2026-01-01T00:00:00Z",
"modifiedAt": "2026-01-13T19:00:00Z"
},
{
"id": "uuid",
"name": "photos",
"path": "/photos",
"type": "folder",
"createdAt": "2026-01-01T00:00:00Z"
}
]
}
```
### POST /api/files/upload
Upload a file.
**Request:** `multipart/form-data`
| Field | Type | Description |
|-------|------|-------------|
| `file` | file | The file to upload |
| `path` | string | Destination path |
| `orgId` | string | Organization ID (optional) |
**Response:**
```json
{
"file": {
"id": "uuid",
"name": "uploaded-file.pdf",
"path": "/documents/uploaded-file.pdf",
"size": 1048576
}
}
```
### GET /api/files/download
Download a file.
**Query Parameters:**
| Parameter | Type | Description |
|-----------|------|-------------|
| `path` | string | File path |
| `orgId` | string | Organization ID (optional) |
**Response:** File binary with appropriate Content-Type header.
### POST /api/files/folder
Create a folder.
**Request Body:**
```json
{
"path": "/new-folder",
"orgId": "org-uuid"
}
```
### DELETE /api/files
Delete a file or folder.
**Query Parameters:**
| Parameter | Type | Description |
|-----------|------|-------------|
| `path` | string | Path to delete |
| `orgId` | string | Organization ID (optional) |
### POST /api/files/move
Move/rename a file or folder.
**Request Body:**
```json
{
"sourcePath": "/old-name.pdf",
"destinationPath": "/new-name.pdf",
"orgId": "org-uuid"
}
```
### POST /api/files/copy
Copy a file or folder.
**Request Body:**
```json
{
"sourcePath": "/original.pdf",
"destinationPath": "/copy.pdf",
"orgId": "org-uuid"
}
```
---
## Admin Endpoints
*Requires admin or superadmin role.*
### GET /api/admin/users
List all users.
**Query Parameters:**
| Parameter | Type | Description |
|-----------|------|-------------|
| `page` | int | Page number (default: 1) |
| `limit` | int | Items per page (default: 50) |
| `search` | string | Search by username/email |
### GET /api/admin/users/{userId}
Get user details.
### PATCH /api/admin/users/{userId}
Update user (role, status).
### DELETE /api/admin/users/{userId}
Delete user account.
### Admin Invitations
#### GET /auth/admin/invitations
List admin invitations.
#### POST /auth/admin/invitations
Create admin invitation.
**Request Body:**
```json
{
"username": "newadmin",
"roleId": "admin-role-uuid",
"expiresIn": 86400
}
```
**Response:**
```json
{
"invitation": {
"id": "uuid",
"token": "invite-token",
"expiresAt": "2026-01-14T19:00:00Z"
}
}
```
#### POST /auth/admin/invitations/{token}/accept
Accept an admin invitation.
#### DELETE /auth/admin/invitations/{token}
Revoke an invitation.
---
## Activity Endpoints
### GET /api/activities
Get activity log.
**Query Parameters:**
| Parameter | Type | Description |
|-----------|------|-------------|
| `page` | int | Page number |
| `limit` | int | Items per page |
| `orgId` | string | Filter by organization |
| `userId` | string | Filter by user |
| `action` | string | Filter by action type |
**Response:**
```json
{
"activities": [
{
"id": "uuid",
"userId": "user-uuid",
"username": "johndoe",
"action": "file.upload",
"resourceType": "file",
"resourceId": "/documents/report.pdf",
"metadata": {
"size": 1048576
},
"createdAt": "2026-01-13T19:00:00Z"
}
],
"pagination": {
"page": 1,
"limit": 50,
"total": 150
}
}
```
---
## Error Responses
All errors follow this format:
```json
{
"error": {
"code": "ERROR_CODE",
"message": "Human-readable error message",
"details": {}
}
}
```
### Common Error Codes
| Code | HTTP Status | Description |
|------|-------------|-------------|
| `UNAUTHORIZED` | 401 | Missing or invalid token |
| `FORBIDDEN` | 403 | Insufficient permissions |
| `NOT_FOUND` | 404 | Resource not found |
| `VALIDATION_ERROR` | 400 | Invalid request data |
| `CONFLICT` | 409 | Resource already exists |
| `INTERNAL_ERROR` | 500 | Server error |
---
## Rate Limiting
- **Authentication endpoints**: 10 requests/minute
- **API endpoints**: 100 requests/minute
- **File uploads**: 50 requests/hour
Rate limit headers:
```
X-RateLimit-Limit: 100
X-RateLimit-Remaining: 95
X-RateLimit-Reset: 1705172400
```
---
## Webhooks (Future)
Planned webhook events:
- `user.created`
- `user.deleted`
- `file.uploaded`
- `file.deleted`
- `org.member.added`
- `org.member.removed`

367
docs/ARCHITECTURE.md Normal file
View File

@@ -0,0 +1,367 @@
# b0esche.cloud Architecture
## System Overview
b0esche.cloud is a self-hosted cloud storage platform inspired by Google Workspace, built with a modern microservices-style architecture.
## High-Level Architecture
```
┌─────────────────────────────────────┐
│ Internet │
└─────────────────┬───────────────────┘
┌─────────────────────────────────────────────────────────────────────────────────────┐
│ Traefik Reverse Proxy │
│ (SSL Termination, Routing, Load Balancing) │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
│ │ www.* │ │ go.* │ │ storage.* │ │ of.* │ │
│ └──────┬──────┘ └──────┬──────┘ └──────┬──────┘ └──────┬──────┘ │
└─────────┼────────────────┼────────────────┼────────────────┼────────────────────────┘
│ │ │ │
▼ ▼ ▼ ▼
┌──────────────┐ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ Flutter Web │ │ Go Backend │ │ Nextcloud │ │ Collabora │
│ (Nginx) │ │ (API) │ │ (Storage) │ │ (Office) │
└──────────────┘ └──────┬───────┘ └──────────────┘ └──────────────┘
┌──────────────┐
│ PostgreSQL │
│ (Database) │
└──────────────┘
```
## Components
### 1. Flutter Web Frontend (`b0esche_cloud/`)
The user-facing web application built with Flutter.
**Technology Stack:**
- Flutter 3.x with Dart
- BLoC pattern for state management
- Material Design 3 theming
**Key Modules:**
| Module | Purpose |
|--------|---------|
| `blocs/` | Business logic components (auth, files, orgs) |
| `models/` | Data models (User, File, Organization) |
| `pages/` | UI screens (Home, Files, Settings, Admin) |
| `repositories/` | Data access layer |
| `services/` | API client, WebAuthn service |
| `widgets/` | Reusable UI components |
**State Management Flow:**
```
User Action → BLoC Event → BLoC Logic → State Update → UI Rebuild
Repository
API Service
Go Backend
```
### 2. Go Backend (`go_cloud/`)
The API server handling business logic, authentication, and service orchestration.
**Technology Stack:**
- Go 1.21+
- Chi Router for HTTP routing
- sqlx for database access
- go-webauthn for passkey authentication
**Key Packages:**
| Package | Purpose |
|---------|---------|
| `internal/auth/` | Authentication (OIDC, Passkeys, Sessions) |
| `internal/files/` | File metadata and operations |
| `internal/org/` | Organization and membership management |
| `internal/storage/` | Nextcloud/WebDAV integration |
| `internal/http/` | HTTP handlers and WOPI endpoints |
| `internal/middleware/` | Auth, logging, CORS middleware |
| `pkg/jwt/` | JWT token utilities |
**Request Flow:**
```
HTTP Request → Traefik → Chi Router → Middleware → Handler → Service → Response
Database/Storage
```
### 3. PostgreSQL Database
Stores application metadata (not files).
**Key Tables:**
- `users` - User accounts and profiles
- `roles` - Permission roles (user, admin, superadmin)
- `passkeys` - WebAuthn credentials
- `organizations` - Org definitions
- `org_memberships` - User-org relationships
- `activities` - Audit log
**Schema Relationships:**
```
users ──┬── passkeys (1:N)
├── org_memberships (N:M) ── organizations
├── recovery_codes (1:N)
└── activities (1:N)
```
### 4. Nextcloud (Storage)
File storage backend and OIDC provider.
**Responsibilities:**
- File storage via WebDAV
- User authentication (OIDC)
- File sharing capabilities
- Version control
**Integration Points:**
- WebDAV API for file operations
- OIDC for authentication
- User provisioning sync
### 5. Collabora Online (Office)
Document editing service for Office files.
**Supported Formats:**
- Documents: DOCX, ODT, RTF
- Spreadsheets: XLSX, ODS, CSV
- Presentations: PPTX, ODP
**Integration:**
- WOPI protocol for document access
- Embedded iframe in Flutter app
### 6. Traefik (Reverse Proxy)
SSL termination and request routing.
**Features:**
- Automatic SSL via Let's Encrypt (DNS-01 challenge)
- Dynamic service discovery
- Load balancing
- Request routing based on hostname
## Data Flow
### Authentication Flow
```
┌────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐
│ Client │───▶│ Frontend │───▶│ Backend │───▶│ Database │
└────────┘ └──────────┘ └──────────┘ └──────────┘
│ │
│ 1. Username + Passkey │
│─────────────────────────────▶│
│ │
│ 2. WebAuthn Challenge │
│◀─────────────────────────────│
│ │
│ 3. Signed Challenge │
│─────────────────────────────▶│
│ │ 4. Verify Signature
│ │ 5. Create Session
│ 6. JWT Token │
│◀─────────────────────────────│
```
## Security Architecture
### Authentication & Authorization
- **Passkeys (WebAuthn)**: Primary authentication method using FIDO2/U2F security keys
- **JWT Tokens**: Session-based tokens with configurable expiration
- **Role-Based Access Control (RBAC)**: Owner, Admin, Member roles for organizations
- **Permission System**: Granular permissions for file operations (read, write, view, edit)
### Input Validation & Sanitization
- **Path Traversal Protection**: All file paths are sanitized to prevent directory traversal attacks
- **UUID Validation**: All resource IDs (users, orgs, files) are validated as proper UUIDs
- **JSON Schema Validation**: API inputs are validated for correct structure and types
### Network Security
- **HTTPS Only**: All external traffic is encrypted via TLS
- **CORS Policy**: Restricted to allowed origins with credentials support
- **Rate Limiting**: 100 requests/minute general, 10 requests/minute for auth endpoints
- **Security Headers**:
- `X-Content-Type-Options: nosniff`
- `X-Frame-Options: DENY` (except for WOPI/Collabora)
- `X-XSS-Protection: 1; mode=block`
- `Content-Security-Policy`: Restrictive policy allowing only necessary sources
- `Referrer-Policy: strict-origin-when-cross-origin`
### Data Protection
- **Encrypted Storage**: Files stored encrypted in Nextcloud
- **Secure Passwords**: Auto-generated secure passwords for Nextcloud user accounts
- **Audit Logging**: All operations logged with user/org context
- **No Secrets in Logs**: Sensitive data never logged
### API Security
- **Token Validation**: Every protected endpoint validates JWT tokens
- **Session Management**: Secure session handling with database-backed validation
- **Error Handling**: Safe error responses that don't leak internal details
### File Security
- **Scoped Access**: Users can only access files within their personal workspace or authorized organizations
- **Share Tokens**: Public shares use short-lived, single-use tokens
- **Nextcloud Integration**: Leverages Nextcloud's security features for file access
### Infrastructure Security
- **Container Security**: Docker images run as non-root where possible
- **Network Isolation**: Internal Docker networks prevent direct external access
- **Deployment Security**: Automated deployments with health checks
## Data Flow
### File Upload Flow
```
┌────────┐ ┌──────────┐ ┌──────────┐ ┌───────────┐
│ Client │───▶│ Frontend │───▶│ Backend │───▶│ Nextcloud │
└────────┘ └──────────┘ └──────────┘ └───────────┘
│ │ │
│ 1. Select File │ │
│─────────────────────────────▶│ │
│ │ │
│ │ 2. WebDAV PUT │
│ │───────────────▶│
│ │ │
│ │ 3. Success │
│ │◀───────────────│
│ │ │
│ │ 4. Save Metadata
│ │ (PostgreSQL) │
│ 5. Confirmation │ │
│◀─────────────────────────────│ │
```
## Network Architecture
### Docker Networks
```
┌─────────────────────────────────────────────────────────────┐
│ proxy (172.20.0.0/16) │
│ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ │
│ │ traefik │ │ flutter │ │ go │ │nextcloud│ │
│ │ │ │ web │ │ backend │ │ │ │
│ └─────────┘ └─────────┘ └────┬────┘ └─────────┘ │
└───────────────────────────────┼─────────────────────────────┘
┌───────────────────────────────┼─────────────────────────────┐
│ backend (internal) │
│ ┌────┴────┐ │
│ │postgres │ │
│ └─────────┘ │
└─────────────────────────────────────────────────────────────┘
```
### Port Mapping
| Service | Internal Port | External |
|---------|---------------|----------|
| Traefik | 80, 443 | Exposed |
| Flutter Web | 80 | Via Traefik |
| Go Backend | 8080 | Via Traefik |
| PostgreSQL | 5432 | Internal only |
| Nextcloud | 80 | Via Traefik |
| Collabora | 9980 | Via Traefik |
## Security Architecture
### Authentication Layers
1. **Primary**: WebAuthn Passkeys (FIDO2)
2. **Fallback**: Optional password authentication
3. **Legacy**: OIDC via Nextcloud (deprecated)
4. **Recovery**: One-time recovery codes
### Authorization Model
```
┌─────────────────────────────────────────────────────────────┐
│ Role Hierarchy │
│ │
│ superadmin (Level 3) │
│ ├── All system access │
│ ├── User management │
│ └── Can manage admins │
│ │ │
│ ▼ │
│ admin (Level 2) │
│ ├── Organization management │
│ ├── User role management (within orgs) │
│ └── Activity monitoring │
│ │ │
│ ▼ │
│ user (Level 1) │
│ ├── Personal file management │
│ ├── Organization membership │
│ └── Basic settings │
└─────────────────────────────────────────────────────────────┘
```
### Organization Roles
Within each organization:
- **Owner**: Full control, can delete org
- **Admin**: Can manage members and files
- **Member**: Read/write access to shared files
## Scalability Considerations
### Current Architecture (Single Server)
- All services on one VPS
- Suitable for small teams (< 100 users)
- Simple deployment and maintenance
### Future Scaling Options
1. **Database**: Read replicas, connection pooling
2. **Storage**: S3-compatible backends, CDN for static assets
3. **Backend**: Horizontal scaling with load balancer
4. **Frontend**: CDN distribution, edge caching
## Monitoring & Observability
### Logging
- **Traefik**: Access logs, error logs
- **Go Backend**: Structured JSON logs
- **PostgreSQL**: Query logs, slow query analysis
- **Docker**: Container logs via `docker logs`
### Health Checks
```bash
# Backend health
curl https://go.b0esche.cloud/health
# Frontend availability
curl -I https://www.b0esche.cloud
# Database connectivity
docker exec go-postgres pg_isready
```
### Metrics (Future)
- Prometheus for metrics collection
- Grafana for visualization
- AlertManager for alerting

279
docs/AUTH.md Normal file
View File

@@ -0,0 +1,279 @@
# b0esche.cloud Authentication System
This document describes the complete passkey-first authentication and authorization system for b0esche.cloud.
## Overview
b0esche.cloud implements a modern, secure, username-only, passkey-first authentication system with comprehensive admin functionality and recovery options.
## Authentication Flow
### Primary Authentication: Passkeys (WebAuthn)
- **Username + Passkey Registration**: Users create an account with just a username and register a passkey
- **Passkey-First Login**: Primary authentication method using WebAuthn/FIDO2 standards
- **Device Support**: Works with Touch ID, Windows Hello, YubiKey, and other FIDO2 authenticators
- **Multiple Passkeys**: Users can register multiple devices for redundancy
### Fallback Options
- **Optional Password**: Users can add a password as a fallback authentication method
- **Recovery Codes**: 10 single-use recovery codes generated per user
- **Admin Recovery**: Admins can assist with account recovery if needed
## User Roles and Permissions
### Role Hierarchy
1. **superadmin** (Level 3): Full system access
- User management and role assignments
- Admin invitation system
- System configuration access
- All organization management
2. **admin** (Level 2): Administrative access
- Organization management
- User role promotion (within their orgs)
- Activity monitoring
- File management across organizations
3. **user** (Level 1): Standard user access
- Personal file management
- Organization membership
- Basic account settings
## Bootstrap Process
### Creating the First Administrator
The system includes a bootstrap utility to create the first superadmin user securely:
```bash
# Interactive mode (recommended for production)
./bin/bootstrap
# Environment variable mode (recommended for automation)
export BOOTSTRAP_ADMIN_USERNAME=admin
export BOOTSTRAP_ADMIN_PASSWORD=secure_password_123
./bin/bootstrap
```
#### Bootstrap Process
1. **Check Existing Admin**: Verifies no superadmin user already exists
2. **Secure Input**: Prompts for username and password (or reads from environment)
3. **Password Security**: Enforces minimum 8-character passwords
4. **Role Assignment**: Automatically assigns superadmin role
5. **Secure Storage**: Passwords are bcrypt-hashed before storage
#### Security Guidelines
- **Immediate Action**: Log in immediately after bootstrap to register a passkey
- **Passkey Priority**: Set up passkeys and consider removing the password
- **Recovery Setup**: Generate recovery codes during first login
- **Credential Rotation**: Never leave default credentials in production
## API Endpoints
### Authentication Endpoints
```
POST /auth/passkey/register/start
POST /auth/passkey/register/verify
POST /auth/passkey/login/start
POST /auth/passkey/login/verify
```
### Device Management
```
GET /auth/passkey/devices # List user's passkeys
POST /auth/passkey/devices/add # Add new passkey
DELETE /auth/passkey/devices/{id} # Remove passkey
```
### Recovery System
```
POST /auth/recovery/codes/generate # Generate recovery codes
POST /auth/recovery/codes/use # Use recovery code
DELETE /auth/recovery/codes/revoke # Revoke all codes
```
### Admin Operations
```
GET /auth/admin/invitations # List invitations
POST /auth/admin/invitations # Create invitation
POST /auth/admin/invitations/accept # Accept invitation
DELETE /auth/admin/invitations/{id} # Revoke invitation
```
### Password Fallback
```
POST /auth/password/add # Add password to account
DELETE /auth/password/remove # Remove password from account
```
## Environment Configuration
```bash
# Server Configuration
SERVER_ADDR=:8080
DATABASE_URL=postgresql://user:pass@localhost/db
# WebAuthn Configuration
WEBAUTHN_RP_ID=www.b0esche.cloud
WEBAUTHN_RP_NAME=b0esche.cloud
WEBAUTHN_RP_ORIGIN=https://www.b0esche.cloud
# Security Configuration
JWT_SECRET=your_jwt_secret_key
PASSKEY_TIMEOUT=300000
RECOVERY_CODE_EXPIRY=86400
MAX_RECOVERY_CODES=10
```
## Security Features
### WebAuthn Security
- **FIDO2/WebAuthn Compliant**: Full compliance with WebAuthn Level 2
- **Origin Binding**: Credentials bound to specific domain
- **Challenge-Response**: Cryptographic challenge verification
- **Device Attestation**: Optional device verification
- **Public Key Crypto**: Asymmetric cryptography with private keys never leaving devices
### Account Security
- **Rate Limiting**: Built-in protection against brute force attacks
- **Session Management**: Secure HTTP-only sessions with configurable expiration
- **Audit Logging**: Comprehensive logging of all authentication and admin actions
- **Role-Based Access Control**: Hierarchical permission system
### Data Protection
- **Password Hashing**: bcrypt with automatic salt generation
- **Recovery Code Hashing**: Secure one-way hashing of recovery codes
- **No Plaintext Storage**: No sensitive data stored in plaintext
- **Input Validation**: Comprehensive input sanitization and validation
## Database Schema
The authentication system uses the following key tables:
- **users**: User accounts with role-based access control
- **roles**: Hierarchical role definitions
- **passkeys**: WebAuthn credential storage
- **recovery_codes**: One-time recovery codes
- **admin_invitations**: Secure admin invitation system
- **sessions**: Secure session management
## Development and Testing
### Running Locally
1. **Setup Database**: Ensure PostgreSQL is running with migrations applied
2. **Bootstrap Admin**: Run the bootstrap command to create first admin
3. **Start Server**: Run the API server with appropriate environment
4. **Test Authentication**: Use the Flutter app or API tests
### Testing WebAuthn
WebAuthn requires HTTPS in production browsers. For local testing:
```bash
# Use mkcert for local HTTPS
mkcert -install
mkcert localhost 127.0.0.1 ::1
# Set environment for local development
export WEBAUTHN_RP_ID=localhost
export WEBAUTHN_RP_ORIGIN=https://localhost:8080
```
## User Experience
### Signup Flow
1. **Username Selection**: User chooses a unique username
2. **Real-time Validation**: Immediate feedback on username availability
3. **Passkey Creation**: Browser prompts for passkey registration
4. **Account Creation**: Automatic account creation with passkey
### Login Flow
1. **Passkey Detection**: System shows available passkeys
2. **Biometric Prompt**: Browser authenticates with passkey
3. **Session Creation**: Secure session established
4. **Redirect**: User directed to dashboard
### Security Settings
Users can manage their security through the Settings > Security page:
- **Device Management**: View, add, remove, and label passkeys
- **Recovery Codes**: Generate new recovery codes
- **Password Options**: Add or remove password fallback
- **Account Recovery**: Secure account recovery options
## Troubleshooting
### Common Issues
1. **WebAuthn Not Supported**: Use a modern browser (Chrome, Firefox, Safari, Edge)
2. **HTTPS Required**: WebAuthn requires HTTPS in production environments
3. **Device Compatibility**: Ensure devices support FIDO2/WebAuthn
4. **Database Connection**: Verify database connection and migrations
### Bootstrap Issues
1. **Permission Denied**: Ensure proper file permissions on bootstrap binary
2. **Database Connection**: Check DATABASE_URL configuration
3. **Port Conflicts**: Ensure database port is accessible
### Recovery Process
If a user loses access to all passkeys:
1. **Use Recovery Code**: Enter one of the 10 recovery codes
2. **Contact Admin**: Admins can assist with account recovery
3. **Re-register Passkey**: Set up new passkeys after recovery
4. **Generate New Recovery Codes**: Replace used recovery codes
## Monitoring and Maintenance
### Key Metrics
Monitor these metrics for system health:
- Authentication success/failure rates
- Passkey registration and usage patterns
- Recovery code usage frequency
- Admin action audit logs
- Session expiration and renewal rates
### Maintenance Tasks
Regular maintenance includes:
- Clean up expired sessions and recovery codes
- Review audit logs for suspicious activity
- Update role assignments as needed
- Monitor WebAuthn compatibility with browser updates
## Support and Documentation
For additional support:
- **Technical Issues**: Check application logs and database status
- **Security Concerns**: Review audit logs and user activity
- **Feature Requests**: Follow the contribution guidelines
- **Documentation Updates**: Keep this document current with system changes
---
**Last Updated**: January 2026
**Version**: 1.0
**Compatibility**: WebAuthn Level 2, FIDO2

579
docs/DEPLOYMENT.md Normal file
View File

@@ -0,0 +1,579 @@
# b0esche.cloud Deployment Guide
This guide covers production deployment, server configuration, and operations.
## Production Architecture
### Server Overview
| Component | Domain | Port | Container |
|-----------|--------|------|-----------|
| Flutter Web | www.b0esche.cloud | 80 | `flutter-web` |
| Go Backend | go.b0esche.cloud | 8080 | `go-backend` |
| PostgreSQL | internal | 5432 | `go-postgres` |
| Nextcloud | storage.b0esche.cloud | 80 | `nextcloud` |
| Collabora | of.b0esche.cloud | 9980 | `collabora` |
| Traefik | - | 80, 443 | `traefik` |
### Server Directory Structure
```
/opt/
├── traefik/
│ ├── docker-compose.yml # Traefik + Nextcloud + Collabora
│ ├── traefik.yml # Static configuration
│ ├── .env # DNS credentials
│ └── acme/ # SSL certificates
├── go/
│ ├── docker-compose.yml # Go backend + PostgreSQL
│ ├── .env.production # Production environment
│ └── data/
│ └── postgres/
│ └── backend/
│ └── go_cloud/ # Backend source code
├── flutter/
│ ├── docker-compose.yml # Nginx for Flutter
│ ├── nginx.conf # Nginx configuration
│ └── web/ # Built Flutter files
├── scripts/
│ ├── auto-deploy.sh # Daily auto-deployment
│ ├── deploy-now.sh # Manual deployment trigger
│ ├── backup.sh # Backup script
│ ├── monitor.sh # Health monitoring
│ └── webhook-server.py # GitLab webhook receiver
└── auto-deploy/
└── b0esche_cloud_rollout/ # Deployment workspace
```
## Deployment Methods
### 1. Automatic Deployment (Recommended)
Deployments run automatically at 3 AM daily via cron:
```cron
0 3 * * * /opt/scripts/auto-deploy.sh >> /var/log/auto-deploy.log 2>&1
```
The auto-deploy script:
1. Pulls latest changes from GitLab
2. Builds Flutter web app
3. Rebuilds Go backend Docker image
4. Restarts services
5. Validates health checks
### 2. Manual Deployment (Immediate)
Trigger an immediate deployment:
```bash
# From local machine
ssh b0esche-cloud '/opt/scripts/deploy-now.sh'
# Or directly on server
/opt/scripts/deploy-now.sh
```
### 3. GitLab Webhook (On Push)
The webhook server listens for push events:
```bash
# Start webhook server (runs as systemd service)
systemctl start webhook-server
# Check webhook logs
journalctl -u webhook-server -f
```
## Service Management
### Starting All Services
```bash
# Start in order (dependencies first)
cd /opt/traefik && docker-compose up -d
cd /opt/go && docker-compose up -d
cd /opt/flutter && docker-compose up -d
```
### Stopping All Services
```bash
cd /opt/flutter && docker-compose down
cd /opt/go && docker-compose down
cd /opt/traefik && docker-compose down
```
### Restarting Individual Services
```bash
# Restart Go backend
cd /opt/go && docker-compose restart go-backend
# Restart Flutter frontend
cd /opt/flutter && docker-compose restart flutter-web
# Restart Traefik (caution: brief SSL interruption)
cd /opt/traefik && docker-compose restart traefik
```
### Viewing Logs
```bash
# Follow Go backend logs
docker logs -f go-backend
# Follow Flutter/Nginx logs
docker logs -f flutter-web
# Follow Traefik logs
docker logs -f traefik
# All logs with timestamps
docker logs -f --timestamps go-backend
```
## Configuration Files
### Traefik Configuration
**docker-compose.yml:**
```yaml
version: '3.8'
services:
traefik:
image: traefik:v2.10
container_name: traefik
ports:
- "80:80"
- "443:443"
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
- ./traefik.yml:/etc/traefik/traefik.yml:ro
- ./acme:/etc/traefik/acme
networks:
- proxy
restart: unless-stopped
networks:
proxy:
external: true
```
**traefik.yml:**
```yaml
entryPoints:
web:
address: ":80"
http:
redirections:
entryPoint:
to: websecure
scheme: https
websecure:
address: ":443"
certificatesResolvers:
letsencrypt:
acme:
email: admin@b0esche.cloud
storage: /etc/traefik/acme/acme.json
dnsChallenge:
provider: bunny
delayBeforeCheck: 30
providers:
docker:
exposedByDefault: false
```
### Go Backend Configuration
**docker-compose.yml:**
```yaml
version: '3.8'
services:
postgres:
image: postgres:15-alpine
container_name: go-postgres
environment:
POSTGRES_USER: go_backend
POSTGRES_PASSWORD: ${DB_PASSWORD}
POSTGRES_DB: go_backend
volumes:
- ./data/postgres:/var/lib/postgresql/data
networks:
- backend
restart: unless-stopped
go-backend:
build:
context: ./data/postgres/backend/go_cloud
dockerfile: Dockerfile
container_name: go-backend
env_file: .env.production
labels:
- "traefik.enable=true"
- "traefik.http.routers.go.rule=Host(`go.b0esche.cloud`)"
- "traefik.http.routers.go.tls.certresolver=letsencrypt"
depends_on:
- postgres
networks:
- proxy
- backend
restart: unless-stopped
networks:
proxy:
external: true
backend:
driver: bridge
```
### Flutter/Nginx Configuration
**docker-compose.yml:**
```yaml
version: '3.8'
services:
flutter-web:
image: nginx:alpine
container_name: flutter-web
volumes:
- ./web:/usr/share/nginx/html:ro
- ./nginx.conf:/etc/nginx/conf.d/default.conf:ro
labels:
- "traefik.enable=true"
- "traefik.http.routers.flutter.rule=Host(`www.b0esche.cloud`)"
- "traefik.http.routers.flutter.tls.certresolver=letsencrypt"
networks:
- proxy
restart: unless-stopped
networks:
proxy:
external: true
```
**nginx.conf:**
```nginx
server {
listen 80;
server_name www.b0esche.cloud;
root /usr/share/nginx/html;
index index.html;
# Flutter web app routing
location / {
try_files $uri $uri/ /index.html;
}
# Cache static assets
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
# Gzip compression
gzip on;
gzip_types text/plain text/css application/json application/javascript;
}
```
## Database Operations
### Running Migrations
```bash
# Enter backend container
docker exec -it go-backend sh
# Run migrations
./api migrate up
# Or from host
docker exec go-backend ./api migrate up
```
### Database Backup
```bash
# Manual backup
docker exec go-postgres pg_dump -U go_backend -Fc go_backend > backup.sqlc
# Restore from backup
docker exec -i go-postgres pg_restore -U go_backend -d go_backend < backup.sqlc
```
### Connecting to Database
```bash
# Via docker exec
docker exec -it go-postgres psql -U go_backend -d go_backend
# Common queries
\dt # List tables
\d users # Describe table
SELECT count(*) FROM users; # Count users
```
## SSL Certificate Management
### Certificate Status
```bash
# Check certificate expiry
docker exec traefik cat /etc/traefik/acme/acme.json | jq '.letsencrypt.Certificates[].certificate.NotAfter'
# Force certificate renewal
docker restart traefik
```
### Manual Certificate Operations
```bash
# Backup certificates
cp -r /opt/traefik/acme /opt/traefik/acme.backup
# View certificate details
openssl s_client -connect www.b0esche.cloud:443 -servername www.b0esche.cloud </dev/null 2>/dev/null | openssl x509 -noout -dates
```
## Monitoring
### Health Checks
```bash
# Check all services
/opt/scripts/monitor.sh
# Manual health checks
curl -s https://go.b0esche.cloud/health
curl -s -o /dev/null -w "%{http_code}" https://www.b0esche.cloud
curl -s -o /dev/null -w "%{http_code}" https://storage.b0esche.cloud
```
### Container Status
```bash
# All containers
docker ps --format "table {{.Names}}\t{{.Status}}\t{{.Ports}}"
# Resource usage
docker stats --no-stream
# Container health
docker inspect --format='{{.State.Health.Status}}' go-backend
```
### Disk Usage
```bash
# Docker disk usage
docker system df
# PostgreSQL data size
du -sh /opt/go/data/postgres
# Log sizes
du -sh /var/lib/docker/containers/*/
```
## Backup Strategy
### Automated Backups
Backups run daily via cron:
```cron
0 2 * * * /opt/scripts/backup.sh >> /var/log/backup.log 2>&1
```
### Backup Contents
1. **PostgreSQL database** (pg_dump)
2. **Nextcloud database** (mysqldump)
3. **Traefik certificates** (/opt/traefik/acme)
4. **Configuration files** (.env, docker-compose.yml)
5. **Nextcloud data volume**
### Backup Retention
- Keep backups for 30 days
- Stored in `/opt/backups/b0esche_cloud/`
- Compressed as `.tar.gz`
### Manual Backup
```bash
# Run backup now
/opt/scripts/backup.sh
# List backups
ls -lh /opt/backups/b0esche_cloud/
```
### Restore Procedure
```bash
# 1. Stop services
cd /opt/go && docker-compose down
cd /opt/flutter && docker-compose down
# 2. Extract backup
cd /opt/backups/b0esche_cloud
tar -xzf 20260113_020000.tar.gz
# 3. Restore database
docker exec -i go-postgres pg_restore -U go_backend -d go_backend < go_backend.sqlc
# 4. Restore configurations
cp .env.production /opt/go/
cp go-docker-compose.yml /opt/go/docker-compose.yml
# 5. Restart services
cd /opt/go && docker-compose up -d
cd /opt/flutter && docker-compose up -d
```
## Troubleshooting
### Common Issues
#### Service won't start
```bash
# Check logs for errors
docker logs go-backend --tail 50
# Check container status
docker inspect go-backend | jq '.[0].State'
# Check port conflicts
netstat -tlnp | grep -E '80|443|8080'
```
#### Database connection issues
```bash
# Test database connectivity
docker exec go-backend ping -c 3 postgres
# Check PostgreSQL logs
docker logs go-postgres --tail 50
# Verify credentials
docker exec go-postgres psql -U go_backend -c "SELECT 1"
```
#### SSL certificate errors
```bash
# Check certificate status
curl -vI https://www.b0esche.cloud 2>&1 | grep -A 5 "Server certificate"
# Force renewal
docker restart traefik
sleep 60
curl -vI https://www.b0esche.cloud
```
#### Out of disk space
```bash
# Check disk usage
df -h
# Clean Docker resources
docker system prune -a --volumes
# Clean old backups
find /opt/backups -mtime +30 -delete
# Clean old logs
truncate -s 0 /var/log/auto-deploy.log
```
### Emergency Procedures
#### Rollback Deployment
```bash
# 1. Stop current services
cd /opt/go && docker-compose down
cd /opt/flutter && docker-compose down
# 2. Checkout previous version
cd /opt/auto-deploy/b0esche_cloud_rollout
git log --oneline -10 # Find last working commit
git checkout <commit-hash>
# 3. Redeploy
/opt/scripts/auto-deploy.sh
```
#### Database Recovery
```bash
# Find latest backup
ls -lt /opt/backups/b0esche_cloud/ | head -5
# Restore (see Restore Procedure above)
```
#### Full System Recovery
1. Provision new server
2. Install Docker
3. Copy `/opt` from backup
4. Start services in order
5. Restore database from backup
6. Verify health checks
## Security Checklist
- [ ] All services behind Traefik (no direct port exposure)
- [ ] SSL certificates valid and auto-renewing
- [ ] Database not accessible from internet
- [ ] Strong passwords in `.env.production`
- [ ] Regular backups verified
- [ ] Firewall configured (only 80, 443, 22 open)
- [ ] SSH key authentication only
- [ ] Auto-deploy logs monitored
## Performance Tuning
### PostgreSQL
```sql
-- Check slow queries
SELECT * FROM pg_stat_activity WHERE state = 'active';
-- Analyze tables
ANALYZE;
-- Vacuum
VACUUM ANALYZE;
```
### Nginx
```nginx
# Add to nginx.conf for better performance
worker_connections 1024;
keepalive_timeout 65;
gzip_comp_level 6;
```
### Docker
```bash
# Limit container resources
docker update --memory="512m" --cpus="1" go-backend
# Clean up unused resources
docker system prune -f
```

671
docs/DEVELOPMENT.md Normal file
View File

@@ -0,0 +1,671 @@
# b0esche.cloud Development Guide
This guide covers local development setup, coding conventions, and contribution guidelines.
## Prerequisites
### Required Software
| Software | Version | Installation |
|----------|---------|--------------|
| Go | 1.21+ | `brew install go` |
| Flutter | 3.10+ | [flutter.dev](https://flutter.dev/docs/get-started/install) |
| Docker | 24+ | [docker.com](https://docker.com) |
| PostgreSQL | 15+ | `brew install postgresql@15` or Docker |
| Git | 2.x | `brew install git` |
### Recommended Tools
- **VS Code** with extensions:
- Go
- Flutter
- Dart
- Docker
- GitLens
- **TablePlus** or **DBeaver** for database management
- **Postman** or **Bruno** for API testing
## Security Guidelines
### Code Security
- **Never log secrets**: Passwords, tokens, keys must never appear in logs
- **Validate all inputs**: Use `sanitizePath()` for file paths, validate UUIDs
- **Use structured errors**: Return safe error messages that don't leak internal details
- **HTTPS only**: All API calls must use HTTPS in production
- **Input sanitization**: All user inputs must be validated and sanitized
### Authentication
- **JWT tokens**: Use secure, short-lived tokens
- **Session validation**: Always validate sessions against database
- **Passkey security**: Follow WebAuthn best practices
### File Operations
- **Path validation**: Prevent directory traversal with proper path sanitization
- **Permission checks**: Verify user permissions before file operations
- **Scoped access**: Users can only access authorized files/orgs
### Development Security
- **Local secrets**: Use `.env` files, never commit secrets
- **Test with security**: Include security tests in development
- **Review code**: Security review for all changes
## Project Setup
### 1. Clone the Repository
```bash
git clone https://lab.b0esche.cloud/b0esche/b0esche_cloud.git
cd b0esche_cloud
```
### 2. Backend Setup
```bash
cd go_cloud
# Copy environment file
cp .env.example .env
# Edit .env with your local settings
# Key variables to set:
# - DATABASE_URL=postgres://user:pass@localhost:5432/b0esche_dev?sslmode=disable
# - JWT_SECRET=your-dev-secret
# - WEBAUTHN_RP_ID=localhost
# - WEBAUTHN_RP_ORIGIN=http://localhost:8080
```
#### Start PostgreSQL
**Option A: Using Docker (Recommended)**
```bash
docker run -d \
--name b0esche-postgres \
-e POSTGRES_USER=b0esche \
-e POSTGRES_PASSWORD=devpassword \
-e POSTGRES_DB=b0esche_dev \
-p 5432:5432 \
postgres:15-alpine
```
**Option B: Using local PostgreSQL**
```bash
createdb b0esche_dev
```
#### Run Migrations
```bash
# Install goose
go install github.com/pressly/goose/v3/cmd/goose@latest
# Run migrations
goose -dir migrations postgres "$DATABASE_URL" up
```
#### Start Backend
```bash
# Development mode with hot reload
go run ./cmd/api
# Or build and run
go build -o bin/api ./cmd/api
./bin/api
```
The backend will be available at `http://localhost:8080`.
### 3. Frontend Setup
```bash
cd b0esche_cloud
# Get dependencies
flutter pub get
# Run in Chrome (recommended for web development)
flutter run -d chrome
# Or run with specific port
flutter run -d chrome --web-port=3000
```
The frontend will be available at `http://localhost:3000` (or the port shown).
### 4. Quick Start Script
Use the provided development script:
```bash
./scripts/dev-all.sh
```
This starts all services in the correct order.
## Project Structure
### Backend (`go_cloud/`)
```
go_cloud/
├── cmd/
│ └── api/
│ └── main.go # Application entry point
├── internal/
│ ├── auth/
│ │ ├── auth.go # Authentication service
│ │ ├── passkey.go # WebAuthn implementation
│ │ └── auth_test.go # Tests
│ ├── config/
│ │ └── config.go # Configuration loading
│ ├── database/
│ │ └── database.go # Database connection
│ ├── files/
│ │ └── files.go # File operations
│ ├── http/
│ │ ├── routes.go # Route definitions
│ │ ├── server.go # HTTP server setup
│ │ └── wopi_handlers.go # WOPI protocol handlers
│ ├── middleware/
│ │ └── middleware.go # HTTP middleware
│ ├── models/
│ │ └── *.go # Data models
│ ├── org/
│ │ └── org.go # Organization logic
│ ├── storage/
│ │ ├── nextcloud.go # Nextcloud integration
│ │ └── webdav.go # WebDAV client
│ └── ...
├── migrations/
│ ├── 0001_initial.sql
│ ├── 0002_passkeys.sql
│ └── ...
├── pkg/
│ └── jwt/
│ └── jwt.go # JWT utilities
├── .env.example
├── Dockerfile
├── go.mod
└── Makefile
```
### Frontend (`b0esche_cloud/`)
```
b0esche_cloud/
├── lib/
│ ├── main.dart # App entry point
│ ├── injection.dart # Dependency injection
│ ├── blocs/
│ │ ├── auth/
│ │ │ ├── auth_bloc.dart
│ │ │ ├── auth_event.dart
│ │ │ └── auth_state.dart
│ │ ├── files/
│ │ └── org/
│ ├── models/
│ │ ├── user.dart
│ │ ├── file.dart
│ │ └── organization.dart
│ ├── pages/
│ │ ├── home_page.dart
│ │ ├── files_page.dart
│ │ ├── settings_page.dart
│ │ └── admin/
│ ├── repositories/
│ │ ├── auth_repository.dart
│ │ └── file_repository.dart
│ ├── services/
│ │ ├── api_client.dart
│ │ └── webauthn_service.dart
│ ├── theme/
│ │ └── app_theme.dart
│ └── widgets/
│ ├── file_list.dart
│ └── ...
├── web/
│ └── index.html
├── pubspec.yaml
└── analysis_options.yaml
```
## Coding Conventions
### Go Backend
#### Code Style
- Follow [Effective Go](https://golang.org/doc/effective_go)
- Use `gofmt` for formatting
- Use `golint` and `go vet` for linting
```bash
# Format code
gofmt -w .
# Lint
golint ./...
go vet ./...
```
#### Naming Conventions
- **Packages**: lowercase, single word (`auth`, `files`)
- **Exported functions**: PascalCase (`CreateUser`)
- **Private functions**: camelCase (`validateToken`)
- **Constants**: PascalCase (`DefaultTimeout`)
#### Error Handling
```go
// Always handle errors explicitly
user, err := s.GetUser(ctx, id)
if err != nil {
return nil, fmt.Errorf("failed to get user: %w", err)
}
// Use custom error types for API errors
type APIError struct {
Code string `json:"code"`
Message string `json:"message"`
}
```
#### Project Patterns
```go
// Service pattern
type AuthService struct {
db *sqlx.DB
config *config.Config
}
func NewAuthService(db *sqlx.DB, config *config.Config) *AuthService {
return &AuthService{db: db, config: config}
}
// Handler pattern
func (h *Handler) HandleLogin(w http.ResponseWriter, r *http.Request) {
// Parse request
// Call service
// Return response
}
```
### Flutter Frontend
#### Code Style
- Follow [Effective Dart](https://dart.dev/guides/language/effective-dart)
- Use `dart format` for formatting
- Use `dart analyze` for linting
```bash
# Format code
dart format .
# Analyze
dart analyze
flutter analyze
```
#### Naming Conventions
- **Classes**: PascalCase (`AuthBloc`)
- **Files**: snake_case (`auth_bloc.dart`)
- **Variables/Functions**: camelCase (`getUserName`)
- **Constants**: camelCase or SCREAMING_CAPS
#### BLoC Pattern
```dart
// Events
abstract class AuthEvent {}
class LoginRequested extends AuthEvent {
final String username;
LoginRequested(this.username);
}
// States
abstract class AuthState {}
class AuthInitial extends AuthState {}
class AuthLoading extends AuthState {}
class AuthAuthenticated extends AuthState {
final User user;
AuthAuthenticated(this.user);
}
class AuthError extends AuthState {
final String message;
AuthError(this.message);
}
// BLoC
class AuthBloc extends Bloc<AuthEvent, AuthState> {
AuthBloc() : super(AuthInitial()) {
on<LoginRequested>(_onLoginRequested);
}
Future<void> _onLoginRequested(
LoginRequested event,
Emitter<AuthState> emit,
) async {
emit(AuthLoading());
try {
final user = await _authRepository.login(event.username);
emit(AuthAuthenticated(user));
} catch (e) {
emit(AuthError(e.toString()));
}
}
}
```
#### Widget Structure
```dart
class MyWidget extends StatelessWidget {
const MyWidget({super.key});
@override
Widget build(BuildContext context) {
return BlocBuilder<MyBloc, MyState>(
builder: (context, state) {
return switch (state) {
MyLoading() => const CircularProgressIndicator(),
MyLoaded(:final data) => _buildContent(data),
MyError(:final message) => Text('Error: $message'),
_ => const SizedBox.shrink(),
};
},
);
}
}
```
## Testing
### Backend Tests
```bash
cd go_cloud
# Run all tests
go test ./...
# Run with coverage
go test -cover ./...
# Run specific package
go test ./internal/auth/...
# Verbose output
go test -v ./...
```
Example test:
```go
func TestAuthService_Login(t *testing.T) {
// Setup
db := setupTestDB(t)
service := NewAuthService(db, testConfig)
// Test
user, err := service.Login(context.Background(), "testuser")
// Assert
assert.NoError(t, err)
assert.Equal(t, "testuser", user.Username)
}
```
### Frontend Tests
```bash
cd b0esche_cloud
# Run all tests
flutter test
# Run with coverage
flutter test --coverage
# Run specific test file
flutter test test/auth_bloc_test.dart
# Run integration tests
flutter test integration_test/
```
Example test:
```dart
void main() {
group('AuthBloc', () {
late AuthBloc authBloc;
late MockAuthRepository mockRepository;
setUp(() {
mockRepository = MockAuthRepository();
authBloc = AuthBloc(authRepository: mockRepository);
});
blocTest<AuthBloc, AuthState>(
'emits [AuthLoading, AuthAuthenticated] on successful login',
build: () => authBloc,
act: (bloc) => bloc.add(LoginRequested('testuser')),
expect: () => [
AuthLoading(),
isA<AuthAuthenticated>(),
],
);
});
}
```
## Database Migrations
### Creating a Migration
```bash
cd go_cloud
# Create new migration
goose -dir migrations create add_new_table sql
```
### Migration Best Practices
```sql
-- migrations/0005_add_feature.sql
-- +goose Up
-- Add new column with default
ALTER TABLE users ADD COLUMN new_field TEXT DEFAULT '';
-- Create index for performance
CREATE INDEX idx_users_new_field ON users(new_field);
-- +goose Down
DROP INDEX IF EXISTS idx_users_new_field;
ALTER TABLE users DROP COLUMN new_field;
```
### Running Migrations
```bash
# Apply all pending migrations
goose -dir migrations postgres "$DATABASE_URL" up
# Rollback last migration
goose -dir migrations postgres "$DATABASE_URL" down
# Check migration status
goose -dir migrations postgres "$DATABASE_URL" status
```
## Debugging
### Backend Debugging
**VS Code launch.json:**
```json
{
"version": "0.2.0",
"configurations": [
{
"name": "Launch Go Backend",
"type": "go",
"request": "launch",
"mode": "debug",
"program": "${workspaceFolder}/go_cloud/cmd/api",
"envFile": "${workspaceFolder}/go_cloud/.env"
}
]
}
```
**Logging:**
```go
import "log"
log.Printf("User login attempt: %s", username)
```
### Frontend Debugging
**Chrome DevTools:**
- Press F12 in Chrome
- Use the Flutter DevTools extension
**Debug print:**
```dart
debugPrint('Current state: $state');
```
**VS Code launch.json:**
```json
{
"version": "0.2.0",
"configurations": [
{
"name": "Flutter Web",
"type": "dart",
"request": "launch",
"program": "lib/main.dart",
"deviceId": "chrome"
}
]
}
```
## Git Workflow
### Branch Naming
- `feature/description` - New features
- `fix/description` - Bug fixes
- `refactor/description` - Code refactoring
- `docs/description` - Documentation updates
### Commit Messages
Follow conventional commits:
```
type(scope): description
feat(auth): add passkey registration flow
fix(files): correct upload progress display
docs(readme): update deployment instructions
refactor(api): extract common error handling
```
### Pull Request Process
1. Create feature branch from `main`
2. Make changes with atomic commits
3. Run tests locally
4. Push and create PR
5. Wait for review
6. Squash and merge
## Troubleshooting
### Common Issues
#### Backend won't start
```bash
# Check if port is in use
lsof -i :8080
# Check database connection
psql $DATABASE_URL -c "SELECT 1"
# Check logs
go run ./cmd/api 2>&1 | head -50
```
#### Flutter build fails
```bash
# Clean and rebuild
flutter clean
flutter pub get
flutter run -d chrome
# Check for dependency issues
flutter pub deps
```
#### Database migration fails
```bash
# Check current status
goose -dir migrations postgres "$DATABASE_URL" status
# Force specific version
goose -dir migrations postgres "$DATABASE_URL" fix
```
#### WebAuthn not working locally
- WebAuthn requires HTTPS in production
- For localhost, use `WEBAUTHN_RP_ID=localhost`
- Chrome allows WebAuthn on localhost without HTTPS
## Environment Variables Reference
### Backend (.env)
```bash
# Server
SERVER_ADDR=:8080
DEV_MODE=true
# Database
DATABASE_URL=postgres://user:pass@localhost:5432/dbname?sslmode=disable
# Authentication
JWT_SECRET=your-secret-key
WEBAUTHN_RP_ID=localhost
WEBAUTHN_RP_NAME=b0esche.cloud
WEBAUTHN_RP_ORIGIN=http://localhost:8080
# External Services (optional for local dev)
NEXTCLOUD_BASE_URL=https://storage.b0esche.cloud
NEXTCLOUD_USERNAME=admin
NEXTCLOUD_PASSWORD=password
COLLABORA_BASE_URL=https://of.b0esche.cloud
```
### Frontend
API base URL is configured in `lib/services/api_client.dart`:
```dart
class ApiClient {
// For development
static const baseUrl = 'http://localhost:8080';
// For production (set via build args)
// static const baseUrl = String.fromEnvironment('API_URL');
}
```
## Resources
- [Go Documentation](https://golang.org/doc/)
- [Flutter Documentation](https://flutter.dev/docs)
- [WebAuthn Guide](https://webauthn.guide/)
- [BLoC Library](https://bloclibrary.dev/)
- [Chi Router](https://github.com/go-chi/chi)

196
docs/SECURITY.md Normal file
View File

@@ -0,0 +1,196 @@
# b0esche.cloud Security Guide
This document describes the security architecture, configurations, and best practices for b0esche.cloud.
## Security Architecture Overview
```
Internet
┌─────────────────┐
│ Traefik │ ← Only public entrypoint
│ (443, 80) │ TLS termination
└────────┬────────┘
┌───────────────┼───────────────┐
▼ ▼ ▼
┌──────────┐ ┌──────────┐ ┌──────────┐
│ Flutter │ │ Go API │ │Collabora │
│ Web │ │ Backend │ │ Online │
└──────────┘ └────┬─────┘ └──────────┘
┌─────────────┼─────────────┐
▼ ▼ ▼
┌──────────┐ ┌──────────┐ ┌──────────┐
│PostgreSQL│ │Nextcloud │ │ Redis │
│(internal)│ │(storage) │ │(sessions)│
└──────────┘ └──────────┘ └──────────┘
```
## Authentication Security
### Primary: Passkeys (WebAuthn)
- **Protocol**: WebAuthn/FIDO2 standard
- **Cryptography**: ECDSA with P-256 or RSA with 2048+ bits
- **Origin Binding**: Strictly bound to `https://b0esche.cloud`
- **RP ID**: `b0esche.cloud`
- **Challenge Generation**: 32 bytes of cryptographically secure random data
- **Challenge Expiry**: 60 seconds
### Fallback: Password Authentication
- **Hashing**: Argon2id (OWASP recommended parameters)
- Time: 2 iterations
- Memory: 19 MB
- Threads: 1
- Key Length: 32 bytes
- **Password Requirements**: Minimum 8 characters (enforced server-side)
### Session Management
- **Token Format**: JWT (HS256)
- **Token Lifetime**: 15 minutes (auto-refresh enabled)
- **Session Storage**: Database-backed with revocation support
- **Session Validation**: Every request validates session is not revoked
## Authorization
### Role-Based Access Control (RBAC)
| Role | Level | Permissions |
|------|-------|-------------|
| superadmin | 3 | Full system access, user management |
| admin | 2 | Organization management, user roles |
| user | 1 | Personal files, org membership |
### Organization Scoping
- All file operations are scoped to authenticated user + organization
- Membership verification on every org-scoped request
- Permission checks via middleware pipeline
## API Security
### Rate Limiting
- **General Endpoints**: 100 requests/minute per IP
- **Auth Endpoints**: 10 requests/minute per IP (brute-force protection)
- **Implementation**: Sliding window algorithm
### Input Validation
- **Path Traversal Prevention**: All file paths are sanitized
- `..` sequences are rejected
- Paths are cleaned and normalized
- **UUID Validation**: All IDs are validated as proper UUIDs
- **File Size Limits**: 32MB maximum upload size
### Output Security
- **No Stack Traces**: Error responses never include stack traces
- **Structured Errors**: Consistent error format with codes:
- `UNAUTHENTICATED` (401)
- `PERMISSION_DENIED` (403)
- `NOT_FOUND` (404)
- `INVALID_ARGUMENT` (400)
- `INTERNAL` (500)
- **No Secrets in Logs**: Passwords and tokens are never logged
### Security Headers
The application sets comprehensive security headers:
- **X-Content-Type-Options**: `nosniff` - Prevents MIME type sniffing
- **X-Frame-Options**: `DENY` - Prevents clickjacking (except for WOPI endpoints)
- **X-XSS-Protection**: `1; mode=block` - Enables XSS filtering
- **Content-Security-Policy**: Restrictive policy allowing only necessary sources
- **Referrer-Policy**: `strict-origin-when-cross-origin` - Controls referrer information
- **CORS**: Restricted to allowed origins with credentials support
## Network Security
### TLS Configuration
- **Protocol**: TLS 1.2 minimum (TLS 1.3 preferred)
- **Certificate**: Let's Encrypt (auto-renewed via DNS-01 challenge)
- **HSTS**: Enabled with 1-year max-age
### CORS Policy
- **Allowed Origins**:
- `https://b0esche.cloud`
- `https://www.b0esche.cloud`
- `https://*.b0esche.cloud`
- **Credentials**: Allowed
- **Methods**: GET, POST, PUT, PATCH, DELETE, OPTIONS
- **Max Age**: 3600 seconds
### Port Exposure
| Port | Service | Exposed To |
|------|---------|------------|
| 443 | Traefik (HTTPS) | Internet |
| 80 | Traefik (HTTP→HTTPS) | Internet |
| 22 | SSH | Internet (key-only) |
| 8080 | Go Backend | Internal only |
| 5432 | PostgreSQL | Internal only |
| 9980 | Collabora | Internal only |
## Secure Development Practices
### Code Security Checklist
- [ ] No hardcoded secrets
- [ ] No debug logging of sensitive data
- [ ] Input validation on all endpoints
- [ ] Path sanitization for file operations
- [ ] Parameterized SQL queries (no string concatenation)
- [ ] Error responses don't leak internal details
### Deployment Security
- [ ] Production secrets via environment variables only
- [ ] `.env` files excluded from git
- [ ] Docker containers run as non-root where possible
- [ ] Regular dependency updates
## Incident Response
### Logging
All security-relevant events are logged:
- Login attempts (success/failure)
- Session creation/revocation
- Permission denials
- Rate limit violations
- File access (view/edit/delete)
### Log Format
```
[LEVEL] req_id=<uuid> user_id=<uuid> org_id=<uuid> action=<string>: message
```
### Audit Trail
The `activities` table stores:
- User actions (file operations, org changes)
- Timestamps
- Associated resources
- Success/failure status
## Security Contacts
For security issues, contact the system administrator directly.
Do not report security vulnerabilities in public issue trackers.
## Changelog
| Date | Change |
|------|--------|
| 2026-01-13 | Initial security documentation |
| 2026-01-13 | Removed debug password logging |
| 2026-01-13 | Added rate limiting |
| 2026-01-13 | Added path traversal protection |

32
go_cloud/Dockerfile Normal file
View File

@@ -0,0 +1,32 @@
# ---------- Build stage ----------
FROM golang:1.24-alpine AS builder
WORKDIR /app
# Install ca-certs for HTTPS / OIDC
RUN apk add --no-cache ca-certificates
# Cache dependencies
COPY go.mod go.sum ./
RUN go mod download
# Copy source
COPY . .
# Build statically linked binary
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \
go build -o backend ./cmd/api
# ---------- Runtime stage ----------
FROM gcr.io/distroless/base-debian12
WORKDIR /app
COPY --from=builder /app/backend /app/backend
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
EXPOSE 8080
USER nonroot:nonroot
ENTRYPOINT ["/app/backend"]

Binary file not shown.

Binary file not shown.

View File

@@ -4,6 +4,7 @@ import (
"fmt"
"net/http"
"os"
"path/filepath"
"go.b0esche.cloud/backend/internal/audit"
"go.b0esche.cloud/backend/internal/auth"
@@ -13,9 +14,43 @@ import (
"go.b0esche.cloud/backend/pkg/jwt"
)
// ensureAvatarCacheDir finds a writable, preferably persistent directory for avatar cache and updates cfg
func ensureAvatarCacheDir(cfg *config.Config) {
candidates := []string{
cfg.AvatarCacheDir,
"/var/lib/b0esche/avatars",
"./data/avatars",
filepath.Join(os.TempDir(), "b0esche_avatars"),
}
for _, d := range candidates {
if d == "" {
continue
}
if err := os.MkdirAll(d, 0755); err == nil {
// Try writing a small test file to confirm write permission
testPath := filepath.Join(d, ".write_test")
if err := os.WriteFile(testPath, []byte("ok"), 0644); err == nil {
os.Remove(testPath)
if d != cfg.AvatarCacheDir {
fmt.Printf("[WARN] Avatar cache dir %q not usable, using %q instead. Please set AVATAR_CACHE_DIR to a persistent, writable volume.\n", cfg.AvatarCacheDir, d)
}
cfg.AvatarCacheDir = d
fmt.Printf("[INFO] Avatar cache directory set to %q\n", d)
return
}
}
}
// If none usable, keep configured value and let runtime fallback handle it
fmt.Printf("[WARN] No writable persistent avatar cache directory found; falling back to tmp. Set AVATAR_CACHE_DIR to a persistent path.\n")
}
func main() {
cfg := config.Load()
// Ensure avatar cache directory is usable and persistent when possible
ensureAvatarCacheDir(cfg)
dbConn, err := database.Connect(cfg)
if err != nil {
fmt.Fprintf(os.Stderr, "Database connection error: %v\n", err)

View File

@@ -1,23 +1,27 @@
module go.b0esche.cloud/backend
go 1.25.5
go 1.24.0
require (
github.com/coreos/go-oidc/v3 v3.17.0
github.com/go-chi/chi/v5 v5.2.3
github.com/golang-jwt/jwt/v5 v5.3.0
github.com/golang-jwt/jwt/v4 v4.5.2
github.com/google/uuid v1.6.0
github.com/jackc/pgconn v1.13.0
github.com/jackc/pgx/v5 v5.7.6
golang.org/x/crypto v0.37.0
golang.org/x/oauth2 v0.28.0
)
require (
github.com/coreos/go-oidc/v3 v3.17.0 // indirect
github.com/go-jose/go-jose/v4 v4.1.3 // indirect
github.com/golang-jwt/jwt/v4 v4.5.2 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/jackc/chunkreader/v2 v2.0.1 // indirect
github.com/jackc/pgio v1.0.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgproto3/v2 v2.3.1 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/pgx/v5 v5.7.6 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
golang.org/x/crypto v0.37.0 // indirect
golang.org/x/oauth2 v0.28.0 // indirect
golang.org/x/sync v0.13.0 // indirect
golang.org/x/sys v0.32.0 // indirect
golang.org/x/text v0.24.0 // indirect
)

View File

@@ -1,35 +1,162 @@
github.com/cockroachdb/apd v1.1.0/go.mod h1:8Sl8LxpKi29FqWXR16WEFZRNSz3SoPzUzeMeY4+DwBQ=
github.com/coreos/go-oidc/v3 v3.17.0 h1:hWBGaQfbi0iVviX4ibC7bk8OKT5qNr4klBaCHVNvehc=
github.com/coreos/go-oidc/v3 v3.17.0/go.mod h1:wqPbKFrVnE90vty060SB40FCJ8fTHTxSwyXJqZH+sI8=
github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4=
github.com/coreos/go-systemd v0.0.0-20190719114852-fd7a80b32e1f/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4=
github.com/creack/pty v1.1.7/go.mod h1:lj5s0c3V2DBrqTV7llrYr5NG6My20zk30Fl46Y7DoTY=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/go-chi/chi/v5 v5.2.3 h1:WQIt9uxdsAbgIYgid+BpYc+liqQZGMHRaUwp0JUcvdE=
github.com/go-chi/chi/v5 v5.2.3/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops=
github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs=
github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI=
github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/jackc/chunkreader v1.0.0/go.mod h1:RT6O25fNZIuasFJRyZ4R/Y2BbhasbmZXF9QQ7T3kePo=
github.com/jackc/chunkreader/v2 v2.0.0/go.mod h1:odVSm741yZoC3dpHEUXIqA9tQRhFrgOHwnPIn9lDKlk=
github.com/jackc/chunkreader/v2 v2.0.1 h1:i+RDz65UE+mmpjTfyz0MoVTnzeYxroil2G82ki7MGG8=
github.com/jackc/chunkreader/v2 v2.0.1/go.mod h1:odVSm741yZoC3dpHEUXIqA9tQRhFrgOHwnPIn9lDKlk=
github.com/jackc/pgconn v0.0.0-20190420214824-7e0022ef6ba3/go.mod h1:jkELnwuX+w9qN5YIfX0fl88Ehu4XC3keFuOJJk9pcnA=
github.com/jackc/pgconn v0.0.0-20190824142844-760dd75542eb/go.mod h1:lLjNuW/+OfW9/pnVKPazfWOgNfH2aPem8YQ7ilXGvJE=
github.com/jackc/pgconn v0.0.0-20190831204454-2fabfa3c18b7/go.mod h1:ZJKsE/KZfsUgOEh9hBm+xYTstcNHg7UPMVJqRfQxq4s=
github.com/jackc/pgconn v1.8.0/go.mod h1:1C2Pb36bGIP9QHGBYCjnyhqu7Rv3sGshaQUvmfGIB/o=
github.com/jackc/pgconn v1.9.0/go.mod h1:YctiPyvzfU11JFxoXokUOOKQXQmDMoJL9vJzHH8/2JY=
github.com/jackc/pgconn v1.13.0 h1:3L1XMNV2Zvca/8BYhzcRFS70Lr0WlDg16Di6SFGAbys=
github.com/jackc/pgconn v1.13.0/go.mod h1:AnowpAqO4CMIIJNZl2VJp+KrkAZciAkhEl0W0JIobpI=
github.com/jackc/pgio v1.0.0 h1:g12B9UwVnzGhueNavwioyEEpAmqMe1E/BN9ES+8ovkE=
github.com/jackc/pgio v1.0.0/go.mod h1:oP+2QK2wFfUWgr+gxjoBH9KGBb31Eio69xUb0w5bYf8=
github.com/jackc/pgmock v0.0.0-20190831213851-13a1b77aafa2/go.mod h1:fGZlG77KXmcq05nJLRkk0+p82V8B8Dw8KN2/V9c/OAE=
github.com/jackc/pgmock v0.0.0-20201204152224-4fe30f7445fd/go.mod h1:hrBW0Enj2AZTNpt/7Y5rr2xe/9Mn757Wtb2xeBzPv2c=
github.com/jackc/pgmock v0.0.0-20210724152146-4ad1a8207f65 h1:DadwsjnMwFjfWc9y5Wi/+Zz7xoE5ALHsRQlOctkOiHc=
github.com/jackc/pgmock v0.0.0-20210724152146-4ad1a8207f65/go.mod h1:5R2h2EEX+qri8jOWMbJCtaPWkrrNc7OHwsp2TCqp7ak=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgproto3 v1.1.0/go.mod h1:eR5FA3leWg7p9aeAqi37XOTgTIbkABlvcPB3E5rlc78=
github.com/jackc/pgproto3/v2 v2.0.0-alpha1.0.20190420180111-c116219b62db/go.mod h1:bhq50y+xrl9n5mRYyCBFKkpRVTLYJVWeCc+mEAI3yXA=
github.com/jackc/pgproto3/v2 v2.0.0-alpha1.0.20190609003834-432c2951c711/go.mod h1:uH0AWtUmuShn0bcesswc4aBTWGvw0cAxIJp+6OB//Wg=
github.com/jackc/pgproto3/v2 v2.0.0-rc3/go.mod h1:ryONWYqW6dqSg1Lw6vXNMXoBJhpzvWKnT95C46ckYeM=
github.com/jackc/pgproto3/v2 v2.0.0-rc3.0.20190831210041-4c03ce451f29/go.mod h1:ryONWYqW6dqSg1Lw6vXNMXoBJhpzvWKnT95C46ckYeM=
github.com/jackc/pgproto3/v2 v2.0.6/go.mod h1:WfJCnwN3HIg9Ish/j3sgWXnAfK8A9Y0bwXYU5xKaEdA=
github.com/jackc/pgproto3/v2 v2.1.1/go.mod h1:WfJCnwN3HIg9Ish/j3sgWXnAfK8A9Y0bwXYU5xKaEdA=
github.com/jackc/pgproto3/v2 v2.3.1 h1:nwj7qwf0S+Q7ISFfBndqeLwSwxs+4DPsbRFjECT1Y4Y=
github.com/jackc/pgproto3/v2 v2.3.1/go.mod h1:WfJCnwN3HIg9Ish/j3sgWXnAfK8A9Y0bwXYU5xKaEdA=
github.com/jackc/pgservicefile v0.0.0-20200714003250-2b9c44734f2b/go.mod h1:vsD4gTJCa9TptPL8sPkXrLZ+hDuNrZCnj29CQpr4X1E=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgtype v0.0.0-20190421001408-4ed0de4755e0/go.mod h1:hdSHsc1V01CGwFsrv11mJRHWJ6aifDLfdV3aVjFF0zg=
github.com/jackc/pgtype v0.0.0-20190824184912-ab885b375b90/go.mod h1:KcahbBH1nCMSo2DXpzsoWOAfFkdEtEJpPbVLq8eE+mc=
github.com/jackc/pgtype v0.0.0-20190828014616-a8802b16cc59/go.mod h1:MWlu30kVJrUS8lot6TQqcg7mtthZ9T0EoIBFiJcmcyw=
github.com/jackc/pgx/v4 v4.0.0-20190420224344-cc3461e65d96/go.mod h1:mdxmSJJuR08CZQyj1PVQBHy9XOp5p8/SHH6a0psbY9Y=
github.com/jackc/pgx/v4 v4.0.0-20190421002000-1b8f0016e912/go.mod h1:no/Y67Jkk/9WuGR0JG/JseM9irFbnEPbuWV2EELPNuM=
github.com/jackc/pgx/v4 v4.0.0-pre1.0.20190824185557-6972a5742186/go.mod h1:X+GQnOEnf1dqHGpw7JmHqHc1NxDoalibchSk9/RWuDc=
github.com/jackc/pgx/v5 v5.7.6 h1:rWQc5FwZSPX58r1OQmkuaNicxdmExaEz5A2DO2hUuTk=
github.com/jackc/pgx/v5 v5.7.6/go.mod h1:aruU7o91Tc2q2cFp5h4uP3f6ztExVpyVv88Xl/8Vl8M=
github.com/jackc/puddle v0.0.0-20190413234325-e4ced69a3a2b/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk=
github.com/jackc/puddle v0.0.0-20190608224051-11cab39313c9/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
github.com/konsorten/go-windows-terminal-sequences v1.0.2/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/pty v1.1.8/go.mod h1:O1sed60cT9XZ5uDucP5qwvh+TE3NnUj51EiZO/lmSfw=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/lib/pq v1.0.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
github.com/lib/pq v1.1.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
github.com/lib/pq v1.2.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
github.com/mattn/go-colorable v0.1.1/go.mod h1:FuOcm+DKB9mbwrcAfNl7/TZVBZ6rcnceauSikq3lYCQ=
github.com/mattn/go-isatty v0.0.5/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
github.com/mattn/go-isatty v0.0.7/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rs/xid v1.2.1/go.mod h1:+uKXf+4Djp6Md1KODXJxgGQPKngRmWyn10oCKFzNHOQ=
github.com/rs/zerolog v1.13.0/go.mod h1:YbFCdg8HfsridGWAh22vktObvhZbQsZXe4/zB0OKkWU=
github.com/rs/zerolog v1.15.0/go.mod h1:xYTKnLHcpfU2225ny5qZjxnj9NvkumZYjJHlAThCjNc=
github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0=
github.com/shopspring/decimal v0.0.0-20180709203117-cd690d0c9e24/go.mod h1:M+9NzErvs504Cn4c5DxATwIqPbtswREoFCre64PpcG4=
github.com/sirupsen/logrus v1.4.1/go.mod h1:ni0Sbl8bgC9z8RoU9G6nDWqqs/fq4eDPysMBDgk/93Q=
github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.2.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/zenazn/goji v0.9.0/go.mod h1:7S9M489iMyHBNxwZnk9/EHS098H4/F6TATF2mIxtB1Q=
go.uber.org/atomic v1.3.2/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE=
go.uber.org/atomic v1.4.0/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE=
go.uber.org/multierr v1.1.0/go.mod h1:wR5kodmAFQ0UK8QlbwjlSNy0Z68gJhDJUG5sjR94q/0=
go.uber.org/zap v1.9.1/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q=
go.uber.org/zap v1.10.0/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20190411191339-88737f569e3a/go.mod h1:WFFai1msRO1wXaEeE5yQxYXgSfI8pQAWXbQop6sCtWE=
golang.org/x/crypto v0.0.0-20190820162420-60c769a6c586/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20201203163018-be400aefbc4c/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I=
golang.org/x/crypto v0.0.0-20210616213533-5ff15b29337e/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.0.0-20210711020723-a769d52b0f97/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.0.0-20220722155217-630584e8d5aa/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE=
golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20190813141303-74dc4d7220e7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/oauth2 v0.28.0 h1:CrgCKl8PPAVtLnU3c+EDw6x11699EWlsDeWNWKdIOkc=
golang.org/x/oauth2 v0.28.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT8=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610=
golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190403152447-81d4e9dc473e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190813064441-fde4db37ae7a/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20=
golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0=
golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190425163242-31fd60d6bfdc/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
golang.org/x/tools v0.0.0-20190823170909-c4a336ef6a2f/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/xerrors v0.0.0-20190410155217-1f06c39b4373/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20190513163551-3ee3066db522/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/inconshreveable/log15.v2 v2.0.0-20180818164646-67afb5ed74ec/go.mod h1:aPpfJ7XW+gOuirDoZ8gHhLh3kZ1B08FtV2bbmy7Jv3s=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

@@ -1,29 +0,0 @@
package auth
import (
"testing"
)
func TestGenerateState(t *testing.T) {
state1, err := GenerateState()
if err != nil {
t.Fatal(err)
}
state2, err := GenerateState()
if err != nil {
t.Fatal(err)
}
if state1 == state2 {
t.Error("States should be unique")
}
if len(state1) == 0 {
t.Error("State should not be empty")
}
}
func TestNewService(t *testing.T) {
// Mock db
// service, err := NewService(cfg, db)
// TODO: Mock database for full test
t.Skip("Requires database mock")
}

View File

@@ -6,9 +6,11 @@ import (
"encoding/base64"
"encoding/json"
"fmt"
"strings"
"github.com/google/uuid"
"go.b0esche.cloud/backend/internal/database"
"golang.org/x/crypto/argon2"
"golang.org/x/crypto/bcrypt"
)
@@ -17,6 +19,12 @@ const (
RPID = "b0esche.cloud"
RPName = "b0esche Cloud"
Origin = "https://b0esche.cloud"
// Argon2id parameters (OWASP recommendations)
Argon2Time = 2 // iterations
Argon2Memory = 19 * 1024 // 19 MB
Argon2Threads = 1
Argon2KeyLen = 32
)
type Service struct {
@@ -284,19 +292,76 @@ func byteArraysEqual(a, b []byte) bool {
return true
}
// HashPassword hashes a password using bcrypt
// HashPassword hashes a password using Argon2id (quantum-resistant)
// Format: $argon2id$v=19$m=19456,t=2,p=1$<salt>$<hash>
func (s *Service) HashPassword(password string) (string, error) {
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return "", fmt.Errorf("failed to hash password: %w", err)
// Generate 16-byte random salt
salt := make([]byte, 16)
if _, err := rand.Read(salt); err != nil {
return "", fmt.Errorf("failed to generate salt: %w", err)
}
return string(hash), nil
// Hash with Argon2id
hash := argon2.IDKey([]byte(password), salt, Argon2Time, Argon2Memory, Argon2Threads, Argon2KeyLen)
// Encode in PHC string format
b64Salt := base64.RawStdEncoding.EncodeToString(salt)
b64Hash := base64.RawStdEncoding.EncodeToString(hash)
return fmt.Sprintf("$argon2id$v=19$m=%d,t=%d,p=%d$%s$%s",
Argon2Memory, Argon2Time, Argon2Threads, b64Salt, b64Hash), nil
}
// VerifyPassword checks if a password matches its hash
// Supports both Argon2id (new) and bcrypt (legacy) for backward compatibility
func (s *Service) VerifyPassword(passwordHash string, password string) bool {
err := bcrypt.CompareHashAndPassword([]byte(passwordHash), []byte(password))
return err == nil
// Detect hash format
if strings.HasPrefix(passwordHash, "$argon2id$") {
return s.verifyArgon2(passwordHash, password)
} else if strings.HasPrefix(passwordHash, "$2") {
// Legacy bcrypt hash
err := bcrypt.CompareHashAndPassword([]byte(passwordHash), []byte(password))
return err == nil
}
return false
}
func (s *Service) verifyArgon2(encodedHash string, password string) bool {
// Parse PHC format: $argon2id$v=19$m=19456,t=2,p=1$<salt>$<hash>
parts := strings.Split(encodedHash, "$")
if len(parts) != 6 {
return false
}
var memory, time uint32
var threads uint8
_, err := fmt.Sscanf(parts[3], "m=%d,t=%d,p=%d", &memory, &time, &threads)
if err != nil {
return false
}
salt, err := base64.RawStdEncoding.DecodeString(parts[4])
if err != nil {
return false
}
hash, err := base64.RawStdEncoding.DecodeString(parts[5])
if err != nil {
return false
}
// Compute hash with same parameters
computedHash := argon2.IDKey([]byte(password), salt, time, memory, threads, uint32(len(hash)))
// Constant-time comparison
if len(hash) != len(computedHash) {
return false
}
var diff byte
for i := 0; i < len(hash); i++ {
diff |= hash[i] ^ computedHash[i]
}
return diff == 0
}
// VerifyPasswordLogin verifies username and password credentials

View File

@@ -1,29 +1,49 @@
package config
import (
"log"
"os"
"strconv"
)
type Config struct {
ServerAddr string
DatabaseURL string
OIDCIssuerURL string
OIDCRedirectURL string
OIDCClientID string
OIDCClientSecret string
JWTSecret string
ServerAddr string
DatabaseURL string
OIDCIssuerURL string
OIDCRedirectURL string
OIDCClientID string
OIDCClientSecret string
JWTSecret string
NextcloudURL string
NextcloudUser string
NextcloudPass string
NextcloudBase string
AllowedOrigins string
AvatarCacheDir string
AvatarDownloadTimeoutSeconds int
AvatarDownloadRetries int
}
func Load() *Config {
return &Config{
ServerAddr: getEnv("SERVER_ADDR", ":8080"),
DatabaseURL: os.Getenv("DATABASE_URL"),
OIDCIssuerURL: os.Getenv("OIDC_ISSUER_URL"),
OIDCRedirectURL: os.Getenv("OIDC_REDIRECT_URL"),
OIDCClientID: os.Getenv("OIDC_CLIENT_ID"),
OIDCClientSecret: os.Getenv("OIDC_CLIENT_SECRET"),
JWTSecret: os.Getenv("JWT_SECRET"),
cfg := &Config{
ServerAddr: getEnv("SERVER_ADDR", ":8080"),
DatabaseURL: os.Getenv("DATABASE_URL"),
OIDCIssuerURL: os.Getenv("OIDC_ISSUER_URL"),
OIDCRedirectURL: os.Getenv("OIDC_REDIRECT_URL"),
OIDCClientID: os.Getenv("OIDC_CLIENT_ID"),
OIDCClientSecret: os.Getenv("OIDC_CLIENT_SECRET"),
JWTSecret: os.Getenv("JWT_SECRET"),
NextcloudURL: os.Getenv("NEXTCLOUD_URL"),
NextcloudUser: os.Getenv("NEXTCLOUD_USER"),
NextcloudPass: os.Getenv("NEXTCLOUD_PASSWORD"),
NextcloudBase: getEnv("NEXTCLOUD_BASEPATH", "/"),
AllowedOrigins: getEnv("ALLOWED_ORIGINS", "https://b0esche.cloud,https://www.b0esche.cloud,https://*.b0esche.cloud,http://localhost:8080"),
AvatarCacheDir: getEnv("AVATAR_CACHE_DIR", "/var/cache/b0esche/avatars"),
AvatarDownloadTimeoutSeconds: getEnvInt("AVATAR_DOWNLOAD_TIMEOUT_SECONDS", 20),
AvatarDownloadRetries: getEnvInt("AVATAR_DOWNLOAD_RETRIES", 3),
}
log.Printf("[CONFIG] Nextcloud URL: %q, User: %q, BasePath: %q, AvatarCacheDir: %q, AvatarDownloadTimeoutSeconds: %d, AvatarDownloadRetries: %d\n", cfg.NextcloudURL, cfg.NextcloudUser, cfg.NextcloudBase, cfg.AvatarCacheDir, cfg.AvatarDownloadTimeoutSeconds, cfg.AvatarDownloadRetries)
return cfg
}
func getEnv(key, defaultVal string) string {
@@ -32,3 +52,12 @@ func getEnv(key, defaultVal string) string {
}
return defaultVal
}
func getEnvInt(key string, defaultVal int) int {
if val := os.Getenv(key); val != "" {
if i, err := strconv.Atoi(val); err == nil {
return i
}
}
return defaultVal
}

View File

@@ -3,9 +3,13 @@ package database
import (
"context"
"database/sql"
"database/sql/driver"
"encoding/json"
"log"
"time"
"github.com/google/uuid"
"go.b0esche.cloud/backend/internal/models"
)
type DB struct {
@@ -16,14 +20,57 @@ func New(db *sql.DB) *DB {
return &DB{DB: db}
}
// StringArray handles nullable string arrays from PostgreSQL
type StringArray []string
// Scan handles NULL values properly
func (sa *StringArray) Scan(value interface{}) error {
if value == nil {
*sa = StringArray{}
return nil
}
// Handle byte slice from PostgreSQL array
if bytes, ok := value.([]byte); ok {
var arr []string
if err := json.Unmarshal(bytes, &arr); err != nil {
// If JSON parse fails, try as raw string
*sa = StringArray{string(bytes)}
return nil
}
*sa = StringArray(arr)
return nil
}
// Handle string directly
if str, ok := value.(string); ok {
if str == "" {
*sa = StringArray{}
return nil
}
*sa = StringArray{str}
return nil
}
return nil
}
// Value implements the driver.Valuer interface
func (sa StringArray) Value() (driver.Value, error) {
if len(sa) == 0 {
return nil, nil
}
return json.Marshal(sa)
}
type User struct {
ID uuid.UUID
Email string
Username string
DisplayName string
PasswordHash *string
CreatedAt time.Time
LastLoginAt *time.Time
ID uuid.UUID `json:"id"`
Email string `json:"email"`
Username string `json:"username"`
DisplayName string `json:"displayName"`
PasswordHash *string `json:"-"`
CreatedAt time.Time `json:"createdAt"`
LastLoginAt *time.Time `json:"lastLoginAt"`
}
type Credential struct {
@@ -34,7 +81,7 @@ type Credential struct {
SignCount int64
CreatedAt time.Time
LastUsedAt *time.Time
Transports []string
Transports StringArray
}
type AuthChallenge struct {
@@ -55,10 +102,12 @@ type Session struct {
}
type Organization struct {
ID uuid.UUID
Name string
Slug string
CreatedAt time.Time
ID uuid.UUID `json:"id"`
OwnerID uuid.UUID `json:"ownerId"`
Name string `json:"name"`
Slug string `json:"slug"`
InviteLinkToken *string `json:"inviteLinkToken,omitempty"`
CreatedAt time.Time `json:"createdAt"`
}
type Membership struct {
@@ -68,6 +117,26 @@ type Membership struct {
CreatedAt time.Time
}
type Invitation struct {
ID uuid.UUID `json:"id"`
OrgID uuid.UUID `json:"orgId"`
InvitedBy uuid.UUID `json:"invitedBy"`
Username string `json:"username"`
Role string `json:"role"`
CreatedAt time.Time `json:"createdAt"`
ExpiresAt time.Time `json:"expiresAt"`
AcceptedAt *time.Time `json:"acceptedAt"`
}
type JoinRequest struct {
ID uuid.UUID
OrgID uuid.UUID
UserID uuid.UUID
InviteToken *string
RequestedAt time.Time
Status string
}
type Activity struct {
ID uuid.UUID
UserID uuid.UUID
@@ -78,6 +147,20 @@ type Activity struct {
Timestamp time.Time
}
type File struct {
ID uuid.UUID
OrgID *uuid.UUID
UserID *uuid.UUID
Name string
Path string
Type string
Size int64
LastModified time.Time
CreatedAt time.Time
ModifiedBy *uuid.UUID
ModifiedByName string
}
func (db *DB) GetOrCreateUser(ctx context.Context, sub, email, name string) (*User, error) {
var user User
err := db.QueryRowContext(ctx, `
@@ -120,9 +203,18 @@ func (db *DB) GetSession(ctx context.Context, sessionID uuid.UUID) (*Session, er
return &session, nil
}
func (db *DB) RevokeSession(ctx context.Context, sessionID uuid.UUID) error {
_, err := db.ExecContext(ctx, `
UPDATE sessions
SET revoked_at = NOW()
WHERE id = $1 AND revoked_at IS NULL
`, sessionID)
return err
}
func (db *DB) GetUserOrganizations(ctx context.Context, userID uuid.UUID) ([]Organization, error) {
rows, err := db.QueryContext(ctx, `
SELECT o.id, o.name, o.slug, o.created_at
SELECT o.id, o.owner_id, o.name, o.slug, o.created_at
FROM organizations o
JOIN memberships m ON o.id = m.org_id
WHERE m.user_id = $1
@@ -135,7 +227,7 @@ func (db *DB) GetUserOrganizations(ctx context.Context, userID uuid.UUID) ([]Org
var orgs []Organization
for rows.Next() {
var org Organization
if err := rows.Scan(&org.ID, &org.Name, &org.Slug, &org.CreatedAt); err != nil {
if err := rows.Scan(&org.ID, &org.OwnerID, &org.Name, &org.Slug, &org.CreatedAt); err != nil {
return nil, err
}
orgs = append(orgs, org)
@@ -156,13 +248,20 @@ func (db *DB) GetUserMembership(ctx context.Context, userID, orgID uuid.UUID) (*
return &membership, nil
}
func (db *DB) CreateOrg(ctx context.Context, name, slug string) (*Organization, error) {
// GetOrgMember is an alias for GetUserMembership - checks if user is a member of an org
func (db *DB) GetOrgMember(ctx context.Context, orgID, userID uuid.UUID) (*Membership, error) {
return db.GetUserMembership(ctx, userID, orgID)
}
func (db *DB) CreateOrg(ctx context.Context, ownerID uuid.UUID, name, slug string) (*Organization, error) {
// Generate a unique invite link token
inviteToken := uuid.New().String()
var org Organization
err := db.QueryRowContext(ctx, `
INSERT INTO organizations (name, slug)
VALUES ($1, $2)
RETURNING id, name, slug, created_at
`, name, slug).Scan(&org.ID, &org.Name, &org.Slug, &org.CreatedAt)
INSERT INTO organizations (owner_id, name, slug, invite_link_token)
VALUES ($1, $2, $3, $4)
RETURNING id, owner_id, name, slug, invite_link_token, created_at
`, ownerID, name, slug, inviteToken).Scan(&org.ID, &org.OwnerID, &org.Name, &org.Slug, &org.InviteLinkToken, &org.CreatedAt)
if err != nil {
return nil, err
}
@@ -233,15 +332,685 @@ func (db *DB) GetOrgMembers(ctx context.Context, orgID uuid.UUID) ([]Membership,
return memberships, rows.Err()
}
func (db *DB) UpdateMemberRole(ctx context.Context, orgID, userID uuid.UUID, role string) error {
// GetOrgMembersWithUsers returns members with user details
func (db *DB) GetOrgMembersWithUsers(ctx context.Context, orgID uuid.UUID) ([]struct {
Membership
User
}, error) {
rows, err := db.QueryContext(ctx, `
SELECT m.user_id, m.org_id, m.role, m.created_at,
u.id, u.email, u.username, u.display_name, u.created_at, u.last_login_at
FROM memberships m
JOIN users u ON m.user_id = u.id
WHERE m.org_id = $1
ORDER BY m.created_at
`, orgID)
if err != nil {
return nil, err
}
defer rows.Close()
members := make([]struct {
Membership
User
}, 0)
for rows.Next() {
var m struct {
Membership
User
}
err := rows.Scan(
&m.Membership.UserID, &m.Membership.OrgID, &m.Membership.Role, &m.Membership.CreatedAt,
&m.User.ID, &m.User.Email, &m.User.Username, &m.User.DisplayName, &m.User.CreatedAt, &m.User.LastLoginAt,
)
if err != nil {
return nil, err
}
members = append(members, m)
}
return members, rows.Err()
}
// UpdateMemberRole updates a member's role
func (db *DB) UpdateMemberRole(ctx context.Context, orgID, userID uuid.UUID, newRole string) error {
_, err := db.ExecContext(ctx, `
UPDATE memberships
SET role = $1
WHERE org_id = $2 AND user_id = $3
`, role, orgID, userID)
`, newRole, orgID, userID)
return err
}
// RemoveMember removes a user from an organization
func (db *DB) RemoveMember(ctx context.Context, orgID, userID uuid.UUID) error {
_, err := db.ExecContext(ctx, `
DELETE FROM memberships
WHERE org_id = $1 AND user_id = $2
`, orgID, userID)
return err
}
// SearchUsersByUsername searches users by partial username match
func (db *DB) SearchUsersByUsername(ctx context.Context, query string, limit int) ([]User, error) {
if limit <= 0 {
limit = 10
}
rows, err := db.QueryContext(ctx, `
SELECT id, email, username, display_name, created_at, last_login_at
FROM users
WHERE username ILIKE $1
ORDER BY username
LIMIT $2
`, "%"+query+"%", limit)
if err != nil {
return nil, err
}
defer rows.Close()
users := make([]User, 0)
for rows.Next() {
var u User
err := rows.Scan(&u.ID, &u.Email, &u.Username, &u.DisplayName, &u.CreatedAt, &u.LastLoginAt)
if err != nil {
return nil, err
}
users = append(users, u)
}
return users, rows.Err()
}
// CreateInvitation creates a new invitation
func (db *DB) CreateInvitation(ctx context.Context, orgID, invitedBy uuid.UUID, username, role string) (*Invitation, error) {
var inv Invitation
err := db.QueryRowContext(ctx, `
INSERT INTO invitations (org_id, invited_by, username, role)
VALUES ($1, $2, $3, $4)
RETURNING id, org_id, invited_by, username, role, created_at, expires_at, accepted_at
`, orgID, invitedBy, username, role).Scan(
&inv.ID, &inv.OrgID, &inv.InvitedBy, &inv.Username, &inv.Role,
&inv.CreatedAt, &inv.ExpiresAt, &inv.AcceptedAt,
)
if err != nil {
return nil, err
}
return &inv, nil
}
// GetOrgInvitations returns pending invitations for an org
func (db *DB) GetOrgInvitations(ctx context.Context, orgID uuid.UUID) ([]Invitation, error) {
rows, err := db.QueryContext(ctx, `
SELECT id, org_id, invited_by, username, role, created_at, expires_at, accepted_at
FROM invitations
WHERE org_id = $1 AND accepted_at IS NULL AND expires_at > NOW()
ORDER BY created_at DESC
`, orgID)
if err != nil {
return nil, err
}
defer rows.Close()
invitations := make([]Invitation, 0)
for rows.Next() {
var inv Invitation
err := rows.Scan(
&inv.ID, &inv.OrgID, &inv.InvitedBy, &inv.Username, &inv.Role,
&inv.CreatedAt, &inv.ExpiresAt, &inv.AcceptedAt,
)
if err != nil {
return nil, err
}
invitations = append(invitations, inv)
}
return invitations, rows.Err()
}
// CancelInvitation cancels an invitation
func (db *DB) CancelInvitation(ctx context.Context, invitationID uuid.UUID) error {
_, err := db.ExecContext(ctx, `
DELETE FROM invitations
WHERE id = $1
`, invitationID)
return err
}
// CreateJoinRequest creates a join request
func (db *DB) CreateJoinRequest(ctx context.Context, orgID, userID uuid.UUID, inviteToken *string) (*JoinRequest, error) {
var req JoinRequest
err := db.QueryRowContext(ctx, `
INSERT INTO join_requests (org_id, user_id, invite_token)
VALUES ($1, $2, $3)
ON CONFLICT (org_id, user_id) DO UPDATE SET
invite_token = EXCLUDED.invite_token,
requested_at = NOW(),
status = 'pending'
RETURNING id, org_id, user_id, invite_token, requested_at, status
`, orgID, userID, inviteToken).Scan(
&req.ID, &req.OrgID, &req.UserID, &req.InviteToken, &req.RequestedAt, &req.Status,
)
if err != nil {
return nil, err
}
return &req, nil
}
// GetOrgJoinRequests returns pending join requests for an org
func (db *DB) GetOrgJoinRequests(ctx context.Context, orgID uuid.UUID) ([]struct {
JoinRequest
User
}, error) {
rows, err := db.QueryContext(ctx, `
SELECT jr.id, jr.org_id, jr.user_id, jr.invite_token, jr.requested_at, jr.status,
u.id, u.email, u.username, u.display_name, u.created_at, u.last_login_at
FROM join_requests jr
JOIN users u ON jr.user_id = u.id
WHERE jr.org_id = $1 AND jr.status = 'pending'
ORDER BY jr.requested_at DESC
`, orgID)
if err != nil {
return nil, err
}
defer rows.Close()
requests := make([]struct {
JoinRequest
User
}, 0)
for rows.Next() {
var r struct {
JoinRequest
User
}
err := rows.Scan(
&r.JoinRequest.ID, &r.JoinRequest.OrgID, &r.JoinRequest.UserID, &r.JoinRequest.InviteToken, &r.JoinRequest.RequestedAt, &r.JoinRequest.Status,
&r.User.ID, &r.User.Email, &r.User.Username, &r.User.DisplayName, &r.User.CreatedAt, &r.User.LastLoginAt,
)
if err != nil {
return nil, err
}
requests = append(requests, r)
}
return requests, rows.Err()
}
// AcceptJoinRequest accepts a join request and adds the user as member
func (db *DB) AcceptJoinRequest(ctx context.Context, requestID uuid.UUID, role string) error {
// Get the request details
var orgID, userID uuid.UUID
err := db.QueryRowContext(ctx, `
SELECT org_id, user_id
FROM join_requests
WHERE id = $1 AND status = 'pending'
`, requestID).Scan(&orgID, &userID)
if err != nil {
return err
}
// Add membership
err = db.AddMembership(ctx, userID, orgID, role)
if err != nil {
return err
}
// Mark request as accepted
_, err = db.ExecContext(ctx, `
UPDATE join_requests
SET status = 'accepted'
WHERE id = $1
`, requestID)
return err
}
// RejectJoinRequest rejects a join request
func (db *DB) RejectJoinRequest(ctx context.Context, requestID uuid.UUID) error {
_, err := db.ExecContext(ctx, `
UPDATE join_requests
SET status = 'rejected'
WHERE id = $1
`, requestID)
return err
}
// GetInviteLink returns the invite link token for an org
func (db *DB) GetInviteLink(ctx context.Context, orgID uuid.UUID) (*string, error) {
var token *string
err := db.QueryRowContext(ctx, `
SELECT invite_link_token
FROM organizations
WHERE id = $1
`, orgID).Scan(&token)
if err != nil {
return nil, err
}
return token, nil
}
// RegenerateInviteLink generates a new invite link token
func (db *DB) RegenerateInviteLink(ctx context.Context, orgID uuid.UUID) (*string, error) {
newToken := uuid.New().String()
_, err := db.ExecContext(ctx, `
UPDATE organizations
SET invite_link_token = $1
WHERE id = $2
`, newToken, orgID)
if err != nil {
return nil, err
}
return &newToken, nil
}
// GetOrgFiles returns files for a given organization (top-level folder listing)
func (db *DB) GetOrgFiles(ctx context.Context, orgID uuid.UUID, userID uuid.UUID, path string, q string, page, pageSize int) ([]File, error) {
if page <= 0 {
page = 1
}
if pageSize <= 0 {
pageSize = 100
}
offset := (page - 1) * pageSize
orgIDStr := orgID.String()
userIDStr := userID.String()
log.Printf("[DATA-ISOLATION] stage=before, action=list, orgId=%s, userId=%s, fileCount=0, path=%s", orgIDStr, userIDStr, path)
// Basic search and pagination. Returns only direct children of the given path.
// For root ("/"), we want files where path doesn't contain "/" after the first character.
// For subdirs, we want files where path starts with parent but has no additional "/" after parent.
rows, err := db.QueryContext(ctx, `
SELECT f.id, f.org_id::text, f.user_id::text, f.name, f.path, f.type, f.size, f.last_modified, f.created_at
FROM files f
WHERE f.org_id = $1
AND EXISTS (
SELECT 1
FROM memberships m
WHERE m.org_id = $1 AND m.user_id = $2
)
AND f.path != $3
AND (
($3 = '/' AND f.path LIKE '/%' AND f.path NOT LIKE '/%/%')
OR ($3 != '/' AND f.path LIKE $3 || '/%' AND f.path NOT LIKE $3 || '/%/%')
)
AND ($4 = '' OR f.name ILIKE '%' || $4 || '%')
ORDER BY CASE WHEN f.type = 'folder' THEN 0 ELSE 1 END, f.name
LIMIT $5 OFFSET $6
`, orgID, userID, path, q, pageSize, offset)
if err != nil {
return nil, err
}
defer rows.Close()
var files []File
for rows.Next() {
var f File
var orgNull sql.NullString
var userNull sql.NullString
if err := rows.Scan(&f.ID, &orgNull, &userNull, &f.Name, &f.Path, &f.Type, &f.Size, &f.LastModified, &f.CreatedAt); err != nil {
return nil, err
}
if orgNull.Valid {
oid, _ := uuid.Parse(orgNull.String)
f.OrgID = &oid
}
if userNull.Valid {
uid, _ := uuid.Parse(userNull.String)
f.UserID = &uid
}
files = append(files, f)
}
err = rows.Err()
if err == nil {
log.Printf("[DATA-ISOLATION] stage=after, action=list, orgId=%s, userId=%s, fileCount=%d, path=%s", orgIDStr, userIDStr, len(files), path)
}
return files, err
}
// GetAllOrgFilesUnderPath returns all files recursively under the given path for an org
func (db *DB) GetAllOrgFilesUnderPath(ctx context.Context, orgID uuid.UUID, userID uuid.UUID, path string) ([]File, error) {
orgIDStr := orgID.String()
userIDStr := userID.String()
log.Printf("[DATA-ISOLATION] stage=before, action=list_recursive, orgId=%s, userId=%s, path=%s", orgIDStr, userIDStr, path)
rows, err := db.QueryContext(ctx, `
SELECT f.id, f.org_id::text, f.user_id::text, f.name, f.path, f.type, f.size, f.last_modified, f.created_at
FROM files f
WHERE f.org_id = $1
AND EXISTS (
SELECT 1
FROM memberships m
WHERE m.org_id = $1 AND m.user_id = $2
)
AND f.path LIKE $3 || '%'
AND f.path != $3
ORDER BY f.path
`, orgID, userID, path)
if err != nil {
return nil, err
}
defer rows.Close()
var files []File
for rows.Next() {
var f File
var orgNull sql.NullString
var userNull sql.NullString
if err := rows.Scan(&f.ID, &orgNull, &userNull, &f.Name, &f.Path, &f.Type, &f.Size, &f.LastModified, &f.CreatedAt); err != nil {
return nil, err
}
if orgNull.Valid {
oid, _ := uuid.Parse(orgNull.String)
f.OrgID = &oid
}
if userNull.Valid {
uid, _ := uuid.Parse(userNull.String)
f.UserID = &uid
}
files = append(files, f)
}
err = rows.Err()
if err == nil {
log.Printf("[DATA-ISOLATION] stage=after, action=list_recursive, orgId=%s, userId=%s, fileCount=%d, path=%s", orgIDStr, userIDStr, len(files), path)
}
return files, err
}
// GetUserFiles returns files for a user's personal workspace at a given path
func (db *DB) GetUserFiles(ctx context.Context, userID uuid.UUID, path string, q string, page, pageSize int) ([]File, error) {
if page <= 0 {
page = 1
}
if pageSize <= 0 {
pageSize = 100
}
offset := (page - 1) * pageSize
// Return only direct children of the given path
log.Printf("[DATA-ISOLATION] stage=before, action=list, orgId=, userId=%s, fileCount=0, path=%s", userID.String(), path)
rows, err := db.QueryContext(ctx, `
SELECT id, org_id::text, user_id::text, name, path, type, size, last_modified, created_at
FROM files
WHERE user_id = $1
AND org_id IS NULL
AND path != $2
AND (
($2 = '/' AND path LIKE '/%' AND path NOT LIKE '/%/%')
OR ($2 != '/' AND path LIKE $2 || '/%' AND path NOT LIKE $2 || '/%/%')
)
AND ($3 = '' OR name ILIKE '%' || $3 || '%')
ORDER BY CASE WHEN type = 'folder' THEN 0 ELSE 1 END, name
LIMIT $4 OFFSET $5
`, userID, path, q, pageSize, offset)
if err != nil {
return nil, err
}
defer rows.Close()
var files []File
for rows.Next() {
var f File
var orgNull sql.NullString
var userNull sql.NullString
if err := rows.Scan(&f.ID, &orgNull, &userNull, &f.Name, &f.Path, &f.Type, &f.Size, &f.LastModified, &f.CreatedAt); err != nil {
return nil, err
}
if orgNull.Valid {
oid, _ := uuid.Parse(orgNull.String)
f.OrgID = &oid
}
if userNull.Valid {
uid, _ := uuid.Parse(userNull.String)
f.UserID = &uid
}
files = append(files, f)
}
err = rows.Err()
if err == nil {
log.Printf("[DATA-ISOLATION] stage=after, action=list, orgId=, userId=%s, fileCount=%d, path=%s", userID.String(), len(files), path)
}
return files, err
}
// GetAllUserFilesUnderPath returns all files recursively under the given path for a user
func (db *DB) GetAllUserFilesUnderPath(ctx context.Context, userID uuid.UUID, path string) ([]File, error) {
// Return all descendants of the given path
log.Printf("[DATA-ISOLATION] stage=before, action=list_recursive, orgId=, userId=%s, path=%s", userID.String(), path)
rows, err := db.QueryContext(ctx, `
SELECT id, org_id::text, user_id::text, name, path, type, size, last_modified, created_at
FROM files
WHERE user_id = $1
AND org_id IS NULL
AND path LIKE $2 || '%'
AND path != $2
ORDER BY path
`, userID, path)
if err != nil {
return nil, err
}
defer rows.Close()
var files []File
for rows.Next() {
var f File
var orgNull sql.NullString
var userNull sql.NullString
if err := rows.Scan(&f.ID, &orgNull, &userNull, &f.Name, &f.Path, &f.Type, &f.Size, &f.LastModified, &f.CreatedAt); err != nil {
return nil, err
}
if orgNull.Valid {
oid, _ := uuid.Parse(orgNull.String)
f.OrgID = &oid
}
if userNull.Valid {
uid, _ := uuid.Parse(userNull.String)
f.UserID = &uid
}
files = append(files, f)
}
err = rows.Err()
if err == nil {
log.Printf("[DATA-ISOLATION] stage=after, action=list_recursive, orgId=, userId=%s, fileCount=%d, path=%s", userID.String(), len(files), path)
}
return files, err
}
// CreateFile inserts a file or folder record. orgID or userID may be nil.
func (db *DB) CreateFile(ctx context.Context, orgID *uuid.UUID, userID *uuid.UUID, name, path, fileType string, size int64) (*File, error) {
var f File
var orgIDVal interface{}
var userIDVal interface{}
orgIDStr := ""
userIDStr := ""
if orgID != nil {
orgIDVal = *orgID
orgIDStr = orgID.String()
} else {
orgIDVal = nil
}
if userID != nil {
userIDVal = *userID
userIDStr = userID.String()
} else {
userIDVal = nil
}
log.Printf("[DATA-ISOLATION] stage=before, action=create, orgId=%s, userId=%s, fileCount=1, path=%s", orgIDStr, userIDStr, path)
err := db.QueryRowContext(ctx, `
INSERT INTO files (org_id, user_id, name, path, type, size)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING id, org_id::text, user_id::text, name, path, type, size, last_modified, created_at
`, orgIDVal, userIDVal, name, path, fileType, size).Scan(&f.ID, new(sql.NullString), new(sql.NullString), &f.Name, &f.Path, &f.Type, &f.Size, &f.LastModified, &f.CreatedAt)
if err != nil {
return nil, err
}
log.Printf("[DATA-ISOLATION] stage=after, action=create, orgId=%s, userId=%s, fileCount=1, path=%s", orgIDStr, userIDStr, f.Path)
return &f, nil
}
// GetFileByID retrieves a file by its ID
func (db *DB) GetFileByID(ctx context.Context, fileID uuid.UUID) (*File, error) {
var f File
var orgNull sql.NullString
var userNull sql.NullString
var modifiedByNull sql.NullString
var modifiedByNameNull sql.NullString
err := db.QueryRowContext(ctx, `
SELECT f.id, f.org_id::text, f.user_id::text, f.name, f.path, f.type, f.size, f.last_modified, f.created_at,
f.modified_by::text, u.display_name
FROM files f
LEFT JOIN users u ON f.modified_by = u.id
WHERE f.id = $1
`, fileID).Scan(&f.ID, &orgNull, &userNull, &f.Name, &f.Path, &f.Type, &f.Size, &f.LastModified, &f.CreatedAt,
&modifiedByNull, &modifiedByNameNull)
if err != nil {
return nil, err
}
if orgNull.Valid {
oid, _ := uuid.Parse(orgNull.String)
f.OrgID = &oid
}
if userNull.Valid {
uid, _ := uuid.Parse(userNull.String)
f.UserID = &uid
}
if modifiedByNull.Valid {
mid, _ := uuid.Parse(modifiedByNull.String)
f.ModifiedBy = &mid
}
if modifiedByNameNull.Valid {
f.ModifiedByName = modifiedByNameNull.String
}
return &f, nil
}
// GetOrgFileByPath returns a file by path for an org
func (db *DB) GetOrgFileByPath(ctx context.Context, orgID uuid.UUID, userID uuid.UUID, path string) (*File, error) {
var f File
var orgNull sql.NullString
var userNull sql.NullString
var modifiedByNull sql.NullString
var modifiedByNameNull sql.NullString
err := db.QueryRowContext(ctx, `
SELECT f.id, f.org_id::text, f.user_id::text, f.name, f.path, f.type, f.size, f.last_modified, f.created_at,
f.modified_by::text, u.display_name
FROM files f
LEFT JOIN users u ON f.modified_by = u.id
WHERE f.org_id = $1
AND EXISTS (
SELECT 1
FROM memberships m
WHERE m.org_id = $1 AND m.user_id = $2
)
AND f.path = $3
`, orgID, userID, path).Scan(&f.ID, &orgNull, &userNull, &f.Name, &f.Path, &f.Type, &f.Size, &f.LastModified, &f.CreatedAt,
&modifiedByNull, &modifiedByNameNull)
if err != nil {
return nil, err
}
if orgNull.Valid {
oid, _ := uuid.Parse(orgNull.String)
f.OrgID = &oid
}
if userNull.Valid {
uid, _ := uuid.Parse(userNull.String)
f.UserID = &uid
}
if modifiedByNull.Valid {
mid, _ := uuid.Parse(modifiedByNull.String)
f.ModifiedBy = &mid
}
if modifiedByNameNull.Valid {
f.ModifiedByName = modifiedByNameNull.String
}
return &f, nil
}
// GetUserFileByPath returns a file by path for a user
func (db *DB) GetUserFileByPath(ctx context.Context, userID uuid.UUID, path string) (*File, error) {
var f File
var orgNull sql.NullString
var userNull sql.NullString
var modifiedByNull sql.NullString
var modifiedByNameNull sql.NullString
err := db.QueryRowContext(ctx, `
SELECT f.id, f.org_id::text, f.user_id::text, f.name, f.path, f.type, f.size, f.last_modified, f.created_at,
f.modified_by::text, u.display_name
FROM files f
LEFT JOIN users u ON f.modified_by = u.id
WHERE f.user_id = $1
AND f.org_id IS NULL
AND f.path = $2
`, userID, path).Scan(&f.ID, &orgNull, &userNull, &f.Name, &f.Path, &f.Type, &f.Size, &f.LastModified, &f.CreatedAt,
&modifiedByNull, &modifiedByNameNull)
if err != nil {
return nil, err
}
if orgNull.Valid {
oid, _ := uuid.Parse(orgNull.String)
f.OrgID = &oid
}
if userNull.Valid {
uid, _ := uuid.Parse(userNull.String)
f.UserID = &uid
}
if modifiedByNull.Valid {
mid, _ := uuid.Parse(modifiedByNull.String)
f.ModifiedBy = &mid
}
if modifiedByNameNull.Valid {
f.ModifiedByName = modifiedByNameNull.String
}
return &f, nil
}
// UpdateFileSize updates the size, modification time, and modifier of a file
func (db *DB) UpdateFileSize(ctx context.Context, fileID uuid.UUID, size int64, modifiedBy *uuid.UUID) error {
_, err := db.ExecContext(ctx, `
UPDATE files
SET size = $1, last_modified = NOW(), modified_by = $3
WHERE id = $2
`, size, fileID, modifiedBy)
return err
}
// UpdateFilePath updates the path and name of a file while preserving its ID
// This is used when moving/renaming files to ensure WOPI sessions remain valid
func (db *DB) UpdateFilePath(ctx context.Context, fileID uuid.UUID, newName, newPath string) error {
_, err := db.ExecContext(ctx, `
UPDATE files
SET name = $1, path = $2, last_modified = NOW()
WHERE id = $3
`, newName, newPath, fileID)
return err
}
// DeleteFileByPath removes a file or folder matching path for a given org or user
func (db *DB) DeleteFileByPath(ctx context.Context, orgID *uuid.UUID, userID *uuid.UUID, path string) error {
var res sql.Result
var err error
if orgID != nil {
res, err = db.ExecContext(ctx, `DELETE FROM files WHERE org_id = $1 AND path = $2`, *orgID, path)
} else if userID != nil {
res, err = db.ExecContext(ctx, `DELETE FROM files WHERE user_id = $1 AND path = $2`, *userID, path)
} else {
return nil
}
if err != nil {
return err
}
_, _ = res.RowsAffected()
return nil
}
// Passkey-related methods
func (db *DB) CreateUser(ctx context.Context, username, email, displayName string, passwordHash *string) (*User, error) {
@@ -391,3 +1160,113 @@ func (db *DB) MarkChallengeUsed(ctx context.Context, challenge []byte) error {
`, challenge)
return err
}
// FileShareLink methods
// CreateFileShareLink creates a new share link for a file
func (db *DB) CreateFileShareLink(ctx context.Context, token string, fileID uuid.UUID, orgID *uuid.UUID, createdByUserID uuid.UUID) (*models.FileShareLink, error) {
var link models.FileShareLink
var expiresAtNull sql.NullTime
var orgIDNull sql.NullString
// If caller didn't provide an orgID, try to infer it from the file record
if orgID == nil {
var fileOrgNull sql.NullString
fileErr := db.QueryRowContext(ctx, `SELECT org_id::text FROM files WHERE id = $1`, fileID).Scan(&fileOrgNull)
if fileErr == nil && fileOrgNull.Valid {
if parsed, perr := uuid.Parse(fileOrgNull.String); perr == nil {
orgID = &parsed
}
}
// If the file lookup failed or org_id is not set, orgID remains nil
}
err := db.QueryRowContext(ctx, `
INSERT INTO file_share_links (token, file_id, org_id, created_by_user_id)
VALUES ($1, $2, $3, $4)
RETURNING id, token, file_id, org_id, created_by_user_id, created_at, updated_at, expires_at, is_revoked
`, token, fileID, orgID, createdByUserID).Scan(
&link.ID, &link.Token, &link.FileID, &orgIDNull, &link.CreatedByUserID,
&link.CreatedAt, &link.UpdatedAt, &expiresAtNull, &link.IsRevoked)
if err != nil {
return nil, err
}
if orgIDNull.Valid {
parsed, err := uuid.Parse(orgIDNull.String)
if err != nil {
return nil, err
}
link.OrgID = &parsed
}
if expiresAtNull.Valid {
link.ExpiresAt = &expiresAtNull.Time
}
return &link, nil
}
// GetFileShareLinkByFileID gets the active share link for a file
func (db *DB) GetFileShareLinkByFileID(ctx context.Context, fileID uuid.UUID) (*models.FileShareLink, error) {
var link models.FileShareLink
var expiresAtNull sql.NullTime
var orgIDNull sql.NullString
err := db.QueryRowContext(ctx, `
SELECT id, token, file_id, org_id, created_by_user_id, created_at, updated_at, expires_at, is_revoked
FROM file_share_links
WHERE file_id = $1 AND is_revoked = FALSE AND (expires_at IS NULL OR expires_at > NOW())
ORDER BY created_at DESC
LIMIT 1
`, fileID).Scan(
&link.ID, &link.Token, &link.FileID, &orgIDNull, &link.CreatedByUserID,
&link.CreatedAt, &link.UpdatedAt, &expiresAtNull, &link.IsRevoked)
if err != nil {
return nil, err
}
if orgIDNull.Valid {
parsed, err := uuid.Parse(orgIDNull.String)
if err != nil {
return nil, err
}
link.OrgID = &parsed
}
if expiresAtNull.Valid {
link.ExpiresAt = &expiresAtNull.Time
}
return &link, nil
}
// GetFileShareLinkByToken gets a share link by token
func (db *DB) GetFileShareLinkByToken(ctx context.Context, token string) (*models.FileShareLink, error) {
var link models.FileShareLink
var expiresAtNull sql.NullTime
var orgIDNull sql.NullString
err := db.QueryRowContext(ctx, `
SELECT id, token, file_id, org_id, created_by_user_id, created_at, updated_at, expires_at, is_revoked
FROM file_share_links
WHERE token = $1 AND is_revoked = FALSE AND (expires_at IS NULL OR expires_at > NOW())
`, token).Scan(
&link.ID, &link.Token, &link.FileID, &orgIDNull, &link.CreatedByUserID,
&link.CreatedAt, &link.UpdatedAt, &expiresAtNull, &link.IsRevoked)
if err != nil {
return nil, err
}
if orgIDNull.Valid {
parsed, err := uuid.Parse(orgIDNull.String)
if err != nil {
return nil, err
}
link.OrgID = &parsed
}
if expiresAtNull.Valid {
link.ExpiresAt = &expiresAtNull.Time
}
return &link, nil
}
// RevokeFileShareLink revokes a share link
func (db *DB) RevokeFileShareLink(ctx context.Context, fileID uuid.UUID) error {
_, err := db.ExecContext(ctx, `
UPDATE file_share_links
SET is_revoked = TRUE, updated_at = NOW()
WHERE file_id = $1 AND is_revoked = FALSE
`, fileID)
return err
}

View File

@@ -6,7 +6,7 @@ import (
"net/http"
"os"
"github.com/go-chi/chi/v5/middleware"
chimiddleware "github.com/go-chi/chi/v5/middleware"
"github.com/google/uuid"
)
@@ -14,10 +14,15 @@ import (
type ErrorCode string
const (
CodeUnauthenticated ErrorCode = "UNAUTHENTICATED"
CodeUnauthenticated ErrorCode = "UNAUTHENTICATED"
// More specific authentication error codes
CodeInvalidCredentials ErrorCode = "INVALID_CREDENTIALS"
CodeInvalidPassword ErrorCode = "INVALID_PASSWORD"
CodePermissionDenied ErrorCode = "PERMISSION_DENIED"
CodeNotFound ErrorCode = "NOT_FOUND"
CodeConflict ErrorCode = "CONFLICT"
CodeAlreadyExists ErrorCode = "ALREADY_EXISTS"
CodeInvalidArgument ErrorCode = "INVALID_ARGUMENT"
CodeInternal ErrorCode = "INTERNAL"
)
@@ -40,7 +45,7 @@ func WriteError(w http.ResponseWriter, code ErrorCode, message string, status in
// GetRequestID extracts the request ID from the request context
func GetRequestID(r *http.Request) string {
if reqID := middleware.GetReqID(r.Context()); reqID != "" {
if reqID := chimiddleware.GetReqID(r.Context()); reqID != "" {
return reqID
}
return "unknown"
@@ -48,10 +53,10 @@ func GetRequestID(r *http.Request) string {
// GetUserID extracts user ID from context if available
func GetUserID(r *http.Request) string {
if userID := r.Context().Value("user"); userID != nil {
if uid, ok := userID.(string); ok {
return uid
}
// Use type contextKey matching middleware package
type contextKey string
if userID, ok := r.Context().Value(contextKey("user")).(string); ok && userID != "" {
return userID
}
return ""
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,846 @@
package http
import (
"encoding/json"
"encoding/xml"
"fmt"
"io"
"net/http"
"net/url"
"path"
"strings"
"sync"
"time"
"github.com/google/uuid"
"go.b0esche.cloud/backend/internal/config"
"go.b0esche.cloud/backend/internal/database"
"go.b0esche.cloud/backend/internal/errors"
"go.b0esche.cloud/backend/internal/middleware"
"go.b0esche.cloud/backend/internal/models"
"go.b0esche.cloud/backend/internal/storage"
"go.b0esche.cloud/backend/pkg/jwt"
)
// Collabora discovery cache
var (
collaboraEditorURL string
collaboraDiscoveryCache time.Time
collaboraDiscoveryMu sync.RWMutex
)
// getCollaboraEditorURL fetches the editor URL from Collabora's discovery endpoint
func getCollaboraEditorURL(collaboraBaseURL string) string {
collaboraDiscoveryMu.RLock()
// Cache for 5 minutes
if collaboraEditorURL != "" && time.Since(collaboraDiscoveryCache) < 5*time.Minute {
url := collaboraEditorURL
collaboraDiscoveryMu.RUnlock()
return url
}
collaboraDiscoveryMu.RUnlock()
// Fetch discovery
collaboraDiscoveryMu.Lock()
defer collaboraDiscoveryMu.Unlock()
// Double-check after acquiring write lock
if collaboraEditorURL != "" && time.Since(collaboraDiscoveryCache) < 5*time.Minute {
return collaboraEditorURL
}
discoveryURL := collaboraBaseURL + "/hosting/discovery"
resp, err := http.Get(discoveryURL)
if err != nil {
fmt.Printf("[COLLABORA] Failed to fetch discovery: %v\n", err)
// Fallback to guessed URL
return collaboraBaseURL + "/browser/dist/cool.html"
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
fmt.Printf("[COLLABORA] Failed to read discovery: %v\n", err)
return collaboraBaseURL + "/browser/dist/cool.html"
}
// Parse XML to extract urlsrc
type Action struct {
Name string `xml:"name,attr"`
Ext string `xml:"ext,attr"`
URLSrc string `xml:"urlsrc,attr"`
}
type App struct {
Name string `xml:"name,attr"`
Actions []Action `xml:"action"`
}
type NetZone struct {
Apps []App `xml:"app"`
}
type WopiDiscovery struct {
NetZone NetZone `xml:"net-zone"`
}
var discovery WopiDiscovery
if err := xml.Unmarshal(body, &discovery); err != nil {
fmt.Printf("[COLLABORA] Failed to parse discovery XML: %v\n", err)
return collaboraBaseURL + "/browser/dist/cool.html"
}
// Find the first edit action URL (they all have the same base)
for _, app := range discovery.NetZone.Apps {
for _, action := range app.Actions {
if action.URLSrc != "" {
// Extract base URL (remove query string marker)
url := strings.TrimSuffix(action.URLSrc, "?")
collaboraEditorURL = url
collaboraDiscoveryCache = time.Now()
fmt.Printf("[COLLABORA] Discovered editor URL: %s\n", url)
return url
}
}
}
fmt.Printf("[COLLABORA] No editor URL found in discovery\n")
return collaboraBaseURL + "/browser/dist/cool.html"
}
// WOPILockManager manages file locks to prevent concurrent editing conflicts
type WOPILockManager struct {
locks map[string]*models.WOPILockInfo
mu sync.RWMutex
}
var lockManager = &WOPILockManager{
locks: make(map[string]*models.WOPILockInfo),
}
// AcquireLock tries to acquire a lock for a file
func (m *WOPILockManager) AcquireLock(fileID, userID string) (string, error) {
m.mu.Lock()
defer m.mu.Unlock()
if existing, ok := m.locks[fileID]; ok {
// Check if lock has expired
if time.Now().Before(existing.ExpiresAt) {
// Lock still active - check if same user
if existing.UserID != userID {
fmt.Printf("[WOPI-LOCK] Lock conflict: file=%s locked_by=%s requested_by=%s\n", fileID, existing.UserID, userID)
return "", fmt.Errorf("file locked by another user")
}
// Same user, refresh the lock
lockID := uuid.New().String()
m.locks[fileID] = &models.WOPILockInfo{
FileID: fileID,
UserID: userID,
LockID: lockID,
CreatedAt: time.Now(),
ExpiresAt: time.Now().Add(30 * time.Minute),
}
fmt.Printf("[WOPI-LOCK] Lock refreshed: file=%s user=%s lock_id=%s\n", fileID, userID, lockID)
return lockID, nil
}
// Lock expired, remove it
delete(m.locks, fileID)
}
// Acquire new lock
lockID := uuid.New().String()
m.locks[fileID] = &models.WOPILockInfo{
FileID: fileID,
UserID: userID,
LockID: lockID,
CreatedAt: time.Now(),
ExpiresAt: time.Now().Add(30 * time.Minute),
}
fmt.Printf("[WOPI-LOCK] Lock acquired: file=%s user=%s lock_id=%s\n", fileID, userID, lockID)
return lockID, nil
}
// ReleaseLock releases a lock for a file
func (m *WOPILockManager) ReleaseLock(fileID, userID string) error {
m.mu.Lock()
defer m.mu.Unlock()
if lock, ok := m.locks[fileID]; ok {
if lock.UserID == userID {
delete(m.locks, fileID)
fmt.Printf("[WOPI-LOCK] Lock released: file=%s user=%s\n", fileID, userID)
return nil
}
return fmt.Errorf("lock held by different user")
}
return fmt.Errorf("no lock found")
}
// GetLock returns the current lock info for a file
func (m *WOPILockManager) GetLock(fileID string) *models.WOPILockInfo {
m.mu.RLock()
defer m.mu.RUnlock()
if lock, ok := m.locks[fileID]; ok {
// Check if expired
if time.Now().Before(lock.ExpiresAt) {
return lock
}
// Expired, will be cleaned up on next acquire attempt
}
return nil
}
// validateWOPIAccessToken validates a WOPI access token
func validateWOPIAccessToken(tokenString string, jwtManager *jwt.Manager) (*jwt.Claims, error) {
claims, err := jwtManager.Validate(tokenString)
if err != nil {
fmt.Printf("[WOPI-TOKEN] Token validation failed: %v\n", err)
return nil, err
}
// Check if token has expired
if time.Now().After(claims.ExpiresAt.Time) {
fmt.Printf("[WOPI-TOKEN] Token expired: user=%s\n", claims.UserID)
return nil, fmt.Errorf("token expired")
}
fmt.Printf("[WOPI-TOKEN] Token validated: user=%s expires=%v\n", claims.UserID, claims.ExpiresAt.Time)
return claims, nil
}
// WOPICheckFileInfoHandler handles GET /wopi/files/{fileId}
// Returns metadata about the file and user permissions
func wopiCheckFileInfoHandler(w http.ResponseWriter, r *http.Request, db *database.DB, jwtManager *jwt.Manager) {
fileID := r.PathValue("fileId")
if fileID == "" {
errors.WriteError(w, errors.CodeInvalidArgument, "Missing fileId", http.StatusBadRequest)
return
}
// Get access token from query parameter
accessToken := r.URL.Query().Get("access_token")
if accessToken == "" {
errors.WriteError(w, errors.CodeUnauthenticated, "Missing access_token", http.StatusUnauthorized)
return
}
// Validate token
claims, err := validateWOPIAccessToken(accessToken, jwtManager)
if err != nil {
errors.WriteError(w, errors.CodeUnauthenticated, "Invalid or expired token", http.StatusUnauthorized)
return
}
userID, _ := uuid.Parse(claims.UserID)
// Get file info from database
fileUUID, err := uuid.Parse(fileID)
if err != nil {
errors.WriteError(w, errors.CodeInvalidArgument, "Invalid fileId format", http.StatusBadRequest)
return
}
file, err := db.GetFileByID(r.Context(), fileUUID)
if err != nil {
fmt.Printf("[WOPI-REQUEST] File not found: file=%s error=%v\n", fileID, err)
errors.WriteError(w, errors.CodeNotFound, "File not found", http.StatusNotFound)
return
}
fmt.Printf("[WOPI-CheckFileInfo] START: file=%s user=%s size=%d path=%s\n", fileID, userID.String(), file.Size, file.Path)
// Get user info for UserFriendlyName
user, err := db.GetUserByID(r.Context(), userID)
if err != nil {
fmt.Printf("[WOPI-REQUEST] Failed to get user info: user=%s error=%v\n", userID.String(), err)
errors.WriteError(w, errors.CodeInternal, "Failed to get user info", http.StatusInternalServerError)
return
}
// Verify user has access to this file
canAccess := false
var ownerID string
// Prefer org ownership when file belongs to an org and the user is a member
if file.OrgID != nil {
member, err := db.GetOrgMember(r.Context(), *file.OrgID, userID)
if err == nil && member != nil {
canAccess = true
ownerID = file.OrgID.String()
}
}
// Fallback to per-user file ownership
if !canAccess && file.UserID != nil && *file.UserID == userID {
canAccess = true
ownerID = userID.String()
}
if !canAccess {
fmt.Printf("[WOPI-REQUEST] Access denied: file=%s user=%s\n", fileID, userID.String())
errors.WriteError(w, errors.CodePermissionDenied, "Access denied", http.StatusForbidden)
return
}
// Ensure LastModifiedTime is not zero
lastModifiedTime := file.LastModified
if lastModifiedTime.IsZero() {
lastModifiedTime = file.CreatedAt
}
if lastModifiedTime.IsZero() {
lastModifiedTime = time.Now()
}
// Build response
response := models.WOPICheckFileInfoResponse{
BaseFileName: file.Name,
Size: file.Size,
Version: file.ID.String(),
OwnerId: ownerID,
UserId: userID.String(),
UserFriendlyName: user.DisplayName,
UserCanWrite: true,
UserCanRename: false,
UserCanNotWriteRelative: false,
ReadOnly: false,
RestrictedWebViewOnly: false,
UserCanCreateRelativeToFolder: false,
EnableOwnerTermination: false,
SupportsUpdate: true,
SupportsCobalt: false,
SupportsLocks: true,
SupportsExtendedLockLength: false,
SupportsGetLock: true,
SupportsDelete: false,
SupportsRename: false,
SupportsRenameRelativeToFolder: false,
SupportsFolders: false,
SupportsScenarios: []string{"default"},
LastModifiedTime: lastModifiedTime.UTC().Format(time.RFC3339),
IsAnonymousUser: false,
TimeZone: "UTC",
}
fmt.Printf("[WOPI-REQUEST] CheckFileInfo: file=%s user=%s size=%d\n", fileID, userID.String(), file.Size)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(response)
}
// WOPIGetFileHandler handles GET /wopi/files/{fileId}/contents
// Downloads the document file content
func wopiGetFileHandler(w http.ResponseWriter, r *http.Request, db *database.DB, jwtManager *jwt.Manager, cfg *config.Config) {
fileID := r.PathValue("fileId")
if fileID == "" {
errors.WriteError(w, errors.CodeInvalidArgument, "Missing fileId", http.StatusBadRequest)
return
}
fmt.Printf("[WOPI-GetFile] START: file=%s\n", fileID)
// Get access token from query parameter
accessToken := r.URL.Query().Get("access_token")
if accessToken == "" {
errors.WriteError(w, errors.CodeUnauthenticated, "Missing access_token", http.StatusUnauthorized)
return
}
// Validate token
claims, err := validateWOPIAccessToken(accessToken, jwtManager)
if err != nil {
errors.WriteError(w, errors.CodeUnauthenticated, "Invalid or expired token", http.StatusUnauthorized)
return
}
userID, _ := uuid.Parse(claims.UserID)
// Get file info from database
fileUUID, err := uuid.Parse(fileID)
if err != nil {
errors.WriteError(w, errors.CodeInvalidArgument, "Invalid fileId format", http.StatusBadRequest)
return
}
file, err := db.GetFileByID(r.Context(), fileUUID)
if err != nil {
fmt.Printf("[WOPI-REQUEST] GetFile - File not found: file=%s error=%v\n", fileID, err)
errors.WriteError(w, errors.CodeNotFound, "File not found", http.StatusNotFound)
return
}
// Verify user has access to this file
canAccess := false
var webDAVClient *storage.WebDAVClient
var remotePath string
// Prefer org storage when present and the user is a member
if file.OrgID != nil {
member, err := db.GetOrgMember(r.Context(), *file.OrgID, userID)
if err == nil && member != nil {
canAccess = true
// Use user's WebDAV client for org files too
webDAVClient, err = getUserWebDAVClient(r.Context(), db, userID, cfg.NextcloudURL, cfg.NextcloudUser, cfg.NextcloudPass)
if err != nil {
fmt.Printf("[WOPI-STORAGE] Failed to get user WebDAV client: %v\n", err)
errors.WriteError(w, errors.CodeInternal, "Storage error", http.StatusInternalServerError)
return
}
// Org files: stored under /orgs/{orgID}/ prefix
rel := strings.TrimPrefix(file.Path, "/")
remotePath = path.Join("/orgs", file.OrgID.String(), rel)
}
}
// Fallback to per-user files
if !canAccess && file.UserID != nil && *file.UserID == userID {
canAccess = true
webDAVClient, err = getUserWebDAVClient(r.Context(), db, userID, cfg.NextcloudURL, cfg.NextcloudUser, cfg.NextcloudPass)
if err != nil {
fmt.Printf("[WOPI-STORAGE] Failed to get user WebDAV client: %v\n", err)
errors.WriteError(w, errors.CodeInternal, "Storage error", http.StatusInternalServerError)
return
}
// User files: path is relative to user's WebDAV root
remotePath = file.Path
}
if !canAccess {
fmt.Printf("[WOPI-REQUEST] GetFile - Access denied: file=%s user=%s\n", fileID, userID.String())
errors.WriteError(w, errors.CodePermissionDenied, "Access denied", http.StatusForbidden)
return
}
// Download file from storage
fmt.Printf("[WOPI-STORAGE] GetFile downloading: file=%s remotePath=%s\n", fileID, remotePath)
resp, err := webDAVClient.Download(r.Context(), remotePath, "")
if err != nil {
fmt.Printf("[WOPI-STORAGE] Failed to download file: file=%s path=%s error=%v\n", fileID, file.Path, err)
errors.WriteError(w, errors.CodeNotFound, "File not found in storage", http.StatusNotFound)
return
}
defer resp.Body.Close()
fmt.Printf("[WOPI-STORAGE] Download response status: %d\n", resp.StatusCode)
// Set response headers
contentType := getMimeType(file.Name)
w.Header().Set("Content-Type", contentType)
w.Header().Set("Content-Length", fmt.Sprintf("%d", file.Size))
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", file.Name))
w.WriteHeader(http.StatusOK)
fmt.Printf("[WOPI-STORAGE] GetFile: file=%s user=%s bytes=%d\n", fileID, userID.String(), file.Size)
// Stream file content
io.Copy(w, resp.Body)
}
// WOPIPutFileHandler handles POST /wopi/files/{fileId}/contents
// Uploads edited document back to storage
func wopiPutFileHandler(w http.ResponseWriter, r *http.Request, db *database.DB, jwtManager *jwt.Manager, cfg *config.Config) {
fileID := r.PathValue("fileId")
if fileID == "" {
errors.WriteError(w, errors.CodeInvalidArgument, "Missing fileId", http.StatusBadRequest)
return
}
// Get access token from Authorization header
authHeader := r.Header.Get("Authorization")
if !strings.HasPrefix(authHeader, "Bearer ") {
errors.WriteError(w, errors.CodeUnauthenticated, "Missing authorization", http.StatusUnauthorized)
return
}
accessToken := strings.TrimPrefix(authHeader, "Bearer ")
// Validate token
claims, err := validateWOPIAccessToken(accessToken, jwtManager)
if err != nil {
errors.WriteError(w, errors.CodeUnauthenticated, "Invalid or expired token", http.StatusUnauthorized)
return
}
userID, _ := uuid.Parse(claims.UserID)
// Get file info from database
fileUUID, err := uuid.Parse(fileID)
if err != nil {
errors.WriteError(w, errors.CodeInvalidArgument, "Invalid fileId format", http.StatusBadRequest)
return
}
file, err := db.GetFileByID(r.Context(), fileUUID)
if err != nil {
fmt.Printf("[WOPI-REQUEST] PutFile - File not found: file=%s\n", fileID)
errors.WriteError(w, errors.CodeNotFound, "File not found", http.StatusNotFound)
return
}
// Verify user has access to this file
canAccess := false
var webDAVClient *storage.WebDAVClient
var remotePath string
// Prefer org storage when present and the user is a member
if file.OrgID != nil {
member, err := db.GetOrgMember(r.Context(), *file.OrgID, userID)
if err == nil && member != nil {
canAccess = true
// Use user's WebDAV client for org files too
webDAVClient, err = getUserWebDAVClient(r.Context(), db, userID, cfg.NextcloudURL, cfg.NextcloudUser, cfg.NextcloudPass)
if err != nil {
fmt.Printf("[WOPI-STORAGE] Failed to get user WebDAV client: %v\n", err)
errors.WriteError(w, errors.CodeInternal, "Storage error", http.StatusInternalServerError)
return
}
// Org files: stored under /orgs/{orgID}/ prefix
rel := strings.TrimPrefix(file.Path, "/")
remotePath = path.Join("/orgs", file.OrgID.String(), rel)
}
}
// Fallback to per-user files
if !canAccess && file.UserID != nil && *file.UserID == userID {
canAccess = true
webDAVClient, err = getUserWebDAVClient(r.Context(), db, userID, cfg.NextcloudURL, cfg.NextcloudUser, cfg.NextcloudPass)
if err != nil {
fmt.Printf("[WOPI-STORAGE] Failed to get user WebDAV client: %v\n", err)
errors.WriteError(w, errors.CodeInternal, "Storage error", http.StatusInternalServerError)
return
}
// User files: path is relative to user's WebDAV root
remotePath = file.Path
}
if !canAccess {
fmt.Printf("[WOPI-REQUEST] PutFile - Access denied: file=%s user=%s\n", fileID, userID.String())
errors.WriteError(w, errors.CodePermissionDenied, "Access denied", http.StatusForbidden)
return
}
// Check lock
lock := lockManager.GetLock(fileID)
if lock != nil && lock.UserID != userID.String() {
fmt.Printf("[WOPI-LOCK] Put conflict: file=%s locked_by=%s user=%s\n", fileID, lock.UserID, userID.String())
w.WriteHeader(http.StatusConflict)
return
}
// Read file content from request body
content, err := io.ReadAll(r.Body)
if err != nil {
fmt.Printf("[WOPI-STORAGE] Failed to read request body: %v\n", err)
errors.WriteError(w, errors.CodeInternal, "Failed to read content", http.StatusInternalServerError)
return
}
defer r.Body.Close()
// Upload to storage
fmt.Printf("[WOPI-STORAGE] PutFile uploading: file=%s remotePath=%s\n", fileID, remotePath)
err = webDAVClient.Upload(r.Context(), remotePath, strings.NewReader(string(content)), int64(len(content)))
if err != nil {
fmt.Printf("[WOPI-STORAGE] Failed to upload file: file=%s path=%s error=%v\n", fileID, file.Path, err)
errors.WriteError(w, errors.CodeInternal, "Failed to save file", http.StatusInternalServerError)
return
}
// Update file size and modification time in database
newSize := int64(len(content))
err = db.UpdateFileSize(r.Context(), fileUUID, newSize, &userID)
if err != nil {
fmt.Printf("[WOPI-STORAGE] Failed to update file size: file=%s error=%v\n", fileID, err)
// Don't fail the upload, just log the warning
}
fmt.Printf("[WOPI-STORAGE] PutFile: file=%s user=%s bytes=%d\n", fileID, userID.String(), newSize)
// Return response
response := models.WOPIPutFileResponse{
ItemVersion: fileUUID.String(),
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(response)
}
// WOPILockHandler handles POST /wopi/files/{fileId} with X-WOPI-Override header for lock operations
func wopiLockHandler(w http.ResponseWriter, r *http.Request, db *database.DB, jwtManager *jwt.Manager) {
fileID := r.PathValue("fileId")
if fileID == "" {
errors.WriteError(w, errors.CodeInvalidArgument, "Missing fileId", http.StatusBadRequest)
return
}
// Get access token from Authorization header
authHeader := r.Header.Get("Authorization")
if !strings.HasPrefix(authHeader, "Bearer ") {
errors.WriteError(w, errors.CodeUnauthenticated, "Missing authorization", http.StatusUnauthorized)
return
}
accessToken := strings.TrimPrefix(authHeader, "Bearer ")
// Validate token
claims, err := validateWOPIAccessToken(accessToken, jwtManager)
if err != nil {
errors.WriteError(w, errors.CodeUnauthenticated, "Invalid or expired token", http.StatusUnauthorized)
return
}
userID := claims.UserID
override := r.Header.Get("X-WOPI-Override")
// Get file to verify access
fileUUID, err := uuid.Parse(fileID)
if err != nil {
errors.WriteError(w, errors.CodeInvalidArgument, "Invalid fileId format", http.StatusBadRequest)
return
}
file, err := db.GetFileByID(r.Context(), fileUUID)
if err != nil {
errors.WriteError(w, errors.CodeNotFound, "File not found", http.StatusNotFound)
return
}
// Verify access
canAccess := false
if file.UserID != nil && file.UserID.String() == userID {
canAccess = true
} else if file.OrgID != nil {
userUUID, _ := uuid.Parse(userID)
member, err := db.GetOrgMember(r.Context(), *file.OrgID, userUUID)
canAccess = (err == nil && member != nil)
}
if !canAccess {
errors.WriteError(w, errors.CodePermissionDenied, "Access denied", http.StatusForbidden)
return
}
// Handle lock operations
switch override {
case "LOCK":
// Acquire lock
lockID, err := lockManager.AcquireLock(fileID, userID)
if err != nil {
fmt.Printf("[WOPI-LOCK] Lock acquisition failed: file=%s user=%s error=%s\n", fileID, userID, err.Error())
w.WriteHeader(http.StatusConflict)
w.Write([]byte(fmt.Sprintf(`{"error": "%s"}`, err.Error())))
return
}
w.Header().Set("X-WOPI-LockID", lockID)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{}`))
case "UNLOCK":
// Release lock
err := lockManager.ReleaseLock(fileID, userID)
if err != nil {
fmt.Printf("[WOPI-LOCK] Lock release failed: file=%s user=%s error=%s\n", fileID, userID, err.Error())
w.WriteHeader(http.StatusConflict)
w.Write([]byte(fmt.Sprintf(`{"error": "%s"}`, err.Error())))
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{}`))
case "GET_LOCK":
// Get lock info
lock := lockManager.GetLock(fileID)
if lock == nil {
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{}`))
return
}
w.Header().Set("X-WOPI-LockID", lock.LockID)
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{}`))
default:
errors.WriteError(w, errors.CodeInvalidArgument, "Unknown X-WOPI-Override value", http.StatusBadRequest)
}
}
// WOPISessionHandler handles POST /user/files/{fileId}/wopi-session and /orgs/{orgId}/files/{fileId}/wopi-session
// Returns WOPISrc URL and access token for opening document in Collabora
func wopiSessionHandler(w http.ResponseWriter, r *http.Request, db *database.DB, jwtManager *jwt.Manager, collaboraURL string) {
fileID := r.PathValue("fileId")
if fileID == "" {
errors.WriteError(w, errors.CodeInvalidArgument, "Missing fileId", http.StatusBadRequest)
return
}
// Get user from context (from auth middleware)
userIDStr, ok := middleware.GetUserID(r.Context())
if !ok || userIDStr == "" {
errors.WriteError(w, errors.CodeUnauthenticated, "Unauthorized", http.StatusUnauthorized)
return
}
userID, _ := uuid.Parse(userIDStr)
// Get file info
fileUUID, err := uuid.Parse(fileID)
if err != nil {
errors.WriteError(w, errors.CodeInvalidArgument, "Invalid fileId format", http.StatusBadRequest)
return
}
file, err := db.GetFileByID(r.Context(), fileUUID)
if err != nil {
errors.WriteError(w, errors.CodeNotFound, "File not found", http.StatusNotFound)
return
}
// Verify access
canAccess := false
if file.UserID != nil && *file.UserID == userID {
canAccess = true
} else if file.OrgID != nil {
member, err := db.GetOrgMember(r.Context(), *file.OrgID, userID)
canAccess = (err == nil && member != nil)
}
if !canAccess {
errors.WriteError(w, errors.CodePermissionDenied, "Access denied", http.StatusForbidden)
return
}
// Generate WOPI access token (1 hour duration)
accessToken, err := jwtManager.GenerateWithDuration(userID.String(), nil, "", 1*time.Hour)
if err != nil {
errors.WriteError(w, errors.CodeInternal, "Failed to generate token", http.StatusInternalServerError)
return
}
// Build WOPISrc URL
wopisrc := fmt.Sprintf("https://go.b0esche.cloud/wopi/files/%s?access_token=%s", fileID, accessToken)
response := models.WOPISessionResponse{
WOPISrc: wopisrc,
AccessToken: accessToken,
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
json.NewEncoder(w).Encode(response)
fmt.Printf("[WOPI-REQUEST] Session created: file=%s user=%s\n", fileID, userID.String())
}
// CollaboraProxyHandler serves an HTML page that POSTs WOPISrc to Collabora
// This avoids CORS issues by having the POST originate from our domain
func collaboraProxyHandler(w http.ResponseWriter, r *http.Request, db *database.DB, jwtManager *jwt.Manager, collaboraURL string) {
fileID := r.PathValue("fileId")
if fileID == "" {
errors.WriteError(w, errors.CodeInvalidArgument, "Missing fileId", http.StatusBadRequest)
return
}
// Get user from context (from auth middleware)
userIDStr, ok := middleware.GetUserID(r.Context())
if !ok {
errors.WriteError(w, errors.CodeUnauthenticated, "Not authenticated", http.StatusUnauthorized)
return
}
userID, err := uuid.Parse(userIDStr)
if err != nil {
errors.WriteError(w, errors.CodeInvalidArgument, "Invalid user ID", http.StatusBadRequest)
return
}
// Get file info
fileUUID, err := uuid.Parse(fileID)
if err != nil {
errors.WriteError(w, errors.CodeInvalidArgument, "Invalid fileId format", http.StatusBadRequest)
return
}
file, err := db.GetFileByID(r.Context(), fileUUID)
if err != nil {
errors.WriteError(w, errors.CodeNotFound, "File not found", http.StatusNotFound)
return
}
// Verify access
canAccess := false
if file.UserID != nil && *file.UserID == userID {
canAccess = true
} else if file.OrgID != nil {
member, err := db.GetOrgMember(r.Context(), *file.OrgID, userID)
canAccess = (err == nil && member != nil)
}
if !canAccess {
errors.WriteError(w, errors.CodePermissionDenied, "Access denied", http.StatusForbidden)
return
}
// Generate WOPI access token (1 hour duration)
accessToken, err := jwtManager.GenerateWithDuration(userID.String(), nil, "", 1*time.Hour)
if err != nil {
errors.WriteError(w, errors.CodeInternal, "Failed to generate token", http.StatusInternalServerError)
return
}
// Build WOPISrc URL (without access_token - that goes in a separate form field)
wopiSrc := fmt.Sprintf("https://go.b0esche.cloud/wopi/files/%s", fileID)
// Get the correct Collabora editor URL from discovery (includes version hash)
editorURL := getCollaboraEditorURL(collaboraURL)
// URL-encode the WOPISrc for use in the form action URL
encodedWopiSrc := url.QueryEscape(wopiSrc)
// Build the full Collabora URL with WOPISrc as query parameter
// Collabora expects: cool.html?WOPISrc=<encoded-url>
collaboraFullURL := fmt.Sprintf("%s?WOPISrc=%s", editorURL, encodedWopiSrc)
// Return HTML page with auto-submitting form
// The form POSTs to Collabora with access_token in the body
// WOPISrc must be in the URL as a query parameter
htmlContent := fmt.Sprintf(`<!DOCTYPE html>
<html>
<head>
<title>Loading Document...</title>
<meta charset="utf-8">
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body { width: 100%%; height: 100%%; overflow: hidden; }
.loading { position: fixed; top: 50%%; left: 50%%; transform: translate(-50%%, -50%%); text-align: center; background: white; padding: 20px; border-radius: 8px; box-shadow: 0 2px 8px rgba(0,0,0,0.1); }
.loading p { color: #666; margin-top: 10px; font-family: system-ui, sans-serif; }
</style>
</head>
<body>
<div class="loading">
<p>Loading Collabora Online...</p>
</div>
<form method="POST" action="%s" target="_self" id="collaboraForm" style="display: none;">
<input type="hidden" id="access_token" name="access_token" value="%s">
</form>
<script>
// Auto-submit the form to Collabora
var form = document.getElementById('collaboraForm');
if (form) {
console.log('[COLLABORA] Submitting form to %s');
form.submit();
} else {
console.error('[COLLABORA] Form not found');
}
</script>
</body>
</html>`, collaboraFullURL, accessToken, collaboraFullURL)
w.Header().Set("Content-Type", "text/html; charset=utf-8")
// Don't set X-Frame-Options - this endpoint is meant to be loaded in an iframe
w.WriteHeader(http.StatusOK)
w.Write([]byte(htmlContent))
fmt.Printf("[COLLABORA-PROXY] Served HTML form: file=%s user=%s wopi_src=%s editor_url=%s\n", fileID, userID.String(), wopiSrc, collaboraFullURL)
}

View File

@@ -2,8 +2,12 @@ package middleware
import (
"context"
"fmt"
"net/http"
"regexp"
"strings"
"sync"
"time"
"go.b0esche.cloud/backend/internal/audit"
"go.b0esche.cloud/backend/internal/database"
@@ -21,41 +25,247 @@ var RequestID = middleware.RequestID
var Logger = middleware.Logger
var Recoverer = middleware.Recoverer
// TODO: Implement rate limiter
// SecurityHeaders adds security-related HTTP headers
func SecurityHeaders() func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Prevent MIME type sniffing
w.Header().Set("X-Content-Type-Options", "nosniff")
// Prevent clickjacking - allow for WOPI routes
if !strings.HasPrefix(r.URL.Path, "/wopi") && !strings.HasPrefix(r.URL.Path, "/user/files/") && !strings.HasPrefix(r.URL.Path, "/orgs/") {
w.Header().Set("X-Frame-Options", "DENY")
}
// Enable XSS protection
w.Header().Set("X-XSS-Protection", "1; mode=block")
// Referrer policy
w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin")
// Content Security Policy - basic policy
w.Header().Set("Content-Security-Policy", "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; connect-src 'self' https://go.b0esche.cloud https://of.b0esche.cloud; frame-src 'self' https://of.b0esche.cloud;")
next.ServeHTTP(w, r)
})
}
}
// CORS middleware - accepts allowedOrigins comma-separated string
func CORS(allowedOrigins string) func(http.Handler) http.Handler {
allowedList, allowAll := compileAllowedOrigins(allowedOrigins)
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
origin := r.Header.Get("Origin")
if origin != "" && isOriginAllowed(origin, allowedList) {
w.Header().Set("Access-Control-Allow-Origin", origin)
w.Header().Add("Vary", "Origin")
w.Header().Set("Access-Control-Allow-Credentials", "true")
} else if allowAll {
w.Header().Set("Access-Control-Allow-Origin", "*")
}
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS")
allowHeaders := []string{"Content-Type", "Authorization", "Range", "Accept", "Origin", "X-Requested-With"}
if reqHeaders := r.Header.Get("Access-Control-Request-Headers"); reqHeaders != "" {
allowHeaders = append(allowHeaders, reqHeaders)
}
w.Header().Set("Access-Control-Allow-Headers", strings.Join(uniqueStrings(allowHeaders), ", "))
w.Header().Set("Access-Control-Expose-Headers", "Content-Length, Content-Type, Content-Disposition, Content-Range, Accept-Ranges")
w.Header().Set("Access-Control-Max-Age", "3600")
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusOK)
return
}
next.ServeHTTP(w, r)
})
}
}
func compileAllowedOrigins(origins string) ([]string, bool) {
var allowed []string
allowAll := false
for _, origin := range strings.Split(origins, ",") {
trimmed := strings.TrimSpace(origin)
if trimmed == "" {
continue
}
if trimmed == "*" {
allowAll = true
}
allowed = append(allowed, trimmed)
}
if len(allowed) == 0 && !allowAll {
allowAll = true
}
return allowed, allowAll
}
func uniqueStrings(values []string) []string {
seen := make(map[string]struct{})
var out []string
for _, v := range values {
trimmed := strings.TrimSpace(v)
if trimmed == "" {
continue
}
key := strings.ToLower(trimmed)
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
out = append(out, trimmed)
}
return out
}
func isOriginAllowed(origin string, allowed []string) bool {
if origin == "" {
return false
}
for _, pattern := range allowed {
if originMatches(origin, pattern) {
return true
}
}
return false
}
func originMatches(origin, pattern string) bool {
if pattern == "*" {
return true
}
if !strings.Contains(pattern, "*") {
return strings.EqualFold(origin, pattern)
}
regexPattern := "(?i)^" + regexp.QuoteMeta(pattern) + "$"
regexPattern = strings.ReplaceAll(regexPattern, "\\*", ".*")
matched, err := regexp.MatchString(regexPattern, origin)
return err == nil && matched
}
// rateLimiter tracks request counts per IP address
type rateLimiter struct {
mu sync.RWMutex
requests map[string]*clientRequests
}
type clientRequests struct {
count int
resetTime time.Time
}
var limiter = &rateLimiter{
requests: make(map[string]*clientRequests),
}
// RateLimit implements a simple sliding window rate limiter
// Limits: 100 requests per minute per IP for general endpoints
// 10 requests per minute per IP for auth endpoints
var RateLimit = func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Basic rate limiting logic here
// Get client IP (consider X-Forwarded-For from reverse proxy)
ip := r.RemoteAddr
if forwarded := r.Header.Get("X-Forwarded-For"); forwarded != "" {
ip = strings.Split(forwarded, ",")[0]
}
// Determine rate limit based on endpoint
limit := 100 // Default: 100 requests/minute
if strings.HasPrefix(r.URL.Path, "/auth/") {
limit = 10 // Auth endpoints: 10 requests/minute
}
limiter.mu.Lock()
client, exists := limiter.requests[ip]
now := time.Now()
if !exists || now.After(client.resetTime) {
// New window
limiter.requests[ip] = &clientRequests{
count: 1,
resetTime: now.Add(time.Minute),
}
limiter.mu.Unlock()
next.ServeHTTP(w, r)
return
}
if client.count >= limit {
limiter.mu.Unlock()
w.Header().Set("Retry-After", "60")
errors.WriteError(w, errors.CodeInvalidArgument, "Rate limit exceeded. Please try again later.", http.StatusTooManyRequests)
return
}
client.count++
limiter.mu.Unlock()
next.ServeHTTP(w, r)
})
}
type contextKey string
type ContextKey string
const (
userKey contextKey = "user"
sessionKey contextKey = "session"
orgKey contextKey = "org"
UserKey ContextKey = "user"
SessionKey ContextKey = "session"
TokenKey ContextKey = "token"
OrgKey ContextKey = "org"
)
// GetUserID retrieves the user ID from the request context
func GetUserID(ctx context.Context) (string, bool) {
userID, ok := ctx.Value(UserKey).(string)
return userID, ok
}
// GetSession retrieves the session from the request context
func GetSession(ctx context.Context) (*database.Session, bool) {
session, ok := ctx.Value(SessionKey).(*database.Session)
return session, ok
}
// GetToken retrieves the JWT token from the request context
func GetToken(ctx context.Context) (string, bool) {
token, ok := ctx.Value(TokenKey).(string)
return token, ok
}
// Auth middleware
func Auth(jwtManager *jwt.Manager, db *database.DB) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
authHeader := r.Header.Get("Authorization")
if !strings.HasPrefix(authHeader, "Bearer ") {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
var tokenString string
var tokenSource string
if strings.HasPrefix(authHeader, "Bearer ") {
tokenString = strings.TrimPrefix(authHeader, "Bearer ")
tokenSource = "header"
} else {
// Fallback to query parameter token (for viewers that cannot set headers)
qToken := r.URL.Query().Get("token")
if qToken == "" {
fmt.Printf("[AUTH-TOKEN] source=none, path=%s, statusCode=401\n", r.RequestURI)
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
tokenString = qToken
tokenSource = "query"
}
tokenString := strings.TrimPrefix(authHeader, "Bearer ")
fmt.Printf("[AUTH-TOKEN] source=%s, path=%s\n", tokenSource, r.RequestURI)
claims, session, err := jwtManager.ValidateWithSession(r.Context(), tokenString, db)
if err != nil {
fmt.Printf("[AUTH-TOKEN] validation_failed, source=%s, path=%s, error=%v\n", tokenSource, r.RequestURI, err)
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
ctx := context.WithValue(r.Context(), userKey, claims.UserID)
ctx = context.WithValue(ctx, sessionKey, session)
fmt.Printf("[AUTH-TOKEN] valid, source=%s, userId=%s\n", tokenSource, claims.UserID)
ctx := context.WithValue(r.Context(), UserKey, claims.UserID)
ctx = context.WithValue(ctx, SessionKey, session)
ctx = context.WithValue(ctx, TokenKey, tokenString)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
@@ -65,7 +275,7 @@ func Auth(jwtManager *jwt.Manager, db *database.DB) func(http.Handler) http.Hand
func Org(db *database.DB, auditLogger *audit.Logger) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
userIDStr := r.Context().Value(userKey).(string)
userIDStr := r.Context().Value(UserKey).(string)
userID, _ := uuid.Parse(userIDStr)
orgIDStr := r.Header.Get("X-Org-ID")
@@ -91,20 +301,7 @@ func Org(db *database.DB, auditLogger *audit.Logger) func(http.Handler) http.Han
return
}
_, err = org.CheckMembership(r.Context(), db, userID, orgID)
if err != nil {
auditLogger.Log(r.Context(), audit.Entry{
UserID: &userID,
Action: "org_access",
Success: false,
Metadata: map[string]interface{}{"org_id": orgID, "error": err.Error()},
})
errors.LogError(r, err, "Org access denied")
errors.WriteError(w, errors.CodePermissionDenied, "Forbidden", http.StatusForbidden)
return
}
ctx := context.WithValue(r.Context(), orgKey, orgID)
ctx := context.WithValue(r.Context(), OrgKey, orgID)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
@@ -114,9 +311,9 @@ func Org(db *database.DB, auditLogger *audit.Logger) func(http.Handler) http.Han
func Permission(db *database.DB, auditLogger *audit.Logger, perm permission.Permission) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
userIDStr := r.Context().Value(userKey).(string)
userIDStr := r.Context().Value(UserKey).(string)
userID, _ := uuid.Parse(userIDStr)
orgID := r.Context().Value(orgKey).(uuid.UUID)
orgID := r.Context().Value(OrgKey).(uuid.UUID)
hasPerm, err := permission.HasPermission(r.Context(), db, userID, orgID, perm)
if err != nil || !hasPerm {

View File

@@ -0,0 +1,20 @@
package models
import (
"time"
"github.com/google/uuid"
)
// FileShareLink represents a public share link for a file
type FileShareLink struct {
ID uuid.UUID `json:"id" db:"id"`
Token string `json:"token" db:"token"`
FileID uuid.UUID `json:"file_id" db:"file_id"`
OrgID *uuid.UUID `json:"org_id,omitempty" db:"org_id"`
CreatedByUserID uuid.UUID `json:"created_by_user_id" db:"created_by_user_id"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
ExpiresAt *time.Time `json:"expires_at,omitempty" db:"expires_at"`
IsRevoked bool `json:"is_revoked" db:"is_revoked"`
}

View File

@@ -0,0 +1,72 @@
package models
import "time"
// WOPICheckFileInfoResponse represents the response to WOPI CheckFileInfo request
// Reference: https://docs.microsoft.com/en-us/openspecs/office_protocols/ms-wopi/4b8ffc3f-e8a6-4169-8c4e-34924ac6ae2f
type WOPICheckFileInfoResponse struct {
BaseFileName string `json:"BaseFileName"`
Size int64 `json:"Size"`
Version string `json:"Version"`
OwnerId string `json:"OwnerId"`
UserId string `json:"UserId"`
UserFriendlyName string `json:"UserFriendlyName"`
UserCanWrite bool `json:"UserCanWrite"`
UserCanRename bool `json:"UserCanRename"`
UserCanNotWriteRelative bool `json:"UserCanNotWriteRelative"`
ReadOnly bool `json:"ReadOnly"`
RestrictedWebViewOnly bool `json:"RestrictedWebViewOnly"`
UserCanCreateRelativeToFolder bool `json:"UserCanCreateRelativeToFolder"`
EnableOwnerTermination bool `json:"EnableOwnerTermination"`
SupportsUpdate bool `json:"SupportsUpdate"`
SupportsCobalt bool `json:"SupportsCobalt"`
SupportsLocks bool `json:"SupportsLocks"`
SupportsExtendedLockLength bool `json:"SupportsExtendedLockLength"`
SupportsGetLock bool `json:"SupportsGetLock"`
SupportsDelete bool `json:"SupportsDelete"`
SupportsRename bool `json:"SupportsRename"`
SupportsRenameRelativeToFolder bool `json:"SupportsRenameRelativeToFolder"`
SupportsFolders bool `json:"SupportsFolders"`
SupportsScenarios []string `json:"SupportsScenarios"`
LastModifiedTime string `json:"LastModifiedTime"`
IsAnonymousUser bool `json:"IsAnonymousUser"`
TimeZone string `json:"TimeZone"`
CloseUrl string `json:"CloseUrl,omitempty"`
EditUrl string `json:"EditUrl,omitempty"`
ViewUrl string `json:"ViewUrl,omitempty"`
FileSharingUrl string `json:"FileSharingUrl,omitempty"`
DownloadUrl string `json:"DownloadUrl,omitempty"`
}
// WOPIPutFileResponse represents the response to WOPI PutFile request
type WOPIPutFileResponse struct {
ItemVersion string `json:"ItemVersion"`
}
// WOPILockInfo represents information about a file lock
type WOPILockInfo struct {
FileID string `json:"file_id"`
UserID string `json:"user_id"`
LockID string `json:"lock_id"`
CreatedAt time.Time `json:"created_at"`
ExpiresAt time.Time `json:"expires_at"`
}
// WOPIAccessTokenRequest represents a request to get WOPI access token
type WOPIAccessTokenRequest struct {
FileID string `json:"file_id"`
}
// WOPIAccessTokenResponse represents a response with WOPI access token
type WOPIAccessTokenResponse struct {
AccessToken string `json:"access_token"`
AccessTokenTTL int64 `json:"access_token_ttl"`
BootstrapperUrl string `json:"bootstrapper_url,omitempty"`
ClosePostMessage bool `json:"close_post_message"`
}
// WOPISessionResponse represents a response for creating a WOPI session
type WOPISessionResponse struct {
WOPISrc string `json:"wopi_src"`
AccessToken string `json:"access_token"`
}

View File

@@ -2,10 +2,14 @@ package org
import (
"context"
"fmt"
"regexp"
"strings"
"go.b0esche.cloud/backend/internal/database"
"github.com/google/uuid"
"github.com/jackc/pgconn"
)
// ResolveUserOrgs returns the organizations a user belongs to
@@ -24,17 +28,58 @@ func CheckMembership(ctx context.Context, db *database.DB, userID, orgID uuid.UU
// CreateOrg creates a new organization and adds the user as owner
func CreateOrg(ctx context.Context, db *database.DB, userID uuid.UUID, name, slug string) (*database.Organization, error) {
if slug == "" {
// Simple slug generation
slug = name // TODO: make URL safe
trimmedName := strings.TrimSpace(name)
if trimmedName == "" {
return nil, fmt.Errorf("organization name cannot be empty")
}
baseSlug := slugify(slug)
if baseSlug == "" {
baseSlug = slugify(trimmedName)
}
if baseSlug == "" {
baseSlug = fmt.Sprintf("org-%s", uuid.NewString()[:8])
}
var org *database.Organization
var err error
// Try a handful of suffixes on unique constraint violation
for i := 0; i < 5; i++ {
candidate := baseSlug
if i > 0 {
candidate = fmt.Sprintf("%s-%d", baseSlug, i+1)
}
org, err = db.CreateOrg(ctx, userID, trimmedName, candidate)
if err != nil {
if pgErr, ok := err.(*pgconn.PgError); ok && pgErr.Code == "23505" {
// Unique violation; try next suffix
continue
}
return nil, err
}
break
}
org, err := db.CreateOrg(ctx, name, slug)
if err != nil {
return nil, err
}
err = db.AddMembership(ctx, userID, org.ID, "owner")
if err != nil {
if err = db.AddMembership(ctx, userID, org.ID, "owner"); err != nil {
return nil, err
}
return org, nil
}
// slugify converts a string to a URL-safe slug with hyphens.
func slugify(s string) string {
lower := strings.ToLower(strings.TrimSpace(s))
if lower == "" {
return ""
}
// Replace non-alphanumeric with hyphen
re := regexp.MustCompile(`[^a-z0-9]+`)
slug := re.ReplaceAllString(lower, "-")
slug = strings.Trim(slug, "-")
// Collapse multiple hyphens
slug = strings.ReplaceAll(slug, "--", "-")
return slug
}

View File

@@ -22,9 +22,8 @@ const (
var rolePermissions = map[string][]Permission{
"owner": {FileRead, FileWrite, FileDelete, DocumentView, DocumentEdit, OrgManage},
"admin": {FileRead, FileWrite, FileDelete, DocumentView, DocumentEdit},
"editor": {FileRead, FileWrite, DocumentView, DocumentEdit},
"viewer": {FileRead, DocumentView},
"admin": {FileRead, FileWrite, FileDelete, DocumentView, DocumentEdit, OrgManage},
"member": {FileRead, DocumentView},
}
// HasPermission checks if user has permission in org

View File

@@ -0,0 +1,78 @@
package storage
import (
"bytes"
"crypto/rand"
"encoding/base64"
"fmt"
"io"
"log"
"net/http"
"net/url"
"strings"
"time"
)
// CreateNextcloudUser creates a new Nextcloud user account via OCS API
func CreateNextcloudUser(nextcloudBaseURL, adminUser, adminPass, username, password string) error {
// Remove any path from base URL, we need just the scheme://host:port
baseURL := strings.Split(nextcloudBaseURL, "/remote.php")[0]
urlStr := fmt.Sprintf("%s/ocs/v1.php/cloud/users", baseURL)
// OCS API expects form-encoded data with proper URL encoding
formData := url.Values{
"userid": {username},
"password": {password},
}.Encode()
req, err := http.NewRequest("POST", urlStr, bytes.NewBufferString(formData))
if err != nil {
return fmt.Errorf("failed to create request: %w", err)
}
req.SetBasicAuth(adminUser, adminPass)
req.Header.Set("OCS-APIRequest", "true")
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("failed to create user: %w", err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
// 200 = success, 409 = user already exists (which is fine)
if resp.StatusCode != 200 && resp.StatusCode != 409 {
return fmt.Errorf("failed to create Nextcloud user (status %d): %s", resp.StatusCode, string(body))
}
log.Printf("[NEXTCLOUD] Created user account: %s with generated password\n", username)
return nil
}
// GenerateSecurePassword generates a random secure password
func GenerateSecurePassword(length int) (string, error) {
bytes := make([]byte, length)
if _, err := rand.Read(bytes); err != nil {
return "", err
}
return base64.URLEncoding.EncodeToString(bytes)[:length], nil
}
// NewUserWebDAVClient creates a WebDAV client for a specific user
func NewUserWebDAVClient(nextcloudBaseURL, username, password string) *WebDAVClient {
// Use internal Nextcloud URL to bypass Traefik timeouts
baseURL := "http://nextcloud"
// Build the full WebDAV URL for this user
fullURL := fmt.Sprintf("%s/remote.php/dav/files/%s", baseURL, username)
return &WebDAVClient{
BaseURL: fullURL,
user: username,
pass: password,
basePrefix: "/",
httpClient: &http.Client{Timeout: 10 * time.Minute},
}
}

View File

@@ -0,0 +1,296 @@
package storage
import (
"context"
"fmt"
"io"
"log"
"net/http"
"net/url"
"path"
"strings"
"time"
"go.b0esche.cloud/backend/internal/config"
)
type WebDAVClient struct {
BaseURL string
user string
pass string
basePrefix string
httpClient *http.Client
}
// NewWebDAVClient returns nil if no Nextcloud URL configured
func NewWebDAVClient(cfg *config.Config) *WebDAVClient {
if cfg == nil || strings.TrimSpace(cfg.NextcloudURL) == "" {
log.Printf("[WEBDAV] No Nextcloud URL configured, WebDAV client is nil\n")
return nil
}
u := strings.TrimRight(cfg.NextcloudURL, "/")
if !strings.Contains(u, "/remote.php") {
u += "/remote.php/dav/files/" + cfg.NextcloudUser
}
base := cfg.NextcloudBase
if base == "" {
base = "/"
}
log.Printf("[WEBDAV] Initializing WebDAV client - URL: %s, User: %s, BasePath: %s\n", u, cfg.NextcloudUser, base)
return &WebDAVClient{
BaseURL: u,
user: cfg.NextcloudUser,
pass: cfg.NextcloudPass,
basePrefix: strings.TrimRight(base, "/"),
httpClient: &http.Client{Timeout: 60 * time.Second},
}
}
// ensureParent creates intermediate collections using MKCOL. Ignoring errors when already exists.
func (c *WebDAVClient) ensureParent(ctx context.Context, remotePath string) error {
// build incremental paths
dir := path.Dir(remotePath)
if dir == "." || dir == "/" || dir == "" {
return nil
}
// split and build prefixes
parts := strings.Split(strings.Trim(dir, "/"), "/")
cur := c.basePrefix
for _, p := range parts {
cur = path.Join(cur, p)
var mkurl string
// Always ensure a single '/' between BaseURL and the current path
// e.g. http://nextcloud/remote.php/dav/files/testuser/orgs/<id>
mkurl = fmt.Sprintf("%s/%s", strings.TrimRight(c.BaseURL, "/"), strings.TrimLeft(cur, "/"))
req, _ := http.NewRequestWithContext(ctx, "MKCOL", mkurl, nil)
if c.user != "" {
req.SetBasicAuth(c.user, c.pass)
}
resp, err := c.httpClient.Do(req)
if err != nil {
return err
}
// Read body for diagnostics
b, _ := io.ReadAll(resp.Body)
resp.Body.Close()
// 201 created, 405 exists — ignore
if resp.StatusCode == 201 || resp.StatusCode == 405 {
continue
}
// Any other status is an error: return with diagnostics so caller can log and act on it
return fmt.Errorf("MKCOL failed for %s: status=%d body=%s", mkurl, resp.StatusCode, string(b))
}
return nil
}
// Upload streams the content to the remotePath using HTTP PUT (WebDAV). remotePath should be absolute under basePrefix.
func (c *WebDAVClient) Upload(ctx context.Context, remotePath string, r io.Reader, size int64) error {
if c == nil {
return fmt.Errorf("no webdav client configured")
}
// Ensure parent collections, skip for .avatars as it should exist
if !strings.HasPrefix(remotePath, ".avatars/") {
if err := c.ensureParent(ctx, remotePath); err != nil {
return err
}
}
// Construct URL
// remotePath might be like /orgs/<id>/file.txt; ensure it joins to basePrefix
rel := strings.TrimLeft(remotePath, "/")
u := c.basePrefix
if u == "/" || u == "" {
u = ""
}
u = strings.TrimRight(u, "/")
var full string
if u == "" {
full = fmt.Sprintf("%s/%s", c.BaseURL, url.PathEscape(rel))
} else {
full = fmt.Sprintf("%s%s/%s", c.BaseURL, u, url.PathEscape(rel))
}
full = strings.ReplaceAll(full, "%2F", "/")
fmt.Printf("[WEBDAV-UPLOAD] BaseURL: %s, BasePrefix: %s, RemotePath: %s, Full URL: %s\n", c.BaseURL, c.basePrefix, remotePath, full)
req, err := http.NewRequestWithContext(ctx, "PUT", full, r)
if err != nil {
return err
}
if size > 0 {
req.ContentLength = size
}
if c.user != "" {
req.SetBasicAuth(c.user, c.pass)
}
req.Header.Set("Content-Type", "application/octet-stream")
resp, err := c.httpClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
return nil
} else if resp.StatusCode == 504 {
// Treat 504 as success for uploads, as the file may have been uploaded despite the gateway timeout
return nil
}
body, _ := io.ReadAll(resp.Body)
return fmt.Errorf("webdav upload failed: %d %s", resp.StatusCode, string(body))
}
// Download retrieves a file from the remotePath using HTTP GET (WebDAV).
func (c *WebDAVClient) Download(ctx context.Context, remotePath string, rangeHeader string) (*http.Response, error) {
if c == nil {
return nil, fmt.Errorf("no webdav client configured")
}
rel := strings.TrimLeft(remotePath, "/")
u := c.basePrefix
if u == "/" || u == "" {
u = ""
}
u = strings.TrimRight(u, "/")
var full string
if u == "" {
full = fmt.Sprintf("%s/%s", c.BaseURL, url.PathEscape(rel))
} else {
full = fmt.Sprintf("%s%s/%s", c.BaseURL, u, url.PathEscape(rel))
}
full = strings.ReplaceAll(full, "%2F", "/")
req, err := http.NewRequestWithContext(ctx, "GET", full, nil)
if err != nil {
return nil, err
}
if c.user != "" {
req.SetBasicAuth(c.user, c.pass)
}
if rangeHeader != "" {
req.Header.Set("Range", rangeHeader)
}
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, err
}
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
return resp, nil
}
body, _ := io.ReadAll(resp.Body)
resp.Body.Close()
return nil, fmt.Errorf("webdav download failed: %d %s", resp.StatusCode, string(body))
}
// Delete removes a file or collection from the remotePath using HTTP DELETE (WebDAV).
func (c *WebDAVClient) Delete(ctx context.Context, remotePath string) error {
if c == nil {
return fmt.Errorf("no webdav client configured")
}
rel := strings.TrimLeft(remotePath, "/")
u := c.basePrefix
if u == "/" || u == "" {
u = ""
}
u = strings.TrimRight(u, "/")
var full string
if u == "" {
full = fmt.Sprintf("%s/%s", c.BaseURL, url.PathEscape(rel))
} else {
full = fmt.Sprintf("%s%s/%s", c.BaseURL, u, url.PathEscape(rel))
}
full = strings.ReplaceAll(full, "%2F", "/")
req, err := http.NewRequestWithContext(ctx, "DELETE", full, nil)
if err != nil {
return err
}
if c.user != "" {
req.SetBasicAuth(c.user, c.pass)
}
resp, err := c.httpClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
return nil
}
// 404 means already deleted, consider it success
if resp.StatusCode == 404 {
return nil
}
body, _ := io.ReadAll(resp.Body)
return fmt.Errorf("webdav delete failed: %d %s", resp.StatusCode, string(body))
}
// Move moves/renames a file using WebDAV MOVE method
func (c *WebDAVClient) Move(ctx context.Context, sourcePath, targetPath string) error {
if c == nil {
return fmt.Errorf("no webdav client configured")
}
// Ensure target parent directory exists before moving
if err := c.ensureParent(ctx, targetPath); err != nil {
return fmt.Errorf("failed to create target directory: %w", err)
}
sourceRel := strings.TrimLeft(sourcePath, "/")
targetRel := strings.TrimLeft(targetPath, "/")
u := c.basePrefix
if u == "/" || u == "" {
u = ""
}
u = strings.TrimRight(u, "/")
// Build source URL
var sourceURL string
if u == "" {
sourceURL = fmt.Sprintf("%s/%s", c.BaseURL, url.PathEscape(sourceRel))
} else {
sourceURL = fmt.Sprintf("%s%s/%s", c.BaseURL, u, url.PathEscape(sourceRel))
}
sourceURL = strings.ReplaceAll(sourceURL, "%2F", "/")
// Build target URL
var targetURL string
if u == "" {
targetURL = fmt.Sprintf("%s/%s", c.BaseURL, url.PathEscape(targetRel))
} else {
targetURL = fmt.Sprintf("%s%s/%s", c.BaseURL, u, url.PathEscape(targetRel))
}
targetURL = strings.ReplaceAll(targetURL, "%2F", "/")
req, err := http.NewRequestWithContext(ctx, "MOVE", sourceURL, nil)
if err != nil {
return err
}
req.Header.Set("Destination", targetURL)
if c.user != "" {
req.SetBasicAuth(c.user, c.pass)
}
resp, err := c.httpClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
return nil
}
body, _ := io.ReadAll(resp.Body)
return fmt.Errorf("webdav move failed: %d %s", resp.StatusCode, string(body))
}

View File

@@ -0,0 +1,17 @@
-- Create files table for org and user workspaces
CREATE TABLE files (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
org_id UUID REFERENCES organizations(id),
user_id UUID REFERENCES users(id),
name TEXT NOT NULL,
path TEXT NOT NULL,
type TEXT NOT NULL,
size BIGINT DEFAULT 0,
last_modified TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
CREATE INDEX idx_files_org_id ON files(org_id);
CREATE INDEX idx_files_user_id ON files(user_id);
CREATE INDEX idx_files_path ON files(path);

View File

@@ -0,0 +1,28 @@
-- Scope organization slugs per owner instead of globally unique
ALTER TABLE organizations ADD COLUMN owner_id UUID REFERENCES users(id);
WITH first_owner AS (
SELECT DISTINCT ON (org_id) org_id, user_id
FROM memberships
WHERE role = 'owner'
ORDER BY org_id, created_at
)
UPDATE organizations o
SET owner_id = fo.user_id
FROM first_owner fo
WHERE o.id = fo.org_id;
WITH first_member AS (
SELECT DISTINCT ON (org_id) org_id, user_id
FROM memberships
ORDER BY org_id, created_at
)
UPDATE organizations o
SET owner_id = fm.user_id
FROM first_member fm
WHERE o.owner_id IS NULL
AND o.id = fm.org_id;
ALTER TABLE organizations ALTER COLUMN owner_id SET NOT NULL;
ALTER TABLE organizations DROP CONSTRAINT organizations_slug_key;
CREATE UNIQUE INDEX organizations_owner_slug_key ON organizations(owner_id, slug);

View File

@@ -0,0 +1,29 @@
-- Add invitations and join_requests tables for organization management
CREATE TABLE invitations (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
org_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
invited_by UUID NOT NULL REFERENCES users(id),
username TEXT NOT NULL, -- username of the invited user
role TEXT NOT NULL CHECK (role IN ('owner', 'admin', 'member')),
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
expires_at TIMESTAMP WITH TIME ZONE DEFAULT (NOW() + INTERVAL '7 days'),
accepted_at TIMESTAMP WITH TIME ZONE,
UNIQUE(org_id, username) -- prevent duplicate invites for same user in org
);
CREATE TABLE join_requests (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
org_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES users(id),
invite_token TEXT, -- optional, if from invite link
requested_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'accepted', 'rejected')),
UNIQUE(org_id, user_id) -- prevent duplicate requests
);
-- Index for faster lookups
CREATE INDEX idx_invitations_org_id ON invitations(org_id);
CREATE INDEX idx_invitations_username ON invitations(username);
CREATE INDEX idx_join_requests_org_id ON join_requests(org_id);
CREATE INDEX idx_join_requests_user_id ON join_requests(user_id);

View File

@@ -0,0 +1,4 @@
-- Add invite_link_token to organizations for shareable invite links
ALTER TABLE organizations ADD COLUMN invite_link_token TEXT UNIQUE;
CREATE INDEX idx_organizations_invite_link_token ON organizations(invite_link_token);

View File

@@ -0,0 +1,17 @@
-- Create file_share_links table
CREATE TABLE file_share_links (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
token TEXT NOT NULL UNIQUE,
file_id UUID NOT NULL REFERENCES files(id) ON DELETE CASCADE,
org_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
created_by_user_id UUID NOT NULL REFERENCES users(id),
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
expires_at TIMESTAMP WITH TIME ZONE,
is_revoked BOOLEAN DEFAULT FALSE
);
CREATE INDEX idx_file_share_links_token ON file_share_links(token);
CREATE INDEX idx_file_share_links_file_id ON file_share_links(file_id);
CREATE INDEX idx_file_share_links_org_id ON file_share_links(org_id);

View File

@@ -0,0 +1,10 @@
-- Drop file_share_links table
DROP TABLE IF EXISTS file_share_links;
expires_at TIMESTAMP WITH TIME ZONE,
is_revoked BOOLEAN DEFAULT FALSE
);
CREATE INDEX idx_file_share_links_token ON file_share_links(token);
CREATE INDEX idx_file_share_links_file_id ON file_share_links(file_id);
CREATE INDEX idx_file_share_links_org_id ON file_share_links(org_id);

View File

@@ -0,0 +1,3 @@
-- Make org_id nullable in file_share_links for personal file sharing
ALTER TABLE file_share_links ALTER COLUMN org_id DROP NOT NULL;

View File

@@ -0,0 +1,3 @@
-- Revert: Make org_id not nullable in file_share_links
ALTER TABLE file_share_links ALTER COLUMN org_id SET NOT NULL;

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