Compare commits
168 Commits
dev
...
6ce43a3c9b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6ce43a3c9b | ||
|
|
6943e95479 | ||
|
|
2e1096a9ad | ||
|
|
8d9db29db2 | ||
|
|
afeb7a35ad | ||
|
|
70039a8288 | ||
|
|
634aa521bd | ||
|
|
749672509b | ||
|
|
20e9ae3e4d | ||
|
|
3738b346e3 | ||
|
|
0aa281ac09 | ||
|
|
8baaad2c08 | ||
|
|
19825763ad | ||
|
|
c16cd49237 | ||
|
|
57eea172a2 | ||
|
|
d255f40a8c | ||
|
|
d59a655f0f | ||
|
|
509983b4ff | ||
|
|
5fb08d8831 | ||
|
|
18f5b3f98b | ||
|
|
d58137716f | ||
|
|
18138dde01 | ||
|
|
6db2bf077d | ||
|
|
42ee3057bf | ||
|
|
350eb27e30 | ||
|
|
2ded9b00f9 | ||
|
|
2daa9e9855 | ||
|
|
2fa5f0441a | ||
|
|
64690231c2 | ||
|
|
0ee3b32ef3 | ||
|
|
99419748bb | ||
|
|
89f55471ce | ||
|
|
ef60983534 | ||
|
|
8e06e7e17d | ||
|
|
0b822af438 | ||
|
|
69cf328972 | ||
|
|
181fb0af93 | ||
|
|
7bd1ab16da | ||
|
|
d52307c792 | ||
|
|
dbbad29f2f | ||
|
|
ec25f06ea3 | ||
|
|
36db2daabd | ||
|
|
ff51ef8a71 | ||
|
|
83f0fa0ecb | ||
|
|
1b20fe8b7f | ||
|
|
3e0094b11c | ||
|
|
f0fe439c3b | ||
|
|
d9c8c1e1f3 | ||
|
|
31ab3aad45 | ||
|
|
923b0ede86 | ||
|
|
80411d9231 | ||
|
|
675c2bf95d | ||
|
|
6ffe9aa4df | ||
|
|
1680914017 | ||
|
|
60df1a38ff | ||
|
|
af5c8f0e72 | ||
|
|
0378a0748a | ||
|
|
c330381281 | ||
|
|
6a4cd8c672 | ||
|
|
09d16abcd5 | ||
|
|
330bd86b96 | ||
|
|
d6277f5469 | ||
|
|
c8eb0aefe3 | ||
|
|
17d10e5815 | ||
|
|
ef737429d6 | ||
|
|
2129d72a1f | ||
|
|
3d80072e7b | ||
|
|
5ef5623c8d | ||
|
|
b2e5eef66f | ||
|
|
68270b6906 | ||
|
|
9d466fd63a | ||
|
|
619b2fe23c | ||
|
|
ac1bd2749c | ||
|
|
a3a9360110 | ||
|
|
f0d6d0b8e1 | ||
|
|
b09cdde8d3 | ||
|
|
e5aee55dca | ||
|
|
615c92dc5f | ||
|
|
bd6dd68f0b | ||
|
|
e9517a5a4d | ||
|
|
39e0eb0efd | ||
|
|
a51c0e070c | ||
|
|
56526c8fc5 | ||
|
|
1151abab28 | ||
|
|
35b2095544 | ||
|
|
7259aa41fd | ||
|
|
9952323252 | ||
|
|
d3ed7fd4f3 | ||
|
|
fd4224d1da | ||
|
|
ed22c5eda4 | ||
|
|
acb9b5f71c | ||
|
|
e34d09f762 | ||
|
|
9b03695d61 | ||
|
|
e36a4e5785 | ||
|
|
7cf55325d4 | ||
|
|
6186c4c779 | ||
|
|
9b10b1f6f1 | ||
|
|
0ce9185373 | ||
|
|
4a4e03e041 | ||
|
|
18600a6bc1 | ||
|
|
2f20241ba6 | ||
|
|
7aaca1d1f4 | ||
|
|
185cbc83b9 | ||
|
|
e64925b438 | ||
|
|
6c864612db | ||
|
|
288363d2da | ||
|
|
54fa429e3e | ||
|
|
0f13b6c01d | ||
|
|
f372172898 | ||
|
|
fa6ba846d8 | ||
|
|
1366807b25 | ||
|
|
22868b2958 | ||
|
|
84c7ed0815 | ||
|
|
941d8bf736 | ||
|
|
b381a46483 | ||
|
|
5669385616 | ||
|
|
0797b407a5 | ||
|
|
f3656fdbd0 | ||
|
|
687c7a5a61 | ||
|
|
6a3a2f6701 | ||
|
|
f86c44224e | ||
|
|
7f6e7f7a10 | ||
|
|
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 '../session/session_bloc.dart';
|
||||
import '../session/session_event.dart';
|
||||
import '../session/session_state.dart';
|
||||
import 'auth_event.dart';
|
||||
import 'auth_state.dart';
|
||||
import '../../services/api_client.dart';
|
||||
@@ -252,7 +253,22 @@ class AuthBloc extends Bloc<AuthEvent, AuthState> {
|
||||
CheckAuthRequested event,
|
||||
Emitter<AuthState> emit,
|
||||
) async {
|
||||
// Check if token is valid in SessionBloc
|
||||
emit(AuthUnauthenticated());
|
||||
// Check if session is active from persistent storage
|
||||
final sessionState = sessionBloc.state;
|
||||
|
||||
if (sessionState is SessionActive) {
|
||||
// Session already active - emit authenticated state with minimal info
|
||||
// The full user info will be fetched when needed
|
||||
emit(
|
||||
AuthAuthenticated(
|
||||
token: sessionState.token,
|
||||
userId: '',
|
||||
username: '',
|
||||
email: '',
|
||||
),
|
||||
);
|
||||
} else {
|
||||
emit(AuthUnauthenticated());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,6 +34,8 @@ class DocumentViewerBloc
|
||||
DocumentViewerReady(
|
||||
viewUrl: session.viewUrl,
|
||||
caps: session.capabilities,
|
||||
token: session.token,
|
||||
fileInfo: session.fileInfo,
|
||||
),
|
||||
);
|
||||
_expiryTimer = Timer(
|
||||
|
||||
@@ -15,11 +15,18 @@ class DocumentViewerLoading extends DocumentViewerState {}
|
||||
class DocumentViewerReady extends DocumentViewerState {
|
||||
final Uri viewUrl;
|
||||
final DocumentCapabilities caps;
|
||||
final String token;
|
||||
final FileInfo? fileInfo;
|
||||
|
||||
const DocumentViewerReady({required this.viewUrl, required this.caps});
|
||||
const DocumentViewerReady({
|
||||
required this.viewUrl,
|
||||
required this.caps,
|
||||
required this.token,
|
||||
this.fileInfo,
|
||||
});
|
||||
|
||||
@override
|
||||
List<Object> get props => [viewUrl, caps];
|
||||
List<Object> get props => [viewUrl, caps, token, if (fileInfo != null) fileInfo!];
|
||||
}
|
||||
|
||||
class DocumentViewerError extends DocumentViewerState {
|
||||
|
||||
@@ -52,9 +52,13 @@ class EditorSessionBloc extends Bloc<EditorSessionEvent, EditorSessionState> {
|
||||
return;
|
||||
}
|
||||
if (!session.readOnly) {
|
||||
emit(EditorSessionActive(editUrl: session.editUrl));
|
||||
emit(
|
||||
EditorSessionActive(editUrl: session.editUrl, token: session.token),
|
||||
);
|
||||
} else {
|
||||
emit(EditorSessionReadOnly(viewUrl: session.editUrl));
|
||||
emit(
|
||||
EditorSessionReadOnly(viewUrl: session.editUrl, token: session.token),
|
||||
);
|
||||
}
|
||||
_expiryTimer = Timer(
|
||||
session.expiresAt.difference(DateTime.now()),
|
||||
|
||||
@@ -13,20 +13,22 @@ class EditorSessionStarting extends EditorSessionState {}
|
||||
|
||||
class EditorSessionActive extends EditorSessionState {
|
||||
final Uri editUrl;
|
||||
final String token;
|
||||
|
||||
const EditorSessionActive({required this.editUrl});
|
||||
const EditorSessionActive({required this.editUrl, required this.token});
|
||||
|
||||
@override
|
||||
List<Object> get props => [editUrl];
|
||||
List<Object> get props => [editUrl, token];
|
||||
}
|
||||
|
||||
class EditorSessionReadOnly extends EditorSessionState {
|
||||
final Uri viewUrl;
|
||||
final String token;
|
||||
|
||||
const EditorSessionReadOnly({required this.viewUrl});
|
||||
const EditorSessionReadOnly({required this.viewUrl, required this.token});
|
||||
|
||||
@override
|
||||
List<Object> get props => [viewUrl];
|
||||
List<Object> get props => [viewUrl, token];
|
||||
}
|
||||
|
||||
class EditorSessionFailed extends EditorSessionState {
|
||||
|
||||
@@ -123,24 +123,8 @@ class FileBrowserBloc extends Bloc<FileBrowserEvent, FileBrowserState> {
|
||||
event.parentPath,
|
||||
event.folderName,
|
||||
);
|
||||
// Add the new folder to local state if in current directory
|
||||
if (event.parentPath == _currentPath) {
|
||||
final newFolder = FileItem(
|
||||
name: event.folderName,
|
||||
path: '${event.parentPath}/${event.folderName}',
|
||||
type: FileType.folder,
|
||||
size: 0,
|
||||
lastModified: DateTime.now(),
|
||||
);
|
||||
_currentFiles.add(newFolder);
|
||||
_currentFiles = _sortFiles(_currentFiles, _sortBy, _isAscending);
|
||||
_filteredFiles = _currentFiles
|
||||
.where((f) => f.name.toLowerCase().contains(_currentFilter))
|
||||
.toList();
|
||||
_emitLoadedState(emit);
|
||||
} else {
|
||||
add(LoadDirectory(orgId: event.orgId, path: event.parentPath));
|
||||
}
|
||||
// Reload directory to get the folder with proper ID from backend
|
||||
add(LoadDirectory(orgId: event.orgId, path: event.parentPath));
|
||||
} catch (e) {
|
||||
emit(DirectoryError(_getErrorMessage(e)));
|
||||
}
|
||||
@@ -192,7 +176,8 @@ class FileBrowserBloc extends Bloc<FileBrowserEvent, FileBrowserState> {
|
||||
void _onDeleteFile(DeleteFile event, Emitter<FileBrowserState> emit) async {
|
||||
try {
|
||||
await _fileService.deleteFile(event.orgId, event.path);
|
||||
_currentFiles.removeWhere((f) => f.path == event.path);
|
||||
// Create new list to trigger Equatable change detection
|
||||
_currentFiles = _currentFiles.where((f) => f.path != event.path).toList();
|
||||
_filteredFiles = _currentFiles
|
||||
.where((f) => f.name.toLowerCase().contains(_currentFilter))
|
||||
.toList();
|
||||
@@ -206,9 +191,9 @@ class FileBrowserBloc extends Bloc<FileBrowserEvent, FileBrowserState> {
|
||||
ResetFileBrowser event,
|
||||
Emitter<FileBrowserState> emit,
|
||||
) {
|
||||
emit(DirectoryInitial());
|
||||
_currentOrgId = '';
|
||||
_currentPath = '/';
|
||||
_currentFiles = [];
|
||||
_filteredFiles = [];
|
||||
_currentFilter = '';
|
||||
_currentPage = 1;
|
||||
_pageSize = 20;
|
||||
@@ -277,6 +262,12 @@ class FileBrowserBloc extends Bloc<FileBrowserEvent, FileBrowserState> {
|
||||
) {
|
||||
final sorted = List<FileItem>.from(files);
|
||||
sorted.sort((a, b) {
|
||||
// Always put folders first, then files
|
||||
if (a.type != b.type) {
|
||||
return a.type == FileType.folder ? -1 : 1;
|
||||
}
|
||||
|
||||
// Within the same type (both folders or both files), sort by the selected criterion
|
||||
switch (sortBy) {
|
||||
case 'name':
|
||||
return isAscending
|
||||
@@ -291,12 +282,7 @@ class FileBrowserBloc extends Bloc<FileBrowserEvent, FileBrowserState> {
|
||||
? a.size.compareTo(b.size)
|
||||
: b.size.compareTo(a.size);
|
||||
case 'type':
|
||||
// Folders before files if ascending, else files before folders
|
||||
int typeCompare = isAscending
|
||||
? a.type.index.compareTo(b.type.index)
|
||||
: b.type.index.compareTo(a.type.index);
|
||||
if (typeCompare != 0) return typeCompare;
|
||||
// Within same type, sort by name
|
||||
// Already handled above (folders vs files)
|
||||
return isAscending
|
||||
? a.name.compareTo(b.name)
|
||||
: b.name.compareTo(a.name);
|
||||
|
||||
@@ -62,7 +62,14 @@ class CreateFolder extends FileBrowserEvent {
|
||||
List<Object> get props => [orgId, parentPath, folderName];
|
||||
}
|
||||
|
||||
class ResetFileBrowser extends FileBrowserEvent {}
|
||||
class ResetFileBrowser extends FileBrowserEvent {
|
||||
final String nextOrgId;
|
||||
|
||||
const ResetFileBrowser(this.nextOrgId);
|
||||
|
||||
@override
|
||||
List<Object> get props => [nextOrgId];
|
||||
}
|
||||
|
||||
class LoadPage extends FileBrowserEvent {
|
||||
final int page;
|
||||
|
||||
@@ -73,10 +73,18 @@ class OrganizationBloc extends Bloc<OrganizationEvent, OrganizationState> {
|
||||
) {
|
||||
final currentState = state;
|
||||
if (currentState is OrganizationLoaded) {
|
||||
final selected = currentState.organizations.firstWhere(
|
||||
(org) => org.id == event.orgId,
|
||||
orElse: () => currentState.selectedOrg!,
|
||||
);
|
||||
Organization? selected;
|
||||
|
||||
if (event.orgId.isEmpty) {
|
||||
// Personal workspace - set to null to indicate no org selected
|
||||
selected = null;
|
||||
} else {
|
||||
selected = currentState.organizations.firstWhere(
|
||||
(org) => org.id == event.orgId,
|
||||
orElse: () => currentState.selectedOrg!,
|
||||
);
|
||||
}
|
||||
|
||||
emit(
|
||||
OrganizationLoaded(
|
||||
organizations: currentState.organizations,
|
||||
@@ -86,7 +94,7 @@ class OrganizationBloc extends Bloc<OrganizationEvent, OrganizationState> {
|
||||
);
|
||||
// Reset all dependent blocs
|
||||
permissionBloc.add(PermissionsReset());
|
||||
fileBrowserBloc.add(ResetFileBrowser());
|
||||
fileBrowserBloc.add(ResetFileBrowser(event.orgId));
|
||||
uploadBloc.add(ResetUploads());
|
||||
// Load permissions for the selected org
|
||||
permissionBloc.add(LoadPermissions(event.orgId));
|
||||
@@ -97,59 +105,72 @@ class OrganizationBloc extends Bloc<OrganizationEvent, OrganizationState> {
|
||||
CreateOrganization event,
|
||||
Emitter<OrganizationState> emit,
|
||||
) async {
|
||||
final currentState = state;
|
||||
if (currentState is OrganizationLoaded) {
|
||||
final name = event.name.trim();
|
||||
if (name.isEmpty) {
|
||||
final name = event.name.trim();
|
||||
if (name.isEmpty) {
|
||||
// Try to preserve current state if possible
|
||||
if (state is OrganizationLoaded) {
|
||||
emit(
|
||||
OrganizationLoaded(
|
||||
organizations: currentState.organizations,
|
||||
selectedOrg: currentState.selectedOrg,
|
||||
organizations: (state as OrganizationLoaded).organizations,
|
||||
selectedOrg: (state as OrganizationLoaded).selectedOrg,
|
||||
isLoading: false,
|
||||
error: 'Organization name cannot be empty',
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (currentState.organizations.any((org) => org.name == name)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Get existing organizations list
|
||||
List<Organization> existingOrgs = [];
|
||||
Organization? selectedOrg;
|
||||
|
||||
if (state is OrganizationLoaded) {
|
||||
existingOrgs = (state as OrganizationLoaded).organizations;
|
||||
selectedOrg = (state as OrganizationLoaded).selectedOrg;
|
||||
|
||||
// Check for duplicate name (client-side validation)
|
||||
if (existingOrgs.any((org) => org.name == name)) {
|
||||
emit(
|
||||
OrganizationLoaded(
|
||||
organizations: currentState.organizations,
|
||||
selectedOrg: currentState.selectedOrg,
|
||||
organizations: existingOrgs,
|
||||
selectedOrg: selectedOrg,
|
||||
isLoading: false,
|
||||
error: 'Organization with this name already exists',
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Set loading state
|
||||
emit(
|
||||
OrganizationLoaded(
|
||||
organizations: existingOrgs,
|
||||
selectedOrg: selectedOrg,
|
||||
isLoading: true,
|
||||
),
|
||||
);
|
||||
|
||||
try {
|
||||
final newOrg = await orgApi.createOrganization(name);
|
||||
|
||||
final updatedOrgs = [...existingOrgs, newOrg];
|
||||
emit(OrganizationLoaded(organizations: updatedOrgs, selectedOrg: newOrg));
|
||||
// Reset blocs and load permissions for new org
|
||||
permissionBloc.add(PermissionsReset());
|
||||
fileBrowserBloc.add(ResetFileBrowser(newOrg.id));
|
||||
uploadBloc.add(ResetUploads());
|
||||
permissionBloc.add(LoadPermissions(newOrg.id));
|
||||
} catch (e) {
|
||||
emit(
|
||||
OrganizationLoaded(
|
||||
organizations: currentState.organizations,
|
||||
selectedOrg: currentState.selectedOrg,
|
||||
isLoading: true,
|
||||
organizations: existingOrgs,
|
||||
selectedOrg: selectedOrg,
|
||||
isLoading: false,
|
||||
error: _getErrorMessage(e),
|
||||
),
|
||||
);
|
||||
try {
|
||||
final newOrg = await orgApi.createOrganization(name);
|
||||
final updatedOrgs = [...currentState.organizations, newOrg];
|
||||
emit(
|
||||
OrganizationLoaded(organizations: updatedOrgs, selectedOrg: newOrg),
|
||||
);
|
||||
// Reset blocs and load permissions for new org
|
||||
permissionBloc.add(PermissionsReset());
|
||||
fileBrowserBloc.add(ResetFileBrowser());
|
||||
uploadBloc.add(ResetUploads());
|
||||
permissionBloc.add(LoadPermissions(newOrg.id));
|
||||
} catch (e) {
|
||||
emit(
|
||||
OrganizationLoaded(
|
||||
organizations: currentState.organizations,
|
||||
selectedOrg: currentState.selectedOrg,
|
||||
isLoading: false,
|
||||
error: _getErrorMessage(e),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,11 +16,12 @@ class PermissionBloc extends Bloc<PermissionEvent, PermissionState> {
|
||||
// Simulate loading permissions from backend for orgId
|
||||
await Future.delayed(const Duration(seconds: 1));
|
||||
// Mock capabilities based on orgId
|
||||
// Allow all permissions for authenticated users (proper permissions should come from backend)
|
||||
final capabilities = Capabilities(
|
||||
canRead: true,
|
||||
canWrite: event.orgId == 'org1', // Only admin for personal
|
||||
canShare: event.orgId == 'org1',
|
||||
canAdmin: event.orgId == 'org1',
|
||||
canWrite: true,
|
||||
canShare: true,
|
||||
canAdmin: true,
|
||||
canAnnotate: true,
|
||||
canEdit: true,
|
||||
);
|
||||
|
||||
@@ -1,42 +1,104 @@
|
||||
import 'dart:async';
|
||||
import 'package:bloc/bloc.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'session_event.dart';
|
||||
import 'session_state.dart';
|
||||
|
||||
class SessionBloc extends Bloc<SessionEvent, SessionState> {
|
||||
Timer? _expiryTimer;
|
||||
static const String _tokenKey = 'auth_token';
|
||||
static const String _expiryKey = 'auth_expiry';
|
||||
|
||||
SessionBloc() : super(SessionInitial()) {
|
||||
on<SessionStarted>(_onSessionStarted);
|
||||
on<SessionExpired>(_onSessionExpired);
|
||||
on<SessionRefreshed>(_onSessionRefreshed);
|
||||
on<SessionEnded>(_onSessionEnded);
|
||||
on<SessionRestored>(_onSessionRestored);
|
||||
}
|
||||
|
||||
void _onSessionStarted(SessionStarted event, Emitter<SessionState> emit) {
|
||||
void _onSessionStarted(
|
||||
SessionStarted event,
|
||||
Emitter<SessionState> emit,
|
||||
) async {
|
||||
final expiresAt = DateTime.now().add(
|
||||
const Duration(minutes: 15),
|
||||
); // Match Go
|
||||
|
||||
// Save token to persistent storage
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString(_tokenKey, event.token);
|
||||
await prefs.setString(_expiryKey, expiresAt.toIso8601String());
|
||||
|
||||
emit(SessionActive(token: event.token, expiresAt: expiresAt));
|
||||
_startExpiryTimer(expiresAt);
|
||||
}
|
||||
|
||||
void _onSessionExpired(SessionExpired event, Emitter<SessionState> emit) {
|
||||
_expiryTimer?.cancel();
|
||||
_clearStoredSession();
|
||||
emit(SessionExpiredState());
|
||||
}
|
||||
|
||||
void _onSessionRefreshed(SessionRefreshed event, Emitter<SessionState> emit) {
|
||||
void _onSessionRefreshed(
|
||||
SessionRefreshed event,
|
||||
Emitter<SessionState> emit,
|
||||
) async {
|
||||
final expiresAt = DateTime.now().add(const Duration(minutes: 15));
|
||||
|
||||
// Update stored token
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString(_tokenKey, event.newToken);
|
||||
await prefs.setString(_expiryKey, expiresAt.toIso8601String());
|
||||
|
||||
emit(SessionActive(token: event.newToken, expiresAt: expiresAt));
|
||||
_startExpiryTimer(expiresAt);
|
||||
}
|
||||
|
||||
void _onSessionEnded(SessionEnded event, Emitter<SessionState> emit) {
|
||||
_expiryTimer?.cancel();
|
||||
_clearStoredSession();
|
||||
emit(SessionInitial());
|
||||
}
|
||||
|
||||
void _onSessionRestored(SessionRestored event, Emitter<SessionState> emit) {
|
||||
final expiresAt = event.expiresAt;
|
||||
final now = DateTime.now();
|
||||
|
||||
// Check if token is still valid
|
||||
if (expiresAt.isAfter(now)) {
|
||||
emit(SessionActive(token: event.token, expiresAt: expiresAt));
|
||||
_startExpiryTimer(expiresAt);
|
||||
} else {
|
||||
// Token expired, clear it
|
||||
_clearStoredSession();
|
||||
emit(SessionInitial());
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _clearStoredSession() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.remove(_tokenKey);
|
||||
await prefs.remove(_expiryKey);
|
||||
}
|
||||
|
||||
static Future<void> restoreSession(SessionBloc bloc) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final token = prefs.getString(_tokenKey);
|
||||
final expiryStr = prefs.getString(_expiryKey);
|
||||
|
||||
if (token != null && expiryStr != null) {
|
||||
try {
|
||||
final expiresAt = DateTime.parse(expiryStr);
|
||||
bloc.add(SessionRestored(token: token, expiresAt: expiresAt));
|
||||
} catch (e) {
|
||||
// Invalid stored data, clear it
|
||||
await prefs.remove(_tokenKey);
|
||||
await prefs.remove(_expiryKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _startExpiryTimer(DateTime expiresAt) {
|
||||
_expiryTimer?.cancel();
|
||||
final duration = expiresAt.difference(DateTime.now());
|
||||
|
||||
@@ -28,3 +28,13 @@ class SessionRefreshed extends SessionEvent {
|
||||
}
|
||||
|
||||
class SessionEnded extends SessionEvent {}
|
||||
|
||||
class SessionRestored extends SessionEvent {
|
||||
final String token;
|
||||
final DateTime expiresAt;
|
||||
|
||||
const SessionRestored({required this.token, required this.expiresAt});
|
||||
|
||||
@override
|
||||
List<Object> get props => [token, expiresAt];
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ class UploadBloc extends Bloc<UploadEvent, UploadState> {
|
||||
try {
|
||||
// Simulate upload
|
||||
await _fileRepository.uploadFile(event.orgId, file);
|
||||
|
||||
add(UploadCompleted(file));
|
||||
} catch (e) {
|
||||
add(UploadFailed(fileName: file.name, error: e.toString()));
|
||||
|
||||
@@ -1,24 +1,33 @@
|
||||
import 'package:b0esche_cloud/services/api_client.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
import 'blocs/session/session_bloc.dart';
|
||||
import 'repositories/auth_repository.dart';
|
||||
import 'repositories/file_repository.dart';
|
||||
import 'repositories/mock_auth_repository.dart';
|
||||
import 'repositories/mock_file_repository.dart';
|
||||
import 'repositories/http_auth_repository.dart';
|
||||
import 'repositories/http_file_repository.dart';
|
||||
import 'services/auth_service.dart';
|
||||
import 'services/file_service.dart';
|
||||
import 'services/org_api.dart';
|
||||
import 'viewmodels/login_view_model.dart';
|
||||
import 'viewmodels/file_explorer_view_model.dart';
|
||||
|
||||
final getIt = GetIt.instance;
|
||||
|
||||
void configureDependencies() {
|
||||
// Register repositories
|
||||
getIt.registerSingleton<AuthRepository>(MockAuthRepository());
|
||||
getIt.registerSingleton<FileRepository>(MockFileRepository());
|
||||
void configureDependencies(SessionBloc sessionBloc) {
|
||||
// Register ApiClient first
|
||||
final apiClient = ApiClient(sessionBloc);
|
||||
getIt.registerSingleton<ApiClient>(apiClient);
|
||||
|
||||
// Register repositories (HTTP-backed)
|
||||
getIt.registerSingleton<AuthRepository>(HttpAuthRepository(apiClient));
|
||||
getIt.registerSingleton<FileRepository>(
|
||||
HttpFileRepository(FileService(apiClient)),
|
||||
);
|
||||
|
||||
// Register services
|
||||
getIt.registerSingleton<AuthService>(AuthService(getIt<AuthRepository>()));
|
||||
getIt.registerSingleton<FileService>(FileService(getIt<ApiClient>()));
|
||||
getIt.registerSingleton<OrgApi>(OrgApi(getIt<ApiClient>()));
|
||||
|
||||
// Register viewmodels
|
||||
getIt.registerSingleton<LoginViewModel>(LoginViewModel(getIt<AuthService>()));
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'blocs/auth/auth_bloc.dart';
|
||||
import 'blocs/auth/auth_event.dart';
|
||||
import 'blocs/session/session_bloc.dart';
|
||||
import 'blocs/activity/activity_bloc.dart';
|
||||
import 'services/api_client.dart';
|
||||
@@ -11,6 +12,7 @@ import 'pages/file_explorer.dart';
|
||||
import 'pages/document_viewer.dart';
|
||||
import 'pages/editor_page.dart';
|
||||
import 'theme/app_theme.dart';
|
||||
import 'injection.dart';
|
||||
|
||||
final GoRouter _router = GoRouter(
|
||||
routes: [
|
||||
@@ -41,29 +43,80 @@ void main() {
|
||||
runApp(const MainApp());
|
||||
}
|
||||
|
||||
class MainApp extends StatelessWidget {
|
||||
class MainApp extends StatefulWidget {
|
||||
const MainApp({super.key});
|
||||
|
||||
@override
|
||||
State<MainApp> createState() => _MainAppState();
|
||||
}
|
||||
|
||||
class _MainAppState extends State<MainApp> {
|
||||
final _sessionBloc = SessionBloc();
|
||||
late final AuthBloc _authBloc;
|
||||
late final Future<void> _restoreFuture;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// Configure DI first
|
||||
configureDependencies(_sessionBloc);
|
||||
|
||||
// Create AuthBloc first
|
||||
_authBloc = AuthBloc(
|
||||
apiClient: ApiClient(_sessionBloc),
|
||||
sessionBloc: _sessionBloc,
|
||||
);
|
||||
|
||||
// Restore session and then check auth
|
||||
_restoreFuture = SessionBloc.restoreSession(_sessionBloc).then((_) {
|
||||
// After session is restored, check if we should auto-authenticate
|
||||
if (mounted) {
|
||||
_authBloc.add(const CheckAuthRequested());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MultiBlocProvider(
|
||||
providers: [
|
||||
BlocProvider<SessionBloc>(create: (_) => SessionBloc()),
|
||||
BlocProvider<AuthBloc>(
|
||||
create: (context) => AuthBloc(
|
||||
apiClient: ApiClient(context.read<SessionBloc>()),
|
||||
sessionBloc: context.read<SessionBloc>(),
|
||||
),
|
||||
),
|
||||
BlocProvider<SessionBloc>.value(value: _sessionBloc),
|
||||
BlocProvider<AuthBloc>.value(value: _authBloc),
|
||||
BlocProvider<ActivityBloc>(
|
||||
create: (context) =>
|
||||
ActivityBloc(ActivityApi(ApiClient(context.read<SessionBloc>()))),
|
||||
ActivityBloc(ActivityApi(ApiClient(_sessionBloc))),
|
||||
),
|
||||
],
|
||||
child: MaterialApp.router(
|
||||
routerConfig: _router,
|
||||
theme: AppTheme.darkTheme,
|
||||
child: FutureBuilder<void>(
|
||||
future: _restoreFuture,
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.connectionState != ConnectionState.done) {
|
||||
return MaterialApp(
|
||||
theme: AppTheme.darkTheme,
|
||||
home: const Scaffold(
|
||||
body: Center(
|
||||
child: CircularProgressIndicator(
|
||||
valueColor: AlwaysStoppedAnimation<Color>(
|
||||
AppTheme.accentColor,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
return MaterialApp.router(
|
||||
routerConfig: _router,
|
||||
theme: AppTheme.darkTheme,
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_authBloc.close();
|
||||
_sessionBloc.close();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,24 +1,62 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
|
||||
class FileInfo extends Equatable {
|
||||
final String name;
|
||||
final int size;
|
||||
final DateTime? lastModified;
|
||||
final String? modifiedByName;
|
||||
|
||||
const FileInfo({
|
||||
required this.name,
|
||||
required this.size,
|
||||
this.lastModified,
|
||||
this.modifiedByName,
|
||||
});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [name, size, lastModified, modifiedByName];
|
||||
|
||||
factory FileInfo.fromJson(Map<String, dynamic> json) {
|
||||
return FileInfo(
|
||||
name: json['name'] ?? '',
|
||||
size: json['size'] ?? 0,
|
||||
lastModified: json['lastModified'] != null
|
||||
? DateTime.tryParse(json['lastModified'])
|
||||
: null,
|
||||
modifiedByName: json['modifiedByName'],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class DocumentCapabilities extends Equatable {
|
||||
final bool canEdit;
|
||||
final bool canAnnotate;
|
||||
final bool isPdf;
|
||||
final String mimeType;
|
||||
|
||||
const DocumentCapabilities({
|
||||
required this.canEdit,
|
||||
required this.canAnnotate,
|
||||
required this.isPdf,
|
||||
required this.mimeType,
|
||||
});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [canEdit, canAnnotate, isPdf];
|
||||
List<Object?> get props => [canEdit, canAnnotate, isPdf, mimeType];
|
||||
|
||||
factory DocumentCapabilities.fromJson(Map<String, dynamic> json) {
|
||||
return DocumentCapabilities(
|
||||
canEdit: json['canEdit'],
|
||||
canAnnotate: json['canAnnotate'],
|
||||
isPdf: json['isPdf'],
|
||||
mimeType: json['mimeType'] ?? 'application/octet-stream',
|
||||
);
|
||||
}
|
||||
|
||||
bool get isImage => mimeType.startsWith('image/');
|
||||
bool get isText => mimeType.startsWith('text/');
|
||||
bool get isOffice =>
|
||||
mimeType.contains('word') ||
|
||||
mimeType.contains('spreadsheet') ||
|
||||
mimeType.contains('presentation');
|
||||
}
|
||||
|
||||
@@ -2,21 +2,24 @@ import 'package:equatable/equatable.dart';
|
||||
|
||||
class EditorSession extends Equatable {
|
||||
final Uri editUrl;
|
||||
final String token;
|
||||
final bool readOnly;
|
||||
final DateTime expiresAt;
|
||||
|
||||
const EditorSession({
|
||||
required this.editUrl,
|
||||
required this.token,
|
||||
required this.readOnly,
|
||||
required this.expiresAt,
|
||||
});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [editUrl, readOnly, expiresAt];
|
||||
List<Object?> get props => [editUrl, token, readOnly, expiresAt];
|
||||
|
||||
factory EditorSession.fromJson(Map<String, dynamic> json) {
|
||||
return EditorSession(
|
||||
editUrl: Uri.parse(json['editUrl']),
|
||||
token: json['token'] ?? '',
|
||||
readOnly: json['readOnly'],
|
||||
expiresAt: DateTime.parse(json['expiresAt']),
|
||||
);
|
||||
|
||||
@@ -1,26 +1,34 @@
|
||||
import 'package:equatable/equatable.dart';
|
||||
import 'dart:typed_data';
|
||||
|
||||
enum FileType { folder, file }
|
||||
|
||||
class FileItem extends Equatable {
|
||||
final String? id;
|
||||
final String name;
|
||||
final String path;
|
||||
final FileType type;
|
||||
final int size; // in bytes, 0 for folders
|
||||
final DateTime lastModified;
|
||||
final String? localPath; // optional local file path for uploads
|
||||
final Uint8List? bytes; // optional file bytes for web/desktop uploads
|
||||
|
||||
const FileItem({
|
||||
this.id,
|
||||
required this.name,
|
||||
required this.path,
|
||||
required this.type,
|
||||
this.size = 0,
|
||||
required this.lastModified,
|
||||
this.localPath,
|
||||
this.bytes,
|
||||
});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [name, path, type, size, lastModified];
|
||||
List<Object?> get props => [id, name, path, type, size, lastModified];
|
||||
|
||||
FileItem copyWith({
|
||||
String? id,
|
||||
String? name,
|
||||
String? path,
|
||||
FileType? type,
|
||||
@@ -28,6 +36,7 @@ class FileItem extends Equatable {
|
||||
DateTime? lastModified,
|
||||
}) {
|
||||
return FileItem(
|
||||
id: id ?? this.id,
|
||||
name: name ?? this.name,
|
||||
path: path ?? this.path,
|
||||
type: type ?? this.type,
|
||||
|
||||
@@ -6,16 +6,18 @@ class ViewerSession extends Equatable {
|
||||
final DocumentCapabilities capabilities;
|
||||
final String token;
|
||||
final DateTime expiresAt;
|
||||
final FileInfo? fileInfo;
|
||||
|
||||
const ViewerSession({
|
||||
required this.viewUrl,
|
||||
required this.capabilities,
|
||||
required this.token,
|
||||
required this.expiresAt,
|
||||
this.fileInfo,
|
||||
});
|
||||
|
||||
@override
|
||||
List<Object?> get props => [viewUrl, capabilities, token, expiresAt];
|
||||
List<Object?> get props => [viewUrl, capabilities, token, expiresAt, fileInfo];
|
||||
|
||||
factory ViewerSession.fromJson(Map<String, dynamic> json) {
|
||||
return ViewerSession(
|
||||
@@ -23,6 +25,7 @@ class ViewerSession extends Equatable {
|
||||
capabilities: DocumentCapabilities.fromJson(json['capabilities']),
|
||||
token: json['token'],
|
||||
expiresAt: DateTime.parse(json['expiresAt']),
|
||||
fileInfo: json['fileInfo'] != null ? FileInfo.fromJson(json['fileInfo']) : null,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,548 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'dart:convert';
|
||||
import 'package:web/web.dart' as web;
|
||||
import 'dart:ui_web' as ui;
|
||||
import '../theme/app_theme.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import '../blocs/document_viewer/document_viewer_bloc.dart';
|
||||
import '../blocs/document_viewer/document_viewer_event.dart';
|
||||
import '../blocs/document_viewer/document_viewer_state.dart';
|
||||
import '../blocs/session/session_bloc.dart';
|
||||
import '../blocs/session/session_state.dart';
|
||||
import '../services/file_service.dart';
|
||||
import '../injection.dart';
|
||||
import 'package:syncfusion_flutter_pdfviewer/pdfviewer.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
// Modal version for overlay display
|
||||
class DocumentViewerModal extends StatefulWidget {
|
||||
final String orgId;
|
||||
final String fileId;
|
||||
final VoidCallback onClose;
|
||||
final VoidCallback onEdit;
|
||||
|
||||
const DocumentViewerModal({
|
||||
super.key,
|
||||
required this.orgId,
|
||||
required this.fileId,
|
||||
required this.onClose,
|
||||
required this.onEdit,
|
||||
});
|
||||
|
||||
@override
|
||||
State<DocumentViewerModal> createState() => _DocumentViewerModalState();
|
||||
}
|
||||
|
||||
class _DocumentViewerModalState extends State<DocumentViewerModal> {
|
||||
late DocumentViewerBloc _viewerBloc;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_viewerBloc = DocumentViewerBloc(getIt<FileService>());
|
||||
_viewerBloc.add(DocumentOpened(orgId: widget.orgId, fileId: widget.fileId));
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocProvider.value(
|
||||
value: _viewerBloc,
|
||||
child: Column(
|
||||
children: [
|
||||
// Custom AppBar
|
||||
Container(
|
||||
height: 56,
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.primaryBackground.withValues(alpha: 0.9),
|
||||
border: Border(
|
||||
bottom: BorderSide(
|
||||
color: AppTheme.accentColor.withValues(alpha: 0.3),
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const SizedBox(width: 16),
|
||||
const Text(
|
||||
'Document Viewer',
|
||||
style: TextStyle(
|
||||
color: AppTheme.primaryText,
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
BlocBuilder<DocumentViewerBloc, DocumentViewerState>(
|
||||
builder: (context, state) {
|
||||
if (state is DocumentViewerReady && state.caps.canEdit) {
|
||||
return IconButton(
|
||||
icon: const Icon(
|
||||
Icons.edit,
|
||||
color: AppTheme.primaryText,
|
||||
),
|
||||
onPressed: widget.onEdit,
|
||||
);
|
||||
}
|
||||
return const SizedBox.shrink();
|
||||
},
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.refresh, color: AppTheme.primaryText),
|
||||
splashColor: Colors.transparent,
|
||||
highlightColor: Colors.transparent,
|
||||
onPressed: () {
|
||||
_viewerBloc.add(DocumentReloaded());
|
||||
},
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.close, color: AppTheme.primaryText),
|
||||
splashColor: Colors.transparent,
|
||||
highlightColor: Colors.transparent,
|
||||
onPressed: () {
|
||||
_viewerBloc.add(DocumentClosed());
|
||||
widget.onClose();
|
||||
},
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
),
|
||||
),
|
||||
// Meta info bar
|
||||
BlocBuilder<DocumentViewerBloc, DocumentViewerState>(
|
||||
builder: (context, state) {
|
||||
if (state is DocumentViewerReady) {
|
||||
final fileInfo = state.fileInfo;
|
||||
String lastModifiedText = 'Last modified: Unknown';
|
||||
if (fileInfo != null) {
|
||||
final modifiedDate = fileInfo.lastModified;
|
||||
final modifiedBy = fileInfo.modifiedByName;
|
||||
if (modifiedDate != null) {
|
||||
final formattedDate = '${modifiedDate.day.toString().padLeft(2, '0')}.${modifiedDate.month.toString().padLeft(2, '0')}.${modifiedDate.year} ${modifiedDate.hour.toString().padLeft(2, '0')}:${modifiedDate.minute.toString().padLeft(2, '0')}';
|
||||
if (modifiedBy != null && modifiedBy.isNotEmpty) {
|
||||
lastModifiedText = 'Last modified: $formattedDate by $modifiedBy';
|
||||
} else {
|
||||
lastModifiedText = 'Last modified: $formattedDate';
|
||||
}
|
||||
}
|
||||
}
|
||||
return Container(
|
||||
height: 30,
|
||||
alignment: Alignment.centerLeft,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.primaryBackground.withValues(alpha: 0.3),
|
||||
),
|
||||
child: Text(
|
||||
lastModifiedText,
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: AppTheme.secondaryText,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
return const SizedBox.shrink();
|
||||
},
|
||||
),
|
||||
// Document content
|
||||
Expanded(
|
||||
child: BlocBuilder<DocumentViewerBloc, DocumentViewerState>(
|
||||
builder: (context, state) {
|
||||
if (state is DocumentViewerLoading) {
|
||||
return Container(
|
||||
color: AppTheme.primaryBackground,
|
||||
child: const Center(
|
||||
child: CircularProgressIndicator(
|
||||
valueColor: AlwaysStoppedAnimation<Color>(
|
||||
AppTheme.accentColor,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
if (state is DocumentViewerError) {
|
||||
return Center(
|
||||
child: Text(
|
||||
'Error: ${state.message}',
|
||||
style: const TextStyle(color: AppTheme.primaryText),
|
||||
),
|
||||
);
|
||||
}
|
||||
if (state is DocumentViewerSessionExpired) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Text(
|
||||
'Your viewing session expired. Click to reopen.',
|
||||
style: TextStyle(color: AppTheme.primaryText),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
ElevatedButton(
|
||||
onPressed: () {
|
||||
_viewerBloc.add(
|
||||
DocumentOpened(
|
||||
orgId: widget.orgId,
|
||||
fileId: widget.fileId,
|
||||
),
|
||||
);
|
||||
},
|
||||
child: const Text('Reload'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
if (state is DocumentViewerReady) {
|
||||
// Handle different file types based on MIME type
|
||||
if (state.caps.isPdf) {
|
||||
// PDF viewer using SfPdfViewer
|
||||
return SfPdfViewer.network(
|
||||
state.viewUrl.toString(),
|
||||
headers: {'Authorization': 'Bearer ${state.token}'},
|
||||
onDocumentLoadFailed: (details) {},
|
||||
onDocumentLoaded: (PdfDocumentLoadedDetails details) {},
|
||||
);
|
||||
} else if (state.caps.isImage) {
|
||||
// Image viewer
|
||||
return Container(
|
||||
color: AppTheme.primaryBackground,
|
||||
child: InteractiveViewer(
|
||||
minScale: 0.5,
|
||||
maxScale: 4.0,
|
||||
child: Image.network(
|
||||
state.viewUrl.toString(),
|
||||
headers: {'Authorization': 'Bearer ${state.token}'},
|
||||
fit: BoxFit.contain,
|
||||
errorBuilder: (context, error, stackTrace) {
|
||||
return Center(
|
||||
child: Text(
|
||||
'Failed to load image',
|
||||
style: TextStyle(color: Colors.red[400]),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
} else if (state.caps.isText) {
|
||||
// Text file viewer
|
||||
return FutureBuilder<String>(
|
||||
future: _fetchTextContent(
|
||||
state.viewUrl.toString(),
|
||||
state.token,
|
||||
),
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.connectionState ==
|
||||
ConnectionState.waiting) {
|
||||
return Center(
|
||||
child: CircularProgressIndicator(
|
||||
valueColor: AlwaysStoppedAnimation<Color>(
|
||||
AppTheme.accentColor,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
if (snapshot.hasError) {
|
||||
return Center(
|
||||
child: Text(
|
||||
'Error loading text: ${snapshot.error}',
|
||||
style: TextStyle(color: Colors.red[400]),
|
||||
),
|
||||
);
|
||||
}
|
||||
return SingleChildScrollView(
|
||||
child: Container(
|
||||
color: AppTheme.primaryBackground,
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: SelectableText(
|
||||
snapshot.data ?? '',
|
||||
style: TextStyle(
|
||||
color: AppTheme.primaryText,
|
||||
fontFamily: 'Courier New',
|
||||
fontSize: 13,
|
||||
height: 1.5,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
} else if (state.caps.isOffice) {
|
||||
// Office document viewer using Collabora Online
|
||||
return _buildCollaboraViewer(
|
||||
state.viewUrl.toString(),
|
||||
state.token,
|
||||
);
|
||||
} else {
|
||||
// Unknown file type
|
||||
return Container(
|
||||
color: AppTheme.primaryBackground,
|
||||
child: Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.description,
|
||||
size: 64,
|
||||
color: AppTheme.accentColor,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'File Type Not Supported',
|
||||
style: TextStyle(
|
||||
color: AppTheme.primaryText,
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'MIME Type: ${state.caps.mimeType}',
|
||||
style: TextStyle(
|
||||
color: AppTheme.secondaryText,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
return const Center(
|
||||
child: Text(
|
||||
'No document loaded',
|
||||
style: TextStyle(color: AppTheme.primaryText),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<String> _fetchTextContent(String url, String token) async {
|
||||
try {
|
||||
final response = await http
|
||||
.get(Uri.parse(url), headers: {'Authorization': 'Bearer $token'})
|
||||
.timeout(const Duration(seconds: 30));
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
return response.body;
|
||||
} else {
|
||||
throw Exception('Failed to load text: ${response.statusCode}');
|
||||
}
|
||||
} catch (e) {
|
||||
throw Exception('Error fetching text: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildCollaboraViewer(String documentUrl, String token) {
|
||||
// Create WOPI session to get WOPISrc URL
|
||||
return FutureBuilder<WOPISession>(
|
||||
future: _createWOPISession(token),
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.connectionState == ConnectionState.waiting) {
|
||||
return Container(
|
||||
color: AppTheme.primaryBackground,
|
||||
child: Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
CircularProgressIndicator(
|
||||
valueColor: AlwaysStoppedAnimation<Color>(
|
||||
AppTheme.accentColor,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'Loading document in Collabora Online...',
|
||||
style: TextStyle(
|
||||
color: AppTheme.secondaryText,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (snapshot.hasError) {
|
||||
return Container(
|
||||
color: AppTheme.primaryBackground,
|
||||
child: Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(Icons.error_outline, size: 64, color: Colors.red[400]),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'Failed to load document',
|
||||
style: TextStyle(
|
||||
color: AppTheme.primaryText,
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'${snapshot.error}',
|
||||
style: TextStyle(
|
||||
color: AppTheme.secondaryText,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (!snapshot.hasData) {
|
||||
return Container(
|
||||
color: AppTheme.primaryBackground,
|
||||
child: const Center(
|
||||
child: Text(
|
||||
'No session data',
|
||||
style: TextStyle(color: AppTheme.primaryText),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final wopiSession = snapshot.data!;
|
||||
|
||||
// Use backend proxy endpoint to serve the Collabora form
|
||||
final proxyUrl = _buildProxyUrl(token);
|
||||
return _buildWebView(proxyUrl);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
String _buildProxyUrl(String token) {
|
||||
// Build the proxy URL based on whether we're in org or user workspace
|
||||
String baseUrl = 'https://go.b0esche.cloud';
|
||||
String endpoint;
|
||||
|
||||
if (widget.orgId.isNotEmpty && widget.orgId != 'personal') {
|
||||
endpoint = '/orgs/${widget.orgId}/files/${widget.fileId}/collabora-proxy';
|
||||
} else {
|
||||
endpoint = '/user/files/${widget.fileId}/collabora-proxy';
|
||||
}
|
||||
|
||||
// Pass token as query parameter for iframe (which cannot send Authorization header)
|
||||
return '$baseUrl$endpoint?token=$token';
|
||||
}
|
||||
|
||||
Future<WOPISession> _createWOPISession(String token) async {
|
||||
try {
|
||||
// Use default base URL from backend
|
||||
String baseUrl = 'https://go.b0esche.cloud';
|
||||
|
||||
// Determine endpoint based on whether we're in org or user workspace
|
||||
String endpoint;
|
||||
if (widget.orgId.isNotEmpty && widget.orgId != 'personal') {
|
||||
endpoint = '/orgs/${widget.orgId}/files/${widget.fileId}/wopi-session';
|
||||
} else {
|
||||
endpoint = '/user/files/${widget.fileId}/wopi-session';
|
||||
}
|
||||
|
||||
final response = await http
|
||||
.post(
|
||||
Uri.parse('$baseUrl$endpoint'),
|
||||
headers: {
|
||||
'Authorization': 'Bearer $token',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
)
|
||||
.timeout(const Duration(seconds: 10));
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
final json = jsonDecode(response.body) as Map<String, dynamic>;
|
||||
return WOPISession(
|
||||
wopisrc: json['wopi_src'] as String,
|
||||
accessToken: json['access_token'] as String,
|
||||
);
|
||||
} else {
|
||||
throw Exception(
|
||||
'Failed to create WOPI session: ${response.statusCode}',
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
throw Exception('Error creating WOPI session: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildCollaboraIframe(String proxyUrl) {
|
||||
// Load the backend proxy page which handles Collabora form submission
|
||||
final String viewType =
|
||||
'collabora-${DateTime.now().millisecondsSinceEpoch}';
|
||||
|
||||
ui.platformViewRegistry.registerViewFactory(viewType, (int viewId) {
|
||||
// Create iframe pointing to the proxy endpoint
|
||||
final iframe = web.HTMLIFrameElement()
|
||||
..style.border = 'none'
|
||||
..style.width = '100%'
|
||||
..style.height = '100%'
|
||||
..style.margin = '0'
|
||||
..style.padding = '0'
|
||||
..src = proxyUrl
|
||||
..setAttribute(
|
||||
'allow',
|
||||
'microphone; camera; usb; autoplay; clipboard-read; clipboard-write; fullscreen',
|
||||
)
|
||||
..setAttribute(
|
||||
'sandbox',
|
||||
'allow-same-origin allow-scripts allow-popups allow-popups-to-escape-sandbox allow-forms allow-presentation',
|
||||
);
|
||||
|
||||
final container = web.HTMLDivElement()
|
||||
..style.width = '100%'
|
||||
..style.height = '100%'
|
||||
..style.margin = '0'
|
||||
..style.padding = '0'
|
||||
..style.overflow = 'hidden'
|
||||
..append(iframe);
|
||||
|
||||
return container;
|
||||
});
|
||||
|
||||
return HtmlElementView(viewType: viewType);
|
||||
}
|
||||
|
||||
Widget _buildWebView(String proxyUrl) {
|
||||
// Embed Collabora Online via proxy endpoint
|
||||
return _buildCollaboraIframe(proxyUrl);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_viewerBloc.close();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
// WOPI Session model for Collabora Online integration
|
||||
class WOPISession {
|
||||
final String wopisrc;
|
||||
final String accessToken;
|
||||
|
||||
WOPISession({required this.wopisrc, required this.accessToken});
|
||||
|
||||
factory WOPISession.fromJson(Map<String, dynamic> json) {
|
||||
return WOPISession(
|
||||
wopisrc: json['wopi_src'] as String,
|
||||
accessToken: json['access_token'] as String,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Original page version (for routing if needed)
|
||||
class DocumentViewer extends StatefulWidget {
|
||||
final String orgId;
|
||||
final String fileId;
|
||||
@@ -41,13 +575,26 @@ class _DocumentViewerState extends State<DocumentViewer> {
|
||||
child: BlocBuilder<DocumentViewerBloc, DocumentViewerState>(
|
||||
builder: (context, state) {
|
||||
if (state is DocumentViewerReady) {
|
||||
// Placeholder for meta
|
||||
final fileInfo = state.fileInfo;
|
||||
String lastModifiedText = 'Last modified: Unknown';
|
||||
if (fileInfo != null) {
|
||||
final modifiedDate = fileInfo.lastModified;
|
||||
final modifiedBy = fileInfo.modifiedByName;
|
||||
if (modifiedDate != null) {
|
||||
final formattedDate = '${modifiedDate.day.toString().padLeft(2, '0')}.${modifiedDate.month.toString().padLeft(2, '0')}.${modifiedDate.year} ${modifiedDate.hour.toString().padLeft(2, '0')}:${modifiedDate.minute.toString().padLeft(2, '0')}';
|
||||
if (modifiedBy != null && modifiedBy.isNotEmpty) {
|
||||
lastModifiedText = 'Last modified: $formattedDate by $modifiedBy';
|
||||
} else {
|
||||
lastModifiedText = 'Last modified: $formattedDate';
|
||||
}
|
||||
}
|
||||
}
|
||||
return Container(
|
||||
height: 30,
|
||||
alignment: Alignment.centerLeft,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Text(
|
||||
'Last modified: Unknown by Unknown (v1)',
|
||||
lastModifiedText,
|
||||
style: const TextStyle(fontSize: 12),
|
||||
),
|
||||
);
|
||||
@@ -94,7 +641,16 @@ class _DocumentViewerState extends State<DocumentViewer> {
|
||||
body: BlocBuilder<DocumentViewerBloc, DocumentViewerState>(
|
||||
builder: (context, state) {
|
||||
if (state is DocumentViewerLoading) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
return Container(
|
||||
color: AppTheme.primaryBackground,
|
||||
child: const Center(
|
||||
child: CircularProgressIndicator(
|
||||
valueColor: AlwaysStoppedAnimation<Color>(
|
||||
AppTheme.accentColor,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
if (state is DocumentViewerError) {
|
||||
return Center(child: Text('Error: ${state.message}'));
|
||||
@@ -123,22 +679,133 @@ class _DocumentViewerState extends State<DocumentViewer> {
|
||||
);
|
||||
}
|
||||
if (state is DocumentViewerReady) {
|
||||
if (state.caps.isPdf) {
|
||||
// Use PDF viewer
|
||||
return SfPdfViewer.network(state.viewUrl.toString());
|
||||
} else {
|
||||
// Placeholder for office docs iframe
|
||||
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 BlocBuilder<SessionBloc, SessionState>(
|
||||
builder: (context, sessionState) {
|
||||
String? token;
|
||||
if (sessionState is SessionActive) {
|
||||
token = sessionState.token;
|
||||
}
|
||||
|
||||
if (state.caps.isPdf) {
|
||||
// PDF viewer using SfPdfViewer
|
||||
return SfPdfViewer.network(
|
||||
state.viewUrl.toString(),
|
||||
headers: token != null
|
||||
? {'Authorization': 'Bearer $token'}
|
||||
: {},
|
||||
onDocumentLoadFailed: (details) {},
|
||||
onDocumentLoaded: (PdfDocumentLoadedDetails details) {},
|
||||
);
|
||||
} else if (state.caps.isImage) {
|
||||
// Image viewer
|
||||
return Container(
|
||||
color: AppTheme.primaryBackground,
|
||||
child: InteractiveViewer(
|
||||
minScale: 0.5,
|
||||
maxScale: 4.0,
|
||||
child: Image.network(
|
||||
state.viewUrl.toString(),
|
||||
headers: token != null
|
||||
? {'Authorization': 'Bearer $token'}
|
||||
: {},
|
||||
fit: BoxFit.contain,
|
||||
errorBuilder: (context, error, stackTrace) {
|
||||
return Center(
|
||||
child: Text(
|
||||
'Failed to load image',
|
||||
style: TextStyle(color: Colors.red[400]),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
} else if (state.caps.isText) {
|
||||
// Text file viewer
|
||||
return FutureBuilder<String>(
|
||||
future: _fetchTextContent(
|
||||
state.viewUrl.toString(),
|
||||
token ?? '',
|
||||
),
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.connectionState ==
|
||||
ConnectionState.waiting) {
|
||||
return Center(
|
||||
child: CircularProgressIndicator(
|
||||
valueColor: AlwaysStoppedAnimation<Color>(
|
||||
AppTheme.accentColor,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
if (snapshot.hasError) {
|
||||
return Center(
|
||||
child: Text(
|
||||
'Error loading text: ${snapshot.error}',
|
||||
style: TextStyle(color: Colors.red[400]),
|
||||
),
|
||||
);
|
||||
}
|
||||
return SingleChildScrollView(
|
||||
child: Container(
|
||||
color: AppTheme.primaryBackground,
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: SelectableText(
|
||||
snapshot.data ?? '',
|
||||
style: TextStyle(
|
||||
color: AppTheme.primaryText,
|
||||
fontFamily: 'Courier New',
|
||||
fontSize: 13,
|
||||
height: 1.5,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
} else if (state.caps.isOffice) {
|
||||
// Office document viewer using Collabora Online
|
||||
return _buildCollaboraViewerPage(
|
||||
state.viewUrl.toString(),
|
||||
token ?? '',
|
||||
);
|
||||
} else {
|
||||
// Unknown file type
|
||||
return Container(
|
||||
color: AppTheme.primaryBackground,
|
||||
child: Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.description,
|
||||
size: 64,
|
||||
color: AppTheme.accentColor,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'File Type Not Supported',
|
||||
style: TextStyle(
|
||||
color: AppTheme.primaryText,
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'MIME Type: ${state.caps.mimeType}',
|
||||
style: TextStyle(
|
||||
color: AppTheme.secondaryText,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
return const Center(child: Text('No document loaded'));
|
||||
},
|
||||
@@ -147,6 +814,73 @@ class _DocumentViewerState extends State<DocumentViewer> {
|
||||
);
|
||||
}
|
||||
|
||||
Future<String> _fetchTextContent(String url, String token) async {
|
||||
try {
|
||||
final response = await http
|
||||
.get(Uri.parse(url), headers: {'Authorization': 'Bearer $token'})
|
||||
.timeout(const Duration(seconds: 30));
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
return response.body;
|
||||
} else {
|
||||
throw Exception('Failed to load text: ${response.statusCode}');
|
||||
}
|
||||
} catch (e) {
|
||||
throw Exception('Error fetching text: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildCollaboraViewerPage(String documentUrl, String token) {
|
||||
// Build HTML to embed Collabora Online viewer
|
||||
// For now, we'll show the document download option with a link to open in Collabora
|
||||
// A proper implementation would require WOPI protocol support
|
||||
|
||||
return Container(
|
||||
color: AppTheme.primaryBackground,
|
||||
child: Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(Icons.description, size: 64, color: AppTheme.accentColor),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'Office Document',
|
||||
style: TextStyle(
|
||||
color: AppTheme.primaryText,
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Collabora Online Viewer',
|
||||
style: TextStyle(color: AppTheme.secondaryText, fontSize: 14),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'Opening document in Collabora...',
|
||||
style: TextStyle(color: AppTheme.secondaryText, fontSize: 12),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
CircularProgressIndicator(
|
||||
valueColor: AlwaysStoppedAnimation<Color>(AppTheme.accentColor),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
ElevatedButton.icon(
|
||||
icon: const Icon(Icons.download),
|
||||
label: const Text('Download File'),
|
||||
onPressed: () {
|
||||
// Open file download
|
||||
// In a real implementation, you'd use url_launcher
|
||||
// launchUrl(state.viewUrl);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_viewerBloc.close();
|
||||
|
||||
@@ -8,6 +8,161 @@ import '../services/file_service.dart';
|
||||
import '../injection.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
// Modal version for overlay display
|
||||
class EditorPageModal extends StatefulWidget {
|
||||
final String orgId;
|
||||
final String fileId;
|
||||
final VoidCallback onClose;
|
||||
|
||||
const EditorPageModal({
|
||||
super.key,
|
||||
required this.orgId,
|
||||
required this.fileId,
|
||||
required this.onClose,
|
||||
});
|
||||
|
||||
@override
|
||||
State<EditorPageModal> createState() => _EditorPageModalState();
|
||||
}
|
||||
|
||||
class _EditorPageModalState extends State<EditorPageModal> {
|
||||
late EditorSessionBloc _editorBloc;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_editorBloc = EditorSessionBloc(getIt<FileService>());
|
||||
_editorBloc.add(
|
||||
EditorSessionStarted(orgId: widget.orgId, fileId: widget.fileId),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocProvider.value(
|
||||
value: _editorBloc,
|
||||
child: Column(
|
||||
children: [
|
||||
// Custom AppBar
|
||||
Container(
|
||||
height: 56,
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.primaryBackground.withValues(alpha: 0.9),
|
||||
border: Border(
|
||||
bottom: BorderSide(
|
||||
color: AppTheme.accentColor.withValues(alpha: 0.3),
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const SizedBox(width: 16),
|
||||
const Text(
|
||||
'Document Editor',
|
||||
style: TextStyle(
|
||||
color: AppTheme.primaryText,
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.close, color: AppTheme.primaryText),
|
||||
onPressed: () {
|
||||
_editorBloc.add(EditorSessionEnded());
|
||||
widget.onClose();
|
||||
},
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
),
|
||||
),
|
||||
// Editor content
|
||||
Expanded(
|
||||
child: BlocBuilder<EditorSessionBloc, EditorSessionState>(
|
||||
builder: (context, state) {
|
||||
if (state is EditorSessionStarting) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
if (state is EditorSessionFailed) {
|
||||
return Center(
|
||||
child: Text(
|
||||
'Error: ${state.message}',
|
||||
style: const TextStyle(color: AppTheme.primaryText),
|
||||
),
|
||||
);
|
||||
}
|
||||
if (state is EditorSessionActive) {
|
||||
return Container(
|
||||
color: AppTheme.secondaryText,
|
||||
child: Center(
|
||||
child: Text(
|
||||
'Collabora Editor Active\\n(URL: ${state.editUrl})',
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(color: AppTheme.primaryText),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
if (state is EditorSessionReadOnly) {
|
||||
return Container(
|
||||
color: AppTheme.secondaryText,
|
||||
child: Center(
|
||||
child: Text(
|
||||
'Read Only Mode\\n(URL: ${state.viewUrl})',
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(color: AppTheme.primaryText),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
if (state is EditorSessionExpired) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Text(
|
||||
'Editing session expired.',
|
||||
style: TextStyle(color: AppTheme.primaryText),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
ElevatedButton(
|
||||
onPressed: () {
|
||||
_editorBloc.add(
|
||||
EditorSessionStarted(
|
||||
orgId: widget.orgId,
|
||||
fileId: widget.fileId,
|
||||
),
|
||||
);
|
||||
},
|
||||
child: const Text('Reopen'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
return const Center(
|
||||
child: Text(
|
||||
'No editor session',
|
||||
style: TextStyle(color: AppTheme.primaryText),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_editorBloc.close();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
// Original page version (for routing if needed)
|
||||
class EditorPage extends StatefulWidget {
|
||||
final String orgId;
|
||||
final String fileId;
|
||||
|
||||
@@ -8,10 +8,16 @@ import '../blocs/organization/organization_bloc.dart';
|
||||
import '../blocs/organization/organization_event.dart';
|
||||
import '../blocs/file_browser/file_browser_bloc.dart';
|
||||
import '../blocs/file_browser/file_browser_event.dart';
|
||||
import '../blocs/permission/permission_bloc.dart';
|
||||
import '../blocs/upload/upload_bloc.dart';
|
||||
import '../repositories/file_repository.dart';
|
||||
import '../services/file_service.dart';
|
||||
import '../services/org_api.dart';
|
||||
import '../theme/app_theme.dart';
|
||||
import '../theme/modern_glass_button.dart';
|
||||
import 'login_form.dart' show LoginForm;
|
||||
import 'file_explorer.dart';
|
||||
import '../injection.dart';
|
||||
|
||||
class HomePage extends StatefulWidget {
|
||||
const HomePage({super.key});
|
||||
@@ -24,6 +30,13 @@ class _HomePageState extends State<HomePage> with TickerProviderStateMixin {
|
||||
late String _selectedTab = 'Drive';
|
||||
late AnimationController _animationController;
|
||||
bool _isSignupMode = false;
|
||||
bool _usePasswordMode = false;
|
||||
|
||||
// Shared blocs for the page lifecycle
|
||||
late final PermissionBloc _permissionBloc;
|
||||
late final FileBrowserBloc _fileBrowserBloc;
|
||||
late final UploadBloc _uploadBloc;
|
||||
late final OrganizationBloc _organizationBloc;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -32,11 +45,25 @@ class _HomePageState extends State<HomePage> with TickerProviderStateMixin {
|
||||
duration: const Duration(milliseconds: 400),
|
||||
vsync: this,
|
||||
);
|
||||
|
||||
_permissionBloc = PermissionBloc();
|
||||
_fileBrowserBloc = FileBrowserBloc(getIt<FileService>());
|
||||
_uploadBloc = UploadBloc(getIt<FileRepository>());
|
||||
_organizationBloc = OrganizationBloc(
|
||||
_permissionBloc,
|
||||
_fileBrowserBloc,
|
||||
_uploadBloc,
|
||||
getIt<OrgApi>(),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_animationController.dispose();
|
||||
_organizationBloc.close();
|
||||
_uploadBloc.close();
|
||||
_fileBrowserBloc.close();
|
||||
_permissionBloc.close();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@@ -50,11 +77,15 @@ class _HomePageState extends State<HomePage> with TickerProviderStateMixin {
|
||||
}
|
||||
}
|
||||
|
||||
void _setPasswordMode(bool usePassword) {
|
||||
setState(() => _usePasswordMode = usePassword);
|
||||
}
|
||||
|
||||
void _showCreateOrgDialog(BuildContext context) {
|
||||
final controller = TextEditingController();
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => Dialog(
|
||||
builder: (dialogContext) => Dialog(
|
||||
backgroundColor: Colors.transparent,
|
||||
child: SizedBox(
|
||||
width: 400,
|
||||
@@ -80,6 +111,7 @@ class _HomePageState extends State<HomePage> with TickerProviderStateMixin {
|
||||
TextField(
|
||||
cursorColor: AppTheme.accentColor,
|
||||
controller: controller,
|
||||
autofocus: true,
|
||||
style: TextStyle(color: AppTheme.primaryText),
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Organization Name',
|
||||
@@ -95,13 +127,23 @@ class _HomePageState extends State<HomePage> with TickerProviderStateMixin {
|
||||
borderSide: BorderSide(color: AppTheme.accentColor),
|
||||
),
|
||||
),
|
||||
onSubmitted: (value) {
|
||||
final name = controller.text.trim();
|
||||
if (name.isNotEmpty) {
|
||||
// Use the parent context, not the dialog context
|
||||
BlocProvider.of<OrganizationBloc>(
|
||||
context,
|
||||
).add(CreateOrganization(name));
|
||||
Navigator.of(dialogContext).pop();
|
||||
}
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
ModernGlassButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
onPressed: () => Navigator.of(dialogContext).pop(),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
@@ -109,10 +151,11 @@ class _HomePageState extends State<HomePage> with TickerProviderStateMixin {
|
||||
onPressed: () {
|
||||
final name = controller.text.trim();
|
||||
if (name.isNotEmpty) {
|
||||
context.read<OrganizationBloc>().add(
|
||||
CreateOrganization(name),
|
||||
);
|
||||
Navigator.of(context).pop();
|
||||
// Use the parent context, not the dialog context
|
||||
BlocProvider.of<OrganizationBloc>(
|
||||
context,
|
||||
).add(CreateOrganization(name));
|
||||
Navigator.of(dialogContext).pop();
|
||||
}
|
||||
},
|
||||
child: const Text('Create'),
|
||||
@@ -132,37 +175,58 @@ class _HomePageState extends State<HomePage> with TickerProviderStateMixin {
|
||||
Widget _buildOrgRow(BuildContext context) {
|
||||
return BlocBuilder<OrganizationBloc, OrganizationState>(
|
||||
builder: (context, state) {
|
||||
List<Organization> orgs = [];
|
||||
Organization? selectedOrg;
|
||||
bool isLoading = false;
|
||||
|
||||
if (state is OrganizationLoaded) {
|
||||
final orgs = state.organizations;
|
||||
return Column(
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
...orgs.map(
|
||||
(org) => Row(
|
||||
children: [
|
||||
_buildOrgButton(
|
||||
org,
|
||||
org.id == state.selectedOrg?.id,
|
||||
() {
|
||||
context.read<OrganizationBloc>().add(
|
||||
SelectOrganization(org.id),
|
||||
);
|
||||
},
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
],
|
||||
),
|
||||
),
|
||||
_buildAddButton(() => _showCreateOrgDialog(context)),
|
||||
],
|
||||
),
|
||||
const Divider(height: 1),
|
||||
],
|
||||
);
|
||||
} else {
|
||||
return const SizedBox.shrink();
|
||||
orgs = state.organizations;
|
||||
selectedOrg = state.selectedOrg;
|
||||
isLoading = state.isLoading;
|
||||
} else if (state is OrganizationLoading) {
|
||||
isLoading = true;
|
||||
}
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
// Personal workspace button (always show when logged in)
|
||||
_buildPersonalButton(selectedOrg == null, () {
|
||||
context.read<OrganizationBloc>().add(SelectOrganization(''));
|
||||
}),
|
||||
const SizedBox(width: 16),
|
||||
// Organization tabs
|
||||
...orgs.map(
|
||||
(org) => Row(
|
||||
children: [
|
||||
_buildOrgButton(org, org.id == selectedOrg?.id, () {
|
||||
context.read<OrganizationBloc>().add(
|
||||
SelectOrganization(org.id),
|
||||
);
|
||||
}),
|
||||
const SizedBox(width: 16),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (isLoading)
|
||||
SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(
|
||||
AppTheme.accentColor,
|
||||
),
|
||||
),
|
||||
)
|
||||
else
|
||||
_buildAddButton(() => _showCreateOrgDialog(context)),
|
||||
],
|
||||
),
|
||||
const Divider(height: 1),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -171,6 +235,7 @@ class _HomePageState extends State<HomePage> with TickerProviderStateMixin {
|
||||
final highlightColor = const Color.fromARGB(255, 100, 200, 255);
|
||||
final defaultColor = AppTheme.secondaryText;
|
||||
return TextButton(
|
||||
style: ButtonStyle(splashFactory: NoSplash.splashFactory),
|
||||
onPressed: onTap,
|
||||
child: Text(
|
||||
org.name,
|
||||
@@ -182,18 +247,44 @@ class _HomePageState extends State<HomePage> with TickerProviderStateMixin {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPersonalButton(bool selected, VoidCallback onTap) {
|
||||
final highlightColor = const Color.fromARGB(255, 100, 200, 255);
|
||||
final defaultColor = AppTheme.secondaryText;
|
||||
return TextButton(
|
||||
style: ButtonStyle(splashFactory: NoSplash.splashFactory),
|
||||
onPressed: onTap,
|
||||
child: Text(
|
||||
'Personal',
|
||||
style: TextStyle(
|
||||
color: selected ? highlightColor : defaultColor,
|
||||
fontWeight: selected ? FontWeight.bold : FontWeight.normal,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildAddButton(VoidCallback onTap) {
|
||||
final defaultColor = AppTheme.secondaryText;
|
||||
return TextButton(
|
||||
style: ButtonStyle(splashFactory: NoSplash.splashFactory),
|
||||
onPressed: onTap,
|
||||
child: Text('+ Add Organization', style: TextStyle(color: defaultColor)),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDrive(OrganizationState state) {
|
||||
return state is OrganizationLoaded && state.selectedOrg != null
|
||||
? FileExplorer(orgId: state.selectedOrg!.id)
|
||||
: const FileExplorer(orgId: 'org1');
|
||||
Widget _buildDrive(OrganizationState state, AuthState authState) {
|
||||
String orgId;
|
||||
if (state is OrganizationLoaded && state.selectedOrg != null) {
|
||||
// Show selected organization's files
|
||||
orgId = state.selectedOrg!.id;
|
||||
} else if (authState is AuthAuthenticated) {
|
||||
// Show personal workspace - use empty string to trigger /user/files endpoint
|
||||
orgId = '';
|
||||
} else {
|
||||
orgId = '';
|
||||
}
|
||||
|
||||
return FileExplorer(orgId: orgId);
|
||||
}
|
||||
|
||||
Widget _buildNavButton(String label, IconData icon, {bool isAvatar = false}) {
|
||||
@@ -238,274 +329,328 @@ class _HomePageState extends State<HomePage> with TickerProviderStateMixin {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: AppTheme.primaryBackground,
|
||||
body: Stack(
|
||||
children: [
|
||||
Center(
|
||||
child: BlocBuilder<AuthBloc, AuthState>(
|
||||
builder: (context, state) {
|
||||
final isLoggedIn = state is AuthAuthenticated;
|
||||
if (isLoggedIn && !_animationController.isAnimating) {
|
||||
_animationController.forward();
|
||||
} else if (!isLoggedIn) {
|
||||
_animationController.reverse();
|
||||
}
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(top: 42.0),
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 350),
|
||||
curve: Curves.easeInOut,
|
||||
width: isLoggedIn
|
||||
? MediaQuery.of(context).size.width * 0.9
|
||||
: 340,
|
||||
height: isLoggedIn
|
||||
? MediaQuery.of(context).size.height * 0.9
|
||||
: (_isSignupMode ? 400 : 280),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
child: BackdropFilter(
|
||||
filter: ui.ImageFilter.blur(sigmaX: 10, sigmaY: 10),
|
||||
child: Stack(
|
||||
children: [
|
||||
Container(
|
||||
decoration: AppTheme.glassDecoration,
|
||||
child: isLoggedIn
|
||||
? BlocListener<
|
||||
OrganizationBloc,
|
||||
OrganizationState
|
||||
>(
|
||||
listener: (context, state) {
|
||||
if (state is OrganizationLoaded &&
|
||||
state.selectedOrg != null) {
|
||||
// Reload file browser when org changes
|
||||
context.read<FileBrowserBloc>().add(
|
||||
LoadDirectory(
|
||||
orgId: state.selectedOrg!.id,
|
||||
path: '/',
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
child:
|
||||
BlocBuilder<
|
||||
OrganizationBloc,
|
||||
OrganizationState
|
||||
>(
|
||||
builder: (context, orgState) {
|
||||
if (orgState
|
||||
is OrganizationInitial) {
|
||||
WidgetsBinding.instance
|
||||
.addPostFrameCallback((_) {
|
||||
context
|
||||
.read<
|
||||
OrganizationBloc
|
||||
>()
|
||||
.add(
|
||||
LoadOrganizations(),
|
||||
);
|
||||
});
|
||||
}
|
||||
return Column(
|
||||
children: [
|
||||
const SizedBox(height: 8),
|
||||
_buildOrgRow(context),
|
||||
Expanded(
|
||||
child: _buildDrive(
|
||||
orgState,
|
||||
),
|
||||
),
|
||||
],
|
||||
return MultiBlocProvider(
|
||||
providers: [
|
||||
BlocProvider<PermissionBloc>.value(value: _permissionBloc),
|
||||
BlocProvider<FileBrowserBloc>.value(value: _fileBrowserBloc),
|
||||
BlocProvider<UploadBloc>.value(value: _uploadBloc),
|
||||
BlocProvider<OrganizationBloc>.value(value: _organizationBloc),
|
||||
],
|
||||
child: Scaffold(
|
||||
backgroundColor: AppTheme.primaryBackground,
|
||||
body: Stack(
|
||||
children: [
|
||||
Center(
|
||||
child: BlocBuilder<AuthBloc, AuthState>(
|
||||
builder: (context, state) {
|
||||
final isLoggedIn = state is AuthAuthenticated;
|
||||
if (isLoggedIn && !_animationController.isAnimating) {
|
||||
_animationController.forward();
|
||||
} else if (!isLoggedIn) {
|
||||
_animationController.reverse();
|
||||
}
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(
|
||||
top: MediaQuery.of(context).size.width < 600
|
||||
? 96.0
|
||||
: 78.0,
|
||||
),
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 350),
|
||||
curve: Curves.easeInOut,
|
||||
width: isLoggedIn
|
||||
? MediaQuery.of(context).size.width * 0.9
|
||||
: 340,
|
||||
height: isLoggedIn
|
||||
? MediaQuery.of(context).size.height * 0.9
|
||||
: (_isSignupMode
|
||||
? 400
|
||||
: (_usePasswordMode ? 350 : 280)),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
child: BackdropFilter(
|
||||
filter: ui.ImageFilter.blur(sigmaX: 10, sigmaY: 10),
|
||||
child: Stack(
|
||||
children: [
|
||||
Container(
|
||||
decoration: AppTheme.glassDecoration,
|
||||
child: isLoggedIn
|
||||
? BlocListener<
|
||||
OrganizationBloc,
|
||||
OrganizationState
|
||||
>(
|
||||
listener: (context, state) {
|
||||
if (state is OrganizationLoaded) {
|
||||
// Show errors if present
|
||||
if (state.error != null &&
|
||||
state.error!.isNotEmpty) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(state.error!),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
)
|
||||
: LoginForm(
|
||||
onSignupModeChanged: _setSignupMode,
|
||||
),
|
||||
),
|
||||
// Top-left radial glow - primary accent light
|
||||
AnimatedPositioned(
|
||||
duration: const Duration(milliseconds: 350),
|
||||
curve: Curves.easeInOut,
|
||||
top: isLoggedIn ? -180 : -120,
|
||||
left: isLoggedIn ? -180 : -120,
|
||||
child: IgnorePointer(
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 350),
|
||||
curve: Curves.easeInOut,
|
||||
width: isLoggedIn ? 550 : 400,
|
||||
height: isLoggedIn ? 550 : 400,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
gradient: RadialGradient(
|
||||
colors: [
|
||||
AppTheme.accentColor.withValues(
|
||||
alpha: isLoggedIn ? 0.12 : 0.15,
|
||||
),
|
||||
AppTheme.accentColor.withValues(
|
||||
alpha: 0.04,
|
||||
),
|
||||
Colors.transparent,
|
||||
],
|
||||
stops: const [0.0, 0.6, 1.0],
|
||||
),
|
||||
),
|
||||
),
|
||||
}
|
||||
final orgId =
|
||||
state.selectedOrg?.id ?? '';
|
||||
// Reload file browser when org changes (or when falling back to personal workspace)
|
||||
context.read<FileBrowserBloc>().add(
|
||||
LoadDirectory(
|
||||
orgId: orgId,
|
||||
path: '/',
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
child:
|
||||
BlocBuilder<
|
||||
OrganizationBloc,
|
||||
OrganizationState
|
||||
>(
|
||||
builder: (context, orgState) {
|
||||
if (orgState
|
||||
is OrganizationInitial) {
|
||||
WidgetsBinding.instance
|
||||
.addPostFrameCallback((
|
||||
_,
|
||||
) {
|
||||
// Kick off org fetch and immediately show personal workspace
|
||||
// while org data loads.
|
||||
context
|
||||
.read<
|
||||
OrganizationBloc
|
||||
>()
|
||||
.add(
|
||||
LoadOrganizations(),
|
||||
);
|
||||
context
|
||||
.read<
|
||||
FileBrowserBloc
|
||||
>()
|
||||
.add(
|
||||
const LoadDirectory(
|
||||
orgId: '',
|
||||
path: '/',
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
return Column(
|
||||
children: [
|
||||
const SizedBox(height: 16),
|
||||
_buildOrgRow(context),
|
||||
Expanded(
|
||||
child: _buildDrive(
|
||||
orgState,
|
||||
state,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
)
|
||||
: LoginForm(
|
||||
onSignupModeChanged: _setSignupMode,
|
||||
onPasswordModeChanged: _setPasswordMode,
|
||||
),
|
||||
),
|
||||
),
|
||||
// Bottom-right warm glow - complementary lighting
|
||||
AnimatedPositioned(
|
||||
duration: const Duration(milliseconds: 350),
|
||||
curve: Curves.easeInOut,
|
||||
bottom: isLoggedIn ? -200 : -140,
|
||||
right: isLoggedIn ? -200 : -140,
|
||||
child: IgnorePointer(
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 350),
|
||||
curve: Curves.easeInOut,
|
||||
width: isLoggedIn ? 530 : 380,
|
||||
height: isLoggedIn ? 530 : 380,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
gradient: RadialGradient(
|
||||
colors: [
|
||||
Colors.cyan.withValues(
|
||||
alpha: isLoggedIn ? 0.06 : 0.08,
|
||||
),
|
||||
Colors.transparent,
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
// Top edge subtle highlight
|
||||
Positioned(
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
child: IgnorePointer(
|
||||
child: Container(
|
||||
height: 60,
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [
|
||||
Colors.white.withValues(alpha: 0.05),
|
||||
Colors.transparent,
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
// Left edge subtle side lighting
|
||||
Positioned(
|
||||
left: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
child: IgnorePointer(
|
||||
child: Container(
|
||||
width: 40,
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.centerLeft,
|
||||
end: Alignment.centerRight,
|
||||
colors: [
|
||||
AppTheme.accentColor.withValues(
|
||||
alpha: 0.04,
|
||||
),
|
||||
Colors.transparent,
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
// Diagonal shimmer overlay
|
||||
Positioned(
|
||||
top: 0,
|
||||
left: 0,
|
||||
child: IgnorePointer(
|
||||
child: Transform.rotate(
|
||||
angle: 0.785,
|
||||
child: Container(
|
||||
width: 600,
|
||||
height: 100,
|
||||
// Top-left radial glow - primary accent light
|
||||
AnimatedPositioned(
|
||||
duration: const Duration(milliseconds: 350),
|
||||
curve: Curves.easeInOut,
|
||||
top: isLoggedIn ? -180 : -120,
|
||||
left: isLoggedIn ? -180 : -120,
|
||||
child: IgnorePointer(
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 350),
|
||||
curve: Curves.easeInOut,
|
||||
width: isLoggedIn ? 550 : 400,
|
||||
height: isLoggedIn ? 550 : 400,
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
shape: BoxShape.circle,
|
||||
gradient: RadialGradient(
|
||||
colors: [
|
||||
Colors.white.withValues(alpha: 0),
|
||||
Colors.white.withValues(alpha: 0.06),
|
||||
Colors.white.withValues(alpha: 0),
|
||||
AppTheme.accentColor.withValues(
|
||||
alpha: isLoggedIn ? 0.12 : 0.15,
|
||||
),
|
||||
AppTheme.accentColor.withValues(
|
||||
alpha: 0.04,
|
||||
),
|
||||
Colors.transparent,
|
||||
],
|
||||
stops: const [0.0, 0.6, 1.0],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
// Bottom-right warm glow - complementary lighting
|
||||
AnimatedPositioned(
|
||||
duration: const Duration(milliseconds: 350),
|
||||
curve: Curves.easeInOut,
|
||||
bottom: isLoggedIn ? -200 : -140,
|
||||
right: isLoggedIn ? -200 : -140,
|
||||
child: IgnorePointer(
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 350),
|
||||
curve: Curves.easeInOut,
|
||||
width: isLoggedIn ? 530 : 380,
|
||||
height: isLoggedIn ? 530 : 380,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
gradient: RadialGradient(
|
||||
colors: [
|
||||
Colors.cyan.withValues(
|
||||
alpha: isLoggedIn ? 0.06 : 0.08,
|
||||
),
|
||||
Colors.transparent,
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
// Top edge subtle highlight
|
||||
Positioned(
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
child: IgnorePointer(
|
||||
child: Container(
|
||||
height: 60,
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [
|
||||
Colors.white.withValues(alpha: 0.05),
|
||||
Colors.transparent,
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
// Left edge subtle side lighting
|
||||
Positioned(
|
||||
left: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
child: IgnorePointer(
|
||||
child: Container(
|
||||
width: 40,
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.centerLeft,
|
||||
end: Alignment.centerRight,
|
||||
colors: [
|
||||
AppTheme.accentColor.withValues(
|
||||
alpha: 0.04,
|
||||
),
|
||||
Colors.transparent,
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
// Diagonal shimmer overlay
|
||||
Positioned(
|
||||
top: 0,
|
||||
left: 0,
|
||||
child: IgnorePointer(
|
||||
child: Transform.rotate(
|
||||
angle: 0.785,
|
||||
child: Container(
|
||||
width: 600,
|
||||
height: 100,
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
colors: [
|
||||
Colors.white.withValues(alpha: 0),
|
||||
Colors.white.withValues(
|
||||
alpha: 0.06,
|
||||
),
|
||||
Colors.white.withValues(alpha: 0),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
child: Center(
|
||||
child: Text(
|
||||
'b0esche.cloud',
|
||||
style: TextStyle(
|
||||
fontFamily: 'PixelatedElegance',
|
||||
fontSize: 48,
|
||||
color: AppTheme.primaryText,
|
||||
decoration: TextDecoration.underline,
|
||||
decorationColor: AppTheme.primaryText,
|
||||
fontFeatures: const [FontFeature.slashedZero()],
|
||||
Positioned(
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
child: Center(
|
||||
child: Builder(
|
||||
builder: (context) {
|
||||
final screenWidth = MediaQuery.of(context).size.width;
|
||||
final fontSize = screenWidth < 600 ? 24.0 : 48.0;
|
||||
return Text(
|
||||
'b0esche.cloud',
|
||||
style: TextStyle(
|
||||
fontFamily: 'PixelatedElegance',
|
||||
fontSize: fontSize,
|
||||
color: AppTheme.primaryText,
|
||||
decoration: TextDecoration.underline,
|
||||
decorationColor: AppTheme.primaryText,
|
||||
fontFeatures: const [FontFeature.slashedZero()],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
top: 10,
|
||||
right: 20,
|
||||
child: BlocBuilder<AuthBloc, AuthState>(
|
||||
builder: (context, state) {
|
||||
final isLoggedIn = state is AuthAuthenticated;
|
||||
if (!isLoggedIn) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
return ScaleTransition(
|
||||
scale: Tween<double>(begin: 0, end: 1).animate(
|
||||
CurvedAnimation(
|
||||
parent: _animationController,
|
||||
curve: Curves.easeOutBack,
|
||||
Positioned(
|
||||
top: MediaQuery.of(context).size.width < 600 ? 40 : 10,
|
||||
right: 20,
|
||||
child: BlocBuilder<AuthBloc, AuthState>(
|
||||
builder: (context, state) {
|
||||
final isLoggedIn = state is AuthAuthenticated;
|
||||
if (!isLoggedIn) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
return ScaleTransition(
|
||||
scale: Tween<double>(begin: 0, end: 1).animate(
|
||||
CurvedAnimation(
|
||||
parent: _animationController,
|
||||
curve: Curves.easeOutBack,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
_buildNavButton('Drive', Icons.cloud),
|
||||
const SizedBox(width: 16),
|
||||
_buildNavButton('Mail', Icons.mail),
|
||||
const SizedBox(width: 16),
|
||||
_buildNavButton('Add', Icons.add),
|
||||
const SizedBox(width: 16),
|
||||
_buildNavButton('Profile', Icons.person, isAvatar: true),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
child: Row(
|
||||
children: [
|
||||
_buildNavButton('Drive', Icons.cloud),
|
||||
const SizedBox(width: 16),
|
||||
_buildNavButton('Mail', Icons.mail),
|
||||
const SizedBox(width: 16),
|
||||
_buildNavButton('Add', Icons.add),
|
||||
const SizedBox(width: 16),
|
||||
_buildNavButton(
|
||||
'Profile',
|
||||
Icons.person,
|
||||
isAvatar: true,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'dart:math';
|
||||
import 'dart:convert';
|
||||
import '../blocs/auth/auth_bloc.dart';
|
||||
import '../blocs/auth/auth_event.dart';
|
||||
import '../blocs/auth/auth_state.dart';
|
||||
@@ -12,8 +13,13 @@ import '../theme/modern_glass_button.dart';
|
||||
|
||||
class LoginForm extends StatefulWidget {
|
||||
final ValueChanged<bool>? onSignupModeChanged;
|
||||
final ValueChanged<bool>? onPasswordModeChanged;
|
||||
|
||||
const LoginForm({super.key, this.onSignupModeChanged});
|
||||
const LoginForm({
|
||||
super.key,
|
||||
this.onSignupModeChanged,
|
||||
this.onPasswordModeChanged,
|
||||
});
|
||||
|
||||
@override
|
||||
State<LoginForm> createState() => _LoginFormState();
|
||||
@@ -34,10 +40,10 @@ class _LoginFormState extends State<LoginForm> {
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
String _generateRandomHex(int bytes) {
|
||||
String _generateRandomBase64(int bytes) {
|
||||
final random = Random();
|
||||
final values = List<int>.generate(bytes, (i) => random.nextInt(256));
|
||||
return values.map((v) => v.toRadixString(16).padLeft(2, '0')).join();
|
||||
return base64.encode(values);
|
||||
}
|
||||
|
||||
Future<void> _handleAuthentication(
|
||||
@@ -47,7 +53,7 @@ class _LoginFormState extends State<LoginForm> {
|
||||
try {
|
||||
final credentialId = state.credentialIds.isNotEmpty
|
||||
? state.credentialIds.first
|
||||
: _generateRandomHex(64);
|
||||
: _generateRandomBase64(64);
|
||||
|
||||
if (context.mounted) {
|
||||
context.read<AuthBloc>().add(
|
||||
@@ -55,10 +61,10 @@ class _LoginFormState extends State<LoginForm> {
|
||||
username: _usernameController.text,
|
||||
challenge: state.challenge,
|
||||
credentialId: credentialId,
|
||||
authenticatorData: _generateRandomHex(37),
|
||||
authenticatorData: _generateRandomBase64(37),
|
||||
clientDataJSON:
|
||||
'{"type":"webauthn.get","challenge":"${state.challenge}","origin":"https://b0esche.cloud"}',
|
||||
signature: _generateRandomHex(128),
|
||||
signature: _generateRandomBase64(128),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -76,8 +82,8 @@ class _LoginFormState extends State<LoginForm> {
|
||||
RegistrationChallengeReceived state,
|
||||
) async {
|
||||
try {
|
||||
final credentialId = _generateRandomHex(64);
|
||||
final publicKey = _generateRandomHex(91);
|
||||
final credentialId = _generateRandomBase64(64);
|
||||
final publicKey = _generateRandomBase64(91);
|
||||
|
||||
if (context.mounted) {
|
||||
context.read<AuthBloc>().add(
|
||||
@@ -88,7 +94,7 @@ class _LoginFormState extends State<LoginForm> {
|
||||
publicKey: publicKey,
|
||||
clientDataJSON:
|
||||
'{"type":"webauthn.create","challenge":"${state.challenge}","origin":"https://b0esche.cloud"}',
|
||||
attestationObject: _generateRandomHex(128),
|
||||
attestationObject: _generateRandomBase64(128),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -133,56 +139,28 @@ class _LoginFormState extends State<LoginForm> {
|
||||
child: Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: AnimatedSwitcher(
|
||||
duration: const Duration(milliseconds: 400),
|
||||
transitionBuilder: (child, animation) {
|
||||
return FadeTransition(opacity: animation, child: child);
|
||||
},
|
||||
child: SingleChildScrollView(
|
||||
key: ValueKey<bool>(_isSignup),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
_isSignup ? 'create account' : 'sign in',
|
||||
style: const TextStyle(
|
||||
fontSize: 24,
|
||||
color: AppTheme.primaryText,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.primaryBackground.withValues(alpha: 0.5),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(
|
||||
color: AppTheme.accentColor.withValues(alpha: 0.3),
|
||||
child: AnimatedSize(
|
||||
duration: const Duration(milliseconds: 300),
|
||||
curve: Curves.easeInOut,
|
||||
child: AnimatedSwitcher(
|
||||
duration: const Duration(milliseconds: 400),
|
||||
transitionBuilder: (child, animation) {
|
||||
return FadeTransition(opacity: animation, child: child);
|
||||
},
|
||||
child: SingleChildScrollView(
|
||||
key: ValueKey('${_isSignup}_$_usePasskey'),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
_isSignup ? 'create account' : 'sign in',
|
||||
style: const TextStyle(
|
||||
fontSize: 24,
|
||||
color: AppTheme.primaryText,
|
||||
),
|
||||
),
|
||||
child: TextField(
|
||||
controller: _usernameController,
|
||||
textInputAction: TextInputAction.next,
|
||||
keyboardType: TextInputType.text,
|
||||
cursorColor: AppTheme.accentColor,
|
||||
decoration: InputDecoration(
|
||||
hintText: 'username',
|
||||
hintStyle: TextStyle(color: AppTheme.secondaryText),
|
||||
contentPadding: const EdgeInsets.all(12),
|
||||
border: InputBorder.none,
|
||||
prefixIcon: Icon(
|
||||
Icons.person_outline,
|
||||
color: AppTheme.primaryText,
|
||||
size: 20,
|
||||
),
|
||||
),
|
||||
style: const TextStyle(color: AppTheme.primaryText),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
if (!_isSignup && _usePasskey)
|
||||
const SizedBox.shrink()
|
||||
else
|
||||
const SizedBox(height: 24),
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.primaryBackground.withValues(
|
||||
@@ -194,105 +172,114 @@ class _LoginFormState extends State<LoginForm> {
|
||||
),
|
||||
),
|
||||
child: TextField(
|
||||
controller: _passwordController,
|
||||
controller: _usernameController,
|
||||
textInputAction: TextInputAction.next,
|
||||
keyboardType: TextInputType.visiblePassword,
|
||||
obscureText: true,
|
||||
cursorColor: AppTheme.accentColor,
|
||||
decoration: InputDecoration(
|
||||
hintText: 'password',
|
||||
hintStyle: TextStyle(color: AppTheme.secondaryText),
|
||||
contentPadding: const EdgeInsets.all(12),
|
||||
border: InputBorder.none,
|
||||
prefixIcon: Icon(
|
||||
Icons.lock_outline,
|
||||
color: AppTheme.primaryText,
|
||||
size: 20,
|
||||
),
|
||||
),
|
||||
style: const TextStyle(color: AppTheme.primaryText),
|
||||
),
|
||||
),
|
||||
if (!_isSignup && _usePasskey)
|
||||
const SizedBox.shrink()
|
||||
else
|
||||
const SizedBox(height: 16),
|
||||
if (_isSignup)
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.primaryBackground.withValues(
|
||||
alpha: 0.5,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(
|
||||
color: AppTheme.accentColor.withValues(alpha: 0.3),
|
||||
),
|
||||
),
|
||||
child: TextField(
|
||||
controller: _displayNameController,
|
||||
textInputAction: TextInputAction.done,
|
||||
keyboardType: TextInputType.text,
|
||||
cursorColor: AppTheme.accentColor,
|
||||
decoration: InputDecoration(
|
||||
hintText: 'display name (optional)',
|
||||
hintText: 'username',
|
||||
hintStyle: TextStyle(color: AppTheme.secondaryText),
|
||||
contentPadding: const EdgeInsets.all(12),
|
||||
border: InputBorder.none,
|
||||
prefixIcon: Icon(
|
||||
Icons.badge_outlined,
|
||||
Icons.person_outline,
|
||||
color: AppTheme.primaryText,
|
||||
size: 20,
|
||||
),
|
||||
),
|
||||
style: const TextStyle(color: AppTheme.primaryText),
|
||||
),
|
||||
)
|
||||
else
|
||||
const SizedBox.shrink(),
|
||||
if (_isSignup)
|
||||
const SizedBox(height: 16)
|
||||
else
|
||||
const SizedBox.shrink(),
|
||||
SizedBox(
|
||||
width: 150,
|
||||
child: BlocBuilder<AuthBloc, AuthState>(
|
||||
builder: (context, state) {
|
||||
return ModernGlassButton(
|
||||
isLoading: state is AuthLoading,
|
||||
onPressed: () {
|
||||
if (_usernameController.text.isEmpty) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Username is required'),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (_isSignup) {
|
||||
if (_passwordController.text.isEmpty) {
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
if (!_isSignup && _usePasskey)
|
||||
const SizedBox.shrink()
|
||||
else
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.primaryBackground.withValues(
|
||||
alpha: 0.5,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(
|
||||
color: AppTheme.accentColor.withValues(alpha: 0.3),
|
||||
),
|
||||
),
|
||||
child: TextField(
|
||||
controller: _passwordController,
|
||||
textInputAction: TextInputAction.next,
|
||||
keyboardType: TextInputType.visiblePassword,
|
||||
obscureText: true,
|
||||
cursorColor: AppTheme.accentColor,
|
||||
decoration: InputDecoration(
|
||||
hintText: 'password',
|
||||
hintStyle: TextStyle(color: AppTheme.secondaryText),
|
||||
contentPadding: const EdgeInsets.all(12),
|
||||
border: InputBorder.none,
|
||||
prefixIcon: Icon(
|
||||
Icons.lock_outline,
|
||||
color: AppTheme.primaryText,
|
||||
size: 20,
|
||||
),
|
||||
),
|
||||
style: const TextStyle(color: AppTheme.primaryText),
|
||||
),
|
||||
),
|
||||
if (!_isSignup && _usePasskey)
|
||||
const SizedBox.shrink()
|
||||
else
|
||||
const SizedBox(height: 16),
|
||||
if (_isSignup)
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.primaryBackground.withValues(
|
||||
alpha: 0.5,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(
|
||||
color: AppTheme.accentColor.withValues(alpha: 0.3),
|
||||
),
|
||||
),
|
||||
child: TextField(
|
||||
controller: _displayNameController,
|
||||
textInputAction: TextInputAction.done,
|
||||
keyboardType: TextInputType.text,
|
||||
cursorColor: AppTheme.accentColor,
|
||||
decoration: InputDecoration(
|
||||
hintText: 'display name (optional)',
|
||||
hintStyle: TextStyle(color: AppTheme.secondaryText),
|
||||
contentPadding: const EdgeInsets.all(12),
|
||||
border: InputBorder.none,
|
||||
prefixIcon: Icon(
|
||||
Icons.badge_outlined,
|
||||
color: AppTheme.primaryText,
|
||||
size: 20,
|
||||
),
|
||||
),
|
||||
style: const TextStyle(color: AppTheme.primaryText),
|
||||
),
|
||||
)
|
||||
else
|
||||
const SizedBox.shrink(),
|
||||
if (_isSignup)
|
||||
const SizedBox(height: 16)
|
||||
else
|
||||
const SizedBox.shrink(),
|
||||
SizedBox(
|
||||
width: 150,
|
||||
child: BlocBuilder<AuthBloc, AuthState>(
|
||||
builder: (context, state) {
|
||||
return ModernGlassButton(
|
||||
isLoading: state is AuthLoading,
|
||||
onPressed: () {
|
||||
if (_usernameController.text.isEmpty) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Password is required'),
|
||||
content: Text('Username is required'),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
context.read<AuthBloc>().add(
|
||||
SignupStarted(
|
||||
username: _usernameController.text,
|
||||
email: _usernameController.text,
|
||||
displayName: _displayNameController.text,
|
||||
password: _passwordController.text,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
if (_usePasskey) {
|
||||
context.read<AuthBloc>().add(
|
||||
LoginRequested(
|
||||
username: _usernameController.text,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
if (_isSignup) {
|
||||
if (_passwordController.text.isEmpty) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
@@ -302,91 +289,120 @@ class _LoginFormState extends State<LoginForm> {
|
||||
return;
|
||||
}
|
||||
context.read<AuthBloc>().add(
|
||||
PasswordLoginRequested(
|
||||
SignupStarted(
|
||||
username: _usernameController.text,
|
||||
email: _usernameController.text,
|
||||
displayName: _displayNameController.text,
|
||||
password: _passwordController.text,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
if (_usePasskey) {
|
||||
context.read<AuthBloc>().add(
|
||||
LoginRequested(
|
||||
username: _usernameController.text,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
if (_passwordController.text.isEmpty) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Password is required'),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
context.read<AuthBloc>().add(
|
||||
PasswordLoginRequested(
|
||||
username: _usernameController.text,
|
||||
password: _passwordController.text,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
child: Text(_isSignup ? 'create' : 'sign in'),
|
||||
);
|
||||
},
|
||||
},
|
||||
child: Text(_isSignup ? 'create' : 'sign in'),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
if (_isSignup)
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
'already have an account?',
|
||||
style: TextStyle(color: AppTheme.secondaryText),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
GestureDetector(
|
||||
onTap: () {
|
||||
_resetForm();
|
||||
_setSignupMode(false);
|
||||
},
|
||||
child: Text(
|
||||
'sign in',
|
||||
style: TextStyle(
|
||||
color: AppTheme.accentColor,
|
||||
decoration: TextDecoration.underline,
|
||||
const SizedBox(height: 16),
|
||||
if (_isSignup)
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
'already have an account?',
|
||||
style: TextStyle(color: AppTheme.secondaryText),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
GestureDetector(
|
||||
onTap: () {
|
||||
_resetForm();
|
||||
_setSignupMode(false);
|
||||
},
|
||||
child: Text(
|
||||
'sign in',
|
||||
style: TextStyle(
|
||||
color: AppTheme.accentColor,
|
||||
decoration: TextDecoration.underline,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
else
|
||||
Column(
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
GestureDetector(
|
||||
onTap: () =>
|
||||
setState(() => _usePasskey = !_usePasskey),
|
||||
child: Text(
|
||||
_usePasskey ? 'use password' : 'use passkey',
|
||||
style: TextStyle(
|
||||
color: AppTheme.accentColor,
|
||||
decoration: TextDecoration.underline,
|
||||
fontSize: 12,
|
||||
],
|
||||
)
|
||||
else
|
||||
Column(
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
GestureDetector(
|
||||
onTap: () {
|
||||
setState(() => _usePasskey = !_usePasskey);
|
||||
widget.onPasswordModeChanged?.call(
|
||||
!_usePasskey,
|
||||
);
|
||||
},
|
||||
child: Text(
|
||||
_usePasskey ? 'use password' : 'use passkey',
|
||||
style: TextStyle(
|
||||
color: AppTheme.accentColor,
|
||||
decoration: TextDecoration.underline,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
'don\'t have an account?',
|
||||
style: TextStyle(color: AppTheme.secondaryText),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
GestureDetector(
|
||||
onTap: () {
|
||||
_resetForm();
|
||||
_setSignupMode(true);
|
||||
},
|
||||
child: Text(
|
||||
'create one',
|
||||
style: TextStyle(
|
||||
color: AppTheme.accentColor,
|
||||
decoration: TextDecoration.underline,
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
'don\'t have an account?',
|
||||
style: TextStyle(color: AppTheme.secondaryText),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
GestureDetector(
|
||||
onTap: () {
|
||||
_resetForm();
|
||||
_setSignupMode(true);
|
||||
},
|
||||
child: Text(
|
||||
'create one',
|
||||
style: TextStyle(
|
||||
color: AppTheme.accentColor,
|
||||
decoration: TextDecoration.underline,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'dart:math';
|
||||
import 'dart:convert';
|
||||
import '../blocs/auth/auth_bloc.dart';
|
||||
import '../blocs/auth/auth_event.dart';
|
||||
import '../blocs/auth/auth_state.dart';
|
||||
@@ -28,10 +29,10 @@ class _SignupFormState extends State<SignupForm> {
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
String _generateRandomHex(int bytes) {
|
||||
String _generateRandomBase64(int bytes) {
|
||||
final random = Random();
|
||||
final values = List<int>.generate(bytes, (i) => random.nextInt(256));
|
||||
return values.map((v) => v.toRadixString(16).padLeft(2, '0')).join();
|
||||
return base64.encode(values);
|
||||
}
|
||||
|
||||
Future<void> _handleRegistration(
|
||||
@@ -41,8 +42,8 @@ class _SignupFormState extends State<SignupForm> {
|
||||
try {
|
||||
// Simulate WebAuthn registration by generating fake credential data
|
||||
// In a real implementation, this would call native WebAuthn APIs
|
||||
final credentialId = _generateRandomHex(64);
|
||||
final publicKey = _generateRandomHex(91); // EC2 public key size
|
||||
final credentialId = _generateRandomBase64(64);
|
||||
final publicKey = _generateRandomBase64(91); // EC2 public key size
|
||||
|
||||
if (context.mounted) {
|
||||
context.read<AuthBloc>().add(
|
||||
@@ -53,7 +54,7 @@ class _SignupFormState extends State<SignupForm> {
|
||||
publicKey: publicKey,
|
||||
clientDataJSON:
|
||||
'{"type":"webauthn.create","challenge":"${state.challenge}","origin":"https://b0esche.cloud"}',
|
||||
attestationObject: _generateRandomHex(128),
|
||||
attestationObject: _generateRandomBase64(128),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
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 {
|
||||
await _fileService.moveFile(orgId, sourcePath, targetPath);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> renameFile(String orgId, String path, String newName) async {
|
||||
throw UnimplementedError();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<FileItem>> searchFiles(String orgId, String query) async {
|
||||
// Not yet parameterized on API side; fallback to client-side filter
|
||||
final files = await getFiles(orgId, '/');
|
||||
return files
|
||||
.where((f) => f.name.toLowerCase().contains(query.toLowerCase()))
|
||||
.toList();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<ViewerSession> requestViewerSession(
|
||||
String orgId,
|
||||
String fileId,
|
||||
) async {
|
||||
return await _fileService.requestViewerSession(orgId, fileId);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<EditorSession> requestEditorSession(
|
||||
String orgId,
|
||||
String fileId,
|
||||
) async {
|
||||
return await _fileService.requestEditorSession(orgId, fileId);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> saveAnnotations(
|
||||
String orgId,
|
||||
String fileId,
|
||||
List<Annotation> annotations,
|
||||
) async {
|
||||
await _fileService.saveAnnotations(orgId, fileId, annotations);
|
||||
}
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
import '../models/user.dart';
|
||||
import '../repositories/auth_repository.dart';
|
||||
|
||||
class MockAuthRepository implements AuthRepository {
|
||||
@override
|
||||
Future<User> login(String email, String password) async {
|
||||
await Future.delayed(const Duration(seconds: 1));
|
||||
if (email.isNotEmpty && password.isNotEmpty) {
|
||||
return User(
|
||||
id: 'mock-user-id',
|
||||
username: 'mockuser',
|
||||
email: email,
|
||||
createdAt: DateTime.now(),
|
||||
);
|
||||
} else {
|
||||
throw Exception('Invalid credentials');
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> logout() async {
|
||||
// Mock logout
|
||||
}
|
||||
|
||||
@override
|
||||
Future<User?> getCurrentUser() async {
|
||||
// Mock: return null or a user
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -1,268 +0,0 @@
|
||||
import '../models/file_item.dart';
|
||||
import '../models/viewer_session.dart';
|
||||
import '../models/editor_session.dart';
|
||||
import '../models/annotation.dart';
|
||||
import '../models/document_capabilities.dart';
|
||||
import '../models/api_error.dart';
|
||||
import '../repositories/file_repository.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
|
||||
class MockFileRepository implements FileRepository {
|
||||
final Map<String, List<FileItem>> _orgFiles = {};
|
||||
|
||||
List<FileItem> _getFilesForOrg(String orgId) {
|
||||
if (!_orgFiles.containsKey(orgId)) {
|
||||
// Initialize with different files per org
|
||||
if (orgId == 'org1') {
|
||||
_orgFiles[orgId] = [
|
||||
FileItem(
|
||||
name: 'Personal Documents',
|
||||
path: '/Personal Documents',
|
||||
type: FileType.folder,
|
||||
lastModified: DateTime.now(),
|
||||
),
|
||||
FileItem(
|
||||
name: 'Photos',
|
||||
path: '/Photos',
|
||||
type: FileType.folder,
|
||||
lastModified: DateTime.now(),
|
||||
),
|
||||
FileItem(
|
||||
name: 'resume.pdf',
|
||||
path: '/resume.pdf',
|
||||
type: FileType.file,
|
||||
size: 1024,
|
||||
lastModified: DateTime.now(),
|
||||
),
|
||||
FileItem(
|
||||
name: 'notes.txt',
|
||||
path: '/notes.txt',
|
||||
type: FileType.file,
|
||||
size: 256,
|
||||
lastModified: DateTime.now(),
|
||||
),
|
||||
];
|
||||
} else if (orgId == 'org2') {
|
||||
_orgFiles[orgId] = [
|
||||
FileItem(
|
||||
name: 'Company Reports',
|
||||
path: '/Company Reports',
|
||||
type: FileType.folder,
|
||||
lastModified: DateTime.now(),
|
||||
),
|
||||
FileItem(
|
||||
name: 'annual_report.pdf',
|
||||
path: '/annual_report.pdf',
|
||||
type: FileType.file,
|
||||
size: 2048,
|
||||
lastModified: DateTime.now(),
|
||||
),
|
||||
FileItem(
|
||||
name: 'presentation.pptx',
|
||||
path: '/presentation.pptx',
|
||||
type: FileType.file,
|
||||
size: 4096,
|
||||
lastModified: DateTime.now(),
|
||||
),
|
||||
];
|
||||
} else if (orgId == 'org3') {
|
||||
_orgFiles[orgId] = [
|
||||
FileItem(
|
||||
name: 'Project Code',
|
||||
path: '/Project Code',
|
||||
type: FileType.folder,
|
||||
lastModified: DateTime.now(),
|
||||
),
|
||||
FileItem(
|
||||
name: 'side_project.dart',
|
||||
path: '/side_project.dart',
|
||||
type: FileType.file,
|
||||
size: 512,
|
||||
lastModified: DateTime.now(),
|
||||
),
|
||||
];
|
||||
} else {
|
||||
// Default for new orgs
|
||||
_orgFiles[orgId] = [];
|
||||
}
|
||||
}
|
||||
return _orgFiles[orgId]!;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<FileItem>> getFiles(String orgId, String path) async {
|
||||
await Future.delayed(const Duration(seconds: 1));
|
||||
final files = _getFilesForOrg(orgId);
|
||||
if (path == '/') {
|
||||
return files.where((f) => !f.path.substring(1).contains('/')).toList();
|
||||
} else {
|
||||
return files
|
||||
.where((f) => f.path.startsWith('$path/') && f.path != path)
|
||||
.toList();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<FileItem?> getFile(String orgId, String path) async {
|
||||
final files = _getFilesForOrg(orgId);
|
||||
final index = files.indexWhere((f) => f.path == path);
|
||||
return index != -1 ? files[index] : null;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<EditorSession> requestEditorSession(
|
||||
String orgId,
|
||||
String fileId,
|
||||
) async {
|
||||
await Future.delayed(const Duration(seconds: 1));
|
||||
// Mock: determine editability
|
||||
final isEditable =
|
||||
fileId.endsWith('.docx') ||
|
||||
fileId.endsWith('.xlsx') ||
|
||||
fileId.endsWith('.pptx');
|
||||
final editUrl = Uri.parse(
|
||||
'https://office.b0esche.cloud/editor/$orgId/$fileId?editable=$isEditable',
|
||||
);
|
||||
final expiresAt = DateTime.now().add(const Duration(minutes: 30));
|
||||
return EditorSession(
|
||||
editUrl: editUrl,
|
||||
readOnly: !isEditable,
|
||||
expiresAt: expiresAt,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> deleteFile(String orgId, String path) async {
|
||||
final files = _getFilesForOrg(orgId);
|
||||
files.removeWhere((f) => f.path == path);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> createFolder(
|
||||
String orgId,
|
||||
String parentPath,
|
||||
String folderName,
|
||||
) async {
|
||||
await Future.delayed(const Duration(seconds: 1));
|
||||
final normalizedName = folderName.startsWith('/')
|
||||
? folderName.substring(1)
|
||||
: folderName;
|
||||
final newPath = parentPath == '/'
|
||||
? '/$normalizedName'
|
||||
: '$parentPath/$normalizedName';
|
||||
final files = _getFilesForOrg(orgId);
|
||||
files.add(
|
||||
FileItem(
|
||||
name: normalizedName,
|
||||
path: newPath,
|
||||
type: FileType.folder,
|
||||
lastModified: DateTime.now(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> moveFile(
|
||||
String orgId,
|
||||
String sourcePath,
|
||||
String targetPath,
|
||||
) async {
|
||||
await Future.delayed(const Duration(seconds: 1));
|
||||
final files = _getFilesForOrg(orgId);
|
||||
final fileIndex = files.indexWhere((f) => f.path == sourcePath);
|
||||
if (fileIndex != -1) {
|
||||
final file = files[fileIndex];
|
||||
final newName = file.path.split('/').last;
|
||||
final newPath = targetPath == '/' ? '/$newName' : '$targetPath/$newName';
|
||||
files[fileIndex] = FileItem(
|
||||
name: file.name,
|
||||
path: newPath,
|
||||
type: file.type,
|
||||
size: file.size,
|
||||
lastModified: DateTime.now(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> renameFile(String orgId, String path, String newName) async {
|
||||
await Future.delayed(const Duration(seconds: 1));
|
||||
final files = _getFilesForOrg(orgId);
|
||||
final fileIndex = files.indexWhere((f) => f.path == path);
|
||||
if (fileIndex != -1) {
|
||||
final file = files[fileIndex];
|
||||
final parentPath = p.dirname(path);
|
||||
final newPath = parentPath == '.' ? '/$newName' : '$parentPath/$newName';
|
||||
files[fileIndex] = FileItem(
|
||||
name: newName,
|
||||
path: newPath,
|
||||
type: file.type,
|
||||
size: file.size,
|
||||
lastModified: DateTime.now(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<FileItem>> searchFiles(String orgId, String query) async {
|
||||
await Future.delayed(const Duration(seconds: 1));
|
||||
final files = _getFilesForOrg(orgId);
|
||||
return files
|
||||
.where((f) => f.name.toLowerCase().contains(query.toLowerCase()))
|
||||
.toList();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> uploadFile(String orgId, FileItem file) async {
|
||||
await Future.delayed(const Duration(seconds: 1));
|
||||
final files = _getFilesForOrg(orgId);
|
||||
files.add(file);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<ViewerSession> requestViewerSession(
|
||||
String orgId,
|
||||
String fileId,
|
||||
) async {
|
||||
await Future.delayed(const Duration(seconds: 1));
|
||||
if (fileId.contains('forbidden')) {
|
||||
throw ApiError(
|
||||
code: 'permission_denied',
|
||||
message: 'Access denied',
|
||||
status: 403,
|
||||
);
|
||||
}
|
||||
if (fileId.contains('notfound')) {
|
||||
throw ApiError(code: 'not_found', message: 'File not found', status: 404);
|
||||
}
|
||||
// Mock: assume fileId is path, determine if PDF
|
||||
final isPdf = fileId.endsWith('.pdf');
|
||||
final caps = DocumentCapabilities(
|
||||
canEdit: !isPdf && (fileId.endsWith('.docx') || fileId.endsWith('.xlsx')),
|
||||
canAnnotate: isPdf,
|
||||
isPdf: isPdf,
|
||||
);
|
||||
// Mock URL
|
||||
final viewUrl = Uri.parse(
|
||||
'https://office.b0esche.cloud/viewer/$orgId/$fileId',
|
||||
);
|
||||
final token = 'mock-viewer-token';
|
||||
final expiresAt = DateTime.now().add(const Duration(minutes: 30));
|
||||
return ViewerSession(
|
||||
viewUrl: viewUrl,
|
||||
capabilities: caps,
|
||||
token: token,
|
||||
expiresAt: expiresAt,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> saveAnnotations(
|
||||
String orgId,
|
||||
String fileId,
|
||||
List<Annotation> annotations,
|
||||
) async {
|
||||
await Future.delayed(const Duration(seconds: 2));
|
||||
// Mock: just delay, assume success
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,9 @@ class ApiClient {
|
||||
BaseOptions(
|
||||
baseUrl: baseUrl,
|
||||
connectTimeout: const Duration(seconds: 10),
|
||||
receiveTimeout: const Duration(seconds: 10),
|
||||
receiveTimeout: const Duration(
|
||||
seconds: 60,
|
||||
), // Increased for file uploads and org operations
|
||||
),
|
||||
);
|
||||
|
||||
@@ -29,23 +31,13 @@ class ApiClient {
|
||||
},
|
||||
onError: (error, handler) async {
|
||||
if (error.response?.statusCode == 401) {
|
||||
// Try refresh
|
||||
final refreshSuccess = await _tryRefreshToken();
|
||||
if (refreshSuccess) {
|
||||
// Retry the request
|
||||
final token = _getCurrentToken();
|
||||
if (token != null) {
|
||||
error.requestOptions.headers['Authorization'] = 'Bearer $token';
|
||||
try {
|
||||
final response = await _dio.fetch(error.requestOptions);
|
||||
return handler.resolve(response);
|
||||
} catch (e) {
|
||||
// If retry fails, proceed to error
|
||||
}
|
||||
}
|
||||
final path = error.requestOptions.path;
|
||||
// Do not expire session for auth endpoints; show inline error instead
|
||||
final isAuthEndpoint = path.startsWith('/auth/');
|
||||
if (!isAuthEndpoint) {
|
||||
// Session expired, trigger logout
|
||||
_sessionBloc.add(SessionExpired());
|
||||
}
|
||||
// If refresh failed, logout
|
||||
_sessionBloc.add(SessionExpired());
|
||||
}
|
||||
return handler.next(error);
|
||||
},
|
||||
@@ -53,6 +45,8 @@ class ApiClient {
|
||||
);
|
||||
}
|
||||
|
||||
String get baseUrl => _dio.options.baseUrl;
|
||||
|
||||
String? _getCurrentToken() {
|
||||
// Get from SessionBloc state
|
||||
final state = _sessionBloc.state;
|
||||
@@ -62,20 +56,6 @@ class ApiClient {
|
||||
return null;
|
||||
}
|
||||
|
||||
Future<bool> _tryRefreshToken() async {
|
||||
try {
|
||||
final response = await _dio.post('/auth/refresh');
|
||||
if (response.statusCode == 200) {
|
||||
final newToken = response.data['token'];
|
||||
_sessionBloc.add(SessionRefreshed(newToken));
|
||||
return true;
|
||||
}
|
||||
} catch (e) {
|
||||
// Refresh failed
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
Future<T> get<T>(
|
||||
String path, {
|
||||
Map<String, dynamic>? queryParameters,
|
||||
@@ -96,6 +76,7 @@ class ApiClient {
|
||||
}) async {
|
||||
try {
|
||||
final response = await _dio.post(path, data: data);
|
||||
|
||||
return fromJson(response.data);
|
||||
} on DioException catch (e) {
|
||||
throw _handleError(e);
|
||||
@@ -131,8 +112,17 @@ class ApiClient {
|
||||
);
|
||||
}
|
||||
|
||||
String code = data?['code'] ?? 'UNKNOWN';
|
||||
String message = data?['message'] ?? 'Unknown error';
|
||||
// Only try to extract code/message if data is a Map
|
||||
String code = 'UNKNOWN';
|
||||
String message = 'Unknown error';
|
||||
|
||||
if (data is Map<String, dynamic>) {
|
||||
code = data['code'] ?? 'UNKNOWN';
|
||||
message = data['message'] ?? 'Unknown error';
|
||||
} else if (data != null) {
|
||||
message = data.toString();
|
||||
}
|
||||
|
||||
return ApiError(code: code, message: message, status: status);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,19 +3,40 @@ import '../models/viewer_session.dart';
|
||||
import '../models/editor_session.dart';
|
||||
import '../models/annotation.dart';
|
||||
import 'api_client.dart';
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
class FileService {
|
||||
final ApiClient _apiClient;
|
||||
|
||||
FileService(this._apiClient);
|
||||
|
||||
String get baseUrl => _apiClient.baseUrl;
|
||||
|
||||
Future<List<FileItem>> getFiles(String orgId, String path) async {
|
||||
if (path.isEmpty) {
|
||||
throw Exception('Path cannot be empty');
|
||||
}
|
||||
final pathParam = {'path': path};
|
||||
if (orgId.isEmpty) {
|
||||
return await _apiClient.getList(
|
||||
'/user/files',
|
||||
queryParameters: pathParam,
|
||||
fromJson: (data) => FileItem(
|
||||
id: data['id'],
|
||||
name: data['name'],
|
||||
path: data['path'],
|
||||
type: data['type'] == 'file' ? FileType.file : FileType.folder,
|
||||
size: data['size'],
|
||||
lastModified: DateTime.parse(data['lastModified']),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return await _apiClient.getList(
|
||||
'/orgs/$orgId/files',
|
||||
queryParameters: pathParam,
|
||||
fromJson: (data) => FileItem(
|
||||
id: data['id'],
|
||||
name: data['name'],
|
||||
path: data['path'],
|
||||
type: data['type'] == 'file' ? FileType.file : FileType.folder,
|
||||
@@ -30,11 +51,67 @@ class FileService {
|
||||
}
|
||||
|
||||
Future<void> uploadFile(String orgId, FileItem file) async {
|
||||
throw UnimplementedError();
|
||||
// If bytes or localPath available, send multipart upload with field 'file'
|
||||
final Map<String, dynamic> fields = {'path': file.path};
|
||||
FormData formData;
|
||||
|
||||
if (file.bytes != null) {
|
||||
formData = FormData.fromMap({
|
||||
...fields,
|
||||
'file': MultipartFile.fromBytes(file.bytes!, filename: file.name),
|
||||
});
|
||||
} else if (file.localPath != null) {
|
||||
formData = FormData.fromMap({
|
||||
...fields,
|
||||
'file': MultipartFile.fromFile(file.localPath!, filename: file.name),
|
||||
});
|
||||
} else {
|
||||
// Fallback to metadata-only create (folders or client that can't send file content)
|
||||
final data = {
|
||||
'name': file.name,
|
||||
'path': file.path,
|
||||
'type': file.type == FileType.file ? 'file' : 'folder',
|
||||
'size': file.size,
|
||||
};
|
||||
if (orgId.isEmpty) {
|
||||
await _apiClient.post('/user/files', data: data, fromJson: (d) => null);
|
||||
return;
|
||||
}
|
||||
await _apiClient.post(
|
||||
'/orgs/$orgId/files',
|
||||
data: data,
|
||||
fromJson: (d) => null,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
final endpoint = orgId.isEmpty ? '/user/files' : '/orgs/$orgId/files';
|
||||
await _apiClient.post(endpoint, data: formData, fromJson: (d) => null);
|
||||
}
|
||||
|
||||
Future<void> deleteFile(String orgId, String path) async {
|
||||
throw UnimplementedError();
|
||||
final data = {'path': path};
|
||||
if (orgId.isEmpty) {
|
||||
await _apiClient.post(
|
||||
'/user/files/delete',
|
||||
data: data,
|
||||
fromJson: (d) => null,
|
||||
);
|
||||
return;
|
||||
}
|
||||
await _apiClient.post(
|
||||
'/orgs/$orgId/files/delete',
|
||||
data: data,
|
||||
fromJson: (d) => null,
|
||||
);
|
||||
}
|
||||
|
||||
Future<String> getDownloadUrl(String orgId, String path) async {
|
||||
// Return the download URL for the file
|
||||
if (orgId.isEmpty) {
|
||||
return '/user/files/download?path=${Uri.encodeComponent(path)}';
|
||||
}
|
||||
return '/orgs/$orgId/files/download?path=${Uri.encodeComponent(path)}';
|
||||
}
|
||||
|
||||
Future<void> createFolder(
|
||||
@@ -42,7 +119,41 @@ class FileService {
|
||||
String parentPath,
|
||||
String folderName,
|
||||
) async {
|
||||
throw UnimplementedError();
|
||||
// Normalize folder name to avoid accidental leading slashes creating double-slash paths
|
||||
final normalizedName = folderName
|
||||
.replaceAll(RegExp(r'^/+'), '')
|
||||
.replaceAll(RegExp(r'/+$'), '');
|
||||
if (normalizedName.isEmpty) {
|
||||
throw Exception('Folder name cannot be empty');
|
||||
}
|
||||
|
||||
// Construct proper path: /parent/folder or /folder for root
|
||||
String path;
|
||||
if (parentPath == '/') {
|
||||
path = '/$normalizedName';
|
||||
} else {
|
||||
// Ensure parentPath doesn't end with / before appending
|
||||
final cleanParent = parentPath.endsWith('/')
|
||||
? parentPath.substring(0, parentPath.length - 1)
|
||||
: parentPath;
|
||||
path = '$cleanParent/$normalizedName';
|
||||
}
|
||||
|
||||
final data = {
|
||||
'name': normalizedName,
|
||||
'path': path,
|
||||
'type': 'folder',
|
||||
'size': 0,
|
||||
};
|
||||
if (orgId.isEmpty) {
|
||||
await _apiClient.post('/user/files', data: data, fromJson: (d) => null);
|
||||
return;
|
||||
}
|
||||
await _apiClient.post(
|
||||
'/orgs/$orgId/files',
|
||||
data: data,
|
||||
fromJson: (d) => null,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> moveFile(
|
||||
@@ -50,7 +161,14 @@ class FileService {
|
||||
String sourcePath,
|
||||
String targetPath,
|
||||
) async {
|
||||
throw UnimplementedError();
|
||||
final endpoint = orgId.isEmpty
|
||||
? '/user/files/move'
|
||||
: '/orgs/$orgId/files/move';
|
||||
await _apiClient.post(
|
||||
endpoint,
|
||||
data: {'sourcePath': sourcePath, 'targetPath': targetPath},
|
||||
fromJson: (d) => null,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> renameFile(String orgId, String path, String newName) async {
|
||||
@@ -65,11 +183,14 @@ class FileService {
|
||||
String orgId,
|
||||
String fileId,
|
||||
) async {
|
||||
if (orgId.isEmpty || fileId.isEmpty) {
|
||||
throw Exception('OrgId and fileId cannot be empty');
|
||||
if (fileId.isEmpty) {
|
||||
throw Exception('fileId cannot be empty');
|
||||
}
|
||||
final path = orgId.isEmpty
|
||||
? '/user/files/$fileId/view'
|
||||
: '/orgs/$orgId/files/$fileId/view';
|
||||
return await _apiClient.get(
|
||||
'/orgs/$orgId/files/$fileId/view',
|
||||
path,
|
||||
fromJson: (data) => ViewerSession.fromJson(data),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import '../blocs/organization/organization_state.dart';
|
||||
import 'api_client.dart';
|
||||
import 'dart:developer' as developer;
|
||||
|
||||
class OrgApi {
|
||||
final ApiClient _apiClient;
|
||||
@@ -14,10 +15,18 @@ class OrgApi {
|
||||
}
|
||||
|
||||
Future<Organization> createOrganization(String name) async {
|
||||
return await _apiClient.post(
|
||||
'/orgs',
|
||||
data: {'name': name},
|
||||
fromJson: (data) => Organization.fromJson(data),
|
||||
);
|
||||
developer.log('POST /orgs with payload: {"name": "$name"}', name: 'OrgApi');
|
||||
|
||||
try {
|
||||
final result = await _apiClient.post(
|
||||
'/orgs',
|
||||
data: {'name': name},
|
||||
fromJson: (data) => Organization.fromJson(data),
|
||||
);
|
||||
|
||||
return result;
|
||||
} catch (e) {
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -553,7 +553,7 @@ packages:
|
||||
source: hosted
|
||||
version: "2.0.1"
|
||||
http:
|
||||
dependency: transitive
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: http
|
||||
sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412"
|
||||
@@ -1422,7 +1422,7 @@ packages:
|
||||
source: hosted
|
||||
version: "1.2.0"
|
||||
web:
|
||||
dependency: transitive
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: web
|
||||
sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a"
|
||||
|
||||
@@ -55,6 +55,8 @@ dependencies:
|
||||
infinite_scroll_pagination: ^5.1.1
|
||||
collection: ^1.18.0
|
||||
syncfusion_flutter_pdfviewer: ^31.1.21
|
||||
web: ^1.1.0
|
||||
http: ^1.2.0
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
|
||||
@@ -1,117 +0,0 @@
|
||||
import 'package:bloc_test/bloc_test.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:mockito/mockito.dart';
|
||||
import 'package:b0esche_cloud/blocs/document_viewer/document_viewer_bloc.dart';
|
||||
import 'package:b0esche_cloud/blocs/document_viewer/document_viewer_event.dart';
|
||||
import 'package:b0esche_cloud/blocs/document_viewer/document_viewer_state.dart';
|
||||
import 'package:b0esche_cloud/services/file_service.dart';
|
||||
import 'package:b0esche_cloud/models/viewer_session.dart';
|
||||
import 'package:b0esche_cloud/models/document_capabilities.dart';
|
||||
import 'package:b0esche_cloud/models/api_error.dart';
|
||||
|
||||
class MockFileService extends Mock implements FileService {
|
||||
Future<ViewerSession>? _viewerResponse;
|
||||
|
||||
void setViewerResponse(Future<ViewerSession> response) {
|
||||
_viewerResponse = response;
|
||||
}
|
||||
|
||||
void resetMock() {
|
||||
_viewerResponse = null;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<ViewerSession> requestViewerSession(String orgId, String fileId) {
|
||||
return _viewerResponse ??
|
||||
super.noSuchMethod(
|
||||
Invocation.method(#requestViewerSession, [orgId, fileId]),
|
||||
returnValue: Future.value(null),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// @override
|
||||
// Future<ViewerSession> requestViewerSession(String orgId, String fileId) {
|
||||
// return _viewerResponse ??
|
||||
// super.noSuchMethod(
|
||||
// Invocation.method(#requestViewerSession, [orgId, fileId]),
|
||||
// returnValue: Future.value(null),
|
||||
// );
|
||||
// }
|
||||
// }
|
||||
|
||||
void main() {
|
||||
late MockFileService mockFileService;
|
||||
|
||||
setUp(() {
|
||||
mockFileService = MockFileService();
|
||||
});
|
||||
|
||||
tearDown(() {
|
||||
reset(mockFileService);
|
||||
mockFileService.resetMock();
|
||||
});
|
||||
|
||||
group('DocumentViewerBloc', () {
|
||||
blocTest<DocumentViewerBloc, DocumentViewerState>(
|
||||
'emits [DocumentViewerLoading, DocumentViewerError] when DocumentOpened fails',
|
||||
build: () {
|
||||
mockFileService.setViewerResponse(
|
||||
Future.error(
|
||||
ApiError(
|
||||
code: 'server_error',
|
||||
message: 'Server error',
|
||||
status: 500,
|
||||
),
|
||||
),
|
||||
);
|
||||
return DocumentViewerBloc(mockFileService);
|
||||
},
|
||||
act: (bloc) => bloc.add(DocumentOpened(orgId: 'org1', fileId: 'file1')),
|
||||
expect: () => [
|
||||
DocumentViewerLoading(),
|
||||
DocumentViewerReady(
|
||||
viewUrl: Uri.parse('https://example.com/view'),
|
||||
caps: DocumentCapabilities(
|
||||
canEdit: true,
|
||||
canAnnotate: false,
|
||||
isPdf: false,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
blocTest<DocumentViewerBloc, DocumentViewerState>(
|
||||
'emits [DocumentViewerLoading, DocumentViewerReady] when DocumentOpened succeeds',
|
||||
build: () {
|
||||
mockFileService.setViewerResponse(
|
||||
Future.value(
|
||||
ViewerSession(
|
||||
viewUrl: Uri.parse('https://example.com/view'),
|
||||
capabilities: DocumentCapabilities(
|
||||
canEdit: true,
|
||||
canAnnotate: false,
|
||||
isPdf: false,
|
||||
),
|
||||
token: 'mock-token',
|
||||
expiresAt: DateTime.now().add(const Duration(minutes: 30)),
|
||||
),
|
||||
),
|
||||
);
|
||||
return DocumentViewerBloc(mockFileService);
|
||||
},
|
||||
act: (bloc) => bloc.add(DocumentOpened(orgId: 'org1', fileId: 'file1')),
|
||||
expect: () => [
|
||||
DocumentViewerLoading(),
|
||||
DocumentViewerError(message: 'Failed to open document: Server error'),
|
||||
],
|
||||
);
|
||||
|
||||
blocTest<DocumentViewerBloc, DocumentViewerState>(
|
||||
'emits [DocumentViewerInitial] when DocumentClosed',
|
||||
build: () => DocumentViewerBloc(mockFileService),
|
||||
act: (bloc) => bloc.add(DocumentClosed()),
|
||||
expect: () => [DocumentViewerInitial()],
|
||||
);
|
||||
});
|
||||
}
|
||||
|
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 |
@@ -30,31 +30,42 @@
|
||||
<!-- Favicon -->
|
||||
<link rel="icon" type="image/png" href="favicon.png" />
|
||||
|
||||
<!-- Preload fonts -->
|
||||
<link rel="preload" href="assets/fonts/veteran-typewriter/veteran_typewriter.ttf" as="font" type="font/ttf"
|
||||
crossorigin>
|
||||
<link rel="preload" href="assets/fonts/animal-park/animal_park.otf" as="font" type="font/otf" crossorigin>
|
||||
<link rel="preload" href="assets/fonts/renoire-demo/renoire_demo.otf" as="font" type="font/otf" crossorigin>
|
||||
<!-- Preload PixelatedElegance brand font -->
|
||||
<link rel="preload" href="assets/fonts/pixelated-elegance/PixelatedEleganceRegular-ovyAA.ttf" as="font"
|
||||
type="font/ttf" crossorigin>
|
||||
|
||||
<style>
|
||||
@font-face {
|
||||
font-family: 'VeteranTypewriter';
|
||||
src: url('assets/fonts/veteran-typewriter/veteran_typewriter.ttf') format('truetype');
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'AnimalPark';
|
||||
src: url('assets/fonts/animal-park/animal_park.otf') format('opentype');
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'RenoireDemo';
|
||||
src: url('assets/fonts/renoire-demo/renoire_demo.otf') format('opentype');
|
||||
font-family: 'PixelatedElegance';
|
||||
src: url('assets/fonts/pixelated-elegance/PixelatedEleganceRegular-ovyAA.ttf') format('truetype');
|
||||
}
|
||||
</style>
|
||||
|
||||
<title>b0esche_cloud</title>
|
||||
<link rel="manifest" href="manifest.json">
|
||||
|
||||
<!-- PDF.js library for SfPdfViewer on web - loaded asynchronously to avoid sync XHR warnings -->
|
||||
<script type="module">
|
||||
(async () => {
|
||||
const pdfjsLib = await import('https://cdnjs.cloudflare.com/ajax/libs/pdf.js/4.9.155/pdf.min.mjs');
|
||||
pdfjsLib.GlobalWorkerOptions.workerSrc = "https://cdnjs.cloudflare.com/ajax/libs/pdf.js/4.9.155/pdf.worker.min.mjs";
|
||||
window.pdfjsLib = pdfjsLib;
|
||||
})();
|
||||
</script>
|
||||
|
||||
<!-- Suppress v8BreakIterator deprecation warning (Flutter framework issue) -->
|
||||
<script>
|
||||
// This is a known Flutter web issue - the framework uses v8BreakIterator for feature detection
|
||||
// The warning can be safely ignored as Flutter handles the fallback to Intl.Segmenter internally
|
||||
// See: https://github.com/nickvds/my-public/issues
|
||||
const originalWarn = console.warn;
|
||||
console.warn = function (...args) {
|
||||
if (args[0] && typeof args[0] === 'string' && args[0].includes('v8BreakIterator')) {
|
||||
return; // Suppress this specific warning
|
||||
}
|
||||
originalWarn.apply(console, args);
|
||||
};
|
||||
</script>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
|
||||
32
go_cloud/Dockerfile
Normal file
@@ -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,27 @@
|
||||
module go.b0esche.cloud/backend
|
||||
|
||||
go 1.25.5
|
||||
go 1.24.0
|
||||
|
||||
require (
|
||||
github.com/coreos/go-oidc/v3 v3.17.0
|
||||
github.com/go-chi/chi/v5 v5.2.3
|
||||
github.com/golang-jwt/jwt/v5 v5.3.0
|
||||
github.com/golang-jwt/jwt/v4 v4.5.2
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/jackc/pgconn v1.13.0
|
||||
github.com/jackc/pgx/v5 v5.7.6
|
||||
golang.org/x/crypto v0.37.0
|
||||
golang.org/x/oauth2 v0.28.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/coreos/go-oidc/v3 v3.17.0 // indirect
|
||||
github.com/go-jose/go-jose/v4 v4.1.3 // indirect
|
||||
github.com/golang-jwt/jwt/v4 v4.5.2 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/jackc/chunkreader/v2 v2.0.1 // indirect
|
||||
github.com/jackc/pgio v1.0.0 // indirect
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgproto3/v2 v2.3.1 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||
github.com/jackc/pgx/v5 v5.7.6 // indirect
|
||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||
golang.org/x/crypto v0.37.0 // indirect
|
||||
golang.org/x/oauth2 v0.28.0 // indirect
|
||||
golang.org/x/sync v0.13.0 // indirect
|
||||
golang.org/x/sys v0.32.0 // indirect
|
||||
golang.org/x/text v0.24.0 // indirect
|
||||
)
|
||||
|
||||
131
go_cloud/go.sum
@@ -1,35 +1,162 @@
|
||||
github.com/cockroachdb/apd v1.1.0/go.mod h1:8Sl8LxpKi29FqWXR16WEFZRNSz3SoPzUzeMeY4+DwBQ=
|
||||
github.com/coreos/go-oidc/v3 v3.17.0 h1:hWBGaQfbi0iVviX4ibC7bk8OKT5qNr4klBaCHVNvehc=
|
||||
github.com/coreos/go-oidc/v3 v3.17.0/go.mod h1:wqPbKFrVnE90vty060SB40FCJ8fTHTxSwyXJqZH+sI8=
|
||||
github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4=
|
||||
github.com/coreos/go-systemd v0.0.0-20190719114852-fd7a80b32e1f/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4=
|
||||
github.com/creack/pty v1.1.7/go.mod h1:lj5s0c3V2DBrqTV7llrYr5NG6My20zk30Fl46Y7DoTY=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/go-chi/chi/v5 v5.2.3 h1:WQIt9uxdsAbgIYgid+BpYc+liqQZGMHRaUwp0JUcvdE=
|
||||
github.com/go-chi/chi/v5 v5.2.3/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops=
|
||||
github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs=
|
||||
github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
|
||||
github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
|
||||
github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI=
|
||||
github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
|
||||
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/jackc/chunkreader v1.0.0/go.mod h1:RT6O25fNZIuasFJRyZ4R/Y2BbhasbmZXF9QQ7T3kePo=
|
||||
github.com/jackc/chunkreader/v2 v2.0.0/go.mod h1:odVSm741yZoC3dpHEUXIqA9tQRhFrgOHwnPIn9lDKlk=
|
||||
github.com/jackc/chunkreader/v2 v2.0.1 h1:i+RDz65UE+mmpjTfyz0MoVTnzeYxroil2G82ki7MGG8=
|
||||
github.com/jackc/chunkreader/v2 v2.0.1/go.mod h1:odVSm741yZoC3dpHEUXIqA9tQRhFrgOHwnPIn9lDKlk=
|
||||
github.com/jackc/pgconn v0.0.0-20190420214824-7e0022ef6ba3/go.mod h1:jkELnwuX+w9qN5YIfX0fl88Ehu4XC3keFuOJJk9pcnA=
|
||||
github.com/jackc/pgconn v0.0.0-20190824142844-760dd75542eb/go.mod h1:lLjNuW/+OfW9/pnVKPazfWOgNfH2aPem8YQ7ilXGvJE=
|
||||
github.com/jackc/pgconn v0.0.0-20190831204454-2fabfa3c18b7/go.mod h1:ZJKsE/KZfsUgOEh9hBm+xYTstcNHg7UPMVJqRfQxq4s=
|
||||
github.com/jackc/pgconn v1.8.0/go.mod h1:1C2Pb36bGIP9QHGBYCjnyhqu7Rv3sGshaQUvmfGIB/o=
|
||||
github.com/jackc/pgconn v1.9.0/go.mod h1:YctiPyvzfU11JFxoXokUOOKQXQmDMoJL9vJzHH8/2JY=
|
||||
github.com/jackc/pgconn v1.13.0 h1:3L1XMNV2Zvca/8BYhzcRFS70Lr0WlDg16Di6SFGAbys=
|
||||
github.com/jackc/pgconn v1.13.0/go.mod h1:AnowpAqO4CMIIJNZl2VJp+KrkAZciAkhEl0W0JIobpI=
|
||||
github.com/jackc/pgio v1.0.0 h1:g12B9UwVnzGhueNavwioyEEpAmqMe1E/BN9ES+8ovkE=
|
||||
github.com/jackc/pgio v1.0.0/go.mod h1:oP+2QK2wFfUWgr+gxjoBH9KGBb31Eio69xUb0w5bYf8=
|
||||
github.com/jackc/pgmock v0.0.0-20190831213851-13a1b77aafa2/go.mod h1:fGZlG77KXmcq05nJLRkk0+p82V8B8Dw8KN2/V9c/OAE=
|
||||
github.com/jackc/pgmock v0.0.0-20201204152224-4fe30f7445fd/go.mod h1:hrBW0Enj2AZTNpt/7Y5rr2xe/9Mn757Wtb2xeBzPv2c=
|
||||
github.com/jackc/pgmock v0.0.0-20210724152146-4ad1a8207f65 h1:DadwsjnMwFjfWc9y5Wi/+Zz7xoE5ALHsRQlOctkOiHc=
|
||||
github.com/jackc/pgmock v0.0.0-20210724152146-4ad1a8207f65/go.mod h1:5R2h2EEX+qri8jOWMbJCtaPWkrrNc7OHwsp2TCqp7ak=
|
||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||
github.com/jackc/pgproto3 v1.1.0/go.mod h1:eR5FA3leWg7p9aeAqi37XOTgTIbkABlvcPB3E5rlc78=
|
||||
github.com/jackc/pgproto3/v2 v2.0.0-alpha1.0.20190420180111-c116219b62db/go.mod h1:bhq50y+xrl9n5mRYyCBFKkpRVTLYJVWeCc+mEAI3yXA=
|
||||
github.com/jackc/pgproto3/v2 v2.0.0-alpha1.0.20190609003834-432c2951c711/go.mod h1:uH0AWtUmuShn0bcesswc4aBTWGvw0cAxIJp+6OB//Wg=
|
||||
github.com/jackc/pgproto3/v2 v2.0.0-rc3/go.mod h1:ryONWYqW6dqSg1Lw6vXNMXoBJhpzvWKnT95C46ckYeM=
|
||||
github.com/jackc/pgproto3/v2 v2.0.0-rc3.0.20190831210041-4c03ce451f29/go.mod h1:ryONWYqW6dqSg1Lw6vXNMXoBJhpzvWKnT95C46ckYeM=
|
||||
github.com/jackc/pgproto3/v2 v2.0.6/go.mod h1:WfJCnwN3HIg9Ish/j3sgWXnAfK8A9Y0bwXYU5xKaEdA=
|
||||
github.com/jackc/pgproto3/v2 v2.1.1/go.mod h1:WfJCnwN3HIg9Ish/j3sgWXnAfK8A9Y0bwXYU5xKaEdA=
|
||||
github.com/jackc/pgproto3/v2 v2.3.1 h1:nwj7qwf0S+Q7ISFfBndqeLwSwxs+4DPsbRFjECT1Y4Y=
|
||||
github.com/jackc/pgproto3/v2 v2.3.1/go.mod h1:WfJCnwN3HIg9Ish/j3sgWXnAfK8A9Y0bwXYU5xKaEdA=
|
||||
github.com/jackc/pgservicefile v0.0.0-20200714003250-2b9c44734f2b/go.mod h1:vsD4gTJCa9TptPL8sPkXrLZ+hDuNrZCnj29CQpr4X1E=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||
github.com/jackc/pgtype v0.0.0-20190421001408-4ed0de4755e0/go.mod h1:hdSHsc1V01CGwFsrv11mJRHWJ6aifDLfdV3aVjFF0zg=
|
||||
github.com/jackc/pgtype v0.0.0-20190824184912-ab885b375b90/go.mod h1:KcahbBH1nCMSo2DXpzsoWOAfFkdEtEJpPbVLq8eE+mc=
|
||||
github.com/jackc/pgtype v0.0.0-20190828014616-a8802b16cc59/go.mod h1:MWlu30kVJrUS8lot6TQqcg7mtthZ9T0EoIBFiJcmcyw=
|
||||
github.com/jackc/pgx/v4 v4.0.0-20190420224344-cc3461e65d96/go.mod h1:mdxmSJJuR08CZQyj1PVQBHy9XOp5p8/SHH6a0psbY9Y=
|
||||
github.com/jackc/pgx/v4 v4.0.0-20190421002000-1b8f0016e912/go.mod h1:no/Y67Jkk/9WuGR0JG/JseM9irFbnEPbuWV2EELPNuM=
|
||||
github.com/jackc/pgx/v4 v4.0.0-pre1.0.20190824185557-6972a5742186/go.mod h1:X+GQnOEnf1dqHGpw7JmHqHc1NxDoalibchSk9/RWuDc=
|
||||
github.com/jackc/pgx/v5 v5.7.6 h1:rWQc5FwZSPX58r1OQmkuaNicxdmExaEz5A2DO2hUuTk=
|
||||
github.com/jackc/pgx/v5 v5.7.6/go.mod h1:aruU7o91Tc2q2cFp5h4uP3f6ztExVpyVv88Xl/8Vl8M=
|
||||
github.com/jackc/puddle v0.0.0-20190413234325-e4ced69a3a2b/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk=
|
||||
github.com/jackc/puddle v0.0.0-20190608224051-11cab39313c9/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk=
|
||||
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
|
||||
github.com/konsorten/go-windows-terminal-sequences v1.0.2/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
|
||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
github.com/kr/pty v1.1.8/go.mod h1:O1sed60cT9XZ5uDucP5qwvh+TE3NnUj51EiZO/lmSfw=
|
||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||
github.com/lib/pq v1.0.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
|
||||
github.com/lib/pq v1.1.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
|
||||
github.com/lib/pq v1.2.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
|
||||
github.com/mattn/go-colorable v0.1.1/go.mod h1:FuOcm+DKB9mbwrcAfNl7/TZVBZ6rcnceauSikq3lYCQ=
|
||||
github.com/mattn/go-isatty v0.0.5/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
|
||||
github.com/mattn/go-isatty v0.0.7/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
|
||||
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/rs/xid v1.2.1/go.mod h1:+uKXf+4Djp6Md1KODXJxgGQPKngRmWyn10oCKFzNHOQ=
|
||||
github.com/rs/zerolog v1.13.0/go.mod h1:YbFCdg8HfsridGWAh22vktObvhZbQsZXe4/zB0OKkWU=
|
||||
github.com/rs/zerolog v1.15.0/go.mod h1:xYTKnLHcpfU2225ny5qZjxnj9NvkumZYjJHlAThCjNc=
|
||||
github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0=
|
||||
github.com/shopspring/decimal v0.0.0-20180709203117-cd690d0c9e24/go.mod h1:M+9NzErvs504Cn4c5DxATwIqPbtswREoFCre64PpcG4=
|
||||
github.com/sirupsen/logrus v1.4.1/go.mod h1:ni0Sbl8bgC9z8RoU9G6nDWqqs/fq4eDPysMBDgk/93Q=
|
||||
github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.2.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
|
||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
github.com/zenazn/goji v0.9.0/go.mod h1:7S9M489iMyHBNxwZnk9/EHS098H4/F6TATF2mIxtB1Q=
|
||||
go.uber.org/atomic v1.3.2/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE=
|
||||
go.uber.org/atomic v1.4.0/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE=
|
||||
go.uber.org/multierr v1.1.0/go.mod h1:wR5kodmAFQ0UK8QlbwjlSNy0Z68gJhDJUG5sjR94q/0=
|
||||
go.uber.org/zap v1.9.1/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q=
|
||||
go.uber.org/zap v1.10.0/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20190411191339-88737f569e3a/go.mod h1:WFFai1msRO1wXaEeE5yQxYXgSfI8pQAWXbQop6sCtWE=
|
||||
golang.org/x/crypto v0.0.0-20190820162420-60c769a6c586/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/crypto v0.0.0-20201203163018-be400aefbc4c/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I=
|
||||
golang.org/x/crypto v0.0.0-20210616213533-5ff15b29337e/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.0.0-20210711020723-a769d52b0f97/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.0.0-20220722155217-630584e8d5aa/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
|
||||
golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE=
|
||||
golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc=
|
||||
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20190813141303-74dc4d7220e7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||
golang.org/x/oauth2 v0.28.0 h1:CrgCKl8PPAVtLnU3c+EDw6x11699EWlsDeWNWKdIOkc=
|
||||
golang.org/x/oauth2 v0.28.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT8=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610=
|
||||
golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
||||
golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190403152447-81d4e9dc473e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20190813064441-fde4db37ae7a/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20=
|
||||
golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0=
|
||||
golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20190425163242-31fd60d6bfdc/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
|
||||
golang.org/x/tools v0.0.0-20190823170909-c4a336ef6a2f/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/xerrors v0.0.0-20190410155217-1f06c39b4373/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20190513163551-3ee3066db522/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/inconshreveable/log15.v2 v2.0.0-20180818164646-67afb5ed74ec/go.mod h1:aPpfJ7XW+gOuirDoZ8gHhLh3kZ1B08FtV2bbmy7Jv3s=
|
||||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
|
||||
@@ -6,9 +6,11 @@ import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"go.b0esche.cloud/backend/internal/database"
|
||||
"golang.org/x/crypto/argon2"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
@@ -17,6 +19,12 @@ const (
|
||||
RPID = "b0esche.cloud"
|
||||
RPName = "b0esche Cloud"
|
||||
Origin = "https://b0esche.cloud"
|
||||
|
||||
// Argon2id parameters (OWASP recommendations)
|
||||
Argon2Time = 2 // iterations
|
||||
Argon2Memory = 19 * 1024 // 19 MB
|
||||
Argon2Threads = 1
|
||||
Argon2KeyLen = 32
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
@@ -284,19 +292,76 @@ func byteArraysEqual(a, b []byte) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
// HashPassword hashes a password using bcrypt
|
||||
// HashPassword hashes a password using Argon2id (quantum-resistant)
|
||||
// Format: $argon2id$v=19$m=19456,t=2,p=1$<salt>$<hash>
|
||||
func (s *Service) HashPassword(password string) (string, error) {
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to hash password: %w", err)
|
||||
// Generate 16-byte random salt
|
||||
salt := make([]byte, 16)
|
||||
if _, err := rand.Read(salt); err != nil {
|
||||
return "", fmt.Errorf("failed to generate salt: %w", err)
|
||||
}
|
||||
return string(hash), nil
|
||||
|
||||
// Hash with Argon2id
|
||||
hash := argon2.IDKey([]byte(password), salt, Argon2Time, Argon2Memory, Argon2Threads, Argon2KeyLen)
|
||||
|
||||
// Encode in PHC string format
|
||||
b64Salt := base64.RawStdEncoding.EncodeToString(salt)
|
||||
b64Hash := base64.RawStdEncoding.EncodeToString(hash)
|
||||
|
||||
return fmt.Sprintf("$argon2id$v=19$m=%d,t=%d,p=%d$%s$%s",
|
||||
Argon2Memory, Argon2Time, Argon2Threads, b64Salt, b64Hash), nil
|
||||
}
|
||||
|
||||
// VerifyPassword checks if a password matches its hash
|
||||
// Supports both Argon2id (new) and bcrypt (legacy) for backward compatibility
|
||||
func (s *Service) VerifyPassword(passwordHash string, password string) bool {
|
||||
err := bcrypt.CompareHashAndPassword([]byte(passwordHash), []byte(password))
|
||||
return err == nil
|
||||
// Detect hash format
|
||||
if strings.HasPrefix(passwordHash, "$argon2id$") {
|
||||
return s.verifyArgon2(passwordHash, password)
|
||||
} else if strings.HasPrefix(passwordHash, "$2") {
|
||||
// Legacy bcrypt hash
|
||||
err := bcrypt.CompareHashAndPassword([]byte(passwordHash), []byte(password))
|
||||
return err == nil
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (s *Service) verifyArgon2(encodedHash string, password string) bool {
|
||||
// Parse PHC format: $argon2id$v=19$m=19456,t=2,p=1$<salt>$<hash>
|
||||
parts := strings.Split(encodedHash, "$")
|
||||
if len(parts) != 6 {
|
||||
return false
|
||||
}
|
||||
|
||||
var memory, time uint32
|
||||
var threads uint8
|
||||
_, err := fmt.Sscanf(parts[3], "m=%d,t=%d,p=%d", &memory, &time, &threads)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
salt, err := base64.RawStdEncoding.DecodeString(parts[4])
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
hash, err := base64.RawStdEncoding.DecodeString(parts[5])
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
// Compute hash with same parameters
|
||||
computedHash := argon2.IDKey([]byte(password), salt, time, memory, threads, uint32(len(hash)))
|
||||
|
||||
// Constant-time comparison
|
||||
if len(hash) != len(computedHash) {
|
||||
return false
|
||||
}
|
||||
var diff byte
|
||||
for i := 0; i < len(hash); i++ {
|
||||
diff |= hash[i] ^ computedHash[i]
|
||||
}
|
||||
return diff == 0
|
||||
}
|
||||
|
||||
// VerifyPasswordLogin verifies username and password credentials
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
)
|
||||
|
||||
@@ -12,10 +13,15 @@ type Config struct {
|
||||
OIDCClientID string
|
||||
OIDCClientSecret string
|
||||
JWTSecret string
|
||||
NextcloudURL string
|
||||
NextcloudUser string
|
||||
NextcloudPass string
|
||||
NextcloudBase string
|
||||
AllowedOrigins string
|
||||
}
|
||||
|
||||
func Load() *Config {
|
||||
return &Config{
|
||||
cfg := &Config{
|
||||
ServerAddr: getEnv("SERVER_ADDR", ":8080"),
|
||||
DatabaseURL: os.Getenv("DATABASE_URL"),
|
||||
OIDCIssuerURL: os.Getenv("OIDC_ISSUER_URL"),
|
||||
@@ -23,7 +29,14 @@ func Load() *Config {
|
||||
OIDCClientID: os.Getenv("OIDC_CLIENT_ID"),
|
||||
OIDCClientSecret: os.Getenv("OIDC_CLIENT_SECRET"),
|
||||
JWTSecret: os.Getenv("JWT_SECRET"),
|
||||
NextcloudURL: os.Getenv("NEXTCLOUD_URL"),
|
||||
NextcloudUser: os.Getenv("NEXTCLOUD_USER"),
|
||||
NextcloudPass: os.Getenv("NEXTCLOUD_PASSWORD"),
|
||||
NextcloudBase: getEnv("NEXTCLOUD_BASEPATH", "/"),
|
||||
AllowedOrigins: getEnv("ALLOWED_ORIGINS", "https://b0esche.cloud,https://www.b0esche.cloud,https://*.b0esche.cloud,http://localhost:8080"),
|
||||
}
|
||||
fmt.Printf("[CONFIG] Nextcloud URL: %q, User: %q, BasePath: %q\n", cfg.NextcloudURL, cfg.NextcloudUser, cfg.NextcloudBase)
|
||||
return cfg
|
||||
}
|
||||
|
||||
func getEnv(key, defaultVal string) string {
|
||||
|
||||
@@ -3,6 +3,9 @@ package database
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"database/sql/driver"
|
||||
"encoding/json"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
@@ -16,6 +19,49 @@ func New(db *sql.DB) *DB {
|
||||
return &DB{DB: db}
|
||||
}
|
||||
|
||||
// StringArray handles nullable string arrays from PostgreSQL
|
||||
type StringArray []string
|
||||
|
||||
// Scan handles NULL values properly
|
||||
func (sa *StringArray) Scan(value interface{}) error {
|
||||
if value == nil {
|
||||
*sa = StringArray{}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Handle byte slice from PostgreSQL array
|
||||
if bytes, ok := value.([]byte); ok {
|
||||
var arr []string
|
||||
if err := json.Unmarshal(bytes, &arr); err != nil {
|
||||
// If JSON parse fails, try as raw string
|
||||
*sa = StringArray{string(bytes)}
|
||||
return nil
|
||||
}
|
||||
*sa = StringArray(arr)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Handle string directly
|
||||
if str, ok := value.(string); ok {
|
||||
if str == "" {
|
||||
*sa = StringArray{}
|
||||
return nil
|
||||
}
|
||||
*sa = StringArray{str}
|
||||
return nil
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Value implements the driver.Valuer interface
|
||||
func (sa StringArray) Value() (driver.Value, error) {
|
||||
if len(sa) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
return json.Marshal(sa)
|
||||
}
|
||||
|
||||
type User struct {
|
||||
ID uuid.UUID
|
||||
Email string
|
||||
@@ -34,7 +80,7 @@ type Credential struct {
|
||||
SignCount int64
|
||||
CreatedAt time.Time
|
||||
LastUsedAt *time.Time
|
||||
Transports []string
|
||||
Transports StringArray
|
||||
}
|
||||
|
||||
type AuthChallenge struct {
|
||||
@@ -55,10 +101,11 @@ type Session struct {
|
||||
}
|
||||
|
||||
type Organization struct {
|
||||
ID uuid.UUID
|
||||
Name string
|
||||
Slug string
|
||||
CreatedAt time.Time
|
||||
ID uuid.UUID `json:"id"`
|
||||
OwnerID uuid.UUID `json:"ownerId"`
|
||||
Name string `json:"name"`
|
||||
Slug string `json:"slug"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
}
|
||||
|
||||
type Membership struct {
|
||||
@@ -78,6 +125,20 @@ type Activity struct {
|
||||
Timestamp time.Time
|
||||
}
|
||||
|
||||
type File struct {
|
||||
ID uuid.UUID
|
||||
OrgID *uuid.UUID
|
||||
UserID *uuid.UUID
|
||||
Name string
|
||||
Path string
|
||||
Type string
|
||||
Size int64
|
||||
LastModified time.Time
|
||||
CreatedAt time.Time
|
||||
ModifiedBy *uuid.UUID
|
||||
ModifiedByName string
|
||||
}
|
||||
|
||||
func (db *DB) GetOrCreateUser(ctx context.Context, sub, email, name string) (*User, error) {
|
||||
var user User
|
||||
err := db.QueryRowContext(ctx, `
|
||||
@@ -120,9 +181,18 @@ func (db *DB) GetSession(ctx context.Context, sessionID uuid.UUID) (*Session, er
|
||||
return &session, nil
|
||||
}
|
||||
|
||||
func (db *DB) RevokeSession(ctx context.Context, sessionID uuid.UUID) error {
|
||||
_, err := db.ExecContext(ctx, `
|
||||
UPDATE sessions
|
||||
SET revoked_at = NOW()
|
||||
WHERE id = $1 AND revoked_at IS NULL
|
||||
`, sessionID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (db *DB) GetUserOrganizations(ctx context.Context, userID uuid.UUID) ([]Organization, error) {
|
||||
rows, err := db.QueryContext(ctx, `
|
||||
SELECT o.id, o.name, o.slug, o.created_at
|
||||
SELECT o.id, o.owner_id, o.name, o.slug, o.created_at
|
||||
FROM organizations o
|
||||
JOIN memberships m ON o.id = m.org_id
|
||||
WHERE m.user_id = $1
|
||||
@@ -135,7 +205,7 @@ func (db *DB) GetUserOrganizations(ctx context.Context, userID uuid.UUID) ([]Org
|
||||
var orgs []Organization
|
||||
for rows.Next() {
|
||||
var org Organization
|
||||
if err := rows.Scan(&org.ID, &org.Name, &org.Slug, &org.CreatedAt); err != nil {
|
||||
if err := rows.Scan(&org.ID, &org.OwnerID, &org.Name, &org.Slug, &org.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
orgs = append(orgs, org)
|
||||
@@ -156,13 +226,18 @@ func (db *DB) GetUserMembership(ctx context.Context, userID, orgID uuid.UUID) (*
|
||||
return &membership, nil
|
||||
}
|
||||
|
||||
func (db *DB) CreateOrg(ctx context.Context, name, slug string) (*Organization, error) {
|
||||
// GetOrgMember is an alias for GetUserMembership - checks if user is a member of an org
|
||||
func (db *DB) GetOrgMember(ctx context.Context, orgID, userID uuid.UUID) (*Membership, error) {
|
||||
return db.GetUserMembership(ctx, userID, orgID)
|
||||
}
|
||||
|
||||
func (db *DB) CreateOrg(ctx context.Context, ownerID uuid.UUID, name, slug string) (*Organization, error) {
|
||||
var org Organization
|
||||
err := db.QueryRowContext(ctx, `
|
||||
INSERT INTO organizations (name, slug)
|
||||
VALUES ($1, $2)
|
||||
RETURNING id, name, slug, created_at
|
||||
`, name, slug).Scan(&org.ID, &org.Name, &org.Slug, &org.CreatedAt)
|
||||
INSERT INTO organizations (owner_id, name, slug)
|
||||
VALUES ($1, $2, $3)
|
||||
RETURNING id, owner_id, name, slug, created_at
|
||||
`, ownerID, name, slug).Scan(&org.ID, &org.OwnerID, &org.Name, &org.Slug, &org.CreatedAt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -233,6 +308,228 @@ func (db *DB) GetOrgMembers(ctx context.Context, orgID uuid.UUID) ([]Membership,
|
||||
return memberships, rows.Err()
|
||||
}
|
||||
|
||||
// GetOrgFiles returns files for a given organization (top-level folder listing)
|
||||
func (db *DB) GetOrgFiles(ctx context.Context, orgID uuid.UUID, userID uuid.UUID, path string, q string, page, pageSize int) ([]File, error) {
|
||||
if page <= 0 {
|
||||
page = 1
|
||||
}
|
||||
if pageSize <= 0 {
|
||||
pageSize = 100
|
||||
}
|
||||
offset := (page - 1) * pageSize
|
||||
|
||||
orgIDStr := orgID.String()
|
||||
userIDStr := userID.String()
|
||||
log.Printf("[DATA-ISOLATION] stage=before, action=list, orgId=%s, userId=%s, fileCount=0, path=%s", orgIDStr, userIDStr, path)
|
||||
|
||||
// Basic search and pagination. Returns only direct children of the given path.
|
||||
// For root ("/"), we want files where path doesn't contain "/" after the first character.
|
||||
// For subdirs, we want files where path starts with parent but has no additional "/" after parent.
|
||||
rows, err := db.QueryContext(ctx, `
|
||||
SELECT f.id, f.org_id::text, f.user_id::text, f.name, f.path, f.type, f.size, f.last_modified, f.created_at
|
||||
FROM files f
|
||||
WHERE f.org_id = $1
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
FROM memberships m
|
||||
WHERE m.org_id = $1 AND m.user_id = $2
|
||||
)
|
||||
AND f.path != $3
|
||||
AND (
|
||||
($3 = '/' AND f.path LIKE '/%' AND f.path NOT LIKE '/%/%')
|
||||
OR ($3 != '/' AND f.path LIKE $3 || '/%' AND f.path NOT LIKE $3 || '/%/%')
|
||||
)
|
||||
AND ($4 = '' OR f.name ILIKE '%' || $4 || '%')
|
||||
ORDER BY CASE WHEN f.type = 'folder' THEN 0 ELSE 1 END, f.name
|
||||
LIMIT $5 OFFSET $6
|
||||
`, orgID, userID, path, q, pageSize, offset)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var files []File
|
||||
for rows.Next() {
|
||||
var f File
|
||||
var orgNull sql.NullString
|
||||
var userNull sql.NullString
|
||||
if err := rows.Scan(&f.ID, &orgNull, &userNull, &f.Name, &f.Path, &f.Type, &f.Size, &f.LastModified, &f.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if orgNull.Valid {
|
||||
oid, _ := uuid.Parse(orgNull.String)
|
||||
f.OrgID = &oid
|
||||
}
|
||||
if userNull.Valid {
|
||||
uid, _ := uuid.Parse(userNull.String)
|
||||
f.UserID = &uid
|
||||
}
|
||||
files = append(files, f)
|
||||
}
|
||||
err = rows.Err()
|
||||
if err == nil {
|
||||
log.Printf("[DATA-ISOLATION] stage=after, action=list, orgId=%s, userId=%s, fileCount=%d, path=%s", orgIDStr, userIDStr, len(files), path)
|
||||
}
|
||||
return files, err
|
||||
}
|
||||
|
||||
// GetUserFiles returns files for a user's personal workspace at a given path
|
||||
func (db *DB) GetUserFiles(ctx context.Context, userID uuid.UUID, path string, q string, page, pageSize int) ([]File, error) {
|
||||
if page <= 0 {
|
||||
page = 1
|
||||
}
|
||||
if pageSize <= 0 {
|
||||
pageSize = 100
|
||||
}
|
||||
offset := (page - 1) * pageSize
|
||||
|
||||
// Return only direct children of the given path
|
||||
log.Printf("[DATA-ISOLATION] stage=before, action=list, orgId=, userId=%s, fileCount=0, path=%s", userID.String(), path)
|
||||
rows, err := db.QueryContext(ctx, `
|
||||
SELECT id, org_id::text, user_id::text, name, path, type, size, last_modified, created_at
|
||||
FROM files
|
||||
WHERE user_id = $1
|
||||
AND org_id IS NULL
|
||||
AND path != $2
|
||||
AND (
|
||||
($2 = '/' AND path LIKE '/%' AND path NOT LIKE '/%/%')
|
||||
OR ($2 != '/' AND path LIKE $2 || '/%' AND path NOT LIKE $2 || '/%/%')
|
||||
)
|
||||
AND ($3 = '' OR name ILIKE '%' || $3 || '%')
|
||||
ORDER BY CASE WHEN type = 'folder' THEN 0 ELSE 1 END, name
|
||||
LIMIT $4 OFFSET $5
|
||||
`, userID, path, q, pageSize, offset)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var files []File
|
||||
for rows.Next() {
|
||||
var f File
|
||||
var orgNull sql.NullString
|
||||
var userNull sql.NullString
|
||||
if err := rows.Scan(&f.ID, &orgNull, &userNull, &f.Name, &f.Path, &f.Type, &f.Size, &f.LastModified, &f.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if orgNull.Valid {
|
||||
oid, _ := uuid.Parse(orgNull.String)
|
||||
f.OrgID = &oid
|
||||
}
|
||||
if userNull.Valid {
|
||||
uid, _ := uuid.Parse(userNull.String)
|
||||
f.UserID = &uid
|
||||
}
|
||||
files = append(files, f)
|
||||
}
|
||||
err = rows.Err()
|
||||
if err == nil {
|
||||
log.Printf("[DATA-ISOLATION] stage=after, action=list, orgId=, userId=%s, fileCount=%d, path=%s", userID.String(), len(files), path)
|
||||
}
|
||||
return files, err
|
||||
}
|
||||
|
||||
// CreateFile inserts a file or folder record. orgID or userID may be nil.
|
||||
func (db *DB) CreateFile(ctx context.Context, orgID *uuid.UUID, userID *uuid.UUID, name, path, fileType string, size int64) (*File, error) {
|
||||
var f File
|
||||
var orgIDVal interface{}
|
||||
var userIDVal interface{}
|
||||
orgIDStr := ""
|
||||
userIDStr := ""
|
||||
if orgID != nil {
|
||||
orgIDVal = *orgID
|
||||
orgIDStr = orgID.String()
|
||||
} else {
|
||||
orgIDVal = nil
|
||||
}
|
||||
if userID != nil {
|
||||
userIDVal = *userID
|
||||
userIDStr = userID.String()
|
||||
} else {
|
||||
userIDVal = nil
|
||||
}
|
||||
log.Printf("[DATA-ISOLATION] stage=before, action=create, orgId=%s, userId=%s, fileCount=1, path=%s", orgIDStr, userIDStr, path)
|
||||
|
||||
err := db.QueryRowContext(ctx, `
|
||||
INSERT INTO files (org_id, user_id, name, path, type, size)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
RETURNING id, org_id::text, user_id::text, name, path, type, size, last_modified, created_at
|
||||
`, orgIDVal, userIDVal, name, path, fileType, size).Scan(&f.ID, new(sql.NullString), new(sql.NullString), &f.Name, &f.Path, &f.Type, &f.Size, &f.LastModified, &f.CreatedAt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
log.Printf("[DATA-ISOLATION] stage=after, action=create, orgId=%s, userId=%s, fileCount=1, path=%s", orgIDStr, userIDStr, f.Path)
|
||||
return &f, nil
|
||||
}
|
||||
|
||||
// GetFileByID retrieves a file by its ID
|
||||
func (db *DB) GetFileByID(ctx context.Context, fileID uuid.UUID) (*File, error) {
|
||||
var f File
|
||||
var orgNull sql.NullString
|
||||
var userNull sql.NullString
|
||||
var modifiedByNull sql.NullString
|
||||
var modifiedByNameNull sql.NullString
|
||||
|
||||
err := db.QueryRowContext(ctx, `
|
||||
SELECT f.id, f.org_id::text, f.user_id::text, f.name, f.path, f.type, f.size, f.last_modified, f.created_at,
|
||||
f.modified_by::text, u.display_name
|
||||
FROM files f
|
||||
LEFT JOIN users u ON f.modified_by = u.id
|
||||
WHERE f.id = $1
|
||||
`, fileID).Scan(&f.ID, &orgNull, &userNull, &f.Name, &f.Path, &f.Type, &f.Size, &f.LastModified, &f.CreatedAt,
|
||||
&modifiedByNull, &modifiedByNameNull)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if orgNull.Valid {
|
||||
oid, _ := uuid.Parse(orgNull.String)
|
||||
f.OrgID = &oid
|
||||
}
|
||||
if userNull.Valid {
|
||||
uid, _ := uuid.Parse(userNull.String)
|
||||
f.UserID = &uid
|
||||
}
|
||||
if modifiedByNull.Valid {
|
||||
mid, _ := uuid.Parse(modifiedByNull.String)
|
||||
f.ModifiedBy = &mid
|
||||
}
|
||||
if modifiedByNameNull.Valid {
|
||||
f.ModifiedByName = modifiedByNameNull.String
|
||||
}
|
||||
|
||||
return &f, nil
|
||||
}
|
||||
|
||||
// UpdateFileSize updates the size, modification time, and modifier of a file
|
||||
func (db *DB) UpdateFileSize(ctx context.Context, fileID uuid.UUID, size int64, modifiedBy *uuid.UUID) error {
|
||||
_, err := db.ExecContext(ctx, `
|
||||
UPDATE files
|
||||
SET size = $1, last_modified = NOW(), modified_by = $3
|
||||
WHERE id = $2
|
||||
`, size, fileID, modifiedBy)
|
||||
return err
|
||||
}
|
||||
|
||||
// DeleteFileByPath removes a file or folder matching path for a given org or user
|
||||
func (db *DB) DeleteFileByPath(ctx context.Context, orgID *uuid.UUID, userID *uuid.UUID, path string) error {
|
||||
var res sql.Result
|
||||
var err error
|
||||
if orgID != nil {
|
||||
res, err = db.ExecContext(ctx, `DELETE FROM files WHERE org_id = $1 AND path = $2`, *orgID, path)
|
||||
} else if userID != nil {
|
||||
res, err = db.ExecContext(ctx, `DELETE FROM files WHERE user_id = $1 AND path = $2`, *userID, path)
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, _ = res.RowsAffected()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (db *DB) UpdateMemberRole(ctx context.Context, orgID, userID uuid.UUID, role string) error {
|
||||
_, err := db.ExecContext(ctx, `
|
||||
UPDATE memberships
|
||||
@@ -391,3 +688,5 @@ func (db *DB) MarkChallengeUsed(ctx context.Context, challenge []byte) error {
|
||||
`, challenge)
|
||||
return err
|
||||
}
|
||||
|
||||
// UpdateFileSize updates the size and last_modified timestamp of a file
|
||||
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
"net/http"
|
||||
"os"
|
||||
|
||||
"github.com/go-chi/chi/v5/middleware"
|
||||
chimiddleware "github.com/go-chi/chi/v5/middleware"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
@@ -40,7 +40,7 @@ func WriteError(w http.ResponseWriter, code ErrorCode, message string, status in
|
||||
|
||||
// GetRequestID extracts the request ID from the request context
|
||||
func GetRequestID(r *http.Request) string {
|
||||
if reqID := middleware.GetReqID(r.Context()); reqID != "" {
|
||||
if reqID := chimiddleware.GetReqID(r.Context()); reqID != "" {
|
||||
return reqID
|
||||
}
|
||||
return "unknown"
|
||||
@@ -48,10 +48,10 @@ func GetRequestID(r *http.Request) string {
|
||||
|
||||
// GetUserID extracts user ID from context if available
|
||||
func GetUserID(r *http.Request) string {
|
||||
if userID := r.Context().Value("user"); userID != nil {
|
||||
if uid, ok := userID.(string); ok {
|
||||
return uid
|
||||
}
|
||||
// Use type contextKey matching middleware package
|
||||
type contextKey string
|
||||
if userID, ok := r.Context().Value(contextKey("user")).(string); ok && userID != "" {
|
||||
return userID
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
814
go_cloud/internal/http/wopi_handlers.go
Normal file
@@ -0,0 +1,814 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"path"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"go.b0esche.cloud/backend/internal/config"
|
||||
"go.b0esche.cloud/backend/internal/database"
|
||||
"go.b0esche.cloud/backend/internal/errors"
|
||||
"go.b0esche.cloud/backend/internal/middleware"
|
||||
"go.b0esche.cloud/backend/internal/models"
|
||||
"go.b0esche.cloud/backend/internal/storage"
|
||||
"go.b0esche.cloud/backend/pkg/jwt"
|
||||
)
|
||||
|
||||
// Collabora discovery cache
|
||||
var (
|
||||
collaboraEditorURL string
|
||||
collaboraDiscoveryCache time.Time
|
||||
collaboraDiscoveryMu sync.RWMutex
|
||||
)
|
||||
|
||||
// getCollaboraEditorURL fetches the editor URL from Collabora's discovery endpoint
|
||||
func getCollaboraEditorURL(collaboraBaseURL string) string {
|
||||
collaboraDiscoveryMu.RLock()
|
||||
// Cache for 5 minutes
|
||||
if collaboraEditorURL != "" && time.Since(collaboraDiscoveryCache) < 5*time.Minute {
|
||||
url := collaboraEditorURL
|
||||
collaboraDiscoveryMu.RUnlock()
|
||||
return url
|
||||
}
|
||||
collaboraDiscoveryMu.RUnlock()
|
||||
|
||||
// Fetch discovery
|
||||
collaboraDiscoveryMu.Lock()
|
||||
defer collaboraDiscoveryMu.Unlock()
|
||||
|
||||
// Double-check after acquiring write lock
|
||||
if collaboraEditorURL != "" && time.Since(collaboraDiscoveryCache) < 5*time.Minute {
|
||||
return collaboraEditorURL
|
||||
}
|
||||
|
||||
discoveryURL := collaboraBaseURL + "/hosting/discovery"
|
||||
resp, err := http.Get(discoveryURL)
|
||||
if err != nil {
|
||||
fmt.Printf("[COLLABORA] Failed to fetch discovery: %v\n", err)
|
||||
// Fallback to guessed URL
|
||||
return collaboraBaseURL + "/browser/dist/cool.html"
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
fmt.Printf("[COLLABORA] Failed to read discovery: %v\n", err)
|
||||
return collaboraBaseURL + "/browser/dist/cool.html"
|
||||
}
|
||||
|
||||
// Parse XML to extract urlsrc
|
||||
type Action struct {
|
||||
Name string `xml:"name,attr"`
|
||||
Ext string `xml:"ext,attr"`
|
||||
URLSrc string `xml:"urlsrc,attr"`
|
||||
}
|
||||
type App struct {
|
||||
Name string `xml:"name,attr"`
|
||||
Actions []Action `xml:"action"`
|
||||
}
|
||||
type NetZone struct {
|
||||
Apps []App `xml:"app"`
|
||||
}
|
||||
type WopiDiscovery struct {
|
||||
NetZone NetZone `xml:"net-zone"`
|
||||
}
|
||||
|
||||
var discovery WopiDiscovery
|
||||
if err := xml.Unmarshal(body, &discovery); err != nil {
|
||||
fmt.Printf("[COLLABORA] Failed to parse discovery XML: %v\n", err)
|
||||
return collaboraBaseURL + "/browser/dist/cool.html"
|
||||
}
|
||||
|
||||
// Find the first edit action URL (they all have the same base)
|
||||
for _, app := range discovery.NetZone.Apps {
|
||||
for _, action := range app.Actions {
|
||||
if action.URLSrc != "" {
|
||||
// Extract base URL (remove query string marker)
|
||||
url := strings.TrimSuffix(action.URLSrc, "?")
|
||||
collaboraEditorURL = url
|
||||
collaboraDiscoveryCache = time.Now()
|
||||
fmt.Printf("[COLLABORA] Discovered editor URL: %s\n", url)
|
||||
return url
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Printf("[COLLABORA] No editor URL found in discovery\n")
|
||||
return collaboraBaseURL + "/browser/dist/cool.html"
|
||||
}
|
||||
|
||||
// WOPILockManager manages file locks to prevent concurrent editing conflicts
|
||||
type WOPILockManager struct {
|
||||
locks map[string]*models.WOPILockInfo
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
var lockManager = &WOPILockManager{
|
||||
locks: make(map[string]*models.WOPILockInfo),
|
||||
}
|
||||
|
||||
// AcquireLock tries to acquire a lock for a file
|
||||
func (m *WOPILockManager) AcquireLock(fileID, userID string) (string, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
if existing, ok := m.locks[fileID]; ok {
|
||||
// Check if lock has expired
|
||||
if time.Now().Before(existing.ExpiresAt) {
|
||||
// Lock still active - check if same user
|
||||
if existing.UserID != userID {
|
||||
fmt.Printf("[WOPI-LOCK] Lock conflict: file=%s locked_by=%s requested_by=%s\n", fileID, existing.UserID, userID)
|
||||
return "", fmt.Errorf("file locked by another user")
|
||||
}
|
||||
// Same user, refresh the lock
|
||||
lockID := uuid.New().String()
|
||||
m.locks[fileID] = &models.WOPILockInfo{
|
||||
FileID: fileID,
|
||||
UserID: userID,
|
||||
LockID: lockID,
|
||||
CreatedAt: time.Now(),
|
||||
ExpiresAt: time.Now().Add(30 * time.Minute),
|
||||
}
|
||||
fmt.Printf("[WOPI-LOCK] Lock refreshed: file=%s user=%s lock_id=%s\n", fileID, userID, lockID)
|
||||
return lockID, nil
|
||||
}
|
||||
// Lock expired, remove it
|
||||
delete(m.locks, fileID)
|
||||
}
|
||||
|
||||
// Acquire new lock
|
||||
lockID := uuid.New().String()
|
||||
m.locks[fileID] = &models.WOPILockInfo{
|
||||
FileID: fileID,
|
||||
UserID: userID,
|
||||
LockID: lockID,
|
||||
CreatedAt: time.Now(),
|
||||
ExpiresAt: time.Now().Add(30 * time.Minute),
|
||||
}
|
||||
fmt.Printf("[WOPI-LOCK] Lock acquired: file=%s user=%s lock_id=%s\n", fileID, userID, lockID)
|
||||
return lockID, nil
|
||||
}
|
||||
|
||||
// ReleaseLock releases a lock for a file
|
||||
func (m *WOPILockManager) ReleaseLock(fileID, userID string) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
if lock, ok := m.locks[fileID]; ok {
|
||||
if lock.UserID == userID {
|
||||
delete(m.locks, fileID)
|
||||
fmt.Printf("[WOPI-LOCK] Lock released: file=%s user=%s\n", fileID, userID)
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("lock held by different user")
|
||||
}
|
||||
return fmt.Errorf("no lock found")
|
||||
}
|
||||
|
||||
// GetLock returns the current lock info for a file
|
||||
func (m *WOPILockManager) GetLock(fileID string) *models.WOPILockInfo {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
|
||||
if lock, ok := m.locks[fileID]; ok {
|
||||
// Check if expired
|
||||
if time.Now().Before(lock.ExpiresAt) {
|
||||
return lock
|
||||
}
|
||||
// Expired, will be cleaned up on next acquire attempt
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateWOPIAccessToken validates a WOPI access token
|
||||
func validateWOPIAccessToken(tokenString string, jwtManager *jwt.Manager) (*jwt.Claims, error) {
|
||||
claims, err := jwtManager.Validate(tokenString)
|
||||
if err != nil {
|
||||
fmt.Printf("[WOPI-TOKEN] Token validation failed: %v\n", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Check if token has expired
|
||||
if time.Now().After(claims.ExpiresAt.Time) {
|
||||
fmt.Printf("[WOPI-TOKEN] Token expired: user=%s\n", claims.UserID)
|
||||
return nil, fmt.Errorf("token expired")
|
||||
}
|
||||
|
||||
fmt.Printf("[WOPI-TOKEN] Token validated: user=%s expires=%v\n", claims.UserID, claims.ExpiresAt.Time)
|
||||
return claims, nil
|
||||
}
|
||||
|
||||
// WOPICheckFileInfoHandler handles GET /wopi/files/{fileId}
|
||||
// Returns metadata about the file and user permissions
|
||||
func wopiCheckFileInfoHandler(w http.ResponseWriter, r *http.Request, db *database.DB, jwtManager *jwt.Manager) {
|
||||
fileID := r.PathValue("fileId")
|
||||
if fileID == "" {
|
||||
errors.WriteError(w, errors.CodeInvalidArgument, "Missing fileId", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Get access token from query parameter
|
||||
accessToken := r.URL.Query().Get("access_token")
|
||||
if accessToken == "" {
|
||||
errors.WriteError(w, errors.CodeUnauthenticated, "Missing access_token", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
// Validate token
|
||||
claims, err := validateWOPIAccessToken(accessToken, jwtManager)
|
||||
if err != nil {
|
||||
errors.WriteError(w, errors.CodeUnauthenticated, "Invalid or expired token", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
userID, _ := uuid.Parse(claims.UserID)
|
||||
|
||||
// Get file info from database
|
||||
fileUUID, err := uuid.Parse(fileID)
|
||||
if err != nil {
|
||||
errors.WriteError(w, errors.CodeInvalidArgument, "Invalid fileId format", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
file, err := db.GetFileByID(r.Context(), fileUUID)
|
||||
if err != nil {
|
||||
fmt.Printf("[WOPI-REQUEST] File not found: file=%s error=%v\n", fileID, err)
|
||||
errors.WriteError(w, errors.CodeNotFound, "File not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
// Verify user has access to this file
|
||||
canAccess := false
|
||||
var ownerID string
|
||||
|
||||
if file.UserID != nil && *file.UserID == userID {
|
||||
canAccess = true
|
||||
ownerID = userID.String()
|
||||
} else if file.OrgID != nil {
|
||||
// Check if user is member of the org
|
||||
member, err := db.GetOrgMember(r.Context(), *file.OrgID, userID)
|
||||
if err == nil && member != nil {
|
||||
canAccess = true
|
||||
ownerID = file.OrgID.String()
|
||||
}
|
||||
}
|
||||
|
||||
if !canAccess {
|
||||
fmt.Printf("[WOPI-REQUEST] Access denied: file=%s user=%s\n", fileID, userID.String())
|
||||
errors.WriteError(w, errors.CodePermissionDenied, "Access denied", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
// Build response
|
||||
response := models.WOPICheckFileInfoResponse{
|
||||
BaseFileName: file.Name,
|
||||
Size: file.Size,
|
||||
Version: file.ID.String(),
|
||||
OwnerId: ownerID,
|
||||
UserId: userID.String(),
|
||||
UserFriendlyName: "", // Could be populated from user info
|
||||
UserCanWrite: true,
|
||||
UserCanRename: false,
|
||||
UserCanNotWriteRelative: false,
|
||||
ReadOnly: false,
|
||||
RestrictedWebViewOnly: false,
|
||||
UserCanCreateRelativeToFolder: false,
|
||||
EnableOwnerTermination: false,
|
||||
SupportsUpdate: true,
|
||||
SupportsCobalt: false,
|
||||
SupportsLocks: true,
|
||||
SupportsExtendedLockLength: false,
|
||||
SupportsGetLock: true,
|
||||
SupportsDelete: false,
|
||||
SupportsRename: false,
|
||||
SupportsRenameRelativeToFolder: false,
|
||||
SupportsFolders: false,
|
||||
SupportsScenarios: []string{"default"},
|
||||
LastModifiedTime: file.LastModified.UTC().Format(time.RFC3339),
|
||||
IsAnonymousUser: false,
|
||||
TimeZone: "UTC",
|
||||
}
|
||||
|
||||
fmt.Printf("[WOPI-REQUEST] CheckFileInfo: file=%s user=%s size=%d\n", fileID, userID.String(), file.Size)
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
json.NewEncoder(w).Encode(response)
|
||||
}
|
||||
|
||||
// WOPIGetFileHandler handles GET /wopi/files/{fileId}/contents
|
||||
// Downloads the document file content
|
||||
func wopiGetFileHandler(w http.ResponseWriter, r *http.Request, db *database.DB, jwtManager *jwt.Manager, cfg *config.Config) {
|
||||
fileID := r.PathValue("fileId")
|
||||
if fileID == "" {
|
||||
errors.WriteError(w, errors.CodeInvalidArgument, "Missing fileId", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Get access token from query parameter
|
||||
accessToken := r.URL.Query().Get("access_token")
|
||||
if accessToken == "" {
|
||||
errors.WriteError(w, errors.CodeUnauthenticated, "Missing access_token", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
// Validate token
|
||||
claims, err := validateWOPIAccessToken(accessToken, jwtManager)
|
||||
if err != nil {
|
||||
errors.WriteError(w, errors.CodeUnauthenticated, "Invalid or expired token", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
userID, _ := uuid.Parse(claims.UserID)
|
||||
|
||||
// Get file info from database
|
||||
fileUUID, err := uuid.Parse(fileID)
|
||||
if err != nil {
|
||||
errors.WriteError(w, errors.CodeInvalidArgument, "Invalid fileId format", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
file, err := db.GetFileByID(r.Context(), fileUUID)
|
||||
if err != nil {
|
||||
fmt.Printf("[WOPI-REQUEST] GetFile - File not found: file=%s error=%v\n", fileID, err)
|
||||
errors.WriteError(w, errors.CodeNotFound, "File not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
// Verify user has access to this file
|
||||
canAccess := false
|
||||
var webDAVClient *storage.WebDAVClient
|
||||
var remotePath string
|
||||
|
||||
if file.UserID != nil && *file.UserID == userID {
|
||||
canAccess = true
|
||||
// Get user's WebDAV client - use config
|
||||
webDAVClient, err = getUserWebDAVClient(r.Context(), db, userID, cfg.NextcloudURL, cfg.NextcloudUser, cfg.NextcloudPass)
|
||||
if err != nil {
|
||||
fmt.Printf("[WOPI-STORAGE] Failed to get user WebDAV client: %v\n", err)
|
||||
errors.WriteError(w, errors.CodeInternal, "Storage error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
// User files: path is relative to user's WebDAV root
|
||||
remotePath = file.Path
|
||||
} else if file.OrgID != nil {
|
||||
// Check if user is member of the org
|
||||
member, err := db.GetOrgMember(r.Context(), *file.OrgID, userID)
|
||||
if err == nil && member != nil {
|
||||
canAccess = true
|
||||
// Use user's WebDAV client for org files too
|
||||
webDAVClient, err = getUserWebDAVClient(r.Context(), db, userID, cfg.NextcloudURL, cfg.NextcloudUser, cfg.NextcloudPass)
|
||||
if err != nil {
|
||||
fmt.Printf("[WOPI-STORAGE] Failed to get user WebDAV client: %v\n", err)
|
||||
errors.WriteError(w, errors.CodeInternal, "Storage error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
// Org files: stored under /orgs/{orgID}/ prefix
|
||||
rel := strings.TrimPrefix(file.Path, "/")
|
||||
remotePath = path.Join("/orgs", file.OrgID.String(), rel)
|
||||
}
|
||||
}
|
||||
|
||||
if !canAccess {
|
||||
fmt.Printf("[WOPI-REQUEST] GetFile - Access denied: file=%s user=%s\n", fileID, userID.String())
|
||||
errors.WriteError(w, errors.CodePermissionDenied, "Access denied", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
// Download file from storage
|
||||
fmt.Printf("[WOPI-STORAGE] GetFile downloading: file=%s remotePath=%s\n", fileID, remotePath)
|
||||
resp, err := webDAVClient.Download(r.Context(), remotePath, "")
|
||||
if err != nil {
|
||||
fmt.Printf("[WOPI-STORAGE] Failed to download file: file=%s path=%s error=%v\n", fileID, file.Path, err)
|
||||
errors.WriteError(w, errors.CodeNotFound, "File not found in storage", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Set response headers
|
||||
contentType := getMimeType(file.Name)
|
||||
w.Header().Set("Content-Type", contentType)
|
||||
w.Header().Set("Content-Length", fmt.Sprintf("%d", file.Size))
|
||||
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", file.Name))
|
||||
w.WriteHeader(http.StatusOK)
|
||||
|
||||
fmt.Printf("[WOPI-STORAGE] GetFile: file=%s user=%s bytes=%d\n", fileID, userID.String(), file.Size)
|
||||
|
||||
// Stream file content
|
||||
io.Copy(w, resp.Body)
|
||||
}
|
||||
|
||||
// WOPIPutFileHandler handles POST /wopi/files/{fileId}/contents
|
||||
// Uploads edited document back to storage
|
||||
func wopiPutFileHandler(w http.ResponseWriter, r *http.Request, db *database.DB, jwtManager *jwt.Manager, cfg *config.Config) {
|
||||
fileID := r.PathValue("fileId")
|
||||
if fileID == "" {
|
||||
errors.WriteError(w, errors.CodeInvalidArgument, "Missing fileId", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Get access token from Authorization header
|
||||
authHeader := r.Header.Get("Authorization")
|
||||
if !strings.HasPrefix(authHeader, "Bearer ") {
|
||||
errors.WriteError(w, errors.CodeUnauthenticated, "Missing authorization", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
accessToken := strings.TrimPrefix(authHeader, "Bearer ")
|
||||
|
||||
// Validate token
|
||||
claims, err := validateWOPIAccessToken(accessToken, jwtManager)
|
||||
if err != nil {
|
||||
errors.WriteError(w, errors.CodeUnauthenticated, "Invalid or expired token", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
userID, _ := uuid.Parse(claims.UserID)
|
||||
|
||||
// Get file info from database
|
||||
fileUUID, err := uuid.Parse(fileID)
|
||||
if err != nil {
|
||||
errors.WriteError(w, errors.CodeInvalidArgument, "Invalid fileId format", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
file, err := db.GetFileByID(r.Context(), fileUUID)
|
||||
if err != nil {
|
||||
fmt.Printf("[WOPI-REQUEST] PutFile - File not found: file=%s\n", fileID)
|
||||
errors.WriteError(w, errors.CodeNotFound, "File not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
// Verify user has access to this file
|
||||
canAccess := false
|
||||
var webDAVClient *storage.WebDAVClient
|
||||
var remotePath string
|
||||
|
||||
if file.UserID != nil && *file.UserID == userID {
|
||||
canAccess = true
|
||||
webDAVClient, err = getUserWebDAVClient(r.Context(), db, userID, cfg.NextcloudURL, cfg.NextcloudUser, cfg.NextcloudPass)
|
||||
if err != nil {
|
||||
fmt.Printf("[WOPI-STORAGE] Failed to get user WebDAV client: %v\n", err)
|
||||
errors.WriteError(w, errors.CodeInternal, "Storage error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
// User files: path is relative to user's WebDAV root
|
||||
remotePath = file.Path
|
||||
} else if file.OrgID != nil {
|
||||
member, err := db.GetOrgMember(r.Context(), *file.OrgID, userID)
|
||||
if err == nil && member != nil {
|
||||
canAccess = true
|
||||
// Use user's WebDAV client for org files too
|
||||
webDAVClient, err = getUserWebDAVClient(r.Context(), db, userID, cfg.NextcloudURL, cfg.NextcloudUser, cfg.NextcloudPass)
|
||||
if err != nil {
|
||||
fmt.Printf("[WOPI-STORAGE] Failed to get user WebDAV client: %v\n", err)
|
||||
errors.WriteError(w, errors.CodeInternal, "Storage error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
// Org files: stored under /orgs/{orgID}/ prefix
|
||||
rel := strings.TrimPrefix(file.Path, "/")
|
||||
remotePath = path.Join("/orgs", file.OrgID.String(), rel)
|
||||
}
|
||||
}
|
||||
|
||||
if !canAccess {
|
||||
fmt.Printf("[WOPI-REQUEST] PutFile - Access denied: file=%s user=%s\n", fileID, userID.String())
|
||||
errors.WriteError(w, errors.CodePermissionDenied, "Access denied", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
// Check lock
|
||||
lock := lockManager.GetLock(fileID)
|
||||
if lock != nil && lock.UserID != userID.String() {
|
||||
fmt.Printf("[WOPI-LOCK] Put conflict: file=%s locked_by=%s user=%s\n", fileID, lock.UserID, userID.String())
|
||||
w.WriteHeader(http.StatusConflict)
|
||||
return
|
||||
}
|
||||
|
||||
// Read file content from request body
|
||||
content, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
fmt.Printf("[WOPI-STORAGE] Failed to read request body: %v\n", err)
|
||||
errors.WriteError(w, errors.CodeInternal, "Failed to read content", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
defer r.Body.Close()
|
||||
|
||||
// Upload to storage
|
||||
fmt.Printf("[WOPI-STORAGE] PutFile uploading: file=%s remotePath=%s\n", fileID, remotePath)
|
||||
err = webDAVClient.Upload(r.Context(), remotePath, strings.NewReader(string(content)), int64(len(content)))
|
||||
if err != nil {
|
||||
fmt.Printf("[WOPI-STORAGE] Failed to upload file: file=%s path=%s error=%v\n", fileID, file.Path, err)
|
||||
errors.WriteError(w, errors.CodeInternal, "Failed to save file", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Update file size and modification time in database
|
||||
newSize := int64(len(content))
|
||||
err = db.UpdateFileSize(r.Context(), fileUUID, newSize, &userID)
|
||||
if err != nil {
|
||||
fmt.Printf("[WOPI-STORAGE] Failed to update file size: file=%s error=%v\n", fileID, err)
|
||||
// Don't fail the upload, just log the warning
|
||||
}
|
||||
|
||||
fmt.Printf("[WOPI-STORAGE] PutFile: file=%s user=%s bytes=%d\n", fileID, userID.String(), newSize)
|
||||
|
||||
// Return response
|
||||
response := models.WOPIPutFileResponse{
|
||||
ItemVersion: fileUUID.String(),
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
json.NewEncoder(w).Encode(response)
|
||||
}
|
||||
|
||||
// WOPILockHandler handles POST /wopi/files/{fileId} with X-WOPI-Override header for lock operations
|
||||
func wopiLockHandler(w http.ResponseWriter, r *http.Request, db *database.DB, jwtManager *jwt.Manager) {
|
||||
fileID := r.PathValue("fileId")
|
||||
if fileID == "" {
|
||||
errors.WriteError(w, errors.CodeInvalidArgument, "Missing fileId", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Get access token from Authorization header
|
||||
authHeader := r.Header.Get("Authorization")
|
||||
if !strings.HasPrefix(authHeader, "Bearer ") {
|
||||
errors.WriteError(w, errors.CodeUnauthenticated, "Missing authorization", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
accessToken := strings.TrimPrefix(authHeader, "Bearer ")
|
||||
|
||||
// Validate token
|
||||
claims, err := validateWOPIAccessToken(accessToken, jwtManager)
|
||||
if err != nil {
|
||||
errors.WriteError(w, errors.CodeUnauthenticated, "Invalid or expired token", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
userID := claims.UserID
|
||||
override := r.Header.Get("X-WOPI-Override")
|
||||
|
||||
// Get file to verify access
|
||||
fileUUID, err := uuid.Parse(fileID)
|
||||
if err != nil {
|
||||
errors.WriteError(w, errors.CodeInvalidArgument, "Invalid fileId format", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
file, err := db.GetFileByID(r.Context(), fileUUID)
|
||||
if err != nil {
|
||||
errors.WriteError(w, errors.CodeNotFound, "File not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
// Verify access
|
||||
canAccess := false
|
||||
if file.UserID != nil && file.UserID.String() == userID {
|
||||
canAccess = true
|
||||
} else if file.OrgID != nil {
|
||||
userUUID, _ := uuid.Parse(userID)
|
||||
member, err := db.GetOrgMember(r.Context(), *file.OrgID, userUUID)
|
||||
canAccess = (err == nil && member != nil)
|
||||
}
|
||||
|
||||
if !canAccess {
|
||||
errors.WriteError(w, errors.CodePermissionDenied, "Access denied", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
// Handle lock operations
|
||||
switch override {
|
||||
case "LOCK":
|
||||
// Acquire lock
|
||||
lockID, err := lockManager.AcquireLock(fileID, userID)
|
||||
if err != nil {
|
||||
fmt.Printf("[WOPI-LOCK] Lock acquisition failed: file=%s user=%s error=%s\n", fileID, userID, err.Error())
|
||||
w.WriteHeader(http.StatusConflict)
|
||||
w.Write([]byte(fmt.Sprintf(`{"error": "%s"}`, err.Error())))
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("X-WOPI-LockID", lockID)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(`{}`))
|
||||
|
||||
case "UNLOCK":
|
||||
// Release lock
|
||||
err := lockManager.ReleaseLock(fileID, userID)
|
||||
if err != nil {
|
||||
fmt.Printf("[WOPI-LOCK] Lock release failed: file=%s user=%s error=%s\n", fileID, userID, err.Error())
|
||||
w.WriteHeader(http.StatusConflict)
|
||||
w.Write([]byte(fmt.Sprintf(`{"error": "%s"}`, err.Error())))
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(`{}`))
|
||||
|
||||
case "GET_LOCK":
|
||||
// Get lock info
|
||||
lock := lockManager.GetLock(fileID)
|
||||
if lock == nil {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(`{}`))
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("X-WOPI-LockID", lock.LockID)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(`{}`))
|
||||
|
||||
default:
|
||||
errors.WriteError(w, errors.CodeInvalidArgument, "Unknown X-WOPI-Override value", http.StatusBadRequest)
|
||||
}
|
||||
}
|
||||
|
||||
// WOPISessionHandler handles POST /user/files/{fileId}/wopi-session and /orgs/{orgId}/files/{fileId}/wopi-session
|
||||
// Returns WOPISrc URL and access token for opening document in Collabora
|
||||
func wopiSessionHandler(w http.ResponseWriter, r *http.Request, db *database.DB, jwtManager *jwt.Manager, collaboraURL string) {
|
||||
fileID := r.PathValue("fileId")
|
||||
if fileID == "" {
|
||||
errors.WriteError(w, errors.CodeInvalidArgument, "Missing fileId", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Get user from context (from auth middleware)
|
||||
userIDStr, ok := middleware.GetUserID(r.Context())
|
||||
if !ok || userIDStr == "" {
|
||||
errors.WriteError(w, errors.CodeUnauthenticated, "Unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
userID, _ := uuid.Parse(userIDStr)
|
||||
|
||||
// Get file info
|
||||
fileUUID, err := uuid.Parse(fileID)
|
||||
if err != nil {
|
||||
errors.WriteError(w, errors.CodeInvalidArgument, "Invalid fileId format", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
file, err := db.GetFileByID(r.Context(), fileUUID)
|
||||
if err != nil {
|
||||
errors.WriteError(w, errors.CodeNotFound, "File not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
// Verify access
|
||||
canAccess := false
|
||||
if file.UserID != nil && *file.UserID == userID {
|
||||
canAccess = true
|
||||
} else if file.OrgID != nil {
|
||||
member, err := db.GetOrgMember(r.Context(), *file.OrgID, userID)
|
||||
canAccess = (err == nil && member != nil)
|
||||
}
|
||||
|
||||
if !canAccess {
|
||||
errors.WriteError(w, errors.CodePermissionDenied, "Access denied", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
// Generate WOPI access token (1 hour duration)
|
||||
accessToken, err := jwtManager.GenerateWithDuration(userID.String(), nil, "", 1*time.Hour)
|
||||
if err != nil {
|
||||
errors.WriteError(w, errors.CodeInternal, "Failed to generate token", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Build WOPISrc URL
|
||||
wopisrc := fmt.Sprintf("https://go.b0esche.cloud/wopi/files/%s?access_token=%s", fileID, accessToken)
|
||||
|
||||
response := models.WOPISessionResponse{
|
||||
WOPISrc: wopisrc,
|
||||
AccessToken: accessToken,
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
json.NewEncoder(w).Encode(response)
|
||||
|
||||
fmt.Printf("[WOPI-REQUEST] Session created: file=%s user=%s\n", fileID, userID.String())
|
||||
}
|
||||
|
||||
// CollaboraProxyHandler serves an HTML page that POSTs WOPISrc to Collabora
|
||||
// This avoids CORS issues by having the POST originate from our domain
|
||||
func collaboraProxyHandler(w http.ResponseWriter, r *http.Request, db *database.DB, jwtManager *jwt.Manager, collaboraURL string) {
|
||||
fileID := r.PathValue("fileId")
|
||||
if fileID == "" {
|
||||
errors.WriteError(w, errors.CodeInvalidArgument, "Missing fileId", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Get user from context (from auth middleware)
|
||||
userIDStr, ok := middleware.GetUserID(r.Context())
|
||||
if !ok {
|
||||
errors.WriteError(w, errors.CodeUnauthenticated, "Not authenticated", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
userID, err := uuid.Parse(userIDStr)
|
||||
if err != nil {
|
||||
errors.WriteError(w, errors.CodeInvalidArgument, "Invalid user ID", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Get file info
|
||||
fileUUID, err := uuid.Parse(fileID)
|
||||
if err != nil {
|
||||
errors.WriteError(w, errors.CodeInvalidArgument, "Invalid fileId format", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
file, err := db.GetFileByID(r.Context(), fileUUID)
|
||||
if err != nil {
|
||||
errors.WriteError(w, errors.CodeNotFound, "File not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
// Verify access
|
||||
canAccess := false
|
||||
if file.UserID != nil && *file.UserID == userID {
|
||||
canAccess = true
|
||||
} else if file.OrgID != nil {
|
||||
member, err := db.GetOrgMember(r.Context(), *file.OrgID, userID)
|
||||
canAccess = (err == nil && member != nil)
|
||||
}
|
||||
|
||||
if !canAccess {
|
||||
errors.WriteError(w, errors.CodePermissionDenied, "Access denied", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
// Generate WOPI access token (1 hour duration)
|
||||
accessToken, err := jwtManager.GenerateWithDuration(userID.String(), nil, "", 1*time.Hour)
|
||||
if err != nil {
|
||||
errors.WriteError(w, errors.CodeInternal, "Failed to generate token", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Build WOPISrc URL (without access_token - that goes in a separate form field)
|
||||
wopiSrc := fmt.Sprintf("https://go.b0esche.cloud/wopi/files/%s", fileID)
|
||||
|
||||
// Get the correct Collabora editor URL from discovery (includes version hash)
|
||||
editorURL := getCollaboraEditorURL(collaboraURL)
|
||||
|
||||
// URL-encode the WOPISrc for use in the form action URL
|
||||
encodedWopiSrc := url.QueryEscape(wopiSrc)
|
||||
|
||||
// Build the full Collabora URL with WOPISrc as query parameter
|
||||
// Collabora expects: cool.html?WOPISrc=<encoded-url>
|
||||
collaboraFullURL := fmt.Sprintf("%s?WOPISrc=%s", editorURL, encodedWopiSrc)
|
||||
|
||||
// Return HTML page with auto-submitting form
|
||||
// The form POSTs to Collabora with access_token in the body
|
||||
// WOPISrc must be in the URL as a query parameter
|
||||
htmlContent := fmt.Sprintf(`<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Loading Document...</title>
|
||||
<meta charset="utf-8">
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
html, body { width: 100%%; height: 100%%; overflow: hidden; }
|
||||
.loading { position: fixed; top: 50%%; left: 50%%; transform: translate(-50%%, -50%%); text-align: center; background: white; padding: 20px; border-radius: 8px; box-shadow: 0 2px 8px rgba(0,0,0,0.1); }
|
||||
.loading p { color: #666; margin-top: 10px; font-family: system-ui, sans-serif; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="loading">
|
||||
<p>Loading Collabora Online...</p>
|
||||
</div>
|
||||
<form method="POST" action="%s" target="_self" id="collaboraForm" style="display: none;">
|
||||
<input type="hidden" id="access_token" name="access_token" value="%s">
|
||||
</form>
|
||||
<script>
|
||||
// Auto-submit the form to Collabora
|
||||
var form = document.getElementById('collaboraForm');
|
||||
if (form) {
|
||||
console.log('[COLLABORA] Submitting form to %s');
|
||||
form.submit();
|
||||
} else {
|
||||
console.error('[COLLABORA] Form not found');
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>`, collaboraFullURL, accessToken, collaboraFullURL)
|
||||
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
// Don't set X-Frame-Options - this endpoint is meant to be loaded in an iframe
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(htmlContent))
|
||||
|
||||
fmt.Printf("[COLLABORA-PROXY] Served HTML form: file=%s user=%s wopi_src=%s editor_url=%s\n", fileID, userID.String(), wopiSrc, collaboraFullURL)
|
||||
}
|
||||
@@ -2,7 +2,9 @@ package middleware
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"go.b0esche.cloud/backend/internal/audit"
|
||||
@@ -21,6 +23,103 @@ var RequestID = middleware.RequestID
|
||||
var Logger = middleware.Logger
|
||||
var Recoverer = middleware.Recoverer
|
||||
|
||||
// CORS middleware - accepts allowedOrigins comma-separated string
|
||||
func CORS(allowedOrigins string) func(http.Handler) http.Handler {
|
||||
allowedList, allowAll := compileAllowedOrigins(allowedOrigins)
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
origin := r.Header.Get("Origin")
|
||||
if origin != "" && isOriginAllowed(origin, allowedList) {
|
||||
w.Header().Set("Access-Control-Allow-Origin", origin)
|
||||
w.Header().Add("Vary", "Origin")
|
||||
w.Header().Set("Access-Control-Allow-Credentials", "true")
|
||||
} else if allowAll {
|
||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
}
|
||||
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS")
|
||||
allowHeaders := []string{"Content-Type", "Authorization", "Range", "Accept", "Origin", "X-Requested-With"}
|
||||
if reqHeaders := r.Header.Get("Access-Control-Request-Headers"); reqHeaders != "" {
|
||||
allowHeaders = append(allowHeaders, reqHeaders)
|
||||
}
|
||||
w.Header().Set("Access-Control-Allow-Headers", strings.Join(uniqueStrings(allowHeaders), ", "))
|
||||
w.Header().Set("Access-Control-Expose-Headers", "Content-Length, Content-Type, Content-Disposition")
|
||||
w.Header().Set("Access-Control-Max-Age", "3600")
|
||||
|
||||
if r.Method == http.MethodOptions {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
return
|
||||
}
|
||||
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func compileAllowedOrigins(origins string) ([]string, bool) {
|
||||
var allowed []string
|
||||
allowAll := false
|
||||
|
||||
for _, origin := range strings.Split(origins, ",") {
|
||||
trimmed := strings.TrimSpace(origin)
|
||||
if trimmed == "" {
|
||||
continue
|
||||
}
|
||||
if trimmed == "*" {
|
||||
allowAll = true
|
||||
}
|
||||
allowed = append(allowed, trimmed)
|
||||
}
|
||||
|
||||
if len(allowed) == 0 && !allowAll {
|
||||
allowAll = true
|
||||
}
|
||||
|
||||
return allowed, allowAll
|
||||
}
|
||||
|
||||
func uniqueStrings(values []string) []string {
|
||||
seen := make(map[string]struct{})
|
||||
var out []string
|
||||
for _, v := range values {
|
||||
trimmed := strings.TrimSpace(v)
|
||||
if trimmed == "" {
|
||||
continue
|
||||
}
|
||||
key := strings.ToLower(trimmed)
|
||||
if _, ok := seen[key]; ok {
|
||||
continue
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
out = append(out, trimmed)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func isOriginAllowed(origin string, allowed []string) bool {
|
||||
if origin == "" {
|
||||
return false
|
||||
}
|
||||
for _, pattern := range allowed {
|
||||
if originMatches(origin, pattern) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func originMatches(origin, pattern string) bool {
|
||||
if pattern == "*" {
|
||||
return true
|
||||
}
|
||||
if !strings.Contains(pattern, "*") {
|
||||
return strings.EqualFold(origin, pattern)
|
||||
}
|
||||
regexPattern := "(?i)^" + regexp.QuoteMeta(pattern) + "$"
|
||||
regexPattern = strings.ReplaceAll(regexPattern, "\\*", ".*")
|
||||
matched, err := regexp.MatchString(regexPattern, origin)
|
||||
return err == nil && matched
|
||||
}
|
||||
|
||||
// TODO: Implement rate limiter
|
||||
var RateLimit = func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -29,33 +128,69 @@ var RateLimit = func(next http.Handler) http.Handler {
|
||||
})
|
||||
}
|
||||
|
||||
type contextKey string
|
||||
type ContextKey string
|
||||
|
||||
const (
|
||||
userKey contextKey = "user"
|
||||
sessionKey contextKey = "session"
|
||||
orgKey contextKey = "org"
|
||||
UserKey ContextKey = "user"
|
||||
SessionKey ContextKey = "session"
|
||||
TokenKey ContextKey = "token"
|
||||
OrgKey ContextKey = "org"
|
||||
)
|
||||
|
||||
// GetUserID retrieves the user ID from the request context
|
||||
func GetUserID(ctx context.Context) (string, bool) {
|
||||
userID, ok := ctx.Value(UserKey).(string)
|
||||
return userID, ok
|
||||
}
|
||||
|
||||
// GetSession retrieves the session from the request context
|
||||
func GetSession(ctx context.Context) (*database.Session, bool) {
|
||||
session, ok := ctx.Value(SessionKey).(*database.Session)
|
||||
return session, ok
|
||||
}
|
||||
|
||||
// GetToken retrieves the JWT token from the request context
|
||||
func GetToken(ctx context.Context) (string, bool) {
|
||||
token, ok := ctx.Value(TokenKey).(string)
|
||||
return token, ok
|
||||
}
|
||||
|
||||
// Auth middleware
|
||||
func Auth(jwtManager *jwt.Manager, db *database.DB) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
authHeader := r.Header.Get("Authorization")
|
||||
if !strings.HasPrefix(authHeader, "Bearer ") {
|
||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
var tokenString string
|
||||
var tokenSource string
|
||||
if strings.HasPrefix(authHeader, "Bearer ") {
|
||||
tokenString = strings.TrimPrefix(authHeader, "Bearer ")
|
||||
tokenSource = "header"
|
||||
} else {
|
||||
// Fallback to query parameter token (for viewers that cannot set headers)
|
||||
qToken := r.URL.Query().Get("token")
|
||||
if qToken == "" {
|
||||
fmt.Printf("[AUTH-TOKEN] source=none, path=%s, statusCode=401\n", r.RequestURI)
|
||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
tokenString = qToken
|
||||
tokenSource = "query"
|
||||
}
|
||||
|
||||
tokenString := strings.TrimPrefix(authHeader, "Bearer ")
|
||||
fmt.Printf("[AUTH-TOKEN] source=%s, path=%s\n", tokenSource, r.RequestURI)
|
||||
|
||||
claims, session, err := jwtManager.ValidateWithSession(r.Context(), tokenString, db)
|
||||
if err != nil {
|
||||
fmt.Printf("[AUTH-TOKEN] validation_failed, source=%s, path=%s, error=%v\n", tokenSource, r.RequestURI, err)
|
||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
ctx := context.WithValue(r.Context(), userKey, claims.UserID)
|
||||
ctx = context.WithValue(ctx, sessionKey, session)
|
||||
fmt.Printf("[AUTH-TOKEN] valid, source=%s, userId=%s\n", tokenSource, claims.UserID)
|
||||
|
||||
ctx := context.WithValue(r.Context(), UserKey, claims.UserID)
|
||||
ctx = context.WithValue(ctx, SessionKey, session)
|
||||
ctx = context.WithValue(ctx, TokenKey, tokenString)
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
@@ -65,7 +200,7 @@ func Auth(jwtManager *jwt.Manager, db *database.DB) func(http.Handler) http.Hand
|
||||
func Org(db *database.DB, auditLogger *audit.Logger) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
userIDStr := r.Context().Value(userKey).(string)
|
||||
userIDStr := r.Context().Value(UserKey).(string)
|
||||
userID, _ := uuid.Parse(userIDStr)
|
||||
|
||||
orgIDStr := r.Header.Get("X-Org-ID")
|
||||
@@ -104,7 +239,7 @@ func Org(db *database.DB, auditLogger *audit.Logger) func(http.Handler) http.Han
|
||||
return
|
||||
}
|
||||
|
||||
ctx := context.WithValue(r.Context(), orgKey, orgID)
|
||||
ctx := context.WithValue(r.Context(), OrgKey, orgID)
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
@@ -114,9 +249,9 @@ func Org(db *database.DB, auditLogger *audit.Logger) func(http.Handler) http.Han
|
||||
func Permission(db *database.DB, auditLogger *audit.Logger, perm permission.Permission) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
userIDStr := r.Context().Value(userKey).(string)
|
||||
userIDStr := r.Context().Value(UserKey).(string)
|
||||
userID, _ := uuid.Parse(userIDStr)
|
||||
orgID := r.Context().Value(orgKey).(uuid.UUID)
|
||||
orgID := r.Context().Value(OrgKey).(uuid.UUID)
|
||||
|
||||
hasPerm, err := permission.HasPermission(r.Context(), db, userID, orgID, perm)
|
||||
if err != nil || !hasPerm {
|
||||
|
||||
72
go_cloud/internal/models/wopi.go
Normal file
@@ -0,0 +1,72 @@
|
||||
package models
|
||||
|
||||
import "time"
|
||||
|
||||
// WOPICheckFileInfoResponse represents the response to WOPI CheckFileInfo request
|
||||
// Reference: https://docs.microsoft.com/en-us/openspecs/office_protocols/ms-wopi/4b8ffc3f-e8a6-4169-8c4e-34924ac6ae2f
|
||||
type WOPICheckFileInfoResponse struct {
|
||||
BaseFileName string `json:"BaseFileName"`
|
||||
Size int64 `json:"Size"`
|
||||
Version string `json:"Version"`
|
||||
OwnerId string `json:"OwnerId"`
|
||||
UserId string `json:"UserId"`
|
||||
UserFriendlyName string `json:"UserFriendlyName"`
|
||||
UserCanWrite bool `json:"UserCanWrite"`
|
||||
UserCanRename bool `json:"UserCanRename"`
|
||||
UserCanNotWriteRelative bool `json:"UserCanNotWriteRelative"`
|
||||
ReadOnly bool `json:"ReadOnly"`
|
||||
RestrictedWebViewOnly bool `json:"RestrictedWebViewOnly"`
|
||||
UserCanCreateRelativeToFolder bool `json:"UserCanCreateRelativeToFolder"`
|
||||
EnableOwnerTermination bool `json:"EnableOwnerTermination"`
|
||||
SupportsUpdate bool `json:"SupportsUpdate"`
|
||||
SupportsCobalt bool `json:"SupportsCobalt"`
|
||||
SupportsLocks bool `json:"SupportsLocks"`
|
||||
SupportsExtendedLockLength bool `json:"SupportsExtendedLockLength"`
|
||||
SupportsGetLock bool `json:"SupportsGetLock"`
|
||||
SupportsDelete bool `json:"SupportsDelete"`
|
||||
SupportsRename bool `json:"SupportsRename"`
|
||||
SupportsRenameRelativeToFolder bool `json:"SupportsRenameRelativeToFolder"`
|
||||
SupportsFolders bool `json:"SupportsFolders"`
|
||||
SupportsScenarios []string `json:"SupportsScenarios"`
|
||||
LastModifiedTime string `json:"LastModifiedTime"`
|
||||
IsAnonymousUser bool `json:"IsAnonymousUser"`
|
||||
TimeZone string `json:"TimeZone"`
|
||||
CloseUrl string `json:"CloseUrl,omitempty"`
|
||||
EditUrl string `json:"EditUrl,omitempty"`
|
||||
ViewUrl string `json:"ViewUrl,omitempty"`
|
||||
FileSharingUrl string `json:"FileSharingUrl,omitempty"`
|
||||
DownloadUrl string `json:"DownloadUrl,omitempty"`
|
||||
}
|
||||
|
||||
// WOPIPutFileResponse represents the response to WOPI PutFile request
|
||||
type WOPIPutFileResponse struct {
|
||||
ItemVersion string `json:"ItemVersion"`
|
||||
}
|
||||
|
||||
// WOPILockInfo represents information about a file lock
|
||||
type WOPILockInfo struct {
|
||||
FileID string `json:"file_id"`
|
||||
UserID string `json:"user_id"`
|
||||
LockID string `json:"lock_id"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
}
|
||||
|
||||
// WOPIAccessTokenRequest represents a request to get WOPI access token
|
||||
type WOPIAccessTokenRequest struct {
|
||||
FileID string `json:"file_id"`
|
||||
}
|
||||
|
||||
// WOPIAccessTokenResponse represents a response with WOPI access token
|
||||
type WOPIAccessTokenResponse struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
AccessTokenTTL int64 `json:"access_token_ttl"`
|
||||
BootstrapperUrl string `json:"bootstrapper_url,omitempty"`
|
||||
ClosePostMessage bool `json:"close_post_message"`
|
||||
}
|
||||
|
||||
// WOPISessionResponse represents a response for creating a WOPI session
|
||||
type WOPISessionResponse struct {
|
||||
WOPISrc string `json:"wopi_src"`
|
||||
AccessToken string `json:"access_token"`
|
||||
}
|
||||
@@ -2,10 +2,14 @@ package org
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"go.b0esche.cloud/backend/internal/database"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgconn"
|
||||
)
|
||||
|
||||
// ResolveUserOrgs returns the organizations a user belongs to
|
||||
@@ -24,17 +28,58 @@ func CheckMembership(ctx context.Context, db *database.DB, userID, orgID uuid.UU
|
||||
|
||||
// CreateOrg creates a new organization and adds the user as owner
|
||||
func CreateOrg(ctx context.Context, db *database.DB, userID uuid.UUID, name, slug string) (*database.Organization, error) {
|
||||
if slug == "" {
|
||||
// Simple slug generation
|
||||
slug = name // TODO: make URL safe
|
||||
trimmedName := strings.TrimSpace(name)
|
||||
if trimmedName == "" {
|
||||
return nil, fmt.Errorf("organization name cannot be empty")
|
||||
}
|
||||
|
||||
baseSlug := slugify(slug)
|
||||
if baseSlug == "" {
|
||||
baseSlug = slugify(trimmedName)
|
||||
}
|
||||
if baseSlug == "" {
|
||||
baseSlug = fmt.Sprintf("org-%s", uuid.NewString()[:8])
|
||||
}
|
||||
|
||||
var org *database.Organization
|
||||
var err error
|
||||
// Try a handful of suffixes on unique constraint violation
|
||||
for i := 0; i < 5; i++ {
|
||||
candidate := baseSlug
|
||||
if i > 0 {
|
||||
candidate = fmt.Sprintf("%s-%d", baseSlug, i+1)
|
||||
}
|
||||
org, err = db.CreateOrg(ctx, userID, trimmedName, candidate)
|
||||
if err != nil {
|
||||
if pgErr, ok := err.(*pgconn.PgError); ok && pgErr.Code == "23505" {
|
||||
// Unique violation; try next suffix
|
||||
continue
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
break
|
||||
}
|
||||
org, err := db.CreateOrg(ctx, name, slug)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
err = db.AddMembership(ctx, userID, org.ID, "owner")
|
||||
if err != nil {
|
||||
|
||||
if err = db.AddMembership(ctx, userID, org.ID, "owner"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return org, nil
|
||||
}
|
||||
|
||||
// slugify converts a string to a URL-safe slug with hyphens.
|
||||
func slugify(s string) string {
|
||||
lower := strings.ToLower(strings.TrimSpace(s))
|
||||
if lower == "" {
|
||||
return ""
|
||||
}
|
||||
// Replace non-alphanumeric with hyphen
|
||||
re := regexp.MustCompile(`[^a-z0-9]+`)
|
||||
slug := re.ReplaceAllString(lower, "-")
|
||||
slug = strings.Trim(slug, "-")
|
||||
// Collapse multiple hyphens
|
||||
slug = strings.ReplaceAll(slug, "--", "-")
|
||||
return slug
|
||||
}
|
||||
|
||||
83
go_cloud/internal/storage/nextcloud.go
Normal file
@@ -0,0 +1,83 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// CreateNextcloudUser creates a new Nextcloud user account via OCS API
|
||||
func CreateNextcloudUser(nextcloudBaseURL, adminUser, adminPass, username, password string) error {
|
||||
// Remove any path from base URL, we need just the scheme://host:port
|
||||
baseURL := strings.Split(nextcloudBaseURL, "/remote.php")[0]
|
||||
urlStr := fmt.Sprintf("%s/ocs/v1.php/cloud/users", baseURL)
|
||||
|
||||
fmt.Printf("[DEBUG-PASSWORD-FLOW] CreateNextcloudUser called with password: %s\n", password)
|
||||
|
||||
// OCS API expects form-encoded data with proper URL encoding
|
||||
formData := url.Values{
|
||||
"userid": {username},
|
||||
"password": {password},
|
||||
}.Encode()
|
||||
|
||||
fmt.Printf("[DEBUG-PASSWORD-FLOW] Form data being sent to OCS API: %s\n", formData)
|
||||
|
||||
req, err := http.NewRequest("POST", urlStr, bytes.NewBufferString(formData))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
req.SetBasicAuth(adminUser, adminPass)
|
||||
req.Header.Set("OCS-APIRequest", "true")
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create user: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
|
||||
// 200 = success, 409 = user already exists (which is fine)
|
||||
if resp.StatusCode != 200 && resp.StatusCode != 409 {
|
||||
return fmt.Errorf("failed to create Nextcloud user (status %d): %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
fmt.Printf("[NEXTCLOUD] Created user account: %s with generated password\n", username)
|
||||
return nil
|
||||
}
|
||||
|
||||
// GenerateSecurePassword generates a random secure password
|
||||
func GenerateSecurePassword(length int) (string, error) {
|
||||
bytes := make([]byte, length)
|
||||
if _, err := rand.Read(bytes); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return base64.URLEncoding.EncodeToString(bytes)[:length], nil
|
||||
}
|
||||
|
||||
// NewUserWebDAVClient creates a WebDAV client for a specific user
|
||||
func NewUserWebDAVClient(nextcloudBaseURL, username, password string) *WebDAVClient {
|
||||
// Remove any path from base URL, we need just the scheme://host:port
|
||||
baseURL := strings.Split(nextcloudBaseURL, "/remote.php")[0]
|
||||
// Build the full WebDAV URL for this user
|
||||
fullURL := fmt.Sprintf("%s/remote.php/dav/files/%s", baseURL, username)
|
||||
|
||||
fmt.Printf("[WEBDAV-USER] Input URL: %s, Base: %s, Full: %s, User: %s\n", nextcloudBaseURL, baseURL, fullURL, username)
|
||||
fmt.Printf("[DEBUG-PASSWORD-FLOW] NewUserWebDAVClient called with password: %s\n", password)
|
||||
|
||||
return &WebDAVClient{
|
||||
baseURL: fullURL,
|
||||
user: username,
|
||||
pass: password,
|
||||
basePrefix: "/",
|
||||
httpClient: &http.Client{},
|
||||
}
|
||||
}
|
||||
274
go_cloud/internal/storage/webdav.go
Normal file
@@ -0,0 +1,274 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"path"
|
||||
"strings"
|
||||
|
||||
"go.b0esche.cloud/backend/internal/config"
|
||||
)
|
||||
|
||||
type WebDAVClient struct {
|
||||
baseURL string
|
||||
user string
|
||||
pass string
|
||||
basePrefix string
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
// NewWebDAVClient returns nil if no Nextcloud URL configured
|
||||
func NewWebDAVClient(cfg *config.Config) *WebDAVClient {
|
||||
if cfg == nil || strings.TrimSpace(cfg.NextcloudURL) == "" {
|
||||
fmt.Printf("[WEBDAV] No Nextcloud URL configured, WebDAV client is nil\n")
|
||||
return nil
|
||||
}
|
||||
u := strings.TrimRight(cfg.NextcloudURL, "/")
|
||||
base := cfg.NextcloudBase
|
||||
if base == "" {
|
||||
base = "/"
|
||||
}
|
||||
fmt.Printf("[WEBDAV] Initializing WebDAV client - URL: %s, User: %s, BasePath: %s\n", u, cfg.NextcloudUser, base)
|
||||
return &WebDAVClient{
|
||||
baseURL: u,
|
||||
user: cfg.NextcloudUser,
|
||||
pass: cfg.NextcloudPass,
|
||||
basePrefix: strings.TrimRight(base, "/"),
|
||||
httpClient: &http.Client{},
|
||||
}
|
||||
}
|
||||
|
||||
// ensureParent creates intermediate collections using MKCOL. Ignoring errors when already exists.
|
||||
func (c *WebDAVClient) ensureParent(ctx context.Context, remotePath string) error {
|
||||
// build incremental paths
|
||||
dir := path.Dir(remotePath)
|
||||
if dir == "." || dir == "/" || dir == "" {
|
||||
return nil
|
||||
}
|
||||
// split and build prefixes
|
||||
parts := strings.Split(strings.Trim(dir, "/"), "/")
|
||||
cur := c.basePrefix
|
||||
for _, p := range parts {
|
||||
cur = path.Join(cur, p)
|
||||
mkurl := fmt.Sprintf("%s%s", c.baseURL, cur)
|
||||
req, _ := http.NewRequestWithContext(ctx, "MKCOL", mkurl, nil)
|
||||
if c.user != "" {
|
||||
req.SetBasicAuth(c.user, c.pass)
|
||||
}
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
resp.Body.Close()
|
||||
// 201 created, 405 exists — ignore
|
||||
if resp.StatusCode == 201 || resp.StatusCode == 405 {
|
||||
continue
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Upload streams the content to the remotePath using HTTP PUT (WebDAV). remotePath should be absolute under basePrefix.
|
||||
func (c *WebDAVClient) Upload(ctx context.Context, remotePath string, r io.Reader, size int64) error {
|
||||
if c == nil {
|
||||
return fmt.Errorf("no webdav client configured")
|
||||
}
|
||||
// Ensure parent collections
|
||||
if err := c.ensureParent(ctx, remotePath); err != nil {
|
||||
return err
|
||||
}
|
||||
// Construct URL
|
||||
// remotePath might be like /orgs/<id>/file.txt; ensure it joins to basePrefix
|
||||
rel := strings.TrimLeft(remotePath, "/")
|
||||
u := c.basePrefix
|
||||
if u == "/" || u == "" {
|
||||
u = ""
|
||||
}
|
||||
u = strings.TrimRight(u, "/")
|
||||
|
||||
var full string
|
||||
if u == "" {
|
||||
full = fmt.Sprintf("%s/%s", c.baseURL, url.PathEscape(rel))
|
||||
} else {
|
||||
full = fmt.Sprintf("%s%s/%s", c.baseURL, u, url.PathEscape(rel))
|
||||
}
|
||||
full = strings.ReplaceAll(full, "%2F", "/")
|
||||
|
||||
fmt.Printf("[WEBDAV-UPLOAD] BaseURL: %s, BasePrefix: %s, RemotePath: %s, Full URL: %s\n", c.baseURL, c.basePrefix, remotePath, full)
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "PUT", full, r)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if size > 0 {
|
||||
req.ContentLength = size
|
||||
}
|
||||
if c.user != "" {
|
||||
req.SetBasicAuth(c.user, c.pass)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/octet-stream")
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
|
||||
return nil
|
||||
}
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return fmt.Errorf("webdav upload failed: %d %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
// Download retrieves a file from the remotePath using HTTP GET (WebDAV).
|
||||
func (c *WebDAVClient) Download(ctx context.Context, remotePath string, rangeHeader string) (*http.Response, error) {
|
||||
if c == nil {
|
||||
return nil, fmt.Errorf("no webdav client configured")
|
||||
}
|
||||
|
||||
rel := strings.TrimLeft(remotePath, "/")
|
||||
u := c.basePrefix
|
||||
if u == "/" || u == "" {
|
||||
u = ""
|
||||
}
|
||||
u = strings.TrimRight(u, "/")
|
||||
|
||||
var full string
|
||||
if u == "" {
|
||||
full = fmt.Sprintf("%s/%s", c.baseURL, url.PathEscape(rel))
|
||||
} else {
|
||||
full = fmt.Sprintf("%s%s/%s", c.baseURL, u, url.PathEscape(rel))
|
||||
}
|
||||
full = strings.ReplaceAll(full, "%2F", "/")
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", full, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if c.user != "" {
|
||||
req.SetBasicAuth(c.user, c.pass)
|
||||
}
|
||||
if rangeHeader != "" {
|
||||
req.Header.Set("Range", rangeHeader)
|
||||
}
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
return nil, fmt.Errorf("webdav download failed: %d %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
// Delete removes a file or collection from the remotePath using HTTP DELETE (WebDAV).
|
||||
func (c *WebDAVClient) Delete(ctx context.Context, remotePath string) error {
|
||||
if c == nil {
|
||||
return fmt.Errorf("no webdav client configured")
|
||||
}
|
||||
|
||||
rel := strings.TrimLeft(remotePath, "/")
|
||||
u := c.basePrefix
|
||||
if u == "/" || u == "" {
|
||||
u = ""
|
||||
}
|
||||
u = strings.TrimRight(u, "/")
|
||||
|
||||
var full string
|
||||
if u == "" {
|
||||
full = fmt.Sprintf("%s/%s", c.baseURL, url.PathEscape(rel))
|
||||
} else {
|
||||
full = fmt.Sprintf("%s%s/%s", c.baseURL, u, url.PathEscape(rel))
|
||||
}
|
||||
full = strings.ReplaceAll(full, "%2F", "/")
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "DELETE", full, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if c.user != "" {
|
||||
req.SetBasicAuth(c.user, c.pass)
|
||||
}
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// 404 means already deleted, consider it success
|
||||
if resp.StatusCode == 404 {
|
||||
return nil
|
||||
}
|
||||
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return fmt.Errorf("webdav delete failed: %d %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
// Move moves/renames a file using WebDAV MOVE method
|
||||
func (c *WebDAVClient) Move(ctx context.Context, sourcePath, targetPath string) error {
|
||||
if c == nil {
|
||||
return fmt.Errorf("no webdav client configured")
|
||||
}
|
||||
|
||||
sourceRel := strings.TrimLeft(sourcePath, "/")
|
||||
targetRel := strings.TrimLeft(targetPath, "/")
|
||||
|
||||
u := c.basePrefix
|
||||
if u == "/" || u == "" {
|
||||
u = ""
|
||||
}
|
||||
u = strings.TrimRight(u, "/")
|
||||
|
||||
// Build source URL
|
||||
var sourceURL string
|
||||
if u == "" {
|
||||
sourceURL = fmt.Sprintf("%s/%s", c.baseURL, url.PathEscape(sourceRel))
|
||||
} else {
|
||||
sourceURL = fmt.Sprintf("%s%s/%s", c.baseURL, u, url.PathEscape(sourceRel))
|
||||
}
|
||||
sourceURL = strings.ReplaceAll(sourceURL, "%2F", "/")
|
||||
|
||||
// Build target URL
|
||||
var targetURL string
|
||||
if u == "" {
|
||||
targetURL = fmt.Sprintf("%s/%s", c.baseURL, url.PathEscape(targetRel))
|
||||
} else {
|
||||
targetURL = fmt.Sprintf("%s%s/%s", c.baseURL, u, url.PathEscape(targetRel))
|
||||
}
|
||||
targetURL = strings.ReplaceAll(targetURL, "%2F", "/")
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "MOVE", sourceURL, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Destination", targetURL)
|
||||
if c.user != "" {
|
||||
req.SetBasicAuth(c.user, c.pass)
|
||||
}
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
|
||||
return nil
|
||||
}
|
||||
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return fmt.Errorf("webdav move failed: %d %s", resp.StatusCode, string(body))
|
||||
}
|
||||
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);
|
||||
28
go_cloud/migrations/0004_org_owner_slug.sql
Normal file
@@ -0,0 +1,28 @@
|
||||
-- Scope organization slugs per owner instead of globally unique
|
||||
ALTER TABLE organizations ADD COLUMN owner_id UUID REFERENCES users(id);
|
||||
|
||||
WITH first_owner AS (
|
||||
SELECT DISTINCT ON (org_id) org_id, user_id
|
||||
FROM memberships
|
||||
WHERE role = 'owner'
|
||||
ORDER BY org_id, created_at
|
||||
)
|
||||
UPDATE organizations o
|
||||
SET owner_id = fo.user_id
|
||||
FROM first_owner fo
|
||||
WHERE o.id = fo.org_id;
|
||||
|
||||
WITH first_member AS (
|
||||
SELECT DISTINCT ON (org_id) org_id, user_id
|
||||
FROM memberships
|
||||
ORDER BY org_id, created_at
|
||||
)
|
||||
UPDATE organizations o
|
||||
SET owner_id = fm.user_id
|
||||
FROM first_member fm
|
||||
WHERE o.owner_id IS NULL
|
||||
AND o.id = fm.org_id;
|
||||
|
||||
ALTER TABLE organizations ALTER COLUMN owner_id SET NOT NULL;
|
||||
ALTER TABLE organizations DROP CONSTRAINT organizations_slug_key;
|
||||
CREATE UNIQUE INDEX organizations_owner_slug_key ON organizations(owner_id, slug);
|
||||
50
go_cloud/migrations/run-migrations.sh
Normal file
@@ -0,0 +1,50 @@
|
||||
#!/bin/bash
|
||||
# Database Migration Runner for b0esche.cloud
|
||||
# Runs all SQL migrations in order
|
||||
|
||||
set -e
|
||||
|
||||
# Check for required environment variable
|
||||
if [ -z "$DATABASE_URL" ]; then
|
||||
echo "ERROR: DATABASE_URL environment variable not set"
|
||||
echo "Example: DATABASE_URL=postgres://user:pass@localhost:5432/dbname"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
|
||||
echo "=== b0esche.cloud Database Migrations ==="
|
||||
echo "Database: $DATABASE_URL"
|
||||
echo
|
||||
|
||||
# Function to run a single migration
|
||||
run_migration() {
|
||||
local file=$1
|
||||
echo "Running: $(basename $file)"
|
||||
psql "$DATABASE_URL" -f "$file" -v ON_ERROR_STOP=1
|
||||
if [ $? -eq 0 ]; then
|
||||
echo "✓ Success"
|
||||
else
|
||||
echo "✗ Failed"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Run migrations in order
|
||||
echo "Step 1/4: Initial schema..."
|
||||
run_migration "$SCRIPT_DIR/0001_initial.sql"
|
||||
|
||||
echo
|
||||
echo "Step 2/4: Passkeys and authentication..."
|
||||
run_migration "$SCRIPT_DIR/0002_passkeys.sql"
|
||||
|
||||
echo
|
||||
echo "Step 3/4: Files and storage..."
|
||||
run_migration "$SCRIPT_DIR/0003_files.sql"
|
||||
|
||||
echo
|
||||
echo "Step 4/4: Organization ownership and slug scope..."
|
||||
run_migration "$SCRIPT_DIR/0004_org_owner_slug.sql"
|
||||
|
||||
echo
|
||||
echo "=== All migrations completed successfully! ==="
|
||||
@@ -27,12 +27,16 @@ func NewManager(secret string) *Manager {
|
||||
}
|
||||
|
||||
func (m *Manager) Generate(userID string, orgIDs []string, sessionID string) (string, error) {
|
||||
return m.GenerateWithDuration(userID, orgIDs, sessionID, 15*time.Minute)
|
||||
}
|
||||
|
||||
func (m *Manager) GenerateWithDuration(userID string, orgIDs []string, sessionID string, duration time.Duration) (string, error) {
|
||||
claims := Claims{
|
||||
UserID: userID,
|
||||
OrgIDs: orgIDs,
|
||||
SessionID: sessionID,
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
ExpiresAt: jwt.NewNumericDate(time.Now().Add(15 * time.Minute)),
|
||||
ExpiresAt: jwt.NewNumericDate(time.Now().Add(duration)),
|
||||
IssuedAt: jwt.NewNumericDate(time.Now()),
|
||||
},
|
||||
}
|
||||
|
||||