This commit is contained in:
2025-07-29 17:04:45 +02:00
parent d8e0bffe56
commit 68e13938b7
10 changed files with 276 additions and 176 deletions

1
.gitignore vendored
View File

@@ -1,2 +1,3 @@
/cli /cli
/server /server
/env/

2
.vscode/launch.json vendored
View File

@@ -10,7 +10,7 @@
"type": "go", "type": "go",
"request": "launch", "request": "launch",
"mode": "auto", "mode": "auto",
"args": ["sync"], "args": ["list", "-a", "http://localhost:8080"],
"console": "integratedTerminal", "console": "integratedTerminal",
"program": "${workspaceFolder}/cmd/cli" "program": "${workspaceFolder}/cmd/cli"
} }

View File

@@ -41,7 +41,7 @@ func (p *AddCmd) Execute(_ context.Context, f *flag.FlagSet, _ ...interface{}) s
} }
if p.name == "" { if p.name == "" {
p.name = filepath.Base(filepath.Dir(path)) p.name = filepath.Base(path)
} }
m, err := game.Add(p.name, path) m, err := game.Add(p.name, path)

View File

@@ -2,6 +2,8 @@ package list
import ( import (
"cloudsave/pkg/game" "cloudsave/pkg/game"
"cloudsave/pkg/remote/client"
"cloudsave/pkg/tools/prompt/credentials"
"context" "context"
"flag" "flag"
"fmt" "fmt"
@@ -12,6 +14,7 @@ import (
type ( type (
ListCmd struct { ListCmd struct {
remote bool
} }
) )
@@ -24,20 +27,72 @@ func (*ListCmd) Usage() string {
} }
func (p *ListCmd) SetFlags(f *flag.FlagSet) { 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 { func (p *ListCmd) Execute(_ context.Context, f *flag.FlagSet, _ ...interface{}) subcommands.ExitStatus {
datastore, err := game.All() 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 { if err != nil {
fmt.Fprintln(os.Stderr, "error: failed to load datastore:", err) fmt.Fprintf(os.Stderr, "failed to read std output: %s", err)
return subcommands.ExitFailure return subcommands.ExitFailure
} }
fmt.Println("ID | NAME | PATH") if err := remote(f.Arg(0), username, password); err != nil {
fmt.Println("-- | ---- | ----") fmt.Fprintln(os.Stderr, "error:", err)
for _, metadata := range datastore { return subcommands.ExitFailure
fmt.Println(metadata.ID, "|", metadata.Name, "|", metadata.Path) }
return subcommands.ExitSuccess
}
if err := local(); err != nil {
fmt.Fprintln(os.Stderr, "error:", err)
return subcommands.ExitFailure
} }
return subcommands.ExitSuccess 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
}

View File

@@ -1,7 +1,9 @@
package remote package remote
import ( import (
"cloudsave/pkg/game"
"cloudsave/pkg/remote" "cloudsave/pkg/remote"
"cloudsave/pkg/remote/client"
"context" "context"
"flag" "flag"
"fmt" "fmt"
@@ -30,38 +32,60 @@ func (p *RemoteCmd) SetFlags(f *flag.FlagSet) {
f.BoolVar(&p.set, "set", false, "set remote for a game") f.BoolVar(&p.set, "set", false, "set remote for a game")
} }
func (p *RemoteCmd) Execute(_ context.Context, f *flag.FlagSet, _ ...interface{}) subcommands.ExitStatus { func (p *RemoteCmd) Execute(ctx context.Context, f *flag.FlagSet, _ ...interface{}) subcommands.ExitStatus {
if p.list { switch {
remotes, err := remote.All() case p.list:
if err != nil { {
fmt.Fprintln(os.Stderr, "error: failed to load datastore:", err) if err := list(); err != nil {
fmt.Fprintln(os.Stderr, "error:", err)
return subcommands.ExitFailure return subcommands.ExitFailure
} }
fmt.Println("ID | REMOTE URL")
fmt.Println("-- | ----------")
for _, remote := range remotes {
fmt.Println(remote.GameID, "|", remote.URL)
} }
return subcommands.ExitSuccess case p.set:
} {
if p.set {
if f.NArg() != 2 { if f.NArg() != 2 {
fmt.Fprintln(os.Stderr, "error: the command is expecting for 2 arguments") subcommands.HelpCommand().Execute(ctx, f, nil)
f.Usage()
return subcommands.ExitUsageError return subcommands.ExitUsageError
} }
if err := set(f.Arg(0), f.Arg(1)); err != nil {
err := remote.Set(f.Arg(0), f.Arg(1)) fmt.Fprintln(os.Stderr, "error:", err)
if err != nil {
fmt.Fprintln(os.Stderr, "error: failed to set remote:", err)
return subcommands.ExitFailure return subcommands.ExitFailure
} }
fmt.Println(f.Arg(0)) }
default:
{
subcommands.HelpCommand().Execute(ctx, f, nil)
return subcommands.ExitUsageError
}
}
return subcommands.ExitSuccess return subcommands.ExitSuccess
}
func list() error {
games, err := game.All()
if err != nil {
return fmt.Errorf("failed to load datastore: %w", err)
} }
f.Usage() for _, g := range games {
return subcommands.ExitUsageError 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)
} }

View File

@@ -7,6 +7,7 @@ import (
"cloudsave/pkg/remote/client" "cloudsave/pkg/remote/client"
"cloudsave/pkg/tools/prompt/credentials" "cloudsave/pkg/tools/prompt/credentials"
"context" "context"
"errors"
"flag" "flag"
"fmt" "fmt"
"log/slog" "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 { func (p *SyncCmd) Execute(_ context.Context, f *flag.FlagSet, _ ...interface{}) subcommands.ExitStatus {
remotes, err := remote.All() games, err := game.All()
if err != nil { if err != nil {
fmt.Fprintln(os.Stderr, "error: failed to load datastore:", err) fmt.Fprintln(os.Stderr, "error: failed to load datastore:", err)
return subcommands.ExitFailure return subcommands.ExitFailure
} }
if len(remotes) == 0 { remoteCred := make(map[string]map[string]string)
fmt.Println("nothing to do: no remote found") for _, g := range games {
return subcommands.ExitSuccess r, err := remote.One(g.ID)
}
username, password, err := credentials.Read()
if err != nil { if err != nil {
fmt.Fprintln(os.Stderr, "error: failed to read std output:", err) if errors.Is(err, remote.ErrNoRemote) {
return subcommands.ExitFailure
}
for _, r := range remotes {
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 continue
} }
fmt.Fprintln(os.Stderr, "error: failed to load datastore:", err)
return subcommands.ExitFailure
}
exists, err := client.Exists(r.GameID) 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 := cli.Exists(r.GameID)
if err != nil { if err != nil {
slog.Error(err.Error()) slog.Error(err.Error())
continue continue
} }
if !exists { 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) fmt.Fprintln(os.Stderr, "failed to push:", err)
return subcommands.ExitFailure return subcommands.ExitFailure
} }
@@ -85,7 +78,7 @@ func (p *SyncCmd) Execute(_ context.Context, f *flag.FlagSet, _ ...interface{})
continue continue
} }
hremote, err := client.Hash(r.GameID) hremote, err := cli.Hash(r.GameID)
if err != nil { if err != nil {
fmt.Fprintln(os.Stderr, "error: failed to get the file hash from the remote:", err) fmt.Fprintln(os.Stderr, "error: failed to get the file hash from the remote:", err)
continue continue
@@ -97,7 +90,7 @@ func (p *SyncCmd) Execute(_ context.Context, f *flag.FlagSet, _ ...interface{})
continue continue
} }
remoteMetadata, err := client.Metadata(r.GameID) remoteMetadata, err := cli.Metadata(r.GameID)
if err != nil { if err != nil {
fmt.Fprintln(os.Stderr, "error: failed to get the game metadata from the remote:", err) fmt.Fprintln(os.Stderr, "error: failed to get the game metadata from the remote:", err)
continue continue
@@ -116,7 +109,7 @@ func (p *SyncCmd) Execute(_ context.Context, f *flag.FlagSet, _ ...interface{})
} }
if vlocal > remoteMetadata.Version { 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) fmt.Fprintln(os.Stderr, "failed to push:", err)
return subcommands.ExitFailure return subcommands.ExitFailure
} }
@@ -124,7 +117,7 @@ func (p *SyncCmd) Execute(_ context.Context, f *flag.FlagSet, _ ...interface{})
} }
if vlocal < remoteMetadata.Version { 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) fmt.Fprintln(os.Stderr, "failed to push:", err)
return subcommands.ExitFailure return subcommands.ExitFailure
} }
@@ -140,15 +133,26 @@ func (p *SyncCmd) Execute(_ context.Context, f *flag.FlagSet, _ ...interface{})
} }
if vlocal == remoteMetadata.Version { if vlocal == remoteMetadata.Version {
g, err := game.One(r.GameID) if err := conflict(r.GameID, g, remoteMetadata, cli); err != nil {
fmt.Fprintln(os.Stderr, "error: failed to resolve conflict:", 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 { if err != nil {
slog.Warn("a conflict was found but the game is not found in the database") slog.Warn("a conflict was found but the game is not found in the database")
slog.Debug("debug info", "gameID", r.GameID) slog.Debug("debug info", "gameID", gameID)
continue return nil
} }
fmt.Println() fmt.Println()
fmt.Println("--- /!\\ CONFLICT ---") fmt.Println("--- /!\\ CONFLICT ---")
fmt.Println("----")
fmt.Println(g.Name, "(", g.Path, ")") fmt.Println(g.Name, "(", g.Path, ")")
fmt.Println("----") fmt.Println("----")
fmt.Println("Your version:", g.Date.Format(time.RFC1123)) fmt.Println("Your version:", g.Date.Format(time.RFC1123))
@@ -160,34 +164,25 @@ func (p *SyncCmd) Execute(_ context.Context, f *flag.FlagSet, _ ...interface{})
switch res { switch res {
case prompt.My: case prompt.My:
{ {
if err := push(r.GameID, m, client); err != nil { if err := push(gameID, m, cli); err != nil {
fmt.Fprintln(os.Stderr, "failed to push:", err) return fmt.Errorf("failed to push: %w", err)
return subcommands.ExitFailure
} }
} }
case prompt.Their: case prompt.Their:
{ {
if err := pull(r.GameID, client); err != nil { if err := pull(gameID, cli); err != nil {
fmt.Fprintln(os.Stderr, "failed to push:", err) return fmt.Errorf("failed to push: %w", err)
return subcommands.ExitFailure
} }
if err := game.SetVersion(r.GameID, remoteMetadata.Version); err != nil { if err := game.SetVersion(gameID, remoteMetadata.Version); err != nil {
fmt.Fprintln(os.Stderr, "error: failed to synchronize version number:", err) return fmt.Errorf("failed to synchronize version number: %w", err)
continue
} }
if err := game.SetDate(r.GameID, remoteMetadata.Date); err != nil { if err := game.SetDate(gameID, remoteMetadata.Date); err != nil {
fmt.Fprintln(os.Stderr, "error: failed to synchronize date:", err) return fmt.Errorf("failed to synchronize date: %w", err)
continue
} }
} }
} }
continue return nil
}
}
return subcommands.ExitSuccess
} }
func push(gameID string, m game.Metadata, cli *client.Client) error { func push(gameID string, m game.Metadata, cli *client.Client) error {
@@ -201,3 +196,30 @@ func pull(gameID string, cli *client.Client) error {
return cli.Pull(gameID, archivePath) 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
}

View File

@@ -10,7 +10,6 @@ import (
"cloudsave/cmd/cli/commands/version" "cloudsave/cmd/cli/commands/version"
"context" "context"
"flag" "flag"
"fmt"
"os" "os"
"github.com/google/subcommands" "github.com/google/subcommands"
@@ -33,8 +32,5 @@ func main() {
flag.Parse() flag.Parse()
ctx := context.Background() ctx := context.Background()
exitCode := subcommands.Execute(ctx) os.Exit(int(subcommands.Execute(ctx)))
fmt.Println()
os.Exit(int(exitCode))
} }

View File

@@ -6,8 +6,10 @@ import (
"crypto/md5" "crypto/md5"
"encoding/hex" "encoding/hex"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"io" "io"
"log/slog"
"net/http" "net/http"
"os" "os"
"path/filepath" "path/filepath"
@@ -44,9 +46,9 @@ func NewServer(documentRoot string, creds map[string]string, port int) *HTTPServ
router.Use(recoverMiddleware) router.Use(recoverMiddleware)
router.Use(middleware.GetHead) router.Use(middleware.GetHead)
router.Use(middleware.Compress(5, "application/gzip")) router.Use(middleware.Compress(5, "application/gzip"))
router.Use(BasicAuth("cloudsave", creds))
router.Use(middleware.Heartbeat("/heartbeat")) router.Use(middleware.Heartbeat("/heartbeat"))
router.Route("/api", func(routerAPI chi.Router) { router.Route("/api", func(routerAPI chi.Router) {
routerAPI.Use(BasicAuth("cloudsave", creds))
routerAPI.Route("/v1", func(r chi.Router) { routerAPI.Route("/v1", func(r chi.Router) {
// Get information about the server // Get information about the server
r.Get("/version", s.Information) 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) { 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 { if err != nil {
fmt.Fprintln(os.Stderr, "failed to open datastore (", s.documentRoot, "):", err) fmt.Fprintln(os.Stderr, "failed to open datastore (", s.documentRoot, "):", err)
internalServerError(w, r) internalServerError(w, r)
return return
} }
datastore := make([]game.Metadata, 0)
for _, d := range ds { 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 { if err != nil {
slog.Error("error: failed to load metadata.json", "err", err)
continue continue
} }

View File

@@ -211,35 +211,62 @@ func (c *Client) Pull(gameID, archivePath string) error {
return nil return nil
} }
func (c *Client) Ping() bool { func (c *Client) Ping() error {
cli := http.Client{} cli := http.Client{}
hburl, err := url.JoinPath(c.baseURL, "heartbeat") hburl, err := url.JoinPath(c.baseURL, "heartbeat")
if err != nil { if err != nil {
fmt.Fprintln(os.Stderr, "cannot connect to remote:", err) return err
return false
} }
req, err := http.NewRequest("GET", hburl, nil) req, err := http.NewRequest("GET", hburl, nil)
if err != nil { if err != nil {
fmt.Fprintln(os.Stderr, "cannot connect to remote:", err) return err
return false
} }
req.SetBasicAuth(c.username, c.password) req.SetBasicAuth(c.username, c.password)
res, err := cli.Do(req) res, err := cli.Do(req)
if err != nil { if err != nil {
fmt.Fprintln(os.Stderr, "cannot connect to remote:", err) return err
return false
} }
if res.StatusCode != http.StatusOK { if res.StatusCode != http.StatusOK {
fmt.Fprintln(os.Stderr, "cannot connect to remote: server return code", res.StatusCode) return fmt.Errorf("cannot connect to remote: server return code %s", res.Status)
return false
} }
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) { func (c *Client) get(url string) (obj.HTTPObject, error) {

View File

@@ -1,8 +1,8 @@
package remote package remote
import ( import (
"cloudsave/pkg/game"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"os" "os"
"path/filepath" "path/filepath"
@@ -20,6 +20,10 @@ var (
datastorepath string datastorepath string
) )
var (
ErrNoRemote error = errors.New("no remote found for this game")
)
func init() { func init() {
var err error var err error
roaming, err = os.UserConfigDir() 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) { func One(gameID string) (Remote, error) {
content, err := os.ReadFile(filepath.Join(datastorepath, gameID, "remote.json")) content, err := os.ReadFile(filepath.Join(datastorepath, gameID, "remote.json"))
if err != nil { if err != nil {
if errors.Is(err, os.ErrNotExist) {
return Remote{}, ErrNoRemote
}
return Remote{}, err 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) 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")) r.GameID = gameID
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
return r, nil return r, nil
} }
@@ -102,7 +62,7 @@ func Set(gameID, url string) error {
URL: url, 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 { if err != nil {
return err return err
} }