eleven / ocho Public

Clone
git clone https://amadan.net/eleven/ocho

Plain git — no account needed to clone.

Download

Download this file

1package main
2
3// Content-blind spam brakes (issue #578): per-user token buckets on the few
4// endpoints a flood abuses — messages, new DMs, invites. The server enforces
5// them without reading anything (pure metadata), admins are exempt, and the
6// ceilings are far above any human pace so a legitimate member never meets
7// them. Ported from home/main.go's per-IP limiter, keyed by user id here.
8
9import (
10 "sync"
11 "time"
12)
13
14// modLimiter is a per-key token bucket. Buckets refill continuously and idle
15// ones are swept when the map grows, so memory stays bounded.
16type modLimiter struct {
17 mu sync.Mutex
18 buckets map[string]*modBucket
19 burst float64
20 perSec float64
21}
22
23type modBucket struct {
24 tokens float64
25 last time.Time
26}
27
28func newModLimiter(burst, perSec float64) *modLimiter {
29 return &modLimiter{buckets: map[string]*modBucket{}, burst: burst, perSec: perSec}
30}
31
32func (rl *modLimiter) allow(key string) bool {
33 rl.mu.Lock()
34 defer rl.mu.Unlock()
35 now := time.Now()
36 if len(rl.buckets) > 10000 {
37 for k, b := range rl.buckets {
38 if b.tokens >= rl.burst || now.Sub(b.last) > time.Hour {
39 delete(rl.buckets, k)
40 }
41 }
42 }
43 b, ok := rl.buckets[key]
44 if !ok {
45 b = &modBucket{tokens: rl.burst, last: now}
46 rl.buckets[key] = b
47 }
48 b.tokens += now.Sub(b.last).Seconds() * rl.perSec
49 if b.tokens > rl.burst {
50 b.tokens = rl.burst
51 }
52 b.last = now
53 if b.tokens < 1 {
54 return false
55 }
56 b.tokens--
57 return true
58}
59
60// The suite's brakes. Ceilings, not budgets: bursts absorb honest spikes
61// (pasting a few lines, inviting a family), the refill rates are what a flood
62// runs into.
63type modBrakes struct {
64 // messages: burst 30, ~30/min sustained — chatty humans never notice;
65 // a script spraying a group does.
66 messages *modLimiter
67 // new DMs opened: burst 8, ~10/hour — the mass-DM spam pattern.
68 dms *modLimiter
69 // fresh members get a tighter DM bucket for their first day (probation):
70 // burst 3, ~3/hour. The classic spam-join opens DMs immediately.
71 dmsNew *modLimiter
72 // member invites minted: burst 10, ~20/day.
73 invites *modLimiter
74}
75
76func newModBrakes() *modBrakes {
77 return &modBrakes{
78 messages: newModLimiter(30, 0.5),
79 dms: newModLimiter(8, 10.0/3600),
80 dmsNew: newModLimiter(3, 3.0/3600),
81 invites: newModLimiter(10, 20.0/86400),
82 }
83}
84
85// probationWindow is how long a fresh member gets the tighter DM bucket.
86const probationWindow = 24 * time.Hour
87
88// allowDM picks the probation bucket for members younger than a day.
89func (s *Server) allowDM(uid string, createdAt int64) bool {
90 if time.Since(time.Unix(createdAt, 0)) < probationWindow {
91 return s.brake().dmsNew.allow(uid)
92 }
93 return s.brake().dms.allow(uid)
94}
95