Initial commit: Open sourcing all of the Maple Open Technologies code.
This commit is contained in:
commit
755d54a99d
2010 changed files with 448675 additions and 0 deletions
401
native/desktop/maplefile/internal/app/app_files_upload.go
Normal file
401
native/desktop/maplefile/internal/app/app_files_upload.go
Normal file
|
|
@ -0,0 +1,401 @@
|
|||
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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue