From f31a19beab2bd6beeda8f8f25be8b746547e27cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aur=C3=A9lie=20DELHAIE?= Date: Thu, 24 Jul 2025 20:54:58 +0200 Subject: [PATCH] wip --- cmd/cli/commands/sync/sync.go | 62 +++++++++------------ cmd/server/api/api.go | 65 +++++++++++++++++----- cmd/server/data/data.go | 53 ++++++++++++++++++ pkg/game/game.go | 20 +++++++ pkg/remote/client/client.go | 101 +++++++++++++++++++++++++++++++--- 5 files changed, 245 insertions(+), 56 deletions(-) create mode 100644 cmd/server/data/data.go diff --git a/cmd/cli/commands/sync/sync.go b/cmd/cli/commands/sync/sync.go index 2688808..1c8698c 100644 --- a/cmd/cli/commands/sync/sync.go +++ b/cmd/cli/commands/sync/sync.go @@ -9,9 +9,8 @@ import ( "flag" "fmt" "log/slog" - "net/http" - "net/url" "os" + "path/filepath" "github.com/google/subcommands" ) @@ -39,6 +38,11 @@ func (p *SyncCmd) Execute(_ context.Context, f *flag.FlagSet, _ ...interface{}) return subcommands.ExitFailure } + if len(remotes) == 0 { + fmt.Println("nothing to do: no remote found") + return subcommands.ExitSuccess + } + username, password, err := credentials.Read() if err != nil { fmt.Fprintln(os.Stderr, "error: failed to read std output:", err) @@ -46,13 +50,19 @@ func (p *SyncCmd) Execute(_ context.Context, f *flag.FlagSet, _ ...interface{}) } for _, r := range remotes { - if !ping(r.URL, username, password) { - slog.Warn("remote is unavailable", "url", r.URL) - continue + m, err := game.One(r.GameID) + if err != nil { + fmt.Fprintln(os.Stderr, "error: cannot get metadata for this game: %w", err) + return subcommands.ExitFailure } client := client.New(r.URL, username, password) + if !client.Ping() { + slog.Warn("remote is unavailable", "url", r.URL) + continue + } + hlocal, err := game.Hash(r.GameID) if err != nil { slog.Error(err.Error()) @@ -75,12 +85,18 @@ func (p *SyncCmd) Execute(_ context.Context, f *flag.FlagSet, _ ...interface{}) } if vremote == 0 { - fmt.Println("push") + if err := push(r.GameID, m, client); err != nil { + fmt.Fprintln(os.Stderr, "failed to push:", err) + return subcommands.ExitFailure + } continue } if vlocal > vremote { - fmt.Println("push") + if err := push(r.GameID, m, client); err != nil { + fmt.Fprintln(os.Stderr, "failed to push:", err) + return subcommands.ExitFailure + } continue } @@ -98,34 +114,8 @@ func (p *SyncCmd) Execute(_ context.Context, f *flag.FlagSet, _ ...interface{}) return subcommands.ExitSuccess } -func ping(remote, username, password string) bool { - cli := http.Client{} +func push(gameID string, m game.Metadata, cli *client.Client) error { + archivePath := filepath.Join(game.DatastorePath(), gameID, "data.tar.gz") - hburl, err := url.JoinPath(remote, "heartbeat") - if err != nil { - fmt.Fprintln(os.Stderr, "cannot connect to remote:", err) - return false - } - - 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 + return cli.Push(gameID, archivePath, m) } - diff --git a/cmd/server/api/api.go b/cmd/server/api/api.go index 6340b1b..71c1f46 100644 --- a/cmd/server/api/api.go +++ b/cmd/server/api/api.go @@ -1,6 +1,7 @@ package api import ( + "cloudsave/cmd/server/data" "cloudsave/pkg/game" "crypto/md5" "encoding/json" @@ -140,20 +141,30 @@ func (s HTTPServer) download(w http.ResponseWriter, r *http.Request) { } func (s HTTPServer) upload(w http.ResponseWriter, r *http.Request) { - id := chi.URLParam(r, "id") - path := filepath.Clean(filepath.Join(s.documentRoot, "data", id, "data.tar.gz")) + const ( + sizeLimit int64 = 500 << 20 // 500 MB + ) - // Limit max upload size (e.g., 500 MB) - r.Body = http.MaxBytesReader(w, r.Body, 500<<20) + id := chi.URLParam(r, "id") + + // Limit max upload size + r.Body = http.MaxBytesReader(w, r.Body, sizeLimit) // Parse multipart form - err := r.ParseMultipartForm(500 << 20) // 500 MB + err := r.ParseMultipartForm(sizeLimit) if err != nil { fmt.Fprintln(os.Stderr, "error: failed to load payload:", err) badRequest("bad payload", w, r) return } + m, err := parseFormMetadata(id, r.MultipartForm.Value) + if err != nil { + fmt.Fprintln(os.Stderr, "error: cannot find metadata in the form:", err) + badRequest("metadata not found", w, r) + return + } + // Retrieve file file, _, err := r.FormFile("payload") if err != nil { @@ -163,18 +174,15 @@ func (s HTTPServer) upload(w http.ResponseWriter, r *http.Request) { } defer file.Close() - // Create destination file - f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY, 0640) - if err != nil { - fmt.Fprintln(os.Stderr, "error: failed to open file:", err) + //TODO make a transaction + if err := data.UpdateMetadata(id, s.documentRoot, m); err != nil { + fmt.Fprintln(os.Stderr, "error: failed to write metadata to disk:", err) internalServerError(w, r) return } - defer f.Close() - // Copy the uploaded content to the file - if _, err := io.Copy(f, file); err != nil { - fmt.Fprintln(os.Stderr, "error: an error occured while downloading data:", err) + if err := data.Write(id, s.documentRoot, file); err != nil { + fmt.Fprintln(os.Stderr, "error: failed to write file to disk:", err) internalServerError(w, r) return } @@ -257,3 +265,34 @@ func (s HTTPServer) version(w http.ResponseWriter, r *http.Request) { ok(metadata.Version, w, r) } + +func parseFormMetadata(gameID string, values map[string][]string) (game.Metadata, error) { + var name string + if v, ok := values["name"]; ok { + if len(v) != 0 { + return game.Metadata{}, fmt.Errorf("error: corrupted metadata") + + } + name = v[0] + } else { + return game.Metadata{}, fmt.Errorf("error: cannot find metadata in the form") + } + + var version int + if v, ok := values["version"]; ok { + if len(v) != 0 { + return game.Metadata{}, fmt.Errorf("error: corrupted metadata") + } + if v, err := strconv.Atoi(v[0]); err == nil { + version = v + } + } else { + return game.Metadata{}, fmt.Errorf("error: cannot find metadata in the form") + } + + return game.Metadata{ + ID: gameID, + Version: version, + Name: name, + }, nil +} diff --git a/cmd/server/data/data.go b/cmd/server/data/data.go new file mode 100644 index 0000000..e57ef20 --- /dev/null +++ b/cmd/server/data/data.go @@ -0,0 +1,53 @@ +package data + +import ( + "cloudsave/pkg/game" + "encoding/json" + "fmt" + "io" + "os" + "path/filepath" +) + +func Write(gameID, documentRoot string, r io.Reader) error { + dataFolderPath := filepath.Join(documentRoot, "data", gameID) + partPath := filepath.Join(dataFolderPath, "data.tar.gz.part") + finalFilePath := filepath.Join(dataFolderPath, "data.tar.gz") + + if err := os.MkdirAll(dataFolderPath, 0740); err != nil { + return err + } + + f, err := os.OpenFile(partPath, os.O_CREATE|os.O_WRONLY, 0740) + if err != nil { + return err + } + + if _, err := io.Copy(f, r); err != nil { + f.Close() + if err := os.Remove(partPath); err != nil { + return fmt.Errorf("failed to write the file and cannot clean the folder: %w", err) + } + return fmt.Errorf("failed to write the file: %w", err) + } + f.Close() + + if err := os.Rename(partPath, finalFilePath); err != nil { + return err + } + + return nil +} + +func UpdateMetadata(gameID, documentRoot string, m game.Metadata) 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 err + } + defer f.Close() + + e := json.NewEncoder(f) + return e.Encode(m) +} diff --git a/pkg/game/game.go b/pkg/game/game.go index d739915..eb62c77 100644 --- a/pkg/game/game.go +++ b/pkg/game/game.go @@ -90,6 +90,26 @@ func All() ([]Metadata, error) { return datastore, nil } +func One(gameID string) (Metadata, error) { + _, err := os.ReadDir(datastorepath) + if err != nil { + return Metadata{}, fmt.Errorf("cannot open the datastore: %w", err) + } + + content, err := os.ReadFile(filepath.Join(datastorepath, gameID, "metadata.json")) + if err != nil { + return Metadata{}, fmt.Errorf("game not found: %w", err) + } + + var m Metadata + err = json.Unmarshal(content, &m) + if err != nil { + return Metadata{}, fmt.Errorf("corrupted datastore: failed to parse %s/metadata.json: %w", gameID, err) + } + + return m, nil +} + func DatastorePath() string { return datastorepath } diff --git a/pkg/remote/client/client.go b/pkg/remote/client/client.go index eca8100..c8b18dd 100644 --- a/pkg/remote/client/client.go +++ b/pkg/remote/client/client.go @@ -1,12 +1,18 @@ package client import ( + "bytes" + "cloudsave/pkg/game" "cloudsave/pkg/remote/obj" "encoding/json" "errors" "fmt" + "io" + "mime/multipart" "net/http" "net/url" + "os" + "strconv" ) type ( @@ -26,7 +32,7 @@ func New(baseURL, username, password string) *Client { } func (c *Client) Hash(gameID string) (string, error) { - u, err := url.JoinPath(c.baseURL, "api", "v1", "game", gameID, "hash") + u, err := url.JoinPath(c.baseURL, "api", "v1", "games", gameID, "hash") if err != nil { return "", err } @@ -44,7 +50,7 @@ func (c *Client) Hash(gameID string) (string, error) { } func (c *Client) Version(gameID string) (int, error) { - u, err := url.JoinPath(c.baseURL, "api", "v1", "game", gameID, "version") + u, err := url.JoinPath(c.baseURL, "api", "v1", "games", gameID, "version") if err != nil { return 0, err } @@ -61,6 +67,91 @@ func (c *Client) Version(gameID string) (int, error) { return 0, errors.New("invalid payload sent by the server") } +func (c *Client) Push(gameID, archivePath string, m game.Metadata) error { + u, err := url.JoinPath(c.baseURL, "api", "v1", "games", gameID, "data") + if err != nil { + return err + } + + f, err := os.OpenFile(archivePath, os.O_RDONLY, 0) + if err != nil { + return fmt.Errorf("failed to open file: %w", err) + } + defer f.Close() + + buf := new(bytes.Buffer) + writer := multipart.NewWriter(buf) + + part, err := writer.CreateFormFile("payload", "data.tar.gz") + if err != nil { + return err + } + + if _, err := io.Copy(part, f); err != nil { + return fmt.Errorf("failed to copy data: %w", err) + } + + writer.WriteField("name", m.Name) + writer.WriteField("version", strconv.Itoa(m.Version)) + + if err := writer.Close(); err != nil { + return err + } + + cli := http.Client{} + + req, err := http.NewRequest("POST", u, buf) + if err != nil { + return err + } + + req.SetBasicAuth(c.username, c.password) + req.Header.Set("Content-Type", writer.FormDataContentType()) + + res, err := cli.Do(req) + if err != nil { + return err + } + defer res.Body.Close() + + if res.StatusCode != 201 { + return fmt.Errorf("server returns an unexpected status code: %s (expected 201)", res.Status) + } + + return nil +} + +func (c *Client) Ping() bool { + cli := http.Client{} + + hburl, err := url.JoinPath(c.baseURL, "heartbeat") + if err != nil { + fmt.Fprintln(os.Stderr, "cannot connect to remote:", err) + return false + } + + req, err := http.NewRequest("GET", hburl, nil) + if err != nil { + fmt.Fprintln(os.Stderr, "cannot connect to remote:", err) + return false + } + + req.SetBasicAuth(c.username, c.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 +} + func (c *Client) get(url string) (any, error) { cli := http.Client{} @@ -78,7 +169,7 @@ func (c *Client) get(url string) (any, error) { defer res.Body.Close() if res.StatusCode != 200 { - return nil, fmt.Errorf("server returns an unexpected status code: %d %s", res.StatusCode, res.Status) + return nil, fmt.Errorf("server returns an unexpected status code: %d %s (expected 200)", res.StatusCode, res.Status) } var httpObject obj.HTTPObject @@ -90,7 +181,3 @@ func (c *Client) get(url string) (any, error) { return httpObject, nil } - -func (c *Client) post() { - -} \ No newline at end of file