cerca

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

commit e7fd18a2c195e83f99c8c91cb0b61f436005f49c
parent df4155ff766df8fa569314d4aca56582efea981d
Author: cblgh <cblgh@cblgh.org>
Date:   Mon, 12 Dec 2022 11:50:41 +0100

add rss functionality

Diffstat:
Mdatabase/database.go | 9++++++---
Mgo.mod | 2++
Mgo.sum | 8++++++++
Mserver/server.go | 84++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----
Mtypes/types.go | 6++++++
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"`