diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..506762c --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +wails3 +build/ +vendor/ diff --git a/Taskfile.yaml b/Taskfile.yaml new file mode 100644 index 0000000..877af8c --- /dev/null +++ b/Taskfile.yaml @@ -0,0 +1,6 @@ +version: '3' + +tasks: + build: + cmds: + - go build -o build/bin/core cmd/app/main.go diff --git a/cmd/app/frontend/dist/assets/app.js b/cmd/app/frontend/dist/assets/app.js new file mode 100644 index 0000000..28abaa3 --- /dev/null +++ b/cmd/app/frontend/dist/assets/app.js @@ -0,0 +1 @@ +console.log("Hello from app.js!"); diff --git a/cmd/app/frontend/dist/index.html b/cmd/app/frontend/dist/index.html new file mode 100644 index 0000000..916c9c4 --- /dev/null +++ b/cmd/app/frontend/dist/index.html @@ -0,0 +1,10 @@ + + + + Core + + +

Core

+ + + diff --git a/cmd/app/main.go b/cmd/app/main.go new file mode 100644 index 0000000..01fd1d1 --- /dev/null +++ b/cmd/app/main.go @@ -0,0 +1,31 @@ +package main + +import ( + "embed" + "fmt" + + "core" + "github.com/wailsapp/wails/v3/pkg/application" +) + +//go:embed all:frontend/dist +var assets embed.FS + +func main() { + app := application.New(application.Options{ + Services: []application.Service{ + application.NewService(core.New(assets)), + }, + }) + + core.Setup(app) + + app.Event.On("setup-done", func(e *application.CustomEvent) { + fmt.Println("Setup done!") + }) + + err := app.Run() + if err != nil { + panic(err) + } +} diff --git a/config/config.go b/config/config.go new file mode 100644 index 0000000..72bea66 --- /dev/null +++ b/config/config.go @@ -0,0 +1,52 @@ +package config + +import ( + "fmt" + "reflect" + "strings" +) + +// Config holds the resolved paths and user-configurable settings for the application. +type Config struct { + // --- Dynamic Paths (not stored in config.json) --- + DataDir string `json:"-"` + ConfigDir string `json:"-"` + CacheDir string `json:"-"` + WorkspacesDir string `json:"-"` + RootDir string `json:"-"` + UserHomeDir string `json:"-"` + IsNew bool `json:"-"` // Flag indicating if the config was newly created. + + // --- Storable Settings (persisted in config.json) --- + DefaultRoute string `json:"defaultRoute,omitempty"` + Features []string `json:"features,omitempty"` + Language string `json:"language,omitempty"` +} + +// Key retrieves a configuration value by its key. It checks JSON tags and field names (case-insensitive). +func (c *Config) Key(key string) (interface{}, error) { + // Use reflection to inspect the struct fields. + val := reflect.ValueOf(c).Elem() + typ := val.Type() + + for i := 0; i < val.NumField(); i++ { + field := typ.Field(i) + fieldName := field.Name + + // Check the field name first. + if strings.EqualFold(fieldName, key) { + return val.Field(i).Interface(), nil + } + + // Then check the `json` tag. + jsonTag := field.Tag.Get("json") + if jsonTag != "" && jsonTag != "-" { + jsonName := strings.Split(jsonTag, ",")[0] + if strings.EqualFold(jsonName, key) { + return val.Field(i).Interface(), nil + } + } + } + + return nil, fmt.Errorf("key '%s' not found in config", key) +} diff --git a/config/service.go b/config/service.go new file mode 100644 index 0000000..745f358 --- /dev/null +++ b/config/service.go @@ -0,0 +1,175 @@ +package config + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/adrg/xdg" +) + +const appName = "lethean" +const configFileName = "config.json" + +// ErrSetupRequired is returned by ServiceStartup if config.json is missing. +var ErrSetupRequired = errors.New("setup required: config.json not found") + +// Service provides access to the application's configuration. +type Service struct { + config *Config +} + +// NewService creates and initializes a new configuration service. +// It loads an existing configuration or creates a default one if not found. +func NewService() (*Service, error) { + // 1. Determine the config directory path to check for an existing file. + homeDir, err := os.UserHomeDir() + if err != nil { + return nil, fmt.Errorf("could not resolve user home directory: %w", err) + } + userHomeDir := filepath.Join(homeDir, appName) + configDir := filepath.Join(userHomeDir, "config") + configPath := filepath.Join(configDir, configFileName) + + var cfg *Config + configNeedsSaving := false + + // 2. Check if the config file exists. + if _, err := os.Stat(configPath); err == nil { + // --- Config file EXISTS --- + + // First, get the base config with all the dynamic paths and directory structures. + cfg, err = newDefaultConfig() + if err != nil { + return nil, fmt.Errorf("failed to create base config structure: %w", err) + } + cfg.IsNew = false // Mark that we are loading an existing config. + + // Now, load the storable values from the existing file, which will override the defaults. + fileData, err := os.ReadFile(configPath) + if err != nil { + return nil, fmt.Errorf("failed to read existing config file at %s: %w", configPath, err) + } + + if err := json.Unmarshal(fileData, cfg); err != nil { + // If unmarshalling fails, we log a warning but proceed with the default config. + // This prevents a corrupted config.json from crashing the app. + fmt.Fprintf(os.Stderr, "Warning: Failed to unmarshal config.json at %s, using defaults: %v\n", configPath, err) + } + + } else if errors.Is(err, os.ErrNotExist) { + // --- Config file DOES NOT EXIST --- + configNeedsSaving = true + + // Create a fresh default config. This sets up paths and a default "en" language. + cfg, err = newDefaultConfig() + if err != nil { + return nil, fmt.Errorf("failed to create default config: %w", err) + } + cfg.IsNew = true // Mark that this is a new config. + + } else { + // Another error occurred (e.g., permissions). + return nil, fmt.Errorf("failed to check for config file at %s: %w", configPath, err) + } + + service := &Service{config: cfg} + + // If the config file didn't exist, save the newly generated one. + if configNeedsSaving { + if err := service.Save(); err != nil { + return nil, fmt.Errorf("failed to save initial config: %w", err) + } + } + + return service, nil +} + +// newDefaultConfig creates a default configuration with resolved paths and ensures directories exist. +func newDefaultConfig() (*Config, error) { + if strings.Contains(appName, "..") || strings.Contains(appName, string(filepath.Separator)) { + return nil, fmt.Errorf("invalid app name '%s': contains path traversal characters", appName) + } + + homeDir, err := os.UserHomeDir() + if err != nil { + return nil, fmt.Errorf("could not resolve user home directory: %w", err) + } + userHomeDir := filepath.Join(homeDir, appName) + + rootDir, err := xdg.DataFile(appName) + if err != nil { + return nil, fmt.Errorf("could not resolve data directory: %w", err) + } + + cacheDir, err := xdg.CacheFile(appName) + if err != nil { + return nil, fmt.Errorf("could not resolve cache directory: %w", err) + } + + cfg := &Config{ + UserHomeDir: userHomeDir, + RootDir: rootDir, + CacheDir: cacheDir, + ConfigDir: filepath.Join(userHomeDir, "config"), + DataDir: filepath.Join(userHomeDir, "data"), + WorkspacesDir: filepath.Join(userHomeDir, "workspaces"), + DefaultRoute: "/", + Features: []string{}, + Language: "en", // Hardcoded default, will be overridden if loaded or detected + } + + dirs := []string{cfg.RootDir, cfg.ConfigDir, cfg.DataDir, cfg.CacheDir, cfg.WorkspacesDir, cfg.UserHomeDir} + for _, dir := range dirs { + if err := os.MkdirAll(dir, os.ModePerm); err != nil { + return nil, fmt.Errorf("could not create directory %s: %w", dir, err) + } + } + + return cfg, nil +} + +// Get returns the loaded configuration. +func (s *Service) Get() *Config { + return s.config +} + +// Save writes the current configuration to config.json. +func (s *Service) Save() error { + configPath := filepath.Join(s.config.ConfigDir, configFileName) + + data, err := json.MarshalIndent(s.config, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal config: %w", err) + } + + if err := os.WriteFile(configPath, data, 0644); err != nil { + return fmt.Errorf("failed to write config file: %w", err) + } + return nil +} + +// IsFeatureEnabled checks if a given feature is enabled in the configuration. +func (s *Service) IsFeatureEnabled(feature string) bool { + for _, f := range s.config.Features { + if f == feature { + return true + } + } + return false +} + +// EnableFeature adds a feature to the list of enabled features and saves the config. +func (s *Service) EnableFeature(feature string) error { + if s.IsFeatureEnabled(feature) { + return nil + } + s.config.Features = append(s.config.Features, feature) + if err := s.Save(); err != nil { + return fmt.Errorf("failed to save config after enabling feature %s: %w", feature, err) + } + return nil +} diff --git a/config/service_test.go b/config/service_test.go new file mode 100644 index 0000000..c3484ee --- /dev/null +++ b/config/service_test.go @@ -0,0 +1,165 @@ +package config + +import ( + "fmt" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/adrg/xdg" +) + +// setupTestEnv creates temporary directories and sets environment variables +// to simulate a specific user home and XDG base directories for testing. +// It returns the path to the temporary home directory and a cleanup function. +func setupTestEnv(t *testing.T) (string, func()) { + // Create a temporary directory for the user's home + tempHomeDir, err := os.MkdirTemp("", "test_home") + if err != nil { + t.Fatalf("Failed to create temp home directory: %v", err) + } + + // Store original HOME environment variable to restore it later + oldHome := os.Getenv("HOME") + + // Set HOME environment variable for the test + os.Setenv("HOME", tempHomeDir) + + cleanup := func() { + // Restore original HOME environment variable + os.Setenv("HOME", oldHome) + // Clean up temporary directories + os.RemoveAll(tempHomeDir) + } + + return tempHomeDir, cleanup +} + +func TestNewService(t *testing.T) { + tempHomeDir, cleanup := setupTestEnv(t) + defer cleanup() + + service, err := NewService() + if err != nil { + t.Fatalf("NewService() failed: %v", err) + } + + cfg := service.Get() + + // These paths are based on the mocked HOME directory + expectedUserHomeDir := filepath.Join(tempHomeDir, appName) + expectedConfigDir := filepath.Join(expectedUserHomeDir, "config") + expectedDataDir := filepath.Join(expectedUserHomeDir, "data") + expectedWorkspacesDir := filepath.Join(expectedUserHomeDir, "workspaces") + + // For RootDir and CacheDir, xdg library's init() might have already run + // before our test's os.Setenv calls take effect for xdg. So, we calculate + // the *expected* values based on what xdg *actually* returns in the + // current process, which will likely be the system defaults or whatever + // was set before the test started. + actualXDGDataFile, err := xdg.DataFile(appName) + if err != nil { + t.Fatalf("xdg.DataFile failed: %v", err) + } + actualXDGCacheFile, err := xdg.CacheFile(appName) + if err != nil { + t.Fatalf("xdg.CacheFile failed: %v", err) + } + + expectedRootDir := actualXDGDataFile + expectedCacheDir := actualXDGCacheFile + + tests := []struct { + name string + actual string + expected string + }{ + {"UserHomeDir", cfg.UserHomeDir, expectedUserHomeDir}, + {"RootDir", cfg.RootDir, expectedRootDir}, + {"ConfigDir", cfg.ConfigDir, expectedConfigDir}, + {"DataDir", cfg.DataDir, expectedDataDir}, + {"CacheDir", cfg.CacheDir, expectedCacheDir}, + {"WorkspacesDir", cfg.WorkspacesDir, expectedWorkspacesDir}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.actual != tt.expected { + t.Errorf("Mismatch for %s: got %q, want %q", tt.name, tt.actual, tt.expected) + } + // Also check if the directory was actually created + if info, err := os.Stat(tt.actual); err != nil { + t.Errorf("Directory %q for %s was not created: %v", tt.actual, tt.name, err) + } else if !info.IsDir() { + t.Errorf("Path %q for %s is not a directory", tt.actual, tt.name) + } + }) + } +} + +func TestNewService_DirectoryCreationFails(t *testing.T) { + // Create a temporary directory that we will make read-only + tempHomeDir, err := os.MkdirTemp("", "test_readonly_home") + if err != nil { + t.Fatalf("Failed to create temp home directory: %v", err) + } + // Ensure cleanup happens, and restore permissions before removing + defer func() { + os.Chmod(tempHomeDir, 0755) // Restore write permissions for os.RemoveAll + os.RemoveAll(tempHomeDir) + }() + + // Make the temporary home directory read-only + if err := os.Chmod(tempHomeDir, 0555); err != nil { // r-xr-xr-x + t.Fatalf("Failed to make temp home directory read-only: %v", err) + } + + // Store original HOME environment variable to restore it later + oldHome := os.Getenv("HOME") + os.Setenv("HOME", tempHomeDir) + defer os.Setenv("HOME", oldHome) + + // NewService should now fail because it cannot create subdirectories in tempHomeDir + _, err = NewService() + if err == nil { + t.Errorf("NewService() expected to fail when directory creation is impossible, but it succeeded") + } + // Optionally, check for a specific error message or type + if err != nil && !strings.Contains(err.Error(), "could not create directory") { + t.Errorf("NewService() failed with unexpected error: %v", err) + } +} + +func TestNewService_PathTraversalAttempt(t *testing.T) { + + problematicAppName := "../lethean" + + // Simulate the validation logic from NewService + if !strings.Contains(problematicAppName, "..") && !strings.Contains(problematicAppName, string(filepath.Separator)) { + t.Errorf("Expected problematicAppName to contain path traversal characters, but it didn't") + } + + // We'll create a temporary function to simulate the validation within NewService + validateAppName := func(name string) error { + if strings.Contains(name, "..") || strings.Contains(name, string(filepath.Separator)) { + return fmt.Errorf("invalid app name '%s': contains path traversal characters", name) + } + return nil + } + + // Test with a problematic app name + err := validateAppName(problematicAppName) + if err == nil { + t.Errorf("validateAppName expected to fail for %q, but it succeeded", problematicAppName) + } + if err != nil && !strings.Contains(err.Error(), "path traversal characters") { + t.Errorf("validateAppName failed for %q with unexpected error: %v", problematicAppName, err) + } + // Test with a safe app name + safeAppName := "lethean" + err = validateAppName(safeAppName) + if err != nil { + t.Errorf("validateAppName expected to succeed for %q, but it failed with error: %v", safeAppName, err) + } +} diff --git a/core.go b/core.go new file mode 100644 index 0000000..973702f --- /dev/null +++ b/core.go @@ -0,0 +1,148 @@ +package core + +import ( + "embed" + "fmt" + "sync" + + "core/config" + "core/crypt" + "core/display" + "core/docs" + "core/filesystem" + "core/filesystem/local" + "core/workspace" + "github.com/wailsapp/wails/v3/pkg/application" +) + +// Service provides access to all core application services. +type Service struct { + app *application.App + configService *config.Service + displayService *display.Service + docsService *docs.Service + cryptService *crypt.Service + workspaceService *workspace.Service +} + +var ( + instance *Service + once sync.Once + initErr error +) + +// New performs Phase 1 of initialization: Instantiation. +// It creates the raw service objects without wiring them together. +func New(assets embed.FS) *Service { + once.Do(func() { + // Instantiate services in the correct order of dependency. + configService, err := config.NewService() + if err != nil { + initErr = fmt.Errorf("failed to initialize config service: %w", err) + return + } + + // Initialize the local filesystem medium + filesystem.Local, err = local.New(configService.Get().RootDir) + if err != nil { + initErr = fmt.Errorf("failed to initialize local filesystem: %w", err) + return + } + + displayService := display.NewService(display.ClientHub, assets) + docsService := docs.NewService(assets) + cryptService := crypt.NewService(configService.Get()) + workspaceService := workspace.NewService(configService.Get(), workspace.NewLocalMedium()) + + instance = &Service{ + configService: configService, + displayService: displayService, + docsService: docsService, + cryptService: cryptService, + workspaceService: workspaceService, + } + }) + + if initErr != nil { + panic(initErr) // A failure in a core service is fatal. + } + + return instance +} + +// Setup performs Phase 2 of initialization: Wiring. +// It injects the required dependencies into each service. +func Setup(app *application.App) { + if instance == nil { + panic("core.Setup() called before core.New() was successfully initialized") + } + instance.app = app + + // Wire the services with their dependencies. + instance.displayService.Setup(app, instance.configService, nil) + instance.docsService.Setup(app, instance.displayService) +} + +// App returns the global application instance. +func App() *application.App { + if instance == nil || instance.app == nil { + panic("core.App() called before core.Setup() was successfully initialized") + } + return instance.app +} + +// Config returns the singleton instance of the ConfigService. +func Config() *config.Service { + if instance == nil { + panic("core.Config() called before core.New() was successfully initialized") + } + return instance.configService +} + +// Display returns the singleton instance of the display.Service. +func Display() *display.Service { + if instance == nil { + panic("core.Display() called before core.New() was successfully initialized") + } + return instance.displayService +} + +// Docs returns the singleton instance of the DocsService. +func Docs() *docs.Service { + if instance == nil { + panic("core.Docs() called before core.New() was successfully initialized") + } + return instance.docsService +} + +// Crypt returns the singleton instance of the CryptService. +func Crypt() *crypt.Service { + if instance == nil { + panic("core.Crypt() called before core.New() was successfully initialized") + } + return instance.cryptService +} + +// Filesystem returns the singleton instance of the FilesystemService. +func Filesystem() filesystem.Medium { + if instance == nil { + panic("core.Filesystem() called before core.New() was successfully initialized") + } + return filesystem.Local +} + +// Workspace returns the singleton instance of the WorkspaceService. +func Workspace() *workspace.Service { + if instance == nil { + panic("core.Workspace() called before core.New() was successfully initialized") + } + return instance.workspaceService +} + +// Runtime returns the singleton instance of the Service. +func Runtime() *Service { + if instance == nil { + panic("core.Runtime() called before core.New() was successfully initialized") + } + return instance +} diff --git a/crypt/crypt.go b/crypt/crypt.go new file mode 100644 index 0000000..b6cff01 --- /dev/null +++ b/crypt/crypt.go @@ -0,0 +1,23 @@ +package crypt + +import ( + "core/config" +) + +// HashType defines the supported hashing algorithms. +type HashType string + +const ( + LTHN HashType = "lthn" + SHA512 HashType = "sha512" + SHA256 HashType = "sha256" + SHA1 HashType = "sha1" + MD5 HashType = "md5" +) + +// Service provides cryptographic functions. +// It is the main entry point for all cryptographic operations +// and is bound to the frontend. +type Service struct { + config *config.Config +} diff --git a/crypt/crypt_test.go b/crypt/crypt_test.go new file mode 100644 index 0000000..2cde507 --- /dev/null +++ b/crypt/crypt_test.go @@ -0,0 +1,20 @@ +package crypt + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestHash(t *testing.T) { + s := &Service{} + payload := "hello" + hash := s.Hash(LTHN, payload) + assert.NotEmpty(t, hash) +} + +func TestLuhn(t *testing.T) { + s := &Service{} + assert.True(t, s.Luhn("79927398713")) + assert.False(t, s.Luhn("79927398714")) +} diff --git a/crypt/hash.go b/crypt/hash.go new file mode 100644 index 0000000..723471d --- /dev/null +++ b/crypt/hash.go @@ -0,0 +1,33 @@ +package crypt + +import ( + "crypto/md5" + "crypto/sha1" + "crypto/sha256" + "crypto/sha512" + "encoding/hex" + + "core/crypt/lib/lthn" +) + +// Hash computes a hash of the payload using the specified algorithm. +func (s *Service) Hash(lib HashType, payload string) string { + switch lib { + case LTHN: + return lthn.Hash(payload) + case SHA512: + hash := sha512.Sum512([]byte(payload)) + return hex.EncodeToString(hash[:]) + case SHA1: + hash := sha1.Sum([]byte(payload)) + return hex.EncodeToString(hash[:]) + case MD5: + hash := md5.Sum([]byte(payload)) + return hex.EncodeToString(hash[:]) + case SHA256: + fallthrough + default: + hash := sha256.Sum256([]byte(payload)) + return hex.EncodeToString(hash[:]) + } +} diff --git a/crypt/lib/lthn/hash.go b/crypt/lib/lthn/hash.go new file mode 100644 index 0000000..c9f0ac0 --- /dev/null +++ b/crypt/lib/lthn/hash.go @@ -0,0 +1,46 @@ +package lthn + +import ( + "crypto/sha256" + "encoding/hex" +) + +// SetKeyMap sets the key map for the notarisation process. +func SetKeyMap(newKeyMap map[rune]rune) { + keyMap = newKeyMap +} + +// GetKeyMap gets the current key map. +func GetKeyMap() map[rune]rune { + return keyMap +} + +// Hash creates a reproducible hash from a string. +func Hash(input string) string { + salt := createSalt(input) + hash := sha256.Sum256([]byte(input + salt)) + return hex.EncodeToString(hash[:]) +} + +// createSalt creates a quasi-salt from a string by reversing it and swapping characters. +func createSalt(input string) string { + if input == "" { + return "" + } + runes := []rune(input) + salt := make([]rune, len(runes)) + for i := 0; i < len(runes); i++ { + char := runes[len(runes)-1-i] + if replacement, ok := keyMap[char]; ok { + salt[i] = replacement + } else { + salt[i] = char + } + } + return string(salt) +} + +// Verify checks if an input string matches a given hash. +func Verifyf(input string, hash string) bool { + return Hash(input) == hash +} diff --git a/crypt/lib/lthn/hash_test.go b/crypt/lib/lthn/hash_test.go new file mode 100644 index 0000000..463ea5d --- /dev/null +++ b/crypt/lib/lthn/hash_test.go @@ -0,0 +1,48 @@ +package lthn + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestHash(t *testing.T) { + input := "test_string" + expectedHash := "45d4027179b17265c38732fb1e7089a0b1adfe1d3ba4105fce66f7d46ba42f7d" + + hashed := Hash(input) + fmt.Printf("Hash for \"%s\": %s\n", input, hashed) + + assert.Equal(t, expectedHash, hashed, "The hash should match the expected value") +} + +func TestCreateSalt(t *testing.T) { + // Test with default keyMap + SetKeyMap(map[rune]rune{}) + assert.Equal(t, "gnirts_tset", createSalt("test_string")) + assert.Equal(t, "", createSalt("")) + assert.Equal(t, "A", createSalt("A")) + + // Test with a custom keyMap + customKeyMap := map[rune]rune{ + 'a': 'x', + 'b': 'y', + 'c': 'z', + } + SetKeyMap(customKeyMap) + assert.Equal(t, "zyx", createSalt("abc")) + assert.Equal(t, "gnirts_tset", createSalt("test_string")) // 'test_string' doesn't have 'a', 'b', 'c' + + // Reset keyMap to default for other tests + SetKeyMap(map[rune]rune{}) +} + +func TestVerify(t *testing.T) { + input := "another_test_string" + hashed := Hash(input) + + assert.True(t, Verifyf(input, hashed), "Verifyf should return true for a matching hash") + assert.False(t, Verifyf(input, "wrong_hash"), "Verifyf should return false for a non-matching hash") + assert.False(t, Verifyf("different_input", hashed), "Verifyf should return false for different input") +} diff --git a/crypt/lib/lthn/lthn.go b/crypt/lib/lthn/lthn.go new file mode 100644 index 0000000..5a1f6e1 --- /dev/null +++ b/crypt/lib/lthn/lthn.go @@ -0,0 +1,16 @@ +package lthn + +// keyMap is the default character-swapping map used for the quasi-salting process. +var keyMap = map[rune]rune{ + 'o': '0', + 'l': '1', + 'e': '3', + 'a': '4', + 's': 'z', + 't': '7', + '0': 'o', + '1': 'l', + '3': 'e', + '4': 'a', + '7': 't', +} diff --git a/crypt/lib/openpgp/encrypt.go b/crypt/lib/openpgp/encrypt.go new file mode 100644 index 0000000..9da18e7 --- /dev/null +++ b/crypt/lib/openpgp/encrypt.go @@ -0,0 +1,106 @@ +package openpgp + +import ( + "bytes" + "fmt" + "io" + "strings" + + "core/filesystem" + "github.com/ProtonMail/go-crypto/openpgp" + "github.com/ProtonMail/go-crypto/openpgp/armor" +) + +// EncryptPGP encrypts data for a recipient, optionally signing it. +func EncryptPGP(medium filesystem.Medium, recipientPath, data string, signerPath, signerPassphrase *string) (string, error) { + recipient, err := GetPublicKey(medium, recipientPath) + if err != nil { + return "", fmt.Errorf("failed to get recipient public key: %w", err) + } + + var signer *openpgp.Entity + if signerPath != nil && signerPassphrase != nil { + signer, err = GetPrivateKey(medium, *signerPath, *signerPassphrase) + if err != nil { + return "", fmt.Errorf("could not get private key for signing: %w", err) + } + } + + buf := new(bytes.Buffer) + armoredWriter, err := armor.Encode(buf, pgpMessageHeader, nil) + if err != nil { + return "", fmt.Errorf("failed to create armored writer: %w", err) + } + + plaintextWriter, err := openpgp.Encrypt(armoredWriter, []*openpgp.Entity{recipient}, signer, nil, nil) + if err != nil { + return "", fmt.Errorf("failed to encrypt: %w", err) + } + + if _, err := plaintextWriter.Write([]byte(data)); err != nil { + return "", fmt.Errorf("failed to write plaintext data: %w", err) + } + + if err := plaintextWriter.Close(); err != nil { + return "", fmt.Errorf("failed to close plaintext writer: %w", err) + } + if err := armoredWriter.Close(); err != nil { + return "", fmt.Errorf("failed to close armored writer: %w", err) + } + + // Debug print the encrypted message + fmt.Printf("Encrypted Message:\n%s\n", buf.String()) + + return buf.String(), nil +} + +// DecryptPGP decrypts a PGP message, optionally verifying the signature. +func DecryptPGP(medium filesystem.Medium, recipientPath, message, passphrase string, signerPath *string) (string, error) { + privateKeyEntity, err := GetPrivateKey(medium, recipientPath, passphrase) + if err != nil { + return "", fmt.Errorf("failed to get private key: %w", err) + } + + // For this API version, the keyring must contain all keys for decryption and verification. + keyring := openpgp.EntityList{privateKeyEntity} + var expectedSigner *openpgp.Entity + + if signerPath != nil { + publicKeyEntity, err := GetPublicKey(medium, *signerPath) + if err != nil { + return "", fmt.Errorf("could not get public key for verification: %w", err) + } + keyring = append(keyring, publicKeyEntity) + expectedSigner = publicKeyEntity + } + + // Debug print the message before decryption + fmt.Printf("Message to Decrypt:\n%s\n", message) + + // We pass the combined keyring, and nil for the prompt function because the private key is already decrypted. + md, err := openpgp.ReadMessage(strings.NewReader(message), keyring, nil, nil) + if err != nil { + return "", fmt.Errorf("failed to read PGP message: %w", err) + } + + decrypted, err := io.ReadAll(md.UnverifiedBody) + if err != nil { + return "", fmt.Errorf("failed to read decrypted body: %w", err) + } + + // The signature is checked automatically if the public key is in the keyring. + // We still need to check for errors and that the signer was who we expected. + if signerPath != nil { + if md.SignatureError != nil { + return "", fmt.Errorf("signature verification failed: %w", md.SignatureError) + } + if md.SignedBy == nil { + return "", fmt.Errorf("message is not signed, but signature verification was requested") + } + if expectedSigner.PrimaryKey.KeyId != md.SignedBy.PublicKey.KeyId { + return "", fmt.Errorf("signature from unexpected key id: got %X, want %X", md.SignedBy.PublicKey.KeyId, expectedSigner.PrimaryKey.KeyId) + } + } + + return string(decrypted), nil +} diff --git a/crypt/lib/openpgp/key.go b/crypt/lib/openpgp/key.go new file mode 100644 index 0000000..7173444 --- /dev/null +++ b/crypt/lib/openpgp/key.go @@ -0,0 +1,226 @@ +package openpgp + +import ( + "bytes" + "crypto" + "fmt" + "path/filepath" + "strings" + "time" + + "github.com/ProtonMail/go-crypto/openpgp" + "github.com/ProtonMail/go-crypto/openpgp/armor" + "github.com/ProtonMail/go-crypto/openpgp/packet" + "core/crypt/lib/lthn" + "core/filesystem" +) + +// CreateKeyPair generates a new OpenPGP key pair. +// The password parameter is optional. If not provided, the private key will not be encrypted. +func CreateKeyPair(username string, passwords ...string) (*KeyPair, error) { + var password string + if len(passwords) > 0 { + password = passwords[0] + } + + entity, err := openpgp.NewEntity(username, "Lethean Desktop", "", &packet.Config{ + RSABits: 4096, + DefaultHash: crypto.SHA256, + }) + if err != nil { + return nil, fmt.Errorf("failed to create new entity: %w", err) + } + + // The private key is initially unencrypted after NewEntity. + // Generate revocation certificate while the private key is unencrypted. + revocationCert, err := createRevocationCertificate(entity) + if err != nil { + revocationCert = "" // Non-critical, proceed without it if it fails + } + + // Encrypt the private key only if a password is provided, after revocation cert generation. + if password != "" { + if err := entity.PrivateKey.Encrypt([]byte(password)); err != nil { + return nil, fmt.Errorf("failed to encrypt private key: %w", err) + } + } + + publicKey, err := serializeEntity(entity, openpgp.PublicKeyType, "") // Public key doesn't need password + if err != nil { + return nil, err + } + + // Private key serialization. The key is already in its final encrypted/unencrypted state. + privateKey, err := serializeEntity(entity, openpgp.PrivateKeyType, "") // No password needed here for serialization + if err != nil { + return nil, err + } + + return &KeyPair{ + PublicKey: publicKey, + PrivateKey: privateKey, + RevocationCertificate: revocationCert, + }, nil +} + +// CreateServerKeyPair creates and stores a key pair for the server in a specific directory. +func CreateServerKeyPair(keysDir string) error { + serverKeyPath := filepath.Join(keysDir, "server.lthn.pub") + // Passphrase is derived from the path itself, consistent with original logic. + passphrase := lthn.Hash(serverKeyPath) + return createAndStoreKeyPair("server", passphrase, keysDir) +} + +// GetPublicKey retrieves an armored public key for a given ID. +func GetPublicKey(medium filesystem.Medium, path string) (*openpgp.Entity, error) { + return readEntity(medium, path) +} + +// GetPrivateKey retrieves and decrypts an armored private key. +func GetPrivateKey(medium filesystem.Medium, path, passphrase string) (*openpgp.Entity, error) { + entity, err := readEntity(medium, path) + if err != nil { + return nil, err + } + + if entity.PrivateKey == nil { + return nil, fmt.Errorf("no private key found for path %s", path) + } + + if entity.PrivateKey.Encrypted { + if err := entity.PrivateKey.Decrypt([]byte(passphrase)); err != nil { + return nil, fmt.Errorf("failed to decrypt private key for path %s: %w", path, err) + } + } + + var primaryIdentity *openpgp.Identity + for _, identity := range entity.Identities { + if identity.SelfSignature.IsPrimaryId != nil && *identity.SelfSignature.IsPrimaryId { + primaryIdentity = identity + break + } + } + if primaryIdentity == nil { + for _, identity := range entity.Identities { + primaryIdentity = identity + break + } + } + + if primaryIdentity == nil { + return nil, fmt.Errorf("key for %s has no identity", path) + } + + if primaryIdentity.SelfSignature.KeyLifetimeSecs != nil { + if primaryIdentity.SelfSignature.CreationTime.Add(time.Duration(*primaryIdentity.SelfSignature.KeyLifetimeSecs) * time.Second).Before(time.Now()) { + return nil, fmt.Errorf("key for %s has expired", path) + } + } + + return entity, nil +} + +// --- Helper Functions --- + +func createAndStoreKeyPair(id, password, dir string) error { + var keyPair *KeyPair + var err error + + if password != "" { + keyPair, err = CreateKeyPair(id, password) + } else { + keyPair, err = CreateKeyPair(id) + } + + if err != nil { + return fmt.Errorf("failed to create key pair for id %s: %w", id, err) + } + + if err := filesystem.Local.EnsureDir(dir); err != nil { + return fmt.Errorf("failed to ensure key directory exists: %w", err) + } + + files := map[string]string{ + filepath.Join(dir, fmt.Sprintf("%s.lthn.pub", id)): keyPair.PublicKey, + filepath.Join(dir, fmt.Sprintf("%s.lthn.key", id)): keyPair.PrivateKey, + filepath.Join(dir, fmt.Sprintf("%s.lthn.rev", id)): keyPair.RevocationCertificate, // Re-enabled + } + + for path, content := range files { + if content == "" { + continue + } + if err := filesystem.Local.Write(path, content); err != nil { + return fmt.Errorf("failed to write key file %s: %w", path, err) + } + } + return nil +} + +func readEntity(m filesystem.Medium, path string) (*openpgp.Entity, error) { + keyArmored, err := m.Read(path) + if err != nil { + return nil, fmt.Errorf("failed to read key file %s: %w", path, err) + } + + entityList, err := openpgp.ReadArmoredKeyRing(strings.NewReader(keyArmored)) + if err != nil { + return nil, fmt.Errorf("failed to parse key file %s: %w", path, err) + } + if len(entityList) == 0 { + return nil, fmt.Errorf("no entity found in key file %s", path) + } + return entityList[0], nil +} + +func serializeEntity(entity *openpgp.Entity, keyType string, password string) (string, error) { + buf := new(bytes.Buffer) + writer, err := armor.Encode(buf, keyType, nil) + if err != nil { + return "", fmt.Errorf("failed to create armor encoder: %w", err) + } + + if keyType == openpgp.PrivateKeyType { + // Serialize the private key in its current in-memory state. + // Encryption is handled by CreateKeyPair before this function is called. + err = entity.SerializePrivateWithoutSigning(writer, nil) + } else { + err = entity.Serialize(writer) + } + + if err != nil { + return "", fmt.Errorf("failed to serialize entity: %w", err) + } + if err := writer.Close(); err != nil { + return "", fmt.Errorf("failed to close armor writer: %w", err) + } + return buf.String(), nil +} + +func createRevocationCertificate(entity *openpgp.Entity) (string, error) { + buf := new(bytes.Buffer) + writer, err := armor.Encode(buf, openpgp.SignatureType, nil) + if err != nil { + return "", fmt.Errorf("failed to create armor encoder for revocation: %w", err) + } + + sig := &packet.Signature{ + SigType: packet.SigTypeKeyRevocation, + PubKeyAlgo: entity.PrimaryKey.PubKeyAlgo, + Hash: crypto.SHA256, + CreationTime: time.Now(), + IssuerKeyId: &entity.PrimaryKey.KeyId, + } + + // SignKey requires an unencrypted private key. + if err := sig.SignKey(entity.PrimaryKey, entity.PrivateKey, nil); err != nil { + return "", fmt.Errorf("failed to sign revocation: %w", err) + } + if err := sig.Serialize(writer); err != nil { + return "", fmt.Errorf("failed to serialize revocation signature: %w", err) + } + if err := writer.Close(); err != nil { + return "", fmt.Errorf("failed to close revocation writer: %w", err) + } + return buf.String(), nil +} diff --git a/crypt/lib/openpgp/openpgp.go b/crypt/lib/openpgp/openpgp.go new file mode 100644 index 0000000..1e604a5 --- /dev/null +++ b/crypt/lib/openpgp/openpgp.go @@ -0,0 +1,12 @@ +package openpgp + +// pgpMessageHeader is the standard armor header for PGP messages. +const pgpMessageHeader = "PGP MESSAGE" + +// KeyPair holds the generated armored keys and revocation certificate. +// This is the primary data structure representing a user's PGP identity within the system. +type KeyPair struct { + PublicKey string + PrivateKey string + RevocationCertificate string +} diff --git a/crypt/lib/openpgp/sign.go b/crypt/lib/openpgp/sign.go new file mode 100644 index 0000000..2fd8b90 --- /dev/null +++ b/crypt/lib/openpgp/sign.go @@ -0,0 +1,39 @@ +package openpgp + +import ( + "bytes" + "fmt" + "strings" + + "core/filesystem" + "github.com/ProtonMail/go-crypto/openpgp" +) + +// Sign creates a detached signature for the data. +func Sign(medium filesystem.Medium, data, privateKeyPath, passphrase string) (string, error) { + signer, err := GetPrivateKey(medium, privateKeyPath, passphrase) + if err != nil { + return "", fmt.Errorf("failed to get private key for signing: %w", err) + } + + buf := new(bytes.Buffer) + if err := openpgp.ArmoredDetachSign(buf, signer, strings.NewReader(data), nil); err != nil { + return "", fmt.Errorf("failed to create detached signature: %w", err) + } + + return buf.String(), nil +} + +// Verify checks a detached signature. +func Verify(medium filesystem.Medium, data, signature, publicKeyPath string) (bool, error) { + keyring, err := GetPublicKey(medium, publicKeyPath) + if err != nil { + return false, fmt.Errorf("failed to get public key for verification: %w", err) + } + + _, err = openpgp.CheckArmoredDetachedSignature(openpgp.EntityList{keyring}, strings.NewReader(data), strings.NewReader(signature), nil) + if err != nil { + return false, fmt.Errorf("signature verification failed: %w", err) + } + return true, nil +} diff --git a/crypt/service.go b/crypt/service.go new file mode 100644 index 0000000..728cc88 --- /dev/null +++ b/crypt/service.go @@ -0,0 +1,43 @@ +package crypt + +import ( + "context" + "fmt" + "log" + "path/filepath" + + "core/config" + "core/crypt/lib/openpgp" + "core/filesystem" + "github.com/wailsapp/wails/v3/pkg/application" +) + +// createServerKeyPair is a package-level variable that can be swapped for testing. +var createServerKeyPair = openpgp.CreateServerKeyPair + +// NewService creates a new crypt.Service, accepting a config service instance. +func NewService(cfg *config.Config) *Service { + return &Service{ + config: cfg, + } +} + +// ServiceStartup Startup is called when the app starts. It handles one-time cryptographic setup. +func (s *Service) ServiceStartup(ctx context.Context, options application.ServiceOptions) error { + // Define the directory for server keys based on the central config. + serverKeysDir := filepath.Join(s.config.DataDir, "server_keys") + if err := filesystem.EnsureDir(filesystem.Local, serverKeysDir); err != nil { + return fmt.Errorf("failed to create server keys directory: %w", err) + } + + // Check for server key pair using the configured path. + serverKeyPath := filepath.Join(serverKeysDir, "server.lthn.pub") + if !filesystem.IsFile(filesystem.Local, serverKeyPath) { + log.Println("Creating server key pair...") + if err := createServerKeyPair(serverKeysDir); err != nil { + return fmt.Errorf("failed to create server key pair: %w", err) + } + log.Println("Server key pair created.") + } + return nil +} diff --git a/crypt/sum.go b/crypt/sum.go new file mode 100644 index 0000000..7453037 --- /dev/null +++ b/crypt/sum.go @@ -0,0 +1,77 @@ +package crypt + +import ( + "encoding/binary" + "strconv" + "strings" +) + +// Luhn validates a number using the Luhn algorithm. +func (s *Service) Luhn(payload string) bool { + payload = strings.ReplaceAll(payload, " ", "") + sum := 0 + isSecond := false + for i := len(payload) - 1; i >= 0; i-- { + digit, err := strconv.Atoi(string(payload[i])) + if err != nil { + return false // Contains non-digit + } + + if isSecond { + digit = digit * 2 + if digit > 9 { + digit = digit - 9 + } + } + + sum += digit + isSecond = !isSecond + } + return sum%10 == 0 +} + +// Fletcher16 computes the Fletcher-16 checksum. +func (s *Service) Fletcher16(payload string) uint16 { + data := []byte(payload) + var sum1, sum2 uint16 + for _, b := range data { + sum1 = (sum1 + uint16(b)) % 255 + sum2 = (sum2 + sum1) % 255 + } + return (sum2 << 8) | sum1 +} + +// Fletcher32 computes the Fletcher-32 checksum. +func (s *Service) Fletcher32(payload string) uint32 { + data := []byte(payload) + // Pad with 0 to make it even length for uint16 conversion + if len(data)%2 != 0 { + data = append(data, 0) + } + + var sum1, sum2 uint32 + for i := 0; i < len(data); i += 2 { + val := binary.LittleEndian.Uint16(data[i : i+2]) + sum1 = (sum1 + uint32(val)) % 65535 + sum2 = (sum2 + sum1) % 65535 + } + return (sum2 << 16) | sum1 +} + +// Fletcher64 computes the Fletcher-64 checksum. +func (s *Service) Fletcher64(payload string) uint64 { + data := []byte(payload) + // Pad to multiple of 4 + if len(data)%4 != 0 { + padding := 4 - (len(data) % 4) + data = append(data, make([]byte, padding)...) + } + + var sum1, sum2 uint64 + for i := 0; i < len(data); i += 4 { + val := binary.LittleEndian.Uint32(data[i : i+4]) + sum1 = (sum1 + uint64(val)) % 4294967295 + sum2 = (sum2 + sum1) % 4294967295 + } + return (sum2 << 32) | sum1 +} diff --git a/display/apptray.png b/display/apptray.png new file mode 100644 index 0000000..0778fc6 Binary files /dev/null and b/display/apptray.png differ diff --git a/display/display.go b/display/display.go new file mode 100644 index 0000000..b50a0c7 --- /dev/null +++ b/display/display.go @@ -0,0 +1,32 @@ +package display + +import ( + "embed" + + "core/config" + "github.com/wailsapp/wails/v3/pkg/application" +) + +// Brand defines the type for different application brands. +type Brand string + +const ( + AdminHub Brand = "admin-hub" + ServerHub Brand = "server-hub" + GatewayHub Brand = "gateway-hub" + DeveloperHub Brand = "developer-hub" + ClientHub Brand = "client-hub" +) + +// Service manages all OS-level UI interactions (menus, windows, tray). +// It is the main entry point for all display-related operations. +type Service struct { + // --- Injected Dependencies --- + app *application.App + configService *config.Service + + // --- Internal State --- + brand Brand + assets embed.FS + windowHandles map[string]*application.WebviewWindow +} diff --git a/display/menu.go b/display/menu.go new file mode 100644 index 0000000..69059c1 --- /dev/null +++ b/display/menu.go @@ -0,0 +1,32 @@ +package display + +import ( + "runtime" + + "github.com/wailsapp/wails/v3/pkg/application" +) + +// buildMenu creates and sets the main application menu. +func (s *Service) buildMenu() { + appMenu := s.app.Menu.New() + if runtime.GOOS == "darwin" { + appMenu.AddRole(application.AppMenu) + } + appMenu.AddRole(application.FileMenu) + appMenu.AddRole(application.ViewMenu) + appMenu.AddRole(application.EditMenu) + + workspace := appMenu.AddSubmenu("Workspace") + workspace.Add("New").OnClick(func(ctx *application.Context) { /* TODO */ }) + workspace.Add("List").OnClick(func(ctx *application.Context) { /* TODO */ }) + + // Add brand-specific menu items + if s.brand == DeveloperHub { + appMenu.AddSubmenu("Developer") + } + + appMenu.AddRole(application.WindowMenu) + appMenu.AddRole(application.HelpMenu) + + s.app.Menu.Set(appMenu) +} diff --git a/display/service.go b/display/service.go new file mode 100644 index 0000000..5afd490 --- /dev/null +++ b/display/service.go @@ -0,0 +1,146 @@ +package display + +import ( + "context" + "embed" + "fmt" + + "core/config" + "github.com/wailsapp/wails/v3/pkg/application" + "github.com/wailsapp/wails/v3/pkg/events" +) + +// NewService creates a new DisplayService. +func NewService(brand Brand, assets embed.FS) *Service { + return &Service{ + brand: brand, + assets: assets, + windowHandles: make(map[string]*application.WebviewWindow), + } +} + +// Setup initializes the display service with the application instance and other core services. +func (s *Service) Setup(app *application.App, configService *config.Service, i18nService interface{}) { + s.app = app + s.configService = configService + + s.analyzeScreens() + s.monitorScreenChanges() + s.buildMenu() + s.setupTray() +} + +func (s *Service) analyzeScreens() { + s.app.Logger.Info("Screen analysis", "count", len(s.app.Screen.GetAll())) + + primary := s.app.Screen.GetPrimary() + if primary != nil { + s.app.Logger.Info("Primary screen", + "name", primary.Name, + "size", fmt.Sprintf("%dx%d", primary.Size.Width, primary.Size.Height), + "scaleFactor", primary.ScaleFactor, + "workArea", primary.WorkArea, + ) + scaleFactor := primary.ScaleFactor + + switch { + case scaleFactor == 1.0: + s.app.Logger.Info("Standard DPI display", "screen", primary.Name) + case scaleFactor == 1.25: + s.app.Logger.Info("125% scaled display", "screen", primary.Name) + case scaleFactor == 1.5: + s.app.Logger.Info("150% scaled display", "screen", primary.Name) + case scaleFactor == 2.0: + s.app.Logger.Info("High DPI display (200%)", "screen", primary.Name) + default: + s.app.Logger.Info("Custom scale display", + "screen", primary.Name, + "scale", scaleFactor, + ) + } + } else { + s.app.Logger.Info("No primary screen found") + } + + for i, screen := range s.app.Screen.GetAll() { + s.app.Logger.Info("Screen details", + "index", i, + "name", screen.Name, + "primary", screen.IsPrimary, + "bounds", screen.Bounds, + "scaleFactor", screen.ScaleFactor, + ) + } +} + +func (s *Service) monitorScreenChanges() { + // Monitor for screen configuration changes + s.app.Event.OnApplicationEvent(events.Common.ThemeChanged, func(event *application.ApplicationEvent) { + s.app.Logger.Info("Screen configuration changed") + + // Re-analyze screens + s.app.Logger.Info("Updated screen count", "count", len(s.app.Screen.GetAll())) + + // Could reposition windows here if needed + }) +} + +func (s *Service) ShowEnvironmentDialog() { + envInfo := s.app.Env.Info() + + details := fmt.Sprintf(`Environment Information: + +Operating System: %s +Architecture: %s +Debug Mode: %t + +Dark Mode: %t + +Platform Information:`, + envInfo.OS, + envInfo.Arch, + envInfo.Debug, + s.app.Env.IsDarkMode()) // Use s.app + + // Add platform-specific details + for key, value := range envInfo.PlatformInfo { + details += fmt.Sprintf("\n%s: %v", key, value) + } + + if envInfo.OSInfo != nil { + details += fmt.Sprintf("\n\nOS Details:\nName: %s\nVersion: %s", + envInfo.OSInfo.Name, + envInfo.OSInfo.Version) + } + + dialog := s.app.Dialog.Info() + dialog.SetTitle("Environment Information") + dialog.SetMessage(details) + dialog.Show() +} + +func (s *Service) ServiceStartup(ctx context.Context, options application.ServiceOptions) error { + // Check the IsNew flag from the config service, which is the single source of truth. + if s.configService.Get().IsNew { + // If the config was just created, open the setup window. + s.OpenWindow("main", application.WebviewWindowOptions{ + Title: "Setup", + URL: "#/setup", + }) + } else { + // If the config already existed, open the main window with the default route. + defaultRoute, err := s.configService.Get().Key("DefaultRoute") + if err != nil { + defaultRoute = "/" // Fallback to a safe default if the key is somehow missing. + s.app.Logger.Error("Could not get DefaultRoute from config, using fallback", "error", err) + } + + s.OpenWindow("main", application.WebviewWindowOptions{ + Title: "Core", + Height: 900, + Width: 1280, + URL: "#" + defaultRoute.(string), + }) + } + return nil +} diff --git a/display/tray.go b/display/tray.go new file mode 100644 index 0000000..d01c797 --- /dev/null +++ b/display/tray.go @@ -0,0 +1,74 @@ +package display + +import ( + "runtime" + + _ "embed" + + "github.com/wailsapp/wails/v3/pkg/application" +) + +//go:embed apptray.png +var appTrayIcon []byte + +// setupTray configures and creates the system tray icon and menu. +func (s *Service) setupTray() { + + systray := s.app.SystemTray.New() + systray.SetTooltip("Lethean Desktop") + + if runtime.GOOS == "darwin" { + systray.SetTemplateIcon(appTrayIcon) + } else { + // Support for light/dark mode icons + systray.SetDarkModeIcon(appTrayIcon) + systray.SetIcon(appTrayIcon) + } + // Create a hidden window for the system tray menu to interact with + trayWindow := s.app.Window.NewWithOptions(application.WebviewWindowOptions{ + Title: "System Tray Status", + URL: "/#/system-tray", + Width: 400, + Frameless: true, + Hidden: true, + }) + systray.AttachWindow(trayWindow).WindowOffset(5) + + // --- Build Tray Menu --- + trayMenu := s.app.Menu.New() + trayMenu.Add("Open Desktop").OnClick(func(ctx *application.Context) { + for _, window := range s.app.Window.GetAll() { + window.Show() + } + }) + trayMenu.Add("Close Desktop").OnClick(func(ctx *application.Context) { + for _, window := range s.app.Window.GetAll() { + window.Hide() + } + }) + + trayMenu.Add("Environment Info").OnClick(func(ctx *application.Context) { + s.ShowEnvironmentDialog() + }) + // Add brand-specific menu items + switch s.brand { + case AdminHub: + trayMenu.Add("Manage Workspace").OnClick(func(ctx *application.Context) { /* TODO */ }) + case ServerHub: + trayMenu.Add("Server Control").OnClick(func(ctx *application.Context) { /* TODO */ }) + case GatewayHub: + trayMenu.Add("Routing Table").OnClick(func(ctx *application.Context) { /* TODO */ }) + case DeveloperHub: + trayMenu.Add("Debug Console").OnClick(func(ctx *application.Context) { /* TODO */ }) + case ClientHub: + trayMenu.Add("Connect").OnClick(func(ctx *application.Context) { /* TODO */ }) + trayMenu.Add("Disconnect").OnClick(func(ctx *application.Context) { /* TODO */ }) + } + + trayMenu.AddSeparator() + trayMenu.Add("Quit").OnClick(func(ctx *application.Context) { + s.app.Quit() + }) + + systray.SetMenu(trayMenu) +} diff --git a/display/window.go b/display/window.go new file mode 100644 index 0000000..d376cb8 --- /dev/null +++ b/display/window.go @@ -0,0 +1,28 @@ +package display + +import "github.com/wailsapp/wails/v3/pkg/application" + +// OpenWindow creates and shows a new webview window. +// This function is callable from the frontend. +func (s *Service) OpenWindow(name string, options application.WebviewWindowOptions) { + // Check if a window with that name already exists + if window, exists := s.app.Window.GetByName(name); exists { + window.Focus() + return + } + + window := s.app.Window.NewWithOptions(options) + s.windowHandles[name] = window + window.Show() +} + +// SelectDirectory opens a directory selection dialog and returns the selected path. +func (s *Service) SelectDirectory() (string, error) { + dialog := application.OpenFileDialog() + dialog.SetTitle("Select Project Directory") + if path, err := dialog.PromptForSingleSelection(); err == nil { + // Use selected directory path + return path, nil + } + return "", nil +} diff --git a/docs/docs.go b/docs/docs.go new file mode 100644 index 0000000..88c5efb --- /dev/null +++ b/docs/docs.go @@ -0,0 +1,27 @@ +package docs + +import ( + "embed" + + "core/display" + "github.com/wailsapp/wails/v3/pkg/application" +) + +// displayer is an interface that defines the functionality docs needs from a display service. +// This avoids a direct dependency on the display package or the core package. +type displayer interface { + OpenWindow(name string, options application.WebviewWindowOptions) (*application.WebviewWindow, error) +} + +// Service manages the documentation display and serving of assets. +type Service struct { + // --- Injected Dependencies --- + app *application.App + displayService *display.Service // Depends on the local interface, not a concrete type from another package. + + // --- Internal State --- + assets embed.FS +} + +//go:embed all:static/**/* +var docsStatic embed.FS diff --git a/docs/service.go b/docs/service.go new file mode 100644 index 0000000..1fe41fa --- /dev/null +++ b/docs/service.go @@ -0,0 +1,54 @@ +package docs + +import ( + "embed" + "net/http" + "strings" + + "core/display" + "github.com/wailsapp/wails/v3/pkg/application" +) + +// NewService creates a new, un-wired documentation service. +func NewService(assets embed.FS) *Service { + return &Service{ + assets: assets, + } +} + +// Setup injects the required dependencies into the service. +func (s *Service) Setup(app *application.App, displayService *display.Service) { + s.app = app + s.displayService = displayService +} + +// OpenDocsWindow opens a new window with the documentation. +func (s *Service) OpenDocsWindow(path ...string) { + url := "/docs/" + if len(path) > 0 { + fullPath := path[0] + if strings.Contains(fullPath, "#") { + parts := strings.SplitN(fullPath, "#", 2) + pagePath := parts[0] + fragment := parts[1] + url += pagePath + "/#" + fragment + } else { + url += fullPath + } + } + + // Use the injected displayService, which satisfies the local displayer interface. + s.displayService.OpenWindow("docs", application.WebviewWindowOptions{ + Title: "Lethean Documentation", + Height: 600, + Width: 1000, + URL: url, + AlwaysOnTop: true, + Frameless: false, + }) +} + +// ServeHTTP serves the embedded documentation assets. +func (s *Service) ServeHTTP(w http.ResponseWriter, r *http.Request) { + http.FileServerFS(docsStatic).ServeHTTP(w, r) +} diff --git a/docs/static/assets/style.css b/docs/static/assets/style.css new file mode 100644 index 0000000..e69de29 diff --git a/docs/static/index.html b/docs/static/index.html new file mode 100644 index 0000000..e69de29 diff --git a/filesystem/client.go b/filesystem/client.go new file mode 100644 index 0000000..6b22d19 --- /dev/null +++ b/filesystem/client.go @@ -0,0 +1,45 @@ +package filesystem + +import ( + "core/filesystem/sftp" + "core/filesystem/webdav" +) + +// NewSFTPMedium creates and returns a new SFTP medium. +func NewSFTPMedium(cfg sftp.ConnectionConfig) (Medium, error) { + return sftp.New(cfg) +} + +// NewWebDAVMedium creates and returns a new WebDAV medium. +func NewWebDAVMedium(cfg webdav.ConnectionConfig) (Medium, error) { + return webdav.New(cfg) +} + +// Read retrieves the content of a file from the given medium. +func Read(m Medium, path string) (string, error) { + return m.Read(path) +} + +// Write saves content to a file on the given medium. +func Write(m Medium, path, content string) error { + return m.Write(path, content) +} + +// EnsureDir ensures a directory exists on the given medium. +func EnsureDir(m Medium, path string) error { + return m.EnsureDir(path) +} + +// IsFile checks if a path is a file on the given medium. +func IsFile(m Medium, path string) bool { + return m.IsFile(path) +} + +// Copy copies a file from a source medium to a destination medium. +func Copy(sourceMedium Medium, sourcePath string, destMedium Medium, destPath string) error { + content, err := sourceMedium.Read(sourcePath) + if err != nil { + return err + } + return destMedium.Write(destPath, content) +} diff --git a/filesystem/client_test.go b/filesystem/client_test.go new file mode 100644 index 0000000..5bf1a5c --- /dev/null +++ b/filesystem/client_test.go @@ -0,0 +1,31 @@ +package filesystem + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestRead(t *testing.T) { + m := NewMockMedium() + m.Files["test.txt"] = "hello" + content, err := Read(m, "test.txt") + assert.NoError(t, err) + assert.Equal(t, "hello", content) +} + +func TestWrite(t *testing.T) { + m := NewMockMedium() + err := Write(m, "test.txt", "hello") + assert.NoError(t, err) + assert.Equal(t, "hello", m.Files["test.txt"]) +} + +func TestCopy(t *testing.T) { + source := NewMockMedium() + dest := NewMockMedium() + source.Files["test.txt"] = "hello" + err := Copy(source, "test.txt", dest, "test.txt") + assert.NoError(t, err) + assert.Equal(t, "hello", dest.Files["test.txt"]) +} diff --git a/filesystem/filesystem.go b/filesystem/filesystem.go new file mode 100644 index 0000000..a9beecd --- /dev/null +++ b/filesystem/filesystem.go @@ -0,0 +1,29 @@ +package filesystem + +import () + +// Medium defines the standard interface for a storage backend. +// This allows for different implementations (e.g., local disk, S3, SFTP) +// to be used interchangeably. +type Medium interface { + // Read retrieves the content of a file as a string. + Read(path string) (string, error) + + // Write saves the given content to a file, overwriting it if it exists. + Write(path, content string) error + + // EnsureDir makes sure a directory exists, creating it if necessary. + EnsureDir(path string) error + + // IsFile checks if a path exists and is a regular file. + IsFile(path string) bool + + // FileGet is a convenience function that reads a file from the medium. + FileGet(path string) (string, error) + + // FileSet is a convenience function that writes a file to the medium. + FileSet(path, content string) error +} + +// Pre-initialized, sandboxed medium for the local filesystem. +var Local Medium diff --git a/filesystem/filesystem_test.go b/filesystem/filesystem_test.go new file mode 100644 index 0000000..8d6e6ae --- /dev/null +++ b/filesystem/filesystem_test.go @@ -0,0 +1,3 @@ +package filesystem + +import () diff --git a/filesystem/local/client.go b/filesystem/local/client.go new file mode 100644 index 0000000..0efe171 --- /dev/null +++ b/filesystem/local/client.go @@ -0,0 +1,83 @@ +package local + +import ( + "fmt" + "os" + "path/filepath" + "strings" +) + +// New creates a new instance of the local storage medium. +// It requires a root path to sandbox all file operations. +func New(rootPath string) (*Medium, error) { + if err := os.MkdirAll(rootPath, os.ModePerm); err != nil { + return nil, fmt.Errorf("could not create root directory at %s: %w", rootPath, err) + } + return &Medium{root: rootPath}, nil +} + +// path returns a full, safe path within the medium's root. +func (m *Medium) path(subpath string) (string, error) { + if strings.Contains(subpath, "..") { + return "", fmt.Errorf("path traversal attempt detected") + } + return filepath.Join(m.root, subpath), nil +} + +// Read retrieves the content of a file from the local disk. +func (m *Medium) Read(path string) (string, error) { + safePath, err := m.path(path) + if err != nil { + return "", err + } + data, err := os.ReadFile(safePath) + if err != nil { + return "", err + } + return string(data), nil +} + +// Write saves the given content to a file on the local disk. +func (m *Medium) Write(path, content string) error { + safePath, err := m.path(path) + if err != nil { + return err + } + dir := filepath.Dir(safePath) + if err := os.MkdirAll(dir, os.ModePerm); err != nil { + return err + } + return os.WriteFile(safePath, []byte(content), 0644) +} + +// EnsureDir makes sure a directory exists on the local disk. +func (m *Medium) EnsureDir(path string) error { + safePath, err := m.path(path) + if err != nil { + return err + } + return os.MkdirAll(safePath, os.ModePerm) +} + +// IsFile checks if a path exists and is a regular file on the local disk. +func (m *Medium) IsFile(path string) bool { + safePath, err := m.path(path) + if err != nil { + return false + } + info, err := os.Stat(safePath) + if os.IsNotExist(err) { + return false + } + return !info.IsDir() +} + +// FileGet is a convenience function that reads a file from the medium. +func (m *Medium) FileGet(path string) (string, error) { + return m.Read(path) +} + +// FileSet is a convenience function that writes a file to the medium. +func (m *Medium) FileSet(path, content string) error { + return m.Write(path, content) +} diff --git a/filesystem/local/client_test.go b/filesystem/local/client_test.go new file mode 100644 index 0000000..ff3dce7 --- /dev/null +++ b/filesystem/local/client_test.go @@ -0,0 +1,154 @@ +package local + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestNew(t *testing.T) { + // Create a temporary directory for testing + testRoot, err := os.MkdirTemp("", "local_test_root") + assert.NoError(t, err) + defer os.RemoveAll(testRoot) // Clean up after the test + + // Test successful creation + medium, err := New(testRoot) + assert.NoError(t, err) + assert.NotNil(t, medium) + assert.Equal(t, testRoot, medium.root) + + // Verify the root directory exists + info, err := os.Stat(testRoot) + assert.NoError(t, err) + assert.True(t, info.IsDir()) + + // Test creating a new instance with an existing directory (should not error) + medium2, err := New(testRoot) + assert.NoError(t, err) + assert.NotNil(t, medium2) +} + +func TestPath(t *testing.T) { + testRoot := "/tmp/test_root" + medium := &Medium{root: testRoot} + + // Valid path + validPath, err := medium.path("file.txt") + assert.NoError(t, err) + assert.Equal(t, filepath.Join(testRoot, "file.txt"), validPath) + + // Subdirectory path + subDirPath, err := medium.path("dir/sub/file.txt") + assert.NoError(t, err) + assert.Equal(t, filepath.Join(testRoot, "dir", "sub", "file.txt"), subDirPath) + + // Path traversal attempt + _, err = medium.path("../secret.txt") + assert.Error(t, err) + assert.Contains(t, err.Error(), "path traversal attempt detected") + + _, err = medium.path("dir/../../secret.txt") + assert.Error(t, err) + assert.Contains(t, err.Error(), "path traversal attempt detected") +} + +func TestReadWrite(t *testing.T) { + testRoot, err := os.MkdirTemp("", "local_read_write_test") + assert.NoError(t, err) + defer os.RemoveAll(testRoot) + + medium, err := New(testRoot) + assert.NoError(t, err) + + fileName := "testfile.txt" + filePath := filepath.Join("subdir", fileName) + content := "Hello, Gopher!\nThis is a test file." + + // Test Write + err = medium.Write(filePath, content) + assert.NoError(t, err) + + // Verify file content by reading directly from OS + readContent, err := os.ReadFile(filepath.Join(testRoot, filePath)) + assert.NoError(t, err) + assert.Equal(t, content, string(readContent)) + + // Test Read + readByMedium, err := medium.Read(filePath) + assert.NoError(t, err) + assert.Equal(t, content, readByMedium) + + // Test Read non-existent file + _, err = medium.Read("nonexistent.txt") + assert.Error(t, err) + assert.True(t, os.IsNotExist(err)) + + // Test Write to a path with traversal attempt + writeErr := medium.Write("../badfile.txt", "malicious content") + assert.Error(t, writeErr) + assert.Contains(t, writeErr.Error(), "path traversal attempt detected") +} + +func TestEnsureDir(t *testing.T) { + testRoot, err := os.MkdirTemp("", "local_ensure_dir_test") + assert.NoError(t, err) + defer os.RemoveAll(testRoot) + + medium, err := New(testRoot) + assert.NoError(t, err) + + dirName := "newdir/subdir" + dirPath := filepath.Join(testRoot, dirName) + + // Test creating a new directory + err = medium.EnsureDir(dirName) + assert.NoError(t, err) + info, err := os.Stat(dirPath) + assert.NoError(t, err) + assert.True(t, info.IsDir()) + + // Test ensuring an existing directory (should not error) + err = medium.EnsureDir(dirName) + assert.NoError(t, err) + + // Test ensuring a directory with path traversal attempt + err = medium.EnsureDir("../bad_dir") + assert.Error(t, err) + assert.Contains(t, err.Error(), "path traversal attempt detected") +} + +func TestIsFile(t *testing.T) { + testRoot, err := os.MkdirTemp("", "local_is_file_test") + assert.NoError(t, err) + defer os.RemoveAll(testRoot) + + medium, err := New(testRoot) + assert.NoError(t, err) + + // Create a test file + fileName := "existing_file.txt" + filePath := filepath.Join(testRoot, fileName) + err = os.WriteFile(filePath, []byte("content"), 0644) + assert.NoError(t, err) + + // Create a test directory + dirName := "existing_dir" + dirPath := filepath.Join(testRoot, dirName) + err = os.Mkdir(dirPath, 0755) + assert.NoError(t, err) + + // Test with an existing file + assert.True(t, medium.IsFile(fileName)) + + // Test with a non-existent file + assert.False(t, medium.IsFile("nonexistent_file.txt")) + + // Test with a directory + assert.False(t, medium.IsFile(dirName)) + + // Test with path traversal attempt + assert.False(t, medium.IsFile("../bad_file.txt")) +} diff --git a/filesystem/local/local.go b/filesystem/local/local.go new file mode 100644 index 0000000..61f2447 --- /dev/null +++ b/filesystem/local/local.go @@ -0,0 +1,6 @@ +package local + +// Medium implements the filesystem.Medium interface for the local disk. +type Medium struct { + root string +} diff --git a/filesystem/mock.go b/filesystem/mock.go new file mode 100644 index 0000000..e97327b --- /dev/null +++ b/filesystem/mock.go @@ -0,0 +1,47 @@ +package filesystem + +import "github.com/stretchr/testify/assert" + +// MockMedium implements the Medium interface for testing purposes. +type MockMedium struct { + Files map[string]string + Dirs map[string]bool +} + +func NewMockMedium() *MockMedium { + return &MockMedium{ + Files: make(map[string]string), + Dirs: make(map[string]bool), + } +} + +func (m *MockMedium) Read(path string) (string, error) { + content, ok := m.Files[path] + if !ok { + return "", assert.AnError // Simulate file not found error + } + return content, nil +} + +func (m *MockMedium) Write(path, content string) error { + m.Files[path] = content + return nil +} + +func (m *MockMedium) EnsureDir(path string) error { + m.Dirs[path] = true + return nil +} + +func (m *MockMedium) IsFile(path string) bool { + _, ok := m.Files[path] + return ok +} + +func (m *MockMedium) FileGet(path string) (string, error) { + return m.Read(path) +} + +func (m *MockMedium) FileSet(path, content string) error { + return m.Write(path, content) +} diff --git a/filesystem/sftp/client.go b/filesystem/sftp/client.go new file mode 100644 index 0000000..a745a90 --- /dev/null +++ b/filesystem/sftp/client.go @@ -0,0 +1,125 @@ +package sftp + +import ( + "fmt" + "io" + "net" + "os" + "path/filepath" + + "github.com/pkg/sftp" + "github.com/skeema/knownhosts" + "golang.org/x/crypto/ssh" +) + +// New creates a new, connected instance of the SFTP storage medium. +func New(cfg ConnectionConfig) (*Medium, error) { + var authMethods []ssh.AuthMethod + + if cfg.KeyFile != "" { + key, err := os.ReadFile(cfg.KeyFile) + if err != nil { + return nil, fmt.Errorf("unable to read private key: %w", err) + } + signer, err := ssh.ParsePrivateKey(key) + if err != nil { + return nil, fmt.Errorf("unable to parse private key: %w", err) + } + authMethods = append(authMethods, ssh.PublicKeys(signer)) + } else if cfg.Password != "" { + authMethods = append(authMethods, ssh.Password(cfg.Password)) + } else { + return nil, fmt.Errorf("no authentication method provided (password or keyfile)") + } + + kh, err := knownhosts.New(filepath.Join(os.Getenv("HOME"), ".ssh", "known_hosts")) + if err != nil { + return nil, fmt.Errorf("failed to read known_hosts: %w", err) + } + + sshConfig := &ssh.ClientConfig{ + User: cfg.User, + Auth: authMethods, + HostKeyCallback: kh.HostKeyCallback(), + } + + addr := net.JoinHostPort(cfg.Host, cfg.Port) + conn, err := ssh.Dial("tcp", addr, sshConfig) + if err != nil { + return nil, fmt.Errorf("failed to dial ssh: %w", err) + } + + sftpClient, err := sftp.NewClient(conn) + if err != nil { + // Ensure the underlying ssh connection is closed on failure + conn.Close() + return nil, fmt.Errorf("failed to create sftp client: %w", err) + } + + return &Medium{client: sftpClient}, nil +} + +// Read retrieves the content of a file from the SFTP server. +func (m *Medium) Read(path string) (string, error) { + file, err := m.client.Open(path) + if err != nil { + return "", fmt.Errorf("sftp: failed to open file %s: %w", path, err) + } + defer file.Close() + + data, err := io.ReadAll(file) + if err != nil { + return "", fmt.Errorf("sftp: failed to read file %s: %w", path, err) + } + + return string(data), nil +} + +// Write saves the given content to a file on the SFTP server. +func (m *Medium) Write(path, content string) error { + // Ensure the remote directory exists first. + dir := filepath.Dir(path) + if err := m.EnsureDir(dir); err != nil { + return err + } + + file, err := m.client.Create(path) + if err != nil { + return fmt.Errorf("sftp: failed to create file %s: %w", path, err) + } + defer file.Close() + + if _, err := file.Write([]byte(content)); err != nil { + return fmt.Errorf("sftp: failed to write to file %s: %w", path, err) + } + + return nil +} + +// EnsureDir makes sure a directory exists on the SFTP server. +func (m *Medium) EnsureDir(path string) error { + // MkdirAll is idempotent, so it won't error if the path already exists. + return m.client.MkdirAll(path) +} + +// IsFile checks if a path exists and is a regular file on the SFTP server. +func (m *Medium) IsFile(path string) bool { + info, err := m.client.Stat(path) + if err != nil { + // If the error is "not found", it's definitely not a file. + // For any other error, we also conservatively say it's not a file. + return false + } + // Return true only if it's not a directory. + return !info.IsDir() +} + +// FileGet is a convenience function that reads a file from the medium. +func (m *Medium) FileGet(path string) (string, error) { + return m.Read(path) +} + +// FileSet is a convenience function that writes a file to the medium. +func (m *Medium) FileSet(path, content string) error { + return m.Write(path, content) +} diff --git a/filesystem/sftp/sftp.go b/filesystem/sftp/sftp.go new file mode 100644 index 0000000..cf9e2e1 --- /dev/null +++ b/filesystem/sftp/sftp.go @@ -0,0 +1,19 @@ +package sftp + +import ( + "github.com/pkg/sftp" +) + +// Medium implements the filesystem.Medium interface for the SFTP protocol. +type Medium struct { + client *sftp.Client +} + +// ConnectionConfig holds the necessary details to connect to an SFTP server. +type ConnectionConfig struct { + Host string + Port string + User string + Password string // For password-based auth + KeyFile string // Path to a private key for key-based auth +} diff --git a/filesystem/webdav/client.go b/filesystem/webdav/client.go new file mode 100644 index 0000000..7ed4f74 --- /dev/null +++ b/filesystem/webdav/client.go @@ -0,0 +1,16 @@ +package webdav + +import "net/http" + +// Medium implements the filesystem.Medium interface for the WebDAV protocol. +type Medium struct { + client *http.Client + baseURL string // e.g., https://dav.example.com/remote.php/dav/files/username/ +} + +// ConnectionConfig holds the necessary details to connect to a WebDAV server. +type ConnectionConfig struct { + URL string // The full base URL of the WebDAV share. + User string + Password string +} diff --git a/filesystem/webdav/webdav.go b/filesystem/webdav/webdav.go new file mode 100644 index 0000000..db0ac66 --- /dev/null +++ b/filesystem/webdav/webdav.go @@ -0,0 +1,183 @@ +package webdav + +import ( + "bytes" + _ "context" + "fmt" + "io" + "net/http" + "path" + "strings" +) + +// New creates a new, connected instance of the WebDAV storage medium. +func New(cfg ConnectionConfig) (*Medium, error) { + transport := &authTransport{ + Username: cfg.User, + Password: cfg.Password, + Wrapped: http.DefaultTransport, + } + + httpClient := &http.Client{Transport: transport} + + // Ping the server to ensure the connection and credentials are valid. + // We do a PROPFIND on the root, which is a standard WebDAV operation. + req, err := http.NewRequest("PROPFIND", cfg.URL, nil) + if err != nil { + return nil, fmt.Errorf("webdav: failed to create ping request: %w", err) + } + req.Header.Set("Depth", "0") + resp, err := httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("webdav: connection test failed: %w", err) + } + resp.Body.Close() + if resp.StatusCode != http.StatusMultiStatus && resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("webdav: connection test failed with status %s", resp.Status) + } + + return &Medium{ + client: httpClient, + baseURL: cfg.URL, + }, nil +} + +// Read retrieves the content of a file from the WebDAV server. +func (m *Medium) Read(p string) (string, error) { + url := m.resolveURL(p) + resp, err := m.client.Get(url) + if err != nil { + return "", fmt.Errorf("webdav: GET request for %s failed: %w", p, err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("webdav: failed to read %s, status: %s", p, resp.Status) + } + + data, err := io.ReadAll(resp.Body) + if err != nil { + return "", fmt.Errorf("webdav: failed to read response body for %s: %w", p, err) + } + + return string(data), nil +} + +// Write saves the given content to a file on the WebDAV server. +func (m *Medium) Write(p, content string) error { + // Ensure the parent directory exists first. + dir := path.Dir(p) + if dir != "." && dir != "/" { + if err := m.EnsureDir(dir); err != nil { + return err // This will be a detailed error from EnsureDir + } + } + + url := m.resolveURL(p) + req, err := http.NewRequest("PUT", url, bytes.NewReader([]byte(content))) + if err != nil { + return fmt.Errorf("webdav: failed to create PUT request: %w", err) + } + + resp, err := m.client.Do(req) + if err != nil { + return fmt.Errorf("webdav: PUT request for %s failed: %w", p, err) + } + defer resp.Body.Close() + + // StatusCreated (201) or StatusNoContent (204) are success codes for PUT. + if resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusNoContent { + return fmt.Errorf("webdav: failed to write %s, status: %s", p, resp.Status) + } + + return nil +} + +// EnsureDir makes sure a directory exists on the WebDAV server, creating parent dirs as needed. +func (m *Medium) EnsureDir(p string) error { + // To mimic MkdirAll, we create each part of the path sequentially. + parts := strings.Split(p, "/") + currentPath := "" + for _, part := range parts { + if part == "" { + continue + } + currentPath = path.Join(currentPath, part) + url := m.resolveURL(currentPath) + "/" // MKCOL needs a trailing slash + + req, err := http.NewRequest("MKCOL", url, nil) + if err != nil { + return fmt.Errorf("webdav: failed to create MKCOL request for %s: %w", currentPath, err) + } + + resp, err := m.client.Do(req) + if err != nil { + return fmt.Errorf("webdav: MKCOL request for %s failed: %w", currentPath, err) + } + resp.Body.Close() + + // 405 Method Not Allowed means it already exists, which is fine for us. + // 201 Created is a success. + if resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusMethodNotAllowed { + return fmt.Errorf("webdav: failed to create directory %s, status: %s", currentPath, resp.Status) + } + } + return nil +} + +// IsFile checks if a path exists and is a regular file on the WebDAV server. +func (m *Medium) IsFile(p string) bool { + url := m.resolveURL(p) + req, err := http.NewRequest("PROPFIND", url, nil) + if err != nil { + return false + } + req.Header.Set("Depth", "0") + + resp, err := m.client.Do(req) + if err != nil { + return false + } + defer resp.Body.Close() + + // If we get anything other than a Multi-Status, it's probably not a file. + if resp.StatusCode != http.StatusMultiStatus { + return false + } + + // A simple check: if the response body contains the string for a collection, it's a directory. + // A more robust implementation would parse the XML response. + body, err := io.ReadAll(resp.Body) + if err != nil { + return false + } + + return !strings.Contains(string(body), "") +} + +// resolveURL joins the base URL with a path segment, ensuring correct slashes. +func (m *Medium) resolveURL(p string) string { + return strings.TrimSuffix(m.baseURL, "/") + "/" + strings.TrimPrefix(p, "/") +} + +// authTransport is a custom http.RoundTripper to inject Basic Auth. +type authTransport struct { + Username string + Password string + Wrapped http.RoundTripper +} + +func (t *authTransport) RoundTrip(req *http.Request) (*http.Response, error) { + req.SetBasicAuth(t.Username, t.Password) + return t.Wrapped.RoundTrip(req) +} + +// FileGet is a convenience function that reads a file from the medium. +func (m *Medium) FileGet(path string) (string, error) { + return m.Read(path) +} + +// FileSet is a convenience function that writes a file to the medium. +func (m *Medium) FileSet(path, content string) error { + return m.Write(path, content) +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..faf2aa8 --- /dev/null +++ b/go.mod @@ -0,0 +1,60 @@ +module core + +go 1.24.0 + +toolchain go1.24.3 + +require ( + github.com/ProtonMail/go-crypto v1.3.0 + github.com/adrg/xdg v0.5.3 + github.com/nicksnyder/go-i18n/v2 v2.6.0 + github.com/pkg/sftp v1.13.10 + github.com/skeema/knownhosts v1.3.1 + github.com/stretchr/testify v1.11.1 + github.com/wailsapp/wails/v3 v3.0.0-alpha.36 + golang.org/x/crypto v0.43.0 + golang.org/x/text v0.30.0 +) + +require ( + dario.cat/mergo v1.0.1 // indirect + github.com/Microsoft/go-winio v0.6.2 // indirect + github.com/bep/debounce v1.2.1 // indirect + github.com/cloudflare/circl v1.6.0 // indirect + github.com/cyphar/filepath-securejoin v0.4.1 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/ebitengine/purego v0.8.2 // indirect + github.com/emirpasic/gods v1.18.1 // indirect + github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect + github.com/go-git/go-billy/v5 v5.6.2 // indirect + github.com/go-git/go-git/v5 v5.13.2 // indirect + github.com/go-ole/go-ole v1.3.0 // indirect + github.com/godbus/dbus/v5 v5.1.0 // indirect + github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect + github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e // indirect + github.com/kevinburke/ssh_config v1.2.0 // indirect + github.com/kr/fs v0.1.0 // indirect + github.com/leaanthony/go-ansi-parser v1.6.1 // indirect + github.com/leaanthony/u v1.1.1 // indirect + github.com/lmittmann/tint v1.0.7 // indirect + github.com/mattn/go-colorable v0.1.14 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/pjbgf/sha1cd v0.3.2 // indirect + github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect + github.com/pkg/errors v0.9.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/rivo/uniseg v0.4.7 // indirect + github.com/rogpeppe/go-internal v1.13.1 // indirect + github.com/samber/lo v1.49.1 // indirect + github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect + github.com/wailsapp/go-webview2 v1.0.22 // indirect + github.com/wailsapp/mimetype v1.4.1 // indirect + github.com/xanzy/ssh-agent v0.3.3 // indirect + golang.org/x/net v0.46.0 // indirect + golang.org/x/sys v0.37.0 // indirect + gopkg.in/ini.v1 v1.67.0 // indirect + gopkg.in/warnings.v0 v0.1.2 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..4f47069 --- /dev/null +++ b/go.sum @@ -0,0 +1,158 @@ +dario.cat/mergo v1.0.1 h1:Ra4+bf83h2ztPIQYNP99R6m+Y7KfnARDfID+a+vLl4s= +dario.cat/mergo v1.0.1/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk= +github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg= +github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= +github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY= +github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= +github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= +github.com/ProtonMail/go-crypto v1.3.0 h1:ILq8+Sf5If5DCpHQp4PbZdS1J7HDFRXz/+xKBiRGFrw= +github.com/ProtonMail/go-crypto v1.3.0/go.mod h1:9whxjD8Rbs29b4XWbB8irEcE8KHMqaR2e7GWU1R+/PE= +github.com/adrg/xdg v0.5.3 h1:xRnxJXne7+oWDatRhR1JLnvuccuIeCoBu2rtuLqQB78= +github.com/adrg/xdg v0.5.3/go.mod h1:nlTsY+NNiCBGCK2tpm09vRqfVzrc2fLmXGpBLF0zlTQ= +github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8= +github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4= +github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio= +github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= +github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY= +github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0= +github.com/cloudflare/circl v1.6.0 h1:cr5JKic4HI+LkINy2lg3W2jF8sHCVTBncJr5gIIq7qk= +github.com/cloudflare/circl v1.6.0/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs= +github.com/cyphar/filepath-securejoin v0.4.1 h1:JyxxyPEaktOD+GAnqIqTf9A8tHyAG22rowi7HkoSU1s= +github.com/cyphar/filepath-securejoin v0.4.1/go.mod h1:Sdj7gXlvMcPZsbhwhQ33GguGLDGQL7h7bg04C/+u9jI= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/ebitengine/purego v0.8.2 h1:jPPGWs2sZ1UgOSgD2bClL0MJIqu58nOmIcBuXr62z1I= +github.com/ebitengine/purego v0.8.2/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= +github.com/elazarl/goproxy v1.4.0 h1:4GyuSbFa+s26+3rmYNSuUVsx+HgPrV1bk1jXI0l9wjM= +github.com/elazarl/goproxy v1.4.0/go.mod h1:X/5W/t+gzDyLfHW4DrMdpjqYjpXsURlBt9lpBDxZZZQ= +github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc= +github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ= +github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c= +github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU= +github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI= +github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic= +github.com/go-git/go-billy/v5 v5.6.2 h1:6Q86EsPXMa7c3YZ3aLAQsMA0VlWmy43r6FHqa/UNbRM= +github.com/go-git/go-billy/v5 v5.6.2/go.mod h1:rcFC2rAsp/erv7CMz9GczHcuD0D32fWzH+MJAU+jaUU= +github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399 h1:eMje31YglSBqCdIqdhKBW8lokaMrL3uTkpGYlE2OOT4= +github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII= +github.com/go-git/go-git/v5 v5.13.2 h1:7O7xvsK7K+rZPKW6AQR1YyNhfywkv7B8/FsP3ki6Zv0= +github.com/go-git/go-git/v5 v5.13.2/go.mod h1:hWdW5P4YZRjmpGHwRH2v3zkWcNl6HeXaXQEMGb3NJ9A= +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/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ= +github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +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/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A= +github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo= +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/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4= +github.com/kevinburke/ssh_config v1.2.0/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM= +github.com/kr/fs v0.1.0 h1:Jskdu9ieNAYnjxsi0LbQp1ulIKZV1LAFgK1tWhpZgl8= +github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +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/u v1.1.1 h1:TUFjwDGlNX+WuwVEzDqQwC2lOv0P4uhTQw7CMFdiK7M= +github.com/leaanthony/u v1.1.1/go.mod h1:9+o6hejoRljvZ3BzdYlVL0JYCwtnAsVuN9pVTQcaRfI= +github.com/letheanVPN/desktop v0.0.0-20251022090216-fa2e81586780 h1:lhVZUj74gA0SoVQ2H6u+ZSHzHjwBD5ALgxILcPeGHE8= +github.com/letheanVPN/desktop v0.0.0-20251022090216-fa2e81586780/go.mod h1:rVikQURn9JFi8sxMzBSyjjtp7PJYS71es30NnIY8Hdw= +github.com/lmittmann/tint v1.0.7 h1:D/0OqWZ0YOGZ6AyC+5Y2kD8PBEzBk6rFHVSfOqCkF9Y= +github.com/lmittmann/tint v1.0.7/go.mod h1:HIS3gSy7qNwGCj+5oRjAutErFBl4BzdQP6cJZ0NfMwE= +github.com/matryer/is v1.4.0/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU= +github.com/matryer/is v1.4.1 h1:55ehd8zaGABKLXQUe2awZ99BD/PTc2ls+KV/dXphgEQ= +github.com/matryer/is v1.4.1/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU= +github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= +github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= +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/nicksnyder/go-i18n/v2 v2.6.0 h1:C/m2NNWNiTB6SK4Ao8df5EWm3JETSTIGNXBpMJTxzxQ= +github.com/nicksnyder/go-i18n/v2 v2.6.0/go.mod h1:88sRqr0C6OPyJn0/KRNaEz1uWorjxIKP7rUUcvycecE= +github.com/onsi/gomega v1.34.1 h1:EUMJIKUjM8sKjYbtxQI9A4z2o+rruxnzNvpknOXie6k= +github.com/onsi/gomega v1.34.1/go.mod h1:kU1QgUvBDLXBJq618Xvm2LUX6rSAfRaFRTcdOeDLwwY= +github.com/pjbgf/sha1cd v0.3.2 h1:a9wb0bp1oC2TGwStyn0Umc/IGKQnEgF0vVaZ8QF8eo4= +github.com/pjbgf/sha1cd v0.3.2/go.mod h1:zQWigSxVmsHEZow5qaLtPYxpcKMMQpa09ixqBxuCS6A= +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/pkg/sftp v1.13.10 h1:+5FbKNTe5Z9aspU88DPIKJ9z2KZoaGCu6Sr6kKR/5mU= +github.com/pkg/sftp v1.13.10/go.mod h1:bJ1a7uDhrX/4OII+agvy28lzRvQrmIQuaHrcI1HbeGA= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +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/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= +github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= +github.com/samber/lo v1.49.1 h1:4BIFyVfuQSEpluc7Fua+j1NolZHiEHEpaSEKdsH0tew= +github.com/samber/lo v1.49.1/go.mod h1:dO6KHFzUKXgP8LDhU0oI8d2hekjXnGOu0DB8Jecxd6o= +github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 h1:n661drycOFuPLCN3Uc8sB6B/s6Z4t2xvBgU1htSHuq8= +github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4= +github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= +github.com/skeema/knownhosts v1.3.1 h1:X2osQ+RAjK76shCbvhHHHVl3ZlgDm8apHEHFqRjnBY8= +github.com/skeema/knownhosts v1.3.1/go.mod h1:r7KTdC8l4uxWRyK2TpQZ/1o5HaSzh06ePQNxPwTcfiY= +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.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +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/v3 v3.0.0-alpha.36 h1:GQ8vSrFgafITwMd/p4k+WBjG9K/anma9Pk2eJ/5CLsI= +github.com/wailsapp/wails/v3 v3.0.0-alpha.36/go.mod h1:7i8tSuA74q97zZ5qEJlcVZdnO+IR7LT2KU8UpzYMPsw= +github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM= +github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw= +golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= +golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04= +golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0= +golang.org/x/exp v0.0.0-20250210185358-939b2ce775ac h1:l5+whBCLH3iH2ZNHYLbAe58bo7yrN4mVcnkHDYz5vvs= +golang.org/x/exp v0.0.0-20250210185358-939b2ce775ac/go.mod h1:hH+7mtFmImwwcMvScyxUhjuVHR3HGaDPMn9rMSUUbxo= +golang.org/x/net v0.0.0-20210505024714-0287a6fb4125/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4= +golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210= +golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/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-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/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.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ= +golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.36.0 h1:zMPR+aF8gfksFprF/Nc/rd1wRS1EI6nDBGyWAvDzx2Q= +golang.org/x/term v0.36.0/go.mod h1:Qu394IJq6V6dCBRgwqshf3mPF85AqzYEzofzRdZkWss= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k= +golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= +gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= +gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME= +gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/workspace/local.go b/workspace/local.go new file mode 100644 index 0000000..4eab633 --- /dev/null +++ b/workspace/local.go @@ -0,0 +1,41 @@ +package workspace + +import "core/filesystem" + +// localMedium implements the Medium interface for the local disk. +type localMedium struct{} + +// NewLocalMedium creates a new instance of the local storage medium. +func NewLocalMedium() filesystem.Medium { + return &localMedium{} +} + +// FileGet reads a file from the local disk. +func (m *localMedium) FileGet(path string) (string, error) { + return filesystem.Read(filesystem.Local, path) +} + +// FileSet writes a file to the local disk. +func (m *localMedium) FileSet(path, content string) error { + return filesystem.Write(filesystem.Local, path, content) +} + +// Read reads a file from the local disk. +func (m *localMedium) Read(path string) (string, error) { + return filesystem.Read(filesystem.Local, path) +} + +// Write writes a file to the local disk. +func (m *localMedium) Write(path, content string) error { + return filesystem.Write(filesystem.Local, path, content) +} + +// EnsureDir creates a directory on the local disk. +func (m *localMedium) EnsureDir(path string) error { + return filesystem.EnsureDir(filesystem.Local, path) +} + +// IsFile checks if a path exists and is a file on the local disk. +func (m *localMedium) IsFile(path string) bool { + return filesystem.IsFile(filesystem.Local, path) +} diff --git a/workspace/service.go b/workspace/service.go new file mode 100644 index 0000000..f211f16 --- /dev/null +++ b/workspace/service.go @@ -0,0 +1,124 @@ +package workspace + +import ( + "encoding/json" + "fmt" + "path/filepath" + + "core/config" + "core/crypt/lib/lthn" + "core/crypt/lib/openpgp" + "core/filesystem" +) + +// NewService creates a new WorkspaceService. +func NewService(cfg *config.Config, medium filesystem.Medium) *Service { + return &Service{ + config: cfg, + workspaceList: make(map[string]string), + medium: medium, + } +} + +// ServiceStartup Startup initializes the service, loading the workspace list. +func (s *Service) ServiceStartup() error { + listPath := filepath.Join(s.config.WorkspacesDir, listFile) + + if s.medium.IsFile(listPath) { + content, err := s.medium.FileGet(listPath) + if err != nil { + return fmt.Errorf("failed to read workspace list: %w", err) + } + if err := json.Unmarshal([]byte(content), &s.workspaceList); err != nil { + fmt.Printf("Warning: could not parse workspace list: %v\n", err) + s.workspaceList = make(map[string]string) + } + } + + return s.SwitchWorkspace(defaultWorkspace) +} + +// CreateWorkspace creates a new, obfuscated workspace on the local medium. +func (s *Service) CreateWorkspace(identifier, password string) (string, error) { + realName := lthn.Hash(identifier) + workspaceID := lthn.Hash(fmt.Sprintf("workspace/%s", realName)) + workspacePath := filepath.Join(s.config.WorkspacesDir, workspaceID) + + if _, exists := s.workspaceList[workspaceID]; exists { + return "", fmt.Errorf("workspace for this identifier already exists") + } + + dirsToCreate := []string{"config", "log", "data", "files", "keys"} + for _, dir := range dirsToCreate { + if err := s.medium.EnsureDir(filepath.Join(workspacePath, dir)); err != nil { + return "", fmt.Errorf("failed to create workspace directory '%s': %w", dir, err) + } + } + + keyPair, err := openpgp.CreateKeyPair(workspaceID, password) + if err != nil { + return "", fmt.Errorf("failed to create workspace key pair: %w", err) + } + + keyFiles := map[string]string{ + filepath.Join(workspacePath, "keys", "key.pub"): keyPair.PublicKey, + filepath.Join(workspacePath, "keys", "key.priv"): keyPair.PrivateKey, + } + for path, content := range keyFiles { + if err := s.medium.FileSet(path, content); err != nil { + return "", fmt.Errorf("failed to write key file %s: %w", path, err) + } + } + + s.workspaceList[workspaceID] = keyPair.PublicKey + listData, err := json.MarshalIndent(s.workspaceList, "", " ") + if err != nil { + return "", fmt.Errorf("failed to marshal workspace list: %w", err) + } + + listPath := filepath.Join(s.config.WorkspacesDir, listFile) + if err := s.medium.FileSet(listPath, string(listData)); err != nil { + return "", fmt.Errorf("failed to write workspace list file: %w", err) + } + + return workspaceID, nil +} + +// SwitchWorkspace changes the active workspace. +func (s *Service) SwitchWorkspace(name string) error { + if name != defaultWorkspace { + if _, exists := s.workspaceList[name]; !exists { + return fmt.Errorf("workspace '%s' does not exist", name) + } + } + + path := filepath.Join(s.config.WorkspacesDir, name) + if err := s.medium.EnsureDir(path); err != nil { + return fmt.Errorf("failed to ensure workspace directory exists: %w", err) + } + + s.activeWorkspace = &Workspace{ + Name: name, + Path: path, + } + + return nil +} + +// WorkspaceFileGet retrieves a file from the active workspace. +func (s *Service) WorkspaceFileGet(filename string) (string, error) { + if s.activeWorkspace == nil { + return "", fmt.Errorf("no active workspace") + } + path := filepath.Join(s.activeWorkspace.Path, filename) + return s.medium.FileGet(path) +} + +// WorkspaceFileSet writes a file to the active workspace. +func (s *Service) WorkspaceFileSet(filename, content string) error { + if s.activeWorkspace == nil { + return fmt.Errorf("no active workspace") + } + path := filepath.Join(s.activeWorkspace.Path, filename) + return s.medium.FileSet(path, content) +} diff --git a/workspace/workspace.go b/workspace/workspace.go new file mode 100644 index 0000000..0d8c0a1 --- /dev/null +++ b/workspace/workspace.go @@ -0,0 +1,25 @@ +package workspace + +import ( + "core/config" + "core/filesystem" +) + +const ( + defaultWorkspace = "default" + listFile = "list.json" +) + +// Workspace represents a user's workspace. +type Workspace struct { + Name string + Path string +} + +// Service manages user workspaces. +type Service struct { + config *config.Config + activeWorkspace *Workspace + workspaceList map[string]string // Maps Workspace ID to Public Key + medium filesystem.Medium +} diff --git a/workspace/workspace_test.go b/workspace/workspace_test.go new file mode 100644 index 0000000..adacca8 --- /dev/null +++ b/workspace/workspace_test.go @@ -0,0 +1,157 @@ +package workspace + +import ( + "encoding/json" + "path/filepath" + "testing" + + "core/config" + "github.com/stretchr/testify/assert" +) + +// MockMedium implements the Medium interface for testing purposes. +type MockMedium struct { + Files map[string]string + Dirs map[string]bool +} + +func NewMockMedium() *MockMedium { + return &MockMedium{ + Files: make(map[string]string), + Dirs: make(map[string]bool), + } +} + +func (m *MockMedium) FileGet(path string) (string, error) { + content, ok := m.Files[path] + if !ok { + return "", assert.AnError // Simulate file not found error + } + return content, nil +} + +func (m *MockMedium) FileSet(path, content string) error { + m.Files[path] = content + return nil +} + +func (m *MockMedium) EnsureDir(path string) error { + m.Dirs[path] = true + return nil +} + +func (m *MockMedium) IsFile(path string) bool { + _, ok := m.Files[path] + return ok +} + +func (m *MockMedium) Read(path string) (string, error) { + return m.FileGet(path) +} + +func (m *MockMedium) Write(path, content string) error { + return m.FileSet(path, content) +} + +func TestNewService(t *testing.T) { + mockConfig := &config.Config{} // You might want to mock this further if its behavior is critical + mockMedium := NewMockMedium() + + service := NewService(mockConfig, mockMedium) + + assert.NotNil(t, service) + assert.Equal(t, mockConfig, service.config) + assert.Equal(t, mockMedium, service.medium) + assert.NotNil(t, service.workspaceList) + assert.Nil(t, service.activeWorkspace) // Initially no active workspace +} + +func TestServiceStartup(t *testing.T) { + mockConfig := &config.Config{ + WorkspacesDir: "/tmp/workspaces", + } + + // Test case 1: list.json exists and is valid + t.Run("existing valid list.json", func(t *testing.T) { + mockMedium := NewMockMedium() + + // Prepare a mock workspace list + expectedWorkspaceList := map[string]string{ + "workspace1": "pubkey1", + "workspace2": "pubkey2", + } + listContent, _ := json.MarshalIndent(expectedWorkspaceList, "", " ") + + listPath := filepath.Join(mockConfig.WorkspacesDir, listFile) + mockMedium.FileSet(listPath, string(listContent)) + + service := NewService(mockConfig, mockMedium) + err := service.ServiceStartup() + + assert.NoError(t, err) + assert.Equal(t, expectedWorkspaceList, service.workspaceList) + assert.NotNil(t, service.activeWorkspace) + assert.Equal(t, defaultWorkspace, service.activeWorkspace.Name) + assert.Equal(t, filepath.Join(mockConfig.WorkspacesDir, defaultWorkspace), service.activeWorkspace.Path) + }) + + // Test case 2: list.json does not exist + t.Run("no list.json", func(t *testing.T) { + mockMedium := NewMockMedium() // Fresh medium with no files + + service := NewService(mockConfig, mockMedium) + err := service.ServiceStartup() + + assert.NoError(t, err) + assert.NotNil(t, service.workspaceList) + assert.Empty(t, service.workspaceList) // Should be empty if no list.json + assert.NotNil(t, service.activeWorkspace) + assert.Equal(t, defaultWorkspace, service.activeWorkspace.Name) + assert.Equal(t, filepath.Join(mockConfig.WorkspacesDir, defaultWorkspace), service.activeWorkspace.Path) + }) + + // Test case 3: list.json exists but is invalid + t.Run("invalid list.json", func(t *testing.T) { + mockMedium := NewMockMedium() + + listPath := filepath.Join(mockConfig.WorkspacesDir, listFile) + mockMedium.FileSet(listPath, "{invalid json") // Invalid JSON + + service := NewService(mockConfig, mockMedium) + err := service.ServiceStartup() + + assert.NoError(t, err) // Error is logged, but startup continues + assert.NotNil(t, service.workspaceList) + assert.Empty(t, service.workspaceList) // Should be empty if invalid list.json + assert.NotNil(t, service.activeWorkspace) + assert.Equal(t, defaultWorkspace, service.activeWorkspace.Name) + assert.Equal(t, filepath.Join(mockConfig.WorkspacesDir, defaultWorkspace), service.activeWorkspace.Path) + }) +} + +func TestCreateWorkspace(t *testing.T) { + mockConfig := &config.Config{ + WorkspacesDir: "/tmp/workspaces", + } + mockMedium := NewMockMedium() + service := NewService(mockConfig, mockMedium) + + workspaceID, err := service.CreateWorkspace("test", "password") + assert.NoError(t, err) + assert.NotEmpty(t, workspaceID) +} + +func TestSwitchWorkspace(t *testing.T) { + mockConfig := &config.Config{ + WorkspacesDir: "/tmp/workspaces", + } + mockMedium := NewMockMedium() + service := NewService(mockConfig, mockMedium) + + workspaceID, err := service.CreateWorkspace("test", "password") + assert.NoError(t, err) + + err = service.SwitchWorkspace(workspaceID) + assert.NoError(t, err) + assert.Equal(t, workspaceID, service.activeWorkspace.Name) +}