Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 13adb26fba | |||
| 23ffa93615 | |||
| 23e46e5eef | |||
| c1fc8a52f4 | |||
| 6127da4626 | |||
| d208b1da91 | |||
| c329f96a76 | |||
| f699fcd7f6 | |||
| 898012a30d | |||
| 2f777c72ee | |||
| d479004217 | |||
| cf96815d0f |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -1,4 +1,7 @@
|
|||||||
/cli
|
/cli
|
||||||
/server
|
/server
|
||||||
|
/web
|
||||||
/env/
|
/env/
|
||||||
/build/
|
/build/
|
||||||
|
*.exe
|
||||||
|
/config.json
|
||||||
12
.vscode/launch.json
vendored
12
.vscode/launch.json
vendored
@@ -4,9 +4,17 @@
|
|||||||
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
|
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
|
||||||
"version": "0.2.0",
|
"version": "0.2.0",
|
||||||
"configurations": [
|
"configurations": [
|
||||||
|
|
||||||
{
|
{
|
||||||
"name": "Launch Package",
|
"name": "server",
|
||||||
|
"type": "go",
|
||||||
|
"request": "launch",
|
||||||
|
"mode": "auto",
|
||||||
|
"args": ["-document-root", "${workspaceFolder}/env"],
|
||||||
|
"console": "integratedTerminal",
|
||||||
|
"program": "${workspaceFolder}/cmd/server"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "cli",
|
||||||
"type": "go",
|
"type": "go",
|
||||||
"request": "launch",
|
"request": "launch",
|
||||||
"mode": "auto",
|
"mode": "auto",
|
||||||
|
|||||||
7
LICENSE
Normal file
7
LICENSE
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
Copyright 2025 Aurélie DELHAIE
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||||
56
README.md
Normal file
56
README.md
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
# CloudSave
|
||||||
|
|
||||||
|
The software is still in alpha.
|
||||||
|
|
||||||
|
A client/server that allows unsynchronized games (such as emulators, old games, etc.) to be kept up to date on multiple computers.
|
||||||
|
|
||||||
|
## Build
|
||||||
|
|
||||||
|
You need go1.24
|
||||||
|
|
||||||
|
After downloading the go toolchain, just run the script `./build.sh`
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
### Server
|
||||||
|
|
||||||
|
The server needs an empty directory. After creating this directory, you need to make a file that contains your credential. The format is "username:password". The server only understand bcrypt password hash for now.
|
||||||
|
|
||||||
|
e.g.:
|
||||||
|
```
|
||||||
|
test:$2y$10$uULsuyROe3LVdTzFoBH7HO0zhvyKp6CX2FDNl7quXMFYqzitU0kc.
|
||||||
|
```
|
||||||
|
|
||||||
|
The default path to this directory is `/var/lib/cloudsave`, this can be changed with the `-document-root` argument
|
||||||
|
|
||||||
|
### Client
|
||||||
|
|
||||||
|
#### Register a game
|
||||||
|
|
||||||
|
You can register a game with the verb `add`
|
||||||
|
```bash
|
||||||
|
cloudsave add /home/user/gamedata
|
||||||
|
```
|
||||||
|
|
||||||
|
You can also change the name of the registration and add a remote
|
||||||
|
```bash
|
||||||
|
cloudsave add -name "My Game" -remote "http://localhost:8080" /home/user/gamedata
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Make an archive of the current state
|
||||||
|
|
||||||
|
This is a command line tool, it cannot auto detect changes.
|
||||||
|
Run this command to start the scan, if needed, the tool will create a new archive
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cloudsave scan
|
||||||
|
```
|
||||||
|
#### Send everythings on the server
|
||||||
|
|
||||||
|
This will pull and push data to the server.
|
||||||
|
|
||||||
|
Note: If multiple computers are pushing to this server, a conflict may be generated. If so, the tool will ask for the version to keep
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cloudsave sync
|
||||||
|
```
|
||||||
24
build.sh
24
build.sh
@@ -1,7 +1,7 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
|
|
||||||
MAKE_PACKAGE=false
|
MAKE_PACKAGE=false
|
||||||
VERSION=0.0.2
|
VERSION=0.0.3
|
||||||
|
|
||||||
usage() {
|
usage() {
|
||||||
echo "Usage: $0 [OPTIONS]"
|
echo "Usage: $0 [OPTIONS]"
|
||||||
@@ -55,6 +55,28 @@ for platform in "${platforms[@]}"; do
|
|||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
|
|
||||||
|
# WEB
|
||||||
|
|
||||||
|
platforms=("linux/amd64" "linux/arm64" "linux/riscv64" "linux/ppc64le")
|
||||||
|
|
||||||
|
for platform in "${platforms[@]}"; do
|
||||||
|
echo "* Compiling web server for $platform..."
|
||||||
|
platform_split=(${platform//\// })
|
||||||
|
|
||||||
|
EXT=""
|
||||||
|
if [ "${platform_split[0]}" == "windows" ]; then
|
||||||
|
EXT=.exe
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$MAKE_PACKAGE" == "true" ]; then
|
||||||
|
CGO_ENABLED=0 GOOS=${platform_split[0]} GOARCH=${platform_split[1]} go build -o build/cloudsave_web$EXT -a ./cmd/web
|
||||||
|
tar -czf build/web_${platform_split[0]}_${platform_split[1]}.tar.gz build/cloudsave_web$EXT
|
||||||
|
rm build/cloudsave_web$EXT
|
||||||
|
else
|
||||||
|
CGO_ENABLED=0 GOOS=${platform_split[0]} GOARCH=${platform_split[1]} go build -o build/cloudsave_web_${platform_split[0]}_${platform_split[1]}$EXT -a ./cmd/web
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
## CLIENT
|
## CLIENT
|
||||||
|
|
||||||
platforms=("windows/amd64" "windows/arm64" "darwin/amd64" "darwin/arm64" "linux/amd64" "linux/arm64")
|
platforms=("windows/amd64" "windows/arm64" "darwin/amd64" "darwin/arm64" "linux/amd64" "linux/arm64")
|
||||||
|
|||||||
@@ -1,32 +1,39 @@
|
|||||||
package add
|
package add
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"cloudsave/pkg/remote"
|
||||||
"cloudsave/pkg/repository"
|
"cloudsave/pkg/repository"
|
||||||
"context"
|
"context"
|
||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/google/subcommands"
|
"github.com/google/subcommands"
|
||||||
)
|
)
|
||||||
|
|
||||||
type (
|
type (
|
||||||
AddCmd struct {
|
AddCmd struct {
|
||||||
name string
|
name string
|
||||||
|
remote string
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
func (*AddCmd) Name() string { return "add" }
|
func (*AddCmd) Name() string { return "add" }
|
||||||
func (*AddCmd) Synopsis() string { return "Add a folder to the sync list" }
|
func (*AddCmd) Synopsis() string { return "add a folder to the sync list" }
|
||||||
func (*AddCmd) Usage() string {
|
func (*AddCmd) Usage() string {
|
||||||
return `add:
|
return `Usage: cloudsave add [-name] [-remote] <PATH>
|
||||||
Add a folder to the sync list
|
|
||||||
|
Add a folder to the track list
|
||||||
|
|
||||||
|
Options:
|
||||||
`
|
`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *AddCmd) SetFlags(f *flag.FlagSet) {
|
func (p *AddCmd) SetFlags(f *flag.FlagSet) {
|
||||||
f.StringVar(&p.name, "name", "", "Override the name of the game")
|
f.StringVar(&p.name, "name", "", "Override the name of the game")
|
||||||
|
f.StringVar(&p.remote, "remote", "", "Defines a remote server to sync with")
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *AddCmd) Execute(_ context.Context, f *flag.FlagSet, _ ...interface{}) subcommands.ExitStatus {
|
func (p *AddCmd) Execute(_ context.Context, f *flag.FlagSet, _ ...interface{}) subcommands.ExitStatus {
|
||||||
@@ -50,6 +57,10 @@ func (p *AddCmd) Execute(_ context.Context, f *flag.FlagSet, _ ...interface{}) s
|
|||||||
return subcommands.ExitFailure
|
return subcommands.ExitFailure
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if len(strings.TrimSpace(p.remote)) > 0 {
|
||||||
|
remote.Set(m.ID, p.remote)
|
||||||
|
}
|
||||||
|
|
||||||
fmt.Println(m.ID)
|
fmt.Println(m.ID)
|
||||||
|
|
||||||
return subcommands.ExitSuccess
|
return subcommands.ExitSuccess
|
||||||
|
|||||||
@@ -20,8 +20,9 @@ type (
|
|||||||
func (*ListCmd) Name() string { return "apply" }
|
func (*ListCmd) Name() string { return "apply" }
|
||||||
func (*ListCmd) Synopsis() string { return "apply a backup" }
|
func (*ListCmd) Synopsis() string { return "apply a backup" }
|
||||||
func (*ListCmd) Usage() string {
|
func (*ListCmd) Usage() string {
|
||||||
return `apply:
|
return `Usage: cloudsave apply <GAME_ID> <BACKUP_ID>
|
||||||
Apply a backup
|
|
||||||
|
Apply a backup
|
||||||
`
|
`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -22,8 +22,11 @@ type (
|
|||||||
func (*ListCmd) Name() string { return "list" }
|
func (*ListCmd) Name() string { return "list" }
|
||||||
func (*ListCmd) Synopsis() string { return "list all game registered" }
|
func (*ListCmd) Synopsis() string { return "list all game registered" }
|
||||||
func (*ListCmd) Usage() string {
|
func (*ListCmd) Usage() string {
|
||||||
return `list:
|
return `Usage: cloudsave list [-include-backup] [-a]
|
||||||
List all game registered
|
|
||||||
|
List all game registered
|
||||||
|
|
||||||
|
Options:
|
||||||
`
|
`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
package pull
|
package pull
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"cloudsave/cmd/cli/tools/prompt/credentials"
|
||||||
"cloudsave/pkg/remote/client"
|
"cloudsave/pkg/remote/client"
|
||||||
"cloudsave/pkg/repository"
|
"cloudsave/pkg/repository"
|
||||||
"cloudsave/pkg/tools/archive"
|
"cloudsave/pkg/tools/archive"
|
||||||
"cloudsave/cmd/cli/tools/prompt/credentials"
|
|
||||||
"context"
|
"context"
|
||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -22,8 +22,9 @@ type (
|
|||||||
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
|
||||||
`
|
`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -20,10 +20,17 @@ type (
|
|||||||
)
|
)
|
||||||
|
|
||||||
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
|
||||||
`
|
`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,8 +17,10 @@ type (
|
|||||||
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
|
||||||
`
|
`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -20,11 +20,14 @@ type (
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
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.
|
||||||
`
|
`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -27,8 +27,9 @@ type (
|
|||||||
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.
|
||||||
`
|
`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -47,6 +48,7 @@ func (p *SyncCmd) Execute(_ context.Context, f *flag.FlagSet, _ ...interface{})
|
|||||||
r, err := remote.One(g.ID)
|
r, err := remote.One(g.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, remote.ErrNoRemote) {
|
if errors.Is(err, remote.ErrNoRemote) {
|
||||||
|
fmt.Println(g.Name + ": no remote configured")
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
fmt.Fprintln(os.Stderr, "error: failed to load datastore:", err)
|
fmt.Fprintln(os.Stderr, "error: failed to load datastore:", err)
|
||||||
@@ -85,6 +87,7 @@ func (p *SyncCmd) Execute(_ context.Context, f *flag.FlagSet, _ ...interface{})
|
|||||||
destroyPg()
|
destroyPg()
|
||||||
slog.Warn("failed to push backup files", "err", err)
|
slog.Warn("failed to push backup files", "err", err)
|
||||||
}
|
}
|
||||||
|
fmt.Println(g.Name + ": pushed")
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -136,7 +139,7 @@ func (p *SyncCmd) Execute(_ context.Context, f *flag.FlagSet, _ ...interface{})
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
fmt.Println("already up-to-date")
|
fmt.Println(g.Name + ": already up-to-date")
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -148,6 +151,7 @@ func (p *SyncCmd) Execute(_ context.Context, f *flag.FlagSet, _ ...interface{})
|
|||||||
return subcommands.ExitFailure
|
return subcommands.ExitFailure
|
||||||
}
|
}
|
||||||
destroyPg()
|
destroyPg()
|
||||||
|
fmt.Println(g.Name + ": pushed")
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -168,6 +172,7 @@ func (p *SyncCmd) Execute(_ context.Context, f *flag.FlagSet, _ ...interface{})
|
|||||||
fmt.Fprintln(os.Stderr, "error: failed to synchronize date:", err)
|
fmt.Fprintln(os.Stderr, "error: failed to synchronize date:", err)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
fmt.Println(g.Name + ": pulled")
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
||||||
`
|
`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,12 +3,9 @@ package api
|
|||||||
import (
|
import (
|
||||||
"cloudsave/cmd/server/data"
|
"cloudsave/cmd/server/data"
|
||||||
"cloudsave/pkg/repository"
|
"cloudsave/pkg/repository"
|
||||||
"crypto/md5"
|
|
||||||
"encoding/hex"
|
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
@@ -343,41 +340,19 @@ func (s HTTPServer) histExists(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
func (s HTTPServer) hash(w http.ResponseWriter, r *http.Request) {
|
func (s HTTPServer) hash(w http.ResponseWriter, r *http.Request) {
|
||||||
id := chi.URLParam(r, "id")
|
id := chi.URLParam(r, "id")
|
||||||
path := filepath.Clean(filepath.Join(s.documentRoot, "data", id))
|
|
||||||
|
|
||||||
sdir, err := os.Stat(path)
|
sum, err := data.Hash(id, s.documentRoot)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
notFound("id not found", w, r)
|
if errors.Is(err, data.ErrNotExists) {
|
||||||
return
|
notFound("id not found", w, r)
|
||||||
}
|
return
|
||||||
|
}
|
||||||
if !sdir.IsDir() {
|
fmt.Fprintln(os.Stderr, "error: an error occured while calculating the hash:", err)
|
||||||
notFound("id not found", w, r)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
path = filepath.Join(path, "data.tar.gz")
|
|
||||||
|
|
||||||
f, err := os.OpenFile(path, os.O_RDONLY, 0)
|
|
||||||
if err != nil {
|
|
||||||
notFound("id not found", w, r)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
defer f.Close()
|
|
||||||
|
|
||||||
// Create MD5 hasher
|
|
||||||
hasher := md5.New()
|
|
||||||
|
|
||||||
// Copy file content into hasher
|
|
||||||
if _, err := io.Copy(hasher, f); err != nil {
|
|
||||||
fmt.Fprintln(os.Stderr, "error: an error occured while reading data:", err)
|
|
||||||
internalServerError(w, r)
|
internalServerError(w, r)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get checksum result
|
ok(sum, w, r)
|
||||||
sum := hasher.Sum(nil)
|
|
||||||
ok(hex.EncodeToString(sum), w, r)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s HTTPServer) metadata(w http.ResponseWriter, r *http.Request) {
|
func (s HTTPServer) metadata(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|||||||
@@ -9,12 +9,51 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
type (
|
||||||
|
cache map[string]cachedInfo
|
||||||
|
|
||||||
|
cachedInfo struct {
|
||||||
|
MD5 string
|
||||||
|
Version int
|
||||||
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
ErrBackupNotExists error = errors.New("backup not found")
|
ErrBackupNotExists error = errors.New("backup not found")
|
||||||
|
ErrNotExists error = errors.New("not found")
|
||||||
|
|
||||||
|
// singleton
|
||||||
|
hashCacheMu sync.RWMutex
|
||||||
|
hashCache cache = make(map[string]cachedInfo)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func (c cache) Get(gameID string) (cachedInfo, bool) {
|
||||||
|
hashCacheMu.RLock()
|
||||||
|
defer hashCacheMu.RUnlock()
|
||||||
|
|
||||||
|
if v, ok := c[gameID]; ok {
|
||||||
|
return v, true
|
||||||
|
}
|
||||||
|
return cachedInfo{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c cache) Register(gameID string, v cachedInfo) {
|
||||||
|
hashCacheMu.Lock()
|
||||||
|
defer hashCacheMu.Unlock()
|
||||||
|
|
||||||
|
c[gameID] = v
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c cache) Remove(gameID string) {
|
||||||
|
hashCacheMu.Lock()
|
||||||
|
defer hashCacheMu.Unlock()
|
||||||
|
|
||||||
|
delete(c, gameID)
|
||||||
|
}
|
||||||
|
|
||||||
func Write(gameID, documentRoot string, r io.Reader) error {
|
func Write(gameID, documentRoot string, r io.Reader) error {
|
||||||
dataFolderPath := filepath.Join(documentRoot, "data", gameID)
|
dataFolderPath := filepath.Join(documentRoot, "data", gameID)
|
||||||
partPath := filepath.Join(dataFolderPath, "data.tar.gz.part")
|
partPath := filepath.Join(dataFolderPath, "data.tar.gz.part")
|
||||||
@@ -42,6 +81,7 @@ func Write(gameID, documentRoot string, r io.Reader) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
hashCache.Remove(gameID)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -97,6 +137,7 @@ func UpdateMetadata(gameID, documentRoot string, m repository.Metadata) error {
|
|||||||
|
|
||||||
func ArchiveInfo(gameID, documentRoot, uuid string) (repository.Backup, error) {
|
func ArchiveInfo(gameID, documentRoot, uuid string) (repository.Backup, error) {
|
||||||
dataFolderPath := filepath.Join(documentRoot, "data", gameID, "hist", uuid, "data.tar.gz")
|
dataFolderPath := filepath.Join(documentRoot, "data", gameID, "hist", uuid, "data.tar.gz")
|
||||||
|
cacheID := gameID + ":" + uuid
|
||||||
|
|
||||||
finfo, err := os.Stat(dataFolderPath)
|
finfo, err := os.Stat(dataFolderPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -106,11 +147,23 @@ func ArchiveInfo(gameID, documentRoot, uuid string) (repository.Backup, error) {
|
|||||||
return repository.Backup{}, err
|
return repository.Backup{}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if m, ok := hashCache.Get(cacheID); ok {
|
||||||
|
return repository.Backup{
|
||||||
|
CreatedAt: finfo.ModTime(),
|
||||||
|
UUID: uuid,
|
||||||
|
MD5: m.MD5,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
h, err := hash.FileMD5(dataFolderPath)
|
h, err := hash.FileMD5(dataFolderPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return repository.Backup{}, fmt.Errorf("failed to calculate file md5: %w", err)
|
return repository.Backup{}, fmt.Errorf("failed to calculate file md5: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
hashCache.Register(cacheID, cachedInfo{
|
||||||
|
MD5: h,
|
||||||
|
})
|
||||||
|
|
||||||
return repository.Backup{
|
return repository.Backup{
|
||||||
CreatedAt: finfo.ModTime(),
|
CreatedAt: finfo.ModTime(),
|
||||||
UUID: uuid,
|
UUID: uuid,
|
||||||
@@ -118,6 +171,65 @@ func ArchiveInfo(gameID, documentRoot, uuid string) (repository.Backup, error) {
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func Hash(gameID, documentRoot string) (string, error) {
|
||||||
|
path := filepath.Clean(filepath.Join(documentRoot, "data", gameID))
|
||||||
|
|
||||||
|
sdir, err := os.Stat(path)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, os.ErrNotExist) {
|
||||||
|
return "", ErrNotExists
|
||||||
|
}
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
if !sdir.IsDir() {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
v, err := getVersion(gameID, documentRoot)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to read game metadata: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if m, ok := hashCache.Get(gameID); ok {
|
||||||
|
if v == m.Version {
|
||||||
|
return m.MD5, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
path = filepath.Join(path, "data.tar.gz")
|
||||||
|
|
||||||
|
h, err := hash.FileMD5(path)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
hashCache.Register(gameID, cachedInfo{
|
||||||
|
Version: v,
|
||||||
|
MD5: h,
|
||||||
|
})
|
||||||
|
|
||||||
|
return h, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func getVersion(gameID, documentRoot string) (int, error) {
|
||||||
|
path := filepath.Join(documentRoot, "data", gameID, "metadata.json")
|
||||||
|
|
||||||
|
f, err := os.OpenFile(path, os.O_RDONLY, 0)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
|
d := json.NewDecoder(f)
|
||||||
|
var m repository.Metadata
|
||||||
|
if err := d.Decode(&m); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return m.Version, nil
|
||||||
|
}
|
||||||
|
|
||||||
func makeDataFolder(gameID, documentRoot string) error {
|
func makeDataFolder(gameID, documentRoot string) error {
|
||||||
if err := os.MkdirAll(filepath.Join(documentRoot, "data", gameID), 0740); err != nil {
|
if err := os.MkdirAll(filepath.Join(documentRoot, "data", gameID), 0740); err != nil {
|
||||||
return err
|
return err
|
||||||
|
|||||||
8
cmd/web/config.template.json
Normal file
8
cmd/web/config.template.json
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"server": {
|
||||||
|
"port": 8181
|
||||||
|
},
|
||||||
|
"remote": {
|
||||||
|
"url": "http://localhost:8080"
|
||||||
|
}
|
||||||
|
}
|
||||||
40
cmd/web/server/config/config.go
Normal file
40
cmd/web/server/config/config.go
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
)
|
||||||
|
|
||||||
|
type (
|
||||||
|
Configuration struct {
|
||||||
|
Server ServerConfiguration `json:"server"`
|
||||||
|
Remote RemoteConfiguration `json:"remote"`
|
||||||
|
}
|
||||||
|
|
||||||
|
ServerConfiguration struct {
|
||||||
|
Port int `json:"port"`
|
||||||
|
}
|
||||||
|
|
||||||
|
RemoteConfiguration struct {
|
||||||
|
URL string `json:"url"`
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
func Load(path string) (Configuration, error) {
|
||||||
|
f, err := os.OpenFile(path, os.O_RDONLY, 0)
|
||||||
|
if err != nil {
|
||||||
|
return Configuration{}, fmt.Errorf("failed to open configuration file: %w", err)
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
|
d := json.NewDecoder(f)
|
||||||
|
|
||||||
|
var c Configuration
|
||||||
|
err = d.Decode(&c)
|
||||||
|
if err != nil {
|
||||||
|
return Configuration{}, fmt.Errorf("failed to parse configuration file (%s): %w", path, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return c, nil
|
||||||
|
}
|
||||||
63
cmd/web/server/middlewares.go
Normal file
63
cmd/web/server/middlewares.go
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
)
|
||||||
|
|
||||||
|
func recoverMiddleware(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
defer func() {
|
||||||
|
err := recover()
|
||||||
|
if err != nil {
|
||||||
|
internalServerError(w, r)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// BasicAuth implements a simple middleware handler for adding basic http auth to a route.
|
||||||
|
func BasicAuth(realm string, creds map[string]string) func(next http.Handler) http.Handler {
|
||||||
|
return func(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
user, pass, ok := r.BasicAuth()
|
||||||
|
if !ok {
|
||||||
|
basicAuthFailed(w, r, realm)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
credPass := creds[user]
|
||||||
|
if err := bcrypt.CompareHashAndPassword([]byte(credPass), []byte(pass)); err != nil {
|
||||||
|
basicAuthFailed(w, r, realm)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func basicAuthFailed(w http.ResponseWriter, r *http.Request, realm string) {
|
||||||
|
unauthorized(realm, w, r)
|
||||||
|
}
|
||||||
|
|
||||||
|
func unauthorized(realm string, w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Add("WWW-Authenticate", fmt.Sprintf(`Basic realm="%s"`, realm))
|
||||||
|
w.WriteHeader(http.StatusUnauthorized)
|
||||||
|
_, err := w.Write([]byte(UnauthorizedErrorHTMLPage))
|
||||||
|
if err != nil {
|
||||||
|
log.Println(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func internalServerError(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
_, err := w.Write([]byte(InternalServerErrorHTMLPage))
|
||||||
|
if err != nil {
|
||||||
|
log.Println(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
248
cmd/web/server/server.go
Normal file
248
cmd/web/server/server.go
Normal file
@@ -0,0 +1,248 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"cloudsave/cmd/web/server/config"
|
||||||
|
"cloudsave/pkg/constants"
|
||||||
|
"cloudsave/pkg/remote/client"
|
||||||
|
"cloudsave/pkg/repository"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"html/template"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"runtime"
|
||||||
|
"slices"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
"github.com/go-chi/chi/v5/middleware"
|
||||||
|
|
||||||
|
_ "embed"
|
||||||
|
)
|
||||||
|
|
||||||
|
type (
|
||||||
|
HTTPServer struct {
|
||||||
|
Server *http.Server
|
||||||
|
Config config.Configuration
|
||||||
|
Templates Templates
|
||||||
|
}
|
||||||
|
|
||||||
|
Templates struct {
|
||||||
|
Dashboard *template.Template
|
||||||
|
Detailled *template.Template
|
||||||
|
System *template.Template
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
type (
|
||||||
|
DetaillePayload struct {
|
||||||
|
Version string
|
||||||
|
Save repository.Metadata
|
||||||
|
BackupMetadata []repository.Backup
|
||||||
|
Hash string
|
||||||
|
}
|
||||||
|
|
||||||
|
DashboardPayload struct {
|
||||||
|
Version string
|
||||||
|
Saves []repository.Metadata
|
||||||
|
}
|
||||||
|
|
||||||
|
SystemPayload struct {
|
||||||
|
Version string
|
||||||
|
Client client.Information
|
||||||
|
Server client.Information
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
//go:embed templates/500.html
|
||||||
|
InternalServerErrorHTMLPage string
|
||||||
|
|
||||||
|
//go:embed templates/401.html
|
||||||
|
UnauthorizedErrorHTMLPage string
|
||||||
|
|
||||||
|
//go:embed templates/dashboard.html
|
||||||
|
DashboardHTMLPage string
|
||||||
|
|
||||||
|
//go:embed templates/detailled.html
|
||||||
|
DetailledHTMLPage string
|
||||||
|
|
||||||
|
//go:embed templates/information.html
|
||||||
|
SystemHTMLPage string
|
||||||
|
)
|
||||||
|
|
||||||
|
// NewServer start the http server
|
||||||
|
func NewServer(c config.Configuration) *HTTPServer {
|
||||||
|
dashboardTemplate := template.New("dashboard")
|
||||||
|
dashboardTemplate.Parse(DashboardHTMLPage)
|
||||||
|
|
||||||
|
detailledTemplate := template.New("detailled")
|
||||||
|
detailledTemplate.Parse(DetailledHTMLPage)
|
||||||
|
|
||||||
|
systemTemplate := template.New("system")
|
||||||
|
systemTemplate.Parse(SystemHTMLPage)
|
||||||
|
|
||||||
|
s := &HTTPServer{
|
||||||
|
Config: c,
|
||||||
|
Templates: Templates{
|
||||||
|
Dashboard: dashboardTemplate,
|
||||||
|
Detailled: detailledTemplate,
|
||||||
|
System: systemTemplate,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
router := chi.NewRouter()
|
||||||
|
router.Use(middleware.Logger)
|
||||||
|
router.Use(recoverMiddleware)
|
||||||
|
router.Route("/web", func(routerAPI chi.Router) {
|
||||||
|
routerAPI.Get("/", s.dashboard)
|
||||||
|
routerAPI.Get("/{id}", s.detailled)
|
||||||
|
routerAPI.Get("/system", s.system)
|
||||||
|
})
|
||||||
|
s.Server = &http.Server{
|
||||||
|
Addr: fmt.Sprintf(":%d", c.Server.Port),
|
||||||
|
Handler: router,
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *HTTPServer) dashboard(w http.ResponseWriter, r *http.Request) {
|
||||||
|
user, pass, ok := r.BasicAuth()
|
||||||
|
if !ok {
|
||||||
|
basicAuthFailed(w, r, "realm")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
cli := client.New(s.Config.Remote.URL, user, pass)
|
||||||
|
|
||||||
|
if err := cli.Ping(); err != nil {
|
||||||
|
slog.Error("unable to connect to the remote", "err", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
saves, err := cli.All()
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, client.ErrUnauthorized) {
|
||||||
|
unauthorized("Unable to access resources", w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
slog.Error("unable to connect to the remote", "err", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
slices.SortFunc(saves, func(a, b repository.Metadata) int {
|
||||||
|
return a.Date.Compare(b.Date)
|
||||||
|
})
|
||||||
|
|
||||||
|
slices.Reverse(saves)
|
||||||
|
|
||||||
|
payload := DashboardPayload{
|
||||||
|
Version: constants.Version,
|
||||||
|
Saves: saves,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.Templates.Dashboard.Execute(w, payload); err != nil {
|
||||||
|
slog.Error("failed to render the html pages", "err", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *HTTPServer) detailled(w http.ResponseWriter, r *http.Request) {
|
||||||
|
user, pass, ok := r.BasicAuth()
|
||||||
|
if !ok {
|
||||||
|
basicAuthFailed(w, r, "realm")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
id := chi.URLParam(r, "id")
|
||||||
|
cli := client.New(s.Config.Remote.URL, user, pass)
|
||||||
|
|
||||||
|
if err := cli.Ping(); err != nil {
|
||||||
|
slog.Error("unable to connect to the remote", "err", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
save, err := cli.Metadata(id)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, client.ErrUnauthorized) {
|
||||||
|
unauthorized("Unable to access resources", w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
slog.Error("unable to connect to the remote", "err", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
h, err := cli.Hash(id)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("unable to connect to the remote", "err", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ids, err := cli.ListArchives(id)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("unable to connect to the remote", "err", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var bm []repository.Backup
|
||||||
|
for _, i := range ids {
|
||||||
|
b, err := cli.ArchiveInfo(id, i)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("unable to connect to the remote", "err", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
bm = append(bm, b)
|
||||||
|
}
|
||||||
|
|
||||||
|
payload := DetaillePayload{
|
||||||
|
Save: save,
|
||||||
|
Hash: h,
|
||||||
|
BackupMetadata: bm,
|
||||||
|
Version: constants.Version,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.Templates.Detailled.Execute(w, payload); err != nil {
|
||||||
|
slog.Error("failed to render the html pages", "err", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *HTTPServer) system(w http.ResponseWriter, r *http.Request) {
|
||||||
|
user, pass, ok := r.BasicAuth()
|
||||||
|
if !ok {
|
||||||
|
basicAuthFailed(w, r, "realm")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cli := client.New(s.Config.Remote.URL, user, pass)
|
||||||
|
|
||||||
|
if err := cli.Ping(); err != nil {
|
||||||
|
slog.Error("unable to connect to the remote", "err", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
clientInfo := client.Information{
|
||||||
|
Version: constants.Version,
|
||||||
|
APIVersion: constants.ApiVersion,
|
||||||
|
GoVersion: runtime.Version(),
|
||||||
|
OSName: runtime.GOOS,
|
||||||
|
OSArchitecture: runtime.GOARCH,
|
||||||
|
}
|
||||||
|
serverInfo, err := cli.Version()
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, client.ErrUnauthorized) {
|
||||||
|
unauthorized("Unable to access resources", w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
slog.Error("unable to connect to the remote", "err", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
payload := SystemPayload{
|
||||||
|
Version: constants.Version,
|
||||||
|
Client: clientInfo,
|
||||||
|
Server: serverInfo,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.Templates.System.Execute(w, payload); err != nil {
|
||||||
|
slog.Error("failed to render the html pages", "err", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
9
cmd/web/server/templates/401.html
Normal file
9
cmd/web/server/templates/401.html
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>You are not allowed</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>401 Unauthorized</h1>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
9
cmd/web/server/templates/500.html
Normal file
9
cmd/web/server/templates/500.html
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>An error occured</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>500 Internal Server Error</h1>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
38
cmd/web/server/templates/dashboard.html
Normal file
38
cmd/web/server/templates/dashboard.html
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Dashboard</title>
|
||||||
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.7/dist/css/bootstrap.min.css" rel="stylesheet"
|
||||||
|
integrity="sha384-LN+7fdVzj6u52u30Kp6M/trliBMCMKTyK833zpbD+pXdCLuTusPj697FH4R/5mcr" crossorigin="anonymous">
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<nav class="navbar bg-body-tertiary">
|
||||||
|
<div class="container-fluid">
|
||||||
|
<span class="navbar-brand mb-0 h1">CloudSave</span>
|
||||||
|
<a class="muted" href="/web/system">v{{.Version}}</a>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
<div class="container" style="margin-top: 1rem;">
|
||||||
|
<div class="list-group">
|
||||||
|
{{range .Saves}}
|
||||||
|
<a href="/web/{{.ID}}" class="list-group-item list-group-item-action">
|
||||||
|
<div class="d-flex w-100 justify-content-between">
|
||||||
|
<h5 class="mb-1">{{.Name}}</h5>
|
||||||
|
<small>{{.Date}}</small>
|
||||||
|
</div>
|
||||||
|
<p class="mb-1">Version: {{.Version}}</p>
|
||||||
|
<small>{{.ID}}</small>
|
||||||
|
</a>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.7/dist/js/bootstrap.bundle.min.js"
|
||||||
|
integrity="sha384-ndDqU0Gzau9qJ1lfW4pNLlhNTkCfHzAVBReH9diLvGRem5+R9g2FzA8ZGN954O5Q"
|
||||||
|
crossorigin="anonymous"></script>
|
||||||
|
|
||||||
|
</html>
|
||||||
54
cmd/web/server/templates/detailled.html
Normal file
54
cmd/web/server/templates/detailled.html
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>{{.Save.Name}}</title>
|
||||||
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.7/dist/css/bootstrap.min.css" rel="stylesheet"
|
||||||
|
integrity="sha384-LN+7fdVzj6u52u30Kp6M/trliBMCMKTyK833zpbD+pXdCLuTusPj697FH4R/5mcr" crossorigin="anonymous">
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<nav class="navbar bg-body-tertiary">
|
||||||
|
<div class="container-fluid">
|
||||||
|
<span class="navbar-brand mb-0 h1">CloudSave</span>
|
||||||
|
<a class="muted" href="/web/system">v{{.Version}}</a>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
<div class="container" style="margin-top: 1rem;">
|
||||||
|
<nav aria-label="breadcrumb">
|
||||||
|
<ol class="breadcrumb">
|
||||||
|
<li class="breadcrumb-item"><a href="/web">Home</a></li>
|
||||||
|
<li class="breadcrumb-item active" aria-current="page">{{.Save.ID}}</li>
|
||||||
|
</ol>
|
||||||
|
</nav>
|
||||||
|
<div class="list-group">
|
||||||
|
<h1>{{.Save.Name}} <span class="badge text-bg-success">Version {{.Save.Version}}</span></h1>
|
||||||
|
<hr />
|
||||||
|
<h3>Details</h3>
|
||||||
|
<ul class="list-group list-group-flush">
|
||||||
|
<li class="list-group-item">UUID: {{.Save.ID}}</li>
|
||||||
|
<li class="list-group-item">Last Upload: {{.Save.Date}}</li>
|
||||||
|
<li class="list-group-item">Hash (MD5): {{.Hash}}</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<hr />
|
||||||
|
<h3>Backup</h3>
|
||||||
|
{{ range .BackupMetadata}}
|
||||||
|
<div class="card" style="margin-top: 1rem;">
|
||||||
|
<div class="card-body">
|
||||||
|
<h5 class="card-title">{{.CreatedAt}}</h5>
|
||||||
|
<h6 class="card-subtitle mb-2 text-body-secondary">{{.UUID}}</h6>
|
||||||
|
<p class="card-text">MD5: {{.MD5}}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.7/dist/js/bootstrap.bundle.min.js"
|
||||||
|
integrity="sha384-ndDqU0Gzau9qJ1lfW4pNLlhNTkCfHzAVBReH9diLvGRem5+R9g2FzA8ZGN954O5Q"
|
||||||
|
crossorigin="anonymous"></script>
|
||||||
|
|
||||||
|
</html>
|
||||||
52
cmd/web/server/templates/information.html
Normal file
52
cmd/web/server/templates/information.html
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>System Information</title>
|
||||||
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.7/dist/css/bootstrap.min.css" rel="stylesheet"
|
||||||
|
integrity="sha384-LN+7fdVzj6u52u30Kp6M/trliBMCMKTyK833zpbD+pXdCLuTusPj697FH4R/5mcr" crossorigin="anonymous">
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<nav class="navbar bg-body-tertiary">
|
||||||
|
<div class="container-fluid">
|
||||||
|
<span class="navbar-brand mb-0 h1">CloudSave</span>
|
||||||
|
<span class="muted">v{{.Version}}</span>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
<div class="container" style="margin-top: 1rem;">
|
||||||
|
<nav aria-label="breadcrumb">
|
||||||
|
<ol class="breadcrumb">
|
||||||
|
<li class="breadcrumb-item"><a href="/web">Home</a></li>
|
||||||
|
<li class="breadcrumb-item active" aria-current="page">System</li>
|
||||||
|
</ol>
|
||||||
|
</nav>
|
||||||
|
<div class="list-group">
|
||||||
|
<h3>Client</h3>
|
||||||
|
<hr />
|
||||||
|
<ul class="list-group list-group-flush">
|
||||||
|
<li class="list-group-item">Version: {{.Client.Version}}</li>
|
||||||
|
<li class="list-group-item">API Version: {{.Client.APIVersion}}</li>
|
||||||
|
<li class="list-group-item">Go Version: {{.Client.GoVersion}}</li>
|
||||||
|
<li class="list-group-item">OS: {{.Client.OSName}}/{{.Client.OSArchitecture}}</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<hr />
|
||||||
|
<h3>Server</h3>
|
||||||
|
<hr />
|
||||||
|
<ul class="list-group list-group-flush">
|
||||||
|
<li class="list-group-item">Version: {{.Server.Version}}</li>
|
||||||
|
<li class="list-group-item">API Version: {{.Server.APIVersion}}</li>
|
||||||
|
<li class="list-group-item">Go Version: {{.Server.GoVersion}}</li>
|
||||||
|
<li class="list-group-item">OS: {{.Server.OSName}}/{{.Server.OSArchitecture}}</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.7/dist/js/bootstrap.bundle.min.js"
|
||||||
|
integrity="sha384-ndDqU0Gzau9qJ1lfW4pNLlhNTkCfHzAVBReH9diLvGRem5+R9g2FzA8ZGN954O5Q"
|
||||||
|
crossorigin="anonymous"></script>
|
||||||
|
|
||||||
|
</html>
|
||||||
34
cmd/web/web.go
Normal file
34
cmd/web/web.go
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"cloudsave/cmd/web/server"
|
||||||
|
"cloudsave/cmd/web/server/config"
|
||||||
|
"cloudsave/pkg/constants"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"runtime"
|
||||||
|
"strconv"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
fmt.Printf("CloudSave web -- v%s.%s.%s\n\n", constants.Version, runtime.GOOS, runtime.GOARCH)
|
||||||
|
|
||||||
|
var configPath string
|
||||||
|
flag.StringVar(&configPath, "config", "/var/lib/cloudsave/config.json", "Define the path to the configuration file")
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
c, err := config.Load(configPath)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, "failed to load configuration:", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
s := server.NewServer(c)
|
||||||
|
|
||||||
|
fmt.Println("starting server at :" + strconv.Itoa(c.Server.Port))
|
||||||
|
if err := s.Server.ListenAndServe(); err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, "failed to start web server:", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
package constants
|
package constants
|
||||||
|
|
||||||
const Version = "0.0.2"
|
const Version = "0.0.3"
|
||||||
|
|
||||||
const ApiVersion = 1
|
const ApiVersion = 1
|
||||||
|
|||||||
@@ -36,7 +36,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 {
|
||||||
@@ -382,6 +383,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)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user