full stack second commit
This commit is contained in:
150
README.md
150
README.md
@@ -1 +1,149 @@
|
|||||||
# Test
|
# b0esche.cloud
|
||||||
|
|
||||||
|
A self-hosted, SaaS-style document platform with Go backend and Flutter web frontend.
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
- `go_cloud/`: Go backend (control plane) with REST API
|
||||||
|
- `b0esche_cloud/`: Flutter web frontend with BLoC architecture
|
||||||
|
- Supporting services: Nextcloud (storage), Collabora (editing), PostgreSQL (database)
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- Go 1.21+
|
||||||
|
- Flutter 3.10+
|
||||||
|
- Docker and Docker Compose
|
||||||
|
- PostgreSQL (or Docker)
|
||||||
|
- Nextcloud instance
|
||||||
|
- Collabora Online instance
|
||||||
|
|
||||||
|
## Local Development Setup
|
||||||
|
|
||||||
|
### 1. Start Supporting Services
|
||||||
|
|
||||||
|
Use Docker Compose to start PostgreSQL, Nextcloud, and Collabora:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker-compose up -d db nextcloud collabora
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Backend Setup
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd go_cloud
|
||||||
|
cp .env.example .env
|
||||||
|
# Edit .env with your configuration (DB URL, Nextcloud URL, etc.)
|
||||||
|
go run ./cmd/api
|
||||||
|
```
|
||||||
|
|
||||||
|
Or use the provided script:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/dev-backend.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Frontend Setup
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd b0esche_cloud
|
||||||
|
flutter pub get
|
||||||
|
flutter run -d chrome
|
||||||
|
```
|
||||||
|
|
||||||
|
Or use the script:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/dev-frontend.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Full Development Environment
|
||||||
|
|
||||||
|
To start everything:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/dev-all.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
This will bring up all services, backend, and frontend.
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
### Backend (.env)
|
||||||
|
|
||||||
|
Copy `go_cloud/.env.example` to `go_cloud/.env` and fill in:
|
||||||
|
|
||||||
|
- `DATABASE_URL`: PostgreSQL connection string
|
||||||
|
- `JWT_SECRET`: Random secret for JWT signing
|
||||||
|
- `OIDC_*`: OIDC provider settings
|
||||||
|
- `NEXTCLOUD_*`: Nextcloud API settings
|
||||||
|
- `COLLABORA_*`: Collabora settings
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
|
||||||
|
The frontend uses build-time environment variables for API base URL. For dev, it's hardcoded in `ApiClient` constructor.
|
||||||
|
|
||||||
|
For production builds, update accordingly.
|
||||||
|
|
||||||
|
## Running Tests
|
||||||
|
|
||||||
|
### Backend
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd go_cloud
|
||||||
|
go test ./...
|
||||||
|
```
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd b0esche_cloud
|
||||||
|
flutter test
|
||||||
|
```
|
||||||
|
|
||||||
|
## Building for Production
|
||||||
|
|
||||||
|
### Backend
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd go_cloud
|
||||||
|
go build -o bin/api ./cmd/api
|
||||||
|
```
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd b0esche_cloud
|
||||||
|
flutter build web
|
||||||
|
```
|
||||||
|
|
||||||
|
## Database Migrations
|
||||||
|
|
||||||
|
Migrations are in `go_cloud/migrations/`.
|
||||||
|
|
||||||
|
To apply:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Dev
|
||||||
|
go run github.com/pressly/goose/v3/cmd/goose@latest postgres "$DATABASE_URL" up
|
||||||
|
|
||||||
|
# Production
|
||||||
|
# Use your deployment tool to run the migration command
|
||||||
|
```
|
||||||
|
|
||||||
|
## Backup Strategy
|
||||||
|
|
||||||
|
- **Database**: Regular PostgreSQL dumps of orgs, memberships, activities
|
||||||
|
- **Files**: Nextcloud/S3 backups handled at storage layer
|
||||||
|
- **Recovery**: Restore DB, then files; Go control plane is stateless
|
||||||
|
|
||||||
|
## Contributing
|
||||||
|
|
||||||
|
1. Clone the repo
|
||||||
|
2. Follow local setup
|
||||||
|
3. Make changes
|
||||||
|
4. Run tests
|
||||||
|
5. Submit PR
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
[License here]
|
||||||
|
|||||||
@@ -26,41 +26,40 @@ class DocumentViewerBloc
|
|||||||
event.orgId,
|
event.orgId,
|
||||||
event.fileId,
|
event.fileId,
|
||||||
);
|
);
|
||||||
|
if (session.expiresAt.isBefore(DateTime.now())) {
|
||||||
|
emit(DocumentViewerSessionExpired());
|
||||||
|
return;
|
||||||
|
}
|
||||||
emit(
|
emit(
|
||||||
DocumentViewerReady(
|
DocumentViewerReady(
|
||||||
viewUrl: session.viewUrl,
|
viewUrl: session.viewUrl,
|
||||||
caps: session.capabilities,
|
caps: session.capabilities,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
if (session.expiresAt.isAfter(DateTime.now())) {
|
_expiryTimer = Timer(
|
||||||
_expiryTimer = Timer(
|
session.expiresAt.difference(DateTime.now()),
|
||||||
session.expiresAt.difference(DateTime.now()),
|
() => emit(DocumentViewerSessionExpired()),
|
||||||
() => emit(DocumentViewerSessionExpired()),
|
);
|
||||||
);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e is ApiError) {
|
if (e is ApiError) {
|
||||||
switch (e.code) {
|
switch (e.code) {
|
||||||
case 'unauthorized':
|
case 'UNAUTHENTICATED':
|
||||||
// Already handled by ApiClient
|
// Already handled by ApiClient
|
||||||
break;
|
break;
|
||||||
case 'permission_denied':
|
case 'PERMISSION_DENIED':
|
||||||
emit(
|
emit(
|
||||||
DocumentViewerError(
|
DocumentViewerError(
|
||||||
message: 'You don\'t have access to this document.',
|
message: 'You don\'t have access to this document.',
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
break;
|
break;
|
||||||
case 'not_found':
|
case 'NOT_FOUND':
|
||||||
emit(
|
emit(
|
||||||
DocumentViewerError(
|
DocumentViewerError(
|
||||||
message: 'This document no longer exists or was moved.',
|
message: 'This document no longer exists or was moved.',
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
break;
|
break;
|
||||||
case 'not_found':
|
|
||||||
emit(DocumentViewerError(message: 'Document not found'));
|
|
||||||
break;
|
|
||||||
default:
|
default:
|
||||||
emit(
|
emit(
|
||||||
DocumentViewerError(
|
DocumentViewerError(
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import 'package:bloc/bloc.dart';
|
|||||||
import 'editor_session_event.dart';
|
import 'editor_session_event.dart';
|
||||||
import 'editor_session_state.dart';
|
import 'editor_session_state.dart';
|
||||||
import '../../services/file_service.dart';
|
import '../../services/file_service.dart';
|
||||||
|
import '../../models/api_error.dart';
|
||||||
|
|
||||||
class EditorSessionBloc extends Bloc<EditorSessionEvent, EditorSessionState> {
|
class EditorSessionBloc extends Bloc<EditorSessionEvent, EditorSessionState> {
|
||||||
final FileService _fileService;
|
final FileService _fileService;
|
||||||
@@ -15,6 +16,27 @@ class EditorSessionBloc extends Bloc<EditorSessionEvent, EditorSessionState> {
|
|||||||
on<EditorSessionEnded>(_onEditorSessionEnded);
|
on<EditorSessionEnded>(_onEditorSessionEnded);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
String _getErrorMessage(dynamic error) {
|
||||||
|
if (error is ApiError) {
|
||||||
|
switch (error.code) {
|
||||||
|
case 'UNAUTHENTICATED':
|
||||||
|
return 'Session expired. Please log in again.';
|
||||||
|
case 'PERMISSION_DENIED':
|
||||||
|
return 'You do not have permission to edit this file.';
|
||||||
|
case 'NOT_FOUND':
|
||||||
|
return 'The file was not found.';
|
||||||
|
case 'CONFLICT':
|
||||||
|
return 'The file has been modified. Please refresh and try again.';
|
||||||
|
case 'INVALID_ARGUMENT':
|
||||||
|
return 'Invalid request.';
|
||||||
|
case 'INTERNAL':
|
||||||
|
default:
|
||||||
|
return 'An error occurred while opening the editor.';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return error.toString();
|
||||||
|
}
|
||||||
|
|
||||||
void _onEditorSessionStarted(
|
void _onEditorSessionStarted(
|
||||||
EditorSessionStarted event,
|
EditorSessionStarted event,
|
||||||
Emitter<EditorSessionState> emit,
|
Emitter<EditorSessionState> emit,
|
||||||
@@ -25,19 +47,21 @@ class EditorSessionBloc extends Bloc<EditorSessionEvent, EditorSessionState> {
|
|||||||
event.orgId,
|
event.orgId,
|
||||||
event.fileId,
|
event.fileId,
|
||||||
);
|
);
|
||||||
|
if (session.expiresAt.isBefore(DateTime.now())) {
|
||||||
|
emit(EditorSessionExpired());
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!session.readOnly) {
|
if (!session.readOnly) {
|
||||||
emit(EditorSessionActive(editUrl: session.editUrl));
|
emit(EditorSessionActive(editUrl: session.editUrl));
|
||||||
} else {
|
} else {
|
||||||
emit(EditorSessionReadOnly(viewUrl: session.editUrl));
|
emit(EditorSessionReadOnly(viewUrl: session.editUrl));
|
||||||
}
|
}
|
||||||
if (session.expiresAt.isAfter(DateTime.now())) {
|
_expiryTimer = Timer(
|
||||||
_expiryTimer = Timer(
|
session.expiresAt.difference(DateTime.now()),
|
||||||
session.expiresAt.difference(DateTime.now()),
|
() => add(EditorSessionEnded()), // Or emit expired
|
||||||
() => emit(EditorSessionExpired()),
|
);
|
||||||
);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
emit(EditorSessionFailed(message: e.toString()));
|
emit(EditorSessionFailed(message: _getErrorMessage(e)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import 'file_browser_event.dart';
|
|||||||
import 'file_browser_state.dart';
|
import 'file_browser_state.dart';
|
||||||
import '../../services/file_service.dart';
|
import '../../services/file_service.dart';
|
||||||
import '../../models/file_item.dart';
|
import '../../models/file_item.dart';
|
||||||
|
import '../../models/api_error.dart';
|
||||||
import 'package:path/path.dart' as p;
|
import 'package:path/path.dart' as p;
|
||||||
|
|
||||||
class FileBrowserBloc extends Bloc<FileBrowserEvent, FileBrowserState> {
|
class FileBrowserBloc extends Bloc<FileBrowserEvent, FileBrowserState> {
|
||||||
@@ -33,6 +34,27 @@ class FileBrowserBloc extends Bloc<FileBrowserEvent, FileBrowserState> {
|
|||||||
on<SearchFiles>(_onSearchFiles);
|
on<SearchFiles>(_onSearchFiles);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
String _getErrorMessage(dynamic error) {
|
||||||
|
if (error is ApiError) {
|
||||||
|
switch (error.code) {
|
||||||
|
case 'UNAUTHENTICATED':
|
||||||
|
return 'Session expired. Please log in again.';
|
||||||
|
case 'PERMISSION_DENIED':
|
||||||
|
return 'You do not have access to this folder.';
|
||||||
|
case 'NOT_FOUND':
|
||||||
|
return 'The requested folder or file was not found.';
|
||||||
|
case 'CONFLICT':
|
||||||
|
return 'A conflict occurred. Please try again.';
|
||||||
|
case 'INVALID_ARGUMENT':
|
||||||
|
return 'Invalid input provided.';
|
||||||
|
case 'INTERNAL':
|
||||||
|
default:
|
||||||
|
return 'An internal error occurred. Please try again later.';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return error.toString();
|
||||||
|
}
|
||||||
|
|
||||||
void _onLoadDirectory(
|
void _onLoadDirectory(
|
||||||
LoadDirectory event,
|
LoadDirectory event,
|
||||||
Emitter<FileBrowserState> emit,
|
Emitter<FileBrowserState> emit,
|
||||||
@@ -52,7 +74,7 @@ class FileBrowserBloc extends Bloc<FileBrowserEvent, FileBrowserState> {
|
|||||||
_emitLoadedState(emit);
|
_emitLoadedState(emit);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
emit(DirectoryError(e.toString()));
|
emit(DirectoryError(_getErrorMessage(e)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -120,7 +142,7 @@ class FileBrowserBloc extends Bloc<FileBrowserEvent, FileBrowserState> {
|
|||||||
add(LoadDirectory(orgId: event.orgId, path: event.parentPath));
|
add(LoadDirectory(orgId: event.orgId, path: event.parentPath));
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
emit(DirectoryError(e.toString()));
|
emit(DirectoryError(_getErrorMessage(e)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -143,7 +165,7 @@ class FileBrowserBloc extends Bloc<FileBrowserEvent, FileBrowserState> {
|
|||||||
_emitLoadedState(emit);
|
_emitLoadedState(emit);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
emit(DirectoryError(e.toString()));
|
emit(DirectoryError(_getErrorMessage(e)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -163,7 +185,7 @@ class FileBrowserBloc extends Bloc<FileBrowserEvent, FileBrowserState> {
|
|||||||
_emitLoadedState(emit);
|
_emitLoadedState(emit);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
emit(DirectoryError(e.toString()));
|
emit(DirectoryError(_getErrorMessage(e)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -176,7 +198,7 @@ class FileBrowserBloc extends Bloc<FileBrowserEvent, FileBrowserState> {
|
|||||||
.toList();
|
.toList();
|
||||||
_emitLoadedState(emit);
|
_emitLoadedState(emit);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
emit(DirectoryError(e.toString()));
|
emit(DirectoryError(_getErrorMessage(e)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -219,7 +241,7 @@ class FileBrowserBloc extends Bloc<FileBrowserEvent, FileBrowserState> {
|
|||||||
_emitLoadedState(emit);
|
_emitLoadedState(emit);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
emit(DirectoryError(e.toString()));
|
emit(DirectoryError(_getErrorMessage(e)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import '../file_browser/file_browser_event.dart';
|
|||||||
import '../upload/upload_bloc.dart';
|
import '../upload/upload_bloc.dart';
|
||||||
import '../upload/upload_event.dart';
|
import '../upload/upload_event.dart';
|
||||||
import '../../services/org_api.dart';
|
import '../../services/org_api.dart';
|
||||||
|
import '../../models/api_error.dart';
|
||||||
|
|
||||||
class OrganizationBloc extends Bloc<OrganizationEvent, OrganizationState> {
|
class OrganizationBloc extends Bloc<OrganizationEvent, OrganizationState> {
|
||||||
final PermissionBloc permissionBloc;
|
final PermissionBloc permissionBloc;
|
||||||
@@ -27,6 +28,27 @@ class OrganizationBloc extends Bloc<OrganizationEvent, OrganizationState> {
|
|||||||
on<CreateOrganization>(_onCreateOrganization);
|
on<CreateOrganization>(_onCreateOrganization);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
String _getErrorMessage(dynamic error) {
|
||||||
|
if (error is ApiError) {
|
||||||
|
switch (error.code) {
|
||||||
|
case 'UNAUTHENTICATED':
|
||||||
|
return 'Session expired. Please log in again.';
|
||||||
|
case 'PERMISSION_DENIED':
|
||||||
|
return 'You do not have permission to perform this action.';
|
||||||
|
case 'NOT_FOUND':
|
||||||
|
return 'The requested resource was not found.';
|
||||||
|
case 'CONFLICT':
|
||||||
|
return 'A conflict occurred. Please try again.';
|
||||||
|
case 'INVALID_ARGUMENT':
|
||||||
|
return 'Invalid input provided.';
|
||||||
|
case 'INTERNAL':
|
||||||
|
default:
|
||||||
|
return 'An internal error occurred. Please try again later.';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return error.toString();
|
||||||
|
}
|
||||||
|
|
||||||
void _onLoadOrganizations(
|
void _onLoadOrganizations(
|
||||||
LoadOrganizations event,
|
LoadOrganizations event,
|
||||||
Emitter<OrganizationState> emit,
|
Emitter<OrganizationState> emit,
|
||||||
@@ -41,7 +63,7 @@ class OrganizationBloc extends Bloc<OrganizationEvent, OrganizationState> {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
emit(OrganizationError(e.toString()));
|
emit(OrganizationError(_getErrorMessage(e)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -124,7 +146,7 @@ class OrganizationBloc extends Bloc<OrganizationEvent, OrganizationState> {
|
|||||||
organizations: currentState.organizations,
|
organizations: currentState.organizations,
|
||||||
selectedOrg: currentState.selectedOrg,
|
selectedOrg: currentState.selectedOrg,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
error: e.toString(),
|
error: _getErrorMessage(e),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import 'pdf_annotation_event.dart';
|
|||||||
import 'pdf_annotation_state.dart';
|
import 'pdf_annotation_state.dart';
|
||||||
import '../../services/file_service.dart';
|
import '../../services/file_service.dart';
|
||||||
import '../../models/annotation.dart';
|
import '../../models/annotation.dart';
|
||||||
|
import '../../models/api_error.dart';
|
||||||
|
|
||||||
class PdfAnnotationBloc extends Bloc<PdfAnnotationEvent, PdfAnnotationState> {
|
class PdfAnnotationBloc extends Bloc<PdfAnnotationEvent, PdfAnnotationState> {
|
||||||
final FileService _fileService;
|
final FileService _fileService;
|
||||||
@@ -22,6 +23,20 @@ class PdfAnnotationBloc extends Bloc<PdfAnnotationEvent, PdfAnnotationState> {
|
|||||||
on<AnnotationsSaved>(_onAnnotationsSaved);
|
on<AnnotationsSaved>(_onAnnotationsSaved);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
String _getErrorMessage(dynamic error) {
|
||||||
|
if (error is ApiError) {
|
||||||
|
switch (error.code) {
|
||||||
|
case 'CONFLICT':
|
||||||
|
return 'The document has changed. Please reload to see the latest version.';
|
||||||
|
case 'PERMISSION_DENIED':
|
||||||
|
return 'You do not have permission to annotate this document.';
|
||||||
|
default:
|
||||||
|
return error.message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return error.toString();
|
||||||
|
}
|
||||||
|
|
||||||
void _onAnnotationToolSelected(
|
void _onAnnotationToolSelected(
|
||||||
AnnotationToolSelected event,
|
AnnotationToolSelected event,
|
||||||
Emitter<PdfAnnotationState> emit,
|
Emitter<PdfAnnotationState> emit,
|
||||||
@@ -107,7 +122,7 @@ class PdfAnnotationBloc extends Bloc<PdfAnnotationEvent, PdfAnnotationState> {
|
|||||||
// Reset to idle or editing?
|
// Reset to idle or editing?
|
||||||
emit(PdfAnnotationIdle());
|
emit(PdfAnnotationIdle());
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
emit(PdfAnnotationError(message: e.toString()));
|
emit(PdfAnnotationError(message: _getErrorMessage(e)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import 'package:b0esche_cloud/services/api_client.dart';
|
||||||
import 'package:get_it/get_it.dart';
|
import 'package:get_it/get_it.dart';
|
||||||
import 'repositories/auth_repository.dart';
|
import 'repositories/auth_repository.dart';
|
||||||
import 'repositories/file_repository.dart';
|
import 'repositories/file_repository.dart';
|
||||||
@@ -17,7 +18,7 @@ void configureDependencies() {
|
|||||||
|
|
||||||
// Register services
|
// Register services
|
||||||
getIt.registerSingleton<AuthService>(AuthService(getIt<AuthRepository>()));
|
getIt.registerSingleton<AuthService>(AuthService(getIt<AuthRepository>()));
|
||||||
getIt.registerSingleton<FileService>(FileService(getIt<FileRepository>()));
|
getIt.registerSingleton<FileService>(FileService(getIt<ApiClient>()));
|
||||||
|
|
||||||
// Register viewmodels
|
// Register viewmodels
|
||||||
getIt.registerSingleton<LoginViewModel>(LoginViewModel(getIt<AuthService>()));
|
getIt.registerSingleton<LoginViewModel>(LoginViewModel(getIt<AuthService>()));
|
||||||
|
|||||||
@@ -118,36 +118,21 @@ class ApiClient {
|
|||||||
ApiError _handleError(DioException e) {
|
ApiError _handleError(DioException e) {
|
||||||
final status = e.response?.statusCode;
|
final status = e.response?.statusCode;
|
||||||
final data = e.response?.data;
|
final data = e.response?.data;
|
||||||
if (status == 403) {
|
|
||||||
|
// Handle network errors
|
||||||
|
if (e.type == DioExceptionType.connectionError ||
|
||||||
|
e.type == DioExceptionType.connectionTimeout ||
|
||||||
|
e.type == DioExceptionType.receiveTimeout ||
|
||||||
|
e.type == DioExceptionType.sendTimeout) {
|
||||||
return ApiError(
|
return ApiError(
|
||||||
code: 'permission_denied',
|
code: 'NETWORK_ERROR',
|
||||||
message: 'Access denied',
|
message: 'Network error. Please check your connection and try again.',
|
||||||
status: status,
|
|
||||||
);
|
|
||||||
} else if (status == 404) {
|
|
||||||
return ApiError(
|
|
||||||
code: 'not_found',
|
|
||||||
message: 'Resource not found',
|
|
||||||
status: status,
|
|
||||||
);
|
|
||||||
} else if (status == 409) {
|
|
||||||
return ApiError(
|
|
||||||
code: 'conflict',
|
|
||||||
message: 'Version conflict',
|
|
||||||
status: status,
|
|
||||||
);
|
|
||||||
} else if (status == 401) {
|
|
||||||
return ApiError(
|
|
||||||
code: 'unauthorized',
|
|
||||||
message: 'Unauthorized',
|
|
||||||
status: status,
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
return ApiError(
|
|
||||||
code: 'server_error',
|
|
||||||
message: data?['message'] ?? 'Server error',
|
|
||||||
status: status,
|
status: status,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
String code = data?['code'] ?? 'UNKNOWN';
|
||||||
|
String message = data?['message'] ?? 'Unknown error';
|
||||||
|
return ApiError(code: code, message: message, status: status);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,15 +20,6 @@ class MockFileService extends Mock implements FileService {
|
|||||||
_viewerResponse = null;
|
_viewerResponse = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
|
||||||
Future<ViewerSession> requestViewerSession(String orgId, String fileId) {
|
|
||||||
return _viewerResponse ?? super.noSuchMethod(
|
|
||||||
Invocation.method(#requestViewerSession, [orgId, fileId]),
|
|
||||||
returnValue: Future.value(null),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<ViewerSession> requestViewerSession(String orgId, String fileId) {
|
Future<ViewerSession> requestViewerSession(String orgId, String fileId) {
|
||||||
return _viewerResponse ??
|
return _viewerResponse ??
|
||||||
@@ -39,6 +30,16 @@ class MockFileService extends Mock implements FileService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// @override
|
||||||
|
// Future<ViewerSession> requestViewerSession(String orgId, String fileId) {
|
||||||
|
// return _viewerResponse ??
|
||||||
|
// super.noSuchMethod(
|
||||||
|
// Invocation.method(#requestViewerSession, [orgId, fileId]),
|
||||||
|
// returnValue: Future.value(null),
|
||||||
|
// );
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
late MockFileService mockFileService;
|
late MockFileService mockFileService;
|
||||||
|
|
||||||
|
|||||||
@@ -12,3 +12,11 @@ OIDC_CLIENT_SECRET=your_client_secret
|
|||||||
|
|
||||||
# JWT
|
# JWT
|
||||||
JWT_SECRET=your_jwt_secret_key
|
JWT_SECRET=your_jwt_secret_key
|
||||||
|
|
||||||
|
# Nextcloud
|
||||||
|
NEXTCLOUD_URL=https://storage.b0esche.cloud
|
||||||
|
NEXTCLOUD_USERNAME=admin
|
||||||
|
NEXTCLOUD_PASSWORD=your_password
|
||||||
|
|
||||||
|
# Collabora
|
||||||
|
COLLABORA_URL=https://office.b0esche.cloud
|
||||||
73
go_cloud/internal/errors/errors.go
Normal file
73
go_cloud/internal/errors/errors.go
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
package errors
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5/middleware"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ErrorCode represents standardized error codes
|
||||||
|
type ErrorCode string
|
||||||
|
|
||||||
|
const (
|
||||||
|
CodeUnauthenticated ErrorCode = "UNAUTHENTICATED"
|
||||||
|
CodePermissionDenied ErrorCode = "PERMISSION_DENIED"
|
||||||
|
CodeNotFound ErrorCode = "NOT_FOUND"
|
||||||
|
CodeConflict ErrorCode = "CONFLICT"
|
||||||
|
CodeInvalidArgument ErrorCode = "INVALID_ARGUMENT"
|
||||||
|
CodeInternal ErrorCode = "INTERNAL"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ErrorResponse represents the JSON error response structure
|
||||||
|
type ErrorResponse struct {
|
||||||
|
Code ErrorCode `json:"code"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// WriteError writes a standardized JSON error response
|
||||||
|
func WriteError(w http.ResponseWriter, code ErrorCode, message string, status int) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(status)
|
||||||
|
json.NewEncoder(w).Encode(ErrorResponse{
|
||||||
|
Code: code,
|
||||||
|
Message: message,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetRequestID extracts the request ID from the request context
|
||||||
|
func GetRequestID(r *http.Request) string {
|
||||||
|
if reqID := middleware.GetReqID(r.Context()); reqID != "" {
|
||||||
|
return reqID
|
||||||
|
}
|
||||||
|
return "unknown"
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetUserID extracts user ID from context if available
|
||||||
|
func GetUserID(r *http.Request) string {
|
||||||
|
if userID := r.Context().Value("user"); userID != nil {
|
||||||
|
if uid, ok := userID.(string); ok {
|
||||||
|
return uid
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetOrgID extracts org ID from context if available
|
||||||
|
func GetOrgID(r *http.Request) string {
|
||||||
|
if orgID := r.Context().Value("org"); orgID != nil {
|
||||||
|
if oid, ok := orgID.(uuid.UUID); ok {
|
||||||
|
return oid.String()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// LogError logs an error with context
|
||||||
|
func LogError(r *http.Request, err error, message string) {
|
||||||
|
fmt.Fprintf(os.Stderr, "[ERROR] req_id=%s user_id=%s org_id=%s %s: %v\n",
|
||||||
|
GetRequestID(r), GetUserID(r), GetOrgID(r), message, err)
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"go.b0esche.cloud/backend/internal/auth"
|
"go.b0esche.cloud/backend/internal/auth"
|
||||||
"go.b0esche.cloud/backend/internal/config"
|
"go.b0esche.cloud/backend/internal/config"
|
||||||
"go.b0esche.cloud/backend/internal/database"
|
"go.b0esche.cloud/backend/internal/database"
|
||||||
|
"go.b0esche.cloud/backend/internal/errors"
|
||||||
"go.b0esche.cloud/backend/internal/middleware"
|
"go.b0esche.cloud/backend/internal/middleware"
|
||||||
"go.b0esche.cloud/backend/internal/org"
|
"go.b0esche.cloud/backend/internal/org"
|
||||||
"go.b0esche.cloud/backend/internal/permission"
|
"go.b0esche.cloud/backend/internal/permission"
|
||||||
@@ -98,7 +99,8 @@ func healthHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
func authLoginHandler(w http.ResponseWriter, r *http.Request, authService *auth.Service) {
|
func authLoginHandler(w http.ResponseWriter, r *http.Request, authService *auth.Service) {
|
||||||
state, err := auth.GenerateState()
|
state, err := auth.GenerateState()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
errors.LogError(r, err, "Failed to generate state")
|
||||||
|
errors.WriteError(w, errors.CodeInternal, "Internal server error", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -121,14 +123,16 @@ func authCallbackHandler(w http.ResponseWriter, r *http.Request, cfg *config.Con
|
|||||||
Success: false,
|
Success: false,
|
||||||
Metadata: map[string]interface{}{"error": err.Error()},
|
Metadata: map[string]interface{}{"error": err.Error()},
|
||||||
})
|
})
|
||||||
http.Error(w, "Authentication failed", http.StatusUnauthorized)
|
errors.LogError(r, err, "Authentication failed")
|
||||||
|
errors.WriteError(w, errors.CodeUnauthenticated, "Authentication failed", http.StatusUnauthorized)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get user orgs
|
// Get user orgs
|
||||||
orgs, err := org.ResolveUserOrgs(r.Context(), db, user.ID)
|
orgs, err := org.ResolveUserOrgs(r.Context(), db, user.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, "Server error", http.StatusInternalServerError)
|
errors.LogError(r, err, "Failed to resolve user orgs")
|
||||||
|
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
orgIDs := make([]string, len(orgs))
|
orgIDs := make([]string, len(orgs))
|
||||||
@@ -138,7 +142,8 @@ func authCallbackHandler(w http.ResponseWriter, r *http.Request, cfg *config.Con
|
|||||||
|
|
||||||
token, err := jwtManager.Generate(user.Email, orgIDs, session.ID.String())
|
token, err := jwtManager.Generate(user.Email, orgIDs, session.ID.String())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, "Token generation failed", http.StatusInternalServerError)
|
errors.LogError(r, err, "Token generation failed")
|
||||||
|
errors.WriteError(w, errors.CodeInternal, "Token generation failed", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -155,21 +160,23 @@ func authCallbackHandler(w http.ResponseWriter, r *http.Request, cfg *config.Con
|
|||||||
func refreshHandler(w http.ResponseWriter, r *http.Request, jwtManager *jwt.Manager, db *database.DB) {
|
func refreshHandler(w http.ResponseWriter, r *http.Request, jwtManager *jwt.Manager, db *database.DB) {
|
||||||
authHeader := r.Header.Get("Authorization")
|
authHeader := r.Header.Get("Authorization")
|
||||||
if !strings.HasPrefix(authHeader, "Bearer ") {
|
if !strings.HasPrefix(authHeader, "Bearer ") {
|
||||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
errors.WriteError(w, errors.CodeUnauthenticated, "Unauthorized", http.StatusUnauthorized)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
tokenString := strings.TrimPrefix(authHeader, "Bearer ")
|
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 {
|
||||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
errors.LogError(r, err, "Invalid token")
|
||||||
|
errors.WriteError(w, errors.CodeUnauthenticated, "Unauthorized", http.StatusUnauthorized)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
userID, _ := uuid.Parse(claims.UserID)
|
userID, _ := uuid.Parse(claims.UserID)
|
||||||
orgs, err := db.GetUserOrganizations(r.Context(), userID)
|
orgs, err := db.GetUserOrganizations(r.Context(), userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, "Server error", http.StatusInternalServerError)
|
errors.LogError(r, err, "Failed to get user organizations")
|
||||||
|
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
orgIDs := make([]string, len(orgs))
|
orgIDs := make([]string, len(orgs))
|
||||||
@@ -179,7 +186,8 @@ func refreshHandler(w http.ResponseWriter, r *http.Request, jwtManager *jwt.Mana
|
|||||||
|
|
||||||
newToken, err := jwtManager.Generate(claims.UserID, orgIDs, session.ID.String())
|
newToken, err := jwtManager.Generate(claims.UserID, orgIDs, session.ID.String())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, "Server error", http.StatusInternalServerError)
|
errors.LogError(r, err, "Token generation failed")
|
||||||
|
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -190,21 +198,23 @@ func refreshHandler(w http.ResponseWriter, r *http.Request, jwtManager *jwt.Mana
|
|||||||
func listOrgsHandler(w http.ResponseWriter, r *http.Request, db *database.DB, jwtManager *jwt.Manager) {
|
func listOrgsHandler(w http.ResponseWriter, r *http.Request, db *database.DB, jwtManager *jwt.Manager) {
|
||||||
authHeader := r.Header.Get("Authorization")
|
authHeader := r.Header.Get("Authorization")
|
||||||
if !strings.HasPrefix(authHeader, "Bearer ") {
|
if !strings.HasPrefix(authHeader, "Bearer ") {
|
||||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
errors.WriteError(w, errors.CodeUnauthenticated, "Unauthorized", http.StatusUnauthorized)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
tokenString := strings.TrimPrefix(authHeader, "Bearer ")
|
tokenString := strings.TrimPrefix(authHeader, "Bearer ")
|
||||||
|
|
||||||
claims, _, err := jwtManager.ValidateWithSession(r.Context(), tokenString, db)
|
claims, _, err := jwtManager.ValidateWithSession(r.Context(), tokenString, db)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
errors.LogError(r, err, "Invalid token")
|
||||||
|
errors.WriteError(w, errors.CodeUnauthenticated, "Unauthorized", http.StatusUnauthorized)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
userID, _ := uuid.Parse(claims.UserID)
|
userID, _ := uuid.Parse(claims.UserID)
|
||||||
orgs, err := org.ResolveUserOrgs(r.Context(), db, userID)
|
orgs, err := org.ResolveUserOrgs(r.Context(), db, userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, "Server error", http.StatusInternalServerError)
|
errors.LogError(r, err, "Failed to resolve user orgs")
|
||||||
|
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -215,14 +225,15 @@ func listOrgsHandler(w http.ResponseWriter, r *http.Request, db *database.DB, jw
|
|||||||
func createOrgHandler(w http.ResponseWriter, r *http.Request, db *database.DB, auditLogger *audit.Logger, jwtManager *jwt.Manager) {
|
func createOrgHandler(w http.ResponseWriter, r *http.Request, db *database.DB, auditLogger *audit.Logger, jwtManager *jwt.Manager) {
|
||||||
authHeader := r.Header.Get("Authorization")
|
authHeader := r.Header.Get("Authorization")
|
||||||
if !strings.HasPrefix(authHeader, "Bearer ") {
|
if !strings.HasPrefix(authHeader, "Bearer ") {
|
||||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
errors.WriteError(w, errors.CodeUnauthenticated, "Unauthorized", http.StatusUnauthorized)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
tokenString := strings.TrimPrefix(authHeader, "Bearer ")
|
tokenString := strings.TrimPrefix(authHeader, "Bearer ")
|
||||||
|
|
||||||
claims, _, err := jwtManager.ValidateWithSession(r.Context(), tokenString, db)
|
claims, _, err := jwtManager.ValidateWithSession(r.Context(), tokenString, db)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
errors.LogError(r, err, "Invalid token")
|
||||||
|
errors.WriteError(w, errors.CodeUnauthenticated, "Unauthorized", http.StatusUnauthorized)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -233,13 +244,14 @@ func createOrgHandler(w http.ResponseWriter, r *http.Request, db *database.DB, a
|
|||||||
Slug string `json:"slug,omitempty"`
|
Slug string `json:"slug,omitempty"`
|
||||||
}
|
}
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
http.Error(w, "Bad request", http.StatusBadRequest)
|
errors.WriteError(w, errors.CodeInvalidArgument, "Bad request", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
org, err := org.CreateOrg(r.Context(), db, userID, req.Name, req.Slug)
|
org, err := org.CreateOrg(r.Context(), db, userID, req.Name, req.Slug)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, "Server error", http.StatusInternalServerError)
|
errors.LogError(r, err, "Failed to create org")
|
||||||
|
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -283,17 +295,17 @@ func viewerHandler(w http.ResponseWriter, r *http.Request, db *database.DB, audi
|
|||||||
session := struct {
|
session := struct {
|
||||||
ViewUrl string `json:"viewUrl"`
|
ViewUrl string `json:"viewUrl"`
|
||||||
Capabilities struct {
|
Capabilities struct {
|
||||||
CanEdit bool `json:"canEdit"`
|
CanEdit bool `json:"canEdit"`
|
||||||
CanAnnotate bool `json:"canAnnotate"`
|
CanAnnotate bool `json:"canAnnotate"`
|
||||||
IsPdf bool `json:"isPdf"`
|
IsPdf bool `json:"isPdf"`
|
||||||
} `json:"capabilities"`
|
} `json:"capabilities"`
|
||||||
ExpiresAt string `json:"expiresAt"`
|
ExpiresAt string `json:"expiresAt"`
|
||||||
}{
|
}{
|
||||||
ViewUrl: "https://view.example.com/" + fileId,
|
ViewUrl: "https://view.example.com/" + fileId,
|
||||||
Capabilities: struct {
|
Capabilities: struct {
|
||||||
CanEdit bool `json:"canEdit"`
|
CanEdit bool `json:"canEdit"`
|
||||||
CanAnnotate bool `json:"canAnnotate"`
|
CanAnnotate bool `json:"canAnnotate"`
|
||||||
IsPdf bool `json:"isPdf"`
|
IsPdf bool `json:"isPdf"`
|
||||||
}{CanEdit: true, CanAnnotate: true, IsPdf: true},
|
}{CanEdit: true, CanAnnotate: true, IsPdf: true},
|
||||||
ExpiresAt: "2023-01-01T01:00:00Z",
|
ExpiresAt: "2023-01-01T01:00:00Z",
|
||||||
}
|
}
|
||||||
@@ -325,10 +337,6 @@ func editorHandler(w http.ResponseWriter, r *http.Request, db *database.DB, audi
|
|||||||
json.NewEncoder(w).Encode(session)
|
json.NewEncoder(w).Encode(session)
|
||||||
}
|
}
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
json.NewEncoder(w).Encode(session)
|
|
||||||
}
|
|
||||||
|
|
||||||
func annotationsHandler(w http.ResponseWriter, r *http.Request, db *database.DB, auditLogger *audit.Logger) {
|
func annotationsHandler(w http.ResponseWriter, r *http.Request, db *database.DB, auditLogger *audit.Logger) {
|
||||||
userIDStr := r.Context().Value("user").(string)
|
userIDStr := r.Context().Value("user").(string)
|
||||||
userID, _ := uuid.Parse(userIDStr)
|
userID, _ := uuid.Parse(userIDStr)
|
||||||
@@ -341,7 +349,7 @@ func annotationsHandler(w http.ResponseWriter, r *http.Request, db *database.DB,
|
|||||||
BaseVersionId string `json:"baseVersionId"`
|
BaseVersionId string `json:"baseVersionId"`
|
||||||
}
|
}
|
||||||
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
||||||
http.Error(w, "Bad request", http.StatusBadRequest)
|
errors.WriteError(w, errors.CodeInvalidArgument, "Bad request", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -364,7 +372,8 @@ func activityHandler(w http.ResponseWriter, r *http.Request, db *database.DB) {
|
|||||||
|
|
||||||
activities, err := db.GetOrgActivities(r.Context(), orgID, 50)
|
activities, err := db.GetOrgActivities(r.Context(), orgID, 50)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, "Server error", http.StatusInternalServerError)
|
errors.LogError(r, err, "Failed to get org activities")
|
||||||
|
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -377,7 +386,8 @@ func listMembersHandler(w http.ResponseWriter, r *http.Request, db *database.DB)
|
|||||||
|
|
||||||
members, err := db.GetOrgMembers(r.Context(), orgID)
|
members, err := db.GetOrgMembers(r.Context(), orgID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, "Server error", http.StatusInternalServerError)
|
errors.LogError(r, err, "Failed to get org members")
|
||||||
|
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -390,7 +400,7 @@ func updateMemberRoleHandler(w http.ResponseWriter, r *http.Request, db *databas
|
|||||||
userIDStr := chi.URLParam(r, "userId")
|
userIDStr := chi.URLParam(r, "userId")
|
||||||
userID, err := uuid.Parse(userIDStr)
|
userID, err := uuid.Parse(userIDStr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, "Invalid user ID", http.StatusBadRequest)
|
errors.WriteError(w, errors.CodeInvalidArgument, "Invalid user ID", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -398,12 +408,13 @@ func updateMemberRoleHandler(w http.ResponseWriter, r *http.Request, db *databas
|
|||||||
Role string `json:"role"`
|
Role string `json:"role"`
|
||||||
}
|
}
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
http.Error(w, "Bad request", http.StatusBadRequest)
|
errors.WriteError(w, errors.CodeInvalidArgument, "Bad request", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := db.UpdateMemberRole(r.Context(), orgID, userID, req.Role); err != nil {
|
if err := db.UpdateMemberRole(r.Context(), orgID, userID, req.Role); err != nil {
|
||||||
http.Error(w, "Server error", http.StatusInternalServerError)
|
errors.LogError(r, err, "Failed to update member role")
|
||||||
|
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
|
|
||||||
"go.b0esche.cloud/backend/internal/audit"
|
"go.b0esche.cloud/backend/internal/audit"
|
||||||
"go.b0esche.cloud/backend/internal/database"
|
"go.b0esche.cloud/backend/internal/database"
|
||||||
|
"go.b0esche.cloud/backend/internal/errors"
|
||||||
"go.b0esche.cloud/backend/internal/org"
|
"go.b0esche.cloud/backend/internal/org"
|
||||||
"go.b0esche.cloud/backend/internal/permission"
|
"go.b0esche.cloud/backend/internal/permission"
|
||||||
"go.b0esche.cloud/backend/pkg/jwt"
|
"go.b0esche.cloud/backend/pkg/jwt"
|
||||||
@@ -73,7 +74,7 @@ func Org(db *database.DB, auditLogger *audit.Logger) func(http.Handler) http.Han
|
|||||||
}
|
}
|
||||||
orgID, err := uuid.Parse(orgIDStr)
|
orgID, err := uuid.Parse(orgIDStr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, "Invalid org ID", http.StatusBadRequest)
|
errors.WriteError(w, errors.CodeInvalidArgument, "Invalid org ID", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -85,7 +86,21 @@ func Org(db *database.DB, auditLogger *audit.Logger) func(http.Handler) http.Han
|
|||||||
Success: false,
|
Success: false,
|
||||||
Metadata: map[string]interface{}{"org_id": orgID, "error": err.Error()},
|
Metadata: map[string]interface{}{"org_id": orgID, "error": err.Error()},
|
||||||
})
|
})
|
||||||
http.Error(w, "Forbidden", http.StatusForbidden)
|
errors.LogError(r, err, "Org access denied")
|
||||||
|
errors.WriteError(w, errors.CodePermissionDenied, "Forbidden", http.StatusForbidden)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = org.CheckMembership(r.Context(), db, userID, orgID)
|
||||||
|
if err != nil {
|
||||||
|
auditLogger.Log(r.Context(), audit.Entry{
|
||||||
|
UserID: &userID,
|
||||||
|
Action: "org_access",
|
||||||
|
Success: false,
|
||||||
|
Metadata: map[string]interface{}{"org_id": orgID, "error": err.Error()},
|
||||||
|
})
|
||||||
|
errors.LogError(r, err, "Org access denied")
|
||||||
|
errors.WriteError(w, errors.CodePermissionDenied, "Forbidden", http.StatusForbidden)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -113,7 +128,8 @@ func Permission(db *database.DB, auditLogger *audit.Logger, perm permission.Perm
|
|||||||
Success: false,
|
Success: false,
|
||||||
Metadata: map[string]interface{}{"permission": perm},
|
Metadata: map[string]interface{}{"permission": perm},
|
||||||
})
|
})
|
||||||
http.Error(w, "Forbidden", http.StatusForbidden)
|
errors.LogError(r, err, "Permission denied")
|
||||||
|
errors.WriteError(w, errors.CodePermissionDenied, "Forbidden", http.StatusForbidden)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
22
scripts/dev-all.sh
Executable file
22
scripts/dev-all.sh
Executable file
@@ -0,0 +1,22 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
cd "$(dirname "$0")/.."
|
||||||
|
|
||||||
|
echo "Starting supporting services with Docker Compose..."
|
||||||
|
docker-compose up -d db nextcloud collabora
|
||||||
|
|
||||||
|
echo "Starting backend..."
|
||||||
|
./scripts/dev-backend.sh &
|
||||||
|
BACKEND_PID=$!
|
||||||
|
|
||||||
|
echo "Starting frontend..."
|
||||||
|
./scripts/dev-frontend.sh &
|
||||||
|
FRONTEND_PID=$!
|
||||||
|
|
||||||
|
echo "All services started. Press Ctrl+C to stop."
|
||||||
|
|
||||||
|
trap "kill $BACKEND_PID $FRONTEND_PID; docker-compose down" INT
|
||||||
|
|
||||||
|
wait
|
||||||
15
scripts/dev-backend.sh
Executable file
15
scripts/dev-backend.sh
Executable file
@@ -0,0 +1,15 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
cd "$(dirname "$0")/.."
|
||||||
|
|
||||||
|
cd go_cloud
|
||||||
|
|
||||||
|
if [ ! -f .env ]; then
|
||||||
|
cp .env.example .env
|
||||||
|
echo "Copied .env.example to .env. Please edit it with your settings."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
go run ./cmd/api
|
||||||
11
scripts/dev-frontend.sh
Executable file
11
scripts/dev-frontend.sh
Executable file
@@ -0,0 +1,11 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
cd "$(dirname "$0")/.."
|
||||||
|
|
||||||
|
cd b0esche_cloud
|
||||||
|
|
||||||
|
flutter pub get
|
||||||
|
|
||||||
|
flutter run -d web-server --web-port 3000
|
||||||
Reference in New Issue
Block a user