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