add web gui

This commit is contained in:
2025-08-06 14:05:29 +02:00
parent 4ee8fe48bc
commit cf96815d0f
14 changed files with 588 additions and 3 deletions

3
.gitignore vendored
View File

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

View File

@@ -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/server_${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")

View File

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

View File

@@ -0,0 +1,40 @@
package config
import (
"encoding/json"
"fmt"
"os"
)
type (
Configuration struct {
Server ServerConfiguration `json:"server"`
Remote RemoteConfiguration `json:"remote"`
}
ServerConfiguration struct {
Port int `json:"port"`
}
RemoteConfiguration struct {
URL string `json:"url"`
}
)
func Load(path string) (Configuration, error) {
f, err := os.OpenFile(path, os.O_RDONLY, 0)
if err != nil {
return Configuration{}, fmt.Errorf("failed to open configuration file: %w", err)
}
defer f.Close()
d := json.NewDecoder(f)
var c Configuration
err = d.Decode(&c)
if err != nil {
return Configuration{}, fmt.Errorf("failed to parse configuration file (%s): %w", path, err)
}
return c, nil
}

View File

@@ -0,0 +1,63 @@
package server
import (
"fmt"
"log"
"net/http"
"golang.org/x/crypto/bcrypt"
)
func recoverMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
err := recover()
if err != nil {
internalServerError(w, r)
}
}()
next.ServeHTTP(w, r)
})
}
// BasicAuth implements a simple middleware handler for adding basic http auth to a route.
func BasicAuth(realm string, creds map[string]string) func(next http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
user, pass, ok := r.BasicAuth()
if !ok {
basicAuthFailed(w, r, realm)
return
}
credPass := creds[user]
if err := bcrypt.CompareHashAndPassword([]byte(credPass), []byte(pass)); err != nil {
basicAuthFailed(w, r, realm)
return
}
next.ServeHTTP(w, r)
})
}
}
func basicAuthFailed(w http.ResponseWriter, r *http.Request, realm string) {
unauthorized(realm, w, r)
}
func unauthorized(realm string, w http.ResponseWriter, r *http.Request) {
w.Header().Add("WWW-Authenticate", fmt.Sprintf(`Basic realm="%s"`, realm))
w.WriteHeader(http.StatusUnauthorized)
_, err := w.Write([]byte(UnauthorizedErrorHTMLPage))
if err != nil {
log.Println(err)
}
}
func internalServerError(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
_, err := w.Write([]byte(InternalServerErrorHTMLPage))
if err != nil {
log.Println(err)
}
}

248
cmd/web/server/server.go Normal file
View 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
}
}

View File

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

View File

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

View File

@@ -0,0 +1,38 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Dashboard</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.7/dist/css/bootstrap.min.css" rel="stylesheet"
integrity="sha384-LN+7fdVzj6u52u30Kp6M/trliBMCMKTyK833zpbD+pXdCLuTusPj697FH4R/5mcr" crossorigin="anonymous">
</head>
<body>
<nav class="navbar bg-body-tertiary">
<div class="container-fluid">
<span class="navbar-brand mb-0 h1">CloudSave</span>
<a class="muted" href="/web/system">v{{.Version}}</a>
</div>
</nav>
<div class="container" style="margin-top: 1rem;">
<div class="list-group">
{{range .Saves}}
<a href="/web/{{.ID}}" class="list-group-item list-group-item-action">
<div class="d-flex w-100 justify-content-between">
<h5 class="mb-1">{{.Name}}</h5>
<small>{{.Date}}</small>
</div>
<p class="mb-1">Version: {{.Version}}</p>
<small>{{.ID}}</small>
</a>
{{end}}
</div>
</div>
</body>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.7/dist/js/bootstrap.bundle.min.js"
integrity="sha384-ndDqU0Gzau9qJ1lfW4pNLlhNTkCfHzAVBReH9diLvGRem5+R9g2FzA8ZGN954O5Q"
crossorigin="anonymous"></script>
</html>

View File

@@ -0,0 +1,54 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{.Save.Name}}</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.7/dist/css/bootstrap.min.css" rel="stylesheet"
integrity="sha384-LN+7fdVzj6u52u30Kp6M/trliBMCMKTyK833zpbD+pXdCLuTusPj697FH4R/5mcr" crossorigin="anonymous">
</head>
<body>
<nav class="navbar bg-body-tertiary">
<div class="container-fluid">
<span class="navbar-brand mb-0 h1">CloudSave</span>
<a class="muted" href="/web/system">v{{.Version}}</a>
</div>
</nav>
<div class="container" style="margin-top: 1rem;">
<nav aria-label="breadcrumb">
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="/web">Home</a></li>
<li class="breadcrumb-item active" aria-current="page">{{.Save.ID}}</li>
</ol>
</nav>
<div class="list-group">
<h1>{{.Save.Name}} <span class="badge text-bg-success">Version {{.Save.Version}}</span></h1>
<hr />
<h3>Details</h3>
<ul class="list-group list-group-flush">
<li class="list-group-item">UUID: {{.Save.ID}}</li>
<li class="list-group-item">Last Upload: {{.Save.Date}}</li>
<li class="list-group-item">Hash (MD5): {{.Hash}}</li>
</ul>
<hr />
<h3>Backup</h3>
{{ range .BackupMetadata}}
<div class="card" style="margin-top: 1rem;">
<div class="card-body">
<h5 class="card-title">{{.CreatedAt}}</h5>
<h6 class="card-subtitle mb-2 text-body-secondary">{{.UUID}}</h6>
<p class="card-text">MD5: {{.MD5}}</p>
</div>
</div>
{{end}}
</div>
</div>
</body>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.7/dist/js/bootstrap.bundle.min.js"
integrity="sha384-ndDqU0Gzau9qJ1lfW4pNLlhNTkCfHzAVBReH9diLvGRem5+R9g2FzA8ZGN954O5Q"
crossorigin="anonymous"></script>
</html>

View File

@@ -0,0 +1,52 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>System Information</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.7/dist/css/bootstrap.min.css" rel="stylesheet"
integrity="sha384-LN+7fdVzj6u52u30Kp6M/trliBMCMKTyK833zpbD+pXdCLuTusPj697FH4R/5mcr" crossorigin="anonymous">
</head>
<body>
<nav class="navbar bg-body-tertiary">
<div class="container-fluid">
<span class="navbar-brand mb-0 h1">CloudSave</span>
<span class="muted">v{{.Version}}</span>
</div>
</nav>
<div class="container" style="margin-top: 1rem;">
<nav aria-label="breadcrumb">
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="/web">Home</a></li>
<li class="breadcrumb-item active" aria-current="page">System</li>
</ol>
</nav>
<div class="list-group">
<h3>Client</h3>
<hr />
<ul class="list-group list-group-flush">
<li class="list-group-item">Version: {{.Client.Version}}</li>
<li class="list-group-item">API Version: {{.Client.APIVersion}}</li>
<li class="list-group-item">Go Version: {{.Client.GoVersion}}</li>
<li class="list-group-item">OS: {{.Client.OSName}}/{{.Client.OSArchitecture}}</li>
</ul>
<hr />
<h3>Server</h3>
<hr />
<ul class="list-group list-group-flush">
<li class="list-group-item">Version: {{.Server.Version}}</li>
<li class="list-group-item">API Version: {{.Server.APIVersion}}</li>
<li class="list-group-item">Go Version: {{.Server.GoVersion}}</li>
<li class="list-group-item">OS: {{.Server.OSName}}/{{.Server.OSArchitecture}}</li>
</ul>
</div>
</div>
</body>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.7/dist/js/bootstrap.bundle.min.js"
integrity="sha384-ndDqU0Gzau9qJ1lfW4pNLlhNTkCfHzAVBReH9diLvGRem5+R9g2FzA8ZGN954O5Q"
crossorigin="anonymous"></script>
</html>

34
cmd/web/web.go Normal file
View File

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

View File

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

View File

@@ -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)
} }