From cf96815d0f7174622b208476f05fa8b122d705d4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aur=C3=A9lie=20DELHAIE?= Date: Wed, 6 Aug 2025 14:05:29 +0200 Subject: [PATCH 1/4] add web gui --- .gitignore | 5 +- build.sh | 22 ++ cmd/web/config.template.json | 8 + cmd/web/server/config/config.go | 40 ++++ cmd/web/server/middlewares.go | 63 ++++++ cmd/web/server/server.go | 248 ++++++++++++++++++++++ cmd/web/server/templates/401.html | 9 + cmd/web/server/templates/500.html | 9 + cmd/web/server/templates/dashboard.html | 38 ++++ cmd/web/server/templates/detailled.html | 54 +++++ cmd/web/server/templates/information.html | 52 +++++ cmd/web/web.go | 34 +++ pkg/constants/constants.go | 2 +- pkg/remote/client/client.go | 7 +- 14 files changed, 588 insertions(+), 3 deletions(-) create mode 100644 cmd/web/config.template.json create mode 100644 cmd/web/server/config/config.go create mode 100644 cmd/web/server/middlewares.go create mode 100644 cmd/web/server/server.go create mode 100644 cmd/web/server/templates/401.html create mode 100644 cmd/web/server/templates/500.html create mode 100644 cmd/web/server/templates/dashboard.html create mode 100644 cmd/web/server/templates/detailled.html create mode 100644 cmd/web/server/templates/information.html create mode 100644 cmd/web/web.go diff --git a/.gitignore b/.gitignore index 66a6401..5de49e4 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,7 @@ /cli /server +/web /env/ -/build/ \ No newline at end of file +/build/ +*.exe +/config.json \ No newline at end of file diff --git a/build.sh b/build.sh index 386ea6c..59974a3 100755 --- a/build.sh +++ b/build.sh @@ -55,6 +55,28 @@ for platform in "${platforms[@]}"; do fi done +# WEB + +platforms=("linux/amd64" "linux/arm64" "linux/riscv64" "linux/ppc64le") + +for platform in "${platforms[@]}"; do + echo "* Compiling web server for $platform..." + platform_split=(${platform//\// }) + + EXT="" + if [ "${platform_split[0]}" == "windows" ]; then + EXT=.exe + fi + + if [ "$MAKE_PACKAGE" == "true" ]; then + CGO_ENABLED=0 GOOS=${platform_split[0]} GOARCH=${platform_split[1]} go build -o build/cloudsave_web$EXT -a ./cmd/web + tar -czf build/server_${platform_split[0]}_${platform_split[1]}.tar.gz build/cloudsave_web$EXT + rm build/cloudsave_web$EXT + else + CGO_ENABLED=0 GOOS=${platform_split[0]} GOARCH=${platform_split[1]} go build -o build/cloudsave_web_${platform_split[0]}_${platform_split[1]}$EXT -a ./cmd/web + fi +done + ## CLIENT platforms=("windows/amd64" "windows/arm64" "darwin/amd64" "darwin/arm64" "linux/amd64" "linux/arm64") diff --git a/cmd/web/config.template.json b/cmd/web/config.template.json new file mode 100644 index 0000000..83c88ac --- /dev/null +++ b/cmd/web/config.template.json @@ -0,0 +1,8 @@ +{ + "server": { + "port": 8181 + }, + "remote": { + "url": "http://localhost:8080" + } +} \ No newline at end of file diff --git a/cmd/web/server/config/config.go b/cmd/web/server/config/config.go new file mode 100644 index 0000000..87616bc --- /dev/null +++ b/cmd/web/server/config/config.go @@ -0,0 +1,40 @@ +package config + +import ( + "encoding/json" + "fmt" + "os" +) + +type ( + Configuration struct { + Server ServerConfiguration `json:"server"` + Remote RemoteConfiguration `json:"remote"` + } + + ServerConfiguration struct { + Port int `json:"port"` + } + + RemoteConfiguration struct { + URL string `json:"url"` + } +) + +func Load(path string) (Configuration, error) { + f, err := os.OpenFile(path, os.O_RDONLY, 0) + if err != nil { + return Configuration{}, fmt.Errorf("failed to open configuration file: %w", err) + } + defer f.Close() + + d := json.NewDecoder(f) + + var c Configuration + err = d.Decode(&c) + if err != nil { + return Configuration{}, fmt.Errorf("failed to parse configuration file (%s): %w", path, err) + } + + return c, nil +} diff --git a/cmd/web/server/middlewares.go b/cmd/web/server/middlewares.go new file mode 100644 index 0000000..eda0a7d --- /dev/null +++ b/cmd/web/server/middlewares.go @@ -0,0 +1,63 @@ +package server + +import ( + "fmt" + "log" + "net/http" + + "golang.org/x/crypto/bcrypt" +) + +func recoverMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + defer func() { + err := recover() + if err != nil { + internalServerError(w, r) + } + }() + next.ServeHTTP(w, r) + }) +} + +// 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 { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + user, pass, ok := r.BasicAuth() + if !ok { + basicAuthFailed(w, r, realm) + return + } + + credPass := creds[user] + if err := bcrypt.CompareHashAndPassword([]byte(credPass), []byte(pass)); err != nil { + basicAuthFailed(w, r, realm) + return + } + + next.ServeHTTP(w, r) + }) + } +} + +func basicAuthFailed(w http.ResponseWriter, r *http.Request, realm string) { + unauthorized(realm, w, r) +} + +func unauthorized(realm string, w http.ResponseWriter, r *http.Request) { + w.Header().Add("WWW-Authenticate", fmt.Sprintf(`Basic realm="%s"`, realm)) + w.WriteHeader(http.StatusUnauthorized) + _, err := w.Write([]byte(UnauthorizedErrorHTMLPage)) + if err != nil { + log.Println(err) + } +} + +func internalServerError(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + _, err := w.Write([]byte(InternalServerErrorHTMLPage)) + if err != nil { + log.Println(err) + } +} diff --git a/cmd/web/server/server.go b/cmd/web/server/server.go new file mode 100644 index 0000000..7eee69a --- /dev/null +++ b/cmd/web/server/server.go @@ -0,0 +1,248 @@ +package server + +import ( + "cloudsave/cmd/web/server/config" + "cloudsave/pkg/constants" + "cloudsave/pkg/remote/client" + "cloudsave/pkg/repository" + "errors" + "fmt" + "html/template" + "log/slog" + "net/http" + "runtime" + "slices" + + "github.com/go-chi/chi/v5" + "github.com/go-chi/chi/v5/middleware" + + _ "embed" +) + +type ( + HTTPServer struct { + Server *http.Server + Config config.Configuration + Templates Templates + } + + Templates struct { + Dashboard *template.Template + Detailled *template.Template + System *template.Template + } +) + +type ( + DetaillePayload struct { + Version string + Save repository.Metadata + BackupMetadata []repository.Backup + Hash string + } + + DashboardPayload struct { + Version string + Saves []repository.Metadata + } + + SystemPayload struct { + Version string + Client client.Information + Server client.Information + } +) + +var ( + //go:embed templates/500.html + InternalServerErrorHTMLPage string + + //go:embed templates/401.html + UnauthorizedErrorHTMLPage string + + //go:embed templates/dashboard.html + DashboardHTMLPage string + + //go:embed templates/detailled.html + DetailledHTMLPage string + + //go:embed templates/information.html + SystemHTMLPage string +) + +// NewServer start the http server +func NewServer(c config.Configuration) *HTTPServer { + dashboardTemplate := template.New("dashboard") + dashboardTemplate.Parse(DashboardHTMLPage) + + detailledTemplate := template.New("detailled") + detailledTemplate.Parse(DetailledHTMLPage) + + systemTemplate := template.New("system") + systemTemplate.Parse(SystemHTMLPage) + + s := &HTTPServer{ + Config: c, + Templates: Templates{ + Dashboard: dashboardTemplate, + Detailled: detailledTemplate, + System: systemTemplate, + }, + } + router := chi.NewRouter() + router.Use(middleware.Logger) + router.Use(recoverMiddleware) + router.Route("/web", func(routerAPI chi.Router) { + routerAPI.Get("/", s.dashboard) + routerAPI.Get("/{id}", s.detailled) + routerAPI.Get("/system", s.system) + }) + s.Server = &http.Server{ + Addr: fmt.Sprintf(":%d", c.Server.Port), + Handler: router, + } + return s +} + +func (s *HTTPServer) dashboard(w http.ResponseWriter, r *http.Request) { + user, pass, ok := r.BasicAuth() + if !ok { + basicAuthFailed(w, r, "realm") + return + } + + cli := client.New(s.Config.Remote.URL, user, pass) + + if err := cli.Ping(); err != nil { + slog.Error("unable to connect to the remote", "err", err) + return + } + + saves, err := cli.All() + if err != nil { + if errors.Is(err, client.ErrUnauthorized) { + unauthorized("Unable to access resources", w, r) + return + } + slog.Error("unable to connect to the remote", "err", err) + return + } + + slices.SortFunc(saves, func(a, b repository.Metadata) int { + return a.Date.Compare(b.Date) + }) + + slices.Reverse(saves) + + payload := DashboardPayload{ + Version: constants.Version, + Saves: saves, + } + + if err := s.Templates.Dashboard.Execute(w, payload); err != nil { + slog.Error("failed to render the html pages", "err", err) + return + } +} + +func (s *HTTPServer) detailled(w http.ResponseWriter, r *http.Request) { + user, pass, ok := r.BasicAuth() + if !ok { + basicAuthFailed(w, r, "realm") + return + } + + id := chi.URLParam(r, "id") + cli := client.New(s.Config.Remote.URL, user, pass) + + if err := cli.Ping(); err != nil { + slog.Error("unable to connect to the remote", "err", err) + return + } + + save, err := cli.Metadata(id) + if err != nil { + if errors.Is(err, client.ErrUnauthorized) { + unauthorized("Unable to access resources", w, r) + return + } + slog.Error("unable to connect to the remote", "err", err) + return + } + + h, err := cli.Hash(id) + if err != nil { + slog.Error("unable to connect to the remote", "err", err) + return + } + + ids, err := cli.ListArchives(id) + if err != nil { + slog.Error("unable to connect to the remote", "err", err) + return + } + + var bm []repository.Backup + for _, i := range ids { + b, err := cli.ArchiveInfo(id, i) + if err != nil { + slog.Error("unable to connect to the remote", "err", err) + return + } + bm = append(bm, b) + } + + payload := DetaillePayload{ + Save: save, + Hash: h, + BackupMetadata: bm, + Version: constants.Version, + } + + if err := s.Templates.Detailled.Execute(w, payload); err != nil { + slog.Error("failed to render the html pages", "err", err) + return + } +} + +func (s *HTTPServer) system(w http.ResponseWriter, r *http.Request) { + user, pass, ok := r.BasicAuth() + if !ok { + basicAuthFailed(w, r, "realm") + return + } + cli := client.New(s.Config.Remote.URL, user, pass) + + if err := cli.Ping(); err != nil { + slog.Error("unable to connect to the remote", "err", err) + return + } + + clientInfo := client.Information{ + Version: constants.Version, + APIVersion: constants.ApiVersion, + GoVersion: runtime.Version(), + OSName: runtime.GOOS, + OSArchitecture: runtime.GOARCH, + } + serverInfo, err := cli.Version() + if err != nil { + if errors.Is(err, client.ErrUnauthorized) { + unauthorized("Unable to access resources", w, r) + return + } + slog.Error("unable to connect to the remote", "err", err) + return + } + + payload := SystemPayload{ + Version: constants.Version, + Client: clientInfo, + Server: serverInfo, + } + + if err := s.Templates.System.Execute(w, payload); err != nil { + slog.Error("failed to render the html pages", "err", err) + return + } +} diff --git a/cmd/web/server/templates/401.html b/cmd/web/server/templates/401.html new file mode 100644 index 0000000..6f52e99 --- /dev/null +++ b/cmd/web/server/templates/401.html @@ -0,0 +1,9 @@ + + + + You are not allowed + + +

401 Unauthorized

+ + \ No newline at end of file diff --git a/cmd/web/server/templates/500.html b/cmd/web/server/templates/500.html new file mode 100644 index 0000000..0e14e49 --- /dev/null +++ b/cmd/web/server/templates/500.html @@ -0,0 +1,9 @@ + + + + An error occured + + +

500 Internal Server Error

+ + \ No newline at end of file diff --git a/cmd/web/server/templates/dashboard.html b/cmd/web/server/templates/dashboard.html new file mode 100644 index 0000000..d5cb04e --- /dev/null +++ b/cmd/web/server/templates/dashboard.html @@ -0,0 +1,38 @@ + + + + + + + Dashboard + + + + + +
+ +
+ + + + \ No newline at end of file diff --git a/cmd/web/server/templates/detailled.html b/cmd/web/server/templates/detailled.html new file mode 100644 index 0000000..bb68b94 --- /dev/null +++ b/cmd/web/server/templates/detailled.html @@ -0,0 +1,54 @@ + + + + + + + {{.Save.Name}} + + + + + +
+ +
+

{{.Save.Name}} Version {{.Save.Version}}

+
+

Details

+
    +
  • UUID: {{.Save.ID}}
  • +
  • Last Upload: {{.Save.Date}}
  • +
  • Hash (MD5): {{.Hash}}
  • +
+ +
+

Backup

+ {{ range .BackupMetadata}} +
+
+
{{.CreatedAt}}
+
{{.UUID}}
+

MD5: {{.MD5}}

+
+
+ {{end}} +
+
+ + + + \ No newline at end of file diff --git a/cmd/web/server/templates/information.html b/cmd/web/server/templates/information.html new file mode 100644 index 0000000..5140f78 --- /dev/null +++ b/cmd/web/server/templates/information.html @@ -0,0 +1,52 @@ + + + + + + + System Information + + + + + +
+ +
+

Client

+
+
    +
  • Version: {{.Client.Version}}
  • +
  • API Version: {{.Client.APIVersion}}
  • +
  • Go Version: {{.Client.GoVersion}}
  • +
  • OS: {{.Client.OSName}}/{{.Client.OSArchitecture}}
  • +
+ +
+

Server

+
+
    +
  • Version: {{.Server.Version}}
  • +
  • API Version: {{.Server.APIVersion}}
  • +
  • Go Version: {{.Server.GoVersion}}
  • +
  • OS: {{.Server.OSName}}/{{.Server.OSArchitecture}}
  • +
+
+
+ + + + \ No newline at end of file diff --git a/cmd/web/web.go b/cmd/web/web.go new file mode 100644 index 0000000..2030e3f --- /dev/null +++ b/cmd/web/web.go @@ -0,0 +1,34 @@ +package main + +import ( + "cloudsave/cmd/web/server" + "cloudsave/cmd/web/server/config" + "cloudsave/pkg/constants" + "flag" + "fmt" + "os" + "runtime" + "strconv" +) + +func main() { + fmt.Printf("CloudSave web -- v%s.%s.%s\n\n", constants.Version, runtime.GOOS, runtime.GOARCH) + + var configPath string + flag.StringVar(&configPath, "config", "/var/lib/cloudsave/config.json", "Define the path to the configuration file") + flag.Parse() + + c, err := config.Load(configPath) + if err != nil { + fmt.Fprintln(os.Stderr, "failed to load configuration:", err) + os.Exit(1) + } + + s := server.NewServer(c) + + fmt.Println("starting server at :" + strconv.Itoa(c.Server.Port)) + if err := s.Server.ListenAndServe(); err != nil { + fmt.Fprintln(os.Stderr, "failed to start web server:", err) + os.Exit(1) + } +} diff --git a/pkg/constants/constants.go b/pkg/constants/constants.go index 1dc2fa6..ba987f3 100644 --- a/pkg/constants/constants.go +++ b/pkg/constants/constants.go @@ -1,5 +1,5 @@ package constants -const Version = "0.0.2" +const Version = "0.0.3" const ApiVersion = 1 diff --git a/pkg/remote/client/client.go b/pkg/remote/client/client.go index 76633c2..c161939 100644 --- a/pkg/remote/client/client.go +++ b/pkg/remote/client/client.go @@ -36,7 +36,8 @@ type ( ) var ( - ErrNotFound error = errors.New("not found") + ErrNotFound error = errors.New("not found") + ErrUnauthorized error = errors.New("unauthorized (HTTP Error 401)") ) func New(baseURL, username, password string) *Client { @@ -382,6 +383,10 @@ func (c *Client) get(url string) (obj.HTTPObject, error) { return obj.HTTPObject{}, ErrNotFound } + if res.StatusCode == 401 { + return obj.HTTPObject{}, ErrUnauthorized + } + if res.StatusCode != 200 { return obj.HTTPObject{}, fmt.Errorf("server returns an unexpected status code: %d %s (expected 200)", res.StatusCode, res.Status) } From d479004217189ced3ea617e28e323dd6620100fa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aur=C3=A9lie=20DELHAIE?= Date: Wed, 6 Aug 2025 14:05:59 +0200 Subject: [PATCH 2/4] version change --- build.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.sh b/build.sh index 59974a3..571f5ba 100755 --- a/build.sh +++ b/build.sh @@ -1,7 +1,7 @@ #!/bin/bash MAKE_PACKAGE=false -VERSION=0.0.2 +VERSION=0.0.3 usage() { echo "Usage: $0 [OPTIONS]" From 2f777c72eef1fb72462d6c62d0034dcb7847aa87 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aur=C3=A9lie=20DELHAIE?= Date: Wed, 6 Aug 2025 23:09:12 +0200 Subject: [PATCH 3/4] better usage prompt + hash opti on server --- cmd/cli/commands/add/add.go | 19 ++++- cmd/cli/commands/apply/apply.go | 5 +- cmd/cli/commands/list/list.go | 7 +- cmd/cli/commands/pull/pull.go | 7 +- cmd/cli/commands/remote/remote.go | 13 +++- cmd/cli/commands/remove/remove.go | 6 +- cmd/cli/commands/run/run.go | 11 ++- cmd/cli/commands/sync/sync.go | 11 ++- cmd/cli/commands/version/version.go | 9 ++- cmd/server/data/data.go | 114 ++++++++++++++++++++++++++++ 10 files changed, 176 insertions(+), 26 deletions(-) diff --git a/cmd/cli/commands/add/add.go b/cmd/cli/commands/add/add.go index 5e1c629..e4d9974 100644 --- a/cmd/cli/commands/add/add.go +++ b/cmd/cli/commands/add/add.go @@ -1,32 +1,39 @@ package add import ( + "cloudsave/pkg/remote" "cloudsave/pkg/repository" "context" "flag" "fmt" "os" "path/filepath" + "strings" "github.com/google/subcommands" ) type ( AddCmd struct { - name string + name string + remote string } ) func (*AddCmd) Name() string { return "add" } -func (*AddCmd) Synopsis() string { return "Add a folder to the sync list" } +func (*AddCmd) Synopsis() string { return "add a folder to the sync list" } func (*AddCmd) Usage() string { - return `add: - Add a folder to the sync list + return `Usage: cloudsave add [-name] [-remote] + +Add a folder to the track list + +Options: ` } func (p *AddCmd) SetFlags(f *flag.FlagSet) { f.StringVar(&p.name, "name", "", "Override the name of the game") + f.StringVar(&p.remote, "remote", "", "Defines a remote server to sync with") } func (p *AddCmd) Execute(_ context.Context, f *flag.FlagSet, _ ...interface{}) subcommands.ExitStatus { @@ -50,6 +57,10 @@ func (p *AddCmd) Execute(_ context.Context, f *flag.FlagSet, _ ...interface{}) s return subcommands.ExitFailure } + if len(strings.TrimSpace(p.remote)) > 0 { + remote.Set(m.ID, p.remote) + } + fmt.Println(m.ID) return subcommands.ExitSuccess diff --git a/cmd/cli/commands/apply/apply.go b/cmd/cli/commands/apply/apply.go index 0e3e389..b6610d0 100644 --- a/cmd/cli/commands/apply/apply.go +++ b/cmd/cli/commands/apply/apply.go @@ -20,8 +20,9 @@ type ( func (*ListCmd) Name() string { return "apply" } func (*ListCmd) Synopsis() string { return "apply a backup" } func (*ListCmd) Usage() string { - return `apply: - Apply a backup + return `Usage: cloudsave apply + +Apply a backup ` } diff --git a/cmd/cli/commands/list/list.go b/cmd/cli/commands/list/list.go index 8987ae3..3b80f24 100644 --- a/cmd/cli/commands/list/list.go +++ b/cmd/cli/commands/list/list.go @@ -22,8 +22,11 @@ type ( func (*ListCmd) Name() string { return "list" } func (*ListCmd) Synopsis() string { return "list all game registered" } func (*ListCmd) Usage() string { - return `list: - List all game registered + return `Usage: cloudsave list [-include-backup] [-a] + +List all game registered + +Options: ` } diff --git a/cmd/cli/commands/pull/pull.go b/cmd/cli/commands/pull/pull.go index 5a0da4e..4490d1c 100644 --- a/cmd/cli/commands/pull/pull.go +++ b/cmd/cli/commands/pull/pull.go @@ -1,10 +1,10 @@ package pull import ( + "cloudsave/cmd/cli/tools/prompt/credentials" "cloudsave/pkg/remote/client" "cloudsave/pkg/repository" "cloudsave/pkg/tools/archive" - "cloudsave/cmd/cli/tools/prompt/credentials" "context" "flag" "fmt" @@ -22,8 +22,9 @@ type ( func (*PullCmd) Name() string { return "pull" } func (*PullCmd) Synopsis() string { return "pull a game save from the remote" } func (*PullCmd) Usage() string { - return `list: - Pull a game save from the remote + return `Usage: cloudsave pull + +Pull a game save from the remote ` } diff --git a/cmd/cli/commands/remote/remote.go b/cmd/cli/commands/remote/remote.go index e47cfc5..d35a1c5 100644 --- a/cmd/cli/commands/remote/remote.go +++ b/cmd/cli/commands/remote/remote.go @@ -20,10 +20,17 @@ type ( ) func (*RemoteCmd) Name() string { return "remote" } -func (*RemoteCmd) Synopsis() string { return "manage remote" } +func (*RemoteCmd) Synopsis() string { return "add or update the remote url" } func (*RemoteCmd) Usage() string { - return `remote: - manage remove + return `Usage: cloudsave remote <-set|-list> + +The -list argument lists all remotes for each registered game. +This command performs a connection test. + +The -set argument allow you to set (create or update) +the URL to the remote for a game + +Options ` } diff --git a/cmd/cli/commands/remove/remove.go b/cmd/cli/commands/remove/remove.go index 2459616..bdfc1e1 100644 --- a/cmd/cli/commands/remove/remove.go +++ b/cmd/cli/commands/remove/remove.go @@ -17,8 +17,10 @@ type ( func (*RemoveCmd) Name() string { return "remove" } func (*RemoveCmd) Synopsis() string { return "unregister a game" } func (*RemoveCmd) Usage() string { - return `remove: - Unregister a game + return `Usage: cloudsave remove + +Unregister a game +Caution: all the backup are deleted ` } diff --git a/cmd/cli/commands/run/run.go b/cmd/cli/commands/run/run.go index 45335ab..8c260fb 100644 --- a/cmd/cli/commands/run/run.go +++ b/cmd/cli/commands/run/run.go @@ -20,11 +20,14 @@ type ( } ) -func (*RunCmd) Name() string { return "run" } -func (*RunCmd) Synopsis() string { return "Check and process all the folder" } +func (*RunCmd) Name() string { return "scan" } +func (*RunCmd) Synopsis() string { return "check and process all the folder" } func (*RunCmd) Usage() string { - return `run: - Check and process all the folder + return `Usage: cloudsave scan + +Check if the files have been modified. If so, +the current archive is moved to the backup list +and a new archive is created with a new version number. ` } diff --git a/cmd/cli/commands/sync/sync.go b/cmd/cli/commands/sync/sync.go index 9fe9e5d..9702fbb 100644 --- a/cmd/cli/commands/sync/sync.go +++ b/cmd/cli/commands/sync/sync.go @@ -27,8 +27,9 @@ type ( func (*SyncCmd) Name() string { return "sync" } func (*SyncCmd) Synopsis() string { return "list all game registered" } func (*SyncCmd) Usage() string { - return `add: - List all game registered + return `Usage: cloudsave sync + +Synchronize the archives with the server defined for each game. ` } @@ -47,6 +48,7 @@ func (p *SyncCmd) Execute(_ context.Context, f *flag.FlagSet, _ ...interface{}) r, err := remote.One(g.ID) if err != nil { if errors.Is(err, remote.ErrNoRemote) { + fmt.Println(g.Name + ": no remote configured") continue } fmt.Fprintln(os.Stderr, "error: failed to load datastore:", err) @@ -85,6 +87,7 @@ func (p *SyncCmd) Execute(_ context.Context, f *flag.FlagSet, _ ...interface{}) destroyPg() slog.Warn("failed to push backup files", "err", err) } + fmt.Println(g.Name + ": pushed") continue } @@ -136,7 +139,7 @@ func (p *SyncCmd) Execute(_ context.Context, f *flag.FlagSet, _ ...interface{}) continue } } - fmt.Println("already up-to-date") + fmt.Println(g.Name + ": already up-to-date") continue } @@ -148,6 +151,7 @@ func (p *SyncCmd) Execute(_ context.Context, f *flag.FlagSet, _ ...interface{}) return subcommands.ExitFailure } destroyPg() + fmt.Println(g.Name + ": pushed") continue } @@ -168,6 +172,7 @@ func (p *SyncCmd) Execute(_ context.Context, f *flag.FlagSet, _ ...interface{}) fmt.Fprintln(os.Stderr, "error: failed to synchronize date:", err) continue } + fmt.Println(g.Name + ": pulled") continue } diff --git a/cmd/cli/commands/version/version.go b/cmd/cli/commands/version/version.go index 98d6d20..0325b3b 100644 --- a/cmd/cli/commands/version/version.go +++ b/cmd/cli/commands/version/version.go @@ -1,9 +1,9 @@ package version import ( + "cloudsave/cmd/cli/tools/prompt/credentials" "cloudsave/pkg/constants" "cloudsave/pkg/remote/client" - "cloudsave/cmd/cli/tools/prompt/credentials" "context" "flag" "fmt" @@ -23,8 +23,11 @@ type ( func (*VersionCmd) Name() string { return "version" } func (*VersionCmd) Synopsis() string { return "show version and system information" } func (*VersionCmd) Usage() string { - return `add: - Show version and system information + return `Usage: cloudsave version [-a] + +Print the version of the software + +Options: ` } diff --git a/cmd/server/data/data.go b/cmd/server/data/data.go index 6b1c553..fd38fbb 100644 --- a/cmd/server/data/data.go +++ b/cmd/server/data/data.go @@ -9,12 +9,50 @@ import ( "io" "os" "path/filepath" + "sync" +) + +type ( + cache map[string]cachedInfo + + cachedInfo struct { + MD5 string + Version int + } ) var ( ErrBackupNotExists error = errors.New("backup not found") + + // singleton + hashCacheMu sync.RWMutex + hashCache cache = make(map[string]cachedInfo) ) +func (c cache) Get(gameID string) (cachedInfo, bool) { + hashCacheMu.RLock() + defer hashCacheMu.RUnlock() + + if v, ok := c[gameID]; ok { + return v, true + } + return cachedInfo{}, false +} + +func (c cache) Register(gameID string, v cachedInfo) { + hashCacheMu.Lock() + defer hashCacheMu.Unlock() + + c[gameID] = v +} + +func (c cache) Remove(gameID string) { + hashCacheMu.Lock() + defer hashCacheMu.Unlock() + + delete(c, gameID) +} + func Write(gameID, documentRoot string, r io.Reader) error { dataFolderPath := filepath.Join(documentRoot, "data", gameID) partPath := filepath.Join(dataFolderPath, "data.tar.gz.part") @@ -42,6 +80,7 @@ func Write(gameID, documentRoot string, r io.Reader) error { return err } + hashCache.Remove(gameID) return nil } @@ -97,6 +136,7 @@ func UpdateMetadata(gameID, documentRoot string, m repository.Metadata) error { func ArchiveInfo(gameID, documentRoot, uuid string) (repository.Backup, error) { dataFolderPath := filepath.Join(documentRoot, "data", gameID, "hist", uuid, "data.tar.gz") + cacheID := gameID + ":" + uuid finfo, err := os.Stat(dataFolderPath) if err != nil { @@ -106,11 +146,29 @@ func ArchiveInfo(gameID, documentRoot, uuid string) (repository.Backup, error) { return repository.Backup{}, err } + v, err := getVersion(gameID, documentRoot) + if err != nil { + return repository.Backup{}, fmt.Errorf("failed to read game metadata: %w", err) + } + + if m, ok := hashCache.Get(cacheID); ok { + return repository.Backup{ + CreatedAt: finfo.ModTime(), + UUID: uuid, + MD5: m.MD5, + }, nil + } + h, err := hash.FileMD5(dataFolderPath) if err != nil { return repository.Backup{}, fmt.Errorf("failed to calculate file md5: %w", err) } + hashCache.Register(cacheID, cachedInfo{ + Version: v, + MD5: h, + }) + return repository.Backup{ CreatedAt: finfo.ModTime(), UUID: uuid, @@ -118,6 +176,62 @@ func ArchiveInfo(gameID, documentRoot, uuid string) (repository.Backup, error) { }, nil } +func Hash(gameID, documentRoot string) (string, error) { + path := filepath.Clean(filepath.Join(documentRoot, "data", gameID)) + + sdir, err := os.Stat(path) + if err != nil { + return "", err + } + + if !sdir.IsDir() { + return "", err + } + + v, err := getVersion(gameID, documentRoot) + if err != nil { + return "", fmt.Errorf("failed to read game metadata: %w", err) + } + + if m, ok := hashCache.Get(gameID); ok { + if v == m.Version { + return m.MD5, nil + } + } + + path = filepath.Join(path, "data.tar.gz") + + h, err := hash.FileMD5(path) + if err != nil { + return "", err + } + + hashCache.Register(gameID, cachedInfo{ + Version: v, + MD5: h, + }) + + return h, nil +} + +func getVersion(gameID, documentRoot string) (int, error) { + path := filepath.Join(documentRoot, "data", gameID, "metadata.json") + + f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY, 0740) + if err != nil { + return 0, err + } + defer f.Close() + + d := json.NewDecoder(f) + var m repository.Metadata + if err := d.Decode(&m); err != nil { + return 0, err + } + + return m.Version, nil +} + func makeDataFolder(gameID, documentRoot string) error { if err := os.MkdirAll(filepath.Join(documentRoot, "data", gameID), 0740); err != nil { return err From 898012a30d8e376c8364c3ff968129b25bd5bf8e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aur=C3=A9lie=20DELHAIE?= Date: Wed, 6 Aug 2025 23:17:29 +0200 Subject: [PATCH 4/4] fix error --- cmd/server/data/data.go | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/cmd/server/data/data.go b/cmd/server/data/data.go index fd38fbb..19fc9ba 100644 --- a/cmd/server/data/data.go +++ b/cmd/server/data/data.go @@ -146,11 +146,6 @@ func ArchiveInfo(gameID, documentRoot, uuid string) (repository.Backup, error) { return repository.Backup{}, err } - v, err := getVersion(gameID, documentRoot) - if err != nil { - return repository.Backup{}, fmt.Errorf("failed to read game metadata: %w", err) - } - if m, ok := hashCache.Get(cacheID); ok { return repository.Backup{ CreatedAt: finfo.ModTime(), @@ -165,8 +160,7 @@ func ArchiveInfo(gameID, documentRoot, uuid string) (repository.Backup, error) { } hashCache.Register(cacheID, cachedInfo{ - Version: v, - MD5: h, + MD5: h, }) return repository.Backup{ @@ -217,7 +211,7 @@ func Hash(gameID, documentRoot string) (string, error) { func getVersion(gameID, documentRoot string) (int, error) { path := filepath.Join(documentRoot, "data", gameID, "metadata.json") - f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY, 0740) + f, err := os.OpenFile(path, os.O_RDONLY, 0) if err != nil { return 0, err }