Compare commits

...

56 Commits

Author SHA1 Message Date
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
45 changed files with 3714 additions and 1060 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

@@ -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)));
} }

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

@@ -24,10 +24,18 @@ class UploadBloc extends Bloc<UploadEvent, UploadState> {
for (final file in event.files) { for (final file in event.files) {
try { try {
print(
'[UploadBloc] Starting upload for ${file.name} to orgId=${event.orgId}, path=${file.path}',
);
print(
'[UploadBloc] File bytes: ${file.bytes?.length ?? 0} bytes, localPath: ${file.localPath}',
);
// Simulate upload // Simulate upload
await _fileRepository.uploadFile(event.orgId, file); await _fileRepository.uploadFile(event.orgId, file);
print('[UploadBloc] Upload successful for ${file.name}');
add(UploadCompleted(file)); add(UploadCompleted(file));
} catch (e) { } catch (e) {
print('[UploadBloc] Upload failed for ${file.name}: $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,74 @@ 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()),
),
);
}
return MaterialApp.router(
routerConfig: _router,
theme: AppTheme.darkTheme,
);
},
), ),
); );
} }
@override
void dispose() {
_authBloc.close();
_sessionBloc.close();
super.dispose();
}
} }

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

@@ -4,11 +4,210 @@ 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';
// 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 const Center(child: CircularProgressIndicator());
}
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) {
if (state.caps.isPdf) {
return SfPdfViewer.network(
state.viewUrl.toString(),
headers: {'Authorization': 'Bearer ${state.token}'},
);
} else {
return Container(
color: AppTheme.secondaryText,
child: Center(
child: Text(
'Office Document Viewer\\n(URL: ${state.viewUrl})',
textAlign: TextAlign.center,
style: const TextStyle(color: AppTheme.primaryText),
),
),
);
}
}
return const Center(
child: Text(
'No document loaded',
style: TextStyle(color: AppTheme.primaryText),
),
);
},
),
),
],
),
);
}
@override
void dispose() {
_viewerBloc.close();
super.dispose();
}
}
// 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;
@@ -124,8 +323,20 @@ class _DocumentViewerState extends State<DocumentViewer> {
} }
if (state is DocumentViewerReady) { if (state is DocumentViewerReady) {
if (state.caps.isPdf) { if (state.caps.isPdf) {
// Use PDF viewer return BlocBuilder<SessionBloc, SessionState>(
return SfPdfViewer.network(state.viewUrl.toString()); builder: (context, sessionState) {
String? token;
if (sessionState is SessionActive) {
token = sessionState.token;
}
return SfPdfViewer.network(
state.viewUrl.toString(),
headers: token != null
? {'Authorization': 'Bearer $token'}
: {},
);
},
);
} else { } else {
// Placeholder for office docs iframe // Placeholder for office docs iframe
return Container( return Container(

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;

View File

@@ -1,8 +1,9 @@
import 'dart:ui';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:file_picker/file_picker.dart' hide FileType; import 'package:file_picker/file_picker.dart' hide FileType;
import 'package:go_router/go_router.dart';
import 'package:path/path.dart' as p; import 'package:path/path.dart' as p;
import 'dart:html' as html;
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/file_browser/file_browser_state.dart'; import '../blocs/file_browser/file_browser_state.dart';
@@ -11,9 +12,14 @@ import '../blocs/permission/permission_event.dart';
import '../blocs/permission/permission_state.dart'; import '../blocs/permission/permission_state.dart';
import '../blocs/upload/upload_bloc.dart'; import '../blocs/upload/upload_bloc.dart';
import '../blocs/upload/upload_event.dart'; import '../blocs/upload/upload_event.dart';
import '../blocs/upload/upload_state.dart';
import '../models/file_item.dart'; import '../models/file_item.dart';
import '../theme/app_theme.dart'; import '../theme/app_theme.dart';
import '../theme/modern_glass_button.dart'; import '../theme/modern_glass_button.dart';
import 'document_viewer.dart';
import 'editor_page.dart';
import '../injection.dart';
import '../services/file_service.dart';
class FileExplorer extends StatefulWidget { class FileExplorer extends StatefulWidget {
final String orgId; final String orgId;
@@ -124,10 +130,13 @@ class _FileExplorerState extends State<FileExplorer> {
onPressed: () { onPressed: () {
final folderName = controller.text.trim(); final folderName = controller.text.trim();
if (folderName.isNotEmpty) { if (folderName.isNotEmpty) {
final nameWithSlash = folderName.startsWith('/') // Strip any leading/trailing slashes so we only send the bare folder name
? folderName final sanitized = folderName
: '/$folderName'; .replaceAll(RegExp(r'^/+'), '')
Navigator.of(context).pop(nameWithSlash); .replaceAll(RegExp(r'/+$'), '');
if (sanitized.isNotEmpty) {
Navigator.of(context).pop(sanitized);
}
} }
}, },
child: const Text( child: const Text(
@@ -229,7 +238,7 @@ class _FileExplorerState extends State<FileExplorer> {
if (newName.isNotEmpty && newName != file.name) { if (newName.isNotEmpty && newName != file.name) {
context.read<FileBrowserBloc>().add( context.read<FileBrowserBloc>().add(
RenameFile( RenameFile(
orgId: 'org1', orgId: widget.orgId,
path: file.path, path: file.path,
newName: newName, newName: newName,
), ),
@@ -258,10 +267,34 @@ class _FileExplorerState extends State<FileExplorer> {
); );
} }
void _downloadFile(FileItem file) { void _downloadFile(FileItem file) async {
ScaffoldMessenger.of( try {
context, final fileService = getIt<FileService>();
).showSnackBar(SnackBar(content: Text('Download ${file.name}'))); final downloadUrl = await fileService.getDownloadUrl(
widget.orgId,
file.path,
);
// For web, use the download URL with the ApiClient base URL (from DI)
final fullUrl = '${fileService.baseUrl}$downloadUrl';
// Trigger download via anchor element
html.AnchorElement(href: fullUrl)
..setAttribute('download', file.name)
..click();
if (context.mounted) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text('Downloading ${file.name}')));
}
} catch (e) {
if (context.mounted) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text('Download failed: $e')));
}
}
} }
void _sendFile(FileItem file) { void _sendFile(FileItem file) {
@@ -331,7 +364,7 @@ class _FileExplorerState extends State<FileExplorer> {
); );
if (confirmed == true) { if (confirmed == true) {
context.read<FileBrowserBloc>().add( context.read<FileBrowserBloc>().add(
DeleteFile(orgId: 'org1', path: file.path), DeleteFile(orgId: widget.orgId, path: file.path),
); );
} }
} }
@@ -530,10 +563,13 @@ class _FileExplorerState extends State<FileExplorer> {
if (file.type == FileType.folder) { if (file.type == FileType.folder) {
context.read<FileBrowserBloc>().add(NavigateToFolder(file.path)); context.read<FileBrowserBloc>().add(NavigateToFolder(file.path));
} else { } else {
final fileId = file.path.startsWith('/') if (file.id == null || file.id!.isEmpty) {
? file.path.substring(1) ScaffoldMessenger.of(context).showSnackBar(
: file.path; const SnackBar(content: Text('Error: File ID is missing')),
context.go('/viewer/${widget.orgId}/$fileId'); );
return;
}
_showDocumentViewer(widget.orgId, file.id!);
} }
}, },
child: Container( child: Container(
@@ -632,387 +668,519 @@ class _FileExplorerState extends State<FileExplorer> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return BlocBuilder<FileBrowserBloc, FileBrowserState>( return BlocListener<UploadBloc, UploadState>(
builder: (context, state) { listener: (context, uploadState) {
if (state is DirectoryLoading) { if (uploadState is UploadInProgress) {
return Center( // Show error if any upload failed
child: CircularProgressIndicator(color: AppTheme.accentColor), for (final upload in uploadState.uploads) {
); if (upload.error != null && upload.error!.isNotEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Upload failed: ${upload.error}')),
);
}
}
final hasCompleted = uploadState.uploads.any((u) => u.isCompleted);
if (hasCompleted) {
final fbState = context.read<FileBrowserBloc>().state;
String currentPath = '/';
if (fbState is DirectoryLoaded) currentPath = fbState.currentPath;
if (fbState is DirectoryEmpty) currentPath = fbState.currentPath;
context.read<FileBrowserBloc>().add(
LoadDirectory(orgId: widget.orgId, path: currentPath),
);
}
} }
},
child: BlocBuilder<FileBrowserBloc, FileBrowserState>(
builder: (context, state) {
if (state is DirectoryLoading) {
return Center(
child: CircularProgressIndicator(color: AppTheme.accentColor),
);
}
if (state is DirectoryError) { if (state is DirectoryError) {
return Center( return Center(
child: Text( child: Text(
'Error: ${state.error}', 'Error: ${state.error}',
style: const TextStyle(color: AppTheme.primaryText), style: const TextStyle(color: AppTheme.primaryText),
), ),
); );
} }
if (state is DirectoryEmpty) { if (state is DirectoryEmpty) {
return Padding( return Padding(
padding: const EdgeInsets.all(16.0), padding: const EdgeInsets.all(16.0),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Container( Container(
decoration: BoxDecoration( decoration: BoxDecoration(
border: Border( border: Border(
bottom: BorderSide( bottom: BorderSide(
color: AppTheme.primaryText.withValues(alpha: 0.5), color: AppTheme.primaryText.withValues(alpha: 0.5),
width: 1, width: 1,
),
), ),
), ),
child: _buildTitle(),
), ),
child: _buildTitle(), const SizedBox(height: 16),
), BlocBuilder<PermissionBloc, PermissionState>(
const SizedBox(height: 16), builder: (context, permState) {
BlocBuilder<PermissionBloc, PermissionState>( if (permState is PermissionLoaded &&
builder: (context, permState) { permState.capabilities.canWrite) {
if (permState is PermissionLoaded && return Row(
permState.capabilities.canWrite) { children: [
return Row( ModernGlassButton(
children: [ onPressed: () async {
ModernGlassButton( final result = await FilePicker.platform
onPressed: () async { .pickFiles(withData: true);
final result = await FilePicker.platform if (result != null && result.files.isNotEmpty) {
.pickFiles(); final files = result.files
if (result != null && result.files.isNotEmpty) { .map(
final files = result.files (file) => FileItem(
.map( name: file.name,
(file) => FileItem( // Parent path only; server uses filename from multipart
name: file.name, path: state.currentPath,
path: '/${file.name}', type: FileType.file,
type: FileType.file, size: file.size,
size: file.size, lastModified: DateTime.now(),
lastModified: DateTime.now(), localPath: file.path,
), bytes: file.bytes,
) ),
.toList(); )
context.read<UploadBloc>().add( .toList();
StartUpload( context.read<UploadBloc>().add(
files: files, StartUpload(
targetPath: '/', files: files,
orgId: 'org1', targetPath: state.currentPath,
), orgId: widget.orgId,
);
}
},
child: const Row(
children: [
Icon(Icons.upload),
SizedBox(width: 8),
Text('Upload File'),
],
),
),
const SizedBox(width: 16),
ModernGlassButton(
onPressed: () async {
final folderName = await _showCreateFolderDialog(
context,
);
if (folderName != null && folderName.isNotEmpty) {
context.read<FileBrowserBloc>().add(
CreateFolder(
orgId: 'org1',
parentPath: '/',
folderName: folderName,
),
);
}
},
child: const Row(
children: [
Icon(Icons.create_new_folder),
SizedBox(width: 8),
Text('New Folder'),
],
),
),
],
);
}
return const SizedBox.shrink();
},
),
const SizedBox(height: 16),
Row(
children: [
IconButton(
icon: const Icon(
Icons.arrow_back,
color: AppTheme.primaryText,
),
splashColor: Colors.transparent,
highlightColor: Colors.transparent,
onPressed: () {
final parentPath = _getParentPath(state.currentPath);
context.read<FileBrowserBloc>().add(
LoadDirectory(orgId: 'org1', path: parentPath),
);
},
),
const Text(
'Empty Folder',
style: TextStyle(color: AppTheme.primaryText),
),
],
),
],
),
);
}
if (state is DirectoryLoaded) {
return Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
decoration: BoxDecoration(
border: Border(
bottom: BorderSide(
color: AppTheme.primaryText.withValues(alpha: 0.5),
width: 1,
),
),
),
child: _buildTitle(),
),
const SizedBox(height: 16),
Visibility(
visible: state.breadcrumbs.isNotEmpty,
child: Column(
children: [
Row(
children: [
IconButton(
icon: const Icon(
Icons.arrow_back,
color: AppTheme.primaryText,
),
onPressed: () {
final parentPath = _getParentPath(
state.currentPath,
);
context.read<FileBrowserBloc>().add(
LoadDirectory(orgId: 'org1', path: parentPath),
);
},
),
Expanded(
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: state.breadcrumbs.map((breadcrumb) {
return TextButton(
onPressed: () {
context.read<FileBrowserBloc>().add(
NavigateToFolder(breadcrumb.path),
);
},
child: Text(
'/${breadcrumb.name}',
style: const TextStyle(
color: AppTheme.secondaryText,
),
), ),
); );
}).toList(), }
},
child: const Row(
children: [
Icon(Icons.upload),
SizedBox(width: 8),
Text('Upload File'),
],
), ),
), ),
), const SizedBox(width: 16),
], ModernGlassButton(
), onPressed: () async {
const SizedBox(height: 16), final folderName =
], await _showCreateFolderDialog(context);
), if (folderName != null &&
), folderName.isNotEmpty) {
BlocBuilder<PermissionBloc, PermissionState>( context.read<FileBrowserBloc>().add(
builder: (context, permState) { CreateFolder(
if (permState is PermissionLoaded && orgId: widget.orgId,
permState.capabilities.canWrite) { parentPath: '/',
return Row( folderName: folderName,
children: [ ),
ModernGlassButton( );
onPressed: () async { }
final result = await FilePicker.platform },
.pickFiles(); child: const Row(
if (result != null && result.files.isNotEmpty) { children: [
final files = result.files Icon(Icons.create_new_folder),
.map( SizedBox(width: 8),
(file) => FileItem( Text('New Folder'),
name: file.name, ],
path: '/${file.name}',
type: FileType.file,
size: file.size,
lastModified: DateTime.now(),
),
)
.toList();
context.read<UploadBloc>().add(
StartUpload(
files: files,
targetPath: '/',
orgId: 'org1',
),
);
}
},
child: const Row(
children: [
Icon(Icons.upload),
SizedBox(width: 8),
Text('Upload File'),
],
),
),
const SizedBox(width: 16),
ModernGlassButton(
onPressed: () async {
final folderName = await _showCreateFolderDialog(
context,
);
if (folderName != null && folderName.isNotEmpty) {
context.read<FileBrowserBloc>().add(
CreateFolder(
orgId: 'org1',
parentPath: state.currentPath,
folderName: folderName,
),
);
}
},
child: const Row(
children: [
Icon(Icons.create_new_folder),
SizedBox(width: 8),
Text('New Folder'),
],
),
),
],
);
}
return const SizedBox.shrink();
},
),
const SizedBox(height: 16),
Expanded(
child: ListView.builder(
itemCount: state.paginatedFiles.length,
itemBuilder: (context, index) {
final file = state.paginatedFiles[index];
final isSelected = _selectedFilePath == file.path;
final isHovered = _hovered[file.path] ?? false;
if (file.type == FileType.folder) {
return DragTarget<FileItem>(
builder: (context, candidate, rejected) {
final isDraggedOver = candidate.isNotEmpty;
return Draggable<FileItem>(
data: file,
feedback: Opacity(
opacity: 0.8,
child: Icon(
Icons.folder,
color: AppTheme.primaryText,
size: 48,
),
), ),
child: _buildFileItem(
file,
isSelected,
isHovered,
isDraggedOver,
),
);
},
onAcceptWithDetails: (draggedFile) {
context.read<FileBrowserBloc>().add(
MoveFile(
orgId: 'org1',
sourcePath: draggedFile.data.path,
targetPath: file.path,
),
);
},
);
} else {
return Draggable<FileItem>(
data: file,
feedback: Opacity(
opacity: 0.8,
child: Icon(
Icons.insert_drive_file,
color: AppTheme.primaryText,
size: 48,
), ),
), ],
child: _buildFileItem(
file,
isSelected,
isHovered,
false,
),
); );
} }
return const SizedBox.shrink();
}, },
), ),
),
if (state.totalPages > 1) ...[
const SizedBox(height: 16), const SizedBox(height: 16),
Row( Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
IconButton( IconButton(
icon: const Icon( icon: const Icon(
Icons.chevron_left, Icons.arrow_back,
color: AppTheme.primaryText, color: AppTheme.primaryText,
), ),
splashColor: Colors.transparent, splashColor: Colors.transparent,
highlightColor: Colors.transparent, highlightColor: Colors.transparent,
onPressed: state.currentPage > 1 onPressed: () {
? () { final parentPath = _getParentPath(state.currentPath);
context.read<FileBrowserBloc>().add( context.read<FileBrowserBloc>().add(
LoadPage(state.currentPage - 1), LoadDirectory(
); orgId: widget.orgId,
} path: parentPath,
: null, ),
);
},
), ),
Text( const Text(
'${state.currentPage} / ${state.totalPages}', 'Empty Folder',
style: const TextStyle( style: TextStyle(color: AppTheme.primaryText),
color: AppTheme.primaryText,
fontSize: 16,
),
),
IconButton(
icon: const Icon(
Icons.chevron_right,
color: AppTheme.primaryText,
),
splashColor: Colors.transparent,
highlightColor: Colors.transparent,
onPressed: state.currentPage < state.totalPages
? () {
context.read<FileBrowserBloc>().add(
LoadPage(state.currentPage + 1),
);
}
: null,
), ),
], ],
), ),
], ],
], ),
), );
); }
}
return const SizedBox.shrink(); if (state is DirectoryLoaded) {
return Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
decoration: BoxDecoration(
border: Border(
bottom: BorderSide(
color: AppTheme.primaryText.withValues(alpha: 0.5),
width: 1,
),
),
),
child: _buildTitle(),
),
const SizedBox(height: 16),
Visibility(
visible: state.breadcrumbs.isNotEmpty,
child: Column(
children: [
Row(
children: [
IconButton(
icon: const Icon(
Icons.arrow_back,
color: AppTheme.primaryText,
),
onPressed: () {
final parentPath = _getParentPath(
state.currentPath,
);
context.read<FileBrowserBloc>().add(
LoadDirectory(
orgId: widget.orgId,
path: parentPath,
),
);
},
),
Expanded(
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: state.breadcrumbs.map((breadcrumb) {
return TextButton(
onPressed: () {
context.read<FileBrowserBloc>().add(
NavigateToFolder(breadcrumb.path),
);
},
child: Text(
'/${breadcrumb.name}',
style: const TextStyle(
color: AppTheme.secondaryText,
),
),
);
}).toList(),
),
),
),
],
),
const SizedBox(height: 16),
],
),
),
BlocBuilder<PermissionBloc, PermissionState>(
builder: (context, permState) {
if (permState is PermissionLoaded &&
permState.capabilities.canWrite) {
return Row(
children: [
ModernGlassButton(
onPressed: () async {
final result = await FilePicker.platform
.pickFiles(withData: true);
if (result != null && result.files.isNotEmpty) {
final files = result.files
.map(
(file) => FileItem(
name: file.name,
// Parent path only; server uses filename from multipart
path: state.currentPath,
type: FileType.file,
size: file.size,
lastModified: DateTime.now(),
localPath: file.path,
bytes: file.bytes,
),
)
.toList();
context.read<UploadBloc>().add(
StartUpload(
files: files,
targetPath: state.currentPath,
orgId: widget.orgId,
),
);
}
},
child: const Row(
children: [
Icon(Icons.upload),
SizedBox(width: 8),
Text('Upload File'),
],
),
),
const SizedBox(width: 16),
ModernGlassButton(
onPressed: () async {
final folderName =
await _showCreateFolderDialog(context);
if (folderName != null &&
folderName.isNotEmpty) {
context.read<FileBrowserBloc>().add(
CreateFolder(
orgId: widget.orgId,
parentPath: state.currentPath,
folderName: folderName,
),
);
}
},
child: const Row(
children: [
Icon(Icons.create_new_folder),
SizedBox(width: 8),
Text('New Folder'),
],
),
),
],
);
}
return const SizedBox.shrink();
},
),
const SizedBox(height: 16),
Expanded(
child: ListView.builder(
itemCount: state.paginatedFiles.length,
itemBuilder: (context, index) {
final file = state.paginatedFiles[index];
final isSelected = _selectedFilePath == file.path;
final isHovered = _hovered[file.path] ?? false;
if (file.type == FileType.folder) {
return DragTarget<FileItem>(
builder: (context, candidate, rejected) {
final isDraggedOver = candidate.isNotEmpty;
return Draggable<FileItem>(
data: file,
feedback: Opacity(
opacity: 0.8,
child: Icon(
Icons.folder,
color: AppTheme.primaryText,
size: 48,
),
),
child: _buildFileItem(
file,
isSelected,
isHovered,
isDraggedOver,
),
);
},
onAcceptWithDetails: (draggedFile) {
context.read<FileBrowserBloc>().add(
MoveFile(
orgId: widget.orgId,
sourcePath: draggedFile.data.path,
targetPath: file.path,
),
);
},
);
} else {
return Draggable<FileItem>(
data: file,
feedback: Opacity(
opacity: 0.8,
child: Icon(
Icons.insert_drive_file,
color: AppTheme.primaryText,
size: 48,
),
),
child: _buildFileItem(
file,
isSelected,
isHovered,
false,
),
);
}
},
),
),
if (state.totalPages > 1) ...[
const SizedBox(height: 16),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
IconButton(
icon: const Icon(
Icons.chevron_left,
color: AppTheme.primaryText,
),
splashColor: Colors.transparent,
highlightColor: Colors.transparent,
onPressed: state.currentPage > 1
? () {
context.read<FileBrowserBloc>().add(
LoadPage(state.currentPage - 1),
);
}
: null,
),
Text(
'${state.currentPage} / ${state.totalPages}',
style: const TextStyle(
color: AppTheme.primaryText,
fontSize: 16,
),
),
IconButton(
icon: const Icon(
Icons.chevron_right,
color: AppTheme.primaryText,
),
splashColor: Colors.transparent,
highlightColor: Colors.transparent,
onPressed: state.currentPage < state.totalPages
? () {
context.read<FileBrowserBloc>().add(
LoadPage(state.currentPage + 1),
);
}
: null,
),
],
),
],
],
),
);
}
return const SizedBox.shrink();
},
),
);
}
void _showDocumentViewer(String orgId, String fileId) {
showDialog(
context: context,
barrierDismissible: false,
barrierColor: Colors.transparent,
builder: (BuildContext context) {
return BackdropFilter(
filter: ImageFilter.blur(sigmaX: 10, sigmaY: 10),
child: Dialog(
backgroundColor: Colors.transparent,
insetPadding: const EdgeInsets.all(16),
child: Container(
constraints: BoxConstraints(
maxWidth: MediaQuery.of(context).size.width * 0.9,
maxHeight: MediaQuery.of(context).size.height * 0.9,
),
decoration: BoxDecoration(
color: AppTheme.primaryBackground,
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: AppTheme.accentColor.withValues(alpha: 0.3),
width: 2,
),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.5),
blurRadius: 20,
spreadRadius: 5,
),
],
),
child: ClipRRect(
borderRadius: BorderRadius.circular(14),
child: DocumentViewerModal(
orgId: orgId,
fileId: fileId,
onEdit: () {
Navigator.of(context).pop();
_showDocumentEditor(orgId, fileId);
},
onClose: () => Navigator.of(context).pop(),
),
),
),
),
);
},
);
}
void _showDocumentEditor(String orgId, String fileId) {
showDialog(
context: context,
barrierDismissible: false,
barrierColor: Colors.transparent,
builder: (BuildContext context) {
return BackdropFilter(
filter: ImageFilter.blur(sigmaX: 10, sigmaY: 10),
child: Dialog(
backgroundColor: Colors.transparent,
insetPadding: const EdgeInsets.all(16),
child: Container(
constraints: BoxConstraints(
maxWidth: MediaQuery.of(context).size.width * 0.9,
maxHeight: MediaQuery.of(context).size.height * 0.9,
),
decoration: BoxDecoration(
color: AppTheme.primaryBackground,
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: AppTheme.accentColor.withValues(alpha: 0.3),
width: 2,
),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.5),
blurRadius: 20,
spreadRadius: 5,
),
],
),
child: ClipRRect(
borderRadius: BorderRadius.circular(14),
child: EditorPageModal(
orgId: orgId,
fileId: fileId,
onClose: () => Navigator.of(context).pop(),
),
),
),
),
);
}, },
); );
} }

View File

@@ -8,10 +8,16 @@ import '../blocs/organization/organization_bloc.dart';
import '../blocs/organization/organization_event.dart'; import '../blocs/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,6 +77,10 @@ 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(
@@ -132,37 +163,52 @@ 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: [
...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),
],
);
}, },
); );
} }
@@ -190,10 +236,19 @@ class _HomePageState extends State<HomePage> with TickerProviderStateMixin {
); );
} }
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 +293,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), ? 60.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();
@@ -40,6 +46,12 @@ class _LoginFormState extends State<LoginForm> {
return values.map((v) => v.toRadixString(16).padLeft(2, '0')).join(); return values.map((v) => v.toRadixString(16).padLeft(2, '0')).join();
} }
String _generateRandomBase64(int bytes) {
final random = Random();
final values = List<int>.generate(bytes, (i) => random.nextInt(256));
return base64.encode(values);
}
Future<void> _handleAuthentication( Future<void> _handleAuthentication(
BuildContext context, BuildContext context,
AuthenticationChallengeReceived state, AuthenticationChallengeReceived state,
@@ -47,7 +59,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 +67,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 +88,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 +100,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 +145,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 +178,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 +295,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';
@@ -34,6 +35,12 @@ class _SignupFormState extends State<SignupForm> {
return values.map((v) => v.toRadixString(16).padLeft(2, '0')).join(); return values.map((v) => v.toRadixString(16).padLeft(2, '0')).join();
} }
String _generateRandomBase64(int bytes) {
final random = Random();
final values = List<int>.generate(bytes, (i) => random.nextInt(256));
return base64.encode(values);
}
Future<void> _handleRegistration( Future<void> _handleRegistration(
BuildContext context, BuildContext context,
RegistrationChallengeReceived state, RegistrationChallengeReceived state,
@@ -41,8 +48,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 +60,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 {
throw UnimplementedError();
}
@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

@@ -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,
@@ -153,6 +164,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 +187,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 +207,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 +230,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

View File

@@ -29,22 +29,7 @@ class ApiClient {
}, },
onError: (error, handler) async { onError: (error, handler) async {
if (error.response?.statusCode == 401) { if (error.response?.statusCode == 401) {
// Try refresh // Session expired, trigger logout
final refreshSuccess = await _tryRefreshToken();
if (refreshSuccess) {
// Retry the request
final token = _getCurrentToken();
if (token != null) {
error.requestOptions.headers['Authorization'] = 'Bearer $token';
try {
final response = await _dio.fetch(error.requestOptions);
return handler.resolve(response);
} catch (e) {
// If retry fails, proceed to error
}
}
}
// If refresh failed, logout
_sessionBloc.add(SessionExpired()); _sessionBloc.add(SessionExpired());
} }
return handler.next(error); return handler.next(error);
@@ -53,6 +38,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 +49,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,

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(
@@ -65,11 +176,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

@@ -70,14 +70,7 @@ void main() {
act: (bloc) => bloc.add(DocumentOpened(orgId: 'org1', fileId: 'file1')), act: (bloc) => bloc.add(DocumentOpened(orgId: 'org1', fileId: 'file1')),
expect: () => [ expect: () => [
DocumentViewerLoading(), DocumentViewerLoading(),
DocumentViewerReady( DocumentViewerError(message: 'Failed to open document: Server error'),
viewUrl: Uri.parse('https://example.com/view'),
caps: DocumentCapabilities(
canEdit: true,
canAnnotate: false,
isPdf: false,
),
),
], ],
); );
@@ -103,7 +96,15 @@ void main() {
act: (bloc) => bloc.add(DocumentOpened(orgId: 'org1', fileId: 'file1')), act: (bloc) => bloc.add(DocumentOpened(orgId: 'org1', fileId: 'file1')),
expect: () => [ expect: () => [
DocumentViewerLoading(), DocumentViewerLoading(),
DocumentViewerError(message: 'Failed to open document: Server error'), DocumentViewerReady(
viewUrl: Uri.parse('https://example.com/view'),
caps: DocumentCapabilities(
canEdit: true,
canAnnotate: false,
isPdf: false,
),
token: 'mock-token',
),
], ],
); );

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

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

@@ -12,6 +12,11 @@ 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 {
@@ -23,6 +28,11 @@ 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,http://localhost:8080"),
} }
} }

View File

@@ -55,10 +55,10 @@ type Session struct {
} }
type Organization struct { type Organization struct {
ID uuid.UUID ID uuid.UUID `json:"id"`
Name string Name string `json:"name"`
Slug string Slug string `json:"slug"`
CreatedAt time.Time CreatedAt time.Time `json:"createdAt"`
} }
type Membership struct { type Membership struct {
@@ -78,6 +78,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,6 +132,15 @@ 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.name, o.slug, o.created_at
@@ -233,6 +254,181 @@ 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, path string, q string, page, pageSize int) ([]File, error) {
if page <= 0 {
page = 1
}
if pageSize <= 0 {
pageSize = 100
}
offset := (page - 1) * pageSize
// 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 id, org_id::text, user_id::text, name, path, type, size, last_modified, created_at
FROM files
WHERE org_id = $1
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 name
LIMIT $4 OFFSET $5
`, orgID, 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)
}
return files, rows.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
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 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 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)
}
return files, rows.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{}
if orgID != nil {
orgIDVal = *orgID
} else {
orgIDVal = nil
}
if userID != nil {
userIDVal = *userID
} else {
userIDVal = nil
}
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
}
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
}
// 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

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

@@ -21,6 +21,40 @@ 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 {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
origin := r.Header.Get("Origin")
// Check if origin is allowed
if origin != "" {
// Simple check - in production you'd want to parse allowedOrigins properly
for _, allowed := range strings.Split(allowedOrigins, ",") {
if strings.TrimSpace(allowed) == origin {
w.Header().Set("Access-Control-Allow-Origin", origin)
w.Header().Set("Access-Control-Allow-Credentials", "true")
break
}
}
}
// Fallback to * if no credentials needed
if w.Header().Get("Access-Control-Allow-Origin") == "" {
w.Header().Set("Access-Control-Allow-Origin", "*")
}
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
w.Header().Set("Access-Control-Max-Age", "3600")
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusOK)
return
}
next.ServeHTTP(w, r)
})
}
}
// 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) {
@@ -34,9 +68,28 @@ type contextKey string
const ( const (
userKey contextKey = "user" userKey contextKey = "user"
sessionKey contextKey = "session" sessionKey contextKey = "session"
tokenKey contextKey = "token"
orgKey contextKey = "org" 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 {
@@ -56,6 +109,7 @@ func Auth(jwtManager *jwt.Manager, db *database.DB) func(http.Handler) http.Hand
ctx := context.WithValue(r.Context(), userKey, claims.UserID) ctx := context.WithValue(r.Context(), userKey, claims.UserID)
ctx = context.WithValue(ctx, sessionKey, session) ctx = context.WithValue(ctx, sessionKey, session)
ctx = context.WithValue(ctx, tokenKey, tokenString)
next.ServeHTTP(w, r.WithContext(ctx)) next.ServeHTTP(w, r.WithContext(ctx))
}) })
} }

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, 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,190 @@
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) == "" {
return nil
}
u := strings.TrimRight(cfg.NextcloudURL, "/")
base := cfg.NextcloudBase
if base == "" {
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 = "/"
}
full := fmt.Sprintf("%s%s/%s", c.baseURL, u, url.PathEscape(rel))
full = strings.ReplaceAll(full, "%2F", "/")
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) (io.ReadCloser, int64, error) {
if c == nil {
return nil, 0, fmt.Errorf("no webdav client configured")
}
rel := strings.TrimLeft(remotePath, "/")
u := c.basePrefix
if u == "/" || u == "" {
u = "/"
}
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, 0, err
}
if c.user != "" {
req.SetBasicAuth(c.user, c.pass)
}
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, 0, err
}
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
return resp.Body, resp.ContentLength, nil
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
return nil, 0, 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 = "/"
}
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))
}

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,46 @@
#!/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/3: Initial schema..."
run_migration "$SCRIPT_DIR/0001_initial.sql"
echo
echo "Step 2/3: Passkeys and authentication..."
run_migration "$SCRIPT_DIR/0002_passkeys.sql"
echo
echo "Step 3/3: Files and storage..."
run_migration "$SCRIPT_DIR/0003_files.sql"
echo
echo "=== All migrations completed successfully! ==="