Initial commit: runnable Ask a Plumber First server.

This commit is contained in:
2026-08-21 23:30:15 -07:00
parent 7bc79af954
commit d167b9216a
38 changed files with 4174 additions and 0 deletions
+33
View File
@@ -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
+18
View File
@@ -0,0 +1,18 @@
# Local listen address (ignored when PORT is set, e.g. on App Platform)
LISTEN=:8080
DATA_PATH=data.db
# Optional: first matching registrant becomes admin only if no admin exists yet.
# Later promote/demote via /admin/users (admins only).
ADMIN_USERNAME=yourusername
# Set to 1 when serving over HTTPS
SECURE_COOKIE=0
# Prod (PlanetScale Postgres): paste the dashboard URI. When set, SQLite is ignored.
# Use 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
# 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
+8
View File
@@ -0,0 +1,8 @@
/data.db
/data.db-*
/.test.db
/.test.db-*
/bin/
/tmp/
.env
*.exe
+74
View File
@@ -0,0 +1,74 @@
package main
import (
"log"
"net/http"
"os"
"strings"
"github.com/joho/godotenv"
"plumber"
"plumber/internal/blob"
"plumber/internal/store"
"plumber/internal/web"
)
func main() {
_ = godotenv.Load()
listen := listenAddr()
st, err := store.Connect(os.Getenv("DATABASE_URL"), env("DATA_PATH", "data.db"), plumber.SchemaSQL)
if err != nil {
log.Fatalf("database: %v", err)
}
defer st.Close()
if os.Getenv("DATABASE_URL") != "" {
log.Printf("database: postgres")
} else {
log.Printf("database: sqlite")
}
uploader := blob.NewSpaces(blob.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"),
})
if uploader.Enabled() {
log.Printf("avatars: digitalocean spaces")
} else {
log.Printf("avatars: uploads disabled (set SPACES_* to enable)")
}
srv, err := web.New(st, st.SessionStore(), plumber.TemplateFS, plumber.StaticFS, web.Config{
AdminUsername: os.Getenv("ADMIN_USERNAME"),
SecureCookie: os.Getenv("SECURE_COOKIE") == "1",
Blob: uploader,
})
if err != nil {
log.Fatalf("server: %v", err)
}
log.Printf("listening on %s", listen)
if err := http.ListenAndServe(listen, srv.Handler()); err != nil {
log.Fatal(err)
}
}
// 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
}
+12
View File
@@ -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
+42
View File
@@ -0,0 +1,42 @@
module plumber
go 1.25.0
require (
github.com/alexedwards/scs/postgresstore v0.0.0-20251002162104-209de6e426de
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
modernc.org/sqlite v1.57.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/dustin/go-humanize v1.0.1 // 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
github.com/mattn/go-isatty v0.0.24 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
golang.org/x/sync v0.22.0 // indirect
golang.org/x/sys v0.47.0 // indirect
golang.org/x/text v0.41.0 // indirect
modernc.org/libc v1.74.4 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
)
+110
View File
@@ -0,0 +1,110 @@
github.com/alexedwards/scs/postgresstore v0.0.0-20251002162104-209de6e426de h1:LDrMkjj4OCCQsq9SvIPQV1l3leMxqXZTCTxDFwMrqTE=
github.com/alexedwards/scs/postgresstore v0.0.0-20251002162104-209de6e426de/go.mod h1:TDDdV/xnjj+/4zBQ9a2k+i2AbuAdY7SQjPUh5zoTZ3M=
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/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
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/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo=
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk=
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/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
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/lib/pq v1.4.0 h1:TmtCFbH+Aw0AixwyttznSMQDgbR5Yed/Gg6S8Funrhc=
github.com/lib/pq v1.4.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
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/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
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/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk=
golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40=
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/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE=
golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk=
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=
modernc.org/cc/v4 v4.29.1 h1:MKgdCV3WykTSPqpVrnxdEDS0HEd2FHpKZDzxzU5LyeI=
modernc.org/cc/v4 v4.29.1/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
modernc.org/ccgo/v4 v4.34.6 h1:sBgfIwyN0TQ9C5hwIeuqyeAKyMWnbvj2fvpF4L11uzU=
modernc.org/ccgo/v4 v4.34.6/go.mod h1:SZ8YcN9NG7XVsQYdm6jYBvi8PQP1qi+kqB6OhjqI3Fk=
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/gc/v3 v3.1.4 h1:2g65LGVSmFQrXeITAw97x7hCRvZFcyE1uDP+7Vng7JI=
modernc.org/gc/v3 v3.1.4/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.74.4 h1:fX1Omw4o2/1C2iRkkIsrQTasJQldLhRmuPreXLoWs9k=
modernc.org/libc v1.74.4/go.mod h1:eeQAS9W3sZeKYMFubydxJpII9ybHWshk+7or7bLG9co=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.57.0 h1:qNQP6xnx5M0ISNtlnxoOX0+cD5bJ0/gr9aMmndFczzg=
modernc.org/sqlite v1.57.0/go.mod h1:yCJ2cmAaIkHQ25oXWrF8H4O1lIfPYPR26yCEDj2P3pQ=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
+88
View File
@@ -0,0 +1,88 @@
package blob
import (
"context"
"fmt"
"io"
"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, key string, body io.Reader, contentType string, size int64) (publicURL string, err error)
}
// Disabled is a no-op uploader used when Spaces is not configured.
type Disabled struct{}
func (Disabled) Enabled() bool { return false }
func (Disabled) Upload(context.Context, string, io.Reader, string, int64) (string, error) {
return "", fmt.Errorf("avatar uploads are not configured")
}
// 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
}
// 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}
}
type spaces struct {
client *s3.Client
cfg SpacesConfig
}
func (s *spaces) Enabled() bool { return true }
func (s *spaces) Upload(ctx context.Context, key string, body io.Reader, contentType string, size int64) (string, error) {
key = strings.TrimPrefix(key, "/")
input := &s3.PutObjectInput{
Bucket: aws.String(s.cfg.Bucket),
Key: aws.String(key),
Body: body,
ContentType: aws.String(contentType),
ACL: types.ObjectCannedACLPublicRead,
}
if size > 0 {
input.ContentLength = aws.Int64(size)
}
if _, err := s.client.PutObject(ctx, input); err != nil {
return "", err
}
if s.cfg.CDNBase != "" {
return s.cfg.CDNBase + "/" + key, nil
}
// Virtual-hostedstyle Spaces URL.
host := strings.TrimPrefix(s.cfg.Endpoint, "https://")
host = strings.TrimPrefix(host, "http://")
return fmt.Sprintf("https://%s.%s/%s", s.cfg.Bucket, host, key), nil
}
+56
View File
@@ -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 ""
}
+53
View File
@@ -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()
}
+33
View File
@@ -0,0 +1,33 @@
package store
import (
"context"
"errors"
)
// ErrLastAdmin is returned when demoting the only remaining admin.
var ErrLastAdmin = errors.New("cannot demote the last admin")
// DB is the persistence API used by the web layer.
// Named DB to avoid colliding with scs.Store.
type DB interface {
CreateUser(ctx context.Context, username, passwordHash string, asAdmin bool) (*User, error)
UserByID(ctx context.Context, id string) (*User, error)
UserByUsername(ctx context.Context, username string) (*User, error)
CountAdmins(ctx context.Context) (int, error)
ListUsers(ctx context.Context) ([]User, error)
SetRole(ctx context.Context, userID, role string) error
CreateQuestion(ctx context.Context, authorID, title, body, city string) (*RankedQuestion, error)
ListHunt(ctx context.Context, huntDate, viewerID string) ([]RankedQuestion, error)
GetQuestion(ctx context.Context, id, viewerID string) (*RankedQuestion, error)
Vote(ctx context.Context, userID, questionID string, value int) error
GetAnswer(ctx context.Context, questionID string) (*Answer, error)
UpsertAnswer(ctx context.Context, questionID, authorID, body string) error
HideQuestion(ctx context.Context, id string) error
UpdateProfile(ctx context.Context, userID, state, avatarURL string) error
ListQuestionsByAuthor(ctx context.Context, authorID string) ([]RankedQuestion, error)
ListQuestionsAnsweredBy(ctx context.Context, adminID string) ([]RankedQuestion, error)
}
// Compile-time check: *Store implements DB.
var _ DB = (*Store)(nil)
+28
View File
@@ -0,0 +1,28 @@
package store
import (
"database/sql"
"fmt"
"strings"
)
func migrateUserProfileColumns(db *sql.DB, dialect string) error {
cols := []string{"avatar_url", "state"}
for _, col := range cols {
var stmt string
switch dialect {
case dialectPostgres:
stmt = fmt.Sprintf(`ALTER TABLE users ADD COLUMN IF NOT EXISTS %s TEXT NOT NULL DEFAULT ''`, col)
default:
stmt = fmt.Sprintf(`ALTER TABLE users ADD COLUMN %s TEXT NOT NULL DEFAULT ''`, col)
}
if _, err := db.Exec(stmt); err != nil {
// SQLite errors when the column already exists.
if dialect == dialectSQLite && strings.Contains(strings.ToLower(err.Error()), "duplicate column") {
continue
}
return fmt.Errorf("add column %s: %w", col, err)
}
}
return nil
}
+130
View File
@@ -0,0 +1,130 @@
package store
import (
"database/sql"
"fmt"
"net/url"
"strconv"
"strings"
"time"
_ "github.com/jackc/pgx/v5/stdlib"
)
const (
dialectSQLite = "sqlite"
dialectPostgres = "postgres"
)
func rebind(query string) string {
n := 0
var b strings.Builder
for i := 0; i < len(query); i++ {
if query[i] == '?' {
n++
b.WriteByte('$')
b.WriteString(strconv.Itoa(n))
continue
}
b.WriteByte(query[i])
}
return b.String()
}
func (s *Store) q(query string) string {
if s.dialect == dialectPostgres {
return rebind(query)
}
return query
}
func applySchema(db *sql.DB, schema string) error {
for _, stmt := range strings.Split(schema, ";") {
stmt = strings.TrimSpace(stmt)
if stmt == "" {
continue
}
upper := strings.ToUpper(stmt)
if strings.HasPrefix(upper, "PRAGMA") {
continue
}
if _, err := db.Exec(stmt); err != nil {
return fmt.Errorf("%w: %s", err, stmt)
}
}
return nil
}
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
}
func OpenPostgres(databaseURL, schema string) (*Store, error) {
return openPostgres(databaseURL, schema, 5*time.Minute)
}
// OpenPostgresWithoutSessionCleanup opens Postgres without a session cleanup goroutine (for tests).
func OpenPostgresWithoutSessionCleanup(databaseURL, schema string) (*Store, error) {
return openPostgres(databaseURL, schema, 0)
}
func openPostgres(databaseURL, schema string, sessionCleanup time.Duration) (*Store, error) {
dsn, err := postgresDSN(databaseURL)
if err != nil {
return nil, err
}
db, err := sql.Open("pgx", dsn)
if err != nil {
return nil, err
}
db.SetMaxOpenConns(20)
db.SetMaxIdleConns(5)
if err := db.Ping(); err != nil {
_ = db.Close()
return nil, fmt.Errorf("postgres ping: %w", err)
}
if err := applySchema(db, schema); err != nil {
_ = db.Close()
return nil, fmt.Errorf("apply schema: %w", err)
}
if err := applySessionsSchema(db, dialectPostgres); err != nil {
_ = db.Close()
return nil, fmt.Errorf("apply sessions schema: %w", err)
}
if err := migrateUserProfileColumns(db, dialectPostgres); err != nil {
_ = db.Close()
return nil, fmt.Errorf("migrate profile columns: %w", err)
}
st := &Store{db: db, dialect: dialectPostgres}
st.initSessionStore(sessionCleanup)
return st, nil
}
// Connect uses PlanetScale Postgres when DATABASE_URL is set, otherwise SQLite.
func Connect(databaseURL, sqlitePath, schema string) (*Store, error) {
if strings.TrimSpace(databaseURL) != "" {
return OpenPostgres(databaseURL, schema)
}
if sqlitePath == "" {
sqlitePath = "data.db"
}
return Open(sqlitePath, schema)
}
+41
View File
@@ -0,0 +1,41 @@
package store
import "testing"
func TestRebindPostgresPlaceholders(t *testing.T) {
got := rebind(`SELECT a FROM t WHERE x = ? AND y = ?`)
want := `SELECT a FROM t WHERE x = $1 AND y = $2`
if got != want {
t.Fatalf("got %q", got)
}
}
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 !containsAny(out, "sslmode=verify-full") {
t.Fatalf("missing default sslmode: %s", out)
}
}
func containsAny(s string, parts ...string) bool {
for _, p := range parts {
if len(p) > 0 && (len(s) >= len(p)) {
for i := 0; i+len(p) <= len(s); i++ {
if s[i:i+len(p)] == p {
return true
}
}
}
}
return false
}
+154
View File
@@ -0,0 +1,154 @@
package store
import (
"database/sql"
"log"
"time"
"github.com/alexedwards/scs/postgresstore"
"github.com/alexedwards/scs/v2"
)
const sessionsSchemaSQLite = `
CREATE TABLE IF NOT EXISTS sessions (
token TEXT PRIMARY KEY,
data BLOB NOT NULL,
expiry REAL NOT NULL
);
CREATE INDEX IF NOT EXISTS sessions_expiry_idx ON sessions(expiry);
`
const sessionsSchemaPostgres = `
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);
`
func applySessionsSchema(db *sql.DB, dialect string) error {
schema := sessionsSchemaSQLite
if dialect == dialectPostgres {
schema = sessionsSchemaPostgres
}
return applySchema(db, schema)
}
type sessionStopper interface {
StopCleanup()
}
// SessionStore returns the scs store backed by this database.
func (s *Store) SessionStore() scs.Store {
return s.sessionStore
}
func (s *Store) initSessionStore(cleanupInterval time.Duration) {
switch s.dialect {
case dialectPostgres:
ps := postgresstore.NewWithCleanupInterval(s.db, cleanupInterval)
s.sessionStore = ps
s.sessionStopper = ps
default:
ss := newSQLiteSessionStore(s.db, cleanupInterval)
s.sessionStore = ss
s.sessionStopper = ss
}
}
// sqliteSessionStore is a modernc-safe scs.Store (uses ? placeholders).
type sqliteSessionStore struct {
db *sql.DB
stopCleanup chan bool
}
func newSQLiteSessionStore(db *sql.DB, cleanupInterval time.Duration) *sqliteSessionStore {
s := &sqliteSessionStore{db: db}
if cleanupInterval > 0 {
s.stopCleanup = make(chan bool)
go s.startCleanup(cleanupInterval)
}
return s
}
func (s *sqliteSessionStore) Find(token string) ([]byte, bool, error) {
var b []byte
err := s.db.QueryRow(
`SELECT data FROM sessions WHERE token = ? AND julianday('now') < expiry`,
token,
).Scan(&b)
if err == sql.ErrNoRows {
return nil, false, nil
}
if err != nil {
return nil, false, err
}
return b, true, nil
}
func (s *sqliteSessionStore) Commit(token string, b []byte, expiry time.Time) error {
_, err := s.db.Exec(
`REPLACE INTO sessions (token, data, expiry) VALUES (?, ?, julianday(?))`,
token,
b,
expiry.UTC().Format("2006-01-02T15:04:05.999"),
)
return err
}
func (s *sqliteSessionStore) Delete(token string) error {
_, err := s.db.Exec(`DELETE FROM sessions WHERE token = ?`, token)
return err
}
func (s *sqliteSessionStore) All() (map[string][]byte, error) {
rows, err := s.db.Query(`SELECT token, data FROM sessions WHERE julianday('now') < expiry`)
if err != nil {
return nil, err
}
defer rows.Close()
out := make(map[string][]byte)
for rows.Next() {
var token string
var data []byte
if err := rows.Scan(&token, &data); err != nil {
return nil, err
}
out[token] = data
}
return out, rows.Err()
}
func (s *sqliteSessionStore) startCleanup(interval time.Duration) {
ticker := time.NewTicker(interval)
for {
select {
case <-ticker.C:
if err := s.deleteExpired(); err != nil {
log.Println(err)
}
case <-s.stopCleanup:
ticker.Stop()
return
}
}
}
func (s *sqliteSessionStore) StopCleanup() {
if s.stopCleanup != nil {
s.stopCleanup <- true
}
}
func (s *sqliteSessionStore) deleteExpired() error {
_, err := s.db.Exec(`DELETE FROM sessions WHERE expiry < julianday('now')`)
return err
}
// Ensure interface compliance.
var (
_ scs.Store = (*sqliteSessionStore)(nil)
_ scs.IterableStore = (*sqliteSessionStore)(nil)
_ sessionStopper = (*sqliteSessionStore)(nil)
)
+406
View File
@@ -0,0 +1,406 @@
package store
import (
"context"
"database/sql"
"fmt"
"strings"
"time"
"github.com/alexedwards/scs/v2"
"github.com/google/uuid"
_ "modernc.org/sqlite"
"plumber/internal/pacific"
)
type Store struct {
db *sql.DB
dialect string
sessionStore scs.Store
sessionStopper sessionStopper
}
type User struct {
ID string
Username string
Name string
Role string
AvatarURL string
State string
CreatedAt string
PasswordHash string
}
func (u *User) Admin() bool {
return u != nil && u.Role == "admin"
}
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
}
type Answer struct {
QuestionID string
AuthorID string
AuthorName string
Body string
CreatedAt string
UpdatedAt string
}
func Open(path, schema string) (*Store, error) {
return openSQLite(path, schema, 5*time.Minute)
}
// OpenWithoutSessionCleanup opens SQLite without a session cleanup goroutine (for tests).
func OpenWithoutSessionCleanup(path, schema string) (*Store, error) {
return openSQLite(path, schema, 0)
}
func openSQLite(path, schema string, sessionCleanup time.Duration) (*Store, error) {
dsn := path
if !strings.Contains(dsn, "?") {
dsn += "?_pragma=foreign_keys(1)&_pragma=busy_timeout(5000)&_pragma=journal_mode(WAL)"
}
db, err := sql.Open("sqlite", dsn)
if err != nil {
return nil, err
}
db.SetMaxOpenConns(1)
if _, err := db.Exec(schema); err != nil {
_ = db.Close()
return nil, fmt.Errorf("apply schema: %w", err)
}
if err := applySessionsSchema(db, dialectSQLite); err != nil {
_ = db.Close()
return nil, fmt.Errorf("apply sessions schema: %w", err)
}
if err := migrateUserProfileColumns(db, dialectSQLite); err != nil {
_ = db.Close()
return nil, fmt.Errorf("migrate profile columns: %w", err)
}
st := &Store{db: db, dialect: dialectSQLite}
st.initSessionStore(sessionCleanup)
return st, nil
}
func (s *Store) Close() error {
if s.sessionStopper != nil {
s.sessionStopper.StopCleanup()
s.sessionStopper = nil
}
return s.db.Close()
}
func (s *Store) CreateUser(ctx context.Context, username, passwordHash string, asAdmin bool) (*User, error) {
username = NormalizeUsername(username)
role := "user"
if asAdmin {
role = "admin"
}
u := &User{
ID: uuid.NewString(),
Username: username,
Name: username,
Role: role,
PasswordHash: passwordHash,
CreatedAt: time.Now().UTC().Format(time.RFC3339),
}
_, err := s.db.ExecContext(ctx, s.q(`INSERT INTO users (id, username, name, password_hash, role, avatar_url, state, created_at) VALUES (?, ?, ?, ?, ?, '', '', ?)`),
u.ID, u.Username, u.Name, u.PasswordHash, u.Role, u.CreatedAt)
if err != nil {
return nil, err
}
return u, nil
}
func (s *Store) CountAdmins(ctx context.Context) (int, error) {
var n int
err := s.db.QueryRowContext(ctx, s.q(`SELECT COUNT(*) FROM users WHERE role = ?`), "admin").Scan(&n)
return n, err
}
func (s *Store) ListUsers(ctx context.Context) ([]User, error) {
rows, err := s.db.QueryContext(ctx, s.q(`SELECT id, username, name, role, avatar_url, state, created_at FROM users ORDER BY created_at ASC`))
if err != nil {
return nil, err
}
defer rows.Close()
var out []User
for rows.Next() {
var u User
if err := rows.Scan(&u.ID, &u.Username, &u.Name, &u.Role, &u.AvatarURL, &u.State, &u.CreatedAt); err != nil {
return nil, err
}
out = append(out, u)
}
return out, rows.Err()
}
func (s *Store) SetRole(ctx context.Context, userID, role string) error {
if role != "user" && role != "admin" {
return fmt.Errorf("invalid role")
}
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
var current string
err = tx.QueryRowContext(ctx, s.q(`SELECT role FROM users WHERE id = ?`), userID).Scan(&current)
if err != nil {
return err
}
if current == "admin" && role == "user" {
var n int
if err := tx.QueryRowContext(ctx, s.q(`SELECT COUNT(*) FROM users WHERE role = ?`), "admin").Scan(&n); err != nil {
return err
}
if n <= 1 {
return ErrLastAdmin
}
}
res, err := tx.ExecContext(ctx, s.q(`UPDATE users SET role = ? WHERE id = ?`), role, userID)
if err != nil {
return err
}
aff, err := res.RowsAffected()
if err != nil {
return err
}
if aff == 0 {
return sql.ErrNoRows
}
return tx.Commit()
}
func (s *Store) UserByID(ctx context.Context, id string) (*User, error) {
return scanUser(s.db.QueryRowContext(ctx, s.q(`SELECT id, username, name, role, avatar_url, state, created_at FROM users WHERE id = ?`), id), false)
}
func (s *Store) UserByUsername(ctx context.Context, username string) (*User, error) {
return scanUser(s.db.QueryRowContext(ctx, s.q(`SELECT id, username, name, role, avatar_url, state, created_at, password_hash FROM users WHERE username = ?`), NormalizeUsername(username)), true)
}
func scanUser(row *sql.Row, withSecrets bool) (*User, error) {
var u User
var err error
if withSecrets {
err = row.Scan(&u.ID, &u.Username, &u.Name, &u.Role, &u.AvatarURL, &u.State, &u.CreatedAt, &u.PasswordHash)
} else {
err = row.Scan(&u.ID, &u.Username, &u.Name, &u.Role, &u.AvatarURL, &u.State, &u.CreatedAt)
}
if err != nil {
return nil, err
}
return &u, nil
}
func NormalizeUsername(s string) string {
return strings.ToLower(strings.TrimSpace(s))
}
func (s *Store) CreateQuestion(ctx context.Context, authorID, title, body, city string) (*RankedQuestion, error) {
q := &RankedQuestion{
ID: uuid.NewString(),
AuthorID: authorID,
Title: strings.TrimSpace(title),
Body: strings.TrimSpace(body),
City: strings.TrimSpace(city),
HuntDate: pacific.Today(),
CreatedAt: time.Now().UTC().Format(time.RFC3339),
}
_, err := s.db.ExecContext(ctx, s.q(`INSERT INTO questions (id, author_id, title, body, city, hunt_date, hidden, created_at) VALUES (?, ?, ?, ?, ?, ?, 0, ?)`),
q.ID, q.AuthorID, q.Title, q.Body, q.City, q.HuntDate, q.CreatedAt)
if err != nil {
return nil, err
}
return q, nil
}
func (s *Store) ListHunt(ctx context.Context, huntDate, viewerID string) ([]RankedQuestion, error) {
rows, err := s.db.QueryContext(ctx, s.q(`
SELECT q.id, q.author_id, u.name, q.title, q.body, q.city, q.hunt_date, q.hidden, q.created_at,
COALESCE(SUM(v.value), 0) AS score,
CASE WHEN a.question_id IS NULL THEN 0 ELSE 1 END AS answered,
COALESCE((SELECT value FROM votes WHERE user_id = ? AND question_id = q.id), 0) 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 = ? 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`), viewerID, huntDate)
if err != nil {
return nil, err
}
defer rows.Close()
var out []RankedQuestion
for rows.Next() {
q, err := scanRanked(rows)
if err != nil {
return nil, err
}
out = append(out, q)
}
return out, rows.Err()
}
func (s *Store) GetQuestion(ctx context.Context, id, viewerID string) (*RankedQuestion, error) {
row := s.db.QueryRowContext(ctx, s.q(`
SELECT q.id, q.author_id, u.name, q.title, q.body, q.city, q.hunt_date, q.hidden, q.created_at,
COALESCE((SELECT SUM(value) FROM votes WHERE question_id = q.id), 0) AS score,
CASE WHEN a.question_id IS NULL THEN 0 ELSE 1 END AS answered,
COALESCE((SELECT value FROM votes WHERE user_id = ? AND question_id = q.id), 0) 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 = ?`), viewerID, id)
q, err := scanRankedRow(row)
if err != nil {
return nil, err
}
return &q, nil
}
type scanned interface {
Scan(dest ...any) error
}
func scanRanked(rows scanned) (RankedQuestion, error) {
var q RankedQuestion
var hidden, answered int
err := rows.Scan(&q.ID, &q.AuthorID, &q.AuthorName, &q.Title, &q.Body, &q.City, &q.HuntDate, &hidden, &q.CreatedAt, &q.Score, &answered, &q.UserVote)
q.Hidden = hidden != 0
q.Answered = answered != 0
return q, err
}
func scanRankedRow(row *sql.Row) (RankedQuestion, error) {
return scanRanked(row)
}
func (s *Store) Vote(ctx context.Context, userID, questionID string, value int) error {
if value != 1 && value != -1 {
return fmt.Errorf("invalid vote")
}
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
var current sql.NullInt64
err = tx.QueryRowContext(ctx, s.q(`SELECT value FROM votes WHERE user_id = ? AND question_id = ?`), userID, questionID).Scan(&current)
if err != nil && err != sql.ErrNoRows {
return err
}
if err == nil && current.Valid && int(current.Int64) == value {
_, err = tx.ExecContext(ctx, s.q(`DELETE FROM votes WHERE user_id = ? AND question_id = ?`), userID, questionID)
} else {
_, err = tx.ExecContext(ctx, s.q(`INSERT INTO votes (user_id, question_id, value) VALUES (?, ?, ?)
ON CONFLICT (user_id, question_id) DO UPDATE SET value = excluded.value`), userID, questionID, value)
}
if err != nil {
return err
}
return tx.Commit()
}
func (s *Store) GetAnswer(ctx context.Context, questionID string) (*Answer, error) {
var a Answer
err := s.db.QueryRowContext(ctx, s.q(`
SELECT a.question_id, a.author_id, u.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 = ?`), questionID).Scan(&a.QuestionID, &a.AuthorID, &a.AuthorName, &a.Body, &a.CreatedAt, &a.UpdatedAt)
if err != nil {
return nil, err
}
return &a, nil
}
func (s *Store) UpsertAnswer(ctx context.Context, questionID, authorID, body string) error {
body = strings.TrimSpace(body)
now := time.Now().UTC().Format(time.RFC3339)
_, err := s.db.ExecContext(ctx, s.q(`
INSERT INTO answers (question_id, author_id, body, created_at, updated_at) VALUES (?, ?, ?, ?, ?)
ON CONFLICT (question_id) DO UPDATE SET body = excluded.body, author_id = excluded.author_id, updated_at = excluded.updated_at`),
questionID, authorID, body, now, now)
return err
}
func (s *Store) HideQuestion(ctx context.Context, id string) error {
_, err := s.db.ExecContext(ctx, s.q(`UPDATE questions SET hidden = 1 WHERE id = ?`), id)
return err
}
func (s *Store) UpdateProfile(ctx context.Context, userID, state, avatarURL string) error {
state = strings.TrimSpace(state)
if avatarURL == "" {
_, err := s.db.ExecContext(ctx, s.q(`UPDATE users SET state = ? WHERE id = ?`), state, userID)
return err
}
_, err := s.db.ExecContext(ctx, s.q(`UPDATE users SET state = ?, avatar_url = ? WHERE id = ?`), state, avatarURL, userID)
return err
}
func (s *Store) ListQuestionsByAuthor(ctx context.Context, authorID string) ([]RankedQuestion, error) {
rows, err := s.db.QueryContext(ctx, s.q(`
SELECT q.id, q.author_id, u.name, q.title, q.body, q.city, q.hunt_date, q.hidden, q.created_at,
COALESCE((SELECT SUM(value) FROM votes WHERE question_id = q.id), 0) AS score,
CASE WHEN a.question_id IS NULL THEN 0 ELSE 1 END AS answered,
0 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 = ? AND q.hidden = 0
ORDER BY q.created_at DESC`), authorID)
if err != nil {
return nil, err
}
defer rows.Close()
return scanRankedList(rows)
}
func (s *Store) ListQuestionsAnsweredBy(ctx context.Context, adminID string) ([]RankedQuestion, error) {
rows, err := s.db.QueryContext(ctx, s.q(`
SELECT q.id, q.author_id, u.name, q.title, q.body, q.city, q.hunt_date, q.hidden, q.created_at,
COALESCE((SELECT SUM(value) FROM votes WHERE question_id = q.id), 0) AS score,
1 AS answered,
0 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 = ? AND q.hidden = 0
ORDER BY ans.updated_at DESC`), adminID)
if err != nil {
return nil, err
}
defer rows.Close()
return scanRankedList(rows)
}
func scanRankedList(rows *sql.Rows) ([]RankedQuestion, error) {
var out []RankedQuestion
for rows.Next() {
q, err := scanRanked(rows)
if err != nil {
return nil, err
}
out = append(out, q)
}
return out, rows.Err()
}
+70
View File
@@ -0,0 +1,70 @@
package web
import (
"errors"
"net/http"
"github.com/go-chi/chi/v5"
"plumber/internal/store"
)
type adminUsersPage struct {
page
Users []store.User
Error string
}
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
}
users, err := s.store.ListUsers(r.Context())
if err != nil {
http.Error(w, "could not load users", http.StatusInternalServerError)
return
}
s.exec(w, "admin-users", adminUsersPage{
page: s.basePage(r, "Users"),
Users: users,
})
}
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 := r.PostFormValue("role")
err := s.store.SetRole(r.Context(), id, role)
if errors.Is(err, store.ErrLastAdmin) {
users, listErr := s.store.ListUsers(r.Context())
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)
}
+112
View File
@@ -0,0 +1,112 @@
package web
import (
"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}$`)
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 (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"))
u, err := s.store.UserByUsername(r.Context(), username)
if err != nil || bcrypt.CompareHashAndPassword([]byte(u.PasswordHash), []byte(password)) != nil {
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.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
}
username := strings.TrimSpace(r.PostFormValue("username"))
password := r.PostFormValue("password")
p := authPage{page: s.basePage(r, "Create account"), Username: username}
if !usernameRe.MatchString(username) {
p.Error = "Username must be 320 letters, numbers, or underscores."
s.exec(w, "register", p)
return
}
if utf8.RuneCountInString(password) < 8 {
p.Error = "Password must be at least 8 characters."
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
}
asAdmin := false
if s.cfg.AdminUsername != "" && store.NormalizeUsername(username) == store.NormalizeUsername(s.cfg.AdminUsername) {
n, err := s.store.CountAdmins(r.Context())
if err != nil {
http.Error(w, "could not create account", http.StatusInternalServerError)
return
}
asAdmin = n == 0
}
u, err := s.store.CreateUser(r.Context(), username, string(hash), asAdmin)
if err != nil {
p.Error = "That username is taken."
s.exec(w, "register", p)
return
}
s.sessions.Put(r.Context(), "user_id", u.ID)
http.Redirect(w, r, "/", http.StatusSeeOther)
}
func (s *Server) handleAuthPrompt(w http.ResponseWriter, r *http.Request) {
s.exec(w, "signin-prompt", s.basePage(r, ""))
}
+330
View File
@@ -0,0 +1,330 @@
package web
import (
"context"
"database/sql"
"fmt"
"sort"
"strings"
"sync"
"time"
"github.com/google/uuid"
"plumber/internal/pacific"
"plumber/internal/store"
)
// memDB is an in-memory store.DB for tests.
type memDB struct {
mu sync.Mutex
users map[string]*store.User // id -> user
byName map[string]string // username -> id
questions map[string]*store.RankedQuestion // id -> question
votes map[string]int // userID|questionID -> value
answers map[string]*store.Answer // questionID -> answer
}
func newMemDB() *memDB {
return &memDB{
users: map[string]*store.User{},
byName: map[string]string{},
questions: map[string]*store.RankedQuestion{},
votes: map[string]int{},
answers: map[string]*store.Answer{},
}
}
func voteKey(userID, questionID string) string {
return userID + "|" + questionID
}
func (m *memDB) CreateUser(_ context.Context, username, passwordHash string, asAdmin bool) (*store.User, error) {
m.mu.Lock()
defer m.mu.Unlock()
username = store.NormalizeUsername(username)
if _, ok := m.byName[username]; ok {
return nil, fmt.Errorf("username taken")
}
role := "user"
if asAdmin {
role = "admin"
}
u := &store.User{
ID: uuid.NewString(),
Username: username,
Name: username,
Role: role,
PasswordHash: passwordHash,
CreatedAt: time.Now().UTC().Format(time.RFC3339),
}
m.users[u.ID] = u
m.byName[username] = u.ID
cp := *u
return &cp, nil
}
func (m *memDB) UserByID(_ context.Context, id string) (*store.User, error) {
m.mu.Lock()
defer m.mu.Unlock()
u, ok := m.users[id]
if !ok {
return nil, sql.ErrNoRows
}
cp := *u
cp.PasswordHash = ""
return &cp, nil
}
func (m *memDB) UserByUsername(_ context.Context, username string) (*store.User, error) {
m.mu.Lock()
defer m.mu.Unlock()
id, ok := m.byName[store.NormalizeUsername(username)]
if !ok {
return nil, sql.ErrNoRows
}
cp := *m.users[id]
return &cp, nil
}
func (m *memDB) CountAdmins(_ context.Context) (int, error) {
m.mu.Lock()
defer m.mu.Unlock()
n := 0
for _, u := range m.users {
if u.Role == "admin" {
n++
}
}
return n, nil
}
func (m *memDB) ListUsers(_ context.Context) ([]store.User, error) {
m.mu.Lock()
defer m.mu.Unlock()
out := make([]store.User, 0, len(m.users))
for _, u := range m.users {
cp := *u
cp.PasswordHash = ""
out = append(out, cp)
}
sort.Slice(out, func(i, j int) bool {
return out[i].CreatedAt < out[j].CreatedAt
})
return out, nil
}
func (m *memDB) SetRole(_ context.Context, userID, role string) error {
if role != "user" && role != "admin" {
return fmt.Errorf("invalid role")
}
m.mu.Lock()
defer m.mu.Unlock()
u, ok := m.users[userID]
if !ok {
return sql.ErrNoRows
}
if u.Role == "admin" && role == "user" {
n := 0
for _, x := range m.users {
if x.Role == "admin" {
n++
}
}
if n <= 1 {
return store.ErrLastAdmin
}
}
u.Role = role
return nil
}
func (m *memDB) CreateQuestion(_ context.Context, authorID, title, body, city string) (*store.RankedQuestion, error) {
m.mu.Lock()
defer m.mu.Unlock()
author, ok := m.users[authorID]
if !ok {
return nil, fmt.Errorf("unknown author")
}
q := &store.RankedQuestion{
ID: uuid.NewString(),
AuthorID: authorID,
AuthorName: author.Name,
Title: strings.TrimSpace(title),
Body: strings.TrimSpace(body),
City: strings.TrimSpace(city),
HuntDate: pacific.Today(),
CreatedAt: time.Now().UTC().Format(time.RFC3339),
}
m.questions[q.ID] = q
cp := *q
return &cp, nil
}
func (m *memDB) rankedLocked(q *store.RankedQuestion, viewerID string) store.RankedQuestion {
out := *q
score := 0
for k, v := range m.votes {
_, qid, ok := strings.Cut(k, "|")
if ok && qid == q.ID {
score += v
}
}
out.Score = score
out.Answered = m.answers[q.ID] != nil
if viewerID != "" {
out.UserVote = m.votes[voteKey(viewerID, q.ID)]
}
if u, ok := m.users[q.AuthorID]; ok {
out.AuthorName = u.Name
}
return out
}
func (m *memDB) ListHunt(_ context.Context, huntDate, viewerID string) ([]store.RankedQuestion, error) {
m.mu.Lock()
defer m.mu.Unlock()
var out []store.RankedQuestion
for _, q := range m.questions {
if q.HuntDate != huntDate || q.Hidden {
continue
}
out = append(out, m.rankedLocked(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
})
return out, nil
}
func (m *memDB) GetQuestion(_ context.Context, id, viewerID string) (*store.RankedQuestion, error) {
m.mu.Lock()
defer m.mu.Unlock()
q, ok := m.questions[id]
if !ok {
return nil, sql.ErrNoRows
}
out := m.rankedLocked(q, viewerID)
return &out, nil
}
func (m *memDB) Vote(_ context.Context, userID, questionID string, value int) error {
if value != 1 && value != -1 {
return fmt.Errorf("invalid vote")
}
m.mu.Lock()
defer m.mu.Unlock()
if _, ok := m.questions[questionID]; !ok {
return fmt.Errorf("unknown question")
}
k := voteKey(userID, questionID)
if cur, ok := m.votes[k]; ok && cur == value {
delete(m.votes, k)
return nil
}
m.votes[k] = value
return nil
}
func (m *memDB) GetAnswer(_ context.Context, questionID string) (*store.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 *memDB) UpsertAnswer(_ context.Context, questionID, authorID, body string) error {
m.mu.Lock()
defer m.mu.Unlock()
body = strings.TrimSpace(body)
now := time.Now().UTC().Format(time.RFC3339)
if existing, ok := m.answers[questionID]; ok {
existing.Body = body
existing.AuthorID = authorID
existing.UpdatedAt = now
return nil
}
m.answers[questionID] = &store.Answer{
QuestionID: questionID,
AuthorID: authorID,
Body: body,
CreatedAt: now,
UpdatedAt: now,
}
return nil
}
func (m *memDB) 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 *memDB) UpdateProfile(_ context.Context, userID, state, avatarURL string) error {
m.mu.Lock()
defer m.mu.Unlock()
u, ok := m.users[userID]
if !ok {
return sql.ErrNoRows
}
u.State = state
if avatarURL != "" {
u.AvatarURL = avatarURL
}
return nil
}
func (m *memDB) ListQuestionsByAuthor(_ context.Context, authorID string) ([]store.RankedQuestion, error) {
m.mu.Lock()
defer m.mu.Unlock()
var out []store.RankedQuestion
for _, q := range m.questions {
if q.AuthorID != authorID || q.Hidden {
continue
}
out = append(out, m.rankedLocked(q, ""))
}
sort.Slice(out, func(i, j int) bool {
return out[i].CreatedAt > out[j].CreatedAt
})
return out, nil
}
func (m *memDB) ListQuestionsAnsweredBy(_ context.Context, adminID string) ([]store.RankedQuestion, error) {
m.mu.Lock()
defer m.mu.Unlock()
var out []store.RankedQuestion
for qid, a := range m.answers {
if a.AuthorID != adminID {
continue
}
q, ok := m.questions[qid]
if !ok || q.Hidden {
continue
}
rq := m.rankedLocked(q, "")
rq.Answered = true
out = append(out, rq)
}
sort.Slice(out, func(i, j int) bool {
return out[i].CreatedAt > out[j].CreatedAt
})
return out, nil
}
var _ store.DB = (*memDB)(nil)
+142
View File
@@ -0,0 +1,142 @@
package web
import (
"io"
"net/http"
"path"
"strings"
"github.com/google/uuid"
"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 := ""
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
}
ct := hdr.Header.Get("Content-Type")
ext, contentType, ok := avatarType(hdr.Filename, ct)
if !ok {
s.renderProfile(w, r, u, "Avatar must be a JPEG, PNG, or WebP image.", state)
return
}
if hdr.Size > 2<<20 {
s.renderProfile(w, r, u, "Avatar must be 2MB or smaller.", state)
return
}
key := path.Join("avatars", u.ID, uuid.NewString()+ext)
limited := io.LimitReader(file, (2<<20)+1)
url, upErr := s.cfg.Blob.Upload(r.Context(), key, limited, contentType, hdr.Size)
if upErr != nil {
s.renderProfile(w, r, u, "Could not upload avatar. Try again later.", state)
return
}
avatarURL = url
} else if err != http.ErrMissingFile {
s.renderProfile(w, r, u, "Could not read avatar file.", state)
return
}
if err := s.store.UpdateProfile(r.Context(), u.ID, state, avatarURL); 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 avatarType(filename, contentType string) (ext, normalized string, ok bool) {
contentType = strings.ToLower(strings.TrimSpace(contentType))
filename = strings.ToLower(filename)
switch {
case strings.HasPrefix(contentType, "image/jpeg"), strings.HasSuffix(filename, ".jpg"), strings.HasSuffix(filename, ".jpeg"):
return ".jpg", "image/jpeg", true
case strings.HasPrefix(contentType, "image/png"), strings.HasSuffix(filename, ".png"):
return ".png", "image/png", true
case strings.HasPrefix(contentType, "image/webp"), strings.HasSuffix(filename, ".webp"):
return ".webp", "image/webp", true
default:
return "", "", false
}
}
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
}
if fresh, e := s.store.UserByID(r.Context(), u.ID); e == nil {
u = fresh
}
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,
})
}
+518
View File
@@ -0,0 +1,518 @@
package web
import (
"context"
"crypto/rand"
"encoding/hex"
"fmt"
"html/template"
"io/fs"
"log"
"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 {
AdminUsername string
SecureCookie bool
Blob blob.Uploader
}
type Server struct {
store store.DB
sessions *scs.SessionManager
tmpl *template.Template
cfg Config
static http.Handler
}
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.DB, 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))),
}, nil
}
func (s *Server) Handler() http.Handler {
r := chi.NewRouter()
r.Use(middleware.RequestID)
r.Use(middleware.RealIP)
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 = title[:120]
}
if len(body) > 8000 {
body = body[:8000]
}
if len(city) > 80 {
city = city[:80]
}
q, err := s.store.CreateQuestion(r.Context(), u.ID, title, body, city)
if 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, _ = s.store.GetAnswer(r.Context(), q.ID)
}
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
default:
http.Error(w, "invalid vote", http.StatusBadRequest)
return
}
if err := s.store.Vote(r.Context(), u.ID, id, value); err != nil {
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 = body[:12000]
}
if err := s.store.UpsertAnswer(r.Context(), id, u.ID, body); err != nil {
http.Error(w, "could not save answer", http.StatusInternalServerError)
return
}
ans, 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: ans})
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
}
s.sessions.Remove(r.Context(), "user_id")
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)
}
}
func randomHex(n int) string {
b := make([]byte, n)
if _, err := rand.Read(b); err != nil {
panic(err)
}
return hex.EncodeToString(b)
}
+468
View File
@@ -0,0 +1,468 @@
package web
import (
"bytes"
"context"
"io"
"mime/multipart"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/alexedwards/scs/v2"
"plumber"
)
func newTestServer(t *testing.T) (*Server, *memDB, scs.Store) {
t.Helper()
fake := newMemDB()
sessions := scs.New()
srv, err := New(fake, sessions.Store, plumber.TemplateFS, plumber.StaticFS, Config{AdminUsername: "hub"})
if err != nil {
t.Fatal(err)
}
return srv, fake, sessions.Store
}
func TestHomeEmptyAndViewport(t *testing.T) {
srv, _, _ := newTestServer(t)
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, "No questions yet") {
t.Fatal("missing empty state")
}
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)
h := srv.Handler()
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/register", nil))
cookie := rec.Result().Cookies()
csrf := csrfFrom(rec.Body.String())
if csrf == "" {
t.Fatal("no csrf")
}
form := strings.NewReader("_csrf=" + csrf + "&username=hub&password=hunter22")
req := httptest.NewRequest(http.MethodPost, "/register", form)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
for _, c := range cookie {
req.AddCookie(c)
}
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusSeeOther {
t.Fatalf("register status %d %s", rec.Code, rec.Body.String())
}
session := rec.Result().Cookies()
req = httptest.NewRequest(http.MethodGet, "/submit", nil)
for _, c := range session {
req.AddCookie(c)
}
rec = httptest.NewRecorder()
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 TestSessionSurvivesServerRestart(t *testing.T) {
fake := newMemDB()
sessionStore := scs.New().Store
srv1, err := New(fake, sessionStore, plumber.TemplateFS, plumber.StaticFS, Config{AdminUsername: "hub"})
if err != nil {
t.Fatal(err)
}
h1 := srv1.Handler()
rec := httptest.NewRecorder()
h1.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/register", nil))
preCookies := rec.Result().Cookies()
csrf := csrfFrom(rec.Body.String())
if csrf == "" {
t.Fatal("no csrf")
}
form := strings.NewReader("_csrf=" + csrf + "&username=hub&password=hunter22")
req := httptest.NewRequest(http.MethodPost, "/register", form)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
for _, c := range preCookies {
req.AddCookie(c)
}
rec = httptest.NewRecorder()
h1.ServeHTTP(rec, req)
if rec.Code != http.StatusSeeOther {
t.Fatalf("register status %d %s", rec.Code, rec.Body.String())
}
sessionCookies := mergeCookies(preCookies, rec.Result().Cookies())
srv2, err := New(fake, sessionStore, plumber.TemplateFS, plumber.StaticFS, Config{AdminUsername: "hub"})
if err != nil {
t.Fatal(err)
}
req = httptest.NewRequest(http.MethodGet, "/submit", nil)
for _, c := range sessionCookies {
req.AddCookie(c)
}
rec = httptest.NewRecorder()
srv2.Handler().ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected authenticated submit form after restart, got %d: %s", rec.Code, rec.Body.String())
}
if !strings.Contains(rec.Body.String(), "Ask a question") {
t.Fatal("session did not survive restart")
}
}
func registerUser(t *testing.T, h http.Handler, username, password string) []*http.Cookie {
t.Helper()
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/register", nil))
cookies := rec.Result().Cookies()
csrf := csrfFrom(rec.Body.String())
form := strings.NewReader("_csrf=" + csrf + "&username=" + username + "&password=" + password)
req := httptest.NewRequest(http.MethodPost, "/register", 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("register %s: %d %s", username, rec.Code, rec.Body.String())
}
return mergeCookies(cookies, rec.Result().Cookies())
}
func TestAdminSeedOnlyWhenNoAdmins(t *testing.T) {
srv, fake, _ := newTestServer(t)
h := srv.Handler()
registerUser(t, h, "hub", "hunter22")
u, err := fake.UserByUsername(context.Background(), "hub")
if err != nil || !u.Admin() {
t.Fatalf("hub should be first admin: %+v %v", u, err)
}
registerUser(t, h, "hub2", "hunter22")
// Create another account that also matches AdminUsername after an admin exists — use a fresh server config with AdminUsername hub2 after hub exists
srv2, err := New(fake, scs.New().Store, plumber.TemplateFS, plumber.StaticFS, Config{AdminUsername: "lateradmin"})
if err != nil {
t.Fatal(err)
}
registerUser(t, srv2.Handler(), "lateradmin", "hunter22")
u2, err := fake.UserByUsername(context.Background(), "lateradmin")
if err != nil {
t.Fatal(err)
}
if u2.Admin() {
t.Fatal("lateradmin must stay user when an admin already exists")
}
}
func TestAdminUsersPageAccessAndRoles(t *testing.T) {
srv, fake, _ := newTestServer(t)
h := srv.Handler()
adminCookies := registerUser(t, h, "hub", "hunter22")
registerUser(t, h, "bob", "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(), "bob") {
t.Fatal("missing bob on admin page")
}
bob, err := fake.UserByUsername(context.Background(), "bob")
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, _ = fake.UserByUsername(context.Background(), "bob")
if !bob.Admin() {
t.Fatal("bob should be admin")
}
// Non-admin forbidden
bobCookies := registerUser(t, h, "carol", "hunter22")
rec = httptest.NewRecorder()
req = httptest.NewRequest(http.MethodGet, "/admin/users", nil)
for _, c := range bobCookies {
req.AddCookie(c)
}
h.ServeHTTP(rec, req)
if rec.Code != http.StatusForbidden {
t.Fatalf("non-admin expected 403, got %d", rec.Code)
}
// Demote last remaining admin after demoting bob first — leave only hub, then demote hub
hub, err := fake.UserByUsername(context.Background(), "hub")
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/"+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)
}
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, _ = fake.UserByUsername(context.Background(), "hub")
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, key string, _ io.Reader, _ string, _ int64) (string, error) {
f.calls++
f.last = key
return "https://cdn.example.com/" + key, nil
}
func TestProfilePageAndState(t *testing.T) {
srv, fake, _ := newTestServer(t)
h := srv.Handler()
cookies := registerUser(t, h, "alice", "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 := fake.UserByUsername(context.Background(), "alice")
if err != nil || u.State != "CA" {
t.Fatalf("state not saved: %+v %v", u, err)
}
// invalid state
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) {
fake := newMemDB()
blob := &fakeBlob{}
sessions := scs.New()
srv, err := New(fake, sessions.Store, plumber.TemplateFS, plumber.StaticFS, Config{
AdminUsername: "hub",
Blob: blob,
})
if err != nil {
t.Fatal(err)
}
h := srv.Handler()
adminCookies := registerUser(t, h, "hub", "hunter22")
userCookies := registerUser(t, h, "alice", "hunter22")
alice, _ := fake.UserByUsername(context.Background(), "alice")
hub, _ := fake.UserByUsername(context.Background(), "hub")
q, err := fake.CreateQuestion(context.Background(), alice.ID, "Drip", "Under sink", "Oakland")
if err != nil {
t.Fatal(err)
}
if err := fake.UpsertAnswer(context.Background(), q.ID, hub.ID, "Replace the cartridge."); 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)
}
_, _ = part.Write([]byte("fakepngbytes"))
_ = 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 blob.calls != 1 {
t.Fatalf("expected 1 upload, got %d", blob.calls)
}
hub, _ = fake.UserByUsername(context.Background(), "hub")
if !strings.Contains(hub.AvatarURL, "cdn.example.com/avatars/") {
t.Fatalf("avatar url %q", hub.AvatarURL)
}
_ = userCookies
}
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]
}
+40
View File
@@ -0,0 +1,40 @@
PRAGMA foreign_keys = ON;
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
);
+789
View File
@@ -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;
}
+1
View File
File diff suppressed because one or more lines are too long
+45
View File
@@ -0,0 +1,45 @@
{{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}}
<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>
</main>
{{template "footer" .}}
{{end}}
+73
View File
@@ -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}}
+21
View File
@@ -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}}
+19
View File
@@ -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}}
+12
View File
@@ -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}}
+37
View File
@@ -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}}
+8
View File
@@ -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}}
+37
View File
@@ -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}}">
<input type="hidden" name="value" value="1">
<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}}">
<input type="hidden" name="value" value="-1">
<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}}
+57
View File
@@ -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}}
+37
View File
@@ -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}}
+20
View File
@@ -0,0 +1,20 @@
{{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">320 letters, numbers, or underscores.</p>
<label for="password">Password</label>
<input id="password" name="password" type="password" required minlength="8" autocomplete="new-password">
<p class="hint">At least 8 characters.</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}}
+19
View File
@@ -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 todays 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 todays hunt</button>
</form>
</main>
{{template "footer" .}}
{{end}}
+33
View File
@@ -0,0 +1,33 @@
# Plumber — follow-ups
From the project review. Priority order within each section.
## Fix soon
- [x] **Persist sessions** — Sessions live in the app DB (`sessions` table): SQLite locally, `postgresstore` when `DATABASE_URL` is set. Opaque cookie unchanged; unused `SESSION_SECRET` removed from config / `.env.example`.
- [x] **Drop Dockerfile** — Deploying on DigitalOcean App Platform (buildpack from `go.mod`); no container image needed.
- [ ] **Rune-safe truncation**`title[:120]`, `body[:8000]`, `city[:80]`, answer body, etc. can split multi-byte UTF-8. Truncate by runes (or safely).
- [x] **Admin bootstrap**`ADMIN_USERNAME` seeds the first admin on register only when no admin exists. Promote/demote via `/admin/users` (admins only); roles stay in `users.role`.
## Docs & ops
- [ ] **README** — How to run locally, env vars (from `.env.example`), admin bootstrap, SQLite vs PlanetScale `DATABASE_URL`, App Platform notes (`PORT`, `SECURE_COOKIE=1`).
- [ ] **Migrations story** — Schema is applied on boot from `schema.sql` (+ sessions DDL). OK for v1; plan real migrations before schema drifts between SQLite and Postgres.
- [x] **App Platform listen port** — Prefers `PORT`, then `LISTEN`, then `:8080`.
- [x] **Prod DB = PlanetScale Postgres** — App already opens Postgres when `DATABASE_URL` is set; DSN cleanup strips PlanetScale/libpq-only params (`sslrootcert=system`, `sslnegotiation`). Use dashboard URI on **5432** for boot schema create; **6432** (PgBouncer) later if you need pooling.
## Smaller / later
- [ ] Rate-limit login/register (bcrypt helps; still open to brute-force).
- [ ] Graceful shutdown instead of bare `ListenAndServe`.
- [ ] More tests: vote HTMX paths, admin answer/hide, archive redirects; optional Postgres integration test.
- [ ] Watch dual-dialect schema — one SQL file works now; expect divergence later.
## Suggested order of attack
1. ~~Persist sessions~~ done.
2. ~~Drop Dockerfile~~ done (App Platform).
3. ~~Wire `PORT`~~ done.
4. ~~Admin roles page~~ done.
5. Short README (run, env, admin, App Platform + PlanetScale).
6. Rune-safe truncation + a couple of handler tests (vote, admin hide).