admin html dashboard and handler test
This commit is contained in:
parent
6cc83620ba
commit
e361567eed
|
@ -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.
|
||||
|
||||
|
|
|
@ -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> 🌐 Domain <em>{{ $domain }}</em> »</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>
|
|
@ -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),
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
|
@ -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>
|
|
@ -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")
|
||||
}
|
||||
|
|
|
@ -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++ {
|
||||
|
|
|
@ -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!")
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue