work
This commit is contained in:
@@ -9,6 +9,200 @@ import '../injection.dart';
|
||||
import 'package:syncfusion_flutter_pdfviewer/pdfviewer.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
// Modal version for overlay display
|
||||
class DocumentViewerModal extends StatefulWidget {
|
||||
final String orgId;
|
||||
final String fileId;
|
||||
final VoidCallback onClose;
|
||||
final VoidCallback onEdit;
|
||||
|
||||
const DocumentViewerModal({
|
||||
super.key,
|
||||
required this.orgId,
|
||||
required this.fileId,
|
||||
required this.onClose,
|
||||
required this.onEdit,
|
||||
});
|
||||
|
||||
@override
|
||||
State<DocumentViewerModal> createState() => _DocumentViewerModalState();
|
||||
}
|
||||
|
||||
class _DocumentViewerModalState extends State<DocumentViewerModal> {
|
||||
late DocumentViewerBloc _viewerBloc;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_viewerBloc = DocumentViewerBloc(getIt<FileService>());
|
||||
_viewerBloc.add(DocumentOpened(orgId: widget.orgId, fileId: widget.fileId));
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocProvider.value(
|
||||
value: _viewerBloc,
|
||||
child: Column(
|
||||
children: [
|
||||
// Custom AppBar
|
||||
Container(
|
||||
height: 56,
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.primaryBackground.withValues(alpha: 0.9),
|
||||
border: Border(
|
||||
bottom: BorderSide(
|
||||
color: AppTheme.accentColor.withValues(alpha: 0.3),
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const SizedBox(width: 16),
|
||||
const Text(
|
||||
'Document Viewer',
|
||||
style: TextStyle(
|
||||
color: AppTheme.primaryText,
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
BlocBuilder<DocumentViewerBloc, DocumentViewerState>(
|
||||
builder: (context, state) {
|
||||
if (state is DocumentViewerReady && state.caps.canEdit) {
|
||||
return IconButton(
|
||||
icon: const Icon(
|
||||
Icons.edit,
|
||||
color: AppTheme.primaryText,
|
||||
),
|
||||
onPressed: widget.onEdit,
|
||||
);
|
||||
}
|
||||
return const SizedBox.shrink();
|
||||
},
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.refresh, color: AppTheme.primaryText),
|
||||
splashColor: Colors.transparent,
|
||||
highlightColor: Colors.transparent,
|
||||
onPressed: () {
|
||||
_viewerBloc.add(DocumentReloaded());
|
||||
},
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.close, color: AppTheme.primaryText),
|
||||
splashColor: Colors.transparent,
|
||||
highlightColor: Colors.transparent,
|
||||
onPressed: () {
|
||||
_viewerBloc.add(DocumentClosed());
|
||||
widget.onClose();
|
||||
},
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
),
|
||||
),
|
||||
// Meta info bar
|
||||
BlocBuilder<DocumentViewerBloc, DocumentViewerState>(
|
||||
builder: (context, state) {
|
||||
if (state is DocumentViewerReady) {
|
||||
return Container(
|
||||
height: 30,
|
||||
alignment: Alignment.centerLeft,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.primaryBackground.withValues(alpha: 0.3),
|
||||
),
|
||||
child: const Text(
|
||||
'Last modified: Unknown by Unknown (v1)',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: AppTheme.secondaryText,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
return const SizedBox.shrink();
|
||||
},
|
||||
),
|
||||
// Document content
|
||||
Expanded(
|
||||
child: BlocBuilder<DocumentViewerBloc, DocumentViewerState>(
|
||||
builder: (context, state) {
|
||||
if (state is DocumentViewerLoading) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
if (state is DocumentViewerError) {
|
||||
return Center(
|
||||
child: Text(
|
||||
'Error: ${state.message}',
|
||||
style: const TextStyle(color: AppTheme.primaryText),
|
||||
),
|
||||
);
|
||||
}
|
||||
if (state is DocumentViewerSessionExpired) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Text(
|
||||
'Your viewing session expired. Click to reopen.',
|
||||
style: TextStyle(color: AppTheme.primaryText),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
ElevatedButton(
|
||||
onPressed: () {
|
||||
_viewerBloc.add(
|
||||
DocumentOpened(
|
||||
orgId: widget.orgId,
|
||||
fileId: widget.fileId,
|
||||
),
|
||||
);
|
||||
},
|
||||
child: const Text('Reload'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
if (state is DocumentViewerReady) {
|
||||
if (state.caps.isPdf) {
|
||||
return SfPdfViewer.network(state.viewUrl.toString());
|
||||
} else {
|
||||
return Container(
|
||||
color: AppTheme.secondaryText,
|
||||
child: Center(
|
||||
child: Text(
|
||||
'Office Document Viewer\\n(URL: ${state.viewUrl})',
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(color: AppTheme.primaryText),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
return const Center(
|
||||
child: Text(
|
||||
'No document loaded',
|
||||
style: TextStyle(color: AppTheme.primaryText),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_viewerBloc.close();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
// Original page version (for routing if needed)
|
||||
class DocumentViewer extends StatefulWidget {
|
||||
final String orgId;
|
||||
final String fileId;
|
||||
|
||||
@@ -8,6 +8,161 @@ import '../services/file_service.dart';
|
||||
import '../injection.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
// Modal version for overlay display
|
||||
class EditorPageModal extends StatefulWidget {
|
||||
final String orgId;
|
||||
final String fileId;
|
||||
final VoidCallback onClose;
|
||||
|
||||
const EditorPageModal({
|
||||
super.key,
|
||||
required this.orgId,
|
||||
required this.fileId,
|
||||
required this.onClose,
|
||||
});
|
||||
|
||||
@override
|
||||
State<EditorPageModal> createState() => _EditorPageModalState();
|
||||
}
|
||||
|
||||
class _EditorPageModalState extends State<EditorPageModal> {
|
||||
late EditorSessionBloc _editorBloc;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_editorBloc = EditorSessionBloc(getIt<FileService>());
|
||||
_editorBloc.add(
|
||||
EditorSessionStarted(orgId: widget.orgId, fileId: widget.fileId),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocProvider.value(
|
||||
value: _editorBloc,
|
||||
child: Column(
|
||||
children: [
|
||||
// Custom AppBar
|
||||
Container(
|
||||
height: 56,
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.primaryBackground.withValues(alpha: 0.9),
|
||||
border: Border(
|
||||
bottom: BorderSide(
|
||||
color: AppTheme.accentColor.withValues(alpha: 0.3),
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const SizedBox(width: 16),
|
||||
const Text(
|
||||
'Document Editor',
|
||||
style: TextStyle(
|
||||
color: AppTheme.primaryText,
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.close, color: AppTheme.primaryText),
|
||||
onPressed: () {
|
||||
_editorBloc.add(EditorSessionEnded());
|
||||
widget.onClose();
|
||||
},
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
),
|
||||
),
|
||||
// Editor content
|
||||
Expanded(
|
||||
child: BlocBuilder<EditorSessionBloc, EditorSessionState>(
|
||||
builder: (context, state) {
|
||||
if (state is EditorSessionStarting) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
if (state is EditorSessionFailed) {
|
||||
return Center(
|
||||
child: Text(
|
||||
'Error: ${state.message}',
|
||||
style: const TextStyle(color: AppTheme.primaryText),
|
||||
),
|
||||
);
|
||||
}
|
||||
if (state is EditorSessionActive) {
|
||||
return Container(
|
||||
color: AppTheme.secondaryText,
|
||||
child: Center(
|
||||
child: Text(
|
||||
'Collabora Editor Active\\n(URL: ${state.editUrl})',
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(color: AppTheme.primaryText),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
if (state is EditorSessionReadOnly) {
|
||||
return Container(
|
||||
color: AppTheme.secondaryText,
|
||||
child: Center(
|
||||
child: Text(
|
||||
'Read Only Mode\\n(URL: ${state.viewUrl})',
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(color: AppTheme.primaryText),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
if (state is EditorSessionExpired) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Text(
|
||||
'Editing session expired.',
|
||||
style: TextStyle(color: AppTheme.primaryText),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
ElevatedButton(
|
||||
onPressed: () {
|
||||
_editorBloc.add(
|
||||
EditorSessionStarted(
|
||||
orgId: widget.orgId,
|
||||
fileId: widget.fileId,
|
||||
),
|
||||
);
|
||||
},
|
||||
child: const Text('Reopen'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
return const Center(
|
||||
child: Text(
|
||||
'No editor session',
|
||||
style: TextStyle(color: AppTheme.primaryText),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_editorBloc.close();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
// Original page version (for routing if needed)
|
||||
class EditorPage extends StatefulWidget {
|
||||
final String orgId;
|
||||
final String fileId;
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import 'dart:ui';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:file_picker/file_picker.dart' hide FileType;
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'dart:html' as html;
|
||||
import '../blocs/file_browser/file_browser_bloc.dart';
|
||||
import '../blocs/file_browser/file_browser_event.dart';
|
||||
import '../blocs/file_browser/file_browser_state.dart';
|
||||
@@ -14,6 +15,10 @@ import '../blocs/upload/upload_event.dart';
|
||||
import '../models/file_item.dart';
|
||||
import '../theme/app_theme.dart';
|
||||
import '../theme/modern_glass_button.dart';
|
||||
import 'document_viewer.dart';
|
||||
import 'editor_page.dart';
|
||||
import '../injection.dart';
|
||||
import '../services/file_service.dart';
|
||||
|
||||
class FileExplorer extends StatefulWidget {
|
||||
final String orgId;
|
||||
@@ -258,10 +263,35 @@ class _FileExplorerState extends State<FileExplorer> {
|
||||
);
|
||||
}
|
||||
|
||||
void _downloadFile(FileItem file) {
|
||||
void _downloadFile(FileItem file) async {
|
||||
try {
|
||||
final fileService = getIt<FileService>();
|
||||
final downloadUrl = await fileService.getDownloadUrl(
|
||||
widget.orgId,
|
||||
file.path,
|
||||
);
|
||||
|
||||
// For web, use the download URL with the base URL
|
||||
final baseUrl = 'https://go.b0esche.cloud'; // Should come from config
|
||||
final fullUrl = '$baseUrl$downloadUrl';
|
||||
|
||||
// Trigger download via anchor element
|
||||
html.AnchorElement(href: fullUrl)
|
||||
..setAttribute('download', file.name)
|
||||
..click();
|
||||
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text('Download ${file.name}')));
|
||||
).showSnackBar(SnackBar(content: Text('Downloading ${file.name}')));
|
||||
}
|
||||
} catch (e) {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text('Download failed: $e')));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _sendFile(FileItem file) {
|
||||
@@ -533,7 +563,7 @@ class _FileExplorerState extends State<FileExplorer> {
|
||||
final fileId = file.path.startsWith('/')
|
||||
? file.path.substring(1)
|
||||
: file.path;
|
||||
context.go('/viewer/${widget.orgId}/$fileId');
|
||||
_showDocumentViewer(widget.orgId, fileId);
|
||||
}
|
||||
},
|
||||
child: Container(
|
||||
@@ -1018,4 +1048,100 @@ class _FileExplorerState extends State<FileExplorer> {
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void _showDocumentViewer(String orgId, String fileId) {
|
||||
showDialog(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
barrierColor: Colors.transparent,
|
||||
builder: (BuildContext context) {
|
||||
return BackdropFilter(
|
||||
filter: ImageFilter.blur(sigmaX: 10, sigmaY: 10),
|
||||
child: Dialog(
|
||||
backgroundColor: Colors.transparent,
|
||||
insetPadding: const EdgeInsets.all(16),
|
||||
child: Container(
|
||||
constraints: BoxConstraints(
|
||||
maxWidth: MediaQuery.of(context).size.width * 0.9,
|
||||
maxHeight: MediaQuery.of(context).size.height * 0.9,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.primaryBackground,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(
|
||||
color: AppTheme.accentColor.withValues(alpha: 0.3),
|
||||
width: 2,
|
||||
),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.5),
|
||||
blurRadius: 20,
|
||||
spreadRadius: 5,
|
||||
),
|
||||
],
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
child: DocumentViewerModal(
|
||||
orgId: orgId,
|
||||
fileId: fileId,
|
||||
onEdit: () {
|
||||
Navigator.of(context).pop();
|
||||
_showDocumentEditor(orgId, fileId);
|
||||
},
|
||||
onClose: () => Navigator.of(context).pop(),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void _showDocumentEditor(String orgId, String fileId) {
|
||||
showDialog(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
barrierColor: Colors.transparent,
|
||||
builder: (BuildContext context) {
|
||||
return BackdropFilter(
|
||||
filter: ImageFilter.blur(sigmaX: 10, sigmaY: 10),
|
||||
child: Dialog(
|
||||
backgroundColor: Colors.transparent,
|
||||
insetPadding: const EdgeInsets.all(16),
|
||||
child: Container(
|
||||
constraints: BoxConstraints(
|
||||
maxWidth: MediaQuery.of(context).size.width * 0.9,
|
||||
maxHeight: MediaQuery.of(context).size.height * 0.9,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.primaryBackground,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(
|
||||
color: AppTheme.accentColor.withValues(alpha: 0.3),
|
||||
width: 2,
|
||||
),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.5),
|
||||
blurRadius: 20,
|
||||
spreadRadius: 5,
|
||||
),
|
||||
],
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
child: EditorPageModal(
|
||||
orgId: orgId,
|
||||
fileId: fileId,
|
||||
onClose: () => Navigator.of(context).pop(),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -112,6 +112,14 @@ class FileService {
|
||||
);
|
||||
}
|
||||
|
||||
Future<String> getDownloadUrl(String orgId, String path) async {
|
||||
// Return the download URL for the file
|
||||
if (orgId.isEmpty) {
|
||||
return '/user/files/download?path=${Uri.encodeComponent(path)}';
|
||||
}
|
||||
return '/orgs/$orgId/files/download?path=${Uri.encodeComponent(path)}';
|
||||
}
|
||||
|
||||
Future<void> createFolder(
|
||||
String orgId,
|
||||
String parentPath,
|
||||
|
||||
BIN
go_cloud/api
BIN
go_cloud/api
Binary file not shown.
@@ -6,9 +6,11 @@ import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"go.b0esche.cloud/backend/internal/database"
|
||||
"golang.org/x/crypto/argon2"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
@@ -17,6 +19,12 @@ const (
|
||||
RPID = "b0esche.cloud"
|
||||
RPName = "b0esche Cloud"
|
||||
Origin = "https://b0esche.cloud"
|
||||
|
||||
// Argon2id parameters (OWASP recommendations)
|
||||
Argon2Time = 2 // iterations
|
||||
Argon2Memory = 19 * 1024 // 19 MB
|
||||
Argon2Threads = 1
|
||||
Argon2KeyLen = 32
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
@@ -284,19 +292,76 @@ func byteArraysEqual(a, b []byte) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
// HashPassword hashes a password using bcrypt
|
||||
// HashPassword hashes a password using Argon2id (quantum-resistant)
|
||||
// Format: $argon2id$v=19$m=19456,t=2,p=1$<salt>$<hash>
|
||||
func (s *Service) HashPassword(password string) (string, error) {
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to hash password: %w", err)
|
||||
// Generate 16-byte random salt
|
||||
salt := make([]byte, 16)
|
||||
if _, err := rand.Read(salt); err != nil {
|
||||
return "", fmt.Errorf("failed to generate salt: %w", err)
|
||||
}
|
||||
return string(hash), nil
|
||||
|
||||
// Hash with Argon2id
|
||||
hash := argon2.IDKey([]byte(password), salt, Argon2Time, Argon2Memory, Argon2Threads, Argon2KeyLen)
|
||||
|
||||
// Encode in PHC string format
|
||||
b64Salt := base64.RawStdEncoding.EncodeToString(salt)
|
||||
b64Hash := base64.RawStdEncoding.EncodeToString(hash)
|
||||
|
||||
return fmt.Sprintf("$argon2id$v=19$m=%d,t=%d,p=%d$%s$%s",
|
||||
Argon2Memory, Argon2Time, Argon2Threads, b64Salt, b64Hash), nil
|
||||
}
|
||||
|
||||
// VerifyPassword checks if a password matches its hash
|
||||
// Supports both Argon2id (new) and bcrypt (legacy) for backward compatibility
|
||||
func (s *Service) VerifyPassword(passwordHash string, password string) bool {
|
||||
// Detect hash format
|
||||
if strings.HasPrefix(passwordHash, "$argon2id$") {
|
||||
return s.verifyArgon2(passwordHash, password)
|
||||
} else if strings.HasPrefix(passwordHash, "$2") {
|
||||
// Legacy bcrypt hash
|
||||
err := bcrypt.CompareHashAndPassword([]byte(passwordHash), []byte(password))
|
||||
return err == nil
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (s *Service) verifyArgon2(encodedHash string, password string) bool {
|
||||
// Parse PHC format: $argon2id$v=19$m=19456,t=2,p=1$<salt>$<hash>
|
||||
parts := strings.Split(encodedHash, "$")
|
||||
if len(parts) != 6 {
|
||||
return false
|
||||
}
|
||||
|
||||
var memory, time uint32
|
||||
var threads uint8
|
||||
_, err := fmt.Sscanf(parts[3], "m=%d,t=%d,p=%d", &memory, &time, &threads)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
salt, err := base64.RawStdEncoding.DecodeString(parts[4])
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
hash, err := base64.RawStdEncoding.DecodeString(parts[5])
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
// Compute hash with same parameters
|
||||
computedHash := argon2.IDKey([]byte(password), salt, time, memory, threads, uint32(len(hash)))
|
||||
|
||||
// Constant-time comparison
|
||||
if len(hash) != len(computedHash) {
|
||||
return false
|
||||
}
|
||||
var diff byte
|
||||
for i := 0; i < len(hash); i++ {
|
||||
diff |= hash[i] ^ computedHash[i]
|
||||
}
|
||||
return diff == 0
|
||||
}
|
||||
|
||||
// VerifyPasswordLogin verifies username and password credentials
|
||||
|
||||
@@ -78,16 +78,20 @@ func NewRouter(cfg *config.Config, db *database.DB, jwtManager *jwt.Manager, aut
|
||||
r.Get("/user/files", func(w http.ResponseWriter, req *http.Request) {
|
||||
userFilesHandler(w, req, db)
|
||||
})
|
||||
// Download user file
|
||||
r.Get("/user/files/download", func(w http.ResponseWriter, req *http.Request) {
|
||||
downloadUserFileHandler(w, req, db, storageClient)
|
||||
})
|
||||
// Create / delete in user workspace
|
||||
r.Post("/user/files", func(w http.ResponseWriter, req *http.Request) {
|
||||
createUserFileHandler(w, req, db, auditLogger, storageClient)
|
||||
})
|
||||
r.Delete("/user/files", func(w http.ResponseWriter, req *http.Request) {
|
||||
deleteUserFileHandler(w, req, db, auditLogger)
|
||||
deleteUserFileHandler(w, req, db, auditLogger, storageClient)
|
||||
})
|
||||
// POST wrapper for delete
|
||||
r.Post("/user/files/delete", func(w http.ResponseWriter, req *http.Request) {
|
||||
deleteUserFilePostHandler(w, req, db, auditLogger)
|
||||
deleteUserFilePostHandler(w, req, db, auditLogger, storageClient)
|
||||
})
|
||||
|
||||
// Org routes
|
||||
@@ -106,6 +110,10 @@ func NewRouter(cfg *config.Config, db *database.DB, jwtManager *jwt.Manager, aut
|
||||
r.With(middleware.Permission(db, auditLogger, permission.FileRead)).Get("/files", func(w http.ResponseWriter, req *http.Request) {
|
||||
listFilesHandler(w, req, db)
|
||||
})
|
||||
// Download org file
|
||||
r.With(middleware.Permission(db, auditLogger, permission.FileRead)).Get("/files/download", func(w http.ResponseWriter, req *http.Request) {
|
||||
downloadOrgFileHandler(w, req, db, storageClient)
|
||||
})
|
||||
|
||||
// Create file/folder in org workspace
|
||||
r.With(middleware.Permission(db, auditLogger, permission.FileWrite)).Post("/files", func(w http.ResponseWriter, req *http.Request) {
|
||||
@@ -113,12 +121,12 @@ func NewRouter(cfg *config.Config, db *database.DB, jwtManager *jwt.Manager, aut
|
||||
})
|
||||
// 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)
|
||||
deleteOrgFilePostHandler(w, req, db, auditLogger, storageClient)
|
||||
})
|
||||
|
||||
// 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)
|
||||
deleteOrgFileHandler(w, req, db, auditLogger, storageClient)
|
||||
})
|
||||
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) {
|
||||
@@ -991,7 +999,7 @@ func createOrgFileHandler(w http.ResponseWriter, r *http.Request, db *database.D
|
||||
}
|
||||
|
||||
// deleteOrgFileHandler deletes a file/folder in org workspace by path
|
||||
func deleteOrgFileHandler(w http.ResponseWriter, r *http.Request, db *database.DB, auditLogger *audit.Logger) {
|
||||
func deleteOrgFileHandler(w http.ResponseWriter, r *http.Request, db *database.DB, auditLogger *audit.Logger, storageClient *storage.WebDAVClient) {
|
||||
orgID := r.Context().Value("org").(uuid.UUID)
|
||||
userIDStr, _ := r.Context().Value("user").(string)
|
||||
userID, _ := uuid.Parse(userIDStr)
|
||||
@@ -1004,6 +1012,16 @@ func deleteOrgFileHandler(w http.ResponseWriter, r *http.Request, db *database.D
|
||||
return
|
||||
}
|
||||
|
||||
// Delete from Nextcloud if configured
|
||||
if storageClient != nil {
|
||||
rel := strings.TrimPrefix(req.Path, "/")
|
||||
remotePath := path.Join("/orgs", orgID.String(), rel)
|
||||
if err := storageClient.Delete(r.Context(), remotePath); err != nil {
|
||||
errors.LogError(r, err, "Failed to delete from Nextcloud (continuing anyway)")
|
||||
}
|
||||
}
|
||||
|
||||
// Delete from database
|
||||
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)
|
||||
@@ -1022,8 +1040,8 @@ func deleteOrgFileHandler(w http.ResponseWriter, r *http.Request, db *database.D
|
||||
}
|
||||
|
||||
// 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)
|
||||
func deleteOrgFilePostHandler(w http.ResponseWriter, r *http.Request, db *database.DB, auditLogger *audit.Logger, storageClient *storage.WebDAVClient) {
|
||||
deleteOrgFileHandler(w, r, db, auditLogger, storageClient)
|
||||
}
|
||||
|
||||
// createUserFileHandler creates a file or folder record for the authenticated user's personal workspace.
|
||||
@@ -1154,12 +1172,12 @@ func createUserFileHandler(w http.ResponseWriter, r *http.Request, db *database.
|
||||
}
|
||||
|
||||
// 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)
|
||||
func deleteUserFilePostHandler(w http.ResponseWriter, r *http.Request, db *database.DB, auditLogger *audit.Logger, storageClient *storage.WebDAVClient) {
|
||||
deleteUserFileHandler(w, r, db, auditLogger, storageClient)
|
||||
}
|
||||
|
||||
// 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) {
|
||||
func deleteUserFileHandler(w http.ResponseWriter, r *http.Request, db *database.DB, auditLogger *audit.Logger, storageClient *storage.WebDAVClient) {
|
||||
userIDStr, ok := r.Context().Value("user").(string)
|
||||
if !ok || userIDStr == "" {
|
||||
errors.WriteError(w, errors.CodeUnauthenticated, "Unauthorized", http.StatusUnauthorized)
|
||||
@@ -1175,6 +1193,16 @@ func deleteUserFileHandler(w http.ResponseWriter, r *http.Request, db *database.
|
||||
return
|
||||
}
|
||||
|
||||
// Delete from Nextcloud if configured
|
||||
if storageClient != nil {
|
||||
rel := strings.TrimPrefix(req.Path, "/")
|
||||
remotePath := path.Join("/user", userID.String(), rel)
|
||||
if err := storageClient.Delete(r.Context(), remotePath); err != nil {
|
||||
errors.LogError(r, err, "Failed to delete from Nextcloud (continuing anyway)")
|
||||
}
|
||||
}
|
||||
|
||||
// Delete from database
|
||||
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)
|
||||
@@ -1190,3 +1218,86 @@ func deleteUserFileHandler(w http.ResponseWriter, r *http.Request, db *database.
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(`{"status":"ok"}`))
|
||||
}
|
||||
|
||||
// downloadOrgFileHandler downloads a file from org workspace
|
||||
func downloadOrgFileHandler(w http.ResponseWriter, r *http.Request, db *database.DB, storageClient *storage.WebDAVClient) {
|
||||
orgID := r.Context().Value("org").(uuid.UUID)
|
||||
|
||||
filePath := r.URL.Query().Get("path")
|
||||
if filePath == "" {
|
||||
errors.WriteError(w, errors.CodeInvalidArgument, "Missing path parameter", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Try to download from Nextcloud first
|
||||
if storageClient != nil {
|
||||
rel := strings.TrimPrefix(filePath, "/")
|
||||
remotePath := path.Join("/orgs", orgID.String(), rel)
|
||||
|
||||
reader, size, err := storageClient.Download(r.Context(), remotePath)
|
||||
if err == nil {
|
||||
defer reader.Close()
|
||||
|
||||
// Set appropriate headers
|
||||
fileName := path.Base(filePath)
|
||||
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", fileName))
|
||||
w.Header().Set("Content-Type", "application/octet-stream")
|
||||
if size > 0 {
|
||||
w.Header().Set("Content-Length", fmt.Sprintf("%d", size))
|
||||
}
|
||||
|
||||
// Stream the file
|
||||
io.Copy(w, reader)
|
||||
return
|
||||
}
|
||||
|
||||
errors.LogError(r, err, "Failed to download from Nextcloud, trying local storage")
|
||||
}
|
||||
|
||||
// Fallback to local storage (if implemented)
|
||||
errors.WriteError(w, errors.CodeNotFound, "File not found", http.StatusNotFound)
|
||||
}
|
||||
|
||||
// downloadUserFileHandler downloads a file from user's personal workspace
|
||||
func downloadUserFileHandler(w http.ResponseWriter, r *http.Request, db *database.DB, storageClient *storage.WebDAVClient) {
|
||||
userIDStr, ok := r.Context().Value("user").(string)
|
||||
if !ok || userIDStr == "" {
|
||||
errors.WriteError(w, errors.CodeUnauthenticated, "Unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
userID, _ := uuid.Parse(userIDStr)
|
||||
|
||||
filePath := r.URL.Query().Get("path")
|
||||
if filePath == "" {
|
||||
errors.WriteError(w, errors.CodeInvalidArgument, "Missing path parameter", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Try to download from Nextcloud first
|
||||
if storageClient != nil {
|
||||
rel := strings.TrimPrefix(filePath, "/")
|
||||
remotePath := path.Join("/user", userID.String(), rel)
|
||||
|
||||
reader, size, err := storageClient.Download(r.Context(), remotePath)
|
||||
if err == nil {
|
||||
defer reader.Close()
|
||||
|
||||
// Set appropriate headers
|
||||
fileName := path.Base(filePath)
|
||||
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", fileName))
|
||||
w.Header().Set("Content-Type", "application/octet-stream")
|
||||
if size > 0 {
|
||||
w.Header().Set("Content-Length", fmt.Sprintf("%d", size))
|
||||
}
|
||||
|
||||
// Stream the file
|
||||
io.Copy(w, reader)
|
||||
return
|
||||
}
|
||||
|
||||
errors.LogError(r, err, "Failed to download from Nextcloud, trying local storage")
|
||||
}
|
||||
|
||||
// Fallback to local storage (if implemented)
|
||||
errors.WriteError(w, errors.CodeNotFound, "File not found", http.StatusNotFound)
|
||||
}
|
||||
|
||||
@@ -111,3 +111,80 @@ func (c *WebDAVClient) Upload(ctx context.Context, remotePath string, r io.Reade
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return fmt.Errorf("webdav upload failed: %d %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
// Download retrieves a file from the remotePath using HTTP GET (WebDAV).
|
||||
func (c *WebDAVClient) Download(ctx context.Context, remotePath string) (io.ReadCloser, int64, error) {
|
||||
if c == nil {
|
||||
return nil, 0, fmt.Errorf("no webdav client configured")
|
||||
}
|
||||
|
||||
rel := strings.TrimLeft(remotePath, "/")
|
||||
u := c.basePrefix
|
||||
if u == "/" || u == "" {
|
||||
u = "/"
|
||||
}
|
||||
full := fmt.Sprintf("%s%s/%s", c.baseURL, u, url.PathEscape(rel))
|
||||
full = strings.ReplaceAll(full, "%2F", "/")
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", full, nil)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
if c.user != "" {
|
||||
req.SetBasicAuth(c.user, c.pass)
|
||||
}
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
|
||||
return resp.Body, resp.ContentLength, nil
|
||||
}
|
||||
|
||||
defer resp.Body.Close()
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return nil, 0, fmt.Errorf("webdav download failed: %d %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
// Delete removes a file or collection from the remotePath using HTTP DELETE (WebDAV).
|
||||
func (c *WebDAVClient) Delete(ctx context.Context, remotePath string) error {
|
||||
if c == nil {
|
||||
return fmt.Errorf("no webdav client configured")
|
||||
}
|
||||
|
||||
rel := strings.TrimLeft(remotePath, "/")
|
||||
u := c.basePrefix
|
||||
if u == "/" || u == "" {
|
||||
u = "/"
|
||||
}
|
||||
full := fmt.Sprintf("%s%s/%s", c.baseURL, u, url.PathEscape(rel))
|
||||
full = strings.ReplaceAll(full, "%2F", "/")
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "DELETE", full, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if c.user != "" {
|
||||
req.SetBasicAuth(c.user, c.pass)
|
||||
}
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// 404 means already deleted, consider it success
|
||||
if resp.StatusCode == 404 {
|
||||
return nil
|
||||
}
|
||||
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return fmt.Errorf("webdav delete failed: %d %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user