From e361567eed9ce9ad885ad909a73bf309f97bd506 Mon Sep 17 00:00:00 2001 From: wgroeneveld Date: Sun, 24 Apr 2022 13:27:42 +0200 Subject: [PATCH] admin html dashboard and handler test --- README.md | 2 +- app/admin/dashboard.html | 69 ++++++++++++++++++ app/admin/handler.go | 111 ++++++++++++++++++++++++++-- app/admin/handler_test.go | 127 +++++++++++++++++++++++++++++++++ app/admin/moderated.html | 26 +++++++ app/routes.go | 3 +- app/webmention/handler_test.go | 5 ++ common/config.go | 9 ++- 8 files changed, 341 insertions(+), 11 deletions(-) create mode 100644 app/admin/dashboard.html create mode 100644 app/admin/handler_test.go create mode 100644 app/admin/moderated.html diff --git a/README.md b/README.md index f95aea1..6b09331 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/app/admin/dashboard.html b/app/admin/dashboard.html new file mode 100644 index 0000000..1d66ea4 --- /dev/null +++ b/app/admin/dashboard.html @@ -0,0 +1,69 @@ + + + + + Go-Jamming admin dashboard + + + + +

🥞 Go-Jamming Admin

+
+ +

Mentions To Approve

+ +{{ range $domain, $mentions := .Mentions }} +

   🌐 Domain {{ $domain }} »

+ + {{ if $mentions }} + + + + + + + + + + + + {{ range $mentions }} + + + + + + + + {{ end }} + +
SourceTargetContentApprove?Reject?
{{ .Source }}{{ .Target }}{{ .Content }}✅ Yes!❌ Nop!
+ {{ else }} +

No mentions to approve, all done.

+ {{ end }} +{{ end }} +
+ +

Config

+ +Current config.json contents: + +
+{{ .Config }}
+
+ +
+ + + \ No newline at end of file diff --git a/app/admin/handler.go b/app/admin/handler.go index 0c7986c..5437aeb 100644 --- a/app/admin/handler.go +++ b/app/admin/handler.go @@ -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), } } diff --git a/app/admin/handler_test.go b/app/admin/handler_test.go new file mode 100644 index 0000000..5764809 --- /dev/null +++ b/app/admin/handler_test.go @@ -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) +} diff --git a/app/admin/moderated.html b/app/admin/moderated.html new file mode 100644 index 0000000..6489fdc --- /dev/null +++ b/app/admin/moderated.html @@ -0,0 +1,26 @@ + + + + + + Go-Jamming moderation: {{ .Action }} + + + + +

🥞 Go-Jamming Moderation done!

+ +

{{ .Action }}: {{ .Item }}

+ +
+ +

Thanks 👍. You'll be redirected to the admin dashboard...

+

If you cannot wait, please click here.

+ + + \ No newline at end of file diff --git a/app/routes.go b/app/routes.go index cb68d2f..2d15ae6 100644 --- a/app/routes.go +++ b/app/routes.go @@ -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") } diff --git a/app/webmention/handler_test.go b/app/webmention/handler_test.go index bdf6515..c3f338a 100644 --- a/app/webmention/handler_test.go +++ b/app/webmention/handler_test.go @@ -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++ { diff --git a/common/config.go b/common/config.go index a101087..6bc253b 100644 --- a/common/config.go +++ b/common/config.go @@ -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!") }