#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.
88 lines
2.1 KiB
Go
88 lines
2.1 KiB
Go
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))
|
|
}
|