27 Commits

Author SHA1 Message Date
bbd0983e63 Merge pull request '0.0.4' (#4) from 0.0.4 into main
Reviewed-on: #4
2025-08-17 00:43:00 +02:00
54dcc7d006 show cmd 2025-08-17 00:42:12 +02:00
851ff89886 implement applu 2025-08-17 00:30:19 +02:00
aa29fae900 fix windows sync 2025-08-10 12:34:08 +02:00
822c93bf4c opti 2025-08-10 02:32:56 +02:00
ab857bd0dd ver num 2025-08-10 02:09:34 +02:00
e34dc704ca wip refactoring 2025-08-10 02:03:27 +02:00
810c5ac627 wip 2025-08-09 22:19:57 +02:00
13adb26fba Merge pull request 'fix error while sync new save' (#3) from fix into main
Reviewed-on: #3
2025-08-08 21:35:46 +02:00
23ffa93615 fix error while sync new save 2025-08-08 21:30:58 +02:00
23e46e5eef add license 2025-08-07 00:16:46 +02:00
c1fc8a52f4 Actualiser README.md 2025-08-06 23:47:35 +02:00
6127da4626 Actualiser README.md 2025-08-06 23:46:12 +02:00
d208b1da91 Actualiser README.md 2025-08-06 23:45:13 +02:00
c329f96a76 fix script, add readme.md 2025-08-06 23:41:38 +02:00
f699fcd7f6 Merge pull request '0.0.3' (#2) from 0.0.3 into main
Reviewed-on: #2
2025-08-06 23:20:25 +02:00
898012a30d fix error 2025-08-06 23:17:29 +02:00
2f777c72ee better usage prompt + hash opti on server 2025-08-06 23:09:12 +02:00
d479004217 version change 2025-08-06 14:05:59 +02:00
cf96815d0f add web gui 2025-08-06 14:05:29 +02:00
4ee8fe48bc Merge pull request '0.0.2' (#1) from 0.0.2 into main
Reviewed-on: #1
2025-07-31 21:28:49 +02:00
f2fee0990b fix rel path error 2025-07-31 21:23:44 +02:00
95857356ab fix build script 2025-07-31 15:44:40 +02:00
58c6bc56cf apply backup 2025-07-30 17:20:43 +02:00
30b76e1887 push backup 2025-07-30 15:14:01 +02:00
c6edb91f29 starting 0.0.2 dev 2025-07-30 00:49:22 +02:00
c099d3e64f fix build.sh 2025-07-29 20:11:26 +02:00
39 changed files with 2364 additions and 662 deletions

5
.gitignore vendored
View File

@@ -1,4 +1,7 @@
/cli /cli
/server /server
/web
/env/ /env/
/build/ /build/
*.exe
/config.json

14
.vscode/launch.json vendored
View File

@@ -4,13 +4,21 @@
// 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", "type": "go",
"request": "launch", "request": "launch",
"mode": "auto", "mode": "auto",
"args": ["list", "-a", "http://localhost:8080"], "args": ["-document-root", "${workspaceFolder}/env"],
"console": "integratedTerminal",
"program": "${workspaceFolder}/cmd/server"
},
{
"name": "cli",
"type": "go",
"request": "launch",
"mode": "auto",
"args": ["run"],
"console": "integratedTerminal", "console": "integratedTerminal",
"program": "${workspaceFolder}/cmd/cli" "program": "${workspaceFolder}/cmd/cli"
} }

7
LICENSE Normal file
View File

@@ -0,0 +1,7 @@
Copyright 2025 Aurélie DELHAIE
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

56
README.md Normal file
View File

@@ -0,0 +1,56 @@
# CloudSave
The software is still in alpha.
A client/server that allows unsynchronized games (such as emulators, old games, etc.) to be kept up to date on multiple computers.
## Build
You need go1.24
After downloading the go toolchain, just run the script `./build.sh`
## Usage
### Server
The server needs an empty directory. After creating this directory, you need to make a file that contains your credential. The format is "username:password". The server only understand bcrypt password hash for now.
e.g.:
```
test:$2y$10$uULsuyROe3LVdTzFoBH7HO0zhvyKp6CX2FDNl7quXMFYqzitU0kc.
```
The default path to this directory is `/var/lib/cloudsave`, this can be changed with the `-document-root` argument
### Client
#### Register a game
You can register a game with the verb `add`
```bash
cloudsave add /home/user/gamedata
```
You can also change the name of the registration and add a remote
```bash
cloudsave add -name "My Game" -remote "http://localhost:8080" /home/user/gamedata
```
#### Make an archive of the current state
This is a command line tool, it cannot auto detect changes.
Run this command to start the scan, if needed, the tool will create a new archive
```bash
cloudsave scan
```
#### Send everythings on the server
This will pull and push data to the server.
Note: If multiple computers are pushing to this server, a conflict may be generated. If so, the tool will ask for the version to keep
```bash
cloudsave sync
```

View File

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

View File

@@ -1,7 +1,7 @@
package add package add
import ( import (
"cloudsave/pkg/game" "cloudsave/pkg/data"
"context" "context"
"flag" "flag"
"fmt" "fmt"
@@ -13,20 +13,26 @@ import (
type ( type (
AddCmd struct { AddCmd struct {
name string Service *data.Service
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 := game.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
} }

View File

@@ -0,0 +1,54 @@
package apply
import (
"cloudsave/pkg/data"
"context"
"flag"
"fmt"
"os"
"github.com/google/subcommands"
)
type (
ListCmd struct {
Service *data.Service
}
)
func (*ListCmd) Name() string { return "apply" }
func (*ListCmd) Synopsis() string { return "apply a backup" }
func (*ListCmd) Usage() string {
return `Usage: cloudsave apply <GAME_ID> [BACKUP_ID]
Apply a backup
`
}
func (p *ListCmd) SetFlags(f *flag.FlagSet) {
}
func (p *ListCmd) Execute(_ context.Context, f *flag.FlagSet, _ ...interface{}) subcommands.ExitStatus {
if f.NArg() < 1 {
fmt.Fprintln(os.Stderr, "error: missing game ID and/or backup uuid")
return subcommands.ExitUsageError
}
gameID := f.Arg(0)
uuid := f.Arg(1)
if len(uuid) == 0 {
if err := p.Service.ApplyCurrent(gameID); err != nil {
fmt.Fprintf(os.Stderr, "error: failed to apply: %s", err)
return subcommands.ExitFailure
}
return subcommands.ExitSuccess
}
if err := p.Service.ApplyBackup(gameID, uuid); err != nil {
fmt.Fprintf(os.Stderr, "error: failed to apply: %s", err)
return subcommands.ExitFailure
}
return subcommands.ExitSuccess
}

View File

@@ -1,9 +1,9 @@
package list package list
import ( import (
"cloudsave/pkg/game" "cloudsave/cmd/cli/tools/prompt/credentials"
"cloudsave/pkg/data"
"cloudsave/pkg/remote/client" "cloudsave/pkg/remote/client"
"cloudsave/pkg/tools/prompt/credentials"
"context" "context"
"flag" "flag"
"fmt" "fmt"
@@ -14,20 +14,26 @@ import (
type ( type (
ListCmd struct { ListCmd struct {
remote bool Service *data.Service
remote bool
backup bool
} }
) )
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:
` `
} }
func (p *ListCmd) SetFlags(f *flag.FlagSet) { func (p *ListCmd) SetFlags(f *flag.FlagSet) {
f.BoolVar(&p.remote, "a", false, "list all including remote data") f.BoolVar(&p.remote, "a", false, "list all including remote data")
f.BoolVar(&p.backup, "include-backup", false, "include backup uuids in the output")
} }
func (p *ListCmd) Execute(_ context.Context, f *flag.FlagSet, _ ...interface{}) subcommands.ExitStatus { func (p *ListCmd) Execute(_ context.Context, f *flag.FlagSet, _ ...interface{}) subcommands.ExitStatus {
@@ -39,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); 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(); 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() error { func (p *ListCmd) local(includeBackup bool) error {
games, err := game.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)
} }
@@ -66,13 +72,25 @@ func local() error {
fmt.Println("ID:", g.ID) fmt.Println("ID:", g.ID)
fmt.Println("Name:", g.Name) fmt.Println("Name:", g.Name)
fmt.Println("Last Version:", g.Date, "( Version Number", g.Version, ")") fmt.Println("Last Version:", g.Date, "( Version Number", g.Version, ")")
if includeBackup {
bk, err := p.Service.AllBackups(g.ID)
if err != nil {
return fmt.Errorf("failed to list backup files: %w", err)
}
if len(bk) > 0 {
fmt.Println("Backup:")
for _, b := range bk {
fmt.Printf(" - %s (%s)\n", b.UUID, b.CreatedAt)
}
}
}
fmt.Println("---") fmt.Println("---")
} }
return nil return nil
} }
func remote(url, username, password string) error { func (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 {
@@ -91,6 +109,22 @@ func remote(url, username, password string) error {
fmt.Println("ID:", g.ID) fmt.Println("ID:", g.ID)
fmt.Println("Name:", g.Name) fmt.Println("Name:", g.Name)
fmt.Println("Last Version:", g.Date, "( Version Number", g.Version, ")") fmt.Println("Last Version:", g.Date, "( Version Number", g.Version, ")")
if includeBackup {
bk, err := cli.ListArchives(g.ID)
if err != nil {
return fmt.Errorf("failed to list backup files: %w", err)
}
if len(bk) > 0 {
fmt.Println("Backup:")
for _, uuid := range bk {
b, err := cli.ArchiveInfo(g.ID, uuid)
if err != nil {
return fmt.Errorf("failed to list backup files: %w", err)
}
fmt.Printf(" - %s (%s)\n", b.UUID, b.CreatedAt)
}
}
}
fmt.Println("---") fmt.Println("---")
} }

View File

@@ -1,29 +1,29 @@
package pull package pull
import ( import (
"cloudsave/pkg/game" "cloudsave/cmd/cli/tools/prompt/credentials"
"cloudsave/pkg/data"
"cloudsave/pkg/remote/client" "cloudsave/pkg/remote/client"
"cloudsave/pkg/tools/archive"
"cloudsave/pkg/tools/prompt/credentials"
"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(game.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 = game.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

View File

@@ -1,7 +1,7 @@
package remote package remote
import ( import (
"cloudsave/pkg/game" "cloudsave/pkg/data"
"cloudsave/pkg/remote" "cloudsave/pkg/remote"
"cloudsave/pkg/remote/client" "cloudsave/pkg/remote/client"
"context" "context"
@@ -14,16 +14,24 @@ import (
type ( type (
RemoteCmd struct { RemoteCmd struct {
set bool Service *data.Service
list bool set 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 := game.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)
} }

View File

@@ -1,7 +1,7 @@
package remove package remove
import ( import (
"cloudsave/pkg/game" "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 := game.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

View File

@@ -1,162 +1,53 @@
package run package run
import ( import (
"archive/tar" "cloudsave/pkg/data"
"cloudsave/pkg/game"
"compress/gzip"
"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 := game.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
} }
pg := progressbar.New(len(datastore))
defer pg.Close()
for _, metadata := range datastore { for _, metadata := range datastore {
pg.Describe("Scanning " + metadata.Name + "...") if err := p.Service.MakeBackup(metadata.ID); err != nil {
metadataPath := filepath.Join(game.DatastorePath(), metadata.ID) fmt.Fprintln(os.Stderr, "error: failed to make backup:", err)
//todo transaction
err := archiveIfChanged(metadata.ID, metadata.Path, filepath.Join(metadataPath, "data.tar.gz"), filepath.Join(metadataPath, ".last_run"))
if err != nil {
fmt.Fprintf(os.Stderr, "error: cannot process the data of %s: %s\n", metadata.ID, err)
return subcommands.ExitFailure return subcommands.ExitFailure
} }
if err := game.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 return subcommands.ExitFailure
} }
if err := game.SetDate(metadata.ID, time.Now()); err != nil { fmt.Println("✅", metadata.Name)
fmt.Fprintf(os.Stderr, "error: cannot process the data of %s: %s\n", metadata.ID, err)
return subcommands.ExitFailure
}
pg.Add(1)
} }
pg.Finish() 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(id, srcDir, destTarGz, stateFile string) error {
// 1) Load last run time
var lastRun time.Time
data, err := os.ReadFile(stateFile)
if err != nil && !os.IsNotExist(err) {
return fmt.Errorf("reading state file: %w", err)
}
if err == nil {
lastRun, err = time.Parse(time.RFC3339, string(data))
if err != nil {
return fmt.Errorf("parsing state file timestamp: %w", err)
}
}
// 2) 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("scanning source directory: %w", err)
}
if !changed {
return nil
}
// 3) Create tar.gz
f, err := os.Create(destTarGz)
if err != nil {
return fmt.Errorf("creating archive file: %w", err)
}
defer f.Close()
gw := gzip.NewWriter(f)
defer gw.Close()
tw := tar.NewWriter(gw)
defer tw.Close()
// Walk again to add files
err = filepath.Walk(srcDir, func(path string, info os.FileInfo, walkErr error) error {
if walkErr != nil {
return walkErr
}
// Create tar header
header, err := tar.FileInfoHeader(info, path)
if err != nil {
return err
}
// Preserve directory structure relative to srcDir
relPath, err := filepath.Rel(filepath.Dir(srcDir), path)
if err != nil {
return err
}
header.Name = relPath
if err := tw.WriteHeader(header); err != nil {
return err
}
if info.Mode().IsRegular() {
file, err := os.Open(path)
if err != nil {
return err
}
defer file.Close()
if _, err := io.Copy(tw, file); err != nil {
return err
}
}
return nil
})
if err != nil {
return fmt.Errorf("writing tar entries: %w", err)
}
// 4) Update state file
now := time.Now().UTC().Format(time.RFC3339)
if err := os.WriteFile(stateFile, []byte(now), 0644); err != nil {
return fmt.Errorf("updating state file: %w", err)
}
return nil
}

View 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
}

View File

@@ -2,32 +2,35 @@ package sync
import ( import (
"cloudsave/cmd/cli/tools/prompt" "cloudsave/cmd/cli/tools/prompt"
"cloudsave/pkg/game" "cloudsave/cmd/cli/tools/prompt/credentials"
"cloudsave/pkg/data"
"cloudsave/pkg/remote" "cloudsave/pkg/remote"
"cloudsave/pkg/remote/client" "cloudsave/pkg/remote/client"
"cloudsave/pkg/tools/prompt/credentials" "cloudsave/pkg/repository"
"context" "context"
"errors" "errors"
"flag" "flag"
"fmt" "fmt"
"log/slog" "log/slog"
"os" "os"
"path/filepath"
"time" "time"
"github.com/google/subcommands" "github.com/google/subcommands"
"github.com/schollz/progressbar/v3"
) )
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.
` `
} }
@@ -35,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 := game.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
@@ -46,18 +49,27 @@ 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)
return subcommands.ExitFailure return subcommands.ExitFailure
} }
cli, err := connect(remoteCred, r) cli, err := connect(remoteCred, r)
if err != nil { if err != nil {
fmt.Fprintln(os.Stderr, "error: failed to connect to the remote:", err) fmt.Fprintln(os.Stderr, "error: failed to connect to the remote:", err)
return subcommands.ExitFailure return subcommands.ExitFailure
} }
pg := progressbar.New(-1)
destroyPg := func() {
pg.Finish()
pg.Clear()
pg.Close()
}
pg.Describe(fmt.Sprintf("[%s] Checking status...", g.Name))
exists, err := cli.Exists(r.GameID) exists, err := cli.Exists(r.GameID)
if err != nil { if err != nil {
slog.Error(err.Error()) slog.Error(err.Error())
@@ -65,75 +77,96 @@ func (p *SyncCmd) Execute(_ context.Context, f *flag.FlagSet, _ ...interface{})
} }
if !exists { if !exists {
if err := push(r.GameID, g, cli); err != nil { pg.Describe(fmt.Sprintf("[%s] Pushing data...", g.Name))
if err := p.push(g, cli); err != nil {
destroyPg()
fmt.Fprintln(os.Stderr, "failed to push:", err) fmt.Fprintln(os.Stderr, "failed to push:", err)
return subcommands.ExitFailure return subcommands.ExitFailure
} }
pg.Describe(fmt.Sprintf("[%s] Pushing backup...", g.Name))
if err := p.pushBackup(g, cli); err != nil {
destroyPg()
slog.Warn("failed to push backup files", "err", err)
}
fmt.Println(g.Name + ": pushed")
continue continue
} }
hlocal, err := game.Hash(r.GameID) pg.Describe(fmt.Sprintf("[%s] Fetching metadata...", g.Name))
if err != nil {
slog.Error(err.Error())
continue
}
hremote, err := cli.Hash(r.GameID) hremote, err := cli.Hash(r.GameID)
if err != nil { if err != nil {
destroyPg()
fmt.Fprintln(os.Stderr, "error: failed to get the file hash from the remote:", err) fmt.Fprintln(os.Stderr, "error: failed to get the file hash from the remote:", err)
continue continue
} }
vlocal, err := game.Version(r.GameID)
if err != nil {
slog.Error(err.Error())
continue
}
remoteMetadata, err := cli.Metadata(r.GameID) remoteMetadata, err := cli.Metadata(r.GameID)
if err != nil { if err != nil {
destroyPg()
fmt.Fprintln(os.Stderr, "error: failed to get the game metadata from the remote:", err) fmt.Fprintln(os.Stderr, "error: failed to get the game metadata from the remote:", err)
continue continue
} }
if hlocal == hremote { pg.Describe(fmt.Sprintf("[%s] Pulling backup...", g.Name))
if vlocal != remoteMetadata.Version { if err := p.pullBackup(g, cli); err != nil {
slog.Warn("failed to pull backup files", "err", err)
}
pg.Describe(fmt.Sprintf("[%s] Pushing backup...", g.Name))
if err := p.pushBackup(g, cli); err != nil {
slog.Warn("failed to push backup files", "err", err)
}
if g.MD5 == hremote {
destroyPg()
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 := game.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 {
if err := push(r.GameID, g, cli); err != nil { pg.Describe(fmt.Sprintf("[%s] Pushing data...", g.Name))
if err := p.push(g, cli); err != nil {
destroyPg()
fmt.Fprintln(os.Stderr, "failed to push:", err) fmt.Fprintln(os.Stderr, "failed to push:", err)
return subcommands.ExitFailure return subcommands.ExitFailure
} }
destroyPg()
fmt.Println(g.Name + ": pushed")
continue continue
} }
if vlocal < remoteMetadata.Version { if g.Version < remoteMetadata.Version {
if err := pull(r.GameID, cli); err != nil { destroyPg()
if err := p.pull(r.GameID, cli); err != nil {
destroyPg()
fmt.Fprintln(os.Stderr, "failed to push:", err) fmt.Fprintln(os.Stderr, "failed to push:", err)
return subcommands.ExitFailure return subcommands.ExitFailure
} }
if err := game.SetVersion(r.GameID, remoteMetadata.Version); err != nil {
fmt.Fprintln(os.Stderr, "error: failed to synchronize version number:", err) g.Version = remoteMetadata.Version
continue g.Date = remoteMetadata.Date
}
if err := game.SetDate(r.GameID, remoteMetadata.Date); err != nil { if err := p.Service.UpdateMetadata(g.ID, g); err != nil {
fmt.Fprintln(os.Stderr, "error: failed to synchronize date:", err) destroyPg()
continue fmt.Fprintln(os.Stderr, "failed to push:", err)
return subcommands.ExitFailure
} }
fmt.Println(g.Name + ": pulled")
continue continue
} }
if vlocal == remoteMetadata.Version { destroyPg()
if err := conflict(r.GameID, g, remoteMetadata, cli); err != nil {
if g.Version == remoteMetadata.Version {
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
} }
@@ -141,11 +174,12 @@ func (p *SyncCmd) Execute(_ context.Context, f *flag.FlagSet, _ ...interface{})
} }
} }
fmt.Println("done.")
return subcommands.ExitSuccess return subcommands.ExitSuccess
} }
func conflict(gameID string, m, remoteMetadata game.Metadata, cli *client.Client) error { func (p *SyncCmd) conflict(gameID string, m, remoteMetadata repository.Metadata, cli *client.Client) error {
g, err := game.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)
@@ -164,37 +198,83 @@ func conflict(gameID string, m, remoteMetadata game.Metadata, cli *client.Client
switch res { switch res {
case prompt.My: case prompt.My:
{ {
if err := push(gameID, m, cli); err != nil { if err := 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 := game.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 := game.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(gameID string, m game.Metadata, cli *client.Client) error { func (p *SyncCmd) push(m repository.Metadata, cli *client.Client) error {
archivePath := filepath.Join(game.DatastorePath(), gameID, "data.tar.gz") return p.Service.PushArchive(m.ID, "", cli)
return cli.Push(gameID, archivePath, m)
} }
func pull(gameID string, cli *client.Client) error { func (p *SyncCmd) pushBackup(m repository.Metadata, cli *client.Client) error {
archivePath := filepath.Join(game.DatastorePath(), gameID, "data.tar.gz") bs, err := p.Service.AllBackups(m.ID)
if err != nil {
return err
}
return cli.Pull(gameID, archivePath) for _, b := range bs {
binfo, err := cli.ArchiveInfo(m.ID, b.UUID)
if err != nil {
if !errors.Is(err, client.ErrNotFound) {
return fmt.Errorf("failed to get remote information about the backup file: %w", err)
}
}
if binfo.MD5 != b.MD5 {
if err := cli.PushBackup(b, m); err != nil {
return fmt.Errorf("failed to push backup: %w", err)
}
}
}
return nil
}
func (p *SyncCmd) pullBackup(m repository.Metadata, cli *client.Client) error {
bs, err := cli.ListArchives(m.ID)
if err != nil {
return err
}
for _, uuid := range bs {
rinfo, err := cli.ArchiveInfo(m.ID, uuid)
if err != nil {
return err
}
linfo, err := p.Service.Backup(m.ID, uuid)
if err != nil {
return err
}
if linfo.MD5 != rinfo.MD5 {
if err := p.Service.PullBackup(m.ID, uuid, cli); err != nil {
return err
}
}
}
return nil
}
func (p *SyncCmd) pull(gameID string, cli *client.Client) error {
return p.Service.PullArchive(gameID, "", cli)
} }
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) {

View File

@@ -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/pkg/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:
` `
} }

View File

@@ -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.ListCmd{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()

View File

@@ -1,14 +1,11 @@
package api package api
import ( import (
"cloudsave/cmd/server/data" "cloudsave/pkg/data"
"cloudsave/pkg/game" "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()
@@ -64,6 +63,11 @@ func NewServer(documentRoot string, creds map[string]string, port int) *HTTPServ
saveRouter.Get("/{id}/data", s.download) saveRouter.Get("/{id}/data", s.download)
saveRouter.Get("/{id}/hash", s.hash) saveRouter.Get("/{id}/hash", s.hash)
saveRouter.Get("/{id}/metadata", s.metadata) saveRouter.Get("/{id}/metadata", s.metadata)
saveRouter.Get("/{id}/hist", s.allHist)
saveRouter.Post("/{id}/hist/{uuid}/data", s.histUpload)
saveRouter.Get("/{id}/hist/{uuid}/data", s.histDownload)
saveRouter.Get("/{id}/hist/{uuid}/info", s.histExists)
}) })
}) })
}) })
@@ -78,7 +82,7 @@ func NewServer(documentRoot string, creds map[string]string, port int) *HTTPServ
func (s HTTPServer) all(w http.ResponseWriter, r *http.Request) { func (s HTTPServer) all(w http.ResponseWriter, r *http.Request) {
path := filepath.Join(s.documentRoot, "data") path := filepath.Join(s.documentRoot, "data")
datastore := make([]game.Metadata, 0) datastore := make([]repository.Metadata, 0)
if _, err := os.Stat(path); err != nil { if _, err := os.Stat(path); err != nil {
if errors.Is(err, os.ErrNotExist) { if errors.Is(err, os.ErrNotExist) {
@@ -104,7 +108,7 @@ func (s HTTPServer) all(w http.ResponseWriter, r *http.Request) {
continue continue
} }
var m game.Metadata var m repository.Metadata
err = json.Unmarshal(content, &m) err = json.Unmarshal(content, &m)
if err != nil { if err != nil {
fmt.Fprintf(os.Stderr, "corrupted datastore: failed to parse %s/metadata.json: %s", d.Name(), err) fmt.Fprintf(os.Stderr, "corrupted datastore: failed to parse %s/metadata.json: %s", d.Name(), err)
@@ -193,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
} }
@@ -209,9 +213,66 @@ func (s HTTPServer) upload(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusCreated) w.WriteHeader(http.StatusCreated)
} }
func (s HTTPServer) hash(w http.ResponseWriter, r *http.Request) { func (s HTTPServer) allHist(w http.ResponseWriter, r *http.Request) {
gameID := chi.URLParam(r, "id")
datastore := make([]string, 0)
ds, err := s.Service.AllBackups(gameID)
if err != nil {
fmt.Fprintln(os.Stderr, "failed to open datastore (", s.documentRoot, "):", err)
internalServerError(w, r)
return
}
for _, d := range ds {
datastore = append(datastore, d.UUID)
}
ok(datastore, w, r)
}
func (s HTTPServer) histUpload(w http.ResponseWriter, r *http.Request) {
const (
sizeLimit int64 = 500 << 20 // 500 MB
)
gameID := chi.URLParam(r, "id")
uuid := chi.URLParam(r, "uuid")
// Limit max upload size
r.Body = http.MaxBytesReader(w, r.Body, sizeLimit)
// Parse multipart form
err := r.ParseMultipartForm(sizeLimit)
if err != nil {
fmt.Fprintln(os.Stderr, "error: failed to load payload:", err)
badRequest("bad payload", w, r)
return
}
// Retrieve file
file, _, err := r.FormFile("payload")
if err != nil {
fmt.Fprintln(os.Stderr, "error: cannot find payload in the form:", err)
badRequest("payload not found", w, r)
return
}
defer file.Close()
if err := s.Service.CopyBackup(gameID, uuid, file); err != nil {
fmt.Fprintln(os.Stderr, "error: failed to write data to disk:", err)
internalServerError(w, r)
return
}
// Respond success
w.WriteHeader(http.StatusCreated)
}
func (s HTTPServer) histDownload(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id") id := chi.URLParam(r, "id")
path := filepath.Clean(filepath.Join(s.documentRoot, "data", id)) uuid := chi.URLParam(r, "uuid")
path := filepath.Clean(filepath.Join(s.documentRoot, "data", id, "hist", uuid))
sdir, err := os.Stat(path) sdir, err := os.Stat(path)
if err != nil { if err != nil {
@@ -233,19 +294,56 @@ func (s HTTPServer) hash(w http.ResponseWriter, r *http.Request) {
} }
defer f.Close() defer f.Close()
// Create MD5 hasher // Get file info to set headers
hasher := md5.New() fi, err := f.Stat()
if err != nil || fi.IsDir() {
// 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 // Set headers
sum := hasher.Sum(nil) w.Header().Set("Content-Disposition", "attachment; filename=\"data.tar.gz\"")
ok(hex.EncodeToString(sum), w, r) w.Header().Set("Content-Type", "application/gzip")
w.Header().Set("Content-Length", strconv.FormatInt(fi.Size(), 10))
w.WriteHeader(200)
// Stream the file content
http.ServeContent(w, r, "data.tar.gz", fi.ModTime(), f)
}
func (s HTTPServer) histExists(w http.ResponseWriter, r *http.Request) {
gameID := chi.URLParam(r, "id")
uuid := chi.URLParam(r, "uuid")
finfo, err := s.Service.Backup(gameID, uuid)
if err != nil {
if errors.Is(err, repository.ErrNotFound) {
notFound("not found", w, r)
return
}
fmt.Fprintln(os.Stderr, "error: failed to read data:", err)
internalServerError(w, r)
return
}
ok(finfo, w, r)
}
func (s HTTPServer) hash(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
m, err := s.Service.One(id)
if err != nil {
if errors.Is(err, repository.ErrNotFound) {
notFound("not found", w, r)
return
}
fmt.Fprintln(os.Stderr, "error: an error occured while calculating the hash:", err)
internalServerError(w, r)
return
}
ok(m.MD5, w, r)
} }
func (s HTTPServer) metadata(w http.ResponseWriter, r *http.Request) { func (s HTTPServer) metadata(w http.ResponseWriter, r *http.Request) {
@@ -272,7 +370,7 @@ func (s HTTPServer) metadata(w http.ResponseWriter, r *http.Request) {
} }
defer f.Close() defer f.Close()
var metadata game.Metadata var metadata repository.Metadata
d := json.NewDecoder(f) d := json.NewDecoder(f)
err = d.Decode(&metadata) err = d.Decode(&metadata)
if err != nil { if err != nil {
@@ -284,46 +382,46 @@ func (s HTTPServer) metadata(w http.ResponseWriter, r *http.Request) {
ok(metadata, w, r) ok(metadata, w, r)
} }
func parseFormMetadata(gameID string, values map[string][]string) (game.Metadata, error) { func parseFormMetadata(gameID string, values map[string][]string) (repository.Metadata, error) {
var name string var name string
if v, ok := values["name"]; ok { if v, ok := values["name"]; ok {
if len(v) == 0 { if len(v) == 0 {
return game.Metadata{}, fmt.Errorf("error: corrupted metadata") return repository.Metadata{}, fmt.Errorf("error: corrupted metadata")
} }
name = v[0] name = v[0]
} else { } else {
return game.Metadata{}, fmt.Errorf("error: cannot find metadata in the form") return repository.Metadata{}, fmt.Errorf("error: cannot find metadata in the form")
} }
var version int var version int
if v, ok := values["version"]; ok { if v, ok := values["version"]; ok {
if len(v) == 0 { if len(v) == 0 {
return game.Metadata{}, fmt.Errorf("error: corrupted metadata") return repository.Metadata{}, fmt.Errorf("error: corrupted metadata")
} }
if v, err := strconv.Atoi(v[0]); err == nil { if v, err := strconv.Atoi(v[0]); err == nil {
version = v version = v
} else { } else {
return game.Metadata{}, err return repository.Metadata{}, err
} }
} else { } else {
return game.Metadata{}, fmt.Errorf("error: cannot find metadata in the form") return repository.Metadata{}, fmt.Errorf("error: cannot find metadata in the form")
} }
var date time.Time var date time.Time
if v, ok := values["date"]; ok { if v, ok := values["date"]; ok {
if len(v) == 0 { if len(v) == 0 {
return game.Metadata{}, fmt.Errorf("error: corrupted metadata") return repository.Metadata{}, fmt.Errorf("error: corrupted metadata")
} }
if v, err := time.Parse(time.RFC3339, v[0]); err == nil { if v, err := time.Parse(time.RFC3339, v[0]); err == nil {
date = v date = v
} else { } else {
return game.Metadata{}, err return repository.Metadata{}, err
} }
} else { } else {
return game.Metadata{}, fmt.Errorf("error: cannot find metadata in the form") return repository.Metadata{}, fmt.Errorf("error: cannot find metadata in the form")
} }
return game.Metadata{ return repository.Metadata{
ID: gameID, ID: gameID,
Version: version, Version: version,
Name: name, Name: name,

View File

@@ -1,60 +0,0 @@
package data
import (
"cloudsave/pkg/game"
"encoding/json"
"fmt"
"io"
"os"
"path/filepath"
)
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 UpdateMetadata(gameID, documentRoot string, m game.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 makeDataFolder(gameID, documentRoot string) error {
return os.MkdirAll(filepath.Join(documentRoot, "data", gameID), 0740)
}

View File

@@ -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 {

View File

@@ -0,0 +1,8 @@
{
"server": {
"port": 8181
},
"remote": {
"url": "http://localhost:8080"
}
}

View 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
}

View 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
View 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
}
}

View File

@@ -0,0 +1,9 @@
<!DOCTYPE html>
<html>
<head>
<title>You are not allowed</title>
</head>
<body>
<h1>401 Unauthorized</h1>
</body>
</html>

View File

@@ -0,0 +1,9 @@
<!DOCTYPE html>
<html>
<head>
<title>An error occured</title>
</head>
<body>
<h1>500 Internal Server Error</h1>
</body>
</html>

View 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>

View 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>

View 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
View File

@@ -0,0 +1,34 @@
package main
import (
"cloudsave/cmd/web/server"
"cloudsave/cmd/web/server/config"
"cloudsave/pkg/constants"
"flag"
"fmt"
"os"
"runtime"
"strconv"
)
func main() {
fmt.Printf("CloudSave web -- v%s.%s.%s\n\n", constants.Version, runtime.GOOS, runtime.GOARCH)
var configPath string
flag.StringVar(&configPath, "config", "/var/lib/cloudsave/config.json", "Define the path to the configuration file")
flag.Parse()
c, err := config.Load(configPath)
if err != nil {
fmt.Fprintln(os.Stderr, "failed to load configuration:", err)
os.Exit(1)
}
s := server.NewServer(c)
fmt.Println("starting server at :" + strconv.Itoa(c.Server.Port))
if err := s.Server.ListenAndServe(); err != nil {
fmt.Fprintln(os.Stderr, "failed to start web server:", err)
os.Exit(1)
}
}

1
go.mod
View File

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

2
go.sum
View File

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

View File

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

385
pkg/data/data.go Normal file
View 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)
}

View File

@@ -1,254 +0,0 @@
package game
import (
"cloudsave/pkg/tools/id"
"crypto/md5"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"os"
"path/filepath"
"time"
)
type (
Metadata struct {
ID string `json:"id"`
Name string `json:"name"`
Path string `json:"path"`
Version int `json:"version"`
Date time.Time `json:"date"`
}
)
var (
roaming string
datastorepath string
)
func init() {
var err error
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())
}
}
func Add(name, path string) (Metadata, error) {
m := Metadata{
ID: id.New(),
Name: name,
Path: path,
}
err := os.MkdirAll(filepath.Join(datastorepath, m.ID), 0740)
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 Metadata{}, 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 Metadata{}, fmt.Errorf("cannot write into the metadata file in the datastore: %w", err)
}
return m, nil
}
func Register(m Metadata, path string) error {
m.Path = path
err := os.MkdirAll(filepath.Join(datastorepath, m.ID), 0740)
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 DatastorePath() string {
return datastorepath
}
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")
f, err := os.OpenFile(path, os.O_RDONLY, 0)
if err != nil {
return "", err
}
defer f.Close()
hasher := md5.New()
if _, err := io.Copy(hasher, f); err != nil {
return "", err
}
sum := hasher.Sum(nil)
return hex.EncodeToString(sum), nil
}
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 {
return err
}
var metadata Metadata
d := json.NewDecoder(f)
err = d.Decode(&metadata)
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
}
func SetDate(gameID string, dt time.Time) error {
path := filepath.Join(datastorepath, gameID, "metadata.json")
f, err := os.OpenFile(path, os.O_RDONLY, 0)
if err != nil {
return err
}
var metadata Metadata
d := json.NewDecoder(f)
err = d.Decode(&metadata)
if err != nil {
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
}
return nil
}

View File

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

View File

@@ -0,0 +1,484 @@
package repository
import (
"cloudsave/pkg/tools/hash"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"time"
)
type (
Metadata struct {
ID string `json:"id"`
Name string `json:"name"`
Path string `json:"path"`
Version int `json:"version"`
Date time.Time `json:"date"`
MD5 string `json:"-"`
}
Remote struct {
URL string `json:"url"`
GameID string `json:"-"`
}
Backup struct {
CreatedAt time.Time `json:"created_at"`
MD5 string `json:"md5"`
UUID string `json:"uuid"`
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 (
ErrNotFound error = errors.New("not found")
)
func NewGameIdentifier(gameID string) GameIdentifier {
return GameIdentifier{
gameID: gameID,
}
}
func (bi GameIdentifier) Key() string {
return bi.gameID
}
func NewBackupIdentifier(gameID, backupID string) BackupIdentifier {
return BackupIdentifier{
gameID: gameID,
backupID: backupID,
}
}
func (bi BackupIdentifier) Key() string {
return bi.gameID + ":" + bi.backupID
}
func NewLazyRepository(dataRootPath string) (*LazyRepository, error) {
if m, err := os.Stat(dataRootPath); err != nil {
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")
}
}
return &LazyRepository{
dataRoot: dataRootPath,
}, nil
}
func (l *LazyRepository) Mkdir(id Identifier) error {
return os.MkdirAll(l.DataPath(id), 0740)
}
func (l *LazyRepository) All() ([]string, error) {
dir, err := os.ReadDir(l.dataRoot)
if err != 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) 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
}
func (l *LazyRepository) Backup(id BackupIdentifier) (Backup, error) {
path := l.DataPath(id)
fs, err := os.Stat(filepath.Join(path, "data.tar.gz"))
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return Backup{}, ErrNotFound
}
return Backup{}, fmt.Errorf("corrupted datastore: failed to open metadata: %w", err)
}
h, err := hash.FileMD5(filepath.Join(path, "data.tar.gz"))
if err != nil {
return Backup{}, fmt.Errorf("corrupted datastore: failed to open metadata: %w", err)
}
return Backup{
CreatedAt: fs.ModTime(),
MD5: h,
UUID: id.backupID,
ArchivePath: filepath.Join(path, "data.tar.gz"),
}, nil
}
func (l *LazyRepository) LastScan(id GameIdentifier) (time.Time, error) {
path := l.DataPath(id)
data, err := os.ReadFile(filepath.Join(path, ".last_run"))
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return time.Time{}, nil
}
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()
data := time.Now().Format(time.RFC3339)
if _, err := f.WriteString(data); err != nil {
return fmt.Errorf("failed to write file: %w", err)
}
return nil
}
func (l *LazyRepository) ReadBlob(id Identifier) (io.Reader, error) {
path := l.DataPath(id)
dst, err := os.OpenFile(filepath.Join(path, "data.tar.gz"), os.O_RDONLY, 0)
if err != nil {
return nil, fmt.Errorf("failed to open blob: %w", err)
}
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 {
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)
}
return nil
}
func (l *LazyRepository) Remote(id GameIdentifier) (*Remote, error) {
path := l.DataPath(id)
src, err := os.OpenFile(filepath.Join(path, "remote.json"), os.O_RDONLY, 0)
if err != nil {
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)
}
return &r, nil
}
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 {
return nil, err
}
return &EagerRepository{
Repository: r,
data: make(map[string]Data),
}, nil
}
func (r *EagerRepository) Preload() error {
games, err := r.Repository.All()
if err != nil {
return fmt.Errorf("failed to load all data: %w", err)
}
for _, g := range games {
backup, err := r.Repository.AllHist(NewGameIdentifier(g))
if err != nil {
return fmt.Errorf("[%s] failed to load hist data: %w", g, err)
}
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
}
func (r *EagerRepository) WriteMetadata(id GameIdentifier, m Metadata) error {
err := r.Repository.WriteMetadata(id, m)
if err != nil {
return err
}
d := r.data[id.gameID]
d.Metadata = m
r.data[id.gameID] = d
return nil
}
func (r *EagerRepository) Metadata(id GameIdentifier) (Metadata, error) {
if d, ok := r.data[id.gameID]; ok {
return d.Metadata, nil
}
return Metadata{}, ErrNotFound
}
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 {
return err
}
d := r.data[id.gameID]
d.Remote = &Remote{
URL: url,
GameID: d.Metadata.ID,
}
r.data[id.gameID] = d
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
}

View File

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

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

@@ -0,0 +1,23 @@
package hash
import (
"crypto/md5"
"encoding/hex"
"io"
"os"
)
func FileMD5(fp string) (string, error) {
f, err := os.OpenFile(fp, os.O_RDONLY, 0)
if err != nil {
return "", err
}
defer f.Close()
hasher := md5.New()
if _, err := io.Copy(hasher, f); err != nil {
return "", err
}
sum := hasher.Sum(nil)
return hex.EncodeToString(sum), nil
}