1
2 package server
3
4 import (
5 "bytes"
6 "context"
7 "crypto/md5"
8 "crypto/sha512"
9 "crypto/tls"
10 "embed"
11 "encoding/base64"
12 "flag"
13 "fmt"
14 "html/template"
15 "image"
16 "io"
17 "io/fs"
18 "log"
19 "maps"
20 "net"
21 "net/http"
22 "net/smtp"
23 "net/url"
24 "os"
25 "os/signal"
26 "path"
27 "path/filepath"
28 "regexp"
29 "runtime/debug"
30 "slices"
31 "strconv"
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/sys/unix"
45 "golang.org/x/text/language"
46 "golang.org/x/text/language/display"
47 "gopkg.in/yaml.v3"
48 )
49
50
51
52 var SrirachaVersion = "DEV"
53
54
55 var localeFS embed.FS
56
57
58 const (
59 defaultServerSiteName = "Sriracha"
60 defaultServerSiteHome = "/"
61 defaultServerOekakiWidth = 540
62 defaultServerOekakiHeight = 540
63 defaultServerRefresh = 30
64 )
65
66
67 var defaultServerEmbeds = [][2]string{
68 {"YouTube", "https://youtube.com/oembed?format=json&url=SRIRACHA_EMBED"},
69 {"Vimeo", "https://vimeo.com/api/oembed.json?url=SRIRACHA_EMBED"},
70 {"SoundCloud", "https://soundcloud.com/oembed?format=json&url=SRIRACHA_EMBED"},
71 }
72
73
74 const (
75 bannerOverboard = -1
76 bannerNews = -2
77 bannerPages = -3
78 )
79
80
81 type NewsOption int
82
83
84 const (
85 NewsDisable NewsOption = 0
86 NewsWriteToNews NewsOption = 1
87 NewsWriteToIndex NewsOption = 2
88 )
89
90
91 type ServerOptions struct {
92 SiteName string
93 SiteHome string
94 News NewsOption
95 BoardIndex bool
96 CAPTCHA bool
97 Refresh int
98 Uploads []*UploadType
99 Embeds [][2]string
100 OekakiWidth int
101 OekakiHeight int
102 Overboard string
103 OverboardType BoardType
104 OverboardThreads int
105 OverboardReplies int
106 Identifiers bool
107 Locale string
108 Locales map[string]string
109 LocalesSorted []string
110 Access map[string]string
111 Banners map[int][]*Banner
112 Notifications bool
113 DevMode bool
114 FuncMaps map[string]template.FuncMap
115 }
116
117
118 func (opt *ServerOptions) DefaultLocaleName() string {
119 if opt.Locale == "" || opt.Locale == "en" {
120 return "English"
121 }
122 name := opt.Locales[opt.Locale]
123 if name != "" {
124 return name
125 }
126 return opt.Locale
127 }
128
129
130 type rebuildInfo struct {
131 post *Post
132 wg *sync.WaitGroup
133 }
134
135
136 type Server struct {
137 Boards []*Board
138
139 rangeBans map[*Ban]*regexp.Regexp
140
141 config *Config
142 dbPool *pgxpool.Pool
143 opt ServerOptions
144
145 tpl *template.Template
146 original *template.Template
147 customTemplates []string
148
149 notifications []notification
150 notificationsPattern *regexp.Regexp
151 notificationsWaitGroup sync.WaitGroup
152 shutdownNotifications chan struct{}
153
154 rebuildQueue chan *rebuildInfo
155 rebuildWaitGroup sync.WaitGroup
156 rebuildLock sync.Mutex
157
158 httpServer *http.Server
159
160 lock sync.Mutex
161 }
162
163
164 func NewServer() *Server {
165 return &Server{
166 opt: ServerOptions{
167 Banners: make(map[int][]*Banner),
168 },
169 shutdownNotifications: make(chan struct{}),
170 rebuildQueue: make(chan *rebuildInfo),
171 }
172 }
173
174
175
176 func (s *Server) parseBuildInfo() {
177 if SrirachaVersion == "" {
178 SrirachaVersion = "DEV"
179 } else if SrirachaVersion != "DEV" {
180 return
181 }
182 info, ok := debug.ReadBuildInfo()
183 if !ok {
184 return
185 }
186 buildTag := info.Main.Version
187 if buildTag != "" && buildTag[0] == 'v' {
188 SrirachaVersion = buildTag[1:]
189 firstHyphen := strings.IndexRune(SrirachaVersion, '-')
190 firstPlus := strings.IndexRune(SrirachaVersion, '+')
191 if firstHyphen == -1 && firstPlus == -1 {
192 return
193 }
194 if firstHyphen != -1 {
195 SrirachaVersion = SrirachaVersion[:firstHyphen]
196 firstPlus = strings.IndexRune(SrirachaVersion, '+')
197 }
198 if firstPlus != -1 {
199 SrirachaVersion = SrirachaVersion[:firstPlus]
200 }
201 SrirachaVersion += "-DEV"
202 }
203 for _, setting := range info.Settings {
204 if setting.Key == "vcs.revision" {
205 revision := setting.Value
206 if len(revision) > 10 {
207 revision = revision[:10]
208 }
209 SrirachaVersion += "-" + revision
210 return
211 }
212 }
213 }
214
215
216
217 func (s *Server) forbidden(w http.ResponseWriter, data *templateData, action string) bool {
218 var required AccountRole
219 switch s.config.Access[action] {
220 case "mod":
221 required = RoleMod
222 case "admin":
223 required = RoleAdmin
224 case "super-admin":
225 required = RoleSuperAdmin
226 }
227 return data.forbidden(w, required)
228 }
229
230
231 func (s *Server) parseConfig(configFile string) error {
232 buf, err := os.ReadFile(configFile)
233 if err != nil {
234 return err
235 }
236
237 config := &Config{
238 Access: make(map[string]string),
239 }
240 err = yaml.Unmarshal(buf, config)
241 if err != nil {
242 return err
243 }
244
245 switch {
246 case config.Root == "":
247 return fmt.Errorf("root (lowercase!) must be set in %s to the root directory (where board files are written)", configFile)
248 case config.Serve == "":
249 return fmt.Errorf("serve (lowercase!) must be set in %s to the HTTP server listen address (hostname:port)", configFile)
250 case config.SaltData == "":
251 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)
252 case config.SaltPass == "":
253 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)
254 case config.SaltTrip == "":
255 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)
256 }
257
258 if config.DBURL == "" {
259 switch {
260 case config.Address == "":
261 return fmt.Errorf("address (lowercase!) must be set in %s to the database address (hostname:port)", configFile)
262 case config.Username == "":
263 return fmt.Errorf("username (lowercase!) must be set in %s to the database username", configFile)
264 case config.Password == "":
265 return fmt.Errorf("password (lowercase!) must be set in %s to the database password", configFile)
266 case config.DBName == "":
267 return fmt.Errorf("dbname (lowercase!) must be set in %s to the database name", configFile)
268 }
269 }
270
271 if config.Locale == "" {
272 config.Locale = "en"
273 }
274
275 if config.MailFrom != "" && ParseEmail(config.MailFrom) == "" {
276 return fmt.Errorf("mailfrom is not a valid email address: %s", config.MailFrom)
277 } else if config.MailReplyTo != "" && ParseEmail(config.MailReplyTo) == "" {
278 return fmt.Errorf("mailreplyto is not a valid email address: %s", config.MailReplyTo)
279 }
280
281 if config.Mentions <= 0 {
282 config.Mentions = 60
283 }
284 if config.Notifications <= 0 {
285 config.Notifications = 1440
286 }
287
288 defaultAccess := map[string]string{
289 "ban.add": "mod",
290 "ban.shorten": "admin",
291 "ban.lengthen": "mod",
292 "ban.delete": "admin",
293 "banfile.add": "mod",
294 "banfile.delete": "admin",
295 "banner.add": "admin",
296 "banner.update": "admin",
297 "banner.delete": "super-admin",
298 "board.add": "admin",
299 "board.update": "admin",
300 "board.delete": "super-admin",
301 "keyword.add": "admin",
302 "keyword.update": "admin",
303 "keyword.delete": "admin",
304 "page.add": "admin",
305 "page.update": "admin",
306 "page.delete": "admin",
307 "post.sticky": "mod",
308 "post.lock": "mod",
309 "post.move": "mod",
310 "post.delete": "mod",
311 }
312 validateAccess := func(name string, v string) error {
313 if _, ok := defaultAccess[name]; !ok && name != "default" {
314 return fmt.Errorf("access configuration contains unrecognized action %s", name)
315 }
316 switch v {
317 case "mod", "admin", "super-admin", "disable":
318 return nil
319 default:
320 return fmt.Errorf("action %s has unknown access level %s: must be 'mod', 'admin', 'super-admin' or 'disable'", name, v)
321 }
322 }
323 var defaultRequirement string
324 for name, v := range config.Access {
325 err = validateAccess(name, v)
326 if err != nil {
327 return fmt.Errorf("access configuration is invalid: %s", err)
328 } else if name == "default" {
329 defaultRequirement = v
330 delete(config.Access, name)
331 }
332 }
333 for name, v := range defaultAccess {
334 if config.Access[name] != "" {
335 continue
336 } else if defaultRequirement != "" {
337 config.Access[name] = defaultRequirement
338 continue
339 }
340 config.Access[name] = v
341 }
342
343 s.config = config
344 s.config.ImportMode = s.config.Import.Enabled()
345
346 if s.config.MailDomains != "" {
347 s.notificationsPattern, err = regexp.Compile(s.config.MailDomains)
348 if err != nil {
349 return fmt.Errorf("failed to parse maildomains regular expression: %s", err)
350 }
351 }
352 return nil
353 }
354
355
356 func (s *Server) parseLocales() error {
357 return fs.WalkDir(localeFS, "locale", func(p string, d fs.DirEntry, err error) error {
358 if err != nil {
359 return err
360 } else if d.IsDir() || !strings.HasSuffix(p, ".po") {
361 return nil
362 }
363 id := filepath.Base(strings.TrimSuffix(p, ".po"))
364
365 buf, err := localeFS.ReadFile(fmt.Sprintf("locale/%s/%s.po", id, id))
366 if err != nil {
367 return fmt.Errorf("failed to load locale %s: %s", id, err)
368 }
369
370 po := gotext.NewPo()
371 po.Parse(buf)
372 gotext.GetStorage().AddTranslator(fmt.Sprintf("sriracha-%s", id), po)
373 return nil
374 })
375 }
376
377
378 func (s *Server) connectToMailServer() (*smtp.Client, error) {
379 if s.config.MailAddress == "" {
380 return nil, nil
381 }
382
383
384 address := s.config.MailAddress
385 hostname, _, err := net.SplitHostPort(s.config.MailAddress)
386 if err != nil {
387 hostname = s.config.MailAddress
388 if strings.ContainsRune(s.config.MailAddress, ':') {
389 address = fmt.Sprintf("[%s]:25", address)
390 } else {
391 address = address + ":25"
392 }
393 }
394 tlsConfig := &tls.Config{
395 InsecureSkipVerify: s.config.MailInsecure,
396 ServerName: hostname,
397 }
398
399
400 var conn net.Conn
401 if s.config.MailTLS {
402 conn, err = tls.Dial("tcp", address, tlsConfig)
403 if err != nil {
404 log.Fatalf("failed to connect to SMTP server with TLS enabled at %s: %s", address, err)
405 }
406 } else {
407 conn, err = net.Dial("tcp", address)
408 if err != nil {
409 log.Fatalf("failed to connect to SMTP server without TLS enabled at %s: %s", address, err)
410 }
411 }
412
413
414 client, err := smtp.NewClient(conn, hostname)
415 if err != nil {
416 conn.Close()
417 return nil, fmt.Errorf("failed to initialize SMTP client: %s", err)
418 }
419
420
421 if !s.config.MailTLS {
422 ok, _ := client.Extension("STARTTLS")
423 if ok {
424 err = client.StartTLS(tlsConfig)
425 if err != nil {
426 client.Close()
427 return nil, fmt.Errorf("failed to upgrade plain text connection to TLS even though support for it was advertised")
428 }
429 }
430 }
431
432
433 var auth smtp.Auth
434 switch s.config.MailAuth {
435 case "challenge":
436 auth = smtp.CRAMMD5Auth(s.config.MailUsername, s.config.MailPassword)
437 case "plain":
438 auth = smtp.PlainAuth("", s.config.MailUsername, s.config.MailPassword, hostname)
439 case "", "none":
440
441 default:
442 client.Close()
443 return nil, fmt.Errorf("unrecognized mailauth configuration value %s: must be challenge / plain / none", s.config.MailAuth)
444 }
445 if auth != nil {
446 err := client.Auth(auth)
447 if err != nil {
448 client.Close()
449 return nil, fmt.Errorf("failed to authenticate with SMTP server: %s", err)
450 }
451 }
452
453
454 if err = client.Noop(); err != nil {
455 client.Close()
456 return nil, fmt.Errorf("failed to verify SMTP server connection by sending NOOP command: %s", err)
457 }
458 return client, nil
459 }
460
461
462 func (s *Server) sendMail(client *smtp.Client, recipient string, subject string, message string) error {
463
464 var body []byte
465 if s.config.MailFrom != "" {
466 body = fmt.Appendf(body, "From: %s\n", s.config.MailFrom)
467 }
468 body = fmt.Appendf(body, "To: %s\nSubject: %s\n", recipient, subject)
469 if s.config.MailReplyTo != "" {
470 body = fmt.Appendf(body, "Reply-To: %s\n", s.config.MailReplyTo)
471 }
472 body = fmt.Appendf(body, "\n%s", message)
473
474
475 if err := client.Reset(); err != nil {
476 return fmt.Errorf("failed to reset state: %s", err)
477 }
478
479
480 if s.config.MailFrom != "" {
481 if err := client.Mail(s.config.MailFrom); err != nil {
482 return fmt.Errorf("failed to set from address: %s", err)
483 }
484 }
485 if err := client.Rcpt(recipient); err != nil {
486 return fmt.Errorf("failed to set recipient address: %s", err)
487 }
488
489
490 wc, err := client.Data()
491 if err != nil {
492 return fmt.Errorf("failed to send DATA command: %s", err)
493 }
494
495
496 _, err = wc.Write(body)
497 if err != nil {
498 return fmt.Errorf("failed to write email body: %s", err)
499 }
500
501
502 err = wc.Close()
503 if err != nil {
504 return fmt.Errorf("failed to write email body: %s", err)
505 }
506 return nil
507 }
508
509
510 func (s *Server) begin() *database.DB {
511 return database.Begin(s.dbPool, s.config)
512 }
513
514
515 func (s *Server) setDefaultServerConfig() error {
516 db := s.begin()
517 defer db.Commit()
518
519 siteName := db.GetString("sitename")
520 if siteName == "" {
521 siteName = defaultServerSiteName
522 }
523 s.opt.SiteName = siteName
524
525 siteHome := db.GetString("sitehome")
526 if siteHome == "" {
527 siteHome = defaultServerSiteHome
528 }
529 s.opt.SiteHome = siteHome
530
531 news := NewsOption(db.GetInt("news"))
532 if news == NewsDisable || news == NewsWriteToNews || news == NewsWriteToIndex {
533 s.opt.News = news
534 }
535
536 boardIndex := db.GetString("boardindex")
537 s.opt.BoardIndex = boardIndex == "" || boardIndex == "1"
538
539 s.opt.CAPTCHA = db.GetBool("captcha")
540
541 oekakiWidth := db.GetInt("oekakiwidth")
542 if oekakiWidth == 0 {
543 oekakiWidth = defaultServerOekakiWidth
544 }
545 s.opt.OekakiWidth = oekakiWidth
546
547 oekakiHeight := db.GetInt("oekakiheight")
548 if oekakiHeight == 0 {
549 oekakiHeight = defaultServerOekakiHeight
550 }
551 s.opt.OekakiHeight = oekakiHeight
552
553 if !db.HaveConfig("refresh") {
554 s.opt.Refresh = defaultServerRefresh
555 } else {
556 s.opt.Refresh = db.GetInt("refresh")
557 }
558
559 s.opt.Overboard = db.GetString("overboard")
560 s.opt.OverboardType = BoardType(db.GetInt("overboardtype"))
561 s.opt.OverboardThreads = db.GetInt("overboardthreads")
562 s.opt.OverboardReplies = db.GetInt("overboardreplies")
563
564 s.opt.Uploads = s.config.UploadTypes()
565
566 s.opt.Embeds = nil
567 if !db.HaveConfig("embeds") {
568 s.opt.Embeds = append(s.opt.Embeds, defaultServerEmbeds...)
569 } else {
570 embeds := db.GetMultiString("embeds")
571 for _, v := range embeds {
572 split := strings.SplitN(v, " ", 2)
573 if len(split) != 2 {
574 continue
575 }
576 s.opt.Embeds = append(s.opt.Embeds, [2]string{split[0], split[1]})
577 }
578 }
579
580 s.reloadBans(db)
581
582 s.opt.Identifiers = s.config.Identifiers
583
584 s.opt.Locale = s.config.Locale
585
586 s.opt.Locales = make(map[string]string)
587 english := display.English.Languages()
588 fs.WalkDir(localeFS, "locale", func(p string, d fs.DirEntry, err error) error {
589 if err != nil {
590 return err
591 } else if d.IsDir() || !strings.HasSuffix(p, ".po") {
592 return nil
593 }
594 id := filepath.Base(strings.TrimSuffix(p, ".po"))
595
596 name := id
597 tag, err := language.Parse(id)
598 if err == nil {
599 tagName := english.Name(tag)
600 if tagName != "" {
601 name = tagName
602 }
603 }
604
605 s.opt.Locales[id] = name
606 return nil
607 })
608 s.opt.Locales["en@pirate"] = "Pirate English"
609 if s.opt.Locale != "" && s.opt.Locale != "en" {
610 s.opt.Locales["en"] = "English"
611 }
612 s.opt.LocalesSorted = slices.SortedFunc(maps.Keys(s.opt.Locales), func(s1, s2 string) int {
613 return strings.Compare(s.opt.Locales[s1], s.opt.Locales[s2])
614 })
615
616 templateFuncMaps = make(map[string]template.FuncMap)
617 templateFuncMaps[""] = newTemplateFuncMap(s.opt.Locale)
618 for id := range s.opt.Locales {
619 templateFuncMaps[id] = newTemplateFuncMap(id)
620 }
621
622 s.opt.Access = make(map[string]string)
623 maps.Copy(s.opt.Access, s.config.Access)
624 return nil
625 }
626
627
628 func (s *Server) setDefaultPluginConfig() error {
629 db := s.begin()
630 defer db.Commit()
631
632 for i, info := range allPluginInfo {
633 db.Plugin = info.Name
634
635 for i, config := range info.Config {
636 if !db.HaveConfig(config.Name) {
637 db.SaveString(config.Name, config.Value)
638 } else {
639 info.Config[i].Value = db.GetString(config.Name)
640 }
641 }
642
643 p := allPlugins[i]
644 pUpdate, ok := p.(sriracha.PluginWithUpdate)
645 if ok {
646 for _, config := range info.Config {
647 pUpdate.Update(db, config.Name)
648 }
649 }
650 }
651 return nil
652 }
653
654
655 func (s *Server) loadPluginConfig() error {
656 db := s.begin()
657 defer db.Commit()
658
659 for _, info := range allPluginInfo {
660 db.Plugin = info.Name
661 for i, c := range info.Config {
662 v := db.GetString(strings.ToLower(info.Name + "." + c.Name))
663 if v != "" {
664 info.Config[i].Value = v
665 }
666 }
667 }
668 db.Plugin = ""
669 return nil
670 }
671
672
673 func (s *Server) officialTemplateDir() string {
674 officialDir := "internal/server/template"
675 _, err := os.Stat(officialDir)
676 if !os.IsNotExist(err) {
677 return officialDir
678 }
679 officialDir = "template"
680 _, err = os.Stat(officialDir)
681 if !os.IsNotExist(err) {
682 return officialDir
683 }
684 return ""
685 }
686
687
688
689 func (s *Server) validateTemplateConfig(officialDir string) error {
690 if s.config.Template == "" {
691 return nil
692 }
693 _, err := os.Stat(s.config.Template)
694 if os.IsNotExist(err) {
695 return fmt.Errorf("custom template directory %s does not exist", s.config.Template)
696 }
697 officialTemplate, err := os.Stat(officialDir)
698 if err != nil {
699 return fmt.Errorf("failed to locate official template directory: start sriracha in the same directory as the file README.md")
700 }
701 customTemplate, err := os.Stat(s.config.Template)
702 if err != nil {
703 return fmt.Errorf("custom template directory %s is inaccessible", s.config.Template)
704 }
705 if os.SameFile(officialTemplate, customTemplate) {
706 return fmt.Errorf("official templates and custom templates must be located in separate directories")
707 }
708 return nil
709 }
710
711
712
713
714
715 func (s *Server) parseTemplates(officialDir string, customDir string) error {
716 s.customTemplates = s.customTemplates[:0]
717 wrapError := func(name string, err error) error {
718 var source string
719 if !slices.Contains(s.customTemplates, name) {
720 source = "official"
721 } else {
722 source = "custom"
723 }
724 return fmt.Errorf("failed to parse %s template file %s: %s", source, name, err)
725 }
726 parseDir := func(dir string, custom bool) error {
727 entries, err := os.ReadDir(dir)
728 if err != nil {
729 return err
730 }
731 for _, f := range entries {
732 if !strings.HasSuffix(f.Name(), ".gohtml") {
733 continue
734 } else if custom {
735 s.customTemplates = append(s.customTemplates, f.Name())
736 }
737
738 buf, err := os.ReadFile(filepath.Join(dir, f.Name()))
739 if err != nil {
740 return wrapError(f.Name(), err)
741 }
742
743 _, err = s.tpl.New(f.Name()).Parse(string(buf))
744 if err != nil {
745 return wrapError(f.Name(), err)
746 }
747 }
748 return nil
749 }
750 if officialDir == "" {
751 s.tpl = template.New("sriracha").Funcs(templateFuncMaps[""])
752
753 entries, err := templateFS.ReadDir("template")
754 if err != nil {
755 return err
756 }
757 for _, f := range entries {
758 if !strings.HasSuffix(f.Name(), ".gohtml") {
759 continue
760 }
761
762 buf, err := templateFS.ReadFile(filepath.Join("template", f.Name()))
763 if err != nil {
764 return wrapError(f.Name(), err)
765 }
766
767 _, err = s.tpl.New(f.Name()).Parse(string(buf))
768 if err != nil {
769 return wrapError(f.Name(), err)
770 }
771 }
772 } else {
773 s.tpl = template.New("sriracha").Funcs(templateFuncMaps[""])
774
775 err := parseDir(officialDir, false)
776 if err != nil {
777 return err
778 }
779 }
780
781 if customDir != "" {
782 err := parseDir(customDir, true)
783 if err != nil {
784 return err
785 }
786 }
787
788 var err error
789 s.original, err = s.tpl.Clone()
790 return err
791 }
792
793 func (s *Server) _watchTemplates(officialDir string, watcher *fsnotify.Watcher) {
794 for {
795 select {
796 case event, ok := <-watcher.Events:
797 if !ok {
798 return
799 } else if !event.Has(fsnotify.Create) && !event.Has(fsnotify.Write) && !event.Has(fsnotify.Remove) && !event.Has(fsnotify.Rename) {
800 continue
801 }
802 err := s.parseTemplates(officialDir, s.config.Template)
803 if err != nil {
804 log.Printf("failed to parse template files: %s", err)
805 }
806 case err, ok := <-watcher.Errors:
807 if !ok {
808 return
809 }
810 log.Printf("fsnotify error: %s", err)
811 }
812 }
813 }
814
815
816 func (s *Server) watchTemplates(officialDir string) error {
817 watcher, err := fsnotify.NewWatcher()
818 if err != nil {
819 log.Fatal(err)
820 }
821 go s._watchTemplates(officialDir, watcher)
822
823 err = watcher.Add(officialDir)
824 if err == nil && s.config.Template != "" {
825 err = watcher.Add(s.config.Template)
826 }
827 return err
828 }
829
830
831 func (s *Server) log(db *database.DB, account *Account, board *Board, action string, info string) {
832 user := "system"
833 if account != nil && account.ID != 0 {
834 if account.Role == RoleSuperAdmin || account.Role == RoleAdmin {
835 user = "admin"
836 } else {
837 user = "mod"
838 }
839 }
840 for _, handlerInfo := range allPluginAuditHandlers {
841 db.Plugin = handlerInfo.Name
842 err := handlerInfo.Handler(db, user, action, info)
843 if err != nil {
844 log.Fatalf("plugin %s failed to process audit event: %s", handlerInfo.Name, err)
845 }
846 }
847 db.Plugin = ""
848
849 db.AddLog(&Log{
850 Account: account,
851 Board: board,
852 Message: action,
853 Changes: info,
854 })
855 }
856
857
858 func (s *Server) refreshBannerCache(db *database.DB) {
859 banners := s.opt.Banners
860 for id := range banners {
861 banners[id] = banners[id][:0]
862 }
863
864 for _, banner := range db.AllBanners() {
865 for _, board := range banner.Boards {
866 banners[board.ID] = append(banners[board.ID], banner)
867 }
868 if banner.Overboard {
869 banners[bannerOverboard] = append(banners[bannerOverboard], banner)
870 }
871 if banner.News {
872 banners[bannerNews] = append(banners[bannerNews], banner)
873 }
874 if banner.Pages {
875 banners[bannerPages] = append(banners[bannerPages], banner)
876 }
877 }
878
879 for id := range banners {
880 if len(banners[id]) == 0 {
881 delete(banners, id)
882 }
883 }
884 }
885
886
887 func (s *Server) deletePostFiles(p *Post) {
888 if p.Board == nil {
889 return
890 } else if p.ID != 0 && p.Parent == 0 {
891 os.Remove(filepath.Join(s.config.Root, p.Board.Dir, "res", fmt.Sprintf("%d.html", p.ID)))
892 }
893
894 if p.File == "" {
895 return
896 }
897 srcPath := filepath.Join(s.config.Root, p.Board.Dir, "src", p.File)
898 os.Remove(srcPath)
899
900 if p.Thumb == "" {
901 return
902 }
903 thumbPath := filepath.Join(s.config.Root, p.Board.Dir, "thumb", p.Thumb)
904 os.Remove(thumbPath)
905 }
906
907
908 func (s *Server) deletePost(db *database.DB, p *Post) {
909 posts := db.AllPostsInThread(p.ID, false)
910 for _, post := range posts {
911 s.deletePostFiles(post)
912 }
913
914 db.DeletePost(p.ID)
915 }
916
917
918 func (s *Server) buildData(db *database.DB, w http.ResponseWriter, r *http.Request) *templateData {
919 if strings.HasPrefix(r.URL.Path, "/sriracha/logout") {
920 http.SetCookie(w, &http.Cookie{
921 Name: "sriracha_session",
922 Value: "",
923 Path: "/",
924 })
925 http.Redirect(w, r, "/sriracha/", http.StatusFound)
926 return s.newTemplateData()
927 }
928
929 if r.URL.Path == "/sriracha/" || r.URL.Path == "/sriracha" {
930 var failedLogin bool
931 username := r.FormValue("username")
932 if len(username) != 0 {
933 failedLogin = true
934 password := r.FormValue("password")
935 if len(password) != 0 {
936 if !s.opt.DevMode {
937
938 var solved bool
939 ipHash := s.hashIP(r)
940 challenge := db.GetCAPTCHA(ipHash)
941 if challenge != nil {
942 solution := FormString(r, "captcha")
943 if strings.ToLower(solution) == challenge.Text {
944 solved = true
945 db.DeleteCAPTCHA(ipHash)
946 os.Remove(filepath.Join(s.config.Root, "captcha", challenge.Image+".png"))
947 }
948 }
949 if !solved {
950 data := s.newTemplateData()
951 data.Info = "Invalid CAPTCHA."
952 data.Template = "manage_error"
953 return data
954 }
955 }
956
957
958 account := db.LoginAccount(username, password)
959 if account != nil {
960 http.SetCookie(w, &http.Cookie{
961 Name: "sriracha_session",
962 Value: account.Session,
963 Path: "/",
964 })
965 if s.config.ImportMode {
966 http.Redirect(w, r, "/sriracha/import/", http.StatusFound)
967 }
968 data := s.newTemplateData()
969 data.Account = account
970 return data
971 }
972 }
973 }
974 if failedLogin {
975 data := s.newTemplateData()
976 data.Info = "Invalid username or password."
977 data.Template = "manage_error"
978 return data
979 }
980 }
981
982 cookies := r.CookiesNamed("sriracha_session")
983 if len(cookies) > 0 {
984 account := db.AccountBySessionKey(cookies[0].Value)
985 if account != nil {
986 data := s.newTemplateData()
987 data.Account = account
988 return data
989 }
990 }
991 return s.newTemplateData()
992 }
993
994
995 func (s *Server) writeThread(db *database.DB, board *Board, postID int) {
996 posts := db.AllPostsInThread(postID, true)
997 if len(posts) == 0 {
998 return
999 }
1000
1001 if board.Unique == 0 {
1002 board.Unique = db.UniqueUserPosts(board)
1003 }
1004
1005 f, err := os.OpenFile(filepath.Join(s.config.Root, board.Dir, "res", fmt.Sprintf("%d.html", postID)), os.O_WRONLY|os.O_CREATE|os.O_TRUNC, NewFilePermission)
1006 if err != nil {
1007 log.Fatal(err)
1008 }
1009
1010 data := s.newTemplateData()
1011 data.Board = board
1012 data.Boards = db.AllBoards()
1013 data.Threads = [][]*Post{posts}
1014 data.ReplyMode = postID
1015 data.Template = "board_page"
1016 data.execute(f)
1017 }
1018
1019
1020 func (s *Server) writeIndexes(db *database.DB, board *Board) {
1021 if board.Unique == 0 {
1022 board.Unique = db.UniqueUserPosts(board)
1023 }
1024
1025 data := s.newTemplateData()
1026 data.Board = board
1027 data.Boards = db.AllBoards()
1028 data.ReplyMode = 1
1029 data.Template = "board_catalog"
1030
1031 threadInfo := db.AllThreads(board, true)
1032
1033
1034 if board.Type == TypeImageboard {
1035 catalogFile, err := os.OpenFile(filepath.Join(s.config.Root, board.Dir, "catalog.html"), os.O_WRONLY|os.O_CREATE|os.O_TRUNC, NewFilePermission)
1036 if err != nil {
1037 log.Fatal(err)
1038 }
1039
1040 for _, info := range threadInfo {
1041 thread := db.PostByID(info[0])
1042 thread.Replies = info[1]
1043 data.Threads = append(data.Threads, []*Post{thread})
1044 }
1045 data.execute(catalogFile)
1046
1047 catalogFile.Close()
1048 }
1049
1050
1051
1052 data.ReplyMode = 0
1053 data.Template = "board_page"
1054 data.Pages = pageCount(len(threadInfo), board.Threads)
1055 for page := 0; page < data.Pages; page++ {
1056 fileName := "index.html"
1057 if page > 0 {
1058 fileName = fmt.Sprintf("%d.html", page)
1059 }
1060
1061 indexFile, err := os.OpenFile(filepath.Join(s.config.Root, board.Dir, fileName), os.O_WRONLY|os.O_CREATE|os.O_TRUNC, NewFilePermission)
1062 if err != nil {
1063 log.Fatal(err)
1064 }
1065
1066 start := page * board.Threads
1067 end := len(threadInfo)
1068 if board.Threads != 0 && end > start+board.Threads {
1069 end = start + board.Threads
1070 }
1071
1072 data.Threads = data.Threads[:0]
1073 for _, info := range threadInfo[start:end] {
1074 thread := db.PostByID(info[0])
1075 thread.Replies = info[1]
1076 posts := []*Post{thread}
1077 if board.Type == TypeImageboard {
1078 posts = append(posts, db.AllReplies(thread.ID, board.Replies, true)...)
1079 }
1080 data.Threads = append(data.Threads, posts)
1081 }
1082 data.Page = page
1083 data.execute(indexFile)
1084
1085 indexFile.Close()
1086 }
1087 }
1088
1089
1090 func (s *Server) writeOverboard(db *database.DB) {
1091 var overboardDir string
1092 if s.opt.Overboard != "/" {
1093 overboardDir = s.opt.Overboard
1094 }
1095
1096 overboard := &Board{
1097 ID: -1,
1098 Type: s.opt.OverboardType,
1099 Name: gotext.Get("Overboard"),
1100 Dir: overboardDir,
1101 Threads: s.opt.OverboardThreads,
1102 Replies: s.opt.OverboardReplies,
1103 }
1104
1105 data := s.newTemplateData()
1106 data.Board = overboard
1107 data.Boards = db.AllBoards()
1108 data.ReplyMode = 1
1109 data.Template = "board_catalog"
1110
1111 threadInfo := db.AllThreads(nil, true)
1112
1113
1114 if overboard.Type == TypeImageboard {
1115 catalogFile, err := os.OpenFile(filepath.Join(s.config.Root, overboardDir, "catalog.html"), os.O_WRONLY|os.O_CREATE|os.O_TRUNC, NewFilePermission)
1116 if err != nil {
1117 log.Fatal(err)
1118 }
1119
1120 for _, info := range threadInfo {
1121 thread := db.PostByID(info[0])
1122 thread.Replies = info[1]
1123 data.Threads = append(data.Threads, []*Post{thread})
1124 }
1125 data.execute(catalogFile)
1126
1127 catalogFile.Close()
1128 }
1129
1130
1131
1132 data.ReplyMode = 0
1133 data.Template = "board_page"
1134 data.Pages = pageCount(len(threadInfo), overboard.Threads)
1135 for page := 0; page < data.Pages; page++ {
1136 fileName := "index.html"
1137 if page > 0 {
1138 fileName = fmt.Sprintf("%d.html", page)
1139 }
1140
1141 indexFile, err := os.OpenFile(filepath.Join(s.config.Root, overboardDir, fileName), os.O_WRONLY|os.O_CREATE|os.O_TRUNC, NewFilePermission)
1142 if err != nil {
1143 log.Fatal(err)
1144 }
1145
1146 start := page * overboard.Threads
1147 end := len(threadInfo)
1148 if overboard.Threads != 0 && end > start+overboard.Threads {
1149 end = start + overboard.Threads
1150 }
1151
1152 data.Threads = data.Threads[:0]
1153 for _, info := range threadInfo[start:end] {
1154 thread := db.PostByID(info[0])
1155 thread.Replies = info[1]
1156 posts := []*Post{thread}
1157 if overboard.Type == TypeImageboard {
1158 posts = append(posts, db.AllReplies(thread.ID, overboard.Replies, true)...)
1159 }
1160 data.Threads = append(data.Threads, posts)
1161 }
1162 data.Page = page
1163 data.execute(indexFile)
1164
1165 indexFile.Close()
1166 }
1167 }
1168
1169
1170 func (s *Server) newPageTemplate(db *database.DB) *template.Template {
1171 tpl, err := s.original.Clone()
1172 if err != nil {
1173 log.Fatal(err)
1174 }
1175 return tpl.Funcs(map[string]any{
1176
1177 "BoardByID": db.BoardByID,
1178 "BoardByDir": db.BoardByDir,
1179 "UniqueUserPosts": db.UniqueUserPosts,
1180 "AllBoards": db.AllBoards,
1181
1182 "NewsByID": db.NewsByID,
1183 "AllNews": db.AllNews,
1184
1185 "AllThreads": db.AllThreads,
1186 "AllPostsInThread": db.AllPostsInThread,
1187 "AllReplies": db.AllReplies,
1188 "PendingPosts": db.PendingPosts,
1189 "PostByID": db.PostByID,
1190 "PostsByIP": db.PostsByIP,
1191 "PostsByFileHash": db.PostsByFileHash,
1192 "PostByField": db.PostByField,
1193 "LastPostByIP": db.LastPostByIP,
1194 "ReplyCount": db.ReplyCount,
1195 })
1196 }
1197
1198
1199 func (s *Server) writePages(db *database.DB, pages []*Page, dryRun bool) error {
1200 data := s.newTemplateData()
1201 data.Boards = db.AllBoards()
1202 data.Template = "page"
1203
1204 tpl := s.newPageTemplate(db)
1205 for _, p := range pages {
1206 err := p.Validate()
1207 if err != nil {
1208 if dryRun {
1209 return err
1210 }
1211 log.Printf("Warning: skipped invalid page %s: %s", p.Path, err)
1212 continue
1213 }
1214
1215 dir := filepath.Dir(p.Path)
1216 if dir != "" {
1217 dirPath := filepath.Join(s.config.Root, dir)
1218 _, err := os.Stat(dirPath)
1219 if os.IsNotExist(err) {
1220 os.MkdirAll(dirPath, NewDirPermission)
1221 }
1222 }
1223
1224 var w io.Writer
1225 var pageFile *os.File
1226 if dryRun {
1227 w = io.Discard
1228 } else {
1229 pageFile, err = os.OpenFile(filepath.Join(s.config.Root, p.Path+".html"), os.O_WRONLY|os.O_CREATE|os.O_TRUNC, NewFilePermission)
1230 if err != nil {
1231 log.Fatal(err)
1232 }
1233 w = pageFile
1234 }
1235
1236 data.tpl, err = tpl.Clone()
1237 if err != nil {
1238 log.Fatal(err)
1239 }
1240 data.tpl, err = data.tpl.New("line").Parse(p.Content)
1241 if err != nil {
1242 if dryRun {
1243 return err
1244 }
1245 log.Printf("Warning: failed to parse content of page %s: %s", p.Path, err)
1246 pageFile.Close()
1247 continue
1248 }
1249
1250 if strings.HasPrefix(p.Content, doctypePrefx) {
1251 data.Template = "line"
1252 } else {
1253 data.Template = "page"
1254 }
1255 err = data.executeWithError(w)
1256 if err != nil {
1257 if dryRun {
1258 return err
1259 }
1260 log.Printf("Warning: failed to render page %s: %s", p.Path, err)
1261 pageFile.Close()
1262 continue
1263 }
1264
1265 if !dryRun {
1266 pageFile.Close()
1267 }
1268 }
1269 return nil
1270 }
1271
1272
1273 func (s *Server) rebuildThread(db *database.DB, post *Post) {
1274 s.writeThread(db, post.Board, post.Thread())
1275 s.writeIndexes(db, post.Board)
1276 if s.opt.Overboard != "" {
1277 s.writeOverboard(db)
1278 }
1279 }
1280
1281
1282 func (s *Server) rebuildBoard(db *database.DB, board *Board) {
1283 for _, info := range db.AllThreads(board, true) {
1284 s.writeThread(db, board, info[0])
1285 }
1286 s.writeIndexes(db, board)
1287 }
1288
1289
1290 func (s *Server) rebuildAll(db *database.DB) {
1291 for _, b := range db.AllBoards() {
1292 s.rebuildBoard(db, b)
1293 }
1294
1295 if s.opt.Overboard != "" {
1296 s.writeOverboard(db)
1297 }
1298
1299 s.rebuildNews(db)
1300
1301 pages := db.AllPages()
1302 if len(pages) != 0 {
1303 s.writePages(db, pages, false)
1304 }
1305 }
1306
1307
1308 func (s *Server) writeNewsItem(db *database.DB, n *News) {
1309 if n.ID <= 0 {
1310 return
1311 }
1312
1313 data := s.newTemplateData()
1314 data.Boards = db.AllBoards()
1315 data.Template = "news"
1316 data.AllNews = []*News{n}
1317 data.Pages = 1
1318 data.Extra = "view"
1319
1320 itemFile, err := os.OpenFile(filepath.Join(s.config.Root, fmt.Sprintf("news-%d.html", n.ID)), os.O_WRONLY|os.O_CREATE|os.O_TRUNC, NewFilePermission)
1321 if err != nil {
1322 log.Fatal(err)
1323 }
1324 data.execute(itemFile)
1325 itemFile.Close()
1326 }
1327
1328
1329 func (s *Server) writeNewsIndexes(db *database.DB) {
1330 allNews := db.AllNews(true)
1331 data := s.newTemplateData()
1332 data.Boards = db.AllBoards()
1333 data.Template = "news"
1334
1335 const newsCount = 10
1336 data.Pages = pageCount(len(allNews), newsCount)
1337 for page := 0; page < data.Pages; page++ {
1338 fileName := "news.html"
1339 if s.opt.News == NewsWriteToIndex {
1340 fileName = "index.html"
1341 }
1342 if page > 0 {
1343 fileName = fmt.Sprintf("news-p%d.html", page)
1344 }
1345
1346 indexFile, err := os.OpenFile(filepath.Join(s.config.Root, fileName), os.O_WRONLY|os.O_CREATE|os.O_TRUNC, NewFilePermission)
1347 if err != nil {
1348 log.Fatal(err)
1349 }
1350
1351 start := page * newsCount
1352 end := len(allNews)
1353 if newsCount != 0 && end > start+newsCount {
1354 end = start + newsCount
1355 }
1356
1357 data.AllNews = allNews[start:end]
1358 data.Page = page
1359 data.execute(indexFile)
1360
1361 indexFile.Close()
1362 }
1363 }
1364
1365
1366 func (s *Server) rebuildNewsItem(db *database.DB, n *News) {
1367 s.writeNewsItem(db, n)
1368 s.writeNewsIndexes(db)
1369 }
1370
1371
1372 func (s *Server) rebuildNews(db *database.DB) {
1373 for _, n := range db.AllNews(true) {
1374 s.writeNewsItem(db, n)
1375 }
1376 s.writeNewsIndexes(db)
1377 }
1378
1379
1380 func (s *Server) reloadBans(db *database.DB) {
1381 var rangeBans = make(map[*Ban]*regexp.Regexp)
1382 bans := db.AllBans(true)
1383 for _, ban := range bans {
1384 pattern, err := regexp.Compile(ban.IP[2:])
1385 if err != nil {
1386 log.Printf("warning: failed to compile IP range ban `%s` as regular expression: %s", ban.IP[2:], err)
1387 return
1388 }
1389 rangeBans[ban] = pattern
1390 }
1391 s.rangeBans = rangeBans
1392 }
1393
1394
1395 func (s *Server) serveManage(db *database.DB, w http.ResponseWriter, r *http.Request) {
1396 data := s.buildData(db, w, r)
1397 if strings.HasPrefix(r.URL.Path, "/sriracha/logout") {
1398 return
1399 }
1400 var skipExecute bool
1401
1402 if len(data.Info) != 0 {
1403 data.Template = "manage_error"
1404 data.execute(w)
1405 return
1406 }
1407
1408 if strings.HasPrefix(r.URL.Path, "/sriracha/oekaki/") {
1409 postID := PathInt(r, "/sriracha/oekaki/")
1410 post := db.PostByID(postID)
1411 if post == nil || !post.IsOekaki() {
1412 data.BoardError(w, "invalid or deleted post")
1413 return
1414 }
1415
1416 data := s.buildData(db, w, r)
1417 data.Template = "oekaki"
1418 data.Message2 = template.HTML(`
1419 <script type="text/javascript">
1420 Tegaki.open({
1421 width: ` + strconv.Itoa(s.opt.OekakiWidth) + `,
1422 height: ` + strconv.Itoa(s.opt.OekakiHeight) + `,
1423 replayMode: true,
1424 replayURL: '` + post.Board.Path() + `src/` + post.File + `'
1425 });
1426 document.getElementById('tegaki-finish-btn').addEventListener('click', function(e) {
1427 window.close();
1428 return false;
1429 });
1430 </script>`)
1431 data.execute(w)
1432 return
1433 }
1434
1435 if data.Account != nil {
1436 db.UpdateAccountLastActive(data.Account.ID)
1437 }
1438
1439 data.Template = "manage_login"
1440
1441 if data.Account == nil {
1442 data.execute(w)
1443 return
1444 } else if s.config.ImportMode {
1445 if data.Account.Role != RoleSuperAdmin {
1446 data.ManageError("Sriracha is running in import mode. Only super-administrators may log in.")
1447 data.execute(w)
1448 return
1449 } else if !strings.HasPrefix(r.URL.Path, "/sriracha/import/") {
1450 http.Redirect(w, r, "/sriracha/import/", http.StatusFound)
1451 return
1452 }
1453 data.Info = "IMPORT MODE"
1454 }
1455
1456 switch {
1457 case strings.HasPrefix(r.URL.Path, "/sriracha/preference"):
1458 s.servePreference(data, db, w, r)
1459 case strings.HasPrefix(r.URL.Path, "/sriracha/account"):
1460 s.serveAccount(data, db, w, r)
1461 case strings.HasPrefix(r.URL.Path, "/sriracha/banner"):
1462 s.serveBanner(data, db, w, r)
1463 case strings.HasPrefix(r.URL.Path, "/sriracha/ban"):
1464 s.serveBan(data, db, w, r)
1465 case strings.HasPrefix(r.URL.Path, "/sriracha/board"):
1466 skipExecute = s.serveBoard(data, db, w, r)
1467 case strings.HasPrefix(r.URL.Path, "/sriracha/import"):
1468 s.serveImport(data, db, w, r)
1469 case strings.HasPrefix(r.URL.Path, "/sriracha/keyword"):
1470 s.serveKeyword(data, db, w, r)
1471 case strings.HasPrefix(r.URL.Path, "/sriracha/log"):
1472 s.serveLog(data, db, w, r)
1473 case strings.HasPrefix(r.URL.Path, "/sriracha/mod"):
1474 s.serveMod(data, db, w, r)
1475 case strings.HasPrefix(r.URL.Path, "/sriracha/news"):
1476 s.serveNews(data, db, w, r)
1477 case strings.HasPrefix(r.URL.Path, "/sriracha/page"):
1478 s.servePage(data, db, w, r)
1479 case strings.HasPrefix(r.URL.Path, "/sriracha/plugin"):
1480 s.servePlugin(data, db, w, r)
1481 case strings.HasPrefix(r.URL.Path, "/sriracha/setting"):
1482 s.serveSetting(data, db, w, r)
1483 default:
1484 s.serveStatus(data, db, w, r)
1485 }
1486
1487 if skipExecute {
1488 return
1489 }
1490 data.execute(w)
1491 }
1492
1493
1494 func (s *Server) serve(w http.ResponseWriter, r *http.Request) {
1495 s.lock.Lock()
1496 defer s.lock.Unlock()
1497
1498 if r.Method == http.MethodPost {
1499 const maxMemory = 32 << 20
1500 r.ParseMultipartForm(maxMemory)
1501
1502 var modified bool
1503 f := make(url.Values)
1504 for key, values := range r.Form {
1505 f[key] = make([]string, len(values))
1506 for i := range values {
1507 modified = true
1508 f[key][i] = strings.ReplaceAll(values[i], "\r", "")
1509 }
1510 }
1511 if modified {
1512 r.Form = f
1513 }
1514 }
1515
1516 var action string
1517 if r.URL.Path == "/sriracha/" || r.URL.Path == "/sriracha" {
1518 action = r.FormValue("action")
1519 if action == "" {
1520 values := r.URL.Query()
1521 action = values.Get("action")
1522 }
1523 } else if strings.HasPrefix(r.URL.Path, "/sriracha/captcha/") {
1524 action = "captcha"
1525 }
1526
1527 db := s.begin()
1528 defer db.Commit()
1529 var handled bool
1530
1531 db.DeleteExpiredSubscriptions()
1532
1533 if db.DeleteExpiredBans() > 0 {
1534 s.reloadBans(db)
1535 }
1536
1537
1538 ip := s.requestIP(r)
1539 for ban, pattern := range s.rangeBans {
1540 if pattern.MatchString(ip) {
1541 data := s.buildData(db, w, r)
1542 data.ManageError("You are banned. " + ban.Info() + fmt.Sprintf(" (Ban #%d)", ban.ID))
1543 data.execute(w)
1544 handled = true
1545 break
1546 }
1547 }
1548
1549
1550 if !handled {
1551 ban := db.BanByIP(s.hashIP(r))
1552 if ban != nil {
1553 data := s.buildData(db, w, r)
1554 data.ManageError("You are banned. " + ban.Info() + fmt.Sprintf(" (Ban #%d)", ban.ID))
1555 data.execute(w)
1556 handled = true
1557 } else if strings.HasPrefix(r.URL.Path, "/sriracha/post/") {
1558 postID := PathInt(r, "/sriracha/post/")
1559 post := db.PostByID(postID)
1560 if post == nil {
1561 data := s.buildData(db, w, r)
1562 data.BoardError(w, "Invalid or deleted post.")
1563 } else {
1564 http.Redirect(w, r, fmt.Sprintf("%sres/%d.html#%d", post.Board.Path(), post.Thread(), post.ID), http.StatusFound)
1565 }
1566 handled = true
1567 }
1568 }
1569
1570 if !handled {
1571 if strings.HasPrefix(r.URL.Path, "/sriracha/subscribe") {
1572 action = "subscribe"
1573 }
1574
1575 if s.config.ImportMode && action != "" {
1576 data := s.buildData(db, w, r)
1577 data.BoardError(w, "Sriracha is running in import mode. All boards are currently locked. Please wait and try again.")
1578 } else {
1579 switch action {
1580 case "post":
1581 s.servePost(db, w, r)
1582 case "report":
1583 s.serveReport(db, w, r)
1584 case "delete":
1585 s.serveDelete(db, w, r)
1586 case "captcha":
1587 s.serveCAPTCHA(db, w, r)
1588 case "subscribe":
1589 s.serveSubscribe(db, w, r)
1590 default:
1591 s.serveManage(db, w, r)
1592 }
1593 }
1594 }
1595 }
1596
1597
1598 func (s *Server) listen() error {
1599 info, err := os.Stat("static/css/futaba.css")
1600 if err != nil || info.IsDir() {
1601 return 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")
1602 }
1603
1604 mux := http.NewServeMux()
1605 mux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir("static"))))
1606 mux.HandleFunc("/sriracha/", s.serve)
1607 mux.Handle("/", http.FileServer(http.Dir(s.config.Root)))
1608
1609 fmt.Printf("Serving http://%s\n", s.config.Serve)
1610 s.httpServer = &http.Server{
1611 Addr: s.config.Serve,
1612 Handler: mux,
1613 }
1614 return s.httpServer.ListenAndServe()
1615 }
1616
1617
1618 func (s *Server) handleRebuild() {
1619 defer s.rebuildWaitGroup.Done()
1620
1621 minWait := 1 * time.Second
1622 maxWait := 10 * time.Second
1623
1624 lastBuild := time.Now()
1625
1626 var info *rebuildInfo
1627 var pending []*rebuildInfo
1628 var boards []*Board
1629 var threads []int
1630 var shutdown bool
1631 var t *time.Timer
1632 for {
1633
1634 info = <-s.rebuildQueue
1635 if info == nil {
1636 return
1637 }
1638 pending = append(pending, info)
1639 if time.Since(lastBuild) < maxWait {
1640 for {
1641
1642 t = time.NewTimer(minWait)
1643 var found bool
1644 DRAINQUEUE:
1645 for {
1646 select {
1647 case info = <-s.rebuildQueue:
1648 if info == nil {
1649 shutdown = true
1650 } else {
1651 pending = append(pending, info)
1652 found = true
1653 }
1654 case <-t.C:
1655 break DRAINQUEUE
1656 }
1657 }
1658 if !found {
1659 break
1660 }
1661
1662 if time.Since(lastBuild) >= maxWait {
1663 break
1664 }
1665 }
1666 }
1667 if shutdown && len(pending) == 0 {
1668 return
1669 }
1670
1671
1672 db := s.begin()
1673 for _, info := range pending {
1674 thread := info.post.Thread()
1675 if !slices.Contains(threads, thread) {
1676 s.writeThread(db, info.post.Board, thread)
1677 threads = append(threads, thread)
1678 }
1679 if !slices.Contains(boards, info.post.Board) {
1680 s.writeIndexes(db, info.post.Board)
1681 boards = append(boards, info.post.Board)
1682 }
1683 }
1684 if s.opt.Overboard != "" {
1685 s.writeOverboard(db)
1686 }
1687 for _, info := range pending {
1688 s.queueNotifications(db, info.post)
1689 }
1690 db.Commit()
1691
1692 for _, info := range pending {
1693 info.wg.Done()
1694 }
1695
1696 pending = pending[:0]
1697 boards = boards[:0]
1698 threads = threads[:0]
1699
1700 lastBuild = time.Now()
1701
1702 if shutdown {
1703 return
1704 }
1705 }
1706 }
1707
1708 func (s *Server) _handleSignal(signals chan os.Signal) {
1709
1710 <-signals
1711
1712 s.Stop()
1713 }
1714
1715
1716
1717 func (s *Server) startSignalHandler() {
1718 signals := make(chan os.Signal, 1)
1719 signal.Notify(signals, unix.SIGINT, unix.SIGTERM)
1720 go s._handleSignal(signals)
1721 }
1722
1723
1724 func (s *Server) Run() error {
1725 s.parseBuildInfo()
1726
1727 printInfo := func() {
1728 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")
1729 }
1730 flag.Usage = func() {
1731 fmt.Fprintf(os.Stderr, "Usage:\n sriracha [OPTION...] [PLUGIN...]\n\nOptions:\n")
1732 flag.PrintDefaults()
1733 printInfo()
1734 }
1735 var (
1736 configFile string
1737 devMode bool
1738 rebuild bool
1739 printVersion bool
1740 )
1741 flag.StringVar(&configFile, "config", "", "path to configuration file (default: ~/.config/sriracha/config.yml)")
1742 flag.BoolVar(&devMode, "dev", false, "run in development mode (watch official and custom template files for changes)")
1743 flag.BoolVar(&rebuild, "rebuild", false, "rebuild static files before serving any requests")
1744 flag.BoolVar(&printVersion, "version", false, "print version information and exit")
1745 flag.Parse()
1746
1747 if printVersion {
1748 fmt.Fprintf(os.Stderr, "Sriracha version %s\n", SrirachaVersion)
1749 printInfo()
1750 return nil
1751 }
1752
1753 s.rebuildWaitGroup.Add(1)
1754 go s.handleRebuild()
1755
1756 s.startSignalHandler()
1757
1758 if configFile == "" {
1759 homeDir, err := os.UserHomeDir()
1760 if err == nil {
1761 configFile = path.Join(homeDir, ".config", "sriracha", "config.yml")
1762 }
1763 }
1764
1765 err := s.parseConfig(configFile)
1766 if err != nil {
1767 return fmt.Errorf("failed to parse configuration %s: %s", configFile, err)
1768 }
1769 s.config.StartTime = time.Now()
1770
1771
1772 gotext.SetDomain("sriracha")
1773
1774 err = s.parseLocales()
1775 if err != nil {
1776 log.Fatalf("failed to parse locale files: %s", err)
1777 }
1778
1779 var officialDir string
1780 if devMode {
1781 s.opt.DevMode = true
1782
1783 officialDir = s.officialTemplateDir()
1784 if officialDir == "" {
1785 return fmt.Errorf("failed to locate official template directory: start sriracha in the same directory as the file README.md")
1786 }
1787
1788 err = s.validateTemplateConfig(officialDir)
1789 if err != nil {
1790 return fmt.Errorf("invalid custom template directory: %s", err)
1791 }
1792 }
1793
1794 if s.config.MailAddress != "" {
1795 s.opt.Notifications = true
1796 if !devMode {
1797 fmt.Println("Verifying mail server configuration...")
1798 client, err := s.connectToMailServer()
1799 if err != nil {
1800 log.Fatalf("failed to verify mail server configuration: %s", err)
1801 }
1802 client.Close()
1803 }
1804 }
1805
1806 s.dbPool, err = database.Connect(s.config)
1807 if err != nil {
1808 return fmt.Errorf("failed to connect to database: %s", err)
1809 }
1810
1811 err = s.setDefaultServerConfig()
1812 if err != nil {
1813 return fmt.Errorf("failed to set default server configuration: %s", err)
1814 }
1815
1816 err = s.loadPluginConfig()
1817 if err != nil {
1818 return fmt.Errorf("failed to load plugin configuration: %s", err)
1819 }
1820
1821 err = s.loadPlugins()
1822 if err != nil {
1823 return fmt.Errorf("failed to load plugins: %s", err)
1824 }
1825
1826 err = s.setDefaultPluginConfig()
1827 if err != nil {
1828 return fmt.Errorf("failed to set default plugin configuration: %s", err)
1829 }
1830
1831 err = s.parseTemplates(officialDir, s.config.Template)
1832 if err != nil {
1833 return fmt.Errorf("failed to parse template files: %s", err)
1834 }
1835
1836 if unix.Access(s.config.Root, unix.W_OK) != nil {
1837 return fmt.Errorf("configured root directory %s is not writable", s.config.Root)
1838 }
1839
1840 captchaDir := filepath.Join(s.config.Root, "captcha")
1841 _, err = os.Stat(captchaDir)
1842 if os.IsNotExist(err) {
1843 err := os.Mkdir(captchaDir, NewDirPermission)
1844 if err != nil {
1845 log.Fatalf("failed to create captcha directory: %s", err)
1846 }
1847 }
1848
1849 bannerDir := filepath.Join(s.config.Root, "banner")
1850 _, err = os.Stat(bannerDir)
1851 if os.IsNotExist(err) {
1852 err := os.Mkdir(bannerDir, NewDirPermission)
1853 if err != nil {
1854 log.Fatalf("failed to create banner directory: %s", err)
1855 }
1856 }
1857
1858 siteIndexFile := filepath.Join(s.config.Root, "index.html")
1859 _, err = os.Stat(siteIndexFile)
1860 if os.IsNotExist(err) {
1861 err = os.WriteFile(siteIndexFile, siteIndexHTML, NewFilePermission)
1862 if err != nil {
1863 log.Fatalf("failed to write site index at %s: %s", siteIndexFile, err)
1864 }
1865 }
1866
1867
1868 db := s.begin()
1869 s.refreshBannerCache(db)
1870 sv := db.GetString("sv")
1871 if sv != SrirachaVersion {
1872 if sv != "" {
1873 fmt.Printf("Upgraded from Sriracha version %s to %s, rebuilding...\n", sv, SrirachaVersion)
1874 rebuild = true
1875 }
1876 db.SaveString("sv", SrirachaVersion)
1877 }
1878 if rebuild {
1879 allPages := db.AllPages()
1880 if len(allPages) > 0 {
1881 fmt.Println("Rebuilding pages...")
1882 s.writePages(db, allPages, false)
1883 }
1884 published := len(db.AllNews(true))
1885 if published > 0 {
1886 fmt.Println("Rebuilding news...")
1887 s.rebuildNews(db)
1888 }
1889 if s.opt.Overboard != "" {
1890 fmt.Println("Rebuilding overboard...")
1891 s.writeOverboard(db)
1892 }
1893 for _, b := range db.AllBoards() {
1894 fmt.Printf("Rebuilding %s...\n", b.Path())
1895 s.rebuildBoard(db, b)
1896 }
1897 }
1898 db.Commit()
1899
1900 if devMode {
1901 dir := "directory"
1902 if s.config.Template != "" {
1903 dir = "directories"
1904 }
1905 fmt.Printf("Development mode enabled. Monitoring template %s...\n", dir)
1906 err = s.watchTemplates(officialDir)
1907 if err != nil {
1908 return fmt.Errorf("failed to watch templates for changes: %s", err)
1909 }
1910 }
1911
1912 if s.config.MailAddress != "" {
1913 s.notificationsWaitGroup.Add(1)
1914 go s.handleNotifications()
1915 }
1916
1917 err = s.listen()
1918 if err != nil {
1919 if err != http.ErrServerClosed {
1920 return err
1921 }
1922
1923 s.rebuildWaitGroup.Wait()
1924
1925 s.notificationsWaitGroup.Wait()
1926 }
1927 return nil
1928 }
1929
1930
1931 func (s *Server) hashData(data string) string {
1932 checksum := sha512.Sum384([]byte(data + s.config.SaltData))
1933 return base64.URLEncoding.EncodeToString(checksum[:])
1934 }
1935
1936
1937 func md5Sum(data string) string {
1938 return fmt.Sprintf("%x", md5.Sum([]byte(data)))
1939 }
1940
1941
1942 func parseHostname(address string) string {
1943 if address == "" {
1944 return ""
1945 }
1946 hostname, port, err := net.SplitHostPort(address)
1947 if err != nil || port == "" {
1948 return address
1949 }
1950 return hostname
1951 }
1952
1953
1954 func (s *Server) requestIP(r *http.Request) string {
1955 var address string
1956 if s.config.Header != "" {
1957 values := r.Header[s.config.Header]
1958 if len(values) > 0 {
1959 address = values[0]
1960 }
1961 } else {
1962 address = r.RemoteAddr
1963 }
1964 if address == "" {
1965 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.")
1966 }
1967 return parseHostname(address)
1968 }
1969
1970 func (s *Server) _hashIP(address string) string {
1971 if address == "" {
1972 return ""
1973 }
1974 return s.hashData(parseHostname(address))
1975 }
1976
1977
1978 func (s *Server) hashIP(r *http.Request) string {
1979 return s._hashIP(s.requestIP(r))
1980 }
1981
1982
1983 func (s *Server) imageDimensions(buf []byte) (int, int) {
1984 imgConfig, _, err := image.DecodeConfig(bytes.NewReader(buf))
1985 if err != nil {
1986 return 0, 0
1987 }
1988 return imgConfig.Width, imgConfig.Height
1989 }
1990
1991
1992 func (s *Server) Stop() {
1993 fmt.Println("Shutting down...")
1994
1995
1996 if s.httpServer != nil {
1997 s.httpServer.Shutdown(context.Background())
1998 }
1999
2000
2001 s.lock.Lock()
2002 s.rebuildLock.Lock()
2003 s.rebuildQueue <- nil
2004 s.rebuildWaitGroup.Wait()
2005
2006
2007 if s.opt.Notifications {
2008 s.shutdownNotifications <- struct{}{}
2009 }
2010
2011
2012 if s.httpServer == nil {
2013 os.Exit(0)
2014 }
2015 }
2016
2017
2018 func pluginByName(name string) (any, *pluginInfo) {
2019 name = strings.ToLower(name)
2020 for i, info := range allPluginInfo {
2021 if strings.ToLower(info.Name) == name {
2022 return allPlugins[i], info
2023 }
2024 }
2025 return nil, nil
2026 }
2027
2028
2029 func FormatValue(v interface{}) interface{} {
2030 if role, ok := v.(AccountRole); ok {
2031 return FormatRole(role)
2032 } else if t, ok := v.(BoardType); ok {
2033 return FormatBoardType(t)
2034 } else if t, ok := v.(BoardHide); ok {
2035 return FormatBoardHide(t)
2036 } else if t, ok := v.(BoardLock); ok {
2037 return FormatBoardLock(t)
2038 } else if t, ok := v.(BoardApproval); ok {
2039 return FormatBoardApproval(t)
2040 } else if t, ok := v.(BoardIdentifiers); ok {
2041 return FormatBoardIdentifiers(t)
2042 }
2043 return v
2044 }
2045
2046
2047 func printChanges(old interface{}, new interface{}) string {
2048 const mask = "***"
2049 diff, err := diff.Diff(old, new)
2050 if err != nil {
2051 log.Fatal(err)
2052 } else if len(diff) == 0 {
2053 return ""
2054 }
2055 var label string
2056 for _, change := range diff {
2057 from := change.From
2058 to := change.To
2059
2060 var name string
2061 if len(change.Path) > 0 {
2062 name = change.Path[0]
2063 if name == "Password" {
2064 from = mask
2065 to = mask
2066 }
2067 }
2068
2069 label += fmt.Sprintf(` [%s: "%v" > "%v"]`, name, FormatValue(from), FormatValue(to))
2070 }
2071 return label
2072 }
2073
2074
2075 func calculateFileHash(buf []byte) string {
2076 checksum := sha512.Sum384(buf)
2077 return base64.URLEncoding.EncodeToString(checksum[:])
2078 }
2079
2080
2081 func pageCount(items int, pageSize int) int {
2082 if items == 0 || pageSize == 0 {
2083 return 1
2084 }
2085 pages := items / pageSize
2086 if items%pageSize != 0 {
2087 pages++
2088 }
2089 return pages
2090 }
2091
2092
2093
2094 const doctypePrefx = "<!DOCTYPE html>"
2095
2096
2097 var siteIndexHTML = []byte(`
2098 <!DOCTYPE html>
2099 <html>
2100 <body>
2101 <meta http-equiv="refresh" content="0; url=/sriracha/">
2102 <a href="/sriracha/">Redirecting...</a>
2103 </body>
2104 </html>
2105 `)
2106
View as plain text