1256 lines
48 KiB
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
|
|
}
|
|
|