idle
This commit is contained in:
@@ -1,9 +1,10 @@
|
|||||||
import 'package:b0esche_cloud/services/api_client.dart';
|
import 'package:b0esche_cloud/services/api_client.dart';
|
||||||
import 'package:get_it/get_it.dart';
|
import 'package:get_it/get_it.dart';
|
||||||
|
import 'blocs/session/session_bloc.dart';
|
||||||
import 'repositories/auth_repository.dart';
|
import 'repositories/auth_repository.dart';
|
||||||
import 'repositories/file_repository.dart';
|
import 'repositories/file_repository.dart';
|
||||||
import 'repositories/mock_auth_repository.dart';
|
import 'repositories/http_auth_repository.dart';
|
||||||
import 'repositories/mock_file_repository.dart';
|
import 'repositories/http_file_repository.dart';
|
||||||
import 'services/auth_service.dart';
|
import 'services/auth_service.dart';
|
||||||
import 'services/file_service.dart';
|
import 'services/file_service.dart';
|
||||||
import 'viewmodels/login_view_model.dart';
|
import 'viewmodels/login_view_model.dart';
|
||||||
@@ -11,10 +12,11 @@ import 'viewmodels/file_explorer_view_model.dart';
|
|||||||
|
|
||||||
final getIt = GetIt.instance;
|
final getIt = GetIt.instance;
|
||||||
|
|
||||||
void configureDependencies() {
|
void configureDependencies(SessionBloc sessionBloc) {
|
||||||
// Register repositories
|
// Register repositories (HTTP-backed)
|
||||||
getIt.registerSingleton<AuthRepository>(MockAuthRepository());
|
final apiClient = ApiClient(sessionBloc);
|
||||||
getIt.registerSingleton<FileRepository>(MockFileRepository());
|
getIt.registerSingleton<AuthRepository>(HttpAuthRepository(apiClient));
|
||||||
|
getIt.registerSingleton<FileRepository>(HttpFileRepository(FileService(apiClient)));
|
||||||
|
|
||||||
// Register services
|
// Register services
|
||||||
getIt.registerSingleton<AuthService>(AuthService(getIt<AuthRepository>()));
|
getIt.registerSingleton<AuthService>(AuthService(getIt<AuthRepository>()));
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import 'pages/file_explorer.dart';
|
|||||||
import 'pages/document_viewer.dart';
|
import 'pages/document_viewer.dart';
|
||||||
import 'pages/editor_page.dart';
|
import 'pages/editor_page.dart';
|
||||||
import 'theme/app_theme.dart';
|
import 'theme/app_theme.dart';
|
||||||
|
import 'injection.dart';
|
||||||
|
|
||||||
final GoRouter _router = GoRouter(
|
final GoRouter _router = GoRouter(
|
||||||
routes: [
|
routes: [
|
||||||
@@ -55,12 +56,15 @@ class _MainAppState extends State<MainApp> {
|
|||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.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(
|
_authBloc = AuthBloc(
|
||||||
apiClient: ApiClient(_sessionBloc),
|
apiClient: ApiClient(_sessionBloc),
|
||||||
sessionBloc: _sessionBloc,
|
sessionBloc: _sessionBloc,
|
||||||
);
|
);
|
||||||
// Restore session from persistent storage
|
|
||||||
SessionBloc.restoreSession(_sessionBloc);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import 'package:equatable/equatable.dart';
|
import 'package:equatable/equatable.dart';
|
||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
enum FileType { folder, file }
|
enum FileType { folder, file }
|
||||||
|
|
||||||
@@ -8,6 +9,8 @@ class FileItem extends Equatable {
|
|||||||
final FileType type;
|
final FileType type;
|
||||||
final int size; // in bytes, 0 for folders
|
final int size; // in bytes, 0 for folders
|
||||||
final DateTime lastModified;
|
final DateTime lastModified;
|
||||||
|
final String? localPath; // optional local file path for uploads
|
||||||
|
final Uint8List? bytes; // optional file bytes for web/desktop uploads
|
||||||
|
|
||||||
const FileItem({
|
const FileItem({
|
||||||
required this.name,
|
required this.name,
|
||||||
@@ -15,6 +18,8 @@ class FileItem extends Equatable {
|
|||||||
required this.type,
|
required this.type,
|
||||||
this.size = 0,
|
this.size = 0,
|
||||||
required this.lastModified,
|
required this.lastModified,
|
||||||
|
this.localPath,
|
||||||
|
this.bytes,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|||||||
@@ -686,6 +686,8 @@ class _FileExplorerState extends State<FileExplorer> {
|
|||||||
type: FileType.file,
|
type: FileType.file,
|
||||||
size: file.size,
|
size: file.size,
|
||||||
lastModified: DateTime.now(),
|
lastModified: DateTime.now(),
|
||||||
|
localPath: file.path,
|
||||||
|
bytes: file.bytes,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.toList();
|
.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/editor_session.dart';
|
||||||
import '../models/annotation.dart';
|
import '../models/annotation.dart';
|
||||||
import 'api_client.dart';
|
import 'api_client.dart';
|
||||||
|
import 'package:dio/dio.dart';
|
||||||
|
|
||||||
class FileService {
|
class FileService {
|
||||||
final ApiClient _apiClient;
|
final ApiClient _apiClient;
|
||||||
@@ -13,8 +14,24 @@ class FileService {
|
|||||||
if (path.isEmpty) {
|
if (path.isEmpty) {
|
||||||
throw Exception('Path cannot be empty');
|
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(
|
return await _apiClient.getList(
|
||||||
'/orgs/$orgId/files',
|
'/orgs/$orgId/files',
|
||||||
|
queryParameters: pathParam,
|
||||||
fromJson: (data) => FileItem(
|
fromJson: (data) => FileItem(
|
||||||
name: data['name'],
|
name: data['name'],
|
||||||
path: data['path'],
|
path: data['path'],
|
||||||
@@ -30,11 +47,51 @@ class FileService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> uploadFile(String orgId, FileItem file) async {
|
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 {
|
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(
|
Future<void> createFolder(
|
||||||
@@ -42,7 +99,18 @@ class FileService {
|
|||||||
String parentPath,
|
String parentPath,
|
||||||
String folderName,
|
String folderName,
|
||||||
) async {
|
) 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(
|
Future<void> moveFile(
|
||||||
|
|||||||
@@ -78,6 +78,18 @@ type Activity struct {
|
|||||||
Timestamp time.Time
|
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) {
|
func (db *DB) GetOrCreateUser(ctx context.Context, sub, email, name string) (*User, error) {
|
||||||
var user User
|
var user User
|
||||||
err := db.QueryRowContext(ctx, `
|
err := db.QueryRowContext(ctx, `
|
||||||
@@ -233,6 +245,140 @@ func (db *DB) GetOrgMembers(ctx context.Context, orgID uuid.UUID) ([]Membership,
|
|||||||
return memberships, rows.Err()
|
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 {
|
func (db *DB) UpdateMemberRole(ctx context.Context, orgID, userID uuid.UUID, role string) error {
|
||||||
_, err := db.ExecContext(ctx, `
|
_, err := db.ExecContext(ctx, `
|
||||||
UPDATE memberships
|
UPDATE memberships
|
||||||
|
|||||||
@@ -2,9 +2,13 @@ package http
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
"go.b0esche.cloud/backend/internal/audit"
|
"go.b0esche.cloud/backend/internal/audit"
|
||||||
"go.b0esche.cloud/backend/internal/auth"
|
"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.Route("/", func(r chi.Router) {
|
||||||
r.Use(middleware.Auth(jwtManager, db))
|
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
|
// Org routes
|
||||||
r.Get("/orgs", func(w http.ResponseWriter, req *http.Request) {
|
r.Get("/orgs", func(w http.ResponseWriter, req *http.Request) {
|
||||||
listOrgsHandler(w, req, db, jwtManager)
|
listOrgsHandler(w, req, db, jwtManager)
|
||||||
@@ -78,7 +98,21 @@ func NewRouter(cfg *config.Config, db *database.DB, jwtManager *jwt.Manager, aut
|
|||||||
|
|
||||||
// File routes
|
// File routes
|
||||||
r.With(middleware.Permission(db, auditLogger, permission.FileRead)).Get("/files", func(w http.ResponseWriter, req *http.Request) {
|
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.Route("/files/{fileId}", func(r chi.Router) {
|
||||||
r.With(middleware.Permission(db, auditLogger, permission.DocumentView)).Get("/view", func(w http.ResponseWriter, req *http.Request) {
|
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)
|
json.NewEncoder(w).Encode(org)
|
||||||
}
|
}
|
||||||
|
|
||||||
func listFilesHandler(w http.ResponseWriter, r *http.Request) {
|
func listFilesHandler(w http.ResponseWriter, r *http.Request, db *database.DB) {
|
||||||
// Mock files
|
// Org ID is provided by middleware.Org
|
||||||
files := []struct {
|
orgID := r.Context().Value("org").(uuid.UUID)
|
||||||
Name string `json:"name"`
|
// Query params: path, q (search), page, pageSize
|
||||||
Path string `json:"path"`
|
path := r.URL.Query().Get("path")
|
||||||
Type string `json:"type"`
|
if path == "" {
|
||||||
Size int `json:"size"`
|
path = "/"
|
||||||
LastModified string `json:"lastModified"`
|
}
|
||||||
}{
|
q := r.URL.Query().Get("q")
|
||||||
{"test.pdf", "/test.pdf", "file", 1234, "2023-01-01T00:00:00Z"},
|
page := 1
|
||||||
{"folder", "/folder", "folder", 0, "2023-01-01T00:00:00Z"},
|
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")
|
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) {
|
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,
|
"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