monorepo/native/desktop/maplefile/internal/app/app_collections.go

1256 lines
48 KiB
Go

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:<id>")
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:<id>" 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:<id>")
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:<id>" 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
}