diff --git a/internal/util/cooldowns.go b/internal/util/cooldowns.go index e1576f4..afdb6a8 100644 --- a/internal/util/cooldowns.go +++ b/internal/util/cooldowns.go @@ -25,16 +25,25 @@ const THREAD_COOLDOWN = 2 * time.Minute // const THREAD_COOLDOWN = 0 * time.Minute -func IsOnCooldown(ip string, m map[string]time.Time, duration time.Duration) time.Duration { +func GetRemainingCooldown(ip string, m map[string]time.Time, duration time.Duration) time.Duration { + CooldownMutex.Lock() + defer CooldownMutex.Unlock() + + last, exists := m[ip] + if !exists { + return 0 + } + return duration - time.Since(last) +} + +func BeginCooldown(ip string, m map[string]time.Time, duration time.Duration) { CooldownMutex.Lock() defer CooldownMutex.Unlock() last, exists := m[ip] if !exists || time.Since(last) >= duration { m[ip] = time.Now() - return time.Duration(0) } - return duration - time.Since(last) } func GetIP(r *http.Request) string { diff --git a/internal/util/posts.go b/internal/util/posts.go index 74a2c4d..9c3e8d1 100644 --- a/internal/util/posts.go +++ b/internal/util/posts.go @@ -12,6 +12,7 @@ import ( "os/exec" "path" "path/filepath" + "slices" "strings" "github.com/disintegration/imaging" @@ -19,11 +20,26 @@ import ( // 10 MB memory limit const FILE_MEM_LIMIT int64 = 10 << 20 +const MAX_REQUEST_BYTES int64 = FILE_MEM_LIMIT + (1 << 20) + const POST_MEDIA_FULL_PATH = "web/static/media/posts/full" const POST_MEDIA_THUMB_PATH = "web/static/media/posts/thumb" + const MAX_THREAD_COUNT = 50 -var SUPPORTED_VID_FORMATS = []string{".mp4", ".webm", ".ogg"} +const MAX_BODY_LEN = 3000 +const MAX_SUBJECT_LEN = 100 + +var SUPPORTED_IMAGE_MIME_TYPES = []string{"image/jpeg", "image/png", "image/gif", "image/webp"} +var SUPPORTED_VIDEO_MIME_TYPES = []string{"video/webm", "video/mp4", "video/ogg"} + +type PostMediaType int64 + +const ( + PostFileImage PostMediaType = iota + PostFileVideo + PostFileUnsupported +) func EnrichPost(body string) string { var b strings.Builder @@ -62,27 +78,32 @@ func EnrichPost(body string) string { return b.String() } -func IsFileVideo(file multipart.File) (bool, error) { +func DetectPostFileType(file multipart.File) (PostMediaType, error) { buffer := make([]byte, 512) - _, err := file.Read(buffer) - if err != nil { - return false, err - } - _, err = file.Seek(0, 0) - if err != nil { - return false, err + n, err := file.Read(buffer) + if err != nil && err != io.EOF { + return PostFileUnsupported, err } - fileType := http.DetectContentType(buffer) + fileType := http.DetectContentType(buffer[:n]) - return strings.HasPrefix(fileType, "video/"), nil + switch { + case slices.Contains(SUPPORTED_IMAGE_MIME_TYPES, fileType): + return PostFileImage, nil + case slices.Contains(SUPPORTED_VIDEO_MIME_TYPES, fileType): + return PostFileVideo, nil + default: + return PostFileUnsupported, nil + } } func SavePostFile(file multipart.File, fileName string) (error, string, string) { - isFileVideo, err := IsFileVideo(file) + mediaType, err := DetectPostFileType(file) if err != nil { return err, "", "" } + file.Seek(0, io.SeekStart) + fileExt := strings.ToLower(filepath.Ext(fileName)) // FULL @@ -116,7 +137,7 @@ func SavePostFile(file multipart.File, fileName string) (error, string, string) } var thumbFileName string - if isFileVideo { + if mediaType == PostFileVideo { fileNameNoExt := strings.TrimSuffix(fileName, fileExt) thumbFileName = fileNameNoExt + ".jpg" @@ -188,10 +209,12 @@ func GetPostFileInfo(mediaPath string) PostFileInfo { defer file.Close() // read file type - isFileVideo, _ := IsFileVideo(file) + mediaType, _ := DetectPostFileType(file) + file.Seek(0, io.SeekStart) + result.Size = fileInfo.Size() - if !isFileVideo { + if mediaType != PostFileVideo { cfg, _, err := image.DecodeConfig(file) if err != nil { log.Printf("Failed to decode image at %s: %v", mediaPath, err) @@ -238,3 +261,17 @@ func FormatFileInfo(fileInfo PostFileInfo) string { } return fmt.Sprintf("(%s, %dx%d)", humanSize, fileInfo.Width, fileInfo.Height) } + +func IsMediaTooLarge(file multipart.File) (bool, error) { + limitedReader := io.LimitReader(file, FILE_MEM_LIMIT+1) + fileBytes, err := io.ReadAll(limitedReader) + if err != nil { + return true, err + } + + if int64(len(fileBytes)) > FILE_MEM_LIMIT { + return true, nil + } + + return false, nil +} diff --git a/web/main.go b/web/main.go index 69805fb..2ed93bd 100644 --- a/web/main.go +++ b/web/main.go @@ -8,7 +8,6 @@ import ( "log" "net/http" "path/filepath" - "slices" "strconv" "strings" "time" @@ -131,23 +130,42 @@ func main() { slug := chi.URLParam(r, "slug") ip := util.GetIP(r) - timeRemaining := util.IsOnCooldown(ip, util.ThreadCooldowns, util.THREAD_COOLDOWN) + // check cooldown + timeRemaining := util.GetRemainingCooldown(ip, util.ThreadCooldowns, util.THREAD_COOLDOWN) if timeRemaining > 0 { - response := fmt.Sprintf("Please wait %.0f seconds.", timeRemaining.Seconds()) + response := fmt.Sprintf("Please wait %.0f seconds", timeRemaining.Seconds()) io.Copy(io.Discard, r.Body) http.Error(w, response, http.StatusTooManyRequests) return } + // parse form + r.Body = http.MaxBytesReader(w, r.Body, util.MAX_REQUEST_BYTES) if err := r.ParseMultipartForm(util.FILE_MEM_LIMIT); err != nil { http.Error(w, "Failed to parse form", http.StatusBadRequest) log.Printf("ParseMultipartForm: %v", err) return } - subject := r.FormValue("subject") + // validate inputs + subject := strings.ReplaceAll(strings.TrimSpace(r.FormValue("subject")), "\n", "") body := strings.TrimSpace(r.FormValue("body")) + if len(subject) > util.MAX_SUBJECT_LEN { + http.Error(w, fmt.Sprintf("Subject exceeds %d characters", util.MAX_BODY_LEN), http.StatusBadRequest) + return + } + + if body == "" { + http.Error(w, "Body is empty", http.StatusBadRequest) + return + } + + if len(body) > util.MAX_BODY_LEN { + http.Error(w, fmt.Sprintf("Body exceeds %d characters", util.MAX_BODY_LEN), http.StatusBadRequest) + return + } + file, header, err := r.FormFile("file") if err != nil { http.Error(w, "Failed to retrive file from form", http.StatusBadRequest) @@ -156,16 +174,32 @@ func main() { } defer file.Close() - isFileVideo, err := util.IsFileVideo(file) + if header.Size > util.FILE_MEM_LIMIT { + http.Error(w, "File too large (max 10 MB)", http.StatusRequestEntityTooLarge) + return + } + + isMediaTooLarge, err := util.IsMediaTooLarge(file) + if err != nil { + http.Error(w, "Failed to detect if file is too large", http.StatusInternalServerError) + return + } + file.Seek(0, io.SeekStart) + + if isMediaTooLarge { + http.Error(w, "File too large (max 10MB)", http.StatusRequestEntityTooLarge) + return + } + + mediaType, err := util.DetectPostFileType(file) if err != nil { http.Error(w, "Failed to detect if file is a video", http.StatusInternalServerError) return } + file.Seek(0, io.SeekStart) - fileExt := strings.ToLower(filepath.Ext(header.Filename)) - - if isFileVideo && !slices.Contains(util.SUPPORTED_VID_FORMATS, fileExt) { - http.Error(w, "Unsupported file format", http.StatusBadRequest) + if mediaType == util.PostFileUnsupported { + http.Error(w, "Unsupported media type", http.StatusBadRequest) return } @@ -182,6 +216,8 @@ func main() { log.Printf("PutThread: %v", err) return } + + util.BeginCooldown(ip, util.ThreadCooldowns, util.THREAD_COOLDOWN) }) // CREATE POST @@ -189,26 +225,35 @@ func main() { slug := chi.URLParam(r, "slug") ip := util.GetIP(r) - timeRemaining := util.IsOnCooldown(ip, util.PostCooldowns, util.POST_COOLDOWN) + // check cooldown + timeRemaining := util.GetRemainingCooldown(ip, util.PostCooldowns, util.POST_COOLDOWN) if timeRemaining > 0 { - response := fmt.Sprintf("Please wait %.0f seconds.", timeRemaining.Seconds()) + response := fmt.Sprintf("Please wait %.0f seconds", timeRemaining.Seconds()) io.Copy(io.Discard, r.Body) http.Error(w, response, http.StatusTooManyRequests) return } + // parse form + r.Body = http.MaxBytesReader(w, r.Body, util.MAX_REQUEST_BYTES) if err := r.ParseMultipartForm(util.FILE_MEM_LIMIT); err != nil { http.Error(w, "Failed to parse form", http.StatusBadRequest) log.Printf("ParseMultipartForm: %v", err) return } + // validate inputs body := strings.TrimSpace(r.FormValue("body")) mediaPath := "" thumbPath := "" if body == "" { - http.Error(w, "Malformed post body", http.StatusBadRequest) + http.Error(w, "Body is empty", http.StatusBadRequest) + return + } + + if len(body) > util.MAX_BODY_LEN { + http.Error(w, fmt.Sprintf("Body exceeds %d characters", util.MAX_BODY_LEN), http.StatusBadRequest) return } @@ -222,21 +267,36 @@ func main() { } else { defer file.Close() - // file type - isFileVideo, err := util.IsFileVideo(file) + if header.Size > util.FILE_MEM_LIMIT { + http.Error(w, "File too large (max 10 MB)", http.StatusRequestEntityTooLarge) + return + } + + isMediaTooLarge, err := util.IsMediaTooLarge(file) + if err != nil { + http.Error(w, "Failed to detect if file is too large", http.StatusInternalServerError) + return + } + file.Seek(0, io.SeekStart) + + if isMediaTooLarge { + http.Error(w, "File too large (max 5MB)", http.StatusRequestEntityTooLarge) + return + } + + mediaType, err := util.DetectPostFileType(file) if err != nil { http.Error(w, "Failed to detect if file is a video", http.StatusInternalServerError) return } + file.Seek(0, io.SeekStart) - fileExt := strings.ToLower(filepath.Ext(header.Filename)) - - if isFileVideo && !slices.Contains(util.SUPPORTED_VID_FORMATS, fileExt) { - http.Error(w, "Unsupported file format", http.StatusBadRequest) + if mediaType == util.PostFileUnsupported { + http.Error(w, "Unsupported media type", http.StatusBadRequest) return } - filename := strconv.FormatInt(time.Now().UnixNano(), 10) + fileExt + filename := strconv.FormatInt(time.Now().UnixNano(), 10) + filepath.Ext(header.Filename) err, savedMediaPath, savedThumbPath := util.SavePostFile(file, filename) if err != nil { http.Error(w, "Failed to save file", http.StatusInternalServerError) @@ -247,6 +307,7 @@ func main() { thumbPath = savedThumbPath } + // put post into DB threadIdStr := chi.URLParam(r, "threadId") threadId, err := strconv.Atoi(threadIdStr) if err != nil { @@ -259,6 +320,8 @@ func main() { log.Printf("PutPost: %v", err) return } + + util.BeginCooldown(ip, util.PostCooldowns, util.POST_COOLDOWN) }) // ----------------- diff --git a/web/static/index.css b/web/static/index.css index b0ea980..2d3b3b3 100644 --- a/web/static/index.css +++ b/web/static/index.css @@ -132,8 +132,7 @@ body { margin: auto; } -#newPostForm button, -#newThreadForm button { +#newPostForm button { display: block; margin-left: auto; } diff --git a/web/static/index.js b/web/static/index.js index 7cdb15a..e2daebb 100644 --- a/web/static/index.js +++ b/web/static/index.js @@ -13,6 +13,11 @@ function isOffScreen(el) { ); } +function isHttpWarningStatus(code) { + const warningStatuses = [429, 400, 413]; + return warningStatuses.includes(code); +} + function insertAfter(referenceNode, newNode) { referenceNode.parentNode.insertBefore(newNode, referenceNode.nextSibling); } diff --git a/web/views/board.templ b/web/views/board.templ index dae5aa0..7206840 100644 --- a/web/views/board.templ +++ b/web/views/board.templ @@ -8,58 +8,6 @@ import ( "strconv" ) -templ NewThreadForm(board database.Board) { - - -} - templ BoardHeader(board database.Board) {
@@ -74,8 +22,12 @@ templ Board(board database.Board) { @shared.Layout() { @BoardHeader(board)
- - @NewThreadForm(board) + + @shared.NewPostForm(board, fmt.Sprintf("/%s/threads", board.Slug), true)

@@ -89,7 +41,7 @@ templ Board(board database.Board) {
+
+ + {{ acceptedMimeTypes := strings.Join(util.SUPPORTED_IMAGE_MIME_TYPES, ",") + "," + strings.Join(util.SUPPORTED_VIDEO_MIME_TYPES, ",") }} + + + + + + + + + + + + + + +
Comment
File + +
+ +
+} diff --git a/web/views/thread.templ b/web/views/thread.templ index a65af4a..db1d91c 100644 --- a/web/views/thread.templ +++ b/web/views/thread.templ @@ -117,46 +117,7 @@ templ Thread(board database.Board, thread database.Thread, posts []database.Post @BoardHeader(board)
- -
- - - - - - - - - - - -
comment
File - -
- -
+ @shared.NewPostForm(board, fmt.Sprintf("/%s/threads/%d", board.Slug, thread.Id), false)