From ae66403cea794f4961757f2797a0b6a48bda95ce Mon Sep 17 00:00:00 2001 From: Dominic Ferrando Date: Sat, 19 Apr 2025 21:50:19 -0400 Subject: [PATCH] Implement cooldowns --- internal/util/cooldowns.go | 45 ++++++++++++++++ internal/util/posts.go | 104 +++++++++++++++++++++++++++++++++++++ internal/util/util.go | 44 ---------------- web/main.go | 78 ++++++---------------------- web/static/index.css | 12 ++++- web/views/board.templ | 23 +++++--- web/views/thread.templ | 14 +++-- 7 files changed, 204 insertions(+), 116 deletions(-) create mode 100644 internal/util/cooldowns.go create mode 100644 internal/util/posts.go delete mode 100644 internal/util/util.go diff --git a/internal/util/cooldowns.go b/internal/util/cooldowns.go new file mode 100644 index 0000000..ba78d89 --- /dev/null +++ b/internal/util/cooldowns.go @@ -0,0 +1,45 @@ +package util + +import ( + "net" + "net/http" + "strings" + "sync" + "time" +) + +var ( + PostCooldowns = make(map[string]time.Time) + ThreadCooldowns = make(map[string]time.Time) +) + +var cooldownMutex sync.Mutex + +const POST_COOLDOWN = 15 * time.Second +const THREAD_COOLDOWN = 2 * time.Minute + +func IsOnCooldown(ip string, m map[string]time.Time, duration time.Duration) bool { + cooldownMutex.Lock() + defer cooldownMutex.Unlock() + + last, exists := m[ip] + if !exists || time.Since(last) >= duration { + m[ip] = time.Now() + return false + } + return true +} + +func GetIP(r *http.Request) string { + if xff := r.Header.Get("X-Forwarded-For"); xff != "" { + parts := strings.Split(xff, ",") + return strings.TrimSpace(parts[0]) + } + + // Fallback to RemoteAddr + ip, _, err := net.SplitHostPort(r.RemoteAddr) + if err != nil { + return r.RemoteAddr + } + return ip +} diff --git a/internal/util/posts.go b/internal/util/posts.go new file mode 100644 index 0000000..6f74d56 --- /dev/null +++ b/internal/util/posts.go @@ -0,0 +1,104 @@ +package util + +import ( + "fmt" + "html/template" + "image" + "io" + "log" + "mime/multipart" + "os" + "path/filepath" + "strings" + + "github.com/disintegration/imaging" +) + +// 10 MB memory limit +const FILE_MEM_LIMIT int64 = 10 << 20 +const POST_IMG_FULL_PATH = "web/static/img/posts/full" +const POST_IMG_THUMB_PATH = "web/static/img/posts/thumb" + +func EnrichPost(body string) string { + var b strings.Builder + for _, rawLine := range strings.Split(body, "\n") { + var outLine string + + for i, rawWord := range strings.Split(rawLine, " ") { + var outWord string + if strings.HasPrefix(rawWord, ">>") { + postId := strings.TrimPrefix(rawWord, ">>") + esc := template.HTMLEscapeString(rawWord) + outWord = fmt.Sprintf( + `%s`, + postId, esc, + ) + } else { + outWord = template.HTMLEscapeString(rawWord) + } + + if i != 0 { + outLine += " " + } + outLine += outWord + } + + if strings.HasPrefix(rawLine, ">") && !strings.HasPrefix(rawLine, ">>") { + outLine = `` + outLine + `` + } + + b.WriteString(outLine) + b.WriteString("
") + } + return b.String() +} + +func SavePostFile(file *multipart.File, filename string) error { + dstPathFull := filepath.Join(POST_IMG_FULL_PATH, filename) + dstPathThumb := filepath.Join(POST_IMG_THUMB_PATH, filename) + + // FULL + if err := os.MkdirAll(POST_IMG_FULL_PATH, 0755); err != nil { + log.Printf("MkdirAll (full): %v", err) + return err + } + + dstFull, err := os.Create(dstPathFull) + if err != nil { + log.Printf("os.Create (full): %v", err) + return err + } + defer dstFull.Close() + if _, err := io.Copy(dstFull, *file); err != nil { + log.Printf("io.Copy (full): %v", err) + return err + } + + if _, err := (*file).Seek(0, 0); err != nil { // rewind file + log.Printf("seek file (thumb): %v", err) + return err + } + + // THUMBNAIL + if err := os.MkdirAll(POST_IMG_THUMB_PATH, 0755); err != nil { + log.Printf("MkdirAll (thumb): %v", err) + return err + } + + img, _, err := image.Decode(*file) + if err != nil { + log.Printf("image.Decode: %v", err) + return err + } + + thumb := imaging.Resize(img, 300, 0, imaging.Lanczos) + if err = imaging.Save(thumb, dstPathThumb); err != nil { + log.Printf("imaging.Save: %v", err) + return err + } + + return nil +} diff --git a/internal/util/util.go b/internal/util/util.go deleted file mode 100644 index b006a8a..0000000 --- a/internal/util/util.go +++ /dev/null @@ -1,44 +0,0 @@ -package util - -import ( - "fmt" - "html/template" - "strings" -) - -func EnrichPost(body string) string { - var b strings.Builder - for _, rawLine := range strings.Split(body, "\n") { - var outLine string - - for i, rawWord := range strings.Split(rawLine, " ") { - var outWord string - if strings.HasPrefix(rawWord, ">>") { - postId := strings.TrimPrefix(rawWord, ">>") - esc := template.HTMLEscapeString(rawWord) - outWord = fmt.Sprintf( - `%s`, - postId, esc, - ) - } else { - outWord = template.HTMLEscapeString(rawWord) - } - - if i != 0 { - outLine += " " - } - outLine += outWord - } - - if strings.HasPrefix(rawLine, ">") && !strings.HasPrefix(rawLine, ">>") { - outLine = `` + outLine + `` - } - - b.WriteString(outLine) - b.WriteString("
") - } - return b.String() -} diff --git a/web/main.go b/web/main.go index d593bde..a8210a7 100644 --- a/web/main.go +++ b/web/main.go @@ -4,78 +4,22 @@ import ( "database/sql" "errors" "fmt" - "image" - "io" "log" - "mime/multipart" "net/http" - "os" "path/filepath" "strconv" "time" - "github.com/disintegration/imaging" "github.com/dominicf2001/comfychan/internal/database" + "github.com/dominicf2001/comfychan/internal/util" "github.com/dominicf2001/comfychan/web/views" "github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5/middleware" _ "github.com/mattn/go-sqlite3" ) -// 10 MB memory limit -const FILE_MEM_LIMIT int64 = 10 << 20 -const POST_IMG_FULL_PATH = "web/static/img/posts/full" -const POST_IMG_THUMB_PATH = "web/static/img/posts/thumb" - var dev = true -func savePostFile(file *multipart.File, filename string) error { - dstPathFull := filepath.Join(POST_IMG_FULL_PATH, filename) - dstPathThumb := filepath.Join(POST_IMG_THUMB_PATH, filename) - - // FULL - if err := os.MkdirAll(POST_IMG_FULL_PATH, 0755); err != nil { - log.Printf("MkdirAll (full): %v", err) - return err - } - - dstFull, err := os.Create(dstPathFull) - if err != nil { - log.Printf("os.Create (full): %v", err) - return err - } - defer dstFull.Close() - if _, err := io.Copy(dstFull, *file); err != nil { - log.Printf("io.Copy (full): %v", err) - return err - } - - if _, err := (*file).Seek(0, 0); err != nil { // rewind file - log.Printf("seek file (thumb): %v", err) - return err - } - - // THUMBNAIL - if err := os.MkdirAll(POST_IMG_THUMB_PATH, 0755); err != nil { - log.Printf("MkdirAll (thumb): %v", err) - return err - } - - img, _, err := image.Decode(*file) - if err != nil { - log.Printf("image.Decode: %v", err) - return err - } - - thumb := imaging.Resize(img, 300, 0, imaging.Lanczos) - if err = imaging.Save(thumb, dstPathThumb); err != nil { - log.Printf("imaging.Save: %v", err) - return err - } - - return nil -} - func disableCacheInDevMode(next http.Handler) http.Handler { if !dev { return next @@ -174,12 +118,18 @@ func main() { // CREATE THREAD r.Post("/{slug}/threads", func(w http.ResponseWriter, r *http.Request) { + ip := util.GetIP(r) + if util.IsOnCooldown(ip, util.ThreadCooldowns, util.THREAD_COOLDOWN) { + http.Error(w, "Too many posts at once.", http.StatusTooManyRequests) + return + } + slug := chi.URLParam(r, "slug") subject := r.FormValue("subject") body := r.FormValue("body") - if err := r.ParseMultipartForm(FILE_MEM_LIMIT); err != nil { + 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 @@ -194,7 +144,7 @@ func main() { defer file.Close() filename := strconv.FormatInt(time.Now().UnixNano(), 10) + filepath.Ext(header.Filename) - if err := savePostFile(&file, filename); err != nil { + if err := util.SavePostFile(&file, filename); err != nil { http.Error(w, "Failed to save file", http.StatusInternalServerError) log.Printf("savePostFile: %v", err) return @@ -209,6 +159,12 @@ func main() { // CREATE POST r.Post("/{slug}/threads/{threadId}", func(w http.ResponseWriter, r *http.Request) { + ip := util.GetIP(r) + if util.IsOnCooldown(ip, util.PostCooldowns, util.POST_COOLDOWN) { + http.Error(w, "Too many posts at once.", http.StatusTooManyRequests) + return + } + // slug := chi.URLParam(r, "slug") // TODO: use relative thread_nums (see issue #1) threadIdStr := chi.URLParam(r, "threadId") @@ -221,7 +177,7 @@ func main() { body := r.FormValue("body") mediaPath := "" - if err := r.ParseMultipartForm(FILE_MEM_LIMIT); err != nil { + 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 @@ -237,7 +193,7 @@ func main() { } else { defer file.Close() filename := strconv.FormatInt(time.Now().UnixNano(), 10) + filepath.Ext(header.Filename) - if err := savePostFile(&file, filename); err != nil { + if err := util.SavePostFile(&file, filename); err != nil { http.Error(w, "Failed to save file", http.StatusInternalServerError) log.Printf("savePostFile: %v", err) return diff --git a/web/static/index.css b/web/static/index.css index cd38b85..e4ded78 100644 --- a/web/static/index.css +++ b/web/static/index.css @@ -70,7 +70,6 @@ body { .catalog-preview h1 { font-size: 14px; - color: #0F0C5D; font-weight: bold; } @@ -82,6 +81,7 @@ body { .catalog-preview-link { text-decoration: none; cursor: pointer; + color: #0F0C5D; } .catalog-preview-img { @@ -251,3 +251,13 @@ body { .box p { padding: 10px; } + + +.warning { + background: #FFAAAA; + color: #000; + border: 1px solid #000; + padding: 8px; + margin: 5px 0; + font-size: 12px; +} diff --git a/web/views/board.templ b/web/views/board.templ index 1038723..8a1ea6f 100644 --- a/web/views/board.templ +++ b/web/views/board.templ @@ -8,6 +8,9 @@ import ( ) templ NewThreadForm(board database.Board) { +
- + @@ -82,7 +91,7 @@ templ ThreadsCatalog(previews []CatalogThreadPreview) { />

- { preview.Subject } + { preview.Subject }

@templ.Raw(util.EnrichPost(preview.Body)) diff --git a/web/views/thread.templ b/web/views/thread.templ index 0aa5536..b5bae01 100644 --- a/web/views/thread.templ +++ b/web/views/thread.templ @@ -62,6 +62,9 @@ templ Thread(board database.Board, thread database.Thread, posts []database.Post @BoardHeader(board)

+
Subject
Comment