Initial commit: Open sourcing all of the Maple Open Technologies code.
This commit is contained in:
commit
755d54a99d
2010 changed files with 448675 additions and 0 deletions
113
native/desktop/maplefile/.claudeignore
Normal file
113
native/desktop/maplefile/.claudeignore
Normal 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
68
native/desktop/maplefile/.gitignore
vendored
Normal 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
|
||||
19
native/desktop/maplefile/README.md
Normal file
19
native/desktop/maplefile/README.md
Normal 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`.
|
||||
201
native/desktop/maplefile/Taskfile.yml
Normal file
201
native/desktop/maplefile/Taskfile.yml
Normal 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
|
||||
234
native/desktop/maplefile/docs/CODE_SIGNING.md
Normal file
234
native/desktop/maplefile/docs/CODE_SIGNING.md
Normal 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)
|
||||
|
|
@ -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** |
|
||||
13
native/desktop/maplefile/frontend/index.html
Normal file
13
native/desktop/maplefile/frontend/index.html
Normal 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>
|
||||
|
||||
1466
native/desktop/maplefile/frontend/package-lock.json
generated
Normal file
1466
native/desktop/maplefile/frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
22
native/desktop/maplefile/frontend/package.json
Normal file
22
native/desktop/maplefile/frontend/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
24
native/desktop/maplefile/frontend/src/App.css
Normal file
24
native/desktop/maplefile/frontend/src/App.css
Normal 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;
|
||||
}
|
||||
274
native/desktop/maplefile/frontend/src/App.jsx
Normal file
274
native/desktop/maplefile/frontend/src/App.jsx
Normal 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;
|
||||
93
native/desktop/maplefile/frontend/src/assets/fonts/OFL.txt
Normal file
93
native/desktop/maplefile/frontend/src/assets/fonts/OFL.txt
Normal 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.
Binary file not shown.
|
After Width: | Height: | Size: 136 KiB |
187
native/desktop/maplefile/frontend/src/components/IconPicker.css
Normal file
187
native/desktop/maplefile/frontend/src/components/IconPicker.css
Normal 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;
|
||||
}
|
||||
154
native/desktop/maplefile/frontend/src/components/IconPicker.jsx
Normal file
154
native/desktop/maplefile/frontend/src/components/IconPicker.jsx
Normal 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;
|
||||
|
|
@ -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;
|
||||
}
|
||||
264
native/desktop/maplefile/frontend/src/components/Navigation.jsx
Normal file
264
native/desktop/maplefile/frontend/src/components/Navigation.jsx
Normal 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;
|
||||
106
native/desktop/maplefile/frontend/src/components/Page.css
Normal file
106
native/desktop/maplefile/frontend/src/components/Page.css
Normal 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;
|
||||
}
|
||||
24
native/desktop/maplefile/frontend/src/components/Page.jsx
Normal file
24
native/desktop/maplefile/frontend/src/components/Page.jsx
Normal 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;
|
||||
|
|
@ -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;
|
||||
15
native/desktop/maplefile/frontend/src/main.jsx
Normal file
15
native/desktop/maplefile/frontend/src/main.jsx
Normal 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>,
|
||||
);
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -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" }}
|
||||
>
|
||||
← 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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
1378
native/desktop/maplefile/frontend/src/pages/User/Me/MeDetail.jsx
Normal file
1378
native/desktop/maplefile/frontend/src/pages/User/Me/MeDetail.jsx
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
26
native/desktop/maplefile/frontend/src/style.css
Normal file
26
native/desktop/maplefile/frontend/src/style.css
Normal 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;
|
||||
}
|
||||
7
native/desktop/maplefile/frontend/vite.config.js
Normal file
7
native/desktop/maplefile/frontend/vite.config.js
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import {defineConfig} from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()]
|
||||
})
|
||||
73
native/desktop/maplefile/go.mod
Normal file
73
native/desktop/maplefile/go.mod
Normal 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
|
||||
)
|
||||
234
native/desktop/maplefile/go.sum
Normal file
234
native/desktop/maplefile/go.sum
Normal 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=
|
||||
977
native/desktop/maplefile/internal/app/app_auth.go
Normal file
977
native/desktop/maplefile/internal/app/app_auth.go
Normal 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
|
||||
}
|
||||
1256
native/desktop/maplefile/internal/app/app_collections.go
Normal file
1256
native/desktop/maplefile/internal/app/app_collections.go
Normal file
File diff suppressed because it is too large
Load diff
444
native/desktop/maplefile/internal/app/app_dashboard.go
Normal file
444
native/desktop/maplefile/internal/app/app_dashboard.go
Normal 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] + "..."
|
||||
}
|
||||
451
native/desktop/maplefile/internal/app/app_export.go
Normal file
451
native/desktop/maplefile/internal/app/app_export.go
Normal 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()
|
||||
}
|
||||
204
native/desktop/maplefile/internal/app/app_export_data.go
Normal file
204
native/desktop/maplefile/internal/app/app_export_data.go
Normal 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
|
||||
}
|
||||
346
native/desktop/maplefile/internal/app/app_export_files.go
Normal file
346
native/desktop/maplefile/internal/app/app_export_files.go
Normal 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
|
||||
}
|
||||
610
native/desktop/maplefile/internal/app/app_files.go
Normal file
610
native/desktop/maplefile/internal/app/app_files.go
Normal 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
|
||||
}
|
||||
191
native/desktop/maplefile/internal/app/app_files_cleanup.go
Normal file
191
native/desktop/maplefile/internal/app/app_files_cleanup.go
Normal 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")
|
||||
}
|
||||
}
|
||||
880
native/desktop/maplefile/internal/app/app_files_download.go
Normal file
880
native/desktop/maplefile/internal/app/app_files_download.go
Normal 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
|
||||
}
|
||||
401
native/desktop/maplefile/internal/app/app_files_upload.go
Normal file
401
native/desktop/maplefile/internal/app/app_files_upload.go
Normal file
|
|
@ -0,0 +1,401 @@
|
|||
package app
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/wailsapp/wails/v2/pkg/runtime"
|
||||
"go.uber.org/zap"
|
||||
|
||||
"codeberg.org/mapleopentech/monorepo/cloud/maplefile-backend/pkg/maplefile/e2ee"
|
||||
"codeberg.org/mapleopentech/monorepo/native/desktop/maplefile/internal/domain/file"
|
||||
)
|
||||
|
||||
// =============================================================================
|
||||
// FILE UPLOAD OPERATIONS
|
||||
// =============================================================================
|
||||
|
||||
// SelectFile opens a native file dialog and returns the selected file path
|
||||
func (a *Application) SelectFile() (string, error) {
|
||||
selection, err := runtime.OpenFileDialog(a.ctx, runtime.OpenDialogOptions{
|
||||
Title: "Select File to Upload",
|
||||
Filters: []runtime.FileFilter{
|
||||
{DisplayName: "All Files", Pattern: "*.*"},
|
||||
{DisplayName: "Images", Pattern: "*.jpg;*.jpeg;*.png;*.gif;*.webp;*.bmp"},
|
||||
{DisplayName: "Documents", Pattern: "*.pdf;*.doc;*.docx;*.txt;*.md"},
|
||||
{DisplayName: "Videos", Pattern: "*.mp4;*.mov;*.avi;*.mkv;*.webm"},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
a.logger.Error("Failed to open file dialog", zap.Error(err))
|
||||
return "", fmt.Errorf("failed to open file dialog: %w", err)
|
||||
}
|
||||
return selection, nil
|
||||
}
|
||||
|
||||
// FileUploadInput represents the input for uploading a file
|
||||
type FileUploadInput struct {
|
||||
FilePath string `json:"file_path"`
|
||||
CollectionID string `json:"collection_id"`
|
||||
TagIDs []string `json:"tag_ids,omitempty"` // Tag IDs to assign to this file
|
||||
}
|
||||
|
||||
// FileUploadResult represents the result of a file upload
|
||||
type FileUploadResult struct {
|
||||
FileID string `json:"file_id"`
|
||||
Filename string `json:"filename"`
|
||||
Size int64 `json:"size"`
|
||||
Success bool `json:"success"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
// UploadFile encrypts and uploads a file to a collection
|
||||
func (a *Application) UploadFile(input FileUploadInput) (*FileUploadResult, error) {
|
||||
a.logger.Info("Starting file upload",
|
||||
zap.String("file_path", input.FilePath),
|
||||
zap.String("collection_id", input.CollectionID))
|
||||
|
||||
// Get current session for authentication
|
||||
session, err := a.authService.GetCurrentSession(a.ctx)
|
||||
if err != nil {
|
||||
a.logger.Error("Failed to get current session", zap.Error(err))
|
||||
return nil, fmt.Errorf("not authenticated: %w", err)
|
||||
}
|
||||
|
||||
// Get master key from key cache
|
||||
masterKey, cleanup, err := a.keyCache.GetMasterKey(session.Email)
|
||||
if err != nil {
|
||||
a.logger.Error("Failed to get master key from cache", zap.Error(err))
|
||||
return nil, fmt.Errorf("master key not available - please log in again: %w", err)
|
||||
}
|
||||
defer cleanup()
|
||||
|
||||
apiClient := a.authService.GetAPIClient()
|
||||
|
||||
// Step 1: Read the file from disk
|
||||
fileContent, err := os.ReadFile(input.FilePath)
|
||||
if err != nil {
|
||||
a.logger.Error("Failed to read file", zap.Error(err))
|
||||
return nil, fmt.Errorf("failed to read file: %w", err)
|
||||
}
|
||||
|
||||
filename := filepath.Base(input.FilePath)
|
||||
fileSize := int64(len(fileContent))
|
||||
mimeType := http.DetectContentType(fileContent)
|
||||
|
||||
a.logger.Info("File read successfully",
|
||||
zap.String("filename", filename),
|
||||
zap.Int64("size", fileSize),
|
||||
zap.String("mime_type", mimeType))
|
||||
|
||||
// Step 2: Get collection key (need to fetch collection first)
|
||||
a.logger.Info("Step 2: Fetching collection for upload",
|
||||
zap.String("collection_id", input.CollectionID),
|
||||
zap.String("api_url", apiClient.GetBaseURL()+"/api/v1/collections/"+input.CollectionID))
|
||||
|
||||
collectionReq, err := http.NewRequestWithContext(a.ctx, "GET",
|
||||
apiClient.GetBaseURL()+"/api/v1/collections/"+input.CollectionID, nil)
|
||||
if err != nil {
|
||||
a.logger.Error("Failed to create collection request", zap.Error(err))
|
||||
return nil, fmt.Errorf("failed to create collection request: %w", err)
|
||||
}
|
||||
collectionReq.Header.Set("Authorization", "Bearer "+session.AccessToken)
|
||||
|
||||
collectionResp, err := a.httpClient.Do(collectionReq)
|
||||
if err != nil {
|
||||
a.logger.Error("Failed to fetch collection", zap.Error(err))
|
||||
return nil, fmt.Errorf("failed to fetch collection: %w", err)
|
||||
}
|
||||
defer collectionResp.Body.Close()
|
||||
|
||||
a.logger.Info("Step 2a: Collection fetch response", zap.Int("status", collectionResp.StatusCode))
|
||||
|
||||
if collectionResp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(collectionResp.Body)
|
||||
a.logger.Error("Failed to fetch collection - bad status",
|
||||
zap.Int("status", collectionResp.StatusCode),
|
||||
zap.String("body", string(body)))
|
||||
return nil, fmt.Errorf("failed to fetch collection: %s", string(body))
|
||||
}
|
||||
|
||||
var collectionData struct {
|
||||
EncryptedCollectionKey struct {
|
||||
Ciphertext string `json:"ciphertext"`
|
||||
Nonce string `json:"nonce"`
|
||||
} `json:"encrypted_collection_key"`
|
||||
}
|
||||
if err := json.NewDecoder(collectionResp.Body).Decode(&collectionData); err != nil {
|
||||
a.logger.Error("Failed to decode collection response", zap.Error(err))
|
||||
return nil, fmt.Errorf("failed to decode collection: %w", err)
|
||||
}
|
||||
|
||||
a.logger.Info("Step 2b: Collection data decoded",
|
||||
zap.Int("ciphertext_len", len(collectionData.EncryptedCollectionKey.Ciphertext)),
|
||||
zap.Int("nonce_len", len(collectionData.EncryptedCollectionKey.Nonce)))
|
||||
|
||||
// Decrypt collection key
|
||||
collectionKeyCiphertext, err := base64.StdEncoding.DecodeString(collectionData.EncryptedCollectionKey.Ciphertext)
|
||||
if err != nil {
|
||||
a.logger.Error("Failed to decode collection key ciphertext", zap.Error(err))
|
||||
return nil, fmt.Errorf("failed to decode collection key ciphertext: %w", err)
|
||||
}
|
||||
collectionKeyNonce, err := base64.StdEncoding.DecodeString(collectionData.EncryptedCollectionKey.Nonce)
|
||||
if err != nil {
|
||||
a.logger.Error("Failed to decode collection key nonce", zap.Error(err))
|
||||
return nil, fmt.Errorf("failed to decode collection key nonce: %w", err)
|
||||
}
|
||||
|
||||
// Handle web frontend combined ciphertext format (nonce + encrypted_data)
|
||||
actualCollectionKeyCiphertext := extractActualCiphertext(collectionKeyCiphertext, collectionKeyNonce)
|
||||
|
||||
a.logger.Info("Step 2c: Decrypting collection key",
|
||||
zap.Int("ciphertext_bytes", len(actualCollectionKeyCiphertext)),
|
||||
zap.Int("nonce_bytes", len(collectionKeyNonce)),
|
||||
zap.Int("master_key_bytes", len(masterKey)))
|
||||
|
||||
collectionKey, err := e2ee.DecryptCollectionKey(&e2ee.EncryptedKey{
|
||||
Ciphertext: actualCollectionKeyCiphertext,
|
||||
Nonce: collectionKeyNonce,
|
||||
}, masterKey)
|
||||
if err != nil {
|
||||
a.logger.Error("Failed to decrypt collection key", zap.Error(err))
|
||||
return nil, fmt.Errorf("failed to decrypt collection key: %w", err)
|
||||
}
|
||||
|
||||
a.logger.Info("Collection key decrypted successfully", zap.Int("key_length", len(collectionKey)))
|
||||
|
||||
// Step 3: Generate a new file key
|
||||
fileKey, err := e2ee.GenerateFileKey()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to generate file key: %w", err)
|
||||
}
|
||||
|
||||
// Step 4: Encrypt file content using SecretBox (XSalsa20-Poly1305) for web frontend compatibility
|
||||
encryptedContent, err := e2ee.EncryptFileSecretBox(fileContent, fileKey)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to encrypt file: %w", err)
|
||||
}
|
||||
|
||||
// Step 5: Encrypt metadata using SecretBox (XSalsa20-Poly1305) for web frontend compatibility
|
||||
metadata := &e2ee.FileMetadata{
|
||||
Name: filename,
|
||||
MimeType: mimeType,
|
||||
Size: fileSize,
|
||||
}
|
||||
encryptedMetadata, err := e2ee.EncryptMetadataSecretBox(metadata, fileKey)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to encrypt metadata: %w", err)
|
||||
}
|
||||
|
||||
// Step 6: Encrypt file key with collection key using SecretBox for web frontend compatibility
|
||||
encryptedFileKey, err := e2ee.EncryptFileKeySecretBox(fileKey, collectionKey)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to encrypt file key: %w", err)
|
||||
}
|
||||
|
||||
// Step 7: Compute encrypted hash
|
||||
hash := sha256.Sum256(encryptedContent)
|
||||
encryptedHash := base64.StdEncoding.EncodeToString(hash[:])
|
||||
|
||||
// Step 8: Generate client-side file ID
|
||||
fileID := uuid.New().String()
|
||||
|
||||
// Step 9: Create pending file request
|
||||
// NOTE: The web frontend sends ciphertext and nonce as SEPARATE fields (not combined).
|
||||
// The ciphertext field contains only the encrypted data (from crypto_secretbox_easy),
|
||||
// and the nonce field contains the nonce separately.
|
||||
pendingFileReq := map[string]interface{}{
|
||||
"id": fileID,
|
||||
"collection_id": input.CollectionID,
|
||||
"encrypted_metadata": encryptedMetadata,
|
||||
"encrypted_file_key": map[string]string{
|
||||
"ciphertext": base64.StdEncoding.EncodeToString(encryptedFileKey.Ciphertext),
|
||||
"nonce": base64.StdEncoding.EncodeToString(encryptedFileKey.Nonce),
|
||||
},
|
||||
"encryption_version": "xsalsa20-poly1305-v1",
|
||||
"encrypted_hash": encryptedHash,
|
||||
"expected_file_size_in_bytes": int64(len(encryptedContent)),
|
||||
"content_type": mimeType,
|
||||
}
|
||||
|
||||
// Add tag IDs if provided
|
||||
if len(input.TagIDs) > 0 {
|
||||
pendingFileReq["tag_ids"] = input.TagIDs
|
||||
a.logger.Info("Adding tags to file upload",
|
||||
zap.Int("tag_count", len(input.TagIDs)))
|
||||
}
|
||||
|
||||
pendingBody, err := json.Marshal(pendingFileReq)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to marshal pending file request: %w", err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(a.ctx, "POST",
|
||||
apiClient.GetBaseURL()+"/api/v1/files/pending",
|
||||
bytes.NewReader(pendingBody))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create pending file request: %w", err)
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+session.AccessToken)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := a.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create pending file: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
a.logger.Error("Failed to create pending file",
|
||||
zap.Int("status", resp.StatusCode),
|
||||
zap.String("body", string(body)))
|
||||
return nil, fmt.Errorf("failed to create pending file: %s", string(body))
|
||||
}
|
||||
|
||||
var pendingResp struct {
|
||||
File struct {
|
||||
ID string `json:"id"`
|
||||
} `json:"file"`
|
||||
PresignedUploadURL string `json:"presigned_upload_url"`
|
||||
UploadURLExpirationTime string `json:"upload_url_expiration_time"`
|
||||
Success bool `json:"success"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&pendingResp); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode pending file response: %w", err)
|
||||
}
|
||||
|
||||
if !pendingResp.Success {
|
||||
return nil, fmt.Errorf("failed to create pending file: %s", pendingResp.Message)
|
||||
}
|
||||
|
||||
a.logger.Info("Pending file created, uploading to S3",
|
||||
zap.String("file_id", pendingResp.File.ID),
|
||||
zap.String("presigned_url", pendingResp.PresignedUploadURL[:50]+"..."))
|
||||
|
||||
// Step 10: Upload encrypted content to S3
|
||||
uploadReq, err := http.NewRequestWithContext(a.ctx, "PUT",
|
||||
pendingResp.PresignedUploadURL,
|
||||
bytes.NewReader(encryptedContent))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create upload request: %w", err)
|
||||
}
|
||||
uploadReq.Header.Set("Content-Type", "application/octet-stream")
|
||||
uploadReq.ContentLength = int64(len(encryptedContent))
|
||||
|
||||
uploadResp, err := a.httpClient.DoLargeDownload(uploadReq)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to upload to S3: %w", err)
|
||||
}
|
||||
defer uploadResp.Body.Close()
|
||||
|
||||
if uploadResp.StatusCode != http.StatusOK && uploadResp.StatusCode != http.StatusCreated {
|
||||
body, _ := io.ReadAll(uploadResp.Body)
|
||||
a.logger.Error("Failed to upload to S3",
|
||||
zap.Int("status", uploadResp.StatusCode),
|
||||
zap.String("body", string(body)))
|
||||
return nil, fmt.Errorf("failed to upload to S3: status %d", uploadResp.StatusCode)
|
||||
}
|
||||
|
||||
a.logger.Info("File uploaded to S3, completing upload")
|
||||
|
||||
// Step 11: Complete the upload
|
||||
completeReq := map[string]interface{}{
|
||||
"actual_file_size_in_bytes": int64(len(encryptedContent)),
|
||||
"upload_confirmed": true,
|
||||
}
|
||||
|
||||
completeBody, err := json.Marshal(completeReq)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to marshal complete request: %w", err)
|
||||
}
|
||||
|
||||
completeHTTPReq, err := http.NewRequestWithContext(a.ctx, "POST",
|
||||
apiClient.GetBaseURL()+"/api/v1/file/"+pendingResp.File.ID+"/complete",
|
||||
bytes.NewReader(completeBody))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create complete request: %w", err)
|
||||
}
|
||||
completeHTTPReq.Header.Set("Authorization", "Bearer "+session.AccessToken)
|
||||
completeHTTPReq.Header.Set("Content-Type", "application/json")
|
||||
|
||||
completeResp, err := a.httpClient.Do(completeHTTPReq)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to complete upload: %w", err)
|
||||
}
|
||||
defer completeResp.Body.Close()
|
||||
|
||||
if completeResp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(completeResp.Body)
|
||||
a.logger.Error("Failed to complete upload",
|
||||
zap.Int("status", completeResp.StatusCode),
|
||||
zap.String("body", string(body)))
|
||||
return nil, fmt.Errorf("failed to complete upload: %s", string(body))
|
||||
}
|
||||
|
||||
var completeRespData struct {
|
||||
Success bool `json:"success"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
if err := json.NewDecoder(completeResp.Body).Decode(&completeRespData); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode complete response: %w", err)
|
||||
}
|
||||
|
||||
// Save file metadata to local repository so it appears in dashboard and file list
|
||||
localFile := &file.File{
|
||||
ID: pendingResp.File.ID,
|
||||
CollectionID: input.CollectionID,
|
||||
OwnerID: session.UserID,
|
||||
Name: filename,
|
||||
MimeType: mimeType,
|
||||
DecryptedSizeInBytes: fileSize,
|
||||
EncryptedSizeInBytes: int64(len(encryptedContent)),
|
||||
FilePath: input.FilePath, // Original file path
|
||||
SyncStatus: file.SyncStatusSynced,
|
||||
State: file.StateActive,
|
||||
CreatedAt: time.Now(),
|
||||
ModifiedAt: time.Now(),
|
||||
LastSyncedAt: time.Now(),
|
||||
}
|
||||
|
||||
if err := a.mustGetFileRepo().Create(localFile); err != nil {
|
||||
// Log but don't fail - the upload succeeded, just local tracking failed
|
||||
a.logger.Warn("Failed to save file to local repository",
|
||||
zap.String("file_id", pendingResp.File.ID),
|
||||
zap.Error(err))
|
||||
} else {
|
||||
a.logger.Info("File saved to local repository",
|
||||
zap.String("file_id", pendingResp.File.ID),
|
||||
zap.String("filename", filename))
|
||||
|
||||
// Index the file in the search index
|
||||
if err := a.indexFileForSearch(pendingResp.File.ID, input.CollectionID, filename, input.TagIDs, fileSize); err != nil {
|
||||
a.logger.Warn("Failed to index file in search",
|
||||
zap.String("file_id", pendingResp.File.ID),
|
||||
zap.Error(err))
|
||||
}
|
||||
}
|
||||
|
||||
a.logger.Info("File upload completed successfully",
|
||||
zap.String("file_id", pendingResp.File.ID),
|
||||
zap.String("filename", filename),
|
||||
zap.Int64("size", fileSize))
|
||||
|
||||
return &FileUploadResult{
|
||||
FileID: pendingResp.File.ID,
|
||||
Filename: filename,
|
||||
Size: fileSize,
|
||||
Success: true,
|
||||
Message: "File uploaded successfully",
|
||||
}, nil
|
||||
}
|
||||
225
native/desktop/maplefile/internal/app/app_password.go
Normal file
225
native/desktop/maplefile/internal/app/app_password.go
Normal 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)
|
||||
}
|
||||
324
native/desktop/maplefile/internal/app/app_search.go
Normal file
324
native/desktop/maplefile/internal/app/app_search.go
Normal 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
|
||||
}
|
||||
38
native/desktop/maplefile/internal/app/app_settings.go
Normal file
38
native/desktop/maplefile/internal/app/app_settings.go
Normal 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)
|
||||
}
|
||||
148
native/desktop/maplefile/internal/app/app_sync.go
Normal file
148
native/desktop/maplefile/internal/app/app_sync.go
Normal 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)
|
||||
}
|
||||
861
native/desktop/maplefile/internal/app/app_tags.go
Normal file
861
native/desktop/maplefile/internal/app/app_tags.go
Normal 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
|
||||
}
|
||||
253
native/desktop/maplefile/internal/app/app_user.go
Normal file
253
native/desktop/maplefile/internal/app/app_user.go
Normal 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
|
||||
}
|
||||
294
native/desktop/maplefile/internal/app/application.go
Normal file
294
native/desktop/maplefile/internal/app/application.go
Normal 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()
|
||||
}
|
||||
227
native/desktop/maplefile/internal/app/wire.go
Normal file
227
native/desktop/maplefile/internal/app/wire.go
Normal 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
|
||||
}
|
||||
197
native/desktop/maplefile/internal/app/wire_gen.go
Normal file
197
native/desktop/maplefile/internal/app/wire_gen.go
Normal 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
|
||||
}
|
||||
270
native/desktop/maplefile/internal/config/config.go
Normal file
270
native/desktop/maplefile/internal/config/config.go
Normal 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",
|
||||
}
|
||||
}
|
||||
253
native/desktop/maplefile/internal/config/integrity.go
Normal file
253
native/desktop/maplefile/internal/config/integrity.go
Normal 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)
|
||||
}
|
||||
162
native/desktop/maplefile/internal/config/leveldb.go
Normal file
162
native/desktop/maplefile/internal/config/leveldb.go
Normal 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
|
||||
}
|
||||
398
native/desktop/maplefile/internal/config/methods.go
Normal file
398
native/desktop/maplefile/internal/config/methods.go
Normal 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)
|
||||
175
native/desktop/maplefile/internal/config/userdata.go
Normal file
175
native/desktop/maplefile/internal/config/userdata.go
Normal 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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
98
native/desktop/maplefile/internal/domain/collection/model.go
Normal file
98
native/desktop/maplefile/internal/domain/collection/model.go
Normal 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
|
||||
}
|
||||
58
native/desktop/maplefile/internal/domain/file/constants.go
Normal file
58
native/desktop/maplefile/internal/domain/file/constants.go
Normal 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"
|
||||
)
|
||||
28
native/desktop/maplefile/internal/domain/file/interface.go
Normal file
28
native/desktop/maplefile/internal/domain/file/interface.go
Normal 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)
|
||||
}
|
||||
88
native/desktop/maplefile/internal/domain/file/model.go
Normal file
88
native/desktop/maplefile/internal/domain/file/model.go
Normal 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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
30
native/desktop/maplefile/internal/domain/session/model.go
Normal file
30
native/desktop/maplefile/internal/domain/session/model.go
Normal 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 != ""
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
77
native/desktop/maplefile/internal/domain/syncstate/model.go
Normal file
77
native/desktop/maplefile/internal/domain/syncstate/model.go
Normal 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()
|
||||
}
|
||||
}
|
||||
10
native/desktop/maplefile/internal/domain/user/interface.go
Normal file
10
native/desktop/maplefile/internal/domain/user/interface.go
Normal 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)
|
||||
}
|
||||
19
native/desktop/maplefile/internal/domain/user/model.go
Normal file
19
native/desktop/maplefile/internal/domain/user/model.go
Normal 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 != ""
|
||||
}
|
||||
212
native/desktop/maplefile/internal/repo/collection/repository.go
Normal file
212
native/desktop/maplefile/internal/repo/collection/repository.go
Normal 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
|
||||
}
|
||||
213
native/desktop/maplefile/internal/repo/file/repository.go
Normal file
213
native/desktop/maplefile/internal/repo/file/repository.go
Normal 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
|
||||
}
|
||||
55
native/desktop/maplefile/internal/repo/session/repository.go
Normal file
55
native/desktop/maplefile/internal/repo/session/repository.go
Normal 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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
105
native/desktop/maplefile/internal/repo/user/repository.go
Normal file
105
native/desktop/maplefile/internal/repo/user/repository.go
Normal 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
|
||||
}
|
||||
281
native/desktop/maplefile/internal/service/auth/service.go
Normal file
281
native/desktop/maplefile/internal/service/auth/service.go
Normal 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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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
Loading…
Add table
Add a link
Reference in a new issue