Compare commits

...

348 Commits

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
82 changed files with 14128 additions and 1846 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 # 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 ## Project Structure
- `go_cloud/`: Go backend (control plane) with REST API ```
- `b0esche_cloud/`: Flutter web frontend with BLoC architecture b0esche_cloud/
- Supporting services: Nextcloud (storage), Collabora (editing), PostgreSQL (database) ├── 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 ## Prerequisites
- Go 1.21+ - Go 1.21+
- Flutter 3.10+ - Flutter 3.10+
- Docker and Docker Compose - Docker & Docker Compose
- PostgreSQL (or Docker) - PostgreSQL 15+
- Nextcloud instance
- Collabora Online instance
## Local Development Setup ## Local Development
### 1. Start Supporting Services ### Quick Start
Use Docker Compose to start PostgreSQL, Nextcloud, and Collabora:
```bash ```bash
docker-compose up -d db nextcloud collabora # Start everything
./scripts/dev-all.sh
``` ```
### 2. Backend Setup ### Manual Setup
**Backend:**
```bash ```bash
cd go_cloud cd go_cloud
cp .env.example .env cp .env.example .env
# Edit .env with your configuration (DB URL, Nextcloud URL, etc.) # Edit .env with your configuration
go run ./cmd/api go run ./cmd/api
``` ```
Or use the provided script: **Frontend:**
```bash
./scripts/dev-backend.sh
```
### 3. Frontend Setup
```bash ```bash
cd b0esche_cloud cd b0esche_cloud
flutter pub get flutter pub get
flutter run -d chrome 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 ## 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 ## Production Deployment
- `JWT_SECRET`: Random secret for JWT signing
- `OIDC_*`: OIDC provider settings
- `NEXTCLOUD_*`: Nextcloud API settings
- `COLLABORA_*`: Collabora settings
### 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 ```
/opt/
```bash ├── traefik/ # Reverse proxy + SSL
cd go_cloud ├── go/ # Go backend + PostgreSQL
go test ./... ├── flutter/ # Flutter web build + Nginx
├── scripts/ # Operations scripts
└── auto-deploy/ # Auto-deployment workspace
``` ```
### Frontend ### Server Scripts
```bash | Script | Description |
cd b0esche_cloud |--------|-------------|
flutter test | `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 | Component | Technology |
cd go_cloud |-----------|------------|
go build -o bin/api ./cmd/api | 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 | Document | Description |
cd b0esche_cloud |----------|-------------|
flutter build web | [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) |
## Database Migrations | [SECURITY.md](docs/SECURITY.md) | Security architecture, hardening, best practices |
| [DEVELOPMENT.md](docs/DEVELOPMENT.md) | Local setup, coding conventions, testing |
Migrations are in `go_cloud/migrations/`. | [DEPLOYMENT.md](docs/DEPLOYMENT.md) | Production deployment, operations, troubleshooting |
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
## License ## License
[License here] Private project - All rights reserved

View File

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

View File

@@ -6,6 +6,7 @@ import 'auth_event.dart';
import 'auth_state.dart'; import 'auth_state.dart';
import '../../services/api_client.dart'; import '../../services/api_client.dart';
import '../../models/api_error.dart'; import '../../models/api_error.dart';
import '../../models/user.dart';
class AuthBloc extends Bloc<AuthEvent, AuthState> { class AuthBloc extends Bloc<AuthEvent, AuthState> {
final ApiClient apiClient; final ApiClient apiClient;
@@ -22,6 +23,7 @@ class AuthBloc extends Bloc<AuthEvent, AuthState> {
on<AuthenticationResponseSubmitted>(_onAuthenticationResponseSubmitted); on<AuthenticationResponseSubmitted>(_onAuthenticationResponseSubmitted);
on<LogoutRequested>(_onLogoutRequested); on<LogoutRequested>(_onLogoutRequested);
on<CheckAuthRequested>(_onCheckAuthRequested); on<CheckAuthRequested>(_onCheckAuthRequested);
on<UpdateUserProfile>(_onUpdateUserProfile);
} }
Future<void> _onSignupStarted( Future<void> _onSignupStarted(
@@ -54,7 +56,8 @@ class AuthBloc extends Bloc<AuthEvent, AuthState> {
add(RegistrationChallengeRequested(userId: userId)); add(RegistrationChallengeRequested(userId: userId));
} catch (e) { } catch (e) {
final errorMessage = _extractErrorMessage(e); final errorMessage = _extractErrorMessage(e);
emit(AuthFailure(errorMessage)); final code = e is ApiError ? e.code : null;
emit(AuthFailure(errorMessage, code: code));
} }
} }
@@ -80,7 +83,8 @@ class AuthBloc extends Bloc<AuthEvent, AuthState> {
); );
} catch (e) { } catch (e) {
final errorMessage = _extractErrorMessage(e); final errorMessage = _extractErrorMessage(e);
emit(AuthFailure(errorMessage)); final code = e is ApiError ? e.code : null;
emit(AuthFailure(errorMessage, code: code));
} }
} }
@@ -108,17 +112,33 @@ class AuthBloc extends Bloc<AuthEvent, AuthState> {
sessionBloc.add(SessionStarted(token)); sessionBloc.add(SessionStarted(token));
emit( // Fetch full profile and include it in state when possible
AuthAuthenticated( try {
token: token, final profile = await apiClient.getUserProfile();
userId: user['id'], final fullUser = profile.isNotEmpty ? User.fromJson(profile) : null;
username: user['username'], emit(
email: user['email'], 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) { } catch (e) {
final errorMessage = _extractErrorMessage(e); final errorMessage = _extractErrorMessage(e);
emit(AuthFailure(errorMessage)); final code = e is ApiError ? e.code : null;
emit(AuthFailure(errorMessage, code: code));
} }
} }
@@ -131,7 +151,8 @@ class AuthBloc extends Bloc<AuthEvent, AuthState> {
add(AuthenticationChallengeRequested(username: event.username)); add(AuthenticationChallengeRequested(username: event.username));
} catch (e) { } catch (e) {
final errorMessage = _extractErrorMessage(e); final errorMessage = _extractErrorMessage(e);
emit(AuthFailure(errorMessage)); final code = e is ApiError ? e.code : null;
emit(AuthFailure(errorMessage, code: code));
} }
} }
@@ -160,7 +181,8 @@ class AuthBloc extends Bloc<AuthEvent, AuthState> {
); );
} catch (e) { } catch (e) {
final errorMessage = _extractErrorMessage(e); final errorMessage = _extractErrorMessage(e);
emit(AuthFailure(errorMessage)); final code = e is ApiError ? e.code : null;
emit(AuthFailure(errorMessage, code: code));
} }
} }
@@ -188,17 +210,33 @@ class AuthBloc extends Bloc<AuthEvent, AuthState> {
sessionBloc.add(SessionStarted(token)); sessionBloc.add(SessionStarted(token));
emit( // Fetch full profile and include it in state when possible
AuthAuthenticated( try {
token: token, final profile = await apiClient.getUserProfile();
userId: user['id'], final fullUser = profile.isNotEmpty ? User.fromJson(profile) : null;
username: user['username'], emit(
email: user['email'], 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) { } catch (e) {
final errorMessage = _extractErrorMessage(e); final errorMessage = _extractErrorMessage(e);
emit(AuthFailure(errorMessage)); final code = e is ApiError ? e.code : null;
emit(AuthFailure(errorMessage, code: code));
} }
} }
@@ -228,17 +266,34 @@ class AuthBloc extends Bloc<AuthEvent, AuthState> {
final user = response['user']; final user = response['user'];
sessionBloc.add(SessionStarted(token)); sessionBloc.add(SessionStarted(token));
emit(
AuthAuthenticated( // Fetch full profile and include it in state when possible
token: token, try {
userId: user['id'], final profile = await apiClient.getUserProfile();
username: user['username'], final fullUser = profile.isNotEmpty ? User.fromJson(profile) : null;
email: user['email'], 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) { } catch (e) {
final errorMessage = _extractErrorMessage(e); final errorMessage = _extractErrorMessage(e);
emit(AuthFailure(errorMessage)); final code = e is ApiError ? e.code : null;
emit(AuthFailure(errorMessage, code: code));
} }
} }
@@ -257,18 +312,68 @@ class AuthBloc extends Bloc<AuthEvent, AuthState> {
final sessionState = sessionBloc.state; final sessionState = sessionBloc.state;
if (sessionState is SessionActive) { if (sessionState is SessionActive) {
// Session already active - emit authenticated state with minimal info // Try to fetch full profile immediately so UI can show avatar/displayName
// The full user info will be fetched when needed try {
emit( final profile = await apiClient.getUserProfile();
AuthAuthenticated( final fullUser = profile.isNotEmpty ? User.fromJson(profile) : null;
token: sessionState.token, emit(
userId: '', AuthAuthenticated(
username: '', token: sessionState.token,
email: '', 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 { } else {
emit(AuthUnauthenticated()); 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 'package:equatable/equatable.dart';
import '../../models/user.dart';
abstract class AuthEvent extends Equatable { abstract class AuthEvent extends Equatable {
const AuthEvent(); const AuthEvent();
@@ -135,3 +136,12 @@ class PasswordLoginRequested extends AuthEvent {
@override @override
List<Object> get props => [username, password]; 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 'package:equatable/equatable.dart';
import '../../models/user.dart';
abstract class AuthState extends Equatable { abstract class AuthState extends Equatable {
const AuthState(); const AuthState();
@@ -68,25 +69,28 @@ class AuthAuthenticated extends AuthState {
final String userId; final String userId;
final String username; final String username;
final String email; final String email;
final User? user;
const AuthAuthenticated({ const AuthAuthenticated({
required this.token, required this.token,
required this.userId, required this.userId,
required this.username, required this.username,
required this.email, required this.email,
this.user,
}); });
@override @override
List<Object> get props => [token, userId, username, email]; List<Object?> get props => [token, userId, username, email, user];
} }
class AuthFailure extends AuthState { class AuthFailure extends AuthState {
final String error; final String error;
final String? code;
const AuthFailure(this.error); const AuthFailure(this.error, {this.code});
@override @override
List<Object> get props => [error]; List<Object?> get props => [error, code];
} }
class AuthUnauthenticated extends AuthState { class AuthUnauthenticated extends AuthState {

View File

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

View File

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

View File

@@ -1,9 +1,13 @@
import 'package:bloc/bloc.dart'; import 'package:bloc/bloc.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'permission_event.dart'; import 'permission_event.dart';
import 'permission_state.dart'; import 'permission_state.dart';
import '../../services/api_client.dart';
class PermissionBloc extends Bloc<PermissionEvent, PermissionState> { class PermissionBloc extends Bloc<PermissionEvent, PermissionState> {
PermissionBloc() : super(PermissionInitial()) { final ApiClient apiClient;
PermissionBloc(this.apiClient) : super(PermissionInitial()) {
on<LoadPermissions>(_onLoadPermissions); on<LoadPermissions>(_onLoadPermissions);
on<PermissionsReset>(_onPermissionsReset); on<PermissionsReset>(_onPermissionsReset);
} }
@@ -12,20 +16,36 @@ class PermissionBloc extends Bloc<PermissionEvent, PermissionState> {
LoadPermissions event, LoadPermissions event,
Emitter<PermissionState> emit, Emitter<PermissionState> emit,
) async { ) 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()); emit(PermissionLoading());
// Simulate loading permissions from backend for orgId try {
await Future.delayed(const Duration(seconds: 1)); final response = await apiClient.getRaw(
// Mock capabilities based on orgId '/orgs/${event.orgId}/permissions',
// Allow all permissions for authenticated users (proper permissions should come from backend) );
final capabilities = Capabilities( final capabilities = Capabilities(
canRead: true, canRead: response['canRead'] ?? false,
canWrite: true, canWrite: response['canWrite'] ?? false,
canShare: true, canShare: response['canShare'] ?? false,
canAdmin: true, canAdmin: response['canAdmin'] ?? false,
canAnnotate: true, canAnnotate: response['canAnnotate'] ?? false,
canEdit: true, canEdit: response['canEdit'] ?? false,
); );
emit(PermissionLoaded(capabilities)); emit(PermissionLoaded(capabilities));
} catch (e) {
emit(PermissionDenied(e.toString()));
}
} }
void _onPermissionsReset( void _onPermissionsReset(

View File

@@ -1,4 +1,4 @@
import 'package:b0esche_cloud/services/api_client.dart'; import 'services/api_client.dart';
import 'package:get_it/get_it.dart'; import 'package:get_it/get_it.dart';
import 'blocs/session/session_bloc.dart'; import 'blocs/session/session_bloc.dart';
import 'repositories/auth_repository.dart'; import 'repositories/auth_repository.dart';
@@ -8,8 +8,6 @@ import 'repositories/http_file_repository.dart';
import 'services/auth_service.dart'; import 'services/auth_service.dart';
import 'services/file_service.dart'; import 'services/file_service.dart';
import 'services/org_api.dart'; import 'services/org_api.dart';
import 'viewmodels/login_view_model.dart';
import 'viewmodels/file_explorer_view_model.dart';
final getIt = GetIt.instance; final getIt = GetIt.instance;
@@ -28,10 +26,4 @@ void configureDependencies(SessionBloc sessionBloc) {
getIt.registerSingleton<AuthService>(AuthService(getIt<AuthRepository>())); getIt.registerSingleton<AuthService>(AuthService(getIt<AuthRepository>()));
getIt.registerSingleton<FileService>(FileService(getIt<ApiClient>())); getIt.registerSingleton<FileService>(FileService(getIt<ApiClient>()));
getIt.registerSingleton<OrgApi>(OrgApi(getIt<ApiClient>())); getIt.registerSingleton<OrgApi>(OrgApi(getIt<ApiClient>()));
// Register viewmodels
getIt.registerSingleton<LoginViewModel>(LoginViewModel(getIt<AuthService>()));
getIt.registerSingleton<FileExplorerViewModel>(
FileExplorerViewModel(getIt<FileService>()),
);
} }

View File

@@ -11,12 +11,20 @@ import 'pages/home_page.dart';
import 'pages/file_explorer.dart'; import 'pages/file_explorer.dart';
import 'pages/document_viewer.dart'; import 'pages/document_viewer.dart';
import 'pages/editor_page.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 'theme/app_theme.dart';
import 'injection.dart'; import 'injection.dart';
import 'package:flutter/foundation.dart' show kIsWeb;
final GoRouter _router = GoRouter( final GoRouter _router = GoRouter(
initialLocation: kIsWeb ? Uri.base.path : '/',
routes: [ routes: [
GoRoute(path: '/', builder: (context, state) => const HomePage()), GoRoute(path: '/', builder: (context, state) => const HomePage()),
GoRoute(path: '/login', builder: (context, state) => const LoginPage()),
GoRoute( GoRoute(
path: '/viewer/:orgId/:fileId', path: '/viewer/:orgId/:fileId',
builder: (context, state) => DocumentViewer( builder: (context, state) => DocumentViewer(
@@ -36,6 +44,16 @@ final GoRouter _router = GoRouter(
builder: (context, state) => builder: (context, state) =>
FileExplorer(orgId: state.pathParameters['orgId']!), 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']!),
),
], ],
); );
@@ -107,6 +125,19 @@ class _MainAppState extends State<MainApp> {
return MaterialApp.router( return MaterialApp.router(
routerConfig: _router, routerConfig: _router,
theme: AppTheme.darkTheme, 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!,
);
},
); );
}, },
), ),

View File

@@ -1,5 +1,33 @@
import 'package:equatable/equatable.dart'; 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 { class DocumentCapabilities extends Equatable {
final bool canEdit; final bool canEdit;
final bool canAnnotate; final bool canAnnotate;
@@ -31,4 +59,6 @@ class DocumentCapabilities extends Equatable {
mimeType.contains('word') || mimeType.contains('word') ||
mimeType.contains('spreadsheet') || mimeType.contains('spreadsheet') ||
mimeType.contains('presentation'); mimeType.contains('presentation');
bool get isVideo => mimeType.startsWith('video/');
bool get isAudio => mimeType.startsWith('audio/');
} }

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

View File

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

View File

@@ -1,6 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'dart:convert'; import 'dart:convert';
import 'dart:html' as html; import 'package:web/web.dart' as web;
import 'dart:ui_web' as ui; import 'dart:ui_web' as ui;
import '../theme/app_theme.dart'; import '../theme/app_theme.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
@@ -12,8 +12,11 @@ import '../blocs/session/session_state.dart';
import '../services/file_service.dart'; import '../services/file_service.dart';
import '../injection.dart'; import '../injection.dart';
import 'package:syncfusion_flutter_pdfviewer/pdfviewer.dart'; import 'package:syncfusion_flutter_pdfviewer/pdfviewer.dart';
import 'package:syncfusion_flutter_core/theme.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:http/http.dart' as http; import 'package:http/http.dart' as http;
import 'package:url_launcher/url_launcher.dart';
import '../widgets/file_viewer_dispatch.dart';
// Modal version for overlay display // Modal version for overlay display
class DocumentViewerModal extends StatefulWidget { class DocumentViewerModal extends StatefulWidget {
@@ -112,6 +115,22 @@ class _DocumentViewerModalState extends State<DocumentViewerModal> {
BlocBuilder<DocumentViewerBloc, DocumentViewerState>( BlocBuilder<DocumentViewerBloc, DocumentViewerState>(
builder: (context, state) { builder: (context, state) {
if (state is DocumentViewerReady) { if (state is DocumentViewerReady) {
final fileInfo = state.fileInfo;
String lastModifiedText = 'Last modified: Unknown';
if (fileInfo != null) {
final modifiedDate = fileInfo.lastModified;
final modifiedBy = fileInfo.modifiedByName;
if (modifiedDate != null) {
final formattedDate =
'${modifiedDate.day.toString().padLeft(2, '0')}.${modifiedDate.month.toString().padLeft(2, '0')}.${modifiedDate.year} ${modifiedDate.hour.toString().padLeft(2, '0')}:${modifiedDate.minute.toString().padLeft(2, '0')}';
if (modifiedBy != null && modifiedBy.isNotEmpty) {
lastModifiedText =
'Last modified: $formattedDate by $modifiedBy';
} else {
lastModifiedText = 'Last modified: $formattedDate';
}
}
}
return Container( return Container(
height: 30, height: 30,
alignment: Alignment.centerLeft, alignment: Alignment.centerLeft,
@@ -119,9 +138,9 @@ class _DocumentViewerModalState extends State<DocumentViewerModal> {
decoration: BoxDecoration( decoration: BoxDecoration(
color: AppTheme.primaryBackground.withValues(alpha: 0.3), color: AppTheme.primaryBackground.withValues(alpha: 0.3),
), ),
child: const Text( child: Text(
'Last modified: Unknown by Unknown (v1)', lastModifiedText,
style: TextStyle( style: const TextStyle(
fontSize: 12, fontSize: 12,
color: AppTheme.secondaryText, color: AppTheme.secondaryText,
), ),
@@ -183,34 +202,42 @@ class _DocumentViewerModalState extends State<DocumentViewerModal> {
if (state is DocumentViewerReady) { if (state is DocumentViewerReady) {
// Handle different file types based on MIME type // Handle different file types based on MIME type
if (state.caps.isPdf) { if (state.caps.isPdf) {
// PDF viewer using SfPdfViewer return FileViewerDispatch.buildFileViewer(
return SfPdfViewer.network( context,
state.viewUrl.toString(), state.viewUrl.toString(),
headers: {'Authorization': 'Bearer ${state.token}'}, state.caps.mimeType,
onDocumentLoadFailed: (details) {}, token: state.token,
onDocumentLoaded: (PdfDocumentLoadedDetails details) {}, fileName: state.fileInfo?.name,
viewerId: 'internal-pdf-${state.viewUrl.hashCode}',
onHyperlinkClicked: (details) =>
_handleHyperlink(details.uri),
); );
} else if (state.caps.isImage) { } else if (state.caps.isImage) {
// Image viewer return FileViewerDispatch.buildFileViewer(
return Container( context,
color: AppTheme.primaryBackground, state.viewUrl.toString(),
child: InteractiveViewer( state.caps.mimeType,
minScale: 0.5, token: state.token,
maxScale: 4.0, fileName: state.fileInfo?.name,
child: Image.network( viewerId: 'internal-image-${state.viewUrl.hashCode}',
state.viewUrl.toString(), );
headers: {'Authorization': 'Bearer ${state.token}'}, } else if (state.caps.isVideo) {
fit: BoxFit.contain, return FileViewerDispatch.buildFileViewer(
errorBuilder: (context, error, stackTrace) { context,
return Center( state.viewUrl.toString(),
child: Text( state.caps.mimeType,
'Failed to load image', token: state.token,
style: TextStyle(color: Colors.red[400]), fileName: state.fileInfo?.name,
), viewerId: 'internal-video-${state.viewUrl.hashCode}',
); );
}, } else if (state.caps.isAudio) {
), return FileViewerDispatch.buildFileViewer(
), context,
state.viewUrl.toString(),
state.caps.mimeType,
token: state.token,
fileName: state.fileInfo?.name,
viewerId: 'internal-audio-${state.viewUrl.hashCode}',
); );
} else if (state.caps.isText) { } else if (state.caps.isText) {
// Text file viewer // Text file viewer
@@ -401,22 +428,31 @@ class _DocumentViewerModalState extends State<DocumentViewerModal> {
); );
} }
// ignore: unused_local_variable
final wopiSession = snapshot.data!; final wopiSession = snapshot.data!;
// Build Collabora Online viewer URL with WOPISrc // Use backend proxy endpoint to serve the Collabora form
// The WOPISrc must be URL-encoded and kept encoded final proxyUrl = _buildProxyUrl(token);
// We use a double-encoding approach: encodeComponent keeps it encoded through iframe.src return _buildWebView(proxyUrl);
final baseUrl = 'https://of.b0esche.cloud/loleaflet/dist/loleaflet.html';
final collaboraUrl = Uri.parse(baseUrl)
.replace(queryParameters: {'WOPISrc': wopiSession.wopisrc})
.toString();
// Use WebView to display Collabora Online
return _buildWebView(collaboraUrl);
}, },
); );
} }
String _buildProxyUrl(String token) {
// Build the proxy URL based on whether we're in org or user workspace
String baseUrl = 'https://go.b0esche.cloud';
String endpoint;
if (widget.orgId.isNotEmpty && widget.orgId != 'personal') {
endpoint = '/orgs/${widget.orgId}/files/${widget.fileId}/collabora-proxy';
} else {
endpoint = '/user/files/${widget.fileId}/collabora-proxy';
}
// Pass token as query parameter for iframe (which cannot send Authorization header)
return '$baseUrl$endpoint?token=$token';
}
Future<WOPISession> _createWOPISession(String token) async { Future<WOPISession> _createWOPISession(String token) async {
try { try {
// Use default base URL from backend // Use default base URL from backend
@@ -456,41 +492,137 @@ class _DocumentViewerModalState extends State<DocumentViewerModal> {
} }
} }
Widget _buildCollaboraIframe(String collaboraUrl) { Widget _buildCollaboraIframe(String proxyUrl) {
// For Collabora Online, create an iframe that loads the editor directly // Load the backend proxy page which handles Collabora form submission
const String viewType = 'collabora-iframe'; final String viewType =
'collabora-${DateTime.now().millisecondsSinceEpoch}';
ui.platformViewRegistry.registerViewFactory(
viewType, ui.platformViewRegistry.registerViewFactory(viewType, (int viewId) {
(int viewId) { // Create iframe pointing to the proxy endpoint
// Create the iframe with the properly encoded Collabora URL final iframe = web.HTMLIFrameElement()
final iframe = html.IFrameElement() ..style.border = 'none'
..src = collaboraUrl ..style.width = '100%'
..style.border = 'none' ..style.height = '100%'
..style.width = '100%' ..style.margin = '0'
..style.height = '100%' ..style.padding = '0'
..style.margin = '0' ..src = proxyUrl
..style.padding = '0' ..setAttribute(
..setAttribute( 'allow',
'allow', 'microphone; camera; usb; autoplay; clipboard-read; clipboard-write; fullscreen',
'microphone; camera; usb; autoplay; clipboard-read; clipboard-write', )
) ..setAttribute(
// Remove allow-same-origin for security, add allow-popups-to-escape-sandbox 'sandbox',
..setAttribute( 'allow-same-origin allow-scripts allow-popups allow-popups-to-escape-sandbox allow-forms allow-presentation',
'sandbox', );
'allow-scripts allow-popups allow-forms allow-pointer-lock allow-presentation allow-modals allow-downloads allow-popups-to-escape-sandbox',
); final container = web.HTMLDivElement()
..style.width = '100%'
return iframe; ..style.height = '100%'
}, ..style.margin = '0'
); ..style.padding = '0'
..style.overflow = 'hidden'
..append(iframe);
return container;
});
return HtmlElementView(viewType: viewType); return HtmlElementView(viewType: viewType);
} }
Widget _buildWebView(String url) { Widget _buildWebView(String proxyUrl) {
// Embed Collabora Online in an iframe for web platform // Embed Collabora Online via proxy endpoint
return _buildCollaboraIframe(url); return _buildCollaboraIframe(proxyUrl);
}
Future<void> _handleHyperlink(String url) async {
final shouldOpen = await showDialog<bool>(
context: context,
builder: (BuildContext context) {
return Dialog(
backgroundColor: Colors.transparent,
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 400),
child: Container(
decoration: AppTheme.glassDecoration,
padding: const EdgeInsets.all(16),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Text(
'Open Link',
style: TextStyle(
color: AppTheme.primaryText,
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 16),
Text(
'Open this link in your browser?\n\n$url',
style: const TextStyle(color: AppTheme.primaryText),
textAlign: TextAlign.center,
),
const SizedBox(height: 24),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(
style: ButtonStyle(
splashFactory: NoSplash.splashFactory,
overlayColor: WidgetStateProperty.resolveWith<Color?>((
Set<WidgetState> states,
) {
if (states.contains(WidgetState.pressed)) {
return Colors.transparent;
}
return null; // Use default for other states (like hover)
}),
),
onPressed: () => Navigator.of(context).pop(false),
child: const Text(
'Cancel',
style: TextStyle(color: AppTheme.primaryText),
),
),
TextButton(
style: ButtonStyle(
splashFactory: NoSplash.splashFactory,
overlayColor: WidgetStateProperty.resolveWith<Color?>((
Set<WidgetState> states,
) {
if (states.contains(WidgetState.pressed)) {
return Colors.transparent;
}
return null; // Use default for other states (like hover)
}),
),
onPressed: () => Navigator.of(context).pop(true),
child: const Text(
'Open',
style: TextStyle(
color: AppTheme.accentColor,
decoration: TextDecoration.underline,
decorationColor: AppTheme.accentColor,
decorationThickness: 1.5,
),
),
),
],
),
],
),
),
),
);
},
);
if (shouldOpen == true) {
final uri = Uri.parse(url);
if (await canLaunchUrl(uri)) {
await launchUrl(uri, mode: LaunchMode.externalApplication);
}
}
} }
@override @override
@@ -548,13 +680,28 @@ class _DocumentViewerState extends State<DocumentViewer> {
child: BlocBuilder<DocumentViewerBloc, DocumentViewerState>( child: BlocBuilder<DocumentViewerBloc, DocumentViewerState>(
builder: (context, state) { builder: (context, state) {
if (state is DocumentViewerReady) { if (state is DocumentViewerReady) {
// Placeholder for meta final fileInfo = state.fileInfo;
String lastModifiedText = 'Last modified: Unknown';
if (fileInfo != null) {
final modifiedDate = fileInfo.lastModified;
final modifiedBy = fileInfo.modifiedByName;
if (modifiedDate != null) {
final formattedDate =
'${modifiedDate.day.toString().padLeft(2, '0')}.${modifiedDate.month.toString().padLeft(2, '0')}.${modifiedDate.year} ${modifiedDate.hour.toString().padLeft(2, '0')}:${modifiedDate.minute.toString().padLeft(2, '0')}';
if (modifiedBy != null && modifiedBy.isNotEmpty) {
lastModifiedText =
'Last modified: $formattedDate by $modifiedBy';
} else {
lastModifiedText = 'Last modified: $formattedDate';
}
}
}
return Container( return Container(
height: 30, height: 30,
alignment: Alignment.centerLeft, alignment: Alignment.centerLeft,
padding: const EdgeInsets.symmetric(horizontal: 16), padding: const EdgeInsets.symmetric(horizontal: 16),
child: Text( child: Text(
'Last modified: Unknown by Unknown (v1)', lastModifiedText,
style: const TextStyle(fontSize: 12), style: const TextStyle(fontSize: 12),
), ),
); );
@@ -647,14 +794,32 @@ class _DocumentViewerState extends State<DocumentViewer> {
} }
if (state.caps.isPdf) { if (state.caps.isPdf) {
// PDF viewer using SfPdfViewer // PDF viewer using SfPdfViewer, wrapped in SfTheme for custom accent color
return SfPdfViewer.network( return SfTheme(
state.viewUrl.toString(), data: SfThemeData(
headers: token != null pdfViewerThemeData: SfPdfViewerThemeData(
? {'Authorization': 'Bearer $token'} backgroundColor: AppTheme.primaryBackground,
: {}, progressBarColor: AppTheme.accentColor,
onDocumentLoadFailed: (details) {}, scrollStatusStyle: PdfScrollStatusStyle(
onDocumentLoaded: (PdfDocumentLoadedDetails details) {}, backgroundColor: AppTheme.primaryBackground,
),
scrollHeadStyle: PdfScrollHeadStyle(
backgroundColor: AppTheme.accentColor,
),
),
),
child: SfPdfViewer.network(
state.viewUrl.toString(),
headers: token != null
? {'Authorization': 'Bearer $token'}
: {},
onDocumentLoadFailed: (details) {},
onDocumentLoaded: (PdfDocumentLoadedDetails details) {},
canShowHyperlinkDialog: false,
enableHyperlinkNavigation: false,
onHyperlinkClicked: (details) =>
_handleHyperlink(details.uri),
),
); );
} else if (state.caps.isImage) { } else if (state.caps.isImage) {
// Image viewer // Image viewer
@@ -841,6 +1006,97 @@ class _DocumentViewerState extends State<DocumentViewer> {
); );
} }
Future<void> _handleHyperlink(String url) async {
final shouldOpen = await showDialog<bool>(
context: context,
builder: (BuildContext context) {
return Dialog(
backgroundColor: Colors.transparent,
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 400),
child: Container(
decoration: AppTheme.glassDecoration,
padding: const EdgeInsets.all(16),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Text(
'Open Link',
style: TextStyle(
color: AppTheme.primaryText,
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 16),
Text(
'Do you want to open this link in your browser?\n\n$url',
style: const TextStyle(color: AppTheme.primaryText),
textAlign: TextAlign.center,
),
const SizedBox(height: 24),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(
style: ButtonStyle(
splashFactory: NoSplash.splashFactory,
overlayColor: WidgetStateProperty.resolveWith<Color?>((
Set<WidgetState> states,
) {
if (states.contains(WidgetState.pressed)) {
return Colors.transparent;
}
return null; // Use default for other states (like hover)
}),
),
onPressed: () => Navigator.of(context).pop(false),
child: const Text(
'Cancel',
style: TextStyle(color: AppTheme.primaryText),
),
),
TextButton(
style: ButtonStyle(
splashFactory: NoSplash.splashFactory,
overlayColor: WidgetStateProperty.resolveWith<Color?>((
Set<WidgetState> states,
) {
if (states.contains(WidgetState.pressed)) {
return Colors.transparent;
}
return null; // Use default for other states (like hover)
}),
),
onPressed: () => Navigator.of(context).pop(true),
child: const Text(
'Open',
style: TextStyle(
color: AppTheme.accentColor,
decoration: TextDecoration.underline,
decorationColor: AppTheme.accentColor,
decorationThickness: 1.5,
),
),
),
],
),
],
),
),
),
);
},
);
if (shouldOpen == true) {
final uri = Uri.parse(url);
if (await canLaunchUrl(uri)) {
await launchUrl(uri, mode: LaunchMode.externalApplication);
}
}
}
@override @override
void dispose() { void dispose() {
_viewerBloc.close(); _viewerBloc.close();

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,5 @@
import 'dart:ui' as ui; import 'dart:ui' as ui;
import 'package:b0esche_cloud/blocs/organization/organization_state.dart'; import '../blocs/organization/organization_state.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import '../blocs/auth/auth_bloc.dart'; import '../blocs/auth/auth_bloc.dart';
@@ -9,14 +9,19 @@ import '../blocs/organization/organization_event.dart';
import '../blocs/file_browser/file_browser_bloc.dart'; import '../blocs/file_browser/file_browser_bloc.dart';
import '../blocs/file_browser/file_browser_event.dart'; import '../blocs/file_browser/file_browser_event.dart';
import '../blocs/permission/permission_bloc.dart'; import '../blocs/permission/permission_bloc.dart';
import '../blocs/permission/permission_event.dart';
import '../blocs/upload/upload_bloc.dart'; import '../blocs/upload/upload_bloc.dart';
import '../repositories/file_repository.dart'; import '../repositories/file_repository.dart';
import '../services/file_service.dart'; import '../services/file_service.dart';
import '../services/org_api.dart'; import '../services/org_api.dart';
import '../services/api_client.dart';
import '../theme/app_theme.dart'; import '../theme/app_theme.dart';
import '../theme/modern_glass_button.dart'; import '../theme/modern_glass_button.dart';
import 'login_form.dart' show LoginForm; import 'login_form.dart' show LoginForm;
import 'file_explorer.dart'; import 'file_explorer.dart';
import '../widgets/organization_settings_dialog.dart';
import '../widgets/account_settings_dialog.dart';
import '../widgets/audio_player_bar.dart';
import '../injection.dart'; import '../injection.dart';
class HomePage extends StatefulWidget { class HomePage extends StatefulWidget {
@@ -27,6 +32,12 @@ class HomePage extends StatefulWidget {
} }
class _HomePageState extends State<HomePage> with TickerProviderStateMixin { class _HomePageState extends State<HomePage> with TickerProviderStateMixin {
// Audio bar state
String? _audioFileName;
String? _audioFileUrl;
bool _showAudioBar = false;
late AnimationController _audioBarController;
late Animation<Offset> _audioBarOffset;
late String _selectedTab = 'Drive'; late String _selectedTab = 'Drive';
late AnimationController _animationController; late AnimationController _animationController;
bool _isSignupMode = false; bool _isSignupMode = false;
@@ -46,7 +57,19 @@ class _HomePageState extends State<HomePage> with TickerProviderStateMixin {
vsync: this, vsync: this,
); );
_permissionBloc = PermissionBloc(); _audioBarController = AnimationController(
duration: const Duration(milliseconds: 400),
vsync: this,
);
_audioBarOffset =
Tween<Offset>(begin: const Offset(0, -1), end: Offset.zero).animate(
CurvedAnimation(
parent: _audioBarController,
curve: Curves.easeOutBack,
),
);
_permissionBloc = PermissionBloc(getIt<ApiClient>());
_fileBrowserBloc = FileBrowserBloc(getIt<FileService>()); _fileBrowserBloc = FileBrowserBloc(getIt<FileService>());
_uploadBloc = UploadBloc(getIt<FileRepository>()); _uploadBloc = UploadBloc(getIt<FileRepository>());
_organizationBloc = OrganizationBloc( _organizationBloc = OrganizationBloc(
@@ -60,6 +83,7 @@ class _HomePageState extends State<HomePage> with TickerProviderStateMixin {
@override @override
void dispose() { void dispose() {
_animationController.dispose(); _animationController.dispose();
_audioBarController.dispose();
_organizationBloc.close(); _organizationBloc.close();
_uploadBloc.close(); _uploadBloc.close();
_fileBrowserBloc.close(); _fileBrowserBloc.close();
@@ -67,6 +91,15 @@ class _HomePageState extends State<HomePage> with TickerProviderStateMixin {
super.dispose(); super.dispose();
} }
void _onAudioFileSelected(String fileName, String fileUrl) {
setState(() {
_audioFileName = fileName;
_audioFileUrl = fileUrl;
_showAudioBar = true;
});
_audioBarController.forward();
}
void _setSignupMode(bool isSignup) { void _setSignupMode(bool isSignup) {
if (_isSignupMode && !isSignup) { if (_isSignupMode && !isSignup) {
Future.delayed(const Duration(milliseconds: 200), () { Future.delayed(const Duration(milliseconds: 200), () {
@@ -144,7 +177,10 @@ class _HomePageState extends State<HomePage> with TickerProviderStateMixin {
children: [ children: [
ModernGlassButton( ModernGlassButton(
onPressed: () => Navigator.of(dialogContext).pop(), onPressed: () => Navigator.of(dialogContext).pop(),
child: const Text('Cancel'), child: Text(
'Cancel',
style: TextStyle(color: Colors.red[400]),
),
), ),
const SizedBox(width: 16), const SizedBox(width: 16),
ModernGlassButton( ModernGlassButton(
@@ -172,6 +208,29 @@ class _HomePageState extends State<HomePage> with TickerProviderStateMixin {
); );
} }
void _showOrganizationSettings(BuildContext context) {
final orgState = _organizationBloc.state;
final permState = _permissionBloc.state;
if (orgState is OrganizationLoaded && orgState.selectedOrg != null) {
showDialog(
context: context,
builder: (dialogContext) => OrganizationSettingsDialog(
organization: orgState.selectedOrg!,
permissionState: permState,
orgApi: getIt<OrgApi>(),
),
);
}
}
void _showAccountSettings(BuildContext context) {
showDialog(
context: context,
builder: (dialogContext) => const AccountSettingsDialog(),
);
}
Widget _buildOrgRow(BuildContext context) { Widget _buildOrgRow(BuildContext context) {
return BlocBuilder<OrganizationBloc, OrganizationState>( return BlocBuilder<OrganizationBloc, OrganizationState>(
builder: (context, state) { builder: (context, state) {
@@ -235,7 +294,17 @@ class _HomePageState extends State<HomePage> with TickerProviderStateMixin {
final highlightColor = const Color.fromARGB(255, 100, 200, 255); final highlightColor = const Color.fromARGB(255, 100, 200, 255);
final defaultColor = AppTheme.secondaryText; final defaultColor = AppTheme.secondaryText;
return TextButton( return TextButton(
style: ButtonStyle(splashFactory: NoSplash.splashFactory), style: ButtonStyle(
splashFactory: NoSplash.splashFactory,
overlayColor: WidgetStateProperty.resolveWith<Color?>((
Set<WidgetState> states,
) {
if (states.contains(WidgetState.pressed)) {
return Colors.transparent;
}
return null; // Use default for other states (like hover)
}),
),
onPressed: onTap, onPressed: onTap,
child: Text( child: Text(
org.name, org.name,
@@ -251,7 +320,17 @@ class _HomePageState extends State<HomePage> with TickerProviderStateMixin {
final highlightColor = const Color.fromARGB(255, 100, 200, 255); final highlightColor = const Color.fromARGB(255, 100, 200, 255);
final defaultColor = AppTheme.secondaryText; final defaultColor = AppTheme.secondaryText;
return TextButton( return TextButton(
style: ButtonStyle(splashFactory: NoSplash.splashFactory), style: ButtonStyle(
splashFactory: NoSplash.splashFactory,
overlayColor: WidgetStateProperty.resolveWith<Color?>((
Set<WidgetState> states,
) {
if (states.contains(WidgetState.pressed)) {
return Colors.transparent;
}
return null; // Use default for other states (like hover)
}),
),
onPressed: onTap, onPressed: onTap,
child: Text( child: Text(
'Personal', 'Personal',
@@ -266,7 +345,17 @@ class _HomePageState extends State<HomePage> with TickerProviderStateMixin {
Widget _buildAddButton(VoidCallback onTap) { Widget _buildAddButton(VoidCallback onTap) {
final defaultColor = AppTheme.secondaryText; final defaultColor = AppTheme.secondaryText;
return TextButton( return TextButton(
style: ButtonStyle(splashFactory: NoSplash.splashFactory), style: ButtonStyle(
splashFactory: NoSplash.splashFactory,
overlayColor: WidgetStateProperty.resolveWith<Color?>((
Set<WidgetState> states,
) {
if (states.contains(WidgetState.pressed)) {
return Colors.transparent;
}
return null; // Use default for other states (like hover)
}),
),
onPressed: onTap, onPressed: onTap,
child: Text('+ Add Organization', style: TextStyle(color: defaultColor)), child: Text('+ Add Organization', style: TextStyle(color: defaultColor)),
); );
@@ -284,24 +373,118 @@ class _HomePageState extends State<HomePage> with TickerProviderStateMixin {
orgId = ''; orgId = '';
} }
return FileExplorer(orgId: orgId); return FileExplorer(
orgId: orgId,
onAudioFileSelected: _onAudioFileSelected,
);
} }
Widget _buildNavButton(String label, IconData icon, {bool isAvatar = false}) { Widget _buildNavButton(
String label,
IconData icon, {
bool isAvatar = false,
VoidCallback? onTap,
}) {
final isSelected = _selectedTab == label; final isSelected = _selectedTab == label;
final highlightColor = const Color.fromARGB(255, 100, 200, 255); final highlightColor = const Color.fromARGB(255, 100, 200, 255);
final defaultColor = AppTheme.secondaryText; final defaultColor = onTap != null
? AppTheme.primaryText
: AppTheme.secondaryText;
return GestureDetector( return GestureDetector(
onTap: () { onTap:
setState(() { onTap ??
_selectedTab = label; () {
}); setState(() {
}, _selectedTab = label;
});
},
child: isAvatar child: isAvatar
? CircleAvatar( ? BlocBuilder<AuthBloc, AuthState>(
backgroundColor: isSelected ? highlightColor : defaultColor, builder: (context, state) {
child: Icon(icon, color: AppTheme.primaryBackground), if (state is AuthAuthenticated &&
state.user?.avatarUrl != null &&
state.token.isNotEmpty) {
String url = state.user!.avatarUrl!;
if (!url.contains('token=') && state.token.isNotEmpty) {
url = "$url&token=${state.token}";
}
// Show default avatar while image downloads and display a progress ring
return SizedBox(
width: 44,
height: 44,
child: Stack(
alignment: Alignment.center,
children: [
// Default placeholder visible under the image/progress
CircleAvatar(
radius: 20,
backgroundColor: isSelected
? highlightColor
: defaultColor,
child: Icon(icon, color: AppTheme.primaryBackground),
),
// Network image with loading and error handling
ClipOval(
child: Image.network(
url,
width: 40,
height: 40,
fit: BoxFit.cover,
loadingBuilder: (context, child, loadingProgress) {
if (loadingProgress == null) return child;
final expected =
loadingProgress.expectedTotalBytes;
final loaded =
loadingProgress.cumulativeBytesLoaded;
double? value;
if (expected != null && expected > 0) {
value = loaded / expected;
}
return SizedBox(
width: 40,
height: 40,
child: Stack(
alignment: Alignment.center,
children: [
// transparent circle so placeholder remains visible
CircleAvatar(
radius: 20,
backgroundColor: Colors.transparent,
),
SizedBox(
width: 44,
height: 44,
child: CircularProgressIndicator(
value: value,
strokeWidth: 2.5,
valueColor:
AlwaysStoppedAnimation<Color>(
AppTheme.accentColor,
),
backgroundColor: AppTheme.secondaryText
.withValues(alpha: 0.12),
),
),
],
),
);
},
errorBuilder: (context, error, stackTrace) {
// keep placeholder visible on error
return const SizedBox.shrink();
},
),
),
],
),
);
}
return CircleAvatar(
backgroundColor: isSelected ? highlightColor : defaultColor,
child: Icon(icon, color: AppTheme.primaryBackground),
);
},
) )
: Column( : Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
@@ -340,6 +523,118 @@ class _HomePageState extends State<HomePage> with TickerProviderStateMixin {
backgroundColor: AppTheme.primaryBackground, backgroundColor: AppTheme.primaryBackground,
body: Stack( body: Stack(
children: [ children: [
// Top bar: title always centered, nav buttons right
Positioned(
top: 0,
left: 0,
right: 0,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 32),
child: SizedBox(
height: 64,
child: Stack(
alignment: Alignment.center,
children: [
// Centered title
Align(
alignment: Alignment.center,
child: Builder(
builder: (context) {
final screenWidth = MediaQuery.of(
context,
).size.width;
final fontSize = screenWidth < 600 ? 24.0 : 48.0;
return Text(
'b0esche.cloud',
style: TextStyle(
fontFamily: 'PixelatedElegance',
fontSize: fontSize,
color: AppTheme.primaryText,
decoration: TextDecoration.underline,
decorationColor: AppTheme.primaryText,
fontFeatures: const [FontFeature.slashedZero()],
),
);
},
),
),
if (_showAudioBar &&
_audioFileName != null &&
_audioFileUrl != null)
Positioned(
right: 184,
top: 10,
child: SlideTransition(
position: _audioBarOffset,
child: AudioPlayerBar(
fileName: _audioFileName!,
fileUrl: _audioFileUrl!,
onClose: () {
_audioBarController.reverse();
setState(() => _showAudioBar = false);
},
),
),
),
// Right: nav buttons
Align(
alignment: Alignment.centerRight,
child: BlocBuilder<AuthBloc, AuthState>(
builder: (context, state) {
final isLoggedIn = state is AuthAuthenticated;
if (!isLoggedIn) {
return const SizedBox.shrink();
}
return BlocBuilder<
OrganizationBloc,
OrganizationState
>(
builder: (context, orgState) {
final hasSelectedOrg =
orgState is OrganizationLoaded &&
orgState.selectedOrg != null;
return Row(
mainAxisSize: MainAxisSize.min,
children: [
_buildNavButton('Drive', Icons.cloud),
const SizedBox(width: 16),
_buildNavButton('Mail', Icons.mail),
const SizedBox(width: 16),
_buildNavButton(
'Add',
Icons.add,
onTap: () {},
),
const SizedBox(width: 16),
if (hasSelectedOrg)
_buildNavButton(
'Manage',
Icons.manage_accounts_rounded,
onTap: () =>
_showOrganizationSettings(context),
),
if (hasSelectedOrg)
const SizedBox(width: 16),
_buildNavButton(
'Profile',
Icons.person,
isAvatar: true,
onTap: () =>
_showAccountSettings(context),
),
],
);
},
);
},
),
),
],
),
),
),
),
Center( Center(
child: BlocBuilder<AuthBloc, AuthState>( child: BlocBuilder<AuthBloc, AuthState>(
builder: (context, state) { builder: (context, state) {
@@ -401,6 +696,10 @@ class _HomePageState extends State<HomePage> with TickerProviderStateMixin {
path: '/', path: '/',
), ),
); );
// Reload permissions when org changes
context.read<PermissionBloc>().add(
LoadPermissions(orgId),
);
} }
}, },
child: child:
@@ -590,65 +889,6 @@ class _HomePageState extends State<HomePage> with TickerProviderStateMixin {
}, },
), ),
), ),
Positioned(
top: 0,
left: 0,
right: 0,
child: Center(
child: Builder(
builder: (context) {
final screenWidth = MediaQuery.of(context).size.width;
final fontSize = screenWidth < 600 ? 24.0 : 48.0;
return Text(
'b0esche.cloud',
style: TextStyle(
fontFamily: 'PixelatedElegance',
fontSize: fontSize,
color: AppTheme.primaryText,
decoration: TextDecoration.underline,
decorationColor: AppTheme.primaryText,
fontFeatures: const [FontFeature.slashedZero()],
),
);
},
),
),
),
Positioned(
top: MediaQuery.of(context).size.width < 600 ? 40 : 10,
right: 20,
child: BlocBuilder<AuthBloc, AuthState>(
builder: (context, state) {
final isLoggedIn = state is AuthAuthenticated;
if (!isLoggedIn) {
return const SizedBox.shrink();
}
return ScaleTransition(
scale: Tween<double>(begin: 0, end: 1).animate(
CurvedAnimation(
parent: _animationController,
curve: Curves.easeOutBack,
),
),
child: Row(
children: [
_buildNavButton('Drive', Icons.cloud),
const SizedBox(width: 16),
_buildNavButton('Mail', Icons.mail),
const SizedBox(width: 16),
_buildNavButton('Add', Icons.add),
const SizedBox(width: 16),
_buildNavButton(
'Profile',
Icons.person,
isAvatar: true,
),
],
),
);
},
),
),
], ],
), ),
), ),

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

@@ -32,6 +32,12 @@ class _LoginFormState extends State<LoginForm> {
bool _usePasskey = true; bool _usePasskey = true;
bool _isSignup = false; bool _isSignup = false;
// UI error state for inline validation
String? _usernameErrorText;
String? _passwordErrorText;
bool _usernameHasError = false;
bool _passwordHasError = false;
@override @override
void dispose() { void dispose() {
_usernameController.dispose(); _usernameController.dispose();
@@ -112,6 +118,12 @@ class _LoginFormState extends State<LoginForm> {
_passwordController.clear(); _passwordController.clear();
_displayNameController.clear(); _displayNameController.clear();
_usePasskey = true; _usePasskey = true;
// Clear inline error state
_usernameHasError = false;
_passwordHasError = false;
_usernameErrorText = null;
_passwordErrorText = null;
} }
void _setSignupMode(bool isSignup) { void _setSignupMode(bool isSignup) {
@@ -124,16 +136,43 @@ class _LoginFormState extends State<LoginForm> {
return BlocListener<AuthBloc, AuthState>( return BlocListener<AuthBloc, AuthState>(
listener: (context, state) { listener: (context, state) {
if (state is AuthFailure) { if (state is AuthFailure) {
ScaffoldMessenger.of( // Handle specific credential errors inline
context, if (state.code == 'INVALID_PASSWORD' ||
).showSnackBar(SnackBar(content: Text(state.error))); 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) { } else if (state is AuthenticationChallengeReceived) {
_handleAuthentication(context, state); _handleAuthentication(context, state);
} else if (state is RegistrationChallengeReceived) { } else if (state is RegistrationChallengeReceived) {
_handleRegistration(context, state); _handleRegistration(context, state);
} else if (state is AuthAuthenticated) { } else if (state is AuthAuthenticated) {
context.read<SessionBloc>().add(SessionStarted(state.token)); context.read<SessionBloc>().add(SessionStarted(state.token));
context.go('/'); final redirect = GoRouterState.of(
context,
).uri.queryParameters['redirect'];
context.go(redirect ?? '/');
} }
}, },
child: Center( child: Center(
@@ -154,7 +193,7 @@ class _LoginFormState extends State<LoginForm> {
crossAxisAlignment: CrossAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.center,
children: [ children: [
Text( Text(
_isSignup ? 'create account' : 'sign in', _isSignup ? 'Create Account' : 'Sign In',
style: const TextStyle( style: const TextStyle(
fontSize: 24, fontSize: 24,
color: AppTheme.primaryText, color: AppTheme.primaryText,
@@ -168,7 +207,9 @@ class _LoginFormState extends State<LoginForm> {
), ),
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(16),
border: Border.all( border: Border.all(
color: AppTheme.accentColor.withValues(alpha: 0.3), color: _usernameHasError
? Colors.red
: AppTheme.accentColor.withValues(alpha: 0.3),
), ),
), ),
child: TextField( child: TextField(
@@ -176,6 +217,14 @@ class _LoginFormState extends State<LoginForm> {
textInputAction: TextInputAction.next, textInputAction: TextInputAction.next,
keyboardType: TextInputType.text, keyboardType: TextInputType.text,
cursorColor: AppTheme.accentColor, cursorColor: AppTheme.accentColor,
onChanged: (_) {
if (_usernameHasError || _usernameErrorText != null) {
setState(() {
_usernameHasError = false;
_usernameErrorText = null;
});
}
},
decoration: InputDecoration( decoration: InputDecoration(
hintText: 'username', hintText: 'username',
hintStyle: TextStyle(color: AppTheme.secondaryText), hintStyle: TextStyle(color: AppTheme.secondaryText),
@@ -190,6 +239,17 @@ class _LoginFormState extends State<LoginForm> {
style: const TextStyle(color: AppTheme.primaryText), style: const TextStyle(color: AppTheme.primaryText),
), ),
), ),
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), const SizedBox(height: 16),
if (!_isSignup && _usePasskey) if (!_isSignup && _usePasskey)
const SizedBox.shrink() const SizedBox.shrink()
@@ -201,7 +261,9 @@ class _LoginFormState extends State<LoginForm> {
), ),
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(16),
border: Border.all( border: Border.all(
color: AppTheme.accentColor.withValues(alpha: 0.3), color: _passwordHasError
? Colors.red
: AppTheme.accentColor.withValues(alpha: 0.3),
), ),
), ),
child: TextField( child: TextField(
@@ -210,6 +272,15 @@ class _LoginFormState extends State<LoginForm> {
keyboardType: TextInputType.visiblePassword, keyboardType: TextInputType.visiblePassword,
obscureText: true, obscureText: true,
cursorColor: AppTheme.accentColor, cursorColor: AppTheme.accentColor,
onChanged: (_) {
if (_passwordHasError ||
_passwordErrorText != null) {
setState(() {
_passwordHasError = false;
_passwordErrorText = null;
});
}
},
decoration: InputDecoration( decoration: InputDecoration(
hintText: 'password', hintText: 'password',
hintStyle: TextStyle(color: AppTheme.secondaryText), hintStyle: TextStyle(color: AppTheme.secondaryText),
@@ -224,6 +295,17 @@ class _LoginFormState extends State<LoginForm> {
style: const TextStyle(color: AppTheme.primaryText), 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) if (!_isSignup && _usePasskey)
const SizedBox.shrink() const SizedBox.shrink()
else else
@@ -359,7 +441,12 @@ class _LoginFormState extends State<LoginForm> {
children: [ children: [
GestureDetector( GestureDetector(
onTap: () { onTap: () {
setState(() => _usePasskey = !_usePasskey); setState(() {
_usePasskey = !_usePasskey;
// Clear password errors when switching modes
_passwordHasError = false;
_passwordErrorText = null;
});
widget.onPasswordModeChanged?.call( widget.onPasswordModeChanged?.call(
!_usePasskey, !_usePasskey,
); );

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

@@ -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

@@ -1,293 +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;
import 'package:uuid/uuid.dart';
class MockFileRepository implements FileRepository {
final Map<String, List<FileItem>> _orgFiles = {};
final _uuid = const Uuid();
List<FileItem> _getFilesForOrg(String orgId) {
if (!_orgFiles.containsKey(orgId)) {
// Initialize with different files per org
if (orgId == 'org1') {
_orgFiles[orgId] = [
FileItem(
id: _uuid.v4(),
name: 'Personal Documents',
path: '/Personal Documents',
type: FileType.folder,
lastModified: DateTime.now(),
),
FileItem(
id: _uuid.v4(),
name: 'Photos',
path: '/Photos',
type: FileType.folder,
lastModified: DateTime.now(),
),
FileItem(
id: _uuid.v4(),
name: 'resume.pdf',
path: '/resume.pdf',
type: FileType.file,
size: 1024,
lastModified: DateTime.now(),
),
FileItem(
id: _uuid.v4(),
name: 'notes.txt',
path: '/notes.txt',
type: FileType.file,
size: 256,
lastModified: DateTime.now(),
),
];
} else if (orgId == 'org2') {
_orgFiles[orgId] = [
FileItem(
id: _uuid.v4(),
name: 'Company Reports',
path: '/Company Reports',
type: FileType.folder,
lastModified: DateTime.now(),
),
FileItem(
id: _uuid.v4(),
name: 'annual_report.pdf',
path: '/annual_report.pdf',
type: FileType.file,
size: 2048,
lastModified: DateTime.now(),
),
FileItem(
id: _uuid.v4(),
name: 'presentation.pptx',
path: '/presentation.pptx',
type: FileType.file,
size: 4096,
lastModified: DateTime.now(),
),
];
} else if (orgId == 'org3') {
_orgFiles[orgId] = [
FileItem(
id: _uuid.v4(),
name: 'Project Code',
path: '/Project Code',
type: FileType.folder,
lastModified: DateTime.now(),
),
FileItem(
id: _uuid.v4(),
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,
token: 'mock-editor-token',
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(
id: _uuid.v4(),
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(
id: file.id,
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(
id: file.id,
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(
FileItem(
id: _uuid.v4(),
name: file.name,
path: file.path,
type: file.type,
size: file.size,
lastModified: file.lastModified,
),
);
}
@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,
mimeType: isPdf ? 'application/pdf' : 'application/octet-stream',
);
// 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:dio/dio.dart';
import 'package:http_parser/http_parser.dart';
import '../models/api_error.dart'; import '../models/api_error.dart';
import '../blocs/session/session_bloc.dart'; import '../blocs/session/session_bloc.dart';
import '../blocs/session/session_event.dart'; import '../blocs/session/session_event.dart';
@@ -14,7 +15,7 @@ class ApiClient {
baseUrl: baseUrl, baseUrl: baseUrl,
connectTimeout: const Duration(seconds: 10), connectTimeout: const Duration(seconds: 10),
receiveTimeout: const Duration( receiveTimeout: const Duration(
seconds: 60, seconds: 120,
), // Increased for file uploads and org operations ), // Increased for file uploads and org operations
), ),
); );
@@ -47,6 +48,8 @@ class ApiClient {
String get baseUrl => _dio.options.baseUrl; String get baseUrl => _dio.options.baseUrl;
String? get currentToken => _getCurrentToken();
String? _getCurrentToken() { String? _getCurrentToken() {
// Get from SessionBloc state // Get from SessionBloc state
final state = _sessionBloc.state; final state = _sessionBloc.state;
@@ -69,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>( Future<T> post<T>(
String path, { String path, {
dynamic data, dynamic data,
@@ -83,6 +98,70 @@ class ApiClient {
} }
} }
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>( Future<List<T>> getList<T>(
String path, { String path, {
Map<String, dynamic>? queryParameters, Map<String, dynamic>? queryParameters,
@@ -90,7 +169,69 @@ class ApiClient {
}) async { }) async {
try { try {
final response = await _dio.get(path, queryParameters: queryParameters); 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) { } on DioException catch (e) {
throw _handleError(e); throw _handleError(e);
} }

View File

@@ -1,9 +1,11 @@
import 'dart:typed_data';
import '../models/file_item.dart'; import '../models/file_item.dart';
import '../models/viewer_session.dart'; import '../models/viewer_session.dart';
import '../models/editor_session.dart'; import '../models/editor_session.dart';
import '../models/annotation.dart'; import '../models/annotation.dart';
import 'api_client.dart'; import 'api_client.dart';
import 'package:dio/dio.dart'; import 'package:dio/dio.dart';
import 'package:archive/archive.dart';
class FileService { class FileService {
final ApiClient _apiClient; final ApiClient _apiClient;
@@ -114,6 +116,24 @@ class FileService {
return '/orgs/$orgId/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( Future<void> createFolder(
String orgId, String orgId,
String parentPath, String parentPath,
@@ -199,11 +219,14 @@ class FileService {
String orgId, String orgId,
String fileId, String fileId,
) async { ) async {
if (orgId.isEmpty || fileId.isEmpty) { if (fileId.isEmpty) {
throw Exception('OrgId and fileId cannot be empty'); throw Exception('fileId cannot be empty');
} }
final path = orgId.isEmpty
? '/user/files/$fileId/edit'
: '/orgs/$orgId/files/$fileId/edit';
return await _apiClient.get( return await _apiClient.get(
'/orgs/$orgId/files/$fileId/edit', path,
fromJson: (data) => EditorSession.fromJson(data), fromJson: (data) => EditorSession.fromJson(data),
); );
} }
@@ -213,11 +236,14 @@ class FileService {
String fileId, String fileId,
List<Annotation> annotations, List<Annotation> annotations,
) async { ) async {
if (orgId.isEmpty || fileId.isEmpty) { if (fileId.isEmpty) {
throw Exception('OrgId and fileId cannot be empty'); throw Exception('fileId cannot be empty');
} }
final path = orgId.isEmpty
? '/user/files/$fileId/annotations'
: '/orgs/$orgId/files/$fileId/annotations';
await _apiClient.post( await _apiClient.post(
'/orgs/$orgId/files/$fileId/annotations', path,
data: { data: {
'annotations': annotations.map((a) => a.toJson()).toList(), 'annotations': annotations.map((a) => a.toJson()).toList(),
'baseVersionId': '1', // mock 'baseVersionId': '1', // mock
@@ -225,4 +251,149 @@ class FileService {
fromJson: (data) => null, 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,4 +1,6 @@
import '../blocs/organization/organization_state.dart'; import '../blocs/organization/organization_state.dart';
import '../models/organization.dart';
import '../models/user.dart';
import 'api_client.dart'; import 'api_client.dart';
import 'dart:developer' as developer; import 'dart:developer' as developer;
@@ -29,4 +31,113 @@ class OrgApi {
rethrow; 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 { class AppTheme {
static const Color primaryBackground = Colors.black; 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 accentColor = Color.fromARGB(255, 100, 200, 255);
static const Color secondaryText = Colors.white70; static const Color secondaryText = Colors.white70;
static const Color primaryText = Colors.white; static const Color primaryText = Colors.white;
static const Color errorColor = Colors.redAccent;
static const Color glassBackground = Colors.white; static const Color glassBackground = Colors.white;
static const double glassOpacity = 0.1; static const double glassOpacity = 0.1;
static const double glassBlur = 10; static const double glassBlur = 10;

View File

@@ -6,11 +6,15 @@ class ModernGlassButton extends StatefulWidget {
final VoidCallback onPressed; final VoidCallback onPressed;
final Widget child; final Widget child;
final bool isLoading; final bool isLoading;
final EdgeInsets padding;
final bool showShadows;
const ModernGlassButton({ const ModernGlassButton({
required this.onPressed, required this.onPressed,
required this.child, required this.child,
this.isLoading = false, this.isLoading = false,
this.padding = const EdgeInsets.symmetric(horizontal: 24, vertical: 8),
this.showShadows = true,
super.key, super.key,
}); });
@@ -61,34 +65,32 @@ class _ModernGlassButtonState extends State<ModernGlassButton>
child: Stack( child: Stack(
children: [ children: [
// Shadow layer // Shadow layer
Container( if (widget.showShadows)
decoration: BoxDecoration( Container(
borderRadius: BorderRadius.circular(12), decoration: BoxDecoration(
boxShadow: [ borderRadius: BorderRadius.circular(12),
BoxShadow( boxShadow: [
color: AppTheme.accentColor.withValues(alpha: 0.3), BoxShadow(
blurRadius: _isHovered ? 24 : 12, color: AppTheme.accentColor.withValues(alpha: 0.3),
spreadRadius: _isHovered ? 2 : 0, blurRadius: _isHovered ? 24 : 12,
offset: const Offset(0, 8), spreadRadius: _isHovered ? 2 : 0,
), offset: const Offset(0, 8),
BoxShadow( ),
color: AppTheme.accentColor.withValues(alpha: 0.1), BoxShadow(
blurRadius: 20, color: AppTheme.accentColor.withValues(alpha: 0.1),
spreadRadius: 5, blurRadius: 20,
), spreadRadius: 5,
], ),
],
),
), ),
),
// Glass button with gradient // Glass button with gradient
ClipRRect( ClipRRect(
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
child: BackdropFilter( child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 15, sigmaY: 15), filter: ImageFilter.blur(sigmaX: 15, sigmaY: 15),
child: Container( child: Container(
padding: const EdgeInsets.symmetric( padding: widget.padding,
horizontal: 24,
vertical: 8,
),
decoration: BoxDecoration( decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
gradient: LinearGradient( 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 FlutterMacOS
import Foundation import Foundation
import audio_session
import connectivity_plus import connectivity_plus
import desktop_drop import desktop_drop
import device_info_plus import device_info_plus
import file_picker import file_picker
import flutter_secure_storage_darwin import flutter_secure_storage_darwin
import irondash_engine_context import irondash_engine_context
import path_provider_foundation import just_audio
import shared_preferences_foundation import shared_preferences_foundation
import sqflite_darwin import sqflite_darwin
import super_native_extensions import super_native_extensions
import syncfusion_pdfviewer_macos import syncfusion_pdfviewer_macos
import url_launcher_macos import url_launcher_macos
import video_player_avfoundation
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
AudioSessionPlugin.register(with: registry.registrar(forPlugin: "AudioSessionPlugin"))
ConnectivityPlusPlugin.register(with: registry.registrar(forPlugin: "ConnectivityPlusPlugin")) ConnectivityPlusPlugin.register(with: registry.registrar(forPlugin: "ConnectivityPlusPlugin"))
DesktopDropPlugin.register(with: registry.registrar(forPlugin: "DesktopDropPlugin")) DesktopDropPlugin.register(with: registry.registrar(forPlugin: "DesktopDropPlugin"))
DeviceInfoPlusMacosPlugin.register(with: registry.registrar(forPlugin: "DeviceInfoPlusMacosPlugin")) DeviceInfoPlusMacosPlugin.register(with: registry.registrar(forPlugin: "DeviceInfoPlusMacosPlugin"))
FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin")) FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin"))
FlutterSecureStorageDarwinPlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStorageDarwinPlugin")) FlutterSecureStorageDarwinPlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStorageDarwinPlugin"))
IrondashEngineContextPlugin.register(with: registry.registrar(forPlugin: "IrondashEngineContextPlugin")) 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")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin")) SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin"))
SuperNativeExtensionsPlugin.register(with: registry.registrar(forPlugin: "SuperNativeExtensionsPlugin")) SuperNativeExtensionsPlugin.register(with: registry.registrar(forPlugin: "SuperNativeExtensionsPlugin"))
SyncfusionFlutterPdfViewerPlugin.register(with: registry.registrar(forPlugin: "SyncfusionFlutterPdfViewerPlugin")) SyncfusionFlutterPdfViewerPlugin.register(with: registry.registrar(forPlugin: "SyncfusionFlutterPdfViewerPlugin"))
UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin")) UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin"))
FVPVideoPlayerPlugin.register(with: registry.registrar(forPlugin: "FVPVideoPlayerPlugin"))
} }

View File

@@ -1,22 +1,14 @@
# Generated by pub # Generated by pub
# See https://dart.dev/tools/pub/glossary#lockfile # See https://dart.dev/tools/pub/glossary#lockfile
packages: packages:
_fe_analyzer_shared: archive:
dependency: transitive dependency: "direct main"
description: description:
name: _fe_analyzer_shared name: archive
sha256: "0b2f2bd91ba804e53a61d757b986f89f1f9eaed5b11e4b2f5a2468d86d6c9fc7" sha256: "2fde1607386ab523f7a36bb3e7edb43bd58e6edaf2ffb29d8a6d578b297fdbbd"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "67.0.0" version: "4.0.7"
analyzer:
dependency: transitive
description:
name: analyzer
sha256: "37577842a27e4338429a1cbc32679d508836510b056f1eedf0c8d20e39c1383d"
url: "https://pub.dev"
source: hosted
version: "6.4.1"
args: args:
dependency: transitive dependency: transitive
description: description:
@@ -33,94 +25,22 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.13.0" 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: bloc:
dependency: "direct main" dependency: "direct main"
description: description:
name: bloc name: bloc
sha256: a2cebb899f91d36eeeaa55c7b20b5915db5a9df1b8fd4a3c9c825e22e474537d sha256: a48653a82055a900b88cd35f92429f068c5a8057ae9b136d197b3d56c57efb81
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "9.1.0" version: "9.2.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"
cached_network_image: cached_network_image:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -153,22 +73,6 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.4.0" 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: clock:
dependency: transitive dependency: transitive
description: description:
@@ -177,14 +81,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.1.2" version: "1.1.2"
code_builder: code_assets:
dependency: transitive dependency: transitive
description: description:
name: code_builder name: code_assets
sha256: "11654819532ba94c34de52ff5feb52bd81cba1de00ef2ed622fd50295f9d4243" sha256: "83ccdaa064c980b5596c35dd64a8d3ecc68620174ab9b90b6343b753aa721687"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "4.11.0" version: "1.0.0"
collection: collection:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -217,22 +121,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.1.2" version: "3.1.2"
coverage:
dependency: transitive
description:
name: coverage
sha256: "5da775aa218eaf2151c721b16c01c7676fbfdd99cebba2bf64e8b807a28ff94d"
url: "https://pub.dev"
source: hosted
version: "1.15.0"
cross_file: cross_file:
dependency: transitive dependency: transitive
description: description:
name: cross_file name: cross_file
sha256: "701dcfc06da0882883a2657c445103380e53e647060ad8d9dfb710c100996608" sha256: "28bb3ae56f117b5aec029d702a90f57d285cd975c3c5c281eaca38dbc47c5937"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.3.5+1" version: "0.3.5+2"
crypto: crypto:
dependency: transitive dependency: transitive
description: description:
@@ -241,22 +137,22 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.0.7" version: "3.0.7"
dart_style: csslib:
dependency: transitive dependency: transitive
description: description:
name: dart_style name: csslib
sha256: "99e066ce75c89d6b29903d788a7bb9369cf754f7b24bf70bf4b6d6d6b26853b9" sha256: "09bad715f418841f976c77db72d5398dc1253c21fb9c0c7f0b0b985860b2d58e"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.3.6" version: "1.0.2"
dbus: dbus:
dependency: transitive dependency: transitive
description: description:
name: dbus name: dbus
sha256: "79e0c23480ff85dc68de79e2cd6334add97e48f7f4865d17686dd6ea81a47e8c" sha256: d0c98dcd4f5169878b6cf8f6e0a52403a9dff371a3e2f019697accbf6f44a270
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.7.11" version: "0.7.12"
desktop_drop: desktop_drop:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -281,22 +177,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "7.0.3" 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: dio:
dependency: "direct main" dependency: "direct main"
description: description:
name: dio name: dio
sha256: d90ee57923d1828ac14e492ca49440f65477f4bb1263575900be731a3dac66a9 sha256: b9d46faecab38fc8cc286f80bc4d61a3bb5d4ac49e51ed877b4d6706efe57b25
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "5.9.0" version: "5.9.1"
dio_web_adapter: dio_web_adapter:
dependency: transitive dependency: transitive
description: description:
@@ -309,26 +197,18 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
name: equatable name: equatable
sha256: "567c64b3cb4cf82397aac55f4f0cbd3ca20d77c6c03bedbc4ceaddc08904aef7" sha256: "3e0141505477fd8ad55d6eb4e7776d3fe8430be8e497ccb1521370c3f21a3e2b"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.0.7" version: "2.0.8"
fake_async:
dependency: transitive
description:
name: fake_async
sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44"
url: "https://pub.dev"
source: hosted
version: "1.3.3"
ffi: ffi:
dependency: transitive dependency: transitive
description: description:
name: ffi name: ffi
sha256: "289279317b4b16eb2bb7e271abccd4bf84ec9bdcbe999e278a94b804f5630418" sha256: d07d37192dbf97461359c1518788f203b0c9102cfd2c35a716b823741219542c
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.1.4" version: "2.1.5"
file: file:
dependency: transitive dependency: transitive
description: description:
@@ -341,10 +221,10 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
name: file_picker name: file_picker
sha256: "7872545770c277236fd32b022767576c562ba28366204ff1a5628853cf8f2200" sha256: "57d9a1dd5063f85fa3107fb42d1faffda52fdc948cefd5fe5ea85267a5fc7343"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "10.3.7" version: "10.3.10"
fixnum: fixnum:
dependency: transitive dependency: transitive
description: description:
@@ -402,10 +282,10 @@ packages:
dependency: "direct dev" dependency: "direct dev"
description: description:
name: flutter_lints name: flutter_lints
sha256: "3105dc8492f6183fb076ccf1f351ac3d60564bff92e20bfc4af9cc1651f4e7e1" sha256: "5398f14efa795ffb7a33e9b6a08798b26a180edac4ad7db3f231e40f82ce11e1"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "6.0.0" version: "5.0.0"
flutter_plugin_android_lifecycle: flutter_plugin_android_lifecycle:
dependency: transitive dependency: transitive
description: description:
@@ -478,24 +358,11 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.2.3" version: "2.2.3"
flutter_test:
dependency: "direct dev"
description: flutter
source: sdk
version: "0.0.0"
flutter_web_plugins: flutter_web_plugins:
dependency: transitive dependency: "direct main"
description: flutter description: flutter
source: sdk source: sdk
version: "0.0.0" 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: get_it:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -516,18 +383,10 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
name: go_router name: go_router
sha256: eff94d2a6fc79fa8b811dde79c7549808c2346037ee107a1121b4a644c745f2a sha256: "7974313e217a7771557add6ff2238acb63f635317c35fa590d348fb238f00896"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "17.0.1" version: "17.1.0"
graphs:
dependency: transitive
description:
name: graphs
sha256: "741bbf84165310a68ff28fe9e727332eef1407342fca52759cb21ad8177bb8d0"
url: "https://pub.dev"
source: hosted
version: "2.3.2"
hive: hive:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -544,38 +403,46 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.1.0" version: "1.1.0"
hive_generator: hooks:
dependency: "direct dev" dependency: transitive
description: description:
name: hive_generator name: hooks
sha256: "06cb8f58ace74de61f63500564931f9505368f45f98958bd7a6c35ba24159db4" sha256: "7a08a0d684cb3b8fb604b78455d5d352f502b68079f7b80b831c62220ab0a4f6"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.0.1" version: "1.0.1"
http: html:
dependency: transitive dependency: transitive
description:
name: html
sha256: "6d1264f2dffa1b1101c25a91dff0dc2daee4c18e87cd8538729773c073dbf602"
url: "https://pub.dev"
source: hosted
version: "0.15.6"
http:
dependency: "direct main"
description: description:
name: http name: http
sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.6.0" 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: http_parser:
dependency: transitive dependency: "direct main"
description: description:
name: http_parser name: http_parser
sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "4.1.2" 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: infinite_scroll_pagination:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -588,18 +455,10 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
name: injectable name: injectable
sha256: "8fc24421cfeff76d1d38484d8b9617beeb54a58b6edfd002b10cc896b8b8f3fe" sha256: "32b36a9d87f18662bee0b1951b81f47a01f2bf28cd6ea94f60bc5453c7bf598c"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.7.1+2" version: "2.7.1+4"
injectable_generator:
dependency: "direct dev"
description:
name: injectable_generator
sha256: af403d76c7b18b4217335e0075e950cd0579fd7f8d7bd47ee7c85ada31680ba1
url: "https://pub.dev"
source: hosted
version: "2.6.2"
intl: intl:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -608,14 +467,6 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.20.2" 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: irondash_engine_context:
dependency: transitive dependency: transitive
description: description:
@@ -632,62 +483,38 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.7.0" 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 dependency: transitive
description: description:
name: js name: just_audio_platform_interface
sha256: "53385261521cc4a0c4658fd0ad07a7d14591cf8fc33abbceae306ddb974888dc" sha256: "2532c8d6702528824445921c5ff10548b518b13f808c2e34c2fd54793b999a6a"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.7.2" version: "4.6.0"
json_annotation: just_audio_web:
dependency: transitive dependency: "direct main"
description: description:
name: json_annotation name: just_audio_web
sha256: "1ce844379ca14835a50d2f019a3099f419082cfdd231cd86a142af94dd5c6bb1" sha256: "6ba8a2a7e87d57d32f0f7b42856ade3d6a9fbe0f1a11fabae0a4f00bb73f0663"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "4.9.0" version: "0.4.16"
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"
lints: lints:
dependency: transitive dependency: transitive
description: description:
name: lints name: lints
sha256: a5e2b223cb7c9c8efdc663ef484fdd95bb243bff242ef5b13e26883547fce9a0 sha256: c35bb79562d980e9a453fc715854e1ed39e24e7d0297a880ef54e17f9874a9d7
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "6.0.0" version: "5.1.1"
logger: logger:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -704,14 +531,6 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.3.0" 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: material_color_utilities:
dependency: transitive dependency: transitive
description: description:
@@ -736,22 +555,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.0.0" version: "2.0.0"
mockito: native_toolchain_c:
dependency: "direct dev"
description:
name: mockito
sha256: "6841eed20a7befac0ce07df8116c8b8233ed1f4486a7647c7fc5a02ae6163917"
url: "https://pub.dev"
source: hosted
version: "5.4.4"
mocktail:
dependency: transitive dependency: transitive
description: description:
name: mocktail name: native_toolchain_c
sha256: "890df3f9688106f25755f26b1c60589a92b3ab91a22b8b224947ad041bf172d8" sha256: "89e83885ba09da5fdf2cdacc8002a712ca238c28b7f717910b34bcd27b0d03ac"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.0.4" version: "0.17.4"
nested: nested:
dependency: transitive dependency: transitive
description: description:
@@ -768,14 +579,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.5.0" version: "0.5.0"
node_preamble: objective_c:
dependency: transitive dependency: transitive
description: description:
name: node_preamble name: objective_c
sha256: "6e7eac89047ab8a8d26cf16127b5ed26de65209847630400f9aefd7cd5c730db" sha256: "100a1c87616ab6ed41ec263b083c0ef3261ee6cd1dc3b0f35f8ddfa4f996fe52"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.0.2" version: "9.3.0"
octo_image: octo_image:
dependency: transitive dependency: transitive
description: description:
@@ -784,14 +595,6 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.1.0" 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: path:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -828,10 +631,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: path_provider_foundation name: path_provider_foundation
sha256: "6d13aece7b3f5c5a9731eaf553ff9dcbc2eff41087fd2df587fd0fed9a3eb0c4" sha256: "2a376b7d6392d80cd3705782d2caa734ca4727776db0b6ec36ef3f1855197699"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.5.1" version: "2.6.0"
path_provider_linux: path_provider_linux:
dependency: transitive dependency: transitive
description: description:
@@ -888,14 +691,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.1.8" version: "2.1.8"
pool: posix:
dependency: transitive dependency: transitive
description: description:
name: pool name: posix
sha256: "978783255c543aa3586a1b3c21f6e9d720eb315376a915872c61ef8b5c20177d" sha256: "6323a5b0fa688b6a010df4905a56b00181479e6d10534cecfecede2aa55add61"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.5.2" version: "6.0.3"
provider: provider:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -912,22 +715,6 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.2.0" 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: rxdart:
dependency: transitive dependency: transitive
description: description:
@@ -948,10 +735,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: shared_preferences_android name: shared_preferences_android
sha256: "83af5c682796c0f7719c2bbf74792d113e40ae97981b8f266fa84574573556bc" sha256: cbc40be9be1c5af4dab4d6e0de4d5d3729e6f3d65b89d21e1815d57705644a6f
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.4.18" version: "2.4.20"
shared_preferences_foundation: shared_preferences_foundation:
dependency: transitive dependency: transitive
description: description:
@@ -992,38 +779,6 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.4.1" 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: sky_engine:
dependency: transitive dependency: transitive
description: flutter description: flutter
@@ -1037,46 +792,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.2.12" 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: source_span:
dependency: transitive dependency: transitive
description: description:
name: source_span name: source_span
sha256: "254ee5351d6cb365c859e20ee823c3bb479bf4a293c22d17a9f1bf144ce86f7c" sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.10.1" version: "1.10.2"
sqflite: sqflite:
dependency: transitive dependency: transitive
description: description:
@@ -1117,30 +840,6 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.4.0" 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: string_scanner:
dependency: transitive dependency: transitive
description: description:
@@ -1166,7 +865,7 @@ packages:
source: hosted source: hosted
version: "0.9.1" version: "0.9.1"
syncfusion_flutter_core: syncfusion_flutter_core:
dependency: transitive dependency: "direct main"
description: description:
name: syncfusion_flutter_core name: syncfusion_flutter_core
sha256: e1fdfcc3ed7e1f040ba95838780b2eb1857e3e5eccb817fbe94ea2b09c35eac4 sha256: e1fdfcc3ed7e1f040ba95838780b2eb1857e3e5eccb817fbe94ea2b09c35eac4
@@ -1253,38 +952,6 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.2.2" 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: typed_data:
dependency: transitive dependency: transitive
description: description:
@@ -1302,7 +969,7 @@ packages:
source: hosted source: hosted
version: "1.1.0" version: "1.1.0"
url_launcher: url_launcher:
dependency: transitive dependency: "direct main"
description: description:
name: url_launcher name: url_launcher
sha256: f6a7e5c4835bb4e3026a04793a4199ca2d14c739ec378fdfe23fc8075d0439f8 sha256: f6a7e5c4835bb4e3026a04793a4199ca2d14c739ec378fdfe23fc8075d0439f8
@@ -1353,10 +1020,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: url_launcher_web name: url_launcher_web
sha256: "4bd2b7b4dc4d4d0b94e5babfffbca8eac1a126c7f3d6ecbc1a11013faa3abba2" sha256: d0412fcf4c6b31ecfdb7762359b7206ffba3bbffd396c6d9f9c4616ece476c1f
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.4.1" version: "2.4.2"
url_launcher_windows: url_launcher_windows:
dependency: transitive dependency: transitive
description: description:
@@ -1393,10 +1060,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: vector_graphics_compiler name: vector_graphics_compiler
sha256: d354a7ec6931e6047785f4db12a1f61ec3d43b207fc0790f863818543f8ff0dc sha256: "201e876b5d52753626af64b6359cd13ac6011b80728731428fd34bc840f71c9b"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.1.19" version: "1.1.20"
vector_math: vector_math:
dependency: transitive dependency: transitive
description: description:
@@ -1405,54 +1072,54 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.2.0" version: "2.2.0"
vm_service: video_player:
dependency: transitive dependency: "direct main"
description: description:
name: vm_service name: video_player
sha256: "45caa6c5917fa127b5dbcfbd1fa60b14e583afdc08bfc96dda38886ca252eb60" sha256: "096bc28ce10d131be80dfb00c223024eb0fba301315a406728ab43dd99c45bdf"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "15.0.2" version: "2.10.1"
watcher: video_player_android:
dependency: transitive dependency: transitive
description: description:
name: watcher name: video_player_android
sha256: f52385d4f73589977c80797e60fe51014f7f2b957b5e9a62c3f6ada439889249 sha256: ee4fd520b0cafa02e4a867a0f882092e727cdaa1a2d24762171e787f8a502b0a
url: "https://pub.dev" url: "https://pub.dev"
source: hosted 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: web:
dependency: transitive dependency: "direct main"
description: description:
name: web name: web
sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.1.1" 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: win32:
dependency: transitive dependency: transitive
description: description:
@@ -1495,4 +1162,4 @@ packages:
version: "3.1.3" version: "3.1.3"
sdks: sdks:
dart: ">=3.10.4 <4.0.0" dart: ">=3.10.4 <4.0.0"
flutter: ">=3.35.1" flutter: ">=3.38.4"

View File

@@ -1,5 +1,5 @@
name: b0esche_cloud name: b0esche
description: "A new Flutter project." description: "b0esche secure cloud"
publish_to: "none" publish_to: "none"
version: 0.1.0 version: 0.1.0
@@ -16,6 +16,7 @@ dependencies:
# Networking # Networking
dio: ^5.3.2 dio: ^5.3.2
http_parser: ^4.0.2
# Routing # Routing
go_router: ^17.0.1 go_router: ^17.0.1
@@ -48,6 +49,7 @@ dependencies:
path_provider: ^2.1.2 path_provider: ^2.1.2
connectivity_plus: ^7.0.0 connectivity_plus: ^7.0.0
provider: ^6.1.1 provider: ^6.1.1
url_launcher: ^6.2.2
file_picker: ^10.3.7 file_picker: ^10.3.7
flutter_dropzone: ^4.0.0 flutter_dropzone: ^4.0.0
desktop_drop: ^0.7.0 desktop_drop: ^0.7.0
@@ -55,27 +57,28 @@ dependencies:
infinite_scroll_pagination: ^5.1.1 infinite_scroll_pagination: ^5.1.1
collection: ^1.18.0 collection: ^1.18.0
syncfusion_flutter_pdfviewer: ^31.1.21 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: dev_dependencies:
flutter_test: flutter_lints: ^5.0.0
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: flutter:
uses-material-design: true uses-material-design: true
assets: assets:
- assets/fonts/ - assets/fonts/
- assets/icons/.
fonts: fonts:
- family: PixelatedElegance - family: PixelatedElegance

View File

@@ -19,7 +19,7 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta content="IE=Edge" http-equiv="X-UA-Compatible"> <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 --> <!-- iOS meta tags & icons -->
<meta name="mobile-web-app-capable" content="yes"> <meta name="mobile-web-app-capable" content="yes">
@@ -31,8 +31,8 @@
<link rel="icon" type="image/png" href="favicon.png" /> <link rel="icon" type="image/png" href="favicon.png" />
<!-- Preload PixelatedElegance brand font --> <!-- Preload PixelatedElegance brand font -->
<link rel="preload" href="assets/fonts/pixelated-elegance/PixelatedEleganceRegular-ovyAA.ttf" as="font" type="font/ttf" <link rel="preload" href="assets/fonts/pixelated-elegance/PixelatedEleganceRegular-ovyAA.ttf" as="font"
crossorigin> type="font/ttf" crossorigin>
<style> <style>
@font-face { @font-face {
@@ -44,11 +44,27 @@
<title>b0esche_cloud</title> <title>b0esche_cloud</title>
<link rel="manifest" href="manifest.json"> <link rel="manifest" href="manifest.json">
<!-- PDF.js library for SfPdfViewer on web --> <!-- PDF.js library for SfPdfViewer on web - loaded asynchronously to avoid sync XHR warnings -->
<script type="module" async> <script type="module">
import * as pdfjsLib from 'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/4.9.155/pdf.min.mjs'; (async () => {
pdfjsLib.GlobalWorkerOptions.workerSrc = "https://cdnjs.cloudflare.com/ajax/libs/pdf.js/4.9.155/pdf.worker.min.mjs"; const pdfjsLib = await import('https://cdnjs.cloudflare.com/ajax/libs/pdf.js/4.9.155/pdf.min.mjs');
window.pdfjsLib = pdfjsLib; 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> </script>
</head> </head>

View File

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

View File

@@ -1,6 +1,6 @@
# Project-level configuration. # Project-level configuration.
cmake_minimum_required(VERSION 3.14) 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 name of the executable created for the application. Change this to change
# the on-disk name of your application. # 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 |

Binary file not shown.

Binary file not shown.

View File

@@ -4,6 +4,7 @@ import (
"fmt" "fmt"
"net/http" "net/http"
"os" "os"
"path/filepath"
"go.b0esche.cloud/backend/internal/audit" "go.b0esche.cloud/backend/internal/audit"
"go.b0esche.cloud/backend/internal/auth" "go.b0esche.cloud/backend/internal/auth"
@@ -13,9 +14,43 @@ import (
"go.b0esche.cloud/backend/pkg/jwt" "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() { func main() {
cfg := config.Load() cfg := config.Load()
// Ensure avatar cache directory is usable and persistent when possible
ensureAvatarCacheDir(cfg)
dbConn, err := database.Connect(cfg) dbConn, err := database.Connect(cfg)
if err != nil { if err != nil {
fmt.Fprintf(os.Stderr, "Database connection error: %v\n", err) fmt.Fprintf(os.Stderr, "Database connection error: %v\n", err)

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

@@ -1,41 +1,48 @@
package config package config
import ( import (
"fmt" "log"
"os" "os"
"strconv"
) )
type Config struct { type Config struct {
ServerAddr string ServerAddr string
DatabaseURL string DatabaseURL string
OIDCIssuerURL string OIDCIssuerURL string
OIDCRedirectURL string OIDCRedirectURL string
OIDCClientID string OIDCClientID string
OIDCClientSecret string OIDCClientSecret string
JWTSecret string JWTSecret string
NextcloudURL string NextcloudURL string
NextcloudUser string NextcloudUser string
NextcloudPass string NextcloudPass string
NextcloudBase string NextcloudBase string
AllowedOrigins string AllowedOrigins string
AvatarCacheDir string
AvatarDownloadTimeoutSeconds int
AvatarDownloadRetries int
} }
func Load() *Config { func Load() *Config {
cfg := &Config{ cfg := &Config{
ServerAddr: getEnv("SERVER_ADDR", ":8080"), ServerAddr: getEnv("SERVER_ADDR", ":8080"),
DatabaseURL: os.Getenv("DATABASE_URL"), DatabaseURL: os.Getenv("DATABASE_URL"),
OIDCIssuerURL: os.Getenv("OIDC_ISSUER_URL"), OIDCIssuerURL: os.Getenv("OIDC_ISSUER_URL"),
OIDCRedirectURL: os.Getenv("OIDC_REDIRECT_URL"), OIDCRedirectURL: os.Getenv("OIDC_REDIRECT_URL"),
OIDCClientID: os.Getenv("OIDC_CLIENT_ID"), OIDCClientID: os.Getenv("OIDC_CLIENT_ID"),
OIDCClientSecret: os.Getenv("OIDC_CLIENT_SECRET"), OIDCClientSecret: os.Getenv("OIDC_CLIENT_SECRET"),
JWTSecret: os.Getenv("JWT_SECRET"), JWTSecret: os.Getenv("JWT_SECRET"),
NextcloudURL: os.Getenv("NEXTCLOUD_URL"), NextcloudURL: os.Getenv("NEXTCLOUD_URL"),
NextcloudUser: os.Getenv("NEXTCLOUD_USER"), NextcloudUser: os.Getenv("NEXTCLOUD_USER"),
NextcloudPass: os.Getenv("NEXTCLOUD_PASSWORD"), NextcloudPass: os.Getenv("NEXTCLOUD_PASSWORD"),
NextcloudBase: getEnv("NEXTCLOUD_BASEPATH", "/"), NextcloudBase: getEnv("NEXTCLOUD_BASEPATH", "/"),
AllowedOrigins: getEnv("ALLOWED_ORIGINS", "https://b0esche.cloud,https://www.b0esche.cloud,https://*.b0esche.cloud,http://localhost:8080"), 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),
} }
fmt.Printf("[CONFIG] Nextcloud URL: %q, User: %q, BasePath: %q\n", cfg.NextcloudURL, cfg.NextcloudUser, cfg.NextcloudBase) 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 return cfg
} }
@@ -45,3 +52,12 @@ func getEnv(key, defaultVal string) string {
} }
return defaultVal 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

@@ -9,6 +9,7 @@ import (
"time" "time"
"github.com/google/uuid" "github.com/google/uuid"
"go.b0esche.cloud/backend/internal/models"
) )
type DB struct { type DB struct {
@@ -63,13 +64,13 @@ func (sa StringArray) Value() (driver.Value, error) {
} }
type User struct { type User struct {
ID uuid.UUID ID uuid.UUID `json:"id"`
Email string Email string `json:"email"`
Username string Username string `json:"username"`
DisplayName string DisplayName string `json:"displayName"`
PasswordHash *string PasswordHash *string `json:"-"`
CreatedAt time.Time CreatedAt time.Time `json:"createdAt"`
LastLoginAt *time.Time LastLoginAt *time.Time `json:"lastLoginAt"`
} }
type Credential struct { type Credential struct {
@@ -101,11 +102,12 @@ type Session struct {
} }
type Organization struct { type Organization struct {
ID uuid.UUID `json:"id"` ID uuid.UUID `json:"id"`
OwnerID uuid.UUID `json:"ownerId"` OwnerID uuid.UUID `json:"ownerId"`
Name string `json:"name"` Name string `json:"name"`
Slug string `json:"slug"` Slug string `json:"slug"`
CreatedAt time.Time `json:"createdAt"` InviteLinkToken *string `json:"inviteLinkToken,omitempty"`
CreatedAt time.Time `json:"createdAt"`
} }
type Membership struct { type Membership struct {
@@ -115,6 +117,26 @@ type Membership struct {
CreatedAt time.Time 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 { type Activity struct {
ID uuid.UUID ID uuid.UUID
UserID uuid.UUID UserID uuid.UUID
@@ -126,15 +148,17 @@ type Activity struct {
} }
type File struct { type File struct {
ID uuid.UUID ID uuid.UUID
OrgID *uuid.UUID OrgID *uuid.UUID
UserID *uuid.UUID UserID *uuid.UUID
Name string Name string
Path string Path string
Type string Type string
Size int64 Size int64
LastModified time.Time LastModified time.Time
CreatedAt time.Time CreatedAt time.Time
ModifiedBy *uuid.UUID
ModifiedByName string
} }
func (db *DB) GetOrCreateUser(ctx context.Context, sub, email, name string) (*User, error) { func (db *DB) GetOrCreateUser(ctx context.Context, sub, email, name string) (*User, error) {
@@ -230,12 +254,14 @@ func (db *DB) GetOrgMember(ctx context.Context, orgID, userID uuid.UUID) (*Membe
} }
func (db *DB) CreateOrg(ctx context.Context, ownerID uuid.UUID, name, slug string) (*Organization, error) { 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 var org Organization
err := db.QueryRowContext(ctx, ` err := db.QueryRowContext(ctx, `
INSERT INTO organizations (owner_id, name, slug) INSERT INTO organizations (owner_id, name, slug, invite_link_token)
VALUES ($1, $2, $3) VALUES ($1, $2, $3, $4)
RETURNING id, owner_id, name, slug, created_at RETURNING id, owner_id, name, slug, invite_link_token, created_at
`, ownerID, name, slug).Scan(&org.ID, &org.OwnerID, &org.Name, &org.Slug, &org.CreatedAt) `, ownerID, name, slug, inviteToken).Scan(&org.ID, &org.OwnerID, &org.Name, &org.Slug, &org.InviteLinkToken, &org.CreatedAt)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -306,6 +332,272 @@ func (db *DB) GetOrgMembers(ctx context.Context, orgID uuid.UUID) ([]Membership,
return memberships, rows.Err() return memberships, rows.Err()
} }
// 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
`, 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) // 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) { 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 { if page <= 0 {
@@ -371,6 +663,55 @@ func (db *DB) GetOrgFiles(ctx context.Context, orgID uuid.UUID, userID uuid.UUID
return files, err 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 // 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) { func (db *DB) GetUserFiles(ctx context.Context, userID uuid.UUID, path string, q string, page, pageSize int) ([]File, error) {
if page <= 0 { if page <= 0 {
@@ -427,6 +768,49 @@ func (db *DB) GetUserFiles(ctx context.Context, userID uuid.UUID, path string, q
return files, err 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. // 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) { 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 f File
@@ -465,12 +849,17 @@ func (db *DB) GetFileByID(ctx context.Context, fileID uuid.UUID) (*File, error)
var f File var f File
var orgNull sql.NullString var orgNull sql.NullString
var userNull sql.NullString var userNull sql.NullString
var modifiedByNull sql.NullString
var modifiedByNameNull sql.NullString
err := db.QueryRowContext(ctx, ` err := db.QueryRowContext(ctx, `
SELECT id, org_id::text, user_id::text, name, path, type, size, last_modified, created_at 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.modified_by::text, u.display_name
WHERE id = $1 FROM files f
`, fileID).Scan(&f.ID, &orgNull, &userNull, &f.Name, &f.Path, &f.Type, &f.Size, &f.LastModified, &f.CreatedAt) 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 { if err != nil {
return nil, err return nil, err
@@ -484,17 +873,123 @@ func (db *DB) GetFileByID(ctx context.Context, fileID uuid.UUID) (*File, error)
uid, _ := uuid.Parse(userNull.String) uid, _ := uuid.Parse(userNull.String)
f.UserID = &uid f.UserID = &uid
} }
if modifiedByNull.Valid {
mid, _ := uuid.Parse(modifiedByNull.String)
f.ModifiedBy = &mid
}
if modifiedByNameNull.Valid {
f.ModifiedByName = modifiedByNameNull.String
}
return &f, nil return &f, nil
} }
// UpdateFileSize updates the size and modification time of a file // GetOrgFileByPath returns a file by path for an org
func (db *DB) UpdateFileSize(ctx context.Context, fileID uuid.UUID, size int64) error { 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, ` _, err := db.ExecContext(ctx, `
UPDATE files UPDATE files
SET size = $1, last_modified = NOW() SET size = $1, last_modified = NOW(), modified_by = $3
WHERE id = $2 WHERE id = $2
`, size, fileID) `, 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 return err
} }
@@ -516,15 +1011,6 @@ func (db *DB) DeleteFileByPath(ctx context.Context, orgID *uuid.UUID, userID *uu
return nil return nil
} }
func (db *DB) UpdateMemberRole(ctx context.Context, orgID, userID uuid.UUID, role string) error {
_, err := db.ExecContext(ctx, `
UPDATE memberships
SET role = $1
WHERE org_id = $2 AND user_id = $3
`, role, orgID, userID)
return err
}
// Passkey-related methods // Passkey-related methods
func (db *DB) CreateUser(ctx context.Context, username, email, displayName string, passwordHash *string) (*User, error) { func (db *DB) CreateUser(ctx context.Context, username, email, displayName string, passwordHash *string) (*User, error) {
@@ -675,4 +1161,112 @@ func (db *DB) MarkChallengeUsed(ctx context.Context, challenge []byte) error {
return err return err
} }
// UpdateFileSize updates the size and last_modified timestamp of a file // 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

@@ -14,10 +14,15 @@ import (
type ErrorCode string type ErrorCode string
const ( const (
CodeUnauthenticated ErrorCode = "UNAUTHENTICATED" CodeUnauthenticated ErrorCode = "UNAUTHENTICATED"
// More specific authentication error codes
CodeInvalidCredentials ErrorCode = "INVALID_CREDENTIALS"
CodeInvalidPassword ErrorCode = "INVALID_PASSWORD"
CodePermissionDenied ErrorCode = "PERMISSION_DENIED" CodePermissionDenied ErrorCode = "PERMISSION_DENIED"
CodeNotFound ErrorCode = "NOT_FOUND" CodeNotFound ErrorCode = "NOT_FOUND"
CodeConflict ErrorCode = "CONFLICT" CodeConflict ErrorCode = "CONFLICT"
CodeAlreadyExists ErrorCode = "ALREADY_EXISTS"
CodeInvalidArgument ErrorCode = "INVALID_ARGUMENT" CodeInvalidArgument ErrorCode = "INVALID_ARGUMENT"
CodeInternal ErrorCode = "INTERNAL" CodeInternal ErrorCode = "INTERNAL"
) )

File diff suppressed because it is too large Load Diff

View File

@@ -2,9 +2,12 @@ package http
import ( import (
"encoding/json" "encoding/json"
"encoding/xml"
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
"net/url"
"path"
"strings" "strings"
"sync" "sync"
"time" "time"
@@ -19,6 +22,89 @@ import (
"go.b0esche.cloud/backend/pkg/jwt" "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 // WOPILockManager manages file locks to prevent concurrent editing conflicts
type WOPILockManager struct { type WOPILockManager struct {
locks map[string]*models.WOPILockInfo locks map[string]*models.WOPILockInfo
@@ -159,15 +245,22 @@ func wopiCheckFileInfoHandler(w http.ResponseWriter, r *http.Request, db *databa
return 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 // Verify user has access to this file
canAccess := false canAccess := false
var ownerID string var ownerID string
if file.UserID != nil && *file.UserID == userID { // Prefer org ownership when file belongs to an org and the user is a member
canAccess = true if file.OrgID != nil {
ownerID = userID.String()
} else if file.OrgID != nil {
// Check if user is member of the org
member, err := db.GetOrgMember(r.Context(), *file.OrgID, userID) member, err := db.GetOrgMember(r.Context(), *file.OrgID, userID)
if err == nil && member != nil { if err == nil && member != nil {
canAccess = true canAccess = true
@@ -175,12 +268,27 @@ func wopiCheckFileInfoHandler(w http.ResponseWriter, r *http.Request, db *databa
} }
} }
// Fallback to per-user file ownership
if !canAccess && file.UserID != nil && *file.UserID == userID {
canAccess = true
ownerID = userID.String()
}
if !canAccess { if !canAccess {
fmt.Printf("[WOPI-REQUEST] Access denied: file=%s user=%s\n", fileID, userID.String()) fmt.Printf("[WOPI-REQUEST] Access denied: file=%s user=%s\n", fileID, userID.String())
errors.WriteError(w, errors.CodePermissionDenied, "Access denied", http.StatusForbidden) errors.WriteError(w, errors.CodePermissionDenied, "Access denied", http.StatusForbidden)
return return
} }
// Ensure LastModifiedTime is not zero
lastModifiedTime := file.LastModified
if lastModifiedTime.IsZero() {
lastModifiedTime = file.CreatedAt
}
if lastModifiedTime.IsZero() {
lastModifiedTime = time.Now()
}
// Build response // Build response
response := models.WOPICheckFileInfoResponse{ response := models.WOPICheckFileInfoResponse{
BaseFileName: file.Name, BaseFileName: file.Name,
@@ -188,7 +296,7 @@ func wopiCheckFileInfoHandler(w http.ResponseWriter, r *http.Request, db *databa
Version: file.ID.String(), Version: file.ID.String(),
OwnerId: ownerID, OwnerId: ownerID,
UserId: userID.String(), UserId: userID.String(),
UserFriendlyName: "", // Could be populated from user info UserFriendlyName: user.DisplayName,
UserCanWrite: true, UserCanWrite: true,
UserCanRename: false, UserCanRename: false,
UserCanNotWriteRelative: false, UserCanNotWriteRelative: false,
@@ -206,7 +314,7 @@ func wopiCheckFileInfoHandler(w http.ResponseWriter, r *http.Request, db *databa
SupportsRenameRelativeToFolder: false, SupportsRenameRelativeToFolder: false,
SupportsFolders: false, SupportsFolders: false,
SupportsScenarios: []string{"default"}, SupportsScenarios: []string{"default"},
LastModifiedTime: file.LastModified.UTC().Format(time.RFC3339), LastModifiedTime: lastModifiedTime.UTC().Format(time.RFC3339),
IsAnonymousUser: false, IsAnonymousUser: false,
TimeZone: "UTC", TimeZone: "UTC",
} }
@@ -227,6 +335,8 @@ func wopiGetFileHandler(w http.ResponseWriter, r *http.Request, db *database.DB,
return return
} }
fmt.Printf("[WOPI-GetFile] START: file=%s\n", fileID)
// Get access token from query parameter // Get access token from query parameter
accessToken := r.URL.Query().Get("access_token") accessToken := r.URL.Query().Get("access_token")
if accessToken == "" { if accessToken == "" {
@@ -260,31 +370,37 @@ func wopiGetFileHandler(w http.ResponseWriter, r *http.Request, db *database.DB,
// Verify user has access to this file // Verify user has access to this file
canAccess := false canAccess := false
var webDAVClient *storage.WebDAVClient var webDAVClient *storage.WebDAVClient
var remotePath string
if file.UserID != nil && *file.UserID == userID { // 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 canAccess = true
// Get user's WebDAV client - need to pass config webDAVClient, err = getUserWebDAVClient(r.Context(), db, userID, cfg.NextcloudURL, cfg.NextcloudUser, cfg.NextcloudPass)
// For now, create a new WebDAV client without full config
webDAVClient, err = getUserWebDAVClient(r.Context(), db, userID, "http://nc.b0esche.cloud", "admin", "")
if err != nil { if err != nil {
fmt.Printf("[WOPI-STORAGE] Failed to get user WebDAV client: %v\n", err) fmt.Printf("[WOPI-STORAGE] Failed to get user WebDAV client: %v\n", err)
errors.WriteError(w, errors.CodeInternal, "Storage error", http.StatusInternalServerError) errors.WriteError(w, errors.CodeInternal, "Storage error", http.StatusInternalServerError)
return return
} }
} else if file.OrgID != nil { // User files: path is relative to user's WebDAV root
// Check if user is member of the org remotePath = file.Path
member, err := db.GetOrgMember(r.Context(), *file.OrgID, userID)
if err == nil && member != nil {
canAccess = true
// Create admin WebDAV client for org files
cfg := &config.Config{
NextcloudURL: "http://nc.b0esche.cloud",
NextcloudUser: "admin",
NextcloudPass: "",
NextcloudBase: "/",
}
webDAVClient = storage.NewWebDAVClient(cfg)
}
} }
if !canAccess { if !canAccess {
@@ -294,7 +410,8 @@ func wopiGetFileHandler(w http.ResponseWriter, r *http.Request, db *database.DB,
} }
// Download file from storage // Download file from storage
resp, err := webDAVClient.Download(r.Context(), file.Path, "") fmt.Printf("[WOPI-STORAGE] GetFile downloading: file=%s remotePath=%s\n", fileID, remotePath)
resp, err := webDAVClient.Download(r.Context(), remotePath, "")
if err != nil { if err != nil {
fmt.Printf("[WOPI-STORAGE] Failed to download file: file=%s path=%s error=%v\n", fileID, file.Path, err) 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) errors.WriteError(w, errors.CodeNotFound, "File not found in storage", http.StatusNotFound)
@@ -302,6 +419,8 @@ func wopiGetFileHandler(w http.ResponseWriter, r *http.Request, db *database.DB,
} }
defer resp.Body.Close() defer resp.Body.Close()
fmt.Printf("[WOPI-STORAGE] Download response status: %d\n", resp.StatusCode)
// Set response headers // Set response headers
contentType := getMimeType(file.Name) contentType := getMimeType(file.Name)
w.Header().Set("Content-Type", contentType) w.Header().Set("Content-Type", contentType)
@@ -317,7 +436,7 @@ func wopiGetFileHandler(w http.ResponseWriter, r *http.Request, db *database.DB,
// WOPIPutFileHandler handles POST /wopi/files/{fileId}/contents // WOPIPutFileHandler handles POST /wopi/files/{fileId}/contents
// Uploads edited document back to storage // Uploads edited document back to storage
func wopiPutFileHandler(w http.ResponseWriter, r *http.Request, db *database.DB, jwtManager *jwt.Manager) { func wopiPutFileHandler(w http.ResponseWriter, r *http.Request, db *database.DB, jwtManager *jwt.Manager, cfg *config.Config) {
fileID := r.PathValue("fileId") fileID := r.PathValue("fileId")
if fileID == "" { if fileID == "" {
errors.WriteError(w, errors.CodeInvalidArgument, "Missing fileId", http.StatusBadRequest) errors.WriteError(w, errors.CodeInvalidArgument, "Missing fileId", http.StatusBadRequest)
@@ -358,28 +477,37 @@ func wopiPutFileHandler(w http.ResponseWriter, r *http.Request, db *database.DB,
// Verify user has access to this file // Verify user has access to this file
canAccess := false canAccess := false
var webDAVClient *storage.WebDAVClient var webDAVClient *storage.WebDAVClient
var remotePath string
if file.UserID != nil && *file.UserID == userID { // 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 canAccess = true
webDAVClient, err = getUserWebDAVClient(r.Context(), db, userID, "http://nc.b0esche.cloud", "admin", "") webDAVClient, err = getUserWebDAVClient(r.Context(), db, userID, cfg.NextcloudURL, cfg.NextcloudUser, cfg.NextcloudPass)
if err != nil { if err != nil {
fmt.Printf("[WOPI-STORAGE] Failed to get user WebDAV client: %v\n", err) fmt.Printf("[WOPI-STORAGE] Failed to get user WebDAV client: %v\n", err)
errors.WriteError(w, errors.CodeInternal, "Storage error", http.StatusInternalServerError) errors.WriteError(w, errors.CodeInternal, "Storage error", http.StatusInternalServerError)
return return
} }
} else if file.OrgID != nil { // User files: path is relative to user's WebDAV root
member, err := db.GetOrgMember(r.Context(), *file.OrgID, userID) remotePath = file.Path
if err == nil && member != nil {
canAccess = true
// Create admin WebDAV client for org files
cfg := &config.Config{
NextcloudURL: "http://nc.b0esche.cloud",
NextcloudUser: "admin",
NextcloudPass: "",
NextcloudBase: "/",
}
webDAVClient = storage.NewWebDAVClient(cfg)
}
} }
if !canAccess { if !canAccess {
@@ -406,7 +534,8 @@ func wopiPutFileHandler(w http.ResponseWriter, r *http.Request, db *database.DB,
defer r.Body.Close() defer r.Body.Close()
// Upload to storage // Upload to storage
err = webDAVClient.Upload(r.Context(), file.Path, strings.NewReader(string(content)), int64(len(content))) 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 { if err != nil {
fmt.Printf("[WOPI-STORAGE] Failed to upload file: file=%s path=%s error=%v\n", fileID, file.Path, err) 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) errors.WriteError(w, errors.CodeInternal, "Failed to save file", http.StatusInternalServerError)
@@ -415,7 +544,7 @@ func wopiPutFileHandler(w http.ResponseWriter, r *http.Request, db *database.DB,
// Update file size and modification time in database // Update file size and modification time in database
newSize := int64(len(content)) newSize := int64(len(content))
err = db.UpdateFileSize(r.Context(), fileUUID, newSize) err = db.UpdateFileSize(r.Context(), fileUUID, newSize, &userID)
if err != nil { if err != nil {
fmt.Printf("[WOPI-STORAGE] Failed to update file size: file=%s error=%v\n", fileID, err) 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 // Don't fail the upload, just log the warning
@@ -603,3 +732,115 @@ func wopiSessionHandler(w http.ResponseWriter, r *http.Request, db *database.DB,
fmt.Printf("[WOPI-REQUEST] Session created: file=%s user=%s\n", fileID, userID.String()) 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

@@ -6,6 +6,8 @@ import (
"net/http" "net/http"
"regexp" "regexp"
"strings" "strings"
"sync"
"time"
"go.b0esche.cloud/backend/internal/audit" "go.b0esche.cloud/backend/internal/audit"
"go.b0esche.cloud/backend/internal/database" "go.b0esche.cloud/backend/internal/database"
@@ -23,6 +25,27 @@ var RequestID = middleware.RequestID
var Logger = middleware.Logger var Logger = middleware.Logger
var Recoverer = middleware.Recoverer var Recoverer = middleware.Recoverer
// 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 // CORS middleware - accepts allowedOrigins comma-separated string
func CORS(allowedOrigins string) func(http.Handler) http.Handler { func CORS(allowedOrigins string) func(http.Handler) http.Handler {
allowedList, allowAll := compileAllowedOrigins(allowedOrigins) allowedList, allowAll := compileAllowedOrigins(allowedOrigins)
@@ -42,7 +65,7 @@ func CORS(allowedOrigins string) func(http.Handler) http.Handler {
allowHeaders = append(allowHeaders, reqHeaders) allowHeaders = append(allowHeaders, reqHeaders)
} }
w.Header().Set("Access-Control-Allow-Headers", strings.Join(uniqueStrings(allowHeaders), ", ")) w.Header().Set("Access-Control-Allow-Headers", strings.Join(uniqueStrings(allowHeaders), ", "))
w.Header().Set("Access-Control-Expose-Headers", "Content-Length, Content-Type, Content-Disposition") 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") w.Header().Set("Access-Control-Max-Age", "3600")
if r.Method == http.MethodOptions { if r.Method == http.MethodOptions {
@@ -120,10 +143,62 @@ func originMatches(origin, pattern string) bool {
return err == nil && matched return err == nil && matched
} }
// TODO: Implement rate limiter // 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 { var RateLimit = func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 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) next.ServeHTTP(w, r)
}) })
} }
@@ -226,19 +301,6 @@ func Org(db *database.DB, auditLogger *audit.Logger) func(http.Handler) http.Han
return 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)) next.ServeHTTP(w, r.WithContext(ctx))
}) })

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

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

View File

@@ -6,9 +6,11 @@ import (
"encoding/base64" "encoding/base64"
"fmt" "fmt"
"io" "io"
"log"
"net/http" "net/http"
"net/url" "net/url"
"strings" "strings"
"time"
) )
// CreateNextcloudUser creates a new Nextcloud user account via OCS API // CreateNextcloudUser creates a new Nextcloud user account via OCS API
@@ -17,16 +19,12 @@ func CreateNextcloudUser(nextcloudBaseURL, adminUser, adminPass, username, passw
baseURL := strings.Split(nextcloudBaseURL, "/remote.php")[0] baseURL := strings.Split(nextcloudBaseURL, "/remote.php")[0]
urlStr := fmt.Sprintf("%s/ocs/v1.php/cloud/users", baseURL) urlStr := fmt.Sprintf("%s/ocs/v1.php/cloud/users", baseURL)
fmt.Printf("[DEBUG-PASSWORD-FLOW] CreateNextcloudUser called with password: %s\n", password)
// OCS API expects form-encoded data with proper URL encoding // OCS API expects form-encoded data with proper URL encoding
formData := url.Values{ formData := url.Values{
"userid": {username}, "userid": {username},
"password": {password}, "password": {password},
}.Encode() }.Encode()
fmt.Printf("[DEBUG-PASSWORD-FLOW] Form data being sent to OCS API: %s\n", formData)
req, err := http.NewRequest("POST", urlStr, bytes.NewBufferString(formData)) req, err := http.NewRequest("POST", urlStr, bytes.NewBufferString(formData))
if err != nil { if err != nil {
return fmt.Errorf("failed to create request: %w", err) return fmt.Errorf("failed to create request: %w", err)
@@ -50,7 +48,7 @@ func CreateNextcloudUser(nextcloudBaseURL, adminUser, adminPass, username, passw
return fmt.Errorf("failed to create Nextcloud user (status %d): %s", resp.StatusCode, string(body)) return fmt.Errorf("failed to create Nextcloud user (status %d): %s", resp.StatusCode, string(body))
} }
fmt.Printf("[NEXTCLOUD] Created user account: %s with generated password\n", username) log.Printf("[NEXTCLOUD] Created user account: %s with generated password\n", username)
return nil return nil
} }
@@ -65,19 +63,16 @@ func GenerateSecurePassword(length int) (string, error) {
// NewUserWebDAVClient creates a WebDAV client for a specific user // NewUserWebDAVClient creates a WebDAV client for a specific user
func NewUserWebDAVClient(nextcloudBaseURL, username, password string) *WebDAVClient { func NewUserWebDAVClient(nextcloudBaseURL, username, password string) *WebDAVClient {
// Remove any path from base URL, we need just the scheme://host:port // Use internal Nextcloud URL to bypass Traefik timeouts
baseURL := strings.Split(nextcloudBaseURL, "/remote.php")[0] baseURL := "http://nextcloud"
// Build the full WebDAV URL for this user // Build the full WebDAV URL for this user
fullURL := fmt.Sprintf("%s/remote.php/dav/files/%s", baseURL, username) fullURL := fmt.Sprintf("%s/remote.php/dav/files/%s", baseURL, username)
fmt.Printf("[WEBDAV-USER] Input URL: %s, Base: %s, Full: %s, User: %s\n", nextcloudBaseURL, baseURL, fullURL, username)
fmt.Printf("[DEBUG-PASSWORD-FLOW] NewUserWebDAVClient called with password: %s\n", password)
return &WebDAVClient{ return &WebDAVClient{
baseURL: fullURL, BaseURL: fullURL,
user: username, user: username,
pass: password, pass: password,
basePrefix: "/", basePrefix: "/",
httpClient: &http.Client{}, httpClient: &http.Client{Timeout: 10 * time.Minute},
} }
} }

View File

@@ -4,16 +4,18 @@ import (
"context" "context"
"fmt" "fmt"
"io" "io"
"log"
"net/http" "net/http"
"net/url" "net/url"
"path" "path"
"strings" "strings"
"time"
"go.b0esche.cloud/backend/internal/config" "go.b0esche.cloud/backend/internal/config"
) )
type WebDAVClient struct { type WebDAVClient struct {
baseURL string BaseURL string
user string user string
pass string pass string
basePrefix string basePrefix string
@@ -23,21 +25,24 @@ type WebDAVClient struct {
// NewWebDAVClient returns nil if no Nextcloud URL configured // NewWebDAVClient returns nil if no Nextcloud URL configured
func NewWebDAVClient(cfg *config.Config) *WebDAVClient { func NewWebDAVClient(cfg *config.Config) *WebDAVClient {
if cfg == nil || strings.TrimSpace(cfg.NextcloudURL) == "" { if cfg == nil || strings.TrimSpace(cfg.NextcloudURL) == "" {
fmt.Printf("[WEBDAV] No Nextcloud URL configured, WebDAV client is nil\n") log.Printf("[WEBDAV] No Nextcloud URL configured, WebDAV client is nil\n")
return nil return nil
} }
u := strings.TrimRight(cfg.NextcloudURL, "/") u := strings.TrimRight(cfg.NextcloudURL, "/")
if !strings.Contains(u, "/remote.php") {
u += "/remote.php/dav/files/" + cfg.NextcloudUser
}
base := cfg.NextcloudBase base := cfg.NextcloudBase
if base == "" { if base == "" {
base = "/" base = "/"
} }
fmt.Printf("[WEBDAV] Initializing WebDAV client - URL: %s, User: %s, BasePath: %s\n", u, cfg.NextcloudUser, base) log.Printf("[WEBDAV] Initializing WebDAV client - URL: %s, User: %s, BasePath: %s\n", u, cfg.NextcloudUser, base)
return &WebDAVClient{ return &WebDAVClient{
baseURL: u, BaseURL: u,
user: cfg.NextcloudUser, user: cfg.NextcloudUser,
pass: cfg.NextcloudPass, pass: cfg.NextcloudPass,
basePrefix: strings.TrimRight(base, "/"), basePrefix: strings.TrimRight(base, "/"),
httpClient: &http.Client{}, httpClient: &http.Client{Timeout: 60 * time.Second},
} }
} }
@@ -53,7 +58,10 @@ func (c *WebDAVClient) ensureParent(ctx context.Context, remotePath string) erro
cur := c.basePrefix cur := c.basePrefix
for _, p := range parts { for _, p := range parts {
cur = path.Join(cur, p) cur = path.Join(cur, p)
mkurl := fmt.Sprintf("%s%s", c.baseURL, cur) 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) req, _ := http.NewRequestWithContext(ctx, "MKCOL", mkurl, nil)
if c.user != "" { if c.user != "" {
req.SetBasicAuth(c.user, c.pass) req.SetBasicAuth(c.user, c.pass)
@@ -62,11 +70,15 @@ func (c *WebDAVClient) ensureParent(ctx context.Context, remotePath string) erro
if err != nil { if err != nil {
return err return err
} }
// Read body for diagnostics
b, _ := io.ReadAll(resp.Body)
resp.Body.Close() resp.Body.Close()
// 201 created, 405 exists — ignore // 201 created, 405 exists — ignore
if resp.StatusCode == 201 || resp.StatusCode == 405 { if resp.StatusCode == 201 || resp.StatusCode == 405 {
continue 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 return nil
} }
@@ -76,9 +88,11 @@ func (c *WebDAVClient) Upload(ctx context.Context, remotePath string, r io.Reade
if c == nil { if c == nil {
return fmt.Errorf("no webdav client configured") return fmt.Errorf("no webdav client configured")
} }
// Ensure parent collections // Ensure parent collections, skip for .avatars as it should exist
if err := c.ensureParent(ctx, remotePath); err != nil { if !strings.HasPrefix(remotePath, ".avatars/") {
return err if err := c.ensureParent(ctx, remotePath); err != nil {
return err
}
} }
// Construct URL // Construct URL
// remotePath might be like /orgs/<id>/file.txt; ensure it joins to basePrefix // remotePath might be like /orgs/<id>/file.txt; ensure it joins to basePrefix
@@ -91,13 +105,13 @@ func (c *WebDAVClient) Upload(ctx context.Context, remotePath string, r io.Reade
var full string var full string
if u == "" { if u == "" {
full = fmt.Sprintf("%s/%s", c.baseURL, url.PathEscape(rel)) full = fmt.Sprintf("%s/%s", c.BaseURL, url.PathEscape(rel))
} else { } else {
full = fmt.Sprintf("%s%s/%s", c.baseURL, u, url.PathEscape(rel)) full = fmt.Sprintf("%s%s/%s", c.BaseURL, u, url.PathEscape(rel))
} }
full = strings.ReplaceAll(full, "%2F", "/") 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) 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) req, err := http.NewRequestWithContext(ctx, "PUT", full, r)
if err != nil { if err != nil {
@@ -118,6 +132,9 @@ func (c *WebDAVClient) Upload(ctx context.Context, remotePath string, r io.Reade
defer resp.Body.Close() defer resp.Body.Close()
if resp.StatusCode >= 200 && resp.StatusCode < 300 { if resp.StatusCode >= 200 && resp.StatusCode < 300 {
return nil 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) body, _ := io.ReadAll(resp.Body)
return fmt.Errorf("webdav upload failed: %d %s", resp.StatusCode, string(body)) return fmt.Errorf("webdav upload failed: %d %s", resp.StatusCode, string(body))
@@ -138,9 +155,9 @@ func (c *WebDAVClient) Download(ctx context.Context, remotePath string, rangeHea
var full string var full string
if u == "" { if u == "" {
full = fmt.Sprintf("%s/%s", c.baseURL, url.PathEscape(rel)) full = fmt.Sprintf("%s/%s", c.BaseURL, url.PathEscape(rel))
} else { } else {
full = fmt.Sprintf("%s%s/%s", c.baseURL, u, url.PathEscape(rel)) full = fmt.Sprintf("%s%s/%s", c.BaseURL, u, url.PathEscape(rel))
} }
full = strings.ReplaceAll(full, "%2F", "/") full = strings.ReplaceAll(full, "%2F", "/")
@@ -184,9 +201,9 @@ func (c *WebDAVClient) Delete(ctx context.Context, remotePath string) error {
var full string var full string
if u == "" { if u == "" {
full = fmt.Sprintf("%s/%s", c.baseURL, url.PathEscape(rel)) full = fmt.Sprintf("%s/%s", c.BaseURL, url.PathEscape(rel))
} else { } else {
full = fmt.Sprintf("%s%s/%s", c.baseURL, u, url.PathEscape(rel)) full = fmt.Sprintf("%s%s/%s", c.BaseURL, u, url.PathEscape(rel))
} }
full = strings.ReplaceAll(full, "%2F", "/") full = strings.ReplaceAll(full, "%2F", "/")
@@ -223,6 +240,11 @@ func (c *WebDAVClient) Move(ctx context.Context, sourcePath, targetPath string)
return fmt.Errorf("no webdav client configured") 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, "/") sourceRel := strings.TrimLeft(sourcePath, "/")
targetRel := strings.TrimLeft(targetPath, "/") targetRel := strings.TrimLeft(targetPath, "/")
@@ -235,18 +257,18 @@ func (c *WebDAVClient) Move(ctx context.Context, sourcePath, targetPath string)
// Build source URL // Build source URL
var sourceURL string var sourceURL string
if u == "" { if u == "" {
sourceURL = fmt.Sprintf("%s/%s", c.baseURL, url.PathEscape(sourceRel)) sourceURL = fmt.Sprintf("%s/%s", c.BaseURL, url.PathEscape(sourceRel))
} else { } else {
sourceURL = fmt.Sprintf("%s%s/%s", c.baseURL, u, url.PathEscape(sourceRel)) sourceURL = fmt.Sprintf("%s%s/%s", c.BaseURL, u, url.PathEscape(sourceRel))
} }
sourceURL = strings.ReplaceAll(sourceURL, "%2F", "/") sourceURL = strings.ReplaceAll(sourceURL, "%2F", "/")
// Build target URL // Build target URL
var targetURL string var targetURL string
if u == "" { if u == "" {
targetURL = fmt.Sprintf("%s/%s", c.baseURL, url.PathEscape(targetRel)) targetURL = fmt.Sprintf("%s/%s", c.BaseURL, url.PathEscape(targetRel))
} else { } else {
targetURL = fmt.Sprintf("%s%s/%s", c.baseURL, u, url.PathEscape(targetRel)) targetURL = fmt.Sprintf("%s%s/%s", c.BaseURL, u, url.PathEscape(targetRel))
} }
targetURL = strings.ReplaceAll(targetURL, "%2F", "/") targetURL = strings.ReplaceAll(targetURL, "%2F", "/")

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;

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;

View File

@@ -0,0 +1 @@
ALTER TABLE users ADD COLUMN avatar_url TEXT;

View File

@@ -0,0 +1 @@
ALTER TABLE users DROP COLUMN avatar_url;

View File

@@ -0,0 +1 @@
ALTER TABLE users ADD COLUMN updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW();

View File

@@ -31,20 +31,48 @@ run_migration() {
} }
# Run migrations in order # Run migrations in order
echo "Step 1/4: Initial schema..." echo "Step 1/11: Initial schema..."
run_migration "$SCRIPT_DIR/0001_initial.sql" run_migration "$SCRIPT_DIR/0001_initial.sql"
echo echo
echo "Step 2/4: Passkeys and authentication..." echo "Step 2/11: Passkeys and authentication..."
run_migration "$SCRIPT_DIR/0002_passkeys.sql" run_migration "$SCRIPT_DIR/0002_passkeys.sql"
echo echo
echo "Step 3/4: Files and storage..." echo "Step 3/11: Files and storage..."
run_migration "$SCRIPT_DIR/0003_files.sql" run_migration "$SCRIPT_DIR/0003_files.sql"
echo echo
echo "Step 4/4: Organization ownership and slug scope..." echo "Step 4/11: Organization ownership and slug scope..."
run_migration "$SCRIPT_DIR/0004_org_owner_slug.sql" run_migration "$SCRIPT_DIR/0004_org_owner_slug.sql"
echo
echo "Step 5/11: Organization invitations and join requests..."
run_migration "$SCRIPT_DIR/0005_org_invitations.sql"
echo
echo "Step 6/11: Organization invite links..."
run_migration "$SCRIPT_DIR/0006_org_invite_link.sql"
echo
echo "Step 7/11: File share links..."
run_migration "$SCRIPT_DIR/0007_file_share_links.sql"
echo
echo "Step 8/11: File share links nullable org..."
run_migration "$SCRIPT_DIR/0008_file_share_links_nullable_org.sql"
echo
echo "Step 9/11: File share links org id nullable..."
run_migration "$SCRIPT_DIR/0009_file_share_links_org_id_nullable.sql"
echo
echo "Step 10/11: Add avatar URL to users..."
run_migration "$SCRIPT_DIR/0010_add_avatar_url.sql"
echo
echo "Step 11/11: Add updated_at to users..."
run_migration "$SCRIPT_DIR/0011_add_updated_at.sql"
echo echo
echo "=== All migrations completed successfully! ===" echo "=== All migrations completed successfully! ==="

Binary file not shown.

107
scripts/auto-deploy.sh Executable file
View File

@@ -0,0 +1,107 @@
#!/bin/bash
# Daily auto-deploy script for Flutter/Go projects
# Runs at 3AM daily - no more disruptive deployments!
# Run as root/admin - no extra users needed!
set -e
# Configuration
GIT_REPO="https://lab.b0esche.cloud/b0esche/b0esche_cloud.git"
DEPLOY_DIR="/opt/auto-deploy/b0esche_cloud_rollout"
BUILD_DIR="/opt/go/data/postgres/backend/go_cloud"
LOG_FILE="/var/log/auto-deploy.log"
TIMEOUT=1800 # 30 minutes for Flutter build (increased from default)
SKIP_IF_RECENT_HOURS=2 # Skip if deployed within last 2 hours
# Logging
exec > >(tee -a "$LOG_FILE") 2>&1
# Check if we should skip deployment
LAST_DEPLOY_FILE="/tmp/last_deploy_time"
if [ -f "$LAST_DEPLOY_FILE" ]; then
LAST_DEPLOY=$(cat "$LAST_DEPLOY_FILE")
CURRENT_TIME=$(date +%s)
if [ $((CURRENT_TIME - LAST_DEPLOY)) -lt $((SKIP_IF_RECENT_HOURS * 3600)) ]; then
echo "=== Skipping deployment - recent deploy was less than $SKIP_IF_RECENT_HOURS hours ago ==="
echo "=== Auto-deploy skipped at $(date) ==="
exit 0
fi
fi
echo "=== Scheduled auto-deploy started at $(date) ==="
# Create deploy directory if it doesn't exist
mkdir -p "$DEPLOY_DIR"
cd "$DEPLOY_DIR"
echo "Pulling latest changes..."
if [ -d ".git" ]; then
git fetch origin
git reset --hard origin/main
else
git clone "$GIT_REPO" .
fi
echo "=== Deploying Backend ==="
# Copy backend code to build directory
echo "Copying backend code to build directory..."
mkdir -p "$BUILD_DIR"
cp -r go_cloud/* "$BUILD_DIR/"
# Build and start backend from traefik directory
cd /opt/traefik
echo "Building go-backend container..."
docker-compose build --no-cache go-backend
echo "Recreating go-backend container with fresh environment..."
docker-compose up -d --force-recreate go-backend
echo "Backend deployed successfully!"
echo "=== Deploying Frontend ==="
# Build Flutter for web with proper timeout
echo "Building Flutter web app..."
FRONTEND_DIR="$DEPLOY_DIR/b0esche_cloud"
cd "$FRONTEND_DIR"
# Ensure a clean build environment to avoid stale package config or .dart_tool cache
sudo -u admin rm -rf .dart_tool build
# Fetch packages and clean previous artifacts
timeout 300 sudo -u admin /opt/flutter/bin/flutter pub get
sudo -u admin /opt/flutter/bin/flutter clean
# Now build
timeout 900 sudo -u admin /opt/flutter/bin/flutter build web --release || {
echo "Flutter build failed or timed out"
exit 1
}
# Copy built files to nginx volume
echo "Copying built files to web server..."
rm -rf /opt/traefik/web/*
cp -r build/web/* /opt/traefik/web/
# Restart nginx container
echo "Restarting flutter-web container..."
cd /opt/traefik
docker-compose up -d --force-recreate flutter-web
echo "=== Deployment completed successfully at $(date) ==="
# Record deployment time
date +%s > "$LAST_DEPLOY_FILE"
# Health checks
echo "=== Running health checks ==="
sleep 10
if curl -f -s https://go.b0esche.cloud/health > /dev/null 2>&1; then
echo "✅ Backend health check passed"
else
echo "❌ Backend health check failed"
fi
if curl -f -s https://www.b0esche.cloud > /dev/null 2>&1; then
echo "✅ Frontend health check passed"
else
echo "❌ Frontend health check failed"
fi
echo "=== Scheduled auto-deploy completed ==="

50
scripts/backup.sh Executable file
View File

@@ -0,0 +1,50 @@
#!/bin/bash
# b0esche.cloud Backup Script
# Usage: ./backup.sh
set -e
DATE=$(date +%Y%m%d_%H%M%S)
BACKUP_DIR="/opt/backups/b0esche_cloud/$DATE"
RETENTION_DAYS=30
echo "Starting backup for b0esche.cloud - $DATE"
# Create backup directory
mkdir -p "$BACKUP_DIR"
echo "Backing up Go backend PostgreSQL database..."
docker exec go-postgres pg_dump -U go_backend -Fc go_backend > "$BACKUP_DIR/go_backend.sqlc"
echo "Backing up Nextcloud database..."
docker exec nextcloud-db mysqldump -u nextcloud -pSu11Fd02!!! nextcloud > "$BACKUP_DIR/nextcloud.sql"
echo "Backing up Traefik certificates..."
cp -r /opt/traefik/acme "$BACKUP_DIR/"
echo "Backing up configuration files..."
cp /opt/go/.env.production "$BACKUP_DIR/"
cp /opt/go/docker-compose.yml "$BACKUP_DIR/go-docker-compose.yml"
cp /opt/flutter/docker-compose.yml "$BACKUP_DIR/flutter-docker-compose.yml"
cp /opt/flutter/nginx.conf "$BACKUP_DIR/"
cp /opt/traefik/docker-compose.yml "$BACKUP_DIR/traefik-docker-compose.yml"
cp /opt/traefik/traefik.yml "$BACKUP_DIR/"
echo "Backing up Docker volumes..."
docker run --rm -v nextcloud_data_31:/data -v "$BACKUP_DIR":/backup alpine tar czf /backup/nextcloud_data.tar.gz -C /data .
echo "Compressing backup..."
cd /opt/backups/b0esche_cloud
tar czf "$DATE.tar.gz" "$DATE"
rm -rf "$DATE"
echo "Backup completed: /opt/backups/b0esche_cloud/$DATE.tar.gz"
# Clean up old backups
echo "Cleaning up backups older than $RETENTION_DAYS days..."
find /opt/backups/b0esche_cloud -name "*.tar.gz" -mtime +$RETENTION_DAYS -delete
echo "Backup process completed successfully!"
# Show backup size
ls -lh "/opt/backups/b0esche_cloud/$DATE.tar.gz"

9
scripts/deploy-now.sh Executable file
View File

@@ -0,0 +1,9 @@
#!/bin/bash
# Manual deploy script - call this when you want immediate deployment
# Usage: ./deploy-now.sh
echo "🚀 Starting immediate deployment..."
/opt/scripts/auto-deploy.sh
echo "✅ Manual deployment completed!"

120
scripts/monitor.sh Executable file
View File

@@ -0,0 +1,120 @@
#!/bin/bash
# b0esche.cloud Monitoring Script
# Usage: ./monitor.sh
set -e
GREEN='\033[0;32m'
RED='\033[0;31m'
YELLOW='\033[1;33m'
NC='\033[0m' # No Color
echo "=== b0esche.cloud Service Status ==="
echo
# Check critical services
services=("traefik" "go-backend" "go-postgres" "flutter-web" "nextcloud" "nextcloud-db" "collabora")
for service in "${services[@]}"; do
if docker ps --format "table {{.Names}}" | grep -q "^$service$"; then
echo -e "${GREEN}${NC} $service is running"
else
echo -e "${RED}${NC} $service is not running"
fi
done
echo
echo "=== Service Health Checks ==="
echo
# HTTP/HTTPS health checks
echo -n "Flutter Web (www.b0esche.cloud): "
if curl -s --max-time 5 https://www.b0esche.cloud | grep -q "b0esche_cloud"; then
echo -e "${GREEN}OK${NC}"
else
echo -e "${RED}FAILED${NC}"
fi
echo -n "Go Backend (go.b0esche.cloud): "
if curl -s --max-time 5 https://go.b0esche.cloud/health | grep -q "ok"; then
echo -e "${GREEN}OK${NC}"
else
echo -e "${RED}FAILED${NC}"
fi
echo -n "Nextcloud (storage.b0esche.cloud): "
if curl -s --max-time 5 -I https://storage.b0esche.cloud | grep -q "HTTP/2 200"; then
echo -e "${GREEN}OK${NC}"
else
echo -e "${RED}FAILED${NC}"
fi
echo -n "Collabora (of.b0esche.cloud): "
if curl -s --max-time 5 -I https://of.b0esche.cloud | grep -q "HTTP/2"; then
echo -e "${GREEN}OK${NC}"
else
echo -e "${YELLOW}DEGRADED${NC}"
fi
echo
echo "=== Resource Usage ==="
echo
# Show container resource usage
docker stats --no-stream --format "table {{.Container}}\t{{.CPUPerc}}\t{{.MemUsage}}\t{{.MemPerc}}" | grep -E "(traefik|go-backend|flutter-web|nextcloud|collabora)"
echo
echo "=== Recent Error Logs ==="
echo
# Show recent error logs from each service
for service in traefik go-backend nextcloud collabora; do
errors=$(docker logs "$service" --since=1h 2>&1 | grep -i error | tail -3 | wc -l)
if [ "$errors" -gt 0 ]; then
echo -e "${YELLOW}$service:${NC} $errors errors in last hour"
docker logs "$service" --since=1h 2>&1 | grep -i error | tail -3 | sed 's/^/ /'
fi
done
echo
echo "=== SSL Certificate Status ==="
echo
# Check certificate expiry for main domains
domains=("www.b0esche.cloud" "go.b0esche.cloud" "storage.b0esche.cloud" "of.b0esche.cloud")
for domain in "${domains[@]}"; do
expiry=$(echo | openssl s_client -servername "$domain" -connect "$domain:443" 2>/dev/null | openssl x509 -noout -dates 2>/dev/null | grep notAfter | cut -d= -f2)
if [ -n "$expiry" ]; then
expiry_epoch=$(date -d "$expiry" +%s)
current_epoch=$(date +%s)
days_left=$(( (expiry_epoch - current_epoch) / 86400 ))
if [ "$days_left" -lt 7 ]; then
echo -e "${RED}$domain: ${NC}Expires in $days_left days"
elif [ "$days_left" -lt 30 ]; then
echo -e "${YELLOW}$domain: ${NC}Expires in $days_left days"
else
echo -e "${GREEN}$domain: ${NC}Expires in $days_left days"
fi
else
echo -e "${RED}$domain: ${NC}Certificate check failed"
fi
done
echo
echo "=== Disk Usage ==="
echo
# Show disk usage for critical directories
echo "PostgreSQL data:"
du -sh /opt/go/data/postgres 2>/dev/null || echo " Not accessible"
echo "Backup directory:"
du -sh /opt/backups 2>/dev/null || echo " Not found"
echo "Docker volumes:"
docker system df --format "table {{.Type}}\t{{.TotalCount}}\t{{.Size}}"
echo
echo "=== Monitoring Complete ==="

92
scripts/webhook-server.py Executable file
View File

@@ -0,0 +1,92 @@
#!/usr/bin/env python3
# Simple webhook server for Gitea
# Listens for push events and triggers auto-deploy
import http.server
import socketserver
import json
import subprocess
import os
import hmac
import hashlib
from urllib.parse import urlparse
# Configuration
PORT = 8080
SECRET = 'your-webhook-secret' # Change this!
DEPLOY_SCRIPT = '/opt/scripts/auto-deploy.sh'
class WebhookHandler(http.server.BaseHTTPRequestHandler):
def do_POST(self):
if self.path == '/webhook':
try:
# Read the payload
content_length = int(self.headers['Content-Length'])
post_data = self.rfile.read(content_length)
# Verify signature (optional but recommended)
signature = self.headers.get('X-Gitea-Signature', '')
if signature:
expected_sig = hmac.new(
SECRET.encode(),
post_data,
hashlib.sha256
).hexdigest()
expected_sig = f'sha256={expected_sig}'
if not hmac.compare_digest(signature, expected_sig):
self.send_response(401)
self.end_headers()
self.wfile.write(b'Invalid signature')
return
# Parse the webhook payload
data = json.loads(post_data.decode('utf-8'))
# Only deploy on manual trigger (with "deploy" in commit message)
commit_message = data.get('commits', [{}])[0].get('message', '').lower()
if 'deploy now' in commit_message or 'manual deploy' in commit_message:
print(f"Manual deploy requested: {commit_message}")
# Run the deploy script in background
subprocess.Popen([
'nohup', 'bash', '/opt/scripts/deploy-now.sh'
], stdout=open('/var/log/webhook-deploy.log', 'a'),
stderr=subprocess.STDOUT)
self.send_response(200)
self.end_headers()
self.wfile.write(b'Manual deployment triggered')
else:
print(f"Ignoring push - not a manual deploy trigger")
self.send_response(200)
self.end_headers()
self.wfile.write(b'Push received - daily deploy scheduled for 3AM')
except Exception as e:
print(f"Webhook error: {e}")
self.send_response(500)
self.end_headers()
self.wfile.write(b'Internal server error')
else:
self.send_response(404)
self.end_headers()
self.wfile.write(b'Not found')
def do_GET(self):
if self.path == '/health':
self.send_response(200)
self.end_headers()
self.wfile.write(b'OK')
else:
self.send_response(404)
self.end_headers()
self.wfile.write(b'Not found')
if __name__ == '__main__':
os.makedirs('/var/log', exist_ok=True)
with socketserver.TCPServer(("", PORT), WebhookHandler) as httpd:
print(f"Webhook server listening on port {PORT}")
httpd.serve_forever()