444 lines
16 KiB
Go
444 lines
16 KiB
Go
package app
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"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"
|
|
)
|
|
|
|
// DashboardData contains the formatted dashboard data for the frontend
|
|
type DashboardData struct {
|
|
Summary DashboardSummary `json:"summary"`
|
|
StorageUsageTrend StorageUsageTrend `json:"storage_usage_trend"`
|
|
RecentFiles []DashboardRecentFile `json:"recent_files"`
|
|
}
|
|
|
|
// DashboardSummary contains summary statistics
|
|
type DashboardSummary struct {
|
|
TotalFiles int `json:"total_files"`
|
|
TotalFolders int `json:"total_folders"`
|
|
StorageUsed string `json:"storage_used"`
|
|
StorageLimit string `json:"storage_limit"`
|
|
StorageUsagePercentage int `json:"storage_usage_percentage"`
|
|
}
|
|
|
|
// StorageUsageTrend contains storage usage trend data
|
|
type StorageUsageTrend struct {
|
|
Period string `json:"period"`
|
|
DataPoints []StorageTrendDataPoint `json:"data_points"`
|
|
}
|
|
|
|
// StorageTrendDataPoint represents a single data point in the storage trend
|
|
type StorageTrendDataPoint struct {
|
|
Date string `json:"date"`
|
|
Usage string `json:"usage"`
|
|
}
|
|
|
|
// DashboardRecentFile represents a recent file for dashboard display
|
|
type DashboardRecentFile struct {
|
|
ID string `json:"id"`
|
|
CollectionID string `json:"collection_id"`
|
|
Name string `json:"name"`
|
|
Size string `json:"size"`
|
|
SizeInBytes int64 `json:"size_in_bytes"`
|
|
MimeType string `json:"mime_type"`
|
|
CreatedAt string `json:"created_at"`
|
|
IsDecrypted bool `json:"is_decrypted"`
|
|
SyncStatus string `json:"sync_status"`
|
|
HasLocalContent bool `json:"has_local_content"`
|
|
}
|
|
|
|
// GetDashboardData fetches and formats dashboard data from the backend
|
|
func (a *Application) GetDashboardData() (*DashboardData, error) {
|
|
// Get API client from auth service
|
|
apiClient := a.authService.GetAPIClient()
|
|
if apiClient == nil {
|
|
return nil, fmt.Errorf("API client not available")
|
|
}
|
|
|
|
// Ensure we have a valid session with tokens
|
|
session, err := a.authService.GetCurrentSession(a.ctx)
|
|
if err != nil || session == nil {
|
|
return nil, fmt.Errorf("no active session - please log in")
|
|
}
|
|
|
|
if !session.IsValid() {
|
|
return nil, fmt.Errorf("session expired - please log in again")
|
|
}
|
|
|
|
// Ensure tokens are set in the API client
|
|
// This is important after app restarts or hot reloads
|
|
apiClient.SetTokens(session.AccessToken, session.RefreshToken)
|
|
a.logger.Debug("Restored tokens to API client for dashboard request",
|
|
zap.String("user_id", session.UserID),
|
|
zap.Time("token_expires_at", session.ExpiresAt))
|
|
|
|
// Check if access token is about to expire or already expired
|
|
timeUntilExpiry := time.Until(session.ExpiresAt)
|
|
now := time.Now()
|
|
sessionAge := now.Sub(session.CreatedAt)
|
|
|
|
a.logger.Debug("Token status check",
|
|
zap.Time("now", now),
|
|
zap.Time("expires_at", session.ExpiresAt),
|
|
zap.Duration("time_until_expiry", timeUntilExpiry),
|
|
zap.Duration("session_age", sessionAge))
|
|
|
|
if timeUntilExpiry < 0 {
|
|
a.logger.Warn("Access token already expired, refresh should happen automatically",
|
|
zap.Duration("expired_since", -timeUntilExpiry))
|
|
} else if timeUntilExpiry < 2*time.Minute {
|
|
a.logger.Info("Access token expiring soon, refresh may be needed",
|
|
zap.Duration("time_until_expiry", timeUntilExpiry))
|
|
}
|
|
|
|
// If session is very old (more than 1 day), recommend fresh login
|
|
if sessionAge > 24*time.Hour {
|
|
a.logger.Warn("Session is very old, consider logging out and logging in again",
|
|
zap.Duration("session_age", sessionAge))
|
|
}
|
|
|
|
// Fetch dashboard data from backend
|
|
// The client will automatically refresh the token if it gets a 401
|
|
a.logger.Debug("Calling backend API for dashboard data")
|
|
resp, err := apiClient.GetDashboard(a.ctx)
|
|
if err != nil {
|
|
a.logger.Error("Failed to fetch dashboard data",
|
|
zap.Error(err),
|
|
zap.String("error_type", fmt.Sprintf("%T", err)))
|
|
|
|
// Check if this is an unauthorized error that should trigger token refresh
|
|
if apiErr, ok := err.(*client.APIError); ok {
|
|
a.logger.Error("API Error details",
|
|
zap.Int("status", apiErr.Status),
|
|
zap.String("title", apiErr.Title),
|
|
zap.String("detail", apiErr.Detail))
|
|
}
|
|
|
|
return nil, fmt.Errorf("failed to fetch dashboard: %w", err)
|
|
}
|
|
|
|
if resp.Dashboard == nil {
|
|
return nil, fmt.Errorf("dashboard data is empty")
|
|
}
|
|
|
|
dashboard := resp.Dashboard
|
|
|
|
// Format summary data
|
|
summary := DashboardSummary{
|
|
TotalFiles: dashboard.Summary.TotalFiles,
|
|
TotalFolders: dashboard.Summary.TotalFolders,
|
|
StorageUsed: formatStorageAmount(dashboard.Summary.StorageUsed),
|
|
StorageLimit: formatStorageAmount(dashboard.Summary.StorageLimit),
|
|
StorageUsagePercentage: dashboard.Summary.StorageUsagePercentage,
|
|
}
|
|
|
|
// Format storage usage trend
|
|
dataPoints := make([]StorageTrendDataPoint, len(dashboard.StorageUsageTrend.DataPoints))
|
|
for i, dp := range dashboard.StorageUsageTrend.DataPoints {
|
|
dataPoints[i] = StorageTrendDataPoint{
|
|
Date: dp.Date,
|
|
Usage: formatStorageAmount(dp.Usage),
|
|
}
|
|
}
|
|
|
|
trend := StorageUsageTrend{
|
|
Period: dashboard.StorageUsageTrend.Period,
|
|
DataPoints: dataPoints,
|
|
}
|
|
|
|
// Get master key for decryption (needed for cloud-only files)
|
|
masterKey, cleanup, masterKeyErr := a.keyCache.GetMasterKey(session.Email)
|
|
if masterKeyErr != nil {
|
|
a.logger.Warn("Master key not available for dashboard file decryption",
|
|
zap.Error(masterKeyErr))
|
|
} else {
|
|
defer cleanup()
|
|
}
|
|
|
|
// Build a cache of collection keys for efficient decryption
|
|
// First, pre-populate from the dashboard response's collection_keys (if available)
|
|
// This avoids making additional API calls for each collection
|
|
collectionKeyCache := make(map[string][]byte) // collectionID -> decrypted collection key
|
|
|
|
if masterKeyErr == nil && len(dashboard.CollectionKeys) > 0 {
|
|
a.logger.Debug("Pre-populating collection key cache from dashboard response",
|
|
zap.Int("collection_keys_count", len(dashboard.CollectionKeys)))
|
|
|
|
for _, ck := range dashboard.CollectionKeys {
|
|
// Decode the encrypted collection key
|
|
collKeyCiphertext, decodeErr := tryDecodeBase64(ck.EncryptedCollectionKey)
|
|
if decodeErr != nil {
|
|
a.logger.Warn("Failed to decode collection key ciphertext from dashboard",
|
|
zap.String("collection_id", ck.CollectionID),
|
|
zap.Error(decodeErr))
|
|
continue
|
|
}
|
|
|
|
collKeyNonce, decodeErr := tryDecodeBase64(ck.EncryptedCollectionKeyNonce)
|
|
if decodeErr != nil {
|
|
a.logger.Warn("Failed to decode collection key nonce from dashboard",
|
|
zap.String("collection_id", ck.CollectionID),
|
|
zap.Error(decodeErr))
|
|
continue
|
|
}
|
|
|
|
// Handle combined ciphertext format (nonce prepended to ciphertext)
|
|
actualCollKeyCiphertext := extractActualCiphertext(collKeyCiphertext, collKeyNonce)
|
|
|
|
// Decrypt the collection key with the master key
|
|
collectionKey, decryptErr := e2ee.DecryptCollectionKey(&e2ee.EncryptedKey{
|
|
Ciphertext: actualCollKeyCiphertext,
|
|
Nonce: collKeyNonce,
|
|
}, masterKey)
|
|
if decryptErr != nil {
|
|
a.logger.Warn("Failed to decrypt collection key from dashboard",
|
|
zap.String("collection_id", ck.CollectionID),
|
|
zap.Error(decryptErr))
|
|
continue
|
|
}
|
|
|
|
// Cache the decrypted collection key
|
|
collectionKeyCache[ck.CollectionID] = collectionKey
|
|
a.logger.Debug("Cached collection key from dashboard response",
|
|
zap.String("collection_id", ck.CollectionID))
|
|
}
|
|
|
|
a.logger.Info("Pre-populated collection key cache from dashboard",
|
|
zap.Int("cached_keys", len(collectionKeyCache)))
|
|
}
|
|
|
|
// Format recent files (use local data if available, otherwise decrypt from cloud)
|
|
recentFiles := make([]DashboardRecentFile, 0, len(dashboard.RecentFiles))
|
|
for _, cloudFile := range dashboard.RecentFiles {
|
|
// Debug: Log what we received from the API
|
|
a.logger.Debug("Processing dashboard recent file",
|
|
zap.String("file_id", cloudFile.ID),
|
|
zap.String("collection_id", cloudFile.CollectionID),
|
|
zap.Int("encrypted_file_key_ciphertext_len", len(cloudFile.EncryptedFileKey.Ciphertext)),
|
|
zap.Int("encrypted_file_key_nonce_len", len(cloudFile.EncryptedFileKey.Nonce)),
|
|
zap.String("encrypted_file_key_ciphertext_preview", truncateForLog(cloudFile.EncryptedFileKey.Ciphertext, 50)),
|
|
zap.Int("encrypted_metadata_len", len(cloudFile.EncryptedMetadata)))
|
|
|
|
// Default values for files not in local repository
|
|
filename := "Encrypted File"
|
|
isDecrypted := false
|
|
syncStatus := file.SyncStatusCloudOnly // Default: cloud only
|
|
hasLocalContent := false
|
|
sizeInBytes := cloudFile.EncryptedFileSizeInBytes
|
|
mimeType := "application/octet-stream"
|
|
|
|
// Check local repository for this file to get decrypted name and sync status
|
|
localFile, err := a.mustGetFileRepo().Get(cloudFile.ID)
|
|
if err == nil && localFile != nil && localFile.State != file.StateDeleted {
|
|
// File exists locally - use local data
|
|
syncStatus = localFile.SyncStatus
|
|
hasLocalContent = localFile.HasLocalContent()
|
|
|
|
// Use decrypted filename if available
|
|
if localFile.Name != "" {
|
|
filename = localFile.Name
|
|
isDecrypted = true
|
|
}
|
|
|
|
// Use decrypted mime type if available
|
|
if localFile.MimeType != "" {
|
|
mimeType = localFile.MimeType
|
|
}
|
|
|
|
// Use local size (decrypted) if available
|
|
if localFile.DecryptedSizeInBytes > 0 {
|
|
sizeInBytes = localFile.DecryptedSizeInBytes
|
|
}
|
|
} else if masterKeyErr == nil && cloudFile.EncryptedMetadata != "" {
|
|
// File not in local repo, but we have the master key - try to decrypt from cloud data
|
|
decryptedFilename, decryptedMimeType, decryptErr := a.decryptDashboardFileMetadata(
|
|
cloudFile, masterKey, collectionKeyCache, apiClient)
|
|
if decryptErr != nil {
|
|
// Log at Warn level for better visibility during troubleshooting
|
|
a.logger.Warn("Failed to decrypt dashboard file metadata",
|
|
zap.String("file_id", cloudFile.ID),
|
|
zap.String("collection_id", cloudFile.CollectionID),
|
|
zap.Int("encrypted_file_key_ciphertext_len", len(cloudFile.EncryptedFileKey.Ciphertext)),
|
|
zap.Int("encrypted_file_key_nonce_len", len(cloudFile.EncryptedFileKey.Nonce)),
|
|
zap.Error(decryptErr))
|
|
} else {
|
|
filename = decryptedFilename
|
|
mimeType = decryptedMimeType
|
|
isDecrypted = true
|
|
}
|
|
}
|
|
|
|
recentFiles = append(recentFiles, DashboardRecentFile{
|
|
ID: cloudFile.ID,
|
|
CollectionID: cloudFile.CollectionID,
|
|
Name: filename,
|
|
Size: formatFileSize(sizeInBytes),
|
|
SizeInBytes: sizeInBytes,
|
|
MimeType: mimeType,
|
|
CreatedAt: cloudFile.CreatedAt.Format(time.RFC3339),
|
|
IsDecrypted: isDecrypted,
|
|
SyncStatus: syncStatus.String(),
|
|
HasLocalContent: hasLocalContent,
|
|
})
|
|
}
|
|
|
|
dashboardData := &DashboardData{
|
|
Summary: summary,
|
|
StorageUsageTrend: trend,
|
|
RecentFiles: recentFiles,
|
|
}
|
|
|
|
a.logger.Info("Dashboard data fetched successfully",
|
|
zap.Int("total_files", summary.TotalFiles),
|
|
zap.Int("recent_files", len(recentFiles)))
|
|
|
|
return dashboardData, nil
|
|
}
|
|
|
|
// formatStorageAmount converts StorageAmount to human-readable string
|
|
func formatStorageAmount(amount client.StorageAmount) string {
|
|
if amount.Value == 0 {
|
|
return "0 B"
|
|
}
|
|
return fmt.Sprintf("%.2f %s", amount.Value, amount.Unit)
|
|
}
|
|
|
|
// formatFileSize converts bytes to human-readable format
|
|
func formatFileSize(bytes int64) string {
|
|
if bytes == 0 {
|
|
return "0 B"
|
|
}
|
|
|
|
const unit = 1024
|
|
if bytes < unit {
|
|
return fmt.Sprintf("%d B", bytes)
|
|
}
|
|
|
|
div, exp := int64(unit), 0
|
|
for n := bytes / unit; n >= unit; n /= unit {
|
|
div *= unit
|
|
exp++
|
|
}
|
|
|
|
units := []string{"B", "KB", "MB", "GB", "TB"}
|
|
return fmt.Sprintf("%.1f %s", float64(bytes)/float64(div), units[exp+1])
|
|
}
|
|
|
|
// decryptDashboardFileMetadata decrypts file metadata for a dashboard recent file
|
|
// Collection keys should already be pre-populated in the cache from the dashboard API response
|
|
func (a *Application) decryptDashboardFileMetadata(
|
|
cloudFile client.RecentFileDashboard,
|
|
masterKey []byte,
|
|
collectionKeyCache map[string][]byte,
|
|
apiClient *client.Client,
|
|
) (filename string, mimeType string, err error) {
|
|
|
|
// Step 1: Get the collection key from cache (should be pre-populated from dashboard API response)
|
|
collectionKey, exists := collectionKeyCache[cloudFile.CollectionID]
|
|
if !exists {
|
|
// Collection key was not provided by the dashboard API - this shouldn't happen
|
|
// but we log a warning for debugging
|
|
a.logger.Warn("Collection key not found in cache - dashboard API should have provided it",
|
|
zap.String("collection_id", cloudFile.CollectionID),
|
|
zap.String("file_id", cloudFile.ID))
|
|
return "", "", fmt.Errorf("collection key not available for collection %s", cloudFile.CollectionID)
|
|
}
|
|
|
|
// Step 2: Get the file's encrypted_file_key
|
|
// First try using the dashboard data, but if empty, fetch from the file endpoint directly
|
|
var fileKeyCiphertext, fileKeyNonce []byte
|
|
|
|
if cloudFile.EncryptedFileKey.Ciphertext != "" && cloudFile.EncryptedFileKey.Nonce != "" {
|
|
// Use data from dashboard response
|
|
var decodeErr error
|
|
fileKeyCiphertext, decodeErr = tryDecodeBase64(cloudFile.EncryptedFileKey.Ciphertext)
|
|
if decodeErr != nil {
|
|
return "", "", fmt.Errorf("failed to decode file key ciphertext: %w", decodeErr)
|
|
}
|
|
|
|
fileKeyNonce, decodeErr = tryDecodeBase64(cloudFile.EncryptedFileKey.Nonce)
|
|
if decodeErr != nil {
|
|
return "", "", fmt.Errorf("failed to decode file key nonce: %w", decodeErr)
|
|
}
|
|
} else {
|
|
// Dashboard response has empty encrypted_file_key, fetch from file endpoint
|
|
// This endpoint properly deserializes the encrypted_file_key through the repository
|
|
a.logger.Debug("Dashboard encrypted_file_key is empty, fetching from file endpoint",
|
|
zap.String("file_id", cloudFile.ID))
|
|
|
|
file, fetchErr := apiClient.GetFile(a.ctx, cloudFile.ID)
|
|
if fetchErr != nil {
|
|
return "", "", fmt.Errorf("failed to fetch file %s: %w", cloudFile.ID, fetchErr)
|
|
}
|
|
|
|
if file.EncryptedFileKey.Ciphertext == "" || file.EncryptedFileKey.Nonce == "" {
|
|
return "", "", fmt.Errorf("file endpoint also returned empty encrypted_file_key for file %s", cloudFile.ID)
|
|
}
|
|
|
|
var decodeErr error
|
|
fileKeyCiphertext, decodeErr = tryDecodeBase64(file.EncryptedFileKey.Ciphertext)
|
|
if decodeErr != nil {
|
|
return "", "", fmt.Errorf("failed to decode file key ciphertext from file endpoint: %w", decodeErr)
|
|
}
|
|
|
|
fileKeyNonce, decodeErr = tryDecodeBase64(file.EncryptedFileKey.Nonce)
|
|
if decodeErr != nil {
|
|
return "", "", fmt.Errorf("failed to decode file key nonce from file endpoint: %w", decodeErr)
|
|
}
|
|
}
|
|
|
|
// Handle combined ciphertext format for file key
|
|
actualFileKeyCiphertext := extractActualCiphertext(fileKeyCiphertext, fileKeyNonce)
|
|
|
|
fileKey, err := e2ee.DecryptFileKey(&e2ee.EncryptedKey{
|
|
Ciphertext: actualFileKeyCiphertext,
|
|
Nonce: fileKeyNonce,
|
|
}, collectionKey)
|
|
if err != nil {
|
|
return "", "", fmt.Errorf("failed to decrypt file key: %w", err)
|
|
}
|
|
|
|
// Step 3: Decrypt the file metadata with the file key
|
|
// Use tryDecodeBase64 to handle multiple base64 encoding formats
|
|
encryptedMetadataBytes, err := tryDecodeBase64(cloudFile.EncryptedMetadata)
|
|
if err != nil {
|
|
return "", "", fmt.Errorf("failed to decode encrypted metadata: %w", err)
|
|
}
|
|
|
|
// Split nonce and ciphertext from the combined metadata (auto-detect nonce size)
|
|
metadataNonce, metadataCiphertext, err := e2ee.SplitNonceAndCiphertextAuto(encryptedMetadataBytes)
|
|
if err != nil {
|
|
return "", "", fmt.Errorf("failed to split metadata nonce/ciphertext: %w", err)
|
|
}
|
|
|
|
decryptedMetadata, err := e2ee.DecryptWithAlgorithm(metadataCiphertext, metadataNonce, fileKey)
|
|
if err != nil {
|
|
return "", "", fmt.Errorf("failed to decrypt metadata: %w", err)
|
|
}
|
|
|
|
// Step 4: Parse the decrypted metadata JSON
|
|
var metadata struct {
|
|
Name string `json:"name"`
|
|
MimeType string `json:"mime_type"`
|
|
}
|
|
if err := json.Unmarshal(decryptedMetadata, &metadata); err != nil {
|
|
return "", "", fmt.Errorf("failed to parse metadata JSON: %w", err)
|
|
}
|
|
|
|
return metadata.Name, metadata.MimeType, nil
|
|
}
|
|
|
|
// truncateForLog truncates a string for logging purposes
|
|
func truncateForLog(s string, maxLen int) string {
|
|
if len(s) <= maxLen {
|
|
return s
|
|
}
|
|
return s[:maxLen] + "..."
|
|
}
|