This commit is contained in:
Leon Bösche
2026-01-09 18:58:09 +01:00
parent 2a62e13fc7
commit f18e779375
8 changed files with 759 additions and 23 deletions

View File

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

View File

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

View File

@@ -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) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text('Download ${file.name}')));
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('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(),
),
),
),
),
);
},
);
}
}

View File

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

Binary file not shown.

View File

@@ -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 {
err := bcrypt.CompareHashAndPassword([]byte(passwordHash), []byte(password))
return err == nil
// 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

View File

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

View File

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