cerca

lean forum software (pmc local branch)
Log | Files | Refs | README | LICENSE

commit 85a21b3733447b239315bc023d6a134ddeb35e64
parent 5b4cf8c225dc8bce321f45baefc6fef42ba2954d
Author: Alexander Cobleigh <cblgh@cblgh.org>
Date:   Thu, 15 Dec 2022 10:38:58 +0100

Merge pull request #40 from cblgh/rss-feed

RSS Support
Diffstat:
MREADME.md | 5+++++
Mdatabase/database.go | 9++++++---
Mdefaults/sample-config.toml | 5+++++
Mgo.mod | 2++
Mgo.sum | 8++++++++
Mhtml/head.html | 7+++++++
Alimiter/limiter.go | 111+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mserver/server.go | 129++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------
Mtypes/types.go | 6++++++
9 files changed, 262 insertions(+), 20 deletions(-)

diff --git a/README.md b/README.md @@ -39,6 +39,11 @@ name = "" # whatever you want to name your forum; primarily used as display in t conduct_url = "" # optional + recommended: if omitted, the CoC checkboxes in /register will be hidden language = "English" # Swedish, English. contributions for more translations welcome! +[rss] +feed_name = "" # defaults to [general]'s name if unset +feed_description = "" +forum_url = "" # should be forum index route https://example.com. used to generate post routes for feed, must be set to generate a feed + [documents] logo = "content/logo.html" # can contain emoji, <img>, <svg> etc. see defaults/sample-logo.html in repo for instructions about = "content/about.md" 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/defaults/sample-config.toml b/defaults/sample-config.toml @@ -3,6 +3,11 @@ name = "" # whatever you want to name your forum; primarily used as display in t conduct_url = "" # optional + recommended: if omitted, the CoC checkboxes in /register will be hidden language = "English" # Swedish, English. contributions for more translations welcome! +[rss] +feed_name = "" # defaults to [general]'s name if unset +feed_description = "" +forum_url = "" # should be forum index route https://example.com. used to generate post routes for feed, must be set to generate a feed + [documents] logo = "content/logo.html" # can contain emoji, <img>, <svg> etc. see defaults/sample-logo.html in repo for instructions about = "content/about.md" 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/html/head.html b/html/head.html @@ -29,6 +29,10 @@ /* normalize post titles */ font-size: 1rem; } + button { + text-decoration: underline; + cursor: pointer; + } #logo { width: 48px; height: 48px; @@ -191,6 +195,9 @@ <li><a href="#bottom">{{ "Bottom" | translate }}</a></li> {{end}} <li><a href="/about">{{ "About" | translate }}</a></li> + {{ if .HasRSS }} + <li><a href="/rss.xml">rss</a></li> + {{ end }} {{ if .LoggedIn }} <li><a href="/logout">{{"Logout" | translate }}</a></li> {{ else }} diff --git a/limiter/limiter.go b/limiter/limiter.go @@ -0,0 +1,111 @@ +package limiter + +import ( + "context" + "golang.org/x/time/rate" + "time" +) + +type TimedRateLimiter struct { + // periodic forgetting of identifiers that have been seen & assigned a rate limiter to prevent bloat over time + timers map[string]*time.Timer + // buckets of access tokens, refreshing over time + limiters map[string]*rate.Limiter + // routes that are rate limited + routes map[string]bool + refreshPeriod time.Duration + timeToRemember time.Duration + burst int +} + +func NewTimedRateLimiter(limitedRoutes []string, refresh, remember time.Duration) *TimedRateLimiter { + rl := TimedRateLimiter{} + rl.timers = make(map[string]*time.Timer) + rl.limiters = make(map[string]*rate.Limiter) + rl.routes = make(map[string]bool) + for _, route := range limitedRoutes { + rl.routes[route] = true + } + rl.refreshPeriod = refresh + rl.timeToRemember = remember + rl.burst = 15 /* default value, use rl.SetBurstAllowance to change */ + return &rl +} + +// amount of accesses allowed ~concurrently, before needing to wait for a rl.refreshPeriod +func (rl *TimedRateLimiter) SetBurstAllowance(burst int) { + if burst >= 1 { + rl.burst = burst + } +} + +// find out if resource access is allowed or not: calling consumes a rate limit token +func (rl *TimedRateLimiter) IsLimited(identifier, route string) bool { + // route isn't rate limited + if _, exists := rl.routes[route]; !exists { + return false + } + // route is designated to be rate limited, try the limiter to see if we can access it + ret := !rl.access(identifier) + return ret +} + +func (rl *TimedRateLimiter) BlockUntilAllowed(identifier, route string, ctx context.Context) error { + // route isn't rate limited + if _, exists := rl.routes[route]; !exists { + return nil + } + limiter := rl.getLimiter(identifier) + err := limiter.Wait(ctx) + if err != nil { + return err + } + return nil +} + +func (rl *TimedRateLimiter) getLimiter(identifier string) *rate.Limiter { + // limiter doesn't yet exist for this identifier + if _, exists := rl.limiters[identifier]; !exists { + // create a rate limit for it + rl.createRateLimit(identifier) + // remember this identifier (remote ip) for rl.timeToRemember before forgetting + rl.rememberIdentifier(identifier) + } + limiter := rl.limiters[identifier] + return limiter +} + +// returns true if identifier currently allowed to access the resource +func (rl *TimedRateLimiter) access(identifier string) bool { + limiter := rl.getLimiter(identifier) + // consumes one token from the rate limiter bucket + allowed := limiter.Allow() + return allowed +} + +func (rl *TimedRateLimiter) createRateLimit(identifier string) { + accessRate := rate.Every(rl.refreshPeriod) + limit := rate.NewLimiter(accessRate, rl.burst) + rl.limiters[identifier] = limit +} + +func (rl *TimedRateLimiter) rememberIdentifier(identifier string) { + // timer already exists; refresh it + if timer, exists := rl.timers[identifier]; exists { + timer.Reset(rl.timeToRemember) + return + } + // new timer + timer := time.AfterFunc(rl.timeToRemember, func() { + rl.forgetLimiter(identifier) + }) + // map timer to its identifier + rl.timers[identifier] = timer +} + +// forget the rate limiter associated for this identifier (to prevent memory growth over time) +func (rl *TimedRateLimiter) forgetLimiter(identifier string) { + if _, exists := rl.limiters[identifier]; exists { + delete(rl.limiters, identifier) + } +} 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 */ @@ -35,6 +37,7 @@ type TemplateData struct { Data interface{} QuickNav bool LoggedIn bool // TODO (2022-01-09): put this in a middleware || template function or sth? + HasRSS bool LoggedInID int ForumName string Title string @@ -93,6 +96,7 @@ type RequestHandler struct { config types.Config translator i18n.Translator templates *template.Template + rssFeed string } var developing bool @@ -103,6 +107,44 @@ 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(25) + 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] + // specific fix in case of using a reverse proxy setup + if address, exists := req.Header["X-Real-Ip"]; ip == "127.0.0.1" && exists { + ip = address[0] + } + // rate limiting likely not working as intended on server; + // set a x-real-ip header: https://docs.nginx.com/nginx/admin-guide/web-server/reverse-proxy/ + if !developing && ip == "127.0.0.1" { + next.ServeHTTP(res, req) + return + } + 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 +271,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) @@ -239,7 +281,7 @@ func (h RequestHandler) ThreadRoute(res http.ResponseWriter, req *http.Request) Title: title, Message: h.translator.Translate("ErrThread404Message"), } - h.renderView(res, "generic-message", TemplateData{Data: data, LoggedIn: loggedIn}) + h.renderView(res, "generic-message", TemplateData{Data: data, HasRSS: h.config.RSS.URL != "", LoggedIn: loggedIn}) return } @@ -256,6 +298,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 } @@ -273,7 +317,7 @@ func (h RequestHandler) ThreadRoute(res http.ResponseWriter, req *http.Request) thread[i].Content = template.HTML(content) } data := ThreadData{Posts: thread, ThreadURL: req.URL.Path} - view := TemplateData{Data: &data, QuickNav: loggedIn, LoggedIn: loggedIn, LoggedInID: userid} + view := TemplateData{Data: &data, QuickNav: loggedIn, HasRSS: h.config.RSS.URL != "", LoggedIn: loggedIn, LoggedInID: userid} if len(thread) > 0 { data.Title = thread[0].ThreadTitle view.Title = data.Title @@ -306,7 +350,7 @@ func (h RequestHandler) IndexRoute(res http.ResponseWriter, req *http.Request) { } // show index listing threads := h.db.ListThreads(mostRecentPost) - view := TemplateData{Data: IndexData{threads}, LoggedIn: loggedIn, Title: h.translator.Translate("Threads")} + view := TemplateData{Data: IndexData{threads}, HasRSS: h.config.RSS.URL != "", LoggedIn: loggedIn, Title: h.translator.Translate("Threads")} h.renderView(res, "index", view) } @@ -314,6 +358,47 @@ 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 joinPath(host, upath string) string { + host = strings.TrimSuffix(host, "/") + upath = strings.TrimPrefix(upath, "/") + return fmt.Sprintf("%s/%s", host, upath) +} + +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 := joinPath(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 + } + feedName := config.RSS.Name + if feedName == "" { + feedName = config.Community.Name + } + feed := rss.OutputRSS(feedName, 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 { @@ -327,7 +412,7 @@ func (h RequestHandler) LoginRoute(res http.ResponseWriter, req *http.Request) { loggedIn, _ := h.IsLoggedIn(req) switch req.Method { case "GET": - h.renderView(res, "login", TemplateData{Data: LoginData{}, LoggedIn: loggedIn, Title: h.translator.Translate("Login")}) + h.renderView(res, "login", TemplateData{Data: LoginData{}, HasRSS: h.config.RSS.URL != "", LoggedIn: loggedIn, Title: h.translator.Translate("Login")}) case "POST": username := req.PostFormValue("username") password := req.PostFormValue("password") @@ -339,7 +424,7 @@ func (h RequestHandler) LoginRoute(res http.ResponseWriter, req *http.Request) { } if err != nil { fmt.Println(err) - h.renderView(res, "login", TemplateData{Data: LoginData{FailedAttempt: true}, LoggedIn: loggedIn, Title: h.translator.Translate("Login")}) + h.renderView(res, "login", TemplateData{Data: LoginData{FailedAttempt: true}, HasRSS: h.config.RSS.URL != "", LoggedIn: loggedIn, Title: h.translator.Translate("Login")}) return } // save user id in cookie @@ -388,7 +473,7 @@ func (h RequestHandler) handleChangePassword(res http.ResponseWriter, req *http. case "GET": switch req.URL.Path { default: - h.renderView(res, "change-password", TemplateData{LoggedIn: true, Data: ChangePasswordData{Action: "/reset/submit"}}) + h.renderView(res, "change-password", TemplateData{HasRSS: h.config.RSS.URL != "", LoggedIn: true, Data: ChangePasswordData{Action: "/reset/submit"}}) } case "POST": switch req.URL.Path { @@ -441,7 +526,7 @@ func (h RequestHandler) handleChangePassword(res http.ResponseWriter, req *http. // then save the hash h.db.UpdateUserPasswordHash(uid, pwhashNew) // render a success message & show a link to the login page :') - h.renderView(res, "change-password-success", TemplateData{LoggedIn: true, Data: ChangePasswordData{Keypair: keypairString}}) + h.renderView(res, "change-password-success", TemplateData{HasRSS: h.config.RSS.URL != "", LoggedIn: true, Data: ChangePasswordData{Keypair: keypairString}}) default: fmt.Printf("unsupported POST route (%s), redirecting to /\n", req.URL.Path) IndexRedirect(res, req) @@ -591,7 +676,7 @@ func (h RequestHandler) RegisterRoute(res http.ResponseWriter, req *http.Request LinkMessage: h.translator.Translate("RegisterLinkMessage"), LinkText: h.translator.Translate("Index"), } - h.renderView(res, "generic-message", TemplateData{Data: data, LoggedIn: loggedIn, Title: h.translator.Translate("Register")}) + h.renderView(res, "generic-message", TemplateData{Data: data, HasRSS: h.config.RSS.URL != "", LoggedIn: loggedIn, Title: h.translator.Translate("Register")}) return } @@ -694,7 +779,7 @@ func (h RequestHandler) RegisterRoute(res http.ResponseWriter, req *http.Request } kpJson, err := keypair.Marshal() ed.Check(err, "marshal keypair") - h.renderView(res, "register-success", TemplateData{Data: RegisterSuccessData{string(kpJson)}, LoggedIn: loggedIn, Title: h.translator.Translate("RegisterSuccess")}) + h.renderView(res, "register-success", TemplateData{Data: RegisterSuccessData{string(kpJson)}, HasRSS: h.config.RSS.URL != "", LoggedIn: loggedIn, Title: h.translator.Translate("RegisterSuccess")}) default: fmt.Println("non get/post method, redirecting to index") IndexRedirect(res, req) @@ -716,14 +801,14 @@ func (h RequestHandler) GenericRoute(res http.ResponseWriter, req *http.Request) func (h RequestHandler) AboutRoute(res http.ResponseWriter, req *http.Request) { loggedIn, _ := h.IsLoggedIn(req) input := util.Markup(template.HTML(h.files["about"])) - h.renderView(res, "about-template", TemplateData{Data: input, LoggedIn: loggedIn, Title: h.translator.Translate("About")}) + h.renderView(res, "about-template", TemplateData{Data: input, HasRSS: h.config.RSS.URL != "", LoggedIn: loggedIn, Title: h.translator.Translate("About")}) } 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) @@ -741,7 +826,7 @@ func (h RequestHandler) NewThreadRoute(res http.ResponseWriter, req *http.Reques h.renderView(res, "generic-message", TemplateData{Data: data, Title: title}) return } - h.renderView(res, "new-thread", TemplateData{LoggedIn: loggedIn, Title: h.translator.Translate("ThreadNew")}) + h.renderView(res, "new-thread", TemplateData{HasRSS: h.config.RSS.URL != "", LoggedIn: loggedIn, Title: h.translator.Translate("ThreadNew")}) case "POST": // Handle POST (=> title := req.PostFormValue("title") @@ -757,6 +842,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 +853,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 @@ -786,7 +873,7 @@ func (h RequestHandler) DeletePostRoute(res http.ResponseWriter, req *http.Reque renderErr := func(msg string) { fmt.Println(msg) genericErr.Message = msg - h.renderView(res, "generic-message", TemplateData{Data: genericErr, LoggedIn: loggedIn}) + h.renderView(res, "generic-message", TemplateData{Data: genericErr, HasRSS: h.config.RSS.URL != "", LoggedIn: loggedIn}) } if !loggedIn || !ok { @@ -815,6 +902,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 +926,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 +989,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 +1004,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"`