cerca

lean forum software (pmc local branch)
git clone http://git.permacomputing.net/repos/cerca.git # read-only access
Log | Files | Refs | README | LICENSE

server.go (31000B)


      1 package server
      2 
      3 import (
      4 	"context"
      5 	"database/sql"
      6 	"errors"
      7 	"fmt"
      8 	"html/template"
      9 	"log"
     10 	"net"
     11 	"net/http"
     12 	"net/url"
     13 	"os"
     14 	"path/filepath"
     15 	"strings"
     16 	"syscall"
     17 	"time"
     18 
     19 	"cerca/crypto"
     20 	"cerca/database"
     21 	"cerca/defaults"
     22 	cercaHTML "cerca/html"
     23 	"cerca/i18n"
     24 	"cerca/limiter"
     25 	"cerca/server/session"
     26 	"cerca/types"
     27 	"cerca/util"
     28 
     29 	"github.com/carlmjohnson/requests"
     30 	"github.com/cblgh/plain/rss"
     31 )
     32 
     33 /* TODO (2022-01-03): include csrf token via gorilla, or w/e, when rendering */
     34 
     35 type TemplateData struct {
     36 	Data       interface{}
     37 	QuickNav   bool
     38 	LoggedIn   bool
     39 	IsAdmin    bool
     40 	HasRSS     bool
     41 	LoggedInID int
     42 	ForumName  string
     43 	Title      string
     44 }
     45 
     46 type PasswordResetData struct {
     47 	Action   string
     48 	Username string
     49 	Payload  string
     50 }
     51 
     52 type ChangePasswordData struct {
     53 	Action string
     54 }
     55 
     56 type IndexData struct {
     57 	Threads []database.Thread
     58 }
     59 
     60 type GenericMessageData struct {
     61 	Title       string
     62 	Message     string
     63 	LinkMessage string
     64 	Link        string
     65 	LinkText    string
     66 }
     67 
     68 type RegisterData struct {
     69 	VerificationCode         string
     70 	ErrorMessage             string
     71 	Rules                    template.HTML
     72 	VerificationInstructions template.HTML
     73 	ConductLink              string
     74 }
     75 
     76 type LoginData struct {
     77 	FailedAttempt bool
     78 }
     79 
     80 type ThreadData struct {
     81 	Title     string
     82 	Posts     []database.Post
     83 	ThreadURL string
     84 	Private   bool
     85 }
     86 
     87 type EditPostData struct {
     88 	Title   string
     89 	Content string
     90 }
     91 
     92 type RequestHandler struct {
     93 	db         *database.DB
     94 	session    *session.Session
     95 	allowlist  []string // allowlist of domains valid for forum registration
     96 	files      map[string][]byte
     97 	config     types.Config
     98 	translator i18n.Translator
     99 	templates  *template.Template
    100 	rssFeed    string
    101 }
    102 
    103 var developing bool
    104 
    105 func dump(err error) {
    106 	if developing {
    107 		fmt.Println(err)
    108 	}
    109 }
    110 
    111 type RateLimitingWare struct {
    112 	limiter *limiter.TimedRateLimiter
    113 }
    114 
    115 func NewRateLimitingWare(routes []string) *RateLimitingWare {
    116 	ware := RateLimitingWare{}
    117 	// refresh one access every 15 minutes. forget about the requester after 24h of non-activity
    118 	ware.limiter = limiter.NewTimedRateLimiter(routes, 15*time.Minute, 24*time.Hour)
    119 	// allow 15 requests at once, then
    120 	ware.limiter.SetBurstAllowance(25)
    121 	return &ware
    122 }
    123 
    124 func (ware *RateLimitingWare) Handler(next http.Handler) http.Handler {
    125 	return http.HandlerFunc(func(res http.ResponseWriter, req *http.Request) {
    126 		portIndex := strings.LastIndex(req.RemoteAddr, ":")
    127 		ip := req.RemoteAddr[:portIndex]
    128 		// specific fix in case of using a reverse proxy setup
    129 		if address, exists := req.Header["X-Real-Ip"]; ip == "127.0.0.1" && exists {
    130 			ip = address[0]
    131 		}
    132 		// rate limiting likely not working as intended on server;
    133 		// set a x-real-ip header: https://docs.nginx.com/nginx/admin-guide/web-server/reverse-proxy/
    134 		if !developing && ip == "127.0.0.1" {
    135 			next.ServeHTTP(res, req)
    136 			return
    137 		}
    138 		err := ware.limiter.BlockUntilAllowed(ip, req.URL.String(), req.Context())
    139 		if err != nil {
    140 			err = util.Eout(err, "RateLimitingWare")
    141 			dump(err)
    142 			http.Error(res, "An error occured", http.StatusInternalServerError)
    143 			return
    144 		}
    145 		next.ServeHTTP(res, req)
    146 	})
    147 }
    148 
    149 // returns true if logged in, and the userid of the logged in user.
    150 // returns false (and userid set to -1) if not logged in
    151 func (h RequestHandler) IsLoggedIn(req *http.Request) (bool, int) {
    152 	ed := util.Describe("IsLoggedIn")
    153 	userid, err := h.session.Get(req)
    154 	err = ed.Eout(err, "getting userid from session cookie")
    155 	if err != nil {
    156 		dump(err)
    157 		return false, -1
    158 	}
    159 
    160 	// make sure the user from the cookie actually exists
    161 	userExists, err := h.db.CheckUserExists(userid)
    162 	if err != nil {
    163 		dump(ed.Eout(err, "check userid in db"))
    164 		return false, -1
    165 	} else if !userExists {
    166 		return false, -1
    167 	}
    168 	return true, userid
    169 }
    170 
    171 // establish closure over config + translator so that it's present in templates during render
    172 func generateTemplates(config types.Config, translator i18n.Translator) (*template.Template, error) {
    173 	// only read logo contents once when generating
    174 	logo, err := os.ReadFile(config.Documents.LogoPath)
    175 	util.Check(err, "generate-template: dump logo")
    176 	templateFuncs := template.FuncMap{
    177 		"dumpLogo": func() template.HTML {
    178 			return template.HTML(logo)
    179 		},
    180 		"formatDateTime": func(t time.Time) string {
    181 			return t.Format("2006-01-02 15:04:05")
    182 		},
    183 		"formatDateTimeRFC3339": func(t time.Time) string {
    184 			return t.Format(time.RFC3339Nano)
    185 		},
    186 		"formatDate": func(t time.Time) string {
    187 			return t.Format("2006-01-02")
    188 		},
    189 		"formatDateTimeRelative": util.RelativeTime,
    190 		"formatDateRelative": func(t time.Time) string {
    191 			diff := time.Since(t)
    192 			if diff < time.Hour*24 {
    193 				return "today"
    194 			} else if diff >= time.Hour*24 && diff < time.Hour*48 {
    195 				return "yesterday"
    196 			}
    197 			return t.Format("2006-01-02")
    198 		},
    199 		"translate": func(key string) string {
    200 			return translator.Translate(key)
    201 		},
    202 		"translateWithData": func(key string) string {
    203 			data := struct {
    204 				Name string
    205 				Link string
    206 			}{
    207 				Name: config.Community.Name,
    208 				Link: config.Community.ConductLink,
    209 			}
    210 			return translator.TranslateWithData(key, i18n.TranslationData{data})
    211 		},
    212 		"capitalize": util.Capitalize,
    213 		"markup":     util.Markup,
    214 		"tohtml": func(s string) template.HTML {
    215 			// use of this function is risky cause it interprets the passed in string and renders it as unescaped html.
    216 			// can allow for attacks!
    217 			//
    218 			// advice: only use on strings that come statically from within cerca code, never on titles that may contain user-submitted data
    219 			// :)
    220 			return (template.HTML)(s)
    221 		},
    222 	}
    223 	views := []string{
    224 		"about",
    225 		"about-template",
    226 		"footer",
    227 		"generic-message",
    228 		"head",
    229 		"edit-post",
    230 		"index",
    231 		"login",
    232 		"login-component",
    233 		"new-thread",
    234 		"register",
    235 		"register-success",
    236 		"thread",
    237 		"admin",
    238 		"admins-list",
    239 		"admin-add-user",
    240 		"moderation-log",
    241 		"password-reset",
    242 		"change-password",
    243 		"change-password-success",
    244 	}
    245 
    246 	rootTemplate := template.New("root")
    247 
    248 	for _, view := range views {
    249 		newTemplate, err := rootTemplate.Funcs(templateFuncs).ParseFS(cercaHTML.Templates, fmt.Sprintf("%s.html", view))
    250 		if err != nil {
    251 			return nil, fmt.Errorf("could not get files: %w", err)
    252 		}
    253 		rootTemplate = newTemplate
    254 	}
    255 
    256 	return rootTemplate, nil
    257 }
    258 
    259 func (h RequestHandler) renderView(res http.ResponseWriter, viewName string, data TemplateData) {
    260 	if data.Title == "" {
    261 		data.Title = strings.ReplaceAll(viewName, "-", " ")
    262 	}
    263 
    264 	if h.config.Community.Name != "" {
    265 		data.ForumName = h.config.Community.Name
    266 	}
    267 	if data.ForumName == "" {
    268 		data.ForumName = "Forum"
    269 	}
    270 
    271 	view := fmt.Sprintf("%s.html", viewName)
    272 	if err := h.templates.ExecuteTemplate(res, view, data); err != nil {
    273 		if errors.Is(err, syscall.EPIPE) {
    274 			fmt.Println("recovering from broken pipe")
    275 			return
    276 		} else {
    277 			util.Check(err, "rendering %q view", view)
    278 		}
    279 	}
    280 }
    281 
    282 func (h RequestHandler) renderGenericMessage(res http.ResponseWriter, req *http.Request, incomingData GenericMessageData) {
    283 	loggedIn, _ := h.IsLoggedIn(req)
    284 	isAdmin, _ := h.IsAdmin(req)
    285 	data := TemplateData{
    286 		Data: incomingData,
    287 		// the following two fields are defaults that usually are not set and which are cumbersome to set each time since
    288 		// they don't really matter / vary across invocations
    289 		HasRSS:   h.config.RSS.URL != "",
    290 		LoggedIn: loggedIn,
    291 		IsAdmin:  isAdmin,
    292 	}
    293 	h.renderView(res, "generic-message", data)
    294 	return
    295 }
    296 
    297 func (h *RequestHandler) ThreadRoute(res http.ResponseWriter, req *http.Request) {
    298 	threadid, ok := util.GetURLPortion(req, 2)
    299 	loggedIn, userid := h.IsLoggedIn(req)
    300 	isAdmin, _ := h.IsAdmin(req)
    301 
    302 	threadMissingData := GenericMessageData{
    303 		Title:   h.translator.Translate("ErrThread404"),
    304 		Message: h.translator.Translate("ErrThread404Message"),
    305 	}
    306 	if !ok {
    307 		h.renderGenericMessage(res, req, threadMissingData)
    308 		return
    309 	}
    310 
    311 	if req.Method == "POST" && loggedIn {
    312 		// handle POST (=> add a reply, then show the thread)
    313 		content := req.PostFormValue("content")
    314 		// TODO (2022-01-09): make sure rendered content won't be empty after sanitizing:
    315 		// * run sanitize step && strings.TrimSpace and check length **before** doing AddPost
    316 		// TODO(2022-01-09): send errors back to thread's posting view
    317 		_ = h.db.AddPost(content, threadid, userid)
    318 		// we want to effectively redirect to <#posts+1> to mark the thread as read in the thread index
    319 		// TODO(2022-01-30): find a solution for either:
    320 		// * scrolling to thread bottom (and maintaining the same slug, important for visited state in browser)
    321 		// * passing data to signal "your post was successfully added" (w/o impacting visited state / url)
    322 		posts, err := h.db.GetThread(threadid)
    323 
    324 		if err != nil {
    325 			h.renderGenericMessage(res, req, threadMissingData)
    326 			return
    327 		}
    328 
    329 		newSlug := util.GetThreadSlug(threadid, posts[0].ThreadTitle, len(posts))
    330 		// update the rss feed
    331 		h.rssFeed = GenerateRSS(h.db, h.config)
    332 		http.Redirect(res, req, newSlug, http.StatusFound)
    333 		return
    334 	}
    335 
    336 	// check if we're dealing with a private thread. this can return an error if the thread id does not exist
    337 	isPrivate, err := h.db.IsThreadPrivate(threadid)
    338 
    339 	if err != nil || (isPrivate && !loggedIn) {
    340 		h.renderGenericMessage(res, req, threadMissingData)
    341 		return
    342 	}
    343 	thread, err := h.db.GetThread(threadid)
    344 
    345 	if err != nil {
    346 		h.renderGenericMessage(res, req, threadMissingData)
    347 		return
    348 	}
    349 
    350 	data := ThreadData{Posts: thread, ThreadURL: req.URL.Path, Private: isPrivate}
    351 	view := TemplateData{Data: &data, IsAdmin: isAdmin, QuickNav: loggedIn, HasRSS: h.config.RSS.URL != "", LoggedIn: loggedIn, LoggedInID: userid}
    352 	if len(thread) > 0 {
    353 		data.Title = thread[0].ThreadTitle
    354 		view.Title = data.Title
    355 	}
    356 	h.renderView(res, "thread", view)
    357 }
    358 
    359 func (h RequestHandler) ErrorRoute(res http.ResponseWriter, req *http.Request, status int) {
    360 	title := h.translator.Translate("ErrGeneric404")
    361 	data := GenericMessageData{
    362 		Title:   title,
    363 		Message: fmt.Sprintf(h.translator.Translate("ErrGeneric404Message"), status),
    364 	}
    365 	h.renderGenericMessage(res, req, data)
    366 }
    367 
    368 func (h RequestHandler) IndexRoute(res http.ResponseWriter, req *http.Request) {
    369 	// handle 404
    370 	if req.URL.Path != "/" {
    371 		h.ErrorRoute(res, req, http.StatusNotFound)
    372 		return
    373 	}
    374 	loggedIn, _ := h.IsLoggedIn(req)
    375 	var mostRecentPost bool
    376 	isAdmin, _ := h.IsAdmin(req)
    377 
    378 	params := req.URL.Query()
    379 	if q, exists := params["sort"]; exists {
    380 		sortby := q[0]
    381 		mostRecentPost = sortby == "posts"
    382 	}
    383 	includePrivateThreads := loggedIn
    384 	// show index listing
    385 	threads := h.db.ListThreads(mostRecentPost, includePrivateThreads)
    386 	view := TemplateData{Data: IndexData{threads}, IsAdmin: isAdmin, HasRSS: h.config.RSS.URL != "", LoggedIn: loggedIn, Title: h.translator.Translate("Threads")}
    387 	h.renderView(res, "index", view)
    388 }
    389 
    390 func IndexRedirect(res http.ResponseWriter, req *http.Request) {
    391 	http.Redirect(res, req, "/", http.StatusSeeOther)
    392 }
    393 
    394 const rfc822RSS = "Mon, 02 Jan 2006 15:04:05 -0700"
    395 
    396 func joinPath(host, upath string) string {
    397 	host = strings.TrimSuffix(host, "/")
    398 	upath = strings.TrimPrefix(upath, "/")
    399 	return fmt.Sprintf("%s/%s", host, upath)
    400 }
    401 
    402 func GenerateRSS(db *database.DB, config types.Config) string {
    403 	if config.RSS.URL == "" {
    404 		return "feed not configured"
    405 	}
    406 	// TODO (2022-12-08): augment ListThreads to choose getting author of latest post or thread creator (currently latest
    407 	// post always)
    408 	sortByPost := true
    409 	includePrivateThreads := false
    410 	threads := db.ListThreads(sortByPost, includePrivateThreads)
    411 	entries := make([]string, len(threads))
    412 	for i, t := range threads {
    413 		fulltime := t.Publish.Format(rfc822RSS)
    414 		date := t.Publish.Format("2006-01-02")
    415 		posturl := joinPath(config.RSS.URL, fmt.Sprintf("%s#%d", t.Slug, t.PostID))
    416 		entry := rss.OutputRSSItem(fulltime, t.Title, fmt.Sprintf("[%s] %s posted", date, t.Author), posturl)
    417 		entries[i] = entry
    418 	}
    419 	feedName := config.RSS.Name
    420 	if feedName == "" {
    421 		feedName = config.Community.Name
    422 	}
    423 	feed := rss.OutputRSS(feedName, config.RSS.URL, config.RSS.Description, entries)
    424 	return feed
    425 }
    426 
    427 func (h *RequestHandler) RSSRoute(res http.ResponseWriter, req *http.Request) {
    428 	// error if feed not configured (e.g. config.RSS.URL not set)
    429 	if h.config.RSS.URL == "" {
    430 		http.Error(res, "Feed Not Configured", http.StatusNotFound)
    431 		return
    432 	}
    433 	res.Header().Set("Content-Type", "application/xml")
    434 	res.Write([]byte(h.rssFeed))
    435 }
    436 
    437 func (h RequestHandler) LogoutRoute(res http.ResponseWriter, req *http.Request) {
    438 	loggedIn, _ := h.IsLoggedIn(req)
    439 	if loggedIn {
    440 		h.session.Delete(res, req)
    441 	}
    442 	IndexRedirect(res, req)
    443 }
    444 
    445 func (h RequestHandler) LoginRoute(res http.ResponseWriter, req *http.Request) {
    446 	ed := util.Describe("LoginRoute")
    447 	loggedIn, _ := h.IsLoggedIn(req)
    448 	switch req.Method {
    449 	case "GET":
    450 		h.renderView(res, "login", TemplateData{Data: LoginData{}, HasRSS: h.config.RSS.URL != "", LoggedIn: loggedIn, Title: h.translator.Translate("Login")})
    451 	case "POST":
    452 		username := req.PostFormValue("username")
    453 		password := req.PostFormValue("password")
    454 		// * hash received password and compare to stored hash
    455 		passwordHash, userid, err := h.db.GetPasswordHash(username)
    456 		// make sure user exists
    457 		if err = ed.Eout(err, "getting password hash and uid"); err == nil && !crypto.ValidatePasswordHash(password, passwordHash) {
    458 			err = errors.New("incorrect password")
    459 		}
    460 		if err != nil {
    461 			fmt.Println(err)
    462 			h.renderView(res, "login", TemplateData{Data: LoginData{FailedAttempt: true}, HasRSS: h.config.RSS.URL != "", LoggedIn: loggedIn, Title: h.translator.Translate("Login")})
    463 			return
    464 		}
    465 		// save user id in cookie
    466 		err = h.session.Save(req, res, userid)
    467 		ed.Check(err, "saving session cookie")
    468 		IndexRedirect(res, req)
    469 	default:
    470 		fmt.Println("non get/post method, redirecting to index")
    471 		IndexRedirect(res, req)
    472 	}
    473 }
    474 
    475 // downloads the content at the verification link and compares it to the verification code. returns true if the verification link content contains the verification code somewhere
    476 func hasVerificationCode(link, verification string) bool {
    477 	var linkBody string
    478 	err := requests.
    479 		URL(link).
    480 		ToString(&linkBody).
    481 		Fetch(context.Background())
    482 	if err != nil {
    483 		fmt.Println(util.Eout(err, "HasVerificationCode"))
    484 		return false
    485 	}
    486 
    487 	return strings.Contains(strings.TrimSpace(linkBody), strings.TrimSpace(verification))
    488 }
    489 
    490 func (h RequestHandler) handleChangePassword(res http.ResponseWriter, req *http.Request) {
    491 	// TODO (2022-10-24): add translations for change password view
    492 	title := h.translator.Translate("ChangePassword")
    493 	renderErr := func(errFmt string, args ...interface{}) {
    494 		errMessage := fmt.Sprintf(errFmt, args...)
    495 		fmt.Println(errMessage)
    496 		data := GenericMessageData{
    497 			Title:    title,
    498 			Message:  errMessage,
    499 			Link:     "/reset",
    500 			LinkText: h.translator.Translate("GoBack"),
    501 		}
    502 		h.renderGenericMessage(res, req, data)
    503 	}
    504 	_, uid := h.IsLoggedIn(req)
    505 
    506 	ed := util.Describe("change password")
    507 	switch req.Method {
    508 	case "GET":
    509 		switch req.URL.Path {
    510 		default:
    511 			h.renderView(res, "change-password", TemplateData{HasRSS: h.config.RSS.URL != "", LoggedIn: true, Data: ChangePasswordData{Action: "/reset/submit"}})
    512 		}
    513 	case "POST":
    514 		switch req.URL.Path {
    515 		case "/reset/submit":
    516 			oldPassword := req.PostFormValue("password-old")
    517 			newPassword := req.PostFormValue("password-new")
    518 
    519 			// check that the submitted, old password is valid
    520 			username, err := h.db.GetUsername(uid)
    521 			if err != nil {
    522 				dump(ed.Eout(err, "get username"))
    523 				return
    524 			}
    525 
    526 			pwhashOld, _, err := h.db.GetPasswordHash(username)
    527 			if err != nil {
    528 				dump(ed.Eout(err, "get old password hash"))
    529 				return
    530 			}
    531 
    532 			oldPasswordValid := crypto.ValidatePasswordHash(oldPassword, pwhashOld)
    533 			if !oldPasswordValid {
    534 				renderErr("old password did not match what was in database; not changing password")
    535 				return
    536 			}
    537 
    538 			// let's set the new password in the database. first, hash it
    539 			pwhashNew, err := crypto.HashPassword(newPassword)
    540 			if err != nil {
    541 				dump(ed.Eout(err, "hash new password"))
    542 				return
    543 			}
    544 			// then save the hash
    545 			h.db.UpdateUserPasswordHash(uid, pwhashNew)
    546 			// render a success message & show a link to the login page :')
    547 			h.renderView(res, "change-password-success", TemplateData{HasRSS: h.config.RSS.URL != "", LoggedIn: true, Data: ChangePasswordData{}})
    548 		default:
    549 			fmt.Printf("unsupported POST route (%s), redirecting to /\n", req.URL.Path)
    550 			IndexRedirect(res, req)
    551 		}
    552 	default:
    553 		fmt.Println("non get/post method, redirecting to index")
    554 		IndexRedirect(res, req)
    555 	}
    556 }
    557 
    558 func (h RequestHandler) ResetPasswordRoute(res http.ResponseWriter, req *http.Request) {
    559 	loggedIn, _ := h.IsLoggedIn(req)
    560 	title := util.Capitalize(h.translator.Translate("PasswordReset"))
    561 
    562 	// the user was logged in, let them change their password themselves :)
    563 	if loggedIn {
    564 		h.handleChangePassword(res, req)
    565 		return
    566 	}
    567 
    568 	renderPlaceholder := func(errFmt string, args ...interface{}) {
    569 		errMessage := fmt.Sprintf(errFmt, args...)
    570 		fmt.Println(errMessage)
    571 		data := GenericMessageData{
    572 			Title:    title,
    573 			Message:  errMessage,
    574 			Link:     "/",
    575 			LinkText: h.translator.Translate("GoBack"),
    576 		}
    577 		h.renderView(res, "generic-message", TemplateData{Data: data, Title: title})
    578 	}
    579 	renderPlaceholder("Password reset under construction: please contact admin if you need help resetting yr pw :)")
    580 	return
    581 }
    582 
    583 func (h RequestHandler) RegisterRoute(res http.ResponseWriter, req *http.Request) {
    584 	ed := util.Describe("register route")
    585 	loggedIn, _ := h.IsLoggedIn(req)
    586 	if loggedIn {
    587 		// TODO (2022-09-20): translate
    588 		data := GenericMessageData{
    589 			Title:       util.Capitalize(h.translator.Translate("Register")),
    590 			Message:     h.translator.Translate("RegisterMessage"),
    591 			Link:        "/",
    592 			LinkMessage: h.translator.Translate("RegisterLinkMessage"),
    593 			LinkText:    h.translator.Translate("Index"),
    594 		}
    595 		h.renderGenericMessage(res, req, data)
    596 		return
    597 	}
    598 
    599 	rules := util.Markup(string(h.files["rules"]))
    600 	verification := util.Markup(string(h.files["verification-instructions"]))
    601 	conduct := h.config.Community.ConductLink
    602 	var verificationCode string
    603 	renderErr := func(errFmt string, args ...interface{}) {
    604 		errMessage := fmt.Sprintf(errFmt, args...)
    605 		fmt.Println(errMessage)
    606 		h.renderView(res, "register", TemplateData{Data: RegisterData{verificationCode, errMessage, rules, verification, conduct}})
    607 	}
    608 
    609 	var err error
    610 	switch req.Method {
    611 	case "GET":
    612 		// try to get the verification code from the session (useful in case someone refreshed the page)
    613 		verificationCode, err = h.session.GetVerificationCode(req)
    614 		// we had an error getting the verification code, generate a code and set it on the session
    615 		if err != nil {
    616 			prefix := util.VerificationPrefix(h.config.Community.Name)
    617 			verificationCode = fmt.Sprintf("%s%06d\n", prefix, crypto.GenerateVerificationCode())
    618 			err = h.session.SaveVerificationCode(req, res, verificationCode)
    619 			if err != nil {
    620 				renderErr("Had troubles setting the verification code on session")
    621 				return
    622 			}
    623 		}
    624 		h.renderView(res, "register", TemplateData{Data: RegisterData{verificationCode, "", rules, verification, conduct}})
    625 	case "POST":
    626 		verificationCode, err = h.session.GetVerificationCode(req)
    627 		if err != nil {
    628 			renderErr("There was no verification record for this browser session; missing data to compare against verification link content")
    629 			return
    630 		}
    631 		username := req.PostFormValue("username")
    632 		password := req.PostFormValue("password")
    633 		var verificationLink string
    634 		// skip verification code during dev registering
    635 		if !developing {
    636 			// read verification code from form
    637 			verificationLink = req.PostFormValue("verificationlink")
    638 			// fmt.Printf("user: %s, verilink: %s\n", username, verificationLink)
    639 			u, err := url.Parse(verificationLink)
    640 			if err != nil {
    641 				renderErr("Had troubles parsing the verification link, are you sure it was a proper url?")
    642 				return
    643 			}
    644 			// check verification link domain against allowlist
    645 			if !util.Contains(h.allowlist, u.Host) {
    646 				fmt.Println(h.allowlist, u.Host, util.Contains(h.allowlist, u.Host))
    647 				renderErr("Verification link's host (%s) is not in the allowlist", u.Host)
    648 				return
    649 			}
    650 
    651 			// parse out verification code from verification link and compare against verification code in session
    652 			has := hasVerificationCode(verificationLink, verificationCode)
    653 			if !has {
    654 				if !developing {
    655 					renderErr("Verification code from link (%s) does not match", verificationLink)
    656 					return
    657 				}
    658 			}
    659 		}
    660 		// make sure username is not registered already
    661 		var exists bool
    662 		if exists, err = h.db.CheckUsernameExists(username); err != nil {
    663 			renderErr("Database had a problem when checking username")
    664 			return
    665 		} else if exists {
    666 			renderErr("Username %s appears to already exist, please pick another name", username)
    667 			return
    668 		}
    669 		var hash string
    670 		if hash, err = crypto.HashPassword(password); err != nil {
    671 			fmt.Println(ed.Eout(err, "hash password"))
    672 			renderErr("Database had a problem when hashing password")
    673 			return
    674 		}
    675 		var userID int
    676 		if userID, err = h.db.CreateUser(username, hash); err != nil {
    677 			renderErr("Error in db when creating user")
    678 			return
    679 		}
    680 		// log the new user in
    681 		h.session.Save(req, res, userID)
    682 		// log where the registration is coming from, in the case of indirect invites && for curiosity
    683 		err = h.db.AddRegistration(userID, verificationLink)
    684 		if err = ed.Eout(err, "add registration"); err != nil {
    685 			dump(err)
    686 		}
    687 		h.renderView(res, "register-success", TemplateData{HasRSS: h.config.RSS.URL != "", LoggedIn: true, Title: h.translator.Translate("RegisterSuccess")})
    688 	default:
    689 		fmt.Println("non get/post method, redirecting to index")
    690 		IndexRedirect(res, req)
    691 	}
    692 }
    693 
    694 // purely an example route; intentionally unused :)
    695 func (h RequestHandler) GenericRoute(res http.ResponseWriter, req *http.Request) {
    696 	data := GenericMessageData{
    697 		Title:       "GenericTitle",
    698 		Message:     "Generic message",
    699 		Link:        "/",
    700 		LinkMessage: "Generic link messsage",
    701 		LinkText:    "with link",
    702 	}
    703 	h.renderGenericMessage(res, req, data)
    704 }
    705 
    706 func (h RequestHandler) AboutRoute(res http.ResponseWriter, req *http.Request) {
    707 	loggedIn, _ := h.IsLoggedIn(req)
    708 	input := util.Markup(string(h.files["about"]))
    709 	h.renderView(res, "about-template", TemplateData{Data: input, HasRSS: h.config.RSS.URL != "", LoggedIn: loggedIn, Title: h.translator.Translate("About")})
    710 }
    711 
    712 func (h RequestHandler) RobotsRoute(res http.ResponseWriter, req *http.Request) {
    713 	fmt.Fprintln(res, "User-agent: *\nDisallow: /")
    714 }
    715 
    716 func (h *RequestHandler) NewThreadRoute(res http.ResponseWriter, req *http.Request) {
    717 	loggedIn, userid := h.IsLoggedIn(req)
    718 	switch req.Method {
    719 	// Handle GET (=> want to start a new thread)
    720 	case "GET":
    721 		// TODO (2022-09-20): translate
    722 		if !loggedIn {
    723 			title := h.translator.Translate("NotLoggedIn")
    724 			data := GenericMessageData{
    725 				Title:       title,
    726 				Message:     h.translator.Translate("NewThreadMessage"),
    727 				Link:        "/login",
    728 				LinkMessage: h.translator.Translate("NewThreadLinkMessage"),
    729 				LinkText:    h.translator.Translate("LogIn"),
    730 			}
    731 			h.renderGenericMessage(res, req, data)
    732 			return
    733 		}
    734 		h.renderView(res, "new-thread", TemplateData{
    735 			HasRSS: h.config.RSS.URL != "", LoggedIn: loggedIn, Title: h.translator.Translate("ThreadNew")})
    736 	case "POST":
    737 		// Handle POST (=>
    738 		title := req.PostFormValue("title")
    739 		content := req.PostFormValue("content")
    740 		isPrivate := req.PostFormValue("isPrivate") == "1"
    741 
    742 		// TODO (2022-01-10): unstub topicid, once we have other topics :)
    743 		// the new thread was created: forward info to database
    744 		threadid, err := h.db.CreateThread(title, content, userid, 1, isPrivate)
    745 		if err != nil {
    746 			data := GenericMessageData{
    747 				Title:   h.translator.Translate("NewThreadCreateError"),
    748 				Message: h.translator.Translate("NewThreadCreateErrorMessage"),
    749 			}
    750 			h.renderGenericMessage(res, req, data)
    751 			return
    752 		}
    753 		// update the rss feed
    754 		h.rssFeed = GenerateRSS(h.db, h.config)
    755 		// when data has been stored => redirect to thread
    756 		slug := fmt.Sprintf("thread/%d/%s/", threadid, util.SanitizeURL(title))
    757 		http.Redirect(res, req, "/"+slug, http.StatusSeeOther)
    758 	default:
    759 		fmt.Println("non get/post method, redirecting to index")
    760 		IndexRedirect(res, req)
    761 	}
    762 }
    763 
    764 func (h *RequestHandler) DeletePostRoute(res http.ResponseWriter, req *http.Request) {
    765 	if req.Method == "GET" {
    766 		IndexRedirect(res, req)
    767 		return
    768 	}
    769 	threadURL := req.PostFormValue("thread")
    770 	postid, ok := util.GetURLPortion(req, 3)
    771 	loggedIn, userid := h.IsLoggedIn(req)
    772 
    773 	// generic error message base, with specifics being swapped out depending on the error
    774 	genericErr := GenericMessageData{
    775 		Title:       h.translator.Translate("ErrUnaccepted"),
    776 		LinkMessage: h.translator.Translate("GoBack"),
    777 		Link:        threadURL,
    778 		LinkText:    h.translator.Translate("ThreadThe"),
    779 	}
    780 
    781 	renderErr := func(msg string) {
    782 		fmt.Println(msg)
    783 		genericErr.Message = msg
    784 		h.renderGenericMessage(res, req, genericErr)
    785 	}
    786 
    787 	if !loggedIn || !ok {
    788 		renderErr("Invalid post id, or you were not allowed to delete it")
    789 		return
    790 	}
    791 
    792 	post, err := h.db.GetPost(postid)
    793 	if err != nil {
    794 		dump(err)
    795 		renderErr("The post you tried to delete was not found")
    796 		return
    797 	}
    798 
    799 	authorized := post.AuthorID == userid
    800 	switch req.Method {
    801 	case "POST":
    802 		if authorized {
    803 			err = h.db.DeletePost(postid)
    804 			if err != nil {
    805 				dump(err)
    806 				renderErr("Error happened while deleting the post")
    807 				return
    808 			}
    809 		} else {
    810 			renderErr("That's not your post to delete? Sorry buddy!")
    811 			return
    812 		}
    813 		// update the rss feed, in case the deleted post was present in feed
    814 		h.rssFeed = GenerateRSS(h.db, h.config)
    815 	}
    816 	http.Redirect(res, req, threadURL, http.StatusSeeOther)
    817 }
    818 
    819 func (h *RequestHandler) EditPostRoute(res http.ResponseWriter, req *http.Request) {
    820 	postid, ok := util.GetURLPortion(req, 3)
    821 	loggedIn, userid := h.IsLoggedIn(req)
    822 	post, err := h.db.GetPost(postid)
    823 
    824 	if !ok || errors.Is(err, sql.ErrNoRows) {
    825 		title := h.translator.Translate("ErrEdit404")
    826 		data := GenericMessageData{
    827 			Title:   title,
    828 			Message: h.translator.Translate("ErrEdit404Message"),
    829 		}
    830 		h.renderGenericMessage(res, req, data)
    831 		return
    832 	}
    833 	if !loggedIn || userid != post.AuthorID {
    834 		res.WriteHeader(401)
    835 		title := h.translator.Translate("ErrGeneric401")
    836 		data := GenericMessageData{
    837 			Title:   title,
    838 			Message: h.translator.Translate("ErrGeneric401Message"),
    839 		}
    840 		h.renderGenericMessage(res, req, data)
    841 		return
    842 	}
    843 	if req.Method == "POST" {
    844 		content := req.PostFormValue("content")
    845 		h.db.EditPost(content, postid)
    846 		post.Content = content
    847 	}
    848 	view := TemplateData{Data: post, QuickNav: loggedIn, HasRSS: h.config.RSS.URL != "", LoggedIn: loggedIn, LoggedInID: userid}
    849 	h.renderView(res, "edit-post", view)
    850 }
    851 
    852 func Serve(allowlist []string, sessionKey string, isdev bool, dir string, conf types.Config) {
    853 	port := ":8272"
    854 
    855 	if isdev {
    856 		developing = true
    857 		port = ":8277"
    858 	}
    859 
    860 	forum, err := NewServer(allowlist, sessionKey, dir, conf)
    861 	if err != nil {
    862 		util.Check(err, "instantiate CercaForum")
    863 	}
    864 
    865 	l, err := net.Listen("tcp", port)
    866 	if err != nil {
    867 		util.Check(err, "setting up tcp listener")
    868 	}
    869 	fmt.Println("Serving forum on", port)
    870 
    871 	rateLimitingInstance := NewRateLimitingWare([]string{"/rss/", "/rss.xml"})
    872 	limitingMiddleware := rateLimitingInstance.Handler(forum)
    873 	http.Serve(l, limitingMiddleware)
    874 }
    875 
    876 // CercaForum is an HTTP.ServeMux which is set up to initialize and run
    877 // a cerca-based forum. Software developers who wish to customize the
    878 // networks and security which they use to operate may wish to use this
    879 // to listen with TLS, Onion, or I2P addresses without manual setup.
    880 type CercaForum struct {
    881 	http.ServeMux
    882 	Directory string
    883 }
    884 
    885 func (u *CercaForum) directory() string {
    886 	if u.Directory == "" {
    887 		dir, err := os.Getwd()
    888 		if err != nil {
    889 			log.Fatal(err)
    890 		}
    891 		u.Directory = filepath.Join(dir, "CercaForum")
    892 	}
    893 	os.MkdirAll(u.Directory, 0755)
    894 	return u.Directory
    895 }
    896 
    897 // NewServer sets up a new CercaForum object. Always use this to initialize
    898 // new CercaForum objects. Pass the result to http.Serve() with your choice
    899 // of net.Listener.
    900 func NewServer(allowlist []string, sessionKey, dir string, config types.Config) (*CercaForum, error) {
    901 	s := &CercaForum{
    902 		ServeMux:  http.ServeMux{},
    903 		Directory: dir,
    904 	}
    905 
    906 	dbpath := filepath.Join(s.directory(), "forum.db")
    907 	db := database.InitDB(dbpath)
    908 
    909 	config.EnsureDefaultPaths()
    910 	// load the documents specified in the config
    911 	// iff document doesn't exist, dump a default document where it should be and read that
    912 	type triple struct{ key, docpath, content string }
    913 	triples := []triple{
    914 		{"about", config.Documents.AboutPath, defaults.DEFAULT_ABOUT},
    915 		{"rules", config.Documents.RegisterRulesPath, defaults.DEFAULT_RULES},
    916 		{"verification-instructions", config.Documents.VerificationExplanationPath, defaults.DEFAULT_VERIFICATION},
    917 		{"logo", config.Documents.LogoPath, defaults.DEFAULT_LOGO},
    918 	}
    919 
    920 	files := make(map[string][]byte)
    921 	for _, t := range triples {
    922 		data, err := util.LoadFile(t.key, t.docpath, t.content)
    923 		if err != nil {
    924 			return s, err
    925 		}
    926 		files[t.key] = data
    927 	}
    928 
    929 	// TODO (2022-10-20): when receiving user request, inspect user-agent language and change language from server default
    930 	// for currently translated languages, see i18n/i18n.go
    931 	translator := i18n.Init(config.Community.Language)
    932 	templates := template.Must(generateTemplates(config, translator))
    933 	feed := GenerateRSS(&db, config)
    934 	handler := RequestHandler{&db, session.New(sessionKey, developing), allowlist, files, config, translator, templates, feed}
    935 
    936 	/* note: be careful with trailing slashes; go's default handler is a bit sensitive */
    937 	// TODO (2022-01-10): introduce middleware to make sure there is never an issue with trailing slashes
    938 
    939 	// moderation and admin related routes, for contents see file server/moderation.go
    940 	s.ServeMux.HandleFunc("/reset/", handler.ResetPasswordRoute)
    941 	s.ServeMux.HandleFunc("/admin", handler.AdminRoute)
    942 	s.ServeMux.HandleFunc("/demote-admin", handler.AdminDemoteAdmin)
    943 	s.ServeMux.HandleFunc("/add-user", handler.AdminManualAddUserRoute)
    944 	s.ServeMux.HandleFunc("/moderations", handler.ModerationLogRoute)
    945 	s.ServeMux.HandleFunc("/proposal-veto", handler.VetoProposal)
    946 	s.ServeMux.HandleFunc("/proposal-confirm", handler.ConfirmProposal)
    947 	// regular ol forum routes
    948 	s.ServeMux.HandleFunc("/about", handler.AboutRoute)
    949 	s.ServeMux.HandleFunc("/logout", handler.LogoutRoute)
    950 	s.ServeMux.HandleFunc("/login", handler.LoginRoute)
    951 	s.ServeMux.HandleFunc("/register", handler.RegisterRoute)
    952 	s.ServeMux.HandleFunc("/post/delete/", handler.DeletePostRoute)
    953 	s.ServeMux.HandleFunc("/post/edit/", handler.EditPostRoute)
    954 	s.ServeMux.HandleFunc("/thread/new/", handler.NewThreadRoute)
    955 	s.ServeMux.HandleFunc("/thread/", handler.ThreadRoute)
    956 	s.ServeMux.HandleFunc("/robots.txt", handler.RobotsRoute)
    957 	s.ServeMux.HandleFunc("/", handler.IndexRoute)
    958 	s.ServeMux.HandleFunc("/rss/", handler.RSSRoute)
    959 	s.ServeMux.HandleFunc("/rss.xml", handler.RSSRoute)
    960 
    961 	fileserver := http.FileServer(http.Dir("html/assets/"))
    962 	s.ServeMux.Handle("/assets/", http.StripPrefix("/assets/", fileserver))
    963 
    964 	return s, nil
    965 }