Compare commits
46 Commits
dev
...
cadf504643
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cadf504643 | ||
|
|
1ceb27dea8 | ||
|
|
11436e93c5 | ||
|
|
7f6fe23219 | ||
|
|
c8cd82edb4 | ||
|
|
ff370af5a1 | ||
|
|
ca39b3dee4 | ||
|
|
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 'package:bloc/bloc.dart';
|
||||||
import '../session/session_bloc.dart';
|
import '../session/session_bloc.dart';
|
||||||
import '../session/session_event.dart';
|
import '../session/session_event.dart';
|
||||||
|
import '../session/session_state.dart';
|
||||||
import 'auth_event.dart';
|
import 'auth_event.dart';
|
||||||
import 'auth_state.dart';
|
import 'auth_state.dart';
|
||||||
import '../../services/api_client.dart';
|
import '../../services/api_client.dart';
|
||||||
@@ -252,7 +253,22 @@ class AuthBloc extends Bloc<AuthEvent, AuthState> {
|
|||||||
CheckAuthRequested event,
|
CheckAuthRequested event,
|
||||||
Emitter<AuthState> emit,
|
Emitter<AuthState> emit,
|
||||||
) async {
|
) async {
|
||||||
// Check if token is valid in SessionBloc
|
// Check if session is active from persistent storage
|
||||||
emit(AuthUnauthenticated());
|
final sessionState = sessionBloc.state;
|
||||||
|
|
||||||
|
if (sessionState is SessionActive) {
|
||||||
|
// Session already active - emit authenticated state with minimal info
|
||||||
|
// The full user info will be fetched when needed
|
||||||
|
emit(
|
||||||
|
AuthAuthenticated(
|
||||||
|
token: sessionState.token,
|
||||||
|
userId: '',
|
||||||
|
username: '',
|
||||||
|
email: '',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
emit(AuthUnauthenticated());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -123,24 +123,8 @@ class FileBrowserBloc extends Bloc<FileBrowserEvent, FileBrowserState> {
|
|||||||
event.parentPath,
|
event.parentPath,
|
||||||
event.folderName,
|
event.folderName,
|
||||||
);
|
);
|
||||||
// Add the new folder to local state if in current directory
|
// Reload directory to get the folder with proper ID from backend
|
||||||
if (event.parentPath == _currentPath) {
|
add(LoadDirectory(orgId: event.orgId, path: event.parentPath));
|
||||||
final newFolder = FileItem(
|
|
||||||
name: event.folderName,
|
|
||||||
path: '${event.parentPath}/${event.folderName}',
|
|
||||||
type: FileType.folder,
|
|
||||||
size: 0,
|
|
||||||
lastModified: DateTime.now(),
|
|
||||||
);
|
|
||||||
_currentFiles.add(newFolder);
|
|
||||||
_currentFiles = _sortFiles(_currentFiles, _sortBy, _isAscending);
|
|
||||||
_filteredFiles = _currentFiles
|
|
||||||
.where((f) => f.name.toLowerCase().contains(_currentFilter))
|
|
||||||
.toList();
|
|
||||||
_emitLoadedState(emit);
|
|
||||||
} else {
|
|
||||||
add(LoadDirectory(orgId: event.orgId, path: event.parentPath));
|
|
||||||
}
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
emit(DirectoryError(_getErrorMessage(e)));
|
emit(DirectoryError(_getErrorMessage(e)));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,11 +16,12 @@ class PermissionBloc extends Bloc<PermissionEvent, PermissionState> {
|
|||||||
// Simulate loading permissions from backend for orgId
|
// Simulate loading permissions from backend for orgId
|
||||||
await Future.delayed(const Duration(seconds: 1));
|
await Future.delayed(const Duration(seconds: 1));
|
||||||
// Mock capabilities based on orgId
|
// Mock capabilities based on orgId
|
||||||
|
// Allow all permissions for authenticated users (proper permissions should come from backend)
|
||||||
final capabilities = Capabilities(
|
final capabilities = Capabilities(
|
||||||
canRead: true,
|
canRead: true,
|
||||||
canWrite: event.orgId == 'org1', // Only admin for personal
|
canWrite: true,
|
||||||
canShare: event.orgId == 'org1',
|
canShare: true,
|
||||||
canAdmin: event.orgId == 'org1',
|
canAdmin: true,
|
||||||
canAnnotate: true,
|
canAnnotate: true,
|
||||||
canEdit: true,
|
canEdit: true,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,42 +1,104 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'package:bloc/bloc.dart';
|
import 'package:bloc/bloc.dart';
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
import 'session_event.dart';
|
import 'session_event.dart';
|
||||||
import 'session_state.dart';
|
import 'session_state.dart';
|
||||||
|
|
||||||
class SessionBloc extends Bloc<SessionEvent, SessionState> {
|
class SessionBloc extends Bloc<SessionEvent, SessionState> {
|
||||||
Timer? _expiryTimer;
|
Timer? _expiryTimer;
|
||||||
|
static const String _tokenKey = 'auth_token';
|
||||||
|
static const String _expiryKey = 'auth_expiry';
|
||||||
|
|
||||||
SessionBloc() : super(SessionInitial()) {
|
SessionBloc() : super(SessionInitial()) {
|
||||||
on<SessionStarted>(_onSessionStarted);
|
on<SessionStarted>(_onSessionStarted);
|
||||||
on<SessionExpired>(_onSessionExpired);
|
on<SessionExpired>(_onSessionExpired);
|
||||||
on<SessionRefreshed>(_onSessionRefreshed);
|
on<SessionRefreshed>(_onSessionRefreshed);
|
||||||
on<SessionEnded>(_onSessionEnded);
|
on<SessionEnded>(_onSessionEnded);
|
||||||
|
on<SessionRestored>(_onSessionRestored);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _onSessionStarted(SessionStarted event, Emitter<SessionState> emit) {
|
void _onSessionStarted(
|
||||||
|
SessionStarted event,
|
||||||
|
Emitter<SessionState> emit,
|
||||||
|
) async {
|
||||||
final expiresAt = DateTime.now().add(
|
final expiresAt = DateTime.now().add(
|
||||||
const Duration(minutes: 15),
|
const Duration(minutes: 15),
|
||||||
); // Match Go
|
); // Match Go
|
||||||
|
|
||||||
|
// Save token to persistent storage
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.setString(_tokenKey, event.token);
|
||||||
|
await prefs.setString(_expiryKey, expiresAt.toIso8601String());
|
||||||
|
|
||||||
emit(SessionActive(token: event.token, expiresAt: expiresAt));
|
emit(SessionActive(token: event.token, expiresAt: expiresAt));
|
||||||
_startExpiryTimer(expiresAt);
|
_startExpiryTimer(expiresAt);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _onSessionExpired(SessionExpired event, Emitter<SessionState> emit) {
|
void _onSessionExpired(SessionExpired event, Emitter<SessionState> emit) {
|
||||||
_expiryTimer?.cancel();
|
_expiryTimer?.cancel();
|
||||||
|
_clearStoredSession();
|
||||||
emit(SessionExpiredState());
|
emit(SessionExpiredState());
|
||||||
}
|
}
|
||||||
|
|
||||||
void _onSessionRefreshed(SessionRefreshed event, Emitter<SessionState> emit) {
|
void _onSessionRefreshed(
|
||||||
|
SessionRefreshed event,
|
||||||
|
Emitter<SessionState> emit,
|
||||||
|
) async {
|
||||||
final expiresAt = DateTime.now().add(const Duration(minutes: 15));
|
final expiresAt = DateTime.now().add(const Duration(minutes: 15));
|
||||||
|
|
||||||
|
// Update stored token
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.setString(_tokenKey, event.newToken);
|
||||||
|
await prefs.setString(_expiryKey, expiresAt.toIso8601String());
|
||||||
|
|
||||||
emit(SessionActive(token: event.newToken, expiresAt: expiresAt));
|
emit(SessionActive(token: event.newToken, expiresAt: expiresAt));
|
||||||
_startExpiryTimer(expiresAt);
|
_startExpiryTimer(expiresAt);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _onSessionEnded(SessionEnded event, Emitter<SessionState> emit) {
|
void _onSessionEnded(SessionEnded event, Emitter<SessionState> emit) {
|
||||||
_expiryTimer?.cancel();
|
_expiryTimer?.cancel();
|
||||||
|
_clearStoredSession();
|
||||||
emit(SessionInitial());
|
emit(SessionInitial());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _onSessionRestored(SessionRestored event, Emitter<SessionState> emit) {
|
||||||
|
final expiresAt = event.expiresAt;
|
||||||
|
final now = DateTime.now();
|
||||||
|
|
||||||
|
// Check if token is still valid
|
||||||
|
if (expiresAt.isAfter(now)) {
|
||||||
|
emit(SessionActive(token: event.token, expiresAt: expiresAt));
|
||||||
|
_startExpiryTimer(expiresAt);
|
||||||
|
} else {
|
||||||
|
// Token expired, clear it
|
||||||
|
_clearStoredSession();
|
||||||
|
emit(SessionInitial());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _clearStoredSession() async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.remove(_tokenKey);
|
||||||
|
await prefs.remove(_expiryKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<void> restoreSession(SessionBloc bloc) async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
final token = prefs.getString(_tokenKey);
|
||||||
|
final expiryStr = prefs.getString(_expiryKey);
|
||||||
|
|
||||||
|
if (token != null && expiryStr != null) {
|
||||||
|
try {
|
||||||
|
final expiresAt = DateTime.parse(expiryStr);
|
||||||
|
bloc.add(SessionRestored(token: token, expiresAt: expiresAt));
|
||||||
|
} catch (e) {
|
||||||
|
// Invalid stored data, clear it
|
||||||
|
await prefs.remove(_tokenKey);
|
||||||
|
await prefs.remove(_expiryKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
void _startExpiryTimer(DateTime expiresAt) {
|
void _startExpiryTimer(DateTime expiresAt) {
|
||||||
_expiryTimer?.cancel();
|
_expiryTimer?.cancel();
|
||||||
final duration = expiresAt.difference(DateTime.now());
|
final duration = expiresAt.difference(DateTime.now());
|
||||||
|
|||||||
@@ -28,3 +28,13 @@ class SessionRefreshed extends SessionEvent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class SessionEnded extends SessionEvent {}
|
class SessionEnded extends SessionEvent {}
|
||||||
|
|
||||||
|
class SessionRestored extends SessionEvent {
|
||||||
|
final String token;
|
||||||
|
final DateTime expiresAt;
|
||||||
|
|
||||||
|
const SessionRestored({required this.token, required this.expiresAt});
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<Object> get props => [token, expiresAt];
|
||||||
|
}
|
||||||
|
|||||||
@@ -24,10 +24,18 @@ class UploadBloc extends Bloc<UploadEvent, UploadState> {
|
|||||||
|
|
||||||
for (final file in event.files) {
|
for (final file in event.files) {
|
||||||
try {
|
try {
|
||||||
|
print(
|
||||||
|
'[UploadBloc] Starting upload for ${file.name} to orgId=${event.orgId}, path=${file.path}',
|
||||||
|
);
|
||||||
|
print(
|
||||||
|
'[UploadBloc] File bytes: ${file.bytes?.length ?? 0} bytes, localPath: ${file.localPath}',
|
||||||
|
);
|
||||||
// Simulate upload
|
// Simulate upload
|
||||||
await _fileRepository.uploadFile(event.orgId, file);
|
await _fileRepository.uploadFile(event.orgId, file);
|
||||||
|
print('[UploadBloc] Upload successful for ${file.name}');
|
||||||
add(UploadCompleted(file));
|
add(UploadCompleted(file));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
print('[UploadBloc] Upload failed for ${file.name}: $e');
|
||||||
add(UploadFailed(fileName: file.name, error: e.toString()));
|
add(UploadFailed(fileName: file.name, error: e.toString()));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,24 +1,33 @@
|
|||||||
import 'package:b0esche_cloud/services/api_client.dart';
|
import 'package:b0esche_cloud/services/api_client.dart';
|
||||||
import 'package:get_it/get_it.dart';
|
import 'package:get_it/get_it.dart';
|
||||||
|
import 'blocs/session/session_bloc.dart';
|
||||||
import 'repositories/auth_repository.dart';
|
import 'repositories/auth_repository.dart';
|
||||||
import 'repositories/file_repository.dart';
|
import 'repositories/file_repository.dart';
|
||||||
import 'repositories/mock_auth_repository.dart';
|
import 'repositories/http_auth_repository.dart';
|
||||||
import 'repositories/mock_file_repository.dart';
|
import 'repositories/http_file_repository.dart';
|
||||||
import 'services/auth_service.dart';
|
import 'services/auth_service.dart';
|
||||||
import 'services/file_service.dart';
|
import 'services/file_service.dart';
|
||||||
|
import 'services/org_api.dart';
|
||||||
import 'viewmodels/login_view_model.dart';
|
import 'viewmodels/login_view_model.dart';
|
||||||
import 'viewmodels/file_explorer_view_model.dart';
|
import 'viewmodels/file_explorer_view_model.dart';
|
||||||
|
|
||||||
final getIt = GetIt.instance;
|
final getIt = GetIt.instance;
|
||||||
|
|
||||||
void configureDependencies() {
|
void configureDependencies(SessionBloc sessionBloc) {
|
||||||
// Register repositories
|
// Register ApiClient first
|
||||||
getIt.registerSingleton<AuthRepository>(MockAuthRepository());
|
final apiClient = ApiClient(sessionBloc);
|
||||||
getIt.registerSingleton<FileRepository>(MockFileRepository());
|
getIt.registerSingleton<ApiClient>(apiClient);
|
||||||
|
|
||||||
|
// Register repositories (HTTP-backed)
|
||||||
|
getIt.registerSingleton<AuthRepository>(HttpAuthRepository(apiClient));
|
||||||
|
getIt.registerSingleton<FileRepository>(
|
||||||
|
HttpFileRepository(FileService(apiClient)),
|
||||||
|
);
|
||||||
|
|
||||||
// Register services
|
// Register services
|
||||||
getIt.registerSingleton<AuthService>(AuthService(getIt<AuthRepository>()));
|
getIt.registerSingleton<AuthService>(AuthService(getIt<AuthRepository>()));
|
||||||
getIt.registerSingleton<FileService>(FileService(getIt<ApiClient>()));
|
getIt.registerSingleton<FileService>(FileService(getIt<ApiClient>()));
|
||||||
|
getIt.registerSingleton<OrgApi>(OrgApi(getIt<ApiClient>()));
|
||||||
|
|
||||||
// Register viewmodels
|
// Register viewmodels
|
||||||
getIt.registerSingleton<LoginViewModel>(LoginViewModel(getIt<AuthService>()));
|
getIt.registerSingleton<LoginViewModel>(LoginViewModel(getIt<AuthService>()));
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import 'pages/file_explorer.dart';
|
|||||||
import 'pages/document_viewer.dart';
|
import 'pages/document_viewer.dart';
|
||||||
import 'pages/editor_page.dart';
|
import 'pages/editor_page.dart';
|
||||||
import 'theme/app_theme.dart';
|
import 'theme/app_theme.dart';
|
||||||
|
import 'injection.dart';
|
||||||
|
|
||||||
final GoRouter _router = GoRouter(
|
final GoRouter _router = GoRouter(
|
||||||
routes: [
|
routes: [
|
||||||
@@ -41,29 +42,67 @@ void main() {
|
|||||||
runApp(const MainApp());
|
runApp(const MainApp());
|
||||||
}
|
}
|
||||||
|
|
||||||
class MainApp extends StatelessWidget {
|
class MainApp extends StatefulWidget {
|
||||||
const MainApp({super.key});
|
const MainApp({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<MainApp> createState() => _MainAppState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _MainAppState extends State<MainApp> {
|
||||||
|
final _sessionBloc = SessionBloc();
|
||||||
|
late final AuthBloc _authBloc;
|
||||||
|
late final Future<void> _restoreFuture;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
// Restore session from persistent storage early so ApiClient has token if present
|
||||||
|
_restoreFuture = SessionBloc.restoreSession(_sessionBloc);
|
||||||
|
// Configure DI to use HTTP repositories
|
||||||
|
configureDependencies(_sessionBloc);
|
||||||
|
|
||||||
|
_authBloc = AuthBloc(
|
||||||
|
apiClient: ApiClient(_sessionBloc),
|
||||||
|
sessionBloc: _sessionBloc,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return MultiBlocProvider(
|
return MultiBlocProvider(
|
||||||
providers: [
|
providers: [
|
||||||
BlocProvider<SessionBloc>(create: (_) => SessionBloc()),
|
BlocProvider<SessionBloc>.value(value: _sessionBloc),
|
||||||
BlocProvider<AuthBloc>(
|
BlocProvider<AuthBloc>.value(value: _authBloc),
|
||||||
create: (context) => AuthBloc(
|
|
||||||
apiClient: ApiClient(context.read<SessionBloc>()),
|
|
||||||
sessionBloc: context.read<SessionBloc>(),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
BlocProvider<ActivityBloc>(
|
BlocProvider<ActivityBloc>(
|
||||||
create: (context) =>
|
create: (context) =>
|
||||||
ActivityBloc(ActivityApi(ApiClient(context.read<SessionBloc>()))),
|
ActivityBloc(ActivityApi(ApiClient(_sessionBloc))),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
child: MaterialApp.router(
|
child: FutureBuilder<void>(
|
||||||
routerConfig: _router,
|
future: _restoreFuture,
|
||||||
theme: AppTheme.darkTheme,
|
builder: (context, snapshot) {
|
||||||
|
if (snapshot.connectionState != ConnectionState.done) {
|
||||||
|
return MaterialApp(
|
||||||
|
theme: AppTheme.darkTheme,
|
||||||
|
home: const Scaffold(
|
||||||
|
body: Center(child: CircularProgressIndicator()),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return MaterialApp.router(
|
||||||
|
routerConfig: _router,
|
||||||
|
theme: AppTheme.darkTheme,
|
||||||
|
);
|
||||||
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_authBloc.close();
|
||||||
|
_sessionBloc.close();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,26 +1,34 @@
|
|||||||
import 'package:equatable/equatable.dart';
|
import 'package:equatable/equatable.dart';
|
||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
enum FileType { folder, file }
|
enum FileType { folder, file }
|
||||||
|
|
||||||
class FileItem extends Equatable {
|
class FileItem extends Equatable {
|
||||||
|
final String? id;
|
||||||
final String name;
|
final String name;
|
||||||
final String path;
|
final String path;
|
||||||
final FileType type;
|
final FileType type;
|
||||||
final int size; // in bytes, 0 for folders
|
final int size; // in bytes, 0 for folders
|
||||||
final DateTime lastModified;
|
final DateTime lastModified;
|
||||||
|
final String? localPath; // optional local file path for uploads
|
||||||
|
final Uint8List? bytes; // optional file bytes for web/desktop uploads
|
||||||
|
|
||||||
const FileItem({
|
const FileItem({
|
||||||
|
this.id,
|
||||||
required this.name,
|
required this.name,
|
||||||
required this.path,
|
required this.path,
|
||||||
required this.type,
|
required this.type,
|
||||||
this.size = 0,
|
this.size = 0,
|
||||||
required this.lastModified,
|
required this.lastModified,
|
||||||
|
this.localPath,
|
||||||
|
this.bytes,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
List<Object?> get props => [name, path, type, size, lastModified];
|
List<Object?> get props => [id, name, path, type, size, lastModified];
|
||||||
|
|
||||||
FileItem copyWith({
|
FileItem copyWith({
|
||||||
|
String? id,
|
||||||
String? name,
|
String? name,
|
||||||
String? path,
|
String? path,
|
||||||
FileType? type,
|
FileType? type,
|
||||||
@@ -28,6 +36,7 @@ class FileItem extends Equatable {
|
|||||||
DateTime? lastModified,
|
DateTime? lastModified,
|
||||||
}) {
|
}) {
|
||||||
return FileItem(
|
return FileItem(
|
||||||
|
id: id ?? this.id,
|
||||||
name: name ?? this.name,
|
name: name ?? this.name,
|
||||||
path: path ?? this.path,
|
path: path ?? this.path,
|
||||||
type: type ?? this.type,
|
type: type ?? this.type,
|
||||||
|
|||||||
@@ -9,6 +9,200 @@ import '../injection.dart';
|
|||||||
import 'package:syncfusion_flutter_pdfviewer/pdfviewer.dart';
|
import 'package:syncfusion_flutter_pdfviewer/pdfviewer.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
|
// Modal version for overlay display
|
||||||
|
class DocumentViewerModal extends StatefulWidget {
|
||||||
|
final String orgId;
|
||||||
|
final String fileId;
|
||||||
|
final VoidCallback onClose;
|
||||||
|
final VoidCallback onEdit;
|
||||||
|
|
||||||
|
const DocumentViewerModal({
|
||||||
|
super.key,
|
||||||
|
required this.orgId,
|
||||||
|
required this.fileId,
|
||||||
|
required this.onClose,
|
||||||
|
required this.onEdit,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<DocumentViewerModal> createState() => _DocumentViewerModalState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _DocumentViewerModalState extends State<DocumentViewerModal> {
|
||||||
|
late DocumentViewerBloc _viewerBloc;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_viewerBloc = DocumentViewerBloc(getIt<FileService>());
|
||||||
|
_viewerBloc.add(DocumentOpened(orgId: widget.orgId, fileId: widget.fileId));
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return BlocProvider.value(
|
||||||
|
value: _viewerBloc,
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
// Custom AppBar
|
||||||
|
Container(
|
||||||
|
height: 56,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppTheme.primaryBackground.withValues(alpha: 0.9),
|
||||||
|
border: Border(
|
||||||
|
bottom: BorderSide(
|
||||||
|
color: AppTheme.accentColor.withValues(alpha: 0.3),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
const SizedBox(width: 16),
|
||||||
|
const Text(
|
||||||
|
'Document Viewer',
|
||||||
|
style: TextStyle(
|
||||||
|
color: AppTheme.primaryText,
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Spacer(),
|
||||||
|
BlocBuilder<DocumentViewerBloc, DocumentViewerState>(
|
||||||
|
builder: (context, state) {
|
||||||
|
if (state is DocumentViewerReady && state.caps.canEdit) {
|
||||||
|
return IconButton(
|
||||||
|
icon: const Icon(
|
||||||
|
Icons.edit,
|
||||||
|
color: AppTheme.primaryText,
|
||||||
|
),
|
||||||
|
onPressed: widget.onEdit,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return const SizedBox.shrink();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.refresh, color: AppTheme.primaryText),
|
||||||
|
splashColor: Colors.transparent,
|
||||||
|
highlightColor: Colors.transparent,
|
||||||
|
onPressed: () {
|
||||||
|
_viewerBloc.add(DocumentReloaded());
|
||||||
|
},
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.close, color: AppTheme.primaryText),
|
||||||
|
splashColor: Colors.transparent,
|
||||||
|
highlightColor: Colors.transparent,
|
||||||
|
onPressed: () {
|
||||||
|
_viewerBloc.add(DocumentClosed());
|
||||||
|
widget.onClose();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// Meta info bar
|
||||||
|
BlocBuilder<DocumentViewerBloc, DocumentViewerState>(
|
||||||
|
builder: (context, state) {
|
||||||
|
if (state is DocumentViewerReady) {
|
||||||
|
return Container(
|
||||||
|
height: 30,
|
||||||
|
alignment: Alignment.centerLeft,
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppTheme.primaryBackground.withValues(alpha: 0.3),
|
||||||
|
),
|
||||||
|
child: const Text(
|
||||||
|
'Last modified: Unknown by Unknown (v1)',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
color: AppTheme.secondaryText,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return const SizedBox.shrink();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
// Document content
|
||||||
|
Expanded(
|
||||||
|
child: BlocBuilder<DocumentViewerBloc, DocumentViewerState>(
|
||||||
|
builder: (context, state) {
|
||||||
|
if (state is DocumentViewerLoading) {
|
||||||
|
return const Center(child: CircularProgressIndicator());
|
||||||
|
}
|
||||||
|
if (state is DocumentViewerError) {
|
||||||
|
return Center(
|
||||||
|
child: Text(
|
||||||
|
'Error: ${state.message}',
|
||||||
|
style: const TextStyle(color: AppTheme.primaryText),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (state is DocumentViewerSessionExpired) {
|
||||||
|
return Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
const Text(
|
||||||
|
'Your viewing session expired. Click to reopen.',
|
||||||
|
style: TextStyle(color: AppTheme.primaryText),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
ElevatedButton(
|
||||||
|
onPressed: () {
|
||||||
|
_viewerBloc.add(
|
||||||
|
DocumentOpened(
|
||||||
|
orgId: widget.orgId,
|
||||||
|
fileId: widget.fileId,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
child: const Text('Reload'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (state is DocumentViewerReady) {
|
||||||
|
if (state.caps.isPdf) {
|
||||||
|
return SfPdfViewer.network(state.viewUrl.toString());
|
||||||
|
} else {
|
||||||
|
return Container(
|
||||||
|
color: AppTheme.secondaryText,
|
||||||
|
child: Center(
|
||||||
|
child: Text(
|
||||||
|
'Office Document Viewer\\n(URL: ${state.viewUrl})',
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: const TextStyle(color: AppTheme.primaryText),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return const Center(
|
||||||
|
child: Text(
|
||||||
|
'No document loaded',
|
||||||
|
style: TextStyle(color: AppTheme.primaryText),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_viewerBloc.close();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Original page version (for routing if needed)
|
||||||
class DocumentViewer extends StatefulWidget {
|
class DocumentViewer extends StatefulWidget {
|
||||||
final String orgId;
|
final String orgId;
|
||||||
final String fileId;
|
final String fileId;
|
||||||
|
|||||||
@@ -8,6 +8,161 @@ import '../services/file_service.dart';
|
|||||||
import '../injection.dart';
|
import '../injection.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
|
// Modal version for overlay display
|
||||||
|
class EditorPageModal extends StatefulWidget {
|
||||||
|
final String orgId;
|
||||||
|
final String fileId;
|
||||||
|
final VoidCallback onClose;
|
||||||
|
|
||||||
|
const EditorPageModal({
|
||||||
|
super.key,
|
||||||
|
required this.orgId,
|
||||||
|
required this.fileId,
|
||||||
|
required this.onClose,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<EditorPageModal> createState() => _EditorPageModalState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _EditorPageModalState extends State<EditorPageModal> {
|
||||||
|
late EditorSessionBloc _editorBloc;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_editorBloc = EditorSessionBloc(getIt<FileService>());
|
||||||
|
_editorBloc.add(
|
||||||
|
EditorSessionStarted(orgId: widget.orgId, fileId: widget.fileId),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return BlocProvider.value(
|
||||||
|
value: _editorBloc,
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
// Custom AppBar
|
||||||
|
Container(
|
||||||
|
height: 56,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppTheme.primaryBackground.withValues(alpha: 0.9),
|
||||||
|
border: Border(
|
||||||
|
bottom: BorderSide(
|
||||||
|
color: AppTheme.accentColor.withValues(alpha: 0.3),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
const SizedBox(width: 16),
|
||||||
|
const Text(
|
||||||
|
'Document Editor',
|
||||||
|
style: TextStyle(
|
||||||
|
color: AppTheme.primaryText,
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Spacer(),
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.close, color: AppTheme.primaryText),
|
||||||
|
onPressed: () {
|
||||||
|
_editorBloc.add(EditorSessionEnded());
|
||||||
|
widget.onClose();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// Editor content
|
||||||
|
Expanded(
|
||||||
|
child: BlocBuilder<EditorSessionBloc, EditorSessionState>(
|
||||||
|
builder: (context, state) {
|
||||||
|
if (state is EditorSessionStarting) {
|
||||||
|
return const Center(child: CircularProgressIndicator());
|
||||||
|
}
|
||||||
|
if (state is EditorSessionFailed) {
|
||||||
|
return Center(
|
||||||
|
child: Text(
|
||||||
|
'Error: ${state.message}',
|
||||||
|
style: const TextStyle(color: AppTheme.primaryText),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (state is EditorSessionActive) {
|
||||||
|
return Container(
|
||||||
|
color: AppTheme.secondaryText,
|
||||||
|
child: Center(
|
||||||
|
child: Text(
|
||||||
|
'Collabora Editor Active\\n(URL: ${state.editUrl})',
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: const TextStyle(color: AppTheme.primaryText),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (state is EditorSessionReadOnly) {
|
||||||
|
return Container(
|
||||||
|
color: AppTheme.secondaryText,
|
||||||
|
child: Center(
|
||||||
|
child: Text(
|
||||||
|
'Read Only Mode\\n(URL: ${state.viewUrl})',
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: const TextStyle(color: AppTheme.primaryText),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (state is EditorSessionExpired) {
|
||||||
|
return Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
const Text(
|
||||||
|
'Editing session expired.',
|
||||||
|
style: TextStyle(color: AppTheme.primaryText),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
ElevatedButton(
|
||||||
|
onPressed: () {
|
||||||
|
_editorBloc.add(
|
||||||
|
EditorSessionStarted(
|
||||||
|
orgId: widget.orgId,
|
||||||
|
fileId: widget.fileId,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
child: const Text('Reopen'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return const Center(
|
||||||
|
child: Text(
|
||||||
|
'No editor session',
|
||||||
|
style: TextStyle(color: AppTheme.primaryText),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_editorBloc.close();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Original page version (for routing if needed)
|
||||||
class EditorPage extends StatefulWidget {
|
class EditorPage extends StatefulWidget {
|
||||||
final String orgId;
|
final String orgId;
|
||||||
final String fileId;
|
final String fileId;
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
|
import 'dart:ui';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
import 'package:file_picker/file_picker.dart' hide FileType;
|
import 'package:file_picker/file_picker.dart' hide FileType;
|
||||||
import 'package:go_router/go_router.dart';
|
|
||||||
import 'package:path/path.dart' as p;
|
import 'package:path/path.dart' as p;
|
||||||
|
import 'dart:html' as html;
|
||||||
import '../blocs/file_browser/file_browser_bloc.dart';
|
import '../blocs/file_browser/file_browser_bloc.dart';
|
||||||
import '../blocs/file_browser/file_browser_event.dart';
|
import '../blocs/file_browser/file_browser_event.dart';
|
||||||
import '../blocs/file_browser/file_browser_state.dart';
|
import '../blocs/file_browser/file_browser_state.dart';
|
||||||
@@ -11,9 +12,14 @@ import '../blocs/permission/permission_event.dart';
|
|||||||
import '../blocs/permission/permission_state.dart';
|
import '../blocs/permission/permission_state.dart';
|
||||||
import '../blocs/upload/upload_bloc.dart';
|
import '../blocs/upload/upload_bloc.dart';
|
||||||
import '../blocs/upload/upload_event.dart';
|
import '../blocs/upload/upload_event.dart';
|
||||||
|
import '../blocs/upload/upload_state.dart';
|
||||||
import '../models/file_item.dart';
|
import '../models/file_item.dart';
|
||||||
import '../theme/app_theme.dart';
|
import '../theme/app_theme.dart';
|
||||||
import '../theme/modern_glass_button.dart';
|
import '../theme/modern_glass_button.dart';
|
||||||
|
import 'document_viewer.dart';
|
||||||
|
import 'editor_page.dart';
|
||||||
|
import '../injection.dart';
|
||||||
|
import '../services/file_service.dart';
|
||||||
|
|
||||||
class FileExplorer extends StatefulWidget {
|
class FileExplorer extends StatefulWidget {
|
||||||
final String orgId;
|
final String orgId;
|
||||||
@@ -229,7 +235,7 @@ class _FileExplorerState extends State<FileExplorer> {
|
|||||||
if (newName.isNotEmpty && newName != file.name) {
|
if (newName.isNotEmpty && newName != file.name) {
|
||||||
context.read<FileBrowserBloc>().add(
|
context.read<FileBrowserBloc>().add(
|
||||||
RenameFile(
|
RenameFile(
|
||||||
orgId: 'org1',
|
orgId: widget.orgId,
|
||||||
path: file.path,
|
path: file.path,
|
||||||
newName: newName,
|
newName: newName,
|
||||||
),
|
),
|
||||||
@@ -258,10 +264,34 @@ class _FileExplorerState extends State<FileExplorer> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _downloadFile(FileItem file) {
|
void _downloadFile(FileItem file) async {
|
||||||
ScaffoldMessenger.of(
|
try {
|
||||||
context,
|
final fileService = getIt<FileService>();
|
||||||
).showSnackBar(SnackBar(content: Text('Download ${file.name}')));
|
final downloadUrl = await fileService.getDownloadUrl(
|
||||||
|
widget.orgId,
|
||||||
|
file.path,
|
||||||
|
);
|
||||||
|
|
||||||
|
// For web, use the download URL with the ApiClient base URL (from DI)
|
||||||
|
final fullUrl = '${fileService.baseUrl}$downloadUrl';
|
||||||
|
|
||||||
|
// Trigger download via anchor element
|
||||||
|
html.AnchorElement(href: fullUrl)
|
||||||
|
..setAttribute('download', file.name)
|
||||||
|
..click();
|
||||||
|
|
||||||
|
if (context.mounted) {
|
||||||
|
ScaffoldMessenger.of(
|
||||||
|
context,
|
||||||
|
).showSnackBar(SnackBar(content: Text('Downloading ${file.name}')));
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (context.mounted) {
|
||||||
|
ScaffoldMessenger.of(
|
||||||
|
context,
|
||||||
|
).showSnackBar(SnackBar(content: Text('Download failed: $e')));
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void _sendFile(FileItem file) {
|
void _sendFile(FileItem file) {
|
||||||
@@ -331,7 +361,7 @@ class _FileExplorerState extends State<FileExplorer> {
|
|||||||
);
|
);
|
||||||
if (confirmed == true) {
|
if (confirmed == true) {
|
||||||
context.read<FileBrowserBloc>().add(
|
context.read<FileBrowserBloc>().add(
|
||||||
DeleteFile(orgId: 'org1', path: file.path),
|
DeleteFile(orgId: widget.orgId, path: file.path),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -530,10 +560,13 @@ class _FileExplorerState extends State<FileExplorer> {
|
|||||||
if (file.type == FileType.folder) {
|
if (file.type == FileType.folder) {
|
||||||
context.read<FileBrowserBloc>().add(NavigateToFolder(file.path));
|
context.read<FileBrowserBloc>().add(NavigateToFolder(file.path));
|
||||||
} else {
|
} else {
|
||||||
final fileId = file.path.startsWith('/')
|
if (file.id == null || file.id!.isEmpty) {
|
||||||
? file.path.substring(1)
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
: file.path;
|
const SnackBar(content: Text('Error: File ID is missing')),
|
||||||
context.go('/viewer/${widget.orgId}/$fileId');
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_showDocumentViewer(widget.orgId, file.id!);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
child: Container(
|
child: Container(
|
||||||
@@ -632,387 +665,519 @@ class _FileExplorerState extends State<FileExplorer> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return BlocBuilder<FileBrowserBloc, FileBrowserState>(
|
return BlocListener<UploadBloc, UploadState>(
|
||||||
builder: (context, state) {
|
listener: (context, uploadState) {
|
||||||
if (state is DirectoryLoading) {
|
if (uploadState is UploadInProgress) {
|
||||||
return Center(
|
// Show error if any upload failed
|
||||||
child: CircularProgressIndicator(color: AppTheme.accentColor),
|
for (final upload in uploadState.uploads) {
|
||||||
);
|
if (upload.error != null && upload.error!.isNotEmpty) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text('Upload failed: ${upload.error}')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
final hasCompleted = uploadState.uploads.any((u) => u.isCompleted);
|
||||||
|
if (hasCompleted) {
|
||||||
|
final fbState = context.read<FileBrowserBloc>().state;
|
||||||
|
String currentPath = '/';
|
||||||
|
if (fbState is DirectoryLoaded) currentPath = fbState.currentPath;
|
||||||
|
if (fbState is DirectoryEmpty) currentPath = fbState.currentPath;
|
||||||
|
context.read<FileBrowserBloc>().add(
|
||||||
|
LoadDirectory(orgId: widget.orgId, path: currentPath),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
child: BlocBuilder<FileBrowserBloc, FileBrowserState>(
|
||||||
|
builder: (context, state) {
|
||||||
|
if (state is DirectoryLoading) {
|
||||||
|
return Center(
|
||||||
|
child: CircularProgressIndicator(color: AppTheme.accentColor),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (state is DirectoryError) {
|
if (state is DirectoryError) {
|
||||||
return Center(
|
return Center(
|
||||||
child: Text(
|
child: Text(
|
||||||
'Error: ${state.error}',
|
'Error: ${state.error}',
|
||||||
style: const TextStyle(color: AppTheme.primaryText),
|
style: const TextStyle(color: AppTheme.primaryText),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (state is DirectoryEmpty) {
|
if (state is DirectoryEmpty) {
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.all(16.0),
|
padding: const EdgeInsets.all(16.0),
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Container(
|
Container(
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
border: Border(
|
border: Border(
|
||||||
bottom: BorderSide(
|
bottom: BorderSide(
|
||||||
color: AppTheme.primaryText.withValues(alpha: 0.5),
|
color: AppTheme.primaryText.withValues(alpha: 0.5),
|
||||||
width: 1,
|
width: 1,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
child: _buildTitle(),
|
||||||
),
|
),
|
||||||
child: _buildTitle(),
|
const SizedBox(height: 16),
|
||||||
),
|
BlocBuilder<PermissionBloc, PermissionState>(
|
||||||
const SizedBox(height: 16),
|
builder: (context, permState) {
|
||||||
BlocBuilder<PermissionBloc, PermissionState>(
|
if (permState is PermissionLoaded &&
|
||||||
builder: (context, permState) {
|
permState.capabilities.canWrite) {
|
||||||
if (permState is PermissionLoaded &&
|
return Row(
|
||||||
permState.capabilities.canWrite) {
|
children: [
|
||||||
return Row(
|
ModernGlassButton(
|
||||||
children: [
|
onPressed: () async {
|
||||||
ModernGlassButton(
|
final result = await FilePicker.platform
|
||||||
onPressed: () async {
|
.pickFiles(withData: true);
|
||||||
final result = await FilePicker.platform
|
if (result != null && result.files.isNotEmpty) {
|
||||||
.pickFiles();
|
final files = result.files
|
||||||
if (result != null && result.files.isNotEmpty) {
|
.map(
|
||||||
final files = result.files
|
(file) => FileItem(
|
||||||
.map(
|
name: file.name,
|
||||||
(file) => FileItem(
|
// Parent path only; server uses filename from multipart
|
||||||
name: file.name,
|
path: state.currentPath,
|
||||||
path: '/${file.name}',
|
type: FileType.file,
|
||||||
type: FileType.file,
|
size: file.size,
|
||||||
size: file.size,
|
lastModified: DateTime.now(),
|
||||||
lastModified: DateTime.now(),
|
localPath: file.path,
|
||||||
),
|
bytes: file.bytes,
|
||||||
)
|
),
|
||||||
.toList();
|
)
|
||||||
context.read<UploadBloc>().add(
|
.toList();
|
||||||
StartUpload(
|
context.read<UploadBloc>().add(
|
||||||
files: files,
|
StartUpload(
|
||||||
targetPath: '/',
|
files: files,
|
||||||
orgId: 'org1',
|
targetPath: state.currentPath,
|
||||||
),
|
orgId: widget.orgId,
|
||||||
);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
child: const Row(
|
|
||||||
children: [
|
|
||||||
Icon(Icons.upload),
|
|
||||||
SizedBox(width: 8),
|
|
||||||
Text('Upload File'),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 16),
|
|
||||||
ModernGlassButton(
|
|
||||||
onPressed: () async {
|
|
||||||
final folderName = await _showCreateFolderDialog(
|
|
||||||
context,
|
|
||||||
);
|
|
||||||
if (folderName != null && folderName.isNotEmpty) {
|
|
||||||
context.read<FileBrowserBloc>().add(
|
|
||||||
CreateFolder(
|
|
||||||
orgId: 'org1',
|
|
||||||
parentPath: '/',
|
|
||||||
folderName: folderName,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
child: const Row(
|
|
||||||
children: [
|
|
||||||
Icon(Icons.create_new_folder),
|
|
||||||
SizedBox(width: 8),
|
|
||||||
Text('New Folder'),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return const SizedBox.shrink();
|
|
||||||
},
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
IconButton(
|
|
||||||
icon: const Icon(
|
|
||||||
Icons.arrow_back,
|
|
||||||
color: AppTheme.primaryText,
|
|
||||||
),
|
|
||||||
splashColor: Colors.transparent,
|
|
||||||
highlightColor: Colors.transparent,
|
|
||||||
onPressed: () {
|
|
||||||
final parentPath = _getParentPath(state.currentPath);
|
|
||||||
context.read<FileBrowserBloc>().add(
|
|
||||||
LoadDirectory(orgId: 'org1', path: parentPath),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
const Text(
|
|
||||||
'Empty Folder',
|
|
||||||
style: TextStyle(color: AppTheme.primaryText),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (state is DirectoryLoaded) {
|
|
||||||
return Padding(
|
|
||||||
padding: const EdgeInsets.all(16.0),
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Container(
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
border: Border(
|
|
||||||
bottom: BorderSide(
|
|
||||||
color: AppTheme.primaryText.withValues(alpha: 0.5),
|
|
||||||
width: 1,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
child: _buildTitle(),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
Visibility(
|
|
||||||
visible: state.breadcrumbs.isNotEmpty,
|
|
||||||
child: Column(
|
|
||||||
children: [
|
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
IconButton(
|
|
||||||
icon: const Icon(
|
|
||||||
Icons.arrow_back,
|
|
||||||
color: AppTheme.primaryText,
|
|
||||||
),
|
|
||||||
onPressed: () {
|
|
||||||
final parentPath = _getParentPath(
|
|
||||||
state.currentPath,
|
|
||||||
);
|
|
||||||
context.read<FileBrowserBloc>().add(
|
|
||||||
LoadDirectory(orgId: 'org1', path: parentPath),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
Expanded(
|
|
||||||
child: SingleChildScrollView(
|
|
||||||
scrollDirection: Axis.horizontal,
|
|
||||||
child: Row(
|
|
||||||
children: state.breadcrumbs.map((breadcrumb) {
|
|
||||||
return TextButton(
|
|
||||||
onPressed: () {
|
|
||||||
context.read<FileBrowserBloc>().add(
|
|
||||||
NavigateToFolder(breadcrumb.path),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
child: Text(
|
|
||||||
'/${breadcrumb.name}',
|
|
||||||
style: const TextStyle(
|
|
||||||
color: AppTheme.secondaryText,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}).toList(),
|
}
|
||||||
|
},
|
||||||
|
child: const Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.upload),
|
||||||
|
SizedBox(width: 8),
|
||||||
|
Text('Upload File'),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
const SizedBox(width: 16),
|
||||||
],
|
ModernGlassButton(
|
||||||
),
|
onPressed: () async {
|
||||||
const SizedBox(height: 16),
|
final folderName =
|
||||||
],
|
await _showCreateFolderDialog(context);
|
||||||
),
|
if (folderName != null &&
|
||||||
),
|
folderName.isNotEmpty) {
|
||||||
BlocBuilder<PermissionBloc, PermissionState>(
|
context.read<FileBrowserBloc>().add(
|
||||||
builder: (context, permState) {
|
CreateFolder(
|
||||||
if (permState is PermissionLoaded &&
|
orgId: widget.orgId,
|
||||||
permState.capabilities.canWrite) {
|
parentPath: '/',
|
||||||
return Row(
|
folderName: folderName,
|
||||||
children: [
|
),
|
||||||
ModernGlassButton(
|
);
|
||||||
onPressed: () async {
|
}
|
||||||
final result = await FilePicker.platform
|
},
|
||||||
.pickFiles();
|
child: const Row(
|
||||||
if (result != null && result.files.isNotEmpty) {
|
children: [
|
||||||
final files = result.files
|
Icon(Icons.create_new_folder),
|
||||||
.map(
|
SizedBox(width: 8),
|
||||||
(file) => FileItem(
|
Text('New Folder'),
|
||||||
name: file.name,
|
],
|
||||||
path: '/${file.name}',
|
|
||||||
type: FileType.file,
|
|
||||||
size: file.size,
|
|
||||||
lastModified: DateTime.now(),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.toList();
|
|
||||||
context.read<UploadBloc>().add(
|
|
||||||
StartUpload(
|
|
||||||
files: files,
|
|
||||||
targetPath: '/',
|
|
||||||
orgId: 'org1',
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
child: const Row(
|
|
||||||
children: [
|
|
||||||
Icon(Icons.upload),
|
|
||||||
SizedBox(width: 8),
|
|
||||||
Text('Upload File'),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 16),
|
|
||||||
ModernGlassButton(
|
|
||||||
onPressed: () async {
|
|
||||||
final folderName = await _showCreateFolderDialog(
|
|
||||||
context,
|
|
||||||
);
|
|
||||||
if (folderName != null && folderName.isNotEmpty) {
|
|
||||||
context.read<FileBrowserBloc>().add(
|
|
||||||
CreateFolder(
|
|
||||||
orgId: 'org1',
|
|
||||||
parentPath: state.currentPath,
|
|
||||||
folderName: folderName,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
child: const Row(
|
|
||||||
children: [
|
|
||||||
Icon(Icons.create_new_folder),
|
|
||||||
SizedBox(width: 8),
|
|
||||||
Text('New Folder'),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return const SizedBox.shrink();
|
|
||||||
},
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
Expanded(
|
|
||||||
child: ListView.builder(
|
|
||||||
itemCount: state.paginatedFiles.length,
|
|
||||||
itemBuilder: (context, index) {
|
|
||||||
final file = state.paginatedFiles[index];
|
|
||||||
final isSelected = _selectedFilePath == file.path;
|
|
||||||
final isHovered = _hovered[file.path] ?? false;
|
|
||||||
|
|
||||||
if (file.type == FileType.folder) {
|
|
||||||
return DragTarget<FileItem>(
|
|
||||||
builder: (context, candidate, rejected) {
|
|
||||||
final isDraggedOver = candidate.isNotEmpty;
|
|
||||||
return Draggable<FileItem>(
|
|
||||||
data: file,
|
|
||||||
feedback: Opacity(
|
|
||||||
opacity: 0.8,
|
|
||||||
child: Icon(
|
|
||||||
Icons.folder,
|
|
||||||
color: AppTheme.primaryText,
|
|
||||||
size: 48,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
child: _buildFileItem(
|
|
||||||
file,
|
|
||||||
isSelected,
|
|
||||||
isHovered,
|
|
||||||
isDraggedOver,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
onAcceptWithDetails: (draggedFile) {
|
|
||||||
context.read<FileBrowserBloc>().add(
|
|
||||||
MoveFile(
|
|
||||||
orgId: 'org1',
|
|
||||||
sourcePath: draggedFile.data.path,
|
|
||||||
targetPath: file.path,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
return Draggable<FileItem>(
|
|
||||||
data: file,
|
|
||||||
feedback: Opacity(
|
|
||||||
opacity: 0.8,
|
|
||||||
child: Icon(
|
|
||||||
Icons.insert_drive_file,
|
|
||||||
color: AppTheme.primaryText,
|
|
||||||
size: 48,
|
|
||||||
),
|
),
|
||||||
),
|
],
|
||||||
child: _buildFileItem(
|
|
||||||
file,
|
|
||||||
isSelected,
|
|
||||||
isHovered,
|
|
||||||
false,
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
return const SizedBox.shrink();
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
),
|
|
||||||
if (state.totalPages > 1) ...[
|
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
Row(
|
Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
|
||||||
children: [
|
children: [
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(
|
icon: const Icon(
|
||||||
Icons.chevron_left,
|
Icons.arrow_back,
|
||||||
color: AppTheme.primaryText,
|
color: AppTheme.primaryText,
|
||||||
),
|
),
|
||||||
splashColor: Colors.transparent,
|
splashColor: Colors.transparent,
|
||||||
highlightColor: Colors.transparent,
|
highlightColor: Colors.transparent,
|
||||||
onPressed: state.currentPage > 1
|
onPressed: () {
|
||||||
? () {
|
final parentPath = _getParentPath(state.currentPath);
|
||||||
context.read<FileBrowserBloc>().add(
|
context.read<FileBrowserBloc>().add(
|
||||||
LoadPage(state.currentPage - 1),
|
LoadDirectory(
|
||||||
);
|
orgId: widget.orgId,
|
||||||
}
|
path: parentPath,
|
||||||
: null,
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
),
|
),
|
||||||
Text(
|
const Text(
|
||||||
'${state.currentPage} / ${state.totalPages}',
|
'Empty Folder',
|
||||||
style: const TextStyle(
|
style: TextStyle(color: AppTheme.primaryText),
|
||||||
color: AppTheme.primaryText,
|
|
||||||
fontSize: 16,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
IconButton(
|
|
||||||
icon: const Icon(
|
|
||||||
Icons.chevron_right,
|
|
||||||
color: AppTheme.primaryText,
|
|
||||||
),
|
|
||||||
splashColor: Colors.transparent,
|
|
||||||
highlightColor: Colors.transparent,
|
|
||||||
onPressed: state.currentPage < state.totalPages
|
|
||||||
? () {
|
|
||||||
context.read<FileBrowserBloc>().add(
|
|
||||||
LoadPage(state.currentPage + 1),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
: null,
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
],
|
),
|
||||||
),
|
);
|
||||||
);
|
}
|
||||||
}
|
|
||||||
|
|
||||||
return const SizedBox.shrink();
|
if (state is DirectoryLoaded) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.all(16.0),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
border: Border(
|
||||||
|
bottom: BorderSide(
|
||||||
|
color: AppTheme.primaryText.withValues(alpha: 0.5),
|
||||||
|
width: 1,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: _buildTitle(),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Visibility(
|
||||||
|
visible: state.breadcrumbs.isNotEmpty,
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(
|
||||||
|
Icons.arrow_back,
|
||||||
|
color: AppTheme.primaryText,
|
||||||
|
),
|
||||||
|
onPressed: () {
|
||||||
|
final parentPath = _getParentPath(
|
||||||
|
state.currentPath,
|
||||||
|
);
|
||||||
|
context.read<FileBrowserBloc>().add(
|
||||||
|
LoadDirectory(
|
||||||
|
orgId: widget.orgId,
|
||||||
|
path: parentPath,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
scrollDirection: Axis.horizontal,
|
||||||
|
child: Row(
|
||||||
|
children: state.breadcrumbs.map((breadcrumb) {
|
||||||
|
return TextButton(
|
||||||
|
onPressed: () {
|
||||||
|
context.read<FileBrowserBloc>().add(
|
||||||
|
NavigateToFolder(breadcrumb.path),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
child: Text(
|
||||||
|
'/${breadcrumb.name}',
|
||||||
|
style: const TextStyle(
|
||||||
|
color: AppTheme.secondaryText,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}).toList(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
BlocBuilder<PermissionBloc, PermissionState>(
|
||||||
|
builder: (context, permState) {
|
||||||
|
if (permState is PermissionLoaded &&
|
||||||
|
permState.capabilities.canWrite) {
|
||||||
|
return Row(
|
||||||
|
children: [
|
||||||
|
ModernGlassButton(
|
||||||
|
onPressed: () async {
|
||||||
|
final result = await FilePicker.platform
|
||||||
|
.pickFiles(withData: true);
|
||||||
|
if (result != null && result.files.isNotEmpty) {
|
||||||
|
final files = result.files
|
||||||
|
.map(
|
||||||
|
(file) => FileItem(
|
||||||
|
name: file.name,
|
||||||
|
// Parent path only; server uses filename from multipart
|
||||||
|
path: state.currentPath,
|
||||||
|
type: FileType.file,
|
||||||
|
size: file.size,
|
||||||
|
lastModified: DateTime.now(),
|
||||||
|
localPath: file.path,
|
||||||
|
bytes: file.bytes,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.toList();
|
||||||
|
context.read<UploadBloc>().add(
|
||||||
|
StartUpload(
|
||||||
|
files: files,
|
||||||
|
targetPath: state.currentPath,
|
||||||
|
orgId: widget.orgId,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
child: const Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.upload),
|
||||||
|
SizedBox(width: 8),
|
||||||
|
Text('Upload File'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 16),
|
||||||
|
ModernGlassButton(
|
||||||
|
onPressed: () async {
|
||||||
|
final folderName =
|
||||||
|
await _showCreateFolderDialog(context);
|
||||||
|
if (folderName != null &&
|
||||||
|
folderName.isNotEmpty) {
|
||||||
|
context.read<FileBrowserBloc>().add(
|
||||||
|
CreateFolder(
|
||||||
|
orgId: widget.orgId,
|
||||||
|
parentPath: state.currentPath,
|
||||||
|
folderName: folderName,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
child: const Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.create_new_folder),
|
||||||
|
SizedBox(width: 8),
|
||||||
|
Text('New Folder'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return const SizedBox.shrink();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Expanded(
|
||||||
|
child: ListView.builder(
|
||||||
|
itemCount: state.paginatedFiles.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final file = state.paginatedFiles[index];
|
||||||
|
final isSelected = _selectedFilePath == file.path;
|
||||||
|
final isHovered = _hovered[file.path] ?? false;
|
||||||
|
|
||||||
|
if (file.type == FileType.folder) {
|
||||||
|
return DragTarget<FileItem>(
|
||||||
|
builder: (context, candidate, rejected) {
|
||||||
|
final isDraggedOver = candidate.isNotEmpty;
|
||||||
|
return Draggable<FileItem>(
|
||||||
|
data: file,
|
||||||
|
feedback: Opacity(
|
||||||
|
opacity: 0.8,
|
||||||
|
child: Icon(
|
||||||
|
Icons.folder,
|
||||||
|
color: AppTheme.primaryText,
|
||||||
|
size: 48,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: _buildFileItem(
|
||||||
|
file,
|
||||||
|
isSelected,
|
||||||
|
isHovered,
|
||||||
|
isDraggedOver,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
onAcceptWithDetails: (draggedFile) {
|
||||||
|
context.read<FileBrowserBloc>().add(
|
||||||
|
MoveFile(
|
||||||
|
orgId: widget.orgId,
|
||||||
|
sourcePath: draggedFile.data.path,
|
||||||
|
targetPath: file.path,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return Draggable<FileItem>(
|
||||||
|
data: file,
|
||||||
|
feedback: Opacity(
|
||||||
|
opacity: 0.8,
|
||||||
|
child: Icon(
|
||||||
|
Icons.insert_drive_file,
|
||||||
|
color: AppTheme.primaryText,
|
||||||
|
size: 48,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: _buildFileItem(
|
||||||
|
file,
|
||||||
|
isSelected,
|
||||||
|
isHovered,
|
||||||
|
false,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (state.totalPages > 1) ...[
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(
|
||||||
|
Icons.chevron_left,
|
||||||
|
color: AppTheme.primaryText,
|
||||||
|
),
|
||||||
|
splashColor: Colors.transparent,
|
||||||
|
highlightColor: Colors.transparent,
|
||||||
|
onPressed: state.currentPage > 1
|
||||||
|
? () {
|
||||||
|
context.read<FileBrowserBloc>().add(
|
||||||
|
LoadPage(state.currentPage - 1),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
'${state.currentPage} / ${state.totalPages}',
|
||||||
|
style: const TextStyle(
|
||||||
|
color: AppTheme.primaryText,
|
||||||
|
fontSize: 16,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(
|
||||||
|
Icons.chevron_right,
|
||||||
|
color: AppTheme.primaryText,
|
||||||
|
),
|
||||||
|
splashColor: Colors.transparent,
|
||||||
|
highlightColor: Colors.transparent,
|
||||||
|
onPressed: state.currentPage < state.totalPages
|
||||||
|
? () {
|
||||||
|
context.read<FileBrowserBloc>().add(
|
||||||
|
LoadPage(state.currentPage + 1),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return const SizedBox.shrink();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showDocumentViewer(String orgId, String fileId) {
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
barrierDismissible: false,
|
||||||
|
barrierColor: Colors.transparent,
|
||||||
|
builder: (BuildContext context) {
|
||||||
|
return BackdropFilter(
|
||||||
|
filter: ImageFilter.blur(sigmaX: 10, sigmaY: 10),
|
||||||
|
child: Dialog(
|
||||||
|
backgroundColor: Colors.transparent,
|
||||||
|
insetPadding: const EdgeInsets.all(16),
|
||||||
|
child: Container(
|
||||||
|
constraints: BoxConstraints(
|
||||||
|
maxWidth: MediaQuery.of(context).size.width * 0.9,
|
||||||
|
maxHeight: MediaQuery.of(context).size.height * 0.9,
|
||||||
|
),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppTheme.primaryBackground,
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
border: Border.all(
|
||||||
|
color: AppTheme.accentColor.withValues(alpha: 0.3),
|
||||||
|
width: 2,
|
||||||
|
),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.black.withValues(alpha: 0.5),
|
||||||
|
blurRadius: 20,
|
||||||
|
spreadRadius: 5,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(14),
|
||||||
|
child: DocumentViewerModal(
|
||||||
|
orgId: orgId,
|
||||||
|
fileId: fileId,
|
||||||
|
onEdit: () {
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
_showDocumentEditor(orgId, fileId);
|
||||||
|
},
|
||||||
|
onClose: () => Navigator.of(context).pop(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showDocumentEditor(String orgId, String fileId) {
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
barrierDismissible: false,
|
||||||
|
barrierColor: Colors.transparent,
|
||||||
|
builder: (BuildContext context) {
|
||||||
|
return BackdropFilter(
|
||||||
|
filter: ImageFilter.blur(sigmaX: 10, sigmaY: 10),
|
||||||
|
child: Dialog(
|
||||||
|
backgroundColor: Colors.transparent,
|
||||||
|
insetPadding: const EdgeInsets.all(16),
|
||||||
|
child: Container(
|
||||||
|
constraints: BoxConstraints(
|
||||||
|
maxWidth: MediaQuery.of(context).size.width * 0.9,
|
||||||
|
maxHeight: MediaQuery.of(context).size.height * 0.9,
|
||||||
|
),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppTheme.primaryBackground,
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
border: Border.all(
|
||||||
|
color: AppTheme.accentColor.withValues(alpha: 0.3),
|
||||||
|
width: 2,
|
||||||
|
),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.black.withValues(alpha: 0.5),
|
||||||
|
blurRadius: 20,
|
||||||
|
spreadRadius: 5,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(14),
|
||||||
|
child: EditorPageModal(
|
||||||
|
orgId: orgId,
|
||||||
|
fileId: fileId,
|
||||||
|
onClose: () => Navigator.of(context).pop(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,10 +8,16 @@ import '../blocs/organization/organization_bloc.dart';
|
|||||||
import '../blocs/organization/organization_event.dart';
|
import '../blocs/organization/organization_event.dart';
|
||||||
import '../blocs/file_browser/file_browser_bloc.dart';
|
import '../blocs/file_browser/file_browser_bloc.dart';
|
||||||
import '../blocs/file_browser/file_browser_event.dart';
|
import '../blocs/file_browser/file_browser_event.dart';
|
||||||
|
import '../blocs/permission/permission_bloc.dart';
|
||||||
|
import '../blocs/upload/upload_bloc.dart';
|
||||||
|
import '../repositories/file_repository.dart';
|
||||||
|
import '../services/file_service.dart';
|
||||||
|
import '../services/org_api.dart';
|
||||||
import '../theme/app_theme.dart';
|
import '../theme/app_theme.dart';
|
||||||
import '../theme/modern_glass_button.dart';
|
import '../theme/modern_glass_button.dart';
|
||||||
import 'login_form.dart' show LoginForm;
|
import 'login_form.dart' show LoginForm;
|
||||||
import 'file_explorer.dart';
|
import 'file_explorer.dart';
|
||||||
|
import '../injection.dart';
|
||||||
|
|
||||||
class HomePage extends StatefulWidget {
|
class HomePage extends StatefulWidget {
|
||||||
const HomePage({super.key});
|
const HomePage({super.key});
|
||||||
@@ -24,6 +30,13 @@ class _HomePageState extends State<HomePage> with TickerProviderStateMixin {
|
|||||||
late String _selectedTab = 'Drive';
|
late String _selectedTab = 'Drive';
|
||||||
late AnimationController _animationController;
|
late AnimationController _animationController;
|
||||||
bool _isSignupMode = false;
|
bool _isSignupMode = false;
|
||||||
|
bool _usePasswordMode = false;
|
||||||
|
|
||||||
|
// Shared blocs for the page lifecycle
|
||||||
|
late final PermissionBloc _permissionBloc;
|
||||||
|
late final FileBrowserBloc _fileBrowserBloc;
|
||||||
|
late final UploadBloc _uploadBloc;
|
||||||
|
late final OrganizationBloc _organizationBloc;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@@ -32,11 +45,25 @@ class _HomePageState extends State<HomePage> with TickerProviderStateMixin {
|
|||||||
duration: const Duration(milliseconds: 400),
|
duration: const Duration(milliseconds: 400),
|
||||||
vsync: this,
|
vsync: this,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
_permissionBloc = PermissionBloc();
|
||||||
|
_fileBrowserBloc = FileBrowserBloc(getIt<FileService>());
|
||||||
|
_uploadBloc = UploadBloc(getIt<FileRepository>());
|
||||||
|
_organizationBloc = OrganizationBloc(
|
||||||
|
_permissionBloc,
|
||||||
|
_fileBrowserBloc,
|
||||||
|
_uploadBloc,
|
||||||
|
getIt<OrgApi>(),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_animationController.dispose();
|
_animationController.dispose();
|
||||||
|
_organizationBloc.close();
|
||||||
|
_uploadBloc.close();
|
||||||
|
_fileBrowserBloc.close();
|
||||||
|
_permissionBloc.close();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -50,6 +77,10 @@ class _HomePageState extends State<HomePage> with TickerProviderStateMixin {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _setPasswordMode(bool usePassword) {
|
||||||
|
setState(() => _usePasswordMode = usePassword);
|
||||||
|
}
|
||||||
|
|
||||||
void _showCreateOrgDialog(BuildContext context) {
|
void _showCreateOrgDialog(BuildContext context) {
|
||||||
final controller = TextEditingController();
|
final controller = TextEditingController();
|
||||||
showDialog(
|
showDialog(
|
||||||
@@ -132,37 +163,52 @@ class _HomePageState extends State<HomePage> with TickerProviderStateMixin {
|
|||||||
Widget _buildOrgRow(BuildContext context) {
|
Widget _buildOrgRow(BuildContext context) {
|
||||||
return BlocBuilder<OrganizationBloc, OrganizationState>(
|
return BlocBuilder<OrganizationBloc, OrganizationState>(
|
||||||
builder: (context, state) {
|
builder: (context, state) {
|
||||||
|
List<Organization> orgs = [];
|
||||||
|
Organization? selectedOrg;
|
||||||
|
bool isLoading = false;
|
||||||
|
|
||||||
if (state is OrganizationLoaded) {
|
if (state is OrganizationLoaded) {
|
||||||
final orgs = state.organizations;
|
orgs = state.organizations;
|
||||||
return Column(
|
selectedOrg = state.selectedOrg;
|
||||||
children: [
|
isLoading = state.isLoading;
|
||||||
Row(
|
} else if (state is OrganizationLoading) {
|
||||||
children: [
|
isLoading = true;
|
||||||
...orgs.map(
|
|
||||||
(org) => Row(
|
|
||||||
children: [
|
|
||||||
_buildOrgButton(
|
|
||||||
org,
|
|
||||||
org.id == state.selectedOrg?.id,
|
|
||||||
() {
|
|
||||||
context.read<OrganizationBloc>().add(
|
|
||||||
SelectOrganization(org.id),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
const SizedBox(width: 16),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
_buildAddButton(() => _showCreateOrgDialog(context)),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
const Divider(height: 1),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
return const SizedBox.shrink();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
...orgs.map(
|
||||||
|
(org) => Row(
|
||||||
|
children: [
|
||||||
|
_buildOrgButton(org, org.id == selectedOrg?.id, () {
|
||||||
|
context.read<OrganizationBloc>().add(
|
||||||
|
SelectOrganization(org.id),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
const SizedBox(width: 16),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (isLoading)
|
||||||
|
SizedBox(
|
||||||
|
width: 20,
|
||||||
|
height: 20,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
strokeWidth: 2,
|
||||||
|
valueColor: AlwaysStoppedAnimation<Color>(
|
||||||
|
AppTheme.accentColor,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
else
|
||||||
|
_buildAddButton(() => _showCreateOrgDialog(context)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const Divider(height: 1),
|
||||||
|
],
|
||||||
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -190,10 +236,19 @@ class _HomePageState extends State<HomePage> with TickerProviderStateMixin {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildDrive(OrganizationState state) {
|
Widget _buildDrive(OrganizationState state, AuthState authState) {
|
||||||
return state is OrganizationLoaded && state.selectedOrg != null
|
String orgId;
|
||||||
? FileExplorer(orgId: state.selectedOrg!.id)
|
if (state is OrganizationLoaded && state.selectedOrg != null) {
|
||||||
: const FileExplorer(orgId: 'org1');
|
// Show selected organization's files
|
||||||
|
orgId = state.selectedOrg!.id;
|
||||||
|
} else if (authState is AuthAuthenticated) {
|
||||||
|
// Show personal workspace - use empty string to trigger /user/files endpoint
|
||||||
|
orgId = '';
|
||||||
|
} else {
|
||||||
|
orgId = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
return FileExplorer(orgId: orgId);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildNavButton(String label, IconData icon, {bool isAvatar = false}) {
|
Widget _buildNavButton(String label, IconData icon, {bool isAvatar = false}) {
|
||||||
@@ -238,274 +293,328 @@ class _HomePageState extends State<HomePage> with TickerProviderStateMixin {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
return MultiBlocProvider(
|
||||||
backgroundColor: AppTheme.primaryBackground,
|
providers: [
|
||||||
body: Stack(
|
BlocProvider<PermissionBloc>.value(value: _permissionBloc),
|
||||||
children: [
|
BlocProvider<FileBrowserBloc>.value(value: _fileBrowserBloc),
|
||||||
Center(
|
BlocProvider<UploadBloc>.value(value: _uploadBloc),
|
||||||
child: BlocBuilder<AuthBloc, AuthState>(
|
BlocProvider<OrganizationBloc>.value(value: _organizationBloc),
|
||||||
builder: (context, state) {
|
],
|
||||||
final isLoggedIn = state is AuthAuthenticated;
|
child: Scaffold(
|
||||||
if (isLoggedIn && !_animationController.isAnimating) {
|
backgroundColor: AppTheme.primaryBackground,
|
||||||
_animationController.forward();
|
body: Stack(
|
||||||
} else if (!isLoggedIn) {
|
children: [
|
||||||
_animationController.reverse();
|
Center(
|
||||||
}
|
child: BlocBuilder<AuthBloc, AuthState>(
|
||||||
return Padding(
|
builder: (context, state) {
|
||||||
padding: const EdgeInsets.only(top: 42.0),
|
final isLoggedIn = state is AuthAuthenticated;
|
||||||
child: AnimatedContainer(
|
if (isLoggedIn && !_animationController.isAnimating) {
|
||||||
duration: const Duration(milliseconds: 350),
|
_animationController.forward();
|
||||||
curve: Curves.easeInOut,
|
} else if (!isLoggedIn) {
|
||||||
width: isLoggedIn
|
_animationController.reverse();
|
||||||
? MediaQuery.of(context).size.width * 0.9
|
}
|
||||||
: 340,
|
return Padding(
|
||||||
height: isLoggedIn
|
padding: EdgeInsets.only(
|
||||||
? MediaQuery.of(context).size.height * 0.9
|
top: MediaQuery.of(context).size.width < 600
|
||||||
: (_isSignupMode ? 400 : 280),
|
? 60.0
|
||||||
child: ClipRRect(
|
: 78.0,
|
||||||
borderRadius: BorderRadius.circular(16),
|
),
|
||||||
child: BackdropFilter(
|
child: AnimatedContainer(
|
||||||
filter: ui.ImageFilter.blur(sigmaX: 10, sigmaY: 10),
|
duration: const Duration(milliseconds: 350),
|
||||||
child: Stack(
|
curve: Curves.easeInOut,
|
||||||
children: [
|
width: isLoggedIn
|
||||||
Container(
|
? MediaQuery.of(context).size.width * 0.9
|
||||||
decoration: AppTheme.glassDecoration,
|
: 340,
|
||||||
child: isLoggedIn
|
height: isLoggedIn
|
||||||
? BlocListener<
|
? MediaQuery.of(context).size.height * 0.9
|
||||||
OrganizationBloc,
|
: (_isSignupMode
|
||||||
OrganizationState
|
? 400
|
||||||
>(
|
: (_usePasswordMode ? 350 : 280)),
|
||||||
listener: (context, state) {
|
child: ClipRRect(
|
||||||
if (state is OrganizationLoaded &&
|
borderRadius: BorderRadius.circular(16),
|
||||||
state.selectedOrg != null) {
|
child: BackdropFilter(
|
||||||
// Reload file browser when org changes
|
filter: ui.ImageFilter.blur(sigmaX: 10, sigmaY: 10),
|
||||||
context.read<FileBrowserBloc>().add(
|
child: Stack(
|
||||||
LoadDirectory(
|
children: [
|
||||||
orgId: state.selectedOrg!.id,
|
Container(
|
||||||
path: '/',
|
decoration: AppTheme.glassDecoration,
|
||||||
),
|
child: isLoggedIn
|
||||||
);
|
? BlocListener<
|
||||||
}
|
OrganizationBloc,
|
||||||
},
|
OrganizationState
|
||||||
child:
|
>(
|
||||||
BlocBuilder<
|
listener: (context, state) {
|
||||||
OrganizationBloc,
|
if (state is OrganizationLoaded) {
|
||||||
OrganizationState
|
// Show errors if present
|
||||||
>(
|
if (state.error != null &&
|
||||||
builder: (context, orgState) {
|
state.error!.isNotEmpty) {
|
||||||
if (orgState
|
ScaffoldMessenger.of(
|
||||||
is OrganizationInitial) {
|
context,
|
||||||
WidgetsBinding.instance
|
).showSnackBar(
|
||||||
.addPostFrameCallback((_) {
|
SnackBar(
|
||||||
context
|
content: Text(state.error!),
|
||||||
.read<
|
),
|
||||||
OrganizationBloc
|
|
||||||
>()
|
|
||||||
.add(
|
|
||||||
LoadOrganizations(),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return Column(
|
|
||||||
children: [
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
_buildOrgRow(context),
|
|
||||||
Expanded(
|
|
||||||
child: _buildDrive(
|
|
||||||
orgState,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
);
|
||||||
},
|
}
|
||||||
),
|
final orgId =
|
||||||
)
|
state.selectedOrg?.id ?? '';
|
||||||
: LoginForm(
|
// Reload file browser when org changes (or when falling back to personal workspace)
|
||||||
onSignupModeChanged: _setSignupMode,
|
context.read<FileBrowserBloc>().add(
|
||||||
),
|
LoadDirectory(
|
||||||
),
|
orgId: orgId,
|
||||||
// Top-left radial glow - primary accent light
|
path: '/',
|
||||||
AnimatedPositioned(
|
),
|
||||||
duration: const Duration(milliseconds: 350),
|
);
|
||||||
curve: Curves.easeInOut,
|
}
|
||||||
top: isLoggedIn ? -180 : -120,
|
},
|
||||||
left: isLoggedIn ? -180 : -120,
|
child:
|
||||||
child: IgnorePointer(
|
BlocBuilder<
|
||||||
child: AnimatedContainer(
|
OrganizationBloc,
|
||||||
duration: const Duration(milliseconds: 350),
|
OrganizationState
|
||||||
curve: Curves.easeInOut,
|
>(
|
||||||
width: isLoggedIn ? 550 : 400,
|
builder: (context, orgState) {
|
||||||
height: isLoggedIn ? 550 : 400,
|
if (orgState
|
||||||
decoration: BoxDecoration(
|
is OrganizationInitial) {
|
||||||
shape: BoxShape.circle,
|
WidgetsBinding.instance
|
||||||
gradient: RadialGradient(
|
.addPostFrameCallback((
|
||||||
colors: [
|
_,
|
||||||
AppTheme.accentColor.withValues(
|
) {
|
||||||
alpha: isLoggedIn ? 0.12 : 0.15,
|
// Kick off org fetch and immediately show personal workspace
|
||||||
),
|
// while org data loads.
|
||||||
AppTheme.accentColor.withValues(
|
context
|
||||||
alpha: 0.04,
|
.read<
|
||||||
),
|
OrganizationBloc
|
||||||
Colors.transparent,
|
>()
|
||||||
],
|
.add(
|
||||||
stops: const [0.0, 0.6, 1.0],
|
LoadOrganizations(),
|
||||||
),
|
);
|
||||||
),
|
context
|
||||||
),
|
.read<
|
||||||
|
FileBrowserBloc
|
||||||
|
>()
|
||||||
|
.add(
|
||||||
|
const LoadDirectory(
|
||||||
|
orgId: '',
|
||||||
|
path: '/',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return Column(
|
||||||
|
children: [
|
||||||
|
const SizedBox(height: 80),
|
||||||
|
_buildOrgRow(context),
|
||||||
|
Expanded(
|
||||||
|
child: _buildDrive(
|
||||||
|
orgState,
|
||||||
|
state,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: LoginForm(
|
||||||
|
onSignupModeChanged: _setSignupMode,
|
||||||
|
onPasswordModeChanged: _setPasswordMode,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
// Top-left radial glow - primary accent light
|
||||||
// Bottom-right warm glow - complementary lighting
|
AnimatedPositioned(
|
||||||
AnimatedPositioned(
|
duration: const Duration(milliseconds: 350),
|
||||||
duration: const Duration(milliseconds: 350),
|
curve: Curves.easeInOut,
|
||||||
curve: Curves.easeInOut,
|
top: isLoggedIn ? -180 : -120,
|
||||||
bottom: isLoggedIn ? -200 : -140,
|
left: isLoggedIn ? -180 : -120,
|
||||||
right: isLoggedIn ? -200 : -140,
|
child: IgnorePointer(
|
||||||
child: IgnorePointer(
|
child: AnimatedContainer(
|
||||||
child: AnimatedContainer(
|
duration: const Duration(milliseconds: 350),
|
||||||
duration: const Duration(milliseconds: 350),
|
curve: Curves.easeInOut,
|
||||||
curve: Curves.easeInOut,
|
width: isLoggedIn ? 550 : 400,
|
||||||
width: isLoggedIn ? 530 : 380,
|
height: isLoggedIn ? 550 : 400,
|
||||||
height: isLoggedIn ? 530 : 380,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
shape: BoxShape.circle,
|
|
||||||
gradient: RadialGradient(
|
|
||||||
colors: [
|
|
||||||
Colors.cyan.withValues(
|
|
||||||
alpha: isLoggedIn ? 0.06 : 0.08,
|
|
||||||
),
|
|
||||||
Colors.transparent,
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
// Top edge subtle highlight
|
|
||||||
Positioned(
|
|
||||||
top: 0,
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
child: IgnorePointer(
|
|
||||||
child: Container(
|
|
||||||
height: 60,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
gradient: LinearGradient(
|
|
||||||
begin: Alignment.topCenter,
|
|
||||||
end: Alignment.bottomCenter,
|
|
||||||
colors: [
|
|
||||||
Colors.white.withValues(alpha: 0.05),
|
|
||||||
Colors.transparent,
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
// Left edge subtle side lighting
|
|
||||||
Positioned(
|
|
||||||
left: 0,
|
|
||||||
top: 0,
|
|
||||||
bottom: 0,
|
|
||||||
child: IgnorePointer(
|
|
||||||
child: Container(
|
|
||||||
width: 40,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
gradient: LinearGradient(
|
|
||||||
begin: Alignment.centerLeft,
|
|
||||||
end: Alignment.centerRight,
|
|
||||||
colors: [
|
|
||||||
AppTheme.accentColor.withValues(
|
|
||||||
alpha: 0.04,
|
|
||||||
),
|
|
||||||
Colors.transparent,
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
// Diagonal shimmer overlay
|
|
||||||
Positioned(
|
|
||||||
top: 0,
|
|
||||||
left: 0,
|
|
||||||
child: IgnorePointer(
|
|
||||||
child: Transform.rotate(
|
|
||||||
angle: 0.785,
|
|
||||||
child: Container(
|
|
||||||
width: 600,
|
|
||||||
height: 100,
|
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
gradient: LinearGradient(
|
shape: BoxShape.circle,
|
||||||
|
gradient: RadialGradient(
|
||||||
colors: [
|
colors: [
|
||||||
Colors.white.withValues(alpha: 0),
|
AppTheme.accentColor.withValues(
|
||||||
Colors.white.withValues(alpha: 0.06),
|
alpha: isLoggedIn ? 0.12 : 0.15,
|
||||||
Colors.white.withValues(alpha: 0),
|
),
|
||||||
|
AppTheme.accentColor.withValues(
|
||||||
|
alpha: 0.04,
|
||||||
|
),
|
||||||
|
Colors.transparent,
|
||||||
|
],
|
||||||
|
stops: const [0.0, 0.6, 1.0],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// Bottom-right warm glow - complementary lighting
|
||||||
|
AnimatedPositioned(
|
||||||
|
duration: const Duration(milliseconds: 350),
|
||||||
|
curve: Curves.easeInOut,
|
||||||
|
bottom: isLoggedIn ? -200 : -140,
|
||||||
|
right: isLoggedIn ? -200 : -140,
|
||||||
|
child: IgnorePointer(
|
||||||
|
child: AnimatedContainer(
|
||||||
|
duration: const Duration(milliseconds: 350),
|
||||||
|
curve: Curves.easeInOut,
|
||||||
|
width: isLoggedIn ? 530 : 380,
|
||||||
|
height: isLoggedIn ? 530 : 380,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
gradient: RadialGradient(
|
||||||
|
colors: [
|
||||||
|
Colors.cyan.withValues(
|
||||||
|
alpha: isLoggedIn ? 0.06 : 0.08,
|
||||||
|
),
|
||||||
|
Colors.transparent,
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
// Top edge subtle highlight
|
||||||
],
|
Positioned(
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
child: IgnorePointer(
|
||||||
|
child: Container(
|
||||||
|
height: 60,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
gradient: LinearGradient(
|
||||||
|
begin: Alignment.topCenter,
|
||||||
|
end: Alignment.bottomCenter,
|
||||||
|
colors: [
|
||||||
|
Colors.white.withValues(alpha: 0.05),
|
||||||
|
Colors.transparent,
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// Left edge subtle side lighting
|
||||||
|
Positioned(
|
||||||
|
left: 0,
|
||||||
|
top: 0,
|
||||||
|
bottom: 0,
|
||||||
|
child: IgnorePointer(
|
||||||
|
child: Container(
|
||||||
|
width: 40,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
gradient: LinearGradient(
|
||||||
|
begin: Alignment.centerLeft,
|
||||||
|
end: Alignment.centerRight,
|
||||||
|
colors: [
|
||||||
|
AppTheme.accentColor.withValues(
|
||||||
|
alpha: 0.04,
|
||||||
|
),
|
||||||
|
Colors.transparent,
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// Diagonal shimmer overlay
|
||||||
|
Positioned(
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
child: IgnorePointer(
|
||||||
|
child: Transform.rotate(
|
||||||
|
angle: 0.785,
|
||||||
|
child: Container(
|
||||||
|
width: 600,
|
||||||
|
height: 100,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
gradient: LinearGradient(
|
||||||
|
colors: [
|
||||||
|
Colors.white.withValues(alpha: 0),
|
||||||
|
Colors.white.withValues(
|
||||||
|
alpha: 0.06,
|
||||||
|
),
|
||||||
|
Colors.white.withValues(alpha: 0),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
);
|
||||||
);
|
},
|
||||||
},
|
),
|
||||||
),
|
),
|
||||||
),
|
Positioned(
|
||||||
Positioned(
|
top: 0,
|
||||||
top: 0,
|
left: 0,
|
||||||
left: 0,
|
right: 0,
|
||||||
right: 0,
|
child: Center(
|
||||||
child: Center(
|
child: Builder(
|
||||||
child: Text(
|
builder: (context) {
|
||||||
'b0esche.cloud',
|
final screenWidth = MediaQuery.of(context).size.width;
|
||||||
style: TextStyle(
|
final fontSize = screenWidth < 600 ? 24.0 : 48.0;
|
||||||
fontFamily: 'PixelatedElegance',
|
return Text(
|
||||||
fontSize: 48,
|
'b0esche.cloud',
|
||||||
color: AppTheme.primaryText,
|
style: TextStyle(
|
||||||
decoration: TextDecoration.underline,
|
fontFamily: 'PixelatedElegance',
|
||||||
decorationColor: AppTheme.primaryText,
|
fontSize: fontSize,
|
||||||
fontFeatures: const [FontFeature.slashedZero()],
|
color: AppTheme.primaryText,
|
||||||
|
decoration: TextDecoration.underline,
|
||||||
|
decorationColor: AppTheme.primaryText,
|
||||||
|
fontFeatures: const [FontFeature.slashedZero()],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
Positioned(
|
||||||
Positioned(
|
top: MediaQuery.of(context).size.width < 600 ? 40 : 10,
|
||||||
top: 10,
|
right: 20,
|
||||||
right: 20,
|
child: BlocBuilder<AuthBloc, AuthState>(
|
||||||
child: BlocBuilder<AuthBloc, AuthState>(
|
builder: (context, state) {
|
||||||
builder: (context, state) {
|
final isLoggedIn = state is AuthAuthenticated;
|
||||||
final isLoggedIn = state is AuthAuthenticated;
|
if (!isLoggedIn) {
|
||||||
if (!isLoggedIn) {
|
return const SizedBox.shrink();
|
||||||
return const SizedBox.shrink();
|
}
|
||||||
}
|
return ScaleTransition(
|
||||||
return ScaleTransition(
|
scale: Tween<double>(begin: 0, end: 1).animate(
|
||||||
scale: Tween<double>(begin: 0, end: 1).animate(
|
CurvedAnimation(
|
||||||
CurvedAnimation(
|
parent: _animationController,
|
||||||
parent: _animationController,
|
curve: Curves.easeOutBack,
|
||||||
curve: Curves.easeOutBack,
|
),
|
||||||
),
|
),
|
||||||
),
|
child: Row(
|
||||||
child: Row(
|
children: [
|
||||||
children: [
|
_buildNavButton('Drive', Icons.cloud),
|
||||||
_buildNavButton('Drive', Icons.cloud),
|
const SizedBox(width: 16),
|
||||||
const SizedBox(width: 16),
|
_buildNavButton('Mail', Icons.mail),
|
||||||
_buildNavButton('Mail', Icons.mail),
|
const SizedBox(width: 16),
|
||||||
const SizedBox(width: 16),
|
_buildNavButton('Add', Icons.add),
|
||||||
_buildNavButton('Add', Icons.add),
|
const SizedBox(width: 16),
|
||||||
const SizedBox(width: 16),
|
_buildNavButton(
|
||||||
_buildNavButton('Profile', Icons.person, isAvatar: true),
|
'Profile',
|
||||||
],
|
Icons.person,
|
||||||
),
|
isAvatar: true,
|
||||||
);
|
),
|
||||||
},
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
],
|
||||||
],
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
import 'dart:math';
|
import 'dart:math';
|
||||||
|
import 'dart:convert';
|
||||||
import '../blocs/auth/auth_bloc.dart';
|
import '../blocs/auth/auth_bloc.dart';
|
||||||
import '../blocs/auth/auth_event.dart';
|
import '../blocs/auth/auth_event.dart';
|
||||||
import '../blocs/auth/auth_state.dart';
|
import '../blocs/auth/auth_state.dart';
|
||||||
@@ -12,8 +13,13 @@ import '../theme/modern_glass_button.dart';
|
|||||||
|
|
||||||
class LoginForm extends StatefulWidget {
|
class LoginForm extends StatefulWidget {
|
||||||
final ValueChanged<bool>? onSignupModeChanged;
|
final ValueChanged<bool>? onSignupModeChanged;
|
||||||
|
final ValueChanged<bool>? onPasswordModeChanged;
|
||||||
|
|
||||||
const LoginForm({super.key, this.onSignupModeChanged});
|
const LoginForm({
|
||||||
|
super.key,
|
||||||
|
this.onSignupModeChanged,
|
||||||
|
this.onPasswordModeChanged,
|
||||||
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<LoginForm> createState() => _LoginFormState();
|
State<LoginForm> createState() => _LoginFormState();
|
||||||
@@ -40,6 +46,12 @@ class _LoginFormState extends State<LoginForm> {
|
|||||||
return values.map((v) => v.toRadixString(16).padLeft(2, '0')).join();
|
return values.map((v) => v.toRadixString(16).padLeft(2, '0')).join();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
String _generateRandomBase64(int bytes) {
|
||||||
|
final random = Random();
|
||||||
|
final values = List<int>.generate(bytes, (i) => random.nextInt(256));
|
||||||
|
return base64.encode(values);
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _handleAuthentication(
|
Future<void> _handleAuthentication(
|
||||||
BuildContext context,
|
BuildContext context,
|
||||||
AuthenticationChallengeReceived state,
|
AuthenticationChallengeReceived state,
|
||||||
@@ -47,7 +59,7 @@ class _LoginFormState extends State<LoginForm> {
|
|||||||
try {
|
try {
|
||||||
final credentialId = state.credentialIds.isNotEmpty
|
final credentialId = state.credentialIds.isNotEmpty
|
||||||
? state.credentialIds.first
|
? state.credentialIds.first
|
||||||
: _generateRandomHex(64);
|
: _generateRandomBase64(64);
|
||||||
|
|
||||||
if (context.mounted) {
|
if (context.mounted) {
|
||||||
context.read<AuthBloc>().add(
|
context.read<AuthBloc>().add(
|
||||||
@@ -55,10 +67,10 @@ class _LoginFormState extends State<LoginForm> {
|
|||||||
username: _usernameController.text,
|
username: _usernameController.text,
|
||||||
challenge: state.challenge,
|
challenge: state.challenge,
|
||||||
credentialId: credentialId,
|
credentialId: credentialId,
|
||||||
authenticatorData: _generateRandomHex(37),
|
authenticatorData: _generateRandomBase64(37),
|
||||||
clientDataJSON:
|
clientDataJSON:
|
||||||
'{"type":"webauthn.get","challenge":"${state.challenge}","origin":"https://b0esche.cloud"}',
|
'{"type":"webauthn.get","challenge":"${state.challenge}","origin":"https://b0esche.cloud"}',
|
||||||
signature: _generateRandomHex(128),
|
signature: _generateRandomBase64(128),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -76,8 +88,8 @@ class _LoginFormState extends State<LoginForm> {
|
|||||||
RegistrationChallengeReceived state,
|
RegistrationChallengeReceived state,
|
||||||
) async {
|
) async {
|
||||||
try {
|
try {
|
||||||
final credentialId = _generateRandomHex(64);
|
final credentialId = _generateRandomBase64(64);
|
||||||
final publicKey = _generateRandomHex(91);
|
final publicKey = _generateRandomBase64(91);
|
||||||
|
|
||||||
if (context.mounted) {
|
if (context.mounted) {
|
||||||
context.read<AuthBloc>().add(
|
context.read<AuthBloc>().add(
|
||||||
@@ -88,7 +100,7 @@ class _LoginFormState extends State<LoginForm> {
|
|||||||
publicKey: publicKey,
|
publicKey: publicKey,
|
||||||
clientDataJSON:
|
clientDataJSON:
|
||||||
'{"type":"webauthn.create","challenge":"${state.challenge}","origin":"https://b0esche.cloud"}',
|
'{"type":"webauthn.create","challenge":"${state.challenge}","origin":"https://b0esche.cloud"}',
|
||||||
attestationObject: _generateRandomHex(128),
|
attestationObject: _generateRandomBase64(128),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -133,56 +145,28 @@ class _LoginFormState extends State<LoginForm> {
|
|||||||
child: Center(
|
child: Center(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(16.0),
|
padding: const EdgeInsets.all(16.0),
|
||||||
child: AnimatedSwitcher(
|
child: AnimatedSize(
|
||||||
duration: const Duration(milliseconds: 400),
|
duration: const Duration(milliseconds: 300),
|
||||||
transitionBuilder: (child, animation) {
|
curve: Curves.easeInOut,
|
||||||
return FadeTransition(opacity: animation, child: child);
|
child: AnimatedSwitcher(
|
||||||
},
|
duration: const Duration(milliseconds: 400),
|
||||||
child: SingleChildScrollView(
|
transitionBuilder: (child, animation) {
|
||||||
key: ValueKey<bool>(_isSignup),
|
return FadeTransition(opacity: animation, child: child);
|
||||||
child: Column(
|
},
|
||||||
mainAxisSize: MainAxisSize.min,
|
child: SingleChildScrollView(
|
||||||
crossAxisAlignment: CrossAxisAlignment.center,
|
key: ValueKey('${_isSignup}_$_usePasskey'),
|
||||||
children: [
|
child: Column(
|
||||||
Text(
|
mainAxisSize: MainAxisSize.min,
|
||||||
_isSignup ? 'create account' : 'sign in',
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
style: const TextStyle(
|
children: [
|
||||||
fontSize: 24,
|
Text(
|
||||||
color: AppTheme.primaryText,
|
_isSignup ? 'create account' : 'sign in',
|
||||||
),
|
style: const TextStyle(
|
||||||
),
|
fontSize: 24,
|
||||||
const SizedBox(height: 24),
|
color: AppTheme.primaryText,
|
||||||
Container(
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: AppTheme.primaryBackground.withValues(alpha: 0.5),
|
|
||||||
borderRadius: BorderRadius.circular(16),
|
|
||||||
border: Border.all(
|
|
||||||
color: AppTheme.accentColor.withValues(alpha: 0.3),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
child: TextField(
|
const SizedBox(height: 24),
|
||||||
controller: _usernameController,
|
|
||||||
textInputAction: TextInputAction.next,
|
|
||||||
keyboardType: TextInputType.text,
|
|
||||||
cursorColor: AppTheme.accentColor,
|
|
||||||
decoration: InputDecoration(
|
|
||||||
hintText: 'username',
|
|
||||||
hintStyle: TextStyle(color: AppTheme.secondaryText),
|
|
||||||
contentPadding: const EdgeInsets.all(12),
|
|
||||||
border: InputBorder.none,
|
|
||||||
prefixIcon: Icon(
|
|
||||||
Icons.person_outline,
|
|
||||||
color: AppTheme.primaryText,
|
|
||||||
size: 20,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
style: const TextStyle(color: AppTheme.primaryText),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
if (!_isSignup && _usePasskey)
|
|
||||||
const SizedBox.shrink()
|
|
||||||
else
|
|
||||||
Container(
|
Container(
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: AppTheme.primaryBackground.withValues(
|
color: AppTheme.primaryBackground.withValues(
|
||||||
@@ -194,105 +178,114 @@ class _LoginFormState extends State<LoginForm> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
child: TextField(
|
child: TextField(
|
||||||
controller: _passwordController,
|
controller: _usernameController,
|
||||||
textInputAction: TextInputAction.next,
|
textInputAction: TextInputAction.next,
|
||||||
keyboardType: TextInputType.visiblePassword,
|
|
||||||
obscureText: true,
|
|
||||||
cursorColor: AppTheme.accentColor,
|
|
||||||
decoration: InputDecoration(
|
|
||||||
hintText: 'password',
|
|
||||||
hintStyle: TextStyle(color: AppTheme.secondaryText),
|
|
||||||
contentPadding: const EdgeInsets.all(12),
|
|
||||||
border: InputBorder.none,
|
|
||||||
prefixIcon: Icon(
|
|
||||||
Icons.lock_outline,
|
|
||||||
color: AppTheme.primaryText,
|
|
||||||
size: 20,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
style: const TextStyle(color: AppTheme.primaryText),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
if (!_isSignup && _usePasskey)
|
|
||||||
const SizedBox.shrink()
|
|
||||||
else
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
if (_isSignup)
|
|
||||||
Container(
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: AppTheme.primaryBackground.withValues(
|
|
||||||
alpha: 0.5,
|
|
||||||
),
|
|
||||||
borderRadius: BorderRadius.circular(16),
|
|
||||||
border: Border.all(
|
|
||||||
color: AppTheme.accentColor.withValues(alpha: 0.3),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
child: TextField(
|
|
||||||
controller: _displayNameController,
|
|
||||||
textInputAction: TextInputAction.done,
|
|
||||||
keyboardType: TextInputType.text,
|
keyboardType: TextInputType.text,
|
||||||
cursorColor: AppTheme.accentColor,
|
cursorColor: AppTheme.accentColor,
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
hintText: 'display name (optional)',
|
hintText: 'username',
|
||||||
hintStyle: TextStyle(color: AppTheme.secondaryText),
|
hintStyle: TextStyle(color: AppTheme.secondaryText),
|
||||||
contentPadding: const EdgeInsets.all(12),
|
contentPadding: const EdgeInsets.all(12),
|
||||||
border: InputBorder.none,
|
border: InputBorder.none,
|
||||||
prefixIcon: Icon(
|
prefixIcon: Icon(
|
||||||
Icons.badge_outlined,
|
Icons.person_outline,
|
||||||
color: AppTheme.primaryText,
|
color: AppTheme.primaryText,
|
||||||
size: 20,
|
size: 20,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
style: const TextStyle(color: AppTheme.primaryText),
|
style: const TextStyle(color: AppTheme.primaryText),
|
||||||
),
|
),
|
||||||
)
|
),
|
||||||
else
|
const SizedBox(height: 16),
|
||||||
const SizedBox.shrink(),
|
if (!_isSignup && _usePasskey)
|
||||||
if (_isSignup)
|
const SizedBox.shrink()
|
||||||
const SizedBox(height: 16)
|
else
|
||||||
else
|
Container(
|
||||||
const SizedBox.shrink(),
|
decoration: BoxDecoration(
|
||||||
SizedBox(
|
color: AppTheme.primaryBackground.withValues(
|
||||||
width: 150,
|
alpha: 0.5,
|
||||||
child: BlocBuilder<AuthBloc, AuthState>(
|
),
|
||||||
builder: (context, state) {
|
borderRadius: BorderRadius.circular(16),
|
||||||
return ModernGlassButton(
|
border: Border.all(
|
||||||
isLoading: state is AuthLoading,
|
color: AppTheme.accentColor.withValues(alpha: 0.3),
|
||||||
onPressed: () {
|
),
|
||||||
if (_usernameController.text.isEmpty) {
|
),
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
child: TextField(
|
||||||
const SnackBar(
|
controller: _passwordController,
|
||||||
content: Text('Username is required'),
|
textInputAction: TextInputAction.next,
|
||||||
),
|
keyboardType: TextInputType.visiblePassword,
|
||||||
);
|
obscureText: true,
|
||||||
return;
|
cursorColor: AppTheme.accentColor,
|
||||||
}
|
decoration: InputDecoration(
|
||||||
if (_isSignup) {
|
hintText: 'password',
|
||||||
if (_passwordController.text.isEmpty) {
|
hintStyle: TextStyle(color: AppTheme.secondaryText),
|
||||||
|
contentPadding: const EdgeInsets.all(12),
|
||||||
|
border: InputBorder.none,
|
||||||
|
prefixIcon: Icon(
|
||||||
|
Icons.lock_outline,
|
||||||
|
color: AppTheme.primaryText,
|
||||||
|
size: 20,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
style: const TextStyle(color: AppTheme.primaryText),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (!_isSignup && _usePasskey)
|
||||||
|
const SizedBox.shrink()
|
||||||
|
else
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
if (_isSignup)
|
||||||
|
Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppTheme.primaryBackground.withValues(
|
||||||
|
alpha: 0.5,
|
||||||
|
),
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
border: Border.all(
|
||||||
|
color: AppTheme.accentColor.withValues(alpha: 0.3),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: TextField(
|
||||||
|
controller: _displayNameController,
|
||||||
|
textInputAction: TextInputAction.done,
|
||||||
|
keyboardType: TextInputType.text,
|
||||||
|
cursorColor: AppTheme.accentColor,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
hintText: 'display name (optional)',
|
||||||
|
hintStyle: TextStyle(color: AppTheme.secondaryText),
|
||||||
|
contentPadding: const EdgeInsets.all(12),
|
||||||
|
border: InputBorder.none,
|
||||||
|
prefixIcon: Icon(
|
||||||
|
Icons.badge_outlined,
|
||||||
|
color: AppTheme.primaryText,
|
||||||
|
size: 20,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
style: const TextStyle(color: AppTheme.primaryText),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
else
|
||||||
|
const SizedBox.shrink(),
|
||||||
|
if (_isSignup)
|
||||||
|
const SizedBox(height: 16)
|
||||||
|
else
|
||||||
|
const SizedBox.shrink(),
|
||||||
|
SizedBox(
|
||||||
|
width: 150,
|
||||||
|
child: BlocBuilder<AuthBloc, AuthState>(
|
||||||
|
builder: (context, state) {
|
||||||
|
return ModernGlassButton(
|
||||||
|
isLoading: state is AuthLoading,
|
||||||
|
onPressed: () {
|
||||||
|
if (_usernameController.text.isEmpty) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
const SnackBar(
|
const SnackBar(
|
||||||
content: Text('Password is required'),
|
content: Text('Username is required'),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
context.read<AuthBloc>().add(
|
if (_isSignup) {
|
||||||
SignupStarted(
|
|
||||||
username: _usernameController.text,
|
|
||||||
email: _usernameController.text,
|
|
||||||
displayName: _displayNameController.text,
|
|
||||||
password: _passwordController.text,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
if (_usePasskey) {
|
|
||||||
context.read<AuthBloc>().add(
|
|
||||||
LoginRequested(
|
|
||||||
username: _usernameController.text,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
if (_passwordController.text.isEmpty) {
|
if (_passwordController.text.isEmpty) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
const SnackBar(
|
const SnackBar(
|
||||||
@@ -302,91 +295,120 @@ class _LoginFormState extends State<LoginForm> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
context.read<AuthBloc>().add(
|
context.read<AuthBloc>().add(
|
||||||
PasswordLoginRequested(
|
SignupStarted(
|
||||||
username: _usernameController.text,
|
username: _usernameController.text,
|
||||||
|
email: _usernameController.text,
|
||||||
|
displayName: _displayNameController.text,
|
||||||
password: _passwordController.text,
|
password: _passwordController.text,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
} else {
|
||||||
|
if (_usePasskey) {
|
||||||
|
context.read<AuthBloc>().add(
|
||||||
|
LoginRequested(
|
||||||
|
username: _usernameController.text,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
if (_passwordController.text.isEmpty) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
content: Text('Password is required'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
context.read<AuthBloc>().add(
|
||||||
|
PasswordLoginRequested(
|
||||||
|
username: _usernameController.text,
|
||||||
|
password: _passwordController.text,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
},
|
child: Text(_isSignup ? 'create' : 'sign in'),
|
||||||
child: Text(_isSignup ? 'create' : 'sign in'),
|
);
|
||||||
);
|
},
|
||||||
},
|
),
|
||||||
),
|
),
|
||||||
),
|
const SizedBox(height: 16),
|
||||||
const SizedBox(height: 16),
|
if (_isSignup)
|
||||||
if (_isSignup)
|
Row(
|
||||||
Row(
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
children: [
|
||||||
children: [
|
Text(
|
||||||
Text(
|
'already have an account?',
|
||||||
'already have an account?',
|
style: TextStyle(color: AppTheme.secondaryText),
|
||||||
style: TextStyle(color: AppTheme.secondaryText),
|
),
|
||||||
),
|
const SizedBox(width: 8),
|
||||||
const SizedBox(width: 8),
|
GestureDetector(
|
||||||
GestureDetector(
|
onTap: () {
|
||||||
onTap: () {
|
_resetForm();
|
||||||
_resetForm();
|
_setSignupMode(false);
|
||||||
_setSignupMode(false);
|
},
|
||||||
},
|
child: Text(
|
||||||
child: Text(
|
'sign in',
|
||||||
'sign in',
|
style: TextStyle(
|
||||||
style: TextStyle(
|
color: AppTheme.accentColor,
|
||||||
color: AppTheme.accentColor,
|
decoration: TextDecoration.underline,
|
||||||
decoration: TextDecoration.underline,
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
],
|
||||||
],
|
)
|
||||||
)
|
else
|
||||||
else
|
Column(
|
||||||
Column(
|
children: [
|
||||||
children: [
|
Row(
|
||||||
Row(
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
children: [
|
||||||
children: [
|
GestureDetector(
|
||||||
GestureDetector(
|
onTap: () {
|
||||||
onTap: () =>
|
setState(() => _usePasskey = !_usePasskey);
|
||||||
setState(() => _usePasskey = !_usePasskey),
|
widget.onPasswordModeChanged?.call(
|
||||||
child: Text(
|
!_usePasskey,
|
||||||
_usePasskey ? 'use password' : 'use passkey',
|
);
|
||||||
style: TextStyle(
|
},
|
||||||
color: AppTheme.accentColor,
|
child: Text(
|
||||||
decoration: TextDecoration.underline,
|
_usePasskey ? 'use password' : 'use passkey',
|
||||||
fontSize: 12,
|
style: TextStyle(
|
||||||
|
color: AppTheme.accentColor,
|
||||||
|
decoration: TextDecoration.underline,
|
||||||
|
fontSize: 12,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
],
|
||||||
],
|
),
|
||||||
),
|
const SizedBox(height: 16),
|
||||||
const SizedBox(height: 16),
|
Row(
|
||||||
Row(
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
children: [
|
||||||
children: [
|
Text(
|
||||||
Text(
|
'don\'t have an account?',
|
||||||
'don\'t have an account?',
|
style: TextStyle(color: AppTheme.secondaryText),
|
||||||
style: TextStyle(color: AppTheme.secondaryText),
|
),
|
||||||
),
|
const SizedBox(width: 8),
|
||||||
const SizedBox(width: 8),
|
GestureDetector(
|
||||||
GestureDetector(
|
onTap: () {
|
||||||
onTap: () {
|
_resetForm();
|
||||||
_resetForm();
|
_setSignupMode(true);
|
||||||
_setSignupMode(true);
|
},
|
||||||
},
|
child: Text(
|
||||||
child: Text(
|
'create one',
|
||||||
'create one',
|
style: TextStyle(
|
||||||
style: TextStyle(
|
color: AppTheme.accentColor,
|
||||||
color: AppTheme.accentColor,
|
decoration: TextDecoration.underline,
|
||||||
decoration: TextDecoration.underline,
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
],
|
||||||
],
|
),
|
||||||
),
|
],
|
||||||
],
|
),
|
||||||
),
|
],
|
||||||
],
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
import 'dart:math';
|
import 'dart:math';
|
||||||
|
import 'dart:convert';
|
||||||
import '../blocs/auth/auth_bloc.dart';
|
import '../blocs/auth/auth_bloc.dart';
|
||||||
import '../blocs/auth/auth_event.dart';
|
import '../blocs/auth/auth_event.dart';
|
||||||
import '../blocs/auth/auth_state.dart';
|
import '../blocs/auth/auth_state.dart';
|
||||||
@@ -34,6 +35,12 @@ class _SignupFormState extends State<SignupForm> {
|
|||||||
return values.map((v) => v.toRadixString(16).padLeft(2, '0')).join();
|
return values.map((v) => v.toRadixString(16).padLeft(2, '0')).join();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
String _generateRandomBase64(int bytes) {
|
||||||
|
final random = Random();
|
||||||
|
final values = List<int>.generate(bytes, (i) => random.nextInt(256));
|
||||||
|
return base64.encode(values);
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _handleRegistration(
|
Future<void> _handleRegistration(
|
||||||
BuildContext context,
|
BuildContext context,
|
||||||
RegistrationChallengeReceived state,
|
RegistrationChallengeReceived state,
|
||||||
@@ -41,8 +48,8 @@ class _SignupFormState extends State<SignupForm> {
|
|||||||
try {
|
try {
|
||||||
// Simulate WebAuthn registration by generating fake credential data
|
// Simulate WebAuthn registration by generating fake credential data
|
||||||
// In a real implementation, this would call native WebAuthn APIs
|
// In a real implementation, this would call native WebAuthn APIs
|
||||||
final credentialId = _generateRandomHex(64);
|
final credentialId = _generateRandomBase64(64);
|
||||||
final publicKey = _generateRandomHex(91); // EC2 public key size
|
final publicKey = _generateRandomBase64(91); // EC2 public key size
|
||||||
|
|
||||||
if (context.mounted) {
|
if (context.mounted) {
|
||||||
context.read<AuthBloc>().add(
|
context.read<AuthBloc>().add(
|
||||||
@@ -53,7 +60,7 @@ class _SignupFormState extends State<SignupForm> {
|
|||||||
publicKey: publicKey,
|
publicKey: publicKey,
|
||||||
clientDataJSON:
|
clientDataJSON:
|
||||||
'{"type":"webauthn.create","challenge":"${state.challenge}","origin":"https://b0esche.cloud"}',
|
'{"type":"webauthn.create","challenge":"${state.challenge}","origin":"https://b0esche.cloud"}',
|
||||||
attestationObject: _generateRandomHex(128),
|
attestationObject: _generateRandomBase64(128),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,9 +6,11 @@ import '../models/document_capabilities.dart';
|
|||||||
import '../models/api_error.dart';
|
import '../models/api_error.dart';
|
||||||
import '../repositories/file_repository.dart';
|
import '../repositories/file_repository.dart';
|
||||||
import 'package:path/path.dart' as p;
|
import 'package:path/path.dart' as p;
|
||||||
|
import 'package:uuid/uuid.dart';
|
||||||
|
|
||||||
class MockFileRepository implements FileRepository {
|
class MockFileRepository implements FileRepository {
|
||||||
final Map<String, List<FileItem>> _orgFiles = {};
|
final Map<String, List<FileItem>> _orgFiles = {};
|
||||||
|
final _uuid = const Uuid();
|
||||||
|
|
||||||
List<FileItem> _getFilesForOrg(String orgId) {
|
List<FileItem> _getFilesForOrg(String orgId) {
|
||||||
if (!_orgFiles.containsKey(orgId)) {
|
if (!_orgFiles.containsKey(orgId)) {
|
||||||
@@ -16,18 +18,21 @@ class MockFileRepository implements FileRepository {
|
|||||||
if (orgId == 'org1') {
|
if (orgId == 'org1') {
|
||||||
_orgFiles[orgId] = [
|
_orgFiles[orgId] = [
|
||||||
FileItem(
|
FileItem(
|
||||||
|
id: _uuid.v4(),
|
||||||
name: 'Personal Documents',
|
name: 'Personal Documents',
|
||||||
path: '/Personal Documents',
|
path: '/Personal Documents',
|
||||||
type: FileType.folder,
|
type: FileType.folder,
|
||||||
lastModified: DateTime.now(),
|
lastModified: DateTime.now(),
|
||||||
),
|
),
|
||||||
FileItem(
|
FileItem(
|
||||||
|
id: _uuid.v4(),
|
||||||
name: 'Photos',
|
name: 'Photos',
|
||||||
path: '/Photos',
|
path: '/Photos',
|
||||||
type: FileType.folder,
|
type: FileType.folder,
|
||||||
lastModified: DateTime.now(),
|
lastModified: DateTime.now(),
|
||||||
),
|
),
|
||||||
FileItem(
|
FileItem(
|
||||||
|
id: _uuid.v4(),
|
||||||
name: 'resume.pdf',
|
name: 'resume.pdf',
|
||||||
path: '/resume.pdf',
|
path: '/resume.pdf',
|
||||||
type: FileType.file,
|
type: FileType.file,
|
||||||
@@ -35,6 +40,7 @@ class MockFileRepository implements FileRepository {
|
|||||||
lastModified: DateTime.now(),
|
lastModified: DateTime.now(),
|
||||||
),
|
),
|
||||||
FileItem(
|
FileItem(
|
||||||
|
id: _uuid.v4(),
|
||||||
name: 'notes.txt',
|
name: 'notes.txt',
|
||||||
path: '/notes.txt',
|
path: '/notes.txt',
|
||||||
type: FileType.file,
|
type: FileType.file,
|
||||||
@@ -45,12 +51,14 @@ class MockFileRepository implements FileRepository {
|
|||||||
} else if (orgId == 'org2') {
|
} else if (orgId == 'org2') {
|
||||||
_orgFiles[orgId] = [
|
_orgFiles[orgId] = [
|
||||||
FileItem(
|
FileItem(
|
||||||
|
id: _uuid.v4(),
|
||||||
name: 'Company Reports',
|
name: 'Company Reports',
|
||||||
path: '/Company Reports',
|
path: '/Company Reports',
|
||||||
type: FileType.folder,
|
type: FileType.folder,
|
||||||
lastModified: DateTime.now(),
|
lastModified: DateTime.now(),
|
||||||
),
|
),
|
||||||
FileItem(
|
FileItem(
|
||||||
|
id: _uuid.v4(),
|
||||||
name: 'annual_report.pdf',
|
name: 'annual_report.pdf',
|
||||||
path: '/annual_report.pdf',
|
path: '/annual_report.pdf',
|
||||||
type: FileType.file,
|
type: FileType.file,
|
||||||
@@ -58,6 +66,7 @@ class MockFileRepository implements FileRepository {
|
|||||||
lastModified: DateTime.now(),
|
lastModified: DateTime.now(),
|
||||||
),
|
),
|
||||||
FileItem(
|
FileItem(
|
||||||
|
id: _uuid.v4(),
|
||||||
name: 'presentation.pptx',
|
name: 'presentation.pptx',
|
||||||
path: '/presentation.pptx',
|
path: '/presentation.pptx',
|
||||||
type: FileType.file,
|
type: FileType.file,
|
||||||
@@ -68,12 +77,14 @@ class MockFileRepository implements FileRepository {
|
|||||||
} else if (orgId == 'org3') {
|
} else if (orgId == 'org3') {
|
||||||
_orgFiles[orgId] = [
|
_orgFiles[orgId] = [
|
||||||
FileItem(
|
FileItem(
|
||||||
|
id: _uuid.v4(),
|
||||||
name: 'Project Code',
|
name: 'Project Code',
|
||||||
path: '/Project Code',
|
path: '/Project Code',
|
||||||
type: FileType.folder,
|
type: FileType.folder,
|
||||||
lastModified: DateTime.now(),
|
lastModified: DateTime.now(),
|
||||||
),
|
),
|
||||||
FileItem(
|
FileItem(
|
||||||
|
id: _uuid.v4(),
|
||||||
name: 'side_project.dart',
|
name: 'side_project.dart',
|
||||||
path: '/side_project.dart',
|
path: '/side_project.dart',
|
||||||
type: FileType.file,
|
type: FileType.file,
|
||||||
@@ -153,6 +164,7 @@ class MockFileRepository implements FileRepository {
|
|||||||
final files = _getFilesForOrg(orgId);
|
final files = _getFilesForOrg(orgId);
|
||||||
files.add(
|
files.add(
|
||||||
FileItem(
|
FileItem(
|
||||||
|
id: _uuid.v4(),
|
||||||
name: normalizedName,
|
name: normalizedName,
|
||||||
path: newPath,
|
path: newPath,
|
||||||
type: FileType.folder,
|
type: FileType.folder,
|
||||||
@@ -175,6 +187,7 @@ class MockFileRepository implements FileRepository {
|
|||||||
final newName = file.path.split('/').last;
|
final newName = file.path.split('/').last;
|
||||||
final newPath = targetPath == '/' ? '/$newName' : '$targetPath/$newName';
|
final newPath = targetPath == '/' ? '/$newName' : '$targetPath/$newName';
|
||||||
files[fileIndex] = FileItem(
|
files[fileIndex] = FileItem(
|
||||||
|
id: file.id,
|
||||||
name: file.name,
|
name: file.name,
|
||||||
path: newPath,
|
path: newPath,
|
||||||
type: file.type,
|
type: file.type,
|
||||||
@@ -194,6 +207,7 @@ class MockFileRepository implements FileRepository {
|
|||||||
final parentPath = p.dirname(path);
|
final parentPath = p.dirname(path);
|
||||||
final newPath = parentPath == '.' ? '/$newName' : '$parentPath/$newName';
|
final newPath = parentPath == '.' ? '/$newName' : '$parentPath/$newName';
|
||||||
files[fileIndex] = FileItem(
|
files[fileIndex] = FileItem(
|
||||||
|
id: file.id,
|
||||||
name: newName,
|
name: newName,
|
||||||
path: newPath,
|
path: newPath,
|
||||||
type: file.type,
|
type: file.type,
|
||||||
@@ -216,7 +230,16 @@ class MockFileRepository implements FileRepository {
|
|||||||
Future<void> uploadFile(String orgId, FileItem file) async {
|
Future<void> uploadFile(String orgId, FileItem file) async {
|
||||||
await Future.delayed(const Duration(seconds: 1));
|
await Future.delayed(const Duration(seconds: 1));
|
||||||
final files = _getFilesForOrg(orgId);
|
final files = _getFilesForOrg(orgId);
|
||||||
files.add(file);
|
files.add(
|
||||||
|
FileItem(
|
||||||
|
id: _uuid.v4(),
|
||||||
|
name: file.name,
|
||||||
|
path: file.path,
|
||||||
|
type: file.type,
|
||||||
|
size: file.size,
|
||||||
|
lastModified: file.lastModified,
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|||||||
@@ -29,22 +29,7 @@ class ApiClient {
|
|||||||
},
|
},
|
||||||
onError: (error, handler) async {
|
onError: (error, handler) async {
|
||||||
if (error.response?.statusCode == 401) {
|
if (error.response?.statusCode == 401) {
|
||||||
// Try refresh
|
// Session expired, trigger logout
|
||||||
final refreshSuccess = await _tryRefreshToken();
|
|
||||||
if (refreshSuccess) {
|
|
||||||
// Retry the request
|
|
||||||
final token = _getCurrentToken();
|
|
||||||
if (token != null) {
|
|
||||||
error.requestOptions.headers['Authorization'] = 'Bearer $token';
|
|
||||||
try {
|
|
||||||
final response = await _dio.fetch(error.requestOptions);
|
|
||||||
return handler.resolve(response);
|
|
||||||
} catch (e) {
|
|
||||||
// If retry fails, proceed to error
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// If refresh failed, logout
|
|
||||||
_sessionBloc.add(SessionExpired());
|
_sessionBloc.add(SessionExpired());
|
||||||
}
|
}
|
||||||
return handler.next(error);
|
return handler.next(error);
|
||||||
@@ -53,6 +38,8 @@ class ApiClient {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
String get baseUrl => _dio.options.baseUrl;
|
||||||
|
|
||||||
String? _getCurrentToken() {
|
String? _getCurrentToken() {
|
||||||
// Get from SessionBloc state
|
// Get from SessionBloc state
|
||||||
final state = _sessionBloc.state;
|
final state = _sessionBloc.state;
|
||||||
@@ -62,20 +49,6 @@ class ApiClient {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<bool> _tryRefreshToken() async {
|
|
||||||
try {
|
|
||||||
final response = await _dio.post('/auth/refresh');
|
|
||||||
if (response.statusCode == 200) {
|
|
||||||
final newToken = response.data['token'];
|
|
||||||
_sessionBloc.add(SessionRefreshed(newToken));
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
// Refresh failed
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<T> get<T>(
|
Future<T> get<T>(
|
||||||
String path, {
|
String path, {
|
||||||
Map<String, dynamic>? queryParameters,
|
Map<String, dynamic>? queryParameters,
|
||||||
|
|||||||
@@ -3,19 +3,40 @@ import '../models/viewer_session.dart';
|
|||||||
import '../models/editor_session.dart';
|
import '../models/editor_session.dart';
|
||||||
import '../models/annotation.dart';
|
import '../models/annotation.dart';
|
||||||
import 'api_client.dart';
|
import 'api_client.dart';
|
||||||
|
import 'package:dio/dio.dart';
|
||||||
|
|
||||||
class FileService {
|
class FileService {
|
||||||
final ApiClient _apiClient;
|
final ApiClient _apiClient;
|
||||||
|
|
||||||
FileService(this._apiClient);
|
FileService(this._apiClient);
|
||||||
|
|
||||||
|
String get baseUrl => _apiClient.baseUrl;
|
||||||
|
|
||||||
Future<List<FileItem>> getFiles(String orgId, String path) async {
|
Future<List<FileItem>> getFiles(String orgId, String path) async {
|
||||||
if (path.isEmpty) {
|
if (path.isEmpty) {
|
||||||
throw Exception('Path cannot be empty');
|
throw Exception('Path cannot be empty');
|
||||||
}
|
}
|
||||||
|
final pathParam = {'path': path};
|
||||||
|
if (orgId.isEmpty) {
|
||||||
|
return await _apiClient.getList(
|
||||||
|
'/user/files',
|
||||||
|
queryParameters: pathParam,
|
||||||
|
fromJson: (data) => FileItem(
|
||||||
|
id: data['id'],
|
||||||
|
name: data['name'],
|
||||||
|
path: data['path'],
|
||||||
|
type: data['type'] == 'file' ? FileType.file : FileType.folder,
|
||||||
|
size: data['size'],
|
||||||
|
lastModified: DateTime.parse(data['lastModified']),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return await _apiClient.getList(
|
return await _apiClient.getList(
|
||||||
'/orgs/$orgId/files',
|
'/orgs/$orgId/files',
|
||||||
|
queryParameters: pathParam,
|
||||||
fromJson: (data) => FileItem(
|
fromJson: (data) => FileItem(
|
||||||
|
id: data['id'],
|
||||||
name: data['name'],
|
name: data['name'],
|
||||||
path: data['path'],
|
path: data['path'],
|
||||||
type: data['type'] == 'file' ? FileType.file : FileType.folder,
|
type: data['type'] == 'file' ? FileType.file : FileType.folder,
|
||||||
@@ -30,11 +51,67 @@ class FileService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> uploadFile(String orgId, FileItem file) async {
|
Future<void> uploadFile(String orgId, FileItem file) async {
|
||||||
throw UnimplementedError();
|
// If bytes or localPath available, send multipart upload with field 'file'
|
||||||
|
final Map<String, dynamic> fields = {'path': file.path};
|
||||||
|
FormData formData;
|
||||||
|
|
||||||
|
if (file.bytes != null) {
|
||||||
|
formData = FormData.fromMap({
|
||||||
|
...fields,
|
||||||
|
'file': MultipartFile.fromBytes(file.bytes!, filename: file.name),
|
||||||
|
});
|
||||||
|
} else if (file.localPath != null) {
|
||||||
|
formData = FormData.fromMap({
|
||||||
|
...fields,
|
||||||
|
'file': MultipartFile.fromFile(file.localPath!, filename: file.name),
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Fallback to metadata-only create (folders or client that can't send file content)
|
||||||
|
final data = {
|
||||||
|
'name': file.name,
|
||||||
|
'path': file.path,
|
||||||
|
'type': file.type == FileType.file ? 'file' : 'folder',
|
||||||
|
'size': file.size,
|
||||||
|
};
|
||||||
|
if (orgId.isEmpty) {
|
||||||
|
await _apiClient.post('/user/files', data: data, fromJson: (d) => null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await _apiClient.post(
|
||||||
|
'/orgs/$orgId/files',
|
||||||
|
data: data,
|
||||||
|
fromJson: (d) => null,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final endpoint = orgId.isEmpty ? '/user/files' : '/orgs/$orgId/files';
|
||||||
|
await _apiClient.post(endpoint, data: formData, fromJson: (d) => null);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> deleteFile(String orgId, String path) async {
|
Future<void> deleteFile(String orgId, String path) async {
|
||||||
throw UnimplementedError();
|
final data = {'path': path};
|
||||||
|
if (orgId.isEmpty) {
|
||||||
|
await _apiClient.post(
|
||||||
|
'/user/files/delete',
|
||||||
|
data: data,
|
||||||
|
fromJson: (d) => null,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await _apiClient.post(
|
||||||
|
'/orgs/$orgId/files/delete',
|
||||||
|
data: data,
|
||||||
|
fromJson: (d) => null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<String> getDownloadUrl(String orgId, String path) async {
|
||||||
|
// Return the download URL for the file
|
||||||
|
if (orgId.isEmpty) {
|
||||||
|
return '/user/files/download?path=${Uri.encodeComponent(path)}';
|
||||||
|
}
|
||||||
|
return '/orgs/$orgId/files/download?path=${Uri.encodeComponent(path)}';
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> createFolder(
|
Future<void> createFolder(
|
||||||
@@ -42,7 +119,33 @@ class FileService {
|
|||||||
String parentPath,
|
String parentPath,
|
||||||
String folderName,
|
String folderName,
|
||||||
) async {
|
) async {
|
||||||
throw UnimplementedError();
|
// Construct proper path: /parent/folder or /folder for root
|
||||||
|
String path;
|
||||||
|
if (parentPath == '/') {
|
||||||
|
path = '/$folderName';
|
||||||
|
} else {
|
||||||
|
// Ensure parentPath doesn't end with / before appending
|
||||||
|
final cleanParent = parentPath.endsWith('/')
|
||||||
|
? parentPath.substring(0, parentPath.length - 1)
|
||||||
|
: parentPath;
|
||||||
|
path = '$cleanParent/$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(
|
Future<void> moveFile(
|
||||||
@@ -65,11 +168,14 @@ class FileService {
|
|||||||
String orgId,
|
String orgId,
|
||||||
String fileId,
|
String fileId,
|
||||||
) async {
|
) async {
|
||||||
if (orgId.isEmpty || fileId.isEmpty) {
|
if (fileId.isEmpty) {
|
||||||
throw Exception('OrgId and fileId cannot be empty');
|
throw Exception('fileId cannot be empty');
|
||||||
}
|
}
|
||||||
|
final path = orgId.isEmpty
|
||||||
|
? '/user/files/$fileId/view'
|
||||||
|
: '/orgs/$orgId/files/$fileId/view';
|
||||||
return await _apiClient.get(
|
return await _apiClient.get(
|
||||||
'/orgs/$orgId/files/$fileId/view',
|
path,
|
||||||
fromJson: (data) => ViewerSession.fromJson(data),
|
fromJson: (data) => ViewerSession.fromJson(data),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
|
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
BIN
go_cloud/bin/api
@@ -1,23 +1,23 @@
|
|||||||
module go.b0esche.cloud/backend
|
module go.b0esche.cloud/backend
|
||||||
|
|
||||||
go 1.25.5
|
go 1.24.0
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
github.com/coreos/go-oidc/v3 v3.17.0
|
||||||
github.com/go-chi/chi/v5 v5.2.3
|
github.com/go-chi/chi/v5 v5.2.3
|
||||||
github.com/golang-jwt/jwt/v5 v5.3.0
|
github.com/golang-jwt/jwt/v4 v4.5.2
|
||||||
|
github.com/google/uuid v1.6.0
|
||||||
|
github.com/jackc/pgx/v5 v5.7.6
|
||||||
|
golang.org/x/crypto v0.37.0
|
||||||
|
golang.org/x/oauth2 v0.28.0
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/coreos/go-oidc/v3 v3.17.0 // indirect
|
|
||||||
github.com/go-jose/go-jose/v4 v4.1.3 // indirect
|
github.com/go-jose/go-jose/v4 v4.1.3 // indirect
|
||||||
github.com/golang-jwt/jwt/v4 v4.5.2 // indirect
|
|
||||||
github.com/google/uuid v1.6.0 // indirect
|
|
||||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||||
github.com/jackc/pgx/v5 v5.7.6 // indirect
|
|
||||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||||
golang.org/x/crypto v0.37.0 // indirect
|
|
||||||
golang.org/x/oauth2 v0.28.0 // indirect
|
|
||||||
golang.org/x/sync v0.13.0 // indirect
|
golang.org/x/sync v0.13.0 // indirect
|
||||||
|
golang.org/x/sys v0.32.0 // indirect
|
||||||
golang.org/x/text v0.24.0 // indirect
|
golang.org/x/text v0.24.0 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,14 +1,16 @@
|
|||||||
github.com/coreos/go-oidc/v3 v3.17.0 h1:hWBGaQfbi0iVviX4ibC7bk8OKT5qNr4klBaCHVNvehc=
|
github.com/coreos/go-oidc/v3 v3.17.0 h1:hWBGaQfbi0iVviX4ibC7bk8OKT5qNr4klBaCHVNvehc=
|
||||||
github.com/coreos/go-oidc/v3 v3.17.0/go.mod h1:wqPbKFrVnE90vty060SB40FCJ8fTHTxSwyXJqZH+sI8=
|
github.com/coreos/go-oidc/v3 v3.17.0/go.mod h1:wqPbKFrVnE90vty060SB40FCJ8fTHTxSwyXJqZH+sI8=
|
||||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/go-chi/chi/v5 v5.2.3 h1:WQIt9uxdsAbgIYgid+BpYc+liqQZGMHRaUwp0JUcvdE=
|
github.com/go-chi/chi/v5 v5.2.3 h1:WQIt9uxdsAbgIYgid+BpYc+liqQZGMHRaUwp0JUcvdE=
|
||||||
github.com/go-chi/chi/v5 v5.2.3/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops=
|
github.com/go-chi/chi/v5 v5.2.3/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops=
|
||||||
github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs=
|
github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs=
|
||||||
github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
|
github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
|
||||||
github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI=
|
github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI=
|
||||||
github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
|
github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
|
||||||
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
|
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
|
||||||
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
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/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 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||||
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
|
github.com/stretchr/testify v1.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 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE=
|
||||||
golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc=
|
golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc=
|
||||||
golang.org/x/oauth2 v0.28.0 h1:CrgCKl8PPAVtLnU3c+EDw6x11699EWlsDeWNWKdIOkc=
|
golang.org/x/oauth2 v0.28.0 h1:CrgCKl8PPAVtLnU3c+EDw6x11699EWlsDeWNWKdIOkc=
|
||||||
golang.org/x/oauth2 v0.28.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT8=
|
golang.org/x/oauth2 v0.28.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT8=
|
||||||
golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610=
|
golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610=
|
||||||
golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
||||||
|
golang.org/x/sys v0.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 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0=
|
||||||
golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU=
|
golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
|||||||
@@ -6,9 +6,11 @@ import (
|
|||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
"go.b0esche.cloud/backend/internal/database"
|
"go.b0esche.cloud/backend/internal/database"
|
||||||
|
"golang.org/x/crypto/argon2"
|
||||||
"golang.org/x/crypto/bcrypt"
|
"golang.org/x/crypto/bcrypt"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -17,6 +19,12 @@ const (
|
|||||||
RPID = "b0esche.cloud"
|
RPID = "b0esche.cloud"
|
||||||
RPName = "b0esche Cloud"
|
RPName = "b0esche Cloud"
|
||||||
Origin = "https://b0esche.cloud"
|
Origin = "https://b0esche.cloud"
|
||||||
|
|
||||||
|
// Argon2id parameters (OWASP recommendations)
|
||||||
|
Argon2Time = 2 // iterations
|
||||||
|
Argon2Memory = 19 * 1024 // 19 MB
|
||||||
|
Argon2Threads = 1
|
||||||
|
Argon2KeyLen = 32
|
||||||
)
|
)
|
||||||
|
|
||||||
type Service struct {
|
type Service struct {
|
||||||
@@ -284,19 +292,76 @@ func byteArraysEqual(a, b []byte) bool {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
// HashPassword hashes a password using bcrypt
|
// HashPassword hashes a password using Argon2id (quantum-resistant)
|
||||||
|
// Format: $argon2id$v=19$m=19456,t=2,p=1$<salt>$<hash>
|
||||||
func (s *Service) HashPassword(password string) (string, error) {
|
func (s *Service) HashPassword(password string) (string, error) {
|
||||||
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
// Generate 16-byte random salt
|
||||||
if err != nil {
|
salt := make([]byte, 16)
|
||||||
return "", fmt.Errorf("failed to hash password: %w", err)
|
if _, err := rand.Read(salt); err != nil {
|
||||||
|
return "", fmt.Errorf("failed to generate salt: %w", err)
|
||||||
}
|
}
|
||||||
return string(hash), nil
|
|
||||||
|
// Hash with Argon2id
|
||||||
|
hash := argon2.IDKey([]byte(password), salt, Argon2Time, Argon2Memory, Argon2Threads, Argon2KeyLen)
|
||||||
|
|
||||||
|
// Encode in PHC string format
|
||||||
|
b64Salt := base64.RawStdEncoding.EncodeToString(salt)
|
||||||
|
b64Hash := base64.RawStdEncoding.EncodeToString(hash)
|
||||||
|
|
||||||
|
return fmt.Sprintf("$argon2id$v=19$m=%d,t=%d,p=%d$%s$%s",
|
||||||
|
Argon2Memory, Argon2Time, Argon2Threads, b64Salt, b64Hash), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// VerifyPassword checks if a password matches its hash
|
// VerifyPassword checks if a password matches its hash
|
||||||
|
// Supports both Argon2id (new) and bcrypt (legacy) for backward compatibility
|
||||||
func (s *Service) VerifyPassword(passwordHash string, password string) bool {
|
func (s *Service) VerifyPassword(passwordHash string, password string) bool {
|
||||||
err := bcrypt.CompareHashAndPassword([]byte(passwordHash), []byte(password))
|
// Detect hash format
|
||||||
return err == nil
|
if strings.HasPrefix(passwordHash, "$argon2id$") {
|
||||||
|
return s.verifyArgon2(passwordHash, password)
|
||||||
|
} else if strings.HasPrefix(passwordHash, "$2") {
|
||||||
|
// Legacy bcrypt hash
|
||||||
|
err := bcrypt.CompareHashAndPassword([]byte(passwordHash), []byte(password))
|
||||||
|
return err == nil
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) verifyArgon2(encodedHash string, password string) bool {
|
||||||
|
// Parse PHC format: $argon2id$v=19$m=19456,t=2,p=1$<salt>$<hash>
|
||||||
|
parts := strings.Split(encodedHash, "$")
|
||||||
|
if len(parts) != 6 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
var memory, time uint32
|
||||||
|
var threads uint8
|
||||||
|
_, err := fmt.Sscanf(parts[3], "m=%d,t=%d,p=%d", &memory, &time, &threads)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
salt, err := base64.RawStdEncoding.DecodeString(parts[4])
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
hash, err := base64.RawStdEncoding.DecodeString(parts[5])
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compute hash with same parameters
|
||||||
|
computedHash := argon2.IDKey([]byte(password), salt, time, memory, threads, uint32(len(hash)))
|
||||||
|
|
||||||
|
// Constant-time comparison
|
||||||
|
if len(hash) != len(computedHash) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
var diff byte
|
||||||
|
for i := 0; i < len(hash); i++ {
|
||||||
|
diff |= hash[i] ^ computedHash[i]
|
||||||
|
}
|
||||||
|
return diff == 0
|
||||||
}
|
}
|
||||||
|
|
||||||
// VerifyPasswordLogin verifies username and password credentials
|
// VerifyPasswordLogin verifies username and password credentials
|
||||||
|
|||||||
@@ -12,6 +12,11 @@ type Config struct {
|
|||||||
OIDCClientID string
|
OIDCClientID string
|
||||||
OIDCClientSecret string
|
OIDCClientSecret string
|
||||||
JWTSecret string
|
JWTSecret string
|
||||||
|
NextcloudURL string
|
||||||
|
NextcloudUser string
|
||||||
|
NextcloudPass string
|
||||||
|
NextcloudBase string
|
||||||
|
AllowedOrigins string
|
||||||
}
|
}
|
||||||
|
|
||||||
func Load() *Config {
|
func Load() *Config {
|
||||||
@@ -23,6 +28,11 @@ func Load() *Config {
|
|||||||
OIDCClientID: os.Getenv("OIDC_CLIENT_ID"),
|
OIDCClientID: os.Getenv("OIDC_CLIENT_ID"),
|
||||||
OIDCClientSecret: os.Getenv("OIDC_CLIENT_SECRET"),
|
OIDCClientSecret: os.Getenv("OIDC_CLIENT_SECRET"),
|
||||||
JWTSecret: os.Getenv("JWT_SECRET"),
|
JWTSecret: os.Getenv("JWT_SECRET"),
|
||||||
|
NextcloudURL: os.Getenv("NEXTCLOUD_URL"),
|
||||||
|
NextcloudUser: os.Getenv("NEXTCLOUD_USER"),
|
||||||
|
NextcloudPass: os.Getenv("NEXTCLOUD_PASSWORD"),
|
||||||
|
NextcloudBase: getEnv("NEXTCLOUD_BASEPATH", "/"),
|
||||||
|
AllowedOrigins: getEnv("ALLOWED_ORIGINS", "https://b0esche.cloud,http://localhost:8080"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -55,10 +55,10 @@ type Session struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type Organization struct {
|
type Organization struct {
|
||||||
ID uuid.UUID
|
ID uuid.UUID `json:"id"`
|
||||||
Name string
|
Name string `json:"name"`
|
||||||
Slug string
|
Slug string `json:"slug"`
|
||||||
CreatedAt time.Time
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type Membership struct {
|
type Membership struct {
|
||||||
@@ -78,6 +78,18 @@ type Activity struct {
|
|||||||
Timestamp time.Time
|
Timestamp time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type File struct {
|
||||||
|
ID uuid.UUID
|
||||||
|
OrgID *uuid.UUID
|
||||||
|
UserID *uuid.UUID
|
||||||
|
Name string
|
||||||
|
Path string
|
||||||
|
Type string
|
||||||
|
Size int64
|
||||||
|
LastModified time.Time
|
||||||
|
CreatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
func (db *DB) GetOrCreateUser(ctx context.Context, sub, email, name string) (*User, error) {
|
func (db *DB) GetOrCreateUser(ctx context.Context, sub, email, name string) (*User, error) {
|
||||||
var user User
|
var user User
|
||||||
err := db.QueryRowContext(ctx, `
|
err := db.QueryRowContext(ctx, `
|
||||||
@@ -120,6 +132,15 @@ func (db *DB) GetSession(ctx context.Context, sessionID uuid.UUID) (*Session, er
|
|||||||
return &session, nil
|
return &session, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (db *DB) RevokeSession(ctx context.Context, sessionID uuid.UUID) error {
|
||||||
|
_, err := db.ExecContext(ctx, `
|
||||||
|
UPDATE sessions
|
||||||
|
SET revoked_at = NOW()
|
||||||
|
WHERE id = $1 AND revoked_at IS NULL
|
||||||
|
`, sessionID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
func (db *DB) GetUserOrganizations(ctx context.Context, userID uuid.UUID) ([]Organization, error) {
|
func (db *DB) GetUserOrganizations(ctx context.Context, userID uuid.UUID) ([]Organization, error) {
|
||||||
rows, err := db.QueryContext(ctx, `
|
rows, err := db.QueryContext(ctx, `
|
||||||
SELECT o.id, o.name, o.slug, o.created_at
|
SELECT o.id, o.name, o.slug, o.created_at
|
||||||
@@ -233,6 +254,153 @@ func (db *DB) GetOrgMembers(ctx context.Context, orgID uuid.UUID) ([]Membership,
|
|||||||
return memberships, rows.Err()
|
return memberships, rows.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetOrgFiles returns files for a given organization (top-level folder listing)
|
||||||
|
func (db *DB) GetOrgFiles(ctx context.Context, orgID uuid.UUID, path string, q string, page, pageSize int) ([]File, error) {
|
||||||
|
if page <= 0 {
|
||||||
|
page = 1
|
||||||
|
}
|
||||||
|
if pageSize <= 0 {
|
||||||
|
pageSize = 100
|
||||||
|
}
|
||||||
|
offset := (page - 1) * pageSize
|
||||||
|
|
||||||
|
// Basic search and pagination. Returns only direct children of the given path.
|
||||||
|
// For root ("/"), we want files where path doesn't contain "/" after the first character.
|
||||||
|
// For subdirs, we want files where path starts with parent but has no additional "/" after parent.
|
||||||
|
rows, err := db.QueryContext(ctx, `
|
||||||
|
SELECT id, org_id::text, user_id::text, name, path, type, size, last_modified, created_at
|
||||||
|
FROM files
|
||||||
|
WHERE org_id = $1
|
||||||
|
AND path != $2
|
||||||
|
AND (
|
||||||
|
($2 = '/' AND path LIKE '/%' AND path NOT LIKE '/%/%')
|
||||||
|
OR ($2 != '/' AND path LIKE $2 || '/%' AND path NOT LIKE $2 || '/%/%')
|
||||||
|
)
|
||||||
|
AND ($3 = '' OR name ILIKE '%' || $3 || '%')
|
||||||
|
ORDER BY name
|
||||||
|
LIMIT $4 OFFSET $5
|
||||||
|
`, orgID, path, q, pageSize, offset)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var files []File
|
||||||
|
for rows.Next() {
|
||||||
|
var f File
|
||||||
|
var orgNull sql.NullString
|
||||||
|
var userNull sql.NullString
|
||||||
|
if err := rows.Scan(&f.ID, &orgNull, &userNull, &f.Name, &f.Path, &f.Type, &f.Size, &f.LastModified, &f.CreatedAt); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if orgNull.Valid {
|
||||||
|
oid, _ := uuid.Parse(orgNull.String)
|
||||||
|
f.OrgID = &oid
|
||||||
|
}
|
||||||
|
if userNull.Valid {
|
||||||
|
uid, _ := uuid.Parse(userNull.String)
|
||||||
|
f.UserID = &uid
|
||||||
|
}
|
||||||
|
files = append(files, f)
|
||||||
|
}
|
||||||
|
return files, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetUserFiles returns files for a user's personal workspace at a given path
|
||||||
|
func (db *DB) GetUserFiles(ctx context.Context, userID uuid.UUID, path string, q string, page, pageSize int) ([]File, error) {
|
||||||
|
if page <= 0 {
|
||||||
|
page = 1
|
||||||
|
}
|
||||||
|
if pageSize <= 0 {
|
||||||
|
pageSize = 100
|
||||||
|
}
|
||||||
|
offset := (page - 1) * pageSize
|
||||||
|
|
||||||
|
// Return only direct children of the given path
|
||||||
|
rows, err := db.QueryContext(ctx, `
|
||||||
|
SELECT id, org_id::text, user_id::text, name, path, type, size, last_modified, created_at
|
||||||
|
FROM files
|
||||||
|
WHERE user_id = $1
|
||||||
|
AND path != $2
|
||||||
|
AND (
|
||||||
|
($2 = '/' AND path LIKE '/%' AND path NOT LIKE '/%/%')
|
||||||
|
OR ($2 != '/' AND path LIKE $2 || '/%' AND path NOT LIKE $2 || '/%/%')
|
||||||
|
)
|
||||||
|
AND ($3 = '' OR name ILIKE '%' || $3 || '%')
|
||||||
|
ORDER BY name
|
||||||
|
LIMIT $4 OFFSET $5
|
||||||
|
`, userID, path, q, pageSize, offset)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var files []File
|
||||||
|
for rows.Next() {
|
||||||
|
var f File
|
||||||
|
var orgNull sql.NullString
|
||||||
|
var userNull sql.NullString
|
||||||
|
if err := rows.Scan(&f.ID, &orgNull, &userNull, &f.Name, &f.Path, &f.Type, &f.Size, &f.LastModified, &f.CreatedAt); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if orgNull.Valid {
|
||||||
|
oid, _ := uuid.Parse(orgNull.String)
|
||||||
|
f.OrgID = &oid
|
||||||
|
}
|
||||||
|
if userNull.Valid {
|
||||||
|
uid, _ := uuid.Parse(userNull.String)
|
||||||
|
f.UserID = &uid
|
||||||
|
}
|
||||||
|
files = append(files, f)
|
||||||
|
}
|
||||||
|
return files, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateFile inserts a file or folder record. orgID or userID may be nil.
|
||||||
|
func (db *DB) CreateFile(ctx context.Context, orgID *uuid.UUID, userID *uuid.UUID, name, path, fileType string, size int64) (*File, error) {
|
||||||
|
var f File
|
||||||
|
var orgIDVal interface{}
|
||||||
|
var userIDVal interface{}
|
||||||
|
if orgID != nil {
|
||||||
|
orgIDVal = *orgID
|
||||||
|
} else {
|
||||||
|
orgIDVal = nil
|
||||||
|
}
|
||||||
|
if userID != nil {
|
||||||
|
userIDVal = *userID
|
||||||
|
} else {
|
||||||
|
userIDVal = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
err := db.QueryRowContext(ctx, `
|
||||||
|
INSERT INTO files (org_id, user_id, name, path, type, size)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6)
|
||||||
|
RETURNING id, org_id::text, user_id::text, name, path, type, size, last_modified, created_at
|
||||||
|
`, orgIDVal, userIDVal, name, path, fileType, size).Scan(&f.ID, new(sql.NullString), new(sql.NullString), &f.Name, &f.Path, &f.Type, &f.Size, &f.LastModified, &f.CreatedAt)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &f, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteFileByPath removes a file or folder matching path for a given org or user
|
||||||
|
func (db *DB) DeleteFileByPath(ctx context.Context, orgID *uuid.UUID, userID *uuid.UUID, path string) error {
|
||||||
|
var res sql.Result
|
||||||
|
var err error
|
||||||
|
if orgID != nil {
|
||||||
|
res, err = db.ExecContext(ctx, `DELETE FROM files WHERE org_id = $1 AND path = $2`, *orgID, path)
|
||||||
|
} else if userID != nil {
|
||||||
|
res, err = db.ExecContext(ctx, `DELETE FROM files WHERE user_id = $1 AND path = $2`, *userID, path)
|
||||||
|
} else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, _ = res.RowsAffected()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (db *DB) UpdateMemberRole(ctx context.Context, orgID, userID uuid.UUID, role string) error {
|
func (db *DB) UpdateMemberRole(ctx context.Context, orgID, userID uuid.UUID, role string) error {
|
||||||
_, err := db.ExecContext(ctx, `
|
_, err := db.ExecContext(ctx, `
|
||||||
UPDATE memberships
|
UPDATE memberships
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5/middleware"
|
chimiddleware "github.com/go-chi/chi/v5/middleware"
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -40,7 +40,7 @@ func WriteError(w http.ResponseWriter, code ErrorCode, message string, status in
|
|||||||
|
|
||||||
// GetRequestID extracts the request ID from the request context
|
// GetRequestID extracts the request ID from the request context
|
||||||
func GetRequestID(r *http.Request) string {
|
func GetRequestID(r *http.Request) string {
|
||||||
if reqID := middleware.GetReqID(r.Context()); reqID != "" {
|
if reqID := chimiddleware.GetReqID(r.Context()); reqID != "" {
|
||||||
return reqID
|
return reqID
|
||||||
}
|
}
|
||||||
return "unknown"
|
return "unknown"
|
||||||
@@ -48,10 +48,10 @@ func GetRequestID(r *http.Request) string {
|
|||||||
|
|
||||||
// GetUserID extracts user ID from context if available
|
// GetUserID extracts user ID from context if available
|
||||||
func GetUserID(r *http.Request) string {
|
func GetUserID(r *http.Request) string {
|
||||||
if userID := r.Context().Value("user"); userID != nil {
|
// Use type contextKey matching middleware package
|
||||||
if uid, ok := userID.(string); ok {
|
type contextKey string
|
||||||
return uid
|
if userID, ok := r.Context().Value(contextKey("user")).(string); ok && userID != "" {
|
||||||
}
|
return userID
|
||||||
}
|
}
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,15 @@
|
|||||||
package http
|
package http
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"mime/multipart"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path"
|
||||||
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -18,15 +25,19 @@ import (
|
|||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
"github.com/google/uuid"
|
"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 {
|
func NewRouter(cfg *config.Config, db *database.DB, jwtManager *jwt.Manager, authService *auth.Service, auditLogger *audit.Logger) http.Handler {
|
||||||
r := chi.NewRouter()
|
r := chi.NewRouter()
|
||||||
|
// optional WebDAV/Nextcloud client
|
||||||
|
storageClient := storage.NewWebDAVClient(cfg)
|
||||||
|
|
||||||
// Global middleware
|
// Global middleware
|
||||||
r.Use(middleware.RequestID)
|
r.Use(middleware.RequestID)
|
||||||
r.Use(middleware.Logger)
|
r.Use(middleware.Logger)
|
||||||
r.Use(middleware.Recoverer)
|
r.Use(middleware.Recoverer)
|
||||||
|
r.Use(middleware.CORS(cfg.AllowedOrigins))
|
||||||
r.Use(middleware.RateLimit)
|
r.Use(middleware.RateLimit)
|
||||||
|
|
||||||
// Health check
|
// 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) {
|
r.Post("/refresh", func(w http.ResponseWriter, req *http.Request) {
|
||||||
refreshHandler(w, req, jwtManager, db)
|
refreshHandler(w, req, jwtManager, db)
|
||||||
})
|
})
|
||||||
|
r.Post("/logout", func(w http.ResponseWriter, req *http.Request) {
|
||||||
|
logoutHandler(w, req, jwtManager, db, auditLogger)
|
||||||
|
})
|
||||||
// Passkey routes
|
// Passkey routes
|
||||||
r.Post("/signup", func(w http.ResponseWriter, req *http.Request) {
|
r.Post("/signup", func(w http.ResponseWriter, req *http.Request) {
|
||||||
signupHandler(w, req, db, auditLogger)
|
signupHandler(w, req, db, auditLogger)
|
||||||
@@ -59,49 +73,93 @@ func NewRouter(cfg *config.Config, db *database.DB, jwtManager *jwt.Manager, aut
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
// Auth middleware for protected routes
|
// Protected routes (with auth middleware)
|
||||||
r.Use(middleware.Auth(jwtManager, db))
|
r.Route("/", func(r chi.Router) {
|
||||||
|
r.Use(middleware.Auth(jwtManager, db))
|
||||||
|
|
||||||
// Org routes
|
// User-scoped routes (personal workspace)
|
||||||
r.Get("/orgs", func(w http.ResponseWriter, req *http.Request) {
|
r.Get("/user/files", func(w http.ResponseWriter, req *http.Request) {
|
||||||
listOrgsHandler(w, req, db, jwtManager)
|
userFilesHandler(w, req, db)
|
||||||
})
|
})
|
||||||
r.Post("/orgs", func(w http.ResponseWriter, req *http.Request) {
|
// User file viewer
|
||||||
createOrgHandler(w, req, db, auditLogger, jwtManager)
|
r.Get("/user/files/{fileId}/view", func(w http.ResponseWriter, req *http.Request) {
|
||||||
})
|
userViewerHandler(w, req, db, auditLogger)
|
||||||
|
})
|
||||||
|
// 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
|
// Org routes
|
||||||
r.Route("/orgs/{orgId}", func(r chi.Router) {
|
r.Get("/orgs", func(w http.ResponseWriter, req *http.Request) {
|
||||||
r.Use(middleware.Org(db, auditLogger))
|
listOrgsHandler(w, req, db, jwtManager)
|
||||||
|
})
|
||||||
|
r.Post("/orgs", func(w http.ResponseWriter, req *http.Request) {
|
||||||
|
createOrgHandler(w, req, db, auditLogger, jwtManager)
|
||||||
|
})
|
||||||
|
|
||||||
// File routes
|
// Org-scoped routes
|
||||||
r.With(middleware.Permission(db, auditLogger, permission.FileRead)).Get("/files", func(w http.ResponseWriter, req *http.Request) {
|
r.Route("/orgs/{orgId}", func(r chi.Router) {
|
||||||
listFilesHandler(w, req)
|
r.Use(middleware.Org(db, auditLogger))
|
||||||
})
|
|
||||||
r.Route("/files/{fileId}", func(r chi.Router) {
|
// File routes
|
||||||
r.With(middleware.Permission(db, auditLogger, permission.DocumentView)).Get("/view", func(w http.ResponseWriter, req *http.Request) {
|
r.With(middleware.Permission(db, auditLogger, permission.FileRead)).Get("/files", func(w http.ResponseWriter, req *http.Request) {
|
||||||
viewerHandler(w, req, db, auditLogger)
|
listFilesHandler(w, req, db)
|
||||||
})
|
})
|
||||||
r.With(middleware.Permission(db, auditLogger, permission.DocumentEdit)).Get("/edit", func(w http.ResponseWriter, req *http.Request) {
|
// Download org file
|
||||||
editorHandler(w, req, db, auditLogger)
|
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) {
|
// Also accept POST delete for clients that cannot send DELETE with body
|
||||||
fileMetaHandler(w, req)
|
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) {
|
}) // Close protected routes
|
||||||
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)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
return r
|
return r
|
||||||
}
|
}
|
||||||
@@ -149,6 +207,41 @@ func refreshHandler(w http.ResponseWriter, r *http.Request, jwtManager *jwt.Mana
|
|||||||
w.Write([]byte(`{"token": "` + newToken + `"}`))
|
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) {
|
func listOrgsHandler(w http.ResponseWriter, r *http.Request, db *database.DB, jwtManager *jwt.Manager) {
|
||||||
authHeader := r.Header.Get("Authorization")
|
authHeader := r.Header.Get("Authorization")
|
||||||
if !strings.HasPrefix(authHeader, "Bearer ") {
|
if !strings.HasPrefix(authHeader, "Bearer ") {
|
||||||
@@ -220,25 +313,50 @@ func createOrgHandler(w http.ResponseWriter, r *http.Request, db *database.DB, a
|
|||||||
json.NewEncoder(w).Encode(org)
|
json.NewEncoder(w).Encode(org)
|
||||||
}
|
}
|
||||||
|
|
||||||
func listFilesHandler(w http.ResponseWriter, r *http.Request) {
|
func listFilesHandler(w http.ResponseWriter, r *http.Request, db *database.DB) {
|
||||||
// Mock files
|
// Org ID is provided by middleware.Org
|
||||||
files := []struct {
|
orgID := r.Context().Value("org").(uuid.UUID)
|
||||||
Name string `json:"name"`
|
// Query params: path, q (search), page, pageSize
|
||||||
Path string `json:"path"`
|
path := r.URL.Query().Get("path")
|
||||||
Type string `json:"type"`
|
if path == "" {
|
||||||
Size int `json:"size"`
|
path = "/"
|
||||||
LastModified string `json:"lastModified"`
|
}
|
||||||
}{
|
q := r.URL.Query().Get("q")
|
||||||
{"test.pdf", "/test.pdf", "file", 1234, "2023-01-01T00:00:00Z"},
|
page := 1
|
||||||
{"folder", "/folder", "folder", 0, "2023-01-01T00:00:00Z"},
|
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{}{
|
||||||
|
"id": f.ID.String(),
|
||||||
|
"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")
|
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) {
|
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)
|
userID, _ := uuid.Parse(userIDStr)
|
||||||
orgID := r.Context().Value("org").(uuid.UUID)
|
orgID := r.Context().Value("org").(uuid.UUID)
|
||||||
fileId := chi.URLParam(r, "fileId")
|
fileId := chi.URLParam(r, "fileId")
|
||||||
@@ -268,8 +386,44 @@ func viewerHandler(w http.ResponseWriter, r *http.Request, db *database.DB, audi
|
|||||||
json.NewEncoder(w).Encode(session)
|
json.NewEncoder(w).Encode(session)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// userViewerHandler serves a viewer session for personal workspace files
|
||||||
|
func userViewerHandler(w http.ResponseWriter, r *http.Request, db *database.DB, auditLogger *audit.Logger) {
|
||||||
|
userIDStr, _ := middleware.GetUserID(r.Context())
|
||||||
|
userID, _ := uuid.Parse(userIDStr)
|
||||||
|
fileId := chi.URLParam(r, "fileId")
|
||||||
|
|
||||||
|
// For now, return a synthetic viewer session similar to org viewer
|
||||||
|
session := struct {
|
||||||
|
ViewUrl string `json:"viewUrl"`
|
||||||
|
Capabilities struct {
|
||||||
|
CanEdit bool `json:"canEdit"`
|
||||||
|
CanAnnotate bool `json:"canAnnotate"`
|
||||||
|
IsPdf bool `json:"isPdf"`
|
||||||
|
} `json:"capabilities"`
|
||||||
|
ExpiresAt string `json:"expiresAt"`
|
||||||
|
}{
|
||||||
|
ViewUrl: "https://view.example.com/" + fileId,
|
||||||
|
Capabilities: struct {
|
||||||
|
CanEdit bool `json:"canEdit"`
|
||||||
|
CanAnnotate bool `json:"canAnnotate"`
|
||||||
|
IsPdf bool `json:"isPdf"`
|
||||||
|
}{
|
||||||
|
CanEdit: false,
|
||||||
|
CanAnnotate: true,
|
||||||
|
IsPdf: strings.HasSuffix(strings.ToLower(fileId), ".pdf"),
|
||||||
|
},
|
||||||
|
ExpiresAt: time.Now().Add(15 * time.Minute).UTC().Format(time.RFC3339),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Optionally log activity without org id
|
||||||
|
db.LogActivity(r.Context(), userID, uuid.Nil, &fileId, "view_user_file", map[string]interface{}{})
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(session)
|
||||||
|
}
|
||||||
|
|
||||||
func editorHandler(w http.ResponseWriter, r *http.Request, db *database.DB, auditLogger *audit.Logger) {
|
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)
|
userID, _ := uuid.Parse(userIDStr)
|
||||||
orgID := r.Context().Value("org").(uuid.UUID)
|
orgID := r.Context().Value("org").(uuid.UUID)
|
||||||
fileId := chi.URLParam(r, "fileId")
|
fileId := chi.URLParam(r, "fileId")
|
||||||
@@ -292,7 +446,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) {
|
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)
|
userID, _ := uuid.Parse(userIDStr)
|
||||||
orgID := r.Context().Value("org").(uuid.UUID)
|
orgID := r.Context().Value("org").(uuid.UUID)
|
||||||
fileId := chi.URLParam(r, "fileId")
|
fileId := chi.URLParam(r, "fileId")
|
||||||
@@ -541,7 +695,7 @@ func registrationVerifyHandler(w http.ResponseWriter, r *http.Request, db *datab
|
|||||||
|
|
||||||
// Generate JWT
|
// Generate JWT
|
||||||
orgIDs := []string{}
|
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 {
|
if err != nil {
|
||||||
errors.LogError(r, err, "Token generation failed")
|
errors.LogError(r, err, "Token generation failed")
|
||||||
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
|
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
|
||||||
@@ -643,7 +797,7 @@ func authenticationVerifyHandler(w http.ResponseWriter, r *http.Request, db *dat
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Generate JWT
|
// 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 {
|
if err != nil {
|
||||||
errors.LogError(r, err, "Token generation failed")
|
errors.LogError(r, err, "Token generation failed")
|
||||||
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
|
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
|
||||||
@@ -713,7 +867,7 @@ func passwordLoginHandler(w http.ResponseWriter, r *http.Request, db *database.D
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Generate JWT
|
// 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 {
|
if err != nil {
|
||||||
errors.LogError(r, err, "Token generation failed")
|
errors.LogError(r, err, "Token generation failed")
|
||||||
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
|
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
|
||||||
@@ -732,3 +886,500 @@ func passwordLoginHandler(w http.ResponseWriter, r *http.Request, db *database.D
|
|||||||
"user": user,
|
"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{}{
|
||||||
|
"id": f.ID.String(),
|
||||||
|
"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,40 @@ var RequestID = middleware.RequestID
|
|||||||
var Logger = middleware.Logger
|
var Logger = middleware.Logger
|
||||||
var Recoverer = middleware.Recoverer
|
var Recoverer = middleware.Recoverer
|
||||||
|
|
||||||
|
// CORS middleware - accepts allowedOrigins comma-separated string
|
||||||
|
func CORS(allowedOrigins string) func(http.Handler) http.Handler {
|
||||||
|
return func(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
origin := r.Header.Get("Origin")
|
||||||
|
// Check if origin is allowed
|
||||||
|
if origin != "" {
|
||||||
|
// Simple check - in production you'd want to parse allowedOrigins properly
|
||||||
|
for _, allowed := range strings.Split(allowedOrigins, ",") {
|
||||||
|
if strings.TrimSpace(allowed) == origin {
|
||||||
|
w.Header().Set("Access-Control-Allow-Origin", origin)
|
||||||
|
w.Header().Set("Access-Control-Allow-Credentials", "true")
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Fallback to * if no credentials needed
|
||||||
|
if w.Header().Get("Access-Control-Allow-Origin") == "" {
|
||||||
|
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||||
|
}
|
||||||
|
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS")
|
||||||
|
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
|
||||||
|
w.Header().Set("Access-Control-Max-Age", "3600")
|
||||||
|
|
||||||
|
if r.Method == http.MethodOptions {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// TODO: Implement rate limiter
|
// TODO: Implement rate limiter
|
||||||
var RateLimit = func(next http.Handler) http.Handler {
|
var RateLimit = func(next http.Handler) http.Handler {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -37,6 +71,18 @@ const (
|
|||||||
orgKey contextKey = "org"
|
orgKey contextKey = "org"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// GetUserID retrieves the user ID from the request context
|
||||||
|
func GetUserID(ctx context.Context) (string, bool) {
|
||||||
|
userID, ok := ctx.Value(userKey).(string)
|
||||||
|
return userID, ok
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetSession retrieves the session from the request context
|
||||||
|
func GetSession(ctx context.Context) (*database.Session, bool) {
|
||||||
|
session, ok := ctx.Value(sessionKey).(*database.Session)
|
||||||
|
return session, ok
|
||||||
|
}
|
||||||
|
|
||||||
// Auth middleware
|
// Auth middleware
|
||||||
func Auth(jwtManager *jwt.Manager, db *database.DB) func(http.Handler) http.Handler {
|
func Auth(jwtManager *jwt.Manager, db *database.DB) func(http.Handler) http.Handler {
|
||||||
return func(next http.Handler) http.Handler {
|
return func(next http.Handler) http.Handler {
|
||||||
|
|||||||
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! ==="
|
||||||