~gitpocket/gitpocket

feat(basin): logo, failure based rate limits, size caps, session expiry

commit
b9073efe11e630baa39bc91afee48452eb70a686 browse tree
parent
266053a6e7bb9c2b05622beea635d12c2088a60d
author
james <j@cpl0.zip>
signature
none
date
2026-11-09 21:21 UTC

12 files changed, +83 −29

README.md modified

@@ -1,4 +1,4 @@
1-forge is a small self hosted git server in Go modelled on the browsing side of sourcehut. It serves repositories over ssh and smart https, renders trees, blobs, logs, diffs, refs and READMEs as plain server side HTML with no JavaScript, and keeps users, keys, tokens and repository metadata in a single SQLite file.
1+forge is a small self hosted git server in Go modelled on the browsing side of sourcehut. It serves repositories over ssh and smart https, renders trees, blobs, logs, diffs, refs and READMEs as server side HTML, and keeps users, profiles, keys, tokens and repository metadata in a single SQLite file. Source is highlighted with chroma, Markdown is rendered with goldmark and sanitised with bluemonday, and LaTeX in Markdown is typeset in the browser by a bundled KaTeX, which is the only JavaScript a reader ever loads.
22
33 ## Build
44
@@ -27,6 +27,10 @@ Pushes over ssh reuse the host sshd. Point `AuthorizedKeysCommand` for the `git`
2727
2828 Anonymous clients may fetch public repositories. Private repositories and all pushes require basic auth with either the account password or an access token created in settings. Token hashes are stored as SHA256, passwords as bcrypt. The web forms rely on the standard library cross origin protection, so a POST without a same origin `Sec-Fetch-Site` or `Origin` header from a browser is refused while non browser git clients pass.
2929
30+## Profiles
31+
32+Each user has a bio, location, workplace, website and an avatar. Avatar uploads are decoded on the server, cropped to the square the settings page chose with a small canvas script, resampled to 256 pixels and stored as PNG, so the served image never contains the original file's metadata or dimensions. Without JavaScript the crop falls back to the centred square.
33+
3034 ## Limitations
3135
32-There is no syntax highlighting, no search, no per directory last commit column and no collaborators beyond the owner. Pull requests are planned as a second phase and the schema leaves room for them, but nothing is wired yet. Markdown READMEs are rendered with goldmark and sanitised with bluemonday, other README formats are shown preformatted.
36+There is no search, no collaborators beyond the owner and no disk quota, so a pushed repository is bounded only by the nginx body limit and free space. Login, registration and https basic auth share a per address failure counter of ten misses per five minutes, which nginx must feed through `X-Forwarded-For`. Blobs over 1 MiB are not rendered and over 64 MiB are not served raw. Pull requests are planned as a second phase and the schema leaves room for them, but nothing is wired yet.

avatar.go modified

@@ -19,8 +19,8 @@ func cropAvatar(data []byte, x, y, size int) ([]byte, error) {
1919 if err != nil {
2020 return nil, errors.New("Avatar must be a PNG, JPEG, GIF or WebP image")
2121 }
22- if cfg.Width*cfg.Height > 40_000_000 {
23- return nil, errors.New("Avatar image is too large, keep it under 40 megapixels")
22+ if cfg.Width*cfg.Height > 25_000_000 {
23+ return nil, errors.New("Avatar image is too large, keep it under 25 megapixels")
2424 }
2525 img, _, err := image.Decode(bytes.NewReader(data))
2626 if err != nil {

git.go modified

@@ -238,6 +238,15 @@ func (g Git) Blob(commit, filePath string) ([]byte, error) {
238238 return out, nil
239239 }
240240
241+func (g Git) Size(commit, filePath string) int64 {
242+ out, err := g.run(nil, "cat-file", "-s", commit+":"+filePath)
243+ if err != nil {
244+ return 0
245+ }
246+ n, _ := strconv.ParseInt(strings.TrimSpace(string(out)), 10, 64)
247+ return n
248+}
249+
241250 func (g Git) Kind(commit, filePath string) string {
242251 out, err := g.run(nil, "cat-file", "-t", commit+":"+filePath)
243252 if err != nil {

highlight.go modified

@@ -22,6 +22,9 @@ var formatter = html.New(
2222 var highlightStyle = styles.Get("github")
2323
2424 func highlight(name string, content []byte) (template.HTML, bool) {
25+ if len(content) > 256<<10 {
26+ return "", false
27+ }
2528 lexer := lexers.Match(filepath.Base(name))
2629 if lexer == nil {
2730 lexer = lexers.Analyse(string(content))

limit.go modified

@@ -19,10 +19,7 @@ func newLimiter(max int, window time.Duration) *limiter {
1919 return &limiter{hits: map[string][]time.Time{}, max: max, window: window}
2020 }
2121
22-func (l *limiter) allow(key string) bool {
23- l.mu.Lock()
24- defer l.mu.Unlock()
25- now := time.Now()
22+func (l *limiter) prune(key string, now time.Time) []time.Time {
2623 cutoff := now.Add(-l.window)
2724 kept := l.hits[key][:0]
2825 for _, t := range l.hits[key] {
@@ -30,19 +27,32 @@ func (l *limiter) allow(key string) bool {
3027 kept = append(kept, t)
3128 }
3229 }
33- if len(kept) >= l.max {
30+ if len(kept) == 0 {
31+ delete(l.hits, key)
32+ } else {
3433 l.hits[key] = kept
35- return false
3634 }
37- l.hits[key] = append(kept, now)
3835 if len(l.hits) > 10000 {
3936 for k, v := range l.hits {
40- if len(v) == 0 || !v[len(v)-1].After(cutoff) {
37+ if !v[len(v)-1].After(cutoff) {
4138 delete(l.hits, k)
4239 }
4340 }
4441 }
45- return true
42+ return kept
43+}
44+
45+func (l *limiter) blocked(key string) bool {
46+ l.mu.Lock()
47+ defer l.mu.Unlock()
48+ return len(l.prune(key, time.Now())) >= l.max
49+}
50+
51+func (l *limiter) fail(key string) {
52+ l.mu.Lock()
53+ defer l.mu.Unlock()
54+ now := time.Now()
55+ l.hits[key] = append(l.prune(key, now), now)
4656 }
4757
4858 func clientIP(r *http.Request) string {

smart.go modified

@@ -48,8 +48,16 @@ func (s *Server) smart(w http.ResponseWriter, r *http.Request, owner User, name,
4848 }
4949 needAuth := service == "git-receive-pack" || repo.Private
5050 if needAuth {
51+ ip := clientIP(r)
52+ if s.limiter.blocked(ip) {
53+ http.Error(w, "too many failed attempts", 429)
54+ return
55+ }
5156 u, ok := s.basicUser(r)
5257 if !ok || u.ID != repo.Owner.ID {
58+ if _, _, given := r.BasicAuth(); given {
59+ s.limiter.fail(ip)
60+ }
5361 w.Header().Set("WWW-Authenticate", `Basic realm="`+s.host+`"`)
5462 http.Error(w, "authentication required", 401)
5563 return

static/logo.svg added

@@ -0,0 +1 @@
1+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" width="32" height="32"><rect width="32" height="32" rx="7" fill="#212529"/><g fill="none" stroke="#fff" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M11 9v14"/><path d="M11 16c0-3.5 2.5-5.5 7-5.5h2"/></g><g fill="#fff"><circle cx="11" cy="8" r="2.6"/><circle cx="11" cy="24" r="2.6"/><circle cx="21" cy="10.5" r="2.6"/></g></svg>

static/style.css modified

@@ -18,7 +18,8 @@ h2:first-child,h3:first-child{margin-top:0}
1818 .notice{border:1px solid var(--add);border-radius:3px;padding:.4rem .6rem;background:var(--addbg)}
1919 header{background:var(--soft);border-bottom:1px solid var(--line)}
2020 nav.site{max-width:1100px;margin:0 auto;padding:.55rem 1rem;display:flex;gap:1.2rem;align-items:center;flex-wrap:wrap}
21-nav.site .brand{font-weight:700;color:var(--fg)}
21+nav.site .brand{font-weight:700;color:var(--fg);display:inline-flex;align-items:center;gap:.5rem}
22+nav.site .brand svg{display:block}
2223 nav.site .spacer{flex:1}
2324 nav.site a{color:var(--fg)}
2425 nav.site form{display:inline}
@@ -32,7 +33,8 @@ nav.tabs a{color:var(--fg);padding:.4rem .9rem;border:1px solid transparent;bord
3233 nav.tabs a:hover{text-decoration:none;background:#fff;border-color:var(--line)}
3334 main{max-width:1100px;margin:0 auto;padding:1.2rem 1rem 3rem}
3435 .hero{padding:1rem 0 1.5rem;border-bottom:1px solid var(--line);margin-bottom:1rem;max-width:44rem}
35-.hero h1{font-size:1.8rem}
36+.hero h1{font-size:1.8rem;display:flex;align-items:center;gap:.6rem}
37+.hero h1 img{width:40px;height:40px}
3638 .hero p{margin:.4rem 0}
3739 .row{display:flex;justify-content:space-between;align-items:baseline}
3840 .row h2{margin-top:0}

store.go modified

@@ -233,7 +233,7 @@ func (s *Store) CreateSession(user int64) (string, error) {
233233
234234 func (s *Store) SessionUser(id string) (User, error) {
235235 var user int64
236- err := s.db.QueryRow(`select user from sessions where id = ?`, id).Scan(&user)
236+ err := s.db.QueryRow(`select user from sessions where id = ? and created > ?`, id, time.Now().Add(-90*24*time.Hour).Unix()).Scan(&user)
237237 if err != nil {
238238 return User{}, ErrNotFound
239239 }

templates/index.html modified

@@ -7,7 +7,7 @@
77 {{template "repolist" .Data.Repos}}
88 {{else}}
99 <section class="hero">
10-<h1>{{.Host}}</h1>
10+<h1><img src="/static/logo.svg" alt="">{{.Host}}</h1>
1111 <p>A small git host. Push over SSH or HTTPS, browse trees, logs and diffs, and read READMEs with Markdown and LaTeX rendering. No JavaScript is needed to use it.</p>
1212 <p class="muted">{{.Data.Public}} public repositories from {{.Data.Users}} users.</p>
1313 <p><a class="button" href="/login">Log in</a> <a class="button secondary" href="/register">Register</a></p>

templates/layout.html modified

@@ -4,6 +4,7 @@
44 <meta charset="utf-8">
55 <meta name="viewport" content="width=device-width, initial-scale=1">
66 <title>{{.Title}}</title>
7+<link rel="icon" href="/static/logo.svg" type="image/svg+xml">
78 <link rel="stylesheet" href="/static/style.css">
89 <link rel="stylesheet" href="/static/highlight.css">
910 {{if .Math}}<link rel="stylesheet" href="/static/katex/katex.min.css">
@@ -14,7 +15,7 @@
1415 <body>
1516 <header>
1617 <nav class="site">
17-<a class="brand" href="/">{{.Host}}</a>
18+<a class="brand" href="/"><svg viewBox="0 0 32 32" width="22" height="22" aria-hidden="true"><rect width="32" height="32" rx="7" fill="currentColor"/><g fill="none" stroke="#fff" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M11 9v14"/><path d="M11 16c0-3.5 2.5-5.5 7-5.5h2"/></g><g fill="#fff"><circle cx="11" cy="8" r="2.6"/><circle cx="11" cy="24" r="2.6"/><circle cx="21" cy="10.5" r="2.6"/></g></svg>{{.Host}}</a>
1819 <span class="spacer"></span>
1920 {{if .User}}<a href="/~{{.User.Name}}">~{{.User.Name}}</a><a href="/create">New repository</a><a href="/settings">Settings</a><form method="post" action="/logout"><button>Log out</button></form>{{else}}<a href="/login">Log in</a><a href="/register">Register</a>{{end}}
2021 </nav>

web.go modified

@@ -177,13 +177,15 @@ func (s *Server) loginForm(w http.ResponseWriter, r *http.Request) {
177177 }
178178
179179 func (s *Server) login(w http.ResponseWriter, r *http.Request) {
180- if !s.limiter.allow(clientIP(r)) {
180+ ip := clientIP(r)
181+ if s.limiter.blocked(ip) {
181182 w.WriteHeader(429)
182183 s.render(w, r, "login", page{Title: "Log in", Error: "Too many attempts, try again in a few minutes"})
183184 return
184185 }
185186 u, ok := s.store.CheckPassword(strings.TrimSpace(r.FormValue("name")), r.FormValue("password"))
186187 if !ok {
188+ s.limiter.fail(ip)
187189 w.WriteHeader(401)
188190 s.render(w, r, "login", page{Title: "Log in", Error: "Wrong name or password"})
189191 return
@@ -218,7 +220,8 @@ func (s *Server) register(w http.ResponseWriter, r *http.Request) {
218220 w.WriteHeader(code)
219221 s.render(w, r, "register", page{Title: "Register", Error: msg, Data: map[string]any{"Open": s.open, "Invite": invite}})
220222 }
221- if !s.limiter.allow(clientIP(r)) {
223+ ip := clientIP(r)
224+ if s.limiter.blocked(ip) {
222225 retry(429, "Too many attempts, try again in a few minutes")
223226 return
224227 }
@@ -228,6 +231,7 @@ func (s *Server) register(w http.ResponseWriter, r *http.Request) {
228231 return
229232 }
230233 if !s.open && !s.store.UseInvite(invite) {
234+ s.limiter.fail(ip)
231235 retry(400, "That invite is invalid or already used")
232236 return
233237 }
@@ -424,7 +428,8 @@ func (s *Server) password(w http.ResponseWriter, r *http.Request) {
424428 return
425429 }
426430 if _, ok := s.store.CheckPassword(u.Name, r.FormValue("current")); !ok {
427- http.Redirect(w, r, "/settings?error=current+password+is+wrong", http.StatusSeeOther)
431+ s.limiter.fail(clientIP(r))
432+ http.Redirect(w, r, "/settings?error=Current+password+is+wrong", http.StatusSeeOther)
428433 return
429434 }
430435 if err := s.store.SetPassword(u.ID, r.FormValue("password")); err != nil {
@@ -563,6 +568,10 @@ func (s *Server) summary(w http.ResponseWriter, r *http.Request, repo Repo) {
563568
564569 var displayMath = regexp.MustCompile(`(?m)^( {0,3})\$\$([^$\n]+)\$\$[ \t]*$`)
565570
571+const maxRender = 1 << 20
572+
573+const maxRaw = 64 << 20
574+
566575 func (s *Server) renderMarkdown(src []byte) template.HTML {
567576 src = displayMath.ReplaceAll(src, []byte("$1$$$$\n$2\n$$$$"))
568577 var buf bytes.Buffer
@@ -629,16 +638,19 @@ func (s *Server) tree(w http.ResponseWriter, r *http.Request, repo Repo, spec st
629638 data["Last"] = g.LastCommits(rp.Commit, filePath, names)
630639 s.render(w, r, "tree", page{Title: repo.Path() + "/" + filePath, Data: data})
631640 case "blob":
632- content, err := g.Blob(rp.Commit, filePath)
633- if err != nil {
634- s.fail(w, r, 404, "No such path")
635- return
641+ size := g.Size(rp.Commit, filePath)
642+ data["Size"] = size
643+ var content []byte
644+ if size <= maxRender {
645+ if content, err = g.Blob(rp.Commit, filePath); err != nil {
646+ s.fail(w, r, 404, "No such path")
647+ return
648+ }
636649 }
637- data["Size"] = int64(len(content))
638- if isBinary(content) {
639- data["Binary"] = true
640- } else if len(content) > 1<<20 {
650+ if size > maxRender {
641651 data["Large"] = true
652+ } else if isBinary(content) {
653+ data["Binary"] = true
642654 } else if strings.HasSuffix(strings.ToLower(filePath), ".md") && r.URL.Query().Get("source") == "" {
643655 data["Markdown"] = s.renderMarkdown(content)
644656 } else if h, ok := highlight(filePath, content); ok {
@@ -669,6 +681,10 @@ func (s *Server) raw(w http.ResponseWriter, r *http.Request, repo Repo, spec str
669681 s.fail(w, r, 404, "No such ref")
670682 return
671683 }
684+ if g.Size(commit, filePath) > maxRaw {
685+ s.fail(w, r, 413, "File too large to serve")
686+ return
687+ }
672688 content, err := g.Blob(commit, filePath)
673689 if err != nil {
674690 s.fail(w, r, 404, "No such path")