diff --git a/.gitignore b/.gitignore index 66a6401..5de49e4 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,7 @@ /cli /server +/web /env/ -/build/ \ No newline at end of file +/build/ +*.exe +/config.json \ No newline at end of file diff --git a/build.sh b/build.sh index 386ea6c..59974a3 100755 --- a/build.sh +++ b/build.sh @@ -55,6 +55,28 @@ for platform in "${platforms[@]}"; do fi done +# WEB + +platforms=("linux/amd64" "linux/arm64" "linux/riscv64" "linux/ppc64le") + +for platform in "${platforms[@]}"; do + echo "* Compiling web server for $platform..." + platform_split=(${platform//\// }) + + EXT="" + if [ "${platform_split[0]}" == "windows" ]; then + EXT=.exe + fi + + if [ "$MAKE_PACKAGE" == "true" ]; then + CGO_ENABLED=0 GOOS=${platform_split[0]} GOARCH=${platform_split[1]} go build -o build/cloudsave_web$EXT -a ./cmd/web + tar -czf build/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 platforms=("windows/amd64" "windows/arm64" "darwin/amd64" "darwin/arm64" "linux/amd64" "linux/arm64") diff --git a/cmd/web/config.template.json b/cmd/web/config.template.json new file mode 100644 index 0000000..83c88ac --- /dev/null +++ b/cmd/web/config.template.json @@ -0,0 +1,8 @@ +{ + "server": { + "port": 8181 + }, + "remote": { + "url": "http://localhost:8080" + } +} \ No newline at end of file diff --git a/cmd/web/server/config/config.go b/cmd/web/server/config/config.go new file mode 100644 index 0000000..87616bc --- /dev/null +++ b/cmd/web/server/config/config.go @@ -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 +} diff --git a/cmd/web/server/middlewares.go b/cmd/web/server/middlewares.go new file mode 100644 index 0000000..eda0a7d --- /dev/null +++ b/cmd/web/server/middlewares.go @@ -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) + } +} diff --git a/cmd/web/server/server.go b/cmd/web/server/server.go new file mode 100644 index 0000000..7eee69a --- /dev/null +++ b/cmd/web/server/server.go @@ -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 + } +} diff --git a/cmd/web/server/templates/401.html b/cmd/web/server/templates/401.html new file mode 100644 index 0000000..6f52e99 --- /dev/null +++ b/cmd/web/server/templates/401.html @@ -0,0 +1,9 @@ + + + + You are not allowed + + +

401 Unauthorized

+ + \ No newline at end of file diff --git a/cmd/web/server/templates/500.html b/cmd/web/server/templates/500.html new file mode 100644 index 0000000..0e14e49 --- /dev/null +++ b/cmd/web/server/templates/500.html @@ -0,0 +1,9 @@ + + + + An error occured + + +

500 Internal Server Error

+ + \ No newline at end of file diff --git a/cmd/web/server/templates/dashboard.html b/cmd/web/server/templates/dashboard.html new file mode 100644 index 0000000..d5cb04e --- /dev/null +++ b/cmd/web/server/templates/dashboard.html @@ -0,0 +1,38 @@ + + + + + + + Dashboard + + + + + +
+
+ {{range .Saves}} + +
+
{{.Name}}
+ {{.Date}} +
+

Version: {{.Version}}

+ {{.ID}} +
+ {{end}} +
+
+ + + + \ No newline at end of file diff --git a/cmd/web/server/templates/detailled.html b/cmd/web/server/templates/detailled.html new file mode 100644 index 0000000..bb68b94 --- /dev/null +++ b/cmd/web/server/templates/detailled.html @@ -0,0 +1,54 @@ + + + + + + + {{.Save.Name}} + + + + + +
+ +
+

{{.Save.Name}} Version {{.Save.Version}}

+
+

Details

+ + +
+

Backup

+ {{ range .BackupMetadata}} +
+
+
{{.CreatedAt}}
+
{{.UUID}}
+

MD5: {{.MD5}}

+
+
+ {{end}} +
+
+ + + + \ No newline at end of file diff --git a/cmd/web/server/templates/information.html b/cmd/web/server/templates/information.html new file mode 100644 index 0000000..5140f78 --- /dev/null +++ b/cmd/web/server/templates/information.html @@ -0,0 +1,52 @@ + + + + + + + System Information + + + + + +
+ +
+

Client

+
+ + +
+

Server

+
+ +
+
+ + + + \ No newline at end of file diff --git a/cmd/web/web.go b/cmd/web/web.go new file mode 100644 index 0000000..2030e3f --- /dev/null +++ b/cmd/web/web.go @@ -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) + } +} diff --git a/pkg/constants/constants.go b/pkg/constants/constants.go index 1dc2fa6..ba987f3 100644 --- a/pkg/constants/constants.go +++ b/pkg/constants/constants.go @@ -1,5 +1,5 @@ package constants -const Version = "0.0.2" +const Version = "0.0.3" const ApiVersion = 1 diff --git a/pkg/remote/client/client.go b/pkg/remote/client/client.go index 76633c2..c161939 100644 --- a/pkg/remote/client/client.go +++ b/pkg/remote/client/client.go @@ -36,7 +36,8 @@ type ( ) 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 { @@ -382,6 +383,10 @@ func (c *Client) get(url string) (obj.HTTPObject, error) { return obj.HTTPObject{}, ErrNotFound } + if res.StatusCode == 401 { + return obj.HTTPObject{}, ErrUnauthorized + } + if res.StatusCode != 200 { return obj.HTTPObject{}, fmt.Errorf("server returns an unexpected status code: %d %s (expected 200)", res.StatusCode, res.Status) }