Compare commits

...

168 Commits

Author SHA1 Message Date
Leon Bösche
6ce43a3c9b Add last modified tracking: show 'Last modified: date by username' in document viewer
- Added modified_by column to files table
- Updated WOPI PutFile to track who modified the file
- Updated view handlers to return file metadata (name, size, lastModified, modifiedByName)
- Updated Flutter models and UI to display last modified info
2026-01-13 16:45:57 +01:00
Leon Bösche
6943e95479 Fix WOPI: Use correct WebDAV path for org files (/orgs/{orgId}/ prefix) 2026-01-13 16:10:57 +01:00
Leon Bösche
2e1096a9ad Add id attribute to form field to fix autofill warning 2026-01-13 15:48:20 +01:00
Leon Bösche
8d9db29db2 Fix WOPI: Use config for Nextcloud URL instead of hardcoded nc.b0esche.cloud 2026-01-13 15:25:04 +01:00
Leon Bösche
afeb7a35ad Replace deprecated dart:html with package:web
- Updated document_viewer.dart to use web.HTMLIFrameElement/HTMLDivElement
- Updated file_explorer.dart to use web.HTMLAnchorElement for downloads
- Added web and http packages to pubspec.yaml
2026-01-13 14:52:12 +01:00
Leon Bösche
70039a8288 Fix Collabora WOPI: Put WOPISrc in URL query param, not form body 2026-01-13 14:50:24 +01:00
Leon Bösche
634aa521bd Refactor index.html: Clean up preload link formatting and improve console warning suppression 2026-01-13 14:38:59 +01:00
Leon Bösche
749672509b Fix Collabora: Fetch versioned editor URL from discovery endpoint
- Added getCollaboraEditorURL() to fetch correct /browser/{version}/cool.html path
- Cache discovery response for 5 minutes to avoid repeated requests
- Fixed 'Invalid URI' error caused by version mismatch
2026-01-13 14:38:08 +01:00
Leon Bösche
20e9ae3e4d Suppress Flutter web deprecation warnings (v8BreakIterator, async XMLHttpRequest) 2026-01-13 14:34:18 +01:00
Leon Bösche
3738b346e3 Remove docker-compose-server.yml from .gitignore 2026-01-13 14:05:06 +01:00
Leon Bösche
0aa281ac09 Add .gitignore for docker-compose-server.yml and update WOPISrc URL handling in collaboraProxyHandler 2026-01-13 14:01:02 +01:00
Leon Bösche
8baaad2c08 Fix Collabora form submission: remove iframe target and use _self to bypass CSP 2026-01-13 12:59:49 +01:00
Leon Bösche
19825763ad Fix Collabora endpoint: use /browser/dist/cool.html instead of /loleaflet/dist/loleaflet.html 2026-01-13 12:46:56 +01:00
Leon Bösche
c16cd49237 Use /loleaflet/dist/loleaflet.html - standard Collabora Online WOPI endpoint 2026-01-13 00:28:34 +01:00
Leon Bösche
57eea172a2 Revert to form POST submission to /cool/dist/cool.html (correct WOPI pattern)
- Collabora Online requires POST request with WOPISrc in form body
- GET requests with query parameters cause 400 Bad Request
- Using modern /cool/ endpoint with form submission to iframe
2026-01-13 00:13:21 +01:00
Leon Bösche
d255f40a8c Try Collabora path /cool/dist/cool.html (newer naming convention) 2026-01-12 17:25:53 +01:00
Leon Bösche
d59a655f0f Fix Collabora path from /loleaflet to /lool (correct endpoint) 2026-01-12 17:11:50 +01:00
Leon Bösche
509983b4ff Fix: Update Collabora proxy to load editor in iframe with WOPISrc in query string 2026-01-12 16:58:39 +01:00
Leon Bösche
5fb08d8831 Improve Collabora proxy: add load event listener and relax iframe sandbox for form submission
- Use window load event to ensure form element exists before submission
- Add allow-popups-to-escape-sandbox for cross-origin form POSTs
- Add allow-presentation for better compatibility
- Improved error logging for debugging form submission issues
2026-01-12 16:51:16 +01:00
Leon Bösche
18f5b3f98b Fix: Use form POST submission for Collabora (not GET query string)
- Collabora requires POST request to /loleaflet/dist/loleaflet.html
- WOPISrc must be in request body as form parameter
- Form targets iframe by name for proper document loading
- Matches WOPI/Collabora standard integration pattern
2026-01-12 16:46:11 +01:00
Leon Bösche
d58137716f Fix: Collabora proxy should load iframe with WOPISrc in query string, not form submission
- Changed from form POST submission to direct iframe load
- WOPISrc goes in Collabora URL query string, not as form parameter
- This matches Collabora's expected request format for WOPI integration
2026-01-12 16:40:35 +01:00
Leon Bösche
18138dde01 Fix: Remove X-Frame-Options header from Collabora proxy to allow iframe loading
- The collaboraProxyHandler endpoint is intentionally meant to load in an iframe
- X-Frame-Options: SAMEORIGIN was blocking cross-origin iframe loads
- Removed the header to allow the Flutter web frontend to embed the proxy page
2026-01-12 16:34:05 +01:00
Leon Bösche
6db2bf077d Fix: Clean up whitespace in document viewer and WOPI handler files 2026-01-12 16:10:40 +01:00
Leon Bösche
42ee3057bf Fix: Collabora proxy handler - create WOPI session inline instead of calling non-existent function 2026-01-12 16:10:04 +01:00
Leon Bösche
350eb27e30 Clarify Collabora proxy token handling for iframe cross-origin requests 2026-01-12 16:08:35 +01:00
Leon Bösche
2ded9b00f9 idle 2026-01-12 16:00:27 +01:00
Leon Bösche
2daa9e9855 Fix: Use JavaScript in head to submit form for Collabora POST
- Inject script element into head that creates and submits form
- Script executes immediately when loaded, doesn't rely on Future callbacks
- Form targets iframe by name, WOPISrc in body not query params
- This ensures form submission happens reliably before any async operations
2026-01-12 15:58:08 +01:00
Leon Bösche
2fa5f0441a Fix: Create form in parent document to POST to Collabora iframe
- Data URL iframe form submission was blocked by CORS
- New approach: create iframe in viewport, form in document body
- Form targets iframe by name, submits WOPISrc to loleaflet.html via POST
- Form submission happens in parent document context, not sandboxed iframe
- This avoids CORS issues and ensures form actually submits
2026-01-12 15:53:05 +01:00
Leon Bösche
64690231c2 Fix: Refactor Collabora iframe creation for improved readability and maintainability 2026-01-12 15:44:57 +01:00
Leon Bösche
0ee3b32ef3 Fix: Use data URL iframe with auto-submitting form for Collabora POST
- Create iframe with data URL containing HTML page
- HTML page has form that auto-submits to loleaflet.html with WOPISrc in body
- This ensures POST request instead of GET
- Form submits within iframe context, not affected by parent page JavaScript
2026-01-12 15:34:43 +01:00
Leon Bösche
99419748bb Fix: Pass WOPISrc directly to form submission, don't build URL with query params
- Remove URL building with query parameters that was causing GET requests
- Pass WOPISrc directly to _buildCollaboraIframe function
- JavaScript now creates form with POST method and WOPISrc in body
- This ensures Collabora receives POST not GET request
2026-01-12 15:28:50 +01:00
Leon Bösche
89f55471ce Fix: Use JavaScript form submission for Collabora POST request
- Collabora expects POST to loleaflet.html with WOPISrc in body, not GET
- Previous Dart form.submit() wasn't executing reliably
- Use ScriptElement to inject and execute form submission JavaScript
- This bypasses Dart's HTML layer limitations and ensures form POSTs properly
- Unique viewType names with timestamp to avoid registration conflicts
2026-01-12 15:22:40 +01:00
Leon Bösche
ef60983534 Fix: Refactor Collabora iframe setup for improved readability and maintainability 2026-01-12 10:26:25 +01:00
Leon Bösche
8e06e7e17d Fix: Update Collabora iframe to use POST method for WOPISrc submission 2026-01-12 10:26:17 +01:00
Leon Bösche
0b822af438 Fix: Use proper URL building for Collabora WOPISrc parameter
- Changed from manual string concatenation to Uri.parse().replace(queryParameters: ...)
- This properly encodes the WOPISrc while maintaining encoding through iframe.src
- Fixes Collabora 'WOPISrc validation error: unencoded WOPISrc' warning
- Uri API ensures query parameters are properly percent-encoded
2026-01-12 10:12:58 +01:00
Leon Bösche
69cf328972 Fix Collabora 400 error by properly URL-encoding WOPISrc parameter
- WOPISrc is a full URL that needs URL encoding when passed as query param
- Use Uri.encodeComponent() to properly encode the WOPISrc value
- Simplify iframe setup to just use the properly constructed URL
- This fixes the 400 Bad Request from Collabora when loading documents
2026-01-12 09:50:41 +01:00
Leon Bösche
181fb0af93 Fix Collabora iframe loading and sandbox security
- Reverted to direct iframe src approach instead of form submission
- This avoids 'Form submission canceled' error
- Removed allow-same-origin from sandbox to improve security
- Added allow-popups-to-escape-sandbox for Collabora functionality
- Use direct URL with WOPISrc parameter instead of POST form
2026-01-12 01:47:52 +01:00
Leon Bösche
7bd1ab16da Fix Collabora Online loading using form-based POST instead of URL params
- Collabora's loleaflet.html expects WOPISrc as POST parameter, not URL query param
- Changed from iframe with src= to form submission approach
- Extract WOPISrc from URL and pass as hidden form input
- This avoids 400 Bad Request from Collabora when using GET with URL params
2026-01-12 01:40:10 +01:00
Leon Bösche
d52307c792 Implement Collabora Online iframe viewer with WOPI integration
- Added dart:html and dart:ui_web imports for iframe support
- Created _buildCollaboraIframe() method to register and display iframe
- Set proper sandbox and allow attributes for Collabora functionality
- Full-screen iframe embedding of Collabora Online document editor
- Replaces placeholder UI with actual document viewing capability
2026-01-12 01:30:53 +01:00
Leon Bösche
dbbad29f2f Fix: Standardize formatting of WOPICheckFileInfoResponse struct fields 2026-01-12 01:18:32 +01:00
Leon Bösche
ec25f06ea3 Fix: Refactor code for improved readability and consistency in document viewer and signup form 2026-01-12 01:18:08 +01:00
Leon Bösche
36db2daabd Fix: Clean up wopi.go file completely 2026-01-12 01:17:41 +01:00
Leon Bösche
ff51ef8a71 Fix: Complete WOPI integration - resolve all compilation errors
Go backend:
- Clean up corrupted wopi.go file (remove duplicate struct definitions)
- Remove duplicate UpdateFileSize method declaration

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

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 589 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 574 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 731 KiB

View File

@@ -1,6 +1,7 @@
import 'package:bloc/bloc.dart';
import '../session/session_bloc.dart';
import '../session/session_event.dart';
import '../session/session_state.dart';
import 'auth_event.dart';
import 'auth_state.dart';
import '../../services/api_client.dart';
@@ -252,7 +253,22 @@ class AuthBloc extends Bloc<AuthEvent, AuthState> {
CheckAuthRequested event,
Emitter<AuthState> emit,
) async {
// Check if token is valid in SessionBloc
// Check if session is active from persistent storage
final sessionState = sessionBloc.state;
if (sessionState is SessionActive) {
// Session already active - emit authenticated state with minimal info
// The full user info will be fetched when needed
emit(
AuthAuthenticated(
token: sessionState.token,
userId: '',
username: '',
email: '',
),
);
} else {
emit(AuthUnauthenticated());
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -16,11 +16,12 @@ class PermissionBloc extends Bloc<PermissionEvent, PermissionState> {
// Simulate loading permissions from backend for orgId
await Future.delayed(const Duration(seconds: 1));
// Mock capabilities based on orgId
// Allow all permissions for authenticated users (proper permissions should come from backend)
final capabilities = Capabilities(
canRead: true,
canWrite: event.orgId == 'org1', // Only admin for personal
canShare: event.orgId == 'org1',
canAdmin: event.orgId == 'org1',
canWrite: true,
canShare: true,
canAdmin: true,
canAnnotate: true,
canEdit: true,
);

View File

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

View File

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

View File

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

View File

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

View File

@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart';
import 'blocs/auth/auth_bloc.dart';
import 'blocs/auth/auth_event.dart';
import 'blocs/session/session_bloc.dart';
import 'blocs/activity/activity_bloc.dart';
import 'services/api_client.dart';
@@ -11,6 +12,7 @@ import 'pages/file_explorer.dart';
import 'pages/document_viewer.dart';
import 'pages/editor_page.dart';
import 'theme/app_theme.dart';
import 'injection.dart';
final GoRouter _router = GoRouter(
routes: [
@@ -41,29 +43,80 @@ void main() {
runApp(const MainApp());
}
class MainApp extends StatelessWidget {
class MainApp extends StatefulWidget {
const MainApp({super.key});
@override
State<MainApp> createState() => _MainAppState();
}
class _MainAppState extends State<MainApp> {
final _sessionBloc = SessionBloc();
late final AuthBloc _authBloc;
late final Future<void> _restoreFuture;
@override
void initState() {
super.initState();
// Configure DI first
configureDependencies(_sessionBloc);
// Create AuthBloc first
_authBloc = AuthBloc(
apiClient: ApiClient(_sessionBloc),
sessionBloc: _sessionBloc,
);
// Restore session and then check auth
_restoreFuture = SessionBloc.restoreSession(_sessionBloc).then((_) {
// After session is restored, check if we should auto-authenticate
if (mounted) {
_authBloc.add(const CheckAuthRequested());
}
});
}
@override
Widget build(BuildContext context) {
return MultiBlocProvider(
providers: [
BlocProvider<SessionBloc>(create: (_) => SessionBloc()),
BlocProvider<AuthBloc>(
create: (context) => AuthBloc(
apiClient: ApiClient(context.read<SessionBloc>()),
sessionBloc: context.read<SessionBloc>(),
),
),
BlocProvider<SessionBloc>.value(value: _sessionBloc),
BlocProvider<AuthBloc>.value(value: _authBloc),
BlocProvider<ActivityBloc>(
create: (context) =>
ActivityBloc(ActivityApi(ApiClient(context.read<SessionBloc>()))),
ActivityBloc(ActivityApi(ApiClient(_sessionBloc))),
),
],
child: MaterialApp.router(
routerConfig: _router,
child: FutureBuilder<void>(
future: _restoreFuture,
builder: (context, snapshot) {
if (snapshot.connectionState != ConnectionState.done) {
return MaterialApp(
theme: AppTheme.darkTheme,
home: const Scaffold(
body: Center(
child: CircularProgressIndicator(
valueColor: AlwaysStoppedAnimation<Color>(
AppTheme.accentColor,
),
),
),
),
);
}
return MaterialApp.router(
routerConfig: _router,
theme: AppTheme.darkTheme,
);
},
),
);
}
@override
void dispose() {
_authBloc.close();
_sessionBloc.close();
super.dispose();
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,14 +1,548 @@
import 'package:flutter/material.dart';
import 'dart:convert';
import 'package:web/web.dart' as web;
import 'dart:ui_web' as ui;
import '../theme/app_theme.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../blocs/document_viewer/document_viewer_bloc.dart';
import '../blocs/document_viewer/document_viewer_event.dart';
import '../blocs/document_viewer/document_viewer_state.dart';
import '../blocs/session/session_bloc.dart';
import '../blocs/session/session_state.dart';
import '../services/file_service.dart';
import '../injection.dart';
import 'package:syncfusion_flutter_pdfviewer/pdfviewer.dart';
import 'package:go_router/go_router.dart';
import 'package:http/http.dart' as http;
// Modal version for overlay display
class DocumentViewerModal extends StatefulWidget {
final String orgId;
final String fileId;
final VoidCallback onClose;
final VoidCallback onEdit;
const DocumentViewerModal({
super.key,
required this.orgId,
required this.fileId,
required this.onClose,
required this.onEdit,
});
@override
State<DocumentViewerModal> createState() => _DocumentViewerModalState();
}
class _DocumentViewerModalState extends State<DocumentViewerModal> {
late DocumentViewerBloc _viewerBloc;
@override
void initState() {
super.initState();
_viewerBloc = DocumentViewerBloc(getIt<FileService>());
_viewerBloc.add(DocumentOpened(orgId: widget.orgId, fileId: widget.fileId));
}
@override
Widget build(BuildContext context) {
return BlocProvider.value(
value: _viewerBloc,
child: Column(
children: [
// Custom AppBar
Container(
height: 56,
decoration: BoxDecoration(
color: AppTheme.primaryBackground.withValues(alpha: 0.9),
border: Border(
bottom: BorderSide(
color: AppTheme.accentColor.withValues(alpha: 0.3),
),
),
),
child: Row(
children: [
const SizedBox(width: 16),
const Text(
'Document Viewer',
style: TextStyle(
color: AppTheme.primaryText,
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
const Spacer(),
BlocBuilder<DocumentViewerBloc, DocumentViewerState>(
builder: (context, state) {
if (state is DocumentViewerReady && state.caps.canEdit) {
return IconButton(
icon: const Icon(
Icons.edit,
color: AppTheme.primaryText,
),
onPressed: widget.onEdit,
);
}
return const SizedBox.shrink();
},
),
IconButton(
icon: const Icon(Icons.refresh, color: AppTheme.primaryText),
splashColor: Colors.transparent,
highlightColor: Colors.transparent,
onPressed: () {
_viewerBloc.add(DocumentReloaded());
},
),
IconButton(
icon: const Icon(Icons.close, color: AppTheme.primaryText),
splashColor: Colors.transparent,
highlightColor: Colors.transparent,
onPressed: () {
_viewerBloc.add(DocumentClosed());
widget.onClose();
},
),
const SizedBox(width: 8),
],
),
),
// Meta info bar
BlocBuilder<DocumentViewerBloc, DocumentViewerState>(
builder: (context, state) {
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(
height: 30,
alignment: Alignment.centerLeft,
padding: const EdgeInsets.symmetric(horizontal: 16),
decoration: BoxDecoration(
color: AppTheme.primaryBackground.withValues(alpha: 0.3),
),
child: Text(
lastModifiedText,
style: const TextStyle(
fontSize: 12,
color: AppTheme.secondaryText,
),
),
);
}
return const SizedBox.shrink();
},
),
// Document content
Expanded(
child: BlocBuilder<DocumentViewerBloc, DocumentViewerState>(
builder: (context, state) {
if (state is DocumentViewerLoading) {
return Container(
color: AppTheme.primaryBackground,
child: const Center(
child: CircularProgressIndicator(
valueColor: AlwaysStoppedAnimation<Color>(
AppTheme.accentColor,
),
),
),
);
}
if (state is DocumentViewerError) {
return Center(
child: Text(
'Error: ${state.message}',
style: const TextStyle(color: AppTheme.primaryText),
),
);
}
if (state is DocumentViewerSessionExpired) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Text(
'Your viewing session expired. Click to reopen.',
style: TextStyle(color: AppTheme.primaryText),
),
const SizedBox(height: 16),
ElevatedButton(
onPressed: () {
_viewerBloc.add(
DocumentOpened(
orgId: widget.orgId,
fileId: widget.fileId,
),
);
},
child: const Text('Reload'),
),
],
),
);
}
if (state is DocumentViewerReady) {
// Handle different file types based on MIME type
if (state.caps.isPdf) {
// PDF viewer using SfPdfViewer
return SfPdfViewer.network(
state.viewUrl.toString(),
headers: {'Authorization': 'Bearer ${state.token}'},
onDocumentLoadFailed: (details) {},
onDocumentLoaded: (PdfDocumentLoadedDetails details) {},
);
} else if (state.caps.isImage) {
// Image viewer
return Container(
color: AppTheme.primaryBackground,
child: InteractiveViewer(
minScale: 0.5,
maxScale: 4.0,
child: Image.network(
state.viewUrl.toString(),
headers: {'Authorization': 'Bearer ${state.token}'},
fit: BoxFit.contain,
errorBuilder: (context, error, stackTrace) {
return Center(
child: Text(
'Failed to load image',
style: TextStyle(color: Colors.red[400]),
),
);
},
),
),
);
} else if (state.caps.isText) {
// Text file viewer
return FutureBuilder<String>(
future: _fetchTextContent(
state.viewUrl.toString(),
state.token,
),
builder: (context, snapshot) {
if (snapshot.connectionState ==
ConnectionState.waiting) {
return Center(
child: CircularProgressIndicator(
valueColor: AlwaysStoppedAnimation<Color>(
AppTheme.accentColor,
),
),
);
}
if (snapshot.hasError) {
return Center(
child: Text(
'Error loading text: ${snapshot.error}',
style: TextStyle(color: Colors.red[400]),
),
);
}
return SingleChildScrollView(
child: Container(
color: AppTheme.primaryBackground,
padding: const EdgeInsets.all(16),
child: SelectableText(
snapshot.data ?? '',
style: TextStyle(
color: AppTheme.primaryText,
fontFamily: 'Courier New',
fontSize: 13,
height: 1.5,
),
),
),
);
},
);
} else if (state.caps.isOffice) {
// Office document viewer using Collabora Online
return _buildCollaboraViewer(
state.viewUrl.toString(),
state.token,
);
} else {
// Unknown file type
return Container(
color: AppTheme.primaryBackground,
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.description,
size: 64,
color: AppTheme.accentColor,
),
const SizedBox(height: 16),
Text(
'File Type Not Supported',
style: TextStyle(
color: AppTheme.primaryText,
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
Text(
'MIME Type: ${state.caps.mimeType}',
style: TextStyle(
color: AppTheme.secondaryText,
fontSize: 12,
),
),
],
),
),
);
}
}
return const Center(
child: Text(
'No document loaded',
style: TextStyle(color: AppTheme.primaryText),
),
);
},
),
),
],
),
);
}
Future<String> _fetchTextContent(String url, String token) async {
try {
final response = await http
.get(Uri.parse(url), headers: {'Authorization': 'Bearer $token'})
.timeout(const Duration(seconds: 30));
if (response.statusCode == 200) {
return response.body;
} else {
throw Exception('Failed to load text: ${response.statusCode}');
}
} catch (e) {
throw Exception('Error fetching text: $e');
}
}
Widget _buildCollaboraViewer(String documentUrl, String token) {
// Create WOPI session to get WOPISrc URL
return FutureBuilder<WOPISession>(
future: _createWOPISession(token),
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return Container(
color: AppTheme.primaryBackground,
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
CircularProgressIndicator(
valueColor: AlwaysStoppedAnimation<Color>(
AppTheme.accentColor,
),
),
const SizedBox(height: 16),
Text(
'Loading document in Collabora Online...',
style: TextStyle(
color: AppTheme.secondaryText,
fontSize: 12,
),
),
],
),
),
);
}
if (snapshot.hasError) {
return Container(
color: AppTheme.primaryBackground,
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.error_outline, size: 64, color: Colors.red[400]),
const SizedBox(height: 16),
Text(
'Failed to load document',
style: TextStyle(
color: AppTheme.primaryText,
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
Text(
'${snapshot.error}',
style: TextStyle(
color: AppTheme.secondaryText,
fontSize: 12,
),
),
],
),
),
);
}
if (!snapshot.hasData) {
return Container(
color: AppTheme.primaryBackground,
child: const Center(
child: Text(
'No session data',
style: TextStyle(color: AppTheme.primaryText),
),
),
);
}
final wopiSession = snapshot.data!;
// Use backend proxy endpoint to serve the Collabora form
final proxyUrl = _buildProxyUrl(token);
return _buildWebView(proxyUrl);
},
);
}
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 {
try {
// Use default base URL from backend
String baseUrl = 'https://go.b0esche.cloud';
// Determine endpoint based on whether we're in org or user workspace
String endpoint;
if (widget.orgId.isNotEmpty && widget.orgId != 'personal') {
endpoint = '/orgs/${widget.orgId}/files/${widget.fileId}/wopi-session';
} else {
endpoint = '/user/files/${widget.fileId}/wopi-session';
}
final response = await http
.post(
Uri.parse('$baseUrl$endpoint'),
headers: {
'Authorization': 'Bearer $token',
'Content-Type': 'application/json',
},
)
.timeout(const Duration(seconds: 10));
if (response.statusCode == 200) {
final json = jsonDecode(response.body) as Map<String, dynamic>;
return WOPISession(
wopisrc: json['wopi_src'] as String,
accessToken: json['access_token'] as String,
);
} else {
throw Exception(
'Failed to create WOPI session: ${response.statusCode}',
);
}
} catch (e) {
throw Exception('Error creating WOPI session: $e');
}
}
Widget _buildCollaboraIframe(String proxyUrl) {
// Load the backend proxy page which handles Collabora form submission
final String viewType =
'collabora-${DateTime.now().millisecondsSinceEpoch}';
ui.platformViewRegistry.registerViewFactory(viewType, (int viewId) {
// Create iframe pointing to the proxy endpoint
final iframe = web.HTMLIFrameElement()
..style.border = 'none'
..style.width = '100%'
..style.height = '100%'
..style.margin = '0'
..style.padding = '0'
..src = proxyUrl
..setAttribute(
'allow',
'microphone; camera; usb; autoplay; clipboard-read; clipboard-write; fullscreen',
)
..setAttribute(
'sandbox',
'allow-same-origin allow-scripts allow-popups allow-popups-to-escape-sandbox allow-forms allow-presentation',
);
final container = web.HTMLDivElement()
..style.width = '100%'
..style.height = '100%'
..style.margin = '0'
..style.padding = '0'
..style.overflow = 'hidden'
..append(iframe);
return container;
});
return HtmlElementView(viewType: viewType);
}
Widget _buildWebView(String proxyUrl) {
// Embed Collabora Online via proxy endpoint
return _buildCollaboraIframe(proxyUrl);
}
@override
void dispose() {
_viewerBloc.close();
super.dispose();
}
}
// WOPI Session model for Collabora Online integration
class WOPISession {
final String wopisrc;
final String accessToken;
WOPISession({required this.wopisrc, required this.accessToken});
factory WOPISession.fromJson(Map<String, dynamic> json) {
return WOPISession(
wopisrc: json['wopi_src'] as String,
accessToken: json['access_token'] as String,
);
}
}
// Original page version (for routing if needed)
class DocumentViewer extends StatefulWidget {
final String orgId;
final String fileId;
@@ -41,13 +575,26 @@ class _DocumentViewerState extends State<DocumentViewer> {
child: BlocBuilder<DocumentViewerBloc, DocumentViewerState>(
builder: (context, state) {
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(
height: 30,
alignment: Alignment.centerLeft,
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Text(
'Last modified: Unknown by Unknown (v1)',
lastModifiedText,
style: const TextStyle(fontSize: 12),
),
);
@@ -94,7 +641,16 @@ class _DocumentViewerState extends State<DocumentViewer> {
body: BlocBuilder<DocumentViewerBloc, DocumentViewerState>(
builder: (context, state) {
if (state is DocumentViewerLoading) {
return const Center(child: CircularProgressIndicator());
return Container(
color: AppTheme.primaryBackground,
child: const Center(
child: CircularProgressIndicator(
valueColor: AlwaysStoppedAnimation<Color>(
AppTheme.accentColor,
),
),
),
);
}
if (state is DocumentViewerError) {
return Center(child: Text('Error: ${state.message}'));
@@ -123,22 +679,133 @@ class _DocumentViewerState extends State<DocumentViewer> {
);
}
if (state is DocumentViewerReady) {
return BlocBuilder<SessionBloc, SessionState>(
builder: (context, sessionState) {
String? token;
if (sessionState is SessionActive) {
token = sessionState.token;
}
if (state.caps.isPdf) {
// Use PDF viewer
return SfPdfViewer.network(state.viewUrl.toString());
} else {
// Placeholder for office docs iframe
// PDF viewer using SfPdfViewer
return SfPdfViewer.network(
state.viewUrl.toString(),
headers: token != null
? {'Authorization': 'Bearer $token'}
: {},
onDocumentLoadFailed: (details) {},
onDocumentLoaded: (PdfDocumentLoadedDetails details) {},
);
} else if (state.caps.isImage) {
// Image viewer
return Container(
color: AppTheme.secondaryText,
child: Center(
color: AppTheme.primaryBackground,
child: InteractiveViewer(
minScale: 0.5,
maxScale: 4.0,
child: Image.network(
state.viewUrl.toString(),
headers: token != null
? {'Authorization': 'Bearer $token'}
: {},
fit: BoxFit.contain,
errorBuilder: (context, error, stackTrace) {
return Center(
child: Text(
'Office Document Viewer\n(URL: ${state.viewUrl})',
textAlign: TextAlign.center,
style: const TextStyle(color: AppTheme.primaryText),
'Failed to load image',
style: TextStyle(color: Colors.red[400]),
),
);
},
),
),
);
} else if (state.caps.isText) {
// Text file viewer
return FutureBuilder<String>(
future: _fetchTextContent(
state.viewUrl.toString(),
token ?? '',
),
builder: (context, snapshot) {
if (snapshot.connectionState ==
ConnectionState.waiting) {
return Center(
child: CircularProgressIndicator(
valueColor: AlwaysStoppedAnimation<Color>(
AppTheme.accentColor,
),
),
);
}
if (snapshot.hasError) {
return Center(
child: Text(
'Error loading text: ${snapshot.error}',
style: TextStyle(color: Colors.red[400]),
),
);
}
return SingleChildScrollView(
child: Container(
color: AppTheme.primaryBackground,
padding: const EdgeInsets.all(16),
child: SelectableText(
snapshot.data ?? '',
style: TextStyle(
color: AppTheme.primaryText,
fontFamily: 'Courier New',
fontSize: 13,
height: 1.5,
),
),
),
);
},
);
} else if (state.caps.isOffice) {
// Office document viewer using Collabora Online
return _buildCollaboraViewerPage(
state.viewUrl.toString(),
token ?? '',
);
} else {
// Unknown file type
return Container(
color: AppTheme.primaryBackground,
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.description,
size: 64,
color: AppTheme.accentColor,
),
const SizedBox(height: 16),
Text(
'File Type Not Supported',
style: TextStyle(
color: AppTheme.primaryText,
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
Text(
'MIME Type: ${state.caps.mimeType}',
style: TextStyle(
color: AppTheme.secondaryText,
fontSize: 12,
),
),
],
),
),
);
}
},
);
}
return const Center(child: Text('No document loaded'));
},
@@ -147,6 +814,73 @@ class _DocumentViewerState extends State<DocumentViewer> {
);
}
Future<String> _fetchTextContent(String url, String token) async {
try {
final response = await http
.get(Uri.parse(url), headers: {'Authorization': 'Bearer $token'})
.timeout(const Duration(seconds: 30));
if (response.statusCode == 200) {
return response.body;
} else {
throw Exception('Failed to load text: ${response.statusCode}');
}
} catch (e) {
throw Exception('Error fetching text: $e');
}
}
Widget _buildCollaboraViewerPage(String documentUrl, String token) {
// Build HTML to embed Collabora Online viewer
// For now, we'll show the document download option with a link to open in Collabora
// A proper implementation would require WOPI protocol support
return Container(
color: AppTheme.primaryBackground,
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.description, size: 64, color: AppTheme.accentColor),
const SizedBox(height: 16),
Text(
'Office Document',
style: TextStyle(
color: AppTheme.primaryText,
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
Text(
'Collabora Online Viewer',
style: TextStyle(color: AppTheme.secondaryText, fontSize: 14),
),
const SizedBox(height: 16),
Text(
'Opening document in Collabora...',
style: TextStyle(color: AppTheme.secondaryText, fontSize: 12),
),
const SizedBox(height: 24),
CircularProgressIndicator(
valueColor: AlwaysStoppedAnimation<Color>(AppTheme.accentColor),
),
const SizedBox(height: 24),
ElevatedButton.icon(
icon: const Icon(Icons.download),
label: const Text('Download File'),
onPressed: () {
// Open file download
// In a real implementation, you'd use url_launcher
// launchUrl(state.viewUrl);
},
),
],
),
),
);
}
@override
void dispose() {
_viewerBloc.close();

View File

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

View File

@@ -1,8 +1,9 @@
import 'dart:ui';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:file_picker/file_picker.dart' hide FileType;
import 'package:go_router/go_router.dart';
import 'package:path/path.dart' as p;
import 'package:web/web.dart' as web;
import '../blocs/file_browser/file_browser_bloc.dart';
import '../blocs/file_browser/file_browser_event.dart';
import '../blocs/file_browser/file_browser_state.dart';
@@ -11,9 +12,14 @@ import '../blocs/permission/permission_event.dart';
import '../blocs/permission/permission_state.dart';
import '../blocs/upload/upload_bloc.dart';
import '../blocs/upload/upload_event.dart';
import '../blocs/upload/upload_state.dart';
import '../models/file_item.dart';
import '../theme/app_theme.dart';
import '../theme/modern_glass_button.dart';
import 'document_viewer.dart';
import 'editor_page.dart';
import '../injection.dart';
import '../services/file_service.dart';
class FileExplorer extends StatefulWidget {
final String orgId;
@@ -55,6 +61,18 @@ class _FileExplorerState extends State<FileExplorer> {
context.read<PermissionBloc>().add(LoadPermissions(widget.orgId));
}
@override
void didUpdateWidget(covariant FileExplorer oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.orgId != widget.orgId) {
// Reset and reload when switching between Personal and Org workspaces
final bloc = context.read<FileBrowserBloc>();
bloc.add(ResetFileBrowser(widget.orgId));
bloc.add(LoadDirectory(orgId: widget.orgId, path: '/'));
}
}
Future<String?> _showCreateFolderDialog(BuildContext context) async {
final TextEditingController controller = TextEditingController();
return showDialog<String>(
@@ -124,10 +142,13 @@ class _FileExplorerState extends State<FileExplorer> {
onPressed: () {
final folderName = controller.text.trim();
if (folderName.isNotEmpty) {
final nameWithSlash = folderName.startsWith('/')
? folderName
: '/$folderName';
Navigator.of(context).pop(nameWithSlash);
// Strip any leading/trailing slashes so we only send the bare folder name
final sanitized = folderName
.replaceAll(RegExp(r'^/+'), '')
.replaceAll(RegExp(r'/+$'), '');
if (sanitized.isNotEmpty) {
Navigator.of(context).pop(sanitized);
}
}
},
child: const Text(
@@ -229,7 +250,7 @@ class _FileExplorerState extends State<FileExplorer> {
if (newName.isNotEmpty && newName != file.name) {
context.read<FileBrowserBloc>().add(
RenameFile(
orgId: 'org1',
orgId: widget.orgId,
path: file.path,
newName: newName,
),
@@ -258,10 +279,35 @@ class _FileExplorerState extends State<FileExplorer> {
);
}
void _downloadFile(FileItem file) {
void _downloadFile(FileItem file) async {
try {
final fileService = getIt<FileService>();
final downloadUrl = await fileService.getDownloadUrl(
widget.orgId,
file.path,
);
// For web, use the download URL with the ApiClient base URL (from DI)
final fullUrl = '${fileService.baseUrl}$downloadUrl';
// Trigger download via anchor element
final anchor = web.HTMLAnchorElement()
..href = fullUrl
..download = file.name;
anchor.click();
if (context.mounted) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text('Download ${file.name}')));
).showSnackBar(SnackBar(content: Text('Downloading ${file.name}')));
}
} catch (e) {
if (context.mounted) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text('Download failed: $e')));
}
}
}
void _sendFile(FileItem file) {
@@ -331,7 +377,7 @@ class _FileExplorerState extends State<FileExplorer> {
);
if (confirmed == true) {
context.read<FileBrowserBloc>().add(
DeleteFile(orgId: 'org1', path: file.path),
DeleteFile(orgId: widget.orgId, path: file.path),
);
}
}
@@ -530,10 +576,13 @@ class _FileExplorerState extends State<FileExplorer> {
if (file.type == FileType.folder) {
context.read<FileBrowserBloc>().add(NavigateToFolder(file.path));
} else {
final fileId = file.path.startsWith('/')
? file.path.substring(1)
: file.path;
context.go('/viewer/${widget.orgId}/$fileId');
if (file.id == null || file.id!.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Error: File ID is missing')),
);
return;
}
_showDocumentViewer(widget.orgId, file.id!);
}
},
child: Container(
@@ -632,7 +681,30 @@ class _FileExplorerState extends State<FileExplorer> {
@override
Widget build(BuildContext context) {
return BlocBuilder<FileBrowserBloc, FileBrowserState>(
return BlocListener<UploadBloc, UploadState>(
listener: (context, uploadState) {
if (uploadState is UploadInProgress) {
// Show error if any upload failed
for (final upload in uploadState.uploads) {
if (upload.error != null && upload.error!.isNotEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Upload failed: ${upload.error}')),
);
}
}
final hasCompleted = uploadState.uploads.any((u) => u.isCompleted);
if (hasCompleted) {
final fbState = context.read<FileBrowserBloc>().state;
String currentPath = '/';
if (fbState is DirectoryLoaded) currentPath = fbState.currentPath;
if (fbState is DirectoryEmpty) currentPath = fbState.currentPath;
context.read<FileBrowserBloc>().add(
LoadDirectory(orgId: widget.orgId, path: currentPath),
);
}
}
},
child: BlocBuilder<FileBrowserBloc, FileBrowserState>(
builder: (context, state) {
if (state is DirectoryLoading) {
return Center(
@@ -676,24 +748,27 @@ class _FileExplorerState extends State<FileExplorer> {
ModernGlassButton(
onPressed: () async {
final result = await FilePicker.platform
.pickFiles();
.pickFiles(withData: true);
if (result != null && result.files.isNotEmpty) {
final files = result.files
.map(
(file) => FileItem(
name: file.name,
path: '/${file.name}',
// Parent path only; server uses filename from multipart
path: state.currentPath,
type: FileType.file,
size: file.size,
lastModified: DateTime.now(),
localPath: file.path,
bytes: file.bytes,
),
)
.toList();
context.read<UploadBloc>().add(
StartUpload(
files: files,
targetPath: '/',
orgId: 'org1',
targetPath: state.currentPath,
orgId: widget.orgId,
),
);
}
@@ -709,13 +784,13 @@ class _FileExplorerState extends State<FileExplorer> {
const SizedBox(width: 16),
ModernGlassButton(
onPressed: () async {
final folderName = await _showCreateFolderDialog(
context,
);
if (folderName != null && folderName.isNotEmpty) {
final folderName =
await _showCreateFolderDialog(context);
if (folderName != null &&
folderName.isNotEmpty) {
context.read<FileBrowserBloc>().add(
CreateFolder(
orgId: 'org1',
orgId: widget.orgId,
parentPath: '/',
folderName: folderName,
),
@@ -736,6 +811,10 @@ class _FileExplorerState extends State<FileExplorer> {
return const SizedBox.shrink();
},
),
// Only show back button and "Empty Folder" text if not at root
if (state.currentPath != '/')
Column(
children: [
const SizedBox(height: 16),
Row(
children: [
@@ -747,9 +826,14 @@ class _FileExplorerState extends State<FileExplorer> {
splashColor: Colors.transparent,
highlightColor: Colors.transparent,
onPressed: () {
final parentPath = _getParentPath(state.currentPath);
final parentPath = _getParentPath(
state.currentPath,
);
context.read<FileBrowserBloc>().add(
LoadDirectory(orgId: 'org1', path: parentPath),
LoadDirectory(
orgId: widget.orgId,
path: parentPath,
),
);
},
),
@@ -761,6 +845,8 @@ class _FileExplorerState extends State<FileExplorer> {
),
],
),
],
),
);
}
@@ -798,7 +884,10 @@ class _FileExplorerState extends State<FileExplorer> {
state.currentPath,
);
context.read<FileBrowserBloc>().add(
LoadDirectory(orgId: 'org1', path: parentPath),
LoadDirectory(
orgId: widget.orgId,
path: parentPath,
),
);
},
),
@@ -839,24 +928,27 @@ class _FileExplorerState extends State<FileExplorer> {
ModernGlassButton(
onPressed: () async {
final result = await FilePicker.platform
.pickFiles();
.pickFiles(withData: true);
if (result != null && result.files.isNotEmpty) {
final files = result.files
.map(
(file) => FileItem(
name: file.name,
path: '/${file.name}',
// Parent path only; server uses filename from multipart
path: state.currentPath,
type: FileType.file,
size: file.size,
lastModified: DateTime.now(),
localPath: file.path,
bytes: file.bytes,
),
)
.toList();
context.read<UploadBloc>().add(
StartUpload(
files: files,
targetPath: '/',
orgId: 'org1',
targetPath: state.currentPath,
orgId: widget.orgId,
),
);
}
@@ -872,13 +964,13 @@ class _FileExplorerState extends State<FileExplorer> {
const SizedBox(width: 16),
ModernGlassButton(
onPressed: () async {
final folderName = await _showCreateFolderDialog(
context,
);
if (folderName != null && folderName.isNotEmpty) {
final folderName =
await _showCreateFolderDialog(context);
if (folderName != null &&
folderName.isNotEmpty) {
context.read<FileBrowserBloc>().add(
CreateFolder(
orgId: 'org1',
orgId: widget.orgId,
parentPath: state.currentPath,
folderName: folderName,
),
@@ -933,7 +1025,7 @@ class _FileExplorerState extends State<FileExplorer> {
onAcceptWithDetails: (draggedFile) {
context.read<FileBrowserBloc>().add(
MoveFile(
orgId: 'org1',
orgId: widget.orgId,
sourcePath: draggedFile.data.path,
targetPath: file.path,
),
@@ -1014,6 +1106,103 @@ class _FileExplorerState extends State<FileExplorer> {
return const SizedBox.shrink();
},
),
);
}
void _showDocumentViewer(String orgId, String fileId) {
showDialog(
context: context,
barrierDismissible: false,
barrierColor: Colors.transparent,
builder: (BuildContext context) {
return BackdropFilter(
filter: ImageFilter.blur(sigmaX: 10, sigmaY: 10),
child: Dialog(
backgroundColor: Colors.transparent,
insetPadding: const EdgeInsets.all(16),
child: Container(
constraints: BoxConstraints(
maxWidth: MediaQuery.of(context).size.width * 0.9,
maxHeight: MediaQuery.of(context).size.height * 0.9,
),
decoration: BoxDecoration(
color: AppTheme.primaryBackground,
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: AppTheme.accentColor.withValues(alpha: 0.3),
width: 2,
),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.5),
blurRadius: 20,
spreadRadius: 5,
),
],
),
child: ClipRRect(
borderRadius: BorderRadius.circular(14),
child: DocumentViewerModal(
orgId: orgId,
fileId: fileId,
onEdit: () {
Navigator.of(context).pop();
_showDocumentEditor(orgId, fileId);
},
onClose: () => Navigator.of(context).pop(),
),
),
),
),
);
},
);
}
void _showDocumentEditor(String orgId, String fileId) {
showDialog(
context: context,
barrierDismissible: false,
barrierColor: Colors.transparent,
builder: (BuildContext context) {
return BackdropFilter(
filter: ImageFilter.blur(sigmaX: 10, sigmaY: 10),
child: Dialog(
backgroundColor: Colors.transparent,
insetPadding: const EdgeInsets.all(16),
child: Container(
constraints: BoxConstraints(
maxWidth: MediaQuery.of(context).size.width * 0.9,
maxHeight: MediaQuery.of(context).size.height * 0.9,
),
decoration: BoxDecoration(
color: AppTheme.primaryBackground,
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: AppTheme.accentColor.withValues(alpha: 0.3),
width: 2,
),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.5),
blurRadius: 20,
spreadRadius: 5,
),
],
),
child: ClipRRect(
borderRadius: BorderRadius.circular(14),
child: EditorPageModal(
orgId: orgId,
fileId: fileId,
onClose: () => Navigator.of(context).pop(),
),
),
),
),
);
},
);
}
}

View File

@@ -8,10 +8,16 @@ import '../blocs/organization/organization_bloc.dart';
import '../blocs/organization/organization_event.dart';
import '../blocs/file_browser/file_browser_bloc.dart';
import '../blocs/file_browser/file_browser_event.dart';
import '../blocs/permission/permission_bloc.dart';
import '../blocs/upload/upload_bloc.dart';
import '../repositories/file_repository.dart';
import '../services/file_service.dart';
import '../services/org_api.dart';
import '../theme/app_theme.dart';
import '../theme/modern_glass_button.dart';
import 'login_form.dart' show LoginForm;
import 'file_explorer.dart';
import '../injection.dart';
class HomePage extends StatefulWidget {
const HomePage({super.key});
@@ -24,6 +30,13 @@ class _HomePageState extends State<HomePage> with TickerProviderStateMixin {
late String _selectedTab = 'Drive';
late AnimationController _animationController;
bool _isSignupMode = false;
bool _usePasswordMode = false;
// Shared blocs for the page lifecycle
late final PermissionBloc _permissionBloc;
late final FileBrowserBloc _fileBrowserBloc;
late final UploadBloc _uploadBloc;
late final OrganizationBloc _organizationBloc;
@override
void initState() {
@@ -32,11 +45,25 @@ class _HomePageState extends State<HomePage> with TickerProviderStateMixin {
duration: const Duration(milliseconds: 400),
vsync: this,
);
_permissionBloc = PermissionBloc();
_fileBrowserBloc = FileBrowserBloc(getIt<FileService>());
_uploadBloc = UploadBloc(getIt<FileRepository>());
_organizationBloc = OrganizationBloc(
_permissionBloc,
_fileBrowserBloc,
_uploadBloc,
getIt<OrgApi>(),
);
}
@override
void dispose() {
_animationController.dispose();
_organizationBloc.close();
_uploadBloc.close();
_fileBrowserBloc.close();
_permissionBloc.close();
super.dispose();
}
@@ -50,11 +77,15 @@ class _HomePageState extends State<HomePage> with TickerProviderStateMixin {
}
}
void _setPasswordMode(bool usePassword) {
setState(() => _usePasswordMode = usePassword);
}
void _showCreateOrgDialog(BuildContext context) {
final controller = TextEditingController();
showDialog(
context: context,
builder: (context) => Dialog(
builder: (dialogContext) => Dialog(
backgroundColor: Colors.transparent,
child: SizedBox(
width: 400,
@@ -80,6 +111,7 @@ class _HomePageState extends State<HomePage> with TickerProviderStateMixin {
TextField(
cursorColor: AppTheme.accentColor,
controller: controller,
autofocus: true,
style: TextStyle(color: AppTheme.primaryText),
decoration: InputDecoration(
labelText: 'Organization Name',
@@ -95,13 +127,23 @@ class _HomePageState extends State<HomePage> with TickerProviderStateMixin {
borderSide: BorderSide(color: AppTheme.accentColor),
),
),
onSubmitted: (value) {
final name = controller.text.trim();
if (name.isNotEmpty) {
// Use the parent context, not the dialog context
BlocProvider.of<OrganizationBloc>(
context,
).add(CreateOrganization(name));
Navigator.of(dialogContext).pop();
}
},
),
const SizedBox(height: 24),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
ModernGlassButton(
onPressed: () => Navigator.of(context).pop(),
onPressed: () => Navigator.of(dialogContext).pop(),
child: const Text('Cancel'),
),
const SizedBox(width: 16),
@@ -109,10 +151,11 @@ class _HomePageState extends State<HomePage> with TickerProviderStateMixin {
onPressed: () {
final name = controller.text.trim();
if (name.isNotEmpty) {
context.read<OrganizationBloc>().add(
CreateOrganization(name),
);
Navigator.of(context).pop();
// Use the parent context, not the dialog context
BlocProvider.of<OrganizationBloc>(
context,
).add(CreateOrganization(name));
Navigator.of(dialogContext).pop();
}
},
child: const Text('Create'),
@@ -132,37 +175,58 @@ class _HomePageState extends State<HomePage> with TickerProviderStateMixin {
Widget _buildOrgRow(BuildContext context) {
return BlocBuilder<OrganizationBloc, OrganizationState>(
builder: (context, state) {
List<Organization> orgs = [];
Organization? selectedOrg;
bool isLoading = false;
if (state is OrganizationLoaded) {
final orgs = state.organizations;
orgs = state.organizations;
selectedOrg = state.selectedOrg;
isLoading = state.isLoading;
} else if (state is OrganizationLoading) {
isLoading = true;
}
return Column(
children: [
Row(
children: [
// Personal workspace button (always show when logged in)
_buildPersonalButton(selectedOrg == null, () {
context.read<OrganizationBloc>().add(SelectOrganization(''));
}),
const SizedBox(width: 16),
// Organization tabs
...orgs.map(
(org) => Row(
children: [
_buildOrgButton(
org,
org.id == state.selectedOrg?.id,
() {
_buildOrgButton(org, org.id == selectedOrg?.id, () {
context.read<OrganizationBloc>().add(
SelectOrganization(org.id),
);
},
),
}),
const SizedBox(width: 16),
],
),
),
if (isLoading)
SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation<Color>(
AppTheme.accentColor,
),
),
)
else
_buildAddButton(() => _showCreateOrgDialog(context)),
],
),
const Divider(height: 1),
],
);
} else {
return const SizedBox.shrink();
}
},
);
}
@@ -171,6 +235,7 @@ class _HomePageState extends State<HomePage> with TickerProviderStateMixin {
final highlightColor = const Color.fromARGB(255, 100, 200, 255);
final defaultColor = AppTheme.secondaryText;
return TextButton(
style: ButtonStyle(splashFactory: NoSplash.splashFactory),
onPressed: onTap,
child: Text(
org.name,
@@ -182,18 +247,44 @@ class _HomePageState extends State<HomePage> with TickerProviderStateMixin {
);
}
Widget _buildPersonalButton(bool selected, VoidCallback onTap) {
final highlightColor = const Color.fromARGB(255, 100, 200, 255);
final defaultColor = AppTheme.secondaryText;
return TextButton(
style: ButtonStyle(splashFactory: NoSplash.splashFactory),
onPressed: onTap,
child: Text(
'Personal',
style: TextStyle(
color: selected ? highlightColor : defaultColor,
fontWeight: selected ? FontWeight.bold : FontWeight.normal,
),
),
);
}
Widget _buildAddButton(VoidCallback onTap) {
final defaultColor = AppTheme.secondaryText;
return TextButton(
style: ButtonStyle(splashFactory: NoSplash.splashFactory),
onPressed: onTap,
child: Text('+ Add Organization', style: TextStyle(color: defaultColor)),
);
}
Widget _buildDrive(OrganizationState state) {
return state is OrganizationLoaded && state.selectedOrg != null
? FileExplorer(orgId: state.selectedOrg!.id)
: const FileExplorer(orgId: 'org1');
Widget _buildDrive(OrganizationState state, AuthState authState) {
String orgId;
if (state is OrganizationLoaded && state.selectedOrg != null) {
// Show selected organization's files
orgId = state.selectedOrg!.id;
} else if (authState is AuthAuthenticated) {
// Show personal workspace - use empty string to trigger /user/files endpoint
orgId = '';
} else {
orgId = '';
}
return FileExplorer(orgId: orgId);
}
Widget _buildNavButton(String label, IconData icon, {bool isAvatar = false}) {
@@ -238,7 +329,14 @@ class _HomePageState extends State<HomePage> with TickerProviderStateMixin {
@override
Widget build(BuildContext context) {
return Scaffold(
return MultiBlocProvider(
providers: [
BlocProvider<PermissionBloc>.value(value: _permissionBloc),
BlocProvider<FileBrowserBloc>.value(value: _fileBrowserBloc),
BlocProvider<UploadBloc>.value(value: _uploadBloc),
BlocProvider<OrganizationBloc>.value(value: _organizationBloc),
],
child: Scaffold(
backgroundColor: AppTheme.primaryBackground,
body: Stack(
children: [
@@ -252,7 +350,11 @@ class _HomePageState extends State<HomePage> with TickerProviderStateMixin {
_animationController.reverse();
}
return Padding(
padding: const EdgeInsets.only(top: 42.0),
padding: EdgeInsets.only(
top: MediaQuery.of(context).size.width < 600
? 96.0
: 78.0,
),
child: AnimatedContainer(
duration: const Duration(milliseconds: 350),
curve: Curves.easeInOut,
@@ -261,7 +363,9 @@ class _HomePageState extends State<HomePage> with TickerProviderStateMixin {
: 340,
height: isLoggedIn
? MediaQuery.of(context).size.height * 0.9
: (_isSignupMode ? 400 : 280),
: (_isSignupMode
? 400
: (_usePasswordMode ? 350 : 280)),
child: ClipRRect(
borderRadius: BorderRadius.circular(16),
child: BackdropFilter(
@@ -276,12 +380,24 @@ class _HomePageState extends State<HomePage> with TickerProviderStateMixin {
OrganizationState
>(
listener: (context, state) {
if (state is OrganizationLoaded &&
state.selectedOrg != null) {
// Reload file browser when org changes
if (state is OrganizationLoaded) {
// Show errors if present
if (state.error != null &&
state.error!.isNotEmpty) {
ScaffoldMessenger.of(
context,
).showSnackBar(
SnackBar(
content: Text(state.error!),
),
);
}
final orgId =
state.selectedOrg?.id ?? '';
// Reload file browser when org changes (or when falling back to personal workspace)
context.read<FileBrowserBloc>().add(
LoadDirectory(
orgId: state.selectedOrg!.id,
orgId: orgId,
path: '/',
),
);
@@ -296,7 +412,11 @@ class _HomePageState extends State<HomePage> with TickerProviderStateMixin {
if (orgState
is OrganizationInitial) {
WidgetsBinding.instance
.addPostFrameCallback((_) {
.addPostFrameCallback((
_,
) {
// Kick off org fetch and immediately show personal workspace
// while org data loads.
context
.read<
OrganizationBloc
@@ -304,15 +424,26 @@ class _HomePageState extends State<HomePage> with TickerProviderStateMixin {
.add(
LoadOrganizations(),
);
context
.read<
FileBrowserBloc
>()
.add(
const LoadDirectory(
orgId: '',
path: '/',
),
);
});
}
return Column(
children: [
const SizedBox(height: 8),
const SizedBox(height: 16),
_buildOrgRow(context),
Expanded(
child: _buildDrive(
orgState,
state,
),
),
],
@@ -322,6 +453,7 @@ class _HomePageState extends State<HomePage> with TickerProviderStateMixin {
)
: LoginForm(
onSignupModeChanged: _setSignupMode,
onPasswordModeChanged: _setPasswordMode,
),
),
// Top-left radial glow - primary accent light
@@ -438,7 +570,9 @@ class _HomePageState extends State<HomePage> with TickerProviderStateMixin {
gradient: LinearGradient(
colors: [
Colors.white.withValues(alpha: 0),
Colors.white.withValues(alpha: 0.06),
Colors.white.withValues(
alpha: 0.06,
),
Colors.white.withValues(alpha: 0),
],
),
@@ -461,21 +595,27 @@ class _HomePageState extends State<HomePage> with TickerProviderStateMixin {
left: 0,
right: 0,
child: Center(
child: Text(
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: 48,
fontSize: fontSize,
color: AppTheme.primaryText,
decoration: TextDecoration.underline,
decorationColor: AppTheme.primaryText,
fontFeatures: const [FontFeature.slashedZero()],
),
);
},
),
),
),
Positioned(
top: 10,
top: MediaQuery.of(context).size.width < 600 ? 40 : 10,
right: 20,
child: BlocBuilder<AuthBloc, AuthState>(
builder: (context, state) {
@@ -498,7 +638,11 @@ class _HomePageState extends State<HomePage> with TickerProviderStateMixin {
const SizedBox(width: 16),
_buildNavButton('Add', Icons.add),
const SizedBox(width: 16),
_buildNavButton('Profile', Icons.person, isAvatar: true),
_buildNavButton(
'Profile',
Icons.person,
isAvatar: true,
),
],
),
);
@@ -507,6 +651,7 @@ class _HomePageState extends State<HomePage> with TickerProviderStateMixin {
),
],
),
),
);
}
}

View File

@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart';
import 'dart:math';
import 'dart:convert';
import '../blocs/auth/auth_bloc.dart';
import '../blocs/auth/auth_event.dart';
import '../blocs/auth/auth_state.dart';
@@ -12,8 +13,13 @@ import '../theme/modern_glass_button.dart';
class LoginForm extends StatefulWidget {
final ValueChanged<bool>? onSignupModeChanged;
final ValueChanged<bool>? onPasswordModeChanged;
const LoginForm({super.key, this.onSignupModeChanged});
const LoginForm({
super.key,
this.onSignupModeChanged,
this.onPasswordModeChanged,
});
@override
State<LoginForm> createState() => _LoginFormState();
@@ -34,10 +40,10 @@ class _LoginFormState extends State<LoginForm> {
super.dispose();
}
String _generateRandomHex(int bytes) {
String _generateRandomBase64(int bytes) {
final random = Random();
final values = List<int>.generate(bytes, (i) => random.nextInt(256));
return values.map((v) => v.toRadixString(16).padLeft(2, '0')).join();
return base64.encode(values);
}
Future<void> _handleAuthentication(
@@ -47,7 +53,7 @@ class _LoginFormState extends State<LoginForm> {
try {
final credentialId = state.credentialIds.isNotEmpty
? state.credentialIds.first
: _generateRandomHex(64);
: _generateRandomBase64(64);
if (context.mounted) {
context.read<AuthBloc>().add(
@@ -55,10 +61,10 @@ class _LoginFormState extends State<LoginForm> {
username: _usernameController.text,
challenge: state.challenge,
credentialId: credentialId,
authenticatorData: _generateRandomHex(37),
authenticatorData: _generateRandomBase64(37),
clientDataJSON:
'{"type":"webauthn.get","challenge":"${state.challenge}","origin":"https://b0esche.cloud"}',
signature: _generateRandomHex(128),
signature: _generateRandomBase64(128),
),
);
}
@@ -76,8 +82,8 @@ class _LoginFormState extends State<LoginForm> {
RegistrationChallengeReceived state,
) async {
try {
final credentialId = _generateRandomHex(64);
final publicKey = _generateRandomHex(91);
final credentialId = _generateRandomBase64(64);
final publicKey = _generateRandomBase64(91);
if (context.mounted) {
context.read<AuthBloc>().add(
@@ -88,7 +94,7 @@ class _LoginFormState extends State<LoginForm> {
publicKey: publicKey,
clientDataJSON:
'{"type":"webauthn.create","challenge":"${state.challenge}","origin":"https://b0esche.cloud"}',
attestationObject: _generateRandomHex(128),
attestationObject: _generateRandomBase64(128),
),
);
}
@@ -133,13 +139,16 @@ class _LoginFormState extends State<LoginForm> {
child: Center(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: AnimatedSize(
duration: const Duration(milliseconds: 300),
curve: Curves.easeInOut,
child: AnimatedSwitcher(
duration: const Duration(milliseconds: 400),
transitionBuilder: (child, animation) {
return FadeTransition(opacity: animation, child: child);
},
child: SingleChildScrollView(
key: ValueKey<bool>(_isSignup),
key: ValueKey('${_isSignup}_$_usePasskey'),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
@@ -154,7 +163,9 @@ class _LoginFormState extends State<LoginForm> {
const SizedBox(height: 24),
Container(
decoration: BoxDecoration(
color: AppTheme.primaryBackground.withValues(alpha: 0.5),
color: AppTheme.primaryBackground.withValues(
alpha: 0.5,
),
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: AppTheme.accentColor.withValues(alpha: 0.3),
@@ -347,8 +358,12 @@ class _LoginFormState extends State<LoginForm> {
mainAxisAlignment: MainAxisAlignment.center,
children: [
GestureDetector(
onTap: () =>
setState(() => _usePasskey = !_usePasskey),
onTap: () {
setState(() => _usePasskey = !_usePasskey);
widget.onPasswordModeChanged?.call(
!_usePasskey,
);
},
child: Text(
_usePasskey ? 'use password' : 'use passkey',
style: TextStyle(
@@ -392,6 +407,7 @@ class _LoginFormState extends State<LoginForm> {
),
),
),
),
);
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -13,7 +13,9 @@ class ApiClient {
BaseOptions(
baseUrl: baseUrl,
connectTimeout: const Duration(seconds: 10),
receiveTimeout: const Duration(seconds: 10),
receiveTimeout: const Duration(
seconds: 60,
), // Increased for file uploads and org operations
),
);
@@ -29,30 +31,22 @@ class ApiClient {
},
onError: (error, handler) async {
if (error.response?.statusCode == 401) {
// Try refresh
final refreshSuccess = await _tryRefreshToken();
if (refreshSuccess) {
// Retry the request
final token = _getCurrentToken();
if (token != null) {
error.requestOptions.headers['Authorization'] = 'Bearer $token';
try {
final response = await _dio.fetch(error.requestOptions);
return handler.resolve(response);
} catch (e) {
// If retry fails, proceed to error
}
}
}
// If refresh failed, logout
final path = error.requestOptions.path;
// Do not expire session for auth endpoints; show inline error instead
final isAuthEndpoint = path.startsWith('/auth/');
if (!isAuthEndpoint) {
// Session expired, trigger logout
_sessionBloc.add(SessionExpired());
}
}
return handler.next(error);
},
),
);
}
String get baseUrl => _dio.options.baseUrl;
String? _getCurrentToken() {
// Get from SessionBloc state
final state = _sessionBloc.state;
@@ -62,20 +56,6 @@ class ApiClient {
return null;
}
Future<bool> _tryRefreshToken() async {
try {
final response = await _dio.post('/auth/refresh');
if (response.statusCode == 200) {
final newToken = response.data['token'];
_sessionBloc.add(SessionRefreshed(newToken));
return true;
}
} catch (e) {
// Refresh failed
}
return false;
}
Future<T> get<T>(
String path, {
Map<String, dynamic>? queryParameters,
@@ -96,6 +76,7 @@ class ApiClient {
}) async {
try {
final response = await _dio.post(path, data: data);
return fromJson(response.data);
} on DioException catch (e) {
throw _handleError(e);
@@ -131,8 +112,17 @@ class ApiClient {
);
}
String code = data?['code'] ?? 'UNKNOWN';
String message = data?['message'] ?? 'Unknown error';
// Only try to extract code/message if data is a Map
String code = 'UNKNOWN';
String message = 'Unknown error';
if (data is Map<String, dynamic>) {
code = data['code'] ?? 'UNKNOWN';
message = data['message'] ?? 'Unknown error';
} else if (data != null) {
message = data.toString();
}
return ApiError(code: code, message: message, status: status);
}
}

View File

@@ -3,19 +3,40 @@ import '../models/viewer_session.dart';
import '../models/editor_session.dart';
import '../models/annotation.dart';
import 'api_client.dart';
import 'package:dio/dio.dart';
class FileService {
final ApiClient _apiClient;
FileService(this._apiClient);
String get baseUrl => _apiClient.baseUrl;
Future<List<FileItem>> getFiles(String orgId, String path) async {
if (path.isEmpty) {
throw Exception('Path cannot be empty');
}
final pathParam = {'path': path};
if (orgId.isEmpty) {
return await _apiClient.getList(
'/user/files',
queryParameters: pathParam,
fromJson: (data) => FileItem(
id: data['id'],
name: data['name'],
path: data['path'],
type: data['type'] == 'file' ? FileType.file : FileType.folder,
size: data['size'],
lastModified: DateTime.parse(data['lastModified']),
),
);
}
return await _apiClient.getList(
'/orgs/$orgId/files',
queryParameters: pathParam,
fromJson: (data) => FileItem(
id: data['id'],
name: data['name'],
path: data['path'],
type: data['type'] == 'file' ? FileType.file : FileType.folder,
@@ -30,11 +51,67 @@ class FileService {
}
Future<void> uploadFile(String orgId, FileItem file) async {
throw UnimplementedError();
// If bytes or localPath available, send multipart upload with field 'file'
final Map<String, dynamic> fields = {'path': file.path};
FormData formData;
if (file.bytes != null) {
formData = FormData.fromMap({
...fields,
'file': MultipartFile.fromBytes(file.bytes!, filename: file.name),
});
} else if (file.localPath != null) {
formData = FormData.fromMap({
...fields,
'file': MultipartFile.fromFile(file.localPath!, filename: file.name),
});
} else {
// Fallback to metadata-only create (folders or client that can't send file content)
final data = {
'name': file.name,
'path': file.path,
'type': file.type == FileType.file ? 'file' : 'folder',
'size': file.size,
};
if (orgId.isEmpty) {
await _apiClient.post('/user/files', data: data, fromJson: (d) => null);
return;
}
await _apiClient.post(
'/orgs/$orgId/files',
data: data,
fromJson: (d) => null,
);
return;
}
final endpoint = orgId.isEmpty ? '/user/files' : '/orgs/$orgId/files';
await _apiClient.post(endpoint, data: formData, fromJson: (d) => null);
}
Future<void> deleteFile(String orgId, String path) async {
throw UnimplementedError();
final data = {'path': path};
if (orgId.isEmpty) {
await _apiClient.post(
'/user/files/delete',
data: data,
fromJson: (d) => null,
);
return;
}
await _apiClient.post(
'/orgs/$orgId/files/delete',
data: data,
fromJson: (d) => null,
);
}
Future<String> getDownloadUrl(String orgId, String path) async {
// Return the download URL for the file
if (orgId.isEmpty) {
return '/user/files/download?path=${Uri.encodeComponent(path)}';
}
return '/orgs/$orgId/files/download?path=${Uri.encodeComponent(path)}';
}
Future<void> createFolder(
@@ -42,7 +119,41 @@ class FileService {
String parentPath,
String folderName,
) async {
throw UnimplementedError();
// Normalize folder name to avoid accidental leading slashes creating double-slash paths
final normalizedName = folderName
.replaceAll(RegExp(r'^/+'), '')
.replaceAll(RegExp(r'/+$'), '');
if (normalizedName.isEmpty) {
throw Exception('Folder name cannot be empty');
}
// Construct proper path: /parent/folder or /folder for root
String path;
if (parentPath == '/') {
path = '/$normalizedName';
} else {
// Ensure parentPath doesn't end with / before appending
final cleanParent = parentPath.endsWith('/')
? parentPath.substring(0, parentPath.length - 1)
: parentPath;
path = '$cleanParent/$normalizedName';
}
final data = {
'name': normalizedName,
'path': path,
'type': 'folder',
'size': 0,
};
if (orgId.isEmpty) {
await _apiClient.post('/user/files', data: data, fromJson: (d) => null);
return;
}
await _apiClient.post(
'/orgs/$orgId/files',
data: data,
fromJson: (d) => null,
);
}
Future<void> moveFile(
@@ -50,7 +161,14 @@ class FileService {
String sourcePath,
String targetPath,
) async {
throw UnimplementedError();
final endpoint = orgId.isEmpty
? '/user/files/move'
: '/orgs/$orgId/files/move';
await _apiClient.post(
endpoint,
data: {'sourcePath': sourcePath, 'targetPath': targetPath},
fromJson: (d) => null,
);
}
Future<void> renameFile(String orgId, String path, String newName) async {
@@ -65,11 +183,14 @@ class FileService {
String orgId,
String fileId,
) async {
if (orgId.isEmpty || fileId.isEmpty) {
throw Exception('OrgId and fileId cannot be empty');
if (fileId.isEmpty) {
throw Exception('fileId cannot be empty');
}
final path = orgId.isEmpty
? '/user/files/$fileId/view'
: '/orgs/$orgId/files/$fileId/view';
return await _apiClient.get(
'/orgs/$orgId/files/$fileId/view',
path,
fromJson: (data) => ViewerSession.fromJson(data),
);
}

View File

@@ -1,5 +1,6 @@
import '../blocs/organization/organization_state.dart';
import 'api_client.dart';
import 'dart:developer' as developer;
class OrgApi {
final ApiClient _apiClient;
@@ -14,10 +15,18 @@ class OrgApi {
}
Future<Organization> createOrganization(String name) async {
return await _apiClient.post(
developer.log('POST /orgs with payload: {"name": "$name"}', name: 'OrgApi');
try {
final result = await _apiClient.post(
'/orgs',
data: {'name': name},
fromJson: (data) => Organization.fromJson(data),
);
return result;
} catch (e) {
rethrow;
}
}
}

View File

@@ -553,7 +553,7 @@ packages:
source: hosted
version: "2.0.1"
http:
dependency: transitive
dependency: "direct main"
description:
name: http
sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412"
@@ -1422,7 +1422,7 @@ packages:
source: hosted
version: "1.2.0"
web:
dependency: transitive
dependency: "direct main"
description:
name: web
sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a"

View File

@@ -55,6 +55,8 @@ dependencies:
infinite_scroll_pagination: ^5.1.1
collection: ^1.18.0
syncfusion_flutter_pdfviewer: ^31.1.21
web: ^1.1.0
http: ^1.2.0
dev_dependencies:
flutter_test:

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 917 B

After

Width:  |  Height:  |  Size: 1008 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.1 KiB

After

Width:  |  Height:  |  Size: 147 KiB

View File

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

32
go_cloud/Dockerfile Normal file
View File

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

Binary file not shown.

Binary file not shown.

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,7 @@
package config
import (
"fmt"
"os"
)
@@ -12,10 +13,15 @@ type Config struct {
OIDCClientID string
OIDCClientSecret string
JWTSecret string
NextcloudURL string
NextcloudUser string
NextcloudPass string
NextcloudBase string
AllowedOrigins string
}
func Load() *Config {
return &Config{
cfg := &Config{
ServerAddr: getEnv("SERVER_ADDR", ":8080"),
DatabaseURL: os.Getenv("DATABASE_URL"),
OIDCIssuerURL: os.Getenv("OIDC_ISSUER_URL"),
@@ -23,7 +29,14 @@ func Load() *Config {
OIDCClientID: os.Getenv("OIDC_CLIENT_ID"),
OIDCClientSecret: os.Getenv("OIDC_CLIENT_SECRET"),
JWTSecret: os.Getenv("JWT_SECRET"),
NextcloudURL: os.Getenv("NEXTCLOUD_URL"),
NextcloudUser: os.Getenv("NEXTCLOUD_USER"),
NextcloudPass: os.Getenv("NEXTCLOUD_PASSWORD"),
NextcloudBase: getEnv("NEXTCLOUD_BASEPATH", "/"),
AllowedOrigins: getEnv("ALLOWED_ORIGINS", "https://b0esche.cloud,https://www.b0esche.cloud,https://*.b0esche.cloud,http://localhost:8080"),
}
fmt.Printf("[CONFIG] Nextcloud URL: %q, User: %q, BasePath: %q\n", cfg.NextcloudURL, cfg.NextcloudUser, cfg.NextcloudBase)
return cfg
}
func getEnv(key, defaultVal string) string {

View File

@@ -3,6 +3,9 @@ package database
import (
"context"
"database/sql"
"database/sql/driver"
"encoding/json"
"log"
"time"
"github.com/google/uuid"
@@ -16,6 +19,49 @@ func New(db *sql.DB) *DB {
return &DB{DB: db}
}
// StringArray handles nullable string arrays from PostgreSQL
type StringArray []string
// Scan handles NULL values properly
func (sa *StringArray) Scan(value interface{}) error {
if value == nil {
*sa = StringArray{}
return nil
}
// Handle byte slice from PostgreSQL array
if bytes, ok := value.([]byte); ok {
var arr []string
if err := json.Unmarshal(bytes, &arr); err != nil {
// If JSON parse fails, try as raw string
*sa = StringArray{string(bytes)}
return nil
}
*sa = StringArray(arr)
return nil
}
// Handle string directly
if str, ok := value.(string); ok {
if str == "" {
*sa = StringArray{}
return nil
}
*sa = StringArray{str}
return nil
}
return nil
}
// Value implements the driver.Valuer interface
func (sa StringArray) Value() (driver.Value, error) {
if len(sa) == 0 {
return nil, nil
}
return json.Marshal(sa)
}
type User struct {
ID uuid.UUID
Email string
@@ -34,7 +80,7 @@ type Credential struct {
SignCount int64
CreatedAt time.Time
LastUsedAt *time.Time
Transports []string
Transports StringArray
}
type AuthChallenge struct {
@@ -55,10 +101,11 @@ type Session struct {
}
type Organization struct {
ID uuid.UUID
Name string
Slug string
CreatedAt time.Time
ID uuid.UUID `json:"id"`
OwnerID uuid.UUID `json:"ownerId"`
Name string `json:"name"`
Slug string `json:"slug"`
CreatedAt time.Time `json:"createdAt"`
}
type Membership struct {
@@ -78,6 +125,20 @@ type Activity struct {
Timestamp time.Time
}
type File struct {
ID uuid.UUID
OrgID *uuid.UUID
UserID *uuid.UUID
Name string
Path string
Type string
Size int64
LastModified time.Time
CreatedAt time.Time
ModifiedBy *uuid.UUID
ModifiedByName string
}
func (db *DB) GetOrCreateUser(ctx context.Context, sub, email, name string) (*User, error) {
var user User
err := db.QueryRowContext(ctx, `
@@ -120,9 +181,18 @@ func (db *DB) GetSession(ctx context.Context, sessionID uuid.UUID) (*Session, er
return &session, nil
}
func (db *DB) RevokeSession(ctx context.Context, sessionID uuid.UUID) error {
_, err := db.ExecContext(ctx, `
UPDATE sessions
SET revoked_at = NOW()
WHERE id = $1 AND revoked_at IS NULL
`, sessionID)
return err
}
func (db *DB) GetUserOrganizations(ctx context.Context, userID uuid.UUID) ([]Organization, error) {
rows, err := db.QueryContext(ctx, `
SELECT o.id, o.name, o.slug, o.created_at
SELECT o.id, o.owner_id, o.name, o.slug, o.created_at
FROM organizations o
JOIN memberships m ON o.id = m.org_id
WHERE m.user_id = $1
@@ -135,7 +205,7 @@ func (db *DB) GetUserOrganizations(ctx context.Context, userID uuid.UUID) ([]Org
var orgs []Organization
for rows.Next() {
var org Organization
if err := rows.Scan(&org.ID, &org.Name, &org.Slug, &org.CreatedAt); err != nil {
if err := rows.Scan(&org.ID, &org.OwnerID, &org.Name, &org.Slug, &org.CreatedAt); err != nil {
return nil, err
}
orgs = append(orgs, org)
@@ -156,13 +226,18 @@ func (db *DB) GetUserMembership(ctx context.Context, userID, orgID uuid.UUID) (*
return &membership, nil
}
func (db *DB) CreateOrg(ctx context.Context, name, slug string) (*Organization, error) {
// GetOrgMember is an alias for GetUserMembership - checks if user is a member of an org
func (db *DB) GetOrgMember(ctx context.Context, orgID, userID uuid.UUID) (*Membership, error) {
return db.GetUserMembership(ctx, userID, orgID)
}
func (db *DB) CreateOrg(ctx context.Context, ownerID uuid.UUID, name, slug string) (*Organization, error) {
var org Organization
err := db.QueryRowContext(ctx, `
INSERT INTO organizations (name, slug)
VALUES ($1, $2)
RETURNING id, name, slug, created_at
`, name, slug).Scan(&org.ID, &org.Name, &org.Slug, &org.CreatedAt)
INSERT INTO organizations (owner_id, name, slug)
VALUES ($1, $2, $3)
RETURNING id, owner_id, name, slug, created_at
`, ownerID, name, slug).Scan(&org.ID, &org.OwnerID, &org.Name, &org.Slug, &org.CreatedAt)
if err != nil {
return nil, err
}
@@ -233,6 +308,228 @@ func (db *DB) GetOrgMembers(ctx context.Context, orgID uuid.UUID) ([]Membership,
return memberships, rows.Err()
}
// GetOrgFiles returns files for a given organization (top-level folder listing)
func (db *DB) GetOrgFiles(ctx context.Context, orgID uuid.UUID, userID uuid.UUID, path string, q string, page, pageSize int) ([]File, error) {
if page <= 0 {
page = 1
}
if pageSize <= 0 {
pageSize = 100
}
offset := (page - 1) * pageSize
orgIDStr := orgID.String()
userIDStr := userID.String()
log.Printf("[DATA-ISOLATION] stage=before, action=list, orgId=%s, userId=%s, fileCount=0, path=%s", orgIDStr, userIDStr, path)
// Basic search and pagination. Returns only direct children of the given path.
// For root ("/"), we want files where path doesn't contain "/" after the first character.
// For subdirs, we want files where path starts with parent but has no additional "/" after parent.
rows, err := db.QueryContext(ctx, `
SELECT f.id, f.org_id::text, f.user_id::text, f.name, f.path, f.type, f.size, f.last_modified, f.created_at
FROM files f
WHERE f.org_id = $1
AND EXISTS (
SELECT 1
FROM memberships m
WHERE m.org_id = $1 AND m.user_id = $2
)
AND f.path != $3
AND (
($3 = '/' AND f.path LIKE '/%' AND f.path NOT LIKE '/%/%')
OR ($3 != '/' AND f.path LIKE $3 || '/%' AND f.path NOT LIKE $3 || '/%/%')
)
AND ($4 = '' OR f.name ILIKE '%' || $4 || '%')
ORDER BY CASE WHEN f.type = 'folder' THEN 0 ELSE 1 END, f.name
LIMIT $5 OFFSET $6
`, orgID, userID, path, q, pageSize, offset)
if err != nil {
return nil, err
}
defer rows.Close()
var files []File
for rows.Next() {
var f File
var orgNull sql.NullString
var userNull sql.NullString
if err := rows.Scan(&f.ID, &orgNull, &userNull, &f.Name, &f.Path, &f.Type, &f.Size, &f.LastModified, &f.CreatedAt); err != nil {
return nil, err
}
if orgNull.Valid {
oid, _ := uuid.Parse(orgNull.String)
f.OrgID = &oid
}
if userNull.Valid {
uid, _ := uuid.Parse(userNull.String)
f.UserID = &uid
}
files = append(files, f)
}
err = rows.Err()
if err == nil {
log.Printf("[DATA-ISOLATION] stage=after, action=list, orgId=%s, userId=%s, fileCount=%d, path=%s", orgIDStr, userIDStr, len(files), path)
}
return files, err
}
// GetUserFiles returns files for a user's personal workspace at a given path
func (db *DB) GetUserFiles(ctx context.Context, userID uuid.UUID, path string, q string, page, pageSize int) ([]File, error) {
if page <= 0 {
page = 1
}
if pageSize <= 0 {
pageSize = 100
}
offset := (page - 1) * pageSize
// Return only direct children of the given path
log.Printf("[DATA-ISOLATION] stage=before, action=list, orgId=, userId=%s, fileCount=0, path=%s", userID.String(), path)
rows, err := db.QueryContext(ctx, `
SELECT id, org_id::text, user_id::text, name, path, type, size, last_modified, created_at
FROM files
WHERE user_id = $1
AND org_id IS NULL
AND path != $2
AND (
($2 = '/' AND path LIKE '/%' AND path NOT LIKE '/%/%')
OR ($2 != '/' AND path LIKE $2 || '/%' AND path NOT LIKE $2 || '/%/%')
)
AND ($3 = '' OR name ILIKE '%' || $3 || '%')
ORDER BY CASE WHEN type = 'folder' THEN 0 ELSE 1 END, name
LIMIT $4 OFFSET $5
`, userID, path, q, pageSize, offset)
if err != nil {
return nil, err
}
defer rows.Close()
var files []File
for rows.Next() {
var f File
var orgNull sql.NullString
var userNull sql.NullString
if err := rows.Scan(&f.ID, &orgNull, &userNull, &f.Name, &f.Path, &f.Type, &f.Size, &f.LastModified, &f.CreatedAt); err != nil {
return nil, err
}
if orgNull.Valid {
oid, _ := uuid.Parse(orgNull.String)
f.OrgID = &oid
}
if userNull.Valid {
uid, _ := uuid.Parse(userNull.String)
f.UserID = &uid
}
files = append(files, f)
}
err = rows.Err()
if err == nil {
log.Printf("[DATA-ISOLATION] stage=after, action=list, orgId=, userId=%s, fileCount=%d, path=%s", userID.String(), len(files), path)
}
return files, err
}
// CreateFile inserts a file or folder record. orgID or userID may be nil.
func (db *DB) CreateFile(ctx context.Context, orgID *uuid.UUID, userID *uuid.UUID, name, path, fileType string, size int64) (*File, error) {
var f File
var orgIDVal interface{}
var userIDVal interface{}
orgIDStr := ""
userIDStr := ""
if orgID != nil {
orgIDVal = *orgID
orgIDStr = orgID.String()
} else {
orgIDVal = nil
}
if userID != nil {
userIDVal = *userID
userIDStr = userID.String()
} else {
userIDVal = nil
}
log.Printf("[DATA-ISOLATION] stage=before, action=create, orgId=%s, userId=%s, fileCount=1, path=%s", orgIDStr, userIDStr, path)
err := db.QueryRowContext(ctx, `
INSERT INTO files (org_id, user_id, name, path, type, size)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING id, org_id::text, user_id::text, name, path, type, size, last_modified, created_at
`, orgIDVal, userIDVal, name, path, fileType, size).Scan(&f.ID, new(sql.NullString), new(sql.NullString), &f.Name, &f.Path, &f.Type, &f.Size, &f.LastModified, &f.CreatedAt)
if err != nil {
return nil, err
}
log.Printf("[DATA-ISOLATION] stage=after, action=create, orgId=%s, userId=%s, fileCount=1, path=%s", orgIDStr, userIDStr, f.Path)
return &f, nil
}
// GetFileByID retrieves a file by its ID
func (db *DB) GetFileByID(ctx context.Context, fileID uuid.UUID) (*File, error) {
var f File
var orgNull sql.NullString
var userNull sql.NullString
var modifiedByNull sql.NullString
var modifiedByNameNull sql.NullString
err := db.QueryRowContext(ctx, `
SELECT f.id, f.org_id::text, f.user_id::text, f.name, f.path, f.type, f.size, f.last_modified, f.created_at,
f.modified_by::text, u.display_name
FROM files f
LEFT JOIN users u ON f.modified_by = u.id
WHERE f.id = $1
`, fileID).Scan(&f.ID, &orgNull, &userNull, &f.Name, &f.Path, &f.Type, &f.Size, &f.LastModified, &f.CreatedAt,
&modifiedByNull, &modifiedByNameNull)
if err != nil {
return nil, err
}
if orgNull.Valid {
oid, _ := uuid.Parse(orgNull.String)
f.OrgID = &oid
}
if userNull.Valid {
uid, _ := uuid.Parse(userNull.String)
f.UserID = &uid
}
if modifiedByNull.Valid {
mid, _ := uuid.Parse(modifiedByNull.String)
f.ModifiedBy = &mid
}
if modifiedByNameNull.Valid {
f.ModifiedByName = modifiedByNameNull.String
}
return &f, nil
}
// UpdateFileSize updates the size, modification time, and modifier of a file
func (db *DB) UpdateFileSize(ctx context.Context, fileID uuid.UUID, size int64, modifiedBy *uuid.UUID) error {
_, err := db.ExecContext(ctx, `
UPDATE files
SET size = $1, last_modified = NOW(), modified_by = $3
WHERE id = $2
`, size, fileID, modifiedBy)
return err
}
// DeleteFileByPath removes a file or folder matching path for a given org or user
func (db *DB) DeleteFileByPath(ctx context.Context, orgID *uuid.UUID, userID *uuid.UUID, path string) error {
var res sql.Result
var err error
if orgID != nil {
res, err = db.ExecContext(ctx, `DELETE FROM files WHERE org_id = $1 AND path = $2`, *orgID, path)
} else if userID != nil {
res, err = db.ExecContext(ctx, `DELETE FROM files WHERE user_id = $1 AND path = $2`, *userID, path)
} else {
return nil
}
if err != nil {
return err
}
_, _ = res.RowsAffected()
return nil
}
func (db *DB) UpdateMemberRole(ctx context.Context, orgID, userID uuid.UUID, role string) error {
_, err := db.ExecContext(ctx, `
UPDATE memberships
@@ -391,3 +688,5 @@ func (db *DB) MarkChallengeUsed(ctx context.Context, challenge []byte) error {
`, challenge)
return err
}
// UpdateFileSize updates the size and last_modified timestamp of a file

View File

@@ -6,7 +6,7 @@ import (
"net/http"
"os"
"github.com/go-chi/chi/v5/middleware"
chimiddleware "github.com/go-chi/chi/v5/middleware"
"github.com/google/uuid"
)
@@ -40,7 +40,7 @@ func WriteError(w http.ResponseWriter, code ErrorCode, message string, status in
// GetRequestID extracts the request ID from the request context
func GetRequestID(r *http.Request) string {
if reqID := middleware.GetReqID(r.Context()); reqID != "" {
if reqID := chimiddleware.GetReqID(r.Context()); reqID != "" {
return reqID
}
return "unknown"
@@ -48,10 +48,10 @@ func GetRequestID(r *http.Request) string {
// GetUserID extracts user ID from context if available
func GetUserID(r *http.Request) string {
if userID := r.Context().Value("user"); userID != nil {
if uid, ok := userID.(string); ok {
return uid
}
// Use type contextKey matching middleware package
type contextKey string
if userID, ok := r.Context().Value(contextKey("user")).(string); ok && userID != "" {
return userID
}
return ""
}

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -2,7 +2,9 @@ package middleware
import (
"context"
"fmt"
"net/http"
"regexp"
"strings"
"go.b0esche.cloud/backend/internal/audit"
@@ -21,6 +23,103 @@ var RequestID = middleware.RequestID
var Logger = middleware.Logger
var Recoverer = middleware.Recoverer
// CORS middleware - accepts allowedOrigins comma-separated string
func CORS(allowedOrigins string) func(http.Handler) http.Handler {
allowedList, allowAll := compileAllowedOrigins(allowedOrigins)
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
origin := r.Header.Get("Origin")
if origin != "" && isOriginAllowed(origin, allowedList) {
w.Header().Set("Access-Control-Allow-Origin", origin)
w.Header().Add("Vary", "Origin")
w.Header().Set("Access-Control-Allow-Credentials", "true")
} else if allowAll {
w.Header().Set("Access-Control-Allow-Origin", "*")
}
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS")
allowHeaders := []string{"Content-Type", "Authorization", "Range", "Accept", "Origin", "X-Requested-With"}
if reqHeaders := r.Header.Get("Access-Control-Request-Headers"); reqHeaders != "" {
allowHeaders = append(allowHeaders, reqHeaders)
}
w.Header().Set("Access-Control-Allow-Headers", strings.Join(uniqueStrings(allowHeaders), ", "))
w.Header().Set("Access-Control-Expose-Headers", "Content-Length, Content-Type, Content-Disposition")
w.Header().Set("Access-Control-Max-Age", "3600")
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusOK)
return
}
next.ServeHTTP(w, r)
})
}
}
func compileAllowedOrigins(origins string) ([]string, bool) {
var allowed []string
allowAll := false
for _, origin := range strings.Split(origins, ",") {
trimmed := strings.TrimSpace(origin)
if trimmed == "" {
continue
}
if trimmed == "*" {
allowAll = true
}
allowed = append(allowed, trimmed)
}
if len(allowed) == 0 && !allowAll {
allowAll = true
}
return allowed, allowAll
}
func uniqueStrings(values []string) []string {
seen := make(map[string]struct{})
var out []string
for _, v := range values {
trimmed := strings.TrimSpace(v)
if trimmed == "" {
continue
}
key := strings.ToLower(trimmed)
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
out = append(out, trimmed)
}
return out
}
func isOriginAllowed(origin string, allowed []string) bool {
if origin == "" {
return false
}
for _, pattern := range allowed {
if originMatches(origin, pattern) {
return true
}
}
return false
}
func originMatches(origin, pattern string) bool {
if pattern == "*" {
return true
}
if !strings.Contains(pattern, "*") {
return strings.EqualFold(origin, pattern)
}
regexPattern := "(?i)^" + regexp.QuoteMeta(pattern) + "$"
regexPattern = strings.ReplaceAll(regexPattern, "\\*", ".*")
matched, err := regexp.MatchString(regexPattern, origin)
return err == nil && matched
}
// TODO: Implement rate limiter
var RateLimit = func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -29,33 +128,69 @@ var RateLimit = func(next http.Handler) http.Handler {
})
}
type contextKey string
type ContextKey string
const (
userKey contextKey = "user"
sessionKey contextKey = "session"
orgKey contextKey = "org"
UserKey ContextKey = "user"
SessionKey ContextKey = "session"
TokenKey ContextKey = "token"
OrgKey ContextKey = "org"
)
// GetUserID retrieves the user ID from the request context
func GetUserID(ctx context.Context) (string, bool) {
userID, ok := ctx.Value(UserKey).(string)
return userID, ok
}
// GetSession retrieves the session from the request context
func GetSession(ctx context.Context) (*database.Session, bool) {
session, ok := ctx.Value(SessionKey).(*database.Session)
return session, ok
}
// GetToken retrieves the JWT token from the request context
func GetToken(ctx context.Context) (string, bool) {
token, ok := ctx.Value(TokenKey).(string)
return token, ok
}
// Auth middleware
func Auth(jwtManager *jwt.Manager, db *database.DB) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
authHeader := r.Header.Get("Authorization")
if !strings.HasPrefix(authHeader, "Bearer ") {
var tokenString string
var tokenSource string
if strings.HasPrefix(authHeader, "Bearer ") {
tokenString = strings.TrimPrefix(authHeader, "Bearer ")
tokenSource = "header"
} else {
// Fallback to query parameter token (for viewers that cannot set headers)
qToken := r.URL.Query().Get("token")
if qToken == "" {
fmt.Printf("[AUTH-TOKEN] source=none, path=%s, statusCode=401\n", r.RequestURI)
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
tokenString = qToken
tokenSource = "query"
}
fmt.Printf("[AUTH-TOKEN] source=%s, path=%s\n", tokenSource, r.RequestURI)
tokenString := strings.TrimPrefix(authHeader, "Bearer ")
claims, session, err := jwtManager.ValidateWithSession(r.Context(), tokenString, db)
if err != nil {
fmt.Printf("[AUTH-TOKEN] validation_failed, source=%s, path=%s, error=%v\n", tokenSource, r.RequestURI, err)
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
ctx := context.WithValue(r.Context(), userKey, claims.UserID)
ctx = context.WithValue(ctx, sessionKey, session)
fmt.Printf("[AUTH-TOKEN] valid, source=%s, userId=%s\n", tokenSource, claims.UserID)
ctx := context.WithValue(r.Context(), UserKey, claims.UserID)
ctx = context.WithValue(ctx, SessionKey, session)
ctx = context.WithValue(ctx, TokenKey, tokenString)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
@@ -65,7 +200,7 @@ func Auth(jwtManager *jwt.Manager, db *database.DB) func(http.Handler) http.Hand
func Org(db *database.DB, auditLogger *audit.Logger) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
userIDStr := r.Context().Value(userKey).(string)
userIDStr := r.Context().Value(UserKey).(string)
userID, _ := uuid.Parse(userIDStr)
orgIDStr := r.Header.Get("X-Org-ID")
@@ -104,7 +239,7 @@ func Org(db *database.DB, auditLogger *audit.Logger) func(http.Handler) http.Han
return
}
ctx := context.WithValue(r.Context(), orgKey, orgID)
ctx := context.WithValue(r.Context(), OrgKey, orgID)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
@@ -114,9 +249,9 @@ func Org(db *database.DB, auditLogger *audit.Logger) func(http.Handler) http.Han
func Permission(db *database.DB, auditLogger *audit.Logger, perm permission.Permission) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
userIDStr := r.Context().Value(userKey).(string)
userIDStr := r.Context().Value(UserKey).(string)
userID, _ := uuid.Parse(userIDStr)
orgID := r.Context().Value(orgKey).(uuid.UUID)
orgID := r.Context().Value(OrgKey).(uuid.UUID)
hasPerm, err := permission.HasPermission(r.Context(), db, userID, orgID, perm)
if err != nil || !hasPerm {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,50 @@
#!/bin/bash
# Database Migration Runner for b0esche.cloud
# Runs all SQL migrations in order
set -e
# Check for required environment variable
if [ -z "$DATABASE_URL" ]; then
echo "ERROR: DATABASE_URL environment variable not set"
echo "Example: DATABASE_URL=postgres://user:pass@localhost:5432/dbname"
exit 1
fi
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
echo "=== b0esche.cloud Database Migrations ==="
echo "Database: $DATABASE_URL"
echo
# Function to run a single migration
run_migration() {
local file=$1
echo "Running: $(basename $file)"
psql "$DATABASE_URL" -f "$file" -v ON_ERROR_STOP=1
if [ $? -eq 0 ]; then
echo "✓ Success"
else
echo "✗ Failed"
exit 1
fi
}
# Run migrations in order
echo "Step 1/4: Initial schema..."
run_migration "$SCRIPT_DIR/0001_initial.sql"
echo
echo "Step 2/4: Passkeys and authentication..."
run_migration "$SCRIPT_DIR/0002_passkeys.sql"
echo
echo "Step 3/4: Files and storage..."
run_migration "$SCRIPT_DIR/0003_files.sql"
echo
echo "Step 4/4: Organization ownership and slug scope..."
run_migration "$SCRIPT_DIR/0004_org_owner_slug.sql"
echo
echo "=== All migrations completed successfully! ==="

View File

@@ -27,12 +27,16 @@ func NewManager(secret string) *Manager {
}
func (m *Manager) Generate(userID string, orgIDs []string, sessionID string) (string, error) {
return m.GenerateWithDuration(userID, orgIDs, sessionID, 15*time.Minute)
}
func (m *Manager) GenerateWithDuration(userID string, orgIDs []string, sessionID string, duration time.Duration) (string, error) {
claims := Claims{
UserID: userID,
OrgIDs: orgIDs,
SessionID: sessionID,
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(15 * time.Minute)),
ExpiresAt: jwt.NewNumericDate(time.Now().Add(duration)),
IssuedAt: jwt.NewNumericDate(time.Now()),
},
}