🚧 Implement admin interface to manage shares and users

This commit is contained in:
Andreas Schneider 2020-10-24 16:57:00 +02:00
parent f798551b97
commit 5034d1a1f8
12 changed files with 503 additions and 2 deletions

View File

@ -32,17 +32,20 @@ import (
"net/http" "net/http"
"strings" "strings"
"github.com/go-chi/chi"
"golang.org/x/crypto/bcrypt" "golang.org/x/crypto/bcrypt"
"golang.org/x/net/webdav" "golang.org/x/net/webdav"
) )
type CmdServe struct { type CmdServe struct {
ListenAddress string `name:"listen-address" default:":3000" help:"Address to listen on for HTTP requests."` 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 { func (cmd *CmdServe) Run(app *app) error {
h := &webdav.Handler{} h := &webdav.Handler{}
h.Prefix = "/dav/" h.Prefix = cmd.WebDavPath + "/"
h.LockSystem = webdav.NewMemLS() h.LockSystem = webdav.NewMemLS()
h.FileSystem = BaseDir(app.DataDirectory) 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))) 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) 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 fmt.Errorf("cannot listen: %w", err)
} }
return nil return nil

View File

@ -52,6 +52,7 @@ type ShareStore interface {
AddLogin(share Share, username string, login Login) error AddLogin(share Share, username string, login Login) error
RemoveLogin(share Share, username string, loginName string) error RemoveLogin(share Share, username string, loginName string) error
GetShare(id string) (Share, error)
GetShares() ([]Share, error) GetShares() ([]Share, error)
GetShareUsers(share Share) ([]ShareUser, error) GetShareUsers(share Share) ([]ShareUser, error)
GetShareLogins(share Share, username string) ([]Login, 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) { func (store *DBStore) GetShares() (shares []Share, err error) {
err = store.db.View(func(tx *buntdb.Tx) error { err = store.db.View(func(tx *buntdb.Tx) error {
var processingError error var processingError error

View File

@ -201,6 +201,23 @@ func TestStoreShareHandling(t *testing.T) {
_ = store.RemoveUser(user2.Username) _ = 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) { t.Run("multiple shares should exist", func(t *testing.T) {
shares, _ := store.GetShares() shares, _ := store.GetShares()
if len(shares) != 3 { if len(shares) != 3 {

42
templates/base.html Normal file
View File

@ -0,0 +1,42 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>ShareDAV</title>
<style>
thead {
font-weight: bold;
}
#content {
float: right;
width: 100%;
background-color: #F0F0F0;
}
#menu {
float: left;
width: 200px;
margin-left: -200px;
background-color: #CCCCCC;
}
#menu span {
display: block;
}
body {
margin-left: 200px
}
</style>
{{ block "page-styles" . }}{{ end }}
</head>
<body>
<div id="content">
{{ block "page-content" . }}{{ end }}
</div>
<div id="menu">
<span><a href="./">ShareDAV</a></span>
<span><a href="users">Users</a></span>
<span><a href="shares">Shares</a></span>
</div>
<div style="clear: both;"></div>
</body>
</html>

17
templates/confirm.html Normal file
View File

@ -0,0 +1,17 @@
{{ define "page-styles"}}
{{ end }}
{{ define "page-content" }}
<form method="post" action="{{ $.URL }}">
{{ range $k, $v := .Fields }}
<input type="hidden" name="{{ $k }}" value="{{ $v }}"/>
{{ end }}
<div class="confirm message">
{{ $.Message }}
</div>
<div>
<input type="submit" name="_yes" value="Yes"/> <input type="submit" name="_no" value="No"/>
</div>
</form>
{{ end }}

View File

@ -0,0 +1,24 @@
{{ define "page-styles"}}
<style>
#content label,
#content label > * {
display: block;
}
</style>
{{ end }}
{{ define "page-content" }}
<form method="post">
<div>
<label>
Name: <input type="text" width="40" name="name" placeholder="Enter name"/>
</label>
<label>
Role: <textarea rows="5" cols="40" name="description" placeholder="Enter description"></textarea>
</label>
</div>
<div>
<input type="submit" value="Create share"/>
</div>
</form>
{{ end }}

8
templates/error.html Normal file
View File

@ -0,0 +1,8 @@
{{ define "page-content" }}
<div class="error">
<div class="message">{{ .ErrorMessage }}</div>
{{ if $.ReturnURL }}
<a href="{{ $.ReturnURL }}">Go back</a>
{{ end }}
</div>
{{ end }}

3
templates/index.html Normal file
View File

@ -0,0 +1,3 @@
{{ define "page-content" }}
Test {{.foo}}
{{ end }}

View File

@ -0,0 +1,22 @@
{{ define "page-content" }}
<form method="post">
<input name="share" type="hidden" value="{{ .ShareId }}"/>
<label>
User:
<select name="user">
{{ range $user := .Users}}
<option value="{{ $user.Username }}">{{ $user.Username }}</option>
{{ end }}
</select>
</label>
<label>
Role:
<select name="role">
<option value="reader">Reader</option>
<option value="writer" selected>Writer</option>
<option value="admin">Admin</option>
</select>
</label>
<input type="submit" value="Add User"/>
</form>
{{ end }}

47
templates/shares.html Normal file
View File

@ -0,0 +1,47 @@
{{ define "page-styles"}}
<style>
div.share-user {
margin-left: 2em;
margin-top: .5em;
font-style: italic;
}
a.share-add-user {
margin-left: 2em;
margin-top: .5em;
font-style: italic;
}
div.share {
margin-bottom: 2em;
}
:target {
background-color: #c4e1ff;
}
</style>
{{ end }}
{{ define "page-content" }}
<div id="shares">
{{ range $share := . }}
<div id="share-{{$share.UUID}}" class="share">
UUID: {{ $share.UUID }} <form style="display: inline-block;" action="delete-share" method="post">
<input type="hidden" name="share" value="{{ $share.UUID }}"/>
<input type="submit" value="Delete"/>
</form>
<br/>
Name: {{ $share.Name }}<br/>
{{ range $user := .Users }}
<div id="user-{{$share.UUID}}-{{$user.Username}}" class="share-user">
User {{ $user.Username }} ({{ $user.Role }})
<form style="display: inline-block;" action="share-delete-user" method="post">
<input type="hidden" name="share" value="{{ $share.UUID }}"/>
<input type="hidden" name="user" value="{{ $user.Username }}"/>
<input type="submit" value="Delete"/>
</form>
</div>
{{ end }}
<a href="share-add-user?share={{ $share.UUID }}" class="share-add-user">Add User</a>
</div>
{{ end }}
<a href="create-share" class="create-share">Create Share</a>
</div>
{{ end }}

16
templates/users.html Normal file
View File

@ -0,0 +1,16 @@
{{ define "page-content" }}
<table>
<thead>
<tr>
<td>User</td>
<td>Role</td>
</tr>
</thead>
<tbody>
{{ range $user := .}}<tr>
<td>{{ $user.Username }}</td>
<td>{{ $user.Role }}</td>
</tr>{{ end }}
</tbody>
</table>
{{ end }}

272
webadmin.go Normal file
View File

@ -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 <organization> 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 <COPYRIGHT HOLDER> 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).<br/>
This will delete all data permanently.<br/><br/>
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)
}