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

610 lines
22 KiB
Go

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