aliases: add overview and revoke handlers

This commit is contained in:
Henry 2021-03-12 14:42:30 +01:00
parent 9d60d09843
commit 50e4ebbaca
12 changed files with 371 additions and 7 deletions

View File

@ -245,6 +245,7 @@ func runroomsrv() error {
},
roomsrv.StateManager,
handlers.Databases{
Aliases: db.Aliases,
AuthWithSSB: db.AuthWithSSB,
AuthFallback: db.AuthFallback,
AllowList: db.AllowList,

View File

@ -0,0 +1,97 @@
// SPDX-License-Identifier: MIT
package admin
import (
"errors"
"fmt"
"net/http"
"strconv"
"github.com/gorilla/csrf"
"go.mindeco.de/http/render"
"github.com/ssb-ngi-pointer/go-ssb-room/roomdb"
weberrors "github.com/ssb-ngi-pointer/go-ssb-room/web/errors"
)
type aliasesHandler struct {
r *render.Renderer
db roomdb.AliasService
}
const redirectToAliases = "/admin/aliases"
func (h aliasesHandler) overview(rw http.ResponseWriter, req *http.Request) (interface{}, error) {
lst, err := h.db.List(req.Context())
if err != nil {
return nil, err
}
// Reverse the slice to provide recent-to-oldest results
for i, j := 0, len(lst)-1; i < j; i, j = i+1, j-1 {
lst[i], lst[j] = lst[j], lst[i]
}
pageData, err := paginate(lst, len(lst), req.URL.Query())
if err != nil {
return nil, err
}
return pageData, nil
}
func (h aliasesHandler) revokeConfirm(rw http.ResponseWriter, req *http.Request) (interface{}, error) {
if req.Method != "GET" {
return nil, weberrors.ErrBadRequest{Where: "HTTP Method", Details: fmt.Errorf("expected GET request")}
}
id, err := strconv.ParseInt(req.URL.Query().Get("id"), 10, 64)
if err != nil {
err = weberrors.ErrBadRequest{Where: "ID", Details: err}
return nil, err
}
entry, err := h.db.GetByID(req.Context(), id)
if err != nil {
if errors.Is(err, roomdb.ErrNotFound) {
http.Redirect(rw, req, redirectToAliases, http.StatusFound)
return nil, ErrRedirected
}
return nil, err
}
return map[string]interface{}{
"Entry": entry,
csrf.TemplateTag: csrf.TemplateField(req),
}, nil
}
func (h aliasesHandler) revoke(rw http.ResponseWriter, req *http.Request) {
if req.Method != "POST" {
err := weberrors.ErrBadRequest{Where: "HTTP Method", Details: fmt.Errorf("expected POST request")}
h.r.Error(rw, req, http.StatusMethodNotAllowed, err)
return
}
err := req.ParseForm()
if err != nil {
err = weberrors.ErrBadRequest{Where: "Form data", Details: err}
http.Redirect(rw, req, redirectToAliases, http.StatusFound)
return
}
status := http.StatusFound
err = h.db.Revoke(req.Context(), req.FormValue("name"))
if err != nil {
if !errors.Is(err, roomdb.ErrNotFound) {
h.r.Error(rw, req, http.StatusInternalServerError, err)
return
}
status = http.StatusNotFound
}
http.Redirect(rw, req, redirectToAliases, status)
}

View File

@ -0,0 +1,122 @@
package admin
import (
"bytes"
"net/http"
"net/url"
"testing"
"github.com/stretchr/testify/assert"
"github.com/ssb-ngi-pointer/go-ssb-room/roomdb"
"github.com/ssb-ngi-pointer/go-ssb-room/web"
"github.com/ssb-ngi-pointer/go-ssb-room/web/router"
"github.com/ssb-ngi-pointer/go-ssb-room/web/webassert"
refs "go.mindeco.de/ssb-refs"
)
func TestAliasesOverview(t *testing.T) {
ts := newSession(t)
a := assert.New(t)
lst := []roomdb.Alias{
{ID: 1, Name: "alice", Feed: refs.FeedRef{ID: bytes.Repeat([]byte{0}, 32), Algo: "fake"}},
{ID: 2, Name: "bob", Feed: refs.FeedRef{ID: bytes.Repeat([]byte("1312"), 8), Algo: "test"}},
{ID: 3, Name: "cleo", Feed: refs.FeedRef{ID: bytes.Repeat([]byte("acab"), 8), Algo: "true"}},
}
ts.Aliases.ListReturns(lst, nil)
html, resp := ts.Client.GetHTML("/aliases")
a.Equal(http.StatusOK, resp.Code, "wrong HTTP status code")
webassert.Localized(t, html, []webassert.LocalizedElement{
{"#welcome", "AdminAliasesWelcome"},
{"title", "AdminAliasesTitle"},
{"#aliasCount", "ListCountPlural"},
})
a.EqualValues(html.Find("#theList li").Length(), 3)
lst = []roomdb.Alias{
{ID: 666, Name: "dave", Feed: refs.FeedRef{ID: bytes.Repeat([]byte{1}, 32), Algo: "one"}},
}
ts.Aliases.ListReturns(lst, nil)
html, resp = ts.Client.GetHTML("/aliases")
a.Equal(http.StatusOK, resp.Code, "wrong HTTP status code")
webassert.Localized(t, html, []webassert.LocalizedElement{
{"#welcome", "AdminAliasesWelcome"},
{"title", "AdminAliasesTitle"},
{"#aliasCount", "ListCountSingular"},
})
elems := html.Find("#theList li")
a.EqualValues(elems.Length(), 1)
// check for link to Revoke confirm link
link, yes := elems.ContentsFiltered("a").Attr("href")
a.True(yes, "a-tag has href attribute")
a.Equal("/admin/aliases/revoke/confirm?id=666", link)
}
func TestAliasesRevokeConfirmation(t *testing.T) {
ts := newSession(t)
a := assert.New(t)
testKey, err := refs.ParseFeedRef("@x7iOLUcq3o+sjGeAnipvWeGzfuYgrXl8L4LYlxIhwDc=.ed25519")
a.NoError(err)
testEntry := roomdb.Alias{ID: 666, Name: "the-test-name", Feed: *testKey}
ts.Aliases.GetByIDReturns(testEntry, nil)
urlTo := web.NewURLTo(ts.Router)
urlRevokeConfirm := urlTo(router.AdminAliasesRevokeConfirm, "id", 3)
html, resp := ts.Client.GetHTML(urlRevokeConfirm.String())
a.Equal(http.StatusOK, resp.Code, "wrong HTTP status code")
a.Equal(testKey.Ref(), html.Find("pre#verify").Text(), "has the key for verification")
form := html.Find("form#confirm")
method, ok := form.Attr("method")
a.True(ok, "form has method set")
a.Equal("POST", method)
action, ok := form.Attr("action")
a.True(ok, "form has action set")
addURL, err := ts.Router.Get(router.AdminAliasesRevoke).URL()
a.NoError(err)
a.Equal(addURL.String(), action)
webassert.InputsInForm(t, form, []webassert.InputElement{
{Name: "name", Type: "hidden", Value: testEntry.Name},
})
}
func TestAliasesRevoke(t *testing.T) {
ts := newSession(t)
a := assert.New(t)
urlTo := web.NewURLTo(ts.Router)
urlRevoke := urlTo(router.AdminAliasesRevoke)
ts.Aliases.RevokeReturns(nil)
addVals := url.Values{"name": []string{"the-name"}}
rec := ts.Client.PostForm(urlRevoke.String(), addVals)
a.Equal(http.StatusFound, rec.Code)
a.Equal(1, ts.Aliases.RevokeCallCount())
_, theName := ts.Aliases.RevokeArgsForCall(0)
a.EqualValues("the-name", theName)
// now for unknown ID
ts.Aliases.RevokeReturns(roomdb.ErrNotFound)
addVals = url.Values{"name": []string{"nope"}}
rec = ts.Client.PostForm(urlRevoke.String(), addVals)
a.Equal(http.StatusNotFound, rec.Code)
//TODO: update redirect code with flash errors
}

View File

@ -1,3 +1,5 @@
// SPDX-License-Identifier: MIT
package admin
import (

View File

@ -27,6 +27,7 @@ type testSession struct {
Client *tester.Tester
Router *mux.Router
Aliases *mockdb.FakeAliasService
AllowListDB *mockdb.FakeAllowListService
PinnedDB *mockdb.FakePinnedNoticesService
NoticeDB *mockdb.FakeNoticesService
@ -43,6 +44,7 @@ func newSession(t *testing.T) *testSession {
var ts testSession
// fake dbs
ts.Aliases = new(mockdb.FakeAliasService)
ts.AllowListDB = new(mockdb.FakeAllowListService)
ts.PinnedDB = new(mockdb.FakePinnedNoticesService)
ts.NoticeDB = new(mockdb.FakeNoticesService)
@ -97,6 +99,7 @@ func newSession(t *testing.T) *testSession {
r,
ts.RoomState,
Databases{
Aliases: ts.Aliases,
AllowList: ts.AllowListDB,
Invites: ts.InvitesDB,
Notices: ts.NoticeDB,

View File

@ -22,6 +22,9 @@ var HTMLTemplates = []string{
"admin/dashboard.tmpl",
"admin/menu.tmpl",
"admin/aliases.tmpl",
"admin/aliases-revoke-confirm.tmpl",
"admin/allow-list.tmpl",
"admin/allow-list-remove-confirm.tmpl",
@ -34,6 +37,7 @@ var HTMLTemplates = []string{
// Databases is an option struct that encapsualtes the required database services
type Databases struct {
Aliases roomdb.AliasService
AllowList roomdb.AllowListService
Invites roomdb.InviteService
Notices roomdb.NoticesService
@ -62,14 +66,22 @@ func Handler(
return map[string]interface{}{}, nil
}))
var ah = allowListHandler{
var ah = aliasesHandler{
r: r,
db: dbs.Aliases,
}
mux.HandleFunc("/aliases", r.HTML("admin/aliases.tmpl", ah.overview))
mux.HandleFunc("/aliases/revoke/confirm", r.HTML("admin/aliases-revoke-confirm.tmpl", ah.revokeConfirm))
mux.HandleFunc("/aliases/revoke", ah.revoke)
var mh = allowListHandler{
r: r,
al: dbs.AllowList,
}
mux.HandleFunc("/members", r.HTML("admin/allow-list.tmpl", ah.overview))
mux.HandleFunc("/members/add", ah.add)
mux.HandleFunc("/members/remove/confirm", r.HTML("admin/allow-list-remove-confirm.tmpl", ah.removeConfirm))
mux.HandleFunc("/members/remove", ah.remove)
mux.HandleFunc("/members", r.HTML("admin/allow-list.tmpl", mh.overview))
mux.HandleFunc("/members/add", mh.add)
mux.HandleFunc("/members/remove/confirm", r.HTML("admin/allow-list-remove-confirm.tmpl", mh.removeConfirm))
mux.HandleFunc("/members/remove", mh.remove)
var ih = invitesHandler{
r: r,

View File

@ -19,8 +19,8 @@ import (
"go.mindeco.de/logging"
"golang.org/x/crypto/ed25519"
"github.com/ssb-ngi-pointer/go-ssb-room/roomdb"
"github.com/ssb-ngi-pointer/go-ssb-room/internal/repo"
"github.com/ssb-ngi-pointer/go-ssb-room/roomdb"
"github.com/ssb-ngi-pointer/go-ssb-room/roomstate"
"github.com/ssb-ngi-pointer/go-ssb-room/web"
"github.com/ssb-ngi-pointer/go-ssb-room/web/handlers/admin"
@ -42,6 +42,7 @@ var HTMLTemplates = []string{
// Databases is an options stuct for the required databases of the web handlers
type Databases struct {
Aliases roomdb.AliasService
AuthWithSSB roomdb.AuthWithSSBService
AuthFallback roomdb.AuthFallbackService
AllowList roomdb.AllowListService
@ -97,7 +98,11 @@ func New(
}),
render.InjectTemplateFunc("current_page_is", func(r *http.Request) interface{} {
return func(routeName string) bool {
url, err := router.CompleteApp().Get(routeName).URLPath()
route := router.CompleteApp().Get(routeName)
if route == nil {
return false
}
url, err := route.URLPath()
if err != nil {
return false
}
@ -226,6 +231,7 @@ func New(
r,
roomState,
admin.Databases{
Aliases: dbs.Aliases,
AllowList: dbs.AllowList,
Invites: dbs.Invites,
Notices: dbs.Notices,

View File

@ -17,6 +17,10 @@ AuthSignOut = "Sign out"
AdminDashboardWelcome = "Welcome to your dashboard"
AdminDashboardTitle = "Room Admin Dashboard"
AdminAliasesTitle = "Aliases"
AdminAliasesWelcome = "Here you can see and revoke the registerd aliases of this room."
AdminAliasesRevoke = "Revoke"
AdminAllowListTitle = "Members"
AdminAllowListWelcome = "Here you can see all the members of the room and ways to add new ones (by their SSB ID) or remove exising ones."
AdminAllowListAdd = "Add"

View File

@ -9,6 +9,10 @@ const (
AdminDashboard = "admin:dashboard"
AdminMenu = "admin:menu"
AdminAliasesOverview = "admin:aliases:overview"
AdminAliasesRevokeConfirm = "admin:aliases:revoke:confirm"
AdminAliasesRevoke = "admin:aliases:revoke"
AdminAllowListOverview = "admin:allow-list:overview"
AdminAllowListAdd = "admin:allow-list:add"
AdminAllowListRemoveConfirm = "admin:allow-list:remove:confirm"
@ -34,6 +38,10 @@ func Admin(m *mux.Router) *mux.Router {
m.Path("/dashboard").Methods("GET").Name(AdminDashboard)
m.Path("/menu").Methods("GET").Name(AdminMenu)
m.Path("/aliases").Methods("GET").Name(AdminAliasesOverview)
m.Path("/aliases/revoke/confirm").Methods("GET").Name(AdminAliasesRevokeConfirm)
m.Path("/aliases/revoke").Methods("POST").Name(AdminAliasesRevoke)
m.Path("/members").Methods("GET").Name(AdminAllowListOverview)
m.Path("/members/add").Methods("POST").Name(AdminAllowListAdd)
m.Path("/members/remove/confirm").Methods("GET").Name(AdminAllowListRemoveConfirm)

View File

@ -0,0 +1,31 @@
{{ define "title" }}{{i18n "AdminAliasesRevokeConfirmTitle"}}{{ end }}
{{ define "content" }}
<div class="flex flex-col justify-center items-center h-64">
<span
id="welcome"
class="text-center"
>{{i18n "AdminAliasesRevokeConfirmWelcome"}}</span>
<pre
id="verify"
class="my-4 font-mono truncate max-w-full text-lg text-gray-700"
>{{.Entry.Feed.Ref}}</pre>
<form id="confirm" action="{{urlTo "admin:aliases:revoke"}}" method="POST">
{{ .csrfField }}
<input type="hidden" name="name" value={{.Entry.Name}}>
<div class="grid grid-cols-2 gap-4">
<a
href="javascript:history.back()"
class="px-4 h-8 shadow rounded flex flex-row justify-center items-center bg-white align-middle text-gray-600 focus:outline-none focus:ring-2 focus:ring-gray-300 focus:ring-opacity-50"
>{{i18n "GenericGoBack"}}</a>
<button
type="submit"
class="shadow rounded px-4 h-8 text-gray-100 bg-pink-600 hover:bg-pink-700 focus:outline-none focus:ring-2 focus:ring-pink-600 focus:ring-opacity-50"
>{{i18n "GenericConfirm"}}</button>
</div>
</form>
</div>
{{end}}

View File

@ -0,0 +1,69 @@
{{ define "title" }}{{i18n "AdminAliasesTitle"}}{{ end }}
{{ define "content" }}
<h1
class="text-3xl tracking-tight font-black text-black mt-2 mb-4"
>{{i18n "AdminAliasesTitle"}}</h1>
<p id="welcome" class="my-2">{{i18n "AdminAliasesWelcome"}}</p>
<p
id="aliasCount"
class="text-lg font-bold my-2"
>{{i18npl "ListCount" .Count}}</p>
<ul id="theList" class="divide-y pb-4">
{{range .Entries}}
<li class="flex flex-row items-center h-12">
<span
class="font-mono truncate flex-auto text-gray-600 tracking-wider"
>{{.Feed.Ref}}</span>
<a
href="{{urlTo "admin:aliases:revoke:confirm" "id" .ID}}"
class="pl-4 w-20 py-2 text-center text-gray-400 hover:text-red-600 font-bold cursor-pointer"
>{{i18n "AdminAliasesRevoke"}}</a>
</li>
{{end}}
</ul>
{{$pageNums := .Paginator.PageNums}}
{{$view := .View}}
{{if gt $pageNums 1}}
<div class="flex flex-row justify-center">
{{if not .FirstInView}}
<a
href="{{urlTo "admin:allow-list:overview"}}?page=1"
class="rounded px-3 py-2 text-pink-600 border-transparent hover:border-pink-400 border-2"
>1</a>
<span
class="px-3 py-2 text-gray-400 border-2 border-transparent"
>..</span>
{{end}}
{{range $view.Pages}}
{{if le . $pageNums}}
{{if eq . $view.Current}}
<span
class="px-3 py-2 cursor-default text-gray-500 border-2 border-transparent"
>{{.}}</span>
{{else}}
<a
href="{{urlTo "admin:allow-list:overview"}}?page={{.}}"
class="rounded px-3 py-2 mx-1 text-pink-600 border-transparent hover:border-pink-400 border-2"
>{{.}}</a>
{{end}}
{{end}}
{{end}}
{{if not .LastInView}}
<span
class="px-3 py-2 text-gray-400 border-2 border-transparent"
>..</span>
<a
href="{{urlTo "admin:allow-list:overview"}}?page={{$view.Last}}"
class="rounded px-3 py-2 text-pink-600 border-transparent hover:border-pink-400 border-2"
>{{$view.Last}}</a>
{{end}}
</div>
{{end}}
{{end}}

View File

@ -18,6 +18,15 @@
</svg>{{i18n "NavAdminDashboard"}}
</a>
<a
href="{{urlTo "admin:aliases:overview"}}"
class="{{if current_page_is "admin:aliases:overview"}}bg-gray-300 {{else}}hover:bg-gray-200 {{end}}pr-1 pl-2 py-3 sm:py-1 rounded-md flex flex-row items-center font-semibold text-sm text-gray-700 hover:text-gray-800 truncate"
>
<svg class="text-green-600 w-4 h-4 mr-1" viewBox="0 0 24 24">
<path fill="currentColor" d="M23,12L20.56,9.22L20.9,5.54L17.29,4.72L15.4,1.54L12,3L8.6,1.54L6.71,4.72L3.1,5.53L3.44,9.21L1,12L3.44,14.78L3.1,18.47L6.71,19.29L8.6,22.47L12,21L15.4,22.46L17.29,19.28L20.9,18.46L20.56,14.78L23,12M10,17L6,13L7.41,11.59L10,14.17L16.59,7.58L18,9L10,17Z" />
</svg>{{i18n "AdminAliasesTitle"}}
</a>
<a
href="{{urlTo "admin:allow-list:overview"}}"
class="{{if current_page_is "admin:allow-list:overview"}}bg-gray-300 {{else}}hover:bg-gray-200 {{end}}pr-1 pl-2 py-3 sm:py-1 rounded-md flex flex-row items-center font-semibold text-sm text-gray-700 hover:text-gray-800 truncate"