full stack second commit

This commit is contained in:
Leon Bösche
2025-12-18 00:11:30 +01:00
parent b35adc3d06
commit 87ee5f2ae3
16 changed files with 472 additions and 99 deletions

150
README.md
View File

@@ -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]

View File

@@ -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()),
);
}
} 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(

View File

@@ -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<EditorSessionEvent, EditorSessionState> {
final FileService _fileService;
@@ -15,6 +16,27 @@ class EditorSessionBloc extends Bloc<EditorSessionEvent, EditorSessionState> {
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(
EditorSessionStarted event,
Emitter<EditorSessionState> emit,
@@ -25,19 +47,21 @@ class EditorSessionBloc extends Bloc<EditorSessionEvent, EditorSessionState> {
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()),
() => add(EditorSessionEnded()), // Or emit expired
);
}
} catch (e) {
emit(EditorSessionFailed(message: e.toString()));
emit(EditorSessionFailed(message: _getErrorMessage(e)));
}
}

View File

@@ -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<FileBrowserEvent, FileBrowserState> {
@@ -33,6 +34,27 @@ class FileBrowserBloc extends Bloc<FileBrowserEvent, FileBrowserState> {
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(
LoadDirectory event,
Emitter<FileBrowserState> emit,
@@ -52,7 +74,7 @@ class FileBrowserBloc extends Bloc<FileBrowserEvent, FileBrowserState> {
_emitLoadedState(emit);
}
} 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));
}
} catch (e) {
emit(DirectoryError(e.toString()));
emit(DirectoryError(_getErrorMessage(e)));
}
}
@@ -143,7 +165,7 @@ class FileBrowserBloc extends Bloc<FileBrowserEvent, FileBrowserState> {
_emitLoadedState(emit);
}
} catch (e) {
emit(DirectoryError(e.toString()));
emit(DirectoryError(_getErrorMessage(e)));
}
}
@@ -163,7 +185,7 @@ class FileBrowserBloc extends Bloc<FileBrowserEvent, FileBrowserState> {
_emitLoadedState(emit);
}
} catch (e) {
emit(DirectoryError(e.toString()));
emit(DirectoryError(_getErrorMessage(e)));
}
}
@@ -176,7 +198,7 @@ class FileBrowserBloc extends Bloc<FileBrowserEvent, FileBrowserState> {
.toList();
_emitLoadedState(emit);
} catch (e) {
emit(DirectoryError(e.toString()));
emit(DirectoryError(_getErrorMessage(e)));
}
}
@@ -219,7 +241,7 @@ class FileBrowserBloc extends Bloc<FileBrowserEvent, FileBrowserState> {
_emitLoadedState(emit);
}
} catch (e) {
emit(DirectoryError(e.toString()));
emit(DirectoryError(_getErrorMessage(e)));
}
}

View File

@@ -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<OrganizationEvent, OrganizationState> {
final PermissionBloc permissionBloc;
@@ -27,6 +28,27 @@ class OrganizationBloc extends Bloc<OrganizationEvent, OrganizationState> {
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(
LoadOrganizations event,
Emitter<OrganizationState> emit,
@@ -41,7 +63,7 @@ class OrganizationBloc extends Bloc<OrganizationEvent, OrganizationState> {
),
);
} catch (e) {
emit(OrganizationError(e.toString()));
emit(OrganizationError(_getErrorMessage(e)));
}
}
@@ -124,7 +146,7 @@ class OrganizationBloc extends Bloc<OrganizationEvent, OrganizationState> {
organizations: currentState.organizations,
selectedOrg: currentState.selectedOrg,
isLoading: false,
error: e.toString(),
error: _getErrorMessage(e),
),
);
}

View File

@@ -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<PdfAnnotationEvent, PdfAnnotationState> {
final FileService _fileService;
@@ -22,6 +23,20 @@ class PdfAnnotationBloc extends Bloc<PdfAnnotationEvent, PdfAnnotationState> {
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(
AnnotationToolSelected event,
Emitter<PdfAnnotationState> emit,
@@ -107,7 +122,7 @@ class PdfAnnotationBloc extends Bloc<PdfAnnotationEvent, PdfAnnotationState> {
// Reset to idle or editing?
emit(PdfAnnotationIdle());
} catch (e) {
emit(PdfAnnotationError(message: e.toString()));
emit(PdfAnnotationError(message: _getErrorMessage(e)));
}
}
}

View File

@@ -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>(AuthService(getIt<AuthRepository>()));
getIt.registerSingleton<FileService>(FileService(getIt<FileRepository>()));
getIt.registerSingleton<FileService>(FileService(getIt<ApiClient>()));
// Register viewmodels
getIt.registerSingleton<LoginViewModel>(LoginViewModel(getIt<AuthService>()));

View File

@@ -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);
}
}

View File

@@ -20,15 +20,6 @@ class MockFileService extends Mock implements FileService {
_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 ??
@@ -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() {
late MockFileService mockFileService;

View File

@@ -12,3 +12,11 @@ OIDC_CLIENT_SECRET=your_client_secret
# JWT
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

View 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)
}

View File

@@ -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
}
@@ -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
}

View File

@@ -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
}

22
scripts/dev-all.sh Executable file
View 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
View 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
View 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