Add member accounts: invites, registration, login, sessions, ban
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.
This commit is contained in:
@@ -7,6 +7,7 @@ import (
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
@@ -20,6 +21,9 @@ type config struct {
|
||||
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 {
|
||||
@@ -31,6 +35,7 @@ func loadConfig() config {
|
||||
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")
|
||||
@@ -55,8 +60,9 @@ func fatal(msg string, args ...any) {
|
||||
}
|
||||
|
||||
type app struct {
|
||||
cfg config
|
||||
pool *pgxpool.Pool
|
||||
cfg config
|
||||
pool *pgxpool.Pool
|
||||
logins limiter // zero value is ready to use
|
||||
}
|
||||
|
||||
func main() {
|
||||
@@ -108,11 +114,13 @@ func main() {
|
||||
}()
|
||||
|
||||
slog.Info("listening", "ctx", "startup", "addr", cfg.addr)
|
||||
fatal("listener", "error", http.ListenAndServe(cfg.addr, a.memberMux()))
|
||||
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)
|
||||
@@ -120,13 +128,28 @@ func (a *app) memberMux() *http.ServeMux {
|
||||
}
|
||||
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.HandleFunc("GET /admin", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Write([]byte("levyraati admin"))
|
||||
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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user