55 Commits

Author SHA1 Message Date
1c89df0673 switch back to qt
Some checks failed
CloudSave/pipeline/head There was a failure building this commit
2025-09-13 15:32:37 +02:00
1239c5ed6b fix status print
Some checks failed
CloudSave/pipeline/head There was a failure building this commit
2025-09-13 11:21:42 +02:00
03818e20e5 fix loop sync cli
Some checks failed
CloudSave/pipeline/head There was a failure building this commit
2025-09-13 09:32:59 +02:00
b36142c309 wip
Some checks failed
CloudSave/pipeline/head There was a failure building this commit
2025-09-12 19:06:52 +02:00
57fc77755e wip
Some checks failed
CloudSave/pipeline/head There was a failure building this commit
2025-09-12 01:39:47 +02:00
5f7ca22b8f wip
Some checks failed
CloudSave/pipeline/head There was a failure building this commit
2025-09-11 17:41:57 +02:00
d15de3c6a1 refactoring sync
Some checks failed
CloudSave/pipeline/head There was a failure building this commit
2025-09-08 17:40:58 +02:00
f56d3c5857 wip
Some checks failed
CloudSave/pipeline/head There was a failure building this commit
2025-09-08 01:21:53 +02:00
7e5d8855d4 fix crash
Some checks failed
CloudSave/pipeline/head There was a failure building this commit
2025-09-07 19:30:05 +02:00
e6ca29a7aa first commit
Some checks failed
CloudSave/pipeline/head There was a failure building this commit
2025-09-07 19:26:18 +02:00
62911f2405 fix access right
Some checks failed
CloudSave/pipeline/head There was a failure building this commit
CloudSave/pipeline/pr-main There was a failure building this commit
2025-09-07 19:02:26 +02:00
3db9974aa8 hook test
All checks were successful
CloudSave/pipeline/head This commit looks good
CloudSave/pipeline/pr-main This commit looks good
2025-09-07 12:47:59 +02:00
8b4c599657 better compile script 2025-09-07 12:43:19 +02:00
5ba8642904 fix timeing
All checks were successful
CloudSave/pipeline/head This commit looks good
CloudSave/pipeline/pr-main This commit looks good
2025-09-07 01:50:41 +02:00
ddcfe2a698 fix build script 2025-09-07 01:41:44 +02:00
1cf8b986fa version
All checks were successful
CloudSave/pipeline/head This commit looks good
2025-09-07 01:33:18 +02:00
46f312078d fix sec
Some checks failed
CloudSave/pipeline/head Something is wrong with the build of this commit
2025-09-07 01:31:14 +02:00
af11e843a4 fixing sec issues
Some checks failed
CloudSave/pipeline/head There was a failure building this commit
2025-09-07 01:14:19 +02:00
b3232e79d5 add gosec
Some checks failed
CloudSave/pipeline/head There was a failure building this commit
2025-09-07 00:31:37 +02:00
0a33d1b68d wip 0.0.5
All checks were successful
CloudSave/pipeline/head This commit looks good
2025-09-02 22:32:07 +02:00
8518503d40 version 2025-09-02 19:30:43 +02:00
fdc019a200 fixes 2025-09-02 19:21:03 +02:00
7bf88d9d8c Add jenkinsfile
All checks were successful
CloudSave/pipeline/head This commit looks good
2025-08-29 14:37:05 +02:00
7ec9432d7b change version 2025-08-25 22:14:19 +02:00
044d49a9dc fix error when add 2025-08-25 22:13:23 +02:00
9a14571c31 fixes (the last) 2025-08-18 22:10:27 +02:00
0f2c0e511f fixes 2025-08-18 21:28:01 +02:00
0d92b6b8a0 multiple fix again 2025-08-18 20:52:06 +02:00
2ff191fecf more fix 2025-08-18 20:04:32 +02:00
b2425d310b fix things 2025-08-18 19:38:42 +02:00
97cd8f065f fix things 2025-08-18 19:38:34 +02:00
573fba708e fix can't create entry 2025-08-17 17:45:50 +02:00
a7c85ea3c6 fix name 2025-08-17 01:12:31 +02:00
ea6948dbe2 Actualiser README.md 2025-08-17 00:50:56 +02:00
da2ad068b4 update version number 2025-08-17 00:43:58 +02:00
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
50 changed files with 2861 additions and 1116 deletions

6
.gitignore vendored
View File

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

23
.vscode/launch.json vendored
View File

@@ -4,13 +4,30 @@
// 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": "web",
"type": "go", "type": "go",
"request": "launch", "request": "launch",
"mode": "auto", "mode": "auto",
"args": ["run"], "args": ["-config", "${workspaceFolder}/env/config.json"],
"console": "integratedTerminal",
"program": "${workspaceFolder}/cmd/web"
},
{
"name": "server",
"type": "go",
"request": "launch",
"mode": "auto",
"args": ["-document-root", "${workspaceFolder}/env"],
"console": "integratedTerminal",
"program": "${workspaceFolder}/cmd/server"
},
{
"name": "cli",
"type": "go",
"request": "launch",
"mode": "auto",
"args": ["sync"],
"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.

58
README.md Normal file
View File

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

View File

@@ -1,7 +1,7 @@
#!/bin/bash #!/bin/bash
MAKE_PACKAGE=false MAKE_PACKAGE=false
VERSION=0.0.2 VERSION=0.0.4
usage() { usage() {
echo "Usage: $0 [OPTIONS]" echo "Usage: $0 [OPTIONS]"
@@ -35,7 +35,7 @@ fi
## SERVER ## SERVER
platforms=("linux/amd64" "linux/arm64" "linux/riscv64" "linux/ppc64le") platforms=("linux/amd64" "linux/arm64" "linux/riscv64" "linux/ppc64le" "windows/amd64")
for platform in "${platforms[@]}"; do for platform in "${platforms[@]}"; do
echo "* Compiling server for $platform..." echo "* Compiling server for $platform..."
@@ -47,11 +47,61 @@ for platform in "${platforms[@]}"; do
fi fi
if [ "$MAKE_PACKAGE" == "true" ]; then if [ "$MAKE_PACKAGE" == "true" ]; then
CGO_ENABLED=0 GOOS=${platform_split[0]} GOARCH=${platform_split[1]} go build -o build/cloudsave_server$EXT -a ./cmd/server CGO_ENABLED=0 GOOS=${platform_split[0]} GOARCH=${platform_split[1]} GORISCV64=rva22u64 GOAMD64=v3 GOARM64=v8.2 go build -o build/cloudsave_server$EXT -a ./cmd/server
if [ $? -ne 0 ]; then
exit 1
fi
tar -czf build/server_${platform_split[0]}_${platform_split[1]}.tar.gz build/cloudsave_server$EXT tar -czf build/server_${platform_split[0]}_${platform_split[1]}.tar.gz build/cloudsave_server$EXT
if [ $? -ne 0 ]; then
exit 1
fi
rm build/cloudsave_server$EXT rm build/cloudsave_server$EXT
if [ $? -ne 0 ]; then
exit 1
fi
else 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 CGO_ENABLED=0 GOOS=${platform_split[0]} GOARCH=${platform_split[1]} GORISCV64=rva22u64 GOAMD64=v3 GOARM64=v8.2 go build -o build/cloudsave_server_${platform_split[0]}_${platform_split[1]}$EXT -a ./cmd/server
if [ $? -ne 0 ]; then
exit 1
fi
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
if [ $? -ne 0 ]; then
exit 1
fi
tar -czf build/web_${platform_split[0]}_${platform_split[1]}.tar.gz build/cloudsave_web$EXT
if [ $? -ne 0 ]; then
exit 1
fi
rm build/cloudsave_web$EXT
if [ $? -ne 0 ]; then
exit 1
fi
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
if [ $? -ne 0 ]; then
exit 1
fi
fi fi
done done
@@ -70,9 +120,23 @@ for platform in "${platforms[@]}"; do
if [ "$MAKE_PACKAGE" == "true" ]; then if [ "$MAKE_PACKAGE" == "true" ]; then
CGO_ENABLED=0 GOOS=${platform_split[0]} GOARCH=${platform_split[1]} go build -o build/cloudsave$EXT -a ./cmd/cli CGO_ENABLED=0 GOOS=${platform_split[0]} GOARCH=${platform_split[1]} go build -o build/cloudsave$EXT -a ./cmd/cli
if [ $? -ne 0 ]; then
exit 1
fi
tar -czf build/cli_${platform_split[0]}_${platform_split[1]}.tar.gz build/cloudsave$EXT tar -czf build/cli_${platform_split[0]}_${platform_split[1]}.tar.gz build/cloudsave$EXT
if [ $? -ne 0 ]; then
exit 1
fi
rm build/cloudsave$EXT rm build/cloudsave$EXT
if [ $? -ne 0 ]; then
exit 1
fi
else 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 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
if [ $? -ne 0 ]; then
exit 1
fi
fi fi
done done

View File

@@ -1,7 +1,7 @@
package add package add
import ( import (
"cloudsave/pkg/repository" "cloudsave/pkg/data"
"context" "context"
"flag" "flag"
"fmt" "fmt"
@@ -13,20 +13,26 @@ import (
type ( type (
AddCmd struct { AddCmd struct {
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 := repository.Add(p.name, path) gameID, err := p.Service.Add(p.name, path, p.remote)
if err != nil { if err != nil {
fmt.Fprintln(os.Stderr, "error: failed to add game reference:", err) fmt.Fprintln(os.Stderr, "error: failed to add this gamesave to the datastore:", err)
return subcommands.ExitFailure return subcommands.ExitFailure
} }
fmt.Println(m.ID) if _, err := p.Service.Scan(gameID); err != nil {
fmt.Fprintln(os.Stderr, "error: failed to scan:", err)
return subcommands.ExitFailure
}
return subcommands.ExitSuccess return subcommands.ExitSuccess
} }

View File

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

View File

@@ -2,8 +2,8 @@ package list
import ( import (
"cloudsave/cmd/cli/tools/prompt/credentials" "cloudsave/cmd/cli/tools/prompt/credentials"
"cloudsave/pkg/data"
"cloudsave/pkg/remote/client" "cloudsave/pkg/remote/client"
"cloudsave/pkg/repository"
"context" "context"
"flag" "flag"
"fmt" "fmt"
@@ -14,16 +14,20 @@ import (
type ( type (
ListCmd struct { ListCmd struct {
remote bool Service *data.Service
backup bool 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:
` `
} }
@@ -39,27 +43,27 @@ func (p *ListCmd) Execute(_ context.Context, f *flag.FlagSet, _ ...interface{})
return subcommands.ExitUsageError return subcommands.ExitUsageError
} }
username, password, err := credentials.Read() username, password, err := credentials.Read(f.Arg(0))
if err != nil { if err != nil {
fmt.Fprintf(os.Stderr, "failed to read std output: %s", err) fmt.Fprintf(os.Stderr, "error: failed to read std output: %s", err)
return subcommands.ExitFailure return subcommands.ExitFailure
} }
if err := remote(f.Arg(0), username, password, p.backup); err != nil { if err := p.server(f.Arg(0), username, password, p.backup); err != nil {
fmt.Fprintln(os.Stderr, "error:", err) fmt.Fprintln(os.Stderr, "error:", err)
return subcommands.ExitFailure return subcommands.ExitFailure
} }
return subcommands.ExitSuccess return subcommands.ExitSuccess
} }
if err := local(p.backup); err != nil { if err := p.local(p.backup); err != nil {
fmt.Fprintln(os.Stderr, "error:", err) fmt.Fprintln(os.Stderr, "error:", err)
return subcommands.ExitFailure return subcommands.ExitFailure
} }
return subcommands.ExitSuccess return subcommands.ExitSuccess
} }
func local(includeBackup bool) error { func (p *ListCmd) local(includeBackup bool) error {
games, err := repository.All() games, err := p.Service.AllGames()
if err != nil { if err != nil {
return fmt.Errorf("failed to load datastore: %w", err) return fmt.Errorf("failed to load datastore: %w", err)
} }
@@ -67,9 +71,11 @@ func local(includeBackup bool) error {
for _, g := range games { for _, g := range games {
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)
fmt.Println("Version:", g.Version)
fmt.Println("MD5:", g.MD5)
if includeBackup { if includeBackup {
bk, err := repository.Archives(g.ID) bk, err := p.Service.AllBackups(g.ID)
if err != nil { if err != nil {
return fmt.Errorf("failed to list backup files: %w", err) return fmt.Errorf("failed to list backup files: %w", err)
} }
@@ -86,7 +92,7 @@ func local(includeBackup bool) error {
return nil return nil
} }
func remote(url, username, password string, includeBackup bool) error { func (p *ListCmd) server(url, username, password string, includeBackup bool) error {
cli := client.New(url, username, password) cli := client.New(url, username, password)
if err := cli.Ping(); err != nil { if err := cli.Ping(); err != nil {
@@ -104,7 +110,9 @@ func remote(url, username, password string, includeBackup bool) error {
for _, g := range games { for _, g := range games {
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)
fmt.Println("Version:", g.Version)
fmt.Println("MD5:", g.MD5)
if includeBackup { if includeBackup {
bk, err := cli.ListArchives(g.ID) bk, err := cli.ListArchives(g.ID)
if err != nil { if err != nil {

View File

@@ -0,0 +1,60 @@
package login
import (
"cloudsave/cmd/cli/tools/prompt/credentials"
"cloudsave/pkg/remote/client"
"context"
"flag"
"fmt"
"os"
"github.com/google/subcommands"
)
type (
LoginCmd struct {
}
)
func (*LoginCmd) Name() string { return "login" }
func (*LoginCmd) Synopsis() string { return "save IN PLAIN TEXT your credentials" }
func (*LoginCmd) Usage() string {
return `Usage: cloudsave login <SERVER_HOSTNAME>
Warning: this command saves the login into a plain text json file
Options:
`
}
func (p *LoginCmd) SetFlags(f *flag.FlagSet) {
}
func (p *LoginCmd) Execute(_ context.Context, f *flag.FlagSet, _ ...interface{}) subcommands.ExitStatus {
if f.NArg() != 1 {
fmt.Fprintf(os.Stderr, "error: this command take 1 argument")
return subcommands.ExitUsageError
}
server := f.Arg(0)
username, password, err := credentials.Read(server)
if err != nil {
fmt.Fprintf(os.Stderr, "error: failed to read std output: %s", err)
return subcommands.ExitFailure
}
cli := client.New(server, username, password)
if _, err := cli.Version(); err != nil {
fmt.Fprintf(os.Stderr, "error: failed to login: %s", err)
return subcommands.ExitFailure
}
if err := credentials.Login(username, password, server); err != nil {
fmt.Fprintf(os.Stderr, "error: failed to save login: %s", err)
return subcommands.ExitFailure
}
fmt.Println("login information saved!")
return subcommands.ExitSuccess
}

View File

@@ -0,0 +1,43 @@
package logout
import (
"cloudsave/cmd/cli/tools/prompt/credentials"
"context"
"flag"
"fmt"
"os"
"github.com/google/subcommands"
)
type (
LogoutCmd struct {
}
)
func (*LogoutCmd) Name() string { return "logout" }
func (*LogoutCmd) Synopsis() string { return "logout from a server" }
func (*LogoutCmd) Usage() string {
return `Usage: cloudsave logout <SERVER_HOSTNAME>
Options:
`
}
func (p *LogoutCmd) SetFlags(f *flag.FlagSet) {
}
func (p *LogoutCmd) Execute(_ context.Context, f *flag.FlagSet, _ ...interface{}) subcommands.ExitStatus {
if f.NArg() != 1 {
fmt.Fprintf(os.Stderr, "error: this command take 1 argument")
return subcommands.ExitUsageError
}
if err := credentials.Logout(f.Arg(0)); err != nil {
fmt.Fprintf(os.Stderr, "error: failed to logout: %s", err)
return subcommands.ExitFailure
}
fmt.Println("bye!")
return subcommands.ExitSuccess
}

View File

@@ -1,29 +1,29 @@
package pull package pull
import ( import (
"cloudsave/pkg/remote/client"
"cloudsave/pkg/repository"
"cloudsave/pkg/tools/archive"
"cloudsave/cmd/cli/tools/prompt/credentials" "cloudsave/cmd/cli/tools/prompt/credentials"
"cloudsave/pkg/data"
"cloudsave/pkg/remote/client"
"context" "context"
"flag" "flag"
"fmt" "fmt"
"os" "os"
"path/filepath"
"github.com/google/subcommands" "github.com/google/subcommands"
) )
type ( type (
PullCmd struct { PullCmd struct {
Service *data.Service
} }
) )
func (*PullCmd) Name() string { return "pull" } func (*PullCmd) Name() string { return "pull" }
func (*PullCmd) Synopsis() string { return "pull a game save from the remote" } func (*PullCmd) Synopsis() string { return "pull a game save from the remote" }
func (*PullCmd) Usage() string { func (*PullCmd) Usage() string {
return `list: return `Usage: cloudsave pull <GAME_ID>
Pull a game save from the remote
Pull a game save from the remote
` `
} }
@@ -41,47 +41,35 @@ func (p *PullCmd) Execute(_ context.Context, f *flag.FlagSet, _ ...interface{})
gameID := f.Arg(1) gameID := f.Arg(1)
path := f.Arg(2) path := f.Arg(2)
username, password, err := credentials.Read() username, password, err := credentials.Read(url)
if err != nil { if err != nil {
fmt.Fprintf(os.Stderr, "failed to read std output: %s", err) fmt.Fprintf(os.Stderr, "error: failed to read std output: %s", err)
return subcommands.ExitFailure return subcommands.ExitFailure
} }
cli := client.New(url, username, password) cli := client.New(url, username, password)
if err := cli.Ping(); err != nil { if err := cli.Ping(); err != nil {
fmt.Fprintf(os.Stderr, "failed to connect to the remote: %s", err) fmt.Fprintf(os.Stderr, "error: failed to connect to the remote: %s", err)
return subcommands.ExitFailure return subcommands.ExitFailure
} }
archivePath := filepath.Join(repository.DatastorePath(), gameID, "data.tar.gz") if err := p.Service.PullCurrent(gameID, path, cli); err != nil {
fmt.Fprintf(os.Stderr, "error: failed to pull current archive: %s", err)
return subcommands.ExitFailure
}
m, err := cli.Metadata(gameID) ids, err := cli.ListArchives(gameID)
if err != nil { if err != nil {
fmt.Fprintf(os.Stderr, "failed to get metadata: %s", err) fmt.Fprintf(os.Stderr, "error: failed to list backup archive: %s", err)
return subcommands.ExitFailure return subcommands.ExitFailure
} }
err = repository.Register(m, path) for _, id := range ids {
if err != nil { if err := p.Service.PullBackup(gameID, id, cli); err != nil {
fmt.Fprintf(os.Stderr, "failed to register local metadata: %s", err) fmt.Fprintf(os.Stderr, "error: failed to pull backup archive %s: %s", id, err)
return subcommands.ExitFailure return subcommands.ExitFailure
} }
if err := cli.Pull(gameID, archivePath); err != nil {
fmt.Fprintf(os.Stderr, "failed to pull from the remote: %s", err)
return subcommands.ExitFailure
}
fi, err := os.OpenFile(archivePath, os.O_RDONLY, 0)
if err != nil {
fmt.Fprintf(os.Stderr, "failed to open archive: %s", err)
return subcommands.ExitFailure
}
if err := archive.Untar(fi, path); err != nil {
fmt.Fprintf(os.Stderr, "failed to unarchive file: %s", err)
return subcommands.ExitFailure
} }
return subcommands.ExitSuccess return subcommands.ExitSuccess

View File

@@ -1,9 +1,9 @@
package remote package remote
import ( import (
"cloudsave/pkg/data"
"cloudsave/pkg/remote" "cloudsave/pkg/remote"
"cloudsave/pkg/remote/client" "cloudsave/pkg/remote/client"
"cloudsave/pkg/repository"
"context" "context"
"flag" "flag"
"fmt" "fmt"
@@ -14,16 +14,24 @@ import (
type ( type (
RemoteCmd struct { RemoteCmd struct {
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 := repository.All() games, err := p.Service.AllGames()
if err != nil { if err != nil {
return fmt.Errorf("failed to load datastore: %w", err) return fmt.Errorf("failed to load datastore: %w", err)
} }
@@ -70,7 +78,7 @@ func list() error {
for _, g := range games { for _, g := range games {
r, err := remote.One(g.ID) r, err := remote.One(g.ID)
if err != nil { if err != nil {
return fmt.Errorf("failed to load datastore: %w", err) continue
} }
cli := client.New(r.URL, "", "") cli := client.New(r.URL, "", "")

View File

@@ -1,7 +1,7 @@
package remove package remove
import ( import (
"cloudsave/pkg/repository" "cloudsave/pkg/data"
"context" "context"
"flag" "flag"
"fmt" "fmt"
@@ -11,14 +11,18 @@ import (
) )
type ( type (
RemoveCmd struct{} RemoveCmd struct {
Service *data.Service
}
) )
func (*RemoveCmd) Name() string { return "remove" } func (*RemoveCmd) Name() string { return "remove" }
func (*RemoveCmd) Synopsis() string { return "unregister a game" } func (*RemoveCmd) Synopsis() string { return "unregister a game" }
func (*RemoveCmd) Usage() string { func (*RemoveCmd) Usage() string {
return `remove: return `Usage: cloudsave remove <GAME_ID>
Unregister a game
Unregister a game
Caution: all the backup are deleted
` `
} }
@@ -31,7 +35,7 @@ func (p *RemoveCmd) Execute(_ context.Context, f *flag.FlagSet, _ ...interface{}
return subcommands.ExitUsageError return subcommands.ExitUsageError
} }
err := repository.Remove(f.Arg(0)) err := p.Service.RemoveGame(f.Arg(0))
if err != nil { if err != nil {
fmt.Fprintln(os.Stderr, "error: failed to unregister the game:", err) fmt.Fprintln(os.Stderr, "error: failed to unregister the game:", err)
return subcommands.ExitFailure return subcommands.ExitFailure

View File

@@ -1,136 +1,54 @@
package run package run
import ( import (
"cloudsave/pkg/repository" "cloudsave/pkg/data"
"cloudsave/pkg/tools/archive"
"context" "context"
"flag" "flag"
"fmt" "fmt"
"io"
"os" "os"
"path/filepath"
"time"
"github.com/google/subcommands" "github.com/google/subcommands"
"github.com/schollz/progressbar/v3"
) )
type ( type (
RunCmd struct { RunCmd struct {
Service *data.Service
} }
) )
func (*RunCmd) Name() string { return "run" } func (*RunCmd) Name() string { return "scan" }
func (*RunCmd) Synopsis() string { return "Check and process all the folder" } func (*RunCmd) Synopsis() string { return "check and process all the folder" }
func (*RunCmd) Usage() string { func (*RunCmd) Usage() string {
return `run: return `Usage: cloudsave scan
Check and process all the folder
Check if the files have been modified. If so,
the current archive is moved to the backup list
and a new archive is created with a new version number.
` `
} }
func (p *RunCmd) SetFlags(f *flag.FlagSet) {} func (p *RunCmd) SetFlags(f *flag.FlagSet) {}
func (p *RunCmd) Execute(_ context.Context, f *flag.FlagSet, _ ...interface{}) subcommands.ExitStatus { func (p *RunCmd) Execute(_ context.Context, f *flag.FlagSet, _ ...interface{}) subcommands.ExitStatus {
datastore, err := repository.All() datastore, err := p.Service.AllGames()
if err != nil { if err != nil {
fmt.Fprintln(os.Stderr, "error: failed to load datastore:", err) fmt.Fprintln(os.Stderr, "error: failed to load datastore:", err)
return subcommands.ExitFailure return subcommands.ExitFailure
} }
for _, metadata := range datastore { for _, metadata := range datastore {
metadataPath := filepath.Join(repository.DatastorePath(), metadata.ID) changed, err := p.Service.Scan(metadata.ID)
//todo transaction
err := archiveIfChanged(metadata.ID, metadata.Path, filepath.Join(metadataPath, "data.tar.gz"), filepath.Join(metadataPath, ".last_run"))
if err != nil { if err != nil {
fmt.Fprintf(os.Stderr, "error: cannot process the data of %s: %s\n", metadata.ID, err) fmt.Println("❌", metadata.Name, ":", err.Error())
return subcommands.ExitFailure continue
} }
if err := repository.SetVersion(metadata.ID, metadata.Version+1); err != nil { if changed {
fmt.Fprintf(os.Stderr, "error: cannot process the data of %s: %s\n", metadata.ID, err) fmt.Println("✅", metadata.Name, ": backed up")
return subcommands.ExitFailure } else {
fmt.Println("🆗", metadata.Name, ": up to date")
} }
if err := repository.SetDate(metadata.ID, time.Now()); err != nil {
fmt.Fprintf(os.Stderr, "error: cannot process the data of %s: %s\n", metadata.ID, err)
return subcommands.ExitFailure
}
fmt.Println("✅", metadata.Name)
} }
fmt.Println("done.") fmt.Println("done.")
return subcommands.ExitSuccess return subcommands.ExitSuccess
} }
// archiveIfChanged will archive srcDir into destTarGz only if any file
// in srcDir has a modification time > the last run time stored in stateFile.
// After archiving, it updates stateFile to the current time.
func archiveIfChanged(gameID, srcDir, destTarGz, stateFile string) error {
pg := progressbar.New(-1)
destroyPg := func() {
pg.Finish()
pg.Clear()
pg.Close()
}
defer destroyPg()
pg.Describe("Scanning " + gameID + "...")
// load last run time
var lastRun time.Time
data, err := os.ReadFile(stateFile)
if err != nil && !os.IsNotExist(err) {
return fmt.Errorf("failed to reading state file: %w", err)
}
if err == nil {
lastRun, err = time.Parse(time.RFC3339, string(data))
if err != nil {
return fmt.Errorf("parsing state file timestamp: %w", err)
}
}
// check for changes
changed := false
err = filepath.Walk(srcDir, func(path string, info os.FileInfo, walkErr error) error {
if walkErr != nil {
return walkErr
}
if info.ModTime().After(lastRun) {
changed = true
return io.EOF // early exit
}
return nil
})
if err != nil && err != io.EOF {
return fmt.Errorf("failed to scanning source directory: %w", err)
}
if !changed {
pg.Finish()
return nil
}
// make a backup
pg.Describe("Backup current data...")
if err := repository.MakeArchive(gameID); err != nil {
return fmt.Errorf("failed to archive data: %w", err)
}
// create archive
pg.Describe("Archiving new data...")
f, err := os.Create(destTarGz)
if err != nil {
return fmt.Errorf("failed to creating archive file: %w", err)
}
defer f.Close()
if err := archive.Tar(f, srcDir); err != nil {
return fmt.Errorf("failed archiving files: %w", err)
}
now := time.Now().UTC().Format(time.RFC3339)
if err := os.WriteFile(stateFile, []byte(now), 0644); err != nil {
return fmt.Errorf("updating state file: %w", err)
}
return nil
}

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

@@ -3,16 +3,15 @@ package sync
import ( import (
"cloudsave/cmd/cli/tools/prompt" "cloudsave/cmd/cli/tools/prompt"
"cloudsave/cmd/cli/tools/prompt/credentials" "cloudsave/cmd/cli/tools/prompt/credentials"
"cloudsave/pkg/data"
"cloudsave/pkg/remote" "cloudsave/pkg/remote"
"cloudsave/pkg/remote/client" "cloudsave/pkg/remote/client"
"cloudsave/pkg/repository" "cloudsave/pkg/repository"
"cloudsave/pkg/sync"
"context" "context"
"errors"
"flag" "flag"
"fmt" "fmt"
"log/slog"
"os" "os"
"path/filepath"
"time" "time"
"github.com/google/subcommands" "github.com/google/subcommands"
@@ -21,14 +20,16 @@ import (
type ( type (
SyncCmd struct { SyncCmd struct {
Service *data.Service
} }
) )
func (*SyncCmd) Name() string { return "sync" } func (*SyncCmd) Name() string { return "sync" }
func (*SyncCmd) Synopsis() string { return "list all game registered" } func (*SyncCmd) Synopsis() string { return "list all game registered" }
func (*SyncCmd) Usage() string { func (*SyncCmd) Usage() string {
return `add: return `Usage: cloudsave sync
List all game registered
Synchronize the archives with the server defined for each game.
` `
} }
@@ -36,266 +37,92 @@ func (p *SyncCmd) SetFlags(f *flag.FlagSet) {
} }
func (p *SyncCmd) Execute(_ context.Context, f *flag.FlagSet, _ ...interface{}) subcommands.ExitStatus { func (p *SyncCmd) Execute(_ context.Context, f *flag.FlagSet, _ ...interface{}) subcommands.ExitStatus {
games, err := repository.All()
remoteCred := make(map[string]map[string]string)
rs, err := remote.All()
if err != nil { if err != nil {
fmt.Fprintln(os.Stderr, "error: failed to load datastore:", err) fmt.Fprintln(os.Stderr, "error: failed to connect to the remote:", err)
return subcommands.ExitFailure return subcommands.ExitFailure
} }
remoteCred := make(map[string]map[string]string) done := make(map[string]struct{})
for _, g := range games { for _, r := range rs {
r, err := remote.One(g.ID) if _, ok := done[r.URL]; ok {
if err != nil { continue
if errors.Is(err, remote.ErrNoRemote) {
continue
}
fmt.Fprintln(os.Stderr, "error: failed to load datastore:", err)
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) fmt.Println()
done[r.URL] = struct{}{}
syncer := sync.NewSyncer(cli, p.Service)
var pg *progressbar.ProgressBar
destroyPg := func() { destroyPg := func() {
pg.Finish() pg.Finish()
pg.Clear() pg.Clear()
pg.Close() pg.Close()
} }
pg.Describe(fmt.Sprintf("[%s] Checking status...", g.Name)) syncer.SetStateCallback(func(s sync.State, g repository.Metadata) {
exists, err := cli.Exists(r.GameID) switch s {
if err != nil { case sync.FetchingMetdata:
slog.Error(err.Error()) pg = progressbar.New(-1)
continue pg.Describe(fmt.Sprintf("%s: fetching metadata from repository", g.Name))
} case sync.Pushing:
pg.Describe(fmt.Sprintf("%s: pushing data to the server", g.Name))
if !exists { case sync.Pulling:
pg.Describe(fmt.Sprintf("[%s] Pushing data...", g.Name)) pg.Describe(fmt.Sprintf("%s: pull data from the server", g.Name))
if err := push(g, cli); err != nil { case sync.UpToDate:
destroyPg() destroyPg()
fmt.Fprintln(os.Stderr, "failed to push:", err) fmt.Println("🆗", g.Name+": already up-to-date")
return subcommands.ExitFailure case sync.Pushed:
}
pg.Describe(fmt.Sprintf("[%s] Pushing backup...", g.Name))
if err := pushBackup(g, cli); err != nil {
destroyPg() destroyPg()
slog.Warn("failed to push backup files", "err", err) fmt.Println("⬆️", g.Name+": pushed")
} case sync.Pulled:
continue
}
pg.Describe(fmt.Sprintf("[%s] Fetching metadata...", g.Name))
hlocal, err := repository.Hash(r.GameID)
if err != nil {
destroyPg()
slog.Error(err.Error())
continue
}
hremote, err := cli.Hash(r.GameID)
if err != nil {
destroyPg()
fmt.Fprintln(os.Stderr, "error: failed to get the file hash from the remote:", err)
continue
}
vlocal, err := repository.Version(r.GameID)
if err != nil {
destroyPg()
slog.Error(err.Error())
continue
}
remoteMetadata, err := cli.Metadata(r.GameID)
if err != nil {
destroyPg()
fmt.Fprintln(os.Stderr, "error: failed to get the game metadata from the remote:", err)
continue
}
pg.Describe(fmt.Sprintf("[%s] Pulling backup...", g.Name))
if err := pullBackup(g, cli); err != nil {
slog.Warn("failed to pull backup files", "err", err)
}
pg.Describe(fmt.Sprintf("[%s] Pushing backup...", g.Name))
if err := pushBackup(g, cli); err != nil {
slog.Warn("failed to push backup files", "err", err)
}
if hlocal == hremote {
destroyPg()
if vlocal != remoteMetadata.Version {
slog.Debug("version is not the same, but the hash is equal. Updating local database")
if err := repository.SetVersion(r.GameID, remoteMetadata.Version); err != nil {
fmt.Fprintln(os.Stderr, "error: failed to synchronize version number:", err)
continue
}
}
fmt.Println("already up-to-date")
continue
}
if vlocal > remoteMetadata.Version {
pg.Describe(fmt.Sprintf("[%s] Pushing data...", g.Name))
if err := push(g, cli); err != nil {
destroyPg() destroyPg()
fmt.Fprintln(os.Stderr, "failed to push:", err) fmt.Println("⬇️", g.Name+": pulled")
return subcommands.ExitFailure
} }
})
syncer.SetErrorCallback(func(err error, g repository.Metadata) {
destroyPg() destroyPg()
continue fmt.Println("❌", g.Name+": "+err.Error())
} })
if vlocal < remoteMetadata.Version { syncer.SetConflictCallback(func(a, b repository.Metadata) sync.ConflictResolution {
destroyPg() fmt.Println()
if err := pull(r.GameID, cli); err != nil { fmt.Println("--- ⚠️ CONFLICT ---")
destroyPg() fmt.Println(a.Name, "(", a.Path, ")")
fmt.Fprintln(os.Stderr, "failed to push:", err) fmt.Println("----")
return subcommands.ExitFailure fmt.Println("Your version:", a.Date.Format(time.RFC1123))
} fmt.Println("Their version:", b.Date.Format(time.RFC1123))
if err := repository.SetVersion(r.GameID, remoteMetadata.Version); err != nil { fmt.Println()
destroyPg()
fmt.Fprintln(os.Stderr, "error: failed to synchronize version number:", err)
continue
}
if err := repository.SetDate(r.GameID, remoteMetadata.Date); err != nil {
destroyPg()
fmt.Fprintln(os.Stderr, "error: failed to synchronize date:", err)
continue
}
continue
}
destroyPg() res := prompt.Conflict()
if vlocal == remoteMetadata.Version { switch res {
if err := conflict(r.GameID, g, remoteMetadata, cli); err != nil { case prompt.Their:
fmt.Fprintln(os.Stderr, "error: failed to resolve conflict:", err) return sync.Their
continue case prompt.My:
return sync.Mine
} }
continue
} return sync.None
})
syncer.Sync()
} }
fmt.Println("done.") fmt.Println("done.")
return subcommands.ExitSuccess return subcommands.ExitSuccess
} }
func conflict(gameID string, m, remoteMetadata repository.Metadata, cli *client.Client) error {
g, err := repository.One(gameID)
if err != nil {
slog.Warn("a conflict was found but the game is not found in the database")
slog.Debug("debug info", "gameID", gameID)
return nil
}
fmt.Println()
fmt.Println("--- /!\\ CONFLICT ---")
fmt.Println(g.Name, "(", g.Path, ")")
fmt.Println("----")
fmt.Println("Your version:", g.Date.Format(time.RFC1123))
fmt.Println("Their version:", remoteMetadata.Date.Format(time.RFC1123))
fmt.Println()
res := prompt.Conflict()
switch res {
case prompt.My:
{
if err := push(m, cli); err != nil {
return fmt.Errorf("failed to push: %w", err)
}
}
case prompt.Their:
{
if err := pull(gameID, cli); err != nil {
return fmt.Errorf("failed to push: %w", err)
}
if err := repository.SetVersion(gameID, remoteMetadata.Version); err != nil {
return fmt.Errorf("failed to synchronize version number: %w", err)
}
if err := repository.SetDate(gameID, remoteMetadata.Date); err != nil {
return fmt.Errorf("failed to synchronize date: %w", err)
}
}
}
return nil
}
func push(m repository.Metadata, cli *client.Client) error {
archivePath := filepath.Join(repository.DatastorePath(), m.ID, "data.tar.gz")
return cli.PushSave(archivePath, m)
}
func pushBackup(m repository.Metadata, cli *client.Client) error {
bs, err := repository.Archives(m.ID)
if err != nil {
return err
}
for _, b := range bs {
binfo, err := cli.ArchiveInfo(m.ID, b.UUID)
if err != nil {
if !errors.Is(err, client.ErrNotFound) {
return fmt.Errorf("failed to get remote information about the backup file: %w", err)
}
}
if binfo.MD5 != b.MD5 {
if err := cli.PushBackup(b, m); err != nil {
return fmt.Errorf("failed to push backup: %w", err)
}
}
}
return nil
}
func pullBackup(m repository.Metadata, cli *client.Client) error {
bs, err := cli.ListArchives(m.ID)
if err != nil {
return err
}
for _, uuid := range bs {
rinfo, err := cli.ArchiveInfo(m.ID, uuid)
if err != nil {
return err
}
linfo, err := repository.Archive(m.ID, uuid)
if err != nil {
if !errors.Is(err, os.ErrNotExist) {
return err
}
}
path := filepath.Join(repository.DatastorePath(), m.ID, "hist", uuid)
if err := os.MkdirAll(path, 0740); err != nil {
return err
}
if rinfo.MD5 != linfo.MD5 {
if err := cli.PullBackup(m.ID, uuid, filepath.Join(path, "data.tar.gz")); err != nil {
return err
}
}
}
return nil
}
func pull(gameID string, cli *client.Client) error {
archivePath := filepath.Join(repository.DatastorePath(), gameID, "data.tar.gz")
return cli.Pull(gameID, archivePath)
}
func connect(remoteCred map[string]map[string]string, r remote.Remote) (*client.Client, error) { func connect(remoteCred map[string]map[string]string, r remote.Remote) (*client.Client, error) {
var cli *client.Client var cli *client.Client
@@ -304,7 +131,10 @@ func connect(remoteCred map[string]map[string]string, r remote.Remote) (*client.
return cli, nil return cli, nil
} }
username, password, err := credentials.Read() fmt.Println()
fmt.Println("Connexion to", r.URL)
fmt.Println("============")
username, password, err := credentials.Read(r.URL)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to read std output: %w", err) return nil, fmt.Errorf("failed to read std output: %w", err)
} }

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/cmd/cli/tools/prompt/credentials"
"context" "context"
"flag" "flag"
"fmt" "fmt"
@@ -23,8 +23,11 @@ type (
func (*VersionCmd) Name() string { return "version" } func (*VersionCmd) Name() string { return "version" }
func (*VersionCmd) Synopsis() string { return "show version and system information" } func (*VersionCmd) Synopsis() string { return "show version and system information" }
func (*VersionCmd) Usage() string { func (*VersionCmd) Usage() string {
return `add: return `Usage: cloudsave version [-a]
Show version and system information
Print the version of the software
Options:
` `
} }
@@ -39,7 +42,7 @@ func (p *VersionCmd) Execute(_ context.Context, f *flag.FlagSet, _ ...interface{
return subcommands.ExitUsageError return subcommands.ExitUsageError
} }
username, password, err := credentials.Read() username, password, err := credentials.Read(f.Arg(0))
if err != nil { if err != nil {
fmt.Fprintf(os.Stderr, "failed to read std output: %s", err) fmt.Fprintf(os.Stderr, "failed to read std output: %s", err)
return subcommands.ExitFailure return subcommands.ExitFailure

View File

@@ -2,34 +2,64 @@ 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/login"
"cloudsave/cmd/cli/commands/logout"
"cloudsave/cmd/cli/commands/pull" "cloudsave/cmd/cli/commands/pull"
"cloudsave/cmd/cli/commands/remote" "cloudsave/cmd/cli/commands/remote"
"cloudsave/cmd/cli/commands/remove" "cloudsave/cmd/cli/commands/remove"
"cloudsave/cmd/cli/commands/run" "cloudsave/cmd/cli/commands/run"
"cloudsave/cmd/cli/commands/show"
"cloudsave/cmd/cli/commands/sync" "cloudsave/cmd/cli/commands/sync"
"cloudsave/cmd/cli/commands/version" "cloudsave/cmd/cli/commands/version"
"cloudsave/pkg/data"
"cloudsave/pkg/repository"
"context" "context"
"flag" "flag"
"os" "os"
"path/filepath"
"github.com/google/subcommands" "github.com/google/subcommands"
) )
func main() { func main() {
roaming, err := os.UserConfigDir()
if err != nil {
panic("failed to get user config path: " + err.Error())
}
datastorepath := filepath.Join(roaming, "cloudsave", "data")
err = os.MkdirAll(datastorepath, 0740)
if err != nil {
panic("cannot make the datastore:" + err.Error())
}
repo, err := repository.NewLazyRepository(datastorepath)
if err != nil {
panic("cannot make the datastore:" + err.Error())
}
s := data.NewService(repo)
subcommands.Register(subcommands.HelpCommand(), "help") subcommands.Register(subcommands.HelpCommand(), "help")
subcommands.Register(subcommands.FlagsCommand(), "help") subcommands.Register(subcommands.FlagsCommand(), "help")
subcommands.Register(subcommands.CommandsCommand(), "help") subcommands.Register(subcommands.CommandsCommand(), "help")
subcommands.Register(&version.VersionCmd{}, "help") subcommands.Register(&version.VersionCmd{}, "help")
subcommands.Register(&add.AddCmd{}, "management") subcommands.Register(&add.AddCmd{Service: s}, "management")
subcommands.Register(&run.RunCmd{}, "management") subcommands.Register(&run.RunCmd{Service: s}, "management")
subcommands.Register(&list.ListCmd{}, "management") subcommands.Register(&list.ListCmd{Service: s}, "management")
subcommands.Register(&remove.RemoveCmd{}, "management") subcommands.Register(&remove.RemoveCmd{Service: s}, "management")
subcommands.Register(&show.ShowCmd{Service: s}, "management")
subcommands.Register(&remote.RemoteCmd{}, "remote") subcommands.Register(&apply.ApplyCmd{Service: s}, "restore")
subcommands.Register(&sync.SyncCmd{}, "remote")
subcommands.Register(&pull.PullCmd{}, "remote") subcommands.Register(&remote.RemoteCmd{Service: s}, "remote")
subcommands.Register(&sync.SyncCmd{Service: s}, "remote")
subcommands.Register(&pull.PullCmd{Service: s}, "remote")
subcommands.Register(&login.LoginCmd{}, "remote")
subcommands.Register(&logout.LogoutCmd{}, "remote")
flag.Parse() flag.Parse()
ctx := context.Background() ctx := context.Background()

View File

@@ -2,14 +2,59 @@ package credentials
import ( import (
"bufio" "bufio"
"encoding/json"
"errors"
"fmt" "fmt"
"os" "os"
"path/filepath"
"strings" "strings"
"golang.org/x/term" "golang.org/x/term"
) )
func Read() (string, string, error) { type (
credential struct {
Username string `json:"username"`
Password string `json:"password"`
}
credentialsStore struct {
Store map[string]credential `json:"store"`
}
)
var (
datastorePath string
)
func init() {
roaming, err := os.UserConfigDir()
if err != nil {
panic("failed to get user config path: " + err.Error())
}
datastorePath = filepath.Join(roaming, "cloudsave")
}
func Get(server string) (string, string, error) {
var err error
store, err := load()
if err == nil {
if c, ok := store[server]; ok {
return c.Username, c.Password, nil
}
}
return "","",fmt.Errorf("not found")
}
func Read(server string) (string, string, error) {
var err error
store, err := load()
if err == nil {
if c, ok := store[server]; ok {
return c.Username, c.Password, nil
}
}
fmt.Print("Enter username: ") fmt.Print("Enter username: ")
reader := bufio.NewReader(os.Stdin) reader := bufio.NewReader(os.Stdin)
username, _ := reader.ReadString('\n') username, _ := reader.ReadString('\n')
@@ -24,3 +69,66 @@ func Read() (string, string, error) {
return username, string(password), nil return username, string(password), nil
} }
func Login(username, password, server string) error {
store, err := load()
if err != nil {
return err
}
store[server] = credential{
Username: username,
Password: password,
}
return save(store)
}
func Logout(server string) error {
store, err := load()
if err != nil {
return err
}
delete(store, server)
return save(store)
}
func save(store map[string]credential) error {
c := credentialsStore{
Store: store,
}
f, err := os.OpenFile(filepath.Clean(filepath.Join(datastorePath, "credential.json")), os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0740)
if err != nil {
return fmt.Errorf("failed to open datastore: %w", err)
}
defer f.Close()
e := json.NewEncoder(f)
if err := e.Encode(c); err != nil {
return fmt.Errorf("failed to encode data: %w", err)
}
return nil
}
func load() (map[string]credential, error) {
f, err := os.OpenFile(filepath.Clean(filepath.Join(datastorePath, "credential.json")), os.O_RDONLY, 0)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return make(map[string]credential), nil
}
return nil, fmt.Errorf("failed to open datastore: %w", err)
}
defer f.Close()
var c credentialsStore
d := json.NewDecoder(f)
if err := d.Decode(&c); err != nil {
return nil, fmt.Errorf("failed to decode data: %w", err)
}
return c.Store, nil
}

View File

@@ -1,14 +1,10 @@
package api package api
import ( import (
"cloudsave/cmd/server/data" "cloudsave/pkg/data"
"cloudsave/pkg/repository" "cloudsave/pkg/repository"
"crypto/md5"
"encoding/hex"
"encoding/json"
"errors" "errors"
"fmt" "fmt"
"io"
"log/slog" "log/slog"
"net/http" "net/http"
"os" "os"
@@ -23,21 +19,25 @@ import (
type ( type (
HTTPServer struct { HTTPServer struct {
Server *http.Server Server *http.Server
Service *data.Service
documentRoot string documentRoot string
creds map[string]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,
creds: creds,
} }
router := chi.NewRouter() router := chi.NewRouter()
router.NotFound(func(writer http.ResponseWriter, request *http.Request) { router.NotFound(func(writer http.ResponseWriter, request *http.Request) {
notFound("This route does not exist", writer, request) notFound("id not found", writer, request)
}) })
router.MethodNotAllowed(func(writer http.ResponseWriter, request *http.Request) { router.MethodNotAllowed(func(writer http.ResponseWriter, request *http.Request) {
methodNotAllowed(writer, request) methodNotAllowed(writer, request)
@@ -48,7 +48,7 @@ func NewServer(documentRoot string, creds map[string]string, port int) *HTTPServ
router.Use(middleware.Compress(5, "application/gzip")) router.Use(middleware.Compress(5, "application/gzip"))
router.Use(middleware.Heartbeat("/heartbeat")) router.Use(middleware.Heartbeat("/heartbeat"))
router.Route("/api", func(routerAPI chi.Router) { router.Route("/api", func(routerAPI chi.Router) {
routerAPI.Use(BasicAuth("cloudsave", creds)) routerAPI.Use(s.BasicAuth("cloudsave"))
routerAPI.Route("/v1", func(r chi.Router) { routerAPI.Route("/v1", func(r chi.Router) {
// Get information about the server // Get information about the server
r.Get("/version", s.Information) r.Get("/version", s.Information)
@@ -62,7 +62,6 @@ func NewServer(documentRoot string, creds map[string]string, port int) *HTTPServ
gamesRouter.Group(func(saveRouter chi.Router) { gamesRouter.Group(func(saveRouter chi.Router) {
saveRouter.Post("/{id}/data", s.upload) saveRouter.Post("/{id}/data", s.upload)
saveRouter.Get("/{id}/data", s.download) saveRouter.Get("/{id}/data", s.download)
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.Get("/{id}/hist", s.allHist)
@@ -75,50 +74,25 @@ func NewServer(documentRoot string, creds map[string]string, port int) *HTTPServ
}) })
}) })
s.Server = &http.Server{ s.Server = &http.Server{
Addr: fmt.Sprintf(":%d", port), Addr: fmt.Sprintf(":%d", port),
Handler: router, Handler: router,
ReadHeaderTimeout: 2 * time.Second,
} }
return s return s
} }
func (s *HTTPServer) SetCredentials(creds map[string]string) {
s.creds = creds
}
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") datastore, err := s.Service.AllGames()
datastore := make([]repository.Metadata, 0)
if _, err := os.Stat(path); err != nil {
if errors.Is(err, os.ErrNotExist) {
ok(datastore, w, r)
return
}
fmt.Fprintln(os.Stderr, "failed to open datastore (", s.documentRoot, "):", err)
internalServerError(w, r)
return
}
ds, err := os.ReadDir(path)
if err != nil { if err != nil {
fmt.Fprintln(os.Stderr, "failed to open datastore (", s.documentRoot, "):", err) slog.Error(err.Error())
internalServerError(w, r) internalServerError(w, r)
return return
} }
for _, d := range ds {
content, err := os.ReadFile(filepath.Join(path, d.Name(), "metadata.json"))
if err != nil {
slog.Error("error: failed to load metadata.json", "err", err)
continue
}
var m repository.Metadata
err = json.Unmarshal(content, &m)
if err != nil {
fmt.Fprintf(os.Stderr, "corrupted datastore: failed to parse %s/metadata.json: %s", d.Name(), err)
internalServerError(w, r)
}
datastore = append(datastore, m)
}
ok(datastore, w, r) ok(datastore, w, r)
} }
@@ -126,32 +100,19 @@ func (s HTTPServer) download(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id") id := chi.URLParam(r, "id")
path := filepath.Clean(filepath.Join(s.documentRoot, "data", id)) path := filepath.Clean(filepath.Join(s.documentRoot, "data", id))
sdir, err := os.Stat(path) fi, err := os.Stat(filepath.Join(path, "data.tar.gz"))
if err != nil { if err != nil {
notFound("id not found", w, r) notFound("id not found", w, r)
return return
} }
if !sdir.IsDir() { f, err := s.Service.Repository().ReadBlob(repository.NewGameIdentifier(id))
notFound("id not found", w, r)
return
}
path = filepath.Join(path, "data.tar.gz")
f, err := os.OpenFile(path, os.O_RDONLY, 0)
if err != nil { if err != nil {
notFound("id not found", w, r) slog.Error(err.Error())
return
}
defer f.Close()
// Get file info to set headers
fi, err := f.Stat()
if err != nil || fi.IsDir() {
internalServerError(w, r) internalServerError(w, r)
return return
} }
defer f.Close()
// Set headers // Set headers
w.Header().Set("Content-Disposition", "attachment; filename=\"data.tar.gz\"") w.Header().Set("Content-Disposition", "attachment; filename=\"data.tar.gz\"")
@@ -198,14 +159,20 @@ 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)
return
}
if err := s.Service.ReloadCache(id); err != nil {
fmt.Fprintln(os.Stderr, "error: failed to reload data from the disk:", err)
internalServerError(w, r) internalServerError(w, r)
return return
} }
@@ -216,20 +183,9 @@ func (s HTTPServer) upload(w http.ResponseWriter, r *http.Request) {
func (s HTTPServer) allHist(w http.ResponseWriter, r *http.Request) { func (s HTTPServer) allHist(w http.ResponseWriter, r *http.Request) {
gameID := chi.URLParam(r, "id") gameID := chi.URLParam(r, "id")
path := filepath.Join(s.documentRoot, "data", gameID, "hist")
datastore := make([]string, 0) datastore := make([]string, 0)
if _, err := os.Stat(path); err != nil { ds, err := s.Service.AllBackups(gameID)
if errors.Is(err, os.ErrNotExist) {
ok(datastore, w, r)
return
}
fmt.Fprintln(os.Stderr, "failed to open datastore (", s.documentRoot, "):", err)
internalServerError(w, r)
return
}
ds, err := os.ReadDir(path)
if err != nil { if err != nil {
fmt.Fprintln(os.Stderr, "failed to open datastore (", s.documentRoot, "):", err) fmt.Fprintln(os.Stderr, "failed to open datastore (", s.documentRoot, "):", err)
internalServerError(w, r) internalServerError(w, r)
@@ -237,7 +193,7 @@ func (s HTTPServer) allHist(w http.ResponseWriter, r *http.Request) {
} }
for _, d := range ds { for _, d := range ds {
datastore = append(datastore, d.Name()) datastore = append(datastore, d.UUID)
} }
ok(datastore, w, r) ok(datastore, w, r)
@@ -271,8 +227,14 @@ func (s HTTPServer) histUpload(w http.ResponseWriter, r *http.Request) {
} }
defer file.Close() defer file.Close()
if err := data.WriteHist(gameID, s.documentRoot, uuid, file); err != nil { if err := s.Service.CopyBackup(gameID, uuid, file); err != nil {
fmt.Fprintln(os.Stderr, "error: failed to write file to disk:", err) fmt.Fprintln(os.Stderr, "error: failed to write data to the disk:", err)
internalServerError(w, r)
return
}
if err := s.Service.ReloadCache(gameID); err != nil {
fmt.Fprintln(os.Stderr, "error: failed to reload data from the disk:", err)
internalServerError(w, r) internalServerError(w, r)
return return
} }
@@ -286,32 +248,19 @@ func (s HTTPServer) histDownload(w http.ResponseWriter, r *http.Request) {
uuid := chi.URLParam(r, "uuid") uuid := chi.URLParam(r, "uuid")
path := filepath.Clean(filepath.Join(s.documentRoot, "data", id, "hist", uuid)) path := filepath.Clean(filepath.Join(s.documentRoot, "data", id, "hist", uuid))
sdir, err := os.Stat(path) fi, err := os.Stat(filepath.Join(path, "data.tar.gz"))
if err != nil { if err != nil {
notFound("id not found", w, r) notFound("id not found", w, r)
return return
} }
if !sdir.IsDir() { f, err := s.Service.Repository().ReadBlob(repository.NewBackupIdentifier(id, uuid))
notFound("id not found", w, r)
return
}
path = filepath.Join(path, "data.tar.gz")
f, err := os.OpenFile(path, os.O_RDONLY, 0)
if err != nil { if err != nil {
notFound("id not found", w, r) slog.Error(err.Error())
return
}
defer f.Close()
// Get file info to set headers
fi, err := f.Stat()
if err != nil || fi.IsDir() {
internalServerError(w, r) internalServerError(w, r)
return return
} }
defer f.Close()
// Set headers // Set headers
w.Header().Set("Content-Disposition", "attachment; filename=\"data.tar.gz\"") w.Header().Set("Content-Disposition", "attachment; filename=\"data.tar.gz\"")
@@ -327,10 +276,10 @@ func (s HTTPServer) histExists(w http.ResponseWriter, r *http.Request) {
gameID := chi.URLParam(r, "id") gameID := chi.URLParam(r, "id")
uuid := chi.URLParam(r, "uuid") uuid := chi.URLParam(r, "uuid")
finfo, err := data.ArchiveInfo(gameID, s.documentRoot, uuid) finfo, err := s.Service.Backup(gameID, uuid)
if err != nil { if err != nil {
if errors.Is(err, data.ErrBackupNotExists) { if errors.Is(err, repository.ErrNotFound) {
notFound("backup not found", w, r) notFound("not found", w, r)
return return
} }
fmt.Fprintln(os.Stderr, "error: failed to read data:", err) fmt.Fprintln(os.Stderr, "error: failed to read data:", err)
@@ -341,78 +290,18 @@ func (s HTTPServer) histExists(w http.ResponseWriter, r *http.Request) {
ok(finfo, w, r) ok(finfo, w, r)
} }
func (s HTTPServer) hash(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
path := filepath.Clean(filepath.Join(s.documentRoot, "data", id))
sdir, err := os.Stat(path)
if err != nil {
notFound("id not found", w, r)
return
}
if !sdir.IsDir() {
notFound("id not found", w, r)
return
}
path = filepath.Join(path, "data.tar.gz")
f, err := os.OpenFile(path, os.O_RDONLY, 0)
if err != nil {
notFound("id not found", w, r)
return
}
defer f.Close()
// Create MD5 hasher
hasher := md5.New()
// Copy file content into hasher
if _, err := io.Copy(hasher, f); err != nil {
fmt.Fprintln(os.Stderr, "error: an error occured while reading data:", err)
internalServerError(w, r)
return
}
// Get checksum result
sum := hasher.Sum(nil)
ok(hex.EncodeToString(sum), w, r)
}
func (s HTTPServer) metadata(w http.ResponseWriter, r *http.Request) { func (s HTTPServer) metadata(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id") id := chi.URLParam(r, "id")
path := filepath.Clean(filepath.Join(s.documentRoot, "data", id)) metadata, err := s.Service.One(id)
sdir, err := os.Stat(path)
if err != nil { if err != nil {
notFound("id not found", w, r) if errors.Is(err, repository.ErrNotFound) {
return notFound("id not found", w, r)
} return
}
if !sdir.IsDir() { slog.Error(err.Error())
notFound("id not found", w, r)
return
}
path = filepath.Join(path, "metadata.json")
f, err := os.OpenFile(path, os.O_RDONLY, 0)
if err != nil {
notFound("id not found", w, r)
return
}
defer f.Close()
var metadata repository.Metadata
d := json.NewDecoder(f)
err = d.Decode(&metadata)
if err != nil {
fmt.Fprintln(os.Stderr, "error: an error occured while reading data:", err)
internalServerError(w, r) internalServerError(w, r)
return return
} }
ok(metadata, w, r) ok(metadata, w, r)
} }

View File

@@ -20,7 +20,7 @@ func recoverMiddleware(next http.Handler) http.Handler {
} }
// BasicAuth implements a simple middleware handler for adding basic http auth to a route. // 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 { func (s *HTTPServer) BasicAuth(realm string) func(next http.Handler) http.Handler {
return func(next http.Handler) http.Handler { return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
user, pass, ok := r.BasicAuth() user, pass, ok := r.BasicAuth()
@@ -29,7 +29,7 @@ func BasicAuth(realm string, creds map[string]string) func(next http.Handler) ht
return return
} }
credPass := creds[user] credPass := s.creds[user]
if err := bcrypt.CompareHashAndPassword([]byte(credPass), []byte(pass)); err != nil { if err := bcrypt.CompareHashAndPassword([]byte(credPass), []byte(pass)); err != nil {
basicAuthFailed(w, r, realm) basicAuthFailed(w, r, realm)
return return

View File

@@ -3,13 +3,13 @@ package api
import ( import (
"cloudsave/pkg/remote/obj" "cloudsave/pkg/remote/obj"
"encoding/json" "encoding/json"
"log" "log/slog"
"net/http" "net/http"
"time" "time"
) )
func internalServerError(w http.ResponseWriter, r *http.Request) { func internalServerError(w http.ResponseWriter, r *http.Request) {
e := obj.HTTPError{ payload := obj.HTTPError{
HTTPCore: obj.HTTPCore{ HTTPCore: obj.HTTPCore{
Status: http.StatusInternalServerError, Status: http.StatusInternalServerError,
Path: r.RequestURI, Path: r.RequestURI,
@@ -19,20 +19,16 @@ func internalServerError(w http.ResponseWriter, r *http.Request) {
Message: "The server encountered an unexpected condition that prevented it from fulfilling the request.", Message: "The server encountered an unexpected condition that prevented it from fulfilling the request.",
} }
payload, err := json.Marshal(e)
if err != nil {
log.Println(err)
}
w.Header().Add("Content-Type", "application/json") w.Header().Add("Content-Type", "application/json")
w.WriteHeader(http.StatusInternalServerError) w.WriteHeader(http.StatusInternalServerError)
_, err = w.Write(payload) e := json.NewEncoder(w)
if err != nil { if err := e.Encode(payload); err != nil {
log.Println(err) slog.Error(err.Error())
} }
} }
func notFound(message string, w http.ResponseWriter, r *http.Request) { func notFound(message string, w http.ResponseWriter, r *http.Request) {
e := obj.HTTPError{ payload := obj.HTTPError{
HTTPCore: obj.HTTPCore{ HTTPCore: obj.HTTPCore{
Status: http.StatusNotFound, Status: http.StatusNotFound,
Path: r.RequestURI, Path: r.RequestURI,
@@ -42,20 +38,16 @@ func notFound(message string, w http.ResponseWriter, r *http.Request) {
Message: message, Message: message,
} }
payload, err := json.Marshal(e)
if err != nil {
log.Println(err)
}
w.Header().Add("Content-Type", "application/json") w.Header().Add("Content-Type", "application/json")
w.WriteHeader(http.StatusNotFound) w.WriteHeader(http.StatusNotFound)
_, err = w.Write(payload) e := json.NewEncoder(w)
if err != nil { if err := e.Encode(payload); err != nil {
log.Println(err) slog.Error(err.Error())
} }
} }
func methodNotAllowed(w http.ResponseWriter, r *http.Request) { func methodNotAllowed(w http.ResponseWriter, r *http.Request) {
e := obj.HTTPError{ payload := obj.HTTPError{
HTTPCore: obj.HTTPCore{ HTTPCore: obj.HTTPCore{
Status: http.StatusMethodNotAllowed, Status: http.StatusMethodNotAllowed,
Path: r.RequestURI, Path: r.RequestURI,
@@ -65,20 +57,16 @@ func methodNotAllowed(w http.ResponseWriter, r *http.Request) {
Message: "The server knows the request method, but the target resource doesn't support this method", Message: "The server knows the request method, but the target resource doesn't support this method",
} }
payload, err := json.Marshal(e)
if err != nil {
log.Println(err)
}
w.Header().Add("Content-Type", "application/json") w.Header().Add("Content-Type", "application/json")
w.WriteHeader(http.StatusMethodNotAllowed) w.WriteHeader(http.StatusMethodNotAllowed)
_, err = w.Write(payload) e := json.NewEncoder(w)
if err != nil { if err := e.Encode(payload); err != nil {
log.Println(err) slog.Error(err.Error())
} }
} }
func unauthorized(w http.ResponseWriter, r *http.Request) { func unauthorized(w http.ResponseWriter, r *http.Request) {
e := obj.HTTPError{ payload := obj.HTTPError{
HTTPCore: obj.HTTPCore{ HTTPCore: obj.HTTPCore{
Status: http.StatusUnauthorized, Status: http.StatusUnauthorized,
Path: r.RequestURI, Path: r.RequestURI,
@@ -88,21 +76,17 @@ func unauthorized(w http.ResponseWriter, r *http.Request) {
Message: "The request has not been completed because it lacks valid authentication credentials for the requested resource.", Message: "The request has not been completed because it lacks valid authentication credentials for the requested resource.",
} }
payload, err := json.Marshal(e)
if err != nil {
log.Println(err)
}
w.Header().Add("Content-Type", "application/json") w.Header().Add("Content-Type", "application/json")
w.Header().Add("WWW-Authenticate", "Custom realm=\"loginUserHandler via /api/login\"") w.Header().Add("WWW-Authenticate", "Custom realm=\"loginUserHandler via /api/login\"")
w.WriteHeader(http.StatusUnauthorized) w.WriteHeader(http.StatusUnauthorized)
_, err = w.Write(payload) e := json.NewEncoder(w)
if err != nil { if err := e.Encode(payload); err != nil {
log.Println(err) slog.Error(err.Error())
} }
} }
func ok(o interface{}, w http.ResponseWriter, r *http.Request) { func ok(o interface{}, w http.ResponseWriter, r *http.Request) {
e := obj.HTTPObject{ payload := obj.HTTPObject{
HTTPCore: obj.HTTPCore{ HTTPCore: obj.HTTPCore{
Status: http.StatusOK, Status: http.StatusOK,
Path: r.RequestURI, Path: r.RequestURI,
@@ -110,20 +94,15 @@ func ok(o interface{}, w http.ResponseWriter, r *http.Request) {
}, },
Data: o, Data: o,
} }
payload, err := json.Marshal(e)
if err != nil {
log.Println(err)
}
w.Header().Add("Content-Type", "application/json") w.Header().Add("Content-Type", "application/json")
_, err = w.Write(payload) e := json.NewEncoder(w)
if err != nil { if err := e.Encode(payload); err != nil {
log.Println(err) slog.Error(err.Error())
} }
} }
func badRequest(message string, w http.ResponseWriter, r *http.Request) { func badRequest(message string, w http.ResponseWriter, r *http.Request) {
e := obj.HTTPError{ payload := obj.HTTPError{
HTTPCore: obj.HTTPCore{ HTTPCore: obj.HTTPCore{
Status: http.StatusBadRequest, Status: http.StatusBadRequest,
Path: r.RequestURI, Path: r.RequestURI,
@@ -133,14 +112,10 @@ func badRequest(message string, w http.ResponseWriter, r *http.Request) {
Message: message, Message: message,
} }
payload, err := json.Marshal(e)
if err != nil {
log.Println(err)
}
w.Header().Add("Content-Type", "application/json") w.Header().Add("Content-Type", "application/json")
w.WriteHeader(http.StatusBadRequest) w.WriteHeader(http.StatusBadRequest)
_, err = w.Write(payload) e := json.NewEncoder(w)
if err != nil { if err := e.Encode(payload); err != nil {
log.Println(err) slog.Error(err.Error())
} }
} }

View File

@@ -1,131 +0,0 @@
package data
import (
"cloudsave/pkg/repository"
"cloudsave/pkg/tools/hash"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"path/filepath"
)
var (
ErrBackupNotExists error = errors.New("backup not found")
)
func Write(gameID, documentRoot string, r io.Reader) error {
dataFolderPath := filepath.Join(documentRoot, "data", gameID)
partPath := filepath.Join(dataFolderPath, "data.tar.gz.part")
finalFilePath := filepath.Join(dataFolderPath, "data.tar.gz")
if err := makeDataFolder(gameID, documentRoot); err != nil {
return err
}
f, err := os.OpenFile(partPath, os.O_CREATE|os.O_WRONLY, 0740)
if err != nil {
return err
}
if _, err := io.Copy(f, r); err != nil {
f.Close()
if err := os.Remove(partPath); err != nil {
return fmt.Errorf("failed to write the file and cannot clean the folder: %w", err)
}
return fmt.Errorf("failed to write the file: %w", err)
}
f.Close()
if err := os.Rename(partPath, finalFilePath); err != nil {
return err
}
return nil
}
func WriteHist(gameID, documentRoot, uuid string, r io.Reader) error {
dataFolderPath := filepath.Join(documentRoot, "data", gameID, "hist", uuid)
partPath := filepath.Join(dataFolderPath, "data.tar.gz.part")
finalFilePath := filepath.Join(dataFolderPath, "data.tar.gz")
if err := makeDataFolder(gameID, documentRoot); err != nil {
return err
}
if err := os.MkdirAll(dataFolderPath, 0740); err != nil {
return err
}
f, err := os.OpenFile(partPath, os.O_CREATE|os.O_WRONLY, 0740)
if err != nil {
return err
}
if _, err := io.Copy(f, r); err != nil {
f.Close()
if err := os.Remove(partPath); err != nil {
return fmt.Errorf("failed to write the file and cannot clean the folder: %w", err)
}
return fmt.Errorf("failed to write the file: %w", err)
}
f.Close()
if err := os.Rename(partPath, finalFilePath); err != nil {
return err
}
return nil
}
func UpdateMetadata(gameID, documentRoot string, m repository.Metadata) error {
if err := makeDataFolder(gameID, documentRoot); err != nil {
return err
}
path := filepath.Join(documentRoot, "data", gameID, "metadata.json")
f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY, 0740)
if err != nil {
return err
}
defer f.Close()
e := json.NewEncoder(f)
return e.Encode(m)
}
func ArchiveInfo(gameID, documentRoot, uuid string) (repository.Backup, error) {
dataFolderPath := filepath.Join(documentRoot, "data", gameID, "hist", uuid, "data.tar.gz")
finfo, err := os.Stat(dataFolderPath)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return repository.Backup{}, ErrBackupNotExists
}
return repository.Backup{}, err
}
h, err := hash.FileMD5(dataFolderPath)
if err != nil {
return repository.Backup{}, fmt.Errorf("failed to calculate file md5: %w", err)
}
return repository.Backup{
CreatedAt: finfo.ModTime(),
UUID: uuid,
MD5: h,
}, nil
}
func makeDataFolder(gameID, documentRoot string) error {
if err := os.MkdirAll(filepath.Join(documentRoot, "data", gameID), 0740); err != nil {
return err
}
if err := os.MkdirAll(filepath.Join(documentRoot, "data", gameID, "hist"), 0740); err != nil {
return err
}
return nil
}

View File

@@ -5,12 +5,30 @@ package main
import ( import (
"fmt" "fmt"
"os" "os"
"os/signal"
"syscall"
) )
const defaultDocumentRoot string = "/var/lib/cloudsave" const defaultDocumentRoot string = "/var/lib/cloudsave"
var (
updateChan chan struct{}
)
func main() { func main() {
run() updateChan = make(chan struct{})
sigc := make(chan os.Signal, 1)
signal.Notify(sigc, syscall.SIGHUP)
go func() {
for {
<-sigc
updateChan <- struct{}{}
}
}()
run(updateChan)
} }
func fatal(message string, exitCode int) { func fatal(message string, exitCode int) {

View File

@@ -2,16 +2,54 @@ package main
import ( import (
"cloudsave/pkg/tools/windows" "cloudsave/pkg/tools/windows"
_ "embed"
"os" "os"
"github.com/getlantern/systray"
) )
const defaultDocumentRoot string = "C:/ProgramData/CloudSave" const defaultDocumentRoot string = "C:\\ProgramData\\CloudSave"
//go:embed res/icon.ico
var icon []byte
var (
updateChan chan struct{}
)
func main() { func main() {
run() updateChan = make(chan struct{})
go systray.Run(onReady, onExit)
run(updateChan)
} }
func fatal(message string, exitCode int) { func fatal(message string, exitCode int) {
windows.MessageBox(windows.NULL, message, "CloudSave", windows.MB_OK) windows.MessageBox(windows.NULL, message, "CloudSave", windows.MB_OK)
os.Exit(exitCode) os.Exit(exitCode)
} }
func onReady() {
systray.SetTitle("CloudSave")
systray.SetTooltip("CloudSave")
systray.SetIcon(icon)
mReload := systray.AddMenuItem("Reload", "Reload the server data")
mQuit := systray.AddMenuItem("Quit", "Quit the server")
go func() {
<-mQuit.ClickedCh
os.Exit(0)
}()
go func() {
for {
<-mReload.ClickedCh
updateChan <- struct{}{}
}
}()
}
func onExit() {
// clean up here
}

BIN
cmd/server/res/icon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

@@ -4,30 +4,82 @@ 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"
"log/slog"
"path/filepath" "path/filepath"
"runtime" "runtime"
"strconv" "strconv"
) )
func run() { func run(updateChan <-chan struct{}) {
fmt.Printf("CloudSave server -- v%s.%s.%s\n\n", constants.Version, runtime.GOOS, runtime.GOARCH) fmt.Printf("CloudSave server -- v%s.%s.%s\n\n", constants.Version, runtime.GOOS, runtime.GOARCH)
var documentRoot string var documentRoot string
var port int var port int
var noCache, verbose 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.BoolVar(&verbose, "verbose", false, "Show more logs")
flag.Parse() flag.Parse()
if verbose {
slog.SetLogLoggerLevel(slog.LevelDebug)
}
slog.Info("loading .htpasswd")
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)
} }
slog.Info("users loaded: " + strconv.Itoa(len(h.Content())) + " user(s) loaded")
server := api.NewServer(documentRoot, h.Content(), port) var repo repository.Repository
if !noCache {
slog.Info("loading eager repository...")
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)
}
fmt.Println("starting server at :" + strconv.Itoa(port)) repo = r
} else {
slog.Info("loading lazy repository...")
repo, err = repository.NewLazyRepository(filepath.Join(documentRoot, "data"))
if err != nil {
fatal("failed to load datastore: "+err.Error(), 1)
}
}
slog.Info("repository loaded")
s := data.NewService(repo)
server := api.NewServer(documentRoot, s, h.Content(), port)
go func() {
for {
<-updateChan
if r, ok := repo.(*repository.EagerRepository); ok {
if err := r.Reload(); err != nil {
fatal("failed to reload data: "+err.Error(), 1)
}
}
h, err := htpasswd.Open(filepath.Join(documentRoot, ".htpasswd"))
if err != nil {
fatal("failed to load .htpasswd: "+err.Error(), 1)
}
slog.Info("users loaded: " + strconv.Itoa(len(h.Content())) + " user(s) loaded")
server.SetCredentials(h.Content())
}
}()
fmt.Println("server started at :" + strconv.Itoa(port))
if err := server.Server.ListenAndServe(); err != nil { if err := server.Server.ListenAndServe(); err != nil {
fatal("failed to start server: "+err.Error(), 1) fatal("failed to start server: "+err.Error(), 1)
} }

View File

@@ -2,6 +2,7 @@ package htpasswd
import ( import (
"os" "os"
"path/filepath"
"strings" "strings"
) )
@@ -12,7 +13,7 @@ type (
) )
func Open(path string) (File, error) { func Open(path string) (File, error) {
c, err := os.ReadFile(path) c, err := os.ReadFile(filepath.Clean(path))
if err != nil { if err != nil {
return File{}, err return File{}, err
} }
@@ -26,7 +27,7 @@ func Open(path string) (File, error) {
if len(kv) != 2 { if len(kv) != 2 {
continue continue
} }
f.data[kv[0]] = kv[1] f.data[kv[0]] = kv[1]
} }
return f, nil return f, nil

View File

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

View File

@@ -0,0 +1,41 @@
package config
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
)
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(filepath.Clean(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"
"time"
"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")
if _, err := dashboardTemplate.Parse(DashboardHTMLPage); err != nil {
panic("failed to load template 'dashboard': " + err.Error())
}
detailledTemplate := template.New("detailled")
if _, err := detailledTemplate.Parse(DetailledHTMLPage); err != nil {
panic("failed to load template 'detailled': " + err.Error())
}
systemTemplate := template.New("system")
if _, err := systemTemplate.Parse(SystemHTMLPage); err != nil {
panic("failed to load template 'system': " + err.Error())
}
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,
ReadHeaderTimeout: 2 * time.Second,
}
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 error
var save repository.Metadata
var ids []string
wg.Add(1)
go func() {
save, err1 = cli.Metadata(id)
wg.Done()
}()
wg.Add(1)
go func() {
ids, err2 = cli.ListArchives(id)
wg.Done()
}()
wg.Wait()
if err1 != nil || err2 != nil {
if errors.Is(err1, client.ErrUnauthorized) {
unauthorized("Unable to access resources", w, r)
return
}
slog.Error("failed to get metadata: 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("failed to get backup: unable to connect to the remote", "err", err)
return
}
bm = append(bm, b)
}()
}
wg.Wait()
payload := DetaillePayload{
Save: save,
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): {{.Save.MD5}}</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)
}
}

41
go.mod
View File

@@ -3,6 +3,8 @@ module cloudsave
go 1.24 go 1.24
require ( require (
fyne.io/fyne/v2 v2.6.3
github.com/getlantern/systray v1.2.2
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/google/uuid v1.6.0
@@ -12,7 +14,46 @@ require (
) )
require ( require (
fyne.io/systray v1.11.0 // indirect
github.com/BurntSushi/toml v1.4.0 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/fredbi/uri v1.1.0 // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/fyne-io/gl-js v0.2.0 // indirect
github.com/fyne-io/glfw-js v0.3.0 // indirect
github.com/fyne-io/image v0.1.1 // indirect
github.com/fyne-io/oksvg v0.1.0 // indirect
github.com/getlantern/context v0.0.0-20190109183933-c447772a6520 // indirect
github.com/getlantern/errors v0.0.0-20190325191628-abdb3e3e36f7 // indirect
github.com/getlantern/golog v0.0.0-20190830074920-4ef2e798c2d7 // indirect
github.com/getlantern/hex v0.0.0-20190417191902-c6586a6fe0b7 // indirect
github.com/getlantern/hidden v0.0.0-20190325191715-f02dbb02be55 // indirect
github.com/getlantern/ops v0.0.0-20190325191751-d70cb0d6f85f // indirect
github.com/go-gl/gl v0.0.0-20231021071112-07e5d0ea2e71 // indirect
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20240506104042-037f3cc74f2a // indirect
github.com/go-stack/stack v1.8.0 // indirect
github.com/go-text/render v0.2.0 // indirect
github.com/go-text/typesetting v0.2.1 // indirect
github.com/godbus/dbus/v5 v5.1.0 // indirect
github.com/hack-pad/go-indexeddb v0.3.2 // indirect
github.com/hack-pad/safejs v0.1.0 // indirect
github.com/jeandeaual/go-locale v0.0.0-20250612000132-0ef82f21eade // indirect
github.com/jsummers/gobmp v0.0.0-20230614200233-a9de23ed2e25 // indirect
github.com/kr/text v0.2.0 // indirect
github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db // indirect github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db // indirect
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 // indirect
github.com/nicksnyder/go-i18n/v2 v2.5.1 // indirect
github.com/oxtoacart/bpool v0.0.0-20190530202638-03653db5a59c // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/rivo/uniseg v0.4.7 // indirect github.com/rivo/uniseg v0.4.7 // indirect
github.com/rymdport/portal v0.4.1 // indirect
github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c // indirect
github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef // indirect
github.com/stretchr/testify v1.10.0 // indirect
github.com/yuin/goldmark v1.7.8 // indirect
golang.org/x/image v0.24.0 // indirect
golang.org/x/net v0.35.0 // indirect
golang.org/x/sys v0.33.0 // indirect golang.org/x/sys v0.33.0 // indirect
golang.org/x/text v0.25.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
) )

101
go.sum
View File

@@ -1,30 +1,127 @@
fyne.io/fyne/v2 v2.6.3 h1:cvtM2KHeRuH+WhtHiA63z5wJVBkQ9+Ay0UMl9PxFHyA=
fyne.io/fyne/v2 v2.6.3/go.mod h1:NGSurpRElVoI1G3h+ab2df3O5KLGh1CGbsMMcX0bPIs=
fyne.io/systray v1.11.0 h1:D9HISlxSkx+jHSniMBR6fCFOUjk1x/OOOJLa9lJYAKg=
fyne.io/systray v1.11.0/go.mod h1:RVwqP9nYMo7h5zViCBHri2FgjXF7H2cub7MAq4NSoLs=
github.com/BurntSushi/toml v1.4.0 h1:kuoIxZQy2WRRk1pttg9asf+WVv6tWQuBNVmK8+nqPr0=
github.com/BurntSushi/toml v1.4.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
github.com/chengxilo/virtualterm v1.0.4 h1:Z6IpERbRVlfB8WkOmtbHiDbBANU7cimRIof7mk9/PwM= github.com/chengxilo/virtualterm v1.0.4 h1:Z6IpERbRVlfB8WkOmtbHiDbBANU7cimRIof7mk9/PwM=
github.com/chengxilo/virtualterm v1.0.4/go.mod h1:DyxxBZz/x1iqJjFxTFcr6/x+jSpqN0iwWCOK1q10rlY= github.com/chengxilo/virtualterm v1.0.4/go.mod h1:DyxxBZz/x1iqJjFxTFcr6/x+jSpqN0iwWCOK1q10rlY=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/felixge/fgprof v0.9.3 h1:VvyZxILNuCiUCSXtPtYmmtGvb65nqXh2QFWc0Wpf2/g=
github.com/felixge/fgprof v0.9.3/go.mod h1:RdbpDgzqYVh/T9fPELJyV7EYJuHB55UTEULNun8eiPw=
github.com/fredbi/uri v1.1.0 h1:OqLpTXtyRg9ABReqvDGdJPqZUxs8cyBDOMXBbskCaB8=
github.com/fredbi/uri v1.1.0/go.mod h1:aYTUoAXBOq7BLfVJ8GnKmfcuURosB1xyHDIfWeC/iW4=
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/fyne-io/gl-js v0.2.0 h1:+EXMLVEa18EfkXBVKhifYB6OGs3HwKO3lUElA0LlAjs=
github.com/fyne-io/gl-js v0.2.0/go.mod h1:ZcepK8vmOYLu96JoxbCKJy2ybr+g1pTnaBDdl7c3ajI=
github.com/fyne-io/glfw-js v0.3.0 h1:d8k2+Y7l+zy2pc7wlGRyPfTgZoqDf3AI4G+2zOWhWUk=
github.com/fyne-io/glfw-js v0.3.0/go.mod h1:Ri6te7rdZtBgBpxLW19uBpp3Dl6K9K/bRaYdJ22G8Jk=
github.com/fyne-io/image v0.1.1 h1:WH0z4H7qfvNUw5l4p3bC1q70sa5+YWVt6HCj7y4VNyA=
github.com/fyne-io/image v0.1.1/go.mod h1:xrfYBh6yspc+KjkgdZU/ifUC9sPA5Iv7WYUBzQKK7JM=
github.com/fyne-io/oksvg v0.1.0 h1:7EUKk3HV3Y2E+qypp3nWqMXD7mum0hCw2KEGhI1fnBw=
github.com/fyne-io/oksvg v0.1.0/go.mod h1:dJ9oEkPiWhnTFNCmRgEze+YNprJF7YRbpjgpWS4kzoI=
github.com/getlantern/context v0.0.0-20190109183933-c447772a6520 h1:NRUJuo3v3WGC/g5YiyF790gut6oQr5f3FBI88Wv0dx4=
github.com/getlantern/context v0.0.0-20190109183933-c447772a6520/go.mod h1:L+mq6/vvYHKjCX2oez0CgEAJmbq1fbb/oNJIWQkBybY=
github.com/getlantern/errors v0.0.0-20190325191628-abdb3e3e36f7 h1:6uJ+sZ/e03gkbqZ0kUG6mfKoqDb4XMAzMIwlajq19So=
github.com/getlantern/errors v0.0.0-20190325191628-abdb3e3e36f7/go.mod h1:l+xpFBrCtDLpK9qNjxs+cHU6+BAdlBaxHqikB6Lku3A=
github.com/getlantern/golog v0.0.0-20190830074920-4ef2e798c2d7 h1:guBYzEaLz0Vfc/jv0czrr2z7qyzTOGC9hiQ0VC+hKjk=
github.com/getlantern/golog v0.0.0-20190830074920-4ef2e798c2d7/go.mod h1:zx/1xUUeYPy3Pcmet8OSXLbF47l+3y6hIPpyLWoR9oc=
github.com/getlantern/hex v0.0.0-20190417191902-c6586a6fe0b7 h1:micT5vkcr9tOVk1FiH8SWKID8ultN44Z+yzd2y/Vyb0=
github.com/getlantern/hex v0.0.0-20190417191902-c6586a6fe0b7/go.mod h1:dD3CgOrwlzca8ed61CsZouQS5h5jIzkK9ZWrTcf0s+o=
github.com/getlantern/hidden v0.0.0-20190325191715-f02dbb02be55 h1:XYzSdCbkzOC0FDNrgJqGRo8PCMFOBFL9py72DRs7bmc=
github.com/getlantern/hidden v0.0.0-20190325191715-f02dbb02be55/go.mod h1:6mmzY2kW1TOOrVy+r41Za2MxXM+hhqTtY3oBKd2AgFA=
github.com/getlantern/ops v0.0.0-20190325191751-d70cb0d6f85f h1:wrYrQttPS8FHIRSlsrcuKazukx/xqO/PpLZzZXsF+EA=
github.com/getlantern/ops v0.0.0-20190325191751-d70cb0d6f85f/go.mod h1:D5ao98qkA6pxftxoqzibIBBrLSUli+kYnJqrgBf9cIA=
github.com/getlantern/systray v1.2.2 h1:dCEHtfmvkJG7HZ8lS/sLklTH4RKUcIsKrAD9sThoEBE=
github.com/getlantern/systray v1.2.2/go.mod h1:pXFOI1wwqwYXEhLPm9ZGjS2u/vVELeIgNMY5HvhHhcE=
github.com/go-chi/chi/v5 v5.2.1 h1:KOIHODQj58PmL80G2Eak4WdvUzjSJSm0vG72crDCqb8= 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/go-gl/gl v0.0.0-20231021071112-07e5d0ea2e71 h1:5BVwOaUSBTlVZowGO6VZGw2H/zl9nrd3eCZfYV+NfQA=
github.com/go-gl/gl v0.0.0-20231021071112-07e5d0ea2e71/go.mod h1:9YTyiznxEY1fVinfM7RvRcjRHbw2xLBJ3AAGIT0I4Nw=
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20240506104042-037f3cc74f2a h1:vxnBhFDDT+xzxf1jTJKMKZw3H0swfWk9RpWbBbDK5+0=
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20240506104042-037f3cc74f2a/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
github.com/go-stack/stack v1.8.0 h1:5SgMzNM5HxrEjV0ww2lTmX6E2Izsfxas4+YHWRs3Lsk=
github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
github.com/go-text/render v0.2.0 h1:LBYoTmp5jYiJ4NPqDc2pz17MLmA3wHw1dZSVGcOdeAc=
github.com/go-text/render v0.2.0/go.mod h1:CkiqfukRGKJA5vZZISkjSYrcdtgKQWRa2HIzvwNN5SU=
github.com/go-text/typesetting v0.2.1 h1:x0jMOGyO3d1qFAPI0j4GSsh7M0Q3Ypjzr4+CEVg82V8=
github.com/go-text/typesetting v0.2.1/go.mod h1:mTOxEwasOFpAMBjEQDhdWRckoLLeI/+qrQeBCTGEt6M=
github.com/go-text/typesetting-utils v0.0.0-20241103174707-87a29e9e6066 h1:qCuYC+94v2xrb1PoS4NIDe7DGYtLnU2wWiQe9a1B1c0=
github.com/go-text/typesetting-utils v0.0.0-20241103174707-87a29e9e6066/go.mod h1:DDxDdQEnB70R8owOx3LVpEFvpMK9eeH1o2r0yZhFI9o=
github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/google/pprof v0.0.0-20211214055906-6f57359322fd h1:1FjCyPC+syAzJ5/2S8fqdZK1R22vvA0J7JZKcuOIQ7Y=
github.com/google/pprof v0.0.0-20211214055906-6f57359322fd/go.mod h1:KgnwoLYCZ8IQu3XUZ8Nc/bM9CCZFOyjUNOSygVozoDg=
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 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hack-pad/go-indexeddb v0.3.2 h1:DTqeJJYc1usa45Q5r52t01KhvlSN02+Oq+tQbSBI91A=
github.com/hack-pad/go-indexeddb v0.3.2/go.mod h1:QvfTevpDVlkfomY498LhstjwbPW6QC4VC/lxYb0Kom0=
github.com/hack-pad/safejs v0.1.0 h1:qPS6vjreAqh2amUqj4WNG1zIw7qlRQJ9K10eDKMCnE8=
github.com/hack-pad/safejs v0.1.0/go.mod h1:HdS+bKF1NrE72VoXZeWzxFOVQVUSqZJAG0xNCnb+Tio=
github.com/jeandeaual/go-locale v0.0.0-20250612000132-0ef82f21eade h1:FmusiCI1wHw+XQbvL9M+1r/C3SPqKrmBaIOYwVfQoDE=
github.com/jeandeaual/go-locale v0.0.0-20250612000132-0ef82f21eade/go.mod h1:ZDXo8KHryOWSIqnsb/CiDq7hQUYryCgdVnxbj8tDG7o=
github.com/jsummers/gobmp v0.0.0-20230614200233-a9de23ed2e25 h1:YLvr1eE6cdCqjOe972w/cYF+FjW34v27+9Vo5106B4M=
github.com/jsummers/gobmp v0.0.0-20230614200233-a9de23ed2e25/go.mod h1:kLgvv7o6UM+0QSf0QjAse3wReFDsb9qbZJdfexWlrQw=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/lxn/walk v0.0.0-20210112085537-c389da54e794/go.mod h1:E23UucZGqpuUANJooIbHWCufXvOcT6E7Stq81gU+CSQ=
github.com/lxn/win v0.0.0-20210218163916-a377121e959e/go.mod h1:KxxjdtRkfNoYDCUP5ryK7XJJNTnpC8atvtmTheChOtk=
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=
github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db/go.mod h1:l0dey0ia/Uv7NcFFVbCLtqEBQbrT4OCwCSKTEv6enCw= github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db/go.mod h1:l0dey0ia/Uv7NcFFVbCLtqEBQbrT4OCwCSKTEv6enCw=
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ=
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8=
github.com/nicksnyder/go-i18n/v2 v2.5.1 h1:IxtPxYsR9Gp60cGXjfuR/llTqV8aYMsC472zD0D1vHk=
github.com/nicksnyder/go-i18n/v2 v2.5.1/go.mod h1:DrhgsSDZxoAfvVrBVLXoxZn/pN5TXqaDbq7ju94viiQ=
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs=
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
github.com/oxtoacart/bpool v0.0.0-20190530202638-03653db5a59c h1:rp5dCmg/yLR3mgFuSOe4oEnDDmGLROTvMragMUXpTQw=
github.com/oxtoacart/bpool v0.0.0-20190530202638-03653db5a59c/go.mod h1:X07ZCGwUbLaax7L0S3Tw4hpejzu63ZrrQiUe6W0hcy0=
github.com/pkg/profile v1.7.0 h1:hnbDkaNWPCLMO9wGLdBFTIZvzDrDfBM2072E1S9gJkA=
github.com/pkg/profile v1.7.0/go.mod h1:8Uer0jas47ZQMJ7VD+OHknK4YDY07LPUC6dEvqDjvNo=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/rymdport/portal v0.4.1 h1:2dnZhjf5uEaeDjeF/yBIeeRo6pNI2QAKm7kq1w/kbnA=
github.com/rymdport/portal v0.4.1/go.mod h1:kFF4jslnJ8pD5uCi17brj/ODlfIidOxlgUDTO5ncnC4=
github.com/schollz/progressbar/v3 v3.18.0 h1:uXdoHABRFmNIjUfte/Ex7WtuyVslrw2wVPQmCN62HpA= github.com/schollz/progressbar/v3 v3.18.0 h1:uXdoHABRFmNIjUfte/Ex7WtuyVslrw2wVPQmCN62HpA=
github.com/schollz/progressbar/v3 v3.18.0/go.mod h1:IsO3lpbaGuzh8zIMzgY3+J8l4C8GjO0Y9S69eFvNsec= github.com/schollz/progressbar/v3 v3.18.0/go.mod h1:IsO3lpbaGuzh8zIMzgY3+J8l4C8GjO0Y9S69eFvNsec=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966/go.mod h1:sUM3LWHvSMaG192sy56D9F7CNvL7jUJVXoqM1QKLnog=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c h1:km8GpoQut05eY3GiYWEedbTT0qnSxrCjsVbb7yKY1KE=
github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c/go.mod h1:cNQ3dwVJtS5Hmnjxy6AgTPd0Inb3pW05ftPSX7NZO7Q=
github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef h1:Ch6Q+AZUxDBCVqdkI8FSpFyZDtCVBc2VmejdNrm5rRQ=
github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef/go.mod h1:nXTWP6+gD5+LUJ8krVhhoeHjvHTutPxMYl5SvkcnJNE=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/yuin/goldmark v1.7.8 h1:iERMLn0/QJeHFhxSt3p6PeN9mGnvIKSpG9YYorDMnic=
github.com/yuin/goldmark v1.7.8/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8= golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8=
golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw= golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw=
golang.org/x/image v0.24.0 h1:AN7zRgVsbvmTfNyqIbbOraYL8mSwcKncEj8ofjgzcMQ=
golang.org/x/image v0.24.0/go.mod h1:4b/ITuLfqYq1hqZcjofwctIhi7sZh2WaCjvsBNjjya8=
golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8=
golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk=
golang.org/x/sys v0.0.0-20201018230417-eeed37f84f13/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/term v0.32.0 h1:DR4lr0TjUs3epypdhTOkMmuF5CDFJ/8pOnbzMZPQ7bg= golang.org/x/term v0.32.0 h1:DR4lr0TjUs3epypdhTOkMmuF5CDFJ/8pOnbzMZPQ7bg=
golang.org/x/term v0.32.0/go.mod h1:uZG1FhGx848Sqfsq4/DlJr3xGGsYMu/L5GW4abiaEPQ= golang.org/x/term v0.32.0/go.mod h1:uZG1FhGx848Sqfsq4/DlJr3xGGsYMu/L5GW4abiaEPQ=
golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4=
golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA=
gopkg.in/Knetic/govaluate.v3 v3.0.0/go.mod h1:csKLBORsPbafmSCGTEh3U7Ozmsuq8ZSIlKk1bcqph0E=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f h1:BLraFXnmrev5lT+xlilqcH8XK9/i0At2xKjWk4p6zsU=
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

22
jenkinsfile Normal file
View File

@@ -0,0 +1,22 @@
pipeline {
agent any
stages {
stage('Audit') {
steps {
sh '''
go install github.com/securego/gosec/v2/cmd/gosec@v2.22.8
go install honnef.co/go/tools/cmd/staticcheck@v0.6.1
/var/lib/jenkins/go/bin/staticcheck ./...
/var/lib/jenkins/go/bin/gosec -exclude="G401,G501,G103" ./...
'''
}
}
stage('Build') {
steps {
sh './build.sh'
}
}
}
}

View File

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

410
pkg/data/data.go Normal file
View File

@@ -0,0 +1,410 @@
package data
import (
"cloudsave/pkg/remote/client"
"cloudsave/pkg/repository"
"cloudsave/pkg/tools/archive"
"errors"
"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: 0,
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) (bool, error) {
id := repository.NewGameIdentifier(gameID)
lastRun, err := s.repo.LastScan(id)
if err != nil {
return false, fmt.Errorf("failed to get last scan time: %w", err)
}
m, err := s.repo.Metadata(id)
if err != nil {
return false, fmt.Errorf("failed to get game metadata: %w", err)
}
if !IsDirectoryChanged(m.Path, lastRun) {
return false, nil
}
if err := s.MakeBackup(gameID); err != nil {
return false, fmt.Errorf("failed to make the backup: %w", err)
}
f, err := s.repo.WriteBlob(id)
if err != nil {
return false, 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 false, fmt.Errorf("failed to make archive: %w", err)
}
if err := s.repo.ResetLastScan(id); err != nil {
return false, 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 false, fmt.Errorf("failed to update metadata: %w", err)
}
return true, nil
}
func (s *Service) MakeBackup(gameID string) error {
var id repository.Identifier = repository.NewGameIdentifier(gameID)
src, err := s.repo.ReadBlob(id)
if err != nil {
if errors.Is(err, repository.ErrNotFound) {
return 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.Stat(path); err == nil {
if err := os.RemoveAll(path); err != nil {
return fmt.Errorf("failed to clean the destination directory: %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) Repository() repository.Repository {
return l.repo
}
func (l Service) ReloadCache(gameID string) error {
if er, ok := l.repo.(*repository.EagerRepository); ok {
return er.ReloadMetadata(repository.NewGameIdentifier(gameID))
}
return nil
}
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(filepath.Clean(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

@@ -9,10 +9,12 @@ import (
"errors" "errors"
"fmt" "fmt"
"io" "io"
"log/slog"
"mime/multipart" "mime/multipart"
"net/http" "net/http"
"net/url" "net/url"
"os" "os"
"path/filepath"
"strconv" "strconv"
"time" "time"
@@ -36,7 +38,8 @@ type (
) )
var ( var (
ErrNotFound error = errors.New("not found") ErrNotFound error = errors.New("not found")
ErrUnauthorized error = errors.New("unauthorized (HTTP Error 401)")
) )
func New(baseURL, username, password string) *Client { func New(baseURL, username, password string) *Client {
@@ -48,7 +51,7 @@ func New(baseURL, username, password string) *Client {
} }
func (c *Client) Exists(gameID string) (bool, error) { func (c *Client) Exists(gameID string) (bool, error) {
u, err := url.JoinPath(c.baseURL, "api", "v1", "games", gameID, "hash") u, err := url.JoinPath(c.baseURL, "api", "v1", "games", gameID, "metadata")
if err != nil { if err != nil {
return false, err return false, err
} }
@@ -103,22 +106,13 @@ func (c *Client) Version() (Information, error) {
return Information{}, errors.New("invalid payload sent by the server") return Information{}, errors.New("invalid payload sent by the server")
} }
// Deprecated: use c.Metadata instead
func (c *Client) Hash(gameID string) (string, error) { func (c *Client) Hash(gameID string) (string, error) {
u, err := url.JoinPath(c.baseURL, "api", "v1", "games", gameID, "hash") m, err := c.Metadata(gameID)
if err != nil { if err != nil {
return "", err return "", err
} }
return m.MD5, nil
o, err := c.get(u)
if err != nil {
return "", err
}
if h, ok := (o.Data).(string); ok {
return h, nil
}
return "", errors.New("invalid payload sent by the server")
} }
func (c *Client) Metadata(gameID string) (repository.Metadata, error) { func (c *Client) Metadata(gameID string) (repository.Metadata, error) {
@@ -138,6 +132,7 @@ func (c *Client) Metadata(gameID string) (repository.Metadata, error) {
Name: m["name"].(string), Name: m["name"].(string),
Version: int(m["version"].(float64)), Version: int(m["version"].(float64)),
Date: customtime.MustParse(time.RFC3339, m["date"].(string)), Date: customtime.MustParse(time.RFC3339, m["date"].(string)),
MD5: m["md5"].(string),
} }
return gm, nil return gm, nil
} }
@@ -174,6 +169,10 @@ func (c *Client) ListArchives(gameID string) ([]string, error) {
return nil, err return nil, err
} }
if o.Data == nil {
return nil, nil
}
if m, ok := (o.Data).([]any); ok { if m, ok := (o.Data).([]any); ok {
var res []string var res []string
for _, uuid := range m { for _, uuid := range m {
@@ -223,10 +222,15 @@ func (c *Client) Pull(gameID, archivePath string) error {
req.SetBasicAuth(c.username, c.password) req.SetBasicAuth(c.username, c.password)
f, err := os.OpenFile(archivePath+".part", os.O_CREATE|os.O_WRONLY, 0740) f, err := os.OpenFile(filepath.Clean(archivePath+".part"), os.O_CREATE|os.O_WRONLY, 0740)
if err != nil { if err != nil {
return fmt.Errorf("failed to open file: %w", err) return fmt.Errorf("failed to open file: %w", err)
} }
defer func() {
if err := os.Rename(filepath.Clean(archivePath+".part"), archivePath); err != nil {
panic(err)
}
}()
defer f.Close() defer f.Close()
res, err := cli.Do(req) res, err := cli.Do(req)
@@ -249,8 +253,10 @@ func (c *Client) Pull(gameID, archivePath string) error {
return fmt.Errorf("an error occured while copying the file from the remote: %w", err) return fmt.Errorf("an error occured while copying the file from the remote: %w", err)
} }
if err := os.Rename(archivePath+".part", archivePath); err != nil { if err := os.Remove(archivePath); err != nil {
return fmt.Errorf("failed to move temporary data: %w", err) if !errors.Is(err, os.ErrNotExist) {
return fmt.Errorf("failed to remove the old version of the archive: %w", err)
}
} }
return nil return nil
@@ -271,19 +277,24 @@ func (c *Client) PullBackup(gameID, uuid, archivePath string) error {
req.SetBasicAuth(c.username, c.password) req.SetBasicAuth(c.username, c.password)
f, err := os.OpenFile(archivePath+".part", os.O_CREATE|os.O_WRONLY, 0740) f, err := os.OpenFile(filepath.Clean(archivePath+".part"), os.O_CREATE|os.O_WRONLY, 0740)
if err != nil { if err != nil {
return fmt.Errorf("failed to open file: %w", err) return fmt.Errorf("failed to open file: %w", err)
} }
defer f.Close()
res, err := cli.Do(req) res, err := cli.Do(req)
if err != nil { if err != nil {
if err := f.Close(); err != nil {
slog.Error("failed to close file", "err", err)
}
return fmt.Errorf("cannot connect to remote: %w", err) return fmt.Errorf("cannot connect to remote: %w", err)
} }
defer res.Body.Close() defer res.Body.Close()
if res.StatusCode != http.StatusOK { if res.StatusCode != http.StatusOK {
if err := f.Close(); err != nil {
slog.Error("failed to close file", "err", err)
}
return fmt.Errorf("cannot connect to remote: server return code: %s", res.Status) return fmt.Errorf("cannot connect to remote: server return code: %s", res.Status)
} }
@@ -294,8 +305,14 @@ func (c *Client) PullBackup(gameID, uuid, archivePath string) error {
defer bar.Close() defer bar.Close()
if _, err := io.Copy(io.MultiWriter(f, bar), res.Body); err != nil { if _, err := io.Copy(io.MultiWriter(f, bar), res.Body); err != nil {
if err := f.Close(); err != nil {
slog.Error("failed to close file", "err", err)
}
return fmt.Errorf("an error occured while copying the file from the remote: %w", err) return fmt.Errorf("an error occured while copying the file from the remote: %w", err)
} }
if err := f.Close(); err != nil {
slog.Error("failed to close file", "err", err)
}
if err := os.Rename(archivePath+".part", archivePath); err != nil { if err := os.Rename(archivePath+".part", archivePath); err != nil {
return fmt.Errorf("failed to move temporary data: %w", err) return fmt.Errorf("failed to move temporary data: %w", err)
@@ -342,6 +359,10 @@ func (c *Client) All() ([]repository.Metadata, error) {
return nil, err return nil, err
} }
if o.Data == nil {
return nil, nil
}
if games, ok := (o.Data).([]any); ok { if games, ok := (o.Data).([]any); ok {
var res []repository.Metadata var res []repository.Metadata
for _, g := range games { for _, g := range games {
@@ -351,6 +372,7 @@ func (c *Client) All() ([]repository.Metadata, error) {
Name: v["name"].(string), Name: v["name"].(string),
Version: int(v["version"].(float64)), Version: int(v["version"].(float64)),
Date: customtime.MustParse(time.RFC3339, v["date"].(string)), Date: customtime.MustParse(time.RFC3339, v["date"].(string)),
MD5: v["md5"].(string),
} }
res = append(res, gm) res = append(res, gm)
} }
@@ -362,6 +384,10 @@ func (c *Client) All() ([]repository.Metadata, error) {
return nil, errors.New("invalid payload sent by the server") return nil, errors.New("invalid payload sent by the server")
} }
func (c *Client) BaseURL() string {
return c.baseURL
}
func (c *Client) get(url string) (obj.HTTPObject, error) { func (c *Client) get(url string) (obj.HTTPObject, error) {
cli := http.Client{} cli := http.Client{}
@@ -382,6 +408,10 @@ func (c *Client) get(url string) (obj.HTTPObject, error) {
return obj.HTTPObject{}, ErrNotFound return obj.HTTPObject{}, ErrNotFound
} }
if res.StatusCode == 401 {
return obj.HTTPObject{}, ErrUnauthorized
}
if res.StatusCode != 200 { if res.StatusCode != 200 {
return obj.HTTPObject{}, fmt.Errorf("server returns an unexpected status code: %d %s (expected 200)", res.StatusCode, res.Status) return obj.HTTPObject{}, fmt.Errorf("server returns an unexpected status code: %d %s (expected 200)", res.StatusCode, res.Status)
} }
@@ -397,7 +427,7 @@ func (c *Client) get(url string) (obj.HTTPObject, error) {
} }
func (c *Client) push(u, archivePath string, m repository.Metadata) error { func (c *Client) push(u, archivePath string, m repository.Metadata) error {
f, err := os.OpenFile(archivePath, os.O_RDONLY, 0) f, err := os.OpenFile(filepath.Clean(archivePath), os.O_RDONLY, 0)
if err != nil { if err != nil {
return fmt.Errorf("failed to open file: %w", err) return fmt.Errorf("failed to open file: %w", err)
} }
@@ -415,9 +445,15 @@ func (c *Client) push(u, archivePath string, m repository.Metadata) error {
return fmt.Errorf("failed to copy data: %w", err) return fmt.Errorf("failed to copy data: %w", err)
} }
writer.WriteField("name", m.Name) if err := writer.WriteField("name", m.Name); err != nil {
writer.WriteField("version", strconv.Itoa(m.Version)) return err
writer.WriteField("date", m.Date.Format(time.RFC3339)) }
if err := writer.WriteField("version", strconv.Itoa(m.Version)); err != nil {
return err
}
if err := writer.WriteField("date", m.Date.Format(time.RFC3339)); err != nil {
return err
}
if err := writer.Close(); err != nil { if err := writer.Close(); err != nil {
return err return err

View File

@@ -39,7 +39,7 @@ func init() {
} }
func One(gameID string) (Remote, error) { func One(gameID string) (Remote, error) {
content, err := os.ReadFile(filepath.Join(datastorepath, gameID, "remote.json")) content, err := os.ReadFile(filepath.Clean(filepath.Join(datastorepath, gameID, "remote.json")))
if err != nil { if err != nil {
if errors.Is(err, os.ErrNotExist) { if errors.Is(err, os.ErrNotExist) {
return Remote{}, ErrNoRemote return Remote{}, ErrNoRemote
@@ -57,12 +57,34 @@ func One(gameID string) (Remote, error) {
return r, nil return r, nil
} }
func All() ([]Remote, error) {
d, err := os.ReadDir(filepath.Clean(datastorepath))
if err != nil {
return nil, fmt.Errorf("failed to load datastore: %w", err)
}
var res []Remote
for _, g := range d {
r, err := One(g.Name())
if err != nil {
if errors.Is(err, ErrNoRemote) {
continue
}
return nil, fmt.Errorf("failed to load remote: %w", err)
}
res = append(res, r)
}
return res, nil
}
func Set(gameID, url string) error { func Set(gameID, url string) error {
r := Remote{ r := Remote{
URL: url, URL: url,
} }
f, err := os.OpenFile(filepath.Join(datastorepath, gameID, "remote.json"), os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0740) f, err := os.OpenFile(filepath.Join(filepath.Join(datastorepath, gameID, "remote.json")), os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0740)
if err != nil { if err != nil {
return err return err
} }

View File

@@ -2,16 +2,15 @@ package repository
import ( import (
"cloudsave/pkg/tools/hash" "cloudsave/pkg/tools/hash"
"cloudsave/pkg/tools/id"
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"io" "io"
"log/slog"
"os" "os"
"path/filepath" "path/filepath"
"sync"
"time" "time"
"github.com/google/uuid"
) )
type ( type (
@@ -21,6 +20,12 @@ type (
Path string `json:"path"` Path string `json:"path"`
Version int `json:"version"` Version int `json:"version"`
Date time.Time `json:"date"` Date time.Time `json:"date"`
MD5 string `json:"md5,omitempty"`
}
Remote struct {
URL string `json:"url"`
GameID string `json:"-"`
} }
Backup struct { Backup struct {
@@ -29,353 +34,556 @@ type (
UUID string `json:"uuid"` UUID string `json:"uuid"`
ArchivePath string `json:"-"` ArchivePath string `json:"-"`
} }
Data struct {
Metadata Metadata
Remote *Remote
DataPath string
Backup map[string]Backup
}
GameIdentifier struct {
gameID string
}
BackupIdentifier struct {
gameID string
backupID string
}
Identifier interface {
Key() string
}
LazyRepository struct {
dataRoot string
}
EagerRepository struct {
Repository
mu sync.RWMutex
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.ReadSeekCloser, error)
Backup(id BackupIdentifier) (Backup, error)
Remote(id GameIdentifier) (*Remote, error)
SetRemote(gameID GameIdentifier, url string) error
ResetLastScan(id GameIdentifier) error
DataPath(id Identifier) string
Remove(gameID GameIdentifier) error
}
) )
var ( var (
roaming string ErrNotFound error = errors.New("not found")
datastorepath string
) )
func init() { func NewGameIdentifier(gameID string) GameIdentifier {
var err error return GameIdentifier{
roaming, err = os.UserConfigDir() gameID: gameID,
if err != nil {
panic("failed to get user config path: " + err.Error())
} }
}
func (bi GameIdentifier) Key() string {
return bi.gameID
}
datastorepath = filepath.Join(roaming, "cloudsave", "data") func NewBackupIdentifier(gameID, backupID string) BackupIdentifier {
err = os.MkdirAll(datastorepath, 0740) return BackupIdentifier{
if err != nil { gameID: gameID,
panic("cannot make the datastore:" + err.Error()) backupID: backupID,
} }
} }
func Add(name, path string) (Metadata, error) { func (bi BackupIdentifier) Key() string {
m := Metadata{ return bi.gameID + ":" + bi.backupID
ID: id.New(),
Name: name,
Path: path,
}
err := os.MkdirAll(filepath.Join(datastorepath, m.ID), 0740)
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 { func NewLazyRepository(dataRootPath string) (*LazyRepository, error) {
m.Path = path if m, err := os.Stat(dataRootPath); err != nil {
if errors.Is(err, os.ErrNotExist) {
err := os.MkdirAll(filepath.Join(datastorepath, m.ID), 0740) if err := os.MkdirAll(dataRootPath, 0750); err != nil {
if err != nil { return nil, fmt.Errorf("failed to make the directory: %w", err)
panic("cannot make directory for the game:" + err.Error()) }
} else {
return nil, fmt.Errorf("failed to open datastore: %w", err)
}
} else {
if !m.IsDir() {
return nil, fmt.Errorf("failed to open datastore: not a directory")
}
} }
f, err := os.OpenFile(filepath.Join(datastorepath, m.ID, "metadata.json"), os.O_CREATE|os.O_WRONLY, 0740) return &LazyRepository{
if err != nil { dataRoot: dataRootPath,
return fmt.Errorf("cannot open the metadata file in the datastore: %w", err) }, nil
} }
defer f.Close()
e := json.NewEncoder(f) func (l *LazyRepository) Mkdir(id Identifier) error {
err = e.Encode(m) path := l.DataPath(id)
if _, err := os.Stat(path); errors.Is(err, os.ErrNotExist) {
slog.Debug("making directory", "path", path, "id", id, "perm", "0750")
return os.MkdirAll(path, 0750)
}
return nil
}
func (l *LazyRepository) All() ([]string, error) {
slog.Debug("loading all current data...")
dir, err := os.ReadDir(l.dataRoot)
if err != nil { if err != nil {
return fmt.Errorf("cannot write into the metadata file in the datastore: %w", err) return nil, fmt.Errorf("failed to open directory: %w", err)
}
var res []string
for _, d := range dir {
res = append(res, d.Name())
}
return res, nil
}
func (l *LazyRepository) AllHist(id GameIdentifier) ([]string, error) {
path := l.DataPath(id)
slog.Debug("loading hist data...", "id", 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)
slog.Debug("loading write buffer...", "id", ID)
dst, err := os.OpenFile(filepath.Clean(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 {
m.MD5 = ""
path := l.DataPath(id)
slog.Debug("writing metadata", "id", id, "metadata", m)
dst, err := os.OpenFile(filepath.Clean(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 return nil
} }
func All() ([]Metadata, error) { func (l *LazyRepository) Metadata(id GameIdentifier) (Metadata, error) {
ds, err := os.ReadDir(datastorepath) path := l.DataPath(id)
if err != nil {
return nil, fmt.Errorf("cannot open the datastore: %w", err)
}
var datastore []Metadata slog.Debug("loading metadata", "id", id)
for _, d := range ds { src, err := os.OpenFile(filepath.Clean(filepath.Join(path, "metadata.json")), os.O_RDONLY, 0)
content, err := os.ReadFile(filepath.Join(datastorepath, d.Name(), "metadata.json")) if err != nil {
if err != nil { if errors.Is(err, os.ErrNotExist) {
continue return Metadata{}, ErrNotFound
} }
return Metadata{}, fmt.Errorf("corrupted datastore: failed to open metadata: %w", err)
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 var m Metadata
err = json.Unmarshal(content, &m) d := json.NewDecoder(src)
if err := d.Decode(&m); err != nil {
return Metadata{}, fmt.Errorf("corrupted datastore: failed to parse metadata: %w", err)
}
if _, err := os.Stat(filepath.Join(path, "data.tar.gz")); err != nil {
if errors.Is(err, os.ErrNotExist) {
return m, nil
}
return Metadata{}, fmt.Errorf("failed to open archive: %w", err)
}
slog.Debug("loading md5 hash", "id", id)
m.MD5, err = hash.FileMD5(filepath.Join(path, "data.tar.gz"))
if err != nil { if err != nil {
return Metadata{}, fmt.Errorf("corrupted datastore: failed to parse %s/metadata.json: %w", gameID, err) return Metadata{}, fmt.Errorf("failed to calculate md5: %w", err)
} }
return m, nil return m, nil
} }
func MakeArchive(gameID string) error { func (l *LazyRepository) Backup(id BackupIdentifier) (Backup, error) {
path := filepath.Join(datastorepath, gameID, "data.tar.gz") path := l.DataPath(id)
// open old slog.Debug("loading hist metadata", "id", id)
f, err := os.OpenFile(path, os.O_RDONLY, 0) fs, err := os.Stat(filepath.Join(path, "data.tar.gz"))
if err != nil { if err != nil {
if errors.Is(err, os.ErrNotExist) { if errors.Is(err, os.ErrNotExist) {
return nil return Backup{}, ErrNotFound
} }
return fmt.Errorf("failed to open old file: %w", err) return Backup{}, fmt.Errorf("corrupted datastore: failed to open metadata: %w", err)
}
defer f.Close()
histDirPath := filepath.Join(datastorepath, gameID, "hist", uuid.NewString())
if err := os.MkdirAll(histDirPath, 0740); err != nil {
return fmt.Errorf("failed to make directory: %w", err)
} }
// open new slog.Debug("loading md5 hash", "id", id)
nf, err := os.OpenFile(filepath.Join(histDirPath, "data.tar.gz"), os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0740) h, err := hash.FileMD5(filepath.Join(path, "data.tar.gz"))
if err != nil { if err != nil {
return fmt.Errorf("failed to open new file: %w", err) return Backup{}, fmt.Errorf("corrupted datastore: failed to open metadata: %w", err)
}
defer nf.Close()
// copy
if _, err := io.Copy(nf, f); err != nil {
return fmt.Errorf("failed to copy data: %w", err)
} }
return nil return Backup{
} CreatedAt: fs.ModTime(),
func RestoreArchive(gameID, uuid string) error {
histDirPath := filepath.Join(datastorepath, gameID, "hist", uuid)
if err := os.MkdirAll(histDirPath, 0740); err != nil {
return fmt.Errorf("failed to make directory: %w", err)
}
// open old
nf, err := os.OpenFile(filepath.Join(histDirPath, "data.tar.gz"), os.O_RDONLY, 0)
if err != nil {
return fmt.Errorf("failed to open new file: %w", err)
}
defer nf.Close()
path := filepath.Join(datastorepath, gameID, "data.tar.gz")
// open new
f, err := os.OpenFile(path, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0740)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return nil
}
return fmt.Errorf("failed to open old file: %w", err)
}
defer f.Close()
// copy
if _, err := io.Copy(f, nf); err != nil {
return fmt.Errorf("failed to copy data: %w", err)
}
return nil
}
func Archive(gameID, uuid string) (Backup, error) {
histDirPath := filepath.Join(datastorepath, gameID, "hist", uuid)
if err := os.MkdirAll(histDirPath, 0740); err != nil {
return Backup{}, fmt.Errorf("failed to make 'hist' directory")
}
finfo, err := os.Stat(histDirPath)
if err != nil {
return Backup{}, fmt.Errorf("corrupted datastore: %w", err)
}
archivePath := filepath.Join(histDirPath, "data.tar.gz")
h, err := hash.FileMD5(archivePath)
if err != nil {
return Backup{}, fmt.Errorf("failed to calculate md5 hash: %w", err)
}
b := Backup{
CreatedAt: finfo.ModTime(),
UUID: filepath.Base(finfo.Name()),
MD5: h, MD5: h,
ArchivePath: archivePath, UUID: id.backupID,
} ArchivePath: filepath.Join(path, "data.tar.gz"),
}, nil
return b, nil
} }
func Archives(gameID string) ([]Backup, error) { func (l *LazyRepository) LastScan(id GameIdentifier) (time.Time, error) {
histDirPath := filepath.Join(datastorepath, gameID, "hist") path := l.DataPath(id)
if err := os.MkdirAll(histDirPath, 0740); err != nil {
return nil, fmt.Errorf("failed to make 'hist' directory")
}
d, err := os.ReadDir(histDirPath) data, err := os.ReadFile(filepath.Clean(filepath.Join(path, ".last_run")))
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to open 'hist' directory") if errors.Is(err, os.ErrNotExist) {
return time.Time{}, nil
}
return time.Time{}, fmt.Errorf("failed to reading state file: %w", err)
} }
var res []Backup lastRun, err := time.Parse(time.RFC3339, string(data))
for _, f := range d { if err != nil {
finfo, err := f.Info() 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)
slog.Debug("resetting last scan datetime for", "id", id)
f, err := os.OpenFile(filepath.Clean(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.ReadSeekCloser, error) {
path := l.DataPath(id)
slog.Debug("loading read buffer...", "id", id)
dst, err := os.OpenFile(filepath.Clean(filepath.Join(path, "data.tar.gz")), os.O_RDONLY, 0)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return nil, fmt.Errorf("failed to open blob: %w", ErrNotFound)
}
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.Clean(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.Clean(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)
slog.Debug("removing data", "id", 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 {
r.mu.Lock()
defer r.mu.Unlock()
slog.Info("loading data from datastore to memory...")
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 { if err != nil {
return nil, fmt.Errorf("corrupted datastore: %w", err) return fmt.Errorf("[%s] failed to load hist data: %w", g, err)
} }
path := filepath.Join(histDirPath, finfo.Name())
archivePath := filepath.Join(path, "data.tar.gz")
h, err := hash.FileMD5(archivePath) remote, err := r.Repository.Remote(NewGameIdentifier(g))
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to calculate md5 hash: %w", err) return fmt.Errorf("[%s] failed to load remote metadata: %w", g, err)
} }
b := Backup{ m, err := r.Repository.Metadata(NewGameIdentifier(g))
CreatedAt: finfo.ModTime(), if err != nil {
UUID: filepath.Base(finfo.Name()), return fmt.Errorf("[%s] failed to load metadata: %w", g, err)
MD5: h,
ArchivePath: archivePath,
} }
res = append(res, b) 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) ClearCache() {
r.mu.Lock()
defer r.mu.Unlock()
slog.Info("clearing cache...")
for k := range r.data {
delete(r.data, k)
}
}
func (r *EagerRepository) Reload() error {
r.ClearCache()
return r.Preload()
}
func (r *EagerRepository) All() ([]string, error) {
r.mu.RLock()
defer r.mu.RUnlock()
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) {
r.mu.RLock()
defer r.mu.RUnlock()
var res []string
if d, ok := r.data[id.gameID]; ok {
for _, b := range d.Backup {
res = append(res, b.UUID)
}
} }
return res, nil return res, nil
} }
func DatastorePath() string { func (r *EagerRepository) WriteMetadata(id GameIdentifier, m Metadata) error {
return datastorepath r.mu.Lock()
} defer r.mu.Unlock()
func Remove(gameID string) error { err := r.Repository.WriteMetadata(id, m)
err := os.RemoveAll(filepath.Join(datastorepath, gameID))
if err != nil {
return err
}
return nil
}
func Hash(gameID string) (string, error) {
path := filepath.Join(datastorepath, gameID, "data.tar.gz")
return hash.FileMD5(path)
}
func Version(gameID string) (int, error) {
path := filepath.Join(datastorepath, gameID, "metadata.json")
f, err := os.OpenFile(path, os.O_RDONLY, 0)
if err != nil {
return 0, err
}
defer f.Close()
var metadata Metadata
d := json.NewDecoder(f)
err = d.Decode(&metadata)
if err != nil {
return 0, err
}
return metadata.Version, nil
}
func SetVersion(gameID string, version int) error {
path := filepath.Join(datastorepath, gameID, "metadata.json")
f, err := os.OpenFile(path, os.O_RDONLY, 0)
if err != nil { if err != nil {
return err return err
} }
var metadata Metadata d := r.data[id.gameID]
d := json.NewDecoder(f) d.Metadata = m
err = d.Decode(&metadata) r.data[id.gameID] = d
if err != nil {
f.Close()
return err
}
f.Close()
metadata.Version = version
f, err = os.OpenFile(path, os.O_WRONLY|os.O_TRUNC, 0740)
if err != nil {
return err
}
defer f.Close()
e := json.NewEncoder(f)
err = e.Encode(metadata)
if err != nil {
return err
}
return nil return nil
} }
func SetDate(gameID string, dt time.Time) error { func (r *EagerRepository) Metadata(id GameIdentifier) (Metadata, error) {
path := filepath.Join(datastorepath, gameID, "metadata.json") r.mu.RLock()
defer r.mu.RUnlock()
f, err := os.OpenFile(path, os.O_RDONLY, 0) if d, ok := r.data[id.gameID]; ok {
return d.Metadata, nil
}
return Metadata{}, ErrNotFound
}
func (r *EagerRepository) Backup(id BackupIdentifier) (Backup, error) {
r.mu.RLock()
defer r.mu.RUnlock()
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 {
r.mu.Lock()
defer r.mu.Unlock()
err := r.Repository.SetRemote(id, url)
if err != nil { if err != nil {
return err return err
} }
var metadata Metadata d := r.data[id.gameID]
d := json.NewDecoder(f) d.Remote = &Remote{
err = d.Decode(&metadata) URL: url,
if err != nil { GameID: d.Metadata.ID,
f.Close() }
r.data[id.gameID] = d
return nil
}
func (r *EagerRepository) Remove(id GameIdentifier) error {
r.mu.Lock()
defer r.mu.Unlock()
if err := r.Repository.Remove(id); err != nil {
return err return err
} }
f.Close() delete(r.data, id.gameID)
return nil
}
metadata.Date = dt func (r *EagerRepository) ReloadMetadata(id GameIdentifier) error {
backup, err := r.Repository.AllHist(id)
f, err = os.OpenFile(path, os.O_WRONLY|os.O_TRUNC, 0740)
if err != nil { if err != nil {
return err return fmt.Errorf("[%s] failed to load hist data: %w", id, err)
} }
defer f.Close()
e := json.NewEncoder(f) remote, err := r.Repository.Remote(id)
err = e.Encode(metadata)
if err != nil { if err != nil {
return err return fmt.Errorf("[%s] failed to load remote metadata: %w", id, err)
}
m, err := r.Repository.Metadata(id)
if err != nil {
return fmt.Errorf("[%s] failed to load metadata: %w", id, err)
}
backups := make(map[string]Backup)
for _, b := range backup {
info, err := r.Repository.Backup(NewBackupIdentifier(id.gameID, b))
if err != nil {
return fmt.Errorf("[%s] failed to get backup information: %w", id, err)
}
backups[b] = info
}
r.data[id.gameID] = Data{
Metadata: m,
Remote: remote,
DataPath: r.DataPath(id),
Backup: backups,
} }
return nil return nil

237
pkg/sync/sync.go Normal file
View File

@@ -0,0 +1,237 @@
package sync
import (
"cloudsave/pkg/data"
"cloudsave/pkg/remote"
"cloudsave/pkg/remote/client"
"cloudsave/pkg/repository"
"errors"
"fmt"
)
type (
ConflictResolution int
State int
decision int
Syncer struct {
cli *client.Client
service *data.Service
stateCallback func(s State, g repository.Metadata)
errorCallback func(err error, g repository.Metadata)
conflictCallback func(a, b repository.Metadata) ConflictResolution
}
)
const (
Their ConflictResolution = iota
Mine
None
)
const (
ignore decision = iota
push
pull
)
const (
FetchingMetdata State = iota
Pushing
Pulling
Pushed
Pulled
UpToDate
Done
)
var (
ErrFetching error = errors.New("failed to fetch the metadata")
ErrPushing error = errors.New("failed to push data")
ErrPulling error = errors.New("failed to pull data")
ErrDatastore error = errors.New("failed to get data from local datastore")
)
func NewSyncer(cli *client.Client, service *data.Service) *Syncer {
return &Syncer{
cli: cli,
service: service,
}
}
func (s *Syncer) SetStateCallback(fn func(s State, g repository.Metadata)) {
s.stateCallback = fn
}
func (s *Syncer) SetErrorCallback(fn func(err error, g repository.Metadata)) {
s.errorCallback = fn
}
func (s *Syncer) SetConflictCallback(fn func(a, b repository.Metadata) ConflictResolution) {
s.conflictCallback = fn
}
func (s *Syncer) Sync() {
games, err := s.service.AllGames()
if err != nil {
s.errorCallback(fmt.Errorf("failed to get all games: %w", err), repository.Metadata{})
return
}
for _, g := range games {
r, err := remote.One(g.ID)
if err != nil {
continue
}
if r.URL != s.cli.BaseURL() {
continue
}
if err := s.sync(g); err != nil {
s.errorCallback(err, g)
}
}
s.stateCallback(Done, repository.Metadata{})
}
func (s *Syncer) sync(g repository.Metadata) error {
s.stateCallback(FetchingMetdata, g)
remoteMetadata, err := s.cli.Metadata(g.ID)
if err != nil {
if errors.Is(err, client.ErrNotFound) {
s.stateCallback(Pushing, g)
if err := s.push(g); err != nil {
return fmt.Errorf("%w: %s", ErrPushing, err)
}
s.stateCallback(Pushed, g)
return nil
}
return fmt.Errorf("%w: %s", ErrFetching, err)
}
if g.MD5 == remoteMetadata.MD5 {
s.stateCallback(UpToDate, g)
return nil
}
d := ignore
if g.Version > remoteMetadata.Version {
d = push
}
if g.Version < remoteMetadata.Version {
d = pull
}
if g.Version == remoteMetadata.Version {
r := s.conflictCallback(g, remoteMetadata)
switch r {
case Mine:
{
d = push
}
case Their:
{
d = pull
}
}
return nil
}
switch d {
case push:
{
s.stateCallback(Pushing, g)
if err := s.push(g); err != nil {
return fmt.Errorf("%w: %s", ErrPushing, err)
}
s.stateCallback(Pushed, g)
return nil
}
case pull:
{
s.stateCallback(Pulling, g)
if err := s.pull(g, remoteMetadata); err != nil {
return fmt.Errorf("%w: %s", ErrPulling, err)
}
s.stateCallback(Pulled, g)
return nil
}
}
return nil
}
func (s *Syncer) push(g repository.Metadata) error {
if err := s.service.PushArchive(g.ID, "", s.cli); err != nil {
return err
}
// manage backup
bs, err := s.service.AllBackups(g.ID)
if err != nil {
return err
}
for _, b := range bs {
binfo, err := s.cli.ArchiveInfo(g.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 := s.cli.PushBackup(b, g); err != nil {
return fmt.Errorf("failed to push backup: %w", err)
}
}
}
return nil
}
func (s *Syncer) pull(g, r repository.Metadata) error {
g.Version = r.Version
g.Date = r.Date
if err := s.service.UpdateMetadata(g.ID, g); err != nil {
return err
}
if err := s.service.PullArchive(g.ID, "", s.cli); err != nil {
return err
}
if err := s.service.ApplyCurrent(g.ID); err != nil {
return err
}
// manage backup
bs, err := s.cli.ListArchives(g.ID)
if err != nil {
return err
}
for _, uuid := range bs {
rinfo, err := s.cli.ArchiveInfo(g.ID, uuid)
if err != nil {
return err
}
linfo, err := s.service.Backup(g.ID, uuid)
if err != nil {
return err
}
if linfo.MD5 != rinfo.MD5 {
if err := s.service.PullBackup(g.ID, uuid, s.cli); err != nil {
return err
}
}
}
return nil
}

View File

@@ -5,10 +5,17 @@ import (
"compress/gzip" "compress/gzip"
"fmt" "fmt"
"io" "io"
"log/slog"
"os" "os"
"path/filepath" "path/filepath"
) )
const (
// Tune these to your apps needs
maxCompressedUpload = 500 << 20 // 500 MiB compressed
maxUncompressedOutput = 1000 << 20 // 100 MiB after inflate
)
func Untar(file io.Reader, path string) error { func Untar(file io.Reader, path string) error {
gzr, err := gzip.NewReader(file) gzr, err := gzip.NewReader(file)
if err != nil { if err != nil {
@@ -37,7 +44,7 @@ func Untar(file io.Reader, path string) error {
} }
// the target location where the dir/file should be created // the target location where the dir/file should be created
target := filepath.Join(path, header.Name) target := filepath.Clean(filepath.Join(path, filepath.Clean(header.Name)))
// the following switch could also be done using fi.Mode(), not sure if there // the following switch could also be done using fi.Mode(), not sure if there
// a benefit of using one vs. the other. // a benefit of using one vs. the other.
@@ -49,26 +56,35 @@ func Untar(file io.Reader, path string) error {
// if its a dir and it doesn't exist create it // if its a dir and it doesn't exist create it
case tar.TypeDir: case tar.TypeDir:
if _, err := os.Stat(target); err != nil { if _, err := os.Stat(target); err != nil {
if err := os.MkdirAll(target, 0755); err != nil { if err := os.MkdirAll(target, 0740); err != nil {
return err return err
} }
} }
// if it's a file create it // if it's a file create it
case tar.TypeReg: case tar.TypeReg:
f, err := os.OpenFile(target, os.O_CREATE|os.O_RDWR, os.FileMode(header.Mode)) f, err := os.OpenFile(target, os.O_CREATE|os.O_RDWR, header.FileInfo().Mode())
if err != nil { if err != nil {
return err return err
} }
limited := &io.LimitedReader{R: gzr, N: maxUncompressedOutput}
// copy over contents // copy over contents
if _, err := io.Copy(f, tr); err != nil { if _, err := io.Copy(f, limited); err != nil {
return err return err
} }
// manually close here after each file operation; defering would cause each file close // manually close here after each file operation; defering would cause each file close
// to wait until all operations have completed. // to wait until all operations have completed.
f.Close() if err := f.Close(); err != nil {
slog.Error("failed to close file", "err", err)
}
if limited.N == 0 {
// Limit exhausted → likely bomb
return fmt.Errorf("payload too large after decompression")
}
} }
} }
} }
@@ -106,7 +122,7 @@ func Tar(file io.Writer, root string) error {
return nil return nil
} }
file, err := os.Open(path) file, err := os.Open(filepath.Clean(path))
if err != nil { if err != nil {
return fmt.Errorf("failed to open file: %w", err) return fmt.Errorf("failed to open file: %w", err)
} }

View File

@@ -5,10 +5,11 @@ import (
"encoding/hex" "encoding/hex"
"io" "io"
"os" "os"
"path/filepath"
) )
func FileMD5(fp string) (string, error) { func FileMD5(fp string) (string, error) {
f, err := os.OpenFile(fp, os.O_RDONLY, 0) f, err := os.OpenFile(filepath.Clean(fp), os.O_RDONLY, 0)
if err != nil { if err != nil {
return "", err return "", err
} }

View File

@@ -0,0 +1,40 @@
package iterator
type (
Iterator[T any] struct {
index int
values []T
}
)
func New[T any](values []T) *Iterator[T] {
return &Iterator[T]{
values: values,
}
}
func (it *Iterator[T]) Next() bool {
if len(it.values) == it.index {
return false
}
it.index += 1
return true
}
func (it *Iterator[T]) Value() T {
if len(it.values) == 0 {
var zero T
return zero
}
if len(it.values) == it.index {
var zero T
return zero
}
return it.values[it.index]
}
func (it *Iterator[T]) IsEmpty() bool {
return len(it.values) == 0
}