From 130af90e034d30c08e8a76af74d476c2ead62243 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aur=C3=A9lie=20DELHAIE?= Date: Thu, 15 May 2025 19:38:13 +0200 Subject: [PATCH] sync --- cmd/cli/commands/sync/sync.go | 110 ++++++++++++++++---- cmd/server/api/api.go | 37 +++++++ cmd/server/api/responses.go | 52 +++------ go.mod | 3 + go.sum | 4 + pkg/game/game.go | 72 ++++++++++++- pkg/remote/client/client.go | 96 +++++++++++++++++ pkg/remote/obj/obj.go | 22 ++++ pkg/tools/prompt/credentials/credentials.go | 26 +++++ 9 files changed, 360 insertions(+), 62 deletions(-) create mode 100644 pkg/remote/client/client.go create mode 100644 pkg/remote/obj/obj.go create mode 100644 pkg/tools/prompt/credentials/credentials.go diff --git a/cmd/cli/commands/sync/sync.go b/cmd/cli/commands/sync/sync.go index dfaad50..2688808 100644 --- a/cmd/cli/commands/sync/sync.go +++ b/cmd/cli/commands/sync/sync.go @@ -1,12 +1,16 @@ package sync import ( + "cloudsave/pkg/game" "cloudsave/pkg/remote" + "cloudsave/pkg/remote/client" + "cloudsave/pkg/tools/prompt/credentials" "context" - "crypto/md5" "flag" "fmt" - "io" + "log/slog" + "net/http" + "net/url" "os" "github.com/google/subcommands" @@ -29,37 +33,99 @@ func (p *SyncCmd) SetFlags(f *flag.FlagSet) { } func (p *SyncCmd) Execute(_ context.Context, f *flag.FlagSet, _ ...interface{}) subcommands.ExitStatus { - _, err := remote.All() + remotes, err := remote.All() if err != nil { fmt.Fprintln(os.Stderr, "error: failed to load datastore:", err) return subcommands.ExitFailure } - for _, remote := range remotes { - + username, password, err := credentials.Read() + if err != nil { + fmt.Fprintln(os.Stderr, "error: failed to read std output:", err) + return subcommands.ExitFailure + } + + for _, r := range remotes { + if !ping(r.URL, username, password) { + slog.Warn("remote is unavailable", "url", r.URL) + continue + } + + client := client.New(r.URL, username, password) + + hlocal, err := game.Hash(r.GameID) + if err != nil { + slog.Error(err.Error()) + continue + } + + hremote, _ := client.Hash(r.GameID) + + vlocal, err := game.Version(r.GameID) + if err != nil { + slog.Error(err.Error()) + continue + } + + vremote, _ := client.Version(r.GameID) + + if hlocal == hremote { + fmt.Println("already up-to-date") + continue + } + + if vremote == 0 { + fmt.Println("push") + continue + } + + if vlocal > vremote { + fmt.Println("push") + continue + } + + if vlocal < vremote { + fmt.Println("pull") + continue + } + + if vlocal == vremote { + fmt.Println("conflict") + continue + } } return subcommands.ExitSuccess } -func hash(path string) string { - f, err := os.OpenFile(path, os.O_RDONLY, 0) +func ping(remote, username, password string) bool { + cli := http.Client{} + + hburl, err := url.JoinPath(remote, "heartbeat") if err != nil { - notFound("id not found", w, r) - return - } - defer f.Close() - - // Create MD5 hasher - hasher := md5.New() - - // Copy file content into hasher - if _, err := io.Copy(hasher, f); err != nil { - fmt.Fprintln(os.Stderr, "error: an error occured while reading data:", err) - internalServerError(w, r) - return + fmt.Fprintln(os.Stderr, "cannot connect to remote:", err) + return false } - // Get checksum result - sum := hasher.Sum(nil) + req, err := http.NewRequest("GET", hburl, nil) + if err != nil { + fmt.Fprintln(os.Stderr, "cannot connect to remote:", err) + return false + } + + req.SetBasicAuth(username, password) + + res, err := cli.Do(req) + if err != nil { + fmt.Fprintln(os.Stderr, "cannot connect to remote:", err) + return false + } + + if res.StatusCode != http.StatusOK { + fmt.Fprintln(os.Stderr, "cannot connect to remote: server return code", res.StatusCode) + return false + } + + return true } + diff --git a/cmd/server/api/api.go b/cmd/server/api/api.go index e0cc0ee..6340b1b 100644 --- a/cmd/server/api/api.go +++ b/cmd/server/api/api.go @@ -57,6 +57,7 @@ func NewServer(documentRoot string, creds map[string]string, port int) *HTTPServ saveRouter.Post("/{id}/data", s.upload) saveRouter.Get("/{id}/data", s.download) saveRouter.Get("/{id}/hash", s.hash) + saveRouter.Get("/{id}/version", s.version) }) }) }) @@ -220,3 +221,39 @@ func (s HTTPServer) hash(w http.ResponseWriter, r *http.Request) { sum := hasher.Sum(nil) ok(sum, w, r) } + +func (s HTTPServer) version(w http.ResponseWriter, r *http.Request) { + id := chi.URLParam(r, "id") + path := filepath.Clean(filepath.Join(s.documentRoot, "data", id)) + + sdir, err := os.Stat(path) + if err != nil { + notFound("id not found", w, r) + return + } + + if !sdir.IsDir() { + notFound("id not found", w, r) + return + } + + path = filepath.Join(path, "metadata.json") + + f, err := os.OpenFile(path, os.O_RDONLY, 0) + if err != nil { + notFound("id not found", w, r) + return + } + defer f.Close() + + var metadata game.Metadata + d := json.NewDecoder(f) + err = d.Decode(&metadata) + if err != nil { + fmt.Fprintln(os.Stderr, "error: an error occured while reading data:", err) + internalServerError(w, r) + return + } + + ok(metadata.Version, w, r) +} diff --git a/cmd/server/api/responses.go b/cmd/server/api/responses.go index b7bdc66..e85f64c 100644 --- a/cmd/server/api/responses.go +++ b/cmd/server/api/responses.go @@ -1,34 +1,16 @@ package api import ( + "cloudsave/pkg/remote/obj" "encoding/json" "log" "net/http" "time" ) -type ( - httpCore struct { - Status int `json:"status"` - Timestamp time.Time `json:"timestamp"` - Path string `json:"path"` - } - - httpError struct { - httpCore - Error string `json:"error"` - Message string `json:"message"` - } - - httpObject struct { - httpCore - Data any `json:"data"` - } -) - func internalServerError(w http.ResponseWriter, r *http.Request) { - e := httpError{ - httpCore: httpCore{ + e := obj.HTTPError{ + HTTPCore: obj.HTTPCore{ Status: http.StatusInternalServerError, Path: r.RequestURI, Timestamp: time.Now(), @@ -50,8 +32,8 @@ func internalServerError(w http.ResponseWriter, r *http.Request) { } func notFound(message string, w http.ResponseWriter, r *http.Request) { - e := httpError{ - httpCore: httpCore{ + e := obj.HTTPError{ + HTTPCore: obj.HTTPCore{ Status: http.StatusNotFound, Path: r.RequestURI, Timestamp: time.Now(), @@ -73,8 +55,8 @@ func notFound(message string, w http.ResponseWriter, r *http.Request) { } func methodNotAllowed(w http.ResponseWriter, r *http.Request) { - e := httpError{ - httpCore: httpCore{ + e := obj.HTTPError{ + HTTPCore: obj.HTTPCore{ Status: http.StatusMethodNotAllowed, Path: r.RequestURI, Timestamp: time.Now(), @@ -96,8 +78,8 @@ func methodNotAllowed(w http.ResponseWriter, r *http.Request) { } func unauthorized(w http.ResponseWriter, r *http.Request) { - e := httpError{ - httpCore: httpCore{ + e := obj.HTTPError{ + HTTPCore: obj.HTTPCore{ Status: http.StatusUnauthorized, Path: r.RequestURI, Timestamp: time.Now(), @@ -120,8 +102,8 @@ func unauthorized(w http.ResponseWriter, r *http.Request) { } func forbidden(w http.ResponseWriter, r *http.Request) { - e := httpError{ - httpCore: httpCore{ + e := obj.HTTPError{ + HTTPCore: obj.HTTPCore{ Status: http.StatusForbidden, Path: r.RequestURI, Timestamp: time.Now(), @@ -142,14 +124,14 @@ func forbidden(w http.ResponseWriter, r *http.Request) { } } -func ok(obj interface{}, w http.ResponseWriter, r *http.Request) { - e := httpObject{ - httpCore: httpCore{ +func ok(o interface{}, w http.ResponseWriter, r *http.Request) { + e := obj.HTTPObject{ + HTTPCore: obj.HTTPCore{ Status: http.StatusOK, Path: r.RequestURI, Timestamp: time.Now(), }, - Data: obj, + Data: o, } payload, err := json.Marshal(e) @@ -164,8 +146,8 @@ func ok(obj interface{}, w http.ResponseWriter, r *http.Request) { } func badRequest(message string, w http.ResponseWriter, r *http.Request) { - e := httpError{ - httpCore: httpCore{ + e := obj.HTTPError{ + HTTPCore: obj.HTTPCore{ Status: http.StatusBadRequest, Path: r.RequestURI, Timestamp: time.Now(), diff --git a/go.mod b/go.mod index 121bfdd..b3522a7 100644 --- a/go.mod +++ b/go.mod @@ -6,4 +6,7 @@ require ( github.com/go-chi/chi/v5 v5.2.1 github.com/google/subcommands v1.2.0 golang.org/x/crypto v0.38.0 + golang.org/x/term v0.32.0 ) + +require golang.org/x/sys v0.33.0 // indirect diff --git a/go.sum b/go.sum index 020c1a8..f308fb6 100644 --- a/go.sum +++ b/go.sum @@ -4,3 +4,7 @@ github.com/google/subcommands v1.2.0 h1:vWQspBTo2nEqTUFita5/KeEWlUL8kQObDFbub/EN github.com/google/subcommands v1.2.0/go.mod h1:ZjhPrFU+Olkh9WazFPsl27BQ4UPiG37m3yTrtFlrHVk= golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8= golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw= +golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= +golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/term v0.32.0 h1:DR4lr0TjUs3epypdhTOkMmuF5CDFJ/8pOnbzMZPQ7bg= +golang.org/x/term v0.32.0/go.mod h1:uZG1FhGx848Sqfsq4/DlJr3xGGsYMu/L5GW4abiaEPQ= diff --git a/pkg/game/game.go b/pkg/game/game.go index 528fd09..d739915 100644 --- a/pkg/game/game.go +++ b/pkg/game/game.go @@ -2,17 +2,21 @@ package game import ( "cloudsave/pkg/tools/id" + "crypto/md5" + "encoding/hex" "encoding/json" "fmt" + "io" "os" "path/filepath" ) type ( Metadata struct { - ID string `json:"id"` - Name string `json:"name"` - Path string `json:"path"` + ID string `json:"id"` + Name string `json:"name"` + Path string `json:"path"` + Version int `json:"version"` } ) @@ -99,8 +103,66 @@ func Remove(gameID string) error { } func Hash(gameID string) (string, error) { - content, err := os.ReadFile(filepath.Join(datastorepath, d.Name(), "data.tar.gz")) - if err != nil { + path := filepath.Join(datastorepath, gameID, "data.tar.gz") + f, err := os.OpenFile(path, os.O_RDONLY, 0) + if err != nil { + return "", err } + defer f.Close() + + hasher := md5.New() + if _, err := io.Copy(hasher, f); err != nil { + return "", err + } + sum := hasher.Sum(nil) + return hex.EncodeToString(sum), nil +} + +func Version(gameID string) (int, error) { + path := filepath.Join(datastorepath, gameID, "metadata.json") + + f, err := os.OpenFile(path, os.O_RDONLY, 0) + if err != nil { + return 0, err + } + defer f.Close() + + var metadata Metadata + d := json.NewDecoder(f) + err = d.Decode(&metadata) + if err != nil { + return 0, err + } + + return metadata.Version, nil +} + +func SetVersion(gameID string, version int) error { + path := filepath.Join(datastorepath, gameID, "metadata.json") + + f, err := os.OpenFile(path, os.O_RDWR, 0740) + if err != nil { + return err + } + defer f.Close() + + var metadata Metadata + d := json.NewDecoder(f) + err = d.Decode(&metadata) + if err != nil { + return err + } + + f.Seek(0, io.SeekStart) + + metadata.Version = version + + e := json.NewEncoder(f) + err = e.Encode(metadata) + if err != nil { + return err + } + + return nil } diff --git a/pkg/remote/client/client.go b/pkg/remote/client/client.go new file mode 100644 index 0000000..eca8100 --- /dev/null +++ b/pkg/remote/client/client.go @@ -0,0 +1,96 @@ +package client + +import ( + "cloudsave/pkg/remote/obj" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/url" +) + +type ( + Client struct { + baseURL string + username string + password string + } +) + +func New(baseURL, username, password string) *Client { + return &Client{ + baseURL: baseURL, + username: username, + password: password, + } +} + +func (c *Client) Hash(gameID string) (string, error) { + u, err := url.JoinPath(c.baseURL, "api", "v1", "game", gameID, "hash") + if err != nil { + return "", err + } + + o, err := c.get(u) + if err != nil { + return "", err + } + + if h, ok := (o).(string); ok { + return h, nil + } + + return "", errors.New("invalid payload sent by the server") +} + +func (c *Client) Version(gameID string) (int, error) { + u, err := url.JoinPath(c.baseURL, "api", "v1", "game", gameID, "version") + if err != nil { + return 0, err + } + + o, err := c.get(u) + if err != nil { + return 0, err + } + + if h, ok := (o).(int); ok { + return h, nil + } + + return 0, errors.New("invalid payload sent by the server") +} + +func (c *Client) get(url string) (any, error) { + cli := http.Client{} + + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return nil, err + } + + req.SetBasicAuth(c.username, c.password) + + res, err := cli.Do(req) + if err != nil { + return nil, err + } + defer res.Body.Close() + + if res.StatusCode != 200 { + return nil, fmt.Errorf("server returns an unexpected status code: %d %s", res.StatusCode, res.Status) + } + + var httpObject obj.HTTPObject + d := json.NewDecoder(res.Body) + err = d.Decode(&httpObject) + if err != nil { + return nil, err + } + + return httpObject, nil +} + +func (c *Client) post() { + +} \ No newline at end of file diff --git a/pkg/remote/obj/obj.go b/pkg/remote/obj/obj.go new file mode 100644 index 0000000..599c70b --- /dev/null +++ b/pkg/remote/obj/obj.go @@ -0,0 +1,22 @@ +package obj + +import "time" + +type ( + HTTPCore struct { + Status int `json:"status"` + Timestamp time.Time `json:"timestamp"` + Path string `json:"path"` + } + + HTTPError struct { + HTTPCore + Error string `json:"error"` + Message string `json:"message"` + } + + HTTPObject struct { + HTTPCore + Data any `json:"data"` + } +) diff --git a/pkg/tools/prompt/credentials/credentials.go b/pkg/tools/prompt/credentials/credentials.go new file mode 100644 index 0000000..7f9abaa --- /dev/null +++ b/pkg/tools/prompt/credentials/credentials.go @@ -0,0 +1,26 @@ +package credentials + +import ( + "bufio" + "fmt" + "os" + "strings" + + "golang.org/x/term" +) + +func Read() (string, string, error) { + fmt.Print("Enter username: ") + reader := bufio.NewReader(os.Stdin) + username, _ := reader.ReadString('\n') + username = strings.TrimSpace(username) + + fmt.Printf("password for %s: ", username) + password, err := term.ReadPassword(int(os.Stdin.Fd())) + if err != nil { + return "", "", err + } + fmt.Println() + + return username, string(password), nil +}