diff --git a/cmd_serve.go b/cmd_serve.go index db0ee71..8073bcb 100644 --- a/cmd_serve.go +++ b/cmd_serve.go @@ -32,17 +32,20 @@ import ( "net/http" "strings" + "github.com/go-chi/chi" "golang.org/x/crypto/bcrypt" "golang.org/x/net/webdav" ) type CmdServe struct { ListenAddress string `name:"listen-address" default:":3000" help:"Address to listen on for HTTP requests."` + WebDavPath string `name:"dav-path" default:"/dav" help:"Path to use for WebDAV requests."` + AdminPath string `name:"admin-path" default:"/admin" help:"Path to use for the admin interface."` } func (cmd *CmdServe) Run(app *app) error { h := &webdav.Handler{} - h.Prefix = "/dav/" + h.Prefix = cmd.WebDavPath + "/" h.LockSystem = webdav.NewMemLS() h.FileSystem = BaseDir(app.DataDirectory) @@ -95,8 +98,18 @@ func (cmd *CmdServe) Run(app *app) error { h.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), "mapping", &directoryMapping))) }) + r := chi.NewRouter() + r.Mount(cmd.AdminPath, newWebAdminHandler(app)) + + // Can't use Chi at the root, since it can't properly handle the mounted WebDAV + // handler. So we register Chi as the default handler, but delegate the WebDAV + // path with the default mux. + m := http.NewServeMux() + m.Handle("/", r) + m.Handle(h.Prefix, authenticatedWebdavHandler) + fmt.Printf("Listening on %s\n", cmd.ListenAddress) - if err := http.ListenAndServe(cmd.ListenAddress, authenticatedWebdavHandler); err != http.ErrServerClosed && err != nil { + if err := http.ListenAndServe(cmd.ListenAddress, m); err != http.ErrServerClosed && err != nil { return fmt.Errorf("cannot listen: %w", err) } return nil diff --git a/store.go b/store.go index 58289ae..4a9ca96 100644 --- a/store.go +++ b/store.go @@ -52,6 +52,7 @@ type ShareStore interface { AddLogin(share Share, username string, login Login) error RemoveLogin(share Share, username string, loginName string) error + GetShare(id string) (Share, error) GetShares() ([]Share, error) GetShareUsers(share Share) ([]ShareUser, error) GetShareLogins(share Share, username string) ([]Login, error) @@ -476,6 +477,25 @@ func (store *DBStore) RemoveLogin(share Share, username string, loginName string }) } +func (store *DBStore) GetShare(id string) (Share, error) { + var share Share + if err := store.db.View(func(tx *buntdb.Tx) error { + val, err := tx.Get(sharePrefix + id) + if err == buntdb.ErrNotFound { + return ErrShareNotFound + } + + share, err = unmarshalShare(id, val) + if err != nil { + return err + } + return nil + }); err != nil { + return Share{}, err + } + return share, nil +} + func (store *DBStore) GetShares() (shares []Share, err error) { err = store.db.View(func(tx *buntdb.Tx) error { var processingError error diff --git a/store_test.go b/store_test.go index 0ea893e..030f14d 100644 --- a/store_test.go +++ b/store_test.go @@ -201,6 +201,23 @@ func TestStoreShareHandling(t *testing.T) { _ = store.RemoveUser(user2.Username) }() + t.Run("can get single share", func(t *testing.T) { + share, err := store.GetShare(share1.UUID.String()) + if err != nil { + t.Errorf("unexpected error: %v", err) + } + if share != share1 { + t.Error("share should equal share1") + } + }) + + t.Run("unknown share cannot be retrieved", func(t *testing.T) { + _, err := store.GetShare(uuid.NewV4().String()) + if err != ErrShareNotFound { + t.Errorf("unexpected error: %v", err) + } + }) + t.Run("multiple shares should exist", func(t *testing.T) { shares, _ := store.GetShares() if len(shares) != 3 { diff --git a/templates/base.html b/templates/base.html new file mode 100644 index 0000000..5e4779c --- /dev/null +++ b/templates/base.html @@ -0,0 +1,42 @@ + + + + + ShareDAV + + {{ block "page-styles" . }}{{ end }} + + +
+ {{ block "page-content" . }}{{ end }} +
+ +
+ + \ No newline at end of file diff --git a/templates/confirm.html b/templates/confirm.html new file mode 100644 index 0000000..08f26ff --- /dev/null +++ b/templates/confirm.html @@ -0,0 +1,17 @@ +{{ define "page-styles"}} + +{{ end }} + +{{ define "page-content" }} +
+ {{ range $k, $v := .Fields }} + + {{ end }} +
+ {{ $.Message }} +
+
+ +
+
+{{ end }} \ No newline at end of file diff --git a/templates/create-share.html b/templates/create-share.html new file mode 100644 index 0000000..4803c4d --- /dev/null +++ b/templates/create-share.html @@ -0,0 +1,24 @@ +{{ define "page-styles"}} + +{{ end }} + +{{ define "page-content" }} +
+
+ + +
+
+ +
+
+{{ end }} \ No newline at end of file diff --git a/templates/error.html b/templates/error.html new file mode 100644 index 0000000..042a373 --- /dev/null +++ b/templates/error.html @@ -0,0 +1,8 @@ +{{ define "page-content" }} +
+
{{ .ErrorMessage }}
+ {{ if $.ReturnURL }} + Go back + {{ end }} +
+{{ end }} \ No newline at end of file diff --git a/templates/index.html b/templates/index.html new file mode 100644 index 0000000..12669f6 --- /dev/null +++ b/templates/index.html @@ -0,0 +1,3 @@ +{{ define "page-content" }} + Test {{.foo}} +{{ end }} \ No newline at end of file diff --git a/templates/share-add-user.html b/templates/share-add-user.html new file mode 100644 index 0000000..7c9e93a --- /dev/null +++ b/templates/share-add-user.html @@ -0,0 +1,22 @@ +{{ define "page-content" }} +
+ + + + +
+{{ end }} \ No newline at end of file diff --git a/templates/shares.html b/templates/shares.html new file mode 100644 index 0000000..1231ebc --- /dev/null +++ b/templates/shares.html @@ -0,0 +1,47 @@ +{{ define "page-styles"}} + +{{ end }} + +{{ define "page-content" }} +
+ {{ range $share := . }} +
+ UUID: {{ $share.UUID }}
+ + +
+
+ Name: {{ $share.Name }}
+ {{ range $user := .Users }} + + {{ end }} + +
+ {{ end }} + Create Share +
+{{ end }} \ No newline at end of file diff --git a/templates/users.html b/templates/users.html new file mode 100644 index 0000000..32f6bb0 --- /dev/null +++ b/templates/users.html @@ -0,0 +1,16 @@ +{{ define "page-content" }} + + + + + + + + + {{ range $user := .}} + + + {{ end }} + +
UserRole
{{ $user.Username }}{{ $user.Role }}
+{{ end }} \ No newline at end of file diff --git a/webadmin.go b/webadmin.go new file mode 100644 index 0000000..d5d6a4c --- /dev/null +++ b/webadmin.go @@ -0,0 +1,272 @@ +// Copyright (c) 2020, Andreas Schneider +// All rights reserved. +// +// Redistribution and use in source and binary forms, with or without +// modification, are permitted provided that the following conditions are met: +// * Redistributions of source code must retain the above copyright +// notice, this list of conditions and the following disclaimer. +// * Redistributions in binary form must reproduce the above copyright +// notice, this list of conditions and the following disclaimer in the +// documentation and/or other materials provided with the distribution. +// * Neither the name of the nor the +// names of its contributors may be used to endorse or promote products +// derived from this software without specific prior written permission. +// +// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +// ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +// WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +// DISCLAIMED. IN NO EVENT SHALL BE LIABLE FOR ANY +// DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +// (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +// LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND +// ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +// (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +// SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +package main + +import ( + "fmt" + "html/template" + "net/http" + "path" + + "github.com/go-chi/chi" + uuid "github.com/satori/go.uuid" +) + +type webAdminHandler struct { + router chi.Router + tplError *template.Template + tplConfirm *template.Template + tplIndex *template.Template + tplUsers *template.Template + tplShares *template.Template + tplShareAddUser *template.Template + tplCreateShare *template.Template +} + +func newWebAdminHandler(app *app) *webAdminHandler { + loadTemplate := func(filename string) *template.Template { + t, err := template.ParseFiles( + path.Join("templates", "base.html"), + path.Join("templates", filename+".html"), + ) + if err != nil { + panic(fmt.Sprintf("cannot load template %q: %v", filename, err)) + } + return t + } + + h := &webAdminHandler{ + tplError: loadTemplate("error"), + tplConfirm: loadTemplate("confirm"), + tplIndex: loadTemplate("index"), + tplUsers: loadTemplate("users"), + tplShares: loadTemplate("shares"), + tplShareAddUser: loadTemplate("share-add-user"), + tplCreateShare: loadTemplate("create-share"), + } + + renderPage := func(w http.ResponseWriter, tmpl *template.Template, model interface{}) { + if err := tmpl.Execute(w, model); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + } + } + + renderError := func(w http.ResponseWriter, msg, returnURL string) { + model := struct { + ErrorMessage string + ReturnURL string + }{ + ErrorMessage: msg, + ReturnURL: returnURL, + } + + renderPage(w, h.tplError, model) + } + + const confirmRequested = 0 + const confirmAccepted = 1 + const confirmDenied = 2 + confirm := func(w http.ResponseWriter, r *http.Request, msg template.HTML, returnURL string) int { + if r.FormValue("_yes") != "" { + return confirmAccepted + } else if r.FormValue("_no") != "" { + return confirmDenied + } else { + model := struct { + URL string + Fields map[string]string + Message template.HTML + }{ + URL: returnURL, + Fields: make(map[string]string), + Message: msg, + } + + for k, v := range r.Form { + model.Fields[k] = v[0] + } + + renderPage(w, h.tplConfirm, model) + return confirmRequested + } + } + + r := chi.NewRouter() + r.Get("/", func(w http.ResponseWriter, r *http.Request) { + renderPage(w, h.tplIndex, map[string]string{"foo": "bar"}) + }) + r.Get("/users", func(w http.ResponseWriter, r *http.Request) { + users, err := app.userStore.GetUsers() + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + renderPage(w, h.tplUsers, users) + }) + r.Get("/shares", func(w http.ResponseWriter, r *http.Request) { + shares, err := app.shareStore.GetShares() + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + type shareInfo struct { + Share + Users []ShareUser + } + + shareInfos := make([]shareInfo, len(shares)) + for i := range shares { + shareInfos[i].Share = shares[i] + users, err := app.shareStore.GetShareUsers(shares[i]) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + shareInfos[i].Users = users + } + + renderPage(w, h.tplShares, shareInfos) + }) + r.Route("/share-add-user", func(r chi.Router) { + r.Get("/", func(w http.ResponseWriter, r *http.Request) { + shareId := r.URL.Query().Get("share") + if shareId == "" { + http.Error(w, "invalid share id", http.StatusBadRequest) + return + } + + users, err := app.userStore.GetUsers() + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + model := map[string]interface{}{ + "ShareId": shareId, + "Users": users, + } + + renderPage(w, h.tplShareAddUser, model) + }) + r.Post("/", func(w http.ResponseWriter, r *http.Request) { + returnURL := "share-add-user?share=" + r.FormValue("share") + + shareId, err := uuid.FromString(r.FormValue("share")) + if err != nil { + renderError(w, "Internal error: "+err.Error(), "") + return + } + share := Share{UUID: shareId} + + user, err := app.userStore.GetUser(r.FormValue("user")) + if err == ErrUserNotFound { + renderError(w, "User not found.", returnURL) + return + } else if err != nil { + renderError(w, "Internal error: "+err.Error(), "") + return + } + + err = app.shareStore.AddUserToShare(share, user.Username, ShareRole(r.FormValue("role"))) + if err != nil { + renderError(w, "Cannot add user to share: "+err.Error(), returnURL) + return + } + + http.Redirect(w, r, "shares", http.StatusFound) + }) + }) + r.Post("/share-delete-user", func(w http.ResponseWriter, r *http.Request) { + returnURL := "shares" + + shareId, err := uuid.FromString(r.FormValue("share")) + if err != nil { + renderError(w, "Internal error: "+err.Error(), "") + return + } + share := Share{UUID: shareId} + + err = app.shareStore.RemoveUserFromShare(share, r.FormValue("user")) + if err != nil { + renderError(w, "Cannot remove user from share: "+err.Error(), returnURL) + return + } + + http.Redirect(w, r, "shares", http.StatusFound) + }) + r.Route("/create-share", func(r chi.Router) { + r.Get("/", func(w http.ResponseWriter, r *http.Request) { + renderPage(w, h.tplCreateShare, nil) + }) + r.Post("/", func(w http.ResponseWriter, r *http.Request) { + share, err := app.shareStore.CreateShare() + if err != nil { + renderError(w, "Cannot create share: "+err.Error(), "") + return + } + + share.Name = r.FormValue("name") + share.Description = r.FormValue("description") + + if err := app.shareStore.UpdateShareAttributes(share); err != nil { + renderError(w, "Cannot update share: "+err.Error(), "") + return + } + + http.Redirect(w, r, "shares#share-"+share.UUID.String(), http.StatusFound) + }) + }) + r.Post("/delete-share", func(w http.ResponseWriter, r *http.Request) { + share, err := app.shareStore.GetShare(r.FormValue("share")) + if err != nil { + renderError(w, "Internal error: "+err.Error(), "") + return + } + + message := fmt.Sprintf(`You are about to delete the share %s (%s).
+This will delete all data permanently.

+Are you sure you want to continue?`, share.UUID, share.Name) + if confirmStatus := confirm(w, r, template.HTML(message), "delete-share"); confirmStatus == confirmRequested { + // We have already rendered. Nothing to do. + return + } else if confirmStatus == confirmAccepted { + if err := app.shareStore.RemoveShare(share.UUID); err != nil { + renderError(w, "Share cannot be removed: "+err.Error(), "shares") + return + } + } + http.Redirect(w, r, "shares", http.StatusFound) + }) + h.router = r + + return h +} + +func (h webAdminHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + h.router.ServeHTTP(w, r) +}