Validation work

This commit is contained in:
Dominic Ferrando
2025-04-22 20:25:26 -04:00
parent 024280e96d
commit 0c657ae1c5
8 changed files with 225 additions and 134 deletions
+12 -3
View File
@@ -25,16 +25,25 @@ const THREAD_COOLDOWN = 2 * time.Minute
// const THREAD_COOLDOWN = 0 * 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() CooldownMutex.Lock()
defer CooldownMutex.Unlock() defer CooldownMutex.Unlock()
last, exists := m[ip] last, exists := m[ip]
if !exists || time.Since(last) >= duration { if !exists || time.Since(last) >= duration {
m[ip] = time.Now() m[ip] = time.Now()
return time.Duration(0)
} }
return duration - time.Since(last)
} }
func GetIP(r *http.Request) string { func GetIP(r *http.Request) string {
+52 -15
View File
@@ -12,6 +12,7 @@ import (
"os/exec" "os/exec"
"path" "path"
"path/filepath" "path/filepath"
"slices"
"strings" "strings"
"github.com/disintegration/imaging" "github.com/disintegration/imaging"
@@ -19,11 +20,26 @@ import (
// 10 MB memory limit // 10 MB memory limit
const FILE_MEM_LIMIT int64 = 10 << 20 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_FULL_PATH = "web/static/media/posts/full"
const POST_MEDIA_THUMB_PATH = "web/static/media/posts/thumb" const POST_MEDIA_THUMB_PATH = "web/static/media/posts/thumb"
const MAX_THREAD_COUNT = 50 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 { func EnrichPost(body string) string {
var b strings.Builder var b strings.Builder
@@ -62,27 +78,32 @@ func EnrichPost(body string) string {
return b.String() return b.String()
} }
func IsFileVideo(file multipart.File) (bool, error) { func DetectPostFileType(file multipart.File) (PostMediaType, error) {
buffer := make([]byte, 512) buffer := make([]byte, 512)
_, err := file.Read(buffer) n, err := file.Read(buffer)
if err != nil { if err != nil && err != io.EOF {
return false, err return PostFileUnsupported, err
}
_, err = file.Seek(0, 0)
if err != nil {
return false, 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) { func SavePostFile(file multipart.File, fileName string) (error, string, string) {
isFileVideo, err := IsFileVideo(file) mediaType, err := DetectPostFileType(file)
if err != nil { if err != nil {
return err, "", "" return err, "", ""
} }
file.Seek(0, io.SeekStart)
fileExt := strings.ToLower(filepath.Ext(fileName)) fileExt := strings.ToLower(filepath.Ext(fileName))
// FULL // FULL
@@ -116,7 +137,7 @@ func SavePostFile(file multipart.File, fileName string) (error, string, string)
} }
var thumbFileName string var thumbFileName string
if isFileVideo { if mediaType == PostFileVideo {
fileNameNoExt := strings.TrimSuffix(fileName, fileExt) fileNameNoExt := strings.TrimSuffix(fileName, fileExt)
thumbFileName = fileNameNoExt + ".jpg" thumbFileName = fileNameNoExt + ".jpg"
@@ -188,10 +209,12 @@ func GetPostFileInfo(mediaPath string) PostFileInfo {
defer file.Close() defer file.Close()
// read file type // read file type
isFileVideo, _ := IsFileVideo(file) mediaType, _ := DetectPostFileType(file)
file.Seek(0, io.SeekStart)
result.Size = fileInfo.Size() result.Size = fileInfo.Size()
if !isFileVideo { if mediaType != PostFileVideo {
cfg, _, err := image.DecodeConfig(file) cfg, _, err := image.DecodeConfig(file)
if err != nil { if err != nil {
log.Printf("Failed to decode image at %s: %v", mediaPath, err) 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) 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
}
+82 -19
View File
@@ -8,7 +8,6 @@ import (
"log" "log"
"net/http" "net/http"
"path/filepath" "path/filepath"
"slices"
"strconv" "strconv"
"strings" "strings"
"time" "time"
@@ -131,23 +130,42 @@ func main() {
slug := chi.URLParam(r, "slug") slug := chi.URLParam(r, "slug")
ip := util.GetIP(r) 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 { 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) io.Copy(io.Discard, r.Body)
http.Error(w, response, http.StatusTooManyRequests) http.Error(w, response, http.StatusTooManyRequests)
return return
} }
// parse form
r.Body = http.MaxBytesReader(w, r.Body, util.MAX_REQUEST_BYTES)
if err := r.ParseMultipartForm(util.FILE_MEM_LIMIT); err != nil { if err := r.ParseMultipartForm(util.FILE_MEM_LIMIT); err != nil {
http.Error(w, "Failed to parse form", http.StatusBadRequest) http.Error(w, "Failed to parse form", http.StatusBadRequest)
log.Printf("ParseMultipartForm: %v", err) log.Printf("ParseMultipartForm: %v", err)
return return
} }
subject := r.FormValue("subject") // validate inputs
subject := strings.ReplaceAll(strings.TrimSpace(r.FormValue("subject")), "\n", "")
body := strings.TrimSpace(r.FormValue("body")) 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") file, header, err := r.FormFile("file")
if err != nil { if err != nil {
http.Error(w, "Failed to retrive file from form", http.StatusBadRequest) http.Error(w, "Failed to retrive file from form", http.StatusBadRequest)
@@ -156,16 +174,32 @@ func main() {
} }
defer file.Close() 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 { if err != nil {
http.Error(w, "Failed to detect if file is a video", http.StatusInternalServerError) http.Error(w, "Failed to detect if file is a video", http.StatusInternalServerError)
return return
} }
file.Seek(0, io.SeekStart)
fileExt := strings.ToLower(filepath.Ext(header.Filename)) if mediaType == util.PostFileUnsupported {
http.Error(w, "Unsupported media type", http.StatusBadRequest)
if isFileVideo && !slices.Contains(util.SUPPORTED_VID_FORMATS, fileExt) {
http.Error(w, "Unsupported file format", http.StatusBadRequest)
return return
} }
@@ -182,6 +216,8 @@ func main() {
log.Printf("PutThread: %v", err) log.Printf("PutThread: %v", err)
return return
} }
util.BeginCooldown(ip, util.ThreadCooldowns, util.THREAD_COOLDOWN)
}) })
// CREATE POST // CREATE POST
@@ -189,26 +225,35 @@ func main() {
slug := chi.URLParam(r, "slug") slug := chi.URLParam(r, "slug")
ip := util.GetIP(r) 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 { 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) io.Copy(io.Discard, r.Body)
http.Error(w, response, http.StatusTooManyRequests) http.Error(w, response, http.StatusTooManyRequests)
return return
} }
// parse form
r.Body = http.MaxBytesReader(w, r.Body, util.MAX_REQUEST_BYTES)
if err := r.ParseMultipartForm(util.FILE_MEM_LIMIT); err != nil { if err := r.ParseMultipartForm(util.FILE_MEM_LIMIT); err != nil {
http.Error(w, "Failed to parse form", http.StatusBadRequest) http.Error(w, "Failed to parse form", http.StatusBadRequest)
log.Printf("ParseMultipartForm: %v", err) log.Printf("ParseMultipartForm: %v", err)
return return
} }
// validate inputs
body := strings.TrimSpace(r.FormValue("body")) body := strings.TrimSpace(r.FormValue("body"))
mediaPath := "" mediaPath := ""
thumbPath := "" thumbPath := ""
if body == "" { 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 return
} }
@@ -222,21 +267,36 @@ func main() {
} else { } else {
defer file.Close() defer file.Close()
// file type if header.Size > util.FILE_MEM_LIMIT {
isFileVideo, err := util.IsFileVideo(file) 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 { if err != nil {
http.Error(w, "Failed to detect if file is a video", http.StatusInternalServerError) http.Error(w, "Failed to detect if file is a video", http.StatusInternalServerError)
return return
} }
file.Seek(0, io.SeekStart)
fileExt := strings.ToLower(filepath.Ext(header.Filename)) if mediaType == util.PostFileUnsupported {
http.Error(w, "Unsupported media type", http.StatusBadRequest)
if isFileVideo && !slices.Contains(util.SUPPORTED_VID_FORMATS, fileExt) {
http.Error(w, "Unsupported file format", http.StatusBadRequest)
return 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) err, savedMediaPath, savedThumbPath := util.SavePostFile(file, filename)
if err != nil { if err != nil {
http.Error(w, "Failed to save file", http.StatusInternalServerError) http.Error(w, "Failed to save file", http.StatusInternalServerError)
@@ -247,6 +307,7 @@ func main() {
thumbPath = savedThumbPath thumbPath = savedThumbPath
} }
// put post into DB
threadIdStr := chi.URLParam(r, "threadId") threadIdStr := chi.URLParam(r, "threadId")
threadId, err := strconv.Atoi(threadIdStr) threadId, err := strconv.Atoi(threadIdStr)
if err != nil { if err != nil {
@@ -259,6 +320,8 @@ func main() {
log.Printf("PutPost: %v", err) log.Printf("PutPost: %v", err)
return return
} }
util.BeginCooldown(ip, util.PostCooldowns, util.POST_COOLDOWN)
}) })
// ----------------- // -----------------
+1 -2
View File
@@ -132,8 +132,7 @@ body {
margin: auto; margin: auto;
} }
#newPostForm button, #newPostForm button {
#newThreadForm button {
display: block; display: block;
margin-left: auto; margin-left: auto;
} }
+5
View File
@@ -13,6 +13,11 @@ function isOffScreen(el) {
); );
} }
function isHttpWarningStatus(code) {
const warningStatuses = [429, 400, 413];
return warningStatuses.includes(code);
}
function insertAfter(referenceNode, newNode) { function insertAfter(referenceNode, newNode) {
referenceNode.parentNode.insertBefore(newNode, referenceNode.nextSibling); referenceNode.parentNode.insertBefore(newNode, referenceNode.nextSibling);
} }
+7 -55
View File
@@ -8,58 +8,6 @@ import (
"strconv" "strconv"
) )
templ NewThreadForm(board database.Board) {
<div style="display: none;" id="newPostWarning" class="warning"></div>
<form
hx-encoding="multipart/form-data"
style="display: none;"
id="newThreadForm"
hx-swap="none"
hx-post={ fmt.Sprintf("/%s/threads", board.Slug) }
_="
on htmx:beforeSend toggle @disabled on <button/> until htmx:afterRequest
on htmx:afterRequest
if event.detail.xhr.status is not 429
hide me
then show #newThreadBtn
then set #newPostBody.value to ''
then set #newPostFile.value to ''
then set #newPostSubject.value to ''
then hide #newPostWarning
then trigger refreshThreads on body
else
show #newPostWarning
then put event.detail.xhr.responseText into #newPostWarning
end"
>
<table>
<tbody>
<tr class="new-post-form-field">
<th>Subject</th>
<td><input id="newPostSubject" name="subject"/></td>
</tr>
<tr class="new-post-form-field">
<th>Comment</th>
<td><textarea id="newPostBody" name="body" required></textarea></td>
</tr>
<tr class="new-post-form-field">
<th>File</th>
<td>
<input
accept="image/*,video/mp4,video/webm,video/ogg"
id="newPostFile"
name="file"
type="file"
required
/>
</td>
</tr>
</tbody>
</table>
<button type="submit">Submit</button>
</form>
}
templ BoardHeader(board database.Board) { templ BoardHeader(board database.Board) {
<header class="board-header"> <header class="board-header">
<img src={ fmt.Sprintf("/static/media/banners/%s.png", board.Slug) }/> <img src={ fmt.Sprintf("/static/media/banners/%s.png", board.Slug) }/>
@@ -74,8 +22,12 @@ templ Board(board database.Board) {
@shared.Layout() { @shared.Layout() {
@BoardHeader(board) @BoardHeader(board)
<div class="new-post-container"> <div class="new-post-container">
<button _="on click hide me then show #newThreadForm" id="newThreadBtn">[New Thread]</button> <button
@NewThreadForm(board) _="on load hide #newPostForm
on click hide me then show #newPostForm"
id="newThreadBtn"
>[New Thread]</button>
@shared.NewPostForm(board, fmt.Sprintf("/%s/threads", board.Slug), true)
</div> </div>
<hr/> <hr/>
<div id="boardActionBar"> <div id="boardActionBar">
@@ -89,7 +41,7 @@ templ Board(board database.Board) {
<hr/> <hr/>
<div <div
hx-get={ fmt.Sprintf("/hx/%s/catalog", board.Slug) } hx-get={ fmt.Sprintf("/hx/%s/catalog", board.Slug) }
hx-trigger="load, refreshThreads from:body" hx-trigger="load, refreshPosts from:body"
_="on htmx:afterRequest _="on htmx:afterRequest
call resizeCatalogPreviewImgs() call resizeCatalogPreviewImgs()
then call applyCatalogSearch()" then call applyCatalogSearch()"
+65
View File
@@ -0,0 +1,65 @@
package shared
import "github.com/dominicf2001/comfychan/internal/database"
import "strings"
import "github.com/dominicf2001/comfychan/internal/util"
import "strconv"
templ NewPostForm(board database.Board, endpoint string, isForThread bool) {
<div style="display: none;" id="newPostWarning" class="warning"></div>
<form
hx-encoding="multipart/form-data"
id="newPostForm"
hx-swap="none"
hx-post={ endpoint }
_="
on htmx:beforeRequest toggle @disabled on <button/> until htmx:afterRequest
on htmx:afterRequest
if isHttpWarningStatus(event.detail.xhr.status)
show #newPostWarning
put event.detail.xhr.responseText into #newPostWarning
else
hide #newPostWarning
set #newPostBody.value to ''
set #newPostFile.value to ''
set #newPostSubject.value to ''
trigger refreshPosts on body
end
"
>
<table>
{{ acceptedMimeTypes := strings.Join(util.SUPPORTED_IMAGE_MIME_TYPES, ",") + "," + strings.Join(util.SUPPORTED_VIDEO_MIME_TYPES, ",") }}
<tbody>
<tr
if !isForThread {
style="display: none;"
}
class="new-post-form-field"
>
<th>Subject</th>
<td><input maxlength={ strconv.Itoa(util.MAX_SUBJECT_LEN) } id="newPostSubject" name="subject"/></td>
</tr>
<tr class="new-post-form-field">
<th>Comment</th>
<td><textarea maxlength={ strconv.Itoa(util.MAX_BODY_LEN) } id="newPostBody" name="body" required></textarea></td>
</tr>
<tr class="new-post-form-field">
<th>File</th>
<td>
<input
accept={ acceptedMimeTypes }
id="newPostFile"
name="file"
type="file"
if isForThread {
required
}
/>
</td>
</tr>
</tbody>
</table>
<button type="submit">Submit</button>
</form>
}
+1 -40
View File
@@ -117,46 +117,7 @@ templ Thread(board database.Board, thread database.Thread, posts []database.Post
<script src="/static/thread.js" defer></script> <script src="/static/thread.js" defer></script>
@BoardHeader(board) @BoardHeader(board)
<div class="new-post-container"> <div class="new-post-container">
<div style="display: none;" id="newPostWarning" class="warning"></div> @shared.NewPostForm(board, fmt.Sprintf("/%s/threads/%d", board.Slug, thread.Id), false)
<form
hx-encoding="multipart/form-data"
id="newPostForm"
hx-target="#newPostWarning"
hx-post={ fmt.Sprintf("/%s/threads/%d", board.Slug, thread.Id) }
_="
on htmx:beforeSend toggle @disabled on <button/> until htmx:afterRequest
on htmx:afterRequest
if event.detail.xhr.status is not 429
set #newPostBody.value to ''
then set #newPostFile.value to ''
then hide #newPostWarning
then trigger refreshPosts on body
else
show #newPostWarning
then put event.detail.xhr.responseText into #newPostWarning
end"
>
<table>
<tbody>
<tr class="new-post-form-field">
<th>comment</th>
<td><textarea id="newPostBody" name="body" required></textarea></td>
</tr>
<tr class="new-post-form-field">
<th>File</th>
<td>
<input
accept="image/*,video/mp4,video/webm,video/ogg"
id="newPostFile"
name="file"
type="file"
/>
</td>
</tr>
</tbody>
</table>
<button type="submit">Submit</button>
</form>
</div> </div>
<hr/> <hr/>
<div style="margin-left: 25px;"> <div style="margin-left: 25px;">