commit e7fd18a2c195e83f99c8c91cb0b61f436005f49c
parent df4155ff766df8fa569314d4aca56582efea981d
Author: cblgh <cblgh@cblgh.org>
Date: Mon, 12 Dec 2022 11:50:41 +0100
add rss functionality
Diffstat:
5 files changed, 101 insertions(+), 8 deletions(-)
diff --git a/database/database.go b/database/database.go
@@ -244,13 +244,16 @@ type Thread struct {
Slug string
ID int
Publish time.Time
+ PostID int
}
// get a list of threads
+// NOTE: this query is setting thread.Author not by thread creator, but latest poster. if this becomes a problem, revert
+// its use and employ Thread.PostID to perform another query for each thread to get the post author name (wrt server.go:GenerateRSS)
func (d DB) ListThreads(sortByPost bool) []Thread {
query := `
- SELECT count(t.id), t.title, t.id, u.name FROM threads t
- INNER JOIN users u on u.id = t.authorid
+ SELECT count(t.id), t.title, t.id, u.name, p.publishtime, p.id FROM threads t
+ INNER JOIN users u on u.id = p.authorid
INNER JOIN posts p ON t.id = p.threadid
GROUP BY t.id
%s
@@ -274,7 +277,7 @@ func (d DB) ListThreads(sortByPost bool) []Thread {
var data Thread
var threads []Thread
for rows.Next() {
- if err := rows.Scan(&postCount, &data.Title, &data.ID, &data.Author); err != nil {
+ if err := rows.Scan(&postCount, &data.Title, &data.ID, &data.Author, &data.Publish, &data.PostID); err != nil {
log.Fatalln(util.Eout(err, "list threads: read in data via scan"))
}
data.Slug = util.GetThreadSlug(data.ID, data.Title, postCount)
diff --git a/go.mod b/go.mod
@@ -4,6 +4,7 @@ go 1.16
require (
github.com/carlmjohnson/requests v0.22.1 // indirect
+ github.com/cblgh/plain v0.0.0-20220516113423-253f690e4083 // indirect
github.com/gomarkdown/markdown v0.0.0-20211212230626-5af6ad2f47df // indirect
github.com/gorilla/sessions v1.2.1 // indirect
github.com/komkom/toml v0.1.2 // indirect
@@ -13,4 +14,5 @@ require (
github.com/synacor/argon2id v0.0.0-20190318165710-18569dfc600b // indirect
golang.org/x/crypto v0.0.0-20211215153901-e495a2d5b3d3 // indirect
golang.org/x/exp v0.0.0-20221023144134-a1e5550cf13e // indirect
+ golang.org/x/time v0.3.0 // indirect
)
diff --git a/go.sum b/go.sum
@@ -2,10 +2,14 @@ github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuP
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
github.com/carlmjohnson/requests v0.22.1 h1:YoifpEbpJW4LPRX/+0dJe3vTLducEE9Ib10k6lElIUM=
github.com/carlmjohnson/requests v0.22.1/go.mod h1:Hw4fFOk3xDlHQbNRTGo4oc52TUTpVEq93sNy/H+mrQM=
+github.com/cblgh/plain v0.0.0-20220516113423-253f690e4083 h1:Eg12wDUrNtT4sIW0WbzK8CFOMmvkVhvkGS6tAw2Qi2I=
+github.com/cblgh/plain v0.0.0-20220516113423-253f690e4083/go.mod h1:xahAONv6bNEm+RyZoHbP3Te1SJ3gLN42ckkFZxbxp5Y=
github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
+github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
+github.com/gomarkdown/markdown v0.0.0-20210514010506-3b9f47219fe7/go.mod h1:aii0r/K0ZnHv7G0KF7xy1v0A7s2Ljrb5byB7MO5p6TU=
github.com/gomarkdown/markdown v0.0.0-20211212230626-5af6ad2f47df h1:M7mdNDTRraBcrHZg2aOYiFP9yTDajb6fquRZRpXnbVA=
github.com/gomarkdown/markdown v0.0.0-20211212230626-5af6ad2f47df/go.mod h1:JDGcbDT52eL4fju3sZ4TeHGsQwhG9nbDV21aMyhwPoA=
github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
@@ -35,6 +39,7 @@ github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/
github.com/synacor/argon2id v0.0.0-20190318165710-18569dfc600b h1:Yu/2y+2iAAcTRfdlMZ3dEdb1aYWXesDDaQjb7xLgy7Y=
github.com/synacor/argon2id v0.0.0-20190318165710-18569dfc600b/go.mod h1:RQlLg9p2W+/d3q6xRWilTA2R4ltKiwEmzoI1urnKm9U=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
+golang.org/dl v0.0.0-20190829154251-82a15e2f2ead/go.mod h1:IUMfjQLJQd4UTqG1Z90tenwKoCX93Gn3MAQJMOSBsDQ=
golang.org/x/crypto v0.0.0-20190228161510-8dd112bcdc25/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
@@ -42,6 +47,7 @@ golang.org/x/crypto v0.0.0-20211215153901-e495a2d5b3d3 h1:0es+/5331RGQPcXlMfP+Wr
golang.org/x/crypto v0.0.0-20211215153901-e495a2d5b3d3/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/exp v0.0.0-20221023144134-a1e5550cf13e h1:SkwG94eNiiYJhbeDE018Grw09HIN/KB9NlRmZsrzfWs=
golang.org/x/exp v0.0.0-20221023144134-a1e5550cf13e/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE=
+golang.org/x/image v0.0.0-20220321031419-a8550c1d254a/go.mod h1:023OzeP/+EPmXeapQh35lcL3II3LrY8Ic+EFFKVhULM=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
@@ -73,6 +79,8 @@ golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
+golang.org/x/time v0.3.0 h1:rg5rLMjNzMS1RkNLzCG38eapWhnYLFYXDXj2gOlr8j4=
+golang.org/x/time v0.3.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
diff --git a/server/server.go b/server/server.go
@@ -22,11 +22,13 @@ import (
"cerca/defaults"
cercaHTML "cerca/html"
"cerca/i18n"
+ "cerca/limiter"
"cerca/server/session"
"cerca/types"
"cerca/util"
"github.com/carlmjohnson/requests"
+ "github.com/cblgh/plain/rss"
)
/* TODO (2022-01-03): include csrf token via gorilla, or w/e, when rendering */
@@ -93,6 +95,7 @@ type RequestHandler struct {
config types.Config
translator i18n.Translator
templates *template.Template
+ rssFeed string
}
var developing bool
@@ -103,6 +106,34 @@ func dump(err error) {
}
}
+type RateLimitingWare struct {
+ limiter *limiter.TimedRateLimiter
+}
+
+func NewRateLimitingWare(routes []string) *RateLimitingWare {
+ ware := RateLimitingWare{}
+ // refresh one access every 15 minutes. forget about the requester after 24h of non-activity
+ ware.limiter = limiter.NewTimedRateLimiter(routes, 15*time.Minute, 24*time.Hour)
+ // allow 15 requests at once, then
+ ware.limiter.SetBurstAllowance(15)
+ return &ware
+}
+
+func (ware *RateLimitingWare) Handler(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(res http.ResponseWriter, req *http.Request) {
+ portIndex := strings.LastIndex(req.RemoteAddr, ":")
+ ip := req.RemoteAddr[:portIndex]
+ err := ware.limiter.BlockUntilAllowed(ip, req.URL.String(), req.Context())
+ if err != nil {
+ err = util.Eout(err, "RateLimitingWare")
+ dump(err)
+ http.Error(res, "An error occured", http.StatusInternalServerError)
+ return
+ }
+ next.ServeHTTP(res, req)
+ })
+}
+
// returns true if logged in, and the userid of the logged in user.
// returns false (and userid set to -1) if not logged in
func (h RequestHandler) IsLoggedIn(req *http.Request) (bool, int) {
@@ -229,7 +260,7 @@ func (h RequestHandler) renderView(res http.ResponseWriter, viewName string, dat
}
}
-func (h RequestHandler) ThreadRoute(res http.ResponseWriter, req *http.Request) {
+func (h *RequestHandler) ThreadRoute(res http.ResponseWriter, req *http.Request) {
threadid, ok := util.GetURLPortion(req, 2)
loggedIn, userid := h.IsLoggedIn(req)
@@ -256,6 +287,8 @@ func (h RequestHandler) ThreadRoute(res http.ResponseWriter, req *http.Request)
// * passing data to signal "your post was successfully added" (w/o impacting visited state / url)
posts := h.db.GetThread(threadid)
newSlug := util.GetThreadSlug(threadid, posts[0].ThreadTitle, len(posts))
+ // update the rss feed
+ h.rssFeed = GenerateRSS(h.db, h.config)
http.Redirect(res, req, newSlug, http.StatusFound)
return
}
@@ -314,6 +347,37 @@ func IndexRedirect(res http.ResponseWriter, req *http.Request) {
http.Redirect(res, req, "/", http.StatusSeeOther)
}
+const rfc822RSS = "Mon, 02 Jan 2006 15:04:05 -0700"
+
+func GenerateRSS(db *database.DB, config types.Config) string {
+ if config.RSS.URL == "" {
+ return "feed not configured"
+ }
+ // TODO (2022-12-08): augment ListThreads to choose getting author of latest post or thread creator (currently latest
+ // post always)
+ threads := db.ListThreads(true)
+ entries := make([]string, len(threads))
+ for i, t := range threads {
+ fulltime := t.Publish.Format(rfc822RSS)
+ date := t.Publish.Format("2006-01-02")
+ posturl := filepath.Join(config.RSS.URL, fmt.Sprintf("%s#%d", t.Slug, t.PostID))
+ entry := rss.OutputRSSItem(fulltime, t.Title, fmt.Sprintf("[%s] %s posted", date, t.Author), posturl)
+ entries[i] = entry
+ }
+ feed := rss.OutputRSS(config.RSS.Name, config.RSS.URL, config.RSS.Description, entries)
+ return feed
+}
+
+func (h *RequestHandler) RSSRoute(res http.ResponseWriter, req *http.Request) {
+ // error if feed not configured (e.g. config.RSS.URL not set)
+ if h.config.RSS.URL == "" {
+ http.Error(res, "Feed Not Configured", http.StatusNotFound)
+ return
+ }
+ res.Header().Set("Content-Type", "application/xml")
+ res.Write([]byte(h.rssFeed))
+}
+
func (h RequestHandler) LogoutRoute(res http.ResponseWriter, req *http.Request) {
loggedIn, _ := h.IsLoggedIn(req)
if loggedIn {
@@ -723,7 +787,7 @@ func (h RequestHandler) RobotsRoute(res http.ResponseWriter, req *http.Request)
fmt.Fprintln(res, "User-agent: *\nDisallow: /")
}
-func (h RequestHandler) NewThreadRoute(res http.ResponseWriter, req *http.Request) {
+func (h *RequestHandler) NewThreadRoute(res http.ResponseWriter, req *http.Request) {
loggedIn, userid := h.IsLoggedIn(req)
switch req.Method {
// Handle GET (=> want to start a new thread)
@@ -757,6 +821,8 @@ func (h RequestHandler) NewThreadRoute(res http.ResponseWriter, req *http.Reques
h.renderView(res, "generic-message", TemplateData{Data: data, Title: h.translator.Translate("ThreadNew")})
return
}
+ // update the rss feed
+ h.rssFeed = GenerateRSS(h.db, h.config)
// when data has been stored => redirect to thread
slug := fmt.Sprintf("thread/%d/%s/", threadid, util.SanitizeURL(title))
http.Redirect(res, req, "/"+slug, http.StatusSeeOther)
@@ -766,7 +832,7 @@ func (h RequestHandler) NewThreadRoute(res http.ResponseWriter, req *http.Reques
}
}
-func (h RequestHandler) DeletePostRoute(res http.ResponseWriter, req *http.Request) {
+func (h *RequestHandler) DeletePostRoute(res http.ResponseWriter, req *http.Request) {
if req.Method == "GET" {
IndexRedirect(res, req)
return
@@ -815,6 +881,8 @@ func (h RequestHandler) DeletePostRoute(res http.ResponseWriter, req *http.Reque
renderErr("That's not your post to delete? Sorry buddy!")
return
}
+ // update the rss feed, in case the deleted post was present in feed
+ h.rssFeed = GenerateRSS(h.db, h.config)
}
http.Redirect(res, req, threadURL, http.StatusSeeOther)
}
@@ -837,7 +905,10 @@ func Serve(allowlist []string, sessionKey string, isdev bool, dir string, conf t
util.Check(err, "setting up tcp listener")
}
fmt.Println("Serving forum on", port)
- http.Serve(l, forum)
+
+ rateLimitingInstance := NewRateLimitingWare([]string{"/rss/", "/rss.xml"})
+ limitingMiddleware := rateLimitingInstance.Handler(forum)
+ http.Serve(l, limitingMiddleware)
}
// CercaForum is an HTTP.ServeMux which is set up to initialize and run
@@ -897,7 +968,8 @@ func NewServer(allowlist []string, sessionKey, dir string, config types.Config)
// for currently translated languages, see i18n/i18n.go
translator := i18n.Init(config.Community.Language)
templates := template.Must(generateTemplates(config, translator))
- handler := RequestHandler{&db, session.New(sessionKey, developing), allowlist, files, config, translator, templates}
+ feed := GenerateRSS(&db, config)
+ handler := RequestHandler{&db, session.New(sessionKey, developing), allowlist, files, config, translator, templates, feed}
/* note: be careful with trailing slashes; go's default handler is a bit sensitive */
// TODO (2022-01-10): introduce middleware to make sure there is never an issue with trailing slashes
@@ -911,6 +983,8 @@ func NewServer(allowlist []string, sessionKey, dir string, config types.Config)
s.ServeMux.HandleFunc("/thread/", handler.ThreadRoute)
s.ServeMux.HandleFunc("/robots.txt", handler.RobotsRoute)
s.ServeMux.HandleFunc("/", handler.IndexRoute)
+ s.ServeMux.HandleFunc("/rss/", handler.RSSRoute)
+ s.ServeMux.HandleFunc("/rss.xml", handler.RSSRoute)
fileserver := http.FileServer(http.Dir("html/assets/"))
s.ServeMux.Handle("/assets/", http.StripPrefix("/assets/", fileserver))
diff --git a/types/types.go b/types/types.go
@@ -11,6 +11,12 @@ type Config struct {
Language string `json:"language"`
} `json:"general"`
+ RSS struct {
+ Name string `json:"feed_name"`
+ Description string `json:"feed_description"`
+ URL string `json:"forum_url"`
+ } `json:"rss"`
+
Documents struct {
LogoPath string `json:"logo"`
AboutPath string `json:"about"`