Move the package into src/
Thirty-seven entries in the root, most of them .go files. The assets had to come along: //go:embed cannot reach outside its own directory, so templates/, static/ and migrations/ live beside the code that embeds them, and testdata/ beside the test that reads it. storage/ stays put — runtime data, not source. go build now needs -o. Without it the output would be named after the package directory and collide with src/ itself.
This commit is contained in:
+330
@@ -0,0 +1,330 @@
|
||||
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
|
||||
IsAdmin bool
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
// Initials for the avatar circle: no default image on disk, no identicon generator.
|
||||
func (m *member) Initials() string {
|
||||
out, n := "", 0
|
||||
for _, f := range strings.Fields(m.Name) {
|
||||
out += strings.ToUpper(string([]rune(f)[0]))
|
||||
// ponytail: count runes taken, not bytes — "Ä" is 2 bytes and used to end the loop early.
|
||||
if n++; n == 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.is_admin, 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.IsAdmin, &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)
|
||||
}
|
||||
Reference in New Issue
Block a user