...

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/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  		// Check whether file already exists.
   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  		// Check whether thumbnail already exists.
   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 // 32 MB
   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)) // This should be rounded to the nearest second. Oh well.
   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  				// YouTube returns a low quality 4:3 thumbnail by default.
   690  				// Replace with high quality 16:9 thumbnail when available.
   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  				// Fetch thumbnail.
   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  					// Retry using backup thumbnail URL.
   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  			// Keyword matched. Parse action.
   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  			// Apply action.
   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">&gt;&gt;&gt;%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  	// Replace sentinels with characters.
  1050  	if !rawHTML {
  1051  		newLineSentinel := "\x85" // Next line (NEL) character
  1052  		post.Message = strings.ReplaceAll(post.Message, "\n", "<br>\n")
  1053  		post.Message = strings.ReplaceAll(post.Message, newLineSentinel, "\n")
  1054  		bracketSentinel := "\x1e" // Record separator
  1055  		post.Message = strings.ReplaceAll(post.Message, bracketSentinel, "[")
  1056  	}
  1057  
  1058  	// Replace XHTML line break tags added by plugins or by staff posting raw HTML.
  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