package app import ( "bytes" "crypto/sha256" "encoding/base64" "encoding/json" "fmt" "io" "net/http" "os" "path/filepath" "time" "github.com/google/uuid" "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" ) // ============================================================================= // FILE UPLOAD OPERATIONS // ============================================================================= // SelectFile opens a native file dialog and returns the selected file path func (a *Application) SelectFile() (string, error) { selection, err := runtime.OpenFileDialog(a.ctx, runtime.OpenDialogOptions{ Title: "Select File to Upload", Filters: []runtime.FileFilter{ {DisplayName: "All Files", Pattern: "*.*"}, {DisplayName: "Images", Pattern: "*.jpg;*.jpeg;*.png;*.gif;*.webp;*.bmp"}, {DisplayName: "Documents", Pattern: "*.pdf;*.doc;*.docx;*.txt;*.md"}, {DisplayName: "Videos", Pattern: "*.mp4;*.mov;*.avi;*.mkv;*.webm"}, }, }) if err != nil { a.logger.Error("Failed to open file dialog", zap.Error(err)) return "", fmt.Errorf("failed to open file dialog: %w", err) } return selection, nil } // FileUploadInput represents the input for uploading a file type FileUploadInput struct { FilePath string `json:"file_path"` CollectionID string `json:"collection_id"` TagIDs []string `json:"tag_ids,omitempty"` // Tag IDs to assign to this file } // FileUploadResult represents the result of a file upload type FileUploadResult struct { FileID string `json:"file_id"` Filename string `json:"filename"` Size int64 `json:"size"` Success bool `json:"success"` Message string `json:"message"` } // UploadFile encrypts and uploads a file to a collection func (a *Application) UploadFile(input FileUploadInput) (*FileUploadResult, error) { a.logger.Info("Starting file upload", zap.String("file_path", input.FilePath), zap.String("collection_id", input.CollectionID)) // 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 key cache masterKey, cleanup, err := a.keyCache.GetMasterKey(session.Email) if err != nil { a.logger.Error("Failed to get master key from cache", zap.Error(err)) return nil, fmt.Errorf("master key not available - please log in again: %w", err) } defer cleanup() apiClient := a.authService.GetAPIClient() // Step 1: Read the file from disk fileContent, err := os.ReadFile(input.FilePath) if err != nil { a.logger.Error("Failed to read file", zap.Error(err)) return nil, fmt.Errorf("failed to read file: %w", err) } filename := filepath.Base(input.FilePath) fileSize := int64(len(fileContent)) mimeType := http.DetectContentType(fileContent) a.logger.Info("File read successfully", zap.String("filename", filename), zap.Int64("size", fileSize), zap.String("mime_type", mimeType)) // Step 2: Get collection key (need to fetch collection first) a.logger.Info("Step 2: Fetching collection for upload", zap.String("collection_id", input.CollectionID), zap.String("api_url", apiClient.GetBaseURL()+"/api/v1/collections/"+input.CollectionID)) collectionReq, err := http.NewRequestWithContext(a.ctx, "GET", apiClient.GetBaseURL()+"/api/v1/collections/"+input.CollectionID, nil) if err != nil { a.logger.Error("Failed to create collection request", zap.Error(err)) return nil, fmt.Errorf("failed to create collection request: %w", err) } collectionReq.Header.Set("Authorization", "Bearer "+session.AccessToken) collectionResp, err := a.httpClient.Do(collectionReq) if err != nil { a.logger.Error("Failed to fetch collection", zap.Error(err)) return nil, fmt.Errorf("failed to fetch collection: %w", err) } defer collectionResp.Body.Close() a.logger.Info("Step 2a: Collection fetch response", zap.Int("status", collectionResp.StatusCode)) if collectionResp.StatusCode != http.StatusOK { body, _ := io.ReadAll(collectionResp.Body) a.logger.Error("Failed to fetch collection - bad status", zap.Int("status", collectionResp.StatusCode), zap.String("body", string(body))) return nil, fmt.Errorf("failed to fetch collection: %s", string(body)) } var collectionData struct { EncryptedCollectionKey struct { Ciphertext string `json:"ciphertext"` Nonce string `json:"nonce"` } `json:"encrypted_collection_key"` } if err := json.NewDecoder(collectionResp.Body).Decode(&collectionData); err != nil { a.logger.Error("Failed to decode collection response", zap.Error(err)) return nil, fmt.Errorf("failed to decode collection: %w", err) } a.logger.Info("Step 2b: Collection data decoded", zap.Int("ciphertext_len", len(collectionData.EncryptedCollectionKey.Ciphertext)), zap.Int("nonce_len", len(collectionData.EncryptedCollectionKey.Nonce))) // Decrypt collection key collectionKeyCiphertext, err := base64.StdEncoding.DecodeString(collectionData.EncryptedCollectionKey.Ciphertext) if err != nil { a.logger.Error("Failed to decode collection key ciphertext", zap.Error(err)) return nil, fmt.Errorf("failed to decode collection key ciphertext: %w", err) } collectionKeyNonce, err := base64.StdEncoding.DecodeString(collectionData.EncryptedCollectionKey.Nonce) if err != nil { a.logger.Error("Failed to decode collection key nonce", zap.Error(err)) return nil, fmt.Errorf("failed to decode collection key nonce: %w", err) } // Handle web frontend combined ciphertext format (nonce + encrypted_data) actualCollectionKeyCiphertext := extractActualCiphertext(collectionKeyCiphertext, collectionKeyNonce) a.logger.Info("Step 2c: Decrypting collection key", zap.Int("ciphertext_bytes", len(actualCollectionKeyCiphertext)), zap.Int("nonce_bytes", len(collectionKeyNonce)), zap.Int("master_key_bytes", len(masterKey))) collectionKey, err := e2ee.DecryptCollectionKey(&e2ee.EncryptedKey{ Ciphertext: actualCollectionKeyCiphertext, Nonce: collectionKeyNonce, }, masterKey) if err != nil { a.logger.Error("Failed to decrypt collection key", zap.Error(err)) return nil, fmt.Errorf("failed to decrypt collection key: %w", err) } a.logger.Info("Collection key decrypted successfully", zap.Int("key_length", len(collectionKey))) // Step 3: Generate a new file key fileKey, err := e2ee.GenerateFileKey() if err != nil { return nil, fmt.Errorf("failed to generate file key: %w", err) } // Step 4: Encrypt file content using SecretBox (XSalsa20-Poly1305) for web frontend compatibility encryptedContent, err := e2ee.EncryptFileSecretBox(fileContent, fileKey) if err != nil { return nil, fmt.Errorf("failed to encrypt file: %w", err) } // Step 5: Encrypt metadata using SecretBox (XSalsa20-Poly1305) for web frontend compatibility metadata := &e2ee.FileMetadata{ Name: filename, MimeType: mimeType, Size: fileSize, } encryptedMetadata, err := e2ee.EncryptMetadataSecretBox(metadata, fileKey) if err != nil { return nil, fmt.Errorf("failed to encrypt metadata: %w", err) } // Step 6: Encrypt file key with collection key using SecretBox for web frontend compatibility encryptedFileKey, err := e2ee.EncryptFileKeySecretBox(fileKey, collectionKey) if err != nil { return nil, fmt.Errorf("failed to encrypt file key: %w", err) } // Step 7: Compute encrypted hash hash := sha256.Sum256(encryptedContent) encryptedHash := base64.StdEncoding.EncodeToString(hash[:]) // Step 8: Generate client-side file ID fileID := uuid.New().String() // Step 9: Create pending file request // NOTE: The web frontend sends ciphertext and nonce as SEPARATE fields (not combined). // The ciphertext field contains only the encrypted data (from crypto_secretbox_easy), // and the nonce field contains the nonce separately. pendingFileReq := map[string]interface{}{ "id": fileID, "collection_id": input.CollectionID, "encrypted_metadata": encryptedMetadata, "encrypted_file_key": map[string]string{ "ciphertext": base64.StdEncoding.EncodeToString(encryptedFileKey.Ciphertext), "nonce": base64.StdEncoding.EncodeToString(encryptedFileKey.Nonce), }, "encryption_version": "xsalsa20-poly1305-v1", "encrypted_hash": encryptedHash, "expected_file_size_in_bytes": int64(len(encryptedContent)), "content_type": mimeType, } // Add tag IDs if provided if len(input.TagIDs) > 0 { pendingFileReq["tag_ids"] = input.TagIDs a.logger.Info("Adding tags to file upload", zap.Int("tag_count", len(input.TagIDs))) } pendingBody, err := json.Marshal(pendingFileReq) if err != nil { return nil, fmt.Errorf("failed to marshal pending file request: %w", err) } req, err := http.NewRequestWithContext(a.ctx, "POST", apiClient.GetBaseURL()+"/api/v1/files/pending", bytes.NewReader(pendingBody)) if err != nil { return nil, fmt.Errorf("failed to create pending file request: %w", err) } req.Header.Set("Authorization", "Bearer "+session.AccessToken) req.Header.Set("Content-Type", "application/json") resp, err := a.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("failed to create pending file: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated { body, _ := io.ReadAll(resp.Body) a.logger.Error("Failed to create pending file", zap.Int("status", resp.StatusCode), zap.String("body", string(body))) return nil, fmt.Errorf("failed to create pending file: %s", string(body)) } var pendingResp struct { File struct { ID string `json:"id"` } `json:"file"` PresignedUploadURL string `json:"presigned_upload_url"` UploadURLExpirationTime string `json:"upload_url_expiration_time"` Success bool `json:"success"` Message string `json:"message"` } if err := json.NewDecoder(resp.Body).Decode(&pendingResp); err != nil { return nil, fmt.Errorf("failed to decode pending file response: %w", err) } if !pendingResp.Success { return nil, fmt.Errorf("failed to create pending file: %s", pendingResp.Message) } a.logger.Info("Pending file created, uploading to S3", zap.String("file_id", pendingResp.File.ID), zap.String("presigned_url", pendingResp.PresignedUploadURL[:50]+"...")) // Step 10: Upload encrypted content to S3 uploadReq, err := http.NewRequestWithContext(a.ctx, "PUT", pendingResp.PresignedUploadURL, bytes.NewReader(encryptedContent)) if err != nil { return nil, fmt.Errorf("failed to create upload request: %w", err) } uploadReq.Header.Set("Content-Type", "application/octet-stream") uploadReq.ContentLength = int64(len(encryptedContent)) uploadResp, err := a.httpClient.DoLargeDownload(uploadReq) if err != nil { return nil, fmt.Errorf("failed to upload to S3: %w", err) } defer uploadResp.Body.Close() if uploadResp.StatusCode != http.StatusOK && uploadResp.StatusCode != http.StatusCreated { body, _ := io.ReadAll(uploadResp.Body) a.logger.Error("Failed to upload to S3", zap.Int("status", uploadResp.StatusCode), zap.String("body", string(body))) return nil, fmt.Errorf("failed to upload to S3: status %d", uploadResp.StatusCode) } a.logger.Info("File uploaded to S3, completing upload") // Step 11: Complete the upload completeReq := map[string]interface{}{ "actual_file_size_in_bytes": int64(len(encryptedContent)), "upload_confirmed": true, } completeBody, err := json.Marshal(completeReq) if err != nil { return nil, fmt.Errorf("failed to marshal complete request: %w", err) } completeHTTPReq, err := http.NewRequestWithContext(a.ctx, "POST", apiClient.GetBaseURL()+"/api/v1/file/"+pendingResp.File.ID+"/complete", bytes.NewReader(completeBody)) if err != nil { return nil, fmt.Errorf("failed to create complete request: %w", err) } completeHTTPReq.Header.Set("Authorization", "Bearer "+session.AccessToken) completeHTTPReq.Header.Set("Content-Type", "application/json") completeResp, err := a.httpClient.Do(completeHTTPReq) if err != nil { return nil, fmt.Errorf("failed to complete upload: %w", err) } defer completeResp.Body.Close() if completeResp.StatusCode != http.StatusOK { body, _ := io.ReadAll(completeResp.Body) a.logger.Error("Failed to complete upload", zap.Int("status", completeResp.StatusCode), zap.String("body", string(body))) return nil, fmt.Errorf("failed to complete upload: %s", string(body)) } var completeRespData struct { Success bool `json:"success"` Message string `json:"message"` } if err := json.NewDecoder(completeResp.Body).Decode(&completeRespData); err != nil { return nil, fmt.Errorf("failed to decode complete response: %w", err) } // Save file metadata to local repository so it appears in dashboard and file list localFile := &file.File{ ID: pendingResp.File.ID, CollectionID: input.CollectionID, OwnerID: session.UserID, Name: filename, MimeType: mimeType, DecryptedSizeInBytes: fileSize, EncryptedSizeInBytes: int64(len(encryptedContent)), FilePath: input.FilePath, // Original file path SyncStatus: file.SyncStatusSynced, State: file.StateActive, CreatedAt: time.Now(), ModifiedAt: time.Now(), LastSyncedAt: time.Now(), } if err := a.mustGetFileRepo().Create(localFile); err != nil { // Log but don't fail - the upload succeeded, just local tracking failed a.logger.Warn("Failed to save file to local repository", zap.String("file_id", pendingResp.File.ID), zap.Error(err)) } else { a.logger.Info("File saved to local repository", zap.String("file_id", pendingResp.File.ID), zap.String("filename", filename)) // Index the file in the search index if err := a.indexFileForSearch(pendingResp.File.ID, input.CollectionID, filename, input.TagIDs, fileSize); err != nil { a.logger.Warn("Failed to index file in search", zap.String("file_id", pendingResp.File.ID), zap.Error(err)) } } a.logger.Info("File upload completed successfully", zap.String("file_id", pendingResp.File.ID), zap.String("filename", filename), zap.Int64("size", fileSize)) return &FileUploadResult{ FileID: pendingResp.File.ID, Filename: filename, Size: fileSize, Success: true, Message: "File uploaded successfully", }, nil }