Address production-readiness review: clearer errors, safer votes, and ops hardening.
Distinguish auth/lookup failures, make votes idempotent on visible questions, bound shutdown, page admin users, LRU throttle, trusted-proxy CIDRs, avatar cleanup, versioned migrations, and session cleanup logging.
This commit is contained in:
+3
-2
@@ -11,8 +11,9 @@ DATABASE_URL=postgresql://user:password@host.example.com:5432/postgres?sslmode=v
|
|||||||
# When PORT is set (App Platform), cookies are Secure by default; SECURE_COOKIE=0 is rejected.
|
# When PORT is set (App Platform), cookies are Secure by default; SECURE_COOKIE=0 is rejected.
|
||||||
# Locally, set to 1 when serving over HTTPS:
|
# Locally, set to 1 when serving over HTTPS:
|
||||||
SECURE_COOKIE=0
|
SECURE_COOKIE=0
|
||||||
# Set to 1 only behind a trusted reverse proxy that sets X-Forwarded-For.
|
# Comma-separated CIDRs of reverse proxies allowed to set X-Forwarded-For
|
||||||
# TRUST_PROXY=0
|
# (direct peer must match). Leave unset to ignore XFF and use RemoteAddr.
|
||||||
|
# TRUSTED_PROXY_CIDRS=10.0.0.0/8,192.168.0.0/16
|
||||||
# DigitalOcean Spaces (profile avatars). Leave unset to disable uploads.
|
# DigitalOcean Spaces (profile avatars). Leave unset to disable uploads.
|
||||||
# SPACES_KEY=
|
# SPACES_KEY=
|
||||||
# SPACES_SECRET=
|
# SPACES_SECRET=
|
||||||
|
|||||||
+29
-5
@@ -5,6 +5,7 @@ import (
|
|||||||
"database/sql"
|
"database/sql"
|
||||||
"errors"
|
"errors"
|
||||||
"log"
|
"log"
|
||||||
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
@@ -56,7 +57,7 @@ func newHandler(db *sql.DB, sessions *store.SessionStore, uploader blob.Uploader
|
|||||||
srv, err := web.New(store.NewPostgres(db), sessions.Store(), plumber.TemplateFS, plumber.StaticFS, web.Config{
|
srv, err := web.New(store.NewPostgres(db), sessions.Store(), plumber.TemplateFS, plumber.StaticFS, web.Config{
|
||||||
AdminSetupSecret: strings.TrimSpace(os.Getenv("ADMIN_SETUP_SECRET")),
|
AdminSetupSecret: strings.TrimSpace(os.Getenv("ADMIN_SETUP_SECRET")),
|
||||||
SecureCookie: secureCookieFromEnv(),
|
SecureCookie: secureCookieFromEnv(),
|
||||||
TrustProxy: os.Getenv("TRUST_PROXY") == "1",
|
TrustedProxies: parseTrustedProxies(os.Getenv("TRUSTED_PROXY_CIDRS")),
|
||||||
Blob: uploader,
|
Blob: uploader,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -65,6 +66,22 @@ func newHandler(db *sql.DB, sessions *store.SessionStore, uploader blob.Uploader
|
|||||||
return srv.Handler()
|
return srv.Handler()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func parseTrustedProxies(raw string) []*net.IPNet {
|
||||||
|
var out []*net.IPNet
|
||||||
|
for _, part := range strings.Split(raw, ",") {
|
||||||
|
part = strings.TrimSpace(part)
|
||||||
|
if part == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
_, n, err := net.ParseCIDR(part)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("TRUSTED_PROXY_CIDRS: bad CIDR %q: %v", part, err)
|
||||||
|
}
|
||||||
|
out = append(out, n)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
// secureCookieFromEnv defaults to secure when PORT is set (PaaS/production)
|
// secureCookieFromEnv defaults to secure when PORT is set (PaaS/production)
|
||||||
// and refuses an explicit disable in that environment.
|
// and refuses an explicit disable in that environment.
|
||||||
func secureCookieFromEnv() bool {
|
func secureCookieFromEnv() bool {
|
||||||
@@ -96,12 +113,19 @@ func run(httpSrv *http.Server) {
|
|||||||
case sig := <-sigCh:
|
case sig := <-sigCh:
|
||||||
log.Printf("shutdown signal: %v", sig)
|
log.Printf("shutdown signal: %v", sig)
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
defer cancel()
|
err := httpSrv.Shutdown(ctx)
|
||||||
if err := httpSrv.Shutdown(ctx); err != nil {
|
cancel()
|
||||||
|
if err != nil {
|
||||||
log.Printf("shutdown: %v", err)
|
log.Printf("shutdown: %v", err)
|
||||||
|
_ = httpSrv.Close()
|
||||||
}
|
}
|
||||||
if err := <-errCh; err != nil && !errors.Is(err, http.ErrServerClosed) {
|
select {
|
||||||
log.Fatal(err)
|
case err := <-errCh:
|
||||||
|
if err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||||
|
log.Printf("server exit: %v", err)
|
||||||
|
}
|
||||||
|
case <-time.After(3 * time.Second):
|
||||||
|
log.Printf("server exit: timed out waiting for ListenAndServe")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,6 @@ SET data = excluded.data, expiry = excluded.expiry;
|
|||||||
DELETE FROM sessions
|
DELETE FROM sessions
|
||||||
WHERE token = $1;
|
WHERE token = $1;
|
||||||
|
|
||||||
-- name: DeleteExpiredSessions :exec
|
-- name: DeleteExpiredSessions :execrows
|
||||||
DELETE FROM sessions
|
DELETE FROM sessions
|
||||||
WHERE expiry <= now();
|
WHERE expiry <= now();
|
||||||
|
|||||||
+11
-1
@@ -15,7 +15,17 @@ WHERE username = $1;
|
|||||||
-- name: ListUsers :many
|
-- name: ListUsers :many
|
||||||
SELECT id, username, name, role, avatar_url, state, created_at
|
SELECT id, username, name, role, avatar_url, state, created_at
|
||||||
FROM users
|
FROM users
|
||||||
ORDER BY created_at ASC
|
WHERE (
|
||||||
|
sqlc.arg(search) = ''
|
||||||
|
OR username ILIKE '%' || sqlc.arg(search) || '%'
|
||||||
|
OR name ILIKE '%' || sqlc.arg(search) || '%'
|
||||||
|
)
|
||||||
|
AND (
|
||||||
|
sqlc.arg(cursor_created) = ''
|
||||||
|
OR created_at < sqlc.arg(cursor_created)
|
||||||
|
OR (created_at = sqlc.arg(cursor_created) AND id < sqlc.arg(cursor_id))
|
||||||
|
)
|
||||||
|
ORDER BY created_at DESC, id DESC
|
||||||
LIMIT sqlc.arg(row_limit);
|
LIMIT sqlc.arg(row_limit);
|
||||||
|
|
||||||
-- name: CountAdmins :one
|
-- name: CountAdmins :one
|
||||||
|
|||||||
+13
-3
@@ -3,12 +3,22 @@ SELECT value
|
|||||||
FROM votes
|
FROM votes
|
||||||
WHERE user_id = $1 AND question_id = $2;
|
WHERE user_id = $1 AND question_id = $2;
|
||||||
|
|
||||||
|
-- name: QuestionIsVisible :one
|
||||||
|
SELECT EXISTS(
|
||||||
|
SELECT 1 FROM questions WHERE id = $1 AND hidden = 0
|
||||||
|
)::bool;
|
||||||
|
|
||||||
-- name: DeleteVote :exec
|
-- name: DeleteVote :exec
|
||||||
DELETE FROM votes
|
DELETE FROM votes
|
||||||
WHERE user_id = $1 AND question_id = $2;
|
WHERE user_id = $1 AND question_id = $2;
|
||||||
|
|
||||||
-- name: UpsertVote :exec
|
-- name: UpsertVoteOnVisible :execrows
|
||||||
INSERT INTO votes (user_id, question_id, value)
|
INSERT INTO votes (user_id, question_id, value)
|
||||||
VALUES ($1, $2, $3)
|
SELECT $1, $2, $3
|
||||||
|
FROM questions q
|
||||||
|
WHERE q.id = $2 AND q.hidden = 0
|
||||||
ON CONFLICT (user_id, question_id) DO UPDATE
|
ON CONFLICT (user_id, question_id) DO UPDATE
|
||||||
SET value = excluded.value;
|
SET value = excluded.value
|
||||||
|
WHERE EXISTS (
|
||||||
|
SELECT 1 FROM questions q2 WHERE q2.id = excluded.question_id AND q2.hidden = 0
|
||||||
|
);
|
||||||
|
|||||||
+41
-4
@@ -17,6 +17,7 @@ import (
|
|||||||
type Uploader interface {
|
type Uploader interface {
|
||||||
Enabled() bool
|
Enabled() bool
|
||||||
Upload(ctx context.Context, obj FileUpload) (publicURL string, err error)
|
Upload(ctx context.Context, obj FileUpload) (publicURL string, err error)
|
||||||
|
Delete(ctx context.Context, key string) error
|
||||||
}
|
}
|
||||||
|
|
||||||
// FileUpload is a file body to store (e.g. an avatar).
|
// FileUpload is a file body to store (e.g. an avatar).
|
||||||
@@ -51,6 +52,8 @@ func (Disabled) Upload(context.Context, FileUpload) (string, error) {
|
|||||||
return "", fmt.Errorf("avatar uploads are not configured")
|
return "", fmt.Errorf("avatar uploads are not configured")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (Disabled) Delete(context.Context, string) error { return nil }
|
||||||
|
|
||||||
// FromEnv builds an Uploader from SPACES_* environment variables.
|
// FromEnv builds an Uploader from SPACES_* environment variables.
|
||||||
func FromEnv() Uploader {
|
func FromEnv() Uploader {
|
||||||
return NewSpaces(SpacesConfig{
|
return NewSpaces(SpacesConfig{
|
||||||
@@ -99,11 +102,45 @@ func (s *spaces) Upload(ctx context.Context, obj FileUpload) (string, error) {
|
|||||||
if _, err := s.client.PutObject(ctx, input); err != nil {
|
if _, err := s.client.PutObject(ctx, input); err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
if s.cfg.CDNBase != "" {
|
return s.publicURL(key), nil
|
||||||
return s.cfg.CDNBase + "/" + key, nil
|
}
|
||||||
|
|
||||||
|
func (s *spaces) Delete(ctx context.Context, key string) error {
|
||||||
|
key = strings.TrimPrefix(key, "/")
|
||||||
|
if key == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
_, err := s.client.DeleteObject(ctx, &s3.DeleteObjectInput{
|
||||||
|
Bucket: aws.String(s.cfg.Bucket),
|
||||||
|
Key: aws.String(key),
|
||||||
|
})
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *spaces) publicURL(key string) string {
|
||||||
|
if s.cfg.CDNBase != "" {
|
||||||
|
return s.cfg.CDNBase + "/" + key
|
||||||
}
|
}
|
||||||
// Virtual-hosted–style Spaces URL.
|
|
||||||
host := strings.TrimPrefix(s.cfg.Endpoint, "https://")
|
host := strings.TrimPrefix(s.cfg.Endpoint, "https://")
|
||||||
host = strings.TrimPrefix(host, "http://")
|
host = strings.TrimPrefix(host, "http://")
|
||||||
return fmt.Sprintf("https://%s.%s/%s", s.cfg.Bucket, host, key), nil
|
return fmt.Sprintf("https://%s.%s/%s", s.cfg.Bucket, host, key)
|
||||||
|
}
|
||||||
|
|
||||||
|
// KeyFromPublicURL extracts the object key from a Spaces/CDN URL when possible.
|
||||||
|
func KeyFromPublicURL(publicURL, cdnBase, bucket, endpoint string) string {
|
||||||
|
publicURL = strings.TrimSpace(publicURL)
|
||||||
|
if publicURL == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
cdnBase = strings.TrimRight(strings.TrimSpace(cdnBase), "/")
|
||||||
|
if cdnBase != "" && strings.HasPrefix(publicURL, cdnBase+"/") {
|
||||||
|
return strings.TrimPrefix(publicURL, cdnBase+"/")
|
||||||
|
}
|
||||||
|
host := strings.TrimPrefix(strings.TrimSpace(endpoint), "https://")
|
||||||
|
host = strings.TrimPrefix(host, "http://")
|
||||||
|
prefix := fmt.Sprintf("https://%s.%s/", bucket, host)
|
||||||
|
if strings.HasPrefix(publicURL, prefix) {
|
||||||
|
return strings.TrimPrefix(publicURL, prefix)
|
||||||
|
}
|
||||||
|
return ""
|
||||||
}
|
}
|
||||||
|
|||||||
+37
-10
@@ -43,7 +43,7 @@ func (m *Memory) CreateUser(_ context.Context, u *User) error {
|
|||||||
}
|
}
|
||||||
u.Username = NormalizeUsername(u.Username)
|
u.Username = NormalizeUsername(u.Username)
|
||||||
if _, ok := m.byName[u.Username]; ok {
|
if _, ok := m.byName[u.Username]; ok {
|
||||||
return fmt.Errorf("username taken")
|
return ErrDuplicateUsername
|
||||||
}
|
}
|
||||||
if u.ID == "" {
|
if u.ID == "" {
|
||||||
u.ID = uuid.NewString()
|
u.ID = uuid.NewString()
|
||||||
@@ -94,18 +94,44 @@ func (m *Memory) UserByUsername(_ context.Context, username string) (*User, erro
|
|||||||
return &cp, nil
|
return &cp, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Memory) ListUsers(_ context.Context) ([]User, error) {
|
func (m *Memory) ListUsers(_ context.Context, q ListUsersQuery) ([]User, string, string, error) {
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
defer m.mu.Unlock()
|
defer m.mu.Unlock()
|
||||||
|
limit := q.Limit
|
||||||
|
if limit <= 0 {
|
||||||
|
limit = AdminUsersLimit
|
||||||
|
}
|
||||||
|
search := strings.ToLower(strings.TrimSpace(q.Search))
|
||||||
out := make([]User, 0, len(m.users))
|
out := make([]User, 0, len(m.users))
|
||||||
for _, u := range m.users {
|
for _, u := range m.users {
|
||||||
|
if search != "" &&
|
||||||
|
!strings.Contains(strings.ToLower(u.Username), search) &&
|
||||||
|
!strings.Contains(strings.ToLower(u.Name), search) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if q.CursorCreated != "" {
|
||||||
|
if u.CreatedAt > q.CursorCreated {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if u.CreatedAt == q.CursorCreated && u.ID >= q.CursorID {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
out = append(out, *u)
|
out = append(out, *u)
|
||||||
}
|
}
|
||||||
sort.Slice(out, func(i, j int) bool { return out[i].CreatedAt < out[j].CreatedAt })
|
sort.Slice(out, func(i, j int) bool {
|
||||||
if len(out) > AdminUsersLimit {
|
if out[i].CreatedAt != out[j].CreatedAt {
|
||||||
out = out[:AdminUsersLimit]
|
return out[i].CreatedAt > out[j].CreatedAt
|
||||||
}
|
}
|
||||||
return out, nil
|
return out[i].ID > out[j].ID
|
||||||
|
})
|
||||||
|
var nextCreated, nextID string
|
||||||
|
if len(out) > limit {
|
||||||
|
last := out[limit-1]
|
||||||
|
nextCreated, nextID = last.CreatedAt, last.ID
|
||||||
|
out = out[:limit]
|
||||||
|
}
|
||||||
|
return out, nextCreated, nextID, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Memory) CountAdmins(_ context.Context) (int, error) {
|
func (m *Memory) CountAdmins(_ context.Context) (int, error) {
|
||||||
@@ -331,16 +357,17 @@ func (m *Memory) UpsertAnswer(_ context.Context, a *Answer) error {
|
|||||||
func (m *Memory) Vote(_ context.Context, userID, questionID string, value int) error {
|
func (m *Memory) Vote(_ context.Context, userID, questionID string, value int) error {
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
defer m.mu.Unlock()
|
defer m.mu.Unlock()
|
||||||
if value != 1 && value != -1 {
|
if value != 1 && value != -1 && value != 0 {
|
||||||
return fmt.Errorf("invalid vote")
|
return fmt.Errorf("invalid vote")
|
||||||
}
|
}
|
||||||
if _, ok := m.questions[questionID]; !ok {
|
q, ok := m.questions[questionID]
|
||||||
return sql.ErrNoRows
|
if !ok || q.Hidden {
|
||||||
|
return ErrHiddenOrMissing
|
||||||
}
|
}
|
||||||
if m.votes[questionID] == nil {
|
if m.votes[questionID] == nil {
|
||||||
m.votes[questionID] = map[string]int{}
|
m.votes[questionID] = map[string]int{}
|
||||||
}
|
}
|
||||||
if cur, ok := m.votes[questionID][userID]; ok && cur == value {
|
if value == 0 {
|
||||||
delete(m.votes[questionID], userID)
|
delete(m.votes[questionID], userID)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package store
|
|||||||
import (
|
import (
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"log"
|
||||||
)
|
)
|
||||||
|
|
||||||
// migrateUserProfileColumns adds avatar_url and state when missing (existing DBs).
|
// migrateUserProfileColumns adds avatar_url and state when missing (existing DBs).
|
||||||
@@ -16,3 +17,71 @@ func migrateUserProfileColumns(db *sql.DB) error {
|
|||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const migrateLockKey int64 = 0x706c756d5f6d6967 // "plum_mig"
|
||||||
|
|
||||||
|
// applyMigrations runs versioned migrations under an advisory lock.
|
||||||
|
// Fresh databases apply schemaSQL as version 001; later versions are incremental.
|
||||||
|
func applyMigrations(db *sql.DB, schemaSQL string) error {
|
||||||
|
tx, err := db.Begin()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
if _, err := tx.Exec(`SELECT pg_advisory_xact_lock($1)`, migrateLockKey); err != nil {
|
||||||
|
return fmt.Errorf("migrate lock: %w", err)
|
||||||
|
}
|
||||||
|
if _, err := tx.Exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||||
|
version TEXT PRIMARY KEY,
|
||||||
|
applied_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
)`); err != nil {
|
||||||
|
return fmt.Errorf("schema_migrations: %w", err)
|
||||||
|
}
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
applied, err := appliedVersions(db)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
migrations := []struct {
|
||||||
|
version string
|
||||||
|
run func(*sql.DB) error
|
||||||
|
}{
|
||||||
|
{"001_schema", func(db *sql.DB) error { return applySchema(db, schemaSQL) }},
|
||||||
|
{"002_user_profile_columns", migrateUserProfileColumns},
|
||||||
|
}
|
||||||
|
for _, m := range migrations {
|
||||||
|
if applied[m.version] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
log.Printf("migrate: applying %s", m.version)
|
||||||
|
if err := m.run(db); err != nil {
|
||||||
|
return fmt.Errorf("migrate %s: %w", m.version, err)
|
||||||
|
}
|
||||||
|
if _, err := db.Exec(`INSERT INTO schema_migrations (version) VALUES ($1)`, m.version); err != nil {
|
||||||
|
return fmt.Errorf("record %s: %w", m.version, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func appliedVersions(db *sql.DB) (map[string]bool, error) {
|
||||||
|
rows, err := db.Query(`SELECT version FROM schema_migrations`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
out := map[string]bool{}
|
||||||
|
for rows.Next() {
|
||||||
|
var v string
|
||||||
|
if err := rows.Scan(&v); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out[v] = true
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|||||||
@@ -63,13 +63,9 @@ func OpenPostgres(databaseURL, schema string) (*sql.DB, *SessionStore, error) {
|
|||||||
_ = db.Close()
|
_ = db.Close()
|
||||||
return nil, nil, fmt.Errorf("postgres ping: %w", err)
|
return nil, nil, fmt.Errorf("postgres ping: %w", err)
|
||||||
}
|
}
|
||||||
if err := applySchema(db, schema); err != nil {
|
if err := applyMigrations(db, schema); err != nil {
|
||||||
_ = db.Close()
|
_ = db.Close()
|
||||||
return nil, nil, fmt.Errorf("apply schema: %w", err)
|
return nil, nil, fmt.Errorf("migrate: %w", err)
|
||||||
}
|
|
||||||
if err := migrateUserProfileColumns(db); err != nil {
|
|
||||||
_ = db.Close()
|
|
||||||
return nil, nil, fmt.Errorf("migrate profile columns: %w", err)
|
|
||||||
}
|
}
|
||||||
sessions := NewSessionStore(db, 5*time.Minute)
|
sessions := NewSessionStore(db, 5*time.Minute)
|
||||||
return db, sessions, nil
|
return db, sessions, nil
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ func (p *Postgres) CreateUser(ctx context.Context, u *User) error {
|
|||||||
Role: string(role),
|
Role: string(role),
|
||||||
CreatedAt: u.CreatedAt,
|
CreatedAt: u.CreatedAt,
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
return err
|
return mapUniqueViolation(err)
|
||||||
}
|
}
|
||||||
if err := tx.Commit(); err != nil {
|
if err := tx.Commit(); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -89,8 +89,8 @@ func (p *Postgres) UserByUsername(ctx context.Context, username string) (*User,
|
|||||||
return UserByUsername(ctx, p.db, username)
|
return UserByUsername(ctx, p.db, username)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *Postgres) ListUsers(ctx context.Context) ([]User, error) {
|
func (p *Postgres) ListUsers(ctx context.Context, q ListUsersQuery) ([]User, string, string, error) {
|
||||||
return ListUsers(ctx, p.db)
|
return ListUsers(ctx, p.db, q)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *Postgres) CountAdmins(ctx context.Context) (int, error) {
|
func (p *Postgres) CountAdmins(ctx context.Context) (int, error) {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"errors"
|
"errors"
|
||||||
|
"log"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -103,10 +104,21 @@ func (s *SessionStore) cleanupLoop(interval time.Duration) {
|
|||||||
defer close(s.stopped)
|
defer close(s.stopped)
|
||||||
ticker := time.NewTicker(interval)
|
ticker := time.NewTicker(interval)
|
||||||
defer ticker.Stop()
|
defer ticker.Stop()
|
||||||
|
var lastErrLog time.Time
|
||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
case <-ticker.C:
|
case <-ticker.C:
|
||||||
_ = s.q.DeleteExpiredSessions(context.Background())
|
n, err := s.q.DeleteExpiredSessions(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
if time.Since(lastErrLog) > time.Minute {
|
||||||
|
log.Printf("session cleanup: %v", err)
|
||||||
|
lastErrLog = time.Now()
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if n > 0 {
|
||||||
|
log.Printf("session cleanup: deleted %d expired row(s)", n)
|
||||||
|
}
|
||||||
case <-s.stop:
|
case <-s.stop:
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,14 +10,17 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
const deleteExpiredSessions = `-- name: DeleteExpiredSessions :exec
|
const deleteExpiredSessions = `-- name: DeleteExpiredSessions :execrows
|
||||||
DELETE FROM sessions
|
DELETE FROM sessions
|
||||||
WHERE expiry <= now()
|
WHERE expiry <= now()
|
||||||
`
|
`
|
||||||
|
|
||||||
func (q *Queries) DeleteExpiredSessions(ctx context.Context) error {
|
func (q *Queries) DeleteExpiredSessions(ctx context.Context) (int64, error) {
|
||||||
_, err := q.db.ExecContext(ctx, deleteExpiredSessions)
|
result, err := q.db.ExecContext(ctx, deleteExpiredSessions)
|
||||||
return err
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return result.RowsAffected()
|
||||||
}
|
}
|
||||||
|
|
||||||
const deleteSession = `-- name: DeleteSession :exec
|
const deleteSession = `-- name: DeleteSession :exec
|
||||||
|
|||||||
@@ -129,10 +129,27 @@ func (q *Queries) GetUserRole(ctx context.Context, id string) (string, error) {
|
|||||||
const listUsers = `-- name: ListUsers :many
|
const listUsers = `-- name: ListUsers :many
|
||||||
SELECT id, username, name, role, avatar_url, state, created_at
|
SELECT id, username, name, role, avatar_url, state, created_at
|
||||||
FROM users
|
FROM users
|
||||||
ORDER BY created_at ASC
|
WHERE (
|
||||||
LIMIT $1
|
$1 = ''
|
||||||
|
OR username ILIKE '%' || $1 || '%'
|
||||||
|
OR name ILIKE '%' || $1 || '%'
|
||||||
|
)
|
||||||
|
AND (
|
||||||
|
$2 = ''
|
||||||
|
OR created_at < $2
|
||||||
|
OR (created_at = $2 AND id < $3)
|
||||||
|
)
|
||||||
|
ORDER BY created_at DESC, id DESC
|
||||||
|
LIMIT $4
|
||||||
`
|
`
|
||||||
|
|
||||||
|
type ListUsersParams struct {
|
||||||
|
Search interface{}
|
||||||
|
CursorCreated interface{}
|
||||||
|
CursorID string
|
||||||
|
RowLimit int32
|
||||||
|
}
|
||||||
|
|
||||||
type ListUsersRow struct {
|
type ListUsersRow struct {
|
||||||
ID string
|
ID string
|
||||||
Username string
|
Username string
|
||||||
@@ -143,8 +160,13 @@ type ListUsersRow struct {
|
|||||||
CreatedAt string
|
CreatedAt string
|
||||||
}
|
}
|
||||||
|
|
||||||
func (q *Queries) ListUsers(ctx context.Context, rowLimit int32) ([]ListUsersRow, error) {
|
func (q *Queries) ListUsers(ctx context.Context, arg ListUsersParams) ([]ListUsersRow, error) {
|
||||||
rows, err := q.db.QueryContext(ctx, listUsers, rowLimit)
|
rows, err := q.db.QueryContext(ctx, listUsers,
|
||||||
|
arg.Search,
|
||||||
|
arg.CursorCreated,
|
||||||
|
arg.CursorID,
|
||||||
|
arg.RowLimit,
|
||||||
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,20 +42,41 @@ func (q *Queries) GetVote(ctx context.Context, arg GetVoteParams) (int32, error)
|
|||||||
return value, err
|
return value, err
|
||||||
}
|
}
|
||||||
|
|
||||||
const upsertVote = `-- name: UpsertVote :exec
|
const questionIsVisible = `-- name: QuestionIsVisible :one
|
||||||
INSERT INTO votes (user_id, question_id, value)
|
SELECT EXISTS(
|
||||||
VALUES ($1, $2, $3)
|
SELECT 1 FROM questions WHERE id = $1 AND hidden = 0
|
||||||
ON CONFLICT (user_id, question_id) DO UPDATE
|
)::bool
|
||||||
SET value = excluded.value
|
|
||||||
`
|
`
|
||||||
|
|
||||||
type UpsertVoteParams struct {
|
func (q *Queries) QuestionIsVisible(ctx context.Context, id string) (bool, error) {
|
||||||
|
row := q.db.QueryRowContext(ctx, questionIsVisible, id)
|
||||||
|
var column_1 bool
|
||||||
|
err := row.Scan(&column_1)
|
||||||
|
return column_1, err
|
||||||
|
}
|
||||||
|
|
||||||
|
const upsertVoteOnVisible = `-- name: UpsertVoteOnVisible :execrows
|
||||||
|
INSERT INTO votes (user_id, question_id, value)
|
||||||
|
SELECT $1, $2, $3
|
||||||
|
FROM questions q
|
||||||
|
WHERE q.id = $2 AND q.hidden = 0
|
||||||
|
ON CONFLICT (user_id, question_id) DO UPDATE
|
||||||
|
SET value = excluded.value
|
||||||
|
WHERE EXISTS (
|
||||||
|
SELECT 1 FROM questions q2 WHERE q2.id = excluded.question_id AND q2.hidden = 0
|
||||||
|
)
|
||||||
|
`
|
||||||
|
|
||||||
|
type UpsertVoteOnVisibleParams struct {
|
||||||
UserID string
|
UserID string
|
||||||
QuestionID string
|
QuestionID string
|
||||||
Value int32
|
Value int32
|
||||||
}
|
}
|
||||||
|
|
||||||
func (q *Queries) UpsertVote(ctx context.Context, arg UpsertVoteParams) error {
|
func (q *Queries) UpsertVoteOnVisible(ctx context.Context, arg UpsertVoteOnVisibleParams) (int64, error) {
|
||||||
_, err := q.db.ExecContext(ctx, upsertVote, arg.UserID, arg.QuestionID, arg.Value)
|
result, err := q.db.ExecContext(ctx, upsertVoteOnVisible, arg.UserID, arg.QuestionID, arg.Value)
|
||||||
return err
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return result.RowsAffected()
|
||||||
}
|
}
|
||||||
|
|||||||
+11
-2
@@ -6,15 +6,23 @@ import "context"
|
|||||||
const (
|
const (
|
||||||
HuntListLimit = 100
|
HuntListLimit = 100
|
||||||
ProfileListLimit = 50
|
ProfileListLimit = 50
|
||||||
AdminUsersLimit = 200
|
AdminUsersLimit = 50
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// ListUsersQuery is a paginated admin user search.
|
||||||
|
type ListUsersQuery struct {
|
||||||
|
Search string
|
||||||
|
CursorCreated string
|
||||||
|
CursorID string
|
||||||
|
Limit int
|
||||||
|
}
|
||||||
|
|
||||||
// Store is the application persistence API used by the web layer.
|
// Store is the application persistence API used by the web layer.
|
||||||
type Store interface {
|
type Store interface {
|
||||||
CreateUser(ctx context.Context, u *User) error
|
CreateUser(ctx context.Context, u *User) error
|
||||||
UserByID(ctx context.Context, id string) (*User, error)
|
UserByID(ctx context.Context, id string) (*User, error)
|
||||||
UserByUsername(ctx context.Context, username string) (*User, error)
|
UserByUsername(ctx context.Context, username string) (*User, error)
|
||||||
ListUsers(ctx context.Context) ([]User, error)
|
ListUsers(ctx context.Context, q ListUsersQuery) (users []User, nextCursorCreated, nextCursorID string, err error)
|
||||||
CountAdmins(ctx context.Context) (int, error)
|
CountAdmins(ctx context.Context) (int, error)
|
||||||
SetUserRole(ctx context.Context, id string, role Role) error
|
SetUserRole(ctx context.Context, id string, role Role) error
|
||||||
SaveUserProfile(ctx context.Context, u *User) error
|
SaveUserProfile(ctx context.Context, u *User) error
|
||||||
@@ -29,5 +37,6 @@ type Store interface {
|
|||||||
GetAnswer(ctx context.Context, questionID string) (*Answer, error)
|
GetAnswer(ctx context.Context, questionID string) (*Answer, error)
|
||||||
UpsertAnswer(ctx context.Context, a *Answer) error
|
UpsertAnswer(ctx context.Context, a *Answer) error
|
||||||
|
|
||||||
|
// Vote sets the vote to 1, -1, or 0 (clear) on a visible question.
|
||||||
Vote(ctx context.Context, userID, questionID string, value int) error
|
Vote(ctx context.Context, userID, questionID string, value int) error
|
||||||
}
|
}
|
||||||
|
|||||||
+21
-6
@@ -82,14 +82,14 @@ func (u *User) Create(ctx context.Context) error {
|
|||||||
if u.CreatedAt == "" {
|
if u.CreatedAt == "" {
|
||||||
u.CreatedAt = time.Now().UTC().Format(time.RFC3339)
|
u.CreatedAt = time.Now().UTC().Format(time.RFC3339)
|
||||||
}
|
}
|
||||||
return sqlc.New(u.db).CreateUser(ctx, sqlc.CreateUserParams{
|
return mapUniqueViolation(sqlc.New(u.db).CreateUser(ctx, sqlc.CreateUserParams{
|
||||||
ID: u.ID,
|
ID: u.ID,
|
||||||
Username: u.Username,
|
Username: u.Username,
|
||||||
Name: u.Name,
|
Name: u.Name,
|
||||||
PasswordHash: u.PasswordHash,
|
PasswordHash: u.PasswordHash,
|
||||||
Role: string(u.Role),
|
Role: string(u.Role),
|
||||||
CreatedAt: u.CreatedAt,
|
CreatedAt: u.CreatedAt,
|
||||||
})
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
// adminRoleLockKey serializes SetRole so concurrent demotions cannot bypass the
|
// adminRoleLockKey serializes SetRole so concurrent demotions cannot bypass the
|
||||||
@@ -171,17 +171,32 @@ func CountAdmins(ctx context.Context, db *sql.DB) (int, error) {
|
|||||||
return int(n), err
|
return int(n), err
|
||||||
}
|
}
|
||||||
|
|
||||||
func ListUsers(ctx context.Context, db *sql.DB) ([]User, error) {
|
func ListUsers(ctx context.Context, db *sql.DB, q ListUsersQuery) ([]User, string, string, error) {
|
||||||
rows, err := sqlc.New(db).ListUsers(ctx, AdminUsersLimit)
|
limit := q.Limit
|
||||||
|
if limit <= 0 {
|
||||||
|
limit = AdminUsersLimit
|
||||||
|
}
|
||||||
|
rows, err := sqlc.New(db).ListUsers(ctx, sqlc.ListUsersParams{
|
||||||
|
Search: q.Search,
|
||||||
|
CursorCreated: q.CursorCreated,
|
||||||
|
CursorID: q.CursorID,
|
||||||
|
RowLimit: int32(limit + 1),
|
||||||
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, "", "", err
|
||||||
}
|
}
|
||||||
out := make([]User, 0, len(rows))
|
out := make([]User, 0, len(rows))
|
||||||
for _, r := range rows {
|
for _, r := range rows {
|
||||||
u := toUser(db, r.ID, r.Username, r.Name, r.Role, r.AvatarUrl, r.State, r.CreatedAt, "")
|
u := toUser(db, r.ID, r.Username, r.Name, r.Role, r.AvatarUrl, r.State, r.CreatedAt, "")
|
||||||
out = append(out, *u)
|
out = append(out, *u)
|
||||||
}
|
}
|
||||||
return out, nil
|
var nextCreated, nextID string
|
||||||
|
if len(out) > limit {
|
||||||
|
last := out[limit-1]
|
||||||
|
nextCreated, nextID = last.CreatedAt, last.ID
|
||||||
|
out = out[:limit]
|
||||||
|
}
|
||||||
|
return out, nextCreated, nextID, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func UserByID(ctx context.Context, db *sql.DB, id string) (*User, error) {
|
func UserByID(ctx context.Context, db *sql.DB, id string) (*User, error) {
|
||||||
|
|||||||
+41
-17
@@ -3,38 +3,62 @@ package store
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgconn"
|
||||||
|
|
||||||
"plumber/internal/store/sqlc"
|
"plumber/internal/store/sqlc"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Vote toggles or sets a user's vote on a question (value must be 1 or -1).
|
// ErrDuplicateUsername is returned when inserting a username that already exists.
|
||||||
func Vote(ctx context.Context, db *sql.DB, userID, questionID string, value int) error {
|
var ErrDuplicateUsername = errors.New("username taken")
|
||||||
if value != 1 && value != -1 {
|
|
||||||
|
// ErrHiddenOrMissing is returned when voting on a hidden or unknown question.
|
||||||
|
var ErrHiddenOrMissing = errors.New("question not votable")
|
||||||
|
|
||||||
|
// SetVote sets the user's vote to value (1, -1, or 0 to clear) on a visible question.
|
||||||
|
func SetVote(ctx context.Context, db *sql.DB, userID, questionID string, value int) error {
|
||||||
|
if value != 1 && value != -1 && value != 0 {
|
||||||
return fmt.Errorf("invalid vote")
|
return fmt.Errorf("invalid vote")
|
||||||
}
|
}
|
||||||
tx, err := db.BeginTx(ctx, nil)
|
q := sqlc.New(db)
|
||||||
|
if value == 0 {
|
||||||
|
visible, err := q.QuestionIsVisible(ctx, questionID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
defer tx.Rollback()
|
if !visible {
|
||||||
|
return ErrHiddenOrMissing
|
||||||
q := sqlc.New(tx)
|
|
||||||
current, err := q.GetVote(ctx, sqlc.GetVoteParams{UserID: userID, QuestionID: questionID})
|
|
||||||
if err != nil && err != sql.ErrNoRows {
|
|
||||||
return err
|
|
||||||
}
|
}
|
||||||
if err == nil && int(current) == value {
|
return q.DeleteVote(ctx, sqlc.DeleteVoteParams{
|
||||||
err = q.DeleteVote(ctx, sqlc.DeleteVoteParams{UserID: userID, QuestionID: questionID})
|
UserID: userID,
|
||||||
} else {
|
QuestionID: questionID,
|
||||||
err = q.UpsertVote(ctx, sqlc.UpsertVoteParams{
|
})
|
||||||
|
}
|
||||||
|
n, err := q.UpsertVoteOnVisible(ctx, sqlc.UpsertVoteOnVisibleParams{
|
||||||
UserID: userID,
|
UserID: userID,
|
||||||
QuestionID: questionID,
|
QuestionID: questionID,
|
||||||
Value: int32(value),
|
Value: int32(value),
|
||||||
})
|
})
|
||||||
}
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return mapUniqueViolation(err)
|
||||||
}
|
}
|
||||||
return tx.Commit()
|
if n == 0 {
|
||||||
|
return ErrHiddenOrMissing
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Vote is kept as an alias for SetVote for callers that still use the old name.
|
||||||
|
func Vote(ctx context.Context, db *sql.DB, userID, questionID string, value int) error {
|
||||||
|
return SetVote(ctx, db, userID, questionID, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
func mapUniqueViolation(err error) error {
|
||||||
|
var pgErr *pgconn.PgError
|
||||||
|
if errors.As(err, &pgErr) && pgErr.Code == "23505" {
|
||||||
|
return ErrDuplicateUsername
|
||||||
|
}
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
+28
-2
@@ -3,6 +3,8 @@ package web
|
|||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
|
|
||||||
@@ -13,6 +15,9 @@ type adminUsersPage struct {
|
|||||||
page
|
page
|
||||||
Users []store.User
|
Users []store.User
|
||||||
Error string
|
Error string
|
||||||
|
Search string
|
||||||
|
NextCursor string
|
||||||
|
HasMore bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) requireAdmin(w http.ResponseWriter, r *http.Request) *store.User {
|
func (s *Server) requireAdmin(w http.ResponseWriter, r *http.Request) *store.User {
|
||||||
@@ -28,14 +33,35 @@ func (s *Server) handleAdminUsers(w http.ResponseWriter, r *http.Request) {
|
|||||||
if s.requireAdmin(w, r) == nil {
|
if s.requireAdmin(w, r) == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
users, err := s.store.ListUsers(r.Context())
|
search := strings.TrimSpace(r.URL.Query().Get("q"))
|
||||||
|
cursorCreated := r.URL.Query().Get("cursor_created")
|
||||||
|
cursorID := r.URL.Query().Get("cursor_id")
|
||||||
|
users, nextCreated, nextID, err := s.store.ListUsers(r.Context(), store.ListUsersQuery{
|
||||||
|
Search: search,
|
||||||
|
CursorCreated: cursorCreated,
|
||||||
|
CursorID: cursorID,
|
||||||
|
Limit: store.AdminUsersLimit,
|
||||||
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, "could not load users", http.StatusInternalServerError)
|
http.Error(w, "could not load users", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
next := ""
|
||||||
|
if nextCreated != "" {
|
||||||
|
v := url.Values{}
|
||||||
|
if search != "" {
|
||||||
|
v.Set("q", search)
|
||||||
|
}
|
||||||
|
v.Set("cursor_created", nextCreated)
|
||||||
|
v.Set("cursor_id", nextID)
|
||||||
|
next = "/admin/users?" + v.Encode()
|
||||||
|
}
|
||||||
s.exec(w, "admin-users", adminUsersPage{
|
s.exec(w, "admin-users", adminUsersPage{
|
||||||
page: s.basePage(r, "Users"),
|
page: s.basePage(r, "Users"),
|
||||||
Users: users,
|
Users: users,
|
||||||
|
Search: search,
|
||||||
|
NextCursor: next,
|
||||||
|
HasMore: next != "",
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -50,7 +76,7 @@ func (s *Server) handleAdminSetRole(w http.ResponseWriter, r *http.Request) {
|
|||||||
role := store.Role(r.PostFormValue("role"))
|
role := store.Role(r.PostFormValue("role"))
|
||||||
err := s.store.SetUserRole(r.Context(), id, role)
|
err := s.store.SetUserRole(r.Context(), id, role)
|
||||||
if errors.Is(err, store.ErrLastAdmin) {
|
if errors.Is(err, store.ErrLastAdmin) {
|
||||||
users, listErr := s.store.ListUsers(r.Context())
|
users, _, _, listErr := s.store.ListUsers(r.Context(), store.ListUsersQuery{Limit: store.AdminUsersLimit})
|
||||||
if listErr != nil {
|
if listErr != nil {
|
||||||
http.Error(w, "could not demote last admin", http.StatusBadRequest)
|
http.Error(w, "could not demote last admin", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
|
|||||||
+21
-3
@@ -2,6 +2,9 @@ package web
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"crypto/subtle"
|
"crypto/subtle"
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"regexp"
|
"regexp"
|
||||||
@@ -79,8 +82,16 @@ func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
u, err := s.store.UserByUsername(r.Context(), username)
|
u, err := s.store.UserByUsername(r.Context(), username)
|
||||||
hash := loginDummyHash
|
hash := loginDummyHash
|
||||||
if err == nil {
|
switch {
|
||||||
|
case err == nil:
|
||||||
hash = []byte(u.PasswordHash)
|
hash = []byte(u.PasswordHash)
|
||||||
|
case errors.Is(err, sql.ErrNoRows):
|
||||||
|
// unknown user — still bcrypt against dummy hash
|
||||||
|
default:
|
||||||
|
log.Printf("login lookup: %v", err)
|
||||||
|
_ = bcrypt.CompareHashAndPassword(loginDummyHash, []byte(password))
|
||||||
|
http.Error(w, "service temporarily unavailable", http.StatusServiceUnavailable)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
if err != nil || bcrypt.CompareHashAndPassword(hash, []byte(password)) != nil {
|
if err != nil || bcrypt.CompareHashAndPassword(hash, []byte(password)) != nil {
|
||||||
s.loginFail.record(loginFailKey(ip, userKey))
|
s.loginFail.record(loginFailKey(ip, userKey))
|
||||||
@@ -138,7 +149,7 @@ func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
role := store.RoleUser
|
role := store.RoleUser
|
||||||
if setupSecretMatches(s.cfg.AdminSetupSecret, setupSecret) {
|
if setupSecretMatches(s.cfg.AdminSetupSecret, setupSecret) {
|
||||||
role = store.RoleAdmin // store downgrades if an admin already exists
|
role = store.RoleAdmin
|
||||||
}
|
}
|
||||||
u := &store.User{
|
u := &store.User{
|
||||||
Username: username,
|
Username: username,
|
||||||
@@ -146,12 +157,19 @@ func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) {
|
|||||||
Role: role,
|
Role: role,
|
||||||
}
|
}
|
||||||
if err := s.store.CreateUser(r.Context(), u); err != nil {
|
if err := s.store.CreateUser(r.Context(), u); err != nil {
|
||||||
|
if errors.Is(err, store.ErrDuplicateUsername) {
|
||||||
p.Error = "That username is taken."
|
p.Error = "That username is taken."
|
||||||
s.exec(w, "register", p)
|
s.exec(w, "register", p)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
log.Printf("register create: %v", err)
|
||||||
|
http.Error(w, "could not create account", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
if err := s.sessions.RenewToken(r.Context()); err != nil {
|
if err := s.sessions.RenewToken(r.Context()); err != nil {
|
||||||
http.Error(w, "could not start session", http.StatusInternalServerError)
|
log.Printf("register session: %v", err)
|
||||||
|
s.sessions.Put(r.Context(), "flash", "Account created — please sign in.")
|
||||||
|
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
s.sessions.Put(r.Context(), "user_id", u.ID)
|
s.sessions.Put(r.Context(), "user_id", u.ID)
|
||||||
|
|||||||
+30
-6
@@ -11,7 +11,6 @@ import (
|
|||||||
"path"
|
"path"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
|
||||||
"golang.org/x/image/draw"
|
"golang.org/x/image/draw"
|
||||||
_ "golang.org/x/image/webp"
|
_ "golang.org/x/image/webp"
|
||||||
|
|
||||||
@@ -63,6 +62,7 @@ func (s *Server) handleProfile(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
avatarURL := ""
|
avatarURL := ""
|
||||||
|
avatarKey := ""
|
||||||
file, hdr, err := r.FormFile("avatar")
|
file, hdr, err := r.FormFile("avatar")
|
||||||
if err == nil {
|
if err == nil {
|
||||||
defer file.Close()
|
defer file.Close()
|
||||||
@@ -79,9 +79,10 @@ func (s *Server) handleProfile(w http.ResponseWriter, r *http.Request) {
|
|||||||
s.renderProfile(w, r, u, "Avatar must be a JPEG, PNG, or WebP image.", state)
|
s.renderProfile(w, r, u, "Avatar must be a JPEG, PNG, or WebP image.", state)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
key := path.Join("avatars", u.ID, uuid.NewString()+ext)
|
prevURL := u.AvatarURL
|
||||||
|
avatarKey = path.Join("avatars", u.ID, "avatar"+ext)
|
||||||
url, upErr := s.cfg.Blob.Upload(r.Context(), blob.FileUpload{
|
url, upErr := s.cfg.Blob.Upload(r.Context(), blob.FileUpload{
|
||||||
Key: key,
|
Key: avatarKey,
|
||||||
Body: bytes.NewReader(body),
|
Body: bytes.NewReader(body),
|
||||||
ContentType: contentType,
|
ContentType: contentType,
|
||||||
Size: int64(len(body)),
|
Size: int64(len(body)),
|
||||||
@@ -91,15 +92,25 @@ func (s *Server) handleProfile(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
avatarURL = url
|
avatarURL = url
|
||||||
|
u.State = state
|
||||||
|
u.AvatarURL = avatarURL
|
||||||
|
if err := s.store.SaveUserProfile(r.Context(), u); err != nil {
|
||||||
|
_ = s.cfg.Blob.Delete(r.Context(), avatarKey)
|
||||||
|
http.Error(w, "could not save profile", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if oldKey := avatarObjectKey(prevURL, u.ID); oldKey != "" && oldKey != avatarKey {
|
||||||
|
_ = s.cfg.Blob.Delete(r.Context(), oldKey)
|
||||||
|
}
|
||||||
|
s.sessions.Put(r.Context(), "flash", "Profile saved.")
|
||||||
|
http.Redirect(w, r, "/profile", http.StatusSeeOther)
|
||||||
|
return
|
||||||
} else if err != http.ErrMissingFile {
|
} else if err != http.ErrMissingFile {
|
||||||
s.renderProfile(w, r, u, "Could not read avatar file.", state)
|
s.renderProfile(w, r, u, "Could not read avatar file.", state)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
u.State = state
|
u.State = state
|
||||||
if avatarURL != "" {
|
|
||||||
u.AvatarURL = avatarURL
|
|
||||||
}
|
|
||||||
if err := s.store.SaveUserProfile(r.Context(), u); err != nil {
|
if err := s.store.SaveUserProfile(r.Context(), u); err != nil {
|
||||||
http.Error(w, "could not save profile", http.StatusInternalServerError)
|
http.Error(w, "could not save profile", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
@@ -108,6 +119,19 @@ func (s *Server) handleProfile(w http.ResponseWriter, r *http.Request) {
|
|||||||
http.Redirect(w, r, "/profile", http.StatusSeeOther)
|
http.Redirect(w, r, "/profile", http.StatusSeeOther)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func avatarObjectKey(publicURL, userID string) string {
|
||||||
|
marker := "/avatars/" + userID + "/"
|
||||||
|
i := strings.Index(publicURL, marker)
|
||||||
|
if i < 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
rest := publicURL[i+1:] // avatars/...
|
||||||
|
if q := strings.IndexAny(rest, "?#"); q >= 0 {
|
||||||
|
rest = rest[:q]
|
||||||
|
}
|
||||||
|
return rest
|
||||||
|
}
|
||||||
|
|
||||||
// prepareAvatar reads at most maxBytes, sniffs/decodes the image, resizes to a
|
// prepareAvatar reads at most maxBytes, sniffs/decodes the image, resizes to a
|
||||||
// small avatar, and re-encodes so only bounded valid image bytes are stored.
|
// small avatar, and re-encodes so only bounded valid image bytes are stored.
|
||||||
func prepareAvatar(r io.Reader, maxBytes int64) (body []byte, ext, contentType string, err error) {
|
func prepareAvatar(r io.Reader, maxBytes int64) (body []byte, ext, contentType string, err error) {
|
||||||
|
|||||||
+23
-4
@@ -3,11 +3,14 @@ package web
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
|
"database/sql"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"html/template"
|
"html/template"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
"log"
|
"log"
|
||||||
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -28,8 +31,8 @@ type Config struct {
|
|||||||
// posts the matching setup_secret. It is ignored once any admin exists.
|
// posts the matching setup_secret. It is ignored once any admin exists.
|
||||||
AdminSetupSecret string
|
AdminSetupSecret string
|
||||||
SecureCookie bool
|
SecureCookie bool
|
||||||
// TrustProxy enables X-Forwarded-For / RealIP only behind a known proxy.
|
// TrustedProxies are CIDRs allowed to set X-Forwarded-For (direct peer).
|
||||||
TrustProxy bool
|
TrustedProxies []*net.IPNet
|
||||||
Blob blob.Uploader
|
Blob blob.Uploader
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -145,7 +148,7 @@ func New(st store.Store, sessionStore scs.Store, templateFS fs.FS, staticFS fs.F
|
|||||||
func (s *Server) Handler() http.Handler {
|
func (s *Server) Handler() http.Handler {
|
||||||
r := chi.NewRouter()
|
r := chi.NewRouter()
|
||||||
r.Use(middleware.RequestID)
|
r.Use(middleware.RequestID)
|
||||||
if s.cfg.TrustProxy {
|
if len(s.cfg.TrustedProxies) > 0 {
|
||||||
r.Use(middleware.RealIP)
|
r.Use(middleware.RealIP)
|
||||||
}
|
}
|
||||||
r.Use(middleware.Logger)
|
r.Use(middleware.Logger)
|
||||||
@@ -356,7 +359,17 @@ func (s *Server) handleQuestion(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
var ans *store.Answer
|
var ans *store.Answer
|
||||||
if q.Answered {
|
if q.Answered {
|
||||||
ans, _ = s.store.GetAnswer(r.Context(), q.ID)
|
ans, err = s.store.GetAnswer(r.Context(), q.ID)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
log.Printf("question %s marked answered but answer missing", q.ID)
|
||||||
|
http.Error(w, "answer unavailable", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log.Printf("get answer %s: %v", q.ID, err)
|
||||||
|
http.Error(w, "could not load answer", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
}
|
}
|
||||||
s.exec(w, "question", questionPage{
|
s.exec(w, "question", questionPage{
|
||||||
page: s.basePage(r, q.Title),
|
page: s.basePage(r, q.Title),
|
||||||
@@ -385,11 +398,17 @@ func (s *Server) handleVote(w http.ResponseWriter, r *http.Request) {
|
|||||||
value = 1
|
value = 1
|
||||||
case "-1":
|
case "-1":
|
||||||
value = -1
|
value = -1
|
||||||
|
case "0":
|
||||||
|
value = 0
|
||||||
default:
|
default:
|
||||||
http.Error(w, "invalid vote", http.StatusBadRequest)
|
http.Error(w, "invalid vote", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := s.store.Vote(r.Context(), u.ID, id, value); err != nil {
|
if err := s.store.Vote(r.Context(), u.ID, id, value); err != nil {
|
||||||
|
if errors.Is(err, store.ErrHiddenOrMissing) {
|
||||||
|
http.Error(w, "not found", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
http.Error(w, "could not vote", http.StatusInternalServerError)
|
http.Error(w, "could not vote", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -332,6 +332,8 @@ func (f *fakeBlob) Upload(_ context.Context, obj blob.FileUpload) (string, error
|
|||||||
return "https://cdn.example.com/" + obj.Key, nil
|
return "https://cdn.example.com/" + obj.Key, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (f *fakeBlob) Delete(_ context.Context, _ string) error { return nil }
|
||||||
|
|
||||||
func TestProfilePageAndState(t *testing.T) {
|
func TestProfilePageAndState(t *testing.T) {
|
||||||
srv, mem := newTestServer(t, Config{})
|
srv, mem := newTestServer(t, Config{})
|
||||||
h := srv.Handler()
|
h := srv.Handler()
|
||||||
|
|||||||
+119
-35
@@ -1,22 +1,33 @@
|
|||||||
package web
|
package web
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"container/list"
|
||||||
|
"log"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
const defaultThrottleMaxKeys = 10_000
|
const defaultThrottleMaxKeys = 10_000
|
||||||
|
|
||||||
// throttle is a sliding-window rate limiter with expired-key eviction and a cap.
|
// throttle is a sliding-window rate limiter with LRU eviction at capacity.
|
||||||
type throttle struct {
|
type throttle struct {
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
hits map[string][]time.Time
|
hits map[string]*throttleEntry
|
||||||
|
lru *list.List // front = most recently used
|
||||||
limit int
|
limit int
|
||||||
window time.Duration
|
window time.Duration
|
||||||
maxKeys int
|
maxKeys int
|
||||||
|
rejects atomic.Uint64
|
||||||
|
lastLog time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type throttleEntry struct {
|
||||||
|
times []time.Time
|
||||||
|
el *list.Element
|
||||||
}
|
}
|
||||||
|
|
||||||
func newThrottle(limit int, window time.Duration, maxKeys int) *throttle {
|
func newThrottle(limit int, window time.Duration, maxKeys int) *throttle {
|
||||||
@@ -24,7 +35,8 @@ func newThrottle(limit int, window time.Duration, maxKeys int) *throttle {
|
|||||||
maxKeys = defaultThrottleMaxKeys
|
maxKeys = defaultThrottleMaxKeys
|
||||||
}
|
}
|
||||||
return &throttle{
|
return &throttle{
|
||||||
hits: map[string][]time.Time{},
|
hits: map[string]*throttleEntry{},
|
||||||
|
lru: list.New(),
|
||||||
limit: limit,
|
limit: limit,
|
||||||
window: window,
|
window: window,
|
||||||
maxKeys: maxKeys,
|
maxKeys: maxKeys,
|
||||||
@@ -38,25 +50,60 @@ func (t *throttle) allow(key string) bool {
|
|||||||
t.mu.Lock()
|
t.mu.Lock()
|
||||||
defer t.mu.Unlock()
|
defer t.mu.Unlock()
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
t.evictExpiredLocked(now)
|
cutoff := now.Add(-t.window)
|
||||||
|
|
||||||
xs := pruneTimes(t.hits[key], now.Add(-t.window))
|
ent, ok := t.hits[key]
|
||||||
if len(xs) >= t.limit {
|
if ok {
|
||||||
if len(xs) == 0 {
|
ent.times = pruneTimes(ent.times, cutoff)
|
||||||
delete(t.hits, key)
|
if len(ent.times) == 0 {
|
||||||
} else {
|
t.removeLocked(key, ent)
|
||||||
t.hits[key] = xs
|
ok = false
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
if ok {
|
||||||
|
if len(ent.times) >= t.limit {
|
||||||
|
t.touchLocked(key, ent)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
if _, exists := t.hits[key]; !exists && len(t.hits) >= t.maxKeys {
|
ent.times = append(ent.times, now)
|
||||||
t.evictExpiredLocked(now)
|
t.touchLocked(key, ent)
|
||||||
if len(t.hits) >= t.maxKeys {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
t.hits[key] = append(xs, now)
|
|
||||||
return true
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// New key: make room via LRU if needed.
|
||||||
|
for len(t.hits) >= t.maxKeys {
|
||||||
|
oldest := t.lru.Back()
|
||||||
|
if oldest == nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
oldKey := oldest.Value.(string)
|
||||||
|
t.removeLocked(oldKey, t.hits[oldKey])
|
||||||
|
n := t.rejects.Add(1)
|
||||||
|
if time.Since(t.lastLog) > time.Minute {
|
||||||
|
log.Printf("throttle: LRU evicted key at capacity=%d rejects=%d", t.maxKeys, n)
|
||||||
|
t.lastLog = now
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ent = &throttleEntry{times: []time.Time{now}}
|
||||||
|
ent.el = t.lru.PushFront(key)
|
||||||
|
t.hits[key] = ent
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *throttle) touchLocked(key string, ent *throttleEntry) {
|
||||||
|
if ent.el != nil {
|
||||||
|
t.lru.MoveToFront(ent.el)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *throttle) removeLocked(key string, ent *throttleEntry) {
|
||||||
|
if ent == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if ent.el != nil {
|
||||||
|
t.lru.Remove(ent.el)
|
||||||
|
}
|
||||||
|
delete(t.hits, key)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t *throttle) lenKeys() int {
|
func (t *throttle) lenKeys() int {
|
||||||
@@ -65,18 +112,6 @@ func (t *throttle) lenKeys() int {
|
|||||||
return len(t.hits)
|
return len(t.hits)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t *throttle) evictExpiredLocked(now time.Time) {
|
|
||||||
cutoff := now.Add(-t.window)
|
|
||||||
for k, xs := range t.hits {
|
|
||||||
xs = pruneTimes(xs, cutoff)
|
|
||||||
if len(xs) == 0 {
|
|
||||||
delete(t.hits, k)
|
|
||||||
} else {
|
|
||||||
t.hits[k] = xs
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func pruneTimes(xs []time.Time, cutoff time.Time) []time.Time {
|
func pruneTimes(xs []time.Time, cutoff time.Time) []time.Time {
|
||||||
n := 0
|
n := 0
|
||||||
for _, ts := range xs {
|
for _, ts := range xs {
|
||||||
@@ -137,8 +172,17 @@ func (f *failureTracker) record(key string) {
|
|||||||
f.evictExpiredLocked(now)
|
f.evictExpiredLocked(now)
|
||||||
st := f.fails[key]
|
st := f.fails[key]
|
||||||
if st.count == 0 && len(f.fails) >= f.maxKeys {
|
if st.count == 0 && len(f.fails) >= f.maxKeys {
|
||||||
|
// Drop an arbitrary expired-or-oldest entry.
|
||||||
|
for k, v := range f.fails {
|
||||||
|
if now.Sub(v.last) > f.window/2 {
|
||||||
|
delete(f.fails, k)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(f.fails) >= f.maxKeys {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
}
|
||||||
st.count++
|
st.count++
|
||||||
st.last = now
|
st.last = now
|
||||||
f.fails[key] = st
|
f.fails[key] = st
|
||||||
@@ -184,18 +228,58 @@ func progressiveDelay(failCount int) time.Duration {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) clientIP(r *http.Request) string {
|
func (s *Server) clientIP(r *http.Request) string {
|
||||||
if s.cfg.TrustProxy {
|
|
||||||
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
|
|
||||||
return strings.TrimSpace(strings.Split(xff, ",")[0])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
host, _, err := net.SplitHostPort(r.RemoteAddr)
|
host, _, err := net.SplitHostPort(r.RemoteAddr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return r.RemoteAddr
|
host = r.RemoteAddr
|
||||||
|
}
|
||||||
|
peer := net.ParseIP(host)
|
||||||
|
if peer == nil || !ipInNets(peer, s.cfg.TrustedProxies) {
|
||||||
|
return host
|
||||||
|
}
|
||||||
|
xff := r.Header.Get("X-Forwarded-For")
|
||||||
|
if xff == "" {
|
||||||
|
return host
|
||||||
|
}
|
||||||
|
parts := strings.Split(xff, ",")
|
||||||
|
// Walk right-to-left; skip trusted hops; first untrusted is the client.
|
||||||
|
for i := len(parts) - 1; i >= 0; i-- {
|
||||||
|
p := net.ParseIP(strings.TrimSpace(parts[i]))
|
||||||
|
if p == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !ipInNets(p, s.cfg.TrustedProxies) {
|
||||||
|
return p.String()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return host
|
return host
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func ipInNets(ip net.IP, nets []*net.IPNet) bool {
|
||||||
|
for _, n := range nets {
|
||||||
|
if n.Contains(ip) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseCIDRs(raw string) []*net.IPNet {
|
||||||
|
var out []*net.IPNet
|
||||||
|
for _, part := range strings.Split(raw, ",") {
|
||||||
|
part = strings.TrimSpace(part)
|
||||||
|
if part == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
_, n, err := net.ParseCIDR(part)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("trusted proxy CIDR ignored %q: %v", part, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, n)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
func authTooMany(w http.ResponseWriter) {
|
func authTooMany(w http.ResponseWriter) {
|
||||||
http.Error(w, "too many attempts; try again later", http.StatusTooManyRequests)
|
http.Error(w, "too many attempts; try again later", http.StatusTooManyRequests)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package web
|
package web
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"sync"
|
"sync"
|
||||||
@@ -33,8 +34,8 @@ func TestThrottleMaxKeys(t *testing.T) {
|
|||||||
if !th.allow("one") || !th.allow("two") {
|
if !th.allow("one") || !th.allow("two") {
|
||||||
t.Fatal("first keys should pass")
|
t.Fatal("first keys should pass")
|
||||||
}
|
}
|
||||||
if th.allow("three") {
|
if !th.allow("three") {
|
||||||
t.Fatal("over maxKeys should reject new key")
|
t.Fatal("over maxKeys should LRU-evict and accept new key")
|
||||||
}
|
}
|
||||||
if th.lenKeys() != 2 {
|
if th.lenKeys() != 2 {
|
||||||
t.Fatalf("keys=%d want 2", th.lenKeys())
|
t.Fatalf("keys=%d want 2", th.lenKeys())
|
||||||
@@ -90,7 +91,11 @@ func TestFailureTrackerEvictsExpired(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestClientIPTrustProxy(t *testing.T) {
|
func TestClientIPTrustProxy(t *testing.T) {
|
||||||
srv := &Server{cfg: Config{TrustProxy: true}}
|
_, proxyNet, err := net.ParseCIDR("10.0.0.0/8")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
srv := &Server{cfg: Config{TrustedProxies: []*net.IPNet{proxyNet}}}
|
||||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||||
req.RemoteAddr = "10.0.0.1:1234"
|
req.RemoteAddr = "10.0.0.1:1234"
|
||||||
req.Header.Set("X-Forwarded-For", "203.0.113.9, 10.0.0.1")
|
req.Header.Set("X-Forwarded-For", "203.0.113.9, 10.0.0.1")
|
||||||
@@ -98,9 +103,9 @@ func TestClientIPTrustProxy(t *testing.T) {
|
|||||||
t.Fatalf("trusted xff got %q", got)
|
t.Fatalf("trusted xff got %q", got)
|
||||||
}
|
}
|
||||||
|
|
||||||
srv.cfg.TrustProxy = false
|
req.RemoteAddr = "203.0.113.50:9"
|
||||||
if got := srv.clientIP(req); got != "10.0.0.1" {
|
if got := srv.clientIP(req); got != "203.0.113.50" {
|
||||||
t.Fatalf("untrusted should use RemoteAddr host, got %q", got)
|
t.Fatalf("untrusted peer should ignore xff, got %q", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,11 @@
|
|||||||
<p class="eyebrow">Admin</p>
|
<p class="eyebrow">Admin</p>
|
||||||
<h1>Users</h1>
|
<h1>Users</h1>
|
||||||
{{if .Error}}<p class="banner error" role="alert">{{.Error}}</p>{{end}}
|
{{if .Error}}<p class="banner error" role="alert">{{.Error}}</p>{{end}}
|
||||||
|
<form class="ask" method="get" action="/admin/users" style="margin-bottom:1.5rem">
|
||||||
|
<label for="q">Search</label>
|
||||||
|
<input id="q" name="q" type="search" value="{{.Search}}" placeholder="username or name">
|
||||||
|
<button type="submit" class="btn btn-primary">Search</button>
|
||||||
|
</form>
|
||||||
<div class="admin-users">
|
<div class="admin-users">
|
||||||
<table class="user-table">
|
<table class="user-table">
|
||||||
<thead>
|
<thead>
|
||||||
@@ -40,6 +45,9 @@
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
{{if .HasMore}}
|
||||||
|
<p><a href="{{.NextCursor}}">Next page</a></p>
|
||||||
|
{{end}}
|
||||||
</main>
|
</main>
|
||||||
{{template "footer" .}}
|
{{template "footer" .}}
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
hx-post="/questions/{{.Question.ID}}/vote"
|
hx-post="/questions/{{.Question.ID}}/vote"
|
||||||
{{if eq .View "list"}}hx-target="#leaderboard" hx-swap="outerHTML"{{else}}hx-target="#vote-{{.Question.ID}}" hx-swap="outerHTML"{{end}}>
|
{{if eq .View "list"}}hx-target="#leaderboard" hx-swap="outerHTML"{{else}}hx-target="#vote-{{.Question.ID}}" hx-swap="outerHTML"{{end}}>
|
||||||
<input type="hidden" name="_csrf" value="{{.CSRF}}">
|
<input type="hidden" name="_csrf" value="{{.CSRF}}">
|
||||||
<input type="hidden" name="value" value="1">
|
{{if eq .Question.UserVote 1}}<input type="hidden" name="value" value="0">{{else}}<input type="hidden" name="value" value="1">{{end}}
|
||||||
<input type="hidden" name="view" value="{{.View}}">
|
<input type="hidden" name="view" value="{{.View}}">
|
||||||
<input type="hidden" name="date" value="{{.Date}}">
|
<input type="hidden" name="date" value="{{.Date}}">
|
||||||
<button type="submit" class="vote-btn{{if eq .Question.UserVote 1}} is-up{{end}}" aria-label="Upvote" aria-pressed="{{if eq .Question.UserVote 1}}true{{else}}false{{end}}">
|
<button type="submit" class="vote-btn{{if eq .Question.UserVote 1}} is-up{{end}}" aria-label="Upvote" aria-pressed="{{if eq .Question.UserVote 1}}true{{else}}false{{end}}">
|
||||||
@@ -17,7 +17,7 @@
|
|||||||
hx-post="/questions/{{.Question.ID}}/vote"
|
hx-post="/questions/{{.Question.ID}}/vote"
|
||||||
{{if eq .View "list"}}hx-target="#leaderboard" hx-swap="outerHTML"{{else}}hx-target="#vote-{{.Question.ID}}" hx-swap="outerHTML"{{end}}>
|
{{if eq .View "list"}}hx-target="#leaderboard" hx-swap="outerHTML"{{else}}hx-target="#vote-{{.Question.ID}}" hx-swap="outerHTML"{{end}}>
|
||||||
<input type="hidden" name="_csrf" value="{{.CSRF}}">
|
<input type="hidden" name="_csrf" value="{{.CSRF}}">
|
||||||
<input type="hidden" name="value" value="-1">
|
{{if eq .Question.UserVote -1}}<input type="hidden" name="value" value="0">{{else}}<input type="hidden" name="value" value="-1">{{end}}
|
||||||
<input type="hidden" name="view" value="{{.View}}">
|
<input type="hidden" name="view" value="{{.View}}">
|
||||||
<input type="hidden" name="date" value="{{.Date}}">
|
<input type="hidden" name="date" value="{{.Date}}">
|
||||||
<button type="submit" class="vote-btn{{if eq .Question.UserVote -1}} is-down{{end}}" aria-label="Downvote" aria-pressed="{{if eq .Question.UserVote -1}}true{{else}}false{{end}}">
|
<button type="submit" class="vote-btn{{if eq .Question.UserVote -1}} is-down{{end}}" aria-label="Downvote" aria-pressed="{{if eq .Question.UserVote -1}}true{{else}}false{{end}}">
|
||||||
|
|||||||
Reference in New Issue
Block a user