rss endpoint implementation as suggested by Chris
parent
3be4b23159
commit
ec6d61b221
11
INSTALL.md
11
INSTALL.md
|
@ -32,11 +32,12 @@ Place a `config.json` file in the same directory that looks like this: (below ar
|
|||
}
|
||||
```
|
||||
|
||||
- baseURL, with trailing slash: base access point, used in approval/admin panel
|
||||
- adminEmail, the e-mail address to send notificaions to. If absent, will not send out mails. **uses 127.0.0.1:25 postfix** at the moment.
|
||||
- port, host: http server params
|
||||
- token, allowedWebmentionSources: see below, used for authentication
|
||||
- blacklist/whitelist: domains from which we do (NOT) send to or accept mentions from.
|
||||
- `baseURL`, with trailing slash: base access point, used in approval/admin panel
|
||||
- `adminEmail`, the e-mail address to send notificaions to. If absent, will not send out mails. **uses 127.0.0.1:25 postfix** at the moment.
|
||||
- `port`, host: http server params
|
||||
- `token`: see below, used for authentication
|
||||
- `blacklist`/`whitelist`: domains from which we do (NOT) send to or accept mentions from. This is usually the domain of the `source` in the receiving mention. **Note**: the blacklist is also used to block outgoing mentions.
|
||||
- `allowedWebmentionSources`: your own domains which go-jamming is able to receive mentions from. This is usually domain of the `target` in the receiving mention.
|
||||
|
||||
If a config file is missing, or required keys are missing, a warning will be generated and default values will be used instead. See `common/config.go`.
|
||||
|
||||
|
|
12
README.md
12
README.md
|
@ -168,6 +168,18 @@ Will result in a `200 OK` - that returns XML according to [The W3 pingback XML-R
|
|||
|
||||
Happens automatically through `PUT /webmention/:domain/:token`! Links that are discovered as `rel="pingback"` that **do not** already have a webmention link will be processed as XML-RPC requests to be send.
|
||||
|
||||
### 3. Keep track of mentions
|
||||
|
||||
#### 3.1 `GET /feed/[domain]/[token]`
|
||||
|
||||
To keep track of all incoming mentions, simply subscribe to the above URL for each of your domains using your favorite RSS reader.
|
||||
|
||||
Mentions to approve also appear in the feed, together with links to accept or reject them with a single click! (See below, section "mentions in moderation").
|
||||
|
||||
#### 3.2 SMTP `localhost`
|
||||
|
||||
Messages of incoming mentions can also be sent via e-mail if you have a localhost SMTP server setup. See the `INSTALL.md` for more instructions and config parameters on how to do so.
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
|
|
@ -9,7 +9,7 @@ import (
|
|||
)
|
||||
|
||||
const (
|
||||
dateFormatWithTimeZone = "2006-01-02T15:04:05-07:00"
|
||||
DateFormatWithTimeZone = "2006-01-02T15:04:05-07:00"
|
||||
dateFormatWithAbsoluteTimeZone = "2006-01-02T15:04:05-0700"
|
||||
dateFormatWithTimeZoneSuffixed = "2006-01-02T15:04:05.000Z"
|
||||
dateFormatWithoutTimeZone = "2006-01-02T15:04:05"
|
||||
|
@ -22,7 +22,7 @@ var (
|
|||
// This is similar to Hugo's string-to-date casting system
|
||||
// See https://github.com/spf13/cast/blob/master/caste.go
|
||||
supportedFormats = []string{
|
||||
dateFormatWithTimeZone,
|
||||
DateFormatWithTimeZone,
|
||||
dateFormatWithAbsoluteTimeZone,
|
||||
dateFormatWithTimeZoneSuffixed,
|
||||
dateFormatWithSecondsWithoutTimeZone,
|
||||
|
@ -81,6 +81,10 @@ type IndiewebData struct {
|
|||
Target string `json:"target"`
|
||||
}
|
||||
|
||||
func (id *IndiewebData) PublishedDate() time.Time {
|
||||
return common.ToTime(id.Published, DateFormatWithTimeZone)
|
||||
}
|
||||
|
||||
func (id *IndiewebData) AsMention() Mention {
|
||||
return Mention{
|
||||
Source: id.Source,
|
||||
|
@ -93,7 +97,7 @@ func (id *IndiewebData) IsEmpty() bool {
|
|||
}
|
||||
|
||||
func PublishedNow() string {
|
||||
return common.Now().UTC().Format(dateFormatWithTimeZone)
|
||||
return common.Now().UTC().Format(DateFormatWithTimeZone)
|
||||
}
|
||||
|
||||
func shorten(txt string) string {
|
||||
|
@ -185,7 +189,7 @@ func Published(hEntry *microformats.Microformat) string {
|
|||
if err != nil {
|
||||
continue
|
||||
}
|
||||
return formatted.Format(dateFormatWithTimeZone)
|
||||
return formatted.Format(DateFormatWithTimeZone)
|
||||
}
|
||||
|
||||
return PublishedNow()
|
||||
|
|
|
@ -5,6 +5,7 @@ import (
|
|||
"brainbaking.com/go-jamming/app/index"
|
||||
"brainbaking.com/go-jamming/app/pictures"
|
||||
"brainbaking.com/go-jamming/app/pingback"
|
||||
"brainbaking.com/go-jamming/app/rss"
|
||||
"brainbaking.com/go-jamming/app/webmention"
|
||||
)
|
||||
|
||||
|
@ -20,12 +21,14 @@ 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("/feed/{domain}/{token}", s.domainAndTokenOnly(rss.HandleGet(c, db))).Methods("GET")
|
||||
|
||||
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/{token}", s.authorizedOnly(admin.HandleGet(c, db))).Methods("GET")
|
||||
s.router.HandleFunc("/admin/{token}", s.tokenOnly(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")
|
||||
s.router.HandleFunc("/admin/approve/{token}/{key}", s.tokenOnly(admin.HandleApprove(c, db))).Methods("GET")
|
||||
s.router.HandleFunc("/admin/reject/{token}/{key}", s.tokenOnly(admin.HandleReject(c, db))).Methods("GET")
|
||||
}
|
||||
|
|
|
@ -0,0 +1,92 @@
|
|||
package rss
|
||||
|
||||
import (
|
||||
"brainbaking.com/go-jamming/app/mf"
|
||||
"brainbaking.com/go-jamming/common"
|
||||
"brainbaking.com/go-jamming/db"
|
||||
"fmt"
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/rs/zerolog/log"
|
||||
"net/http"
|
||||
"sort"
|
||||
"text/template"
|
||||
"time"
|
||||
)
|
||||
|
||||
import _ "embed"
|
||||
|
||||
const (
|
||||
MaxRssItems = 50
|
||||
)
|
||||
|
||||
//go:embed mentionsrss.xml
|
||||
var mentionsrssTemplate []byte
|
||||
|
||||
type RssMentions struct {
|
||||
Domain string
|
||||
Date time.Time
|
||||
Items []*RssMentionItem
|
||||
}
|
||||
|
||||
type RssMentionItem struct {
|
||||
ApproveURL string
|
||||
RejectURL string
|
||||
Data *mf.IndiewebData
|
||||
}
|
||||
|
||||
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("mentionsRss", mentionsrssTemplate)
|
||||
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
domain := mux.Vars(r)["domain"]
|
||||
|
||||
mentions := getLatestMentions(domain, repo, c)
|
||||
err := tmpl.Execute(w, RssMentions{
|
||||
Items: mentions,
|
||||
Date: time.Now(),
|
||||
Domain: domain,
|
||||
})
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
log.Error().Err(err).Msg("Unable to fill in dashboard template")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func getLatestMentions(domain string, repo db.MentionRepo, c *common.Config) []*RssMentionItem {
|
||||
toMod := repo.GetAllToModerate(domain).Data
|
||||
all := repo.GetAll(domain).Data
|
||||
|
||||
var data []*RssMentionItem
|
||||
for _, v := range toMod {
|
||||
wm := v.AsMention()
|
||||
data = append(data, &RssMentionItem{
|
||||
Data: v,
|
||||
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()),
|
||||
})
|
||||
}
|
||||
for _, v := range all {
|
||||
data = append(data, &RssMentionItem{
|
||||
Data: v,
|
||||
})
|
||||
}
|
||||
|
||||
// TODO this date is the published date, not the webmention received date!
|
||||
// This means it "might" disappear after the cutoff point in the RSS feed, and we don't store a received timestamp
|
||||
sort.Slice(data, func(i, j int) bool {
|
||||
return data[i].Data.PublishedDate().After(data[j].Data.PublishedDate())
|
||||
})
|
||||
if len(data) > MaxRssItems {
|
||||
return data[0:MaxRssItems]
|
||||
}
|
||||
return data
|
||||
}
|
|
@ -0,0 +1,75 @@
|
|||
package rss
|
||||
|
||||
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.Config{
|
||||
BaseURL: "http://localhost:1337/",
|
||||
Port: 1337,
|
||||
Token: "miauwkes",
|
||||
AllowedWebmentionSources: []string{"brainbaking.com"},
|
||||
Blacklist: []string{},
|
||||
Whitelist: []string{"brainbaking.com"},
|
||||
}
|
||||
repo db.MentionRepo
|
||||
)
|
||||
|
||||
func init() {
|
||||
repo = db.NewMentionRepo(cnf)
|
||||
}
|
||||
func TestHandleGet(t *testing.T) {
|
||||
wmInMod := mf.Mention{
|
||||
Source: "https://infos.by/markdown-v-nauke/",
|
||||
Target: "https://brainbaking.com/post/2021/02/writing-academic-papers-in-markdown/",
|
||||
}
|
||||
wmApproved := mf.Mention{
|
||||
Source: "https://brainbaking.com/post/2022/04/equality-in-game-credits/",
|
||||
Target: "https://brainbaking.com/",
|
||||
}
|
||||
|
||||
repo.InModeration(wmInMod, &mf.IndiewebData{
|
||||
Source: wmInMod.Source,
|
||||
Target: wmInMod.Target,
|
||||
Name: "inmod1",
|
||||
})
|
||||
repo.Save(wmApproved, &mf.IndiewebData{
|
||||
Source: wmApproved.Source,
|
||||
Target: wmApproved.Target,
|
||||
Name: "approved1",
|
||||
})
|
||||
r := mux.NewRouter()
|
||||
r.HandleFunc("/feed/{domain}/{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/feed/%s/%s", ts.URL, cnf.AllowedWebmentionSources[0], 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, "<description>Go-Jamming @ brainbaking.com</description>")
|
||||
assert.Contains(t, content, "<title>To Moderate: inmod1 ()</title>")
|
||||
assert.Contains(t, content, "<title>approved1 ()</title>")
|
||||
}
|
|
@ -0,0 +1,38 @@
|
|||
<?xml version="1.0" encoding="utf-8" standalone="yes"?>
|
||||
<rss version='2.0' xmlns:content='http://purl.org/rss/1.0/modules/content/' xmlns:atom='http://www.w3.org/2005/Atom' xmlns:dc='http://purl.org/dc/elements/1.1/'>
|
||||
<channel>
|
||||
<title>Go-Jamming @ {{ .Domain }}</title>
|
||||
<description>Go-Jamming @ {{ .Domain }}</description>
|
||||
<generator>Go-Jamming</generator>
|
||||
<language>en-us</language>
|
||||
<lastBuildDate>{{ .Date.Format "Mon, 02 Jan 2006 15:04:05 -0700" }}</lastBuildDate>
|
||||
|
||||
{{ range .Items }}
|
||||
<item>
|
||||
<title>{{ if .ApproveURL }}To Moderate: {{ end }}{{ .Data.Name | html }} ({{ .Data.Url }})</title>
|
||||
<link>{{ .Data.Target }}</link>
|
||||
<pubDate>{{ .Data.PublishedDate.Format "Mon, 02 Jan 2006 15:04:05 -0700" }}</pubDate>
|
||||
<dc:creator>{{ .Data.Author.Name | html }}</dc:creator>
|
||||
<description>
|
||||
<![CDATA[
|
||||
{{ if .ApproveURL }}
|
||||
<a href="{{ .ApproveURL }}">✅ Approve this mention!</a><br/>
|
||||
<a href="{{ .RejectURL }}">❌ Reject this mention!</a><br/><br/>
|
||||
{{ end }}
|
||||
|
||||
Author: {{ .Data.Author }}<br/>
|
||||
Name: {{ .Data.Name }}<br/>
|
||||
Published: {{ .Data.Published }}<br/>
|
||||
Type: {{ .Data.IndiewebType }}<br/>
|
||||
Url: <a href="{{ .Data.Url }}">{{ .Data.Url }}</a><br/><br/>
|
||||
|
||||
Source: <a href="{{ .Data.Source }}">{{ .Data.Source }}</a><br/>
|
||||
Target: <a href="{{ .Data.Target }}">{{ .Data.Target }}</a><br/><br/>
|
||||
|
||||
Content: {{ .Data.Content }}
|
||||
]]>
|
||||
</description>
|
||||
</item>
|
||||
{{ end }}
|
||||
</channel>
|
||||
</rss>
|
|
@ -21,7 +21,7 @@ type server struct {
|
|||
}
|
||||
|
||||
func (s *server) domainAndTokenOnly(h http.HandlerFunc) http.HandlerFunc {
|
||||
return s.domainOnly(s.authorizedOnly(h))
|
||||
return s.domainOnly(s.tokenOnly(h))
|
||||
}
|
||||
|
||||
func (s *server) domainOnly(h http.HandlerFunc) http.HandlerFunc {
|
||||
|
@ -35,7 +35,7 @@ func (s *server) domainOnly(h http.HandlerFunc) http.HandlerFunc {
|
|||
}
|
||||
}
|
||||
|
||||
func (s *server) authorizedOnly(h http.HandlerFunc) http.HandlerFunc {
|
||||
func (s *server) tokenOnly(h http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
if vars["token"] != s.conf.Token {
|
||||
|
|
|
@ -14,13 +14,13 @@ var conf = &common.Config{
|
|||
AllowedWebmentionSources: []string{"http://ewelja.be"},
|
||||
}
|
||||
|
||||
func TestAuthorizedOnlyUnauthorizedWithWrongToken(t *testing.T) {
|
||||
func TestTokenOnlyUnauthorizedWithWrongToken(t *testing.T) {
|
||||
srv := &server{
|
||||
conf: conf,
|
||||
}
|
||||
|
||||
passed := false
|
||||
handler := srv.authorizedOnly(func(writer http.ResponseWriter, request *http.Request) {
|
||||
handler := srv.tokenOnly(func(writer http.ResponseWriter, request *http.Request) {
|
||||
passed = true
|
||||
})
|
||||
r, _ := http.NewRequest("PUT", "/whatever", nil)
|
||||
|
@ -54,13 +54,13 @@ func TestDomainOnlyWithWrongDomain(t *testing.T) {
|
|||
assert.False(t, passed, "should not have called unauthorized func")
|
||||
}
|
||||
|
||||
func TestAuthorizedOnlyOkIfTokenAndDomainMatch(t *testing.T) {
|
||||
func TestTokenOnlyOkIfTokenAndDomainMatch(t *testing.T) {
|
||||
srv := &server{
|
||||
conf: conf,
|
||||
}
|
||||
|
||||
passed := false
|
||||
handler := srv.authorizedOnly(func(writer http.ResponseWriter, request *http.Request) {
|
||||
handler := srv.tokenOnly(func(writer http.ResponseWriter, request *http.Request) {
|
||||
passed = true
|
||||
})
|
||||
r, _ := http.NewRequest("PUT", "/whatever", nil)
|
||||
|
|
|
@ -1,7 +1,6 @@
|
|||
package common
|
||||
|
||||
import (
|
||||
"github.com/rs/zerolog/log"
|
||||
"time"
|
||||
)
|
||||
|
||||
|
@ -20,14 +19,17 @@ func TimeToIso(theTime time.Time) string {
|
|||
|
||||
// IsoToTime converts an ISO time string into a time.Time object
|
||||
// As produced by clients using day.js - e.g. 2021-04-09T15:51:43.732Z
|
||||
func IsoToTime(since string) time.Time {
|
||||
if since == "" {
|
||||
func IsoToTime(date string) time.Time {
|
||||
return ToTime(date, IsoFormat)
|
||||
}
|
||||
|
||||
func ToTime(date string, format string) time.Time {
|
||||
if date == "" {
|
||||
return time.Time{}
|
||||
}
|
||||
t, err := time.Parse(IsoFormat, since)
|
||||
t, err := time.Parse(format, date)
|
||||
if err != nil {
|
||||
log.Warn().Str("time", since).Msg("Invalid ISO date, reverting to now()")
|
||||
return Now()
|
||||
return time.Time{}
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
|
|
@ -42,9 +42,9 @@ func (s *TimeSuite) TestIsoToTimeInISOString() {
|
|||
assert.Equal(s.T(), expectedtime.Second(), since.Second())
|
||||
}
|
||||
|
||||
func (s *TimeSuite) TestIsoToTimeInvalidStringReturnsNowTime() {
|
||||
func (s *TimeSuite) TestIsoToTimeInvalidStringReturnsZeroTime() {
|
||||
since := IsoToTime("woef ik ben een hondje")
|
||||
assert.Equal(s.T(), s.nowtime, since)
|
||||
assert.True(s.T(), since.IsZero())
|
||||
}
|
||||
|
||||
func (s *TimeSuite) TestIsoToTimeEmptyReturnsZeroTime() {
|
||||
|
|
|
@ -132,7 +132,8 @@ func TestGetAllAndSaveSomeJson(t *testing.T) {
|
|||
db.Save(mf.Mention{
|
||||
Target: "https://pussycat.com/coolpussy.html",
|
||||
}, &mf.IndiewebData{
|
||||
Name: "lolz",
|
||||
Name: "lolz",
|
||||
Published: "2021-07-24T23:27:25+01:00",
|
||||
})
|
||||
|
||||
results := db.GetAll("pussycat.com")
|
||||
|
|
Loading…
Reference in New Issue