fix(web): throttle posts per account
7 files changed, +64 −10
- avatar.go modified +11 −0
- flow_test.go modified +16 −0
- inbox.go modified +17 −7
- limit.go modified +13 −1
- main.go modified +4 −1
- static/style.css modified +1 −0
- web.go modified +2 −1
avatar.go modified
| @@ -67,7 +67,18 @@ var blocks = [][][2]float64{ | ||
| 67 | 67 | |
| 68 | 68 | var centres = []int{0, 3, 4} |
| 69 | 69 | |
| 70 | +var identicons sync.Map | |
| 71 | + | |
| 70 | 72 | func identicon(seed string) []byte { |
| 73 | + if b, ok := identicons.Load(seed); ok { | |
| 74 | + return b.([]byte) | |
| 75 | + } | |
| 76 | + b := drawIdenticon(seed) | |
| 77 | + identicons.Store(seed, b) | |
| 78 | + return b | |
| 79 | +} | |
| 80 | + | |
| 81 | +func drawIdenticon(seed string) []byte { | |
| 71 | 82 | sum := sha256.Sum256([]byte(seed)) |
| 72 | 83 | h := float64(uint16(sum[28])<<8|uint16(sum[29])) / 65536 |
| 73 | 84 | fg := hsl(h, 0.5+float64(sum[30])/255*0.2, 0.42+float64(sum[31])/255*0.12) |
flow_test.go modified
| @@ -495,3 +495,19 @@ func TestProfileLinks(t *testing.T) { | ||
| 495 | 495 | t.Error("profile does not show four links") |
| 496 | 496 | } |
| 497 | 497 | } |
| 498 | + | |
| 499 | +func TestPostThrottle(t *testing.T) { | |
| 500 | + s, h := testServer(t) | |
| 501 | + alice, _ := s.store.CreateUser("alice", "correct horse") | |
| 502 | + c, _ := s.store.CreateSession(alice.ID) | |
| 503 | + limited := false | |
| 504 | + for i := 0; i < 80 && !limited; i++ { | |
| 505 | + limited = post(h, "/notifications", url.Values{}, c).Code == http.StatusTooManyRequests | |
| 506 | + } | |
| 507 | + if !limited { | |
| 508 | + t.Error("a logged in user can post without limit") | |
| 509 | + } | |
| 510 | + if w := post(h, "/login", url.Values{"name": {"alice"}, "password": {"correct horse"}}, ""); w.Code == http.StatusTooManyRequests { | |
| 511 | + t.Error("anonymous posts share the user bucket") | |
| 512 | + } | |
| 513 | +} | |
inbox.go modified
| @@ -112,6 +112,11 @@ func (s *Store) Notifications(user int64, limit int) []Notification { | ||
| 112 | 112 | } |
| 113 | 113 | |
| 114 | 114 | func (s *Store) ReadThread(user, repo int64, kind string, number int64) { |
| 115 | + var unread bool | |
| 116 | + s.db.QueryRow(`select 1 from notifications where user = ? and repo = ? and kind = ? and number = ? and unread = 1`, user, repo, kind, number).Scan(&unread) | |
| 117 | + if !unread { | |
| 118 | + return | |
| 119 | + } | |
| 115 | 120 | s.db.Exec(`update notifications set unread = 0 where user = ? and repo = ? and kind = ? and number = ? and unread = 1`, user, repo, kind, number) |
| 116 | 121 | } |
| 117 | 122 | |
| @@ -135,14 +140,19 @@ func (s *Server) openNotification(w http.ResponseWriter, r *http.Request) { | ||
| 135 | 140 | return |
| 136 | 141 | } |
| 137 | 142 | id, _ := strconv.ParseInt(r.PathValue("id"), 10, 64) |
| 138 | - for _, n := range s.store.Notifications(u.ID, 100) { | |
| 139 | - if n.ID == id { | |
| 140 | - s.store.ReadThread(u.ID, n.Repo.ID, n.Kind, n.Number) | |
| 141 | - http.Redirect(w, r, n.Link(), http.StatusSeeOther) | |
| 142 | - return | |
| 143 | - } | |
| 143 | + var n Notification | |
| 144 | + var repo int64 | |
| 145 | + if s.store.db.QueryRow(`select repo, kind, number from notifications where id = ? and user = ?`, id, u.ID).Scan(&repo, &n.Kind, &n.Number) != nil { | |
| 146 | + http.Redirect(w, r, "/notifications", http.StatusSeeOther) | |
| 147 | + return | |
| 148 | + } | |
| 149 | + n.Repo, _ = s.store.RepoByID(repo) | |
| 150 | + if !s.store.CanRead(n.Repo, u) { | |
| 151 | + http.Redirect(w, r, "/notifications", http.StatusSeeOther) | |
| 152 | + return | |
| 144 | 153 | } |
| 145 | - http.Redirect(w, r, "/notifications", http.StatusSeeOther) | |
| 154 | + s.store.ReadThread(u.ID, repo, n.Kind, n.Number) | |
| 155 | + http.Redirect(w, r, n.Link(), http.StatusSeeOther) | |
| 146 | 156 | } |
| 147 | 157 | |
| 148 | 158 | func shortVerb(verb string) string { |
limit.go modified
| @@ -3,6 +3,7 @@ package main | ||
| 3 | 3 | import ( |
| 4 | 4 | "net" |
| 5 | 5 | "net/http" |
| 6 | + "strconv" | |
| 6 | 7 | "strings" |
| 7 | 8 | "sync" |
| 8 | 9 | "time" |
| @@ -135,6 +136,17 @@ func heavyRequest(r *http.Request) bool { | ||
| 135 | 136 | (strings.Contains(p, "/log") && q.Get("q") != "") || p == "/login" || p == "/register" |
| 136 | 137 | } |
| 137 | 138 | |
| 139 | +func (s *Server) allowPost(r *http.Request) bool { | |
| 140 | + if r.Method != "POST" { | |
| 141 | + return true | |
| 142 | + } | |
| 143 | + if _, err := r.Cookie("session"); err != nil { | |
| 144 | + return true | |
| 145 | + } | |
| 146 | + u := s.current(r) | |
| 147 | + return u == nil || s.posts.allow(strconv.FormatInt(u.ID, 10)) | |
| 148 | +} | |
| 149 | + | |
| 138 | 150 | func (s *Server) throttle(next http.Handler) http.Handler { |
| 139 | 151 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| 140 | 152 | if strings.HasPrefix(r.URL.Path, "/static/") { |
| @@ -142,7 +154,7 @@ func (s *Server) throttle(next http.Handler) http.Handler { | ||
| 142 | 154 | return |
| 143 | 155 | } |
| 144 | 156 | ip := clientIP(r) |
| 145 | - if !s.requests.allow(ip) || (heavyRequest(r) && !s.heavy.allow(ip)) { | |
| 157 | + if !s.requests.allow(ip) || (heavyRequest(r) && !s.heavy.allow(ip)) || !s.allowPost(r) { | |
| 146 | 158 | w.Header().Set("Retry-After", "10") |
| 147 | 159 | http.Error(w, "too many requests, slow down", http.StatusTooManyRequests) |
| 148 | 160 | return |
main.go modified
| @@ -9,6 +9,7 @@ import ( | ||
| 9 | 9 | "net/http" |
| 10 | 10 | "os" |
| 11 | 11 | "path/filepath" |
| 12 | + "strconv" | |
| 12 | 13 | "strings" |
| 13 | 14 | "time" |
| 14 | 15 | ) |
| @@ -188,13 +189,15 @@ func main() { | ||
| 188 | 189 | os.Exit(2) |
| 189 | 190 | } |
| 190 | 191 | store := mustStore(*root) |
| 191 | - if _, err := store.UserByName(args[1]); err != nil { | |
| 192 | + u, err := store.UserByName(args[1]) | |
| 193 | + if err != nil { | |
| 192 | 194 | log.Fatal("no such user") |
| 193 | 195 | } |
| 194 | 196 | if err := store.DeleteUser(args[1]); err != nil { |
| 195 | 197 | log.Fatal(err) |
| 196 | 198 | } |
| 197 | 199 | os.RemoveAll(filepath.Join(*root, "repos", args[1])) |
| 200 | + os.Remove(filepath.Join(*root, "avatars", strconv.FormatInt(u.ID, 10))) | |
| 198 | 201 | default: |
| 199 | 202 | flag.Usage() |
| 200 | 203 | os.Exit(2) |
static/style.css modified
| @@ -528,3 +528,4 @@ nav.site .bell .badge{position:absolute;top:-.35rem;left:.85rem;min-width:1rem;p | ||
| 528 | 528 | @media(max-width:700px){.inbox li a{grid-template-columns:1.1rem minmax(0,1fr)}.inbox .who{grid-column:2}} |
| 529 | 529 | fieldset.links{border:0;padding:0;margin:0 0 .8rem;display:flex;flex-direction:column;gap:.35rem;min-width:0} |
| 530 | 530 | fieldset.links legend{padding:0;margin-bottom:.25rem} |
| 531 | +@media(max-width:700px){.inboxpanel{position:fixed;top:3.2rem;left:1rem;right:1rem;width:auto;max-width:none}} | |
web.go modified
| @@ -48,6 +48,7 @@ type Server struct { | ||
| 48 | 48 | requests *buckets |
| 49 | 49 | heavy *buckets |
| 50 | 50 | uploads *limiter |
| 51 | + posts *buckets | |
| 51 | 52 | store *Store |
| 52 | 53 | root string |
| 53 | 54 | host string |
| @@ -81,7 +82,7 @@ type page struct { | ||
| 81 | 82 | } |
| 82 | 83 | |
| 83 | 84 | func newServer(store *Store, root, host, name string, sshUser string) *Server { |
| 84 | - s := &Server{store: store, root: root, host: host, name: name, sshUser: sshUser, mailer: loadMailer(root), pages: map[string]*template.Template{}, limiter: newLimiter(10, 5*time.Minute), requests: newBuckets(5, 120), heavy: newBuckets(0.5, 20), uploads: newLimiter(20, time.Hour)} | |
| 85 | + s := &Server{store: store, root: root, host: host, name: name, sshUser: sshUser, mailer: loadMailer(root), pages: map[string]*template.Template{}, limiter: newLimiter(10, 5*time.Minute), requests: newBuckets(5, 120), heavy: newBuckets(0.5, 20), uploads: newLimiter(20, time.Hour), posts: newBuckets(0.2, 60)} | |
| 85 | 86 | funcs := template.FuncMap{ |
| 86 | 87 | "short": func(h string) string { |
| 87 | 88 | if len(h) > 10 { |