Merge branch 'feature-core-integration' into new

This commit is contained in:
Snider 2026-02-08 22:00:01 +00:00
commit aba0698c24
46 changed files with 2758 additions and 0 deletions

6
Taskfile.yaml Normal file
View file

@ -0,0 +1,6 @@
version: '3'
tasks:
build:
cmds:
- go build -o build/bin/core cmd/app/main.go

1
cmd/app/frontend/dist/assets/app.js vendored Normal file
View file

@ -0,0 +1 @@
console.log("Hello from app.js!");

10
cmd/app/frontend/dist/index.html vendored Normal file
View file

@ -0,0 +1,10 @@
<!DOCTYPE html>
<html>
<head>
<title>Core</title>
</head>
<body>
<h1>Core</h1>
<script src="assets/app.js"></script>
</body>
</html>

31
cmd/app/main.go Normal file
View file

@ -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)
}
}

52
config/config.go Normal file
View file

@ -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)
}

175
config/service.go Normal file
View file

@ -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
}

165
config/service_test.go Normal file
View file

@ -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)
}
}

148
core.go Normal file
View file

@ -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
}

23
crypt/crypt.go Normal file
View file

@ -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
}

20
crypt/crypt_test.go Normal file
View file

@ -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"))
}

33
crypt/hash.go Normal file
View file

@ -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[:])
}
}

46
crypt/lib/lthn/hash.go Normal file
View file

@ -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
}

View file

@ -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")
}

16
crypt/lib/lthn/lthn.go Normal file
View file

@ -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',
}

View file

@ -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
}

226
crypt/lib/openpgp/key.go Normal file
View file

@ -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
}

View file

@ -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
}

39
crypt/lib/openpgp/sign.go Normal file
View file

@ -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
}

43
crypt/service.go Normal file
View file

@ -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
}

77
crypt/sum.go Normal file
View file

@ -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
}

BIN
display/apptray.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

32
display/display.go Normal file
View file

@ -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
}

32
display/menu.go Normal file
View file

@ -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)
}

146
display/service.go Normal file
View file

@ -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
}

74
display/tray.go Normal file
View file

@ -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)
}

28
display/window.go Normal file
View file

@ -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
}

27
docs/docs.go Normal file
View file

@ -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

54
docs/service.go Normal file
View file

@ -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)
}

0
docs/static/assets/style.css vendored Normal file
View file

0
docs/static/index.html vendored Normal file
View file

45
filesystem/client.go Normal file
View file

@ -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)
}

31
filesystem/client_test.go Normal file
View file

@ -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"])
}

29
filesystem/filesystem.go Normal file
View file

@ -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

View file

@ -0,0 +1,3 @@
package filesystem
import ()

View file

@ -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)
}

View file

@ -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"))
}

View file

@ -0,0 +1,6 @@
package local
// Medium implements the filesystem.Medium interface for the local disk.
type Medium struct {
root string
}

47
filesystem/mock.go Normal file
View file

@ -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)
}

125
filesystem/sftp/client.go Normal file
View file

@ -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)
}

19
filesystem/sftp/sftp.go Normal file
View file

@ -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
}

View file

@ -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
}

183
filesystem/webdav/webdav.go Normal file
View file

@ -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), "<D:collection/>")
}
// 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)
}

41
workspace/local.go Normal file
View file

@ -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)
}

124
workspace/service.go Normal file
View file

@ -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)
}

25
workspace/workspace.go Normal file
View file

@ -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
}

157
workspace/workspace_test.go Normal file
View file

@ -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)
}