diff --git a/internal/database/handlers.go b/internal/database/handlers.go index 37bbd22..ea8ab27 100644 --- a/internal/database/handlers.go +++ b/internal/database/handlers.go @@ -4,6 +4,7 @@ import ( "database/sql" "errors" "io/fs" + "log" "os" "path" "time" @@ -349,9 +350,46 @@ func GetAdmin(db *sql.DB, username string) (Admin, error) { return result, nil } -func BanIp(db *sql.DB, ip string, reason string, expiration time.Time) error { +func BanIp(db *sql.DB, ipHash string, reason string, expiration time.Time) error { + log.Printf("IP: %s, reason: %s, expiration: %v", ipHash, reason, expiration) + // if the ban already exists for ip, only update if its greater than existing _, err := db.Exec(` INSERT INTO bans (ip_hash, reason, expiration) - VALUES (?, ?, ?)`, ip, reason, expiration) + VALUES (?, ?, ?) + ON CONFLICT(ip_hash) DO UPDATE SET + reason = excluded.reason, + expiration = excluded.expiration + WHERE excluded.expiration > bans.expiration + `, ipHash, reason, expiration) return err } + +var ErrBanNotFound = errors.New("ban not found") + +func GetBan(db *sql.DB, ip string) (Ban, error) { + row := db.QueryRow(` + SELECT ip_hash, reason, expiration + FROM bans + where ip_hash = ?`, ip) + + var r Ban + err := row.Scan(&r.IpHash, &r.Reason, &r.Expiration) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return Ban{}, ErrBanNotFound + } + return Ban{}, err + } + + if time.Now().After(r.Expiration) { + _, err := db.Exec(` + DELETE FROM bans + WHERE ip_hash = ?`, ip) + if err != nil { + return Ban{}, err + } + return Ban{}, ErrBanNotFound + } + + return r, nil +} diff --git a/internal/database/models.go b/internal/database/models.go index c99d897..3f5c402 100644 --- a/internal/database/models.go +++ b/internal/database/models.go @@ -34,3 +34,9 @@ type Admin struct { Username string Password string } + +type Ban struct { + IpHash string + Reason string + Expiration time.Time +} diff --git a/internal/database/seed.sql b/internal/database/seed.sql index 25b1174..578a1bc 100644 --- a/internal/database/seed.sql +++ b/internal/database/seed.sql @@ -42,7 +42,7 @@ CREATE TABLE IF NOT EXISTS admins ( CREATE TABLE IF NOT EXISTS bans ( id INTEGER PRIMARY KEY AUTOINCREMENT, - ip_hash TEXT NOT NULL, + ip_hash TEXT NOT NULL UNIQUE, reason TEXT NOT NULL, expiration DATETIME NOT NULL ); diff --git a/internal/util/cooldowns.go b/internal/util/cooldowns.go index addf4ee..5cf965e 100644 --- a/internal/util/cooldowns.go +++ b/internal/util/cooldowns.go @@ -16,9 +16,9 @@ const POST_COOLDOWN = 15 * time.Second const THREAD_COOLDOWN = 2 * time.Minute -func GetRemainingCooldown(ip string, m map[string]time.Time, duration time.Duration) time.Duration { +func GetRemainingCooldown(ipHash string, m map[string]time.Time, duration time.Duration) time.Duration { CooldownMutex.RLock() - last, exists := m[ip] + last, exists := m[ipHash] CooldownMutex.RUnlock() if !exists { return 0 @@ -26,12 +26,12 @@ func GetRemainingCooldown(ip string, m map[string]time.Time, duration time.Durat return duration - time.Since(last) } -func BeginCooldown(ip string, m map[string]time.Time, duration time.Duration) { +func BeginCooldown(ipHash string, m map[string]time.Time, duration time.Duration) { CooldownMutex.Lock() defer CooldownMutex.Unlock() - last, exists := m[ip] + last, exists := m[ipHash] if !exists || time.Since(last) >= duration { - m[ip] = time.Now() + m[ipHash] = time.Now() } } diff --git a/web/main.go b/web/main.go index 60ea8f7..5712cef 100644 --- a/web/main.go +++ b/web/main.go @@ -49,14 +49,6 @@ func disableCacheInDevMode(next http.Handler) http.Handler { }) } -func SumUniquePostIps(posts []database.Post) int { - uniqueIpHashes := map[string]bool{} - for _, post := range posts { - uniqueIpHashes[post.IpHash] = true - } - return len(uniqueIpHashes) -} - func isAdmin(r *http.Request) bool { if util.DevMode { return true @@ -159,10 +151,25 @@ func main() { // CREATE THREAD r.Post("/{slug}/threads", func(w http.ResponseWriter, r *http.Request) { slug := chi.URLParam(r, "slug") - ip := util.GetIP(r) + ipHash := util.HashIp(util.GetIP(r)) + + // guard banned ips + ban, err := database.GetBan(db, ipHash) + if err != nil { + if !errors.Is(err, database.ErrBanNotFound) { + http.Error(w, "Failed to get ban", http.StatusInternalServerError) + return + } + } else { + msg := fmt.Sprintf("You are banned until: %s. Reason: %s", + ban.Expiration.Format("2006-01-02 15:04"), + ban.Reason) + http.Error(w, msg, http.StatusForbidden) + return + } // check cooldown - timeRemaining := util.GetRemainingCooldown(ip, util.ThreadCooldowns, util.THREAD_COOLDOWN) + timeRemaining := util.GetRemainingCooldown(ipHash, util.ThreadCooldowns, util.THREAD_COOLDOWN) if timeRemaining > 0 && !isAdmin(r) { response := fmt.Sprintf("Please wait %.0f seconds", timeRemaining.Seconds()) io.Copy(io.Discard, r.Body) @@ -240,14 +247,14 @@ func main() { return } - threadId, err := database.PutThread(db, slug, subject, body, savedMediaPath, savedThumbPath, util.HashIp(ip)) + threadId, err := database.PutThread(db, slug, subject, body, savedMediaPath, savedThumbPath, ipHash) if err != nil { http.Error(w, "Failed to create thread", http.StatusInternalServerError) log.Printf("PutThread: %v", err) return } - util.BeginCooldown(ip, util.ThreadCooldowns, util.THREAD_COOLDOWN) + util.BeginCooldown(ipHash, util.ThreadCooldowns, util.THREAD_COOLDOWN) // Check if it's an HTMX request redirectUrl := fmt.Sprintf("/%s/threads/%d", slug, threadId) @@ -262,10 +269,25 @@ func main() { // CREATE POST r.Post("/{slug}/threads/{threadId}", func(w http.ResponseWriter, r *http.Request) { slug := chi.URLParam(r, "slug") - ip := util.GetIP(r) + ipHash := util.HashIp(util.GetIP(r)) + + // guard banned ips + ban, err := database.GetBan(db, ipHash) + if err != nil { + if !errors.Is(err, database.ErrBanNotFound) { + http.Error(w, "Failed to get ban", http.StatusInternalServerError) + return + } + } else { + msg := fmt.Sprintf("You are banned until: %s. Reason: %s", + ban.Expiration.Format("2006-01-02 15:04"), + ban.Reason) + http.Error(w, msg, http.StatusForbidden) + return + } // check cooldown - timeRemaining := util.GetRemainingCooldown(ip, util.PostCooldowns, util.POST_COOLDOWN) + timeRemaining := util.GetRemainingCooldown(ipHash, util.PostCooldowns, util.POST_COOLDOWN) if timeRemaining > 0 && !isAdmin(r) { response := fmt.Sprintf("Please wait %.0f seconds", timeRemaining.Seconds()) io.Copy(io.Discard, r.Body) @@ -352,13 +374,13 @@ func main() { return } - if err := database.PutPost(db, slug, threadId, body, mediaPath, thumbPath, util.HashIp(ip)); err != nil { + if err := database.PutPost(db, slug, threadId, body, mediaPath, thumbPath, ipHash); err != nil { http.Error(w, "Failed to create post", http.StatusInternalServerError) log.Printf("PutPost: %v", err) return } - util.BeginCooldown(ip, util.PostCooldowns, util.POST_COOLDOWN) + util.BeginCooldown(ipHash, util.PostCooldowns, util.POST_COOLDOWN) }) // ----------------- @@ -393,6 +415,11 @@ func main() { return } + uniqueIpHashes := map[string]bool{} + for _, post := range posts { + uniqueIpHashes[post.IpHash] = true + } + previews = append(previews, views.CatalogThreadPreview{ Subject: thread.Subject, Body: op.Body, @@ -400,7 +427,7 @@ func main() { ThreadURL: fmt.Sprintf("/%s/threads/%d", slug, thread.Id), ThumbPath: op.ThumbPath, ReplyCount: len(posts), - IpCount: SumUniquePostIps(posts), + IpCount: len(uniqueIpHashes), }) } diff --git a/web/static/index.js b/web/static/index.js index 01628af..d9f2584 100644 --- a/web/static/index.js +++ b/web/static/index.js @@ -23,7 +23,7 @@ function smoothScrollTo(loc) { } function isHttpWarningStatus(code) { - const warningStatuses = [429, 400, 413]; + const warningStatuses = [429, 400, 413, 403]; return warningStatuses.includes(code); }