Step 2 of the build order. The admin mints an invite link, the recipient registers with it, and from then on has a session. - The invite is spent in the same transaction that creates the account, so a failed signup leaves the code usable - Sessions are idle timeouts, 24h or 30 days with remember me, read from a cookie or a bearer header, extended at most once a minute - Ban is a reversible toggle that drops the member's live sessions - No password minimum; login is rate limited instead, 10 failures per email in 15 minutes, cleared by a correct password - Invite codes render as links carrying ?code=, which the register form prefills; PUBLIC_URL makes them pasteable from the loopback admin panel Tests cover invite spending, the idle timeout, ban, and the rate limiter.
175 lines
5.1 KiB
Go
175 lines
5.1 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"crypto/subtle"
|
|
"log/slog"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
)
|
|
|
|
type config struct {
|
|
databaseURL string
|
|
adminUser string
|
|
adminPass string
|
|
addr string
|
|
adminAddr string
|
|
storageDir string
|
|
secureCookies bool
|
|
// Public address of the member site, so admin-side invite links are pasteable. The admin
|
|
// listener's own Host is a tunnel, not the site, so it cannot be derived.
|
|
publicURL string
|
|
}
|
|
|
|
func loadConfig() config {
|
|
c := config{
|
|
databaseURL: os.Getenv("DATABASE_URL"),
|
|
adminUser: env("ADMIN_USER", "admin"),
|
|
adminPass: os.Getenv("ADMIN_PASSWORD"),
|
|
addr: env("ADDR", ":8080"),
|
|
adminAddr: env("ADMIN_ADDR", "127.0.0.1:8081"),
|
|
storageDir: env("STORAGE_DIR", "./storage"),
|
|
secureCookies: env("SECURE_COOKIES", "true") != "false",
|
|
publicURL: strings.TrimRight(os.Getenv("PUBLIC_URL"), "/"),
|
|
}
|
|
if c.databaseURL == "" {
|
|
fatal("DATABASE_URL is not set")
|
|
}
|
|
// An admin panel that silently opens is worse than one that won't boot.
|
|
if c.adminPass == "" {
|
|
fatal("ADMIN_PASSWORD is not set")
|
|
}
|
|
return c
|
|
}
|
|
|
|
func env(key, def string) string {
|
|
if v := os.Getenv(key); v != "" {
|
|
return v
|
|
}
|
|
return def
|
|
}
|
|
|
|
func fatal(msg string, args ...any) {
|
|
slog.Error(msg, args...)
|
|
os.Exit(1)
|
|
}
|
|
|
|
type app struct {
|
|
cfg config
|
|
pool *pgxpool.Pool
|
|
logins limiter // zero value is ready to use
|
|
}
|
|
|
|
func main() {
|
|
slog.SetDefault(slog.New(slog.NewJSONHandler(os.Stdout, nil)))
|
|
cfg := loadConfig()
|
|
|
|
ctx := context.Background()
|
|
pool, err := pgxpool.New(ctx, cfg.databaseURL)
|
|
if err != nil {
|
|
fatal("database connect", "error", err)
|
|
}
|
|
defer pool.Close()
|
|
|
|
// Wait for Postgres rather than crash-looping past a healthcheck that hasn't gone green yet.
|
|
for i := 0; ; i++ {
|
|
pingCtx, cancel := context.WithTimeout(ctx, 3*time.Second)
|
|
err = pool.Ping(pingCtx)
|
|
cancel()
|
|
if err == nil {
|
|
break
|
|
}
|
|
if i == 10 {
|
|
fatal("database unreachable", "error", err)
|
|
}
|
|
time.Sleep(time.Second)
|
|
}
|
|
|
|
if err := migrate(ctx, pool); err != nil {
|
|
fatal("migrations", "error", err)
|
|
}
|
|
if err := sweep(ctx, pool); err != nil {
|
|
fatal("startup sweep", "error", err)
|
|
}
|
|
for _, dir := range []string{"audio", "tmp"} {
|
|
if err := os.MkdirAll(filepath.Join(cfg.storageDir, dir), 0o755); err != nil {
|
|
fatal("storage dir", "error", err, "dir", dir)
|
|
}
|
|
}
|
|
|
|
a := &app{cfg: cfg, pool: pool}
|
|
|
|
// ponytail: two listeners, one process. Admin is loopback-only — reach it over an SSH tunnel
|
|
// or the reverse proxy. A separate binary would need its own deploy and would race the
|
|
// startup migrations; it buys nothing else.
|
|
go func() {
|
|
slog.Info("admin listening", "ctx", "startup", "addr", cfg.adminAddr)
|
|
err := http.ListenAndServe(cfg.adminAddr, a.requireAdmin(a.adminMux()))
|
|
fatal("admin listener", "error", err)
|
|
}()
|
|
|
|
slog.Info("listening", "ctx", "startup", "addr", cfg.addr)
|
|
fatal("listener", "error", http.ListenAndServe(cfg.addr, a.withMember(a.memberMux())))
|
|
}
|
|
|
|
func (a *app) memberMux() *http.ServeMux {
|
|
mux := http.NewServeMux()
|
|
mux.Handle("GET /static/", http.FileServerFS(assetFS))
|
|
|
|
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) {
|
|
if err := a.pool.Ping(r.Context()); err != nil {
|
|
http.Error(w, "db down", http.StatusServiceUnavailable)
|
|
return
|
|
}
|
|
w.Write([]byte("ok"))
|
|
})
|
|
|
|
mux.HandleFunc("GET /login", a.loginPage)
|
|
mux.HandleFunc("POST /login", a.login)
|
|
mux.HandleFunc("GET /register", a.registerPage)
|
|
mux.HandleFunc("POST /register", a.register)
|
|
mux.HandleFunc("POST /logout", a.logout)
|
|
|
|
mux.HandleFunc("GET /{$}", a.requireMember(func(w http.ResponseWriter, r *http.Request) {
|
|
a.render(w, r, http.StatusOK, "home.html", page{Title: "Jono"})
|
|
}))
|
|
return mux
|
|
}
|
|
|
|
func (a *app) adminMux() *http.ServeMux {
|
|
mux := http.NewServeMux()
|
|
mux.Handle("GET /static/", http.FileServerFS(assetFS))
|
|
mux.HandleFunc("GET /admin", a.adminDashboard)
|
|
mux.HandleFunc("POST /admin/invites", a.createInvite)
|
|
mux.HandleFunc("POST /admin/users/{id}/ban", a.toggleBan)
|
|
mux.HandleFunc("POST /admin/users/{id}/password", a.resetPassword)
|
|
mux.HandleFunc("GET /{$}", func(w http.ResponseWriter, r *http.Request) {
|
|
http.Redirect(w, r, "/admin", http.StatusSeeOther)
|
|
})
|
|
return mux
|
|
}
|
|
|
|
// ponytail: Basic Auth, no admin session, no admin row. Ceiling: one admin, no logout
|
|
// (close the browser). Add a cookie session if a second admin ever needs one.
|
|
//
|
|
// No bcrypt: hashing protects stored passwords against a database leak, and this one lives in the
|
|
// env file next to the Postgres password already. The constant-time compare is the part that matters.
|
|
func (a *app) requireAdmin(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
u, p, ok := r.BasicAuth()
|
|
userOK := subtle.ConstantTimeCompare([]byte(u), []byte(a.cfg.adminUser)) == 1
|
|
passOK := subtle.ConstantTimeCompare([]byte(p), []byte(a.cfg.adminPass)) == 1
|
|
if !ok || !userOK || !passOK {
|
|
w.Header().Set("WWW-Authenticate", `Basic realm="levyraati admin"`)
|
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
|
return
|
|
}
|
|
next.ServeHTTP(w, r)
|
|
})
|
|
}
|