1 package server
2
3 import (
4 "bytes"
5 "encoding/json"
6 "fmt"
7 "html"
8 "html/template"
9 "image"
10 "image/gif"
11 "image/jpeg"
12 "image/png"
13 "io"
14 "log"
15 "mime/multipart"
16 "net/http"
17 "os"
18 "os/exec"
19 "path/filepath"
20 "regexp"
21 "strconv"
22 "strings"
23 "sync"
24 "time"
25
26 "codeberg.org/tslocum/sriracha/internal/database"
27 . "codeberg.org/tslocum/sriracha/model"
28 . "codeberg.org/tslocum/sriracha/util"
29 "github.com/aquilax/tripcode"
30 "github.com/gabriel-vasile/mimetype"
31 "github.com/nfnt/resize"
32 )
33
34 var postUploadFileLock = &sync.Mutex{}
35
36 type embedInfo struct {
37 Title string `json:"title"`
38 Thumb string `json:"thumbnail_url"`
39 HTML string `json:"html"`
40 }
41
42 func resizeImage(b *Board, r io.Reader, mimeType string) (image.Image, error) {
43 var img image.Image
44 var err error
45 switch mimeType {
46 case "image/jpeg", "image/pjpeg":
47 img, err = jpeg.Decode(r)
48 if err != nil {
49 return nil, fmt.Errorf("unsupported filetype")
50 }
51 case "image/gif":
52 img, err = gif.Decode(r)
53 if err != nil {
54 return nil, fmt.Errorf("unsupported filetype")
55 }
56 case "image/png":
57 img, err = png.Decode(r)
58 if err != nil {
59 return nil, fmt.Errorf("unsupported filetype")
60 }
61 }
62 return resize.Thumbnail(uint(b.ThumbWidth), uint(b.ThumbHeight), img, resize.Lanczos3), nil
63 }
64
65 func writeImage(img image.Image, mimeType string, filePath string) error {
66 file, err := os.OpenFile(filePath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, NewFilePermission)
67 if err != nil {
68 log.Fatal(err)
69 }
70 defer file.Close()
71
72 switch mimeType {
73 case "image/jpeg":
74 err = jpeg.Encode(file, img, nil)
75 if err != nil {
76 return fmt.Errorf("unsupported filetype")
77 }
78 case "image/gif":
79 err = gif.Encode(file, img, nil)
80 if err != nil {
81 return fmt.Errorf("unsupported filetype")
82 }
83 case "image/png":
84 err = png.Encode(file, img)
85 if err != nil {
86 return fmt.Errorf("unsupported filetype")
87 }
88 }
89 return nil
90 }
91
92 func createPostThumbnail(p *Post, buf []byte, mimeType string, mediaOverlay bool, thumbPath string) error {
93 thumbImg, err := resizeImage(p.Board, bytes.NewReader(buf), mimeType)
94 if err != nil {
95 return fmt.Errorf("unsupported filetype")
96 }
97
98 if mediaOverlay {
99 thumbImg = p.AddMediaOverlay(thumbImg)
100 }
101
102 bounds := thumbImg.Bounds()
103 p.ThumbWidth, p.ThumbHeight = bounds.Dx(), bounds.Dy()
104
105 err = writeImage(thumbImg, mimeType, thumbPath)
106 if err != nil {
107 return fmt.Errorf("unsupported filetype")
108 }
109 return nil
110 }
111
112 func setFileAndThumb(p *Post, fileExt string, thumbExt string) {
113 postUploadFileLock.Lock()
114 defer postUploadFileLock.Unlock()
115
116 fileID := time.Now().UnixNano()
117 fileIDString := fmt.Sprintf("%d", fileID)
118
119 if thumbExt == "" {
120 switch fileExt {
121 case "jpg", "png", "gif":
122 thumbExt = fileExt
123 case "svg":
124 thumbExt = "png"
125 default:
126 thumbExt = "jpg"
127 }
128 }
129
130 p.File = fileIDString + "." + fileExt
131 p.Thumb = fileIDString + "s." + thumbExt
132 }
133
134 func setPostFileAttributes(p *Post, buf []byte) error {
135 p.FileHash = calculateFileHash(buf)
136
137 p.FileSize = int64(len(buf))
138 return nil
139 }
140
141 func (s *Server) loadPostForm(db *database.DB, r *http.Request, p *Post) error {
142 limitString := func(v string, limit int) string {
143 if len(v) > limit {
144 return v[:limit]
145 }
146 return v
147 }
148
149 p.Parent = FormInt(r, "parent")
150 p.Password = FormString(r, "password")
151
152 p.Name = limitString(FormString(r, "name"), p.Board.MaxName)
153 p.Email = limitString(FormString(r, "email"), p.Board.MaxEmail)
154 p.Subject = limitString(FormString(r, "subject"), p.Board.MaxSubject)
155 p.Message = html.EscapeString(limitString(FormString(r, "message"), p.Board.MaxMessage))
156
157 if len(p.Name) < p.Board.MinName {
158 if p.Board.MinName == 1 {
159 return fmt.Errorf("please enter a name")
160 } else {
161 return fmt.Errorf("name too short: must be at least %d characters in length", p.Board.MinName)
162 }
163 }
164 if len(p.Email) < p.Board.MinEmail {
165 if p.Board.MinEmail == 1 {
166 return fmt.Errorf("please enter an email")
167 } else {
168 return fmt.Errorf("email too short: must be at least %d characters in length", p.Board.MinEmail)
169 }
170 }
171 if len(p.Subject) < p.Board.MinSubject && (p.Board.Type == TypeImageboard || p.Parent == 0) {
172 if p.Board.MinSubject == 1 {
173 return fmt.Errorf("please enter a subject")
174 } else {
175 return fmt.Errorf("subject too short: must be at least %d characters in length", p.Board.MinSubject)
176 }
177 }
178 if len(p.Message) < p.Board.MinMessage {
179 if p.Board.MinMessage == 1 {
180 return fmt.Errorf("please enter a message")
181 } else {
182 return fmt.Errorf("message too short: must be at least %d characters in length", p.Board.MinMessage)
183 }
184 }
185
186 if strings.ContainsRune(p.Name, '#') {
187 split := strings.SplitN(p.Name, "#", 3)
188
189 p.Name = split[0]
190 standardPass := split[1]
191 var securePass string
192 if len(split) == 3 {
193 securePass = split[2]
194 }
195
196 if standardPass != "" {
197 p.Tripcode = tripcode.Tripcode(standardPass)
198 }
199 if securePass != "" {
200 if standardPass != "" {
201 p.Tripcode += "!"
202 }
203 p.Tripcode += "!" + tripcode.SecureTripcode(securePass, s.config.SaltTrip)
204 }
205 }
206
207 if p.Parent != 0 && p.Board.Type == TypeForum {
208 p.Subject = ""
209 }
210 return nil
211 }
212
213 func (s *Server) loadPostFiles(r *http.Request, p *Post) ([]*multipart.FileHeader, error) {
214 if r.PostForm == nil {
215 const maxMemory = 32 << 20
216 err := r.ParseMultipartForm(maxMemory)
217 if err != nil {
218 return nil, err
219 }
220 }
221 if r.MultipartForm == nil || r.MultipartForm.File == nil {
222 return nil, nil
223 }
224 files := r.MultipartForm.File["file"]
225 if len(files) > p.Board.Files {
226 if p.Board.Files == 0 {
227 return nil, fmt.Errorf("file attachments are not allowed")
228 }
229 return nil, fmt.Errorf("too many files: only %d files may be uploaded at once", p.Board.Files)
230 }
231 return files, nil
232 }
233
234 func (s *Server) loadPostFile(db *database.DB, r *http.Request, p *Post, fileHeader *multipart.FileHeader) error {
235 minSize := p.Board.MinSizeThread
236 maxSize := p.Board.MaxSizeThread
237 if p.Parent != 0 {
238 minSize = p.Board.MinSizeReply
239 maxSize = p.Board.MaxSizeReply
240 }
241
242 if maxSize == 0 {
243 return nil
244 } else if minSize > 0 && fileHeader.Size < minSize {
245 if minSize == 1 {
246 if len(p.Board.Embeds) == 0 {
247 return fmt.Errorf("a file is required")
248 }
249 return fmt.Errorf("a file or embed is required")
250 }
251 return fmt.Errorf("a file %s or larger is required", FormatFileSize(minSize))
252 } else if fileHeader.Size > maxSize {
253 return fmt.Errorf("file too large: maximum file size allowed is %s", FormatFileSize(maxSize))
254 }
255
256 formFile, err := fileHeader.Open()
257 if err != nil {
258 return err
259 }
260 defer formFile.Close()
261
262 buf, err := io.ReadAll(formFile)
263 if err != nil {
264 return err
265 }
266
267 if int64(len(buf)) < minSize {
268 if minSize == 1 {
269 if len(p.Board.Embeds) == 0 {
270 return fmt.Errorf("a file is required")
271 } else {
272 return fmt.Errorf("a file or embed is required")
273 }
274 } else {
275 return fmt.Errorf("a file %s or larger is required", FormatFileSize(minSize))
276 }
277 } else if int64(len(buf)) > maxSize {
278 return fmt.Errorf("that file exceeds the maximum file size: %s", FormatFileSize(maxSize))
279 }
280
281 p.FileMIME = mimetype.Detect(buf).String()
282 p.FileOriginal = fileHeader.Filename
283
284 oekakiPost := p.Board.Oekaki && p.FileMIME == "application/octet-stream" && len(buf) >= 3 && buf[0] == 0x54 && buf[1] == 0x47 && buf[2] == 0x4B
285 if oekakiPost {
286 p.FileMIME = "application/x-tegaki"
287 }
288
289 var fileExt string
290 var fileThumb string
291 if p.Board.HasUpload(p.FileMIME) {
292 for _, u := range s.config.UploadTypes() {
293 if u.MIME == p.FileMIME {
294 fileExt = u.Ext
295 fileThumb = u.Thumb
296 break
297 }
298 }
299 }
300 if fileExt == "" {
301 if oekakiPost {
302 fileExt = "tgkr"
303 } else {
304 for _, info := range allPluginAttachHandlers {
305 db.Plugin = info.Name
306 handled, err := info.Handler(db, p, buf)
307 if err != nil {
308 db.Plugin = ""
309 return err
310 } else if handled {
311 db.Plugin = ""
312 return nil
313 }
314 }
315 db.Plugin = ""
316 return fmt.Errorf("unsupported filetype")
317 }
318 }
319
320 var thumbExt string
321 var thumbData []byte
322 if fileThumb != "" && fileThumb != "none" {
323 thumbData, err = os.ReadFile("static/img/" + fileThumb)
324 if err != nil {
325 log.Fatalf("failed to open thumbnail file %s: %s", fileThumb, err)
326 }
327
328 thumbExt = MIMEToExt(mimetype.Detect(thumbData).String())
329 }
330
331 setFileAndThumb(p, fileExt, thumbExt)
332
333 err = setPostFileAttributes(p, buf)
334 if err != nil {
335 return err
336 }
337 if oekakiPost && FormBool(r, "oekaki") {
338 p.FileOriginal = FormString(r, "title")
339 }
340
341 srcPath := filepath.Join(s.config.Root, p.Board.Dir, "src", p.File)
342 thumbPath := filepath.Join(s.config.Root, p.Board.Dir, "thumb", p.Thumb)
343
344 err = os.WriteFile(srcPath, buf, NewFilePermission)
345 if err != nil {
346 log.Fatal(err)
347 }
348
349 if oekakiPost {
350 formThumb, formThumbHeader, err := r.FormFile("thumb")
351 if err != nil || formThumbHeader == nil || formThumbHeader.Size < minSize {
352 return fmt.Errorf("a thumbnail is required")
353 }
354
355 buf, err := io.ReadAll(formThumb)
356 if err != nil {
357 log.Fatal(err)
358 }
359
360 imgWidth, imgHeight := s.imageDimensions(buf)
361 if imgWidth == 0 || imgHeight == 0 {
362 return fmt.Errorf("unsupported thumbnail filetype")
363 }
364 p.FileWidth, p.FileHeight = imgWidth, imgHeight
365
366 return createPostThumbnail(p, buf, "image/png", false, thumbPath)
367 }
368
369 if fileThumb == "none" {
370 p.Thumb = ""
371 return nil
372 } else if fileThumb != "" {
373 return createPostThumbnail(p, thumbData, mimetype.Detect(thumbData).String(), false, thumbPath)
374 }
375
376 isImage := p.FileMIME == "image/jpeg" || p.FileMIME == "image/pjpeg" || p.FileMIME == "image/png" || p.FileMIME == "image/gif"
377 if isImage {
378 imgWidth, imgHeight := s.imageDimensions(buf)
379 if imgWidth == 0 || imgHeight == 0 {
380 return fmt.Errorf("unsupported filetype")
381 }
382 p.FileWidth, p.FileHeight = imgWidth, imgHeight
383
384 return createPostThumbnail(p, buf, p.FileMIME, false, thumbPath)
385 }
386
387 ffmpegThumbnail := strings.HasPrefix(p.FileMIME, "image/") || strings.HasPrefix(p.FileMIME, "video/")
388 if !ffmpegThumbnail {
389 p.Thumb = ""
390 return nil
391 }
392
393 cmd := exec.Command("ffprobe", "-hide_banner", "-loglevel", "error", "-of", "csv=p=0", "-select_streams", "v", "-show_entries", "stream=width,height", srcPath)
394 out, err := cmd.Output()
395 if err != nil {
396 return fmt.Errorf("failed to create thumbnail: %s", err)
397 }
398 split := bytes.Split(bytes.TrimSpace(out), []byte(","))
399 if len(split) >= 2 {
400 p.FileWidth, p.FileHeight = ParseInt(string(split[0])), ParseInt(string(split[1]))
401 }
402
403 quarterDuration := "0"
404 cmd = exec.Command("ffprobe", "-hide_banner", "-loglevel", "error", "-of", "csv=p=0", "-show_entries", "format=duration", srcPath)
405 out, err = cmd.Output()
406 if err == nil {
407 v, err := strconv.ParseFloat(string(bytes.TrimSpace(out)), 64)
408 if err == nil {
409 quarterDuration = fmt.Sprintf("%f", v/4)
410 }
411 }
412
413 cmd = exec.Command("ffmpeg", "-hide_banner", "-loglevel", "error", "-ss", quarterDuration, "-i", srcPath, "-frames:v", "1", "-vf", fmt.Sprintf("scale=w=%d:h=%d:force_original_aspect_ratio=decrease", p.Board.ThumbWidth, p.Board.ThumbHeight), thumbPath)
414 _, err = cmd.Output()
415 if err != nil {
416 return fmt.Errorf("failed to create thumbnail: %s", err)
417 }
418
419 cmd = exec.Command("ffprobe", "-hide_banner", "-loglevel", "error", "-of", "csv=p=0", "-select_streams", "v", "-show_entries", "stream=width,height", thumbPath)
420 out, err = cmd.Output()
421 if err == nil {
422 split := bytes.Split(bytes.TrimSpace(out), []byte(","))
423 if len(split) >= 2 {
424 p.ThumbWidth, p.ThumbHeight = ParseInt(string(split[0])), ParseInt(string(split[1]))
425
426 if strings.HasPrefix(p.FileMIME, "video/") {
427 thumbData, err := os.ReadFile(thumbPath)
428 if err != nil {
429 log.Fatal(err)
430 }
431
432 err = createPostThumbnail(p, thumbData, "image/jpeg", true, thumbPath)
433 if err != nil {
434 log.Fatal(err)
435 }
436 }
437 }
438 }
439 return nil
440 }
441
442 func (s *Server) checkDuplicateFileHash(db *database.DB, post *Post) *Post {
443 if post.FileHash == "" || post.Board.Instances == 0 {
444 return nil
445 }
446 var allowed int
447 var filterBoard *Board
448 if post.Board.Instances > 0 {
449 allowed = post.Board.Instances
450 } else {
451 allowed = -post.Board.Instances
452 filterBoard = post.Board
453 }
454 matches := db.PostsByFileHash(post.FileHash, filterBoard)
455 if len(matches) >= allowed {
456 return matches[0]
457 }
458 return nil
459 }
460
461 func (s *Server) servePost(db *database.DB, w http.ResponseWriter, r *http.Request) {
462 if r.Method != http.MethodPost {
463 http.Error(w, "invalid request", http.StatusInternalServerError)
464 return
465 }
466
467 boardDir := FormString(r, "board")
468 b := db.BoardByDir(boardDir)
469 if b == nil {
470 data := s.buildData(db, w, r)
471 data.BoardError(w, Get(b, data.Account, "No board specified."))
472 return
473 }
474
475 var (
476 rawHTML bool
477 staffPost bool
478 staffCapcode string
479 )
480 data := s.buildData(db, w, r)
481 if data.Account != nil {
482 staffPost = FormString(r, "capcode") != ""
483 if staffPost {
484 capcode := FormInt(r, "capcode")
485 if capcode < 0 || capcode > 2 || (data.Account.Role == RoleMod && capcode == 2) {
486 capcode = 0
487 }
488 switch capcode {
489 case 1:
490 staffCapcode = "Mod"
491 case 2:
492 staffCapcode = "Admin"
493 }
494
495 rawHTML = FormBool(r, "raw")
496 }
497 }
498
499 switch b.Lock {
500 case LockPost:
501 if !staffPost {
502 data := s.buildData(db, w, r)
503 data.BoardError(w, Get(b, data.Account, "Board locked. No new posts may be created."))
504 return
505 }
506 case LockStaff:
507 data := s.buildData(db, w, r)
508 data.BoardError(w, Get(b, data.Account, "Board locked. No new posts may be created."))
509 return
510 }
511
512 now := time.Now().Unix()
513 post := &Post{
514 Board: b,
515 Timestamp: now,
516 Bumped: now,
517 Moderated: 1,
518 }
519
520 post.IP = s.hashIP(r)
521
522 if b.Delay != 0 {
523 lastPost := db.LastPostByIP(post.Board, post.IP)
524 if lastPost != nil {
525 nextPost := lastPost.Timestamp + int64(b.Delay)
526 if time.Now().Unix() < nextPost {
527 waitTime := time.Until(time.Unix(nextPost, 0))
528 data := s.buildData(db, w, r)
529 data.BoardError(w, Get(b, data.Account, "Please wait %s before creating a new post.", waitTime))
530 return
531 }
532 }
533 }
534
535 err := s.loadPostForm(db, r, post)
536 if err != nil {
537 s.deletePostFiles(post)
538
539 data := s.buildData(db, w, r)
540 data.BoardError(w, err.Error())
541 return
542 }
543
544 var parentPost *Post
545 if post.Parent != 0 {
546 parentPost = db.PostByID(post.Parent)
547 if parentPost == nil || parentPost.Parent != 0 {
548 s.deletePostFiles(post)
549
550 data := s.buildData(db, w, r)
551 data.BoardError(w, Get(b, data.Account, "No post selected."))
552 return
553 }
554 }
555
556 oekakiPost := b.Oekaki && FormBool(r, "oekaki")
557
558 var solvedCAPTCHA *CAPTCHA
559 if !staffPost {
560 if b.Lock == LockThread && parentPost == nil {
561 s.deletePostFiles(post)
562
563 data := s.buildData(db, w, r)
564 data.BoardError(w, Get(b, data.Account, "You may only reply to threads."))
565 return
566 }
567 if s.opt.CAPTCHA {
568 expired := db.ExpiredCAPTCHAs()
569 for _, c := range expired {
570 db.DeleteCAPTCHA(c.IP)
571 os.Remove(filepath.Join(s.config.Root, "captcha", c.Image+".png"))
572 }
573
574 challenge := db.GetCAPTCHA(post.IP)
575 if challenge != nil {
576 solution := FormString(r, "captcha")
577 if strings.ToLower(solution) == challenge.Text {
578 solvedCAPTCHA = challenge
579 }
580 }
581 if solvedCAPTCHA == nil {
582 s.deletePostFiles(post)
583
584 data := s.buildData(db, w, r)
585 data.BoardError(w, Get(b, data.Account, "Incorrect CAPTCHA text. Please try again."))
586 return
587 }
588 }
589 }
590
591 files, err := s.loadPostFiles(r, post)
592 if err != nil {
593 data := s.buildData(db, w, r)
594 data.BoardError(w, err.Error())
595 return
596 }
597
598 if oekakiPost && len(files) == 0 {
599 data := s.buildData(db, w, r)
600 data.Template = "oekaki"
601 for key, values := range r.Form {
602 if len(values) == 0 {
603 continue
604 }
605 data.Message += template.HTML(fmt.Sprintf(`<input type="hidden" name="%s" value="%s">`+"\n", html.EscapeString(key), html.EscapeString(values[0])))
606 }
607 data.Message2 = template.HTML(`
608 <script type="text/javascript">
609 Tegaki.open({
610 width: ` + strconv.Itoa(s.opt.OekakiWidth) + `,
611 height: ` + strconv.Itoa(s.opt.OekakiHeight) + `,
612 saveReplay: true,
613 onDone: onDone,
614 onCancel: onCancel
615 });
616 </script>`)
617 data.execute(w)
618 return
619 }
620 if solvedCAPTCHA != nil {
621 db.DeleteCAPTCHA(post.IP)
622 os.Remove(filepath.Join(s.config.Root, "captcha", solvedCAPTCHA.Image+".png"))
623 }
624
625 if post.File == "" && len(b.Embeds) > 0 {
626 embed := FormString(r, "embed")
627 if embed != "" {
628 for _, embedName := range b.Embeds {
629 var embedURL string
630 for _, info := range s.opt.Embeds {
631 if info[0] == embedName {
632 embedURL = info[1]
633 break
634 }
635 }
636 if embedURL == "" {
637 continue
638 }
639
640 resp, err := http.Get(strings.ReplaceAll(embedURL, "SRIRACHA_EMBED", embed))
641 if err != nil {
642 continue
643 }
644 defer resp.Body.Close()
645
646 info := &embedInfo{}
647 err = json.NewDecoder(resp.Body).Decode(&info)
648 if err != nil || info.Title == "" || info.Thumb == "" || info.HTML == "" || !strings.HasPrefix(info.Thumb, "https://") {
649 continue
650 }
651
652 thumbResp, err := http.Get(info.Thumb)
653 if err != nil {
654 continue
655 }
656 defer thumbResp.Body.Close()
657
658 buf, err := io.ReadAll(thumbResp.Body)
659 if err != nil {
660 continue
661 }
662
663 mimeType := mimetype.Detect(buf).String()
664
665 fileExt := MIMEToExt(mimeType)
666 if fileExt == "" {
667 continue
668 }
669
670 thumbName := fmt.Sprintf("%d.%s", time.Now().UnixNano(), fileExt)
671 thumbPath := filepath.Join(s.config.Root, b.Dir, "thumb", thumbName)
672
673 err = createPostThumbnail(post, buf, mimeType, true, thumbPath)
674 if err != nil {
675 continue
676 }
677
678 post.FileHash = "e " + embedName + " " + info.Title
679 post.FileOriginal = embed
680 post.File = info.HTML
681 post.Thumb = thumbName
682 break
683 }
684
685 if post.File == "" {
686 data := s.buildData(db, w, r)
687 data.BoardError(w, Get(b, data.Account, "Failed to embed media."))
688 return
689 }
690 }
691 }
692
693 var remainingFiles []*multipart.FileHeader
694 if post.File == "" && len(files) > 0 {
695 err = s.loadPostFile(db, r, post, files[0])
696 if err != nil {
697 data := s.buildData(db, w, r)
698 data.BoardError(w, err.Error())
699 return
700 } else if len(files) > 1 {
701 remainingFiles = files[1:]
702 }
703
704 }
705
706 duplicate := s.checkDuplicateFileHash(db, post)
707 if duplicate != nil {
708 var postLink string
709 if duplicate.Moderated != ModeratedHidden {
710 postLink = fmt.Sprintf(` <a href="%sres/%d.html#%d">here</a>`, duplicate.Board.Path(), duplicate.Thread(), duplicate.ID)
711 }
712
713 var uploadType = "file"
714 if post.IsEmbed() {
715 uploadType = "embed"
716 }
717
718 data := s.buildData(db, w, r)
719 data.Template = "board_error"
720 data.Info = fmt.Sprintf("Duplicate %s uploaded.", uploadType)
721 data.Message = template.HTML(fmt.Sprintf(`<div style="text-align: center;">That %s has already been posted%s.</div><br>`, uploadType, postLink))
722 data.execute(w)
723 return
724 }
725
726 if rawHTML {
727 post.Message = html.UnescapeString(post.Message)
728 }
729
730 var addReport bool
731 if !staffPost {
732 if parentPost != nil && parentPost.Locked {
733 data := s.buildData(db, w, r)
734 data.BoardError(w, Get(b, data.Account, "That thread is locked."))
735 return
736 }
737
738 for _, keyword := range db.AllKeywords() {
739 if !keyword.HasBoard(b.ID) {
740 continue
741 }
742 rgxp, err := regexp.Compile(keyword.Text)
743 if err != nil {
744 s.deletePostFiles(post)
745 log.Fatalf("failed to compile regexp %s: %s", keyword.Text, err)
746 }
747 if rgxp.MatchString(post.Name) || rgxp.MatchString(post.Email) || rgxp.MatchString(post.Subject) || rgxp.MatchString(post.Message) {
748 var action string
749 var banExpire int64
750 switch keyword.Action {
751 case "hide":
752 action = "hide"
753 case "report":
754 action = "report"
755 case "delete":
756 action = "delete"
757 case "ban1h":
758 action = "ban"
759 banExpire = time.Now().Add(1 * time.Hour).Unix()
760 case "ban1d":
761 action = "ban"
762 banExpire = time.Now().Add(24 * time.Hour).Unix()
763 case "ban2d":
764 action = "ban"
765 banExpire = time.Now().Add(2 * 24 * time.Hour).Unix()
766 case "ban1w":
767 action = "ban"
768 banExpire = time.Now().Add(7 * 24 * time.Hour).Unix()
769 case "ban2w":
770 action = "ban"
771 banExpire = time.Now().Add(14 * 24 * time.Hour).Unix()
772 case "ban1m":
773 action = "ban"
774 banExpire = time.Now().Add(28 * 24 * time.Hour).Unix()
775 case "ban0":
776 action = "ban"
777 default:
778 s.deletePostFiles(post)
779 log.Fatalf("unknown keyword action: %s", keyword.Action)
780 }
781
782 switch action {
783 case "hide":
784 post.Moderated = 0
785 case "report":
786 addReport = true
787 case "ban":
788 existing := db.BanByIP(post.IP)
789 if existing == nil {
790 ban := &Ban{
791 IP: post.IP,
792 Timestamp: time.Now().Unix(),
793 Expire: banExpire,
794 Reason: Get(b, data.Account, "Detected banned keyword."),
795 }
796 db.AddBan(ban)
797
798 s.log(db, nil, nil, fmt.Sprintf("Added >>/ban/%d", ban.ID), ban.Info()+fmt.Sprintf(" Detected >>/keyword/%d", keyword.ID))
799 }
800 }
801
802 if action == "delete" || action == "ban" {
803 s.deletePostFiles(post)
804
805 data := s.buildData(db, w, r)
806 data.BoardError(w, Get(b, data.Account, "Detected banned keyword."))
807 return
808 }
809 }
810 }
811
812 if post.FileHash != "" && db.FileBanned(post.FileHash) {
813 ban := &Ban{
814 IP: post.IP,
815 Timestamp: time.Now().Unix(),
816 Reason: Get(b, data.Account, "Detected banned file."),
817 }
818 db.AddBan(ban)
819
820 s.log(db, nil, nil, fmt.Sprintf("Added >>/ban/%d", ban.ID), ban.Info())
821 s.deletePostFiles(post)
822
823 data := s.buildData(db, w, r)
824 data.BoardError(w, Get(b, data.Account, "Detected banned file."))
825 return
826 }
827 }
828
829 if !rawHTML {
830 if post.Board.WordBreak != 0 {
831 pattern, err := regexp.Compile(`[^\s]{` + strconv.Itoa(post.Board.WordBreak) + `,}`)
832 if err != nil {
833 log.Fatal(err)
834 }
835
836 buf := &strings.Builder{}
837 post.Message = pattern.ReplaceAllStringFunc(post.Message, func(s string) string {
838 buf.Reset()
839 for i, r := range s {
840 if i != 0 && i%post.Board.WordBreak == 0 {
841 buf.WriteRune('\n')
842 }
843 buf.WriteRune(r)
844 }
845 return buf.String()
846 })
847 }
848
849 for _, info := range allPluginPostHandlers {
850 db.Plugin = info.Name
851 err := info.Handler(db, post)
852 if err != nil {
853 s.deletePostFiles(post)
854
855 data := s.buildData(db, w, r)
856 data.BoardError(w, err.Error())
857 return
858 }
859 post.Message = strings.ReplaceAll(post.Message, "<br>", "\n")
860 }
861 db.Plugin = ""
862
863 var foundURL bool
864 post.Message = URLPattern.ReplaceAllStringFunc(post.Message, func(s string) string {
865 foundURL = true
866 match := URLPattern.FindStringSubmatch(s)
867 return fmt.Sprintf(`<a href="%s" target="_blank">%s</a>`, match[1], match[1])
868 })
869 if foundURL {
870 post.Message = FixURLPattern1.ReplaceAllString(post.Message, `(<a href="$1" target="_blank">$2</a>)`)
871 post.Message = FixURLPattern2.ReplaceAllString(post.Message, `<a href="$1" target="_blank">$2</a>.`)
872 post.Message = FixURLPattern3.ReplaceAllString(post.Message, `<a href="$1" target="_blank">$2</a>,`)
873 }
874
875 post.Message = RefLinkPattern.ReplaceAllStringFunc(post.Message, func(s string) string {
876 postID, err := strconv.Atoi(s[8:])
877 if err != nil || postID <= 0 {
878 return s
879 }
880 refPost := db.PostByID(postID)
881 if refPost == nil {
882 return s
883 }
884 className := "refop"
885 if refPost.Parent != 0 {
886 className = "refreply"
887 }
888 return fmt.Sprintf(`<a href="%sres/%d.html#%d" class="%s">%s</a>`, refPost.Board.Path(), refPost.Thread(), refPost.ID, className, s)
889 })
890
891 var allBoards []*Board
892 post.Message = BoardLinkPattern.ReplaceAllStringFunc(post.Message, func(s string) string {
893 if allBoards == nil {
894 allBoards = db.AllBoards()
895 }
896 path := strings.TrimSuffix(strings.TrimPrefix(s[12:], "/"), "/")
897 for _, b := range allBoards {
898 if b.Dir == path {
899 return fmt.Sprintf(`<a href="%s">>>>%s</a>`, b.Path(), b.Path())
900 }
901 }
902 return s
903 })
904
905 var quote bool
906 lines := strings.Split(post.Message, "\n")
907 for i := range lines {
908 lines[i] = QuotePattern.ReplaceAllStringFunc(lines[i], func(s string) string {
909 quote = true
910 return `<span class="unkfunc">` + s + `</span>`
911 })
912 }
913 if quote {
914 post.Message = strings.Join(lines, "\n")
915 }
916 }
917
918 if strings.TrimSpace(post.Message) == "" && post.File == "" {
919 maxSize := post.Board.MaxSizeThread
920 if post.Parent != 0 {
921 maxSize = post.Board.MaxSizeReply
922 }
923 var options []string
924 if maxSize != 0 {
925 options = append(options, "upload a file")
926 }
927 if len(post.Board.Embeds) != 0 {
928 options = append(options, "enter an embed URL")
929 }
930 if post.Board.MaxMessage != 0 {
931 options = append(options, "enter a message")
932 }
933 buf := &strings.Builder{}
934 for i, o := range options {
935 if i > 0 {
936 if i == len(options)-1 {
937 buf.WriteString(" or ")
938 } else {
939 buf.WriteString(", ")
940 }
941 }
942 buf.WriteString(o)
943 }
944 data := s.buildData(db, w, r)
945 data.BoardError(w, fmt.Sprintf("Please %s.", buf.String()))
946 return
947 }
948
949 post.SetNameBlock(b.DefaultName, staffCapcode, s.opt.Identifiers)
950
951 if !rawHTML {
952 newLineSentinel := "\x85"
953 post.Message = strings.ReplaceAll(post.Message, "\n", "<br>\n")
954 post.Message = strings.ReplaceAll(post.Message, newLineSentinel, "\n")
955 bracketSentinel := "\x1e"
956 post.Message = strings.ReplaceAll(post.Message, bracketSentinel, "[")
957 }
958
959 if post.Password != "" {
960 post.Password = s.hashData(post.Password)
961 }
962
963 if !staffPost && (b.Approval == ApprovalAll || (b.Approval == ApprovalFile && post.File != "")) {
964 post.Moderated = 0
965 }
966
967 postCopy := post.Copy()
968 for _, info := range allPluginInsertHandlers {
969 db.Plugin = info.Name
970 err := info.Handler(db, postCopy)
971 if err != nil {
972 s.deletePostFiles(post)
973
974 data := s.buildData(db, w, r)
975 data.BoardError(w, err.Error())
976 return
977 }
978 }
979 db.Plugin = ""
980
981 db.AddPost(post)
982
983 posts := []*Post{post}
984 cancel := func() {
985 for _, p := range posts {
986 s.deletePostFiles(p)
987 }
988 db.SoftRollBack()
989 }
990 for _, fileHeader := range remainingFiles {
991 p := post.Copy()
992 p.ID = 0
993 if post.Parent == 0 {
994 p.Parent = post.ID
995 }
996 p.Subject = ""
997 p.Message = ""
998 p.ResetAttachment()
999
1000 err = s.loadPostFile(db, r, p, fileHeader)
1001 if err != nil {
1002 cancel()
1003
1004 data := s.buildData(db, w, r)
1005 data.BoardError(w, err.Error())
1006 return
1007 }
1008
1009 duplicate := s.checkDuplicateFileHash(db, p)
1010 if duplicate != nil {
1011 cancel()
1012
1013 var postLink string
1014 if duplicate.Moderated != ModeratedHidden {
1015 postLink = fmt.Sprintf(` <a href="%sres/%d.html#%d">here</a>`, duplicate.Board.Path(), duplicate.Thread(), duplicate.ID)
1016 }
1017
1018 var uploadType = "file"
1019 if p.IsEmbed() {
1020 uploadType = "embed"
1021 }
1022
1023 data := s.buildData(db, w, r)
1024 data.Template = "board_error"
1025 data.Info = fmt.Sprintf("Duplicate %s uploaded.", uploadType)
1026 data.Message = template.HTML(fmt.Sprintf(`<div style="text-align: center;">That %s has already been posted%s.</div><br>`, uploadType, postLink))
1027 data.execute(w)
1028 return
1029 }
1030
1031 db.AddPost(p)
1032 posts = append(posts, p)
1033 }
1034
1035 postCopy = post.Copy()
1036 for _, info := range allPluginCreateHandlers {
1037 db.Plugin = info.Name
1038 err := info.Handler(db, postCopy)
1039 if err != nil {
1040 cancel()
1041
1042 log.Fatalf("plugin %s failed to process create event: %s", info.Name, err)
1043 }
1044 }
1045 db.Plugin = ""
1046
1047 if post.Moderated == ModeratedHidden {
1048 data.Template = "board_info"
1049 data.Info = Get(b, data.Account, "Your post will be shown once it has been approved.")
1050 data.execute(w)
1051 return
1052 } else if addReport {
1053 report := &Report{
1054 Board: b,
1055 Post: post,
1056 Timestamp: time.Now().Unix(),
1057 IP: s.hashIP(r),
1058 }
1059 db.AddReport(report)
1060 }
1061
1062 if post.Parent == 0 {
1063 for _, thread := range db.TrimThreads(post.Board) {
1064 s.deletePost(db, thread)
1065 }
1066 } else if strings.ToLower(post.Email) != "sage" {
1067 bump := post.Board.MaxReplies == 0 || db.ReplyCount(post.Parent) <= post.Board.MaxReplies
1068 if bump {
1069 db.BumpThread(post.Parent, now)
1070 }
1071 }
1072
1073 s.rebuildLock.Lock()
1074 db.Commit()
1075
1076 wg := &sync.WaitGroup{}
1077 wg.Add(1)
1078
1079 s.lock.Unlock()
1080 s.rebuildQueue <- &rebuildInfo{post: post, wg: wg}
1081 s.rebuildLock.Unlock()
1082
1083 wg.Wait()
1084
1085 redir := fmt.Sprintf("%sres/%d.html#%d", b.Path(), post.Thread(), post.ID)
1086 http.Redirect(w, r, redir, http.StatusFound)
1087
1088 s.lock.Lock()
1089 }
1090
View as plain text