From c6edb91f297b871ceb69f065393528771c1b505a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aur=C3=A9lie=20DELHAIE?= Date: Wed, 30 Jul 2025 00:49:22 +0200 Subject: [PATCH] starting 0.0.2 dev --- build.sh | 2 +- cmd/cli/commands/add/add.go | 4 +- cmd/cli/commands/list/list.go | 6 +- cmd/cli/commands/pull/pull.go | 8 +- cmd/cli/commands/remote/remote.go | 4 +- cmd/cli/commands/remove/remove.go | 4 +- cmd/cli/commands/run/run.go | 75 +++++----------- cmd/cli/commands/sync/sync.go | 30 +++---- cmd/cli/commands/version/version.go | 2 +- .../tools/prompt/credentials/credentials.go | 0 cmd/server/api/api.go | 90 ++++++++++++++++--- cmd/server/data/data.go | 45 +++++++++- pkg/constants/constants.go | 2 +- pkg/remote/client/client.go | 20 ++--- .../game.go => repository/repository.go} | 62 ++++++++++++- pkg/tools/archive/archive.go | 47 ++++++++++ 16 files changed, 287 insertions(+), 114 deletions(-) rename {pkg => cmd/cli}/tools/prompt/credentials/credentials.go (100%) rename pkg/{game/game.go => repository/repository.go} (77%) diff --git a/build.sh b/build.sh index c0ad24f..5eea3c2 100755 --- a/build.sh +++ b/build.sh @@ -1,7 +1,7 @@ #!/bin/bash MAKE_PACKAGE=false -VERSION=0.0.1 +VERSION=0.0.2 usage() { echo "Usage: $0 [OPTIONS]" diff --git a/cmd/cli/commands/add/add.go b/cmd/cli/commands/add/add.go index 84ee030..5e1c629 100644 --- a/cmd/cli/commands/add/add.go +++ b/cmd/cli/commands/add/add.go @@ -1,7 +1,7 @@ package add import ( - "cloudsave/pkg/game" + "cloudsave/pkg/repository" "context" "flag" "fmt" @@ -44,7 +44,7 @@ func (p *AddCmd) Execute(_ context.Context, f *flag.FlagSet, _ ...interface{}) s p.name = filepath.Base(path) } - m, err := game.Add(p.name, path) + m, err := repository.Add(p.name, path) if err != nil { fmt.Fprintln(os.Stderr, "error: failed to add game reference:", err) return subcommands.ExitFailure diff --git a/cmd/cli/commands/list/list.go b/cmd/cli/commands/list/list.go index 3a763b5..805c44c 100644 --- a/cmd/cli/commands/list/list.go +++ b/cmd/cli/commands/list/list.go @@ -1,9 +1,9 @@ package list import ( - "cloudsave/pkg/game" "cloudsave/pkg/remote/client" - "cloudsave/pkg/tools/prompt/credentials" + "cloudsave/pkg/repository" + "cloudsave/cmd/cli/tools/prompt/credentials" "context" "flag" "fmt" @@ -57,7 +57,7 @@ func (p *ListCmd) Execute(_ context.Context, f *flag.FlagSet, _ ...interface{}) } func local() error { - games, err := game.All() + games, err := repository.All() if err != nil { return fmt.Errorf("failed to load datastore: %w", err) } diff --git a/cmd/cli/commands/pull/pull.go b/cmd/cli/commands/pull/pull.go index a20d4ef..5a0da4e 100644 --- a/cmd/cli/commands/pull/pull.go +++ b/cmd/cli/commands/pull/pull.go @@ -1,10 +1,10 @@ package pull import ( - "cloudsave/pkg/game" "cloudsave/pkg/remote/client" + "cloudsave/pkg/repository" "cloudsave/pkg/tools/archive" - "cloudsave/pkg/tools/prompt/credentials" + "cloudsave/cmd/cli/tools/prompt/credentials" "context" "flag" "fmt" @@ -54,7 +54,7 @@ func (p *PullCmd) Execute(_ context.Context, f *flag.FlagSet, _ ...interface{}) return subcommands.ExitFailure } - archivePath := filepath.Join(game.DatastorePath(), gameID, "data.tar.gz") + archivePath := filepath.Join(repository.DatastorePath(), gameID, "data.tar.gz") m, err := cli.Metadata(gameID) if err != nil { @@ -62,7 +62,7 @@ func (p *PullCmd) Execute(_ context.Context, f *flag.FlagSet, _ ...interface{}) return subcommands.ExitFailure } - err = game.Register(m, path) + err = repository.Register(m, path) if err != nil { fmt.Fprintf(os.Stderr, "failed to register local metadata: %s", err) return subcommands.ExitFailure diff --git a/cmd/cli/commands/remote/remote.go b/cmd/cli/commands/remote/remote.go index d1eebb1..e47cfc5 100644 --- a/cmd/cli/commands/remote/remote.go +++ b/cmd/cli/commands/remote/remote.go @@ -1,9 +1,9 @@ package remote import ( - "cloudsave/pkg/game" "cloudsave/pkg/remote" "cloudsave/pkg/remote/client" + "cloudsave/pkg/repository" "context" "flag" "fmt" @@ -62,7 +62,7 @@ func (p *RemoteCmd) Execute(ctx context.Context, f *flag.FlagSet, _ ...interface } func list() error { - games, err := game.All() + games, err := repository.All() if err != nil { return fmt.Errorf("failed to load datastore: %w", err) } diff --git a/cmd/cli/commands/remove/remove.go b/cmd/cli/commands/remove/remove.go index ee051c7..2459616 100644 --- a/cmd/cli/commands/remove/remove.go +++ b/cmd/cli/commands/remove/remove.go @@ -1,7 +1,7 @@ package remove import ( - "cloudsave/pkg/game" + "cloudsave/pkg/repository" "context" "flag" "fmt" @@ -31,7 +31,7 @@ func (p *RemoveCmd) Execute(_ context.Context, f *flag.FlagSet, _ ...interface{} return subcommands.ExitUsageError } - err := game.Remove(f.Arg(0)) + err := repository.Remove(f.Arg(0)) if err != nil { fmt.Fprintln(os.Stderr, "error: failed to unregister the game:", err) return subcommands.ExitFailure diff --git a/cmd/cli/commands/run/run.go b/cmd/cli/commands/run/run.go index c9d0684..42aec35 100644 --- a/cmd/cli/commands/run/run.go +++ b/cmd/cli/commands/run/run.go @@ -1,9 +1,8 @@ package run import ( - "archive/tar" - "cloudsave/pkg/game" - "compress/gzip" + "cloudsave/pkg/repository" + "cloudsave/pkg/tools/archive" "context" "flag" "fmt" @@ -32,7 +31,7 @@ func (*RunCmd) Usage() string { func (p *RunCmd) SetFlags(f *flag.FlagSet) {} func (p *RunCmd) Execute(_ context.Context, f *flag.FlagSet, _ ...interface{}) subcommands.ExitStatus { - datastore, err := game.All() + datastore, err := repository.All() if err != nil { fmt.Fprintln(os.Stderr, "error: failed to load datastore:", err) return subcommands.ExitFailure @@ -43,18 +42,18 @@ func (p *RunCmd) Execute(_ context.Context, f *flag.FlagSet, _ ...interface{}) s for _, metadata := range datastore { pg.Describe("Scanning " + metadata.Name + "...") - metadataPath := filepath.Join(game.DatastorePath(), metadata.ID) + metadataPath := filepath.Join(repository.DatastorePath(), metadata.ID) //todo transaction err := archiveIfChanged(metadata.ID, metadata.Path, filepath.Join(metadataPath, "data.tar.gz"), filepath.Join(metadataPath, ".last_run")) if err != nil { fmt.Fprintf(os.Stderr, "error: cannot process the data of %s: %s\n", metadata.ID, err) return subcommands.ExitFailure } - if err := game.SetVersion(metadata.ID, metadata.Version+1); err != nil { + if err := repository.SetVersion(metadata.ID, metadata.Version+1); err != nil { fmt.Fprintf(os.Stderr, "error: cannot process the data of %s: %s\n", metadata.ID, err) return subcommands.ExitFailure } - if err := game.SetDate(metadata.ID, time.Now()); err != nil { + if err := repository.SetDate(metadata.ID, time.Now()); err != nil { fmt.Fprintf(os.Stderr, "error: cannot process the data of %s: %s\n", metadata.ID, err) return subcommands.ExitFailure } @@ -69,12 +68,12 @@ func (p *RunCmd) Execute(_ context.Context, f *flag.FlagSet, _ ...interface{}) s // archiveIfChanged will archive srcDir into destTarGz only if any file // in srcDir has a modification time > the last run time stored in stateFile. // After archiving, it updates stateFile to the current time. -func archiveIfChanged(id, srcDir, destTarGz, stateFile string) error { - // 1) Load last run time +func archiveIfChanged(gameID, srcDir, destTarGz, stateFile string) error { + // load last run time var lastRun time.Time data, err := os.ReadFile(stateFile) if err != nil && !os.IsNotExist(err) { - return fmt.Errorf("reading state file: %w", err) + return fmt.Errorf("failed to reading state file: %w", err) } if err == nil { lastRun, err = time.Parse(time.RFC3339, string(data)) @@ -83,7 +82,7 @@ func archiveIfChanged(id, srcDir, destTarGz, stateFile string) error { } } - // 2) Check for changes + // check for changes changed := false err = filepath.Walk(srcDir, func(path string, info os.FileInfo, walkErr error) error { if walkErr != nil { @@ -96,63 +95,29 @@ func archiveIfChanged(id, srcDir, destTarGz, stateFile string) error { return nil }) if err != nil && err != io.EOF { - return fmt.Errorf("scanning source directory: %w", err) + return fmt.Errorf("failed to scanning source directory: %w", err) } if !changed { return nil } - // 3) Create tar.gz + // make a backup + if err := repository.Archive(gameID); err != nil { + return fmt.Errorf("failed to archive data: %w", err) + } + + // create archive f, err := os.Create(destTarGz) if err != nil { - return fmt.Errorf("creating archive file: %w", err) + return fmt.Errorf("failed to creating archive file: %w", err) } defer f.Close() - gw := gzip.NewWriter(f) - defer gw.Close() - - tw := tar.NewWriter(gw) - defer tw.Close() - - // Walk again to add files - err = filepath.Walk(srcDir, func(path string, info os.FileInfo, walkErr error) error { - if walkErr != nil { - return walkErr - } - // Create tar header - header, err := tar.FileInfoHeader(info, path) - if err != nil { - return err - } - // Preserve directory structure relative to srcDir - relPath, err := filepath.Rel(filepath.Dir(srcDir), path) - if err != nil { - return err - } - header.Name = relPath - - if err := tw.WriteHeader(header); err != nil { - return err - } - if info.Mode().IsRegular() { - file, err := os.Open(path) - if err != nil { - return err - } - defer file.Close() - if _, err := io.Copy(tw, file); err != nil { - return err - } - } - return nil - }) - if err != nil { - return fmt.Errorf("writing tar entries: %w", err) + if err := archive.Tar(f, srcDir); err != nil { + return fmt.Errorf("failed archiving files") } - // 4) Update state file now := time.Now().UTC().Format(time.RFC3339) if err := os.WriteFile(stateFile, []byte(now), 0644); err != nil { return fmt.Errorf("updating state file: %w", err) diff --git a/cmd/cli/commands/sync/sync.go b/cmd/cli/commands/sync/sync.go index 8be11bd..146a2a5 100644 --- a/cmd/cli/commands/sync/sync.go +++ b/cmd/cli/commands/sync/sync.go @@ -2,10 +2,10 @@ package sync import ( "cloudsave/cmd/cli/tools/prompt" - "cloudsave/pkg/game" + "cloudsave/cmd/cli/tools/prompt/credentials" "cloudsave/pkg/remote" "cloudsave/pkg/remote/client" - "cloudsave/pkg/tools/prompt/credentials" + "cloudsave/pkg/repository" "context" "errors" "flag" @@ -35,7 +35,7 @@ func (p *SyncCmd) SetFlags(f *flag.FlagSet) { } func (p *SyncCmd) Execute(_ context.Context, f *flag.FlagSet, _ ...interface{}) subcommands.ExitStatus { - games, err := game.All() + games, err := repository.All() if err != nil { fmt.Fprintln(os.Stderr, "error: failed to load datastore:", err) return subcommands.ExitFailure @@ -72,7 +72,7 @@ func (p *SyncCmd) Execute(_ context.Context, f *flag.FlagSet, _ ...interface{}) continue } - hlocal, err := game.Hash(r.GameID) + hlocal, err := repository.Hash(r.GameID) if err != nil { slog.Error(err.Error()) continue @@ -84,7 +84,7 @@ func (p *SyncCmd) Execute(_ context.Context, f *flag.FlagSet, _ ...interface{}) continue } - vlocal, err := game.Version(r.GameID) + vlocal, err := repository.Version(r.GameID) if err != nil { slog.Error(err.Error()) continue @@ -99,7 +99,7 @@ func (p *SyncCmd) Execute(_ context.Context, f *flag.FlagSet, _ ...interface{}) if hlocal == hremote { if vlocal != remoteMetadata.Version { slog.Debug("version is not the same, but the hash is equal. Updating local database") - if err := game.SetVersion(r.GameID, remoteMetadata.Version); err != nil { + if err := repository.SetVersion(r.GameID, remoteMetadata.Version); err != nil { fmt.Fprintln(os.Stderr, "error: failed to synchronize version number:", err) continue } @@ -121,11 +121,11 @@ func (p *SyncCmd) Execute(_ context.Context, f *flag.FlagSet, _ ...interface{}) fmt.Fprintln(os.Stderr, "failed to push:", err) return subcommands.ExitFailure } - if err := game.SetVersion(r.GameID, remoteMetadata.Version); err != nil { + if err := repository.SetVersion(r.GameID, remoteMetadata.Version); err != nil { fmt.Fprintln(os.Stderr, "error: failed to synchronize version number:", err) continue } - if err := game.SetDate(r.GameID, remoteMetadata.Date); err != nil { + if err := repository.SetDate(r.GameID, remoteMetadata.Date); err != nil { fmt.Fprintln(os.Stderr, "error: failed to synchronize date:", err) continue } @@ -144,8 +144,8 @@ func (p *SyncCmd) Execute(_ context.Context, f *flag.FlagSet, _ ...interface{}) return subcommands.ExitSuccess } -func conflict(gameID string, m, remoteMetadata game.Metadata, cli *client.Client) error { - g, err := game.One(gameID) +func conflict(gameID string, m, remoteMetadata repository.Metadata, cli *client.Client) error { + g, err := repository.One(gameID) if err != nil { slog.Warn("a conflict was found but the game is not found in the database") slog.Debug("debug info", "gameID", gameID) @@ -174,10 +174,10 @@ func conflict(gameID string, m, remoteMetadata game.Metadata, cli *client.Client if err := pull(gameID, cli); err != nil { return fmt.Errorf("failed to push: %w", err) } - if err := game.SetVersion(gameID, remoteMetadata.Version); err != nil { + if err := repository.SetVersion(gameID, remoteMetadata.Version); err != nil { return fmt.Errorf("failed to synchronize version number: %w", err) } - if err := game.SetDate(gameID, remoteMetadata.Date); err != nil { + if err := repository.SetDate(gameID, remoteMetadata.Date); err != nil { return fmt.Errorf("failed to synchronize date: %w", err) } } @@ -185,14 +185,14 @@ func conflict(gameID string, m, remoteMetadata game.Metadata, cli *client.Client return nil } -func push(gameID string, m game.Metadata, cli *client.Client) error { - archivePath := filepath.Join(game.DatastorePath(), gameID, "data.tar.gz") +func push(gameID string, m repository.Metadata, cli *client.Client) error { + archivePath := filepath.Join(repository.DatastorePath(), gameID, "data.tar.gz") return cli.Push(gameID, archivePath, m) } func pull(gameID string, cli *client.Client) error { - archivePath := filepath.Join(game.DatastorePath(), gameID, "data.tar.gz") + archivePath := filepath.Join(repository.DatastorePath(), gameID, "data.tar.gz") return cli.Pull(gameID, archivePath) } diff --git a/cmd/cli/commands/version/version.go b/cmd/cli/commands/version/version.go index 79c5bf5..98d6d20 100644 --- a/cmd/cli/commands/version/version.go +++ b/cmd/cli/commands/version/version.go @@ -3,7 +3,7 @@ package version import ( "cloudsave/pkg/constants" "cloudsave/pkg/remote/client" - "cloudsave/pkg/tools/prompt/credentials" + "cloudsave/cmd/cli/tools/prompt/credentials" "context" "flag" "fmt" diff --git a/pkg/tools/prompt/credentials/credentials.go b/cmd/cli/tools/prompt/credentials/credentials.go similarity index 100% rename from pkg/tools/prompt/credentials/credentials.go rename to cmd/cli/tools/prompt/credentials/credentials.go diff --git a/cmd/server/api/api.go b/cmd/server/api/api.go index aaa5e51..a2eb870 100644 --- a/cmd/server/api/api.go +++ b/cmd/server/api/api.go @@ -2,7 +2,7 @@ package api import ( "cloudsave/cmd/server/data" - "cloudsave/pkg/game" + "cloudsave/pkg/repository" "crypto/md5" "encoding/hex" "encoding/json" @@ -61,6 +61,7 @@ func NewServer(documentRoot string, creds map[string]string, port int) *HTTPServ // Data routes gamesRouter.Group(func(saveRouter chi.Router) { saveRouter.Post("/{id}/data", s.upload) + saveRouter.Post("/{id}/hist/data", s.histUpload) saveRouter.Get("/{id}/data", s.download) saveRouter.Get("/{id}/hash", s.hash) saveRouter.Get("/{id}/metadata", s.metadata) @@ -78,7 +79,7 @@ func NewServer(documentRoot string, creds map[string]string, port int) *HTTPServ func (s HTTPServer) all(w http.ResponseWriter, r *http.Request) { path := filepath.Join(s.documentRoot, "data") - datastore := make([]game.Metadata, 0) + datastore := make([]repository.Metadata, 0) if _, err := os.Stat(path); err != nil { if errors.Is(err, os.ErrNotExist) { @@ -104,7 +105,7 @@ func (s HTTPServer) all(w http.ResponseWriter, r *http.Request) { continue } - var m game.Metadata + var m repository.Metadata err = json.Unmarshal(content, &m) if err != nil { fmt.Fprintf(os.Stderr, "corrupted datastore: failed to parse %s/metadata.json: %s", d.Name(), err) @@ -209,6 +210,49 @@ func (s HTTPServer) upload(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusCreated) } +func (s HTTPServer) histUpload(w http.ResponseWriter, r *http.Request) { + const ( + sizeLimit int64 = 500 << 20 // 500 MB + ) + + id := chi.URLParam(r, "id") + dt, err := formParseDate("date", r.MultipartForm.Value) + if err != nil { + fmt.Fprintln(os.Stderr, "error: failed to load payload:", err) + badRequest("bad payload", w, r) + return + } + + // Limit max upload size + r.Body = http.MaxBytesReader(w, r.Body, sizeLimit) + + // Parse multipart form + err = r.ParseMultipartForm(sizeLimit) + if err != nil { + fmt.Fprintln(os.Stderr, "error: failed to load payload:", err) + badRequest("bad payload", w, r) + return + } + + // Retrieve file + file, _, err := r.FormFile("payload") + if err != nil { + fmt.Fprintln(os.Stderr, "error: cannot find payload in the form:", err) + badRequest("payload not found", w, r) + return + } + defer file.Close() + + if err := data.WriteHist(id, s.documentRoot, dt, file); err != nil { + fmt.Fprintln(os.Stderr, "error: failed to write file to disk:", err) + internalServerError(w, r) + return + } + + // Respond success + w.WriteHeader(http.StatusCreated) +} + func (s HTTPServer) hash(w http.ResponseWriter, r *http.Request) { id := chi.URLParam(r, "id") path := filepath.Clean(filepath.Join(s.documentRoot, "data", id)) @@ -272,7 +316,7 @@ func (s HTTPServer) metadata(w http.ResponseWriter, r *http.Request) { } defer f.Close() - var metadata game.Metadata + var metadata repository.Metadata d := json.NewDecoder(f) err = d.Decode(&metadata) if err != nil { @@ -284,49 +328,67 @@ func (s HTTPServer) metadata(w http.ResponseWriter, r *http.Request) { ok(metadata, w, r) } -func parseFormMetadata(gameID string, values map[string][]string) (game.Metadata, error) { +func parseFormMetadata(gameID string, values map[string][]string) (repository.Metadata, error) { var name string if v, ok := values["name"]; ok { if len(v) == 0 { - return game.Metadata{}, fmt.Errorf("error: corrupted metadata") + return repository.Metadata{}, fmt.Errorf("error: corrupted metadata") } name = v[0] } else { - return game.Metadata{}, fmt.Errorf("error: cannot find metadata in the form") + return repository.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") + return repository.Metadata{}, fmt.Errorf("error: corrupted metadata") } if v, err := strconv.Atoi(v[0]); err == nil { version = v } else { - return game.Metadata{}, err + return repository.Metadata{}, err } } else { - return game.Metadata{}, fmt.Errorf("error: cannot find metadata in the form") + return repository.Metadata{}, fmt.Errorf("error: cannot find metadata in the form") } var date time.Time if v, ok := values["date"]; ok { if len(v) == 0 { - return game.Metadata{}, fmt.Errorf("error: corrupted metadata") + return repository.Metadata{}, fmt.Errorf("error: corrupted metadata") } if v, err := time.Parse(time.RFC3339, v[0]); err == nil { date = v } else { - return game.Metadata{}, err + return repository.Metadata{}, err } } else { - return game.Metadata{}, fmt.Errorf("error: cannot find metadata in the form") + return repository.Metadata{}, fmt.Errorf("error: cannot find metadata in the form") } - return game.Metadata{ + return repository.Metadata{ ID: gameID, Version: version, Name: name, Date: date, }, nil } + +func formParseDate(key string, values map[string][]string) (time.Time, error) { + var date time.Time + if v, ok := values[key]; ok { + if len(v) == 0 { + return time.Time{}, fmt.Errorf("error: corrupted metadata") + } + if v, err := time.Parse(time.RFC3339, v[0]); err == nil { + date = v + } else { + return time.Time{}, err + } + } else { + return time.Time{}, fmt.Errorf("error: cannot find metadata in the form") + } + + return date, nil +} diff --git a/cmd/server/data/data.go b/cmd/server/data/data.go index cddfe7f..58213c2 100644 --- a/cmd/server/data/data.go +++ b/cmd/server/data/data.go @@ -1,12 +1,13 @@ package data import ( - "cloudsave/pkg/game" + "cloudsave/pkg/repository" "encoding/json" "fmt" "io" "os" "path/filepath" + "time" ) func Write(gameID, documentRoot string, r io.Reader) error { @@ -39,7 +40,37 @@ func Write(gameID, documentRoot string, r io.Reader) error { return nil } -func UpdateMetadata(gameID, documentRoot string, m game.Metadata) error { +func WriteHist(gameID, documentRoot string, dt time.Time, r io.Reader) error { + dataFolderPath := filepath.Join(documentRoot, "data", gameID, "hist") + partPath := filepath.Join(dataFolderPath, dt.Format("2006-01-02T15-04-05Z07-00")+".data.tar.gz.part") + finalFilePath := filepath.Join(dataFolderPath, dt.Format("2006-01-02T15-04-05Z07-00")+".data.tar.gz") + + if err := makeDataFolder(gameID, documentRoot); 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 repository.Metadata) error { if err := makeDataFolder(gameID, documentRoot); err != nil { return err } @@ -56,5 +87,13 @@ func UpdateMetadata(gameID, documentRoot string, m game.Metadata) error { } func makeDataFolder(gameID, documentRoot string) error { - return os.MkdirAll(filepath.Join(documentRoot, "data", gameID), 0740) + if err := os.MkdirAll(filepath.Join(documentRoot, "data", gameID), 0740); err != nil { + return err + } + + if err := os.MkdirAll(filepath.Join(documentRoot, "data", gameID, "hist"), 0740); err != nil { + return err + } + + return nil } diff --git a/pkg/constants/constants.go b/pkg/constants/constants.go index 4e14d23..1dc2fa6 100644 --- a/pkg/constants/constants.go +++ b/pkg/constants/constants.go @@ -1,5 +1,5 @@ package constants -const Version = "0.0.1" +const Version = "0.0.2" const ApiVersion = 1 diff --git a/pkg/remote/client/client.go b/pkg/remote/client/client.go index cafca98..c649a1a 100644 --- a/pkg/remote/client/client.go +++ b/pkg/remote/client/client.go @@ -2,8 +2,8 @@ package client import ( "bytes" - "cloudsave/pkg/game" "cloudsave/pkg/remote/obj" + "cloudsave/pkg/repository" customtime "cloudsave/pkg/tools/time" "encoding/json" "errors" @@ -117,19 +117,19 @@ func (c *Client) Hash(gameID string) (string, error) { return "", errors.New("invalid payload sent by the server") } -func (c *Client) Metadata(gameID string) (game.Metadata, error) { +func (c *Client) Metadata(gameID string) (repository.Metadata, error) { u, err := url.JoinPath(c.baseURL, "api", "v1", "games", gameID, "metadata") if err != nil { - return game.Metadata{}, err + return repository.Metadata{}, err } o, err := c.get(u) if err != nil { - return game.Metadata{}, err + return repository.Metadata{}, err } if m, ok := (o.Data).(map[string]any); ok { - gm := game.Metadata{ + gm := repository.Metadata{ ID: m["id"].(string), Name: m["name"].(string), Version: int(m["version"].(float64)), @@ -138,10 +138,10 @@ func (c *Client) Metadata(gameID string) (game.Metadata, error) { return gm, nil } - return game.Metadata{}, errors.New("invalid payload sent by the server") + return repository.Metadata{}, errors.New("invalid payload sent by the server") } -func (c *Client) Push(gameID, archivePath string, m game.Metadata) error { +func (c *Client) Push(gameID, archivePath string, m repository.Metadata) error { u, err := url.JoinPath(c.baseURL, "api", "v1", "games", gameID, "data") if err != nil { return err @@ -271,7 +271,7 @@ func (c *Client) Ping() error { return nil } -func (c *Client) All() ([]game.Metadata, error) { +func (c *Client) All() ([]repository.Metadata, error) { u, err := url.JoinPath(c.baseURL, "api", "v1", "games") if err != nil { return nil, err @@ -283,10 +283,10 @@ func (c *Client) All() ([]game.Metadata, error) { } if games, ok := (o.Data).([]any); ok { - var res []game.Metadata + var res []repository.Metadata for _, g := range games { if v, ok := g.(map[string]any); ok { - gm := game.Metadata{ + gm := repository.Metadata{ ID: v["id"].(string), Name: v["name"].(string), Version: int(v["version"].(float64)), diff --git a/pkg/game/game.go b/pkg/repository/repository.go similarity index 77% rename from pkg/game/game.go rename to pkg/repository/repository.go index f3ca35d..bd2a102 100644 --- a/pkg/game/game.go +++ b/pkg/repository/repository.go @@ -1,4 +1,4 @@ -package game +package repository import ( "cloudsave/pkg/tools/id" @@ -7,6 +7,7 @@ import ( "encoding/json" "fmt" "io" + "io/fs" "os" "path/filepath" "time" @@ -135,6 +136,65 @@ func One(gameID string) (Metadata, error) { return m, nil } +func Archive(gameID string) error { + path := filepath.Join(datastorepath, gameID, "data.tar.gz") + + // open old + f, err := os.OpenFile(path, os.O_RDONLY, 0) + if err != nil { + return fmt.Errorf("failed to open old file: %w", err) + } + defer f.Close() + + histDirPath := filepath.Join(datastorepath, gameID, "hist") + if err := os.MkdirAll(histDirPath, 0740); err != nil { + return fmt.Errorf("failed to make 'hist' directory") + } + + d, err := os.ReadDir(histDirPath) + if err != nil { + return fmt.Errorf("failed to open 'hist' directory") + } + + // keep the dir under 6 files + if len(d) > 5 { + var oldest *fs.FileInfo + for _, hfile := range d { + finfo, err := hfile.Info() + if err != nil { + return fmt.Errorf("failed to read backup file: %w", err) + } + + if oldest == nil { + oldest = &finfo + continue + } + + if finfo.ModTime().Before((*oldest).ModTime()) { + oldest = &finfo + } + } + + if err := os.Remove((*oldest).Name()); err != nil { + return fmt.Errorf("failed to remove the oldest backup file: %w", err) + } + } + + // open new + nf, err := os.OpenFile(filepath.Join(datastorepath, gameID, "hist", time.Now().Format("2006-01-02T15-04-05Z07-00")+".data.tar.gz"), os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0740) + if err != nil { + return fmt.Errorf("failed to open new file: %w", err) + } + defer nf.Close() + + // copy + if _, err := io.Copy(nf, f); err != nil { + return fmt.Errorf("failed to copy data: %w", err) + } + + return nil +} + func DatastorePath() string { return datastorepath } diff --git a/pkg/tools/archive/archive.go b/pkg/tools/archive/archive.go index 63b1688..97f40bb 100644 --- a/pkg/tools/archive/archive.go +++ b/pkg/tools/archive/archive.go @@ -3,6 +3,7 @@ package archive import ( "archive/tar" "compress/gzip" + "fmt" "io" "os" "path/filepath" @@ -71,3 +72,49 @@ func Untar(file io.Reader, path string) error { } } } + +func Tar(file io.Writer, path string) error { + gw := gzip.NewWriter(file) + defer gw.Close() + + tw := tar.NewWriter(gw) + defer tw.Close() + + // Walk again to add files + err := filepath.Walk(path, func(path string, info os.FileInfo, walkErr error) error { + if walkErr != nil { + return walkErr + } + // Create tar header + header, err := tar.FileInfoHeader(info, path) + if err != nil { + return err + } + // Preserve directory structure relative to srcDir + relPath, err := filepath.Rel(filepath.Dir(path), path) + if err != nil { + return err + } + header.Name = relPath + + if err := tw.WriteHeader(header); err != nil { + return err + } + if info.Mode().IsRegular() { + file, err := os.Open(path) + if err != nil { + return err + } + defer file.Close() + if _, err := io.Copy(tw, file); err != nil { + return err + } + } + return nil + }) + if err != nil { + return fmt.Errorf("writing tar entries: %w", err) + } + + return nil +}