23 Commits
0.0.4d ... dev

Author SHA1 Message Date
b4811fba4a wip 2026-05-08 14:06:08 +02:00
b314a683c9 fixes 2025-11-19 19:08:21 +01:00
8e18f2ce76 remove useless subcommand 2025-11-19 18:48:35 +01:00
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
37 changed files with 1332 additions and 341 deletions

13
.dockerignore Normal file
View File

@@ -0,0 +1,13 @@
/cli
/server
/web
/gui
/env/
/build/
*.exe
/config.json
/.codex
/.vscode
# for docker-compose.yml
/data

8
.gitignore vendored
View File

@@ -1,7 +1,13 @@
/cli
/server
/web
/gui
/env/
/build/
*.exe
/config.json
/config.json
/.codex
/.vscode
# for docker-compose.yml
/data

260
README.md
View File

@@ -1,58 +1,270 @@
# CloudSave
The software is still in alpha.
CloudSave is a small client/server tool to keep save folders in sync across multiple computers.
It is aimed at games that do not provide their own cloud sync, such as emulators, old games, or any title that stores progress in a local directory.
A client/server that allows unsynchronized games (such as emulators, old games, etc.) to be kept up to date on multiple computers.
The project is still in alpha.
## What Is In The Repository
This repository currently contains three Go binaries:
- `cmd/cli`: the end-user CLI (`cloudsave`)
- `cmd/server`: the HTTP API server
- `cmd/web`: a small read-only web UI that talks to the API server
## Build
You need go1.24
The module targets Go `1.24` in [go.mod](/home/aurelie/src/cloudsave/go.mod), while the container image builds with Go `1.26.3` from [dockerfile](/home/aurelie/src/cloudsave/dockerfile). In practice, using a recent Go toolchain is recommended.
After downloading the go toolchain, just run the script `./build.sh`
To build all binaries for the platforms configured in the project:
## 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.:
```bash
./build.sh
```
Artifacts are written to `./build`.
If you only want one binary, you can also build it directly:
```bash
go build -o cloudsave ./cmd/cli
go build -o cloudsave_server ./cmd/server
go build -o cloudsave_web ./cmd/web
```
## Server
The server exposes an authenticated HTTP API on port `8080` by default.
### Data Directory
By default, the server uses:
```text
/var/lib/cloudsave
```
You can override it with:
```bash
cloudsave_server -document-root /path/to/cloudsave-data
```
Inside this directory, the server expects:
- `.htpasswd`: credentials file
- `data/`: stored save archives and metadata
### Authentication
The API uses HTTP Basic Auth. Credentials are read from `.htpasswd`.
Example:
```text
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 code currently expects bcrypt hashes when validating passwords.
The default path to this directory is `/var/lib/cloudsave`, this can be changed with the `-document-root` argument
### Start The Server
### Client
```bash
cloudsave_server
```
#### Register a game
Useful flags from [cmd/server/runner.go](/home/aurelie/src/cloudsave/cmd/server/runner.go):
- `-document-root`: change the storage directory
- `-port`: change the listening port
- `-no-cache`: use the lazy repository instead of the eager cache
- `-verbose`: enable more logs
On non-Windows systems, sending `SIGHUP` reloads the eager cache and the `.htpasswd` file.
## Docker
The repository contains a server-only container setup:
- [dockerfile](/home/aurelie/src/cloudsave/dockerfile)
- [docker-compose.yml](/home/aurelie/src/cloudsave/docker-compose.yml)
Run it with:
```bash
docker compose up --build
```
This maps:
- port `8080`
- local `./data` to `/var/lib/cloudsave`
Before starting the container, you still need to create `./data/.htpasswd`.
## Client
The CLI stores its local database in the user config directory under `cloudsave/data`.
It keeps:
- per-game metadata
- current local archive
- backup archives
- `remote.json` per game when a remote is configured
Saved credentials are stored separately in `credential.json`.
Important: the `login` command stores credentials in plain text. This is also stated in the code.
## Typical Workflow
### 1. 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
You can override the displayed name:
```bash
cloudsave add -name "My Game" -remote "http://localhost:8080" /home/user/gamedata
cloudsave add -name "My Game" /home/user/gamedata
```
#### Make an archive of the current state
Note: the `-remote` flag exists on `add`, but the current implementation does not persist it. Use `cloudsave remote -set` after `add`.
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
### 2. List registered games
```bash
cloudsave list
```
To include local backup IDs:
```bash
cloudsave list -include-backup
```
### 3. Create or refresh the local archive
```bash
cloudsave scan
```
#### Send everything on the server
This will pull and push data to the server.
This scans all registered folders. If a folder changed since the last scan, the current archive is moved to the backup history and a new `data.tar.gz` archive is created.
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
### 4. Configure the remote server for a game
```bash
cloudsave remote -set GAME_ID http://localhost:8080
```
To list configured remotes:
```bash
cloudsave remote -list
```
### 5. Save credentials locally
```bash
cloudsave login http://localhost:8080
```
This verifies the credentials against the server and then stores them locally in plain text.
### 6. Synchronize with the server
```bash
cloudsave sync
```
The sync command:
- groups games by remote URL
- authenticates once per remote
- compares local and remote metadata
- pushes or pulls as needed
- asks for a resolution if versions conflict
### 7. Restore a save locally
Apply the latest local archive for a game:
```bash
cloudsave apply GAME_ID
```
Apply a specific backup:
```bash
cloudsave apply GAME_ID BACKUP_ID
```
## Other CLI Commands
Show metadata for one game:
```bash
cloudsave show GAME_ID
```
Pull one game and its backups from a remote into a local path:
```bash
cloudsave pull http://localhost:8080 GAME_ID /path/to/restore
```
Show local version information:
```bash
cloudsave version
```
Show remote version information:
```bash
cloudsave version -a http://localhost:8080
```
Remove a registered game and its local backups:
```bash
cloudsave remove GAME_ID
```
## Web UI
The repository also contains a small web frontend in `cmd/web`.
It uses a JSON config file, for example:
```json
{
"server": {
"port": 8081
},
"remote": {
"url": "http://localhost:8080"
}
}
```
Then start it with:
```bash
cloudsave_web -config /path/to/config.json
```
The web UI itself does not manage users. It forwards HTTP Basic Auth credentials to the configured API server.
## Current Caveats
These points are worth knowing in the current state of the project:
- the software is still alpha
- credentials saved by `cloudsave login` are stored in plain text
- the Docker setup only runs the API server, not the web UI
- `add -remote` is exposed by the CLI but is not currently persisted by the service layer

24
api.Dockerfile Normal file
View File

@@ -0,0 +1,24 @@
FROM golang:1.26.3-trixie AS build
ENV GOOS=linux
ENV CGO_ENABLED=0
ENV GOAMD64=v3
ENV GORISCV64=rva22u64
ENV GOARM64=v8.2
COPY . /src
RUN cd /src \
&& go build -ldflags="-s -w" -o server ./cmd/server \
&& chown 0:0 server \
&& chmod ugo+x server
FROM busybox:1.37.0 AS prod
COPY --from=build /etc/passwd /etc/passwd
COPY --from=build /etc/shadow /etc/shadow
COPY --from=build /src/server /server
VOLUME [ "/var/lib/cloudsave" ]
ENTRYPOINT [ "/server" ]

View File

@@ -35,7 +35,7 @@ fi
## 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
echo "* Compiling server for $platform..."
@@ -48,10 +48,24 @@ for platform in "${platforms[@]}"; do
if [ "$MAKE_PACKAGE" == "true" ]; then
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
if [ $? -ne 0 ]; then
exit 1
fi
rm build/cloudsave_server$EXT
if [ $? -ne 0 ]; then
exit 1
fi
else
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
@@ -70,10 +84,24 @@ for platform in "${platforms[@]}"; do
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
done
@@ -92,9 +120,23 @@ for platform in "${platforms[@]}"; do
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
if [ $? -ne 0 ]; then
exit 1
fi
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
if [ $? -ne 0 ]; then
exit 1
fi
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
if [ $? -ne 0 ]; then
exit 1
fi
fi
done

View File

@@ -43,7 +43,7 @@ func (p *ListCmd) Execute(_ context.Context, f *flag.FlagSet, _ ...interface{})
return subcommands.ExitUsageError
}
username, password, err := credentials.Read()
username, password, err := credentials.Read(f.Arg(0))
if err != nil {
fmt.Fprintf(os.Stderr, "error: failed to read std output: %s", err)
return subcommands.ExitFailure

View File

@@ -0,0 +1,61 @@
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.Fprintln(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\n", err)
return subcommands.ExitFailure
}
if err := credentials.Login(username, password, server); err != nil {
fmt.Fprintf(os.Stderr, "error: failed to save login: %s\n", 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

@@ -41,7 +41,7 @@ func (p *PullCmd) Execute(_ context.Context, f *flag.FlagSet, _ ...interface{})
gameID := f.Arg(1)
path := f.Arg(2)
username, password, err := credentials.Read()
username, password, err := credentials.Read(url)
if err != nil {
fmt.Fprintf(os.Stderr, "error: failed to read std output: %s", err)
return subcommands.ExitFailure

View File

@@ -27,9 +27,11 @@ func (*RemoteCmd) Usage() string {
The -list argument lists all remotes for each registered game.
This command performs a connection test.
Usage: cloudsave remote -list
The -set argument allow you to set (create or update)
the URL to the remote for a game
Usage: cloudsave remote -set GAME_ID REMOTE_URL
Options
`

View File

@@ -1,51 +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
}
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

@@ -7,11 +7,10 @@ import (
"cloudsave/pkg/remote"
"cloudsave/pkg/remote/client"
"cloudsave/pkg/repository"
"cloudsave/pkg/sync"
"context"
"errors"
"flag"
"fmt"
"log/slog"
"os"
"time"
@@ -38,241 +37,92 @@ func (p *SyncCmd) SetFlags(f *flag.FlagSet) {
}
func (p *SyncCmd) Execute(_ context.Context, f *flag.FlagSet, _ ...interface{}) subcommands.ExitStatus {
games, err := p.Service.AllGames()
remoteCred := make(map[string]map[string]string)
rs, err := remote.All()
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
}
remoteCred := make(map[string]map[string]string)
for _, g := range games {
r, err := remote.One(g.ID)
if err != nil {
if errors.Is(err, remote.ErrNoRemote) {
fmt.Println("⬛", g.Name+": no remote configured")
continue
}
fmt.Fprintln(os.Stderr, "error: failed to load datastore:", err)
return subcommands.ExitFailure
done := make(map[string]struct{})
for _, r := range rs {
if _, ok := done[r.URL]; ok {
continue
}
cli, err := connect(remoteCred, r)
if err != nil {
fmt.Fprintln(os.Stderr, "error: failed to connect to the remote:", err)
return subcommands.ExitFailure
}
pg := progressbar.New(-1)
fmt.Println()
done[r.URL] = struct{}{}
syncer := sync.NewSyncer(cli, p.Service)
var pg *progressbar.ProgressBar
destroyPg := func() {
pg.Finish()
pg.Clear()
pg.Close()
}
pg.Describe(fmt.Sprintf("[%s] Checking status...", g.Name))
exists, err := cli.Exists(r.GameID)
if err != nil {
slog.Error(err.Error())
continue
}
if !exists {
pg.Describe(fmt.Sprintf("[%s] Pushing data...", g.Name))
if err := p.push(g, cli); err != nil {
syncer.SetStateCallback(func(s sync.State, g repository.Metadata) {
switch s {
case sync.FetchingMetdata:
pg = progressbar.New(-1)
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))
case sync.Pulling:
pg.Describe(fmt.Sprintf("%s: pull data from the server", g.Name))
case sync.UpToDate:
destroyPg()
fmt.Fprintln(os.Stderr, "failed to push:", err)
return subcommands.ExitFailure
}
pg.Describe(fmt.Sprintf("[%s] Pushing backup...", g.Name))
if err := p.pushBackup(g, cli); err != nil {
fmt.Println("🆗", g.Name+": already up-to-date")
case sync.Pushed:
destroyPg()
slog.Warn("failed to push backup files", "err", err)
}
destroyPg()
fmt.Println("⬆️", g.Name+": pushed")
continue
}
pg.Describe(fmt.Sprintf("[%s] Fetching metadata...", g.Name))
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 := p.pullBackup(g, cli); err != nil {
slog.Warn("failed to pull backup files", "err", err)
}
pg.Describe(fmt.Sprintf("[%s] Pushing backup...", g.Name))
if err := p.pushBackup(g, cli); err != nil {
slog.Warn("failed to push backup files", "err", err)
}
if g.MD5 == remoteMetadata.MD5 {
destroyPg()
if g.Version != remoteMetadata.Version {
slog.Debug("version is not the same, but the hash is equal. Updating local database")
if err := p.Service.SetVersion(r.GameID, remoteMetadata.Version); err != nil {
fmt.Fprintln(os.Stderr, "error: failed to synchronize version number:", err)
continue
}
}
fmt.Println("🆗", g.Name+": already up-to-date")
continue
}
if g.Version > remoteMetadata.Version {
pg.Describe(fmt.Sprintf("[%s] Pushing data...", g.Name))
if err := p.push(g, cli); err != nil {
fmt.Println("⬆️", g.Name+": pushed")
case sync.Pulled:
destroyPg()
fmt.Fprintln(os.Stderr, "failed to push:", err)
return subcommands.ExitFailure
fmt.Println("⬇️", g.Name+": pulled")
}
})
syncer.SetErrorCallback(func(err error, g repository.Metadata) {
destroyPg()
fmt.Println("⬆️", g.Name+": pushed")
continue
}
fmt.Println("", g.Name+": "+err.Error())
})
if g.Version < remoteMetadata.Version {
destroyPg()
if err := p.pull(g, cli); err != nil {
destroyPg()
fmt.Fprintln(os.Stderr, "failed to push:", err)
return subcommands.ExitFailure
syncer.SetConflictCallback(func(a, b repository.Metadata) sync.ConflictResolution {
fmt.Println()
fmt.Println("--- ⚠️ CONFLICT ---")
fmt.Println(a.Name, "(", a.Path, ")")
fmt.Println("----")
fmt.Println("Your version:", a.Date.Format(time.RFC1123))
fmt.Println("Their version:", b.Date.Format(time.RFC1123))
fmt.Println()
res := prompt.Conflict()
switch res {
case prompt.Their:
return sync.Their
case prompt.My:
return sync.Mine
}
g.Version = remoteMetadata.Version
g.Date = remoteMetadata.Date
return sync.None
})
if err := p.Service.UpdateMetadata(g.ID, g); err != nil {
destroyPg()
fmt.Fprintln(os.Stderr, "failed to push:", err)
return subcommands.ExitFailure
}
fmt.Println("⬇️", g.Name+": pulled")
continue
}
destroyPg()
if g.Version == remoteMetadata.Version {
if err := p.conflict(r.GameID, g, remoteMetadata, cli); err != nil {
fmt.Fprintln(os.Stderr, "error: failed to resolve conflict:", err)
continue
}
continue
}
syncer.Sync()
}
fmt.Println("done.")
return subcommands.ExitSuccess
}
func (p *SyncCmd) conflict(gameID string, m, remoteMetadata repository.Metadata, cli *client.Client) error {
g, err := p.Service.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 := p.push(m, cli); err != nil {
return fmt.Errorf("failed to push: %w", err)
}
}
case prompt.Their:
{
if err := p.pull(g, cli); err != nil {
return fmt.Errorf("failed to push: %w", err)
}
g.Version = remoteMetadata.Version
g.Date = remoteMetadata.Date
if err := p.Service.UpdateMetadata(g.ID, g); err != nil {
return fmt.Errorf("failed to push: %w", err)
}
}
}
return nil
}
func (p *SyncCmd) push(m repository.Metadata, cli *client.Client) error {
return p.Service.PushArchive(m.ID, "", cli)
}
func (p *SyncCmd) pushBackup(m repository.Metadata, cli *client.Client) error {
bs, err := p.Service.AllBackups(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 (p *SyncCmd) pullBackup(m repository.Metadata, cli *client.Client) error {
bs, err := cli.ListArchives(m.ID)
if err != nil {
return err
}
for _, uuid := range bs {
rinfo, err := cli.ArchiveInfo(m.ID, uuid)
if err != nil {
return err
}
linfo, err := p.Service.Backup(m.ID, uuid)
if err != nil {
return err
}
if linfo.MD5 != rinfo.MD5 {
if err := p.Service.PullBackup(m.ID, uuid, cli); err != nil {
return err
}
}
}
return nil
}
func (p *SyncCmd) pull(g repository.Metadata, cli *client.Client) error {
if err := p.Service.PullArchive(g.ID, "", cli); err != nil {
return err
}
return p.Service.ApplyCurrent(g.ID)
}
func connect(remoteCred map[string]map[string]string, r remote.Remote) (*client.Client, error) {
var cli *client.Client
@@ -284,7 +134,7 @@ func connect(remoteCred map[string]map[string]string, r remote.Remote) (*client.
fmt.Println()
fmt.Println("Connexion to", r.URL)
fmt.Println("============")
username, password, err := credentials.Read()
username, password, err := credentials.Read(r.URL)
if err != nil {
return nil, fmt.Errorf("failed to read std output: %w", err)
}

View File

@@ -42,7 +42,7 @@ func (p *VersionCmd) Execute(_ context.Context, f *flag.FlagSet, _ ...interface{
return subcommands.ExitUsageError
}
username, password, err := credentials.Read()
username, password, err := credentials.Read(f.Arg(0))
if err != nil {
fmt.Fprintf(os.Stderr, "failed to read std output: %s", err)
return subcommands.ExitFailure

View File

@@ -4,6 +4,8 @@ import (
"cloudsave/cmd/cli/commands/add"
"cloudsave/cmd/cli/commands/apply"
"cloudsave/cmd/cli/commands/list"
"cloudsave/cmd/cli/commands/login"
"cloudsave/cmd/cli/commands/logout"
"cloudsave/cmd/cli/commands/pull"
"cloudsave/cmd/cli/commands/remote"
"cloudsave/cmd/cli/commands/remove"
@@ -42,7 +44,6 @@ func main() {
subcommands.Register(subcommands.HelpCommand(), "help")
subcommands.Register(subcommands.FlagsCommand(), "help")
subcommands.Register(subcommands.CommandsCommand(), "help")
subcommands.Register(&version.VersionCmd{}, "help")
subcommands.Register(&add.AddCmd{Service: s}, "management")
@@ -56,6 +57,8 @@ func main() {
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()
ctx := context.Background()

View File

@@ -2,14 +2,59 @@ package credentials
import (
"bufio"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"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: ")
reader := bufio.NewReader(os.Stdin)
username, _ := reader.ReadString('\n')
@@ -24,3 +69,66 @@ func Read() (string, string, error) {
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

@@ -21,6 +21,7 @@ type (
Server *http.Server
Service *data.Service
documentRoot string
creds map[string]string
}
)
@@ -32,6 +33,7 @@ func NewServer(documentRoot string, srv *data.Service, creds map[string]string,
s := &HTTPServer{
Service: srv,
documentRoot: documentRoot,
creds: creds,
}
router := chi.NewRouter()
router.NotFound(func(writer http.ResponseWriter, request *http.Request) {
@@ -46,7 +48,7 @@ func NewServer(documentRoot string, srv *data.Service, creds map[string]string,
router.Use(middleware.Compress(5, "application/gzip"))
router.Use(middleware.Heartbeat("/heartbeat"))
router.Route("/api", func(routerAPI chi.Router) {
routerAPI.Use(BasicAuth("cloudsave", creds))
routerAPI.Use(s.BasicAuth("cloudsave"))
routerAPI.Route("/v1", func(r chi.Router) {
// Get information about the server
r.Get("/version", s.Information)
@@ -72,12 +74,17 @@ func NewServer(documentRoot string, srv *data.Service, creds map[string]string,
})
})
s.Server = &http.Server{
Addr: fmt.Sprintf(":%d", port),
Handler: router,
Addr: fmt.Sprintf(":%d", port),
Handler: router,
ReadHeaderTimeout: 2 * time.Second,
}
return s
}
func (s *HTTPServer) SetCredentials(creds map[string]string) {
s.creds = creds
}
func (s HTTPServer) all(w http.ResponseWriter, r *http.Request) {
datastore, err := s.Service.AllGames()
if err != nil {

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.
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 http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
user, pass, ok := r.BasicAuth()
@@ -29,7 +29,7 @@ func BasicAuth(realm string, creds map[string]string) func(next http.Handler) ht
return
}
credPass := creds[user]
credPass := s.creds[user]
if err := bcrypt.CompareHashAndPassword([]byte(credPass), []byte(pass)); err != nil {
basicAuthFailed(w, r, realm)
return

View File

@@ -5,12 +5,30 @@ package main
import (
"fmt"
"os"
"os/signal"
"syscall"
)
const defaultDocumentRoot string = "/var/lib/cloudsave"
var (
updateChan chan struct{}
)
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) {

View File

@@ -13,9 +13,15 @@ const defaultDocumentRoot string = "C:\\ProgramData\\CloudSave"
//go:embed res/icon.ico
var icon []byte
var (
updateChan chan struct{}
)
func main() {
updateChan = make(chan struct{})
go systray.Run(onReady, onExit)
run()
run(updateChan)
}
func fatal(message string, exitCode int) {
@@ -28,12 +34,20 @@ func onReady() {
systray.SetTooltip("CloudSave")
systray.SetIcon(icon)
mQuit := systray.AddMenuItem("Quit", "Quit")
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() {

View File

@@ -14,7 +14,7 @@ import (
"strconv"
)
func run() {
func run(updateChan <-chan struct{}) {
fmt.Printf("CloudSave server -- v%s.%s.%s\n\n", constants.Version, runtime.GOOS, runtime.GOARCH)
var documentRoot string
@@ -30,6 +30,14 @@ func run() {
slog.SetLogLoggerLevel(slog.LevelDebug)
}
if !filepath.IsAbs(documentRoot) {
if v, err := filepath.Abs(documentRoot); err == nil {
documentRoot = v
} else {
fatal("failed to get absolute path from document-root flag: "+err.Error(), 2)
}
}
slog.Info("loading .htpasswd")
h, err := htpasswd.Open(filepath.Join(documentRoot, ".htpasswd"))
if err != nil {
@@ -47,6 +55,7 @@ func run() {
if err := r.Preload(); err != nil {
fatal("failed to load datastore: "+err.Error(), 1)
}
repo = r
} else {
slog.Info("loading lazy repository...")
@@ -61,6 +70,23 @@ func run() {
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 {
fatal("failed to start server: "+err.Error(), 1)

View File

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

View File

@@ -4,6 +4,7 @@ import (
"encoding/json"
"fmt"
"os"
"path/filepath"
)
type (
@@ -22,7 +23,7 @@ type (
)
func Load(path string) (Configuration, error) {
f, err := os.OpenFile(path, os.O_RDONLY, 0)
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)
}

View File

@@ -13,6 +13,7 @@ import (
"runtime"
"slices"
"sync"
"time"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
@@ -74,13 +75,19 @@ var (
// NewServer start the http server
func NewServer(c config.Configuration) *HTTPServer {
dashboardTemplate := template.New("dashboard")
dashboardTemplate.Parse(DashboardHTMLPage)
if _, err := dashboardTemplate.Parse(DashboardHTMLPage); err != nil {
panic("failed to load template 'dashboard': " + err.Error())
}
detailledTemplate := template.New("detailled")
detailledTemplate.Parse(DetailledHTMLPage)
if _, err := detailledTemplate.Parse(DetailledHTMLPage); err != nil {
panic("failed to load template 'detailled': " + err.Error())
}
systemTemplate := template.New("system")
systemTemplate.Parse(SystemHTMLPage)
if _, err := systemTemplate.Parse(SystemHTMLPage); err != nil {
panic("failed to load template 'system': " + err.Error())
}
s := &HTTPServer{
Config: c,
@@ -99,8 +106,9 @@ func NewServer(c config.Configuration) *HTTPServer {
routerAPI.Get("/system", s.system)
})
s.Server = &http.Server{
Addr: fmt.Sprintf(":%d", c.Server.Port),
Handler: router,
Addr: fmt.Sprintf(":%d", c.Server.Port),
Handler: router,
ReadHeaderTimeout: 2 * time.Second,
}
return s
}

63
docker-compose.yml Normal file
View File

@@ -0,0 +1,63 @@
services:
api:
build:
context: .
dockerfile: api.Dockerfile
volumes:
- "./data:/var/lib/cloudsave"
networks:
- cloudsave_net
healthcheck:
test: wget --no-verbose --tries=1 --spider http://localhost:8080/heartbeat || exit 1
interval: 3s
timeout: 2s
retries: 3
start_period: 10s
labels:
- "traefik.enable=true"
- "traefik.http.routers.api-router.rule=Host(`${DOMAIN:-localhost}`) && PathPrefix(`/api`)"
- "traefik.http.routers.api-router.entrypoints=web"
- "traefik.http.services.cloudsave-api.loadbalancer.server.port=8080"
web:
build:
context: .
dockerfile: web.Dockerfile
volumes:
- "./config.json:/var/lib/cloudsave/config.json"
networks:
- cloudsave_net
depends_on:
api:
condition: service_healthy
labels:
- "traefik.enable=true"
- "traefik.http.routers.web-router.rule=Host(`${DOMAIN:-localhost}`) && PathPrefix(`/web`)"
- "traefik.http.routers.web-router.entrypoints=web"
- "traefik.http.services.cloudsave-web.loadbalancer.server.port=8080"
proxy:
image: traefik:3.7.0
ports:
- 127.0.0.1:80:80
- 127.0.0.1:8080:8080
networks:
- cloudsave_net
depends_on:
api:
condition: service_healthy
web:
condition: service_started
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
command:
- --api.dashboard=true
- --api.insecure=true
- --providers.docker=true
- --providers.docker.exposedbydefault=false
- --entrypoints.web.address=:80
- --log.level=DEBUG
- --accesslog=true
networks:
cloudsave_net:

32
go.mod
View File

@@ -3,6 +3,7 @@ module cloudsave
go 1.24
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/google/subcommands v1.2.0
@@ -13,15 +14,46 @@ 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/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/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/text v0.25.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

74
go.sum
View File

@@ -1,8 +1,29 @@
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/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/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=
@@ -19,39 +40,88 @@ github.com/getlantern/systray v1.2.2 h1:dCEHtfmvkJG7HZ8lS/sLklTH4RKUcIsKrAD9sTho
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/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/go.mod h1:ZjhPrFU+Olkh9WazFPsl27BQ4UPiG37m3yTrtFlrHVk=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/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/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/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/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
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/go.mod h1:IsO3lpbaGuzh8zIMzgY3+J8l4C8GjO0Y9S69eFvNsec=
github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966/go.mod h1:sUM3LWHvSMaG192sy56D9F7CNvL7jUJVXoqM1QKLnog=
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.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
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/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/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
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/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/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

@@ -2,6 +2,17 @@ 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
const Version = "0.0.4d"
const Version = "0.0.5"
const ApiVersion = 1

View File

@@ -400,7 +400,7 @@ func (l Service) apply(src, dst string) error {
return fmt.Errorf("failed to remove old save: %w", err)
}
f, err := os.OpenFile(src, os.O_RDONLY, 0)
f, err := os.OpenFile(filepath.Clean(src), os.O_RDONLY, 0)
if err != nil {
return fmt.Errorf("failed to open archive: %w", err)
}

View File

@@ -9,10 +9,12 @@ import (
"errors"
"fmt"
"io"
"log/slog"
"mime/multipart"
"net/http"
"net/url"
"os"
"path/filepath"
"strconv"
"time"
@@ -220,12 +222,12 @@ func (c *Client) Pull(gameID, archivePath string) error {
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 {
return fmt.Errorf("failed to open file: %w", err)
}
defer func() {
if err := os.Rename(archivePath+".part", archivePath); err != nil {
if err := os.Rename(filepath.Clean(archivePath+".part"), archivePath); err != nil {
panic(err)
}
}()
@@ -275,20 +277,24 @@ func (c *Client) PullBackup(gameID, uuid, archivePath string) error {
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 {
return fmt.Errorf("failed to open file: %w", err)
}
res, err := cli.Do(req)
if err != nil {
f.Close()
if err := f.Close(); err != nil {
slog.Error("failed to close file", "err", err)
}
return fmt.Errorf("cannot connect to remote: %w", err)
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
f.Close()
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)
}
@@ -299,10 +305,14 @@ func (c *Client) PullBackup(gameID, uuid, archivePath string) error {
defer bar.Close()
if _, err := io.Copy(io.MultiWriter(f, bar), res.Body); err != nil {
f.Close()
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)
}
f.Close()
if err := f.Close(); err != nil {
slog.Error("failed to close file", "err", err)
}
if err := os.Rename(archivePath+".part", archivePath); err != nil {
return fmt.Errorf("failed to move temporary data: %w", err)
@@ -374,6 +384,10 @@ func (c *Client) All() ([]repository.Metadata, error) {
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) {
cli := http.Client{}
@@ -413,7 +427,7 @@ func (c *Client) get(url string) (obj.HTTPObject, 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 {
return fmt.Errorf("failed to open file: %w", err)
}
@@ -431,9 +445,15 @@ func (c *Client) push(u, archivePath string, m repository.Metadata) error {
return fmt.Errorf("failed to copy data: %w", err)
}
writer.WriteField("name", m.Name)
writer.WriteField("version", strconv.Itoa(m.Version))
writer.WriteField("date", m.Date.Format(time.RFC3339))
if err := writer.WriteField("name", m.Name); err != nil {
return err
}
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 {
return err

View File

@@ -39,7 +39,7 @@ func init() {
}
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 errors.Is(err, os.ErrNotExist) {
return Remote{}, ErrNoRemote
@@ -57,12 +57,34 @@ func One(gameID string) (Remote, error) {
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 {
r := Remote{
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 {
return err
}

View File

@@ -73,7 +73,7 @@ type (
AllHist(gameID GameIdentifier) ([]string, error)
WriteBlob(ID Identifier) (io.Writer, error)
WriteMetadata(gameID GameIdentifier, m Metadata) error
WriteMetadata(gameID GameIdentifier, m Metadata) error
Metadata(gameID GameIdentifier) (Metadata, error)
LastScan(gameID GameIdentifier) (time.Time, error)
@@ -117,7 +117,7 @@ func (bi BackupIdentifier) Key() string {
func NewLazyRepository(dataRootPath string) (*LazyRepository, error) {
if m, err := os.Stat(dataRootPath); err != nil {
if errors.Is(err, os.ErrNotExist) {
if err := os.MkdirAll(dataRootPath, 0740); err != nil {
if err := os.MkdirAll(dataRootPath, 0750); err != nil {
return nil, fmt.Errorf("failed to make the directory: %w", err)
}
} else {
@@ -137,8 +137,8 @@ func NewLazyRepository(dataRootPath string) (*LazyRepository, error) {
func (l *LazyRepository) Mkdir(id Identifier) error {
path := l.DataPath(id)
if _, err := os.Stat(path); errors.Is(err, os.ErrNotExist) {
slog.Debug("making directory", "path", path, "id", id, "perm", "0740")
return os.MkdirAll(path, 0740)
slog.Debug("making directory", "path", path, "id", id, "perm", "0750")
return os.MkdirAll(path, 0750)
}
return nil
}
@@ -182,7 +182,7 @@ 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.Join(path, "data.tar.gz"), os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0740)
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)
}
@@ -195,7 +195,7 @@ func (l *LazyRepository) WriteMetadata(id GameIdentifier, m Metadata) error {
path := l.DataPath(id)
slog.Debug("writing metadata", "id", id, "metadata", m)
dst, err := os.OpenFile(filepath.Join(path, "metadata.json"), os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0740)
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)
}
@@ -213,7 +213,7 @@ func (l *LazyRepository) Metadata(id GameIdentifier) (Metadata, error) {
path := l.DataPath(id)
slog.Debug("loading metadata", "id", id)
src, err := os.OpenFile(filepath.Join(path, "metadata.json"), os.O_RDONLY, 0)
src, err := os.OpenFile(filepath.Clean(filepath.Join(path, "metadata.json")), os.O_RDONLY, 0)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return Metadata{}, ErrNotFound
@@ -272,7 +272,7 @@ func (l *LazyRepository) Backup(id BackupIdentifier) (Backup, error) {
func (l *LazyRepository) LastScan(id GameIdentifier) (time.Time, error) {
path := l.DataPath(id)
data, err := os.ReadFile(filepath.Join(path, ".last_run"))
data, err := os.ReadFile(filepath.Clean(filepath.Join(path, ".last_run")))
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return time.Time{}, nil
@@ -292,7 +292,7 @@ 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.Join(path, ".last_run"), os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0740)
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)
}
@@ -311,7 +311,7 @@ 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.Join(path, "data.tar.gz"), os.O_RDONLY, 0)
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)
@@ -325,7 +325,7 @@ func (l *LazyRepository) ReadBlob(id Identifier) (io.ReadSeekCloser, error) {
func (l *LazyRepository) SetRemote(id GameIdentifier, url string) error {
path := l.DataPath(id)
src, err := os.OpenFile(filepath.Join(path, "remote.json"), os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0740)
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)
}
@@ -345,7 +345,7 @@ func (l *LazyRepository) SetRemote(id GameIdentifier, url string) error {
func (l *LazyRepository) Remote(id GameIdentifier) (*Remote, error) {
path := l.DataPath(id)
src, err := os.OpenFile(filepath.Join(path, "remote.json"), os.O_RDONLY, 0)
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
@@ -401,6 +401,7 @@ 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)
@@ -443,6 +444,21 @@ func (r *EagerRepository) Preload() error {
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()

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"
"fmt"
"io"
"log/slog"
"os"
"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 {
gzr, err := gzip.NewReader(file)
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
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
// 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
case tar.TypeDir:
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
}
}
// if it's a file create it
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 {
return err
}
limited := &io.LimitedReader{R: gzr, N: maxUncompressedOutput}
// copy over contents
if _, err := io.Copy(f, tr); err != nil {
if _, err := io.Copy(f, limited); err != nil {
return err
}
// manually close here after each file operation; defering would cause each file close
// 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
}
file, err := os.Open(path)
file, err := os.Open(filepath.Clean(path))
if err != nil {
return fmt.Errorf("failed to open file: %w", err)
}

View File

@@ -5,10 +5,11 @@ import (
"encoding/hex"
"io"
"os"
"path/filepath"
)
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 {
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
}

24
web.Dockerfile Normal file
View File

@@ -0,0 +1,24 @@
FROM golang:1.26.3-trixie AS build
ENV GOOS=linux
ENV CGO_ENABLED=0
ENV GOAMD64=v3
ENV GORISCV64=rva22u64
ENV GOARM64=v8.2
COPY . /src
RUN cd /src \
&& go build -ldflags="-s -w" -o web ./cmd/web \
&& chown 0:0 web \
&& chmod ugo+x web
FROM scratch AS prod
COPY --from=build /etc/passwd /etc/passwd
COPY --from=build /etc/shadow /etc/shadow
COPY --from=build /src/web /web
VOLUME [ "/var/lib/cloudsave" ]
ENTRYPOINT [ "/web" ]