610 lines
22 KiB
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
|
|
}
|