Initial commit: Open sourcing all of the Maple Open Technologies code.

This commit is contained in:
Bartlomiej Mika 2025-12-02 14:33:08 -05:00
commit 755d54a99d
2010 changed files with 448675 additions and 0 deletions

View 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
}