~gitpocket/gitpocket

1

fix(web): throttle posts per account

commit
c0e915d5007aece28000cd7683df78cb1f26b900 browse tree
parent
9aa9cf2df95be6f4aa78f88a4f6ae1171a1fab78
author
obscurejames <354576047+obscurejames@users.noreply.github.com>
signature
Verified ~james signed with a key registered by ~james
date
2026-11-10 22:21 UTC

7 files changed, +64 −10

avatar.go modified

@@ -67,7 +67,18 @@ var blocks = [][][2]float64{
6767
6868 var centres = []int{0, 3, 4}
6969
70+var identicons sync.Map
71+
7072 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 {
7182 sum := sha256.Sum256([]byte(seed))
7283 h := float64(uint16(sum[28])<<8|uint16(sum[29])) / 65536
7384 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) {
495495 t.Error("profile does not show four links")
496496 }
497497 }
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 {
112112 }
113113
114114 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+ }
115120 s.db.Exec(`update notifications set unread = 0 where user = ? and repo = ? and kind = ? and number = ? and unread = 1`, user, repo, kind, number)
116121 }
117122
@@ -135,14 +140,19 @@ func (s *Server) openNotification(w http.ResponseWriter, r *http.Request) {
135140 return
136141 }
137142 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
144153 }
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)
146156 }
147157
148158 func shortVerb(verb string) string {

limit.go modified

@@ -3,6 +3,7 @@ package main
33 import (
44 "net"
55 "net/http"
6+ "strconv"
67 "strings"
78 "sync"
89 "time"
@@ -135,6 +136,17 @@ func heavyRequest(r *http.Request) bool {
135136 (strings.Contains(p, "/log") && q.Get("q") != "") || p == "/login" || p == "/register"
136137 }
137138
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+
138150 func (s *Server) throttle(next http.Handler) http.Handler {
139151 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
140152 if strings.HasPrefix(r.URL.Path, "/static/") {
@@ -142,7 +154,7 @@ func (s *Server) throttle(next http.Handler) http.Handler {
142154 return
143155 }
144156 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) {
146158 w.Header().Set("Retry-After", "10")
147159 http.Error(w, "too many requests, slow down", http.StatusTooManyRequests)
148160 return

main.go modified

@@ -9,6 +9,7 @@ import (
99 "net/http"
1010 "os"
1111 "path/filepath"
12+ "strconv"
1213 "strings"
1314 "time"
1415 )
@@ -188,13 +189,15 @@ func main() {
188189 os.Exit(2)
189190 }
190191 store := mustStore(*root)
191- if _, err := store.UserByName(args[1]); err != nil {
192+ u, err := store.UserByName(args[1])
193+ if err != nil {
192194 log.Fatal("no such user")
193195 }
194196 if err := store.DeleteUser(args[1]); err != nil {
195197 log.Fatal(err)
196198 }
197199 os.RemoveAll(filepath.Join(*root, "repos", args[1]))
200+ os.Remove(filepath.Join(*root, "avatars", strconv.FormatInt(u.ID, 10)))
198201 default:
199202 flag.Usage()
200203 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
528528 @media(max-width:700px){.inbox li a{grid-template-columns:1.1rem minmax(0,1fr)}.inbox .who{grid-column:2}}
529529 fieldset.links{border:0;padding:0;margin:0 0 .8rem;display:flex;flex-direction:column;gap:.35rem;min-width:0}
530530 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 {
4848 requests *buckets
4949 heavy *buckets
5050 uploads *limiter
51+ posts *buckets
5152 store *Store
5253 root string
5354 host string
@@ -81,7 +82,7 @@ type page struct {
8182 }
8283
8384 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)}
8586 funcs := template.FuncMap{
8687 "short": func(h string) string {
8788 if len(h) > 10 {