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
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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue