package app import ( "encoding/base64" "encoding/json" "fmt" "io" "net/http" "os" "os/exec" "path/filepath" sysRuntime "runtime" "strings" "github.com/wailsapp/wails/v2/pkg/runtime" "go.uber.org/zap" "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/pkg/maplefile/e2ee" "codeberg.org/mapleopentech/monorepo/native/desktop/maplefile/internal/domain/file" "codeberg.org/mapleopentech/monorepo/native/desktop/maplefile/internal/service/inputvalidation" ) // ============================================================================= // FILE DOWNLOAD OPERATIONS // ============================================================================= // tryDecodeBase64 attempts to decode a base64 string using multiple encoding variants. // The web frontend uses URL-safe base64 without padding (libsodium default), // while Go typically uses standard base64 with padding. func tryDecodeBase64(s string) ([]byte, error) { var lastErr error // Try URL-safe base64 without padding FIRST (libsodium's URLSAFE_NO_PADDING) // This is the format used by the web frontend if data, err := base64.RawURLEncoding.DecodeString(s); err == nil { return data, nil } else { lastErr = err } // Try standard base64 with padding (Go's default) if data, err := base64.StdEncoding.DecodeString(s); err == nil { return data, nil } else { lastErr = err } // Try standard base64 without padding if data, err := base64.RawStdEncoding.DecodeString(s); err == nil { return data, nil } else { lastErr = err } // Try URL-safe base64 with padding if data, err := base64.URLEncoding.DecodeString(s); err == nil { return data, nil } else { lastErr = err } return nil, fmt.Errorf("failed to decode base64 with any encoding variant (input length: %d, first 50 chars: %s, last error: %w)", len(s), truncateString(s, 50), lastErr) } // truncateString truncates a string to the specified length func truncateString(s string, maxLen int) string { if len(s) <= maxLen { return s } return s[:maxLen] + "..." } // GetFileDownloadURL gets a presigned download URL for a file func (a *Application) GetFileDownloadURL(fileID string) (string, error) { // Validate input if err := inputvalidation.ValidateFileID(fileID); err != nil { return "", err } // Get current session for authentication session, err := a.authService.GetCurrentSession(a.ctx) if err != nil { a.logger.Error("Failed to get current session", zap.Error(err)) return "", fmt.Errorf("not authenticated: %w", err) } apiClient := a.authService.GetAPIClient() // Make the HTTP GET request for download URL // Note: Backend uses singular "file" not plural "files" in the path req, err := http.NewRequestWithContext(a.ctx, "GET", apiClient.GetBaseURL()+"/api/v1/file/"+fileID+"/download-url", nil) if err != nil { a.logger.Error("Failed to create download URL request", zap.Error(err)) return "", fmt.Errorf("failed to create request: %w", err) } req.Header.Set("Authorization", "Bearer "+session.AccessToken) resp, err := a.httpClient.Do(req) if err != nil { a.logger.Error("Failed to send download URL request", zap.Error(err)) return "", fmt.Errorf("failed to send request: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { body, _ := io.ReadAll(resp.Body) a.logger.Error("Failed to get download URL", zap.Int("status", resp.StatusCode), zap.String("body", string(body))) return "", fmt.Errorf("failed to get download URL: %s", string(body)) } // Response structure matches backend's GetPresignedDownloadURLResponseDTO var urlResp struct { PresignedDownloadURL string `json:"presigned_download_url"` DownloadURLExpirationTime string `json:"download_url_expiration_time"` Success bool `json:"success"` Message string `json:"message"` } if err := json.NewDecoder(resp.Body).Decode(&urlResp); err != nil { a.logger.Error("Failed to decode download URL response", zap.Error(err)) return "", fmt.Errorf("failed to decode response: %w", err) } return urlResp.PresignedDownloadURL, nil } // DownloadFile downloads, decrypts, and saves a file to the user's chosen location. // If the file already exists locally, it copies from local storage instead of re-downloading. func (a *Application) DownloadFile(fileID string) (string, error) { // Validate input if err := inputvalidation.ValidateFileID(fileID); err != nil { return "", err } a.logger.Info("Starting file download", zap.String("file_id", fileID)) // First, check if file already exists locally localFile, err := a.mustGetFileRepo().Get(fileID) if err == nil && localFile != nil && localFile.FilePath != "" { // Check if local file actually exists on disk if _, statErr := os.Stat(localFile.FilePath); statErr == nil { a.logger.Info("File exists locally, using local copy", zap.String("file_id", fileID), zap.String("local_path", localFile.FilePath)) // Open save dialog for user to choose location savePath, dialogErr := runtime.SaveFileDialog(a.ctx, runtime.SaveDialogOptions{ Title: "Save File As", DefaultFilename: localFile.Name, }) if dialogErr != nil { return "", fmt.Errorf("failed to open save dialog: %w", dialogErr) } // User cancelled the dialog if savePath == "" { a.logger.Info("User cancelled save dialog") return "", nil } // Copy local file to chosen location srcFile, copyErr := os.Open(localFile.FilePath) if copyErr != nil { return "", fmt.Errorf("failed to open local file: %w", copyErr) } defer srcFile.Close() dstFile, copyErr := os.Create(savePath) if copyErr != nil { return "", fmt.Errorf("failed to create destination file: %w", copyErr) } defer dstFile.Close() if _, copyErr := io.Copy(dstFile, srcFile); copyErr != nil { return "", fmt.Errorf("failed to copy file: %w", copyErr) } a.logger.Info("File saved from local copy", zap.String("file_id", fileID), zap.String("save_path", savePath)) return savePath, nil } } // File not available locally, download from cloud a.logger.Info("File not available locally, downloading from cloud", zap.String("file_id", fileID)) // Get current session for authentication session, err := a.authService.GetCurrentSession(a.ctx) if err != nil { a.logger.Error("Failed to get current session", zap.Error(err)) return "", fmt.Errorf("not authenticated: %w", err) } // Get master key from cache email := session.Email masterKey, cleanupMasterKey, err := a.keyCache.GetMasterKey(email) if err != nil { a.logger.Error("Failed to get master key from cache", zap.Error(err)) return "", fmt.Errorf("encryption key not available: %w", err) } defer cleanupMasterKey() apiClient := a.authService.GetAPIClient() // Step 1: Get file metadata fileReq, err := http.NewRequestWithContext(a.ctx, "GET", apiClient.GetBaseURL()+"/api/v1/file/"+fileID, nil) if err != nil { a.logger.Error("Failed to create get file request", zap.Error(err)) return "", fmt.Errorf("failed to create request: %w", err) } fileReq.Header.Set("Authorization", "Bearer "+session.AccessToken) fileResp, err := a.httpClient.Do(fileReq) if err != nil { a.logger.Error("Failed to get file metadata", zap.Error(err)) return "", fmt.Errorf("failed to get file: %w", err) } defer fileResp.Body.Close() if fileResp.StatusCode != http.StatusOK { body, _ := io.ReadAll(fileResp.Body) a.logger.Error("Failed to get file", zap.Int("status", fileResp.StatusCode), zap.String("body", string(body))) return "", fmt.Errorf("failed to get file: %s", string(body)) } var fileData struct { ID string `json:"id"` CollectionID string `json:"collection_id"` EncryptedMetadata string `json:"encrypted_metadata"` EncryptedFileKey struct { Ciphertext string `json:"ciphertext"` Nonce string `json:"nonce"` } `json:"encrypted_file_key"` } if err := json.NewDecoder(fileResp.Body).Decode(&fileData); err != nil { a.logger.Error("Failed to decode file response", zap.Error(err)) return "", fmt.Errorf("failed to decode response: %w", err) } // Step 2: Get collection to decrypt collection key collReq, err := http.NewRequestWithContext(a.ctx, "GET", apiClient.GetBaseURL()+"/api/v1/collections/"+fileData.CollectionID, nil) if err != nil { a.logger.Error("Failed to create get collection request", zap.Error(err)) return "", fmt.Errorf("failed to create request: %w", err) } collReq.Header.Set("Authorization", "Bearer "+session.AccessToken) collResp, err := a.httpClient.Do(collReq) if err != nil { a.logger.Error("Failed to get collection", zap.Error(err)) return "", fmt.Errorf("failed to get collection: %w", err) } defer collResp.Body.Close() if collResp.StatusCode != http.StatusOK { body, _ := io.ReadAll(collResp.Body) a.logger.Error("Failed to get collection", zap.Int("status", collResp.StatusCode), zap.String("body", string(body))) return "", fmt.Errorf("failed to get collection: %s", string(body)) } var collData struct { EncryptedCollectionKey struct { Ciphertext string `json:"ciphertext"` Nonce string `json:"nonce"` } `json:"encrypted_collection_key"` } if err := json.NewDecoder(collResp.Body).Decode(&collData); err != nil { a.logger.Error("Failed to decode collection response", zap.Error(err)) return "", fmt.Errorf("failed to decode collection: %w", err) } // Step 3: Decrypt collection key with master key // Use tryDecodeBase64 to handle multiple base64 encoding formats collKeyNonce, err := tryDecodeBase64(collData.EncryptedCollectionKey.Nonce) if err != nil { return "", fmt.Errorf("failed to decode collection key nonce: %w", err) } collKeyCiphertext, err := tryDecodeBase64(collData.EncryptedCollectionKey.Ciphertext) if err != nil { return "", fmt.Errorf("failed to decode collection key ciphertext: %w", err) } // Handle web frontend combined ciphertext format (nonce + encrypted_data) actualCollKeyCiphertext := extractActualCiphertext(collKeyCiphertext, collKeyNonce) collectionKey, err := e2ee.DecryptCollectionKey(&e2ee.EncryptedKey{ Ciphertext: actualCollKeyCiphertext, Nonce: collKeyNonce, }, masterKey) if err != nil { a.logger.Error("Failed to decrypt collection key", zap.Error(err)) return "", fmt.Errorf("failed to decrypt collection key: %w", err) } // Step 4: Decrypt file key with collection key // NOTE: The web frontend may send combined ciphertext (nonce + encrypted_data) // or separate fields. We handle both formats. // Use tryDecodeBase64 to handle multiple base64 encoding formats fileKeyNonce, err := tryDecodeBase64(fileData.EncryptedFileKey.Nonce) if err != nil { return "", fmt.Errorf("failed to decode file key nonce: %w", err) } fileKeyCiphertext, err := tryDecodeBase64(fileData.EncryptedFileKey.Ciphertext) if err != nil { return "", fmt.Errorf("failed to decode file key ciphertext: %w", err) } // Handle web frontend combined ciphertext format (nonce + encrypted_data) actualFileKeyCiphertext := extractActualCiphertext(fileKeyCiphertext, fileKeyNonce) a.logger.Info("Decrypting file key", zap.Int("nonce_size", len(fileKeyNonce)), zap.Int("ciphertext_size", len(actualFileKeyCiphertext)), zap.Int("collection_key_size", len(collectionKey))) fileKey, err := e2ee.DecryptFileKey(&e2ee.EncryptedKey{ Ciphertext: actualFileKeyCiphertext, Nonce: fileKeyNonce, }, collectionKey) if err != nil { a.logger.Error("Failed to decrypt file key", zap.Error(err)) return "", fmt.Errorf("failed to decrypt file key: %w", err) } a.logger.Info("File key decrypted successfully", zap.Int("file_key_size", len(fileKey))) // Step 5: Decrypt metadata to get filename // Use tryDecodeBase64 to handle URL-safe base64 without padding (libsodium format) encryptedMetadataBytes, err := tryDecodeBase64(fileData.EncryptedMetadata) if err != nil { return "", fmt.Errorf("failed to decode metadata: %w", err) } metadataNonce, metadataCiphertext, err := e2ee.SplitNonceAndCiphertextAuto(encryptedMetadataBytes) if err != nil { return "", fmt.Errorf("failed to parse metadata: %w", err) } decryptedMetadata, err := e2ee.DecryptWithAlgorithm(metadataCiphertext, metadataNonce, fileKey) if err != nil { return "", fmt.Errorf("failed to decrypt metadata: %w", err) } var metadata struct { Filename string `json:"name"` MimeType string `json:"mime_type"` } if err := json.Unmarshal(decryptedMetadata, &metadata); err != nil { return "", fmt.Errorf("failed to parse metadata: %w", err) } // Step 6: Get presigned download URL downloadURL, err := a.GetFileDownloadURL(fileID) if err != nil { return "", fmt.Errorf("failed to get download URL: %w", err) } // Step 6.5: Validate download URL before use (SSRF protection) if err := inputvalidation.ValidateDownloadURL(downloadURL); err != nil { a.logger.Error("Download URL validation failed", zap.String("file_id", fileID), zap.Error(err)) return "", fmt.Errorf("download URL validation failed: %w", err) } // Step 7: Download encrypted file from S3 (use large download client - no timeout for big files) a.logger.Info("Downloading encrypted file from S3", zap.String("filename", metadata.Filename)) downloadResp, err := a.httpClient.GetLargeDownload(downloadURL) if err != nil { a.logger.Error("Failed to download file from S3", zap.Error(err)) return "", fmt.Errorf("failed to download file: %w", err) } defer downloadResp.Body.Close() if downloadResp.StatusCode != http.StatusOK { body, _ := io.ReadAll(downloadResp.Body) a.logger.Error("S3 download failed", zap.Int("status", downloadResp.StatusCode), zap.String("body", string(body))) return "", fmt.Errorf("failed to download file from storage: status %d", downloadResp.StatusCode) } encryptedContent, err := io.ReadAll(downloadResp.Body) if err != nil { a.logger.Error("Failed to read encrypted content", zap.Error(err)) return "", fmt.Errorf("failed to read file content: %w", err) } a.logger.Info("Downloaded encrypted file", zap.Int("encrypted_size", len(encryptedContent))) // Step 8: Decrypt file content a.logger.Info("Decrypting file content", zap.Int("encrypted_size", len(encryptedContent)), zap.Int("file_key_size", len(fileKey)), zap.Int("first_bytes_of_content", int(encryptedContent[0]))) decryptedContent, err := e2ee.DecryptFile(encryptedContent, fileKey) if err != nil { a.logger.Error("Failed to decrypt file content", zap.Error(err), zap.Int("encrypted_size", len(encryptedContent)), zap.Int("file_key_size", len(fileKey))) return "", fmt.Errorf("failed to decrypt file: %w", err) } a.logger.Info("File content decrypted successfully", zap.Int("decrypted_size", len(decryptedContent))) a.logger.Info("Decrypted file", zap.Int("decrypted_size", len(decryptedContent))) // Step 9: Open save dialog for user to choose location savePath, err := runtime.SaveFileDialog(a.ctx, runtime.SaveDialogOptions{ Title: "Save File As", DefaultFilename: metadata.Filename, }) if err != nil { a.logger.Error("Failed to open save dialog", zap.Error(err)) return "", fmt.Errorf("failed to open save dialog: %w", err) } // User cancelled the dialog if savePath == "" { a.logger.Info("User cancelled save dialog") return "", nil } // Step 10: Write decrypted content to file (0600 = owner read/write only for security) if err := os.WriteFile(savePath, decryptedContent, 0600); err != nil { a.logger.Error("Failed to write file", zap.Error(err), zap.String("path", savePath)) return "", fmt.Errorf("failed to save file: %w", err) } a.logger.Info("File downloaded and decrypted successfully", zap.String("file_id", fileID), zap.String("filename", metadata.Filename), zap.String("save_path", savePath), zap.Int("size", len(decryptedContent))) return savePath, nil } // OnloadFileResult represents the result of onloading a file for offline access type OnloadFileResult struct { FileID string `json:"file_id"` Filename string `json:"filename"` LocalFilePath string `json:"local_file_path"` Size int64 `json:"size"` Success bool `json:"success"` Message string `json:"message"` } // OnloadFile downloads and stores a file locally for offline access (no save dialog) func (a *Application) OnloadFile(fileID string) (*OnloadFileResult, error) { // Validate input if err := inputvalidation.ValidateFileID(fileID); err != nil { return nil, err } a.logger.Info("Onloading file for offline access", zap.String("file_id", fileID)) // Get current session for authentication session, err := a.authService.GetCurrentSession(a.ctx) if err != nil { a.logger.Error("Failed to get current session", zap.Error(err)) return nil, fmt.Errorf("not authenticated: %w", err) } // Get master key from cache email := session.Email masterKey, cleanupMasterKey, err := a.keyCache.GetMasterKey(email) if err != nil { a.logger.Error("Failed to get master key from cache", zap.Error(err)) return nil, fmt.Errorf("encryption key not available: %w", err) } defer cleanupMasterKey() apiClient := a.authService.GetAPIClient() // Step 1: Get file metadata fileReq, err := http.NewRequestWithContext(a.ctx, "GET", apiClient.GetBaseURL()+"/api/v1/file/"+fileID, nil) if err != nil { return nil, fmt.Errorf("failed to create request: %w", err) } fileReq.Header.Set("Authorization", "Bearer "+session.AccessToken) fileResp, err := a.httpClient.Do(fileReq) if err != nil { return nil, fmt.Errorf("failed to get file: %w", err) } defer fileResp.Body.Close() if fileResp.StatusCode != http.StatusOK { body, _ := io.ReadAll(fileResp.Body) return nil, fmt.Errorf("failed to get file: %s", string(body)) } var fileData struct { ID string `json:"id"` CollectionID string `json:"collection_id"` EncryptedMetadata string `json:"encrypted_metadata"` FileNonce string `json:"file_nonce"` EncryptedSizeInBytes int64 `json:"encrypted_file_size_in_bytes"` EncryptedFileKey struct { Ciphertext string `json:"ciphertext"` Nonce string `json:"nonce"` } `json:"encrypted_file_key"` } if err := json.NewDecoder(fileResp.Body).Decode(&fileData); err != nil { return nil, fmt.Errorf("failed to decode response: %w", err) } // Step 2: Get collection to decrypt collection key collReq, err := http.NewRequestWithContext(a.ctx, "GET", apiClient.GetBaseURL()+"/api/v1/collections/"+fileData.CollectionID, nil) if err != nil { return nil, fmt.Errorf("failed to create request: %w", err) } collReq.Header.Set("Authorization", "Bearer "+session.AccessToken) collResp, err := a.httpClient.Do(collReq) if err != nil { return nil, fmt.Errorf("failed to get collection: %w", err) } defer collResp.Body.Close() if collResp.StatusCode != http.StatusOK { body, _ := io.ReadAll(collResp.Body) return nil, fmt.Errorf("failed to get collection: %s", string(body)) } var collData struct { EncryptedCollectionKey struct { Ciphertext string `json:"ciphertext"` Nonce string `json:"nonce"` } `json:"encrypted_collection_key"` } if err := json.NewDecoder(collResp.Body).Decode(&collData); err != nil { return nil, fmt.Errorf("failed to decode collection: %w", err) } // Step 3: Decrypt collection key with master key // Use tryDecodeBase64 to handle multiple base64 encoding formats collKeyNonce, err := tryDecodeBase64(collData.EncryptedCollectionKey.Nonce) if err != nil { return nil, fmt.Errorf("failed to decode collection key nonce: %w", err) } collKeyCiphertext, err := tryDecodeBase64(collData.EncryptedCollectionKey.Ciphertext) if err != nil { return nil, fmt.Errorf("failed to decode collection key ciphertext: %w", err) } // Handle web frontend combined ciphertext format (nonce + encrypted_data) actualCollKeyCiphertext := extractActualCiphertext(collKeyCiphertext, collKeyNonce) collectionKey, err := e2ee.DecryptCollectionKey(&e2ee.EncryptedKey{ Ciphertext: actualCollKeyCiphertext, Nonce: collKeyNonce, }, masterKey) if err != nil { return nil, fmt.Errorf("failed to decrypt collection key: %w", err) } // Step 4: Decrypt file key with collection key // NOTE: The web frontend may send combined ciphertext (nonce + encrypted_data) // or separate fields. We handle both formats. // Use tryDecodeBase64 to handle multiple base64 encoding formats fileKeyNonce, err := tryDecodeBase64(fileData.EncryptedFileKey.Nonce) if err != nil { return nil, fmt.Errorf("failed to decode file key nonce: %w", err) } fileKeyCiphertext, err := tryDecodeBase64(fileData.EncryptedFileKey.Ciphertext) if err != nil { return nil, fmt.Errorf("failed to decode file key ciphertext: %w", err) } // Handle web frontend combined ciphertext format (nonce + encrypted_data) actualFileKeyCiphertext := extractActualCiphertext(fileKeyCiphertext, fileKeyNonce) fileKey, err := e2ee.DecryptFileKey(&e2ee.EncryptedKey{ Ciphertext: actualFileKeyCiphertext, Nonce: fileKeyNonce, }, collectionKey) if err != nil { return nil, fmt.Errorf("failed to decrypt file key: %w", err) } // Step 5: Decrypt metadata to get filename // Use tryDecodeBase64 to handle URL-safe base64 without padding (libsodium format) encryptedMetadataBytes, err := tryDecodeBase64(fileData.EncryptedMetadata) if err != nil { return nil, fmt.Errorf("failed to decode metadata: %w", err) } metadataNonce, metadataCiphertext, err := e2ee.SplitNonceAndCiphertextAuto(encryptedMetadataBytes) if err != nil { return nil, fmt.Errorf("failed to parse metadata: %w", err) } decryptedMetadata, err := e2ee.DecryptWithAlgorithm(metadataCiphertext, metadataNonce, fileKey) if err != nil { return nil, fmt.Errorf("failed to decrypt metadata: %w", err) } var metadata struct { Filename string `json:"name"` MimeType string `json:"mime_type"` } if err := json.Unmarshal(decryptedMetadata, &metadata); err != nil { return nil, fmt.Errorf("failed to parse metadata: %w", err) } // Step 6: Get presigned download URL downloadURL, err := a.GetFileDownloadURL(fileID) if err != nil { return nil, fmt.Errorf("failed to get download URL: %w", err) } // Step 6.5: Validate download URL before use (SSRF protection) if err := inputvalidation.ValidateDownloadURL(downloadURL); err != nil { a.logger.Error("Download URL validation failed", zap.String("file_id", fileID), zap.Error(err)) return nil, fmt.Errorf("download URL validation failed: %w", err) } // Step 7: Download encrypted file from S3 (use large download client - no timeout for big files) a.logger.Info("Downloading encrypted file from S3", zap.String("filename", metadata.Filename)) downloadResp, err := a.httpClient.GetLargeDownload(downloadURL) if err != nil { return nil, fmt.Errorf("failed to download file: %w", err) } defer downloadResp.Body.Close() if downloadResp.StatusCode != http.StatusOK { return nil, fmt.Errorf("failed to download file from storage: status %d", downloadResp.StatusCode) } encryptedContent, err := io.ReadAll(downloadResp.Body) if err != nil { return nil, fmt.Errorf("failed to read file content: %w", err) } // Step 8: Decrypt file content decryptedContent, err := e2ee.DecryptFile(encryptedContent, fileKey) if err != nil { return nil, fmt.Errorf("failed to decrypt file: %w", err) } // Step 9: Create local storage directory structure dataDir, err := a.config.GetAppDataDirPath(a.ctx) if err != nil { return nil, fmt.Errorf("failed to get data directory: %w", err) } // Create files directory: /files// filesDir := filepath.Join(dataDir, "files", fileData.CollectionID) if err := os.MkdirAll(filesDir, 0700); err != nil { return nil, fmt.Errorf("failed to create files directory: %w", err) } // Save decrypted file localFilePath := filepath.Join(filesDir, metadata.Filename) if err := os.WriteFile(localFilePath, decryptedContent, 0600); err != nil { return nil, fmt.Errorf("failed to save file: %w", err) } // Step 10: Update local file repository with sync status localFile := &file.File{ ID: fileID, CollectionID: fileData.CollectionID, Name: metadata.Filename, MimeType: metadata.MimeType, FilePath: localFilePath, DecryptedSizeInBytes: int64(len(decryptedContent)), EncryptedSizeInBytes: fileData.EncryptedSizeInBytes, SyncStatus: file.SyncStatusSynced, EncryptedFileKey: file.EncryptedFileKeyData{ Ciphertext: fileData.EncryptedFileKey.Ciphertext, Nonce: fileData.EncryptedFileKey.Nonce, }, EncryptedMetadata: fileData.EncryptedMetadata, FileNonce: fileData.FileNonce, } // Check if file already exists in local repo existingFile, _ := a.mustGetFileRepo().Get(fileID) if existingFile != nil { if err := a.mustGetFileRepo().Update(localFile); err != nil { a.logger.Warn("Failed to update local file record", zap.Error(err)) } } else { if err := a.mustGetFileRepo().Create(localFile); err != nil { a.logger.Warn("Failed to create local file record", zap.Error(err)) } } a.logger.Info("File onloaded successfully", zap.String("file_id", fileID), zap.String("filename", metadata.Filename), zap.String("local_path", localFilePath), zap.Int("size", len(decryptedContent))) return &OnloadFileResult{ FileID: fileID, Filename: metadata.Filename, LocalFilePath: localFilePath, Size: int64(len(decryptedContent)), Success: true, Message: "File downloaded for offline access", }, nil } // OffloadFile removes the local copy of a file while keeping it in the cloud func (a *Application) OffloadFile(fileID string) error { // Validate input if err := inputvalidation.ValidateFileID(fileID); err != nil { return err } a.logger.Info("Offloading file to cloud-only", zap.String("file_id", fileID)) // Get the file from local repository localFile, err := a.mustGetFileRepo().Get(fileID) if err != nil { a.logger.Error("Failed to get file from local repo", zap.Error(err)) return fmt.Errorf("file not found locally: %w", err) } if localFile == nil { return fmt.Errorf("file not found in local storage") } if !localFile.HasLocalContent() { a.logger.Info("File already cloud-only, nothing to offload") return nil } // Delete the local file from disk if localFile.FilePath != "" { if err := os.Remove(localFile.FilePath); err != nil && !os.IsNotExist(err) { a.logger.Warn("Failed to delete local file", zap.Error(err), zap.String("path", localFile.FilePath)) // Continue anyway - we'll update the metadata } else { a.logger.Info("Deleted local file", zap.String("path", localFile.FilePath)) } } // Delete encrypted file if it exists if localFile.EncryptedFilePath != "" { if err := os.Remove(localFile.EncryptedFilePath); err != nil && !os.IsNotExist(err) { a.logger.Warn("Failed to delete encrypted file", zap.Error(err), zap.String("path", localFile.EncryptedFilePath)) } } // Delete thumbnail if it exists if localFile.ThumbnailPath != "" { if err := os.Remove(localFile.ThumbnailPath); err != nil && !os.IsNotExist(err) { a.logger.Warn("Failed to delete thumbnail", zap.Error(err), zap.String("path", localFile.ThumbnailPath)) } } // Update the local file record to cloud-only status localFile.FilePath = "" localFile.EncryptedFilePath = "" localFile.ThumbnailPath = "" localFile.SyncStatus = file.SyncStatusCloudOnly if err := a.mustGetFileRepo().Update(localFile); err != nil { a.logger.Error("Failed to update file record", zap.Error(err)) return fmt.Errorf("failed to update file record: %w", err) } a.logger.Info("File offloaded successfully", zap.String("file_id", fileID), zap.String("filename", localFile.Name)) return nil } // OpenFile opens a locally stored file with the system's default application func (a *Application) OpenFile(fileID string) error { // Validate input if err := inputvalidation.ValidateFileID(fileID); err != nil { return err } a.logger.Info("Opening file", zap.String("file_id", fileID)) // Get the file from local repository localFile, err := a.mustGetFileRepo().Get(fileID) if err != nil { a.logger.Error("Failed to get file from local repo", zap.Error(err)) return fmt.Errorf("file not found locally: %w", err) } if localFile == nil { return fmt.Errorf("file not found in local storage") } if localFile.FilePath == "" { return fmt.Errorf("file has not been downloaded for offline access") } // Security: Validate file path is within expected application data directory appDataDir, err := a.config.GetAppDataDirPath(a.ctx) if err != nil { a.logger.Error("Failed to get app data directory", zap.Error(err)) return fmt.Errorf("failed to validate file path: %w", err) } if err := validatePathWithinDirectory(localFile.FilePath, appDataDir); err != nil { a.logger.Error("File path validation failed", zap.String("file_path", localFile.FilePath), zap.String("expected_dir", appDataDir), zap.Error(err)) return fmt.Errorf("invalid file path: %w", err) } // Check if file exists on disk if _, err := os.Stat(localFile.FilePath); os.IsNotExist(err) { return fmt.Errorf("file no longer exists at %s", localFile.FilePath) } // Open the file with the system's default application var cmd *exec.Cmd switch sysRuntime.GOOS { case "darwin": cmd = exec.Command("open", localFile.FilePath) case "windows": cmd = exec.Command("cmd", "/c", "start", "", localFile.FilePath) case "linux": cmd = exec.Command("xdg-open", localFile.FilePath) default: return fmt.Errorf("unsupported operating system: %s", sysRuntime.GOOS) } if err := cmd.Start(); err != nil { a.logger.Error("Failed to open file", zap.Error(err), zap.String("path", localFile.FilePath)) return fmt.Errorf("failed to open file: %w", err) } a.logger.Info("File opened successfully", zap.String("file_id", fileID), zap.String("path", localFile.FilePath)) return nil } // validatePathWithinDirectory checks that a file path is within the expected directory. // This is a defense-in-depth measure to prevent path traversal attacks. func validatePathWithinDirectory(filePath, expectedDir string) error { // Get absolute paths to handle any relative path components absFilePath, err := filepath.Abs(filePath) if err != nil { return fmt.Errorf("failed to resolve file path: %w", err) } absExpectedDir, err := filepath.Abs(expectedDir) if err != nil { return fmt.Errorf("failed to resolve expected directory: %w", err) } // Clean paths to remove any . or .. components absFilePath = filepath.Clean(absFilePath) absExpectedDir = filepath.Clean(absExpectedDir) // Ensure the expected directory ends with a separator to prevent partial matches // e.g., /app/data should not match /app/data-other/file if !strings.HasSuffix(absExpectedDir, string(filepath.Separator)) { absExpectedDir = absExpectedDir + string(filepath.Separator) } // Check if the file path starts with the expected directory if !strings.HasPrefix(absFilePath, absExpectedDir) && absFilePath != strings.TrimSuffix(absExpectedDir, string(filepath.Separator)) { return fmt.Errorf("path is outside application data directory") } return nil }