7 Commits

Author SHA1 Message Date
4ee8fe48bc Merge pull request '0.0.2' (#1) from 0.0.2 into main
Reviewed-on: #1
2025-07-31 21:28:49 +02:00
f2fee0990b fix rel path error 2025-07-31 21:23:44 +02:00
95857356ab fix build script 2025-07-31 15:44:40 +02:00
58c6bc56cf apply backup 2025-07-30 17:20:43 +02:00
30b76e1887 push backup 2025-07-30 15:14:01 +02:00
c6edb91f29 starting 0.0.2 dev 2025-07-30 00:49:22 +02:00
c099d3e64f fix build.sh 2025-07-29 20:11:26 +02:00
21 changed files with 891 additions and 190 deletions

2
.vscode/launch.json vendored
View File

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

View File

@@ -1,6 +1,7 @@
#!/bin/bash #!/bin/bash
MAKE_PACKAGE=false MAKE_PACKAGE=false
VERSION=0.0.2
usage() { usage() {
echo "Usage: $0 [OPTIONS]" echo "Usage: $0 [OPTIONS]"
@@ -35,20 +36,22 @@ fi
## SERVER ## SERVER
platforms=("linux/amd64" "linux/arm64" "linux/riscv64" "linux/ppc64le") platforms=("linux/amd64" "linux/arm64" "linux/riscv64" "linux/ppc64le")
CGO_ENABLED=0
for platform in "${platforms[@]}"; do for platform in "${platforms[@]}"; do
echo "* Compiling server for $platform..." echo "* Compiling server for $platform..."
platform_split=(${platform//\// }) platform_split=(${platform//\// })
GOOS=${platform_split[0]} EXT=""
GOARCH=${platform_split[1]} if [ "${platform_split[0]}" == "windows" ]; then
EXT=.exe
go build -o build/server_${platform_split[0]}_${platform_split[1]}.bin ./cmd/server fi
if [ "$MAKE_PACKAGE" == "true" ]; then if [ "$MAKE_PACKAGE" == "true" ]; then
tar -czf build/server_${platform_split[0]}_${platform_split[1]}.tar.gz build/server_${platform_split[0]}_${platform_split[1]}.bin CGO_ENABLED=0 GOOS=${platform_split[0]} GOARCH=${platform_split[1]} go build -o build/cloudsave_server$EXT -a ./cmd/server
rm build/server_${platform_split[0]}_${platform_split[1]}.bin tar -czf build/server_${platform_split[0]}_${platform_split[1]}.tar.gz build/cloudsave_server$EXT
rm build/cloudsave_server$EXT
else
CGO_ENABLED=0 GOOS=${platform_split[0]} GOARCH=${platform_split[1]} go build -o build/cloudsave_server_${platform_split[0]}_${platform_split[1]}$EXT -a ./cmd/server
fi fi
done done
@@ -60,12 +63,16 @@ for platform in "${platforms[@]}"; do
echo "* Compiling client for $platform..." echo "* Compiling client for $platform..."
platform_split=(${platform//\// }) platform_split=(${platform//\// })
GOOS=${platform_split[0]} EXT=""
GOARCH=${platform_split[1]} if [ "${platform_split[0]}" == "windows" ]; then
EXT=.exe
fi
go build -o build/cli_${platform_split[0]}_${platform_split[1]}.bin ./cmd/cli
if [ "$MAKE_PACKAGE" == "true" ]; then if [ "$MAKE_PACKAGE" == "true" ]; then
tar -czf build/cli_${platform_split[0]}_${platform_split[1]}.tar.gz build/cli_${platform_split[0]}_${platform_split[1]}.bin CGO_ENABLED=0 GOOS=${platform_split[0]} GOARCH=${platform_split[1]} go build -o build/cloudsave$EXT -a ./cmd/cli
rm build/cli_${platform_split[0]}_${platform_split[1]}.bin tar -czf build/cli_${platform_split[0]}_${platform_split[1]}.tar.gz build/cloudsave$EXT
rm build/cloudsave$EXT
else
CGO_ENABLED=0 GOOS=${platform_split[0]} GOARCH=${platform_split[1]} go build -o build/cloudsave_${platform_split[0]}_${platform_split[1]}$EXT -a ./cmd/cli
fi fi
done done

View File

@@ -1,7 +1,7 @@
package add package add
import ( import (
"cloudsave/pkg/game" "cloudsave/pkg/repository"
"context" "context"
"flag" "flag"
"fmt" "fmt"
@@ -44,7 +44,7 @@ func (p *AddCmd) Execute(_ context.Context, f *flag.FlagSet, _ ...interface{}) s
p.name = filepath.Base(path) p.name = filepath.Base(path)
} }
m, err := game.Add(p.name, path) m, err := repository.Add(p.name, path)
if err != nil { if err != nil {
fmt.Fprintln(os.Stderr, "error: failed to add game reference:", err) fmt.Fprintln(os.Stderr, "error: failed to add game reference:", err)
return subcommands.ExitFailure return subcommands.ExitFailure

View File

@@ -0,0 +1,69 @@
package apply
import (
"cloudsave/pkg/repository"
"cloudsave/pkg/tools/archive"
"context"
"flag"
"fmt"
"os"
"path/filepath"
"github.com/google/subcommands"
)
type (
ListCmd struct {
}
)
func (*ListCmd) Name() string { return "apply" }
func (*ListCmd) Synopsis() string { return "apply a backup" }
func (*ListCmd) Usage() string {
return `apply:
Apply a backup
`
}
func (p *ListCmd) SetFlags(f *flag.FlagSet) {
}
func (p *ListCmd) Execute(_ context.Context, f *flag.FlagSet, _ ...interface{}) subcommands.ExitStatus {
if f.NArg() != 2 {
fmt.Fprintln(os.Stderr, "error: missing game ID and/or backup uuid")
return subcommands.ExitUsageError
}
gameID := f.Arg(0)
uuid := f.Arg(1)
g, err := repository.One(gameID)
if err != nil {
fmt.Fprintf(os.Stderr, "error: failed to open game metadata: %s\n", err)
return subcommands.ExitFailure
}
if err := repository.RestoreArchive(gameID, uuid); err != nil {
fmt.Fprintf(os.Stderr, "error: failed to restore backup: %s\n", err)
return subcommands.ExitFailure
}
if err := os.RemoveAll(g.Path); err != nil {
fmt.Fprintf(os.Stderr, "error: failed to remove old data: %s\n", err)
return subcommands.ExitFailure
}
file, err := os.OpenFile(filepath.Join(repository.DatastorePath(), gameID, "data.tar.gz"), os.O_RDONLY, 0)
if err != nil {
fmt.Fprintf(os.Stderr, "error: failed to open archive: %s\n", err)
return subcommands.ExitFailure
}
defer file.Close()
if err := archive.Untar(file, g.Path); err != nil {
fmt.Fprintf(os.Stderr, "error: failed to extract archive: %s\n", err)
return subcommands.ExitFailure
}
return subcommands.ExitSuccess
}

View File

@@ -1,9 +1,9 @@
package list package list
import ( import (
"cloudsave/pkg/game" "cloudsave/cmd/cli/tools/prompt/credentials"
"cloudsave/pkg/remote/client" "cloudsave/pkg/remote/client"
"cloudsave/pkg/tools/prompt/credentials" "cloudsave/pkg/repository"
"context" "context"
"flag" "flag"
"fmt" "fmt"
@@ -15,6 +15,7 @@ import (
type ( type (
ListCmd struct { ListCmd struct {
remote bool remote bool
backup bool
} }
) )
@@ -28,6 +29,7 @@ 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") f.BoolVar(&p.remote, "a", false, "list all including remote data")
f.BoolVar(&p.backup, "include-backup", false, "include backup uuids in the output")
} }
func (p *ListCmd) Execute(_ context.Context, f *flag.FlagSet, _ ...interface{}) subcommands.ExitStatus { func (p *ListCmd) Execute(_ context.Context, f *flag.FlagSet, _ ...interface{}) subcommands.ExitStatus {
@@ -43,21 +45,21 @@ func (p *ListCmd) Execute(_ context.Context, f *flag.FlagSet, _ ...interface{})
return subcommands.ExitFailure return subcommands.ExitFailure
} }
if err := remote(f.Arg(0), username, password); err != nil { if err := remote(f.Arg(0), username, password, p.backup); err != nil {
fmt.Fprintln(os.Stderr, "error:", err) fmt.Fprintln(os.Stderr, "error:", err)
return subcommands.ExitFailure return subcommands.ExitFailure
} }
return subcommands.ExitSuccess return subcommands.ExitSuccess
} }
if err := local(); err != nil { if err := local(p.backup); err != nil {
fmt.Fprintln(os.Stderr, "error:", err) fmt.Fprintln(os.Stderr, "error:", err)
return subcommands.ExitFailure return subcommands.ExitFailure
} }
return subcommands.ExitSuccess return subcommands.ExitSuccess
} }
func local() error { func local(includeBackup bool) error {
games, err := game.All() games, err := repository.All()
if err != nil { if err != nil {
return fmt.Errorf("failed to load datastore: %w", err) return fmt.Errorf("failed to load datastore: %w", err)
} }
@@ -66,13 +68,25 @@ func local() error {
fmt.Println("ID:", g.ID) fmt.Println("ID:", g.ID)
fmt.Println("Name:", g.Name) fmt.Println("Name:", g.Name)
fmt.Println("Last Version:", g.Date, "( Version Number", g.Version, ")") fmt.Println("Last Version:", g.Date, "( Version Number", g.Version, ")")
if includeBackup {
bk, err := repository.Archives(g.ID)
if err != nil {
return fmt.Errorf("failed to list backup files: %w", err)
}
if len(bk) > 0 {
fmt.Println("Backup:")
for _, b := range bk {
fmt.Printf(" - %s (%s)\n", b.UUID, b.CreatedAt)
}
}
}
fmt.Println("---") fmt.Println("---")
} }
return nil return nil
} }
func remote(url, username, password string) error { func remote(url, username, password string, includeBackup bool) error {
cli := client.New(url, username, password) cli := client.New(url, username, password)
if err := cli.Ping(); err != nil { if err := cli.Ping(); err != nil {
@@ -91,6 +105,22 @@ func remote(url, username, password string) error {
fmt.Println("ID:", g.ID) fmt.Println("ID:", g.ID)
fmt.Println("Name:", g.Name) fmt.Println("Name:", g.Name)
fmt.Println("Last Version:", g.Date, "( Version Number", g.Version, ")") fmt.Println("Last Version:", g.Date, "( Version Number", g.Version, ")")
if includeBackup {
bk, err := cli.ListArchives(g.ID)
if err != nil {
return fmt.Errorf("failed to list backup files: %w", err)
}
if len(bk) > 0 {
fmt.Println("Backup:")
for _, uuid := range bk {
b, err := cli.ArchiveInfo(g.ID, uuid)
if err != nil {
return fmt.Errorf("failed to list backup files: %w", err)
}
fmt.Printf(" - %s (%s)\n", b.UUID, b.CreatedAt)
}
}
}
fmt.Println("---") fmt.Println("---")
} }

View File

@@ -1,10 +1,10 @@
package pull package pull
import ( import (
"cloudsave/pkg/game"
"cloudsave/pkg/remote/client" "cloudsave/pkg/remote/client"
"cloudsave/pkg/repository"
"cloudsave/pkg/tools/archive" "cloudsave/pkg/tools/archive"
"cloudsave/pkg/tools/prompt/credentials" "cloudsave/cmd/cli/tools/prompt/credentials"
"context" "context"
"flag" "flag"
"fmt" "fmt"
@@ -54,7 +54,7 @@ func (p *PullCmd) Execute(_ context.Context, f *flag.FlagSet, _ ...interface{})
return subcommands.ExitFailure 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) m, err := cli.Metadata(gameID)
if err != nil { if err != nil {
@@ -62,7 +62,7 @@ func (p *PullCmd) Execute(_ context.Context, f *flag.FlagSet, _ ...interface{})
return subcommands.ExitFailure return subcommands.ExitFailure
} }
err = game.Register(m, path) err = repository.Register(m, path)
if err != nil { if err != nil {
fmt.Fprintf(os.Stderr, "failed to register local metadata: %s", err) fmt.Fprintf(os.Stderr, "failed to register local metadata: %s", err)
return subcommands.ExitFailure return subcommands.ExitFailure

View File

@@ -1,9 +1,9 @@
package remote package remote
import ( import (
"cloudsave/pkg/game"
"cloudsave/pkg/remote" "cloudsave/pkg/remote"
"cloudsave/pkg/remote/client" "cloudsave/pkg/remote/client"
"cloudsave/pkg/repository"
"context" "context"
"flag" "flag"
"fmt" "fmt"
@@ -62,7 +62,7 @@ func (p *RemoteCmd) Execute(ctx context.Context, f *flag.FlagSet, _ ...interface
} }
func list() error { func list() error {
games, err := game.All() games, err := repository.All()
if err != nil { if err != nil {
return fmt.Errorf("failed to load datastore: %w", err) return fmt.Errorf("failed to load datastore: %w", err)
} }

View File

@@ -1,7 +1,7 @@
package remove package remove
import ( import (
"cloudsave/pkg/game" "cloudsave/pkg/repository"
"context" "context"
"flag" "flag"
"fmt" "fmt"
@@ -31,7 +31,7 @@ func (p *RemoveCmd) Execute(_ context.Context, f *flag.FlagSet, _ ...interface{}
return subcommands.ExitUsageError return subcommands.ExitUsageError
} }
err := game.Remove(f.Arg(0)) err := repository.Remove(f.Arg(0))
if err != nil { if err != nil {
fmt.Fprintln(os.Stderr, "error: failed to unregister the game:", err) fmt.Fprintln(os.Stderr, "error: failed to unregister the game:", err)
return subcommands.ExitFailure return subcommands.ExitFailure

View File

@@ -1,9 +1,8 @@
package run package run
import ( import (
"archive/tar" "cloudsave/pkg/repository"
"cloudsave/pkg/game" "cloudsave/pkg/tools/archive"
"compress/gzip"
"context" "context"
"flag" "flag"
"fmt" "fmt"
@@ -32,49 +31,55 @@ func (*RunCmd) Usage() string {
func (p *RunCmd) SetFlags(f *flag.FlagSet) {} func (p *RunCmd) SetFlags(f *flag.FlagSet) {}
func (p *RunCmd) Execute(_ context.Context, f *flag.FlagSet, _ ...interface{}) subcommands.ExitStatus { func (p *RunCmd) Execute(_ context.Context, f *flag.FlagSet, _ ...interface{}) subcommands.ExitStatus {
datastore, err := game.All() datastore, err := repository.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
} }
pg := progressbar.New(len(datastore))
defer pg.Close()
for _, metadata := range datastore { for _, metadata := range datastore {
pg.Describe("Scanning " + metadata.Name + "...") metadataPath := filepath.Join(repository.DatastorePath(), metadata.ID)
metadataPath := filepath.Join(game.DatastorePath(), metadata.ID)
//todo transaction //todo transaction
err := archiveIfChanged(metadata.ID, metadata.Path, filepath.Join(metadataPath, "data.tar.gz"), filepath.Join(metadataPath, ".last_run")) err := archiveIfChanged(metadata.ID, metadata.Path, filepath.Join(metadataPath, "data.tar.gz"), filepath.Join(metadataPath, ".last_run"))
if err != nil { if err != nil {
fmt.Fprintf(os.Stderr, "error: cannot process the data of %s: %s\n", metadata.ID, err) fmt.Fprintf(os.Stderr, "error: cannot process the data of %s: %s\n", metadata.ID, err)
return subcommands.ExitFailure 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) fmt.Fprintf(os.Stderr, "error: cannot process the data of %s: %s\n", metadata.ID, err)
return subcommands.ExitFailure 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) fmt.Fprintf(os.Stderr, "error: cannot process the data of %s: %s\n", metadata.ID, err)
return subcommands.ExitFailure return subcommands.ExitFailure
} }
pg.Add(1) fmt.Println("✅", metadata.Name)
} }
pg.Finish() fmt.Println("done.")
return subcommands.ExitSuccess return subcommands.ExitSuccess
} }
// archiveIfChanged will archive srcDir into destTarGz only if any file // archiveIfChanged will archive srcDir into destTarGz only if any file
// in srcDir has a modification time > the last run time stored in stateFile. // in srcDir has a modification time > the last run time stored in stateFile.
// After archiving, it updates stateFile to the current time. // After archiving, it updates stateFile to the current time.
func archiveIfChanged(id, srcDir, destTarGz, stateFile string) error { func archiveIfChanged(gameID, srcDir, destTarGz, stateFile string) error {
// 1) Load last run time pg := progressbar.New(-1)
destroyPg := func() {
pg.Finish()
pg.Clear()
pg.Close()
}
defer destroyPg()
pg.Describe("Scanning " + gameID + "...")
// load last run time
var lastRun time.Time var lastRun time.Time
data, err := os.ReadFile(stateFile) data, err := os.ReadFile(stateFile)
if err != nil && !os.IsNotExist(err) { 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 { if err == nil {
lastRun, err = time.Parse(time.RFC3339, string(data)) lastRun, err = time.Parse(time.RFC3339, string(data))
@@ -83,7 +88,7 @@ func archiveIfChanged(id, srcDir, destTarGz, stateFile string) error {
} }
} }
// 2) Check for changes // check for changes
changed := false changed := false
err = filepath.Walk(srcDir, func(path string, info os.FileInfo, walkErr error) error { err = filepath.Walk(srcDir, func(path string, info os.FileInfo, walkErr error) error {
if walkErr != nil { if walkErr != nil {
@@ -96,63 +101,32 @@ func archiveIfChanged(id, srcDir, destTarGz, stateFile string) error {
return nil return nil
}) })
if err != nil && err != io.EOF { 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 { if !changed {
pg.Finish()
return nil return nil
} }
// 3) Create tar.gz // make a backup
pg.Describe("Backup current data...")
if err := repository.MakeArchive(gameID); err != nil {
return fmt.Errorf("failed to archive data: %w", err)
}
// create archive
pg.Describe("Archiving new data...")
f, err := os.Create(destTarGz) f, err := os.Create(destTarGz)
if err != nil { if err != nil {
return fmt.Errorf("creating archive file: %w", err) return fmt.Errorf("failed to creating archive file: %w", err)
} }
defer f.Close() defer f.Close()
gw := gzip.NewWriter(f) if err := archive.Tar(f, srcDir); err != nil {
defer gw.Close() return fmt.Errorf("failed archiving files: %w", err)
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)
} }
// 4) Update state file
now := time.Now().UTC().Format(time.RFC3339) now := time.Now().UTC().Format(time.RFC3339)
if err := os.WriteFile(stateFile, []byte(now), 0644); err != nil { if err := os.WriteFile(stateFile, []byte(now), 0644); err != nil {
return fmt.Errorf("updating state file: %w", err) return fmt.Errorf("updating state file: %w", err)

View File

@@ -2,10 +2,10 @@ package sync
import ( import (
"cloudsave/cmd/cli/tools/prompt" "cloudsave/cmd/cli/tools/prompt"
"cloudsave/pkg/game" "cloudsave/cmd/cli/tools/prompt/credentials"
"cloudsave/pkg/remote" "cloudsave/pkg/remote"
"cloudsave/pkg/remote/client" "cloudsave/pkg/remote/client"
"cloudsave/pkg/tools/prompt/credentials" "cloudsave/pkg/repository"
"context" "context"
"errors" "errors"
"flag" "flag"
@@ -16,6 +16,7 @@ import (
"time" "time"
"github.com/google/subcommands" "github.com/google/subcommands"
"github.com/schollz/progressbar/v3"
) )
type ( type (
@@ -35,7 +36,7 @@ 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 {
games, err := game.All() games, err := repository.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
@@ -51,13 +52,21 @@ func (p *SyncCmd) Execute(_ context.Context, f *flag.FlagSet, _ ...interface{})
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
} }
cli, err := connect(remoteCred, r) cli, err := connect(remoteCred, r)
if err != nil { if err != nil {
fmt.Fprintln(os.Stderr, "error: failed to connect to the remote:", err) fmt.Fprintln(os.Stderr, "error: failed to connect to the remote:", err)
return subcommands.ExitFailure return subcommands.ExitFailure
} }
pg := progressbar.New(-1)
destroyPg := func() {
pg.Finish()
pg.Clear()
pg.Close()
}
pg.Describe(fmt.Sprintf("[%s] Checking status...", g.Name))
exists, err := cli.Exists(r.GameID) exists, err := cli.Exists(r.GameID)
if err != nil { if err != nil {
slog.Error(err.Error()) slog.Error(err.Error())
@@ -65,41 +74,64 @@ func (p *SyncCmd) Execute(_ context.Context, f *flag.FlagSet, _ ...interface{})
} }
if !exists { if !exists {
if err := push(r.GameID, g, cli); err != nil { pg.Describe(fmt.Sprintf("[%s] Pushing data...", g.Name))
if err := push(g, cli); err != nil {
destroyPg()
fmt.Fprintln(os.Stderr, "failed to push:", err) fmt.Fprintln(os.Stderr, "failed to push:", err)
return subcommands.ExitFailure return subcommands.ExitFailure
} }
pg.Describe(fmt.Sprintf("[%s] Pushing backup...", g.Name))
if err := pushBackup(g, cli); err != nil {
destroyPg()
slog.Warn("failed to push backup files", "err", err)
}
continue continue
} }
hlocal, err := game.Hash(r.GameID) pg.Describe(fmt.Sprintf("[%s] Fetching metadata...", g.Name))
hlocal, err := repository.Hash(r.GameID)
if err != nil { if err != nil {
destroyPg()
slog.Error(err.Error()) slog.Error(err.Error())
continue continue
} }
hremote, err := cli.Hash(r.GameID) hremote, err := cli.Hash(r.GameID)
if err != nil { if err != nil {
destroyPg()
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
} }
vlocal, err := game.Version(r.GameID) vlocal, err := repository.Version(r.GameID)
if err != nil { if err != nil {
destroyPg()
slog.Error(err.Error()) slog.Error(err.Error())
continue continue
} }
remoteMetadata, err := cli.Metadata(r.GameID) remoteMetadata, err := cli.Metadata(r.GameID)
if err != nil { if err != nil {
destroyPg()
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
} }
pg.Describe(fmt.Sprintf("[%s] Pulling backup...", g.Name))
if err := pullBackup(g, cli); err != nil {
slog.Warn("failed to pull backup files", "err", err)
}
pg.Describe(fmt.Sprintf("[%s] Pushing backup...", g.Name))
if err := pushBackup(g, cli); err != nil {
slog.Warn("failed to push backup files", "err", err)
}
if hlocal == hremote { if hlocal == hremote {
destroyPg()
if vlocal != remoteMetadata.Version { if vlocal != remoteMetadata.Version {
slog.Debug("version is not the same, but the hash is equal. Updating local database") 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) fmt.Fprintln(os.Stderr, "error: failed to synchronize version number:", err)
continue continue
} }
@@ -109,29 +141,38 @@ func (p *SyncCmd) Execute(_ context.Context, f *flag.FlagSet, _ ...interface{})
} }
if vlocal > remoteMetadata.Version { if vlocal > remoteMetadata.Version {
if err := push(r.GameID, g, cli); err != nil { pg.Describe(fmt.Sprintf("[%s] Pushing data...", g.Name))
if err := push(g, cli); err != nil {
destroyPg()
fmt.Fprintln(os.Stderr, "failed to push:", err) fmt.Fprintln(os.Stderr, "failed to push:", err)
return subcommands.ExitFailure return subcommands.ExitFailure
} }
destroyPg()
continue continue
} }
if vlocal < remoteMetadata.Version { if vlocal < remoteMetadata.Version {
destroyPg()
if err := pull(r.GameID, cli); err != nil { if err := pull(r.GameID, cli); err != nil {
destroyPg()
fmt.Fprintln(os.Stderr, "failed to push:", err) fmt.Fprintln(os.Stderr, "failed to push:", err)
return subcommands.ExitFailure return subcommands.ExitFailure
} }
if err := game.SetVersion(r.GameID, remoteMetadata.Version); err != nil { if err := repository.SetVersion(r.GameID, remoteMetadata.Version); err != nil {
destroyPg()
fmt.Fprintln(os.Stderr, "error: failed to synchronize version number:", err) fmt.Fprintln(os.Stderr, "error: failed to synchronize version number:", err)
continue continue
} }
if err := game.SetDate(r.GameID, remoteMetadata.Date); err != nil { if err := repository.SetDate(r.GameID, remoteMetadata.Date); err != nil {
destroyPg()
fmt.Fprintln(os.Stderr, "error: failed to synchronize date:", err) fmt.Fprintln(os.Stderr, "error: failed to synchronize date:", err)
continue continue
} }
continue continue
} }
destroyPg()
if vlocal == remoteMetadata.Version { if vlocal == remoteMetadata.Version {
if err := conflict(r.GameID, g, remoteMetadata, cli); err != nil { if err := conflict(r.GameID, g, remoteMetadata, cli); err != nil {
fmt.Fprintln(os.Stderr, "error: failed to resolve conflict:", err) fmt.Fprintln(os.Stderr, "error: failed to resolve conflict:", err)
@@ -141,11 +182,12 @@ func (p *SyncCmd) Execute(_ context.Context, f *flag.FlagSet, _ ...interface{})
} }
} }
fmt.Println("done.")
return subcommands.ExitSuccess return subcommands.ExitSuccess
} }
func conflict(gameID string, m, remoteMetadata game.Metadata, cli *client.Client) error { func conflict(gameID string, m, remoteMetadata repository.Metadata, cli *client.Client) error {
g, err := game.One(gameID) g, err := repository.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", gameID) slog.Debug("debug info", "gameID", gameID)
@@ -164,7 +206,7 @@ func conflict(gameID string, m, remoteMetadata game.Metadata, cli *client.Client
switch res { switch res {
case prompt.My: case prompt.My:
{ {
if err := push(gameID, m, cli); err != nil { if err := push(m, cli); err != nil {
return fmt.Errorf("failed to push: %w", err) return fmt.Errorf("failed to push: %w", err)
} }
} }
@@ -174,10 +216,10 @@ func conflict(gameID string, m, remoteMetadata game.Metadata, cli *client.Client
if err := pull(gameID, cli); err != nil { if err := pull(gameID, cli); err != nil {
return fmt.Errorf("failed to push: %w", err) 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) 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) return fmt.Errorf("failed to synchronize date: %w", err)
} }
} }
@@ -185,14 +227,71 @@ func conflict(gameID string, m, remoteMetadata game.Metadata, cli *client.Client
return nil return nil
} }
func push(gameID string, m game.Metadata, cli *client.Client) error { func push(m repository.Metadata, cli *client.Client) error {
archivePath := filepath.Join(game.DatastorePath(), gameID, "data.tar.gz") archivePath := filepath.Join(repository.DatastorePath(), m.ID, "data.tar.gz")
return cli.Push(gameID, archivePath, m) return cli.PushSave(archivePath, m)
}
func pushBackup(m repository.Metadata, cli *client.Client) error {
bs, err := repository.Archives(m.ID)
if err != nil {
return err
}
for _, b := range bs {
binfo, err := cli.ArchiveInfo(m.ID, b.UUID)
if err != nil {
if !errors.Is(err, client.ErrNotFound) {
return fmt.Errorf("failed to get remote information about the backup file: %w", err)
}
}
if binfo.MD5 != b.MD5 {
if err := cli.PushBackup(b, m); err != nil {
return fmt.Errorf("failed to push backup: %w", err)
}
}
}
return nil
}
func pullBackup(m repository.Metadata, cli *client.Client) error {
bs, err := cli.ListArchives(m.ID)
if err != nil {
return err
}
for _, uuid := range bs {
rinfo, err := cli.ArchiveInfo(m.ID, uuid)
if err != nil {
return err
}
linfo, err := repository.Archive(m.ID, uuid)
if err != nil {
if !errors.Is(err, os.ErrNotExist) {
return err
}
}
path := filepath.Join(repository.DatastorePath(), m.ID, "hist", uuid)
if err := os.MkdirAll(path, 0740); err != nil {
return err
}
if rinfo.MD5 != linfo.MD5 {
if err := cli.PullBackup(m.ID, uuid, filepath.Join(path, "data.tar.gz")); err != nil {
return err
}
}
}
return nil
} }
func pull(gameID string, cli *client.Client) error { 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) return cli.Pull(gameID, archivePath)
} }

View File

@@ -3,7 +3,7 @@ package version
import ( import (
"cloudsave/pkg/constants" "cloudsave/pkg/constants"
"cloudsave/pkg/remote/client" "cloudsave/pkg/remote/client"
"cloudsave/pkg/tools/prompt/credentials" "cloudsave/cmd/cli/tools/prompt/credentials"
"context" "context"
"flag" "flag"
"fmt" "fmt"

View File

@@ -2,7 +2,7 @@ package api
import ( import (
"cloudsave/cmd/server/data" "cloudsave/cmd/server/data"
"cloudsave/pkg/game" "cloudsave/pkg/repository"
"crypto/md5" "crypto/md5"
"encoding/hex" "encoding/hex"
"encoding/json" "encoding/json"
@@ -64,6 +64,11 @@ func NewServer(documentRoot string, creds map[string]string, port int) *HTTPServ
saveRouter.Get("/{id}/data", s.download) saveRouter.Get("/{id}/data", s.download)
saveRouter.Get("/{id}/hash", s.hash) saveRouter.Get("/{id}/hash", s.hash)
saveRouter.Get("/{id}/metadata", s.metadata) saveRouter.Get("/{id}/metadata", s.metadata)
saveRouter.Get("/{id}/hist", s.allHist)
saveRouter.Post("/{id}/hist/{uuid}/data", s.histUpload)
saveRouter.Get("/{id}/hist/{uuid}/data", s.histDownload)
saveRouter.Get("/{id}/hist/{uuid}/info", s.histExists)
}) })
}) })
}) })
@@ -78,7 +83,7 @@ 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) {
path := filepath.Join(s.documentRoot, "data") path := filepath.Join(s.documentRoot, "data")
datastore := make([]game.Metadata, 0) datastore := make([]repository.Metadata, 0)
if _, err := os.Stat(path); err != nil { if _, err := os.Stat(path); err != nil {
if errors.Is(err, os.ErrNotExist) { if errors.Is(err, os.ErrNotExist) {
@@ -104,7 +109,7 @@ func (s HTTPServer) all(w http.ResponseWriter, r *http.Request) {
continue continue
} }
var m game.Metadata var m repository.Metadata
err = json.Unmarshal(content, &m) err = json.Unmarshal(content, &m)
if err != nil { if err != nil {
fmt.Fprintf(os.Stderr, "corrupted datastore: failed to parse %s/metadata.json: %s", d.Name(), err) fmt.Fprintf(os.Stderr, "corrupted datastore: failed to parse %s/metadata.json: %s", d.Name(), err)
@@ -209,6 +214,133 @@ func (s HTTPServer) upload(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusCreated) w.WriteHeader(http.StatusCreated)
} }
func (s HTTPServer) allHist(w http.ResponseWriter, r *http.Request) {
gameID := chi.URLParam(r, "id")
path := filepath.Join(s.documentRoot, "data", gameID, "hist")
datastore := make([]string, 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
}
for _, d := range ds {
datastore = append(datastore, d.Name())
}
ok(datastore, w, r)
}
func (s HTTPServer) histUpload(w http.ResponseWriter, r *http.Request) {
const (
sizeLimit int64 = 500 << 20 // 500 MB
)
gameID := chi.URLParam(r, "id")
uuid := chi.URLParam(r, "uuid")
// 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(gameID, s.documentRoot, uuid, 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) histDownload(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
uuid := chi.URLParam(r, "uuid")
path := filepath.Clean(filepath.Join(s.documentRoot, "data", id, "hist", uuid))
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, "data.tar.gz")
f, err := os.OpenFile(path, os.O_RDONLY, 0)
if err != nil {
notFound("id not found", w, r)
return
}
defer f.Close()
// Get file info to set headers
fi, err := f.Stat()
if err != nil || fi.IsDir() {
internalServerError(w, r)
return
}
// Set headers
w.Header().Set("Content-Disposition", "attachment; filename=\"data.tar.gz\"")
w.Header().Set("Content-Type", "application/gzip")
w.Header().Set("Content-Length", strconv.FormatInt(fi.Size(), 10))
w.WriteHeader(200)
// Stream the file content
http.ServeContent(w, r, "data.tar.gz", fi.ModTime(), f)
}
func (s HTTPServer) histExists(w http.ResponseWriter, r *http.Request) {
gameID := chi.URLParam(r, "id")
uuid := chi.URLParam(r, "uuid")
finfo, err := data.ArchiveInfo(gameID, s.documentRoot, uuid)
if err != nil {
if errors.Is(err, data.ErrBackupNotExists) {
notFound("backup not found", w, r)
return
}
fmt.Fprintln(os.Stderr, "error: failed to read data:", err)
internalServerError(w, r)
return
}
ok(finfo, w, r)
}
func (s HTTPServer) hash(w http.ResponseWriter, r *http.Request) { func (s HTTPServer) hash(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id") id := chi.URLParam(r, "id")
path := filepath.Clean(filepath.Join(s.documentRoot, "data", id)) path := filepath.Clean(filepath.Join(s.documentRoot, "data", id))
@@ -272,7 +404,7 @@ func (s HTTPServer) metadata(w http.ResponseWriter, r *http.Request) {
} }
defer f.Close() defer f.Close()
var metadata game.Metadata var metadata repository.Metadata
d := json.NewDecoder(f) d := json.NewDecoder(f)
err = d.Decode(&metadata) err = d.Decode(&metadata)
if err != nil { if err != nil {
@@ -284,46 +416,46 @@ func (s HTTPServer) metadata(w http.ResponseWriter, r *http.Request) {
ok(metadata, w, r) 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 var name string
if v, ok := values["name"]; ok { if v, ok := values["name"]; ok {
if len(v) == 0 { if len(v) == 0 {
return game.Metadata{}, fmt.Errorf("error: corrupted metadata") return repository.Metadata{}, fmt.Errorf("error: corrupted metadata")
} }
name = v[0] name = v[0]
} else { } 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 var version int
if v, ok := values["version"]; ok { if v, ok := values["version"]; ok {
if len(v) == 0 { 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 { if v, err := strconv.Atoi(v[0]); err == nil {
version = v version = v
} else { } else {
return game.Metadata{}, err return repository.Metadata{}, err
} }
} else { } 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 var date time.Time
if v, ok := values["date"]; ok { if v, ok := values["date"]; ok {
if len(v) == 0 { 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 { if v, err := time.Parse(time.RFC3339, v[0]); err == nil {
date = v date = v
} else { } else {
return game.Metadata{}, err return repository.Metadata{}, err
} }
} else { } 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, ID: gameID,
Version: version, Version: version,
Name: name, Name: name,

View File

@@ -1,14 +1,20 @@
package data package data
import ( import (
"cloudsave/pkg/game" "cloudsave/pkg/repository"
"cloudsave/pkg/tools/hash"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"io" "io"
"os" "os"
"path/filepath" "path/filepath"
) )
var (
ErrBackupNotExists error = errors.New("backup not found")
)
func Write(gameID, documentRoot string, r io.Reader) error { func Write(gameID, documentRoot string, r io.Reader) error {
dataFolderPath := filepath.Join(documentRoot, "data", gameID) dataFolderPath := filepath.Join(documentRoot, "data", gameID)
partPath := filepath.Join(dataFolderPath, "data.tar.gz.part") partPath := filepath.Join(dataFolderPath, "data.tar.gz.part")
@@ -39,7 +45,41 @@ func Write(gameID, documentRoot string, r io.Reader) error {
return nil return nil
} }
func UpdateMetadata(gameID, documentRoot string, m game.Metadata) error { func WriteHist(gameID, documentRoot, uuid string, r io.Reader) error {
dataFolderPath := filepath.Join(documentRoot, "data", gameID, "hist", uuid)
partPath := filepath.Join(dataFolderPath, "data.tar.gz.part")
finalFilePath := filepath.Join(dataFolderPath, "data.tar.gz")
if err := makeDataFolder(gameID, documentRoot); err != nil {
return err
}
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 repository.Metadata) error {
if err := makeDataFolder(gameID, documentRoot); err != nil { if err := makeDataFolder(gameID, documentRoot); err != nil {
return err return err
} }
@@ -55,6 +95,37 @@ func UpdateMetadata(gameID, documentRoot string, m game.Metadata) error {
return e.Encode(m) return e.Encode(m)
} }
func makeDataFolder(gameID, documentRoot string) error { func ArchiveInfo(gameID, documentRoot, uuid string) (repository.Backup, error) {
return os.MkdirAll(filepath.Join(documentRoot, "data", gameID), 0740) dataFolderPath := filepath.Join(documentRoot, "data", gameID, "hist", uuid, "data.tar.gz")
finfo, err := os.Stat(dataFolderPath)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return repository.Backup{}, ErrBackupNotExists
}
return repository.Backup{}, err
}
h, err := hash.FileMD5(dataFolderPath)
if err != nil {
return repository.Backup{}, fmt.Errorf("failed to calculate file md5: %w", err)
}
return repository.Backup{
CreatedAt: finfo.ModTime(),
UUID: uuid,
MD5: h,
}, nil
}
func makeDataFolder(gameID, documentRoot string) error {
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
} }

1
go.mod
View File

@@ -5,6 +5,7 @@ go 1.24
require ( require (
github.com/go-chi/chi/v5 v5.2.1 github.com/go-chi/chi/v5 v5.2.1
github.com/google/subcommands v1.2.0 github.com/google/subcommands v1.2.0
github.com/google/uuid v1.6.0
github.com/schollz/progressbar/v3 v3.18.0 github.com/schollz/progressbar/v3 v3.18.0
golang.org/x/crypto v0.38.0 golang.org/x/crypto v0.38.0
golang.org/x/term v0.32.0 golang.org/x/term v0.32.0

2
go.sum
View File

@@ -6,6 +6,8 @@ github.com/go-chi/chi/v5 v5.2.1 h1:KOIHODQj58PmL80G2Eak4WdvUzjSJSm0vG72crDCqb8=
github.com/go-chi/chi/v5 v5.2.1/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops= github.com/go-chi/chi/v5 v5.2.1/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops=
github.com/google/subcommands v1.2.0 h1:vWQspBTo2nEqTUFita5/KeEWlUL8kQObDFbub/EN9oE= github.com/google/subcommands v1.2.0 h1:vWQspBTo2nEqTUFita5/KeEWlUL8kQObDFbub/EN9oE=
github.com/google/subcommands v1.2.0/go.mod h1:ZjhPrFU+Olkh9WazFPsl27BQ4UPiG37m3yTrtFlrHVk= github.com/google/subcommands v1.2.0/go.mod h1:ZjhPrFU+Olkh9WazFPsl27BQ4UPiG37m3yTrtFlrHVk=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db h1:62I3jR2EmQ4l5rM/4FEfDWcRD+abF5XlKShorW5LRoQ= github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db h1:62I3jR2EmQ4l5rM/4FEfDWcRD+abF5XlKShorW5LRoQ=

View File

@@ -1,5 +1,5 @@
package constants package constants
const Version = "0.0.1" const Version = "0.0.2"
const ApiVersion = 1 const ApiVersion = 1

View File

@@ -2,8 +2,8 @@ package client
import ( import (
"bytes" "bytes"
"cloudsave/pkg/game"
"cloudsave/pkg/remote/obj" "cloudsave/pkg/remote/obj"
"cloudsave/pkg/repository"
customtime "cloudsave/pkg/tools/time" customtime "cloudsave/pkg/tools/time"
"encoding/json" "encoding/json"
"errors" "errors"
@@ -35,6 +35,10 @@ type (
} }
) )
var (
ErrNotFound error = errors.New("not found")
)
func New(baseURL, username, password string) *Client { func New(baseURL, username, password string) *Client {
return &Client{ return &Client{
baseURL: baseURL, baseURL: baseURL,
@@ -117,19 +121,19 @@ func (c *Client) Hash(gameID string) (string, error) {
return "", errors.New("invalid payload sent by the server") 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") u, err := url.JoinPath(c.baseURL, "api", "v1", "games", gameID, "metadata")
if err != nil { if err != nil {
return game.Metadata{}, err return repository.Metadata{}, err
} }
o, err := c.get(u) o, err := c.get(u)
if err != nil { if err != nil {
return game.Metadata{}, err return repository.Metadata{}, err
} }
if m, ok := (o.Data).(map[string]any); ok { if m, ok := (o.Data).(map[string]any); ok {
gm := game.Metadata{ gm := repository.Metadata{
ID: m["id"].(string), ID: m["id"].(string),
Name: m["name"].(string), Name: m["name"].(string),
Version: int(m["version"].(float64)), Version: int(m["version"].(float64)),
@@ -138,66 +142,122 @@ func (c *Client) Metadata(gameID string) (game.Metadata, error) {
return gm, nil 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) PushSave(archivePath string, m repository.Metadata) error {
u, err := url.JoinPath(c.baseURL, "api", "v1", "games", m.ID, "data")
if err != nil {
return err
}
return c.push(u, archivePath, m)
}
func (c *Client) PushBackup(archiveMetadata repository.Backup, m repository.Metadata) error {
u, err := url.JoinPath(c.baseURL, "api", "v1", "games", m.ID, "hist", archiveMetadata.UUID, "data")
if err != nil {
return err
}
return c.push(u, archiveMetadata.ArchivePath, m)
}
func (c *Client) ListArchives(gameID string) ([]string, error) {
u, err := url.JoinPath(c.baseURL, "api", "v1", "games", gameID, "hist")
if err != nil {
return nil, err
}
o, err := c.get(u)
if err != nil {
return nil, err
}
if m, ok := (o.Data).([]any); ok {
var res []string
for _, uuid := range m {
res = append(res, uuid.(string))
}
return res, nil
}
return nil, errors.New("invalid payload sent by the server")
}
func (c *Client) ArchiveInfo(gameID, uuid string) (repository.Backup, error) {
u, err := url.JoinPath(c.baseURL, "api", "v1", "games", gameID, "hist", uuid, "info")
if err != nil {
return repository.Backup{}, err
}
o, err := c.get(u)
if err != nil {
return repository.Backup{}, err
}
if m, ok := (o.Data).(map[string]any); ok {
b := repository.Backup{
UUID: m["uuid"].(string),
CreatedAt: customtime.MustParse(time.RFC3339, m["created_at"].(string)),
MD5: m["md5"].(string),
}
return b, nil
}
return repository.Backup{}, errors.New("invalid payload sent by the server")
}
func (c *Client) Pull(gameID, archivePath string) error {
u, err := url.JoinPath(c.baseURL, "api", "v1", "games", gameID, "data") u, err := url.JoinPath(c.baseURL, "api", "v1", "games", gameID, "data")
if err != nil { if err != nil {
return err 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))
writer.WriteField("date", m.Date.Format(time.RFC3339))
if err := writer.Close(); err != nil {
return err
}
cli := http.Client{} cli := http.Client{}
req, err := http.NewRequest("POST", u, buf) req, err := http.NewRequest("GET", u, nil)
if err != nil { if err != nil {
return err return err
} }
req.SetBasicAuth(c.username, c.password) req.SetBasicAuth(c.username, c.password)
req.Header.Set("Content-Type", writer.FormDataContentType())
f, err := os.OpenFile(archivePath+".part", os.O_CREATE|os.O_WRONLY, 0740)
if err != nil {
return fmt.Errorf("failed to open file: %w", err)
}
defer f.Close()
res, err := cli.Do(req) res, err := cli.Do(req)
if err != nil { if err != nil {
return err return fmt.Errorf("cannot connect to remote: %w", err)
} }
defer res.Body.Close() defer res.Body.Close()
if res.StatusCode != 201 { if res.StatusCode != http.StatusOK {
return fmt.Errorf("server returns an unexpected status code: %s (expected 201)", res.Status) return fmt.Errorf("cannot connect to remote: server return code: %s", res.Status)
}
bar := progressbar.DefaultBytes(
res.ContentLength,
"Pulling...",
)
defer bar.Close()
if _, err := io.Copy(io.MultiWriter(f, bar), res.Body); err != nil {
return fmt.Errorf("an error occured while copying the file from the remote: %w", err)
}
if err := os.Rename(archivePath+".part", archivePath); err != nil {
return fmt.Errorf("failed to move temporary data: %w", err)
} }
return nil return nil
} }
func (c *Client) Pull(gameID, archivePath string) error { func (c *Client) PullBackup(gameID, uuid, archivePath string) error {
u, err := url.JoinPath(c.baseURL, "api", "v1", "games", gameID, "data") u, err := url.JoinPath(c.baseURL, "api", "v1", "games", gameID, "hist", uuid, "data")
if err != nil { if err != nil {
return err return err
} }
@@ -271,7 +331,7 @@ func (c *Client) Ping() error {
return nil 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") u, err := url.JoinPath(c.baseURL, "api", "v1", "games")
if err != nil { if err != nil {
return nil, err return nil, err
@@ -283,10 +343,10 @@ func (c *Client) All() ([]game.Metadata, error) {
} }
if games, ok := (o.Data).([]any); ok { if games, ok := (o.Data).([]any); ok {
var res []game.Metadata var res []repository.Metadata
for _, g := range games { for _, g := range games {
if v, ok := g.(map[string]any); ok { if v, ok := g.(map[string]any); ok {
gm := game.Metadata{ gm := repository.Metadata{
ID: v["id"].(string), ID: v["id"].(string),
Name: v["name"].(string), Name: v["name"].(string),
Version: int(v["version"].(float64)), Version: int(v["version"].(float64)),
@@ -318,6 +378,10 @@ func (c *Client) get(url string) (obj.HTTPObject, error) {
} }
defer res.Body.Close() defer res.Body.Close()
if res.StatusCode == 404 {
return obj.HTTPObject{}, ErrNotFound
}
if res.StatusCode != 200 { if res.StatusCode != 200 {
return obj.HTTPObject{}, fmt.Errorf("server returns an unexpected status code: %d %s (expected 200)", res.StatusCode, res.Status) return obj.HTTPObject{}, fmt.Errorf("server returns an unexpected status code: %d %s (expected 200)", res.StatusCode, res.Status)
} }
@@ -331,3 +395,53 @@ func (c *Client) get(url string) (obj.HTTPObject, error) {
return httpObject, nil return httpObject, nil
} }
func (c *Client) push(u, archivePath string, m repository.Metadata) error {
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))
writer.WriteField("date", m.Date.Format(time.RFC3339))
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
}

View File

@@ -1,15 +1,17 @@
package game package repository
import ( import (
"cloudsave/pkg/tools/hash"
"cloudsave/pkg/tools/id" "cloudsave/pkg/tools/id"
"crypto/md5"
"encoding/hex"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"io" "io"
"os" "os"
"path/filepath" "path/filepath"
"time" "time"
"github.com/google/uuid"
) )
type ( type (
@@ -20,6 +22,13 @@ type (
Version int `json:"version"` Version int `json:"version"`
Date time.Time `json:"date"` Date time.Time `json:"date"`
} }
Backup struct {
CreatedAt time.Time `json:"created_at"`
MD5 string `json:"md5"`
UUID string `json:"uuid"`
ArchivePath string `json:"-"`
}
) )
var ( var (
@@ -135,6 +144,136 @@ func One(gameID string) (Metadata, error) {
return m, nil return m, nil
} }
func MakeArchive(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 {
if errors.Is(err, os.ErrNotExist) {
return nil
}
return fmt.Errorf("failed to open old file: %w", err)
}
defer f.Close()
histDirPath := filepath.Join(datastorepath, gameID, "hist", uuid.NewString())
if err := os.MkdirAll(histDirPath, 0740); err != nil {
return fmt.Errorf("failed to make directory: %w", err)
}
// open new
nf, err := os.OpenFile(filepath.Join(histDirPath, "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 RestoreArchive(gameID, uuid string) error {
histDirPath := filepath.Join(datastorepath, gameID, "hist", uuid)
if err := os.MkdirAll(histDirPath, 0740); err != nil {
return fmt.Errorf("failed to make directory: %w", err)
}
// open old
nf, err := os.OpenFile(filepath.Join(histDirPath, "data.tar.gz"), os.O_RDONLY, 0)
if err != nil {
return fmt.Errorf("failed to open new file: %w", err)
}
defer nf.Close()
path := filepath.Join(datastorepath, gameID, "data.tar.gz")
// open new
f, err := os.OpenFile(path, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0740)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return nil
}
return fmt.Errorf("failed to open old file: %w", err)
}
defer f.Close()
// copy
if _, err := io.Copy(f, nf); err != nil {
return fmt.Errorf("failed to copy data: %w", err)
}
return nil
}
func Archive(gameID, uuid string) (Backup, error) {
histDirPath := filepath.Join(datastorepath, gameID, "hist", uuid)
if err := os.MkdirAll(histDirPath, 0740); err != nil {
return Backup{}, fmt.Errorf("failed to make 'hist' directory")
}
finfo, err := os.Stat(histDirPath)
if err != nil {
return Backup{}, fmt.Errorf("corrupted datastore: %w", err)
}
archivePath := filepath.Join(histDirPath, "data.tar.gz")
h, err := hash.FileMD5(archivePath)
if err != nil {
return Backup{}, fmt.Errorf("failed to calculate md5 hash: %w", err)
}
b := Backup{
CreatedAt: finfo.ModTime(),
UUID: filepath.Base(finfo.Name()),
MD5: h,
ArchivePath: archivePath,
}
return b, nil
}
func Archives(gameID string) ([]Backup, error) {
histDirPath := filepath.Join(datastorepath, gameID, "hist")
if err := os.MkdirAll(histDirPath, 0740); err != nil {
return nil, fmt.Errorf("failed to make 'hist' directory")
}
d, err := os.ReadDir(histDirPath)
if err != nil {
return nil, fmt.Errorf("failed to open 'hist' directory")
}
var res []Backup
for _, f := range d {
finfo, err := f.Info()
if err != nil {
return nil, fmt.Errorf("corrupted datastore: %w", err)
}
path := filepath.Join(histDirPath, finfo.Name())
archivePath := filepath.Join(path, "data.tar.gz")
h, err := hash.FileMD5(archivePath)
if err != nil {
return nil, fmt.Errorf("failed to calculate md5 hash: %w", err)
}
b := Backup{
CreatedAt: finfo.ModTime(),
UUID: filepath.Base(finfo.Name()),
MD5: h,
ArchivePath: archivePath,
}
res = append(res, b)
}
return res, nil
}
func DatastorePath() string { func DatastorePath() string {
return datastorepath return datastorepath
} }
@@ -150,18 +289,7 @@ func Remove(gameID string) error {
func Hash(gameID string) (string, error) { func Hash(gameID string) (string, error) {
path := filepath.Join(datastorepath, gameID, "data.tar.gz") path := filepath.Join(datastorepath, gameID, "data.tar.gz")
f, err := os.OpenFile(path, os.O_RDONLY, 0) return hash.FileMD5(path)
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) { func Version(gameID string) (int, error) {

View File

@@ -3,6 +3,7 @@ package archive
import ( import (
"archive/tar" "archive/tar"
"compress/gzip" "compress/gzip"
"fmt"
"io" "io"
"os" "os"
"path/filepath" "path/filepath"
@@ -71,3 +72,53 @@ func Untar(file io.Reader, path string) error {
} }
} }
} }
func Tar(file io.Writer, root string) error {
gw := gzip.NewWriter(file)
defer gw.Close()
tw := tar.NewWriter(gw)
defer tw.Close()
// Walk again to add files
err := filepath.Walk(root, func(path string, info os.FileInfo, walkErr error) error {
if walkErr != nil {
return fmt.Errorf("failed to walk through the directory: %w", walkErr)
}
relpath, err := filepath.Rel(root, path)
if err != nil {
return fmt.Errorf("failed to make relative path: %w", err)
}
// Create tar header
header, err := tar.FileInfoHeader(info, path)
if err != nil {
return fmt.Errorf("failed to make file info header: %w", err)
}
header.Name = relpath
if err := tw.WriteHeader(header); err != nil {
return fmt.Errorf("failed to write header: %w", err)
}
if !info.Mode().IsRegular() {
return nil
}
file, err := os.Open(path)
if err != nil {
return fmt.Errorf("failed to open file: %w", err)
}
defer file.Close()
if _, err := io.Copy(tw, file); err != nil {
return fmt.Errorf("failed to copy file: %w", err)
}
return nil
})
if err != nil {
return fmt.Errorf("writing tar entries: %w", err)
}
return nil
}

23
pkg/tools/hash/hash.go Normal file
View File

@@ -0,0 +1,23 @@
package hash
import (
"crypto/md5"
"encoding/hex"
"io"
"os"
)
func FileMD5(fp string) (string, error) {
f, err := os.OpenFile(fp, 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
}