idle
This commit is contained in:
@@ -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>()));
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
49
b0esche_cloud/lib/repositories/http_auth_repository.dart
Normal file
49
b0esche_cloud/lib/repositories/http_auth_repository.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
73
b0esche_cloud/lib/repositories/http_file_repository.dart
Normal file
73
b0esche_cloud/lib/repositories/http_file_repository.dart
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"}`))
|
||||
}
|
||||
|
||||
17
go_cloud/migrations/0003_files.sql
Normal file
17
go_cloud/migrations/0003_files.sql
Normal 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);
|
||||
Reference in New Issue
Block a user