mail notifier, admin wrappers endpoints, first impl done

This commit is contained in:
Wouter Groeneveld 2022-04-23 21:27:43 +02:00
parent 2e504eaa65
commit 6cc83620ba
17 changed files with 304 additions and 62 deletions

View File

@ -1,10 +1,10 @@
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"
"net/http"
)
@ -16,32 +16,39 @@ func HandleGet(repo db.MentionRepo) http.HandlerFunc {
}
}
// TODO validate or not? see webmention.HandlePost
// 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 {
return func(w http.ResponseWriter, r *http.Request) {
r.ParseForm()
wm := mf.Mention{
Source: r.FormValue("source"),
Target: r.FormValue("target"),
key := mux.Vars(r)["key"]
approved := repo.Approve(key)
if approved == nil {
http.NotFound(w, r)
return
}
repo.Approve(wm)
c.AddToWhitelist(wm.SourceDomain())
w.WriteHeader(200)
c.AddToWhitelist(approved.AsMention().SourceDomain())
w.WriteHeader(http.StatusOK)
fmt.Fprintf(w, "Approved: %s", approved.AsMention().String())
}
}
// 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 {
return func(w http.ResponseWriter, r *http.Request) {
r.ParseForm()
wm := mf.Mention{
Source: r.FormValue("source"),
Target: r.FormValue("target"),
key := mux.Vars(r)["key"]
rejected := repo.Reject(key)
if rejected == nil {
http.NotFound(w, r)
return
}
repo.Reject(wm)
c.AddToBlacklist(wm.SourceDomain())
w.WriteHeader(200)
c.AddToBlacklist(rejected.AsMention().SourceDomain())
w.WriteHeader(http.StatusOK)
fmt.Fprintf(w, "Rejected: %s", rejected.AsMention().String())
}
}

View File

@ -39,7 +39,8 @@ func (wm Mention) SourceDomain() string {
// Key returns a unique string representation of the mention for use in storage.
// TODO Profiling indicated that md5() consumes a lot of CPU power, so this could be replaced with db migration.
func (wm Mention) Key() string {
return fmt.Sprintf("%x", md5.Sum([]byte("source="+wm.Source+",target="+wm.Target)))
key := fmt.Sprintf("%x", md5.Sum([]byte("source="+wm.Source+",target="+wm.Target)))
return fmt.Sprintf("%s:%s", key, wm.TargetDomain())
}
func (wm Mention) SourceUrl() *url.URL {

View File

@ -0,0 +1,67 @@
package notifier
import (
"brainbaking.com/go-jamming/app/mf"
"brainbaking.com/go-jamming/common"
"encoding/base64"
"github.com/rs/zerolog/log"
"net/mail"
"net/smtp"
)
type MailNotifier struct {
Conf *common.Config
}
// sendMail is a utility function that sends a mail without authentication to localhost. Tested using postfix.
// cheers https://github.com/gadelkareem/go-helpers/blob/master/helpers.go
func sendMail(from, subject, body, toName, toAddress string) error {
c, err := smtp.Dial("127.0.0.1:25")
if err != nil {
return err
}
defer c.Close()
if err = c.Mail(from); err != nil {
return err
}
to := (&mail.Address{toName, toAddress}).String()
if err = c.Rcpt(to); err != nil {
return err
}
w, err := c.Data()
if err != nil {
return err
}
msg := "To: " + to + "\r\n" +
"From: " + from + "\r\n" +
"Subject: " + subject + "\r\n" +
"Content-Type: text/html; charset=\"UTF-8\"\r\n" +
"Content-Transfer-Encoding: base64\r\n" +
"\r\n" + base64.StdEncoding.EncodeToString([]byte(body))
_, err = w.Write([]byte(msg))
if err != nil {
return err
}
err = w.Close()
if err != nil {
return err
}
return c.Quit()
}
func (mn *MailNotifier) NotifyReceived(wm mf.Mention, indieweb *mf.IndiewebData) {
err := sendMail(
"admin@brainbaking.com",
"Webmention in moderation from "+wm.SourceDomain(),
BuildNotification(wm, indieweb, mn.Conf),
"Go-Jamming User",
"wouter@brainbaking.com")
if err != nil {
log.Err(err).Msg("Unable to send notification mail, check localhost postfix settings?")
}
}

24
app/notifier/notifier.go Normal file
View File

@ -0,0 +1,24 @@
package notifier
import (
"brainbaking.com/go-jamming/app/mf"
"brainbaking.com/go-jamming/common"
"fmt"
)
type Notifier interface {
NotifyReceived(wm mf.Mention, data *mf.IndiewebData)
}
// BuildNotification returns a string representation of the Mention to notify the admin.
func BuildNotification(wm mf.Mention, data *mf.IndiewebData, cnf *common.Config) string {
enter := "\n"
acceptUrl := fmt.Sprintf("%sadmin/approve/%s/%s", cnf.BaseURL, cnf.Token, wm.Key())
rejectUrl := fmt.Sprintf("%sadmin/reject/%s/%s", cnf.BaseURL, cnf.Token, wm.Key())
return fmt.Sprintf("Hi admin, %s%s,A webmention was received: %sSource %s, Target %s%sContent: %s%s%sAccept? %s%sReject? %s%sCheerio, your go-jammin' thing.",
enter, enter, enter,
wm.Source, wm.Target, enter,
data.Content, enter, enter,
acceptUrl, enter, rejectUrl, enter)
}

View File

@ -0,0 +1,37 @@
package notifier
import (
"brainbaking.com/go-jamming/app/mf"
"brainbaking.com/go-jamming/common"
"github.com/stretchr/testify/assert"
"testing"
)
func TestBuildNotification(t *testing.T) {
wm := mf.Mention{
Source: "https://brainbaking.com/valid-indieweb-source.html",
Target: "https://brainbaking.com/valid-indieweb-target.html",
}
cnf := &common.Config{
AllowedWebmentionSources: []string{
"brainbaking.com",
},
BaseURL: "https://jam.brainbaking.com/",
Token: "mytoken",
Blacklist: []string{},
Whitelist: []string{},
}
expected := `Hi admin,
,A webmention was received:
Source https://brainbaking.com/valid-indieweb-source.html, Target https://brainbaking.com/valid-indieweb-target.html
Content: somecontent
Accept? https://jam.brainbaking.com/admin/approve/mytoken/19d462ddff3c3322c662dac3461324bb:brainbaking.com
Reject? https://jam.brainbaking.com/admin/reject/mytoken/19d462ddff3c3322c662dac3461324bb:brainbaking.com
Cheerio, your go-jammin' thing.`
result := BuildNotification(wm, &mf.IndiewebData{Content: "somecontent"}, cnf)
assert.Equal(t, result, expected)
}

View File

@ -20,11 +20,11 @@ func (s *server) routes() {
s.router.HandleFunc("/pingback", pingback.HandlePost(c, db)).Methods("POST")
s.router.HandleFunc("/webmention", webmention.HandlePost(c, db)).Methods("POST")
s.router.HandleFunc("/webmention/{domain}/{token}", s.authorizedOnly(webmention.HandleGet(db))).Methods("GET")
s.router.HandleFunc("/webmention/{domain}/{token}", s.authorizedOnly(webmention.HandlePut(c, db))).Methods("PUT")
s.router.HandleFunc("/webmention/{domain}/{token}", s.authorizedOnly(webmention.HandleDelete(db))).Methods("DELETE")
s.router.HandleFunc("/webmention/{domain}/{token}", s.domainAndTokenOnly(webmention.HandleGet(db))).Methods("GET")
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.authorizedOnly(admin.HandleGet(db))).Methods("GET")
s.router.HandleFunc("/admin/approve/{token}", s.authorizedOnly(admin.HandleApprove(c, db))).Methods("POST")
s.router.HandleFunc("/admin/reject/{token}", s.authorizedOnly(admin.HandleReject(c, db))).Methods("POST")
s.router.HandleFunc("/admin/{domain}/{token}", s.domainAndTokenOnly(admin.HandleGet(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

@ -20,10 +20,25 @@ type server struct {
repo db.MentionRepo
}
func (s *server) domainAndTokenOnly(h http.HandlerFunc) http.HandlerFunc {
return s.domainOnly(s.authorizedOnly(h))
}
func (s *server) domainOnly(h http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
if !s.conf.IsAnAllowedDomain(vars["domain"]) {
rest.Unauthorized(w)
return
}
h(w, r)
}
}
func (s *server) authorizedOnly(h http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
if vars["token"] != s.conf.Token || !s.conf.IsAnAllowedDomain(vars["domain"]) {
if vars["token"] != s.conf.Token {
rest.Unauthorized(w)
return
}

View File

@ -34,13 +34,13 @@ func TestAuthorizedOnlyUnauthorizedWithWrongToken(t *testing.T) {
assert.False(t, passed, "should not have called unauthorized func")
}
func TestAuthorizedOnlyUnauthorizedWithWrongDomain(t *testing.T) {
func TestDomainOnlyWithWrongDomain(t *testing.T) {
srv := &server{
conf: conf,
}
passed := false
handler := srv.authorizedOnly(func(writer http.ResponseWriter, request *http.Request) {
handler := srv.domainOnly(func(writer http.ResponseWriter, request *http.Request) {
passed = true
})
r, _ := http.NewRequest("PUT", "/whatever", nil)

View File

@ -2,6 +2,7 @@ package webmention
import (
"brainbaking.com/go-jamming/app/mf"
"brainbaking.com/go-jamming/app/notifier"
"brainbaking.com/go-jamming/app/webmention/recv"
"brainbaking.com/go-jamming/app/webmention/send"
"brainbaking.com/go-jamming/db"
@ -88,6 +89,9 @@ func HandlePost(conf *common.Config, repo db.MentionRepo) http.HandlerFunc {
RestClient: httpClient,
Conf: conf,
Repo: repo,
Notifier: &notifier.MailNotifier{
Conf: conf,
},
}
go recv.Receive(wm)

View File

@ -2,6 +2,7 @@ package recv
import (
"brainbaking.com/go-jamming/app/mf"
"brainbaking.com/go-jamming/app/notifier"
"brainbaking.com/go-jamming/common"
"brainbaking.com/go-jamming/db"
"brainbaking.com/go-jamming/rest"
@ -16,6 +17,7 @@ import (
type Receiver struct {
RestClient rest.Client
Notifier notifier.Notifier
Conf *common.Config
Repo db.MentionRepo
}
@ -62,23 +64,30 @@ func (recv *Receiver) processSourceBody(body string, wm mf.Mention) {
indieweb := recv.convertBodyToIndiewebData(body, wm, data)
recv.processAuthorPicture(indieweb)
recv.saveMentionToDatabase(wm, indieweb)
if recv.Conf.IsWhitelisted(wm.Source) {
recv.processWhitelistedMention(wm, indieweb)
} else {
recv.processMentionInModeration(wm, indieweb)
}
}
func (recv *Receiver) saveMentionToDatabase(wm mf.Mention, indieweb *mf.IndiewebData) {
if recv.Conf.IsWhitelisted(wm.Source) {
key, err := recv.Repo.Save(wm, indieweb)
if err != nil {
log.Error().Err(err).Stringer("wm", wm).Msg("Failed to save new mention to db")
}
log.Info().Str("key", key).Msg("OK: Webmention processed, in whitelist.")
} else {
key, err := recv.Repo.InModeration(wm, indieweb)
if err != nil {
log.Error().Err(err).Stringer("wm", wm).Msg("Failed to save new mention to in moderation db")
}
log.Info().Str("key", key).Msg("OK: Webmention processed, in moderation.")
func (recv *Receiver) processMentionInModeration(wm mf.Mention, indieweb *mf.IndiewebData) {
key, err := recv.Repo.InModeration(wm, indieweb)
if err != nil {
log.Error().Err(err).Stringer("wm", wm).Msg("Failed to save new mention to in moderation db")
}
if recv.Notifier != nil {
recv.Notifier.NotifyReceived(wm, indieweb)
}
log.Info().Str("key", key).Msg("OK: Webmention processed, in moderation.")
}
func (recv *Receiver) processWhitelistedMention(wm mf.Mention, indieweb *mf.IndiewebData) {
key, err := recv.Repo.Save(wm, indieweb)
if err != nil {
log.Error().Err(err).Stringer("wm", wm).Msg("Failed to save new mention to db")
}
log.Info().Str("key", key).Msg("OK: Webmention processed, in whitelist.")
}
func (recv *Receiver) processAuthorPicture(indieweb *mf.IndiewebData) {

View File

@ -237,7 +237,7 @@ func TestReceiveTargetDoesNotExistAnymoreDeletesPossiblyOlderWebmention(t *testi
assert.Empty(t, indb)
}
func TestReceiveFromNotInWhitelistSavesInModeration(t *testing.T) {
func TestReceiveFromNotInWhitelistSavesInModerationAndNotifies(t *testing.T) {
wm := mf.Mention{
Source: "https://brainbaking.com/valid-indieweb-source.html",
Target: "https://brainbaking.com/valid-indieweb-target.html",
@ -246,22 +246,30 @@ func TestReceiveFromNotInWhitelistSavesInModeration(t *testing.T) {
AllowedWebmentionSources: []string{
"brainbaking.com",
},
BaseURL: "https://jam.brainbaking.com/",
Token: "mytoken",
Blacklist: []string{},
Whitelist: []string{},
}
repo := db.NewMentionRepo(cnf)
t.Cleanup(db.Purge)
notifierMock := &mocks.StringNotifier{
Conf: cnf,
Output: "",
}
receiver := &Receiver{
Conf: cnf,
Repo: repo,
RestClient: &mocks.RestClientMock{
GetBodyFunc: mocks.RelPathGetBodyFunc("../../../mocks/"),
},
Notifier: notifierMock,
}
receiver.Receive(wm)
assert.Empty(t, repo.GetAll("brainbaking.com").Data)
assert.Equal(t, 1, len(repo.GetAllToModerate("brainbaking.com").Data))
assert.Contains(t, notifierMock.Output, "Accept?")
}
func TestReceiveFromBlacklistedDomainDoesNothing(t *testing.T) {

View File

@ -12,6 +12,8 @@ import (
)
type Config struct {
// BaseURL should end with a / and is used to build URLs in notifications
BaseURL string `json:"baseURL"`
Port int `json:"port"`
Token string `json:"token"`
UtcOffset int `json:"utcOffset"`
@ -49,6 +51,9 @@ func (c *Config) missingKeys() []string {
if c.Token == "" {
keys = append(keys, "token")
}
if c.BaseURL == "" {
keys = append(keys, "baseURL")
}
if len(c.AllowedWebmentionSources) == 0 {
keys = append(keys, "allowedWebmentionSources")
}
@ -128,7 +133,8 @@ func config() *Config {
}
func defaultConfig() *Config {
return &Config{
defaultConfig := &Config{
BaseURL: "https://jam.brainbaking.com/",
Port: 1337,
Token: "miauwkes",
UtcOffset: 60,
@ -136,4 +142,6 @@ func defaultConfig() *Config {
Blacklist: []string{"youtube.com"},
Whitelist: []string{"brainbaking.com"},
}
defaultConfig.Save()
return defaultConfig
}

View File

@ -24,6 +24,7 @@ func TestReadFromJsonWithCorrectJsonData(t *testing.T) {
confString := `{
"port": 1337,
"host": "localhost",
"baseURL": "https://jam.brainbaking.com/",
"token": "miauwkes",
"utcOffset": 60,
"allowedWebmentionSources": [
@ -57,6 +58,7 @@ func TestWhitelist(t *testing.T) {
Whitelist: []string{
"youtube.com",
},
BaseURL: "https://jam.brainbaking.com/",
Port: 123,
Token: "token",
AllowedWebmentionSources: []string{"blah.com"},
@ -79,6 +81,7 @@ func TestAddToBlacklistNotYetAddsToListAndSaves(t *testing.T) {
Blacklist: []string{
"youtube.com",
},
BaseURL: "https://jam.brainbaking.com/",
Port: 123,
Token: "token",
AllowedWebmentionSources: []string{"blah.com"},

View File

@ -58,15 +58,18 @@ func lastSentKey(domain string) string {
// Delete removes a possibly present mention by key. Ignores but logs possible errors.
func (r *mentionRepoBunt) Delete(wm mf.Mention) {
key := r.mentionToKey(wm)
r.deleteByKey(wm.Key())
}
func (r *mentionRepoBunt) deleteByKey(key string) {
err := r.db.Update(func(tx *buntdb.Tx) error {
_, err := tx.Delete(key)
return err
})
if err != nil {
log.Warn().Err(err).Str("key", key).Stringer("wm", wm).Msg("Unable to delete")
log.Warn().Err(err).Str("key", key).Msg("Unable to delete")
} else {
log.Debug().Str("key", key).Stringer("wm", wm).Msg("Deleted.")
log.Debug().Str("key", key).Msg("Deleted.")
}
}
@ -88,11 +91,14 @@ func pictureKey(domain string) string {
// Save saves the mention by marshalling data. Returns the key or a marshal/persist error.
func (r *mentionRepoBunt) Save(wm mf.Mention, data *mf.IndiewebData) (string, error) {
return r.saveByKey(wm.Key(), data)
}
func (r *mentionRepoBunt) saveByKey(key string, data *mf.IndiewebData) (string, error) {
jsonData, err := json.Marshal(data)
if err != nil {
return "", err
}
key := r.mentionToKey(wm)
err = r.db.Update(func(tx *buntdb.Tx) error {
_, _, err := tx.Set(key, string(jsonData), nil)
return err
@ -103,14 +109,10 @@ func (r *mentionRepoBunt) Save(wm mf.Mention, data *mf.IndiewebData) (string, er
return key, nil
}
func (r *mentionRepoBunt) mentionToKey(wm mf.Mention) string {
return fmt.Sprintf("%s:%s", wm.Key(), wm.TargetDomain())
}
// Get returns a single unmarshalled json value based on the mention key.
// It returns the unmarshalled result or nil if something went wrong.
func (r *mentionRepoBunt) Get(wm mf.Mention) *mf.IndiewebData {
return r.getByKey(r.mentionToKey(wm))
return r.getByKey(wm.Key())
}
func (r *mentionRepoBunt) getByKey(key string) *mf.IndiewebData {

View File

@ -7,21 +7,41 @@ import (
)
type MentionRepo interface {
// InModeration saves the mention data to the in moderation db to approve or reject later.
// Returns the key or a marshal/persist error.
InModeration(key mf.Mention, data *mf.IndiewebData) (string, error)
// Save saves the mention to the approved db.
// Returns the key or a marshal/persist error.
Save(key mf.Mention, data *mf.IndiewebData) (string, error)
// Delete removes a possibly present mention from the approved db by key.
// Ignores but logs possible errors.
Delete(key mf.Mention)
Approve(key mf.Mention)
Reject(key mf.Mention)
// Approve saves the mention to the approved database and deletes the one in moderation.
// If the key is invalid, it returns nil.
Approve(key string) *mf.IndiewebData
// Reject removes the in moderation key from the db and returns the deleted entry
// If the key is invalid, it returns nil.
Reject(key string) *mf.IndiewebData
// Get returns a single unmarshalled json value based on the approved mention key in the db.
// It returns the unmarshalled result or nil if something went wrong.
Get(key mf.Mention) *mf.IndiewebData
// GetAll returns a wrapped data result for all approved mentions for a particular domain.
GetAll(domain string) mf.IndiewebDataResult
// GetAll returns a wrapped data result for all to approve mentions for a particular domain.
GetAllToModerate(domain string) mf.IndiewebDataResult
// CleanupSpam removes potential blacklisted spam from the approved database by checking the url of each entry.
CleanupSpam(domain string, blacklist []string)
// SavePicture saves the picture byte data in the approved database and returns a key or error.
SavePicture(bytes string, domain string) (string, error)
// GetPicture returns a byte slice (or nil if unknown) from the approved database for a particular source domain.
GetPicture(domain string) []byte
// LastSentMention fetches the last known RSS link where mentions were sent from the approved db.
// Returns an empty string if an error occured.
LastSentMention(domain string) string
// UpdateLastSentMention updates the last sent mention link in the approved db. Logs but ignores errors.
UpdateLastSentMention(domain string, lastSent string)
}
@ -30,6 +50,7 @@ type MentionRepoWrapper struct {
approvedRepo *mentionRepoBunt
}
// Save saves the data to the
func (m MentionRepoWrapper) Save(key mf.Mention, data *mf.IndiewebData) (string, error) {
return m.approvedRepo.Save(key, data)
}
@ -46,14 +67,17 @@ func (m MentionRepoWrapper) Delete(key mf.Mention) {
m.approvedRepo.Delete(key)
}
func (m MentionRepoWrapper) Approve(keyInModeration mf.Mention) {
toApprove := m.toApproveRepo.Get(keyInModeration)
m.Save(keyInModeration, toApprove)
m.toApproveRepo.Delete(keyInModeration)
func (m MentionRepoWrapper) Approve(keyInModeration string) *mf.IndiewebData {
toApprove := m.toApproveRepo.getByKey(keyInModeration)
m.approvedRepo.saveByKey(keyInModeration, toApprove)
m.toApproveRepo.deleteByKey(keyInModeration)
return toApprove
}
func (m MentionRepoWrapper) Reject(keyInModeration mf.Mention) {
m.toApproveRepo.Delete(keyInModeration)
func (m MentionRepoWrapper) Reject(keyInModeration string) *mf.IndiewebData {
toReject := m.toApproveRepo.getByKey(keyInModeration)
m.toApproveRepo.deleteByKey(keyInModeration)
return toReject
}
func (m MentionRepoWrapper) CleanupSpam(domain string, blacklist []string) {

View File

@ -15,7 +15,23 @@ var (
}
)
func TestApproveCases(t *testing.T) {
func TestRejectUnknownKeyReturnsNil(t *testing.T) {
repo := NewMentionRepo(repoCnf)
t.Cleanup(Purge)
result := repo.Reject("fuckballz")
assert.Nil(t, result)
}
func TestApproveUnknownKeyReturnsNil(t *testing.T) {
repo := NewMentionRepo(repoCnf)
t.Cleanup(Purge)
result := repo.Approve("fuckballz")
assert.Nil(t, result)
}
func TestApproveAndRejectCases(t *testing.T) {
cases := []struct {
label string
approve bool
@ -42,6 +58,7 @@ func TestApproveCases(t *testing.T) {
defer Purge()
wm := mf.Mention{
Source: "https://jefklakscodex.com/dinges",
Target: "https://brainbaking.com/sjiekedinges.html",
}
data := &mf.IndiewebData{
@ -50,9 +67,9 @@ func TestApproveCases(t *testing.T) {
repo.InModeration(wm, data)
if tc.approve {
repo.Approve(wm)
repo.Approve(wm.Key())
} else {
repo.Reject(wm)
repo.Reject(wm.Key())
}
allWms := repo.GetAll("brainbaking.com")

16
mocks/stringnotifier.go Normal file
View File

@ -0,0 +1,16 @@
package mocks
import (
"brainbaking.com/go-jamming/app/mf"
"brainbaking.com/go-jamming/app/notifier"
"brainbaking.com/go-jamming/common"
)
type StringNotifier struct {
Output string
Conf *common.Config
}
func (sn *StringNotifier) NotifyReceived(wm mf.Mention, indieweb *mf.IndiewebData) {
sn.Output = notifier.BuildNotification(wm, indieweb, sn.Conf)
}