From 87ee5f2ae3eaa838058f6922d5afd7ee8c7ac466 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Leon=20B=C3=B6sche?= Date: Thu, 18 Dec 2025 00:11:30 +0100 Subject: [PATCH] full stack second commit --- README.md | 150 +++++++++++++++++- .../document_viewer/document_viewer_bloc.dart | 23 ++- .../editor_session/editor_session_bloc.dart | 38 ++++- .../blocs/file_browser/file_browser_bloc.dart | 34 +++- .../blocs/organization/organization_bloc.dart | 26 ++- .../pdf_annotation/pdf_annotation_bloc.dart | 17 +- b0esche_cloud/lib/injection.dart | 3 +- b0esche_cloud/lib/services/api_client.dart | 39 ++--- .../test/document_viewer_bloc_test.dart | 19 +-- go_cloud/.env.example | 10 +- go_cloud/internal/errors/errors.go | 73 +++++++++ go_cloud/internal/http/routes.go | 69 ++++---- go_cloud/internal/middleware/middleware.go | 22 ++- scripts/dev-all.sh | 22 +++ scripts/dev-backend.sh | 15 ++ scripts/dev-frontend.sh | 11 ++ 16 files changed, 472 insertions(+), 99 deletions(-) create mode 100644 go_cloud/internal/errors/errors.go create mode 100755 scripts/dev-all.sh create mode 100755 scripts/dev-backend.sh create mode 100755 scripts/dev-frontend.sh diff --git a/README.md b/README.md index 8ae0569..59d8299 100644 --- a/README.md +++ b/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] diff --git a/b0esche_cloud/lib/blocs/document_viewer/document_viewer_bloc.dart b/b0esche_cloud/lib/blocs/document_viewer/document_viewer_bloc.dart index 350993f..b339b07 100644 --- a/b0esche_cloud/lib/blocs/document_viewer/document_viewer_bloc.dart +++ b/b0esche_cloud/lib/blocs/document_viewer/document_viewer_bloc.dart @@ -26,41 +26,40 @@ class DocumentViewerBloc event.orgId, event.fileId, ); + if (session.expiresAt.isBefore(DateTime.now())) { + emit(DocumentViewerSessionExpired()); + return; + } emit( DocumentViewerReady( viewUrl: session.viewUrl, caps: session.capabilities, ), ); - if (session.expiresAt.isAfter(DateTime.now())) { - _expiryTimer = Timer( - session.expiresAt.difference(DateTime.now()), - () => emit(DocumentViewerSessionExpired()), - ); - } + _expiryTimer = Timer( + session.expiresAt.difference(DateTime.now()), + () => emit(DocumentViewerSessionExpired()), + ); } catch (e) { if (e is ApiError) { switch (e.code) { - case 'unauthorized': + case 'UNAUTHENTICATED': // Already handled by ApiClient break; - case 'permission_denied': + case 'PERMISSION_DENIED': emit( DocumentViewerError( message: 'You don\'t have access to this document.', ), ); break; - case 'not_found': + case 'NOT_FOUND': emit( DocumentViewerError( message: 'This document no longer exists or was moved.', ), ); break; - case 'not_found': - emit(DocumentViewerError(message: 'Document not found')); - break; default: emit( DocumentViewerError( diff --git a/b0esche_cloud/lib/blocs/editor_session/editor_session_bloc.dart b/b0esche_cloud/lib/blocs/editor_session/editor_session_bloc.dart index e3174f1..03d941b 100644 --- a/b0esche_cloud/lib/blocs/editor_session/editor_session_bloc.dart +++ b/b0esche_cloud/lib/blocs/editor_session/editor_session_bloc.dart @@ -3,6 +3,7 @@ import 'package:bloc/bloc.dart'; import 'editor_session_event.dart'; import 'editor_session_state.dart'; import '../../services/file_service.dart'; +import '../../models/api_error.dart'; class EditorSessionBloc extends Bloc { final FileService _fileService; @@ -15,6 +16,27 @@ class EditorSessionBloc extends Bloc { on(_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( EditorSessionStarted event, Emitter emit, @@ -25,19 +47,21 @@ class EditorSessionBloc extends Bloc { event.orgId, event.fileId, ); + if (session.expiresAt.isBefore(DateTime.now())) { + emit(EditorSessionExpired()); + return; + } if (!session.readOnly) { emit(EditorSessionActive(editUrl: session.editUrl)); } else { emit(EditorSessionReadOnly(viewUrl: session.editUrl)); } - if (session.expiresAt.isAfter(DateTime.now())) { - _expiryTimer = Timer( - session.expiresAt.difference(DateTime.now()), - () => emit(EditorSessionExpired()), - ); - } + _expiryTimer = Timer( + session.expiresAt.difference(DateTime.now()), + () => add(EditorSessionEnded()), // Or emit expired + ); } catch (e) { - emit(EditorSessionFailed(message: e.toString())); + emit(EditorSessionFailed(message: _getErrorMessage(e))); } } diff --git a/b0esche_cloud/lib/blocs/file_browser/file_browser_bloc.dart b/b0esche_cloud/lib/blocs/file_browser/file_browser_bloc.dart index ca066eb..5b671ba 100644 --- a/b0esche_cloud/lib/blocs/file_browser/file_browser_bloc.dart +++ b/b0esche_cloud/lib/blocs/file_browser/file_browser_bloc.dart @@ -3,6 +3,7 @@ import 'file_browser_event.dart'; import 'file_browser_state.dart'; import '../../services/file_service.dart'; import '../../models/file_item.dart'; +import '../../models/api_error.dart'; import 'package:path/path.dart' as p; class FileBrowserBloc extends Bloc { @@ -33,6 +34,27 @@ class FileBrowserBloc extends Bloc { on(_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( LoadDirectory event, Emitter emit, @@ -52,7 +74,7 @@ class FileBrowserBloc extends Bloc { _emitLoadedState(emit); } } catch (e) { - emit(DirectoryError(e.toString())); + emit(DirectoryError(_getErrorMessage(e))); } } @@ -120,7 +142,7 @@ class FileBrowserBloc extends Bloc { add(LoadDirectory(orgId: event.orgId, path: event.parentPath)); } } catch (e) { - emit(DirectoryError(e.toString())); + emit(DirectoryError(_getErrorMessage(e))); } } @@ -143,7 +165,7 @@ class FileBrowserBloc extends Bloc { _emitLoadedState(emit); } } catch (e) { - emit(DirectoryError(e.toString())); + emit(DirectoryError(_getErrorMessage(e))); } } @@ -163,7 +185,7 @@ class FileBrowserBloc extends Bloc { _emitLoadedState(emit); } } catch (e) { - emit(DirectoryError(e.toString())); + emit(DirectoryError(_getErrorMessage(e))); } } @@ -176,7 +198,7 @@ class FileBrowserBloc extends Bloc { .toList(); _emitLoadedState(emit); } catch (e) { - emit(DirectoryError(e.toString())); + emit(DirectoryError(_getErrorMessage(e))); } } @@ -219,7 +241,7 @@ class FileBrowserBloc extends Bloc { _emitLoadedState(emit); } } catch (e) { - emit(DirectoryError(e.toString())); + emit(DirectoryError(_getErrorMessage(e))); } } diff --git a/b0esche_cloud/lib/blocs/organization/organization_bloc.dart b/b0esche_cloud/lib/blocs/organization/organization_bloc.dart index b801bbf..c6ad4be 100644 --- a/b0esche_cloud/lib/blocs/organization/organization_bloc.dart +++ b/b0esche_cloud/lib/blocs/organization/organization_bloc.dart @@ -9,6 +9,7 @@ import '../file_browser/file_browser_event.dart'; import '../upload/upload_bloc.dart'; import '../upload/upload_event.dart'; import '../../services/org_api.dart'; +import '../../models/api_error.dart'; class OrganizationBloc extends Bloc { final PermissionBloc permissionBloc; @@ -27,6 +28,27 @@ class OrganizationBloc extends Bloc { on(_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( LoadOrganizations event, Emitter emit, @@ -41,7 +63,7 @@ class OrganizationBloc extends Bloc { ), ); } catch (e) { - emit(OrganizationError(e.toString())); + emit(OrganizationError(_getErrorMessage(e))); } } @@ -124,7 +146,7 @@ class OrganizationBloc extends Bloc { organizations: currentState.organizations, selectedOrg: currentState.selectedOrg, isLoading: false, - error: e.toString(), + error: _getErrorMessage(e), ), ); } diff --git a/b0esche_cloud/lib/blocs/pdf_annotation/pdf_annotation_bloc.dart b/b0esche_cloud/lib/blocs/pdf_annotation/pdf_annotation_bloc.dart index 444b7f0..285dda2 100644 --- a/b0esche_cloud/lib/blocs/pdf_annotation/pdf_annotation_bloc.dart +++ b/b0esche_cloud/lib/blocs/pdf_annotation/pdf_annotation_bloc.dart @@ -3,6 +3,7 @@ import 'pdf_annotation_event.dart'; import 'pdf_annotation_state.dart'; import '../../services/file_service.dart'; import '../../models/annotation.dart'; +import '../../models/api_error.dart'; class PdfAnnotationBloc extends Bloc { final FileService _fileService; @@ -22,6 +23,20 @@ class PdfAnnotationBloc extends Bloc { on(_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( AnnotationToolSelected event, Emitter emit, @@ -107,7 +122,7 @@ class PdfAnnotationBloc extends Bloc { // Reset to idle or editing? emit(PdfAnnotationIdle()); } catch (e) { - emit(PdfAnnotationError(message: e.toString())); + emit(PdfAnnotationError(message: _getErrorMessage(e))); } } } diff --git a/b0esche_cloud/lib/injection.dart b/b0esche_cloud/lib/injection.dart index 6ecdc78..5ee9cc4 100644 --- a/b0esche_cloud/lib/injection.dart +++ b/b0esche_cloud/lib/injection.dart @@ -1,3 +1,4 @@ +import 'package:b0esche_cloud/services/api_client.dart'; import 'package:get_it/get_it.dart'; import 'repositories/auth_repository.dart'; import 'repositories/file_repository.dart'; @@ -17,7 +18,7 @@ void configureDependencies() { // Register services getIt.registerSingleton(AuthService(getIt())); - getIt.registerSingleton(FileService(getIt())); + getIt.registerSingleton(FileService(getIt())); // Register viewmodels getIt.registerSingleton(LoginViewModel(getIt())); diff --git a/b0esche_cloud/lib/services/api_client.dart b/b0esche_cloud/lib/services/api_client.dart index 01d7c3c..bd4c9ce 100644 --- a/b0esche_cloud/lib/services/api_client.dart +++ b/b0esche_cloud/lib/services/api_client.dart @@ -118,36 +118,21 @@ class ApiClient { ApiError _handleError(DioException e) { final status = e.response?.statusCode; 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( - code: 'permission_denied', - message: 'Access denied', - 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', + code: 'NETWORK_ERROR', + message: 'Network error. Please check your connection and try again.', status: status, ); } + + String code = data?['code'] ?? 'UNKNOWN'; + String message = data?['message'] ?? 'Unknown error'; + return ApiError(code: code, message: message, status: status); } } diff --git a/b0esche_cloud/test/document_viewer_bloc_test.dart b/b0esche_cloud/test/document_viewer_bloc_test.dart index d877fef..e044fa5 100644 --- a/b0esche_cloud/test/document_viewer_bloc_test.dart +++ b/b0esche_cloud/test/document_viewer_bloc_test.dart @@ -20,15 +20,6 @@ class MockFileService extends Mock implements FileService { _viewerResponse = null; } - @override - Future requestViewerSession(String orgId, String fileId) { - return _viewerResponse ?? super.noSuchMethod( - Invocation.method(#requestViewerSession, [orgId, fileId]), - returnValue: Future.value(null), - ); - } -} - @override Future requestViewerSession(String orgId, String fileId) { return _viewerResponse ?? @@ -39,6 +30,16 @@ class MockFileService extends Mock implements FileService { } } +// @override +// Future requestViewerSession(String orgId, String fileId) { +// return _viewerResponse ?? +// super.noSuchMethod( +// Invocation.method(#requestViewerSession, [orgId, fileId]), +// returnValue: Future.value(null), +// ); +// } +// } + void main() { late MockFileService mockFileService; diff --git a/go_cloud/.env.example b/go_cloud/.env.example index 48873e3..07f4019 100644 --- a/go_cloud/.env.example +++ b/go_cloud/.env.example @@ -11,4 +11,12 @@ OIDC_CLIENT_ID=your_client_id OIDC_CLIENT_SECRET=your_client_secret # JWT -JWT_SECRET=your_jwt_secret_key \ No newline at end of file +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 \ No newline at end of file diff --git a/go_cloud/internal/errors/errors.go b/go_cloud/internal/errors/errors.go new file mode 100644 index 0000000..02289a3 --- /dev/null +++ b/go_cloud/internal/errors/errors.go @@ -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) +} diff --git a/go_cloud/internal/http/routes.go b/go_cloud/internal/http/routes.go index 02bfe2d..8f7a00b 100644 --- a/go_cloud/internal/http/routes.go +++ b/go_cloud/internal/http/routes.go @@ -9,6 +9,7 @@ import ( "go.b0esche.cloud/backend/internal/auth" "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/org" "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) { state, err := auth.GenerateState() 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 } @@ -121,14 +123,16 @@ func authCallbackHandler(w http.ResponseWriter, r *http.Request, cfg *config.Con Success: false, 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 } // Get user orgs orgs, err := org.ResolveUserOrgs(r.Context(), db, user.ID) 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 } 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()) 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 } @@ -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) { authHeader := r.Header.Get("Authorization") if !strings.HasPrefix(authHeader, "Bearer ") { - http.Error(w, "Unauthorized", http.StatusUnauthorized) + errors.WriteError(w, errors.CodeUnauthenticated, "Unauthorized", http.StatusUnauthorized) return } tokenString := strings.TrimPrefix(authHeader, "Bearer ") claims, session, err := jwtManager.ValidateWithSession(r.Context(), tokenString, db) if err != nil { - http.Error(w, "Unauthorized", http.StatusUnauthorized) + errors.LogError(r, err, "Invalid token") + errors.WriteError(w, errors.CodeUnauthenticated, "Unauthorized", http.StatusUnauthorized) return } userID, _ := uuid.Parse(claims.UserID) orgs, err := db.GetUserOrganizations(r.Context(), userID) 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 } 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()) 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 } @@ -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) { authHeader := r.Header.Get("Authorization") if !strings.HasPrefix(authHeader, "Bearer ") { - http.Error(w, "Unauthorized", http.StatusUnauthorized) + errors.WriteError(w, errors.CodeUnauthenticated, "Unauthorized", http.StatusUnauthorized) return } tokenString := strings.TrimPrefix(authHeader, "Bearer ") claims, _, err := jwtManager.ValidateWithSession(r.Context(), tokenString, db) if err != nil { - http.Error(w, "Unauthorized", http.StatusUnauthorized) + errors.LogError(r, err, "Invalid token") + errors.WriteError(w, errors.CodeUnauthenticated, "Unauthorized", http.StatusUnauthorized) return } userID, _ := uuid.Parse(claims.UserID) orgs, err := org.ResolveUserOrgs(r.Context(), db, userID) 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 } @@ -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) { authHeader := r.Header.Get("Authorization") if !strings.HasPrefix(authHeader, "Bearer ") { - http.Error(w, "Unauthorized", http.StatusUnauthorized) + errors.WriteError(w, errors.CodeUnauthenticated, "Unauthorized", http.StatusUnauthorized) return } tokenString := strings.TrimPrefix(authHeader, "Bearer ") claims, _, err := jwtManager.ValidateWithSession(r.Context(), tokenString, db) if err != nil { - http.Error(w, "Unauthorized", http.StatusUnauthorized) + errors.LogError(r, err, "Invalid token") + errors.WriteError(w, errors.CodeUnauthenticated, "Unauthorized", http.StatusUnauthorized) return } @@ -233,13 +244,14 @@ func createOrgHandler(w http.ResponseWriter, r *http.Request, db *database.DB, a Slug string `json:"slug,omitempty"` } 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 } org, err := org.CreateOrg(r.Context(), db, userID, req.Name, req.Slug) 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 } @@ -283,17 +295,17 @@ func viewerHandler(w http.ResponseWriter, r *http.Request, db *database.DB, audi session := struct { ViewUrl string `json:"viewUrl"` Capabilities struct { - CanEdit bool `json:"canEdit"` + CanEdit bool `json:"canEdit"` CanAnnotate bool `json:"canAnnotate"` - IsPdf bool `json:"isPdf"` + IsPdf bool `json:"isPdf"` } `json:"capabilities"` ExpiresAt string `json:"expiresAt"` }{ ViewUrl: "https://view.example.com/" + fileId, Capabilities: struct { - CanEdit bool `json:"canEdit"` + CanEdit bool `json:"canEdit"` CanAnnotate bool `json:"canAnnotate"` - IsPdf bool `json:"isPdf"` + IsPdf bool `json:"isPdf"` }{CanEdit: true, CanAnnotate: true, IsPdf: true}, 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) } - 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) { userIDStr := r.Context().Value("user").(string) userID, _ := uuid.Parse(userIDStr) @@ -341,7 +349,7 @@ func annotationsHandler(w http.ResponseWriter, r *http.Request, db *database.DB, BaseVersionId string `json:"baseVersionId"` } 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 } @@ -364,7 +372,8 @@ func activityHandler(w http.ResponseWriter, r *http.Request, db *database.DB) { activities, err := db.GetOrgActivities(r.Context(), orgID, 50) 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 } @@ -377,7 +386,8 @@ func listMembersHandler(w http.ResponseWriter, r *http.Request, db *database.DB) members, err := db.GetOrgMembers(r.Context(), orgID) 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 } @@ -390,7 +400,7 @@ func updateMemberRoleHandler(w http.ResponseWriter, r *http.Request, db *databas userIDStr := chi.URLParam(r, "userId") userID, err := uuid.Parse(userIDStr) if err != nil { - http.Error(w, "Invalid user ID", http.StatusBadRequest) + errors.WriteError(w, errors.CodeInvalidArgument, "Invalid user ID", http.StatusBadRequest) return } @@ -398,12 +408,13 @@ func updateMemberRoleHandler(w http.ResponseWriter, r *http.Request, db *databas Role string `json:"role"` } 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 } 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 } diff --git a/go_cloud/internal/middleware/middleware.go b/go_cloud/internal/middleware/middleware.go index 51aa896..c64d235 100644 --- a/go_cloud/internal/middleware/middleware.go +++ b/go_cloud/internal/middleware/middleware.go @@ -7,6 +7,7 @@ import ( "go.b0esche.cloud/backend/internal/audit" "go.b0esche.cloud/backend/internal/database" + "go.b0esche.cloud/backend/internal/errors" "go.b0esche.cloud/backend/internal/org" "go.b0esche.cloud/backend/internal/permission" "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) if err != nil { - http.Error(w, "Invalid org ID", http.StatusBadRequest) + errors.WriteError(w, errors.CodeInvalidArgument, "Invalid org ID", http.StatusBadRequest) return } @@ -85,7 +86,21 @@ func Org(db *database.DB, auditLogger *audit.Logger) func(http.Handler) http.Han Success: false, 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 } @@ -113,7 +128,8 @@ func Permission(db *database.DB, auditLogger *audit.Logger, perm permission.Perm Success: false, 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 } diff --git a/scripts/dev-all.sh b/scripts/dev-all.sh new file mode 100755 index 0000000..118dc64 --- /dev/null +++ b/scripts/dev-all.sh @@ -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 \ No newline at end of file diff --git a/scripts/dev-backend.sh b/scripts/dev-backend.sh new file mode 100755 index 0000000..a01d92c --- /dev/null +++ b/scripts/dev-backend.sh @@ -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 \ No newline at end of file diff --git a/scripts/dev-frontend.sh b/scripts/dev-frontend.sh new file mode 100755 index 0000000..db8c22d --- /dev/null +++ b/scripts/dev-frontend.sh @@ -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 \ No newline at end of file