Compare commits

...

134 Commits

Author SHA1 Message Date
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
58 changed files with 5824 additions and 1350 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 'package:bloc/bloc.dart';
import '../session/session_bloc.dart'; import '../session/session_bloc.dart';
import '../session/session_event.dart'; import '../session/session_event.dart';
import '../session/session_state.dart';
import 'auth_event.dart'; import 'auth_event.dart';
import 'auth_state.dart'; import 'auth_state.dart';
import '../../services/api_client.dart'; import '../../services/api_client.dart';
@@ -252,7 +253,22 @@ class AuthBloc extends Bloc<AuthEvent, AuthState> {
CheckAuthRequested event, CheckAuthRequested event,
Emitter<AuthState> emit, Emitter<AuthState> emit,
) async { ) async {
// Check if token is valid in SessionBloc // Check if session is active from persistent storage
emit(AuthUnauthenticated()); 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,7 @@ class DocumentViewerBloc
DocumentViewerReady( DocumentViewerReady(
viewUrl: session.viewUrl, viewUrl: session.viewUrl,
caps: session.capabilities, caps: session.capabilities,
token: session.token,
), ),
); );
_expiryTimer = Timer( _expiryTimer = Timer(

View File

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

View File

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

View File

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

View File

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

View File

@@ -62,7 +62,14 @@ class CreateFolder extends FileBrowserEvent {
List<Object> get props => [orgId, parentPath, folderName]; 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 { class LoadPage extends FileBrowserEvent {
final int page; final int page;

View File

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

View File

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

View File

@@ -1,42 +1,104 @@
import 'dart:async'; import 'dart:async';
import 'package:bloc/bloc.dart'; import 'package:bloc/bloc.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'session_event.dart'; import 'session_event.dart';
import 'session_state.dart'; import 'session_state.dart';
class SessionBloc extends Bloc<SessionEvent, SessionState> { class SessionBloc extends Bloc<SessionEvent, SessionState> {
Timer? _expiryTimer; Timer? _expiryTimer;
static const String _tokenKey = 'auth_token';
static const String _expiryKey = 'auth_expiry';
SessionBloc() : super(SessionInitial()) { SessionBloc() : super(SessionInitial()) {
on<SessionStarted>(_onSessionStarted); on<SessionStarted>(_onSessionStarted);
on<SessionExpired>(_onSessionExpired); on<SessionExpired>(_onSessionExpired);
on<SessionRefreshed>(_onSessionRefreshed); on<SessionRefreshed>(_onSessionRefreshed);
on<SessionEnded>(_onSessionEnded); 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( final expiresAt = DateTime.now().add(
const Duration(minutes: 15), const Duration(minutes: 15),
); // Match Go ); // 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)); emit(SessionActive(token: event.token, expiresAt: expiresAt));
_startExpiryTimer(expiresAt); _startExpiryTimer(expiresAt);
} }
void _onSessionExpired(SessionExpired event, Emitter<SessionState> emit) { void _onSessionExpired(SessionExpired event, Emitter<SessionState> emit) {
_expiryTimer?.cancel(); _expiryTimer?.cancel();
_clearStoredSession();
emit(SessionExpiredState()); 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)); 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)); emit(SessionActive(token: event.newToken, expiresAt: expiresAt));
_startExpiryTimer(expiresAt); _startExpiryTimer(expiresAt);
} }
void _onSessionEnded(SessionEnded event, Emitter<SessionState> emit) { void _onSessionEnded(SessionEnded event, Emitter<SessionState> emit) {
_expiryTimer?.cancel(); _expiryTimer?.cancel();
_clearStoredSession();
emit(SessionInitial()); 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) { void _startExpiryTimer(DateTime expiresAt) {
_expiryTimer?.cancel(); _expiryTimer?.cancel();
final duration = expiresAt.difference(DateTime.now()); final duration = expiresAt.difference(DateTime.now());

View File

@@ -28,3 +28,13 @@ class SessionRefreshed extends SessionEvent {
} }
class SessionEnded 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 { try {
// Simulate upload // Simulate upload
await _fileRepository.uploadFile(event.orgId, file); await _fileRepository.uploadFile(event.orgId, file);
add(UploadCompleted(file)); add(UploadCompleted(file));
} catch (e) { } catch (e) {
add(UploadFailed(fileName: file.name, error: e.toString())); add(UploadFailed(fileName: file.name, error: e.toString()));

View File

@@ -1,24 +1,33 @@
import 'package:b0esche_cloud/services/api_client.dart'; import 'package:b0esche_cloud/services/api_client.dart';
import 'package:get_it/get_it.dart'; import 'package:get_it/get_it.dart';
import 'blocs/session/session_bloc.dart';
import 'repositories/auth_repository.dart'; import 'repositories/auth_repository.dart';
import 'repositories/file_repository.dart'; import 'repositories/file_repository.dart';
import 'repositories/mock_auth_repository.dart'; import 'repositories/http_auth_repository.dart';
import 'repositories/mock_file_repository.dart'; import 'repositories/http_file_repository.dart';
import 'services/auth_service.dart'; import 'services/auth_service.dart';
import 'services/file_service.dart'; import 'services/file_service.dart';
import 'services/org_api.dart';
import 'viewmodels/login_view_model.dart'; import 'viewmodels/login_view_model.dart';
import 'viewmodels/file_explorer_view_model.dart'; import 'viewmodels/file_explorer_view_model.dart';
final getIt = GetIt.instance; final getIt = GetIt.instance;
void configureDependencies() { void configureDependencies(SessionBloc sessionBloc) {
// Register repositories // Register ApiClient first
getIt.registerSingleton<AuthRepository>(MockAuthRepository()); final apiClient = ApiClient(sessionBloc);
getIt.registerSingleton<FileRepository>(MockFileRepository()); getIt.registerSingleton<ApiClient>(apiClient);
// Register repositories (HTTP-backed)
getIt.registerSingleton<AuthRepository>(HttpAuthRepository(apiClient));
getIt.registerSingleton<FileRepository>(
HttpFileRepository(FileService(apiClient)),
);
// Register services // Register services
getIt.registerSingleton<AuthService>(AuthService(getIt<AuthRepository>())); getIt.registerSingleton<AuthService>(AuthService(getIt<AuthRepository>()));
getIt.registerSingleton<FileService>(FileService(getIt<ApiClient>())); getIt.registerSingleton<FileService>(FileService(getIt<ApiClient>()));
getIt.registerSingleton<OrgApi>(OrgApi(getIt<ApiClient>()));
// Register viewmodels // Register viewmodels
getIt.registerSingleton<LoginViewModel>(LoginViewModel(getIt<AuthService>())); 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:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'blocs/auth/auth_bloc.dart'; import 'blocs/auth/auth_bloc.dart';
import 'blocs/auth/auth_event.dart';
import 'blocs/session/session_bloc.dart'; import 'blocs/session/session_bloc.dart';
import 'blocs/activity/activity_bloc.dart'; import 'blocs/activity/activity_bloc.dart';
import 'services/api_client.dart'; import 'services/api_client.dart';
@@ -11,6 +12,7 @@ import 'pages/file_explorer.dart';
import 'pages/document_viewer.dart'; import 'pages/document_viewer.dart';
import 'pages/editor_page.dart'; import 'pages/editor_page.dart';
import 'theme/app_theme.dart'; import 'theme/app_theme.dart';
import 'injection.dart';
final GoRouter _router = GoRouter( final GoRouter _router = GoRouter(
routes: [ routes: [
@@ -41,29 +43,80 @@ void main() {
runApp(const MainApp()); runApp(const MainApp());
} }
class MainApp extends StatelessWidget { class MainApp extends StatefulWidget {
const MainApp({super.key}); 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return MultiBlocProvider( return MultiBlocProvider(
providers: [ providers: [
BlocProvider<SessionBloc>(create: (_) => SessionBloc()), BlocProvider<SessionBloc>.value(value: _sessionBloc),
BlocProvider<AuthBloc>( BlocProvider<AuthBloc>.value(value: _authBloc),
create: (context) => AuthBloc(
apiClient: ApiClient(context.read<SessionBloc>()),
sessionBloc: context.read<SessionBloc>(),
),
),
BlocProvider<ActivityBloc>( BlocProvider<ActivityBloc>(
create: (context) => create: (context) =>
ActivityBloc(ActivityApi(ApiClient(context.read<SessionBloc>()))), ActivityBloc(ActivityApi(ApiClient(_sessionBloc))),
), ),
], ],
child: MaterialApp.router( child: FutureBuilder<void>(
routerConfig: _router, future: _restoreFuture,
theme: AppTheme.darkTheme, 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

@@ -4,21 +4,31 @@ class DocumentCapabilities extends Equatable {
final bool canEdit; final bool canEdit;
final bool canAnnotate; final bool canAnnotate;
final bool isPdf; final bool isPdf;
final String mimeType;
const DocumentCapabilities({ const DocumentCapabilities({
required this.canEdit, required this.canEdit,
required this.canAnnotate, required this.canAnnotate,
required this.isPdf, required this.isPdf,
required this.mimeType,
}); });
@override @override
List<Object?> get props => [canEdit, canAnnotate, isPdf]; List<Object?> get props => [canEdit, canAnnotate, isPdf, mimeType];
factory DocumentCapabilities.fromJson(Map<String, dynamic> json) { factory DocumentCapabilities.fromJson(Map<String, dynamic> json) {
return DocumentCapabilities( return DocumentCapabilities(
canEdit: json['canEdit'], canEdit: json['canEdit'],
canAnnotate: json['canAnnotate'], canAnnotate: json['canAnnotate'],
isPdf: json['isPdf'], 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 { class EditorSession extends Equatable {
final Uri editUrl; final Uri editUrl;
final String token;
final bool readOnly; final bool readOnly;
final DateTime expiresAt; final DateTime expiresAt;
const EditorSession({ const EditorSession({
required this.editUrl, required this.editUrl,
required this.token,
required this.readOnly, required this.readOnly,
required this.expiresAt, required this.expiresAt,
}); });
@override @override
List<Object?> get props => [editUrl, readOnly, expiresAt]; List<Object?> get props => [editUrl, token, readOnly, expiresAt];
factory EditorSession.fromJson(Map<String, dynamic> json) { factory EditorSession.fromJson(Map<String, dynamic> json) {
return EditorSession( return EditorSession(
editUrl: Uri.parse(json['editUrl']), editUrl: Uri.parse(json['editUrl']),
token: json['token'] ?? '',
readOnly: json['readOnly'], readOnly: json['readOnly'],
expiresAt: DateTime.parse(json['expiresAt']), expiresAt: DateTime.parse(json['expiresAt']),
); );

View File

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

View File

@@ -1,14 +1,521 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'dart:convert';
import 'dart:html' as html;
import 'dart:ui_web' as ui;
import '../theme/app_theme.dart'; import '../theme/app_theme.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import '../blocs/document_viewer/document_viewer_bloc.dart'; import '../blocs/document_viewer/document_viewer_bloc.dart';
import '../blocs/document_viewer/document_viewer_event.dart'; import '../blocs/document_viewer/document_viewer_event.dart';
import '../blocs/document_viewer/document_viewer_state.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 '../services/file_service.dart';
import '../injection.dart'; import '../injection.dart';
import 'package:syncfusion_flutter_pdfviewer/pdfviewer.dart'; import 'package:syncfusion_flutter_pdfviewer/pdfviewer.dart';
import 'package:go_router/go_router.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) {
return Container(
height: 30,
alignment: Alignment.centerLeft,
padding: const EdgeInsets.symmetric(horizontal: 16),
decoration: BoxDecoration(
color: AppTheme.primaryBackground.withValues(alpha: 0.3),
),
child: const Text(
'Last modified: Unknown by Unknown (v1)',
style: 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!;
// Build Collabora Online viewer URL with WOPISrc
// The WOPISrc must be URL-encoded and kept encoded
// We use a double-encoding approach: encodeComponent keeps it encoded through iframe.src
final baseUrl = 'https://of.b0esche.cloud/loleaflet/dist/loleaflet.html';
final collaboraUrl = Uri.parse(baseUrl)
.replace(queryParameters: {'WOPISrc': wopiSession.wopisrc})
.toString();
// Use WebView to display Collabora Online
return _buildWebView(collaboraUrl);
},
);
}
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 collaboraUrl) {
// For Collabora Online, create an iframe that loads the editor directly
const String viewType = 'collabora-iframe';
ui.platformViewRegistry.registerViewFactory(
viewType,
(int viewId) {
// Create the iframe with the properly encoded Collabora URL
final iframe = html.IFrameElement()
..src = collaboraUrl
..style.border = 'none'
..style.width = '100%'
..style.height = '100%'
..style.margin = '0'
..style.padding = '0'
..setAttribute(
'allow',
'microphone; camera; usb; autoplay; clipboard-read; clipboard-write',
)
// Remove allow-same-origin for security, add allow-popups-to-escape-sandbox
..setAttribute(
'sandbox',
'allow-scripts allow-popups allow-forms allow-pointer-lock allow-presentation allow-modals allow-downloads allow-popups-to-escape-sandbox',
);
return iframe;
},
);
return HtmlElementView(viewType: viewType);
}
Widget _buildWebView(String url) {
// Embed Collabora Online in an iframe for web platform
return _buildCollaboraIframe(url);
}
@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 { class DocumentViewer extends StatefulWidget {
final String orgId; final String orgId;
final String fileId; final String fileId;
@@ -94,7 +601,16 @@ class _DocumentViewerState extends State<DocumentViewer> {
body: BlocBuilder<DocumentViewerBloc, DocumentViewerState>( body: BlocBuilder<DocumentViewerBloc, DocumentViewerState>(
builder: (context, state) { builder: (context, state) {
if (state is DocumentViewerLoading) { 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) { if (state is DocumentViewerError) {
return Center(child: Text('Error: ${state.message}')); return Center(child: Text('Error: ${state.message}'));
@@ -123,22 +639,133 @@ class _DocumentViewerState extends State<DocumentViewer> {
); );
} }
if (state is DocumentViewerReady) { if (state is DocumentViewerReady) {
if (state.caps.isPdf) { return BlocBuilder<SessionBloc, SessionState>(
// Use PDF viewer builder: (context, sessionState) {
return SfPdfViewer.network(state.viewUrl.toString()); String? token;
} else { if (sessionState is SessionActive) {
// Placeholder for office docs iframe token = sessionState.token;
return Container( }
color: AppTheme.secondaryText,
child: Center( if (state.caps.isPdf) {
child: Text( // PDF viewer using SfPdfViewer
'Office Document Viewer\n(URL: ${state.viewUrl})', return SfPdfViewer.network(
textAlign: TextAlign.center, state.viewUrl.toString(),
style: const TextStyle(color: AppTheme.primaryText), headers: token != null
), ? {'Authorization': 'Bearer $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: token != null
? {'Authorization': 'Bearer $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(),
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')); return const Center(child: Text('No document loaded'));
}, },
@@ -147,6 +774,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 @override
void dispose() { void dispose() {
_viewerBloc.close(); _viewerBloc.close();

View File

@@ -8,6 +8,161 @@ import '../services/file_service.dart';
import '../injection.dart'; import '../injection.dart';
import 'package:go_router/go_router.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 { class EditorPage extends StatefulWidget {
final String orgId; final String orgId;
final String fileId; final String fileId;

File diff suppressed because it is too large Load Diff

View File

@@ -8,10 +8,16 @@ import '../blocs/organization/organization_bloc.dart';
import '../blocs/organization/organization_event.dart'; import '../blocs/organization/organization_event.dart';
import '../blocs/file_browser/file_browser_bloc.dart'; import '../blocs/file_browser/file_browser_bloc.dart';
import '../blocs/file_browser/file_browser_event.dart'; import '../blocs/file_browser/file_browser_event.dart';
import '../blocs/permission/permission_bloc.dart';
import '../blocs/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/app_theme.dart';
import '../theme/modern_glass_button.dart'; import '../theme/modern_glass_button.dart';
import 'login_form.dart' show LoginForm; import 'login_form.dart' show LoginForm;
import 'file_explorer.dart'; import 'file_explorer.dart';
import '../injection.dart';
class HomePage extends StatefulWidget { class HomePage extends StatefulWidget {
const HomePage({super.key}); const HomePage({super.key});
@@ -24,6 +30,13 @@ class _HomePageState extends State<HomePage> with TickerProviderStateMixin {
late String _selectedTab = 'Drive'; late String _selectedTab = 'Drive';
late AnimationController _animationController; late AnimationController _animationController;
bool _isSignupMode = false; 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 @override
void initState() { void initState() {
@@ -32,11 +45,25 @@ class _HomePageState extends State<HomePage> with TickerProviderStateMixin {
duration: const Duration(milliseconds: 400), duration: const Duration(milliseconds: 400),
vsync: this, vsync: this,
); );
_permissionBloc = PermissionBloc();
_fileBrowserBloc = FileBrowserBloc(getIt<FileService>());
_uploadBloc = UploadBloc(getIt<FileRepository>());
_organizationBloc = OrganizationBloc(
_permissionBloc,
_fileBrowserBloc,
_uploadBloc,
getIt<OrgApi>(),
);
} }
@override @override
void dispose() { void dispose() {
_animationController.dispose(); _animationController.dispose();
_organizationBloc.close();
_uploadBloc.close();
_fileBrowserBloc.close();
_permissionBloc.close();
super.dispose(); super.dispose();
} }
@@ -50,11 +77,15 @@ class _HomePageState extends State<HomePage> with TickerProviderStateMixin {
} }
} }
void _setPasswordMode(bool usePassword) {
setState(() => _usePasswordMode = usePassword);
}
void _showCreateOrgDialog(BuildContext context) { void _showCreateOrgDialog(BuildContext context) {
final controller = TextEditingController(); final controller = TextEditingController();
showDialog( showDialog(
context: context, context: context,
builder: (context) => Dialog( builder: (dialogContext) => Dialog(
backgroundColor: Colors.transparent, backgroundColor: Colors.transparent,
child: SizedBox( child: SizedBox(
width: 400, width: 400,
@@ -80,6 +111,7 @@ class _HomePageState extends State<HomePage> with TickerProviderStateMixin {
TextField( TextField(
cursorColor: AppTheme.accentColor, cursorColor: AppTheme.accentColor,
controller: controller, controller: controller,
autofocus: true,
style: TextStyle(color: AppTheme.primaryText), style: TextStyle(color: AppTheme.primaryText),
decoration: InputDecoration( decoration: InputDecoration(
labelText: 'Organization Name', labelText: 'Organization Name',
@@ -95,13 +127,23 @@ class _HomePageState extends State<HomePage> with TickerProviderStateMixin {
borderSide: BorderSide(color: AppTheme.accentColor), 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), const SizedBox(height: 24),
Row( Row(
mainAxisAlignment: MainAxisAlignment.end, mainAxisAlignment: MainAxisAlignment.end,
children: [ children: [
ModernGlassButton( ModernGlassButton(
onPressed: () => Navigator.of(context).pop(), onPressed: () => Navigator.of(dialogContext).pop(),
child: const Text('Cancel'), child: const Text('Cancel'),
), ),
const SizedBox(width: 16), const SizedBox(width: 16),
@@ -109,10 +151,11 @@ class _HomePageState extends State<HomePage> with TickerProviderStateMixin {
onPressed: () { onPressed: () {
final name = controller.text.trim(); final name = controller.text.trim();
if (name.isNotEmpty) { if (name.isNotEmpty) {
context.read<OrganizationBloc>().add( // Use the parent context, not the dialog context
CreateOrganization(name), BlocProvider.of<OrganizationBloc>(
); context,
Navigator.of(context).pop(); ).add(CreateOrganization(name));
Navigator.of(dialogContext).pop();
} }
}, },
child: const Text('Create'), child: const Text('Create'),
@@ -132,37 +175,58 @@ class _HomePageState extends State<HomePage> with TickerProviderStateMixin {
Widget _buildOrgRow(BuildContext context) { Widget _buildOrgRow(BuildContext context) {
return BlocBuilder<OrganizationBloc, OrganizationState>( return BlocBuilder<OrganizationBloc, OrganizationState>(
builder: (context, state) { builder: (context, state) {
List<Organization> orgs = [];
Organization? selectedOrg;
bool isLoading = false;
if (state is OrganizationLoaded) { if (state is OrganizationLoaded) {
final orgs = state.organizations; orgs = state.organizations;
return Column( selectedOrg = state.selectedOrg;
children: [ isLoading = state.isLoading;
Row( } else if (state is OrganizationLoading) {
children: [ isLoading = true;
...orgs.map(
(org) => Row(
children: [
_buildOrgButton(
org,
org.id == state.selectedOrg?.id,
() {
context.read<OrganizationBloc>().add(
SelectOrganization(org.id),
);
},
),
const SizedBox(width: 16),
],
),
),
_buildAddButton(() => _showCreateOrgDialog(context)),
],
),
const Divider(height: 1),
],
);
} else {
return const SizedBox.shrink();
} }
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 == 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),
],
);
}, },
); );
} }
@@ -171,6 +235,7 @@ class _HomePageState extends State<HomePage> with TickerProviderStateMixin {
final highlightColor = const Color.fromARGB(255, 100, 200, 255); final highlightColor = const Color.fromARGB(255, 100, 200, 255);
final defaultColor = AppTheme.secondaryText; final defaultColor = AppTheme.secondaryText;
return TextButton( return TextButton(
style: ButtonStyle(splashFactory: NoSplash.splashFactory),
onPressed: onTap, onPressed: onTap,
child: Text( child: Text(
org.name, 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) { Widget _buildAddButton(VoidCallback onTap) {
final defaultColor = AppTheme.secondaryText; final defaultColor = AppTheme.secondaryText;
return TextButton( return TextButton(
style: ButtonStyle(splashFactory: NoSplash.splashFactory),
onPressed: onTap, onPressed: onTap,
child: Text('+ Add Organization', style: TextStyle(color: defaultColor)), child: Text('+ Add Organization', style: TextStyle(color: defaultColor)),
); );
} }
Widget _buildDrive(OrganizationState state) { Widget _buildDrive(OrganizationState state, AuthState authState) {
return state is OrganizationLoaded && state.selectedOrg != null String orgId;
? FileExplorer(orgId: state.selectedOrg!.id) if (state is OrganizationLoaded && state.selectedOrg != null) {
: const FileExplorer(orgId: 'org1'); // 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}) { Widget _buildNavButton(String label, IconData icon, {bool isAvatar = false}) {
@@ -238,274 +329,328 @@ class _HomePageState extends State<HomePage> with TickerProviderStateMixin {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return MultiBlocProvider(
backgroundColor: AppTheme.primaryBackground, providers: [
body: Stack( BlocProvider<PermissionBloc>.value(value: _permissionBloc),
children: [ BlocProvider<FileBrowserBloc>.value(value: _fileBrowserBloc),
Center( BlocProvider<UploadBloc>.value(value: _uploadBloc),
child: BlocBuilder<AuthBloc, AuthState>( BlocProvider<OrganizationBloc>.value(value: _organizationBloc),
builder: (context, state) { ],
final isLoggedIn = state is AuthAuthenticated; child: Scaffold(
if (isLoggedIn && !_animationController.isAnimating) { backgroundColor: AppTheme.primaryBackground,
_animationController.forward(); body: Stack(
} else if (!isLoggedIn) { children: [
_animationController.reverse(); Center(
} child: BlocBuilder<AuthBloc, AuthState>(
return Padding( builder: (context, state) {
padding: const EdgeInsets.only(top: 42.0), final isLoggedIn = state is AuthAuthenticated;
child: AnimatedContainer( if (isLoggedIn && !_animationController.isAnimating) {
duration: const Duration(milliseconds: 350), _animationController.forward();
curve: Curves.easeInOut, } else if (!isLoggedIn) {
width: isLoggedIn _animationController.reverse();
? MediaQuery.of(context).size.width * 0.9 }
: 340, return Padding(
height: isLoggedIn padding: EdgeInsets.only(
? MediaQuery.of(context).size.height * 0.9 top: MediaQuery.of(context).size.width < 600
: (_isSignupMode ? 400 : 280), ? 96.0
child: ClipRRect( : 78.0,
borderRadius: BorderRadius.circular(16), ),
child: BackdropFilter( child: AnimatedContainer(
filter: ui.ImageFilter.blur(sigmaX: 10, sigmaY: 10), duration: const Duration(milliseconds: 350),
child: Stack( curve: Curves.easeInOut,
children: [ width: isLoggedIn
Container( ? MediaQuery.of(context).size.width * 0.9
decoration: AppTheme.glassDecoration, : 340,
child: isLoggedIn height: isLoggedIn
? BlocListener< ? MediaQuery.of(context).size.height * 0.9
OrganizationBloc, : (_isSignupMode
OrganizationState ? 400
>( : (_usePasswordMode ? 350 : 280)),
listener: (context, state) { child: ClipRRect(
if (state is OrganizationLoaded && borderRadius: BorderRadius.circular(16),
state.selectedOrg != null) { child: BackdropFilter(
// Reload file browser when org changes filter: ui.ImageFilter.blur(sigmaX: 10, sigmaY: 10),
context.read<FileBrowserBloc>().add( child: Stack(
LoadDirectory( children: [
orgId: state.selectedOrg!.id, Container(
path: '/', decoration: AppTheme.glassDecoration,
), child: isLoggedIn
); ? BlocListener<
} OrganizationBloc,
}, OrganizationState
child: >(
BlocBuilder< listener: (context, state) {
OrganizationBloc, if (state is OrganizationLoaded) {
OrganizationState // Show errors if present
>( if (state.error != null &&
builder: (context, orgState) { state.error!.isNotEmpty) {
if (orgState ScaffoldMessenger.of(
is OrganizationInitial) { context,
WidgetsBinding.instance ).showSnackBar(
.addPostFrameCallback((_) { SnackBar(
context content: Text(state.error!),
.read< ),
OrganizationBloc
>()
.add(
LoadOrganizations(),
);
});
}
return Column(
children: [
const SizedBox(height: 8),
_buildOrgRow(context),
Expanded(
child: _buildDrive(
orgState,
),
),
],
); );
}, }
), final orgId =
) state.selectedOrg?.id ?? '';
: LoginForm( // Reload file browser when org changes (or when falling back to personal workspace)
onSignupModeChanged: _setSignupMode, context.read<FileBrowserBloc>().add(
), LoadDirectory(
), orgId: orgId,
// Top-left radial glow - primary accent light path: '/',
AnimatedPositioned( ),
duration: const Duration(milliseconds: 350), );
curve: Curves.easeInOut, }
top: isLoggedIn ? -180 : -120, },
left: isLoggedIn ? -180 : -120, child:
child: IgnorePointer( BlocBuilder<
child: AnimatedContainer( OrganizationBloc,
duration: const Duration(milliseconds: 350), OrganizationState
curve: Curves.easeInOut, >(
width: isLoggedIn ? 550 : 400, builder: (context, orgState) {
height: isLoggedIn ? 550 : 400, if (orgState
decoration: BoxDecoration( is OrganizationInitial) {
shape: BoxShape.circle, WidgetsBinding.instance
gradient: RadialGradient( .addPostFrameCallback((
colors: [ _,
AppTheme.accentColor.withValues( ) {
alpha: isLoggedIn ? 0.12 : 0.15, // Kick off org fetch and immediately show personal workspace
), // while org data loads.
AppTheme.accentColor.withValues( context
alpha: 0.04, .read<
), OrganizationBloc
Colors.transparent, >()
], .add(
stops: const [0.0, 0.6, 1.0], LoadOrganizations(),
), );
), context
), .read<
FileBrowserBloc
>()
.add(
const LoadDirectory(
orgId: '',
path: '/',
),
);
});
}
return Column(
children: [
const SizedBox(height: 16),
_buildOrgRow(context),
Expanded(
child: _buildDrive(
orgState,
state,
),
),
],
);
},
),
)
: LoginForm(
onSignupModeChanged: _setSignupMode,
onPasswordModeChanged: _setPasswordMode,
),
), ),
), // Top-left radial glow - primary accent light
// Bottom-right warm glow - complementary lighting AnimatedPositioned(
AnimatedPositioned( duration: const Duration(milliseconds: 350),
duration: const Duration(milliseconds: 350), curve: Curves.easeInOut,
curve: Curves.easeInOut, top: isLoggedIn ? -180 : -120,
bottom: isLoggedIn ? -200 : -140, left: isLoggedIn ? -180 : -120,
right: isLoggedIn ? -200 : -140, child: IgnorePointer(
child: IgnorePointer( child: AnimatedContainer(
child: AnimatedContainer( duration: const Duration(milliseconds: 350),
duration: const Duration(milliseconds: 350), curve: Curves.easeInOut,
curve: Curves.easeInOut, width: isLoggedIn ? 550 : 400,
width: isLoggedIn ? 530 : 380, height: isLoggedIn ? 550 : 400,
height: isLoggedIn ? 530 : 380,
decoration: BoxDecoration(
shape: BoxShape.circle,
gradient: RadialGradient(
colors: [
Colors.cyan.withValues(
alpha: isLoggedIn ? 0.06 : 0.08,
),
Colors.transparent,
],
),
),
),
),
),
// Top edge subtle highlight
Positioned(
top: 0,
left: 0,
right: 0,
child: IgnorePointer(
child: Container(
height: 60,
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Colors.white.withValues(alpha: 0.05),
Colors.transparent,
],
),
),
),
),
),
// Left edge subtle side lighting
Positioned(
left: 0,
top: 0,
bottom: 0,
child: IgnorePointer(
child: Container(
width: 40,
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.centerLeft,
end: Alignment.centerRight,
colors: [
AppTheme.accentColor.withValues(
alpha: 0.04,
),
Colors.transparent,
],
),
),
),
),
),
// Diagonal shimmer overlay
Positioned(
top: 0,
left: 0,
child: IgnorePointer(
child: Transform.rotate(
angle: 0.785,
child: Container(
width: 600,
height: 100,
decoration: BoxDecoration( decoration: BoxDecoration(
gradient: LinearGradient( shape: BoxShape.circle,
gradient: RadialGradient(
colors: [ colors: [
Colors.white.withValues(alpha: 0), AppTheme.accentColor.withValues(
Colors.white.withValues(alpha: 0.06), alpha: isLoggedIn ? 0.12 : 0.15,
Colors.white.withValues(alpha: 0), ),
AppTheme.accentColor.withValues(
alpha: 0.04,
),
Colors.transparent,
],
stops: const [0.0, 0.6, 1.0],
),
),
),
),
),
// Bottom-right warm glow - complementary lighting
AnimatedPositioned(
duration: const Duration(milliseconds: 350),
curve: Curves.easeInOut,
bottom: isLoggedIn ? -200 : -140,
right: isLoggedIn ? -200 : -140,
child: IgnorePointer(
child: AnimatedContainer(
duration: const Duration(milliseconds: 350),
curve: Curves.easeInOut,
width: isLoggedIn ? 530 : 380,
height: isLoggedIn ? 530 : 380,
decoration: BoxDecoration(
shape: BoxShape.circle,
gradient: RadialGradient(
colors: [
Colors.cyan.withValues(
alpha: isLoggedIn ? 0.06 : 0.08,
),
Colors.transparent,
], ],
), ),
), ),
), ),
), ),
), ),
), // Top edge subtle highlight
], Positioned(
top: 0,
left: 0,
right: 0,
child: IgnorePointer(
child: Container(
height: 60,
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Colors.white.withValues(alpha: 0.05),
Colors.transparent,
],
),
),
),
),
),
// Left edge subtle side lighting
Positioned(
left: 0,
top: 0,
bottom: 0,
child: IgnorePointer(
child: Container(
width: 40,
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.centerLeft,
end: Alignment.centerRight,
colors: [
AppTheme.accentColor.withValues(
alpha: 0.04,
),
Colors.transparent,
],
),
),
),
),
),
// Diagonal shimmer overlay
Positioned(
top: 0,
left: 0,
child: IgnorePointer(
child: Transform.rotate(
angle: 0.785,
child: Container(
width: 600,
height: 100,
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [
Colors.white.withValues(alpha: 0),
Colors.white.withValues(
alpha: 0.06,
),
Colors.white.withValues(alpha: 0),
],
),
),
),
),
),
),
],
),
), ),
), ),
), ),
), );
); },
}, ),
), ),
), Positioned(
Positioned( top: 0,
top: 0, left: 0,
left: 0, right: 0,
right: 0, child: Center(
child: Center( child: Builder(
child: Text( builder: (context) {
'b0esche.cloud', final screenWidth = MediaQuery.of(context).size.width;
style: TextStyle( final fontSize = screenWidth < 600 ? 24.0 : 48.0;
fontFamily: 'PixelatedElegance', return Text(
fontSize: 48, 'b0esche.cloud',
color: AppTheme.primaryText, style: TextStyle(
decoration: TextDecoration.underline, fontFamily: 'PixelatedElegance',
decorationColor: AppTheme.primaryText, fontSize: fontSize,
fontFeatures: const [FontFeature.slashedZero()], color: AppTheme.primaryText,
decoration: TextDecoration.underline,
decorationColor: AppTheme.primaryText,
fontFeatures: const [FontFeature.slashedZero()],
),
);
},
), ),
), ),
), ),
), Positioned(
Positioned( top: MediaQuery.of(context).size.width < 600 ? 40 : 10,
top: 10, right: 20,
right: 20, child: BlocBuilder<AuthBloc, AuthState>(
child: BlocBuilder<AuthBloc, AuthState>( builder: (context, state) {
builder: (context, state) { final isLoggedIn = state is AuthAuthenticated;
final isLoggedIn = state is AuthAuthenticated; if (!isLoggedIn) {
if (!isLoggedIn) { return const SizedBox.shrink();
return const SizedBox.shrink(); }
} return ScaleTransition(
return ScaleTransition( scale: Tween<double>(begin: 0, end: 1).animate(
scale: Tween<double>(begin: 0, end: 1).animate( CurvedAnimation(
CurvedAnimation( parent: _animationController,
parent: _animationController, curve: Curves.easeOutBack,
curve: Curves.easeOutBack, ),
), ),
), child: Row(
child: Row( children: [
children: [ _buildNavButton('Drive', Icons.cloud),
_buildNavButton('Drive', Icons.cloud), const SizedBox(width: 16),
const SizedBox(width: 16), _buildNavButton('Mail', Icons.mail),
_buildNavButton('Mail', Icons.mail), const SizedBox(width: 16),
const SizedBox(width: 16), _buildNavButton('Add', Icons.add),
_buildNavButton('Add', Icons.add), const SizedBox(width: 16),
const SizedBox(width: 16), _buildNavButton(
_buildNavButton('Profile', Icons.person, isAvatar: true), 'Profile',
], Icons.person,
), isAvatar: true,
); ),
}, ],
),
);
},
),
), ),
), ],
], ),
), ),
); );
} }

View File

@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'dart:math'; import 'dart:math';
import 'dart:convert';
import '../blocs/auth/auth_bloc.dart'; import '../blocs/auth/auth_bloc.dart';
import '../blocs/auth/auth_event.dart'; import '../blocs/auth/auth_event.dart';
import '../blocs/auth/auth_state.dart'; import '../blocs/auth/auth_state.dart';
@@ -12,8 +13,13 @@ import '../theme/modern_glass_button.dart';
class LoginForm extends StatefulWidget { class LoginForm extends StatefulWidget {
final ValueChanged<bool>? onSignupModeChanged; final ValueChanged<bool>? onSignupModeChanged;
final ValueChanged<bool>? onPasswordModeChanged;
const LoginForm({super.key, this.onSignupModeChanged}); const LoginForm({
super.key,
this.onSignupModeChanged,
this.onPasswordModeChanged,
});
@override @override
State<LoginForm> createState() => _LoginFormState(); State<LoginForm> createState() => _LoginFormState();
@@ -34,10 +40,10 @@ class _LoginFormState extends State<LoginForm> {
super.dispose(); super.dispose();
} }
String _generateRandomHex(int bytes) { String _generateRandomBase64(int bytes) {
final random = Random(); final random = Random();
final values = List<int>.generate(bytes, (i) => random.nextInt(256)); 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( Future<void> _handleAuthentication(
@@ -47,7 +53,7 @@ class _LoginFormState extends State<LoginForm> {
try { try {
final credentialId = state.credentialIds.isNotEmpty final credentialId = state.credentialIds.isNotEmpty
? state.credentialIds.first ? state.credentialIds.first
: _generateRandomHex(64); : _generateRandomBase64(64);
if (context.mounted) { if (context.mounted) {
context.read<AuthBloc>().add( context.read<AuthBloc>().add(
@@ -55,10 +61,10 @@ class _LoginFormState extends State<LoginForm> {
username: _usernameController.text, username: _usernameController.text,
challenge: state.challenge, challenge: state.challenge,
credentialId: credentialId, credentialId: credentialId,
authenticatorData: _generateRandomHex(37), authenticatorData: _generateRandomBase64(37),
clientDataJSON: clientDataJSON:
'{"type":"webauthn.get","challenge":"${state.challenge}","origin":"https://b0esche.cloud"}', '{"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, RegistrationChallengeReceived state,
) async { ) async {
try { try {
final credentialId = _generateRandomHex(64); final credentialId = _generateRandomBase64(64);
final publicKey = _generateRandomHex(91); final publicKey = _generateRandomBase64(91);
if (context.mounted) { if (context.mounted) {
context.read<AuthBloc>().add( context.read<AuthBloc>().add(
@@ -88,7 +94,7 @@ class _LoginFormState extends State<LoginForm> {
publicKey: publicKey, publicKey: publicKey,
clientDataJSON: clientDataJSON:
'{"type":"webauthn.create","challenge":"${state.challenge}","origin":"https://b0esche.cloud"}', '{"type":"webauthn.create","challenge":"${state.challenge}","origin":"https://b0esche.cloud"}',
attestationObject: _generateRandomHex(128), attestationObject: _generateRandomBase64(128),
), ),
); );
} }
@@ -133,56 +139,28 @@ class _LoginFormState extends State<LoginForm> {
child: Center( child: Center(
child: Padding( child: Padding(
padding: const EdgeInsets.all(16.0), padding: const EdgeInsets.all(16.0),
child: AnimatedSwitcher( child: AnimatedSize(
duration: const Duration(milliseconds: 400), duration: const Duration(milliseconds: 300),
transitionBuilder: (child, animation) { curve: Curves.easeInOut,
return FadeTransition(opacity: animation, child: child); child: AnimatedSwitcher(
}, duration: const Duration(milliseconds: 400),
child: SingleChildScrollView( transitionBuilder: (child, animation) {
key: ValueKey<bool>(_isSignup), return FadeTransition(opacity: animation, child: child);
child: Column( },
mainAxisSize: MainAxisSize.min, child: SingleChildScrollView(
crossAxisAlignment: CrossAxisAlignment.center, key: ValueKey('${_isSignup}_$_usePasskey'),
children: [ child: Column(
Text( mainAxisSize: MainAxisSize.min,
_isSignup ? 'create account' : 'sign in', crossAxisAlignment: CrossAxisAlignment.center,
style: const TextStyle( children: [
fontSize: 24, Text(
color: AppTheme.primaryText, _isSignup ? 'create account' : 'sign in',
), style: const TextStyle(
), fontSize: 24,
const SizedBox(height: 24), color: AppTheme.primaryText,
Container(
decoration: BoxDecoration(
color: AppTheme.primaryBackground.withValues(alpha: 0.5),
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: AppTheme.accentColor.withValues(alpha: 0.3),
), ),
), ),
child: TextField( const SizedBox(height: 24),
controller: _usernameController,
textInputAction: TextInputAction.next,
keyboardType: TextInputType.text,
cursorColor: AppTheme.accentColor,
decoration: InputDecoration(
hintText: 'username',
hintStyle: TextStyle(color: AppTheme.secondaryText),
contentPadding: const EdgeInsets.all(12),
border: InputBorder.none,
prefixIcon: Icon(
Icons.person_outline,
color: AppTheme.primaryText,
size: 20,
),
),
style: const TextStyle(color: AppTheme.primaryText),
),
),
const SizedBox(height: 16),
if (!_isSignup && _usePasskey)
const SizedBox.shrink()
else
Container( Container(
decoration: BoxDecoration( decoration: BoxDecoration(
color: AppTheme.primaryBackground.withValues( color: AppTheme.primaryBackground.withValues(
@@ -194,105 +172,114 @@ class _LoginFormState extends State<LoginForm> {
), ),
), ),
child: TextField( child: TextField(
controller: _passwordController, controller: _usernameController,
textInputAction: TextInputAction.next, textInputAction: TextInputAction.next,
keyboardType: TextInputType.visiblePassword,
obscureText: true,
cursorColor: AppTheme.accentColor,
decoration: InputDecoration(
hintText: 'password',
hintStyle: TextStyle(color: AppTheme.secondaryText),
contentPadding: const EdgeInsets.all(12),
border: InputBorder.none,
prefixIcon: Icon(
Icons.lock_outline,
color: AppTheme.primaryText,
size: 20,
),
),
style: const TextStyle(color: AppTheme.primaryText),
),
),
if (!_isSignup && _usePasskey)
const SizedBox.shrink()
else
const SizedBox(height: 16),
if (_isSignup)
Container(
decoration: BoxDecoration(
color: AppTheme.primaryBackground.withValues(
alpha: 0.5,
),
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: AppTheme.accentColor.withValues(alpha: 0.3),
),
),
child: TextField(
controller: _displayNameController,
textInputAction: TextInputAction.done,
keyboardType: TextInputType.text, keyboardType: TextInputType.text,
cursorColor: AppTheme.accentColor, cursorColor: AppTheme.accentColor,
decoration: InputDecoration( decoration: InputDecoration(
hintText: 'display name (optional)', hintText: 'username',
hintStyle: TextStyle(color: AppTheme.secondaryText), hintStyle: TextStyle(color: AppTheme.secondaryText),
contentPadding: const EdgeInsets.all(12), contentPadding: const EdgeInsets.all(12),
border: InputBorder.none, border: InputBorder.none,
prefixIcon: Icon( prefixIcon: Icon(
Icons.badge_outlined, Icons.person_outline,
color: AppTheme.primaryText, color: AppTheme.primaryText,
size: 20, size: 20,
), ),
), ),
style: const TextStyle(color: AppTheme.primaryText), style: const TextStyle(color: AppTheme.primaryText),
), ),
) ),
else const SizedBox(height: 16),
const SizedBox.shrink(), if (!_isSignup && _usePasskey)
if (_isSignup) const SizedBox.shrink()
const SizedBox(height: 16) else
else Container(
const SizedBox.shrink(), decoration: BoxDecoration(
SizedBox( color: AppTheme.primaryBackground.withValues(
width: 150, alpha: 0.5,
child: BlocBuilder<AuthBloc, AuthState>( ),
builder: (context, state) { borderRadius: BorderRadius.circular(16),
return ModernGlassButton( border: Border.all(
isLoading: state is AuthLoading, color: AppTheme.accentColor.withValues(alpha: 0.3),
onPressed: () { ),
if (_usernameController.text.isEmpty) { ),
ScaffoldMessenger.of(context).showSnackBar( child: TextField(
const SnackBar( controller: _passwordController,
content: Text('Username is required'), textInputAction: TextInputAction.next,
), keyboardType: TextInputType.visiblePassword,
); obscureText: true,
return; cursorColor: AppTheme.accentColor,
} decoration: InputDecoration(
if (_isSignup) { hintText: 'password',
if (_passwordController.text.isEmpty) { hintStyle: TextStyle(color: AppTheme.secondaryText),
contentPadding: const EdgeInsets.all(12),
border: InputBorder.none,
prefixIcon: Icon(
Icons.lock_outline,
color: AppTheme.primaryText,
size: 20,
),
),
style: const TextStyle(color: AppTheme.primaryText),
),
),
if (!_isSignup && _usePasskey)
const SizedBox.shrink()
else
const SizedBox(height: 16),
if (_isSignup)
Container(
decoration: BoxDecoration(
color: AppTheme.primaryBackground.withValues(
alpha: 0.5,
),
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: AppTheme.accentColor.withValues(alpha: 0.3),
),
),
child: TextField(
controller: _displayNameController,
textInputAction: TextInputAction.done,
keyboardType: TextInputType.text,
cursorColor: AppTheme.accentColor,
decoration: InputDecoration(
hintText: 'display name (optional)',
hintStyle: TextStyle(color: AppTheme.secondaryText),
contentPadding: const EdgeInsets.all(12),
border: InputBorder.none,
prefixIcon: Icon(
Icons.badge_outlined,
color: AppTheme.primaryText,
size: 20,
),
),
style: const TextStyle(color: AppTheme.primaryText),
),
)
else
const SizedBox.shrink(),
if (_isSignup)
const SizedBox(height: 16)
else
const SizedBox.shrink(),
SizedBox(
width: 150,
child: BlocBuilder<AuthBloc, AuthState>(
builder: (context, state) {
return ModernGlassButton(
isLoading: state is AuthLoading,
onPressed: () {
if (_usernameController.text.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
const SnackBar( const SnackBar(
content: Text('Password is required'), content: Text('Username is required'),
), ),
); );
return; return;
} }
context.read<AuthBloc>().add( if (_isSignup) {
SignupStarted(
username: _usernameController.text,
email: _usernameController.text,
displayName: _displayNameController.text,
password: _passwordController.text,
),
);
} else {
if (_usePasskey) {
context.read<AuthBloc>().add(
LoginRequested(
username: _usernameController.text,
),
);
} else {
if (_passwordController.text.isEmpty) { if (_passwordController.text.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
const SnackBar( const SnackBar(
@@ -302,91 +289,120 @@ class _LoginFormState extends State<LoginForm> {
return; return;
} }
context.read<AuthBloc>().add( context.read<AuthBloc>().add(
PasswordLoginRequested( SignupStarted(
username: _usernameController.text, username: _usernameController.text,
email: _usernameController.text,
displayName: _displayNameController.text,
password: _passwordController.text, password: _passwordController.text,
), ),
); );
} else {
if (_usePasskey) {
context.read<AuthBloc>().add(
LoginRequested(
username: _usernameController.text,
),
);
} else {
if (_passwordController.text.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Password is required'),
),
);
return;
}
context.read<AuthBloc>().add(
PasswordLoginRequested(
username: _usernameController.text,
password: _passwordController.text,
),
);
}
} }
} },
}, child: Text(_isSignup ? 'create' : 'sign in'),
child: Text(_isSignup ? 'create' : 'sign in'), );
); },
}, ),
), ),
), const SizedBox(height: 16),
const SizedBox(height: 16), if (_isSignup)
if (_isSignup) Row(
Row( mainAxisAlignment: MainAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.center, children: [
children: [ Text(
Text( 'already have an account?',
'already have an account?', style: TextStyle(color: AppTheme.secondaryText),
style: TextStyle(color: AppTheme.secondaryText), ),
), const SizedBox(width: 8),
const SizedBox(width: 8), GestureDetector(
GestureDetector( onTap: () {
onTap: () { _resetForm();
_resetForm(); _setSignupMode(false);
_setSignupMode(false); },
}, child: Text(
child: Text( 'sign in',
'sign in', style: TextStyle(
style: TextStyle( color: AppTheme.accentColor,
color: AppTheme.accentColor, decoration: TextDecoration.underline,
decoration: TextDecoration.underline, ),
), ),
), ),
), ],
], )
) else
else Column(
Column( children: [
children: [ Row(
Row( mainAxisAlignment: MainAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.center, children: [
children: [ GestureDetector(
GestureDetector( onTap: () {
onTap: () => setState(() => _usePasskey = !_usePasskey);
setState(() => _usePasskey = !_usePasskey), widget.onPasswordModeChanged?.call(
child: Text( !_usePasskey,
_usePasskey ? 'use password' : 'use passkey', );
style: TextStyle( },
color: AppTheme.accentColor, child: Text(
decoration: TextDecoration.underline, _usePasskey ? 'use password' : 'use passkey',
fontSize: 12, style: TextStyle(
color: AppTheme.accentColor,
decoration: TextDecoration.underline,
fontSize: 12,
),
), ),
), ),
), ],
], ),
), const SizedBox(height: 16),
const SizedBox(height: 16), Row(
Row( mainAxisAlignment: MainAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.center, children: [
children: [ Text(
Text( 'don\'t have an account?',
'don\'t have an account?', style: TextStyle(color: AppTheme.secondaryText),
style: TextStyle(color: AppTheme.secondaryText), ),
), const SizedBox(width: 8),
const SizedBox(width: 8), GestureDetector(
GestureDetector( onTap: () {
onTap: () { _resetForm();
_resetForm(); _setSignupMode(true);
_setSignupMode(true); },
}, child: Text(
child: Text( 'create one',
'create one', style: TextStyle(
style: TextStyle( color: AppTheme.accentColor,
color: AppTheme.accentColor, decoration: TextDecoration.underline,
decoration: TextDecoration.underline, ),
), ),
), ),
), ],
], ),
), ],
], ),
), ],
], ),
), ),
), ),
), ),

View File

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

@@ -6,9 +6,11 @@ import '../models/document_capabilities.dart';
import '../models/api_error.dart'; import '../models/api_error.dart';
import '../repositories/file_repository.dart'; import '../repositories/file_repository.dart';
import 'package:path/path.dart' as p; import 'package:path/path.dart' as p;
import 'package:uuid/uuid.dart';
class MockFileRepository implements FileRepository { class MockFileRepository implements FileRepository {
final Map<String, List<FileItem>> _orgFiles = {}; final Map<String, List<FileItem>> _orgFiles = {};
final _uuid = const Uuid();
List<FileItem> _getFilesForOrg(String orgId) { List<FileItem> _getFilesForOrg(String orgId) {
if (!_orgFiles.containsKey(orgId)) { if (!_orgFiles.containsKey(orgId)) {
@@ -16,18 +18,21 @@ class MockFileRepository implements FileRepository {
if (orgId == 'org1') { if (orgId == 'org1') {
_orgFiles[orgId] = [ _orgFiles[orgId] = [
FileItem( FileItem(
id: _uuid.v4(),
name: 'Personal Documents', name: 'Personal Documents',
path: '/Personal Documents', path: '/Personal Documents',
type: FileType.folder, type: FileType.folder,
lastModified: DateTime.now(), lastModified: DateTime.now(),
), ),
FileItem( FileItem(
id: _uuid.v4(),
name: 'Photos', name: 'Photos',
path: '/Photos', path: '/Photos',
type: FileType.folder, type: FileType.folder,
lastModified: DateTime.now(), lastModified: DateTime.now(),
), ),
FileItem( FileItem(
id: _uuid.v4(),
name: 'resume.pdf', name: 'resume.pdf',
path: '/resume.pdf', path: '/resume.pdf',
type: FileType.file, type: FileType.file,
@@ -35,6 +40,7 @@ class MockFileRepository implements FileRepository {
lastModified: DateTime.now(), lastModified: DateTime.now(),
), ),
FileItem( FileItem(
id: _uuid.v4(),
name: 'notes.txt', name: 'notes.txt',
path: '/notes.txt', path: '/notes.txt',
type: FileType.file, type: FileType.file,
@@ -45,12 +51,14 @@ class MockFileRepository implements FileRepository {
} else if (orgId == 'org2') { } else if (orgId == 'org2') {
_orgFiles[orgId] = [ _orgFiles[orgId] = [
FileItem( FileItem(
id: _uuid.v4(),
name: 'Company Reports', name: 'Company Reports',
path: '/Company Reports', path: '/Company Reports',
type: FileType.folder, type: FileType.folder,
lastModified: DateTime.now(), lastModified: DateTime.now(),
), ),
FileItem( FileItem(
id: _uuid.v4(),
name: 'annual_report.pdf', name: 'annual_report.pdf',
path: '/annual_report.pdf', path: '/annual_report.pdf',
type: FileType.file, type: FileType.file,
@@ -58,6 +66,7 @@ class MockFileRepository implements FileRepository {
lastModified: DateTime.now(), lastModified: DateTime.now(),
), ),
FileItem( FileItem(
id: _uuid.v4(),
name: 'presentation.pptx', name: 'presentation.pptx',
path: '/presentation.pptx', path: '/presentation.pptx',
type: FileType.file, type: FileType.file,
@@ -68,12 +77,14 @@ class MockFileRepository implements FileRepository {
} else if (orgId == 'org3') { } else if (orgId == 'org3') {
_orgFiles[orgId] = [ _orgFiles[orgId] = [
FileItem( FileItem(
id: _uuid.v4(),
name: 'Project Code', name: 'Project Code',
path: '/Project Code', path: '/Project Code',
type: FileType.folder, type: FileType.folder,
lastModified: DateTime.now(), lastModified: DateTime.now(),
), ),
FileItem( FileItem(
id: _uuid.v4(),
name: 'side_project.dart', name: 'side_project.dart',
path: '/side_project.dart', path: '/side_project.dart',
type: FileType.file, type: FileType.file,
@@ -126,6 +137,7 @@ class MockFileRepository implements FileRepository {
final expiresAt = DateTime.now().add(const Duration(minutes: 30)); final expiresAt = DateTime.now().add(const Duration(minutes: 30));
return EditorSession( return EditorSession(
editUrl: editUrl, editUrl: editUrl,
token: 'mock-editor-token',
readOnly: !isEditable, readOnly: !isEditable,
expiresAt: expiresAt, expiresAt: expiresAt,
); );
@@ -153,6 +165,7 @@ class MockFileRepository implements FileRepository {
final files = _getFilesForOrg(orgId); final files = _getFilesForOrg(orgId);
files.add( files.add(
FileItem( FileItem(
id: _uuid.v4(),
name: normalizedName, name: normalizedName,
path: newPath, path: newPath,
type: FileType.folder, type: FileType.folder,
@@ -175,6 +188,7 @@ class MockFileRepository implements FileRepository {
final newName = file.path.split('/').last; final newName = file.path.split('/').last;
final newPath = targetPath == '/' ? '/$newName' : '$targetPath/$newName'; final newPath = targetPath == '/' ? '/$newName' : '$targetPath/$newName';
files[fileIndex] = FileItem( files[fileIndex] = FileItem(
id: file.id,
name: file.name, name: file.name,
path: newPath, path: newPath,
type: file.type, type: file.type,
@@ -194,6 +208,7 @@ class MockFileRepository implements FileRepository {
final parentPath = p.dirname(path); final parentPath = p.dirname(path);
final newPath = parentPath == '.' ? '/$newName' : '$parentPath/$newName'; final newPath = parentPath == '.' ? '/$newName' : '$parentPath/$newName';
files[fileIndex] = FileItem( files[fileIndex] = FileItem(
id: file.id,
name: newName, name: newName,
path: newPath, path: newPath,
type: file.type, type: file.type,
@@ -216,7 +231,16 @@ class MockFileRepository implements FileRepository {
Future<void> uploadFile(String orgId, FileItem file) async { Future<void> uploadFile(String orgId, FileItem file) async {
await Future.delayed(const Duration(seconds: 1)); await Future.delayed(const Duration(seconds: 1));
final files = _getFilesForOrg(orgId); final files = _getFilesForOrg(orgId);
files.add(file); files.add(
FileItem(
id: _uuid.v4(),
name: file.name,
path: file.path,
type: file.type,
size: file.size,
lastModified: file.lastModified,
),
);
} }
@override @override
@@ -241,6 +265,7 @@ class MockFileRepository implements FileRepository {
canEdit: !isPdf && (fileId.endsWith('.docx') || fileId.endsWith('.xlsx')), canEdit: !isPdf && (fileId.endsWith('.docx') || fileId.endsWith('.xlsx')),
canAnnotate: isPdf, canAnnotate: isPdf,
isPdf: isPdf, isPdf: isPdf,
mimeType: isPdf ? 'application/pdf' : 'application/octet-stream',
); );
// Mock URL // Mock URL
final viewUrl = Uri.parse( final viewUrl = Uri.parse(

View File

@@ -13,7 +13,9 @@ class ApiClient {
BaseOptions( BaseOptions(
baseUrl: baseUrl, baseUrl: baseUrl,
connectTimeout: const Duration(seconds: 10), connectTimeout: const Duration(seconds: 10),
receiveTimeout: const Duration(seconds: 10), receiveTimeout: const Duration(
seconds: 60,
), // Increased for file uploads and org operations
), ),
); );
@@ -29,23 +31,13 @@ class ApiClient {
}, },
onError: (error, handler) async { onError: (error, handler) async {
if (error.response?.statusCode == 401) { if (error.response?.statusCode == 401) {
// Try refresh final path = error.requestOptions.path;
final refreshSuccess = await _tryRefreshToken(); // Do not expire session for auth endpoints; show inline error instead
if (refreshSuccess) { final isAuthEndpoint = path.startsWith('/auth/');
// Retry the request if (!isAuthEndpoint) {
final token = _getCurrentToken(); // Session expired, trigger logout
if (token != null) { _sessionBloc.add(SessionExpired());
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
_sessionBloc.add(SessionExpired());
} }
return handler.next(error); return handler.next(error);
}, },
@@ -53,6 +45,8 @@ class ApiClient {
); );
} }
String get baseUrl => _dio.options.baseUrl;
String? _getCurrentToken() { String? _getCurrentToken() {
// Get from SessionBloc state // Get from SessionBloc state
final state = _sessionBloc.state; final state = _sessionBloc.state;
@@ -62,20 +56,6 @@ class ApiClient {
return null; 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>( Future<T> get<T>(
String path, { String path, {
Map<String, dynamic>? queryParameters, Map<String, dynamic>? queryParameters,
@@ -96,6 +76,7 @@ class ApiClient {
}) async { }) async {
try { try {
final response = await _dio.post(path, data: data); final response = await _dio.post(path, data: data);
return fromJson(response.data); return fromJson(response.data);
} on DioException catch (e) { } on DioException catch (e) {
throw _handleError(e); throw _handleError(e);
@@ -131,8 +112,17 @@ class ApiClient {
); );
} }
String code = data?['code'] ?? 'UNKNOWN'; // Only try to extract code/message if data is a Map
String message = data?['message'] ?? 'Unknown error'; 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); 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/editor_session.dart';
import '../models/annotation.dart'; import '../models/annotation.dart';
import 'api_client.dart'; import 'api_client.dart';
import 'package:dio/dio.dart';
class FileService { class FileService {
final ApiClient _apiClient; final ApiClient _apiClient;
FileService(this._apiClient); FileService(this._apiClient);
String get baseUrl => _apiClient.baseUrl;
Future<List<FileItem>> getFiles(String orgId, String path) async { Future<List<FileItem>> getFiles(String orgId, String path) async {
if (path.isEmpty) { if (path.isEmpty) {
throw Exception('Path cannot be empty'); 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( return await _apiClient.getList(
'/orgs/$orgId/files', '/orgs/$orgId/files',
queryParameters: pathParam,
fromJson: (data) => FileItem( fromJson: (data) => FileItem(
id: data['id'],
name: data['name'], name: data['name'],
path: data['path'], path: data['path'],
type: data['type'] == 'file' ? FileType.file : FileType.folder, type: data['type'] == 'file' ? FileType.file : FileType.folder,
@@ -30,11 +51,67 @@ class FileService {
} }
Future<void> uploadFile(String orgId, FileItem file) async { 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 { 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( Future<void> createFolder(
@@ -42,7 +119,41 @@ class FileService {
String parentPath, String parentPath,
String folderName, String folderName,
) async { ) 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( Future<void> moveFile(
@@ -50,7 +161,14 @@ class FileService {
String sourcePath, String sourcePath,
String targetPath, String targetPath,
) async { ) 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 { Future<void> renameFile(String orgId, String path, String newName) async {
@@ -65,11 +183,14 @@ class FileService {
String orgId, String orgId,
String fileId, String fileId,
) async { ) async {
if (orgId.isEmpty || fileId.isEmpty) { if (fileId.isEmpty) {
throw Exception('OrgId and fileId cannot be empty'); throw Exception('fileId cannot be empty');
} }
final path = orgId.isEmpty
? '/user/files/$fileId/view'
: '/orgs/$orgId/files/$fileId/view';
return await _apiClient.get( return await _apiClient.get(
'/orgs/$orgId/files/$fileId/view', path,
fromJson: (data) => ViewerSession.fromJson(data), fromJson: (data) => ViewerSession.fromJson(data),
); );
} }

View File

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

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,26 @@
<!-- Favicon --> <!-- Favicon -->
<link rel="icon" type="image/png" href="favicon.png" /> <link rel="icon" type="image/png" href="favicon.png" />
<!-- Preload fonts --> <!-- Preload PixelatedElegance brand font -->
<link rel="preload" href="assets/fonts/veteran-typewriter/veteran_typewriter.ttf" as="font" type="font/ttf" <link rel="preload" href="assets/fonts/pixelated-elegance/PixelatedEleganceRegular-ovyAA.ttf" as="font" type="font/ttf"
crossorigin> 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>
<style> <style>
@font-face { @font-face {
font-family: 'VeteranTypewriter'; font-family: 'PixelatedElegance';
src: url('assets/fonts/veteran-typewriter/veteran_typewriter.ttf') format('truetype'); src: url('assets/fonts/pixelated-elegance/PixelatedEleganceRegular-ovyAA.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');
} }
</style> </style>
<title>b0esche_cloud</title> <title>b0esche_cloud</title>
<link rel="manifest" href="manifest.json"> <link rel="manifest" href="manifest.json">
<!-- PDF.js library for SfPdfViewer on web -->
<script type="module" async>
import * as pdfjsLib from '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>
</head> </head>
<body> <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 module go.b0esche.cloud/backend
go 1.25.5 go 1.24.0
require ( require (
github.com/coreos/go-oidc/v3 v3.17.0
github.com/go-chi/chi/v5 v5.2.3 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 ( require (
github.com/coreos/go-oidc/v3 v3.17.0 // indirect
github.com/go-jose/go-jose/v4 v4.1.3 // indirect github.com/go-jose/go-jose/v4 v4.1.3 // indirect
github.com/golang-jwt/jwt/v4 v4.5.2 // indirect github.com/jackc/chunkreader/v2 v2.0.1 // indirect
github.com/google/uuid v1.6.0 // indirect github.com/jackc/pgio v1.0.0 // indirect
github.com/jackc/pgpassfile 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/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 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/sync v0.13.0 // indirect
golang.org/x/sys v0.32.0 // indirect
golang.org/x/text v0.24.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 h1:hWBGaQfbi0iVviX4ibC7bk8OKT5qNr4klBaCHVNvehc=
github.com/coreos/go-oidc/v3 v3.17.0/go.mod h1:wqPbKFrVnE90vty060SB40FCJ8fTHTxSwyXJqZH+sI8= 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.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 h1:WQIt9uxdsAbgIYgid+BpYc+liqQZGMHRaUwp0JUcvdE=
github.com/go-chi/chi/v5 v5.2.3/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops= 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 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs=
github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= 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 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI=
github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= 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/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= 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 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 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 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= 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 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= 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 h1:rWQc5FwZSPX58r1OQmkuaNicxdmExaEz5A2DO2hUuTk=
github.com/jackc/pgx/v5 v5.7.6/go.mod h1:aruU7o91Tc2q2cFp5h4uP3f6ztExVpyVv88Xl/8Vl8M= 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 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= 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/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.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.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.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 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE=
golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc= 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 h1:CrgCKl8PPAVtLnU3c+EDw6x11699EWlsDeWNWKdIOkc=
golang.org/x/oauth2 v0.28.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT8= 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 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610=
golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= 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 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0=
golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU= 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 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.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/base64"
"encoding/json" "encoding/json"
"fmt" "fmt"
"strings"
"github.com/google/uuid" "github.com/google/uuid"
"go.b0esche.cloud/backend/internal/database" "go.b0esche.cloud/backend/internal/database"
"golang.org/x/crypto/argon2"
"golang.org/x/crypto/bcrypt" "golang.org/x/crypto/bcrypt"
) )
@@ -17,6 +19,12 @@ const (
RPID = "b0esche.cloud" RPID = "b0esche.cloud"
RPName = "b0esche Cloud" RPName = "b0esche Cloud"
Origin = "https://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 { type Service struct {
@@ -284,19 +292,76 @@ func byteArraysEqual(a, b []byte) bool {
return true 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) { func (s *Service) HashPassword(password string) (string, error) {
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) // Generate 16-byte random salt
if err != nil { salt := make([]byte, 16)
return "", fmt.Errorf("failed to hash password: %w", err) 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 // 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 { func (s *Service) VerifyPassword(passwordHash string, password string) bool {
err := bcrypt.CompareHashAndPassword([]byte(passwordHash), []byte(password)) // Detect hash format
return err == nil 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 // VerifyPasswordLogin verifies username and password credentials

View File

@@ -1,6 +1,7 @@
package config package config
import ( import (
"fmt"
"os" "os"
) )
@@ -12,10 +13,15 @@ type Config struct {
OIDCClientID string OIDCClientID string
OIDCClientSecret string OIDCClientSecret string
JWTSecret string JWTSecret string
NextcloudURL string
NextcloudUser string
NextcloudPass string
NextcloudBase string
AllowedOrigins string
} }
func Load() *Config { func Load() *Config {
return &Config{ cfg := &Config{
ServerAddr: getEnv("SERVER_ADDR", ":8080"), ServerAddr: getEnv("SERVER_ADDR", ":8080"),
DatabaseURL: os.Getenv("DATABASE_URL"), DatabaseURL: os.Getenv("DATABASE_URL"),
OIDCIssuerURL: os.Getenv("OIDC_ISSUER_URL"), OIDCIssuerURL: os.Getenv("OIDC_ISSUER_URL"),
@@ -23,7 +29,14 @@ func Load() *Config {
OIDCClientID: os.Getenv("OIDC_CLIENT_ID"), OIDCClientID: os.Getenv("OIDC_CLIENT_ID"),
OIDCClientSecret: os.Getenv("OIDC_CLIENT_SECRET"), OIDCClientSecret: os.Getenv("OIDC_CLIENT_SECRET"),
JWTSecret: os.Getenv("JWT_SECRET"), JWTSecret: os.Getenv("JWT_SECRET"),
NextcloudURL: os.Getenv("NEXTCLOUD_URL"),
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 { func getEnv(key, defaultVal string) string {

View File

@@ -3,6 +3,9 @@ package database
import ( import (
"context" "context"
"database/sql" "database/sql"
"database/sql/driver"
"encoding/json"
"log"
"time" "time"
"github.com/google/uuid" "github.com/google/uuid"
@@ -16,6 +19,49 @@ func New(db *sql.DB) *DB {
return &DB{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 { type User struct {
ID uuid.UUID ID uuid.UUID
Email string Email string
@@ -34,7 +80,7 @@ type Credential struct {
SignCount int64 SignCount int64
CreatedAt time.Time CreatedAt time.Time
LastUsedAt *time.Time LastUsedAt *time.Time
Transports []string Transports StringArray
} }
type AuthChallenge struct { type AuthChallenge struct {
@@ -55,10 +101,11 @@ type Session struct {
} }
type Organization struct { type Organization struct {
ID uuid.UUID ID uuid.UUID `json:"id"`
Name string OwnerID uuid.UUID `json:"ownerId"`
Slug string Name string `json:"name"`
CreatedAt time.Time Slug string `json:"slug"`
CreatedAt time.Time `json:"createdAt"`
} }
type Membership struct { type Membership struct {
@@ -78,6 +125,18 @@ type Activity struct {
Timestamp time.Time 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
}
func (db *DB) GetOrCreateUser(ctx context.Context, sub, email, name string) (*User, error) { func (db *DB) GetOrCreateUser(ctx context.Context, sub, email, name string) (*User, error) {
var user User var user User
err := db.QueryRowContext(ctx, ` err := db.QueryRowContext(ctx, `
@@ -120,9 +179,18 @@ func (db *DB) GetSession(ctx context.Context, sessionID uuid.UUID) (*Session, er
return &session, nil 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) { func (db *DB) GetUserOrganizations(ctx context.Context, userID uuid.UUID) ([]Organization, error) {
rows, err := db.QueryContext(ctx, ` 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 FROM organizations o
JOIN memberships m ON o.id = m.org_id JOIN memberships m ON o.id = m.org_id
WHERE m.user_id = $1 WHERE m.user_id = $1
@@ -135,7 +203,7 @@ func (db *DB) GetUserOrganizations(ctx context.Context, userID uuid.UUID) ([]Org
var orgs []Organization var orgs []Organization
for rows.Next() { for rows.Next() {
var org Organization 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 return nil, err
} }
orgs = append(orgs, org) orgs = append(orgs, org)
@@ -156,13 +224,18 @@ func (db *DB) GetUserMembership(ctx context.Context, userID, orgID uuid.UUID) (*
return &membership, nil 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 var org Organization
err := db.QueryRowContext(ctx, ` err := db.QueryRowContext(ctx, `
INSERT INTO organizations (name, slug) INSERT INTO organizations (owner_id, name, slug)
VALUES ($1, $2) VALUES ($1, $2, $3)
RETURNING id, name, slug, created_at RETURNING id, owner_id, name, slug, created_at
`, name, slug).Scan(&org.ID, &org.Name, &org.Slug, &org.CreatedAt) `, ownerID, name, slug).Scan(&org.ID, &org.OwnerID, &org.Name, &org.Slug, &org.CreatedAt)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -233,6 +306,216 @@ func (db *DB) GetOrgMembers(ctx context.Context, orgID uuid.UUID) ([]Membership,
return memberships, rows.Err() 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
err := db.QueryRowContext(ctx, `
SELECT id, org_id::text, user_id::text, name, path, type, size, last_modified, created_at
FROM files
WHERE id = $1
`, fileID).Scan(&f.ID, &orgNull, &userNull, &f.Name, &f.Path, &f.Type, &f.Size, &f.LastModified, &f.CreatedAt)
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
}
return &f, nil
}
// UpdateFileSize updates the size and modification time of a file
func (db *DB) UpdateFileSize(ctx context.Context, fileID uuid.UUID, size int64) error {
_, err := db.ExecContext(ctx, `
UPDATE files
SET size = $1, last_modified = NOW()
WHERE id = $2
`, size, fileID)
return err
}
// DeleteFileByPath removes a file or folder matching path for a given org or user
func (db *DB) DeleteFileByPath(ctx context.Context, orgID *uuid.UUID, userID *uuid.UUID, path string) error {
var res sql.Result
var err error
if orgID != nil {
res, err = db.ExecContext(ctx, `DELETE FROM files WHERE org_id = $1 AND path = $2`, *orgID, path)
} else if userID != nil {
res, err = db.ExecContext(ctx, `DELETE FROM files WHERE user_id = $1 AND path = $2`, *userID, path)
} else {
return nil
}
if err != nil {
return err
}
_, _ = res.RowsAffected()
return nil
}
func (db *DB) UpdateMemberRole(ctx context.Context, orgID, userID uuid.UUID, role string) error { func (db *DB) UpdateMemberRole(ctx context.Context, orgID, userID uuid.UUID, role string) error {
_, err := db.ExecContext(ctx, ` _, err := db.ExecContext(ctx, `
UPDATE memberships UPDATE memberships
@@ -391,3 +674,5 @@ func (db *DB) MarkChallengeUsed(ctx context.Context, challenge []byte) error {
`, challenge) `, challenge)
return err return err
} }
// UpdateFileSize updates the size and last_modified timestamp of a file

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,605 @@
package http
import (
"encoding/json"
"fmt"
"io"
"net/http"
"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"
)
// 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
if file.UserID != nil && *file.UserID == userID {
canAccess = true
// Get user's WebDAV client - need to pass config
// For now, create a new WebDAV client without full config
webDAVClient, err = getUserWebDAVClient(r.Context(), db, userID, "http://nc.b0esche.cloud", "admin", "")
if err != nil {
fmt.Printf("[WOPI-STORAGE] Failed to get user WebDAV client: %v\n", err)
errors.WriteError(w, errors.CodeInternal, "Storage error", http.StatusInternalServerError)
return
}
} 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
// Create admin WebDAV client for org files
cfg := &config.Config{
NextcloudURL: "http://nc.b0esche.cloud",
NextcloudUser: "admin",
NextcloudPass: "",
NextcloudBase: "/",
}
webDAVClient = storage.NewWebDAVClient(cfg)
}
}
if !canAccess {
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
resp, err := webDAVClient.Download(r.Context(), file.Path, "")
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) {
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
if file.UserID != nil && *file.UserID == userID {
canAccess = true
webDAVClient, err = getUserWebDAVClient(r.Context(), db, userID, "http://nc.b0esche.cloud", "admin", "")
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
}
} else if file.OrgID != nil {
member, err := db.GetOrgMember(r.Context(), *file.OrgID, userID)
if err == nil && member != nil {
canAccess = true
// Create admin WebDAV client for org files
cfg := &config.Config{
NextcloudURL: "http://nc.b0esche.cloud",
NextcloudUser: "admin",
NextcloudPass: "",
NextcloudBase: "/",
}
webDAVClient = storage.NewWebDAVClient(cfg)
}
}
if !canAccess {
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
err = webDAVClient.Upload(r.Context(), file.Path, 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)
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())
}

View File

@@ -2,7 +2,9 @@ package middleware
import ( import (
"context" "context"
"fmt"
"net/http" "net/http"
"regexp"
"strings" "strings"
"go.b0esche.cloud/backend/internal/audit" "go.b0esche.cloud/backend/internal/audit"
@@ -21,6 +23,103 @@ var RequestID = middleware.RequestID
var Logger = middleware.Logger var Logger = middleware.Logger
var Recoverer = middleware.Recoverer 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 // TODO: Implement rate limiter
var RateLimit = func(next http.Handler) http.Handler { var RateLimit = func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -29,33 +128,69 @@ var RateLimit = func(next http.Handler) http.Handler {
}) })
} }
type contextKey string type ContextKey string
const ( const (
userKey contextKey = "user" UserKey ContextKey = "user"
sessionKey contextKey = "session" SessionKey ContextKey = "session"
orgKey contextKey = "org" 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 // Auth middleware
func Auth(jwtManager *jwt.Manager, db *database.DB) func(http.Handler) http.Handler { func Auth(jwtManager *jwt.Manager, db *database.DB) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler { return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
authHeader := r.Header.Get("Authorization") authHeader := r.Header.Get("Authorization")
if !strings.HasPrefix(authHeader, "Bearer ") { var tokenString string
http.Error(w, "Unauthorized", http.StatusUnauthorized) var tokenSource string
return if strings.HasPrefix(authHeader, "Bearer ") {
tokenString = strings.TrimPrefix(authHeader, "Bearer ")
tokenSource = "header"
} else {
// Fallback to query parameter token (for viewers that cannot set headers)
qToken := r.URL.Query().Get("token")
if qToken == "" {
fmt.Printf("[AUTH-TOKEN] source=none, path=%s, statusCode=401\n", r.RequestURI)
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
tokenString = qToken
tokenSource = "query"
} }
tokenString := strings.TrimPrefix(authHeader, "Bearer ") fmt.Printf("[AUTH-TOKEN] source=%s, path=%s\n", tokenSource, r.RequestURI)
claims, session, err := jwtManager.ValidateWithSession(r.Context(), tokenString, db) claims, session, err := jwtManager.ValidateWithSession(r.Context(), tokenString, db)
if err != nil { 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) http.Error(w, "Unauthorized", http.StatusUnauthorized)
return return
} }
ctx := context.WithValue(r.Context(), userKey, claims.UserID) fmt.Printf("[AUTH-TOKEN] valid, source=%s, userId=%s\n", tokenSource, claims.UserID)
ctx = context.WithValue(ctx, sessionKey, session)
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)) 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 { func Org(db *database.DB, auditLogger *audit.Logger) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler { return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
userIDStr := r.Context().Value(userKey).(string) userIDStr := r.Context().Value(UserKey).(string)
userID, _ := uuid.Parse(userIDStr) userID, _ := uuid.Parse(userIDStr)
orgIDStr := r.Header.Get("X-Org-ID") 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 return
} }
ctx := context.WithValue(r.Context(), orgKey, orgID) ctx := context.WithValue(r.Context(), OrgKey, orgID)
next.ServeHTTP(w, r.WithContext(ctx)) next.ServeHTTP(w, r.WithContext(ctx))
}) })
} }
@@ -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 { func Permission(db *database.DB, auditLogger *audit.Logger, perm permission.Permission) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler { return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
userIDStr := r.Context().Value(userKey).(string) userIDStr := r.Context().Value(UserKey).(string)
userID, _ := uuid.Parse(userIDStr) 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) hasPerm, err := permission.HasPermission(r.Context(), db, userID, orgID, perm)
if err != nil || !hasPerm { 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 ( import (
"context" "context"
"fmt"
"regexp"
"strings"
"go.b0esche.cloud/backend/internal/database" "go.b0esche.cloud/backend/internal/database"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/jackc/pgconn"
) )
// ResolveUserOrgs returns the organizations a user belongs to // 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 // 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) { func CreateOrg(ctx context.Context, db *database.DB, userID uuid.UUID, name, slug string) (*database.Organization, error) {
if slug == "" { trimmedName := strings.TrimSpace(name)
// Simple slug generation if trimmedName == "" {
slug = name // TODO: make URL safe 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 { if err != nil {
return nil, err 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 nil, err
} }
return org, nil 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) { 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{ claims := Claims{
UserID: userID, UserID: userID,
OrgIDs: orgIDs, OrgIDs: orgIDs,
SessionID: sessionID, SessionID: sessionID,
RegisteredClaims: jwt.RegisteredClaims{ RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(time.Now().Add(15 * time.Minute)), ExpiresAt: jwt.NewNumericDate(time.Now().Add(duration)),
IssuedAt: jwt.NewNumericDate(time.Now()), IssuedAt: jwt.NewNumericDate(time.Now()),
}, },
} }