Compare commits
23 Commits
4ee8fe48bc
...
0.0.4
| Author | SHA1 | Date | |
|---|---|---|---|
| a7c85ea3c6 | |||
| ea6948dbe2 | |||
| da2ad068b4 | |||
| bbd0983e63 | |||
| 54dcc7d006 | |||
| 851ff89886 | |||
| aa29fae900 | |||
| 822c93bf4c | |||
| ab857bd0dd | |||
| e34dc704ca | |||
| 810c5ac627 | |||
| 13adb26fba | |||
| 23ffa93615 | |||
| 23e46e5eef | |||
| c1fc8a52f4 | |||
| 6127da4626 | |||
| d208b1da91 | |||
| c329f96a76 | |||
| f699fcd7f6 | |||
| 898012a30d | |||
| 2f777c72ee | |||
| d479004217 | |||
| cf96815d0f |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -1,4 +1,7 @@
|
|||||||
/cli
|
/cli
|
||||||
/server
|
/server
|
||||||
|
/web
|
||||||
/env/
|
/env/
|
||||||
/build/
|
/build/
|
||||||
|
*.exe
|
||||||
|
/config.json
|
||||||
12
.vscode/launch.json
vendored
12
.vscode/launch.json
vendored
@@ -4,9 +4,17 @@
|
|||||||
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
|
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
|
||||||
"version": "0.2.0",
|
"version": "0.2.0",
|
||||||
"configurations": [
|
"configurations": [
|
||||||
|
|
||||||
{
|
{
|
||||||
"name": "Launch Package",
|
"name": "server",
|
||||||
|
"type": "go",
|
||||||
|
"request": "launch",
|
||||||
|
"mode": "auto",
|
||||||
|
"args": ["-document-root", "${workspaceFolder}/env"],
|
||||||
|
"console": "integratedTerminal",
|
||||||
|
"program": "${workspaceFolder}/cmd/server"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "cli",
|
||||||
"type": "go",
|
"type": "go",
|
||||||
"request": "launch",
|
"request": "launch",
|
||||||
"mode": "auto",
|
"mode": "auto",
|
||||||
|
|||||||
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.
|
||||||
58
README.md
Normal file
58
README.md
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
# 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.
|
||||||
|
```
|
||||||
|
|
||||||
|
To generate bcrypt password, I recommand [hash_utils](https://git.thelilfrog.com/thelilfrog/hash_utils), which is offline and secure
|
||||||
|
|
||||||
|
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 everything 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
|
||||||
|
```
|
||||||
24
build.sh
24
build.sh
@@ -1,7 +1,7 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
|
|
||||||
MAKE_PACKAGE=false
|
MAKE_PACKAGE=false
|
||||||
VERSION=0.0.2
|
VERSION=0.0.4
|
||||||
|
|
||||||
usage() {
|
usage() {
|
||||||
echo "Usage: $0 [OPTIONS]"
|
echo "Usage: $0 [OPTIONS]"
|
||||||
@@ -55,6 +55,28 @@ for platform in "${platforms[@]}"; do
|
|||||||
fi
|
fi
|
||||||
done
|
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
|
||||||
|
|
||||||
## CLIENT
|
## CLIENT
|
||||||
|
|
||||||
platforms=("windows/amd64" "windows/arm64" "darwin/amd64" "darwin/arm64" "linux/amd64" "linux/arm64")
|
platforms=("windows/amd64" "windows/arm64" "darwin/amd64" "darwin/arm64" "linux/amd64" "linux/arm64")
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
package add
|
package add
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"cloudsave/pkg/repository"
|
"cloudsave/pkg/data"
|
||||||
"context"
|
"context"
|
||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -13,20 +13,26 @@ import (
|
|||||||
|
|
||||||
type (
|
type (
|
||||||
AddCmd struct {
|
AddCmd struct {
|
||||||
|
Service *data.Service
|
||||||
name string
|
name string
|
||||||
|
remote string
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
func (*AddCmd) Name() string { return "add" }
|
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 {
|
func (*AddCmd) Usage() string {
|
||||||
return `add:
|
return `Usage: cloudsave add [-name] [-remote] <PATH>
|
||||||
Add a folder to the sync list
|
|
||||||
|
Add a folder to the track list
|
||||||
|
|
||||||
|
Options:
|
||||||
`
|
`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *AddCmd) SetFlags(f *flag.FlagSet) {
|
func (p *AddCmd) SetFlags(f *flag.FlagSet) {
|
||||||
f.StringVar(&p.name, "name", "", "Override the name of the game")
|
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 {
|
func (p *AddCmd) Execute(_ context.Context, f *flag.FlagSet, _ ...interface{}) subcommands.ExitStatus {
|
||||||
@@ -44,13 +50,16 @@ func (p *AddCmd) Execute(_ context.Context, f *flag.FlagSet, _ ...interface{}) s
|
|||||||
p.name = filepath.Base(path)
|
p.name = filepath.Base(path)
|
||||||
}
|
}
|
||||||
|
|
||||||
m, err := repository.Add(p.name, path)
|
gameID, err := p.Service.Add(p.name, path, p.remote)
|
||||||
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 this gamesave to the datastore:", err)
|
||||||
return subcommands.ExitFailure
|
return subcommands.ExitFailure
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Println(m.ID)
|
if err := p.Service.Scan(gameID); err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, "error: failed to scan:", err)
|
||||||
|
return subcommands.ExitFailure
|
||||||
|
}
|
||||||
|
|
||||||
return subcommands.ExitSuccess
|
return subcommands.ExitSuccess
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,35 +1,35 @@
|
|||||||
package apply
|
package apply
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"cloudsave/pkg/repository"
|
"cloudsave/pkg/data"
|
||||||
"cloudsave/pkg/tools/archive"
|
|
||||||
"context"
|
"context"
|
||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
|
||||||
|
|
||||||
"github.com/google/subcommands"
|
"github.com/google/subcommands"
|
||||||
)
|
)
|
||||||
|
|
||||||
type (
|
type (
|
||||||
ListCmd struct {
|
ApplyCmd struct {
|
||||||
|
Service *data.Service
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
func (*ListCmd) Name() string { return "apply" }
|
func (*ApplyCmd) Name() string { return "apply" }
|
||||||
func (*ListCmd) Synopsis() string { return "apply a backup" }
|
func (*ApplyCmd) Synopsis() string { return "apply a backup" }
|
||||||
func (*ListCmd) Usage() string {
|
func (*ApplyCmd) Usage() string {
|
||||||
return `apply:
|
return `Usage: cloudsave apply <GAME_ID> [BACKUP_ID]
|
||||||
|
|
||||||
Apply a backup
|
Apply a backup
|
||||||
`
|
`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *ListCmd) SetFlags(f *flag.FlagSet) {
|
func (p *ApplyCmd) SetFlags(f *flag.FlagSet) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *ListCmd) Execute(_ context.Context, f *flag.FlagSet, _ ...interface{}) subcommands.ExitStatus {
|
func (p *ApplyCmd) Execute(_ context.Context, f *flag.FlagSet, _ ...interface{}) subcommands.ExitStatus {
|
||||||
if f.NArg() != 2 {
|
if f.NArg() < 1 {
|
||||||
fmt.Fprintln(os.Stderr, "error: missing game ID and/or backup uuid")
|
fmt.Fprintln(os.Stderr, "error: missing game ID and/or backup uuid")
|
||||||
return subcommands.ExitUsageError
|
return subcommands.ExitUsageError
|
||||||
}
|
}
|
||||||
@@ -37,31 +37,16 @@ func (p *ListCmd) Execute(_ context.Context, f *flag.FlagSet, _ ...interface{})
|
|||||||
gameID := f.Arg(0)
|
gameID := f.Arg(0)
|
||||||
uuid := f.Arg(1)
|
uuid := f.Arg(1)
|
||||||
|
|
||||||
g, err := repository.One(gameID)
|
if len(uuid) == 0 {
|
||||||
if err != nil {
|
if err := p.Service.ApplyCurrent(gameID); err != nil {
|
||||||
fmt.Fprintf(os.Stderr, "error: failed to open game metadata: %s\n", err)
|
fmt.Fprintf(os.Stderr, "error: failed to apply: %s", err)
|
||||||
return subcommands.ExitFailure
|
return subcommands.ExitFailure
|
||||||
}
|
}
|
||||||
|
return subcommands.ExitSuccess
|
||||||
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 {
|
if err := p.Service.ApplyBackup(gameID, uuid); err != nil {
|
||||||
fmt.Fprintf(os.Stderr, "error: failed to remove old data: %s\n", err)
|
fmt.Fprintf(os.Stderr, "error: failed to apply: %s", 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.ExitFailure
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ package list
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"cloudsave/cmd/cli/tools/prompt/credentials"
|
"cloudsave/cmd/cli/tools/prompt/credentials"
|
||||||
|
"cloudsave/pkg/data"
|
||||||
"cloudsave/pkg/remote/client"
|
"cloudsave/pkg/remote/client"
|
||||||
"cloudsave/pkg/repository"
|
|
||||||
"context"
|
"context"
|
||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -14,6 +14,7 @@ import (
|
|||||||
|
|
||||||
type (
|
type (
|
||||||
ListCmd struct {
|
ListCmd struct {
|
||||||
|
Service *data.Service
|
||||||
remote bool
|
remote bool
|
||||||
backup bool
|
backup bool
|
||||||
}
|
}
|
||||||
@@ -22,8 +23,11 @@ type (
|
|||||||
func (*ListCmd) Name() string { return "list" }
|
func (*ListCmd) Name() string { return "list" }
|
||||||
func (*ListCmd) Synopsis() string { return "list all game registered" }
|
func (*ListCmd) Synopsis() string { return "list all game registered" }
|
||||||
func (*ListCmd) Usage() string {
|
func (*ListCmd) Usage() string {
|
||||||
return `list:
|
return `Usage: cloudsave list [-include-backup] [-a]
|
||||||
|
|
||||||
List all game registered
|
List all game registered
|
||||||
|
|
||||||
|
Options:
|
||||||
`
|
`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -41,25 +45,25 @@ func (p *ListCmd) Execute(_ context.Context, f *flag.FlagSet, _ ...interface{})
|
|||||||
|
|
||||||
username, password, err := credentials.Read()
|
username, password, err := credentials.Read()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Fprintf(os.Stderr, "failed to read std output: %s", err)
|
fmt.Fprintf(os.Stderr, "error: failed to read std output: %s", err)
|
||||||
return subcommands.ExitFailure
|
return subcommands.ExitFailure
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := remote(f.Arg(0), username, password, p.backup); err != nil {
|
if err := p.server(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(p.backup); err != nil {
|
if err := p.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(includeBackup bool) error {
|
func (p *ListCmd) local(includeBackup bool) error {
|
||||||
games, err := repository.All()
|
games, err := p.Service.AllGames()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to load datastore: %w", err)
|
return fmt.Errorf("failed to load datastore: %w", err)
|
||||||
}
|
}
|
||||||
@@ -69,7 +73,7 @@ func local(includeBackup bool) error {
|
|||||||
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 {
|
if includeBackup {
|
||||||
bk, err := repository.Archives(g.ID)
|
bk, err := p.Service.AllBackups(g.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to list backup files: %w", err)
|
return fmt.Errorf("failed to list backup files: %w", err)
|
||||||
}
|
}
|
||||||
@@ -86,7 +90,7 @@ func local(includeBackup bool) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func remote(url, username, password string, includeBackup bool) error {
|
func (p *ListCmd) server(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 {
|
||||||
|
|||||||
@@ -1,28 +1,28 @@
|
|||||||
package pull
|
package pull
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"cloudsave/pkg/remote/client"
|
|
||||||
"cloudsave/pkg/repository"
|
|
||||||
"cloudsave/pkg/tools/archive"
|
|
||||||
"cloudsave/cmd/cli/tools/prompt/credentials"
|
"cloudsave/cmd/cli/tools/prompt/credentials"
|
||||||
|
"cloudsave/pkg/data"
|
||||||
|
"cloudsave/pkg/remote/client"
|
||||||
"context"
|
"context"
|
||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
|
||||||
|
|
||||||
"github.com/google/subcommands"
|
"github.com/google/subcommands"
|
||||||
)
|
)
|
||||||
|
|
||||||
type (
|
type (
|
||||||
PullCmd struct {
|
PullCmd struct {
|
||||||
|
Service *data.Service
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
func (*PullCmd) Name() string { return "pull" }
|
func (*PullCmd) Name() string { return "pull" }
|
||||||
func (*PullCmd) Synopsis() string { return "pull a game save from the remote" }
|
func (*PullCmd) Synopsis() string { return "pull a game save from the remote" }
|
||||||
func (*PullCmd) Usage() string {
|
func (*PullCmd) Usage() string {
|
||||||
return `list:
|
return `Usage: cloudsave pull <GAME_ID>
|
||||||
|
|
||||||
Pull a game save from the remote
|
Pull a game save from the remote
|
||||||
`
|
`
|
||||||
}
|
}
|
||||||
@@ -43,45 +43,33 @@ func (p *PullCmd) Execute(_ context.Context, f *flag.FlagSet, _ ...interface{})
|
|||||||
|
|
||||||
username, password, err := credentials.Read()
|
username, password, err := credentials.Read()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Fprintf(os.Stderr, "failed to read std output: %s", err)
|
fmt.Fprintf(os.Stderr, "error: failed to read std output: %s", err)
|
||||||
return subcommands.ExitFailure
|
return subcommands.ExitFailure
|
||||||
}
|
}
|
||||||
|
|
||||||
cli := client.New(url, username, password)
|
cli := client.New(url, username, password)
|
||||||
|
|
||||||
if err := cli.Ping(); err != nil {
|
if err := cli.Ping(); err != nil {
|
||||||
fmt.Fprintf(os.Stderr, "failed to connect to the remote: %s", err)
|
fmt.Fprintf(os.Stderr, "error: failed to connect to the remote: %s", err)
|
||||||
return subcommands.ExitFailure
|
return subcommands.ExitFailure
|
||||||
}
|
}
|
||||||
|
|
||||||
archivePath := filepath.Join(repository.DatastorePath(), gameID, "data.tar.gz")
|
if err := p.Service.PullCurrent(gameID, path, cli); err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "error: failed to pull current archive: %s", err)
|
||||||
|
return subcommands.ExitFailure
|
||||||
|
}
|
||||||
|
|
||||||
m, err := cli.Metadata(gameID)
|
ids, err := cli.ListArchives(gameID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Fprintf(os.Stderr, "failed to get metadata: %s", err)
|
fmt.Fprintf(os.Stderr, "error: failed to list backup archive: %s", err)
|
||||||
return subcommands.ExitFailure
|
return subcommands.ExitFailure
|
||||||
}
|
}
|
||||||
|
|
||||||
err = repository.Register(m, path)
|
for _, id := range ids {
|
||||||
if err != nil {
|
if err := p.Service.PullBackup(gameID, id, cli); err != nil {
|
||||||
fmt.Fprintf(os.Stderr, "failed to register local metadata: %s", err)
|
fmt.Fprintf(os.Stderr, "error: failed to pull backup archive %s: %s", id, err)
|
||||||
return subcommands.ExitFailure
|
return subcommands.ExitFailure
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := cli.Pull(gameID, archivePath); err != nil {
|
|
||||||
fmt.Fprintf(os.Stderr, "failed to pull from the remote: %s", err)
|
|
||||||
return subcommands.ExitFailure
|
|
||||||
}
|
|
||||||
|
|
||||||
fi, err := os.OpenFile(archivePath, os.O_RDONLY, 0)
|
|
||||||
if err != nil {
|
|
||||||
fmt.Fprintf(os.Stderr, "failed to open archive: %s", err)
|
|
||||||
return subcommands.ExitFailure
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := archive.Untar(fi, path); err != nil {
|
|
||||||
fmt.Fprintf(os.Stderr, "failed to unarchive file: %s", err)
|
|
||||||
return subcommands.ExitFailure
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return subcommands.ExitSuccess
|
return subcommands.ExitSuccess
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
package remote
|
package remote
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"cloudsave/pkg/data"
|
||||||
"cloudsave/pkg/remote"
|
"cloudsave/pkg/remote"
|
||||||
"cloudsave/pkg/remote/client"
|
"cloudsave/pkg/remote/client"
|
||||||
"cloudsave/pkg/repository"
|
|
||||||
"context"
|
"context"
|
||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -14,16 +14,24 @@ import (
|
|||||||
|
|
||||||
type (
|
type (
|
||||||
RemoteCmd struct {
|
RemoteCmd struct {
|
||||||
|
Service *data.Service
|
||||||
set bool
|
set bool
|
||||||
list bool
|
list bool
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
func (*RemoteCmd) Name() string { return "remote" }
|
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 {
|
func (*RemoteCmd) Usage() string {
|
||||||
return `remote:
|
return `Usage: cloudsave remote <-set|-list>
|
||||||
manage remove
|
|
||||||
|
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
|
||||||
`
|
`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -36,7 +44,7 @@ func (p *RemoteCmd) Execute(ctx context.Context, f *flag.FlagSet, _ ...interface
|
|||||||
switch {
|
switch {
|
||||||
case p.list:
|
case p.list:
|
||||||
{
|
{
|
||||||
if err := list(); err != nil {
|
if err := p.print(); err != nil {
|
||||||
fmt.Fprintln(os.Stderr, "error:", err)
|
fmt.Fprintln(os.Stderr, "error:", err)
|
||||||
return subcommands.ExitFailure
|
return subcommands.ExitFailure
|
||||||
}
|
}
|
||||||
@@ -61,8 +69,8 @@ func (p *RemoteCmd) Execute(ctx context.Context, f *flag.FlagSet, _ ...interface
|
|||||||
return subcommands.ExitSuccess
|
return subcommands.ExitSuccess
|
||||||
}
|
}
|
||||||
|
|
||||||
func list() error {
|
func (p *RemoteCmd) print() error {
|
||||||
games, err := repository.All()
|
games, err := p.Service.AllGames()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to load datastore: %w", err)
|
return fmt.Errorf("failed to load datastore: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
package remove
|
package remove
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"cloudsave/pkg/repository"
|
"cloudsave/pkg/data"
|
||||||
"context"
|
"context"
|
||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -11,14 +11,18 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type (
|
type (
|
||||||
RemoveCmd struct{}
|
RemoveCmd struct {
|
||||||
|
Service *data.Service
|
||||||
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
func (*RemoveCmd) Name() string { return "remove" }
|
func (*RemoveCmd) Name() string { return "remove" }
|
||||||
func (*RemoveCmd) Synopsis() string { return "unregister a game" }
|
func (*RemoveCmd) Synopsis() string { return "unregister a game" }
|
||||||
func (*RemoveCmd) Usage() string {
|
func (*RemoveCmd) Usage() string {
|
||||||
return `remove:
|
return `Usage: cloudsave remove <GAME_ID>
|
||||||
|
|
||||||
Unregister a game
|
Unregister a game
|
||||||
|
Caution: all the backup are deleted
|
||||||
`
|
`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -31,7 +35,7 @@ func (p *RemoveCmd) Execute(_ context.Context, f *flag.FlagSet, _ ...interface{}
|
|||||||
return subcommands.ExitUsageError
|
return subcommands.ExitUsageError
|
||||||
}
|
}
|
||||||
|
|
||||||
err := repository.Remove(f.Arg(0))
|
err := p.Service.RemoveGame(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
|
||||||
|
|||||||
@@ -1,56 +1,48 @@
|
|||||||
package run
|
package run
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"cloudsave/pkg/repository"
|
"cloudsave/pkg/data"
|
||||||
"cloudsave/pkg/tools/archive"
|
|
||||||
"context"
|
"context"
|
||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/google/subcommands"
|
"github.com/google/subcommands"
|
||||||
"github.com/schollz/progressbar/v3"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type (
|
type (
|
||||||
RunCmd struct {
|
RunCmd struct {
|
||||||
|
Service *data.Service
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
func (*RunCmd) Name() string { return "run" }
|
func (*RunCmd) Name() string { return "scan" }
|
||||||
func (*RunCmd) Synopsis() string { return "Check and process all the folder" }
|
func (*RunCmd) Synopsis() string { return "check and process all the folder" }
|
||||||
func (*RunCmd) Usage() string {
|
func (*RunCmd) Usage() string {
|
||||||
return `run:
|
return `Usage: cloudsave scan
|
||||||
Check and process all the folder
|
|
||||||
|
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) 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 := repository.All()
|
datastore, err := p.Service.AllGames()
|
||||||
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
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, metadata := range datastore {
|
for _, metadata := range datastore {
|
||||||
metadataPath := filepath.Join(repository.DatastorePath(), metadata.ID)
|
if err := p.Service.MakeBackup(metadata.ID); err != nil {
|
||||||
//todo transaction
|
fmt.Fprintln(os.Stderr, "error: failed to make backup:", err)
|
||||||
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
|
return subcommands.ExitFailure
|
||||||
}
|
}
|
||||||
if err := repository.SetVersion(metadata.ID, metadata.Version+1); err != nil {
|
if err := p.Service.Scan(metadata.ID); err != nil {
|
||||||
fmt.Fprintf(os.Stderr, "error: cannot process the data of %s: %s\n", metadata.ID, err)
|
fmt.Fprintln(os.Stderr, "error: failed to scan:", err)
|
||||||
return subcommands.ExitFailure
|
|
||||||
}
|
|
||||||
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
|
return subcommands.ExitFailure
|
||||||
}
|
}
|
||||||
fmt.Println("✅", metadata.Name)
|
fmt.Println("✅", metadata.Name)
|
||||||
@@ -59,78 +51,3 @@ func (p *RunCmd) Execute(_ context.Context, f *flag.FlagSet, _ ...interface{}) s
|
|||||||
fmt.Println("done.")
|
fmt.Println("done.")
|
||||||
return subcommands.ExitSuccess
|
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(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("failed to reading state file: %w", err)
|
|
||||||
}
|
|
||||||
if err == nil {
|
|
||||||
lastRun, err = time.Parse(time.RFC3339, string(data))
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("parsing state file timestamp: %w", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// check for changes
|
|
||||||
changed := false
|
|
||||||
err = filepath.Walk(srcDir, func(path string, info os.FileInfo, walkErr error) error {
|
|
||||||
if walkErr != nil {
|
|
||||||
return walkErr
|
|
||||||
}
|
|
||||||
if info.ModTime().After(lastRun) {
|
|
||||||
changed = true
|
|
||||||
return io.EOF // early exit
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
if err != nil && err != io.EOF {
|
|
||||||
return fmt.Errorf("failed to scanning source directory: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if !changed {
|
|
||||||
pg.Finish()
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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("failed to creating archive file: %w", err)
|
|
||||||
}
|
|
||||||
defer f.Close()
|
|
||||||
|
|
||||||
if err := archive.Tar(f, srcDir); err != nil {
|
|
||||||
return fmt.Errorf("failed archiving files: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|||||||
51
cmd/cli/commands/show/show.go
Normal file
51
cmd/cli/commands/show/show.go
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
package show
|
||||||
|
|
||||||
|
import (
|
||||||
|
"cloudsave/pkg/data"
|
||||||
|
"context"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"github.com/google/subcommands"
|
||||||
|
)
|
||||||
|
|
||||||
|
type (
|
||||||
|
ShowCmd struct {
|
||||||
|
Service *data.Service
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
func (*ShowCmd) Name() string { return "show" }
|
||||||
|
func (*ShowCmd) Synopsis() string { return "show metadata about game" }
|
||||||
|
func (*ShowCmd) Usage() string {
|
||||||
|
return `Usage: cloudsave show <GAME_ID>
|
||||||
|
|
||||||
|
Show metdata about a game
|
||||||
|
`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *ShowCmd) SetFlags(f *flag.FlagSet) {
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *ShowCmd) Execute(_ context.Context, f *flag.FlagSet, _ ...interface{}) subcommands.ExitStatus {
|
||||||
|
if f.NArg() != 1 {
|
||||||
|
fmt.Fprintln(os.Stderr, "error: missing game ID")
|
||||||
|
return subcommands.ExitUsageError
|
||||||
|
}
|
||||||
|
|
||||||
|
gameID := f.Arg(0)
|
||||||
|
g, err := p.Service.One(gameID)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "error: failed to apply: %s", err)
|
||||||
|
return subcommands.ExitFailure
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println(g.Name)
|
||||||
|
fmt.Println("------")
|
||||||
|
fmt.Println("Version: ", g.Version)
|
||||||
|
fmt.Println("Path: ", g.Path)
|
||||||
|
fmt.Println("MD5: ", g.MD5)
|
||||||
|
|
||||||
|
return subcommands.ExitSuccess
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ package sync
|
|||||||
import (
|
import (
|
||||||
"cloudsave/cmd/cli/tools/prompt"
|
"cloudsave/cmd/cli/tools/prompt"
|
||||||
"cloudsave/cmd/cli/tools/prompt/credentials"
|
"cloudsave/cmd/cli/tools/prompt/credentials"
|
||||||
|
"cloudsave/pkg/data"
|
||||||
"cloudsave/pkg/remote"
|
"cloudsave/pkg/remote"
|
||||||
"cloudsave/pkg/remote/client"
|
"cloudsave/pkg/remote/client"
|
||||||
"cloudsave/pkg/repository"
|
"cloudsave/pkg/repository"
|
||||||
@@ -12,7 +13,6 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/google/subcommands"
|
"github.com/google/subcommands"
|
||||||
@@ -21,14 +21,16 @@ import (
|
|||||||
|
|
||||||
type (
|
type (
|
||||||
SyncCmd struct {
|
SyncCmd struct {
|
||||||
|
Service *data.Service
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
func (*SyncCmd) Name() string { return "sync" }
|
func (*SyncCmd) Name() string { return "sync" }
|
||||||
func (*SyncCmd) Synopsis() string { return "list all game registered" }
|
func (*SyncCmd) Synopsis() string { return "list all game registered" }
|
||||||
func (*SyncCmd) Usage() string {
|
func (*SyncCmd) Usage() string {
|
||||||
return `add:
|
return `Usage: cloudsave sync
|
||||||
List all game registered
|
|
||||||
|
Synchronize the archives with the server defined for each game.
|
||||||
`
|
`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -36,7 +38,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 := repository.All()
|
games, err := p.Service.AllGames()
|
||||||
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
|
||||||
@@ -47,6 +49,7 @@ func (p *SyncCmd) Execute(_ context.Context, f *flag.FlagSet, _ ...interface{})
|
|||||||
r, err := remote.One(g.ID)
|
r, err := remote.One(g.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, remote.ErrNoRemote) {
|
if errors.Is(err, remote.ErrNoRemote) {
|
||||||
|
fmt.Println(g.Name + ": no remote configured")
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
fmt.Fprintln(os.Stderr, "error: failed to load datastore:", err)
|
fmt.Fprintln(os.Stderr, "error: failed to load datastore:", err)
|
||||||
@@ -75,26 +78,21 @@ func (p *SyncCmd) Execute(_ context.Context, f *flag.FlagSet, _ ...interface{})
|
|||||||
|
|
||||||
if !exists {
|
if !exists {
|
||||||
pg.Describe(fmt.Sprintf("[%s] Pushing data...", g.Name))
|
pg.Describe(fmt.Sprintf("[%s] Pushing data...", g.Name))
|
||||||
if err := push(g, cli); err != nil {
|
if err := p.push(g, cli); err != nil {
|
||||||
destroyPg()
|
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))
|
pg.Describe(fmt.Sprintf("[%s] Pushing backup...", g.Name))
|
||||||
if err := pushBackup(g, cli); err != nil {
|
if err := p.pushBackup(g, cli); err != nil {
|
||||||
destroyPg()
|
destroyPg()
|
||||||
slog.Warn("failed to push backup files", "err", err)
|
slog.Warn("failed to push backup files", "err", err)
|
||||||
}
|
}
|
||||||
|
fmt.Println(g.Name + ": pushed")
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
pg.Describe(fmt.Sprintf("[%s] Fetching metadata...", g.Name))
|
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)
|
hremote, err := cli.Hash(r.GameID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -103,13 +101,6 @@ func (p *SyncCmd) Execute(_ context.Context, f *flag.FlagSet, _ ...interface{})
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
vlocal, err := repository.Version(r.GameID)
|
|
||||||
if err != nil {
|
|
||||||
destroyPg()
|
|
||||||
slog.Error(err.Error())
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
remoteMetadata, err := cli.Metadata(r.GameID)
|
remoteMetadata, err := cli.Metadata(r.GameID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
destroyPg()
|
destroyPg()
|
||||||
@@ -118,63 +109,64 @@ func (p *SyncCmd) Execute(_ context.Context, f *flag.FlagSet, _ ...interface{})
|
|||||||
}
|
}
|
||||||
|
|
||||||
pg.Describe(fmt.Sprintf("[%s] Pulling backup...", g.Name))
|
pg.Describe(fmt.Sprintf("[%s] Pulling backup...", g.Name))
|
||||||
if err := pullBackup(g, cli); err != nil {
|
if err := p.pullBackup(g, cli); err != nil {
|
||||||
slog.Warn("failed to pull backup files", "err", err)
|
slog.Warn("failed to pull backup files", "err", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
pg.Describe(fmt.Sprintf("[%s] Pushing backup...", g.Name))
|
pg.Describe(fmt.Sprintf("[%s] Pushing backup...", g.Name))
|
||||||
if err := pushBackup(g, cli); err != nil {
|
if err := p.pushBackup(g, cli); err != nil {
|
||||||
slog.Warn("failed to push backup files", "err", err)
|
slog.Warn("failed to push backup files", "err", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if hlocal == hremote {
|
if g.MD5 == hremote {
|
||||||
destroyPg()
|
destroyPg()
|
||||||
if vlocal != remoteMetadata.Version {
|
if g.Version != 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 := repository.SetVersion(r.GameID, remoteMetadata.Version); err != nil {
|
if err := p.Service.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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
fmt.Println("already up-to-date")
|
fmt.Println(g.Name + ": already up-to-date")
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if vlocal > remoteMetadata.Version {
|
if g.Version > remoteMetadata.Version {
|
||||||
pg.Describe(fmt.Sprintf("[%s] Pushing data...", g.Name))
|
pg.Describe(fmt.Sprintf("[%s] Pushing data...", g.Name))
|
||||||
if err := push(g, cli); err != nil {
|
if err := p.push(g, cli); err != nil {
|
||||||
destroyPg()
|
destroyPg()
|
||||||
fmt.Fprintln(os.Stderr, "failed to push:", err)
|
fmt.Fprintln(os.Stderr, "failed to push:", err)
|
||||||
return subcommands.ExitFailure
|
return subcommands.ExitFailure
|
||||||
}
|
}
|
||||||
destroyPg()
|
destroyPg()
|
||||||
|
fmt.Println(g.Name + ": pushed")
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if vlocal < remoteMetadata.Version {
|
if g.Version < remoteMetadata.Version {
|
||||||
destroyPg()
|
destroyPg()
|
||||||
if err := pull(r.GameID, cli); err != nil {
|
if err := p.pull(r.GameID, cli); err != nil {
|
||||||
destroyPg()
|
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 := repository.SetVersion(r.GameID, remoteMetadata.Version); err != nil {
|
|
||||||
|
g.Version = remoteMetadata.Version
|
||||||
|
g.Date = remoteMetadata.Date
|
||||||
|
|
||||||
|
if err := p.Service.UpdateMetadata(g.ID, g); err != nil {
|
||||||
destroyPg()
|
destroyPg()
|
||||||
fmt.Fprintln(os.Stderr, "error: failed to synchronize version number:", err)
|
fmt.Fprintln(os.Stderr, "failed to push:", err)
|
||||||
continue
|
return subcommands.ExitFailure
|
||||||
}
|
|
||||||
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
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
destroyPg()
|
destroyPg()
|
||||||
|
|
||||||
if vlocal == remoteMetadata.Version {
|
if g.Version == remoteMetadata.Version {
|
||||||
if err := conflict(r.GameID, g, remoteMetadata, cli); err != nil {
|
if err := p.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)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -186,8 +178,8 @@ func (p *SyncCmd) Execute(_ context.Context, f *flag.FlagSet, _ ...interface{})
|
|||||||
return subcommands.ExitSuccess
|
return subcommands.ExitSuccess
|
||||||
}
|
}
|
||||||
|
|
||||||
func conflict(gameID string, m, remoteMetadata repository.Metadata, cli *client.Client) error {
|
func (p *SyncCmd) conflict(gameID string, m, remoteMetadata repository.Metadata, cli *client.Client) error {
|
||||||
g, err := repository.One(gameID)
|
g, err := p.Service.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)
|
||||||
@@ -206,35 +198,33 @@ func conflict(gameID string, m, remoteMetadata repository.Metadata, cli *client.
|
|||||||
switch res {
|
switch res {
|
||||||
case prompt.My:
|
case prompt.My:
|
||||||
{
|
{
|
||||||
if err := push(m, cli); err != nil {
|
if err := p.push(m, cli); err != nil {
|
||||||
return fmt.Errorf("failed to push: %w", err)
|
return fmt.Errorf("failed to push: %w", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
case prompt.Their:
|
case prompt.Their:
|
||||||
{
|
{
|
||||||
if err := pull(gameID, cli); err != nil {
|
if err := p.pull(gameID, cli); err != nil {
|
||||||
return fmt.Errorf("failed to push: %w", err)
|
return fmt.Errorf("failed to push: %w", err)
|
||||||
}
|
}
|
||||||
if err := repository.SetVersion(gameID, remoteMetadata.Version); err != nil {
|
g.Version = remoteMetadata.Version
|
||||||
return fmt.Errorf("failed to synchronize version number: %w", err)
|
g.Date = remoteMetadata.Date
|
||||||
}
|
|
||||||
if err := repository.SetDate(gameID, remoteMetadata.Date); err != nil {
|
if err := p.Service.UpdateMetadata(g.ID, g); err != nil {
|
||||||
return fmt.Errorf("failed to synchronize date: %w", err)
|
return fmt.Errorf("failed to push: %w", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func push(m repository.Metadata, cli *client.Client) error {
|
func (p *SyncCmd) push(m repository.Metadata, cli *client.Client) error {
|
||||||
archivePath := filepath.Join(repository.DatastorePath(), m.ID, "data.tar.gz")
|
return p.Service.PushArchive(m.ID, "", cli)
|
||||||
|
|
||||||
return cli.PushSave(archivePath, m)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func pushBackup(m repository.Metadata, cli *client.Client) error {
|
func (p *SyncCmd) pushBackup(m repository.Metadata, cli *client.Client) error {
|
||||||
bs, err := repository.Archives(m.ID)
|
bs, err := p.Service.AllBackups(m.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -257,7 +247,7 @@ func pushBackup(m repository.Metadata, cli *client.Client) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func pullBackup(m repository.Metadata, cli *client.Client) error {
|
func (p *SyncCmd) pullBackup(m repository.Metadata, cli *client.Client) error {
|
||||||
bs, err := cli.ListArchives(m.ID)
|
bs, err := cli.ListArchives(m.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -269,20 +259,13 @@ func pullBackup(m repository.Metadata, cli *client.Client) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
linfo, err := repository.Archive(m.ID, uuid)
|
linfo, err := p.Service.Backup(m.ID, uuid)
|
||||||
if err != nil {
|
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
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
if rinfo.MD5 != linfo.MD5 {
|
if linfo.MD5 != rinfo.MD5 {
|
||||||
if err := cli.PullBackup(m.ID, uuid, filepath.Join(path, "data.tar.gz")); err != nil {
|
if err := p.Service.PullBackup(m.ID, uuid, cli); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -290,10 +273,8 @@ func pullBackup(m repository.Metadata, cli *client.Client) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func pull(gameID string, cli *client.Client) error {
|
func (p *SyncCmd) pull(gameID string, cli *client.Client) error {
|
||||||
archivePath := filepath.Join(repository.DatastorePath(), gameID, "data.tar.gz")
|
return p.Service.PullArchive(gameID, "", cli)
|
||||||
|
|
||||||
return cli.Pull(gameID, archivePath)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func connect(remoteCred map[string]map[string]string, r remote.Remote) (*client.Client, error) {
|
func connect(remoteCred map[string]map[string]string, r remote.Remote) (*client.Client, error) {
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
package version
|
package version
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"cloudsave/cmd/cli/tools/prompt/credentials"
|
||||||
"cloudsave/pkg/constants"
|
"cloudsave/pkg/constants"
|
||||||
"cloudsave/pkg/remote/client"
|
"cloudsave/pkg/remote/client"
|
||||||
"cloudsave/cmd/cli/tools/prompt/credentials"
|
|
||||||
"context"
|
"context"
|
||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -23,8 +23,11 @@ type (
|
|||||||
func (*VersionCmd) Name() string { return "version" }
|
func (*VersionCmd) Name() string { return "version" }
|
||||||
func (*VersionCmd) Synopsis() string { return "show version and system information" }
|
func (*VersionCmd) Synopsis() string { return "show version and system information" }
|
||||||
func (*VersionCmd) Usage() string {
|
func (*VersionCmd) Usage() string {
|
||||||
return `add:
|
return `Usage: cloudsave version [-a]
|
||||||
Show version and system information
|
|
||||||
|
Print the version of the software
|
||||||
|
|
||||||
|
Options:
|
||||||
`
|
`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,34 +2,60 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"cloudsave/cmd/cli/commands/add"
|
"cloudsave/cmd/cli/commands/add"
|
||||||
|
"cloudsave/cmd/cli/commands/apply"
|
||||||
"cloudsave/cmd/cli/commands/list"
|
"cloudsave/cmd/cli/commands/list"
|
||||||
"cloudsave/cmd/cli/commands/pull"
|
"cloudsave/cmd/cli/commands/pull"
|
||||||
"cloudsave/cmd/cli/commands/remote"
|
"cloudsave/cmd/cli/commands/remote"
|
||||||
"cloudsave/cmd/cli/commands/remove"
|
"cloudsave/cmd/cli/commands/remove"
|
||||||
"cloudsave/cmd/cli/commands/run"
|
"cloudsave/cmd/cli/commands/run"
|
||||||
|
"cloudsave/cmd/cli/commands/show"
|
||||||
"cloudsave/cmd/cli/commands/sync"
|
"cloudsave/cmd/cli/commands/sync"
|
||||||
"cloudsave/cmd/cli/commands/version"
|
"cloudsave/cmd/cli/commands/version"
|
||||||
|
"cloudsave/pkg/data"
|
||||||
|
"cloudsave/pkg/repository"
|
||||||
"context"
|
"context"
|
||||||
"flag"
|
"flag"
|
||||||
"os"
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
"github.com/google/subcommands"
|
"github.com/google/subcommands"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
|
roaming, err := os.UserConfigDir()
|
||||||
|
if err != nil {
|
||||||
|
panic("failed to get user config path: " + err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
datastorepath := filepath.Join(roaming, "cloudsave", "data")
|
||||||
|
err = os.MkdirAll(datastorepath, 0740)
|
||||||
|
if err != nil {
|
||||||
|
panic("cannot make the datastore:" + err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
repo, err := repository.NewLazyRepository(datastorepath)
|
||||||
|
if err != nil {
|
||||||
|
panic("cannot make the datastore:" + err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
s := data.NewService(repo)
|
||||||
|
|
||||||
subcommands.Register(subcommands.HelpCommand(), "help")
|
subcommands.Register(subcommands.HelpCommand(), "help")
|
||||||
subcommands.Register(subcommands.FlagsCommand(), "help")
|
subcommands.Register(subcommands.FlagsCommand(), "help")
|
||||||
subcommands.Register(subcommands.CommandsCommand(), "help")
|
subcommands.Register(subcommands.CommandsCommand(), "help")
|
||||||
subcommands.Register(&version.VersionCmd{}, "help")
|
subcommands.Register(&version.VersionCmd{}, "help")
|
||||||
|
|
||||||
subcommands.Register(&add.AddCmd{}, "management")
|
subcommands.Register(&add.AddCmd{Service: s}, "management")
|
||||||
subcommands.Register(&run.RunCmd{}, "management")
|
subcommands.Register(&run.RunCmd{Service: s}, "management")
|
||||||
subcommands.Register(&list.ListCmd{}, "management")
|
subcommands.Register(&list.ListCmd{Service: s}, "management")
|
||||||
subcommands.Register(&remove.RemoveCmd{}, "management")
|
subcommands.Register(&remove.RemoveCmd{Service: s}, "management")
|
||||||
|
subcommands.Register(&show.ShowCmd{Service: s}, "management")
|
||||||
|
|
||||||
subcommands.Register(&remote.RemoteCmd{}, "remote")
|
subcommands.Register(&apply.ApplyCmd{Service: s}, "restore")
|
||||||
subcommands.Register(&sync.SyncCmd{}, "remote")
|
|
||||||
subcommands.Register(&pull.PullCmd{}, "remote")
|
subcommands.Register(&remote.RemoteCmd{Service: s}, "remote")
|
||||||
|
subcommands.Register(&sync.SyncCmd{Service: s}, "remote")
|
||||||
|
subcommands.Register(&pull.PullCmd{Service: s}, "remote")
|
||||||
|
|
||||||
flag.Parse()
|
flag.Parse()
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|||||||
@@ -1,14 +1,11 @@
|
|||||||
package api
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"cloudsave/cmd/server/data"
|
"cloudsave/pkg/data"
|
||||||
"cloudsave/pkg/repository"
|
"cloudsave/pkg/repository"
|
||||||
"crypto/md5"
|
|
||||||
"encoding/hex"
|
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
@@ -23,16 +20,18 @@ import (
|
|||||||
type (
|
type (
|
||||||
HTTPServer struct {
|
HTTPServer struct {
|
||||||
Server *http.Server
|
Server *http.Server
|
||||||
|
Service *data.Service
|
||||||
documentRoot string
|
documentRoot string
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
// NewServer start the http server
|
// NewServer start the http server
|
||||||
func NewServer(documentRoot string, creds map[string]string, port int) *HTTPServer {
|
func NewServer(documentRoot string, srv *data.Service, creds map[string]string, port int) *HTTPServer {
|
||||||
if !filepath.IsAbs(documentRoot) {
|
if !filepath.IsAbs(documentRoot) {
|
||||||
panic("the document root is not an absolute path")
|
panic("the document root is not an absolute path")
|
||||||
}
|
}
|
||||||
s := &HTTPServer{
|
s := &HTTPServer{
|
||||||
|
Service: srv,
|
||||||
documentRoot: documentRoot,
|
documentRoot: documentRoot,
|
||||||
}
|
}
|
||||||
router := chi.NewRouter()
|
router := chi.NewRouter()
|
||||||
@@ -198,14 +197,14 @@ func (s HTTPServer) upload(w http.ResponseWriter, r *http.Request) {
|
|||||||
defer file.Close()
|
defer file.Close()
|
||||||
|
|
||||||
//TODO make a transaction
|
//TODO make a transaction
|
||||||
if err := data.UpdateMetadata(id, s.documentRoot, m); err != nil {
|
if err := s.Service.UpdateMetadata(id, m); err != nil {
|
||||||
fmt.Fprintln(os.Stderr, "error: failed to write metadata to disk:", err)
|
fmt.Fprintln(os.Stderr, "error: failed to write metadata to disk:", err)
|
||||||
internalServerError(w, r)
|
internalServerError(w, r)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := data.Write(id, s.documentRoot, file); err != nil {
|
if err := s.Service.Copy(id, file); err != nil {
|
||||||
fmt.Fprintln(os.Stderr, "error: failed to write file to disk:", err)
|
fmt.Fprintln(os.Stderr, "error: failed to write data to disk:", err)
|
||||||
internalServerError(w, r)
|
internalServerError(w, r)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -216,20 +215,9 @@ func (s HTTPServer) upload(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
func (s HTTPServer) allHist(w http.ResponseWriter, r *http.Request) {
|
func (s HTTPServer) allHist(w http.ResponseWriter, r *http.Request) {
|
||||||
gameID := chi.URLParam(r, "id")
|
gameID := chi.URLParam(r, "id")
|
||||||
path := filepath.Join(s.documentRoot, "data", gameID, "hist")
|
|
||||||
datastore := make([]string, 0)
|
datastore := make([]string, 0)
|
||||||
|
|
||||||
if _, err := os.Stat(path); err != nil {
|
ds, err := s.Service.AllBackups(gameID)
|
||||||
if errors.Is(err, os.ErrNotExist) {
|
|
||||||
ok(datastore, w, r)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
fmt.Fprintln(os.Stderr, "failed to open datastore (", s.documentRoot, "):", err)
|
|
||||||
internalServerError(w, r)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
ds, err := os.ReadDir(path)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Fprintln(os.Stderr, "failed to open datastore (", s.documentRoot, "):", err)
|
fmt.Fprintln(os.Stderr, "failed to open datastore (", s.documentRoot, "):", err)
|
||||||
internalServerError(w, r)
|
internalServerError(w, r)
|
||||||
@@ -237,7 +225,7 @@ func (s HTTPServer) allHist(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for _, d := range ds {
|
for _, d := range ds {
|
||||||
datastore = append(datastore, d.Name())
|
datastore = append(datastore, d.UUID)
|
||||||
}
|
}
|
||||||
|
|
||||||
ok(datastore, w, r)
|
ok(datastore, w, r)
|
||||||
@@ -271,8 +259,8 @@ func (s HTTPServer) histUpload(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
defer file.Close()
|
defer file.Close()
|
||||||
|
|
||||||
if err := data.WriteHist(gameID, s.documentRoot, uuid, file); err != nil {
|
if err := s.Service.CopyBackup(gameID, uuid, file); err != nil {
|
||||||
fmt.Fprintln(os.Stderr, "error: failed to write file to disk:", err)
|
fmt.Fprintln(os.Stderr, "error: failed to write data to disk:", err)
|
||||||
internalServerError(w, r)
|
internalServerError(w, r)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -327,10 +315,10 @@ func (s HTTPServer) histExists(w http.ResponseWriter, r *http.Request) {
|
|||||||
gameID := chi.URLParam(r, "id")
|
gameID := chi.URLParam(r, "id")
|
||||||
uuid := chi.URLParam(r, "uuid")
|
uuid := chi.URLParam(r, "uuid")
|
||||||
|
|
||||||
finfo, err := data.ArchiveInfo(gameID, s.documentRoot, uuid)
|
finfo, err := s.Service.Backup(gameID, uuid)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, data.ErrBackupNotExists) {
|
if errors.Is(err, repository.ErrNotFound) {
|
||||||
notFound("backup not found", w, r)
|
notFound("not found", w, r)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
fmt.Fprintln(os.Stderr, "error: failed to read data:", err)
|
fmt.Fprintln(os.Stderr, "error: failed to read data:", err)
|
||||||
@@ -343,41 +331,19 @@ func (s HTTPServer) histExists(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
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))
|
|
||||||
|
|
||||||
sdir, err := os.Stat(path)
|
m, err := s.Service.One(id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
notFound("id not found", w, r)
|
if errors.Is(err, repository.ErrNotFound) {
|
||||||
|
notFound("not found", w, r)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
fmt.Fprintln(os.Stderr, "error: an error occured while calculating the hash:", err)
|
||||||
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()
|
|
||||||
|
|
||||||
// Create MD5 hasher
|
|
||||||
hasher := md5.New()
|
|
||||||
|
|
||||||
// Copy file content into hasher
|
|
||||||
if _, err := io.Copy(hasher, f); err != nil {
|
|
||||||
fmt.Fprintln(os.Stderr, "error: an error occured while reading data:", err)
|
|
||||||
internalServerError(w, r)
|
internalServerError(w, r)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get checksum result
|
ok(m.MD5, w, r)
|
||||||
sum := hasher.Sum(nil)
|
|
||||||
ok(hex.EncodeToString(sum), w, r)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s HTTPServer) metadata(w http.ResponseWriter, r *http.Request) {
|
func (s HTTPServer) metadata(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|||||||
@@ -1,131 +0,0 @@
|
|||||||
package data
|
|
||||||
|
|
||||||
import (
|
|
||||||
"cloudsave/pkg/repository"
|
|
||||||
"cloudsave/pkg/tools/hash"
|
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
)
|
|
||||||
|
|
||||||
var (
|
|
||||||
ErrBackupNotExists error = errors.New("backup not found")
|
|
||||||
)
|
|
||||||
|
|
||||||
func Write(gameID, documentRoot string, r io.Reader) error {
|
|
||||||
dataFolderPath := filepath.Join(documentRoot, "data", gameID)
|
|
||||||
partPath := filepath.Join(dataFolderPath, "data.tar.gz.part")
|
|
||||||
finalFilePath := filepath.Join(dataFolderPath, "data.tar.gz")
|
|
||||||
|
|
||||||
if err := makeDataFolder(gameID, documentRoot); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
f, err := os.OpenFile(partPath, os.O_CREATE|os.O_WRONLY, 0740)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, err := io.Copy(f, r); err != nil {
|
|
||||||
f.Close()
|
|
||||||
if err := os.Remove(partPath); err != nil {
|
|
||||||
return fmt.Errorf("failed to write the file and cannot clean the folder: %w", err)
|
|
||||||
}
|
|
||||||
return fmt.Errorf("failed to write the file: %w", err)
|
|
||||||
}
|
|
||||||
f.Close()
|
|
||||||
|
|
||||||
if err := os.Rename(partPath, finalFilePath); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func 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
|
|
||||||
}
|
|
||||||
path := filepath.Join(documentRoot, "data", gameID, "metadata.json")
|
|
||||||
|
|
||||||
f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY, 0740)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer f.Close()
|
|
||||||
|
|
||||||
e := json.NewEncoder(f)
|
|
||||||
return e.Encode(m)
|
|
||||||
}
|
|
||||||
|
|
||||||
func ArchiveInfo(gameID, documentRoot, uuid string) (repository.Backup, error) {
|
|
||||||
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
|
|
||||||
}
|
|
||||||
@@ -4,6 +4,8 @@ import (
|
|||||||
"cloudsave/cmd/server/api"
|
"cloudsave/cmd/server/api"
|
||||||
"cloudsave/cmd/server/security/htpasswd"
|
"cloudsave/cmd/server/security/htpasswd"
|
||||||
"cloudsave/pkg/constants"
|
"cloudsave/pkg/constants"
|
||||||
|
"cloudsave/pkg/data"
|
||||||
|
"cloudsave/pkg/repository"
|
||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@@ -16,16 +18,36 @@ func run() {
|
|||||||
|
|
||||||
var documentRoot string
|
var documentRoot string
|
||||||
var port int
|
var port int
|
||||||
|
var noCache bool
|
||||||
flag.StringVar(&documentRoot, "document-root", defaultDocumentRoot, "Define the path to the document root")
|
flag.StringVar(&documentRoot, "document-root", defaultDocumentRoot, "Define the path to the document root")
|
||||||
flag.IntVar(&port, "port", 8080, "Define the port of the server")
|
flag.IntVar(&port, "port", 8080, "Define the port of the server")
|
||||||
|
flag.BoolVar(&noCache, "no-cache", false, "Disable the cache")
|
||||||
flag.Parse()
|
flag.Parse()
|
||||||
|
|
||||||
h, err := htpasswd.Open(filepath.Join(documentRoot, ".htpasswd"))
|
h, err := htpasswd.Open(filepath.Join(documentRoot, ".htpasswd"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fatal("failed to load .htpasswd: "+err.Error(), 1)
|
fatal("failed to load .htpasswd: "+err.Error(), 1)
|
||||||
}
|
}
|
||||||
|
var repo repository.Repository
|
||||||
|
if noCache {
|
||||||
|
r, err := repository.NewEagerRepository(filepath.Join(documentRoot, "data"))
|
||||||
|
if err != nil {
|
||||||
|
fatal("failed to load datastore: "+err.Error(), 1)
|
||||||
|
}
|
||||||
|
if err := r.Preload(); err != nil {
|
||||||
|
fatal("failed to load datastore: "+err.Error(), 1)
|
||||||
|
}
|
||||||
|
repo = r
|
||||||
|
} else {
|
||||||
|
repo, err = repository.NewLazyRepository(filepath.Join(documentRoot, "data"))
|
||||||
|
if err != nil {
|
||||||
|
fatal("failed to load datastore: "+err.Error(), 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
server := api.NewServer(documentRoot, h.Content(), port)
|
s := data.NewService(repo)
|
||||||
|
|
||||||
|
server := api.NewServer(documentRoot, s, h.Content(), port)
|
||||||
|
|
||||||
fmt.Println("starting server at :" + strconv.Itoa(port))
|
fmt.Println("starting server at :" + strconv.Itoa(port))
|
||||||
if err := server.Server.ListenAndServe(); err != nil {
|
if err := server.Server.ListenAndServe(); err != 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
270
cmd/web/server/server.go
Normal file
270
cmd/web/server/server.go
Normal file
@@ -0,0 +1,270 @@
|
|||||||
|
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"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"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
|
||||||
|
}
|
||||||
|
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
var err1, err2, err3 error
|
||||||
|
var save repository.Metadata
|
||||||
|
var h string
|
||||||
|
var ids []string
|
||||||
|
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
save, err1 = cli.Metadata(id)
|
||||||
|
wg.Done()
|
||||||
|
}()
|
||||||
|
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
h, err2 = cli.Hash(id)
|
||||||
|
wg.Done()
|
||||||
|
}()
|
||||||
|
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
ids, err3 = cli.ListArchives(id)
|
||||||
|
wg.Done()
|
||||||
|
}()
|
||||||
|
|
||||||
|
wg.Wait()
|
||||||
|
|
||||||
|
if err1 != nil || err2 != nil || err3 != nil {
|
||||||
|
if errors.Is(err1, client.ErrUnauthorized) {
|
||||||
|
unauthorized("Unable to access resources", w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
slog.Error("unable to connect to the remote", "err", err1)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
wg = sync.WaitGroup{}
|
||||||
|
|
||||||
|
var bm []repository.Backup
|
||||||
|
for _, i := range ids {
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
b, err := cli.ArchiveInfo(id, i)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("unable to connect to the remote", "err", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
bm = append(bm, b)
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
wg.Wait()
|
||||||
|
|
||||||
|
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,5 +1,5 @@
|
|||||||
package constants
|
package constants
|
||||||
|
|
||||||
const Version = "0.0.2"
|
const Version = "0.0.4"
|
||||||
|
|
||||||
const ApiVersion = 1
|
const ApiVersion = 1
|
||||||
|
|||||||
385
pkg/data/data.go
Normal file
385
pkg/data/data.go
Normal file
@@ -0,0 +1,385 @@
|
|||||||
|
package data
|
||||||
|
|
||||||
|
import (
|
||||||
|
"cloudsave/pkg/remote/client"
|
||||||
|
"cloudsave/pkg/repository"
|
||||||
|
"cloudsave/pkg/tools/archive"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
type (
|
||||||
|
Service struct {
|
||||||
|
repo repository.Repository
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
func NewService(repo repository.Repository) *Service {
|
||||||
|
return &Service{
|
||||||
|
repo: repo,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) Add(name, path, remote string) (string, error) {
|
||||||
|
gameID := repository.NewGameIdentifier(uuid.NewString())
|
||||||
|
|
||||||
|
if err := s.repo.Mkdir(gameID); err != nil {
|
||||||
|
return "", fmt.Errorf("failed to add game reference: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
m := repository.Metadata{
|
||||||
|
ID: gameID.Key(),
|
||||||
|
Name: name,
|
||||||
|
Path: path,
|
||||||
|
Version: 1,
|
||||||
|
Date: time.Now(),
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.repo.WriteMetadata(gameID, m); err != nil {
|
||||||
|
return "", fmt.Errorf("failed to add game reference: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return gameID.Key(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) One(gameID string) (repository.Metadata, error) {
|
||||||
|
id := repository.NewGameIdentifier(gameID)
|
||||||
|
|
||||||
|
m, err := s.repo.Metadata(id)
|
||||||
|
if err != nil {
|
||||||
|
return repository.Metadata{}, fmt.Errorf("failed to get metadata: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) Backup(gameID, backupID string) (repository.Backup, error) {
|
||||||
|
id := repository.NewBackupIdentifier(gameID, backupID)
|
||||||
|
|
||||||
|
if err := s.repo.Mkdir(id); err != nil {
|
||||||
|
return repository.Backup{}, fmt.Errorf("failed to make game dir: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return s.repo.Backup(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) UpdateMetadata(gameID string, m repository.Metadata) error {
|
||||||
|
id := repository.NewGameIdentifier(gameID)
|
||||||
|
|
||||||
|
if err := s.repo.Mkdir(id); err != nil {
|
||||||
|
return fmt.Errorf("failed to make game dir: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.repo.WriteMetadata(id, m); err != nil {
|
||||||
|
return fmt.Errorf("failed to write metadate: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) Scan(gameID string) error {
|
||||||
|
id := repository.NewGameIdentifier(gameID)
|
||||||
|
|
||||||
|
lastRun, err := s.repo.LastScan(id)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to get last scan time: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
m, err := s.repo.Metadata(id)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to get game metadata: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !IsDirectoryChanged(m.Path, lastRun) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
f, err := s.repo.WriteBlob(id)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to get datastore stream: %w", err)
|
||||||
|
}
|
||||||
|
if v, ok := f.(io.Closer); ok {
|
||||||
|
defer v.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := archive.Tar(f, m.Path); err != nil {
|
||||||
|
return fmt.Errorf("failed to make archive: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.repo.ResetLastScan(id); err != nil {
|
||||||
|
return fmt.Errorf("failed to reset scan date: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
m.Date = time.Now()
|
||||||
|
m.Version += 1
|
||||||
|
|
||||||
|
if err := s.repo.WriteMetadata(id, m); err != nil {
|
||||||
|
return fmt.Errorf("failed to update metadata: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) MakeBackup(gameID string) error {
|
||||||
|
var id repository.Identifier = repository.NewGameIdentifier(gameID)
|
||||||
|
|
||||||
|
src, err := s.repo.ReadBlob(id)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if v, ok := src.(io.Closer); ok {
|
||||||
|
defer v.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
id = repository.NewBackupIdentifier(gameID, uuid.NewString())
|
||||||
|
|
||||||
|
if err := s.repo.Mkdir(id); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
dst, err := s.repo.WriteBlob(id)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if v, ok := dst.(io.Closer); ok {
|
||||||
|
defer v.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := io.Copy(dst, src); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) AllGames() ([]repository.Metadata, error) {
|
||||||
|
ids, err := s.repo.All()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get the list of ids: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var ms []repository.Metadata
|
||||||
|
for _, id := range ids {
|
||||||
|
m, err := s.repo.Metadata(repository.NewGameIdentifier(id))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to open metadata: %w", err)
|
||||||
|
}
|
||||||
|
ms = append(ms, m)
|
||||||
|
}
|
||||||
|
|
||||||
|
return ms, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) AllBackups(gameID string) ([]repository.Backup, error) {
|
||||||
|
ids, err := s.repo.AllHist(repository.NewGameIdentifier(gameID))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get the list of ids: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var bs []repository.Backup
|
||||||
|
for _, id := range ids {
|
||||||
|
b, err := s.repo.Backup(repository.NewBackupIdentifier(gameID, id))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to open metadata: %w", err)
|
||||||
|
}
|
||||||
|
bs = append(bs, b)
|
||||||
|
}
|
||||||
|
|
||||||
|
return bs, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l Service) PullArchive(gameID, backupID string, cli *client.Client) error {
|
||||||
|
if len(backupID) > 0 {
|
||||||
|
path := l.repo.DataPath(repository.NewBackupIdentifier(gameID, backupID))
|
||||||
|
return cli.PullBackup(gameID, backupID, filepath.Join(path, "data.tar.gz"))
|
||||||
|
}
|
||||||
|
|
||||||
|
path := l.repo.DataPath(repository.NewGameIdentifier(gameID))
|
||||||
|
return cli.Pull(gameID, filepath.Join(path, "data.tar.gz"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l Service) PushArchive(gameID, backupID string, cli *client.Client) error {
|
||||||
|
m, err := l.repo.Metadata(repository.NewGameIdentifier(gameID))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(backupID) > 0 {
|
||||||
|
path := l.repo.DataPath(repository.NewBackupIdentifier(gameID, backupID))
|
||||||
|
return cli.PushSave(filepath.Join(path, "data.taz.gz"), m)
|
||||||
|
}
|
||||||
|
|
||||||
|
path := l.repo.DataPath(repository.NewGameIdentifier(gameID))
|
||||||
|
return cli.PushSave(filepath.Join(path, "data.tar.gz"), m)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l Service) PullCurrent(id, path string, cli *client.Client) error {
|
||||||
|
gameID := repository.NewGameIdentifier(id)
|
||||||
|
if err := l.repo.Mkdir(gameID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
m, err := cli.Metadata(id)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to get metadata from the server: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := l.repo.WriteMetadata(gameID, m); err != nil {
|
||||||
|
return fmt.Errorf("failed to write metadata: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
archivePath := filepath.Join(l.repo.DataPath(gameID), "data.tar.gz")
|
||||||
|
|
||||||
|
if err := cli.Pull(id, archivePath); err != nil {
|
||||||
|
return fmt.Errorf("failed to pull from the server: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
f, err := l.repo.ReadBlob(gameID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to open blob from local repository: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.MkdirAll(path, 0740); err != nil {
|
||||||
|
return fmt.Errorf("failed to create destination directory: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := archive.Untar(f, path); err != nil {
|
||||||
|
return fmt.Errorf("failed to untar archive: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := l.repo.ResetLastScan(gameID); err != nil {
|
||||||
|
return fmt.Errorf("failed to create .last_run file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l Service) PullBackup(gameID, backupID string, cli *client.Client) error {
|
||||||
|
id := repository.NewBackupIdentifier(gameID, backupID)
|
||||||
|
|
||||||
|
archivePath := filepath.Join(l.repo.DataPath(id), "data.tar.gz")
|
||||||
|
|
||||||
|
if err := cli.PullBackup(gameID, backupID, archivePath); err != nil {
|
||||||
|
return fmt.Errorf("failed to pull backup: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l Service) RemoveGame(gameID string) error {
|
||||||
|
return l.repo.Remove(repository.NewGameIdentifier(gameID))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l Service) SetVersion(gameID string, value int) error {
|
||||||
|
id := repository.NewGameIdentifier(gameID)
|
||||||
|
|
||||||
|
m, err := l.repo.Metadata(id)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to get metadata from the server: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
m.Version = value
|
||||||
|
|
||||||
|
if err := l.repo.WriteMetadata(id, m); err != nil {
|
||||||
|
return fmt.Errorf("failed to write metadata: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func IsDirectoryChanged(path string, lastRun time.Time) bool {
|
||||||
|
changed := false
|
||||||
|
_ = filepath.Walk(path, func(path string, info os.FileInfo, walkErr error) error {
|
||||||
|
if walkErr != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if info.ModTime().After(lastRun) {
|
||||||
|
changed = true
|
||||||
|
return io.EOF // early exit
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
return changed
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l Service) Copy(id string, src io.Reader) error {
|
||||||
|
dst, err := l.repo.WriteBlob(repository.NewGameIdentifier(id))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if v, ok := dst.(io.Closer); ok {
|
||||||
|
defer v.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := io.Copy(dst, src); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l Service) CopyBackup(gameID, backupID string, src io.Reader) error {
|
||||||
|
id := repository.NewBackupIdentifier(gameID, backupID)
|
||||||
|
|
||||||
|
if err := l.repo.Mkdir(id); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
dst, err := l.repo.WriteBlob(id)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if v, ok := dst.(io.Closer); ok {
|
||||||
|
defer v.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := io.Copy(dst, src); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l Service) ApplyCurrent(gameID string) error {
|
||||||
|
id := repository.NewGameIdentifier(gameID)
|
||||||
|
path := l.repo.DataPath(id)
|
||||||
|
|
||||||
|
g, err := l.repo.Metadata(id)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return l.apply(filepath.Join(path, "data.tar.gz"), g.Path)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l Service) ApplyBackup(gameID, backupID string) error {
|
||||||
|
id := repository.NewGameIdentifier(gameID)
|
||||||
|
fullID := repository.NewBackupIdentifier(gameID, backupID)
|
||||||
|
path := l.repo.DataPath(fullID)
|
||||||
|
|
||||||
|
g, err := l.repo.Metadata(id)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return l.apply(filepath.Join(path, "data.tar.gz"), g.Path)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l Service) apply(src, dst string) error {
|
||||||
|
if err := os.RemoveAll(dst); err != nil {
|
||||||
|
return fmt.Errorf("failed to remove old save: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
f, err := os.OpenFile(src, os.O_RDONLY, 0)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to open archive: %w", err)
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
|
return archive.Untar(f, dst)
|
||||||
|
}
|
||||||
@@ -37,6 +37,7 @@ type (
|
|||||||
|
|
||||||
var (
|
var (
|
||||||
ErrNotFound error = errors.New("not found")
|
ErrNotFound error = errors.New("not found")
|
||||||
|
ErrUnauthorized error = errors.New("unauthorized (HTTP Error 401)")
|
||||||
)
|
)
|
||||||
|
|
||||||
func New(baseURL, username, password string) *Client {
|
func New(baseURL, username, password string) *Client {
|
||||||
@@ -275,15 +276,16 @@ func (c *Client) PullBackup(gameID, uuid, archivePath string) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to open file: %w", err)
|
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 {
|
||||||
|
f.Close()
|
||||||
return fmt.Errorf("cannot connect to remote: %w", err)
|
return fmt.Errorf("cannot connect to remote: %w", err)
|
||||||
}
|
}
|
||||||
defer res.Body.Close()
|
defer res.Body.Close()
|
||||||
|
|
||||||
if res.StatusCode != http.StatusOK {
|
if res.StatusCode != http.StatusOK {
|
||||||
|
f.Close()
|
||||||
return fmt.Errorf("cannot connect to remote: server return code: %s", res.Status)
|
return fmt.Errorf("cannot connect to remote: server return code: %s", res.Status)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -294,8 +296,10 @@ func (c *Client) PullBackup(gameID, uuid, archivePath string) error {
|
|||||||
defer bar.Close()
|
defer bar.Close()
|
||||||
|
|
||||||
if _, err := io.Copy(io.MultiWriter(f, bar), res.Body); err != nil {
|
if _, err := io.Copy(io.MultiWriter(f, bar), res.Body); err != nil {
|
||||||
|
f.Close()
|
||||||
return fmt.Errorf("an error occured while copying the file from the remote: %w", err)
|
return fmt.Errorf("an error occured while copying the file from the remote: %w", err)
|
||||||
}
|
}
|
||||||
|
f.Close()
|
||||||
|
|
||||||
if err := os.Rename(archivePath+".part", archivePath); err != nil {
|
if err := os.Rename(archivePath+".part", archivePath); err != nil {
|
||||||
return fmt.Errorf("failed to move temporary data: %w", err)
|
return fmt.Errorf("failed to move temporary data: %w", err)
|
||||||
@@ -382,6 +386,10 @@ func (c *Client) get(url string) (obj.HTTPObject, error) {
|
|||||||
return obj.HTTPObject{}, ErrNotFound
|
return obj.HTTPObject{}, ErrNotFound
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if res.StatusCode == 401 {
|
||||||
|
return obj.HTTPObject{}, ErrUnauthorized
|
||||||
|
}
|
||||||
|
|
||||||
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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ package repository
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"cloudsave/pkg/tools/hash"
|
"cloudsave/pkg/tools/hash"
|
||||||
"cloudsave/pkg/tools/id"
|
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -10,8 +9,6 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type (
|
type (
|
||||||
@@ -21,6 +18,12 @@ type (
|
|||||||
Path string `json:"path"`
|
Path string `json:"path"`
|
||||||
Version int `json:"version"`
|
Version int `json:"version"`
|
||||||
Date time.Time `json:"date"`
|
Date time.Time `json:"date"`
|
||||||
|
MD5 string `json:"-"`
|
||||||
|
}
|
||||||
|
|
||||||
|
Remote struct {
|
||||||
|
URL string `json:"url"`
|
||||||
|
GameID string `json:"-"`
|
||||||
}
|
}
|
||||||
|
|
||||||
Backup struct {
|
Backup struct {
|
||||||
@@ -29,354 +32,453 @@ type (
|
|||||||
UUID string `json:"uuid"`
|
UUID string `json:"uuid"`
|
||||||
ArchivePath string `json:"-"`
|
ArchivePath string `json:"-"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Data struct {
|
||||||
|
Metadata Metadata
|
||||||
|
Remote *Remote
|
||||||
|
DataPath string
|
||||||
|
Backup map[string]Backup
|
||||||
|
}
|
||||||
|
|
||||||
|
GameIdentifier struct {
|
||||||
|
gameID string
|
||||||
|
}
|
||||||
|
|
||||||
|
BackupIdentifier struct {
|
||||||
|
gameID string
|
||||||
|
backupID string
|
||||||
|
}
|
||||||
|
|
||||||
|
Identifier interface {
|
||||||
|
Key() string
|
||||||
|
}
|
||||||
|
|
||||||
|
LazyRepository struct {
|
||||||
|
dataRoot string
|
||||||
|
}
|
||||||
|
|
||||||
|
EagerRepository struct {
|
||||||
|
Repository
|
||||||
|
|
||||||
|
data map[string]Data
|
||||||
|
}
|
||||||
|
|
||||||
|
Repository interface {
|
||||||
|
Mkdir(id Identifier) error
|
||||||
|
|
||||||
|
All() ([]string, error)
|
||||||
|
AllHist(gameID GameIdentifier) ([]string, error)
|
||||||
|
|
||||||
|
WriteBlob(ID Identifier) (io.Writer, error)
|
||||||
|
WriteMetadata(gameID GameIdentifier, m Metadata) error
|
||||||
|
|
||||||
|
Metadata(gameID GameIdentifier) (Metadata, error)
|
||||||
|
LastScan(gameID GameIdentifier) (time.Time, error)
|
||||||
|
ReadBlob(gameID Identifier) (io.Reader, error)
|
||||||
|
Backup(id BackupIdentifier) (Backup, error)
|
||||||
|
Remote(id GameIdentifier) (*Remote, error)
|
||||||
|
|
||||||
|
SetRemote(gameID GameIdentifier, url string) error
|
||||||
|
ResetLastScan(id GameIdentifier) error
|
||||||
|
|
||||||
|
DataPath(id Identifier) string
|
||||||
|
|
||||||
|
Remove(gameID GameIdentifier) error
|
||||||
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
roaming string
|
ErrNotFound error = errors.New("not found")
|
||||||
datastorepath string
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func init() {
|
func NewGameIdentifier(gameID string) GameIdentifier {
|
||||||
var err error
|
return GameIdentifier{
|
||||||
roaming, err = os.UserConfigDir()
|
gameID: gameID,
|
||||||
if err != nil {
|
}
|
||||||
panic("failed to get user config path: " + err.Error())
|
}
|
||||||
|
func (bi GameIdentifier) Key() string {
|
||||||
|
return bi.gameID
|
||||||
}
|
}
|
||||||
|
|
||||||
datastorepath = filepath.Join(roaming, "cloudsave", "data")
|
func NewBackupIdentifier(gameID, backupID string) BackupIdentifier {
|
||||||
err = os.MkdirAll(datastorepath, 0740)
|
return BackupIdentifier{
|
||||||
if err != nil {
|
gameID: gameID,
|
||||||
panic("cannot make the datastore:" + err.Error())
|
backupID: backupID,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func Add(name, path string) (Metadata, error) {
|
func (bi BackupIdentifier) Key() string {
|
||||||
m := Metadata{
|
return bi.gameID + ":" + bi.backupID
|
||||||
ID: id.New(),
|
|
||||||
Name: name,
|
|
||||||
Path: path,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
err := os.MkdirAll(filepath.Join(datastorepath, m.ID), 0740)
|
func NewLazyRepository(dataRootPath string) (*LazyRepository, error) {
|
||||||
if err != nil {
|
if m, err := os.Stat(dataRootPath); err != nil {
|
||||||
panic("cannot make directory for the game:" + err.Error())
|
if errors.Is(err, os.ErrNotExist) {
|
||||||
|
if err := os.MkdirAll(dataRootPath, 0740); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to make the directory: %w", err)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return nil, fmt.Errorf("failed to open datastore: %w", err)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if !m.IsDir() {
|
||||||
|
return nil, fmt.Errorf("failed to open datastore: not a directory")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
f, err := os.OpenFile(filepath.Join(datastorepath, m.ID, "metadata.json"), os.O_CREATE|os.O_WRONLY, 0740)
|
return &LazyRepository{
|
||||||
if err != nil {
|
dataRoot: dataRootPath,
|
||||||
return Metadata{}, fmt.Errorf("cannot open the metadata file in the datastore: %w", err)
|
}, nil
|
||||||
}
|
}
|
||||||
defer f.Close()
|
|
||||||
|
|
||||||
e := json.NewEncoder(f)
|
func (l *LazyRepository) Mkdir(id Identifier) error {
|
||||||
err = e.Encode(m)
|
return os.MkdirAll(l.DataPath(id), 0740)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *LazyRepository) All() ([]string, error) {
|
||||||
|
dir, err := os.ReadDir(l.dataRoot)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return Metadata{}, fmt.Errorf("cannot write into the metadata file in the datastore: %w", err)
|
return nil, fmt.Errorf("failed to open directory: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var res []string
|
||||||
|
for _, d := range dir {
|
||||||
|
res = append(res, d.Name())
|
||||||
|
}
|
||||||
|
|
||||||
|
return res, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *LazyRepository) AllHist(id GameIdentifier) ([]string, error) {
|
||||||
|
path := l.DataPath(id)
|
||||||
|
|
||||||
|
dir, err := os.ReadDir(filepath.Join(path, "hist"))
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, os.ErrNotExist) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("failed to open directory: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var res []string
|
||||||
|
for _, d := range dir {
|
||||||
|
res = append(res, d.Name())
|
||||||
|
}
|
||||||
|
|
||||||
|
return res, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *LazyRepository) WriteBlob(ID Identifier) (io.Writer, error) {
|
||||||
|
path := l.DataPath(ID)
|
||||||
|
|
||||||
|
dst, err := os.OpenFile(filepath.Join(path, "data.tar.gz"), os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0740)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to open destination file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return dst, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *LazyRepository) WriteMetadata(id GameIdentifier, m Metadata) error {
|
||||||
|
path := l.DataPath(id)
|
||||||
|
|
||||||
|
dst, err := os.OpenFile(filepath.Join(path, "metadata.json"), os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0740)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to open destination file: %w", err)
|
||||||
|
}
|
||||||
|
defer dst.Close()
|
||||||
|
|
||||||
|
e := json.NewEncoder(dst)
|
||||||
|
if err := e.Encode(m); err != nil {
|
||||||
|
return fmt.Errorf("failed to encode data: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *LazyRepository) Metadata(id GameIdentifier) (Metadata, error) {
|
||||||
|
path := l.DataPath(id)
|
||||||
|
|
||||||
|
src, err := os.OpenFile(filepath.Join(path, "metadata.json"), os.O_RDONLY, 0)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, os.ErrNotExist) {
|
||||||
|
return Metadata{}, ErrNotFound
|
||||||
|
}
|
||||||
|
return Metadata{}, fmt.Errorf("corrupted datastore: failed to open metadata: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var m Metadata
|
||||||
|
d := json.NewDecoder(src)
|
||||||
|
if err := d.Decode(&m); err != nil {
|
||||||
|
return Metadata{}, fmt.Errorf("corrupted datastore: failed to parse metadata: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
m.MD5, err = hash.FileMD5(filepath.Join(path, "data.tar.gz"))
|
||||||
|
if err != nil {
|
||||||
|
return Metadata{}, fmt.Errorf("failed to calculate md5: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func Register(m Metadata, path string) error {
|
func (l *LazyRepository) Backup(id BackupIdentifier) (Backup, error) {
|
||||||
m.Path = path
|
path := l.DataPath(id)
|
||||||
|
|
||||||
err := os.MkdirAll(filepath.Join(datastorepath, m.ID), 0740)
|
fs, err := os.Stat(filepath.Join(path, "data.tar.gz"))
|
||||||
if err != nil {
|
|
||||||
panic("cannot make directory for the game:" + err.Error())
|
|
||||||
}
|
|
||||||
|
|
||||||
f, err := os.OpenFile(filepath.Join(datastorepath, m.ID, "metadata.json"), os.O_CREATE|os.O_WRONLY, 0740)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("cannot open the metadata file in the datastore: %w", err)
|
|
||||||
}
|
|
||||||
defer f.Close()
|
|
||||||
|
|
||||||
e := json.NewEncoder(f)
|
|
||||||
err = e.Encode(m)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("cannot write into the metadata file in the datastore: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func All() ([]Metadata, error) {
|
|
||||||
ds, err := os.ReadDir(datastorepath)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("cannot open the datastore: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
var datastore []Metadata
|
|
||||||
for _, d := range ds {
|
|
||||||
content, err := os.ReadFile(filepath.Join(datastorepath, d.Name(), "metadata.json"))
|
|
||||||
if err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
var m Metadata
|
|
||||||
err = json.Unmarshal(content, &m)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("corrupted datastore: failed to parse %s/metadata.json: %w", d.Name(), err)
|
|
||||||
}
|
|
||||||
|
|
||||||
datastore = append(datastore, m)
|
|
||||||
}
|
|
||||||
return datastore, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func One(gameID string) (Metadata, error) {
|
|
||||||
_, err := os.ReadDir(datastorepath)
|
|
||||||
if err != nil {
|
|
||||||
return Metadata{}, fmt.Errorf("cannot open the datastore: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
content, err := os.ReadFile(filepath.Join(datastorepath, gameID, "metadata.json"))
|
|
||||||
if err != nil {
|
|
||||||
return Metadata{}, fmt.Errorf("game not found: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
var m Metadata
|
|
||||||
err = json.Unmarshal(content, &m)
|
|
||||||
if err != nil {
|
|
||||||
return Metadata{}, fmt.Errorf("corrupted datastore: failed to parse %s/metadata.json: %w", gameID, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
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 err != nil {
|
||||||
if errors.Is(err, os.ErrNotExist) {
|
if errors.Is(err, os.ErrNotExist) {
|
||||||
return nil
|
return Backup{}, ErrNotFound
|
||||||
}
|
}
|
||||||
return fmt.Errorf("failed to open old file: %w", err)
|
return Backup{}, fmt.Errorf("corrupted datastore: failed to open metadata: %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
|
h, err := hash.FileMD5(filepath.Join(path, "data.tar.gz"))
|
||||||
nf, err := os.OpenFile(filepath.Join(histDirPath, "data.tar.gz"), os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0740)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to open new file: %w", err)
|
return Backup{}, fmt.Errorf("corrupted datastore: failed to open metadata: %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
|
return Backup{
|
||||||
|
CreatedAt: fs.ModTime(),
|
||||||
|
MD5: h,
|
||||||
|
UUID: id.backupID,
|
||||||
|
ArchivePath: filepath.Join(path, "data.tar.gz"),
|
||||||
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func RestoreArchive(gameID, uuid string) error {
|
func (l *LazyRepository) LastScan(id GameIdentifier) (time.Time, error) {
|
||||||
histDirPath := filepath.Join(datastorepath, gameID, "hist", uuid)
|
path := l.DataPath(id)
|
||||||
if err := os.MkdirAll(histDirPath, 0740); err != nil {
|
|
||||||
return fmt.Errorf("failed to make directory: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// open old
|
data, err := os.ReadFile(filepath.Join(path, ".last_run"))
|
||||||
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 err != nil {
|
||||||
if errors.Is(err, os.ErrNotExist) {
|
if errors.Is(err, os.ErrNotExist) {
|
||||||
return nil
|
return time.Time{}, nil
|
||||||
}
|
}
|
||||||
return fmt.Errorf("failed to open old file: %w", err)
|
return time.Time{}, fmt.Errorf("failed to reading state file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
lastRun, err := time.Parse(time.RFC3339, string(data))
|
||||||
|
if err != nil {
|
||||||
|
return time.Time{}, fmt.Errorf("parsing state file timestamp: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return lastRun, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *LazyRepository) ResetLastScan(id GameIdentifier) error {
|
||||||
|
path := l.DataPath(id)
|
||||||
|
|
||||||
|
f, err := os.OpenFile(filepath.Join(path, ".last_run"), os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0740)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to open file: %w", err)
|
||||||
}
|
}
|
||||||
defer f.Close()
|
defer f.Close()
|
||||||
|
|
||||||
// copy
|
data := time.Now().Format(time.RFC3339)
|
||||||
if _, err := io.Copy(f, nf); err != nil {
|
|
||||||
return fmt.Errorf("failed to copy data: %w", err)
|
if _, err := f.WriteString(data); err != nil {
|
||||||
|
return fmt.Errorf("failed to write file: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func Archive(gameID, uuid string) (Backup, error) {
|
func (l *LazyRepository) ReadBlob(id Identifier) (io.Reader, error) {
|
||||||
histDirPath := filepath.Join(datastorepath, gameID, "hist", uuid)
|
path := l.DataPath(id)
|
||||||
if err := os.MkdirAll(histDirPath, 0740); err != nil {
|
|
||||||
return Backup{}, fmt.Errorf("failed to make 'hist' directory")
|
|
||||||
}
|
|
||||||
|
|
||||||
finfo, err := os.Stat(histDirPath)
|
dst, err := os.OpenFile(filepath.Join(path, "data.tar.gz"), os.O_RDONLY, 0)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return Backup{}, fmt.Errorf("corrupted datastore: %w", err)
|
return nil, fmt.Errorf("failed to open blob: %w", err)
|
||||||
}
|
}
|
||||||
archivePath := filepath.Join(histDirPath, "data.tar.gz")
|
|
||||||
|
|
||||||
h, err := hash.FileMD5(archivePath)
|
return dst, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *LazyRepository) SetRemote(id GameIdentifier, url string) error {
|
||||||
|
path := l.DataPath(id)
|
||||||
|
|
||||||
|
src, err := os.OpenFile(filepath.Join(path, "remote.json"), os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0740)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return Backup{}, fmt.Errorf("failed to calculate md5 hash: %w", err)
|
return fmt.Errorf("failed to open remote description: %w", err)
|
||||||
|
}
|
||||||
|
defer src.Close()
|
||||||
|
|
||||||
|
var r Remote
|
||||||
|
r.URL = url
|
||||||
|
|
||||||
|
e := json.NewEncoder(src)
|
||||||
|
if err := e.Encode(r); err != nil {
|
||||||
|
return fmt.Errorf("failed to marshall remote description: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
b := Backup{
|
return nil
|
||||||
CreatedAt: finfo.ModTime(),
|
|
||||||
UUID: filepath.Base(finfo.Name()),
|
|
||||||
MD5: h,
|
|
||||||
ArchivePath: archivePath,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return b, nil
|
func (l *LazyRepository) Remote(id GameIdentifier) (*Remote, error) {
|
||||||
}
|
path := l.DataPath(id)
|
||||||
|
|
||||||
func Archives(gameID string) ([]Backup, error) {
|
src, err := os.OpenFile(filepath.Join(path, "remote.json"), os.O_RDONLY, 0)
|
||||||
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 {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to open 'hist' directory")
|
if errors.Is(err, os.ErrNotExist) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("failed to open remote description: %w", err)
|
||||||
|
}
|
||||||
|
defer src.Close()
|
||||||
|
|
||||||
|
var r Remote
|
||||||
|
e := json.NewDecoder(src)
|
||||||
|
if err := e.Decode(&r); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to marshall remote description: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
var res []Backup
|
return &r, nil
|
||||||
for _, f := range d {
|
}
|
||||||
finfo, err := f.Info()
|
|
||||||
|
func (l *LazyRepository) Remove(id GameIdentifier) error {
|
||||||
|
path := l.DataPath(id)
|
||||||
|
|
||||||
|
if err := os.RemoveAll(path); err != nil {
|
||||||
|
return fmt.Errorf("failed to remove game folder from the datastore: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *LazyRepository) DataPath(id Identifier) string {
|
||||||
|
switch identifier := id.(type) {
|
||||||
|
case GameIdentifier:
|
||||||
|
return filepath.Join(r.dataRoot, identifier.gameID)
|
||||||
|
case BackupIdentifier:
|
||||||
|
return filepath.Join(r.dataRoot, identifier.gameID, "hist", identifier.backupID)
|
||||||
|
}
|
||||||
|
|
||||||
|
panic("identifier type not supported")
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewEagerRepository(dataRootPath string) (*EagerRepository, error) {
|
||||||
|
r, err := NewLazyRepository(dataRootPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("corrupted datastore: %w", err)
|
return nil, err
|
||||||
}
|
}
|
||||||
path := filepath.Join(histDirPath, finfo.Name())
|
|
||||||
archivePath := filepath.Join(path, "data.tar.gz")
|
|
||||||
|
|
||||||
h, err := hash.FileMD5(archivePath)
|
return &EagerRepository{
|
||||||
|
Repository: r,
|
||||||
|
data: make(map[string]Data),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *EagerRepository) Preload() error {
|
||||||
|
games, err := r.Repository.All()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to calculate md5 hash: %w", err)
|
return fmt.Errorf("failed to load all data: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
b := Backup{
|
for _, g := range games {
|
||||||
CreatedAt: finfo.ModTime(),
|
backup, err := r.Repository.AllHist(NewGameIdentifier(g))
|
||||||
UUID: filepath.Base(finfo.Name()),
|
if err != nil {
|
||||||
MD5: h,
|
return fmt.Errorf("[%s] failed to load hist data: %w", g, err)
|
||||||
ArchivePath: archivePath,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
res = append(res, b)
|
remote, err := r.Repository.Remote(NewGameIdentifier(g))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("[%s] failed to load remote metadata: %w", g, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
m, err := r.Repository.Metadata(NewGameIdentifier(g))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("[%s] failed to load metadata: %w", g, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
backups := make(map[string]Backup)
|
||||||
|
for _, b := range backup {
|
||||||
|
info, err := r.Repository.Backup(NewBackupIdentifier(g, b))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("[%s] failed to get backup information: %w", g, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
backups[b] = info
|
||||||
|
}
|
||||||
|
|
||||||
|
r.data[g] = Data{
|
||||||
|
Metadata: m,
|
||||||
|
Remote: remote,
|
||||||
|
DataPath: r.DataPath(NewGameIdentifier(g)),
|
||||||
|
Backup: backups,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *EagerRepository) All() ([]string, error) {
|
||||||
|
var res []string
|
||||||
|
for _, g := range r.data {
|
||||||
|
res = append(res, g.Metadata.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
return res, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *EagerRepository) AllHist(id GameIdentifier) ([]string, error) {
|
||||||
|
var res []string
|
||||||
|
if d, ok := r.data[id.gameID]; ok {
|
||||||
|
for _, b := range d.Backup {
|
||||||
|
res = append(res, b.UUID)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return res, nil
|
return res, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func DatastorePath() string {
|
func (r *EagerRepository) WriteMetadata(id GameIdentifier, m Metadata) error {
|
||||||
return datastorepath
|
err := r.Repository.WriteMetadata(id, m)
|
||||||
}
|
|
||||||
|
|
||||||
func Remove(gameID string) error {
|
|
||||||
err := os.RemoveAll(filepath.Join(datastorepath, gameID))
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func Hash(gameID string) (string, error) {
|
|
||||||
path := filepath.Join(datastorepath, gameID, "data.tar.gz")
|
|
||||||
|
|
||||||
return hash.FileMD5(path)
|
|
||||||
}
|
|
||||||
|
|
||||||
func Version(gameID string) (int, error) {
|
|
||||||
path := filepath.Join(datastorepath, gameID, "metadata.json")
|
|
||||||
|
|
||||||
f, err := os.OpenFile(path, os.O_RDONLY, 0)
|
|
||||||
if err != nil {
|
|
||||||
return 0, err
|
|
||||||
}
|
|
||||||
defer f.Close()
|
|
||||||
|
|
||||||
var metadata Metadata
|
|
||||||
d := json.NewDecoder(f)
|
|
||||||
err = d.Decode(&metadata)
|
|
||||||
if err != nil {
|
|
||||||
return 0, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return metadata.Version, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func SetVersion(gameID string, version int) error {
|
|
||||||
path := filepath.Join(datastorepath, gameID, "metadata.json")
|
|
||||||
|
|
||||||
f, err := os.OpenFile(path, os.O_RDONLY, 0)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
var metadata Metadata
|
d := r.data[id.gameID]
|
||||||
d := json.NewDecoder(f)
|
d.Metadata = m
|
||||||
err = d.Decode(&metadata)
|
r.data[id.gameID] = d
|
||||||
if err != nil {
|
|
||||||
f.Close()
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
f.Close()
|
|
||||||
|
|
||||||
metadata.Version = version
|
|
||||||
|
|
||||||
f, err = os.OpenFile(path, os.O_WRONLY|os.O_TRUNC, 0740)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer f.Close()
|
|
||||||
|
|
||||||
e := json.NewEncoder(f)
|
|
||||||
err = e.Encode(metadata)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func SetDate(gameID string, dt time.Time) error {
|
func (r *EagerRepository) Metadata(id GameIdentifier) (Metadata, error) {
|
||||||
path := filepath.Join(datastorepath, gameID, "metadata.json")
|
if d, ok := r.data[id.gameID]; ok {
|
||||||
|
return d.Metadata, nil
|
||||||
|
}
|
||||||
|
return Metadata{}, ErrNotFound
|
||||||
|
}
|
||||||
|
|
||||||
f, err := os.OpenFile(path, os.O_RDONLY, 0)
|
func (r *EagerRepository) Backup(id BackupIdentifier) (Backup, error) {
|
||||||
|
if d, ok := r.data[id.gameID]; ok {
|
||||||
|
if b, ok := d.Backup[id.backupID]; ok {
|
||||||
|
return b, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Backup{}, ErrNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *EagerRepository) SetRemote(id GameIdentifier, url string) error {
|
||||||
|
err := r.Repository.SetRemote(id, url)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
var metadata Metadata
|
d := r.data[id.gameID]
|
||||||
d := json.NewDecoder(f)
|
d.Remote = &Remote{
|
||||||
err = d.Decode(&metadata)
|
URL: url,
|
||||||
if err != nil {
|
GameID: d.Metadata.ID,
|
||||||
f.Close()
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
f.Close()
|
|
||||||
|
|
||||||
metadata.Date = dt
|
|
||||||
|
|
||||||
f, err = os.OpenFile(path, os.O_WRONLY|os.O_TRUNC, 0740)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
defer f.Close()
|
|
||||||
|
|
||||||
e := json.NewEncoder(f)
|
|
||||||
err = e.Encode(metadata)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
}
|
||||||
|
r.data[id.gameID] = d
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *EagerRepository) Remove(id GameIdentifier) error {
|
||||||
|
if err := r.Repository.Remove(id); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
delete(r.data, id.gameID)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user