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 }