Initial commit: Open sourcing all of the Maple Open Technologies code.
This commit is contained in:
commit
755d54a99d
2010 changed files with 448675 additions and 0 deletions
880
native/desktop/maplefile/internal/app/app_files_download.go
Normal file
880
native/desktop/maplefile/internal/app/app_files_download.go
Normal file
|
|
@ -0,0 +1,880 @@
|
|||
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: <data_dir>/files/<collection_id>/
|
||||
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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue