From e9df8f7d9f31a4154bbb46e4c77ebeefb4cd9990 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Leon=20B=C3=B6sche?= Date: Fri, 9 Jan 2026 17:01:41 +0100 Subject: [PATCH] idle --- b0esche_cloud/lib/injection.dart | 14 +- b0esche_cloud/lib/main.dart | 8 +- b0esche_cloud/lib/models/file_item.dart | 5 + b0esche_cloud/lib/pages/file_explorer.dart | 2 + .../repositories/http_auth_repository.dart | 49 +++ .../repositories/http_file_repository.dart | 73 ++++ b0esche_cloud/lib/services/file_service.dart | 74 +++- go_cloud/internal/database/db.go | 146 +++++++ go_cloud/internal/http/routes.go | 408 +++++++++++++++++- go_cloud/migrations/0003_files.sql | 17 + 10 files changed, 772 insertions(+), 24 deletions(-) create mode 100644 b0esche_cloud/lib/repositories/http_auth_repository.dart create mode 100644 b0esche_cloud/lib/repositories/http_file_repository.dart create mode 100644 go_cloud/migrations/0003_files.sql diff --git a/b0esche_cloud/lib/injection.dart b/b0esche_cloud/lib/injection.dart index 5ee9cc4..6edc1aa 100644 --- a/b0esche_cloud/lib/injection.dart +++ b/b0esche_cloud/lib/injection.dart @@ -1,9 +1,10 @@ import 'package:b0esche_cloud/services/api_client.dart'; import 'package:get_it/get_it.dart'; +import 'blocs/session/session_bloc.dart'; import 'repositories/auth_repository.dart'; import 'repositories/file_repository.dart'; -import 'repositories/mock_auth_repository.dart'; -import 'repositories/mock_file_repository.dart'; +import 'repositories/http_auth_repository.dart'; +import 'repositories/http_file_repository.dart'; import 'services/auth_service.dart'; import 'services/file_service.dart'; import 'viewmodels/login_view_model.dart'; @@ -11,10 +12,11 @@ import 'viewmodels/file_explorer_view_model.dart'; final getIt = GetIt.instance; -void configureDependencies() { - // Register repositories - getIt.registerSingleton(MockAuthRepository()); - getIt.registerSingleton(MockFileRepository()); +void configureDependencies(SessionBloc sessionBloc) { + // Register repositories (HTTP-backed) + final apiClient = ApiClient(sessionBloc); + getIt.registerSingleton(HttpAuthRepository(apiClient)); + getIt.registerSingleton(HttpFileRepository(FileService(apiClient))); // Register services getIt.registerSingleton(AuthService(getIt())); diff --git a/b0esche_cloud/lib/main.dart b/b0esche_cloud/lib/main.dart index 2596c70..b496439 100644 --- a/b0esche_cloud/lib/main.dart +++ b/b0esche_cloud/lib/main.dart @@ -11,6 +11,7 @@ import 'pages/file_explorer.dart'; import 'pages/document_viewer.dart'; import 'pages/editor_page.dart'; import 'theme/app_theme.dart'; +import 'injection.dart'; final GoRouter _router = GoRouter( routes: [ @@ -55,12 +56,15 @@ class _MainAppState extends State { @override void initState() { super.initState(); + // Restore session from persistent storage early so ApiClient has token if present + SessionBloc.restoreSession(_sessionBloc); + // Configure DI to use HTTP repositories + configureDependencies(_sessionBloc); + _authBloc = AuthBloc( apiClient: ApiClient(_sessionBloc), sessionBloc: _sessionBloc, ); - // Restore session from persistent storage - SessionBloc.restoreSession(_sessionBloc); } @override diff --git a/b0esche_cloud/lib/models/file_item.dart b/b0esche_cloud/lib/models/file_item.dart index ed2db3f..2a160dd 100644 --- a/b0esche_cloud/lib/models/file_item.dart +++ b/b0esche_cloud/lib/models/file_item.dart @@ -1,4 +1,5 @@ import 'package:equatable/equatable.dart'; +import 'dart:typed_data'; enum FileType { folder, file } @@ -8,6 +9,8 @@ class FileItem extends Equatable { final FileType type; final int size; // in bytes, 0 for folders final DateTime lastModified; + final String? localPath; // optional local file path for uploads + final Uint8List? bytes; // optional file bytes for web/desktop uploads const FileItem({ required this.name, @@ -15,6 +18,8 @@ class FileItem extends Equatable { required this.type, this.size = 0, required this.lastModified, + this.localPath, + this.bytes, }); @override diff --git a/b0esche_cloud/lib/pages/file_explorer.dart b/b0esche_cloud/lib/pages/file_explorer.dart index f6fba5b..7a70ec7 100644 --- a/b0esche_cloud/lib/pages/file_explorer.dart +++ b/b0esche_cloud/lib/pages/file_explorer.dart @@ -686,6 +686,8 @@ class _FileExplorerState extends State { type: FileType.file, size: file.size, lastModified: DateTime.now(), + localPath: file.path, + bytes: file.bytes, ), ) .toList(); diff --git a/b0esche_cloud/lib/repositories/http_auth_repository.dart b/b0esche_cloud/lib/repositories/http_auth_repository.dart new file mode 100644 index 0000000..ad45ba3 --- /dev/null +++ b/b0esche_cloud/lib/repositories/http_auth_repository.dart @@ -0,0 +1,49 @@ +import '../models/user.dart'; +import '../repositories/auth_repository.dart'; +import '../services/api_client.dart'; + +class HttpAuthRepository implements AuthRepository { + final ApiClient _apiClient; + HttpAuthRepository(this._apiClient); + + @override + Future login(String email, String password) async { + final res = await _apiClient.post('/auth/password-login', data: { + 'username': email, + 'password': password, + }, fromJson: (d) { + final user = d['user']; + return User( + id: user['id'].toString(), + username: user['username'] ?? user['email'], + email: user['email'], + createdAt: DateTime.parse(user['createdAt'] ?? DateTime.now().toIso8601String()), + ); + }); + return res; + } + + @override + Future getCurrentUser() async { + try { + // Attempt to refresh token / get session user info + final res = await _apiClient.post('/auth/refresh', fromJson: (d) => d); + if (res != null && res['user'] != null) { + final user = res['user']; + return User( + id: user['id'].toString(), + username: user['username'] ?? user['email'], + email: user['email'], + createdAt: DateTime.parse(user['createdAt'] ?? DateTime.now().toIso8601String()), + ); + } + } catch (_) {} + return null; + } + + @override + Future logout() async { + // Clear session via client-side session bloc; no server endpoint required here + return; + } +} diff --git a/b0esche_cloud/lib/repositories/http_file_repository.dart b/b0esche_cloud/lib/repositories/http_file_repository.dart new file mode 100644 index 0000000..ce3bd2f --- /dev/null +++ b/b0esche_cloud/lib/repositories/http_file_repository.dart @@ -0,0 +1,73 @@ +import '../models/file_item.dart'; +import '../models/viewer_session.dart'; +import '../models/editor_session.dart'; +import '../models/annotation.dart'; +import '../repositories/file_repository.dart'; +import '../services/file_service.dart'; + +class HttpFileRepository implements FileRepository { + final FileService _fileService; + HttpFileRepository(this._fileService); + + @override + Future> getFiles(String orgId, String path) async { + return await _fileService.getFiles(orgId, path); + } + + @override + Future getFile(String orgId, String path) async { + // Not implemented in API yet; fallback to listing + final files = await getFiles(orgId, path); + for (final f in files) { + if (f.path == path) return f; + } + return null; + } + + @override + Future uploadFile(String orgId, FileItem file) async { + await _fileService.uploadFile(orgId, file); + } + + @override + Future deleteFile(String orgId, String path) async { + await _fileService.deleteFile(orgId, path); + } + + @override + Future createFolder(String orgId, String parentPath, String folderName) async { + await _fileService.createFolder(orgId, parentPath, folderName); + } + + @override + Future moveFile(String orgId, String sourcePath, String targetPath) async { + throw UnimplementedError(); + } + + @override + Future renameFile(String orgId, String path, String newName) async { + throw UnimplementedError(); + } + + @override + Future> searchFiles(String orgId, String query) async { + // Not yet parameterized on API side; fallback to client-side filter + final files = await getFiles(orgId, '/'); + return files.where((f) => f.name.toLowerCase().contains(query.toLowerCase())).toList(); + } + + @override + Future requestViewerSession(String orgId, String fileId) async { + return await _fileService.requestViewerSession(orgId, fileId); + } + + @override + Future requestEditorSession(String orgId, String fileId) async { + return await _fileService.requestEditorSession(orgId, fileId); + } + + @override + Future saveAnnotations(String orgId, String fileId, List annotations) async { + await _fileService.saveAnnotations(orgId, fileId, annotations); + } +} diff --git a/b0esche_cloud/lib/services/file_service.dart b/b0esche_cloud/lib/services/file_service.dart index 792df0e..0abf75f 100644 --- a/b0esche_cloud/lib/services/file_service.dart +++ b/b0esche_cloud/lib/services/file_service.dart @@ -3,6 +3,7 @@ import '../models/viewer_session.dart'; import '../models/editor_session.dart'; import '../models/annotation.dart'; import 'api_client.dart'; +import 'package:dio/dio.dart'; class FileService { final ApiClient _apiClient; @@ -13,8 +14,24 @@ class FileService { if (path.isEmpty) { throw Exception('Path cannot be empty'); } + final pathParam = {'path': path}; + if (orgId.isEmpty) { + return await _apiClient.getList( + '/user/files', + queryParameters: pathParam, + fromJson: (data) => FileItem( + name: data['name'], + path: data['path'], + type: data['type'] == 'file' ? FileType.file : FileType.folder, + size: data['size'], + lastModified: DateTime.parse(data['lastModified']), + ), + ); + } + return await _apiClient.getList( '/orgs/$orgId/files', + queryParameters: pathParam, fromJson: (data) => FileItem( name: data['name'], path: data['path'], @@ -30,11 +47,51 @@ class FileService { } Future uploadFile(String orgId, FileItem file) async { - throw UnimplementedError(); + // If bytes or localPath available, send multipart upload with field 'file' + final Map fields = { + 'path': file.path, + }; + FormData formData; + if (file.bytes != null) { + formData = FormData.fromMap({ + ...fields, + 'file': MultipartFile.fromBytes(file.bytes!, filename: file.name), + }); + } else if (file.localPath != null) { + formData = FormData.fromMap({ + ...fields, + 'file': MultipartFile.fromFile(file.localPath!, filename: file.name), + }); + } else { + // Fallback to metadata-only create (folders or client that can't send file content) + final data = { + 'name': file.name, + 'path': file.path, + 'type': file.type == FileType.file ? 'file' : 'folder', + 'size': file.size, + }; + if (orgId.isEmpty) { + await _apiClient.post('/user/files', data: data, fromJson: (d) => null); + return; + } + await _apiClient.post('/orgs/$orgId/files', data: data, fromJson: (d) => null); + return; + } + + if (orgId.isEmpty) { + await _apiClient.post('/user/files', data: formData, fromJson: (d) => null); + return; + } + await _apiClient.post('/orgs/$orgId/files', data: formData, fromJson: (d) => null); } Future deleteFile(String orgId, String path) async { - throw UnimplementedError(); + final data = {'path': path}; + if (orgId.isEmpty) { + await _apiClient.post('/user/files/delete', data: data, fromJson: (d) => null); + return; + } + await _apiClient.post('/orgs/$orgId/files/delete', data: data, fromJson: (d) => null); } Future createFolder( @@ -42,7 +99,18 @@ class FileService { String parentPath, String folderName, ) async { - throw UnimplementedError(); + final path = parentPath.endsWith('/') ? '$parentPath$folderName' : '$parentPath/$folderName'; + final data = { + 'name': folderName, + 'path': path, + 'type': 'folder', + 'size': 0, + }; + if (orgId.isEmpty) { + await _apiClient.post('/user/files', data: data, fromJson: (d) => null); + return; + } + await _apiClient.post('/orgs/$orgId/files', data: data, fromJson: (d) => null); } Future moveFile( diff --git a/go_cloud/internal/database/db.go b/go_cloud/internal/database/db.go index a5bfd9f..eaae00d 100644 --- a/go_cloud/internal/database/db.go +++ b/go_cloud/internal/database/db.go @@ -78,6 +78,18 @@ type Activity struct { Timestamp time.Time } +type File struct { + ID uuid.UUID + OrgID *uuid.UUID + UserID *uuid.UUID + Name string + Path string + Type string + Size int64 + LastModified time.Time + CreatedAt time.Time +} + func (db *DB) GetOrCreateUser(ctx context.Context, sub, email, name string) (*User, error) { var user User err := db.QueryRowContext(ctx, ` @@ -233,6 +245,140 @@ func (db *DB) GetOrgMembers(ctx context.Context, orgID uuid.UUID) ([]Membership, return memberships, rows.Err() } +// GetOrgFiles returns files for a given organization (top-level folder listing) +func (db *DB) GetOrgFiles(ctx context.Context, orgID uuid.UUID, path string, q string, page, pageSize int) ([]File, error) { + if page <= 0 { + page = 1 + } + if pageSize <= 0 { + pageSize = 100 + } + offset := (page - 1) * pageSize + + // Basic search and pagination. Returns files under the given path (including nested). + rows, err := db.QueryContext(ctx, ` + SELECT id, org_id::text, user_id::text, name, path, type, size, last_modified, created_at + FROM files + WHERE org_id = $1 AND path != $2 AND path LIKE $2 || '/%' + AND ($4 = '' OR name ILIKE '%' || $4 || '%') + ORDER BY name + LIMIT $5 OFFSET $6 + `, orgID, path, path, q, pageSize, offset) + if err != nil { + return nil, err + } + defer rows.Close() + + var files []File + for rows.Next() { + var f File + var orgNull sql.NullString + var userNull sql.NullString + if err := rows.Scan(&f.ID, &orgNull, &userNull, &f.Name, &f.Path, &f.Type, &f.Size, &f.LastModified, &f.CreatedAt); err != nil { + return nil, err + } + if orgNull.Valid { + oid, _ := uuid.Parse(orgNull.String) + f.OrgID = &oid + } + if userNull.Valid { + uid, _ := uuid.Parse(userNull.String) + f.UserID = &uid + } + files = append(files, f) + } + return files, rows.Err() +} + +// GetUserFiles returns files for a user's personal workspace at a given path +func (db *DB) GetUserFiles(ctx context.Context, userID uuid.UUID, path string, q string, page, pageSize int) ([]File, error) { + if page <= 0 { + page = 1 + } + if pageSize <= 0 { + pageSize = 100 + } + offset := (page - 1) * pageSize + + rows, err := db.QueryContext(ctx, ` + SELECT id, org_id::text, user_id::text, name, path, type, size, last_modified, created_at + FROM files + WHERE user_id = $1 AND path != $2 AND path LIKE $2 || '/%' + AND ($4 = '' OR name ILIKE '%' || $4 || '%') + ORDER BY name + LIMIT $5 OFFSET $6 + `, userID, path, path, q, pageSize, offset) + if err != nil { + return nil, err + } + defer rows.Close() + + var files []File + for rows.Next() { + var f File + var orgNull sql.NullString + var userNull sql.NullString + if err := rows.Scan(&f.ID, &orgNull, &userNull, &f.Name, &f.Path, &f.Type, &f.Size, &f.LastModified, &f.CreatedAt); err != nil { + return nil, err + } + if orgNull.Valid { + oid, _ := uuid.Parse(orgNull.String) + f.OrgID = &oid + } + if userNull.Valid { + uid, _ := uuid.Parse(userNull.String) + f.UserID = &uid + } + files = append(files, f) + } + return files, rows.Err() +} + +// CreateFile inserts a file or folder record. orgID or userID may be nil. +func (db *DB) CreateFile(ctx context.Context, orgID *uuid.UUID, userID *uuid.UUID, name, path, fileType string, size int64) (*File, error) { + var f File + var orgIDVal interface{} + var userIDVal interface{} + if orgID != nil { + orgIDVal = *orgID + } else { + orgIDVal = nil + } + if userID != nil { + userIDVal = *userID + } else { + userIDVal = nil + } + + err := db.QueryRowContext(ctx, ` + INSERT INTO files (org_id, user_id, name, path, type, size) + VALUES ($1, $2, $3, $4, $5, $6) + RETURNING id, org_id::text, user_id::text, name, path, type, size, last_modified, created_at + `, orgIDVal, userIDVal, name, path, fileType, size).Scan(&f.ID, new(sql.NullString), new(sql.NullString), &f.Name, &f.Path, &f.Type, &f.Size, &f.LastModified, &f.CreatedAt) + if err != nil { + return nil, err + } + return &f, nil +} + +// DeleteFileByPath removes a file or folder matching path for a given org or user +func (db *DB) DeleteFileByPath(ctx context.Context, orgID *uuid.UUID, userID *uuid.UUID, path string) error { + var res sql.Result + var err error + if orgID != nil { + res, err = db.ExecContext(ctx, `DELETE FROM files WHERE org_id = $1 AND path = $2`, *orgID, path) + } else if userID != nil { + res, err = db.ExecContext(ctx, `DELETE FROM files WHERE user_id = $1 AND path = $2`, *userID, path) + } else { + return nil + } + if err != nil { + return err + } + _, _ = res.RowsAffected() + return nil +} + func (db *DB) UpdateMemberRole(ctx context.Context, orgID, userID uuid.UUID, role string) error { _, err := db.ExecContext(ctx, ` UPDATE memberships diff --git a/go_cloud/internal/http/routes.go b/go_cloud/internal/http/routes.go index 2184e33..f493fa8 100644 --- a/go_cloud/internal/http/routes.go +++ b/go_cloud/internal/http/routes.go @@ -2,9 +2,13 @@ package http import ( "encoding/json" + "io" "net/http" + "os" + "path/filepath" "strings" "time" + "fmt" "go.b0esche.cloud/backend/internal/audit" "go.b0esche.cloud/backend/internal/auth" @@ -64,6 +68,22 @@ func NewRouter(cfg *config.Config, db *database.DB, jwtManager *jwt.Manager, aut r.Route("/", func(r chi.Router) { r.Use(middleware.Auth(jwtManager, db)) + // User-scoped routes (personal workspace) + r.Get("/user/files", func(w http.ResponseWriter, req *http.Request) { + userFilesHandler(w, req, db) + }) + // Create / delete in user workspace + r.Post("/user/files", func(w http.ResponseWriter, req *http.Request) { + createUserFileHandler(w, req, db, auditLogger) + }) + r.Delete("/user/files", func(w http.ResponseWriter, req *http.Request) { + deleteUserFileHandler(w, req, db, auditLogger) + }) + // POST wrapper for delete + r.Post("/user/files/delete", func(w http.ResponseWriter, req *http.Request) { + deleteUserFilePostHandler(w, req, db, auditLogger) + }) + // Org routes r.Get("/orgs", func(w http.ResponseWriter, req *http.Request) { listOrgsHandler(w, req, db, jwtManager) @@ -78,7 +98,21 @@ func NewRouter(cfg *config.Config, db *database.DB, jwtManager *jwt.Manager, aut // File routes r.With(middleware.Permission(db, auditLogger, permission.FileRead)).Get("/files", func(w http.ResponseWriter, req *http.Request) { - listFilesHandler(w, req) + listFilesHandler(w, req, db) + }) + + // Create file/folder in org workspace + r.With(middleware.Permission(db, auditLogger, permission.FileWrite)).Post("/files", func(w http.ResponseWriter, req *http.Request) { + createOrgFileHandler(w, req, db, auditLogger) + }) + // Also accept POST delete for clients that cannot send DELETE with body + r.With(middleware.Permission(db, auditLogger, permission.FileWrite)).Post("/files/delete", func(w http.ResponseWriter, req *http.Request) { + deleteOrgFilePostHandler(w, req, db, auditLogger) + }) + + // Delete file/folder in org workspace (body: {"path":"/path"}) + r.With(middleware.Permission(db, auditLogger, permission.FileWrite)).Delete("/files", func(w http.ResponseWriter, req *http.Request) { + deleteOrgFileHandler(w, req, db, auditLogger) }) r.Route("/files/{fileId}", func(r chi.Router) { r.With(middleware.Permission(db, auditLogger, permission.DocumentView)).Get("/view", func(w http.ResponseWriter, req *http.Request) { @@ -223,21 +257,45 @@ func createOrgHandler(w http.ResponseWriter, r *http.Request, db *database.DB, a json.NewEncoder(w).Encode(org) } -func listFilesHandler(w http.ResponseWriter, r *http.Request) { - // Mock files - files := []struct { - Name string `json:"name"` - Path string `json:"path"` - Type string `json:"type"` - Size int `json:"size"` - LastModified string `json:"lastModified"` - }{ - {"test.pdf", "/test.pdf", "file", 1234, "2023-01-01T00:00:00Z"}, - {"folder", "/folder", "folder", 0, "2023-01-01T00:00:00Z"}, +func listFilesHandler(w http.ResponseWriter, r *http.Request, db *database.DB) { + // Org ID is provided by middleware.Org + orgID := r.Context().Value("org").(uuid.UUID) + // Query params: path, q (search), page, pageSize + path := r.URL.Query().Get("path") + if path == "" { + path = "/" + } + q := r.URL.Query().Get("q") + page := 1 + pageSize := 100 + if p := r.URL.Query().Get("page"); p != "" { + fmt.Sscanf(p, "%d", &page) + } + if ps := r.URL.Query().Get("pageSize"); ps != "" { + fmt.Sscanf(ps, "%d", &pageSize) + } + + files, err := db.GetOrgFiles(r.Context(), orgID, path, q, page, pageSize) + if err != nil { + errors.LogError(r, err, "Failed to get org files") + errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError) + return + } + + // Convert to a JSON-friendly shape expected by frontend + out := make([]map[string]interface{}, 0, len(files)) + for _, f := range files { + out = append(out, map[string]interface{}{ + "name": f.Name, + "path": f.Path, + "type": f.Type, + "size": f.Size, + "lastModified": f.LastModified.UTC().Format(time.RFC3339), + }) } w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(files) + json.NewEncoder(w).Encode(out) } func viewerHandler(w http.ResponseWriter, r *http.Request, db *database.DB, auditLogger *audit.Logger) { @@ -735,3 +793,327 @@ func passwordLoginHandler(w http.ResponseWriter, r *http.Request, db *database.D "user": user, }) } + +// userFilesHandler returns files for the authenticated user's personal workspace. +func userFilesHandler(w http.ResponseWriter, r *http.Request, db *database.DB) { + userIDStr, ok := r.Context().Value("user").(string) + if !ok || userIDStr == "" { + errors.WriteError(w, errors.CodeUnauthenticated, "Unauthorized", http.StatusUnauthorized) + return + } + userID, err := uuid.Parse(userIDStr) + if err != nil { + errors.LogError(r, err, "Invalid user id in context") + errors.WriteError(w, errors.CodeUnauthenticated, "Unauthorized", http.StatusUnauthorized) + return + } + + path := r.URL.Query().Get("path") + if path == "" { + path = "/" + } + q := r.URL.Query().Get("q") + page := 1 + pageSize := 100 + if p := r.URL.Query().Get("page"); p != "" { + fmt.Sscanf(p, "%d", &page) + } + if ps := r.URL.Query().Get("pageSize"); ps != "" { + fmt.Sscanf(ps, "%d", &pageSize) + } + + files, err := db.GetUserFiles(r.Context(), userID, path, q, page, pageSize) + if err != nil { + errors.LogError(r, err, "Failed to get user files") + errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError) + return + } + + out := make([]map[string]interface{}, 0, len(files)) + for _, f := range files { + out = append(out, map[string]interface{}{ + "name": f.Name, + "path": f.Path, + "type": f.Type, + "size": f.Size, + "lastModified": f.LastModified.UTC().Format(time.RFC3339), + }) + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(out) +} + +// createOrgFileHandler creates a file or folder record for an org workspace. +func createOrgFileHandler(w http.ResponseWriter, r *http.Request, db *database.DB, auditLogger *audit.Logger) { + orgID := r.Context().Value("org").(uuid.UUID) + userIDStr, _ := r.Context().Value("user").(string) + userID, _ := uuid.Parse(userIDStr) + + // Support multipart uploads (field "file") or JSON metadata for folders + contentType := r.Header.Get("Content-Type") + if strings.HasPrefix(contentType, "multipart/form-data") { + // Handle file upload + if err := r.ParseMultipartForm(32 << 20); err != nil { + errors.WriteError(w, errors.CodeInvalidArgument, "Bad multipart request", http.StatusBadRequest) + return + } + parentPath := r.FormValue("path") + if parentPath == "" { + parentPath = "/" + } + file, header, err := r.FormFile("file") + if err != nil { + errors.WriteError(w, errors.CodeInvalidArgument, "Missing file", http.StatusBadRequest) + return + } + defer file.Close() + + // Save to disk under data/uploads/orgs// + baseDir := filepath.Join("data", "uploads", "orgs", orgID.String()) + targetDir := filepath.Join(baseDir, parentPath) + if err := os.MkdirAll(targetDir, 0o755); err != nil { + errors.LogError(r, err, "Failed to create target dir") + errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError) + return + } + + outPath := filepath.Join(targetDir, header.Filename) + outFile, err := os.Create(outPath) + if err != nil { + errors.LogError(r, err, "Failed to create file") + errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError) + return + } + defer outFile.Close() + written, err := io.Copy(outFile, file) + if err != nil { + errors.LogError(r, err, "Failed to write file") + errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError) + return + } + + // Store metadata in DB; store path relative to workspace root + storedPath := filepath.ToSlash(filepath.Join(parentPath, header.Filename)) + if !strings.HasPrefix(storedPath, "/") { + storedPath = "/" + storedPath + } + f, err := db.CreateFile(r.Context(), &orgID, &userID, header.Filename, storedPath, "file", written) + if err != nil { + errors.LogError(r, err, "Failed to create org file") + errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError) + return + } + + auditLogger.Log(r.Context(), audit.Entry{ + UserID: &userID, + OrgID: &orgID, + Action: "upload_file", + Success: true, + }) + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{"id": f.ID}) + return + } + + var req struct { + Name string `json:"name"` + Path string `json:"path"` + Type string `json:"type"` // file|folder + Size int64 `json:"size"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + errors.WriteError(w, errors.CodeInvalidArgument, "Bad request", http.StatusBadRequest) + return + } + + f, err := db.CreateFile(r.Context(), &orgID, &userID, req.Name, req.Path, req.Type, req.Size) + if err != nil { + errors.LogError(r, err, "Failed to create org file") + errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError) + return + } + + auditLogger.Log(r.Context(), audit.Entry{ + UserID: &userID, + OrgID: &orgID, + Action: "create_file", + Success: true, + }) + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{"id": f.ID}) +} + +// deleteOrgFileHandler deletes a file/folder in org workspace by path +func deleteOrgFileHandler(w http.ResponseWriter, r *http.Request, db *database.DB, auditLogger *audit.Logger) { + orgID := r.Context().Value("org").(uuid.UUID) + userIDStr, _ := r.Context().Value("user").(string) + userID, _ := uuid.Parse(userIDStr) + + var req struct { + Path string `json:"path"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + errors.WriteError(w, errors.CodeInvalidArgument, "Bad request", http.StatusBadRequest) + return + } + + if err := db.DeleteFileByPath(r.Context(), &orgID, nil, req.Path); err != nil { + errors.LogError(r, err, "Failed to delete org file") + errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError) + return + } + + auditLogger.Log(r.Context(), audit.Entry{ + UserID: &userID, + OrgID: &orgID, + Action: "delete_file", + Success: true, + }) + + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"status":"ok"}`)) +} + +// Also accept POST /orgs/{orgId}/files/delete for clients that cannot send DELETE with body +func deleteOrgFilePostHandler(w http.ResponseWriter, r *http.Request, db *database.DB, auditLogger *audit.Logger) { + deleteOrgFileHandler(w, r, db, auditLogger) +} + +// createUserFileHandler creates a file or folder record for the authenticated user's personal workspace. +func createUserFileHandler(w http.ResponseWriter, r *http.Request, db *database.DB, auditLogger *audit.Logger) { + userIDStr, ok := r.Context().Value("user").(string) + if !ok || userIDStr == "" { + errors.WriteError(w, errors.CodeUnauthenticated, "Unauthorized", http.StatusUnauthorized) + return + } + userID, _ := uuid.Parse(userIDStr) + // Support multipart uploads for file content or JSON for folders + contentType := r.Header.Get("Content-Type") + if strings.HasPrefix(contentType, "multipart/form-data") { + if err := r.ParseMultipartForm(32 << 20); err != nil { + errors.WriteError(w, errors.CodeInvalidArgument, "Bad multipart request", http.StatusBadRequest) + return + } + parentPath := r.FormValue("path") + if parentPath == "" { + parentPath = "/" + } + file, header, err := r.FormFile("file") + if err != nil { + errors.WriteError(w, errors.CodeInvalidArgument, "Missing file", http.StatusBadRequest) + return + } + defer file.Close() + + baseDir := filepath.Join("data", "uploads", "users", userID.String()) + targetDir := filepath.Join(baseDir, parentPath) + if err := os.MkdirAll(targetDir, 0o755); err != nil { + errors.LogError(r, err, "Failed to create target dir") + errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError) + return + } + outPath := filepath.Join(targetDir, header.Filename) + outFile, err := os.Create(outPath) + if err != nil { + errors.LogError(r, err, "Failed to create file") + errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError) + return + } + defer outFile.Close() + written, err := io.Copy(outFile, file) + if err != nil { + errors.LogError(r, err, "Failed to write file") + errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError) + return + } + + storedPath := filepath.ToSlash(filepath.Join(parentPath, header.Filename)) + if !strings.HasPrefix(storedPath, "/") { + storedPath = "/" + storedPath + } + f, err := db.CreateFile(r.Context(), nil, &userID, header.Filename, storedPath, "file", written) + if err != nil { + errors.LogError(r, err, "Failed to create user file") + errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError) + return + } + + auditLogger.Log(r.Context(), audit.Entry{ + UserID: &userID, + Action: "upload_user_file", + Success: true, + }) + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{"id": f.ID}) + return + } + + var req struct { + Name string `json:"name"` + Path string `json:"path"` + Type string `json:"type"` // file|folder + Size int64 `json:"size"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + errors.WriteError(w, errors.CodeInvalidArgument, "Bad request", http.StatusBadRequest) + return + } + + f, err := db.CreateFile(r.Context(), nil, &userID, req.Name, req.Path, req.Type, req.Size) + if err != nil { + errors.LogError(r, err, "Failed to create user file") + errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError) + return + } + + auditLogger.Log(r.Context(), audit.Entry{ + UserID: &userID, + Action: "create_user_file", + Success: true, + }) + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{"id": f.ID}) +} + +// Also accept POST /user/files/delete +func deleteUserFilePostHandler(w http.ResponseWriter, r *http.Request, db *database.DB, auditLogger *audit.Logger) { + deleteUserFileHandler(w, r, db, auditLogger) +} + +// deleteUserFileHandler deletes a file/folder in user's personal workspace by path +func deleteUserFileHandler(w http.ResponseWriter, r *http.Request, db *database.DB, auditLogger *audit.Logger) { + userIDStr, ok := r.Context().Value("user").(string) + if !ok || userIDStr == "" { + errors.WriteError(w, errors.CodeUnauthenticated, "Unauthorized", http.StatusUnauthorized) + return + } + userID, _ := uuid.Parse(userIDStr) + + var req struct { + Path string `json:"path"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + errors.WriteError(w, errors.CodeInvalidArgument, "Bad request", http.StatusBadRequest) + return + } + + if err := db.DeleteFileByPath(r.Context(), nil, &userID, req.Path); err != nil { + errors.LogError(r, err, "Failed to delete user file") + errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError) + return + } + + auditLogger.Log(r.Context(), audit.Entry{ + UserID: &userID, + Action: "delete_user_file", + Success: true, + }) + + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"status":"ok"}`)) +} diff --git a/go_cloud/migrations/0003_files.sql b/go_cloud/migrations/0003_files.sql new file mode 100644 index 0000000..6247b74 --- /dev/null +++ b/go_cloud/migrations/0003_files.sql @@ -0,0 +1,17 @@ +-- Create files table for org and user workspaces + +CREATE TABLE files ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + org_id UUID REFERENCES organizations(id), + user_id UUID REFERENCES users(id), + name TEXT NOT NULL, + path TEXT NOT NULL, + type TEXT NOT NULL, + size BIGINT DEFAULT 0, + last_modified TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); + +CREATE INDEX idx_files_org_id ON files(org_id); +CREATE INDEX idx_files_user_id ON files(user_id); +CREATE INDEX idx_files_path ON files(path);