From 2bfa3b725045776413888c973ea9fe7a5b5a5ad5 Mon Sep 17 00:00:00 2001 From: Snider Date: Fri, 24 Oct 2025 04:55:10 +0100 Subject: [PATCH] Add initial project structure with core functionality and basic files --- .gitignore | 3 + LICENSE.txt | 287 +++++++++++++++++++++++ Makefile | 2 + README.md | 42 +++- Taskfile.yaml | 6 + actions.go | 3 + cmd/app/frontend/dist/assets/app.js | 1 + cmd/app/frontend/dist/assets/apptray.png | Bin 0 -> 32953 bytes cmd/app/frontend/dist/index.html | 10 + cmd/app/main.go | 35 +++ config/config.go | 128 ++++++++++ config/config_test.go | 81 +++++++ config/header.go | 55 +++++ core.go | 139 +++++++++++ crypt/crypt.go | 23 ++ crypt/crypt_test.go | 20 ++ crypt/hash.go | 33 +++ crypt/lib/lthn/hash.go | 46 ++++ crypt/lib/lthn/hash_test.go | 48 ++++ crypt/lib/lthn/lthn.go | 16 ++ crypt/lib/openpgp/encrypt.go | 106 +++++++++ crypt/lib/openpgp/key.go | 226 ++++++++++++++++++ crypt/lib/openpgp/openpgp.go | 12 + crypt/lib/openpgp/sign.go | 39 +++ crypt/service.go | 43 ++++ crypt/sum.go | 77 ++++++ display/display.go | 154 ++++++++++++ display/header.go | 26 ++ display/menu.go | 32 +++ display/tray.go | 71 ++++++ display/window.go | 28 +++ docs/docs.go | 27 +++ docs/service.go | 54 +++++ docs/static/assets/style.css | 0 docs/static/index.html | 0 filesystem/client.go | 45 ++++ filesystem/client_test.go | 31 +++ filesystem/filesystem.go | 27 +++ filesystem/filesystem_test.go | 1 + filesystem/local/client.go | 83 +++++++ filesystem/local/client_test.go | 154 ++++++++++++ filesystem/local/local.go | 6 + filesystem/mock.go | 47 ++++ filesystem/sftp/client.go | 125 ++++++++++ filesystem/sftp/sftp.go | 19 ++ filesystem/webdav/client.go | 16 ++ filesystem/webdav/webdav.go | 183 +++++++++++++++ go.mod | 56 +++++ header.go | 45 ++++ workspace/local.go | 41 ++++ workspace/service.go | 124 ++++++++++ workspace/workspace.go | 25 ++ workspace/workspace_test.go | 157 +++++++++++++ 53 files changed, 3057 insertions(+), 1 deletion(-) create mode 100644 .gitignore create mode 100644 LICENSE.txt create mode 100644 Makefile create mode 100644 Taskfile.yaml create mode 100644 actions.go create mode 100644 cmd/app/frontend/dist/assets/app.js create mode 100644 cmd/app/frontend/dist/assets/apptray.png create mode 100644 cmd/app/frontend/dist/index.html create mode 100644 cmd/app/main.go create mode 100644 config/config.go create mode 100644 config/config_test.go create mode 100644 config/header.go create mode 100644 core.go create mode 100644 crypt/crypt.go create mode 100644 crypt/crypt_test.go create mode 100644 crypt/hash.go create mode 100644 crypt/lib/lthn/hash.go create mode 100644 crypt/lib/lthn/hash_test.go create mode 100644 crypt/lib/lthn/lthn.go create mode 100644 crypt/lib/openpgp/encrypt.go create mode 100644 crypt/lib/openpgp/key.go create mode 100644 crypt/lib/openpgp/openpgp.go create mode 100644 crypt/lib/openpgp/sign.go create mode 100644 crypt/service.go create mode 100644 crypt/sum.go create mode 100644 display/display.go create mode 100644 display/header.go create mode 100644 display/menu.go create mode 100644 display/tray.go create mode 100644 display/window.go create mode 100644 docs/docs.go create mode 100644 docs/service.go create mode 100644 docs/static/assets/style.css create mode 100644 docs/static/index.html create mode 100644 filesystem/client.go create mode 100644 filesystem/client_test.go create mode 100644 filesystem/filesystem.go create mode 100644 filesystem/filesystem_test.go create mode 100644 filesystem/local/client.go create mode 100644 filesystem/local/client_test.go create mode 100644 filesystem/local/local.go create mode 100644 filesystem/mock.go create mode 100644 filesystem/sftp/client.go create mode 100644 filesystem/sftp/sftp.go create mode 100644 filesystem/webdav/client.go create mode 100644 filesystem/webdav/webdav.go create mode 100644 go.mod create mode 100644 header.go create mode 100644 workspace/local.go create mode 100644 workspace/service.go create mode 100644 workspace/workspace.go create mode 100644 workspace/workspace_test.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..506762c8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +wails3 +build/ +vendor/ diff --git a/LICENSE.txt b/LICENSE.txt new file mode 100644 index 00000000..4153cd37 --- /dev/null +++ b/LICENSE.txt @@ -0,0 +1,287 @@ + EUROPEAN UNION PUBLIC LICENCE v. 1.2 + EUPL © the European Union 2007, 2016 + +This European Union Public Licence (the ‘EUPL’) applies to the Work (as defined +below) which is provided under the terms of this Licence. Any use of the Work, +other than as authorised under this Licence is prohibited (to the extent such +use is covered by a right of the copyright holder of the Work). + +The Work is provided under the terms of this Licence when the Licensor (as +defined below) has placed the following notice immediately following the +copyright notice for the Work: + + Licensed under the EUPL + +or has expressed by any other means his willingness to license under the EUPL. + +1. Definitions + +In this Licence, the following terms have the following meaning: + +- ‘The Licence’: this Licence. + +- ‘The Original Work’: the work or software distributed or communicated by the + Licensor under this Licence, available as Source Code and also as Executable + Code as the case may be. + +- ‘Derivative Works’: the works or software that could be created by the + Licensee, based upon the Original Work or modifications thereof. This Licence + does not define the extent of modification or dependence on the Original Work + required in order to classify a work as a Derivative Work; this extent is + determined by copyright law applicable in the country mentioned in Article 15. + +- ‘The Work’: the Original Work or its Derivative Works. + +- ‘The Source Code’: the human-readable form of the Work which is the most + convenient for people to study and modify. + +- ‘The Executable Code’: any code which has generally been compiled and which is + meant to be interpreted by a computer as a program. + +- ‘The Licensor’: the natural or legal person that distributes or communicates + the Work under the Licence. + +- ‘Contributor(s)’: any natural or legal person who modifies the Work under the + Licence, or otherwise contributes to the creation of a Derivative Work. + +- ‘The Licensee’ or ‘You’: any natural or legal person who makes any usage of + the Work under the terms of the Licence. + +- ‘Distribution’ or ‘Communication’: any act of selling, giving, lending, + renting, distributing, communicating, transmitting, or otherwise making + available, online or offline, copies of the Work or providing access to its + essential functionalities at the disposal of any other natural or legal + person. + +2. Scope of the rights granted by the Licence + +The Licensor hereby grants You a worldwide, royalty-free, non-exclusive, +sublicensable licence to do the following, for the duration of copyright vested +in the Original Work: + +- use the Work in any circumstance and for all usage, +- reproduce the Work, +- modify the Work, and make Derivative Works based upon the Work, +- communicate to the public, including the right to make available or display + the Work or copies thereof to the public and perform publicly, as the case may + be, the Work, +- distribute the Work or copies thereof, +- lend and rent the Work or copies thereof, +- sublicense rights in the Work or copies thereof. + +Those rights can be exercised on any media, supports and formats, whether now +known or later invented, as far as the applicable law permits so. + +In the countries where moral rights apply, the Licensor waives his right to +exercise his moral right to the extent allowed by law in order to make effective +the licence of the economic rights here above listed. + +The Licensor grants to the Licensee royalty-free, non-exclusive usage rights to +any patents held by the Licensor, to the extent necessary to make use of the +rights granted on the Work under this Licence. + +3. Communication of the Source Code + +The Licensor may provide the Work either in its Source Code form, or as +Executable Code. If the Work is provided as Executable Code, the Licensor +provides in addition a machine-readable copy of the Source Code of the Work +along with each copy of the Work that the Licensor distributes or indicates, in +a notice following the copyright notice attached to the Work, a repository where +the Source Code is easily and freely accessible for as long as the Licensor +continues to distribute or communicate the Work. + +4. Limitations on copyright + +Nothing in this Licence is intended to deprive the Licensee of the benefits from +any exception or limitation to the exclusive rights of the rights owners in the +Work, of the exhaustion of those rights or of other applicable limitations +thereto. + +5. Obligations of the Licensee + +The grant of the rights mentioned above is subject to some restrictions and +obligations imposed on the Licensee. Those obligations are the following: + +Attribution right: The Licensee shall keep intact all copyright, patent or +trademarks notices and all notices that refer to the Licence and to the +disclaimer of warranties. The Licensee must include a copy of such notices and a +copy of the Licence with every copy of the Work he/she distributes or +communicates. The Licensee must cause any Derivative Work to carry prominent +notices stating that the Work has been modified and the date of modification. + +Copyleft clause: If the Licensee distributes or communicates copies of the +Original Works or Derivative Works, this Distribution or Communication will be +done under the terms of this Licence or of a later version of this Licence +unless the Original Work is expressly distributed only under this version of the +Licence — for example by communicating ‘EUPL v. 1.2 only’. The Licensee +(becoming Licensor) cannot offer or impose any additional terms or conditions on +the Work or Derivative Work that alter or restrict the terms of the Licence. + +Compatibility clause: If the Licensee Distributes or Communicates Derivative +Works or copies thereof based upon both the Work and another work licensed under +a Compatible Licence, this Distribution or Communication can be done under the +terms of this Compatible Licence. For the sake of this clause, ‘Compatible +Licence’ refers to the licences listed in the appendix attached to this Licence. +Should the Licensee's obligations under the Compatible Licence conflict with +his/her obligations under this Licence, the obligations of the Compatible +Licence shall prevail. + +Provision of Source Code: When distributing or communicating copies of the Work, +the Licensee will provide a machine-readable copy of the Source Code or indicate +a repository where this Source will be easily and freely available for as long +as the Licensee continues to distribute or communicate the Work. + +Legal Protection: This Licence does not grant permission to use the trade names, +trademarks, service marks, or names of the Licensor, except as required for +reasonable and customary use in describing the origin of the Work and +reproducing the content of the copyright notice. + +6. Chain of Authorship + +The original Licensor warrants that the copyright in the Original Work granted +hereunder is owned by him/her or licensed to him/her and that he/she has the +power and authority to grant the Licence. + +Each Contributor warrants that the copyright in the modifications he/she brings +to the Work are owned by him/her or licensed to him/her and that he/she has the +power and authority to grant the Licence. + +Each time You accept the Licence, the original Licensor and subsequent +Contributors grant You a licence to their contributions to the Work, under the +terms of this Licence. + +7. Disclaimer of Warranty + +The Work is a work in progress, which is continuously improved by numerous +Contributors. It is not a finished work and may therefore contain defects or +‘bugs’ inherent to this type of development. + +For the above reason, the Work is provided under the Licence on an ‘as is’ basis +and without warranties of any kind concerning the Work, including without +limitation merchantability, fitness for a particular purpose, absence of defects +or errors, accuracy, non-infringement of intellectual property rights other than +copyright as stated in Article 6 of this Licence. + +This disclaimer of warranty is an essential part of the Licence and a condition +for the grant of any rights to the Work. + +8. Disclaimer of Liability + +Except in the cases of wilful misconduct or damages directly caused to natural +persons, the Licensor will in no event be liable for any direct or indirect, +material or moral, damages of any kind, arising out of the Licence or of the use +of the Work, including without limitation, damages for loss of goodwill, work +stoppage, computer failure or malfunction, loss of data or any commercial +damage, even if the Licensor has been advised of the possibility of such damage. +However, the Licensor will be liable under statutory product liability laws as +far such laws apply to the Work. + +9. Additional agreements + +While distributing the Work, You may choose to conclude an additional agreement, +defining obligations or services consistent with this Licence. However, if +accepting obligations, You may act only on your own behalf and on your sole +responsibility, not on behalf of the original Licensor or any other Contributor, +and only if You agree to indemnify, defend, and hold each Contributor harmless +for any liability incurred by, or claims asserted against such Contributor by +the fact You have accepted any warranty or additional liability. + +10. Acceptance of the Licence + +The provisions of this Licence can be accepted by clicking on an icon ‘I agree’ +placed under the bottom of a window displaying the text of this Licence or by +affirming consent in any other similar way, in accordance with the rules of +applicable law. Clicking on that icon indicates your clear and irrevocable +acceptance of this Licence and all of its terms and conditions. + +Similarly, you irrevocably accept this Licence and all of its terms and +conditions by exercising any rights granted to You by Article 2 of this Licence, +such as the use of the Work, the creation by You of a Derivative Work or the +Distribution or Communication by You of the Work or copies thereof. + +11. Information to the public + +In case of any Distribution or Communication of the Work by means of electronic +communication by You (for example, by offering to download the Work from a +remote location) the distribution channel or media (for example, a website) must +at least provide to the public the information requested by the applicable law +regarding the Licensor, the Licence and the way it may be accessible, concluded, +stored and reproduced by the Licensee. + +12. Termination of the Licence + +The Licence and the rights granted hereunder will terminate automatically upon +any breach by the Licensee of the terms of the Licence. + +Such a termination will not terminate the licences of any person who has +received the Work from the Licensee under the Licence, provided such persons +remain in full compliance with the Licence. + +13. Miscellaneous + +Without prejudice of Article 9 above, the Licence represents the complete +agreement between the Parties as to the Work. + +If any provision of the Licence is invalid or unenforceable under applicable +law, this will not affect the validity or enforceability of the Licence as a +whole. Such provision will be construed or reformed so as necessary to make it +valid and enforceable. + +The European Commission may publish other linguistic versions or new versions of +this Licence or updated versions of the Appendix, so far this is required and +reasonable, without reducing the scope of the rights granted by the Licence. New +versions of the Licence will be published with a unique version number. + +All linguistic versions of this Licence, approved by the European Commission, +have identical value. Parties can take advantage of the linguistic version of +their choice. + +14. Jurisdiction + +Without prejudice to specific agreement between parties, + +- any litigation resulting from the interpretation of this License, arising + between the European Union institutions, bodies, offices or agencies, as a + Licensor, and any Licensee, will be subject to the jurisdiction of the Court + of Justice of the European Union, as laid down in article 272 of the Treaty on + the Functioning of the European Union, + +- any litigation arising between other parties and resulting from the + interpretation of this License, will be subject to the exclusive jurisdiction + of the competent court where the Licensor resides or conducts its primary + business. + +15. Applicable Law + +Without prejudice to specific agreement between parties, + +- this Licence shall be governed by the law of the European Union Member State + where the Licensor has his seat, resides or has his registered office, + +- this licence shall be governed by Belgian law if the Licensor has no seat, + residence or registered office inside a European Union Member State. + +Appendix + +‘Compatible Licences’ according to Article 5 EUPL are: + +- GNU General Public License (GPL) v. 2, v. 3 +- GNU Affero General Public License (AGPL) v. 3 +- Open Software License (OSL) v. 2.1, v. 3.0 +- Eclipse Public License (EPL) v. 1.0 +- CeCILL v. 2.0, v. 2.1 +- Mozilla Public Licence (MPL) v. 2 +- GNU Lesser General Public Licence (LGPL) v. 2.1, v. 3 +- Creative Commons Attribution-ShareAlike v. 3.0 Unported (CC BY-SA 3.0) for + works other than software +- European Union Public Licence (EUPL) v. 1.1, v. 1.2 +- Québec Free and Open-Source Licence — Reciprocity (LiLiQ-R) or Strong + Reciprocity (LiLiQ-R+). + +The European Commission may update this Appendix to later versions of the above +licences without producing a new version of the EUPL, as long as they provide +the rights granted in Article 2 of this Licence and protect the covered Source +Code from exclusive appropriation. + +All other changes or additions to this Appendix require the production of a new +EUPL version. diff --git a/Makefile b/Makefile new file mode 100644 index 00000000..ab6bd3a4 --- /dev/null +++ b/Makefile @@ -0,0 +1,2 @@ +all: + go build -o build/bin/core cmd/app/main.go \ No newline at end of file diff --git a/README.md b/README.md index e8217fe2..14171f2a 100644 --- a/README.md +++ b/README.md @@ -1 +1,41 @@ -# Core \ No newline at end of file +# Core + +A Helper for GoLang projects, who also use, but not exclusive to Wails.io v3+ + +You need a file called apptray.png in your assets folder +```go +package main + +import ( + "embed" + + "github.com/Snider/Core" + "github.com/Snider/Core/display" + "github.com/wailsapp/wails/v3/pkg/application" +) + +//go:embed all:public/* +var assets embed.FS + +func main() { + + app := application.New(application.Options{ + Assets: application.AssetOptions{ + Handler: application.AssetFileServerFS(assets), + }, + }) + + app.RegisterService(application.NewService(core.Service( + core.WithWails(app), // Provides the Wails application instance to core services + core.WithAssets(assets), // Provides the embed.FS to core services + core.WithService(display.Register), // Provides the ability to open windows + core.WithService(config.Register), // Provides the ability to persist UI state (windows reopen where they closed) + core.WithServiceLock(), // locks core from accepting new services blocking access to IPC + ))) + + err := app.Run() + if err != nil { + panic(err) + } +} +``` \ No newline at end of file diff --git a/Taskfile.yaml b/Taskfile.yaml new file mode 100644 index 00000000..877af8c3 --- /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/actions.go b/actions.go new file mode 100644 index 00000000..66e10079 --- /dev/null +++ b/actions.go @@ -0,0 +1,3 @@ +package core + +type ActionServiceStartup struct{} diff --git a/cmd/app/frontend/dist/assets/app.js b/cmd/app/frontend/dist/assets/app.js new file mode 100644 index 00000000..28abaa36 --- /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/assets/apptray.png b/cmd/app/frontend/dist/assets/apptray.png new file mode 100644 index 0000000000000000000000000000000000000000..0778fc6187abdbb56b8f7f4d0b9c78399642e59b GIT binary patch literal 32953 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 wwhHecO_CP>2BD$qDh? + + + Core + + +

Core

+ + + diff --git a/cmd/app/main.go b/cmd/app/main.go new file mode 100644 index 00000000..abfa33da --- /dev/null +++ b/cmd/app/main.go @@ -0,0 +1,35 @@ +package main + +import ( + "embed" + + "github.com/Snider/Core" + "github.com/Snider/Core/config" + "github.com/Snider/Core/display" + "github.com/wailsapp/wails/v3/pkg/application" +) + +//go:embed all:frontend/dist +var assets embed.FS + +func main() { + + app := application.New(application.Options{ + Assets: application.AssetOptions{ + Handler: application.AssetFileServerFS(assets), + }, + }) + + app.RegisterService(application.NewService(core.Service( + core.WithWails(app), // Provides the Wails application instance to core services + core.WithAssets(assets), // Provides the embed.FS to core services + core.WithService(display.Register), // Provides the ability to open windows + core.WithService(config.Register), // Provides the ability to persist UI state (windows reopen where they closed) + core.WithServiceLock(), // locks core from accepting new services blocking access to IPC + ))) + + err := app.Run() + if err != nil { + panic(err) + } +} diff --git a/config/config.go b/config/config.go new file mode 100644 index 00000000..62f39761 --- /dev/null +++ b/config/config.go @@ -0,0 +1,128 @@ +package config + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + + core "github.com/Snider/Core" + "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. +var service *Config + +// NewService creates and initializes a new configuration service. +// It loads an existing configuration or creates a default one if not found. +func Register(c *core.Core) error { + homeDir, err := os.UserHomeDir() + if err != nil { + return 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) + + service = &Config{ + core: c, + UserHomeDir: userHomeDir, + ConfigDir: configDir, + DataDir: filepath.Join(userHomeDir, "data"), + WorkspacesDir: filepath.Join(userHomeDir, "workspaces"), + DefaultRoute: "/", + Features: []string{}, + Language: "en", + } + + return c.RegisterModule("config", service) +} + +// 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 +} + +// Save writes the current configuration to config.json. +func (c *Config) Save() error { + configPath := filepath.Join(c.ConfigDir, configFileName) + + data, err := json.MarshalIndent(*c, "", " ") + 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 (c *Config) IsFeatureEnabled(feature string) bool { + for _, f := range c.Features { + if f == feature { + return true + } + } + return false +} + +// EnableFeature adds a feature to the list of enabled features and saves the config. +func (c *Config) EnableFeature(feature string) error { + if c.IsFeatureEnabled(feature) { + return nil + } + c.Features = append(c.Features, feature) + if err := c.Save(); err != nil { + return fmt.Errorf("failed to save config after enabling feature %s: %w", feature, err) + } + return nil +} diff --git a/config/config_test.go b/config/config_test.go new file mode 100644 index 00000000..b6757c4e --- /dev/null +++ b/config/config_test.go @@ -0,0 +1,81 @@ +package config + +import ( + "os" + "path/filepath" + "testing" + + "github.com/Snider/Core" +) + +// setupTestEnv creates a temporary home directory for testing. +func setupTestEnv(t *testing.T) (string, func()) { + tempHomeDir, err := os.MkdirTemp("", "test_home") + if err != nil { + t.Fatalf("Failed to create temp home directory: %v", err) + } + + oldHome := os.Getenv("HOME") + os.Setenv("HOME", tempHomeDir) + + cleanup := func() { + os.Setenv("HOME", oldHome) + os.RemoveAll(tempHomeDir) + } + + return tempHomeDir, cleanup +} + +// newTestCore creates a new, empty core instance for testing. +func newTestCore(t *testing.T) *core.Core { + c := core.Service() + if c == nil { + t.Fatalf("core.Service() returned a nil instance, which is not expected for a test setup") + } + return c +} + +func TestRegister(t *testing.T) { + tempHomeDir, cleanup := setupTestEnv(t) + defer cleanup() + + c := newTestCore(t) + + if err := Register(c); err != nil { + t.Fatalf("Register() failed: %v", err) + } + + mod := c.Mod("config") + if mod == nil { + t.Fatalf("Failed to get config module from core instance") + } + + cfg, ok := mod.(*Config) + if !ok { + t.Fatalf("Module is not of type *Config") + } + + expectedUserHomeDir := filepath.Join(tempHomeDir, appName) + expectedConfigDir := filepath.Join(expectedUserHomeDir, "config") + expectedDataDir := filepath.Join(expectedUserHomeDir, "data") + expectedWorkspacesDir := filepath.Join(expectedUserHomeDir, "workspaces") + + tests := []struct { + name string + actual string + expected string + }{ + {"UserHomeDir", cfg.UserHomeDir, expectedUserHomeDir}, + {"ConfigDir", cfg.ConfigDir, expectedConfigDir}, + {"DataDir", cfg.DataDir, expectedDataDir}, + {"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) + } + }) + } +} diff --git a/config/header.go b/config/header.go new file mode 100644 index 00000000..ae5da433 --- /dev/null +++ b/config/header.go @@ -0,0 +1,55 @@ +package config + +import ( + "fmt" + "reflect" + "strings" + + core "github.com/Snider/Core" +) + +// Config holds the resolved paths and user-configurable settings for the application. +type Config struct { + // --- Dynamic Paths (not stored in config.json) --- + core *core.Core + 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/core.go b/core.go new file mode 100644 index 00000000..eb2a1199 --- /dev/null +++ b/core.go @@ -0,0 +1,139 @@ +package core + +import ( + "context" + "embed" + "errors" + "fmt" + + "github.com/wailsapp/wails/v3/pkg/application" +) + +// Service initialises a Core instance using the provided options and performs the necessary setup. +func Service(opts ...Option) *Core { + c := &Core{ + mods: make(map[string]any), + } + // Apply all options (including WithService calls) + for _, o := range opts { + if err := o(c); err != nil { + return nil + } + } + c.once.Do(func() { + // any one‑time initialisation you need + instance = c + c.initErr = nil + }) + if c.initErr != nil { + return nil + } + if c.serviceLock { + c.servicesLocked = true + } + return c +} + +// WithService wraps a function that registers a package or module with the provided Core instance as an Option. +func WithService(reg func(*Core) error) Option { + return func(c *Core) error { + return reg(c) + } +} + +// WithWails sets the Wails application instance to the Core configuration and returns an Option function. +func WithWails(app *application.App) Option { + return func(c *Core) error { + c.App = app + return nil + } +} + +// WithAssets sets the provided embedded filesystem as the assets for the Core instance. +func WithAssets(fs embed.FS) Option { + return func(c *Core) error { + c.assets = fs + return nil + } +} + +func WithServiceLock() Option { + return func(c *Core) error { + c.serviceLock = true + return nil + } +} + +// ServiceStartup initializes the service during application startup by executing the ActionServiceStartup message. +func (c *Core) ServiceStartup(ctx context.Context, options application.ServiceOptions) error { + return c.ACTION(ActionServiceStartup{}) +} + +// ACTION processes a Message by invoking all registered handlers and returns an aggregated error if any handlers fail. +func (c *Core) ACTION(msg Message) error { + c.ipcMu.RLock() + handlers := append([]func(*Core, Message) error(nil), c.ipcHandlers...) + c.ipcMu.RUnlock() + + var agg error + for _, h := range handlers { + if err := h(c, msg); err != nil { + agg = fmt.Errorf("%w; %v", agg, err) + } + } + return agg +} + +// RegisterAction adds a single handler function to the list of registered IPC handlers in a thread-safe manner. +func (c *Core) RegisterAction(handler func(*Core, Message) error) { + c.ipcMu.Lock() + c.ipcHandlers = append(c.ipcHandlers, handler) + c.ipcMu.Unlock() +} + +// RegisterActions registers multiple IPC handler functions to be executed during message processing in a thread-safe manner. +func (c *Core) RegisterActions(handlers ...func(*Core, Message) error) { + c.ipcMu.Lock() + c.ipcHandlers = append(c.ipcHandlers, handlers...) + c.ipcMu.Unlock() +} + +// RegisterModule inserts an API object under a unique name. +func (c *Core) RegisterModule(name string, api any) error { + + if c.servicesLocked { + return fmt.Errorf("core: module %q is not permitted by the serviceLock setting", name) + } + + if name == "" { + return errors.New("core: module name cannot be empty") + } + c.modMu.Lock() + defer c.modMu.Unlock() + if _, exists := c.mods[name]; exists { + return fmt.Errorf("core: module %q already registered", name) + } + c.mods[name] = api + return nil +} + +// Mod caller must type‑assert the result to the concrete API type it expects. +func (c *Core) Mod(name string) any { + c.modMu.RLock() + api, ok := c.mods[name] + c.modMu.RUnlock() + if !ok { + return nil + } + return api +} + +// Mod is a generic helper to get a module of expected type T. +func Mod[T any](c *Core, name string) *T { + raw := c.Mod(name) + typed, ok := raw.(*T) + if !ok { + return nil + } + return typed +} diff --git a/crypt/crypt.go b/crypt/crypt.go new file mode 100644 index 00000000..49d590e7 --- /dev/null +++ b/crypt/crypt.go @@ -0,0 +1,23 @@ +package crypt + +import ( + "github.com/Snider/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 00000000..2cde5070 --- /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 00000000..5ca021f0 --- /dev/null +++ b/crypt/hash.go @@ -0,0 +1,33 @@ +package crypt + +import ( + "crypto/md5" + "crypto/sha1" + "crypto/sha256" + "crypto/sha512" + "encoding/hex" + + "github.com/Snider/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 00000000..c9f0ac09 --- /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 00000000..463ea5d6 --- /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 00000000..5a1f6e1d --- /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 00000000..4ed1f771 --- /dev/null +++ b/crypt/lib/openpgp/encrypt.go @@ -0,0 +1,106 @@ +package openpgp + +import ( + "bytes" + "fmt" + "io" + "strings" + + "github.com/ProtonMail/go-crypto/openpgp" + "github.com/ProtonMail/go-crypto/openpgp/armor" + "github.com/Snider/Core/filesystem" +) + +// 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 00000000..8ee57eda --- /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" + "github.com/Snider/Core/crypt/lib/lthn" + "github.com/Snider/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 00000000..1e604a55 --- /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 00000000..4d999976 --- /dev/null +++ b/crypt/lib/openpgp/sign.go @@ -0,0 +1,39 @@ +package openpgp + +import ( + "bytes" + "fmt" + "strings" + + "github.com/ProtonMail/go-crypto/openpgp" + "github.com/Snider/Core/filesystem" +) + +// 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 00000000..8690ecf5 --- /dev/null +++ b/crypt/service.go @@ -0,0 +1,43 @@ +package crypt + +import ( + "context" + "fmt" + "log" + "path/filepath" + + "github.com/Snider/Core/config" + "github.com/Snider/Core/crypt/lib/openpgp" + "github.com/Snider/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 00000000..74530374 --- /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/display.go b/display/display.go new file mode 100644 index 00000000..40907bd1 --- /dev/null +++ b/display/display.go @@ -0,0 +1,154 @@ +package display + +import ( + "context" + "fmt" + + "github.com/Snider/Core" + "github.com/wailsapp/wails/v3/pkg/application" + "github.com/wailsapp/wails/v3/pkg/events" +) + +type ActionOpenWindow struct { + Target string +} + +var instance *API + +func Register(c *core.Core) error { + instance = &API{ + core: c, + windowHandles: make(map[string]*application.WebviewWindow), + } + if err := c.RegisterModule("display", instance); err != nil { + return err + } + c.RegisterAction(handleActionCall) + return nil +} + +func handleActionCall(c *core.Core, msg core.Message) error { + switch m := msg.(type) { + case *ActionOpenWindow: + instance.OpenWindow(m.Target, application.WebviewWindowOptions{ + Title: "Core", + Height: 900, + Width: 1280, + URL: m.Target, + }) + return nil + case core.ActionServiceStartup: + err := instance.ServiceStartup(context.Background(), application.ServiceOptions{}) + if err != nil { + return err + } + return nil + default: + c.App.Logger.Error("Unknown message type", "type", fmt.Sprintf("%T", m)) + return nil + } +} + +func (d *API) analyzeScreens() { + d.core.App.Logger.Info("Screen analysis", "count", len(d.core.App.Screen.GetAll())) + + primary := d.core.App.Screen.GetPrimary() + if primary != nil { + d.core.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: + d.core.App.Logger.Info("Standard DPI display", "screen", primary.Name) + case scaleFactor == 1.25: + d.core.App.Logger.Info("125% scaled display", "screen", primary.Name) + case scaleFactor == 1.5: + d.core.App.Logger.Info("150% scaled display", "screen", primary.Name) + case scaleFactor == 2.0: + d.core.App.Logger.Info("High DPI display (200%)", "screen", primary.Name) + default: + d.core.App.Logger.Info("Custom scale display", + "screen", primary.Name, + "scale", scaleFactor, + ) + } + } else { + d.core.App.Logger.Info("No primary screen found") + } + + for i, screen := range d.core.App.Screen.GetAll() { + d.core.App.Logger.Info("Screen details", + "index", i, + "name", screen.Name, + "primary", screen.IsPrimary, + "bounds", screen.Bounds, + "scaleFactor", screen.ScaleFactor, + ) + } +} + +func (d *API) monitorScreenChanges() { + // Monitor for screen configuration changes + d.core.App.Event.OnApplicationEvent(events.Common.ThemeChanged, func(event *application.ApplicationEvent) { + d.core.App.Logger.Info("Screen configuration changed") + + // Re-analyze screens + d.core.App.Logger.Info("Updated screen count", "count", len(d.core.App.Screen.GetAll())) + + // Could reposition windows here if needed + }) +} + +func (d *API) ShowEnvironmentDialog() { + envInfo := d.core.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, + d.core.App.Env.IsDarkMode()) // Use d.core.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 := d.core.App.Dialog.Info() + dialog.SetTitle("Environment Information") + dialog.SetMessage(details) + dialog.Show() +} + +func (d *API) ServiceStartup(ctx context.Context, options application.ServiceOptions) error { + d.core.App.Logger.Info("Display service starting up") + d.analyzeScreens() + d.monitorScreenChanges() + d.buildMenu() + d.systemTray() + d.core.App.Window.NewWithOptions(application.WebviewWindowOptions{ + Title: "Core", + Height: 900, + Width: 1280, + URL: "/", + }) + return nil +} diff --git a/display/header.go b/display/header.go new file mode 100644 index 00000000..c17adf7f --- /dev/null +++ b/display/header.go @@ -0,0 +1,26 @@ +package display + +import ( + "github.com/Snider/Core" + "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 API struct { + // --- Injected Dependencies --- + core *core.Core + + windowHandles map[string]*application.WebviewWindow +} diff --git a/display/menu.go b/display/menu.go new file mode 100644 index 00000000..b01cd2be --- /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 (d *API) buildMenu() { + appMenu := d.core.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) + + d.core.App.Menu.Set(appMenu) +} diff --git a/display/tray.go b/display/tray.go new file mode 100644 index 00000000..7f5156c5 --- /dev/null +++ b/display/tray.go @@ -0,0 +1,71 @@ +package display + +import ( + _ "embed" + + "github.com/wailsapp/wails/v3/pkg/application" +) + +// setupTray configures and creates the system tray icon and menu. +func (d *API) systemTray() { + + systray := d.core.App.SystemTray.New() + systray.SetTooltip("Lethean Desktop") + systray.SetLabel("hey") + //appTrayIcon, _ := d.assets.ReadFile("assets/apptray.png") + // + //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 := d.core.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 := d.core.App.Menu.New() + trayMenu.Add("Open Desktop").OnClick(func(ctx *application.Context) { + for _, window := range d.core.App.Window.GetAll() { + window.Show() + } + }) + trayMenu.Add("Close Desktop").OnClick(func(ctx *application.Context) { + for _, window := range d.core.App.Window.GetAll() { + window.Hide() + } + }) + + trayMenu.Add("Environment Info").OnClick(func(ctx *application.Context) { + d.ShowEnvironmentDialog() + }) + // Add brand-specific menu items + //switch d.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) { + d.core.App.Quit() + }) + + systray.SetMenu(trayMenu) +} diff --git a/display/window.go b/display/window.go new file mode 100644 index 00000000..15d2c0e0 --- /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 (d *API) OpenWindow(name string, options application.WebviewWindowOptions) { + // Check if a window with that name already exists + if window, exists := d.core.App.Window.GetByName(name); exists { + window.Focus() + return + } + + window := d.core.App.Window.NewWithOptions(options) + d.windowHandles[name] = window + window.Show() +} + +// SelectDirectory opens a directory selection dialog and returns the selected path. +func (d *API) 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 00000000..0ac0a43b --- /dev/null +++ b/docs/docs.go @@ -0,0 +1,27 @@ +package docs + +import ( + "embed" + + "github.com/Snider/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.Display // 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 00000000..16b896ba --- /dev/null +++ b/docs/service.go @@ -0,0 +1,54 @@ +package docs + +import ( + "embed" + "net/http" + "strings" + + "github.com/Snider/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.Display) { + 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 00000000..e69de29b diff --git a/docs/static/index.html b/docs/static/index.html new file mode 100644 index 00000000..e69de29b diff --git a/filesystem/client.go b/filesystem/client.go new file mode 100644 index 00000000..52adc53e --- /dev/null +++ b/filesystem/client.go @@ -0,0 +1,45 @@ +package filesystem + +import ( + "github.com/Snider/Core/filesystem/sftp" + "github.com/Snider/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 00000000..5bf1a5cc --- /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 00000000..56fde025 --- /dev/null +++ b/filesystem/filesystem.go @@ -0,0 +1,27 @@ +package filesystem + +// 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 00000000..e4b5af92 --- /dev/null +++ b/filesystem/filesystem_test.go @@ -0,0 +1 @@ +package filesystem diff --git a/filesystem/local/client.go b/filesystem/local/client.go new file mode 100644 index 00000000..0efe1719 --- /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 00000000..ff3dce72 --- /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 00000000..61f2447b --- /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 00000000..e97327b4 --- /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 00000000..a745a90c --- /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 00000000..cf9e2e11 --- /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 00000000..7ed4f741 --- /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 00000000..db0ac663 --- /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 00000000..aaf978df --- /dev/null +++ b/go.mod @@ -0,0 +1,56 @@ +module github.com/Snider/Core + +go 1.25 + +require ( + github.com/ProtonMail/go-crypto v1.3.0 + github.com/adrg/xdg v0.5.3 + github.com/pkg/sftp v1.13.10 + github.com/skeema/knownhosts v1.3.2 + github.com/stretchr/testify v1.11.1 + github.com/wailsapp/wails/v3 v3.0.0-alpha.36 + golang.org/x/crypto v0.43.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/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.45.0 // indirect + golang.org/x/sys v0.37.0 // indirect + golang.org/x/text v0.30.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/header.go b/header.go new file mode 100644 index 00000000..96970bb7 --- /dev/null +++ b/header.go @@ -0,0 +1,45 @@ +package core + +import ( + "embed" + "sync" + + "github.com/wailsapp/wails/v3/pkg/application" +) + +type Contract struct { + DontPanic bool + DisableLogging bool +} +type Ipc struct { + Target string +} + +var allowedModules = map[string]bool{ + "docs": true, + "display": true, + // add more names here if you want to restrict what can be loaded +} + +type Message interface{} +type Core struct { + once sync.Once + initErr error + App *application.App + assets embed.FS + + modMu sync.RWMutex + mods map[string]any + ipcMu sync.RWMutex + ipcHandlers []func(*Core, Message) error + serviceLock bool + servicesLocked bool +} + +type Option func(*Core) error + +var ( + instance *Core + once sync.Once + initErr error +) diff --git a/workspace/local.go b/workspace/local.go new file mode 100644 index 00000000..6f750cdc --- /dev/null +++ b/workspace/local.go @@ -0,0 +1,41 @@ +package workspace + +import "github.com/Snider/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 00000000..b529122b --- /dev/null +++ b/workspace/service.go @@ -0,0 +1,124 @@ +package workspace + +import ( + "encoding/json" + "fmt" + "path/filepath" + + "github.com/Snider/Core/config" + "github.com/Snider/Core/crypt/lib/lthn" + "github.com/Snider/Core/crypt/lib/openpgp" + "github.com/Snider/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 00000000..503d1139 --- /dev/null +++ b/workspace/workspace.go @@ -0,0 +1,25 @@ +package workspace + +import ( + "github.com/Snider/Core/config" + "github.com/Snider/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 00000000..adacca80 --- /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) +}