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.
329 lines
10 KiB
Go
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)
|
|
}
|