Files
Levyraati26_go/auth.go
T
Esa Kataja 1fe5211ae6 Replace Postgres with SQLite
Ten members and a handful of songs a week never needed a database server,
and the server was the last thing making this a two-container deployment.
modernc.org/sqlite is pure Go, so CGO_ENABLED=0 survives and the dependency
count is unchanged: pgx out, sqlite in.

The port stayed small because the driver matches $1-style placeholders
against argument ordinals exactly as pgx does, so no query needed rewriting
for parameters. What did change:

- timestamptz becomes timestamp holding UTC 'YYYY-MM-DD HH:MM:SS'. The
  declared type is what makes the driver return time.Time, and the
  fixed-width UTC string is what makes ordering and comparison against
  datetime('now') mean what they say.
- interval has no equivalent: sessions.idle_ttl is seconds, and the review
  edit window travels as a SQLite date modifier string.
- No stddev_pop, so the divisive and unified boards spell the population
  formula out, guarded with max(0.0, ...) because cancellation returns a
  tiny negative when every score is identical.
- foreign_keys is off by default, so the cascades only exist because the
  pragma is set on every connection.

Drops the postgres service, its healthcheck, the depends_on gate, the
startup retry loop and POSTGRES_PASSWORD. ./storage is now the whole
backup. Tests get a fresh database file per test and run everywhere
instead of skipping without TEST_DATABASE_URL.
2026-08-02 20:47:41 +03:00

329 lines
10 KiB
Go

package main
import (
"context"
"crypto/rand"
"database/sql"
"encoding/hex"
"errors"
"log/slog"
"net/http"
"strings"
"time"
"golang.org/x/crypto/bcrypt"
"modernc.org/sqlite"
)
const (
sessionCookie = "session"
idleShort = 24 * time.Hour
idleRemember = 30 * 24 * time.Hour
// Skip the extending UPDATE unless the session has aged at least this much, so a sliding
// session is not a write on every request.
extendAfter = time.Minute
)
type member struct {
ID int64
Name string
Email string
Avatar *string
Banned bool
CreatedAt time.Time
}
// Initials for the avatar circle: no default image on disk, no identicon generator.
func (m *member) Initials() string {
out := ""
for _, f := range strings.Fields(m.Name) {
out += strings.ToUpper(string([]rune(f)[0]))
if len(out) == 2 {
break
}
}
return out
}
type ctxKey int
const memberKey ctxKey = 0
func memberFrom(ctx context.Context) *member {
m, _ := ctx.Value(memberKey).(*member)
return m
}
func token() string {
b := make([]byte, 32)
rand.Read(b)
return hex.EncodeToString(b)
}
// A bearer header as well as the cookie, so something that isn't a browser can authenticate
// without a second concept. SameSite=Lax still guards the cookie path, and a cross-origin page
// cannot set Authorization without CORS, which is not enabled.
func sessionToken(r *http.Request) string {
if h := r.Header.Get("Authorization"); strings.HasPrefix(h, "Bearer ") {
return strings.TrimPrefix(h, "Bearer ")
}
if c, err := r.Cookie(sessionCookie); err == nil {
return c.Value
}
return ""
}
func (a *app) startSession(ctx context.Context, userID int64, remember bool) (string, time.Time, error) {
ttl := idleShort
if remember {
ttl = idleRemember
}
tok := token()
expires := time.Now().Add(ttl)
_, err := a.db.ExecContext(ctx,
`insert into sessions (token, user_id, idle_ttl, expires_at) values ($1, $2, $3, $4)`,
tok, userID, int64(ttl.Seconds()), expires)
return tok, expires, err
}
func (a *app) setSessionCookie(w http.ResponseWriter, tok string, expires time.Time) {
http.SetCookie(w, &http.Cookie{
Name: sessionCookie, Value: tok, Path: "/", Expires: expires,
HttpOnly: true, Secure: a.cfg.secureCookies, SameSite: http.SameSiteLaxMode,
})
}
// session loads the member behind a token, extends the idle timeout, and treats a banned or
// expired session as no session at all.
func (a *app) session(w http.ResponseWriter, r *http.Request) *member {
tok := sessionToken(r)
if tok == "" {
return nil
}
var (
m member
expires time.Time
ttl time.Duration
ttlSeconds int64
)
err := a.db.QueryRowContext(r.Context(), `
select s.expires_at, s.idle_ttl,
u.id, u.name, u.email, u.avatar, u.banned, u.created_at
from sessions s join users u on u.id = s.user_id
where s.token = $1 and s.expires_at > datetime('now')`, tok).
Scan(&expires, &ttlSeconds, &m.ID, &m.Name, &m.Email, &m.Avatar, &m.Banned, &m.CreatedAt)
if err != nil {
if !errors.Is(err, sql.ErrNoRows) {
slog.Error("session lookup", "ctx", "auth", "error", err)
}
return nil
}
if m.Banned {
// Banning deletes sessions, so this is belt and braces for a row that outlived one.
a.db.ExecContext(r.Context(), `delete from sessions where user_id = $1`, m.ID)
return nil
}
ttl = time.Duration(ttlSeconds) * time.Second
if time.Until(expires) < ttl-extendAfter {
newExpiry := time.Now().Add(ttl)
if _, err := a.db.ExecContext(r.Context(),
`update sessions set expires_at = $2 where token = $1`, tok, newExpiry); err == nil {
a.setSessionCookie(w, tok, newExpiry)
}
}
return &m
}
func (a *app) withMember(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if m := a.session(w, r); m != nil {
r = r.WithContext(context.WithValue(r.Context(), memberKey, m))
}
next.ServeHTTP(w, r)
})
}
func (a *app) requireMember(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if memberFrom(r.Context()) == nil {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
next(w, r)
}
}
// --- pages ---
type authForm struct {
Name, Email, Code string
Errors map[string]string
}
func (a *app) loginPage(w http.ResponseWriter, r *http.Request) {
a.render(w, r, http.StatusOK, "login.html", page{Title: "Kirjaudu", Narrow: true, Data: authForm{}})
}
func (a *app) login(w http.ResponseWriter, r *http.Request) {
email := strings.TrimSpace(strings.ToLower(r.FormValue("email")))
form := authForm{Email: email, Errors: map[string]string{}}
if a.logins.locked(email) {
form.Errors["form"] = "Liian monta yritystä. Yritä hetken kuluttua uudelleen."
a.render(w, r, http.StatusTooManyRequests, "login.html", page{Title: "Kirjaudu", Narrow: true, Data: form})
return
}
var (
id int64
hash string
banned bool
)
err := a.db.QueryRowContext(r.Context(),
`select id, password_hash, banned from users where email = $1`, email).Scan(&id, &hash, &banned)
if err != nil || bcrypt.CompareHashAndPassword([]byte(hash), []byte(r.FormValue("password"))) != nil {
a.logins.fail(email)
// One message for both cases: a distinct "no such account" tells anyone who asks which
// addresses are members.
form.Errors["form"] = "Sähköposti tai salasana ei täsmää."
a.render(w, r, http.StatusUnauthorized, "login.html", page{Title: "Kirjaudu", Narrow: true, Data: form})
return
}
if banned {
form.Errors["form"] = "Tunnus on estetty."
a.render(w, r, http.StatusForbidden, "login.html", page{Title: "Kirjaudu", Narrow: true, Data: form})
return
}
tok, expires, err := a.startSession(r.Context(), id, r.FormValue("remember") != "")
if err != nil {
slog.Error("start session", "ctx", "auth", "error", err)
http.Error(w, "virhe", http.StatusInternalServerError)
return
}
a.logins.succeed(email)
a.setSessionCookie(w, tok, expires)
slog.Info("login", "ctx", "auth", "user", id)
http.Redirect(w, r, "/", http.StatusSeeOther)
}
func (a *app) logout(w http.ResponseWriter, r *http.Request) {
if tok := sessionToken(r); tok != "" {
a.db.ExecContext(r.Context(), `delete from sessions where token = $1`, tok)
}
http.SetCookie(w, &http.Cookie{
Name: sessionCookie, Value: "", Path: "/", MaxAge: -1,
HttpOnly: true, Secure: a.cfg.secureCookies, SameSite: http.SameSiteLaxMode,
})
http.Redirect(w, r, "/login", http.StatusSeeOther)
}
func (a *app) registerPage(w http.ResponseWriter, r *http.Request) {
a.render(w, r, http.StatusOK, "register.html",
page{Title: "Liity", Narrow: true, Data: authForm{Code: r.URL.Query().Get("code")}})
}
// register spends the invite only when the account is actually created: both statements are in one
// transaction, so a failed signup leaves the code usable.
func (a *app) register(w http.ResponseWriter, r *http.Request) {
form := authForm{
Name: strings.TrimSpace(r.FormValue("name")),
Email: strings.TrimSpace(strings.ToLower(r.FormValue("email"))),
Code: strings.TrimSpace(r.FormValue("code")),
Errors: map[string]string{},
}
password := r.FormValue("password")
if form.Name == "" || len([]rune(form.Name)) > 50 {
form.Errors["name"] = "Nimi on pakollinen, enintään 50 merkkiä."
}
if !strings.Contains(form.Email, "@") {
form.Errors["email"] = "Tarkista sähköpostiosoite."
}
// ponytail: no length policy. Invite-only, ten friends, bcrypt, and the admin is the reset
// path — a minimum buys nothing here and makes dev accounts tedious.
if password == "" {
form.Errors["password"] = "Salasana on pakollinen."
}
if form.Code == "" {
form.Errors["code"] = "Kutsukoodi on pakollinen."
}
if len(form.Errors) > 0 {
a.render(w, r, http.StatusUnprocessableEntity, "register.html", page{Title: "Liity", Narrow: true, Data: form})
return
}
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
slog.Error("hash password", "ctx", "auth", "error", err)
http.Error(w, "virhe", http.StatusInternalServerError)
return
}
tx, err := a.db.BeginTx(r.Context(), nil)
if err != nil {
slog.Error("begin", "ctx", "auth", "error", err)
http.Error(w, "virhe", http.StatusInternalServerError)
return
}
defer tx.Rollback()
var inviteID int64
err = tx.QueryRowContext(r.Context(),
`update invites set is_valid = 0 where code = $1 and is_valid returning id`,
form.Code).Scan(&inviteID)
if errors.Is(err, sql.ErrNoRows) {
form.Errors["code"] = "Kutsukoodi ei kelpaa."
a.render(w, r, http.StatusUnprocessableEntity, "register.html", page{Title: "Liity", Narrow: true, Data: form})
return
} else if err != nil {
slog.Error("burn invite", "ctx", "invites", "error", err)
http.Error(w, "virhe", http.StatusInternalServerError)
return
}
var userID int64
err = tx.QueryRowContext(r.Context(),
`insert into users (name, email, password_hash) values ($1, $2, $3) returning id`,
form.Name, form.Email, string(hash)).Scan(&userID)
if isUnique(err) {
// Rolls back, so the invite is still valid.
form.Errors["email"] = "Sähköpostiosoite on jo käytössä."
a.render(w, r, http.StatusUnprocessableEntity, "register.html", page{Title: "Liity", Narrow: true, Data: form})
return
} else if err != nil {
slog.Error("create user", "ctx", "auth", "error", err)
http.Error(w, "virhe", http.StatusInternalServerError)
return
}
if err := tx.Commit(); err != nil {
slog.Error("commit registration", "ctx", "auth", "error", err)
http.Error(w, "virhe", http.StatusInternalServerError)
return
}
slog.Info("registered", "ctx", "auth", "user", userID, "invite", inviteID)
tok, expires, err := a.startSession(r.Context(), userID, false)
if err != nil {
slog.Error("start session", "ctx", "auth", "error", err)
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
a.setSessionCookie(w, tok, expires)
a.flash(w, "Tervetuloa mukaan!")
http.Redirect(w, r, "/", http.StatusSeeOther)
}
// SQLITE_CONSTRAINT_UNIQUE and SQLITE_CONSTRAINT_PRIMARYKEY, spelled out rather than pulled in from
// modernc.org/sqlite/lib — that package is the whole generated amalgamation, for two integers.
const (
sqliteConstraintUnique = 2067
sqliteConstraintPrimaryKey = 1555
)
func isUnique(err error) bool {
var e *sqlite.Error
return errors.As(err, &e) &&
(e.Code() == sqliteConstraintUnique || e.Code() == sqliteConstraintPrimaryKey)
}