admin html dashboard and handler test

This commit is contained in:
Wouter Groeneveld 2022-04-24 13:27:42 +02:00
parent 6cc83620ba
commit e361567eed
8 changed files with 341 additions and 11 deletions

View File

@ -52,7 +52,7 @@ Accepted form format:
target=https://aaronpk.example/post-by-aaron
```
Will result in a `202 Accepted` - it handles things async. Stores in `.json` files in `[dataPath]/domain`.
Will result in a `202 Accepted` - it handles things async. Stores mentions in a **to approve** and **approved** database separately.
This also saves the author picture/avatar locally - if present in the microformat. It does _not_ resize images, however, if it's bigger than 5 MB, it falls back to a default one.

69
app/admin/dashboard.html Normal file
View File

@ -0,0 +1,69 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Go-Jamming admin dashboard</title>
<style>
body {
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
margin: 1em;
}
thead {
font-weight: bold;
}
thead tr td {
border-bottom: 1px solid black;
}
</style>
</head>
<body>
<h1>🥞 Go-Jamming Admin</h1>
<hr/>
<h2>Mentions To Approve</h2>
{{ range $domain, $mentions := .Mentions }}
<h3>&nbsp;&nbsp;&nbsp;🌐 Domain <em>{{ $domain }}</em>&nbsp;&raquo;</h3>
{{ if $mentions }}
<table>
<thead>
<tr>
<td>Source</td>
<td>Target</td>
<td>Content</td>
<td>Approve?</td>
<td>Reject?</td>
</tr>
</thead>
<tbody>
{{ range $mentions }}
<tr>
<td>{{ .Source }}</td>
<td>{{ .Target }}</td>
<td>{{ .Content }}</td>
<td><a href="{{ .ApproveURL }}">✅ Yes!</a></td>
<td><a href="{{ .RejectURL }}">❌ Nop!</a></td>
</tr>
{{ end }}
</tbody>
</table>
{{ else }}
<p>No mentions to approve, all done.</p>
{{ end }}
{{ end }}
<hr/>
<h2>Config</h2>
Current <code>config.json</code> contents:
<pre>
{{ .Config }}
</pre>
<hr/>
</body>
</html>

View File

@ -1,25 +1,108 @@
package admin
import (
"brainbaking.com/go-jamming/app/mf"
"brainbaking.com/go-jamming/common"
"brainbaking.com/go-jamming/db"
"brainbaking.com/go-jamming/rest"
"fmt"
"github.com/gorilla/mux"
"github.com/rs/zerolog/log"
"net/http"
"text/template"
)
func HandleGet(repo db.MentionRepo) http.HandlerFunc {
import _ "embed"
//go:embed dashboard.html
var dashboardTemplate []byte
//go:embed moderated.html
var moderatedTemplate []byte
type dashboardMention struct {
Source string
Target string
Content string
ApproveURL string
RejectURL string
}
type dashboardData struct {
Config string
Mentions map[string][]dashboardMention
}
type dashboardModerated struct {
Action string
Item string
RedirectURL string
}
func indiewebDataToDashboardMention(c *common.Config, dbMentions []*mf.IndiewebData) []dashboardMention {
var mentions []dashboardMention
for _, dbMention := range dbMentions {
wm := dbMention.AsMention()
// TODO move this to somewhere else? the wm? duplicate in notifier.go
approveUrl := fmt.Sprintf("%sadmin/approve/%s/%s", c.BaseURL, c.Token, wm.Key())
rejectUrl := fmt.Sprintf("%sadmin/reject/%s/%s", c.BaseURL, c.Token, wm.Key())
mentions = append(mentions, dashboardMention{
Source: dbMention.Source,
Target: dbMention.Target,
Content: dbMention.Content,
ApproveURL: approveUrl,
RejectURL: rejectUrl,
})
}
return mentions
}
func getDashboardData(c *common.Config, repo db.MentionRepo) *dashboardData {
data := &dashboardData{
Config: c.String(),
Mentions: map[string][]dashboardMention{},
}
for _, domain := range c.AllowedWebmentionSources {
data.Mentions[domain] = indiewebDataToDashboardMention(c, repo.GetAllToModerate(domain).Data)
}
return data
}
func asTemplate(name string, data []byte) *template.Template {
tmpl, err := template.New(name).Parse(string(data))
if err != nil {
log.Fatal().Err(err).Str("name", name).Msg("Template invalid")
}
return tmpl
}
func HandleGet(c *common.Config, repo db.MentionRepo) http.HandlerFunc {
tmpl := asTemplate("dashboard", dashboardTemplate)
return func(w http.ResponseWriter, r *http.Request) {
err := tmpl.Execute(w, getDashboardData(c, repo))
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
log.Error().Err(err).Msg("Unable to fill in dashboard template")
}
}
}
func HandleGetToApprove(repo db.MentionRepo) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
domain := mux.Vars(r)["domain"]
rest.Json(w, repo.GetAllToModerate(domain))
}
}
// TODO unit tests
// HandleApprove approves the Mention (by key in URL) and adds to the whitelist.
// Returns 200 OK with approved source/target or 404 if key is invalid.
func HandleApprove(c *common.Config, repo db.MentionRepo) http.HandlerFunc {
tmpl := asTemplate("moderated", moderatedTemplate)
return func(w http.ResponseWriter, r *http.Request) {
key := mux.Vars(r)["key"]
@ -30,14 +113,19 @@ func HandleApprove(c *common.Config, repo db.MentionRepo) http.HandlerFunc {
}
c.AddToWhitelist(approved.AsMention().SourceDomain())
w.WriteHeader(http.StatusOK)
fmt.Fprintf(w, "Approved: %s", approved.AsMention().String())
err := tmpl.Execute(w, asDashboardModerated("Approved", approved, c))
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
log.Error().Err(err).Msg("Unable to fill in dashboard template")
}
}
}
// HandleReject rejects the Mention (by key in URL) and adds to the blacklist.
// Returns 200 OK with rejected source/target or 404 if key is invalid.
func HandleReject(c *common.Config, repo db.MentionRepo) http.HandlerFunc {
tmpl := asTemplate("moderated", moderatedTemplate)
return func(w http.ResponseWriter, r *http.Request) {
key := mux.Vars(r)["key"]
@ -48,7 +136,18 @@ func HandleReject(c *common.Config, repo db.MentionRepo) http.HandlerFunc {
}
c.AddToBlacklist(rejected.AsMention().SourceDomain())
w.WriteHeader(http.StatusOK)
fmt.Fprintf(w, "Rejected: %s", rejected.AsMention().String())
err := tmpl.Execute(w, asDashboardModerated("Rejected", rejected, c))
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
log.Error().Err(err).Msg("Unable to fill in dashboard template")
}
}
}
func asDashboardModerated(action string, mention *mf.IndiewebData, c *common.Config) dashboardModerated {
return dashboardModerated{
Action: action,
Item: mention.AsMention().String(),
RedirectURL: fmt.Sprintf("%sadmin/%s", c.BaseURL, c.Token),
}
}

127
app/admin/handler_test.go Normal file
View File

@ -0,0 +1,127 @@
package admin
import (
"brainbaking.com/go-jamming/app/mf"
"brainbaking.com/go-jamming/common"
"brainbaking.com/go-jamming/db"
"fmt"
"github.com/gorilla/mux"
"github.com/stretchr/testify/assert"
"io/ioutil"
"net/http"
"net/http/httptest"
"os"
"testing"
)
var (
cnf = common.Configure()
repo db.MentionRepo
)
func init() {
repo = db.NewMentionRepo(cnf)
}
func TestHandleGet(t *testing.T) {
wm := mf.Mention{
Source: "https://infos.by/markdown-v-nauke/",
Target: "https://brainbaking.com/post/2021/02/writing-academic-papers-in-markdown/",
}
repo.InModeration(wm, &mf.IndiewebData{
Source: wm.Source,
Target: wm.Target,
Name: "mytest",
})
r := mux.NewRouter()
r.HandleFunc("/admin/{token}", HandleGet(cnf, repo)).Methods("GET")
ts := httptest.NewServer(r)
t.Cleanup(func() {
os.Remove("config.json")
ts.Close()
db.Purge()
})
client := &http.Client{}
req, err := http.NewRequest("GET", fmt.Sprintf("%s/admin/%s", ts.URL, cnf.Token), nil)
resp, err := client.Do(req)
assert.NoError(t, err)
contentBytes, _ := ioutil.ReadAll(resp.Body)
content := string(contentBytes)
defer resp.Body.Close()
assert.Contains(t, content, "admin dashboard")
assert.Contains(t, content, wm.Source)
assert.Contains(t, content, wm.Target)
}
func TestHandleReject(t *testing.T) {
wm := mf.Mention{
Source: "https://infos.by/markdown-v-nauke/",
Target: "https://brainbaking.com/post/2021/02/writing-academic-papers-in-markdown/",
}
key, _ := repo.InModeration(wm, &mf.IndiewebData{
Source: wm.Source,
Target: wm.Target,
Name: "mytest",
})
assert.NotEmpty(t, repo.GetAllToModerate("brainbaking.com").Data)
r := mux.NewRouter()
r.HandleFunc("/admin/reject/{token}/{key}", HandleReject(cnf, repo)).Methods("GET")
ts := httptest.NewServer(r)
t.Cleanup(func() {
os.Remove("config.json")
ts.Close()
db.Purge()
})
client := &http.Client{}
req, err := http.NewRequest("GET", fmt.Sprintf("%s/admin/reject/%s/%s", ts.URL, cnf.Token, key), nil)
_, err = client.Do(req)
assert.NoError(t, err)
assert.Empty(t, repo.GetAllToModerate("brainbaking.com").Data)
assert.Empty(t, repo.GetAll("brainbaking.com").Data)
}
func TestHandleApprove(t *testing.T) {
wm := mf.Mention{
Source: "https://infos.by/markdown-v-nauke/",
Target: "https://brainbaking.com/post/2021/02/writing-academic-papers-in-markdown/",
}
key, _ := repo.InModeration(wm, &mf.IndiewebData{
Source: wm.Source,
Target: wm.Target,
Name: "mytest",
})
assert.NotEmpty(t, repo.GetAllToModerate("brainbaking.com").Data)
r := mux.NewRouter()
// just using httptest.NewServer(r.HandleFunc("url", HandleApprove(...)) won't get the context vars into the mux
r.HandleFunc("/admin/approve/{token}/{key}", HandleApprove(cnf, repo)).Methods("GET")
ts := httptest.NewServer(r)
t.Cleanup(func() {
os.Remove("config.json")
ts.Close()
db.Purge()
})
client := &http.Client{}
req, err := http.NewRequest("GET", fmt.Sprintf("%s/admin/approve/%s/%s", ts.URL, cnf.Token, key), nil)
_, err = client.Do(req)
assert.NoError(t, err)
assert.Empty(t, repo.GetAllToModerate("brainbaking.com").Data)
assert.NotEmpty(t, repo.GetAll("brainbaking.com").Data)
}

26
app/admin/moderated.html Normal file
View File

@ -0,0 +1,26 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="Refresh" content="3; url={{ .RedirectURL }}" />
<title>Go-Jamming moderation: {{ .Action }}</title>
<style>
body {
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
margin: 1em;
}
</style>
</head>
<body>
<h1>🥞 Go-Jamming Moderation done!</h1>
<h2><em>{{ .Action }}</em>: {{ .Item }}</h2>
<hr/>
<p>Thanks 👍. You'll be redirected to the admin dashboard...</p>
<p>If you cannot wait, <a href="{{ .RedirectURL }}">please click here</a>.</p>
</body>
</html>

View File

@ -24,7 +24,8 @@ func (s *server) routes() {
s.router.HandleFunc("/webmention/{domain}/{token}", s.domainAndTokenOnly(webmention.HandlePut(c, db))).Methods("PUT")
s.router.HandleFunc("/webmention/{domain}/{token}", s.domainAndTokenOnly(webmention.HandleDelete(db))).Methods("DELETE")
s.router.HandleFunc("/admin/{domain}/{token}", s.domainAndTokenOnly(admin.HandleGet(db))).Methods("GET")
s.router.HandleFunc("/admin/{token}", s.authorizedOnly(admin.HandleGet(c, db))).Methods("GET")
s.router.HandleFunc("/admin/{domain}/{token}", s.domainAndTokenOnly(admin.HandleGetToApprove(db))).Methods("GET")
s.router.HandleFunc("/admin/approve/{token}/{key}", s.authorizedOnly(admin.HandleApprove(c, db))).Methods("GET")
s.router.HandleFunc("/admin/reject/{token}/{key}", s.authorizedOnly(admin.HandleReject(c, db))).Methods("GET")
}

View File

@ -46,6 +46,7 @@ func TestHandleDelete(t *testing.T) {
ts := httptest.NewServer(HandleDelete(repo))
defer ts.Close()
defer db.Purge()
client := &http.Client{}
req, err := http.NewRequest("DELETE", fmt.Sprintf("%s?source=%s&target=%s", ts.URL, wm.Source, wm.Target), nil)
@ -59,6 +60,8 @@ func TestHandleDelete(t *testing.T) {
func TestHandlePostWithInvalidUrlsShouldReturnBadRequest(t *testing.T) {
ts := httptest.NewServer(HandlePost(cnf, repo))
defer ts.Close()
defer db.Purge()
res, err := http.PostForm(ts.URL, postWm("https://haha.be/woof/said/the/dog.txt", "https://pussies.nl/mycatjustthrewup/gottacleanup.html"))
assert.NoError(t, err)
@ -72,6 +75,8 @@ func TestHandlePostWithInvalidUrlsShouldReturnBadRequest(t *testing.T) {
func TestHandlePostWithTestServer_Parallel(t *testing.T) {
ts := httptest.NewServer(HandlePost(cnf, repo))
defer ts.Close()
defer db.Purge()
var wg sync.WaitGroup
for i := 0; i < 3; i++ {

View File

@ -17,7 +17,6 @@ type Config struct {
Port int `json:"port"`
Token string `json:"token"`
UtcOffset int `json:"utcOffset"`
DataPath string `json:"dataPath"`
AllowedWebmentionSources []string `json:"allowedWebmentionSources"`
Blacklist []string `json:"blacklist"`
Whitelist []string `json:"whitelist"`
@ -103,9 +102,13 @@ func addToList(key string, arr []string) []string {
return append(arr, key)
}
func (c *Config) String() string {
bytes, _ := json.MarshalIndent(c, "", " ")
return string(bytes)
}
func (c *Config) Save() {
bytes, _ := json.Marshal(c) // we assume a correct internral state here
err := ioutil.WriteFile("config.json", bytes, fs.ModePerm)
err := ioutil.WriteFile("config.json", []byte(c.String()), fs.ModePerm)
if err != nil {
log.Err(err).Msg("Unable to save config.json to disk!")
}