diff --git a/.gitignore b/.gitignore index a76f226..4f1cdce 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ /cli -/server \ No newline at end of file +/server +/env/ \ No newline at end of file diff --git a/.vscode/launch.json b/.vscode/launch.json index 0200674..3d7adf9 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -10,7 +10,7 @@ "type": "go", "request": "launch", "mode": "auto", - "args": ["sync"], + "args": ["list", "-a", "http://localhost:8080"], "console": "integratedTerminal", "program": "${workspaceFolder}/cmd/cli" } diff --git a/cmd/cli/commands/add/add.go b/cmd/cli/commands/add/add.go index b8cfe86..84ee030 100644 --- a/cmd/cli/commands/add/add.go +++ b/cmd/cli/commands/add/add.go @@ -41,7 +41,7 @@ func (p *AddCmd) Execute(_ context.Context, f *flag.FlagSet, _ ...interface{}) s } if p.name == "" { - p.name = filepath.Base(filepath.Dir(path)) + p.name = filepath.Base(path) } m, err := game.Add(p.name, path) diff --git a/cmd/cli/commands/list/list.go b/cmd/cli/commands/list/list.go index 9c72367..3a763b5 100644 --- a/cmd/cli/commands/list/list.go +++ b/cmd/cli/commands/list/list.go @@ -2,6 +2,8 @@ package list import ( "cloudsave/pkg/game" + "cloudsave/pkg/remote/client" + "cloudsave/pkg/tools/prompt/credentials" "context" "flag" "fmt" @@ -12,6 +14,7 @@ import ( type ( ListCmd struct { + remote bool } ) @@ -24,20 +27,72 @@ func (*ListCmd) Usage() string { } func (p *ListCmd) SetFlags(f *flag.FlagSet) { + f.BoolVar(&p.remote, "a", false, "list all including remote data") } func (p *ListCmd) Execute(_ context.Context, f *flag.FlagSet, _ ...interface{}) subcommands.ExitStatus { - datastore, err := game.All() - if err != nil { - fmt.Fprintln(os.Stderr, "error: failed to load datastore:", err) + if p.remote { + if f.NArg() != 1 { + fmt.Fprintln(os.Stderr, "error: missing remote url") + return subcommands.ExitUsageError + } + + username, password, err := credentials.Read() + if err != nil { + fmt.Fprintf(os.Stderr, "failed to read std output: %s", err) + return subcommands.ExitFailure + } + + if err := remote(f.Arg(0), username, password); err != nil { + fmt.Fprintln(os.Stderr, "error:", err) + return subcommands.ExitFailure + } + return subcommands.ExitSuccess + } + if err := local(); err != nil { + fmt.Fprintln(os.Stderr, "error:", err) return subcommands.ExitFailure } - - fmt.Println("ID | NAME | PATH") - fmt.Println("-- | ---- | ----") - for _, metadata := range datastore { - fmt.Println(metadata.ID, "|", metadata.Name, "|", metadata.Path) - } - return subcommands.ExitSuccess } + +func local() error { + games, err := game.All() + if err != nil { + return fmt.Errorf("failed to load datastore: %w", err) + } + + for _, g := range games { + fmt.Println("ID:", g.ID) + fmt.Println("Name:", g.Name) + fmt.Println("Last Version:", g.Date, "( Version Number", g.Version, ")") + fmt.Println("---") + } + + return nil +} + +func remote(url, username, password string) error { + cli := client.New(url, username, password) + + if err := cli.Ping(); err != nil { + return fmt.Errorf("failed to connect to the remote: %w", err) + } + + games, err := cli.All() + if err != nil { + return fmt.Errorf("failed to load games from remote: %w", err) + } + + fmt.Println() + fmt.Println("Remote:", url) + fmt.Println("---") + for _, g := range games { + fmt.Println("ID:", g.ID) + fmt.Println("Name:", g.Name) + fmt.Println("Last Version:", g.Date, "( Version Number", g.Version, ")") + fmt.Println("---") + } + + return nil +} diff --git a/cmd/cli/commands/remote/remote.go b/cmd/cli/commands/remote/remote.go index 86530c5..d1eebb1 100644 --- a/cmd/cli/commands/remote/remote.go +++ b/cmd/cli/commands/remote/remote.go @@ -1,7 +1,9 @@ package remote import ( + "cloudsave/pkg/game" "cloudsave/pkg/remote" + "cloudsave/pkg/remote/client" "context" "flag" "fmt" @@ -30,38 +32,60 @@ func (p *RemoteCmd) SetFlags(f *flag.FlagSet) { f.BoolVar(&p.set, "set", false, "set remote for a game") } -func (p *RemoteCmd) Execute(_ context.Context, f *flag.FlagSet, _ ...interface{}) subcommands.ExitStatus { - if p.list { - remotes, err := remote.All() - if err != nil { - fmt.Fprintln(os.Stderr, "error: failed to load datastore:", err) - return subcommands.ExitFailure +func (p *RemoteCmd) Execute(ctx context.Context, f *flag.FlagSet, _ ...interface{}) subcommands.ExitStatus { + switch { + case p.list: + { + if err := list(); err != nil { + fmt.Fprintln(os.Stderr, "error:", err) + return subcommands.ExitFailure + } } - - fmt.Println("ID | REMOTE URL") - fmt.Println("-- | ----------") - for _, remote := range remotes { - fmt.Println(remote.GameID, "|", remote.URL) + case p.set: + { + if f.NArg() != 2 { + subcommands.HelpCommand().Execute(ctx, f, nil) + return subcommands.ExitUsageError + } + if err := set(f.Arg(0), f.Arg(1)); err != nil { + fmt.Fprintln(os.Stderr, "error:", err) + return subcommands.ExitFailure + } } - return subcommands.ExitSuccess - } - - if p.set { - if f.NArg() != 2 { - fmt.Fprintln(os.Stderr, "error: the command is expecting for 2 arguments") - f.Usage() + default: + { + subcommands.HelpCommand().Execute(ctx, f, nil) return subcommands.ExitUsageError } + } + return subcommands.ExitSuccess +} - err := remote.Set(f.Arg(0), f.Arg(1)) - if err != nil { - fmt.Fprintln(os.Stderr, "error: failed to set remote:", err) - return subcommands.ExitFailure - } - fmt.Println(f.Arg(0)) - return subcommands.ExitSuccess +func list() error { + games, err := game.All() + if err != nil { + return fmt.Errorf("failed to load datastore: %w", err) } - f.Usage() - return subcommands.ExitUsageError + for _, g := range games { + r, err := remote.One(g.ID) + if err != nil { + return fmt.Errorf("failed to load datastore: %w", err) + } + + cli := client.New(r.URL, "", "") + + status := "OK" + if err := cli.Ping(); err != nil { + status = "ERROR: " + err.Error() + } + + fmt.Printf("'%s' -> %s (%s)\n", g.Name, r.URL, status) + } + + return nil +} + +func set(gameID, url string) error { + return remote.Set(gameID, url) } diff --git a/cmd/cli/commands/sync/sync.go b/cmd/cli/commands/sync/sync.go index 1f4bf16..8be11bd 100644 --- a/cmd/cli/commands/sync/sync.go +++ b/cmd/cli/commands/sync/sync.go @@ -7,6 +7,7 @@ import ( "cloudsave/pkg/remote/client" "cloudsave/pkg/tools/prompt/credentials" "context" + "errors" "flag" "fmt" "log/slog" @@ -34,45 +35,37 @@ func (p *SyncCmd) SetFlags(f *flag.FlagSet) { } func (p *SyncCmd) Execute(_ context.Context, f *flag.FlagSet, _ ...interface{}) subcommands.ExitStatus { - remotes, err := remote.All() + games, err := game.All() if err != nil { fmt.Fprintln(os.Stderr, "error: failed to load datastore:", err) 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) - return subcommands.ExitFailure - } - - for _, r := range remotes { - m, err := game.One(r.GameID) + remoteCred := make(map[string]map[string]string) + for _, g := range games { + r, err := remote.One(g.ID) if err != nil { - fmt.Fprintln(os.Stderr, "error: cannot get metadata for this game: %w", err) + if errors.Is(err, remote.ErrNoRemote) { + continue + } + fmt.Fprintln(os.Stderr, "error: failed to load datastore:", err) return subcommands.ExitFailure } - client := client.New(r.URL, username, password) - - if !client.Ping() { - slog.Warn("remote is unavailable", "url", r.URL) - continue + cli, err := connect(remoteCred, r) + if err != nil { + fmt.Fprintln(os.Stderr, "error: failed to connect to the remote:", err) + return subcommands.ExitFailure } - exists, err := client.Exists(r.GameID) + exists, err := cli.Exists(r.GameID) if err != nil { slog.Error(err.Error()) continue } if !exists { - if err := push(r.GameID, m, client); err != nil { + if err := push(r.GameID, g, cli); err != nil { fmt.Fprintln(os.Stderr, "failed to push:", err) return subcommands.ExitFailure } @@ -85,7 +78,7 @@ func (p *SyncCmd) Execute(_ context.Context, f *flag.FlagSet, _ ...interface{}) continue } - hremote, err := client.Hash(r.GameID) + hremote, err := cli.Hash(r.GameID) if err != nil { fmt.Fprintln(os.Stderr, "error: failed to get the file hash from the remote:", err) continue @@ -97,7 +90,7 @@ func (p *SyncCmd) Execute(_ context.Context, f *flag.FlagSet, _ ...interface{}) continue } - remoteMetadata, err := client.Metadata(r.GameID) + remoteMetadata, err := cli.Metadata(r.GameID) if err != nil { fmt.Fprintln(os.Stderr, "error: failed to get the game metadata from the remote:", err) continue @@ -116,7 +109,7 @@ func (p *SyncCmd) Execute(_ context.Context, f *flag.FlagSet, _ ...interface{}) } if vlocal > remoteMetadata.Version { - if err := push(r.GameID, m, client); err != nil { + if err := push(r.GameID, g, cli); err != nil { fmt.Fprintln(os.Stderr, "failed to push:", err) return subcommands.ExitFailure } @@ -124,7 +117,7 @@ func (p *SyncCmd) Execute(_ context.Context, f *flag.FlagSet, _ ...interface{}) } if vlocal < remoteMetadata.Version { - if err := pull(r.GameID, client); err != nil { + if err := pull(r.GameID, cli); err != nil { fmt.Fprintln(os.Stderr, "failed to push:", err) return subcommands.ExitFailure } @@ -140,56 +133,58 @@ func (p *SyncCmd) Execute(_ context.Context, f *flag.FlagSet, _ ...interface{}) } if vlocal == remoteMetadata.Version { - g, err := game.One(r.GameID) - if err != nil { - slog.Warn("a conflict was found but the game is not found in the database") - slog.Debug("debug info", "gameID", r.GameID) + if err := conflict(r.GameID, g, remoteMetadata, cli); err != nil { + fmt.Fprintln(os.Stderr, "error: failed to resolve conflict:", err) continue } - fmt.Println() - fmt.Println("--- /!\\ CONFLICT ---") - fmt.Println("----") - fmt.Println(g.Name, "(", g.Path, ")") - fmt.Println("----") - fmt.Println("Your version:", g.Date.Format(time.RFC1123)) - fmt.Println("Their version:", remoteMetadata.Date.Format(time.RFC1123)) - fmt.Println() - - res := prompt.Conflict() - - switch res { - case prompt.My: - { - if err := push(r.GameID, m, client); err != nil { - fmt.Fprintln(os.Stderr, "failed to push:", err) - return subcommands.ExitFailure - } - } - - case prompt.Their: - { - if err := pull(r.GameID, client); err != nil { - fmt.Fprintln(os.Stderr, "failed to push:", err) - return subcommands.ExitFailure - } - if err := game.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 { - fmt.Fprintln(os.Stderr, "error: failed to synchronize date:", err) - continue - } - } - } continue } - } return subcommands.ExitSuccess } +func conflict(gameID string, m, remoteMetadata game.Metadata, cli *client.Client) error { + g, err := game.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) + return nil + } + fmt.Println() + fmt.Println("--- /!\\ CONFLICT ---") + fmt.Println(g.Name, "(", g.Path, ")") + fmt.Println("----") + fmt.Println("Your version:", g.Date.Format(time.RFC1123)) + fmt.Println("Their version:", remoteMetadata.Date.Format(time.RFC1123)) + fmt.Println() + + res := prompt.Conflict() + + switch res { + case prompt.My: + { + if err := push(gameID, m, cli); err != nil { + return fmt.Errorf("failed to push: %w", err) + } + } + + case prompt.Their: + { + if err := pull(gameID, cli); err != nil { + return fmt.Errorf("failed to push: %w", err) + } + if err := game.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 { + return fmt.Errorf("failed to synchronize date: %w", err) + } + } + } + return nil +} + func push(gameID string, m game.Metadata, cli *client.Client) error { archivePath := filepath.Join(game.DatastorePath(), gameID, "data.tar.gz") @@ -201,3 +196,30 @@ func pull(gameID string, cli *client.Client) error { return cli.Pull(gameID, archivePath) } + +func connect(remoteCred map[string]map[string]string, r remote.Remote) (*client.Client, error) { + var cli *client.Client + + if v, ok := remoteCred[r.URL]; ok { + cli = client.New(r.URL, v["username"], v["password"]) + return cli, nil + } + + username, password, err := credentials.Read() + if err != nil { + return nil, fmt.Errorf("failed to read std output: %w", err) + } + + cli = client.New(r.URL, username, password) + + if err := cli.Ping(); err != nil { + return nil, fmt.Errorf("failed to connect to the remote: %w", err) + } + + c := make(map[string]string) + c["username"] = username + c["password"] = password + remoteCred[r.URL] = c + + return cli, nil +} diff --git a/cmd/cli/main.go b/cmd/cli/main.go index 7bb4312..8bd433d 100644 --- a/cmd/cli/main.go +++ b/cmd/cli/main.go @@ -10,7 +10,6 @@ import ( "cloudsave/cmd/cli/commands/version" "context" "flag" - "fmt" "os" "github.com/google/subcommands" @@ -33,8 +32,5 @@ func main() { flag.Parse() ctx := context.Background() - exitCode := subcommands.Execute(ctx) - fmt.Println() - - os.Exit(int(exitCode)) + os.Exit(int(subcommands.Execute(ctx))) } diff --git a/cmd/server/api/api.go b/cmd/server/api/api.go index 37d4b58..aaa5e51 100644 --- a/cmd/server/api/api.go +++ b/cmd/server/api/api.go @@ -6,8 +6,10 @@ import ( "crypto/md5" "encoding/hex" "encoding/json" + "errors" "fmt" "io" + "log/slog" "net/http" "os" "path/filepath" @@ -44,9 +46,9 @@ func NewServer(documentRoot string, creds map[string]string, port int) *HTTPServ router.Use(recoverMiddleware) router.Use(middleware.GetHead) router.Use(middleware.Compress(5, "application/gzip")) - router.Use(BasicAuth("cloudsave", creds)) router.Use(middleware.Heartbeat("/heartbeat")) router.Route("/api", func(routerAPI chi.Router) { + routerAPI.Use(BasicAuth("cloudsave", creds)) routerAPI.Route("/v1", func(r chi.Router) { // Get information about the server r.Get("/version", s.Information) @@ -75,17 +77,30 @@ func NewServer(documentRoot string, creds map[string]string, port int) *HTTPServ } func (s HTTPServer) all(w http.ResponseWriter, r *http.Request) { - ds, err := os.ReadDir(s.documentRoot) + path := filepath.Join(s.documentRoot, "data") + datastore := make([]game.Metadata, 0) + + if _, err := os.Stat(path); err != nil { + if errors.Is(err, os.ErrNotExist) { + ok(datastore, w, r) + return + } + fmt.Fprintln(os.Stderr, "failed to open datastore (", s.documentRoot, "):", err) + internalServerError(w, r) + return + } + + ds, err := os.ReadDir(path) if err != nil { fmt.Fprintln(os.Stderr, "failed to open datastore (", s.documentRoot, "):", err) internalServerError(w, r) return } - datastore := make([]game.Metadata, 0) for _, d := range ds { - content, err := os.ReadFile(filepath.Join(s.documentRoot, d.Name(), "metadata.json")) + content, err := os.ReadFile(filepath.Join(path, d.Name(), "metadata.json")) if err != nil { + slog.Error("error: failed to load metadata.json", "err", err) continue } diff --git a/pkg/remote/client/client.go b/pkg/remote/client/client.go index 9391bed..a0a3d07 100644 --- a/pkg/remote/client/client.go +++ b/pkg/remote/client/client.go @@ -211,35 +211,62 @@ func (c *Client) Pull(gameID, archivePath string) error { return nil } -func (c *Client) Ping() bool { +func (c *Client) Ping() error { cli := http.Client{} hburl, err := url.JoinPath(c.baseURL, "heartbeat") if err != nil { - fmt.Fprintln(os.Stderr, "cannot connect to remote:", err) - return false + return err } req, err := http.NewRequest("GET", hburl, nil) if err != nil { - fmt.Fprintln(os.Stderr, "cannot connect to remote:", err) - return false + return err } 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 + return err } if res.StatusCode != http.StatusOK { - fmt.Fprintln(os.Stderr, "cannot connect to remote: server return code", res.StatusCode) - return false + return fmt.Errorf("cannot connect to remote: server return code %s", res.Status) } - return true + return nil +} + +func (c *Client) All() ([]game.Metadata, error) { + u, err := url.JoinPath(c.baseURL, "api", "v1", "games") + if err != nil { + return nil, err + } + + o, err := c.get(u) + if err != nil { + return nil, err + } + + if games, ok := (o.Data).([]any); ok { + var res []game.Metadata + for _, g := range games { + if v, ok := g.(map[string]any); ok { + gm := game.Metadata{ + ID: v["id"].(string), + Name: v["name"].(string), + Version: int(v["version"].(float64)), + Date: customtime.MustParse(time.RFC3339, v["date"].(string)), + } + res = append(res, gm) + } + } + + return res, nil + } + + return nil, errors.New("invalid payload sent by the server") } func (c *Client) get(url string) (obj.HTTPObject, error) { diff --git a/pkg/remote/remote.go b/pkg/remote/remote.go index 0507db6..b9ffd6c 100644 --- a/pkg/remote/remote.go +++ b/pkg/remote/remote.go @@ -1,8 +1,8 @@ package remote import ( - "cloudsave/pkg/game" "encoding/json" + "errors" "fmt" "os" "path/filepath" @@ -20,6 +20,10 @@ var ( datastorepath string ) +var ( + ErrNoRemote error = errors.New("no remote found for this game") +) + func init() { var err error roaming, err = os.UserConfigDir() @@ -34,45 +38,12 @@ func init() { } } -func All() ([]Remote, error) { - ds, err := os.ReadDir(datastorepath) - if err != nil { - return nil, fmt.Errorf("cannot open the datastore: %w", err) - } - - var remotes []Remote - for _, d := range ds { - content, err := os.ReadFile(filepath.Join(datastorepath, d.Name(), "remote.json")) - if err != nil { - continue - } - - var r Remote - err = json.Unmarshal(content, &r) - if err != nil { - return nil, fmt.Errorf("corrupted datastore: failed to parse %s/remote.json: %w", d.Name(), err) - } - - content, err = os.ReadFile(filepath.Join(datastorepath, d.Name(), "metadata.json")) - if err != nil { - return nil, fmt.Errorf("corrupted datastore: failed to read %s/metadata.json: %w", d.Name(), err) - } - - var m game.Metadata - err = json.Unmarshal(content, &m) - if err != nil { - return nil, fmt.Errorf("corrupted datastore: failed to parse %s/metadata.json: %w", d.Name(), err) - } - - r.GameID = m.ID - remotes = append(remotes, r) - } - return remotes, nil -} - func One(gameID string) (Remote, error) { content, err := os.ReadFile(filepath.Join(datastorepath, gameID, "remote.json")) if err != nil { + if errors.Is(err, os.ErrNotExist) { + return Remote{}, ErrNoRemote + } return Remote{}, err } @@ -82,18 +53,7 @@ func One(gameID string) (Remote, error) { return Remote{}, fmt.Errorf("corrupted datastore: failed to parse %s/remote.json: %w", gameID, err) } - content, err = os.ReadFile(filepath.Join(datastorepath, gameID, "metadata.json")) - if err != nil { - return Remote{}, fmt.Errorf("corrupted datastore: failed to read %s/metadata.json: %w", gameID, err) - } - - var m game.Metadata - err = json.Unmarshal(content, &m) - if err != nil { - return Remote{}, fmt.Errorf("corrupted datastore: failed to parse %s/metadata.json: %w", gameID, err) - } - - r.GameID = m.ID + r.GameID = gameID return r, nil } @@ -102,7 +62,7 @@ func Set(gameID, url string) error { URL: url, } - f, err := os.OpenFile(filepath.Join(datastorepath, gameID, "remote.json"), os.O_WRONLY|os.O_CREATE, 0740) + f, err := os.OpenFile(filepath.Join(datastorepath, gameID, "remote.json"), os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0740) if err != nil { return err }