Compare commits
134 Commits
dev
...
0b822af438
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 'package:bloc/bloc.dart';
|
||||||
import '../session/session_bloc.dart';
|
import '../session/session_bloc.dart';
|
||||||
import '../session/session_event.dart';
|
import '../session/session_event.dart';
|
||||||
|
import '../session/session_state.dart';
|
||||||
import 'auth_event.dart';
|
import 'auth_event.dart';
|
||||||
import 'auth_state.dart';
|
import 'auth_state.dart';
|
||||||
import '../../services/api_client.dart';
|
import '../../services/api_client.dart';
|
||||||
@@ -252,7 +253,22 @@ class AuthBloc extends Bloc<AuthEvent, AuthState> {
|
|||||||
CheckAuthRequested event,
|
CheckAuthRequested event,
|
||||||
Emitter<AuthState> emit,
|
Emitter<AuthState> emit,
|
||||||
) async {
|
) async {
|
||||||
// Check if token is valid in SessionBloc
|
// Check if session is active from persistent storage
|
||||||
|
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());
|
emit(AuthUnauthenticated());
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ class DocumentViewerBloc
|
|||||||
DocumentViewerReady(
|
DocumentViewerReady(
|
||||||
viewUrl: session.viewUrl,
|
viewUrl: session.viewUrl,
|
||||||
caps: session.capabilities,
|
caps: session.capabilities,
|
||||||
|
token: session.token,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
_expiryTimer = Timer(
|
_expiryTimer = Timer(
|
||||||
|
|||||||
@@ -15,11 +15,16 @@ class DocumentViewerLoading extends DocumentViewerState {}
|
|||||||
class DocumentViewerReady extends DocumentViewerState {
|
class DocumentViewerReady extends DocumentViewerState {
|
||||||
final Uri viewUrl;
|
final Uri viewUrl;
|
||||||
final DocumentCapabilities caps;
|
final DocumentCapabilities caps;
|
||||||
|
final String token;
|
||||||
|
|
||||||
const DocumentViewerReady({required this.viewUrl, required this.caps});
|
const DocumentViewerReady({
|
||||||
|
required this.viewUrl,
|
||||||
|
required this.caps,
|
||||||
|
required this.token,
|
||||||
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
List<Object> get props => [viewUrl, caps];
|
List<Object> get props => [viewUrl, caps, token];
|
||||||
}
|
}
|
||||||
|
|
||||||
class DocumentViewerError extends DocumentViewerState {
|
class DocumentViewerError extends DocumentViewerState {
|
||||||
|
|||||||
@@ -52,9 +52,13 @@ class EditorSessionBloc extends Bloc<EditorSessionEvent, EditorSessionState> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!session.readOnly) {
|
if (!session.readOnly) {
|
||||||
emit(EditorSessionActive(editUrl: session.editUrl));
|
emit(
|
||||||
|
EditorSessionActive(editUrl: session.editUrl, token: session.token),
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
emit(EditorSessionReadOnly(viewUrl: session.editUrl));
|
emit(
|
||||||
|
EditorSessionReadOnly(viewUrl: session.editUrl, token: session.token),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
_expiryTimer = Timer(
|
_expiryTimer = Timer(
|
||||||
session.expiresAt.difference(DateTime.now()),
|
session.expiresAt.difference(DateTime.now()),
|
||||||
|
|||||||
@@ -13,20 +13,22 @@ class EditorSessionStarting extends EditorSessionState {}
|
|||||||
|
|
||||||
class EditorSessionActive extends EditorSessionState {
|
class EditorSessionActive extends EditorSessionState {
|
||||||
final Uri editUrl;
|
final Uri editUrl;
|
||||||
|
final String token;
|
||||||
|
|
||||||
const EditorSessionActive({required this.editUrl});
|
const EditorSessionActive({required this.editUrl, required this.token});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
List<Object> get props => [editUrl];
|
List<Object> get props => [editUrl, token];
|
||||||
}
|
}
|
||||||
|
|
||||||
class EditorSessionReadOnly extends EditorSessionState {
|
class EditorSessionReadOnly extends EditorSessionState {
|
||||||
final Uri viewUrl;
|
final Uri viewUrl;
|
||||||
|
final String token;
|
||||||
|
|
||||||
const EditorSessionReadOnly({required this.viewUrl});
|
const EditorSessionReadOnly({required this.viewUrl, required this.token});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
List<Object> get props => [viewUrl];
|
List<Object> get props => [viewUrl, token];
|
||||||
}
|
}
|
||||||
|
|
||||||
class EditorSessionFailed extends EditorSessionState {
|
class EditorSessionFailed extends EditorSessionState {
|
||||||
|
|||||||
@@ -123,24 +123,8 @@ class FileBrowserBloc extends Bloc<FileBrowserEvent, FileBrowserState> {
|
|||||||
event.parentPath,
|
event.parentPath,
|
||||||
event.folderName,
|
event.folderName,
|
||||||
);
|
);
|
||||||
// Add the new folder to local state if in current directory
|
// Reload directory to get the folder with proper ID from backend
|
||||||
if (event.parentPath == _currentPath) {
|
|
||||||
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));
|
add(LoadDirectory(orgId: event.orgId, path: event.parentPath));
|
||||||
}
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
emit(DirectoryError(_getErrorMessage(e)));
|
emit(DirectoryError(_getErrorMessage(e)));
|
||||||
}
|
}
|
||||||
@@ -192,7 +176,8 @@ class FileBrowserBloc extends Bloc<FileBrowserEvent, FileBrowserState> {
|
|||||||
void _onDeleteFile(DeleteFile event, Emitter<FileBrowserState> emit) async {
|
void _onDeleteFile(DeleteFile event, Emitter<FileBrowserState> emit) async {
|
||||||
try {
|
try {
|
||||||
await _fileService.deleteFile(event.orgId, event.path);
|
await _fileService.deleteFile(event.orgId, event.path);
|
||||||
_currentFiles.removeWhere((f) => f.path == event.path);
|
// Create new list to trigger Equatable change detection
|
||||||
|
_currentFiles = _currentFiles.where((f) => f.path != event.path).toList();
|
||||||
_filteredFiles = _currentFiles
|
_filteredFiles = _currentFiles
|
||||||
.where((f) => f.name.toLowerCase().contains(_currentFilter))
|
.where((f) => f.name.toLowerCase().contains(_currentFilter))
|
||||||
.toList();
|
.toList();
|
||||||
@@ -206,9 +191,9 @@ class FileBrowserBloc extends Bloc<FileBrowserEvent, FileBrowserState> {
|
|||||||
ResetFileBrowser event,
|
ResetFileBrowser event,
|
||||||
Emitter<FileBrowserState> emit,
|
Emitter<FileBrowserState> emit,
|
||||||
) {
|
) {
|
||||||
emit(DirectoryInitial());
|
|
||||||
_currentOrgId = '';
|
|
||||||
_currentPath = '/';
|
_currentPath = '/';
|
||||||
|
_currentFiles = [];
|
||||||
|
_filteredFiles = [];
|
||||||
_currentFilter = '';
|
_currentFilter = '';
|
||||||
_currentPage = 1;
|
_currentPage = 1;
|
||||||
_pageSize = 20;
|
_pageSize = 20;
|
||||||
@@ -277,6 +262,12 @@ class FileBrowserBloc extends Bloc<FileBrowserEvent, FileBrowserState> {
|
|||||||
) {
|
) {
|
||||||
final sorted = List<FileItem>.from(files);
|
final sorted = List<FileItem>.from(files);
|
||||||
sorted.sort((a, b) {
|
sorted.sort((a, b) {
|
||||||
|
// Always put folders first, then files
|
||||||
|
if (a.type != b.type) {
|
||||||
|
return a.type == FileType.folder ? -1 : 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Within the same type (both folders or both files), sort by the selected criterion
|
||||||
switch (sortBy) {
|
switch (sortBy) {
|
||||||
case 'name':
|
case 'name':
|
||||||
return isAscending
|
return isAscending
|
||||||
@@ -291,12 +282,7 @@ class FileBrowserBloc extends Bloc<FileBrowserEvent, FileBrowserState> {
|
|||||||
? a.size.compareTo(b.size)
|
? a.size.compareTo(b.size)
|
||||||
: b.size.compareTo(a.size);
|
: b.size.compareTo(a.size);
|
||||||
case 'type':
|
case 'type':
|
||||||
// Folders before files if ascending, else files before folders
|
// Already handled above (folders vs files)
|
||||||
int typeCompare = isAscending
|
|
||||||
? a.type.index.compareTo(b.type.index)
|
|
||||||
: b.type.index.compareTo(a.type.index);
|
|
||||||
if (typeCompare != 0) return typeCompare;
|
|
||||||
// Within same type, sort by name
|
|
||||||
return isAscending
|
return isAscending
|
||||||
? a.name.compareTo(b.name)
|
? a.name.compareTo(b.name)
|
||||||
: b.name.compareTo(a.name);
|
: b.name.compareTo(a.name);
|
||||||
|
|||||||
@@ -62,7 +62,14 @@ class CreateFolder extends FileBrowserEvent {
|
|||||||
List<Object> get props => [orgId, parentPath, folderName];
|
List<Object> get props => [orgId, parentPath, folderName];
|
||||||
}
|
}
|
||||||
|
|
||||||
class ResetFileBrowser extends FileBrowserEvent {}
|
class ResetFileBrowser extends FileBrowserEvent {
|
||||||
|
final String nextOrgId;
|
||||||
|
|
||||||
|
const ResetFileBrowser(this.nextOrgId);
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<Object> get props => [nextOrgId];
|
||||||
|
}
|
||||||
|
|
||||||
class LoadPage extends FileBrowserEvent {
|
class LoadPage extends FileBrowserEvent {
|
||||||
final int page;
|
final int page;
|
||||||
|
|||||||
@@ -73,10 +73,18 @@ class OrganizationBloc extends Bloc<OrganizationEvent, OrganizationState> {
|
|||||||
) {
|
) {
|
||||||
final currentState = state;
|
final currentState = state;
|
||||||
if (currentState is OrganizationLoaded) {
|
if (currentState is OrganizationLoaded) {
|
||||||
final selected = currentState.organizations.firstWhere(
|
Organization? selected;
|
||||||
|
|
||||||
|
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,
|
(org) => org.id == event.orgId,
|
||||||
orElse: () => currentState.selectedOrg!,
|
orElse: () => currentState.selectedOrg!,
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
emit(
|
emit(
|
||||||
OrganizationLoaded(
|
OrganizationLoaded(
|
||||||
organizations: currentState.organizations,
|
organizations: currentState.organizations,
|
||||||
@@ -86,7 +94,7 @@ class OrganizationBloc extends Bloc<OrganizationEvent, OrganizationState> {
|
|||||||
);
|
);
|
||||||
// Reset all dependent blocs
|
// Reset all dependent blocs
|
||||||
permissionBloc.add(PermissionsReset());
|
permissionBloc.add(PermissionsReset());
|
||||||
fileBrowserBloc.add(ResetFileBrowser());
|
fileBrowserBloc.add(ResetFileBrowser(event.orgId));
|
||||||
uploadBloc.add(ResetUploads());
|
uploadBloc.add(ResetUploads());
|
||||||
// Load permissions for the selected org
|
// Load permissions for the selected org
|
||||||
permissionBloc.add(LoadPermissions(event.orgId));
|
permissionBloc.add(LoadPermissions(event.orgId));
|
||||||
@@ -97,59 +105,72 @@ class OrganizationBloc extends Bloc<OrganizationEvent, OrganizationState> {
|
|||||||
CreateOrganization event,
|
CreateOrganization event,
|
||||||
Emitter<OrganizationState> emit,
|
Emitter<OrganizationState> emit,
|
||||||
) async {
|
) async {
|
||||||
final currentState = state;
|
|
||||||
if (currentState is OrganizationLoaded) {
|
|
||||||
final name = event.name.trim();
|
final name = event.name.trim();
|
||||||
if (name.isEmpty) {
|
if (name.isEmpty) {
|
||||||
|
// Try to preserve current state if possible
|
||||||
|
if (state is OrganizationLoaded) {
|
||||||
emit(
|
emit(
|
||||||
OrganizationLoaded(
|
OrganizationLoaded(
|
||||||
organizations: currentState.organizations,
|
organizations: (state as OrganizationLoaded).organizations,
|
||||||
selectedOrg: currentState.selectedOrg,
|
selectedOrg: (state as OrganizationLoaded).selectedOrg,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
error: 'Organization name cannot be empty',
|
error: 'Organization name cannot be empty',
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (currentState.organizations.any((org) => org.name == name)) {
|
|
||||||
|
// Get existing organizations list
|
||||||
|
List<Organization> existingOrgs = [];
|
||||||
|
Organization? selectedOrg;
|
||||||
|
|
||||||
|
if (state is OrganizationLoaded) {
|
||||||
|
existingOrgs = (state as OrganizationLoaded).organizations;
|
||||||
|
selectedOrg = (state as OrganizationLoaded).selectedOrg;
|
||||||
|
|
||||||
|
// Check for duplicate name (client-side validation)
|
||||||
|
if (existingOrgs.any((org) => org.name == name)) {
|
||||||
emit(
|
emit(
|
||||||
OrganizationLoaded(
|
OrganizationLoaded(
|
||||||
organizations: currentState.organizations,
|
organizations: existingOrgs,
|
||||||
selectedOrg: currentState.selectedOrg,
|
selectedOrg: selectedOrg,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
error: 'Organization with this name already exists',
|
error: 'Organization with this name already exists',
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set loading state
|
||||||
emit(
|
emit(
|
||||||
OrganizationLoaded(
|
OrganizationLoaded(
|
||||||
organizations: currentState.organizations,
|
organizations: existingOrgs,
|
||||||
selectedOrg: currentState.selectedOrg,
|
selectedOrg: selectedOrg,
|
||||||
isLoading: true,
|
isLoading: true,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final newOrg = await orgApi.createOrganization(name);
|
final newOrg = await orgApi.createOrganization(name);
|
||||||
final updatedOrgs = [...currentState.organizations, newOrg];
|
|
||||||
emit(
|
final updatedOrgs = [...existingOrgs, newOrg];
|
||||||
OrganizationLoaded(organizations: updatedOrgs, selectedOrg: newOrg),
|
emit(OrganizationLoaded(organizations: updatedOrgs, selectedOrg: newOrg));
|
||||||
);
|
|
||||||
// Reset blocs and load permissions for new org
|
// Reset blocs and load permissions for new org
|
||||||
permissionBloc.add(PermissionsReset());
|
permissionBloc.add(PermissionsReset());
|
||||||
fileBrowserBloc.add(ResetFileBrowser());
|
fileBrowserBloc.add(ResetFileBrowser(newOrg.id));
|
||||||
uploadBloc.add(ResetUploads());
|
uploadBloc.add(ResetUploads());
|
||||||
permissionBloc.add(LoadPermissions(newOrg.id));
|
permissionBloc.add(LoadPermissions(newOrg.id));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
emit(
|
emit(
|
||||||
OrganizationLoaded(
|
OrganizationLoaded(
|
||||||
organizations: currentState.organizations,
|
organizations: existingOrgs,
|
||||||
selectedOrg: currentState.selectedOrg,
|
selectedOrg: selectedOrg,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
error: _getErrorMessage(e),
|
error: _getErrorMessage(e),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,11 +16,12 @@ class PermissionBloc extends Bloc<PermissionEvent, PermissionState> {
|
|||||||
// Simulate loading permissions from backend for orgId
|
// Simulate loading permissions from backend for orgId
|
||||||
await Future.delayed(const Duration(seconds: 1));
|
await Future.delayed(const Duration(seconds: 1));
|
||||||
// Mock capabilities based on orgId
|
// Mock capabilities based on orgId
|
||||||
|
// Allow all permissions for authenticated users (proper permissions should come from backend)
|
||||||
final capabilities = Capabilities(
|
final capabilities = Capabilities(
|
||||||
canRead: true,
|
canRead: true,
|
||||||
canWrite: event.orgId == 'org1', // Only admin for personal
|
canWrite: true,
|
||||||
canShare: event.orgId == 'org1',
|
canShare: true,
|
||||||
canAdmin: event.orgId == 'org1',
|
canAdmin: true,
|
||||||
canAnnotate: true,
|
canAnnotate: true,
|
||||||
canEdit: true,
|
canEdit: true,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,42 +1,104 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'package:bloc/bloc.dart';
|
import 'package:bloc/bloc.dart';
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
import 'session_event.dart';
|
import 'session_event.dart';
|
||||||
import 'session_state.dart';
|
import 'session_state.dart';
|
||||||
|
|
||||||
class SessionBloc extends Bloc<SessionEvent, SessionState> {
|
class SessionBloc extends Bloc<SessionEvent, SessionState> {
|
||||||
Timer? _expiryTimer;
|
Timer? _expiryTimer;
|
||||||
|
static const String _tokenKey = 'auth_token';
|
||||||
|
static const String _expiryKey = 'auth_expiry';
|
||||||
|
|
||||||
SessionBloc() : super(SessionInitial()) {
|
SessionBloc() : super(SessionInitial()) {
|
||||||
on<SessionStarted>(_onSessionStarted);
|
on<SessionStarted>(_onSessionStarted);
|
||||||
on<SessionExpired>(_onSessionExpired);
|
on<SessionExpired>(_onSessionExpired);
|
||||||
on<SessionRefreshed>(_onSessionRefreshed);
|
on<SessionRefreshed>(_onSessionRefreshed);
|
||||||
on<SessionEnded>(_onSessionEnded);
|
on<SessionEnded>(_onSessionEnded);
|
||||||
|
on<SessionRestored>(_onSessionRestored);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _onSessionStarted(SessionStarted event, Emitter<SessionState> emit) {
|
void _onSessionStarted(
|
||||||
|
SessionStarted event,
|
||||||
|
Emitter<SessionState> emit,
|
||||||
|
) async {
|
||||||
final expiresAt = DateTime.now().add(
|
final expiresAt = DateTime.now().add(
|
||||||
const Duration(minutes: 15),
|
const Duration(minutes: 15),
|
||||||
); // Match Go
|
); // Match Go
|
||||||
|
|
||||||
|
// Save token to persistent storage
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.setString(_tokenKey, event.token);
|
||||||
|
await prefs.setString(_expiryKey, expiresAt.toIso8601String());
|
||||||
|
|
||||||
emit(SessionActive(token: event.token, expiresAt: expiresAt));
|
emit(SessionActive(token: event.token, expiresAt: expiresAt));
|
||||||
_startExpiryTimer(expiresAt);
|
_startExpiryTimer(expiresAt);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _onSessionExpired(SessionExpired event, Emitter<SessionState> emit) {
|
void _onSessionExpired(SessionExpired event, Emitter<SessionState> emit) {
|
||||||
_expiryTimer?.cancel();
|
_expiryTimer?.cancel();
|
||||||
|
_clearStoredSession();
|
||||||
emit(SessionExpiredState());
|
emit(SessionExpiredState());
|
||||||
}
|
}
|
||||||
|
|
||||||
void _onSessionRefreshed(SessionRefreshed event, Emitter<SessionState> emit) {
|
void _onSessionRefreshed(
|
||||||
|
SessionRefreshed event,
|
||||||
|
Emitter<SessionState> emit,
|
||||||
|
) async {
|
||||||
final expiresAt = DateTime.now().add(const Duration(minutes: 15));
|
final expiresAt = DateTime.now().add(const Duration(minutes: 15));
|
||||||
|
|
||||||
|
// Update stored token
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.setString(_tokenKey, event.newToken);
|
||||||
|
await prefs.setString(_expiryKey, expiresAt.toIso8601String());
|
||||||
|
|
||||||
emit(SessionActive(token: event.newToken, expiresAt: expiresAt));
|
emit(SessionActive(token: event.newToken, expiresAt: expiresAt));
|
||||||
_startExpiryTimer(expiresAt);
|
_startExpiryTimer(expiresAt);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _onSessionEnded(SessionEnded event, Emitter<SessionState> emit) {
|
void _onSessionEnded(SessionEnded event, Emitter<SessionState> emit) {
|
||||||
_expiryTimer?.cancel();
|
_expiryTimer?.cancel();
|
||||||
|
_clearStoredSession();
|
||||||
emit(SessionInitial());
|
emit(SessionInitial());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _onSessionRestored(SessionRestored event, Emitter<SessionState> emit) {
|
||||||
|
final expiresAt = event.expiresAt;
|
||||||
|
final now = DateTime.now();
|
||||||
|
|
||||||
|
// Check if token is still valid
|
||||||
|
if (expiresAt.isAfter(now)) {
|
||||||
|
emit(SessionActive(token: event.token, expiresAt: expiresAt));
|
||||||
|
_startExpiryTimer(expiresAt);
|
||||||
|
} else {
|
||||||
|
// Token expired, clear it
|
||||||
|
_clearStoredSession();
|
||||||
|
emit(SessionInitial());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _clearStoredSession() async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.remove(_tokenKey);
|
||||||
|
await prefs.remove(_expiryKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<void> restoreSession(SessionBloc bloc) async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
final token = prefs.getString(_tokenKey);
|
||||||
|
final expiryStr = prefs.getString(_expiryKey);
|
||||||
|
|
||||||
|
if (token != null && expiryStr != null) {
|
||||||
|
try {
|
||||||
|
final expiresAt = DateTime.parse(expiryStr);
|
||||||
|
bloc.add(SessionRestored(token: token, expiresAt: expiresAt));
|
||||||
|
} catch (e) {
|
||||||
|
// Invalid stored data, clear it
|
||||||
|
await prefs.remove(_tokenKey);
|
||||||
|
await prefs.remove(_expiryKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
void _startExpiryTimer(DateTime expiresAt) {
|
void _startExpiryTimer(DateTime expiresAt) {
|
||||||
_expiryTimer?.cancel();
|
_expiryTimer?.cancel();
|
||||||
final duration = expiresAt.difference(DateTime.now());
|
final duration = expiresAt.difference(DateTime.now());
|
||||||
|
|||||||
@@ -28,3 +28,13 @@ class SessionRefreshed extends SessionEvent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class SessionEnded extends SessionEvent {}
|
class SessionEnded extends SessionEvent {}
|
||||||
|
|
||||||
|
class SessionRestored extends SessionEvent {
|
||||||
|
final String token;
|
||||||
|
final DateTime expiresAt;
|
||||||
|
|
||||||
|
const SessionRestored({required this.token, required this.expiresAt});
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<Object> get props => [token, expiresAt];
|
||||||
|
}
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ class UploadBloc extends Bloc<UploadEvent, UploadState> {
|
|||||||
try {
|
try {
|
||||||
// Simulate upload
|
// Simulate upload
|
||||||
await _fileRepository.uploadFile(event.orgId, file);
|
await _fileRepository.uploadFile(event.orgId, file);
|
||||||
|
|
||||||
add(UploadCompleted(file));
|
add(UploadCompleted(file));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
add(UploadFailed(fileName: file.name, error: e.toString()));
|
add(UploadFailed(fileName: file.name, error: e.toString()));
|
||||||
|
|||||||
@@ -1,24 +1,33 @@
|
|||||||
import 'package:b0esche_cloud/services/api_client.dart';
|
import 'package:b0esche_cloud/services/api_client.dart';
|
||||||
import 'package:get_it/get_it.dart';
|
import 'package:get_it/get_it.dart';
|
||||||
|
import 'blocs/session/session_bloc.dart';
|
||||||
import 'repositories/auth_repository.dart';
|
import 'repositories/auth_repository.dart';
|
||||||
import 'repositories/file_repository.dart';
|
import 'repositories/file_repository.dart';
|
||||||
import 'repositories/mock_auth_repository.dart';
|
import 'repositories/http_auth_repository.dart';
|
||||||
import 'repositories/mock_file_repository.dart';
|
import 'repositories/http_file_repository.dart';
|
||||||
import 'services/auth_service.dart';
|
import 'services/auth_service.dart';
|
||||||
import 'services/file_service.dart';
|
import 'services/file_service.dart';
|
||||||
|
import 'services/org_api.dart';
|
||||||
import 'viewmodels/login_view_model.dart';
|
import 'viewmodels/login_view_model.dart';
|
||||||
import 'viewmodels/file_explorer_view_model.dart';
|
import 'viewmodels/file_explorer_view_model.dart';
|
||||||
|
|
||||||
final getIt = GetIt.instance;
|
final getIt = GetIt.instance;
|
||||||
|
|
||||||
void configureDependencies() {
|
void configureDependencies(SessionBloc sessionBloc) {
|
||||||
// Register repositories
|
// Register ApiClient first
|
||||||
getIt.registerSingleton<AuthRepository>(MockAuthRepository());
|
final apiClient = ApiClient(sessionBloc);
|
||||||
getIt.registerSingleton<FileRepository>(MockFileRepository());
|
getIt.registerSingleton<ApiClient>(apiClient);
|
||||||
|
|
||||||
|
// Register repositories (HTTP-backed)
|
||||||
|
getIt.registerSingleton<AuthRepository>(HttpAuthRepository(apiClient));
|
||||||
|
getIt.registerSingleton<FileRepository>(
|
||||||
|
HttpFileRepository(FileService(apiClient)),
|
||||||
|
);
|
||||||
|
|
||||||
// Register services
|
// Register services
|
||||||
getIt.registerSingleton<AuthService>(AuthService(getIt<AuthRepository>()));
|
getIt.registerSingleton<AuthService>(AuthService(getIt<AuthRepository>()));
|
||||||
getIt.registerSingleton<FileService>(FileService(getIt<ApiClient>()));
|
getIt.registerSingleton<FileService>(FileService(getIt<ApiClient>()));
|
||||||
|
getIt.registerSingleton<OrgApi>(OrgApi(getIt<ApiClient>()));
|
||||||
|
|
||||||
// Register viewmodels
|
// Register viewmodels
|
||||||
getIt.registerSingleton<LoginViewModel>(LoginViewModel(getIt<AuthService>()));
|
getIt.registerSingleton<LoginViewModel>(LoginViewModel(getIt<AuthService>()));
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
import 'blocs/auth/auth_bloc.dart';
|
import 'blocs/auth/auth_bloc.dart';
|
||||||
|
import 'blocs/auth/auth_event.dart';
|
||||||
import 'blocs/session/session_bloc.dart';
|
import 'blocs/session/session_bloc.dart';
|
||||||
import 'blocs/activity/activity_bloc.dart';
|
import 'blocs/activity/activity_bloc.dart';
|
||||||
import 'services/api_client.dart';
|
import 'services/api_client.dart';
|
||||||
@@ -11,6 +12,7 @@ import 'pages/file_explorer.dart';
|
|||||||
import 'pages/document_viewer.dart';
|
import 'pages/document_viewer.dart';
|
||||||
import 'pages/editor_page.dart';
|
import 'pages/editor_page.dart';
|
||||||
import 'theme/app_theme.dart';
|
import 'theme/app_theme.dart';
|
||||||
|
import 'injection.dart';
|
||||||
|
|
||||||
final GoRouter _router = GoRouter(
|
final GoRouter _router = GoRouter(
|
||||||
routes: [
|
routes: [
|
||||||
@@ -41,29 +43,80 @@ void main() {
|
|||||||
runApp(const MainApp());
|
runApp(const MainApp());
|
||||||
}
|
}
|
||||||
|
|
||||||
class MainApp extends StatelessWidget {
|
class MainApp extends StatefulWidget {
|
||||||
const MainApp({super.key});
|
const MainApp({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<MainApp> createState() => _MainAppState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _MainAppState extends State<MainApp> {
|
||||||
|
final _sessionBloc = SessionBloc();
|
||||||
|
late final AuthBloc _authBloc;
|
||||||
|
late final Future<void> _restoreFuture;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
// Configure DI first
|
||||||
|
configureDependencies(_sessionBloc);
|
||||||
|
|
||||||
|
// Create AuthBloc first
|
||||||
|
_authBloc = AuthBloc(
|
||||||
|
apiClient: ApiClient(_sessionBloc),
|
||||||
|
sessionBloc: _sessionBloc,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Restore session and then check auth
|
||||||
|
_restoreFuture = SessionBloc.restoreSession(_sessionBloc).then((_) {
|
||||||
|
// After session is restored, check if we should auto-authenticate
|
||||||
|
if (mounted) {
|
||||||
|
_authBloc.add(const CheckAuthRequested());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return MultiBlocProvider(
|
return MultiBlocProvider(
|
||||||
providers: [
|
providers: [
|
||||||
BlocProvider<SessionBloc>(create: (_) => SessionBloc()),
|
BlocProvider<SessionBloc>.value(value: _sessionBloc),
|
||||||
BlocProvider<AuthBloc>(
|
BlocProvider<AuthBloc>.value(value: _authBloc),
|
||||||
create: (context) => AuthBloc(
|
|
||||||
apiClient: ApiClient(context.read<SessionBloc>()),
|
|
||||||
sessionBloc: context.read<SessionBloc>(),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
BlocProvider<ActivityBloc>(
|
BlocProvider<ActivityBloc>(
|
||||||
create: (context) =>
|
create: (context) =>
|
||||||
ActivityBloc(ActivityApi(ApiClient(context.read<SessionBloc>()))),
|
ActivityBloc(ActivityApi(ApiClient(_sessionBloc))),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
child: MaterialApp.router(
|
child: FutureBuilder<void>(
|
||||||
routerConfig: _router,
|
future: _restoreFuture,
|
||||||
|
builder: (context, snapshot) {
|
||||||
|
if (snapshot.connectionState != ConnectionState.done) {
|
||||||
|
return MaterialApp(
|
||||||
theme: AppTheme.darkTheme,
|
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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,21 +4,31 @@ class DocumentCapabilities extends Equatable {
|
|||||||
final bool canEdit;
|
final bool canEdit;
|
||||||
final bool canAnnotate;
|
final bool canAnnotate;
|
||||||
final bool isPdf;
|
final bool isPdf;
|
||||||
|
final String mimeType;
|
||||||
|
|
||||||
const DocumentCapabilities({
|
const DocumentCapabilities({
|
||||||
required this.canEdit,
|
required this.canEdit,
|
||||||
required this.canAnnotate,
|
required this.canAnnotate,
|
||||||
required this.isPdf,
|
required this.isPdf,
|
||||||
|
required this.mimeType,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
List<Object?> get props => [canEdit, canAnnotate, isPdf];
|
List<Object?> get props => [canEdit, canAnnotate, isPdf, mimeType];
|
||||||
|
|
||||||
factory DocumentCapabilities.fromJson(Map<String, dynamic> json) {
|
factory DocumentCapabilities.fromJson(Map<String, dynamic> json) {
|
||||||
return DocumentCapabilities(
|
return DocumentCapabilities(
|
||||||
canEdit: json['canEdit'],
|
canEdit: json['canEdit'],
|
||||||
canAnnotate: json['canAnnotate'],
|
canAnnotate: json['canAnnotate'],
|
||||||
isPdf: json['isPdf'],
|
isPdf: json['isPdf'],
|
||||||
|
mimeType: json['mimeType'] ?? 'application/octet-stream',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool get isImage => mimeType.startsWith('image/');
|
||||||
|
bool get isText => mimeType.startsWith('text/');
|
||||||
|
bool get isOffice =>
|
||||||
|
mimeType.contains('word') ||
|
||||||
|
mimeType.contains('spreadsheet') ||
|
||||||
|
mimeType.contains('presentation');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,21 +2,24 @@ import 'package:equatable/equatable.dart';
|
|||||||
|
|
||||||
class EditorSession extends Equatable {
|
class EditorSession extends Equatable {
|
||||||
final Uri editUrl;
|
final Uri editUrl;
|
||||||
|
final String token;
|
||||||
final bool readOnly;
|
final bool readOnly;
|
||||||
final DateTime expiresAt;
|
final DateTime expiresAt;
|
||||||
|
|
||||||
const EditorSession({
|
const EditorSession({
|
||||||
required this.editUrl,
|
required this.editUrl,
|
||||||
|
required this.token,
|
||||||
required this.readOnly,
|
required this.readOnly,
|
||||||
required this.expiresAt,
|
required this.expiresAt,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
List<Object?> get props => [editUrl, readOnly, expiresAt];
|
List<Object?> get props => [editUrl, token, readOnly, expiresAt];
|
||||||
|
|
||||||
factory EditorSession.fromJson(Map<String, dynamic> json) {
|
factory EditorSession.fromJson(Map<String, dynamic> json) {
|
||||||
return EditorSession(
|
return EditorSession(
|
||||||
editUrl: Uri.parse(json['editUrl']),
|
editUrl: Uri.parse(json['editUrl']),
|
||||||
|
token: json['token'] ?? '',
|
||||||
readOnly: json['readOnly'],
|
readOnly: json['readOnly'],
|
||||||
expiresAt: DateTime.parse(json['expiresAt']),
|
expiresAt: DateTime.parse(json['expiresAt']),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,26 +1,34 @@
|
|||||||
import 'package:equatable/equatable.dart';
|
import 'package:equatable/equatable.dart';
|
||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
enum FileType { folder, file }
|
enum FileType { folder, file }
|
||||||
|
|
||||||
class FileItem extends Equatable {
|
class FileItem extends Equatable {
|
||||||
|
final String? id;
|
||||||
final String name;
|
final String name;
|
||||||
final String path;
|
final String path;
|
||||||
final FileType type;
|
final FileType type;
|
||||||
final int size; // in bytes, 0 for folders
|
final int size; // in bytes, 0 for folders
|
||||||
final DateTime lastModified;
|
final DateTime lastModified;
|
||||||
|
final String? localPath; // optional local file path for uploads
|
||||||
|
final Uint8List? bytes; // optional file bytes for web/desktop uploads
|
||||||
|
|
||||||
const FileItem({
|
const FileItem({
|
||||||
|
this.id,
|
||||||
required this.name,
|
required this.name,
|
||||||
required this.path,
|
required this.path,
|
||||||
required this.type,
|
required this.type,
|
||||||
this.size = 0,
|
this.size = 0,
|
||||||
required this.lastModified,
|
required this.lastModified,
|
||||||
|
this.localPath,
|
||||||
|
this.bytes,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
List<Object?> get props => [name, path, type, size, lastModified];
|
List<Object?> get props => [id, name, path, type, size, lastModified];
|
||||||
|
|
||||||
FileItem copyWith({
|
FileItem copyWith({
|
||||||
|
String? id,
|
||||||
String? name,
|
String? name,
|
||||||
String? path,
|
String? path,
|
||||||
FileType? type,
|
FileType? type,
|
||||||
@@ -28,6 +36,7 @@ class FileItem extends Equatable {
|
|||||||
DateTime? lastModified,
|
DateTime? lastModified,
|
||||||
}) {
|
}) {
|
||||||
return FileItem(
|
return FileItem(
|
||||||
|
id: id ?? this.id,
|
||||||
name: name ?? this.name,
|
name: name ?? this.name,
|
||||||
path: path ?? this.path,
|
path: path ?? this.path,
|
||||||
type: type ?? this.type,
|
type: type ?? this.type,
|
||||||
|
|||||||
@@ -1,14 +1,521 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:html' as html;
|
||||||
|
import 'dart:ui_web' as ui;
|
||||||
import '../theme/app_theme.dart';
|
import '../theme/app_theme.dart';
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
import '../blocs/document_viewer/document_viewer_bloc.dart';
|
import '../blocs/document_viewer/document_viewer_bloc.dart';
|
||||||
import '../blocs/document_viewer/document_viewer_event.dart';
|
import '../blocs/document_viewer/document_viewer_event.dart';
|
||||||
import '../blocs/document_viewer/document_viewer_state.dart';
|
import '../blocs/document_viewer/document_viewer_state.dart';
|
||||||
|
import '../blocs/session/session_bloc.dart';
|
||||||
|
import '../blocs/session/session_state.dart';
|
||||||
import '../services/file_service.dart';
|
import '../services/file_service.dart';
|
||||||
import '../injection.dart';
|
import '../injection.dart';
|
||||||
import 'package:syncfusion_flutter_pdfviewer/pdfviewer.dart';
|
import 'package:syncfusion_flutter_pdfviewer/pdfviewer.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
|
import 'package:http/http.dart' as http;
|
||||||
|
|
||||||
|
// Modal version for overlay display
|
||||||
|
class DocumentViewerModal extends StatefulWidget {
|
||||||
|
final String orgId;
|
||||||
|
final String fileId;
|
||||||
|
final VoidCallback onClose;
|
||||||
|
final VoidCallback onEdit;
|
||||||
|
|
||||||
|
const DocumentViewerModal({
|
||||||
|
super.key,
|
||||||
|
required this.orgId,
|
||||||
|
required this.fileId,
|
||||||
|
required this.onClose,
|
||||||
|
required this.onEdit,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<DocumentViewerModal> createState() => _DocumentViewerModalState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _DocumentViewerModalState extends State<DocumentViewerModal> {
|
||||||
|
late DocumentViewerBloc _viewerBloc;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_viewerBloc = DocumentViewerBloc(getIt<FileService>());
|
||||||
|
_viewerBloc.add(DocumentOpened(orgId: widget.orgId, fileId: widget.fileId));
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return BlocProvider.value(
|
||||||
|
value: _viewerBloc,
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
// Custom AppBar
|
||||||
|
Container(
|
||||||
|
height: 56,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppTheme.primaryBackground.withValues(alpha: 0.9),
|
||||||
|
border: Border(
|
||||||
|
bottom: BorderSide(
|
||||||
|
color: AppTheme.accentColor.withValues(alpha: 0.3),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
const SizedBox(width: 16),
|
||||||
|
const Text(
|
||||||
|
'Document Viewer',
|
||||||
|
style: TextStyle(
|
||||||
|
color: AppTheme.primaryText,
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Spacer(),
|
||||||
|
BlocBuilder<DocumentViewerBloc, DocumentViewerState>(
|
||||||
|
builder: (context, state) {
|
||||||
|
if (state is DocumentViewerReady && state.caps.canEdit) {
|
||||||
|
return IconButton(
|
||||||
|
icon: const Icon(
|
||||||
|
Icons.edit,
|
||||||
|
color: AppTheme.primaryText,
|
||||||
|
),
|
||||||
|
onPressed: widget.onEdit,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return const SizedBox.shrink();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.refresh, color: AppTheme.primaryText),
|
||||||
|
splashColor: Colors.transparent,
|
||||||
|
highlightColor: Colors.transparent,
|
||||||
|
onPressed: () {
|
||||||
|
_viewerBloc.add(DocumentReloaded());
|
||||||
|
},
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.close, color: AppTheme.primaryText),
|
||||||
|
splashColor: Colors.transparent,
|
||||||
|
highlightColor: Colors.transparent,
|
||||||
|
onPressed: () {
|
||||||
|
_viewerBloc.add(DocumentClosed());
|
||||||
|
widget.onClose();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// Meta info bar
|
||||||
|
BlocBuilder<DocumentViewerBloc, DocumentViewerState>(
|
||||||
|
builder: (context, state) {
|
||||||
|
if (state is DocumentViewerReady) {
|
||||||
|
return Container(
|
||||||
|
height: 30,
|
||||||
|
alignment: Alignment.centerLeft,
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppTheme.primaryBackground.withValues(alpha: 0.3),
|
||||||
|
),
|
||||||
|
child: const Text(
|
||||||
|
'Last modified: Unknown by Unknown (v1)',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
color: AppTheme.secondaryText,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return const SizedBox.shrink();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
// Document content
|
||||||
|
Expanded(
|
||||||
|
child: BlocBuilder<DocumentViewerBloc, DocumentViewerState>(
|
||||||
|
builder: (context, state) {
|
||||||
|
if (state is DocumentViewerLoading) {
|
||||||
|
return Container(
|
||||||
|
color: AppTheme.primaryBackground,
|
||||||
|
child: const Center(
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
valueColor: AlwaysStoppedAnimation<Color>(
|
||||||
|
AppTheme.accentColor,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (state is DocumentViewerError) {
|
||||||
|
return Center(
|
||||||
|
child: Text(
|
||||||
|
'Error: ${state.message}',
|
||||||
|
style: const TextStyle(color: AppTheme.primaryText),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (state is DocumentViewerSessionExpired) {
|
||||||
|
return Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
const Text(
|
||||||
|
'Your viewing session expired. Click to reopen.',
|
||||||
|
style: TextStyle(color: AppTheme.primaryText),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
ElevatedButton(
|
||||||
|
onPressed: () {
|
||||||
|
_viewerBloc.add(
|
||||||
|
DocumentOpened(
|
||||||
|
orgId: widget.orgId,
|
||||||
|
fileId: widget.fileId,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
child: const Text('Reload'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (state is DocumentViewerReady) {
|
||||||
|
// Handle different file types based on MIME type
|
||||||
|
if (state.caps.isPdf) {
|
||||||
|
// PDF viewer using SfPdfViewer
|
||||||
|
return SfPdfViewer.network(
|
||||||
|
state.viewUrl.toString(),
|
||||||
|
headers: {'Authorization': 'Bearer ${state.token}'},
|
||||||
|
onDocumentLoadFailed: (details) {},
|
||||||
|
onDocumentLoaded: (PdfDocumentLoadedDetails details) {},
|
||||||
|
);
|
||||||
|
} else if (state.caps.isImage) {
|
||||||
|
// Image viewer
|
||||||
|
return Container(
|
||||||
|
color: AppTheme.primaryBackground,
|
||||||
|
child: InteractiveViewer(
|
||||||
|
minScale: 0.5,
|
||||||
|
maxScale: 4.0,
|
||||||
|
child: Image.network(
|
||||||
|
state.viewUrl.toString(),
|
||||||
|
headers: {'Authorization': 'Bearer ${state.token}'},
|
||||||
|
fit: BoxFit.contain,
|
||||||
|
errorBuilder: (context, error, stackTrace) {
|
||||||
|
return Center(
|
||||||
|
child: Text(
|
||||||
|
'Failed to load image',
|
||||||
|
style: TextStyle(color: Colors.red[400]),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} else if (state.caps.isText) {
|
||||||
|
// Text file viewer
|
||||||
|
return FutureBuilder<String>(
|
||||||
|
future: _fetchTextContent(
|
||||||
|
state.viewUrl.toString(),
|
||||||
|
state.token,
|
||||||
|
),
|
||||||
|
builder: (context, snapshot) {
|
||||||
|
if (snapshot.connectionState ==
|
||||||
|
ConnectionState.waiting) {
|
||||||
|
return Center(
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
valueColor: AlwaysStoppedAnimation<Color>(
|
||||||
|
AppTheme.accentColor,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (snapshot.hasError) {
|
||||||
|
return Center(
|
||||||
|
child: Text(
|
||||||
|
'Error loading text: ${snapshot.error}',
|
||||||
|
style: TextStyle(color: Colors.red[400]),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return SingleChildScrollView(
|
||||||
|
child: Container(
|
||||||
|
color: AppTheme.primaryBackground,
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: SelectableText(
|
||||||
|
snapshot.data ?? '',
|
||||||
|
style: TextStyle(
|
||||||
|
color: AppTheme.primaryText,
|
||||||
|
fontFamily: 'Courier New',
|
||||||
|
fontSize: 13,
|
||||||
|
height: 1.5,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} else if (state.caps.isOffice) {
|
||||||
|
// Office document viewer using Collabora Online
|
||||||
|
return _buildCollaboraViewer(
|
||||||
|
state.viewUrl.toString(),
|
||||||
|
state.token,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// Unknown file type
|
||||||
|
return Container(
|
||||||
|
color: AppTheme.primaryBackground,
|
||||||
|
child: Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.description,
|
||||||
|
size: 64,
|
||||||
|
color: AppTheme.accentColor,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text(
|
||||||
|
'File Type Not Supported',
|
||||||
|
style: TextStyle(
|
||||||
|
color: AppTheme.primaryText,
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
'MIME Type: ${state.caps.mimeType}',
|
||||||
|
style: TextStyle(
|
||||||
|
color: AppTheme.secondaryText,
|
||||||
|
fontSize: 12,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return const Center(
|
||||||
|
child: Text(
|
||||||
|
'No document loaded',
|
||||||
|
style: TextStyle(color: AppTheme.primaryText),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<String> _fetchTextContent(String url, String token) async {
|
||||||
|
try {
|
||||||
|
final response = await http
|
||||||
|
.get(Uri.parse(url), headers: {'Authorization': 'Bearer $token'})
|
||||||
|
.timeout(const Duration(seconds: 30));
|
||||||
|
|
||||||
|
if (response.statusCode == 200) {
|
||||||
|
return response.body;
|
||||||
|
} else {
|
||||||
|
throw Exception('Failed to load text: ${response.statusCode}');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
throw Exception('Error fetching text: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildCollaboraViewer(String documentUrl, String token) {
|
||||||
|
// Create WOPI session to get WOPISrc URL
|
||||||
|
return FutureBuilder<WOPISession>(
|
||||||
|
future: _createWOPISession(token),
|
||||||
|
builder: (context, snapshot) {
|
||||||
|
if (snapshot.connectionState == ConnectionState.waiting) {
|
||||||
|
return Container(
|
||||||
|
color: AppTheme.primaryBackground,
|
||||||
|
child: Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
CircularProgressIndicator(
|
||||||
|
valueColor: AlwaysStoppedAnimation<Color>(
|
||||||
|
AppTheme.accentColor,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text(
|
||||||
|
'Loading document in Collabora Online...',
|
||||||
|
style: TextStyle(
|
||||||
|
color: AppTheme.secondaryText,
|
||||||
|
fontSize: 12,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (snapshot.hasError) {
|
||||||
|
return Container(
|
||||||
|
color: AppTheme.primaryBackground,
|
||||||
|
child: Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Icon(Icons.error_outline, size: 64, color: Colors.red[400]),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text(
|
||||||
|
'Failed to load document',
|
||||||
|
style: TextStyle(
|
||||||
|
color: AppTheme.primaryText,
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
'${snapshot.error}',
|
||||||
|
style: TextStyle(
|
||||||
|
color: AppTheme.secondaryText,
|
||||||
|
fontSize: 12,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!snapshot.hasData) {
|
||||||
|
return Container(
|
||||||
|
color: AppTheme.primaryBackground,
|
||||||
|
child: const Center(
|
||||||
|
child: Text(
|
||||||
|
'No session data',
|
||||||
|
style: TextStyle(color: AppTheme.primaryText),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final wopiSession = snapshot.data!;
|
||||||
|
|
||||||
|
// Build Collabora Online viewer URL with WOPISrc
|
||||||
|
// The WOPISrc must be URL-encoded and kept encoded
|
||||||
|
// We use a double-encoding approach: encodeComponent keeps it encoded through iframe.src
|
||||||
|
final baseUrl = 'https://of.b0esche.cloud/loleaflet/dist/loleaflet.html';
|
||||||
|
final collaboraUrl = Uri.parse(baseUrl)
|
||||||
|
.replace(queryParameters: {'WOPISrc': wopiSession.wopisrc})
|
||||||
|
.toString();
|
||||||
|
|
||||||
|
// Use WebView to display Collabora Online
|
||||||
|
return _buildWebView(collaboraUrl);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<WOPISession> _createWOPISession(String token) async {
|
||||||
|
try {
|
||||||
|
// Use default base URL from backend
|
||||||
|
String baseUrl = 'https://go.b0esche.cloud';
|
||||||
|
|
||||||
|
// Determine endpoint based on whether we're in org or user workspace
|
||||||
|
String endpoint;
|
||||||
|
if (widget.orgId.isNotEmpty && widget.orgId != 'personal') {
|
||||||
|
endpoint = '/orgs/${widget.orgId}/files/${widget.fileId}/wopi-session';
|
||||||
|
} else {
|
||||||
|
endpoint = '/user/files/${widget.fileId}/wopi-session';
|
||||||
|
}
|
||||||
|
|
||||||
|
final response = await http
|
||||||
|
.post(
|
||||||
|
Uri.parse('$baseUrl$endpoint'),
|
||||||
|
headers: {
|
||||||
|
'Authorization': 'Bearer $token',
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.timeout(const Duration(seconds: 10));
|
||||||
|
|
||||||
|
if (response.statusCode == 200) {
|
||||||
|
final json = jsonDecode(response.body) as Map<String, dynamic>;
|
||||||
|
return WOPISession(
|
||||||
|
wopisrc: json['wopi_src'] as String,
|
||||||
|
accessToken: json['access_token'] as String,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
throw Exception(
|
||||||
|
'Failed to create WOPI session: ${response.statusCode}',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
throw Exception('Error creating WOPI session: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildCollaboraIframe(String collaboraUrl) {
|
||||||
|
// For Collabora Online, create an iframe that loads the editor directly
|
||||||
|
const String viewType = 'collabora-iframe';
|
||||||
|
|
||||||
|
ui.platformViewRegistry.registerViewFactory(
|
||||||
|
viewType,
|
||||||
|
(int viewId) {
|
||||||
|
// Create the iframe with the properly encoded Collabora URL
|
||||||
|
final iframe = html.IFrameElement()
|
||||||
|
..src = collaboraUrl
|
||||||
|
..style.border = 'none'
|
||||||
|
..style.width = '100%'
|
||||||
|
..style.height = '100%'
|
||||||
|
..style.margin = '0'
|
||||||
|
..style.padding = '0'
|
||||||
|
..setAttribute(
|
||||||
|
'allow',
|
||||||
|
'microphone; camera; usb; autoplay; clipboard-read; clipboard-write',
|
||||||
|
)
|
||||||
|
// Remove allow-same-origin for security, add allow-popups-to-escape-sandbox
|
||||||
|
..setAttribute(
|
||||||
|
'sandbox',
|
||||||
|
'allow-scripts allow-popups allow-forms allow-pointer-lock allow-presentation allow-modals allow-downloads allow-popups-to-escape-sandbox',
|
||||||
|
);
|
||||||
|
|
||||||
|
return iframe;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
return HtmlElementView(viewType: viewType);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildWebView(String url) {
|
||||||
|
// Embed Collabora Online in an iframe for web platform
|
||||||
|
return _buildCollaboraIframe(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_viewerBloc.close();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// WOPI Session model for Collabora Online integration
|
||||||
|
class WOPISession {
|
||||||
|
final String wopisrc;
|
||||||
|
final String accessToken;
|
||||||
|
|
||||||
|
WOPISession({required this.wopisrc, required this.accessToken});
|
||||||
|
|
||||||
|
factory WOPISession.fromJson(Map<String, dynamic> json) {
|
||||||
|
return WOPISession(
|
||||||
|
wopisrc: json['wopi_src'] as String,
|
||||||
|
accessToken: json['access_token'] as String,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Original page version (for routing if needed)
|
||||||
class DocumentViewer extends StatefulWidget {
|
class DocumentViewer extends StatefulWidget {
|
||||||
final String orgId;
|
final String orgId;
|
||||||
final String fileId;
|
final String fileId;
|
||||||
@@ -94,7 +601,16 @@ class _DocumentViewerState extends State<DocumentViewer> {
|
|||||||
body: BlocBuilder<DocumentViewerBloc, DocumentViewerState>(
|
body: BlocBuilder<DocumentViewerBloc, DocumentViewerState>(
|
||||||
builder: (context, state) {
|
builder: (context, state) {
|
||||||
if (state is DocumentViewerLoading) {
|
if (state is DocumentViewerLoading) {
|
||||||
return const Center(child: CircularProgressIndicator());
|
return Container(
|
||||||
|
color: AppTheme.primaryBackground,
|
||||||
|
child: const Center(
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
valueColor: AlwaysStoppedAnimation<Color>(
|
||||||
|
AppTheme.accentColor,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
if (state is DocumentViewerError) {
|
if (state is DocumentViewerError) {
|
||||||
return Center(child: Text('Error: ${state.message}'));
|
return Center(child: Text('Error: ${state.message}'));
|
||||||
@@ -123,22 +639,133 @@ class _DocumentViewerState extends State<DocumentViewer> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (state is DocumentViewerReady) {
|
if (state is DocumentViewerReady) {
|
||||||
|
return BlocBuilder<SessionBloc, SessionState>(
|
||||||
|
builder: (context, sessionState) {
|
||||||
|
String? token;
|
||||||
|
if (sessionState is SessionActive) {
|
||||||
|
token = sessionState.token;
|
||||||
|
}
|
||||||
|
|
||||||
if (state.caps.isPdf) {
|
if (state.caps.isPdf) {
|
||||||
// Use PDF viewer
|
// PDF viewer using SfPdfViewer
|
||||||
return SfPdfViewer.network(state.viewUrl.toString());
|
return SfPdfViewer.network(
|
||||||
} else {
|
state.viewUrl.toString(),
|
||||||
// Placeholder for office docs iframe
|
headers: token != null
|
||||||
|
? {'Authorization': 'Bearer $token'}
|
||||||
|
: {},
|
||||||
|
onDocumentLoadFailed: (details) {},
|
||||||
|
onDocumentLoaded: (PdfDocumentLoadedDetails details) {},
|
||||||
|
);
|
||||||
|
} else if (state.caps.isImage) {
|
||||||
|
// Image viewer
|
||||||
return Container(
|
return Container(
|
||||||
color: AppTheme.secondaryText,
|
color: AppTheme.primaryBackground,
|
||||||
child: Center(
|
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(
|
child: Text(
|
||||||
'Office Document Viewer\n(URL: ${state.viewUrl})',
|
'Failed to load image',
|
||||||
textAlign: TextAlign.center,
|
style: TextStyle(color: Colors.red[400]),
|
||||||
style: const TextStyle(color: AppTheme.primaryText),
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} else if (state.caps.isText) {
|
||||||
|
// Text file viewer
|
||||||
|
return FutureBuilder<String>(
|
||||||
|
future: _fetchTextContent(
|
||||||
|
state.viewUrl.toString(),
|
||||||
|
token ?? '',
|
||||||
|
),
|
||||||
|
builder: (context, snapshot) {
|
||||||
|
if (snapshot.connectionState ==
|
||||||
|
ConnectionState.waiting) {
|
||||||
|
return Center(
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
valueColor: AlwaysStoppedAnimation<Color>(
|
||||||
|
AppTheme.accentColor,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if (snapshot.hasError) {
|
||||||
|
return Center(
|
||||||
|
child: Text(
|
||||||
|
'Error loading text: ${snapshot.error}',
|
||||||
|
style: TextStyle(color: Colors.red[400]),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return SingleChildScrollView(
|
||||||
|
child: Container(
|
||||||
|
color: AppTheme.primaryBackground,
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: SelectableText(
|
||||||
|
snapshot.data ?? '',
|
||||||
|
style: TextStyle(
|
||||||
|
color: AppTheme.primaryText,
|
||||||
|
fontFamily: 'Courier New',
|
||||||
|
fontSize: 13,
|
||||||
|
height: 1.5,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} else if (state.caps.isOffice) {
|
||||||
|
// Office document viewer using Collabora Online
|
||||||
|
return _buildCollaboraViewerPage(
|
||||||
|
state.viewUrl.toString(),
|
||||||
|
token ?? '',
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// Unknown file type
|
||||||
|
return Container(
|
||||||
|
color: AppTheme.primaryBackground,
|
||||||
|
child: Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.description,
|
||||||
|
size: 64,
|
||||||
|
color: AppTheme.accentColor,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text(
|
||||||
|
'File Type Not Supported',
|
||||||
|
style: TextStyle(
|
||||||
|
color: AppTheme.primaryText,
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
'MIME Type: ${state.caps.mimeType}',
|
||||||
|
style: TextStyle(
|
||||||
|
color: AppTheme.secondaryText,
|
||||||
|
fontSize: 12,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return const Center(child: Text('No document loaded'));
|
return const Center(child: Text('No document loaded'));
|
||||||
},
|
},
|
||||||
@@ -147,6 +774,73 @@ class _DocumentViewerState extends State<DocumentViewer> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<String> _fetchTextContent(String url, String token) async {
|
||||||
|
try {
|
||||||
|
final response = await http
|
||||||
|
.get(Uri.parse(url), headers: {'Authorization': 'Bearer $token'})
|
||||||
|
.timeout(const Duration(seconds: 30));
|
||||||
|
|
||||||
|
if (response.statusCode == 200) {
|
||||||
|
return response.body;
|
||||||
|
} else {
|
||||||
|
throw Exception('Failed to load text: ${response.statusCode}');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
throw Exception('Error fetching text: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildCollaboraViewerPage(String documentUrl, String token) {
|
||||||
|
// Build HTML to embed Collabora Online viewer
|
||||||
|
// For now, we'll show the document download option with a link to open in Collabora
|
||||||
|
// A proper implementation would require WOPI protocol support
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
color: AppTheme.primaryBackground,
|
||||||
|
child: Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Icon(Icons.description, size: 64, color: AppTheme.accentColor),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text(
|
||||||
|
'Office Document',
|
||||||
|
style: TextStyle(
|
||||||
|
color: AppTheme.primaryText,
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
'Collabora Online Viewer',
|
||||||
|
style: TextStyle(color: AppTheme.secondaryText, fontSize: 14),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text(
|
||||||
|
'Opening document in Collabora...',
|
||||||
|
style: TextStyle(color: AppTheme.secondaryText, fontSize: 12),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
CircularProgressIndicator(
|
||||||
|
valueColor: AlwaysStoppedAnimation<Color>(AppTheme.accentColor),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
ElevatedButton.icon(
|
||||||
|
icon: const Icon(Icons.download),
|
||||||
|
label: const Text('Download File'),
|
||||||
|
onPressed: () {
|
||||||
|
// Open file download
|
||||||
|
// In a real implementation, you'd use url_launcher
|
||||||
|
// launchUrl(state.viewUrl);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_viewerBloc.close();
|
_viewerBloc.close();
|
||||||
|
|||||||
@@ -8,6 +8,161 @@ import '../services/file_service.dart';
|
|||||||
import '../injection.dart';
|
import '../injection.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
|
// Modal version for overlay display
|
||||||
|
class EditorPageModal extends StatefulWidget {
|
||||||
|
final String orgId;
|
||||||
|
final String fileId;
|
||||||
|
final VoidCallback onClose;
|
||||||
|
|
||||||
|
const EditorPageModal({
|
||||||
|
super.key,
|
||||||
|
required this.orgId,
|
||||||
|
required this.fileId,
|
||||||
|
required this.onClose,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<EditorPageModal> createState() => _EditorPageModalState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _EditorPageModalState extends State<EditorPageModal> {
|
||||||
|
late EditorSessionBloc _editorBloc;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_editorBloc = EditorSessionBloc(getIt<FileService>());
|
||||||
|
_editorBloc.add(
|
||||||
|
EditorSessionStarted(orgId: widget.orgId, fileId: widget.fileId),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return BlocProvider.value(
|
||||||
|
value: _editorBloc,
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
// Custom AppBar
|
||||||
|
Container(
|
||||||
|
height: 56,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppTheme.primaryBackground.withValues(alpha: 0.9),
|
||||||
|
border: Border(
|
||||||
|
bottom: BorderSide(
|
||||||
|
color: AppTheme.accentColor.withValues(alpha: 0.3),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
const SizedBox(width: 16),
|
||||||
|
const Text(
|
||||||
|
'Document Editor',
|
||||||
|
style: TextStyle(
|
||||||
|
color: AppTheme.primaryText,
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Spacer(),
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.close, color: AppTheme.primaryText),
|
||||||
|
onPressed: () {
|
||||||
|
_editorBloc.add(EditorSessionEnded());
|
||||||
|
widget.onClose();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// Editor content
|
||||||
|
Expanded(
|
||||||
|
child: BlocBuilder<EditorSessionBloc, EditorSessionState>(
|
||||||
|
builder: (context, state) {
|
||||||
|
if (state is EditorSessionStarting) {
|
||||||
|
return const Center(child: CircularProgressIndicator());
|
||||||
|
}
|
||||||
|
if (state is EditorSessionFailed) {
|
||||||
|
return Center(
|
||||||
|
child: Text(
|
||||||
|
'Error: ${state.message}',
|
||||||
|
style: const TextStyle(color: AppTheme.primaryText),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (state is EditorSessionActive) {
|
||||||
|
return Container(
|
||||||
|
color: AppTheme.secondaryText,
|
||||||
|
child: Center(
|
||||||
|
child: Text(
|
||||||
|
'Collabora Editor Active\\n(URL: ${state.editUrl})',
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: const TextStyle(color: AppTheme.primaryText),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (state is EditorSessionReadOnly) {
|
||||||
|
return Container(
|
||||||
|
color: AppTheme.secondaryText,
|
||||||
|
child: Center(
|
||||||
|
child: Text(
|
||||||
|
'Read Only Mode\\n(URL: ${state.viewUrl})',
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: const TextStyle(color: AppTheme.primaryText),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (state is EditorSessionExpired) {
|
||||||
|
return Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
const Text(
|
||||||
|
'Editing session expired.',
|
||||||
|
style: TextStyle(color: AppTheme.primaryText),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
ElevatedButton(
|
||||||
|
onPressed: () {
|
||||||
|
_editorBloc.add(
|
||||||
|
EditorSessionStarted(
|
||||||
|
orgId: widget.orgId,
|
||||||
|
fileId: widget.fileId,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
child: const Text('Reopen'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return const Center(
|
||||||
|
child: Text(
|
||||||
|
'No editor session',
|
||||||
|
style: TextStyle(color: AppTheme.primaryText),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_editorBloc.close();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Original page version (for routing if needed)
|
||||||
class EditorPage extends StatefulWidget {
|
class EditorPage extends StatefulWidget {
|
||||||
final String orgId;
|
final String orgId;
|
||||||
final String fileId;
|
final String fileId;
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
|
import 'dart:ui';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
import 'package:file_picker/file_picker.dart' hide FileType;
|
import 'package:file_picker/file_picker.dart' hide FileType;
|
||||||
import 'package:go_router/go_router.dart';
|
|
||||||
import 'package:path/path.dart' as p;
|
import 'package:path/path.dart' as p;
|
||||||
|
import 'dart:html' as html;
|
||||||
import '../blocs/file_browser/file_browser_bloc.dart';
|
import '../blocs/file_browser/file_browser_bloc.dart';
|
||||||
import '../blocs/file_browser/file_browser_event.dart';
|
import '../blocs/file_browser/file_browser_event.dart';
|
||||||
import '../blocs/file_browser/file_browser_state.dart';
|
import '../blocs/file_browser/file_browser_state.dart';
|
||||||
@@ -11,9 +12,14 @@ import '../blocs/permission/permission_event.dart';
|
|||||||
import '../blocs/permission/permission_state.dart';
|
import '../blocs/permission/permission_state.dart';
|
||||||
import '../blocs/upload/upload_bloc.dart';
|
import '../blocs/upload/upload_bloc.dart';
|
||||||
import '../blocs/upload/upload_event.dart';
|
import '../blocs/upload/upload_event.dart';
|
||||||
|
import '../blocs/upload/upload_state.dart';
|
||||||
import '../models/file_item.dart';
|
import '../models/file_item.dart';
|
||||||
import '../theme/app_theme.dart';
|
import '../theme/app_theme.dart';
|
||||||
import '../theme/modern_glass_button.dart';
|
import '../theme/modern_glass_button.dart';
|
||||||
|
import 'document_viewer.dart';
|
||||||
|
import 'editor_page.dart';
|
||||||
|
import '../injection.dart';
|
||||||
|
import '../services/file_service.dart';
|
||||||
|
|
||||||
class FileExplorer extends StatefulWidget {
|
class FileExplorer extends StatefulWidget {
|
||||||
final String orgId;
|
final String orgId;
|
||||||
@@ -55,6 +61,18 @@ class _FileExplorerState extends State<FileExplorer> {
|
|||||||
context.read<PermissionBloc>().add(LoadPermissions(widget.orgId));
|
context.read<PermissionBloc>().add(LoadPermissions(widget.orgId));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didUpdateWidget(covariant FileExplorer oldWidget) {
|
||||||
|
super.didUpdateWidget(oldWidget);
|
||||||
|
if (oldWidget.orgId != widget.orgId) {
|
||||||
|
// Reset and reload when switching between Personal and Org workspaces
|
||||||
|
final bloc = context.read<FileBrowserBloc>();
|
||||||
|
|
||||||
|
bloc.add(ResetFileBrowser(widget.orgId));
|
||||||
|
bloc.add(LoadDirectory(orgId: widget.orgId, path: '/'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Future<String?> _showCreateFolderDialog(BuildContext context) async {
|
Future<String?> _showCreateFolderDialog(BuildContext context) async {
|
||||||
final TextEditingController controller = TextEditingController();
|
final TextEditingController controller = TextEditingController();
|
||||||
return showDialog<String>(
|
return showDialog<String>(
|
||||||
@@ -124,10 +142,13 @@ class _FileExplorerState extends State<FileExplorer> {
|
|||||||
onPressed: () {
|
onPressed: () {
|
||||||
final folderName = controller.text.trim();
|
final folderName = controller.text.trim();
|
||||||
if (folderName.isNotEmpty) {
|
if (folderName.isNotEmpty) {
|
||||||
final nameWithSlash = folderName.startsWith('/')
|
// Strip any leading/trailing slashes so we only send the bare folder name
|
||||||
? folderName
|
final sanitized = folderName
|
||||||
: '/$folderName';
|
.replaceAll(RegExp(r'^/+'), '')
|
||||||
Navigator.of(context).pop(nameWithSlash);
|
.replaceAll(RegExp(r'/+$'), '');
|
||||||
|
if (sanitized.isNotEmpty) {
|
||||||
|
Navigator.of(context).pop(sanitized);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
child: const Text(
|
child: const Text(
|
||||||
@@ -229,7 +250,7 @@ class _FileExplorerState extends State<FileExplorer> {
|
|||||||
if (newName.isNotEmpty && newName != file.name) {
|
if (newName.isNotEmpty && newName != file.name) {
|
||||||
context.read<FileBrowserBloc>().add(
|
context.read<FileBrowserBloc>().add(
|
||||||
RenameFile(
|
RenameFile(
|
||||||
orgId: 'org1',
|
orgId: widget.orgId,
|
||||||
path: file.path,
|
path: file.path,
|
||||||
newName: newName,
|
newName: newName,
|
||||||
),
|
),
|
||||||
@@ -258,10 +279,34 @@ class _FileExplorerState extends State<FileExplorer> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _downloadFile(FileItem file) {
|
void _downloadFile(FileItem file) async {
|
||||||
|
try {
|
||||||
|
final fileService = getIt<FileService>();
|
||||||
|
final downloadUrl = await fileService.getDownloadUrl(
|
||||||
|
widget.orgId,
|
||||||
|
file.path,
|
||||||
|
);
|
||||||
|
|
||||||
|
// For web, use the download URL with the ApiClient base URL (from DI)
|
||||||
|
final fullUrl = '${fileService.baseUrl}$downloadUrl';
|
||||||
|
|
||||||
|
// Trigger download via anchor element
|
||||||
|
html.AnchorElement(href: fullUrl)
|
||||||
|
..setAttribute('download', file.name)
|
||||||
|
..click();
|
||||||
|
|
||||||
|
if (context.mounted) {
|
||||||
ScaffoldMessenger.of(
|
ScaffoldMessenger.of(
|
||||||
context,
|
context,
|
||||||
).showSnackBar(SnackBar(content: Text('Download ${file.name}')));
|
).showSnackBar(SnackBar(content: Text('Downloading ${file.name}')));
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (context.mounted) {
|
||||||
|
ScaffoldMessenger.of(
|
||||||
|
context,
|
||||||
|
).showSnackBar(SnackBar(content: Text('Download failed: $e')));
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void _sendFile(FileItem file) {
|
void _sendFile(FileItem file) {
|
||||||
@@ -331,7 +376,7 @@ class _FileExplorerState extends State<FileExplorer> {
|
|||||||
);
|
);
|
||||||
if (confirmed == true) {
|
if (confirmed == true) {
|
||||||
context.read<FileBrowserBloc>().add(
|
context.read<FileBrowserBloc>().add(
|
||||||
DeleteFile(orgId: 'org1', path: file.path),
|
DeleteFile(orgId: widget.orgId, path: file.path),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -530,10 +575,13 @@ class _FileExplorerState extends State<FileExplorer> {
|
|||||||
if (file.type == FileType.folder) {
|
if (file.type == FileType.folder) {
|
||||||
context.read<FileBrowserBloc>().add(NavigateToFolder(file.path));
|
context.read<FileBrowserBloc>().add(NavigateToFolder(file.path));
|
||||||
} else {
|
} else {
|
||||||
final fileId = file.path.startsWith('/')
|
if (file.id == null || file.id!.isEmpty) {
|
||||||
? file.path.substring(1)
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
: file.path;
|
const SnackBar(content: Text('Error: File ID is missing')),
|
||||||
context.go('/viewer/${widget.orgId}/$fileId');
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_showDocumentViewer(widget.orgId, file.id!);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
child: Container(
|
child: Container(
|
||||||
@@ -632,7 +680,30 @@ class _FileExplorerState extends State<FileExplorer> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return BlocBuilder<FileBrowserBloc, FileBrowserState>(
|
return BlocListener<UploadBloc, UploadState>(
|
||||||
|
listener: (context, uploadState) {
|
||||||
|
if (uploadState is UploadInProgress) {
|
||||||
|
// Show error if any upload failed
|
||||||
|
for (final upload in uploadState.uploads) {
|
||||||
|
if (upload.error != null && upload.error!.isNotEmpty) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text('Upload failed: ${upload.error}')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
final hasCompleted = uploadState.uploads.any((u) => u.isCompleted);
|
||||||
|
if (hasCompleted) {
|
||||||
|
final fbState = context.read<FileBrowserBloc>().state;
|
||||||
|
String currentPath = '/';
|
||||||
|
if (fbState is DirectoryLoaded) currentPath = fbState.currentPath;
|
||||||
|
if (fbState is DirectoryEmpty) currentPath = fbState.currentPath;
|
||||||
|
context.read<FileBrowserBloc>().add(
|
||||||
|
LoadDirectory(orgId: widget.orgId, path: currentPath),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
child: BlocBuilder<FileBrowserBloc, FileBrowserState>(
|
||||||
builder: (context, state) {
|
builder: (context, state) {
|
||||||
if (state is DirectoryLoading) {
|
if (state is DirectoryLoading) {
|
||||||
return Center(
|
return Center(
|
||||||
@@ -676,24 +747,27 @@ class _FileExplorerState extends State<FileExplorer> {
|
|||||||
ModernGlassButton(
|
ModernGlassButton(
|
||||||
onPressed: () async {
|
onPressed: () async {
|
||||||
final result = await FilePicker.platform
|
final result = await FilePicker.platform
|
||||||
.pickFiles();
|
.pickFiles(withData: true);
|
||||||
if (result != null && result.files.isNotEmpty) {
|
if (result != null && result.files.isNotEmpty) {
|
||||||
final files = result.files
|
final files = result.files
|
||||||
.map(
|
.map(
|
||||||
(file) => FileItem(
|
(file) => FileItem(
|
||||||
name: file.name,
|
name: file.name,
|
||||||
path: '/${file.name}',
|
// Parent path only; server uses filename from multipart
|
||||||
|
path: state.currentPath,
|
||||||
type: FileType.file,
|
type: FileType.file,
|
||||||
size: file.size,
|
size: file.size,
|
||||||
lastModified: DateTime.now(),
|
lastModified: DateTime.now(),
|
||||||
|
localPath: file.path,
|
||||||
|
bytes: file.bytes,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.toList();
|
.toList();
|
||||||
context.read<UploadBloc>().add(
|
context.read<UploadBloc>().add(
|
||||||
StartUpload(
|
StartUpload(
|
||||||
files: files,
|
files: files,
|
||||||
targetPath: '/',
|
targetPath: state.currentPath,
|
||||||
orgId: 'org1',
|
orgId: widget.orgId,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -709,13 +783,13 @@ class _FileExplorerState extends State<FileExplorer> {
|
|||||||
const SizedBox(width: 16),
|
const SizedBox(width: 16),
|
||||||
ModernGlassButton(
|
ModernGlassButton(
|
||||||
onPressed: () async {
|
onPressed: () async {
|
||||||
final folderName = await _showCreateFolderDialog(
|
final folderName =
|
||||||
context,
|
await _showCreateFolderDialog(context);
|
||||||
);
|
if (folderName != null &&
|
||||||
if (folderName != null && folderName.isNotEmpty) {
|
folderName.isNotEmpty) {
|
||||||
context.read<FileBrowserBloc>().add(
|
context.read<FileBrowserBloc>().add(
|
||||||
CreateFolder(
|
CreateFolder(
|
||||||
orgId: 'org1',
|
orgId: widget.orgId,
|
||||||
parentPath: '/',
|
parentPath: '/',
|
||||||
folderName: folderName,
|
folderName: folderName,
|
||||||
),
|
),
|
||||||
@@ -736,6 +810,10 @@ class _FileExplorerState extends State<FileExplorer> {
|
|||||||
return const SizedBox.shrink();
|
return const SizedBox.shrink();
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
// Only show back button and "Empty Folder" text if not at root
|
||||||
|
if (state.currentPath != '/')
|
||||||
|
Column(
|
||||||
|
children: [
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
@@ -747,9 +825,14 @@ class _FileExplorerState extends State<FileExplorer> {
|
|||||||
splashColor: Colors.transparent,
|
splashColor: Colors.transparent,
|
||||||
highlightColor: Colors.transparent,
|
highlightColor: Colors.transparent,
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
final parentPath = _getParentPath(state.currentPath);
|
final parentPath = _getParentPath(
|
||||||
|
state.currentPath,
|
||||||
|
);
|
||||||
context.read<FileBrowserBloc>().add(
|
context.read<FileBrowserBloc>().add(
|
||||||
LoadDirectory(orgId: 'org1', path: parentPath),
|
LoadDirectory(
|
||||||
|
orgId: widget.orgId,
|
||||||
|
path: parentPath,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
@@ -761,6 +844,8 @@ class _FileExplorerState extends State<FileExplorer> {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -798,7 +883,10 @@ class _FileExplorerState extends State<FileExplorer> {
|
|||||||
state.currentPath,
|
state.currentPath,
|
||||||
);
|
);
|
||||||
context.read<FileBrowserBloc>().add(
|
context.read<FileBrowserBloc>().add(
|
||||||
LoadDirectory(orgId: 'org1', path: parentPath),
|
LoadDirectory(
|
||||||
|
orgId: widget.orgId,
|
||||||
|
path: parentPath,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
@@ -839,24 +927,27 @@ class _FileExplorerState extends State<FileExplorer> {
|
|||||||
ModernGlassButton(
|
ModernGlassButton(
|
||||||
onPressed: () async {
|
onPressed: () async {
|
||||||
final result = await FilePicker.platform
|
final result = await FilePicker.platform
|
||||||
.pickFiles();
|
.pickFiles(withData: true);
|
||||||
if (result != null && result.files.isNotEmpty) {
|
if (result != null && result.files.isNotEmpty) {
|
||||||
final files = result.files
|
final files = result.files
|
||||||
.map(
|
.map(
|
||||||
(file) => FileItem(
|
(file) => FileItem(
|
||||||
name: file.name,
|
name: file.name,
|
||||||
path: '/${file.name}',
|
// Parent path only; server uses filename from multipart
|
||||||
|
path: state.currentPath,
|
||||||
type: FileType.file,
|
type: FileType.file,
|
||||||
size: file.size,
|
size: file.size,
|
||||||
lastModified: DateTime.now(),
|
lastModified: DateTime.now(),
|
||||||
|
localPath: file.path,
|
||||||
|
bytes: file.bytes,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.toList();
|
.toList();
|
||||||
context.read<UploadBloc>().add(
|
context.read<UploadBloc>().add(
|
||||||
StartUpload(
|
StartUpload(
|
||||||
files: files,
|
files: files,
|
||||||
targetPath: '/',
|
targetPath: state.currentPath,
|
||||||
orgId: 'org1',
|
orgId: widget.orgId,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -872,13 +963,13 @@ class _FileExplorerState extends State<FileExplorer> {
|
|||||||
const SizedBox(width: 16),
|
const SizedBox(width: 16),
|
||||||
ModernGlassButton(
|
ModernGlassButton(
|
||||||
onPressed: () async {
|
onPressed: () async {
|
||||||
final folderName = await _showCreateFolderDialog(
|
final folderName =
|
||||||
context,
|
await _showCreateFolderDialog(context);
|
||||||
);
|
if (folderName != null &&
|
||||||
if (folderName != null && folderName.isNotEmpty) {
|
folderName.isNotEmpty) {
|
||||||
context.read<FileBrowserBloc>().add(
|
context.read<FileBrowserBloc>().add(
|
||||||
CreateFolder(
|
CreateFolder(
|
||||||
orgId: 'org1',
|
orgId: widget.orgId,
|
||||||
parentPath: state.currentPath,
|
parentPath: state.currentPath,
|
||||||
folderName: folderName,
|
folderName: folderName,
|
||||||
),
|
),
|
||||||
@@ -933,7 +1024,7 @@ class _FileExplorerState extends State<FileExplorer> {
|
|||||||
onAcceptWithDetails: (draggedFile) {
|
onAcceptWithDetails: (draggedFile) {
|
||||||
context.read<FileBrowserBloc>().add(
|
context.read<FileBrowserBloc>().add(
|
||||||
MoveFile(
|
MoveFile(
|
||||||
orgId: 'org1',
|
orgId: widget.orgId,
|
||||||
sourcePath: draggedFile.data.path,
|
sourcePath: draggedFile.data.path,
|
||||||
targetPath: file.path,
|
targetPath: file.path,
|
||||||
),
|
),
|
||||||
@@ -1014,6 +1105,103 @@ class _FileExplorerState extends State<FileExplorer> {
|
|||||||
|
|
||||||
return const SizedBox.shrink();
|
return const SizedBox.shrink();
|
||||||
},
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showDocumentViewer(String orgId, String fileId) {
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
barrierDismissible: false,
|
||||||
|
barrierColor: Colors.transparent,
|
||||||
|
builder: (BuildContext context) {
|
||||||
|
return BackdropFilter(
|
||||||
|
filter: ImageFilter.blur(sigmaX: 10, sigmaY: 10),
|
||||||
|
child: Dialog(
|
||||||
|
backgroundColor: Colors.transparent,
|
||||||
|
insetPadding: const EdgeInsets.all(16),
|
||||||
|
child: Container(
|
||||||
|
constraints: BoxConstraints(
|
||||||
|
maxWidth: MediaQuery.of(context).size.width * 0.9,
|
||||||
|
maxHeight: MediaQuery.of(context).size.height * 0.9,
|
||||||
|
),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppTheme.primaryBackground,
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
border: Border.all(
|
||||||
|
color: AppTheme.accentColor.withValues(alpha: 0.3),
|
||||||
|
width: 2,
|
||||||
|
),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.black.withValues(alpha: 0.5),
|
||||||
|
blurRadius: 20,
|
||||||
|
spreadRadius: 5,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(14),
|
||||||
|
child: DocumentViewerModal(
|
||||||
|
orgId: orgId,
|
||||||
|
fileId: fileId,
|
||||||
|
onEdit: () {
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
_showDocumentEditor(orgId, fileId);
|
||||||
|
},
|
||||||
|
onClose: () => Navigator.of(context).pop(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showDocumentEditor(String orgId, String fileId) {
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
barrierDismissible: false,
|
||||||
|
barrierColor: Colors.transparent,
|
||||||
|
builder: (BuildContext context) {
|
||||||
|
return BackdropFilter(
|
||||||
|
filter: ImageFilter.blur(sigmaX: 10, sigmaY: 10),
|
||||||
|
child: Dialog(
|
||||||
|
backgroundColor: Colors.transparent,
|
||||||
|
insetPadding: const EdgeInsets.all(16),
|
||||||
|
child: Container(
|
||||||
|
constraints: BoxConstraints(
|
||||||
|
maxWidth: MediaQuery.of(context).size.width * 0.9,
|
||||||
|
maxHeight: MediaQuery.of(context).size.height * 0.9,
|
||||||
|
),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppTheme.primaryBackground,
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
border: Border.all(
|
||||||
|
color: AppTheme.accentColor.withValues(alpha: 0.3),
|
||||||
|
width: 2,
|
||||||
|
),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.black.withValues(alpha: 0.5),
|
||||||
|
blurRadius: 20,
|
||||||
|
spreadRadius: 5,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(14),
|
||||||
|
child: EditorPageModal(
|
||||||
|
orgId: orgId,
|
||||||
|
fileId: fileId,
|
||||||
|
onClose: () => Navigator.of(context).pop(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,10 +8,16 @@ import '../blocs/organization/organization_bloc.dart';
|
|||||||
import '../blocs/organization/organization_event.dart';
|
import '../blocs/organization/organization_event.dart';
|
||||||
import '../blocs/file_browser/file_browser_bloc.dart';
|
import '../blocs/file_browser/file_browser_bloc.dart';
|
||||||
import '../blocs/file_browser/file_browser_event.dart';
|
import '../blocs/file_browser/file_browser_event.dart';
|
||||||
|
import '../blocs/permission/permission_bloc.dart';
|
||||||
|
import '../blocs/upload/upload_bloc.dart';
|
||||||
|
import '../repositories/file_repository.dart';
|
||||||
|
import '../services/file_service.dart';
|
||||||
|
import '../services/org_api.dart';
|
||||||
import '../theme/app_theme.dart';
|
import '../theme/app_theme.dart';
|
||||||
import '../theme/modern_glass_button.dart';
|
import '../theme/modern_glass_button.dart';
|
||||||
import 'login_form.dart' show LoginForm;
|
import 'login_form.dart' show LoginForm;
|
||||||
import 'file_explorer.dart';
|
import 'file_explorer.dart';
|
||||||
|
import '../injection.dart';
|
||||||
|
|
||||||
class HomePage extends StatefulWidget {
|
class HomePage extends StatefulWidget {
|
||||||
const HomePage({super.key});
|
const HomePage({super.key});
|
||||||
@@ -24,6 +30,13 @@ class _HomePageState extends State<HomePage> with TickerProviderStateMixin {
|
|||||||
late String _selectedTab = 'Drive';
|
late String _selectedTab = 'Drive';
|
||||||
late AnimationController _animationController;
|
late AnimationController _animationController;
|
||||||
bool _isSignupMode = false;
|
bool _isSignupMode = false;
|
||||||
|
bool _usePasswordMode = false;
|
||||||
|
|
||||||
|
// Shared blocs for the page lifecycle
|
||||||
|
late final PermissionBloc _permissionBloc;
|
||||||
|
late final FileBrowserBloc _fileBrowserBloc;
|
||||||
|
late final UploadBloc _uploadBloc;
|
||||||
|
late final OrganizationBloc _organizationBloc;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@@ -32,11 +45,25 @@ class _HomePageState extends State<HomePage> with TickerProviderStateMixin {
|
|||||||
duration: const Duration(milliseconds: 400),
|
duration: const Duration(milliseconds: 400),
|
||||||
vsync: this,
|
vsync: this,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
_permissionBloc = PermissionBloc();
|
||||||
|
_fileBrowserBloc = FileBrowserBloc(getIt<FileService>());
|
||||||
|
_uploadBloc = UploadBloc(getIt<FileRepository>());
|
||||||
|
_organizationBloc = OrganizationBloc(
|
||||||
|
_permissionBloc,
|
||||||
|
_fileBrowserBloc,
|
||||||
|
_uploadBloc,
|
||||||
|
getIt<OrgApi>(),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_animationController.dispose();
|
_animationController.dispose();
|
||||||
|
_organizationBloc.close();
|
||||||
|
_uploadBloc.close();
|
||||||
|
_fileBrowserBloc.close();
|
||||||
|
_permissionBloc.close();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -50,11 +77,15 @@ class _HomePageState extends State<HomePage> with TickerProviderStateMixin {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _setPasswordMode(bool usePassword) {
|
||||||
|
setState(() => _usePasswordMode = usePassword);
|
||||||
|
}
|
||||||
|
|
||||||
void _showCreateOrgDialog(BuildContext context) {
|
void _showCreateOrgDialog(BuildContext context) {
|
||||||
final controller = TextEditingController();
|
final controller = TextEditingController();
|
||||||
showDialog(
|
showDialog(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (context) => Dialog(
|
builder: (dialogContext) => Dialog(
|
||||||
backgroundColor: Colors.transparent,
|
backgroundColor: Colors.transparent,
|
||||||
child: SizedBox(
|
child: SizedBox(
|
||||||
width: 400,
|
width: 400,
|
||||||
@@ -80,6 +111,7 @@ class _HomePageState extends State<HomePage> with TickerProviderStateMixin {
|
|||||||
TextField(
|
TextField(
|
||||||
cursorColor: AppTheme.accentColor,
|
cursorColor: AppTheme.accentColor,
|
||||||
controller: controller,
|
controller: controller,
|
||||||
|
autofocus: true,
|
||||||
style: TextStyle(color: AppTheme.primaryText),
|
style: TextStyle(color: AppTheme.primaryText),
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
labelText: 'Organization Name',
|
labelText: 'Organization Name',
|
||||||
@@ -95,13 +127,23 @@ class _HomePageState extends State<HomePage> with TickerProviderStateMixin {
|
|||||||
borderSide: BorderSide(color: AppTheme.accentColor),
|
borderSide: BorderSide(color: AppTheme.accentColor),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
onSubmitted: (value) {
|
||||||
|
final name = controller.text.trim();
|
||||||
|
if (name.isNotEmpty) {
|
||||||
|
// Use the parent context, not the dialog context
|
||||||
|
BlocProvider.of<OrganizationBloc>(
|
||||||
|
context,
|
||||||
|
).add(CreateOrganization(name));
|
||||||
|
Navigator.of(dialogContext).pop();
|
||||||
|
}
|
||||||
|
},
|
||||||
),
|
),
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
Row(
|
Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.end,
|
mainAxisAlignment: MainAxisAlignment.end,
|
||||||
children: [
|
children: [
|
||||||
ModernGlassButton(
|
ModernGlassButton(
|
||||||
onPressed: () => Navigator.of(context).pop(),
|
onPressed: () => Navigator.of(dialogContext).pop(),
|
||||||
child: const Text('Cancel'),
|
child: const Text('Cancel'),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 16),
|
const SizedBox(width: 16),
|
||||||
@@ -109,10 +151,11 @@ class _HomePageState extends State<HomePage> with TickerProviderStateMixin {
|
|||||||
onPressed: () {
|
onPressed: () {
|
||||||
final name = controller.text.trim();
|
final name = controller.text.trim();
|
||||||
if (name.isNotEmpty) {
|
if (name.isNotEmpty) {
|
||||||
context.read<OrganizationBloc>().add(
|
// Use the parent context, not the dialog context
|
||||||
CreateOrganization(name),
|
BlocProvider.of<OrganizationBloc>(
|
||||||
);
|
context,
|
||||||
Navigator.of(context).pop();
|
).add(CreateOrganization(name));
|
||||||
|
Navigator.of(dialogContext).pop();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
child: const Text('Create'),
|
child: const Text('Create'),
|
||||||
@@ -132,37 +175,58 @@ class _HomePageState extends State<HomePage> with TickerProviderStateMixin {
|
|||||||
Widget _buildOrgRow(BuildContext context) {
|
Widget _buildOrgRow(BuildContext context) {
|
||||||
return BlocBuilder<OrganizationBloc, OrganizationState>(
|
return BlocBuilder<OrganizationBloc, OrganizationState>(
|
||||||
builder: (context, state) {
|
builder: (context, state) {
|
||||||
|
List<Organization> orgs = [];
|
||||||
|
Organization? selectedOrg;
|
||||||
|
bool isLoading = false;
|
||||||
|
|
||||||
if (state is OrganizationLoaded) {
|
if (state is OrganizationLoaded) {
|
||||||
final orgs = state.organizations;
|
orgs = state.organizations;
|
||||||
|
selectedOrg = state.selectedOrg;
|
||||||
|
isLoading = state.isLoading;
|
||||||
|
} else if (state is OrganizationLoading) {
|
||||||
|
isLoading = true;
|
||||||
|
}
|
||||||
|
|
||||||
return Column(
|
return Column(
|
||||||
children: [
|
children: [
|
||||||
Row(
|
Row(
|
||||||
children: [
|
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(
|
...orgs.map(
|
||||||
(org) => Row(
|
(org) => Row(
|
||||||
children: [
|
children: [
|
||||||
_buildOrgButton(
|
_buildOrgButton(org, org.id == selectedOrg?.id, () {
|
||||||
org,
|
|
||||||
org.id == state.selectedOrg?.id,
|
|
||||||
() {
|
|
||||||
context.read<OrganizationBloc>().add(
|
context.read<OrganizationBloc>().add(
|
||||||
SelectOrganization(org.id),
|
SelectOrganization(org.id),
|
||||||
);
|
);
|
||||||
},
|
}),
|
||||||
),
|
|
||||||
const SizedBox(width: 16),
|
const SizedBox(width: 16),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
if (isLoading)
|
||||||
|
SizedBox(
|
||||||
|
width: 20,
|
||||||
|
height: 20,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
strokeWidth: 2,
|
||||||
|
valueColor: AlwaysStoppedAnimation<Color>(
|
||||||
|
AppTheme.accentColor,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
else
|
||||||
_buildAddButton(() => _showCreateOrgDialog(context)),
|
_buildAddButton(() => _showCreateOrgDialog(context)),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
const Divider(height: 1),
|
const Divider(height: 1),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
} else {
|
|
||||||
return const SizedBox.shrink();
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -171,6 +235,7 @@ class _HomePageState extends State<HomePage> with TickerProviderStateMixin {
|
|||||||
final highlightColor = const Color.fromARGB(255, 100, 200, 255);
|
final highlightColor = const Color.fromARGB(255, 100, 200, 255);
|
||||||
final defaultColor = AppTheme.secondaryText;
|
final defaultColor = AppTheme.secondaryText;
|
||||||
return TextButton(
|
return TextButton(
|
||||||
|
style: ButtonStyle(splashFactory: NoSplash.splashFactory),
|
||||||
onPressed: onTap,
|
onPressed: onTap,
|
||||||
child: Text(
|
child: Text(
|
||||||
org.name,
|
org.name,
|
||||||
@@ -182,18 +247,44 @@ class _HomePageState extends State<HomePage> with TickerProviderStateMixin {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Widget _buildPersonalButton(bool selected, VoidCallback onTap) {
|
||||||
|
final highlightColor = const Color.fromARGB(255, 100, 200, 255);
|
||||||
|
final defaultColor = AppTheme.secondaryText;
|
||||||
|
return TextButton(
|
||||||
|
style: ButtonStyle(splashFactory: NoSplash.splashFactory),
|
||||||
|
onPressed: onTap,
|
||||||
|
child: Text(
|
||||||
|
'Personal',
|
||||||
|
style: TextStyle(
|
||||||
|
color: selected ? highlightColor : defaultColor,
|
||||||
|
fontWeight: selected ? FontWeight.bold : FontWeight.normal,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Widget _buildAddButton(VoidCallback onTap) {
|
Widget _buildAddButton(VoidCallback onTap) {
|
||||||
final defaultColor = AppTheme.secondaryText;
|
final defaultColor = AppTheme.secondaryText;
|
||||||
return TextButton(
|
return TextButton(
|
||||||
|
style: ButtonStyle(splashFactory: NoSplash.splashFactory),
|
||||||
onPressed: onTap,
|
onPressed: onTap,
|
||||||
child: Text('+ Add Organization', style: TextStyle(color: defaultColor)),
|
child: Text('+ Add Organization', style: TextStyle(color: defaultColor)),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildDrive(OrganizationState state) {
|
Widget _buildDrive(OrganizationState state, AuthState authState) {
|
||||||
return state is OrganizationLoaded && state.selectedOrg != null
|
String orgId;
|
||||||
? FileExplorer(orgId: state.selectedOrg!.id)
|
if (state is OrganizationLoaded && state.selectedOrg != null) {
|
||||||
: const FileExplorer(orgId: 'org1');
|
// Show selected organization's files
|
||||||
|
orgId = state.selectedOrg!.id;
|
||||||
|
} else if (authState is AuthAuthenticated) {
|
||||||
|
// Show personal workspace - use empty string to trigger /user/files endpoint
|
||||||
|
orgId = '';
|
||||||
|
} else {
|
||||||
|
orgId = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
return FileExplorer(orgId: orgId);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildNavButton(String label, IconData icon, {bool isAvatar = false}) {
|
Widget _buildNavButton(String label, IconData icon, {bool isAvatar = false}) {
|
||||||
@@ -238,7 +329,14 @@ class _HomePageState extends State<HomePage> with TickerProviderStateMixin {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
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,
|
backgroundColor: AppTheme.primaryBackground,
|
||||||
body: Stack(
|
body: Stack(
|
||||||
children: [
|
children: [
|
||||||
@@ -252,7 +350,11 @@ class _HomePageState extends State<HomePage> with TickerProviderStateMixin {
|
|||||||
_animationController.reverse();
|
_animationController.reverse();
|
||||||
}
|
}
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.only(top: 42.0),
|
padding: EdgeInsets.only(
|
||||||
|
top: MediaQuery.of(context).size.width < 600
|
||||||
|
? 96.0
|
||||||
|
: 78.0,
|
||||||
|
),
|
||||||
child: AnimatedContainer(
|
child: AnimatedContainer(
|
||||||
duration: const Duration(milliseconds: 350),
|
duration: const Duration(milliseconds: 350),
|
||||||
curve: Curves.easeInOut,
|
curve: Curves.easeInOut,
|
||||||
@@ -261,7 +363,9 @@ class _HomePageState extends State<HomePage> with TickerProviderStateMixin {
|
|||||||
: 340,
|
: 340,
|
||||||
height: isLoggedIn
|
height: isLoggedIn
|
||||||
? MediaQuery.of(context).size.height * 0.9
|
? MediaQuery.of(context).size.height * 0.9
|
||||||
: (_isSignupMode ? 400 : 280),
|
: (_isSignupMode
|
||||||
|
? 400
|
||||||
|
: (_usePasswordMode ? 350 : 280)),
|
||||||
child: ClipRRect(
|
child: ClipRRect(
|
||||||
borderRadius: BorderRadius.circular(16),
|
borderRadius: BorderRadius.circular(16),
|
||||||
child: BackdropFilter(
|
child: BackdropFilter(
|
||||||
@@ -276,12 +380,24 @@ class _HomePageState extends State<HomePage> with TickerProviderStateMixin {
|
|||||||
OrganizationState
|
OrganizationState
|
||||||
>(
|
>(
|
||||||
listener: (context, state) {
|
listener: (context, state) {
|
||||||
if (state is OrganizationLoaded &&
|
if (state is OrganizationLoaded) {
|
||||||
state.selectedOrg != null) {
|
// Show errors if present
|
||||||
// Reload file browser when org changes
|
if (state.error != null &&
|
||||||
|
state.error!.isNotEmpty) {
|
||||||
|
ScaffoldMessenger.of(
|
||||||
|
context,
|
||||||
|
).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(state.error!),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
final orgId =
|
||||||
|
state.selectedOrg?.id ?? '';
|
||||||
|
// Reload file browser when org changes (or when falling back to personal workspace)
|
||||||
context.read<FileBrowserBloc>().add(
|
context.read<FileBrowserBloc>().add(
|
||||||
LoadDirectory(
|
LoadDirectory(
|
||||||
orgId: state.selectedOrg!.id,
|
orgId: orgId,
|
||||||
path: '/',
|
path: '/',
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -296,7 +412,11 @@ class _HomePageState extends State<HomePage> with TickerProviderStateMixin {
|
|||||||
if (orgState
|
if (orgState
|
||||||
is OrganizationInitial) {
|
is OrganizationInitial) {
|
||||||
WidgetsBinding.instance
|
WidgetsBinding.instance
|
||||||
.addPostFrameCallback((_) {
|
.addPostFrameCallback((
|
||||||
|
_,
|
||||||
|
) {
|
||||||
|
// Kick off org fetch and immediately show personal workspace
|
||||||
|
// while org data loads.
|
||||||
context
|
context
|
||||||
.read<
|
.read<
|
||||||
OrganizationBloc
|
OrganizationBloc
|
||||||
@@ -304,15 +424,26 @@ class _HomePageState extends State<HomePage> with TickerProviderStateMixin {
|
|||||||
.add(
|
.add(
|
||||||
LoadOrganizations(),
|
LoadOrganizations(),
|
||||||
);
|
);
|
||||||
|
context
|
||||||
|
.read<
|
||||||
|
FileBrowserBloc
|
||||||
|
>()
|
||||||
|
.add(
|
||||||
|
const LoadDirectory(
|
||||||
|
orgId: '',
|
||||||
|
path: '/',
|
||||||
|
),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return Column(
|
return Column(
|
||||||
children: [
|
children: [
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 16),
|
||||||
_buildOrgRow(context),
|
_buildOrgRow(context),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: _buildDrive(
|
child: _buildDrive(
|
||||||
orgState,
|
orgState,
|
||||||
|
state,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -322,6 +453,7 @@ class _HomePageState extends State<HomePage> with TickerProviderStateMixin {
|
|||||||
)
|
)
|
||||||
: LoginForm(
|
: LoginForm(
|
||||||
onSignupModeChanged: _setSignupMode,
|
onSignupModeChanged: _setSignupMode,
|
||||||
|
onPasswordModeChanged: _setPasswordMode,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
// Top-left radial glow - primary accent light
|
// Top-left radial glow - primary accent light
|
||||||
@@ -438,7 +570,9 @@ class _HomePageState extends State<HomePage> with TickerProviderStateMixin {
|
|||||||
gradient: LinearGradient(
|
gradient: LinearGradient(
|
||||||
colors: [
|
colors: [
|
||||||
Colors.white.withValues(alpha: 0),
|
Colors.white.withValues(alpha: 0),
|
||||||
Colors.white.withValues(alpha: 0.06),
|
Colors.white.withValues(
|
||||||
|
alpha: 0.06,
|
||||||
|
),
|
||||||
Colors.white.withValues(alpha: 0),
|
Colors.white.withValues(alpha: 0),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -461,21 +595,27 @@ class _HomePageState extends State<HomePage> with TickerProviderStateMixin {
|
|||||||
left: 0,
|
left: 0,
|
||||||
right: 0,
|
right: 0,
|
||||||
child: Center(
|
child: Center(
|
||||||
child: Text(
|
child: Builder(
|
||||||
|
builder: (context) {
|
||||||
|
final screenWidth = MediaQuery.of(context).size.width;
|
||||||
|
final fontSize = screenWidth < 600 ? 24.0 : 48.0;
|
||||||
|
return Text(
|
||||||
'b0esche.cloud',
|
'b0esche.cloud',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontFamily: 'PixelatedElegance',
|
fontFamily: 'PixelatedElegance',
|
||||||
fontSize: 48,
|
fontSize: fontSize,
|
||||||
color: AppTheme.primaryText,
|
color: AppTheme.primaryText,
|
||||||
decoration: TextDecoration.underline,
|
decoration: TextDecoration.underline,
|
||||||
decorationColor: AppTheme.primaryText,
|
decorationColor: AppTheme.primaryText,
|
||||||
fontFeatures: const [FontFeature.slashedZero()],
|
fontFeatures: const [FontFeature.slashedZero()],
|
||||||
),
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Positioned(
|
Positioned(
|
||||||
top: 10,
|
top: MediaQuery.of(context).size.width < 600 ? 40 : 10,
|
||||||
right: 20,
|
right: 20,
|
||||||
child: BlocBuilder<AuthBloc, AuthState>(
|
child: BlocBuilder<AuthBloc, AuthState>(
|
||||||
builder: (context, state) {
|
builder: (context, state) {
|
||||||
@@ -498,7 +638,11 @@ class _HomePageState extends State<HomePage> with TickerProviderStateMixin {
|
|||||||
const SizedBox(width: 16),
|
const SizedBox(width: 16),
|
||||||
_buildNavButton('Add', Icons.add),
|
_buildNavButton('Add', Icons.add),
|
||||||
const SizedBox(width: 16),
|
const SizedBox(width: 16),
|
||||||
_buildNavButton('Profile', Icons.person, isAvatar: true),
|
_buildNavButton(
|
||||||
|
'Profile',
|
||||||
|
Icons.person,
|
||||||
|
isAvatar: true,
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -507,6 +651,7 @@ class _HomePageState extends State<HomePage> with TickerProviderStateMixin {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
import 'dart:math';
|
import 'dart:math';
|
||||||
|
import 'dart:convert';
|
||||||
import '../blocs/auth/auth_bloc.dart';
|
import '../blocs/auth/auth_bloc.dart';
|
||||||
import '../blocs/auth/auth_event.dart';
|
import '../blocs/auth/auth_event.dart';
|
||||||
import '../blocs/auth/auth_state.dart';
|
import '../blocs/auth/auth_state.dart';
|
||||||
@@ -12,8 +13,13 @@ import '../theme/modern_glass_button.dart';
|
|||||||
|
|
||||||
class LoginForm extends StatefulWidget {
|
class LoginForm extends StatefulWidget {
|
||||||
final ValueChanged<bool>? onSignupModeChanged;
|
final ValueChanged<bool>? onSignupModeChanged;
|
||||||
|
final ValueChanged<bool>? onPasswordModeChanged;
|
||||||
|
|
||||||
const LoginForm({super.key, this.onSignupModeChanged});
|
const LoginForm({
|
||||||
|
super.key,
|
||||||
|
this.onSignupModeChanged,
|
||||||
|
this.onPasswordModeChanged,
|
||||||
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<LoginForm> createState() => _LoginFormState();
|
State<LoginForm> createState() => _LoginFormState();
|
||||||
@@ -34,10 +40,10 @@ class _LoginFormState extends State<LoginForm> {
|
|||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
String _generateRandomHex(int bytes) {
|
String _generateRandomBase64(int bytes) {
|
||||||
final random = Random();
|
final random = Random();
|
||||||
final values = List<int>.generate(bytes, (i) => random.nextInt(256));
|
final values = List<int>.generate(bytes, (i) => random.nextInt(256));
|
||||||
return values.map((v) => v.toRadixString(16).padLeft(2, '0')).join();
|
return base64.encode(values);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _handleAuthentication(
|
Future<void> _handleAuthentication(
|
||||||
@@ -47,7 +53,7 @@ class _LoginFormState extends State<LoginForm> {
|
|||||||
try {
|
try {
|
||||||
final credentialId = state.credentialIds.isNotEmpty
|
final credentialId = state.credentialIds.isNotEmpty
|
||||||
? state.credentialIds.first
|
? state.credentialIds.first
|
||||||
: _generateRandomHex(64);
|
: _generateRandomBase64(64);
|
||||||
|
|
||||||
if (context.mounted) {
|
if (context.mounted) {
|
||||||
context.read<AuthBloc>().add(
|
context.read<AuthBloc>().add(
|
||||||
@@ -55,10 +61,10 @@ class _LoginFormState extends State<LoginForm> {
|
|||||||
username: _usernameController.text,
|
username: _usernameController.text,
|
||||||
challenge: state.challenge,
|
challenge: state.challenge,
|
||||||
credentialId: credentialId,
|
credentialId: credentialId,
|
||||||
authenticatorData: _generateRandomHex(37),
|
authenticatorData: _generateRandomBase64(37),
|
||||||
clientDataJSON:
|
clientDataJSON:
|
||||||
'{"type":"webauthn.get","challenge":"${state.challenge}","origin":"https://b0esche.cloud"}',
|
'{"type":"webauthn.get","challenge":"${state.challenge}","origin":"https://b0esche.cloud"}',
|
||||||
signature: _generateRandomHex(128),
|
signature: _generateRandomBase64(128),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -76,8 +82,8 @@ class _LoginFormState extends State<LoginForm> {
|
|||||||
RegistrationChallengeReceived state,
|
RegistrationChallengeReceived state,
|
||||||
) async {
|
) async {
|
||||||
try {
|
try {
|
||||||
final credentialId = _generateRandomHex(64);
|
final credentialId = _generateRandomBase64(64);
|
||||||
final publicKey = _generateRandomHex(91);
|
final publicKey = _generateRandomBase64(91);
|
||||||
|
|
||||||
if (context.mounted) {
|
if (context.mounted) {
|
||||||
context.read<AuthBloc>().add(
|
context.read<AuthBloc>().add(
|
||||||
@@ -88,7 +94,7 @@ class _LoginFormState extends State<LoginForm> {
|
|||||||
publicKey: publicKey,
|
publicKey: publicKey,
|
||||||
clientDataJSON:
|
clientDataJSON:
|
||||||
'{"type":"webauthn.create","challenge":"${state.challenge}","origin":"https://b0esche.cloud"}',
|
'{"type":"webauthn.create","challenge":"${state.challenge}","origin":"https://b0esche.cloud"}',
|
||||||
attestationObject: _generateRandomHex(128),
|
attestationObject: _generateRandomBase64(128),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -133,13 +139,16 @@ class _LoginFormState extends State<LoginForm> {
|
|||||||
child: Center(
|
child: Center(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(16.0),
|
padding: const EdgeInsets.all(16.0),
|
||||||
|
child: AnimatedSize(
|
||||||
|
duration: const Duration(milliseconds: 300),
|
||||||
|
curve: Curves.easeInOut,
|
||||||
child: AnimatedSwitcher(
|
child: AnimatedSwitcher(
|
||||||
duration: const Duration(milliseconds: 400),
|
duration: const Duration(milliseconds: 400),
|
||||||
transitionBuilder: (child, animation) {
|
transitionBuilder: (child, animation) {
|
||||||
return FadeTransition(opacity: animation, child: child);
|
return FadeTransition(opacity: animation, child: child);
|
||||||
},
|
},
|
||||||
child: SingleChildScrollView(
|
child: SingleChildScrollView(
|
||||||
key: ValueKey<bool>(_isSignup),
|
key: ValueKey('${_isSignup}_$_usePasskey'),
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
crossAxisAlignment: CrossAxisAlignment.center,
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
@@ -154,7 +163,9 @@ class _LoginFormState extends State<LoginForm> {
|
|||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
Container(
|
Container(
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: AppTheme.primaryBackground.withValues(alpha: 0.5),
|
color: AppTheme.primaryBackground.withValues(
|
||||||
|
alpha: 0.5,
|
||||||
|
),
|
||||||
borderRadius: BorderRadius.circular(16),
|
borderRadius: BorderRadius.circular(16),
|
||||||
border: Border.all(
|
border: Border.all(
|
||||||
color: AppTheme.accentColor.withValues(alpha: 0.3),
|
color: AppTheme.accentColor.withValues(alpha: 0.3),
|
||||||
@@ -347,8 +358,12 @@ class _LoginFormState extends State<LoginForm> {
|
|||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
GestureDetector(
|
GestureDetector(
|
||||||
onTap: () =>
|
onTap: () {
|
||||||
setState(() => _usePasskey = !_usePasskey),
|
setState(() => _usePasskey = !_usePasskey);
|
||||||
|
widget.onPasswordModeChanged?.call(
|
||||||
|
!_usePasskey,
|
||||||
|
);
|
||||||
|
},
|
||||||
child: Text(
|
child: Text(
|
||||||
_usePasskey ? 'use password' : 'use passkey',
|
_usePasskey ? 'use password' : 'use passkey',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
@@ -392,6 +407,7 @@ class _LoginFormState extends State<LoginForm> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
import 'dart:math';
|
import 'dart:math';
|
||||||
|
import 'dart:convert';
|
||||||
import '../blocs/auth/auth_bloc.dart';
|
import '../blocs/auth/auth_bloc.dart';
|
||||||
import '../blocs/auth/auth_event.dart';
|
import '../blocs/auth/auth_event.dart';
|
||||||
import '../blocs/auth/auth_state.dart';
|
import '../blocs/auth/auth_state.dart';
|
||||||
@@ -28,10 +29,10 @@ class _SignupFormState extends State<SignupForm> {
|
|||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
String _generateRandomHex(int bytes) {
|
String _generateRandomBase64(int bytes) {
|
||||||
final random = Random();
|
final random = Random();
|
||||||
final values = List<int>.generate(bytes, (i) => random.nextInt(256));
|
final values = List<int>.generate(bytes, (i) => random.nextInt(256));
|
||||||
return values.map((v) => v.toRadixString(16).padLeft(2, '0')).join();
|
return base64.encode(values);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _handleRegistration(
|
Future<void> _handleRegistration(
|
||||||
@@ -41,8 +42,8 @@ class _SignupFormState extends State<SignupForm> {
|
|||||||
try {
|
try {
|
||||||
// Simulate WebAuthn registration by generating fake credential data
|
// Simulate WebAuthn registration by generating fake credential data
|
||||||
// In a real implementation, this would call native WebAuthn APIs
|
// In a real implementation, this would call native WebAuthn APIs
|
||||||
final credentialId = _generateRandomHex(64);
|
final credentialId = _generateRandomBase64(64);
|
||||||
final publicKey = _generateRandomHex(91); // EC2 public key size
|
final publicKey = _generateRandomBase64(91); // EC2 public key size
|
||||||
|
|
||||||
if (context.mounted) {
|
if (context.mounted) {
|
||||||
context.read<AuthBloc>().add(
|
context.read<AuthBloc>().add(
|
||||||
@@ -53,7 +54,7 @@ class _SignupFormState extends State<SignupForm> {
|
|||||||
publicKey: publicKey,
|
publicKey: publicKey,
|
||||||
clientDataJSON:
|
clientDataJSON:
|
||||||
'{"type":"webauthn.create","challenge":"${state.challenge}","origin":"https://b0esche.cloud"}',
|
'{"type":"webauthn.create","challenge":"${state.challenge}","origin":"https://b0esche.cloud"}',
|
||||||
attestationObject: _generateRandomHex(128),
|
attestationObject: _generateRandomBase64(128),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -6,9 +6,11 @@ import '../models/document_capabilities.dart';
|
|||||||
import '../models/api_error.dart';
|
import '../models/api_error.dart';
|
||||||
import '../repositories/file_repository.dart';
|
import '../repositories/file_repository.dart';
|
||||||
import 'package:path/path.dart' as p;
|
import 'package:path/path.dart' as p;
|
||||||
|
import 'package:uuid/uuid.dart';
|
||||||
|
|
||||||
class MockFileRepository implements FileRepository {
|
class MockFileRepository implements FileRepository {
|
||||||
final Map<String, List<FileItem>> _orgFiles = {};
|
final Map<String, List<FileItem>> _orgFiles = {};
|
||||||
|
final _uuid = const Uuid();
|
||||||
|
|
||||||
List<FileItem> _getFilesForOrg(String orgId) {
|
List<FileItem> _getFilesForOrg(String orgId) {
|
||||||
if (!_orgFiles.containsKey(orgId)) {
|
if (!_orgFiles.containsKey(orgId)) {
|
||||||
@@ -16,18 +18,21 @@ class MockFileRepository implements FileRepository {
|
|||||||
if (orgId == 'org1') {
|
if (orgId == 'org1') {
|
||||||
_orgFiles[orgId] = [
|
_orgFiles[orgId] = [
|
||||||
FileItem(
|
FileItem(
|
||||||
|
id: _uuid.v4(),
|
||||||
name: 'Personal Documents',
|
name: 'Personal Documents',
|
||||||
path: '/Personal Documents',
|
path: '/Personal Documents',
|
||||||
type: FileType.folder,
|
type: FileType.folder,
|
||||||
lastModified: DateTime.now(),
|
lastModified: DateTime.now(),
|
||||||
),
|
),
|
||||||
FileItem(
|
FileItem(
|
||||||
|
id: _uuid.v4(),
|
||||||
name: 'Photos',
|
name: 'Photos',
|
||||||
path: '/Photos',
|
path: '/Photos',
|
||||||
type: FileType.folder,
|
type: FileType.folder,
|
||||||
lastModified: DateTime.now(),
|
lastModified: DateTime.now(),
|
||||||
),
|
),
|
||||||
FileItem(
|
FileItem(
|
||||||
|
id: _uuid.v4(),
|
||||||
name: 'resume.pdf',
|
name: 'resume.pdf',
|
||||||
path: '/resume.pdf',
|
path: '/resume.pdf',
|
||||||
type: FileType.file,
|
type: FileType.file,
|
||||||
@@ -35,6 +40,7 @@ class MockFileRepository implements FileRepository {
|
|||||||
lastModified: DateTime.now(),
|
lastModified: DateTime.now(),
|
||||||
),
|
),
|
||||||
FileItem(
|
FileItem(
|
||||||
|
id: _uuid.v4(),
|
||||||
name: 'notes.txt',
|
name: 'notes.txt',
|
||||||
path: '/notes.txt',
|
path: '/notes.txt',
|
||||||
type: FileType.file,
|
type: FileType.file,
|
||||||
@@ -45,12 +51,14 @@ class MockFileRepository implements FileRepository {
|
|||||||
} else if (orgId == 'org2') {
|
} else if (orgId == 'org2') {
|
||||||
_orgFiles[orgId] = [
|
_orgFiles[orgId] = [
|
||||||
FileItem(
|
FileItem(
|
||||||
|
id: _uuid.v4(),
|
||||||
name: 'Company Reports',
|
name: 'Company Reports',
|
||||||
path: '/Company Reports',
|
path: '/Company Reports',
|
||||||
type: FileType.folder,
|
type: FileType.folder,
|
||||||
lastModified: DateTime.now(),
|
lastModified: DateTime.now(),
|
||||||
),
|
),
|
||||||
FileItem(
|
FileItem(
|
||||||
|
id: _uuid.v4(),
|
||||||
name: 'annual_report.pdf',
|
name: 'annual_report.pdf',
|
||||||
path: '/annual_report.pdf',
|
path: '/annual_report.pdf',
|
||||||
type: FileType.file,
|
type: FileType.file,
|
||||||
@@ -58,6 +66,7 @@ class MockFileRepository implements FileRepository {
|
|||||||
lastModified: DateTime.now(),
|
lastModified: DateTime.now(),
|
||||||
),
|
),
|
||||||
FileItem(
|
FileItem(
|
||||||
|
id: _uuid.v4(),
|
||||||
name: 'presentation.pptx',
|
name: 'presentation.pptx',
|
||||||
path: '/presentation.pptx',
|
path: '/presentation.pptx',
|
||||||
type: FileType.file,
|
type: FileType.file,
|
||||||
@@ -68,12 +77,14 @@ class MockFileRepository implements FileRepository {
|
|||||||
} else if (orgId == 'org3') {
|
} else if (orgId == 'org3') {
|
||||||
_orgFiles[orgId] = [
|
_orgFiles[orgId] = [
|
||||||
FileItem(
|
FileItem(
|
||||||
|
id: _uuid.v4(),
|
||||||
name: 'Project Code',
|
name: 'Project Code',
|
||||||
path: '/Project Code',
|
path: '/Project Code',
|
||||||
type: FileType.folder,
|
type: FileType.folder,
|
||||||
lastModified: DateTime.now(),
|
lastModified: DateTime.now(),
|
||||||
),
|
),
|
||||||
FileItem(
|
FileItem(
|
||||||
|
id: _uuid.v4(),
|
||||||
name: 'side_project.dart',
|
name: 'side_project.dart',
|
||||||
path: '/side_project.dart',
|
path: '/side_project.dart',
|
||||||
type: FileType.file,
|
type: FileType.file,
|
||||||
@@ -126,6 +137,7 @@ class MockFileRepository implements FileRepository {
|
|||||||
final expiresAt = DateTime.now().add(const Duration(minutes: 30));
|
final expiresAt = DateTime.now().add(const Duration(minutes: 30));
|
||||||
return EditorSession(
|
return EditorSession(
|
||||||
editUrl: editUrl,
|
editUrl: editUrl,
|
||||||
|
token: 'mock-editor-token',
|
||||||
readOnly: !isEditable,
|
readOnly: !isEditable,
|
||||||
expiresAt: expiresAt,
|
expiresAt: expiresAt,
|
||||||
);
|
);
|
||||||
@@ -153,6 +165,7 @@ class MockFileRepository implements FileRepository {
|
|||||||
final files = _getFilesForOrg(orgId);
|
final files = _getFilesForOrg(orgId);
|
||||||
files.add(
|
files.add(
|
||||||
FileItem(
|
FileItem(
|
||||||
|
id: _uuid.v4(),
|
||||||
name: normalizedName,
|
name: normalizedName,
|
||||||
path: newPath,
|
path: newPath,
|
||||||
type: FileType.folder,
|
type: FileType.folder,
|
||||||
@@ -175,6 +188,7 @@ class MockFileRepository implements FileRepository {
|
|||||||
final newName = file.path.split('/').last;
|
final newName = file.path.split('/').last;
|
||||||
final newPath = targetPath == '/' ? '/$newName' : '$targetPath/$newName';
|
final newPath = targetPath == '/' ? '/$newName' : '$targetPath/$newName';
|
||||||
files[fileIndex] = FileItem(
|
files[fileIndex] = FileItem(
|
||||||
|
id: file.id,
|
||||||
name: file.name,
|
name: file.name,
|
||||||
path: newPath,
|
path: newPath,
|
||||||
type: file.type,
|
type: file.type,
|
||||||
@@ -194,6 +208,7 @@ class MockFileRepository implements FileRepository {
|
|||||||
final parentPath = p.dirname(path);
|
final parentPath = p.dirname(path);
|
||||||
final newPath = parentPath == '.' ? '/$newName' : '$parentPath/$newName';
|
final newPath = parentPath == '.' ? '/$newName' : '$parentPath/$newName';
|
||||||
files[fileIndex] = FileItem(
|
files[fileIndex] = FileItem(
|
||||||
|
id: file.id,
|
||||||
name: newName,
|
name: newName,
|
||||||
path: newPath,
|
path: newPath,
|
||||||
type: file.type,
|
type: file.type,
|
||||||
@@ -216,7 +231,16 @@ class MockFileRepository implements FileRepository {
|
|||||||
Future<void> uploadFile(String orgId, FileItem file) async {
|
Future<void> uploadFile(String orgId, FileItem file) async {
|
||||||
await Future.delayed(const Duration(seconds: 1));
|
await Future.delayed(const Duration(seconds: 1));
|
||||||
final files = _getFilesForOrg(orgId);
|
final files = _getFilesForOrg(orgId);
|
||||||
files.add(file);
|
files.add(
|
||||||
|
FileItem(
|
||||||
|
id: _uuid.v4(),
|
||||||
|
name: file.name,
|
||||||
|
path: file.path,
|
||||||
|
type: file.type,
|
||||||
|
size: file.size,
|
||||||
|
lastModified: file.lastModified,
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -241,6 +265,7 @@ class MockFileRepository implements FileRepository {
|
|||||||
canEdit: !isPdf && (fileId.endsWith('.docx') || fileId.endsWith('.xlsx')),
|
canEdit: !isPdf && (fileId.endsWith('.docx') || fileId.endsWith('.xlsx')),
|
||||||
canAnnotate: isPdf,
|
canAnnotate: isPdf,
|
||||||
isPdf: isPdf,
|
isPdf: isPdf,
|
||||||
|
mimeType: isPdf ? 'application/pdf' : 'application/octet-stream',
|
||||||
);
|
);
|
||||||
// Mock URL
|
// Mock URL
|
||||||
final viewUrl = Uri.parse(
|
final viewUrl = Uri.parse(
|
||||||
|
|||||||
@@ -13,7 +13,9 @@ class ApiClient {
|
|||||||
BaseOptions(
|
BaseOptions(
|
||||||
baseUrl: baseUrl,
|
baseUrl: baseUrl,
|
||||||
connectTimeout: const Duration(seconds: 10),
|
connectTimeout: const Duration(seconds: 10),
|
||||||
receiveTimeout: const Duration(seconds: 10),
|
receiveTimeout: const Duration(
|
||||||
|
seconds: 60,
|
||||||
|
), // Increased for file uploads and org operations
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -29,30 +31,22 @@ class ApiClient {
|
|||||||
},
|
},
|
||||||
onError: (error, handler) async {
|
onError: (error, handler) async {
|
||||||
if (error.response?.statusCode == 401) {
|
if (error.response?.statusCode == 401) {
|
||||||
// Try refresh
|
final path = error.requestOptions.path;
|
||||||
final refreshSuccess = await _tryRefreshToken();
|
// Do not expire session for auth endpoints; show inline error instead
|
||||||
if (refreshSuccess) {
|
final isAuthEndpoint = path.startsWith('/auth/');
|
||||||
// Retry the request
|
if (!isAuthEndpoint) {
|
||||||
final token = _getCurrentToken();
|
// Session expired, trigger logout
|
||||||
if (token != null) {
|
|
||||||
error.requestOptions.headers['Authorization'] = 'Bearer $token';
|
|
||||||
try {
|
|
||||||
final response = await _dio.fetch(error.requestOptions);
|
|
||||||
return handler.resolve(response);
|
|
||||||
} catch (e) {
|
|
||||||
// If retry fails, proceed to error
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// If refresh failed, logout
|
|
||||||
_sessionBloc.add(SessionExpired());
|
_sessionBloc.add(SessionExpired());
|
||||||
}
|
}
|
||||||
|
}
|
||||||
return handler.next(error);
|
return handler.next(error);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
String get baseUrl => _dio.options.baseUrl;
|
||||||
|
|
||||||
String? _getCurrentToken() {
|
String? _getCurrentToken() {
|
||||||
// Get from SessionBloc state
|
// Get from SessionBloc state
|
||||||
final state = _sessionBloc.state;
|
final state = _sessionBloc.state;
|
||||||
@@ -62,20 +56,6 @@ class ApiClient {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<bool> _tryRefreshToken() async {
|
|
||||||
try {
|
|
||||||
final response = await _dio.post('/auth/refresh');
|
|
||||||
if (response.statusCode == 200) {
|
|
||||||
final newToken = response.data['token'];
|
|
||||||
_sessionBloc.add(SessionRefreshed(newToken));
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
// Refresh failed
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<T> get<T>(
|
Future<T> get<T>(
|
||||||
String path, {
|
String path, {
|
||||||
Map<String, dynamic>? queryParameters,
|
Map<String, dynamic>? queryParameters,
|
||||||
@@ -96,6 +76,7 @@ class ApiClient {
|
|||||||
}) async {
|
}) async {
|
||||||
try {
|
try {
|
||||||
final response = await _dio.post(path, data: data);
|
final response = await _dio.post(path, data: data);
|
||||||
|
|
||||||
return fromJson(response.data);
|
return fromJson(response.data);
|
||||||
} on DioException catch (e) {
|
} on DioException catch (e) {
|
||||||
throw _handleError(e);
|
throw _handleError(e);
|
||||||
@@ -131,8 +112,17 @@ class ApiClient {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
String code = data?['code'] ?? 'UNKNOWN';
|
// Only try to extract code/message if data is a Map
|
||||||
String message = data?['message'] ?? 'Unknown error';
|
String code = 'UNKNOWN';
|
||||||
|
String message = 'Unknown error';
|
||||||
|
|
||||||
|
if (data is Map<String, dynamic>) {
|
||||||
|
code = data['code'] ?? 'UNKNOWN';
|
||||||
|
message = data['message'] ?? 'Unknown error';
|
||||||
|
} else if (data != null) {
|
||||||
|
message = data.toString();
|
||||||
|
}
|
||||||
|
|
||||||
return ApiError(code: code, message: message, status: status);
|
return ApiError(code: code, message: message, status: status);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,19 +3,40 @@ import '../models/viewer_session.dart';
|
|||||||
import '../models/editor_session.dart';
|
import '../models/editor_session.dart';
|
||||||
import '../models/annotation.dart';
|
import '../models/annotation.dart';
|
||||||
import 'api_client.dart';
|
import 'api_client.dart';
|
||||||
|
import 'package:dio/dio.dart';
|
||||||
|
|
||||||
class FileService {
|
class FileService {
|
||||||
final ApiClient _apiClient;
|
final ApiClient _apiClient;
|
||||||
|
|
||||||
FileService(this._apiClient);
|
FileService(this._apiClient);
|
||||||
|
|
||||||
|
String get baseUrl => _apiClient.baseUrl;
|
||||||
|
|
||||||
Future<List<FileItem>> getFiles(String orgId, String path) async {
|
Future<List<FileItem>> getFiles(String orgId, String path) async {
|
||||||
if (path.isEmpty) {
|
if (path.isEmpty) {
|
||||||
throw Exception('Path cannot be empty');
|
throw Exception('Path cannot be empty');
|
||||||
}
|
}
|
||||||
|
final pathParam = {'path': path};
|
||||||
|
if (orgId.isEmpty) {
|
||||||
|
return await _apiClient.getList(
|
||||||
|
'/user/files',
|
||||||
|
queryParameters: pathParam,
|
||||||
|
fromJson: (data) => FileItem(
|
||||||
|
id: data['id'],
|
||||||
|
name: data['name'],
|
||||||
|
path: data['path'],
|
||||||
|
type: data['type'] == 'file' ? FileType.file : FileType.folder,
|
||||||
|
size: data['size'],
|
||||||
|
lastModified: DateTime.parse(data['lastModified']),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return await _apiClient.getList(
|
return await _apiClient.getList(
|
||||||
'/orgs/$orgId/files',
|
'/orgs/$orgId/files',
|
||||||
|
queryParameters: pathParam,
|
||||||
fromJson: (data) => FileItem(
|
fromJson: (data) => FileItem(
|
||||||
|
id: data['id'],
|
||||||
name: data['name'],
|
name: data['name'],
|
||||||
path: data['path'],
|
path: data['path'],
|
||||||
type: data['type'] == 'file' ? FileType.file : FileType.folder,
|
type: data['type'] == 'file' ? FileType.file : FileType.folder,
|
||||||
@@ -30,11 +51,67 @@ class FileService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> uploadFile(String orgId, FileItem file) async {
|
Future<void> uploadFile(String orgId, FileItem file) async {
|
||||||
throw UnimplementedError();
|
// If bytes or localPath available, send multipart upload with field 'file'
|
||||||
|
final Map<String, dynamic> fields = {'path': file.path};
|
||||||
|
FormData formData;
|
||||||
|
|
||||||
|
if (file.bytes != null) {
|
||||||
|
formData = FormData.fromMap({
|
||||||
|
...fields,
|
||||||
|
'file': MultipartFile.fromBytes(file.bytes!, filename: file.name),
|
||||||
|
});
|
||||||
|
} else if (file.localPath != null) {
|
||||||
|
formData = FormData.fromMap({
|
||||||
|
...fields,
|
||||||
|
'file': MultipartFile.fromFile(file.localPath!, filename: file.name),
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Fallback to metadata-only create (folders or client that can't send file content)
|
||||||
|
final data = {
|
||||||
|
'name': file.name,
|
||||||
|
'path': file.path,
|
||||||
|
'type': file.type == FileType.file ? 'file' : 'folder',
|
||||||
|
'size': file.size,
|
||||||
|
};
|
||||||
|
if (orgId.isEmpty) {
|
||||||
|
await _apiClient.post('/user/files', data: data, fromJson: (d) => null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await _apiClient.post(
|
||||||
|
'/orgs/$orgId/files',
|
||||||
|
data: data,
|
||||||
|
fromJson: (d) => null,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final endpoint = orgId.isEmpty ? '/user/files' : '/orgs/$orgId/files';
|
||||||
|
await _apiClient.post(endpoint, data: formData, fromJson: (d) => null);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> deleteFile(String orgId, String path) async {
|
Future<void> deleteFile(String orgId, String path) async {
|
||||||
throw UnimplementedError();
|
final data = {'path': path};
|
||||||
|
if (orgId.isEmpty) {
|
||||||
|
await _apiClient.post(
|
||||||
|
'/user/files/delete',
|
||||||
|
data: data,
|
||||||
|
fromJson: (d) => null,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await _apiClient.post(
|
||||||
|
'/orgs/$orgId/files/delete',
|
||||||
|
data: data,
|
||||||
|
fromJson: (d) => null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<String> getDownloadUrl(String orgId, String path) async {
|
||||||
|
// Return the download URL for the file
|
||||||
|
if (orgId.isEmpty) {
|
||||||
|
return '/user/files/download?path=${Uri.encodeComponent(path)}';
|
||||||
|
}
|
||||||
|
return '/orgs/$orgId/files/download?path=${Uri.encodeComponent(path)}';
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> createFolder(
|
Future<void> createFolder(
|
||||||
@@ -42,7 +119,41 @@ class FileService {
|
|||||||
String parentPath,
|
String parentPath,
|
||||||
String folderName,
|
String folderName,
|
||||||
) async {
|
) async {
|
||||||
throw UnimplementedError();
|
// Normalize folder name to avoid accidental leading slashes creating double-slash paths
|
||||||
|
final normalizedName = folderName
|
||||||
|
.replaceAll(RegExp(r'^/+'), '')
|
||||||
|
.replaceAll(RegExp(r'/+$'), '');
|
||||||
|
if (normalizedName.isEmpty) {
|
||||||
|
throw Exception('Folder name cannot be empty');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Construct proper path: /parent/folder or /folder for root
|
||||||
|
String path;
|
||||||
|
if (parentPath == '/') {
|
||||||
|
path = '/$normalizedName';
|
||||||
|
} else {
|
||||||
|
// Ensure parentPath doesn't end with / before appending
|
||||||
|
final cleanParent = parentPath.endsWith('/')
|
||||||
|
? parentPath.substring(0, parentPath.length - 1)
|
||||||
|
: parentPath;
|
||||||
|
path = '$cleanParent/$normalizedName';
|
||||||
|
}
|
||||||
|
|
||||||
|
final data = {
|
||||||
|
'name': normalizedName,
|
||||||
|
'path': path,
|
||||||
|
'type': 'folder',
|
||||||
|
'size': 0,
|
||||||
|
};
|
||||||
|
if (orgId.isEmpty) {
|
||||||
|
await _apiClient.post('/user/files', data: data, fromJson: (d) => null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await _apiClient.post(
|
||||||
|
'/orgs/$orgId/files',
|
||||||
|
data: data,
|
||||||
|
fromJson: (d) => null,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> moveFile(
|
Future<void> moveFile(
|
||||||
@@ -50,7 +161,14 @@ class FileService {
|
|||||||
String sourcePath,
|
String sourcePath,
|
||||||
String targetPath,
|
String targetPath,
|
||||||
) async {
|
) async {
|
||||||
throw UnimplementedError();
|
final endpoint = orgId.isEmpty
|
||||||
|
? '/user/files/move'
|
||||||
|
: '/orgs/$orgId/files/move';
|
||||||
|
await _apiClient.post(
|
||||||
|
endpoint,
|
||||||
|
data: {'sourcePath': sourcePath, 'targetPath': targetPath},
|
||||||
|
fromJson: (d) => null,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> renameFile(String orgId, String path, String newName) async {
|
Future<void> renameFile(String orgId, String path, String newName) async {
|
||||||
@@ -65,11 +183,14 @@ class FileService {
|
|||||||
String orgId,
|
String orgId,
|
||||||
String fileId,
|
String fileId,
|
||||||
) async {
|
) async {
|
||||||
if (orgId.isEmpty || fileId.isEmpty) {
|
if (fileId.isEmpty) {
|
||||||
throw Exception('OrgId and fileId cannot be empty');
|
throw Exception('fileId cannot be empty');
|
||||||
}
|
}
|
||||||
|
final path = orgId.isEmpty
|
||||||
|
? '/user/files/$fileId/view'
|
||||||
|
: '/orgs/$orgId/files/$fileId/view';
|
||||||
return await _apiClient.get(
|
return await _apiClient.get(
|
||||||
'/orgs/$orgId/files/$fileId/view',
|
path,
|
||||||
fromJson: (data) => ViewerSession.fromJson(data),
|
fromJson: (data) => ViewerSession.fromJson(data),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import '../blocs/organization/organization_state.dart';
|
import '../blocs/organization/organization_state.dart';
|
||||||
import 'api_client.dart';
|
import 'api_client.dart';
|
||||||
|
import 'dart:developer' as developer;
|
||||||
|
|
||||||
class OrgApi {
|
class OrgApi {
|
||||||
final ApiClient _apiClient;
|
final ApiClient _apiClient;
|
||||||
@@ -14,10 +15,18 @@ class OrgApi {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<Organization> createOrganization(String name) async {
|
Future<Organization> createOrganization(String name) async {
|
||||||
return await _apiClient.post(
|
developer.log('POST /orgs with payload: {"name": "$name"}', name: 'OrgApi');
|
||||||
|
|
||||||
|
try {
|
||||||
|
final result = await _apiClient.post(
|
||||||
'/orgs',
|
'/orgs',
|
||||||
data: {'name': name},
|
data: {'name': name},
|
||||||
fromJson: (data) => Organization.fromJson(data),
|
fromJson: (data) => Organization.fromJson(data),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
return result;
|
||||||
|
} catch (e) {
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,26 @@
|
|||||||
<!-- Favicon -->
|
<!-- Favicon -->
|
||||||
<link rel="icon" type="image/png" href="favicon.png" />
|
<link rel="icon" type="image/png" href="favicon.png" />
|
||||||
|
|
||||||
<!-- Preload fonts -->
|
<!-- Preload PixelatedElegance brand font -->
|
||||||
<link rel="preload" href="assets/fonts/veteran-typewriter/veteran_typewriter.ttf" as="font" type="font/ttf"
|
<link rel="preload" href="assets/fonts/pixelated-elegance/PixelatedEleganceRegular-ovyAA.ttf" as="font" type="font/ttf"
|
||||||
crossorigin>
|
crossorigin>
|
||||||
<link rel="preload" href="assets/fonts/animal-park/animal_park.otf" as="font" type="font/otf" crossorigin>
|
|
||||||
<link rel="preload" href="assets/fonts/renoire-demo/renoire_demo.otf" as="font" type="font/otf" crossorigin>
|
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: 'VeteranTypewriter';
|
font-family: 'PixelatedElegance';
|
||||||
src: url('assets/fonts/veteran-typewriter/veteran_typewriter.ttf') format('truetype');
|
src: url('assets/fonts/pixelated-elegance/PixelatedEleganceRegular-ovyAA.ttf') format('truetype');
|
||||||
}
|
|
||||||
|
|
||||||
@font-face {
|
|
||||||
font-family: 'AnimalPark';
|
|
||||||
src: url('assets/fonts/animal-park/animal_park.otf') format('opentype');
|
|
||||||
}
|
|
||||||
|
|
||||||
@font-face {
|
|
||||||
font-family: 'RenoireDemo';
|
|
||||||
src: url('assets/fonts/renoire-demo/renoire_demo.otf') format('opentype');
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<title>b0esche_cloud</title>
|
<title>b0esche_cloud</title>
|
||||||
<link rel="manifest" href="manifest.json">
|
<link rel="manifest" href="manifest.json">
|
||||||
|
|
||||||
|
<!-- PDF.js library for SfPdfViewer on web -->
|
||||||
|
<script type="module" async>
|
||||||
|
import * as pdfjsLib from 'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/4.9.155/pdf.min.mjs';
|
||||||
|
pdfjsLib.GlobalWorkerOptions.workerSrc = "https://cdnjs.cloudflare.com/ajax/libs/pdf.js/4.9.155/pdf.worker.min.mjs";
|
||||||
|
window.pdfjsLib = pdfjsLib;
|
||||||
|
</script>
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
32
go_cloud/Dockerfile
Normal file
@@ -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
|
module go.b0esche.cloud/backend
|
||||||
|
|
||||||
go 1.25.5
|
go 1.24.0
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
github.com/coreos/go-oidc/v3 v3.17.0
|
||||||
github.com/go-chi/chi/v5 v5.2.3
|
github.com/go-chi/chi/v5 v5.2.3
|
||||||
github.com/golang-jwt/jwt/v5 v5.3.0
|
github.com/golang-jwt/jwt/v4 v4.5.2
|
||||||
|
github.com/google/uuid v1.6.0
|
||||||
|
github.com/jackc/pgconn v1.13.0
|
||||||
|
github.com/jackc/pgx/v5 v5.7.6
|
||||||
|
golang.org/x/crypto v0.37.0
|
||||||
|
golang.org/x/oauth2 v0.28.0
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/coreos/go-oidc/v3 v3.17.0 // indirect
|
|
||||||
github.com/go-jose/go-jose/v4 v4.1.3 // indirect
|
github.com/go-jose/go-jose/v4 v4.1.3 // indirect
|
||||||
github.com/golang-jwt/jwt/v4 v4.5.2 // indirect
|
github.com/jackc/chunkreader/v2 v2.0.1 // indirect
|
||||||
github.com/google/uuid v1.6.0 // indirect
|
github.com/jackc/pgio v1.0.0 // indirect
|
||||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||||
|
github.com/jackc/pgproto3/v2 v2.3.1 // indirect
|
||||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||||
github.com/jackc/pgx/v5 v5.7.6 // indirect
|
|
||||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||||
golang.org/x/crypto v0.37.0 // indirect
|
|
||||||
golang.org/x/oauth2 v0.28.0 // indirect
|
|
||||||
golang.org/x/sync v0.13.0 // indirect
|
golang.org/x/sync v0.13.0 // indirect
|
||||||
|
golang.org/x/sys v0.32.0 // indirect
|
||||||
golang.org/x/text v0.24.0 // indirect
|
golang.org/x/text v0.24.0 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
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 h1:hWBGaQfbi0iVviX4ibC7bk8OKT5qNr4klBaCHVNvehc=
|
||||||
github.com/coreos/go-oidc/v3 v3.17.0/go.mod h1:wqPbKFrVnE90vty060SB40FCJ8fTHTxSwyXJqZH+sI8=
|
github.com/coreos/go-oidc/v3 v3.17.0/go.mod h1:wqPbKFrVnE90vty060SB40FCJ8fTHTxSwyXJqZH+sI8=
|
||||||
|
github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4=
|
||||||
|
github.com/coreos/go-systemd v0.0.0-20190719114852-fd7a80b32e1f/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4=
|
||||||
|
github.com/creack/pty v1.1.7/go.mod h1:lj5s0c3V2DBrqTV7llrYr5NG6My20zk30Fl46Y7DoTY=
|
||||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/go-chi/chi/v5 v5.2.3 h1:WQIt9uxdsAbgIYgid+BpYc+liqQZGMHRaUwp0JUcvdE=
|
github.com/go-chi/chi/v5 v5.2.3 h1:WQIt9uxdsAbgIYgid+BpYc+liqQZGMHRaUwp0JUcvdE=
|
||||||
github.com/go-chi/chi/v5 v5.2.3/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops=
|
github.com/go-chi/chi/v5 v5.2.3/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops=
|
||||||
github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs=
|
github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs=
|
||||||
github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
|
github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
|
||||||
|
github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
|
||||||
github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI=
|
github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI=
|
||||||
github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
|
github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
|
||||||
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
|
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
|
||||||
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
|
github.com/jackc/chunkreader v1.0.0/go.mod h1:RT6O25fNZIuasFJRyZ4R/Y2BbhasbmZXF9QQ7T3kePo=
|
||||||
|
github.com/jackc/chunkreader/v2 v2.0.0/go.mod h1:odVSm741yZoC3dpHEUXIqA9tQRhFrgOHwnPIn9lDKlk=
|
||||||
|
github.com/jackc/chunkreader/v2 v2.0.1 h1:i+RDz65UE+mmpjTfyz0MoVTnzeYxroil2G82ki7MGG8=
|
||||||
|
github.com/jackc/chunkreader/v2 v2.0.1/go.mod h1:odVSm741yZoC3dpHEUXIqA9tQRhFrgOHwnPIn9lDKlk=
|
||||||
|
github.com/jackc/pgconn v0.0.0-20190420214824-7e0022ef6ba3/go.mod h1:jkELnwuX+w9qN5YIfX0fl88Ehu4XC3keFuOJJk9pcnA=
|
||||||
|
github.com/jackc/pgconn v0.0.0-20190824142844-760dd75542eb/go.mod h1:lLjNuW/+OfW9/pnVKPazfWOgNfH2aPem8YQ7ilXGvJE=
|
||||||
|
github.com/jackc/pgconn v0.0.0-20190831204454-2fabfa3c18b7/go.mod h1:ZJKsE/KZfsUgOEh9hBm+xYTstcNHg7UPMVJqRfQxq4s=
|
||||||
|
github.com/jackc/pgconn v1.8.0/go.mod h1:1C2Pb36bGIP9QHGBYCjnyhqu7Rv3sGshaQUvmfGIB/o=
|
||||||
|
github.com/jackc/pgconn v1.9.0/go.mod h1:YctiPyvzfU11JFxoXokUOOKQXQmDMoJL9vJzHH8/2JY=
|
||||||
|
github.com/jackc/pgconn v1.13.0 h1:3L1XMNV2Zvca/8BYhzcRFS70Lr0WlDg16Di6SFGAbys=
|
||||||
|
github.com/jackc/pgconn v1.13.0/go.mod h1:AnowpAqO4CMIIJNZl2VJp+KrkAZciAkhEl0W0JIobpI=
|
||||||
|
github.com/jackc/pgio v1.0.0 h1:g12B9UwVnzGhueNavwioyEEpAmqMe1E/BN9ES+8ovkE=
|
||||||
|
github.com/jackc/pgio v1.0.0/go.mod h1:oP+2QK2wFfUWgr+gxjoBH9KGBb31Eio69xUb0w5bYf8=
|
||||||
|
github.com/jackc/pgmock v0.0.0-20190831213851-13a1b77aafa2/go.mod h1:fGZlG77KXmcq05nJLRkk0+p82V8B8Dw8KN2/V9c/OAE=
|
||||||
|
github.com/jackc/pgmock v0.0.0-20201204152224-4fe30f7445fd/go.mod h1:hrBW0Enj2AZTNpt/7Y5rr2xe/9Mn757Wtb2xeBzPv2c=
|
||||||
|
github.com/jackc/pgmock v0.0.0-20210724152146-4ad1a8207f65 h1:DadwsjnMwFjfWc9y5Wi/+Zz7xoE5ALHsRQlOctkOiHc=
|
||||||
|
github.com/jackc/pgmock v0.0.0-20210724152146-4ad1a8207f65/go.mod h1:5R2h2EEX+qri8jOWMbJCtaPWkrrNc7OHwsp2TCqp7ak=
|
||||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||||
|
github.com/jackc/pgproto3 v1.1.0/go.mod h1:eR5FA3leWg7p9aeAqi37XOTgTIbkABlvcPB3E5rlc78=
|
||||||
|
github.com/jackc/pgproto3/v2 v2.0.0-alpha1.0.20190420180111-c116219b62db/go.mod h1:bhq50y+xrl9n5mRYyCBFKkpRVTLYJVWeCc+mEAI3yXA=
|
||||||
|
github.com/jackc/pgproto3/v2 v2.0.0-alpha1.0.20190609003834-432c2951c711/go.mod h1:uH0AWtUmuShn0bcesswc4aBTWGvw0cAxIJp+6OB//Wg=
|
||||||
|
github.com/jackc/pgproto3/v2 v2.0.0-rc3/go.mod h1:ryONWYqW6dqSg1Lw6vXNMXoBJhpzvWKnT95C46ckYeM=
|
||||||
|
github.com/jackc/pgproto3/v2 v2.0.0-rc3.0.20190831210041-4c03ce451f29/go.mod h1:ryONWYqW6dqSg1Lw6vXNMXoBJhpzvWKnT95C46ckYeM=
|
||||||
|
github.com/jackc/pgproto3/v2 v2.0.6/go.mod h1:WfJCnwN3HIg9Ish/j3sgWXnAfK8A9Y0bwXYU5xKaEdA=
|
||||||
|
github.com/jackc/pgproto3/v2 v2.1.1/go.mod h1:WfJCnwN3HIg9Ish/j3sgWXnAfK8A9Y0bwXYU5xKaEdA=
|
||||||
|
github.com/jackc/pgproto3/v2 v2.3.1 h1:nwj7qwf0S+Q7ISFfBndqeLwSwxs+4DPsbRFjECT1Y4Y=
|
||||||
|
github.com/jackc/pgproto3/v2 v2.3.1/go.mod h1:WfJCnwN3HIg9Ish/j3sgWXnAfK8A9Y0bwXYU5xKaEdA=
|
||||||
|
github.com/jackc/pgservicefile v0.0.0-20200714003250-2b9c44734f2b/go.mod h1:vsD4gTJCa9TptPL8sPkXrLZ+hDuNrZCnj29CQpr4X1E=
|
||||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||||
|
github.com/jackc/pgtype v0.0.0-20190421001408-4ed0de4755e0/go.mod h1:hdSHsc1V01CGwFsrv11mJRHWJ6aifDLfdV3aVjFF0zg=
|
||||||
|
github.com/jackc/pgtype v0.0.0-20190824184912-ab885b375b90/go.mod h1:KcahbBH1nCMSo2DXpzsoWOAfFkdEtEJpPbVLq8eE+mc=
|
||||||
|
github.com/jackc/pgtype v0.0.0-20190828014616-a8802b16cc59/go.mod h1:MWlu30kVJrUS8lot6TQqcg7mtthZ9T0EoIBFiJcmcyw=
|
||||||
|
github.com/jackc/pgx/v4 v4.0.0-20190420224344-cc3461e65d96/go.mod h1:mdxmSJJuR08CZQyj1PVQBHy9XOp5p8/SHH6a0psbY9Y=
|
||||||
|
github.com/jackc/pgx/v4 v4.0.0-20190421002000-1b8f0016e912/go.mod h1:no/Y67Jkk/9WuGR0JG/JseM9irFbnEPbuWV2EELPNuM=
|
||||||
|
github.com/jackc/pgx/v4 v4.0.0-pre1.0.20190824185557-6972a5742186/go.mod h1:X+GQnOEnf1dqHGpw7JmHqHc1NxDoalibchSk9/RWuDc=
|
||||||
github.com/jackc/pgx/v5 v5.7.6 h1:rWQc5FwZSPX58r1OQmkuaNicxdmExaEz5A2DO2hUuTk=
|
github.com/jackc/pgx/v5 v5.7.6 h1:rWQc5FwZSPX58r1OQmkuaNicxdmExaEz5A2DO2hUuTk=
|
||||||
github.com/jackc/pgx/v5 v5.7.6/go.mod h1:aruU7o91Tc2q2cFp5h4uP3f6ztExVpyVv88Xl/8Vl8M=
|
github.com/jackc/pgx/v5 v5.7.6/go.mod h1:aruU7o91Tc2q2cFp5h4uP3f6ztExVpyVv88Xl/8Vl8M=
|
||||||
|
github.com/jackc/puddle v0.0.0-20190413234325-e4ced69a3a2b/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk=
|
||||||
|
github.com/jackc/puddle v0.0.0-20190608224051-11cab39313c9/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk=
|
||||||
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||||
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||||
|
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
|
||||||
|
github.com/konsorten/go-windows-terminal-sequences v1.0.2/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
|
||||||
|
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||||
|
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||||
|
github.com/kr/pty v1.1.8/go.mod h1:O1sed60cT9XZ5uDucP5qwvh+TE3NnUj51EiZO/lmSfw=
|
||||||
|
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||||
|
github.com/lib/pq v1.0.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
|
||||||
|
github.com/lib/pq v1.1.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
|
||||||
|
github.com/lib/pq v1.2.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
|
||||||
|
github.com/mattn/go-colorable v0.1.1/go.mod h1:FuOcm+DKB9mbwrcAfNl7/TZVBZ6rcnceauSikq3lYCQ=
|
||||||
|
github.com/mattn/go-isatty v0.0.5/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
|
||||||
|
github.com/mattn/go-isatty v0.0.7/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
|
||||||
|
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/rs/xid v1.2.1/go.mod h1:+uKXf+4Djp6Md1KODXJxgGQPKngRmWyn10oCKFzNHOQ=
|
||||||
|
github.com/rs/zerolog v1.13.0/go.mod h1:YbFCdg8HfsridGWAh22vktObvhZbQsZXe4/zB0OKkWU=
|
||||||
|
github.com/rs/zerolog v1.15.0/go.mod h1:xYTKnLHcpfU2225ny5qZjxnj9NvkumZYjJHlAThCjNc=
|
||||||
|
github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0=
|
||||||
|
github.com/shopspring/decimal v0.0.0-20180709203117-cd690d0c9e24/go.mod h1:M+9NzErvs504Cn4c5DxATwIqPbtswREoFCre64PpcG4=
|
||||||
|
github.com/sirupsen/logrus v1.4.1/go.mod h1:ni0Sbl8bgC9z8RoU9G6nDWqqs/fq4eDPysMBDgk/93Q=
|
||||||
|
github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE=
|
||||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
|
github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
|
github.com/stretchr/objx v0.2.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE=
|
||||||
|
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||||
|
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
|
||||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||||
|
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||||
|
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
|
||||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
|
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
|
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||||
|
github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
|
||||||
|
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||||
|
github.com/zenazn/goji v0.9.0/go.mod h1:7S9M489iMyHBNxwZnk9/EHS098H4/F6TATF2mIxtB1Q=
|
||||||
|
go.uber.org/atomic v1.3.2/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE=
|
||||||
|
go.uber.org/atomic v1.4.0/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE=
|
||||||
|
go.uber.org/multierr v1.1.0/go.mod h1:wR5kodmAFQ0UK8QlbwjlSNy0Z68gJhDJUG5sjR94q/0=
|
||||||
|
go.uber.org/zap v1.9.1/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q=
|
||||||
|
go.uber.org/zap v1.10.0/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q=
|
||||||
|
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||||
|
golang.org/x/crypto v0.0.0-20190411191339-88737f569e3a/go.mod h1:WFFai1msRO1wXaEeE5yQxYXgSfI8pQAWXbQop6sCtWE=
|
||||||
|
golang.org/x/crypto v0.0.0-20190820162420-60c769a6c586/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||||
|
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||||
|
golang.org/x/crypto v0.0.0-20201203163018-be400aefbc4c/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I=
|
||||||
|
golang.org/x/crypto v0.0.0-20210616213533-5ff15b29337e/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||||
|
golang.org/x/crypto v0.0.0-20210711020723-a769d52b0f97/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||||
|
golang.org/x/crypto v0.0.0-20220722155217-630584e8d5aa/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
|
||||||
golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE=
|
golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE=
|
||||||
golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc=
|
golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc=
|
||||||
|
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||||
|
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||||
|
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
|
golang.org/x/net v0.0.0-20190813141303-74dc4d7220e7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
|
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||||
|
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||||
golang.org/x/oauth2 v0.28.0 h1:CrgCKl8PPAVtLnU3c+EDw6x11699EWlsDeWNWKdIOkc=
|
golang.org/x/oauth2 v0.28.0 h1:CrgCKl8PPAVtLnU3c+EDw6x11699EWlsDeWNWKdIOkc=
|
||||||
golang.org/x/oauth2 v0.28.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT8=
|
golang.org/x/oauth2 v0.28.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT8=
|
||||||
|
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610=
|
golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610=
|
||||||
golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
||||||
|
golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
|
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
|
golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
|
golang.org/x/sys v0.0.0-20190403152447-81d4e9dc473e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20190813064441-fde4db37ae7a/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20=
|
||||||
|
golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||||
|
golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw=
|
||||||
|
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||||
|
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
|
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
|
||||||
|
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
|
golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
|
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
|
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||||
golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0=
|
golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0=
|
||||||
golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU=
|
golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU=
|
||||||
|
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
|
golang.org/x/tools v0.0.0-20190425163242-31fd60d6bfdc/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
|
||||||
|
golang.org/x/tools v0.0.0-20190823170909-c4a336ef6a2f/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||||
|
golang.org/x/xerrors v0.0.0-20190410155217-1f06c39b4373/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
|
golang.org/x/xerrors v0.0.0-20190513163551-3ee3066db522/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
|
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
|
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/inconshreveable/log15.v2 v2.0.0-20180818164646-67afb5ed74ec/go.mod h1:aPpfJ7XW+gOuirDoZ8gHhLh3kZ1B08FtV2bbmy7Jv3s=
|
||||||
|
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
|||||||
@@ -6,9 +6,11 @@ import (
|
|||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
"go.b0esche.cloud/backend/internal/database"
|
"go.b0esche.cloud/backend/internal/database"
|
||||||
|
"golang.org/x/crypto/argon2"
|
||||||
"golang.org/x/crypto/bcrypt"
|
"golang.org/x/crypto/bcrypt"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -17,6 +19,12 @@ const (
|
|||||||
RPID = "b0esche.cloud"
|
RPID = "b0esche.cloud"
|
||||||
RPName = "b0esche Cloud"
|
RPName = "b0esche Cloud"
|
||||||
Origin = "https://b0esche.cloud"
|
Origin = "https://b0esche.cloud"
|
||||||
|
|
||||||
|
// Argon2id parameters (OWASP recommendations)
|
||||||
|
Argon2Time = 2 // iterations
|
||||||
|
Argon2Memory = 19 * 1024 // 19 MB
|
||||||
|
Argon2Threads = 1
|
||||||
|
Argon2KeyLen = 32
|
||||||
)
|
)
|
||||||
|
|
||||||
type Service struct {
|
type Service struct {
|
||||||
@@ -284,19 +292,76 @@ func byteArraysEqual(a, b []byte) bool {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
// HashPassword hashes a password using bcrypt
|
// HashPassword hashes a password using Argon2id (quantum-resistant)
|
||||||
|
// Format: $argon2id$v=19$m=19456,t=2,p=1$<salt>$<hash>
|
||||||
func (s *Service) HashPassword(password string) (string, error) {
|
func (s *Service) HashPassword(password string) (string, error) {
|
||||||
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
// Generate 16-byte random salt
|
||||||
if err != nil {
|
salt := make([]byte, 16)
|
||||||
return "", fmt.Errorf("failed to hash password: %w", err)
|
if _, err := rand.Read(salt); err != nil {
|
||||||
|
return "", fmt.Errorf("failed to generate salt: %w", err)
|
||||||
}
|
}
|
||||||
return string(hash), nil
|
|
||||||
|
// Hash with Argon2id
|
||||||
|
hash := argon2.IDKey([]byte(password), salt, Argon2Time, Argon2Memory, Argon2Threads, Argon2KeyLen)
|
||||||
|
|
||||||
|
// Encode in PHC string format
|
||||||
|
b64Salt := base64.RawStdEncoding.EncodeToString(salt)
|
||||||
|
b64Hash := base64.RawStdEncoding.EncodeToString(hash)
|
||||||
|
|
||||||
|
return fmt.Sprintf("$argon2id$v=19$m=%d,t=%d,p=%d$%s$%s",
|
||||||
|
Argon2Memory, Argon2Time, Argon2Threads, b64Salt, b64Hash), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// VerifyPassword checks if a password matches its hash
|
// VerifyPassword checks if a password matches its hash
|
||||||
|
// Supports both Argon2id (new) and bcrypt (legacy) for backward compatibility
|
||||||
func (s *Service) VerifyPassword(passwordHash string, password string) bool {
|
func (s *Service) VerifyPassword(passwordHash string, password string) bool {
|
||||||
|
// 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))
|
err := bcrypt.CompareHashAndPassword([]byte(passwordHash), []byte(password))
|
||||||
return err == nil
|
return err == nil
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) verifyArgon2(encodedHash string, password string) bool {
|
||||||
|
// Parse PHC format: $argon2id$v=19$m=19456,t=2,p=1$<salt>$<hash>
|
||||||
|
parts := strings.Split(encodedHash, "$")
|
||||||
|
if len(parts) != 6 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
var memory, time uint32
|
||||||
|
var threads uint8
|
||||||
|
_, err := fmt.Sscanf(parts[3], "m=%d,t=%d,p=%d", &memory, &time, &threads)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
salt, err := base64.RawStdEncoding.DecodeString(parts[4])
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
hash, err := base64.RawStdEncoding.DecodeString(parts[5])
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compute hash with same parameters
|
||||||
|
computedHash := argon2.IDKey([]byte(password), salt, time, memory, threads, uint32(len(hash)))
|
||||||
|
|
||||||
|
// Constant-time comparison
|
||||||
|
if len(hash) != len(computedHash) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
var diff byte
|
||||||
|
for i := 0; i < len(hash); i++ {
|
||||||
|
diff |= hash[i] ^ computedHash[i]
|
||||||
|
}
|
||||||
|
return diff == 0
|
||||||
}
|
}
|
||||||
|
|
||||||
// VerifyPasswordLogin verifies username and password credentials
|
// VerifyPasswordLogin verifies username and password credentials
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package config
|
package config
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -12,10 +13,15 @@ type Config struct {
|
|||||||
OIDCClientID string
|
OIDCClientID string
|
||||||
OIDCClientSecret string
|
OIDCClientSecret string
|
||||||
JWTSecret string
|
JWTSecret string
|
||||||
|
NextcloudURL string
|
||||||
|
NextcloudUser string
|
||||||
|
NextcloudPass string
|
||||||
|
NextcloudBase string
|
||||||
|
AllowedOrigins string
|
||||||
}
|
}
|
||||||
|
|
||||||
func Load() *Config {
|
func Load() *Config {
|
||||||
return &Config{
|
cfg := &Config{
|
||||||
ServerAddr: getEnv("SERVER_ADDR", ":8080"),
|
ServerAddr: getEnv("SERVER_ADDR", ":8080"),
|
||||||
DatabaseURL: os.Getenv("DATABASE_URL"),
|
DatabaseURL: os.Getenv("DATABASE_URL"),
|
||||||
OIDCIssuerURL: os.Getenv("OIDC_ISSUER_URL"),
|
OIDCIssuerURL: os.Getenv("OIDC_ISSUER_URL"),
|
||||||
@@ -23,7 +29,14 @@ func Load() *Config {
|
|||||||
OIDCClientID: os.Getenv("OIDC_CLIENT_ID"),
|
OIDCClientID: os.Getenv("OIDC_CLIENT_ID"),
|
||||||
OIDCClientSecret: os.Getenv("OIDC_CLIENT_SECRET"),
|
OIDCClientSecret: os.Getenv("OIDC_CLIENT_SECRET"),
|
||||||
JWTSecret: os.Getenv("JWT_SECRET"),
|
JWTSecret: os.Getenv("JWT_SECRET"),
|
||||||
|
NextcloudURL: os.Getenv("NEXTCLOUD_URL"),
|
||||||
|
NextcloudUser: os.Getenv("NEXTCLOUD_USER"),
|
||||||
|
NextcloudPass: os.Getenv("NEXTCLOUD_PASSWORD"),
|
||||||
|
NextcloudBase: getEnv("NEXTCLOUD_BASEPATH", "/"),
|
||||||
|
AllowedOrigins: getEnv("ALLOWED_ORIGINS", "https://b0esche.cloud,https://www.b0esche.cloud,https://*.b0esche.cloud,http://localhost:8080"),
|
||||||
}
|
}
|
||||||
|
fmt.Printf("[CONFIG] Nextcloud URL: %q, User: %q, BasePath: %q\n", cfg.NextcloudURL, cfg.NextcloudUser, cfg.NextcloudBase)
|
||||||
|
return cfg
|
||||||
}
|
}
|
||||||
|
|
||||||
func getEnv(key, defaultVal string) string {
|
func getEnv(key, defaultVal string) string {
|
||||||
|
|||||||
@@ -3,6 +3,9 @@ package database
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
|
"database/sql/driver"
|
||||||
|
"encoding/json"
|
||||||
|
"log"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
@@ -16,6 +19,49 @@ func New(db *sql.DB) *DB {
|
|||||||
return &DB{DB: db}
|
return &DB{DB: db}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// StringArray handles nullable string arrays from PostgreSQL
|
||||||
|
type StringArray []string
|
||||||
|
|
||||||
|
// Scan handles NULL values properly
|
||||||
|
func (sa *StringArray) Scan(value interface{}) error {
|
||||||
|
if value == nil {
|
||||||
|
*sa = StringArray{}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle byte slice from PostgreSQL array
|
||||||
|
if bytes, ok := value.([]byte); ok {
|
||||||
|
var arr []string
|
||||||
|
if err := json.Unmarshal(bytes, &arr); err != nil {
|
||||||
|
// If JSON parse fails, try as raw string
|
||||||
|
*sa = StringArray{string(bytes)}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
*sa = StringArray(arr)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle string directly
|
||||||
|
if str, ok := value.(string); ok {
|
||||||
|
if str == "" {
|
||||||
|
*sa = StringArray{}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
*sa = StringArray{str}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Value implements the driver.Valuer interface
|
||||||
|
func (sa StringArray) Value() (driver.Value, error) {
|
||||||
|
if len(sa) == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return json.Marshal(sa)
|
||||||
|
}
|
||||||
|
|
||||||
type User struct {
|
type User struct {
|
||||||
ID uuid.UUID
|
ID uuid.UUID
|
||||||
Email string
|
Email string
|
||||||
@@ -34,7 +80,7 @@ type Credential struct {
|
|||||||
SignCount int64
|
SignCount int64
|
||||||
CreatedAt time.Time
|
CreatedAt time.Time
|
||||||
LastUsedAt *time.Time
|
LastUsedAt *time.Time
|
||||||
Transports []string
|
Transports StringArray
|
||||||
}
|
}
|
||||||
|
|
||||||
type AuthChallenge struct {
|
type AuthChallenge struct {
|
||||||
@@ -55,10 +101,11 @@ type Session struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type Organization struct {
|
type Organization struct {
|
||||||
ID uuid.UUID
|
ID uuid.UUID `json:"id"`
|
||||||
Name string
|
OwnerID uuid.UUID `json:"ownerId"`
|
||||||
Slug string
|
Name string `json:"name"`
|
||||||
CreatedAt time.Time
|
Slug string `json:"slug"`
|
||||||
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type Membership struct {
|
type Membership struct {
|
||||||
@@ -78,6 +125,18 @@ type Activity struct {
|
|||||||
Timestamp time.Time
|
Timestamp time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type File struct {
|
||||||
|
ID uuid.UUID
|
||||||
|
OrgID *uuid.UUID
|
||||||
|
UserID *uuid.UUID
|
||||||
|
Name string
|
||||||
|
Path string
|
||||||
|
Type string
|
||||||
|
Size int64
|
||||||
|
LastModified time.Time
|
||||||
|
CreatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
func (db *DB) GetOrCreateUser(ctx context.Context, sub, email, name string) (*User, error) {
|
func (db *DB) GetOrCreateUser(ctx context.Context, sub, email, name string) (*User, error) {
|
||||||
var user User
|
var user User
|
||||||
err := db.QueryRowContext(ctx, `
|
err := db.QueryRowContext(ctx, `
|
||||||
@@ -120,9 +179,18 @@ func (db *DB) GetSession(ctx context.Context, sessionID uuid.UUID) (*Session, er
|
|||||||
return &session, nil
|
return &session, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (db *DB) RevokeSession(ctx context.Context, sessionID uuid.UUID) error {
|
||||||
|
_, err := db.ExecContext(ctx, `
|
||||||
|
UPDATE sessions
|
||||||
|
SET revoked_at = NOW()
|
||||||
|
WHERE id = $1 AND revoked_at IS NULL
|
||||||
|
`, sessionID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
func (db *DB) GetUserOrganizations(ctx context.Context, userID uuid.UUID) ([]Organization, error) {
|
func (db *DB) GetUserOrganizations(ctx context.Context, userID uuid.UUID) ([]Organization, error) {
|
||||||
rows, err := db.QueryContext(ctx, `
|
rows, err := db.QueryContext(ctx, `
|
||||||
SELECT o.id, o.name, o.slug, o.created_at
|
SELECT o.id, o.owner_id, o.name, o.slug, o.created_at
|
||||||
FROM organizations o
|
FROM organizations o
|
||||||
JOIN memberships m ON o.id = m.org_id
|
JOIN memberships m ON o.id = m.org_id
|
||||||
WHERE m.user_id = $1
|
WHERE m.user_id = $1
|
||||||
@@ -135,7 +203,7 @@ func (db *DB) GetUserOrganizations(ctx context.Context, userID uuid.UUID) ([]Org
|
|||||||
var orgs []Organization
|
var orgs []Organization
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var org Organization
|
var org Organization
|
||||||
if err := rows.Scan(&org.ID, &org.Name, &org.Slug, &org.CreatedAt); err != nil {
|
if err := rows.Scan(&org.ID, &org.OwnerID, &org.Name, &org.Slug, &org.CreatedAt); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
orgs = append(orgs, org)
|
orgs = append(orgs, org)
|
||||||
@@ -156,13 +224,18 @@ func (db *DB) GetUserMembership(ctx context.Context, userID, orgID uuid.UUID) (*
|
|||||||
return &membership, nil
|
return &membership, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (db *DB) CreateOrg(ctx context.Context, name, slug string) (*Organization, error) {
|
// GetOrgMember is an alias for GetUserMembership - checks if user is a member of an org
|
||||||
|
func (db *DB) GetOrgMember(ctx context.Context, orgID, userID uuid.UUID) (*Membership, error) {
|
||||||
|
return db.GetUserMembership(ctx, userID, orgID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *DB) CreateOrg(ctx context.Context, ownerID uuid.UUID, name, slug string) (*Organization, error) {
|
||||||
var org Organization
|
var org Organization
|
||||||
err := db.QueryRowContext(ctx, `
|
err := db.QueryRowContext(ctx, `
|
||||||
INSERT INTO organizations (name, slug)
|
INSERT INTO organizations (owner_id, name, slug)
|
||||||
VALUES ($1, $2)
|
VALUES ($1, $2, $3)
|
||||||
RETURNING id, name, slug, created_at
|
RETURNING id, owner_id, name, slug, created_at
|
||||||
`, name, slug).Scan(&org.ID, &org.Name, &org.Slug, &org.CreatedAt)
|
`, ownerID, name, slug).Scan(&org.ID, &org.OwnerID, &org.Name, &org.Slug, &org.CreatedAt)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -233,6 +306,216 @@ func (db *DB) GetOrgMembers(ctx context.Context, orgID uuid.UUID) ([]Membership,
|
|||||||
return memberships, rows.Err()
|
return memberships, rows.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetOrgFiles returns files for a given organization (top-level folder listing)
|
||||||
|
func (db *DB) GetOrgFiles(ctx context.Context, orgID uuid.UUID, userID uuid.UUID, path string, q string, page, pageSize int) ([]File, error) {
|
||||||
|
if page <= 0 {
|
||||||
|
page = 1
|
||||||
|
}
|
||||||
|
if pageSize <= 0 {
|
||||||
|
pageSize = 100
|
||||||
|
}
|
||||||
|
offset := (page - 1) * pageSize
|
||||||
|
|
||||||
|
orgIDStr := orgID.String()
|
||||||
|
userIDStr := userID.String()
|
||||||
|
log.Printf("[DATA-ISOLATION] stage=before, action=list, orgId=%s, userId=%s, fileCount=0, path=%s", orgIDStr, userIDStr, path)
|
||||||
|
|
||||||
|
// Basic search and pagination. Returns only direct children of the given path.
|
||||||
|
// For root ("/"), we want files where path doesn't contain "/" after the first character.
|
||||||
|
// For subdirs, we want files where path starts with parent but has no additional "/" after parent.
|
||||||
|
rows, err := db.QueryContext(ctx, `
|
||||||
|
SELECT f.id, f.org_id::text, f.user_id::text, f.name, f.path, f.type, f.size, f.last_modified, f.created_at
|
||||||
|
FROM files f
|
||||||
|
WHERE f.org_id = $1
|
||||||
|
AND EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM memberships m
|
||||||
|
WHERE m.org_id = $1 AND m.user_id = $2
|
||||||
|
)
|
||||||
|
AND f.path != $3
|
||||||
|
AND (
|
||||||
|
($3 = '/' AND f.path LIKE '/%' AND f.path NOT LIKE '/%/%')
|
||||||
|
OR ($3 != '/' AND f.path LIKE $3 || '/%' AND f.path NOT LIKE $3 || '/%/%')
|
||||||
|
)
|
||||||
|
AND ($4 = '' OR f.name ILIKE '%' || $4 || '%')
|
||||||
|
ORDER BY CASE WHEN f.type = 'folder' THEN 0 ELSE 1 END, f.name
|
||||||
|
LIMIT $5 OFFSET $6
|
||||||
|
`, orgID, userID, path, q, pageSize, offset)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var files []File
|
||||||
|
for rows.Next() {
|
||||||
|
var f File
|
||||||
|
var orgNull sql.NullString
|
||||||
|
var userNull sql.NullString
|
||||||
|
if err := rows.Scan(&f.ID, &orgNull, &userNull, &f.Name, &f.Path, &f.Type, &f.Size, &f.LastModified, &f.CreatedAt); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if orgNull.Valid {
|
||||||
|
oid, _ := uuid.Parse(orgNull.String)
|
||||||
|
f.OrgID = &oid
|
||||||
|
}
|
||||||
|
if userNull.Valid {
|
||||||
|
uid, _ := uuid.Parse(userNull.String)
|
||||||
|
f.UserID = &uid
|
||||||
|
}
|
||||||
|
files = append(files, f)
|
||||||
|
}
|
||||||
|
err = rows.Err()
|
||||||
|
if err == nil {
|
||||||
|
log.Printf("[DATA-ISOLATION] stage=after, action=list, orgId=%s, userId=%s, fileCount=%d, path=%s", orgIDStr, userIDStr, len(files), path)
|
||||||
|
}
|
||||||
|
return files, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetUserFiles returns files for a user's personal workspace at a given path
|
||||||
|
func (db *DB) GetUserFiles(ctx context.Context, userID uuid.UUID, path string, q string, page, pageSize int) ([]File, error) {
|
||||||
|
if page <= 0 {
|
||||||
|
page = 1
|
||||||
|
}
|
||||||
|
if pageSize <= 0 {
|
||||||
|
pageSize = 100
|
||||||
|
}
|
||||||
|
offset := (page - 1) * pageSize
|
||||||
|
|
||||||
|
// Return only direct children of the given path
|
||||||
|
log.Printf("[DATA-ISOLATION] stage=before, action=list, orgId=, userId=%s, fileCount=0, path=%s", userID.String(), path)
|
||||||
|
rows, err := db.QueryContext(ctx, `
|
||||||
|
SELECT id, org_id::text, user_id::text, name, path, type, size, last_modified, created_at
|
||||||
|
FROM files
|
||||||
|
WHERE user_id = $1
|
||||||
|
AND org_id IS NULL
|
||||||
|
AND path != $2
|
||||||
|
AND (
|
||||||
|
($2 = '/' AND path LIKE '/%' AND path NOT LIKE '/%/%')
|
||||||
|
OR ($2 != '/' AND path LIKE $2 || '/%' AND path NOT LIKE $2 || '/%/%')
|
||||||
|
)
|
||||||
|
AND ($3 = '' OR name ILIKE '%' || $3 || '%')
|
||||||
|
ORDER BY CASE WHEN type = 'folder' THEN 0 ELSE 1 END, name
|
||||||
|
LIMIT $4 OFFSET $5
|
||||||
|
`, userID, path, q, pageSize, offset)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var files []File
|
||||||
|
for rows.Next() {
|
||||||
|
var f File
|
||||||
|
var orgNull sql.NullString
|
||||||
|
var userNull sql.NullString
|
||||||
|
if err := rows.Scan(&f.ID, &orgNull, &userNull, &f.Name, &f.Path, &f.Type, &f.Size, &f.LastModified, &f.CreatedAt); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if orgNull.Valid {
|
||||||
|
oid, _ := uuid.Parse(orgNull.String)
|
||||||
|
f.OrgID = &oid
|
||||||
|
}
|
||||||
|
if userNull.Valid {
|
||||||
|
uid, _ := uuid.Parse(userNull.String)
|
||||||
|
f.UserID = &uid
|
||||||
|
}
|
||||||
|
files = append(files, f)
|
||||||
|
}
|
||||||
|
err = rows.Err()
|
||||||
|
if err == nil {
|
||||||
|
log.Printf("[DATA-ISOLATION] stage=after, action=list, orgId=, userId=%s, fileCount=%d, path=%s", userID.String(), len(files), path)
|
||||||
|
}
|
||||||
|
return files, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateFile inserts a file or folder record. orgID or userID may be nil.
|
||||||
|
func (db *DB) CreateFile(ctx context.Context, orgID *uuid.UUID, userID *uuid.UUID, name, path, fileType string, size int64) (*File, error) {
|
||||||
|
var f File
|
||||||
|
var orgIDVal interface{}
|
||||||
|
var userIDVal interface{}
|
||||||
|
orgIDStr := ""
|
||||||
|
userIDStr := ""
|
||||||
|
if orgID != nil {
|
||||||
|
orgIDVal = *orgID
|
||||||
|
orgIDStr = orgID.String()
|
||||||
|
} else {
|
||||||
|
orgIDVal = nil
|
||||||
|
}
|
||||||
|
if userID != nil {
|
||||||
|
userIDVal = *userID
|
||||||
|
userIDStr = userID.String()
|
||||||
|
} else {
|
||||||
|
userIDVal = nil
|
||||||
|
}
|
||||||
|
log.Printf("[DATA-ISOLATION] stage=before, action=create, orgId=%s, userId=%s, fileCount=1, path=%s", orgIDStr, userIDStr, path)
|
||||||
|
|
||||||
|
err := db.QueryRowContext(ctx, `
|
||||||
|
INSERT INTO files (org_id, user_id, name, path, type, size)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6)
|
||||||
|
RETURNING id, org_id::text, user_id::text, name, path, type, size, last_modified, created_at
|
||||||
|
`, orgIDVal, userIDVal, name, path, fileType, size).Scan(&f.ID, new(sql.NullString), new(sql.NullString), &f.Name, &f.Path, &f.Type, &f.Size, &f.LastModified, &f.CreatedAt)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
log.Printf("[DATA-ISOLATION] stage=after, action=create, orgId=%s, userId=%s, fileCount=1, path=%s", orgIDStr, userIDStr, f.Path)
|
||||||
|
return &f, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetFileByID retrieves a file by its ID
|
||||||
|
func (db *DB) GetFileByID(ctx context.Context, fileID uuid.UUID) (*File, error) {
|
||||||
|
var f File
|
||||||
|
var orgNull sql.NullString
|
||||||
|
var userNull sql.NullString
|
||||||
|
|
||||||
|
err := db.QueryRowContext(ctx, `
|
||||||
|
SELECT id, org_id::text, user_id::text, name, path, type, size, last_modified, created_at
|
||||||
|
FROM files
|
||||||
|
WHERE id = $1
|
||||||
|
`, fileID).Scan(&f.ID, &orgNull, &userNull, &f.Name, &f.Path, &f.Type, &f.Size, &f.LastModified, &f.CreatedAt)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if orgNull.Valid {
|
||||||
|
oid, _ := uuid.Parse(orgNull.String)
|
||||||
|
f.OrgID = &oid
|
||||||
|
}
|
||||||
|
if userNull.Valid {
|
||||||
|
uid, _ := uuid.Parse(userNull.String)
|
||||||
|
f.UserID = &uid
|
||||||
|
}
|
||||||
|
|
||||||
|
return &f, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateFileSize updates the size and modification time of a file
|
||||||
|
func (db *DB) UpdateFileSize(ctx context.Context, fileID uuid.UUID, size int64) error {
|
||||||
|
_, err := db.ExecContext(ctx, `
|
||||||
|
UPDATE files
|
||||||
|
SET size = $1, last_modified = NOW()
|
||||||
|
WHERE id = $2
|
||||||
|
`, size, fileID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteFileByPath removes a file or folder matching path for a given org or user
|
||||||
|
func (db *DB) DeleteFileByPath(ctx context.Context, orgID *uuid.UUID, userID *uuid.UUID, path string) error {
|
||||||
|
var res sql.Result
|
||||||
|
var err error
|
||||||
|
if orgID != nil {
|
||||||
|
res, err = db.ExecContext(ctx, `DELETE FROM files WHERE org_id = $1 AND path = $2`, *orgID, path)
|
||||||
|
} else if userID != nil {
|
||||||
|
res, err = db.ExecContext(ctx, `DELETE FROM files WHERE user_id = $1 AND path = $2`, *userID, path)
|
||||||
|
} else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, _ = res.RowsAffected()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (db *DB) UpdateMemberRole(ctx context.Context, orgID, userID uuid.UUID, role string) error {
|
func (db *DB) UpdateMemberRole(ctx context.Context, orgID, userID uuid.UUID, role string) error {
|
||||||
_, err := db.ExecContext(ctx, `
|
_, err := db.ExecContext(ctx, `
|
||||||
UPDATE memberships
|
UPDATE memberships
|
||||||
@@ -391,3 +674,5 @@ func (db *DB) MarkChallengeUsed(ctx context.Context, challenge []byte) error {
|
|||||||
`, challenge)
|
`, challenge)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// UpdateFileSize updates the size and last_modified timestamp of a file
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5/middleware"
|
chimiddleware "github.com/go-chi/chi/v5/middleware"
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -40,7 +40,7 @@ func WriteError(w http.ResponseWriter, code ErrorCode, message string, status in
|
|||||||
|
|
||||||
// GetRequestID extracts the request ID from the request context
|
// GetRequestID extracts the request ID from the request context
|
||||||
func GetRequestID(r *http.Request) string {
|
func GetRequestID(r *http.Request) string {
|
||||||
if reqID := middleware.GetReqID(r.Context()); reqID != "" {
|
if reqID := chimiddleware.GetReqID(r.Context()); reqID != "" {
|
||||||
return reqID
|
return reqID
|
||||||
}
|
}
|
||||||
return "unknown"
|
return "unknown"
|
||||||
@@ -48,10 +48,10 @@ func GetRequestID(r *http.Request) string {
|
|||||||
|
|
||||||
// GetUserID extracts user ID from context if available
|
// GetUserID extracts user ID from context if available
|
||||||
func GetUserID(r *http.Request) string {
|
func GetUserID(r *http.Request) string {
|
||||||
if userID := r.Context().Value("user"); userID != nil {
|
// Use type contextKey matching middleware package
|
||||||
if uid, ok := userID.(string); ok {
|
type contextKey string
|
||||||
return uid
|
if userID, ok := r.Context().Value(contextKey("user")).(string); ok && userID != "" {
|
||||||
}
|
return userID
|
||||||
}
|
}
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|||||||
605
go_cloud/internal/http/wopi_handlers.go
Normal file
@@ -0,0 +1,605 @@
|
|||||||
|
package http
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"go.b0esche.cloud/backend/internal/config"
|
||||||
|
"go.b0esche.cloud/backend/internal/database"
|
||||||
|
"go.b0esche.cloud/backend/internal/errors"
|
||||||
|
"go.b0esche.cloud/backend/internal/middleware"
|
||||||
|
"go.b0esche.cloud/backend/internal/models"
|
||||||
|
"go.b0esche.cloud/backend/internal/storage"
|
||||||
|
"go.b0esche.cloud/backend/pkg/jwt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// WOPILockManager manages file locks to prevent concurrent editing conflicts
|
||||||
|
type WOPILockManager struct {
|
||||||
|
locks map[string]*models.WOPILockInfo
|
||||||
|
mu sync.RWMutex
|
||||||
|
}
|
||||||
|
|
||||||
|
var lockManager = &WOPILockManager{
|
||||||
|
locks: make(map[string]*models.WOPILockInfo),
|
||||||
|
}
|
||||||
|
|
||||||
|
// AcquireLock tries to acquire a lock for a file
|
||||||
|
func (m *WOPILockManager) AcquireLock(fileID, userID string) (string, error) {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
|
||||||
|
if existing, ok := m.locks[fileID]; ok {
|
||||||
|
// Check if lock has expired
|
||||||
|
if time.Now().Before(existing.ExpiresAt) {
|
||||||
|
// Lock still active - check if same user
|
||||||
|
if existing.UserID != userID {
|
||||||
|
fmt.Printf("[WOPI-LOCK] Lock conflict: file=%s locked_by=%s requested_by=%s\n", fileID, existing.UserID, userID)
|
||||||
|
return "", fmt.Errorf("file locked by another user")
|
||||||
|
}
|
||||||
|
// Same user, refresh the lock
|
||||||
|
lockID := uuid.New().String()
|
||||||
|
m.locks[fileID] = &models.WOPILockInfo{
|
||||||
|
FileID: fileID,
|
||||||
|
UserID: userID,
|
||||||
|
LockID: lockID,
|
||||||
|
CreatedAt: time.Now(),
|
||||||
|
ExpiresAt: time.Now().Add(30 * time.Minute),
|
||||||
|
}
|
||||||
|
fmt.Printf("[WOPI-LOCK] Lock refreshed: file=%s user=%s lock_id=%s\n", fileID, userID, lockID)
|
||||||
|
return lockID, nil
|
||||||
|
}
|
||||||
|
// Lock expired, remove it
|
||||||
|
delete(m.locks, fileID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Acquire new lock
|
||||||
|
lockID := uuid.New().String()
|
||||||
|
m.locks[fileID] = &models.WOPILockInfo{
|
||||||
|
FileID: fileID,
|
||||||
|
UserID: userID,
|
||||||
|
LockID: lockID,
|
||||||
|
CreatedAt: time.Now(),
|
||||||
|
ExpiresAt: time.Now().Add(30 * time.Minute),
|
||||||
|
}
|
||||||
|
fmt.Printf("[WOPI-LOCK] Lock acquired: file=%s user=%s lock_id=%s\n", fileID, userID, lockID)
|
||||||
|
return lockID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReleaseLock releases a lock for a file
|
||||||
|
func (m *WOPILockManager) ReleaseLock(fileID, userID string) error {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
|
||||||
|
if lock, ok := m.locks[fileID]; ok {
|
||||||
|
if lock.UserID == userID {
|
||||||
|
delete(m.locks, fileID)
|
||||||
|
fmt.Printf("[WOPI-LOCK] Lock released: file=%s user=%s\n", fileID, userID)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return fmt.Errorf("lock held by different user")
|
||||||
|
}
|
||||||
|
return fmt.Errorf("no lock found")
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetLock returns the current lock info for a file
|
||||||
|
func (m *WOPILockManager) GetLock(fileID string) *models.WOPILockInfo {
|
||||||
|
m.mu.RLock()
|
||||||
|
defer m.mu.RUnlock()
|
||||||
|
|
||||||
|
if lock, ok := m.locks[fileID]; ok {
|
||||||
|
// Check if expired
|
||||||
|
if time.Now().Before(lock.ExpiresAt) {
|
||||||
|
return lock
|
||||||
|
}
|
||||||
|
// Expired, will be cleaned up on next acquire attempt
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// validateWOPIAccessToken validates a WOPI access token
|
||||||
|
func validateWOPIAccessToken(tokenString string, jwtManager *jwt.Manager) (*jwt.Claims, error) {
|
||||||
|
claims, err := jwtManager.Validate(tokenString)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("[WOPI-TOKEN] Token validation failed: %v\n", err)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if token has expired
|
||||||
|
if time.Now().After(claims.ExpiresAt.Time) {
|
||||||
|
fmt.Printf("[WOPI-TOKEN] Token expired: user=%s\n", claims.UserID)
|
||||||
|
return nil, fmt.Errorf("token expired")
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("[WOPI-TOKEN] Token validated: user=%s expires=%v\n", claims.UserID, claims.ExpiresAt.Time)
|
||||||
|
return claims, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// WOPICheckFileInfoHandler handles GET /wopi/files/{fileId}
|
||||||
|
// Returns metadata about the file and user permissions
|
||||||
|
func wopiCheckFileInfoHandler(w http.ResponseWriter, r *http.Request, db *database.DB, jwtManager *jwt.Manager) {
|
||||||
|
fileID := r.PathValue("fileId")
|
||||||
|
if fileID == "" {
|
||||||
|
errors.WriteError(w, errors.CodeInvalidArgument, "Missing fileId", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get access token from query parameter
|
||||||
|
accessToken := r.URL.Query().Get("access_token")
|
||||||
|
if accessToken == "" {
|
||||||
|
errors.WriteError(w, errors.CodeUnauthenticated, "Missing access_token", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate token
|
||||||
|
claims, err := validateWOPIAccessToken(accessToken, jwtManager)
|
||||||
|
if err != nil {
|
||||||
|
errors.WriteError(w, errors.CodeUnauthenticated, "Invalid or expired token", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
userID, _ := uuid.Parse(claims.UserID)
|
||||||
|
|
||||||
|
// Get file info from database
|
||||||
|
fileUUID, err := uuid.Parse(fileID)
|
||||||
|
if err != nil {
|
||||||
|
errors.WriteError(w, errors.CodeInvalidArgument, "Invalid fileId format", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
file, err := db.GetFileByID(r.Context(), fileUUID)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("[WOPI-REQUEST] File not found: file=%s error=%v\n", fileID, err)
|
||||||
|
errors.WriteError(w, errors.CodeNotFound, "File not found", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify user has access to this file
|
||||||
|
canAccess := false
|
||||||
|
var ownerID string
|
||||||
|
|
||||||
|
if file.UserID != nil && *file.UserID == userID {
|
||||||
|
canAccess = true
|
||||||
|
ownerID = userID.String()
|
||||||
|
} else if file.OrgID != nil {
|
||||||
|
// Check if user is member of the org
|
||||||
|
member, err := db.GetOrgMember(r.Context(), *file.OrgID, userID)
|
||||||
|
if err == nil && member != nil {
|
||||||
|
canAccess = true
|
||||||
|
ownerID = file.OrgID.String()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !canAccess {
|
||||||
|
fmt.Printf("[WOPI-REQUEST] Access denied: file=%s user=%s\n", fileID, userID.String())
|
||||||
|
errors.WriteError(w, errors.CodePermissionDenied, "Access denied", http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build response
|
||||||
|
response := models.WOPICheckFileInfoResponse{
|
||||||
|
BaseFileName: file.Name,
|
||||||
|
Size: file.Size,
|
||||||
|
Version: file.ID.String(),
|
||||||
|
OwnerId: ownerID,
|
||||||
|
UserId: userID.String(),
|
||||||
|
UserFriendlyName: "", // Could be populated from user info
|
||||||
|
UserCanWrite: true,
|
||||||
|
UserCanRename: false,
|
||||||
|
UserCanNotWriteRelative: false,
|
||||||
|
ReadOnly: false,
|
||||||
|
RestrictedWebViewOnly: false,
|
||||||
|
UserCanCreateRelativeToFolder: false,
|
||||||
|
EnableOwnerTermination: false,
|
||||||
|
SupportsUpdate: true,
|
||||||
|
SupportsCobalt: false,
|
||||||
|
SupportsLocks: true,
|
||||||
|
SupportsExtendedLockLength: false,
|
||||||
|
SupportsGetLock: true,
|
||||||
|
SupportsDelete: false,
|
||||||
|
SupportsRename: false,
|
||||||
|
SupportsRenameRelativeToFolder: false,
|
||||||
|
SupportsFolders: false,
|
||||||
|
SupportsScenarios: []string{"default"},
|
||||||
|
LastModifiedTime: file.LastModified.UTC().Format(time.RFC3339),
|
||||||
|
IsAnonymousUser: false,
|
||||||
|
TimeZone: "UTC",
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("[WOPI-REQUEST] CheckFileInfo: file=%s user=%s size=%d\n", fileID, userID.String(), file.Size)
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
json.NewEncoder(w).Encode(response)
|
||||||
|
}
|
||||||
|
|
||||||
|
// WOPIGetFileHandler handles GET /wopi/files/{fileId}/contents
|
||||||
|
// Downloads the document file content
|
||||||
|
func wopiGetFileHandler(w http.ResponseWriter, r *http.Request, db *database.DB, jwtManager *jwt.Manager, cfg *config.Config) {
|
||||||
|
fileID := r.PathValue("fileId")
|
||||||
|
if fileID == "" {
|
||||||
|
errors.WriteError(w, errors.CodeInvalidArgument, "Missing fileId", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get access token from query parameter
|
||||||
|
accessToken := r.URL.Query().Get("access_token")
|
||||||
|
if accessToken == "" {
|
||||||
|
errors.WriteError(w, errors.CodeUnauthenticated, "Missing access_token", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate token
|
||||||
|
claims, err := validateWOPIAccessToken(accessToken, jwtManager)
|
||||||
|
if err != nil {
|
||||||
|
errors.WriteError(w, errors.CodeUnauthenticated, "Invalid or expired token", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
userID, _ := uuid.Parse(claims.UserID)
|
||||||
|
|
||||||
|
// Get file info from database
|
||||||
|
fileUUID, err := uuid.Parse(fileID)
|
||||||
|
if err != nil {
|
||||||
|
errors.WriteError(w, errors.CodeInvalidArgument, "Invalid fileId format", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
file, err := db.GetFileByID(r.Context(), fileUUID)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("[WOPI-REQUEST] GetFile - File not found: file=%s error=%v\n", fileID, err)
|
||||||
|
errors.WriteError(w, errors.CodeNotFound, "File not found", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify user has access to this file
|
||||||
|
canAccess := false
|
||||||
|
var webDAVClient *storage.WebDAVClient
|
||||||
|
|
||||||
|
if file.UserID != nil && *file.UserID == userID {
|
||||||
|
canAccess = true
|
||||||
|
// Get user's WebDAV client - need to pass config
|
||||||
|
// For now, create a new WebDAV client without full config
|
||||||
|
webDAVClient, err = getUserWebDAVClient(r.Context(), db, userID, "http://nc.b0esche.cloud", "admin", "")
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("[WOPI-STORAGE] Failed to get user WebDAV client: %v\n", err)
|
||||||
|
errors.WriteError(w, errors.CodeInternal, "Storage error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} else if file.OrgID != nil {
|
||||||
|
// Check if user is member of the org
|
||||||
|
member, err := db.GetOrgMember(r.Context(), *file.OrgID, userID)
|
||||||
|
if err == nil && member != nil {
|
||||||
|
canAccess = true
|
||||||
|
// Create admin WebDAV client for org files
|
||||||
|
cfg := &config.Config{
|
||||||
|
NextcloudURL: "http://nc.b0esche.cloud",
|
||||||
|
NextcloudUser: "admin",
|
||||||
|
NextcloudPass: "",
|
||||||
|
NextcloudBase: "/",
|
||||||
|
}
|
||||||
|
webDAVClient = storage.NewWebDAVClient(cfg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !canAccess {
|
||||||
|
fmt.Printf("[WOPI-REQUEST] GetFile - Access denied: file=%s user=%s\n", fileID, userID.String())
|
||||||
|
errors.WriteError(w, errors.CodePermissionDenied, "Access denied", http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Download file from storage
|
||||||
|
resp, err := webDAVClient.Download(r.Context(), file.Path, "")
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("[WOPI-STORAGE] Failed to download file: file=%s path=%s error=%v\n", fileID, file.Path, err)
|
||||||
|
errors.WriteError(w, errors.CodeNotFound, "File not found in storage", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
// Set response headers
|
||||||
|
contentType := getMimeType(file.Name)
|
||||||
|
w.Header().Set("Content-Type", contentType)
|
||||||
|
w.Header().Set("Content-Length", fmt.Sprintf("%d", file.Size))
|
||||||
|
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", file.Name))
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
|
||||||
|
fmt.Printf("[WOPI-STORAGE] GetFile: file=%s user=%s bytes=%d\n", fileID, userID.String(), file.Size)
|
||||||
|
|
||||||
|
// Stream file content
|
||||||
|
io.Copy(w, resp.Body)
|
||||||
|
}
|
||||||
|
|
||||||
|
// WOPIPutFileHandler handles POST /wopi/files/{fileId}/contents
|
||||||
|
// Uploads edited document back to storage
|
||||||
|
func wopiPutFileHandler(w http.ResponseWriter, r *http.Request, db *database.DB, jwtManager *jwt.Manager) {
|
||||||
|
fileID := r.PathValue("fileId")
|
||||||
|
if fileID == "" {
|
||||||
|
errors.WriteError(w, errors.CodeInvalidArgument, "Missing fileId", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get access token from Authorization header
|
||||||
|
authHeader := r.Header.Get("Authorization")
|
||||||
|
if !strings.HasPrefix(authHeader, "Bearer ") {
|
||||||
|
errors.WriteError(w, errors.CodeUnauthenticated, "Missing authorization", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
accessToken := strings.TrimPrefix(authHeader, "Bearer ")
|
||||||
|
|
||||||
|
// Validate token
|
||||||
|
claims, err := validateWOPIAccessToken(accessToken, jwtManager)
|
||||||
|
if err != nil {
|
||||||
|
errors.WriteError(w, errors.CodeUnauthenticated, "Invalid or expired token", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
userID, _ := uuid.Parse(claims.UserID)
|
||||||
|
|
||||||
|
// Get file info from database
|
||||||
|
fileUUID, err := uuid.Parse(fileID)
|
||||||
|
if err != nil {
|
||||||
|
errors.WriteError(w, errors.CodeInvalidArgument, "Invalid fileId format", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
file, err := db.GetFileByID(r.Context(), fileUUID)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("[WOPI-REQUEST] PutFile - File not found: file=%s\n", fileID)
|
||||||
|
errors.WriteError(w, errors.CodeNotFound, "File not found", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify user has access to this file
|
||||||
|
canAccess := false
|
||||||
|
var webDAVClient *storage.WebDAVClient
|
||||||
|
|
||||||
|
if file.UserID != nil && *file.UserID == userID {
|
||||||
|
canAccess = true
|
||||||
|
webDAVClient, err = getUserWebDAVClient(r.Context(), db, userID, "http://nc.b0esche.cloud", "admin", "")
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("[WOPI-STORAGE] Failed to get user WebDAV client: %v\n", err)
|
||||||
|
errors.WriteError(w, errors.CodeInternal, "Storage error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} else if file.OrgID != nil {
|
||||||
|
member, err := db.GetOrgMember(r.Context(), *file.OrgID, userID)
|
||||||
|
if err == nil && member != nil {
|
||||||
|
canAccess = true
|
||||||
|
// Create admin WebDAV client for org files
|
||||||
|
cfg := &config.Config{
|
||||||
|
NextcloudURL: "http://nc.b0esche.cloud",
|
||||||
|
NextcloudUser: "admin",
|
||||||
|
NextcloudPass: "",
|
||||||
|
NextcloudBase: "/",
|
||||||
|
}
|
||||||
|
webDAVClient = storage.NewWebDAVClient(cfg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !canAccess {
|
||||||
|
fmt.Printf("[WOPI-REQUEST] PutFile - Access denied: file=%s user=%s\n", fileID, userID.String())
|
||||||
|
errors.WriteError(w, errors.CodePermissionDenied, "Access denied", http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check lock
|
||||||
|
lock := lockManager.GetLock(fileID)
|
||||||
|
if lock != nil && lock.UserID != userID.String() {
|
||||||
|
fmt.Printf("[WOPI-LOCK] Put conflict: file=%s locked_by=%s user=%s\n", fileID, lock.UserID, userID.String())
|
||||||
|
w.WriteHeader(http.StatusConflict)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read file content from request body
|
||||||
|
content, err := io.ReadAll(r.Body)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("[WOPI-STORAGE] Failed to read request body: %v\n", err)
|
||||||
|
errors.WriteError(w, errors.CodeInternal, "Failed to read content", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer r.Body.Close()
|
||||||
|
|
||||||
|
// Upload to storage
|
||||||
|
err = webDAVClient.Upload(r.Context(), file.Path, strings.NewReader(string(content)), int64(len(content)))
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("[WOPI-STORAGE] Failed to upload file: file=%s path=%s error=%v\n", fileID, file.Path, err)
|
||||||
|
errors.WriteError(w, errors.CodeInternal, "Failed to save file", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update file size and modification time in database
|
||||||
|
newSize := int64(len(content))
|
||||||
|
err = db.UpdateFileSize(r.Context(), fileUUID, newSize)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("[WOPI-STORAGE] Failed to update file size: file=%s error=%v\n", fileID, err)
|
||||||
|
// Don't fail the upload, just log the warning
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("[WOPI-STORAGE] PutFile: file=%s user=%s bytes=%d\n", fileID, userID.String(), newSize)
|
||||||
|
|
||||||
|
// Return response
|
||||||
|
response := models.WOPIPutFileResponse{
|
||||||
|
ItemVersion: fileUUID.String(),
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
json.NewEncoder(w).Encode(response)
|
||||||
|
}
|
||||||
|
|
||||||
|
// WOPILockHandler handles POST /wopi/files/{fileId} with X-WOPI-Override header for lock operations
|
||||||
|
func wopiLockHandler(w http.ResponseWriter, r *http.Request, db *database.DB, jwtManager *jwt.Manager) {
|
||||||
|
fileID := r.PathValue("fileId")
|
||||||
|
if fileID == "" {
|
||||||
|
errors.WriteError(w, errors.CodeInvalidArgument, "Missing fileId", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get access token from Authorization header
|
||||||
|
authHeader := r.Header.Get("Authorization")
|
||||||
|
if !strings.HasPrefix(authHeader, "Bearer ") {
|
||||||
|
errors.WriteError(w, errors.CodeUnauthenticated, "Missing authorization", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
accessToken := strings.TrimPrefix(authHeader, "Bearer ")
|
||||||
|
|
||||||
|
// Validate token
|
||||||
|
claims, err := validateWOPIAccessToken(accessToken, jwtManager)
|
||||||
|
if err != nil {
|
||||||
|
errors.WriteError(w, errors.CodeUnauthenticated, "Invalid or expired token", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
userID := claims.UserID
|
||||||
|
override := r.Header.Get("X-WOPI-Override")
|
||||||
|
|
||||||
|
// Get file to verify access
|
||||||
|
fileUUID, err := uuid.Parse(fileID)
|
||||||
|
if err != nil {
|
||||||
|
errors.WriteError(w, errors.CodeInvalidArgument, "Invalid fileId format", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
file, err := db.GetFileByID(r.Context(), fileUUID)
|
||||||
|
if err != nil {
|
||||||
|
errors.WriteError(w, errors.CodeNotFound, "File not found", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify access
|
||||||
|
canAccess := false
|
||||||
|
if file.UserID != nil && file.UserID.String() == userID {
|
||||||
|
canAccess = true
|
||||||
|
} else if file.OrgID != nil {
|
||||||
|
userUUID, _ := uuid.Parse(userID)
|
||||||
|
member, err := db.GetOrgMember(r.Context(), *file.OrgID, userUUID)
|
||||||
|
canAccess = (err == nil && member != nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !canAccess {
|
||||||
|
errors.WriteError(w, errors.CodePermissionDenied, "Access denied", http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle lock operations
|
||||||
|
switch override {
|
||||||
|
case "LOCK":
|
||||||
|
// Acquire lock
|
||||||
|
lockID, err := lockManager.AcquireLock(fileID, userID)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("[WOPI-LOCK] Lock acquisition failed: file=%s user=%s error=%s\n", fileID, userID, err.Error())
|
||||||
|
w.WriteHeader(http.StatusConflict)
|
||||||
|
w.Write([]byte(fmt.Sprintf(`{"error": "%s"}`, err.Error())))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("X-WOPI-LockID", lockID)
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
w.Write([]byte(`{}`))
|
||||||
|
|
||||||
|
case "UNLOCK":
|
||||||
|
// Release lock
|
||||||
|
err := lockManager.ReleaseLock(fileID, userID)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("[WOPI-LOCK] Lock release failed: file=%s user=%s error=%s\n", fileID, userID, err.Error())
|
||||||
|
w.WriteHeader(http.StatusConflict)
|
||||||
|
w.Write([]byte(fmt.Sprintf(`{"error": "%s"}`, err.Error())))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
w.Write([]byte(`{}`))
|
||||||
|
|
||||||
|
case "GET_LOCK":
|
||||||
|
// Get lock info
|
||||||
|
lock := lockManager.GetLock(fileID)
|
||||||
|
if lock == nil {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
w.Write([]byte(`{}`))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("X-WOPI-LockID", lock.LockID)
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
w.Write([]byte(`{}`))
|
||||||
|
|
||||||
|
default:
|
||||||
|
errors.WriteError(w, errors.CodeInvalidArgument, "Unknown X-WOPI-Override value", http.StatusBadRequest)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// WOPISessionHandler handles POST /user/files/{fileId}/wopi-session and /orgs/{orgId}/files/{fileId}/wopi-session
|
||||||
|
// Returns WOPISrc URL and access token for opening document in Collabora
|
||||||
|
func wopiSessionHandler(w http.ResponseWriter, r *http.Request, db *database.DB, jwtManager *jwt.Manager, collaboraURL string) {
|
||||||
|
fileID := r.PathValue("fileId")
|
||||||
|
if fileID == "" {
|
||||||
|
errors.WriteError(w, errors.CodeInvalidArgument, "Missing fileId", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get user from context (from auth middleware)
|
||||||
|
userIDStr, ok := middleware.GetUserID(r.Context())
|
||||||
|
if !ok || userIDStr == "" {
|
||||||
|
errors.WriteError(w, errors.CodeUnauthenticated, "Unauthorized", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
userID, _ := uuid.Parse(userIDStr)
|
||||||
|
|
||||||
|
// Get file info
|
||||||
|
fileUUID, err := uuid.Parse(fileID)
|
||||||
|
if err != nil {
|
||||||
|
errors.WriteError(w, errors.CodeInvalidArgument, "Invalid fileId format", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
file, err := db.GetFileByID(r.Context(), fileUUID)
|
||||||
|
if err != nil {
|
||||||
|
errors.WriteError(w, errors.CodeNotFound, "File not found", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify access
|
||||||
|
canAccess := false
|
||||||
|
if file.UserID != nil && *file.UserID == userID {
|
||||||
|
canAccess = true
|
||||||
|
} else if file.OrgID != nil {
|
||||||
|
member, err := db.GetOrgMember(r.Context(), *file.OrgID, userID)
|
||||||
|
canAccess = (err == nil && member != nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !canAccess {
|
||||||
|
errors.WriteError(w, errors.CodePermissionDenied, "Access denied", http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate WOPI access token (1 hour duration)
|
||||||
|
accessToken, err := jwtManager.GenerateWithDuration(userID.String(), nil, "", 1*time.Hour)
|
||||||
|
if err != nil {
|
||||||
|
errors.WriteError(w, errors.CodeInternal, "Failed to generate token", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build WOPISrc URL
|
||||||
|
wopisrc := fmt.Sprintf("https://go.b0esche.cloud/wopi/files/%s?access_token=%s", fileID, accessToken)
|
||||||
|
|
||||||
|
response := models.WOPISessionResponse{
|
||||||
|
WOPISrc: wopisrc,
|
||||||
|
AccessToken: accessToken,
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
json.NewEncoder(w).Encode(response)
|
||||||
|
|
||||||
|
fmt.Printf("[WOPI-REQUEST] Session created: file=%s user=%s\n", fileID, userID.String())
|
||||||
|
}
|
||||||
@@ -2,7 +2,9 @@ package middleware
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"go.b0esche.cloud/backend/internal/audit"
|
"go.b0esche.cloud/backend/internal/audit"
|
||||||
@@ -21,6 +23,103 @@ var RequestID = middleware.RequestID
|
|||||||
var Logger = middleware.Logger
|
var Logger = middleware.Logger
|
||||||
var Recoverer = middleware.Recoverer
|
var Recoverer = middleware.Recoverer
|
||||||
|
|
||||||
|
// CORS middleware - accepts allowedOrigins comma-separated string
|
||||||
|
func CORS(allowedOrigins string) func(http.Handler) http.Handler {
|
||||||
|
allowedList, allowAll := compileAllowedOrigins(allowedOrigins)
|
||||||
|
return func(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
origin := r.Header.Get("Origin")
|
||||||
|
if origin != "" && isOriginAllowed(origin, allowedList) {
|
||||||
|
w.Header().Set("Access-Control-Allow-Origin", origin)
|
||||||
|
w.Header().Add("Vary", "Origin")
|
||||||
|
w.Header().Set("Access-Control-Allow-Credentials", "true")
|
||||||
|
} else if allowAll {
|
||||||
|
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||||
|
}
|
||||||
|
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS")
|
||||||
|
allowHeaders := []string{"Content-Type", "Authorization", "Range", "Accept", "Origin", "X-Requested-With"}
|
||||||
|
if reqHeaders := r.Header.Get("Access-Control-Request-Headers"); reqHeaders != "" {
|
||||||
|
allowHeaders = append(allowHeaders, reqHeaders)
|
||||||
|
}
|
||||||
|
w.Header().Set("Access-Control-Allow-Headers", strings.Join(uniqueStrings(allowHeaders), ", "))
|
||||||
|
w.Header().Set("Access-Control-Expose-Headers", "Content-Length, Content-Type, Content-Disposition")
|
||||||
|
w.Header().Set("Access-Control-Max-Age", "3600")
|
||||||
|
|
||||||
|
if r.Method == http.MethodOptions {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func compileAllowedOrigins(origins string) ([]string, bool) {
|
||||||
|
var allowed []string
|
||||||
|
allowAll := false
|
||||||
|
|
||||||
|
for _, origin := range strings.Split(origins, ",") {
|
||||||
|
trimmed := strings.TrimSpace(origin)
|
||||||
|
if trimmed == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if trimmed == "*" {
|
||||||
|
allowAll = true
|
||||||
|
}
|
||||||
|
allowed = append(allowed, trimmed)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(allowed) == 0 && !allowAll {
|
||||||
|
allowAll = true
|
||||||
|
}
|
||||||
|
|
||||||
|
return allowed, allowAll
|
||||||
|
}
|
||||||
|
|
||||||
|
func uniqueStrings(values []string) []string {
|
||||||
|
seen := make(map[string]struct{})
|
||||||
|
var out []string
|
||||||
|
for _, v := range values {
|
||||||
|
trimmed := strings.TrimSpace(v)
|
||||||
|
if trimmed == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
key := strings.ToLower(trimmed)
|
||||||
|
if _, ok := seen[key]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[key] = struct{}{}
|
||||||
|
out = append(out, trimmed)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func isOriginAllowed(origin string, allowed []string) bool {
|
||||||
|
if origin == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, pattern := range allowed {
|
||||||
|
if originMatches(origin, pattern) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func originMatches(origin, pattern string) bool {
|
||||||
|
if pattern == "*" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if !strings.Contains(pattern, "*") {
|
||||||
|
return strings.EqualFold(origin, pattern)
|
||||||
|
}
|
||||||
|
regexPattern := "(?i)^" + regexp.QuoteMeta(pattern) + "$"
|
||||||
|
regexPattern = strings.ReplaceAll(regexPattern, "\\*", ".*")
|
||||||
|
matched, err := regexp.MatchString(regexPattern, origin)
|
||||||
|
return err == nil && matched
|
||||||
|
}
|
||||||
|
|
||||||
// TODO: Implement rate limiter
|
// TODO: Implement rate limiter
|
||||||
var RateLimit = func(next http.Handler) http.Handler {
|
var RateLimit = func(next http.Handler) http.Handler {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -29,33 +128,69 @@ var RateLimit = func(next http.Handler) http.Handler {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
type contextKey string
|
type ContextKey string
|
||||||
|
|
||||||
const (
|
const (
|
||||||
userKey contextKey = "user"
|
UserKey ContextKey = "user"
|
||||||
sessionKey contextKey = "session"
|
SessionKey ContextKey = "session"
|
||||||
orgKey contextKey = "org"
|
TokenKey ContextKey = "token"
|
||||||
|
OrgKey ContextKey = "org"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// GetUserID retrieves the user ID from the request context
|
||||||
|
func GetUserID(ctx context.Context) (string, bool) {
|
||||||
|
userID, ok := ctx.Value(UserKey).(string)
|
||||||
|
return userID, ok
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetSession retrieves the session from the request context
|
||||||
|
func GetSession(ctx context.Context) (*database.Session, bool) {
|
||||||
|
session, ok := ctx.Value(SessionKey).(*database.Session)
|
||||||
|
return session, ok
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetToken retrieves the JWT token from the request context
|
||||||
|
func GetToken(ctx context.Context) (string, bool) {
|
||||||
|
token, ok := ctx.Value(TokenKey).(string)
|
||||||
|
return token, ok
|
||||||
|
}
|
||||||
|
|
||||||
// Auth middleware
|
// Auth middleware
|
||||||
func Auth(jwtManager *jwt.Manager, db *database.DB) func(http.Handler) http.Handler {
|
func Auth(jwtManager *jwt.Manager, db *database.DB) func(http.Handler) http.Handler {
|
||||||
return func(next http.Handler) http.Handler {
|
return func(next http.Handler) http.Handler {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
authHeader := r.Header.Get("Authorization")
|
authHeader := r.Header.Get("Authorization")
|
||||||
if !strings.HasPrefix(authHeader, "Bearer ") {
|
var tokenString string
|
||||||
|
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)
|
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
tokenString = qToken
|
||||||
|
tokenSource = "query"
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("[AUTH-TOKEN] source=%s, path=%s\n", tokenSource, r.RequestURI)
|
||||||
|
|
||||||
tokenString := strings.TrimPrefix(authHeader, "Bearer ")
|
|
||||||
claims, session, err := jwtManager.ValidateWithSession(r.Context(), tokenString, db)
|
claims, session, err := jwtManager.ValidateWithSession(r.Context(), tokenString, db)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
fmt.Printf("[AUTH-TOKEN] validation_failed, source=%s, path=%s, error=%v\n", tokenSource, r.RequestURI, err)
|
||||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx := context.WithValue(r.Context(), userKey, claims.UserID)
|
fmt.Printf("[AUTH-TOKEN] valid, source=%s, userId=%s\n", tokenSource, claims.UserID)
|
||||||
ctx = context.WithValue(ctx, sessionKey, session)
|
|
||||||
|
ctx := context.WithValue(r.Context(), UserKey, claims.UserID)
|
||||||
|
ctx = context.WithValue(ctx, SessionKey, session)
|
||||||
|
ctx = context.WithValue(ctx, TokenKey, tokenString)
|
||||||
next.ServeHTTP(w, r.WithContext(ctx))
|
next.ServeHTTP(w, r.WithContext(ctx))
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -65,7 +200,7 @@ func Auth(jwtManager *jwt.Manager, db *database.DB) func(http.Handler) http.Hand
|
|||||||
func Org(db *database.DB, auditLogger *audit.Logger) func(http.Handler) http.Handler {
|
func Org(db *database.DB, auditLogger *audit.Logger) func(http.Handler) http.Handler {
|
||||||
return func(next http.Handler) http.Handler {
|
return func(next http.Handler) http.Handler {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
userIDStr := r.Context().Value(userKey).(string)
|
userIDStr := r.Context().Value(UserKey).(string)
|
||||||
userID, _ := uuid.Parse(userIDStr)
|
userID, _ := uuid.Parse(userIDStr)
|
||||||
|
|
||||||
orgIDStr := r.Header.Get("X-Org-ID")
|
orgIDStr := r.Header.Get("X-Org-ID")
|
||||||
@@ -104,7 +239,7 @@ func Org(db *database.DB, auditLogger *audit.Logger) func(http.Handler) http.Han
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx := context.WithValue(r.Context(), orgKey, orgID)
|
ctx := context.WithValue(r.Context(), OrgKey, orgID)
|
||||||
next.ServeHTTP(w, r.WithContext(ctx))
|
next.ServeHTTP(w, r.WithContext(ctx))
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -114,9 +249,9 @@ func Org(db *database.DB, auditLogger *audit.Logger) func(http.Handler) http.Han
|
|||||||
func Permission(db *database.DB, auditLogger *audit.Logger, perm permission.Permission) func(http.Handler) http.Handler {
|
func Permission(db *database.DB, auditLogger *audit.Logger, perm permission.Permission) func(http.Handler) http.Handler {
|
||||||
return func(next http.Handler) http.Handler {
|
return func(next http.Handler) http.Handler {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
userIDStr := r.Context().Value(userKey).(string)
|
userIDStr := r.Context().Value(UserKey).(string)
|
||||||
userID, _ := uuid.Parse(userIDStr)
|
userID, _ := uuid.Parse(userIDStr)
|
||||||
orgID := r.Context().Value(orgKey).(uuid.UUID)
|
orgID := r.Context().Value(OrgKey).(uuid.UUID)
|
||||||
|
|
||||||
hasPerm, err := permission.HasPermission(r.Context(), db, userID, orgID, perm)
|
hasPerm, err := permission.HasPermission(r.Context(), db, userID, orgID, perm)
|
||||||
if err != nil || !hasPerm {
|
if err != nil || !hasPerm {
|
||||||
|
|||||||
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 (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"go.b0esche.cloud/backend/internal/database"
|
"go.b0esche.cloud/backend/internal/database"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
|
"github.com/jackc/pgconn"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ResolveUserOrgs returns the organizations a user belongs to
|
// ResolveUserOrgs returns the organizations a user belongs to
|
||||||
@@ -24,17 +28,58 @@ func CheckMembership(ctx context.Context, db *database.DB, userID, orgID uuid.UU
|
|||||||
|
|
||||||
// CreateOrg creates a new organization and adds the user as owner
|
// CreateOrg creates a new organization and adds the user as owner
|
||||||
func CreateOrg(ctx context.Context, db *database.DB, userID uuid.UUID, name, slug string) (*database.Organization, error) {
|
func CreateOrg(ctx context.Context, db *database.DB, userID uuid.UUID, name, slug string) (*database.Organization, error) {
|
||||||
if slug == "" {
|
trimmedName := strings.TrimSpace(name)
|
||||||
// Simple slug generation
|
if trimmedName == "" {
|
||||||
slug = name // TODO: make URL safe
|
return nil, fmt.Errorf("organization name cannot be empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
baseSlug := slugify(slug)
|
||||||
|
if baseSlug == "" {
|
||||||
|
baseSlug = slugify(trimmedName)
|
||||||
|
}
|
||||||
|
if baseSlug == "" {
|
||||||
|
baseSlug = fmt.Sprintf("org-%s", uuid.NewString()[:8])
|
||||||
|
}
|
||||||
|
|
||||||
|
var org *database.Organization
|
||||||
|
var err error
|
||||||
|
// Try a handful of suffixes on unique constraint violation
|
||||||
|
for i := 0; i < 5; i++ {
|
||||||
|
candidate := baseSlug
|
||||||
|
if i > 0 {
|
||||||
|
candidate = fmt.Sprintf("%s-%d", baseSlug, i+1)
|
||||||
|
}
|
||||||
|
org, err = db.CreateOrg(ctx, userID, trimmedName, candidate)
|
||||||
|
if err != nil {
|
||||||
|
if pgErr, ok := err.(*pgconn.PgError); ok && pgErr.Code == "23505" {
|
||||||
|
// Unique violation; try next suffix
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
break
|
||||||
}
|
}
|
||||||
org, err := db.CreateOrg(ctx, name, slug)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
err = db.AddMembership(ctx, userID, org.ID, "owner")
|
|
||||||
if err != nil {
|
if err = db.AddMembership(ctx, userID, org.ID, "owner"); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return org, nil
|
return org, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// slugify converts a string to a URL-safe slug with hyphens.
|
||||||
|
func slugify(s string) string {
|
||||||
|
lower := strings.ToLower(strings.TrimSpace(s))
|
||||||
|
if lower == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
// Replace non-alphanumeric with hyphen
|
||||||
|
re := regexp.MustCompile(`[^a-z0-9]+`)
|
||||||
|
slug := re.ReplaceAllString(lower, "-")
|
||||||
|
slug = strings.Trim(slug, "-")
|
||||||
|
// Collapse multiple hyphens
|
||||||
|
slug = strings.ReplaceAll(slug, "--", "-")
|
||||||
|
return slug
|
||||||
|
}
|
||||||
|
|||||||
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) {
|
func (m *Manager) Generate(userID string, orgIDs []string, sessionID string) (string, error) {
|
||||||
|
return m.GenerateWithDuration(userID, orgIDs, sessionID, 15*time.Minute)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) GenerateWithDuration(userID string, orgIDs []string, sessionID string, duration time.Duration) (string, error) {
|
||||||
claims := Claims{
|
claims := Claims{
|
||||||
UserID: userID,
|
UserID: userID,
|
||||||
OrgIDs: orgIDs,
|
OrgIDs: orgIDs,
|
||||||
SessionID: sessionID,
|
SessionID: sessionID,
|
||||||
RegisteredClaims: jwt.RegisteredClaims{
|
RegisteredClaims: jwt.RegisteredClaims{
|
||||||
ExpiresAt: jwt.NewNumericDate(time.Now().Add(15 * time.Minute)),
|
ExpiresAt: jwt.NewNumericDate(time.Now().Add(duration)),
|
||||||
IssuedAt: jwt.NewNumericDate(time.Now()),
|
IssuedAt: jwt.NewNumericDate(time.Now()),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||