1
2 package server
3
4 import (
5 "context"
6 "crypto/md5"
7 "crypto/sha3"
8 "crypto/sha512"
9 "crypto/tls"
10 "embed"
11 "encoding/base64"
12 "flag"
13 "fmt"
14 "hash"
15 "html/template"
16 "image"
17 "io"
18 "io/fs"
19 "log"
20 "maps"
21 "net"
22 "net/http"
23 "net/smtp"
24 "net/url"
25 "os"
26 "os/signal"
27 "path"
28 "path/filepath"
29 "regexp"
30 "runtime/debug"
31 "slices"
32 "strings"
33 "sync"
34 "time"
35
36 "codeberg.org/tslocum/sriracha"
37 "codeberg.org/tslocum/sriracha/internal/database"
38 . "codeberg.org/tslocum/sriracha/model"
39 . "codeberg.org/tslocum/sriracha/util"
40 "github.com/fsnotify/fsnotify"
41 "github.com/jackc/pgx/v5/pgxpool"
42 "github.com/leonelquinteros/gotext"
43 "github.com/r3labs/diff/v3"
44 "golang.org/x/net/http2"
45 "golang.org/x/net/http2/h2c"
46 "golang.org/x/sys/unix"
47 "golang.org/x/text/language"
48 "golang.org/x/text/language/display"
49 "golang.org/x/text/message"
50 "gopkg.in/yaml.v3"
51 )
52
53
54
55 var SrirachaVersion = "DEV"
56
57
58 var localeFS embed.FS
59
60
61 const (
62 defaultServerSiteName = "Sriracha"
63 defaultServerSiteHome = "/"
64 defaultServerOekakiWidth = 540
65 defaultServerOekakiHeight = 540
66 defaultServerRefresh = 30
67 )
68
69
70 var defaultServerEmbeds = [][2]string{
71 {"YouTube", "https://youtube.com/oembed?format=json&url=SRIRACHA_EMBED"},
72 {"Vimeo", "https://vimeo.com/api/oembed.json?url=SRIRACHA_EMBED"},
73 {"SoundCloud", "https://soundcloud.com/oembed?format=json&url=SRIRACHA_EMBED"},
74 }
75
76
77 const (
78 bannerOverboard = -1
79 bannerNews = -2
80 bannerPages = -3
81 )
82
83
84 type NewsOption int
85
86
87 const (
88 NewsDisable NewsOption = 0
89 NewsWriteToNews NewsOption = 1
90 NewsWriteToIndex NewsOption = 2
91 )
92
93 type categoryInfo struct {
94 Name string
95 Description string
96 Boards []*Board
97 Recent []*Post
98 }
99
100 type HashAlgorithm int8
101
102 const (
103 AlgorithmSHA2 HashAlgorithm = 0
104 AlgorithmSHA3 HashAlgorithm = 1
105 )
106
107 const HashSize = 48
108
109
110 type ServerOptions struct {
111 SiteName string
112 SiteHome string
113 SiteIndex bool
114 News NewsOption
115 BoardIndex bool
116 CAPTCHA bool
117 Refresh int
118 Uploads []*UploadType
119 Embeds [][2]string
120 OekakiWidth int
121 OekakiHeight int
122 Overboard string
123 OverboardType BoardType
124 OverboardThreads int
125 OverboardReplies int
126 Identifiers bool
127 Locale string
128 Locales map[string]string
129 LocalesSorted []string
130 Algorithm HashAlgorithm
131 Access map[string]string
132 Banners map[int][]*Banner
133 Rules map[int][]template.HTML
134 Categories []*categoryInfo
135 Notifications bool
136 DevMode bool
137 FuncMaps map[string]template.FuncMap
138 }
139
140
141 func (opt *ServerOptions) DefaultLocaleName() string {
142 if opt.Locale == "" || opt.Locale == "en" {
143 return "English"
144 }
145 name := opt.Locales[opt.Locale]
146 if name != "" {
147 return name
148 }
149 return opt.Locale
150 }
151
152 type cachedKeyword struct {
153 id int
154 p *regexp.Regexp
155 a string
156 }
157
158
159 type rebuildInfo struct {
160 post *Post
161 wg *sync.WaitGroup
162 }
163
164 const entriesPerPage = 25
165
166
167 type Server struct {
168 Boards []*Board
169
170 rangeBans map[*Ban]*regexp.Regexp
171
172 keywordCache map[int][]*cachedKeyword
173
174 config *Config
175 dbPool *pgxpool.Pool
176 opt ServerOptions
177
178 importDatabases []*importInfo
179
180 tpl *template.Template
181 original *template.Template
182 customTemplates []string
183
184 notifications []notification
185 notificationsPattern *regexp.Regexp
186 notificationsWaitGroup sync.WaitGroup
187 shutdownNotifications chan struct{}
188
189 rebuildQueue chan *rebuildInfo
190 rebuildWaitGroup sync.WaitGroup
191 rebuildLock sync.Mutex
192
193 httpClient *http.Client
194
195 httpServer *http.Server
196 httpsServer *http.Server
197 httpsCert *tls.Certificate
198
199 httpMaxRequestSize int64
200
201 msgPrinter *message.Printer
202
203 lock sync.Mutex
204 }
205
206
207 func NewServer() *Server {
208 httpClient := &http.Client{
209 Timeout: 15 * time.Second,
210 }
211 return &Server{
212 keywordCache: make(map[int][]*cachedKeyword),
213 opt: ServerOptions{
214 Banners: make(map[int][]*Banner),
215 Rules: make(map[int][]template.HTML),
216 },
217 shutdownNotifications: make(chan struct{}),
218 rebuildQueue: make(chan *rebuildInfo),
219 httpClient: httpClient,
220 msgPrinter: message.NewPrinter(language.English),
221 }
222 }
223
224
225
226 func (s *Server) parseBuildInfo() {
227 if SrirachaVersion == "" {
228 SrirachaVersion = "DEV"
229 } else if SrirachaVersion != "DEV" {
230 return
231 }
232 info, ok := debug.ReadBuildInfo()
233 if !ok {
234 return
235 }
236 buildTag := info.Main.Version
237 if buildTag != "" && buildTag[0] == 'v' {
238 SrirachaVersion = buildTag[1:]
239 firstHyphen := strings.IndexRune(SrirachaVersion, '-')
240 firstPlus := strings.IndexRune(SrirachaVersion, '+')
241 if firstHyphen == -1 && firstPlus == -1 {
242 return
243 }
244 if firstHyphen != -1 {
245 SrirachaVersion = SrirachaVersion[:firstHyphen]
246 firstPlus = strings.IndexRune(SrirachaVersion, '+')
247 }
248 if firstPlus != -1 {
249 SrirachaVersion = SrirachaVersion[:firstPlus]
250 }
251 SrirachaVersion += "-DEV"
252 }
253 for _, setting := range info.Settings {
254 if setting.Key == "vcs.revision" {
255 revision := setting.Value
256 if len(revision) > 10 {
257 revision = revision[:10]
258 }
259 SrirachaVersion += "-" + revision
260 return
261 }
262 }
263 }
264
265
266
267 func (s *Server) forbidden(w http.ResponseWriter, data *templateData, action string) bool {
268 var required AccountRole
269 switch s.config.Access[action] {
270 case "mod":
271 required = RoleMod
272 case "admin":
273 required = RoleAdmin
274 case "super-admin":
275 required = RoleSuperAdmin
276 }
277 return data.forbidden(w, required)
278 }
279
280
281 func (s *Server) parseConfig(configFile string) error {
282 buf, err := os.ReadFile(configFile)
283 if err != nil {
284 return err
285 }
286
287 config := &Config{
288 Access: make(map[string]string),
289 }
290 err = yaml.Unmarshal(buf, config)
291 if err != nil {
292 return err
293 }
294
295
296 if config.HTTP == "" && config.Serve != "" {
297 config.HTTP = config.Serve
298 }
299
300 switch {
301 case config.Root == "":
302 return fmt.Errorf("root (lowercase!) must be set in %s to the root directory (where board files are written)", configFile)
303 case config.HTTP == "":
304 return fmt.Errorf("http (lowercase!) must be set in %s to the HTTP server listen address (hostname:port)", configFile)
305 case config.SaltData == "":
306 return fmt.Errorf("saltdata (lowercase!) must be set in %s to the one-way secure data hashing salt (a long string of random data which, once set, never changes)", configFile)
307 case config.SaltPass == "":
308 return fmt.Errorf("saltpass (lowercase!) must be set in %s to the two-way secure data hashing salt (a long string of random data which, once set, never changes)", configFile)
309 case config.SaltTrip == "":
310 return fmt.Errorf("salttrip (lowercase!) must be set in %s to the secure tripcode generation salt (a long string of random data which, once set, never changes)", configFile)
311 }
312
313 if config.DBURL == "" {
314 switch {
315 case config.Address == "":
316 return fmt.Errorf("address (lowercase!) must be set in %s to the database address (hostname:port)", configFile)
317 case config.Username == "":
318 return fmt.Errorf("username (lowercase!) must be set in %s to the database username", configFile)
319 case config.Password == "":
320 return fmt.Errorf("password (lowercase!) must be set in %s to the database password", configFile)
321 case config.DBName == "":
322 return fmt.Errorf("dbname (lowercase!) must be set in %s to the database name", configFile)
323 }
324 }
325
326 if config.HTTPS != "" {
327 switch {
328 case config.HTTPSCert == "":
329 return fmt.Errorf("to serve HTTPS connections, httpscert (lowercase!) must be set in %s to a certificate file path", configFile)
330 case config.HTTPSKey == "":
331 return fmt.Errorf("to serve HTTPS connections, httpskey (lowercase!) must be set in %s to a private key file path", configFile)
332 }
333 }
334
335 if config.Algorithm == "" {
336 config.Algorithm = "sha-2"
337 }
338 switch config.Algorithm {
339 case "sha-2":
340 s.opt.Algorithm = AlgorithmSHA2
341 case "sha-3":
342 s.opt.Algorithm = AlgorithmSHA3
343 default:
344 return fmt.Errorf("algorithm must be set to sha-3 or sha-2")
345 }
346
347 if config.Locale == "" {
348 config.Locale = "en"
349 }
350
351 if config.MailFrom != "" && ParseEmail(config.MailFrom) == "" {
352 return fmt.Errorf("mailfrom is not a valid email address: %s", config.MailFrom)
353 } else if config.MailReplyTo != "" && ParseEmail(config.MailReplyTo) == "" {
354 return fmt.Errorf("mailreplyto is not a valid email address: %s", config.MailReplyTo)
355 }
356
357 if config.Mentions <= 0 {
358 config.Mentions = 60
359 }
360 if config.Notifications <= 0 {
361 config.Notifications = 1440
362 }
363
364 defaultAccess := map[string]string{
365 "ban.add": "mod",
366 "ban.shorten": "admin",
367 "ban.lengthen": "mod",
368 "ban.delete": "admin",
369 "banfile.add": "mod",
370 "banfile.delete": "admin",
371 "banner.add": "admin",
372 "banner.update": "admin",
373 "banner.delete": "super-admin",
374 "board.add": "admin",
375 "board.update": "admin",
376 "board.delete": "super-admin",
377 "category.add": "admin",
378 "category.update": "admin",
379 "category.delete": "super-admin",
380 "keyword.add": "admin",
381 "keyword.update": "admin",
382 "keyword.delete": "admin",
383 "page.add": "admin",
384 "page.update": "admin",
385 "page.delete": "admin",
386 "post.sticky": "mod",
387 "post.lock": "mod",
388 "post.move": "mod",
389 "post.delete": "mod",
390 }
391 validateAccess := func(name string, v string) error {
392 if _, ok := defaultAccess[name]; !ok && name != "default" {
393 return fmt.Errorf("access configuration contains unrecognized action %s", name)
394 }
395 switch v {
396 case "mod", "admin", "super-admin", "disable":
397 return nil
398 default:
399 return fmt.Errorf("action %s has unknown access level %s: must be 'mod', 'admin', 'super-admin' or 'disable'", name, v)
400 }
401 }
402 var defaultRequirement string
403 for name, v := range config.Access {
404 err = validateAccess(name, v)
405 if err != nil {
406 return fmt.Errorf("access configuration is invalid: %s", err)
407 } else if name == "default" {
408 defaultRequirement = v
409 delete(config.Access, name)
410 }
411 }
412 for name, v := range defaultAccess {
413 if config.Access[name] != "" {
414 continue
415 } else if defaultRequirement != "" {
416 config.Access[name] = defaultRequirement
417 continue
418 }
419 config.Access[name] = v
420 }
421
422 s.config = config
423
424 if s.config.MailDomains != "" {
425 s.notificationsPattern, err = regexp.Compile(s.config.MailDomains)
426 if err != nil {
427 return fmt.Errorf("failed to parse maildomains regular expression: %s", err)
428 }
429 }
430 return nil
431 }
432
433
434 func (s *Server) parseLocales() error {
435 return fs.WalkDir(localeFS, "locale", func(p string, d fs.DirEntry, err error) error {
436 if err != nil {
437 return err
438 } else if d.IsDir() || !strings.HasSuffix(p, ".po") {
439 return nil
440 }
441 id := filepath.Base(strings.TrimSuffix(p, ".po"))
442
443 buf, err := localeFS.ReadFile(fmt.Sprintf("locale/%s/%s.po", id, id))
444 if err != nil {
445 return fmt.Errorf("failed to load locale %s: %s", id, err)
446 }
447
448 po := gotext.NewPo()
449 po.Parse(buf)
450 gotext.GetStorage().AddTranslator(fmt.Sprintf("sriracha-%s", id), po)
451 return nil
452 })
453 }
454
455
456 func (s *Server) connectToMailServer() (*smtp.Client, error) {
457 if s.config.MailAddress == "" {
458 return nil, nil
459 }
460
461
462 address := s.config.MailAddress
463 hostname, _, err := net.SplitHostPort(s.config.MailAddress)
464 if err != nil {
465 hostname = s.config.MailAddress
466 if strings.ContainsRune(s.config.MailAddress, ':') {
467 address = fmt.Sprintf("[%s]:25", address)
468 } else {
469 address = address + ":25"
470 }
471 }
472 tlsConfig := &tls.Config{
473 InsecureSkipVerify: s.config.MailInsecure,
474 ServerName: hostname,
475 }
476
477
478 var conn net.Conn
479 if s.config.MailTLS {
480 conn, err = tls.Dial("tcp", address, tlsConfig)
481 if err != nil {
482 log.Fatalf("failed to connect to SMTP server with TLS enabled at %s: %s", address, err)
483 }
484 } else {
485 conn, err = net.Dial("tcp", address)
486 if err != nil {
487 log.Fatalf("failed to connect to SMTP server without TLS enabled at %s: %s", address, err)
488 }
489 }
490
491
492 client, err := smtp.NewClient(conn, hostname)
493 if err != nil {
494 conn.Close()
495 return nil, fmt.Errorf("failed to initialize SMTP client: %s", err)
496 }
497
498
499 if !s.config.MailTLS {
500 ok, _ := client.Extension("STARTTLS")
501 if ok {
502 err = client.StartTLS(tlsConfig)
503 if err != nil {
504 client.Close()
505 return nil, fmt.Errorf("failed to upgrade plain text connection to TLS even though support for it was advertised")
506 }
507 }
508 }
509
510
511 var auth smtp.Auth
512 switch s.config.MailAuth {
513 case "challenge":
514 auth = smtp.CRAMMD5Auth(s.config.MailUsername, s.config.MailPassword)
515 case "plain":
516 auth = smtp.PlainAuth("", s.config.MailUsername, s.config.MailPassword, hostname)
517 case "", "none":
518
519 default:
520 client.Close()
521 return nil, fmt.Errorf("unrecognized mailauth configuration value %s: must be challenge / plain / none", s.config.MailAuth)
522 }
523 if auth != nil {
524 err := client.Auth(auth)
525 if err != nil {
526 client.Close()
527 return nil, fmt.Errorf("failed to authenticate with SMTP server: %s", err)
528 }
529 }
530
531
532 if err = client.Noop(); err != nil {
533 client.Close()
534 return nil, fmt.Errorf("failed to verify SMTP server connection by sending NOOP command: %s", err)
535 }
536 return client, nil
537 }
538
539
540 func (s *Server) sendMail(client *smtp.Client, recipient string, subject string, message string) error {
541
542 var body []byte
543 if s.config.MailFrom != "" {
544 body = fmt.Appendf(body, "From: %s\n", s.config.MailFrom)
545 }
546 body = fmt.Appendf(body, "To: %s\nSubject: %s\n", recipient, subject)
547 if s.config.MailReplyTo != "" {
548 body = fmt.Appendf(body, "Reply-To: %s\n", s.config.MailReplyTo)
549 }
550 body = fmt.Appendf(body, "\n%s", message)
551
552
553 if err := client.Reset(); err != nil {
554 return fmt.Errorf("failed to reset state: %s", err)
555 }
556
557
558 if s.config.MailFrom != "" {
559 if err := client.Mail(s.config.MailFrom); err != nil {
560 return fmt.Errorf("failed to set from address: %s", err)
561 }
562 }
563 if err := client.Rcpt(recipient); err != nil {
564 return fmt.Errorf("failed to set recipient address: %s", err)
565 }
566
567
568 wc, err := client.Data()
569 if err != nil {
570 return fmt.Errorf("failed to send DATA command: %s", err)
571 }
572
573
574 _, err = wc.Write(body)
575 if err != nil {
576 return fmt.Errorf("failed to write email body: %s", err)
577 }
578
579
580 err = wc.Close()
581 if err != nil {
582 return fmt.Errorf("failed to write email body: %s", err)
583 }
584 return nil
585 }
586
587
588 func (s *Server) begin() *database.DB {
589 return database.Begin(s.dbPool, s.config)
590 }
591
592
593 func (s *Server) loadServerConfig() error {
594 db := s.begin()
595 defer db.Commit()
596
597 siteName := db.GetString("sitename")
598 if siteName == "" {
599 siteName = defaultServerSiteName
600 }
601 s.opt.SiteName = siteName
602
603 siteHome := db.GetString("sitehome")
604 if siteHome == "" {
605 siteHome = defaultServerSiteHome
606 }
607 s.opt.SiteHome = siteHome
608
609 siteIndex := db.GetString("siteindex")
610 s.opt.SiteIndex = siteIndex == "" || siteIndex == "1"
611
612 news := NewsOption(db.GetInt("news"))
613 if news == NewsDisable || news == NewsWriteToNews || news == NewsWriteToIndex {
614 s.opt.News = news
615 }
616
617 boardIndex := db.GetString("boardindex")
618 s.opt.BoardIndex = boardIndex == "" || boardIndex == "1"
619
620 s.opt.CAPTCHA = db.GetBool("captcha")
621
622 oekakiWidth := db.GetInt("oekakiwidth")
623 if oekakiWidth == 0 {
624 oekakiWidth = defaultServerOekakiWidth
625 }
626 s.opt.OekakiWidth = oekakiWidth
627
628 oekakiHeight := db.GetInt("oekakiheight")
629 if oekakiHeight == 0 {
630 oekakiHeight = defaultServerOekakiHeight
631 }
632 s.opt.OekakiHeight = oekakiHeight
633
634 if !db.HaveConfig("refresh") {
635 s.opt.Refresh = defaultServerRefresh
636 } else {
637 s.opt.Refresh = db.GetInt("refresh")
638 }
639
640 s.opt.Overboard = db.GetString("overboard")
641 s.opt.OverboardType = BoardType(db.GetInt("overboardtype"))
642 s.opt.OverboardThreads = db.GetInt("overboardthreads")
643 s.opt.OverboardReplies = db.GetInt("overboardreplies")
644
645 s.opt.Uploads = s.config.UploadTypes()
646
647 s.opt.Embeds = nil
648 if !db.HaveConfig("embeds") {
649 s.opt.Embeds = append(s.opt.Embeds, defaultServerEmbeds...)
650 } else {
651 embeds := db.GetMultiString("embeds")
652 for _, v := range embeds {
653 split := strings.SplitN(v, " ", 2)
654 if len(split) != 2 {
655 continue
656 }
657 s.opt.Embeds = append(s.opt.Embeds, [2]string{split[0], split[1]})
658 }
659 }
660 db.ClearBoardCache()
661 s.removeInvalidBoardOptions(db)
662
663 s.reloadBans(db)
664
665 s.opt.Identifiers = s.config.Identifiers
666
667 s.opt.Locale = s.config.Locale
668
669 s.opt.Locales = make(map[string]string)
670 english := display.English.Languages()
671 fs.WalkDir(localeFS, "locale", func(p string, d fs.DirEntry, err error) error {
672 if err != nil {
673 return err
674 } else if d.IsDir() || !strings.HasSuffix(p, ".po") {
675 return nil
676 }
677 id := filepath.Base(strings.TrimSuffix(p, ".po"))
678
679 name := id
680 tag, err := language.Parse(id)
681 if err == nil {
682 tagName := english.Name(tag)
683 if tagName != "" {
684 name = tagName
685 }
686
687 if s.opt.Locale == id {
688 s.msgPrinter = message.NewPrinter(tag)
689 }
690 }
691
692 s.opt.Locales[id] = name
693 return nil
694 })
695 s.opt.Locales["en@pirate"] = "Pirate English"
696 if s.opt.Locale != "" && s.opt.Locale != "en" {
697 s.opt.Locales["en"] = "English"
698 }
699 s.opt.LocalesSorted = slices.SortedFunc(maps.Keys(s.opt.Locales), func(s1, s2 string) int {
700 return strings.Compare(s.opt.Locales[s1], s.opt.Locales[s2])
701 })
702
703 templateFuncMaps = make(map[string]template.FuncMap)
704 templateFuncMaps[""] = newTemplateFuncMap(s.opt.Locale)
705 for id := range s.opt.Locales {
706 templateFuncMaps[id] = newTemplateFuncMap(id)
707 }
708
709 s.opt.Access = make(map[string]string)
710 maps.Copy(s.opt.Access, s.config.Access)
711 return nil
712 }
713
714
715 func (s *Server) setDefaultPluginConfig() error {
716 db := s.begin()
717 defer db.Commit()
718
719 for i, info := range allPluginInfo {
720 db.Plugin = info.Name
721
722 for i, config := range info.Config {
723 if !db.HaveConfig(config.Name) {
724 db.SaveString(config.Name, config.Value)
725 } else {
726 info.Config[i].Value = db.GetString(config.Name)
727 }
728 }
729
730 p := allPlugins[i]
731 pUpdate, ok := p.(sriracha.PluginWithUpdate)
732 if ok {
733 for _, config := range info.Config {
734 pUpdate.Update(db, config.Name)
735 }
736 }
737 }
738 return nil
739 }
740
741
742 func (s *Server) loadPluginConfig() error {
743 db := s.begin()
744 defer db.Commit()
745
746 for _, info := range allPluginInfo {
747 db.Plugin = info.Name
748 for i, c := range info.Config {
749 v := db.GetString(strings.ToLower(info.Name + "." + c.Name))
750 if v != "" {
751 info.Config[i].Value = v
752 }
753 }
754 }
755 db.Plugin = ""
756 return nil
757 }
758
759
760 func (s *Server) officialTemplateDir() string {
761 officialDir := "internal/server/template"
762 _, err := os.Stat(officialDir)
763 if !os.IsNotExist(err) {
764 return officialDir
765 }
766 officialDir = "template"
767 _, err = os.Stat(officialDir)
768 if !os.IsNotExist(err) {
769 return officialDir
770 }
771 return ""
772 }
773
774
775
776 func (s *Server) validateTemplateConfig(officialDir string) error {
777 if s.config.Template == "" {
778 return nil
779 }
780 _, err := os.Stat(s.config.Template)
781 if os.IsNotExist(err) {
782 return fmt.Errorf("custom template directory %s does not exist", s.config.Template)
783 }
784 officialTemplate, err := os.Stat(officialDir)
785 if err != nil {
786 return fmt.Errorf("failed to locate official template directory: start sriracha in the same directory as the file README.md")
787 }
788 customTemplate, err := os.Stat(s.config.Template)
789 if err != nil {
790 return fmt.Errorf("custom template directory %s is inaccessible", s.config.Template)
791 }
792 if os.SameFile(officialTemplate, customTemplate) {
793 return fmt.Errorf("official templates and custom templates must be located in separate directories")
794 }
795 return nil
796 }
797
798
799
800
801
802 func (s *Server) parseTemplates(officialDir string, customDir string) error {
803 s.customTemplates = s.customTemplates[:0]
804 wrapError := func(name string, err error) error {
805 var source string
806 if !slices.Contains(s.customTemplates, name) {
807 source = "official"
808 } else {
809 source = "custom"
810 }
811 return fmt.Errorf("failed to parse %s template file %s: %s", source, name, err)
812 }
813 parseDir := func(dir string, custom bool) error {
814 entries, err := os.ReadDir(dir)
815 if err != nil {
816 return err
817 }
818 for _, f := range entries {
819 if !strings.HasSuffix(f.Name(), ".gohtml") {
820 continue
821 } else if custom {
822 s.customTemplates = append(s.customTemplates, f.Name())
823 }
824
825 buf, err := os.ReadFile(filepath.Join(dir, f.Name()))
826 if err != nil {
827 return wrapError(f.Name(), err)
828 }
829
830 _, err = s.tpl.New(f.Name()).Parse(string(buf))
831 if err != nil {
832 return wrapError(f.Name(), err)
833 }
834 }
835 return nil
836 }
837 if officialDir == "" {
838 s.tpl = template.New("sriracha").Funcs(templateFuncMaps[""])
839
840 entries, err := templateFS.ReadDir("template")
841 if err != nil {
842 return err
843 }
844 for _, f := range entries {
845 if !strings.HasSuffix(f.Name(), ".gohtml") {
846 continue
847 }
848
849 buf, err := templateFS.ReadFile(filepath.Join("template", f.Name()))
850 if err != nil {
851 return wrapError(f.Name(), err)
852 }
853
854 _, err = s.tpl.New(f.Name()).Parse(string(buf))
855 if err != nil {
856 return wrapError(f.Name(), err)
857 }
858 }
859 } else {
860 s.tpl = template.New("sriracha").Funcs(templateFuncMaps[""])
861
862 err := parseDir(officialDir, false)
863 if err != nil {
864 return err
865 }
866 }
867
868 if customDir != "" {
869 err := parseDir(customDir, true)
870 if err != nil {
871 return err
872 }
873 }
874
875 var err error
876 s.original, err = s.tpl.Clone()
877 return err
878 }
879
880 func (s *Server) _watchTemplates(officialDir string, watcher *fsnotify.Watcher) {
881 for {
882 select {
883 case event, ok := <-watcher.Events:
884 if !ok {
885 return
886 } else if !event.Has(fsnotify.Create) && !event.Has(fsnotify.Write) && !event.Has(fsnotify.Remove) && !event.Has(fsnotify.Rename) {
887 continue
888 }
889 err := s.parseTemplates(officialDir, s.config.Template)
890 if err != nil {
891 log.Printf("failed to parse template files: %s", err)
892 }
893 case err, ok := <-watcher.Errors:
894 if !ok {
895 return
896 }
897 log.Printf("fsnotify error: %s", err)
898 }
899 }
900 }
901
902
903 func (s *Server) watchTemplates(officialDir string) error {
904 watcher, err := fsnotify.NewWatcher()
905 if err != nil {
906 log.Fatal(err)
907 }
908 go s._watchTemplates(officialDir, watcher)
909
910 err = watcher.Add(officialDir)
911 if err == nil && s.config.Template != "" {
912 err = watcher.Add(s.config.Template)
913 }
914 return err
915 }
916
917
918 func (s *Server) log(db *database.DB, account *Account, board *Board, action string, info string) {
919 user := "system"
920 if account != nil && account.ID != 0 {
921 if account.Role == RoleSuperAdmin || account.Role == RoleAdmin {
922 user = "admin"
923 } else {
924 user = "mod"
925 }
926 }
927 for _, handlerInfo := range allPluginAuditHandlers {
928 db.Plugin = handlerInfo.Name
929 err := handlerInfo.Handler(db, user, action, info)
930 if err != nil {
931 log.Fatalf("plugin %s failed to process audit event: %s", handlerInfo.Name, err)
932 }
933 }
934 db.Plugin = ""
935
936 db.AddLog(&Log{
937 Account: account,
938 Board: board,
939 Message: action,
940 Changes: info,
941 })
942 }
943
944
945 func (s *Server) refreshBannerCache(db *database.DB) {
946 banners := s.opt.Banners
947 for id := range banners {
948 banners[id] = banners[id][:0]
949 }
950
951 for _, banner := range db.AllBanners() {
952 for _, board := range banner.Boards {
953 banners[board.ID] = append(banners[board.ID], banner)
954 }
955 if banner.Overboard {
956 banners[bannerOverboard] = append(banners[bannerOverboard], banner)
957 }
958 if banner.News {
959 banners[bannerNews] = append(banners[bannerNews], banner)
960 }
961 if banner.Pages {
962 banners[bannerPages] = append(banners[bannerPages], banner)
963 }
964 }
965
966 for id := range banners {
967 if len(banners[id]) == 0 {
968 delete(banners, id)
969 }
970 }
971 }
972
973
974 func (s *Server) refreshMaxRequestSize(db *database.DB) {
975 const megabyte = 1048576
976 var messageSize int64
977 var fileSize int64
978 for _, b := range db.AllBoards() {
979 msg := int64(b.MaxMessage)
980 if msg <= 0 {
981 msg = megabyte
982 }
983 if msg > messageSize {
984 messageSize = msg
985 }
986 if b.Files <= 0 {
987 continue
988 }
989 files := int64(b.Files)
990 if b.MaxSizeThread*files > fileSize {
991 fileSize = b.MaxSizeThread * files
992 }
993 if b.MaxSizeReply*files > fileSize {
994 fileSize = b.MaxSizeReply * files
995 }
996 }
997 s.httpMaxRequestSize = 10*megabyte + messageSize + fileSize
998 }
999
1000
1001 func (s *Server) refreshRulesCache(db *database.DB) {
1002 rules := s.opt.Rules
1003 for id := range rules {
1004 rules[id] = rules[id][:0]
1005 }
1006
1007 for _, board := range db.AllBoards() {
1008 for _, info := range allPluginRulesHandlers {
1009 rulesHTML, err := info.Handler(db, board)
1010 if err != nil {
1011 log.Fatalf("failed to refresh rules cache: plugin %s encountered an error: %s", info.Name, err)
1012 }
1013 rulesText := strings.TrimSpace(string(rulesHTML))
1014 if rulesText == "" {
1015 continue
1016 }
1017 for _, rulesLine := range strings.Split(rulesText, "\n") {
1018 if rulesLine == "" {
1019 continue
1020 }
1021 rules[board.ID] = append(rules[board.ID], template.HTML(rulesLine))
1022 }
1023 }
1024 }
1025
1026 for id := range rules {
1027 if len(rules[id]) == 0 {
1028 delete(rules, id)
1029 }
1030 }
1031 }
1032
1033
1034 func (s *Server) refreshKeywordCache(db *database.DB) {
1035 for boardID := range s.keywordCache {
1036 s.keywordCache[boardID] = s.keywordCache[boardID][:0]
1037 }
1038
1039 for _, k := range db.AllKeywords() {
1040 var err error
1041 kw := &cachedKeyword{
1042 id: k.ID,
1043 a: k.Action,
1044 }
1045 kw.p, err = regexp.Compile(k.Text)
1046 if err != nil {
1047 log.Fatalf("failed to parse keyword %s as regular expression: %s", k.Text, err)
1048 }
1049 for _, board := range k.Boards {
1050 s.keywordCache[board.ID] = append(s.keywordCache[board.ID], kw)
1051 }
1052 }
1053
1054 for boardID := range s.keywordCache {
1055 if len(s.keywordCache[boardID]) == 0 {
1056 delete(s.keywordCache, boardID)
1057 }
1058 }
1059 }
1060
1061 func (s *Server) _processCategory(c *Category) {
1062 for _, cat := range c.Categories {
1063 s._processCategory(cat)
1064 }
1065 if len(c.Boards) == 0 {
1066 return
1067 }
1068 info := &categoryInfo{
1069 Name: c.Name,
1070 Description: c.Description,
1071 Boards: c.Boards,
1072 }
1073 s.opt.Categories = append(s.opt.Categories, info)
1074 }
1075
1076
1077 func (s *Server) refreshCategoryCache(db *database.DB) {
1078 s.opt.Categories = s.opt.Categories[:0]
1079 for _, c := range db.AllCategories() {
1080 if c.Parent == nil {
1081 s._processCategory(c)
1082 }
1083 }
1084
1085 if len(s.opt.Categories) == 0 {
1086 info := &categoryInfo{}
1087 for _, b := range db.AllBoards() {
1088 if b.Hide == HideIndex || b.Hide == HideEverywhere {
1089 continue
1090 }
1091 info.Boards = append(info.Boards, b)
1092 }
1093 s.opt.Categories = append(s.opt.Categories, info)
1094 }
1095 }
1096
1097 func (s *Server) refreshRecentPosts(db *database.DB) {
1098 for _, info := range s.opt.Categories {
1099 info.Recent = info.Recent[:0]
1100 for _, b := range info.Boards {
1101 info.Recent = append(info.Recent, db.LastPostByBoard(b))
1102 }
1103 }
1104 }
1105
1106
1107 func (s *Server) deletePostFiles(p *Post) {
1108 if p.Board == nil {
1109 return
1110 } else if p.ID != 0 && p.Parent == 0 {
1111 os.Remove(filepath.Join(s.config.Root, p.Board.Dir, "res", fmt.Sprintf("%d.html", p.ID)))
1112 }
1113
1114 if p.File == "" {
1115 return
1116 }
1117 srcPath := filepath.Join(s.config.Root, p.Board.Dir, "src", p.File)
1118 os.Remove(srcPath)
1119
1120 if p.Thumb == "" {
1121 return
1122 }
1123 thumbPath := filepath.Join(s.config.Root, p.Board.Dir, "thumb", p.Thumb)
1124 os.Remove(thumbPath)
1125 }
1126
1127
1128 func (s *Server) deletePost(db *database.DB, p *Post) {
1129 posts := db.AllPostsInThread(p.ID, false)
1130 for _, post := range posts {
1131 s.deletePostFiles(post)
1132 }
1133
1134 db.DeletePost(p.ID)
1135 }
1136
1137
1138 func (s *Server) httpResponse(r *http.Request) (*http.Response, error) {
1139 r.Header.Set("User-Agent", "Sriracha imageboard and forum server")
1140 return s.httpClient.Do(r)
1141 }
1142
1143
1144 func (s *Server) buildData(db *database.DB, w http.ResponseWriter, r *http.Request) *templateData {
1145 if strings.HasPrefix(r.URL.Path, "/sriracha/logout") {
1146 http.SetCookie(w, &http.Cookie{
1147 Name: "sriracha_session",
1148 Value: "",
1149 Path: "/",
1150 })
1151 http.Redirect(w, r, "/sriracha/", http.StatusFound)
1152 return s.newTemplateData()
1153 }
1154
1155 if r.URL.Path == "/sriracha/" || r.URL.Path == "/sriracha" {
1156 var failedLogin bool
1157 username := r.FormValue("username")
1158 if len(username) != 0 {
1159 failedLogin = true
1160 password := r.FormValue("password")
1161 if len(password) != 0 {
1162 if !s.opt.DevMode {
1163
1164 var solved bool
1165 ipHash := s.hashIP(r)
1166 challenge := db.GetCAPTCHA(ipHash)
1167 if challenge != nil {
1168 solution := FormString(r, "captcha")
1169 if strings.ToLower(solution) == challenge.Text {
1170 solved = true
1171 db.DeleteCAPTCHA(ipHash)
1172 os.Remove(filepath.Join(s.config.Root, "captcha", challenge.Image+".png"))
1173 }
1174 }
1175 if !solved {
1176 data := s.newTemplateData()
1177 data.Info = "Invalid CAPTCHA."
1178 data.Template = "manage_error"
1179 return data
1180 }
1181 }
1182
1183
1184 account := db.LoginAccount(username, password)
1185 if account != nil {
1186 http.SetCookie(w, &http.Cookie{
1187 Name: "sriracha_session",
1188 Value: account.Session,
1189 Path: "/",
1190 })
1191 data := s.newTemplateData()
1192 data.Account = account
1193 if s.config.ImportMode {
1194 data.Redirect(w, r, "/sriracha/import/")
1195 }
1196 return data
1197 }
1198 }
1199 }
1200 if failedLogin {
1201 data := s.newTemplateData()
1202 data.Info = "Invalid username or password."
1203 data.Template = "manage_error"
1204 return data
1205 }
1206 }
1207
1208 cookies := r.CookiesNamed("sriracha_session")
1209 if len(cookies) > 0 {
1210 account := db.AccountBySessionKey(cookies[0].Value)
1211 if account != nil {
1212 data := s.newTemplateData()
1213 data.Account = account
1214 return data
1215 }
1216 }
1217 return s.newTemplateData()
1218 }
1219
1220
1221 func (s *Server) writeThread(db *database.DB, board *Board, postID int) {
1222 posts := db.AllPostsInThread(postID, true)
1223 if len(posts) == 0 {
1224 return
1225 }
1226
1227 if board.Unique == 0 {
1228 board.Unique = db.UniqueUserPosts(board)
1229 }
1230
1231 writePath := filepath.Join(s.config.Root, board.Dir, "res", fmt.Sprintf("_%d.html", postID))
1232 filePath := filepath.Join(s.config.Root, board.Dir, "res", fmt.Sprintf("%d.html", postID))
1233
1234 f, err := os.OpenFile(writePath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, NewFilePermission)
1235 if err != nil {
1236 log.Fatal(err)
1237 }
1238
1239 data := s.newTemplateData()
1240 data.Board = board
1241 data.Boards = db.AllBoards()
1242 data.Threads = [][]*Post{posts}
1243 data.ReplyMode = postID
1244 data.Template = "board_page"
1245 data.execute(f)
1246
1247 f.Close()
1248 err = os.Rename(writePath, filePath)
1249 if err != nil {
1250 log.Fatal(err)
1251 }
1252 }
1253
1254
1255 func (s *Server) writeBoardIndexes(db *database.DB, board *Board) {
1256 if board.Unique == 0 {
1257 board.Unique = db.UniqueUserPosts(board)
1258 }
1259
1260 data := s.newTemplateData()
1261 data.Board = board
1262 data.Boards = db.AllBoards()
1263 data.ReplyMode = 1
1264 data.Template = "board_catalog"
1265
1266 threadInfo := db.AllThreads(board, true)
1267
1268
1269 if board.Type == TypeImageboard {
1270 writePath := filepath.Join(s.config.Root, board.Dir, "_catalog.html")
1271 filePath := filepath.Join(s.config.Root, board.Dir, "catalog.html")
1272
1273 catalogFile, err := os.OpenFile(writePath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, NewFilePermission)
1274 if err != nil {
1275 log.Fatal(err)
1276 }
1277
1278 for _, info := range threadInfo {
1279 thread := db.PostByID(info[0])
1280 thread.Replies = info[1]
1281 data.Threads = append(data.Threads, []*Post{thread})
1282 }
1283 data.execute(catalogFile)
1284
1285 catalogFile.Close()
1286 err = os.Rename(writePath, filePath)
1287 if err != nil {
1288 log.Fatal(err)
1289 }
1290 }
1291
1292
1293
1294 data.ReplyMode = 0
1295 data.Template = "board_page"
1296 data.Pages = pageCount(len(threadInfo), board.Threads)
1297 for page := 0; page < data.Pages; page++ {
1298 fileName := "index.html"
1299 if page > 0 {
1300 fileName = fmt.Sprintf("%d.html", page)
1301 }
1302
1303 writePath := filepath.Join(s.config.Root, board.Dir, "_"+fileName)
1304 filePath := filepath.Join(s.config.Root, board.Dir, fileName)
1305
1306 indexFile, err := os.OpenFile(writePath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, NewFilePermission)
1307 if err != nil {
1308 log.Fatal(err)
1309 }
1310
1311 start := page * board.Threads
1312 end := len(threadInfo)
1313 if board.Threads != 0 && end > start+board.Threads {
1314 end = start + board.Threads
1315 }
1316
1317 data.Threads = data.Threads[:0]
1318 for _, info := range threadInfo[start:end] {
1319 thread := db.PostByID(info[0])
1320 thread.Replies = info[1]
1321 posts := []*Post{thread}
1322 if board.Type == TypeImageboard {
1323 posts = append(posts, db.AllReplies(thread.ID, board.Replies, true)...)
1324 }
1325 data.Threads = append(data.Threads, posts)
1326 }
1327 data.Page = page
1328 data.execute(indexFile)
1329
1330 indexFile.Close()
1331 err = os.Rename(writePath, filePath)
1332 if err != nil {
1333 log.Fatal(err)
1334 }
1335 }
1336 }
1337
1338
1339 func (s *Server) writeOverboard(db *database.DB) {
1340 var overboardDir string
1341 if s.opt.Overboard != "/" {
1342 overboardDir = s.opt.Overboard
1343 }
1344
1345 overboard := &Board{
1346 ID: -1,
1347 Type: s.opt.OverboardType,
1348 Name: gotext.Get("Overboard"),
1349 Dir: overboardDir,
1350 Threads: s.opt.OverboardThreads,
1351 Replies: s.opt.OverboardReplies,
1352 }
1353
1354 data := s.newTemplateData()
1355 data.Board = overboard
1356 data.Boards = db.AllBoards()
1357 data.ReplyMode = 1
1358 data.Template = "board_catalog"
1359
1360 threadInfo := db.AllThreads(nil, true)
1361
1362
1363 if overboard.Type == TypeImageboard {
1364 writePath := filepath.Join(s.config.Root, overboardDir, "_catalog.html")
1365 filePath := filepath.Join(s.config.Root, overboardDir, "catalog.html")
1366
1367 catalogFile, err := os.OpenFile(writePath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, NewFilePermission)
1368 if err != nil {
1369 log.Fatal(err)
1370 }
1371
1372 for _, info := range threadInfo {
1373 thread := db.PostByID(info[0])
1374 thread.Replies = info[1]
1375 data.Threads = append(data.Threads, []*Post{thread})
1376 }
1377 data.execute(catalogFile)
1378
1379 catalogFile.Close()
1380 err = os.Rename(writePath, filePath)
1381 if err != nil {
1382 log.Fatal(err)
1383 }
1384 }
1385
1386
1387
1388 data.ReplyMode = 0
1389 data.Template = "board_page"
1390 data.Pages = pageCount(len(threadInfo), overboard.Threads)
1391 for page := 0; page < data.Pages; page++ {
1392 fileName := "index.html"
1393 if page > 0 {
1394 fileName = fmt.Sprintf("%d.html", page)
1395 }
1396
1397 writePath := filepath.Join(s.config.Root, overboardDir, "_"+fileName)
1398 filePath := filepath.Join(s.config.Root, overboardDir, fileName)
1399
1400 indexFile, err := os.OpenFile(writePath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, NewFilePermission)
1401 if err != nil {
1402 log.Fatal(err)
1403 }
1404
1405 start := page * overboard.Threads
1406 end := len(threadInfo)
1407 if overboard.Threads != 0 && end > start+overboard.Threads {
1408 end = start + overboard.Threads
1409 }
1410
1411 data.Threads = data.Threads[:0]
1412 for _, info := range threadInfo[start:end] {
1413 thread := db.PostByID(info[0])
1414 thread.Replies = info[1]
1415 posts := []*Post{thread}
1416 if overboard.Type == TypeImageboard {
1417 posts = append(posts, db.AllReplies(thread.ID, overboard.Replies, true)...)
1418 }
1419 data.Threads = append(data.Threads, posts)
1420 }
1421 data.Page = page
1422 data.execute(indexFile)
1423
1424 indexFile.Close()
1425 err = os.Rename(writePath, filePath)
1426 if err != nil {
1427 log.Fatal(err)
1428 }
1429 }
1430 }
1431
1432
1433 func (s *Server) newPageTemplate(db *database.DB) *template.Template {
1434 tpl, err := s.original.Clone()
1435 if err != nil {
1436 log.Fatal(err)
1437 }
1438 return tpl.Funcs(map[string]any{
1439
1440 "BoardByID": db.BoardByID,
1441 "BoardByDir": db.BoardByDir,
1442 "UniqueUserPosts": db.UniqueUserPosts,
1443 "AllBoards": db.AllBoards,
1444
1445 "NewsByID": db.NewsByID,
1446 "AllNews": db.AllNews,
1447
1448 "PageByID": db.PageByID,
1449 "PageByPath": db.PageByPath,
1450 "AllPages": db.AllPages,
1451
1452 "AllThreads": db.AllThreads,
1453 "AllPostsInThread": db.AllPostsInThread,
1454 "AllReplies": db.AllReplies,
1455 "PendingPosts": db.PendingPosts,
1456 "PostByID": db.PostByID,
1457 "PostsByIP": db.PostsByIP,
1458 "PostsByFileHash": db.PostsByFileHash,
1459 "PostByField": db.PostByField,
1460 "LastPostByIP": db.LastPostByIP,
1461 "ReplyCount": db.ReplyCount,
1462 })
1463 }
1464
1465 func (s *Server) writePage(db *database.DB, data *templateData, tpl *template.Template, p *Page, w io.Writer) error {
1466 err := p.Validate()
1467 if err != nil {
1468 return err
1469 }
1470
1471 dir := filepath.Dir(p.Path)
1472 if dir != "" {
1473 dirPath := filepath.Join(s.config.Root, dir)
1474 _, err := os.Stat(dirPath)
1475 if os.IsNotExist(err) {
1476 os.MkdirAll(dirPath, NewDirPermission)
1477 }
1478 }
1479
1480 if data == nil {
1481 data = s.newTemplateData()
1482 data.Boards = db.AllBoards()
1483 data.Template = "page"
1484 }
1485 if tpl == nil {
1486 tpl = s.newPageTemplate(db)
1487 }
1488
1489 data.tpl, err = tpl.Clone()
1490 if err != nil {
1491 log.Fatal(err)
1492 }
1493 data.tpl, err = data.tpl.New("line").Parse(p.Content)
1494 if err != nil {
1495 return err
1496 }
1497
1498 if strings.HasPrefix(p.Content, doctypePrefx) {
1499 data.Template = "line"
1500 } else {
1501 data.Template = "page"
1502 }
1503 return data.executeWithError(w)
1504 }
1505
1506
1507 func (s *Server) writePages(db *database.DB, pages []*Page) error {
1508 data := s.newTemplateData()
1509 data.Boards = db.AllBoards()
1510 data.Template = "page"
1511
1512 tpl := s.newPageTemplate(db)
1513 for _, p := range pages {
1514 writePath := filepath.Join(s.config.Root, p.Path+"_.html")
1515 filePath := filepath.Join(s.config.Root, p.Path+".html")
1516
1517 pageFile, err := os.OpenFile(writePath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, NewFilePermission)
1518 if err != nil {
1519 log.Fatal(err)
1520 }
1521
1522 err = s.writePage(db, data, tpl, p, pageFile)
1523 pageFile.Close()
1524 if err != nil {
1525 return err
1526 }
1527 err = os.Rename(writePath, filePath)
1528 if err != nil {
1529 return err
1530 }
1531 }
1532 return nil
1533 }
1534
1535
1536 func (s *Server) rebuildThread(db *database.DB, post *Post) {
1537 s.writeThread(db, post.Board, post.Thread())
1538 s.writeBoardIndexes(db, post.Board)
1539 if s.opt.Overboard != "" {
1540 s.writeOverboard(db)
1541 }
1542 }
1543
1544
1545 func (s *Server) rebuildBoard(db *database.DB, board *Board) {
1546 for _, info := range db.AllThreads(board, true) {
1547 s.writeThread(db, board, info[0])
1548 }
1549 s.writeBoardIndexes(db, board)
1550 }
1551
1552
1553 func (s *Server) rebuildAll(db *database.DB, verbose bool) {
1554 allPages := db.AllPages()
1555 if len(allPages) > 0 {
1556 if verbose {
1557 fmt.Println("Rebuilding pages...")
1558 }
1559 s.writePages(db, allPages)
1560 }
1561 published := len(db.AllNews(true))
1562 if published > 0 {
1563 if verbose {
1564 fmt.Println("Rebuilding news...")
1565 }
1566 s.rebuildNews(db)
1567 }
1568 if s.opt.Overboard != "" {
1569 if verbose {
1570 fmt.Println("Rebuilding overboard...")
1571 }
1572 s.writeOverboard(db)
1573 }
1574 for _, b := range db.AllBoards() {
1575 if verbose {
1576 fmt.Printf("Rebuilding %s...\n", b.Path())
1577 }
1578 s.rebuildBoard(db, b)
1579 }
1580 s.writeSiteIndex(db)
1581 s.writeVisitorGuide(db)
1582 }
1583
1584
1585 func (s *Server) writeNewsItem(db *database.DB, n *News) {
1586 if n.ID <= 0 {
1587 return
1588 }
1589
1590 data := s.newTemplateData()
1591 data.Boards = db.AllBoards()
1592 data.Template = "news"
1593 data.AllNews = []*News{n}
1594 data.Pages = 1
1595 data.Extra = "view"
1596
1597 writePath := filepath.Join(s.config.Root, fmt.Sprintf("_news-%d.html", n.ID))
1598 filePath := filepath.Join(s.config.Root, fmt.Sprintf("news-%d.html", n.ID))
1599
1600 itemFile, err := os.OpenFile(writePath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, NewFilePermission)
1601 if err != nil {
1602 log.Fatal(err)
1603 }
1604 data.execute(itemFile)
1605 itemFile.Close()
1606 err = os.Rename(writePath, filePath)
1607 if err != nil {
1608 log.Fatal(err)
1609 }
1610 }
1611
1612
1613 func (s *Server) writeNewsIndexes(db *database.DB) {
1614 allNews := db.AllNews(true)
1615 data := s.newTemplateData()
1616 data.Boards = db.AllBoards()
1617 data.Template = "news"
1618
1619 const newsCount = 10
1620 data.Pages = pageCount(len(allNews), newsCount)
1621 for page := 0; page < data.Pages; page++ {
1622 fileName := "news.html"
1623 if s.opt.News == NewsWriteToIndex {
1624 fileName = "index.html"
1625 }
1626 if page > 0 {
1627 fileName = fmt.Sprintf("news-p%d.html", page)
1628 }
1629
1630 writePath := filepath.Join(s.config.Root, "_"+fileName)
1631 filePath := filepath.Join(s.config.Root, fileName)
1632
1633 indexFile, err := os.OpenFile(writePath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, NewFilePermission)
1634 if err != nil {
1635 log.Fatal(err)
1636 }
1637
1638 start := page * newsCount
1639 end := len(allNews)
1640 if newsCount != 0 && end > start+newsCount {
1641 end = start + newsCount
1642 }
1643
1644 data.AllNews = allNews[start:end]
1645 data.Page = page
1646 data.execute(indexFile)
1647
1648 indexFile.Close()
1649 err = os.Rename(writePath, filePath)
1650 if err != nil {
1651 log.Fatal(err)
1652 }
1653 }
1654 }
1655
1656
1657 func (s *Server) rebuildNewsItem(db *database.DB, n *News) {
1658 s.writeNewsItem(db, n)
1659 s.writeNewsIndexes(db)
1660 }
1661
1662
1663 func (s *Server) rebuildNews(db *database.DB) {
1664 for _, n := range db.AllNews(true) {
1665 s.writeNewsItem(db, n)
1666 }
1667 s.writeNewsIndexes(db)
1668 }
1669
1670
1671 func (s *Server) writeVisitorGuide(db *database.DB) {
1672 data := s.newTemplateData()
1673 data.Template = "guide"
1674 data.Boards = db.AllBoards()
1675
1676 writePath := filepath.Join(s.config.Root, "_guide.html")
1677 filePath := filepath.Join(s.config.Root, "guide.html")
1678
1679 file, err := os.OpenFile(writePath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, NewFilePermission)
1680 if err != nil {
1681 log.Fatal(err)
1682 }
1683 data.execute(file)
1684 file.Close()
1685 err = os.Rename(writePath, filePath)
1686 if err != nil {
1687 log.Fatal(err)
1688 }
1689 }
1690
1691
1692 func (s *Server) writeSiteIndex(db *database.DB) {
1693 if !s.opt.SiteIndex || s.opt.News == NewsWriteToIndex || s.opt.Overboard == "/" || db.BoardByDir("") != nil {
1694 return
1695 }
1696 allBoards := db.AllBoards()
1697 var keep []*Board
1698 for _, board := range allBoards {
1699 if board.Hide == HideIndex || board.Hide == HideEverywhere {
1700 continue
1701 }
1702 keep = append(keep, board)
1703 }
1704 allBoards = keep
1705 if len(allBoards) < 2 {
1706 return
1707 }
1708 data := s.newTemplateData()
1709 data.Template = "index"
1710
1711 data.Boards = allBoards
1712
1713 if s.opt.News != NewsDisable {
1714 allNews := db.AllNews(true)
1715 var latest *News
1716 if len(allNews) > 0 {
1717 latest = allNews[0]
1718 }
1719 data.News = latest
1720 }
1721
1722 s.refreshRecentPosts(db)
1723
1724 writePath := filepath.Join(s.config.Root, "_index.html")
1725 filePath := filepath.Join(s.config.Root, "index.html")
1726
1727 indexFile, err := os.OpenFile(writePath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, NewFilePermission)
1728 if err != nil {
1729 log.Fatal(err)
1730 }
1731 data.execute(indexFile)
1732 indexFile.Close()
1733 err = os.Rename(writePath, filePath)
1734 if err != nil {
1735 log.Fatal(err)
1736 }
1737 }
1738
1739
1740 func (s *Server) removeInvalidBoardOptions(db *database.DB) {
1741 for _, b := range db.AllBoards() {
1742 var keep []string
1743 var modified bool
1744 for _, boardEmbed := range b.Embeds {
1745 var found bool
1746 for _, serverEmbed := range s.opt.Embeds {
1747 if serverEmbed[0] == boardEmbed {
1748 found = true
1749 break
1750 }
1751 }
1752 if !found {
1753 modified = true
1754 continue
1755 }
1756 keep = append(keep, boardEmbed)
1757 }
1758 if modified {
1759 b.Embeds = keep
1760 db.UpdateBoard(b)
1761 }
1762 }
1763 }
1764
1765
1766 func (s *Server) reloadBans(db *database.DB) {
1767 var rangeBans = make(map[*Ban]*regexp.Regexp)
1768 bans := db.AllBans(true)
1769 for _, ban := range bans {
1770 pattern, err := regexp.Compile(ban.IP[2:])
1771 if err != nil {
1772 log.Printf("warning: failed to compile IP range ban `%s` as regular expression: %s", ban.IP[2:], err)
1773 return
1774 }
1775 rangeBans[ban] = pattern
1776 }
1777 s.rangeBans = rangeBans
1778 }
1779
1780
1781 func (s *Server) serveManage(db *database.DB, w http.ResponseWriter, r *http.Request) {
1782 data := s.buildData(db, w, r)
1783 if strings.HasPrefix(r.URL.Path, "/sriracha/logout") {
1784 return
1785 }
1786 var skipExecute bool
1787
1788 if len(data.Info) != 0 {
1789 data.Template = "manage_error"
1790 data.execute(w)
1791 return
1792 }
1793
1794 if data.Account != nil {
1795 db.UpdateAccountLastActive(data.Account.ID)
1796 }
1797
1798 data.Template = "manage_login"
1799
1800 if data.Account == nil {
1801 data.execute(w)
1802 return
1803 } else if s.config.ImportMode {
1804 if data.Account.Role != RoleSuperAdmin {
1805 data.ManageError("Sriracha is running in import mode. Only super-administrators may log in.")
1806 data.execute(w)
1807 return
1808 } else if !strings.HasPrefix(r.URL.Path, "/sriracha/import/") && !strings.HasPrefix(r.URL.Path, "/sriracha/board/") {
1809 data.Redirect(w, r, "/sriracha/import/")
1810 return
1811 }
1812 data.Info = Get(nil, data.Account, "Import mode enabled. Visitors are forbidden from posting.")
1813 }
1814
1815 switch {
1816 case strings.HasPrefix(r.URL.Path, "/sriracha/preference"):
1817 s.servePreference(data, db, w, r)
1818 case strings.HasPrefix(r.URL.Path, "/sriracha/account"):
1819 s.serveAccount(data, db, w, r)
1820 case strings.HasPrefix(r.URL.Path, "/sriracha/banner"):
1821 s.serveBanner(data, db, w, r)
1822 case strings.HasPrefix(r.URL.Path, "/sriracha/ban"):
1823 s.serveBan(data, db, w, r)
1824 case strings.HasPrefix(r.URL.Path, "/sriracha/board"):
1825 skipExecute = s.serveBoard(data, db, w, r)
1826 case strings.HasPrefix(r.URL.Path, "/sriracha/category"):
1827 s.serveCategory(data, db, w, r)
1828 case strings.HasPrefix(r.URL.Path, "/sriracha/import"):
1829 s.serveImport(data, db, w, r)
1830 case strings.HasPrefix(r.URL.Path, "/sriracha/keyword"):
1831 s.serveKeyword(data, db, w, r)
1832 case strings.HasPrefix(r.URL.Path, "/sriracha/log"):
1833 s.serveLog(data, db, w, r)
1834 case strings.HasPrefix(r.URL.Path, "/sriracha/mod"):
1835 s.serveMod(data, db, w, r)
1836 case strings.HasPrefix(r.URL.Path, "/sriracha/news"):
1837 s.serveNews(data, db, w, r)
1838 case strings.HasPrefix(r.URL.Path, "/sriracha/page"):
1839 s.servePage(data, db, w, r)
1840 case strings.HasPrefix(r.URL.Path, "/sriracha/plugin"):
1841 s.servePlugin(data, db, w, r)
1842 case strings.HasPrefix(r.URL.Path, "/sriracha/setting"):
1843 s.serveSetting(data, db, w, r)
1844 default:
1845 s.serveStatus(data, db, w, r)
1846 }
1847
1848 if skipExecute {
1849 return
1850 }
1851 data.execute(w)
1852 }
1853
1854
1855 func (s *Server) serve(w http.ResponseWriter, r *http.Request) {
1856
1857 w.Header().Set("Server", "Sriracha GNU LGPL")
1858
1859
1860 if r.ContentLength > s.httpMaxRequestSize {
1861 http.Error(w, fmt.Sprintf("Exceeded maximum request size (%s)", FormatFileSize(s.httpMaxRequestSize)), http.StatusBadRequest)
1862 return
1863 }
1864
1865
1866 r.Body = http.MaxBytesReader(w, r.Body, s.httpMaxRequestSize)
1867
1868
1869 if r.Method == http.MethodPost {
1870 const maxMemory = 32 << 20
1871 err := r.ParseMultipartForm(maxMemory)
1872 if r.MultipartForm != nil {
1873 defer r.MultipartForm.RemoveAll()
1874 }
1875 if err != nil && err != http.ErrNotMultipart {
1876 http.Error(w, err.Error(), http.StatusBadRequest)
1877 return
1878 }
1879
1880 var modified bool
1881 f := make(url.Values)
1882 for key, values := range r.Form {
1883 f[key] = make([]string, len(values))
1884 for i := range values {
1885 modified = true
1886 f[key][i] = strings.ReplaceAll(values[i], "\r", "")
1887 }
1888 }
1889 if modified {
1890 r.Form = f
1891 }
1892 }
1893
1894
1895 var action string
1896 switch r.URL.Path {
1897 case "/sriracha/", "/sriracha":
1898 action = r.FormValue("action")
1899 if action == "" {
1900 values := r.URL.Query()
1901 action = values.Get("action")
1902 }
1903 case "/sriracha/captcha":
1904 action = "captcha"
1905 }
1906
1907 s.lock.Lock()
1908 defer s.lock.Unlock()
1909
1910 db := s.begin()
1911 defer db.Commit()
1912
1913 db.DeleteExpiredSubscriptions()
1914
1915 if db.DeleteExpiredBans() > 0 {
1916 s.reloadBans(db)
1917 }
1918
1919
1920 ip := s.requestIP(r)
1921 for ban, pattern := range s.rangeBans {
1922 if pattern.MatchString(ip) {
1923 data := s.buildData(db, w, r)
1924 data.ManageError("You are banned. " + ban.Info() + fmt.Sprintf(" (Ban #%d)", ban.ID))
1925 data.execute(w)
1926 return
1927 }
1928 }
1929
1930
1931 ban := db.BanByIP(s.hashIP(r))
1932 if ban != nil {
1933 data := s.buildData(db, w, r)
1934 data.ManageError("You are banned. " + ban.Info() + fmt.Sprintf(" (Ban #%d)", ban.ID))
1935 data.execute(w)
1936 return
1937 } else if strings.HasPrefix(r.URL.Path, "/sriracha/post/") {
1938 postID := PathInt(r, "/sriracha/post/")
1939 post := db.PostByID(postID)
1940 data := s.buildData(db, w, r)
1941 if post == nil {
1942 data.BoardError(w, "Invalid or deleted post.")
1943 } else {
1944 data.Redirect(w, r, fmt.Sprintf("%sres/%d.html#%d", post.Board.Path(), post.Thread(), post.ID))
1945 }
1946 return
1947 }
1948
1949 if strings.HasPrefix(r.URL.Path, "/sriracha/subscribe") {
1950 action = "subscribe"
1951 }
1952
1953 if s.config.ImportMode && action != "" {
1954 data := s.buildData(db, w, r)
1955 data.BoardError(w, "All boards are locked because Sriracha is running in import mode. Please try again later.")
1956 } else {
1957 switch action {
1958 case "post":
1959 s.servePost(db, w, r)
1960 case "report":
1961 s.serveReport(db, w, r)
1962 case "delete":
1963 s.serveDelete(db, w, r)
1964 case "captcha":
1965 s.serveCAPTCHA(db, w, r)
1966 case "subscribe":
1967 s.serveSubscribe(db, w, r)
1968 default:
1969 s.serveManage(db, w, r)
1970 }
1971 }
1972 }
1973
1974 var cachePattern = regexp.MustCompile(`^/static/.*$`)
1975
1976 func withCacheHeader(fs http.Handler) http.HandlerFunc {
1977 return func(w http.ResponseWriter, r *http.Request) {
1978 if cachePattern.MatchString(r.URL.Path) {
1979
1980 w.Header().Set("Cache-Control", "public, max-age=1209600, immutable")
1981 } else {
1982
1983 w.Header().Set("Cache-Control", "public, no-cache")
1984 }
1985 fs.ServeHTTP(w, r)
1986 }
1987 }
1988
1989
1990
1991 func (s *Server) listen(httpErrors chan error) {
1992 info, err := os.Stat("static/css/futaba.css")
1993 if err != nil || info.IsDir() {
1994 httpErrors <- fmt.Errorf("failed to locate static directory, unable to serve CSS and JS files: run sriracha from the directory that contains static as a subdirectory")
1995 return
1996 }
1997
1998 mux := http.NewServeMux()
1999 mux.Handle("/static/", withCacheHeader(http.StripPrefix("/static/", http.FileServer(http.Dir("static")))))
2000 mux.HandleFunc("/sriracha/", s.serve)
2001 mux.Handle("/", withCacheHeader(http.FileServer(http.Dir(s.config.Root))))
2002
2003 if s.config.HTTPS != "" {
2004 cert, err := tls.LoadX509KeyPair(s.config.HTTPSCert, s.config.HTTPSKey)
2005 if err != nil {
2006 httpErrors <- fmt.Errorf("failed to load HTTPS certificate %s and key %s: %s", s.config.HTTPSCert, s.config.HTTPSKey, err)
2007 return
2008 }
2009 s.httpsCert = &cert
2010
2011 tlsConfig := &tls.Config{
2012 GetCertificate: func(chi *tls.ClientHelloInfo) (*tls.Certificate, error) {
2013 return s.httpsCert, nil
2014 },
2015 InsecureSkipVerify: s.config.InsecureSkipVerify,
2016 }
2017
2018 p := &http.Protocols{}
2019 p.SetHTTP1(!s.config.RejectHTTP1)
2020 p.SetHTTP2(true)
2021 p.SetUnencryptedHTTP2(false)
2022
2023 s.httpsServer = &http.Server{
2024 Addr: s.config.HTTPS,
2025 Handler: mux,
2026 TLSConfig: tlsConfig,
2027 ReadHeaderTimeout: 1 * time.Minute,
2028 IdleTimeout: 1 * time.Minute,
2029 Protocols: p,
2030 HTTP2: &http.HTTP2Config{
2031 WriteByteTimeout: 1 * time.Minute,
2032 },
2033 }
2034
2035 go func() {
2036 httpErrors <- s.httpsServer.ListenAndServeTLS("", "")
2037 }()
2038 }
2039
2040 http2Server := &http2.Server{
2041 IdleTimeout: 1 * time.Minute,
2042 WriteByteTimeout: 1 * time.Minute,
2043 }
2044
2045 p := &http.Protocols{}
2046 p.SetHTTP1(!s.config.RejectHTTP1)
2047 p.SetHTTP2(true)
2048 p.SetUnencryptedHTTP2(true)
2049
2050 s.httpServer = &http.Server{
2051 Addr: s.config.HTTP,
2052 Handler: h2c.NewHandler(mux, http2Server),
2053 ReadHeaderTimeout: 1 * time.Minute,
2054 IdleTimeout: 1 * time.Minute,
2055 Protocols: p,
2056 HTTP2: &http.HTTP2Config{
2057 WriteByteTimeout: 1 * time.Minute,
2058 },
2059 }
2060
2061 httpErrors <- s.httpServer.ListenAndServe()
2062 }
2063
2064
2065 func (s *Server) handleRebuild() {
2066 defer s.rebuildWaitGroup.Done()
2067
2068 minWait := 1 * time.Second
2069 maxWait := 10 * time.Second
2070
2071 lastBuild := time.Now()
2072
2073 var info *rebuildInfo
2074 var pending []*rebuildInfo
2075 var boards []*Board
2076 var threads []int
2077 var shutdown bool
2078 var t *time.Timer
2079 for {
2080
2081 info = <-s.rebuildQueue
2082 if info == nil {
2083 return
2084 }
2085 pending = append(pending, info)
2086 if time.Since(lastBuild) < maxWait {
2087 for {
2088
2089 t = time.NewTimer(minWait)
2090 var found bool
2091 DRAINQUEUE:
2092 for {
2093 select {
2094 case info = <-s.rebuildQueue:
2095 if info == nil {
2096 shutdown = true
2097 } else {
2098 pending = append(pending, info)
2099 found = true
2100 }
2101 case <-t.C:
2102 break DRAINQUEUE
2103 }
2104 }
2105 if !found {
2106 break
2107 }
2108
2109 if time.Since(lastBuild) >= maxWait {
2110 break
2111 }
2112 }
2113 }
2114 if shutdown && len(pending) == 0 {
2115 return
2116 }
2117
2118
2119 db := s.begin()
2120 for _, info := range pending {
2121 thread := info.post.Thread()
2122 if !slices.Contains(threads, thread) {
2123 s.writeThread(db, info.post.Board, thread)
2124 threads = append(threads, thread)
2125 }
2126 if !slices.Contains(boards, info.post.Board) {
2127 s.writeBoardIndexes(db, info.post.Board)
2128 boards = append(boards, info.post.Board)
2129 }
2130 }
2131 if s.opt.Overboard != "" {
2132 s.writeOverboard(db)
2133 }
2134 s.writeSiteIndex(db)
2135 if s.opt.Notifications {
2136 for _, info := range pending {
2137 s.queueNotifications(db, info.post)
2138 }
2139 }
2140 db.Commit()
2141
2142 for _, info := range pending {
2143 info.wg.Done()
2144 }
2145
2146 pending = pending[:0]
2147 boards = boards[:0]
2148 threads = threads[:0]
2149
2150 lastBuild = time.Now()
2151
2152 if shutdown {
2153 return
2154 }
2155 }
2156 }
2157
2158 func (s *Server) _handleSignal(signals chan os.Signal) {
2159 for {
2160
2161 sig := <-signals
2162
2163
2164 if sig == unix.SIGHUP {
2165
2166 db := s.begin()
2167 s.rebuildAll(db, true)
2168 db.Commit()
2169
2170
2171 if s.config.HTTPS != "" {
2172 cert, err := tls.LoadX509KeyPair(s.config.HTTPSCert, s.config.HTTPSKey)
2173 if err != nil {
2174 log.Fatalf("failed to load HTTPS certificate %s and key %s: %s", s.config.HTTPSCert, s.config.HTTPSKey, err)
2175 }
2176 s.httpsCert = &cert
2177 fmt.Printf("HTTPS certificate files reloaded.\n")
2178 }
2179
2180 var extra string
2181 if s.config.HTTPS != "" {
2182 extra = " and https://" + s.config.HTTPS
2183 }
2184 fmt.Printf("Serving http://%s%s\n", s.config.HTTP, extra)
2185 continue
2186 }
2187
2188
2189 s.Stop()
2190 return
2191 }
2192 }
2193
2194
2195
2196 func (s *Server) startSignalHandler() {
2197 signals := make(chan os.Signal, 1)
2198 signal.Notify(signals, unix.SIGHUP, unix.SIGINT, unix.SIGTERM)
2199 go s._handleSignal(signals)
2200 }
2201
2202
2203 func (s *Server) Run() error {
2204 s.parseBuildInfo()
2205
2206
2207 printInfo := func() {
2208 fmt.Fprintf(os.Stderr, "\nSriracha imageboard and forum server\n https://codeberg.org/tslocum/sriracha\nGNU LESSER GENERAL PUBLIC LICENSE\n https://codeberg.org/tslocum/sriracha/src/branch/main/LICENSE\n")
2209 }
2210 flag.Usage = func() {
2211 fmt.Fprintf(os.Stderr, "Usage:\n sriracha [OPTION...] [PLUGIN...]\n\nOptions:\n")
2212 flag.PrintDefaults()
2213 printInfo()
2214 }
2215 var (
2216 configFile string
2217 exportPath string
2218 importPath string
2219 devMode bool
2220 rebuild bool
2221 printVersion bool
2222 )
2223 flag.StringVar(&configFile, "config", "", "path to configuration file (default: ~/.config/sriracha/config.yml)")
2224 flag.StringVar(&exportPath, "export", "", "export posts to zip file at specified path")
2225 flag.StringVar(&importPath, "import", "", "import posts from zip file or sqlite database file at specified path")
2226 flag.BoolVar(&devMode, "dev", false, "run in development mode (watch official and custom template files for changes)")
2227 flag.BoolVar(&rebuild, "rebuild", false, "rebuild static files on startup")
2228 flag.BoolVar(&printVersion, "version", false, "print version information and exit")
2229 flag.Parse()
2230
2231
2232 if printVersion {
2233 fmt.Fprintf(os.Stderr, "Sriracha version %s\n", SrirachaVersion)
2234 printInfo()
2235 return nil
2236 }
2237
2238
2239 s.rebuildWaitGroup.Add(1)
2240 go s.handleRebuild()
2241
2242
2243 s.startSignalHandler()
2244
2245
2246 if configFile == "" {
2247 homeDir, err := os.UserHomeDir()
2248 if err == nil {
2249 configFile = path.Join(homeDir, ".config", "sriracha", "config.yml")
2250 }
2251 }
2252
2253
2254 err := s.parseConfig(configFile)
2255 if err != nil {
2256 return fmt.Errorf("failed to parse configuration %s: %s", configFile, err)
2257 }
2258 s.config.StartTime = time.Now()
2259
2260
2261 gotext.SetDomain("sriracha")
2262
2263
2264 err = s.parseLocales()
2265 if err != nil {
2266 log.Fatalf("failed to parse locale files: %s", err)
2267 }
2268
2269
2270 var officialDir string
2271 if devMode {
2272 s.opt.DevMode = true
2273
2274 officialDir = s.officialTemplateDir()
2275 if officialDir == "" {
2276 return fmt.Errorf("failed to locate official template directory: start sriracha in the same directory as the file README.md")
2277 }
2278
2279 err = s.validateTemplateConfig(officialDir)
2280 if err != nil {
2281 return fmt.Errorf("invalid custom template directory: %s", err)
2282 }
2283 }
2284
2285
2286 s.opt.Notifications = s.config.MailAddress != ""
2287 if s.opt.Notifications && !devMode {
2288 fmt.Println("Verifying mail server configuration...")
2289 client, err := s.connectToMailServer()
2290 if err != nil {
2291 log.Fatalf("failed to verify mail server configuration: %s", err)
2292 }
2293 client.Close()
2294 }
2295
2296
2297 s.dbPool, err = database.Connect(s.config)
2298 if err != nil {
2299 return fmt.Errorf("failed to connect to database: %s", err)
2300 }
2301
2302
2303 err = s.loadServerConfig()
2304 if err != nil {
2305 return fmt.Errorf("failed to set default server configuration: %s", err)
2306 }
2307
2308
2309 err = s.loadPluginConfig()
2310 if err != nil {
2311 return fmt.Errorf("failed to load plugin configuration: %s", err)
2312 }
2313
2314
2315 err = s.loadPlugins()
2316 if err != nil {
2317 return fmt.Errorf("failed to load plugins: %s", err)
2318 }
2319
2320
2321 err = s.setDefaultPluginConfig()
2322 if err != nil {
2323 return fmt.Errorf("failed to set default plugin configuration: %s", err)
2324 }
2325
2326
2327 err = s.parseTemplates(officialDir, s.config.Template)
2328 if err != nil {
2329 return fmt.Errorf("failed to parse template files: %s", err)
2330 }
2331
2332
2333 if exportPath != "" {
2334 db := s.begin()
2335 defer db.Commit()
2336
2337 if !strings.HasSuffix(strings.ToLower(exportPath), ".zip") {
2338 exportPath += ".sriracha.zip"
2339 }
2340
2341 err := s.exportPosts(db, exportPath)
2342 if err != nil {
2343 return fmt.Errorf("failed to export posts: %s", err)
2344 }
2345 return nil
2346 }
2347
2348
2349 if importPath != "" {
2350 s.config.ImportMode = true
2351 err := s.importDatabase(importPath)
2352 if err != nil {
2353 return fmt.Errorf("failed to import posts: %s", err)
2354 }
2355 }
2356
2357
2358 if unix.Access(s.config.Root, unix.W_OK) != nil {
2359 return fmt.Errorf("configured root directory %s is not writable", s.config.Root)
2360 }
2361
2362
2363 captchaDir := filepath.Join(s.config.Root, "captcha")
2364 _, err = os.Stat(captchaDir)
2365 if os.IsNotExist(err) {
2366 err := os.Mkdir(captchaDir, NewDirPermission)
2367 if err != nil {
2368 log.Fatalf("failed to create captcha directory: %s", err)
2369 }
2370 }
2371
2372
2373 bannerDir := filepath.Join(s.config.Root, "banner")
2374 _, err = os.Stat(bannerDir)
2375 if os.IsNotExist(err) {
2376 err := os.Mkdir(bannerDir, NewDirPermission)
2377 if err != nil {
2378 log.Fatalf("failed to create banner directory: %s", err)
2379 }
2380 }
2381
2382
2383 siteIndexFile := filepath.Join(s.config.Root, "index.html")
2384 _, err = os.Stat(siteIndexFile)
2385 if os.IsNotExist(err) {
2386 err = os.WriteFile(siteIndexFile, siteIndexHTML, NewFilePermission)
2387 if err != nil {
2388 log.Fatalf("failed to write site index at %s: %s", siteIndexFile, err)
2389 }
2390 }
2391
2392
2393 s.lock.Lock()
2394
2395 db := s.begin()
2396 s.refreshMaxRequestSize(db)
2397
2398
2399 httpErrors := make(chan error)
2400 go s.listen(httpErrors)
2401
2402
2403 s.refreshBannerCache(db)
2404 s.refreshRulesCache(db)
2405 s.refreshCategoryCache(db)
2406 s.refreshKeywordCache(db)
2407 sv := db.GetString("sv")
2408 if sv != SrirachaVersion {
2409 if sv != "" {
2410 fmt.Printf("Upgraded from Sriracha version %s to %s, rebuilding...\n", sv, SrirachaVersion)
2411 rebuild = true
2412 }
2413 db.SaveString("sv", SrirachaVersion)
2414 }
2415 if rebuild {
2416 s.rebuildAll(db, true)
2417 }
2418 db.Commit()
2419
2420
2421 if devMode {
2422 dir := "directory"
2423 if s.config.Template != "" {
2424 dir = "directories"
2425 }
2426 fmt.Printf("Development mode enabled. Monitoring template %s...\n", dir)
2427 err = s.watchTemplates(officialDir)
2428 if err != nil {
2429 s.lock.Unlock()
2430 return fmt.Errorf("failed to watch templates for changes: %s", err)
2431 }
2432 }
2433
2434
2435 if s.config.MailAddress != "" {
2436 s.notificationsWaitGroup.Add(1)
2437 go s.handleNotifications()
2438 }
2439
2440 if s.config.ImportMode {
2441 fmt.Println("Import mode enabled. Visitors are forbidden from posting.")
2442 }
2443
2444
2445 var extra string
2446 if s.config.HTTPS != "" {
2447 extra = " and https://" + s.config.HTTPS
2448 }
2449 fmt.Printf("Serving http://%s%s\n", s.config.HTTP, extra)
2450 s.lock.Unlock()
2451
2452
2453 err = <-httpErrors
2454
2455
2456 if err == http.ErrServerClosed {
2457
2458 s.rebuildWaitGroup.Wait()
2459
2460 s.notificationsWaitGroup.Wait()
2461 return nil
2462 }
2463 return err
2464 }
2465
2466
2467 func (s *Server) newHash() hash.Hash {
2468 if s.opt.Algorithm == AlgorithmSHA3 {
2469 return sha3.New384()
2470 }
2471 return sha512.New384()
2472 }
2473
2474
2475 func (s *Server) hashBytes(buf []byte, salt string) string {
2476 hash := s.newHash()
2477 hash.Write(buf)
2478 if salt != "" {
2479 hash.Write([]byte(salt))
2480 }
2481 var sum [HashSize]byte
2482 hash.Sum(sum[:0])
2483 return base64.URLEncoding.EncodeToString(sum[:])
2484 }
2485
2486
2487 func (s *Server) hashData(data string) string {
2488 return s.hashBytes([]byte(data), s.config.SaltData)
2489 }
2490
2491
2492 func md5Sum(data string) string {
2493 return fmt.Sprintf("%x", md5.Sum([]byte(data)))
2494 }
2495
2496
2497 func parseHostname(address string) string {
2498 if address == "" {
2499 return ""
2500 }
2501 hostname, port, err := net.SplitHostPort(address)
2502 if err != nil || port == "" {
2503 return address
2504 }
2505 return hostname
2506 }
2507
2508
2509 func (s *Server) requestIP(r *http.Request) string {
2510 var address string
2511 if s.config.Header != "" {
2512 values := r.Header[s.config.Header]
2513 if len(values) > 0 {
2514 address = values[0]
2515 }
2516 } else {
2517 address = r.RemoteAddr
2518 }
2519 if address == "" {
2520 log.Fatal("Error: No client IP address specified in HTTP request. Are you sure the header server option is correct? See MANUAL.md for more info.")
2521 }
2522 return parseHostname(address)
2523 }
2524
2525 func (s *Server) _hashIP(address string) string {
2526 if address == "" {
2527 return ""
2528 }
2529 return s.hashData(parseHostname(address))
2530 }
2531
2532
2533 func (s *Server) hashIP(r *http.Request) string {
2534 return s._hashIP(s.requestIP(r))
2535 }
2536
2537
2538 func (s *Server) imageDimensions(reader io.Reader) (int, int) {
2539 imgConfig, _, err := image.DecodeConfig(reader)
2540 if err != nil {
2541 return 0, 0
2542 }
2543 return imgConfig.Width, imgConfig.Height
2544 }
2545
2546
2547 func (s *Server) Stop() {
2548 fmt.Println("Shutting down...")
2549
2550
2551 if s.httpsServer != nil {
2552 s.httpsServer.Shutdown(context.Background())
2553 } else if s.httpServer != nil {
2554 s.httpServer.Shutdown(context.Background())
2555 }
2556
2557
2558 s.lock.Lock()
2559 s.rebuildLock.Lock()
2560 s.rebuildQueue <- nil
2561 s.rebuildWaitGroup.Wait()
2562
2563
2564 if s.opt.Notifications {
2565 s.shutdownNotifications <- struct{}{}
2566 }
2567
2568
2569 if s.httpServer == nil && s.httpsServer == nil {
2570 os.Exit(0)
2571 }
2572 }
2573
2574
2575 func pluginByName(name string) (any, *pluginInfo) {
2576 name = strings.ToLower(name)
2577 for i, info := range allPluginInfo {
2578 if strings.ToLower(info.Name) == name {
2579 return allPlugins[i], info
2580 }
2581 }
2582 return nil, nil
2583 }
2584
2585
2586 func FormatValue(v interface{}) interface{} {
2587 if role, ok := v.(AccountRole); ok {
2588 return FormatRole(role)
2589 } else if t, ok := v.(BoardType); ok {
2590 return FormatBoardType(t)
2591 } else if t, ok := v.(BoardHide); ok {
2592 return FormatBoardHide(t)
2593 } else if t, ok := v.(BoardLock); ok {
2594 return FormatBoardLock(t)
2595 } else if t, ok := v.(BoardApproval); ok {
2596 return FormatBoardApproval(t)
2597 } else if t, ok := v.(BoardIdentifiers); ok {
2598 return FormatBoardIdentifiers(t)
2599 }
2600 return v
2601 }
2602
2603
2604 func printChanges(old interface{}, new interface{}) string {
2605 const mask = "***"
2606 diff, err := diff.Diff(old, new)
2607 if err != nil {
2608 log.Fatal(err)
2609 } else if len(diff) == 0 {
2610 return ""
2611 }
2612 var label string
2613 for _, change := range diff {
2614 from := change.From
2615 to := change.To
2616
2617 var name string
2618 if len(change.Path) > 0 {
2619 name = change.Path[0]
2620 if name == "Password" {
2621 from = mask
2622 to = mask
2623 }
2624 }
2625
2626 label += fmt.Sprintf(` [%s: "%v" > "%v"]`, name, FormatValue(from), FormatValue(to))
2627 }
2628 return label
2629 }
2630
2631
2632 func pageCount(items int, pageSize int) int {
2633 if items == 0 || pageSize == 0 {
2634 return 1
2635 }
2636 pages := items / pageSize
2637 if items%pageSize != 0 {
2638 pages++
2639 }
2640 return pages
2641 }
2642
2643 func pageSlice[S ~[]T, T any](slice S, page int, perPage int) S {
2644 start := page * perPage
2645 end := len(slice)
2646 if perPage != 0 && end > start+perPage {
2647 end = start + perPage
2648 }
2649 return slice[start:end]
2650 }
2651
2652
2653
2654 const doctypePrefx = "<!DOCTYPE html>"
2655
2656
2657 var siteIndexHTML = []byte(`
2658 <!DOCTYPE html>
2659 <html>
2660 <body>
2661 <meta http-equiv="refresh" content="0; url=/sriracha/">
2662 <a href="/sriracha/">Redirecting...</a>
2663 </body>
2664 </html>
2665 `)
2666
View as plain text