451 lines
14 KiB
Go
451 lines
14 KiB
Go
package app
|
|
|
|
import (
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
sysRuntime "runtime"
|
|
"strings"
|
|
"time"
|
|
"unicode"
|
|
|
|
"github.com/wailsapp/wails/v2/pkg/runtime"
|
|
"go.uber.org/zap"
|
|
)
|
|
|
|
// =============================================================================
|
|
// EXPORT TYPES AND UTILITIES
|
|
// =============================================================================
|
|
|
|
// ExportError represents an error that occurred during export
|
|
type ExportError struct {
|
|
FileID string `json:"file_id"`
|
|
Filename string `json:"filename"`
|
|
CollectionID string `json:"collection_id"`
|
|
ErrorMessage string `json:"error_message"`
|
|
Timestamp string `json:"timestamp"`
|
|
}
|
|
|
|
// ExportEstimate provides an estimate of what will be exported
|
|
type ExportEstimate struct {
|
|
TotalCollections int `json:"total_collections"`
|
|
OwnedCollections int `json:"owned_collections"`
|
|
SharedCollections int `json:"shared_collections"`
|
|
TotalFiles int `json:"total_files"`
|
|
TotalSizeBytes int64 `json:"total_size_bytes"`
|
|
LocalFilesCount int `json:"local_files_count"`
|
|
CloudOnlyCount int `json:"cloud_only_count"`
|
|
EstimatedTime string `json:"estimated_time"`
|
|
}
|
|
|
|
// UserProfileExport represents exported user profile data
|
|
type UserProfileExport struct {
|
|
ID string `json:"id"`
|
|
Email string `json:"email"`
|
|
FirstName string `json:"first_name"`
|
|
LastName string `json:"last_name"`
|
|
Name string `json:"name"`
|
|
Phone string `json:"phone,omitempty"`
|
|
Country string `json:"country,omitempty"`
|
|
Timezone string `json:"timezone,omitempty"`
|
|
CreatedAt string `json:"created_at"`
|
|
ExportedAt string `json:"exported_at"`
|
|
}
|
|
|
|
// CollectionExportData represents a single collection in the export
|
|
type CollectionExportData struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
CollectionType string `json:"collection_type"`
|
|
ParentID string `json:"parent_id,omitempty"`
|
|
FileCount int `json:"file_count"`
|
|
CreatedAt string `json:"created_at"`
|
|
ModifiedAt string `json:"modified_at"`
|
|
IsShared bool `json:"is_shared"`
|
|
}
|
|
|
|
// CollectionsExport represents all exported collections
|
|
type CollectionsExport struct {
|
|
OwnedCollections []*CollectionExportData `json:"owned_collections"`
|
|
SharedCollections []*CollectionExportData `json:"shared_collections"`
|
|
TotalCount int `json:"total_count"`
|
|
ExportedAt string `json:"exported_at"`
|
|
}
|
|
|
|
// FileExportData represents a single file's metadata in the export
|
|
type FileExportData struct {
|
|
ID string `json:"id"`
|
|
Filename string `json:"filename"`
|
|
MimeType string `json:"mime_type"`
|
|
SizeBytes int64 `json:"size_bytes"`
|
|
CreatedAt string `json:"created_at"`
|
|
ModifiedAt string `json:"modified_at"`
|
|
CollectionID string `json:"collection_id"`
|
|
CollectionName string `json:"collection_name"`
|
|
}
|
|
|
|
// FilesMetadataExport represents all exported file metadata
|
|
type FilesMetadataExport struct {
|
|
Files []*FileExportData `json:"files"`
|
|
TotalCount int `json:"total_count"`
|
|
TotalSize int64 `json:"total_size_bytes"`
|
|
ExportedAt string `json:"exported_at"`
|
|
}
|
|
|
|
// FileExportResult represents the result of exporting a single file
|
|
type FileExportResult struct {
|
|
FileID string `json:"file_id"`
|
|
Filename string `json:"filename"`
|
|
SourceType string `json:"source_type"`
|
|
SizeBytes int64 `json:"size_bytes"`
|
|
DestPath string `json:"dest_path"`
|
|
Success bool `json:"success"`
|
|
ErrorMessage string `json:"error_message,omitempty"`
|
|
}
|
|
|
|
// ExportSummary is the final summary of the export operation
|
|
type ExportSummary struct {
|
|
ExportedAt string `json:"exported_at"`
|
|
ExportPath string `json:"export_path"`
|
|
TotalCollections int `json:"total_collections"`
|
|
OwnedCollections int `json:"owned_collections"`
|
|
SharedCollections int `json:"shared_collections"`
|
|
TotalFiles int `json:"total_files"`
|
|
FilesExported int `json:"files_exported"`
|
|
FilesCopiedLocal int `json:"files_copied_local"`
|
|
FilesDownloaded int `json:"files_downloaded"`
|
|
FilesFailed int `json:"files_failed"`
|
|
TotalSizeBytes int64 `json:"total_size_bytes"`
|
|
Errors []ExportError `json:"errors,omitempty"`
|
|
}
|
|
|
|
// =============================================================================
|
|
// EXPORT SETUP OPERATIONS
|
|
// =============================================================================
|
|
|
|
// SelectExportDirectory opens a dialog for the user to select an export directory
|
|
func (a *Application) SelectExportDirectory() (string, error) {
|
|
// Get user's home directory as default
|
|
homeDir, err := os.UserHomeDir()
|
|
if err != nil {
|
|
homeDir = ""
|
|
}
|
|
|
|
dir, err := runtime.OpenDirectoryDialog(a.ctx, runtime.OpenDialogOptions{
|
|
DefaultDirectory: homeDir,
|
|
Title: "Select Export Directory",
|
|
CanCreateDirectories: true,
|
|
ShowHiddenFiles: false,
|
|
TreatPackagesAsDirectories: false,
|
|
})
|
|
if err != nil {
|
|
a.logger.Error("Failed to open directory dialog", zap.Error(err))
|
|
return "", fmt.Errorf("failed to open directory dialog: %w", err)
|
|
}
|
|
|
|
return dir, nil
|
|
}
|
|
|
|
// GetExportEstimate returns an estimate of what will be exported
|
|
func (a *Application) GetExportEstimate() (*ExportEstimate, error) {
|
|
a.logger.Info("Getting export estimate")
|
|
|
|
// Get current session
|
|
session, err := a.authService.GetCurrentSession(a.ctx)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("not authenticated: %w", err)
|
|
}
|
|
|
|
if !session.IsValid() {
|
|
return nil, fmt.Errorf("session expired - please log in again")
|
|
}
|
|
|
|
apiClient := a.authService.GetAPIClient()
|
|
apiClient.SetTokens(session.AccessToken, session.RefreshToken)
|
|
|
|
// Get dashboard for storage stats
|
|
dashResp, err := apiClient.GetDashboard(a.ctx)
|
|
if err != nil {
|
|
a.logger.Warn("Failed to get dashboard for estimate", zap.Error(err))
|
|
}
|
|
|
|
// Get owned collections
|
|
ownedCollections, err := a.ListCollections()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to list owned collections: %w", err)
|
|
}
|
|
|
|
// Get shared collections
|
|
sharedCollections, err := a.listSharedCollections()
|
|
if err != nil {
|
|
a.logger.Warn("Failed to list shared collections", zap.Error(err))
|
|
sharedCollections = []*CollectionData{}
|
|
}
|
|
|
|
// Count files and check local availability
|
|
totalFiles := 0
|
|
localFilesCount := 0
|
|
cloudOnlyCount := 0
|
|
var totalSizeBytes int64 = 0
|
|
|
|
allCollections := append(ownedCollections, sharedCollections...)
|
|
for _, coll := range allCollections {
|
|
totalFiles += coll.TotalFiles
|
|
}
|
|
|
|
// Check local file repository for files with decrypted content available
|
|
// We check for FilePath (decrypted file) since that's what we copy during export
|
|
localFiles, err := a.mustGetFileRepo().List()
|
|
if err == nil {
|
|
for _, f := range localFiles {
|
|
if f.FilePath != "" {
|
|
localFilesCount++
|
|
totalSizeBytes += f.DecryptedSizeInBytes
|
|
}
|
|
}
|
|
}
|
|
cloudOnlyCount = totalFiles - localFilesCount
|
|
if cloudOnlyCount < 0 {
|
|
cloudOnlyCount = 0
|
|
}
|
|
|
|
// Note: Dashboard has storage in formatted units (e.g., "1.5 GB")
|
|
// We use our calculated totalSizeBytes instead for accuracy
|
|
_ = dashResp // Suppress unused variable warning if dashboard call failed
|
|
|
|
// Estimate time based on file count and sizes
|
|
estimatedTime := "Less than a minute"
|
|
if cloudOnlyCount > 0 {
|
|
// Rough estimate: 1 file per second for cloud downloads
|
|
seconds := cloudOnlyCount
|
|
if seconds > 60 {
|
|
minutes := seconds / 60
|
|
if minutes > 60 {
|
|
estimatedTime = fmt.Sprintf("About %d hours", minutes/60)
|
|
} else {
|
|
estimatedTime = fmt.Sprintf("About %d minutes", minutes)
|
|
}
|
|
} else {
|
|
estimatedTime = fmt.Sprintf("About %d seconds", seconds)
|
|
}
|
|
}
|
|
|
|
estimate := &ExportEstimate{
|
|
TotalCollections: len(allCollections),
|
|
OwnedCollections: len(ownedCollections),
|
|
SharedCollections: len(sharedCollections),
|
|
TotalFiles: totalFiles,
|
|
TotalSizeBytes: totalSizeBytes,
|
|
LocalFilesCount: localFilesCount,
|
|
CloudOnlyCount: cloudOnlyCount,
|
|
EstimatedTime: estimatedTime,
|
|
}
|
|
|
|
a.logger.Info("Export estimate calculated",
|
|
zap.Int("total_collections", estimate.TotalCollections),
|
|
zap.Int("total_files", estimate.TotalFiles),
|
|
zap.Int("local_files", estimate.LocalFilesCount),
|
|
zap.Int("cloud_only", estimate.CloudOnlyCount))
|
|
|
|
return estimate, nil
|
|
}
|
|
|
|
// CreateExportDirectory creates the export directory with timestamp
|
|
func (a *Application) CreateExportDirectory(basePath string) (string, error) {
|
|
timestamp := time.Now().Format("2006-01-02_15-04-05")
|
|
exportDir := filepath.Join(basePath, fmt.Sprintf("MapleFile_Export_%s", timestamp))
|
|
|
|
if err := os.MkdirAll(exportDir, 0755); err != nil {
|
|
return "", fmt.Errorf("failed to create export directory: %w", err)
|
|
}
|
|
|
|
// Create subdirectories
|
|
filesDir := filepath.Join(exportDir, "files")
|
|
if err := os.MkdirAll(filesDir, 0755); err != nil {
|
|
return "", fmt.Errorf("failed to create files directory: %w", err)
|
|
}
|
|
|
|
return exportDir, nil
|
|
}
|
|
|
|
// OpenExportFolder opens the export folder in the system file manager
|
|
func (a *Application) OpenExportFolder(path string) error {
|
|
// Security: Validate the path before passing to exec.Command
|
|
if path == "" {
|
|
return fmt.Errorf("path cannot be empty")
|
|
}
|
|
|
|
// Get absolute path and clean it
|
|
absPath, err := filepath.Abs(path)
|
|
if err != nil {
|
|
return fmt.Errorf("invalid path: %w", err)
|
|
}
|
|
absPath = filepath.Clean(absPath)
|
|
|
|
// Verify the path exists and is a directory
|
|
info, err := os.Stat(absPath)
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
return fmt.Errorf("path does not exist: %s", absPath)
|
|
}
|
|
return fmt.Errorf("failed to access path: %w", err)
|
|
}
|
|
|
|
if !info.IsDir() {
|
|
return fmt.Errorf("path is not a directory: %s", absPath)
|
|
}
|
|
|
|
a.logger.Info("Opening export folder",
|
|
zap.String("path", absPath))
|
|
|
|
var cmd *exec.Cmd
|
|
switch sysRuntime.GOOS {
|
|
case "darwin":
|
|
cmd = exec.Command("open", absPath)
|
|
case "windows":
|
|
cmd = exec.Command("explorer", absPath)
|
|
case "linux":
|
|
cmd = exec.Command("xdg-open", absPath)
|
|
default:
|
|
return fmt.Errorf("unsupported operating system: %s", sysRuntime.GOOS)
|
|
}
|
|
|
|
return cmd.Start()
|
|
}
|
|
|
|
// =============================================================================
|
|
// HELPER FUNCTIONS
|
|
// =============================================================================
|
|
|
|
// sanitizeFilename removes or replaces characters that are invalid in filenames.
|
|
// This function provides defense-in-depth against path traversal attacks by:
|
|
// 1. Extracting only the base filename (removing any path components)
|
|
// 2. Handling special directory references (. and ..)
|
|
// 3. Removing control characters
|
|
// 4. Replacing invalid filesystem characters
|
|
// 5. Handling Windows reserved names
|
|
// 6. Limiting filename length
|
|
func sanitizeFilename(name string) string {
|
|
// Step 1: Extract only the base filename to prevent path traversal
|
|
// This handles cases like "../../../etc/passwd" -> "passwd"
|
|
name = filepath.Base(name)
|
|
|
|
// Step 2: Handle special directory references
|
|
if name == "." || name == ".." || name == "" {
|
|
return "unnamed"
|
|
}
|
|
|
|
// Step 3: Trim leading/trailing whitespace and dots
|
|
// Windows doesn't allow filenames ending with dots or spaces
|
|
name = strings.TrimSpace(name)
|
|
name = strings.Trim(name, ".")
|
|
|
|
if name == "" {
|
|
return "unnamed"
|
|
}
|
|
|
|
// Step 4: Remove control characters (ASCII 0-31)
|
|
result := make([]rune, 0, len(name))
|
|
for _, r := range name {
|
|
if r < 32 || !unicode.IsPrint(r) {
|
|
continue // Skip control characters
|
|
}
|
|
result = append(result, r)
|
|
}
|
|
name = string(result)
|
|
|
|
// Step 5: Replace invalid filesystem characters
|
|
// These are invalid on Windows: \ / : * ? " < > |
|
|
// Forward/back slashes are also dangerous for path traversal
|
|
replacer := map[rune]rune{
|
|
'/': '-',
|
|
'\\': '-',
|
|
':': '-',
|
|
'*': '-',
|
|
'?': '-',
|
|
'"': '\'',
|
|
'<': '(',
|
|
'>': ')',
|
|
'|': '-',
|
|
}
|
|
|
|
result = make([]rune, 0, len(name))
|
|
for _, r := range name {
|
|
if replacement, ok := replacer[r]; ok {
|
|
result = append(result, replacement)
|
|
} else {
|
|
result = append(result, r)
|
|
}
|
|
}
|
|
name = string(result)
|
|
|
|
// Step 6: Handle Windows reserved names
|
|
// These names are reserved regardless of extension: CON, PRN, AUX, NUL,
|
|
// COM1-COM9, LPT1-LPT9
|
|
upperName := strings.ToUpper(name)
|
|
// Extract name without extension for comparison
|
|
nameWithoutExt := upperName
|
|
if idx := strings.LastIndex(upperName, "."); idx > 0 {
|
|
nameWithoutExt = upperName[:idx]
|
|
}
|
|
|
|
reservedNames := map[string]bool{
|
|
"CON": true, "PRN": true, "AUX": true, "NUL": true,
|
|
"COM1": true, "COM2": true, "COM3": true, "COM4": true,
|
|
"COM5": true, "COM6": true, "COM7": true, "COM8": true, "COM9": true,
|
|
"LPT1": true, "LPT2": true, "LPT3": true, "LPT4": true,
|
|
"LPT5": true, "LPT6": true, "LPT7": true, "LPT8": true, "LPT9": true,
|
|
}
|
|
|
|
if reservedNames[nameWithoutExt] {
|
|
name = "_" + name
|
|
}
|
|
|
|
// Step 7: Limit filename length
|
|
// Most filesystems support 255 bytes; we use 200 to leave room for path
|
|
const maxFilenameLength = 200
|
|
if len(name) > maxFilenameLength {
|
|
// Try to preserve the extension
|
|
ext := filepath.Ext(name)
|
|
if len(ext) < maxFilenameLength-10 {
|
|
nameWithoutExt := name[:len(name)-len(ext)]
|
|
if len(nameWithoutExt) > maxFilenameLength-len(ext) {
|
|
nameWithoutExt = nameWithoutExt[:maxFilenameLength-len(ext)]
|
|
}
|
|
name = nameWithoutExt + ext
|
|
} else {
|
|
name = name[:maxFilenameLength]
|
|
}
|
|
}
|
|
|
|
// Final check
|
|
if name == "" {
|
|
return "unnamed"
|
|
}
|
|
|
|
return name
|
|
}
|
|
|
|
// copyFile copies a file from src to dst
|
|
func copyFile(src, dst string) error {
|
|
sourceFile, err := os.Open(src)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer sourceFile.Close()
|
|
|
|
destFile, err := os.Create(dst)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer destFile.Close()
|
|
|
|
_, err = io.Copy(destFile, sourceFile)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
return destFile.Sync()
|
|
}
|