Compare commits
39 Commits
dev
...
260b8b180e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
260b8b180e | ||
|
|
4f67ead22d | ||
|
|
14a86b8ae1 | ||
|
|
708d4ca790 | ||
|
|
aac6d2eb46 | ||
|
|
d20840f4a6 | ||
|
|
a1ff88bfd9 | ||
|
|
cfeae0a199 | ||
|
|
bb33ad1241 | ||
|
|
3ec4f9d331 | ||
|
|
2a70212123 | ||
|
|
b3b31f9c4c | ||
|
|
e6c87f6044 | ||
|
|
6866f7fdab | ||
|
|
8114a3746b | ||
|
|
a9d205f454 | ||
|
|
a7b29c990b | ||
|
|
9daccbae82 | ||
|
|
2ab0786e30 | ||
|
|
7489c7b1e7 | ||
|
|
f18e779375 | ||
|
|
2a62e13fc7 | ||
|
|
e16b1bb083 | ||
|
|
ebb97f4f39 | ||
|
|
e9df8f7d9f | ||
|
|
6a0c5780fd | ||
|
|
2876d9980f | ||
|
|
332b89e348 | ||
|
|
b99898815a | ||
|
|
bd5c424786 | ||
|
|
5caf3f6b62 | ||
|
|
b18a171ac2 | ||
|
|
37e1c1a616 | ||
|
|
6a01fe84ac | ||
|
|
7adde54a41 | ||
|
|
1eb8781550 | ||
|
|
352e3ee6c5 | ||
|
|
1930eb37fb | ||
|
|
912fc99e9e |
BIN
b0esche_cloud/assets/icons/b0esche-cloud-icon-cut.png
Normal file
|
After Width: | Height: | Size: 589 KiB |
BIN
b0esche_cloud/assets/icons/b0esche-cloud-icon-sharp.png
Normal file
|
After Width: | Height: | Size: 574 KiB |
BIN
b0esche_cloud/assets/icons/b0esche-cloud-icon.png
Normal file
|
After Width: | Height: | Size: 731 KiB |
@@ -1,6 +1,7 @@
|
||||
import 'package:bloc/bloc.dart';
|
||||
import '../session/session_bloc.dart';
|
||||
import '../session/session_event.dart';
|
||||
import '../session/session_state.dart';
|
||||
import 'auth_event.dart';
|
||||
import 'auth_state.dart';
|
||||
import '../../services/api_client.dart';
|
||||
@@ -252,7 +253,22 @@ class AuthBloc extends Bloc<AuthEvent, AuthState> {
|
||||
CheckAuthRequested event,
|
||||
Emitter<AuthState> emit,
|
||||
) async {
|
||||
// Check if token is valid in SessionBloc
|
||||
emit(AuthUnauthenticated());
|
||||
// Check if session is active from persistent storage
|
||||
final sessionState = sessionBloc.state;
|
||||
|
||||
if (sessionState is SessionActive) {
|
||||
// Session already active - emit authenticated state with minimal info
|
||||
// The full user info will be fetched when needed
|
||||
emit(
|
||||
AuthAuthenticated(
|
||||
token: sessionState.token,
|
||||
userId: '',
|
||||
username: '',
|
||||
email: '',
|
||||
),
|
||||
);
|
||||
} else {
|
||||
emit(AuthUnauthenticated());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,11 +16,12 @@ class PermissionBloc extends Bloc<PermissionEvent, PermissionState> {
|
||||
// Simulate loading permissions from backend for orgId
|
||||
await Future.delayed(const Duration(seconds: 1));
|
||||
// Mock capabilities based on orgId
|
||||
// Allow all permissions for authenticated users (proper permissions should come from backend)
|
||||
final capabilities = Capabilities(
|
||||
canRead: true,
|
||||
canWrite: event.orgId == 'org1', // Only admin for personal
|
||||
canShare: event.orgId == 'org1',
|
||||
canAdmin: event.orgId == 'org1',
|
||||
canWrite: true,
|
||||
canShare: true,
|
||||
canAdmin: true,
|
||||
canAnnotate: true,
|
||||
canEdit: true,
|
||||
);
|
||||
|
||||
@@ -1,42 +1,104 @@
|
||||
import 'dart:async';
|
||||
import 'package:bloc/bloc.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'session_event.dart';
|
||||
import 'session_state.dart';
|
||||
|
||||
class SessionBloc extends Bloc<SessionEvent, SessionState> {
|
||||
Timer? _expiryTimer;
|
||||
static const String _tokenKey = 'auth_token';
|
||||
static const String _expiryKey = 'auth_expiry';
|
||||
|
||||
SessionBloc() : super(SessionInitial()) {
|
||||
on<SessionStarted>(_onSessionStarted);
|
||||
on<SessionExpired>(_onSessionExpired);
|
||||
on<SessionRefreshed>(_onSessionRefreshed);
|
||||
on<SessionEnded>(_onSessionEnded);
|
||||
on<SessionRestored>(_onSessionRestored);
|
||||
}
|
||||
|
||||
void _onSessionStarted(SessionStarted event, Emitter<SessionState> emit) {
|
||||
void _onSessionStarted(
|
||||
SessionStarted event,
|
||||
Emitter<SessionState> emit,
|
||||
) async {
|
||||
final expiresAt = DateTime.now().add(
|
||||
const Duration(minutes: 15),
|
||||
); // Match Go
|
||||
|
||||
// Save token to persistent storage
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString(_tokenKey, event.token);
|
||||
await prefs.setString(_expiryKey, expiresAt.toIso8601String());
|
||||
|
||||
emit(SessionActive(token: event.token, expiresAt: expiresAt));
|
||||
_startExpiryTimer(expiresAt);
|
||||
}
|
||||
|
||||
void _onSessionExpired(SessionExpired event, Emitter<SessionState> emit) {
|
||||
_expiryTimer?.cancel();
|
||||
_clearStoredSession();
|
||||
emit(SessionExpiredState());
|
||||
}
|
||||
|
||||
void _onSessionRefreshed(SessionRefreshed event, Emitter<SessionState> emit) {
|
||||
void _onSessionRefreshed(
|
||||
SessionRefreshed event,
|
||||
Emitter<SessionState> emit,
|
||||
) async {
|
||||
final expiresAt = DateTime.now().add(const Duration(minutes: 15));
|
||||
|
||||
// Update stored token
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString(_tokenKey, event.newToken);
|
||||
await prefs.setString(_expiryKey, expiresAt.toIso8601String());
|
||||
|
||||
emit(SessionActive(token: event.newToken, expiresAt: expiresAt));
|
||||
_startExpiryTimer(expiresAt);
|
||||
}
|
||||
|
||||
void _onSessionEnded(SessionEnded event, Emitter<SessionState> emit) {
|
||||
_expiryTimer?.cancel();
|
||||
_clearStoredSession();
|
||||
emit(SessionInitial());
|
||||
}
|
||||
|
||||
void _onSessionRestored(SessionRestored event, Emitter<SessionState> emit) {
|
||||
final expiresAt = event.expiresAt;
|
||||
final now = DateTime.now();
|
||||
|
||||
// Check if token is still valid
|
||||
if (expiresAt.isAfter(now)) {
|
||||
emit(SessionActive(token: event.token, expiresAt: expiresAt));
|
||||
_startExpiryTimer(expiresAt);
|
||||
} else {
|
||||
// Token expired, clear it
|
||||
_clearStoredSession();
|
||||
emit(SessionInitial());
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _clearStoredSession() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.remove(_tokenKey);
|
||||
await prefs.remove(_expiryKey);
|
||||
}
|
||||
|
||||
static Future<void> restoreSession(SessionBloc bloc) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final token = prefs.getString(_tokenKey);
|
||||
final expiryStr = prefs.getString(_expiryKey);
|
||||
|
||||
if (token != null && expiryStr != null) {
|
||||
try {
|
||||
final expiresAt = DateTime.parse(expiryStr);
|
||||
bloc.add(SessionRestored(token: token, expiresAt: expiresAt));
|
||||
} catch (e) {
|
||||
// Invalid stored data, clear it
|
||||
await prefs.remove(_tokenKey);
|
||||
await prefs.remove(_expiryKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _startExpiryTimer(DateTime expiresAt) {
|
||||
_expiryTimer?.cancel();
|
||||
final duration = expiresAt.difference(DateTime.now());
|
||||
|
||||
@@ -28,3 +28,13 @@ class SessionRefreshed extends SessionEvent {
|
||||
}
|
||||
|
||||
class SessionEnded extends SessionEvent {}
|
||||
|
||||
class SessionRestored extends SessionEvent {
|
||||
final String token;
|
||||
final DateTime expiresAt;
|
||||
|
||||
const SessionRestored({required this.token, required this.expiresAt});
|
||||
|
||||
@override
|
||||
List<Object> get props => [token, expiresAt];
|
||||
}
|
||||
|
||||
@@ -24,10 +24,18 @@ class UploadBloc extends Bloc<UploadEvent, UploadState> {
|
||||
|
||||
for (final file in event.files) {
|
||||
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
|
||||
await _fileRepository.uploadFile(event.orgId, file);
|
||||
print('[UploadBloc] Upload successful for ${file.name}');
|
||||
add(UploadCompleted(file));
|
||||
} catch (e) {
|
||||
print('[UploadBloc] Upload failed for ${file.name}: $e');
|
||||
add(UploadFailed(fileName: file.name, error: e.toString()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,24 +1,33 @@
|
||||
import 'package:b0esche_cloud/services/api_client.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
import 'blocs/session/session_bloc.dart';
|
||||
import 'repositories/auth_repository.dart';
|
||||
import 'repositories/file_repository.dart';
|
||||
import 'repositories/mock_auth_repository.dart';
|
||||
import 'repositories/mock_file_repository.dart';
|
||||
import 'repositories/http_auth_repository.dart';
|
||||
import 'repositories/http_file_repository.dart';
|
||||
import 'services/auth_service.dart';
|
||||
import 'services/file_service.dart';
|
||||
import 'services/org_api.dart';
|
||||
import 'viewmodels/login_view_model.dart';
|
||||
import 'viewmodels/file_explorer_view_model.dart';
|
||||
|
||||
final getIt = GetIt.instance;
|
||||
|
||||
void configureDependencies() {
|
||||
// Register repositories
|
||||
getIt.registerSingleton<AuthRepository>(MockAuthRepository());
|
||||
getIt.registerSingleton<FileRepository>(MockFileRepository());
|
||||
void configureDependencies(SessionBloc sessionBloc) {
|
||||
// Register ApiClient first
|
||||
final apiClient = ApiClient(sessionBloc);
|
||||
getIt.registerSingleton<ApiClient>(apiClient);
|
||||
|
||||
// Register repositories (HTTP-backed)
|
||||
getIt.registerSingleton<AuthRepository>(HttpAuthRepository(apiClient));
|
||||
getIt.registerSingleton<FileRepository>(
|
||||
HttpFileRepository(FileService(apiClient)),
|
||||
);
|
||||
|
||||
// Register services
|
||||
getIt.registerSingleton<AuthService>(AuthService(getIt<AuthRepository>()));
|
||||
getIt.registerSingleton<FileService>(FileService(getIt<ApiClient>()));
|
||||
getIt.registerSingleton<OrgApi>(OrgApi(getIt<ApiClient>()));
|
||||
|
||||
// Register viewmodels
|
||||
getIt.registerSingleton<LoginViewModel>(LoginViewModel(getIt<AuthService>()));
|
||||
|
||||
@@ -11,6 +11,7 @@ import 'pages/file_explorer.dart';
|
||||
import 'pages/document_viewer.dart';
|
||||
import 'pages/editor_page.dart';
|
||||
import 'theme/app_theme.dart';
|
||||
import 'injection.dart';
|
||||
|
||||
final GoRouter _router = GoRouter(
|
||||
routes: [
|
||||
@@ -41,23 +42,40 @@ void main() {
|
||||
runApp(const MainApp());
|
||||
}
|
||||
|
||||
class MainApp extends StatelessWidget {
|
||||
class MainApp extends StatefulWidget {
|
||||
const MainApp({super.key});
|
||||
|
||||
@override
|
||||
State<MainApp> createState() => _MainAppState();
|
||||
}
|
||||
|
||||
class _MainAppState extends State<MainApp> {
|
||||
final _sessionBloc = SessionBloc();
|
||||
late final AuthBloc _authBloc;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// Restore session from persistent storage early so ApiClient has token if present
|
||||
SessionBloc.restoreSession(_sessionBloc);
|
||||
// Configure DI to use HTTP repositories
|
||||
configureDependencies(_sessionBloc);
|
||||
|
||||
_authBloc = AuthBloc(
|
||||
apiClient: ApiClient(_sessionBloc),
|
||||
sessionBloc: _sessionBloc,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MultiBlocProvider(
|
||||
providers: [
|
||||
BlocProvider<SessionBloc>(create: (_) => SessionBloc()),
|
||||
BlocProvider<AuthBloc>(
|
||||
create: (context) => AuthBloc(
|
||||
apiClient: ApiClient(context.read<SessionBloc>()),
|
||||
sessionBloc: context.read<SessionBloc>(),
|
||||
),
|
||||
),
|
||||
BlocProvider<SessionBloc>.value(value: _sessionBloc),
|
||||
BlocProvider<AuthBloc>.value(value: _authBloc),
|
||||
BlocProvider<ActivityBloc>(
|
||||
create: (context) =>
|
||||
ActivityBloc(ActivityApi(ApiClient(context.read<SessionBloc>()))),
|
||||
ActivityBloc(ActivityApi(ApiClient(_sessionBloc))),
|
||||
),
|
||||
],
|
||||
child: MaterialApp.router(
|
||||
@@ -66,4 +84,11 @@ class MainApp extends StatelessWidget {
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_authBloc.close();
|
||||
_sessionBloc.close();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
import 'dart:typed_data';
|
||||
|
||||
enum FileType { folder, file }
|
||||
|
||||
@@ -8,6 +9,8 @@ class FileItem extends Equatable {
|
||||
final FileType type;
|
||||
final int size; // in bytes, 0 for folders
|
||||
final DateTime lastModified;
|
||||
final String? localPath; // optional local file path for uploads
|
||||
final Uint8List? bytes; // optional file bytes for web/desktop uploads
|
||||
|
||||
const FileItem({
|
||||
required this.name,
|
||||
@@ -15,6 +18,8 @@ class FileItem extends Equatable {
|
||||
required this.type,
|
||||
this.size = 0,
|
||||
required this.lastModified,
|
||||
this.localPath,
|
||||
this.bytes,
|
||||
});
|
||||
|
||||
@override
|
||||
|
||||
@@ -9,6 +9,200 @@ import '../injection.dart';
|
||||
import 'package:syncfusion_flutter_pdfviewer/pdfviewer.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());
|
||||
} 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 {
|
||||
final String orgId;
|
||||
final String fileId;
|
||||
|
||||
@@ -8,6 +8,161 @@ import '../services/file_service.dart';
|
||||
import '../injection.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
// Modal version for overlay display
|
||||
class EditorPageModal extends StatefulWidget {
|
||||
final String orgId;
|
||||
final String fileId;
|
||||
final VoidCallback onClose;
|
||||
|
||||
const EditorPageModal({
|
||||
super.key,
|
||||
required this.orgId,
|
||||
required this.fileId,
|
||||
required this.onClose,
|
||||
});
|
||||
|
||||
@override
|
||||
State<EditorPageModal> createState() => _EditorPageModalState();
|
||||
}
|
||||
|
||||
class _EditorPageModalState extends State<EditorPageModal> {
|
||||
late EditorSessionBloc _editorBloc;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_editorBloc = EditorSessionBloc(getIt<FileService>());
|
||||
_editorBloc.add(
|
||||
EditorSessionStarted(orgId: widget.orgId, fileId: widget.fileId),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocProvider.value(
|
||||
value: _editorBloc,
|
||||
child: Column(
|
||||
children: [
|
||||
// Custom AppBar
|
||||
Container(
|
||||
height: 56,
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.primaryBackground.withValues(alpha: 0.9),
|
||||
border: Border(
|
||||
bottom: BorderSide(
|
||||
color: AppTheme.accentColor.withValues(alpha: 0.3),
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const SizedBox(width: 16),
|
||||
const Text(
|
||||
'Document Editor',
|
||||
style: TextStyle(
|
||||
color: AppTheme.primaryText,
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.close, color: AppTheme.primaryText),
|
||||
onPressed: () {
|
||||
_editorBloc.add(EditorSessionEnded());
|
||||
widget.onClose();
|
||||
},
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
),
|
||||
),
|
||||
// Editor content
|
||||
Expanded(
|
||||
child: BlocBuilder<EditorSessionBloc, EditorSessionState>(
|
||||
builder: (context, state) {
|
||||
if (state is EditorSessionStarting) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
if (state is EditorSessionFailed) {
|
||||
return Center(
|
||||
child: Text(
|
||||
'Error: ${state.message}',
|
||||
style: const TextStyle(color: AppTheme.primaryText),
|
||||
),
|
||||
);
|
||||
}
|
||||
if (state is EditorSessionActive) {
|
||||
return Container(
|
||||
color: AppTheme.secondaryText,
|
||||
child: Center(
|
||||
child: Text(
|
||||
'Collabora Editor Active\\n(URL: ${state.editUrl})',
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(color: AppTheme.primaryText),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
if (state is EditorSessionReadOnly) {
|
||||
return Container(
|
||||
color: AppTheme.secondaryText,
|
||||
child: Center(
|
||||
child: Text(
|
||||
'Read Only Mode\\n(URL: ${state.viewUrl})',
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(color: AppTheme.primaryText),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
if (state is EditorSessionExpired) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Text(
|
||||
'Editing session expired.',
|
||||
style: TextStyle(color: AppTheme.primaryText),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
ElevatedButton(
|
||||
onPressed: () {
|
||||
_editorBloc.add(
|
||||
EditorSessionStarted(
|
||||
orgId: widget.orgId,
|
||||
fileId: widget.fileId,
|
||||
),
|
||||
);
|
||||
},
|
||||
child: const Text('Reopen'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
return const Center(
|
||||
child: Text(
|
||||
'No editor session',
|
||||
style: TextStyle(color: AppTheme.primaryText),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_editorBloc.close();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
// Original page version (for routing if needed)
|
||||
class EditorPage extends StatefulWidget {
|
||||
final String orgId;
|
||||
final String fileId;
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import 'dart:ui';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:file_picker/file_picker.dart' hide FileType;
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'dart:html' as html;
|
||||
import '../blocs/file_browser/file_browser_bloc.dart';
|
||||
import '../blocs/file_browser/file_browser_event.dart';
|
||||
import '../blocs/file_browser/file_browser_state.dart';
|
||||
@@ -11,9 +12,14 @@ import '../blocs/permission/permission_event.dart';
|
||||
import '../blocs/permission/permission_state.dart';
|
||||
import '../blocs/upload/upload_bloc.dart';
|
||||
import '../blocs/upload/upload_event.dart';
|
||||
import '../blocs/upload/upload_state.dart';
|
||||
import '../models/file_item.dart';
|
||||
import '../theme/app_theme.dart';
|
||||
import '../theme/modern_glass_button.dart';
|
||||
import 'document_viewer.dart';
|
||||
import 'editor_page.dart';
|
||||
import '../injection.dart';
|
||||
import '../services/file_service.dart';
|
||||
|
||||
class FileExplorer extends StatefulWidget {
|
||||
final String orgId;
|
||||
@@ -229,7 +235,7 @@ class _FileExplorerState extends State<FileExplorer> {
|
||||
if (newName.isNotEmpty && newName != file.name) {
|
||||
context.read<FileBrowserBloc>().add(
|
||||
RenameFile(
|
||||
orgId: 'org1',
|
||||
orgId: widget.orgId,
|
||||
path: file.path,
|
||||
newName: newName,
|
||||
),
|
||||
@@ -258,10 +264,34 @@ class _FileExplorerState extends State<FileExplorer> {
|
||||
);
|
||||
}
|
||||
|
||||
void _downloadFile(FileItem file) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text('Download ${file.name}')));
|
||||
void _downloadFile(FileItem file) async {
|
||||
try {
|
||||
final fileService = getIt<FileService>();
|
||||
final downloadUrl = await fileService.getDownloadUrl(
|
||||
widget.orgId,
|
||||
file.path,
|
||||
);
|
||||
|
||||
// For web, use the download URL with the ApiClient base URL (from DI)
|
||||
final fullUrl = '${fileService.baseUrl}$downloadUrl';
|
||||
|
||||
// Trigger download via anchor element
|
||||
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) {
|
||||
@@ -331,7 +361,7 @@ class _FileExplorerState extends State<FileExplorer> {
|
||||
);
|
||||
if (confirmed == true) {
|
||||
context.read<FileBrowserBloc>().add(
|
||||
DeleteFile(orgId: 'org1', path: file.path),
|
||||
DeleteFile(orgId: widget.orgId, path: file.path),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -533,7 +563,7 @@ class _FileExplorerState extends State<FileExplorer> {
|
||||
final fileId = file.path.startsWith('/')
|
||||
? file.path.substring(1)
|
||||
: file.path;
|
||||
context.go('/viewer/${widget.orgId}/$fileId');
|
||||
_showDocumentViewer(widget.orgId, fileId);
|
||||
}
|
||||
},
|
||||
child: Container(
|
||||
@@ -632,387 +662,519 @@ class _FileExplorerState extends State<FileExplorer> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocBuilder<FileBrowserBloc, FileBrowserState>(
|
||||
builder: (context, state) {
|
||||
if (state is DirectoryLoading) {
|
||||
return Center(
|
||||
child: CircularProgressIndicator(color: AppTheme.accentColor),
|
||||
);
|
||||
return BlocListener<UploadBloc, UploadState>(
|
||||
listener: (context, uploadState) {
|
||||
if (uploadState is UploadInProgress) {
|
||||
// Show error if any upload failed
|
||||
for (final upload in uploadState.uploads) {
|
||||
if (upload.error != null && upload.error!.isNotEmpty) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Upload failed: ${upload.error}')),
|
||||
);
|
||||
}
|
||||
}
|
||||
final hasCompleted = uploadState.uploads.any((u) => u.isCompleted);
|
||||
if (hasCompleted) {
|
||||
final fbState = context.read<FileBrowserBloc>().state;
|
||||
String currentPath = '/';
|
||||
if (fbState is DirectoryLoaded) currentPath = fbState.currentPath;
|
||||
if (fbState is DirectoryEmpty) currentPath = fbState.currentPath;
|
||||
context.read<FileBrowserBloc>().add(
|
||||
LoadDirectory(orgId: widget.orgId, path: currentPath),
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
child: BlocBuilder<FileBrowserBloc, FileBrowserState>(
|
||||
builder: (context, state) {
|
||||
if (state is DirectoryLoading) {
|
||||
return Center(
|
||||
child: CircularProgressIndicator(color: AppTheme.accentColor),
|
||||
);
|
||||
}
|
||||
|
||||
if (state is DirectoryError) {
|
||||
return Center(
|
||||
child: Text(
|
||||
'Error: ${state.error}',
|
||||
style: const TextStyle(color: AppTheme.primaryText),
|
||||
),
|
||||
);
|
||||
}
|
||||
if (state is DirectoryError) {
|
||||
return Center(
|
||||
child: Text(
|
||||
'Error: ${state.error}',
|
||||
style: const TextStyle(color: AppTheme.primaryText),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (state is DirectoryEmpty) {
|
||||
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,
|
||||
if (state is DirectoryEmpty) {
|
||||
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(),
|
||||
),
|
||||
child: _buildTitle(),
|
||||
),
|
||||
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();
|
||||
if (result != null && result.files.isNotEmpty) {
|
||||
final files = result.files
|
||||
.map(
|
||||
(file) => FileItem(
|
||||
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: '/',
|
||||
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,
|
||||
),
|
||||
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,
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
}
|
||||
},
|
||||
child: const Row(
|
||||
children: [
|
||||
Icon(Icons.upload),
|
||||
SizedBox(width: 8),
|
||||
Text('Upload File'),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
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();
|
||||
if (result != null && result.files.isNotEmpty) {
|
||||
final files = result.files
|
||||
.map(
|
||||
(file) => FileItem(
|
||||
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,
|
||||
),
|
||||
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: '/',
|
||||
folderName: folderName,
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
child: const Row(
|
||||
children: [
|
||||
Icon(Icons.create_new_folder),
|
||||
SizedBox(width: 8),
|
||||
Text('New Folder'),
|
||||
],
|
||||
),
|
||||
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),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
IconButton(
|
||||
icon: const Icon(
|
||||
Icons.chevron_left,
|
||||
Icons.arrow_back,
|
||||
color: AppTheme.primaryText,
|
||||
),
|
||||
splashColor: Colors.transparent,
|
||||
highlightColor: Colors.transparent,
|
||||
onPressed: state.currentPage > 1
|
||||
? () {
|
||||
context.read<FileBrowserBloc>().add(
|
||||
LoadPage(state.currentPage - 1),
|
||||
);
|
||||
}
|
||||
: null,
|
||||
onPressed: () {
|
||||
final parentPath = _getParentPath(state.currentPath);
|
||||
context.read<FileBrowserBloc>().add(
|
||||
LoadDirectory(
|
||||
orgId: widget.orgId,
|
||||
path: parentPath,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
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,
|
||||
const Text(
|
||||
'Empty Folder',
|
||||
style: TextStyle(color: AppTheme.primaryText),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
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(),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,10 +8,16 @@ import '../blocs/organization/organization_bloc.dart';
|
||||
import '../blocs/organization/organization_event.dart';
|
||||
import '../blocs/file_browser/file_browser_bloc.dart';
|
||||
import '../blocs/file_browser/file_browser_event.dart';
|
||||
import '../blocs/permission/permission_bloc.dart';
|
||||
import '../blocs/upload/upload_bloc.dart';
|
||||
import '../repositories/file_repository.dart';
|
||||
import '../services/file_service.dart';
|
||||
import '../services/org_api.dart';
|
||||
import '../theme/app_theme.dart';
|
||||
import '../theme/modern_glass_button.dart';
|
||||
import 'login_form.dart' show LoginForm;
|
||||
import 'file_explorer.dart';
|
||||
import '../injection.dart';
|
||||
|
||||
class HomePage extends StatefulWidget {
|
||||
const HomePage({super.key});
|
||||
@@ -24,6 +30,13 @@ class _HomePageState extends State<HomePage> with TickerProviderStateMixin {
|
||||
late String _selectedTab = 'Drive';
|
||||
late AnimationController _animationController;
|
||||
bool _isSignupMode = false;
|
||||
bool _usePasswordMode = false;
|
||||
|
||||
// Shared blocs for the page lifecycle
|
||||
late final PermissionBloc _permissionBloc;
|
||||
late final FileBrowserBloc _fileBrowserBloc;
|
||||
late final UploadBloc _uploadBloc;
|
||||
late final OrganizationBloc _organizationBloc;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -32,11 +45,25 @@ class _HomePageState extends State<HomePage> with TickerProviderStateMixin {
|
||||
duration: const Duration(milliseconds: 400),
|
||||
vsync: this,
|
||||
);
|
||||
|
||||
_permissionBloc = PermissionBloc();
|
||||
_fileBrowserBloc = FileBrowserBloc(getIt<FileService>());
|
||||
_uploadBloc = UploadBloc(getIt<FileRepository>());
|
||||
_organizationBloc = OrganizationBloc(
|
||||
_permissionBloc,
|
||||
_fileBrowserBloc,
|
||||
_uploadBloc,
|
||||
getIt<OrgApi>(),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_animationController.dispose();
|
||||
_organizationBloc.close();
|
||||
_uploadBloc.close();
|
||||
_fileBrowserBloc.close();
|
||||
_permissionBloc.close();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@@ -50,6 +77,10 @@ class _HomePageState extends State<HomePage> with TickerProviderStateMixin {
|
||||
}
|
||||
}
|
||||
|
||||
void _setPasswordMode(bool usePassword) {
|
||||
setState(() => _usePasswordMode = usePassword);
|
||||
}
|
||||
|
||||
void _showCreateOrgDialog(BuildContext context) {
|
||||
final controller = TextEditingController();
|
||||
showDialog(
|
||||
@@ -132,37 +163,52 @@ class _HomePageState extends State<HomePage> with TickerProviderStateMixin {
|
||||
Widget _buildOrgRow(BuildContext context) {
|
||||
return BlocBuilder<OrganizationBloc, OrganizationState>(
|
||||
builder: (context, state) {
|
||||
List<Organization> orgs = [];
|
||||
Organization? selectedOrg;
|
||||
bool isLoading = false;
|
||||
|
||||
if (state is OrganizationLoaded) {
|
||||
final orgs = state.organizations;
|
||||
return Column(
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
...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();
|
||||
orgs = state.organizations;
|
||||
selectedOrg = state.selectedOrg;
|
||||
isLoading = state.isLoading;
|
||||
} else if (state is OrganizationLoading) {
|
||||
isLoading = true;
|
||||
}
|
||||
|
||||
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) {
|
||||
return state is OrganizationLoaded && state.selectedOrg != null
|
||||
? FileExplorer(orgId: state.selectedOrg!.id)
|
||||
: const FileExplorer(orgId: 'org1');
|
||||
Widget _buildDrive(OrganizationState state, AuthState authState) {
|
||||
String orgId;
|
||||
if (state is OrganizationLoaded && state.selectedOrg != null) {
|
||||
// Show selected organization's files
|
||||
orgId = state.selectedOrg!.id;
|
||||
} else if (authState is AuthAuthenticated) {
|
||||
// Show personal workspace - use empty string to trigger /user/files endpoint
|
||||
orgId = '';
|
||||
} else {
|
||||
orgId = '';
|
||||
}
|
||||
|
||||
return FileExplorer(orgId: orgId);
|
||||
}
|
||||
|
||||
Widget _buildNavButton(String label, IconData icon, {bool isAvatar = false}) {
|
||||
@@ -238,274 +293,328 @@ class _HomePageState extends State<HomePage> with TickerProviderStateMixin {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: AppTheme.primaryBackground,
|
||||
body: Stack(
|
||||
children: [
|
||||
Center(
|
||||
child: BlocBuilder<AuthBloc, AuthState>(
|
||||
builder: (context, state) {
|
||||
final isLoggedIn = state is AuthAuthenticated;
|
||||
if (isLoggedIn && !_animationController.isAnimating) {
|
||||
_animationController.forward();
|
||||
} else if (!isLoggedIn) {
|
||||
_animationController.reverse();
|
||||
}
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(top: 42.0),
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 350),
|
||||
curve: Curves.easeInOut,
|
||||
width: isLoggedIn
|
||||
? MediaQuery.of(context).size.width * 0.9
|
||||
: 340,
|
||||
height: isLoggedIn
|
||||
? MediaQuery.of(context).size.height * 0.9
|
||||
: (_isSignupMode ? 400 : 280),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
child: BackdropFilter(
|
||||
filter: ui.ImageFilter.blur(sigmaX: 10, sigmaY: 10),
|
||||
child: Stack(
|
||||
children: [
|
||||
Container(
|
||||
decoration: AppTheme.glassDecoration,
|
||||
child: isLoggedIn
|
||||
? BlocListener<
|
||||
OrganizationBloc,
|
||||
OrganizationState
|
||||
>(
|
||||
listener: (context, state) {
|
||||
if (state is OrganizationLoaded &&
|
||||
state.selectedOrg != null) {
|
||||
// Reload file browser when org changes
|
||||
context.read<FileBrowserBloc>().add(
|
||||
LoadDirectory(
|
||||
orgId: state.selectedOrg!.id,
|
||||
path: '/',
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
child:
|
||||
BlocBuilder<
|
||||
OrganizationBloc,
|
||||
OrganizationState
|
||||
>(
|
||||
builder: (context, orgState) {
|
||||
if (orgState
|
||||
is OrganizationInitial) {
|
||||
WidgetsBinding.instance
|
||||
.addPostFrameCallback((_) {
|
||||
context
|
||||
.read<
|
||||
OrganizationBloc
|
||||
>()
|
||||
.add(
|
||||
LoadOrganizations(),
|
||||
);
|
||||
});
|
||||
}
|
||||
return Column(
|
||||
children: [
|
||||
const SizedBox(height: 8),
|
||||
_buildOrgRow(context),
|
||||
Expanded(
|
||||
child: _buildDrive(
|
||||
orgState,
|
||||
),
|
||||
),
|
||||
],
|
||||
return MultiBlocProvider(
|
||||
providers: [
|
||||
BlocProvider<PermissionBloc>.value(value: _permissionBloc),
|
||||
BlocProvider<FileBrowserBloc>.value(value: _fileBrowserBloc),
|
||||
BlocProvider<UploadBloc>.value(value: _uploadBloc),
|
||||
BlocProvider<OrganizationBloc>.value(value: _organizationBloc),
|
||||
],
|
||||
child: Scaffold(
|
||||
backgroundColor: AppTheme.primaryBackground,
|
||||
body: Stack(
|
||||
children: [
|
||||
Center(
|
||||
child: BlocBuilder<AuthBloc, AuthState>(
|
||||
builder: (context, state) {
|
||||
final isLoggedIn = state is AuthAuthenticated;
|
||||
if (isLoggedIn && !_animationController.isAnimating) {
|
||||
_animationController.forward();
|
||||
} else if (!isLoggedIn) {
|
||||
_animationController.reverse();
|
||||
}
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(
|
||||
top: MediaQuery.of(context).size.width < 600
|
||||
? 60.0
|
||||
: 78.0,
|
||||
),
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 350),
|
||||
curve: Curves.easeInOut,
|
||||
width: isLoggedIn
|
||||
? MediaQuery.of(context).size.width * 0.9
|
||||
: 340,
|
||||
height: isLoggedIn
|
||||
? MediaQuery.of(context).size.height * 0.9
|
||||
: (_isSignupMode
|
||||
? 400
|
||||
: (_usePasswordMode ? 350 : 280)),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
child: BackdropFilter(
|
||||
filter: ui.ImageFilter.blur(sigmaX: 10, sigmaY: 10),
|
||||
child: Stack(
|
||||
children: [
|
||||
Container(
|
||||
decoration: AppTheme.glassDecoration,
|
||||
child: isLoggedIn
|
||||
? BlocListener<
|
||||
OrganizationBloc,
|
||||
OrganizationState
|
||||
>(
|
||||
listener: (context, state) {
|
||||
if (state is OrganizationLoaded) {
|
||||
// Show errors if present
|
||||
if (state.error != null &&
|
||||
state.error!.isNotEmpty) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(state.error!),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
)
|
||||
: LoginForm(
|
||||
onSignupModeChanged: _setSignupMode,
|
||||
),
|
||||
),
|
||||
// Top-left radial glow - primary accent light
|
||||
AnimatedPositioned(
|
||||
duration: const Duration(milliseconds: 350),
|
||||
curve: Curves.easeInOut,
|
||||
top: isLoggedIn ? -180 : -120,
|
||||
left: isLoggedIn ? -180 : -120,
|
||||
child: IgnorePointer(
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 350),
|
||||
curve: Curves.easeInOut,
|
||||
width: isLoggedIn ? 550 : 400,
|
||||
height: isLoggedIn ? 550 : 400,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
gradient: RadialGradient(
|
||||
colors: [
|
||||
AppTheme.accentColor.withValues(
|
||||
alpha: isLoggedIn ? 0.12 : 0.15,
|
||||
),
|
||||
AppTheme.accentColor.withValues(
|
||||
alpha: 0.04,
|
||||
),
|
||||
Colors.transparent,
|
||||
],
|
||||
stops: const [0.0, 0.6, 1.0],
|
||||
),
|
||||
),
|
||||
),
|
||||
}
|
||||
final orgId =
|
||||
state.selectedOrg?.id ?? '';
|
||||
// Reload file browser when org changes (or when falling back to personal workspace)
|
||||
context.read<FileBrowserBloc>().add(
|
||||
LoadDirectory(
|
||||
orgId: orgId,
|
||||
path: '/',
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
child:
|
||||
BlocBuilder<
|
||||
OrganizationBloc,
|
||||
OrganizationState
|
||||
>(
|
||||
builder: (context, orgState) {
|
||||
if (orgState
|
||||
is OrganizationInitial) {
|
||||
WidgetsBinding.instance
|
||||
.addPostFrameCallback((
|
||||
_,
|
||||
) {
|
||||
// Kick off org fetch and immediately show personal workspace
|
||||
// while org data loads.
|
||||
context
|
||||
.read<
|
||||
OrganizationBloc
|
||||
>()
|
||||
.add(
|
||||
LoadOrganizations(),
|
||||
);
|
||||
context
|
||||
.read<
|
||||
FileBrowserBloc
|
||||
>()
|
||||
.add(
|
||||
const LoadDirectory(
|
||||
orgId: '',
|
||||
path: '/',
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
return Column(
|
||||
children: [
|
||||
const SizedBox(height: 80),
|
||||
_buildOrgRow(context),
|
||||
Expanded(
|
||||
child: _buildDrive(
|
||||
orgState,
|
||||
state,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
)
|
||||
: LoginForm(
|
||||
onSignupModeChanged: _setSignupMode,
|
||||
onPasswordModeChanged: _setPasswordMode,
|
||||
),
|
||||
),
|
||||
),
|
||||
// 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,
|
||||
// Top-left radial glow - primary accent light
|
||||
AnimatedPositioned(
|
||||
duration: const Duration(milliseconds: 350),
|
||||
curve: Curves.easeInOut,
|
||||
top: isLoggedIn ? -180 : -120,
|
||||
left: isLoggedIn ? -180 : -120,
|
||||
child: IgnorePointer(
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 350),
|
||||
curve: Curves.easeInOut,
|
||||
width: isLoggedIn ? 550 : 400,
|
||||
height: isLoggedIn ? 550 : 400,
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
shape: BoxShape.circle,
|
||||
gradient: RadialGradient(
|
||||
colors: [
|
||||
Colors.white.withValues(alpha: 0),
|
||||
Colors.white.withValues(alpha: 0.06),
|
||||
Colors.white.withValues(alpha: 0),
|
||||
AppTheme.accentColor.withValues(
|
||||
alpha: isLoggedIn ? 0.12 : 0.15,
|
||||
),
|
||||
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(
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
child: Center(
|
||||
child: Text(
|
||||
'b0esche.cloud',
|
||||
style: TextStyle(
|
||||
fontFamily: 'PixelatedElegance',
|
||||
fontSize: 48,
|
||||
color: AppTheme.primaryText,
|
||||
decoration: TextDecoration.underline,
|
||||
decorationColor: AppTheme.primaryText,
|
||||
fontFeatures: const [FontFeature.slashedZero()],
|
||||
Positioned(
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
child: Center(
|
||||
child: Builder(
|
||||
builder: (context) {
|
||||
final screenWidth = MediaQuery.of(context).size.width;
|
||||
final fontSize = screenWidth < 600 ? 24.0 : 48.0;
|
||||
return Text(
|
||||
'b0esche.cloud',
|
||||
style: TextStyle(
|
||||
fontFamily: 'PixelatedElegance',
|
||||
fontSize: fontSize,
|
||||
color: AppTheme.primaryText,
|
||||
decoration: TextDecoration.underline,
|
||||
decorationColor: AppTheme.primaryText,
|
||||
fontFeatures: const [FontFeature.slashedZero()],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
top: 10,
|
||||
right: 20,
|
||||
child: BlocBuilder<AuthBloc, AuthState>(
|
||||
builder: (context, state) {
|
||||
final isLoggedIn = state is AuthAuthenticated;
|
||||
if (!isLoggedIn) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
return ScaleTransition(
|
||||
scale: Tween<double>(begin: 0, end: 1).animate(
|
||||
CurvedAnimation(
|
||||
parent: _animationController,
|
||||
curve: Curves.easeOutBack,
|
||||
Positioned(
|
||||
top: MediaQuery.of(context).size.width < 600 ? 40 : 10,
|
||||
right: 20,
|
||||
child: BlocBuilder<AuthBloc, AuthState>(
|
||||
builder: (context, state) {
|
||||
final isLoggedIn = state is AuthAuthenticated;
|
||||
if (!isLoggedIn) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
return ScaleTransition(
|
||||
scale: Tween<double>(begin: 0, end: 1).animate(
|
||||
CurvedAnimation(
|
||||
parent: _animationController,
|
||||
curve: Curves.easeOutBack,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
_buildNavButton('Drive', Icons.cloud),
|
||||
const SizedBox(width: 16),
|
||||
_buildNavButton('Mail', Icons.mail),
|
||||
const SizedBox(width: 16),
|
||||
_buildNavButton('Add', Icons.add),
|
||||
const SizedBox(width: 16),
|
||||
_buildNavButton('Profile', Icons.person, isAvatar: true),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
child: Row(
|
||||
children: [
|
||||
_buildNavButton('Drive', Icons.cloud),
|
||||
const SizedBox(width: 16),
|
||||
_buildNavButton('Mail', Icons.mail),
|
||||
const SizedBox(width: 16),
|
||||
_buildNavButton('Add', Icons.add),
|
||||
const SizedBox(width: 16),
|
||||
_buildNavButton(
|
||||
'Profile',
|
||||
Icons.person,
|
||||
isAvatar: true,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'dart:math';
|
||||
import 'dart:convert';
|
||||
import '../blocs/auth/auth_bloc.dart';
|
||||
import '../blocs/auth/auth_event.dart';
|
||||
import '../blocs/auth/auth_state.dart';
|
||||
@@ -12,8 +13,13 @@ import '../theme/modern_glass_button.dart';
|
||||
|
||||
class LoginForm extends StatefulWidget {
|
||||
final ValueChanged<bool>? onSignupModeChanged;
|
||||
final ValueChanged<bool>? onPasswordModeChanged;
|
||||
|
||||
const LoginForm({super.key, this.onSignupModeChanged});
|
||||
const LoginForm({
|
||||
super.key,
|
||||
this.onSignupModeChanged,
|
||||
this.onPasswordModeChanged,
|
||||
});
|
||||
|
||||
@override
|
||||
State<LoginForm> createState() => _LoginFormState();
|
||||
@@ -40,6 +46,12 @@ class _LoginFormState extends State<LoginForm> {
|
||||
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(
|
||||
BuildContext context,
|
||||
AuthenticationChallengeReceived state,
|
||||
@@ -47,7 +59,7 @@ class _LoginFormState extends State<LoginForm> {
|
||||
try {
|
||||
final credentialId = state.credentialIds.isNotEmpty
|
||||
? state.credentialIds.first
|
||||
: _generateRandomHex(64);
|
||||
: _generateRandomBase64(64);
|
||||
|
||||
if (context.mounted) {
|
||||
context.read<AuthBloc>().add(
|
||||
@@ -55,10 +67,10 @@ class _LoginFormState extends State<LoginForm> {
|
||||
username: _usernameController.text,
|
||||
challenge: state.challenge,
|
||||
credentialId: credentialId,
|
||||
authenticatorData: _generateRandomHex(37),
|
||||
authenticatorData: _generateRandomBase64(37),
|
||||
clientDataJSON:
|
||||
'{"type":"webauthn.get","challenge":"${state.challenge}","origin":"https://b0esche.cloud"}',
|
||||
signature: _generateRandomHex(128),
|
||||
signature: _generateRandomBase64(128),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -76,8 +88,8 @@ class _LoginFormState extends State<LoginForm> {
|
||||
RegistrationChallengeReceived state,
|
||||
) async {
|
||||
try {
|
||||
final credentialId = _generateRandomHex(64);
|
||||
final publicKey = _generateRandomHex(91);
|
||||
final credentialId = _generateRandomBase64(64);
|
||||
final publicKey = _generateRandomBase64(91);
|
||||
|
||||
if (context.mounted) {
|
||||
context.read<AuthBloc>().add(
|
||||
@@ -88,7 +100,7 @@ class _LoginFormState extends State<LoginForm> {
|
||||
publicKey: publicKey,
|
||||
clientDataJSON:
|
||||
'{"type":"webauthn.create","challenge":"${state.challenge}","origin":"https://b0esche.cloud"}',
|
||||
attestationObject: _generateRandomHex(128),
|
||||
attestationObject: _generateRandomBase64(128),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -133,56 +145,28 @@ class _LoginFormState extends State<LoginForm> {
|
||||
child: Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: AnimatedSwitcher(
|
||||
duration: const Duration(milliseconds: 400),
|
||||
transitionBuilder: (child, animation) {
|
||||
return FadeTransition(opacity: animation, child: child);
|
||||
},
|
||||
child: SingleChildScrollView(
|
||||
key: ValueKey<bool>(_isSignup),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
_isSignup ? 'create account' : 'sign in',
|
||||
style: const TextStyle(
|
||||
fontSize: 24,
|
||||
color: AppTheme.primaryText,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.primaryBackground.withValues(alpha: 0.5),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(
|
||||
color: AppTheme.accentColor.withValues(alpha: 0.3),
|
||||
child: AnimatedSize(
|
||||
duration: const Duration(milliseconds: 300),
|
||||
curve: Curves.easeInOut,
|
||||
child: AnimatedSwitcher(
|
||||
duration: const Duration(milliseconds: 400),
|
||||
transitionBuilder: (child, animation) {
|
||||
return FadeTransition(opacity: animation, child: child);
|
||||
},
|
||||
child: SingleChildScrollView(
|
||||
key: ValueKey('${_isSignup}_$_usePasskey'),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
_isSignup ? 'create account' : 'sign in',
|
||||
style: const TextStyle(
|
||||
fontSize: 24,
|
||||
color: AppTheme.primaryText,
|
||||
),
|
||||
),
|
||||
child: TextField(
|
||||
controller: _usernameController,
|
||||
textInputAction: TextInputAction.next,
|
||||
keyboardType: TextInputType.text,
|
||||
cursorColor: AppTheme.accentColor,
|
||||
decoration: InputDecoration(
|
||||
hintText: 'username',
|
||||
hintStyle: TextStyle(color: AppTheme.secondaryText),
|
||||
contentPadding: const EdgeInsets.all(12),
|
||||
border: InputBorder.none,
|
||||
prefixIcon: Icon(
|
||||
Icons.person_outline,
|
||||
color: AppTheme.primaryText,
|
||||
size: 20,
|
||||
),
|
||||
),
|
||||
style: const TextStyle(color: AppTheme.primaryText),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
if (!_isSignup && _usePasskey)
|
||||
const SizedBox.shrink()
|
||||
else
|
||||
const SizedBox(height: 24),
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.primaryBackground.withValues(
|
||||
@@ -194,105 +178,114 @@ class _LoginFormState extends State<LoginForm> {
|
||||
),
|
||||
),
|
||||
child: TextField(
|
||||
controller: _passwordController,
|
||||
controller: _usernameController,
|
||||
textInputAction: TextInputAction.next,
|
||||
keyboardType: TextInputType.visiblePassword,
|
||||
obscureText: true,
|
||||
cursorColor: AppTheme.accentColor,
|
||||
decoration: InputDecoration(
|
||||
hintText: 'password',
|
||||
hintStyle: TextStyle(color: AppTheme.secondaryText),
|
||||
contentPadding: const EdgeInsets.all(12),
|
||||
border: InputBorder.none,
|
||||
prefixIcon: Icon(
|
||||
Icons.lock_outline,
|
||||
color: AppTheme.primaryText,
|
||||
size: 20,
|
||||
),
|
||||
),
|
||||
style: const TextStyle(color: AppTheme.primaryText),
|
||||
),
|
||||
),
|
||||
if (!_isSignup && _usePasskey)
|
||||
const SizedBox.shrink()
|
||||
else
|
||||
const SizedBox(height: 16),
|
||||
if (_isSignup)
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.primaryBackground.withValues(
|
||||
alpha: 0.5,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(
|
||||
color: AppTheme.accentColor.withValues(alpha: 0.3),
|
||||
),
|
||||
),
|
||||
child: TextField(
|
||||
controller: _displayNameController,
|
||||
textInputAction: TextInputAction.done,
|
||||
keyboardType: TextInputType.text,
|
||||
cursorColor: AppTheme.accentColor,
|
||||
decoration: InputDecoration(
|
||||
hintText: 'display name (optional)',
|
||||
hintText: 'username',
|
||||
hintStyle: TextStyle(color: AppTheme.secondaryText),
|
||||
contentPadding: const EdgeInsets.all(12),
|
||||
border: InputBorder.none,
|
||||
prefixIcon: Icon(
|
||||
Icons.badge_outlined,
|
||||
Icons.person_outline,
|
||||
color: AppTheme.primaryText,
|
||||
size: 20,
|
||||
),
|
||||
),
|
||||
style: const TextStyle(color: AppTheme.primaryText),
|
||||
),
|
||||
)
|
||||
else
|
||||
const SizedBox.shrink(),
|
||||
if (_isSignup)
|
||||
const SizedBox(height: 16)
|
||||
else
|
||||
const SizedBox.shrink(),
|
||||
SizedBox(
|
||||
width: 150,
|
||||
child: BlocBuilder<AuthBloc, AuthState>(
|
||||
builder: (context, state) {
|
||||
return ModernGlassButton(
|
||||
isLoading: state is AuthLoading,
|
||||
onPressed: () {
|
||||
if (_usernameController.text.isEmpty) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Username is required'),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (_isSignup) {
|
||||
if (_passwordController.text.isEmpty) {
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
if (!_isSignup && _usePasskey)
|
||||
const SizedBox.shrink()
|
||||
else
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.primaryBackground.withValues(
|
||||
alpha: 0.5,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(
|
||||
color: AppTheme.accentColor.withValues(alpha: 0.3),
|
||||
),
|
||||
),
|
||||
child: TextField(
|
||||
controller: _passwordController,
|
||||
textInputAction: TextInputAction.next,
|
||||
keyboardType: TextInputType.visiblePassword,
|
||||
obscureText: true,
|
||||
cursorColor: AppTheme.accentColor,
|
||||
decoration: InputDecoration(
|
||||
hintText: 'password',
|
||||
hintStyle: TextStyle(color: AppTheme.secondaryText),
|
||||
contentPadding: const EdgeInsets.all(12),
|
||||
border: InputBorder.none,
|
||||
prefixIcon: Icon(
|
||||
Icons.lock_outline,
|
||||
color: AppTheme.primaryText,
|
||||
size: 20,
|
||||
),
|
||||
),
|
||||
style: const TextStyle(color: AppTheme.primaryText),
|
||||
),
|
||||
),
|
||||
if (!_isSignup && _usePasskey)
|
||||
const SizedBox.shrink()
|
||||
else
|
||||
const SizedBox(height: 16),
|
||||
if (_isSignup)
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.primaryBackground.withValues(
|
||||
alpha: 0.5,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(
|
||||
color: AppTheme.accentColor.withValues(alpha: 0.3),
|
||||
),
|
||||
),
|
||||
child: TextField(
|
||||
controller: _displayNameController,
|
||||
textInputAction: TextInputAction.done,
|
||||
keyboardType: TextInputType.text,
|
||||
cursorColor: AppTheme.accentColor,
|
||||
decoration: InputDecoration(
|
||||
hintText: 'display name (optional)',
|
||||
hintStyle: TextStyle(color: AppTheme.secondaryText),
|
||||
contentPadding: const EdgeInsets.all(12),
|
||||
border: InputBorder.none,
|
||||
prefixIcon: Icon(
|
||||
Icons.badge_outlined,
|
||||
color: AppTheme.primaryText,
|
||||
size: 20,
|
||||
),
|
||||
),
|
||||
style: const TextStyle(color: AppTheme.primaryText),
|
||||
),
|
||||
)
|
||||
else
|
||||
const SizedBox.shrink(),
|
||||
if (_isSignup)
|
||||
const SizedBox(height: 16)
|
||||
else
|
||||
const SizedBox.shrink(),
|
||||
SizedBox(
|
||||
width: 150,
|
||||
child: BlocBuilder<AuthBloc, AuthState>(
|
||||
builder: (context, state) {
|
||||
return ModernGlassButton(
|
||||
isLoading: state is AuthLoading,
|
||||
onPressed: () {
|
||||
if (_usernameController.text.isEmpty) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Password is required'),
|
||||
content: Text('Username is required'),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
context.read<AuthBloc>().add(
|
||||
SignupStarted(
|
||||
username: _usernameController.text,
|
||||
email: _usernameController.text,
|
||||
displayName: _displayNameController.text,
|
||||
password: _passwordController.text,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
if (_usePasskey) {
|
||||
context.read<AuthBloc>().add(
|
||||
LoginRequested(
|
||||
username: _usernameController.text,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
if (_isSignup) {
|
||||
if (_passwordController.text.isEmpty) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
@@ -302,91 +295,120 @@ class _LoginFormState extends State<LoginForm> {
|
||||
return;
|
||||
}
|
||||
context.read<AuthBloc>().add(
|
||||
PasswordLoginRequested(
|
||||
SignupStarted(
|
||||
username: _usernameController.text,
|
||||
email: _usernameController.text,
|
||||
displayName: _displayNameController.text,
|
||||
password: _passwordController.text,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
if (_usePasskey) {
|
||||
context.read<AuthBloc>().add(
|
||||
LoginRequested(
|
||||
username: _usernameController.text,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
if (_passwordController.text.isEmpty) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Password is required'),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
context.read<AuthBloc>().add(
|
||||
PasswordLoginRequested(
|
||||
username: _usernameController.text,
|
||||
password: _passwordController.text,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
child: Text(_isSignup ? 'create' : 'sign in'),
|
||||
);
|
||||
},
|
||||
},
|
||||
child: Text(_isSignup ? 'create' : 'sign in'),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
if (_isSignup)
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
'already have an account?',
|
||||
style: TextStyle(color: AppTheme.secondaryText),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
GestureDetector(
|
||||
onTap: () {
|
||||
_resetForm();
|
||||
_setSignupMode(false);
|
||||
},
|
||||
child: Text(
|
||||
'sign in',
|
||||
style: TextStyle(
|
||||
color: AppTheme.accentColor,
|
||||
decoration: TextDecoration.underline,
|
||||
const SizedBox(height: 16),
|
||||
if (_isSignup)
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
'already have an account?',
|
||||
style: TextStyle(color: AppTheme.secondaryText),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
GestureDetector(
|
||||
onTap: () {
|
||||
_resetForm();
|
||||
_setSignupMode(false);
|
||||
},
|
||||
child: Text(
|
||||
'sign in',
|
||||
style: TextStyle(
|
||||
color: AppTheme.accentColor,
|
||||
decoration: TextDecoration.underline,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
else
|
||||
Column(
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
GestureDetector(
|
||||
onTap: () =>
|
||||
setState(() => _usePasskey = !_usePasskey),
|
||||
child: Text(
|
||||
_usePasskey ? 'use password' : 'use passkey',
|
||||
style: TextStyle(
|
||||
color: AppTheme.accentColor,
|
||||
decoration: TextDecoration.underline,
|
||||
fontSize: 12,
|
||||
],
|
||||
)
|
||||
else
|
||||
Column(
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
GestureDetector(
|
||||
onTap: () {
|
||||
setState(() => _usePasskey = !_usePasskey);
|
||||
widget.onPasswordModeChanged?.call(
|
||||
!_usePasskey,
|
||||
);
|
||||
},
|
||||
child: Text(
|
||||
_usePasskey ? 'use password' : 'use passkey',
|
||||
style: TextStyle(
|
||||
color: AppTheme.accentColor,
|
||||
decoration: TextDecoration.underline,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
'don\'t have an account?',
|
||||
style: TextStyle(color: AppTheme.secondaryText),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
GestureDetector(
|
||||
onTap: () {
|
||||
_resetForm();
|
||||
_setSignupMode(true);
|
||||
},
|
||||
child: Text(
|
||||
'create one',
|
||||
style: TextStyle(
|
||||
color: AppTheme.accentColor,
|
||||
decoration: TextDecoration.underline,
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
'don\'t have an account?',
|
||||
style: TextStyle(color: AppTheme.secondaryText),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
GestureDetector(
|
||||
onTap: () {
|
||||
_resetForm();
|
||||
_setSignupMode(true);
|
||||
},
|
||||
child: Text(
|
||||
'create one',
|
||||
style: TextStyle(
|
||||
color: AppTheme.accentColor,
|
||||
decoration: TextDecoration.underline,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'dart:math';
|
||||
import 'dart:convert';
|
||||
import '../blocs/auth/auth_bloc.dart';
|
||||
import '../blocs/auth/auth_event.dart';
|
||||
import '../blocs/auth/auth_state.dart';
|
||||
@@ -34,6 +35,12 @@ class _SignupFormState extends State<SignupForm> {
|
||||
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(
|
||||
BuildContext context,
|
||||
RegistrationChallengeReceived state,
|
||||
@@ -41,8 +48,8 @@ class _SignupFormState extends State<SignupForm> {
|
||||
try {
|
||||
// Simulate WebAuthn registration by generating fake credential data
|
||||
// In a real implementation, this would call native WebAuthn APIs
|
||||
final credentialId = _generateRandomHex(64);
|
||||
final publicKey = _generateRandomHex(91); // EC2 public key size
|
||||
final credentialId = _generateRandomBase64(64);
|
||||
final publicKey = _generateRandomBase64(91); // EC2 public key size
|
||||
|
||||
if (context.mounted) {
|
||||
context.read<AuthBloc>().add(
|
||||
@@ -53,7 +60,7 @@ class _SignupFormState extends State<SignupForm> {
|
||||
publicKey: publicKey,
|
||||
clientDataJSON:
|
||||
'{"type":"webauthn.create","challenge":"${state.challenge}","origin":"https://b0esche.cloud"}',
|
||||
attestationObject: _generateRandomHex(128),
|
||||
attestationObject: _generateRandomBase64(128),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
46
b0esche_cloud/lib/repositories/http_auth_repository.dart
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
93
b0esche_cloud/lib/repositories/http_file_repository.dart
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -29,22 +29,7 @@ class ApiClient {
|
||||
},
|
||||
onError: (error, handler) async {
|
||||
if (error.response?.statusCode == 401) {
|
||||
// Try refresh
|
||||
final refreshSuccess = await _tryRefreshToken();
|
||||
if (refreshSuccess) {
|
||||
// Retry the request
|
||||
final token = _getCurrentToken();
|
||||
if (token != null) {
|
||||
error.requestOptions.headers['Authorization'] = 'Bearer $token';
|
||||
try {
|
||||
final response = await _dio.fetch(error.requestOptions);
|
||||
return handler.resolve(response);
|
||||
} catch (e) {
|
||||
// If retry fails, proceed to error
|
||||
}
|
||||
}
|
||||
}
|
||||
// If refresh failed, logout
|
||||
// Session expired, trigger logout
|
||||
_sessionBloc.add(SessionExpired());
|
||||
}
|
||||
return handler.next(error);
|
||||
@@ -53,6 +38,8 @@ class ApiClient {
|
||||
);
|
||||
}
|
||||
|
||||
String get baseUrl => _dio.options.baseUrl;
|
||||
|
||||
String? _getCurrentToken() {
|
||||
// Get from SessionBloc state
|
||||
final state = _sessionBloc.state;
|
||||
@@ -62,20 +49,6 @@ class ApiClient {
|
||||
return null;
|
||||
}
|
||||
|
||||
Future<bool> _tryRefreshToken() async {
|
||||
try {
|
||||
final response = await _dio.post('/auth/refresh');
|
||||
if (response.statusCode == 200) {
|
||||
final newToken = response.data['token'];
|
||||
_sessionBloc.add(SessionRefreshed(newToken));
|
||||
return true;
|
||||
}
|
||||
} catch (e) {
|
||||
// Refresh failed
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
Future<T> get<T>(
|
||||
String path, {
|
||||
Map<String, dynamic>? queryParameters,
|
||||
|
||||
@@ -3,18 +3,37 @@ import '../models/viewer_session.dart';
|
||||
import '../models/editor_session.dart';
|
||||
import '../models/annotation.dart';
|
||||
import 'api_client.dart';
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
class FileService {
|
||||
final ApiClient _apiClient;
|
||||
|
||||
FileService(this._apiClient);
|
||||
|
||||
String get baseUrl => _apiClient.baseUrl;
|
||||
|
||||
Future<List<FileItem>> getFiles(String orgId, String path) async {
|
||||
if (path.isEmpty) {
|
||||
throw Exception('Path cannot be empty');
|
||||
}
|
||||
final pathParam = {'path': path};
|
||||
if (orgId.isEmpty) {
|
||||
return await _apiClient.getList(
|
||||
'/user/files',
|
||||
queryParameters: pathParam,
|
||||
fromJson: (data) => FileItem(
|
||||
name: data['name'],
|
||||
path: data['path'],
|
||||
type: data['type'] == 'file' ? FileType.file : FileType.folder,
|
||||
size: data['size'],
|
||||
lastModified: DateTime.parse(data['lastModified']),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return await _apiClient.getList(
|
||||
'/orgs/$orgId/files',
|
||||
queryParameters: pathParam,
|
||||
fromJson: (data) => FileItem(
|
||||
name: data['name'],
|
||||
path: data['path'],
|
||||
@@ -30,11 +49,82 @@ class FileService {
|
||||
}
|
||||
|
||||
Future<void> uploadFile(String orgId, FileItem file) async {
|
||||
throw UnimplementedError();
|
||||
// If bytes or localPath available, send multipart upload with field 'file'
|
||||
final Map<String, dynamic> fields = {'path': file.path};
|
||||
FormData formData;
|
||||
print(
|
||||
'[FileService] uploadFile: file=${file.name}, path=${file.path}, orgId=$orgId',
|
||||
);
|
||||
print(
|
||||
'[FileService] bytes=${file.bytes?.length ?? 0}, localPath=${file.localPath}',
|
||||
);
|
||||
|
||||
if (file.bytes != null) {
|
||||
print(
|
||||
'[FileService] Using bytes for upload (${file.bytes!.length} bytes)',
|
||||
);
|
||||
formData = FormData.fromMap({
|
||||
...fields,
|
||||
'file': MultipartFile.fromBytes(file.bytes!, filename: file.name),
|
||||
});
|
||||
} else if (file.localPath != null) {
|
||||
print('[FileService] Using localPath for upload: ${file.localPath}');
|
||||
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)
|
||||
print(
|
||||
'[FileService] No bytes or localPath; falling back to metadata-only',
|
||||
);
|
||||
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';
|
||||
print('[FileService] Uploading to endpoint: $endpoint');
|
||||
await _apiClient.post(endpoint, data: formData, fromJson: (d) => null);
|
||||
print('[FileService] Upload completed for ${file.name}');
|
||||
}
|
||||
|
||||
Future<void> deleteFile(String orgId, String path) async {
|
||||
throw UnimplementedError();
|
||||
final data = {'path': path};
|
||||
if (orgId.isEmpty) {
|
||||
await _apiClient.post(
|
||||
'/user/files/delete',
|
||||
data: data,
|
||||
fromJson: (d) => null,
|
||||
);
|
||||
return;
|
||||
}
|
||||
await _apiClient.post(
|
||||
'/orgs/$orgId/files/delete',
|
||||
data: data,
|
||||
fromJson: (d) => null,
|
||||
);
|
||||
}
|
||||
|
||||
Future<String> getDownloadUrl(String orgId, String path) async {
|
||||
// Return the download URL for the file
|
||||
if (orgId.isEmpty) {
|
||||
return '/user/files/download?path=${Uri.encodeComponent(path)}';
|
||||
}
|
||||
return '/orgs/$orgId/files/download?path=${Uri.encodeComponent(path)}';
|
||||
}
|
||||
|
||||
Future<void> createFolder(
|
||||
@@ -42,7 +132,24 @@ class FileService {
|
||||
String parentPath,
|
||||
String folderName,
|
||||
) async {
|
||||
throw UnimplementedError();
|
||||
final path = parentPath.endsWith('/')
|
||||
? '$parentPath$folderName'
|
||||
: '$parentPath/$folderName';
|
||||
final data = {
|
||||
'name': folderName,
|
||||
'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(
|
||||
|
||||
|
Before Width: | Height: | Size: 917 B After Width: | Height: | Size: 1008 B |
|
Before Width: | Height: | Size: 5.2 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 8.1 KiB After Width: | Height: | Size: 147 KiB |
32
go_cloud/Dockerfile
Normal 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"]
|
||||
BIN
go_cloud/api
@@ -1,23 +1,23 @@
|
||||
module go.b0esche.cloud/backend
|
||||
|
||||
go 1.25.5
|
||||
go 1.24.0
|
||||
|
||||
require (
|
||||
github.com/coreos/go-oidc/v3 v3.17.0
|
||||
github.com/go-chi/chi/v5 v5.2.3
|
||||
github.com/golang-jwt/jwt/v5 v5.3.0
|
||||
github.com/golang-jwt/jwt/v4 v4.5.2
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/jackc/pgx/v5 v5.7.6
|
||||
golang.org/x/crypto v0.37.0
|
||||
golang.org/x/oauth2 v0.28.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/coreos/go-oidc/v3 v3.17.0 // indirect
|
||||
github.com/go-jose/go-jose/v4 v4.1.3 // indirect
|
||||
github.com/golang-jwt/jwt/v4 v4.5.2 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||
github.com/jackc/pgx/v5 v5.7.6 // indirect
|
||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||
golang.org/x/crypto v0.37.0 // indirect
|
||||
golang.org/x/oauth2 v0.28.0 // indirect
|
||||
golang.org/x/sync v0.13.0 // indirect
|
||||
golang.org/x/sys v0.32.0 // indirect
|
||||
golang.org/x/text v0.24.0 // indirect
|
||||
)
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
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/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/go-chi/chi/v5 v5.2.3 h1:WQIt9uxdsAbgIYgid+BpYc+liqQZGMHRaUwp0JUcvdE=
|
||||
github.com/go-chi/chi/v5 v5.2.3/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops=
|
||||
github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs=
|
||||
github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
|
||||
github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI=
|
||||
github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
|
||||
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||
@@ -19,17 +21,24 @@ github.com/jackc/pgx/v5 v5.7.6 h1:rWQc5FwZSPX58r1OQmkuaNicxdmExaEz5A2DO2hUuTk=
|
||||
github.com/jackc/pgx/v5 v5.7.6/go.mod h1:aruU7o91Tc2q2cFp5h4uP3f6ztExVpyVv88Xl/8Vl8M=
|
||||
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||
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/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
|
||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
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/oauth2 v0.28.0 h1:CrgCKl8PPAVtLnU3c+EDw6x11699EWlsDeWNWKdIOkc=
|
||||
golang.org/x/oauth2 v0.28.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT8=
|
||||
golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610=
|
||||
golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
||||
golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20=
|
||||
golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0=
|
||||
golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
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=
|
||||
|
||||
@@ -6,9 +6,11 @@ import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"go.b0esche.cloud/backend/internal/database"
|
||||
"golang.org/x/crypto/argon2"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
@@ -17,6 +19,12 @@ const (
|
||||
RPID = "b0esche.cloud"
|
||||
RPName = "b0esche Cloud"
|
||||
Origin = "https://b0esche.cloud"
|
||||
|
||||
// Argon2id parameters (OWASP recommendations)
|
||||
Argon2Time = 2 // iterations
|
||||
Argon2Memory = 19 * 1024 // 19 MB
|
||||
Argon2Threads = 1
|
||||
Argon2KeyLen = 32
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
@@ -284,19 +292,76 @@ func byteArraysEqual(a, b []byte) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
// HashPassword hashes a password using bcrypt
|
||||
// HashPassword hashes a password using Argon2id (quantum-resistant)
|
||||
// Format: $argon2id$v=19$m=19456,t=2,p=1$<salt>$<hash>
|
||||
func (s *Service) HashPassword(password string) (string, error) {
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to hash password: %w", err)
|
||||
// Generate 16-byte random salt
|
||||
salt := make([]byte, 16)
|
||||
if _, err := rand.Read(salt); err != nil {
|
||||
return "", fmt.Errorf("failed to generate salt: %w", err)
|
||||
}
|
||||
return string(hash), nil
|
||||
|
||||
// Hash with Argon2id
|
||||
hash := argon2.IDKey([]byte(password), salt, Argon2Time, Argon2Memory, Argon2Threads, Argon2KeyLen)
|
||||
|
||||
// Encode in PHC string format
|
||||
b64Salt := base64.RawStdEncoding.EncodeToString(salt)
|
||||
b64Hash := base64.RawStdEncoding.EncodeToString(hash)
|
||||
|
||||
return fmt.Sprintf("$argon2id$v=19$m=%d,t=%d,p=%d$%s$%s",
|
||||
Argon2Memory, Argon2Time, Argon2Threads, b64Salt, b64Hash), nil
|
||||
}
|
||||
|
||||
// VerifyPassword checks if a password matches its hash
|
||||
// Supports both Argon2id (new) and bcrypt (legacy) for backward compatibility
|
||||
func (s *Service) VerifyPassword(passwordHash string, password string) bool {
|
||||
err := bcrypt.CompareHashAndPassword([]byte(passwordHash), []byte(password))
|
||||
return err == nil
|
||||
// Detect hash format
|
||||
if strings.HasPrefix(passwordHash, "$argon2id$") {
|
||||
return s.verifyArgon2(passwordHash, password)
|
||||
} else if strings.HasPrefix(passwordHash, "$2") {
|
||||
// Legacy bcrypt hash
|
||||
err := bcrypt.CompareHashAndPassword([]byte(passwordHash), []byte(password))
|
||||
return err == nil
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (s *Service) verifyArgon2(encodedHash string, password string) bool {
|
||||
// Parse PHC format: $argon2id$v=19$m=19456,t=2,p=1$<salt>$<hash>
|
||||
parts := strings.Split(encodedHash, "$")
|
||||
if len(parts) != 6 {
|
||||
return false
|
||||
}
|
||||
|
||||
var memory, time uint32
|
||||
var threads uint8
|
||||
_, err := fmt.Sscanf(parts[3], "m=%d,t=%d,p=%d", &memory, &time, &threads)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
salt, err := base64.RawStdEncoding.DecodeString(parts[4])
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
hash, err := base64.RawStdEncoding.DecodeString(parts[5])
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
// Compute hash with same parameters
|
||||
computedHash := argon2.IDKey([]byte(password), salt, time, memory, threads, uint32(len(hash)))
|
||||
|
||||
// Constant-time comparison
|
||||
if len(hash) != len(computedHash) {
|
||||
return false
|
||||
}
|
||||
var diff byte
|
||||
for i := 0; i < len(hash); i++ {
|
||||
diff |= hash[i] ^ computedHash[i]
|
||||
}
|
||||
return diff == 0
|
||||
}
|
||||
|
||||
// VerifyPasswordLogin verifies username and password credentials
|
||||
|
||||
@@ -12,6 +12,10 @@ type Config struct {
|
||||
OIDCClientID string
|
||||
OIDCClientSecret string
|
||||
JWTSecret string
|
||||
NextcloudURL string
|
||||
NextcloudUser string
|
||||
NextcloudPass string
|
||||
NextcloudBase string
|
||||
}
|
||||
|
||||
func Load() *Config {
|
||||
@@ -23,6 +27,10 @@ func Load() *Config {
|
||||
OIDCClientID: os.Getenv("OIDC_CLIENT_ID"),
|
||||
OIDCClientSecret: os.Getenv("OIDC_CLIENT_SECRET"),
|
||||
JWTSecret: os.Getenv("JWT_SECRET"),
|
||||
NextcloudURL: os.Getenv("NEXTCLOUD_URL"),
|
||||
NextcloudUser: os.Getenv("NEXTCLOUD_USER"),
|
||||
NextcloudPass: os.Getenv("NEXTCLOUD_PASSWORD"),
|
||||
NextcloudBase: getEnv("NEXTCLOUD_BASEPATH", "/"),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -55,10 +55,10 @@ type Session struct {
|
||||
}
|
||||
|
||||
type Organization struct {
|
||||
ID uuid.UUID
|
||||
Name string
|
||||
Slug string
|
||||
CreatedAt time.Time
|
||||
ID uuid.UUID `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Slug string `json:"slug"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
}
|
||||
|
||||
type Membership struct {
|
||||
@@ -78,6 +78,18 @@ type Activity struct {
|
||||
Timestamp time.Time
|
||||
}
|
||||
|
||||
type File struct {
|
||||
ID uuid.UUID
|
||||
OrgID *uuid.UUID
|
||||
UserID *uuid.UUID
|
||||
Name string
|
||||
Path string
|
||||
Type string
|
||||
Size int64
|
||||
LastModified time.Time
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
func (db *DB) GetOrCreateUser(ctx context.Context, sub, email, name string) (*User, error) {
|
||||
var user User
|
||||
err := db.QueryRowContext(ctx, `
|
||||
@@ -120,6 +132,15 @@ func (db *DB) GetSession(ctx context.Context, sessionID uuid.UUID) (*Session, er
|
||||
return &session, nil
|
||||
}
|
||||
|
||||
func (db *DB) RevokeSession(ctx context.Context, sessionID uuid.UUID) error {
|
||||
_, err := db.ExecContext(ctx, `
|
||||
UPDATE sessions
|
||||
SET revoked_at = NOW()
|
||||
WHERE id = $1 AND revoked_at IS NULL
|
||||
`, sessionID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (db *DB) GetUserOrganizations(ctx context.Context, userID uuid.UUID) ([]Organization, error) {
|
||||
rows, err := db.QueryContext(ctx, `
|
||||
SELECT o.id, o.name, o.slug, o.created_at
|
||||
@@ -233,6 +254,140 @@ func (db *DB) GetOrgMembers(ctx context.Context, orgID uuid.UUID) ([]Membership,
|
||||
return memberships, rows.Err()
|
||||
}
|
||||
|
||||
// GetOrgFiles returns files for a given organization (top-level folder listing)
|
||||
func (db *DB) GetOrgFiles(ctx context.Context, orgID uuid.UUID, 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 files under the given path (including nested).
|
||||
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 path LIKE $2 || '/%'
|
||||
AND ($4 = '' OR name ILIKE '%' || $4 || '%')
|
||||
ORDER BY name
|
||||
LIMIT $5 OFFSET $6
|
||||
`, orgID, path, 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
|
||||
|
||||
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 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
|
||||
}
|
||||
|
||||
// DeleteFileByPath removes a file or folder matching path for a given org or user
|
||||
func (db *DB) DeleteFileByPath(ctx context.Context, orgID *uuid.UUID, userID *uuid.UUID, path string) error {
|
||||
var res sql.Result
|
||||
var err error
|
||||
if orgID != nil {
|
||||
res, err = db.ExecContext(ctx, `DELETE FROM files WHERE org_id = $1 AND path = $2`, *orgID, path)
|
||||
} else if userID != nil {
|
||||
res, err = db.ExecContext(ctx, `DELETE FROM files WHERE user_id = $1 AND path = $2`, *userID, path)
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, _ = res.RowsAffected()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (db *DB) UpdateMemberRole(ctx context.Context, orgID, userID uuid.UUID, role string) error {
|
||||
_, err := db.ExecContext(ctx, `
|
||||
UPDATE memberships
|
||||
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
"net/http"
|
||||
"os"
|
||||
|
||||
"github.com/go-chi/chi/v5/middleware"
|
||||
chimiddleware "github.com/go-chi/chi/v5/middleware"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
@@ -40,7 +40,7 @@ func WriteError(w http.ResponseWriter, code ErrorCode, message string, status in
|
||||
|
||||
// GetRequestID extracts the request ID from the request context
|
||||
func GetRequestID(r *http.Request) string {
|
||||
if reqID := middleware.GetReqID(r.Context()); reqID != "" {
|
||||
if reqID := chimiddleware.GetReqID(r.Context()); reqID != "" {
|
||||
return reqID
|
||||
}
|
||||
return "unknown"
|
||||
@@ -48,10 +48,10 @@ func GetRequestID(r *http.Request) string {
|
||||
|
||||
// GetUserID extracts user ID from context if available
|
||||
func GetUserID(r *http.Request) string {
|
||||
if userID := r.Context().Value("user"); userID != nil {
|
||||
if uid, ok := userID.(string); ok {
|
||||
return uid
|
||||
}
|
||||
// Use type contextKey matching middleware package
|
||||
type contextKey string
|
||||
if userID, ok := r.Context().Value(contextKey("user")).(string); ok && userID != "" {
|
||||
return userID
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -18,15 +25,19 @@ import (
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/google/uuid"
|
||||
"go.b0esche.cloud/backend/internal/storage"
|
||||
)
|
||||
|
||||
func NewRouter(cfg *config.Config, db *database.DB, jwtManager *jwt.Manager, authService *auth.Service, auditLogger *audit.Logger) http.Handler {
|
||||
r := chi.NewRouter()
|
||||
// optional WebDAV/Nextcloud client
|
||||
storageClient := storage.NewWebDAVClient(cfg)
|
||||
|
||||
// Global middleware
|
||||
r.Use(middleware.RequestID)
|
||||
r.Use(middleware.Logger)
|
||||
r.Use(middleware.Recoverer)
|
||||
r.Use(middleware.CORS)
|
||||
r.Use(middleware.RateLimit)
|
||||
|
||||
// Health check
|
||||
@@ -37,6 +48,9 @@ func NewRouter(cfg *config.Config, db *database.DB, jwtManager *jwt.Manager, aut
|
||||
r.Post("/refresh", func(w http.ResponseWriter, req *http.Request) {
|
||||
refreshHandler(w, req, jwtManager, db)
|
||||
})
|
||||
r.Post("/logout", func(w http.ResponseWriter, req *http.Request) {
|
||||
logoutHandler(w, req, jwtManager, db, auditLogger)
|
||||
})
|
||||
// Passkey routes
|
||||
r.Post("/signup", func(w http.ResponseWriter, req *http.Request) {
|
||||
signupHandler(w, req, db, auditLogger)
|
||||
@@ -59,49 +73,89 @@ func NewRouter(cfg *config.Config, db *database.DB, jwtManager *jwt.Manager, aut
|
||||
})
|
||||
})
|
||||
|
||||
// Auth middleware for protected routes
|
||||
r.Use(middleware.Auth(jwtManager, db))
|
||||
// Protected routes (with auth middleware)
|
||||
r.Route("/", func(r chi.Router) {
|
||||
r.Use(middleware.Auth(jwtManager, db))
|
||||
|
||||
// Org routes
|
||||
r.Get("/orgs", func(w http.ResponseWriter, req *http.Request) {
|
||||
listOrgsHandler(w, req, db, jwtManager)
|
||||
})
|
||||
r.Post("/orgs", func(w http.ResponseWriter, req *http.Request) {
|
||||
createOrgHandler(w, req, db, auditLogger, jwtManager)
|
||||
})
|
||||
// User-scoped routes (personal workspace)
|
||||
r.Get("/user/files", func(w http.ResponseWriter, req *http.Request) {
|
||||
userFilesHandler(w, req, db)
|
||||
})
|
||||
// Download user file
|
||||
r.Get("/user/files/download", func(w http.ResponseWriter, req *http.Request) {
|
||||
downloadUserFileHandler(w, req, db, storageClient)
|
||||
})
|
||||
// Create / delete in user workspace
|
||||
r.Post("/user/files", func(w http.ResponseWriter, req *http.Request) {
|
||||
createUserFileHandler(w, req, db, auditLogger, storageClient)
|
||||
})
|
||||
r.Delete("/user/files", func(w http.ResponseWriter, req *http.Request) {
|
||||
deleteUserFileHandler(w, req, db, auditLogger, storageClient)
|
||||
})
|
||||
// POST wrapper for delete
|
||||
r.Post("/user/files/delete", func(w http.ResponseWriter, req *http.Request) {
|
||||
deleteUserFilePostHandler(w, req, db, auditLogger, storageClient)
|
||||
})
|
||||
|
||||
// Org-scoped routes
|
||||
r.Route("/orgs/{orgId}", func(r chi.Router) {
|
||||
r.Use(middleware.Org(db, auditLogger))
|
||||
// Org routes
|
||||
r.Get("/orgs", func(w http.ResponseWriter, req *http.Request) {
|
||||
listOrgsHandler(w, req, db, jwtManager)
|
||||
})
|
||||
r.Post("/orgs", func(w http.ResponseWriter, req *http.Request) {
|
||||
createOrgHandler(w, req, db, auditLogger, jwtManager)
|
||||
})
|
||||
|
||||
// File routes
|
||||
r.With(middleware.Permission(db, auditLogger, permission.FileRead)).Get("/files", func(w http.ResponseWriter, req *http.Request) {
|
||||
listFilesHandler(w, req)
|
||||
})
|
||||
r.Route("/files/{fileId}", func(r chi.Router) {
|
||||
r.With(middleware.Permission(db, auditLogger, permission.DocumentView)).Get("/view", func(w http.ResponseWriter, req *http.Request) {
|
||||
viewerHandler(w, req, db, auditLogger)
|
||||
// Org-scoped routes
|
||||
r.Route("/orgs/{orgId}", func(r chi.Router) {
|
||||
r.Use(middleware.Org(db, auditLogger))
|
||||
|
||||
// File routes
|
||||
r.With(middleware.Permission(db, auditLogger, permission.FileRead)).Get("/files", func(w http.ResponseWriter, req *http.Request) {
|
||||
listFilesHandler(w, req, db)
|
||||
})
|
||||
r.With(middleware.Permission(db, auditLogger, permission.DocumentEdit)).Get("/edit", func(w http.ResponseWriter, req *http.Request) {
|
||||
editorHandler(w, req, db, auditLogger)
|
||||
// Download org file
|
||||
r.With(middleware.Permission(db, auditLogger, permission.FileRead)).Get("/files/download", func(w http.ResponseWriter, req *http.Request) {
|
||||
downloadOrgFileHandler(w, req, db, storageClient)
|
||||
})
|
||||
r.With(middleware.Permission(db, auditLogger, permission.DocumentEdit)).Post("/annotations", func(w http.ResponseWriter, req *http.Request) {
|
||||
annotationsHandler(w, req, db, auditLogger)
|
||||
|
||||
// Create file/folder in org workspace
|
||||
r.With(middleware.Permission(db, auditLogger, permission.FileWrite)).Post("/files", func(w http.ResponseWriter, req *http.Request) {
|
||||
createOrgFileHandler(w, req, db, auditLogger, storageClient)
|
||||
})
|
||||
r.Get("/meta", func(w http.ResponseWriter, req *http.Request) {
|
||||
fileMetaHandler(w, req)
|
||||
// Also accept POST delete for clients that cannot send DELETE with body
|
||||
r.With(middleware.Permission(db, auditLogger, permission.FileWrite)).Post("/files/delete", func(w http.ResponseWriter, req *http.Request) {
|
||||
deleteOrgFilePostHandler(w, req, db, auditLogger, storageClient)
|
||||
})
|
||||
|
||||
// Delete file/folder in org workspace (body: {"path":"/path"})
|
||||
r.With(middleware.Permission(db, auditLogger, permission.FileWrite)).Delete("/files", func(w http.ResponseWriter, req *http.Request) {
|
||||
deleteOrgFileHandler(w, req, db, auditLogger, storageClient)
|
||||
})
|
||||
r.Route("/files/{fileId}", func(r chi.Router) {
|
||||
r.With(middleware.Permission(db, auditLogger, permission.DocumentView)).Get("/view", func(w http.ResponseWriter, req *http.Request) {
|
||||
viewerHandler(w, req, db, auditLogger)
|
||||
})
|
||||
r.With(middleware.Permission(db, auditLogger, permission.DocumentEdit)).Get("/edit", func(w http.ResponseWriter, req *http.Request) {
|
||||
editorHandler(w, req, db, auditLogger)
|
||||
})
|
||||
r.With(middleware.Permission(db, auditLogger, permission.DocumentEdit)).Post("/annotations", func(w http.ResponseWriter, req *http.Request) {
|
||||
annotationsHandler(w, req, db, auditLogger)
|
||||
})
|
||||
r.Get("/meta", func(w http.ResponseWriter, req *http.Request) {
|
||||
fileMetaHandler(w, req)
|
||||
})
|
||||
})
|
||||
r.Get("/activity", func(w http.ResponseWriter, req *http.Request) {
|
||||
activityHandler(w, req, db)
|
||||
})
|
||||
r.With(middleware.Permission(db, auditLogger, permission.OrgManage)).Get("/members", func(w http.ResponseWriter, req *http.Request) {
|
||||
listMembersHandler(w, req, db)
|
||||
})
|
||||
r.With(middleware.Permission(db, auditLogger, permission.OrgManage)).Patch("/members/{userId}", func(w http.ResponseWriter, req *http.Request) {
|
||||
updateMemberRoleHandler(w, req, db, auditLogger)
|
||||
})
|
||||
})
|
||||
r.Get("/activity", func(w http.ResponseWriter, req *http.Request) {
|
||||
activityHandler(w, req, db)
|
||||
})
|
||||
r.With(middleware.Permission(db, auditLogger, permission.OrgManage)).Get("/members", func(w http.ResponseWriter, req *http.Request) {
|
||||
listMembersHandler(w, req, db)
|
||||
})
|
||||
r.With(middleware.Permission(db, auditLogger, permission.OrgManage)).Patch("/members/{userId}", func(w http.ResponseWriter, req *http.Request) {
|
||||
updateMemberRoleHandler(w, req, db, auditLogger)
|
||||
})
|
||||
})
|
||||
}) // Close protected routes
|
||||
|
||||
return r
|
||||
}
|
||||
@@ -149,6 +203,41 @@ func refreshHandler(w http.ResponseWriter, r *http.Request, jwtManager *jwt.Mana
|
||||
w.Write([]byte(`{"token": "` + newToken + `"}`))
|
||||
}
|
||||
|
||||
func logoutHandler(w http.ResponseWriter, r *http.Request, jwtManager *jwt.Manager, db *database.DB, auditLogger *audit.Logger) {
|
||||
authHeader := r.Header.Get("Authorization")
|
||||
if !strings.HasPrefix(authHeader, "Bearer ") {
|
||||
errors.WriteError(w, errors.CodeUnauthenticated, "Unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
tokenString := strings.TrimPrefix(authHeader, "Bearer ")
|
||||
|
||||
claims, session, err := jwtManager.ValidateWithSession(r.Context(), tokenString, db)
|
||||
if err != nil {
|
||||
// Token invalid or session already revoked/expired — still return success
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(`{"status": "ok"}`))
|
||||
return
|
||||
}
|
||||
|
||||
userID, _ := uuid.Parse(claims.UserID)
|
||||
|
||||
// Revoke session
|
||||
if err := db.RevokeSession(r.Context(), session.ID); err != nil {
|
||||
errors.LogError(r, err, "Failed to revoke session")
|
||||
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
auditLogger.Log(r.Context(), audit.Entry{
|
||||
UserID: &userID,
|
||||
Action: "logout",
|
||||
Success: true,
|
||||
})
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(`{"status": "ok"}`))
|
||||
}
|
||||
|
||||
func listOrgsHandler(w http.ResponseWriter, r *http.Request, db *database.DB, jwtManager *jwt.Manager) {
|
||||
authHeader := r.Header.Get("Authorization")
|
||||
if !strings.HasPrefix(authHeader, "Bearer ") {
|
||||
@@ -220,25 +309,49 @@ func createOrgHandler(w http.ResponseWriter, r *http.Request, db *database.DB, a
|
||||
json.NewEncoder(w).Encode(org)
|
||||
}
|
||||
|
||||
func listFilesHandler(w http.ResponseWriter, r *http.Request) {
|
||||
// Mock files
|
||||
files := []struct {
|
||||
Name string `json:"name"`
|
||||
Path string `json:"path"`
|
||||
Type string `json:"type"`
|
||||
Size int `json:"size"`
|
||||
LastModified string `json:"lastModified"`
|
||||
}{
|
||||
{"test.pdf", "/test.pdf", "file", 1234, "2023-01-01T00:00:00Z"},
|
||||
{"folder", "/folder", "folder", 0, "2023-01-01T00:00:00Z"},
|
||||
func listFilesHandler(w http.ResponseWriter, r *http.Request, db *database.DB) {
|
||||
// Org ID is provided by middleware.Org
|
||||
orgID := r.Context().Value("org").(uuid.UUID)
|
||||
// Query params: path, q (search), page, pageSize
|
||||
path := r.URL.Query().Get("path")
|
||||
if path == "" {
|
||||
path = "/"
|
||||
}
|
||||
q := r.URL.Query().Get("q")
|
||||
page := 1
|
||||
pageSize := 100
|
||||
if p := r.URL.Query().Get("page"); p != "" {
|
||||
fmt.Sscanf(p, "%d", &page)
|
||||
}
|
||||
if ps := r.URL.Query().Get("pageSize"); ps != "" {
|
||||
fmt.Sscanf(ps, "%d", &pageSize)
|
||||
}
|
||||
|
||||
files, err := db.GetOrgFiles(r.Context(), orgID, path, q, page, pageSize)
|
||||
if err != nil {
|
||||
errors.LogError(r, err, "Failed to get org files")
|
||||
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Convert to a JSON-friendly shape expected by frontend
|
||||
out := make([]map[string]interface{}, 0, len(files))
|
||||
for _, f := range files {
|
||||
out = append(out, map[string]interface{}{
|
||||
"name": f.Name,
|
||||
"path": f.Path,
|
||||
"type": f.Type,
|
||||
"size": f.Size,
|
||||
"lastModified": f.LastModified.UTC().Format(time.RFC3339),
|
||||
})
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(files)
|
||||
json.NewEncoder(w).Encode(out)
|
||||
}
|
||||
|
||||
func viewerHandler(w http.ResponseWriter, r *http.Request, db *database.DB, auditLogger *audit.Logger) {
|
||||
userIDStr := r.Context().Value("user").(string)
|
||||
userIDStr, _ := middleware.GetUserID(r.Context())
|
||||
userID, _ := uuid.Parse(userIDStr)
|
||||
orgID := r.Context().Value("org").(uuid.UUID)
|
||||
fileId := chi.URLParam(r, "fileId")
|
||||
@@ -269,7 +382,7 @@ func viewerHandler(w http.ResponseWriter, r *http.Request, db *database.DB, audi
|
||||
}
|
||||
|
||||
func editorHandler(w http.ResponseWriter, r *http.Request, db *database.DB, auditLogger *audit.Logger) {
|
||||
userIDStr := r.Context().Value("user").(string)
|
||||
userIDStr, _ := middleware.GetUserID(r.Context())
|
||||
userID, _ := uuid.Parse(userIDStr)
|
||||
orgID := r.Context().Value("org").(uuid.UUID)
|
||||
fileId := chi.URLParam(r, "fileId")
|
||||
@@ -292,7 +405,7 @@ func editorHandler(w http.ResponseWriter, r *http.Request, db *database.DB, audi
|
||||
}
|
||||
|
||||
func annotationsHandler(w http.ResponseWriter, r *http.Request, db *database.DB, auditLogger *audit.Logger) {
|
||||
userIDStr := r.Context().Value("user").(string)
|
||||
userIDStr, _ := middleware.GetUserID(r.Context())
|
||||
userID, _ := uuid.Parse(userIDStr)
|
||||
orgID := r.Context().Value("org").(uuid.UUID)
|
||||
fileId := chi.URLParam(r, "fileId")
|
||||
@@ -541,7 +654,7 @@ func registrationVerifyHandler(w http.ResponseWriter, r *http.Request, db *datab
|
||||
|
||||
// Generate JWT
|
||||
orgIDs := []string{}
|
||||
token, err := jwtManager.Generate(user.Email, orgIDs, session.ID.String())
|
||||
token, err := jwtManager.Generate(user.ID.String(), orgIDs, session.ID.String())
|
||||
if err != nil {
|
||||
errors.LogError(r, err, "Token generation failed")
|
||||
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
|
||||
@@ -643,7 +756,7 @@ func authenticationVerifyHandler(w http.ResponseWriter, r *http.Request, db *dat
|
||||
}
|
||||
|
||||
// Generate JWT
|
||||
token, err := jwtManager.Generate(user.Email, orgIDs, session.ID.String())
|
||||
token, err := jwtManager.Generate(user.ID.String(), orgIDs, session.ID.String())
|
||||
if err != nil {
|
||||
errors.LogError(r, err, "Token generation failed")
|
||||
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
|
||||
@@ -713,7 +826,7 @@ func passwordLoginHandler(w http.ResponseWriter, r *http.Request, db *database.D
|
||||
}
|
||||
|
||||
// Generate JWT
|
||||
token, err := jwtManager.Generate(user.Email, orgIDs, session.ID.String())
|
||||
token, err := jwtManager.Generate(user.ID.String(), orgIDs, session.ID.String())
|
||||
if err != nil {
|
||||
errors.LogError(r, err, "Token generation failed")
|
||||
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
|
||||
@@ -732,3 +845,499 @@ func passwordLoginHandler(w http.ResponseWriter, r *http.Request, db *database.D
|
||||
"user": user,
|
||||
})
|
||||
}
|
||||
|
||||
// userFilesHandler returns files for the authenticated user's personal workspace.
|
||||
func userFilesHandler(w http.ResponseWriter, r *http.Request, db *database.DB) {
|
||||
userIDStr, ok := middleware.GetUserID(r.Context())
|
||||
if !ok || userIDStr == "" {
|
||||
errors.WriteError(w, errors.CodeUnauthenticated, "Unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
userID, err := uuid.Parse(userIDStr)
|
||||
if err != nil {
|
||||
errors.LogError(r, err, "Invalid user id in context")
|
||||
errors.WriteError(w, errors.CodeUnauthenticated, "Unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
path := r.URL.Query().Get("path")
|
||||
if path == "" {
|
||||
path = "/"
|
||||
}
|
||||
q := r.URL.Query().Get("q")
|
||||
page := 1
|
||||
pageSize := 100
|
||||
if p := r.URL.Query().Get("page"); p != "" {
|
||||
fmt.Sscanf(p, "%d", &page)
|
||||
}
|
||||
if ps := r.URL.Query().Get("pageSize"); ps != "" {
|
||||
fmt.Sscanf(ps, "%d", &pageSize)
|
||||
}
|
||||
|
||||
files, err := db.GetUserFiles(r.Context(), userID, path, q, page, pageSize)
|
||||
if err != nil {
|
||||
errors.LogError(r, err, "Failed to get user files")
|
||||
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
out := make([]map[string]interface{}, 0, len(files))
|
||||
for _, f := range files {
|
||||
out = append(out, map[string]interface{}{
|
||||
"name": f.Name,
|
||||
"path": f.Path,
|
||||
"type": f.Type,
|
||||
"size": f.Size,
|
||||
"lastModified": f.LastModified.UTC().Format(time.RFC3339),
|
||||
})
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(out)
|
||||
}
|
||||
|
||||
// createOrgFileHandler creates a file or folder record for an org workspace.
|
||||
func createOrgFileHandler(w http.ResponseWriter, r *http.Request, db *database.DB, auditLogger *audit.Logger, storageClient *storage.WebDAVClient) {
|
||||
orgID := r.Context().Value("org").(uuid.UUID)
|
||||
userIDStr, _ := middleware.GetUserID(r.Context())
|
||||
userID, _ := uuid.Parse(userIDStr)
|
||||
var f *database.File
|
||||
var err error
|
||||
|
||||
// Support multipart uploads (field "file") or JSON metadata for folders
|
||||
contentType := r.Header.Get("Content-Type")
|
||||
if strings.HasPrefix(contentType, "multipart/form-data") {
|
||||
// Handle file upload
|
||||
if err = r.ParseMultipartForm(32 << 20); err != nil {
|
||||
errors.WriteError(w, errors.CodeInvalidArgument, "Bad multipart request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
parentPath := r.FormValue("path")
|
||||
if parentPath == "" {
|
||||
parentPath = "/"
|
||||
}
|
||||
var file multipart.File
|
||||
var header *multipart.FileHeader
|
||||
file, header, err = r.FormFile("file")
|
||||
if err != nil {
|
||||
errors.WriteError(w, errors.CodeInvalidArgument, "Missing file", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
// Read file into memory (so we can attempt WebDAV upload and fallback to disk)
|
||||
data, err := io.ReadAll(file)
|
||||
if err != nil {
|
||||
errors.LogError(r, err, "Failed to read uploaded file")
|
||||
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Attempt WebDAV upload when configured
|
||||
storedPath := filepath.ToSlash(filepath.Join(parentPath, header.Filename))
|
||||
if !strings.HasPrefix(storedPath, "/") {
|
||||
storedPath = "/" + storedPath
|
||||
}
|
||||
written := int64(len(data))
|
||||
if storageClient != nil {
|
||||
// Build remote path under /orgs/<orgId>
|
||||
rel := strings.TrimPrefix(storedPath, "/")
|
||||
remotePath := path.Join("/orgs", orgID.String(), rel)
|
||||
if err = storageClient.Upload(r.Context(), remotePath, bytes.NewReader(data), int64(len(data))); err != nil {
|
||||
// Log and fallback to local disk
|
||||
errors.LogError(r, err, "WebDAV upload failed, falling back to local disk")
|
||||
} else {
|
||||
// success -> persist metadata and return
|
||||
f, err = db.CreateFile(r.Context(), &orgID, &userID, header.Filename, storedPath, "file", written)
|
||||
if err != nil {
|
||||
errors.LogError(r, err, "Failed to create org file")
|
||||
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
auditLogger.Log(r.Context(), audit.Entry{
|
||||
UserID: &userID,
|
||||
OrgID: &orgID,
|
||||
Action: "upload_file",
|
||||
Success: true,
|
||||
})
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{"id": f.ID})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: Save to temp directory (WebDAV should be the primary storage)
|
||||
baseDir := filepath.Join("/tmp", "uploads", "orgs", orgID.String())
|
||||
targetDir := filepath.Join(baseDir, parentPath)
|
||||
if err = os.MkdirAll(targetDir, 0o755); err != nil {
|
||||
errors.LogError(r, err, "Failed to create target dir in /tmp")
|
||||
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
outPath := filepath.Join(targetDir, header.Filename)
|
||||
if err = os.WriteFile(outPath, data, 0o644); err != nil {
|
||||
errors.LogError(r, err, "Failed to write file to /tmp")
|
||||
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Store metadata in DB; store path relative to workspace root
|
||||
storedPath = filepath.ToSlash(filepath.Join(parentPath, header.Filename))
|
||||
if !strings.HasPrefix(storedPath, "/") {
|
||||
storedPath = "/" + storedPath
|
||||
}
|
||||
f, err = db.CreateFile(r.Context(), &orgID, &userID, header.Filename, storedPath, "file", written)
|
||||
if err != nil {
|
||||
errors.LogError(r, err, "Failed to create org file")
|
||||
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
auditLogger.Log(r.Context(), audit.Entry{
|
||||
UserID: &userID,
|
||||
OrgID: &orgID,
|
||||
Action: "upload_file",
|
||||
Success: true,
|
||||
})
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{"id": f.ID})
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Name string `json:"name"`
|
||||
Path string `json:"path"`
|
||||
Type string `json:"type"` // file|folder
|
||||
Size int64 `json:"size"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
errors.WriteError(w, errors.CodeInvalidArgument, "Bad request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
f, err = db.CreateFile(r.Context(), &orgID, &userID, req.Name, req.Path, req.Type, req.Size)
|
||||
if err != nil {
|
||||
errors.LogError(r, err, "Failed to create org file")
|
||||
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
auditLogger.Log(r.Context(), audit.Entry{
|
||||
UserID: &userID,
|
||||
OrgID: &orgID,
|
||||
Action: "create_file",
|
||||
Success: true,
|
||||
})
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{"id": f.ID})
|
||||
}
|
||||
|
||||
// deleteOrgFileHandler deletes a file/folder in org workspace by path
|
||||
func deleteOrgFileHandler(w http.ResponseWriter, r *http.Request, db *database.DB, auditLogger *audit.Logger, storageClient *storage.WebDAVClient) {
|
||||
orgID := r.Context().Value("org").(uuid.UUID)
|
||||
userIDStr, _ := middleware.GetUserID(r.Context())
|
||||
userID, _ := uuid.Parse(userIDStr)
|
||||
|
||||
var req struct {
|
||||
Path string `json:"path"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
errors.WriteError(w, errors.CodeInvalidArgument, "Bad request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Delete from Nextcloud if configured
|
||||
if storageClient != nil {
|
||||
rel := strings.TrimPrefix(req.Path, "/")
|
||||
remotePath := path.Join("/orgs", orgID.String(), rel)
|
||||
if err := storageClient.Delete(r.Context(), remotePath); err != nil {
|
||||
errors.LogError(r, err, "Failed to delete from Nextcloud (continuing anyway)")
|
||||
}
|
||||
}
|
||||
|
||||
// Delete from database
|
||||
if err := db.DeleteFileByPath(r.Context(), &orgID, nil, req.Path); err != nil {
|
||||
errors.LogError(r, err, "Failed to delete org file")
|
||||
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
auditLogger.Log(r.Context(), audit.Entry{
|
||||
UserID: &userID,
|
||||
OrgID: &orgID,
|
||||
Action: "delete_file",
|
||||
Success: true,
|
||||
})
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(`{"status":"ok"}`))
|
||||
}
|
||||
|
||||
// Also accept POST /orgs/{orgId}/files/delete for clients that cannot send DELETE with body
|
||||
func deleteOrgFilePostHandler(w http.ResponseWriter, r *http.Request, db *database.DB, auditLogger *audit.Logger, storageClient *storage.WebDAVClient) {
|
||||
deleteOrgFileHandler(w, r, db, auditLogger, storageClient)
|
||||
}
|
||||
|
||||
// createUserFileHandler creates a file or folder record for the authenticated user's personal workspace.
|
||||
func createUserFileHandler(w http.ResponseWriter, r *http.Request, db *database.DB, auditLogger *audit.Logger, storageClient *storage.WebDAVClient) {
|
||||
userIDStr, ok := middleware.GetUserID(r.Context())
|
||||
if !ok || userIDStr == "" {
|
||||
errors.WriteError(w, errors.CodeUnauthenticated, "Unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
userID, _ := uuid.Parse(userIDStr)
|
||||
var f *database.File
|
||||
var err error
|
||||
// Support multipart uploads for file content or JSON for folders
|
||||
contentType := r.Header.Get("Content-Type")
|
||||
if strings.HasPrefix(contentType, "multipart/form-data") {
|
||||
if err = r.ParseMultipartForm(32 << 20); err != nil {
|
||||
errors.WriteError(w, errors.CodeInvalidArgument, "Bad multipart request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
parentPath := r.FormValue("path")
|
||||
if parentPath == "" {
|
||||
parentPath = "/"
|
||||
}
|
||||
var file multipart.File
|
||||
var header *multipart.FileHeader
|
||||
file, header, err = r.FormFile("file")
|
||||
if err != nil {
|
||||
errors.WriteError(w, errors.CodeInvalidArgument, "Missing file", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
// Read file into memory to allow WebDAV upload and disk fallback
|
||||
data, err := io.ReadAll(file)
|
||||
if err != nil {
|
||||
errors.LogError(r, err, "Failed to read uploaded file")
|
||||
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
storedPath := filepath.ToSlash(filepath.Join(parentPath, header.Filename))
|
||||
if !strings.HasPrefix(storedPath, "/") {
|
||||
storedPath = "/" + storedPath
|
||||
}
|
||||
written := int64(len(data))
|
||||
if storageClient != nil {
|
||||
rel := strings.TrimPrefix(storedPath, "/")
|
||||
remotePath := path.Join("/users", userID.String(), rel)
|
||||
if err = storageClient.Upload(r.Context(), remotePath, bytes.NewReader(data), int64(len(data))); err != nil {
|
||||
errors.LogError(r, err, "WebDAV upload failed, falling back to local disk")
|
||||
} else {
|
||||
f, err = db.CreateFile(r.Context(), nil, &userID, header.Filename, storedPath, "file", written)
|
||||
if err != nil {
|
||||
errors.LogError(r, err, "Failed to create user file")
|
||||
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
auditLogger.Log(r.Context(), audit.Entry{
|
||||
UserID: &userID,
|
||||
Action: "upload_user_file",
|
||||
Success: true,
|
||||
})
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{"id": f.ID})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: write to temp directory (WebDAV should be the primary storage)
|
||||
baseDir := filepath.Join("/tmp", "uploads", "users", userID.String())
|
||||
targetDir := filepath.Join(baseDir, parentPath)
|
||||
if err = os.MkdirAll(targetDir, 0o755); err != nil {
|
||||
errors.LogError(r, err, "Failed to create target dir in /tmp")
|
||||
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
outPath := filepath.Join(targetDir, header.Filename)
|
||||
if err = os.WriteFile(outPath, data, 0o644); err != nil {
|
||||
errors.LogError(r, err, "Failed to write file to /tmp")
|
||||
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
f, err = db.CreateFile(r.Context(), nil, &userID, header.Filename, storedPath, "file", written)
|
||||
if err != nil {
|
||||
errors.LogError(r, err, "Failed to create user file")
|
||||
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
auditLogger.Log(r.Context(), audit.Entry{
|
||||
UserID: &userID,
|
||||
Action: "upload_user_file",
|
||||
Success: true,
|
||||
})
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{"id": f.ID})
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Name string `json:"name"`
|
||||
Path string `json:"path"`
|
||||
Type string `json:"type"` // file|folder
|
||||
Size int64 `json:"size"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
errors.WriteError(w, errors.CodeInvalidArgument, "Bad request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
f, err = db.CreateFile(r.Context(), nil, &userID, req.Name, req.Path, req.Type, req.Size)
|
||||
if err != nil {
|
||||
errors.LogError(r, err, "Failed to create user file")
|
||||
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
auditLogger.Log(r.Context(), audit.Entry{
|
||||
UserID: &userID,
|
||||
Action: "create_user_file",
|
||||
Success: true,
|
||||
})
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{"id": f.ID})
|
||||
}
|
||||
|
||||
// Also accept POST /user/files/delete
|
||||
func deleteUserFilePostHandler(w http.ResponseWriter, r *http.Request, db *database.DB, auditLogger *audit.Logger, storageClient *storage.WebDAVClient) {
|
||||
deleteUserFileHandler(w, r, db, auditLogger, storageClient)
|
||||
}
|
||||
|
||||
// deleteUserFileHandler deletes a file/folder in user's personal workspace by path
|
||||
func deleteUserFileHandler(w http.ResponseWriter, r *http.Request, db *database.DB, auditLogger *audit.Logger, storageClient *storage.WebDAVClient) {
|
||||
userIDStr, ok := middleware.GetUserID(r.Context())
|
||||
if !ok || userIDStr == "" {
|
||||
errors.WriteError(w, errors.CodeUnauthenticated, "Unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
userID, _ := uuid.Parse(userIDStr)
|
||||
|
||||
var req struct {
|
||||
Path string `json:"path"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
errors.WriteError(w, errors.CodeInvalidArgument, "Bad request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Delete from Nextcloud if configured
|
||||
if storageClient != nil {
|
||||
rel := strings.TrimPrefix(req.Path, "/")
|
||||
// Keep remote user workspace path consistent with uploads: "/users/<userID>/<rel>"
|
||||
remotePath := path.Join("/users", userID.String(), rel)
|
||||
if err := storageClient.Delete(r.Context(), remotePath); err != nil {
|
||||
errors.LogError(r, err, "Failed to delete from Nextcloud (continuing anyway)")
|
||||
}
|
||||
}
|
||||
|
||||
// Delete from database
|
||||
if err := db.DeleteFileByPath(r.Context(), nil, &userID, req.Path); err != nil {
|
||||
errors.LogError(r, err, "Failed to delete user file")
|
||||
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
auditLogger.Log(r.Context(), audit.Entry{
|
||||
UserID: &userID,
|
||||
Action: "delete_user_file",
|
||||
Success: true,
|
||||
})
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(`{"status":"ok"}`))
|
||||
}
|
||||
|
||||
// downloadOrgFileHandler downloads a file from org workspace
|
||||
func downloadOrgFileHandler(w http.ResponseWriter, r *http.Request, db *database.DB, storageClient *storage.WebDAVClient) {
|
||||
orgID := r.Context().Value("org").(uuid.UUID)
|
||||
|
||||
filePath := r.URL.Query().Get("path")
|
||||
if filePath == "" {
|
||||
errors.WriteError(w, errors.CodeInvalidArgument, "Missing path parameter", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Try to download from Nextcloud first
|
||||
if storageClient != nil {
|
||||
rel := strings.TrimPrefix(filePath, "/")
|
||||
remotePath := path.Join("/orgs", orgID.String(), rel)
|
||||
|
||||
reader, size, err := storageClient.Download(r.Context(), remotePath)
|
||||
if err == nil {
|
||||
defer reader.Close()
|
||||
|
||||
// Set appropriate headers
|
||||
fileName := path.Base(filePath)
|
||||
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", fileName))
|
||||
w.Header().Set("Content-Type", "application/octet-stream")
|
||||
if size > 0 {
|
||||
w.Header().Set("Content-Length", fmt.Sprintf("%d", size))
|
||||
}
|
||||
|
||||
// Stream the file
|
||||
io.Copy(w, reader)
|
||||
return
|
||||
}
|
||||
|
||||
errors.LogError(r, err, "Failed to download from Nextcloud, trying local storage")
|
||||
}
|
||||
|
||||
// Fallback to local storage (if implemented)
|
||||
errors.WriteError(w, errors.CodeNotFound, "File not found", http.StatusNotFound)
|
||||
}
|
||||
|
||||
// downloadUserFileHandler downloads a file from user's personal workspace
|
||||
func downloadUserFileHandler(w http.ResponseWriter, r *http.Request, db *database.DB, storageClient *storage.WebDAVClient) {
|
||||
userIDStr, ok := middleware.GetUserID(r.Context())
|
||||
if !ok || userIDStr == "" {
|
||||
errors.WriteError(w, errors.CodeUnauthenticated, "Unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
userID, _ := uuid.Parse(userIDStr)
|
||||
|
||||
filePath := r.URL.Query().Get("path")
|
||||
if filePath == "" {
|
||||
errors.WriteError(w, errors.CodeInvalidArgument, "Missing path parameter", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Try to download from Nextcloud first
|
||||
if storageClient != nil {
|
||||
rel := strings.TrimPrefix(filePath, "/")
|
||||
// Keep remote user workspace path consistent with uploads: "/users/<userID>/<rel>"
|
||||
remotePath := path.Join("/users", userID.String(), rel)
|
||||
|
||||
reader, size, err := storageClient.Download(r.Context(), remotePath)
|
||||
if err == nil {
|
||||
defer reader.Close()
|
||||
|
||||
// Set appropriate headers
|
||||
fileName := path.Base(filePath)
|
||||
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", fileName))
|
||||
w.Header().Set("Content-Type", "application/octet-stream")
|
||||
if size > 0 {
|
||||
w.Header().Set("Content-Length", fmt.Sprintf("%d", size))
|
||||
}
|
||||
|
||||
// Stream the file
|
||||
io.Copy(w, reader)
|
||||
return
|
||||
}
|
||||
|
||||
errors.LogError(r, err, "Failed to download from Nextcloud, trying local storage")
|
||||
}
|
||||
|
||||
// Fallback to local storage (if implemented)
|
||||
errors.WriteError(w, errors.CodeNotFound, "File not found", http.StatusNotFound)
|
||||
}
|
||||
|
||||
@@ -21,6 +21,23 @@ var RequestID = middleware.RequestID
|
||||
var Logger = middleware.Logger
|
||||
var Recoverer = middleware.Recoverer
|
||||
|
||||
// CORS middleware
|
||||
func CORS(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
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
|
||||
var RateLimit = func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -37,6 +54,18 @@ const (
|
||||
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
|
||||
}
|
||||
|
||||
// Auth middleware
|
||||
func Auth(jwtManager *jwt.Manager, db *database.DB) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
|
||||
190
go_cloud/internal/storage/webdav.go
Normal 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))
|
||||
}
|
||||
17
go_cloud/migrations/0003_files.sql
Normal 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);
|
||||
46
go_cloud/migrations/run-migrations.sh
Normal 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! ==="
|
||||