Compare commits
17 Commits
v0.0.1
...
23e46e5eef
| Author | SHA1 | Date | |
|---|---|---|---|
| 23e46e5eef | |||
| c1fc8a52f4 | |||
| 6127da4626 | |||
| d208b1da91 | |||
| c329f96a76 | |||
| f699fcd7f6 | |||
| 898012a30d | |||
| 2f777c72ee | |||
| d479004217 | |||
| cf96815d0f | |||
| 4ee8fe48bc | |||
| f2fee0990b | |||
| 95857356ab | |||
| 58c6bc56cf | |||
| 30b76e1887 | |||
| c6edb91f29 | |||
| c099d3e64f |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -1,4 +1,7 @@
|
||||
/cli
|
||||
/server
|
||||
/web
|
||||
/env/
|
||||
/build/
|
||||
*.exe
|
||||
/config.json
|
||||
2
.vscode/launch.json
vendored
2
.vscode/launch.json
vendored
@@ -10,7 +10,7 @@
|
||||
"type": "go",
|
||||
"request": "launch",
|
||||
"mode": "auto",
|
||||
"args": ["list", "-a", "http://localhost:8080"],
|
||||
"args": ["run"],
|
||||
"console": "integratedTerminal",
|
||||
"program": "${workspaceFolder}/cmd/cli"
|
||||
}
|
||||
|
||||
7
LICENSE
Normal file
7
LICENSE
Normal file
@@ -0,0 +1,7 @@
|
||||
Copyright 2025 Aurélie DELHAIE
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
56
README.md
Normal file
56
README.md
Normal file
@@ -0,0 +1,56 @@
|
||||
# CloudSave
|
||||
|
||||
The software is still in alpha.
|
||||
|
||||
A client/server that allows unsynchronized games (such as emulators, old games, etc.) to be kept up to date on multiple computers.
|
||||
|
||||
## Build
|
||||
|
||||
You need go1.24
|
||||
|
||||
After downloading the go toolchain, just run the script `./build.sh`
|
||||
|
||||
## Usage
|
||||
|
||||
### Server
|
||||
|
||||
The server needs an empty directory. After creating this directory, you need to make a file that contains your credential. The format is "username:password". The server only understand bcrypt password hash for now.
|
||||
|
||||
e.g.:
|
||||
```
|
||||
test:$2y$10$uULsuyROe3LVdTzFoBH7HO0zhvyKp6CX2FDNl7quXMFYqzitU0kc.
|
||||
```
|
||||
|
||||
The default path to this directory is `/var/lib/cloudsave`, this can be changed with the `-document-root` argument
|
||||
|
||||
### Client
|
||||
|
||||
#### Register a game
|
||||
|
||||
You can register a game with the verb `add`
|
||||
```bash
|
||||
cloudsave add /home/user/gamedata
|
||||
```
|
||||
|
||||
You can also change the name of the registration and add a remote
|
||||
```bash
|
||||
cloudsave add -name "My Game" -remote "http://localhost:8080" /home/user/gamedata
|
||||
```
|
||||
|
||||
#### Make an archive of the current state
|
||||
|
||||
This is a command line tool, it cannot auto detect changes.
|
||||
Run this command to start the scan, if needed, the tool will create a new archive
|
||||
|
||||
```bash
|
||||
cloudsave scan
|
||||
```
|
||||
#### Send everythings on the server
|
||||
|
||||
This will pull and push data to the server.
|
||||
|
||||
Note: If multiple computers are pushing to this server, a conflict may be generated. If so, the tool will ask for the version to keep
|
||||
|
||||
```bash
|
||||
cloudsave sync
|
||||
```
|
||||
53
build.sh
53
build.sh
@@ -1,6 +1,7 @@
|
||||
#!/bin/bash
|
||||
|
||||
MAKE_PACKAGE=false
|
||||
VERSION=0.0.3
|
||||
|
||||
usage() {
|
||||
echo "Usage: $0 [OPTIONS]"
|
||||
@@ -35,20 +36,44 @@ fi
|
||||
## SERVER
|
||||
|
||||
platforms=("linux/amd64" "linux/arm64" "linux/riscv64" "linux/ppc64le")
|
||||
CGO_ENABLED=0
|
||||
|
||||
for platform in "${platforms[@]}"; do
|
||||
echo "* Compiling server for $platform..."
|
||||
platform_split=(${platform//\// })
|
||||
|
||||
GOOS=${platform_split[0]}
|
||||
GOARCH=${platform_split[1]}
|
||||
|
||||
go build -o build/server_${platform_split[0]}_${platform_split[1]}.bin ./cmd/server
|
||||
EXT=""
|
||||
if [ "${platform_split[0]}" == "windows" ]; then
|
||||
EXT=.exe
|
||||
fi
|
||||
|
||||
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
|
||||
rm 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
|
||||
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
|
||||
done
|
||||
|
||||
# WEB
|
||||
|
||||
platforms=("linux/amd64" "linux/arm64" "linux/riscv64" "linux/ppc64le")
|
||||
|
||||
for platform in "${platforms[@]}"; do
|
||||
echo "* Compiling web server for $platform..."
|
||||
platform_split=(${platform//\// })
|
||||
|
||||
EXT=""
|
||||
if [ "${platform_split[0]}" == "windows" ]; then
|
||||
EXT=.exe
|
||||
fi
|
||||
|
||||
if [ "$MAKE_PACKAGE" == "true" ]; then
|
||||
CGO_ENABLED=0 GOOS=${platform_split[0]} GOARCH=${platform_split[1]} go build -o build/cloudsave_web$EXT -a ./cmd/web
|
||||
tar -czf build/web_${platform_split[0]}_${platform_split[1]}.tar.gz build/cloudsave_web$EXT
|
||||
rm build/cloudsave_web$EXT
|
||||
else
|
||||
CGO_ENABLED=0 GOOS=${platform_split[0]} GOARCH=${platform_split[1]} go build -o build/cloudsave_web_${platform_split[0]}_${platform_split[1]}$EXT -a ./cmd/web
|
||||
fi
|
||||
done
|
||||
|
||||
@@ -60,12 +85,16 @@ for platform in "${platforms[@]}"; do
|
||||
echo "* Compiling client for $platform..."
|
||||
platform_split=(${platform//\// })
|
||||
|
||||
GOOS=${platform_split[0]}
|
||||
GOARCH=${platform_split[1]}
|
||||
EXT=""
|
||||
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
|
||||
tar -czf build/cli_${platform_split[0]}_${platform_split[1]}.tar.gz build/cli_${platform_split[0]}_${platform_split[1]}.bin
|
||||
rm 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
|
||||
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
|
||||
done
|
||||
|
||||
@@ -1,32 +1,39 @@
|
||||
package add
|
||||
|
||||
import (
|
||||
"cloudsave/pkg/game"
|
||||
"cloudsave/pkg/remote"
|
||||
"cloudsave/pkg/repository"
|
||||
"context"
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/google/subcommands"
|
||||
)
|
||||
|
||||
type (
|
||||
AddCmd struct {
|
||||
name string
|
||||
name string
|
||||
remote string
|
||||
}
|
||||
)
|
||||
|
||||
func (*AddCmd) Name() string { return "add" }
|
||||
func (*AddCmd) Synopsis() string { return "Add a folder to the sync list" }
|
||||
func (*AddCmd) Synopsis() string { return "add a folder to the sync list" }
|
||||
func (*AddCmd) Usage() string {
|
||||
return `add:
|
||||
Add a folder to the sync list
|
||||
return `Usage: cloudsave add [-name] [-remote] <PATH>
|
||||
|
||||
Add a folder to the track list
|
||||
|
||||
Options:
|
||||
`
|
||||
}
|
||||
|
||||
func (p *AddCmd) SetFlags(f *flag.FlagSet) {
|
||||
f.StringVar(&p.name, "name", "", "Override the name of the game")
|
||||
f.StringVar(&p.remote, "remote", "", "Defines a remote server to sync with")
|
||||
}
|
||||
|
||||
func (p *AddCmd) Execute(_ context.Context, f *flag.FlagSet, _ ...interface{}) subcommands.ExitStatus {
|
||||
@@ -44,12 +51,16 @@ func (p *AddCmd) Execute(_ context.Context, f *flag.FlagSet, _ ...interface{}) s
|
||||
p.name = filepath.Base(path)
|
||||
}
|
||||
|
||||
m, err := game.Add(p.name, path)
|
||||
m, err := repository.Add(p.name, path)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "error: failed to add game reference:", err)
|
||||
return subcommands.ExitFailure
|
||||
}
|
||||
|
||||
if len(strings.TrimSpace(p.remote)) > 0 {
|
||||
remote.Set(m.ID, p.remote)
|
||||
}
|
||||
|
||||
fmt.Println(m.ID)
|
||||
|
||||
return subcommands.ExitSuccess
|
||||
|
||||
70
cmd/cli/commands/apply/apply.go
Normal file
70
cmd/cli/commands/apply/apply.go
Normal file
@@ -0,0 +1,70 @@
|
||||
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 `Usage: cloudsave apply <GAME_ID> <BACKUP_ID>
|
||||
|
||||
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
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
package list
|
||||
|
||||
import (
|
||||
"cloudsave/pkg/game"
|
||||
"cloudsave/cmd/cli/tools/prompt/credentials"
|
||||
"cloudsave/pkg/remote/client"
|
||||
"cloudsave/pkg/tools/prompt/credentials"
|
||||
"cloudsave/pkg/repository"
|
||||
"context"
|
||||
"flag"
|
||||
"fmt"
|
||||
@@ -15,19 +15,24 @@ import (
|
||||
type (
|
||||
ListCmd struct {
|
||||
remote bool
|
||||
backup bool
|
||||
}
|
||||
)
|
||||
|
||||
func (*ListCmd) Name() string { return "list" }
|
||||
func (*ListCmd) Synopsis() string { return "list all game registered" }
|
||||
func (*ListCmd) Usage() string {
|
||||
return `list:
|
||||
List all game registered
|
||||
return `Usage: cloudsave list [-include-backup] [-a]
|
||||
|
||||
List all game registered
|
||||
|
||||
Options:
|
||||
`
|
||||
}
|
||||
|
||||
func (p *ListCmd) SetFlags(f *flag.FlagSet) {
|
||||
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 {
|
||||
@@ -43,21 +48,21 @@ func (p *ListCmd) Execute(_ context.Context, f *flag.FlagSet, _ ...interface{})
|
||||
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)
|
||||
return subcommands.ExitFailure
|
||||
}
|
||||
return subcommands.ExitSuccess
|
||||
}
|
||||
if err := local(); err != nil {
|
||||
if err := local(p.backup); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "error:", err)
|
||||
return subcommands.ExitFailure
|
||||
}
|
||||
return subcommands.ExitSuccess
|
||||
}
|
||||
|
||||
func local() error {
|
||||
games, err := game.All()
|
||||
func local(includeBackup bool) error {
|
||||
games, err := repository.All()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to load datastore: %w", err)
|
||||
}
|
||||
@@ -66,13 +71,25 @@ func local() error {
|
||||
fmt.Println("ID:", g.ID)
|
||||
fmt.Println("Name:", g.Name)
|
||||
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("---")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func remote(url, username, password string) error {
|
||||
func remote(url, username, password string, includeBackup bool) error {
|
||||
cli := client.New(url, username, password)
|
||||
|
||||
if err := cli.Ping(); err != nil {
|
||||
@@ -91,6 +108,22 @@ func remote(url, username, password string) error {
|
||||
fmt.Println("ID:", g.ID)
|
||||
fmt.Println("Name:", g.Name)
|
||||
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("---")
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
package pull
|
||||
|
||||
import (
|
||||
"cloudsave/pkg/game"
|
||||
"cloudsave/cmd/cli/tools/prompt/credentials"
|
||||
"cloudsave/pkg/remote/client"
|
||||
"cloudsave/pkg/repository"
|
||||
"cloudsave/pkg/tools/archive"
|
||||
"cloudsave/pkg/tools/prompt/credentials"
|
||||
"context"
|
||||
"flag"
|
||||
"fmt"
|
||||
@@ -22,8 +22,9 @@ type (
|
||||
func (*PullCmd) Name() string { return "pull" }
|
||||
func (*PullCmd) Synopsis() string { return "pull a game save from the remote" }
|
||||
func (*PullCmd) Usage() string {
|
||||
return `list:
|
||||
Pull a game save from the remote
|
||||
return `Usage: cloudsave pull <GAME_ID>
|
||||
|
||||
Pull a game save from the remote
|
||||
`
|
||||
}
|
||||
|
||||
@@ -54,7 +55,7 @@ func (p *PullCmd) Execute(_ context.Context, f *flag.FlagSet, _ ...interface{})
|
||||
return subcommands.ExitFailure
|
||||
}
|
||||
|
||||
archivePath := filepath.Join(game.DatastorePath(), gameID, "data.tar.gz")
|
||||
archivePath := filepath.Join(repository.DatastorePath(), gameID, "data.tar.gz")
|
||||
|
||||
m, err := cli.Metadata(gameID)
|
||||
if err != nil {
|
||||
@@ -62,7 +63,7 @@ func (p *PullCmd) Execute(_ context.Context, f *flag.FlagSet, _ ...interface{})
|
||||
return subcommands.ExitFailure
|
||||
}
|
||||
|
||||
err = game.Register(m, path)
|
||||
err = repository.Register(m, path)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "failed to register local metadata: %s", err)
|
||||
return subcommands.ExitFailure
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
package remote
|
||||
|
||||
import (
|
||||
"cloudsave/pkg/game"
|
||||
"cloudsave/pkg/remote"
|
||||
"cloudsave/pkg/remote/client"
|
||||
"cloudsave/pkg/repository"
|
||||
"context"
|
||||
"flag"
|
||||
"fmt"
|
||||
@@ -20,10 +20,17 @@ type (
|
||||
)
|
||||
|
||||
func (*RemoteCmd) Name() string { return "remote" }
|
||||
func (*RemoteCmd) Synopsis() string { return "manage remote" }
|
||||
func (*RemoteCmd) Synopsis() string { return "add or update the remote url" }
|
||||
func (*RemoteCmd) Usage() string {
|
||||
return `remote:
|
||||
manage remove
|
||||
return `Usage: cloudsave remote <-set|-list>
|
||||
|
||||
The -list argument lists all remotes for each registered game.
|
||||
This command performs a connection test.
|
||||
|
||||
The -set argument allow you to set (create or update)
|
||||
the URL to the remote for a game
|
||||
|
||||
Options
|
||||
`
|
||||
}
|
||||
|
||||
@@ -62,7 +69,7 @@ func (p *RemoteCmd) Execute(ctx context.Context, f *flag.FlagSet, _ ...interface
|
||||
}
|
||||
|
||||
func list() error {
|
||||
games, err := game.All()
|
||||
games, err := repository.All()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to load datastore: %w", err)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
package remove
|
||||
|
||||
import (
|
||||
"cloudsave/pkg/game"
|
||||
"cloudsave/pkg/repository"
|
||||
"context"
|
||||
"flag"
|
||||
"fmt"
|
||||
@@ -17,8 +17,10 @@ type (
|
||||
func (*RemoveCmd) Name() string { return "remove" }
|
||||
func (*RemoveCmd) Synopsis() string { return "unregister a game" }
|
||||
func (*RemoveCmd) Usage() string {
|
||||
return `remove:
|
||||
Unregister a game
|
||||
return `Usage: cloudsave remove <GAME_ID>
|
||||
|
||||
Unregister a game
|
||||
Caution: all the backup are deleted
|
||||
`
|
||||
}
|
||||
|
||||
@@ -31,7 +33,7 @@ func (p *RemoveCmd) Execute(_ context.Context, f *flag.FlagSet, _ ...interface{}
|
||||
return subcommands.ExitUsageError
|
||||
}
|
||||
|
||||
err := game.Remove(f.Arg(0))
|
||||
err := repository.Remove(f.Arg(0))
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "error: failed to unregister the game:", err)
|
||||
return subcommands.ExitFailure
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
package run
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"cloudsave/pkg/game"
|
||||
"compress/gzip"
|
||||
"cloudsave/pkg/repository"
|
||||
"cloudsave/pkg/tools/archive"
|
||||
"context"
|
||||
"flag"
|
||||
"fmt"
|
||||
@@ -21,60 +20,69 @@ type (
|
||||
}
|
||||
)
|
||||
|
||||
func (*RunCmd) Name() string { return "run" }
|
||||
func (*RunCmd) Synopsis() string { return "Check and process all the folder" }
|
||||
func (*RunCmd) Name() string { return "scan" }
|
||||
func (*RunCmd) Synopsis() string { return "check and process all the folder" }
|
||||
func (*RunCmd) Usage() string {
|
||||
return `run:
|
||||
Check and process all the folder
|
||||
return `Usage: cloudsave scan
|
||||
|
||||
Check if the files have been modified. If so,
|
||||
the current archive is moved to the backup list
|
||||
and a new archive is created with a new version number.
|
||||
`
|
||||
}
|
||||
|
||||
func (p *RunCmd) SetFlags(f *flag.FlagSet) {}
|
||||
|
||||
func (p *RunCmd) Execute(_ context.Context, f *flag.FlagSet, _ ...interface{}) subcommands.ExitStatus {
|
||||
datastore, err := game.All()
|
||||
datastore, err := repository.All()
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "error: failed to load datastore:", err)
|
||||
return subcommands.ExitFailure
|
||||
}
|
||||
|
||||
pg := progressbar.New(len(datastore))
|
||||
defer pg.Close()
|
||||
|
||||
for _, metadata := range datastore {
|
||||
pg.Describe("Scanning " + metadata.Name + "...")
|
||||
metadataPath := filepath.Join(game.DatastorePath(), metadata.ID)
|
||||
metadataPath := filepath.Join(repository.DatastorePath(), metadata.ID)
|
||||
//todo transaction
|
||||
err := archiveIfChanged(metadata.ID, metadata.Path, filepath.Join(metadataPath, "data.tar.gz"), filepath.Join(metadataPath, ".last_run"))
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "error: cannot process the data of %s: %s\n", metadata.ID, err)
|
||||
return subcommands.ExitFailure
|
||||
}
|
||||
if err := game.SetVersion(metadata.ID, metadata.Version+1); err != nil {
|
||||
if err := repository.SetVersion(metadata.ID, metadata.Version+1); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "error: cannot process the data of %s: %s\n", metadata.ID, err)
|
||||
return subcommands.ExitFailure
|
||||
}
|
||||
if err := game.SetDate(metadata.ID, time.Now()); err != nil {
|
||||
if err := repository.SetDate(metadata.ID, time.Now()); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "error: cannot process the data of %s: %s\n", metadata.ID, err)
|
||||
return subcommands.ExitFailure
|
||||
}
|
||||
pg.Add(1)
|
||||
fmt.Println("✅", metadata.Name)
|
||||
}
|
||||
|
||||
pg.Finish()
|
||||
|
||||
fmt.Println("done.")
|
||||
return subcommands.ExitSuccess
|
||||
}
|
||||
|
||||
// archiveIfChanged will archive srcDir into destTarGz only if any file
|
||||
// in srcDir has a modification time > the last run time stored in stateFile.
|
||||
// After archiving, it updates stateFile to the current time.
|
||||
func archiveIfChanged(id, srcDir, destTarGz, stateFile string) error {
|
||||
// 1) Load last run time
|
||||
func archiveIfChanged(gameID, srcDir, destTarGz, stateFile string) error {
|
||||
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
|
||||
data, err := os.ReadFile(stateFile)
|
||||
if err != nil && !os.IsNotExist(err) {
|
||||
return fmt.Errorf("reading state file: %w", err)
|
||||
return fmt.Errorf("failed to reading state file: %w", err)
|
||||
}
|
||||
if err == nil {
|
||||
lastRun, err = time.Parse(time.RFC3339, string(data))
|
||||
@@ -83,7 +91,7 @@ func archiveIfChanged(id, srcDir, destTarGz, stateFile string) error {
|
||||
}
|
||||
}
|
||||
|
||||
// 2) Check for changes
|
||||
// check for changes
|
||||
changed := false
|
||||
err = filepath.Walk(srcDir, func(path string, info os.FileInfo, walkErr error) error {
|
||||
if walkErr != nil {
|
||||
@@ -96,63 +104,32 @@ func archiveIfChanged(id, srcDir, destTarGz, stateFile string) error {
|
||||
return nil
|
||||
})
|
||||
if err != nil && err != io.EOF {
|
||||
return fmt.Errorf("scanning source directory: %w", err)
|
||||
return fmt.Errorf("failed to scanning source directory: %w", err)
|
||||
}
|
||||
|
||||
if !changed {
|
||||
pg.Finish()
|
||||
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)
|
||||
if err != nil {
|
||||
return fmt.Errorf("creating archive file: %w", err)
|
||||
return fmt.Errorf("failed to creating archive file: %w", err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
gw := gzip.NewWriter(f)
|
||||
defer gw.Close()
|
||||
|
||||
tw := tar.NewWriter(gw)
|
||||
defer tw.Close()
|
||||
|
||||
// Walk again to add files
|
||||
err = filepath.Walk(srcDir, func(path string, info os.FileInfo, walkErr error) error {
|
||||
if walkErr != nil {
|
||||
return walkErr
|
||||
}
|
||||
// Create tar header
|
||||
header, err := tar.FileInfoHeader(info, path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Preserve directory structure relative to srcDir
|
||||
relPath, err := filepath.Rel(filepath.Dir(srcDir), path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
header.Name = relPath
|
||||
|
||||
if err := tw.WriteHeader(header); err != nil {
|
||||
return err
|
||||
}
|
||||
if info.Mode().IsRegular() {
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer file.Close()
|
||||
if _, err := io.Copy(tw, file); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("writing tar entries: %w", err)
|
||||
if err := archive.Tar(f, srcDir); err != nil {
|
||||
return fmt.Errorf("failed archiving files: %w", err)
|
||||
}
|
||||
|
||||
// 4) Update state file
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
if err := os.WriteFile(stateFile, []byte(now), 0644); err != nil {
|
||||
return fmt.Errorf("updating state file: %w", err)
|
||||
|
||||
@@ -2,10 +2,10 @@ package sync
|
||||
|
||||
import (
|
||||
"cloudsave/cmd/cli/tools/prompt"
|
||||
"cloudsave/pkg/game"
|
||||
"cloudsave/cmd/cli/tools/prompt/credentials"
|
||||
"cloudsave/pkg/remote"
|
||||
"cloudsave/pkg/remote/client"
|
||||
"cloudsave/pkg/tools/prompt/credentials"
|
||||
"cloudsave/pkg/repository"
|
||||
"context"
|
||||
"errors"
|
||||
"flag"
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/google/subcommands"
|
||||
"github.com/schollz/progressbar/v3"
|
||||
)
|
||||
|
||||
type (
|
||||
@@ -26,8 +27,9 @@ type (
|
||||
func (*SyncCmd) Name() string { return "sync" }
|
||||
func (*SyncCmd) Synopsis() string { return "list all game registered" }
|
||||
func (*SyncCmd) Usage() string {
|
||||
return `add:
|
||||
List all game registered
|
||||
return `Usage: cloudsave sync
|
||||
|
||||
Synchronize the archives with the server defined for each game.
|
||||
`
|
||||
}
|
||||
|
||||
@@ -35,7 +37,7 @@ func (p *SyncCmd) SetFlags(f *flag.FlagSet) {
|
||||
}
|
||||
|
||||
func (p *SyncCmd) Execute(_ context.Context, f *flag.FlagSet, _ ...interface{}) subcommands.ExitStatus {
|
||||
games, err := game.All()
|
||||
games, err := repository.All()
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "error: failed to load datastore:", err)
|
||||
return subcommands.ExitFailure
|
||||
@@ -46,18 +48,27 @@ func (p *SyncCmd) Execute(_ context.Context, f *flag.FlagSet, _ ...interface{})
|
||||
r, err := remote.One(g.ID)
|
||||
if err != nil {
|
||||
if errors.Is(err, remote.ErrNoRemote) {
|
||||
fmt.Println(g.Name + ": no remote configured")
|
||||
continue
|
||||
}
|
||||
fmt.Fprintln(os.Stderr, "error: failed to load datastore:", err)
|
||||
return subcommands.ExitFailure
|
||||
}
|
||||
|
||||
cli, err := connect(remoteCred, r)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "error: failed to connect to the remote:", err)
|
||||
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)
|
||||
if err != nil {
|
||||
slog.Error(err.Error())
|
||||
@@ -65,73 +76,108 @@ func (p *SyncCmd) Execute(_ context.Context, f *flag.FlagSet, _ ...interface{})
|
||||
}
|
||||
|
||||
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)
|
||||
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)
|
||||
}
|
||||
fmt.Println(g.Name + ": pushed")
|
||||
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 {
|
||||
destroyPg()
|
||||
slog.Error(err.Error())
|
||||
continue
|
||||
}
|
||||
|
||||
hremote, err := cli.Hash(r.GameID)
|
||||
if err != nil {
|
||||
destroyPg()
|
||||
fmt.Fprintln(os.Stderr, "error: failed to get the file hash from the remote:", err)
|
||||
continue
|
||||
}
|
||||
|
||||
vlocal, err := game.Version(r.GameID)
|
||||
vlocal, err := repository.Version(r.GameID)
|
||||
if err != nil {
|
||||
destroyPg()
|
||||
slog.Error(err.Error())
|
||||
continue
|
||||
}
|
||||
|
||||
remoteMetadata, err := cli.Metadata(r.GameID)
|
||||
if err != nil {
|
||||
destroyPg()
|
||||
fmt.Fprintln(os.Stderr, "error: failed to get the game metadata from the remote:", err)
|
||||
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 {
|
||||
destroyPg()
|
||||
if vlocal != remoteMetadata.Version {
|
||||
slog.Debug("version is not the same, but the hash is equal. Updating local database")
|
||||
if err := game.SetVersion(r.GameID, remoteMetadata.Version); err != nil {
|
||||
if err := repository.SetVersion(r.GameID, remoteMetadata.Version); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "error: failed to synchronize version number:", err)
|
||||
continue
|
||||
}
|
||||
}
|
||||
fmt.Println("already up-to-date")
|
||||
fmt.Println(g.Name + ": already up-to-date")
|
||||
continue
|
||||
}
|
||||
|
||||
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)
|
||||
return subcommands.ExitFailure
|
||||
}
|
||||
destroyPg()
|
||||
fmt.Println(g.Name + ": pushed")
|
||||
continue
|
||||
}
|
||||
|
||||
if vlocal < remoteMetadata.Version {
|
||||
destroyPg()
|
||||
if err := pull(r.GameID, cli); err != nil {
|
||||
destroyPg()
|
||||
fmt.Fprintln(os.Stderr, "failed to push:", err)
|
||||
return subcommands.ExitFailure
|
||||
}
|
||||
if err := game.SetVersion(r.GameID, remoteMetadata.Version); err != nil {
|
||||
if err := repository.SetVersion(r.GameID, remoteMetadata.Version); err != nil {
|
||||
destroyPg()
|
||||
fmt.Fprintln(os.Stderr, "error: failed to synchronize version number:", err)
|
||||
continue
|
||||
}
|
||||
if err := game.SetDate(r.GameID, remoteMetadata.Date); err != nil {
|
||||
if err := repository.SetDate(r.GameID, remoteMetadata.Date); err != nil {
|
||||
destroyPg()
|
||||
fmt.Fprintln(os.Stderr, "error: failed to synchronize date:", err)
|
||||
continue
|
||||
}
|
||||
fmt.Println(g.Name + ": pulled")
|
||||
continue
|
||||
}
|
||||
|
||||
destroyPg()
|
||||
|
||||
if vlocal == remoteMetadata.Version {
|
||||
if err := conflict(r.GameID, g, remoteMetadata, cli); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "error: failed to resolve conflict:", err)
|
||||
@@ -141,11 +187,12 @@ func (p *SyncCmd) Execute(_ context.Context, f *flag.FlagSet, _ ...interface{})
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Println("done.")
|
||||
return subcommands.ExitSuccess
|
||||
}
|
||||
|
||||
func conflict(gameID string, m, remoteMetadata game.Metadata, cli *client.Client) error {
|
||||
g, err := game.One(gameID)
|
||||
func conflict(gameID string, m, remoteMetadata repository.Metadata, cli *client.Client) error {
|
||||
g, err := repository.One(gameID)
|
||||
if err != nil {
|
||||
slog.Warn("a conflict was found but the game is not found in the database")
|
||||
slog.Debug("debug info", "gameID", gameID)
|
||||
@@ -164,7 +211,7 @@ func conflict(gameID string, m, remoteMetadata game.Metadata, cli *client.Client
|
||||
switch res {
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -174,10 +221,10 @@ func conflict(gameID string, m, remoteMetadata game.Metadata, cli *client.Client
|
||||
if err := pull(gameID, cli); err != nil {
|
||||
return fmt.Errorf("failed to push: %w", err)
|
||||
}
|
||||
if err := game.SetVersion(gameID, remoteMetadata.Version); err != nil {
|
||||
if err := repository.SetVersion(gameID, remoteMetadata.Version); err != nil {
|
||||
return fmt.Errorf("failed to synchronize version number: %w", err)
|
||||
}
|
||||
if err := game.SetDate(gameID, remoteMetadata.Date); err != nil {
|
||||
if err := repository.SetDate(gameID, remoteMetadata.Date); err != nil {
|
||||
return fmt.Errorf("failed to synchronize date: %w", err)
|
||||
}
|
||||
}
|
||||
@@ -185,14 +232,71 @@ func conflict(gameID string, m, remoteMetadata game.Metadata, cli *client.Client
|
||||
return nil
|
||||
}
|
||||
|
||||
func push(gameID string, m game.Metadata, cli *client.Client) error {
|
||||
archivePath := filepath.Join(game.DatastorePath(), gameID, "data.tar.gz")
|
||||
func push(m repository.Metadata, cli *client.Client) error {
|
||||
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 {
|
||||
archivePath := filepath.Join(game.DatastorePath(), gameID, "data.tar.gz")
|
||||
archivePath := filepath.Join(repository.DatastorePath(), gameID, "data.tar.gz")
|
||||
|
||||
return cli.Pull(gameID, archivePath)
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
package version
|
||||
|
||||
import (
|
||||
"cloudsave/cmd/cli/tools/prompt/credentials"
|
||||
"cloudsave/pkg/constants"
|
||||
"cloudsave/pkg/remote/client"
|
||||
"cloudsave/pkg/tools/prompt/credentials"
|
||||
"context"
|
||||
"flag"
|
||||
"fmt"
|
||||
@@ -23,8 +23,11 @@ type (
|
||||
func (*VersionCmd) Name() string { return "version" }
|
||||
func (*VersionCmd) Synopsis() string { return "show version and system information" }
|
||||
func (*VersionCmd) Usage() string {
|
||||
return `add:
|
||||
Show version and system information
|
||||
return `Usage: cloudsave version [-a]
|
||||
|
||||
Print the version of the software
|
||||
|
||||
Options:
|
||||
`
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ package api
|
||||
|
||||
import (
|
||||
"cloudsave/cmd/server/data"
|
||||
"cloudsave/pkg/game"
|
||||
"cloudsave/pkg/repository"
|
||||
"crypto/md5"
|
||||
"encoding/hex"
|
||||
"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}/hash", s.hash)
|
||||
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) {
|
||||
path := filepath.Join(s.documentRoot, "data")
|
||||
datastore := make([]game.Metadata, 0)
|
||||
datastore := make([]repository.Metadata, 0)
|
||||
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
@@ -104,7 +109,7 @@ func (s HTTPServer) all(w http.ResponseWriter, r *http.Request) {
|
||||
continue
|
||||
}
|
||||
|
||||
var m game.Metadata
|
||||
var m repository.Metadata
|
||||
err = json.Unmarshal(content, &m)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "corrupted datastore: failed to parse %s/metadata.json: %s", d.Name(), err)
|
||||
@@ -209,6 +214,133 @@ func (s HTTPServer) upload(w http.ResponseWriter, r *http.Request) {
|
||||
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) {
|
||||
id := chi.URLParam(r, "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()
|
||||
|
||||
var metadata game.Metadata
|
||||
var metadata repository.Metadata
|
||||
d := json.NewDecoder(f)
|
||||
err = d.Decode(&metadata)
|
||||
if err != nil {
|
||||
@@ -284,46 +416,46 @@ func (s HTTPServer) metadata(w http.ResponseWriter, r *http.Request) {
|
||||
ok(metadata, w, r)
|
||||
}
|
||||
|
||||
func parseFormMetadata(gameID string, values map[string][]string) (game.Metadata, error) {
|
||||
func parseFormMetadata(gameID string, values map[string][]string) (repository.Metadata, error) {
|
||||
var name string
|
||||
if v, ok := values["name"]; ok {
|
||||
if len(v) == 0 {
|
||||
return game.Metadata{}, fmt.Errorf("error: corrupted metadata")
|
||||
return repository.Metadata{}, fmt.Errorf("error: corrupted metadata")
|
||||
}
|
||||
name = v[0]
|
||||
} else {
|
||||
return game.Metadata{}, fmt.Errorf("error: cannot find metadata in the form")
|
||||
return repository.Metadata{}, fmt.Errorf("error: cannot find metadata in the form")
|
||||
}
|
||||
|
||||
var version int
|
||||
if v, ok := values["version"]; ok {
|
||||
if len(v) == 0 {
|
||||
return game.Metadata{}, fmt.Errorf("error: corrupted metadata")
|
||||
return repository.Metadata{}, fmt.Errorf("error: corrupted metadata")
|
||||
}
|
||||
if v, err := strconv.Atoi(v[0]); err == nil {
|
||||
version = v
|
||||
} else {
|
||||
return game.Metadata{}, err
|
||||
return repository.Metadata{}, err
|
||||
}
|
||||
} else {
|
||||
return game.Metadata{}, fmt.Errorf("error: cannot find metadata in the form")
|
||||
return repository.Metadata{}, fmt.Errorf("error: cannot find metadata in the form")
|
||||
}
|
||||
|
||||
var date time.Time
|
||||
if v, ok := values["date"]; ok {
|
||||
if len(v) == 0 {
|
||||
return game.Metadata{}, fmt.Errorf("error: corrupted metadata")
|
||||
return repository.Metadata{}, fmt.Errorf("error: corrupted metadata")
|
||||
}
|
||||
if v, err := time.Parse(time.RFC3339, v[0]); err == nil {
|
||||
date = v
|
||||
} else {
|
||||
return game.Metadata{}, err
|
||||
return repository.Metadata{}, err
|
||||
}
|
||||
} else {
|
||||
return game.Metadata{}, fmt.Errorf("error: cannot find metadata in the form")
|
||||
return repository.Metadata{}, fmt.Errorf("error: cannot find metadata in the form")
|
||||
}
|
||||
|
||||
return game.Metadata{
|
||||
return repository.Metadata{
|
||||
ID: gameID,
|
||||
Version: version,
|
||||
Name: name,
|
||||
|
||||
@@ -1,14 +1,58 @@
|
||||
package data
|
||||
|
||||
import (
|
||||
"cloudsave/pkg/game"
|
||||
"cloudsave/pkg/repository"
|
||||
"cloudsave/pkg/tools/hash"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
)
|
||||
|
||||
type (
|
||||
cache map[string]cachedInfo
|
||||
|
||||
cachedInfo struct {
|
||||
MD5 string
|
||||
Version int
|
||||
}
|
||||
)
|
||||
|
||||
var (
|
||||
ErrBackupNotExists error = errors.New("backup not found")
|
||||
|
||||
// singleton
|
||||
hashCacheMu sync.RWMutex
|
||||
hashCache cache = make(map[string]cachedInfo)
|
||||
)
|
||||
|
||||
func (c cache) Get(gameID string) (cachedInfo, bool) {
|
||||
hashCacheMu.RLock()
|
||||
defer hashCacheMu.RUnlock()
|
||||
|
||||
if v, ok := c[gameID]; ok {
|
||||
return v, true
|
||||
}
|
||||
return cachedInfo{}, false
|
||||
}
|
||||
|
||||
func (c cache) Register(gameID string, v cachedInfo) {
|
||||
hashCacheMu.Lock()
|
||||
defer hashCacheMu.Unlock()
|
||||
|
||||
c[gameID] = v
|
||||
}
|
||||
|
||||
func (c cache) Remove(gameID string) {
|
||||
hashCacheMu.Lock()
|
||||
defer hashCacheMu.Unlock()
|
||||
|
||||
delete(c, gameID)
|
||||
}
|
||||
|
||||
func Write(gameID, documentRoot string, r io.Reader) error {
|
||||
dataFolderPath := filepath.Join(documentRoot, "data", gameID)
|
||||
partPath := filepath.Join(dataFolderPath, "data.tar.gz.part")
|
||||
@@ -36,10 +80,45 @@ func Write(gameID, documentRoot string, r io.Reader) error {
|
||||
return err
|
||||
}
|
||||
|
||||
hashCache.Remove(gameID)
|
||||
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 {
|
||||
return err
|
||||
}
|
||||
@@ -55,6 +134,106 @@ func UpdateMetadata(gameID, documentRoot string, m game.Metadata) error {
|
||||
return e.Encode(m)
|
||||
}
|
||||
|
||||
func makeDataFolder(gameID, documentRoot string) error {
|
||||
return os.MkdirAll(filepath.Join(documentRoot, "data", gameID), 0740)
|
||||
func ArchiveInfo(gameID, documentRoot, uuid string) (repository.Backup, error) {
|
||||
dataFolderPath := filepath.Join(documentRoot, "data", gameID, "hist", uuid, "data.tar.gz")
|
||||
cacheID := gameID + ":" + uuid
|
||||
|
||||
finfo, err := os.Stat(dataFolderPath)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return repository.Backup{}, ErrBackupNotExists
|
||||
}
|
||||
return repository.Backup{}, err
|
||||
}
|
||||
|
||||
if m, ok := hashCache.Get(cacheID); ok {
|
||||
return repository.Backup{
|
||||
CreatedAt: finfo.ModTime(),
|
||||
UUID: uuid,
|
||||
MD5: m.MD5,
|
||||
}, nil
|
||||
}
|
||||
|
||||
h, err := hash.FileMD5(dataFolderPath)
|
||||
if err != nil {
|
||||
return repository.Backup{}, fmt.Errorf("failed to calculate file md5: %w", err)
|
||||
}
|
||||
|
||||
hashCache.Register(cacheID, cachedInfo{
|
||||
MD5: h,
|
||||
})
|
||||
|
||||
return repository.Backup{
|
||||
CreatedAt: finfo.ModTime(),
|
||||
UUID: uuid,
|
||||
MD5: h,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func Hash(gameID, documentRoot string) (string, error) {
|
||||
path := filepath.Clean(filepath.Join(documentRoot, "data", gameID))
|
||||
|
||||
sdir, err := os.Stat(path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if !sdir.IsDir() {
|
||||
return "", err
|
||||
}
|
||||
|
||||
v, err := getVersion(gameID, documentRoot)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to read game metadata: %w", err)
|
||||
}
|
||||
|
||||
if m, ok := hashCache.Get(gameID); ok {
|
||||
if v == m.Version {
|
||||
return m.MD5, nil
|
||||
}
|
||||
}
|
||||
|
||||
path = filepath.Join(path, "data.tar.gz")
|
||||
|
||||
h, err := hash.FileMD5(path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
hashCache.Register(gameID, cachedInfo{
|
||||
Version: v,
|
||||
MD5: h,
|
||||
})
|
||||
|
||||
return h, nil
|
||||
}
|
||||
|
||||
func getVersion(gameID, documentRoot string) (int, error) {
|
||||
path := filepath.Join(documentRoot, "data", gameID, "metadata.json")
|
||||
|
||||
f, err := os.OpenFile(path, os.O_RDONLY, 0)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
d := json.NewDecoder(f)
|
||||
var m repository.Metadata
|
||||
if err := d.Decode(&m); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
return m.Version, 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
|
||||
}
|
||||
|
||||
8
cmd/web/config.template.json
Normal file
8
cmd/web/config.template.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"server": {
|
||||
"port": 8181
|
||||
},
|
||||
"remote": {
|
||||
"url": "http://localhost:8080"
|
||||
}
|
||||
}
|
||||
40
cmd/web/server/config/config.go
Normal file
40
cmd/web/server/config/config.go
Normal file
@@ -0,0 +1,40 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
)
|
||||
|
||||
type (
|
||||
Configuration struct {
|
||||
Server ServerConfiguration `json:"server"`
|
||||
Remote RemoteConfiguration `json:"remote"`
|
||||
}
|
||||
|
||||
ServerConfiguration struct {
|
||||
Port int `json:"port"`
|
||||
}
|
||||
|
||||
RemoteConfiguration struct {
|
||||
URL string `json:"url"`
|
||||
}
|
||||
)
|
||||
|
||||
func Load(path string) (Configuration, error) {
|
||||
f, err := os.OpenFile(path, os.O_RDONLY, 0)
|
||||
if err != nil {
|
||||
return Configuration{}, fmt.Errorf("failed to open configuration file: %w", err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
d := json.NewDecoder(f)
|
||||
|
||||
var c Configuration
|
||||
err = d.Decode(&c)
|
||||
if err != nil {
|
||||
return Configuration{}, fmt.Errorf("failed to parse configuration file (%s): %w", path, err)
|
||||
}
|
||||
|
||||
return c, nil
|
||||
}
|
||||
63
cmd/web/server/middlewares.go
Normal file
63
cmd/web/server/middlewares.go
Normal file
@@ -0,0 +1,63 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
func recoverMiddleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
defer func() {
|
||||
err := recover()
|
||||
if err != nil {
|
||||
internalServerError(w, r)
|
||||
}
|
||||
}()
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// BasicAuth implements a simple middleware handler for adding basic http auth to a route.
|
||||
func BasicAuth(realm string, creds map[string]string) func(next http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
user, pass, ok := r.BasicAuth()
|
||||
if !ok {
|
||||
basicAuthFailed(w, r, realm)
|
||||
return
|
||||
}
|
||||
|
||||
credPass := creds[user]
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(credPass), []byte(pass)); err != nil {
|
||||
basicAuthFailed(w, r, realm)
|
||||
return
|
||||
}
|
||||
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func basicAuthFailed(w http.ResponseWriter, r *http.Request, realm string) {
|
||||
unauthorized(realm, w, r)
|
||||
}
|
||||
|
||||
func unauthorized(realm string, w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Add("WWW-Authenticate", fmt.Sprintf(`Basic realm="%s"`, realm))
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
_, err := w.Write([]byte(UnauthorizedErrorHTMLPage))
|
||||
if err != nil {
|
||||
log.Println(err)
|
||||
}
|
||||
}
|
||||
|
||||
func internalServerError(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
_, err := w.Write([]byte(InternalServerErrorHTMLPage))
|
||||
if err != nil {
|
||||
log.Println(err)
|
||||
}
|
||||
}
|
||||
248
cmd/web/server/server.go
Normal file
248
cmd/web/server/server.go
Normal file
@@ -0,0 +1,248 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"cloudsave/cmd/web/server/config"
|
||||
"cloudsave/pkg/constants"
|
||||
"cloudsave/pkg/remote/client"
|
||||
"cloudsave/pkg/repository"
|
||||
"errors"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"runtime"
|
||||
"slices"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/chi/v5/middleware"
|
||||
|
||||
_ "embed"
|
||||
)
|
||||
|
||||
type (
|
||||
HTTPServer struct {
|
||||
Server *http.Server
|
||||
Config config.Configuration
|
||||
Templates Templates
|
||||
}
|
||||
|
||||
Templates struct {
|
||||
Dashboard *template.Template
|
||||
Detailled *template.Template
|
||||
System *template.Template
|
||||
}
|
||||
)
|
||||
|
||||
type (
|
||||
DetaillePayload struct {
|
||||
Version string
|
||||
Save repository.Metadata
|
||||
BackupMetadata []repository.Backup
|
||||
Hash string
|
||||
}
|
||||
|
||||
DashboardPayload struct {
|
||||
Version string
|
||||
Saves []repository.Metadata
|
||||
}
|
||||
|
||||
SystemPayload struct {
|
||||
Version string
|
||||
Client client.Information
|
||||
Server client.Information
|
||||
}
|
||||
)
|
||||
|
||||
var (
|
||||
//go:embed templates/500.html
|
||||
InternalServerErrorHTMLPage string
|
||||
|
||||
//go:embed templates/401.html
|
||||
UnauthorizedErrorHTMLPage string
|
||||
|
||||
//go:embed templates/dashboard.html
|
||||
DashboardHTMLPage string
|
||||
|
||||
//go:embed templates/detailled.html
|
||||
DetailledHTMLPage string
|
||||
|
||||
//go:embed templates/information.html
|
||||
SystemHTMLPage string
|
||||
)
|
||||
|
||||
// NewServer start the http server
|
||||
func NewServer(c config.Configuration) *HTTPServer {
|
||||
dashboardTemplate := template.New("dashboard")
|
||||
dashboardTemplate.Parse(DashboardHTMLPage)
|
||||
|
||||
detailledTemplate := template.New("detailled")
|
||||
detailledTemplate.Parse(DetailledHTMLPage)
|
||||
|
||||
systemTemplate := template.New("system")
|
||||
systemTemplate.Parse(SystemHTMLPage)
|
||||
|
||||
s := &HTTPServer{
|
||||
Config: c,
|
||||
Templates: Templates{
|
||||
Dashboard: dashboardTemplate,
|
||||
Detailled: detailledTemplate,
|
||||
System: systemTemplate,
|
||||
},
|
||||
}
|
||||
router := chi.NewRouter()
|
||||
router.Use(middleware.Logger)
|
||||
router.Use(recoverMiddleware)
|
||||
router.Route("/web", func(routerAPI chi.Router) {
|
||||
routerAPI.Get("/", s.dashboard)
|
||||
routerAPI.Get("/{id}", s.detailled)
|
||||
routerAPI.Get("/system", s.system)
|
||||
})
|
||||
s.Server = &http.Server{
|
||||
Addr: fmt.Sprintf(":%d", c.Server.Port),
|
||||
Handler: router,
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func (s *HTTPServer) dashboard(w http.ResponseWriter, r *http.Request) {
|
||||
user, pass, ok := r.BasicAuth()
|
||||
if !ok {
|
||||
basicAuthFailed(w, r, "realm")
|
||||
return
|
||||
}
|
||||
|
||||
cli := client.New(s.Config.Remote.URL, user, pass)
|
||||
|
||||
if err := cli.Ping(); err != nil {
|
||||
slog.Error("unable to connect to the remote", "err", err)
|
||||
return
|
||||
}
|
||||
|
||||
saves, err := cli.All()
|
||||
if err != nil {
|
||||
if errors.Is(err, client.ErrUnauthorized) {
|
||||
unauthorized("Unable to access resources", w, r)
|
||||
return
|
||||
}
|
||||
slog.Error("unable to connect to the remote", "err", err)
|
||||
return
|
||||
}
|
||||
|
||||
slices.SortFunc(saves, func(a, b repository.Metadata) int {
|
||||
return a.Date.Compare(b.Date)
|
||||
})
|
||||
|
||||
slices.Reverse(saves)
|
||||
|
||||
payload := DashboardPayload{
|
||||
Version: constants.Version,
|
||||
Saves: saves,
|
||||
}
|
||||
|
||||
if err := s.Templates.Dashboard.Execute(w, payload); err != nil {
|
||||
slog.Error("failed to render the html pages", "err", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func (s *HTTPServer) detailled(w http.ResponseWriter, r *http.Request) {
|
||||
user, pass, ok := r.BasicAuth()
|
||||
if !ok {
|
||||
basicAuthFailed(w, r, "realm")
|
||||
return
|
||||
}
|
||||
|
||||
id := chi.URLParam(r, "id")
|
||||
cli := client.New(s.Config.Remote.URL, user, pass)
|
||||
|
||||
if err := cli.Ping(); err != nil {
|
||||
slog.Error("unable to connect to the remote", "err", err)
|
||||
return
|
||||
}
|
||||
|
||||
save, err := cli.Metadata(id)
|
||||
if err != nil {
|
||||
if errors.Is(err, client.ErrUnauthorized) {
|
||||
unauthorized("Unable to access resources", w, r)
|
||||
return
|
||||
}
|
||||
slog.Error("unable to connect to the remote", "err", err)
|
||||
return
|
||||
}
|
||||
|
||||
h, err := cli.Hash(id)
|
||||
if err != nil {
|
||||
slog.Error("unable to connect to the remote", "err", err)
|
||||
return
|
||||
}
|
||||
|
||||
ids, err := cli.ListArchives(id)
|
||||
if err != nil {
|
||||
slog.Error("unable to connect to the remote", "err", err)
|
||||
return
|
||||
}
|
||||
|
||||
var bm []repository.Backup
|
||||
for _, i := range ids {
|
||||
b, err := cli.ArchiveInfo(id, i)
|
||||
if err != nil {
|
||||
slog.Error("unable to connect to the remote", "err", err)
|
||||
return
|
||||
}
|
||||
bm = append(bm, b)
|
||||
}
|
||||
|
||||
payload := DetaillePayload{
|
||||
Save: save,
|
||||
Hash: h,
|
||||
BackupMetadata: bm,
|
||||
Version: constants.Version,
|
||||
}
|
||||
|
||||
if err := s.Templates.Detailled.Execute(w, payload); err != nil {
|
||||
slog.Error("failed to render the html pages", "err", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func (s *HTTPServer) system(w http.ResponseWriter, r *http.Request) {
|
||||
user, pass, ok := r.BasicAuth()
|
||||
if !ok {
|
||||
basicAuthFailed(w, r, "realm")
|
||||
return
|
||||
}
|
||||
cli := client.New(s.Config.Remote.URL, user, pass)
|
||||
|
||||
if err := cli.Ping(); err != nil {
|
||||
slog.Error("unable to connect to the remote", "err", err)
|
||||
return
|
||||
}
|
||||
|
||||
clientInfo := client.Information{
|
||||
Version: constants.Version,
|
||||
APIVersion: constants.ApiVersion,
|
||||
GoVersion: runtime.Version(),
|
||||
OSName: runtime.GOOS,
|
||||
OSArchitecture: runtime.GOARCH,
|
||||
}
|
||||
serverInfo, err := cli.Version()
|
||||
if err != nil {
|
||||
if errors.Is(err, client.ErrUnauthorized) {
|
||||
unauthorized("Unable to access resources", w, r)
|
||||
return
|
||||
}
|
||||
slog.Error("unable to connect to the remote", "err", err)
|
||||
return
|
||||
}
|
||||
|
||||
payload := SystemPayload{
|
||||
Version: constants.Version,
|
||||
Client: clientInfo,
|
||||
Server: serverInfo,
|
||||
}
|
||||
|
||||
if err := s.Templates.System.Execute(w, payload); err != nil {
|
||||
slog.Error("failed to render the html pages", "err", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
9
cmd/web/server/templates/401.html
Normal file
9
cmd/web/server/templates/401.html
Normal file
@@ -0,0 +1,9 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>You are not allowed</title>
|
||||
</head>
|
||||
<body>
|
||||
<h1>401 Unauthorized</h1>
|
||||
</body>
|
||||
</html>
|
||||
9
cmd/web/server/templates/500.html
Normal file
9
cmd/web/server/templates/500.html
Normal file
@@ -0,0 +1,9 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>An error occured</title>
|
||||
</head>
|
||||
<body>
|
||||
<h1>500 Internal Server Error</h1>
|
||||
</body>
|
||||
</html>
|
||||
38
cmd/web/server/templates/dashboard.html
Normal file
38
cmd/web/server/templates/dashboard.html
Normal file
@@ -0,0 +1,38 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Dashboard</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.7/dist/css/bootstrap.min.css" rel="stylesheet"
|
||||
integrity="sha384-LN+7fdVzj6u52u30Kp6M/trliBMCMKTyK833zpbD+pXdCLuTusPj697FH4R/5mcr" crossorigin="anonymous">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<nav class="navbar bg-body-tertiary">
|
||||
<div class="container-fluid">
|
||||
<span class="navbar-brand mb-0 h1">CloudSave</span>
|
||||
<a class="muted" href="/web/system">v{{.Version}}</a>
|
||||
</div>
|
||||
</nav>
|
||||
<div class="container" style="margin-top: 1rem;">
|
||||
<div class="list-group">
|
||||
{{range .Saves}}
|
||||
<a href="/web/{{.ID}}" class="list-group-item list-group-item-action">
|
||||
<div class="d-flex w-100 justify-content-between">
|
||||
<h5 class="mb-1">{{.Name}}</h5>
|
||||
<small>{{.Date}}</small>
|
||||
</div>
|
||||
<p class="mb-1">Version: {{.Version}}</p>
|
||||
<small>{{.ID}}</small>
|
||||
</a>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.7/dist/js/bootstrap.bundle.min.js"
|
||||
integrity="sha384-ndDqU0Gzau9qJ1lfW4pNLlhNTkCfHzAVBReH9diLvGRem5+R9g2FzA8ZGN954O5Q"
|
||||
crossorigin="anonymous"></script>
|
||||
|
||||
</html>
|
||||
54
cmd/web/server/templates/detailled.html
Normal file
54
cmd/web/server/templates/detailled.html
Normal file
@@ -0,0 +1,54 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{{.Save.Name}}</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.7/dist/css/bootstrap.min.css" rel="stylesheet"
|
||||
integrity="sha384-LN+7fdVzj6u52u30Kp6M/trliBMCMKTyK833zpbD+pXdCLuTusPj697FH4R/5mcr" crossorigin="anonymous">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<nav class="navbar bg-body-tertiary">
|
||||
<div class="container-fluid">
|
||||
<span class="navbar-brand mb-0 h1">CloudSave</span>
|
||||
<a class="muted" href="/web/system">v{{.Version}}</a>
|
||||
</div>
|
||||
</nav>
|
||||
<div class="container" style="margin-top: 1rem;">
|
||||
<nav aria-label="breadcrumb">
|
||||
<ol class="breadcrumb">
|
||||
<li class="breadcrumb-item"><a href="/web">Home</a></li>
|
||||
<li class="breadcrumb-item active" aria-current="page">{{.Save.ID}}</li>
|
||||
</ol>
|
||||
</nav>
|
||||
<div class="list-group">
|
||||
<h1>{{.Save.Name}} <span class="badge text-bg-success">Version {{.Save.Version}}</span></h1>
|
||||
<hr />
|
||||
<h3>Details</h3>
|
||||
<ul class="list-group list-group-flush">
|
||||
<li class="list-group-item">UUID: {{.Save.ID}}</li>
|
||||
<li class="list-group-item">Last Upload: {{.Save.Date}}</li>
|
||||
<li class="list-group-item">Hash (MD5): {{.Hash}}</li>
|
||||
</ul>
|
||||
|
||||
<hr />
|
||||
<h3>Backup</h3>
|
||||
{{ range .BackupMetadata}}
|
||||
<div class="card" style="margin-top: 1rem;">
|
||||
<div class="card-body">
|
||||
<h5 class="card-title">{{.CreatedAt}}</h5>
|
||||
<h6 class="card-subtitle mb-2 text-body-secondary">{{.UUID}}</h6>
|
||||
<p class="card-text">MD5: {{.MD5}}</p>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.7/dist/js/bootstrap.bundle.min.js"
|
||||
integrity="sha384-ndDqU0Gzau9qJ1lfW4pNLlhNTkCfHzAVBReH9diLvGRem5+R9g2FzA8ZGN954O5Q"
|
||||
crossorigin="anonymous"></script>
|
||||
|
||||
</html>
|
||||
52
cmd/web/server/templates/information.html
Normal file
52
cmd/web/server/templates/information.html
Normal file
@@ -0,0 +1,52 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>System Information</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.7/dist/css/bootstrap.min.css" rel="stylesheet"
|
||||
integrity="sha384-LN+7fdVzj6u52u30Kp6M/trliBMCMKTyK833zpbD+pXdCLuTusPj697FH4R/5mcr" crossorigin="anonymous">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<nav class="navbar bg-body-tertiary">
|
||||
<div class="container-fluid">
|
||||
<span class="navbar-brand mb-0 h1">CloudSave</span>
|
||||
<span class="muted">v{{.Version}}</span>
|
||||
</div>
|
||||
</nav>
|
||||
<div class="container" style="margin-top: 1rem;">
|
||||
<nav aria-label="breadcrumb">
|
||||
<ol class="breadcrumb">
|
||||
<li class="breadcrumb-item"><a href="/web">Home</a></li>
|
||||
<li class="breadcrumb-item active" aria-current="page">System</li>
|
||||
</ol>
|
||||
</nav>
|
||||
<div class="list-group">
|
||||
<h3>Client</h3>
|
||||
<hr />
|
||||
<ul class="list-group list-group-flush">
|
||||
<li class="list-group-item">Version: {{.Client.Version}}</li>
|
||||
<li class="list-group-item">API Version: {{.Client.APIVersion}}</li>
|
||||
<li class="list-group-item">Go Version: {{.Client.GoVersion}}</li>
|
||||
<li class="list-group-item">OS: {{.Client.OSName}}/{{.Client.OSArchitecture}}</li>
|
||||
</ul>
|
||||
|
||||
<hr />
|
||||
<h3>Server</h3>
|
||||
<hr />
|
||||
<ul class="list-group list-group-flush">
|
||||
<li class="list-group-item">Version: {{.Server.Version}}</li>
|
||||
<li class="list-group-item">API Version: {{.Server.APIVersion}}</li>
|
||||
<li class="list-group-item">Go Version: {{.Server.GoVersion}}</li>
|
||||
<li class="list-group-item">OS: {{.Server.OSName}}/{{.Server.OSArchitecture}}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.7/dist/js/bootstrap.bundle.min.js"
|
||||
integrity="sha384-ndDqU0Gzau9qJ1lfW4pNLlhNTkCfHzAVBReH9diLvGRem5+R9g2FzA8ZGN954O5Q"
|
||||
crossorigin="anonymous"></script>
|
||||
|
||||
</html>
|
||||
34
cmd/web/web.go
Normal file
34
cmd/web/web.go
Normal file
@@ -0,0 +1,34 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"cloudsave/cmd/web/server"
|
||||
"cloudsave/cmd/web/server/config"
|
||||
"cloudsave/pkg/constants"
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"runtime"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
func main() {
|
||||
fmt.Printf("CloudSave web -- v%s.%s.%s\n\n", constants.Version, runtime.GOOS, runtime.GOARCH)
|
||||
|
||||
var configPath string
|
||||
flag.StringVar(&configPath, "config", "/var/lib/cloudsave/config.json", "Define the path to the configuration file")
|
||||
flag.Parse()
|
||||
|
||||
c, err := config.Load(configPath)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "failed to load configuration:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
s := server.NewServer(c)
|
||||
|
||||
fmt.Println("starting server at :" + strconv.Itoa(c.Server.Port))
|
||||
if err := s.Server.ListenAndServe(); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "failed to start web server:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
1
go.mod
1
go.mod
@@ -5,6 +5,7 @@ go 1.24
|
||||
require (
|
||||
github.com/go-chi/chi/v5 v5.2.1
|
||||
github.com/google/subcommands v1.2.0
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/schollz/progressbar/v3 v3.18.0
|
||||
golang.org/x/crypto v0.38.0
|
||||
golang.org/x/term v0.32.0
|
||||
|
||||
2
go.sum
2
go.sum
@@ -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/google/subcommands v1.2.0 h1:vWQspBTo2nEqTUFita5/KeEWlUL8kQObDFbub/EN9oE=
|
||||
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/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
||||
github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db h1:62I3jR2EmQ4l5rM/4FEfDWcRD+abF5XlKShorW5LRoQ=
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
package constants
|
||||
|
||||
const Version = "0.0.1"
|
||||
const Version = "0.0.3"
|
||||
|
||||
const ApiVersion = 1
|
||||
|
||||
@@ -2,8 +2,8 @@ package client
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"cloudsave/pkg/game"
|
||||
"cloudsave/pkg/remote/obj"
|
||||
"cloudsave/pkg/repository"
|
||||
customtime "cloudsave/pkg/tools/time"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
@@ -35,6 +35,11 @@ type (
|
||||
}
|
||||
)
|
||||
|
||||
var (
|
||||
ErrNotFound error = errors.New("not found")
|
||||
ErrUnauthorized error = errors.New("unauthorized (HTTP Error 401)")
|
||||
)
|
||||
|
||||
func New(baseURL, username, password string) *Client {
|
||||
return &Client{
|
||||
baseURL: baseURL,
|
||||
@@ -117,19 +122,19 @@ func (c *Client) Hash(gameID string) (string, error) {
|
||||
return "", errors.New("invalid payload sent by the server")
|
||||
}
|
||||
|
||||
func (c *Client) Metadata(gameID string) (game.Metadata, error) {
|
||||
func (c *Client) Metadata(gameID string) (repository.Metadata, error) {
|
||||
u, err := url.JoinPath(c.baseURL, "api", "v1", "games", gameID, "metadata")
|
||||
if err != nil {
|
||||
return game.Metadata{}, err
|
||||
return repository.Metadata{}, err
|
||||
}
|
||||
|
||||
o, err := c.get(u)
|
||||
if err != nil {
|
||||
return game.Metadata{}, err
|
||||
return repository.Metadata{}, err
|
||||
}
|
||||
|
||||
if m, ok := (o.Data).(map[string]any); ok {
|
||||
gm := game.Metadata{
|
||||
gm := repository.Metadata{
|
||||
ID: m["id"].(string),
|
||||
Name: m["name"].(string),
|
||||
Version: int(m["version"].(float64)),
|
||||
@@ -138,66 +143,122 @@ func (c *Client) Metadata(gameID string) (game.Metadata, error) {
|
||||
return gm, nil
|
||||
}
|
||||
|
||||
return game.Metadata{}, errors.New("invalid payload sent by the server")
|
||||
return repository.Metadata{}, errors.New("invalid payload sent by the server")
|
||||
}
|
||||
|
||||
func (c *Client) Push(gameID, archivePath string, m game.Metadata) error {
|
||||
func (c *Client) 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")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
f, err := os.OpenFile(archivePath, os.O_RDONLY, 0)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to open file: %w", err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
buf := new(bytes.Buffer)
|
||||
writer := multipart.NewWriter(buf)
|
||||
|
||||
part, err := writer.CreateFormFile("payload", "data.tar.gz")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err := io.Copy(part, f); err != nil {
|
||||
return fmt.Errorf("failed to copy data: %w", err)
|
||||
}
|
||||
|
||||
writer.WriteField("name", m.Name)
|
||||
writer.WriteField("version", strconv.Itoa(m.Version))
|
||||
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)
|
||||
req, err := http.NewRequest("GET", u, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
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)
|
||||
if err != nil {
|
||||
return err
|
||||
return fmt.Errorf("cannot connect to remote: %w", err)
|
||||
}
|
||||
defer res.Body.Close()
|
||||
|
||||
if res.StatusCode != 201 {
|
||||
return fmt.Errorf("server returns an unexpected status code: %s (expected 201)", res.Status)
|
||||
if res.StatusCode != http.StatusOK {
|
||||
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
|
||||
}
|
||||
|
||||
func (c *Client) Pull(gameID, archivePath string) error {
|
||||
u, err := url.JoinPath(c.baseURL, "api", "v1", "games", gameID, "data")
|
||||
func (c *Client) PullBackup(gameID, uuid, archivePath string) error {
|
||||
u, err := url.JoinPath(c.baseURL, "api", "v1", "games", gameID, "hist", uuid, "data")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -271,7 +332,7 @@ func (c *Client) Ping() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) All() ([]game.Metadata, error) {
|
||||
func (c *Client) All() ([]repository.Metadata, error) {
|
||||
u, err := url.JoinPath(c.baseURL, "api", "v1", "games")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -283,10 +344,10 @@ func (c *Client) All() ([]game.Metadata, error) {
|
||||
}
|
||||
|
||||
if games, ok := (o.Data).([]any); ok {
|
||||
var res []game.Metadata
|
||||
var res []repository.Metadata
|
||||
for _, g := range games {
|
||||
if v, ok := g.(map[string]any); ok {
|
||||
gm := game.Metadata{
|
||||
gm := repository.Metadata{
|
||||
ID: v["id"].(string),
|
||||
Name: v["name"].(string),
|
||||
Version: int(v["version"].(float64)),
|
||||
@@ -318,6 +379,14 @@ func (c *Client) get(url string) (obj.HTTPObject, error) {
|
||||
}
|
||||
defer res.Body.Close()
|
||||
|
||||
if res.StatusCode == 404 {
|
||||
return obj.HTTPObject{}, ErrNotFound
|
||||
}
|
||||
|
||||
if res.StatusCode == 401 {
|
||||
return obj.HTTPObject{}, ErrUnauthorized
|
||||
}
|
||||
|
||||
if res.StatusCode != 200 {
|
||||
return obj.HTTPObject{}, fmt.Errorf("server returns an unexpected status code: %d %s (expected 200)", res.StatusCode, res.Status)
|
||||
}
|
||||
@@ -331,3 +400,53 @@ func (c *Client) get(url string) (obj.HTTPObject, error) {
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
package game
|
||||
package repository
|
||||
|
||||
import (
|
||||
"cloudsave/pkg/tools/hash"
|
||||
"cloudsave/pkg/tools/id"
|
||||
"crypto/md5"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type (
|
||||
@@ -20,6 +22,13 @@ type (
|
||||
Version int `json:"version"`
|
||||
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 (
|
||||
@@ -135,6 +144,136 @@ func One(gameID string) (Metadata, error) {
|
||||
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 {
|
||||
return datastorepath
|
||||
}
|
||||
@@ -150,18 +289,7 @@ func Remove(gameID string) error {
|
||||
func Hash(gameID string) (string, error) {
|
||||
path := filepath.Join(datastorepath, gameID, "data.tar.gz")
|
||||
|
||||
f, err := os.OpenFile(path, 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
|
||||
return hash.FileMD5(path)
|
||||
}
|
||||
|
||||
func Version(gameID string) (int, error) {
|
||||
@@ -3,6 +3,7 @@ package archive
|
||||
import (
|
||||
"archive/tar"
|
||||
"compress/gzip"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"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
23
pkg/tools/hash/hash.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user