package app import ( "bytes" "encoding/base64" "encoding/json" "fmt" "io" "net/http" "time" "github.com/google/uuid" "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/service/inputvalidation" ) type CollectionData struct { ID string `json:"id"` Name string `json:"name"` Description string `json:"description,omitempty"` CustomIcon string `json:"custom_icon,omitempty"` // Decrypted custom icon (emoji or "icon:") ParentID string `json:"parent_id,omitempty"` CollectionType string `json:"collection_type"` // "folder" or "album" IsShared bool `json:"is_shared"` TotalFiles int `json:"file_count"` // Maps to backend's "file_count" JSON field CreatedAt string `json:"created_at"` ModifiedAt string `json:"modified_at"` Tags []*EmbeddedTagData `json:"tags"` // Tags assigned to this collection } // FileData represents a file for the frontend type FileData struct { ID string `json:"id"` CollectionID string `json:"collection_id"` Filename string `json:"filename"` Size int64 `json:"size"` ContentType string `json:"content_type,omitempty"` CreatedAt string `json:"created_at"` ModifiedAt string `json:"modified_at"` // Sync status: "cloud_only", "local_only", "synced", "modified_locally" SyncStatus string `json:"sync_status"` // HasLocalContent indicates if the file content is available locally HasLocalContent bool `json:"has_local_content"` // LocalFilePath is the path to the decrypted file on disk (if available) LocalFilePath string `json:"local_file_path,omitempty"` // Tags assigned to this file Tags []*EmbeddedTagData `json:"tags"` } // extractActualCiphertext handles the difference between web frontend and legacy native app // encryption formats for collection keys: // - Web frontend format: ciphertext contains nonce + encrypted_data (combined) // - Legacy native format: ciphertext contains just encrypted_data // // This function detects the format by checking if the ciphertext starts with the nonce, // and extracts just the encrypted data if it's in combined format. func extractActualCiphertext(ciphertext, nonce []byte) []byte { // Only check for combined format if we have XSalsa20 (24-byte nonce) // and the ciphertext is long enough to contain both nonce and data if len(ciphertext) > len(nonce) && len(nonce) == 24 { // Check if the ciphertext starts with the nonce (combined format) if bytes.Equal(ciphertext[:len(nonce)], nonce) { // Combined format: extract just the encrypted data return ciphertext[len(nonce):] } } // Legacy format or not combined, use as-is return ciphertext } // ListCollections returns all collections for the current user func (a *Application) ListCollections() ([]*CollectionData, error) { // 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) } // Validate session if !session.IsValid() { return nil, fmt.Errorf("session expired - please log in again") } // Get the cached master key 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() // Ensure tokens are set in the API client // This is important after app restarts or hot reloads apiClient.SetTokens(session.AccessToken, session.RefreshToken) // Define a custom response struct that matches the actual backend API // (the client SDK's Collection struct is incorrect) type collectionAPIItem struct { ID string `json:"id"` EncryptedName string `json:"encrypted_name"` EncryptedCustomIcon string `json:"encrypted_custom_icon,omitempty"` EncryptedCollectionKey struct { Ciphertext string `json:"ciphertext"` Nonce string `json:"nonce"` } `json:"encrypted_collection_key"` CollectionType string `json:"collection_type"` ParentID string `json:"parent_id,omitempty"` UserID string `json:"user_id"` TotalFiles int `json:"file_count"` CreatedAt string `json:"created_at"` ModifiedAt string `json:"modified_at"` SharedWith []struct { RecipientEmail string `json:"recipient_email"` } `json:"shared_with,omitempty"` 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"` } type listCollectionsResponse struct { Collections []*collectionAPIItem `json:"collections"` } // Make the HTTP request - use a helper function to handle 401 retry with token refresh var apiResponse listCollectionsResponse makeRequest := func() (*http.Response, error) { req, err := http.NewRequestWithContext(a.ctx, "GET", apiClient.GetBaseURL()+"/api/v1/collections", nil) if err != nil { return nil, err } // Get the latest access token from the API client (may have been refreshed) accessToken, _ := apiClient.GetTokens() req.Header.Set("Authorization", "Bearer "+accessToken) return a.httpClient.Do(req) } resp, err := makeRequest() if err != nil { a.logger.Error("Failed to send list collections request", zap.Error(err)) return nil, fmt.Errorf("failed to send request: %w", err) } // Handle 401 by trying to refresh the token if resp.StatusCode == http.StatusUnauthorized { resp.Body.Close() a.logger.Info("Got 401, attempting token refresh before retry") // Try to refresh the token using the SDK's refresh mechanism if err := apiClient.RefreshToken(a.ctx); err != nil { a.logger.Error("Token refresh failed", zap.Error(err)) return nil, fmt.Errorf("authentication failed - please log in again: %w", err) } // Retry the request with the new token resp, err = makeRequest() if err != nil { a.logger.Error("Failed to retry list collections request after token refresh", 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 list collections", zap.Int("status", resp.StatusCode), zap.String("body", string(body))) return nil, fmt.Errorf("failed to list collections: %s", string(body)) } if err := json.NewDecoder(resp.Body).Decode(&apiResponse); err != nil { a.logger.Error("Failed to decode list collections response", zap.Error(err)) return nil, fmt.Errorf("failed to parse response: %w", err) } // Log detailed API response info for debugging a.logger.Info("API response received", zap.Int("collections_count", len(apiResponse.Collections))) if len(apiResponse.Collections) == 0 { a.logger.Warn("No collections returned from API - this may indicate an issue with the backend query or user has no collections") } for i, coll := range apiResponse.Collections { a.logger.Debug("Raw collection from API", zap.Int("index", i), zap.String("id", coll.ID), zap.Bool("has_encrypted_key", coll.EncryptedCollectionKey.Ciphertext != "" && coll.EncryptedCollectionKey.Nonce != ""), zap.String("collection_type", coll.CollectionType), zap.String("parent_id", coll.ParentID)) } // Decrypt collection names and build result // // CIPHER DETECTION STRATEGY: // MapleFile supports two encryption schemes for backward compatibility: // // 1. XSalsa20-Poly1305 (libsodium secretbox) - Web frontend format // - 24-byte nonce // - Used by: web frontend (JavaScript with libsodium-wrappers) // - Format: nonce (24 bytes) || ciphertext || auth tag (16 bytes) // // 2. ChaCha20-Poly1305 (IETF) - Legacy native app format // - 12-byte nonce // - Used by: older native desktop/CLI apps // - Format: nonce (12 bytes) || ciphertext || auth tag (16 bytes) // // To determine which cipher was used for a collection, we check the // collection key's nonce size. The collection name is always encrypted // with the SAME cipher as the collection key, so we use the key nonce // size to decide how to split the encrypted name data. // a.logger.Info("Starting to decrypt collections", zap.Int("total_collections", len(apiResponse.Collections)), zap.Int("master_key_length", len(masterKey))) result := make([]*CollectionData, 0, len(apiResponse.Collections)) for _, apiColl := range apiResponse.Collections { a.logger.Info("Processing collection", zap.String("collection_id", apiColl.ID), zap.Int("encrypted_name_base64_length", len(apiColl.EncryptedName)), zap.Int("encrypted_key_ciphertext_base64_length", len(apiColl.EncryptedCollectionKey.Ciphertext)), zap.Int("encrypted_key_nonce_base64_length", len(apiColl.EncryptedCollectionKey.Nonce))) // Step 1: Decode the collection key nonce FIRST to determine the cipher type. // The nonce size tells us which encryption algorithm was used: // - 12 bytes = ChaCha20-Poly1305 (legacy native app) // - 24 bytes = XSalsa20-Poly1305 (web frontend / current standard) // // Try multiple base64 encodings for compatibility: // - StdEncoding: standard base64 with padding (Go's default for []byte) // - RawStdEncoding: standard base64 without padding // - RawURLEncoding: URL-safe base64 without padding (libsodium default) keyNonce, err := base64.StdEncoding.DecodeString(apiColl.EncryptedCollectionKey.Nonce) if err != nil { // Try URL-safe encoding without padding (libsodium format) keyNonce, err = base64.RawURLEncoding.DecodeString(apiColl.EncryptedCollectionKey.Nonce) if err != nil { // Try standard encoding without padding keyNonce, err = base64.RawStdEncoding.DecodeString(apiColl.EncryptedCollectionKey.Nonce) if err != nil { a.logger.Error("Failed to decode collection key nonce (tried all encodings)", zap.String("collection_id", apiColl.ID), zap.String("nonce_value", apiColl.EncryptedCollectionKey.Nonce), zap.Error(err)) continue } } } keyCiphertext, err := base64.StdEncoding.DecodeString(apiColl.EncryptedCollectionKey.Ciphertext) if err != nil { // Try URL-safe encoding without padding keyCiphertext, err = base64.RawURLEncoding.DecodeString(apiColl.EncryptedCollectionKey.Ciphertext) if err != nil { // Try standard encoding without padding keyCiphertext, err = base64.RawStdEncoding.DecodeString(apiColl.EncryptedCollectionKey.Ciphertext) if err != nil { a.logger.Error("Failed to decode collection key ciphertext (tried all encodings)", zap.String("collection_id", apiColl.ID), zap.Error(err)) continue } } } a.logger.Info("Decoded collection key components", zap.String("collection_id", apiColl.ID), zap.Int("key_ciphertext_length", len(keyCiphertext)), zap.Int("key_nonce_length", len(keyNonce))) // Handle web frontend combined ciphertext format (nonce + encrypted_data) actualKeyCiphertext := extractActualCiphertext(keyCiphertext, keyNonce) if len(actualKeyCiphertext) != len(keyCiphertext) { a.logger.Info("Detected combined ciphertext format (web frontend), extracted encrypted data", zap.String("collection_id", apiColl.ID), zap.Int("original_length", len(keyCiphertext)), zap.Int("extracted_length", len(actualKeyCiphertext))) } // Decode the encrypted name from base64 (contains combined nonce + ciphertext) // Try multiple encodings for compatibility encryptedNameCombined, err := base64.StdEncoding.DecodeString(apiColl.EncryptedName) if err != nil { encryptedNameCombined, err = base64.RawURLEncoding.DecodeString(apiColl.EncryptedName) if err != nil { encryptedNameCombined, err = base64.RawStdEncoding.DecodeString(apiColl.EncryptedName) if err != nil { a.logger.Error("Failed to decode encrypted name (tried all encodings)", zap.String("collection_id", apiColl.ID), zap.Error(err)) continue } } } a.logger.Info("Decoded encrypted name", zap.String("collection_id", apiColl.ID), zap.Int("decoded_length", len(encryptedNameCombined))) // Split the nonce and ciphertext from the combined encrypted name // Use the collection key nonce size to determine the cipher type for the name // (the name is encrypted with the same algorithm as the collection key) var nameNonce, nameCiphertext []byte if len(keyNonce) == 24 { // XSalsa20-Poly1305 (24-byte nonce) - web frontend format nameNonce, nameCiphertext, err = e2ee.SplitNonceAndCiphertextSecretBox(encryptedNameCombined) } else { // ChaCha20-Poly1305 (12-byte nonce) - legacy native format nameNonce, nameCiphertext, err = e2ee.SplitNonceAndCiphertext(encryptedNameCombined) } if err != nil { a.logger.Error("Failed to split encrypted name", zap.String("collection_id", apiColl.ID), zap.Int("combined_length", len(encryptedNameCombined)), zap.Int("key_nonce_length", len(keyNonce)), zap.Error(err)) continue } a.logger.Info("Split encrypted name nonce and ciphertext", zap.String("collection_id", apiColl.ID), zap.Int("name_nonce_length", len(nameNonce)), zap.Int("name_ciphertext_length", len(nameCiphertext))) // Decrypt the collection key with the master key collectionKey, err := e2ee.DecryptCollectionKey(&e2ee.EncryptedKey{ Ciphertext: actualKeyCiphertext, Nonce: keyNonce, }, masterKey) if err != nil { a.logger.Error("Failed to decrypt collection key", zap.String("collection_id", apiColl.ID), zap.Int("key_nonce_length", len(keyNonce)), zap.Int("key_ciphertext_length", len(actualKeyCiphertext)), zap.Error(err)) continue } a.logger.Info("Successfully decrypted collection key", zap.String("collection_id", apiColl.ID), zap.Int("collection_key_length", len(collectionKey))) // Decrypt the collection name with the collection key // Use auto-detection based on nonce size (12 = ChaCha20, 24 = XSalsa20) decryptedName, err := e2ee.DecryptWithAlgorithm(nameCiphertext, nameNonce, collectionKey) if err != nil { a.logger.Error("Failed to decrypt collection name", zap.String("collection_id", apiColl.ID), zap.Int("name_nonce_length", len(nameNonce)), zap.Int("name_ciphertext_length", len(nameCiphertext)), zap.Error(err)) continue } // Decrypt custom icon if present var decryptedCustomIcon string if apiColl.EncryptedCustomIcon != "" { encryptedIconCombined, err := base64.StdEncoding.DecodeString(apiColl.EncryptedCustomIcon) if err != nil { a.logger.Warn("Failed to decode encrypted custom icon", zap.String("collection_id", apiColl.ID), zap.Error(err)) } else { // Split nonce and ciphertext using the same algorithm as the collection key var iconNonce, iconCiphertext []byte if len(keyNonce) == 24 { iconNonce, iconCiphertext, err = e2ee.SplitNonceAndCiphertextSecretBox(encryptedIconCombined) } else { iconNonce, iconCiphertext, err = e2ee.SplitNonceAndCiphertext(encryptedIconCombined) } if err != nil { a.logger.Warn("Failed to split encrypted custom icon", zap.String("collection_id", apiColl.ID), zap.Error(err)) } else { decryptedIcon, err := e2ee.DecryptWithAlgorithm(iconCiphertext, iconNonce, collectionKey) if err != nil { a.logger.Warn("Failed to decrypt custom icon", zap.String("collection_id", apiColl.ID), zap.Error(err)) } else { decryptedCustomIcon = string(decryptedIcon) } } } } // Process embedded tags from the API response for this collection embeddedTags := make([]*EmbeddedTagData, 0, len(apiColl.Tags)) for _, tagData := range apiColl.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("collection_id", apiColl.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 collection", zap.String("collection_id", apiColl.ID), zap.String("tag_id", decryptedTag.ID), zap.String("name", decryptedTag.Name), zap.String("color", decryptedTag.Color)) } collectionData := &CollectionData{ ID: apiColl.ID, Name: string(decryptedName), Description: "", // TODO: Handle encrypted description if backend supports it CustomIcon: decryptedCustomIcon, ParentID: apiColl.ParentID, CollectionType: apiColl.CollectionType, // "folder" or "album" IsShared: len(apiColl.SharedWith) > 0, TotalFiles: apiColl.TotalFiles, CreatedAt: apiColl.CreatedAt, ModifiedAt: apiColl.ModifiedAt, Tags: embeddedTags, // Use the decrypted embedded tags } a.logger.Info("Successfully decrypted collection", zap.String("collection_id", collectionData.ID), zap.String("name", collectionData.Name), zap.Bool("is_shared", collectionData.IsShared), zap.Int("total_files", collectionData.TotalFiles), zap.Int("tags_count", len(collectionData.Tags))) result = append(result, collectionData) } a.logger.Info("Listed and decrypted collections", zap.Int("count", len(result)), zap.Int("total_received", len(apiResponse.Collections))) // Log final result for _, coll := range result { a.logger.Info("Final collection state", zap.String("collection_id", coll.ID), zap.String("collection_name", coll.Name), zap.Int("final_tags_count", len(coll.Tags))) } return result, nil } // ListFilesByCollection returns all files in a collection type CreateCollectionInput struct { Name string `json:"name"` Description string `json:"description,omitempty"` ParentID string `json:"parent_id,omitempty"` CustomIcon string `json:"custom_icon,omitempty"` // Emoji or "icon:" for predefined icons CollectionType string `json:"collection_type,omitempty"` // "folder" or "album", defaults to "folder" TagIDs []string `json:"tag_ids,omitempty"` // Tag IDs to assign to this collection } // CreateCollectionResponse represents the response from creating a collection type CreateCollectionResponse struct { CollectionID string `json:"collection_id"` Success bool `json:"success"` } // GetCollection returns a single collection by ID with decrypted key func (a *Application) GetCollection(collectionID string) (*CollectionDetailData, error) { // Validate input if err := inputvalidation.ValidateCollectionID(collectionID); err != nil { return nil, err } // Get current session to get user's master key 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 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() // Define a custom response struct that matches the actual backend API // (the client SDK's Collection struct is incorrect) type getCollectionAPIResponse struct { ID string `json:"id"` EncryptedName string `json:"encrypted_name"` EncryptedCustomIcon string `json:"encrypted_custom_icon,omitempty"` EncryptedCollectionKey struct { Ciphertext string `json:"ciphertext"` Nonce string `json:"nonce"` } `json:"encrypted_collection_key"` CollectionType string `json:"collection_type"` ParentID string `json:"parent_id,omitempty"` UserID string `json:"user_id"` CreatedAt time.Time `json:"created_at"` ModifiedAt time.Time `json:"modified_at"` TotalFiles int `json:"file_count"` Members []struct { RecipientEmail string `json:"recipient_email"` RecipientID string `json:"recipient_id"` } `json:"members"` 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"` } // Make manual HTTP request to bypass broken SDK apiClient := a.authService.GetAPIClient() req, err := http.NewRequestWithContext(a.ctx, "GET", apiClient.GetBaseURL()+"/api/v1/collections/"+collectionID, nil) if err != nil { a.logger.Error("Failed to create HTTP request", zap.Error(err)) return nil, fmt.Errorf("failed to create request: %w", err) } // Add authorization header req.Header.Set("Authorization", "Bearer "+session.AccessToken) req.Header.Set("Content-Type", "application/json") // Execute request resp, err := a.httpClient.Do(req) if err != nil { a.logger.Error("Failed to execute HTTP request", zap.Error(err)) return nil, fmt.Errorf("failed to execute request: %w", err) } defer resp.Body.Close() // Check response status if resp.StatusCode != http.StatusOK { body, _ := io.ReadAll(resp.Body) a.logger.Error("Failed to get collection", zap.Int("status", resp.StatusCode), zap.String("body", string(body))) return nil, fmt.Errorf("API error: status %d", resp.StatusCode) } // Parse response var apiResponse getCollectionAPIResponse if err := json.NewDecoder(resp.Body).Decode(&apiResponse); err != nil { a.logger.Error("Failed to decode collection response", zap.Error(err)) return nil, fmt.Errorf("failed to decode response: %w", err) } a.logger.Debug("Processing collection for GetCollection", zap.String("collection_id", apiResponse.ID), zap.String("encrypted_name", apiResponse.EncryptedName)) // Decode the encrypted name from base64 (contains combined nonce + ciphertext) encryptedNameCombined, err := base64.StdEncoding.DecodeString(apiResponse.EncryptedName) if err != nil { a.logger.Error("Failed to decode encrypted name", zap.Error(err)) return nil, fmt.Errorf("failed to decode encrypted name: %w", err) } // Split the nonce and ciphertext from the combined encrypted name // Use auto-detection to handle both ChaCha20 (12-byte nonce) and XSalsa20 (24-byte nonce) nameNonce, nameCiphertext, err := e2ee.SplitNonceAndCiphertextAuto(encryptedNameCombined) if err != nil { a.logger.Error("Failed to split nonce and ciphertext from encrypted name", zap.Error(err)) return nil, fmt.Errorf("failed to split encrypted name: %w", err) } // Decode the encrypted collection key (ciphertext and nonce separately) ciphertext, err := base64.StdEncoding.DecodeString(apiResponse.EncryptedCollectionKey.Ciphertext) if err != nil { a.logger.Error("Failed to decode encrypted collection key ciphertext", zap.Error(err)) return nil, fmt.Errorf("failed to decode encrypted collection key ciphertext: %w", err) } nonce, err := base64.StdEncoding.DecodeString(apiResponse.EncryptedCollectionKey.Nonce) if err != nil { a.logger.Error("Failed to decode encrypted collection key nonce", zap.Error(err)) return nil, fmt.Errorf("failed to decode encrypted collection key nonce: %w", err) } // Handle web frontend combined ciphertext format (nonce + encrypted_data) actualCiphertext := extractActualCiphertext(ciphertext, nonce) // Decrypt the collection key with the master key collectionKey, err := e2ee.DecryptCollectionKey(&e2ee.EncryptedKey{ Ciphertext: actualCiphertext, Nonce: nonce, }, 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 the collection name with the collection key // Use auto-detection based on nonce size (12 = ChaCha20, 24 = XSalsa20) decryptedName, err := e2ee.DecryptWithAlgorithm(nameCiphertext, nameNonce, collectionKey) if err != nil { a.logger.Error("Failed to decrypt collection name", zap.Error(err)) return nil, fmt.Errorf("failed to decrypt collection name: %w", err) } // Decrypt custom icon if present var decryptedCustomIcon string if apiResponse.EncryptedCustomIcon != "" { encryptedIconCombined, err := base64.StdEncoding.DecodeString(apiResponse.EncryptedCustomIcon) if err != nil { a.logger.Warn("Failed to decode encrypted custom icon", zap.Error(err)) } else { iconNonce, iconCiphertext, err := e2ee.SplitNonceAndCiphertextAuto(encryptedIconCombined) if err != nil { a.logger.Warn("Failed to split encrypted custom icon", zap.Error(err)) } else { decryptedIcon, err := e2ee.DecryptWithAlgorithm(iconCiphertext, iconNonce, collectionKey) if err != nil { a.logger.Warn("Failed to decrypt custom icon", zap.Error(err)) } else { decryptedCustomIcon = string(decryptedIcon) } } } } // Process embedded tags from the API response embeddedTags := make([]*EmbeddedTagData, 0, len(apiResponse.Tags)) for _, tagData := range apiResponse.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("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", zap.String("tag_id", decryptedTag.ID), zap.String("name", decryptedTag.Name), zap.String("color", decryptedTag.Color)) } // Build the response with decrypted data result := &CollectionDetailData{ ID: apiResponse.ID, Name: string(decryptedName), Description: "", // Description is not encrypted, but not in response CustomIcon: decryptedCustomIcon, CollectionType: apiResponse.CollectionType, ParentID: apiResponse.ParentID, IsShared: len(apiResponse.Members) > 1, TotalFiles: apiResponse.TotalFiles, CreatedAt: apiResponse.CreatedAt.Format(time.RFC3339), ModifiedAt: apiResponse.ModifiedAt.Format(time.RFC3339), CollectionKey: base64.StdEncoding.EncodeToString(collectionKey), Tags: embeddedTags, } a.logger.Info("Successfully retrieved and decrypted collection", zap.String("collection_id", collectionID), zap.String("name", result.Name), zap.Bool("is_shared", result.IsShared), zap.Int("total_files", result.TotalFiles), zap.Int("tags_count", len(result.Tags))) a.logger.Info("Collection tags detail", zap.String("collection_id", collectionID), zap.Any("tags", result.Tags)) return result, nil } // CollectionDetailData represents detailed collection data including the decrypted key. // This is returned when viewing a single collection's details. // // Similar to CollectionData, the TotalFiles field uses json:"file_count" to match // the backend API's JSON field name. The frontend accesses this as collection.file_count. type CollectionDetailData struct { ID string `json:"id"` Name string `json:"name"` Description string `json:"description,omitempty"` CustomIcon string `json:"custom_icon,omitempty"` // Decrypted custom icon (emoji or "icon:") CollectionType string `json:"collection_type"` // "folder" or "album" ParentID string `json:"parent_id,omitempty"` IsShared bool `json:"is_shared"` TotalFiles int `json:"file_count"` // Maps to backend's "file_count" JSON field CreatedAt string `json:"created_at"` ModifiedAt string `json:"modified_at"` CollectionKey string `json:"collection_key"` // Base64 encoded decrypted key for frontend Tags []*EmbeddedTagData `json:"tags"` // Tags assigned to this collection } // CreateCollection creates a new collection with E2EE. // Uses XSalsa20-Poly1305 (SecretBox) for cross-client compatibility with web frontend. func (a *Application) CreateCollection(input *CreateCollectionInput) (*CreateCollectionResponse, error) { // Validate input if err := inputvalidation.ValidateCollectionName(input.Name); err != nil { return nil, err } if input.ParentID != "" { if err := inputvalidation.ValidateCollectionID(input.ParentID); err != nil { return nil, fmt.Errorf("invalid parent ID: %w", err) } } if err := inputvalidation.ValidateDescription(input.Description); err != nil { return nil, err } // Get current session to get user's master key 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 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() // Generate a new collection key (32 bytes for XSalsa20-Poly1305) collectionKey, err := e2ee.GenerateRandomBytes(32) if err != nil { a.logger.Error("Failed to generate collection key", zap.Error(err)) return nil, fmt.Errorf("failed to generate collection key: %w", err) } // Encrypt the collection name with the collection key using XSalsa20-Poly1305 (SecretBox) // This produces 24-byte nonces compatible with web frontend's libsodium encryptedName, err := e2ee.EncryptWithSecretBox([]byte(input.Name), collectionKey) if err != nil { a.logger.Error("Failed to encrypt collection name", zap.Error(err)) return nil, fmt.Errorf("failed to encrypt collection name: %w", err) } // Encrypt the collection key with the master key using XSalsa20-Poly1305 (SecretBox) encryptedCollectionKey, err := e2ee.EncryptCollectionKeySecretBox(collectionKey, masterKey) if err != nil { a.logger.Error("Failed to encrypt collection key", zap.Error(err)) return nil, fmt.Errorf("failed to encrypt collection key: %w", err) } // Build the encrypted collection key structure for the API // IMPORTANT: The web frontend expects `ciphertext` to contain nonce + ciphertext combined // This matches the libsodium format where the nonce is prepended to the ciphertext combinedCollectionKeyCiphertext := e2ee.CombineNonceAndCiphertext(encryptedCollectionKey.Nonce, encryptedCollectionKey.Ciphertext) encryptedCollectionKeyStruct := map[string]string{ "ciphertext": base64.StdEncoding.EncodeToString(combinedCollectionKeyCiphertext), "nonce": base64.StdEncoding.EncodeToString(encryptedCollectionKey.Nonce), } // Generate a new collection ID collectionID := uuid.New().String() // Combine nonce and ciphertext for the encrypted name combinedEncryptedName := e2ee.CombineNonceAndCiphertext(encryptedName.Nonce, encryptedName.Ciphertext) // Determine collection type (default to "folder" if not specified) collectionType := input.CollectionType if collectionType == "" { collectionType = "folder" } // Build the raw API request manually apiRequest := map[string]interface{}{ "id": collectionID, "encrypted_name": base64.StdEncoding.EncodeToString(combinedEncryptedName), "collection_type": collectionType, "encrypted_collection_key": encryptedCollectionKeyStruct, } if input.ParentID != "" { apiRequest["parent_id"] = input.ParentID } // Add tag IDs if provided if len(input.TagIDs) > 0 { apiRequest["tag_ids"] = input.TagIDs a.logger.Info("Creating collection with tags", zap.String("collection_id", collectionID), zap.Int("tag_count", len(input.TagIDs))) } // Encrypt and add custom icon if provided if input.CustomIcon != "" { encryptedIcon, err := e2ee.EncryptWithSecretBox([]byte(input.CustomIcon), collectionKey) if err != nil { a.logger.Error("Failed to encrypt custom icon", zap.Error(err)) return nil, fmt.Errorf("failed to encrypt custom icon: %w", err) } combinedEncryptedIcon := e2ee.CombineNonceAndCiphertext(encryptedIcon.Nonce, encryptedIcon.Ciphertext) apiRequest["encrypted_custom_icon"] = base64.StdEncoding.EncodeToString(combinedEncryptedIcon) } // Call the API directly using doRequest apiClient := a.authService.GetAPIClient() // Define a custom response struct that matches the actual backend API // (the client SDK's Collection struct is incorrect) type collectionAPIResponse struct { ID string `json:"id"` EncryptedName string `json:"encrypted_name"` EncryptedCollectionKey struct { Ciphertext string `json:"ciphertext"` Nonce string `json:"nonce"` } `json:"encrypted_collection_key"` CollectionType string `json:"collection_type"` ParentID string `json:"parent_id,omitempty"` UserID string `json:"user_id"` CreatedAt string `json:"created_at"` } // Marshal the request requestBody, err := json.Marshal(apiRequest) if err != nil { a.logger.Error("Failed to marshal collection request", zap.Error(err)) return nil, fmt.Errorf("failed to marshal request: %w", err) } // Make the HTTP request req, err := http.NewRequestWithContext(a.ctx, "POST", apiClient.GetBaseURL()+"/api/v1/collections", bytes.NewReader(requestBody)) if err != nil { a.logger.Error("Failed to create collection request", zap.Error(err)) return nil, fmt.Errorf("failed to create request: %w", err) } req.Header.Set("Content-Type", "application/json") req.Header.Set("Authorization", "Bearer "+session.AccessToken) resp, err := a.httpClient.Do(req) if err != nil { a.logger.Error("Failed to send collection request", zap.Error(err)) return nil, fmt.Errorf("failed to send request: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusCreated { body, _ := io.ReadAll(resp.Body) a.logger.Error("Failed to create collection", zap.Int("status", resp.StatusCode), zap.String("body", string(body))) return nil, fmt.Errorf("failed to create collection: %s", string(body)) } var apiResponse collectionAPIResponse if err := json.NewDecoder(resp.Body).Decode(&apiResponse); err != nil { a.logger.Error("Failed to decode collection response", zap.Error(err)) return nil, fmt.Errorf("failed to decode response: %w", err) } a.logger.Info("Collection created successfully", zap.String("collection_id", apiResponse.ID), zap.String("name", input.Name)) return &CreateCollectionResponse{ CollectionID: apiResponse.ID, Success: true, }, nil } // UpdateCollectionInput represents input for updating a collection type UpdateCollectionInput struct { Name string `json:"name"` CustomIcon string `json:"custom_icon,omitempty"` // Emoji or "icon:" for predefined icons CollectionType string `json:"collection_type,omitempty"` // "folder" or "album" } // UpdateCollectionResponse represents the response from updating a collection type UpdateCollectionResponse struct { Success bool `json:"success"` Message string `json:"message"` } // UpdateCollection updates a collection's encrypted name func (a *Application) UpdateCollection(collectionID string, input *UpdateCollectionInput) (*UpdateCollectionResponse, error) { // Validate input if err := inputvalidation.ValidateCollectionID(collectionID); err != nil { return nil, err } if err := inputvalidation.ValidateCollectionName(input.Name); err != nil { return nil, err } // Get current session to get user's master key 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 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() // First, we need to get the collection to decrypt its key apiClient := a.authService.GetAPIClient() // Make the HTTP request to get the collection getReq, err := http.NewRequestWithContext(a.ctx, "GET", apiClient.GetBaseURL()+"/api/v1/collections/"+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) } getReq.Header.Set("Authorization", "Bearer "+session.AccessToken) getResp, err := a.httpClient.Do(getReq) if err != nil { a.logger.Error("Failed to get collection", zap.Error(err)) return nil, fmt.Errorf("failed to get collection: %w", err) } defer getResp.Body.Close() if getResp.StatusCode != http.StatusOK { body, _ := io.ReadAll(getResp.Body) a.logger.Error("Failed to get collection", zap.Int("status", getResp.StatusCode), zap.String("body", string(body))) return nil, fmt.Errorf("failed to get collection: %s", string(body)) } // Parse the collection response to get the encrypted collection key and version var collectionResp struct { EncryptedCollectionKey struct { Ciphertext string `json:"ciphertext"` Nonce string `json:"nonce"` } `json:"encrypted_collection_key"` Version uint64 `json:"version"` } if err := json.NewDecoder(getResp.Body).Decode(&collectionResp); err != nil { a.logger.Error("Failed to decode collection response", zap.Error(err)) return nil, fmt.Errorf("failed to decode collection: %w", err) } // Decrypt the collection key keyNonce, err := base64.StdEncoding.DecodeString(collectionResp.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) } keyCiphertext, err := base64.StdEncoding.DecodeString(collectionResp.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) actualKeyCiphertext := extractActualCiphertext(keyCiphertext, keyNonce) collectionKey, err := e2ee.DecryptCollectionKey(&e2ee.EncryptedKey{ Ciphertext: actualKeyCiphertext, Nonce: keyNonce, }, 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) } // Encrypt the new name with the collection key using XSalsa20-Poly1305 (SecretBox) // This produces 24-byte nonces compatible with web frontend's libsodium encryptedName, err := e2ee.EncryptWithSecretBox([]byte(input.Name), collectionKey) if err != nil { a.logger.Error("Failed to encrypt collection name", zap.Error(err)) return nil, fmt.Errorf("failed to encrypt collection name: %w", err) } // Combine nonce and ciphertext for the encrypted name combinedEncryptedName := e2ee.CombineNonceAndCiphertext(encryptedName.Nonce, encryptedName.Ciphertext) // Build the update request - backend requires encrypted_collection_key and version // The encrypted_collection_key must be sent as an object with ciphertext and nonce fields // using URL-safe base64 encoding (RawURLEncoding) to match the backend's custom unmarshaler updateRequest := map[string]interface{}{ "encrypted_name": base64.StdEncoding.EncodeToString(combinedEncryptedName), "encrypted_collection_key": map[string]string{ "ciphertext": collectionResp.EncryptedCollectionKey.Ciphertext, "nonce": collectionResp.EncryptedCollectionKey.Nonce, }, "version": collectionResp.Version, } // Encrypt and add custom icon (always include it - empty string means reset to default) if input.CustomIcon != "" { encryptedIcon, err := e2ee.EncryptWithSecretBox([]byte(input.CustomIcon), collectionKey) if err != nil { a.logger.Error("Failed to encrypt custom icon", zap.Error(err)) return nil, fmt.Errorf("failed to encrypt custom icon: %w", err) } combinedEncryptedIcon := e2ee.CombineNonceAndCiphertext(encryptedIcon.Nonce, encryptedIcon.Ciphertext) updateRequest["encrypted_custom_icon"] = base64.StdEncoding.EncodeToString(combinedEncryptedIcon) } else { // Empty string clears the custom icon updateRequest["encrypted_custom_icon"] = "" } // Add collection type if specified if input.CollectionType != "" { updateRequest["collection_type"] = input.CollectionType } requestBody, err := json.Marshal(updateRequest) if err != nil { a.logger.Error("Failed to marshal update request", zap.Error(err)) return nil, fmt.Errorf("failed to marshal request: %w", err) } // Make the HTTP PUT request to update the collection putReq, err := http.NewRequestWithContext(a.ctx, "PUT", apiClient.GetBaseURL()+"/api/v1/collections/"+collectionID, bytes.NewReader(requestBody)) if err != nil { a.logger.Error("Failed to create update request", zap.Error(err)) return nil, fmt.Errorf("failed to create request: %w", err) } putReq.Header.Set("Content-Type", "application/json") putReq.Header.Set("Authorization", "Bearer "+session.AccessToken) putResp, err := a.httpClient.Do(putReq) if err != nil { a.logger.Error("Failed to send update request", zap.Error(err)) return nil, fmt.Errorf("failed to send request: %w", err) } defer putResp.Body.Close() if putResp.StatusCode != http.StatusOK { body, _ := io.ReadAll(putResp.Body) a.logger.Error("Failed to update collection", zap.Int("status", putResp.StatusCode), zap.String("body", string(body))) return nil, fmt.Errorf("failed to update collection: %s", string(body)) } a.logger.Info("Collection updated successfully", zap.String("collection_id", collectionID), zap.String("new_name", input.Name)) return &UpdateCollectionResponse{ Success: true, Message: "Collection updated successfully", }, nil } // DeleteCollection soft-deletes a collection func (a *Application) DeleteCollection(collectionID string) error { // Validate input if err := inputvalidation.ValidateCollectionID(collectionID); 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 DELETE request req, err := http.NewRequestWithContext(a.ctx, "DELETE", apiClient.GetBaseURL()+"/api/v1/collections/"+collectionID, nil) if err != nil { a.logger.Error("Failed to create delete 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 delete request", zap.Error(err)) return fmt.Errorf("failed to send request: %w", err) } defer resp.Body.Close() // Accept both 200 OK and 204 No Content as success if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusNoContent { body, _ := io.ReadAll(resp.Body) a.logger.Error("Failed to delete collection", zap.Int("status", resp.StatusCode), zap.String("body", string(body))) return fmt.Errorf("failed to delete collection: %s", string(body)) } a.logger.Info("Collection deleted successfully", zap.String("collection_id", collectionID)) return nil } // listSharedCollections fetches collections shared with the user func (a *Application) listSharedCollections() ([]*CollectionData, error) { session, err := a.authService.GetCurrentSession(a.ctx) if err != nil { return nil, fmt.Errorf("not authenticated: %w", err) } masterKey, cleanup, err := a.keyCache.GetMasterKey(session.Email) if err != nil { return nil, fmt.Errorf("failed to get master key: %w", err) } defer cleanup() apiClient := a.authService.GetAPIClient() apiClient.SetTokens(session.AccessToken, session.RefreshToken) // Make request to shared collections endpoint type collectionAPIItem struct { ID string `json:"id"` EncryptedName string `json:"encrypted_name"` EncryptedCollectionKey struct { Ciphertext string `json:"ciphertext"` Nonce string `json:"nonce"` } `json:"encrypted_collection_key"` CollectionType string `json:"collection_type"` ParentID string `json:"parent_id,omitempty"` TotalFiles int `json:"file_count"` CreatedAt string `json:"created_at"` ModifiedAt string `json:"modified_at"` } type listCollectionsResponse struct { Collections []*collectionAPIItem `json:"collections"` } req, err := http.NewRequestWithContext(a.ctx, "GET", apiClient.GetBaseURL()+"/api/v1/collections/shared", 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 send request: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("failed to list shared collections: status %d", resp.StatusCode) } var apiResponse listCollectionsResponse if err := json.NewDecoder(resp.Body).Decode(&apiResponse); err != nil { return nil, fmt.Errorf("failed to decode response: %w", err) } result := make([]*CollectionData, 0, len(apiResponse.Collections)) for _, apiColl := range apiResponse.Collections { // Decrypt collection name keyCiphertext, err := base64.StdEncoding.DecodeString(apiColl.EncryptedCollectionKey.Ciphertext) if err != nil { a.logger.Warn("Failed to decode collection key ciphertext", zap.String("id", apiColl.ID), zap.Error(err)) continue } keyNonce, err := base64.StdEncoding.DecodeString(apiColl.EncryptedCollectionKey.Nonce) if err != nil { a.logger.Warn("Failed to decode collection key nonce", zap.String("id", apiColl.ID), zap.Error(err)) continue } // Handle web frontend combined ciphertext format (nonce + encrypted_data) actualKeyCiphertext := extractActualCiphertext(keyCiphertext, keyNonce) collectionKey, err := e2ee.DecryptCollectionKey(&e2ee.EncryptedKey{ Ciphertext: actualKeyCiphertext, Nonce: keyNonce, }, masterKey) if err != nil { a.logger.Warn("Failed to decrypt collection key", zap.String("id", apiColl.ID), zap.Error(err)) continue } encryptedNameBytes, err := base64.StdEncoding.DecodeString(apiColl.EncryptedName) if err != nil { a.logger.Warn("Failed to decode encrypted name", zap.String("id", apiColl.ID), zap.Error(err)) continue } nameNonce, nameCiphertext, err := e2ee.SplitNonceAndCiphertextAuto(encryptedNameBytes) if err != nil { a.logger.Warn("Failed to split name nonce/ciphertext", zap.String("id", apiColl.ID), zap.Error(err)) continue } decryptedName, err := e2ee.DecryptWithAlgorithm(nameCiphertext, nameNonce, collectionKey) if err != nil { a.logger.Warn("Failed to decrypt collection name", zap.String("id", apiColl.ID), zap.Error(err)) continue } result = append(result, &CollectionData{ ID: apiColl.ID, Name: string(decryptedName), ParentID: apiColl.ParentID, CollectionType: apiColl.CollectionType, IsShared: true, TotalFiles: apiColl.TotalFiles, CreatedAt: apiColl.CreatedAt, ModifiedAt: apiColl.ModifiedAt, }) } return result, nil }