401 lines
14 KiB
Go
401 lines
14 KiB
Go
package app
|
|
|
|
import (
|
|
"bytes"
|
|
"crypto/sha256"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/wailsapp/wails/v2/pkg/runtime"
|
|
"go.uber.org/zap"
|
|
|
|
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/pkg/maplefile/e2ee"
|
|
"codeberg.org/mapleopentech/monorepo/native/desktop/maplefile/internal/domain/file"
|
|
)
|
|
|
|
// =============================================================================
|
|
// FILE UPLOAD OPERATIONS
|
|
// =============================================================================
|
|
|
|
// SelectFile opens a native file dialog and returns the selected file path
|
|
func (a *Application) SelectFile() (string, error) {
|
|
selection, err := runtime.OpenFileDialog(a.ctx, runtime.OpenDialogOptions{
|
|
Title: "Select File to Upload",
|
|
Filters: []runtime.FileFilter{
|
|
{DisplayName: "All Files", Pattern: "*.*"},
|
|
{DisplayName: "Images", Pattern: "*.jpg;*.jpeg;*.png;*.gif;*.webp;*.bmp"},
|
|
{DisplayName: "Documents", Pattern: "*.pdf;*.doc;*.docx;*.txt;*.md"},
|
|
{DisplayName: "Videos", Pattern: "*.mp4;*.mov;*.avi;*.mkv;*.webm"},
|
|
},
|
|
})
|
|
if err != nil {
|
|
a.logger.Error("Failed to open file dialog", zap.Error(err))
|
|
return "", fmt.Errorf("failed to open file dialog: %w", err)
|
|
}
|
|
return selection, nil
|
|
}
|
|
|
|
// FileUploadInput represents the input for uploading a file
|
|
type FileUploadInput struct {
|
|
FilePath string `json:"file_path"`
|
|
CollectionID string `json:"collection_id"`
|
|
TagIDs []string `json:"tag_ids,omitempty"` // Tag IDs to assign to this file
|
|
}
|
|
|
|
// FileUploadResult represents the result of a file upload
|
|
type FileUploadResult struct {
|
|
FileID string `json:"file_id"`
|
|
Filename string `json:"filename"`
|
|
Size int64 `json:"size"`
|
|
Success bool `json:"success"`
|
|
Message string `json:"message"`
|
|
}
|
|
|
|
// UploadFile encrypts and uploads a file to a collection
|
|
func (a *Application) UploadFile(input FileUploadInput) (*FileUploadResult, error) {
|
|
a.logger.Info("Starting file upload",
|
|
zap.String("file_path", input.FilePath),
|
|
zap.String("collection_id", input.CollectionID))
|
|
|
|
// 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 nil, fmt.Errorf("not authenticated: %w", err)
|
|
}
|
|
|
|
// Get master key from key cache
|
|
masterKey, cleanup, err := a.keyCache.GetMasterKey(session.Email)
|
|
if err != nil {
|
|
a.logger.Error("Failed to get master key from cache", zap.Error(err))
|
|
return nil, fmt.Errorf("master key not available - please log in again: %w", err)
|
|
}
|
|
defer cleanup()
|
|
|
|
apiClient := a.authService.GetAPIClient()
|
|
|
|
// Step 1: Read the file from disk
|
|
fileContent, err := os.ReadFile(input.FilePath)
|
|
if err != nil {
|
|
a.logger.Error("Failed to read file", zap.Error(err))
|
|
return nil, fmt.Errorf("failed to read file: %w", err)
|
|
}
|
|
|
|
filename := filepath.Base(input.FilePath)
|
|
fileSize := int64(len(fileContent))
|
|
mimeType := http.DetectContentType(fileContent)
|
|
|
|
a.logger.Info("File read successfully",
|
|
zap.String("filename", filename),
|
|
zap.Int64("size", fileSize),
|
|
zap.String("mime_type", mimeType))
|
|
|
|
// Step 2: Get collection key (need to fetch collection first)
|
|
a.logger.Info("Step 2: Fetching collection for upload",
|
|
zap.String("collection_id", input.CollectionID),
|
|
zap.String("api_url", apiClient.GetBaseURL()+"/api/v1/collections/"+input.CollectionID))
|
|
|
|
collectionReq, err := http.NewRequestWithContext(a.ctx, "GET",
|
|
apiClient.GetBaseURL()+"/api/v1/collections/"+input.CollectionID, nil)
|
|
if err != nil {
|
|
a.logger.Error("Failed to create collection request", zap.Error(err))
|
|
return nil, fmt.Errorf("failed to create collection request: %w", err)
|
|
}
|
|
collectionReq.Header.Set("Authorization", "Bearer "+session.AccessToken)
|
|
|
|
collectionResp, err := a.httpClient.Do(collectionReq)
|
|
if err != nil {
|
|
a.logger.Error("Failed to fetch collection", zap.Error(err))
|
|
return nil, fmt.Errorf("failed to fetch collection: %w", err)
|
|
}
|
|
defer collectionResp.Body.Close()
|
|
|
|
a.logger.Info("Step 2a: Collection fetch response", zap.Int("status", collectionResp.StatusCode))
|
|
|
|
if collectionResp.StatusCode != http.StatusOK {
|
|
body, _ := io.ReadAll(collectionResp.Body)
|
|
a.logger.Error("Failed to fetch collection - bad status",
|
|
zap.Int("status", collectionResp.StatusCode),
|
|
zap.String("body", string(body)))
|
|
return nil, fmt.Errorf("failed to fetch collection: %s", string(body))
|
|
}
|
|
|
|
var collectionData struct {
|
|
EncryptedCollectionKey struct {
|
|
Ciphertext string `json:"ciphertext"`
|
|
Nonce string `json:"nonce"`
|
|
} `json:"encrypted_collection_key"`
|
|
}
|
|
if err := json.NewDecoder(collectionResp.Body).Decode(&collectionData); err != nil {
|
|
a.logger.Error("Failed to decode collection response", zap.Error(err))
|
|
return nil, fmt.Errorf("failed to decode collection: %w", err)
|
|
}
|
|
|
|
a.logger.Info("Step 2b: Collection data decoded",
|
|
zap.Int("ciphertext_len", len(collectionData.EncryptedCollectionKey.Ciphertext)),
|
|
zap.Int("nonce_len", len(collectionData.EncryptedCollectionKey.Nonce)))
|
|
|
|
// Decrypt collection key
|
|
collectionKeyCiphertext, err := base64.StdEncoding.DecodeString(collectionData.EncryptedCollectionKey.Ciphertext)
|
|
if err != nil {
|
|
a.logger.Error("Failed to decode collection key ciphertext", zap.Error(err))
|
|
return nil, fmt.Errorf("failed to decode collection key ciphertext: %w", err)
|
|
}
|
|
collectionKeyNonce, err := base64.StdEncoding.DecodeString(collectionData.EncryptedCollectionKey.Nonce)
|
|
if err != nil {
|
|
a.logger.Error("Failed to decode collection key nonce", zap.Error(err))
|
|
return nil, fmt.Errorf("failed to decode collection key nonce: %w", err)
|
|
}
|
|
|
|
// Handle web frontend combined ciphertext format (nonce + encrypted_data)
|
|
actualCollectionKeyCiphertext := extractActualCiphertext(collectionKeyCiphertext, collectionKeyNonce)
|
|
|
|
a.logger.Info("Step 2c: Decrypting collection key",
|
|
zap.Int("ciphertext_bytes", len(actualCollectionKeyCiphertext)),
|
|
zap.Int("nonce_bytes", len(collectionKeyNonce)),
|
|
zap.Int("master_key_bytes", len(masterKey)))
|
|
|
|
collectionKey, err := e2ee.DecryptCollectionKey(&e2ee.EncryptedKey{
|
|
Ciphertext: actualCollectionKeyCiphertext,
|
|
Nonce: collectionKeyNonce,
|
|
}, 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)
|
|
}
|
|
|
|
a.logger.Info("Collection key decrypted successfully", zap.Int("key_length", len(collectionKey)))
|
|
|
|
// Step 3: Generate a new file key
|
|
fileKey, err := e2ee.GenerateFileKey()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to generate file key: %w", err)
|
|
}
|
|
|
|
// Step 4: Encrypt file content using SecretBox (XSalsa20-Poly1305) for web frontend compatibility
|
|
encryptedContent, err := e2ee.EncryptFileSecretBox(fileContent, fileKey)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to encrypt file: %w", err)
|
|
}
|
|
|
|
// Step 5: Encrypt metadata using SecretBox (XSalsa20-Poly1305) for web frontend compatibility
|
|
metadata := &e2ee.FileMetadata{
|
|
Name: filename,
|
|
MimeType: mimeType,
|
|
Size: fileSize,
|
|
}
|
|
encryptedMetadata, err := e2ee.EncryptMetadataSecretBox(metadata, fileKey)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to encrypt metadata: %w", err)
|
|
}
|
|
|
|
// Step 6: Encrypt file key with collection key using SecretBox for web frontend compatibility
|
|
encryptedFileKey, err := e2ee.EncryptFileKeySecretBox(fileKey, collectionKey)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to encrypt file key: %w", err)
|
|
}
|
|
|
|
// Step 7: Compute encrypted hash
|
|
hash := sha256.Sum256(encryptedContent)
|
|
encryptedHash := base64.StdEncoding.EncodeToString(hash[:])
|
|
|
|
// Step 8: Generate client-side file ID
|
|
fileID := uuid.New().String()
|
|
|
|
// Step 9: Create pending file request
|
|
// NOTE: The web frontend sends ciphertext and nonce as SEPARATE fields (not combined).
|
|
// The ciphertext field contains only the encrypted data (from crypto_secretbox_easy),
|
|
// and the nonce field contains the nonce separately.
|
|
pendingFileReq := map[string]interface{}{
|
|
"id": fileID,
|
|
"collection_id": input.CollectionID,
|
|
"encrypted_metadata": encryptedMetadata,
|
|
"encrypted_file_key": map[string]string{
|
|
"ciphertext": base64.StdEncoding.EncodeToString(encryptedFileKey.Ciphertext),
|
|
"nonce": base64.StdEncoding.EncodeToString(encryptedFileKey.Nonce),
|
|
},
|
|
"encryption_version": "xsalsa20-poly1305-v1",
|
|
"encrypted_hash": encryptedHash,
|
|
"expected_file_size_in_bytes": int64(len(encryptedContent)),
|
|
"content_type": mimeType,
|
|
}
|
|
|
|
// Add tag IDs if provided
|
|
if len(input.TagIDs) > 0 {
|
|
pendingFileReq["tag_ids"] = input.TagIDs
|
|
a.logger.Info("Adding tags to file upload",
|
|
zap.Int("tag_count", len(input.TagIDs)))
|
|
}
|
|
|
|
pendingBody, err := json.Marshal(pendingFileReq)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to marshal pending file request: %w", err)
|
|
}
|
|
|
|
req, err := http.NewRequestWithContext(a.ctx, "POST",
|
|
apiClient.GetBaseURL()+"/api/v1/files/pending",
|
|
bytes.NewReader(pendingBody))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to create pending file request: %w", err)
|
|
}
|
|
req.Header.Set("Authorization", "Bearer "+session.AccessToken)
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
resp, err := a.httpClient.Do(req)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to create pending file: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
|
|
body, _ := io.ReadAll(resp.Body)
|
|
a.logger.Error("Failed to create pending file",
|
|
zap.Int("status", resp.StatusCode),
|
|
zap.String("body", string(body)))
|
|
return nil, fmt.Errorf("failed to create pending file: %s", string(body))
|
|
}
|
|
|
|
var pendingResp struct {
|
|
File struct {
|
|
ID string `json:"id"`
|
|
} `json:"file"`
|
|
PresignedUploadURL string `json:"presigned_upload_url"`
|
|
UploadURLExpirationTime string `json:"upload_url_expiration_time"`
|
|
Success bool `json:"success"`
|
|
Message string `json:"message"`
|
|
}
|
|
if err := json.NewDecoder(resp.Body).Decode(&pendingResp); err != nil {
|
|
return nil, fmt.Errorf("failed to decode pending file response: %w", err)
|
|
}
|
|
|
|
if !pendingResp.Success {
|
|
return nil, fmt.Errorf("failed to create pending file: %s", pendingResp.Message)
|
|
}
|
|
|
|
a.logger.Info("Pending file created, uploading to S3",
|
|
zap.String("file_id", pendingResp.File.ID),
|
|
zap.String("presigned_url", pendingResp.PresignedUploadURL[:50]+"..."))
|
|
|
|
// Step 10: Upload encrypted content to S3
|
|
uploadReq, err := http.NewRequestWithContext(a.ctx, "PUT",
|
|
pendingResp.PresignedUploadURL,
|
|
bytes.NewReader(encryptedContent))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to create upload request: %w", err)
|
|
}
|
|
uploadReq.Header.Set("Content-Type", "application/octet-stream")
|
|
uploadReq.ContentLength = int64(len(encryptedContent))
|
|
|
|
uploadResp, err := a.httpClient.DoLargeDownload(uploadReq)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to upload to S3: %w", err)
|
|
}
|
|
defer uploadResp.Body.Close()
|
|
|
|
if uploadResp.StatusCode != http.StatusOK && uploadResp.StatusCode != http.StatusCreated {
|
|
body, _ := io.ReadAll(uploadResp.Body)
|
|
a.logger.Error("Failed to upload to S3",
|
|
zap.Int("status", uploadResp.StatusCode),
|
|
zap.String("body", string(body)))
|
|
return nil, fmt.Errorf("failed to upload to S3: status %d", uploadResp.StatusCode)
|
|
}
|
|
|
|
a.logger.Info("File uploaded to S3, completing upload")
|
|
|
|
// Step 11: Complete the upload
|
|
completeReq := map[string]interface{}{
|
|
"actual_file_size_in_bytes": int64(len(encryptedContent)),
|
|
"upload_confirmed": true,
|
|
}
|
|
|
|
completeBody, err := json.Marshal(completeReq)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to marshal complete request: %w", err)
|
|
}
|
|
|
|
completeHTTPReq, err := http.NewRequestWithContext(a.ctx, "POST",
|
|
apiClient.GetBaseURL()+"/api/v1/file/"+pendingResp.File.ID+"/complete",
|
|
bytes.NewReader(completeBody))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to create complete request: %w", err)
|
|
}
|
|
completeHTTPReq.Header.Set("Authorization", "Bearer "+session.AccessToken)
|
|
completeHTTPReq.Header.Set("Content-Type", "application/json")
|
|
|
|
completeResp, err := a.httpClient.Do(completeHTTPReq)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to complete upload: %w", err)
|
|
}
|
|
defer completeResp.Body.Close()
|
|
|
|
if completeResp.StatusCode != http.StatusOK {
|
|
body, _ := io.ReadAll(completeResp.Body)
|
|
a.logger.Error("Failed to complete upload",
|
|
zap.Int("status", completeResp.StatusCode),
|
|
zap.String("body", string(body)))
|
|
return nil, fmt.Errorf("failed to complete upload: %s", string(body))
|
|
}
|
|
|
|
var completeRespData struct {
|
|
Success bool `json:"success"`
|
|
Message string `json:"message"`
|
|
}
|
|
if err := json.NewDecoder(completeResp.Body).Decode(&completeRespData); err != nil {
|
|
return nil, fmt.Errorf("failed to decode complete response: %w", err)
|
|
}
|
|
|
|
// Save file metadata to local repository so it appears in dashboard and file list
|
|
localFile := &file.File{
|
|
ID: pendingResp.File.ID,
|
|
CollectionID: input.CollectionID,
|
|
OwnerID: session.UserID,
|
|
Name: filename,
|
|
MimeType: mimeType,
|
|
DecryptedSizeInBytes: fileSize,
|
|
EncryptedSizeInBytes: int64(len(encryptedContent)),
|
|
FilePath: input.FilePath, // Original file path
|
|
SyncStatus: file.SyncStatusSynced,
|
|
State: file.StateActive,
|
|
CreatedAt: time.Now(),
|
|
ModifiedAt: time.Now(),
|
|
LastSyncedAt: time.Now(),
|
|
}
|
|
|
|
if err := a.mustGetFileRepo().Create(localFile); err != nil {
|
|
// Log but don't fail - the upload succeeded, just local tracking failed
|
|
a.logger.Warn("Failed to save file to local repository",
|
|
zap.String("file_id", pendingResp.File.ID),
|
|
zap.Error(err))
|
|
} else {
|
|
a.logger.Info("File saved to local repository",
|
|
zap.String("file_id", pendingResp.File.ID),
|
|
zap.String("filename", filename))
|
|
|
|
// Index the file in the search index
|
|
if err := a.indexFileForSearch(pendingResp.File.ID, input.CollectionID, filename, input.TagIDs, fileSize); err != nil {
|
|
a.logger.Warn("Failed to index file in search",
|
|
zap.String("file_id", pendingResp.File.ID),
|
|
zap.Error(err))
|
|
}
|
|
}
|
|
|
|
a.logger.Info("File upload completed successfully",
|
|
zap.String("file_id", pendingResp.File.ID),
|
|
zap.String("filename", filename),
|
|
zap.Int64("size", fileSize))
|
|
|
|
return &FileUploadResult{
|
|
FileID: pendingResp.File.ID,
|
|
Filename: filename,
|
|
Size: fileSize,
|
|
Success: true,
|
|
Message: "File uploaded successfully",
|
|
}, nil
|
|
}
|