...

Source file src/codeberg.org/tslocum/sriracha/internal/server/server_post.go

Documentation: codeberg.org/tslocum/sriracha/internal/server

     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 // 32 MB
   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)) // This should be rounded to the nearest second. Oh well.
   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">&gt;&gt;&gt;%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" // Next line (NEL) character
   953  		post.Message = strings.ReplaceAll(post.Message, "\n", "<br>\n")
   954  		post.Message = strings.ReplaceAll(post.Message, newLineSentinel, "\n")
   955  		bracketSentinel := "\x1e" // Record separator
   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