package app import ( "encoding/json" "fmt" "io" "net/http" "time" "go.uber.org/zap" "codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/pkg/maplefile/client" "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 QUERY OPERATIONS // ============================================================================= // EmbeddedTagData represents a tag attached to a file (for display purposes) type EmbeddedTagData struct { ID string `json:"id"` Name string `json:"name"` Color string `json:"color"` } // FileDetailData represents detailed file information for the frontend type FileDetailData struct { ID string `json:"id"` CollectionID string `json:"collection_id"` Filename string `json:"filename"` MimeType string `json:"mime_type"` Size int64 `json:"size"` EncryptedFileSizeInBytes int64 `json:"encrypted_file_size_in_bytes"` CreatedAt string `json:"created_at"` ModifiedAt string `json:"modified_at"` Version uint64 `json:"version"` State string `json:"state"` // Sync status fields SyncStatus string `json:"sync_status"` HasLocalContent bool `json:"has_local_content"` LocalFilePath string `json:"local_file_path,omitempty"` // Tags assigned to this file Tags []*EmbeddedTagData `json:"tags"` } // ListFilesByCollection lists all files in a collection func (a *Application) ListFilesByCollection(collectionID string) ([]*FileData, error) { // Validate input if err := inputvalidation.ValidateCollectionID(collectionID); err != nil { return nil, err } apiClient := a.authService.GetAPIClient() files, err := apiClient.ListFilesByCollection(a.ctx, collectionID) if err != nil { a.logger.Error("Failed to list files", zap.String("collection_id", collectionID), zap.Error(err)) return nil, fmt.Errorf("failed to list files: %w", err) } // Get collection key for decrypting file metadata (needed for cloud-only files) var collectionKey []byte collectionKeyReady := false // Lazy-load collection key only when needed getCollectionKey := func() ([]byte, error) { if collectionKeyReady { return collectionKey, nil } // Get session for master key session, err := a.authService.GetCurrentSession(a.ctx) if err != nil { return nil, fmt.Errorf("failed to get session: %w", err) } // Get master key from cache masterKey, cleanup, err := a.keyCache.GetMasterKey(session.Email) if err != nil { return nil, fmt.Errorf("failed to get master key: %w", err) } defer cleanup() // Define a custom response struct that matches the actual backend API // (the client SDK's Collection struct has EncryptedCollectionKey as string) type collectionAPIResponse struct { EncryptedCollectionKey struct { Ciphertext string `json:"ciphertext"` Nonce string `json:"nonce"` } `json:"encrypted_collection_key"` } // Make direct HTTP request to get collection req, err := http.NewRequestWithContext(a.ctx, "GET", apiClient.GetBaseURL()+"/api/v1/collections/"+collectionID, nil) if err != nil { return nil, fmt.Errorf("failed to create request: %w", err) } accessToken, _ := apiClient.GetTokens() req.Header.Set("Authorization", "Bearer "+accessToken) resp, err := a.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("failed to fetch collection: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("failed to fetch collection: status %d", resp.StatusCode) } var collection collectionAPIResponse if err := json.NewDecoder(resp.Body).Decode(&collection); err != nil { return nil, fmt.Errorf("failed to decode collection response: %w", err) } // Decode collection key components // Use tryDecodeBase64 to handle multiple base64 encoding formats keyCiphertext, err := tryDecodeBase64(collection.EncryptedCollectionKey.Ciphertext) if err != nil { return nil, fmt.Errorf("failed to decode collection key ciphertext: %w", err) } keyNonce, err := tryDecodeBase64(collection.EncryptedCollectionKey.Nonce) if err != nil { return nil, fmt.Errorf("failed to decode collection key nonce: %w", err) } // Handle web frontend combined ciphertext format (nonce + encrypted_data) actualKeyCiphertext := extractActualCiphertext(keyCiphertext, keyNonce) // Decrypt collection key with master key collectionKey, err = e2ee.DecryptCollectionKey(&e2ee.EncryptedKey{ Ciphertext: actualKeyCiphertext, Nonce: keyNonce, }, masterKey) if err != nil { return nil, fmt.Errorf("failed to decrypt collection key: %w", err) } collectionKeyReady = true return collectionKey, nil } result := make([]*FileData, 0, len(files)) for _, cloudFile := range files { // Skip deleted files - don't show them in the GUI if cloudFile.State == file.StateDeleted { continue } // Default values filename := "Encrypted File" contentType := "application/octet-stream" fileSize := cloudFile.EncryptedSizeInBytes // Check local repository for sync status syncStatus := file.SyncStatusCloudOnly // Default: cloud only (from API) hasLocalContent := false localFilePath := "" localFile, err := a.mustGetFileRepo().Get(cloudFile.ID) if err == nil && localFile != nil { // Skip if local file is marked as deleted if localFile.State == file.StateDeleted { continue } // File exists in local repo - use local data syncStatus = localFile.SyncStatus hasLocalContent = localFile.HasLocalContent() localFilePath = localFile.FilePath // Use decrypted data from local storage if localFile.Name != "" { filename = localFile.Name } if localFile.MimeType != "" { contentType = localFile.MimeType } if localFile.DecryptedSizeInBytes > 0 { fileSize = localFile.DecryptedSizeInBytes } } else { // File not in local repo - decrypt metadata from cloud colKey, err := getCollectionKey() if err != nil { a.logger.Warn("Failed to get collection key for metadata decryption", zap.String("file_id", cloudFile.ID), zap.Error(err)) // Continue with placeholder values } else { // Decrypt file 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 fileKeyCiphertext, err := tryDecodeBase64(cloudFile.EncryptedFileKey.Ciphertext) if err != nil { a.logger.Warn("Failed to decode file key ciphertext", zap.String("file_id", cloudFile.ID), zap.Error(err)) } else { fileKeyNonce, err := tryDecodeBase64(cloudFile.EncryptedFileKey.Nonce) if err != nil { a.logger.Warn("Failed to decode file key nonce", zap.String("file_id", cloudFile.ID), zap.Error(err)) } else { // Handle web frontend combined ciphertext format (nonce + encrypted_data) actualFileKeyCiphertext := extractActualCiphertext(fileKeyCiphertext, fileKeyNonce) fileKey, err := e2ee.DecryptFileKey(&e2ee.EncryptedKey{ Ciphertext: actualFileKeyCiphertext, Nonce: fileKeyNonce, }, colKey) if err != nil { a.logger.Warn("Failed to decrypt file key", zap.String("file_id", cloudFile.ID), zap.Error(err)) } else { // Decrypt metadata // Use tryDecodeBase64 to handle multiple base64 encoding formats encryptedMetadataBytes, err := tryDecodeBase64(cloudFile.EncryptedMetadata) if err != nil { a.logger.Warn("Failed to decode encrypted metadata", zap.String("file_id", cloudFile.ID), zap.Error(err)) } else { metadataNonce, metadataCiphertext, err := e2ee.SplitNonceAndCiphertextAuto(encryptedMetadataBytes) if err != nil { a.logger.Warn("Failed to split metadata nonce/ciphertext", zap.String("file_id", cloudFile.ID), zap.Error(err)) } else { decryptedMetadata, err := e2ee.DecryptWithAlgorithm(metadataCiphertext, metadataNonce, fileKey) if err != nil { a.logger.Warn("Failed to decrypt metadata", zap.String("file_id", cloudFile.ID), zap.Error(err)) } else { // Parse decrypted metadata JSON var metadata struct { Filename string `json:"name"` MimeType string `json:"mime_type"` Size int64 `json:"size"` } if err := json.Unmarshal(decryptedMetadata, &metadata); err != nil { a.logger.Warn("Failed to parse metadata JSON", zap.String("file_id", cloudFile.ID), zap.Error(err)) } else { // Successfully decrypted - use actual values if metadata.Filename != "" { filename = metadata.Filename } if metadata.MimeType != "" { contentType = metadata.MimeType } if metadata.Size > 0 { fileSize = metadata.Size } } } } } } } } } } // Process embedded tags from the API response // The backend includes tags in the list response, so we decrypt them here // instead of making separate API calls per file embeddedTags := make([]*EmbeddedTagData, 0, len(cloudFile.Tags)) if len(cloudFile.Tags) > 0 { // Get master key for tag decryption (we need it for each file with tags) // Note: This is inside the file loop, so we get a fresh key reference for each file session, err := a.authService.GetCurrentSession(a.ctx) if err == nil { masterKey, cleanup, err := a.keyCache.GetMasterKey(session.Email) if err == nil { // Decrypt each embedded tag for _, tagData := range cloudFile.Tags { // Convert to client.Tag format for decryption clientTag := &client.Tag{ ID: tagData.ID, EncryptedName: tagData.EncryptedName, EncryptedColor: tagData.EncryptedColor, EncryptedTagKey: tagData.EncryptedTagKey, } // Decrypt the tag decryptedTag, err := a.decryptTag(clientTag, masterKey) if err != nil { a.logger.Warn("Failed to decrypt embedded tag for file, skipping", zap.String("file_id", cloudFile.ID), zap.String("tag_id", tagData.ID), zap.Error(err)) continue } embeddedTags = append(embeddedTags, &EmbeddedTagData{ ID: decryptedTag.ID, Name: decryptedTag.Name, Color: decryptedTag.Color, }) } cleanup() } else { a.logger.Debug("Failed to get master key for tag decryption, skipping tags", zap.String("file_id", cloudFile.ID), zap.Error(err)) } } else { a.logger.Debug("Failed to get session for tag decryption, skipping tags", zap.String("file_id", cloudFile.ID), zap.Error(err)) } } result = append(result, &FileData{ ID: cloudFile.ID, CollectionID: cloudFile.CollectionID, Filename: filename, Size: fileSize, ContentType: contentType, CreatedAt: cloudFile.CreatedAt.Format(time.RFC3339), ModifiedAt: cloudFile.ModifiedAt.Format(time.RFC3339), SyncStatus: syncStatus.String(), HasLocalContent: hasLocalContent, LocalFilePath: localFilePath, Tags: embeddedTags, }) } a.logger.Info("Listed files", zap.String("collection_id", collectionID), zap.Int("count", len(result))) return result, nil } // GetFile retrieves a single file's details by ID func (a *Application) GetFile(fileID string) (*FileDetailData, error) { // Validate input if err := inputvalidation.ValidateFileID(fileID); err != nil { return nil, err } // Get current session 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 the cached master key for decryption masterKey, cleanup, err := a.keyCache.GetMasterKey(session.Email) if err != nil { a.logger.Error("Failed to get master key", zap.Error(err)) return nil, fmt.Errorf("failed to get master key: %w", err) } defer cleanup() apiClient := a.authService.GetAPIClient() apiClient.SetTokens(session.AccessToken, session.RefreshToken) // Make HTTP request to get file details // Note: Backend uses /api/v1/file/{id} (singular) not /api/v1/files/{id} req, 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 nil, 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 get file request", zap.Error(err)) return nil, 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 file", zap.Int("status", resp.StatusCode), zap.String("body", string(body))) return nil, fmt.Errorf("failed to get file: %s", string(body)) } // Parse response var fileResp 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"` EncryptedFileSizeInBytes int64 `json:"encrypted_file_size_in_bytes"` CreatedAt string `json:"created_at"` ModifiedAt string `json:"modified_at"` Version uint64 `json:"version"` State string `json:"state"` Tags []struct { ID string `json:"id"` EncryptedName string `json:"encrypted_name"` EncryptedColor string `json:"encrypted_color"` EncryptedTagKey struct { Ciphertext string `json:"ciphertext"` Nonce string `json:"nonce"` } `json:"encrypted_tag_key"` } `json:"tags,omitempty"` } if err := json.NewDecoder(resp.Body).Decode(&fileResp); err != nil { a.logger.Error("Failed to decode file response", zap.Error(err)) return nil, fmt.Errorf("failed to decode response: %w", err) } // Now we need to get the collection to decrypt the file key // First get the collection's encrypted collection key collReq, err := http.NewRequestWithContext(a.ctx, "GET", apiClient.GetBaseURL()+"/api/v1/collections/"+fileResp.CollectionID, nil) if err != nil { a.logger.Error("Failed to create get collection request", zap.Error(err)) 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 { a.logger.Error("Failed to get collection for file", zap.Error(err)) return nil, 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 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 { a.logger.Error("Failed to decode collection response", zap.Error(err)) return nil, fmt.Errorf("failed to decode collection: %w", err) } // Decrypt collection key with master key // Use tryDecodeBase64 to handle multiple base64 encoding formats collKeyNonce, err := tryDecodeBase64(collData.EncryptedCollectionKey.Nonce) if err != nil { a.logger.Error("Failed to decode collection key nonce", zap.Error(err)) return nil, fmt.Errorf("failed to decode key nonce: %w", err) } collKeyCiphertext, err := tryDecodeBase64(collData.EncryptedCollectionKey.Ciphertext) if err != nil { a.logger.Error("Failed to decode collection key ciphertext", zap.Error(err)) return nil, fmt.Errorf("failed to decode 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 nil, fmt.Errorf("failed to decrypt collection key: %w", err) } // 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(fileResp.EncryptedFileKey.Nonce) if err != nil { a.logger.Error("Failed to decode file key nonce", zap.Error(err)) return nil, fmt.Errorf("failed to decode file key nonce: %w", err) } fileKeyCiphertext, err := tryDecodeBase64(fileResp.EncryptedFileKey.Ciphertext) if err != nil { a.logger.Error("Failed to decode file key ciphertext", zap.Error(err)) 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 { a.logger.Error("Failed to decrypt file key", zap.Error(err)) return nil, fmt.Errorf("failed to decrypt file key: %w", err) } // Decrypt file metadata with file key // Use tryDecodeBase64 to handle multiple base64 encoding formats encryptedMetadataBytes, err := tryDecodeBase64(fileResp.EncryptedMetadata) if err != nil { a.logger.Error("Failed to decode encrypted metadata", zap.Error(err)) return nil, fmt.Errorf("failed to decode metadata: %w", err) } // Split nonce and ciphertext (auto-detect nonce size: 12 for ChaCha20, 24 for XSalsa20) metadataNonce, metadataCiphertext, err := e2ee.SplitNonceAndCiphertextAuto(encryptedMetadataBytes) if err != nil { a.logger.Error("Failed to split metadata nonce/ciphertext", zap.Error(err)) return nil, fmt.Errorf("failed to parse metadata: %w", err) } decryptedMetadata, err := e2ee.DecryptWithAlgorithm(metadataCiphertext, metadataNonce, fileKey) if err != nil { a.logger.Error("Failed to decrypt file metadata", zap.Error(err)) return nil, fmt.Errorf("failed to decrypt metadata: %w", err) } // Parse decrypted metadata JSON var metadata struct { Filename string `json:"name"` MimeType string `json:"mime_type"` Size int64 `json:"size"` } if err := json.Unmarshal(decryptedMetadata, &metadata); err != nil { a.logger.Error("Failed to parse file metadata", zap.Error(err)) return nil, fmt.Errorf("failed to parse metadata: %w", err) } // Check local repository for sync status syncStatus := file.SyncStatusCloudOnly // Default: cloud only hasLocalContent := false localFilePath := "" localFile, err := a.mustGetFileRepo().Get(fileResp.ID) if err == nil && localFile != nil { syncStatus = localFile.SyncStatus hasLocalContent = localFile.HasLocalContent() localFilePath = localFile.FilePath } // Process embedded tags from the API response embeddedTags := make([]*EmbeddedTagData, 0, len(fileResp.Tags)) for _, tagData := range fileResp.Tags { // Convert the embedded tag structure to client.Tag format for decryption clientTag := &client.Tag{ ID: tagData.ID, EncryptedName: tagData.EncryptedName, EncryptedColor: tagData.EncryptedColor, EncryptedTagKey: &client.EncryptedTagKey{ Ciphertext: tagData.EncryptedTagKey.Ciphertext, Nonce: tagData.EncryptedTagKey.Nonce, }, } // Decrypt the tag using the existing decryptTag helper decryptedTag, err := a.decryptTag(clientTag, masterKey) if err != nil { a.logger.Warn("Failed to decrypt embedded tag, skipping", zap.String("file_id", fileResp.ID), zap.String("tag_id", tagData.ID), zap.Error(err)) continue } embeddedTags = append(embeddedTags, &EmbeddedTagData{ ID: decryptedTag.ID, Name: decryptedTag.Name, Color: decryptedTag.Color, }) a.logger.Debug("Decrypted embedded tag for file", zap.String("file_id", fileResp.ID), zap.String("tag_id", decryptedTag.ID), zap.String("name", decryptedTag.Name), zap.String("color", decryptedTag.Color)) } return &FileDetailData{ ID: fileResp.ID, CollectionID: fileResp.CollectionID, Filename: metadata.Filename, MimeType: metadata.MimeType, Size: metadata.Size, EncryptedFileSizeInBytes: fileResp.EncryptedFileSizeInBytes, CreatedAt: fileResp.CreatedAt, ModifiedAt: fileResp.ModifiedAt, Version: fileResp.Version, State: fileResp.State, SyncStatus: syncStatus.String(), HasLocalContent: hasLocalContent, LocalFilePath: localFilePath, Tags: embeddedTags, }, nil }