This commit is contained in:
Leon Bösche
2026-01-09 17:01:41 +01:00
parent 6a0c5780fd
commit e9df8f7d9f
10 changed files with 772 additions and 24 deletions

View File

@@ -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<AuthRepository>(MockAuthRepository());
getIt.registerSingleton<FileRepository>(MockFileRepository());
void configureDependencies(SessionBloc sessionBloc) {
// Register repositories (HTTP-backed)
final apiClient = ApiClient(sessionBloc);
getIt.registerSingleton<AuthRepository>(HttpAuthRepository(apiClient));
getIt.registerSingleton<FileRepository>(HttpFileRepository(FileService(apiClient)));
// Register services
getIt.registerSingleton<AuthService>(AuthService(getIt<AuthRepository>()));

View File

@@ -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<MainApp> {
@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

View File

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

View File

@@ -686,6 +686,8 @@ class _FileExplorerState extends State<FileExplorer> {
type: FileType.file,
size: file.size,
lastModified: DateTime.now(),
localPath: file.path,
bytes: file.bytes,
),
)
.toList();

View File

@@ -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<User> 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<User?> 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<void> logout() async {
// Clear session via client-side session bloc; no server endpoint required here
return;
}
}

View File

@@ -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<List<FileItem>> getFiles(String orgId, String path) async {
return await _fileService.getFiles(orgId, path);
}
@override
Future<FileItem?> 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<void> uploadFile(String orgId, FileItem file) async {
await _fileService.uploadFile(orgId, file);
}
@override
Future<void> deleteFile(String orgId, String path) async {
await _fileService.deleteFile(orgId, path);
}
@override
Future<void> createFolder(String orgId, String parentPath, String folderName) async {
await _fileService.createFolder(orgId, parentPath, folderName);
}
@override
Future<void> moveFile(String orgId, String sourcePath, String targetPath) async {
throw UnimplementedError();
}
@override
Future<void> renameFile(String orgId, String path, String newName) async {
throw UnimplementedError();
}
@override
Future<List<FileItem>> 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<ViewerSession> requestViewerSession(String orgId, String fileId) async {
return await _fileService.requestViewerSession(orgId, fileId);
}
@override
Future<EditorSession> requestEditorSession(String orgId, String fileId) async {
return await _fileService.requestEditorSession(orgId, fileId);
}
@override
Future<void> saveAnnotations(String orgId, String fileId, List<Annotation> annotations) async {
await _fileService.saveAnnotations(orgId, fileId, annotations);
}
}

View File

@@ -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<void> uploadFile(String orgId, FileItem file) async {
throw UnimplementedError();
// If bytes or localPath available, send multipart upload with field 'file'
final Map<String, dynamic> 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<void> 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<void> 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<void> moveFile(

View File

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

View File

@@ -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/<orgId>/<parentPath>
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"}`))
}

View File

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