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.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(
|
||||
|
||||
@@ -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()),
|
||||
);
|
||||
}
|
||||
_expiryTimer = Timer(
|
||||
session.expiresAt.difference(DateTime.now()),
|
||||
() => add(EditorSessionEnded()), // Or emit expired
|
||||
);
|
||||
} 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 '../../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)));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>()));
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -11,4 +11,12 @@ OIDC_CLIENT_ID=your_client_id
|
||||
OIDC_CLIENT_SECRET=your_client_secret
|
||||
|
||||
# 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/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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
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