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

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

View file

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

View file

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

View file

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

View file

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

View file

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