Initial Ask a Plumber First server
This commit is contained in:
@@ -0,0 +1,33 @@
|
||||
# Hot reload for local development: air
|
||||
# https://github.com/air-verse/air
|
||||
|
||||
root = "."
|
||||
tmp_dir = "tmp"
|
||||
|
||||
[build]
|
||||
cmd = "go build -o ./tmp/server ./cmd/server"
|
||||
bin = "./tmp/server"
|
||||
full_bin = "./tmp/server"
|
||||
include_ext = ["go", "html", "css", "js", "sql"]
|
||||
exclude_dir = ["tmp", "vendor", "testdata", "bin"]
|
||||
exclude_file = []
|
||||
exclude_regex = ["_test\\.go"]
|
||||
exclude_unchanged = true
|
||||
follow_symlink = false
|
||||
delay = 500
|
||||
stop_on_error = true
|
||||
send_interrupt = true
|
||||
kill_delay = "1s"
|
||||
|
||||
[log]
|
||||
time = false
|
||||
main_only = false
|
||||
|
||||
[color]
|
||||
main = "magenta"
|
||||
watcher = "cyan"
|
||||
build = "yellow"
|
||||
runner = "green"
|
||||
|
||||
[misc]
|
||||
clean_on_exit = true
|
||||
@@ -0,0 +1,23 @@
|
||||
# Local listen address (ignored when PORT is set, e.g. on App Platform)
|
||||
LISTEN=:8080
|
||||
# Required: PlanetScale Postgres URI (port 5432 so the app can create tables on boot).
|
||||
# Switch to 6432 (PgBouncer) later if you need pooling.
|
||||
DATABASE_URL=postgresql://user:password@host.example.com:5432/postgres?sslmode=verify-full
|
||||
# Required for integration tests (do not point at the runtime DATABASE_URL).
|
||||
# TEST_DATABASE_URL=postgresql://user:password@host.example.com:5432/plumber_test?sslmode=verify-full
|
||||
# One-time first-admin bootstrap: registrant must also POST setup_secret matching this value,
|
||||
# and only while no admin exists yet. Leave unset after bootstrap. Prefer a long random string.
|
||||
# ADMIN_SETUP_SECRET=
|
||||
# When PORT is set (App Platform), cookies are Secure by default; SECURE_COOKIE=0 is rejected.
|
||||
# Locally, set to 1 when serving over HTTPS:
|
||||
SECURE_COOKIE=0
|
||||
# Comma-separated CIDRs of reverse proxies allowed to set X-Forwarded-For
|
||||
# (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.
|
||||
# SPACES_KEY=
|
||||
# SPACES_SECRET=
|
||||
# SPACES_REGION=nyc3
|
||||
# SPACES_BUCKET=your-bucket
|
||||
# SPACES_ENDPOINT=https://nyc3.digitaloceanspaces.com
|
||||
# SPACES_CDN_BASE=https://your-bucket.nyc3.cdn.digitaloceanspaces.com
|
||||
@@ -0,0 +1,17 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches: [master, main]
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
- name: Test
|
||||
run: go test -race ./...
|
||||
@@ -0,0 +1,4 @@
|
||||
/bin/
|
||||
/tmp/
|
||||
.env
|
||||
*.exe
|
||||
@@ -0,0 +1,7 @@
|
||||
.PHONY: sqlc
|
||||
sqlc:
|
||||
sqlc generate
|
||||
|
||||
.PHONY: sqlc-check
|
||||
sqlc-check:
|
||||
sqlc diff
|
||||
@@ -0,0 +1,150 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/joho/godotenv"
|
||||
|
||||
"plumber"
|
||||
"plumber/internal/blob"
|
||||
"plumber/internal/store"
|
||||
"plumber/internal/web"
|
||||
)
|
||||
|
||||
func main() {
|
||||
_ = godotenv.Load()
|
||||
|
||||
db, sessions := openDB()
|
||||
defer db.Close()
|
||||
defer sessions.Close()
|
||||
|
||||
uploader := blob.FromEnv()
|
||||
handler := newHandler(db, sessions, uploader)
|
||||
run(&http.Server{
|
||||
Addr: listenAddr(),
|
||||
Handler: handler,
|
||||
ReadHeaderTimeout: 5 * time.Second,
|
||||
ReadTimeout: 30 * time.Second,
|
||||
WriteTimeout: 60 * time.Second,
|
||||
IdleTimeout: 90 * time.Second,
|
||||
})
|
||||
}
|
||||
|
||||
func openDB() (*sql.DB, *store.SessionStore) {
|
||||
databaseURL := strings.TrimSpace(os.Getenv("DATABASE_URL"))
|
||||
if databaseURL == "" {
|
||||
log.Fatal("DATABASE_URL is required")
|
||||
}
|
||||
db, sessions, err := store.OpenPostgres(databaseURL, plumber.SchemaSQL)
|
||||
if err != nil {
|
||||
log.Fatalf("database: %v", err)
|
||||
}
|
||||
log.Printf("database: postgres")
|
||||
return db, sessions
|
||||
}
|
||||
|
||||
func newHandler(db *sql.DB, sessions *store.SessionStore, uploader blob.Uploader) http.Handler {
|
||||
srv, err := web.New(store.NewPostgres(db), sessions.Store(), plumber.TemplateFS, plumber.StaticFS, web.Config{
|
||||
AdminSetupSecret: strings.TrimSpace(os.Getenv("ADMIN_SETUP_SECRET")),
|
||||
SecureCookie: secureCookieFromEnv(),
|
||||
TrustedProxies: parseTrustedProxies(os.Getenv("TRUSTED_PROXY_CIDRS")),
|
||||
Blob: uploader,
|
||||
})
|
||||
if err != nil {
|
||||
log.Fatalf("server: %v", err)
|
||||
}
|
||||
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)
|
||||
// and refuses an explicit disable in that environment.
|
||||
func secureCookieFromEnv() bool {
|
||||
v := strings.TrimSpace(os.Getenv("SECURE_COOKIE"))
|
||||
if strings.TrimSpace(os.Getenv("PORT")) != "" {
|
||||
if v == "0" {
|
||||
log.Fatal("SECURE_COOKIE=0 is not allowed when PORT is set")
|
||||
}
|
||||
return true
|
||||
}
|
||||
return v == "1"
|
||||
}
|
||||
|
||||
func run(httpSrv *http.Server) {
|
||||
errCh := make(chan error, 1)
|
||||
go func() {
|
||||
log.Printf("listening on %s", httpSrv.Addr)
|
||||
errCh <- httpSrv.ListenAndServe()
|
||||
}()
|
||||
|
||||
sigCh := make(chan os.Signal, 1)
|
||||
signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM)
|
||||
|
||||
select {
|
||||
case err := <-errCh:
|
||||
if err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
log.Fatal(err)
|
||||
}
|
||||
case sig := <-sigCh:
|
||||
log.Printf("shutdown signal: %v", sig)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
err := httpSrv.Shutdown(ctx)
|
||||
cancel()
|
||||
if err != nil {
|
||||
log.Printf("shutdown: %v", err)
|
||||
_ = httpSrv.Close()
|
||||
}
|
||||
select {
|
||||
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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// listenAddr prefers PORT (App Platform / PaaS), then LISTEN, then :8080.
|
||||
func listenAddr() string {
|
||||
if p := strings.TrimSpace(os.Getenv("PORT")); p != "" {
|
||||
if strings.HasPrefix(p, ":") {
|
||||
return p
|
||||
}
|
||||
return ":" + p
|
||||
}
|
||||
return env("LISTEN", ":8080")
|
||||
}
|
||||
|
||||
func env(key, fallback string) string {
|
||||
v := strings.TrimSpace(os.Getenv(key))
|
||||
if v == "" {
|
||||
return fallback
|
||||
}
|
||||
return v
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
-- name: UpsertAnswer :exec
|
||||
INSERT INTO answers (question_id, author_id, body, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
ON CONFLICT (question_id) DO UPDATE
|
||||
SET body = excluded.body, author_id = excluded.author_id, updated_at = excluded.updated_at;
|
||||
|
||||
-- name: GetAnswer :one
|
||||
SELECT a.question_id, a.author_id, u.name AS author_name, a.body, a.created_at, a.updated_at
|
||||
FROM answers a
|
||||
JOIN users u ON u.id = a.author_id
|
||||
WHERE a.question_id = $1;
|
||||
@@ -0,0 +1,62 @@
|
||||
-- name: CreateQuestion :exec
|
||||
INSERT INTO questions (id, author_id, title, body, city, hunt_date, hidden, created_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, 0, $7);
|
||||
|
||||
-- name: HideQuestion :exec
|
||||
UPDATE questions
|
||||
SET hidden = 1
|
||||
WHERE id = $1;
|
||||
|
||||
-- name: ListHunt :many
|
||||
SELECT q.id, q.author_id, u.name AS author_name, q.title, q.body, q.city, q.hunt_date, q.hidden, q.created_at,
|
||||
COALESCE(SUM(v.value), 0)::bigint AS score,
|
||||
CASE WHEN a.question_id IS NULL THEN 0 ELSE 1 END::bigint AS answered,
|
||||
COALESCE((
|
||||
SELECT votes.value FROM votes
|
||||
WHERE votes.user_id = sqlc.arg(viewer_id) AND votes.question_id = q.id
|
||||
), 0)::bigint AS user_vote
|
||||
FROM questions q
|
||||
JOIN users u ON u.id = q.author_id
|
||||
LEFT JOIN votes v ON v.question_id = q.id
|
||||
LEFT JOIN answers a ON a.question_id = q.id
|
||||
WHERE q.hunt_date = sqlc.arg(hunt_date) AND q.hidden = 0
|
||||
GROUP BY q.id, q.author_id, u.name, q.title, q.body, q.city, q.hunt_date, q.hidden, q.created_at, a.question_id
|
||||
ORDER BY score DESC, q.created_at ASC
|
||||
LIMIT sqlc.arg(row_limit);
|
||||
|
||||
-- name: GetQuestion :one
|
||||
SELECT q.id, q.author_id, u.name AS author_name, q.title, q.body, q.city, q.hunt_date, q.hidden, q.created_at,
|
||||
COALESCE((SELECT SUM(votes.value) FROM votes WHERE votes.question_id = q.id), 0)::bigint AS score,
|
||||
CASE WHEN a.question_id IS NULL THEN 0 ELSE 1 END::bigint AS answered,
|
||||
COALESCE((
|
||||
SELECT votes.value FROM votes
|
||||
WHERE votes.user_id = sqlc.arg(viewer_id) AND votes.question_id = q.id
|
||||
), 0)::bigint AS user_vote
|
||||
FROM questions q
|
||||
JOIN users u ON u.id = q.author_id
|
||||
LEFT JOIN answers a ON a.question_id = q.id
|
||||
WHERE q.id = sqlc.arg(id);
|
||||
|
||||
-- name: ListQuestionsByAuthor :many
|
||||
SELECT q.id, q.author_id, u.name AS author_name, q.title, q.body, q.city, q.hunt_date, q.hidden, q.created_at,
|
||||
COALESCE((SELECT SUM(votes.value) FROM votes WHERE votes.question_id = q.id), 0)::bigint AS score,
|
||||
CASE WHEN a.question_id IS NULL THEN 0 ELSE 1 END::bigint AS answered,
|
||||
0::bigint AS user_vote
|
||||
FROM questions q
|
||||
JOIN users u ON u.id = q.author_id
|
||||
LEFT JOIN answers a ON a.question_id = q.id
|
||||
WHERE q.author_id = sqlc.arg(author_id) AND q.hidden = 0
|
||||
ORDER BY q.created_at DESC
|
||||
LIMIT sqlc.arg(row_limit);
|
||||
|
||||
-- name: ListQuestionsAnsweredBy :many
|
||||
SELECT q.id, q.author_id, u.name AS author_name, q.title, q.body, q.city, q.hunt_date, q.hidden, q.created_at,
|
||||
COALESCE((SELECT SUM(votes.value) FROM votes WHERE votes.question_id = q.id), 0)::bigint AS score,
|
||||
1::bigint AS answered,
|
||||
0::bigint AS user_vote
|
||||
FROM answers ans
|
||||
JOIN questions q ON q.id = ans.question_id
|
||||
JOIN users u ON u.id = q.author_id
|
||||
WHERE ans.author_id = sqlc.arg(admin_id) AND q.hidden = 0
|
||||
ORDER BY ans.updated_at DESC
|
||||
LIMIT sqlc.arg(row_limit);
|
||||
@@ -0,0 +1,18 @@
|
||||
-- name: GetSession :one
|
||||
SELECT data
|
||||
FROM sessions
|
||||
WHERE token = $1 AND expiry > now();
|
||||
|
||||
-- name: UpsertSession :exec
|
||||
INSERT INTO sessions (token, data, expiry)
|
||||
VALUES ($1, $2, $3)
|
||||
ON CONFLICT (token) DO UPDATE
|
||||
SET data = excluded.data, expiry = excluded.expiry;
|
||||
|
||||
-- name: DeleteSession :exec
|
||||
DELETE FROM sessions
|
||||
WHERE token = $1;
|
||||
|
||||
-- name: DeleteExpiredSessions :execrows
|
||||
DELETE FROM sessions
|
||||
WHERE expiry <= now();
|
||||
@@ -0,0 +1,54 @@
|
||||
-- name: CreateUser :exec
|
||||
INSERT INTO users (id, username, name, password_hash, role, avatar_url, state, created_at)
|
||||
VALUES ($1, $2, $3, $4, $5, '', '', $6);
|
||||
|
||||
-- name: GetUserByID :one
|
||||
SELECT id, username, name, role, avatar_url, state, created_at
|
||||
FROM users
|
||||
WHERE id = $1;
|
||||
|
||||
-- name: GetUserByUsername :one
|
||||
SELECT id, username, name, role, avatar_url, state, created_at, password_hash
|
||||
FROM users
|
||||
WHERE username = $1;
|
||||
|
||||
-- name: ListUsers :many
|
||||
SELECT id, username, name, role, avatar_url, state, created_at
|
||||
FROM users
|
||||
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);
|
||||
|
||||
-- name: CountAdmins :one
|
||||
SELECT COUNT(*)::bigint AS count
|
||||
FROM users
|
||||
WHERE role = $1;
|
||||
|
||||
-- name: GetUserRole :one
|
||||
SELECT role
|
||||
FROM users
|
||||
WHERE id = $1;
|
||||
|
||||
-- name: UpdateUserRole :execresult
|
||||
UPDATE users
|
||||
SET role = $1
|
||||
WHERE id = $2;
|
||||
|
||||
-- name: UpdateUserState :exec
|
||||
UPDATE users
|
||||
SET state = $1
|
||||
WHERE id = $2;
|
||||
|
||||
-- name: UpdateUserStateAndAvatar :exec
|
||||
UPDATE users
|
||||
SET state = $1, avatar_url = $2
|
||||
WHERE id = $3;
|
||||
@@ -0,0 +1,24 @@
|
||||
-- name: GetVote :one
|
||||
SELECT value
|
||||
FROM votes
|
||||
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
|
||||
DELETE FROM votes
|
||||
WHERE user_id = $1 AND question_id = $2;
|
||||
|
||||
-- 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
|
||||
);
|
||||
@@ -0,0 +1,12 @@
|
||||
package plumber
|
||||
|
||||
import "embed"
|
||||
|
||||
//go:embed templates/*.html templates/partials/*.html
|
||||
var TemplateFS embed.FS
|
||||
|
||||
//go:embed static
|
||||
var StaticFS embed.FS
|
||||
|
||||
//go:embed schema.sql
|
||||
var SchemaSQL string
|
||||
@@ -0,0 +1,33 @@
|
||||
module plumber
|
||||
|
||||
go 1.25.0
|
||||
|
||||
require (
|
||||
github.com/alexedwards/scs/v2 v2.9.0
|
||||
github.com/aws/aws-sdk-go-v2 v1.43.7
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.37
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.107.3
|
||||
github.com/go-chi/chi/v5 v5.3.1
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/jackc/pgx/v5 v5.10.0
|
||||
github.com/joho/godotenv v1.5.1
|
||||
golang.org/x/crypto v0.55.0
|
||||
golang.org/x/image v0.45.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.18 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.38 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.38 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.39 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.17 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.31 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.38 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.39 // indirect
|
||||
github.com/aws/smithy-go v1.27.8 // indirect
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||
golang.org/x/sync v0.22.0 // indirect
|
||||
golang.org/x/text v0.41.0 // indirect
|
||||
)
|
||||
@@ -0,0 +1,62 @@
|
||||
github.com/alexedwards/scs/v2 v2.9.0 h1:xa05mVpwTBm1iLeTMNFfAWpKUm4fXAW7CeAViqBVS90=
|
||||
github.com/alexedwards/scs/v2 v2.9.0/go.mod h1:ToaROZxyKukJKT/xLcVQAChi5k6+Pn1Gvmdl7h3RRj8=
|
||||
github.com/aws/aws-sdk-go-v2 v1.43.7 h1:msCzvkeYJA9ehbV8mRRmkZLo/zJg/+yDVLNtflg83hQ=
|
||||
github.com/aws/aws-sdk-go-v2 v1.43.7/go.mod h1:tXpPM+v0D1lndmga+HqqLDIzUFJlEeR21aspVklHF00=
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.18 h1:LAfOuhAH331fmOjTQpAaOlH+Ftn7RzSDJ2VFwjdMMy4=
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.18/go.mod h1:4e5xhuXHx1e4U9EthvbPP1r/DIMp5c2823OL8karzcM=
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.37 h1:FJ8Iz4/xISMB/rwLlgfWujfGDFWr0oneQgtA6KPcYLY=
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.37/go.mod h1:Q6pWOgVUp49x4g5QVi29wHofUoICnZ+Zq4jHbRN/7ec=
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.38 h1:MBMg0zJ6i4TkAJ0dVFLKKn2cOkY6FkicmUDM67BRr6g=
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.38/go.mod h1:9MWuJbyiUyj6eA7W1/zm1zuePDPSB3g+xcgRQeMWsXc=
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.38 h1:lHm4jPf3k1Lz5ZWc+Vcn3MKVwym+26kWCba9FkJ4f0Y=
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.38/go.mod h1:Rn+P2XR+FbyZzjmWKjg/KUZNxmGfr5oZwh5jQiE+CzI=
|
||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.39 h1:vo4xvMRs/F6h1E52qsgLqCQgWIQXgIJUauG6rlZEh4U=
|
||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.39/go.mod h1:jB03R1ij/A+OE2e1dz6vgj076gd7vlYcfstAzj3HcnU=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.17 h1:OvYZOB3qA6zvfdRFiRFRzVSiElMYrz3GdntkXZxlp1o=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.17/go.mod h1:JgR/2Ew50ACfIWau1oeMRX59tMtC0kM+PYQGEaT04cY=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.31 h1:uZOinZb+h7lZw8IYzP1z1IuEnueB76/EFkcf/fEW4Ag=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.31/go.mod h1:NRtwAM/p5VRt03TlEUs0pH3TeWamWdf4YyJpSrzPYLc=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.38 h1:H/5TI1jqaHsNoDQ60UwvPvJBg4GURkinXI3Qga29t2w=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.38/go.mod h1:PTVFf+XH++7NJOky+RLBYQx0QA5NcaeEYFQ2fsi0nwo=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.39 h1:HLPAVrlLDaN2boN0xJx7MgaQDNEO3Q+c9L6kl/8m47Q=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.39/go.mod h1:Pg/dVfsNkm1hsIDK/gMvCKtmyNfNTV12mrgHqVE/6Oo=
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.107.3 h1:IKoCZqfWfZzSBi16QFQ+QcbQ3LRQ7QgB1S5tDAyPBQQ=
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.107.3/go.mod h1:RBpRcXiM4s2pOInVs32GsBonnje+fiAj4mcrStRmlCA=
|
||||
github.com/aws/smithy-go v1.27.8 h1:FR0dxZfIlV7Z8eh2iHfIofdunw382XsDV3Mxt9nUvRY=
|
||||
github.com/aws/smithy-go v1.27.8/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/go-chi/chi/v5 v5.3.1 h1:3j4HZLGZQ3JpMCrPJF/Jl3mYJfWLKBfNJ6quurUGCf8=
|
||||
github.com/go-chi/chi/v5 v5.3.1/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||
github.com/jackc/pgx/v5 v5.10.0 h1:VhSvgU2jSli8o3AqIEOTJr7rZwAEUVo4E4XhR94Zfr0=
|
||||
github.com/jackc/pgx/v5 v5.10.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
|
||||
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
||||
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M=
|
||||
golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis=
|
||||
golang.org/x/image v0.45.0 h1:FMb1nTbH5H9vF55SriQHgFw5GnNL9Jg6L25BwXKzhB0=
|
||||
golang.org/x/image v0.45.0/go.mod h1:n62x/7RqlwXDvGsSU4u6IUTUf6KghUZ9Bt7cG/T9Fx4=
|
||||
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
|
||||
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
|
||||
golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
@@ -0,0 +1,146 @@
|
||||
package blob
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/aws/aws-sdk-go-v2/aws"
|
||||
"github.com/aws/aws-sdk-go-v2/credentials"
|
||||
"github.com/aws/aws-sdk-go-v2/service/s3"
|
||||
"github.com/aws/aws-sdk-go-v2/service/s3/types"
|
||||
)
|
||||
|
||||
// Uploader stores public avatar objects.
|
||||
type Uploader interface {
|
||||
Enabled() bool
|
||||
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).
|
||||
type FileUpload struct {
|
||||
Key string
|
||||
Body io.Reader
|
||||
ContentType string
|
||||
Size int64
|
||||
}
|
||||
|
||||
// Disabled is a no-op uploader used when Spaces is not configured.
|
||||
type Disabled struct{}
|
||||
|
||||
// SpacesConfig holds DigitalOcean Spaces settings.
|
||||
type SpacesConfig struct {
|
||||
Key string
|
||||
Secret string
|
||||
Region string
|
||||
Bucket string
|
||||
Endpoint string // e.g. https://nyc3.digitaloceanspaces.com
|
||||
CDNBase string // optional public base URL without trailing slash
|
||||
}
|
||||
|
||||
type spaces struct {
|
||||
client *s3.Client
|
||||
cfg SpacesConfig
|
||||
}
|
||||
|
||||
func (Disabled) Enabled() bool { return false }
|
||||
|
||||
func (Disabled) Upload(context.Context, FileUpload) (string, error) {
|
||||
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.
|
||||
func FromEnv() Uploader {
|
||||
return NewSpaces(SpacesConfig{
|
||||
Key: os.Getenv("SPACES_KEY"),
|
||||
Secret: os.Getenv("SPACES_SECRET"),
|
||||
Region: os.Getenv("SPACES_REGION"),
|
||||
Bucket: os.Getenv("SPACES_BUCKET"),
|
||||
Endpoint: os.Getenv("SPACES_ENDPOINT"),
|
||||
CDNBase: os.Getenv("SPACES_CDN_BASE"),
|
||||
})
|
||||
}
|
||||
|
||||
// NewSpaces returns an Uploader when required env is present; otherwise Disabled.
|
||||
func NewSpaces(cfg SpacesConfig) Uploader {
|
||||
cfg.Key = strings.TrimSpace(cfg.Key)
|
||||
cfg.Secret = strings.TrimSpace(cfg.Secret)
|
||||
cfg.Region = strings.TrimSpace(cfg.Region)
|
||||
cfg.Bucket = strings.TrimSpace(cfg.Bucket)
|
||||
cfg.Endpoint = strings.TrimSpace(cfg.Endpoint)
|
||||
cfg.CDNBase = strings.TrimRight(strings.TrimSpace(cfg.CDNBase), "/")
|
||||
if cfg.Key == "" || cfg.Secret == "" || cfg.Region == "" || cfg.Bucket == "" || cfg.Endpoint == "" {
|
||||
return Disabled{}
|
||||
}
|
||||
client := s3.New(s3.Options{
|
||||
Region: cfg.Region,
|
||||
Credentials: credentials.NewStaticCredentialsProvider(cfg.Key, cfg.Secret, ""),
|
||||
BaseEndpoint: aws.String(cfg.Endpoint),
|
||||
})
|
||||
return &spaces{client: client, cfg: cfg}
|
||||
}
|
||||
|
||||
func (s *spaces) Enabled() bool { return true }
|
||||
|
||||
func (s *spaces) Upload(ctx context.Context, obj FileUpload) (string, error) {
|
||||
key := strings.TrimPrefix(obj.Key, "/")
|
||||
input := &s3.PutObjectInput{
|
||||
Bucket: aws.String(s.cfg.Bucket),
|
||||
Key: aws.String(key),
|
||||
Body: obj.Body,
|
||||
ContentType: aws.String(obj.ContentType),
|
||||
ACL: types.ObjectCannedACLPublicRead,
|
||||
}
|
||||
if obj.Size > 0 {
|
||||
input.ContentLength = aws.Int64(obj.Size)
|
||||
}
|
||||
if _, err := s.client.PutObject(ctx, input); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return s.publicURL(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
|
||||
}
|
||||
host := strings.TrimPrefix(s.cfg.Endpoint, "https://")
|
||||
host = strings.TrimPrefix(host, "http://")
|
||||
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 ""
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package geo
|
||||
|
||||
import "strings"
|
||||
|
||||
// States is the US states + DC allowlist (code -> name).
|
||||
var States = []struct {
|
||||
Code string
|
||||
Name string
|
||||
}{
|
||||
{"AL", "Alabama"}, {"AK", "Alaska"}, {"AZ", "Arizona"}, {"AR", "Arkansas"}, {"CA", "California"},
|
||||
{"CO", "Colorado"}, {"CT", "Connecticut"}, {"DE", "Delaware"}, {"DC", "District of Columbia"},
|
||||
{"FL", "Florida"}, {"GA", "Georgia"}, {"HI", "Hawaii"}, {"ID", "Idaho"}, {"IL", "Illinois"},
|
||||
{"IN", "Indiana"}, {"IA", "Iowa"}, {"KS", "Kansas"}, {"KY", "Kentucky"}, {"LA", "Louisiana"},
|
||||
{"ME", "Maine"}, {"MD", "Maryland"}, {"MA", "Massachusetts"}, {"MI", "Michigan"}, {"MN", "Minnesota"},
|
||||
{"MS", "Mississippi"}, {"MO", "Missouri"}, {"MT", "Montana"}, {"NE", "Nebraska"}, {"NV", "Nevada"},
|
||||
{"NH", "New Hampshire"}, {"NJ", "New Jersey"}, {"NM", "New Mexico"}, {"NY", "New York"},
|
||||
{"NC", "North Carolina"}, {"ND", "North Dakota"}, {"OH", "Ohio"}, {"OK", "Oklahoma"}, {"OR", "Oregon"},
|
||||
{"PA", "Pennsylvania"}, {"RI", "Rhode Island"}, {"SC", "South Carolina"}, {"SD", "South Dakota"},
|
||||
{"TN", "Tennessee"}, {"TX", "Texas"}, {"UT", "Utah"}, {"VT", "Vermont"}, {"VA", "Virginia"},
|
||||
{"WA", "Washington"}, {"WV", "West Virginia"}, {"WI", "Wisconsin"}, {"WY", "Wyoming"},
|
||||
}
|
||||
|
||||
var codes map[string]struct{}
|
||||
|
||||
func init() {
|
||||
codes = make(map[string]struct{}, len(States))
|
||||
for _, s := range States {
|
||||
codes[s.Code] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
// ValidState reports whether state is empty or a known US code.
|
||||
func ValidState(state string) bool {
|
||||
state = strings.ToUpper(strings.TrimSpace(state))
|
||||
if state == "" {
|
||||
return true
|
||||
}
|
||||
_, ok := codes[state]
|
||||
return ok
|
||||
}
|
||||
|
||||
// NormalizeState returns "" or an uppercase 2-letter code.
|
||||
func NormalizeState(state string) string {
|
||||
return strings.ToUpper(strings.TrimSpace(state))
|
||||
}
|
||||
|
||||
// StateName returns the full name for a US state code, or "" if unknown.
|
||||
func StateName(code string) string {
|
||||
code = NormalizeState(code)
|
||||
for _, s := range States {
|
||||
if s.Code == code {
|
||||
return s.Name
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package pacific
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
_ "time/tzdata"
|
||||
)
|
||||
|
||||
const Layout = "2006-01-02"
|
||||
|
||||
var Loc *time.Location
|
||||
|
||||
func init() {
|
||||
loc, err := time.LoadLocation("America/Los_Angeles")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
Loc = loc
|
||||
}
|
||||
|
||||
func HuntDate(t time.Time) string {
|
||||
return t.In(Loc).Format(Layout)
|
||||
}
|
||||
|
||||
func Today() string {
|
||||
return HuntDate(time.Now())
|
||||
}
|
||||
|
||||
func Yesterday() string {
|
||||
now := time.Now().In(Loc)
|
||||
y := time.Date(now.Year(), now.Month(), now.Day()-1, 0, 0, 0, 0, Loc)
|
||||
return y.Format(Layout)
|
||||
}
|
||||
|
||||
func Parse(date string) (time.Time, error) {
|
||||
return time.ParseInLocation(Layout, date, Loc)
|
||||
}
|
||||
|
||||
func Label(date string) string {
|
||||
t, err := Parse(date)
|
||||
if err != nil {
|
||||
return date
|
||||
}
|
||||
return t.Format("January 2, 2006")
|
||||
}
|
||||
|
||||
func IsToday(date string) bool {
|
||||
return date == Today()
|
||||
}
|
||||
|
||||
func IsYesterday(date string) bool {
|
||||
return date == Yesterday()
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"plumber/internal/store/sqlc"
|
||||
)
|
||||
|
||||
// Answer is an admin reply to a question.
|
||||
type Answer struct {
|
||||
QuestionID string
|
||||
AuthorID string
|
||||
AuthorName string
|
||||
Body string
|
||||
CreatedAt string
|
||||
UpdatedAt string
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
// NewAnswer returns an Answer bound to db.
|
||||
func NewAnswer(db *sql.DB) *Answer {
|
||||
return &Answer{db: db}
|
||||
}
|
||||
|
||||
// Upsert inserts or updates the answer for QuestionID.
|
||||
func (a *Answer) Upsert(ctx context.Context) error {
|
||||
if a == nil || a.db == nil {
|
||||
return fmt.Errorf("answer: no database")
|
||||
}
|
||||
a.Body = strings.TrimSpace(a.Body)
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
if a.CreatedAt == "" {
|
||||
a.CreatedAt = now
|
||||
}
|
||||
a.UpdatedAt = now
|
||||
return sqlc.New(a.db).UpsertAnswer(ctx, sqlc.UpsertAnswerParams{
|
||||
QuestionID: a.QuestionID,
|
||||
AuthorID: a.AuthorID,
|
||||
Body: a.Body,
|
||||
CreatedAt: a.CreatedAt,
|
||||
UpdatedAt: a.UpdatedAt,
|
||||
})
|
||||
}
|
||||
|
||||
func GetAnswer(ctx context.Context, db *sql.DB, questionID string) (*Answer, error) {
|
||||
r, err := sqlc.New(db).GetAnswer(ctx, questionID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Answer{
|
||||
QuestionID: r.QuestionID,
|
||||
AuthorID: r.AuthorID,
|
||||
AuthorName: r.AuthorName,
|
||||
Body: r.Body,
|
||||
CreatedAt: r.CreatedAt,
|
||||
UpdatedAt: r.UpdatedAt,
|
||||
db: db,
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
package store
|
||||
|
||||
//go:generate make -C ../.. sqlc
|
||||
@@ -0,0 +1,376 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"plumber/internal/pacific"
|
||||
)
|
||||
|
||||
// Memory is an in-process Store for tests.
|
||||
type Memory struct {
|
||||
mu sync.Mutex
|
||||
users map[string]*User // id -> user
|
||||
byName map[string]string // username -> id
|
||||
questions map[string]*RankedQuestion // id -> question
|
||||
answers map[string]*Answer // questionID -> answer
|
||||
votes map[string]map[string]int // questionID -> userID -> value
|
||||
}
|
||||
|
||||
// NewMemory returns an empty Memory store.
|
||||
func NewMemory() *Memory {
|
||||
return &Memory{
|
||||
users: map[string]*User{},
|
||||
byName: map[string]string{},
|
||||
questions: map[string]*RankedQuestion{},
|
||||
answers: map[string]*Answer{},
|
||||
votes: map[string]map[string]int{},
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Memory) CreateUser(_ context.Context, u *User) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if u.Role != RoleUser && u.Role != RoleAdmin {
|
||||
return fmt.Errorf("invalid role")
|
||||
}
|
||||
u.Username = NormalizeUsername(u.Username)
|
||||
if _, ok := m.byName[u.Username]; ok {
|
||||
return ErrDuplicateUsername
|
||||
}
|
||||
if u.ID == "" {
|
||||
u.ID = uuid.NewString()
|
||||
}
|
||||
if u.Name == "" {
|
||||
u.Name = u.Username
|
||||
}
|
||||
if u.CreatedAt == "" {
|
||||
u.CreatedAt = time.Now().UTC().Format(time.RFC3339)
|
||||
}
|
||||
role := u.Role
|
||||
if role == RoleAdmin {
|
||||
for _, existing := range m.users {
|
||||
if existing.Role == RoleAdmin {
|
||||
role = RoleUser
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
cp := *u
|
||||
cp.Role = role
|
||||
cp.db = nil
|
||||
m.users[cp.ID] = &cp
|
||||
m.byName[cp.Username] = cp.ID
|
||||
*u = cp
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Memory) UserByID(_ context.Context, id string) (*User, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
u, ok := m.users[id]
|
||||
if !ok {
|
||||
return nil, sql.ErrNoRows
|
||||
}
|
||||
cp := *u
|
||||
return &cp, nil
|
||||
}
|
||||
|
||||
func (m *Memory) UserByUsername(_ context.Context, username string) (*User, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
id, ok := m.byName[NormalizeUsername(username)]
|
||||
if !ok {
|
||||
return nil, sql.ErrNoRows
|
||||
}
|
||||
cp := *m.users[id]
|
||||
return &cp, nil
|
||||
}
|
||||
|
||||
func (m *Memory) ListUsers(_ context.Context, q ListUsersQuery) ([]User, string, string, error) {
|
||||
m.mu.Lock()
|
||||
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))
|
||||
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)
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool {
|
||||
if out[i].CreatedAt != out[j].CreatedAt {
|
||||
return out[i].CreatedAt > out[j].CreatedAt
|
||||
}
|
||||
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) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
n := 0
|
||||
for _, u := range m.users {
|
||||
if u.Role == RoleAdmin {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// SetUserRole serializes demotions under m.mu (same critical section as count).
|
||||
func (m *Memory) SetUserRole(_ context.Context, id string, role Role) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if role != RoleUser && role != RoleAdmin {
|
||||
return fmt.Errorf("invalid role")
|
||||
}
|
||||
u, ok := m.users[id]
|
||||
if !ok {
|
||||
return sql.ErrNoRows
|
||||
}
|
||||
if u.Role == RoleAdmin && role == RoleUser {
|
||||
n := 0
|
||||
for _, x := range m.users {
|
||||
if x.Role == RoleAdmin {
|
||||
n++
|
||||
}
|
||||
}
|
||||
if n <= 1 {
|
||||
return ErrLastAdmin
|
||||
}
|
||||
}
|
||||
u.Role = role
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Memory) SaveUserProfile(_ context.Context, u *User) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
cur, ok := m.users[u.ID]
|
||||
if !ok {
|
||||
return sql.ErrNoRows
|
||||
}
|
||||
cur.State = strings.TrimSpace(u.State)
|
||||
if u.AvatarURL != "" {
|
||||
cur.AvatarURL = u.AvatarURL
|
||||
}
|
||||
u.State = cur.State
|
||||
u.AvatarURL = cur.AvatarURL
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Memory) CreateQuestion(_ context.Context, q *RankedQuestion) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
q.Title = strings.TrimSpace(q.Title)
|
||||
q.Body = strings.TrimSpace(q.Body)
|
||||
q.City = strings.TrimSpace(q.City)
|
||||
if q.ID == "" {
|
||||
q.ID = uuid.NewString()
|
||||
}
|
||||
if q.HuntDate == "" {
|
||||
q.HuntDate = pacific.Today()
|
||||
}
|
||||
if q.CreatedAt == "" {
|
||||
q.CreatedAt = time.Now().UTC().Format(time.RFC3339)
|
||||
}
|
||||
author, ok := m.users[q.AuthorID]
|
||||
if !ok {
|
||||
return fmt.Errorf("unknown author")
|
||||
}
|
||||
cp := *q
|
||||
cp.AuthorName = author.Name
|
||||
cp.db = nil
|
||||
m.questions[cp.ID] = &cp
|
||||
*q = cp
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Memory) annotate(q *RankedQuestion, viewerID string) RankedQuestion {
|
||||
out := *q
|
||||
score := 0
|
||||
userVote := 0
|
||||
if votes, ok := m.votes[q.ID]; ok {
|
||||
for uid, v := range votes {
|
||||
score += v
|
||||
if uid == viewerID {
|
||||
userVote = v
|
||||
}
|
||||
}
|
||||
}
|
||||
_, answered := m.answers[q.ID]
|
||||
out.Score = score
|
||||
out.Answered = answered
|
||||
out.UserVote = userVote
|
||||
out.db = nil
|
||||
return out
|
||||
}
|
||||
|
||||
func (m *Memory) GetQuestion(_ context.Context, id, viewerID string) (*RankedQuestion, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
q, ok := m.questions[id]
|
||||
if !ok {
|
||||
return nil, sql.ErrNoRows
|
||||
}
|
||||
out := m.annotate(q, viewerID)
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
func (m *Memory) ListHunt(_ context.Context, huntDate, viewerID string) ([]RankedQuestion, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
out := make([]RankedQuestion, 0)
|
||||
for _, q := range m.questions {
|
||||
if q.HuntDate != huntDate || q.Hidden {
|
||||
continue
|
||||
}
|
||||
out = append(out, m.annotate(q, viewerID))
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool {
|
||||
if out[i].Score != out[j].Score {
|
||||
return out[i].Score > out[j].Score
|
||||
}
|
||||
return out[i].CreatedAt < out[j].CreatedAt
|
||||
})
|
||||
if len(out) > HuntListLimit {
|
||||
out = out[:HuntListLimit]
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (m *Memory) ListQuestionsByAuthor(_ context.Context, authorID string) ([]RankedQuestion, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
out := make([]RankedQuestion, 0)
|
||||
for _, q := range m.questions {
|
||||
if q.AuthorID != authorID || q.Hidden {
|
||||
continue
|
||||
}
|
||||
out = append(out, m.annotate(q, ""))
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].CreatedAt > out[j].CreatedAt })
|
||||
if len(out) > ProfileListLimit {
|
||||
out = out[:ProfileListLimit]
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (m *Memory) ListQuestionsAnsweredBy(_ context.Context, adminID string) ([]RankedQuestion, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
out := make([]RankedQuestion, 0)
|
||||
for qid, a := range m.answers {
|
||||
if a.AuthorID != adminID {
|
||||
continue
|
||||
}
|
||||
q, ok := m.questions[qid]
|
||||
if !ok || q.Hidden {
|
||||
continue
|
||||
}
|
||||
rq := m.annotate(q, "")
|
||||
rq.Answered = true
|
||||
out = append(out, rq)
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].CreatedAt > out[j].CreatedAt })
|
||||
if len(out) > ProfileListLimit {
|
||||
out = out[:ProfileListLimit]
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (m *Memory) HideQuestion(_ context.Context, id string) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
q, ok := m.questions[id]
|
||||
if !ok {
|
||||
return sql.ErrNoRows
|
||||
}
|
||||
q.Hidden = true
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Memory) GetAnswer(_ context.Context, questionID string) (*Answer, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
a, ok := m.answers[questionID]
|
||||
if !ok {
|
||||
return nil, sql.ErrNoRows
|
||||
}
|
||||
cp := *a
|
||||
if u, ok := m.users[a.AuthorID]; ok {
|
||||
cp.AuthorName = u.Name
|
||||
}
|
||||
return &cp, nil
|
||||
}
|
||||
|
||||
func (m *Memory) UpsertAnswer(_ context.Context, a *Answer) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if _, ok := m.questions[a.QuestionID]; !ok {
|
||||
return fmt.Errorf("unknown question")
|
||||
}
|
||||
a.Body = strings.TrimSpace(a.Body)
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
if existing, ok := m.answers[a.QuestionID]; ok {
|
||||
a.CreatedAt = existing.CreatedAt
|
||||
} else if a.CreatedAt == "" {
|
||||
a.CreatedAt = now
|
||||
}
|
||||
a.UpdatedAt = now
|
||||
cp := *a
|
||||
cp.db = nil
|
||||
m.answers[a.QuestionID] = &cp
|
||||
*a = cp
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Memory) Vote(_ context.Context, userID, questionID string, value int) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if value != 1 && value != -1 && value != 0 {
|
||||
return fmt.Errorf("invalid vote")
|
||||
}
|
||||
q, ok := m.questions[questionID]
|
||||
if !ok || q.Hidden {
|
||||
return ErrHiddenOrMissing
|
||||
}
|
||||
if m.votes[questionID] == nil {
|
||||
m.votes[questionID] = map[string]int{}
|
||||
}
|
||||
if value == 0 {
|
||||
delete(m.votes[questionID], userID)
|
||||
return nil
|
||||
}
|
||||
m.votes[questionID][userID] = value
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestMemoryConcurrentLastAdminDemotion(t *testing.T) {
|
||||
m := NewMemory()
|
||||
ctx := context.Background()
|
||||
a := &User{Username: "admin_a", PasswordHash: "x", Role: RoleUser}
|
||||
b := &User{Username: "admin_b", PasswordHash: "x", Role: RoleUser}
|
||||
if err := m.CreateUser(ctx, a); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := m.CreateUser(ctx, b); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := m.SetUserRole(ctx, a.ID, RoleAdmin); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := m.SetUserRole(ctx, b.ID, RoleAdmin); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
errs := make(chan error, 2)
|
||||
wg.Add(2)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
errs <- m.SetUserRole(ctx, a.ID, RoleUser)
|
||||
}()
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
errs <- m.SetUserRole(ctx, b.ID, RoleUser)
|
||||
}()
|
||||
wg.Wait()
|
||||
close(errs)
|
||||
|
||||
var ok, lastAdmin int
|
||||
for err := range errs {
|
||||
switch err {
|
||||
case nil:
|
||||
ok++
|
||||
case ErrLastAdmin:
|
||||
lastAdmin++
|
||||
default:
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
if ok != 1 || lastAdmin != 1 {
|
||||
t.Fatalf("want 1 success and 1 ErrLastAdmin, got ok=%d lastAdmin=%d", ok, lastAdmin)
|
||||
}
|
||||
n, err := m.CountAdmins(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n != 1 {
|
||||
t.Fatalf("admins remaining = %d, want 1", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMemoryConcurrentBootstrapAdmin(t *testing.T) {
|
||||
m := NewMemory()
|
||||
ctx := context.Background()
|
||||
a := &User{Username: "boot_a", PasswordHash: "x", Role: RoleAdmin}
|
||||
b := &User{Username: "boot_b", PasswordHash: "x", Role: RoleAdmin}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
errs := make(chan error, 2)
|
||||
wg.Add(2)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
errs <- m.CreateUser(ctx, a)
|
||||
}()
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
errs <- m.CreateUser(ctx, b)
|
||||
}()
|
||||
wg.Wait()
|
||||
close(errs)
|
||||
for err := range errs {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
n, err := m.CountAdmins(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n != 1 {
|
||||
t.Fatalf("bootstrap race left %d admins, want 1", n)
|
||||
}
|
||||
if a.Role == RoleAdmin && b.Role == RoleAdmin {
|
||||
t.Fatal("both users kept RoleAdmin")
|
||||
}
|
||||
if a.Role != RoleAdmin && b.Role != RoleAdmin {
|
||||
t.Fatal("neither user is admin")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"log"
|
||||
)
|
||||
|
||||
const migrateLockKey int64 = 0x706c756d5f6d6967 // "plum_mig"
|
||||
|
||||
// migrateUserProfileColumns adds avatar_url and state when missing (existing DBs).
|
||||
func migrateUserProfileColumns(ctx context.Context, exec execContext) error {
|
||||
cols := []string{"avatar_url", "state"}
|
||||
for _, col := range cols {
|
||||
stmt := fmt.Sprintf(`ALTER TABLE users ADD COLUMN IF NOT EXISTS %s TEXT NOT NULL DEFAULT ''`, col)
|
||||
if _, err := exec.ExecContext(ctx, stmt); err != nil {
|
||||
return fmt.Errorf("add column %s: %w", col, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type execContext interface {
|
||||
ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error)
|
||||
QueryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error)
|
||||
}
|
||||
|
||||
// applyMigrations runs versioned migrations under a session-level advisory lock
|
||||
// held for the entire process (check versions → apply → record).
|
||||
func applyMigrations(db *sql.DB, schemaSQL string) error {
|
||||
ctx := context.Background()
|
||||
conn, err := db.Conn(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
if _, err := conn.ExecContext(ctx, `SELECT pg_advisory_lock($1)`, migrateLockKey); err != nil {
|
||||
return fmt.Errorf("migrate lock: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
if _, unlockErr := conn.ExecContext(ctx, `SELECT pg_advisory_unlock($1)`, migrateLockKey); unlockErr != nil {
|
||||
log.Printf("migrate unlock: %v", unlockErr)
|
||||
}
|
||||
}()
|
||||
|
||||
if _, err := conn.ExecContext(ctx, `
|
||||
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)
|
||||
}
|
||||
|
||||
applied, err := appliedVersions(ctx, conn)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
migrations := []struct {
|
||||
version string
|
||||
run func(context.Context, execContext) error
|
||||
}{
|
||||
{"001_schema", func(ctx context.Context, exec execContext) error {
|
||||
return applySchema(ctx, exec, 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(ctx, conn); err != nil {
|
||||
return fmt.Errorf("migrate %s: %w", m.version, err)
|
||||
}
|
||||
if _, err := conn.ExecContext(ctx, `INSERT INTO schema_migrations (version) VALUES ($1)`, m.version); err != nil {
|
||||
return fmt.Errorf("record %s: %w", m.version, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func appliedVersions(ctx context.Context, exec execContext) (map[string]bool, error) {
|
||||
rows, err := exec.QueryContext(ctx, `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()
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
_ "github.com/jackc/pgx/v5/stdlib"
|
||||
)
|
||||
|
||||
// applySchema runs semicolon-separated DDL statements.
|
||||
func applySchema(ctx context.Context, exec execContext, schema string) error {
|
||||
for _, stmt := range strings.Split(schema, ";") {
|
||||
stmt = strings.TrimSpace(stmt)
|
||||
if stmt == "" {
|
||||
continue
|
||||
}
|
||||
if _, err := exec.ExecContext(ctx, stmt); err != nil {
|
||||
return fmt.Errorf("%w: %s", err, stmt)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// postgresDSN normalizes DATABASE_URL for pgx (sslmode default, strip unsupported params).
|
||||
func postgresDSN(raw string) (string, error) {
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("DATABASE_URL: %w", err)
|
||||
}
|
||||
switch u.Scheme {
|
||||
case "postgres", "postgresql":
|
||||
default:
|
||||
return "", fmt.Errorf("DATABASE_URL must be a postgres URL")
|
||||
}
|
||||
q := u.Query()
|
||||
if strings.EqualFold(q.Get("sslrootcert"), "system") {
|
||||
q.Del("sslrootcert")
|
||||
}
|
||||
q.Del("sslnegotiation")
|
||||
if q.Get("sslmode") == "" {
|
||||
q.Set("sslmode", "verify-full")
|
||||
}
|
||||
u.RawQuery = q.Encode()
|
||||
return u.String(), nil
|
||||
}
|
||||
|
||||
// OpenPostgres connects to Postgres, applies schema/migrations, and starts session cleanup.
|
||||
func OpenPostgres(databaseURL, schema string) (*sql.DB, *SessionStore, error) {
|
||||
dsn, err := postgresDSN(databaseURL)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
db, err := sql.Open("pgx", dsn)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
db.SetMaxOpenConns(20)
|
||||
db.SetMaxIdleConns(5)
|
||||
if err := db.Ping(); err != nil {
|
||||
_ = db.Close()
|
||||
return nil, nil, fmt.Errorf("postgres ping: %w", err)
|
||||
}
|
||||
if err := applyMigrations(db, schema); err != nil {
|
||||
_ = db.Close()
|
||||
return nil, nil, fmt.Errorf("migrate: %w", err)
|
||||
}
|
||||
sessions := NewSessionStore(db, 5*time.Minute)
|
||||
return db, sessions, nil
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"plumber/internal/store/sqlc"
|
||||
)
|
||||
|
||||
// Postgres implements Store against a sqlc-backed database.
|
||||
type Postgres struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
// NewPostgres wraps db as a Store.
|
||||
func NewPostgres(db *sql.DB) *Postgres {
|
||||
return &Postgres{db: db}
|
||||
}
|
||||
|
||||
func (p *Postgres) CreateUser(ctx context.Context, u *User) error {
|
||||
if u == nil {
|
||||
return fmt.Errorf("user: nil")
|
||||
}
|
||||
if u.Role != RoleUser && u.Role != RoleAdmin {
|
||||
return fmt.Errorf("invalid role")
|
||||
}
|
||||
u.Username = NormalizeUsername(u.Username)
|
||||
if u.ID == "" {
|
||||
u.ID = uuid.NewString()
|
||||
}
|
||||
if u.Name == "" {
|
||||
u.Name = u.Username
|
||||
}
|
||||
if u.CreatedAt == "" {
|
||||
u.CreatedAt = time.Now().UTC().Format(time.RFC3339)
|
||||
}
|
||||
|
||||
if u.Role != RoleAdmin {
|
||||
u.db = p.db
|
||||
return u.Create(ctx)
|
||||
}
|
||||
|
||||
// Bootstrap admin: serialize count+insert so two setup-secret registers
|
||||
// cannot both observe zero admins.
|
||||
tx, err := p.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
if _, err := tx.ExecContext(ctx, `SELECT pg_advisory_xact_lock($1)`, adminRoleLockKey); err != nil {
|
||||
return err
|
||||
}
|
||||
q := sqlc.New(tx)
|
||||
n, err := q.CountAdmins(ctx, string(RoleAdmin))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
role := RoleAdmin
|
||||
if n > 0 {
|
||||
role = RoleUser
|
||||
}
|
||||
if err := q.CreateUser(ctx, sqlc.CreateUserParams{
|
||||
ID: u.ID,
|
||||
Username: u.Username,
|
||||
Name: u.Name,
|
||||
PasswordHash: u.PasswordHash,
|
||||
Role: string(role),
|
||||
CreatedAt: u.CreatedAt,
|
||||
}); err != nil {
|
||||
return mapUniqueViolation(err)
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return err
|
||||
}
|
||||
u.Role = role
|
||||
u.db = p.db
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *Postgres) UserByID(ctx context.Context, id string) (*User, error) {
|
||||
return UserByID(ctx, p.db, id)
|
||||
}
|
||||
|
||||
func (p *Postgres) UserByUsername(ctx context.Context, username string) (*User, error) {
|
||||
return UserByUsername(ctx, p.db, username)
|
||||
}
|
||||
|
||||
func (p *Postgres) ListUsers(ctx context.Context, q ListUsersQuery) ([]User, string, string, error) {
|
||||
return ListUsers(ctx, p.db, q)
|
||||
}
|
||||
|
||||
func (p *Postgres) CountAdmins(ctx context.Context) (int, error) {
|
||||
return CountAdmins(ctx, p.db)
|
||||
}
|
||||
|
||||
func (p *Postgres) SetUserRole(ctx context.Context, id string, role Role) error {
|
||||
u := &User{ID: id, db: p.db}
|
||||
return u.SetRole(ctx, role)
|
||||
}
|
||||
|
||||
func (p *Postgres) SaveUserProfile(ctx context.Context, u *User) error {
|
||||
u.db = p.db
|
||||
return u.SaveProfile(ctx)
|
||||
}
|
||||
|
||||
func (p *Postgres) CreateQuestion(ctx context.Context, q *RankedQuestion) error {
|
||||
q.db = p.db
|
||||
return q.Create(ctx)
|
||||
}
|
||||
|
||||
func (p *Postgres) GetQuestion(ctx context.Context, id, viewerID string) (*RankedQuestion, error) {
|
||||
return GetQuestion(ctx, p.db, id, viewerID)
|
||||
}
|
||||
|
||||
func (p *Postgres) ListHunt(ctx context.Context, huntDate, viewerID string) ([]RankedQuestion, error) {
|
||||
return ListHunt(ctx, p.db, huntDate, viewerID)
|
||||
}
|
||||
|
||||
func (p *Postgres) ListQuestionsByAuthor(ctx context.Context, authorID string) ([]RankedQuestion, error) {
|
||||
return ListQuestionsByAuthor(ctx, p.db, authorID)
|
||||
}
|
||||
|
||||
func (p *Postgres) ListQuestionsAnsweredBy(ctx context.Context, adminID string) ([]RankedQuestion, error) {
|
||||
return ListQuestionsAnsweredBy(ctx, p.db, adminID)
|
||||
}
|
||||
|
||||
func (p *Postgres) HideQuestion(ctx context.Context, id string) error {
|
||||
q := &RankedQuestion{ID: id, db: p.db}
|
||||
return q.Hide(ctx)
|
||||
}
|
||||
|
||||
func (p *Postgres) GetAnswer(ctx context.Context, questionID string) (*Answer, error) {
|
||||
return GetAnswer(ctx, p.db, questionID)
|
||||
}
|
||||
|
||||
func (p *Postgres) UpsertAnswer(ctx context.Context, a *Answer) error {
|
||||
a.db = p.db
|
||||
return a.Upsert(ctx)
|
||||
}
|
||||
|
||||
func (p *Postgres) Vote(ctx context.Context, userID, questionID string, value int) error {
|
||||
return Vote(ctx, p.db, userID, questionID, value)
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNormalizeUsername(t *testing.T) {
|
||||
if got := NormalizeUsername(" Alice_1 "); got != "alice_1" {
|
||||
t.Fatalf("got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPostgresDSNDefaultsSSLMode(t *testing.T) {
|
||||
in := "postgresql://user:pass@db.example.com:5432/postgres"
|
||||
out, err := postgresDSN(in)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(out, "sslmode=verify-full") {
|
||||
t.Fatalf("missing default sslmode: %s", out)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"plumber/internal/pacific"
|
||||
"plumber/internal/store/sqlc"
|
||||
)
|
||||
|
||||
// RankedQuestion is a question row with score / vote annotations for lists.
|
||||
type RankedQuestion struct {
|
||||
ID string
|
||||
AuthorID string
|
||||
AuthorName string
|
||||
Title string
|
||||
Body string
|
||||
City string
|
||||
HuntDate string
|
||||
Hidden bool
|
||||
CreatedAt string
|
||||
Score int
|
||||
Answered bool
|
||||
UserVote int
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
// NewQuestion returns a question bound to db (not yet inserted).
|
||||
func NewQuestion(db *sql.DB) *RankedQuestion {
|
||||
return &RankedQuestion{db: db}
|
||||
}
|
||||
|
||||
// Create inserts the question. Sets ID, HuntDate, and CreatedAt when empty.
|
||||
func (q *RankedQuestion) Create(ctx context.Context) error {
|
||||
if q == nil || q.db == nil {
|
||||
return fmt.Errorf("question: no database")
|
||||
}
|
||||
q.Title = strings.TrimSpace(q.Title)
|
||||
q.Body = strings.TrimSpace(q.Body)
|
||||
q.City = strings.TrimSpace(q.City)
|
||||
if q.ID == "" {
|
||||
q.ID = uuid.NewString()
|
||||
}
|
||||
if q.HuntDate == "" {
|
||||
q.HuntDate = pacific.Today()
|
||||
}
|
||||
if q.CreatedAt == "" {
|
||||
q.CreatedAt = time.Now().UTC().Format(time.RFC3339)
|
||||
}
|
||||
return sqlc.New(q.db).CreateQuestion(ctx, sqlc.CreateQuestionParams{
|
||||
ID: q.ID,
|
||||
AuthorID: q.AuthorID,
|
||||
Title: q.Title,
|
||||
Body: q.Body,
|
||||
City: q.City,
|
||||
HuntDate: q.HuntDate,
|
||||
CreatedAt: q.CreatedAt,
|
||||
})
|
||||
}
|
||||
|
||||
// Hide marks the question hidden.
|
||||
func (q *RankedQuestion) Hide(ctx context.Context) error {
|
||||
if q == nil || q.db == nil {
|
||||
return fmt.Errorf("question: no database")
|
||||
}
|
||||
if err := sqlc.New(q.db).HideQuestion(ctx, q.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
q.Hidden = true
|
||||
return nil
|
||||
}
|
||||
|
||||
func rankedFrom(
|
||||
db *sql.DB,
|
||||
id, authorID, authorName, title, body, city, huntDate, createdAt string,
|
||||
hidden int32, score, answered, userVote int64,
|
||||
) RankedQuestion {
|
||||
return RankedQuestion{
|
||||
ID: id,
|
||||
AuthorID: authorID,
|
||||
AuthorName: authorName,
|
||||
Title: title,
|
||||
Body: body,
|
||||
City: city,
|
||||
HuntDate: huntDate,
|
||||
Hidden: hidden != 0,
|
||||
CreatedAt: createdAt,
|
||||
Score: int(score),
|
||||
Answered: answered != 0,
|
||||
UserVote: int(userVote),
|
||||
db: db,
|
||||
}
|
||||
}
|
||||
|
||||
func ListHunt(ctx context.Context, db *sql.DB, huntDate, viewerID string) ([]RankedQuestion, error) {
|
||||
rows, err := sqlc.New(db).ListHunt(ctx, sqlc.ListHuntParams{
|
||||
ViewerID: viewerID,
|
||||
HuntDate: huntDate,
|
||||
RowLimit: HuntListLimit,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]RankedQuestion, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
out = append(out, rankedFrom(db, r.ID, r.AuthorID, r.AuthorName, r.Title, r.Body, r.City, r.HuntDate, r.CreatedAt, r.Hidden, r.Score, r.Answered, r.UserVote))
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func GetQuestion(ctx context.Context, db *sql.DB, id, viewerID string) (*RankedQuestion, error) {
|
||||
r, err := sqlc.New(db).GetQuestion(ctx, sqlc.GetQuestionParams{
|
||||
ViewerID: viewerID,
|
||||
ID: id,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
q := rankedFrom(db, r.ID, r.AuthorID, r.AuthorName, r.Title, r.Body, r.City, r.HuntDate, r.CreatedAt, r.Hidden, r.Score, r.Answered, r.UserVote)
|
||||
return &q, nil
|
||||
}
|
||||
|
||||
func ListQuestionsByAuthor(ctx context.Context, db *sql.DB, authorID string) ([]RankedQuestion, error) {
|
||||
rows, err := sqlc.New(db).ListQuestionsByAuthor(ctx, sqlc.ListQuestionsByAuthorParams{
|
||||
AuthorID: authorID,
|
||||
RowLimit: ProfileListLimit,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]RankedQuestion, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
out = append(out, rankedFrom(db, r.ID, r.AuthorID, r.AuthorName, r.Title, r.Body, r.City, r.HuntDate, r.CreatedAt, r.Hidden, r.Score, r.Answered, r.UserVote))
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func ListQuestionsAnsweredBy(ctx context.Context, db *sql.DB, adminID string) ([]RankedQuestion, error) {
|
||||
rows, err := sqlc.New(db).ListQuestionsAnsweredBy(ctx, sqlc.ListQuestionsAnsweredByParams{
|
||||
AdminID: adminID,
|
||||
RowLimit: ProfileListLimit,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]RankedQuestion, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
out = append(out, rankedFrom(db, r.ID, r.AuthorID, r.AuthorName, r.Title, r.Body, r.City, r.HuntDate, r.CreatedAt, r.Hidden, r.Score, r.Answered, r.UserVote))
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"log"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/alexedwards/scs/v2"
|
||||
|
||||
"plumber/internal/store/sqlc"
|
||||
)
|
||||
|
||||
// SessionStore persists scs sessions in Postgres via sqlc and optionally
|
||||
// deletes expired rows on an interval.
|
||||
type SessionStore struct {
|
||||
db *sql.DB
|
||||
q *sqlc.Queries
|
||||
stop chan struct{}
|
||||
stopped chan struct{}
|
||||
once sync.Once
|
||||
}
|
||||
|
||||
// NewSessionStore creates a store backed by db. cleanupInterval > 0 starts a
|
||||
// background goroutine that deletes expired sessions; 0 disables cleanup.
|
||||
func NewSessionStore(db *sql.DB, cleanupInterval time.Duration) *SessionStore {
|
||||
s := &SessionStore{
|
||||
db: db,
|
||||
q: sqlc.New(db),
|
||||
}
|
||||
if cleanupInterval > 0 {
|
||||
s.stop = make(chan struct{})
|
||||
s.stopped = make(chan struct{})
|
||||
go s.cleanupLoop(cleanupInterval)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// Store returns the scs.Store implementation (s itself).
|
||||
func (s *SessionStore) Store() scs.Store {
|
||||
return s
|
||||
}
|
||||
|
||||
// Find implements scs.Store.
|
||||
func (s *SessionStore) Find(token string) ([]byte, bool, error) {
|
||||
return s.FindCtx(context.Background(), token)
|
||||
}
|
||||
|
||||
// Commit implements scs.Store.
|
||||
func (s *SessionStore) Commit(token string, data []byte, expiry time.Time) error {
|
||||
return s.CommitCtx(context.Background(), token, data, expiry)
|
||||
}
|
||||
|
||||
// Delete implements scs.Store.
|
||||
func (s *SessionStore) Delete(token string) error {
|
||||
return s.DeleteCtx(context.Background(), token)
|
||||
}
|
||||
|
||||
// FindCtx implements scs.CtxStore.
|
||||
func (s *SessionStore) FindCtx(ctx context.Context, token string) ([]byte, bool, error) {
|
||||
data, err := s.q.GetSession(ctx, token)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, false, nil
|
||||
}
|
||||
return nil, false, err
|
||||
}
|
||||
return data, true, nil
|
||||
}
|
||||
|
||||
// CommitCtx implements scs.CtxStore.
|
||||
func (s *SessionStore) CommitCtx(ctx context.Context, token string, data []byte, expiry time.Time) error {
|
||||
return s.q.UpsertSession(ctx, sqlc.UpsertSessionParams{
|
||||
Token: token,
|
||||
Data: data,
|
||||
Expiry: expiry,
|
||||
})
|
||||
}
|
||||
|
||||
// DeleteCtx implements scs.CtxStore.
|
||||
func (s *SessionStore) DeleteCtx(ctx context.Context, token string) error {
|
||||
return s.q.DeleteSession(ctx, token)
|
||||
}
|
||||
|
||||
// StopCleanup stops the background expiry deleter. Safe to call multiple times.
|
||||
func (s *SessionStore) StopCleanup() {
|
||||
if s == nil || s.stop == nil {
|
||||
return
|
||||
}
|
||||
s.once.Do(func() {
|
||||
close(s.stop)
|
||||
<-s.stopped
|
||||
})
|
||||
}
|
||||
|
||||
// Close stops background session cleanup.
|
||||
func (s *SessionStore) Close() {
|
||||
s.StopCleanup()
|
||||
}
|
||||
|
||||
func (s *SessionStore) cleanupLoop(interval time.Duration) {
|
||||
defer close(s.stopped)
|
||||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
var lastErrLog time.Time
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
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:
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestSessionStoreCommitFindDelete(t *testing.T) {
|
||||
url := os.Getenv("TEST_DATABASE_URL")
|
||||
if url == "" {
|
||||
t.Skip("TEST_DATABASE_URL not set")
|
||||
}
|
||||
schema, err := os.ReadFile("../../schema.sql")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
db, sessions, err := OpenPostgres(url, string(schema))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
defer sessions.Close()
|
||||
|
||||
token := "test-session-" + time.Now().Format("20060102150405.000000000")
|
||||
data := []byte("hello-session")
|
||||
expiry := time.Now().Add(time.Hour)
|
||||
|
||||
if err := sessions.Commit(token, data, expiry); err != nil {
|
||||
t.Fatalf("Commit: %v", err)
|
||||
}
|
||||
got, found, err := sessions.Find(token)
|
||||
if err != nil {
|
||||
t.Fatalf("Find: %v", err)
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("expected found")
|
||||
}
|
||||
if string(got) != string(data) {
|
||||
t.Fatalf("data = %q, want %q", got, data)
|
||||
}
|
||||
if err := sessions.Delete(token); err != nil {
|
||||
t.Fatalf("Delete: %v", err)
|
||||
}
|
||||
_, found, err = sessions.Find(token)
|
||||
if err != nil {
|
||||
t.Fatalf("Find after delete: %v", err)
|
||||
}
|
||||
if found {
|
||||
t.Fatal("expected not found after delete")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.31.1
|
||||
// source: answers.sql
|
||||
|
||||
package sqlc
|
||||
|
||||
import (
|
||||
"context"
|
||||
)
|
||||
|
||||
const getAnswer = `-- name: GetAnswer :one
|
||||
SELECT a.question_id, a.author_id, u.name AS author_name, a.body, a.created_at, a.updated_at
|
||||
FROM answers a
|
||||
JOIN users u ON u.id = a.author_id
|
||||
WHERE a.question_id = $1
|
||||
`
|
||||
|
||||
type GetAnswerRow struct {
|
||||
QuestionID string
|
||||
AuthorID string
|
||||
AuthorName string
|
||||
Body string
|
||||
CreatedAt string
|
||||
UpdatedAt string
|
||||
}
|
||||
|
||||
func (q *Queries) GetAnswer(ctx context.Context, questionID string) (GetAnswerRow, error) {
|
||||
row := q.db.QueryRowContext(ctx, getAnswer, questionID)
|
||||
var i GetAnswerRow
|
||||
err := row.Scan(
|
||||
&i.QuestionID,
|
||||
&i.AuthorID,
|
||||
&i.AuthorName,
|
||||
&i.Body,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const upsertAnswer = `-- name: UpsertAnswer :exec
|
||||
INSERT INTO answers (question_id, author_id, body, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
ON CONFLICT (question_id) DO UPDATE
|
||||
SET body = excluded.body, author_id = excluded.author_id, updated_at = excluded.updated_at
|
||||
`
|
||||
|
||||
type UpsertAnswerParams struct {
|
||||
QuestionID string
|
||||
AuthorID string
|
||||
Body string
|
||||
CreatedAt string
|
||||
UpdatedAt string
|
||||
}
|
||||
|
||||
func (q *Queries) UpsertAnswer(ctx context.Context, arg UpsertAnswerParams) error {
|
||||
_, err := q.db.ExecContext(ctx, upsertAnswer,
|
||||
arg.QuestionID,
|
||||
arg.AuthorID,
|
||||
arg.Body,
|
||||
arg.CreatedAt,
|
||||
arg.UpdatedAt,
|
||||
)
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.31.1
|
||||
|
||||
package sqlc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
)
|
||||
|
||||
type DBTX interface {
|
||||
ExecContext(context.Context, string, ...interface{}) (sql.Result, error)
|
||||
PrepareContext(context.Context, string) (*sql.Stmt, error)
|
||||
QueryContext(context.Context, string, ...interface{}) (*sql.Rows, error)
|
||||
QueryRowContext(context.Context, string, ...interface{}) *sql.Row
|
||||
}
|
||||
|
||||
func New(db DBTX) *Queries {
|
||||
return &Queries{db: db}
|
||||
}
|
||||
|
||||
type Queries struct {
|
||||
db DBTX
|
||||
}
|
||||
|
||||
func (q *Queries) WithTx(tx *sql.Tx) *Queries {
|
||||
return &Queries{
|
||||
db: tx,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.31.1
|
||||
|
||||
package sqlc
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
type Answer struct {
|
||||
QuestionID string
|
||||
AuthorID string
|
||||
Body string
|
||||
CreatedAt string
|
||||
UpdatedAt string
|
||||
}
|
||||
|
||||
type Question struct {
|
||||
ID string
|
||||
AuthorID string
|
||||
Title string
|
||||
Body string
|
||||
City string
|
||||
HuntDate string
|
||||
Hidden int32
|
||||
CreatedAt string
|
||||
}
|
||||
|
||||
type Session struct {
|
||||
Token string
|
||||
Data []byte
|
||||
Expiry time.Time
|
||||
}
|
||||
|
||||
type User struct {
|
||||
ID string
|
||||
Username string
|
||||
Name string
|
||||
PasswordHash string
|
||||
Role string
|
||||
AvatarUrl string
|
||||
State string
|
||||
CreatedAt string
|
||||
}
|
||||
|
||||
type Vote struct {
|
||||
UserID string
|
||||
QuestionID string
|
||||
Value int32
|
||||
}
|
||||
@@ -0,0 +1,316 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.31.1
|
||||
// source: questions.sql
|
||||
|
||||
package sqlc
|
||||
|
||||
import (
|
||||
"context"
|
||||
)
|
||||
|
||||
const createQuestion = `-- name: CreateQuestion :exec
|
||||
INSERT INTO questions (id, author_id, title, body, city, hunt_date, hidden, created_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, 0, $7)
|
||||
`
|
||||
|
||||
type CreateQuestionParams struct {
|
||||
ID string
|
||||
AuthorID string
|
||||
Title string
|
||||
Body string
|
||||
City string
|
||||
HuntDate string
|
||||
CreatedAt string
|
||||
}
|
||||
|
||||
func (q *Queries) CreateQuestion(ctx context.Context, arg CreateQuestionParams) error {
|
||||
_, err := q.db.ExecContext(ctx, createQuestion,
|
||||
arg.ID,
|
||||
arg.AuthorID,
|
||||
arg.Title,
|
||||
arg.Body,
|
||||
arg.City,
|
||||
arg.HuntDate,
|
||||
arg.CreatedAt,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
const getQuestion = `-- name: GetQuestion :one
|
||||
SELECT q.id, q.author_id, u.name AS author_name, q.title, q.body, q.city, q.hunt_date, q.hidden, q.created_at,
|
||||
COALESCE((SELECT SUM(votes.value) FROM votes WHERE votes.question_id = q.id), 0)::bigint AS score,
|
||||
CASE WHEN a.question_id IS NULL THEN 0 ELSE 1 END::bigint AS answered,
|
||||
COALESCE((
|
||||
SELECT votes.value FROM votes
|
||||
WHERE votes.user_id = $1 AND votes.question_id = q.id
|
||||
), 0)::bigint AS user_vote
|
||||
FROM questions q
|
||||
JOIN users u ON u.id = q.author_id
|
||||
LEFT JOIN answers a ON a.question_id = q.id
|
||||
WHERE q.id = $2
|
||||
`
|
||||
|
||||
type GetQuestionParams struct {
|
||||
ViewerID string
|
||||
ID string
|
||||
}
|
||||
|
||||
type GetQuestionRow struct {
|
||||
ID string
|
||||
AuthorID string
|
||||
AuthorName string
|
||||
Title string
|
||||
Body string
|
||||
City string
|
||||
HuntDate string
|
||||
Hidden int32
|
||||
CreatedAt string
|
||||
Score int64
|
||||
Answered int64
|
||||
UserVote int64
|
||||
}
|
||||
|
||||
func (q *Queries) GetQuestion(ctx context.Context, arg GetQuestionParams) (GetQuestionRow, error) {
|
||||
row := q.db.QueryRowContext(ctx, getQuestion, arg.ViewerID, arg.ID)
|
||||
var i GetQuestionRow
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.AuthorID,
|
||||
&i.AuthorName,
|
||||
&i.Title,
|
||||
&i.Body,
|
||||
&i.City,
|
||||
&i.HuntDate,
|
||||
&i.Hidden,
|
||||
&i.CreatedAt,
|
||||
&i.Score,
|
||||
&i.Answered,
|
||||
&i.UserVote,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const hideQuestion = `-- name: HideQuestion :exec
|
||||
UPDATE questions
|
||||
SET hidden = 1
|
||||
WHERE id = $1
|
||||
`
|
||||
|
||||
func (q *Queries) HideQuestion(ctx context.Context, id string) error {
|
||||
_, err := q.db.ExecContext(ctx, hideQuestion, id)
|
||||
return err
|
||||
}
|
||||
|
||||
const listHunt = `-- name: ListHunt :many
|
||||
SELECT q.id, q.author_id, u.name AS author_name, q.title, q.body, q.city, q.hunt_date, q.hidden, q.created_at,
|
||||
COALESCE(SUM(v.value), 0)::bigint AS score,
|
||||
CASE WHEN a.question_id IS NULL THEN 0 ELSE 1 END::bigint AS answered,
|
||||
COALESCE((
|
||||
SELECT votes.value FROM votes
|
||||
WHERE votes.user_id = $1 AND votes.question_id = q.id
|
||||
), 0)::bigint AS user_vote
|
||||
FROM questions q
|
||||
JOIN users u ON u.id = q.author_id
|
||||
LEFT JOIN votes v ON v.question_id = q.id
|
||||
LEFT JOIN answers a ON a.question_id = q.id
|
||||
WHERE q.hunt_date = $2 AND q.hidden = 0
|
||||
GROUP BY q.id, q.author_id, u.name, q.title, q.body, q.city, q.hunt_date, q.hidden, q.created_at, a.question_id
|
||||
ORDER BY score DESC, q.created_at ASC
|
||||
LIMIT $3
|
||||
`
|
||||
|
||||
type ListHuntParams struct {
|
||||
ViewerID string
|
||||
HuntDate string
|
||||
RowLimit int32
|
||||
}
|
||||
|
||||
type ListHuntRow struct {
|
||||
ID string
|
||||
AuthorID string
|
||||
AuthorName string
|
||||
Title string
|
||||
Body string
|
||||
City string
|
||||
HuntDate string
|
||||
Hidden int32
|
||||
CreatedAt string
|
||||
Score int64
|
||||
Answered int64
|
||||
UserVote int64
|
||||
}
|
||||
|
||||
func (q *Queries) ListHunt(ctx context.Context, arg ListHuntParams) ([]ListHuntRow, error) {
|
||||
rows, err := q.db.QueryContext(ctx, listHunt, arg.ViewerID, arg.HuntDate, arg.RowLimit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []ListHuntRow{}
|
||||
for rows.Next() {
|
||||
var i ListHuntRow
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.AuthorID,
|
||||
&i.AuthorName,
|
||||
&i.Title,
|
||||
&i.Body,
|
||||
&i.City,
|
||||
&i.HuntDate,
|
||||
&i.Hidden,
|
||||
&i.CreatedAt,
|
||||
&i.Score,
|
||||
&i.Answered,
|
||||
&i.UserVote,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const listQuestionsAnsweredBy = `-- name: ListQuestionsAnsweredBy :many
|
||||
SELECT q.id, q.author_id, u.name AS author_name, q.title, q.body, q.city, q.hunt_date, q.hidden, q.created_at,
|
||||
COALESCE((SELECT SUM(votes.value) FROM votes WHERE votes.question_id = q.id), 0)::bigint AS score,
|
||||
1::bigint AS answered,
|
||||
0::bigint AS user_vote
|
||||
FROM answers ans
|
||||
JOIN questions q ON q.id = ans.question_id
|
||||
JOIN users u ON u.id = q.author_id
|
||||
WHERE ans.author_id = $1 AND q.hidden = 0
|
||||
ORDER BY ans.updated_at DESC
|
||||
LIMIT $2
|
||||
`
|
||||
|
||||
type ListQuestionsAnsweredByParams struct {
|
||||
AdminID string
|
||||
RowLimit int32
|
||||
}
|
||||
|
||||
type ListQuestionsAnsweredByRow struct {
|
||||
ID string
|
||||
AuthorID string
|
||||
AuthorName string
|
||||
Title string
|
||||
Body string
|
||||
City string
|
||||
HuntDate string
|
||||
Hidden int32
|
||||
CreatedAt string
|
||||
Score int64
|
||||
Answered int64
|
||||
UserVote int64
|
||||
}
|
||||
|
||||
func (q *Queries) ListQuestionsAnsweredBy(ctx context.Context, arg ListQuestionsAnsweredByParams) ([]ListQuestionsAnsweredByRow, error) {
|
||||
rows, err := q.db.QueryContext(ctx, listQuestionsAnsweredBy, arg.AdminID, arg.RowLimit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []ListQuestionsAnsweredByRow{}
|
||||
for rows.Next() {
|
||||
var i ListQuestionsAnsweredByRow
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.AuthorID,
|
||||
&i.AuthorName,
|
||||
&i.Title,
|
||||
&i.Body,
|
||||
&i.City,
|
||||
&i.HuntDate,
|
||||
&i.Hidden,
|
||||
&i.CreatedAt,
|
||||
&i.Score,
|
||||
&i.Answered,
|
||||
&i.UserVote,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const listQuestionsByAuthor = `-- name: ListQuestionsByAuthor :many
|
||||
SELECT q.id, q.author_id, u.name AS author_name, q.title, q.body, q.city, q.hunt_date, q.hidden, q.created_at,
|
||||
COALESCE((SELECT SUM(votes.value) FROM votes WHERE votes.question_id = q.id), 0)::bigint AS score,
|
||||
CASE WHEN a.question_id IS NULL THEN 0 ELSE 1 END::bigint AS answered,
|
||||
0::bigint AS user_vote
|
||||
FROM questions q
|
||||
JOIN users u ON u.id = q.author_id
|
||||
LEFT JOIN answers a ON a.question_id = q.id
|
||||
WHERE q.author_id = $1 AND q.hidden = 0
|
||||
ORDER BY q.created_at DESC
|
||||
LIMIT $2
|
||||
`
|
||||
|
||||
type ListQuestionsByAuthorParams struct {
|
||||
AuthorID string
|
||||
RowLimit int32
|
||||
}
|
||||
|
||||
type ListQuestionsByAuthorRow struct {
|
||||
ID string
|
||||
AuthorID string
|
||||
AuthorName string
|
||||
Title string
|
||||
Body string
|
||||
City string
|
||||
HuntDate string
|
||||
Hidden int32
|
||||
CreatedAt string
|
||||
Score int64
|
||||
Answered int64
|
||||
UserVote int64
|
||||
}
|
||||
|
||||
func (q *Queries) ListQuestionsByAuthor(ctx context.Context, arg ListQuestionsByAuthorParams) ([]ListQuestionsByAuthorRow, error) {
|
||||
rows, err := q.db.QueryContext(ctx, listQuestionsByAuthor, arg.AuthorID, arg.RowLimit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []ListQuestionsByAuthorRow{}
|
||||
for rows.Next() {
|
||||
var i ListQuestionsByAuthorRow
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.AuthorID,
|
||||
&i.AuthorName,
|
||||
&i.Title,
|
||||
&i.Body,
|
||||
&i.City,
|
||||
&i.HuntDate,
|
||||
&i.Hidden,
|
||||
&i.CreatedAt,
|
||||
&i.Score,
|
||||
&i.Answered,
|
||||
&i.UserVote,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.31.1
|
||||
// source: sessions.sql
|
||||
|
||||
package sqlc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
)
|
||||
|
||||
const deleteExpiredSessions = `-- name: DeleteExpiredSessions :execrows
|
||||
DELETE FROM sessions
|
||||
WHERE expiry <= now()
|
||||
`
|
||||
|
||||
func (q *Queries) DeleteExpiredSessions(ctx context.Context) (int64, error) {
|
||||
result, err := q.db.ExecContext(ctx, deleteExpiredSessions)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return result.RowsAffected()
|
||||
}
|
||||
|
||||
const deleteSession = `-- name: DeleteSession :exec
|
||||
DELETE FROM sessions
|
||||
WHERE token = $1
|
||||
`
|
||||
|
||||
func (q *Queries) DeleteSession(ctx context.Context, token string) error {
|
||||
_, err := q.db.ExecContext(ctx, deleteSession, token)
|
||||
return err
|
||||
}
|
||||
|
||||
const getSession = `-- name: GetSession :one
|
||||
SELECT data
|
||||
FROM sessions
|
||||
WHERE token = $1 AND expiry > now()
|
||||
`
|
||||
|
||||
func (q *Queries) GetSession(ctx context.Context, token string) ([]byte, error) {
|
||||
row := q.db.QueryRowContext(ctx, getSession, token)
|
||||
var data []byte
|
||||
err := row.Scan(&data)
|
||||
return data, err
|
||||
}
|
||||
|
||||
const upsertSession = `-- name: UpsertSession :exec
|
||||
INSERT INTO sessions (token, data, expiry)
|
||||
VALUES ($1, $2, $3)
|
||||
ON CONFLICT (token) DO UPDATE
|
||||
SET data = excluded.data, expiry = excluded.expiry
|
||||
`
|
||||
|
||||
type UpsertSessionParams struct {
|
||||
Token string
|
||||
Data []byte
|
||||
Expiry time.Time
|
||||
}
|
||||
|
||||
func (q *Queries) UpsertSession(ctx context.Context, arg UpsertSessionParams) error {
|
||||
_, err := q.db.ExecContext(ctx, upsertSession, arg.Token, arg.Data, arg.Expiry)
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.31.1
|
||||
// source: users.sql
|
||||
|
||||
package sqlc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
)
|
||||
|
||||
const countAdmins = `-- name: CountAdmins :one
|
||||
SELECT COUNT(*)::bigint AS count
|
||||
FROM users
|
||||
WHERE role = $1
|
||||
`
|
||||
|
||||
func (q *Queries) CountAdmins(ctx context.Context, role string) (int64, error) {
|
||||
row := q.db.QueryRowContext(ctx, countAdmins, role)
|
||||
var count int64
|
||||
err := row.Scan(&count)
|
||||
return count, err
|
||||
}
|
||||
|
||||
const createUser = `-- name: CreateUser :exec
|
||||
INSERT INTO users (id, username, name, password_hash, role, avatar_url, state, created_at)
|
||||
VALUES ($1, $2, $3, $4, $5, '', '', $6)
|
||||
`
|
||||
|
||||
type CreateUserParams struct {
|
||||
ID string
|
||||
Username string
|
||||
Name string
|
||||
PasswordHash string
|
||||
Role string
|
||||
CreatedAt string
|
||||
}
|
||||
|
||||
func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) error {
|
||||
_, err := q.db.ExecContext(ctx, createUser,
|
||||
arg.ID,
|
||||
arg.Username,
|
||||
arg.Name,
|
||||
arg.PasswordHash,
|
||||
arg.Role,
|
||||
arg.CreatedAt,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
const getUserByID = `-- name: GetUserByID :one
|
||||
SELECT id, username, name, role, avatar_url, state, created_at
|
||||
FROM users
|
||||
WHERE id = $1
|
||||
`
|
||||
|
||||
type GetUserByIDRow struct {
|
||||
ID string
|
||||
Username string
|
||||
Name string
|
||||
Role string
|
||||
AvatarUrl string
|
||||
State string
|
||||
CreatedAt string
|
||||
}
|
||||
|
||||
func (q *Queries) GetUserByID(ctx context.Context, id string) (GetUserByIDRow, error) {
|
||||
row := q.db.QueryRowContext(ctx, getUserByID, id)
|
||||
var i GetUserByIDRow
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.Username,
|
||||
&i.Name,
|
||||
&i.Role,
|
||||
&i.AvatarUrl,
|
||||
&i.State,
|
||||
&i.CreatedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getUserByUsername = `-- name: GetUserByUsername :one
|
||||
SELECT id, username, name, role, avatar_url, state, created_at, password_hash
|
||||
FROM users
|
||||
WHERE username = $1
|
||||
`
|
||||
|
||||
type GetUserByUsernameRow struct {
|
||||
ID string
|
||||
Username string
|
||||
Name string
|
||||
Role string
|
||||
AvatarUrl string
|
||||
State string
|
||||
CreatedAt string
|
||||
PasswordHash string
|
||||
}
|
||||
|
||||
func (q *Queries) GetUserByUsername(ctx context.Context, username string) (GetUserByUsernameRow, error) {
|
||||
row := q.db.QueryRowContext(ctx, getUserByUsername, username)
|
||||
var i GetUserByUsernameRow
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.Username,
|
||||
&i.Name,
|
||||
&i.Role,
|
||||
&i.AvatarUrl,
|
||||
&i.State,
|
||||
&i.CreatedAt,
|
||||
&i.PasswordHash,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getUserRole = `-- name: GetUserRole :one
|
||||
SELECT role
|
||||
FROM users
|
||||
WHERE id = $1
|
||||
`
|
||||
|
||||
func (q *Queries) GetUserRole(ctx context.Context, id string) (string, error) {
|
||||
row := q.db.QueryRowContext(ctx, getUserRole, id)
|
||||
var role string
|
||||
err := row.Scan(&role)
|
||||
return role, err
|
||||
}
|
||||
|
||||
const listUsers = `-- name: ListUsers :many
|
||||
SELECT id, username, name, role, avatar_url, state, created_at
|
||||
FROM users
|
||||
WHERE (
|
||||
$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 {
|
||||
ID string
|
||||
Username string
|
||||
Name string
|
||||
Role string
|
||||
AvatarUrl string
|
||||
State string
|
||||
CreatedAt string
|
||||
}
|
||||
|
||||
func (q *Queries) ListUsers(ctx context.Context, arg ListUsersParams) ([]ListUsersRow, error) {
|
||||
rows, err := q.db.QueryContext(ctx, listUsers,
|
||||
arg.Search,
|
||||
arg.CursorCreated,
|
||||
arg.CursorID,
|
||||
arg.RowLimit,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []ListUsersRow{}
|
||||
for rows.Next() {
|
||||
var i ListUsersRow
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.Username,
|
||||
&i.Name,
|
||||
&i.Role,
|
||||
&i.AvatarUrl,
|
||||
&i.State,
|
||||
&i.CreatedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, i)
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const updateUserRole = `-- name: UpdateUserRole :execresult
|
||||
UPDATE users
|
||||
SET role = $1
|
||||
WHERE id = $2
|
||||
`
|
||||
|
||||
type UpdateUserRoleParams struct {
|
||||
Role string
|
||||
ID string
|
||||
}
|
||||
|
||||
func (q *Queries) UpdateUserRole(ctx context.Context, arg UpdateUserRoleParams) (sql.Result, error) {
|
||||
return q.db.ExecContext(ctx, updateUserRole, arg.Role, arg.ID)
|
||||
}
|
||||
|
||||
const updateUserState = `-- name: UpdateUserState :exec
|
||||
UPDATE users
|
||||
SET state = $1
|
||||
WHERE id = $2
|
||||
`
|
||||
|
||||
type UpdateUserStateParams struct {
|
||||
State string
|
||||
ID string
|
||||
}
|
||||
|
||||
func (q *Queries) UpdateUserState(ctx context.Context, arg UpdateUserStateParams) error {
|
||||
_, err := q.db.ExecContext(ctx, updateUserState, arg.State, arg.ID)
|
||||
return err
|
||||
}
|
||||
|
||||
const updateUserStateAndAvatar = `-- name: UpdateUserStateAndAvatar :exec
|
||||
UPDATE users
|
||||
SET state = $1, avatar_url = $2
|
||||
WHERE id = $3
|
||||
`
|
||||
|
||||
type UpdateUserStateAndAvatarParams struct {
|
||||
State string
|
||||
AvatarUrl string
|
||||
ID string
|
||||
}
|
||||
|
||||
func (q *Queries) UpdateUserStateAndAvatar(ctx context.Context, arg UpdateUserStateAndAvatarParams) error {
|
||||
_, err := q.db.ExecContext(ctx, updateUserStateAndAvatar, arg.State, arg.AvatarUrl, arg.ID)
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.31.1
|
||||
// source: votes.sql
|
||||
|
||||
package sqlc
|
||||
|
||||
import (
|
||||
"context"
|
||||
)
|
||||
|
||||
const deleteVote = `-- name: DeleteVote :exec
|
||||
DELETE FROM votes
|
||||
WHERE user_id = $1 AND question_id = $2
|
||||
`
|
||||
|
||||
type DeleteVoteParams struct {
|
||||
UserID string
|
||||
QuestionID string
|
||||
}
|
||||
|
||||
func (q *Queries) DeleteVote(ctx context.Context, arg DeleteVoteParams) error {
|
||||
_, err := q.db.ExecContext(ctx, deleteVote, arg.UserID, arg.QuestionID)
|
||||
return err
|
||||
}
|
||||
|
||||
const getVote = `-- name: GetVote :one
|
||||
SELECT value
|
||||
FROM votes
|
||||
WHERE user_id = $1 AND question_id = $2
|
||||
`
|
||||
|
||||
type GetVoteParams struct {
|
||||
UserID string
|
||||
QuestionID string
|
||||
}
|
||||
|
||||
func (q *Queries) GetVote(ctx context.Context, arg GetVoteParams) (int32, error) {
|
||||
row := q.db.QueryRowContext(ctx, getVote, arg.UserID, arg.QuestionID)
|
||||
var value int32
|
||||
err := row.Scan(&value)
|
||||
return value, err
|
||||
}
|
||||
|
||||
const questionIsVisible = `-- name: QuestionIsVisible :one
|
||||
SELECT EXISTS(
|
||||
SELECT 1 FROM questions WHERE id = $1 AND hidden = 0
|
||||
)::bool
|
||||
`
|
||||
|
||||
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
|
||||
QuestionID string
|
||||
Value int32
|
||||
}
|
||||
|
||||
func (q *Queries) UpsertVoteOnVisible(ctx context.Context, arg UpsertVoteOnVisibleParams) (int64, error) {
|
||||
result, err := q.db.ExecContext(ctx, upsertVoteOnVisible, arg.UserID, arg.QuestionID, arg.Value)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return result.RowsAffected()
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package store
|
||||
|
||||
import "context"
|
||||
|
||||
// List row caps keep hunt/profile/admin pages bounded.
|
||||
const (
|
||||
HuntListLimit = 100
|
||||
ProfileListLimit = 50
|
||||
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.
|
||||
type Store interface {
|
||||
CreateUser(ctx context.Context, u *User) error
|
||||
UserByID(ctx context.Context, id string) (*User, error)
|
||||
UserByUsername(ctx context.Context, username string) (*User, error)
|
||||
ListUsers(ctx context.Context, q ListUsersQuery) (users []User, nextCursorCreated, nextCursorID string, err error)
|
||||
CountAdmins(ctx context.Context) (int, error)
|
||||
SetUserRole(ctx context.Context, id string, role Role) error
|
||||
SaveUserProfile(ctx context.Context, u *User) error
|
||||
|
||||
CreateQuestion(ctx context.Context, q *RankedQuestion) error
|
||||
GetQuestion(ctx context.Context, id, viewerID string) (*RankedQuestion, error)
|
||||
ListHunt(ctx context.Context, huntDate, viewerID string) ([]RankedQuestion, error)
|
||||
ListQuestionsByAuthor(ctx context.Context, authorID string) ([]RankedQuestion, error)
|
||||
ListQuestionsAnsweredBy(ctx context.Context, adminID string) ([]RankedQuestion, error)
|
||||
HideQuestion(ctx context.Context, id string) error
|
||||
|
||||
GetAnswer(ctx context.Context, questionID string) (*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
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"plumber/internal/store/sqlc"
|
||||
)
|
||||
|
||||
// ErrLastAdmin is returned when demoting the only remaining admin.
|
||||
var ErrLastAdmin = errors.New("cannot demote the last admin")
|
||||
|
||||
// Role is a user privilege level stored in users.role.
|
||||
type Role string
|
||||
|
||||
const (
|
||||
RoleUser Role = "user"
|
||||
RoleAdmin Role = "admin"
|
||||
)
|
||||
|
||||
// User is an account row. Methods run SQL against db via sqlc.
|
||||
type User struct {
|
||||
ID string
|
||||
Username string
|
||||
Name string
|
||||
Role Role
|
||||
AvatarURL string
|
||||
State string
|
||||
CreatedAt string
|
||||
PasswordHash string
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
// NewUser returns a User bound to db (not yet inserted).
|
||||
func NewUser(db *sql.DB) *User {
|
||||
return &User{db: db}
|
||||
}
|
||||
|
||||
func (u *User) Admin() bool {
|
||||
return u != nil && u.Role == RoleAdmin
|
||||
}
|
||||
|
||||
func NormalizeUsername(s string) string {
|
||||
return strings.ToLower(strings.TrimSpace(s))
|
||||
}
|
||||
|
||||
func toUser(db *sql.DB, id, username, name, role, avatarURL, state, createdAt, passwordHash string) *User {
|
||||
return &User{
|
||||
ID: id,
|
||||
Username: username,
|
||||
Name: name,
|
||||
Role: Role(role),
|
||||
AvatarURL: avatarURL,
|
||||
State: state,
|
||||
CreatedAt: createdAt,
|
||||
PasswordHash: passwordHash,
|
||||
db: db,
|
||||
}
|
||||
}
|
||||
|
||||
// Create inserts the user. Sets ID, Name, and CreatedAt when empty.
|
||||
func (u *User) Create(ctx context.Context) error {
|
||||
if u == nil || u.db == nil {
|
||||
return fmt.Errorf("user: no database")
|
||||
}
|
||||
if u.Role != RoleUser && u.Role != RoleAdmin {
|
||||
return fmt.Errorf("invalid role")
|
||||
}
|
||||
u.Username = NormalizeUsername(u.Username)
|
||||
if u.ID == "" {
|
||||
u.ID = uuid.NewString()
|
||||
}
|
||||
if u.Name == "" {
|
||||
u.Name = u.Username
|
||||
}
|
||||
if u.CreatedAt == "" {
|
||||
u.CreatedAt = time.Now().UTC().Format(time.RFC3339)
|
||||
}
|
||||
return mapUniqueViolation(sqlc.New(u.db).CreateUser(ctx, sqlc.CreateUserParams{
|
||||
ID: u.ID,
|
||||
Username: u.Username,
|
||||
Name: u.Name,
|
||||
PasswordHash: u.PasswordHash,
|
||||
Role: string(u.Role),
|
||||
CreatedAt: u.CreatedAt,
|
||||
}))
|
||||
}
|
||||
|
||||
// adminRoleLockKey serializes SetRole so concurrent demotions cannot bypass the
|
||||
// last-admin guard under READ COMMITTED.
|
||||
const adminRoleLockKey int64 = 0x706c756d5f61646d // "plum_adm"
|
||||
|
||||
// SetRole updates this user's role (last-admin safe).
|
||||
func (u *User) SetRole(ctx context.Context, role Role) error {
|
||||
if u == nil || u.db == nil {
|
||||
return fmt.Errorf("user: no database")
|
||||
}
|
||||
if role != RoleUser && role != RoleAdmin {
|
||||
return fmt.Errorf("invalid role")
|
||||
}
|
||||
tx, err := u.db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
if _, err := tx.ExecContext(ctx, `SELECT pg_advisory_xact_lock($1)`, adminRoleLockKey); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
q := sqlc.New(tx)
|
||||
current, err := q.GetUserRole(ctx, u.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if Role(current) == RoleAdmin && role == RoleUser {
|
||||
n, err := q.CountAdmins(ctx, string(RoleAdmin))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n <= 1 {
|
||||
return ErrLastAdmin
|
||||
}
|
||||
}
|
||||
res, err := q.UpdateUserRole(ctx, sqlc.UpdateUserRoleParams{
|
||||
Role: string(role),
|
||||
ID: u.ID,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
aff, err := res.RowsAffected()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if aff == 0 {
|
||||
return sql.ErrNoRows
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return err
|
||||
}
|
||||
u.Role = role
|
||||
return nil
|
||||
}
|
||||
|
||||
// SaveProfile writes State and optionally AvatarURL.
|
||||
func (u *User) SaveProfile(ctx context.Context) error {
|
||||
if u == nil || u.db == nil {
|
||||
return fmt.Errorf("user: no database")
|
||||
}
|
||||
u.State = strings.TrimSpace(u.State)
|
||||
q := sqlc.New(u.db)
|
||||
if u.AvatarURL == "" {
|
||||
return q.UpdateUserState(ctx, sqlc.UpdateUserStateParams{State: u.State, ID: u.ID})
|
||||
}
|
||||
return q.UpdateUserStateAndAvatar(ctx, sqlc.UpdateUserStateAndAvatarParams{
|
||||
State: u.State,
|
||||
AvatarUrl: u.AvatarURL,
|
||||
ID: u.ID,
|
||||
})
|
||||
}
|
||||
|
||||
func CountAdmins(ctx context.Context, db *sql.DB) (int, error) {
|
||||
n, err := sqlc.New(db).CountAdmins(ctx, string(RoleAdmin))
|
||||
return int(n), err
|
||||
}
|
||||
|
||||
func ListUsers(ctx context.Context, db *sql.DB, q ListUsersQuery) ([]User, string, string, error) {
|
||||
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 {
|
||||
return nil, "", "", err
|
||||
}
|
||||
out := make([]User, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
u := toUser(db, r.ID, r.Username, r.Name, r.Role, r.AvatarUrl, r.State, r.CreatedAt, "")
|
||||
out = append(out, *u)
|
||||
}
|
||||
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) {
|
||||
r, err := sqlc.New(db).GetUserByID(ctx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return toUser(db, r.ID, r.Username, r.Name, r.Role, r.AvatarUrl, r.State, r.CreatedAt, ""), nil
|
||||
}
|
||||
|
||||
func UserByUsername(ctx context.Context, db *sql.DB, username string) (*User, error) {
|
||||
r, err := sqlc.New(db).GetUserByUsername(ctx, NormalizeUsername(username))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return toUser(db, r.ID, r.Username, r.Name, r.Role, r.AvatarUrl, r.State, r.CreatedAt, r.PasswordHash), nil
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
|
||||
"plumber/internal/store/sqlc"
|
||||
)
|
||||
|
||||
// ErrDuplicateUsername is returned when inserting a username that already exists.
|
||||
var ErrDuplicateUsername = errors.New("username taken")
|
||||
|
||||
// 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")
|
||||
}
|
||||
q := sqlc.New(db)
|
||||
if value == 0 {
|
||||
visible, err := q.QuestionIsVisible(ctx, questionID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !visible {
|
||||
return ErrHiddenOrMissing
|
||||
}
|
||||
return q.DeleteVote(ctx, sqlc.DeleteVoteParams{
|
||||
UserID: userID,
|
||||
QuestionID: questionID,
|
||||
})
|
||||
}
|
||||
n, err := q.UpsertVoteOnVisible(ctx, sqlc.UpsertVoteOnVisibleParams{
|
||||
UserID: userID,
|
||||
QuestionID: questionID,
|
||||
Value: int32(value),
|
||||
})
|
||||
if err != nil {
|
||||
return mapUniqueViolation(err)
|
||||
}
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"plumber/internal/store"
|
||||
)
|
||||
|
||||
type adminUsersPage struct {
|
||||
page
|
||||
Users []store.User
|
||||
Error string
|
||||
Search string
|
||||
NextCursor string
|
||||
HasMore bool
|
||||
}
|
||||
|
||||
func (s *Server) requireAdmin(w http.ResponseWriter, r *http.Request) *store.User {
|
||||
u := currentUser(r)
|
||||
if !u.Admin() {
|
||||
http.Error(w, "forbidden", http.StatusForbidden)
|
||||
return nil
|
||||
}
|
||||
return u
|
||||
}
|
||||
|
||||
func (s *Server) handleAdminUsers(w http.ResponseWriter, r *http.Request) {
|
||||
if s.requireAdmin(w, r) == nil {
|
||||
return
|
||||
}
|
||||
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 {
|
||||
http.Error(w, "could not load users", http.StatusInternalServerError)
|
||||
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{
|
||||
page: s.basePage(r, "Users"),
|
||||
Users: users,
|
||||
Search: search,
|
||||
NextCursor: next,
|
||||
HasMore: next != "",
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleAdminSetRole(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.requireCSRF(w, r) {
|
||||
return
|
||||
}
|
||||
if s.requireAdmin(w, r) == nil {
|
||||
return
|
||||
}
|
||||
id := chi.URLParam(r, "id")
|
||||
role := store.Role(r.PostFormValue("role"))
|
||||
err := s.store.SetUserRole(r.Context(), id, role)
|
||||
if errors.Is(err, store.ErrLastAdmin) {
|
||||
users, _, _, listErr := s.store.ListUsers(r.Context(), store.ListUsersQuery{Limit: store.AdminUsersLimit})
|
||||
if listErr != nil {
|
||||
http.Error(w, "could not demote last admin", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
s.exec(w, "admin-users", adminUsersPage{
|
||||
page: s.basePage(r, "Users"),
|
||||
Users: users,
|
||||
Error: "Cannot demote the last admin.",
|
||||
})
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
http.Error(w, "could not update role", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/admin/users", http.StatusSeeOther)
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"crypto/subtle"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
|
||||
"plumber/internal/store"
|
||||
)
|
||||
|
||||
var usernameRe = regexp.MustCompile(`^[a-zA-Z0-9_]{3,20}$`)
|
||||
|
||||
const (
|
||||
minPasswordRunes = 8
|
||||
maxPasswordBytes = 72 // bcrypt truncation limit
|
||||
)
|
||||
|
||||
// loginDummyHash is compared when the username is unknown so login timing
|
||||
// does not reveal whether an account exists (same bcrypt cost as real hashes).
|
||||
var loginDummyHash = mustBcrypt("timing-dummy-not-a-real-password")
|
||||
|
||||
func mustBcrypt(s string) []byte {
|
||||
h, err := bcrypt.GenerateFromPassword([]byte(s), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
func safeNext(raw string) string {
|
||||
if raw == "" {
|
||||
return "/"
|
||||
}
|
||||
u, err := url.Parse(raw)
|
||||
if err != nil || u.IsAbs() || !strings.HasPrefix(u.Path, "/") || strings.HasPrefix(u.Path, "//") {
|
||||
return "/"
|
||||
}
|
||||
return u.RequestURI()
|
||||
}
|
||||
|
||||
func passwordValid(password string) (ok bool, msg string) {
|
||||
if utf8.RuneCountInString(password) < minPasswordRunes {
|
||||
return false, "Password must be at least 8 characters."
|
||||
}
|
||||
if len(password) > maxPasswordBytes {
|
||||
return false, "Password must be at most 72 bytes."
|
||||
}
|
||||
return true, ""
|
||||
}
|
||||
|
||||
func (s *Server) handleLoginForm(w http.ResponseWriter, r *http.Request) {
|
||||
if currentUser(r) != nil {
|
||||
http.Redirect(w, r, safeNext(r.URL.Query().Get("next")), http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
s.exec(w, "login", authPage{
|
||||
page: s.basePage(r, "Sign in"),
|
||||
Next: r.URL.Query().Get("next"),
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.requireCSRF(w, r) {
|
||||
return
|
||||
}
|
||||
username := strings.TrimSpace(r.PostFormValue("username"))
|
||||
password := r.PostFormValue("password")
|
||||
next := safeNext(r.PostFormValue("next"))
|
||||
userKey := store.NormalizeUsername(username)
|
||||
ip := s.clientIP(r)
|
||||
if !s.allowLoginAttempt(w, r, userKey) {
|
||||
return
|
||||
}
|
||||
|
||||
u, err := s.store.UserByUsername(r.Context(), username)
|
||||
hash := loginDummyHash
|
||||
switch {
|
||||
case err == nil:
|
||||
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 {
|
||||
s.loginFail.record(loginFailKey(ip, userKey))
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
s.exec(w, "login", authPage{
|
||||
page: s.basePage(r, "Sign in"),
|
||||
Username: username,
|
||||
Next: next,
|
||||
Error: "Wrong username or password.",
|
||||
})
|
||||
return
|
||||
}
|
||||
s.loginFail.clear(loginFailKey(ip, userKey))
|
||||
if err := s.sessions.RenewToken(r.Context()); err != nil {
|
||||
http.Error(w, "could not start session", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
s.sessions.Put(r.Context(), "user_id", u.ID)
|
||||
http.Redirect(w, r, next, http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (s *Server) handleRegisterForm(w http.ResponseWriter, r *http.Request) {
|
||||
if currentUser(r) != nil {
|
||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
s.exec(w, "register", authPage{page: s.basePage(r, "Create account")})
|
||||
}
|
||||
|
||||
func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.requireCSRF(w, r) {
|
||||
return
|
||||
}
|
||||
if !s.allowRegisterAttempt(w, r) {
|
||||
return
|
||||
}
|
||||
username := strings.TrimSpace(r.PostFormValue("username"))
|
||||
password := r.PostFormValue("password")
|
||||
setupSecret := r.PostFormValue("setup_secret")
|
||||
p := authPage{page: s.basePage(r, "Create account"), Username: username}
|
||||
if !usernameRe.MatchString(username) {
|
||||
p.Error = "Username must be 3–20 letters, numbers, or underscores."
|
||||
s.exec(w, "register", p)
|
||||
return
|
||||
}
|
||||
if ok, msg := passwordValid(password); !ok {
|
||||
p.Error = msg
|
||||
s.exec(w, "register", p)
|
||||
return
|
||||
}
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
http.Error(w, "could not save password", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
role := store.RoleUser
|
||||
if setupSecretMatches(s.cfg.AdminSetupSecret, setupSecret) {
|
||||
role = store.RoleAdmin
|
||||
}
|
||||
u := &store.User{
|
||||
Username: username,
|
||||
PasswordHash: string(hash),
|
||||
Role: role,
|
||||
}
|
||||
if err := s.store.CreateUser(r.Context(), u); err != nil {
|
||||
if errors.Is(err, store.ErrDuplicateUsername) {
|
||||
p.Error = "That username is taken."
|
||||
s.exec(w, "register", p)
|
||||
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 {
|
||||
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
|
||||
}
|
||||
s.sessions.Put(r.Context(), "user_id", u.ID)
|
||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func setupSecretMatches(want, provided string) bool {
|
||||
if want == "" || provided == "" {
|
||||
return false
|
||||
}
|
||||
return subtle.ConstantTimeCompare([]byte(provided), []byte(want)) == 1
|
||||
}
|
||||
|
||||
func (s *Server) handleAuthPrompt(w http.ResponseWriter, r *http.Request) {
|
||||
s.exec(w, "signin-prompt", s.basePage(r, ""))
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestPasswordMaxBytes(t *testing.T) {
|
||||
if ok, _ := passwordValid(strings.Repeat("a", 8)); !ok {
|
||||
t.Fatal("8 ascii runes should pass")
|
||||
}
|
||||
if ok, msg := passwordValid(strings.Repeat("a", 73)); ok || !strings.Contains(msg, "72") {
|
||||
t.Fatalf("73 bytes should fail: ok=%v msg=%q", ok, msg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLogoutDestroysSession(t *testing.T) {
|
||||
srv, _ := newTestServer(t, Config{})
|
||||
h := srv.Handler()
|
||||
name := uniq("out")
|
||||
cookies := registerUser(t, h, name, "hunter22")
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/profile", nil)
|
||||
for _, c := range cookies {
|
||||
req.AddCookie(c)
|
||||
}
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != 200 {
|
||||
t.Fatalf("profile before logout %d", rec.Code)
|
||||
}
|
||||
csrf := csrfFrom(rec.Body.String())
|
||||
form := strings.NewReader("_csrf=" + csrf)
|
||||
req = httptest.NewRequest(http.MethodPost, "/logout", form)
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
for _, c := range cookies {
|
||||
req.AddCookie(c)
|
||||
}
|
||||
rec = httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusSeeOther {
|
||||
t.Fatalf("logout %d", rec.Code)
|
||||
}
|
||||
postLogout := mergeCookies(cookies, rec.Result().Cookies())
|
||||
|
||||
req = httptest.NewRequest(http.MethodGet, "/profile", nil)
|
||||
for _, c := range postLogout {
|
||||
req.AddCookie(c)
|
||||
}
|
||||
rec = httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusSeeOther {
|
||||
t.Fatalf("profile after logout should redirect, got %d", rec.Code)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/binary"
|
||||
"hash/crc32"
|
||||
"image"
|
||||
"image/jpeg"
|
||||
"image/png"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestTruncateRunes(t *testing.T) {
|
||||
tests := []struct {
|
||||
in string
|
||||
max int
|
||||
want string
|
||||
}{
|
||||
{"abc", 10, "abc"},
|
||||
{"abcdef", 3, "abc"},
|
||||
{"héllo", 3, "hél"},
|
||||
{"🙂🙂🙂", 2, "🙂🙂"},
|
||||
{"世界和平", 2, "世界"},
|
||||
{"abc", 0, ""},
|
||||
{"abc", -1, ""},
|
||||
{"", 5, ""},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
if got := truncateRunes(tc.in, tc.max); got != tc.want {
|
||||
t.Fatalf("truncateRunes(%q, %d)=%q want %q", tc.in, tc.max, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrepareAvatar(t *testing.T) {
|
||||
var pngBuf bytes.Buffer
|
||||
if err := png.Encode(&pngBuf, image.NewRGBA(image.Rect(0, 0, 2, 2))); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var jpegBuf bytes.Buffer
|
||||
if err := jpeg.Encode(&jpegBuf, image.NewRGBA(image.Rect(0, 0, 2, 2)), &jpeg.Options{Quality: 90}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var largePNG bytes.Buffer
|
||||
if err := png.Encode(&largePNG, image.NewRGBA(image.Rect(0, 0, 800, 600))); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
oversized := bytes.Repeat([]byte{0x89}, (2<<20)+2)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
in []byte
|
||||
max int64
|
||||
wantExt string
|
||||
wantErr string
|
||||
}{
|
||||
{name: "png", in: pngBuf.Bytes(), max: 2 << 20, wantExt: ".png"},
|
||||
{name: "jpeg", in: jpegBuf.Bytes(), max: 2 << 20, wantExt: ".jpg"},
|
||||
{name: "resize large", in: largePNG.Bytes(), max: 2 << 20, wantExt: ".png"},
|
||||
{name: "empty", in: nil, max: 2 << 20, wantErr: "empty"},
|
||||
{name: "invalid", in: []byte("not-an-image"), max: 2 << 20, wantErr: "unsupported"},
|
||||
{name: "oversized", in: oversized, max: 2 << 20, wantErr: "too large"},
|
||||
{name: "huge dims", in: pngWithDims(100000, 100000), max: 2 << 20, wantErr: "dimensions"},
|
||||
{name: "over decode cap", in: pngWithDims(2048, 2048), max: 2 << 20, wantErr: "dimensions"},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
body, ext, ct, err := prepareAvatar(bytes.NewReader(tc.in), tc.max)
|
||||
if tc.wantErr != "" {
|
||||
if err == nil || !strings.Contains(err.Error(), tc.wantErr) {
|
||||
t.Fatalf("err=%v want substring %q", err, tc.wantErr)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if ext != tc.wantExt {
|
||||
t.Fatalf("ext=%q want %q", ext, tc.wantExt)
|
||||
}
|
||||
if len(body) == 0 || ct == "" {
|
||||
t.Fatalf("empty output body/ct")
|
||||
}
|
||||
if int64(len(body)) > tc.max {
|
||||
t.Fatalf("encoded size %d exceeds max %d", len(body), tc.max)
|
||||
}
|
||||
cfg, _, err := image.DecodeConfig(bytes.NewReader(body))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cfg.Width > 512 || cfg.Height > 512 {
|
||||
t.Fatalf("avatar dims %dx%d exceed 512", cfg.Width, cfg.Height)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func pngWithDims(w, h int) []byte {
|
||||
var buf bytes.Buffer
|
||||
buf.Write([]byte{0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a})
|
||||
var ihdr bytes.Buffer
|
||||
_ = binary.Write(&ihdr, binary.BigEndian, uint32(w))
|
||||
_ = binary.Write(&ihdr, binary.BigEndian, uint32(h))
|
||||
ihdr.Write([]byte{8, 2, 0, 0, 0}) // bit depth, color type, compression, filter, interlace
|
||||
writePNGChunk(&buf, "IHDR", ihdr.Bytes())
|
||||
writePNGChunk(&buf, "IDAT", []byte{0x08, 0xd7, 0x63, 0xf8, 0xcf, 0xc0, 0x00, 0x00, 0x00, 0x03, 0x00, 0x01})
|
||||
writePNGChunk(&buf, "IEND", nil)
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
func writePNGChunk(buf *bytes.Buffer, name string, data []byte) {
|
||||
_ = binary.Write(buf, binary.BigEndian, uint32(len(data)))
|
||||
buf.WriteString(name)
|
||||
buf.Write(data)
|
||||
crc := crc32.NewIEEE()
|
||||
_, _ = crc.Write([]byte(name))
|
||||
_, _ = crc.Write(data)
|
||||
_ = binary.Write(buf, binary.BigEndian, crc.Sum32())
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"image"
|
||||
"image/jpeg"
|
||||
"image/png"
|
||||
"io"
|
||||
"net/http"
|
||||
"path"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/image/draw"
|
||||
_ "golang.org/x/image/webp"
|
||||
|
||||
"plumber/internal/blob"
|
||||
"plumber/internal/geo"
|
||||
"plumber/internal/store"
|
||||
)
|
||||
|
||||
type profilePage struct {
|
||||
page
|
||||
States []struct{ Code, Name string }
|
||||
Questions []store.RankedQuestion
|
||||
QuestionsLabel string
|
||||
UploadsEnabled bool
|
||||
Error string
|
||||
StateVal string
|
||||
}
|
||||
|
||||
func (s *Server) handleProfileForm(w http.ResponseWriter, r *http.Request) {
|
||||
u := currentUser(r)
|
||||
if u == nil {
|
||||
http.Redirect(w, r, "/login?next=/profile", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
s.renderProfile(w, r, u, "", u.State)
|
||||
}
|
||||
|
||||
func (s *Server) handleProfile(w http.ResponseWriter, r *http.Request) {
|
||||
u := currentUser(r)
|
||||
if u == nil {
|
||||
http.Redirect(w, r, "/login?next=/profile", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
if err := r.ParseMultipartForm(3 << 20); err != nil {
|
||||
s.renderProfile(w, r, u, "Could not read form (max 2MB for images).", u.State)
|
||||
return
|
||||
}
|
||||
want := s.sessions.GetString(r.Context(), "csrf")
|
||||
got := r.FormValue("_csrf")
|
||||
if want == "" || got != want {
|
||||
http.Error(w, "invalid csrf token", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
|
||||
state := geo.NormalizeState(r.FormValue("state"))
|
||||
if !geo.ValidState(state) {
|
||||
s.renderProfile(w, r, u, "Choose a valid US state or leave it blank.", state)
|
||||
return
|
||||
}
|
||||
|
||||
avatarURL := ""
|
||||
avatarKey := ""
|
||||
file, hdr, err := r.FormFile("avatar")
|
||||
if err == nil {
|
||||
defer file.Close()
|
||||
if !s.cfg.Blob.Enabled() {
|
||||
s.renderProfile(w, r, u, "Avatar uploads are not configured on this server.", state)
|
||||
return
|
||||
}
|
||||
if hdr.Size > 2<<20 {
|
||||
s.renderProfile(w, r, u, "Avatar must be 2MB or smaller.", state)
|
||||
return
|
||||
}
|
||||
body, ext, contentType, prepErr := prepareAvatar(file, 2<<20)
|
||||
if prepErr != nil {
|
||||
s.renderProfile(w, r, u, "Avatar must be a JPEG, PNG, or WebP image.", state)
|
||||
return
|
||||
}
|
||||
prevURL := u.AvatarURL
|
||||
avatarKey = path.Join("avatars", u.ID, "avatar"+ext)
|
||||
url, upErr := s.cfg.Blob.Upload(r.Context(), blob.FileUpload{
|
||||
Key: avatarKey,
|
||||
Body: bytes.NewReader(body),
|
||||
ContentType: contentType,
|
||||
Size: int64(len(body)),
|
||||
})
|
||||
if upErr != nil {
|
||||
s.renderProfile(w, r, u, "Could not upload avatar. Try again later.", state)
|
||||
return
|
||||
}
|
||||
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 {
|
||||
s.renderProfile(w, r, u, "Could not read avatar file.", state)
|
||||
return
|
||||
}
|
||||
|
||||
u.State = state
|
||||
if err := s.store.SaveUserProfile(r.Context(), u); err != nil {
|
||||
http.Error(w, "could not save profile", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
s.sessions.Put(r.Context(), "flash", "Profile saved.")
|
||||
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
|
||||
// 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) {
|
||||
limited := io.LimitReader(r, maxBytes+1)
|
||||
raw, err := io.ReadAll(limited)
|
||||
if err != nil {
|
||||
return nil, "", "", err
|
||||
}
|
||||
if int64(len(raw)) > maxBytes {
|
||||
return nil, "", "", fmt.Errorf("avatar too large")
|
||||
}
|
||||
if len(raw) == 0 {
|
||||
return nil, "", "", fmt.Errorf("empty avatar")
|
||||
}
|
||||
|
||||
sniff := http.DetectContentType(raw)
|
||||
switch {
|
||||
case strings.HasPrefix(sniff, "image/jpeg"),
|
||||
strings.HasPrefix(sniff, "image/png"),
|
||||
strings.HasPrefix(sniff, "image/webp"):
|
||||
default:
|
||||
return nil, "", "", fmt.Errorf("unsupported type %s", sniff)
|
||||
}
|
||||
|
||||
cfg, format, err := image.DecodeConfig(bytes.NewReader(raw))
|
||||
if err != nil {
|
||||
return nil, "", "", err
|
||||
}
|
||||
// Cap decoded size before allocating pixel buffers (~4 MiB RGBA at 1024²).
|
||||
const maxDecodeDim = 1024
|
||||
const maxPixels = maxDecodeDim * maxDecodeDim
|
||||
if cfg.Width <= 0 || cfg.Height <= 0 || cfg.Width > maxDecodeDim || cfg.Height > maxDecodeDim {
|
||||
return nil, "", "", fmt.Errorf("image dimensions out of range")
|
||||
}
|
||||
if int64(cfg.Width)*int64(cfg.Height) > maxPixels {
|
||||
return nil, "", "", fmt.Errorf("image too many pixels")
|
||||
}
|
||||
|
||||
img, decodedFormat, err := image.Decode(bytes.NewReader(raw))
|
||||
if err != nil {
|
||||
return nil, "", "", err
|
||||
}
|
||||
if format != "" {
|
||||
decodedFormat = format
|
||||
}
|
||||
|
||||
const maxAvatarDim = 512
|
||||
img = fitAvatar(img, maxAvatarDim)
|
||||
|
||||
var out bytes.Buffer
|
||||
switch decodedFormat {
|
||||
case "jpeg":
|
||||
if err := jpeg.Encode(&out, img, &jpeg.Options{Quality: 85}); err != nil {
|
||||
return nil, "", "", err
|
||||
}
|
||||
if int64(out.Len()) > maxBytes {
|
||||
return nil, "", "", fmt.Errorf("encoded avatar too large")
|
||||
}
|
||||
return out.Bytes(), ".jpg", "image/jpeg", nil
|
||||
case "png", "webp":
|
||||
if err := png.Encode(&out, img); err != nil {
|
||||
return nil, "", "", err
|
||||
}
|
||||
if int64(out.Len()) > maxBytes {
|
||||
// Fall back to JPEG when PNG balloons past the upload cap.
|
||||
out.Reset()
|
||||
if err := jpeg.Encode(&out, img, &jpeg.Options{Quality: 85}); err != nil {
|
||||
return nil, "", "", err
|
||||
}
|
||||
if int64(out.Len()) > maxBytes {
|
||||
return nil, "", "", fmt.Errorf("encoded avatar too large")
|
||||
}
|
||||
return out.Bytes(), ".jpg", "image/jpeg", nil
|
||||
}
|
||||
return out.Bytes(), ".png", "image/png", nil
|
||||
default:
|
||||
return nil, "", "", fmt.Errorf("unsupported format %s", decodedFormat)
|
||||
}
|
||||
}
|
||||
|
||||
// fitAvatar scales img down so both sides are at most maxDim.
|
||||
func fitAvatar(img image.Image, maxDim int) image.Image {
|
||||
b := img.Bounds()
|
||||
w, h := b.Dx(), b.Dy()
|
||||
if w <= maxDim && h <= maxDim {
|
||||
return img
|
||||
}
|
||||
scale := float64(maxDim) / float64(w)
|
||||
if float64(h)*scale > float64(maxDim) {
|
||||
scale = float64(maxDim) / float64(h)
|
||||
}
|
||||
nw := int(float64(w) * scale)
|
||||
nh := int(float64(h) * scale)
|
||||
if nw < 1 {
|
||||
nw = 1
|
||||
}
|
||||
if nh < 1 {
|
||||
nh = 1
|
||||
}
|
||||
dst := image.NewRGBA(image.Rect(0, 0, nw, nh))
|
||||
draw.ApproxBiLinear.Scale(dst, dst.Bounds(), img, b, draw.Over, nil)
|
||||
return dst
|
||||
}
|
||||
|
||||
func (s *Server) renderProfile(w http.ResponseWriter, r *http.Request, u *store.User, errMsg, stateVal string) {
|
||||
var (
|
||||
questions []store.RankedQuestion
|
||||
label string
|
||||
err error
|
||||
)
|
||||
if u.Admin() {
|
||||
label = "Questions you answered"
|
||||
questions, err = s.store.ListQuestionsAnsweredBy(r.Context(), u.ID)
|
||||
} else {
|
||||
label = "Your questions"
|
||||
questions, err = s.store.ListQuestionsByAuthor(r.Context(), u.ID)
|
||||
}
|
||||
if err != nil {
|
||||
http.Error(w, "could not load questions", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
p := s.basePage(r, "Profile")
|
||||
p.User = u
|
||||
s.exec(w, "profile", profilePage{
|
||||
page: p,
|
||||
States: geo.States,
|
||||
Questions: questions,
|
||||
QuestionsLabel: label,
|
||||
UploadsEnabled: s.cfg.Blob.Enabled(),
|
||||
Error: errMsg,
|
||||
StateVal: stateVal,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,577 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"io/fs"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/alexedwards/scs/v2"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/chi/v5/middleware"
|
||||
|
||||
"plumber/internal/blob"
|
||||
"plumber/internal/geo"
|
||||
"plumber/internal/pacific"
|
||||
"plumber/internal/store"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
// AdminSetupSecret, when set, can promote the first registrant who also
|
||||
// posts the matching setup_secret. It is ignored once any admin exists.
|
||||
AdminSetupSecret string
|
||||
SecureCookie bool
|
||||
// TrustedProxies are CIDRs allowed to set X-Forwarded-For (direct peer).
|
||||
TrustedProxies []*net.IPNet
|
||||
Blob blob.Uploader
|
||||
}
|
||||
|
||||
type Server struct {
|
||||
store store.Store
|
||||
sessions *scs.SessionManager
|
||||
tmpl *template.Template
|
||||
cfg Config
|
||||
static http.Handler
|
||||
loginIP *throttle
|
||||
registerIP *throttle
|
||||
loginFail *failureTracker
|
||||
}
|
||||
|
||||
type page struct {
|
||||
User *store.User
|
||||
CSRF string
|
||||
Flash string
|
||||
Title string
|
||||
Today string
|
||||
Yesterday string
|
||||
}
|
||||
|
||||
type huntPage struct {
|
||||
page
|
||||
Date string
|
||||
Label string
|
||||
IsToday bool
|
||||
IsYesterday bool
|
||||
Questions []store.RankedQuestion
|
||||
}
|
||||
|
||||
type questionPage struct {
|
||||
page
|
||||
Question *store.RankedQuestion
|
||||
Answer *store.Answer
|
||||
}
|
||||
|
||||
type submitPage struct {
|
||||
page
|
||||
TitleVal string
|
||||
BodyVal string
|
||||
CityVal string
|
||||
Error string
|
||||
}
|
||||
|
||||
type authPage struct {
|
||||
page
|
||||
Username string
|
||||
Error string
|
||||
Next string
|
||||
}
|
||||
|
||||
type voteCtx struct {
|
||||
User *store.User
|
||||
CSRF string
|
||||
View string
|
||||
Date string
|
||||
Question store.RankedQuestion
|
||||
}
|
||||
|
||||
func New(st store.Store, sessionStore scs.Store, templateFS fs.FS, staticFS fs.FS, cfg Config) (*Server, error) {
|
||||
if cfg.Blob == nil {
|
||||
cfg.Blob = blob.Disabled{}
|
||||
}
|
||||
funcMap := template.FuncMap{
|
||||
"voteCtx": func(user *store.User, csrf, view, date string, q store.RankedQuestion) voteCtx {
|
||||
return voteCtx{User: user, CSRF: csrf, View: view, Date: date, Question: q}
|
||||
},
|
||||
"add": func(a, b int) int { return a + b },
|
||||
"rank": func(i int) int { return i + 1 },
|
||||
"isAdmin": func(u *store.User) bool { return u.Admin() },
|
||||
"pacificLabel": pacific.Label,
|
||||
"locationTag": func(u *store.User) string {
|
||||
if u != nil {
|
||||
if name := geo.StateName(u.State); name != "" {
|
||||
return name
|
||||
}
|
||||
}
|
||||
return "Bay Area"
|
||||
},
|
||||
}
|
||||
tmpl, err := template.New("").Funcs(funcMap).ParseFS(templateFS, "templates/*.html", "templates/partials/*.html")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse templates: %w", err)
|
||||
}
|
||||
sessions := scs.New()
|
||||
sessions.Store = sessionStore
|
||||
sessions.Lifetime = 30 * 24 * time.Hour
|
||||
sessions.Cookie.Name = "plumber_session"
|
||||
sessions.Cookie.HttpOnly = true
|
||||
sessions.Cookie.SameSite = http.SameSiteLaxMode
|
||||
sessions.Cookie.Secure = cfg.SecureCookie
|
||||
sessions.Cookie.Path = "/"
|
||||
|
||||
sub, err := fs.Sub(staticFS, "static")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &Server{
|
||||
store: st,
|
||||
sessions: sessions,
|
||||
tmpl: tmpl,
|
||||
cfg: cfg,
|
||||
static: http.StripPrefix("/static/", http.FileServer(http.FS(sub))),
|
||||
loginIP: newThrottle(20, 15*time.Minute, defaultThrottleMaxKeys),
|
||||
registerIP: newThrottle(10, 15*time.Minute, defaultThrottleMaxKeys),
|
||||
loginFail: newFailureTracker(15*time.Minute, defaultThrottleMaxKeys),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *Server) Handler() http.Handler {
|
||||
r := chi.NewRouter()
|
||||
r.Use(middleware.RequestID)
|
||||
// Do not use middleware.RealIP: it rewrites RemoteAddr from client-controlled
|
||||
// forwarding headers before clientIP can validate the TCP peer against
|
||||
// TrustedProxies. clientIP walks X-Forwarded-For itself when the peer is trusted.
|
||||
r.Use(middleware.Logger)
|
||||
r.Use(middleware.Recoverer)
|
||||
r.Use(func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
r.Body = http.MaxBytesReader(w, r.Body, 3<<20)
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
})
|
||||
r.Use(s.sessions.LoadAndSave)
|
||||
r.Use(s.withUser)
|
||||
r.Handle("/static/*", s.static)
|
||||
r.Get("/", s.handleToday)
|
||||
r.Get("/archive", s.handleArchive)
|
||||
r.Get("/hunt/{date}", s.handleHunt)
|
||||
r.Get("/submit", s.handleSubmitForm)
|
||||
r.Post("/submit", s.handleSubmit)
|
||||
r.Get("/questions/{id}", s.handleQuestion)
|
||||
r.Post("/questions/{id}/vote", s.handleVote)
|
||||
r.Post("/questions/{id}/answer", s.handleAnswer)
|
||||
r.Post("/questions/{id}/hide", s.handleHide)
|
||||
r.Get("/login", s.handleLoginForm)
|
||||
r.Post("/login", s.handleLogin)
|
||||
r.Get("/register", s.handleRegisterForm)
|
||||
r.Post("/register", s.handleRegister)
|
||||
r.Get("/auth/prompt", s.handleAuthPrompt)
|
||||
r.Post("/logout", s.handleLogout)
|
||||
r.Get("/admin/users", s.handleAdminUsers)
|
||||
r.Post("/admin/users/{id}/role", s.handleAdminSetRole)
|
||||
r.Get("/profile", s.handleProfileForm)
|
||||
r.Post("/profile", s.handleProfile)
|
||||
return r
|
||||
}
|
||||
|
||||
type ctxKey int
|
||||
|
||||
const userKey ctxKey = 1
|
||||
|
||||
func (s *Server) withUser(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if s.sessions.GetString(r.Context(), "csrf") == "" {
|
||||
s.sessions.Put(r.Context(), "csrf", randomHex(16))
|
||||
}
|
||||
id := s.sessions.GetString(r.Context(), "user_id")
|
||||
if id != "" {
|
||||
u, err := s.store.UserByID(r.Context(), id)
|
||||
if err == nil {
|
||||
r = r.WithContext(context.WithValue(r.Context(), userKey, u))
|
||||
}
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func currentUser(r *http.Request) *store.User {
|
||||
u, _ := r.Context().Value(userKey).(*store.User)
|
||||
return u
|
||||
}
|
||||
|
||||
func (s *Server) basePage(r *http.Request, title string) page {
|
||||
return page{
|
||||
User: currentUser(r),
|
||||
CSRF: s.sessions.GetString(r.Context(), "csrf"),
|
||||
Flash: s.sessions.PopString(r.Context(), "flash"),
|
||||
Title: title,
|
||||
Today: pacific.Today(),
|
||||
Yesterday: pacific.Yesterday(),
|
||||
}
|
||||
}
|
||||
|
||||
func isHTMX(r *http.Request) bool {
|
||||
return r.Header.Get("HX-Request") == "true"
|
||||
}
|
||||
|
||||
func (s *Server) requireCSRF(w http.ResponseWriter, r *http.Request) bool {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Error(w, "bad form", http.StatusBadRequest)
|
||||
return false
|
||||
}
|
||||
want := s.sessions.GetString(r.Context(), "csrf")
|
||||
got := r.PostFormValue("_csrf")
|
||||
if want == "" || got != want {
|
||||
http.Error(w, "invalid csrf token", http.StatusForbidden)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (s *Server) handleToday(w http.ResponseWriter, r *http.Request) {
|
||||
s.renderHunt(w, r, pacific.Today())
|
||||
}
|
||||
|
||||
func (s *Server) handleArchive(w http.ResponseWriter, r *http.Request) {
|
||||
date := r.URL.Query().Get("date")
|
||||
if date == "" || date == pacific.Today() {
|
||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
if _, err := pacific.Parse(date); err != nil || date > pacific.Today() {
|
||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/hunt/"+url.PathEscape(date), http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (s *Server) handleHunt(w http.ResponseWriter, r *http.Request) {
|
||||
date := chi.URLParam(r, "date")
|
||||
if _, err := pacific.Parse(date); err != nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
if date >= pacific.Today() {
|
||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
s.renderHunt(w, r, date)
|
||||
}
|
||||
|
||||
func (s *Server) renderHunt(w http.ResponseWriter, r *http.Request, date string) {
|
||||
viewer := ""
|
||||
if u := currentUser(r); u != nil {
|
||||
viewer = u.ID
|
||||
}
|
||||
questions, err := s.store.ListHunt(r.Context(), date, viewer)
|
||||
if err != nil {
|
||||
http.Error(w, "could not load questions", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
label := pacific.Label(date)
|
||||
title := label
|
||||
if pacific.IsToday(date) {
|
||||
title = "Today"
|
||||
}
|
||||
s.exec(w, "hunt", huntPage{
|
||||
page: s.basePage(r, title),
|
||||
Date: date,
|
||||
Label: label,
|
||||
IsToday: pacific.IsToday(date),
|
||||
IsYesterday: pacific.IsYesterday(date),
|
||||
Questions: questions,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleSubmitForm(w http.ResponseWriter, r *http.Request) {
|
||||
if currentUser(r) == nil {
|
||||
s.sessions.Put(r.Context(), "flash", "Sign in to ask a question.")
|
||||
http.Redirect(w, r, "/login?next=/submit", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
s.exec(w, "submit", submitPage{page: s.basePage(r, "Ask a question")})
|
||||
}
|
||||
|
||||
func (s *Server) handleSubmit(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.requireCSRF(w, r) {
|
||||
return
|
||||
}
|
||||
u := currentUser(r)
|
||||
if u == nil {
|
||||
http.Redirect(w, r, "/login?next=/submit", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
title := strings.TrimSpace(r.PostFormValue("title"))
|
||||
body := strings.TrimSpace(r.PostFormValue("body"))
|
||||
city := strings.TrimSpace(r.PostFormValue("city"))
|
||||
if title == "" || body == "" {
|
||||
s.exec(w, "submit", submitPage{
|
||||
page: s.basePage(r, "Ask a question"),
|
||||
TitleVal: title,
|
||||
BodyVal: body,
|
||||
CityVal: city,
|
||||
Error: "Title and description are required.",
|
||||
})
|
||||
return
|
||||
}
|
||||
if len(title) > 120 {
|
||||
title = truncateRunes(title, 120)
|
||||
}
|
||||
if len(body) > 8000 {
|
||||
body = truncateRunes(body, 8000)
|
||||
}
|
||||
if len(city) > 80 {
|
||||
city = truncateRunes(city, 80)
|
||||
}
|
||||
q := &store.RankedQuestion{
|
||||
AuthorID: u.ID,
|
||||
Title: title,
|
||||
Body: body,
|
||||
City: city,
|
||||
}
|
||||
if err := s.store.CreateQuestion(r.Context(), q); err != nil {
|
||||
http.Error(w, "could not save question", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/questions/"+url.PathEscape(q.ID), http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (s *Server) handleQuestion(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
viewer := ""
|
||||
if u := currentUser(r); u != nil {
|
||||
viewer = u.ID
|
||||
}
|
||||
q, err := s.store.GetQuestion(r.Context(), id, viewer)
|
||||
if err != nil || (q.Hidden && !currentUser(r).Admin()) {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
var ans *store.Answer
|
||||
if q.Answered {
|
||||
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{
|
||||
page: s.basePage(r, q.Title),
|
||||
Question: q,
|
||||
Answer: ans,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleVote(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.requireCSRF(w, r) {
|
||||
return
|
||||
}
|
||||
u := currentUser(r)
|
||||
if u == nil {
|
||||
if isHTMX(r) {
|
||||
s.exec(w, "signin-prompt", s.basePage(r, ""))
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
id := chi.URLParam(r, "id")
|
||||
value := 0
|
||||
switch r.PostFormValue("value") {
|
||||
case "1":
|
||||
value = 1
|
||||
case "-1":
|
||||
value = -1
|
||||
case "0":
|
||||
value = 0
|
||||
default:
|
||||
http.Error(w, "invalid vote", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
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)
|
||||
return
|
||||
}
|
||||
view := r.PostFormValue("view")
|
||||
date := r.PostFormValue("date")
|
||||
if isHTMX(r) {
|
||||
if view == "list" {
|
||||
s.renderLeaderboard(w, r, date)
|
||||
return
|
||||
}
|
||||
q, err := s.store.GetQuestion(r.Context(), id, u.ID)
|
||||
if err != nil {
|
||||
http.Error(w, "not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
s.exec(w, "vote", voteCtx{
|
||||
User: u,
|
||||
CSRF: s.sessions.GetString(r.Context(), "csrf"),
|
||||
View: "question",
|
||||
Date: q.HuntDate,
|
||||
Question: *q,
|
||||
})
|
||||
return
|
||||
}
|
||||
if view == "question" {
|
||||
http.Redirect(w, r, "/questions/"+url.PathEscape(id), http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
if date != "" && date != pacific.Today() {
|
||||
http.Redirect(w, r, "/hunt/"+url.PathEscape(date), http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (s *Server) renderLeaderboard(w http.ResponseWriter, r *http.Request, date string) {
|
||||
if date == "" {
|
||||
date = pacific.Today()
|
||||
}
|
||||
viewer := ""
|
||||
if u := currentUser(r); u != nil {
|
||||
viewer = u.ID
|
||||
}
|
||||
questions, err := s.store.ListHunt(r.Context(), date, viewer)
|
||||
if err != nil {
|
||||
http.Error(w, "could not load questions", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
s.exec(w, "leaderboard", huntPage{
|
||||
page: s.basePage(r, ""),
|
||||
Date: date,
|
||||
Questions: questions,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleAnswer(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.requireCSRF(w, r) {
|
||||
return
|
||||
}
|
||||
u := currentUser(r)
|
||||
if !u.Admin() {
|
||||
http.Error(w, "forbidden", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
id := chi.URLParam(r, "id")
|
||||
body := strings.TrimSpace(r.PostFormValue("body"))
|
||||
if body == "" {
|
||||
http.Error(w, "answer required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if len(body) > 12000 {
|
||||
body = truncateRunes(body, 12000)
|
||||
}
|
||||
ans := &store.Answer{
|
||||
QuestionID: id,
|
||||
AuthorID: u.ID,
|
||||
Body: body,
|
||||
}
|
||||
if err := s.store.UpsertAnswer(r.Context(), ans); err != nil {
|
||||
http.Error(w, "could not save answer", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
saved, err := s.store.GetAnswer(r.Context(), id)
|
||||
if err != nil {
|
||||
http.Error(w, "could not load answer", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if isHTMX(r) {
|
||||
s.exec(w, "answer", questionPage{page: s.basePage(r, ""), Answer: saved})
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/questions/"+url.PathEscape(id), http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (s *Server) handleHide(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.requireCSRF(w, r) {
|
||||
return
|
||||
}
|
||||
u := currentUser(r)
|
||||
if !u.Admin() {
|
||||
http.Error(w, "forbidden", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
id := chi.URLParam(r, "id")
|
||||
q, err := s.store.GetQuestion(r.Context(), id, u.ID)
|
||||
if err != nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
if err := s.store.HideQuestion(r.Context(), id); err != nil {
|
||||
http.Error(w, "could not hide", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if isHTMX(r) && r.PostFormValue("view") == "list" {
|
||||
s.renderLeaderboard(w, r, q.HuntDate)
|
||||
return
|
||||
}
|
||||
if isHTMX(r) {
|
||||
w.Header().Set("HX-Redirect", "/")
|
||||
w.WriteHeader(http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.requireCSRF(w, r) {
|
||||
return
|
||||
}
|
||||
if err := s.sessions.Destroy(r.Context()); err != nil {
|
||||
http.Error(w, "could not sign out", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (s *Server) exec(w http.ResponseWriter, name string, data any) {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
if err := s.tmpl.ExecuteTemplate(w, name, data); err != nil {
|
||||
log.Printf("template %s: %v", name, err)
|
||||
http.Error(w, "template error", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
// truncateRunes shortens s to at most max runes without splitting a code point.
|
||||
func truncateRunes(s string, max int) string {
|
||||
if max <= 0 {
|
||||
return ""
|
||||
}
|
||||
n := 0
|
||||
for byteIdx := range s {
|
||||
if n == max {
|
||||
return s[:byteIdx]
|
||||
}
|
||||
n++
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func randomHex(n int) string {
|
||||
b := make([]byte, n)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
@@ -0,0 +1,716 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"image"
|
||||
"image/png"
|
||||
"mime/multipart"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/alexedwards/scs/v2/memstore"
|
||||
"github.com/google/uuid"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
|
||||
"plumber"
|
||||
"plumber/internal/blob"
|
||||
"plumber/internal/pacific"
|
||||
"plumber/internal/store"
|
||||
)
|
||||
|
||||
func newTestServer(t *testing.T, cfg Config) (*Server, *store.Memory) {
|
||||
t.Helper()
|
||||
mem := store.NewMemory()
|
||||
return newTestServerStore(t, mem, cfg), mem
|
||||
}
|
||||
|
||||
func newTestServerStore(t *testing.T, st store.Store, cfg Config) *Server {
|
||||
t.Helper()
|
||||
if cfg.Blob == nil {
|
||||
cfg.Blob = blob.Disabled{}
|
||||
}
|
||||
srv, err := New(st, memstore.New(), plumber.TemplateFS, plumber.StaticFS, cfg)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return srv
|
||||
}
|
||||
|
||||
func uniq(prefix string) string {
|
||||
return prefix + "_" + strings.ReplaceAll(uuid.NewString()[:8], "-", "")
|
||||
}
|
||||
|
||||
func seedUser(t *testing.T, st store.Store, username, password string, role store.Role) *store.User {
|
||||
t.Helper()
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.MinCost)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
u := &store.User{
|
||||
Username: username,
|
||||
PasswordHash: string(hash),
|
||||
Role: role,
|
||||
}
|
||||
if err := st.CreateUser(context.Background(), u); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return u
|
||||
}
|
||||
|
||||
func sessionValue(cookies []*http.Cookie) string {
|
||||
for _, c := range cookies {
|
||||
if c.Name == "plumber_session" {
|
||||
return c.Value
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func loginUser(t *testing.T, h http.Handler, username, password string) []*http.Cookie {
|
||||
t.Helper()
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/login", nil))
|
||||
pre := rec.Result().Cookies()
|
||||
preToken := sessionValue(pre)
|
||||
csrf := csrfFrom(rec.Body.String())
|
||||
form := strings.NewReader("_csrf=" + csrf + "&username=" + username + "&password=" + password)
|
||||
req := httptest.NewRequest(http.MethodPost, "/login", form)
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
for _, c := range pre {
|
||||
req.AddCookie(c)
|
||||
}
|
||||
rec = httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusSeeOther {
|
||||
t.Fatalf("login %s: %d %s", username, rec.Code, rec.Body.String())
|
||||
}
|
||||
post := mergeCookies(pre, rec.Result().Cookies())
|
||||
postToken := sessionValue(post)
|
||||
if preToken == "" || postToken == "" || preToken == postToken {
|
||||
t.Fatalf("expected session token rotation on login; pre=%q post=%q", preToken, postToken)
|
||||
}
|
||||
// Old anonymous token must not unlock authenticated routes.
|
||||
req = httptest.NewRequest(http.MethodGet, "/profile", nil)
|
||||
for _, c := range pre {
|
||||
req.AddCookie(c)
|
||||
}
|
||||
rec = httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusSeeOther {
|
||||
t.Fatalf("pre-auth cookie should not access profile, got %d", rec.Code)
|
||||
}
|
||||
return post
|
||||
}
|
||||
|
||||
func registerUser(t *testing.T, h http.Handler, username, password string, setupSecret ...string) []*http.Cookie {
|
||||
t.Helper()
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/register", nil))
|
||||
pre := rec.Result().Cookies()
|
||||
preToken := sessionValue(pre)
|
||||
csrf := csrfFrom(rec.Body.String())
|
||||
form := "_csrf=" + csrf + "&username=" + username + "&password=" + password
|
||||
if len(setupSecret) > 0 && setupSecret[0] != "" {
|
||||
form += "&setup_secret=" + setupSecret[0]
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodPost, "/register", strings.NewReader(form))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
for _, c := range pre {
|
||||
req.AddCookie(c)
|
||||
}
|
||||
rec = httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusSeeOther {
|
||||
t.Fatalf("register %s: %d %s", username, rec.Code, rec.Body.String())
|
||||
}
|
||||
post := mergeCookies(pre, rec.Result().Cookies())
|
||||
postToken := sessionValue(post)
|
||||
if preToken == "" || postToken == "" || preToken == postToken {
|
||||
t.Fatalf("expected session token rotation on register; pre=%q post=%q", preToken, postToken)
|
||||
}
|
||||
return post
|
||||
}
|
||||
|
||||
func TestHomeEmptyAndViewport(t *testing.T) {
|
||||
srv, _ := newTestServer(t, Config{})
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
srv.Handler().ServeHTTP(rec, req)
|
||||
if rec.Code != 200 {
|
||||
t.Fatalf("status %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
body := rec.Body.String()
|
||||
if !strings.Contains(body, "width=device-width") {
|
||||
t.Fatal("missing mobile viewport")
|
||||
}
|
||||
if !strings.Contains(body, "not a substitute for a licensed plumber") {
|
||||
t.Fatal("missing disclaimer")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterLoginAsk(t *testing.T) {
|
||||
srv, _ := newTestServer(t, Config{})
|
||||
h := srv.Handler()
|
||||
name := uniq("ask")
|
||||
session := registerUser(t, h, name, "hunter22")
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/submit", nil)
|
||||
for _, c := range session {
|
||||
req.AddCookie(c)
|
||||
}
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != 200 {
|
||||
t.Fatalf("submit form %d", rec.Code)
|
||||
}
|
||||
csrf := csrfFrom(rec.Body.String())
|
||||
form := strings.NewReader("_csrf=" + csrf + "&title=Leaky+faucet&body=Drip+all+night.&city=Oakland")
|
||||
req = httptest.NewRequest(http.MethodPost, "/submit", form)
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
for _, c := range session {
|
||||
req.AddCookie(c)
|
||||
}
|
||||
for _, c := range rec.Result().Cookies() {
|
||||
req.AddCookie(c)
|
||||
}
|
||||
rec2 := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec2, req)
|
||||
if rec2.Code != http.StatusSeeOther {
|
||||
t.Fatalf("submit %d %s", rec2.Code, rec2.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminSetupSecretOnlyWhenNoAdmins(t *testing.T) {
|
||||
mem := store.NewMemory()
|
||||
secret := "one-time-admin-setup-secret"
|
||||
adminName := uniq("seed")
|
||||
srv := newTestServerStore(t, mem, Config{AdminSetupSecret: secret})
|
||||
h := srv.Handler()
|
||||
|
||||
plain := uniq("plain")
|
||||
registerUser(t, h, plain, "hunter22")
|
||||
uPlain, err := mem.UserByUsername(context.Background(), plain)
|
||||
if err != nil || uPlain.Admin() {
|
||||
t.Fatalf("register without setup secret must stay user: %+v %v", uPlain, err)
|
||||
}
|
||||
|
||||
registerUser(t, h, adminName, "hunter22", secret)
|
||||
u, err := mem.UserByUsername(context.Background(), adminName)
|
||||
if err != nil || !u.Admin() {
|
||||
t.Fatalf("setup secret registrant should be admin: %+v %v", u, err)
|
||||
}
|
||||
|
||||
later := uniq("later")
|
||||
srv2 := newTestServerStore(t, mem, Config{AdminSetupSecret: secret})
|
||||
registerUser(t, srv2.Handler(), later, "hunter22", secret)
|
||||
u2, err := mem.UserByUsername(context.Background(), later)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if u2.Admin() {
|
||||
t.Fatal("setup secret must not grant admin once an admin already exists")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminUsersPageAccessAndRoles(t *testing.T) {
|
||||
srv, mem := newTestServer(t, Config{})
|
||||
h := srv.Handler()
|
||||
hubName := uniq("hub")
|
||||
bobName := uniq("bob")
|
||||
carolName := uniq("carol")
|
||||
seedUser(t, mem, hubName, "hunter22", store.RoleAdmin)
|
||||
adminCookies := loginUser(t, h, hubName, "hunter22")
|
||||
registerUser(t, h, bobName, "hunter22")
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/admin/users", nil)
|
||||
for _, c := range adminCookies {
|
||||
req.AddCookie(c)
|
||||
}
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != 200 {
|
||||
t.Fatalf("admin list %d", rec.Code)
|
||||
}
|
||||
if !strings.Contains(rec.Body.String(), bobName) {
|
||||
t.Fatal("missing bob on admin page")
|
||||
}
|
||||
|
||||
bob, err := mem.UserByUsername(context.Background(), bobName)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
csrf := csrfFrom(rec.Body.String())
|
||||
form := strings.NewReader("_csrf=" + csrf + "&role=admin")
|
||||
req = httptest.NewRequest(http.MethodPost, "/admin/users/"+bob.ID+"/role", form)
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
for _, c := range adminCookies {
|
||||
req.AddCookie(c)
|
||||
}
|
||||
rec = httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusSeeOther {
|
||||
t.Fatalf("promote %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
bob, _ = mem.UserByUsername(context.Background(), bobName)
|
||||
if !bob.Admin() {
|
||||
t.Fatal("bob should be admin")
|
||||
}
|
||||
|
||||
carolCookies := registerUser(t, h, carolName, "hunter22")
|
||||
rec = httptest.NewRecorder()
|
||||
req = httptest.NewRequest(http.MethodGet, "/admin/users", nil)
|
||||
for _, c := range carolCookies {
|
||||
req.AddCookie(c)
|
||||
}
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("non-admin expected 403, got %d", rec.Code)
|
||||
}
|
||||
|
||||
rec = httptest.NewRecorder()
|
||||
req = httptest.NewRequest(http.MethodGet, "/admin/users", nil)
|
||||
for _, c := range adminCookies {
|
||||
req.AddCookie(c)
|
||||
}
|
||||
h.ServeHTTP(rec, req)
|
||||
csrf = csrfFrom(rec.Body.String())
|
||||
form = strings.NewReader("_csrf=" + csrf + "&role=user")
|
||||
req = httptest.NewRequest(http.MethodPost, "/admin/users/"+bob.ID+"/role", form)
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
for _, c := range adminCookies {
|
||||
req.AddCookie(c)
|
||||
}
|
||||
rec = httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusSeeOther {
|
||||
t.Fatalf("demote bob %d", rec.Code)
|
||||
}
|
||||
|
||||
hub, err := mem.UserByUsername(context.Background(), hubName)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rec = httptest.NewRecorder()
|
||||
req = httptest.NewRequest(http.MethodGet, "/admin/users", nil)
|
||||
for _, c := range adminCookies {
|
||||
req.AddCookie(c)
|
||||
}
|
||||
h.ServeHTTP(rec, req)
|
||||
csrf = csrfFrom(rec.Body.String())
|
||||
form = strings.NewReader("_csrf=" + csrf + "&role=user")
|
||||
req = httptest.NewRequest(http.MethodPost, "/admin/users/"+hub.ID+"/role", form)
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
for _, c := range adminCookies {
|
||||
req.AddCookie(c)
|
||||
}
|
||||
rec = httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != 200 {
|
||||
t.Fatalf("demote last admin expected page with error, got %d", rec.Code)
|
||||
}
|
||||
if !strings.Contains(rec.Body.String(), "Cannot demote the last admin") {
|
||||
t.Fatalf("missing last-admin error: %s", rec.Body.String())
|
||||
}
|
||||
hub, _ = mem.UserByUsername(context.Background(), hubName)
|
||||
if !hub.Admin() {
|
||||
t.Fatal("hub must remain admin")
|
||||
}
|
||||
}
|
||||
|
||||
type fakeBlob struct {
|
||||
calls int
|
||||
last string
|
||||
}
|
||||
|
||||
func (f *fakeBlob) Enabled() bool { return true }
|
||||
|
||||
func (f *fakeBlob) Upload(_ context.Context, obj blob.FileUpload) (string, error) {
|
||||
f.calls++
|
||||
f.last = obj.Key
|
||||
return "https://cdn.example.com/" + obj.Key, nil
|
||||
}
|
||||
|
||||
func (f *fakeBlob) Delete(_ context.Context, _ string) error { return nil }
|
||||
|
||||
func TestProfilePageAndState(t *testing.T) {
|
||||
srv, mem := newTestServer(t, Config{})
|
||||
h := srv.Handler()
|
||||
name := uniq("alice")
|
||||
cookies := registerUser(t, h, name, "hunter22")
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/profile", nil)
|
||||
for _, c := range cookies {
|
||||
req.AddCookie(c)
|
||||
}
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != 200 {
|
||||
t.Fatalf("profile %d", rec.Code)
|
||||
}
|
||||
if !strings.Contains(rec.Body.String(), "Your questions") {
|
||||
t.Fatal("expected user questions label")
|
||||
}
|
||||
if !strings.Contains(rec.Body.String(), "local plumbing codes") {
|
||||
t.Fatal("missing state helper copy")
|
||||
}
|
||||
|
||||
csrf := csrfFrom(rec.Body.String())
|
||||
var buf bytes.Buffer
|
||||
w := multipart.NewWriter(&buf)
|
||||
_ = w.WriteField("_csrf", csrf)
|
||||
_ = w.WriteField("state", "CA")
|
||||
_ = w.Close()
|
||||
req = httptest.NewRequest(http.MethodPost, "/profile", &buf)
|
||||
req.Header.Set("Content-Type", w.FormDataContentType())
|
||||
for _, c := range cookies {
|
||||
req.AddCookie(c)
|
||||
}
|
||||
rec = httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusSeeOther {
|
||||
t.Fatalf("save profile %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
u, err := mem.UserByUsername(context.Background(), name)
|
||||
if err != nil || u.State != "CA" {
|
||||
t.Fatalf("state not saved: %+v %v", u, err)
|
||||
}
|
||||
|
||||
rec = httptest.NewRecorder()
|
||||
req = httptest.NewRequest(http.MethodGet, "/profile", nil)
|
||||
for _, c := range cookies {
|
||||
req.AddCookie(c)
|
||||
}
|
||||
h.ServeHTTP(rec, req)
|
||||
csrf = csrfFrom(rec.Body.String())
|
||||
buf.Reset()
|
||||
w = multipart.NewWriter(&buf)
|
||||
_ = w.WriteField("_csrf", csrf)
|
||||
_ = w.WriteField("state", "ZZ")
|
||||
_ = w.Close()
|
||||
req = httptest.NewRequest(http.MethodPost, "/profile", &buf)
|
||||
req.Header.Set("Content-Type", w.FormDataContentType())
|
||||
for _, c := range cookies {
|
||||
req.AddCookie(c)
|
||||
}
|
||||
rec = httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != 200 || !strings.Contains(rec.Body.String(), "valid US state") {
|
||||
t.Fatalf("expected invalid state error, got %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestProfileAdminAnsweredListAndAvatarUpload(t *testing.T) {
|
||||
fb := &fakeBlob{}
|
||||
srv, mem := newTestServer(t, Config{Blob: fb})
|
||||
h := srv.Handler()
|
||||
hubName := uniq("hub")
|
||||
aliceName := uniq("alice")
|
||||
hub := seedUser(t, mem, hubName, "hunter22", store.RoleAdmin)
|
||||
alice := seedUser(t, mem, aliceName, "hunter22", store.RoleUser)
|
||||
adminCookies := loginUser(t, h, hubName, "hunter22")
|
||||
|
||||
q := &store.RankedQuestion{
|
||||
AuthorID: alice.ID,
|
||||
Title: "Drip",
|
||||
Body: "Under sink",
|
||||
City: "Oakland",
|
||||
}
|
||||
if err := mem.CreateQuestion(context.Background(), q); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ans := &store.Answer{
|
||||
QuestionID: q.ID,
|
||||
AuthorID: hub.ID,
|
||||
Body: "Replace the cartridge.",
|
||||
}
|
||||
if err := mem.UpsertAnswer(context.Background(), ans); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/profile", nil)
|
||||
for _, c := range adminCookies {
|
||||
req.AddCookie(c)
|
||||
}
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != 200 {
|
||||
t.Fatalf("admin profile %d", rec.Code)
|
||||
}
|
||||
body := rec.Body.String()
|
||||
if !strings.Contains(body, "Questions you answered") || !strings.Contains(body, "Drip") {
|
||||
t.Fatalf("admin answered list missing: %s", body)
|
||||
}
|
||||
|
||||
csrf := csrfFrom(body)
|
||||
var buf bytes.Buffer
|
||||
w := multipart.NewWriter(&buf)
|
||||
_ = w.WriteField("_csrf", csrf)
|
||||
_ = w.WriteField("state", "OR")
|
||||
part, err := w.CreateFormFile("avatar", "pic.png")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
img := image.NewRGBA(image.Rect(0, 0, 1, 1))
|
||||
if err := png.Encode(part, img); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = w.Close()
|
||||
req = httptest.NewRequest(http.MethodPost, "/profile", &buf)
|
||||
req.Header.Set("Content-Type", w.FormDataContentType())
|
||||
for _, c := range adminCookies {
|
||||
req.AddCookie(c)
|
||||
}
|
||||
rec = httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusSeeOther {
|
||||
t.Fatalf("avatar upload %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if fb.calls != 1 {
|
||||
t.Fatalf("expected 1 upload, got %d", fb.calls)
|
||||
}
|
||||
hub, _ = mem.UserByUsername(context.Background(), hubName)
|
||||
if !strings.Contains(hub.AvatarURL, "cdn.example.com/avatars/") {
|
||||
t.Fatalf("avatar url %q", hub.AvatarURL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMutationsVoteAnswerHideAndCSRF(t *testing.T) {
|
||||
srv, mem := newTestServer(t, Config{})
|
||||
h := srv.Handler()
|
||||
adminName := uniq("admin")
|
||||
userName := uniq("user")
|
||||
admin := seedUser(t, mem, adminName, "hunter22", store.RoleAdmin)
|
||||
user := seedUser(t, mem, userName, "hunter22", store.RoleUser)
|
||||
adminCookies := loginUser(t, h, adminName, "hunter22")
|
||||
userCookies := loginUser(t, h, userName, "hunter22")
|
||||
|
||||
q := &store.RankedQuestion{
|
||||
AuthorID: user.ID,
|
||||
Title: "Pipe noise",
|
||||
Body: "Clanking",
|
||||
City: "SF",
|
||||
HuntDate: pacific.Today(),
|
||||
}
|
||||
if err := mem.CreateQuestion(context.Background(), q); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Missing CSRF
|
||||
form := strings.NewReader("value=1&view=question")
|
||||
req := httptest.NewRequest(http.MethodPost, "/questions/"+q.ID+"/vote", form)
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
for _, c := range userCookies {
|
||||
req.AddCookie(c)
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("missing csrf want 403, got %d", rec.Code)
|
||||
}
|
||||
|
||||
// Anonymous HTMX vote → sign-in prompt
|
||||
rec = httptest.NewRecorder()
|
||||
req = httptest.NewRequest(http.MethodGet, "/login", nil)
|
||||
h.ServeHTTP(rec, req)
|
||||
anon := rec.Result().Cookies()
|
||||
csrf := csrfFrom(rec.Body.String())
|
||||
form = strings.NewReader("_csrf=" + csrf + "&value=1&view=question")
|
||||
req = httptest.NewRequest(http.MethodPost, "/questions/"+q.ID+"/vote", form)
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.Header.Set("HX-Request", "true")
|
||||
for _, c := range anon {
|
||||
req.AddCookie(c)
|
||||
}
|
||||
rec = httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != 200 || !strings.Contains(rec.Body.String(), "Sign in") {
|
||||
t.Fatalf("anon htmx vote: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
|
||||
// User vote + HTMX fragment
|
||||
rec = httptest.NewRecorder()
|
||||
req = httptest.NewRequest(http.MethodGet, "/questions/"+q.ID, nil)
|
||||
for _, c := range userCookies {
|
||||
req.AddCookie(c)
|
||||
}
|
||||
h.ServeHTTP(rec, req)
|
||||
csrf = csrfFrom(rec.Body.String())
|
||||
form = strings.NewReader("_csrf=" + csrf + "&value=1&view=question")
|
||||
req = httptest.NewRequest(http.MethodPost, "/questions/"+q.ID+"/vote", form)
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.Header.Set("HX-Request", "true")
|
||||
for _, c := range userCookies {
|
||||
req.AddCookie(c)
|
||||
}
|
||||
rec = httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != 200 {
|
||||
t.Fatalf("vote htmx %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
got, err := mem.GetQuestion(context.Background(), q.ID, user.ID)
|
||||
if err != nil || got.UserVote != 1 || got.Score != 1 {
|
||||
t.Fatalf("vote not applied: %+v %v", got, err)
|
||||
}
|
||||
|
||||
// Non-admin answer rejected
|
||||
rec = httptest.NewRecorder()
|
||||
req = httptest.NewRequest(http.MethodGet, "/questions/"+q.ID, nil)
|
||||
for _, c := range userCookies {
|
||||
req.AddCookie(c)
|
||||
}
|
||||
h.ServeHTTP(rec, req)
|
||||
csrf = csrfFrom(rec.Body.String())
|
||||
form = strings.NewReader("_csrf=" + csrf + "&body=Nope")
|
||||
req = httptest.NewRequest(http.MethodPost, "/questions/"+q.ID+"/answer", form)
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
for _, c := range userCookies {
|
||||
req.AddCookie(c)
|
||||
}
|
||||
rec = httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("non-admin answer want 403, got %d", rec.Code)
|
||||
}
|
||||
|
||||
// Admin answer success (HTMX)
|
||||
rec = httptest.NewRecorder()
|
||||
req = httptest.NewRequest(http.MethodGet, "/questions/"+q.ID, nil)
|
||||
for _, c := range adminCookies {
|
||||
req.AddCookie(c)
|
||||
}
|
||||
h.ServeHTTP(rec, req)
|
||||
csrf = csrfFrom(rec.Body.String())
|
||||
form = strings.NewReader("_csrf=" + csrf + "&body=Tighten+the+nuts.")
|
||||
req = httptest.NewRequest(http.MethodPost, "/questions/"+q.ID+"/answer", form)
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.Header.Set("HX-Request", "true")
|
||||
for _, c := range adminCookies {
|
||||
req.AddCookie(c)
|
||||
}
|
||||
rec = httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != 200 || !strings.Contains(rec.Body.String(), "Tighten the nuts") {
|
||||
t.Fatalf("admin answer: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if _, err := mem.GetAnswer(context.Background(), q.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Hide invalid id
|
||||
rec = httptest.NewRecorder()
|
||||
req = httptest.NewRequest(http.MethodGet, "/questions/"+q.ID, nil)
|
||||
for _, c := range adminCookies {
|
||||
req.AddCookie(c)
|
||||
}
|
||||
h.ServeHTTP(rec, req)
|
||||
csrf = csrfFrom(rec.Body.String())
|
||||
form = strings.NewReader("_csrf=" + csrf)
|
||||
req = httptest.NewRequest(http.MethodPost, "/questions/does-not-exist/hide", form)
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
for _, c := range adminCookies {
|
||||
req.AddCookie(c)
|
||||
}
|
||||
rec = httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusNotFound {
|
||||
t.Fatalf("hide missing want 404, got %d", rec.Code)
|
||||
}
|
||||
|
||||
// Admin hide success
|
||||
form = strings.NewReader("_csrf=" + csrf)
|
||||
req = httptest.NewRequest(http.MethodPost, "/questions/"+q.ID+"/hide", form)
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
for _, c := range adminCookies {
|
||||
req.AddCookie(c)
|
||||
}
|
||||
rec = httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusSeeOther {
|
||||
t.Fatalf("hide %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
hidden, err := mem.GetQuestion(context.Background(), q.ID, admin.ID)
|
||||
if err != nil || !hidden.Hidden {
|
||||
t.Fatalf("question not hidden: %+v %v", hidden, err)
|
||||
}
|
||||
}
|
||||
|
||||
func mergeCookies(sets ...[]*http.Cookie) []*http.Cookie {
|
||||
byName := map[string]*http.Cookie{}
|
||||
for _, set := range sets {
|
||||
for _, c := range set {
|
||||
byName[c.Name] = c
|
||||
}
|
||||
}
|
||||
out := make([]*http.Cookie, 0, len(byName))
|
||||
for _, c := range byName {
|
||||
out = append(out, c)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func csrfFrom(html string) string {
|
||||
const needle = `name="_csrf" value="`
|
||||
i := strings.Index(html, needle)
|
||||
if i < 0 {
|
||||
return ""
|
||||
}
|
||||
html = html[i+len(needle):]
|
||||
j := strings.Index(html, `"`)
|
||||
if j < 0 {
|
||||
return ""
|
||||
}
|
||||
return html[:j]
|
||||
}
|
||||
|
||||
// TestRegisterThrottleUsesTCPPeerThroughRouter ensures forged X-Forwarded-For
|
||||
// cannot bypass rate limits when the direct peer is outside TrustedProxies.
|
||||
// This must go through Handler() so middleware ordering bugs are caught.
|
||||
func TestRegisterThrottleUsesTCPPeerThroughRouter(t *testing.T) {
|
||||
_, proxyNet, err := net.ParseCIDR("10.0.0.0/8")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
srv, _ := newTestServer(t, Config{TrustedProxies: []*net.IPNet{proxyNet}})
|
||||
// Tight window so the test stays fast.
|
||||
srv.registerIP = newThrottle(3, time.Minute, 100)
|
||||
h := srv.Handler()
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/register", nil))
|
||||
cookies := rec.Result().Cookies()
|
||||
csrf := csrfFrom(rec.Body.String())
|
||||
if csrf == "" {
|
||||
t.Fatal("missing csrf")
|
||||
}
|
||||
|
||||
post := func(xff string) int {
|
||||
form := strings.NewReader("_csrf=" + csrf + "&username=ab&password=hunter22")
|
||||
req := httptest.NewRequest(http.MethodPost, "/register", form)
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.RemoteAddr = "203.0.113.50:9"
|
||||
req.Header.Set("X-Forwarded-For", xff)
|
||||
for _, c := range cookies {
|
||||
req.AddCookie(c)
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
h.ServeHTTP(w, req)
|
||||
return w.Code
|
||||
}
|
||||
|
||||
if code := post("198.51.100.1"); code != http.StatusOK {
|
||||
t.Fatalf("attempt 1: got %d want 200 (validation error page)", code)
|
||||
}
|
||||
if code := post("198.51.100.2"); code != http.StatusOK {
|
||||
t.Fatalf("attempt 2: got %d want 200", code)
|
||||
}
|
||||
if code := post("198.51.100.3"); code != http.StatusOK {
|
||||
t.Fatalf("attempt 3: got %d want 200", code)
|
||||
}
|
||||
if code := post("198.51.100.4"); code != http.StatusTooManyRequests {
|
||||
t.Fatalf("forged XFF must not bypass peer throttle, got %d want 429", code)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,309 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"container/list"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
)
|
||||
|
||||
const defaultThrottleMaxKeys = 10_000
|
||||
|
||||
// throttle is a sliding-window rate limiter with LRU eviction at capacity.
|
||||
type throttle struct {
|
||||
mu sync.Mutex
|
||||
hits map[string]*throttleEntry
|
||||
lru *list.List // front = most recently used
|
||||
limit int
|
||||
window time.Duration
|
||||
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 {
|
||||
if maxKeys <= 0 {
|
||||
maxKeys = defaultThrottleMaxKeys
|
||||
}
|
||||
return &throttle{
|
||||
hits: map[string]*throttleEntry{},
|
||||
lru: list.New(),
|
||||
limit: limit,
|
||||
window: window,
|
||||
maxKeys: maxKeys,
|
||||
}
|
||||
}
|
||||
|
||||
func (t *throttle) allow(key string) bool {
|
||||
if t == nil || key == "" {
|
||||
return true
|
||||
}
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
now := time.Now()
|
||||
cutoff := now.Add(-t.window)
|
||||
|
||||
ent, ok := t.hits[key]
|
||||
if ok {
|
||||
ent.times = pruneTimes(ent.times, cutoff)
|
||||
if len(ent.times) == 0 {
|
||||
t.removeLocked(key, ent)
|
||||
ok = false
|
||||
}
|
||||
}
|
||||
if ok {
|
||||
if len(ent.times) >= t.limit {
|
||||
t.touchLocked(key, ent)
|
||||
return false
|
||||
}
|
||||
ent.times = append(ent.times, now)
|
||||
t.touchLocked(key, ent)
|
||||
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 {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
return len(t.hits)
|
||||
}
|
||||
|
||||
func pruneTimes(xs []time.Time, cutoff time.Time) []time.Time {
|
||||
n := 0
|
||||
for _, ts := range xs {
|
||||
if ts.After(cutoff) {
|
||||
xs[n] = ts
|
||||
n++
|
||||
}
|
||||
}
|
||||
return xs[:n]
|
||||
}
|
||||
|
||||
// failureTracker records auth failures for progressive delay (not a hard lockout).
|
||||
type failureTracker struct {
|
||||
mu sync.Mutex
|
||||
fails map[string]failState
|
||||
window time.Duration
|
||||
maxKeys int
|
||||
}
|
||||
|
||||
type failState struct {
|
||||
count int
|
||||
last time.Time
|
||||
}
|
||||
|
||||
func newFailureTracker(window time.Duration, maxKeys int) *failureTracker {
|
||||
if maxKeys <= 0 {
|
||||
maxKeys = defaultThrottleMaxKeys
|
||||
}
|
||||
return &failureTracker{
|
||||
fails: map[string]failState{},
|
||||
window: window,
|
||||
maxKeys: maxKeys,
|
||||
}
|
||||
}
|
||||
|
||||
func (f *failureTracker) delay(key string) time.Duration {
|
||||
if f == nil || key == "" {
|
||||
return 0
|
||||
}
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
now := time.Now()
|
||||
f.evictExpiredLocked(now)
|
||||
st, ok := f.fails[key]
|
||||
if !ok {
|
||||
return 0
|
||||
}
|
||||
return progressiveDelay(st.count)
|
||||
}
|
||||
|
||||
func (f *failureTracker) record(key string) {
|
||||
if f == nil || key == "" {
|
||||
return
|
||||
}
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
now := time.Now()
|
||||
f.evictExpiredLocked(now)
|
||||
st := f.fails[key]
|
||||
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
|
||||
}
|
||||
}
|
||||
st.count++
|
||||
st.last = now
|
||||
f.fails[key] = st
|
||||
}
|
||||
|
||||
func (f *failureTracker) clear(key string) {
|
||||
if f == nil || key == "" {
|
||||
return
|
||||
}
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
delete(f.fails, key)
|
||||
}
|
||||
|
||||
func (f *failureTracker) lenKeys() int {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
return len(f.fails)
|
||||
}
|
||||
|
||||
func (f *failureTracker) evictExpiredLocked(now time.Time) {
|
||||
cutoff := now.Add(-f.window)
|
||||
for k, st := range f.fails {
|
||||
if st.last.Before(cutoff) {
|
||||
delete(f.fails, k)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func progressiveDelay(failCount int) time.Duration {
|
||||
switch {
|
||||
case failCount <= 1:
|
||||
return 0
|
||||
case failCount == 2:
|
||||
return 200 * time.Millisecond
|
||||
case failCount == 3:
|
||||
return 500 * time.Millisecond
|
||||
case failCount == 4:
|
||||
return time.Second
|
||||
default:
|
||||
return 2 * time.Second
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) clientIP(r *http.Request) string {
|
||||
host, _, err := net.SplitHostPort(r.RemoteAddr)
|
||||
if err != nil {
|
||||
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
|
||||
}
|
||||
|
||||
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) {
|
||||
http.Error(w, "too many attempts; try again later", http.StatusTooManyRequests)
|
||||
}
|
||||
|
||||
func loginFailKey(ip, usernameKey string) string {
|
||||
return ip + "\x00" + usernameKey
|
||||
}
|
||||
|
||||
func (s *Server) allowLoginAttempt(w http.ResponseWriter, r *http.Request, usernameKey string) bool {
|
||||
ip := s.clientIP(r)
|
||||
if !s.loginIP.allow(ip) {
|
||||
authTooMany(w)
|
||||
return false
|
||||
}
|
||||
if d := s.loginFail.delay(loginFailKey(ip, usernameKey)); d > 0 {
|
||||
time.Sleep(d)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (s *Server) allowRegisterAttempt(w http.ResponseWriter, r *http.Request) bool {
|
||||
if !s.registerIP.allow(s.clientIP(r)) {
|
||||
authTooMany(w)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestThrottleWindowAndEviction(t *testing.T) {
|
||||
th := newThrottle(2, 50*time.Millisecond, 100)
|
||||
if !th.allow("a") || !th.allow("a") {
|
||||
t.Fatal("first two should pass")
|
||||
}
|
||||
if th.allow("a") {
|
||||
t.Fatal("third within window should fail")
|
||||
}
|
||||
time.Sleep(60 * time.Millisecond)
|
||||
if !th.allow("a") {
|
||||
t.Fatal("after window should pass")
|
||||
}
|
||||
// Expired empty keys should be removed on next allow of another key path.
|
||||
time.Sleep(60 * time.Millisecond)
|
||||
_ = th.allow("b")
|
||||
if th.lenKeys() > 2 {
|
||||
t.Fatalf("expected eviction of stale keys, got %d", th.lenKeys())
|
||||
}
|
||||
}
|
||||
|
||||
func TestThrottleMaxKeys(t *testing.T) {
|
||||
th := newThrottle(5, time.Minute, 2)
|
||||
if !th.allow("one") || !th.allow("two") {
|
||||
t.Fatal("first keys should pass")
|
||||
}
|
||||
if !th.allow("three") {
|
||||
t.Fatal("over maxKeys should LRU-evict and accept new key")
|
||||
}
|
||||
if th.lenKeys() != 2 {
|
||||
t.Fatalf("keys=%d want 2", th.lenKeys())
|
||||
}
|
||||
}
|
||||
|
||||
func TestThrottleConcurrent(t *testing.T) {
|
||||
th := newThrottle(50, time.Minute, 1000)
|
||||
var wg sync.WaitGroup
|
||||
var okCount int
|
||||
var mu sync.Mutex
|
||||
for i := 0; i < 100; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
if th.allow("same") {
|
||||
mu.Lock()
|
||||
okCount++
|
||||
mu.Unlock()
|
||||
}
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
if okCount != 50 {
|
||||
t.Fatalf("ok=%d want 50", okCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFailureTrackerProgressiveAndClear(t *testing.T) {
|
||||
f := newFailureTracker(time.Minute, 100)
|
||||
if d := f.delay("k"); d != 0 {
|
||||
t.Fatalf("fresh delay=%v", d)
|
||||
}
|
||||
f.record("k")
|
||||
f.record("k")
|
||||
if d := f.delay("k"); d != 200*time.Millisecond {
|
||||
t.Fatalf("delay after 2 fails=%v", d)
|
||||
}
|
||||
f.clear("k")
|
||||
if d := f.delay("k"); d != 0 {
|
||||
t.Fatalf("after clear delay=%v", d)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFailureTrackerEvictsExpired(t *testing.T) {
|
||||
f := newFailureTracker(30*time.Millisecond, 100)
|
||||
f.record("old")
|
||||
time.Sleep(40 * time.Millisecond)
|
||||
_ = f.delay("other") // triggers eviction
|
||||
if f.lenKeys() != 0 {
|
||||
t.Fatalf("expired key remained, keys=%d", f.lenKeys())
|
||||
}
|
||||
}
|
||||
|
||||
func TestClientIPTrustProxy(t *testing.T) {
|
||||
_, 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.RemoteAddr = "10.0.0.1:1234"
|
||||
req.Header.Set("X-Forwarded-For", "203.0.113.9, 10.0.0.1")
|
||||
if got := srv.clientIP(req); got != "203.0.113.9" {
|
||||
t.Fatalf("trusted xff got %q", got)
|
||||
}
|
||||
|
||||
req.RemoteAddr = "203.0.113.50:9"
|
||||
if got := srv.clientIP(req); got != "203.0.113.50" {
|
||||
t.Fatalf("untrusted peer should ignore xff, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNoGlobalUsernameHardLockout(t *testing.T) {
|
||||
// Victim IP should still be allowed after another IP burns attempts for the same username.
|
||||
srv := &Server{
|
||||
loginIP: newThrottle(20, time.Minute, 100),
|
||||
loginFail: newFailureTracker(time.Minute, 100),
|
||||
}
|
||||
for i := 0; i < 20; i++ {
|
||||
srv.loginFail.record(loginFailKey("1.1.1.1", "alice"))
|
||||
}
|
||||
victim := httptest.NewRequest(http.MethodPost, "/login", nil)
|
||||
victim.RemoteAddr = "2.2.2.2:9"
|
||||
w := httptest.NewRecorder()
|
||||
if !srv.allowLoginAttempt(w, victim, "alice") {
|
||||
t.Fatal("victim IP must not be hard-locked by username-only attempts")
|
||||
}
|
||||
}
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id TEXT PRIMARY KEY,
|
||||
username TEXT NOT NULL UNIQUE,
|
||||
name TEXT NOT NULL,
|
||||
password_hash TEXT NOT NULL,
|
||||
role TEXT NOT NULL DEFAULT 'user' CHECK (role IN ('user', 'admin')),
|
||||
avatar_url TEXT NOT NULL DEFAULT '',
|
||||
state TEXT NOT NULL DEFAULT '',
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS questions (
|
||||
id TEXT PRIMARY KEY,
|
||||
author_id TEXT NOT NULL REFERENCES users(id),
|
||||
title TEXT NOT NULL,
|
||||
body TEXT NOT NULL,
|
||||
city TEXT NOT NULL DEFAULT '',
|
||||
hunt_date TEXT NOT NULL,
|
||||
hidden INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_questions_hunt_date ON questions(hunt_date, hidden);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS votes (
|
||||
user_id TEXT NOT NULL REFERENCES users(id),
|
||||
question_id TEXT NOT NULL REFERENCES questions(id) ON DELETE CASCADE,
|
||||
value INTEGER NOT NULL CHECK (value IN (-1, 1)),
|
||||
PRIMARY KEY (user_id, question_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS answers (
|
||||
question_id TEXT PRIMARY KEY REFERENCES questions(id) ON DELETE CASCADE,
|
||||
author_id TEXT NOT NULL REFERENCES users(id),
|
||||
body TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
token TEXT PRIMARY KEY,
|
||||
data BYTEA NOT NULL,
|
||||
expiry TIMESTAMPTZ NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS sessions_expiry_idx ON sessions (expiry);
|
||||
@@ -0,0 +1,12 @@
|
||||
version: "2"
|
||||
sql:
|
||||
- engine: "postgresql"
|
||||
schema: "schema.sql"
|
||||
queries: "db/queries"
|
||||
gen:
|
||||
go:
|
||||
package: "sqlc"
|
||||
out: "internal/store/sqlc"
|
||||
sql_package: "database/sql"
|
||||
emit_json_tags: false
|
||||
emit_empty_slices: true
|
||||
+789
@@ -0,0 +1,789 @@
|
||||
:root {
|
||||
--bg: #161719;
|
||||
--panel: #1e2023;
|
||||
--ink: #ecebe7;
|
||||
--muted: #8d9096;
|
||||
--line: #2e3136;
|
||||
--zinc: #6b7078;
|
||||
--signal: #e96a26;
|
||||
--signal-ink: #161719;
|
||||
--err: #e24b4b;
|
||||
--err-bg: #2a1a1a;
|
||||
--ok: #e96a26;
|
||||
--sans: "Archivo", "Helvetica Neue", sans-serif;
|
||||
--mono: "IBM Plex Mono", ui-monospace, monospace;
|
||||
--pad: env(safe-area-inset-bottom, 0px);
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
html, body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
min-height: 100%;
|
||||
background: var(--bg);
|
||||
color: var(--ink);
|
||||
font: 16px/1.45 var(--sans);
|
||||
-webkit-text-size-adjust: 100%;
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
body {
|
||||
background-image:
|
||||
linear-gradient(180deg, #1a1c1f 0%, var(--bg) 180px),
|
||||
repeating-linear-gradient(
|
||||
90deg,
|
||||
transparent,
|
||||
transparent 47px,
|
||||
#1c1e21 47px,
|
||||
#1c1e21 48px
|
||||
);
|
||||
background-size: 100% 100%, 48px 48px;
|
||||
}
|
||||
|
||||
img, svg { display: block; }
|
||||
|
||||
a {
|
||||
color: var(--signal);
|
||||
text-underline-offset: 0.18em;
|
||||
}
|
||||
|
||||
.skip {
|
||||
position: absolute;
|
||||
left: -999px;
|
||||
}
|
||||
.skip:focus {
|
||||
left: 12px;
|
||||
top: 12px;
|
||||
z-index: 20;
|
||||
background: var(--panel);
|
||||
padding: 8px 12px;
|
||||
}
|
||||
|
||||
.top {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 20;
|
||||
background: #141516;
|
||||
border-bottom: 1px solid var(--line);
|
||||
box-shadow: inset 0 2px 0 var(--signal);
|
||||
}
|
||||
|
||||
.top-inner {
|
||||
width: min(840px, calc(100% - 32px));
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px 16px;
|
||||
padding: calc(12px + env(safe-area-inset-top, 0px)) 0 12px;
|
||||
}
|
||||
|
||||
.logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
color: var(--signal);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.mark {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.logo-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.logo-name {
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: var(--ink);
|
||||
line-height: 1.15;
|
||||
}
|
||||
|
||||
.tagline {
|
||||
color: var(--muted);
|
||||
font-family: var(--mono);
|
||||
font-size: 0.65rem;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.top-nav {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.who {
|
||||
color: var(--muted);
|
||||
font-family: var(--mono);
|
||||
font-size: 0.75rem;
|
||||
max-width: 9rem;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.account-menu {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.account-menu-toggle {
|
||||
gap: 8px;
|
||||
list-style: none;
|
||||
padding-left: 10px;
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
.account-menu-toggle:hover {
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
.account-menu-toggle::-webkit-details-marker { display: none; }
|
||||
.account-menu-toggle::marker { content: ""; }
|
||||
|
||||
.nav-avatar {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
flex: none;
|
||||
border: 1px solid var(--line);
|
||||
background: var(--panel);
|
||||
}
|
||||
|
||||
.nav-avatar-ghost {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-family: var(--mono);
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0;
|
||||
text-transform: uppercase;
|
||||
color: var(--signal);
|
||||
}
|
||||
|
||||
.account-menu-caret {
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-left: 4px solid transparent;
|
||||
border-right: 4px solid transparent;
|
||||
border-top: 5px solid var(--muted);
|
||||
}
|
||||
|
||||
.account-menu[open] .account-menu-caret {
|
||||
border-top: none;
|
||||
border-bottom: 5px solid var(--muted);
|
||||
}
|
||||
|
||||
.account-menu-panel {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: calc(100% + 6px);
|
||||
z-index: 10;
|
||||
min-width: 10.5rem;
|
||||
padding: 6px;
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--line);
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
|
||||
.account-menu-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
min-height: 40px;
|
||||
padding: 0 12px;
|
||||
border: 0;
|
||||
border-radius: 2px;
|
||||
background: transparent;
|
||||
color: var(--ink);
|
||||
font: inherit;
|
||||
font-weight: 600;
|
||||
font-size: 0.75rem;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
text-decoration: none;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.account-menu-item:hover,
|
||||
.account-menu-item:focus-visible {
|
||||
background: #26282c;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.logout { margin: 0; }
|
||||
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 44px;
|
||||
padding: 0 16px;
|
||||
border-radius: 3px;
|
||||
border: 1px solid transparent;
|
||||
font: inherit;
|
||||
font-weight: 600;
|
||||
font-size: 0.8rem;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
touch-action: manipulation;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--signal);
|
||||
color: var(--signal-ink);
|
||||
}
|
||||
|
||||
.btn-primary:hover { filter: brightness(1.08); }
|
||||
|
||||
.btn-ghost {
|
||||
background: transparent;
|
||||
color: var(--ink);
|
||||
border-color: var(--line);
|
||||
}
|
||||
|
||||
.btn-ghost:hover { border-color: var(--zinc); }
|
||||
|
||||
.wrap {
|
||||
width: min(840px, calc(100% - 32px));
|
||||
margin: 28px auto 56px;
|
||||
}
|
||||
|
||||
#flash:empty { display: none; }
|
||||
|
||||
.banner {
|
||||
width: min(840px, calc(100% - 32px));
|
||||
margin: 16px auto 0;
|
||||
padding: 12px 16px;
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--line);
|
||||
border-left: 3px solid var(--signal);
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.banner.error {
|
||||
background: var(--err-bg);
|
||||
border-left-color: var(--err);
|
||||
color: #ffd0d0;
|
||||
}
|
||||
|
||||
.hunt-head {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-between;
|
||||
align-items: end;
|
||||
gap: 20px;
|
||||
margin-bottom: 8px;
|
||||
padding-bottom: 20px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
margin: 0;
|
||||
font-family: var(--mono);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.16em;
|
||||
font-size: 0.68rem;
|
||||
font-weight: 500;
|
||||
color: var(--signal);
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 10px 0 0;
|
||||
font-size: clamp(1.8rem, 4.5vw, 2.75rem);
|
||||
font-weight: 500;
|
||||
letter-spacing: -0.03em;
|
||||
line-height: 1.05;
|
||||
text-wrap: pretty;
|
||||
}
|
||||
|
||||
.date-nav {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.chip {
|
||||
min-height: 40px;
|
||||
padding: 8px 12px;
|
||||
border-radius: 3px;
|
||||
border: 1px solid var(--line);
|
||||
background: transparent;
|
||||
color: var(--ink);
|
||||
text-decoration: none;
|
||||
font-family: var(--mono);
|
||||
font-weight: 500;
|
||||
font-size: 0.72rem;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.chip.is-on {
|
||||
background: var(--signal);
|
||||
border-color: var(--signal);
|
||||
color: var(--signal-ink);
|
||||
}
|
||||
|
||||
.date-form {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.date-form input[type="date"] {
|
||||
min-height: 44px;
|
||||
padding: 0 10px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 3px;
|
||||
font: inherit;
|
||||
font-family: var(--mono);
|
||||
font-size: 0.8rem;
|
||||
background: var(--panel);
|
||||
color: var(--ink);
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
.board {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.row {
|
||||
display: grid;
|
||||
grid-template-columns: 2.8rem 3.25rem 1fr;
|
||||
gap: 4px 8px;
|
||||
align-items: start;
|
||||
padding: 14px 12px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.row:last-child { border-bottom: 0; }
|
||||
|
||||
.row:hover { background: #24262b; }
|
||||
|
||||
.rank {
|
||||
font-family: var(--mono);
|
||||
font-weight: 500;
|
||||
font-size: 1.15rem;
|
||||
line-height: 1;
|
||||
color: var(--zinc);
|
||||
text-align: right;
|
||||
padding-top: 12px;
|
||||
}
|
||||
|
||||
.row:first-child .rank { color: var(--signal); }
|
||||
|
||||
.vote {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.vote form { margin: 0; }
|
||||
|
||||
.vote-btn {
|
||||
width: 44px;
|
||||
height: 40px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--zinc);
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
touch-action: manipulation;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.vote-btn:hover { color: var(--ink); background: #2a2d32; }
|
||||
|
||||
.vote-btn.is-up { color: var(--signal); }
|
||||
.vote-btn.is-down { color: var(--muted); }
|
||||
|
||||
.score {
|
||||
font-family: var(--mono);
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-weight: 500;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.q-title {
|
||||
color: var(--ink);
|
||||
font-weight: 500;
|
||||
text-decoration: none;
|
||||
font-size: 1.05rem;
|
||||
text-wrap: pretty;
|
||||
}
|
||||
|
||||
.q-title:hover { color: var(--signal); }
|
||||
|
||||
.meta {
|
||||
margin: 6px 0 0;
|
||||
color: var(--muted);
|
||||
font-family: var(--mono);
|
||||
font-size: 0.72rem;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.dot { margin: 0 0.35em; }
|
||||
|
||||
.badge {
|
||||
display: inline-block;
|
||||
margin-left: 8px;
|
||||
padding: 2px 6px;
|
||||
border: 1px solid var(--signal);
|
||||
color: var(--signal);
|
||||
font-size: 0.62rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.empty {
|
||||
padding: 48px 16px;
|
||||
text-align: left;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.empty-kicker {
|
||||
font-family: var(--mono);
|
||||
font-size: 0.72rem;
|
||||
letter-spacing: 0.16em;
|
||||
text-transform: uppercase;
|
||||
color: var(--signal);
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
|
||||
.inline-hide { margin: 6px 0 0; }
|
||||
|
||||
.linkish {
|
||||
background: none;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
color: var(--muted);
|
||||
font: inherit;
|
||||
font-family: var(--mono);
|
||||
font-size: 0.72rem;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
text-decoration: underline;
|
||||
cursor: pointer;
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
.lede, .hint, .switch {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.lede { margin: 8px 0 0; max-width: 38rem; text-wrap: pretty; }
|
||||
|
||||
.ask, .answer-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
label {
|
||||
font-family: var(--mono);
|
||||
font-weight: 500;
|
||||
font-size: 0.68rem;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
input[type="text"],
|
||||
input[type="password"],
|
||||
textarea {
|
||||
width: 100%;
|
||||
font: inherit;
|
||||
font-size: 16px;
|
||||
padding: 12px 12px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 3px;
|
||||
background: #141516;
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
input:focus, textarea:focus, .btn:focus-visible, .chip:focus-visible, .vote-btn:focus-visible {
|
||||
outline: 2px solid var(--signal);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.optional { font-weight: 400; letter-spacing: 0.04em; }
|
||||
|
||||
.panel-wrap, .auth-wrap {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 3px;
|
||||
padding: 24px 20px 28px;
|
||||
}
|
||||
|
||||
.question-page .q-detail {
|
||||
display: grid;
|
||||
grid-template-columns: 3.25rem 1fr;
|
||||
gap: 8px 14px;
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--line);
|
||||
padding: 18px 14px;
|
||||
}
|
||||
|
||||
.question-page h1 {
|
||||
font-size: clamp(1.5rem, 3.5vw, 2.1rem);
|
||||
line-height: 1.15;
|
||||
}
|
||||
|
||||
.q-body, .answer-body {
|
||||
white-space: pre-wrap;
|
||||
margin: 14px 0 0;
|
||||
text-wrap: pretty;
|
||||
}
|
||||
|
||||
.crumb { margin: 0 0 20px; }
|
||||
.crumb a {
|
||||
color: var(--muted);
|
||||
text-decoration: none;
|
||||
font-family: var(--mono);
|
||||
font-size: 0.72rem;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.crumb a:hover { color: var(--signal); }
|
||||
|
||||
.answer {
|
||||
margin-top: 16px;
|
||||
padding: 20px 18px;
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.answer.is-in {
|
||||
border-color: var(--signal);
|
||||
}
|
||||
|
||||
.answer-kicker {
|
||||
margin: 0 0 6px;
|
||||
font-family: var(--mono);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.16em;
|
||||
font-size: 0.68rem;
|
||||
font-weight: 500;
|
||||
color: var(--signal);
|
||||
}
|
||||
|
||||
.answer h2 {
|
||||
margin: 0;
|
||||
font-size: 1.15rem;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.byline {
|
||||
margin: 6px 0 0;
|
||||
color: var(--muted);
|
||||
font-family: var(--mono);
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
|
||||
.waiting { color: var(--muted); margin: 0; font-family: var(--mono); font-size: 0.8rem; }
|
||||
|
||||
.auth-wrap {
|
||||
width: min(420px, calc(100% - 32px));
|
||||
}
|
||||
|
||||
.auth-wrap h1 { font-size: 1.7rem; }
|
||||
|
||||
.site-footer {
|
||||
width: min(840px, calc(100% - 32px));
|
||||
margin: 0 auto;
|
||||
padding: 20px 0 calc(36px + var(--pad));
|
||||
color: var(--muted);
|
||||
font-family: var(--mono);
|
||||
font-size: 0.72rem;
|
||||
line-height: 1.55;
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
border: 0;
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.row {
|
||||
grid-template-columns: 3.4rem 3.5rem 1fr;
|
||||
padding: 16px 18px;
|
||||
}
|
||||
.rank { font-size: 1.35rem; }
|
||||
.panel-wrap { padding: 32px; }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.btn-primary:hover { filter: none; }
|
||||
}
|
||||
|
||||
.admin-users { margin-top: 20px; overflow-x: auto; }
|
||||
|
||||
.user-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.user-table th,
|
||||
.user-table td {
|
||||
text-align: left;
|
||||
padding: 12px 10px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.user-table th {
|
||||
color: var(--muted);
|
||||
font-family: var(--mono);
|
||||
font-size: 0.7rem;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.user-table .mono {
|
||||
font-family: var(--mono);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.user-actions form { margin: 0; display: inline; }
|
||||
|
||||
.user-actions .btn {
|
||||
min-height: 36px;
|
||||
padding: 0 12px;
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
|
||||
.profile-page h2 {
|
||||
margin: 40px 0 16px;
|
||||
font-size: 1.15rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.profile-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
max-width: 28rem;
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
.profile-avatar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
width: 96px;
|
||||
height: 96px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
border: 1px solid var(--line);
|
||||
background: var(--panel);
|
||||
}
|
||||
|
||||
.avatar-empty {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-family: var(--mono);
|
||||
font-size: 1.6rem;
|
||||
font-weight: 600;
|
||||
color: var(--signal);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.avatar-fields label { margin-bottom: 6px; display: block; }
|
||||
|
||||
.hint, .muted {
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
font-family: var(--mono);
|
||||
font-size: 0.72rem;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.profile-form select,
|
||||
.profile-form input[type="file"] {
|
||||
width: 100%;
|
||||
min-height: 44px;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 3px;
|
||||
background: var(--panel);
|
||||
color: var(--ink);
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.profile-form .btn { align-self: flex-start; margin-top: 8px; }
|
||||
|
||||
.profile-q-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.profile-q-list li {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-between;
|
||||
gap: 8px 16px;
|
||||
padding: 14px 0;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.profile-q-list a {
|
||||
color: var(--ink);
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.profile-q-list a:hover { color: var(--signal); }
|
||||
|
||||
.profile-q-list .meta {
|
||||
color: var(--muted);
|
||||
font-family: var(--mono);
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
Vendored
+1
File diff suppressed because one or more lines are too long
@@ -0,0 +1,53 @@
|
||||
{{define "admin-users"}}
|
||||
{{template "header" .}}
|
||||
<main id="main" class="wrap">
|
||||
<p class="eyebrow">Admin</p>
|
||||
<h1>Users</h1>
|
||||
{{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">
|
||||
<table class="user-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Username</th>
|
||||
<th scope="col">Role</th>
|
||||
<th scope="col">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .Users}}
|
||||
<tr>
|
||||
<td><span class="mono">{{.Username}}</span></td>
|
||||
<td>{{.Role}}</td>
|
||||
<td class="user-actions">
|
||||
{{if eq .Role "admin"}}
|
||||
<form method="post" action="/admin/users/{{.ID}}/role">
|
||||
<input type="hidden" name="_csrf" value="{{$.CSRF}}">
|
||||
<input type="hidden" name="role" value="user">
|
||||
<button type="submit" class="btn btn-ghost">Make user</button>
|
||||
</form>
|
||||
{{else}}
|
||||
<form method="post" action="/admin/users/{{.ID}}/role">
|
||||
<input type="hidden" name="_csrf" value="{{$.CSRF}}">
|
||||
<input type="hidden" name="role" value="admin">
|
||||
<button type="submit" class="btn btn-ghost">Make admin</button>
|
||||
</form>
|
||||
{{end}}
|
||||
</td>
|
||||
</tr>
|
||||
{{else}}
|
||||
<tr><td colspan="3">No users yet.</td></tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{{if .HasMore}}
|
||||
<p><a href="{{.NextCursor}}">Next page</a></p>
|
||||
{{end}}
|
||||
</main>
|
||||
{{template "footer" .}}
|
||||
{{end}}
|
||||
@@ -0,0 +1,73 @@
|
||||
{{define "header"}}
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||
<title>{{if .Title}}{{.Title}} · {{end}}Ask a Plumber First</title>
|
||||
<meta name="description" content="Daily plumbing questions, ranked like a hunt. Ask a 22-year Bay Area plumber.">
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Archivo:ital,wght@0,400;0,500;0,600;1,400&family=IBM+Plex+Mono:wght@400;500;600&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="/static/app.css">
|
||||
<script src="/static/htmx.min.js" defer></script>
|
||||
</head>
|
||||
<body>
|
||||
<a class="skip" href="#main">Skip to content</a>
|
||||
<header class="top">
|
||||
<div class="top-inner">
|
||||
<a class="logo" href="/">
|
||||
<svg class="mark" viewBox="0 0 32 32" aria-hidden="true">
|
||||
<circle cx="16" cy="16" r="14" fill="none" stroke="currentColor" stroke-width="2.25"/>
|
||||
<circle cx="16" cy="16" r="7.5" fill="none" stroke="currentColor" stroke-width="2.25"/>
|
||||
<circle cx="16" cy="16" r="3" fill="currentColor"/>
|
||||
</svg>
|
||||
<span class="logo-text">
|
||||
<span class="logo-name">Ask a Plumber First</span>
|
||||
<span class="tagline">{{locationTag .User}}</span>
|
||||
</span>
|
||||
</a>
|
||||
<nav class="top-nav" aria-label="Account">
|
||||
{{if not (isAdmin .User)}}
|
||||
<a class="btn btn-primary" href="/submit">Ask</a>
|
||||
{{end}}
|
||||
{{if .User}}
|
||||
<details class="account-menu">
|
||||
<summary class="btn btn-ghost account-menu-toggle" title="{{.User.Username}}">
|
||||
{{if .User.AvatarURL}}
|
||||
<img class="nav-avatar" src="{{.User.AvatarURL}}" alt="" width="28" height="28">
|
||||
{{else}}
|
||||
<span class="nav-avatar nav-avatar-ghost" aria-hidden="true">{{slice .User.Username 0 1}}</span>
|
||||
{{end}}
|
||||
<span class="who">{{.User.Username}}</span>
|
||||
<span class="account-menu-caret" aria-hidden="true"></span>
|
||||
</summary>
|
||||
<div class="account-menu-panel" role="menu">
|
||||
<a class="account-menu-item" role="menuitem" href="/profile">Profile</a>
|
||||
{{if isAdmin .User}}
|
||||
<a class="account-menu-item" role="menuitem" href="/admin/users">Users</a>
|
||||
{{end}}
|
||||
<form class="logout" method="post" action="/logout" role="none">
|
||||
<input type="hidden" name="_csrf" value="{{.CSRF}}">
|
||||
<button type="submit" class="account-menu-item" role="menuitem">Sign out</button>
|
||||
</form>
|
||||
</div>
|
||||
</details>
|
||||
{{else}}
|
||||
<a class="btn btn-ghost" href="/login">Sign in</a>
|
||||
{{end}}
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
<div id="flash">
|
||||
{{if .Flash}}<p class="banner" role="status">{{.Flash}}</p>{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{define "footer"}}
|
||||
<footer class="site-footer">
|
||||
<p>This site is not a substitute for a licensed plumber. Advice is general and based on the question as written. If you have a gas leak, flooding, or another emergency, leave the area if needed and call 911.</p>
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
@@ -0,0 +1,21 @@
|
||||
{{define "hunt"}}
|
||||
{{template "header" .}}
|
||||
<main id="main" class="wrap">
|
||||
<div class="hunt-head">
|
||||
<div>
|
||||
<p class="eyebrow">{{if .IsToday}}Live board{{else if .IsYesterday}}Prior board{{else}}Archive{{end}}</p>
|
||||
<h1>{{.Label}}</h1>
|
||||
</div>
|
||||
<nav class="date-nav" aria-label="Hunt date">
|
||||
<a class="chip{{if .IsToday}} is-on{{end}}" href="/">Today</a>
|
||||
<form class="date-form" action="/archive" method="get">
|
||||
<label class="sr-only" for="hunt-date">Pick a date</label>
|
||||
<input id="hunt-date" type="date" name="date" value="{{.Date}}" max="{{.Today}}"
|
||||
onchange="this.form.requestSubmit()">
|
||||
</form>
|
||||
</nav>
|
||||
</div>
|
||||
{{template "leaderboard" .}}
|
||||
</main>
|
||||
{{template "footer" .}}
|
||||
{{end}}
|
||||
@@ -0,0 +1,19 @@
|
||||
{{define "login"}}
|
||||
{{template "header" .}}
|
||||
<main id="main" class="wrap auth-wrap">
|
||||
<p class="eyebrow">Access</p>
|
||||
<h1>Sign in</h1>
|
||||
{{if .Error}}<p class="banner error" role="alert">{{.Error}}</p>{{end}}
|
||||
<form class="ask" method="post" action="/login">
|
||||
<input type="hidden" name="_csrf" value="{{.CSRF}}">
|
||||
<input type="hidden" name="next" value="{{.Next}}">
|
||||
<label for="username">Username</label>
|
||||
<input id="username" name="username" type="text" required maxlength="20" autocomplete="username" autocapitalize="off" spellcheck="false" value="{{.Username}}">
|
||||
<label for="password">Password</label>
|
||||
<input id="password" name="password" type="password" required minlength="8" autocomplete="current-password">
|
||||
<button type="submit" class="btn btn-primary">Sign in</button>
|
||||
</form>
|
||||
<p class="switch">New here? <a href="/register">Create an account</a></p>
|
||||
</main>
|
||||
{{template "footer" .}}
|
||||
{{end}}
|
||||
@@ -0,0 +1,12 @@
|
||||
{{define "answer"}}
|
||||
<section id="answer-block" class="answer{{if .Answer}} is-in{{end}}">
|
||||
{{if .Answer}}
|
||||
<p class="answer-kicker">Shop response</p>
|
||||
<h2>Answer</h2>
|
||||
<p class="byline">{{.Answer.AuthorName}} · 22 years, Bay Area</p>
|
||||
<p class="answer-body">{{.Answer.Body}}</p>
|
||||
{{else}}
|
||||
<p class="waiting">No answer yet. Check back after the hunt.</p>
|
||||
{{end}}
|
||||
</section>
|
||||
{{end}}
|
||||
@@ -0,0 +1,37 @@
|
||||
{{define "leaderboard"}}
|
||||
<ol id="leaderboard" class="board" start="1">
|
||||
{{if not .Questions}}
|
||||
<li class="empty">
|
||||
{{if eq .Date .Today}}
|
||||
<p class="empty-kicker">Queue empty</p>
|
||||
<p>No questions yet. Be the first to <a href="/submit">ask</a>.</p>
|
||||
{{else}}
|
||||
<p>No questions on this day.</p>
|
||||
{{end}}
|
||||
</li>
|
||||
{{else}}
|
||||
{{range $i, $q := .Questions}}
|
||||
<li class="row">
|
||||
<span class="rank" aria-hidden="true">{{rank $i}}</span>
|
||||
{{template "vote" (voteCtx $.User $.CSRF "list" $.Date $q)}}
|
||||
<div class="row-body">
|
||||
<a class="q-title" href="/questions/{{$q.ID}}">{{$q.Title}}</a>
|
||||
<p class="meta">
|
||||
<span>{{$q.AuthorName}}</span>
|
||||
{{if $q.City}}<span class="dot" aria-hidden="true">·</span><span>{{$q.City}}</span>{{end}}
|
||||
{{if $q.Answered}}<span class="badge">Answered</span>{{end}}
|
||||
</p>
|
||||
{{if isAdmin $.User}}
|
||||
<form class="inline-hide" method="post" action="/questions/{{$q.ID}}/hide"
|
||||
hx-post="/questions/{{$q.ID}}/hide" hx-target="#leaderboard" hx-swap="outerHTML">
|
||||
<input type="hidden" name="_csrf" value="{{$.CSRF}}">
|
||||
<input type="hidden" name="view" value="list">
|
||||
<button type="submit" class="linkish">Hide</button>
|
||||
</form>
|
||||
{{end}}
|
||||
</div>
|
||||
</li>
|
||||
{{end}}
|
||||
{{end}}
|
||||
</ol>
|
||||
{{end}}
|
||||
@@ -0,0 +1,8 @@
|
||||
{{define "signin-prompt"}}
|
||||
<p class="banner" role="status">
|
||||
Sign in to vote or ask a question.
|
||||
<a href="/login">Sign in</a>
|
||||
<span class="dot" aria-hidden="true">·</span>
|
||||
<a href="/register">Create an account</a>
|
||||
</p>
|
||||
{{end}}
|
||||
@@ -0,0 +1,37 @@
|
||||
{{define "vote"}}
|
||||
<div id="vote-{{.Question.ID}}" class="vote">
|
||||
{{if .User}}
|
||||
<form method="post" action="/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}}>
|
||||
<input type="hidden" name="_csrf" value="{{.CSRF}}">
|
||||
{{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="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}}">
|
||||
<svg width="18" height="18" viewBox="0 0 18 18" aria-hidden="true"><path d="M9 3.5 15 12H3z" fill="currentColor"/></svg>
|
||||
</button>
|
||||
</form>
|
||||
<span class="score" aria-label="Net score {{.Question.Score}}">{{.Question.Score}}</span>
|
||||
<form method="post" action="/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}}>
|
||||
<input type="hidden" name="_csrf" value="{{.CSRF}}">
|
||||
{{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="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}}">
|
||||
<svg width="18" height="18" viewBox="0 0 18 18" aria-hidden="true"><path d="M9 14.5 3 6h12z" fill="currentColor"/></svg>
|
||||
</button>
|
||||
</form>
|
||||
{{else}}
|
||||
<a class="vote-btn" href="/login" hx-get="/auth/prompt" hx-target="#flash" aria-label="Sign in to upvote">
|
||||
<svg width="18" height="18" viewBox="0 0 18 18" aria-hidden="true"><path d="M9 3.5 15 12H3z" fill="currentColor"/></svg>
|
||||
</a>
|
||||
<span class="score" aria-label="Net score {{.Question.Score}}">{{.Question.Score}}</span>
|
||||
<a class="vote-btn" href="/login" hx-get="/auth/prompt" hx-target="#flash" aria-label="Sign in to downvote">
|
||||
<svg width="18" height="18" viewBox="0 0 18 18" aria-hidden="true"><path d="M9 14.5 3 6h12z" fill="currentColor"/></svg>
|
||||
</a>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
@@ -0,0 +1,57 @@
|
||||
{{define "profile"}}
|
||||
{{template "header" .}}
|
||||
<main id="main" class="wrap profile-page">
|
||||
<p class="eyebrow">Account</p>
|
||||
<h1>Profile</h1>
|
||||
{{if .Error}}<p class="banner error" role="alert">{{.Error}}</p>{{end}}
|
||||
|
||||
<form class="profile-form" method="post" action="/profile" enctype="multipart/form-data">
|
||||
<input type="hidden" name="_csrf" value="{{.CSRF}}">
|
||||
|
||||
<div class="profile-avatar">
|
||||
{{if .User.AvatarURL}}
|
||||
<img class="avatar" src="{{.User.AvatarURL}}" alt="" width="96" height="96">
|
||||
{{else}}
|
||||
<div class="avatar avatar-empty" aria-hidden="true">{{slice .User.Username 0 1}}</div>
|
||||
{{end}}
|
||||
<div class="avatar-fields">
|
||||
<label for="avatar">Profile picture</label>
|
||||
{{if .UploadsEnabled}}
|
||||
<input id="avatar" name="avatar" type="file" accept="image/jpeg,image/png,image/webp,.jpg,.jpeg,.png,.webp">
|
||||
<p class="hint">JPEG, PNG, or WebP · max 2MB</p>
|
||||
{{else}}
|
||||
<p class="hint">Avatar uploads are not configured on this server.</p>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label for="state">State</label>
|
||||
<select id="state" name="state">
|
||||
<option value=""{{if eq .StateVal ""}} selected{{end}}>Prefer not to say</option>
|
||||
{{range .States}}
|
||||
<option value="{{.Code}}"{{if eq $.StateVal .Code}} selected{{end}}>{{.Name}}</option>
|
||||
{{end}}
|
||||
</select>
|
||||
<p class="hint">Optional. Sharing your state helps answers line up with local plumbing codes.</p>
|
||||
|
||||
<button type="submit" class="btn btn-primary">Save profile</button>
|
||||
</form>
|
||||
|
||||
<section class="profile-questions" aria-labelledby="profile-q-heading">
|
||||
<h2 id="profile-q-heading">{{.QuestionsLabel}}</h2>
|
||||
{{if .Questions}}
|
||||
<ul class="profile-q-list">
|
||||
{{range .Questions}}
|
||||
<li>
|
||||
<a href="/questions/{{.ID}}">{{.Title}}</a>
|
||||
<span class="meta">{{.HuntDate}}</span>
|
||||
</li>
|
||||
{{end}}
|
||||
</ul>
|
||||
{{else}}
|
||||
<p class="muted">Nothing here yet.</p>
|
||||
{{end}}
|
||||
</section>
|
||||
</main>
|
||||
{{template "footer" .}}
|
||||
{{end}}
|
||||
@@ -0,0 +1,37 @@
|
||||
{{define "question"}}
|
||||
{{template "header" .}}
|
||||
<main id="main" class="wrap question-page">
|
||||
<p class="crumb"><a href="{{if eq .Question.HuntDate .Today}}/{{else}}/hunt/{{.Question.HuntDate}}{{end}}">← {{pacificLabel .Question.HuntDate}}</a></p>
|
||||
<article class="q-detail">
|
||||
{{template "vote" (voteCtx .User .CSRF "question" .Question.HuntDate .Question)}}
|
||||
<div>
|
||||
<h1>{{.Question.Title}}</h1>
|
||||
<p class="meta">
|
||||
<span>{{.Question.AuthorName}}</span>
|
||||
{{if .Question.City}}<span class="dot" aria-hidden="true">·</span><span>{{.Question.City}}</span>{{end}}
|
||||
<span class="dot" aria-hidden="true">·</span>
|
||||
<a href="{{if eq .Question.HuntDate .Today}}/{{else}}/hunt/{{.Question.HuntDate}}{{end}}">{{.Question.HuntDate}}</a>
|
||||
</p>
|
||||
<p class="q-body">{{.Question.Body}}</p>
|
||||
{{if isAdmin .User}}
|
||||
<form method="post" action="/questions/{{.Question.ID}}/hide"
|
||||
hx-post="/questions/{{.Question.ID}}/hide" hx-target="body">
|
||||
<input type="hidden" name="_csrf" value="{{.CSRF}}">
|
||||
<button type="submit" class="linkish">Hide this question</button>
|
||||
</form>
|
||||
{{end}}
|
||||
</div>
|
||||
</article>
|
||||
{{template "answer" .}}
|
||||
{{if isAdmin .User}}
|
||||
<form class="answer-form" method="post" action="/questions/{{.Question.ID}}/answer"
|
||||
hx-post="/questions/{{.Question.ID}}/answer" hx-target="#answer-block" hx-swap="outerHTML">
|
||||
<input type="hidden" name="_csrf" value="{{.CSRF}}">
|
||||
<label for="answer-body">{{if .Answer}}Edit answer{{else}}Write the answer{{end}}</label>
|
||||
<textarea id="answer-body" name="body" rows="8" required maxlength="12000">{{if .Answer}}{{.Answer.Body}}{{end}}</textarea>
|
||||
<button type="submit" class="btn btn-primary">Save answer</button>
|
||||
</form>
|
||||
{{end}}
|
||||
</main>
|
||||
{{template "footer" .}}
|
||||
{{end}}
|
||||
@@ -0,0 +1,23 @@
|
||||
{{define "register"}}
|
||||
{{template "header" .}}
|
||||
<main id="main" class="wrap auth-wrap">
|
||||
<p class="eyebrow">New account</p>
|
||||
<h1>Create an account</h1>
|
||||
{{if .Error}}<p class="banner error" role="alert">{{.Error}}</p>{{end}}
|
||||
<form class="ask" method="post" action="/register">
|
||||
<input type="hidden" name="_csrf" value="{{.CSRF}}">
|
||||
<label for="username">Username</label>
|
||||
<input id="username" name="username" type="text" required minlength="3" maxlength="20" pattern="[A-Za-z0-9_]+" autocomplete="username" autocapitalize="off" spellcheck="false" value="{{.Username}}">
|
||||
<p class="hint">3–20 letters, numbers, or underscores.</p>
|
||||
<label for="password">Password</label>
|
||||
<input id="password" name="password" type="password" required minlength="8" maxlength="72" autocomplete="new-password">
|
||||
<p class="hint">At least 8 characters (max 72 bytes).</p>
|
||||
<label for="setup_secret">Setup secret <span class="hint">(optional, first install only)</span></label>
|
||||
<input id="setup_secret" name="setup_secret" type="password" autocomplete="off">
|
||||
<p class="hint">Only needed once to create the first admin. Leave blank otherwise.</p>
|
||||
<button type="submit" class="btn btn-primary">Create account</button>
|
||||
</form>
|
||||
<p class="switch">Already have an account? <a href="/login">Sign in</a></p>
|
||||
</main>
|
||||
{{template "footer" .}}
|
||||
{{end}}
|
||||
@@ -0,0 +1,19 @@
|
||||
{{define "submit"}}
|
||||
{{template "header" .}}
|
||||
<main id="main" class="wrap panel-wrap">
|
||||
<h1>Ask a question</h1>
|
||||
<p class="lede">It lands on today’s hunt (Pacific time). People vote; the ranking resets at midnight PT.</p>
|
||||
{{if .Error}}<p class="banner error" role="alert">{{.Error}}</p>{{end}}
|
||||
<form class="ask" method="post" action="/submit">
|
||||
<input type="hidden" name="_csrf" value="{{.CSRF}}">
|
||||
<label for="title">Title</label>
|
||||
<input id="title" name="title" type="text" required maxlength="120" value="{{.TitleVal}}" placeholder="Water heater popping after showers">
|
||||
<label for="body">What is going on?</label>
|
||||
<textarea id="body" name="body" rows="8" required maxlength="8000" placeholder="Age of the house, what you already tried, where you are in the Bay if it helps.">{{.BodyVal}}</textarea>
|
||||
<label for="city">City <span class="optional">(optional)</span></label>
|
||||
<input id="city" name="city" type="text" maxlength="80" value="{{.CityVal}}" placeholder="Oakland">
|
||||
<button type="submit" class="btn btn-primary">Submit to today’s hunt</button>
|
||||
</form>
|
||||
</main>
|
||||
{{template "footer" .}}
|
||||
{{end}}
|
||||
@@ -0,0 +1,30 @@
|
||||
# Plumber — follow-ups
|
||||
|
||||
From the project review. Priority order within each section.
|
||||
|
||||
## Done recently
|
||||
|
||||
- [x] **Persist sessions** — Custom sqlc-backed `SessionStore` (scs API kept; no `postgresstore`).
|
||||
- [x] **Drop Dockerfile** — DigitalOcean App Platform buildpack from `go.mod`.
|
||||
- [x] **Rune-safe truncation** — Form fields truncate by runes.
|
||||
- [x] **Admin bootstrap** — One-time `ADMIN_SETUP_SECRET` on register (not username alone); `/admin/users` for promote/demote.
|
||||
- [x] **Graceful shutdown** — Signal-aware `http.Server.Shutdown` with timeouts.
|
||||
- [x] **Handler tests** — Vote HTMX, answer/hide, CSRF, session rotation via in-memory `Store` (no Postgres for web suite).
|
||||
- [x] **App Platform listen port** — Prefers `PORT`, then `LISTEN`, then `:8080`.
|
||||
- [x] **Prod DB = PlanetScale Postgres** — Required `DATABASE_URL`; DSN cleanup for PlanetScale/libpq-only params.
|
||||
- [x] **Auth hardening** — Rate limits, timing-safe login, password ≤72 bytes, logout destroys session, Secure cookies required when `PORT` is set.
|
||||
|
||||
## Docs & ops
|
||||
|
||||
- [ ] **README** — How to run locally, env vars (from `.env.example`), admin bootstrap, PlanetScale `DATABASE_URL`, App Platform notes (`PORT`, `SECURE_COOKIE=1`).
|
||||
- [ ] **Migrations story** — Schema is applied on boot from `schema.sql`. OK for v1; plan real migrations before schema drifts.
|
||||
|
||||
## Smaller / later
|
||||
|
||||
- [ ] Cursor pagination UI when hunt/profile lists hit their row limits.
|
||||
- [ ] Optional Postgres integration tests (`TEST_DATABASE_URL`) for sqlc SessionStore / advisory locks.
|
||||
|
||||
## Suggested order of attack
|
||||
|
||||
1. Short README (run, env, admin setup secret, App Platform + PlanetScale).
|
||||
2. Migrations plan before the next schema change.
|
||||
Reference in New Issue
Block a user