✨🚧 Implement admin interface to manage shares and users
This commit is contained in:
parent
f798551b97
commit
5034d1a1f8
17
cmd_serve.go
17
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
|
||||
|
|
20
store.go
20
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
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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>
|
|
@ -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 }}
|
|
@ -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 }}
|
|
@ -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 }}
|
|
@ -0,0 +1,3 @@
|
|||
{{ define "page-content" }}
|
||||
Test {{.foo}}
|
||||
{{ end }}
|
|
@ -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 }}
|
|
@ -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 }}
|
|
@ -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 }}
|
|
@ -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)
|
||||
}
|
Loading…
Reference in New Issue