Move the package into src/

Thirty-seven entries in the root, most of them .go files. The assets had to
come along: //go:embed cannot reach outside its own directory, so
templates/, static/ and migrations/ live beside the code that embeds them,
and testdata/ beside the test that reads it. storage/ stays put — runtime
data, not source.

go build now needs -o. Without it the output would be named after the
package directory and collide with src/ itself.
This commit is contained in:
Esa Kataja
2026-09-05 13:53:04 +03:00
parent 5db19b26ae
commit b01d08b1e1
48 changed files with 9 additions and 3 deletions
+73
View File
@@ -0,0 +1,73 @@
package main
import (
"sync"
"time"
)
// Login attempts are limited per email address, which is the thing under attack — and the only key
// available without parsing X-Forwarded-For and maintaining a trusted-proxy list.
//
// ponytail: in memory, dies with the process. A restart clearing the counters is not an attack an
// attacker can mount. A shared store is the upgrade if this ever runs as more than one process.
const (
loginMaxFailures = 10
loginWindow = 15 * time.Minute
loginLockout = 15 * time.Minute
)
type attempts struct {
count int
first time.Time
until time.Time // zero unless locked
}
type limiter struct {
mu sync.Mutex
by map[string]*attempts
}
// locked reports whether the key is currently refused. It does not count as an attempt.
func (l *limiter) locked(key string) bool {
l.mu.Lock()
defer l.mu.Unlock()
a := l.by[key]
return a != nil && time.Now().Before(a.until)
}
func (l *limiter) fail(key string) {
now := time.Now()
l.mu.Lock()
defer l.mu.Unlock()
if l.by == nil {
l.by = map[string]*attempts{}
}
l.sweep(now)
a := l.by[key]
if a == nil || now.Sub(a.first) > loginWindow {
l.by[key] = &attempts{count: 1, first: now}
return
}
a.count++
if a.count >= loginMaxFailures {
a.until = now.Add(loginLockout)
}
}
// A correct password clears the record: the limit is on guessing, not on the account. Locking the
// account itself would let anyone lock its owner out by trying.
func (l *limiter) succeed(key string) {
l.mu.Lock()
defer l.mu.Unlock()
delete(l.by, key)
}
// Called under the lock, on failures only — there is nothing to grow the map otherwise.
func (l *limiter) sweep(now time.Time) {
for k, a := range l.by {
if now.Sub(a.first) > loginWindow && now.After(a.until) {
delete(l.by, k)
}
}
}