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,113 @@
# Application-specific Claude Code ignore file
#—————————————————————————————
# Wails / Native Desktop App
#—————————————————————————————
# Wails build artifacts
build/bin
build/bin/*
# Wails generated files (auto-generated by Wails)
frontend/wailsjs/go
frontend/wailsjs/runtime
# Frontend dependencies and build artifacts
node_modules
frontend/node_modules
frontend/dist
frontend/package-lock.json
frontend/.vite
#—————————————————————————————
# Go
#—————————————————————————————
# Dependencies
vendor/
*.sum
go.work
go.work.sum
# Build artifacts
maplefile
maplefile.exe
bin/
*.exe
*.dll
*.so
*.dylib
# Test and coverage
*.out
*.test
coverage.txt
*.cover
#—————————————————————————————
# Development
#—————————————————————————————
# Task runner
.task
# Logs
*.log
logs/
# Environment files
.env
.env.local
.env.*.local
# Temporary files
tmp/
temp/
#—————————————————————————————
# OS and IDE
#—————————————————————————————
# macOS
.DS_Store
.DS_Store?
._*
.Spotlight-V100
.Trashes
# Windows
ehthumbs.db
Thumbs.db
desktop.ini
# Linux
*~
# IDEs
.idea/
.vscode/
*.swp
*.swo
.vs/
#—————————————————————————————
# Application Specific Ignores
#—————————————————————————————
# Do not share developer's private notebook
private.txt
private_prod.md
private.md
private_*.md
todo.txt
private_docs
private_docs/*
# Do not save the `crev` text output
crev-project.txt
# Do not share private developer documentation
_md/*
# App
maplefile

68
native/desktop/maplefile/.gitignore vendored Normal file
View file

@ -0,0 +1,68 @@
# Wails build artifacts
build/bin
build/bin/*
# Frontend artifacts
node_modules
frontend/dist
frontend/node_modules
frontend/package-lock.json.md5
frontend/package.json.md5
# Wails generated files
frontend/wailsjs/go
frontend/wailsjs/runtime
# Task runner
.task
# Go build artifacts
*.exe
*.exe~
*.dll
*.so
*.dylib
vendor/
# Test binary, built with `go test -c`
*.test
# Output of the go coverage tool
*.out
coverage.txt
# Go workspace file
go.work
# Environment files
.env
.env.local
.env.*.local
# OS files
.DS_Store
.DS_Store?
._*
.Spotlight-V100
.Trashes
ehthumbs.db
Thumbs.db
# IDE files
.idea/
.vscode/
*.swp
*.swo
*~
.vs/
# Logs
*.log
logs/
# Temporary files
tmp/
temp/
# Application binary.
maplefile

View file

@ -0,0 +1,19 @@
# README
## About
This is the official Wails React template.
You can configure the project by editing `wails.json`. More information about the project settings can be found
here: https://wails.io/docs/reference/project-config
## Live Development
To run in live development mode, run `wails dev` in the project directory. This will run a Vite development
server that will provide very fast hot reload of your frontend changes. If you want to develop in a browser
and have access to your Go methods, there is also a dev server that runs on http://localhost:34115. Connect
to this in your browser, and you can call your Go code from devtools.
## Building
To build a redistributable, production mode package, use `wails build`.

View file

@ -0,0 +1,201 @@
version: "3"
vars:
APP_NAME: maplefile
WAILS_VERSION: v2.11.0
tasks:
# Development workflow
dev:
desc: Start app in development mode with hot reload (uses local backend)
env:
MAPLEFILE_MODE: dev
cmds:
- wails dev -ldflags "-X codeberg.org/mapleopentech/monorepo/native/desktop/maplefile/internal/app.BuildMode=dev -X codeberg.org/mapleopentech/monorepo/native/desktop/maplefile/internal/config.BuildMode=dev"
dev:production:
desc: Start app in development mode with production backend
env:
MAPLEFILE_MODE: production
cmds:
- wails dev -ldflags "-X codeberg.org/mapleopentech/monorepo/native/desktop/maplefile/internal/app.BuildMode=production -X codeberg.org/mapleopentech/monorepo/native/desktop/maplefile/internal/config.BuildMode=production"
dev:frontend:
desc: Start frontend development server only
dir: frontend
cmds:
- npm run dev
dev:build:
desc: Build development version (fast, no optimization)
cmds:
- wails build -dev
# Building
build:
desc: Build production binary for current platform (production backend)
cmds:
- wails build -ldflags "-X codeberg.org/mapleopentech/monorepo/native/desktop/maplefile/internal/app.BuildMode=production -X codeberg.org/mapleopentech/monorepo/native/desktop/maplefile/internal/config.BuildMode=production"
build:dev:
desc: Build binary for development (local backend)
cmds:
- wails build -ldflags "-X codeberg.org/mapleopentech/monorepo/native/desktop/maplefile/internal/app.BuildMode=dev -X codeberg.org/mapleopentech/monorepo/native/desktop/maplefile/internal/config.BuildMode=dev"
build:all:
desc: Build for all platforms
cmds:
- echo "Building for macOS..."
- wails build -platform darwin/universal -ldflags "-X codeberg.org/mapleopentech/monorepo/native/desktop/maplefile/internal/app.BuildMode=production -X codeberg.org/mapleopentech/monorepo/native/desktop/maplefile/internal/config.BuildMode=production"
- echo "Building for Linux..."
- wails build -platform linux/amd64 -ldflags "-X codeberg.org/mapleopentech/monorepo/native/desktop/maplefile/internal/app.BuildMode=production -X codeberg.org/mapleopentech/monorepo/native/desktop/maplefile/internal/config.BuildMode=production"
- echo "Building for Windows..."
- wails build -platform windows/amd64 -ldflags "-X codeberg.org/mapleopentech/monorepo/native/desktop/maplefile/internal/app.BuildMode=production -X codeberg.org/mapleopentech/monorepo/native/desktop/maplefile/internal/config.BuildMode=production"
- echo "✅ All builds complete"
build:mac:
desc: Build for macOS (Universal binary)
cmds:
- wails build -platform darwin/universal -ldflags "-X codeberg.org/mapleopentech/monorepo/native/desktop/maplefile/internal/app.BuildMode=production -X codeberg.org/mapleopentech/monorepo/native/desktop/maplefile/internal/config.BuildMode=production"
build:linux:
desc: Build for Linux (amd64)
cmds:
- wails build -platform linux/amd64 -ldflags "-X codeberg.org/mapleopentech/monorepo/native/desktop/maplefile/internal/app.BuildMode=production -X codeberg.org/mapleopentech/monorepo/native/desktop/maplefile/internal/config.BuildMode=production"
build:windows:
desc: Build for Windows (amd64)
cmds:
- wails build -platform windows/amd64 -ldflags "-X codeberg.org/mapleopentech/monorepo/native/desktop/maplefile/internal/app.BuildMode=production -X codeberg.org/mapleopentech/monorepo/native/desktop/maplefile/internal/config.BuildMode=production"
# Frontend tasks
frontend:install:
desc: Install frontend dependencies
dir: frontend
cmds:
- npm install
frontend:build:
desc: Build frontend for production
dir: frontend
cmds:
- npm run build
frontend:lint:
desc: Lint frontend code
dir: frontend
cmds:
- npm run lint
frontend:clean:
desc: Clean frontend build artifacts and dependencies
dir: frontend
cmds:
- rm -rf node_modules dist
# Go tasks
go:tidy:
desc: Tidy Go modules
cmds:
- go mod tidy
go:vendor:
desc: Vendor Go dependencies
cmds:
- go mod vendor
go:test:
desc: Run Go tests
cmds:
- go test ./... -v
go:lint:
desc: Run Go linters
cmds:
- go vet ./...
go:nilaway:
desc: Run nilaway static analysis for nil pointer dereferences
cmds:
- go run go.uber.org/nilaway/cmd/nilaway ./...
go:format:
desc: Format Go code
cmds:
- go fmt ./...
# Wails tasks
wails:doctor:
desc: Check Wails installation and dependencies
cmds:
- wails doctor
wails:version:
desc: Show Wails version
cmds:
- wails version
# Utility tasks
clean:
desc: Clean build artifacts
cmds:
- rm -rf build/bin
- rm -rf frontend/dist
- echo "✅ Build artifacts cleaned"
clean:all:
desc: Clean all build artifacts and dependencies
deps: [clean]
cmds:
- rm -rf frontend/node_modules
- rm -rf vendor
- echo "✅ All artifacts and dependencies cleaned"
setup:
desc: Initial project setup (install dependencies)
cmds:
- echo "📦 Installing frontend dependencies..."
- task: frontend:install
- echo "📦 Tidying Go modules..."
- task: go:tidy
- echo "✅ Setup complete! Run 'task dev' to start development"
check:
desc: Run all checks (format, lint, nilaway, test)
cmds:
- echo "🔍 Formatting Go code..."
- task: go:format
- echo "🔍 Linting Go code..."
- task: go:lint
- echo "🔍 Running nilaway analysis..."
- task: go:nilaway
- echo "🔍 Running tests..."
- task: go:test
- echo "🔍 Linting frontend..."
- task: frontend:lint
- echo "✅ All checks passed"
# Package tasks
package:
desc: Package the application for distribution
deps: [build]
cmds:
- echo "📦 Packaging application..."
- echo "Binary located in build/bin/"
- echo "✅ Package complete"
# Development helpers
info:
desc: Show project information
cmds:
- echo "Application {{.APP_NAME}}"
- echo "Wails Version {{.WAILS_VERSION}}"
- wails version
- echo ""
- echo "Build directory build/bin"
# Default task
default:
desc: Show available tasks
cmds:
- task --list

View file

@ -0,0 +1,234 @@
# Code Signing Guide for MapleFile Desktop
This document outlines the code signing requirements and procedures for MapleFile desktop application releases.
## Why Code Signing is Important
Code signing provides:
1. **Integrity Verification**: Ensures the binary hasn't been tampered with since signing
2. **Publisher Authentication**: Confirms the software comes from MapleFile/Maple Open Technologies
3. **User Trust**: Operating systems trust signed applications more readily
4. **Malware Protection**: Unsigned apps trigger security warnings that users may ignore
## Platform Requirements
### macOS
**Certificate Types:**
- **Developer ID Application**: Required for distribution outside the Mac App Store
- **Developer ID Installer**: Required for signed `.pkg` installers
**Requirements:**
1. Apple Developer Program membership ($99/year)
2. Developer ID certificates from Apple Developer portal
3. Notarization through Apple's notary service
**Signing Process:**
```bash
# Sign the application
codesign --force --options runtime --sign "Developer ID Application: Your Name (TEAM_ID)" \
--timestamp MapleFile.app
# Create a signed DMG
hdiutil create -volname "MapleFile" -srcfolder MapleFile.app -ov -format UDZO MapleFile.dmg
codesign --sign "Developer ID Application: Your Name (TEAM_ID)" MapleFile.dmg
# Notarize (required for macOS 10.15+)
xcrun notarytool submit MapleFile.dmg --apple-id your@email.com --team-id TEAM_ID --wait
xcrun stapler staple MapleFile.dmg
```
**Wails Build Integration:**
```bash
# Wails supports code signing via environment variables
export MACOS_SIGNING_IDENTITY="Developer ID Application: Your Name (TEAM_ID)"
export MACOS_NOTARIZATION_TEAM_ID="TEAM_ID"
export MACOS_NOTARIZATION_APPLE_ID="your@email.com"
export MACOS_NOTARIZATION_PASSWORD="@keychain:AC_PASSWORD"
wails build -platform darwin/universal
```
### Windows
**Certificate Types:**
- **EV Code Signing Certificate**: Extended Validation - highest trust, required for SmartScreen reputation
- **Standard Code Signing Certificate**: Basic signing, builds reputation over time
**Requirements:**
1. Code signing certificate from a trusted CA (DigiCert, Sectigo, GlobalSign, etc.)
2. Hardware token (required for EV certificates)
3. SignTool from Windows SDK
**Signing Process:**
```powershell
# Sign with timestamp (important for validity after certificate expiry)
signtool sign /tr http://timestamp.digicert.com /td sha256 /fd sha256 ^
/a /n "Maple Open Technologies" MapleFile.exe
# Verify signature
signtool verify /pa /v MapleFile.exe
```
**Wails Build Integration:**
```powershell
# Set environment variables before build
$env:WINDOWS_SIGNING_CERTIFICATE = "path/to/certificate.pfx"
$env:WINDOWS_SIGNING_PASSWORD = "certificate_password"
wails build -platform windows/amd64
```
### Linux
Linux doesn't have a universal code signing requirement, but you can:
1. **GPG Signing**: Sign release artifacts with GPG
```bash
gpg --armor --detach-sign MapleFile.tar.gz
```
2. **AppImage Signing**: Sign AppImage files
```bash
# Import your signing key
./appimagetool --sign MapleFile.AppImage
```
3. **Package Signatures**: Use distribution-specific signing
- `.deb`: `dpkg-sig --sign builder package.deb`
- `.rpm`: `rpm --addsign package.rpm`
## Secure Update Mechanism
### Current State
MapleFile currently does not include automatic updates.
### Recommended Implementation
1. **Update Server**: Host update manifests with signed checksums
2. **Version Checking**: Application checks for updates on startup (optional)
3. **Download Verification**: Verify signature before applying update
4. **Rollback Support**: Keep previous version for rollback on failure
**Update Manifest Format:**
```json
{
"version": "1.2.3",
"release_date": "2025-01-15",
"platforms": {
"darwin-arm64": {
"url": "https://releases.maplefile.com/v1.2.3/MapleFile-darwin-arm64.dmg",
"sha256": "abc123...",
"signature": "base64-encoded-signature"
},
"darwin-amd64": {
"url": "https://releases.maplefile.com/v1.2.3/MapleFile-darwin-amd64.dmg",
"sha256": "def456...",
"signature": "base64-encoded-signature"
},
"windows-amd64": {
"url": "https://releases.maplefile.com/v1.2.3/MapleFile-windows-amd64.exe",
"sha256": "ghi789...",
"signature": "base64-encoded-signature"
}
}
}
```
**Verification Process:**
```go
// Pseudocode for update verification
func verifyUpdate(downloadPath, expectedSHA256, signature string) error {
// 1. Verify SHA256 hash
actualHash := sha256sum(downloadPath)
if actualHash != expectedSHA256 {
return errors.New("hash mismatch")
}
// 2. Verify signature (using embedded public key)
if !verifySignature(downloadPath, signature, publicKey) {
return errors.New("signature verification failed")
}
return nil
}
```
## Certificate Management
### Storage
- **Never** commit private keys to version control
- Store certificates in secure vault (e.g., HashiCorp Vault, AWS Secrets Manager)
- Use CI/CD secrets for automated builds
### Rotation
- Set calendar reminders for certificate expiry (typically 1-3 years)
- Plan for certificate rotation before expiry
- Test signing process after certificate renewal
### Revocation
- Maintain list of compromised certificates
- Have incident response plan for key compromise
- Document process for certificate revocation
## Build Pipeline Integration
### GitHub Actions Example
```yaml
name: Release Build
on:
push:
tags:
- 'v*'
jobs:
build-macos:
runs-on: macos-latest
steps:
- uses: actions/checkout@v4
- name: Import Code Signing Certificate
env:
CERTIFICATE_BASE64: ${{ secrets.MACOS_CERTIFICATE }}
CERTIFICATE_PASSWORD: ${{ secrets.MACOS_CERTIFICATE_PASSWORD }}
run: |
echo $CERTIFICATE_BASE64 | base64 --decode > certificate.p12
security create-keychain -p "" build.keychain
security import certificate.p12 -k build.keychain -P "$CERTIFICATE_PASSWORD" -T /usr/bin/codesign
security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "" build.keychain
- name: Build and Sign
env:
MACOS_SIGNING_IDENTITY: ${{ secrets.MACOS_SIGNING_IDENTITY }}
run: |
wails build -platform darwin/universal
# Notarize here...
```
## Verification Commands
### macOS
```bash
# Check signature
codesign -dvv MapleFile.app
# Verify notarization
spctl -a -vv MapleFile.app
```
### Windows
```powershell
# Check signature
signtool verify /pa /v MapleFile.exe
# PowerShell alternative
Get-AuthenticodeSignature MapleFile.exe
```
## References
- [Apple Developer Code Signing](https://developer.apple.com/documentation/security/notarizing_macos_software_before_distribution)
- [Microsoft Authenticode](https://docs.microsoft.com/en-us/windows/win32/seccrypto/cryptography-tools)
- [Wails Build Documentation](https://wails.io/docs/guides/signing)
- [OWASP Code Signing Guidelines](https://cheatsheetseries.owasp.org/cheatsheets/Code_Signing_Cheat_Sheet.html)

View file

@ -0,0 +1,391 @@
# Collection Icon Customization Plan
## Overview
Add the ability to customize collection (folder) icons with emojis or predefined icons. This feature enhances the user experience by allowing visual differentiation between collections.
## Requirements
1. **Customization Options**:
- Custom emoji (e.g., 📁, 🎵, 📷, 💼, 🏠)
- Predefined cross-browser icons from a curated set
- Default folder icon when no customization is set
2. **Behavior**:
- Default to standard folder icon if no customization
- Persist across browser sessions (stored in database)
- Easy to change or revert to default
- Intuitive, user-friendly UI
3. **Security Consideration**:
- Icon data should be encrypted (E2EE) like collection name
- Only emoji characters or predefined icon identifiers allowed
---
## Data Model Design
### New Field: `custom_icon`
Add a new encrypted field to the Collection model that stores icon customization data.
```go
// CustomIcon stores the collection's custom icon configuration
type CustomIcon struct {
Type string `json:"type"` // "emoji", "icon", or "" (empty = default)
Value string `json:"value"` // Emoji character or icon identifier
}
```
**Field Storage Options**:
| Option | Pros | Cons |
|--------|------|------|
| A) Single encrypted JSON field | Simple, one field | Requires parsing |
| B) Two fields (type + value) | Clear structure | More columns |
| C) Single string field | Simplest | Limited validation |
**Recommended: Option C** - Single `encrypted_custom_icon` field storing either:
- Empty string `""` → Default folder icon
- Emoji character (e.g., `"📷"`) → Display as emoji
- Icon identifier (e.g., `"icon:briefcase"`) → Predefined icon
This keeps the schema simple and the client handles interpretation.
---
## Implementation Plan
### Phase 1: Backend (cloud/maplefile-backend)
#### 1.1 Database Schema Update
**Note:** No new migration files needed - updating existing migration `012_create_collections_by_id.up.cql` directly (assumes full database wipe).
Add to `collections_by_id` table:
```sql
encrypted_custom_icon TEXT,
```
#### 1.2 Update Domain Model
File: `internal/domain/collection/model.go`
```go
type Collection struct {
// ... existing fields ...
// EncryptedCustomIcon stores the custom icon for this collection.
// Empty string means use default folder icon.
// Contains either an emoji character or "icon:<identifier>" for predefined icons.
// Encrypted with the collection key for E2EE.
EncryptedCustomIcon string `bson:"encrypted_custom_icon" json:"encrypted_custom_icon"`
}
```
Also add to `CollectionSyncItem` for sync operations:
```go
type CollectionSyncItem struct {
// ... existing fields ...
EncryptedCustomIcon string `json:"encrypted_custom_icon,omitempty" bson:"encrypted_custom_icon,omitempty"`
}
```
**Note:** The sync query in `collectionsync.go:getCollectionSyncItem()` fetches minimal data from `collections_by_id`. This query will need to include `encrypted_custom_icon` so clients can display the correct icon during sync.
#### 1.3 Update Repository Layer
Files to modify:
- `internal/repo/collection/create.go` - Include new field in INSERT
- `internal/repo/collection/update.go` - Include new field in UPDATE
- `internal/repo/collection/get.go` - Include new field in SELECT
- `internal/repo/collection/sync.go` - Include new field in sync queries
#### 1.4 Update HTTP Handlers (if needed)
The existing create/update endpoints should automatically handle the new field since they accept the full Collection struct.
---
### Phase 2: Frontend (web/maplefile-frontend)
#### 2.1 Create Icon Picker Component
New file: `src/components/IconPicker/IconPicker.jsx`
Features:
- Emoji picker tab with common categories (objects, activities, symbols)
- Predefined icons tab (Heroicons subset)
- "Default" option to revert to folder icon
- Search/filter functionality
- Recently used icons
```jsx
// Example structure
const IconPicker = ({ value, onChange, onClose }) => {
const [activeTab, setActiveTab] = useState('emoji'); // 'emoji' | 'icons'
const predefinedIcons = [
{ id: 'briefcase', icon: BriefcaseIcon, label: 'Work' },
{ id: 'photo', icon: PhotoIcon, label: 'Photos' },
{ id: 'music', icon: MusicalNoteIcon, label: 'Music' },
{ id: 'document', icon: DocumentIcon, label: 'Documents' },
{ id: 'archive', icon: ArchiveBoxIcon, label: 'Archive' },
// ... more icons
];
const popularEmojis = ['📁', '📷', '🎵', '💼', '🏠', '❤️', '⭐', '🎮', '📚', '🎨'];
return (
// ... picker UI
);
};
```
#### 2.2 Update CollectionEdit Page
File: `src/pages/User/FileManager/Collections/CollectionEdit.jsx`
Add icon customization section:
```jsx
{/* Icon Customization Section */}
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
<h2 className="text-lg font-semibold text-gray-900 mb-4 flex items-center">
<SparklesIcon className="h-5 w-5 mr-2 text-gray-500" />
Customize Icon
</h2>
<div className="flex items-center space-x-4">
{/* Current Icon Preview */}
<div className="flex items-center justify-center h-16 w-16 bg-gray-100 rounded-xl border-2 border-dashed border-gray-300">
<CollectionIcon icon={formData.customIcon} size="lg" />
</div>
{/* Change/Reset Buttons */}
<div className="space-y-2">
<button onClick={() => setShowIconPicker(true)} className="...">
Change Icon
</button>
{formData.customIcon && (
<button onClick={() => setFormData({...formData, customIcon: ''})} className="...">
Reset to Default
</button>
)}
</div>
</div>
</div>
```
#### 2.3 Create CollectionIcon Component
New file: `src/components/CollectionIcon/CollectionIcon.jsx`
Renders the appropriate icon based on the customIcon value:
```jsx
const CollectionIcon = ({ icon, collectionType = 'folder', size = 'md', className = '' }) => {
const sizes = {
sm: 'h-4 w-4',
md: 'h-6 w-6',
lg: 'h-10 w-10',
};
// Default folder/album icon
if (!icon || icon === '') {
const Icon = collectionType === 'album' ? PhotoIcon : FolderIcon;
return <Icon className={`${sizes[size]} ${className}`} />;
}
// Predefined icon
if (icon.startsWith('icon:')) {
const iconId = icon.replace('icon:', '');
const IconComponent = predefinedIconMap[iconId];
return IconComponent ? <IconComponent className={`${sizes[size]} ${className}`} /> : <FolderIcon className={`${sizes[size]} ${className}`} />;
}
// Emoji
return <span className={`${emojiSizes[size]} ${className}`}>{icon}</span>;
};
```
#### 2.4 Update Collection List/Grid Views
Update anywhere collections are displayed to use the new `CollectionIcon` component:
- `FileManagerIndex.jsx`
- `CollectionDetails.jsx`
- Sidebar navigation (if applicable)
#### 2.5 Update Encryption/Decryption
Update the collection encryption service to handle the new field:
- Encrypt `customIcon` when saving
- Decrypt `encrypted_custom_icon` when loading
---
### Phase 3: Native Desktop (native/desktop/maplefile)
#### 3.1 Update Domain Model
File: `internal/domain/collection/model.go`
```go
type Collection struct {
// ... existing fields ...
// CustomIcon stores the decrypted custom icon for this collection.
// Empty string means use default folder icon.
// Contains either an emoji character or "icon:<identifier>" for predefined icons.
CustomIcon string `json:"custom_icon,omitempty"`
// EncryptedCustomIcon is the encrypted version from cloud
EncryptedCustomIcon string `json:"encrypted_custom_icon,omitempty"`
}
```
#### 3.2 Update Sync Service
Ensure the sync service handles the new field when syncing collections from the cloud.
#### 3.3 Update Frontend (Wails)
The desktop frontend uses the same React patterns, so the IconPicker and CollectionIcon components can be shared or adapted.
---
## Predefined Icon Set
A curated set of cross-browser compatible icons:
| ID | Icon | Use Case |
|----|------|----------|
| `briefcase` | BriefcaseIcon | Work |
| `photo` | PhotoIcon | Photos |
| `music` | MusicalNoteIcon | Music |
| `video` | VideoCameraIcon | Videos |
| `document` | DocumentTextIcon | Documents |
| `archive` | ArchiveBoxIcon | Archive |
| `star` | StarIcon | Favorites |
| `heart` | HeartIcon | Personal |
| `home` | HomeIcon | Home |
| `academic` | AcademicCapIcon | School |
| `code` | CodeBracketIcon | Code |
| `cloud` | CloudIcon | Cloud |
| `lock` | LockClosedIcon | Private |
| `gift` | GiftIcon | Gifts |
| `calendar` | CalendarIcon | Events |
---
## Migration Strategy
1. **Backward Compatible**: Empty `encrypted_custom_icon` means default icon
2. **No Data Migration Needed**: New collections get the field, old collections have NULL/empty
3. **Clients Handle Missing Field**: Treat NULL/empty as default
---
## Testing Checklist
### Backend
- [ ] Migration runs successfully
- [ ] Create collection with custom icon
- [ ] Update collection custom icon
- [ ] Revert to default icon
- [ ] Sync includes custom icon field
- [ ] E2EE encryption/decryption works
### Frontend (Web)
- [ ] Icon picker opens and closes
- [ ] Emoji selection works
- [ ] Predefined icon selection works
- [ ] Reset to default works
- [ ] Icon persists after page reload
- [ ] Icon displays correctly in list/grid views
- [ ] Works across different browsers
### Native Desktop
- [ ] Sync downloads custom icon
- [ ] Icon displays correctly
- [ ] Edit icon works (if implemented)
---
## Security Considerations
1. **E2EE**: Custom icon is encrypted with collection key
2. **Input Validation**: Only allow valid emoji or predefined icon IDs
3. **XSS Prevention**: Sanitize icon display (emoji rendering, no HTML)
4. **Size Limits**: Max length for custom icon field (e.g., 50 chars)
---
## Future Enhancements
1. **Custom uploaded icons** (requires more complex storage)
2. **Icon color customization**
3. **Icon packs/themes**
4. **Bulk icon changes** (apply to multiple collections)
---
## Files to Modify
### Backend (cloud/maplefile-backend)
**Schema Update (modify existing migration):**
1. `migrations/012_create_collections_by_id.up.cql` - Add `encrypted_custom_icon TEXT` column
**Domain Layer:**
2. `internal/domain/collection/model.go` - Add `EncryptedCustomIcon` field to `Collection` and `CollectionSyncItem` structs
**Repository Layer:**
4. `internal/repo/collection/create.go` - Add `encrypted_custom_icon` to INSERT query (line ~57-66)
5. `internal/repo/collection/update.go` - Add `encrypted_custom_icon` to UPDATE query (line ~64-73)
6. `internal/repo/collection/get.go` - Add `encrypted_custom_icon` to SELECT query and scan (line ~44-52, ~72-90)
7. `internal/repo/collection/collectionsync.go` - Add field to sync queries
**Note:** Secondary tables (013-017) do NOT need modification - they are lookup/index tables that only store keys and minimal fields. The `encrypted_custom_icon` is stored only in `collections_by_id`.
### Frontend (web/maplefile-frontend)
**New Components:**
1. `src/components/IconPicker/IconPicker.jsx` - Modal with emoji grid + predefined icons
2. `src/components/CollectionIcon/CollectionIcon.jsx` - Renders appropriate icon based on value
**Modified Pages:**
3. `src/pages/User/FileManager/Collections/CollectionEdit.jsx` - Add icon customization section
4. `src/pages/User/FileManager/Collections/CollectionDetails.jsx` - Display custom icon
5. `src/pages/User/FileManager/Collections/CollectionCreate.jsx` - Optional icon selection on create
6. `src/pages/User/FileManager/FileManagerIndex.jsx` - Display custom icons in list/grid
**Services:**
7. Collection encryption service - Handle `customIcon` field encryption/decryption
### Native Desktop (native/desktop/maplefile)
**Domain:**
1. `internal/domain/collection/model.go` - Add `CustomIcon` and `EncryptedCustomIcon` fields
**Repository:**
2. `internal/repo/collection/repository.go` - Handle new field in CRUD operations
**Sync:**
3. `internal/service/sync/collection.go` - Include field in sync operations
**Frontend:**
4. `frontend/src/` - Adapt IconPicker and CollectionIcon components (if not shared with web)
---
## Estimated Effort
| Phase | Effort |
|-------|--------|
| Backend (migration + model) | 2-3 hours |
| Frontend components | 4-6 hours |
| Frontend integration | 2-3 hours |
| Native desktop | 2-3 hours |
| Testing | 2-3 hours |
| **Total** | **12-18 hours** |

View file

@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"/>
<meta content="width=device-width, initial-scale=1.0" name="viewport"/>
<title>maplefile</title>
</head>
<body>
<div id="root"></div>
<script src="./src/main.jsx" type="module"></script>
</body>
</html>

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,22 @@
{
"name": "frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router-dom": "^7.9.6"
},
"devDependencies": {
"@types/react": "^18.0.17",
"@types/react-dom": "^18.0.6",
"@vitejs/plugin-react": "^2.0.1",
"vite": "^3.0.7"
}
}

View file

@ -0,0 +1,24 @@
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
background-color: #ecf0f1;
color: #333;
}
#root {
min-height: 100vh;
}
a {
color: inherit;
text-decoration: none;
}

View file

@ -0,0 +1,274 @@
// File Path: monorepo/native/desktop/maplefile/frontend/src/App.jsx
import { useState, useEffect } from "react";
import {
BrowserRouter as Router,
Routes,
Route,
Navigate,
useNavigate,
} from "react-router-dom";
import {
IsLoggedIn,
HasStoredPassword,
DecryptLoginChallenge,
} from "../wailsjs/go/app/Application";
import PasswordPrompt from "./components/PasswordPrompt";
import "./App.css";
// Anonymous Pages
import IndexPage from "./pages/Anonymous/Index/IndexPage";
import Register from "./pages/Anonymous/Register/Register";
import RecoveryCode from "./pages/Anonymous/Register/RecoveryCode";
import VerifyEmail from "./pages/Anonymous/Register/VerifyEmail";
import VerifySuccess from "./pages/Anonymous/Register/VerifySuccess";
import RequestOTT from "./pages/Anonymous/Login/RequestOTT";
import VerifyOTT from "./pages/Anonymous/Login/VerifyOTT";
import CompleteLogin from "./pages/Anonymous/Login/CompleteLogin";
import SessionExpired from "./pages/Anonymous/Login/SessionExpired";
import InitiateRecovery from "./pages/Anonymous/Recovery/InitiateRecovery";
import VerifyRecovery from "./pages/Anonymous/Recovery/VerifyRecovery";
import CompleteRecovery from "./pages/Anonymous/Recovery/CompleteRecovery";
// User Pages
import Dashboard from "./pages/User/Dashboard/Dashboard";
import FileManagerIndex from "./pages/User/FileManager/FileManagerIndex";
import CollectionCreate from "./pages/User/FileManager/Collections/CollectionCreate";
import CollectionDetails from "./pages/User/FileManager/Collections/CollectionDetails";
import CollectionEdit from "./pages/User/FileManager/Collections/CollectionEdit";
import CollectionShare from "./pages/User/FileManager/Collections/CollectionShare";
import FileUpload from "./pages/User/FileManager/Files/FileUpload";
import FileDetails from "./pages/User/FileManager/Files/FileDetails";
import MeDetail from "./pages/User/Me/MeDetail";
import DeleteAccount from "./pages/User/Me/DeleteAccount";
import BlockedUsers from "./pages/User/Me/BlockedUsers";
import TagCreate from "./pages/User/Tags/TagCreate";
import TagEdit from "./pages/User/Tags/TagEdit";
import TagSearch from "./pages/User/Tags/TagSearch";
import FullTextSearch from "./pages/User/Search/FullTextSearch";
function AppContent() {
const [authState, setAuthState] = useState({
isLoggedIn: null, // null = checking, true/false = known
hasPassword: null, // Does RAM have password?
needsPassword: false, // Should we show password prompt?
loading: true,
email: null,
});
const navigate = useNavigate();
useEffect(() => {
// Wait for Wails runtime to be ready before checking auth
let attempts = 0;
const maxAttempts = 50; // 5 seconds max
let isCancelled = false;
const checkWailsReady = () => {
if (isCancelled) return;
if (window.go && window.go.app && window.go.app.Application) {
checkAuthState();
} else if (attempts < maxAttempts) {
attempts++;
setTimeout(checkWailsReady, 100);
} else {
// Timeout - assume not logged in
console.error("Wails runtime failed to initialize");
setAuthState({
isLoggedIn: false,
hasPassword: false,
needsPassword: false,
loading: false,
email: null,
});
}
};
checkWailsReady();
return () => {
isCancelled = true;
};
}, []);
async function checkAuthState() {
try {
// Double-check Wails runtime is available
if (!window.go || !window.go.app || !window.go.app.Application) {
throw new Error("Wails runtime not available");
}
// Step 1: Check if user is logged in (session exists)
const loggedIn = await IsLoggedIn();
if (!loggedIn) {
// Not logged in show login screen
setAuthState({
isLoggedIn: false,
hasPassword: false,
needsPassword: false,
loading: false,
email: null,
});
return;
}
// Step 2: User is logged in, check for stored password
const hasPassword = await HasStoredPassword();
if (hasPassword) {
// Password is stored in RAM all good
setAuthState({
isLoggedIn: true,
hasPassword: true,
needsPassword: false,
loading: false,
email: null, // We could fetch session info if needed
});
} else {
// No password in RAM need to prompt
setAuthState({
isLoggedIn: true,
hasPassword: false,
needsPassword: true,
loading: false,
email: null, // PasswordPrompt will get this from session
});
}
} catch (error) {
console.error("Auth state check failed:", error);
setAuthState({
isLoggedIn: false,
hasPassword: false,
needsPassword: false,
loading: false,
email: null,
});
}
}
const handlePasswordVerified = async (password) => {
// For now, we'll assume verification happens in the PasswordPrompt
// The password is stored by StorePasswordForSession in the component
// Update state to indicate password is now available
setAuthState({
...authState,
hasPassword: true,
needsPassword: false,
});
// Navigate to dashboard
navigate("/dashboard");
return true; // Password is valid
};
// Show loading screen while checking auth
if (authState.loading) {
return (
<div
style={{
display: "flex",
justifyContent: "center",
alignItems: "center",
height: "100vh",
background: "#f8f9fa",
}}
>
<div style={{ textAlign: "center" }}>
<h2>MapleFile</h2>
<p style={{ color: "#666" }}>Loading...</p>
</div>
</div>
);
}
// Show password prompt if logged in but no password
if (authState.isLoggedIn && authState.needsPassword) {
return (
<PasswordPrompt
email={authState.email || "Loading..."}
onPasswordVerified={handlePasswordVerified}
/>
);
}
return (
<Routes>
{/* Anonymous/Public Routes */}
<Route path="/" element={<IndexPage />} />
{/* Registration Flow */}
<Route path="/register" element={<Register />} />
<Route path="/register/recovery" element={<RecoveryCode />} />
<Route path="/register/verify-email" element={<VerifyEmail />} />
<Route path="/register/verify-success" element={<VerifySuccess />} />
{/* Login Flow */}
<Route path="/login" element={<RequestOTT />} />
<Route path="/login/verify-ott" element={<VerifyOTT />} />
<Route path="/login/complete" element={<CompleteLogin />} />
<Route path="/session-expired" element={<SessionExpired />} />
{/* Recovery Flow */}
<Route path="/recovery" element={<InitiateRecovery />} />
<Route path="/recovery/initiate" element={<InitiateRecovery />} />
<Route path="/recovery/verify" element={<VerifyRecovery />} />
<Route path="/recovery/complete" element={<CompleteRecovery />} />
{/* Authenticated User Routes */}
<Route path="/dashboard" element={<Dashboard />} />
{/* File Manager Routes */}
<Route path="/file-manager" element={<FileManagerIndex />} />
<Route
path="/file-manager/collections/create"
element={<CollectionCreate />}
/>
<Route
path="/file-manager/collections/:collectionId"
element={<CollectionDetails />}
/>
<Route
path="/file-manager/collections/:collectionId/edit"
element={<CollectionEdit />}
/>
<Route
path="/file-manager/collections/:collectionId/share"
element={<CollectionShare />}
/>
<Route path="/file-manager/upload" element={<FileUpload />} />
<Route path="/file-manager/files/:fileId" element={<FileDetails />} />
{/* Export moved to Profile page - redirect old route */}
<Route path="/file-manager/export" element={<Navigate to="/me?tab=export" replace />} />
{/* User Profile Routes */}
<Route path="/me" element={<MeDetail />} />
<Route path="/profile" element={<MeDetail />} />
<Route path="/me/delete-account" element={<DeleteAccount />} />
<Route path="/me/blocked-users" element={<BlockedUsers />} />
{/* Tags Routes */}
<Route path="/tags/search" element={<TagSearch />} />
<Route path="/me/tags/create" element={<TagCreate />} />
<Route path="/me/tags/:tagId/edit" element={<TagEdit />} />
{/* Search Routes */}
<Route path="/search" element={<FullTextSearch />} />
{/* Catch-all route */}
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
);
}
function App() {
return (
<Router>
<AppContent />
</Router>
);
}
export default App;

View file

@ -0,0 +1,93 @@
Copyright 2016 The Nunito Project Authors (contact@sansoxygen.com),
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.

Binary file not shown.

After

Width:  |  Height:  |  Size: 136 KiB

View file

@ -0,0 +1,187 @@
/* IconPicker.css - Styles for the IconPicker component */
.icon-picker-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
padding: 20px;
}
.icon-picker-modal {
background: white;
border-radius: 12px;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.2);
max-width: 650px;
width: 100%;
max-height: 85vh;
display: flex;
flex-direction: column;
overflow: hidden;
}
.icon-picker-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 20px;
border-bottom: 1px solid #e5e7eb;
}
.icon-picker-header h3 {
margin: 0;
font-size: 18px;
font-weight: 600;
color: #111827;
}
.icon-picker-close {
background: none;
border: none;
font-size: 18px;
color: #6b7280;
cursor: pointer;
padding: 4px 8px;
border-radius: 4px;
transition: all 0.15s ease;
}
.icon-picker-close:hover {
background: #f3f4f6;
color: #111827;
}
.icon-picker-content {
display: flex;
flex: 1;
overflow: hidden;
}
.icon-picker-sidebar {
width: 140px;
flex-shrink: 0;
border-right: 1px solid #e5e7eb;
padding: 12px;
overflow-y: auto;
background: #f9fafb;
}
.icon-picker-category-btn {
display: block;
width: 100%;
text-align: left;
padding: 8px 10px;
margin-bottom: 4px;
font-size: 11px;
font-weight: 500;
color: #4b5563;
background: none;
border: none;
border-radius: 6px;
cursor: pointer;
transition: all 0.15s ease;
}
.icon-picker-category-btn:hover {
background: #e5e7eb;
color: #111827;
}
.icon-picker-category-btn.active {
background: #991b1b;
color: white;
}
.icon-picker-grid-container {
flex: 1;
padding: 16px;
overflow-y: auto;
}
.icon-picker-category-title {
margin: 0 0 12px 0;
font-size: 14px;
font-weight: 600;
color: #374151;
}
.icon-picker-grid {
display: grid;
grid-template-columns: repeat(10, 1fr);
gap: 4px;
}
.icon-picker-emoji-btn {
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
font-size: 22px;
background: none;
border: none;
border-radius: 6px;
cursor: pointer;
transition: all 0.15s ease;
}
.icon-picker-emoji-btn:hover {
background: #f3f4f6;
transform: scale(1.1);
}
.icon-picker-emoji-btn.selected {
background: #fee2e2;
outline: 2px solid #991b1b;
}
.icon-picker-footer {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 20px;
border-top: 1px solid #e5e7eb;
background: #f9fafb;
}
.icon-picker-reset-btn {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 14px;
font-size: 13px;
color: #4b5563;
background: none;
border: none;
border-radius: 6px;
cursor: pointer;
transition: all 0.15s ease;
}
.icon-picker-reset-btn:hover {
background: #e5e7eb;
color: #111827;
}
.icon-picker-cancel-btn {
padding: 8px 16px;
font-size: 13px;
font-weight: 500;
color: #374151;
background: white;
border: 1px solid #d1d5db;
border-radius: 6px;
cursor: pointer;
transition: all 0.15s ease;
}
.icon-picker-cancel-btn:hover {
background: #f9fafb;
border-color: #9ca3af;
}

View file

@ -0,0 +1,154 @@
// File: frontend/src/components/IconPicker.jsx
// Icon picker component for selecting emojis or predefined icons for collections
import React, { useState } from "react";
import "./IconPicker.css";
// Comprehensive emoji collection organized by logical categories
const EMOJI_CATEGORIES = {
"Files & Folders": [
"📁", "📂", "🗂️", "📑", "📄", "📃", "📋", "📝", "✏️", "🖊️",
"📎", "📌", "🔖", "🏷️", "📰", "🗃️", "🗄️", "📦", "📥", "📤",
],
"Work & Business": [
"💼", "🏢", "🏛️", "🏦", "💰", "💵", "💳", "🧾", "📊", "📈",
"📉", "💹", "🗓️", "📅", "⏰", "⌚", "🖥️", "💻", "⌨️", "🖨️",
],
"Tech & Devices": [
"📱", "📲", "☎️", "📞", "📟", "📠", "🔌", "🔋", "💾", "💿",
"📀", "🖱️", "🖲️", "🎮", "🕹️", "🛜", "📡", "📺", "📻", "🎙️",
],
"Media & Creative": [
"📷", "📸", "📹", "🎥", "🎬", "🎞️", "📽️", "🎵", "🎶", "🎤",
"🎧", "🎼", "🎹", "🎸", "🥁", "🎨", "🖼️", "🎭", "🎪", "🎠",
],
"Education & Science": [
"📚", "📖", "📕", "📗", "📘", "📙", "🎓", "🏫", "✍️", "📐",
"📏", "🔬", "🔭", "🧪", "🧫", "🧬", "🔍", "🔎", "💡", "📡",
],
"Communication": [
"💬", "💭", "🗨️", "🗯️", "📧", "📨", "📩", "📮", "📪", "📫",
"📬", "📭", "✉️", "💌", "📯", "🔔", "🔕", "📢", "📣", "🗣️",
],
"Home & Life": [
"🏠", "🏡", "🏘️", "🛏️", "🛋️", "🪑", "🚿", "🛁", "🧹", "🧺",
"👨‍👩‍👧‍👦", "👪", "❤️", "💕", "💝", "💖", "🧸", "🎁", "🎀", "🎈",
],
"Health & Wellness": [
"🏥", "💊", "💉", "🩺", "🩹", "🩼", "♿", "🧘", "🏃", "🚴",
"🏋️", "🤸", "⚕️", "🩸", "🧠", "👁️", "🦷", "💪", "🧬", "🍎",
],
"Food & Drinks": [
"🍕", "🍔", "🍟", "🌮", "🌯", "🍜", "🍝", "🍣", "🍱", "🥗",
"🍰", "🎂", "🧁", "🍩", "🍪", "☕", "🍵", "🥤", "🍷", "🍺",
],
"Travel & Places": [
"✈️", "🚀", "🛸", "🚁", "🚂", "🚗", "🚕", "🚌", "🚢", "⛵",
"🗺️", "🧭", "🏖️", "🏔️", "🏕️", "🗽", "🗼", "🏰", "⛺", "🌍",
],
"Sports & Hobbies": [
"⚽", "🏀", "🏈", "⚾", "🎾", "🏐", "🏓", "🏸", "🎯", "🎱",
"🎳", "🏆", "🥇", "🥈", "🥉", "🎲", "♟️", "🧩", "🎰", "🎮",
],
"Nature & Weather": [
"🌸", "🌺", "🌻", "🌹", "🌷", "🌴", "🌲", "🍀", "🌿", "🍃",
"☀️", "🌙", "⭐", "🌈", "☁️", "🌧️", "❄️", "🔥", "💧", "🌊",
],
"Animals": [
"🐶", "🐱", "🐭", "🐹", "🐰", "🦊", "🐻", "🐼", "🐨", "🦁",
"🐯", "🐮", "🐷", "🐸", "🐵", "🐔", "🐧", "🐦", "🦋", "🐝",
],
"Symbols & Status": [
"✅", "❌", "⭕", "❗", "❓", "💯", "🔴", "🟠", "🟡", "🟢",
"🔵", "🟣", "⚫", "⚪", "🔶", "🔷", "💠", "🔘", "🏁", "🚩",
],
"Security": [
"🔒", "🔓", "🔐", "🔑", "🗝️", "🛡️", "⚔️", "🔫", "🚨", "🚔",
"👮", "🕵️", "🦺", "🧯", "🪖", "⛑️", "🔏", "🔒", "👁️‍🗨️", "🛂",
],
"Celebration": [
"🎉", "🎊", "🎂", "🎁", "🎀", "🎈", "🎄", "🎃", "🎆", "🎇",
"✨", "💫", "🌟", "⭐", "🏅", "🎖️", "🏆", "🥳", "🎯", "🎪",
],
};
// Get all category keys
const CATEGORY_KEYS = Object.keys(EMOJI_CATEGORIES);
const IconPicker = ({ value, onChange, onClose, isOpen }) => {
const [activeCategory, setActiveCategory] = useState("Files & Folders");
if (!isOpen) return null;
const handleSelect = (emoji) => {
onChange(emoji);
onClose();
};
const handleReset = () => {
onChange("");
onClose();
};
return (
<div className="icon-picker-overlay" onClick={onClose}>
<div className="icon-picker-modal" onClick={(e) => e.stopPropagation()}>
{/* Header */}
<div className="icon-picker-header">
<h3>Choose Icon</h3>
<button className="icon-picker-close" onClick={onClose}>
</button>
</div>
{/* Content */}
<div className="icon-picker-content">
{/* Category Sidebar */}
<div className="icon-picker-sidebar">
{CATEGORY_KEYS.map((category) => (
<button
key={category}
className={`icon-picker-category-btn ${
activeCategory === category ? "active" : ""
}`}
onClick={() => setActiveCategory(category)}
>
{category}
</button>
))}
</div>
{/* Emoji Grid */}
<div className="icon-picker-grid-container">
<h4 className="icon-picker-category-title">{activeCategory}</h4>
<div className="icon-picker-grid">
{EMOJI_CATEGORIES[activeCategory].map((emoji, index) => (
<button
key={`${emoji}-${index}`}
className={`icon-picker-emoji-btn ${
value === emoji ? "selected" : ""
}`}
onClick={() => handleSelect(emoji)}
title={emoji}
>
{emoji}
</button>
))}
</div>
</div>
</div>
{/* Footer */}
<div className="icon-picker-footer">
<button className="icon-picker-reset-btn" onClick={handleReset}>
📁 Reset to Default
</button>
<button className="icon-picker-cancel-btn" onClick={onClose}>
Cancel
</button>
</div>
</div>
</div>
);
};
export default IconPicker;

View file

@ -0,0 +1,49 @@
.navigation {
width: 250px;
background-color: #2c3e50;
color: white;
height: 100vh;
padding: 20px;
position: fixed;
left: 0;
top: 0;
}
.nav-header {
margin-bottom: 30px;
padding-bottom: 20px;
border-bottom: 1px solid #34495e;
}
.nav-header h2 {
margin: 0;
font-size: 24px;
}
.nav-menu {
list-style: none;
padding: 0;
margin: 0;
}
.nav-menu li {
margin-bottom: 10px;
}
.nav-menu li a {
color: #ecf0f1;
text-decoration: none;
display: block;
padding: 12px 15px;
border-radius: 5px;
transition: background-color 0.3s;
}
.nav-menu li a:hover {
background-color: #34495e;
}
.nav-menu li.active a {
background-color: #3498db;
font-weight: bold;
}

View file

@ -0,0 +1,264 @@
import { useState, useEffect, useCallback } from 'react';
import { Link, useLocation, useNavigate } from 'react-router-dom';
import './Navigation.css';
import { LogoutWithOptions, GetLocalDataSize } from '../../wailsjs/go/app/Application';
function Navigation() {
const location = useLocation();
const navigate = useNavigate();
const [showLogoutConfirm, setShowLogoutConfirm] = useState(false);
const [isLoggingOut, setIsLoggingOut] = useState(false);
const [deleteLocalData, setDeleteLocalData] = useState(true); // Default to delete for security
const [localDataSize, setLocalDataSize] = useState(0);
const isActive = (path) => {
return location.pathname === path || location.pathname.startsWith(path + '/');
};
// Format bytes to human-readable size
const formatBytes = (bytes) => {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
};
const handleLogoutClick = (e) => {
e.preventDefault();
// Get local data size when opening the modal
GetLocalDataSize()
.then((size) => {
setLocalDataSize(size);
})
.catch((error) => {
console.error('Failed to get local data size:', error);
setLocalDataSize(0);
});
setShowLogoutConfirm(true);
};
const handleLogoutConfirm = () => {
setIsLoggingOut(true);
LogoutWithOptions(deleteLocalData)
.then(() => {
// Reset state before navigating
setDeleteLocalData(true);
navigate('/login');
})
.catch((error) => {
console.error('Logout failed:', error);
alert('Logout failed: ' + error.message);
setIsLoggingOut(false);
setShowLogoutConfirm(false);
});
};
const handleLogoutCancel = useCallback(() => {
if (!isLoggingOut) {
setShowLogoutConfirm(false);
setDeleteLocalData(true); // Reset to default
}
}, [isLoggingOut]);
// Handle Escape key to close modal
useEffect(() => {
const handleKeyDown = (e) => {
if (e.key === 'Escape' && showLogoutConfirm && !isLoggingOut) {
handleLogoutCancel();
}
};
if (showLogoutConfirm) {
document.addEventListener('keydown', handleKeyDown);
}
return () => {
document.removeEventListener('keydown', handleKeyDown);
};
}, [showLogoutConfirm, isLoggingOut, handleLogoutCancel]);
return (
<>
<nav className="navigation">
<div className="nav-header">
<h2>MapleFile</h2>
</div>
<ul className="nav-menu">
<li className={isActive('/dashboard') ? 'active' : ''}>
<Link to="/dashboard">Dashboard</Link>
</li>
<li className={isActive('/file-manager') ? 'active' : ''}>
<Link to="/file-manager">File Manager</Link>
</li>
<li className={isActive('/search') ? 'active' : ''}>
<Link to="/search">Search</Link>
</li>
<li className={isActive('/tags/search') ? 'active' : ''}>
<Link to="/tags/search">Search by Tags</Link>
</li>
<li className={isActive('/me') ? 'active' : ''}>
<Link to="/me">Profile</Link>
</li>
<li>
<a href="#" onClick={handleLogoutClick}>Logout</a>
</li>
</ul>
</nav>
{/* Logout Confirmation Modal */}
{showLogoutConfirm && (
<div
onClick={handleLogoutCancel}
style={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: 'rgba(0, 0, 0, 0.5)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 1000,
}}
>
<div
onClick={(e) => e.stopPropagation()}
style={{
backgroundColor: 'white',
borderRadius: '8px',
padding: '30px',
maxWidth: '500px',
width: '90%',
boxShadow: '0 4px 20px rgba(0, 0, 0, 0.3)',
}}>
<h3 style={{ margin: '0 0 15px 0', color: '#2c3e50', textAlign: 'center' }}>
Sign Out
</h3>
<p style={{ margin: '0 0 20px 0', color: '#666', textAlign: 'center' }}>
You are about to sign out. You'll need to log in again next time.
</p>
{/* Security Warning */}
<div style={{
backgroundColor: '#fef3c7',
border: '1px solid #f59e0b',
borderRadius: '8px',
padding: '16px',
marginBottom: '20px',
}}>
<div style={{ display: 'flex', alignItems: 'flex-start', gap: '12px' }}>
<svg style={{ width: '20px', height: '20px', color: '#d97706', flexShrink: 0, marginTop: '2px' }} fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
<div>
<p style={{ fontWeight: '600', color: '#d97706', margin: '0 0 4px 0', fontSize: '14px' }}>
Security Notice
</p>
<p style={{ color: '#666', margin: 0, fontSize: '13px', lineHeight: '1.5' }}>
For your security, we recommend deleting locally saved data when signing out. This includes your cached files and metadata{localDataSize > 0 ? ` (${formatBytes(localDataSize)})` : ''}. If you keep local data, anyone with access to this device may be able to view your files when you log in again.
</p>
</div>
</div>
</div>
{/* Data deletion options */}
<div style={{ marginBottom: '25px' }}>
<label style={{
display: 'flex',
alignItems: 'flex-start',
gap: '12px',
cursor: 'pointer',
marginBottom: '12px',
padding: '10px',
borderRadius: '6px',
backgroundColor: deleteLocalData ? '#fef2f2' : 'transparent',
border: deleteLocalData ? '1px solid #fca5a5' : '1px solid transparent',
}}>
<input
type="radio"
name="deleteLocalData"
checked={deleteLocalData === true}
onChange={() => setDeleteLocalData(true)}
style={{ marginTop: '4px' }}
/>
<div>
<span style={{ fontWeight: '500', color: '#2c3e50', fontSize: '14px' }}>
Delete all local data (Recommended)
</span>
<p style={{ color: '#666', margin: '4px 0 0 0', fontSize: '12px' }}>
All cached files and metadata will be removed. You'll need to re-download files from the cloud next time.
</p>
</div>
</label>
<label style={{
display: 'flex',
alignItems: 'flex-start',
gap: '12px',
cursor: 'pointer',
padding: '10px',
borderRadius: '6px',
backgroundColor: !deleteLocalData ? '#eff6ff' : 'transparent',
border: !deleteLocalData ? '1px solid #93c5fd' : '1px solid transparent',
}}>
<input
type="radio"
name="deleteLocalData"
checked={deleteLocalData === false}
onChange={() => setDeleteLocalData(false)}
style={{ marginTop: '4px' }}
/>
<div>
<span style={{ fontWeight: '500', color: '#2c3e50', fontSize: '14px' }}>
Keep local data for faster login
</span>
<p style={{ color: '#666', margin: '4px 0 0 0', fontSize: '12px' }}>
Your cached files will be preserved. Only use this on trusted personal devices.
</p>
</div>
</label>
</div>
<div style={{ display: 'flex', gap: '10px', justifyContent: 'flex-end' }}>
<button
onClick={handleLogoutCancel}
disabled={isLoggingOut}
style={{
padding: '10px 25px',
border: '1px solid #ddd',
borderRadius: '5px',
backgroundColor: '#f5f5f5',
color: '#333',
cursor: isLoggingOut ? 'not-allowed' : 'pointer',
fontSize: '14px',
}}
>
Cancel
</button>
<button
onClick={handleLogoutConfirm}
disabled={isLoggingOut}
style={{
padding: '10px 25px',
border: 'none',
borderRadius: '5px',
backgroundColor: '#3b82f6',
color: 'white',
cursor: isLoggingOut ? 'not-allowed' : 'pointer',
fontSize: '14px',
opacity: isLoggingOut ? 0.7 : 1,
}}
>
{isLoggingOut ? 'Signing out...' : 'Sign Out'}
</button>
</div>
</div>
</div>
)}
</>
);
}
export default Navigation;

View file

@ -0,0 +1,106 @@
.page {
padding: 30px;
}
.page-header {
margin-bottom: 30px;
display: flex;
align-items: center;
gap: 15px;
}
.page-header h1 {
margin: 0;
font-size: 28px;
color: #2c3e50;
}
.back-button {
padding: 8px 16px;
background-color: #95a5a6;
color: white;
border: none;
border-radius: 5px;
cursor: pointer;
font-size: 14px;
transition: background-color 0.3s;
}
.back-button:hover {
background-color: #7f8c8d;
}
.page-content {
background-color: white;
padding: 20px;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
color: #333;
}
.page-content p {
color: #333;
}
.page-content label {
color: #333;
}
.page-content input[type="text"],
.page-content input[type="email"],
.page-content input[type="password"],
.page-content input[type="tel"],
.page-content textarea,
.page-content select {
color: #333;
background-color: #fff;
border: 1px solid #ccc;
}
.nav-buttons {
display: flex;
flex-wrap: wrap;
gap: 15px;
margin-top: 20px;
}
.nav-button {
padding: 12px 24px;
background-color: #3498db;
color: white;
border: none;
border-radius: 5px;
cursor: pointer;
font-size: 16px;
transition: background-color 0.3s;
text-decoration: none;
display: inline-block;
}
.nav-button:hover {
background-color: #2980b9;
}
.nav-button.secondary {
background-color: #95a5a6;
}
.nav-button.secondary:hover {
background-color: #7f8c8d;
}
.nav-button.success {
background-color: #27ae60;
}
.nav-button.success:hover {
background-color: #229954;
}
.nav-button.danger {
background-color: #e74c3c;
}
.nav-button.danger:hover {
background-color: #c0392b;
}

View file

@ -0,0 +1,24 @@
import { useNavigate } from 'react-router-dom';
import './Page.css';
function Page({ title, children, showBackButton = false }) {
const navigate = useNavigate();
return (
<div className="page">
<div className="page-header">
{showBackButton && (
<button onClick={() => navigate(-1)} className="back-button">
Back
</button>
)}
<h1>{title}</h1>
</div>
<div className="page-content">
{children}
</div>
</div>
);
}
export default Page;

View file

@ -0,0 +1,180 @@
import { useState } from 'react';
import { Logout, StorePasswordForSession, VerifyPassword } from '../../wailsjs/go/app/Application';
function PasswordPrompt({ email, onPasswordVerified }) {
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const handleSubmit = async (e) => {
e.preventDefault();
setError('');
setLoading(true);
if (!password) {
setError('Please enter your password');
setLoading(false);
return;
}
try {
// Verify password against stored encrypted data
const isValid = await VerifyPassword(password);
if (!isValid) {
setError('Incorrect password. Please try again.');
setLoading(false);
return;
}
// Store password in RAM
await StorePasswordForSession(password);
// Notify parent component
await onPasswordVerified(password);
// Success - parent will handle navigation
} catch (err) {
console.error('Password verification error:', err);
setError('Failed to verify password: ' + err.message);
setLoading(false);
}
};
const handleLogout = async () => {
try {
await Logout();
window.location.reload();
} catch (error) {
console.error('Logout failed:', error);
}
};
return (
<div style={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
height: '100vh',
background: '#f8f9fa'
}}>
<div style={{
background: 'white',
padding: '40px',
borderRadius: '10px',
boxShadow: '0 2px 10px rgba(0,0,0,0.1)',
maxWidth: '450px',
width: '100%'
}}>
<h2 style={{ marginTop: 0 }}>Welcome Back</h2>
<p style={{ color: '#666', marginBottom: '30px' }}>
Enter your password to unlock your encrypted files
</p>
<form onSubmit={handleSubmit}>
<div style={{ marginBottom: '20px' }}>
<label style={{ display: 'block', marginBottom: '5px' }}>Email</label>
<input
type="text"
value={email}
disabled
style={{
width: '100%',
padding: '10px',
background: '#f5f5f5',
border: '1px solid #ddd',
borderRadius: '5px'
}}
/>
</div>
<div style={{ marginBottom: '20px' }}>
<label htmlFor="password" style={{ display: 'block', marginBottom: '5px' }}>
Password
</label>
<input
type="password"
id="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Enter your password"
autoFocus
disabled={loading}
style={{
width: '100%',
padding: '10px',
border: '1px solid #ddd',
borderRadius: '5px'
}}
/>
<small style={{ color: '#666', fontSize: '0.9em' }}>
Your password will be kept in memory until you close the app.
</small>
</div>
{error && (
<div style={{
padding: '10px',
marginBottom: '15px',
background: '#f8d7da',
border: '1px solid #f5c6cb',
borderRadius: '5px',
color: '#721c24'
}}>
{error}
</div>
)}
<div style={{ display: 'flex', gap: '10px' }}>
<button
type="submit"
disabled={loading}
style={{
flex: 1,
padding: '12px',
background: loading ? '#6c757d' : '#007bff',
color: 'white',
border: 'none',
borderRadius: '5px',
cursor: loading ? 'not-allowed' : 'pointer',
fontSize: '16px'
}}
>
{loading ? 'Verifying...' : 'Unlock'}
</button>
<button
type="button"
onClick={handleLogout}
disabled={loading}
style={{
padding: '12px 20px',
background: 'white',
color: '#dc3545',
border: '1px solid #dc3545',
borderRadius: '5px',
cursor: loading ? 'not-allowed' : 'pointer'
}}
>
Logout
</button>
</div>
</form>
<div style={{
marginTop: '20px',
padding: '10px',
background: '#d1ecf1',
border: '1px solid #bee5eb',
borderRadius: '5px',
fontSize: '0.9em',
color: '#0c5460'
}}>
<strong>🔒 Security:</strong> Your password is stored securely in memory
and will be automatically cleared when you close the app.
</div>
</div>
</div>
);
}
export default PasswordPrompt;

View file

@ -0,0 +1,15 @@
// File Path: monorepo/native/desktop/maplefile/frontend/src/main.jsx.jsx
import React from "react";
import { createRoot } from "react-dom/client";
import "./style.css";
import App from "./App";
const container = document.getElementById("root");
const root = createRoot(container);
root.render(
<React.StrictMode>
<App />
</React.StrictMode>,
);

View file

@ -0,0 +1,21 @@
// File Path: monorepo/native/desktop/maplefile/frontend/src/pages/Anonymous/Index/IndexPage.jsx
import { Link } from "react-router-dom";
import Page from "../../../components/Page";
function IndexPage() {
return (
<Page title="Welcome to MapleFile">
<p>Secure, encrypted file storage for your important files.</p>
<div className="nav-buttons">
<Link to="/login" className="nav-button">
Login
</Link>
<Link to="/register" className="nav-button success">
Register
</Link>
</div>
</Page>
);
}
export default IndexPage;

View file

@ -0,0 +1,231 @@
// File Path: monorepo/native/desktop/maplefile/frontend/src/pages/Anonymous/Login/CompleteLogin.jsx
import { useState, useEffect } from "react";
import { Link, useNavigate } from "react-router-dom";
import Page from "../../../components/Page";
import {
DecryptLoginChallenge,
CompleteLogin as CompleteLoginAPI,
} from "../../../../wailsjs/go/app/Application";
function CompleteLogin() {
const navigate = useNavigate();
const [password, setPassword] = useState("");
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
const [message, setMessage] = useState("");
const [challengeData, setChallengeData] = useState(null);
useEffect(() => {
// Get challenge data from sessionStorage
const storedData = sessionStorage.getItem("loginChallenge");
if (!storedData) {
setError("Missing login challenge data. Please start login again.");
return;
}
try {
const data = JSON.parse(storedData);
setChallengeData(data);
} catch (err) {
setError("Invalid challenge data. Please start login again.");
}
}, []);
const handleSubmit = async (e) => {
e.preventDefault();
setError("");
setMessage("");
setLoading(true);
if (!challengeData) {
setError("Missing challenge data. Please return to login.");
setLoading(false);
return;
}
if (!password) {
setError("Please enter your password.");
setLoading(false);
return;
}
try {
// Step 1: Decrypt the login challenge using E2EE
setMessage("Decrypting challenge with E2EE...");
const decryptedChallenge = await DecryptLoginChallenge(
password,
challengeData.salt,
challengeData.encryptedMasterKey,
challengeData.encryptedChallenge,
challengeData.encryptedPrivateKey,
challengeData.publicKey,
// Pass KDF algorithm
challengeData.kdfAlgorithm || "PBKDF2-SHA256",
);
// Step 2: Complete login with the decrypted challenge
setMessage("Completing login...");
const loginInput = {
email: challengeData.email,
challengeId: challengeData.challengeId,
decryptedData: decryptedChallenge,
password: password, // Pass password to backend for storage
// Pass encrypted data for future password verification
salt: challengeData.salt,
encryptedMasterKey: challengeData.encryptedMasterKey,
encryptedPrivateKey: challengeData.encryptedPrivateKey,
publicKey: challengeData.publicKey,
// Pass KDF algorithm for master key caching
kdfAlgorithm: challengeData.kdfAlgorithm || "PBKDF2-SHA256",
};
await CompleteLoginAPI(loginInput);
// Clear challenge data from sessionStorage
sessionStorage.removeItem("loginChallenge");
setMessage("Login successful! Redirecting to dashboard...");
// Redirect to dashboard
setTimeout(() => {
navigate("/dashboard");
}, 1000);
} catch (err) {
const errorMessage = err.message || err.toString();
// Check for wrong password error
if (
errorMessage.includes("wrong password") ||
errorMessage.includes("failed to decrypt master key")
) {
setError("Incorrect password. Please try again.");
} else {
// Try to parse RFC 9457 format
try {
const jsonMatch = errorMessage.match(/\{[\s\S]*\}/);
if (jsonMatch) {
const problemDetails = JSON.parse(jsonMatch[0]);
setError(
problemDetails.detail || problemDetails.title || errorMessage,
);
} else {
setError("Login failed: " + errorMessage);
}
} catch (parseErr) {
setError("Login failed: " + errorMessage);
}
}
} finally {
setLoading(false);
}
};
if (!challengeData && !error) {
return (
<Page title="Complete Login" showBackButton={true}>
<p style={{ color: "#666" }}>Loading...</p>
</Page>
);
}
return (
<Page title="Complete Login" showBackButton={true}>
<form onSubmit={handleSubmit} style={{ maxWidth: "400px" }}>
<p style={{ marginBottom: "20px", color: "#333" }}>
Enter your password to decrypt your keys and complete the login
process.
</p>
{challengeData && (
<div style={{ marginBottom: "15px" }}>
<label
style={{
display: "block",
marginBottom: "5px",
fontWeight: "bold",
color: "#333",
}}
>
Email
</label>
<input
type="email"
value={challengeData.email}
style={{
width: "100%",
padding: "8px",
backgroundColor: "#f5f5f5",
}}
readOnly
/>
</div>
)}
<div style={{ marginBottom: "15px" }}>
<label
htmlFor="password"
style={{
display: "block",
marginBottom: "5px",
fontWeight: "bold",
color: "#333",
}}
>
Password
</label>
<input
type="password"
id="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Enter your password"
style={{ width: "100%", padding: "8px" }}
autoFocus
minLength={8}
/>
<small style={{ display: "block", marginTop: "5px", color: "#666" }}>
Your password is used to decrypt your encryption keys locally.
</small>
</div>
{error && <p style={{ color: "red", marginBottom: "15px" }}>{error}</p>}
{message && (
<p style={{ color: "green", marginBottom: "15px" }}>{message}</p>
)}
<div className="nav-buttons">
<button
type="submit"
className="nav-button success"
disabled={loading || !challengeData}
>
{loading ? "Logging in..." : "Complete Login"}
</button>
<Link to="/login" className="nav-button secondary">
Start Over
</Link>
<Link to="/recovery" className="nav-button secondary">
Forgot Password?
</Link>
</div>
<div
style={{
marginTop: "20px",
padding: "10px",
background: "#f0f0f0",
borderRadius: "5px",
}}
>
<strong>Security:</strong> Your password never leaves this device.
It's used only to decrypt your keys locally using industry-standard
cryptographic algorithms.
</div>
</form>
</Page>
);
}
export default CompleteLogin;

View file

@ -0,0 +1,114 @@
// File Path: monorepo/native/desktop/maplefile/frontend/src/pages/Anonymous/Login/RequestOTT.jsx
import { useState } from "react";
import { Link, useNavigate } from "react-router-dom";
import Page from "../../../components/Page";
import { RequestOTT as RequestOTTAPI } from "../../../../wailsjs/go/app/Application";
function RequestOTT() {
const navigate = useNavigate();
const [email, setEmail] = useState("");
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
const [message, setMessage] = useState("");
const handleSubmit = async (e) => {
e.preventDefault();
setError("");
setMessage("");
setLoading(true);
if (!email) {
setError("Please enter your email address.");
setLoading(false);
return;
}
// Check if Wails runtime is available
if (!window.go || !window.go.app || !window.go.app.Application) {
setError("Application not ready. Please wait a moment and try again.");
setLoading(false);
return;
}
try {
await RequestOTTAPI(email);
setMessage("One-time token sent! Check your email.");
// Redirect to verify OTT page with email
setTimeout(() => {
navigate(`/login/verify-ott?email=${encodeURIComponent(email)}`);
}, 1000);
} catch (err) {
const errorMessage = err.message || err.toString();
// Try to parse RFC 9457 format
try {
const jsonMatch = errorMessage.match(/\{[\s\S]*\}/);
if (jsonMatch) {
const problemDetails = JSON.parse(jsonMatch[0]);
setError(
problemDetails.detail || problemDetails.title || errorMessage,
);
} else {
setError("Failed to send OTT: " + errorMessage);
}
} catch (parseErr) {
setError("Failed to send OTT: " + errorMessage);
}
} finally {
setLoading(false);
}
};
return (
<Page title="Login" showBackButton={true}>
<form onSubmit={handleSubmit} style={{ maxWidth: "400px" }}>
<p style={{ marginBottom: "20px", color: "#333" }}>
Enter your email to receive a one-time token.
</p>
<div style={{ marginBottom: "15px" }}>
<label
htmlFor="email"
style={{
display: "block",
marginBottom: "5px",
fontWeight: "bold",
color: "#333",
}}
>
Email
</label>
<input
type="email"
id="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="your@email.com"
style={{ width: "100%", padding: "8px" }}
autoFocus
/>
</div>
{error && <p style={{ color: "red", marginBottom: "15px" }}>{error}</p>}
{message && (
<p style={{ color: "green", marginBottom: "15px" }}>{message}</p>
)}
<div className="nav-buttons">
<button type="submit" className="nav-button" disabled={loading}>
{loading ? "Sending..." : "Send One-Time Token"}
</button>
<Link to="/register" className="nav-button secondary">
Need an account?
</Link>
<Link to="/recovery" className="nav-button secondary">
Forgot password?
</Link>
</div>
</form>
</Page>
);
}
export default RequestOTT;

View file

@ -0,0 +1,18 @@
// File Path: monorepo/native/desktop/maplefile/frontend/src/pages/Anonymous/Login/SessionExpired.jsx
import { Link } from "react-router-dom";
import Page from "../../../components/Page";
function SessionExpired() {
return (
<Page title="Session Expired">
<p>Your session has expired. Please login again.</p>
<div className="nav-buttons">
<Link to="/login" className="nav-button">
Login Again
</Link>
</div>
</Page>
);
}
export default SessionExpired;

View file

@ -0,0 +1,177 @@
// File Path: monorepo/native/desktop/maplefile/frontend/src/pages/Anonymous/Login/VerifyOTT.jsx
import { useState, useEffect } from "react";
import { Link, useSearchParams, useNavigate } from "react-router-dom";
import Page from "../../../components/Page";
import { VerifyOTT as VerifyOTTAPI } from "../../../../wailsjs/go/app/Application";
function VerifyOTT() {
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const [email, setEmail] = useState("");
const [ott, setOtt] = useState("");
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
const [message, setMessage] = useState("");
useEffect(() => {
// Get email from URL query parameter
const emailParam = searchParams.get("email");
if (emailParam) {
setEmail(emailParam);
}
}, [searchParams]);
const handleSubmit = async (e) => {
e.preventDefault();
setError("");
setMessage("");
setLoading(true);
if (!email) {
setError("Email is missing. Please return to login.");
setLoading(false);
return;
}
if (!ott) {
setError("Please enter the one-time token.");
setLoading(false);
return;
}
try {
// Verify OTT - this returns the encrypted challenge and user keys
const response = await VerifyOTTAPI(email, ott);
setMessage("Token verified! Redirecting...");
// Store the challenge data in sessionStorage to pass to CompleteLogin
sessionStorage.setItem(
"loginChallenge",
JSON.stringify({
email: email,
challengeId: response.challengeId,
encryptedChallenge: response.encryptedChallenge,
salt: response.salt,
encryptedMasterKey: response.encryptedMasterKey,
encryptedPrivateKey: response.encryptedPrivateKey,
publicKey: response.publicKey,
// Include KDF algorithm
kdfAlgorithm: response.kdfAlgorithm || "PBKDF2-SHA256",
}),
);
// Redirect to complete login page
setTimeout(() => {
navigate("/login/complete");
}, 1000);
} catch (err) {
const errorMessage = err.message || err.toString();
// Try to parse RFC 9457 format
try {
const jsonMatch = errorMessage.match(/\{[\s\S]*\}/);
if (jsonMatch) {
const problemDetails = JSON.parse(jsonMatch[0]);
setError(
problemDetails.detail || problemDetails.title || errorMessage,
);
} else {
setError("Verification failed: " + errorMessage);
}
} catch (parseErr) {
setError("Verification failed: " + errorMessage);
}
} finally {
setLoading(false);
}
};
return (
<Page title="Verify Token" showBackButton={true}>
<form onSubmit={handleSubmit} style={{ maxWidth: "400px" }}>
<p style={{ marginBottom: "20px", color: "#333" }}>
Enter the one-time token sent to <strong>{email}</strong>.
</p>
<div style={{ marginBottom: "15px" }}>
<label
htmlFor="email"
style={{
display: "block",
marginBottom: "5px",
fontWeight: "bold",
color: "#333",
}}
>
Email
</label>
<input
type="email"
id="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
style={{
width: "100%",
padding: "8px",
backgroundColor: "#f5f5f5",
}}
readOnly
/>
</div>
<div style={{ marginBottom: "15px" }}>
<label
htmlFor="ott"
style={{
display: "block",
marginBottom: "5px",
fontWeight: "bold",
color: "#333",
}}
>
One-Time Token
</label>
<input
type="text"
id="ott"
value={ott}
onChange={(e) => setOtt(e.target.value)}
placeholder="Enter token from email"
style={{ width: "100%", padding: "8px" }}
autoFocus
/>
</div>
{error && <p style={{ color: "red", marginBottom: "15px" }}>{error}</p>}
{message && (
<p style={{ color: "green", marginBottom: "15px" }}>{message}</p>
)}
<div className="nav-buttons">
<button type="submit" className="nav-button" disabled={loading}>
{loading ? "Verifying..." : "Verify Token"}
</button>
<Link to="/login" className="nav-button secondary">
Back to Login
</Link>
</div>
<div
style={{
marginTop: "20px",
padding: "10px",
background: "#f0f0f0",
borderRadius: "5px",
}}
>
<p style={{ margin: 0, fontSize: "14px", color: "#666" }}>
<strong>Didn't receive the token?</strong> Check your spam folder or
return to login to request a new one.
</p>
</div>
</form>
</Page>
);
}
export default VerifyOTT;

View file

@ -0,0 +1,476 @@
// File Path: monorepo/native/desktop/maplefile/frontend/src/pages/Anonymous/Recovery/CompleteRecovery.jsx
import { useState, useEffect, useMemo } from "react";
import { Link, useNavigate } from "react-router-dom";
import Page from "../../../components/Page";
import { CompleteRecovery as CompleteRecoveryAPI } from "../../../../wailsjs/go/app/Application";
function CompleteRecovery() {
const navigate = useNavigate();
const [newPassword, setNewPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [recoveryPhrase, setRecoveryPhrase] = useState("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const [email, setEmail] = useState("");
const [recoveryToken, setRecoveryToken] = useState("");
const [showNewPassword, setShowNewPassword] = useState(false);
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
useEffect(() => {
// Get recovery session data from sessionStorage
const storedEmail = sessionStorage.getItem("recoveryEmail");
const storedToken = sessionStorage.getItem("recoveryToken");
const canReset = sessionStorage.getItem("canResetCredentials");
if (!storedEmail || !storedToken || canReset !== "true") {
console.log("[CompleteRecovery] No verified recovery session, redirecting");
navigate("/recovery/initiate");
return;
}
setEmail(storedEmail);
setRecoveryToken(storedToken);
}, [navigate]);
// Count words in recovery phrase
const wordCount = useMemo(() => {
if (!recoveryPhrase.trim()) return 0;
return recoveryPhrase.trim().split(/\s+/).length;
}, [recoveryPhrase]);
// Check if passwords match
const passwordsMatch = newPassword && confirmPassword && newPassword === confirmPassword;
const handleSubmit = async (e) => {
e.preventDefault();
setError("");
setLoading(true);
try {
// Validate passwords
if (!newPassword) {
throw new Error("Password is required");
}
if (newPassword.length < 8) {
throw new Error("Password must be at least 8 characters long");
}
if (newPassword !== confirmPassword) {
throw new Error("Passwords do not match");
}
// Validate recovery phrase
const words = recoveryPhrase.trim().toLowerCase().split(/\s+/);
if (words.length !== 12) {
throw new Error("Recovery phrase must be exactly 12 words");
}
// Normalize recovery phrase
const normalizedPhrase = words.join(" ");
console.log("[CompleteRecovery] Completing recovery with new password");
// Call backend to complete recovery
const response = await CompleteRecoveryAPI({
recoveryToken: recoveryToken,
recoveryMnemonic: normalizedPhrase,
newPassword: newPassword,
});
console.log("[CompleteRecovery] Recovery completed successfully");
// Clear all recovery session data
sessionStorage.removeItem("recoveryEmail");
sessionStorage.removeItem("recoverySessionId");
sessionStorage.removeItem("recoveryEncryptedChallenge");
sessionStorage.removeItem("recoveryToken");
sessionStorage.removeItem("canResetCredentials");
// Clear sensitive data from state
setRecoveryPhrase("");
setNewPassword("");
setConfirmPassword("");
// Show success and redirect to login
alert("Account recovery completed successfully! You can now log in with your new password.");
navigate("/login");
} catch (err) {
console.error("[CompleteRecovery] Recovery completion failed:", err);
setError(err.message || "Failed to complete recovery");
} finally {
setLoading(false);
}
};
const handleBackToVerify = () => {
// Clear token but keep session
sessionStorage.removeItem("recoveryToken");
sessionStorage.removeItem("canResetCredentials");
setRecoveryPhrase("");
navigate("/recovery/verify");
};
if (!email) {
return (
<Page title="Complete Recovery" showBackButton={true}>
<p>Loading recovery session...</p>
</Page>
);
}
return (
<Page title="Set Your New Password" showBackButton={false}>
<div style={{ maxWidth: "600px" }}>
{/* Progress Indicator */}
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
marginBottom: "20px",
gap: "10px",
}}
>
<div
style={{
width: "30px",
height: "30px",
borderRadius: "50%",
background: "#27ae60",
color: "white",
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: "14px",
fontWeight: "bold",
}}
>
</div>
<span style={{ color: "#27ae60", fontWeight: "bold" }}>Email</span>
<div style={{ width: "30px", height: "2px", background: "#27ae60" }} />
<div
style={{
width: "30px",
height: "30px",
borderRadius: "50%",
background: "#27ae60",
color: "white",
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: "14px",
fontWeight: "bold",
}}
>
</div>
<span style={{ color: "#27ae60", fontWeight: "bold" }}>Verify</span>
<div style={{ width: "30px", height: "2px", background: "#27ae60" }} />
<div
style={{
width: "30px",
height: "30px",
borderRadius: "50%",
background: "#3498db",
color: "white",
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: "14px",
fontWeight: "bold",
}}
>
3
</div>
<span style={{ color: "#333", fontWeight: "bold" }}>Reset</span>
</div>
<p style={{ marginBottom: "20px", color: "#333" }}>
Final step: Create a new password for <strong>{email}</strong>
</p>
{/* Error Message */}
{error && (
<div
style={{
background: "#f8d7da",
border: "1px solid #f5c6cb",
borderRadius: "8px",
padding: "15px",
marginBottom: "20px",
color: "#721c24",
}}
>
<strong>Error:</strong> {error}
</div>
)}
{/* Security Notice */}
<div
style={{
background: "#cce5ff",
border: "1px solid #b8daff",
borderRadius: "8px",
padding: "15px",
marginBottom: "20px",
}}
>
<strong style={{ color: "#004085" }}>Why enter your recovery phrase again?</strong>
<p style={{ margin: "10px 0 0", color: "#004085", fontSize: "14px" }}>
We need your recovery phrase to decrypt your master key and re-encrypt
it with your new password. This ensures continuous access to your
encrypted files.
</p>
</div>
<form onSubmit={handleSubmit}>
{/* Recovery Phrase */}
<div style={{ marginBottom: "20px" }}>
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
marginBottom: "5px",
}}
>
<label
htmlFor="recoveryPhrase"
style={{ fontWeight: "bold", color: "#333" }}
>
Recovery Phrase (Required Again)
</label>
<span
style={{
fontSize: "12px",
color:
wordCount === 12
? "#27ae60"
: wordCount > 0
? "#f39c12"
: "#666",
fontWeight: "bold",
}}
>
{wordCount}/12 words
</span>
</div>
<textarea
id="recoveryPhrase"
value={recoveryPhrase}
onChange={(e) => setRecoveryPhrase(e.target.value)}
placeholder="Re-enter your 12-word recovery phrase"
rows={3}
required
disabled={loading}
style={{
width: "100%",
padding: "12px",
fontFamily: "monospace",
fontSize: "14px",
lineHeight: "1.6",
borderRadius: "8px",
border:
wordCount === 12 ? "2px solid #27ae60" : "1px solid #ccc",
background: wordCount === 12 ? "#f0fff0" : "#fff",
resize: "none",
color: "#333",
}}
/>
</div>
{/* New Password Section */}
<div
style={{
background: "#f0fff0",
border: "1px solid #27ae60",
borderRadius: "8px",
padding: "15px",
marginBottom: "20px",
}}
>
<h3 style={{ margin: "0 0 15px 0", color: "#27ae60", fontSize: "16px" }}>
Create Your New Password
</h3>
{/* New Password */}
<div style={{ marginBottom: "15px" }}>
<label
htmlFor="newPassword"
style={{
display: "block",
marginBottom: "5px",
fontWeight: "bold",
color: "#333",
}}
>
New Password
</label>
<div style={{ position: "relative" }}>
<input
type={showNewPassword ? "text" : "password"}
id="newPassword"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
placeholder="Enter your new password"
required
disabled={loading}
minLength={8}
style={{
width: "100%",
padding: "8px",
paddingRight: "40px",
borderRadius: "4px",
border: "1px solid #ccc",
color: "#333",
}}
/>
<button
type="button"
onClick={() => setShowNewPassword(!showNewPassword)}
style={{
position: "absolute",
right: "8px",
top: "50%",
transform: "translateY(-50%)",
background: "none",
border: "none",
cursor: "pointer",
color: "#666",
fontSize: "12px",
}}
>
{showNewPassword ? "Hide" : "Show"}
</button>
</div>
<small style={{ display: "block", marginTop: "5px", color: "#666" }}>
Password must be at least 8 characters long
</small>
</div>
{/* Confirm Password */}
<div>
<label
htmlFor="confirmPassword"
style={{
display: "block",
marginBottom: "5px",
fontWeight: "bold",
color: "#333",
}}
>
Confirm New Password
</label>
<div style={{ position: "relative" }}>
<input
type={showConfirmPassword ? "text" : "password"}
id="confirmPassword"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
placeholder="Confirm your new password"
required
disabled={loading}
style={{
width: "100%",
padding: "8px",
paddingRight: "40px",
borderRadius: "4px",
border: passwordsMatch ? "2px solid #27ae60" : "1px solid #ccc",
background: passwordsMatch ? "#f0fff0" : "#fff",
color: "#333",
}}
/>
<button
type="button"
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
style={{
position: "absolute",
right: "8px",
top: "50%",
transform: "translateY(-50%)",
background: "none",
border: "none",
cursor: "pointer",
color: "#666",
fontSize: "12px",
}}
>
{showConfirmPassword ? "Hide" : "Show"}
</button>
</div>
{passwordsMatch && (
<small style={{ display: "block", marginTop: "5px", color: "#27ae60" }}>
Passwords match
</small>
)}
</div>
</div>
<div className="nav-buttons">
<button
type="submit"
className="nav-button success"
disabled={loading || wordCount !== 12 || !newPassword || !passwordsMatch}
style={{
opacity: wordCount === 12 && passwordsMatch ? 1 : 0.5,
cursor: wordCount === 12 && passwordsMatch && !loading ? "pointer" : "not-allowed",
}}
>
{loading ? "Setting New Password..." : "Complete Recovery"}
</button>
<button
type="button"
onClick={handleBackToVerify}
className="nav-button secondary"
disabled={loading}
>
Back to Verification
</button>
</div>
</form>
{/* What Happens Next */}
<div
style={{
marginTop: "20px",
padding: "15px",
background: "#e8f4fd",
borderRadius: "8px",
border: "1px solid #b8daff",
}}
>
<strong style={{ color: "#004085" }}>What Happens Next?</strong>
<ul style={{ margin: "10px 0 0", paddingLeft: "20px", color: "#004085", fontSize: "14px" }}>
<li>Your master key will be decrypted using your recovery key</li>
<li>New encryption keys will be generated</li>
<li>All keys will be re-encrypted with your new password</li>
<li>Your recovery phrase remains the same for future use</li>
<li>You'll be able to log in immediately with your new password</li>
</ul>
</div>
{/* Security Notes */}
<div
style={{
marginTop: "15px",
padding: "10px",
background: "#f0f0f0",
borderRadius: "5px",
}}
>
<strong>Security Notes:</strong>
<ul style={{ margin: "10px 0 0", paddingLeft: "20px", color: "#333", fontSize: "14px" }}>
<li>Choose a strong, unique password</li>
<li>Your new password will be used to encrypt your keys</li>
<li>Keep your recovery phrase safe - it hasn't changed</li>
<li>All your encrypted data remains accessible</li>
</ul>
</div>
</div>
</Page>
);
}
export default CompleteRecovery;

View file

@ -0,0 +1,138 @@
// File Path: monorepo/native/desktop/maplefile/frontend/src/pages/Anonymous/Recovery/InitiateRecovery.jsx
import { useState } from "react";
import { Link, useNavigate } from "react-router-dom";
import Page from "../../../components/Page";
import { InitiateRecovery as InitiateRecoveryAPI } from "../../../../wailsjs/go/app/Application";
function InitiateRecovery() {
const navigate = useNavigate();
const [email, setEmail] = useState("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const handleSubmit = async (e) => {
e.preventDefault();
setError("");
setLoading(true);
try {
// Validate email
if (!email) {
throw new Error("Email address is required");
}
if (!email.includes("@")) {
throw new Error("Please enter a valid email address");
}
console.log("[InitiateRecovery] Starting recovery process for:", email);
// Call backend to initiate recovery
const response = await InitiateRecoveryAPI(email);
console.log("[InitiateRecovery] Recovery initiated successfully");
console.log("[InitiateRecovery] session_id:", response.sessionId);
// Check if session was actually created
if (!response.sessionId || !response.encryptedChallenge) {
// User not found - show generic message for security
setError(
"Unable to initiate recovery. Please ensure you entered the correct email address associated with your account."
);
return;
}
// Store recovery session data in sessionStorage for the verify step
sessionStorage.setItem("recoveryEmail", email);
sessionStorage.setItem("recoverySessionId", response.sessionId);
sessionStorage.setItem("recoveryEncryptedChallenge", response.encryptedChallenge);
// Navigate to verification step
navigate("/recovery/verify");
} catch (err) {
console.error("[InitiateRecovery] Recovery initiation failed:", err);
setError(err.message || "Failed to initiate recovery");
} finally {
setLoading(false);
}
};
return (
<Page title="Account Recovery" showBackButton={true}>
<div style={{ maxWidth: "500px" }}>
{/* Warning Banner */}
<div
style={{
background: "#fff3cd",
border: "1px solid #ffc107",
borderRadius: "8px",
padding: "15px",
marginBottom: "20px",
}}
>
<strong style={{ color: "#856404" }}>Before You Begin</strong>
<p style={{ margin: "10px 0 0", color: "#856404", fontSize: "14px" }}>
You'll need your <strong>12-word recovery phrase</strong> to complete
this process. Make sure you have it ready before proceeding.
</p>
</div>
<form onSubmit={handleSubmit}>
<div style={{ marginBottom: "15px" }}>
<label
htmlFor="email"
style={{
display: "block",
marginBottom: "5px",
fontWeight: "bold",
color: "#333",
}}
>
Email Address
</label>
<input
type="email"
id="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="Enter your email address"
required
disabled={loading}
style={{ width: "100%", padding: "8px" }}
/>
<small style={{ display: "block", marginTop: "5px", color: "#666" }}>
We'll use this to verify your identity and guide you through recovery
</small>
</div>
{error && <p style={{ color: "red", marginBottom: "15px" }}>{error}</p>}
<div className="nav-buttons">
<button type="submit" className="nav-button" disabled={loading}>
{loading ? "Starting Recovery..." : "Start Account Recovery"}
</button>
<Link to="/login" className="nav-button secondary">
Back to Login
</Link>
</div>
</form>
{/* Security Note */}
<div
style={{
marginTop: "20px",
padding: "10px",
background: "#f0f0f0",
borderRadius: "5px",
}}
>
<strong>Security Note:</strong> Your recovery phrase is the only way to
recover your account if you forget your password. It is never stored on
our servers.
</div>
</div>
</Page>
);
}
export default InitiateRecovery;

View file

@ -0,0 +1,366 @@
// File Path: monorepo/native/desktop/maplefile/frontend/src/pages/Anonymous/Recovery/VerifyRecovery.jsx
import { useState, useEffect, useMemo } from "react";
import { Link, useNavigate } from "react-router-dom";
import Page from "../../../components/Page";
import {
DecryptRecoveryChallenge,
VerifyRecovery as VerifyRecoveryAPI,
} from "../../../../wailsjs/go/app/Application";
function VerifyRecovery() {
const navigate = useNavigate();
const [recoveryPhrase, setRecoveryPhrase] = useState("");
const [loading, setLoading] = useState(false);
const [decrypting, setDecrypting] = useState(false);
const [error, setError] = useState("");
const [email, setEmail] = useState("");
const [sessionId, setSessionId] = useState("");
const [encryptedChallenge, setEncryptedChallenge] = useState("");
const [copied, setCopied] = useState(false);
useEffect(() => {
// Get recovery session data from sessionStorage
const storedEmail = sessionStorage.getItem("recoveryEmail");
const storedSessionId = sessionStorage.getItem("recoverySessionId");
const storedChallenge = sessionStorage.getItem("recoveryEncryptedChallenge");
if (!storedEmail || !storedSessionId || !storedChallenge) {
console.log("[VerifyRecovery] No active recovery session, redirecting");
navigate("/recovery/initiate");
return;
}
setEmail(storedEmail);
setSessionId(storedSessionId);
setEncryptedChallenge(storedChallenge);
}, [navigate]);
// Count words in recovery phrase
const wordCount = useMemo(() => {
if (!recoveryPhrase.trim()) return 0;
return recoveryPhrase.trim().split(/\s+/).length;
}, [recoveryPhrase]);
const handleSubmit = async (e) => {
e.preventDefault();
setError("");
setLoading(true);
setDecrypting(true);
try {
// Validate recovery phrase
const words = recoveryPhrase.trim().toLowerCase().split(/\s+/);
if (words.length !== 12) {
throw new Error("Recovery phrase must be exactly 12 words");
}
// Join words with single space (normalize)
const normalizedPhrase = words.join(" ");
console.log("[VerifyRecovery] Decrypting challenge with recovery phrase");
// Decrypt the challenge using recovery phrase via backend
const decryptResult = await DecryptRecoveryChallenge({
recoveryMnemonic: normalizedPhrase,
encryptedChallenge: encryptedChallenge,
});
setDecrypting(false);
if (!decryptResult.isValid) {
throw new Error("Invalid recovery phrase");
}
console.log("[VerifyRecovery] Challenge decrypted, verifying with server");
// Verify the decrypted challenge with the server
const response = await VerifyRecoveryAPI(
sessionId,
decryptResult.decryptedChallenge
);
console.log("[VerifyRecovery] Recovery verified successfully");
// Store recovery token for the completion step
sessionStorage.setItem("recoveryToken", response.recoveryToken);
sessionStorage.setItem("canResetCredentials", response.canResetCredentials.toString());
// Clear the recovery phrase from memory
setRecoveryPhrase("");
// Navigate to completion step
navigate("/recovery/complete");
} catch (err) {
console.error("[VerifyRecovery] Recovery verification failed:", err);
setError(err.message || "Failed to verify recovery phrase");
setDecrypting(false);
} finally {
setLoading(false);
}
};
const handlePaste = async () => {
try {
const text = await navigator.clipboard.readText();
setRecoveryPhrase(text.trim());
setCopied(true);
setTimeout(() => setCopied(false), 3000);
} catch (err) {
console.error("Failed to read clipboard:", err);
alert("Failed to paste from clipboard. Please paste manually.");
}
};
const handleStartOver = () => {
// Clear session data and go back to initiate
sessionStorage.removeItem("recoveryEmail");
sessionStorage.removeItem("recoverySessionId");
sessionStorage.removeItem("recoveryEncryptedChallenge");
setRecoveryPhrase("");
navigate("/recovery/initiate");
};
if (!email) {
return (
<Page title="Verify Recovery" showBackButton={true}>
<p>Loading recovery session...</p>
</Page>
);
}
return (
<Page title="Verify Your Recovery Phrase" showBackButton={false}>
<div style={{ maxWidth: "600px" }}>
{/* Progress Indicator */}
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
marginBottom: "20px",
gap: "10px",
}}
>
<div
style={{
width: "30px",
height: "30px",
borderRadius: "50%",
background: "#27ae60",
color: "white",
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: "14px",
fontWeight: "bold",
}}
>
</div>
<span style={{ color: "#27ae60", fontWeight: "bold" }}>Email</span>
<div style={{ width: "30px", height: "2px", background: "#27ae60" }} />
<div
style={{
width: "30px",
height: "30px",
borderRadius: "50%",
background: "#3498db",
color: "white",
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: "14px",
fontWeight: "bold",
}}
>
2
</div>
<span style={{ color: "#333", fontWeight: "bold" }}>Verify</span>
<div style={{ width: "30px", height: "2px", background: "#ccc" }} />
<div
style={{
width: "30px",
height: "30px",
borderRadius: "50%",
background: "#ccc",
color: "#666",
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: "14px",
fontWeight: "bold",
}}
>
3
</div>
<span style={{ color: "#666" }}>Reset</span>
</div>
<p style={{ marginBottom: "20px", color: "#333" }}>
Enter your 12-word recovery phrase for <strong>{email}</strong>
</p>
{/* Error Message */}
{error && (
<div
style={{
background: "#f8d7da",
border: "1px solid #f5c6cb",
borderRadius: "8px",
padding: "15px",
marginBottom: "20px",
color: "#721c24",
}}
>
<strong>Verification Error:</strong> {error}
</div>
)}
{/* Decrypting Message */}
{decrypting && (
<div
style={{
background: "#cce5ff",
border: "1px solid #b8daff",
borderRadius: "8px",
padding: "15px",
marginBottom: "20px",
color: "#004085",
}}
>
Decrypting challenge with your recovery key...
</div>
)}
<form onSubmit={handleSubmit}>
<div style={{ marginBottom: "15px" }}>
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
marginBottom: "5px",
}}
>
<label
htmlFor="recoveryPhrase"
style={{ fontWeight: "bold", color: "#333" }}
>
Recovery Phrase
</label>
<span
style={{
fontSize: "12px",
color:
wordCount === 12
? "#27ae60"
: wordCount > 0
? "#f39c12"
: "#666",
fontWeight: "bold",
}}
>
{wordCount}/12 words
</span>
</div>
<textarea
id="recoveryPhrase"
value={recoveryPhrase}
onChange={(e) => setRecoveryPhrase(e.target.value)}
placeholder="Enter your 12-word recovery phrase separated by spaces"
rows={4}
required
disabled={loading}
style={{
width: "100%",
padding: "12px",
fontFamily: "monospace",
fontSize: "14px",
lineHeight: "1.6",
borderRadius: "8px",
border:
wordCount === 12 ? "2px solid #27ae60" : "1px solid #ccc",
background: wordCount === 12 ? "#f0fff0" : "#fff",
resize: "none",
color: "#333",
}}
/>
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
marginTop: "5px",
}}
>
<small style={{ color: "#666" }}>
Enter all 12 words in the correct order, separated by spaces
</small>
<button
type="button"
onClick={handlePaste}
disabled={loading}
style={{
padding: "4px 8px",
fontSize: "12px",
background: copied ? "#27ae60" : "#95a5a6",
color: "white",
border: "none",
borderRadius: "4px",
cursor: loading ? "not-allowed" : "pointer",
}}
>
{copied ? "Pasted!" : "Paste"}
</button>
</div>
</div>
<div className="nav-buttons">
<button
type="submit"
className="nav-button success"
disabled={loading || wordCount !== 12}
style={{
opacity: wordCount === 12 ? 1 : 0.5,
cursor: wordCount === 12 && !loading ? "pointer" : "not-allowed",
}}
>
{loading
? decrypting
? "Decrypting..."
: "Verifying..."
: "Verify Recovery Phrase"}
</button>
<button
type="button"
onClick={handleStartOver}
className="nav-button secondary"
disabled={loading}
>
Start Over
</button>
</div>
</form>
{/* Security Tips */}
<div
style={{
marginTop: "20px",
padding: "15px",
background: "#f0f0f0",
borderRadius: "8px",
}}
>
<strong>Security Tips:</strong>
<ul style={{ margin: "10px 0 0", paddingLeft: "20px", color: "#333" }}>
<li>Never share your recovery phrase with anyone</li>
<li>Make sure no one is watching your screen</li>
<li>Your phrase is validated locally and never sent in plain text</li>
</ul>
</div>
</div>
</Page>
);
}
export default VerifyRecovery;

View file

@ -0,0 +1,240 @@
// File Path: monorepo/native/desktop/maplefile/frontend/src/pages/Anonymous/Register/RecoveryCode.jsx
import { useState, useEffect, useMemo } from "react";
import { Link, useNavigate } from "react-router-dom";
import Page from "../../../components/Page";
function RecoveryCode() {
const navigate = useNavigate();
const [recoveryMnemonic, setRecoveryMnemonic] = useState("");
const [email, setEmail] = useState("");
const [copied, setCopied] = useState(false);
const [confirmed, setConfirmed] = useState(false);
const [error, setError] = useState("");
useEffect(() => {
// Get recovery mnemonic from sessionStorage
const storedMnemonic = sessionStorage.getItem("recoveryMnemonic");
const storedEmail = sessionStorage.getItem("registrationEmail");
if (!storedMnemonic) {
setError("No recovery code found. Please start registration again.");
return;
}
setRecoveryMnemonic(storedMnemonic);
setEmail(storedEmail || "");
}, []);
// Split mnemonic into words for display
const mnemonicWords = useMemo(() => {
if (!recoveryMnemonic) return [];
return recoveryMnemonic.split(" ");
}, [recoveryMnemonic]);
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(recoveryMnemonic);
setCopied(true);
setTimeout(() => setCopied(false), 3000);
} catch (err) {
// Fallback for older browsers
const textArea = document.createElement("textarea");
textArea.value = recoveryMnemonic;
document.body.appendChild(textArea);
textArea.select();
document.execCommand("copy");
document.body.removeChild(textArea);
setCopied(true);
setTimeout(() => setCopied(false), 3000);
}
};
const handleContinue = () => {
if (!confirmed) {
setError("Please confirm that you have saved your recovery code.");
return;
}
// Clear the recovery mnemonic from sessionStorage
sessionStorage.removeItem("recoveryMnemonic");
// Navigate to email verification
navigate(`/register/verify-email?email=${encodeURIComponent(email)}`);
};
if (error && !recoveryMnemonic) {
return (
<Page title="Recovery Code" showBackButton={true}>
<p style={{ color: "red", marginBottom: "20px" }}>{error}</p>
<div className="nav-buttons">
<Link to="/register" className="nav-button">
Start Registration
</Link>
</div>
</Page>
);
}
return (
<Page title="Save Your Recovery Code" showBackButton={false}>
<div style={{ maxWidth: "600px" }}>
{/* Warning Banner */}
<div
style={{
background: "#fff3cd",
border: "1px solid #ffc107",
borderRadius: "8px",
padding: "15px",
marginBottom: "20px",
}}
>
<strong style={{ color: "#856404" }}>
IMPORTANT: Save This Recovery Code!
</strong>
<p style={{ margin: "10px 0 0", color: "#856404", fontSize: "14px" }}>
This 12-word recovery phrase is the ONLY way to recover your account
if you forget your password. Write it down and store it in a safe
place. You will NOT see this again.
</p>
</div>
{/* Recovery Words Grid */}
<div
style={{
background: "#f8f9fa",
border: "2px solid #28a745",
borderRadius: "8px",
padding: "20px",
marginBottom: "20px",
}}
>
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(3, 1fr)",
gap: "10px",
}}
>
{mnemonicWords.map((word, index) => (
<div
key={index}
style={{
background: "white",
border: "1px solid #ddd",
borderRadius: "4px",
padding: "8px 12px",
fontFamily: "monospace",
fontSize: "14px",
}}
>
<span style={{ color: "#666", marginRight: "8px" }}>
{index + 1}.
</span>
<span style={{ fontWeight: "bold" }}>{word}</span>
</div>
))}
</div>
</div>
{/* Copy Button */}
<button
onClick={handleCopy}
style={{
width: "100%",
padding: "12px",
marginBottom: "20px",
background: copied ? "#28a745" : "#007bff",
color: "white",
border: "none",
borderRadius: "4px",
cursor: "pointer",
fontSize: "16px",
}}
>
{copied ? "Copied to Clipboard!" : "Copy Recovery Code"}
</button>
{/* Security Tips */}
<div
style={{
background: "#f0f0f0",
borderRadius: "8px",
padding: "15px",
marginBottom: "20px",
}}
>
<strong>Security Tips:</strong>
<ul style={{ margin: "10px 0 0", paddingLeft: "20px" }}>
<li>Write down these 12 words on paper</li>
<li>Store in a secure location (safe, lockbox)</li>
<li>Never share with anyone, including support staff</li>
<li>Do not store digitally (screenshots, cloud storage)</li>
<li>Consider storing copies in multiple secure locations</li>
</ul>
</div>
{/* Confirmation Checkbox */}
<label
style={{
display: "flex",
alignItems: "flex-start",
cursor: "pointer",
marginBottom: "20px",
padding: "10px",
background: confirmed ? "#d4edda" : "#fff",
border: confirmed ? "1px solid #28a745" : "1px solid #ddd",
borderRadius: "4px",
}}
>
<input
type="checkbox"
checked={confirmed}
onChange={(e) => {
setConfirmed(e.target.checked);
setError("");
}}
style={{ marginRight: "10px", marginTop: "3px" }}
/>
<span>
I have written down my recovery code and stored it in a safe place.
I understand that without this code, I cannot recover my account if
I forget my password.
</span>
</label>
{error && <p style={{ color: "red", marginBottom: "15px" }}>{error}</p>}
{/* Continue Button */}
<div className="nav-buttons">
<button
onClick={handleContinue}
className="nav-button success"
disabled={!confirmed}
style={{
opacity: confirmed ? 1 : 0.5,
cursor: confirmed ? "pointer" : "not-allowed",
}}
>
Continue to Email Verification
</button>
</div>
{/* Email reminder */}
{email && (
<p
style={{
marginTop: "15px",
color: "#666",
fontSize: "14px",
textAlign: "center",
}}
>
Verification email will be sent to: <strong>{email}</strong>
</p>
)}
</div>
</Page>
);
}
export default RecoveryCode;

View file

@ -0,0 +1,445 @@
// File Path: monorepo/native/desktop/maplefile/frontend/src/pages/Anonymous/Register/Register.jsx
import { useState } from "react";
import { Link, useNavigate } from "react-router-dom";
import Page from "../../../components/Page";
import {
Register as RegisterAPI,
GenerateRegistrationKeys,
} from "../../../../wailsjs/go/app/Application";
function Register() {
const navigate = useNavigate();
const [formData, setFormData] = useState({
email: "",
password: "",
firstName: "",
lastName: "",
phone: "",
country: "CA",
timezone: "America/Toronto",
betaAccessCode: "",
agreeTermsOfService: false,
agreePromotions: false,
agreeToTracking: false,
});
const [error, setError] = useState("");
const [fieldErrors, setFieldErrors] = useState({});
const [loading, setLoading] = useState(false);
const [message, setMessage] = useState("");
const handleInputChange = (e) => {
const { name, value, type, checked } = e.target;
setFormData((prev) => ({
...prev,
[name]: type === "checkbox" ? checked : value,
}));
// Clear field error when user types
if (fieldErrors[name]) {
setFieldErrors((prev) => {
const newErrors = { ...prev };
delete newErrors[name];
return newErrors;
});
}
};
const handleRegister = async (e) => {
e.preventDefault();
setError("");
setFieldErrors({});
setMessage("");
setLoading(true);
try {
// Generate production E2EE keys using backend crypto library
const registrationKeys = await GenerateRegistrationKeys(
formData.password,
);
// Use PBKDF2-SHA256 for KDF (compatible with web frontend)
// Parameters: 100,000 iterations, 16-byte salt, 32-byte key
const registerInput = {
beta_access_code: formData.betaAccessCode,
email: formData.email,
first_name: formData.firstName,
last_name: formData.lastName,
phone: formData.phone,
country: formData.country,
timezone: formData.timezone,
salt: registrationKeys.salt,
kdf_algorithm: "PBKDF2-SHA256",
kdf_iterations: 100000,
kdf_memory: 0, // Not used for PBKDF2
kdf_parallelism: 0, // Not used for PBKDF2
kdf_salt_length: 16,
kdf_key_length: 32,
encryptedMasterKey: registrationKeys.encryptedMasterKey,
publicKey: registrationKeys.publicKey,
encryptedPrivateKey: registrationKeys.encryptedPrivateKey,
encryptedRecoveryKey: registrationKeys.encryptedRecoveryKey,
masterKeyEncryptedWithRecoveryKey:
registrationKeys.masterKeyEncryptedWithRecoveryKey,
agree_terms_of_service: formData.agreeTermsOfService,
agree_promotions: formData.agreePromotions,
agree_to_tracking_across_third_party_apps_and_services:
formData.agreeToTracking,
};
await RegisterAPI(registerInput);
setMessage("Registration successful! Redirecting to save recovery code...");
// Store recovery mnemonic in sessionStorage for the RecoveryCode page
// This is temporary storage - it will be cleared after the user saves it
sessionStorage.setItem("recoveryMnemonic", registrationKeys.recoveryMnemonic);
sessionStorage.setItem("registrationEmail", formData.email);
// Redirect to recovery code page (user MUST save this before continuing)
setTimeout(() => {
navigate("/register/recovery");
}, 1000);
} catch (err) {
// Handle RFC 9457 Problem Details error format
const errorMessage = err.message || err.toString();
// Try to parse RFC 9457 format
try {
// Check if error contains JSON
const jsonMatch = errorMessage.match(/\{[\s\S]*\}/);
if (jsonMatch) {
const problemDetails = JSON.parse(jsonMatch[0]);
// Set general error
if (problemDetails.title || problemDetails.detail) {
setError(problemDetails.detail || problemDetails.title);
}
// Set field-specific errors
if (problemDetails.invalid_params) {
const errors = {};
problemDetails.invalid_params.forEach((param) => {
errors[param.name] = param.reason;
});
setFieldErrors(errors);
}
} else {
setError("Registration failed: " + errorMessage);
}
} catch (parseErr) {
setError("Registration failed: " + errorMessage);
}
} finally {
setLoading(false);
}
};
return (
<Page title="Register" showBackButton={true}>
<form onSubmit={handleRegister} style={{ maxWidth: "500px" }}>
<div style={{ marginBottom: "15px" }}>
<label
htmlFor="betaAccessCode"
style={{
display: "block",
marginBottom: "5px",
fontWeight: "bold",
color: "#333",
}}
>
Beta Access Code
</label>
<input
type="text"
id="betaAccessCode"
name="betaAccessCode"
value={formData.betaAccessCode}
onChange={handleInputChange}
style={{ width: "100%", padding: "8px" }}
/>
{fieldErrors.beta_access_code && (
<small style={{ color: "red" }}>
{fieldErrors.beta_access_code}
</small>
)}
</div>
<div style={{ marginBottom: "15px" }}>
<label
htmlFor="email"
style={{
display: "block",
marginBottom: "5px",
fontWeight: "bold",
color: "#333",
}}
>
Email
</label>
<input
type="email"
id="email"
name="email"
value={formData.email}
onChange={handleInputChange}
style={{ width: "100%", padding: "8px" }}
/>
{fieldErrors.email && (
<small style={{ color: "red" }}>{fieldErrors.email}</small>
)}
</div>
<div style={{ marginBottom: "15px" }}>
<label
htmlFor="firstName"
style={{
display: "block",
marginBottom: "5px",
fontWeight: "bold",
color: "#333",
}}
>
First Name
</label>
<input
type="text"
id="firstName"
name="firstName"
value={formData.firstName}
onChange={handleInputChange}
style={{ width: "100%", padding: "8px" }}
/>
{fieldErrors.first_name && (
<small style={{ color: "red" }}>{fieldErrors.first_name}</small>
)}
</div>
<div style={{ marginBottom: "15px" }}>
<label
htmlFor="lastName"
style={{
display: "block",
marginBottom: "5px",
fontWeight: "bold",
color: "#333",
}}
>
Last Name
</label>
<input
type="text"
id="lastName"
name="lastName"
value={formData.lastName}
onChange={handleInputChange}
style={{ width: "100%", padding: "8px" }}
/>
{fieldErrors.last_name && (
<small style={{ color: "red" }}>{fieldErrors.last_name}</small>
)}
</div>
<div style={{ marginBottom: "15px" }}>
<label
htmlFor="phone"
style={{
display: "block",
marginBottom: "5px",
fontWeight: "bold",
color: "#333",
}}
>
Phone (Optional)
</label>
<input
type="tel"
id="phone"
name="phone"
value={formData.phone}
onChange={handleInputChange}
style={{ width: "100%", padding: "8px" }}
/>
{fieldErrors.phone && (
<small style={{ color: "red" }}>{fieldErrors.phone}</small>
)}
</div>
<div style={{ marginBottom: "15px" }}>
<label
htmlFor="country"
style={{
display: "block",
marginBottom: "5px",
fontWeight: "bold",
color: "#333",
}}
>
Country
</label>
<input
type="text"
id="country"
name="country"
value={formData.country}
onChange={handleInputChange}
style={{ width: "100%", padding: "8px" }}
/>
{fieldErrors.country && (
<small style={{ color: "red" }}>{fieldErrors.country}</small>
)}
</div>
<div style={{ marginBottom: "15px" }}>
<label
htmlFor="timezone"
style={{
display: "block",
marginBottom: "5px",
fontWeight: "bold",
color: "#333",
}}
>
Timezone
</label>
<input
type="text"
id="timezone"
name="timezone"
value={formData.timezone}
onChange={handleInputChange}
style={{ width: "100%", padding: "8px" }}
/>
{fieldErrors.timezone && (
<small style={{ color: "red" }}>{fieldErrors.timezone}</small>
)}
</div>
<div style={{ marginBottom: "15px" }}>
<label
htmlFor="password"
style={{
display: "block",
marginBottom: "5px",
fontWeight: "bold",
color: "#333",
}}
>
Password (Used for E2EE)
</label>
<input
type="password"
id="password"
name="password"
value={formData.password}
onChange={handleInputChange}
minLength={8}
style={{ width: "100%", padding: "8px" }}
/>
<small style={{ display: "block", marginTop: "5px", color: "#666" }}>
Will be used to encrypt your data (not stored on server). Minimum 8
characters.
</small>
{fieldErrors.password && (
<small style={{ color: "red", display: "block" }}>
{fieldErrors.password}
</small>
)}
</div>
<div style={{ marginBottom: "15px" }}>
<label
style={{
display: "flex",
alignItems: "center",
cursor: "pointer",
color: "#333",
}}
>
<input
type="checkbox"
name="agreeTermsOfService"
checked={formData.agreeTermsOfService}
onChange={handleInputChange}
style={{ marginRight: "8px" }}
/>
<span>I agree to the Terms of Service</span>
</label>
{fieldErrors.agree_terms_of_service && (
<small style={{ color: "red" }}>
{fieldErrors.agree_terms_of_service}
</small>
)}
</div>
<div style={{ marginBottom: "15px" }}>
<label
style={{
display: "flex",
alignItems: "center",
cursor: "pointer",
color: "#333",
}}
>
<input
type="checkbox"
name="agreePromotions"
checked={formData.agreePromotions}
onChange={handleInputChange}
style={{ marginRight: "8px" }}
/>
<span>I agree to receive promotional emails</span>
</label>
</div>
<div style={{ marginBottom: "15px" }}>
<label
style={{
display: "flex",
alignItems: "center",
cursor: "pointer",
color: "#333",
}}
>
<input
type="checkbox"
name="agreeToTracking"
checked={formData.agreeToTracking}
onChange={handleInputChange}
style={{ marginRight: "8px" }}
/>
<span>
I agree to tracking across third-party apps and services
</span>
</label>
</div>
{error && <p style={{ color: "red", marginBottom: "15px" }}>{error}</p>}
{message && (
<p style={{ color: "green", marginBottom: "15px" }}>{message}</p>
)}
<div className="nav-buttons">
<button type="submit" className="nav-button" disabled={loading}>
{loading ? "Registering..." : "Register"}
</button>
<Link to="/login" className="nav-button secondary">
Already have an account?
</Link>
</div>
<div
style={{
marginTop: "20px",
padding: "10px",
background: "#f0f0f0",
borderRadius: "5px",
}}
>
<strong>Security:</strong> Your password is used to generate
encryption keys locally and is never sent to the server. All keys are
encrypted using industry-standard PBKDF2-SHA256 and XSalsa20-Poly1305.
</div>
</form>
</Page>
);
}
export default Register;

View file

@ -0,0 +1,165 @@
// File Path: monorepo/native/desktop/maplefile/frontend/src/pages/Anonymous/Register/VerifyEmail.jsx
import { useState, useEffect } from "react";
import { Link, useSearchParams, useNavigate } from "react-router-dom";
import Page from "../../../components/Page";
import { VerifyEmail as VerifyEmailAPI } from "../../../../wailsjs/go/app/Application";
function VerifyEmail() {
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const [email, setEmail] = useState("");
const [verificationCode, setVerificationCode] = useState("");
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
const [message, setMessage] = useState("");
useEffect(() => {
// Get email from URL query parameter
const emailParam = searchParams.get("email");
if (emailParam) {
setEmail(emailParam);
}
}, [searchParams]);
const handleVerify = async (e) => {
e.preventDefault();
setError("");
setMessage("");
setLoading(true);
if (!email) {
setError("Email is missing. Please return to registration.");
setLoading(false);
return;
}
if (!verificationCode) {
setError("Please enter the verification code.");
setLoading(false);
return;
}
try {
await VerifyEmailAPI(email, verificationCode);
setMessage("Email verified successfully! Redirecting...");
// Redirect to success page after 1 second
setTimeout(() => {
navigate("/register/verify-success");
}, 1000);
} catch (err) {
const errorMessage = err.message || err.toString();
// Try to parse RFC 9457 format
try {
const jsonMatch = errorMessage.match(/\{[\s\S]*\}/);
if (jsonMatch) {
const problemDetails = JSON.parse(jsonMatch[0]);
setError(
problemDetails.detail || problemDetails.title || errorMessage,
);
} else {
setError("Verification failed: " + errorMessage);
}
} catch (parseErr) {
setError("Verification failed: " + errorMessage);
}
} finally {
setLoading(false);
}
};
return (
<Page title="Verify Email" showBackButton={true}>
<div style={{ maxWidth: "400px" }}>
<p style={{ marginBottom: "20px", color: "#333" }}>
Please check your email <strong>{email}</strong> and enter the
verification code sent to you.
</p>
<form onSubmit={handleVerify}>
<div style={{ marginBottom: "15px" }}>
<label
htmlFor="email"
style={{
display: "block",
marginBottom: "5px",
fontWeight: "bold",
color: "#333",
}}
>
Email
</label>
<input
type="email"
id="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
style={{
width: "100%",
padding: "8px",
backgroundColor: "#f5f5f5",
}}
readOnly
/>
</div>
<div style={{ marginBottom: "15px" }}>
<label
htmlFor="code"
style={{
display: "block",
marginBottom: "5px",
fontWeight: "bold",
color: "#333",
}}
>
Verification Code
</label>
<input
type="text"
id="code"
value={verificationCode}
onChange={(e) => setVerificationCode(e.target.value)}
placeholder="Enter 8-digit code"
style={{ width: "100%", padding: "8px" }}
autoFocus
/>
</div>
{error && (
<p style={{ color: "red", marginBottom: "15px" }}>{error}</p>
)}
{message && (
<p style={{ color: "green", marginBottom: "15px" }}>{message}</p>
)}
<div className="nav-buttons">
<button type="submit" className="nav-button" disabled={loading}>
{loading ? "Verifying..." : "Verify Email"}
</button>
<Link to="/login" className="nav-button secondary">
Back to Login
</Link>
</div>
</form>
<div
style={{
marginTop: "20px",
padding: "10px",
background: "#f0f0f0",
borderRadius: "5px",
}}
>
<p style={{ margin: 0, fontSize: "14px", color: "#666" }}>
<strong>Didn't receive the code?</strong> Check your spam folder or
contact support.
</p>
</div>
</div>
</Page>
);
}
export default VerifyEmail;

View file

@ -0,0 +1,58 @@
// File Path: monorepo/native/desktop/maplefile/frontend/src/pages/Anonymous/Register/VerifySuccess.jsx
import { useEffect } from "react";
import { Link, useNavigate } from "react-router-dom";
import Page from "../../../components/Page";
function VerifySuccess() {
const navigate = useNavigate();
useEffect(() => {
// Auto-redirect to login after 5 seconds
const timer = setTimeout(() => {
navigate("/login");
}, 5000);
return () => clearTimeout(timer);
}, [navigate]);
return (
<Page title="Registration Successful">
<div style={{ maxWidth: "500px", textAlign: "center", margin: "0 auto" }}>
<div style={{ fontSize: "48px", marginBottom: "20px" }}></div>
<h2 style={{ color: "#333", marginBottom: "15px" }}>
Email Verified Successfully!
</h2>
<p style={{ color: "#666", marginBottom: "10px", fontSize: "16px" }}>
Your account has been successfully created and verified.
</p>
<p style={{ color: "#666", marginBottom: "30px", fontSize: "16px" }}>
You can now log in to access your MapleFile account.
</p>
<div
style={{
background: "#f0f0f0",
padding: "15px",
borderRadius: "5px",
marginBottom: "30px",
}}
>
<p style={{ margin: 0, fontSize: "14px", color: "#666" }}>
Redirecting to login page in 5 seconds...
</p>
</div>
<div className="nav-buttons">
<Link to="/login" className="nav-button success">
Login Now
</Link>
</div>
</div>
</Page>
);
}
export default VerifySuccess;

View file

@ -0,0 +1,35 @@
.layout {
display: flex;
}
.main-content {
margin-left: 250px;
flex: 1;
min-height: 100vh;
background-color: #ecf0f1;
}
.stat-card {
background: white;
padding: 20px;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
transition: transform 0.2s, box-shadow 0.2s;
}
.stat-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15);
}
.stat-label {
color: #7f8c8d;
font-size: 14px;
margin-bottom: 8px;
}
.stat-value {
color: #2c3e50;
font-size: 28px;
font-weight: bold;
}

View file

@ -0,0 +1,495 @@
// File Path: monorepo/native/desktop/maplefile/frontend/src/pages/User/Dashboard/Dashboard.jsx
import { useState, useEffect } from "react";
import { Link } from "react-router-dom";
import Navigation from "../../../components/Navigation";
import Page from "../../../components/Page";
import { GetDashboardData } from "../../../../wailsjs/go/app/Application";
import "./Dashboard.css";
function Dashboard() {
const [dashboardData, setDashboardData] = useState(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
// Wait for Wails runtime to be ready before loading data
let attempts = 0;
const maxAttempts = 50; // 5 seconds max (50 * 100ms)
let isCancelled = false; // Prevent race conditions
const checkWailsReady = () => {
if (isCancelled) return;
if (window.go && window.go.app && window.go.app.Application) {
loadDashboardData();
} else if (attempts < maxAttempts) {
attempts++;
setTimeout(checkWailsReady, 100);
} else {
// Timeout - show error
if (!isCancelled) {
setIsLoading(false);
setError("Failed to initialize Wails runtime. Please restart the application.");
}
}
};
checkWailsReady();
// Cleanup function to prevent race conditions with StrictMode
return () => {
isCancelled = true;
};
}, []);
const loadDashboardData = async (retryCount = 0) => {
const maxRetries = 2;
try {
setIsLoading(true);
setError(null);
// Check if Wails runtime is available
if (!window.go || !window.go.app || !window.go.app.Application) {
throw new Error("Wails runtime not initialized. Please refresh the page.");
}
console.log("Fetching dashboard data...", retryCount > 0 ? `(retry ${retryCount})` : "");
// First check if we're actually logged in
const { IsLoggedIn } = await import("../../../../wailsjs/go/app/Application");
const loggedIn = await IsLoggedIn();
console.log("IsLoggedIn:", loggedIn);
if (!loggedIn) {
throw new Error("You are not logged in. Please log in first.");
}
const data = await GetDashboardData();
console.log("Dashboard data received:", data);
setDashboardData(data);
setError(null); // Clear any previous errors
} catch (err) {
console.error("Failed to load dashboard - Full error:", err);
console.error("Error name:", err.name);
console.error("Error message:", err.message);
// Retry logic for token refresh scenarios
const isTokenError = err.message && (
err.message.includes("Invalid or expired token") ||
err.message.includes("token") ||
err.message.includes("Unauthorized")
);
if (isTokenError && retryCount < maxRetries) {
console.log(`Token error detected, retrying in 500ms... (attempt ${retryCount + 1}/${maxRetries})`);
// Wait a bit for token refresh to complete, then retry
setTimeout(() => loadDashboardData(retryCount + 1), 500);
return; // Don't set error yet, we're retrying
}
// Show more detailed error message
let errorMsg = err.message || "Failed to load dashboard data";
if (err.toString) {
errorMsg = err.toString();
}
setError(errorMsg);
setIsLoading(false);
} finally {
// Only set loading to false if we're not retrying
if (retryCount >= maxRetries || !error) {
setIsLoading(false);
}
}
};
const handleRefresh = () => {
loadDashboardData();
};
const getTimeAgo = (dateString) => {
if (!dateString) return "Recently";
const now = new Date();
const date = new Date(dateString);
const diffInMinutes = Math.floor((now - date) / (1000 * 60));
if (diffInMinutes < 1) return "Just now";
if (diffInMinutes < 60) return `${diffInMinutes} min ago`;
if (diffInMinutes < 1440) {
const hours = Math.floor(diffInMinutes / 60);
return `${hours} hour${hours > 1 ? "s" : ""} ago`;
}
if (diffInMinutes < 2880) return "Yesterday";
const days = Math.floor(diffInMinutes / 1440);
return `${days} day${days > 1 ? "s" : ""} ago`;
};
if (isLoading) {
return (
<div className="layout">
<Navigation />
<div className="main-content">
<Page title="Dashboard">
<div style={{ textAlign: "center", padding: "40px" }}>
<p>Loading dashboard...</p>
</div>
</Page>
</div>
</div>
);
}
if (error) {
const isTokenError = error.includes("token") || error.includes("Unauthorized");
return (
<div className="layout">
<Navigation />
<div className="main-content">
<Page title="Dashboard">
<div style={{ padding: "20px", color: "#e74c3c" }}>
<h3>Error Loading Dashboard</h3>
<p>{error}</p>
{isTokenError && (
<p style={{ marginTop: "10px", fontSize: "14px", color: "#7f8c8d" }}>
Your session may have expired. Please try logging out and logging back in.
</p>
)}
<div style={{ marginTop: "20px", display: "flex", gap: "10px" }}>
<button onClick={handleRefresh} className="nav-button">
Try Again
</button>
{isTokenError && (
<button
onClick={async () => {
const { Logout } = await import("../../../../wailsjs/go/app/Application");
await Logout();
window.location.href = "/";
}}
className="nav-button danger"
>
Logout
</button>
)}
</div>
</div>
</Page>
</div>
</div>
);
}
if (!dashboardData) {
return (
<div className="layout">
<Navigation />
<div className="main-content">
<Page title="Dashboard">
<p>No dashboard data available.</p>
</Page>
</div>
</div>
);
}
const { summary, storage_usage_trend, recent_files } = dashboardData;
return (
<div className="layout">
<Navigation />
<div className="main-content">
<Page title="Dashboard">
{/* Header with refresh button */}
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
marginBottom: "20px",
}}
>
<div>
<h2 style={{ margin: 0 }}>Welcome Back!</h2>
<p style={{ color: "#7f8c8d", margin: "5px 0 0 0" }}>
Here's what's happening with your files today
</p>
</div>
<button onClick={handleRefresh} className="nav-button secondary">
Refresh
</button>
</div>
{/* Summary Statistics */}
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(auto-fit, minmax(200px, 1fr))",
gap: "20px",
marginBottom: "30px",
}}
>
<div className="stat-card">
<div className="stat-label">Total Files</div>
<div className="stat-value">{summary.total_files}</div>
</div>
<div className="stat-card">
<div className="stat-label">Total Folders</div>
<div className="stat-value">{summary.total_folders}</div>
</div>
<div className="stat-card">
<div className="stat-label">Storage Used</div>
<div className="stat-value">{summary.storage_used}</div>
</div>
<div className="stat-card">
<div className="stat-label">Storage Limit</div>
<div className="stat-value">{summary.storage_limit}</div>
</div>
</div>
{/* Storage Usage Percentage */}
<div
style={{
marginBottom: "30px",
padding: "20px",
background: "white",
borderRadius: "8px",
boxShadow: "0 2px 4px rgba(0,0,0,0.1)",
}}
>
<h3 style={{ marginTop: 0 }}>Storage Usage</h3>
<div
style={{
background: "#ecf0f1",
borderRadius: "10px",
height: "30px",
overflow: "hidden",
position: "relative",
}}
>
<div
style={{
background:
summary.storage_usage_percentage > 80
? "#e74c3c"
: summary.storage_usage_percentage > 50
? "#f39c12"
: "#27ae60",
height: "100%",
width: `${Math.min(summary.storage_usage_percentage, 100)}%`,
transition: "width 0.3s ease",
display: "flex",
alignItems: "center",
justifyContent: "center",
color: "white",
fontWeight: "bold",
}}
>
{summary.storage_usage_percentage}%
</div>
</div>
<div
style={{
display: "flex",
justifyContent: "space-between",
marginTop: "10px",
fontSize: "14px",
color: "#7f8c8d",
}}
>
<span>{summary.storage_used} used</span>
<span>{summary.storage_limit} total</span>
</div>
</div>
{/* Storage Usage Trend */}
{storage_usage_trend &&
storage_usage_trend.data_points &&
storage_usage_trend.data_points.length > 0 && (
<div
style={{
marginBottom: "30px",
padding: "20px",
background: "white",
borderRadius: "8px",
boxShadow: "0 2px 4px rgba(0,0,0,0.1)",
}}
>
<h3 style={{ marginTop: 0 }}>
Storage Trend ({storage_usage_trend.period})
</h3>
<div
style={{
display: "grid",
gridTemplateColumns: `repeat(${storage_usage_trend.data_points.length}, 1fr)`,
gap: "10px",
marginTop: "20px",
}}
>
{storage_usage_trend.data_points.map((point, index) => (
<div
key={index}
style={{ textAlign: "center", fontSize: "12px" }}
>
<div
style={{
color: "#7f8c8d",
marginBottom: "5px",
fontWeight: "500",
}}
>
{point.usage}
</div>
<div style={{ color: "#95a5a6" }}>
{new Date(point.date).toLocaleDateString("en-US", {
month: "short",
day: "numeric",
})}
</div>
</div>
))}
</div>
</div>
)}
{/* Recent Files */}
<div
style={{
marginBottom: "30px",
padding: "20px",
background: "white",
borderRadius: "8px",
boxShadow: "0 2px 4px rgba(0,0,0,0.1)",
}}
>
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
marginBottom: "15px",
}}
>
<h3 style={{ margin: 0 }}>Recent Files</h3>
<Link to="/file-manager" style={{ fontSize: "14px" }}>
View All
</Link>
</div>
{recent_files && recent_files.length > 0 ? (
<div style={{ display: "flex", flexDirection: "column", gap: "10px" }}>
{recent_files.slice(0, 5).map((file) => {
// Determine sync status badge
const getSyncStatusBadge = (syncStatus) => {
switch (syncStatus) {
case "synced":
return { label: "Synced", color: "#27ae60", bgColor: "#d4edda" };
case "local_only":
return { label: "Local", color: "#3498db", bgColor: "#d1ecf1" };
case "cloud_only":
return { label: "Cloud", color: "#9b59b6", bgColor: "#e2d9f3" };
case "modified_locally":
return { label: "Modified", color: "#f39c12", bgColor: "#fff3cd" };
default:
return { label: "Unknown", color: "#7f8c8d", bgColor: "#e9ecef" };
}
};
const syncBadge = getSyncStatusBadge(file.sync_status);
return (
<div
key={file.id}
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
padding: "12px",
background: "#f8f9fa",
borderRadius: "6px",
border: "1px solid #e9ecef",
}}
>
<div style={{ flex: 1 }}>
<div
style={{
display: "flex",
alignItems: "center",
gap: "8px",
marginBottom: "4px",
}}
>
<span
style={{
fontWeight: "500",
color: file.is_decrypted ? "#2c3e50" : "#95a5a6",
}}
>
{file.is_decrypted ? file.name : "🔒 " + file.name}
</span>
<span
style={{
fontSize: "10px",
padding: "2px 6px",
borderRadius: "4px",
backgroundColor: syncBadge.bgColor,
color: syncBadge.color,
fontWeight: "600",
}}
>
{syncBadge.label}
</span>
</div>
<div
style={{ fontSize: "12px", color: "#7f8c8d" }}
>
{file.size} {getTimeAgo(file.created_at)}
</div>
</div>
<Link
to={`/file-manager/files/${file.id}`}
className="nav-button secondary"
style={{
padding: "6px 12px",
fontSize: "12px",
textDecoration: "none",
}}
>
View
</Link>
</div>
);
})}
</div>
) : (
<div style={{ textAlign: "center", padding: "20px", color: "#7f8c8d" }}>
<p>No recent files found</p>
</div>
)}
</div>
{/* Quick Actions */}
<h3>Quick Actions</h3>
<div className="nav-buttons">
<Link to="/file-manager" className="nav-button">
Manage Files
</Link>
<Link to="/file-manager/upload" className="nav-button success">
Upload File
</Link>
<Link
to="/file-manager/collections/create"
className="nav-button success"
>
Create Collection
</Link>
<Link to="/me" className="nav-button secondary">
View Profile
</Link>
</div>
</Page>
</div>
</div>
);
}
export default Dashboard;

View file

@ -0,0 +1,474 @@
// File Path: monorepo/native/desktop/maplefile/frontend/src/pages/User/FileManager/Collections/CollectionCreate.jsx
import { useState, useEffect } from "react";
import { useNavigate, useLocation } from "react-router-dom";
import Navigation from "../../../../components/Navigation";
import Page from "../../../../components/Page";
import IconPicker from "../../../../components/IconPicker";
import { CreateCollection, ListTags } from "../../../../../wailsjs/go/app/Application";
import "../../Dashboard/Dashboard.css";
function CollectionCreate() {
const navigate = useNavigate();
const location = useLocation();
// Get parent collection info from navigation state
const parentCollectionId = location.state?.parentCollectionId;
const parentCollectionName = location.state?.parentCollectionName;
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState("");
const [collectionName, setCollectionName] = useState("");
const [description, setDescription] = useState("");
const [customIcon, setCustomIcon] = useState("");
const [collectionType, setCollectionType] = useState("folder");
const [isIconPickerOpen, setIsIconPickerOpen] = useState(false);
const [availableTags, setAvailableTags] = useState([]);
const [selectedTagIds, setSelectedTagIds] = useState([]);
const [isLoadingTags, setIsLoadingTags] = useState(false);
// Load available tags
useEffect(() => {
const loadTags = async () => {
setIsLoadingTags(true);
try {
const tags = await ListTags();
setAvailableTags(tags || []);
} catch (err) {
console.error("Failed to load tags:", err);
setAvailableTags([]);
} finally {
setIsLoadingTags(false);
}
};
loadTags();
}, []);
const handleSubmit = async (e) => {
e.preventDefault();
setError("");
// Client-side validation
if (!collectionName || !collectionName.trim()) {
setError("Collection name is required");
return;
}
if (collectionName.trim().length < 2) {
setError("Collection name must be at least 2 characters");
return;
}
if (collectionName.trim().length > 100) {
setError("Collection name must be less than 100 characters");
return;
}
setIsLoading(true);
try {
const input = {
name: collectionName.trim(),
description: description.trim(),
parent_id: parentCollectionId || "",
custom_icon: customIcon,
collection_type: collectionType,
tag_ids: selectedTagIds.length > 0 ? selectedTagIds : undefined,
};
const result = await CreateCollection(input);
if (result.success) {
// Navigate back to file manager or parent collection
if (parentCollectionId) {
navigate(`/file-manager/collections/${parentCollectionId}`, {
state: { refresh: true },
});
} else {
navigate("/file-manager", {
state: { refresh: true },
});
}
}
} catch (err) {
console.error("Failed to create collection:", err);
setError(err.message || "Failed to create collection. Please try again.");
} finally {
setIsLoading(false);
}
};
const getBackUrl = () => {
if (parentCollectionId) {
return `/file-manager/collections/${parentCollectionId}`;
}
return "/file-manager";
};
const getBackText = () => {
if (parentCollectionName) {
return `Back to ${parentCollectionName}`;
}
return "Back to File Manager";
};
return (
<div className="layout">
<Navigation />
<div className="main-content">
<Page title="Create New Collection">
<button
onClick={() => navigate(getBackUrl())}
className="nav-button secondary"
style={{ marginBottom: "20px" }}
>
{getBackText()}
</button>
{parentCollectionName && (
<p style={{ marginBottom: "20px", color: "#666" }}>
Creating collection inside: <strong>{parentCollectionName}</strong>
</p>
)}
{error && (
<div
style={{
padding: "15px",
background: "#ffebee",
color: "#c62828",
borderRadius: "4px",
marginBottom: "20px",
border: "1px solid #f44336",
}}
>
{error}
</div>
)}
<form
onSubmit={handleSubmit}
style={{
maxWidth: "600px",
background: "white",
padding: "30px",
borderRadius: "8px",
border: "1px solid #ddd",
}}
>
{/* Collection Name */}
<div style={{ marginBottom: "20px" }}>
<label
htmlFor="name"
style={{
display: "block",
marginBottom: "8px",
fontWeight: "bold",
color: "#333",
}}
>
Collection Name <span style={{ color: "#f44336" }}>*</span>
</label>
<input
type="text"
id="name"
value={collectionName}
onChange={(e) => {
setCollectionName(e.target.value);
if (error) setError("");
}}
placeholder="Enter collection name"
disabled={isLoading}
style={{
width: "100%",
padding: "10px 12px",
border: "1px solid #ddd",
borderRadius: "4px",
fontSize: "14px",
}}
autoFocus
required
/>
<p style={{ fontSize: "12px", color: "#666", marginTop: "5px" }}>
A descriptive name for your collection
</p>
</div>
{/* Custom Icon */}
<div style={{ marginBottom: "20px" }}>
<label
style={{
display: "block",
marginBottom: "8px",
fontWeight: "bold",
color: "#333",
}}
>
Icon{" "}
<span style={{ fontSize: "12px", color: "#666", fontWeight: "normal" }}>
(optional)
</span>
</label>
<button
type="button"
onClick={() => setIsIconPickerOpen(true)}
disabled={isLoading}
style={{
display: "flex",
alignItems: "center",
gap: "10px",
padding: "10px 14px",
border: "1px solid #ddd",
borderRadius: "4px",
background: "white",
cursor: "pointer",
fontSize: "14px",
color: "#333",
transition: "all 0.15s ease",
}}
onMouseOver={(e) => {
e.currentTarget.style.borderColor = "#999";
e.currentTarget.style.background = "#f9f9f9";
}}
onMouseOut={(e) => {
e.currentTarget.style.borderColor = "#ddd";
e.currentTarget.style.background = "white";
}}
>
<span style={{ fontSize: "24px" }}>
{customIcon || "📁"}
</span>
<span>{customIcon ? "Change Icon" : "Choose Icon"}</span>
</button>
<p style={{ fontSize: "12px", color: "#666", marginTop: "5px" }}>
Select an emoji to customize your collection
</p>
</div>
{/* Collection Type */}
<div style={{ marginBottom: "20px" }}>
<label
style={{
display: "block",
marginBottom: "8px",
fontWeight: "bold",
color: "#333",
}}
>
Type
</label>
<div style={{ display: "flex", gap: "10px" }}>
<button
type="button"
onClick={() => setCollectionType("folder")}
disabled={isLoading}
style={{
flex: 1,
display: "flex",
alignItems: "center",
justifyContent: "center",
gap: "8px",
padding: "12px",
border: collectionType === "folder" ? "2px solid #991b1b" : "1px solid #ddd",
borderRadius: "4px",
background: collectionType === "folder" ? "#fef2f2" : "white",
cursor: "pointer",
fontSize: "14px",
color: "#333",
transition: "all 0.15s ease",
}}
>
<span style={{ fontSize: "20px" }}>📁</span>
<span>Folder</span>
</button>
<button
type="button"
onClick={() => setCollectionType("album")}
disabled={isLoading}
style={{
flex: 1,
display: "flex",
alignItems: "center",
justifyContent: "center",
gap: "8px",
padding: "12px",
border: collectionType === "album" ? "2px solid #991b1b" : "1px solid #ddd",
borderRadius: "4px",
background: collectionType === "album" ? "#fef2f2" : "white",
cursor: "pointer",
fontSize: "14px",
color: "#333",
transition: "all 0.15s ease",
}}
>
<span style={{ fontSize: "20px" }}>🖼</span>
<span>Album</span>
</button>
</div>
<p style={{ fontSize: "12px", color: "#666", marginTop: "5px" }}>
Folders are for general files, albums are optimized for photos and media
</p>
</div>
{/* Tag Selection */}
<div style={{ marginBottom: "20px" }}>
<label
style={{
display: "block",
marginBottom: "8px",
fontWeight: "bold",
color: "#333",
}}
>
Tags{" "}
<span style={{ fontSize: "12px", color: "#666", fontWeight: "normal" }}>
(optional)
</span>
</label>
{isLoadingTags ? (
<div style={{ padding: "10px", color: "#666", fontSize: "14px" }}>
Loading tags...
</div>
) : availableTags.length > 0 ? (
<div
style={{
border: "1px solid #ddd",
borderRadius: "4px",
padding: "10px",
maxHeight: "150px",
overflowY: "auto",
}}
>
{availableTags.map((tag) => (
<label
key={tag.id}
style={{
display: "flex",
alignItems: "center",
padding: "6px",
cursor: "pointer",
borderRadius: "4px",
transition: "background-color 0.15s ease",
}}
onMouseOver={(e) => {
e.currentTarget.style.backgroundColor = "#f9f9f9";
}}
onMouseOut={(e) => {
e.currentTarget.style.backgroundColor = "transparent";
}}
>
<input
type="checkbox"
checked={selectedTagIds.includes(tag.id)}
onChange={(e) => {
if (e.target.checked) {
setSelectedTagIds([...selectedTagIds, tag.id]);
} else {
setSelectedTagIds(selectedTagIds.filter((id) => id !== tag.id));
}
}}
disabled={isLoading}
style={{ marginRight: "8px", cursor: "pointer" }}
/>
<span
style={{
display: "inline-block",
width: "12px",
height: "12px",
borderRadius: "50%",
backgroundColor: tag.color || "#666",
marginRight: "8px",
}}
/>
<span style={{ fontSize: "14px", color: "#333" }}>{tag.name}</span>
</label>
))}
</div>
) : (
<div
style={{
padding: "10px",
color: "#666",
fontSize: "14px",
fontStyle: "italic",
}}
>
No tags available. Create tags first to organize your collections.
</div>
)}
<p style={{ fontSize: "12px", color: "#666", marginTop: "5px" }}>
Select tags to organize this collection
</p>
</div>
{/* Description (Optional) */}
<div style={{ marginBottom: "30px" }}>
<label
htmlFor="description"
style={{
display: "block",
marginBottom: "8px",
fontWeight: "bold",
color: "#333",
}}
>
Description{" "}
<span style={{ fontSize: "12px", color: "#666", fontWeight: "normal" }}>
(optional)
</span>
</label>
<textarea
id="description"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Enter a description (optional)"
disabled={isLoading}
rows="4"
style={{
width: "100%",
padding: "10px 12px",
border: "1px solid #ddd",
borderRadius: "4px",
fontSize: "14px",
fontFamily: "inherit",
resize: "vertical",
}}
/>
<p style={{ fontSize: "12px", color: "#666", marginTop: "5px" }}>
Additional details about this collection
</p>
</div>
{/* Submit Buttons */}
<div className="nav-buttons" style={{ marginTop: "30px" }}>
<button
type="submit"
disabled={isLoading || !collectionName.trim()}
className="nav-button success"
>
{isLoading ? "Creating..." : "Create Collection"}
</button>
<button
type="button"
onClick={() => navigate(getBackUrl())}
disabled={isLoading}
className="nav-button secondary"
>
Cancel
</button>
</div>
</form>
</Page>
</div>
{/* Icon Picker Modal */}
<IconPicker
value={customIcon}
onChange={setCustomIcon}
onClose={() => setIsIconPickerOpen(false)}
isOpen={isIconPickerOpen}
/>
</div>
);
}
export default CollectionCreate;

View file

@ -0,0 +1,452 @@
// File Path: monorepo/native/desktop/maplefile/frontend/src/pages/User/FileManager/Collections/CollectionEdit.jsx
import { useState, useEffect, useCallback } from "react";
import { useNavigate, useParams } from "react-router-dom";
import Navigation from "../../../../components/Navigation";
import Page from "../../../../components/Page";
import IconPicker from "../../../../components/IconPicker";
import {
GetCollection,
UpdateCollection,
DeleteCollection,
} from "../../../../../wailsjs/go/app/Application";
import "../../Dashboard/Dashboard.css";
function CollectionEdit() {
const navigate = useNavigate();
const { collectionId } = useParams();
const [isLoading, setIsLoading] = useState(true);
const [isSaving, setIsSaving] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
const [error, setError] = useState("");
const [success, setSuccess] = useState("");
const [collection, setCollection] = useState(null);
const [name, setName] = useState("");
const [customIcon, setCustomIcon] = useState("");
const [collectionType, setCollectionType] = useState("folder");
const [isIconPickerOpen, setIsIconPickerOpen] = useState(false);
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
// Load collection details
const loadCollection = useCallback(async () => {
setIsLoading(true);
setError("");
try {
console.log("Loading collection details...", collectionId);
const collectionData = await GetCollection(collectionId);
console.log("Collection loaded:", collectionData);
setCollection(collectionData);
setName(collectionData.name || "");
setCustomIcon(collectionData.custom_icon || "");
setCollectionType(collectionData.collection_type || "folder");
} catch (err) {
console.error("Failed to load collection:", err);
setError(err.message || "Failed to load collection");
} finally {
setIsLoading(false);
}
}, [collectionId]);
// Initial load - wait for Wails to be ready
useEffect(() => {
if (typeof window !== 'undefined' && window.go && window.go.app && window.go.app.Application) {
loadCollection();
} else {
const checkWails = setInterval(() => {
if (window.go && window.go.app && window.go.app.Application) {
clearInterval(checkWails);
loadCollection();
}
}, 100);
setTimeout(() => clearInterval(checkWails), 10000);
return () => clearInterval(checkWails);
}
}, [loadCollection]);
// Handle save
const handleSave = async (e) => {
e.preventDefault();
if (!name.trim()) {
setError("Collection name is required");
return;
}
setIsSaving(true);
setError("");
setSuccess("");
try {
console.log("Updating collection...", collectionId, name, customIcon, collectionType);
await UpdateCollection(collectionId, {
name: name.trim(),
custom_icon: customIcon,
collection_type: collectionType,
});
setSuccess("Collection updated successfully!");
// Navigate back to collection details after a short delay
setTimeout(() => {
navigate(`/file-manager/collections/${collectionId}`, {
state: { refresh: true }
});
}, 1000);
} catch (err) {
console.error("Failed to update collection:", err);
setError(err.message || "Failed to update collection");
} finally {
setIsSaving(false);
}
};
// Handle delete
const handleDelete = async () => {
setIsDeleting(true);
setError("");
try {
console.log("Deleting collection...", collectionId);
await DeleteCollection(collectionId);
// Navigate to file manager after deletion
navigate("/file-manager", {
state: { refresh: true }
});
} catch (err) {
console.error("Failed to delete collection:", err);
setError(err.message || "Failed to delete collection");
setIsDeleting(false);
setShowDeleteConfirm(false);
}
};
// Handle cancel
const handleCancel = () => {
navigate(`/file-manager/collections/${collectionId}`);
};
if (isLoading) {
return (
<div className="layout">
<Navigation />
<div className="main-content">
<Page title="Loading...">
<div style={{ textAlign: "center", padding: "40px" }}>
<div>Loading collection...</div>
</div>
</Page>
</div>
</div>
);
}
return (
<div className="layout">
<Navigation />
<div className="main-content">
<Page title={`Edit Collection`}>
{/* Back Button */}
<button
onClick={handleCancel}
className="nav-button secondary"
style={{ marginBottom: "20px" }}
>
&larr; Back to Collection
</button>
{/* Error Display */}
{error && (
<div
style={{
padding: "15px",
background: "#ffebee",
color: "#c62828",
borderRadius: "4px",
marginBottom: "20px",
border: "1px solid #f44336",
}}
>
{error}
</div>
)}
{/* Success Display */}
{success && (
<div
style={{
padding: "15px",
background: "#e8f5e9",
color: "#2e7d32",
borderRadius: "4px",
marginBottom: "20px",
border: "1px solid #4caf50",
}}
>
{success}
</div>
)}
{/* Edit Form */}
<div
style={{
background: "white",
padding: "30px",
borderRadius: "8px",
border: "1px solid #ddd",
marginBottom: "30px",
}}
>
<form onSubmit={handleSave}>
<div style={{ marginBottom: "20px" }}>
<label
htmlFor="name"
style={{
display: "block",
marginBottom: "8px",
fontWeight: "bold",
}}
>
Collection Name
</label>
<input
type="text"
id="name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Enter collection name"
style={{
width: "100%",
padding: "12px",
border: "1px solid #ddd",
borderRadius: "4px",
fontSize: "16px",
}}
disabled={isSaving}
/>
</div>
{/* Custom Icon */}
<div style={{ marginBottom: "20px" }}>
<label
style={{
display: "block",
marginBottom: "8px",
fontWeight: "bold",
}}
>
Icon{" "}
<span style={{ fontSize: "12px", color: "#666", fontWeight: "normal" }}>
(optional)
</span>
</label>
<button
type="button"
onClick={() => setIsIconPickerOpen(true)}
disabled={isSaving}
style={{
display: "flex",
alignItems: "center",
gap: "10px",
padding: "10px 14px",
border: "1px solid #ddd",
borderRadius: "4px",
background: "white",
cursor: "pointer",
fontSize: "14px",
color: "#333",
transition: "all 0.15s ease",
}}
onMouseOver={(e) => {
e.currentTarget.style.borderColor = "#999";
e.currentTarget.style.background = "#f9f9f9";
}}
onMouseOut={(e) => {
e.currentTarget.style.borderColor = "#ddd";
e.currentTarget.style.background = "white";
}}
>
<span style={{ fontSize: "24px" }}>
{customIcon || "📁"}
</span>
<span>{customIcon ? "Change Icon" : "Choose Icon"}</span>
</button>
<p style={{ fontSize: "12px", color: "#666", marginTop: "5px" }}>
Select an emoji to customize your collection
</p>
</div>
{/* Collection Type */}
<div style={{ marginBottom: "20px" }}>
<label
style={{
display: "block",
marginBottom: "8px",
fontWeight: "bold",
}}
>
Type
</label>
<div style={{ display: "flex", gap: "10px" }}>
<button
type="button"
onClick={() => setCollectionType("folder")}
disabled={isSaving}
style={{
flex: 1,
display: "flex",
alignItems: "center",
justifyContent: "center",
gap: "8px",
padding: "12px",
border: collectionType === "folder" ? "2px solid #991b1b" : "1px solid #ddd",
borderRadius: "4px",
background: collectionType === "folder" ? "#fef2f2" : "white",
cursor: "pointer",
fontSize: "14px",
color: "#333",
transition: "all 0.15s ease",
}}
>
<span style={{ fontSize: "20px" }}>📁</span>
<span>Folder</span>
</button>
<button
type="button"
onClick={() => setCollectionType("album")}
disabled={isSaving}
style={{
flex: 1,
display: "flex",
alignItems: "center",
justifyContent: "center",
gap: "8px",
padding: "12px",
border: collectionType === "album" ? "2px solid #991b1b" : "1px solid #ddd",
borderRadius: "4px",
background: collectionType === "album" ? "#fef2f2" : "white",
cursor: "pointer",
fontSize: "14px",
color: "#333",
transition: "all 0.15s ease",
}}
>
<span style={{ fontSize: "20px" }}>🖼</span>
<span>Album</span>
</button>
</div>
<p style={{ fontSize: "12px", color: "#666", marginTop: "5px" }}>
Folders are for general files, albums are optimized for photos and media
</p>
</div>
{collection && (
<div
style={{
marginBottom: "20px",
padding: "15px",
background: "#f5f5f5",
borderRadius: "4px",
}}
>
<div style={{ fontSize: "14px", color: "#666" }}>
<div><strong>Collection ID:</strong> {collection.id}</div>
<div><strong>Files:</strong> {collection.file_count || 0}</div>
{collection.parent_id && (
<div><strong>Parent Collection:</strong> {collection.parent_id}</div>
)}
</div>
</div>
)}
<div className="nav-buttons">
<button
type="submit"
className="nav-button success"
disabled={isSaving || !name.trim()}
>
{isSaving ? "Saving..." : "Save Changes"}
</button>
<button
type="button"
onClick={handleCancel}
className="nav-button secondary"
disabled={isSaving}
>
Cancel
</button>
</div>
</form>
</div>
{/* Danger Zone */}
<div
style={{
background: "#fff5f5",
padding: "20px",
borderRadius: "8px",
border: "1px solid #f44336",
}}
>
<h3 style={{ color: "#c62828", marginBottom: "15px" }}>
Danger Zone
</h3>
<p style={{ marginBottom: "15px", color: "#666" }}>
Deleting a collection will move it to trash. This action can be
reversed from the trash within 30 days.
</p>
{!showDeleteConfirm ? (
<button
onClick={() => setShowDeleteConfirm(true)}
className="nav-button danger"
disabled={isDeleting}
>
Delete Collection
</button>
) : (
<div
style={{
background: "#ffebee",
padding: "15px",
borderRadius: "4px",
}}
>
<p style={{ marginBottom: "15px", fontWeight: "bold" }}>
Are you sure you want to delete "{collection?.name}"?
</p>
<div className="nav-buttons">
<button
onClick={handleDelete}
className="nav-button danger"
disabled={isDeleting}
>
{isDeleting ? "Deleting..." : "Yes, Delete"}
</button>
<button
onClick={() => setShowDeleteConfirm(false)}
className="nav-button secondary"
disabled={isDeleting}
>
Cancel
</button>
</div>
</div>
)}
</div>
</Page>
</div>
{/* Icon Picker Modal */}
<IconPicker
value={customIcon}
onChange={setCustomIcon}
onClose={() => setIsIconPickerOpen(false)}
isOpen={isIconPickerOpen}
/>
</div>
);
}
export default CollectionEdit;

View file

@ -0,0 +1,52 @@
// File Path: monorepo/native/desktop/maplefile/frontend/src/pages/User/FileManager/Collections/CollectionShare.jsx
import { Link, useParams } from "react-router-dom";
import Navigation from "../../../../components/Navigation";
import Page from "../../../../components/Page";
import "../../Dashboard/Dashboard.css";
function CollectionShare() {
const { collectionId } = useParams();
return (
<div className="layout">
<Navigation />
<div className="main-content">
<Page
title={`Share Collection (${collectionId})`}
showBackButton={true}
>
<p>Share this collection with other users.</p>
<h3>Sharing Options</h3>
<div className="nav-buttons">
<Link
to={`/file-manager/collections/${collectionId}`}
className="nav-button success"
>
Share (Read Only)
</Link>
<Link
to={`/file-manager/collections/${collectionId}`}
className="nav-button success"
>
Share (Read/Write)
</Link>
<Link
to={`/file-manager/collections/${collectionId}`}
className="nav-button success"
>
Share (Admin)
</Link>
<Link
to={`/file-manager/collections/${collectionId}`}
className="nav-button secondary"
>
Cancel
</Link>
</div>
</Page>
</div>
</div>
);
}
export default CollectionShare;

View file

@ -0,0 +1,604 @@
// File Path: monorepo/native/desktop/maplefile/frontend/src/pages/User/FileManager/FileManagerIndex.jsx
import { useState, useEffect, useCallback } from "react";
import { Link, useNavigate } from "react-router-dom";
import Navigation from "../../../components/Navigation";
import Page from "../../../components/Page";
import {
ListCollections,
GetSyncStatus,
TriggerSync,
ListTags,
} from "../../../../wailsjs/go/app/Application";
import "../Dashboard/Dashboard.css";
function FileManagerIndex() {
const navigate = useNavigate();
const [collections, setCollections] = useState([]);
const [syncStatus, setSyncStatus] = useState(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState("");
const [searchQuery, setSearchQuery] = useState("");
const [filterType, setFilterType] = useState("root"); // root, all, owned, shared
const [availableTags, setAvailableTags] = useState([]);
const [selectedTagIds, setSelectedTagIds] = useState([]);
const [showTagFilter, setShowTagFilter] = useState(false);
// Load collections from local storage (synced from backend)
const loadCollections = useCallback(async () => {
setIsLoading(true);
setError("");
try {
console.log("🔄 Loading collections...");
const collectionsData = await ListCollections();
console.log("✅ Collections loaded:", collectionsData);
console.log("📊 Collections count:", collectionsData ? collectionsData.length : 0);
if (collectionsData && collectionsData.length > 0) {
console.log("🔍 First collection data:", JSON.stringify(collectionsData[0], null, 2));
}
setCollections(collectionsData || []);
} catch (err) {
console.error("❌ Failed to load collections:", err);
setError(err.message || "Failed to load collections");
} finally {
setIsLoading(false);
}
}, []);
// Load sync status
const loadSyncStatus = useCallback(async () => {
try {
const status = await GetSyncStatus();
setSyncStatus(status);
} catch (err) {
console.error("Failed to load sync status:", err);
}
}, []);
// Load available tags
const loadTags = useCallback(async () => {
try {
const tags = await ListTags();
setAvailableTags(tags || []);
} catch (err) {
console.error("Failed to load tags:", err);
}
}, []);
// Handle manual sync trigger
const handleManualSync = async () => {
try {
await TriggerSync();
// Reload sync status after triggering
setTimeout(() => {
loadSyncStatus();
loadCollections();
}, 1000);
} catch (err) {
console.error("Failed to trigger sync:", err);
setError(err.message || "Failed to trigger sync");
}
};
// Initial load - wait for Wails to be ready
useEffect(() => {
// Check if Wails bindings are available
if (typeof window !== 'undefined' && window.go && window.go.app && window.go.app.Application) {
loadCollections();
loadSyncStatus();
loadTags();
} else {
// Wait for Wails to be ready
const checkWails = setInterval(() => {
if (window.go && window.go.app && window.go.app.Application) {
clearInterval(checkWails);
loadCollections();
loadSyncStatus();
loadTags();
}
}, 100);
// Cleanup timeout after 10 seconds
setTimeout(() => clearInterval(checkWails), 10000);
return () => clearInterval(checkWails);
}
}, [loadCollections, loadSyncStatus, loadTags]);
// Auto-refresh sync status every 5 seconds
useEffect(() => {
const interval = setInterval(() => {
if (window.go && window.go.app && window.go.app.Application) {
loadSyncStatus();
}
}, 5000);
return () => clearInterval(interval);
}, [loadSyncStatus]);
// Helper to check if a collection is a root collection (no parent)
const isRootCollection = (collection) => {
return !collection.parent_id || collection.parent_id === "00000000-0000-0000-0000-000000000000";
};
// Filter and search collections
const filteredCollections = collections.filter((collection) => {
// Apply filter type
if (filterType === "owned" && collection.is_shared) return false;
if (filterType === "shared" && !collection.is_shared) return false;
if (filterType === "root" && !isRootCollection(collection)) return false; // Only show root collections
// Apply tag filter (multi-tag filtering with AND logic)
if (selectedTagIds.length > 0) {
const collectionTagIds = (collection.tags || []).map(tag => tag.id);
// Check if collection has ALL selected tags
const hasAllTags = selectedTagIds.every(tagId => collectionTagIds.includes(tagId));
if (!hasAllTags) return false;
}
// Apply search query
if (searchQuery) {
const query = searchQuery.toLowerCase();
return collection.name.toLowerCase().includes(query) ||
(collection.description && collection.description.toLowerCase().includes(query));
}
return true;
});
console.log("🔍 Filtered collections:", filteredCollections);
console.log("📊 Total collections:", collections.length, "Filtered:", filteredCollections.length);
// Get time ago helper
const getTimeAgo = (dateString) => {
if (!dateString) return "Recently";
const now = new Date();
const date = new Date(dateString);
const diffInMinutes = Math.floor((now - date) / (1000 * 60));
if (diffInMinutes < 60) return "Just now";
if (diffInMinutes < 1440) return "Today";
if (diffInMinutes < 2880) return "Yesterday";
const diffInDays = Math.floor(diffInMinutes / 1440);
if (diffInDays < 7) return `${diffInDays} days ago`;
if (diffInDays < 30) return `${Math.floor(diffInDays / 7)} weeks ago`;
return `${Math.floor(diffInDays / 30)} months ago`;
};
return (
<div className="layout">
<Navigation />
<div className="main-content">
<Page title="File Manager">
{/* Sync Status Section */}
{syncStatus && (
<div
style={{
padding: "15px",
background: syncStatus.last_sync_success ? "#e8f5e9" : "#ffebee",
borderRadius: "4px",
marginBottom: "20px",
border: `1px solid ${syncStatus.last_sync_success ? "#4caf50" : "#f44336"}`,
}}
>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<div>
<strong>Sync Status:</strong>{" "}
{syncStatus.is_syncing ? (
<span> Syncing...</span>
) : syncStatus.last_sync_success ? (
<span> Up to date</span>
) : (
<span> Error</span>
)}
<div style={{ fontSize: "12px", marginTop: "5px", color: "#666" }}>
{syncStatus.last_sync_time && (
<div>Last sync: {getTimeAgo(syncStatus.last_sync_time)}</div>
)}
{syncStatus.collections_synced > 0 && (
<div>
Collections: {syncStatus.collections_synced}, Files: {syncStatus.files_synced}
</div>
)}
{syncStatus.last_sync_error && (
<div style={{ color: "#f44336", marginTop: "5px" }}>
Error: {syncStatus.last_sync_error}
</div>
)}
</div>
</div>
<button
onClick={handleManualSync}
disabled={syncStatus.is_syncing}
className="nav-button"
style={{ margin: 0 }}
>
{syncStatus.is_syncing ? "Syncing..." : "Sync Now"}
</button>
</div>
</div>
)}
{/* Error Display */}
{error && (
<div
style={{
padding: "15px",
background: "#ffebee",
color: "#c62828",
borderRadius: "4px",
marginBottom: "20px",
border: "1px solid #f44336",
}}
>
{error}
</div>
)}
{/* Search and Filter Bar */}
<div style={{ marginBottom: "20px" }}>
<div style={{ display: "flex", gap: "10px", marginBottom: "10px" }}>
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Search collections..."
style={{
flex: 1,
padding: "8px 12px",
border: "1px solid #ddd",
borderRadius: "4px",
}}
/>
<select
value={filterType}
onChange={(e) => setFilterType(e.target.value)}
style={{
padding: "8px 12px",
border: "1px solid #ddd",
borderRadius: "4px",
background: "white",
}}
>
<option value="all">All Collections</option>
<option value="root">Root Collections</option>
<option value="owned">My Collections</option>
<option value="shared">Shared with Me</option>
</select>
{availableTags.length > 0 && (
<button
onClick={() => setShowTagFilter(!showTagFilter)}
className="nav-button"
style={{
padding: "8px 16px",
background: selectedTagIds.length > 0 ? "#1976d2" : "white",
color: selectedTagIds.length > 0 ? "white" : "#333",
}}
>
🏷 Tags {selectedTagIds.length > 0 && `(${selectedTagIds.length})`}
</button>
)}
</div>
{/* Tag Filter Panel */}
{showTagFilter && availableTags.length > 0 && (
<div
style={{
padding: "15px",
background: "#f9f9f9",
border: "1px solid #ddd",
borderRadius: "4px",
marginTop: "10px",
}}
>
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
marginBottom: "10px",
}}
>
<strong>Filter by Tags</strong>
{selectedTagIds.length > 0 && (
<button
onClick={() => setSelectedTagIds([])}
style={{
background: "none",
border: "none",
color: "#1976d2",
cursor: "pointer",
fontSize: "12px",
textDecoration: "underline",
}}
>
Clear all
</button>
)}
</div>
<div
style={{
display: "flex",
flexWrap: "wrap",
gap: "8px",
}}
>
{availableTags.map((tag) => {
const isSelected = selectedTagIds.includes(tag.id);
return (
<button
key={tag.id}
onClick={() => {
if (isSelected) {
setSelectedTagIds(selectedTagIds.filter((id) => id !== tag.id));
} else {
setSelectedTagIds([...selectedTagIds, tag.id]);
}
}}
style={{
display: "inline-flex",
alignItems: "center",
gap: "6px",
padding: "6px 12px",
borderRadius: "16px",
fontSize: "13px",
backgroundColor: isSelected
? tag.color || "#666"
: "white",
color: isSelected ? "white" : tag.color || "#666",
border: `2px solid ${tag.color || "#666"}`,
cursor: "pointer",
transition: "all 0.2s ease",
}}
>
<span
style={{
display: "inline-block",
width: "8px",
height: "8px",
borderRadius: "50%",
backgroundColor: isSelected ? "white" : tag.color || "#666",
}}
/>
{tag.name}
</button>
);
})}
</div>
<div
style={{
fontSize: "12px",
color: "#666",
marginTop: "10px",
}}
>
{selectedTagIds.length === 0
? "Select one or more tags to filter collections"
: `Showing collections with ${selectedTagIds.length === 1 ? "this tag" : "all selected tags"}`}
</div>
</div>
)}
</div>
{/* Quick Actions */}
<div style={{ marginBottom: "30px" }}>
<h3>Quick Actions</h3>
<div className="nav-buttons">
<Link
to="/file-manager/collections/create"
className="nav-button success"
>
+ Create Collection
</Link>
<Link to="/file-manager/upload" className="nav-button success">
Upload File
</Link>
</div>
</div>
{/* Collections Grid */}
<h3>
{filterType === "all" && "All Collections"}
{filterType === "root" && "Root Collections"}
{filterType === "owned" && "My Collections"}
{filterType === "shared" && "Shared with Me"}
{searchQuery && ` (${filteredCollections.length} results)`}
</h3>
{isLoading ? (
<div style={{ textAlign: "center", padding: "40px" }}>
<div>Loading collections...</div>
</div>
) : filteredCollections.length === 0 ? (
<div
style={{
textAlign: "center",
padding: "40px",
background: "#f5f5f5",
borderRadius: "8px",
}}
>
<div style={{ fontSize: "48px", marginBottom: "10px" }}>📁</div>
<h4>
{searchQuery
? "No collections found"
: filterType === "shared"
? "No shared collections"
: "No collections yet"}
</h4>
<p style={{ color: "#666" }}>
{searchQuery
? `No collections match "${searchQuery}"`
: filterType === "shared"
? "When someone shares a collection with you, it will appear here"
: "Create your first collection to start organizing your files"}
</p>
{!searchQuery && filterType !== "shared" && (
<Link
to="/file-manager/collections/create"
className="nav-button success"
style={{ marginTop: "20px", display: "inline-block" }}
>
+ Create Your First Collection
</Link>
)}
</div>
) : (
<div className="dashboard-grid">
{filteredCollections.map((collection) => (
<div
key={collection.id}
className="dashboard-card"
style={{ cursor: "pointer", position: "relative" }}
onClick={() => navigate(`/file-manager/collections/${collection.id}`)}
>
{/* Collection Icon */}
<div
style={{
fontSize: "48px",
marginBottom: "10px",
}}
>
{collection.custom_icon
? collection.custom_icon
: collection.is_shared
? "🔗"
: collection.collection_type === "album"
? "🖼️"
: "📁"}
</div>
{/* Collection Name */}
<h3 style={{ marginBottom: "5px", color: "#333" }}>
{collection.name || "Unnamed Collection"}
</h3>
{/* Collection Description */}
{collection.description && (
<p
style={{
fontSize: "12px",
color: "#666",
marginBottom: "10px",
}}
>
{collection.description}
</p>
)}
{/* Collection Stats */}
<div
style={{
fontSize: "14px",
color: "#666",
marginTop: "10px",
paddingTop: "10px",
borderTop: "1px solid #eee",
}}
>
<div>
{collection.file_count || 0}{" "}
{collection.file_count === 1 ? "file" : "files"}
</div>
{/* Collection type badge */}
<div
style={{
fontSize: "11px",
color: "#888",
marginTop: "5px",
textTransform: "capitalize",
}}
>
{collection.collection_type || "folder"}
</div>
{collection.is_shared && (
<div
style={{
fontSize: "12px",
color: "#1976d2",
marginTop: "5px",
}}
>
Shared
</div>
)}
{!isRootCollection(collection) && (
<div
style={{
fontSize: "11px",
color: "#9c27b0",
marginTop: "5px",
}}
>
Sub-collection
</div>
)}
{collection.modified_at && (
<div style={{ fontSize: "12px", marginTop: "5px" }}>
{getTimeAgo(collection.modified_at)}
</div>
)}
</div>
{/* Tags */}
{collection.tags && collection.tags.length > 0 && (
<div
style={{
marginTop: "10px",
paddingTop: "10px",
borderTop: "1px solid #eee",
display: "flex",
flexWrap: "wrap",
gap: "5px",
}}
>
{collection.tags.slice(0, 3).map((tag) => (
<span
key={tag.id}
style={{
display: "inline-flex",
alignItems: "center",
gap: "4px",
padding: "2px 8px",
borderRadius: "12px",
fontSize: "11px",
backgroundColor: tag.color ? `${tag.color}20` : "#e0e0e0",
color: tag.color || "#666",
border: `1px solid ${tag.color || "#ccc"}`,
}}
>
<span
style={{
display: "inline-block",
width: "6px",
height: "6px",
borderRadius: "50%",
backgroundColor: tag.color || "#666",
}}
/>
{tag.name}
</span>
))}
{collection.tags.length > 3 && (
<span
style={{
fontSize: "11px",
color: "#666",
padding: "2px 8px",
}}
>
+{collection.tags.length - 3} more
</span>
)}
</div>
)}
</div>
))}
</div>
)}
</Page>
</div>
</div>
);
}
export default FileManagerIndex;

View file

@ -0,0 +1,762 @@
// File Path: monorepo/native/desktop/maplefile/frontend/src/pages/User/FileManager/Files/FileDetails.jsx
import { useState, useEffect, useCallback } from "react";
import { useNavigate, useParams } from "react-router-dom";
import Navigation from "../../../../components/Navigation";
import Page from "../../../../components/Page";
import {
GetFile,
DeleteFile,
DownloadFile,
OnloadFile,
OpenFile,
OffloadFile,
ListTags,
AssignTagToFile,
UnassignTagFromFile,
} from "../../../../../wailsjs/go/app/Application";
import "../../Dashboard/Dashboard.css";
function FileDetails() {
const navigate = useNavigate();
const { fileId } = useParams();
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState("");
const [file, setFile] = useState(null);
const [isDeleting, setIsDeleting] = useState(false);
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [isDownloading, setIsDownloading] = useState(false);
const [isOnloading, setIsOnloading] = useState(false);
const [isOpening, setIsOpening] = useState(false);
const [isOffloading, setIsOffloading] = useState(false);
const [showTagEditor, setShowTagEditor] = useState(false);
const [allTags, setAllTags] = useState([]);
const [isLoadingTags, setIsLoadingTags] = useState(false);
const [isUpdatingTags, setIsUpdatingTags] = useState(false);
// Load file details
const loadFile = useCallback(async () => {
setIsLoading(true);
setError("");
try {
console.log("Loading file details...", fileId);
const fileData = await GetFile(fileId);
console.log("File loaded:", fileData);
setFile(fileData);
} catch (err) {
console.error("Failed to load file:", err);
setError(err.message || "Failed to load file");
} finally {
setIsLoading(false);
}
}, [fileId]);
// Initial load - wait for Wails to be ready
useEffect(() => {
if (typeof window !== 'undefined' && window.go && window.go.app && window.go.app.Application) {
loadFile();
} else {
const checkWails = setInterval(() => {
if (window.go && window.go.app && window.go.app.Application) {
clearInterval(checkWails);
loadFile();
}
}, 100);
setTimeout(() => clearInterval(checkWails), 10000);
return () => clearInterval(checkWails);
}
}, [loadFile]);
// Helper functions
const formatFileSize = (bytes) => {
if (!bytes) return "0 B";
const sizes = ["B", "KB", "MB", "GB", "TB"];
const i = Math.floor(Math.log(bytes) / Math.log(1024));
return `${(bytes / Math.pow(1024, i)).toFixed(i > 0 ? 2 : 0)} ${sizes[i]}`;
};
const formatDate = (dateString) => {
if (!dateString) return "Unknown";
const date = new Date(dateString);
return date.toLocaleString();
};
const getTimeAgo = (dateString) => {
if (!dateString) return "Recently";
const now = new Date();
const date = new Date(dateString);
const diffInMinutes = Math.floor((now - date) / (1000 * 60));
if (diffInMinutes < 60) return "Just now";
if (diffInMinutes < 1440) return "Today";
if (diffInMinutes < 2880) return "Yesterday";
const diffInDays = Math.floor(diffInMinutes / 1440);
if (diffInDays < 7) return `${diffInDays} days ago`;
if (diffInDays < 30) return `${Math.floor(diffInDays / 7)} weeks ago`;
return `${Math.floor(diffInDays / 30)} months ago`;
};
const getStateIcon = (state) => {
switch (state) {
case "active":
return "[A]";
case "archived":
return "[R]";
case "deleted":
return "[D]";
default:
return "[?]";
}
};
const getStateLabel = (state) => {
switch (state) {
case "active":
return "Active";
case "archived":
return "Archived";
case "deleted":
return "Deleted";
default:
return "Unknown";
}
};
// Get sync status icon and color
const getSyncStatusInfo = (syncStatus) => {
switch (syncStatus) {
case "cloud_only":
return { icon: "☁️", color: "#2196f3", label: "Cloud Only", tooltip: "File is in the cloud, not downloaded locally" };
case "local_only":
return { icon: "💻", color: "#9c27b0", label: "Local Only", tooltip: "File exists only locally, not uploaded to cloud" };
case "synced":
return { icon: "✓", color: "#4caf50", label: "Synced", tooltip: "File is synchronized between local and cloud" };
case "modified_locally":
return { icon: "⬆️", color: "#ff9800", label: "Modified", tooltip: "Local changes pending upload to cloud" };
default:
return { icon: "☁️", color: "#2196f3", label: "Cloud Only", tooltip: "File is in the cloud" };
}
};
// Handle onload file (download for offline access)
const handleOnload = async () => {
setIsOnloading(true);
setError("");
try {
console.log("Onloading file for offline access:", fileId);
const result = await OnloadFile(fileId);
console.log("File onloaded:", result);
// Refresh file details to update sync status
await loadFile();
} catch (err) {
console.error("Failed to onload file:", err);
setError(err.message || "Failed to download file for offline access");
} finally {
setIsOnloading(false);
}
};
// Handle open file
const handleOpen = async () => {
setIsOpening(true);
setError("");
try {
console.log("Opening file:", fileId);
await OpenFile(fileId);
console.log("File opened");
} catch (err) {
console.error("Failed to open file:", err);
setError(err.message || "Failed to open file");
} finally {
setIsOpening(false);
}
};
// Handle offload file (remove local copy, keep in cloud)
const handleOffload = async () => {
setIsOffloading(true);
setError("");
try {
console.log("Offloading file to cloud-only:", fileId);
await OffloadFile(fileId);
console.log("File offloaded");
// Refresh file details to update sync status
await loadFile();
} catch (err) {
console.error("Failed to offload file:", err);
setError(err.message || "Failed to offload file");
} finally {
setIsOffloading(false);
}
};
const handleDownload = async () => {
setIsDownloading(true);
setError("");
try {
console.log("Starting file download and decryption for:", fileId);
const savePath = await DownloadFile(fileId);
console.log("File downloaded and decrypted to:", savePath);
// Show success message if user selected a location (didn't cancel)
if (savePath) {
// Could add a success toast/notification here
console.log("File saved successfully to:", savePath);
}
} catch (err) {
console.error("Failed to download file:", err);
setError(err.message || "Failed to download file");
} finally {
setIsDownloading(false);
}
};
const handleDelete = async () => {
setIsDeleting(true);
try {
console.log("Deleting file:", fileId);
await DeleteFile(fileId);
console.log("File deleted successfully");
// Navigate back to the collection
if (file?.collection_id) {
navigate(`/file-manager/collections/${file.collection_id}`, { state: { refresh: true } });
} else {
navigate("/file-manager", { state: { refresh: true } });
}
} catch (err) {
console.error("Failed to delete file:", err);
setError(err.message || "Failed to delete file");
setIsDeleting(false);
setShowDeleteConfirm(false);
}
};
// Load all available tags for the tag editor
const loadAllTags = async () => {
setIsLoadingTags(true);
try {
console.log("Loading all tags...");
const tags = await ListTags();
console.log("All tags loaded:", tags);
setAllTags(tags || []);
} catch (err) {
console.error("Failed to load tags:", err);
setAllTags([]);
} finally {
setIsLoadingTags(false);
}
};
// Open tag editor modal
const handleOpenTagEditor = async () => {
await loadAllTags();
setShowTagEditor(true);
};
// Check if a tag is assigned to the current file
const isTagAssigned = (tagId) => {
return file?.tags?.some((t) => t.id === tagId) || false;
};
// Handle tag toggle (assign or unassign)
const handleTagToggle = async (tagId) => {
setIsUpdatingTags(true);
try {
if (isTagAssigned(tagId)) {
console.log("Unassigning tag from file:", tagId, fileId);
await UnassignTagFromFile(tagId, fileId);
} else {
console.log("Assigning tag to file:", tagId, fileId);
await AssignTagToFile(tagId, fileId);
}
// Refresh file to update tags
await loadFile();
} catch (err) {
console.error("Failed to update tag:", err);
setError(err.message || "Failed to update tag");
} finally {
setIsUpdatingTags(false);
}
};
const getBackUrl = () => {
if (file?.collection_id) {
return `/file-manager/collections/${file.collection_id}`;
}
return "/file-manager";
};
if (isLoading) {
return (
<div className="layout">
<Navigation />
<div className="main-content">
<Page title="Loading...">
<div style={{ textAlign: "center", padding: "40px" }}>
<div>Loading file details...</div>
</div>
</Page>
</div>
</div>
);
}
return (
<div className="layout">
<Navigation />
<div className="main-content">
<Page title={file?.filename || "File Details"}>
{/* Back Button */}
<button
onClick={() => navigate(getBackUrl())}
className="nav-button secondary"
style={{ marginBottom: "20px" }}
>
Back to Collection
</button>
{/* Error Display */}
{error && (
<div
style={{
padding: "15px",
background: "#ffebee",
color: "#c62828",
borderRadius: "4px",
marginBottom: "20px",
border: "1px solid #f44336",
}}
>
{error}
</div>
)}
{/* File Info Card */}
{file && (
<div
style={{
background: "white",
padding: "20px",
borderRadius: "8px",
border: "1px solid #ddd",
marginBottom: "30px",
}}
>
<div style={{ display: "flex", alignItems: "flex-start", gap: "20px" }}>
<div style={{
width: "80px",
height: "80px",
background: "#f5f5f5",
borderRadius: "8px",
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: "14px",
fontWeight: "bold",
color: "#666",
textTransform: "uppercase",
flexShrink: 0
}}>
{file.mime_type?.split("/")[1]?.substring(0, 4) || "FILE"}
</div>
<div style={{ flex: 1 }}>
<h2 style={{ margin: 0, marginBottom: "10px", wordBreak: "break-word", color: "#333" }}>
{file.filename}
</h2>
{/* File Details Grid */}
<div style={{
display: "grid",
gridTemplateColumns: "repeat(auto-fit, minmax(200px, 1fr))",
gap: "15px",
marginTop: "15px"
}}>
<div>
<div style={{ color: "#666", fontSize: "12px", marginBottom: "4px" }}>SIZE</div>
<div style={{ fontWeight: "500", color: "#333" }}>{formatFileSize(file.size)}</div>
<div style={{ fontSize: "12px", color: "#888" }}>({file.size?.toLocaleString()} bytes)</div>
</div>
<div>
<div style={{ color: "#666", fontSize: "12px", marginBottom: "4px" }}>ENCRYPTED SIZE</div>
<div style={{ fontWeight: "500", color: "#333" }}>{formatFileSize(file.encrypted_file_size_in_bytes)}</div>
</div>
<div>
<div style={{ color: "#666", fontSize: "12px", marginBottom: "4px" }}>TYPE</div>
<div style={{ fontWeight: "500", color: "#333" }}>{file.mime_type || "Unknown"}</div>
</div>
<div>
<div style={{ color: "#666", fontSize: "12px", marginBottom: "4px" }}>STATUS</div>
<div style={{ fontWeight: "500" }}>
<span style={{
display: "inline-block",
padding: "2px 8px",
borderRadius: "4px",
fontSize: "12px",
background: file.state === "active" ? "#e8f5e9" : file.state === "archived" ? "#fff3e0" : "#ffebee",
color: file.state === "active" ? "#2e7d32" : file.state === "archived" ? "#ef6c00" : "#c62828"
}}>
{getStateIcon(file.state)} {getStateLabel(file.state)}
</span>
</div>
</div>
<div>
<div style={{ color: "#666", fontSize: "12px", marginBottom: "4px" }}>SYNC STATUS</div>
<div style={{ fontWeight: "500" }}>
{(() => {
const syncInfo = getSyncStatusInfo(file.sync_status);
return (
<span
title={syncInfo.tooltip}
style={{
display: "inline-flex",
alignItems: "center",
gap: "6px",
padding: "4px 10px",
borderRadius: "12px",
fontSize: "13px",
backgroundColor: `${syncInfo.color}20`,
color: syncInfo.color,
fontWeight: "500",
cursor: "help",
}}
>
{syncInfo.icon} {syncInfo.label}
</span>
);
})()}
</div>
</div>
{file.has_local_content && file.local_file_path && (
<div>
<div style={{ color: "#666", fontSize: "12px", marginBottom: "4px" }}>LOCAL PATH</div>
<div style={{
fontWeight: "500",
fontSize: "11px",
fontFamily: "monospace",
wordBreak: "break-all",
color: "#4caf50"
}}>
{file.local_file_path}
</div>
</div>
)}
<div>
<div style={{ color: "#666", fontSize: "12px", marginBottom: "4px" }}>CREATED</div>
<div style={{ fontWeight: "500", color: "#333" }}>{formatDate(file.created_at)}</div>
<div style={{ fontSize: "12px", color: "#888" }}>{getTimeAgo(file.created_at)}</div>
</div>
<div>
<div style={{ color: "#666", fontSize: "12px", marginBottom: "4px" }}>MODIFIED</div>
<div style={{ fontWeight: "500", color: "#333" }}>{formatDate(file.modified_at)}</div>
<div style={{ fontSize: "12px", color: "#888" }}>{getTimeAgo(file.modified_at)}</div>
</div>
<div>
<div style={{ color: "#666", fontSize: "12px", marginBottom: "4px" }}>VERSION</div>
<div style={{ fontWeight: "500", color: "#333" }}>{file.version}</div>
</div>
<div>
<div style={{ color: "#666", fontSize: "12px", marginBottom: "4px" }}>FILE ID</div>
<div style={{
fontWeight: "500",
fontSize: "11px",
fontFamily: "monospace",
wordBreak: "break-all",
color: "#333"
}}>
{file.id}
</div>
</div>
</div>
{/* Tags Section */}
<div style={{ marginTop: "20px", paddingTop: "15px", borderTop: "1px solid #eee" }}>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: "10px" }}>
<div style={{ color: "#666", fontSize: "12px", fontWeight: "bold" }}>TAGS</div>
<button
onClick={handleOpenTagEditor}
style={{
background: "none",
border: "1px solid #ddd",
borderRadius: "4px",
padding: "4px 10px",
fontSize: "12px",
cursor: "pointer",
color: "#666",
}}
>
Edit Tags
</button>
</div>
<div style={{ display: "flex", flexWrap: "wrap", gap: "8px" }}>
{file.tags && file.tags.length > 0 ? (
file.tags.map((tag) => (
<span
key={tag.id}
style={{
display: "inline-flex",
alignItems: "center",
gap: "6px",
padding: "4px 10px",
borderRadius: "16px",
fontSize: "13px",
backgroundColor: tag.color ? `${tag.color}20` : "#e3f2fd",
color: tag.color || "#1976d2",
fontWeight: "500",
}}
>
{tag.name}
</span>
))
) : (
<span style={{ color: "#999", fontSize: "13px", fontStyle: "italic" }}>
No tags assigned
</span>
)}
</div>
</div>
</div>
</div>
</div>
)}
{/* Actions */}
<div style={{ marginBottom: "30px" }}>
<h3 style={{ color: "#333" }}>Actions</h3>
<div className="nav-buttons">
{/* Show Onload button for cloud-only files */}
{file?.sync_status === "cloud_only" && (
<button
onClick={handleOnload}
className="nav-button success"
disabled={isOnloading}
title="Download file from cloud for offline access"
>
{isOnloading ? "Downloading..." : "☁️⬇️ Onload"}
</button>
)}
{/* Show Open button for files with local content */}
{(file?.sync_status === "synced" || file?.has_local_content) && (
<button
onClick={handleOpen}
className="nav-button"
disabled={isOpening}
title="Open file with default application"
>
{isOpening ? "Opening..." : "📂 Open"}
</button>
)}
{/* Show Offload button for synced files */}
{(file?.sync_status === "synced" || file?.has_local_content) && (
<button
onClick={handleOffload}
className="nav-button secondary"
disabled={isOffloading}
title="Remove local copy, keep in cloud only"
>
{isOffloading ? "Offloading..." : "☁️⬆️ Offload"}
</button>
)}
{/* Show Push button for modified files */}
{file?.sync_status === "modified_locally" && (
<button
onClick={() => {
// TODO: Implement push to cloud
console.log("Push changes for:", fileId);
}}
className="nav-button success"
title="Push local changes to cloud"
>
Push to Cloud
</button>
)}
{/* Save As button - always available */}
<button
onClick={handleDownload}
className="nav-button secondary"
disabled={isDownloading}
title="Save file to a specific location"
>
{isDownloading ? "Saving..." : "💾 Save As"}
</button>
{/* Delete button */}
<button
onClick={() => setShowDeleteConfirm(true)}
className="nav-button danger"
disabled={isDeleting}
>
🗑 Delete File
</button>
</div>
</div>
{/* Delete Confirmation Modal */}
{showDeleteConfirm && (
<div
style={{
position: "fixed",
top: 0,
left: 0,
right: 0,
bottom: 0,
background: "rgba(0, 0, 0, 0.5)",
display: "flex",
alignItems: "center",
justifyContent: "center",
zIndex: 1000,
}}
onClick={() => !isDeleting && setShowDeleteConfirm(false)}
>
<div
style={{
background: "white",
padding: "30px",
borderRadius: "8px",
maxWidth: "400px",
width: "90%",
}}
onClick={(e) => e.stopPropagation()}
>
<h3 style={{ margin: "0 0 15px 0", color: "#333" }}>Delete File?</h3>
<p style={{ color: "#666", marginBottom: "20px" }}>
Are you sure you want to delete "{file?.filename}"? This action cannot be undone.
</p>
<div style={{ display: "flex", gap: "10px", justifyContent: "flex-end" }}>
<button
onClick={() => setShowDeleteConfirm(false)}
className="nav-button secondary"
disabled={isDeleting}
>
Cancel
</button>
<button
onClick={handleDelete}
className="nav-button danger"
disabled={isDeleting}
>
{isDeleting ? "Deleting..." : "Delete"}
</button>
</div>
</div>
</div>
)}
{/* Tag Editor Modal */}
{showTagEditor && (
<div
style={{
position: "fixed",
top: 0,
left: 0,
right: 0,
bottom: 0,
background: "rgba(0, 0, 0, 0.5)",
display: "flex",
alignItems: "center",
justifyContent: "center",
zIndex: 1000,
}}
onClick={() => !isUpdatingTags && setShowTagEditor(false)}
>
<div
style={{
background: "white",
padding: "30px",
borderRadius: "8px",
maxWidth: "450px",
width: "90%",
maxHeight: "80vh",
overflow: "auto",
}}
onClick={(e) => e.stopPropagation()}
>
<h3 style={{ margin: "0 0 15px 0", color: "#333" }}>Manage Tags</h3>
<p style={{ color: "#666", marginBottom: "20px", fontSize: "14px" }}>
Click on a tag to add or remove it from this file.
</p>
{isLoadingTags ? (
<div style={{ textAlign: "center", padding: "20px", color: "#666" }}>
Loading tags...
</div>
) : allTags.length === 0 ? (
<div style={{ textAlign: "center", padding: "20px" }}>
<p style={{ color: "#666", marginBottom: "15px" }}>No tags found.</p>
<button
onClick={() => {
setShowTagEditor(false);
navigate("/tags");
}}
className="nav-button"
>
Create Tags
</button>
</div>
) : (
<div style={{ display: "flex", flexWrap: "wrap", gap: "10px", marginBottom: "20px" }}>
{allTags.map((tag) => {
const assigned = isTagAssigned(tag.id);
return (
<button
key={tag.id}
onClick={() => handleTagToggle(tag.id)}
disabled={isUpdatingTags}
style={{
display: "inline-flex",
alignItems: "center",
gap: "6px",
padding: "8px 14px",
borderRadius: "20px",
fontSize: "14px",
border: assigned ? `2px solid ${tag.color || "#1976d2"}` : "2px solid #ddd",
backgroundColor: assigned ? (tag.color ? `${tag.color}20` : "#e3f2fd") : "white",
color: tag.color || "#1976d2",
fontWeight: "500",
cursor: isUpdatingTags ? "not-allowed" : "pointer",
opacity: isUpdatingTags ? 0.6 : 1,
transition: "all 0.2s ease",
}}
>
{assigned && <span></span>}
{tag.name}
</button>
);
})}
</div>
)}
<div style={{ display: "flex", gap: "10px", justifyContent: "flex-end" }}>
<button
onClick={() => setShowTagEditor(false)}
className="nav-button secondary"
disabled={isUpdatingTags}
>
Done
</button>
</div>
</div>
</div>
)}
</Page>
</div>
</div>
);
}
export default FileDetails;

View file

@ -0,0 +1,539 @@
// File Path: monorepo/native/desktop/maplefile/frontend/src/pages/User/FileManager/Files/FileUpload.jsx
import { useState, useEffect } from "react";
import { useNavigate, useSearchParams } from "react-router-dom";
import Navigation from "../../../../components/Navigation";
import Page from "../../../../components/Page";
import {
SelectFile,
UploadFile,
ListCollections,
ListTags,
} from "../../../../../wailsjs/go/app/Application";
import "../../Dashboard/Dashboard.css";
function FileUpload() {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
// Get collection ID from URL params if provided
const preselectedCollectionId = searchParams.get("collection");
const [selectedFilePath, setSelectedFilePath] = useState("");
const [selectedCollectionId, setSelectedCollectionId] = useState(preselectedCollectionId || "");
const [collections, setCollections] = useState([]);
const [isLoadingCollections, setIsLoadingCollections] = useState(true);
const [isUploading, setIsUploading] = useState(false);
const [uploadProgress, setUploadProgress] = useState("");
const [error, setError] = useState("");
const [uploadResult, setUploadResult] = useState(null);
const [availableTags, setAvailableTags] = useState([]);
const [selectedTagIds, setSelectedTagIds] = useState([]);
const [isLoadingTags, setIsLoadingTags] = useState(false);
// Load available collections and tags
useEffect(() => {
const loadData = async () => {
try {
const collectionsData = await ListCollections();
setCollections(collectionsData || []);
} catch (err) {
console.error("Failed to load collections:", err);
setError("Failed to load collections");
} finally {
setIsLoadingCollections(false);
}
// Load tags
setIsLoadingTags(true);
try {
const tags = await ListTags();
setAvailableTags(tags || []);
} catch (err) {
console.error("Failed to load tags:", err);
setAvailableTags([]);
} finally {
setIsLoadingTags(false);
}
};
// Wait for Wails
if (typeof window !== 'undefined' && window.go && window.go.app && window.go.app.Application) {
loadData();
} else {
const checkWails = setInterval(() => {
if (window.go && window.go.app && window.go.app.Application) {
clearInterval(checkWails);
loadData();
}
}, 100);
setTimeout(() => clearInterval(checkWails), 10000);
return () => clearInterval(checkWails);
}
}, []);
const handleSelectFile = async () => {
try {
setError("");
const filePath = await SelectFile();
if (filePath) {
setSelectedFilePath(filePath);
console.log("File selected:", filePath);
}
} catch (err) {
console.error("Failed to select file:", err);
setError(err.message || "Failed to select file");
}
};
const handleUpload = async () => {
if (!selectedFilePath) {
setError("Please select a file to upload");
return;
}
if (!selectedCollectionId) {
setError("Please select a collection");
return;
}
setIsUploading(true);
setError("");
setUploadProgress("Preparing upload...");
setUploadResult(null);
try {
console.log("Starting upload:", {
filePath: selectedFilePath,
collectionId: selectedCollectionId,
});
setUploadProgress("Encrypting and uploading file...");
const result = await UploadFile({
file_path: selectedFilePath,
collection_id: selectedCollectionId,
tag_ids: selectedTagIds.length > 0 ? selectedTagIds : undefined,
});
console.log("Upload result:", result);
if (result.success) {
setUploadResult(result);
setUploadProgress("Upload complete!");
} else {
setError(result.message || "Upload failed");
setUploadProgress("");
}
} catch (err) {
console.error("Upload failed:", err);
setError(err.message || "Failed to upload file");
setUploadProgress("");
} finally {
setIsUploading(false);
}
};
const handleUploadAnother = () => {
setSelectedFilePath("");
setUploadResult(null);
setUploadProgress("");
setError("");
};
const formatFileSize = (bytes) => {
if (!bytes) return "0 B";
const sizes = ["B", "KB", "MB", "GB", "TB"];
const i = Math.floor(Math.log(bytes) / Math.log(1024));
return `${(bytes / Math.pow(1024, i)).toFixed(i > 0 ? 2 : 0)} ${sizes[i]}`;
};
const getFileName = (filePath) => {
if (!filePath) return "";
const parts = filePath.split(/[/\\]/);
return parts[parts.length - 1];
};
const getBackUrl = () => {
if (preselectedCollectionId) {
return `/file-manager/collections/${preselectedCollectionId}`;
}
return "/file-manager";
};
// Success state
if (uploadResult) {
return (
<div className="layout">
<Navigation />
<div className="main-content">
<Page title="Upload Complete">
<div
style={{
background: "#e8f5e9",
padding: "30px",
borderRadius: "8px",
textAlign: "center",
marginBottom: "30px",
}}
>
<div style={{ fontSize: "64px", marginBottom: "15px" }}>
[OK]
</div>
<h2 style={{ margin: "0 0 10px 0", color: "#2e7d32" }}>
File Uploaded Successfully!
</h2>
<p style={{ color: "#666", margin: "0" }}>
Your file has been encrypted and uploaded to the cloud.
</p>
</div>
<div
style={{
background: "white",
padding: "20px",
borderRadius: "8px",
border: "1px solid #ddd",
marginBottom: "30px",
}}
>
<h3 style={{ marginTop: 0 }}>Upload Details</h3>
<div style={{ display: "grid", gap: "10px" }}>
<div>
<span style={{ color: "#666" }}>Filename: </span>
<strong>{uploadResult.filename}</strong>
</div>
<div>
<span style={{ color: "#666" }}>Size: </span>
<strong>{formatFileSize(uploadResult.size)}</strong>
</div>
<div>
<span style={{ color: "#666" }}>File ID: </span>
<code style={{ fontSize: "12px" }}>{uploadResult.file_id}</code>
</div>
</div>
</div>
<div className="nav-buttons">
<button
onClick={() => navigate(`/file-manager/files/${uploadResult.file_id}`)}
className="nav-button success"
>
View File
</button>
<button
onClick={handleUploadAnother}
className="nav-button"
>
Upload Another
</button>
<button
onClick={() => navigate(getBackUrl(), { state: { refresh: true } })}
className="nav-button secondary"
>
Back to Collection
</button>
</div>
</Page>
</div>
</div>
);
}
return (
<div className="layout">
<Navigation />
<div className="main-content">
<Page title="Upload File">
{/* Back Button */}
<button
onClick={() => navigate(getBackUrl())}
className="nav-button secondary"
style={{ marginBottom: "20px" }}
>
Cancel
</button>
{/* Error Display */}
{error && (
<div
style={{
padding: "15px",
background: "#ffebee",
color: "#c62828",
borderRadius: "4px",
marginBottom: "20px",
border: "1px solid #f44336",
}}
>
{error}
</div>
)}
{/* Upload Form */}
<div
style={{
background: "white",
padding: "30px",
borderRadius: "8px",
border: "1px solid #ddd",
marginBottom: "30px",
}}
>
{/* Step 1: Select Collection */}
<div style={{ marginBottom: "25px" }}>
<label
style={{
display: "block",
fontWeight: "bold",
marginBottom: "8px",
}}
>
1. Select Collection
</label>
{isLoadingCollections ? (
<div style={{ color: "#666" }}>Loading collections...</div>
) : (
<select
value={selectedCollectionId}
onChange={(e) => setSelectedCollectionId(e.target.value)}
disabled={isUploading}
style={{
width: "100%",
padding: "10px 12px",
border: "1px solid #ddd",
borderRadius: "4px",
fontSize: "14px",
backgroundColor: isUploading ? "#f5f5f5" : "white",
}}
>
<option value="">-- Select a collection --</option>
{collections.map((col) => (
<option key={col.id} value={col.id}>
{col.name || "Unnamed Collection"}
</option>
))}
</select>
)}
{collections.length === 0 && !isLoadingCollections && (
<p style={{ color: "#666", fontSize: "14px", marginTop: "8px" }}>
No collections found.{" "}
<button
onClick={() => navigate("/file-manager/collections/create")}
style={{
background: "none",
border: "none",
color: "#1976d2",
cursor: "pointer",
textDecoration: "underline",
padding: 0,
}}
>
Create one first
</button>
</p>
)}
</div>
{/* Step 2: Select File */}
<div style={{ marginBottom: "25px" }}>
<label
style={{
display: "block",
fontWeight: "bold",
marginBottom: "8px",
}}
>
2. Select File
</label>
<div style={{ display: "flex", gap: "10px", alignItems: "center" }}>
<button
onClick={handleSelectFile}
disabled={isUploading}
className="nav-button"
style={{ flexShrink: 0 }}
>
Browse...
</button>
<div
style={{
flex: 1,
padding: "10px 12px",
border: "1px solid #ddd",
borderRadius: "4px",
background: "#f9f9f9",
color: selectedFilePath ? "#333" : "#999",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{selectedFilePath ? getFileName(selectedFilePath) : "No file selected"}
</div>
</div>
{selectedFilePath && (
<div style={{ fontSize: "12px", color: "#666", marginTop: "5px" }}>
Full path: {selectedFilePath}
</div>
)}
</div>
{/* Step 3: Select Tags (Optional) */}
<div style={{ marginBottom: "25px" }}>
<label
style={{
display: "block",
fontWeight: "bold",
marginBottom: "8px",
}}
>
3. Tags{" "}
<span style={{ fontSize: "12px", fontWeight: "normal", color: "#666" }}>
(optional)
</span>
</label>
{isLoadingTags ? (
<div style={{ color: "#666" }}>Loading tags...</div>
) : availableTags.length > 0 ? (
<div
style={{
border: "1px solid #ddd",
borderRadius: "4px",
padding: "10px",
maxHeight: "150px",
overflowY: "auto",
}}
>
{availableTags.map((tag) => (
<label
key={tag.id}
style={{
display: "flex",
alignItems: "center",
padding: "6px",
cursor: "pointer",
borderRadius: "4px",
transition: "background-color 0.15s ease",
}}
onMouseOver={(e) => {
e.currentTarget.style.backgroundColor = "#f9f9f9";
}}
onMouseOut={(e) => {
e.currentTarget.style.backgroundColor = "transparent";
}}
>
<input
type="checkbox"
checked={selectedTagIds.includes(tag.id)}
onChange={(e) => {
if (e.target.checked) {
setSelectedTagIds([...selectedTagIds, tag.id]);
} else {
setSelectedTagIds(selectedTagIds.filter((id) => id !== tag.id));
}
}}
disabled={isUploading}
style={{ marginRight: "8px", cursor: "pointer" }}
/>
<span
style={{
display: "inline-block",
width: "12px",
height: "12px",
borderRadius: "50%",
backgroundColor: tag.color || "#666",
marginRight: "8px",
}}
/>
<span style={{ fontSize: "14px", color: "#333" }}>{tag.name}</span>
</label>
))}
</div>
) : (
<div
style={{
padding: "10px",
color: "#666",
fontSize: "14px",
fontStyle: "italic",
}}
>
No tags available
</div>
)}
</div>
{/* Upload Progress */}
{uploadProgress && (
<div
style={{
padding: "15px",
background: "#e3f2fd",
color: "#1565c0",
borderRadius: "4px",
marginBottom: "20px",
display: "flex",
alignItems: "center",
gap: "10px",
}}
>
<div
style={{
width: "20px",
height: "20px",
border: "2px solid #1565c0",
borderTopColor: "transparent",
borderRadius: "50%",
animation: "spin 1s linear infinite",
}}
/>
{uploadProgress}
</div>
)}
{/* Upload Button */}
<button
onClick={handleUpload}
disabled={isUploading || !selectedFilePath || !selectedCollectionId}
className="nav-button success"
style={{
width: "100%",
padding: "15px",
fontSize: "16px",
opacity: (isUploading || !selectedFilePath || !selectedCollectionId) ? 0.6 : 1,
}}
>
{isUploading ? "Uploading..." : "Encrypt & Upload File"}
</button>
</div>
{/* Info Box */}
<div
style={{
background: "#f5f5f5",
padding: "20px",
borderRadius: "8px",
fontSize: "14px",
color: "#666",
}}
>
<h4 style={{ marginTop: 0, color: "#333" }}>How it works</h4>
<ul style={{ margin: 0, paddingLeft: "20px" }}>
<li>Your file is encrypted locally before being uploaded</li>
<li>Only you and people you share with can decrypt it</li>
<li>The server never sees your unencrypted data</li>
<li>Files are stored securely in the cloud</li>
</ul>
</div>
{/* Inline CSS for spinner animation */}
<style>{`
@keyframes spin {
to { transform: rotate(360deg); }
}
`}</style>
</Page>
</div>
</div>
);
}
export default FileUpload;

View file

@ -0,0 +1,32 @@
// File Path: monorepo/native/desktop/maplefile/frontend/src/pages/User/FileManager/SearchResults.jsx
import { Link } from "react-router-dom";
import Navigation from "../../../components/Navigation";
import Page from "../../../components/Page";
import "../Dashboard/Dashboard.css";
function SearchResults() {
return (
<div className="layout">
<Navigation />
<div className="main-content">
<Page title="Search Files" showBackButton={true}>
<p>Search your encrypted files.</p>
<h3>Search Results (Demo)</h3>
<div className="nav-buttons">
<Link to="/file-manager/files/abc" className="nav-button secondary">
Result 1: document.pdf
</Link>
<Link to="/file-manager/files/def" className="nav-button secondary">
Result 2: image.png
</Link>
<Link to="/file-manager/files/ghi" className="nav-button secondary">
Result 3: report.docx
</Link>
</div>
</Page>
</div>
</div>
);
}
export default SearchResults;

View file

@ -0,0 +1,35 @@
// File Path: monorepo/native/desktop/maplefile/frontend/src/pages/User/FileManager/TrashView.jsx
import { Link } from "react-router-dom";
import Navigation from "../../../components/Navigation";
import Page from "../../../components/Page";
import "../Dashboard/Dashboard.css";
function TrashView() {
return (
<div className="layout">
<Navigation />
<div className="main-content">
<Page title="Trash" showBackButton={true}>
<p>Deleted files and collections.</p>
<h3>Deleted Items (Demo)</h3>
<div className="nav-buttons">
<Link to="/file-manager" className="nav-button secondary">
Restore: old_file.pdf
</Link>
<Link to="/file-manager" className="nav-button secondary">
Restore: archive.zip
</Link>
</div>
<h3>Actions</h3>
<div className="nav-buttons">
<Link to="/file-manager" className="nav-button danger">
Empty Trash
</Link>
</div>
</Page>
</div>
</div>
);
}
export default TrashView;

View file

@ -0,0 +1,339 @@
// File Path: monorepo/native/desktop/maplefile/frontend/src/pages/User/Me/BlockedUsers.jsx
import { useState, useEffect } from "react";
import { Link } from "react-router-dom";
import Navigation from "../../../components/Navigation";
import Page from "../../../components/Page";
import { GetBlockedEmails, AddBlockedEmail, RemoveBlockedEmail } from "../../../../wailsjs/go/app/Application";
function BlockedUsers() {
const [blockedEmails, setBlockedEmails] = useState([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState("");
const [success, setSuccess] = useState("");
// Add form state
const [newEmail, setNewEmail] = useState("");
const [addLoading, setAddLoading] = useState(false);
const [addError, setAddError] = useState("");
// Delete state
const [deleteLoading, setDeleteLoading] = useState("");
useEffect(() => {
// Wait for Wails runtime to be ready
let attempts = 0;
const maxAttempts = 50;
let isCancelled = false;
const checkWailsReady = () => {
if (isCancelled) return;
if (window.go && window.go.app && window.go.app.Application) {
loadBlockedEmails();
} else if (attempts < maxAttempts) {
attempts++;
setTimeout(checkWailsReady, 100);
} else {
if (!isCancelled) {
setIsLoading(false);
setError("Failed to initialize. Please refresh the page.");
}
}
};
checkWailsReady();
return () => {
isCancelled = true;
};
}, []);
const loadBlockedEmails = async () => {
try {
setIsLoading(true);
setError("");
if (!window.go || !window.go.app || !window.go.app.Application) {
throw new Error("Application not ready");
}
console.log("Loading blocked emails...");
const emails = await GetBlockedEmails();
console.log("Blocked emails loaded:", emails);
setBlockedEmails(emails || []);
} catch (err) {
console.error("Failed to load blocked emails:", err);
setError(err.message || "Failed to load blocked users");
} finally {
setIsLoading(false);
}
};
const handleAddEmail = async (e) => {
e.preventDefault();
if (!newEmail.trim()) return;
setAddLoading(true);
setAddError("");
setSuccess("");
try {
console.log("Adding blocked email:", newEmail);
await AddBlockedEmail(newEmail.trim(), "Blocked via UI");
setSuccess(`${newEmail} has been blocked successfully.`);
setNewEmail("");
await loadBlockedEmails();
} catch (err) {
console.error("Failed to add blocked email:", err);
setAddError(err.message || "Failed to block email");
} finally {
setAddLoading(false);
}
};
const handleRemoveEmail = async (email) => {
setDeleteLoading(email);
setError("");
setSuccess("");
try {
console.log("Removing blocked email:", email);
await RemoveBlockedEmail(email);
setSuccess(`${email} has been unblocked.`);
await loadBlockedEmails();
} catch (err) {
console.error("Failed to remove blocked email:", err);
setError(err.message || "Failed to unblock email");
} finally {
setDeleteLoading("");
}
};
const formatDate = (dateString) => {
if (!dateString) return "N/A";
try {
const date = new Date(dateString);
if (isNaN(date.getTime())) return "Invalid Date";
return date.toLocaleDateString("en-US", {
year: "numeric",
month: "long",
day: "numeric",
});
} catch (error) {
return "Invalid Date";
}
};
return (
<div className="layout">
<Navigation />
<div className="main-content">
<Page title="Blocked Users">
{/* Breadcrumb */}
<div style={{ marginBottom: "20px", fontSize: "14px", color: "#6b7280" }}>
<Link to="/dashboard" style={{ color: "#3b82f6", textDecoration: "none" }}>Dashboard</Link>
<span style={{ margin: "0 8px" }}></span>
<Link to="/me" style={{ color: "#3b82f6", textDecoration: "none" }}>My Profile</Link>
<span style={{ margin: "0 8px" }}></span>
<span style={{ color: "#2c3e50", fontWeight: "500" }}>Blocked Users</span>
</div>
{/* Header */}
<div style={{
background: "white",
padding: "30px",
borderRadius: "12px",
marginBottom: "20px",
boxShadow: "0 2px 4px rgba(0,0,0,0.1)",
}}>
<div style={{ display: "flex", alignItems: "center" }}>
<div style={{
width: "50px",
height: "50px",
borderRadius: "12px",
background: "#fee2e2",
display: "flex",
alignItems: "center",
justifyContent: "center",
marginRight: "15px",
}}>
<span style={{ fontSize: "24px" }}>🚫</span>
</div>
<div>
<h1 style={{ margin: 0, color: "#2c3e50", fontSize: "24px" }}>Blocked Users</h1>
<p style={{ margin: "5px 0 0 0", color: "#7f8c8d" }}>
Manage users who cannot share folders with you
</p>
</div>
</div>
</div>
{/* Success message */}
{success && (
<div style={{
marginBottom: "20px",
padding: "15px",
background: "#d4edda",
border: "1px solid #c3e6cb",
borderRadius: "8px",
display: "flex",
alignItems: "center",
}}>
<span style={{ fontSize: "20px", marginRight: "10px" }}></span>
<p style={{ margin: 0, color: "#155724" }}>{success}</p>
</div>
)}
{/* Error message */}
{error && (
<div style={{
marginBottom: "20px",
padding: "15px",
background: "#f8d7da",
border: "1px solid #f5c6cb",
borderRadius: "8px",
display: "flex",
alignItems: "center",
}}>
<span style={{ fontSize: "20px", marginRight: "10px" }}></span>
<p style={{ margin: 0, color: "#721c24" }}>{error}</p>
</div>
)}
{/* Add Email Form */}
<div style={{
background: "white",
padding: "30px",
borderRadius: "12px",
marginBottom: "20px",
boxShadow: "0 2px 4px rgba(0,0,0,0.1)",
}}>
<h2 style={{ marginTop: 0, color: "#2c3e50", fontSize: "18px" }}>Block a User</h2>
<p style={{ color: "#7f8c8d", fontSize: "14px", marginBottom: "15px" }}>
Enter the email address of a user you want to block. They will not be able to share folders with you.
</p>
<form onSubmit={handleAddEmail} style={{ display: "flex", gap: "10px" }}>
<div style={{ flex: 1 }}>
<input
type="email"
value={newEmail}
onChange={(e) => {
setNewEmail(e.target.value);
if (addError) setAddError("");
}}
placeholder="Enter email address to block"
style={{
width: "100%",
padding: "10px",
borderRadius: "8px",
border: "1px solid #ddd",
fontSize: "14px",
}}
disabled={addLoading}
required
/>
{addError && (
<p style={{ margin: "5px 0 0 0", fontSize: "14px", color: "#e74c3c" }}>{addError}</p>
)}
</div>
<button
type="submit"
className="nav-button"
disabled={addLoading || !newEmail.trim()}
style={{ whiteSpace: "nowrap" }}
>
{addLoading ? "Adding..." : " Block"}
</button>
</form>
</div>
{/* Blocked Emails List */}
<div style={{
background: "white",
padding: "30px",
borderRadius: "12px",
boxShadow: "0 2px 4px rgba(0,0,0,0.1)",
}}>
<h2 style={{ marginTop: 0, color: "#2c3e50", fontSize: "18px" }}>
Blocked Users ({blockedEmails.length})
</h2>
{isLoading ? (
<div style={{ textAlign: "center", padding: "40px" }}>
<p style={{ color: "#7f8c8d" }}>Loading blocked users...</p>
</div>
) : blockedEmails.length === 0 ? (
<div style={{ textAlign: "center", padding: "40px" }}>
<span style={{ fontSize: "48px" }}>🚫</span>
<p style={{ color: "#7f8c8d", margin: "10px 0 0 0" }}>No blocked users</p>
<p style={{ fontSize: "14px", color: "#95a5a6", margin: "5px 0 0 0" }}>
Users you block won't be able to share folders with you.
</p>
</div>
) : (
<div style={{ display: "flex", flexDirection: "column", gap: "10px" }}>
{blockedEmails.map((blocked) => (
<div
key={blocked.blocked_email}
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
padding: "15px",
background: "#f8f9fa",
borderRadius: "8px",
border: "1px solid #e9ecef",
}}
>
<div style={{ display: "flex", alignItems: "center" }}>
<span style={{ fontSize: "20px", marginRight: "12px" }}></span>
<div>
<p style={{ margin: 0, fontWeight: "500", color: "#2c3e50" }}>
{blocked.blocked_email}
</p>
<p style={{ margin: "3px 0 0 0", fontSize: "12px", color: "#7f8c8d" }}>
Blocked on {formatDate(blocked.created_at)}
</p>
</div>
</div>
<button
onClick={() => handleRemoveEmail(blocked.blocked_email)}
className="nav-button danger"
disabled={deleteLoading === blocked.blocked_email}
style={{ whiteSpace: "nowrap" }}
>
{deleteLoading === blocked.blocked_email ? "Removing..." : "🗑️ Unblock"}
</button>
</div>
))}
</div>
)}
</div>
{/* Info section */}
<div style={{
marginTop: "20px",
padding: "20px",
background: "#e3f2fd",
border: "1px solid #bbdefb",
borderRadius: "8px",
}}>
<h3 style={{ marginTop: 0, fontSize: "16px", color: "#1565c0" }}>
How blocking works
</h3>
<ul style={{ margin: 0, paddingLeft: "20px", color: "#1976d2", fontSize: "14px" }}>
<li>Blocked users cannot share folders or files with you</li>
<li>You can still share folders with blocked users</li>
<li>Blocking is private - users are not notified when blocked</li>
<li>Existing shares are not affected when you block someone</li>
</ul>
</div>
</Page>
</div>
</div>
);
}
export default BlockedUsers;

View file

@ -0,0 +1,621 @@
// File Path: monorepo/native/desktop/maplefile/frontend/src/pages/User/Me/DeleteAccount.jsx
import { useState, useEffect } from "react";
import { useNavigate, Link } from "react-router-dom";
import Navigation from "../../../components/Navigation";
import Page from "../../../components/Page";
import { GetUserProfile, DeleteAccount as DeleteAccountAPI } from "../../../../wailsjs/go/app/Application";
function DeleteAccount() {
const navigate = useNavigate();
// State management
const [currentStep, setCurrentStep] = useState(1);
const [password, setPassword] = useState("");
const [confirmText, setConfirmText] = useState("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const [userEmail, setUserEmail] = useState("");
const [acknowledgments, setAcknowledgments] = useState({
permanentDeletion: false,
dataLoss: false,
gdprRights: false,
});
// Load user info
useEffect(() => {
let attempts = 0;
const maxAttempts = 50;
let isCancelled = false;
const checkWailsReady = () => {
if (isCancelled) return;
if (window.go && window.go.app && window.go.app.Application) {
loadUserInfo();
} else if (attempts < maxAttempts) {
attempts++;
setTimeout(checkWailsReady, 100);
}
};
checkWailsReady();
return () => {
isCancelled = true;
};
}, []);
const loadUserInfo = async () => {
try {
const user = await GetUserProfile();
setUserEmail(user.email);
} catch (err) {
console.error("Failed to load user info:", err);
setError("Failed to load your account information.");
}
};
// Check if all acknowledgments are checked
const allAcknowledged =
acknowledgments.permanentDeletion &&
acknowledgments.dataLoss &&
acknowledgments.gdprRights;
// Check if confirmation text matches
const confirmationMatches = confirmText === "DELETE MY ACCOUNT";
// Handle checkbox change
const handleAcknowledgmentChange = (key) => {
setAcknowledgments((prev) => ({
...prev,
[key]: !prev[key],
}));
};
// Handle back button
const handleBack = () => {
if (currentStep === 1) {
navigate("/me");
} else {
setCurrentStep(currentStep - 1);
setError("");
}
};
// Handle next step
const handleNext = () => {
setError("");
setCurrentStep(currentStep + 1);
};
// Handle account deletion
const handleDeleteAccount = async () => {
if (!password) {
setError("Please enter your password to confirm deletion.");
return;
}
if (!allAcknowledged) {
setError("Please acknowledge all statements before proceeding.");
return;
}
if (!confirmationMatches) {
setError('Please type "DELETE MY ACCOUNT" exactly to confirm.');
return;
}
setLoading(true);
setError("");
try {
await DeleteAccountAPI(password);
setCurrentStep(4); // Success step
// Wait 3 seconds then redirect to login
setTimeout(() => {
navigate("/login");
}, 3000);
} catch (err) {
console.error("Account deletion failed:", err);
const errorMessage = err.message || err.toString();
if (errorMessage.includes("Invalid") || errorMessage.includes("password")) {
setError("Invalid password. Please try again.");
} else if (errorMessage.includes("permission")) {
setError("You do not have permission to delete this account.");
} else {
setError("Failed to delete account. Please try again or contact support.");
}
} finally {
setLoading(false);
}
};
// Render Step 1: Warning and Information
const renderStep1 = () => (
<div style={{ display: "flex", flexDirection: "column", gap: "20px" }}>
{/* Warning Banner */}
<div style={{
background: "#fee2e2",
borderLeft: "4px solid #ef4444",
padding: "20px",
borderRadius: "8px",
}}>
<div style={{ display: "flex", alignItems: "start" }}>
<span style={{ fontSize: "24px", marginRight: "15px" }}></span>
<div>
<h3 style={{ margin: "0 0 10px 0", color: "#991b1b", fontSize: "18px" }}>
This action is permanent and cannot be undone
</h3>
<p style={{ margin: 0, color: "#7f1d1d" }}>
Once you delete your account, all your data will be permanently removed from our servers. This includes all files, collections, and personal information.
</p>
</div>
</div>
</div>
{/* What will be deleted */}
<div style={{
background: "white",
padding: "25px",
borderRadius: "12px",
border: "1px solid #e5e7eb",
boxShadow: "0 1px 3px rgba(0,0,0,0.1)",
}}>
<h3 style={{ margin: "0 0 20px 0", color: "#2c3e50", fontSize: "18px", display: "flex", alignItems: "center" }}>
<span style={{ marginRight: "10px" }}>🗑</span>
What will be deleted
</h3>
<div style={{ display: "flex", flexDirection: "column", gap: "15px" }}>
{[
{ title: "All your files", desc: "Every file you've uploaded will be permanently deleted from our servers" },
{ title: "All your collections", desc: "All folders and collections you own will be permanently removed" },
{ title: "Personal information", desc: "Your profile, email, and all associated data will be deleted" },
{ title: "Shared access", desc: "You'll be removed from any collections shared with you" },
{ title: "Storage usage history", desc: "All your storage metrics and usage history will be deleted" }
].map((item, idx) => (
<div key={idx} style={{ display: "flex", alignItems: "start" }}>
<span style={{ fontSize: "20px", color: "#ef4444", marginRight: "12px" }}></span>
<div>
<strong style={{ color: "#2c3e50" }}>{item.title}</strong>
<p style={{ margin: "3px 0 0 0", fontSize: "14px", color: "#6b7280" }}>{item.desc}</p>
</div>
</div>
))}
</div>
</div>
{/* GDPR Information */}
<div style={{
background: "#dbeafe",
border: "1px solid #93c5fd",
padding: "20px",
borderRadius: "8px",
}}>
<div style={{ display: "flex", alignItems: "start" }}>
<span style={{ fontSize: "20px", marginRight: "12px" }}></span>
<div>
<h4 style={{ margin: "0 0 8px 0", fontSize: "14px", fontWeight: "600", color: "#1e3a8a" }}>
Your Right to Erasure (GDPR Article 17)
</h4>
<p style={{ margin: 0, fontSize: "14px", color: "#1e40af" }}>
This deletion process complies with GDPR regulations. All your personal data will be permanently erased from our systems within moments of confirmation. This action cannot be reversed.
</p>
</div>
</div>
</div>
{/* Action Buttons */}
<div style={{ display: "flex", justifyContent: "space-between", paddingTop: "15px" }}>
<button onClick={handleBack} className="nav-button secondary">
Cancel
</button>
<button onClick={handleNext} className="nav-button danger">
Continue to Confirmation
</button>
</div>
</div>
);
// Render Step 2: Acknowledgments
const renderStep2 = () => (
<div style={{ display: "flex", flexDirection: "column", gap: "20px" }}>
<div style={{
background: "#fef3c7",
borderLeft: "4px solid #f59e0b",
padding: "20px",
borderRadius: "8px",
}}>
<div style={{ display: "flex", alignItems: "start" }}>
<span style={{ fontSize: "24px", marginRight: "15px" }}>🛡</span>
<div>
<h3 style={{ margin: "0 0 10px 0", color: "#78350f", fontSize: "18px" }}>
Please confirm you understand
</h3>
<p style={{ margin: 0, color: "#92400e" }}>
Before proceeding, you must acknowledge the following statements about your account deletion.
</p>
</div>
</div>
</div>
{/* Acknowledgment Checkboxes */}
<div style={{
background: "white",
padding: "25px",
borderRadius: "12px",
border: "1px solid #e5e7eb",
boxShadow: "0 1px 3px rgba(0,0,0,0.1)",
display: "flex",
flexDirection: "column",
gap: "20px",
}}>
<div style={{ display: "flex", alignItems: "start" }}>
<input
type="checkbox"
id="ack-permanent"
checked={acknowledgments.permanentDeletion}
onChange={() => handleAcknowledgmentChange("permanentDeletion")}
style={{ marginTop: "3px", marginRight: "12px", width: "18px", height: "18px" }}
/>
<label htmlFor="ack-permanent" style={{ fontSize: "14px", color: "#374151", cursor: "pointer" }}>
<strong style={{ color: "#2c3e50" }}>
I understand that this deletion is permanent and irreversible.
</strong>{" "}
Once deleted, my account and all associated data cannot be recovered.
</label>
</div>
<div style={{ display: "flex", alignItems: "start" }}>
<input
type="checkbox"
id="ack-data"
checked={acknowledgments.dataLoss}
onChange={() => handleAcknowledgmentChange("dataLoss")}
style={{ marginTop: "3px", marginRight: "12px", width: "18px", height: "18px" }}
/>
<label htmlFor="ack-data" style={{ fontSize: "14px", color: "#374151", cursor: "pointer" }}>
<strong style={{ color: "#2c3e50" }}>
I understand that all my files and collections will be permanently deleted.
</strong>{" "}
I have downloaded or backed up any important data I wish to keep.
</label>
</div>
<div style={{ display: "flex", alignItems: "start" }}>
<input
type="checkbox"
id="ack-gdpr"
checked={acknowledgments.gdprRights}
onChange={() => handleAcknowledgmentChange("gdprRights")}
style={{ marginTop: "3px", marginRight: "12px", width: "18px", height: "18px" }}
/>
<label htmlFor="ack-gdpr" style={{ fontSize: "14px", color: "#374151", cursor: "pointer" }}>
<strong style={{ color: "#2c3e50" }}>
I am exercising my right to erasure under GDPR Article 17.
</strong>{" "}
I understand that this will result in the immediate and complete deletion of all my personal data from MapleFile servers.
</label>
</div>
</div>
{/* Current Account */}
<div style={{
background: "#f9fafb",
border: "1px solid #e5e7eb",
padding: "15px",
borderRadius: "8px",
}}>
<p style={{ margin: 0, fontSize: "14px", color: "#6b7280" }}>
Account to be deleted:{" "}
<strong style={{ color: "#2c3e50" }}>{userEmail}</strong>
</p>
</div>
{/* Action Buttons */}
<div style={{ display: "flex", justifyContent: "space-between", paddingTop: "15px" }}>
<button onClick={handleBack} className="nav-button secondary">
Back
</button>
<button
onClick={handleNext}
disabled={!allAcknowledged}
className="nav-button danger"
style={{ opacity: allAcknowledged ? 1 : 0.5, cursor: allAcknowledged ? "pointer" : "not-allowed" }}
>
Continue to Final Step
</button>
</div>
</div>
);
// Render Step 3: Final Confirmation
const renderStep3 = () => (
<div style={{ display: "flex", flexDirection: "column", gap: "20px" }}>
<div style={{
background: "#fee2e2",
border: "2px solid #ef4444",
padding: "20px",
borderRadius: "12px",
}}>
<div style={{ display: "flex", alignItems: "start" }}>
<span style={{ fontSize: "32px", marginRight: "15px" }}></span>
<div>
<h3 style={{ margin: "0 0 10px 0", color: "#7f1d1d", fontSize: "20px" }}>
Final Confirmation Required
</h3>
<p style={{ margin: 0, color: "#991b1b" }}>
This is your last chance to cancel. After clicking "Delete My Account", your data will be permanently erased.
</p>
</div>
</div>
</div>
{/* Password Confirmation */}
<div style={{
background: "white",
padding: "25px",
borderRadius: "12px",
border: "1px solid #e5e7eb",
boxShadow: "0 1px 3px rgba(0,0,0,0.1)",
display: "flex",
flexDirection: "column",
gap: "20px",
}}>
<div>
<label htmlFor="password" style={{ display: "block", fontSize: "14px", fontWeight: "500", color: "#374151", marginBottom: "8px" }}>
<span style={{ marginRight: "8px" }}>🔒</span>
Enter your password to confirm
</label>
<input
type="password"
id="password"
value={password}
onChange={(e) => {
setPassword(e.target.value);
setError("");
}}
style={{
width: "100%",
padding: "10px",
border: "1px solid #d1d5db",
borderRadius: "8px",
fontSize: "14px",
}}
placeholder="Your account password"
disabled={loading}
/>
</div>
<div>
<label htmlFor="confirm-text" style={{ display: "block", fontSize: "14px", fontWeight: "500", color: "#374151", marginBottom: "8px" }}>
Type <strong>"DELETE MY ACCOUNT"</strong> to confirm (exactly as shown)
</label>
<input
type="text"
id="confirm-text"
value={confirmText}
onChange={(e) => {
setConfirmText(e.target.value);
setError("");
}}
style={{
width: "100%",
padding: "10px",
border: "1px solid #d1d5db",
borderRadius: "8px",
fontSize: "14px",
fontFamily: "monospace",
}}
placeholder="DELETE MY ACCOUNT"
disabled={loading}
/>
{confirmText && !confirmationMatches && (
<p style={{ margin: "5px 0 0 0", fontSize: "14px", color: "#dc2626" }}>
Text must match exactly: "DELETE MY ACCOUNT"
</p>
)}
</div>
</div>
{/* Error Display */}
{error && (
<div style={{
background: "#fee2e2",
border: "1px solid #fecaca",
padding: "15px",
borderRadius: "8px",
}}>
<div style={{ display: "flex", alignItems: "start" }}>
<span style={{ fontSize: "20px", color: "#dc2626", marginRight: "12px" }}></span>
<p style={{ margin: 0, fontSize: "14px", color: "#991b1b" }}>{error}</p>
</div>
</div>
)}
{/* Action Buttons */}
<div style={{ display: "flex", justifyContent: "space-between", paddingTop: "15px" }}>
<button
onClick={handleBack}
disabled={loading}
className="nav-button secondary"
style={{ opacity: loading ? 0.5 : 1 }}
>
Back
</button>
<button
onClick={handleDeleteAccount}
disabled={loading || !password || !confirmationMatches || !allAcknowledged}
className="nav-button danger"
style={{
opacity: (loading || !password || !confirmationMatches || !allAcknowledged) ? 0.5 : 1,
cursor: (loading || !password || !confirmationMatches || !allAcknowledged) ? "not-allowed" : "pointer",
}}
>
{loading ? "🔄 Deleting Account..." : "🗑️ Delete My Account"}
</button>
</div>
</div>
);
// Render Step 4: Success
const renderStep4 = () => (
<div style={{ display: "flex", flexDirection: "column", gap: "20px" }}>
<div style={{
background: "#d1fae5",
border: "2px solid #10b981",
padding: "40px",
borderRadius: "12px",
textAlign: "center",
}}>
<span style={{ fontSize: "64px" }}></span>
<h3 style={{ margin: "15px 0 10px 0", color: "#065f46", fontSize: "24px" }}>
Account Deleted Successfully
</h3>
<p style={{ margin: "0 0 15px 0", color: "#047857" }}>
Your account and all associated data have been permanently deleted from our servers.
</p>
<p style={{ margin: 0, fontSize: "14px", color: "#059669" }}>
You will be redirected to the login page in a few seconds...
</p>
</div>
<div style={{
background: "#dbeafe",
border: "1px solid #93c5fd",
padding: "20px",
borderRadius: "8px",
}}>
<div style={{ display: "flex", alignItems: "start" }}>
<span style={{ fontSize: "20px", marginRight: "12px" }}></span>
<div style={{ fontSize: "14px", color: "#1e40af" }}>
<strong style={{ color: "#1e3a8a" }}>Thank you for using MapleFile.</strong>
<p style={{ margin: "8px 0 0 0" }}>
If you have any feedback or concerns, please contact our support team. You're always welcome to create a new account in the future.
</p>
</div>
</div>
</div>
</div>
);
// Progress indicator
const renderProgressIndicator = () => {
if (currentStep === 4) return null;
const steps = [
{ number: 1, label: "Warning" },
{ number: 2, label: "Acknowledgment" },
{ number: 3, label: "Confirmation" },
];
return (
<div style={{ marginBottom: "30px" }}>
<div style={{ display: "flex", alignItems: "center" }}>
{steps.map((step, index) => (
<div key={step.number} style={{ display: "flex", alignItems: "center", flex: index < steps.length - 1 ? 1 : "initial" }}>
<div style={{ display: "flex", flexDirection: "column", alignItems: "center" }}>
<div style={{
width: "40px",
height: "40px",
borderRadius: "50%",
border: `2px solid ${currentStep >= step.number ? "#dc2626" : "#d1d5db"}`,
background: currentStep >= step.number ? "#dc2626" : "white",
color: currentStep >= step.number ? "white" : "#9ca3af",
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: "16px",
fontWeight: "600",
}}>
{currentStep > step.number ? "✓" : step.number}
</div>
<span style={{
marginTop: "8px",
fontSize: "12px",
fontWeight: currentStep >= step.number ? "600" : "normal",
color: currentStep >= step.number ? "#dc2626" : "#6b7280",
whiteSpace: "nowrap",
}}>
{step.label}
</span>
</div>
{index < steps.length - 1 && (
<div style={{
flex: 1,
height: "2px",
background: currentStep > step.number ? "#dc2626" : "#d1d5db",
marginBottom: "24px",
marginLeft: "15px",
marginRight: "15px",
}} />
)}
</div>
))}
</div>
</div>
);
};
return (
<div className="layout">
<Navigation />
<div className="main-content">
<Page title="Delete Account">
<div style={{ maxWidth: "800px", margin: "0 auto" }}>
{/* Breadcrumb */}
<div style={{ marginBottom: "20px", fontSize: "14px", color: "#6b7280" }}>
<Link to="/dashboard" style={{ color: "#3b82f6", textDecoration: "none" }}>Dashboard</Link>
<span style={{ margin: "0 8px" }}></span>
<Link to="/me" style={{ color: "#3b82f6", textDecoration: "none" }}>My Profile</Link>
<span style={{ margin: "0 8px" }}></span>
<span style={{ color: "#2c3e50", fontWeight: "500" }}>Delete Account</span>
</div>
{/* Header */}
<div style={{ marginBottom: "30px" }}>
<h1 style={{ margin: 0, color: "#2c3e50", fontSize: "28px", display: "flex", alignItems: "center" }}>
<span style={{ fontSize: "32px", marginRight: "12px" }}>🗑</span>
Delete Account
</h1>
<p style={{ margin: "8px 0 0 0", color: "#6b7280" }}>
Permanently delete your MapleFile account and all associated data
</p>
</div>
{/* Progress Indicator */}
{renderProgressIndicator()}
{/* Main Content */}
<div style={{
background: "white",
padding: "30px",
borderRadius: "12px",
boxShadow: "0 4px 6px rgba(0,0,0,0.1)",
}}>
{currentStep === 1 && renderStep1()}
{currentStep === 2 && renderStep2()}
{currentStep === 3 && renderStep3()}
{currentStep === 4 && renderStep4()}
</div>
{/* Footer Notice */}
{currentStep !== 4 && (
<div style={{ marginTop: "20px", textAlign: "center", fontSize: "14px", color: "#6b7280" }}>
<p style={{ margin: 0 }}>
Need help? Contact our support team at{" "}
<a href="mailto:support@maplefile.com" style={{ color: "#3b82f6", textDecoration: "none" }}>
support@maplefile.com
</a>
</p>
</div>
)}
</div>
</Page>
</div>
</div>
);
}
export default DeleteAccount;

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,391 @@
// File Path: monorepo/native/desktop/maplefile/frontend/src/pages/User/Search/FullTextSearch.jsx
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import Navigation from "../../../components/Navigation";
import Page from "../../../components/Page";
// Utility function to format file sizes
const formatFileSize = (bytes) => {
if (!bytes) return "0 B";
const sizes = ["B", "KB", "MB", "GB"];
const i = Math.floor(Math.log(bytes) / Math.log(1024));
return `${Math.round(bytes / Math.pow(1024, i))} ${sizes[i]}`;
};
const getTimeAgo = (dateString) => {
if (!dateString) return "Recently";
const now = new Date();
const date = new Date(dateString);
const diffInMinutes = Math.floor((now - date) / (1000 * 60));
if (diffInMinutes < 60) return "Just now";
if (diffInMinutes < 1440) return "Today";
if (diffInMinutes < 2880) return "Yesterday";
const diffInDays = Math.floor(diffInMinutes / 1440);
if (diffInDays < 7) return `${diffInDays} days ago`;
if (diffInDays < 30) return `${Math.floor(diffInDays / 7)} weeks ago`;
return `${Math.floor(diffInDays / 30)} months ago`;
};
function FullTextSearch() {
const navigate = useNavigate();
const [searchQuery, setSearchQuery] = useState("");
const [searchResults, setSearchResults] = useState(null);
const [isSearching, setIsSearching] = useState(false);
const [error, setError] = useState("");
const handleSearch = async (e) => {
e.preventDefault();
if (!searchQuery.trim()) {
setError("Please enter a search query");
return;
}
try {
setIsSearching(true);
setError("");
setSearchResults(null);
const { Search } = await import("../../../../wailsjs/go/app/Application");
const results = await Search({
query: searchQuery.trim(),
limit: 50,
});
setSearchResults(results);
if (results.total_files === 0 && results.total_collections === 0) {
setError(`No results found for "${searchQuery}"`);
}
} catch (err) {
console.error("Search failed:", err);
setError(err.message || "Search failed. Please try again.");
} finally {
setIsSearching(false);
}
};
const handleFileClick = (fileId) => {
navigate(`/file-manager/files/${fileId}`);
};
const handleCollectionClick = (collectionId) => {
navigate(`/file-manager/collections/${collectionId}`);
};
return (
<div className="layout">
<Navigation />
<div className="main-content">
<Page title="Full-Text Search">
{/* Search Form */}
<div style={{ marginBottom: "30px" }}>
<form onSubmit={handleSearch}>
<div style={{ display: "flex", gap: "12px" }}>
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Search files and collections..."
style={{
flex: 1,
padding: "12px 16px",
border: "1px solid #ddd",
borderRadius: "4px",
fontSize: "14px",
color: "#333",
}}
disabled={isSearching}
/>
<button
type="submit"
disabled={isSearching || !searchQuery.trim()}
className="nav-button success"
style={{ padding: "12px 24px" }}
>
{isSearching ? "Searching..." : "🔍 Search"}
</button>
</div>
{/* Search Tips */}
<div style={{ marginTop: "10px", fontSize: "13px", color: "#666" }}>
<p style={{ margin: 0 }}>
<strong>Search tips:</strong> Use quotes for exact phrases (e.g., "project report"),
+ for AND logic, - to exclude terms
</p>
</div>
</form>
</div>
{/* Error Message */}
{error && (
<div
style={{
padding: "15px",
background: "#ffebee",
color: "#c62828",
borderRadius: "4px",
marginBottom: "20px",
border: "1px solid #f44336",
}}
>
{error}
</div>
)}
{/* Search Results */}
{searchResults && (
<>
{/* Results Summary */}
<div
style={{
padding: "15px 20px",
background: "#e3f2fd",
color: "#1976d2",
borderRadius: "4px",
marginBottom: "30px",
border: "1px solid #90caf9",
}}
>
<p style={{ margin: 0, fontWeight: "500" }}>
Found <strong>{searchResults.total_files}</strong> file(s) and{" "}
<strong>{searchResults.total_collections}</strong> collection(s)
{searchResults.total_hits > 0 && (
<span> ({searchResults.total_hits} total matches)</span>
)}
</p>
</div>
{/* Files Section */}
{searchResults.files && searchResults.files.length > 0 && (
<div style={{ marginBottom: "40px" }}>
<h3 style={{ color: "#333", marginBottom: "15px" }}>
Files ({searchResults.total_files})
</h3>
<div
style={{
background: "white",
borderRadius: "8px",
border: "1px solid #ddd",
overflow: "hidden",
}}
>
<table style={{ width: "100%", borderCollapse: "collapse" }}>
<thead style={{ background: "#f5f5f5", borderBottom: "1px solid #ddd" }}>
<tr>
<th style={{ padding: "12px", textAlign: "left", fontWeight: "bold", color: "#333" }}>
File Name
</th>
<th style={{ padding: "12px", textAlign: "left", fontWeight: "bold", color: "#333" }}>
Collection
</th>
<th style={{ padding: "12px", textAlign: "left", fontWeight: "bold", color: "#333" }}>
Size
</th>
<th style={{ padding: "12px", textAlign: "left", fontWeight: "bold", color: "#333" }}>
Created
</th>
<th style={{ padding: "12px", textAlign: "left", fontWeight: "bold", color: "#333" }}>
Tags
</th>
</tr>
</thead>
<tbody>
{searchResults.files.map((file) => (
<tr
key={file.id}
style={{
borderBottom: "1px solid #eee",
cursor: "pointer",
}}
onClick={() => handleFileClick(file.id)}
onMouseEnter={(e) => e.currentTarget.style.backgroundColor = "#f9f9f9"}
onMouseLeave={(e) => e.currentTarget.style.backgroundColor = "white"}
>
<td style={{ padding: "12px" }}>
<div style={{ display: "flex", alignItems: "center", gap: "10px" }}>
<span style={{ fontSize: "24px" }}>📄</span>
<span style={{ color: "#333" }}>{file.filename}</span>
</div>
</td>
<td style={{ padding: "12px" }}>
{file.collection_name ? (
<div style={{ display: "flex", alignItems: "center", gap: "6px", color: "#666" }}>
<svg style={{ width: "16px", height: "16px" }} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" />
</svg>
<span>{file.collection_name}</span>
</div>
) : (
<span style={{ color: "#ccc" }}>-</span>
)}
</td>
<td style={{ padding: "12px", color: "#666" }}>
{formatFileSize(file.size)}
</td>
<td style={{ padding: "12px", color: "#666" }}>
{getTimeAgo(file.created_at)}
</td>
<td style={{ padding: "12px" }}>
<div style={{ display: "flex", flexWrap: "wrap", gap: "4px" }}>
{file.tags && file.tags.length > 0 ? (
file.tags.slice(0, 3).map((tag, idx) => (
<span
key={idx}
style={{
display: "inline-block",
padding: "2px 8px",
borderRadius: "12px",
fontSize: "11px",
backgroundColor: "#e3f2fd",
color: "#1976d2",
fontWeight: "500",
whiteSpace: "nowrap",
}}
>
{tag}
</span>
))
) : (
<span style={{ color: "#ccc", fontSize: "11px" }}>-</span>
)}
{file.tags && file.tags.length > 3 && (
<span style={{ color: "#999", fontSize: "11px" }}>
+{file.tags.length - 3}
</span>
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
{/* Collections Section */}
{searchResults.collections && searchResults.collections.length > 0 && (
<div style={{ marginBottom: "40px" }}>
<h3 style={{ color: "#333", marginBottom: "15px" }}>
Collections ({searchResults.total_collections})
</h3>
<div
style={{
background: "white",
borderRadius: "8px",
border: "1px solid #ddd",
overflow: "hidden",
}}
>
<table style={{ width: "100%", borderCollapse: "collapse" }}>
<thead style={{ background: "#f5f5f5", borderBottom: "1px solid #ddd" }}>
<tr>
<th style={{ padding: "12px", textAlign: "left", fontWeight: "bold", color: "#333" }}>
Collection Name
</th>
<th style={{ padding: "12px", textAlign: "center", fontWeight: "bold", color: "#333" }}>
Files
</th>
<th style={{ padding: "12px", textAlign: "left", fontWeight: "bold", color: "#333" }}>
Created
</th>
<th style={{ padding: "12px", textAlign: "left", fontWeight: "bold", color: "#333" }}>
Tags
</th>
</tr>
</thead>
<tbody>
{searchResults.collections.map((collection) => (
<tr
key={collection.id}
style={{
borderBottom: "1px solid #eee",
cursor: "pointer",
}}
onClick={() => handleCollectionClick(collection.id)}
onMouseEnter={(e) => e.currentTarget.style.backgroundColor = "#f9f9f9"}
onMouseLeave={(e) => e.currentTarget.style.backgroundColor = "white"}
>
<td style={{ padding: "12px" }}>
<div style={{ display: "flex", alignItems: "center", gap: "10px" }}>
<span style={{ fontSize: "24px" }}>📁</span>
<span style={{ color: "#333" }}>{collection.name}</span>
</div>
</td>
<td style={{ padding: "12px", textAlign: "center", color: "#666" }}>
{collection.file_count}
</td>
<td style={{ padding: "12px", color: "#666" }}>
{getTimeAgo(collection.created_at)}
</td>
<td style={{ padding: "12px" }}>
<div style={{ display: "flex", flexWrap: "wrap", gap: "4px" }}>
{collection.tags && collection.tags.length > 0 ? (
collection.tags.slice(0, 3).map((tag, idx) => (
<span
key={idx}
style={{
display: "inline-block",
padding: "2px 8px",
borderRadius: "12px",
fontSize: "11px",
backgroundColor: "#e3f2fd",
color: "#1976d2",
fontWeight: "500",
whiteSpace: "nowrap",
}}
>
{tag}
</span>
))
) : (
<span style={{ color: "#ccc", fontSize: "11px" }}>-</span>
)}
{collection.tags && collection.tags.length > 3 && (
<span style={{ color: "#999", fontSize: "11px" }}>
+{collection.tags.length - 3}
</span>
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
</>
)}
{/* Empty State - No Search Yet */}
{!searchResults && !error && (
<div
style={{
textAlign: "center",
padding: "60px 20px",
background: "#f5f5f5",
borderRadius: "8px",
}}
>
<div style={{ fontSize: "64px", marginBottom: "15px" }}>
🔍
</div>
<h3 style={{ marginBottom: "10px", color: "#333" }}>
Search Your Files and Collections
</h3>
<p style={{ color: "#666", maxWidth: "500px", margin: "0 auto" }}>
Enter a search query above to find files and collections. You can search by filename,
collection name, or tags.
</p>
</div>
)}
</Page>
</div>
</div>
);
}
export default FullTextSearch;

View file

@ -0,0 +1,235 @@
// File Path: monorepo/native/desktop/maplefile/frontend/src/pages/User/Tags/TagCreate.jsx
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import Navigation from "../../../components/Navigation";
import Page from "../../../components/Page";
import {
CreateTag,
} from "../../../../wailsjs/go/app/Application";
function TagCreate() {
const navigate = useNavigate();
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState("");
const [formData, setFormData] = useState({
name: "",
color: "#3B82F6"
});
const [formErrors, setFormErrors] = useState({});
const handleSubmit = async (e) => {
e.preventDefault();
// Validate
const errors = {};
if (!formData.name.trim()) {
errors.name = "Tag name is required";
}
if (!formData.color) {
errors.color = "Tag color is required";
}
if (Object.keys(errors).length > 0) {
setFormErrors(errors);
return;
}
try {
setIsLoading(true);
setError("");
setFormErrors({});
await CreateTag(formData.name.trim(), formData.color);
// Navigate back to profile page with tags tab selected
navigate("/me?tab=tags");
} catch (err) {
console.error("Failed to create tag:", err);
setError(err.message || "Failed to create tag");
} finally {
setIsLoading(false);
}
};
return (
<Page>
<Navigation />
<div style={{ maxWidth: "800px", margin: "0 auto", padding: "20px" }}>
{/* Header */}
<div style={{ marginBottom: "30px" }}>
<button
onClick={() => navigate("/me?tab=tags")}
className="nav-button secondary"
style={{ marginBottom: "16px" }}
>
Back to Tags
</button>
<h1 style={{ margin: "0 0 8px 0", fontSize: "28px", fontWeight: "600" }}>
Create Tag
</h1>
<p style={{ margin: 0, color: "#666" }}>
Create a new tag to organize your files and collections
</p>
</div>
{/* Error Alert */}
{error && (
<div style={{
padding: "12px",
marginBottom: "20px",
backgroundColor: "#fee",
border: "1px solid #fcc",
borderRadius: "4px",
color: "#c00"
}}>
{error}
</div>
)}
{/* Form */}
<div style={{
backgroundColor: "#fff",
padding: "24px",
borderRadius: "8px",
border: "1px solid #e0e0e0",
boxShadow: "0 1px 3px rgba(0,0,0,0.1)"
}}>
<form onSubmit={handleSubmit}>
{/* Tag Name */}
<div style={{ marginBottom: "20px" }}>
<label
htmlFor="tag_name"
style={{
display: "block",
marginBottom: "8px",
fontWeight: "500",
fontSize: "14px"
}}
>
Tag Name <span style={{ color: "#c00" }}>*</span>
</label>
<input
id="tag_name"
type="text"
value={formData.name}
onChange={(e) => {
setFormData({ ...formData, name: e.target.value });
setFormErrors({ ...formErrors, name: "" });
}}
placeholder="e.g., Important, Work, Personal"
maxLength={50}
required
disabled={isLoading}
style={{
width: "100%",
padding: "10px 12px",
fontSize: "14px",
border: formErrors.name ? "1px solid #c00" : "1px solid #ccc",
borderRadius: "4px",
outline: "none",
boxSizing: "border-box"
}}
/>
{formErrors.name && (
<p style={{ margin: "6px 0 0 0", fontSize: "13px", color: "#c00" }}>
{formErrors.name}
</p>
)}
</div>
{/* Tag Color */}
<div style={{ marginBottom: "24px" }}>
<label
style={{
display: "block",
marginBottom: "8px",
fontWeight: "500",
fontSize: "14px"
}}
>
Tag Color <span style={{ color: "#c00" }}>*</span>
</label>
<div style={{ display: "flex", gap: "16px", alignItems: "center" }}>
<input
type="color"
value={formData.color}
onChange={(e) => {
setFormData({ ...formData, color: e.target.value });
setFormErrors({ ...formErrors, color: "" });
}}
disabled={isLoading}
style={{
width: "80px",
height: "48px",
border: "2px solid #ccc",
borderRadius: "6px",
cursor: "pointer"
}}
/>
<div style={{ flex: 1 }}>
<input
type="text"
value={formData.color}
onChange={(e) => {
setFormData({ ...formData, color: e.target.value });
setFormErrors({ ...formErrors, color: "" });
}}
placeholder="#3B82F6"
maxLength={7}
disabled={isLoading}
style={{
width: "100%",
padding: "10px 12px",
fontSize: "14px",
border: formErrors.color ? "1px solid #c00" : "1px solid #ccc",
borderRadius: "4px",
outline: "none",
fontFamily: "monospace",
boxSizing: "border-box"
}}
/>
</div>
</div>
{formErrors.color && (
<p style={{ margin: "6px 0 0 0", fontSize: "13px", color: "#c00" }}>
{formErrors.color}
</p>
)}
<p style={{ margin: "8px 0 0 0", fontSize: "12px", color: "#666" }}>
Choose a color to identify this tag visually
</p>
</div>
{/* Buttons */}
<div style={{ display: "flex", gap: "12px", paddingTop: "16px", borderTop: "1px solid #e0e0e0" }}>
<button
type="submit"
disabled={isLoading}
className="nav-button"
style={{
padding: "10px 20px",
cursor: isLoading ? "wait" : "pointer"
}}
>
{isLoading ? "Creating..." : "Create Tag"}
</button>
<button
type="button"
onClick={() => navigate("/me?tab=tags")}
disabled={isLoading}
className="nav-button secondary"
style={{
padding: "10px 20px"
}}
>
Cancel
</button>
</div>
</form>
</div>
</div>
</Page>
);
}
export default TagCreate;

View file

@ -0,0 +1,281 @@
// File Path: monorepo/native/desktop/maplefile/frontend/src/pages/User/Tags/TagEdit.jsx
import { useState, useEffect } from "react";
import { useNavigate, useParams } from "react-router-dom";
import Navigation from "../../../components/Navigation";
import Page from "../../../components/Page";
import {
ListTags,
UpdateTag,
} from "../../../../wailsjs/go/app/Application";
function TagEdit() {
const navigate = useNavigate();
const { tagId } = useParams();
const [isLoading, setIsLoading] = useState(true);
const [isSaving, setIsSaving] = useState(false);
const [error, setError] = useState("");
const [formData, setFormData] = useState({
name: "",
color: "#3B82F6"
});
const [formErrors, setFormErrors] = useState({});
useEffect(() => {
loadTag();
}, [tagId]);
const loadTag = async () => {
try {
setIsLoading(true);
setError("");
// Fetch all tags and find the one we need
const tags = await ListTags();
const tag = tags.find((t) => t.id === tagId);
if (!tag) {
setError("Tag not found");
setTimeout(() => navigate("/me?tab=tags"), 2000);
return;
}
setFormData({
name: tag.name,
color: tag.color
});
} catch (err) {
console.error("Failed to load tag:", err);
setError(err.message || "Failed to load tag");
} finally {
setIsLoading(false);
}
};
const handleSubmit = async (e) => {
e.preventDefault();
// Validate
const errors = {};
if (!formData.name.trim()) {
errors.name = "Tag name is required";
}
if (!formData.color) {
errors.color = "Tag color is required";
}
if (Object.keys(errors).length > 0) {
setFormErrors(errors);
return;
}
try {
setIsSaving(true);
setError("");
setFormErrors({});
await UpdateTag(tagId, formData.name.trim(), formData.color);
// Navigate back to profile page with tags tab selected
navigate("/me?tab=tags");
} catch (err) {
console.error("Failed to update tag:", err);
setError(err.message || "Failed to update tag");
} finally {
setIsSaving(false);
}
};
return (
<Page>
<Navigation />
<div style={{ maxWidth: "800px", margin: "0 auto", padding: "20px" }}>
{/* Header */}
<div style={{ marginBottom: "30px" }}>
<button
onClick={() => navigate("/me?tab=tags")}
className="nav-button secondary"
style={{ marginBottom: "16px" }}
>
Back to Tags
</button>
<h1 style={{ margin: "0 0 8px 0", fontSize: "28px", fontWeight: "600" }}>
Edit Tag
</h1>
<p style={{ margin: 0, color: "#666" }}>
Update tag information
</p>
</div>
{/* Error Alert */}
{error && (
<div style={{
padding: "12px",
marginBottom: "20px",
backgroundColor: "#fee",
border: "1px solid #fcc",
borderRadius: "4px",
color: "#c00"
}}>
{error}
</div>
)}
{/* Loading or Form */}
{isLoading ? (
<div style={{
backgroundColor: "#fff",
padding: "40px",
borderRadius: "8px",
border: "1px solid #e0e0e0",
textAlign: "center"
}}>
<p>Loading tag...</p>
</div>
) : (
<div style={{
backgroundColor: "#fff",
padding: "24px",
borderRadius: "8px",
border: "1px solid #e0e0e0",
boxShadow: "0 1px 3px rgba(0,0,0,0.1)"
}}>
<form onSubmit={handleSubmit}>
{/* Tag Name */}
<div style={{ marginBottom: "20px" }}>
<label
htmlFor="tag_name"
style={{
display: "block",
marginBottom: "8px",
fontWeight: "500",
fontSize: "14px"
}}
>
Tag Name <span style={{ color: "#c00" }}>*</span>
</label>
<input
id="tag_name"
type="text"
value={formData.name}
onChange={(e) => {
setFormData({ ...formData, name: e.target.value });
setFormErrors({ ...formErrors, name: "" });
}}
placeholder="e.g., Important, Work, Personal"
maxLength={50}
required
disabled={isSaving}
style={{
width: "100%",
padding: "10px 12px",
fontSize: "14px",
border: formErrors.name ? "1px solid #c00" : "1px solid #ccc",
borderRadius: "4px",
outline: "none",
boxSizing: "border-box"
}}
/>
{formErrors.name && (
<p style={{ margin: "6px 0 0 0", fontSize: "13px", color: "#c00" }}>
{formErrors.name}
</p>
)}
</div>
{/* Tag Color */}
<div style={{ marginBottom: "24px" }}>
<label
style={{
display: "block",
marginBottom: "8px",
fontWeight: "500",
fontSize: "14px"
}}
>
Tag Color <span style={{ color: "#c00" }}>*</span>
</label>
<div style={{ display: "flex", gap: "16px", alignItems: "center" }}>
<input
type="color"
value={formData.color}
onChange={(e) => {
setFormData({ ...formData, color: e.target.value });
setFormErrors({ ...formErrors, color: "" });
}}
disabled={isSaving}
style={{
width: "80px",
height: "48px",
border: "2px solid #ccc",
borderRadius: "6px",
cursor: "pointer"
}}
/>
<div style={{ flex: 1 }}>
<input
type="text"
value={formData.color}
onChange={(e) => {
setFormData({ ...formData, color: e.target.value });
setFormErrors({ ...formErrors, color: "" });
}}
placeholder="#3B82F6"
maxLength={7}
disabled={isSaving}
style={{
width: "100%",
padding: "10px 12px",
fontSize: "14px",
border: formErrors.color ? "1px solid #c00" : "1px solid #ccc",
borderRadius: "4px",
outline: "none",
fontFamily: "monospace",
boxSizing: "border-box"
}}
/>
</div>
</div>
{formErrors.color && (
<p style={{ margin: "6px 0 0 0", fontSize: "13px", color: "#c00" }}>
{formErrors.color}
</p>
)}
<p style={{ margin: "8px 0 0 0", fontSize: "12px", color: "#666" }}>
Choose a color to identify this tag visually
</p>
</div>
{/* Buttons */}
<div style={{ display: "flex", gap: "12px", paddingTop: "16px", borderTop: "1px solid #e0e0e0" }}>
<button
type="submit"
disabled={isSaving}
className="nav-button"
style={{
padding: "10px 20px",
cursor: isSaving ? "wait" : "pointer"
}}
>
{isSaving ? "Updating..." : "Update Tag"}
</button>
<button
type="button"
onClick={() => navigate("/me?tab=tags")}
disabled={isSaving}
className="nav-button secondary"
style={{
padding: "10px 20px"
}}
>
Cancel
</button>
</div>
</form>
</div>
)}
</div>
</Page>
);
}
export default TagEdit;

View file

@ -0,0 +1,476 @@
// File Path: monorepo/native/desktop/maplefile/frontend/src/pages/User/Tags/TagSearch.jsx
import { useState, useEffect } from "react";
import { useNavigate } from "react-router-dom";
import Navigation from "../../../components/Navigation";
import Page from "../../../components/Page";
function TagSearch() {
const navigate = useNavigate();
const [availableTags, setAvailableTags] = useState([]);
const [selectedTagIds, setSelectedTagIds] = useState([]);
const [searchResults, setSearchResults] = useState(null);
const [decryptedCollections, setDecryptedCollections] = useState([]);
const [decryptedFiles, setDecryptedFiles] = useState([]);
const [isLoadingTags, setIsLoadingTags] = useState(true);
const [isSearching, setIsSearching] = useState(false);
const [isDecrypting, setIsDecrypting] = useState(false);
const [error, setError] = useState("");
const loadTags = async () => {
try {
setIsLoadingTags(true);
setError("");
const { ListTags } = await import("../../../../wailsjs/go/app/Application");
const fetchedTags = await ListTags();
setAvailableTags(fetchedTags || []);
} catch (err) {
console.error("Failed to load tags:", err);
setError(err.message || "Failed to load tags");
} finally {
setIsLoadingTags(false);
}
};
useEffect(() => {
// Wait for Wails runtime to be ready before loading data
let attempts = 0;
const maxAttempts = 50; // 5 seconds max (50 * 100ms)
let isCancelled = false; // Prevent race conditions
const checkWailsReady = () => {
if (isCancelled) return;
if (window.go && window.go.app && window.go.app.Application) {
loadTags();
} else if (attempts < maxAttempts) {
attempts++;
setTimeout(checkWailsReady, 100);
} else {
// Timeout - show error
if (!isCancelled) {
setError("Failed to initialize application. Please reload.");
setIsLoadingTags(false);
}
}
};
checkWailsReady();
// Cleanup function to prevent race conditions with StrictMode
return () => {
isCancelled = true;
};
}, []);
const handleTagToggle = (tagId) => {
if (selectedTagIds.includes(tagId)) {
setSelectedTagIds(selectedTagIds.filter((id) => id !== tagId));
} else {
setSelectedTagIds([...selectedTagIds, tagId]);
}
};
const handleSearch = async () => {
if (selectedTagIds.length === 0) {
setError("Please select at least one tag");
return;
}
try {
setIsSearching(true);
setError("");
setDecryptedCollections([]);
setDecryptedFiles([]);
const { SearchByTags, GetCollection, GetFile } = await import("../../../../wailsjs/go/app/Application");
const results = await SearchByTags(selectedTagIds, 50); // Limit to 50 results
setSearchResults(results);
// Fetch and decrypt collection details
if (results.collection_ids && results.collection_ids.length > 0) {
setIsDecrypting(true);
const collectionPromises = results.collection_ids.map(async (id) => {
try {
return await GetCollection(id);
} catch (error) {
console.error("Failed to fetch collection:", id, error);
return null;
}
});
const collections = (await Promise.all(collectionPromises)).filter(Boolean);
setDecryptedCollections(collections);
}
// Fetch and decrypt file details
if (results.file_ids && results.file_ids.length > 0) {
setIsDecrypting(true);
const filePromises = results.file_ids.map(async (id) => {
try {
return await GetFile(id);
} catch (error) {
console.error("Failed to fetch file:", id, error);
return null;
}
});
const files = (await Promise.all(filePromises)).filter(Boolean);
setDecryptedFiles(files);
}
} catch (err) {
console.error("Failed to search by tags:", err);
setError(err.message || "Failed to search by tags");
} finally {
setIsSearching(false);
setIsDecrypting(false);
}
};
const handleClearSelection = () => {
setSelectedTagIds([]);
setSearchResults(null);
setDecryptedCollections([]);
setDecryptedFiles([]);
setError("");
};
const handleCollectionClick = (collectionId) => {
navigate(`/file-manager/collections/${collectionId}`);
};
const handleFileClick = (fileId) => {
navigate(`/file-manager/files/${fileId}`);
};
if (isLoadingTags) {
return (
<div className="layout">
<Navigation />
<div className="main-content">
<Page title="Search by Tags">
<div style={{ padding: "20px", textAlign: "center" }}>
<p>Loading tags...</p>
</div>
</Page>
</div>
</div>
);
}
if (error && !searchResults) {
return (
<div className="layout">
<Navigation />
<div className="main-content">
<Page title="Search by Tags">
<div style={{ padding: "20px", textAlign: "center" }}>
<p style={{ color: "#d32f2f" }}>{error}</p>
<button
onClick={loadTags}
style={{
marginTop: "10px",
padding: "8px 16px",
background: "#1976d2",
color: "white",
border: "none",
borderRadius: "4px",
cursor: "pointer",
}}
>
Retry
</button>
</div>
</Page>
</div>
</div>
);
}
return (
<div className="layout">
<Navigation />
<div className="main-content">
<Page title="Search by Tags">
<div style={{ padding: "20px" }}>
{/* Header */}
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
marginBottom: "20px",
}}
>
<h1 style={{ margin: 0 }}>Search by Tags</h1>
<button
onClick={() => navigate("/tags")}
className="nav-button"
style={{ padding: "8px 16px" }}
>
Back to Tags
</button>
</div>
{/* Tag Selection */}
<div
style={{
background: "#f5f5f5",
padding: "15px",
borderRadius: "4px",
marginBottom: "20px",
}}
>
<h3 style={{ marginTop: 0 }}>
Select Tags ({selectedTagIds.length} selected)
</h3>
{availableTags.length === 0 ? (
<p>No tags available. Create tags first to use search.</p>
) : (
<div
style={{
display: "flex",
flexWrap: "wrap",
gap: "10px",
marginBottom: "15px",
}}
>
{availableTags.map((tag) => {
const isSelected = selectedTagIds.includes(tag.id);
return (
<button
key={tag.id}
onClick={() => handleTagToggle(tag.id)}
style={{
padding: "8px 16px",
border: `2px solid ${tag.color || "#666"}`,
borderRadius: "16px",
background: isSelected ? tag.color || "#666" : "white",
color: isSelected ? "white" : tag.color || "#666",
cursor: "pointer",
fontWeight: "500",
transition: "all 0.2s",
display: "flex",
alignItems: "center",
gap: "6px",
}}
>
<span
style={{
display: "inline-block",
width: "12px",
height: "12px",
borderRadius: "50%",
background: isSelected ? "white" : tag.color || "#666",
}}
/>
{tag.name}
</button>
);
})}
</div>
)}
<div style={{ display: "flex", gap: "10px" }}>
<button
onClick={handleSearch}
disabled={selectedTagIds.length === 0 || isSearching}
style={{
padding: "10px 20px",
background:
selectedTagIds.length === 0 || isSearching
? "#ccc"
: "#1976d2",
color: "white",
border: "none",
borderRadius: "4px",
cursor:
selectedTagIds.length === 0 || isSearching
? "not-allowed"
: "pointer",
fontWeight: "500",
}}
>
{isSearching ? "Searching..." : "Search"}
</button>
{selectedTagIds.length > 0 && (
<button
onClick={handleClearSelection}
style={{
padding: "10px 20px",
background: "white",
color: "#666",
border: "1px solid #ccc",
borderRadius: "4px",
cursor: "pointer",
}}
>
Clear Selection
</button>
)}
</div>
</div>
{/* Search Results */}
{searchResults && (
<div>
<h2 style={{ marginBottom: "15px" }}>
Search Results - {searchResults.collection_count} Collections,{" "}
{searchResults.file_count} Files
</h2>
{searchResults.collection_count === 0 &&
searchResults.file_count === 0 && (
<div
style={{
padding: "40px",
textAlign: "center",
background: "#f9f9f9",
borderRadius: "4px",
}}
>
<p style={{ fontSize: "16px", color: "#666" }}>
No collections or files found with the selected tags.
</p>
</div>
)}
{/* Decrypting Indicator */}
{isDecrypting && (
<div
style={{
padding: "20px",
textAlign: "center",
background: "#f9f9f9",
borderRadius: "4px",
marginBottom: "20px",
}}
>
<p style={{ fontSize: "14px", color: "#666" }}>
Decrypting results...
</p>
</div>
)}
{/* Collections Section */}
{decryptedCollections.length > 0 && (
<div style={{ marginBottom: "30px" }}>
<h3 style={{ marginBottom: "10px" }}>
Collections ({decryptedCollections.length})
</h3>
<div style={{ display: "flex", flexDirection: "column", gap: "10px" }}>
{decryptedCollections.map((collection) => (
<div
key={collection.id}
onClick={() => handleCollectionClick(collection.id)}
style={{
padding: "15px",
background: "white",
border: "1px solid #ddd",
borderRadius: "4px",
cursor: "pointer",
transition: "all 0.2s",
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = "#f5f5f5";
e.currentTarget.style.borderColor = "#1976d2";
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = "white";
e.currentTarget.style.borderColor = "#ddd";
}}
>
<div
style={{
display: "flex",
alignItems: "center",
gap: "10px",
}}
>
<span style={{ fontSize: "24px" }}>📁</span>
<div style={{ flex: 1 }}>
<div style={{ fontWeight: "600", fontSize: "15px", color: "#333", marginBottom: "4px" }}>
{collection.name}
</div>
{collection.description && (
<div style={{ fontSize: "13px", color: "#666", marginBottom: "4px" }}>
{collection.description}
</div>
)}
<div style={{ fontSize: "12px", color: "#999" }}>
{collection.file_count || 0} files Created {new Date(collection.created_at).toLocaleDateString()}
</div>
</div>
</div>
</div>
))}
</div>
</div>
)}
{/* Files Section */}
{decryptedFiles.length > 0 && (
<div>
<h3 style={{ marginBottom: "10px" }}>
Files ({decryptedFiles.length})
</h3>
<div style={{ display: "flex", flexDirection: "column", gap: "10px" }}>
{decryptedFiles.map((file) => (
<div
key={file.id}
onClick={() => handleFileClick(file.id)}
style={{
padding: "15px",
background: "white",
border: "1px solid #ddd",
borderRadius: "4px",
cursor: "pointer",
transition: "all 0.2s",
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = "#f5f5f5";
e.currentTarget.style.borderColor = "#1976d2";
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = "white";
e.currentTarget.style.borderColor = "#ddd";
}}
>
<div
style={{
display: "flex",
alignItems: "center",
gap: "10px",
}}
>
<span style={{ fontSize: "24px" }}>📄</span>
<div style={{ flex: 1 }}>
<div style={{ fontWeight: "600", fontSize: "15px", color: "#333", marginBottom: "4px" }}>
{file.filename}
</div>
<div style={{ fontSize: "12px", color: "#999" }}>
{formatFileSize(file.size)} Uploaded {new Date(file.created_at).toLocaleDateString()}
</div>
</div>
</div>
</div>
))}
</div>
</div>
)}
</div>
)}
</div>
</Page>
</div>
</div>
);
}
// Helper function to format file size
function formatFileSize(bytes) {
if (bytes === 0) return "0 Bytes";
const k = 1024;
const sizes = ["Bytes", "KB", "MB", "GB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return Math.round(bytes / Math.pow(k, i) * 100) / 100 + " " + sizes[i];
}
export default TagSearch;

View file

@ -0,0 +1,303 @@
// File Path: monorepo/native/desktop/maplefile/frontend/src/pages/User/Tags/TagsList.jsx
import { useState, useEffect } from "react";
import { useNavigate } from "react-router-dom";
import {
ListTags,
DeleteTag,
} from "../../../../wailsjs/go/app/Application";
function TagsList() {
const navigate = useNavigate();
const [tags, setTags] = useState([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState("");
const [deleteError, setDeleteError] = useState("");
const [deletingTagId, setDeletingTagId] = useState(null);
const [confirmDelete, setConfirmDelete] = useState(null); // { tagId, tagName }
useEffect(() => {
loadTags();
}, []);
const loadTags = async () => {
try {
setIsLoading(true);
setError("");
const fetchedTags = await ListTags();
setTags(fetchedTags || []);
} catch (err) {
console.error("Failed to load tags:", err);
setError(err.message || "Failed to load tags");
} finally {
setIsLoading(false);
}
};
const handleDeleteClick = (tagId, tagName) => {
console.log("[TagsList] Delete button clicked:", tagId, tagName);
setConfirmDelete({ tagId, tagName });
};
const handleDeleteConfirm = async () => {
if (!confirmDelete) return;
const { tagId, tagName } = confirmDelete;
try {
setDeletingTagId(tagId);
setDeleteError("");
setConfirmDelete(null);
console.log("[TagsList] Deleting tag:", tagId, tagName);
const result = await DeleteTag(tagId);
console.log("[TagsList] Delete result:", result);
// Reload tags after successful deletion
console.log("[TagsList] Reloading tags after delete");
await loadTags();
console.log("[TagsList] Tags reloaded successfully");
} catch (err) {
console.error("[TagsList] Failed to delete tag:", err);
console.error("[TagsList] Error details:", {
message: err.message,
stack: err.stack,
error: err
});
setDeleteError(err.message || "Failed to delete tag");
} finally {
setDeletingTagId(null);
}
};
const handleDeleteCancel = () => {
console.log("[TagsList] Delete cancelled");
setConfirmDelete(null);
};
if (isLoading) {
return (
<div style={{ textAlign: "center", padding: "40px" }}>
<p>Loading tags...</p>
</div>
);
}
return (
<div>
{/* Header with Buttons */}
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center", marginBottom: "20px" }}>
<div>
<h2 style={{ margin: 0, fontSize: "24px", fontWeight: "600" }}>Tags</h2>
<p style={{ margin: "5px 0 0 0", color: "#666", fontSize: "14px" }}>
Create and organize tags for your files and collections
</p>
</div>
<div style={{ display: "flex", gap: "10px", marginLeft: "auto" }}>
<button
onClick={() => navigate("/tags/search")}
className="nav-button"
style={{ background: "#1976d2", color: "white" }}
>
🔍 Search by Tags
</button>
<button
onClick={() => navigate("/me/tags/create")}
className="nav-button"
>
+ New Tag
</button>
</div>
</div>
{/* Errors */}
{error && (
<div style={{
padding: "12px",
marginBottom: "16px",
backgroundColor: "#fee",
border: "1px solid #fcc",
borderRadius: "4px",
color: "#c00"
}}>
{error}
</div>
)}
{deleteError && (
<div style={{
padding: "12px",
marginBottom: "16px",
backgroundColor: "#fee",
border: "1px solid #fcc",
borderRadius: "4px",
color: "#c00"
}}>
{deleteError}
</div>
)}
{/* Tags List */}
{tags.length === 0 ? (
<div style={{
textAlign: "center",
padding: "60px 20px",
backgroundColor: "#f9f9f9",
borderRadius: "8px",
border: "2px dashed #ddd"
}}>
<h3 style={{ margin: "0 0 10px 0", color: "#666" }}>No tags yet</h3>
<p style={{ margin: "0 0 20px 0", color: "#999" }}>
Create your first tag to organize your files and collections
</p>
<button
onClick={() => navigate("/me/tags/create")}
className="nav-button"
>
+ Create Your First Tag
</button>
</div>
) : (
<div style={{
display: "grid",
gridTemplateColumns: "repeat(auto-fill, minmax(280px, 1fr))",
gap: "16px"
}}>
{tags.map((tag) => (
<div
key={tag.id}
style={{
padding: "16px",
backgroundColor: "#fff",
border: "1px solid #e0e0e0",
borderRadius: "8px",
boxShadow: "0 1px 3px rgba(0,0,0,0.1)",
transition: "box-shadow 0.2s",
}}
onMouseEnter={(e) => {
e.currentTarget.style.boxShadow = "0 4px 8px rgba(0,0,0,0.15)";
}}
onMouseLeave={(e) => {
e.currentTarget.style.boxShadow = "0 1px 3px rgba(0,0,0,0.1)";
}}
>
<div style={{ display: "flex", alignItems: "center", gap: "12px", marginBottom: "8px" }}>
<div
style={{
width: "24px",
height: "24px",
borderRadius: "50%",
backgroundColor: tag.color,
flexShrink: 0,
border: "2px solid rgba(0,0,0,0.1)"
}}
/>
<div style={{ flex: 1, minWidth: 0 }}>
<h4 style={{
margin: 0,
fontSize: "16px",
fontWeight: "600",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap"
}}>
{tag.name}
</h4>
<p style={{
margin: "2px 0 0 0",
fontSize: "12px",
color: "#888",
fontFamily: "monospace"
}}>
{tag.color}
</p>
</div>
</div>
<div style={{ display: "flex", gap: "8px", marginTop: "12px" }}>
<button
onClick={() => navigate(`/me/tags/${tag.id}/edit`)}
className="nav-button secondary"
style={{
flex: 1,
padding: "6px 12px",
fontSize: "13px"
}}
>
Edit
</button>
<button
onClick={() => handleDeleteClick(tag.id, tag.name)}
disabled={deletingTagId === tag.id}
className="nav-button secondary"
style={{
flex: 1,
padding: "6px 12px",
fontSize: "13px",
backgroundColor: deletingTagId === tag.id ? "#ccc" : undefined,
cursor: deletingTagId === tag.id ? "wait" : "pointer"
}}
>
{deletingTagId === tag.id ? "Deleting..." : "Delete"}
</button>
</div>
</div>
))}
</div>
)}
{/* Delete Confirmation Modal */}
{confirmDelete && (
<div style={{
position: "fixed",
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: "rgba(0, 0, 0, 0.5)",
display: "flex",
alignItems: "center",
justifyContent: "center",
zIndex: 1000
}}>
<div style={{
backgroundColor: "#fff",
padding: "24px",
borderRadius: "8px",
maxWidth: "400px",
width: "90%",
boxShadow: "0 4px 6px rgba(0, 0, 0, 0.1)"
}}>
<h3 style={{ margin: "0 0 16px 0", fontSize: "18px", fontWeight: "600" }}>
Delete Tag
</h3>
<p style={{ margin: "0 0 20px 0", color: "#666" }}>
Are you sure you want to delete tag "{confirmDelete.tagName}"? This action cannot be undone.
</p>
<div style={{ display: "flex", gap: "12px", justifyContent: "flex-end" }}>
<button
onClick={handleDeleteCancel}
className="nav-button secondary"
style={{ padding: "8px 16px" }}
>
Cancel
</button>
<button
onClick={handleDeleteConfirm}
className="nav-button"
style={{
padding: "8px 16px",
backgroundColor: "#dc2626",
borderColor: "#dc2626"
}}
>
Delete
</button>
</div>
</div>
</div>
)}
</div>
);
}
export default TagsList;

View file

@ -0,0 +1,26 @@
html {
background-color: rgba(27, 38, 54, 1);
text-align: center;
color: white;
}
body {
margin: 0;
color: white;
font-family: "Nunito", -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto",
"Oxygen", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue",
sans-serif;
}
@font-face {
font-family: "Nunito";
font-style: normal;
font-weight: 400;
src: local(""),
url("assets/fonts/nunito-v16-latin-regular.woff2") format("woff2");
}
#app {
height: 100vh;
text-align: center;
}

View file

@ -0,0 +1,7 @@
import {defineConfig} from 'vite'
import react from '@vitejs/plugin-react'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react()]
})

View file

@ -0,0 +1,73 @@
module codeberg.org/mapleopentech/monorepo/native/desktop/maplefile
go 1.25.4
require (
github.com/RoaringBitmap/roaring v0.4.23 // indirect
github.com/RoaringBitmap/roaring/v2 v2.4.5 // indirect
github.com/bep/debounce v1.2.1 // indirect
github.com/bits-and-blooms/bitset v1.22.0 // indirect
github.com/blevesearch/bleve v1.0.14 // indirect
github.com/blevesearch/bleve/v2 v2.5.5 // indirect
github.com/blevesearch/bleve_index_api v1.2.11 // indirect
github.com/blevesearch/geo v0.2.4 // indirect
github.com/blevesearch/go-faiss v1.0.26 // indirect
github.com/blevesearch/go-porterstemmer v1.0.3 // indirect
github.com/blevesearch/gtreap v0.1.1 // indirect
github.com/blevesearch/mmap-go v1.0.4 // indirect
github.com/blevesearch/scorch_segment_api/v2 v2.3.13 // indirect
github.com/blevesearch/segment v0.9.1 // indirect
github.com/blevesearch/snowballstem v0.9.0 // indirect
github.com/blevesearch/upsidedown_store_api v1.0.2 // indirect
github.com/blevesearch/vellum v1.1.0 // indirect
github.com/blevesearch/zap/v11 v11.0.14 // indirect
github.com/blevesearch/zap/v12 v12.0.14 // indirect
github.com/blevesearch/zap/v13 v13.0.6 // indirect
github.com/blevesearch/zap/v14 v14.0.5 // indirect
github.com/blevesearch/zap/v15 v15.0.3 // indirect
github.com/blevesearch/zapx/v11 v11.4.2 // indirect
github.com/blevesearch/zapx/v12 v12.4.2 // indirect
github.com/blevesearch/zapx/v13 v13.4.2 // indirect
github.com/blevesearch/zapx/v14 v14.4.2 // indirect
github.com/blevesearch/zapx/v15 v15.4.2 // indirect
github.com/blevesearch/zapx/v16 v16.2.7 // indirect
github.com/couchbase/vellum v1.0.2 // indirect
github.com/glycerine/go-unsnap-stream v0.0.0-20181221182339-f9677308dec2 // indirect
github.com/go-ole/go-ole v1.3.0 // indirect
github.com/godbus/dbus/v5 v5.1.0 // indirect
github.com/golang/protobuf v1.5.0 // indirect
github.com/golang/snappy v0.0.4 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/gorilla/websocket v1.5.3 // indirect
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e // indirect
github.com/json-iterator/go v0.0.0-20171115153421-f7279a603ede // indirect
github.com/labstack/echo/v4 v4.13.3 // indirect
github.com/labstack/gommon v0.4.2 // indirect
github.com/leaanthony/go-ansi-parser v1.6.1 // indirect
github.com/leaanthony/gosod v1.0.4 // indirect
github.com/leaanthony/slicer v1.6.0 // indirect
github.com/leaanthony/u v1.1.1 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mschoch/smat v0.2.0 // indirect
github.com/philhofer/fwd v1.0.0 // indirect
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/samber/lo v1.49.1 // indirect
github.com/steveyen/gtreap v0.1.0 // indirect
github.com/tinylib/msgp v1.1.0 // indirect
github.com/tkrajina/go-reflector v0.5.8 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasttemplate v1.2.2 // indirect
github.com/wailsapp/go-webview2 v1.0.22 // indirect
github.com/wailsapp/mimetype v1.4.1 // indirect
github.com/wailsapp/wails/v2 v2.11.0 // indirect
github.com/willf/bitset v1.1.10 // indirect
go.etcd.io/bbolt v1.4.0 // indirect
golang.org/x/crypto v0.33.0 // indirect
golang.org/x/net v0.35.0 // indirect
golang.org/x/sys v0.30.0 // indirect
golang.org/x/text v0.22.0 // indirect
google.golang.org/protobuf v1.36.6 // indirect
)

View file

@ -0,0 +1,234 @@
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/RoaringBitmap/roaring v0.4.23 h1:gpyfd12QohbqhFO4NVDUdoPOCXsyahYRQhINmlHxKeo=
github.com/RoaringBitmap/roaring v0.4.23/go.mod h1:D0gp8kJQgE1A4LQ5wFLggQEyvDi06Mq5mKs52e1TwOo=
github.com/RoaringBitmap/roaring/v2 v2.4.5 h1:uGrrMreGjvAtTBobc0g5IrW1D5ldxDQYe2JW2gggRdg=
github.com/RoaringBitmap/roaring/v2 v2.4.5/go.mod h1:FiJcsfkGje/nZBZgCu0ZxCPOKD/hVXDS2dXi7/eUFE0=
github.com/armon/consul-api v0.0.0-20180202201655-eb2c6b5be1b6/go.mod h1:grANhF5doyWs3UAsr3K4I6qtAmlQcZDesFNEHPZAzj8=
github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY=
github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0=
github.com/bits-and-blooms/bitset v1.12.0/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8=
github.com/bits-and-blooms/bitset v1.22.0 h1:Tquv9S8+SGaS3EhyA+up3FXzmkhxPGjQQCkcs2uw7w4=
github.com/bits-and-blooms/bitset v1.22.0/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8=
github.com/blevesearch/bleve v1.0.14 h1:Q8r+fHTt35jtGXJUM0ULwM3Tzg+MRfyai4ZkWDy2xO4=
github.com/blevesearch/bleve v1.0.14/go.mod h1:e/LJTr+E7EaoVdkQZTfoz7dt4KoDNvDbLb8MSKuNTLQ=
github.com/blevesearch/bleve/v2 v2.5.5 h1:lzC89QUCco+y1qBnJxGqm4AbtsdsnlUvq0kXok8n3C8=
github.com/blevesearch/bleve/v2 v2.5.5/go.mod h1:t5WoESS5TDteTdnjhhvpA1BpLYErOBX2IQViTMLK7wo=
github.com/blevesearch/bleve_index_api v1.2.11 h1:bXQ54kVuwP8hdrXUSOnvTQfgK0KI1+f9A0ITJT8tX1s=
github.com/blevesearch/bleve_index_api v1.2.11/go.mod h1:rKQDl4u51uwafZxFrPD1R7xFOwKnzZW7s/LSeK4lgo0=
github.com/blevesearch/blevex v1.0.0/go.mod h1:2rNVqoG2BZI8t1/P1awgTKnGlx5MP9ZbtEciQaNhswc=
github.com/blevesearch/cld2 v0.0.0-20200327141045-8b5f551d37f5/go.mod h1:PN0QNTLs9+j1bKy3d/GB/59wsNBFC4sWLWG3k69lWbc=
github.com/blevesearch/geo v0.2.4 h1:ECIGQhw+QALCZaDcogRTNSJYQXRtC8/m8IKiA706cqk=
github.com/blevesearch/geo v0.2.4/go.mod h1:K56Q33AzXt2YExVHGObtmRSFYZKYGv0JEN5mdacJJR8=
github.com/blevesearch/go-faiss v1.0.26 h1:4dRLolFgjPyjkaXwff4NfbZFdE/dfywbzDqporeQvXI=
github.com/blevesearch/go-faiss v1.0.26/go.mod h1:OMGQwOaRRYxrmeNdMrXJPvVx8gBnvE5RYrr0BahNnkk=
github.com/blevesearch/go-porterstemmer v1.0.3 h1:GtmsqID0aZdCSNiY8SkuPJ12pD4jI+DdXTAn4YRcHCo=
github.com/blevesearch/go-porterstemmer v1.0.3/go.mod h1:angGc5Ht+k2xhJdZi511LtmxuEf0OVpvUUNrwmM1P7M=
github.com/blevesearch/gtreap v0.1.1 h1:2JWigFrzDMR+42WGIN/V2p0cUvn4UP3C4Q5nmaZGW8Y=
github.com/blevesearch/gtreap v0.1.1/go.mod h1:QaQyDRAT51sotthUWAH4Sj08awFSSWzgYICSZ3w0tYk=
github.com/blevesearch/mmap-go v1.0.2 h1:JtMHb+FgQCTTYIhtMvimw15dJwu1Y5lrZDMOFXVWPk0=
github.com/blevesearch/mmap-go v1.0.2/go.mod h1:ol2qBqYaOUsGdm7aRMRrYGgPvnwLe6Y+7LMvAB5IbSA=
github.com/blevesearch/mmap-go v1.0.4 h1:OVhDhT5B/M1HNPpYPBKIEJaD0F3Si+CrEKULGCDPWmc=
github.com/blevesearch/mmap-go v1.0.4/go.mod h1:EWmEAOmdAS9z/pi/+Toxu99DnsbhG1TIxUoRmJw/pSs=
github.com/blevesearch/scorch_segment_api/v2 v2.3.13 h1:ZPjv/4VwWvHJZKeMSgScCapOy8+DdmsmRyLmSB88UoY=
github.com/blevesearch/scorch_segment_api/v2 v2.3.13/go.mod h1:ENk2LClTehOuMS8XzN3UxBEErYmtwkE7MAArFTXs9Vc=
github.com/blevesearch/segment v0.9.0 h1:5lG7yBCx98or7gK2cHMKPukPZ/31Kag7nONpoBt22Ac=
github.com/blevesearch/segment v0.9.0/go.mod h1:9PfHYUdQCgHktBgvtUOF4x+pc4/l8rdH0u5spnW85UQ=
github.com/blevesearch/segment v0.9.1 h1:+dThDy+Lvgj5JMxhmOVlgFfkUtZV2kw49xax4+jTfSU=
github.com/blevesearch/segment v0.9.1/go.mod h1:zN21iLm7+GnBHWTao9I+Au/7MBiL8pPFtJBJTsk6kQw=
github.com/blevesearch/snowballstem v0.9.0 h1:lMQ189YspGP6sXvZQ4WZ+MLawfV8wOmPoD/iWeNXm8s=
github.com/blevesearch/snowballstem v0.9.0/go.mod h1:PivSj3JMc8WuaFkTSRDW2SlrulNWPl4ABg1tC/hlgLs=
github.com/blevesearch/upsidedown_store_api v1.0.2 h1:U53Q6YoWEARVLd1OYNc9kvhBMGZzVrdmaozG2MfoB+A=
github.com/blevesearch/upsidedown_store_api v1.0.2/go.mod h1:M01mh3Gpfy56Ps/UXHjEO/knbqyQ1Oamg8If49gRwrQ=
github.com/blevesearch/vellum v1.1.0 h1:CinkGyIsgVlYf8Y2LUQHvdelgXr6PYuvoDIajq6yR9w=
github.com/blevesearch/vellum v1.1.0/go.mod h1:QgwWryE8ThtNPxtgWJof5ndPfx0/YMBh+W2weHKPw8Y=
github.com/blevesearch/zap/v11 v11.0.14 h1:IrDAvtlzDylh6H2QCmS0OGcN9Hpf6mISJlfKjcwJs7k=
github.com/blevesearch/zap/v11 v11.0.14/go.mod h1:MUEZh6VHGXv1PKx3WnCbdP404LGG2IZVa/L66pyFwnY=
github.com/blevesearch/zap/v12 v12.0.14 h1:2o9iRtl1xaRjsJ1xcqTyLX414qPAwykHNV7wNVmbp3w=
github.com/blevesearch/zap/v12 v12.0.14/go.mod h1:rOnuZOiMKPQj18AEKEHJxuI14236tTQ1ZJz4PAnWlUg=
github.com/blevesearch/zap/v13 v13.0.6 h1:r+VNSVImi9cBhTNNR+Kfl5uiGy8kIbb0JMz/h8r6+O4=
github.com/blevesearch/zap/v13 v13.0.6/go.mod h1:L89gsjdRKGyGrRN6nCpIScCvvkyxvmeDCwZRcjjPCrw=
github.com/blevesearch/zap/v14 v14.0.5 h1:NdcT+81Nvmp2zL+NhwSvGSLh7xNgGL8QRVZ67njR0NU=
github.com/blevesearch/zap/v14 v14.0.5/go.mod h1:bWe8S7tRrSBTIaZ6cLRbgNH4TUDaC9LZSpRGs85AsGY=
github.com/blevesearch/zap/v15 v15.0.3 h1:Ylj8Oe+mo0P25tr9iLPp33lN6d4qcztGjaIsP51UxaY=
github.com/blevesearch/zap/v15 v15.0.3/go.mod h1:iuwQrImsh1WjWJ0Ue2kBqY83a0rFtJTqfa9fp1rbVVU=
github.com/blevesearch/zapx/v11 v11.4.2 h1:l46SV+b0gFN+Rw3wUI1YdMWdSAVhskYuvxlcgpQFljs=
github.com/blevesearch/zapx/v11 v11.4.2/go.mod h1:4gdeyy9oGa/lLa6D34R9daXNUvfMPZqUYjPwiLmekwc=
github.com/blevesearch/zapx/v12 v12.4.2 h1:fzRbhllQmEMUuAQ7zBuMvKRlcPA5ESTgWlDEoB9uQNE=
github.com/blevesearch/zapx/v12 v12.4.2/go.mod h1:TdFmr7afSz1hFh/SIBCCZvcLfzYvievIH6aEISCte58=
github.com/blevesearch/zapx/v13 v13.4.2 h1:46PIZCO/ZuKZYgxI8Y7lOJqX3Irkc3N8W82QTK3MVks=
github.com/blevesearch/zapx/v13 v13.4.2/go.mod h1:knK8z2NdQHlb5ot/uj8wuvOq5PhDGjNYQQy0QDnopZk=
github.com/blevesearch/zapx/v14 v14.4.2 h1:2SGHakVKd+TrtEqpfeq8X+So5PShQ5nW6GNxT7fWYz0=
github.com/blevesearch/zapx/v14 v14.4.2/go.mod h1:rz0XNb/OZSMjNorufDGSpFpjoFKhXmppH9Hi7a877D8=
github.com/blevesearch/zapx/v15 v15.4.2 h1:sWxpDE0QQOTjyxYbAVjt3+0ieu8NCE0fDRaFxEsp31k=
github.com/blevesearch/zapx/v15 v15.4.2/go.mod h1:1pssev/59FsuWcgSnTa0OeEpOzmhtmr/0/11H0Z8+Nw=
github.com/blevesearch/zapx/v16 v16.2.7 h1:xcgFRa7f/tQXOwApVq7JWgPYSlzyUMmkuYa54tMDuR0=
github.com/blevesearch/zapx/v16 v16.2.7/go.mod h1:murSoCJPCk25MqURrcJaBQ1RekuqSCSfMjXH4rHyA14=
github.com/coreos/etcd v3.3.10+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE=
github.com/coreos/go-etcd v2.0.0+incompatible/go.mod h1:Jez6KQU2B/sWsbdaef3ED8NzMklzPG4d5KIOhIy30Tk=
github.com/coreos/go-semver v0.2.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk=
github.com/couchbase/ghistogram v0.1.0/go.mod h1:s1Jhy76zqfEecpNWJfWUiKZookAFaiGOEoyzgHt9i7k=
github.com/couchbase/moss v0.1.0/go.mod h1:9MaHIaRuy9pvLPUJxB8sh8OrLfyDczECVL37grCIubs=
github.com/couchbase/vellum v1.0.2 h1:BrbP0NKiyDdndMPec8Jjhy0U47CZ0Lgx3xUC2r9rZqw=
github.com/couchbase/vellum v1.0.2/go.mod h1:FcwrEivFpNi24R3jLOs3n+fs5RnuQnQqCLBJ1uAg1W4=
github.com/cpuguy83/go-md2man v1.0.10/go.mod h1:SmD6nW6nTyfqj6ABTjUi3V3JVMnlJmwcJI5acqYI6dE=
github.com/cznic/b v0.0.0-20181122101859-a26611c4d92d/go.mod h1:URriBxXwVq5ijiJ12C7iIZqlA69nTlI+LgI6/pwftG8=
github.com/cznic/mathutil v0.0.0-20181122101859-297441e03548/go.mod h1:e6NPNENfs9mPDVNRekM7lKScauxd5kXTr1Mfyig6TDM=
github.com/cznic/strutil v0.0.0-20181122101858-275e90344537/go.mod h1:AHHPPPXTw0h6pVabbcbyGRK1DckRn7r/STdZEeIDzZc=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/facebookgo/ensure v0.0.0-20200202191622-63f1cf65ac4c/go.mod h1:Yg+htXGokKKdzcwhuNDwVvN+uBxDGXJ7G/VN1d8fa64=
github.com/facebookgo/stack v0.0.0-20160209184415-751773369052/go.mod h1:UbMTZqLaRiH3MsBH8va0n7s1pQYcu3uTb8G4tygF4Zg=
github.com/facebookgo/subset v0.0.0-20200203212716-c811ad88dec4/go.mod h1:5tD+neXqOorC30/tWg0LCSkrqj/AR6gu8yY8/fpw1q0=
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
github.com/glycerine/go-unsnap-stream v0.0.0-20181221182339-f9677308dec2 h1:Ujru1hufTHVb++eG6OuNDKMxZnGIvF6o/u8q/8h2+I4=
github.com/glycerine/go-unsnap-stream v0.0.0-20181221182339-f9677308dec2/go.mod h1:/20jfyN9Y5QPEAprSgKAUr+glWDY39ZiUEAYOEv5dsE=
github.com/glycerine/goconvey v0.0.0-20190410193231-58a59202ab31/go.mod h1:Ogl1Tioa0aV7gstGFO7KhffUsb9M4ydbEbbxpcEDc24=
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.2 h1:6nsPYzhq5kReh6QImI3k5qWzO4PEbvbIW2cwSfR/6xs=
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/golang/snappy v0.0.0-20180518054509-2e65f85255db/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/golang/snappy v0.0.1 h1:Qgr9rKW7uDUkrbSmQeiDsGa8SjGyCOGtuasMWwvp2P4=
github.com/golang/snappy v0.0.1/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM=
github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gopherjs/gopherjs v0.0.0-20190910122728-9d188e94fb99/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
github.com/ikawaha/kagome.ipadic v1.1.2/go.mod h1:DPSBbU0czaJhAb/5uKQZHMc9MTVRpDugJfX+HddPHHg=
github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8=
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e h1:Q3+PugElBCf4PFpxhErSzU3/PY5sFL5Z6rfv4AbGAck=
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e/go.mod h1:alcuEEnZsY1WQsagKhZDsoPCRoOijYqhZvPwLG0kzVs=
github.com/jmhodges/levigo v1.0.0/go.mod h1:Q6Qx+uH3RAqyK4rFQroq9RL7mdkABMcfhEI+nNuzMJQ=
github.com/json-iterator/go v0.0.0-20171115153421-f7279a603ede h1:YrgBGwxMRK0Vq0WSCWFaZUnTsrA/PZE/xs1QZh+/edg=
github.com/json-iterator/go v0.0.0-20171115153421-f7279a603ede/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU=
github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
github.com/kljensen/snowball v0.6.0/go.mod h1:27N7E8fVU5H68RlUmnWwZCfxgt4POBJfENGMvNRhldw=
github.com/labstack/echo/v4 v4.13.3 h1:pwhpCPrTl5qry5HRdM5FwdXnhXSLSY+WE+YQSeCaafY=
github.com/labstack/echo/v4 v4.13.3/go.mod h1:o90YNEeQWjDozo584l7AwhJMHN0bOC4tAfg+Xox9q5g=
github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0=
github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU=
github.com/leaanthony/go-ansi-parser v1.6.1 h1:xd8bzARK3dErqkPFtoF9F3/HgN8UQk0ed1YDKpEz01A=
github.com/leaanthony/go-ansi-parser v1.6.1/go.mod h1:+vva/2y4alzVmmIEpk9QDhA7vLC5zKDTRwfZGOp3IWU=
github.com/leaanthony/gosod v1.0.4 h1:YLAbVyd591MRffDgxUOU1NwLhT9T1/YiwjKZpkNFeaI=
github.com/leaanthony/gosod v1.0.4/go.mod h1:GKuIL0zzPj3O1SdWQOdgURSuhkF+Urizzxh26t9f1cw=
github.com/leaanthony/slicer v1.6.0 h1:1RFP5uiPJvT93TAHi+ipd3NACobkW53yUiBqZheE/Js=
github.com/leaanthony/slicer v1.6.0/go.mod h1:o/Iz29g7LN0GqH3aMjWAe90381nyZlDNquK+mtH2Fj8=
github.com/leaanthony/u v1.1.1 h1:TUFjwDGlNX+WuwVEzDqQwC2lOv0P4uhTQw7CMFdiK7M=
github.com/leaanthony/u v1.1.1/go.mod h1:9+o6hejoRljvZ3BzdYlVL0JYCwtnAsVuN9pVTQcaRfI=
github.com/magiconair/properties v1.8.0/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ=
github.com/matryer/is v1.4.0/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y=
github.com/mschoch/smat v0.0.0-20160514031455-90eadee771ae/go.mod h1:qAyveg+e4CE+eKJXWVjKXM4ck2QobLqTDytGJbLLhJg=
github.com/mschoch/smat v0.2.0 h1:8imxQsjDm8yFEAVBe7azKmKSgzSkZXDuKkSq9374khM=
github.com/mschoch/smat v0.2.0/go.mod h1:kc9mz7DoBKqDyiRL7VZN8KvXQMWeTaVnttLRXOlotKw=
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
github.com/onsi/ginkgo v1.7.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
github.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY=
github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic=
github.com/philhofer/fwd v1.0.0 h1:UbZqGr5Y38ApvM/V/jEljVxwocdweyH+vmYvRPBnbqQ=
github.com/philhofer/fwd v1.0.0/go.mod h1:gk3iGcWd9+svBvR0sR+KPcfE+RNWozjowpeBVG3ZVNU=
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rcrowley/go-metrics v0.0.0-20190826022208-cac0b30c2563/go.mod h1:bCqnVzQkZxMG4s8nGwiZ5l3QUCyqpo9Y+/ZMZ9VjZe4=
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/russross/blackfriday v1.5.2/go.mod h1:JO/DiYxRf+HjHt06OyowR9PTA263kcR/rfWxYHBV53g=
github.com/samber/lo v1.49.1 h1:4BIFyVfuQSEpluc7Fua+j1NolZHiEHEpaSEKdsH0tew=
github.com/samber/lo v1.49.1/go.mod h1:dO6KHFzUKXgP8LDhU0oI8d2hekjXnGOu0DB8Jecxd6o=
github.com/spf13/afero v1.1.2/go.mod h1:j4pytiNVoe2o6bmDsKpLACNPDBIoEAkihy7loJ1B0CQ=
github.com/spf13/cast v1.3.0/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE=
github.com/spf13/cobra v0.0.5/go.mod h1:3K3wKZymM7VvHMDS9+Akkh4K60UwM26emMESw8tLCHU=
github.com/spf13/jwalterweatherman v1.0.0/go.mod h1:cQK4TGJAtQXfYWX+Ddv3mKDzgVb68N+wFjFa4jdeBTo=
github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4=
github.com/spf13/viper v1.3.2/go.mod h1:ZiWeW+zYFKm7srdB9IoDzzZXaJaI5eL9QjNiN/DMA2s=
github.com/steveyen/gtreap v0.1.0 h1:CjhzTa274PyJLJuMZwIzCO1PfC00oRa8d1Kc78bFXJM=
github.com/steveyen/gtreap v0.1.0/go.mod h1:kl/5J7XbrOmlIbYIXdRHDDE5QxHqpk0cmkT7Z4dM9/Y=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/syndtr/goleveldb v1.0.0/go.mod h1:ZVVdQEZoIme9iO1Ch2Jdy24qqXrMMOU6lpPAyBWyWuQ=
github.com/tebeka/snowball v0.4.2/go.mod h1:4IfL14h1lvwZcp1sfXuuc7/7yCsvVffTWxWxCLfFpYg=
github.com/tecbot/gorocksdb v0.0.0-20191217155057-f0fad39f321c/go.mod h1:ahpPrc7HpcfEWDQRZEmnXMzHY03mLDYMCxeDzy46i+8=
github.com/tinylib/msgp v1.1.0 h1:9fQd+ICuRIu/ue4vxJZu6/LzxN0HwMds2nq/0cFvxHU=
github.com/tinylib/msgp v1.1.0/go.mod h1:+d+yLhGm8mzTaHzB+wgMYrodPfmZrzkirds8fDWklFE=
github.com/tkrajina/go-reflector v0.5.8 h1:yPADHrwmUbMq4RGEyaOUpz2H90sRsETNVpjzo3DLVQQ=
github.com/tkrajina/go-reflector v0.5.8/go.mod h1:ECbqLgccecY5kPmPmXg1MrHW585yMcDkVl6IvJe64T4=
github.com/ugorji/go/codec v0.0.0-20181204163529-d75b2dcb6bc8/go.mod h1:VFNgLljTbGfSG7qAOspJ7OScBnGdDN/yBr0sguwnwf0=
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo=
github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ=
github.com/wailsapp/go-webview2 v1.0.22 h1:YT61F5lj+GGaat5OB96Aa3b4QA+mybD0Ggq6NZijQ58=
github.com/wailsapp/go-webview2 v1.0.22/go.mod h1:qJmWAmAmaniuKGZPWwne+uor3AHMB5PFhqiK0Bbj8kc=
github.com/wailsapp/mimetype v1.4.1 h1:pQN9ycO7uo4vsUUuPeHEYoUkLVkaRntMnHJxVwYhwHs=
github.com/wailsapp/mimetype v1.4.1/go.mod h1:9aV5k31bBOv5z6u+QP8TltzvNGJPmNJD4XlAL3U+j3o=
github.com/wailsapp/wails/v2 v2.11.0 h1:seLacV8pqupq32IjS4Y7V8ucab0WZwtK6VvUVxSBtqQ=
github.com/wailsapp/wails/v2 v2.11.0/go.mod h1:jrf0ZaM6+GBc1wRmXsM8cIvzlg0karYin3erahI4+0k=
github.com/willf/bitset v1.1.10 h1:NotGKqX0KwQ72NUzqrjZq5ipPNDQex9lo3WpaS8L2sc=
github.com/willf/bitset v1.1.10/go.mod h1:RjeCKbqT1RxIR/KWY6phxZiaY1IyutSBfGjNPySAYV4=
github.com/xordataexchange/crypt v0.0.3-0.20170626215501-b2862e3d0a77/go.mod h1:aYKd//L2LvnjZzWKhF00oedf4jCCReLcmhLdhm1A27Q=
go.etcd.io/bbolt v1.3.5 h1:XAzx9gjCb0Rxj7EoqcClPD1d5ZBxZJk0jbuoPHenBt0=
go.etcd.io/bbolt v1.3.5/go.mod h1:G5EMThwa9y8QZGBClrRx5EY+Yw9kAhnjy3bSjsnlVTQ=
go.etcd.io/bbolt v1.4.0 h1:TU77id3TnN/zKr7CO/uk+fBCwF2jGcMuw2B/FMAzYIk=
go.etcd.io/bbolt v1.4.0/go.mod h1:AsD+OCi/qPN1giOX1aiLAha3o1U8rAz65bvN4j0sRuk=
golang.org/x/crypto v0.0.0-20181203042331-505ab145d0a9/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
golang.org/x/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus=
golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M=
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20210505024714-0287a6fb4125/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8=
golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20181205085412-a5c9d58dba9a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20181221143128-b4a75ba826a6/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190813064441-fde4db37ae7a/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200202164722-d101bd2416d5/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200810151505-1b9f1253b3ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.0/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View file

@ -0,0 +1,977 @@
package app
import (
"context"
"crypto/rand"
"encoding/base64"
"fmt"
"time"
"github.com/tyler-smith/go-bip39"
"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/service/inputvalidation"
"codeberg.org/mapleopentech/monorepo/native/desktop/maplefile/internal/service/ratelimiter"
"codeberg.org/mapleopentech/monorepo/native/desktop/maplefile/internal/utils"
)
// RequestOTT requests a one-time token for login
func (a *Application) RequestOTT(email string) error {
// Validate input
if err := inputvalidation.ValidateEmail(email); err != nil {
return err
}
// Check rate limit before making request
// Note: We do NOT reset on success here - the rate limit prevents spamming
// the "request OTT" button. Users should wait between OTT requests.
if err := a.rateLimiter.Check(ratelimiter.OpRequestOTT, email); err != nil {
a.logger.Warn("OTT request rate limited",
zap.String("email", utils.MaskEmail(email)),
zap.Error(err))
return err
}
return a.authService.RequestOTT(a.ctx, email)
}
// Logout logs out the current user and deletes all local data (default behavior for security).
// Use LogoutWithOptions for more control over local data deletion.
func (a *Application) Logout() error {
return a.LogoutWithOptions(true) // Default to deleting local data for security
}
// LogoutWithOptions logs out the current user with control over local data deletion.
// If deleteLocalData is true, all locally cached files and metadata will be permanently deleted.
// If deleteLocalData is false, local data is preserved for faster login next time.
func (a *Application) LogoutWithOptions(deleteLocalData bool) error {
// Get session before clearing
session, _ := a.authService.GetCurrentSession(a.ctx)
var userEmail string
if session != nil {
userEmail = session.Email
}
// Stop token manager first
stopCtx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
if err := a.tokenManager.Stop(stopCtx); err != nil {
a.logger.Error("Failed to stop token manager during logout", zap.Error(err))
// Continue with logout even if token manager stop failed
}
// Clear stored password from RAM
if session != nil {
if err := a.passwordStore.ClearPassword(session.Email); err != nil {
a.logger.Error("Failed to clear stored password", zap.Error(err))
} else {
a.logger.Info("Password cleared from secure RAM", zap.String("email", utils.MaskEmail(session.Email)))
}
// Clear cached master key from memory (if it exists)
if a.keyCache.HasMasterKey(session.Email) {
if err := a.keyCache.ClearMasterKey(session.Email); err != nil {
a.logger.Warn("Failed to clear cached master key", zap.Error(err))
} else {
a.logger.Info("Cached master key cleared from secure memory", zap.String("email", utils.MaskEmail(session.Email)))
}
} else {
a.logger.Debug("No cached master key to clear (expected after app restart)", zap.String("email", utils.MaskEmail(session.Email)))
}
}
// Close search index
if err := a.searchService.Close(); err != nil {
a.logger.Error("Failed to close search index during logout", zap.Error(err))
// Continue with logout even if search cleanup fails
} else {
a.logger.Info("Search index closed")
}
// Handle local data based on user preference
if deleteLocalData && userEmail != "" {
// Delete all local data permanently
if err := a.storageManager.DeleteUserData(userEmail); err != nil {
a.logger.Error("Failed to delete local user data", zap.Error(err))
// Continue with logout even if deletion fails
} else {
a.logger.Info("All local user data deleted", zap.String("email", utils.MaskEmail(userEmail)))
}
} else {
// Just cleanup storage connections (keep data on disk)
a.storageManager.Cleanup()
a.logger.Info("User storage connections closed, local data preserved")
}
// Clear session
return a.authService.Logout(a.ctx)
}
// GetLocalDataSize returns the size of locally stored data for the current user in bytes.
// This can be used to show the user how much data will be deleted on logout.
func (a *Application) GetLocalDataSize() (int64, error) {
session, err := a.authService.GetCurrentSession(a.ctx)
if err != nil || session == nil {
return 0, nil
}
size, err := a.storageManager.GetUserDataSize(session.Email)
if err != nil {
a.logger.Warn("Failed to get local data size", zap.Error(err))
return 0, err
}
return size, nil
}
// IsLoggedIn checks if a user is logged in
func (a *Application) IsLoggedIn() (bool, error) {
return a.authService.IsLoggedIn(a.ctx)
}
// Register creates a new user account
func (a *Application) Register(input *client.RegisterInput) error {
// Validate input
if err := inputvalidation.ValidateEmail(input.Email); err != nil {
return err
}
if err := inputvalidation.ValidateDisplayName(input.FirstName, "first name"); err != nil {
return err
}
if err := inputvalidation.ValidateDisplayName(input.LastName, "last name"); err != nil {
return err
}
// Note: Password is not sent directly in RegisterInput - it's used client-side
// to derive encryption keys. The encrypted master key and salt are validated
// by their presence and format on the server side.
// Check rate limit before making request
// Note: We do NOT reset on success - registration is a one-time operation
// and keeping the rate limit prevents re-registration spam attempts.
if err := a.rateLimiter.Check(ratelimiter.OpRegister, input.Email); err != nil {
a.logger.Warn("Registration rate limited",
zap.String("email", utils.MaskEmail(input.Email)),
zap.Error(err))
return err
}
return a.authService.Register(a.ctx, input)
}
// VerifyEmail verifies the email with the verification code
func (a *Application) VerifyEmail(email, code string) error {
// Validate input
if err := inputvalidation.ValidateEmail(email); err != nil {
return err
}
if err := inputvalidation.ValidateOTT(code); err != nil {
return err
}
// Check rate limit before making request
if err := a.rateLimiter.Check(ratelimiter.OpVerifyEmail, email); err != nil {
a.logger.Warn("Email verification rate limited",
zap.String("email", utils.MaskEmail(email)),
zap.Error(err))
return err
}
input := &client.VerifyEmailInput{
Email: email,
Code: code,
}
err := a.authService.VerifyEmail(a.ctx, input)
if err == nil {
// Reset rate limit on success
a.rateLimiter.Reset(ratelimiter.OpVerifyEmail, email)
}
return err
}
// VerifyOTTResponse contains the OTT verification response with encrypted challenge
type VerifyOTTResponse struct {
Message string `json:"message"`
ChallengeID string `json:"challengeId"`
EncryptedChallenge string `json:"encryptedChallenge"`
Salt string `json:"salt"`
EncryptedMasterKey string `json:"encryptedMasterKey"`
EncryptedPrivateKey string `json:"encryptedPrivateKey"`
PublicKey string `json:"publicKey"`
// KDFAlgorithm specifies which key derivation algorithm to use.
// Value: "PBKDF2-SHA256"
KDFAlgorithm string `json:"kdfAlgorithm"`
}
// VerifyOTT verifies the one-time token and returns the encrypted challenge
func (a *Application) VerifyOTT(email, ott string) (*VerifyOTTResponse, error) {
// Validate input
if err := inputvalidation.ValidateEmail(email); err != nil {
return nil, err
}
if err := inputvalidation.ValidateOTT(ott); err != nil {
return nil, err
}
// Check rate limit before making request
if err := a.rateLimiter.Check(ratelimiter.OpVerifyOTT, email); err != nil {
a.logger.Warn("OTT verification rate limited",
zap.String("email", utils.MaskEmail(email)),
zap.Error(err))
return nil, err
}
resp, err := a.authService.VerifyOTT(a.ctx, email, ott)
if err != nil {
a.logger.Error("OTT verification failed", zap.Error(err))
return nil, err
}
// Reset rate limit on success
a.rateLimiter.Reset(ratelimiter.OpVerifyOTT, email)
// Get KDF algorithm from response, default to PBKDF2-SHA256
kdfAlgorithm := resp.KDFAlgorithm
if kdfAlgorithm == "" {
kdfAlgorithm = e2ee.PBKDF2Algorithm
}
return &VerifyOTTResponse{
Message: resp.Message,
ChallengeID: resp.ChallengeID,
EncryptedChallenge: resp.EncryptedChallenge,
Salt: resp.Salt,
EncryptedMasterKey: resp.EncryptedMasterKey,
EncryptedPrivateKey: resp.EncryptedPrivateKey,
PublicKey: resp.PublicKey,
KDFAlgorithm: kdfAlgorithm,
}, nil
}
// CompleteLoginInput contains the data needed to complete login
type CompleteLoginInput struct {
Email string `json:"email"`
ChallengeID string `json:"challengeId"`
DecryptedData string `json:"decryptedData"`
Password string `json:"password"`
// Encrypted user data for future password verification
Salt string `json:"salt"`
EncryptedMasterKey string `json:"encryptedMasterKey"`
EncryptedPrivateKey string `json:"encryptedPrivateKey"`
PublicKey string `json:"publicKey"`
// KDFAlgorithm specifies which key derivation algorithm to use.
// Value: "PBKDF2-SHA256"
KDFAlgorithm string `json:"kdfAlgorithm"`
}
// CompleteLogin completes the login process with the decrypted challenge
func (a *Application) CompleteLogin(input *CompleteLoginInput) error {
// Validate input
if err := inputvalidation.ValidateEmail(input.Email); err != nil {
return err
}
if err := inputvalidation.ValidatePassword(input.Password); err != nil {
return err
}
if input.ChallengeID == "" {
return fmt.Errorf("challenge ID is required")
}
if input.DecryptedData == "" {
return fmt.Errorf("decrypted data is required")
}
// Check rate limit before making request
if err := a.rateLimiter.Check(ratelimiter.OpCompleteLogin, input.Email); err != nil {
a.logger.Warn("Login completion rate limited",
zap.String("email", utils.MaskEmail(input.Email)),
zap.Error(err))
return err
}
clientInput := &client.CompleteLoginInput{
Email: input.Email,
ChallengeID: input.ChallengeID,
DecryptedData: input.DecryptedData,
}
_, err := a.authService.CompleteLogin(a.ctx, clientInput)
if err != nil {
a.logger.Error("Login completion failed", zap.Error(err))
return err
}
// Reset all rate limits for this user on successful login
a.rateLimiter.ResetAll(input.Email)
// Store encrypted user data in session for future password verification
session, err := a.authService.GetCurrentSession(a.ctx)
if err == nil && session != nil {
session.Salt = input.Salt
session.EncryptedMasterKey = input.EncryptedMasterKey
session.EncryptedPrivateKey = input.EncryptedPrivateKey
session.PublicKey = input.PublicKey
// Store KDF algorithm so VerifyPassword knows which algorithm to use
session.KDFAlgorithm = input.KDFAlgorithm
if session.KDFAlgorithm == "" {
session.KDFAlgorithm = e2ee.PBKDF2Algorithm
}
// Update session with encrypted data
if err := a.authService.UpdateSession(a.ctx, session); err != nil {
a.logger.Warn("Failed to update session with encrypted data", zap.Error(err))
// Continue anyway - password storage will still work
} else {
a.logger.Info("Encrypted user data stored in session for password verification")
}
}
// Store password in secure RAM
if err := a.passwordStore.StorePassword(input.Email, input.Password); err != nil {
a.logger.Error("Failed to store password in RAM", zap.Error(err))
// Don't fail login if password storage fails
} else {
a.logger.Info("Password stored securely in RAM for E2EE operations", zap.String("email", utils.MaskEmail(input.Email)))
}
// Cache master key for session to avoid re-decrypting for every file operation
if input.Salt != "" && input.EncryptedMasterKey != "" && input.Password != "" {
kdfAlgorithm := input.KDFAlgorithm
if kdfAlgorithm == "" {
kdfAlgorithm = e2ee.PBKDF2Algorithm
}
if err := a.cacheMasterKeyFromPassword(input.Email, input.Password, input.Salt, input.EncryptedMasterKey, kdfAlgorithm); err != nil {
a.logger.Warn("Failed to cache master key during login", zap.Error(err))
// Continue anyway - user can still use the app, just slower
}
}
a.logger.Info("User logged in successfully", zap.String("email", utils.MaskEmail(input.Email)))
// Initialize user-specific storage for the logged-in user
if err := a.storageManager.InitializeForUser(input.Email); err != nil {
a.logger.Error("Failed to initialize user storage", zap.Error(err))
// Don't fail login - user can still use cloud features, just not local storage
} else {
a.logger.Info("User storage initialized", zap.String("email", utils.MaskEmail(input.Email)))
}
// Initialize search index for the logged-in user
if err := a.searchService.Initialize(a.ctx, input.Email); err != nil {
a.logger.Error("Failed to initialize search index", zap.Error(err))
// Don't fail login if search initialization fails - it's not critical
// The app can still function without search
} else {
a.logger.Info("Search index initialized", zap.String("email", utils.MaskEmail(input.Email)))
// Rebuild search index from local data in the background
userEmail := input.Email // Capture email before goroutine
go func() {
if err := a.rebuildSearchIndexForUser(userEmail); err != nil {
a.logger.Warn("Failed to rebuild search index after login", zap.Error(err))
}
}()
}
// Start token manager for automatic token refresh
a.tokenManager.Start()
a.logger.Info("Token manager started for new session")
return nil
}
// DecryptLoginChallenge decrypts the login challenge using the user's password.
// The kdfAlgorithm parameter specifies which key derivation function to use.
// If kdfAlgorithm is empty, it defaults to "PBKDF2-SHA256".
func (a *Application) DecryptLoginChallenge(password, saltBase64, encryptedMasterKeyBase64, encryptedChallengeBase64, encryptedPrivateKeyBase64, publicKeyBase64, kdfAlgorithm string) (string, error) {
// Default to PBKDF2-SHA256
if kdfAlgorithm == "" {
kdfAlgorithm = e2ee.PBKDF2Algorithm
}
a.logger.Debug("Decrypting login challenge", zap.String("kdf_algorithm", kdfAlgorithm))
// Decode base64 inputs
salt, err := base64.StdEncoding.DecodeString(saltBase64)
if err != nil {
a.logger.Error("Failed to decode salt", zap.Error(err))
return "", fmt.Errorf("invalid salt encoding: %w", err)
}
encryptedChallenge, err := base64.StdEncoding.DecodeString(encryptedChallengeBase64)
if err != nil {
a.logger.Error("Failed to decode encrypted challenge", zap.Error(err))
return "", fmt.Errorf("invalid challenge encoding: %w", err)
}
publicKey, err := base64.StdEncoding.DecodeString(publicKeyBase64)
if err != nil {
a.logger.Error("Failed to decode public key", zap.Error(err))
return "", fmt.Errorf("invalid public key encoding: %w", err)
}
// Decode encrypted private key
encryptedPrivateKeyCombined, err := base64.StdEncoding.DecodeString(encryptedPrivateKeyBase64)
if err != nil {
a.logger.Error("Failed to decode encrypted private key", zap.Error(err))
return "", fmt.Errorf("invalid encrypted private key encoding: %w", err)
}
// Decode encrypted master key
encryptedMasterKeyCombined, err := base64.StdEncoding.DecodeString(encryptedMasterKeyBase64)
if err != nil {
a.logger.Error("Failed to decode encrypted master key", zap.Error(err))
return "", fmt.Errorf("invalid encrypted master key encoding: %w", err)
}
// 1. Derive KEK from password and salt using PBKDF2-SHA256
keychain, err := e2ee.NewSecureKeyChainWithAlgorithm(password, salt, kdfAlgorithm)
if err != nil {
a.logger.Error("Failed to create secure keychain", zap.Error(err), zap.String("kdf_algorithm", kdfAlgorithm))
return "", fmt.Errorf("failed to derive key from password: %w", err)
}
defer keychain.Clear()
// 2. Decrypt master key with KEK into protected memory
// Auto-detect nonce size: web frontend uses 24-byte nonces (XSalsa20), native uses 12-byte (ChaCha20)
masterKeyNonce, masterKeyCiphertext, err := e2ee.SplitNonceAndCiphertextSecretBox(encryptedMasterKeyCombined)
if err != nil {
a.logger.Error("Failed to split encrypted master key", zap.Error(err))
return "", fmt.Errorf("invalid encrypted master key format: %w", err)
}
encryptedMasterKeyStruct := &e2ee.EncryptedKey{
Ciphertext: masterKeyCiphertext,
Nonce: masterKeyNonce,
}
masterKey, err := keychain.DecryptMasterKeySecure(encryptedMasterKeyStruct)
if err != nil {
a.logger.Error("Failed to decrypt master key", zap.Error(err), zap.String("kdf_algorithm", kdfAlgorithm))
return "", fmt.Errorf("failed to decrypt master key (wrong password?): %w", err)
}
defer masterKey.Destroy()
// 3. Decrypt private key with master key into protected memory
// Auto-detect nonce size based on the encrypted data
privateKeyNonce, privateKeyCiphertext, err := e2ee.SplitNonceAndCiphertextSecretBox(encryptedPrivateKeyCombined)
if err != nil {
a.logger.Error("Failed to split encrypted private key", zap.Error(err))
return "", fmt.Errorf("invalid encrypted private key format: %w", err)
}
encryptedPrivateKeyStruct := &e2ee.EncryptedKey{
Ciphertext: privateKeyCiphertext,
Nonce: privateKeyNonce,
}
privateKey, err := e2ee.DecryptPrivateKeySecure(encryptedPrivateKeyStruct, masterKey)
if err != nil {
a.logger.Error("Failed to decrypt private key", zap.Error(err))
return "", fmt.Errorf("failed to decrypt private key: %w", err)
}
defer privateKey.Destroy()
// 4. Decrypt the challenge using the private key (NaCl anonymous box)
decryptedChallenge, err := e2ee.DecryptAnonymousBox(encryptedChallenge, publicKey, privateKey.Bytes())
if err != nil {
a.logger.Error("Failed to decrypt challenge", zap.Error(err))
return "", fmt.Errorf("failed to decrypt login challenge: %w", err)
}
// Convert decrypted challenge to base64 for sending to server
decryptedChallengeBase64 := base64.StdEncoding.EncodeToString(decryptedChallenge)
a.logger.Info("Successfully decrypted login challenge")
return decryptedChallengeBase64, nil
}
// RegistrationKeys contains all the E2EE keys needed for registration
type RegistrationKeys struct {
Salt string `json:"salt"`
EncryptedMasterKey string `json:"encryptedMasterKey"`
PublicKey string `json:"publicKey"`
EncryptedPrivateKey string `json:"encryptedPrivateKey"`
EncryptedRecoveryKey string `json:"encryptedRecoveryKey"`
MasterKeyEncryptedWithRecoveryKey string `json:"masterKeyEncryptedWithRecoveryKey"`
// RecoveryMnemonic is the 12-word BIP39 mnemonic phrase that must be shown to the user
// The user MUST save this phrase securely - it's their only way to recover their account
RecoveryMnemonic string `json:"recoveryMnemonic"`
}
// RecoveryInitiateResponse contains the response from initiating account recovery
type RecoveryInitiateResponse struct {
Message string `json:"message"`
SessionID string `json:"sessionId"`
EncryptedChallenge string `json:"encryptedChallenge"`
}
// InitiateRecovery starts the account recovery process for the given email
func (a *Application) InitiateRecovery(email string) (*RecoveryInitiateResponse, error) {
// Validate input
if err := inputvalidation.ValidateEmail(email); err != nil {
return nil, err
}
// Check rate limit before making request
if err := a.rateLimiter.Check(ratelimiter.OpRequestOTT, email); err != nil {
a.logger.Warn("Recovery initiation rate limited",
zap.String("email", utils.MaskEmail(email)),
zap.Error(err))
return nil, err
}
resp, err := a.authService.InitiateRecovery(a.ctx, email, "recovery_key")
if err != nil {
a.logger.Error("Recovery initiation failed", zap.Error(err))
return nil, err
}
a.logger.Info("Recovery initiated successfully", zap.String("email", utils.MaskEmail(email)))
return &RecoveryInitiateResponse{
Message: resp.Message,
SessionID: resp.SessionID,
EncryptedChallenge: resp.EncryptedChallenge,
}, nil
}
// DecryptRecoveryChallengeInput contains the data needed to process recovery challenge
type DecryptRecoveryChallengeInput struct {
RecoveryMnemonic string `json:"recoveryMnemonic"`
EncryptedChallenge string `json:"encryptedChallenge"`
}
// DecryptRecoveryChallengeResult contains the result of processing recovery challenge
type DecryptRecoveryChallengeResult struct {
DecryptedChallenge string `json:"decryptedChallenge"`
IsValid bool `json:"isValid"`
}
// DecryptRecoveryChallenge validates the recovery mnemonic and processes the challenge.
// Note: The backend currently sends an unencrypted challenge (base64-encoded plaintext).
// This function validates the recovery phrase format and passes through the challenge.
// When the backend implements proper encryption, this function will decrypt the challenge.
func (a *Application) DecryptRecoveryChallenge(input *DecryptRecoveryChallengeInput) (*DecryptRecoveryChallengeResult, error) {
// Validate recovery mnemonic (must be 12 words)
if input.RecoveryMnemonic == "" {
return nil, fmt.Errorf("recovery mnemonic is required")
}
// Validate the mnemonic is a valid BIP39 phrase
if !bip39.IsMnemonicValid(input.RecoveryMnemonic) {
a.logger.Warn("Invalid recovery mnemonic format")
return nil, fmt.Errorf("invalid recovery phrase: must be 12 valid BIP39 words")
}
// Count words to ensure we have exactly 12
words := len(splitMnemonic(input.RecoveryMnemonic))
if words != 12 {
return nil, fmt.Errorf("invalid recovery phrase: must be exactly 12 words, got %d", words)
}
// Validate the encrypted challenge is present
if input.EncryptedChallenge == "" {
return nil, fmt.Errorf("encrypted challenge is required")
}
// Derive recovery key from mnemonic to validate it's a valid recovery phrase
// This also prepares for future decryption when backend implements encryption
seed := bip39.NewSeed(input.RecoveryMnemonic, "")
recoveryKey := seed[:32]
a.logger.Debug("Recovery key derived successfully",
zap.Int("key_length", len(recoveryKey)),
zap.Int("word_count", words))
// TEMPORARY WORKAROUND: Backend currently sends base64-encoded plaintext challenge
// instead of encrypted challenge. See backend TODO in recovery_initiate.go:108-113
// Until backend implements proper encryption, we just validate and pass through.
// Decode the challenge to validate it's valid base64
challengeBytes, err := base64.StdEncoding.DecodeString(input.EncryptedChallenge)
if err != nil {
a.logger.Error("Failed to decode challenge", zap.Error(err))
return nil, fmt.Errorf("invalid challenge format: %w", err)
}
// Re-encode to base64 for sending to backend
decryptedChallengeBase64 := base64.StdEncoding.EncodeToString(challengeBytes)
a.logger.Info("Recovery challenge processed successfully (backend workaround active)")
return &DecryptRecoveryChallengeResult{
DecryptedChallenge: decryptedChallengeBase64,
IsValid: true,
}, nil
}
// splitMnemonic splits a mnemonic phrase into words
func splitMnemonic(mnemonic string) []string {
var words []string
for _, word := range splitByWhitespace(mnemonic) {
if word != "" {
words = append(words, word)
}
}
return words
}
// splitByWhitespace splits a string by whitespace characters
func splitByWhitespace(s string) []string {
return splitString(s)
}
// splitString splits a string into words by spaces
func splitString(s string) []string {
var result []string
word := ""
for _, r := range s {
if r == ' ' || r == '\t' || r == '\n' || r == '\r' {
if word != "" {
result = append(result, word)
word = ""
}
} else {
word += string(r)
}
}
if word != "" {
result = append(result, word)
}
return result
}
// RecoveryVerifyResponse contains the response from verifying recovery
type RecoveryVerifyResponse struct {
Message string `json:"message"`
RecoveryToken string `json:"recoveryToken"`
CanResetCredentials bool `json:"canResetCredentials"`
}
// VerifyRecovery verifies the recovery challenge with the server
func (a *Application) VerifyRecovery(sessionID, decryptedChallenge string) (*RecoveryVerifyResponse, error) {
if sessionID == "" {
return nil, fmt.Errorf("session ID is required")
}
if decryptedChallenge == "" {
return nil, fmt.Errorf("decrypted challenge is required")
}
input := &client.RecoveryVerifyInput{
SessionID: sessionID,
DecryptedChallenge: decryptedChallenge,
}
resp, err := a.authService.VerifyRecovery(a.ctx, input)
if err != nil {
a.logger.Error("Recovery verification failed", zap.Error(err))
return nil, err
}
a.logger.Info("Recovery verification successful")
return &RecoveryVerifyResponse{
Message: resp.Message,
RecoveryToken: resp.RecoveryToken,
CanResetCredentials: resp.CanResetCredentials,
}, nil
}
// CompleteRecoveryInput contains the data needed to complete account recovery
type CompleteRecoveryInput struct {
RecoveryToken string `json:"recoveryToken"`
RecoveryMnemonic string `json:"recoveryMnemonic"`
NewPassword string `json:"newPassword"`
}
// CompleteRecoveryResponse contains the response from completing recovery
type CompleteRecoveryResponse struct {
Message string `json:"message"`
Success bool `json:"success"`
}
// CompleteRecovery completes the account recovery by re-encrypting keys with a new password.
// This function:
// 1. Validates the recovery mnemonic
// 2. Derives the recovery key from the mnemonic
// 3. Generates new encryption keys with the new password
// 4. Sends the new encrypted keys to the server
func (a *Application) CompleteRecovery(input *CompleteRecoveryInput) (*CompleteRecoveryResponse, error) {
// Validate inputs
if input.RecoveryToken == "" {
return nil, fmt.Errorf("recovery token is required")
}
if input.RecoveryMnemonic == "" {
return nil, fmt.Errorf("recovery mnemonic is required")
}
if err := inputvalidation.ValidatePassword(input.NewPassword); err != nil {
return nil, err
}
// Validate the mnemonic is a valid BIP39 phrase
if !bip39.IsMnemonicValid(input.RecoveryMnemonic) {
return nil, fmt.Errorf("invalid recovery phrase: must be 12 valid BIP39 words")
}
// Count words to ensure we have exactly 12
words := len(splitMnemonic(input.RecoveryMnemonic))
if words != 12 {
return nil, fmt.Errorf("invalid recovery phrase: must be exactly 12 words, got %d", words)
}
a.logger.Info("Starting recovery completion - generating new encryption keys")
// 1. Derive recovery key from mnemonic
seed := bip39.NewSeed(input.RecoveryMnemonic, "")
recoveryKeyBytes := seed[:32]
recoveryKey, err := e2ee.NewSecureBuffer(recoveryKeyBytes)
if err != nil {
e2ee.ClearBytes(recoveryKeyBytes)
return nil, fmt.Errorf("failed to create secure buffer for recovery key: %w", err)
}
defer recoveryKey.Destroy()
e2ee.ClearBytes(recoveryKeyBytes)
// 2. Generate new salt for the new password
newSalt, err := e2ee.GenerateSalt()
if err != nil {
return nil, fmt.Errorf("failed to generate new salt: %w", err)
}
// 3. Create new keychain with PBKDF2-SHA256 (for web frontend compatibility)
newKeychain, err := e2ee.NewSecureKeyChainWithAlgorithm(input.NewPassword, newSalt, e2ee.PBKDF2Algorithm)
if err != nil {
return nil, fmt.Errorf("failed to create new keychain: %w", err)
}
defer newKeychain.Clear()
// 4. Generate new master key
masterKeyBytes, err := e2ee.GenerateMasterKey()
if err != nil {
return nil, fmt.Errorf("failed to generate new master key: %w", err)
}
masterKey, err := e2ee.NewSecureBuffer(masterKeyBytes)
if err != nil {
e2ee.ClearBytes(masterKeyBytes)
return nil, fmt.Errorf("failed to create secure buffer for master key: %w", err)
}
defer masterKey.Destroy()
e2ee.ClearBytes(masterKeyBytes)
// 5. Encrypt master key with new KEK
encryptedMasterKey, err := newKeychain.EncryptMasterKeySecretBox(masterKey.Bytes())
if err != nil {
return nil, fmt.Errorf("failed to encrypt master key: %w", err)
}
// 6. Generate new keypair
newPublicKey, privateKeyBytes, err := e2ee.GenerateKeyPair()
if err != nil {
return nil, fmt.Errorf("failed to generate new keypair: %w", err)
}
privateKey, err := e2ee.NewSecureBuffer(privateKeyBytes)
if err != nil {
e2ee.ClearBytes(privateKeyBytes)
return nil, fmt.Errorf("failed to create secure buffer for private key: %w", err)
}
defer privateKey.Destroy()
e2ee.ClearBytes(privateKeyBytes)
// 7. Encrypt private key with master key
encryptedPrivateKey, err := e2ee.EncryptPrivateKeySecretBox(privateKey.Bytes(), masterKey.Bytes())
if err != nil {
return nil, fmt.Errorf("failed to encrypt private key: %w", err)
}
// 8. Encrypt recovery key with master key
encryptedRecoveryKey, err := e2ee.EncryptRecoveryKeySecretBox(recoveryKey.Bytes(), masterKey.Bytes())
if err != nil {
return nil, fmt.Errorf("failed to encrypt recovery key: %w", err)
}
// 9. Encrypt master key with recovery key (for future recovery)
masterKeyEncryptedWithRecoveryKey, err := e2ee.EncryptMasterKeyWithRecoveryKeySecretBox(masterKey.Bytes(), recoveryKey.Bytes())
if err != nil {
return nil, fmt.Errorf("failed to encrypt master key with recovery key: %w", err)
}
// 10. Convert all keys to base64 for transport
newSaltBase64 := base64.StdEncoding.EncodeToString(newSalt)
newPublicKeyBase64 := base64.StdEncoding.EncodeToString(newPublicKey)
newEncryptedMasterKeyBase64 := base64.StdEncoding.EncodeToString(
e2ee.CombineNonceAndCiphertext(encryptedMasterKey.Nonce, encryptedMasterKey.Ciphertext),
)
newEncryptedPrivateKeyBase64 := base64.StdEncoding.EncodeToString(
e2ee.CombineNonceAndCiphertext(encryptedPrivateKey.Nonce, encryptedPrivateKey.Ciphertext),
)
newEncryptedRecoveryKeyBase64 := base64.StdEncoding.EncodeToString(
e2ee.CombineNonceAndCiphertext(encryptedRecoveryKey.Nonce, encryptedRecoveryKey.Ciphertext),
)
newMasterKeyEncryptedWithRecoveryKeyBase64 := base64.StdEncoding.EncodeToString(
e2ee.CombineNonceAndCiphertext(masterKeyEncryptedWithRecoveryKey.Nonce, masterKeyEncryptedWithRecoveryKey.Ciphertext),
)
// 11. Call API to complete recovery
apiInput := &client.RecoveryCompleteInput{
RecoveryToken: input.RecoveryToken,
NewSalt: newSaltBase64,
NewPublicKey: newPublicKeyBase64,
NewEncryptedMasterKey: newEncryptedMasterKeyBase64,
NewEncryptedPrivateKey: newEncryptedPrivateKeyBase64,
NewEncryptedRecoveryKey: newEncryptedRecoveryKeyBase64,
NewMasterKeyEncryptedWithRecoveryKey: newMasterKeyEncryptedWithRecoveryKeyBase64,
}
resp, err := a.authService.CompleteRecovery(a.ctx, apiInput)
if err != nil {
a.logger.Error("Recovery completion failed", zap.Error(err))
return nil, err
}
a.logger.Info("Recovery completed successfully - new encryption keys set")
return &CompleteRecoveryResponse{
Message: resp.Message,
Success: resp.Success,
}, nil
}
// GenerateRegistrationKeys generates all E2EE keys needed for user registration.
// This function uses PBKDF2-SHA256 for key derivation and XSalsa20-Poly1305 (SecretBox)
// for symmetric encryption to ensure compatibility with the web frontend.
func (a *Application) GenerateRegistrationKeys(password string) (*RegistrationKeys, error) {
// 1. Generate salt (16 bytes for PBKDF2)
salt, err := e2ee.GenerateSalt()
if err != nil {
a.logger.Error("Failed to generate salt", zap.Error(err))
return nil, err
}
// 2. Create secure keychain using PBKDF2-SHA256 (compatible with web frontend)
// This derives KEK from password + salt using PBKDF2-SHA256 with 100,000 iterations
keychain, err := e2ee.NewSecureKeyChainWithAlgorithm(password, salt, e2ee.PBKDF2Algorithm)
if err != nil {
a.logger.Error("Failed to create secure keychain", zap.Error(err))
return nil, err
}
defer keychain.Clear() // Clear sensitive data when done
// 3. Generate master key in protected memory
masterKeyBytes, err := e2ee.GenerateMasterKey()
if err != nil {
a.logger.Error("Failed to generate master key", zap.Error(err))
return nil, err
}
masterKey, err := e2ee.NewSecureBuffer(masterKeyBytes)
if err != nil {
e2ee.ClearBytes(masterKeyBytes)
a.logger.Error("Failed to create secure buffer for master key", zap.Error(err))
return nil, err
}
defer masterKey.Destroy()
e2ee.ClearBytes(masterKeyBytes)
// 4. Encrypt master key with KEK using XSalsa20-Poly1305 (SecretBox)
// This produces 24-byte nonces compatible with web frontend's libsodium
encryptedMasterKey, err := keychain.EncryptMasterKeySecretBox(masterKey.Bytes())
if err != nil {
a.logger.Error("Failed to encrypt master key", zap.Error(err))
return nil, err
}
// 5. Generate NaCl keypair for asymmetric encryption
publicKey, privateKeyBytes, err := e2ee.GenerateKeyPair()
if err != nil {
a.logger.Error("Failed to generate keypair", zap.Error(err))
return nil, err
}
privateKey, err := e2ee.NewSecureBuffer(privateKeyBytes)
if err != nil {
e2ee.ClearBytes(privateKeyBytes)
a.logger.Error("Failed to create secure buffer for private key", zap.Error(err))
return nil, err
}
defer privateKey.Destroy()
e2ee.ClearBytes(privateKeyBytes)
// 6. Encrypt private key with master key using XSalsa20-Poly1305 (SecretBox)
encryptedPrivateKey, err := e2ee.EncryptPrivateKeySecretBox(privateKey.Bytes(), masterKey.Bytes())
if err != nil {
a.logger.Error("Failed to encrypt private key", zap.Error(err))
return nil, err
}
// 7. Generate BIP39 mnemonic (12 words) for account recovery
// This matches the web frontend's approach for cross-platform compatibility
entropy := make([]byte, 16) // 128 bits = 12 words
if _, err := rand.Read(entropy); err != nil {
a.logger.Error("Failed to generate entropy for recovery mnemonic", zap.Error(err))
return nil, err
}
recoveryMnemonic, err := bip39.NewMnemonic(entropy)
if err != nil {
a.logger.Error("Failed to generate recovery mnemonic", zap.Error(err))
return nil, err
}
a.logger.Info("Generated 12-word recovery mnemonic")
// Convert mnemonic to seed (64 bytes via HMAC-SHA512) then take first 32 bytes
// This matches web frontend's mnemonicToRecoveryKey() function
seed := bip39.NewSeed(recoveryMnemonic, "") // Empty passphrase like web frontend
recoveryKeyBytes := seed[:32] // Use first 32 bytes as recovery key
recoveryKey, err := e2ee.NewSecureBuffer(recoveryKeyBytes)
if err != nil {
e2ee.ClearBytes(recoveryKeyBytes)
a.logger.Error("Failed to create secure buffer for recovery key", zap.Error(err))
return nil, err
}
defer recoveryKey.Destroy()
e2ee.ClearBytes(recoveryKeyBytes)
// 8. Encrypt recovery key with master key using XSalsa20-Poly1305 (SecretBox)
encryptedRecoveryKey, err := e2ee.EncryptRecoveryKeySecretBox(recoveryKey.Bytes(), masterKey.Bytes())
if err != nil {
a.logger.Error("Failed to encrypt recovery key", zap.Error(err))
return nil, err
}
// 9. Encrypt master key with recovery key using XSalsa20-Poly1305 (SecretBox)
masterKeyEncryptedWithRecoveryKey, err := e2ee.EncryptMasterKeyWithRecoveryKeySecretBox(masterKey.Bytes(), recoveryKey.Bytes())
if err != nil {
a.logger.Error("Failed to encrypt master key with recovery key", zap.Error(err))
return nil, err
}
// Convert all keys to base64 for transport
// Combine nonce and ciphertext for each encrypted key
encryptedMasterKeyBase64 := base64.StdEncoding.EncodeToString(
e2ee.CombineNonceAndCiphertext(encryptedMasterKey.Nonce, encryptedMasterKey.Ciphertext),
)
encryptedPrivateKeyBase64 := base64.StdEncoding.EncodeToString(
e2ee.CombineNonceAndCiphertext(encryptedPrivateKey.Nonce, encryptedPrivateKey.Ciphertext),
)
encryptedRecoveryKeyBase64 := base64.StdEncoding.EncodeToString(
e2ee.CombineNonceAndCiphertext(encryptedRecoveryKey.Nonce, encryptedRecoveryKey.Ciphertext),
)
masterKeyEncryptedWithRecoveryKeyBase64 := base64.StdEncoding.EncodeToString(
e2ee.CombineNonceAndCiphertext(masterKeyEncryptedWithRecoveryKey.Nonce, masterKeyEncryptedWithRecoveryKey.Ciphertext),
)
a.logger.Info("Successfully generated E2EE registration keys using PBKDF2-SHA256 + XSalsa20-Poly1305")
return &RegistrationKeys{
Salt: base64.StdEncoding.EncodeToString(salt),
EncryptedMasterKey: encryptedMasterKeyBase64,
PublicKey: base64.StdEncoding.EncodeToString(publicKey),
EncryptedPrivateKey: encryptedPrivateKeyBase64,
EncryptedRecoveryKey: encryptedRecoveryKeyBase64,
MasterKeyEncryptedWithRecoveryKey: masterKeyEncryptedWithRecoveryKeyBase64,
RecoveryMnemonic: recoveryMnemonic,
}, nil
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,444 @@
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] + "..."
}

View file

@ -0,0 +1,451 @@
package app
import (
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
sysRuntime "runtime"
"strings"
"time"
"unicode"
"github.com/wailsapp/wails/v2/pkg/runtime"
"go.uber.org/zap"
)
// =============================================================================
// EXPORT TYPES AND UTILITIES
// =============================================================================
// ExportError represents an error that occurred during export
type ExportError struct {
FileID string `json:"file_id"`
Filename string `json:"filename"`
CollectionID string `json:"collection_id"`
ErrorMessage string `json:"error_message"`
Timestamp string `json:"timestamp"`
}
// ExportEstimate provides an estimate of what will be exported
type ExportEstimate struct {
TotalCollections int `json:"total_collections"`
OwnedCollections int `json:"owned_collections"`
SharedCollections int `json:"shared_collections"`
TotalFiles int `json:"total_files"`
TotalSizeBytes int64 `json:"total_size_bytes"`
LocalFilesCount int `json:"local_files_count"`
CloudOnlyCount int `json:"cloud_only_count"`
EstimatedTime string `json:"estimated_time"`
}
// UserProfileExport represents exported user profile data
type UserProfileExport struct {
ID string `json:"id"`
Email string `json:"email"`
FirstName string `json:"first_name"`
LastName string `json:"last_name"`
Name string `json:"name"`
Phone string `json:"phone,omitempty"`
Country string `json:"country,omitempty"`
Timezone string `json:"timezone,omitempty"`
CreatedAt string `json:"created_at"`
ExportedAt string `json:"exported_at"`
}
// CollectionExportData represents a single collection in the export
type CollectionExportData struct {
ID string `json:"id"`
Name string `json:"name"`
CollectionType string `json:"collection_type"`
ParentID string `json:"parent_id,omitempty"`
FileCount int `json:"file_count"`
CreatedAt string `json:"created_at"`
ModifiedAt string `json:"modified_at"`
IsShared bool `json:"is_shared"`
}
// CollectionsExport represents all exported collections
type CollectionsExport struct {
OwnedCollections []*CollectionExportData `json:"owned_collections"`
SharedCollections []*CollectionExportData `json:"shared_collections"`
TotalCount int `json:"total_count"`
ExportedAt string `json:"exported_at"`
}
// FileExportData represents a single file's metadata in the export
type FileExportData struct {
ID string `json:"id"`
Filename string `json:"filename"`
MimeType string `json:"mime_type"`
SizeBytes int64 `json:"size_bytes"`
CreatedAt string `json:"created_at"`
ModifiedAt string `json:"modified_at"`
CollectionID string `json:"collection_id"`
CollectionName string `json:"collection_name"`
}
// FilesMetadataExport represents all exported file metadata
type FilesMetadataExport struct {
Files []*FileExportData `json:"files"`
TotalCount int `json:"total_count"`
TotalSize int64 `json:"total_size_bytes"`
ExportedAt string `json:"exported_at"`
}
// FileExportResult represents the result of exporting a single file
type FileExportResult struct {
FileID string `json:"file_id"`
Filename string `json:"filename"`
SourceType string `json:"source_type"`
SizeBytes int64 `json:"size_bytes"`
DestPath string `json:"dest_path"`
Success bool `json:"success"`
ErrorMessage string `json:"error_message,omitempty"`
}
// ExportSummary is the final summary of the export operation
type ExportSummary struct {
ExportedAt string `json:"exported_at"`
ExportPath string `json:"export_path"`
TotalCollections int `json:"total_collections"`
OwnedCollections int `json:"owned_collections"`
SharedCollections int `json:"shared_collections"`
TotalFiles int `json:"total_files"`
FilesExported int `json:"files_exported"`
FilesCopiedLocal int `json:"files_copied_local"`
FilesDownloaded int `json:"files_downloaded"`
FilesFailed int `json:"files_failed"`
TotalSizeBytes int64 `json:"total_size_bytes"`
Errors []ExportError `json:"errors,omitempty"`
}
// =============================================================================
// EXPORT SETUP OPERATIONS
// =============================================================================
// SelectExportDirectory opens a dialog for the user to select an export directory
func (a *Application) SelectExportDirectory() (string, error) {
// Get user's home directory as default
homeDir, err := os.UserHomeDir()
if err != nil {
homeDir = ""
}
dir, err := runtime.OpenDirectoryDialog(a.ctx, runtime.OpenDialogOptions{
DefaultDirectory: homeDir,
Title: "Select Export Directory",
CanCreateDirectories: true,
ShowHiddenFiles: false,
TreatPackagesAsDirectories: false,
})
if err != nil {
a.logger.Error("Failed to open directory dialog", zap.Error(err))
return "", fmt.Errorf("failed to open directory dialog: %w", err)
}
return dir, nil
}
// GetExportEstimate returns an estimate of what will be exported
func (a *Application) GetExportEstimate() (*ExportEstimate, error) {
a.logger.Info("Getting export estimate")
// Get current session
session, err := a.authService.GetCurrentSession(a.ctx)
if err != nil {
return nil, fmt.Errorf("not authenticated: %w", err)
}
if !session.IsValid() {
return nil, fmt.Errorf("session expired - please log in again")
}
apiClient := a.authService.GetAPIClient()
apiClient.SetTokens(session.AccessToken, session.RefreshToken)
// Get dashboard for storage stats
dashResp, err := apiClient.GetDashboard(a.ctx)
if err != nil {
a.logger.Warn("Failed to get dashboard for estimate", zap.Error(err))
}
// Get owned collections
ownedCollections, err := a.ListCollections()
if err != nil {
return nil, fmt.Errorf("failed to list owned collections: %w", err)
}
// Get shared collections
sharedCollections, err := a.listSharedCollections()
if err != nil {
a.logger.Warn("Failed to list shared collections", zap.Error(err))
sharedCollections = []*CollectionData{}
}
// Count files and check local availability
totalFiles := 0
localFilesCount := 0
cloudOnlyCount := 0
var totalSizeBytes int64 = 0
allCollections := append(ownedCollections, sharedCollections...)
for _, coll := range allCollections {
totalFiles += coll.TotalFiles
}
// Check local file repository for files with decrypted content available
// We check for FilePath (decrypted file) since that's what we copy during export
localFiles, err := a.mustGetFileRepo().List()
if err == nil {
for _, f := range localFiles {
if f.FilePath != "" {
localFilesCount++
totalSizeBytes += f.DecryptedSizeInBytes
}
}
}
cloudOnlyCount = totalFiles - localFilesCount
if cloudOnlyCount < 0 {
cloudOnlyCount = 0
}
// Note: Dashboard has storage in formatted units (e.g., "1.5 GB")
// We use our calculated totalSizeBytes instead for accuracy
_ = dashResp // Suppress unused variable warning if dashboard call failed
// Estimate time based on file count and sizes
estimatedTime := "Less than a minute"
if cloudOnlyCount > 0 {
// Rough estimate: 1 file per second for cloud downloads
seconds := cloudOnlyCount
if seconds > 60 {
minutes := seconds / 60
if minutes > 60 {
estimatedTime = fmt.Sprintf("About %d hours", minutes/60)
} else {
estimatedTime = fmt.Sprintf("About %d minutes", minutes)
}
} else {
estimatedTime = fmt.Sprintf("About %d seconds", seconds)
}
}
estimate := &ExportEstimate{
TotalCollections: len(allCollections),
OwnedCollections: len(ownedCollections),
SharedCollections: len(sharedCollections),
TotalFiles: totalFiles,
TotalSizeBytes: totalSizeBytes,
LocalFilesCount: localFilesCount,
CloudOnlyCount: cloudOnlyCount,
EstimatedTime: estimatedTime,
}
a.logger.Info("Export estimate calculated",
zap.Int("total_collections", estimate.TotalCollections),
zap.Int("total_files", estimate.TotalFiles),
zap.Int("local_files", estimate.LocalFilesCount),
zap.Int("cloud_only", estimate.CloudOnlyCount))
return estimate, nil
}
// CreateExportDirectory creates the export directory with timestamp
func (a *Application) CreateExportDirectory(basePath string) (string, error) {
timestamp := time.Now().Format("2006-01-02_15-04-05")
exportDir := filepath.Join(basePath, fmt.Sprintf("MapleFile_Export_%s", timestamp))
if err := os.MkdirAll(exportDir, 0755); err != nil {
return "", fmt.Errorf("failed to create export directory: %w", err)
}
// Create subdirectories
filesDir := filepath.Join(exportDir, "files")
if err := os.MkdirAll(filesDir, 0755); err != nil {
return "", fmt.Errorf("failed to create files directory: %w", err)
}
return exportDir, nil
}
// OpenExportFolder opens the export folder in the system file manager
func (a *Application) OpenExportFolder(path string) error {
// Security: Validate the path before passing to exec.Command
if path == "" {
return fmt.Errorf("path cannot be empty")
}
// Get absolute path and clean it
absPath, err := filepath.Abs(path)
if err != nil {
return fmt.Errorf("invalid path: %w", err)
}
absPath = filepath.Clean(absPath)
// Verify the path exists and is a directory
info, err := os.Stat(absPath)
if err != nil {
if os.IsNotExist(err) {
return fmt.Errorf("path does not exist: %s", absPath)
}
return fmt.Errorf("failed to access path: %w", err)
}
if !info.IsDir() {
return fmt.Errorf("path is not a directory: %s", absPath)
}
a.logger.Info("Opening export folder",
zap.String("path", absPath))
var cmd *exec.Cmd
switch sysRuntime.GOOS {
case "darwin":
cmd = exec.Command("open", absPath)
case "windows":
cmd = exec.Command("explorer", absPath)
case "linux":
cmd = exec.Command("xdg-open", absPath)
default:
return fmt.Errorf("unsupported operating system: %s", sysRuntime.GOOS)
}
return cmd.Start()
}
// =============================================================================
// HELPER FUNCTIONS
// =============================================================================
// sanitizeFilename removes or replaces characters that are invalid in filenames.
// This function provides defense-in-depth against path traversal attacks by:
// 1. Extracting only the base filename (removing any path components)
// 2. Handling special directory references (. and ..)
// 3. Removing control characters
// 4. Replacing invalid filesystem characters
// 5. Handling Windows reserved names
// 6. Limiting filename length
func sanitizeFilename(name string) string {
// Step 1: Extract only the base filename to prevent path traversal
// This handles cases like "../../../etc/passwd" -> "passwd"
name = filepath.Base(name)
// Step 2: Handle special directory references
if name == "." || name == ".." || name == "" {
return "unnamed"
}
// Step 3: Trim leading/trailing whitespace and dots
// Windows doesn't allow filenames ending with dots or spaces
name = strings.TrimSpace(name)
name = strings.Trim(name, ".")
if name == "" {
return "unnamed"
}
// Step 4: Remove control characters (ASCII 0-31)
result := make([]rune, 0, len(name))
for _, r := range name {
if r < 32 || !unicode.IsPrint(r) {
continue // Skip control characters
}
result = append(result, r)
}
name = string(result)
// Step 5: Replace invalid filesystem characters
// These are invalid on Windows: \ / : * ? " < > |
// Forward/back slashes are also dangerous for path traversal
replacer := map[rune]rune{
'/': '-',
'\\': '-',
':': '-',
'*': '-',
'?': '-',
'"': '\'',
'<': '(',
'>': ')',
'|': '-',
}
result = make([]rune, 0, len(name))
for _, r := range name {
if replacement, ok := replacer[r]; ok {
result = append(result, replacement)
} else {
result = append(result, r)
}
}
name = string(result)
// Step 6: Handle Windows reserved names
// These names are reserved regardless of extension: CON, PRN, AUX, NUL,
// COM1-COM9, LPT1-LPT9
upperName := strings.ToUpper(name)
// Extract name without extension for comparison
nameWithoutExt := upperName
if idx := strings.LastIndex(upperName, "."); idx > 0 {
nameWithoutExt = upperName[:idx]
}
reservedNames := map[string]bool{
"CON": true, "PRN": true, "AUX": true, "NUL": true,
"COM1": true, "COM2": true, "COM3": true, "COM4": true,
"COM5": true, "COM6": true, "COM7": true, "COM8": true, "COM9": true,
"LPT1": true, "LPT2": true, "LPT3": true, "LPT4": true,
"LPT5": true, "LPT6": true, "LPT7": true, "LPT8": true, "LPT9": true,
}
if reservedNames[nameWithoutExt] {
name = "_" + name
}
// Step 7: Limit filename length
// Most filesystems support 255 bytes; we use 200 to leave room for path
const maxFilenameLength = 200
if len(name) > maxFilenameLength {
// Try to preserve the extension
ext := filepath.Ext(name)
if len(ext) < maxFilenameLength-10 {
nameWithoutExt := name[:len(name)-len(ext)]
if len(nameWithoutExt) > maxFilenameLength-len(ext) {
nameWithoutExt = nameWithoutExt[:maxFilenameLength-len(ext)]
}
name = nameWithoutExt + ext
} else {
name = name[:maxFilenameLength]
}
}
// Final check
if name == "" {
return "unnamed"
}
return name
}
// copyFile copies a file from src to dst
func copyFile(src, dst string) error {
sourceFile, err := os.Open(src)
if err != nil {
return err
}
defer sourceFile.Close()
destFile, err := os.Create(dst)
if err != nil {
return err
}
defer destFile.Close()
_, err = io.Copy(destFile, sourceFile)
if err != nil {
return err
}
return destFile.Sync()
}

View file

@ -0,0 +1,204 @@
package app
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"time"
"go.uber.org/zap"
)
// =============================================================================
// EXPORT DATA OPERATIONS (Profile, Collections, Metadata)
// =============================================================================
// ExportUserProfile exports the user's profile data
func (a *Application) ExportUserProfile(exportPath string) (*UserProfileExport, error) {
a.logger.Info("Exporting user profile", zap.String("export_path", exportPath))
session, err := a.authService.GetCurrentSession(a.ctx)
if err != nil {
return nil, fmt.Errorf("not authenticated: %w", err)
}
apiClient := a.authService.GetAPIClient()
apiClient.SetTokens(session.AccessToken, session.RefreshToken)
// Get user profile
me, err := apiClient.GetMe(a.ctx)
if err != nil {
return nil, fmt.Errorf("failed to get user profile: %w", err)
}
profile := &UserProfileExport{
ID: me.ID,
Email: me.Email,
FirstName: me.FirstName,
LastName: me.LastName,
Name: me.Name,
Phone: me.Phone,
Country: me.Country,
Timezone: me.Timezone,
CreatedAt: me.CreatedAt.Format(time.RFC3339),
ExportedAt: time.Now().Format(time.RFC3339),
}
// Save to file
profilePath := filepath.Join(exportPath, "profile.json")
data, err := json.MarshalIndent(profile, "", " ")
if err != nil {
return nil, fmt.Errorf("failed to marshal profile: %w", err)
}
if err := os.WriteFile(profilePath, data, 0644); err != nil {
return nil, fmt.Errorf("failed to write profile file: %w", err)
}
a.logger.Info("User profile exported successfully", zap.String("path", profilePath))
return profile, nil
}
// ExportCollections exports all collections (owned and shared)
func (a *Application) ExportCollections(exportPath string) (*CollectionsExport, error) {
a.logger.Info("Exporting collections", zap.String("export_path", exportPath))
// Get owned collections
ownedCollections, err := a.ListCollections()
if err != nil {
return nil, fmt.Errorf("failed to list owned collections: %w", err)
}
// Get shared collections
sharedCollections, err := a.listSharedCollections()
if err != nil {
a.logger.Warn("Failed to list shared collections", zap.Error(err))
sharedCollections = []*CollectionData{}
}
// Convert to export format
ownedExport := make([]*CollectionExportData, len(ownedCollections))
for i, c := range ownedCollections {
ownedExport[i] = &CollectionExportData{
ID: c.ID,
Name: c.Name,
CollectionType: c.CollectionType,
ParentID: c.ParentID,
FileCount: c.TotalFiles,
CreatedAt: c.CreatedAt,
ModifiedAt: c.ModifiedAt,
IsShared: false,
}
}
sharedExport := make([]*CollectionExportData, len(sharedCollections))
for i, c := range sharedCollections {
sharedExport[i] = &CollectionExportData{
ID: c.ID,
Name: c.Name,
CollectionType: c.CollectionType,
ParentID: c.ParentID,
FileCount: c.TotalFiles,
CreatedAt: c.CreatedAt,
ModifiedAt: c.ModifiedAt,
IsShared: true,
}
}
export := &CollectionsExport{
OwnedCollections: ownedExport,
SharedCollections: sharedExport,
TotalCount: len(ownedExport) + len(sharedExport),
ExportedAt: time.Now().Format(time.RFC3339),
}
// Save to file
collectionsPath := filepath.Join(exportPath, "collections.json")
data, err := json.MarshalIndent(export, "", " ")
if err != nil {
return nil, fmt.Errorf("failed to marshal collections: %w", err)
}
if err := os.WriteFile(collectionsPath, data, 0644); err != nil {
return nil, fmt.Errorf("failed to write collections file: %w", err)
}
a.logger.Info("Collections exported successfully",
zap.String("path", collectionsPath),
zap.Int("owned", len(ownedExport)),
zap.Int("shared", len(sharedExport)))
return export, nil
}
// ExportAllFilesMetadata exports metadata for all files in all collections
func (a *Application) ExportAllFilesMetadata(exportPath string) (*FilesMetadataExport, error) {
a.logger.Info("Exporting all files metadata", zap.String("export_path", exportPath))
// Get all collections
ownedCollections, err := a.ListCollections()
if err != nil {
return nil, fmt.Errorf("failed to list owned collections: %w", err)
}
sharedCollections, err := a.listSharedCollections()
if err != nil {
a.logger.Warn("Failed to list shared collections", zap.Error(err))
sharedCollections = []*CollectionData{}
}
allCollections := append(ownedCollections, sharedCollections...)
allFiles := make([]*FileExportData, 0)
var totalSize int64 = 0
// Get files for each collection
for _, coll := range allCollections {
files, err := a.ListFilesByCollection(coll.ID)
if err != nil {
a.logger.Warn("Failed to list files for collection",
zap.String("collection_id", coll.ID),
zap.Error(err))
continue
}
for _, f := range files {
allFiles = append(allFiles, &FileExportData{
ID: f.ID,
Filename: f.Filename,
MimeType: f.ContentType,
SizeBytes: f.Size,
CreatedAt: f.CreatedAt,
ModifiedAt: f.ModifiedAt,
CollectionID: coll.ID,
CollectionName: coll.Name,
})
totalSize += f.Size
}
}
export := &FilesMetadataExport{
Files: allFiles,
TotalCount: len(allFiles),
TotalSize: totalSize,
ExportedAt: time.Now().Format(time.RFC3339),
}
// Save to file
metadataPath := filepath.Join(exportPath, "files_metadata.json")
data, err := json.MarshalIndent(export, "", " ")
if err != nil {
return nil, fmt.Errorf("failed to marshal files metadata: %w", err)
}
if err := os.WriteFile(metadataPath, data, 0644); err != nil {
return nil, fmt.Errorf("failed to write files metadata file: %w", err)
}
a.logger.Info("Files metadata exported successfully",
zap.String("path", metadataPath),
zap.Int("total_files", len(allFiles)),
zap.Int64("total_size", totalSize))
return export, nil
}

View file

@ -0,0 +1,346 @@
package app
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"time"
"go.uber.org/zap"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/pkg/maplefile/e2ee"
)
// =============================================================================
// EXPORT FILE CONTENT OPERATIONS
// =============================================================================
// ExportFileContent exports a single file - copies from local if available, otherwise downloads from cloud
func (a *Application) ExportFileContent(fileID string, collectionName string, exportPath string) (*FileExportResult, error) {
a.logger.Debug("Exporting file content",
zap.String("file_id", fileID),
zap.String("collection_name", collectionName))
result := &FileExportResult{
FileID: fileID,
Success: false,
}
// Create collection folder in export path
// Sanitize collection name for filesystem
safeName := sanitizeFilename(collectionName)
collectionDir := filepath.Join(exportPath, "files", safeName)
if err := os.MkdirAll(collectionDir, 0755); err != nil {
result.ErrorMessage = fmt.Sprintf("failed to create directory: %v", err)
return result, nil
}
// Check if file exists locally with decrypted content (FilePath must be non-empty)
// Note: HasLocalContent() returns true for encrypted-only files, but we need the
// decrypted FilePath to copy, not EncryptedFilePath
localFile, err := a.mustGetFileRepo().Get(fileID)
if err == nil && localFile != nil && localFile.FilePath != "" {
// File exists locally with decrypted content - copy it
result.Filename = localFile.Name
result.SourceType = "local"
result.SizeBytes = localFile.DecryptedSizeInBytes
destPath := filepath.Join(collectionDir, sanitizeFilename(localFile.Name))
result.DestPath = destPath
// Copy the file
if err := copyFile(localFile.FilePath, destPath); err != nil {
result.ErrorMessage = fmt.Sprintf("failed to copy local file: %v", err)
return result, nil
}
result.Success = true
a.logger.Debug("File copied from local storage",
zap.String("file_id", fileID),
zap.String("dest", destPath))
return result, nil
}
// File not available locally - download from cloud and save directly to export path
result.SourceType = "cloud"
// Download and decrypt file directly to export directory
filename, fileSize, err := a.downloadFileToPath(fileID, collectionDir)
if err != nil {
result.ErrorMessage = fmt.Sprintf("failed to download file: %v", err)
return result, nil
}
result.Filename = filename
result.SizeBytes = fileSize
result.DestPath = filepath.Join(collectionDir, sanitizeFilename(filename))
result.Success = true
a.logger.Debug("File downloaded from cloud",
zap.String("file_id", fileID),
zap.String("dest", result.DestPath))
return result, nil
}
// downloadFileToPath downloads and decrypts a file directly to a specified directory.
// Returns the filename, file size, and error. This is used for bulk exports without user dialog.
func (a *Application) downloadFileToPath(fileID string, destDir string) (string, int64, error) {
// Get current session for authentication
session, err := a.authService.GetCurrentSession(a.ctx)
if err != nil {
return "", 0, fmt.Errorf("not authenticated: %w", err)
}
// Get master key from cache
email := session.Email
masterKey, cleanupMasterKey, err := a.keyCache.GetMasterKey(email)
if err != nil {
return "", 0, fmt.Errorf("encryption key not available: %w", err)
}
defer cleanupMasterKey()
// Use SDK client which has automatic token refresh on 401
apiClient := a.authService.GetAPIClient()
// Step 1: Get file metadata using SDK (has automatic 401 retry)
fileData, err := apiClient.GetFile(a.ctx, fileID)
if err != nil {
return "", 0, fmt.Errorf("failed to get file: %w", err)
}
// Step 2: Get collection to decrypt collection key using SDK (has automatic 401 retry)
collData, err := apiClient.GetCollection(a.ctx, fileData.CollectionID)
if err != nil {
return "", 0, fmt.Errorf("failed to get collection: %w", err)
}
// Step 3: Decrypt collection key with master key
// SDK returns EncryptedCollectionKey as an object with Ciphertext and Nonce fields
// Use tryDecodeBase64 to handle multiple base64 encoding formats
collKeyNonce, err := tryDecodeBase64(collData.EncryptedCollectionKey.Nonce)
if err != nil {
return "", 0, fmt.Errorf("failed to decode collection key nonce: %w", err)
}
collKeyCiphertext, err := tryDecodeBase64(collData.EncryptedCollectionKey.Ciphertext)
if err != nil {
return "", 0, fmt.Errorf("failed to decode collection 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 {
return "", 0, fmt.Errorf("failed to decrypt collection key: %w", err)
}
// Step 4: 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(fileData.EncryptedFileKey.Nonce)
if err != nil {
return "", 0, fmt.Errorf("failed to decode file key nonce: %w", err)
}
fileKeyCiphertext, err := tryDecodeBase64(fileData.EncryptedFileKey.Ciphertext)
if err != nil {
return "", 0, 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 {
return "", 0, fmt.Errorf("failed to decrypt file key: %w", err)
}
// Step 5: Decrypt metadata to get filename
// Use tryDecodeBase64 to handle multiple base64 encoding formats
encryptedMetadataBytes, err := tryDecodeBase64(fileData.EncryptedMetadata)
if err != nil {
return "", 0, fmt.Errorf("failed to decode metadata: %w", err)
}
metadataNonce, metadataCiphertext, err := e2ee.SplitNonceAndCiphertextAuto(encryptedMetadataBytes)
if err != nil {
return "", 0, fmt.Errorf("failed to parse metadata: %w", err)
}
decryptedMetadata, err := e2ee.DecryptWithAlgorithm(metadataCiphertext, metadataNonce, fileKey)
if err != nil {
return "", 0, fmt.Errorf("failed to decrypt metadata: %w", err)
}
var metadata struct {
Filename string `json:"name"`
MimeType string `json:"mime_type"`
}
if err := json.Unmarshal(decryptedMetadata, &metadata); err != nil {
return "", 0, fmt.Errorf("failed to parse metadata: %w", err)
}
// Step 6: Get presigned download URL using SDK (has automatic 401 retry)
downloadResp, err := apiClient.GetPresignedDownloadURL(a.ctx, fileID)
if err != nil {
return "", 0, fmt.Errorf("failed to get download URL: %w", err)
}
// Step 7: Download encrypted file from S3 using SDK helper
encryptedContent, err := apiClient.DownloadFromPresignedURL(a.ctx, downloadResp.FileURL)
if err != nil {
return "", 0, fmt.Errorf("failed to download file: %w", err)
}
// Step 8: Decrypt file content
decryptedContent, err := e2ee.DecryptFile(encryptedContent, fileKey)
if err != nil {
return "", 0, fmt.Errorf("failed to decrypt file: %w", err)
}
// Step 9: Write decrypted content to destination
destPath := filepath.Join(destDir, sanitizeFilename(metadata.Filename))
if err := os.WriteFile(destPath, decryptedContent, 0600); err != nil {
return "", 0, fmt.Errorf("failed to save file: %w", err)
}
return metadata.Filename, int64(len(decryptedContent)), nil
}
// ExportAllFiles exports all files from all collections
func (a *Application) ExportAllFiles(exportPath string, progressCallback func(current, total int, filename string)) (*ExportSummary, error) {
a.logger.Info("Exporting all files", zap.String("export_path", exportPath))
summary := &ExportSummary{
ExportedAt: time.Now().Format(time.RFC3339),
ExportPath: exportPath,
Errors: make([]ExportError, 0),
}
// Get all collections
ownedCollections, err := a.ListCollections()
if err != nil {
return nil, fmt.Errorf("failed to list owned collections: %w", err)
}
summary.OwnedCollections = len(ownedCollections)
sharedCollections, err := a.listSharedCollections()
if err != nil {
a.logger.Warn("Failed to list shared collections", zap.Error(err))
sharedCollections = []*CollectionData{}
}
summary.SharedCollections = len(sharedCollections)
summary.TotalCollections = summary.OwnedCollections + summary.SharedCollections
// Build list of all files to export
type fileToExport struct {
fileID string
collectionID string
collectionName string
}
allFilesToExport := make([]fileToExport, 0)
// Collect files from owned collections
for _, coll := range ownedCollections {
files, err := a.ListFilesByCollection(coll.ID)
if err != nil {
a.logger.Warn("Failed to list files for collection",
zap.String("collection_id", coll.ID),
zap.Error(err))
continue
}
for _, f := range files {
allFilesToExport = append(allFilesToExport, fileToExport{
fileID: f.ID,
collectionID: coll.ID,
collectionName: coll.Name,
})
}
}
// Collect files from shared collections
for _, coll := range sharedCollections {
files, err := a.ListFilesByCollection(coll.ID)
if err != nil {
a.logger.Warn("Failed to list files for shared collection",
zap.String("collection_id", coll.ID),
zap.Error(err))
continue
}
for _, f := range files {
// Prefix shared collection names to distinguish them
collName := "Shared - " + coll.Name
allFilesToExport = append(allFilesToExport, fileToExport{
fileID: f.ID,
collectionID: coll.ID,
collectionName: collName,
})
}
}
summary.TotalFiles = len(allFilesToExport)
// Export each file
for i, f := range allFilesToExport {
if progressCallback != nil {
progressCallback(i+1, summary.TotalFiles, f.collectionName)
}
result, err := a.ExportFileContent(f.fileID, f.collectionName, exportPath)
if err != nil || !result.Success {
summary.FilesFailed++
errMsg := "unknown error"
if err != nil {
errMsg = err.Error()
} else if result.ErrorMessage != "" {
errMsg = result.ErrorMessage
}
summary.Errors = append(summary.Errors, ExportError{
FileID: f.fileID,
Filename: result.Filename,
CollectionID: f.collectionID,
ErrorMessage: errMsg,
Timestamp: time.Now().Format(time.RFC3339),
})
continue
}
summary.FilesExported++
summary.TotalSizeBytes += result.SizeBytes
if result.SourceType == "local" {
summary.FilesCopiedLocal++
} else {
summary.FilesDownloaded++
}
}
// Save export manifest
manifestPath := filepath.Join(exportPath, "export_manifest.json")
manifestData, err := json.MarshalIndent(summary, "", " ")
if err != nil {
a.logger.Warn("Failed to marshal export manifest", zap.Error(err))
} else {
if err := os.WriteFile(manifestPath, manifestData, 0644); err != nil {
a.logger.Warn("Failed to write export manifest", zap.Error(err))
}
}
a.logger.Info("Export completed",
zap.Int("files_exported", summary.FilesExported),
zap.Int("files_failed", summary.FilesFailed),
zap.Int("copied_local", summary.FilesCopiedLocal),
zap.Int("downloaded", summary.FilesDownloaded))
return summary, nil
}

View file

@ -0,0 +1,610 @@
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
}

View file

@ -0,0 +1,191 @@
package app
import (
"fmt"
"os"
"time"
"go.uber.org/zap"
"codeberg.org/mapleopentech/monorepo/native/desktop/maplefile/internal/domain/file"
)
// =============================================================================
// FILE CLEANUP OPERATIONS
// =============================================================================
// DeleteFile soft-deletes a file from both the cloud and local storage
func (a *Application) DeleteFile(fileID string) error {
a.logger.Info("DeleteFile called", zap.String("file_id", fileID))
// Use the SDK client which has automatic token refresh on 401
apiClient := a.authService.GetAPIClient()
// Use SDK's DeleteFile method which has automatic 401 retry
if err := apiClient.DeleteFile(a.ctx, fileID); err != nil {
a.logger.Error("Failed to delete file from cloud",
zap.String("file_id", fileID),
zap.Error(err))
return fmt.Errorf("failed to delete file: %w", err)
}
// Cloud delete succeeded - now clean up local data
a.cleanupLocalFile(fileID)
a.logger.Info("File deleted successfully", zap.String("file_id", fileID))
return nil
}
// cleanupLocalFile removes physical binary files immediately and marks the metadata as deleted.
// The metadata record is kept for background cleanup later.
func (a *Application) cleanupLocalFile(fileID string) {
// Get the local file record
localFile, err := a.mustGetFileRepo().Get(fileID)
if err != nil || localFile == nil {
a.logger.Debug("No local file record to clean up", zap.String("file_id", fileID))
return
}
// IMMEDIATELY delete physical binary files
// Delete the physical decrypted file if it exists
if localFile.FilePath != "" {
if err := os.Remove(localFile.FilePath); err != nil {
if !os.IsNotExist(err) {
a.logger.Warn("Failed to delete local decrypted file",
zap.String("file_id", fileID),
zap.String("path", localFile.FilePath),
zap.Error(err))
}
} else {
a.logger.Info("Deleted local decrypted file",
zap.String("file_id", fileID),
zap.String("path", localFile.FilePath))
}
}
// Delete the physical encrypted file if it exists
if localFile.EncryptedFilePath != "" {
if err := os.Remove(localFile.EncryptedFilePath); err != nil {
if !os.IsNotExist(err) {
a.logger.Warn("Failed to delete local encrypted file",
zap.String("file_id", fileID),
zap.String("path", localFile.EncryptedFilePath),
zap.Error(err))
}
} else {
a.logger.Info("Deleted local encrypted file",
zap.String("file_id", fileID),
zap.String("path", localFile.EncryptedFilePath))
}
}
// Delete the thumbnail if it exists
if localFile.ThumbnailPath != "" {
if err := os.Remove(localFile.ThumbnailPath); err != nil {
if !os.IsNotExist(err) {
a.logger.Warn("Failed to delete local thumbnail",
zap.String("file_id", fileID),
zap.String("path", localFile.ThumbnailPath),
zap.Error(err))
}
} else {
a.logger.Info("Deleted local thumbnail",
zap.String("file_id", fileID),
zap.String("path", localFile.ThumbnailPath))
}
}
// Mark the metadata record as deleted (will be cleaned up later by background process)
// Clear the file paths since the physical files are now deleted
localFile.State = file.StateDeleted
localFile.FilePath = ""
localFile.EncryptedFilePath = ""
localFile.ThumbnailPath = ""
localFile.ModifiedAt = time.Now()
if err := a.mustGetFileRepo().Update(localFile); err != nil {
a.logger.Warn("Failed to mark local file metadata as deleted",
zap.String("file_id", fileID),
zap.Error(err))
} else {
a.logger.Info("Marked local file metadata as deleted (will be cleaned up later)",
zap.String("file_id", fileID))
// Remove from search index
if err := a.searchService.DeleteFile(fileID); err != nil {
a.logger.Warn("Failed to remove file from search index",
zap.String("file_id", fileID),
zap.Error(err))
}
}
}
// purgeDeletedFileMetadata permanently removes a deleted file's metadata record.
// This is called by the background cleanup process after a retention period.
func (a *Application) purgeDeletedFileMetadata(fileID string) {
if err := a.mustGetFileRepo().Delete(fileID); err != nil {
a.logger.Warn("Failed to purge deleted file metadata",
zap.String("file_id", fileID),
zap.Error(err))
} else {
a.logger.Info("Purged deleted file metadata",
zap.String("file_id", fileID))
}
}
// deletedFileRetentionPeriod is how long to keep deleted file metadata before purging.
// This allows for potential recovery or sync conflict resolution.
const deletedFileRetentionPeriod = 7 * 24 * time.Hour // 7 days
// cleanupDeletedFiles runs in the background to clean up deleted files.
// It handles two cases:
// 1. Files marked as deleted that still have physical files (cleans up binaries immediately)
// 2. Files marked as deleted past the retention period (purges metadata)
func (a *Application) cleanupDeletedFiles() {
a.logger.Info("Starting background cleanup of deleted files")
// Get all local files
localFiles, err := a.mustGetFileRepo().List()
if err != nil {
a.logger.Error("Failed to list local files for cleanup", zap.Error(err))
return
}
binaryCleanedCount := 0
metadataPurgedCount := 0
now := time.Now()
for _, localFile := range localFiles {
// Only process deleted files
if localFile.State != file.StateDeleted {
continue
}
// Check if there are still physical files to clean up
if localFile.FilePath != "" || localFile.EncryptedFilePath != "" || localFile.ThumbnailPath != "" {
a.logger.Info("Cleaning up orphaned binary files for deleted record",
zap.String("file_id", localFile.ID))
a.cleanupLocalFile(localFile.ID)
binaryCleanedCount++
continue
}
// Check if metadata is past retention period and can be purged
if now.Sub(localFile.ModifiedAt) > deletedFileRetentionPeriod {
a.logger.Info("Purging deleted file metadata (past retention period)",
zap.String("file_id", localFile.ID),
zap.Time("deleted_at", localFile.ModifiedAt))
a.purgeDeletedFileMetadata(localFile.ID)
metadataPurgedCount++
}
}
if binaryCleanedCount > 0 || metadataPurgedCount > 0 {
a.logger.Info("Background cleanup completed",
zap.Int("binaries_cleaned", binaryCleanedCount),
zap.Int("metadata_purged", metadataPurgedCount))
} else {
a.logger.Debug("Background cleanup completed, no cleanup needed")
}
}

View file

@ -0,0 +1,880 @@
package app
import (
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"os/exec"
"path/filepath"
sysRuntime "runtime"
"strings"
"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"
"codeberg.org/mapleopentech/monorepo/native/desktop/maplefile/internal/service/inputvalidation"
)
// =============================================================================
// FILE DOWNLOAD OPERATIONS
// =============================================================================
// tryDecodeBase64 attempts to decode a base64 string using multiple encoding variants.
// The web frontend uses URL-safe base64 without padding (libsodium default),
// while Go typically uses standard base64 with padding.
func tryDecodeBase64(s string) ([]byte, error) {
var lastErr error
// Try URL-safe base64 without padding FIRST (libsodium's URLSAFE_NO_PADDING)
// This is the format used by the web frontend
if data, err := base64.RawURLEncoding.DecodeString(s); err == nil {
return data, nil
} else {
lastErr = err
}
// Try standard base64 with padding (Go's default)
if data, err := base64.StdEncoding.DecodeString(s); err == nil {
return data, nil
} else {
lastErr = err
}
// Try standard base64 without padding
if data, err := base64.RawStdEncoding.DecodeString(s); err == nil {
return data, nil
} else {
lastErr = err
}
// Try URL-safe base64 with padding
if data, err := base64.URLEncoding.DecodeString(s); err == nil {
return data, nil
} else {
lastErr = err
}
return nil, fmt.Errorf("failed to decode base64 with any encoding variant (input length: %d, first 50 chars: %s, last error: %w)", len(s), truncateString(s, 50), lastErr)
}
// truncateString truncates a string to the specified length
func truncateString(s string, maxLen int) string {
if len(s) <= maxLen {
return s
}
return s[:maxLen] + "..."
}
// GetFileDownloadURL gets a presigned download URL for a file
func (a *Application) GetFileDownloadURL(fileID string) (string, error) {
// Validate input
if err := inputvalidation.ValidateFileID(fileID); err != nil {
return "", err
}
// 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 "", fmt.Errorf("not authenticated: %w", err)
}
apiClient := a.authService.GetAPIClient()
// Make the HTTP GET request for download URL
// Note: Backend uses singular "file" not plural "files" in the path
req, err := http.NewRequestWithContext(a.ctx, "GET", apiClient.GetBaseURL()+"/api/v1/file/"+fileID+"/download-url", nil)
if err != nil {
a.logger.Error("Failed to create download URL request", zap.Error(err))
return "", 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 download URL request", zap.Error(err))
return "", 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 download URL",
zap.Int("status", resp.StatusCode),
zap.String("body", string(body)))
return "", fmt.Errorf("failed to get download URL: %s", string(body))
}
// Response structure matches backend's GetPresignedDownloadURLResponseDTO
var urlResp struct {
PresignedDownloadURL string `json:"presigned_download_url"`
DownloadURLExpirationTime string `json:"download_url_expiration_time"`
Success bool `json:"success"`
Message string `json:"message"`
}
if err := json.NewDecoder(resp.Body).Decode(&urlResp); err != nil {
a.logger.Error("Failed to decode download URL response", zap.Error(err))
return "", fmt.Errorf("failed to decode response: %w", err)
}
return urlResp.PresignedDownloadURL, nil
}
// DownloadFile downloads, decrypts, and saves a file to the user's chosen location.
// If the file already exists locally, it copies from local storage instead of re-downloading.
func (a *Application) DownloadFile(fileID string) (string, error) {
// Validate input
if err := inputvalidation.ValidateFileID(fileID); err != nil {
return "", err
}
a.logger.Info("Starting file download", zap.String("file_id", fileID))
// First, check if file already exists locally
localFile, err := a.mustGetFileRepo().Get(fileID)
if err == nil && localFile != nil && localFile.FilePath != "" {
// Check if local file actually exists on disk
if _, statErr := os.Stat(localFile.FilePath); statErr == nil {
a.logger.Info("File exists locally, using local copy",
zap.String("file_id", fileID),
zap.String("local_path", localFile.FilePath))
// Open save dialog for user to choose location
savePath, dialogErr := runtime.SaveFileDialog(a.ctx, runtime.SaveDialogOptions{
Title: "Save File As",
DefaultFilename: localFile.Name,
})
if dialogErr != nil {
return "", fmt.Errorf("failed to open save dialog: %w", dialogErr)
}
// User cancelled the dialog
if savePath == "" {
a.logger.Info("User cancelled save dialog")
return "", nil
}
// Copy local file to chosen location
srcFile, copyErr := os.Open(localFile.FilePath)
if copyErr != nil {
return "", fmt.Errorf("failed to open local file: %w", copyErr)
}
defer srcFile.Close()
dstFile, copyErr := os.Create(savePath)
if copyErr != nil {
return "", fmt.Errorf("failed to create destination file: %w", copyErr)
}
defer dstFile.Close()
if _, copyErr := io.Copy(dstFile, srcFile); copyErr != nil {
return "", fmt.Errorf("failed to copy file: %w", copyErr)
}
a.logger.Info("File saved from local copy",
zap.String("file_id", fileID),
zap.String("save_path", savePath))
return savePath, nil
}
}
// File not available locally, download from cloud
a.logger.Info("File not available locally, downloading from cloud", zap.String("file_id", fileID))
// 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 "", fmt.Errorf("not authenticated: %w", err)
}
// Get master key from cache
email := session.Email
masterKey, cleanupMasterKey, err := a.keyCache.GetMasterKey(email)
if err != nil {
a.logger.Error("Failed to get master key from cache", zap.Error(err))
return "", fmt.Errorf("encryption key not available: %w", err)
}
defer cleanupMasterKey()
apiClient := a.authService.GetAPIClient()
// Step 1: Get file metadata
fileReq, 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 "", fmt.Errorf("failed to create request: %w", err)
}
fileReq.Header.Set("Authorization", "Bearer "+session.AccessToken)
fileResp, err := a.httpClient.Do(fileReq)
if err != nil {
a.logger.Error("Failed to get file metadata", zap.Error(err))
return "", fmt.Errorf("failed to get file: %w", err)
}
defer fileResp.Body.Close()
if fileResp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(fileResp.Body)
a.logger.Error("Failed to get file", zap.Int("status", fileResp.StatusCode), zap.String("body", string(body)))
return "", fmt.Errorf("failed to get file: %s", string(body))
}
var fileData 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"`
}
if err := json.NewDecoder(fileResp.Body).Decode(&fileData); err != nil {
a.logger.Error("Failed to decode file response", zap.Error(err))
return "", fmt.Errorf("failed to decode response: %w", err)
}
// Step 2: Get collection to decrypt collection key
collReq, err := http.NewRequestWithContext(a.ctx, "GET", apiClient.GetBaseURL()+"/api/v1/collections/"+fileData.CollectionID, nil)
if err != nil {
a.logger.Error("Failed to create get collection request", zap.Error(err))
return "", 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", zap.Error(err))
return "", 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 "", 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 "", fmt.Errorf("failed to decode collection: %w", err)
}
// Step 3: Decrypt collection key with master key
// Use tryDecodeBase64 to handle multiple base64 encoding formats
collKeyNonce, err := tryDecodeBase64(collData.EncryptedCollectionKey.Nonce)
if err != nil {
return "", fmt.Errorf("failed to decode collection key nonce: %w", err)
}
collKeyCiphertext, err := tryDecodeBase64(collData.EncryptedCollectionKey.Ciphertext)
if err != nil {
return "", fmt.Errorf("failed to decode collection 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 "", fmt.Errorf("failed to decrypt collection key: %w", err)
}
// Step 4: 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(fileData.EncryptedFileKey.Nonce)
if err != nil {
return "", fmt.Errorf("failed to decode file key nonce: %w", err)
}
fileKeyCiphertext, err := tryDecodeBase64(fileData.EncryptedFileKey.Ciphertext)
if err != nil {
return "", fmt.Errorf("failed to decode file key ciphertext: %w", err)
}
// Handle web frontend combined ciphertext format (nonce + encrypted_data)
actualFileKeyCiphertext := extractActualCiphertext(fileKeyCiphertext, fileKeyNonce)
a.logger.Info("Decrypting file key",
zap.Int("nonce_size", len(fileKeyNonce)),
zap.Int("ciphertext_size", len(actualFileKeyCiphertext)),
zap.Int("collection_key_size", len(collectionKey)))
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 "", fmt.Errorf("failed to decrypt file key: %w", err)
}
a.logger.Info("File key decrypted successfully", zap.Int("file_key_size", len(fileKey)))
// Step 5: Decrypt metadata to get filename
// Use tryDecodeBase64 to handle URL-safe base64 without padding (libsodium format)
encryptedMetadataBytes, err := tryDecodeBase64(fileData.EncryptedMetadata)
if err != nil {
return "", fmt.Errorf("failed to decode metadata: %w", err)
}
metadataNonce, metadataCiphertext, err := e2ee.SplitNonceAndCiphertextAuto(encryptedMetadataBytes)
if err != nil {
return "", fmt.Errorf("failed to parse metadata: %w", err)
}
decryptedMetadata, err := e2ee.DecryptWithAlgorithm(metadataCiphertext, metadataNonce, fileKey)
if err != nil {
return "", fmt.Errorf("failed to decrypt metadata: %w", err)
}
var metadata struct {
Filename string `json:"name"`
MimeType string `json:"mime_type"`
}
if err := json.Unmarshal(decryptedMetadata, &metadata); err != nil {
return "", fmt.Errorf("failed to parse metadata: %w", err)
}
// Step 6: Get presigned download URL
downloadURL, err := a.GetFileDownloadURL(fileID)
if err != nil {
return "", fmt.Errorf("failed to get download URL: %w", err)
}
// Step 6.5: Validate download URL before use (SSRF protection)
if err := inputvalidation.ValidateDownloadURL(downloadURL); err != nil {
a.logger.Error("Download URL validation failed",
zap.String("file_id", fileID),
zap.Error(err))
return "", fmt.Errorf("download URL validation failed: %w", err)
}
// Step 7: Download encrypted file from S3 (use large download client - no timeout for big files)
a.logger.Info("Downloading encrypted file from S3", zap.String("filename", metadata.Filename))
downloadResp, err := a.httpClient.GetLargeDownload(downloadURL)
if err != nil {
a.logger.Error("Failed to download file from S3", zap.Error(err))
return "", fmt.Errorf("failed to download file: %w", err)
}
defer downloadResp.Body.Close()
if downloadResp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(downloadResp.Body)
a.logger.Error("S3 download failed", zap.Int("status", downloadResp.StatusCode), zap.String("body", string(body)))
return "", fmt.Errorf("failed to download file from storage: status %d", downloadResp.StatusCode)
}
encryptedContent, err := io.ReadAll(downloadResp.Body)
if err != nil {
a.logger.Error("Failed to read encrypted content", zap.Error(err))
return "", fmt.Errorf("failed to read file content: %w", err)
}
a.logger.Info("Downloaded encrypted file", zap.Int("encrypted_size", len(encryptedContent)))
// Step 8: Decrypt file content
a.logger.Info("Decrypting file content",
zap.Int("encrypted_size", len(encryptedContent)),
zap.Int("file_key_size", len(fileKey)),
zap.Int("first_bytes_of_content", int(encryptedContent[0])))
decryptedContent, err := e2ee.DecryptFile(encryptedContent, fileKey)
if err != nil {
a.logger.Error("Failed to decrypt file content",
zap.Error(err),
zap.Int("encrypted_size", len(encryptedContent)),
zap.Int("file_key_size", len(fileKey)))
return "", fmt.Errorf("failed to decrypt file: %w", err)
}
a.logger.Info("File content decrypted successfully",
zap.Int("decrypted_size", len(decryptedContent)))
a.logger.Info("Decrypted file", zap.Int("decrypted_size", len(decryptedContent)))
// Step 9: Open save dialog for user to choose location
savePath, err := runtime.SaveFileDialog(a.ctx, runtime.SaveDialogOptions{
Title: "Save File As",
DefaultFilename: metadata.Filename,
})
if err != nil {
a.logger.Error("Failed to open save dialog", zap.Error(err))
return "", fmt.Errorf("failed to open save dialog: %w", err)
}
// User cancelled the dialog
if savePath == "" {
a.logger.Info("User cancelled save dialog")
return "", nil
}
// Step 10: Write decrypted content to file (0600 = owner read/write only for security)
if err := os.WriteFile(savePath, decryptedContent, 0600); err != nil {
a.logger.Error("Failed to write file", zap.Error(err), zap.String("path", savePath))
return "", fmt.Errorf("failed to save file: %w", err)
}
a.logger.Info("File downloaded and decrypted successfully",
zap.String("file_id", fileID),
zap.String("filename", metadata.Filename),
zap.String("save_path", savePath),
zap.Int("size", len(decryptedContent)))
return savePath, nil
}
// OnloadFileResult represents the result of onloading a file for offline access
type OnloadFileResult struct {
FileID string `json:"file_id"`
Filename string `json:"filename"`
LocalFilePath string `json:"local_file_path"`
Size int64 `json:"size"`
Success bool `json:"success"`
Message string `json:"message"`
}
// OnloadFile downloads and stores a file locally for offline access (no save dialog)
func (a *Application) OnloadFile(fileID string) (*OnloadFileResult, error) {
// Validate input
if err := inputvalidation.ValidateFileID(fileID); err != nil {
return nil, err
}
a.logger.Info("Onloading file for offline access", zap.String("file_id", fileID))
// 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 cache
email := session.Email
masterKey, cleanupMasterKey, err := a.keyCache.GetMasterKey(email)
if err != nil {
a.logger.Error("Failed to get master key from cache", zap.Error(err))
return nil, fmt.Errorf("encryption key not available: %w", err)
}
defer cleanupMasterKey()
apiClient := a.authService.GetAPIClient()
// Step 1: Get file metadata
fileReq, err := http.NewRequestWithContext(a.ctx, "GET", apiClient.GetBaseURL()+"/api/v1/file/"+fileID, nil)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
fileReq.Header.Set("Authorization", "Bearer "+session.AccessToken)
fileResp, err := a.httpClient.Do(fileReq)
if err != nil {
return nil, fmt.Errorf("failed to get file: %w", err)
}
defer fileResp.Body.Close()
if fileResp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(fileResp.Body)
return nil, fmt.Errorf("failed to get file: %s", string(body))
}
var fileData struct {
ID string `json:"id"`
CollectionID string `json:"collection_id"`
EncryptedMetadata string `json:"encrypted_metadata"`
FileNonce string `json:"file_nonce"`
EncryptedSizeInBytes int64 `json:"encrypted_file_size_in_bytes"`
EncryptedFileKey struct {
Ciphertext string `json:"ciphertext"`
Nonce string `json:"nonce"`
} `json:"encrypted_file_key"`
}
if err := json.NewDecoder(fileResp.Body).Decode(&fileData); err != nil {
return nil, fmt.Errorf("failed to decode response: %w", err)
}
// Step 2: Get collection to decrypt collection key
collReq, err := http.NewRequestWithContext(a.ctx, "GET", apiClient.GetBaseURL()+"/api/v1/collections/"+fileData.CollectionID, nil)
if err != nil {
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 {
return nil, fmt.Errorf("failed to get collection: %w", err)
}
defer collResp.Body.Close()
if collResp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(collResp.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 {
return nil, fmt.Errorf("failed to decode collection: %w", err)
}
// Step 3: Decrypt collection key with master key
// Use tryDecodeBase64 to handle multiple base64 encoding formats
collKeyNonce, err := tryDecodeBase64(collData.EncryptedCollectionKey.Nonce)
if err != nil {
return nil, fmt.Errorf("failed to decode collection key nonce: %w", err)
}
collKeyCiphertext, err := tryDecodeBase64(collData.EncryptedCollectionKey.Ciphertext)
if err != nil {
return nil, fmt.Errorf("failed to decode collection 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 {
return nil, fmt.Errorf("failed to decrypt collection key: %w", err)
}
// Step 4: 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(fileData.EncryptedFileKey.Nonce)
if err != nil {
return nil, fmt.Errorf("failed to decode file key nonce: %w", err)
}
fileKeyCiphertext, err := tryDecodeBase64(fileData.EncryptedFileKey.Ciphertext)
if err != nil {
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 {
return nil, fmt.Errorf("failed to decrypt file key: %w", err)
}
// Step 5: Decrypt metadata to get filename
// Use tryDecodeBase64 to handle URL-safe base64 without padding (libsodium format)
encryptedMetadataBytes, err := tryDecodeBase64(fileData.EncryptedMetadata)
if err != nil {
return nil, fmt.Errorf("failed to decode metadata: %w", err)
}
metadataNonce, metadataCiphertext, err := e2ee.SplitNonceAndCiphertextAuto(encryptedMetadataBytes)
if err != nil {
return nil, fmt.Errorf("failed to parse metadata: %w", err)
}
decryptedMetadata, err := e2ee.DecryptWithAlgorithm(metadataCiphertext, metadataNonce, fileKey)
if err != nil {
return nil, fmt.Errorf("failed to decrypt metadata: %w", err)
}
var metadata struct {
Filename string `json:"name"`
MimeType string `json:"mime_type"`
}
if err := json.Unmarshal(decryptedMetadata, &metadata); err != nil {
return nil, fmt.Errorf("failed to parse metadata: %w", err)
}
// Step 6: Get presigned download URL
downloadURL, err := a.GetFileDownloadURL(fileID)
if err != nil {
return nil, fmt.Errorf("failed to get download URL: %w", err)
}
// Step 6.5: Validate download URL before use (SSRF protection)
if err := inputvalidation.ValidateDownloadURL(downloadURL); err != nil {
a.logger.Error("Download URL validation failed",
zap.String("file_id", fileID),
zap.Error(err))
return nil, fmt.Errorf("download URL validation failed: %w", err)
}
// Step 7: Download encrypted file from S3 (use large download client - no timeout for big files)
a.logger.Info("Downloading encrypted file from S3", zap.String("filename", metadata.Filename))
downloadResp, err := a.httpClient.GetLargeDownload(downloadURL)
if err != nil {
return nil, fmt.Errorf("failed to download file: %w", err)
}
defer downloadResp.Body.Close()
if downloadResp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("failed to download file from storage: status %d", downloadResp.StatusCode)
}
encryptedContent, err := io.ReadAll(downloadResp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read file content: %w", err)
}
// Step 8: Decrypt file content
decryptedContent, err := e2ee.DecryptFile(encryptedContent, fileKey)
if err != nil {
return nil, fmt.Errorf("failed to decrypt file: %w", err)
}
// Step 9: Create local storage directory structure
dataDir, err := a.config.GetAppDataDirPath(a.ctx)
if err != nil {
return nil, fmt.Errorf("failed to get data directory: %w", err)
}
// Create files directory: <data_dir>/files/<collection_id>/
filesDir := filepath.Join(dataDir, "files", fileData.CollectionID)
if err := os.MkdirAll(filesDir, 0700); err != nil {
return nil, fmt.Errorf("failed to create files directory: %w", err)
}
// Save decrypted file
localFilePath := filepath.Join(filesDir, metadata.Filename)
if err := os.WriteFile(localFilePath, decryptedContent, 0600); err != nil {
return nil, fmt.Errorf("failed to save file: %w", err)
}
// Step 10: Update local file repository with sync status
localFile := &file.File{
ID: fileID,
CollectionID: fileData.CollectionID,
Name: metadata.Filename,
MimeType: metadata.MimeType,
FilePath: localFilePath,
DecryptedSizeInBytes: int64(len(decryptedContent)),
EncryptedSizeInBytes: fileData.EncryptedSizeInBytes,
SyncStatus: file.SyncStatusSynced,
EncryptedFileKey: file.EncryptedFileKeyData{
Ciphertext: fileData.EncryptedFileKey.Ciphertext,
Nonce: fileData.EncryptedFileKey.Nonce,
},
EncryptedMetadata: fileData.EncryptedMetadata,
FileNonce: fileData.FileNonce,
}
// Check if file already exists in local repo
existingFile, _ := a.mustGetFileRepo().Get(fileID)
if existingFile != nil {
if err := a.mustGetFileRepo().Update(localFile); err != nil {
a.logger.Warn("Failed to update local file record", zap.Error(err))
}
} else {
if err := a.mustGetFileRepo().Create(localFile); err != nil {
a.logger.Warn("Failed to create local file record", zap.Error(err))
}
}
a.logger.Info("File onloaded successfully",
zap.String("file_id", fileID),
zap.String("filename", metadata.Filename),
zap.String("local_path", localFilePath),
zap.Int("size", len(decryptedContent)))
return &OnloadFileResult{
FileID: fileID,
Filename: metadata.Filename,
LocalFilePath: localFilePath,
Size: int64(len(decryptedContent)),
Success: true,
Message: "File downloaded for offline access",
}, nil
}
// OffloadFile removes the local copy of a file while keeping it in the cloud
func (a *Application) OffloadFile(fileID string) error {
// Validate input
if err := inputvalidation.ValidateFileID(fileID); err != nil {
return err
}
a.logger.Info("Offloading file to cloud-only", zap.String("file_id", fileID))
// Get the file from local repository
localFile, err := a.mustGetFileRepo().Get(fileID)
if err != nil {
a.logger.Error("Failed to get file from local repo", zap.Error(err))
return fmt.Errorf("file not found locally: %w", err)
}
if localFile == nil {
return fmt.Errorf("file not found in local storage")
}
if !localFile.HasLocalContent() {
a.logger.Info("File already cloud-only, nothing to offload")
return nil
}
// Delete the local file from disk
if localFile.FilePath != "" {
if err := os.Remove(localFile.FilePath); err != nil && !os.IsNotExist(err) {
a.logger.Warn("Failed to delete local file", zap.Error(err), zap.String("path", localFile.FilePath))
// Continue anyway - we'll update the metadata
} else {
a.logger.Info("Deleted local file", zap.String("path", localFile.FilePath))
}
}
// Delete encrypted file if it exists
if localFile.EncryptedFilePath != "" {
if err := os.Remove(localFile.EncryptedFilePath); err != nil && !os.IsNotExist(err) {
a.logger.Warn("Failed to delete encrypted file", zap.Error(err), zap.String("path", localFile.EncryptedFilePath))
}
}
// Delete thumbnail if it exists
if localFile.ThumbnailPath != "" {
if err := os.Remove(localFile.ThumbnailPath); err != nil && !os.IsNotExist(err) {
a.logger.Warn("Failed to delete thumbnail", zap.Error(err), zap.String("path", localFile.ThumbnailPath))
}
}
// Update the local file record to cloud-only status
localFile.FilePath = ""
localFile.EncryptedFilePath = ""
localFile.ThumbnailPath = ""
localFile.SyncStatus = file.SyncStatusCloudOnly
if err := a.mustGetFileRepo().Update(localFile); err != nil {
a.logger.Error("Failed to update file record", zap.Error(err))
return fmt.Errorf("failed to update file record: %w", err)
}
a.logger.Info("File offloaded successfully",
zap.String("file_id", fileID),
zap.String("filename", localFile.Name))
return nil
}
// OpenFile opens a locally stored file with the system's default application
func (a *Application) OpenFile(fileID string) error {
// Validate input
if err := inputvalidation.ValidateFileID(fileID); err != nil {
return err
}
a.logger.Info("Opening file", zap.String("file_id", fileID))
// Get the file from local repository
localFile, err := a.mustGetFileRepo().Get(fileID)
if err != nil {
a.logger.Error("Failed to get file from local repo", zap.Error(err))
return fmt.Errorf("file not found locally: %w", err)
}
if localFile == nil {
return fmt.Errorf("file not found in local storage")
}
if localFile.FilePath == "" {
return fmt.Errorf("file has not been downloaded for offline access")
}
// Security: Validate file path is within expected application data directory
appDataDir, err := a.config.GetAppDataDirPath(a.ctx)
if err != nil {
a.logger.Error("Failed to get app data directory", zap.Error(err))
return fmt.Errorf("failed to validate file path: %w", err)
}
if err := validatePathWithinDirectory(localFile.FilePath, appDataDir); err != nil {
a.logger.Error("File path validation failed",
zap.String("file_path", localFile.FilePath),
zap.String("expected_dir", appDataDir),
zap.Error(err))
return fmt.Errorf("invalid file path: %w", err)
}
// Check if file exists on disk
if _, err := os.Stat(localFile.FilePath); os.IsNotExist(err) {
return fmt.Errorf("file no longer exists at %s", localFile.FilePath)
}
// Open the file with the system's default application
var cmd *exec.Cmd
switch sysRuntime.GOOS {
case "darwin":
cmd = exec.Command("open", localFile.FilePath)
case "windows":
cmd = exec.Command("cmd", "/c", "start", "", localFile.FilePath)
case "linux":
cmd = exec.Command("xdg-open", localFile.FilePath)
default:
return fmt.Errorf("unsupported operating system: %s", sysRuntime.GOOS)
}
if err := cmd.Start(); err != nil {
a.logger.Error("Failed to open file", zap.Error(err), zap.String("path", localFile.FilePath))
return fmt.Errorf("failed to open file: %w", err)
}
a.logger.Info("File opened successfully",
zap.String("file_id", fileID),
zap.String("path", localFile.FilePath))
return nil
}
// validatePathWithinDirectory checks that a file path is within the expected directory.
// This is a defense-in-depth measure to prevent path traversal attacks.
func validatePathWithinDirectory(filePath, expectedDir string) error {
// Get absolute paths to handle any relative path components
absFilePath, err := filepath.Abs(filePath)
if err != nil {
return fmt.Errorf("failed to resolve file path: %w", err)
}
absExpectedDir, err := filepath.Abs(expectedDir)
if err != nil {
return fmt.Errorf("failed to resolve expected directory: %w", err)
}
// Clean paths to remove any . or .. components
absFilePath = filepath.Clean(absFilePath)
absExpectedDir = filepath.Clean(absExpectedDir)
// Ensure the expected directory ends with a separator to prevent partial matches
// e.g., /app/data should not match /app/data-other/file
if !strings.HasSuffix(absExpectedDir, string(filepath.Separator)) {
absExpectedDir = absExpectedDir + string(filepath.Separator)
}
// Check if the file path starts with the expected directory
if !strings.HasPrefix(absFilePath, absExpectedDir) && absFilePath != strings.TrimSuffix(absExpectedDir, string(filepath.Separator)) {
return fmt.Errorf("path is outside application data directory")
}
return nil
}

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
}

View file

@ -0,0 +1,225 @@
package app
import (
"encoding/base64"
"fmt"
"go.uber.org/zap"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/pkg/maplefile/e2ee"
"codeberg.org/mapleopentech/monorepo/native/desktop/maplefile/internal/utils"
)
// VerifyPassword verifies a password against stored encrypted data
func (a *Application) VerifyPassword(password string) (bool, error) {
// Get current session with encrypted data
session, err := a.authService.GetCurrentSession(a.ctx)
if err != nil || session == nil {
return false, fmt.Errorf("no active session")
}
// Check if we have the encrypted data needed for verification
if session.Salt == "" || session.EncryptedMasterKey == "" {
return false, fmt.Errorf("session missing encrypted data for password verification")
}
// Decode base64 inputs
salt, err := base64.StdEncoding.DecodeString(session.Salt)
if err != nil {
a.logger.Error("Failed to decode salt", zap.Error(err))
return false, fmt.Errorf("invalid salt encoding")
}
encryptedMasterKeyBytes, err := base64.StdEncoding.DecodeString(session.EncryptedMasterKey)
if err != nil {
a.logger.Error("Failed to decode encrypted master key", zap.Error(err))
return false, fmt.Errorf("invalid master key encoding")
}
// Determine which KDF algorithm to use
kdfAlgorithm := session.KDFAlgorithm
if kdfAlgorithm == "" {
kdfAlgorithm = e2ee.PBKDF2Algorithm
}
// Try to derive KEK and decrypt master key using SecureKeyChain
// If decryption succeeds, password is correct
keychain, err := e2ee.NewSecureKeyChainWithAlgorithm(password, salt, kdfAlgorithm)
if err != nil {
a.logger.Debug("Password verification failed - could not derive key", zap.String("email", utils.MaskEmail(session.Email)))
return false, nil // Password is incorrect, but not an error condition
}
defer keychain.Clear()
// Split nonce and ciphertext from encrypted master key
// Use auto-detection to handle both ChaCha20 (12-byte nonce) and XSalsa20 (24-byte nonce)
masterKeyNonce, masterKeyCiphertext, err := e2ee.SplitNonceAndCiphertextAuto(encryptedMasterKeyBytes)
if err != nil {
a.logger.Error("Failed to split encrypted master key", zap.Error(err))
return false, fmt.Errorf("invalid master key format")
}
encryptedMasterKeyStruct := &e2ee.EncryptedKey{
Ciphertext: masterKeyCiphertext,
Nonce: masterKeyNonce,
}
// Try to decrypt the master key into protected memory
masterKey, err := keychain.DecryptMasterKeySecure(encryptedMasterKeyStruct)
if err != nil {
a.logger.Debug("Password verification failed - incorrect password", zap.String("email", utils.MaskEmail(session.Email)))
return false, nil // Password is incorrect, but not an error condition
}
// Copy master key bytes before destroying the buffer
// We'll cache it after verification succeeds
masterKeyBytes := make([]byte, masterKey.Size())
copy(masterKeyBytes, masterKey.Bytes())
masterKey.Destroy()
// Cache the master key for the session (already decrypted, no need to re-derive)
if err := a.keyCache.StoreMasterKey(session.Email, masterKeyBytes); err != nil {
a.logger.Warn("Failed to cache master key during password verification", zap.Error(err))
// Don't fail verification if caching fails
} else {
a.logger.Info("Master key cached successfully during password verification", zap.String("email", utils.MaskEmail(session.Email)))
}
a.logger.Info("Password verified successfully", zap.String("email", utils.MaskEmail(session.Email)))
return true, nil
}
// StorePasswordForSession stores password for current session (used by PasswordPrompt)
func (a *Application) StorePasswordForSession(password string) error {
session, err := a.authService.GetCurrentSession(a.ctx)
if err != nil || session == nil {
return fmt.Errorf("no active session")
}
if err := a.passwordStore.StorePassword(session.Email, password); err != nil {
a.logger.Error("Failed to store password for session", zap.String("email", utils.MaskEmail(session.Email)), zap.Error(err))
return err
}
a.logger.Info("Password re-stored in secure RAM after app restart", zap.String("email", utils.MaskEmail(session.Email)))
// Note: Master key caching is now handled in VerifyPassword()
// to avoid running PBKDF2 twice. The password verification step
// already derives KEK and decrypts the master key, so we cache it there.
// This eliminates redundant key derivation delay.
return nil
}
// GetStoredPassword retrieves the stored password for current session
func (a *Application) GetStoredPassword() (string, error) {
session, err := a.authService.GetCurrentSession(a.ctx)
if err != nil || session == nil {
return "", fmt.Errorf("no active session")
}
return a.passwordStore.GetPassword(session.Email)
}
// HasStoredPassword checks if password is stored for current session
func (a *Application) HasStoredPassword() bool {
session, err := a.authService.GetCurrentSession(a.ctx)
if err != nil || session == nil {
return false
}
return a.passwordStore.HasPassword(session.Email)
}
// ClearStoredPassword clears the stored password (optional, for security-sensitive operations)
func (a *Application) ClearStoredPassword() error {
session, err := a.authService.GetCurrentSession(a.ctx)
if err != nil || session == nil {
return fmt.Errorf("no active session")
}
return a.passwordStore.ClearPassword(session.Email)
}
// cacheMasterKeyFromPassword decrypts and caches the master key for the session
// This is an internal helper method used by CompleteLogin and StorePasswordForSession
func (a *Application) cacheMasterKeyFromPassword(email, password, saltBase64, encryptedMasterKeyBase64, kdfAlgorithm string) error {
// Default to PBKDF2-SHA256
if kdfAlgorithm == "" {
kdfAlgorithm = e2ee.PBKDF2Algorithm
}
// Decode base64 inputs
salt, err := base64.StdEncoding.DecodeString(saltBase64)
if err != nil {
return fmt.Errorf("invalid salt encoding: %w", err)
}
encryptedMasterKeyBytes, err := base64.StdEncoding.DecodeString(encryptedMasterKeyBase64)
if err != nil {
return fmt.Errorf("invalid master key encoding: %w", err)
}
// Create secure keychain to derive KEK using the correct KDF algorithm
keychain, err := e2ee.NewSecureKeyChainWithAlgorithm(password, salt, kdfAlgorithm)
if err != nil {
return fmt.Errorf("failed to derive KEK: %w", err)
}
defer keychain.Clear()
// Split nonce and ciphertext using 24-byte nonce (XSalsa20 secretbox format from web frontend)
masterKeyNonce, masterKeyCiphertext, err := e2ee.SplitNonceAndCiphertextSecretBox(encryptedMasterKeyBytes)
if err != nil {
return fmt.Errorf("invalid master key format: %w", err)
}
encryptedMasterKeyStruct := &e2ee.EncryptedKey{
Ciphertext: masterKeyCiphertext,
Nonce: masterKeyNonce,
}
// Decrypt master key into secure buffer (auto-detects cipher based on nonce size)
masterKey, err := keychain.DecryptMasterKeySecure(encryptedMasterKeyStruct)
if err != nil {
return fmt.Errorf("failed to decrypt master key: %w", err)
}
// CRITICAL: Copy bytes BEFORE destroying the buffer to avoid SIGBUS fault
// masterKey.Bytes() returns a pointer to LockedBuffer memory which becomes
// invalid after Destroy() is called
masterKeyBytes := make([]byte, masterKey.Size())
copy(masterKeyBytes, masterKey.Bytes())
// Now safely destroy the secure buffer
masterKey.Destroy()
// Store the copied bytes in cache
if err := a.keyCache.StoreMasterKey(email, masterKeyBytes); err != nil {
return fmt.Errorf("failed to cache master key: %w", err)
}
a.logger.Info("Master key cached successfully for session", zap.String("email", utils.MaskEmail(email)))
return nil
}
// GetCachedMasterKey retrieves the cached master key for the current session
// This is exported and can be called from frontend for file operations
// Returns the master key bytes and a cleanup function that MUST be called when done
func (a *Application) GetCachedMasterKey() ([]byte, func(), error) {
session, err := a.authService.GetCurrentSession(a.ctx)
if err != nil || session == nil {
return nil, nil, fmt.Errorf("no active session")
}
return a.keyCache.GetMasterKey(session.Email)
}
// HasCachedMasterKey checks if a master key is cached for the current session
func (a *Application) HasCachedMasterKey() bool {
session, err := a.authService.GetCurrentSession(a.ctx)
if err != nil || session == nil {
return false
}
return a.keyCache.HasMasterKey(session.Email)
}

View file

@ -0,0 +1,324 @@
// app_search.go contains the search-related application layer code.
//
// This file provides:
// - Search index initialization and rebuild logic
// - Wails bindings for frontend search functionality
// - File and collection indexing helpers
//
// The search feature uses Bleve for local full-text search. Each user has their
// own isolated search index stored in their local application data directory.
// Search results are deduplicated by filename to avoid showing the same file
// multiple times when it exists in multiple collections.
package app
import (
"fmt"
"time"
"go.uber.org/zap"
"codeberg.org/mapleopentech/monorepo/native/desktop/maplefile/internal/domain/file"
"codeberg.org/mapleopentech/monorepo/native/desktop/maplefile/internal/service/search"
)
// =============================================================================
// SEARCH INDEXING OPERATIONS
// These functions are called internally when files/collections are created,
// updated, or when the index needs to be rebuilt.
// =============================================================================
// indexFileForSearch indexes a single file in the search index.
// This is called when a new file is uploaded to add it to the search index immediately.
func (a *Application) indexFileForSearch(fileID, collectionID, filename string, tags []string, size int64) error {
// Get collection name for denormalization
collectionName := ""
collectionRepo := a.getCollectionRepo()
if collectionRepo != nil {
collection, err := collectionRepo.Get(collectionID)
if err == nil && collection != nil {
collectionName = collection.Name
}
}
// Create file document for search
fileDoc := &search.FileDocument{
ID: fileID,
Filename: filename,
Description: "", // No description field in current implementation
CollectionID: collectionID,
CollectionName: collectionName,
Tags: tags,
Size: size,
CreatedAt: time.Now(),
Type: "file",
}
return a.searchService.IndexFile(fileDoc)
}
// indexCollectionForSearch indexes a collection in the search index
func (a *Application) indexCollectionForSearch(collectionID, name string, tags []string, fileCount int) error {
// Create collection document for search
collectionDoc := &search.CollectionDocument{
ID: collectionID,
Name: name,
Description: "", // No description field in current implementation
Tags: tags,
FileCount: fileCount,
CreatedAt: time.Now(),
Type: "collection",
}
return a.searchService.IndexCollection(collectionDoc)
}
// InitializeSearchIndex initializes the search index for the current user.
// This can be called manually if the index needs to be initialized.
func (a *Application) InitializeSearchIndex() error {
a.logger.Info("Manually initializing search index")
// Get current session to get user email
session, err := a.authService.GetCurrentSession(a.ctx)
if err != nil || session == nil {
return fmt.Errorf("no active session found")
}
// Initialize the search service
if err := a.searchService.Initialize(a.ctx, session.Email); err != nil {
a.logger.Error("Failed to initialize search index", zap.Error(err))
return fmt.Errorf("failed to initialize search index: %w", err)
}
a.logger.Info("Search index initialized successfully")
// Rebuild index from local data
if err := a.rebuildSearchIndexForUser(session.Email); err != nil {
a.logger.Warn("Failed to rebuild search index", zap.Error(err))
return fmt.Errorf("failed to rebuild search index: %w", err)
}
return nil
}
// rebuildSearchIndexForUser rebuilds the entire search index from the local file repository.
// This is called on app startup and after login to ensure the search index is up-to-date.
// The rebuild process:
// 1. Lists all files from the local repository
// 2. Deduplicates files by ID (in case of repository corruption)
// 3. Skips deleted files
// 4. Passes all files to the search service for batch indexing
func (a *Application) rebuildSearchIndexForUser(userEmail string) error {
a.logger.Info("Rebuilding search index from local data", zap.String("email", userEmail))
fileRepo := a.getFileRepo()
if fileRepo == nil {
return fmt.Errorf("file repository not available")
}
// Get all local files
localFiles, err := fileRepo.List()
if err != nil {
a.logger.Error("Failed to list files for search index rebuild", zap.Error(err))
return fmt.Errorf("failed to list files: %w", err)
}
// Convert to search documents - use map to deduplicate by ID
fileDocumentsMap := make(map[string]*search.FileDocument)
for _, f := range localFiles {
// Skip deleted files
if f.State == file.StateDeleted {
continue
}
// Check for duplicates in local file repo
if _, exists := fileDocumentsMap[f.ID]; exists {
a.logger.Warn("Duplicate file found in local repository",
zap.String("id", f.ID),
zap.String("name", f.Name))
continue
}
// Get collection name if available
collectionName := ""
collectionRepo := a.getCollectionRepo()
if collectionRepo != nil {
collection, err := collectionRepo.Get(f.CollectionID)
if err == nil && collection != nil {
collectionName = collection.Name
}
}
fileDoc := &search.FileDocument{
ID: f.ID,
Filename: f.Name,
Description: "",
CollectionID: f.CollectionID,
CollectionName: collectionName,
Tags: []string{}, // Tags not stored in file entity currently
Size: f.DecryptedSizeInBytes,
CreatedAt: f.CreatedAt,
Type: "file",
}
fileDocumentsMap[f.ID] = fileDoc
}
// Convert map to slice
fileDocuments := make([]*search.FileDocument, 0, len(fileDocumentsMap))
for _, doc := range fileDocumentsMap {
fileDocuments = append(fileDocuments, doc)
}
a.logger.Info("Prepared files for indexing",
zap.Int("total_from_repo", len(localFiles)),
zap.Int("unique_files", len(fileDocuments)))
// For now, we don't index collections separately since they're fetched from cloud
// Collections will be indexed when they're explicitly created/updated
collectionDocuments := []*search.CollectionDocument{}
// Rebuild the index
if err := a.searchService.RebuildIndex(userEmail, fileDocuments, collectionDocuments); err != nil {
a.logger.Error("Failed to rebuild search index", zap.Error(err))
return fmt.Errorf("failed to rebuild search index: %w", err)
}
a.logger.Info("Search index rebuilt successfully",
zap.Int("files_indexed", len(fileDocuments)),
zap.Int("collections_indexed", len(collectionDocuments)))
return nil
}
// =============================================================================
// WAILS BINDINGS - Exposed to Frontend
// =============================================================================
// SearchInput represents the input for search
type SearchInput struct {
Query string `json:"query"`
Limit int `json:"limit,omitempty"`
}
// SearchResultData represents search results for the frontend
type SearchResultData struct {
Files []FileSearchResult `json:"files"`
Collections []CollectionSearchResult `json:"collections"`
TotalFiles int `json:"total_files"`
TotalCollections int `json:"total_collections"`
TotalHits uint64 `json:"total_hits"`
MaxScore float64 `json:"max_score"`
Query string `json:"query"`
}
// FileSearchResult represents a file in search results
type FileSearchResult struct {
ID string `json:"id"`
Filename string `json:"filename"`
CollectionID string `json:"collection_id"`
CollectionName string `json:"collection_name"`
Tags []string `json:"tags"`
Size int64 `json:"size"`
CreatedAt string `json:"created_at"`
}
// CollectionSearchResult represents a collection in search results
type CollectionSearchResult struct {
ID string `json:"id"`
Name string `json:"name"`
Tags []string `json:"tags"`
FileCount int `json:"file_count"`
CreatedAt string `json:"created_at"`
}
// Search performs a full-text search across files and collections.
// This is the main Wails binding exposed to the frontend for search functionality.
//
// Features:
// - Case-insensitive substring matching (e.g., "mesh" finds "meshtastic")
// - Deduplication by filename (same filename in multiple collections shows once)
// - Auto-initialization if search index is not ready
// - Support for Bleve query syntax (+, -, "", *, ?)
func (a *Application) Search(input SearchInput) (*SearchResultData, error) {
a.logger.Info("Performing search", zap.String("query", input.Query))
// Validate input
if input.Query == "" {
return nil, fmt.Errorf("search query cannot be empty")
}
// Set default limit if not specified
limit := input.Limit
if limit == 0 {
limit = 50
}
// Perform search
result, err := a.searchService.Search(input.Query, limit)
if err != nil {
// If search index is not initialized, try to initialize it automatically
if err.Error() == "search index not initialized" {
a.logger.Warn("Search index not initialized, attempting to initialize now")
if initErr := a.InitializeSearchIndex(); initErr != nil {
a.logger.Error("Failed to auto-initialize search index", zap.Error(initErr))
return nil, fmt.Errorf("search index not initialized. Please log out and log back in, or contact support")
}
// Retry search after initialization
result, err = a.searchService.Search(input.Query, limit)
if err != nil {
a.logger.Error("Search failed after auto-initialization", zap.String("query", input.Query), zap.Error(err))
return nil, fmt.Errorf("search failed: %w", err)
}
} else {
a.logger.Error("Search failed", zap.String("query", input.Query), zap.Error(err))
return nil, fmt.Errorf("search failed: %w", err)
}
}
// Convert to frontend format with deduplication by filename
// Only show one file per unique filename (first occurrence wins)
files := make([]FileSearchResult, 0, len(result.Files))
seenFilenames := make(map[string]bool)
for _, f := range result.Files {
// Skip if we've already seen this filename
if seenFilenames[f.Filename] {
continue
}
seenFilenames[f.Filename] = true
files = append(files, FileSearchResult{
ID: f.ID,
Filename: f.Filename,
CollectionID: f.CollectionID,
CollectionName: f.CollectionName,
Tags: f.Tags,
Size: f.Size,
CreatedAt: f.CreatedAt.Format(time.RFC3339),
})
}
collections := make([]CollectionSearchResult, 0, len(result.Collections))
for _, c := range result.Collections {
collections = append(collections, CollectionSearchResult{
ID: c.ID,
Name: c.Name,
Tags: c.Tags,
FileCount: c.FileCount,
CreatedAt: c.CreatedAt.Format(time.RFC3339),
})
}
a.logger.Info("Search completed",
zap.String("query", input.Query),
zap.Int("files_found", len(files)),
zap.Int("collections_found", len(collections)))
return &SearchResultData{
Files: files,
Collections: collections,
TotalFiles: len(files),
TotalCollections: len(collections),
TotalHits: result.TotalHits,
MaxScore: result.MaxScore,
Query: input.Query,
}, nil
}

View file

@ -0,0 +1,38 @@
package app
// GetTheme returns the current theme setting
func (a *Application) GetTheme() (string, error) {
return a.config.GetTheme(a.ctx)
}
// SetTheme updates the theme setting
func (a *Application) SetTheme(theme string) error {
return a.config.SetTheme(a.ctx, theme)
}
// GetWindowSize returns the configured window size
func (a *Application) GetWindowSize() (map[string]int, error) {
width, height, err := a.config.GetWindowSize(a.ctx)
if err != nil {
return nil, err
}
return map[string]int{
"width": width,
"height": height,
}, nil
}
// SetWindowSize updates the window size configuration
func (a *Application) SetWindowSize(width, height int) error {
return a.config.SetWindowSize(a.ctx, width, height)
}
// GetCloudProviderAddress returns the backend API URL
func (a *Application) GetCloudProviderAddress() (string, error) {
return a.config.GetCloudProviderAddress(a.ctx)
}
// SetCloudProviderAddress updates the backend API URL
func (a *Application) SetCloudProviderAddress(address string) error {
return a.config.SetCloudProviderAddress(a.ctx, address)
}

View file

@ -0,0 +1,148 @@
package app
import (
"fmt"
"time"
"go.uber.org/zap"
"codeberg.org/mapleopentech/monorepo/native/desktop/maplefile/internal/service/sync"
)
// SyncStatusData represents the current sync status for the frontend
type SyncStatusData struct {
IsSyncing bool `json:"is_syncing"`
LastSyncTime string `json:"last_sync_time,omitempty"`
LastSyncSuccess bool `json:"last_sync_success"`
LastSyncError string `json:"last_sync_error,omitempty"`
CollectionsSynced bool `json:"collections_synced"`
FilesSynced bool `json:"files_synced"`
FullySynced bool `json:"fully_synced"`
}
// SyncResultData represents the result of a sync operation
type SyncResultData struct {
CollectionsProcessed int `json:"collections_processed"`
CollectionsAdded int `json:"collections_added"`
CollectionsUpdated int `json:"collections_updated"`
CollectionsDeleted int `json:"collections_deleted"`
FilesProcessed int `json:"files_processed"`
FilesAdded int `json:"files_added"`
FilesUpdated int `json:"files_updated"`
FilesDeleted int `json:"files_deleted"`
Errors []string `json:"errors,omitempty"`
}
// GetSyncStatus returns the current sync status
func (a *Application) GetSyncStatus() (*SyncStatusData, error) {
status, err := a.syncService.GetSyncStatus(a.ctx)
if err != nil {
a.logger.Error("Failed to get sync status", zap.Error(err))
return nil, fmt.Errorf("failed to get sync status: %w", err)
}
return &SyncStatusData{
IsSyncing: false,
LastSyncTime: time.Now().Format(time.RFC3339),
LastSyncSuccess: true,
LastSyncError: "",
CollectionsSynced: status.CollectionsSynced,
FilesSynced: status.FilesSynced,
FullySynced: status.FullySynced,
}, nil
}
// TriggerSync triggers a full sync of collections and files
func (a *Application) TriggerSync() error {
a.logger.Info("Manual sync triggered")
// Get current session for email
session, err := a.authService.GetCurrentSession(a.ctx)
if err != nil {
a.logger.Error("Failed to get session for sync", zap.Error(err))
return fmt.Errorf("not authenticated: %w", err)
}
// Get the stored password for decryption
var password string
if a.passwordStore.HasPassword(session.Email) {
password, err = a.passwordStore.GetPassword(session.Email)
if err != nil {
a.logger.Warn("Failed to get stored password, sync will skip decryption", zap.Error(err))
}
} else {
a.logger.Warn("No stored password, sync will skip decryption")
}
input := &sync.SyncInput{
BatchSize: 50,
MaxBatches: 100,
Password: password,
}
result, err := a.syncService.SyncAll(a.ctx, input)
if err != nil {
a.logger.Error("Sync failed", zap.Error(err))
return fmt.Errorf("sync failed: %w", err)
}
a.logger.Info("Sync completed",
zap.Int("collections_added", result.CollectionsAdded),
zap.Int("files_added", result.FilesAdded),
zap.Int("errors", len(result.Errors)))
return nil
}
// TriggerSyncWithResult triggers a full sync and returns the result
func (a *Application) TriggerSyncWithResult() (*SyncResultData, error) {
a.logger.Info("Manual sync with result triggered")
// Get current session for email
session, err := a.authService.GetCurrentSession(a.ctx)
if err != nil {
a.logger.Error("Failed to get session for sync", zap.Error(err))
return nil, fmt.Errorf("not authenticated: %w", err)
}
// Get the stored password for decryption
var password string
if a.passwordStore.HasPassword(session.Email) {
password, err = a.passwordStore.GetPassword(session.Email)
if err != nil {
a.logger.Warn("Failed to get stored password, sync will skip decryption", zap.Error(err))
}
} else {
a.logger.Warn("No stored password, sync will skip decryption")
}
input := &sync.SyncInput{
BatchSize: 50,
MaxBatches: 100,
Password: password,
}
result, err := a.syncService.SyncAll(a.ctx, input)
if err != nil {
a.logger.Error("Sync failed", zap.Error(err))
return nil, fmt.Errorf("sync failed: %w", err)
}
return &SyncResultData{
CollectionsProcessed: result.CollectionsProcessed,
CollectionsAdded: result.CollectionsAdded,
CollectionsUpdated: result.CollectionsUpdated,
CollectionsDeleted: result.CollectionsDeleted,
FilesProcessed: result.FilesProcessed,
FilesAdded: result.FilesAdded,
FilesUpdated: result.FilesUpdated,
FilesDeleted: result.FilesDeleted,
Errors: result.Errors,
}, nil
}
// ResetSync resets all sync state for a fresh sync
func (a *Application) ResetSync() error {
a.logger.Info("Resetting sync state")
return a.syncService.ResetSync(a.ctx)
}

View file

@ -0,0 +1,861 @@
package app
import (
"bytes"
"encoding/base64"
"fmt"
"time"
"github.com/google/uuid"
"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/service/inputvalidation"
)
// ============================================================================
// Tag Management
// ============================================================================
// TagData represents a decrypted tag for the frontend
type TagData struct {
ID string `json:"id"`
Name string `json:"name"`
Color string `json:"color"`
CreatedAt string `json:"created_at"`
ModifiedAt string `json:"modified_at"`
Version uint64 `json:"version"`
State string `json:"state"`
}
// ListTags fetches all tags for the current user and decrypts them
func (a *Application) ListTags() ([]*TagData, 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
apiClient.SetTokens(session.AccessToken, session.RefreshToken)
// Fetch tags from API
tags, err := apiClient.ListTags(a.ctx)
if err != nil {
a.logger.Error("Failed to fetch tags from API", zap.Error(err))
return nil, fmt.Errorf("failed to fetch tags: %w", err)
}
if len(tags) == 0 {
return []*TagData{}, nil
}
// Get 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()
// Decrypt tags
decryptedTags := make([]*TagData, 0, len(tags))
for _, tag := range tags {
decrypted, err := a.decryptTag(tag, masterKey)
if err != nil {
a.logger.Error("Failed to decrypt tag",
zap.String("tag_id", tag.ID),
zap.Error(err))
continue // Skip tags that fail to decrypt
}
decryptedTags = append(decryptedTags, decrypted)
}
a.logger.Info("Tags fetched and decrypted successfully",
zap.Int("total", len(tags)),
zap.Int("decrypted", len(decryptedTags)))
return decryptedTags, nil
}
// decryptTag decrypts a single tag using the master key
func (a *Application) decryptTag(tag *client.Tag, masterKey []byte) (*TagData, error) {
// Decode encrypted tag key
if tag.EncryptedTagKey == nil {
return nil, fmt.Errorf("tag has no encrypted tag key")
}
// Decode base64 nonce and ciphertext
keyNonce, err := base64.StdEncoding.DecodeString(tag.EncryptedTagKey.Nonce)
if err != nil {
// Try URL-safe encoding without padding
keyNonce, err = base64.RawURLEncoding.DecodeString(tag.EncryptedTagKey.Nonce)
if err != nil {
return nil, fmt.Errorf("failed to decode tag key nonce: %w", err)
}
}
keyCiphertext, err := base64.StdEncoding.DecodeString(tag.EncryptedTagKey.Ciphertext)
if err != nil {
// Try URL-safe encoding without padding
keyCiphertext, err = base64.RawURLEncoding.DecodeString(tag.EncryptedTagKey.Ciphertext)
if err != nil {
return nil, fmt.Errorf("failed to decode tag key ciphertext: %w", err)
}
}
// Extract actual ciphertext (skip nonce if it's prepended)
var actualCiphertext []byte
if len(keyCiphertext) > len(keyNonce) && bytes.Equal(keyCiphertext[:len(keyNonce)], keyNonce) {
// Nonce is prepended to ciphertext
actualCiphertext = keyCiphertext[len(keyNonce):]
} else {
actualCiphertext = keyCiphertext
}
// Decrypt tag key using XSalsa20-Poly1305 (SecretBox)
tagKey, err := e2ee.DecryptTagKey(&e2ee.EncryptedKey{
Ciphertext: actualCiphertext,
Nonce: keyNonce,
}, masterKey)
if err != nil {
return nil, fmt.Errorf("failed to decrypt tag key: %w", err)
}
// Decrypt tag name
name, err := decryptTagField(tag.EncryptedName, tagKey)
if err != nil {
return nil, fmt.Errorf("failed to decrypt tag name: %w", err)
}
// Decrypt tag color
color, err := decryptTagField(tag.EncryptedColor, tagKey)
if err != nil {
return nil, fmt.Errorf("failed to decrypt tag color: %w", err)
}
return &TagData{
ID: tag.ID,
Name: name,
Color: color,
CreatedAt: tag.CreatedAt.Format(time.RFC3339),
ModifiedAt: tag.ModifiedAt.Format(time.RFC3339),
Version: tag.Version,
State: tag.State,
}, nil
}
// decryptTagField decrypts an encrypted tag field (name or color)
// Format: "ciphertext:nonce" both in base64
func decryptTagField(encryptedField string, tagKey []byte) (string, error) {
// Split by colon to get ciphertext and nonce
parts := bytes.Split([]byte(encryptedField), []byte(":"))
if len(parts) != 2 {
return "", fmt.Errorf("invalid encrypted field format (expected 'ciphertext:nonce')")
}
ciphertextB64 := string(parts[0])
nonceB64 := string(parts[1])
// Decode base64 (try URL-safe without padding first, then standard)
ciphertext, err := base64.RawURLEncoding.DecodeString(ciphertextB64)
if err != nil {
ciphertext, err = base64.StdEncoding.DecodeString(ciphertextB64)
if err != nil {
return "", fmt.Errorf("failed to decode ciphertext: %w", err)
}
}
nonce, err := base64.RawURLEncoding.DecodeString(nonceB64)
if err != nil {
nonce, err = base64.StdEncoding.DecodeString(nonceB64)
if err != nil {
return "", fmt.Errorf("failed to decode nonce: %w", err)
}
}
// Decrypt using XSalsa20-Poly1305
decrypted, err := e2ee.DecryptWithSecretBox(ciphertext, nonce, tagKey)
if err != nil {
return "", fmt.Errorf("failed to decrypt field: %w", err)
}
return string(decrypted), nil
}
// CreateTag creates a new tag with encrypted name and color
func (a *Application) CreateTag(name, color string) (*TagData, 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
apiClient.SetTokens(session.AccessToken, session.RefreshToken)
// Get master key for encryption
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()
// Generate new tag key (32 bytes for XSalsa20-Poly1305)
tagKey := e2ee.GenerateKey()
// Encrypt tag name
encryptedName, err := encryptTagField(name, tagKey)
if err != nil {
return nil, fmt.Errorf("failed to encrypt tag name: %w", err)
}
// Encrypt tag color
encryptedColor, err := encryptTagField(color, tagKey)
if err != nil {
return nil, fmt.Errorf("failed to encrypt tag color: %w", err)
}
// Encrypt tag key with master key
encryptedTagKey, err := e2ee.EncryptTagKeySecretBox(tagKey, masterKey)
if err != nil {
return nil, fmt.Errorf("failed to encrypt tag key: %w", err)
}
// Prepare API request
tagID := uuid.New().String()
now := time.Now()
input := &client.CreateTagInput{
ID: tagID,
EncryptedName: encryptedName,
EncryptedColor: encryptedColor,
EncryptedTagKey: &client.EncryptedTagKey{
Ciphertext: base64.StdEncoding.EncodeToString(append(encryptedTagKey.Nonce, encryptedTagKey.Ciphertext...)),
Nonce: base64.StdEncoding.EncodeToString(encryptedTagKey.Nonce),
KeyVersion: 1,
},
CreatedAt: now.Format(time.RFC3339),
ModifiedAt: now.Format(time.RFC3339),
Version: 1,
State: "active",
}
// Create tag via API
tag, err := apiClient.CreateTag(a.ctx, input)
if err != nil {
a.logger.Error("Failed to create tag", zap.Error(err))
return nil, fmt.Errorf("failed to create tag: %w", err)
}
a.logger.Info("Tag created successfully", zap.String("tag_id", tag.ID))
return &TagData{
ID: tag.ID,
Name: name,
Color: color,
CreatedAt: tag.CreatedAt.Format(time.RFC3339),
ModifiedAt: tag.ModifiedAt.Format(time.RFC3339),
Version: tag.Version,
State: tag.State,
}, nil
}
// encryptTagField encrypts a tag field (name or color) with the tag key
// Returns format: "ciphertext:nonce" both in base64
func encryptTagField(plaintext string, tagKey []byte) (string, error) {
encrypted, err := e2ee.EncryptWithSecretBox([]byte(plaintext), tagKey)
if err != nil {
return "", err
}
// Encode to base64 (URL-safe without padding to match web app)
ciphertextB64 := base64.RawURLEncoding.EncodeToString(encrypted.Ciphertext)
nonceB64 := base64.RawURLEncoding.EncodeToString(encrypted.Nonce)
return fmt.Sprintf("%s:%s", ciphertextB64, nonceB64), nil
}
// UpdateTag updates an existing tag's name and/or color
func (a *Application) UpdateTag(tagID, name, color string) (*TagData, 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
apiClient.SetTokens(session.AccessToken, session.RefreshToken)
// Get current tag to retrieve encrypted tag key
currentTag, err := apiClient.GetTag(a.ctx, tagID)
if err != nil {
a.logger.Error("Failed to get current tag", zap.String("tag_id", tagID), zap.Error(err))
return nil, fmt.Errorf("failed to get current tag: %w", err)
}
// Get master key for encryption
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()
// Decrypt tag key
keyNonce, err := base64.StdEncoding.DecodeString(currentTag.EncryptedTagKey.Nonce)
if err != nil {
keyNonce, err = base64.RawURLEncoding.DecodeString(currentTag.EncryptedTagKey.Nonce)
if err != nil {
return nil, fmt.Errorf("failed to decode tag key nonce: %w", err)
}
}
keyCiphertext, err := base64.StdEncoding.DecodeString(currentTag.EncryptedTagKey.Ciphertext)
if err != nil {
keyCiphertext, err = base64.RawURLEncoding.DecodeString(currentTag.EncryptedTagKey.Ciphertext)
if err != nil {
return nil, fmt.Errorf("failed to decode tag key ciphertext: %w", err)
}
}
// Extract actual ciphertext
var actualCiphertext []byte
if len(keyCiphertext) > len(keyNonce) && bytes.Equal(keyCiphertext[:len(keyNonce)], keyNonce) {
actualCiphertext = keyCiphertext[len(keyNonce):]
} else {
actualCiphertext = keyCiphertext
}
tagKey, err := e2ee.DecryptTagKey(&e2ee.EncryptedKey{
Ciphertext: actualCiphertext,
Nonce: keyNonce,
}, masterKey)
if err != nil {
return nil, fmt.Errorf("failed to decrypt tag key: %w", err)
}
// Encrypt new name and color
encryptedName, err := encryptTagField(name, tagKey)
if err != nil {
return nil, fmt.Errorf("failed to encrypt tag name: %w", err)
}
encryptedColor, err := encryptTagField(color, tagKey)
if err != nil {
return nil, fmt.Errorf("failed to encrypt tag color: %w", err)
}
// Prepare update request (must include encrypted_tag_key and other required fields)
input := &client.UpdateTagInput{
EncryptedName: encryptedName,
EncryptedColor: encryptedColor,
EncryptedTagKey: currentTag.EncryptedTagKey, // Keep existing key
CreatedAt: currentTag.CreatedAt.Format(time.RFC3339),
ModifiedAt: time.Now().Format(time.RFC3339),
Version: currentTag.Version,
State: currentTag.State,
}
// Update tag via API
tag, err := apiClient.UpdateTag(a.ctx, tagID, input)
if err != nil {
a.logger.Error("Failed to update tag", zap.String("tag_id", tagID), zap.Error(err))
return nil, fmt.Errorf("failed to update tag: %w", err)
}
a.logger.Info("Tag updated successfully", zap.String("tag_id", tag.ID))
return &TagData{
ID: tag.ID,
Name: name,
Color: color,
CreatedAt: tag.CreatedAt.Format(time.RFC3339),
ModifiedAt: tag.ModifiedAt.Format(time.RFC3339),
Version: tag.Version,
State: tag.State,
}, nil
}
// DeleteTag deletes a tag
func (a *Application) DeleteTag(tagID string) error {
a.logger.Info("DeleteTag called", zap.String("tag_id", tagID))
// Get API client from auth service
apiClient := a.authService.GetAPIClient()
if apiClient == nil {
a.logger.Error("API client not available")
return 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 {
a.logger.Error("No active session", zap.Error(err))
return fmt.Errorf("no active session - please log in")
}
if !session.IsValid() {
a.logger.Error("Session expired")
return fmt.Errorf("session expired - please log in again")
}
a.logger.Info("Session valid, setting tokens", zap.String("tag_id", tagID))
// Ensure tokens are set in the API client
apiClient.SetTokens(session.AccessToken, session.RefreshToken)
a.logger.Info("Calling API DeleteTag", zap.String("tag_id", tagID))
// Delete tag via API
err = apiClient.DeleteTag(a.ctx, tagID)
if err != nil {
a.logger.Error("Failed to delete tag", zap.String("tag_id", tagID), zap.Error(err))
return fmt.Errorf("failed to delete tag: %w", err)
}
a.logger.Info("Tag deleted successfully", zap.String("tag_id", tagID))
return nil
}
// ============================================================================
// Tag Assignment Operations
// ============================================================================
// AssignTagToFile assigns a tag to a file
func (a *Application) AssignTagToFile(tagID, fileID string) error {
// Validate inputs
if err := inputvalidation.ValidateTagID(tagID); err != nil {
return err
}
if err := inputvalidation.ValidateFileID(fileID); err != nil {
return err
}
// Get API client from auth service
apiClient := a.authService.GetAPIClient()
if apiClient == nil {
return 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 fmt.Errorf("no active session - please log in")
}
if !session.IsValid() {
return fmt.Errorf("session expired - please log in again")
}
// Ensure tokens are set in the API client
apiClient.SetTokens(session.AccessToken, session.RefreshToken)
// Call backend API
input := &client.CreateTagAssignmentInput{
TagID: tagID,
EntityID: fileID,
EntityType: "file",
}
_, err = apiClient.AssignTag(a.ctx, input)
if err != nil {
a.logger.Error("Failed to assign tag to file",
zap.String("tag_id", tagID),
zap.String("file_id", fileID),
zap.Error(err))
return fmt.Errorf("failed to assign tag: %w", err)
}
a.logger.Info("Tag assigned to file",
zap.String("tag_id", tagID),
zap.String("file_id", fileID))
return nil
}
// UnassignTagFromFile removes a tag from a file
func (a *Application) UnassignTagFromFile(tagID, fileID string) error {
// Validate inputs
if err := inputvalidation.ValidateTagID(tagID); err != nil {
return err
}
if err := inputvalidation.ValidateFileID(fileID); err != nil {
return err
}
// Get API client from auth service
apiClient := a.authService.GetAPIClient()
if apiClient == nil {
return 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 fmt.Errorf("no active session - please log in")
}
if !session.IsValid() {
return fmt.Errorf("session expired - please log in again")
}
// Ensure tokens are set in the API client
apiClient.SetTokens(session.AccessToken, session.RefreshToken)
// Call backend API
err = apiClient.UnassignTag(a.ctx, tagID, fileID, "file")
if err != nil {
a.logger.Error("Failed to unassign tag from file",
zap.String("tag_id", tagID),
zap.String("file_id", fileID),
zap.Error(err))
return fmt.Errorf("failed to unassign tag: %w", err)
}
a.logger.Info("Tag unassigned from file",
zap.String("tag_id", tagID),
zap.String("file_id", fileID))
return nil
}
// GetTagsForFile returns all tags assigned to a file (decrypted)
func (a *Application) GetTagsForFile(fileID string) ([]*TagData, error) {
// Validate input
if err := inputvalidation.ValidateFileID(fileID); err != nil {
return nil, err
}
// 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
apiClient.SetTokens(session.AccessToken, session.RefreshToken)
// Get 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()
// Fetch tags for entity from API
tags, err := apiClient.GetTagsForEntity(a.ctx, fileID, "file")
if err != nil {
a.logger.Error("Failed to get tags for file",
zap.String("file_id", fileID),
zap.Error(err))
return nil, fmt.Errorf("failed to get tags: %w", err)
}
if len(tags) == 0 {
return []*TagData{}, nil
}
// Decrypt each tag
result := make([]*TagData, 0, len(tags))
for _, tag := range tags {
decryptedTag, err := a.decryptTag(tag, masterKey)
if err != nil {
a.logger.Warn("Failed to decrypt tag, skipping",
zap.String("tag_id", tag.ID),
zap.Error(err))
continue
}
result = append(result, decryptedTag)
}
a.logger.Info("Tags fetched for file",
zap.String("file_id", fileID),
zap.Int("count", len(result)))
return result, nil
}
// ============================================================================
// Collection Tag Assignment Operations
// ============================================================================
// AssignTagToCollection assigns a tag to a collection
func (a *Application) AssignTagToCollection(tagID, collectionID string) error {
// Validate inputs
if err := inputvalidation.ValidateTagID(tagID); err != nil {
return err
}
if err := inputvalidation.ValidateCollectionID(collectionID); err != nil {
return err
}
// Get API client from auth service
apiClient := a.authService.GetAPIClient()
if apiClient == nil {
return 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 fmt.Errorf("no active session - please log in")
}
if !session.IsValid() {
return fmt.Errorf("session expired - please log in again")
}
// Ensure tokens are set in the API client
apiClient.SetTokens(session.AccessToken, session.RefreshToken)
// Call backend API
input := &client.CreateTagAssignmentInput{
TagID: tagID,
EntityID: collectionID,
EntityType: "collection",
}
_, err = apiClient.AssignTag(a.ctx, input)
if err != nil {
a.logger.Error("Failed to assign tag to collection",
zap.String("tag_id", tagID),
zap.String("collection_id", collectionID),
zap.Error(err))
return fmt.Errorf("failed to assign tag: %w", err)
}
a.logger.Info("Tag assigned to collection",
zap.String("tag_id", tagID),
zap.String("collection_id", collectionID))
return nil
}
// UnassignTagFromCollection removes a tag from a collection
func (a *Application) UnassignTagFromCollection(tagID, collectionID string) error {
// Validate inputs
if err := inputvalidation.ValidateTagID(tagID); err != nil {
return err
}
if err := inputvalidation.ValidateCollectionID(collectionID); err != nil {
return err
}
// Get API client from auth service
apiClient := a.authService.GetAPIClient()
if apiClient == nil {
return 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 fmt.Errorf("no active session - please log in")
}
if !session.IsValid() {
return fmt.Errorf("session expired - please log in again")
}
// Ensure tokens are set in the API client
apiClient.SetTokens(session.AccessToken, session.RefreshToken)
// Call backend API
err = apiClient.UnassignTag(a.ctx, tagID, collectionID, "collection")
if err != nil {
a.logger.Error("Failed to unassign tag from collection",
zap.String("tag_id", tagID),
zap.String("collection_id", collectionID),
zap.Error(err))
return fmt.Errorf("failed to unassign tag: %w", err)
}
a.logger.Info("Tag unassigned from collection",
zap.String("tag_id", tagID),
zap.String("collection_id", collectionID))
return nil
}
// GetTagsForCollection returns all tags assigned to a collection (decrypted)
func (a *Application) GetTagsForCollection(collectionID string) ([]*TagData, error) {
// Validate input
if err := inputvalidation.ValidateCollectionID(collectionID); err != nil {
return nil, err
}
// 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
apiClient.SetTokens(session.AccessToken, session.RefreshToken)
// Get 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()
// Fetch tags for entity from API
tags, err := apiClient.GetTagsForEntity(a.ctx, collectionID, "collection")
if err != nil {
a.logger.Error("Failed to get tags for collection",
zap.String("collection_id", collectionID),
zap.Error(err))
return nil, fmt.Errorf("failed to get tags: %w", err)
}
if len(tags) == 0 {
return []*TagData{}, nil
}
// Decrypt each tag
result := make([]*TagData, 0, len(tags))
for _, tag := range tags {
decryptedTag, err := a.decryptTag(tag, masterKey)
if err != nil {
a.logger.Warn("Failed to decrypt tag, skipping",
zap.String("tag_id", tag.ID),
zap.Error(err))
continue
}
result = append(result, decryptedTag)
}
a.logger.Info("Tags fetched for collection",
zap.String("collection_id", collectionID),
zap.Int("count", len(result)))
return result, nil
}
// ============================================================================
// Tag Search Operations
// ============================================================================
// SearchByTagsResult represents the result of a multi-tag search
type SearchByTagsResult struct {
CollectionIDs []string `json:"collection_ids"`
FileIDs []string `json:"file_ids"`
TagCount int `json:"tag_count"`
CollectionCount int `json:"collection_count"`
FileCount int `json:"file_count"`
}
// SearchByTags searches for collections and files that have ALL the specified tags
func (a *Application) SearchByTags(tagIDs []string, limit int) (*SearchByTagsResult, error) {
a.logger.Info("SearchByTags called",
zap.Int("tag_count", len(tagIDs)),
zap.Int("limit", limit))
// Validate inputs
if len(tagIDs) == 0 {
return nil, fmt.Errorf("at least one tag ID is required")
}
// 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
apiClient.SetTokens(session.AccessToken, session.RefreshToken)
// Call backend API
resp, err := apiClient.SearchByTags(a.ctx, tagIDs, limit)
if err != nil {
a.logger.Error("Failed to search by tags", zap.Error(err))
return nil, fmt.Errorf("failed to search by tags: %w", err)
}
// Extract IDs only - frontend will fetch full details as needed
collectionIDs := make([]string, 0, len(resp.Collections))
for _, coll := range resp.Collections {
collectionIDs = append(collectionIDs, coll.ID)
}
fileIDs := make([]string, 0, len(resp.Files))
for _, file := range resp.Files {
fileIDs = append(fileIDs, file.ID)
}
result := &SearchByTagsResult{
CollectionIDs: collectionIDs,
FileIDs: fileIDs,
TagCount: resp.TagCount,
CollectionCount: len(collectionIDs),
FileCount: len(fileIDs),
}
a.logger.Info("SearchByTags completed",
zap.Int("collections", len(collectionIDs)),
zap.Int("files", len(fileIDs)))
return result, nil
}

View file

@ -0,0 +1,253 @@
package app
import (
"fmt"
"time"
"go.uber.org/zap"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/pkg/maplefile/client"
"codeberg.org/mapleopentech/monorepo/native/desktop/maplefile/internal/utils"
)
// GetUserProfile fetches the current user's profile
func (a *Application) GetUserProfile() (*client.User, 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
apiClient.SetTokens(session.AccessToken, session.RefreshToken)
// Fetch user profile from backend
user, err := apiClient.GetMe(a.ctx)
if err != nil {
a.logger.Error("Failed to fetch user profile", zap.Error(err))
return nil, fmt.Errorf("failed to fetch profile: %w", err)
}
a.logger.Info("User profile fetched successfully",
zap.String("user_id", user.ID),
zap.String("email", utils.MaskEmail(user.Email)))
return user, nil
}
// UpdateUserProfile updates the current user's profile
func (a *Application) UpdateUserProfile(input *client.UpdateUserInput) (*client.User, 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
apiClient.SetTokens(session.AccessToken, session.RefreshToken)
// Update user profile
user, err := apiClient.UpdateMe(a.ctx, input)
if err != nil {
a.logger.Error("Failed to update user profile", zap.Error(err))
return nil, fmt.Errorf("failed to update profile: %w", err)
}
a.logger.Info("User profile updated successfully",
zap.String("user_id", user.ID),
zap.String("email", utils.MaskEmail(user.Email)))
return user, nil
}
// ============================================================================
// Blocked Emails Management
// ============================================================================
// BlockedEmailData represents a blocked email entry for the frontend
type BlockedEmailData struct {
BlockedEmail string `json:"blocked_email"`
Reason string `json:"reason"`
CreatedAt string `json:"created_at"`
}
// GetBlockedEmails fetches the list of blocked emails from the backend
func (a *Application) GetBlockedEmails() ([]*BlockedEmailData, 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
apiClient.SetTokens(session.AccessToken, session.RefreshToken)
// Call backend API
resp, err := apiClient.ListBlockedEmails(a.ctx)
if err != nil {
a.logger.Error("Failed to fetch blocked emails", zap.Error(err))
return nil, fmt.Errorf("failed to fetch blocked emails: %w", err)
}
// Convert to frontend format
blockedEmails := make([]*BlockedEmailData, 0, len(resp.BlockedEmails))
for _, blocked := range resp.BlockedEmails {
blockedEmails = append(blockedEmails, &BlockedEmailData{
BlockedEmail: blocked.BlockedEmail,
Reason: blocked.Reason,
CreatedAt: blocked.CreatedAt.Format(time.RFC3339),
})
}
a.logger.Info("Blocked emails fetched successfully",
zap.Int("count", len(blockedEmails)))
return blockedEmails, nil
}
// AddBlockedEmail adds an email to the blocked list
func (a *Application) AddBlockedEmail(email, reason string) (*BlockedEmailData, 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
apiClient.SetTokens(session.AccessToken, session.RefreshToken)
// Call backend API
blocked, err := apiClient.CreateBlockedEmail(a.ctx, email, reason)
if err != nil {
a.logger.Error("Failed to add blocked email",
zap.String("email", utils.MaskEmail(email)),
zap.Error(err))
return nil, fmt.Errorf("failed to block email: %w", err)
}
a.logger.Info("Email blocked successfully",
zap.String("blocked_email", utils.MaskEmail(email)))
return &BlockedEmailData{
BlockedEmail: blocked.BlockedEmail,
Reason: blocked.Reason,
CreatedAt: blocked.CreatedAt.Format(time.RFC3339),
}, nil
}
// RemoveBlockedEmail removes an email from the blocked list
func (a *Application) RemoveBlockedEmail(email string) error {
// Get API client from auth service
apiClient := a.authService.GetAPIClient()
if apiClient == nil {
return 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 fmt.Errorf("no active session - please log in")
}
if !session.IsValid() {
return fmt.Errorf("session expired - please log in again")
}
// Ensure tokens are set in the API client
apiClient.SetTokens(session.AccessToken, session.RefreshToken)
// Call backend API
_, err = apiClient.DeleteBlockedEmail(a.ctx, email)
if err != nil {
a.logger.Error("Failed to remove blocked email",
zap.String("email", utils.MaskEmail(email)),
zap.Error(err))
return fmt.Errorf("failed to unblock email: %w", err)
}
a.logger.Info("Email unblocked successfully",
zap.String("blocked_email", utils.MaskEmail(email)))
return nil
}
// ============================================================================
// Account Deletion
// ============================================================================
// DeleteAccount deletes the current user's account
func (a *Application) DeleteAccount(password string) error {
// Get API client from auth service
apiClient := a.authService.GetAPIClient()
if apiClient == nil {
return 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 fmt.Errorf("no active session - please log in")
}
if !session.IsValid() {
return fmt.Errorf("session expired - please log in again")
}
// Ensure tokens are set in the API client
apiClient.SetTokens(session.AccessToken, session.RefreshToken)
// Call backend API to delete account
err = apiClient.DeleteMe(a.ctx, password)
if err != nil {
a.logger.Error("Failed to delete account", zap.Error(err))
return fmt.Errorf("failed to delete account: %w", err)
}
a.logger.Info("Account deleted successfully",
zap.String("user_id", utils.MaskEmail(session.Email)))
// Logout after successful deletion
_ = a.Logout()
return nil
}

View file

@ -0,0 +1,294 @@
package app
import (
"context"
"fmt"
"time"
"go.uber.org/zap"
"codeberg.org/mapleopentech/monorepo/native/desktop/maplefile/internal/config"
"codeberg.org/mapleopentech/monorepo/native/desktop/maplefile/internal/domain/collection"
"codeberg.org/mapleopentech/monorepo/native/desktop/maplefile/internal/domain/file"
"codeberg.org/mapleopentech/monorepo/native/desktop/maplefile/internal/domain/session"
"codeberg.org/mapleopentech/monorepo/native/desktop/maplefile/internal/service/auth"
"codeberg.org/mapleopentech/monorepo/native/desktop/maplefile/internal/service/httpclient"
"codeberg.org/mapleopentech/monorepo/native/desktop/maplefile/internal/service/keycache"
"codeberg.org/mapleopentech/monorepo/native/desktop/maplefile/internal/service/passwordstore"
"codeberg.org/mapleopentech/monorepo/native/desktop/maplefile/internal/service/ratelimiter"
"codeberg.org/mapleopentech/monorepo/native/desktop/maplefile/internal/service/search"
"codeberg.org/mapleopentech/monorepo/native/desktop/maplefile/internal/service/securitylog"
"codeberg.org/mapleopentech/monorepo/native/desktop/maplefile/internal/service/storagemanager"
"codeberg.org/mapleopentech/monorepo/native/desktop/maplefile/internal/service/sync"
"codeberg.org/mapleopentech/monorepo/native/desktop/maplefile/internal/service/tokenmanager"
"codeberg.org/mapleopentech/monorepo/native/desktop/maplefile/internal/utils"
)
// Application is the main Wails application struct
type Application struct {
ctx context.Context
logger *zap.Logger
config config.ConfigService
authService *auth.Service
tokenManager *tokenmanager.Manager
passwordStore *passwordstore.Service
keyCache *keycache.Service
rateLimiter *ratelimiter.Service
httpClient *httpclient.Service
syncService sync.Service
storageManager *storagemanager.Manager
securityLog *securitylog.Service
searchService search.SearchService
}
// ProvideApplication creates the Application for Wire
func ProvideApplication(
logger *zap.Logger,
configService config.ConfigService,
authService *auth.Service,
tokenManager *tokenmanager.Manager,
passwordStore *passwordstore.Service,
keyCache *keycache.Service,
rateLimiter *ratelimiter.Service,
httpClient *httpclient.Service,
syncService sync.Service,
storageManager *storagemanager.Manager,
securityLog *securitylog.Service,
searchService search.SearchService,
) *Application {
return &Application{
logger: logger,
config: configService,
authService: authService,
tokenManager: tokenManager,
passwordStore: passwordStore,
keyCache: keyCache,
rateLimiter: rateLimiter,
httpClient: httpClient,
syncService: syncService,
storageManager: storageManager,
securityLog: securityLog,
searchService: searchService,
}
}
// getFileRepo returns the file repository for the current user.
// Returns nil if no user is logged in (storage not initialized).
func (a *Application) getFileRepo() file.Repository {
return a.storageManager.GetFileRepository()
}
// mustGetFileRepo returns the file repository for the current user.
// Logs an error and returns a no-op repository if storage is not initialized.
// Use this in places where you expect the user to be logged in.
// The returned repository will never be nil - it returns a safe no-op implementation
// if the actual repository is not available.
func (a *Application) mustGetFileRepo() file.Repository {
repo := a.storageManager.GetFileRepository()
if repo == nil {
a.logger.Error("File repository not available - user storage not initialized")
return &noOpFileRepository{}
}
return repo
}
// getCollectionRepo returns the collection repository for the current user.
// Returns nil if no user is logged in (storage not initialized).
func (a *Application) getCollectionRepo() collection.Repository {
return a.storageManager.GetCollectionRepository()
}
// noOpFileRepository is a safe no-op implementation of file.Repository
// that returns empty results instead of causing nil pointer dereferences.
// This is used when the actual repository is not available (user not logged in).
type noOpFileRepository struct{}
func (r *noOpFileRepository) Get(id string) (*file.File, error) {
return nil, fmt.Errorf("storage not initialized - user must be logged in")
}
func (r *noOpFileRepository) List() ([]*file.File, error) {
return []*file.File{}, fmt.Errorf("storage not initialized - user must be logged in")
}
func (r *noOpFileRepository) ListByCollection(collectionID string) ([]*file.File, error) {
return []*file.File{}, fmt.Errorf("storage not initialized - user must be logged in")
}
func (r *noOpFileRepository) Create(f *file.File) error {
return fmt.Errorf("storage not initialized - user must be logged in")
}
func (r *noOpFileRepository) Update(f *file.File) error {
return fmt.Errorf("storage not initialized - user must be logged in")
}
func (r *noOpFileRepository) Delete(id string) error {
return fmt.Errorf("storage not initialized - user must be logged in")
}
func (r *noOpFileRepository) ListByStatus(status file.SyncStatus) ([]*file.File, error) {
return []*file.File{}, fmt.Errorf("storage not initialized - user must be logged in")
}
func (r *noOpFileRepository) Exists(id string) (bool, error) {
return false, fmt.Errorf("storage not initialized - user must be logged in")
}
// Startup is called when the app starts (Wails lifecycle hook)
func (a *Application) Startup(ctx context.Context) {
a.ctx = ctx
a.logger.Info("MapleFile desktop application started")
a.securityLog.LogAppLifecycle(securitylog.EventAppStart)
// Check if there's a valid session from a previous run
session, err := a.authService.GetCurrentSession(ctx)
if err != nil {
a.logger.Debug("No existing session on startup", zap.Error(err))
return
}
if session == nil {
a.logger.Info("No session found on startup")
return
}
if !session.IsValid() {
a.logger.Info("Session expired on startup, clearing",
zap.Time("expired_at", session.ExpiresAt))
_ = a.authService.Logout(ctx)
return
}
// Valid session found - restore it
a.logger.Info("Resuming valid session from previous run",
zap.String("user_id", session.UserID),
zap.String("email", utils.MaskEmail(session.Email)),
zap.Time("expires_at", session.ExpiresAt))
// Restore tokens to API client
if err := a.authService.RestoreSession(ctx, session); err != nil {
a.logger.Error("Failed to restore session", zap.Error(err))
return
}
// SECURITY: Validate session with server before fully restoring
// This prevents using stale/revoked sessions from previous runs
if err := a.validateSessionWithServer(ctx, session); err != nil {
a.logger.Warn("Session validation with server failed, clearing session",
zap.String("email", utils.MaskEmail(session.Email)),
zap.Error(err))
_ = a.authService.Logout(ctx)
return
}
a.logger.Info("Session validated with server successfully")
// Initialize user-specific storage for the logged-in user
if err := a.storageManager.InitializeForUser(session.Email); err != nil {
a.logger.Error("Failed to initialize user storage", zap.Error(err))
_ = a.authService.Logout(ctx)
return
}
a.logger.Info("User storage initialized",
zap.String("email", utils.MaskEmail(session.Email)))
// Initialize search index for the logged-in user
if err := a.searchService.Initialize(ctx, session.Email); err != nil {
a.logger.Error("Failed to initialize search index", zap.Error(err))
// Don't fail startup if search initialization fails - it's not critical
// The app can still function without search
} else {
a.logger.Info("Search index initialized",
zap.String("email", utils.MaskEmail(session.Email)))
// Rebuild search index from local data in the background
userEmail := session.Email // Capture email before goroutine
go func() {
if err := a.rebuildSearchIndexForUser(userEmail); err != nil {
a.logger.Warn("Failed to rebuild search index on startup", zap.Error(err))
}
}()
}
// Start token manager for automatic refresh
a.tokenManager.Start()
a.logger.Info("Token manager started for resumed session")
// Run background cleanup of deleted files
go a.cleanupDeletedFiles()
}
// validateSessionWithServer validates the stored session by making a request to the server.
// This is a security measure to ensure the session hasn't been revoked server-side.
func (a *Application) validateSessionWithServer(ctx context.Context, session *session.Session) error {
apiClient := a.authService.GetAPIClient()
if apiClient == nil {
return fmt.Errorf("API client not available")
}
// Set tokens in the API client
apiClient.SetTokens(session.AccessToken, session.RefreshToken)
// Make a lightweight request to validate the token
// GetMe is a good choice as it's a simple authenticated endpoint
_, err := apiClient.GetMe(ctx)
if err != nil {
return fmt.Errorf("server validation failed: %w", err)
}
return nil
}
// Shutdown is called when the app shuts down (Wails lifecycle hook)
func (a *Application) Shutdown(ctx context.Context) {
a.logger.Info("MapleFile desktop application shutting down")
a.securityLog.LogAppLifecycle(securitylog.EventAppShutdown)
// Calculate timeout from Wails context
timeout := 3 * time.Second
if deadline, ok := ctx.Deadline(); ok {
remaining := time.Until(deadline)
if remaining > 500*time.Millisecond {
// Leave 500ms buffer for other cleanup
timeout = remaining - 500*time.Millisecond
} else if remaining > 0 {
timeout = remaining
} else {
timeout = 100 * time.Millisecond
}
}
// Stop token manager gracefully
stopCtx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
if err := a.tokenManager.Stop(stopCtx); err != nil {
a.logger.Error("Token manager shutdown error", zap.Error(err))
}
// Cleanup password store (destroy RAM enclaves)
a.logger.Info("Clearing all passwords from secure RAM")
a.passwordStore.Cleanup()
a.logger.Info("Password cleanup completed")
// Cleanup key cache (destroy cached master keys)
a.logger.Info("Clearing all cached master keys from secure memory")
a.keyCache.Cleanup()
a.logger.Info("Key cache cleanup completed")
// Cleanup search index
a.logger.Info("Closing search index")
if err := a.searchService.Close(); err != nil {
a.logger.Error("Search index close error", zap.Error(err))
} else {
a.logger.Info("Search index closed successfully")
}
// Cleanup user-specific storage
a.logger.Info("Cleaning up user storage")
a.storageManager.Cleanup()
a.logger.Info("User storage cleanup completed")
a.logger.Sync()
}

View file

@ -0,0 +1,227 @@
//go:build wireinject
// +build wireinject
package app
import (
"context"
"os"
"strings"
"github.com/google/wire"
"go.uber.org/zap"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/pkg/maplefile/client"
"codeberg.org/mapleopentech/monorepo/native/desktop/maplefile/internal/config"
"codeberg.org/mapleopentech/monorepo/native/desktop/maplefile/pkg/storage/leveldb"
// Domain imports
sessionDomain "codeberg.org/mapleopentech/monorepo/native/desktop/maplefile/internal/domain/session"
// Repository imports
sessionRepo "codeberg.org/mapleopentech/monorepo/native/desktop/maplefile/internal/repo/session"
// Service imports
authService "codeberg.org/mapleopentech/monorepo/native/desktop/maplefile/internal/service/auth"
"codeberg.org/mapleopentech/monorepo/native/desktop/maplefile/internal/service/httpclient"
keyCache "codeberg.org/mapleopentech/monorepo/native/desktop/maplefile/internal/service/keycache"
passwordStore "codeberg.org/mapleopentech/monorepo/native/desktop/maplefile/internal/service/passwordstore"
rateLimiter "codeberg.org/mapleopentech/monorepo/native/desktop/maplefile/internal/service/ratelimiter"
"codeberg.org/mapleopentech/monorepo/native/desktop/maplefile/internal/service/search"
"codeberg.org/mapleopentech/monorepo/native/desktop/maplefile/internal/service/securitylog"
"codeberg.org/mapleopentech/monorepo/native/desktop/maplefile/internal/service/storagemanager"
syncService "codeberg.org/mapleopentech/monorepo/native/desktop/maplefile/internal/service/sync"
tokenManager "codeberg.org/mapleopentech/monorepo/native/desktop/maplefile/internal/service/tokenmanager"
// Use case imports
sessionUC "codeberg.org/mapleopentech/monorepo/native/desktop/maplefile/internal/usecase/session"
)
// InitializeApplication creates a fully configured Application using Wire DI
func InitializeApplication() (*Application, error) {
wire.Build(
// Infrastructure
ProvideLogger,
config.New,
ProvideMapleFileClient,
// Session Repository (global - not user-specific)
ProvideSessionRepository,
// Storage Manager (handles user-specific storage lifecycle)
storagemanager.ProvideManager,
// Bind *storagemanager.Manager to sync.RepositoryProvider interface
wire.Bind(new(syncService.RepositoryProvider), new(*storagemanager.Manager)),
// Use Case Layer
sessionUC.ProvideCreateUseCase,
sessionUC.ProvideGetByIdUseCase,
sessionUC.ProvideDeleteUseCase,
sessionUC.ProvideSaveUseCase,
// Service Layer
authService.ProvideService,
tokenManager.ProvideManager,
passwordStore.ProvideService,
keyCache.ProvideService,
rateLimiter.ProvideService,
httpclient.ProvideService,
securitylog.ProvideService,
search.New,
// Sync Services
syncService.ProvideCollectionSyncService,
syncService.ProvideFileSyncService,
syncService.ProvideService,
// Application
ProvideApplication,
)
return nil, nil
}
// ProvideLogger creates the application logger with environment-aware configuration.
// Defaults to production mode for security. Development mode must be explicitly enabled.
func ProvideLogger() (*zap.Logger, error) {
mode := os.Getenv("MAPLEFILE_MODE")
// Only use development logger if explicitly set to "dev" or "development"
if mode == "dev" || mode == "development" {
// Development: console format, debug level, with caller and stacktrace
return zap.NewDevelopment()
}
// Default to production: JSON format, info level, no caller info, no stacktrace
// This is the secure default - production mode unless explicitly in dev
cfg := zap.NewProductionConfig()
cfg.Level = zap.NewAtomicLevelAt(zap.InfoLevel)
cfg.DisableCaller = true
cfg.DisableStacktrace = true
return cfg.Build()
}
// ProvideSessionRepository creates the session repository with its storage.
// Session storage is GLOBAL (not user-specific) because it stores the current login session.
func ProvideSessionRepository(logger *zap.Logger) (sessionDomain.Repository, error) {
provider, err := config.NewLevelDBConfigurationProviderForSession()
if err != nil {
return nil, err
}
sessionStorage := leveldb.NewDiskStorage(provider, logger.Named("session-storage"))
return sessionRepo.ProvideRepository(sessionStorage), nil
}
// zapLoggerAdapter adapts *zap.Logger to client.Logger interface
type zapLoggerAdapter struct {
logger *zap.Logger
}
func (a *zapLoggerAdapter) Debug(msg string, keysAndValues ...interface{}) {
fields := keysAndValuesToZapFields(keysAndValues...)
a.logger.Debug(msg, fields...)
}
func (a *zapLoggerAdapter) Info(msg string, keysAndValues ...interface{}) {
fields := keysAndValuesToZapFields(keysAndValues...)
a.logger.Info(msg, fields...)
}
func (a *zapLoggerAdapter) Warn(msg string, keysAndValues ...interface{}) {
fields := keysAndValuesToZapFields(keysAndValues...)
a.logger.Warn(msg, fields...)
}
func (a *zapLoggerAdapter) Error(msg string, keysAndValues ...interface{}) {
fields := keysAndValuesToZapFields(keysAndValues...)
a.logger.Error(msg, fields...)
}
// keysAndValuesToZapFields converts key-value pairs to zap fields
func keysAndValuesToZapFields(keysAndValues ...interface{}) []zap.Field {
fields := make([]zap.Field, 0, len(keysAndValues)/2)
for i := 0; i+1 < len(keysAndValues); i += 2 {
key, ok := keysAndValues[i].(string)
if !ok {
continue
}
fields = append(fields, zap.Any(key, keysAndValues[i+1]))
}
return fields
}
// BuildMode is set at compile time via -ldflags
// Example: go build -ldflags "-X codeberg.org/mapleopentech/monorepo/native/desktop/maplefile/internal/app.BuildMode=dev"
var BuildMode string
// ProvideMapleFileClient creates the backend API client
func ProvideMapleFileClient(configService config.ConfigService, logger *zap.Logger) (*client.Client, error) {
ctx := context.Background()
// Determine the API URL based on the mode
// Priority: 1) Environment variable, 2) Build-time variable, 3) Default to production
mode := os.Getenv("MAPLEFILE_MODE")
// Log the detected mode
logger.Info("Startup: checking mode configuration",
zap.String("MAPLEFILE_MODE_env", mode),
zap.String("BuildMode_compile_time", BuildMode),
)
if mode == "" {
if BuildMode != "" {
mode = BuildMode
logger.Info("Startup: using compile-time BuildMode", zap.String("mode", mode))
} else {
mode = "production" // Default to production (secure default)
logger.Info("Startup: no mode set, defaulting to production", zap.String("mode", mode))
}
}
var baseURL string
switch mode {
case "production":
baseURL = client.ProductionURL // https://maplefile.ca
case "dev", "development":
baseURL = client.LocalURL // http://localhost:8000
default:
// Fallback: check config file for custom URL
cfg, err := configService.GetConfig(ctx)
if err != nil {
return nil, err
}
baseURL = cfg.CloudProviderAddress
}
// Create logger adapter for the API client
clientLogger := &zapLoggerAdapter{logger: logger.Named("api-client")}
// Create client with the determined URL and logger
apiClient := client.New(client.Config{
BaseURL: baseURL,
Logger: clientLogger,
})
logger.Info("MapleFile API client initialized",
zap.String("mode", mode),
zap.String("base_url", baseURL),
)
// Security: Warn if using unencrypted HTTP (should only happen in dev mode)
if strings.HasPrefix(baseURL, "http://") {
logger.Warn("SECURITY WARNING: Using unencrypted HTTP connection",
zap.String("mode", mode),
zap.String("base_url", baseURL),
zap.String("recommendation", "This should only be used for local development"),
)
}
// Update the config to reflect the current backend URL (skip in production as it's immutable)
if mode != "production" {
if err := configService.SetCloudProviderAddress(ctx, baseURL); err != nil {
logger.Warn("Failed to update cloud provider address in config", zap.Error(err))
}
}
return apiClient, nil
}

View file

@ -0,0 +1,197 @@
// Code generated by Wire. DO NOT EDIT.
//go:generate go run -mod=mod github.com/google/wire/cmd/wire
//go:build !wireinject
// +build !wireinject
package app
import (
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/pkg/maplefile/client"
"codeberg.org/mapleopentech/monorepo/native/desktop/maplefile/pkg/storage/leveldb"
"codeberg.org/mapleopentech/monorepo/native/desktop/maplefile/internal/config"
session2 "codeberg.org/mapleopentech/monorepo/native/desktop/maplefile/internal/domain/session"
session3 "codeberg.org/mapleopentech/monorepo/native/desktop/maplefile/internal/repo/session"
"codeberg.org/mapleopentech/monorepo/native/desktop/maplefile/internal/service/auth"
"codeberg.org/mapleopentech/monorepo/native/desktop/maplefile/internal/service/httpclient"
"codeberg.org/mapleopentech/monorepo/native/desktop/maplefile/internal/service/keycache"
"codeberg.org/mapleopentech/monorepo/native/desktop/maplefile/internal/service/passwordstore"
"codeberg.org/mapleopentech/monorepo/native/desktop/maplefile/internal/service/ratelimiter"
"codeberg.org/mapleopentech/monorepo/native/desktop/maplefile/internal/service/search"
"codeberg.org/mapleopentech/monorepo/native/desktop/maplefile/internal/service/securitylog"
"codeberg.org/mapleopentech/monorepo/native/desktop/maplefile/internal/service/storagemanager"
"codeberg.org/mapleopentech/monorepo/native/desktop/maplefile/internal/service/sync"
"codeberg.org/mapleopentech/monorepo/native/desktop/maplefile/internal/service/tokenmanager"
"codeberg.org/mapleopentech/monorepo/native/desktop/maplefile/internal/usecase/session"
"context"
"go.uber.org/zap"
"os"
"strings"
)
// Injectors from wire.go:
// InitializeApplication creates a fully configured Application using Wire DI
func InitializeApplication() (*Application, error) {
logger, err := ProvideLogger()
if err != nil {
return nil, err
}
configService, err := config.New()
if err != nil {
return nil, err
}
client, err := ProvideMapleFileClient(configService, logger)
if err != nil {
return nil, err
}
repository, err := ProvideSessionRepository(logger)
if err != nil {
return nil, err
}
createUseCase := session.ProvideCreateUseCase(repository)
getByIdUseCase := session.ProvideGetByIdUseCase(repository)
deleteUseCase := session.ProvideDeleteUseCase(repository)
saveUseCase := session.ProvideSaveUseCase(repository)
service := auth.ProvideService(client, createUseCase, getByIdUseCase, deleteUseCase, saveUseCase, logger)
manager := tokenmanager.ProvideManager(client, service, getByIdUseCase, logger)
passwordstoreService := passwordstore.ProvideService(logger)
keycacheService := keycache.ProvideService(logger)
ratelimiterService := ratelimiter.ProvideService()
httpclientService := httpclient.ProvideService()
storagemanagerManager := storagemanager.ProvideManager(logger)
collectionSyncService := sync.ProvideCollectionSyncService(logger, client, storagemanagerManager)
fileSyncService := sync.ProvideFileSyncService(logger, client, storagemanagerManager)
syncService := sync.ProvideService(logger, collectionSyncService, fileSyncService, storagemanagerManager)
securitylogService := securitylog.ProvideService(logger)
searchService := search.New(configService, logger)
application := ProvideApplication(logger, configService, service, manager, passwordstoreService, keycacheService, ratelimiterService, httpclientService, syncService, storagemanagerManager, securitylogService, searchService)
return application, nil
}
// wire.go:
// ProvideLogger creates the application logger with environment-aware configuration.
// Defaults to production mode for security. Development mode must be explicitly enabled.
func ProvideLogger() (*zap.Logger, error) {
mode := os.Getenv("MAPLEFILE_MODE")
if mode == "dev" || mode == "development" {
return zap.NewDevelopment()
}
cfg := zap.NewProductionConfig()
cfg.Level = zap.NewAtomicLevelAt(zap.InfoLevel)
cfg.DisableCaller = true
cfg.DisableStacktrace = true
return cfg.Build()
}
// ProvideSessionRepository creates the session repository with its storage.
// Session storage is GLOBAL (not user-specific) because it stores the current login session.
func ProvideSessionRepository(logger *zap.Logger) (session2.Repository, error) {
provider, err := config.NewLevelDBConfigurationProviderForSession()
if err != nil {
return nil, err
}
sessionStorage := leveldb.NewDiskStorage(provider, logger.Named("session-storage"))
return session3.ProvideRepository(sessionStorage), nil
}
// zapLoggerAdapter adapts *zap.Logger to client.Logger interface
type zapLoggerAdapter struct {
logger *zap.Logger
}
func (a *zapLoggerAdapter) Debug(msg string, keysAndValues ...interface{}) {
fields := keysAndValuesToZapFields(keysAndValues...)
a.logger.Debug(msg, fields...)
}
func (a *zapLoggerAdapter) Info(msg string, keysAndValues ...interface{}) {
fields := keysAndValuesToZapFields(keysAndValues...)
a.logger.Info(msg, fields...)
}
func (a *zapLoggerAdapter) Warn(msg string, keysAndValues ...interface{}) {
fields := keysAndValuesToZapFields(keysAndValues...)
a.logger.Warn(msg, fields...)
}
func (a *zapLoggerAdapter) Error(msg string, keysAndValues ...interface{}) {
fields := keysAndValuesToZapFields(keysAndValues...)
a.logger.Error(msg, fields...)
}
// keysAndValuesToZapFields converts key-value pairs to zap fields
func keysAndValuesToZapFields(keysAndValues ...interface{}) []zap.Field {
fields := make([]zap.Field, 0, len(keysAndValues)/2)
for i := 0; i+1 < len(keysAndValues); i += 2 {
key, ok := keysAndValues[i].(string)
if !ok {
continue
}
fields = append(fields, zap.Any(key, keysAndValues[i+1]))
}
return fields
}
// BuildMode is set at compile time via -ldflags
// Example: go build -ldflags "-X codeberg.org/mapleopentech/monorepo/native/desktop/maplefile/internal/app.BuildMode=dev"
var BuildMode string
// ProvideMapleFileClient creates the backend API client
func ProvideMapleFileClient(configService config.ConfigService, logger *zap.Logger) (*client.Client, error) {
ctx := context.Background()
mode := os.Getenv("MAPLEFILE_MODE")
logger.Info("Startup: checking mode configuration", zap.String("MAPLEFILE_MODE_env", mode), zap.String("BuildMode_compile_time", BuildMode))
if mode == "" {
if BuildMode != "" {
mode = BuildMode
logger.Info("Startup: using compile-time BuildMode", zap.String("mode", mode))
} else {
mode = "production"
logger.Info("Startup: no mode set, defaulting to production", zap.String("mode", mode))
}
}
var baseURL string
switch mode {
case "production":
baseURL = client.ProductionURL
case "dev", "development":
baseURL = client.LocalURL
default:
cfg, err := configService.GetConfig(ctx)
if err != nil {
return nil, err
}
baseURL = cfg.CloudProviderAddress
}
clientLogger := &zapLoggerAdapter{logger: logger.Named("api-client")}
apiClient := client.New(client.Config{
BaseURL: baseURL,
Logger: clientLogger,
})
logger.Info("MapleFile API client initialized", zap.String("mode", mode), zap.String("base_url", baseURL))
if strings.HasPrefix(baseURL, "http://") {
logger.Warn("SECURITY WARNING: Using unencrypted HTTP connection", zap.String("mode", mode), zap.String("base_url", baseURL), zap.String("recommendation", "This should only be used for local development"))
}
if mode != "production" {
if err := configService.SetCloudProviderAddress(ctx, baseURL); err != nil {
logger.Warn("Failed to update cloud provider address in config", zap.Error(err))
}
}
return apiClient, nil
}

View file

@ -0,0 +1,270 @@
// Package config provides a unified API for managing application configuration
// Location: monorepo/native/desktop/maplefile/internal/config/config.go
package config
import (
"context"
"encoding/json"
"os"
"path/filepath"
"sync"
"time"
)
const (
// AppNameBase is the base name of the desktop application
AppNameBase = "maplefile"
// AppNameDev is the app name used in development mode
AppNameDev = "maplefile-dev"
)
// BuildMode is set at compile time via -ldflags
// Example: go build -ldflags "-X codeberg.org/mapleopentech/monorepo/native/desktop/maplefile/internal/config.BuildMode=dev"
// This must be set alongside the app.BuildMode for consistent behavior.
var BuildMode string
// GetAppName returns the appropriate app name based on the current mode.
// In dev mode, returns "maplefile-dev" to keep dev and production data separate.
// In production mode (or when mode is not set), returns "maplefile".
func GetAppName() string {
mode := GetBuildMode()
if mode == "dev" || mode == "development" {
return AppNameDev
}
return AppNameBase
}
// GetBuildMode returns the current build mode from environment or compile-time variable.
// Priority: 1) Environment variable, 2) Compile-time variable, 3) Default to production
// This is used early in initialization before the full app is set up.
func GetBuildMode() string {
// Check environment variable first
if mode := os.Getenv("MAPLEFILE_MODE"); mode != "" {
return mode
}
// Check compile-time variable
if BuildMode != "" {
return BuildMode
}
// Default to production (secure default)
return "production"
}
// Config holds all application configuration in a flat structure
type Config struct {
// CloudProviderAddress is the URI backend to make all calls to from this application for E2EE cloud operations.
CloudProviderAddress string `json:"cloud_provider_address"`
Credentials *Credentials `json:"credentials"`
// Desktop-specific settings
WindowWidth int `json:"window_width"`
WindowHeight int `json:"window_height"`
Theme string `json:"theme"` // light, dark, auto
Language string `json:"language"` // en, es, fr, etc.
SyncMode string `json:"sync_mode"` // encrypted_only, hybrid, decrypted_only
AutoSync bool `json:"auto_sync"` // Enable automatic synchronization
SyncIntervalMinutes int `json:"sync_interval_minutes"` // Sync interval in minutes
ShowHiddenFiles bool `json:"show_hidden_files"` // Show hidden files in file manager
DefaultView string `json:"default_view"` // list, grid
SortBy string `json:"sort_by"` // name, date, size, type
SortOrder string `json:"sort_order"` // asc, desc
}
// Credentials holds all user credentials for authentication and authorization.
// Values are decrypted for convenience purposes as we assume threat actor cannot access the decrypted values on the user's device.
type Credentials struct {
// Email is the unique registered email of the user whom successfully logged into the system.
Email string `json:"email"`
AccessToken string `json:"access_token"`
AccessTokenExpiryTime *time.Time `json:"access_token_expiry_time"`
RefreshToken string `json:"refresh_token"`
RefreshTokenExpiryTime *time.Time `json:"refresh_token_expiry_time"`
}
// ConfigService defines the unified interface for all configuration operations
type ConfigService interface {
GetConfig(ctx context.Context) (*Config, error)
GetAppDataDirPath(ctx context.Context) (string, error)
GetCloudProviderAddress(ctx context.Context) (string, error)
SetCloudProviderAddress(ctx context.Context, address string) error
GetLoggedInUserCredentials(ctx context.Context) (*Credentials, error)
SetLoggedInUserCredentials(
ctx context.Context,
email string,
accessToken string,
accessTokenExpiryTime *time.Time,
refreshToken string,
refreshTokenExpiryTime *time.Time,
) error
ClearLoggedInUserCredentials(ctx context.Context) error
// User-specific storage methods
// These return paths that are isolated per user and per environment (dev/production)
GetUserDataDirPath(ctx context.Context, userEmail string) (string, error)
GetUserFilesDirPath(ctx context.Context, userEmail string) (string, error)
GetUserSearchIndexDir(ctx context.Context, userEmail string) (string, error)
GetLoggedInUserEmail(ctx context.Context) (string, error)
// Desktop-specific methods
GetWindowSize(ctx context.Context) (width int, height int, err error)
SetWindowSize(ctx context.Context, width int, height int) error
GetTheme(ctx context.Context) (string, error)
SetTheme(ctx context.Context, theme string) error
GetLanguage(ctx context.Context) (string, error)
SetLanguage(ctx context.Context, language string) error
GetSyncMode(ctx context.Context) (string, error)
SetSyncMode(ctx context.Context, mode string) error
GetAutoSync(ctx context.Context) (bool, error)
SetAutoSync(ctx context.Context, enabled bool) error
GetSyncInterval(ctx context.Context) (int, error)
SetSyncInterval(ctx context.Context, minutes int) error
GetShowHiddenFiles(ctx context.Context) (bool, error)
SetShowHiddenFiles(ctx context.Context, show bool) error
GetDefaultView(ctx context.Context) (string, error)
SetDefaultView(ctx context.Context, view string) error
GetSortPreferences(ctx context.Context) (sortBy string, sortOrder string, err error)
SetSortPreferences(ctx context.Context, sortBy string, sortOrder string) error
}
// repository defines the interface for loading and saving configuration
type repository interface {
// LoadConfig loads the configuration, returning defaults if file doesn't exist
LoadConfig(ctx context.Context) (*Config, error)
// SaveConfig saves the configuration to persistent storage
SaveConfig(ctx context.Context, config *Config) error
}
// configService implements the ConfigService interface
type configService struct {
repo repository
mu sync.RWMutex // Thread safety
}
// fileRepository implements the repository interface with file-based storage
type fileRepository struct {
configPath string
appName string
}
// New creates a new configuration service with default settings
// This is the Wire provider function
func New() (ConfigService, error) {
appName := GetAppName()
repo, err := newFileRepository(appName)
if err != nil {
return nil, err
}
// Wrap with integrity checking
fileRepo := repo.(*fileRepository)
integrityRepo, err := NewIntegrityAwareRepository(repo, appName, fileRepo.configPath)
if err != nil {
// Fall back to basic repository if integrity service fails
// This allows the app to still function
return &configService{
repo: repo,
}, nil
}
return &configService{
repo: integrityRepo,
}, nil
}
// NewForTesting creates a configuration service with the specified repository (for testing)
func NewForTesting(repo repository) ConfigService {
return &configService{
repo: repo,
}
}
// newFileRepository creates a new instance of repository
func newFileRepository(appName string) (repository, error) {
configDir, err := os.UserConfigDir()
if err != nil {
return nil, err
}
// Create app-specific config directory with restrictive permissions (owner only)
// 0700 = owner read/write/execute, no access for group or others
appConfigDir := filepath.Join(configDir, appName)
if err := os.MkdirAll(appConfigDir, 0700); err != nil {
return nil, err
}
configPath := filepath.Join(appConfigDir, "config.json")
return &fileRepository{
configPath: configPath,
appName: appName,
}, nil
}
// LoadConfig loads the configuration from file, or returns defaults if file doesn't exist
func (r *fileRepository) LoadConfig(ctx context.Context) (*Config, error) {
// Check if the config file exists
if _, err := os.Stat(r.configPath); os.IsNotExist(err) {
// Return default config if file doesn't exist
defaults := getDefaultConfig()
// Save the defaults for future use
if err := r.SaveConfig(ctx, defaults); err != nil {
return nil, err
}
return defaults, nil
}
// Read config from file
data, err := os.ReadFile(r.configPath)
if err != nil {
return nil, err
}
var config Config
if err := json.Unmarshal(data, &config); err != nil {
return nil, err
}
return &config, nil
}
// SaveConfig saves the configuration to file with restrictive permissions
func (r *fileRepository) SaveConfig(ctx context.Context, config *Config) error {
data, err := json.MarshalIndent(config, "", " ")
if err != nil {
return err
}
// Use 0600 permissions (owner read/write only) for security
return os.WriteFile(r.configPath, data, 0600)
}
// getDefaultConfig returns the default configuration values
// Note: This function is only called from LoadConfig after the config directory
// has already been created by newFileRepository, so no directory creation needed here.
func getDefaultConfig() *Config {
return &Config{
CloudProviderAddress: "http://localhost:8000",
Credentials: &Credentials{
Email: "", // Leave blank because no user was authenticated.
AccessToken: "", // Leave blank because no user was authenticated.
AccessTokenExpiryTime: nil, // Leave blank because no user was authenticated.
RefreshToken: "", // Leave blank because no user was authenticated.
RefreshTokenExpiryTime: nil, // Leave blank because no user was authenticated.
},
// Desktop-specific defaults
WindowWidth: 1440,
WindowHeight: 900,
Theme: "auto",
Language: "en",
SyncMode: "hybrid",
AutoSync: true,
SyncIntervalMinutes: 30,
ShowHiddenFiles: false,
DefaultView: "list",
SortBy: "name",
SortOrder: "asc",
}
}

View file

@ -0,0 +1,253 @@
package config
import (
"context"
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
)
const (
// integrityKeyFile is the filename for the integrity key
integrityKeyFile = ".config_key"
// integrityKeyLength is the length of the HMAC key in bytes
integrityKeyLength = 32
// hmacField is the JSON field name for the HMAC signature
hmacField = "_integrity"
)
// ConfigWithIntegrity wraps a Config with an integrity signature
type ConfigWithIntegrity struct {
Config
Integrity string `json:"_integrity,omitempty"`
}
// IntegrityService provides HMAC-based integrity verification for config files
type IntegrityService struct {
keyPath string
key []byte
}
// NewIntegrityService creates a new integrity service for the given app
func NewIntegrityService(appName string) (*IntegrityService, error) {
configDir, err := os.UserConfigDir()
if err != nil {
return nil, fmt.Errorf("failed to get config directory: %w", err)
}
appConfigDir := filepath.Join(configDir, appName)
keyPath := filepath.Join(appConfigDir, integrityKeyFile)
svc := &IntegrityService{
keyPath: keyPath,
}
// Load or generate key
if err := svc.loadOrGenerateKey(); err != nil {
return nil, fmt.Errorf("failed to initialize integrity key: %w", err)
}
return svc, nil
}
// loadOrGenerateKey loads the HMAC key from file or generates a new one
func (s *IntegrityService) loadOrGenerateKey() error {
// Try to load existing key
data, err := os.ReadFile(s.keyPath)
if err == nil {
// Key exists, decode it
s.key, err = base64.StdEncoding.DecodeString(string(data))
if err != nil || len(s.key) != integrityKeyLength {
// Invalid key, regenerate
return s.generateNewKey()
}
return nil
}
if !os.IsNotExist(err) {
return fmt.Errorf("failed to read integrity key: %w", err)
}
// Key doesn't exist, generate new one
return s.generateNewKey()
}
// generateNewKey generates a new HMAC key and saves it
func (s *IntegrityService) generateNewKey() error {
s.key = make([]byte, integrityKeyLength)
if _, err := rand.Read(s.key); err != nil {
return fmt.Errorf("failed to generate key: %w", err)
}
// Ensure directory exists with restrictive permissions
if err := os.MkdirAll(filepath.Dir(s.keyPath), 0700); err != nil {
return fmt.Errorf("failed to create key directory: %w", err)
}
// Save key with restrictive permissions (owner read only)
encoded := base64.StdEncoding.EncodeToString(s.key)
if err := os.WriteFile(s.keyPath, []byte(encoded), 0400); err != nil {
return fmt.Errorf("failed to save integrity key: %w", err)
}
return nil
}
// ComputeHMAC computes the HMAC signature for config data
func (s *IntegrityService) ComputeHMAC(config *Config) (string, error) {
// Serialize config without the integrity field
data, err := json.Marshal(config)
if err != nil {
return "", fmt.Errorf("failed to serialize config: %w", err)
}
// Compute HMAC-SHA256
h := hmac.New(sha256.New, s.key)
h.Write(data)
signature := h.Sum(nil)
return base64.StdEncoding.EncodeToString(signature), nil
}
// VerifyHMAC verifies the HMAC signature of config data
func (s *IntegrityService) VerifyHMAC(config *Config, providedHMAC string) error {
// Compute expected HMAC
expectedHMAC, err := s.ComputeHMAC(config)
if err != nil {
return fmt.Errorf("failed to compute HMAC: %w", err)
}
// Decode provided HMAC
providedBytes, err := base64.StdEncoding.DecodeString(providedHMAC)
if err != nil {
return errors.New("invalid HMAC format")
}
expectedBytes, err := base64.StdEncoding.DecodeString(expectedHMAC)
if err != nil {
return errors.New("internal error computing HMAC")
}
// Constant-time comparison to prevent timing attacks
if !hmac.Equal(providedBytes, expectedBytes) {
return errors.New("config integrity check failed: file may have been tampered with")
}
return nil
}
// SignConfig adds an HMAC signature to the config
func (s *IntegrityService) SignConfig(config *Config) (*ConfigWithIntegrity, error) {
signature, err := s.ComputeHMAC(config)
if err != nil {
return nil, err
}
return &ConfigWithIntegrity{
Config: *config,
Integrity: signature,
}, nil
}
// VerifyAndExtractConfig verifies the integrity and returns the config
func (s *IntegrityService) VerifyAndExtractConfig(configWithInt *ConfigWithIntegrity) (*Config, error) {
if configWithInt.Integrity == "" {
// No integrity field - config was created before integrity checking was added
// Allow it but log a warning (caller should handle this)
return &configWithInt.Config, nil
}
// Verify the HMAC
if err := s.VerifyHMAC(&configWithInt.Config, configWithInt.Integrity); err != nil {
return nil, err
}
return &configWithInt.Config, nil
}
// integrityAwareRepository wraps a repository with integrity checking
type integrityAwareRepository struct {
inner repository
integritySvc *IntegrityService
configPath string
warnOnMissingMAC bool // If true, allows configs without MAC (for migration)
}
// NewIntegrityAwareRepository creates a repository wrapper with integrity checking
func NewIntegrityAwareRepository(inner repository, appName string, configPath string) (repository, error) {
integritySvc, err := NewIntegrityService(appName)
if err != nil {
return nil, err
}
return &integrityAwareRepository{
inner: inner,
integritySvc: integritySvc,
configPath: configPath,
warnOnMissingMAC: true, // Allow migration from old configs
}, nil
}
// LoadConfig loads and verifies the config
func (r *integrityAwareRepository) LoadConfig(ctx context.Context) (*Config, error) {
// Check if config file exists
if _, err := os.Stat(r.configPath); os.IsNotExist(err) {
// Load from inner (will create defaults)
config, err := r.inner.LoadConfig(ctx)
if err != nil {
return nil, err
}
// Save with integrity
return config, r.SaveConfig(ctx, config)
}
// Read raw file to check for integrity field
data, err := os.ReadFile(r.configPath)
if err != nil {
return nil, fmt.Errorf("failed to read config: %w", err)
}
// Try to parse with integrity field
var configWithInt ConfigWithIntegrity
if err := json.Unmarshal(data, &configWithInt); err != nil {
return nil, fmt.Errorf("failed to parse config: %w", err)
}
// Verify integrity
config, err := r.integritySvc.VerifyAndExtractConfig(&configWithInt)
if err != nil {
return nil, err
}
// If config had no integrity field, save it with one (migration)
if configWithInt.Integrity == "" && r.warnOnMissingMAC {
// Re-save with integrity
_ = r.SaveConfig(ctx, config) // Ignore error, not critical
}
return config, nil
}
// SaveConfig saves the config with integrity signature
func (r *integrityAwareRepository) SaveConfig(ctx context.Context, config *Config) error {
// Sign the config
signedConfig, err := r.integritySvc.SignConfig(config)
if err != nil {
return fmt.Errorf("failed to sign config: %w", err)
}
// Serialize with integrity field
data, err := json.MarshalIndent(signedConfig, "", " ")
if err != nil {
return fmt.Errorf("failed to serialize config: %w", err)
}
// Write with restrictive permissions
return os.WriteFile(r.configPath, data, 0600)
}

View file

@ -0,0 +1,162 @@
// internal/config/leveldb.go
package config
import (
"fmt"
"os"
"path/filepath"
"codeberg.org/mapleopentech/monorepo/native/desktop/maplefile/pkg/storage/leveldb"
)
// LevelDB support functions - desktop-specific databases
// These functions return errors instead of using log.Fatalf to allow proper error handling.
//
// Storage is organized as follows:
// - Global storage (session): {appDir}/session/
// - User-specific storage: {appDir}/users/{emailHash}/{dbName}/
//
// This ensures:
// 1. Different users have isolated data
// 2. Dev and production modes have separate directories ({appName} vs {appName}-dev)
// 3. Email addresses are not exposed in directory names (hashed)
// getAppDir returns the application data directory path, creating it if needed.
// Uses 0700 permissions for security (owner read/write/execute only).
// The directory name is mode-aware: "maplefile-dev" for dev mode, "maplefile" for production.
func getAppDir() (string, error) {
configDir, err := os.UserConfigDir()
if err != nil {
return "", fmt.Errorf("failed to get user config directory: %w", err)
}
appName := GetAppName()
appDir := filepath.Join(configDir, appName)
// Ensure the directory exists with restrictive permissions
if err := os.MkdirAll(appDir, 0700); err != nil {
return "", fmt.Errorf("failed to create app directory: %w", err)
}
return appDir, nil
}
// getUserDir returns the user-specific data directory, creating it if needed.
// Returns an error if userEmail is empty (no user logged in).
func getUserDir(userEmail string) (string, error) {
if userEmail == "" {
return "", fmt.Errorf("no user email provided - user must be logged in")
}
appName := GetAppName()
userDir, err := GetUserSpecificDataDir(appName, userEmail)
if err != nil {
return "", fmt.Errorf("failed to get user data directory: %w", err)
}
return userDir, nil
}
// =============================================================================
// GLOBAL STORAGE PROVIDERS (not user-specific)
// =============================================================================
// NewLevelDBConfigurationProviderForSession returns a LevelDB configuration provider for user sessions.
// Session storage is GLOBAL (not per-user) because it stores the current login session.
func NewLevelDBConfigurationProviderForSession() (leveldb.LevelDBConfigurationProvider, error) {
appDir, err := getAppDir()
if err != nil {
return nil, fmt.Errorf("session storage: %w", err)
}
return leveldb.NewLevelDBConfigurationProvider(appDir, "session"), nil
}
// =============================================================================
// USER-SPECIFIC STORAGE PROVIDERS
// These require a logged-in user's email to determine the storage path.
// =============================================================================
// NewLevelDBConfigurationProviderForLocalFilesWithUser returns a LevelDB configuration provider
// for local file metadata, scoped to a specific user.
func NewLevelDBConfigurationProviderForLocalFilesWithUser(userEmail string) (leveldb.LevelDBConfigurationProvider, error) {
userDir, err := getUserDir(userEmail)
if err != nil {
return nil, fmt.Errorf("local files storage: %w", err)
}
return leveldb.NewLevelDBConfigurationProvider(userDir, "local_files"), nil
}
// NewLevelDBConfigurationProviderForSyncStateWithUser returns a LevelDB configuration provider
// for sync state, scoped to a specific user.
func NewLevelDBConfigurationProviderForSyncStateWithUser(userEmail string) (leveldb.LevelDBConfigurationProvider, error) {
userDir, err := getUserDir(userEmail)
if err != nil {
return nil, fmt.Errorf("sync state storage: %w", err)
}
return leveldb.NewLevelDBConfigurationProvider(userDir, "sync_state"), nil
}
// NewLevelDBConfigurationProviderForCacheWithUser returns a LevelDB configuration provider
// for local cache, scoped to a specific user.
func NewLevelDBConfigurationProviderForCacheWithUser(userEmail string) (leveldb.LevelDBConfigurationProvider, error) {
userDir, err := getUserDir(userEmail)
if err != nil {
return nil, fmt.Errorf("cache storage: %w", err)
}
return leveldb.NewLevelDBConfigurationProvider(userDir, "cache"), nil
}
// NewLevelDBConfigurationProviderForUserDataWithUser returns a LevelDB configuration provider
// for user-specific data, scoped to a specific user.
func NewLevelDBConfigurationProviderForUserDataWithUser(userEmail string) (leveldb.LevelDBConfigurationProvider, error) {
userDir, err := getUserDir(userEmail)
if err != nil {
return nil, fmt.Errorf("user data storage: %w", err)
}
return leveldb.NewLevelDBConfigurationProvider(userDir, "user_data"), nil
}
// =============================================================================
// LEGACY FUNCTIONS (deprecated - use user-specific versions instead)
// These exist for backward compatibility during migration.
// =============================================================================
// NewLevelDBConfigurationProviderForCache returns a LevelDB configuration provider for local cache.
// Deprecated: Use NewLevelDBConfigurationProviderForCacheWithUser instead.
func NewLevelDBConfigurationProviderForCache() (leveldb.LevelDBConfigurationProvider, error) {
appDir, err := getAppDir()
if err != nil {
return nil, fmt.Errorf("cache storage: %w", err)
}
return leveldb.NewLevelDBConfigurationProvider(appDir, "cache"), nil
}
// NewLevelDBConfigurationProviderForLocalFiles returns a LevelDB configuration provider for local file metadata.
// Deprecated: Use NewLevelDBConfigurationProviderForLocalFilesWithUser instead.
func NewLevelDBConfigurationProviderForLocalFiles() (leveldb.LevelDBConfigurationProvider, error) {
appDir, err := getAppDir()
if err != nil {
return nil, fmt.Errorf("local files storage: %w", err)
}
return leveldb.NewLevelDBConfigurationProvider(appDir, "local_files"), nil
}
// NewLevelDBConfigurationProviderForSyncState returns a LevelDB configuration provider for sync state.
// Deprecated: Use NewLevelDBConfigurationProviderForSyncStateWithUser instead.
func NewLevelDBConfigurationProviderForSyncState() (leveldb.LevelDBConfigurationProvider, error) {
appDir, err := getAppDir()
if err != nil {
return nil, fmt.Errorf("sync state storage: %w", err)
}
return leveldb.NewLevelDBConfigurationProvider(appDir, "sync_state"), nil
}
// NewLevelDBConfigurationProviderForUser returns a LevelDB configuration provider for user data.
// Deprecated: Use NewLevelDBConfigurationProviderForUserDataWithUser instead.
func NewLevelDBConfigurationProviderForUser() (leveldb.LevelDBConfigurationProvider, error) {
appDir, err := getAppDir()
if err != nil {
return nil, fmt.Errorf("user storage: %w", err)
}
return leveldb.NewLevelDBConfigurationProvider(appDir, "user"), nil
}

View file

@ -0,0 +1,398 @@
// Package config provides a unified API for managing application configuration
// Location: monorepo/native/desktop/maplefile/internal/config/methods.go
package config
import (
"context"
"fmt"
"net/url"
"os"
"strings"
"time"
)
// Implementation of ConfigService methods
// getConfig is an internal method to get the current configuration
func (s *configService) getConfig(ctx context.Context) (*Config, error) {
s.mu.RLock()
defer s.mu.RUnlock()
return s.repo.LoadConfig(ctx)
}
// saveConfig is an internal method to save the configuration
func (s *configService) saveConfig(ctx context.Context, config *Config) error {
s.mu.Lock()
defer s.mu.Unlock()
return s.repo.SaveConfig(ctx, config)
}
// GetConfig returns the complete configuration
func (s *configService) GetConfig(ctx context.Context) (*Config, error) {
return s.getConfig(ctx)
}
// GetAppDataDirPath returns the proper application data directory path
// The directory is mode-aware: "maplefile-dev" for dev mode, "maplefile" for production.
func (s *configService) GetAppDataDirPath(ctx context.Context) (string, error) {
return GetUserDataDir(GetAppName())
}
// GetUserDataDirPath returns the data directory path for a specific user.
// This path is:
// 1. Isolated per user (different users get different directories)
// 2. Isolated per environment (dev vs production)
// 3. Privacy-preserving (email is hashed to create directory name)
//
// Structure: {appDataDir}/users/{emailHash}/
func (s *configService) GetUserDataDirPath(ctx context.Context, userEmail string) (string, error) {
if userEmail == "" {
return "", fmt.Errorf("user email is required")
}
return GetUserSpecificDataDir(GetAppName(), userEmail)
}
// GetUserFilesDirPath returns the directory where decrypted files are stored for a user.
// Files are organized by collection: {userDir}/files/{collectionId}/{filename}
func (s *configService) GetUserFilesDirPath(ctx context.Context, userEmail string) (string, error) {
if userEmail == "" {
return "", fmt.Errorf("user email is required")
}
return GetUserFilesDir(GetAppName(), userEmail)
}
// GetUserSearchIndexDir returns the search index directory path for a specific user.
func (s *configService) GetUserSearchIndexDir(ctx context.Context, userEmail string) (string, error) {
if userEmail == "" {
return "", fmt.Errorf("user email is required")
}
return GetUserSearchIndexDir(GetAppName(), userEmail)
}
// GetLoggedInUserEmail returns the email of the currently logged-in user.
// Returns an empty string if no user is logged in.
func (s *configService) GetLoggedInUserEmail(ctx context.Context) (string, error) {
config, err := s.getConfig(ctx)
if err != nil {
return "", err
}
if config.Credentials == nil {
return "", nil
}
return config.Credentials.Email, nil
}
// GetCloudProviderAddress returns the cloud provider address
func (s *configService) GetCloudProviderAddress(ctx context.Context) (string, error) {
config, err := s.getConfig(ctx)
if err != nil {
return "", err
}
return config.CloudProviderAddress, nil
}
// SetCloudProviderAddress updates the cloud provider address with security validation.
// In production mode, the address cannot be changed.
// In dev mode, HTTP is allowed for localhost only.
func (s *configService) SetCloudProviderAddress(ctx context.Context, address string) error {
mode := os.Getenv("MAPLEFILE_MODE")
if mode == "" {
mode = "dev"
}
// Security: Block address changes in production mode
if mode == "production" {
return fmt.Errorf("cloud provider address cannot be changed in production mode")
}
// Validate URL format
if err := validateCloudProviderURL(address, mode); err != nil {
return fmt.Errorf("invalid cloud provider address: %w", err)
}
config, err := s.getConfig(ctx)
if err != nil {
return err
}
config.CloudProviderAddress = address
return s.saveConfig(ctx, config)
}
// validateCloudProviderURL validates the cloud provider URL based on the current mode.
// Returns an error if the URL is invalid or doesn't meet security requirements.
func validateCloudProviderURL(rawURL string, mode string) error {
if rawURL == "" {
return fmt.Errorf("URL cannot be empty")
}
parsedURL, err := url.Parse(rawURL)
if err != nil {
return fmt.Errorf("malformed URL: %w", err)
}
// Validate scheme
scheme := strings.ToLower(parsedURL.Scheme)
if scheme != "http" && scheme != "https" {
return fmt.Errorf("URL scheme must be http or https, got: %s", scheme)
}
// Validate host is present
if parsedURL.Host == "" {
return fmt.Errorf("URL must have a host")
}
// Security: In dev mode, allow HTTP only for localhost
if mode == "dev" && scheme == "http" {
host := strings.ToLower(parsedURL.Hostname())
if host != "localhost" && host != "127.0.0.1" && !strings.HasPrefix(host, "192.168.") && !strings.HasPrefix(host, "10.") {
return fmt.Errorf("HTTP is only allowed for localhost/local network in dev mode; use HTTPS for remote servers")
}
}
// Reject URLs with credentials embedded
if parsedURL.User != nil {
return fmt.Errorf("URL must not contain embedded credentials")
}
return nil
}
// SetLoggedInUserCredentials updates the authenticated user's credentials
func (s *configService) SetLoggedInUserCredentials(
ctx context.Context,
email string,
accessToken string,
accessTokenExpiryTime *time.Time,
refreshToken string,
refreshTokenExpiryTime *time.Time,
) error {
config, err := s.getConfig(ctx)
if err != nil {
return err
}
config.Credentials = &Credentials{
Email: email,
AccessToken: accessToken,
AccessTokenExpiryTime: accessTokenExpiryTime,
RefreshToken: refreshToken,
RefreshTokenExpiryTime: refreshTokenExpiryTime,
}
return s.saveConfig(ctx, config)
}
// GetLoggedInUserCredentials returns the authenticated user's credentials
func (s *configService) GetLoggedInUserCredentials(ctx context.Context) (*Credentials, error) {
config, err := s.getConfig(ctx)
if err != nil {
return nil, err
}
return config.Credentials, nil
}
// ClearLoggedInUserCredentials clears the authenticated user's credentials
func (s *configService) ClearLoggedInUserCredentials(ctx context.Context) error {
config, err := s.getConfig(ctx)
if err != nil {
return err
}
// Clear credentials by setting them to empty values
config.Credentials = &Credentials{
Email: "",
AccessToken: "",
AccessTokenExpiryTime: nil,
RefreshToken: "",
RefreshTokenExpiryTime: nil,
}
return s.saveConfig(ctx, config)
}
// Desktop-specific methods
// GetWindowSize returns the configured window size
func (s *configService) GetWindowSize(ctx context.Context) (width int, height int, err error) {
config, err := s.getConfig(ctx)
if err != nil {
return 0, 0, err
}
return config.WindowWidth, config.WindowHeight, nil
}
// SetWindowSize updates the window size configuration
func (s *configService) SetWindowSize(ctx context.Context, width int, height int) error {
config, err := s.getConfig(ctx)
if err != nil {
return err
}
config.WindowWidth = width
config.WindowHeight = height
return s.saveConfig(ctx, config)
}
// GetTheme returns the configured theme
func (s *configService) GetTheme(ctx context.Context) (string, error) {
config, err := s.getConfig(ctx)
if err != nil {
return "", err
}
return config.Theme, nil
}
// SetTheme updates the theme configuration
func (s *configService) SetTheme(ctx context.Context, theme string) error {
config, err := s.getConfig(ctx)
if err != nil {
return err
}
config.Theme = theme
return s.saveConfig(ctx, config)
}
// GetLanguage returns the configured language
func (s *configService) GetLanguage(ctx context.Context) (string, error) {
config, err := s.getConfig(ctx)
if err != nil {
return "", err
}
return config.Language, nil
}
// SetLanguage updates the language configuration
func (s *configService) SetLanguage(ctx context.Context, language string) error {
config, err := s.getConfig(ctx)
if err != nil {
return err
}
config.Language = language
return s.saveConfig(ctx, config)
}
// GetSyncMode returns the configured sync mode
func (s *configService) GetSyncMode(ctx context.Context) (string, error) {
config, err := s.getConfig(ctx)
if err != nil {
return "", err
}
return config.SyncMode, nil
}
// SetSyncMode updates the sync mode configuration
func (s *configService) SetSyncMode(ctx context.Context, mode string) error {
config, err := s.getConfig(ctx)
if err != nil {
return err
}
config.SyncMode = mode
return s.saveConfig(ctx, config)
}
// GetAutoSync returns whether automatic sync is enabled
func (s *configService) GetAutoSync(ctx context.Context) (bool, error) {
config, err := s.getConfig(ctx)
if err != nil {
return false, err
}
return config.AutoSync, nil
}
// SetAutoSync updates the automatic sync setting
func (s *configService) SetAutoSync(ctx context.Context, enabled bool) error {
config, err := s.getConfig(ctx)
if err != nil {
return err
}
config.AutoSync = enabled
return s.saveConfig(ctx, config)
}
// GetSyncInterval returns the sync interval in minutes
func (s *configService) GetSyncInterval(ctx context.Context) (int, error) {
config, err := s.getConfig(ctx)
if err != nil {
return 0, err
}
return config.SyncIntervalMinutes, nil
}
// SetSyncInterval updates the sync interval configuration
func (s *configService) SetSyncInterval(ctx context.Context, minutes int) error {
config, err := s.getConfig(ctx)
if err != nil {
return err
}
config.SyncIntervalMinutes = minutes
return s.saveConfig(ctx, config)
}
// GetShowHiddenFiles returns whether hidden files should be shown
func (s *configService) GetShowHiddenFiles(ctx context.Context) (bool, error) {
config, err := s.getConfig(ctx)
if err != nil {
return false, err
}
return config.ShowHiddenFiles, nil
}
// SetShowHiddenFiles updates the show hidden files setting
func (s *configService) SetShowHiddenFiles(ctx context.Context, show bool) error {
config, err := s.getConfig(ctx)
if err != nil {
return err
}
config.ShowHiddenFiles = show
return s.saveConfig(ctx, config)
}
// GetDefaultView returns the configured default view
func (s *configService) GetDefaultView(ctx context.Context) (string, error) {
config, err := s.getConfig(ctx)
if err != nil {
return "", err
}
return config.DefaultView, nil
}
// SetDefaultView updates the default view configuration
func (s *configService) SetDefaultView(ctx context.Context, view string) error {
config, err := s.getConfig(ctx)
if err != nil {
return err
}
config.DefaultView = view
return s.saveConfig(ctx, config)
}
// GetSortPreferences returns the configured sort preferences
func (s *configService) GetSortPreferences(ctx context.Context) (sortBy string, sortOrder string, err error) {
config, err := s.getConfig(ctx)
if err != nil {
return "", "", err
}
return config.SortBy, config.SortOrder, nil
}
// SetSortPreferences updates the sort preferences
func (s *configService) SetSortPreferences(ctx context.Context, sortBy string, sortOrder string) error {
config, err := s.getConfig(ctx)
if err != nil {
return err
}
config.SortBy = sortBy
config.SortOrder = sortOrder
return s.saveConfig(ctx, config)
}
// Ensure our implementation satisfies the interface
var _ ConfigService = (*configService)(nil)

View file

@ -0,0 +1,175 @@
// internal/config/userdata.go
package config
import (
"crypto/sha256"
"encoding/hex"
"os"
"path/filepath"
"runtime"
"strings"
)
// GetUserDataDir returns the appropriate directory for storing application data
// following platform-specific conventions:
// - Windows: %LOCALAPPDATA%\{appName}
// - macOS: ~/Library/Application Support/{appName}
// - Linux: ~/.local/share/{appName} (or $XDG_DATA_HOME/{appName})
func GetUserDataDir(appName string) (string, error) {
var baseDir string
var err error
switch runtime.GOOS {
case "windows":
// Use LOCALAPPDATA for application data on Windows
baseDir = os.Getenv("LOCALAPPDATA")
if baseDir == "" {
// Fallback to APPDATA if LOCALAPPDATA is not set
baseDir = os.Getenv("APPDATA")
if baseDir == "" {
// Last resort: use UserConfigDir
baseDir, err = os.UserConfigDir()
if err != nil {
return "", err
}
}
}
case "darwin":
// Use ~/Library/Application Support on macOS
home, err := os.UserHomeDir()
if err != nil {
return "", err
}
baseDir = filepath.Join(home, "Library", "Application Support")
default:
// Linux and other Unix-like systems
// Follow XDG Base Directory Specification
if xdgData := os.Getenv("XDG_DATA_HOME"); xdgData != "" {
baseDir = xdgData
} else {
home, err := os.UserHomeDir()
if err != nil {
return "", err
}
baseDir = filepath.Join(home, ".local", "share")
}
}
// Combine with app name
appDataDir := filepath.Join(baseDir, appName)
// Create the directory if it doesn't exist with restrictive permissions
if err := os.MkdirAll(appDataDir, 0700); err != nil {
return "", err
}
return appDataDir, nil
}
// GetUserSpecificDataDir returns the data directory for a specific user.
// User data is isolated by hashing the email to create a unique directory name.
// This ensures:
// 1. Different users have completely separate storage
// 2. Email addresses are not exposed in directory names
// 3. The same user always gets the same directory
//
// Directory structure:
//
// {appDataDir}/users/{emailHash}/
// ├── local_files/ # File and collection metadata (LevelDB)
// ├── sync_state/ # Sync state (LevelDB)
// ├── cache/ # Application cache (LevelDB)
// └── files/ # Downloaded decrypted files
// └── {collectionId}/
// └── {filename}
func GetUserSpecificDataDir(appName, userEmail string) (string, error) {
if userEmail == "" {
return "", nil // No user logged in, return empty
}
appDataDir, err := GetUserDataDir(appName)
if err != nil {
return "", err
}
// Hash the email to create a privacy-preserving directory name
emailHash := hashEmail(userEmail)
// Create user-specific directory
userDir := filepath.Join(appDataDir, "users", emailHash)
// Create the directory with restrictive permissions (owner only)
if err := os.MkdirAll(userDir, 0700); err != nil {
return "", err
}
return userDir, nil
}
// GetUserFilesDir returns the directory where decrypted files are stored for a user.
// Files are organized by collection: {userDir}/files/{collectionId}/{filename}
func GetUserFilesDir(appName, userEmail string) (string, error) {
userDir, err := GetUserSpecificDataDir(appName, userEmail)
if err != nil {
return "", err
}
if userDir == "" {
return "", nil // No user logged in
}
filesDir := filepath.Join(userDir, "files")
// Create with restrictive permissions
if err := os.MkdirAll(filesDir, 0700); err != nil {
return "", err
}
return filesDir, nil
}
// hashEmail creates a SHA256 hash of the email address (lowercase, trimmed).
// Returns a shortened hash (first 16 characters) for more readable directory names
// while still maintaining uniqueness.
func hashEmail(email string) string {
// Normalize email: lowercase and trim whitespace
normalizedEmail := strings.ToLower(strings.TrimSpace(email))
// Create SHA256 hash
hash := sha256.Sum256([]byte(normalizedEmail))
// Return first 16 characters of hex representation (64 bits of entropy is sufficient)
return hex.EncodeToString(hash[:])[:16]
}
// GetEmailHashForPath returns the hash that would be used for a user's directory.
// This can be used to check if a user's data exists without revealing the email.
func GetEmailHashForPath(userEmail string) string {
if userEmail == "" {
return ""
}
return hashEmail(userEmail)
}
// GetUserSearchIndexDir returns the directory where the Bleve search index is stored.
// Returns: {userDir}/search/index.bleve
func GetUserSearchIndexDir(appName, userEmail string) (string, error) {
userDir, err := GetUserSpecificDataDir(appName, userEmail)
if err != nil {
return "", err
}
if userDir == "" {
return "", nil // No user logged in
}
searchIndexPath := filepath.Join(userDir, "search", "index.bleve")
// Create parent directory with restrictive permissions
searchDir := filepath.Join(userDir, "search")
if err := os.MkdirAll(searchDir, 0700); err != nil {
return "", err
}
return searchIndexPath, nil
}

View file

@ -0,0 +1,28 @@
package collection
// Repository defines the data access operations for collections
type Repository interface {
// Create stores a new collection record
Create(collection *Collection) error
// Get retrieves a collection by its ID
Get(id string) (*Collection, error)
// Update modifies an existing collection record
Update(collection *Collection) error
// Delete removes a collection record by its ID
Delete(id string) error
// List returns all collection records
List() ([]*Collection, error)
// ListByParent returns all collections with a specific parent ID
ListByParent(parentID string) ([]*Collection, error)
// ListRoot returns all root-level collections (no parent)
ListRoot() ([]*Collection, error)
// Exists checks if a collection with the given ID exists
Exists(id string) (bool, error)
}

View file

@ -0,0 +1,98 @@
package collection
import "time"
// Collection represents a collection (folder/album) stored locally with sync capabilities.
type Collection struct {
// Identifiers (from cloud)
ID string `json:"id"`
ParentID string `json:"parent_id,omitempty"`
OwnerID string `json:"owner_id"` // UserID from cloud
// Encryption data (from cloud)
EncryptedCollectionKey string `json:"encrypted_collection_key"`
Nonce string `json:"nonce"`
// Collection metadata (from cloud - name is decrypted client-side)
Name string `json:"name"` // Decrypted name
Description string `json:"description,omitempty"` // Optional description
// CustomIcon is the decrypted custom icon for this collection.
// Empty string means use default folder/album icon.
// Contains either an emoji character (e.g., "📷") or "icon:<identifier>" for predefined icons.
CustomIcon string `json:"custom_icon,omitempty"`
// Statistics (from cloud)
TotalFiles int `json:"total_files"`
TotalSizeInBytes int64 `json:"total_size_in_bytes"`
// Sharing info (from cloud)
PermissionLevel string `json:"permission_level,omitempty"` // read_only, read_write, admin
IsOwner bool `json:"is_owner"`
OwnerName string `json:"owner_name,omitempty"`
OwnerEmail string `json:"owner_email,omitempty"`
// Sync tracking (local only)
SyncStatus SyncStatus `json:"sync_status"`
LastSyncedAt time.Time `json:"last_synced_at,omitempty"`
// State from cloud
State string `json:"state"` // active, deleted
// Timestamps (from cloud)
CreatedAt time.Time `json:"created_at"`
ModifiedAt time.Time `json:"modified_at"`
}
// SyncStatus defines the synchronization status of a collection
type SyncStatus int
const (
// SyncStatusCloudOnly indicates the collection metadata is synced from cloud
SyncStatusCloudOnly SyncStatus = iota
// SyncStatusSynced indicates the collection is fully synchronized
SyncStatusSynced
)
// String returns a human-readable string representation of the sync status
func (s SyncStatus) String() string {
switch s {
case SyncStatusCloudOnly:
return "cloud_only"
case SyncStatusSynced:
return "synced"
default:
return "unknown"
}
}
// Collection state constants
const (
// StateActive indicates the collection is active
StateActive = "active"
// StateDeleted indicates the collection is deleted
StateDeleted = "deleted"
)
// Permission level constants
const (
PermissionReadOnly = "read_only"
PermissionReadWrite = "read_write"
PermissionAdmin = "admin"
)
// IsDeleted returns true if the collection is marked as deleted
func (c *Collection) IsDeleted() bool {
return c.State == StateDeleted
}
// CanWrite returns true if the user has write permissions
func (c *Collection) CanWrite() bool {
return c.IsOwner || c.PermissionLevel == PermissionReadWrite || c.PermissionLevel == PermissionAdmin
}
// CanAdmin returns true if the user has admin permissions
func (c *Collection) CanAdmin() bool {
return c.IsOwner || c.PermissionLevel == PermissionAdmin
}

View file

@ -0,0 +1,58 @@
package file
// SyncStatus defines the synchronization status of a file
type SyncStatus int
const (
// SyncStatusLocalOnly indicates the file exists only locally (not uploaded to cloud)
SyncStatusLocalOnly SyncStatus = iota
// SyncStatusCloudOnly indicates the file exists only in the cloud (metadata synced, content not downloaded)
SyncStatusCloudOnly
// SyncStatusSynced indicates the file exists both locally and in the cloud and is synchronized
SyncStatusSynced
// SyncStatusModifiedLocally indicates the file exists in both places but has local changes pending upload
SyncStatusModifiedLocally
)
// String returns a human-readable string representation of the sync status
func (s SyncStatus) String() string {
switch s {
case SyncStatusLocalOnly:
return "local_only"
case SyncStatusCloudOnly:
return "cloud_only"
case SyncStatusSynced:
return "synced"
case SyncStatusModifiedLocally:
return "modified_locally"
default:
return "unknown"
}
}
// Storage mode constants define which file versions to keep locally
const (
// StorageModeEncryptedOnly - Only keep encrypted version locally (most secure)
StorageModeEncryptedOnly = "encrypted_only"
// StorageModeDecryptedOnly - Only keep decrypted version locally (not recommended)
StorageModeDecryptedOnly = "decrypted_only"
// StorageModeHybrid - Keep both encrypted and decrypted versions (default, convenient)
StorageModeHybrid = "hybrid"
)
// File state constants
const (
// StatePending is the initial state of a file before it is uploaded
StatePending = "pending"
// StateActive indicates that the file is fully uploaded and ready for use
StateActive = "active"
// StateDeleted marks the file as deleted
StateDeleted = "deleted"
)

View file

@ -0,0 +1,28 @@
package file
// Repository defines the data access operations for files
type Repository interface {
// Create stores a new file record
Create(file *File) error
// Get retrieves a file by its ID
Get(id string) (*File, error)
// Update modifies an existing file record
Update(file *File) error
// Delete removes a file record by its ID
Delete(id string) error
// List returns all file records
List() ([]*File, error)
// ListByCollection returns all files belonging to a specific collection
ListByCollection(collectionID string) ([]*File, error)
// ListByStatus returns all files with a specific sync status
ListByStatus(status SyncStatus) ([]*File, error)
// Exists checks if a file with the given ID exists
Exists(id string) (bool, error)
}

View file

@ -0,0 +1,88 @@
package file
import "time"
// File represents a file stored locally with sync capabilities.
// This model combines cloud metadata with local storage tracking.
type File struct {
// Identifiers (from cloud)
ID string `json:"id"`
CollectionID string `json:"collection_id"`
OwnerID string `json:"owner_id"` // UserID from cloud
// Encryption data (from cloud API response)
EncryptedFileKey EncryptedFileKeyData `json:"encrypted_file_key"`
FileKeyNonce string `json:"file_key_nonce"`
EncryptedMetadata string `json:"encrypted_metadata"`
MetadataNonce string `json:"metadata_nonce"`
FileNonce string `json:"file_nonce"`
// File sizes (from cloud)
EncryptedSizeInBytes int64 `json:"encrypted_file_size_in_bytes"`
DecryptedSizeInBytes int64 `json:"decrypted_size_in_bytes,omitempty"`
// Local storage paths (local only)
EncryptedFilePath string `json:"encrypted_file_path,omitempty"`
FilePath string `json:"file_path,omitempty"`
ThumbnailPath string `json:"thumbnail_path,omitempty"`
// Decrypted metadata (local only - populated after decryption)
Name string `json:"name,omitempty"`
MimeType string `json:"mime_type,omitempty"`
Metadata *FileMetadata `json:"metadata,omitempty"`
// Sync tracking (local only)
SyncStatus SyncStatus `json:"sync_status"`
LastSyncedAt time.Time `json:"last_synced_at,omitempty"`
// State from cloud
State string `json:"state"` // pending, active, deleted
StorageMode string `json:"storage_mode"` // encrypted_only, hybrid, decrypted_only
Version int `json:"version"` // Cloud version for conflict resolution
// Timestamps (from cloud)
CreatedAt time.Time `json:"created_at"`
ModifiedAt time.Time `json:"modified_at"`
// Thumbnail URL (from cloud, for remote access)
ThumbnailURL string `json:"thumbnail_url,omitempty"`
}
// EncryptedFileKeyData matches the cloud API structure exactly
type EncryptedFileKeyData struct {
Ciphertext string `json:"ciphertext"`
Nonce string `json:"nonce"`
}
// FileMetadata represents decrypted file metadata (populated after decryption)
type FileMetadata struct {
Name string `json:"name"`
MimeType string `json:"mime_type"`
Size int64 `json:"size"`
FileExtension string `json:"file_extension"`
}
// IsCloudOnly returns true if the file only exists in the cloud
func (f *File) IsCloudOnly() bool {
return f.SyncStatus == SyncStatusCloudOnly
}
// IsSynced returns true if the file is synchronized between local and cloud
func (f *File) IsSynced() bool {
return f.SyncStatus == SyncStatusSynced
}
// IsLocalOnly returns true if the file only exists locally
func (f *File) IsLocalOnly() bool {
return f.SyncStatus == SyncStatusLocalOnly
}
// HasLocalContent returns true if the file has local content (not just metadata)
func (f *File) HasLocalContent() bool {
return f.FilePath != "" || f.EncryptedFilePath != ""
}
// IsDeleted returns true if the file is marked as deleted
func (f *File) IsDeleted() bool {
return f.State == StateDeleted
}

View file

@ -0,0 +1,16 @@
package session
// Repository interface defines data access operations for sessions
type Repository interface {
// Save stores a session
Save(session *Session) error
// Get retrieves the current session
Get() (*Session, error)
// Delete removes the current session
Delete() error
// Exists checks if a session exists
Exists() (bool, error)
}

View file

@ -0,0 +1,30 @@
package session
import "time"
// Session represents a user authentication session (domain entity)
type Session struct {
UserID string
Email string
AccessToken string
RefreshToken string
ExpiresAt time.Time
CreatedAt time.Time
// Encrypted user data for password verification (stored during login)
Salt string // Base64 encoded salt for password derivation
EncryptedMasterKey string // Base64 encoded encrypted master key
EncryptedPrivateKey string // Base64 encoded encrypted private key
PublicKey string // Base64 encoded public key
KDFAlgorithm string // Key derivation algorithm: "PBKDF2-SHA256"
}
// IsExpired checks if the session has expired
func (s *Session) IsExpired() bool {
return time.Now().After(s.ExpiresAt)
}
// IsValid checks if the session is valid (not expired and has tokens)
func (s *Session) IsValid() bool {
return !s.IsExpired() && s.AccessToken != "" && s.RefreshToken != ""
}

View file

@ -0,0 +1,13 @@
package syncstate
// Repository defines the data access operations for sync state
type Repository interface {
// Get retrieves the current sync state
Get() (*SyncState, error)
// Save persists the sync state
Save(state *SyncState) error
// Reset clears the sync state (for fresh sync)
Reset() error
}

View file

@ -0,0 +1,77 @@
package syncstate
import "time"
// SyncState tracks the synchronization progress for collections and files.
// It stores cursors from the API for incremental sync and timestamps for tracking.
type SyncState struct {
// Timestamps for tracking when sync occurred
LastCollectionSync time.Time `json:"last_collection_sync"`
LastFileSync time.Time `json:"last_file_sync"`
// Cursors from API responses (used for pagination)
CollectionCursor string `json:"collection_cursor,omitempty"`
FileCursor string `json:"file_cursor,omitempty"`
// Sync completion flags
CollectionSyncComplete bool `json:"collection_sync_complete"`
FileSyncComplete bool `json:"file_sync_complete"`
}
// NewSyncState creates a new empty SyncState
func NewSyncState() *SyncState {
return &SyncState{}
}
// IsCollectionSyncComplete returns true if all collections have been synced
func (s *SyncState) IsCollectionSyncComplete() bool {
return s.CollectionSyncComplete
}
// IsFileSyncComplete returns true if all files have been synced
func (s *SyncState) IsFileSyncComplete() bool {
return s.FileSyncComplete
}
// IsFullySynced returns true if both collections and files are fully synced
func (s *SyncState) IsFullySynced() bool {
return s.CollectionSyncComplete && s.FileSyncComplete
}
// ResetCollectionSync resets the collection sync state for a fresh sync
func (s *SyncState) ResetCollectionSync() {
s.CollectionCursor = ""
s.CollectionSyncComplete = false
s.LastCollectionSync = time.Time{}
}
// ResetFileSync resets the file sync state for a fresh sync
func (s *SyncState) ResetFileSync() {
s.FileCursor = ""
s.FileSyncComplete = false
s.LastFileSync = time.Time{}
}
// Reset resets both collection and file sync states
func (s *SyncState) Reset() {
s.ResetCollectionSync()
s.ResetFileSync()
}
// UpdateCollectionSync updates the collection sync state after a sync operation
func (s *SyncState) UpdateCollectionSync(cursor string, hasMore bool) {
s.CollectionCursor = cursor
s.CollectionSyncComplete = !hasMore
if !hasMore {
s.LastCollectionSync = time.Now()
}
}
// UpdateFileSync updates the file sync state after a sync operation
func (s *SyncState) UpdateFileSync(cursor string, hasMore bool) {
s.FileCursor = cursor
s.FileSyncComplete = !hasMore
if !hasMore {
s.LastFileSync = time.Now()
}
}

View file

@ -0,0 +1,10 @@
package user
// Repository defines the interface for user data persistence
type Repository interface {
Save(user *User) error
GetByID(id string) (*User, error)
GetByEmail(email string) (*User, error)
Delete(id string) error
Exists(id string) (bool, error)
}

View file

@ -0,0 +1,19 @@
package user
import "time"
// User represents a MapleFile user profile stored locally
type User struct {
ID string
Email string
FirstName string
LastName string
StorageQuotaBytes int64
CreatedAt time.Time
UpdatedAt time.Time
}
// IsValid checks if the user has the minimum required fields
func (u *User) IsValid() bool {
return u.ID != "" && u.Email != ""
}

View file

@ -0,0 +1,212 @@
package collection
import (
"encoding/json"
"fmt"
"strings"
"codeberg.org/mapleopentech/monorepo/native/desktop/maplefile/pkg/storage"
"codeberg.org/mapleopentech/monorepo/native/desktop/maplefile/internal/domain/collection"
)
const (
collectionKeyPrefix = "collection:"
parentCollIndex = "parent_collection_index:"
rootCollIndex = "root_collection_index:"
)
type repository struct {
storage storage.Storage
}
// ProvideRepository creates a new collection repository for Wire
func ProvideRepository(storage storage.Storage) collection.Repository {
return &repository{storage: storage}
}
func (r *repository) Create(c *collection.Collection) error {
return r.save(c)
}
func (r *repository) Get(id string) (*collection.Collection, error) {
key := collectionKeyPrefix + id
data, err := r.storage.Get(key)
if err != nil {
return nil, fmt.Errorf("failed to get collection: %w", err)
}
if data == nil {
return nil, nil
}
var c collection.Collection
if err := json.Unmarshal(data, &c); err != nil {
return nil, fmt.Errorf("failed to unmarshal collection: %w", err)
}
return &c, nil
}
func (r *repository) Update(c *collection.Collection) error {
// Get existing collection to clean up old indexes if parent changed
existing, err := r.Get(c.ID)
if err != nil {
return fmt.Errorf("failed to get existing collection: %w", err)
}
if existing != nil && existing.ParentID != c.ParentID {
// Clean up old parent index
if existing.ParentID == "" {
oldRootKey := rootCollIndex + existing.ID
_ = r.storage.Delete(oldRootKey)
} else {
oldParentKey := parentCollIndex + existing.ParentID + ":" + existing.ID
_ = r.storage.Delete(oldParentKey)
}
}
return r.save(c)
}
func (r *repository) Delete(id string) error {
// Get collection first to remove indexes
c, err := r.Get(id)
if err != nil {
return err
}
if c == nil {
return nil // Nothing to delete
}
// Delete parent index or root index
if c.ParentID == "" {
rootKey := rootCollIndex + id
if err := r.storage.Delete(rootKey); err != nil {
return fmt.Errorf("failed to delete root index: %w", err)
}
} else {
parentKey := parentCollIndex + c.ParentID + ":" + id
if err := r.storage.Delete(parentKey); err != nil {
return fmt.Errorf("failed to delete parent index: %w", err)
}
}
// Delete collection
collKey := collectionKeyPrefix + id
if err := r.storage.Delete(collKey); err != nil {
return fmt.Errorf("failed to delete collection: %w", err)
}
return nil
}
func (r *repository) List() ([]*collection.Collection, error) {
var collections []*collection.Collection
err := r.storage.Iterate(func(key, value []byte) error {
keyStr := string(key)
if strings.HasPrefix(keyStr, collectionKeyPrefix) {
var c collection.Collection
if err := json.Unmarshal(value, &c); err != nil {
return fmt.Errorf("failed to unmarshal collection: %w", err)
}
collections = append(collections, &c)
}
return nil
})
if err != nil {
return nil, fmt.Errorf("failed to list collections: %w", err)
}
return collections, nil
}
func (r *repository) ListByParent(parentID string) ([]*collection.Collection, error) {
var collections []*collection.Collection
prefix := parentCollIndex + parentID + ":"
err := r.storage.Iterate(func(key, value []byte) error {
keyStr := string(key)
if strings.HasPrefix(keyStr, prefix) {
// Extract collection ID from index key
collID := strings.TrimPrefix(keyStr, prefix)
c, err := r.Get(collID)
if err != nil {
return err
}
if c != nil {
collections = append(collections, c)
}
}
return nil
})
if err != nil {
return nil, fmt.Errorf("failed to list collections by parent: %w", err)
}
return collections, nil
}
func (r *repository) ListRoot() ([]*collection.Collection, error) {
var collections []*collection.Collection
err := r.storage.Iterate(func(key, value []byte) error {
keyStr := string(key)
if strings.HasPrefix(keyStr, rootCollIndex) {
// Extract collection ID from index key
collID := strings.TrimPrefix(keyStr, rootCollIndex)
c, err := r.Get(collID)
if err != nil {
return err
}
if c != nil {
collections = append(collections, c)
}
}
return nil
})
if err != nil {
return nil, fmt.Errorf("failed to list root collections: %w", err)
}
return collections, nil
}
func (r *repository) Exists(id string) (bool, error) {
key := collectionKeyPrefix + id
data, err := r.storage.Get(key)
if err != nil {
return false, fmt.Errorf("failed to check collection existence: %w", err)
}
return data != nil, nil
}
// save persists the collection and maintains indexes
func (r *repository) save(c *collection.Collection) error {
data, err := json.Marshal(c)
if err != nil {
return fmt.Errorf("failed to marshal collection: %w", err)
}
// Save collection by ID
collKey := collectionKeyPrefix + c.ID
if err := r.storage.Set(collKey, data); err != nil {
return fmt.Errorf("failed to save collection: %w", err)
}
// Create parent index (for ListByParent) or root index (for ListRoot)
if c.ParentID == "" {
rootKey := rootCollIndex + c.ID
if err := r.storage.Set(rootKey, []byte(c.ID)); err != nil {
return fmt.Errorf("failed to create root index: %w", err)
}
} else {
parentKey := parentCollIndex + c.ParentID + ":" + c.ID
if err := r.storage.Set(parentKey, []byte(c.ID)); err != nil {
return fmt.Errorf("failed to create parent index: %w", err)
}
}
return nil
}

View file

@ -0,0 +1,213 @@
package file
import (
"encoding/json"
"fmt"
"strings"
"codeberg.org/mapleopentech/monorepo/native/desktop/maplefile/internal/domain/file"
"codeberg.org/mapleopentech/monorepo/native/desktop/maplefile/pkg/storage"
)
const (
fileKeyPrefix = "file:"
collectionFileIndex = "collection_file_index:"
statusFileIndex = "status_file_index:"
)
type repository struct {
storage storage.Storage
}
// ProvideRepository creates a new file repository for Wire
func ProvideRepository(storage storage.Storage) file.Repository {
return &repository{storage: storage}
}
func (r *repository) Create(f *file.File) error {
return r.save(f)
}
func (r *repository) Get(id string) (*file.File, error) {
key := fileKeyPrefix + id
data, err := r.storage.Get(key)
if err != nil {
return nil, fmt.Errorf("failed to get file: %w", err)
}
if data == nil {
return nil, nil
}
var f file.File
if err := json.Unmarshal(data, &f); err != nil {
return nil, fmt.Errorf("failed to unmarshal file: %w", err)
}
return &f, nil
}
func (r *repository) Update(f *file.File) error {
// Get existing file to clean up old indexes if collection changed
existing, err := r.Get(f.ID)
if err != nil {
return fmt.Errorf("failed to get existing file: %w", err)
}
if existing != nil {
// Clean up old collection index if collection changed
if existing.CollectionID != f.CollectionID {
oldIndexKey := collectionFileIndex + existing.CollectionID + ":" + existing.ID
_ = r.storage.Delete(oldIndexKey)
}
// Clean up old status index if status changed
if existing.SyncStatus != f.SyncStatus {
oldStatusKey := statusFileIndex + existing.SyncStatus.String() + ":" + existing.ID
_ = r.storage.Delete(oldStatusKey)
}
}
return r.save(f)
}
func (r *repository) Delete(id string) error {
// Get file first to remove indexes
f, err := r.Get(id)
if err != nil {
return err
}
if f == nil {
return nil // Nothing to delete
}
// Delete collection index
collIndexKey := collectionFileIndex + f.CollectionID + ":" + id
if err := r.storage.Delete(collIndexKey); err != nil {
return fmt.Errorf("failed to delete collection index: %w", err)
}
// Delete status index
statusKey := statusFileIndex + f.SyncStatus.String() + ":" + id
if err := r.storage.Delete(statusKey); err != nil {
return fmt.Errorf("failed to delete status index: %w", err)
}
// Delete file
fileKey := fileKeyPrefix + id
if err := r.storage.Delete(fileKey); err != nil {
return fmt.Errorf("failed to delete file: %w", err)
}
return nil
}
func (r *repository) List() ([]*file.File, error) {
var files []*file.File
err := r.storage.Iterate(func(key, value []byte) error {
keyStr := string(key)
if strings.HasPrefix(keyStr, fileKeyPrefix) {
var f file.File
if err := json.Unmarshal(value, &f); err != nil {
return fmt.Errorf("failed to unmarshal file: %w", err)
}
files = append(files, &f)
}
return nil
})
if err != nil {
return nil, fmt.Errorf("failed to list files: %w", err)
}
return files, nil
}
func (r *repository) ListByCollection(collectionID string) ([]*file.File, error) {
var files []*file.File
prefix := collectionFileIndex + collectionID + ":"
err := r.storage.Iterate(func(key, value []byte) error {
keyStr := string(key)
if strings.HasPrefix(keyStr, prefix) {
// Extract file ID from index key
fileID := strings.TrimPrefix(keyStr, prefix)
f, err := r.Get(fileID)
if err != nil {
return err
}
if f != nil {
files = append(files, f)
}
}
return nil
})
if err != nil {
return nil, fmt.Errorf("failed to list files by collection: %w", err)
}
return files, nil
}
func (r *repository) ListByStatus(status file.SyncStatus) ([]*file.File, error) {
var files []*file.File
prefix := statusFileIndex + status.String() + ":"
err := r.storage.Iterate(func(key, value []byte) error {
keyStr := string(key)
if strings.HasPrefix(keyStr, prefix) {
// Extract file ID from index key
fileID := strings.TrimPrefix(keyStr, prefix)
f, err := r.Get(fileID)
if err != nil {
return err
}
if f != nil {
files = append(files, f)
}
}
return nil
})
if err != nil {
return nil, fmt.Errorf("failed to list files by status: %w", err)
}
return files, nil
}
func (r *repository) Exists(id string) (bool, error) {
key := fileKeyPrefix + id
data, err := r.storage.Get(key)
if err != nil {
return false, fmt.Errorf("failed to check file existence: %w", err)
}
return data != nil, nil
}
// save persists the file and maintains indexes
func (r *repository) save(f *file.File) error {
data, err := json.Marshal(f)
if err != nil {
return fmt.Errorf("failed to marshal file: %w", err)
}
// Save file by ID
fileKey := fileKeyPrefix + f.ID
if err := r.storage.Set(fileKey, data); err != nil {
return fmt.Errorf("failed to save file: %w", err)
}
// Create collection index (for ListByCollection)
collIndexKey := collectionFileIndex + f.CollectionID + ":" + f.ID
if err := r.storage.Set(collIndexKey, []byte(f.ID)); err != nil {
return fmt.Errorf("failed to create collection index: %w", err)
}
// Create status index (for ListByStatus)
statusKey := statusFileIndex + f.SyncStatus.String() + ":" + f.ID
if err := r.storage.Set(statusKey, []byte(f.ID)); err != nil {
return fmt.Errorf("failed to create status index: %w", err)
}
return nil
}

View file

@ -0,0 +1,55 @@
package session
import (
"encoding/json"
"codeberg.org/mapleopentech/monorepo/native/desktop/maplefile/internal/domain/session"
"codeberg.org/mapleopentech/monorepo/native/desktop/maplefile/pkg/storage"
)
const sessionKey = "current_session"
type repository struct {
storage storage.Storage
}
// ProvideRepository creates a session repository for Wire
func ProvideRepository(storage storage.Storage) session.Repository {
return &repository{storage: storage}
}
func (r *repository) Save(sess *session.Session) error {
data, err := json.Marshal(sess)
if err != nil {
return err
}
return r.storage.Set(sessionKey, data)
}
func (r *repository) Get() (*session.Session, error) {
data, err := r.storage.Get(sessionKey)
if err != nil {
return nil, err
}
if data == nil {
return nil, nil
}
var sess session.Session
if err := json.Unmarshal(data, &sess); err != nil {
return nil, err
}
return &sess, nil
}
func (r *repository) Delete() error {
return r.storage.Delete(sessionKey)
}
func (r *repository) Exists() (bool, error) {
data, err := r.storage.Get(sessionKey)
if err != nil {
return false, err
}
return data != nil, nil
}

View file

@ -0,0 +1,58 @@
package syncstate
import (
"encoding/json"
"fmt"
"codeberg.org/mapleopentech/monorepo/native/desktop/maplefile/internal/domain/syncstate"
"codeberg.org/mapleopentech/monorepo/native/desktop/maplefile/pkg/storage"
)
const syncStateKey = "sync_state"
type repository struct {
storage storage.Storage
}
// ProvideRepository creates a new syncstate repository for Wire
func ProvideRepository(storage storage.Storage) syncstate.Repository {
return &repository{storage: storage}
}
func (r *repository) Get() (*syncstate.SyncState, error) {
data, err := r.storage.Get(syncStateKey)
if err != nil {
return nil, fmt.Errorf("failed to get sync state: %w", err)
}
if data == nil {
// Return empty sync state if none exists
return syncstate.NewSyncState(), nil
}
var state syncstate.SyncState
if err := json.Unmarshal(data, &state); err != nil {
return nil, fmt.Errorf("failed to unmarshal sync state: %w", err)
}
return &state, nil
}
func (r *repository) Save(state *syncstate.SyncState) error {
data, err := json.Marshal(state)
if err != nil {
return fmt.Errorf("failed to marshal sync state: %w", err)
}
if err := r.storage.Set(syncStateKey, data); err != nil {
return fmt.Errorf("failed to save sync state: %w", err)
}
return nil
}
func (r *repository) Reset() error {
if err := r.storage.Delete(syncStateKey); err != nil {
return fmt.Errorf("failed to reset sync state: %w", err)
}
return nil
}

View file

@ -0,0 +1,105 @@
package user
import (
"encoding/json"
"fmt"
"codeberg.org/mapleopentech/monorepo/native/desktop/maplefile/pkg/storage"
"codeberg.org/mapleopentech/monorepo/native/desktop/maplefile/internal/domain/user"
)
const (
userKeyPrefix = "user:"
emailKeyIndex = "email_index:"
)
type repository struct {
storage storage.Storage
}
// ProvideRepository creates a new user repository
func ProvideRepository(storage storage.Storage) user.Repository {
return &repository{
storage: storage,
}
}
func (r *repository) Save(u *user.User) error {
data, err := json.Marshal(u)
if err != nil {
return fmt.Errorf("failed to marshal user: %w", err)
}
// Save user by ID
userKey := userKeyPrefix + u.ID
if err := r.storage.Set(userKey, data); err != nil {
return fmt.Errorf("failed to save user: %w", err)
}
// Create email index
emailKey := emailKeyIndex + u.Email
if err := r.storage.Set(emailKey, []byte(u.ID)); err != nil {
return fmt.Errorf("failed to create email index: %w", err)
}
return nil
}
func (r *repository) GetByID(id string) (*user.User, error) {
key := userKeyPrefix + id
data, err := r.storage.Get(key)
if err != nil {
return nil, fmt.Errorf("failed to get user: %w", err)
}
var u user.User
if err := json.Unmarshal(data, &u); err != nil {
return nil, fmt.Errorf("failed to unmarshal user: %w", err)
}
return &u, nil
}
func (r *repository) GetByEmail(email string) (*user.User, error) {
// Get user ID from email index
emailKey := emailKeyIndex + email
idData, err := r.storage.Get(emailKey)
if err != nil {
return nil, fmt.Errorf("user not found by email: %w", err)
}
userID := string(idData)
return r.GetByID(userID)
}
func (r *repository) Delete(id string) error {
// Get user first to remove email index
u, err := r.GetByID(id)
if err != nil {
return err
}
// Delete email index
emailKey := emailKeyIndex + u.Email
if err := r.storage.Delete(emailKey); err != nil {
return fmt.Errorf("failed to delete email index: %w", err)
}
// Delete user
userKey := userKeyPrefix + id
if err := r.storage.Delete(userKey); err != nil {
return fmt.Errorf("failed to delete user: %w", err)
}
return nil
}
func (r *repository) Exists(id string) (bool, error) {
key := userKeyPrefix + id
_, err := r.storage.Get(key)
if err != nil {
// Key doesn't exist
return false, nil
}
return true, nil
}

View file

@ -0,0 +1,281 @@
package auth
import (
"context"
"time"
"go.uber.org/zap"
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/pkg/maplefile/client"
domainSession "codeberg.org/mapleopentech/monorepo/native/desktop/maplefile/internal/domain/session"
"codeberg.org/mapleopentech/monorepo/native/desktop/maplefile/internal/usecase/session"
"codeberg.org/mapleopentech/monorepo/native/desktop/maplefile/internal/utils"
)
type Service struct {
apiClient *client.Client
createSessionUC *session.CreateUseCase
getSessionUC *session.GetByIdUseCase
deleteSessionUC *session.DeleteUseCase
saveSessionUC *session.SaveUseCase
logger *zap.Logger
}
// ProvideService creates the auth service for Wire
func ProvideService(
apiClient *client.Client,
createSessionUC *session.CreateUseCase,
getSessionUC *session.GetByIdUseCase,
deleteSessionUC *session.DeleteUseCase,
saveSessionUC *session.SaveUseCase,
logger *zap.Logger,
) *Service {
svc := &Service{
apiClient: apiClient,
createSessionUC: createSessionUC,
getSessionUC: getSessionUC,
deleteSessionUC: deleteSessionUC,
saveSessionUC: saveSessionUC,
logger: logger.Named("auth-service"),
}
// Set up token refresh callback to persist new tokens to session
apiClient.OnTokenRefresh(func(accessToken, refreshToken, accessTokenExpiryDate string) {
svc.handleTokenRefresh(accessToken, refreshToken, accessTokenExpiryDate)
})
return svc
}
// handleTokenRefresh is called when the API client automatically refreshes the access token
func (s *Service) handleTokenRefresh(accessToken, refreshToken, accessTokenExpiryDate string) {
// Get the current session
existingSession, err := s.getSessionUC.Execute()
if err != nil {
s.logger.Error("Failed to get session during token refresh callback", zap.Error(err))
return
}
if existingSession == nil {
s.logger.Warn("No session found during token refresh callback")
return
}
// Update the session with new tokens
existingSession.AccessToken = accessToken
existingSession.RefreshToken = refreshToken
// Parse the actual expiry date from the response instead of using hardcoded value
if accessTokenExpiryDate != "" {
expiryTime, parseErr := time.Parse(time.RFC3339, accessTokenExpiryDate)
if parseErr != nil {
s.logger.Warn("Failed to parse access token expiry date, using default 15m",
zap.String("expiry_date", accessTokenExpiryDate),
zap.Error(parseErr))
existingSession.ExpiresAt = time.Now().Add(15 * time.Minute)
} else {
existingSession.ExpiresAt = expiryTime
s.logger.Debug("Using actual token expiry from response",
zap.Time("expiry_time", expiryTime))
}
} else {
s.logger.Warn("No access token expiry date in refresh response, using default 15m")
existingSession.ExpiresAt = time.Now().Add(15 * time.Minute)
}
// Save updated session
if err := s.saveSessionUC.Execute(existingSession); err != nil {
s.logger.Error("Failed to save session after token refresh", zap.Error(err))
return
}
s.logger.Info("Session updated with refreshed tokens", zap.String("email", utils.MaskEmail(existingSession.Email)))
}
// RequestOTT requests a one-time token for login
func (s *Service) RequestOTT(ctx context.Context, email string) error {
_, err := s.apiClient.RequestOTT(ctx, email)
if err != nil {
s.logger.Error("Failed to request OTT", zap.Error(err))
return err
}
s.logger.Info("OTT requested successfully", zap.String("email", utils.MaskEmail(email)))
return nil
}
// VerifyOTT verifies the one-time token and returns the encrypted challenge
func (s *Service) VerifyOTT(ctx context.Context, email, ott string) (*client.VerifyOTTResponse, error) {
resp, err := s.apiClient.VerifyOTT(ctx, email, ott)
if err != nil {
s.logger.Error("OTT verification failed", zap.Error(err))
return nil, err
}
s.logger.Info("OTT verified successfully", zap.String("email", utils.MaskEmail(email)))
return resp, nil
}
// CompleteLogin completes the login process with OTT and challenge
func (s *Service) CompleteLogin(ctx context.Context, input *client.CompleteLoginInput) (*client.LoginResponse, error) {
// Complete login via API
resp, err := s.apiClient.CompleteLogin(ctx, input)
if err != nil {
s.logger.Error("Login failed", zap.Error(err))
return nil, err
}
// Parse expiration time from response
var expiresIn time.Duration
if resp.AccessTokenExpiryDate != "" {
expiryTime, parseErr := time.Parse(time.RFC3339, resp.AccessTokenExpiryDate)
if parseErr != nil {
s.logger.Warn("Failed to parse access token expiry date, using default 15m",
zap.String("expiry_date", resp.AccessTokenExpiryDate),
zap.Error(parseErr))
expiresIn = 15 * time.Minute // Default to 15 minutes (backend default)
} else {
expiresIn = time.Until(expiryTime)
s.logger.Info("Parsed access token expiry",
zap.Time("expiry_time", expiryTime),
zap.Duration("expires_in", expiresIn))
}
} else {
s.logger.Warn("No access token expiry date in response, using default 15m")
expiresIn = 15 * time.Minute // Default to 15 minutes (backend default)
}
// Use email as userID for now (can be improved later)
userID := input.Email
// Save session locally via use case
err = s.createSessionUC.Execute(
userID,
input.Email,
resp.AccessToken,
resp.RefreshToken,
expiresIn,
)
if err != nil {
s.logger.Error("Failed to save session", zap.Error(err))
return nil, err
}
s.logger.Info("User logged in successfully", zap.String("email", utils.MaskEmail(input.Email)))
return resp, nil
}
// Logout removes the local session
func (s *Service) Logout(ctx context.Context) error {
// Delete local session
err := s.deleteSessionUC.Execute()
if err != nil {
s.logger.Error("Failed to delete session", zap.Error(err))
return err
}
s.logger.Info("User logged out successfully")
return nil
}
// GetCurrentSession retrieves the current user session
func (s *Service) GetCurrentSession(ctx context.Context) (*domainSession.Session, error) {
sess, err := s.getSessionUC.Execute()
if err != nil {
s.logger.Error("Failed to get session", zap.Error(err))
return nil, err
}
return sess, nil
}
// UpdateSession updates the current session
func (s *Service) UpdateSession(ctx context.Context, sess *domainSession.Session) error {
return s.saveSessionUC.Execute(sess)
}
// IsLoggedIn checks if a user is currently logged in
func (s *Service) IsLoggedIn(ctx context.Context) (bool, error) {
sess, err := s.getSessionUC.Execute()
if err != nil {
return false, err
}
if sess == nil {
return false, nil
}
return sess.IsValid(), nil
}
// RestoreSession restores tokens to the API client from a persisted session
// This is used on app startup to resume a session from a previous run
func (s *Service) RestoreSession(ctx context.Context, sess *domainSession.Session) error {
if sess == nil {
return nil
}
// Restore tokens to API client
s.apiClient.SetTokens(sess.AccessToken, sess.RefreshToken)
s.logger.Info("Session restored to API client",
zap.String("user_id", sess.UserID),
zap.String("email", utils.MaskEmail(sess.Email)))
return nil
}
// Register creates a new user account
func (s *Service) Register(ctx context.Context, input *client.RegisterInput) error {
_, err := s.apiClient.Register(ctx, input)
if err != nil {
s.logger.Error("Registration failed", zap.Error(err))
return err
}
s.logger.Info("User registered successfully", zap.String("email", utils.MaskEmail(input.Email)))
return nil
}
// VerifyEmail verifies the email with the verification code
func (s *Service) VerifyEmail(ctx context.Context, input *client.VerifyEmailInput) error {
_, err := s.apiClient.VerifyEmailCode(ctx, input)
if err != nil {
s.logger.Error("Email verification failed", zap.Error(err))
return err
}
s.logger.Info("Email verified successfully", zap.String("email", utils.MaskEmail(input.Email)))
return nil
}
// GetAPIClient returns the API client instance
// This allows other parts of the application to make authenticated API calls
func (s *Service) GetAPIClient() *client.Client {
return s.apiClient
}
// InitiateRecovery initiates the account recovery process
func (s *Service) InitiateRecovery(ctx context.Context, email, method string) (*client.RecoveryInitiateResponse, error) {
resp, err := s.apiClient.RecoveryInitiate(ctx, email, method)
if err != nil {
s.logger.Error("Recovery initiation failed", zap.Error(err))
return nil, err
}
s.logger.Info("Recovery initiated successfully", zap.String("email", utils.MaskEmail(email)))
return resp, nil
}
// VerifyRecovery verifies the recovery challenge
func (s *Service) VerifyRecovery(ctx context.Context, input *client.RecoveryVerifyInput) (*client.RecoveryVerifyResponse, error) {
resp, err := s.apiClient.RecoveryVerify(ctx, input)
if err != nil {
s.logger.Error("Recovery verification failed", zap.Error(err))
return nil, err
}
s.logger.Info("Recovery verification successful")
return resp, nil
}
// CompleteRecovery completes the account recovery and resets credentials
func (s *Service) CompleteRecovery(ctx context.Context, input *client.RecoveryCompleteInput) (*client.RecoveryCompleteResponse, error) {
resp, err := s.apiClient.RecoveryComplete(ctx, input)
if err != nil {
s.logger.Error("Recovery completion failed", zap.Error(err))
return nil, err
}
s.logger.Info("Recovery completed successfully")
return resp, nil
}

View file

@ -0,0 +1,199 @@
package httpclient
import (
"net"
"net/http"
"time"
)
// Service provides an HTTP client with proper timeouts.
// This addresses OWASP security concern B1: using http.DefaultClient which has
// no timeouts and can be vulnerable to slowloris attacks and resource exhaustion.
//
// Note: TLS/SSL is handled by Caddy reverse proxy in production (see OWASP report
// A04-4.1 "Certificate Pinning Not Required" - BY DESIGN). This service focuses
// on adding timeouts, not TLS configuration.
//
// For large file downloads, use DoDownloadNoTimeout() which relies on the request's
// context for cancellation instead of a fixed timeout. This allows multi-gigabyte
// files to download without timeout issues while still being cancellable.
type Service struct {
// client is the configured HTTP client for API requests
client *http.Client
// downloadClient is a separate client for file downloads with longer timeouts
downloadClient *http.Client
// noTimeoutClient is for large file downloads where context controls cancellation
noTimeoutClient *http.Client
}
// Config holds configuration options for the HTTP client service
type Config struct {
// RequestTimeout is the overall timeout for API requests (default: 30s)
RequestTimeout time.Duration
// DownloadTimeout is the overall timeout for file downloads (default: 10m)
DownloadTimeout time.Duration
// ConnectTimeout is the timeout for establishing connections (default: 10s)
ConnectTimeout time.Duration
// TLSHandshakeTimeout is the timeout for TLS handshake (default: 10s)
TLSHandshakeTimeout time.Duration
// IdleConnTimeout is how long idle connections stay in the pool (default: 90s)
IdleConnTimeout time.Duration
// MaxIdleConns is the max number of idle connections (default: 100)
MaxIdleConns int
// MaxIdleConnsPerHost is the max idle connections per host (default: 10)
MaxIdleConnsPerHost int
}
// DefaultConfig returns sensible default configuration values
func DefaultConfig() Config {
return Config{
RequestTimeout: 30 * time.Second,
DownloadTimeout: 10 * time.Minute,
ConnectTimeout: 10 * time.Second,
TLSHandshakeTimeout: 10 * time.Second,
IdleConnTimeout: 90 * time.Second,
MaxIdleConns: 100,
MaxIdleConnsPerHost: 10,
}
}
// ProvideService creates a new HTTP client service with secure defaults
func ProvideService() *Service {
return NewService(DefaultConfig())
}
// NewService creates a new HTTP client service with the given configuration
func NewService(cfg Config) *Service {
// Create transport with timeouts and connection pooling
// Note: We don't set TLSClientConfig - Go's defaults are secure and
// production uses Caddy for TLS termination anyway
transport := &http.Transport{
DialContext: (&net.Dialer{
Timeout: cfg.ConnectTimeout,
KeepAlive: 30 * time.Second,
}).DialContext,
TLSHandshakeTimeout: cfg.TLSHandshakeTimeout,
IdleConnTimeout: cfg.IdleConnTimeout,
MaxIdleConns: cfg.MaxIdleConns,
MaxIdleConnsPerHost: cfg.MaxIdleConnsPerHost,
ExpectContinueTimeout: 1 * time.Second,
ForceAttemptHTTP2: true,
}
// Create the main client for API requests
client := &http.Client{
Transport: transport,
Timeout: cfg.RequestTimeout,
}
// Create a separate transport for downloads with longer timeouts
downloadTransport := &http.Transport{
DialContext: (&net.Dialer{
Timeout: cfg.ConnectTimeout,
KeepAlive: 30 * time.Second,
}).DialContext,
TLSHandshakeTimeout: cfg.TLSHandshakeTimeout,
IdleConnTimeout: cfg.IdleConnTimeout,
MaxIdleConns: cfg.MaxIdleConns,
MaxIdleConnsPerHost: cfg.MaxIdleConnsPerHost,
ExpectContinueTimeout: 1 * time.Second,
ForceAttemptHTTP2: true,
// Disable compression for downloads to avoid decompression overhead
DisableCompression: true,
}
// Create the download client with longer timeout
downloadClient := &http.Client{
Transport: downloadTransport,
Timeout: cfg.DownloadTimeout,
}
// Create a no-timeout transport for large file downloads
// This client has no overall timeout - cancellation is controlled via request context
// Connection and TLS handshake still have timeouts to prevent hanging on initial connect
noTimeoutTransport := &http.Transport{
DialContext: (&net.Dialer{
Timeout: cfg.ConnectTimeout,
KeepAlive: 30 * time.Second,
}).DialContext,
TLSHandshakeTimeout: cfg.TLSHandshakeTimeout,
IdleConnTimeout: cfg.IdleConnTimeout,
MaxIdleConns: cfg.MaxIdleConns,
MaxIdleConnsPerHost: cfg.MaxIdleConnsPerHost,
ExpectContinueTimeout: 1 * time.Second,
ForceAttemptHTTP2: true,
DisableCompression: true,
}
// No timeout - relies on context cancellation for large file downloads
noTimeoutClient := &http.Client{
Transport: noTimeoutTransport,
Timeout: 0, // No timeout
}
return &Service{
client: client,
downloadClient: downloadClient,
noTimeoutClient: noTimeoutClient,
}
}
// Client returns the HTTP client for API requests (30s timeout)
func (s *Service) Client() *http.Client {
return s.client
}
// DownloadClient returns the HTTP client for file downloads (10m timeout)
func (s *Service) DownloadClient() *http.Client {
return s.downloadClient
}
// Do executes an HTTP request using the API client
func (s *Service) Do(req *http.Request) (*http.Response, error) {
return s.client.Do(req)
}
// DoDownload executes an HTTP request using the download client (longer timeout)
func (s *Service) DoDownload(req *http.Request) (*http.Response, error) {
return s.downloadClient.Do(req)
}
// Get performs an HTTP GET request using the API client
func (s *Service) Get(url string) (*http.Response, error) {
return s.client.Get(url)
}
// GetDownload performs an HTTP GET request using the download client (longer timeout)
func (s *Service) GetDownload(url string) (*http.Response, error) {
return s.downloadClient.Get(url)
}
// DoLargeDownload executes an HTTP request for large file downloads.
// This client has NO overall timeout - cancellation must be handled via the request's context.
// Use this for multi-gigabyte files that may take hours to download.
// The connection establishment and TLS handshake still have timeouts.
//
// Example usage:
//
// ctx, cancel := context.WithCancel(context.Background())
// defer cancel() // Call cancel() to abort the download
// req, _ := http.NewRequestWithContext(ctx, "GET", url, nil)
// resp, err := httpClient.DoLargeDownload(req)
func (s *Service) DoLargeDownload(req *http.Request) (*http.Response, error) {
return s.noTimeoutClient.Do(req)
}
// GetLargeDownload performs an HTTP GET request for large file downloads.
// This client has NO overall timeout - the download can run indefinitely.
// Use this for multi-gigabyte files. To cancel, use DoLargeDownload with a context.
func (s *Service) GetLargeDownload(url string) (*http.Response, error) {
return s.noTimeoutClient.Get(url)
}

View file

@ -0,0 +1,263 @@
package inputvalidation
import (
"fmt"
"net/mail"
"regexp"
"strings"
"unicode"
"unicode/utf8"
)
// Validation limits for input fields
const (
// Email limits
MaxEmailLength = 254 // RFC 5321
// Name limits (collection names, file names, user names)
MinNameLength = 1
MaxNameLength = 255
// Display name limits
MaxDisplayNameLength = 100
// Description limits
MaxDescriptionLength = 1000
// UUID format (standard UUID v4)
UUIDLength = 36
// OTT (One-Time Token) limits
OTTLength = 8 // 8-digit code
// Password limits
MinPasswordLength = 8
MaxPasswordLength = 128
)
// uuidRegex matches standard UUID format (8-4-4-4-12)
var uuidRegex = regexp.MustCompile(`^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$`)
// ottRegex matches 8-digit OTT codes
var ottRegex = regexp.MustCompile(`^[0-9]{8}$`)
// ValidateEmail validates an email address
func ValidateEmail(email string) error {
if email == "" {
return fmt.Errorf("email is required")
}
// Check length
if len(email) > MaxEmailLength {
return fmt.Errorf("email exceeds maximum length of %d characters", MaxEmailLength)
}
// Use Go's mail package for RFC 5322 validation
_, err := mail.ParseAddress(email)
if err != nil {
return fmt.Errorf("invalid email format")
}
// Additional checks for security
if strings.ContainsAny(email, "\x00\n\r") {
return fmt.Errorf("email contains invalid characters")
}
return nil
}
// ValidateUUID validates a UUID string
func ValidateUUID(id, fieldName string) error {
if id == "" {
return fmt.Errorf("%s is required", fieldName)
}
if len(id) != UUIDLength {
return fmt.Errorf("%s must be a valid UUID", fieldName)
}
if !uuidRegex.MatchString(id) {
return fmt.Errorf("%s must be a valid UUID format", fieldName)
}
return nil
}
// ValidateName validates a name field (collection name, filename, etc.)
func ValidateName(name, fieldName string) error {
if name == "" {
return fmt.Errorf("%s is required", fieldName)
}
// Check length
if len(name) > MaxNameLength {
return fmt.Errorf("%s exceeds maximum length of %d characters", fieldName, MaxNameLength)
}
// Check for valid UTF-8
if !utf8.ValidString(name) {
return fmt.Errorf("%s contains invalid characters", fieldName)
}
// Check for control characters (except tab and newline which might be valid in descriptions)
for _, r := range name {
if r < 32 && r != '\t' && r != '\n' && r != '\r' {
return fmt.Errorf("%s contains invalid control characters", fieldName)
}
// Also check for null byte and other dangerous characters
if r == 0 {
return fmt.Errorf("%s contains null characters", fieldName)
}
}
// Check that it's not all whitespace
if strings.TrimSpace(name) == "" {
return fmt.Errorf("%s cannot be empty or whitespace only", fieldName)
}
return nil
}
// ValidateDisplayName validates a display name (first name, last name, etc.)
func ValidateDisplayName(name, fieldName string) error {
// Display names can be empty (optional fields)
if name == "" {
return nil
}
// Check length
if len(name) > MaxDisplayNameLength {
return fmt.Errorf("%s exceeds maximum length of %d characters", fieldName, MaxDisplayNameLength)
}
// Check for valid UTF-8
if !utf8.ValidString(name) {
return fmt.Errorf("%s contains invalid characters", fieldName)
}
// Check for control characters
for _, r := range name {
if r < 32 || !unicode.IsPrint(r) {
if r != ' ' { // Allow spaces
return fmt.Errorf("%s contains invalid characters", fieldName)
}
}
}
return nil
}
// ValidateDescription validates a description field
func ValidateDescription(desc string) error {
// Descriptions can be empty (optional)
if desc == "" {
return nil
}
// Check length
if len(desc) > MaxDescriptionLength {
return fmt.Errorf("description exceeds maximum length of %d characters", MaxDescriptionLength)
}
// Check for valid UTF-8
if !utf8.ValidString(desc) {
return fmt.Errorf("description contains invalid characters")
}
// Check for null bytes
if strings.ContainsRune(desc, 0) {
return fmt.Errorf("description contains null characters")
}
return nil
}
// ValidateOTT validates a one-time token (8-digit code)
func ValidateOTT(ott string) error {
if ott == "" {
return fmt.Errorf("verification code is required")
}
// Trim whitespace (users might copy-paste with spaces)
ott = strings.TrimSpace(ott)
if !ottRegex.MatchString(ott) {
return fmt.Errorf("verification code must be an 8-digit number")
}
return nil
}
// ValidatePassword validates a password
func ValidatePassword(password string) error {
if password == "" {
return fmt.Errorf("password is required")
}
if len(password) < MinPasswordLength {
return fmt.Errorf("password must be at least %d characters", MinPasswordLength)
}
if len(password) > MaxPasswordLength {
return fmt.Errorf("password exceeds maximum length of %d characters", MaxPasswordLength)
}
// Check for null bytes (could indicate injection attempt)
if strings.ContainsRune(password, 0) {
return fmt.Errorf("password contains invalid characters")
}
return nil
}
// ValidateCollectionID is a convenience function for collection ID validation
func ValidateCollectionID(id string) error {
return ValidateUUID(id, "collection ID")
}
// ValidateFileID is a convenience function for file ID validation
func ValidateFileID(id string) error {
return ValidateUUID(id, "file ID")
}
// ValidateTagID is a convenience function for tag ID validation
func ValidateTagID(id string) error {
return ValidateUUID(id, "tag ID")
}
// ValidateCollectionName validates a collection name
func ValidateCollectionName(name string) error {
return ValidateName(name, "collection name")
}
// ValidateFileName validates a file name
func ValidateFileName(name string) error {
if err := ValidateName(name, "filename"); err != nil {
return err
}
// Additional file-specific validations
// Check for path traversal attempts
if strings.Contains(name, "..") {
return fmt.Errorf("filename cannot contain path traversal sequences")
}
// Check for path separators
if strings.ContainsAny(name, "/\\") {
return fmt.Errorf("filename cannot contain path separators")
}
return nil
}
// SanitizeString removes or replaces potentially dangerous characters
// This is a defense-in-depth measure - validation should be done first
func SanitizeString(s string) string {
// Remove null bytes
s = strings.ReplaceAll(s, "\x00", "")
// Trim excessive whitespace
s = strings.TrimSpace(s)
return s
}

Some files were not shown because too many files have changed in this diff Show more