From 0a33d1b68d6bb41602b65c5660b75dc3e486caf1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aur=C3=A9lie=20DELHAIE?= Date: Tue, 2 Sep 2025 22:32:07 +0200 Subject: [PATCH] wip 0.0.5 --- build.sh | 2 +- cmd/cli/commands/list/list.go | 2 +- cmd/cli/commands/login/login.go | 60 +++++++++++ cmd/cli/commands/logout/logout.go | 43 ++++++++ cmd/cli/commands/pull/pull.go | 2 +- cmd/cli/commands/sync/sync.go | 2 +- cmd/cli/commands/version/version.go | 2 +- cmd/cli/main.go | 4 + .../tools/prompt/credentials/credentials.go | 99 ++++++++++++++++++- cmd/server/api/api.go | 8 +- cmd/server/api/middlewares.go | 4 +- cmd/server/main.go | 20 +++- cmd/server/main_windows.go | 18 +++- cmd/server/runner.go | 18 +++- pkg/repository/repository.go | 18 +++- 15 files changed, 288 insertions(+), 14 deletions(-) create mode 100644 cmd/cli/commands/login/login.go create mode 100644 cmd/cli/commands/logout/logout.go diff --git a/build.sh b/build.sh index 6b585c4..58065de 100755 --- a/build.sh +++ b/build.sh @@ -35,7 +35,7 @@ fi ## SERVER -platforms=("linux/amd64" "linux/arm64" "linux/riscv64" "linux/ppc64le") +platforms=("linux/amd64" "linux/arm64" "linux/riscv64" "linux/ppc64le", "windows/amd64") for platform in "${platforms[@]}"; do echo "* Compiling server for $platform..." diff --git a/cmd/cli/commands/list/list.go b/cmd/cli/commands/list/list.go index 6e6ad74..cc22915 100644 --- a/cmd/cli/commands/list/list.go +++ b/cmd/cli/commands/list/list.go @@ -43,7 +43,7 @@ func (p *ListCmd) Execute(_ context.Context, f *flag.FlagSet, _ ...interface{}) return subcommands.ExitUsageError } - username, password, err := credentials.Read() + username, password, err := credentials.Read(f.Arg(0)) if err != nil { fmt.Fprintf(os.Stderr, "error: failed to read std output: %s", err) return subcommands.ExitFailure diff --git a/cmd/cli/commands/login/login.go b/cmd/cli/commands/login/login.go new file mode 100644 index 0000000..294c26b --- /dev/null +++ b/cmd/cli/commands/login/login.go @@ -0,0 +1,60 @@ +package login + +import ( + "cloudsave/cmd/cli/tools/prompt/credentials" + "cloudsave/pkg/remote/client" + "context" + "flag" + "fmt" + "os" + + "github.com/google/subcommands" +) + +type ( + LoginCmd struct { + } +) + +func (*LoginCmd) Name() string { return "login" } +func (*LoginCmd) Synopsis() string { return "save IN PLAIN TEXT your credentials" } +func (*LoginCmd) Usage() string { + return `Usage: cloudsave login + +Warning: this command saves the login into a plain text json file + +Options: +` +} + +func (p *LoginCmd) SetFlags(f *flag.FlagSet) { +} + +func (p *LoginCmd) Execute(_ context.Context, f *flag.FlagSet, _ ...interface{}) subcommands.ExitStatus { + if f.NArg() != 1 { + fmt.Fprintf(os.Stderr, "error: this command take 1 argument") + return subcommands.ExitUsageError + } + + server := f.Arg(0) + + username, password, err := credentials.Read(server) + if err != nil { + fmt.Fprintf(os.Stderr, "error: failed to read std output: %s", err) + return subcommands.ExitFailure + } + + cli := client.New(server, username, password) + if _, err := cli.Version(); err != nil { + fmt.Fprintf(os.Stderr, "error: failed to login: %s", err) + return subcommands.ExitFailure + } + + if err := credentials.Login(username, password, server); err != nil { + fmt.Fprintf(os.Stderr, "error: failed to save login: %s", err) + return subcommands.ExitFailure + } + + fmt.Println("login information saved!") + return subcommands.ExitSuccess +} diff --git a/cmd/cli/commands/logout/logout.go b/cmd/cli/commands/logout/logout.go new file mode 100644 index 0000000..9ae7d42 --- /dev/null +++ b/cmd/cli/commands/logout/logout.go @@ -0,0 +1,43 @@ +package logout + +import ( + "cloudsave/cmd/cli/tools/prompt/credentials" + "context" + "flag" + "fmt" + "os" + + "github.com/google/subcommands" +) + +type ( + LogoutCmd struct { + } +) + +func (*LogoutCmd) Name() string { return "logout" } +func (*LogoutCmd) Synopsis() string { return "logout from a server" } +func (*LogoutCmd) Usage() string { + return `Usage: cloudsave logout + +Options: +` +} + +func (p *LogoutCmd) SetFlags(f *flag.FlagSet) { +} + +func (p *LogoutCmd) Execute(_ context.Context, f *flag.FlagSet, _ ...interface{}) subcommands.ExitStatus { + if f.NArg() != 1 { + fmt.Fprintf(os.Stderr, "error: this command take 1 argument") + return subcommands.ExitUsageError + } + + if err := credentials.Logout(f.Arg(0)); err != nil { + fmt.Fprintf(os.Stderr, "error: failed to logout: %s", err) + return subcommands.ExitFailure + } + + fmt.Println("bye!") + return subcommands.ExitSuccess +} diff --git a/cmd/cli/commands/pull/pull.go b/cmd/cli/commands/pull/pull.go index 0547bb6..c577794 100644 --- a/cmd/cli/commands/pull/pull.go +++ b/cmd/cli/commands/pull/pull.go @@ -41,7 +41,7 @@ func (p *PullCmd) Execute(_ context.Context, f *flag.FlagSet, _ ...interface{}) gameID := f.Arg(1) path := f.Arg(2) - username, password, err := credentials.Read() + username, password, err := credentials.Read(url) if err != nil { fmt.Fprintf(os.Stderr, "error: failed to read std output: %s", err) return subcommands.ExitFailure diff --git a/cmd/cli/commands/sync/sync.go b/cmd/cli/commands/sync/sync.go index 6fcdf8d..75b114b 100644 --- a/cmd/cli/commands/sync/sync.go +++ b/cmd/cli/commands/sync/sync.go @@ -284,7 +284,7 @@ func connect(remoteCred map[string]map[string]string, r remote.Remote) (*client. fmt.Println() fmt.Println("Connexion to", r.URL) fmt.Println("============") - username, password, err := credentials.Read() + username, password, err := credentials.Read(r.URL) if err != nil { return nil, fmt.Errorf("failed to read std output: %w", err) } diff --git a/cmd/cli/commands/version/version.go b/cmd/cli/commands/version/version.go index 0325b3b..f523252 100644 --- a/cmd/cli/commands/version/version.go +++ b/cmd/cli/commands/version/version.go @@ -42,7 +42,7 @@ func (p *VersionCmd) Execute(_ context.Context, f *flag.FlagSet, _ ...interface{ return subcommands.ExitUsageError } - username, password, err := credentials.Read() + username, password, err := credentials.Read(f.Arg(0)) if err != nil { fmt.Fprintf(os.Stderr, "failed to read std output: %s", err) return subcommands.ExitFailure diff --git a/cmd/cli/main.go b/cmd/cli/main.go index b8c6222..9c71555 100644 --- a/cmd/cli/main.go +++ b/cmd/cli/main.go @@ -4,6 +4,8 @@ import ( "cloudsave/cmd/cli/commands/add" "cloudsave/cmd/cli/commands/apply" "cloudsave/cmd/cli/commands/list" + "cloudsave/cmd/cli/commands/login" + "cloudsave/cmd/cli/commands/logout" "cloudsave/cmd/cli/commands/pull" "cloudsave/cmd/cli/commands/remote" "cloudsave/cmd/cli/commands/remove" @@ -56,6 +58,8 @@ func main() { subcommands.Register(&remote.RemoteCmd{Service: s}, "remote") subcommands.Register(&sync.SyncCmd{Service: s}, "remote") subcommands.Register(&pull.PullCmd{Service: s}, "remote") + subcommands.Register(&login.LoginCmd{}, "remote") + subcommands.Register(&logout.LogoutCmd{}, "remote") flag.Parse() ctx := context.Background() diff --git a/cmd/cli/tools/prompt/credentials/credentials.go b/cmd/cli/tools/prompt/credentials/credentials.go index 7f9abaa..ccc10dd 100644 --- a/cmd/cli/tools/prompt/credentials/credentials.go +++ b/cmd/cli/tools/prompt/credentials/credentials.go @@ -2,14 +2,48 @@ package credentials import ( "bufio" + "encoding/json" + "errors" "fmt" "os" + "path/filepath" "strings" "golang.org/x/term" ) -func Read() (string, string, error) { +type ( + credential struct { + Username string `json:"username"` + Password string `json:"password"` + } + + credentialsStore struct { + Store map[string]credential `json:"store"` + } +) + +var ( + datastorePath string +) + +func init() { + roaming, err := os.UserConfigDir() + if err != nil { + panic("failed to get user config path: " + err.Error()) + } + datastorePath = filepath.Join(roaming, "cloudsave") +} + +func Read(server string) (string, string, error) { + var err error + store, err := load() + if err == nil { + if c, ok := store[server]; ok { + return c.Username, c.Password, nil + } + } + fmt.Print("Enter username: ") reader := bufio.NewReader(os.Stdin) username, _ := reader.ReadString('\n') @@ -24,3 +58,66 @@ func Read() (string, string, error) { return username, string(password), nil } + +func Login(username, password, server string) error { + store, err := load() + if err != nil { + return err + } + + store[server] = credential{ + Username: username, + Password: password, + } + + return save(store) +} + +func Logout(server string) error { + store, err := load() + if err != nil { + return err + } + + delete(store, server) + + return save(store) +} + +func save(store map[string]credential) error { + c := credentialsStore{ + Store: store, + } + + f, err := os.OpenFile(filepath.Join(datastorePath, "credential.json"), os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0740) + if err != nil { + return fmt.Errorf("failed to open datastore: %w", err) + } + defer f.Close() + + e := json.NewEncoder(f) + if err := e.Encode(c); err != nil { + return fmt.Errorf("failed to encode data: %w", err) + } + + return nil +} + +func load() (map[string]credential, error) { + f, err := os.OpenFile(filepath.Join(datastorePath, "credential.json"), os.O_RDONLY, 0) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return make(map[string]credential), nil + } + return nil, fmt.Errorf("failed to open datastore: %w", err) + } + defer f.Close() + + var c credentialsStore + d := json.NewDecoder(f) + if err := d.Decode(&c); err != nil { + return nil, fmt.Errorf("failed to decode data: %w", err) + } + + return c.Store, nil +} diff --git a/cmd/server/api/api.go b/cmd/server/api/api.go index 794e15e..9a210b1 100644 --- a/cmd/server/api/api.go +++ b/cmd/server/api/api.go @@ -21,6 +21,7 @@ type ( Server *http.Server Service *data.Service documentRoot string + creds map[string]string } ) @@ -32,6 +33,7 @@ func NewServer(documentRoot string, srv *data.Service, creds map[string]string, s := &HTTPServer{ Service: srv, documentRoot: documentRoot, + creds: creds, } router := chi.NewRouter() router.NotFound(func(writer http.ResponseWriter, request *http.Request) { @@ -46,7 +48,7 @@ func NewServer(documentRoot string, srv *data.Service, creds map[string]string, router.Use(middleware.Compress(5, "application/gzip")) router.Use(middleware.Heartbeat("/heartbeat")) router.Route("/api", func(routerAPI chi.Router) { - routerAPI.Use(BasicAuth("cloudsave", creds)) + routerAPI.Use(s.BasicAuth("cloudsave")) routerAPI.Route("/v1", func(r chi.Router) { // Get information about the server r.Get("/version", s.Information) @@ -78,6 +80,10 @@ func NewServer(documentRoot string, srv *data.Service, creds map[string]string, return s } +func (s *HTTPServer) SetCredentials(creds map[string]string) { + s.creds = creds +} + func (s HTTPServer) all(w http.ResponseWriter, r *http.Request) { datastore, err := s.Service.AllGames() if err != nil { diff --git a/cmd/server/api/middlewares.go b/cmd/server/api/middlewares.go index 5fd02c1..332ffb1 100644 --- a/cmd/server/api/middlewares.go +++ b/cmd/server/api/middlewares.go @@ -20,7 +20,7 @@ func recoverMiddleware(next http.Handler) http.Handler { } // BasicAuth implements a simple middleware handler for adding basic http auth to a route. -func BasicAuth(realm string, creds map[string]string) func(next http.Handler) http.Handler { +func (s *HTTPServer) BasicAuth(realm string) func(next http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { user, pass, ok := r.BasicAuth() @@ -29,7 +29,7 @@ func BasicAuth(realm string, creds map[string]string) func(next http.Handler) ht return } - credPass := creds[user] + credPass := s.creds[user] if err := bcrypt.CompareHashAndPassword([]byte(credPass), []byte(pass)); err != nil { basicAuthFailed(w, r, realm) return diff --git a/cmd/server/main.go b/cmd/server/main.go index a8c865c..0cb8074 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -5,12 +5,30 @@ package main import ( "fmt" "os" + "os/signal" + "syscall" ) const defaultDocumentRoot string = "/var/lib/cloudsave" +var ( + updateChan chan struct{} +) + func main() { - run() + updateChan = make(chan struct{}) + + sigc := make(chan os.Signal, 1) + signal.Notify(sigc, syscall.SIGHUP) + + go func() { + for { + <-sigc + updateChan <- struct{}{} + } + }() + + run(updateChan) } func fatal(message string, exitCode int) { diff --git a/cmd/server/main_windows.go b/cmd/server/main_windows.go index 70b14e0..55635dc 100644 --- a/cmd/server/main_windows.go +++ b/cmd/server/main_windows.go @@ -13,9 +13,15 @@ const defaultDocumentRoot string = "C:\\ProgramData\\CloudSave" //go:embed res/icon.ico var icon []byte +var ( + updateChan chan struct{} +) + func main() { + updateChan = make(chan struct{}) go systray.Run(onReady, onExit) - run() + + run(updateChan) } func fatal(message string, exitCode int) { @@ -28,12 +34,20 @@ func onReady() { systray.SetTooltip("CloudSave") systray.SetIcon(icon) - mQuit := systray.AddMenuItem("Quit", "Quit") + mReload := systray.AddMenuItem("Reload", "Reload the server data") + mQuit := systray.AddMenuItem("Quit", "Quit the server") go func() { <-mQuit.ClickedCh os.Exit(0) }() + + go func() { + for { + <-mReload.ClickedCh + updateChan <- struct{}{} + } + }() } func onExit() { diff --git a/cmd/server/runner.go b/cmd/server/runner.go index 18deffd..3456b66 100644 --- a/cmd/server/runner.go +++ b/cmd/server/runner.go @@ -14,7 +14,7 @@ import ( "strconv" ) -func run() { +func run(updateChan <-chan struct{}) { fmt.Printf("CloudSave server -- v%s.%s.%s\n\n", constants.Version, runtime.GOOS, runtime.GOARCH) var documentRoot string @@ -47,6 +47,7 @@ func run() { if err := r.Preload(); err != nil { fatal("failed to load datastore: "+err.Error(), 1) } + repo = r } else { slog.Info("loading lazy repository...") @@ -61,6 +62,21 @@ func run() { server := api.NewServer(documentRoot, s, h.Content(), port) + go func() { + for { + <-updateChan + if r, ok := repo.(*repository.EagerRepository); ok { + r.Reload() + } + h, err := htpasswd.Open(filepath.Join(documentRoot, ".htpasswd")) + if err != nil { + fatal("failed to load .htpasswd: "+err.Error(), 1) + } + slog.Info("users loaded: " + strconv.Itoa(len(h.Content())) + " user(s) loaded") + server.SetCredentials(h.Content()) + } + }() + fmt.Println("server started at :" + strconv.Itoa(port)) if err := server.Server.ListenAndServe(); err != nil { fatal("failed to start server: "+err.Error(), 1) diff --git a/pkg/repository/repository.go b/pkg/repository/repository.go index 3175ee3..35d7147 100644 --- a/pkg/repository/repository.go +++ b/pkg/repository/repository.go @@ -73,7 +73,7 @@ type ( AllHist(gameID GameIdentifier) ([]string, error) WriteBlob(ID Identifier) (io.Writer, error) - WriteMetadata(gameID GameIdentifier, m Metadata) error + WriteMetadata(gameID GameIdentifier, m Metadata) error Metadata(gameID GameIdentifier) (Metadata, error) LastScan(gameID GameIdentifier) (time.Time, error) @@ -401,6 +401,7 @@ func (r *EagerRepository) Preload() error { r.mu.Lock() defer r.mu.Unlock() + slog.Info("loading data from datastore to memory...") games, err := r.Repository.All() if err != nil { return fmt.Errorf("failed to load all data: %w", err) @@ -443,6 +444,21 @@ func (r *EagerRepository) Preload() error { return nil } +func (r *EagerRepository) ClearCache() { + r.mu.Lock() + defer r.mu.Unlock() + + slog.Info("clearing cache...") + for k := range r.data { + delete(r.data, k) + } +} + +func (r *EagerRepository) Reload() error { + r.ClearCache() + return r.Preload() +} + func (r *EagerRepository) All() ([]string, error) { r.mu.RLock() defer r.mu.RUnlock()