Syntax highlighting, rate limiting, creator auto-unlock (#1, #2, #26)
CI / test (push) Successful in 19s
CI / docker (push) Skipped

#1: server-side regex highlighter (highlight.go) for go/python/js/json/bash/sql;
token span classes styled in app.css; per-line so gutter stays aligned.
#2: in-memory token-bucket rate limiter (ratelimit.go) on POST /api/pastes,
/api/guess-language and unlock POST; 429 + Retry-After + X-RateLimit headers.
#26: new-page JS POSTs the password to /{id} with ?next= after creation; the
unlock handler honors same-origin ?next= redirect so the creator lands on the
unlocked paste. POST /{id} route added.

Tests: ratelimit_test.go (burst/429, refill, unlock limit, highlight, auto-
unlock e2e); existing tests updated for per-test limiter isolation.
This commit is contained in:
2026-09-08 21:09:25 -05:00
parent 9d75d2f80d
commit 3facff3d1e
11 changed files with 511 additions and 4 deletions
+87
View File
@@ -0,0 +1,87 @@
package main
import (
"net/http"
"strconv"
"strings"
"sync"
"time"
)
// Per-IP token bucket rate limiting (#2). Goroutine-safe via mutex.
type bucket struct {
tokens float64
last time.Time
rate float64 // tokens per second
burst float64
}
type limiter struct {
mu sync.Mutex
buckets map[string]*bucket
}
func newLimiter() *limiter {
return &limiter{buckets: make(map[string]*bucket)}
}
func (l *limiter) allow(key string, rate, burst float64) bool {
l.mu.Lock()
defer l.mu.Unlock()
now := time.Now()
b, ok := l.buckets[key]
if !ok {
b = &bucket{tokens: burst, last: now, rate: rate, burst: burst}
l.buckets[key] = b
}
elapsed := now.Sub(b.last).Seconds()
b.tokens += elapsed * b.rate
if b.tokens > b.burst {
b.tokens = b.burst
}
b.last = now
if b.tokens < 1 {
return false
}
b.tokens--
return true
}
// clientIP extracts the request IP (no reverse proxy header by default).
func clientIP(r *http.Request) string {
host := r.RemoteAddr
if i := strings.LastIndex(host, ":"); i > 0 {
host = host[:i]
}
return host
}
var globalLimiter = newLimiter()
// rateLimitCreate: 1 req/sec refill, burst 5, per IP.
func rateLimitCreate(r *http.Request) bool {
return globalLimiter.allow("create:"+clientIP(r), 1, 5)
}
// rateLimitGuess: 1 req/sec refill, burst 5, per IP.
func rateLimitGuess(r *http.Request) bool {
return globalLimiter.allow("guess:"+clientIP(r), 1, 5)
}
// rateLimitUnlock: 5 per minute per IP+paste.
func rateLimitUnlock(id string, r *http.Request) bool {
return globalLimiter.allow("unlock:"+id+":"+clientIP(r), 5.0/60.0, 5)
}
// writeRateLimited responds 429 with Retry-After based on refill rate.
func writeRateLimited(w http.ResponseWriter, retryAfterSecs int) {
w.Header().Set("Retry-After", strconv.Itoa(retryAfterSecs))
writeErr(w, 429, "rate limit exceeded")
}
// setRateLimitHeaders sets informational X-RateLimit headers for create/guess.
func setRateLimitHeaders(w http.ResponseWriter, limit, burst int) {
w.Header().Set("X-RateLimit-Limit", strconv.Itoa(limit))
w.Header().Set("X-RateLimit-Burst", strconv.Itoa(burst))
}