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:
@@ -5,3 +5,7 @@ ADMIN_PASSWORD=
|
||||
|
||||
# Set to false only for local development over plain HTTP.
|
||||
SECURE_COOKIES=true
|
||||
|
||||
# Public address of the member site. Used to build pasteable invite links in the admin panel.
|
||||
# Unset falls back to a relative link, which is fine locally.
|
||||
PUBLIC_URL=https://levyraati.example.com
|
||||
|
||||
@@ -57,6 +57,7 @@ creates no users: log into the admin panel and mint an invite.
|
||||
| `ADMIN_ADDR` | `127.0.0.1:8081` | Admin listener. Keep it on loopback. Under Compose it binds `:8081` inside the container and is published only to the host's loopback |
|
||||
| `STORAGE_DIR` | `./storage` | Audio, avatars, in-flight conversions |
|
||||
| `SECURE_COOKIES` | `true` | Set `false` for local development over plain HTTP |
|
||||
| `PUBLIC_URL` | — | Public address of the member site, e.g. `https://levyraati.example.com`. Used to build invite links in the admin panel; unset gives relative links |
|
||||
|
||||
### Local development
|
||||
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
type adminInvite struct {
|
||||
ID int64
|
||||
Code string
|
||||
IsValid bool
|
||||
CreatedAt time.Time
|
||||
Link string
|
||||
}
|
||||
|
||||
type adminMember struct {
|
||||
ID int64
|
||||
Name string
|
||||
Email string
|
||||
Banned bool
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
type dashboard struct {
|
||||
Invites []adminInvite
|
||||
Members []adminMember
|
||||
}
|
||||
|
||||
func (a *app) adminDashboard(w http.ResponseWriter, r *http.Request) {
|
||||
var d dashboard
|
||||
|
||||
rows, err := a.pool.Query(r.Context(),
|
||||
`select id, code, is_valid, created_at from invites order by created_at desc limit 50`)
|
||||
if err != nil {
|
||||
adminError(w, "invites", err)
|
||||
return
|
||||
}
|
||||
for rows.Next() {
|
||||
var i adminInvite
|
||||
if err := rows.Scan(&i.ID, &i.Code, &i.IsValid, &i.CreatedAt); err != nil {
|
||||
adminError(w, "invites", err)
|
||||
return
|
||||
}
|
||||
i.Link = a.inviteLink(i.Code)
|
||||
d.Invites = append(d.Invites, i)
|
||||
}
|
||||
rows.Close()
|
||||
if err := rows.Err(); err != nil {
|
||||
adminError(w, "invites", err)
|
||||
return
|
||||
}
|
||||
|
||||
rows, err = a.pool.Query(r.Context(),
|
||||
`select id, name, email, banned, created_at from users order by created_at`)
|
||||
if err != nil {
|
||||
adminError(w, "users", err)
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var m adminMember
|
||||
if err := rows.Scan(&m.ID, &m.Name, &m.Email, &m.Banned, &m.CreatedAt); err != nil {
|
||||
adminError(w, "users", err)
|
||||
return
|
||||
}
|
||||
d.Members = append(d.Members, m)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
adminError(w, "users", err)
|
||||
return
|
||||
}
|
||||
|
||||
a.render(w, r, http.StatusOK, "admin.html", page{Title: "Ylläpito", Admin: true, Data: d})
|
||||
}
|
||||
|
||||
// 128 bits of entropy. The code is shown once on the dashboard and pasted to whoever is joining.
|
||||
func inviteCode() string {
|
||||
b := make([]byte, 16)
|
||||
rand.Read(b)
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
|
||||
// The link is what actually gets sent to someone: the register form reads ?code= and prefills it,
|
||||
// so the recipient clicks and fills in their name. PUBLIC_URL unset falls back to a relative path,
|
||||
// which is enough locally.
|
||||
func (a *app) inviteLink(code string) string {
|
||||
return a.cfg.publicURL + "/register?code=" + url.QueryEscape(code)
|
||||
}
|
||||
|
||||
func (a *app) createInvite(w http.ResponseWriter, r *http.Request) {
|
||||
code := inviteCode()
|
||||
if _, err := a.pool.Exec(r.Context(), `insert into invites (code) values ($1)`, code); err != nil {
|
||||
adminError(w, "invites", err)
|
||||
return
|
||||
}
|
||||
slog.Info("invite minted", "ctx", "invites")
|
||||
// The dashboard lists it as a clickable link immediately below, newest first, so the flash
|
||||
// doesn't repeat the URL as unclickable text.
|
||||
a.flash(w, "Uusi kutsulinkki luotu.")
|
||||
http.Redirect(w, r, "/admin", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// Ban is a reversible toggle. It drops live sessions immediately — checking `banned` only at login
|
||||
// would leave a banned member browsing until their session expired.
|
||||
func (a *app) toggleBan(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
||||
if err != nil {
|
||||
http.Error(w, "not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
var banned bool
|
||||
err = a.pool.QueryRow(r.Context(),
|
||||
`update users set banned = not banned where id = $1 returning banned`, id).Scan(&banned)
|
||||
if err != nil {
|
||||
adminError(w, "users", err)
|
||||
return
|
||||
}
|
||||
if banned {
|
||||
if _, err := a.pool.Exec(r.Context(), `delete from sessions where user_id = $1`, id); err != nil {
|
||||
adminError(w, "users", err)
|
||||
return
|
||||
}
|
||||
a.flash(w, "Jäsen estetty.")
|
||||
} else {
|
||||
a.flash(w, "Esto poistettu.")
|
||||
}
|
||||
slog.Info("ban toggled", "ctx", "auth", "user", id, "banned", banned)
|
||||
http.Redirect(w, r, "/admin", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// The admin reset is the only password recovery there is, so it also drops the member's sessions.
|
||||
func (a *app) resetPassword(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
|
||||
if err != nil {
|
||||
http.Error(w, "not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
password := r.FormValue("password")
|
||||
if password == "" {
|
||||
a.flash(w, "Salasana on pakollinen.")
|
||||
http.Redirect(w, r, "/admin", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
adminError(w, "auth", err)
|
||||
return
|
||||
}
|
||||
if _, err := a.pool.Exec(r.Context(),
|
||||
`update users set password_hash = $2 where id = $1`, id, string(hash)); err != nil {
|
||||
adminError(w, "auth", err)
|
||||
return
|
||||
}
|
||||
if _, err := a.pool.Exec(r.Context(), `delete from sessions where user_id = $1`, id); err != nil {
|
||||
adminError(w, "auth", err)
|
||||
return
|
||||
}
|
||||
slog.Info("password reset by admin", "ctx", "auth", "user", id)
|
||||
a.flash(w, "Salasana vaihdettu.")
|
||||
http.Redirect(w, r, "/admin", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func adminError(w http.ResponseWriter, ctx string, err error) {
|
||||
slog.Error("admin", "ctx", ctx, "error", err)
|
||||
http.Error(w, "virhe", http.StatusInternalServerError)
|
||||
}
|
||||
@@ -0,0 +1,319 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
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.pool.Exec(ctx,
|
||||
`insert into sessions (token, user_id, idle_ttl, expires_at) values ($1, $2, $3, $4)`,
|
||||
tok, userID, ttl, 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
|
||||
ttlMicros int64
|
||||
)
|
||||
err := a.pool.QueryRow(r.Context(), `
|
||||
select s.expires_at, extract(epoch from s.idle_ttl) * 1000000,
|
||||
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 > now()`, tok).
|
||||
Scan(&expires, &ttlMicros, &m.ID, &m.Name, &m.Email, &m.Avatar, &m.Banned, &m.CreatedAt)
|
||||
if err != nil {
|
||||
if !errors.Is(err, pgx.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.pool.Exec(r.Context(), `delete from sessions where user_id = $1`, m.ID)
|
||||
return nil
|
||||
}
|
||||
ttl = time.Duration(ttlMicros) * time.Microsecond
|
||||
if time.Until(expires) < ttl-extendAfter {
|
||||
newExpiry := time.Now().Add(ttl)
|
||||
if _, err := a.pool.Exec(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", 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", Data: form})
|
||||
return
|
||||
}
|
||||
|
||||
var (
|
||||
id int64
|
||||
hash string
|
||||
banned bool
|
||||
)
|
||||
err := a.pool.QueryRow(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", Data: form})
|
||||
return
|
||||
}
|
||||
if banned {
|
||||
form.Errors["form"] = "Tunnus on estetty."
|
||||
a.render(w, r, http.StatusForbidden, "login.html", page{Title: "Kirjaudu", 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.pool.Exec(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", 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", 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.pool.Begin(r.Context())
|
||||
if err != nil {
|
||||
slog.Error("begin", "ctx", "auth", "error", err)
|
||||
http.Error(w, "virhe", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
defer tx.Rollback(r.Context())
|
||||
|
||||
var inviteID int64
|
||||
err = tx.QueryRow(r.Context(),
|
||||
`update invites set is_valid = false where code = $1 and is_valid returning id`,
|
||||
form.Code).Scan(&inviteID)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
form.Errors["code"] = "Kutsukoodi ei kelpaa."
|
||||
a.render(w, r, http.StatusUnprocessableEntity, "register.html", page{Title: "Liity", 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.QueryRow(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", 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(r.Context()); 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)
|
||||
}
|
||||
|
||||
func isUnique(err error) bool {
|
||||
var pgErr interface{ SQLState() string }
|
||||
return errors.As(err, &pgErr) && pgErr.SQLState() == "23505"
|
||||
}
|
||||
+240
@@ -0,0 +1,240 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
// Set TEST_DATABASE_URL to a throwaway database — these drop and recreate the public schema.
|
||||
func testApp(t *testing.T) *app {
|
||||
t.Helper()
|
||||
dbURL := os.Getenv("TEST_DATABASE_URL")
|
||||
if dbURL == "" {
|
||||
t.Skip("TEST_DATABASE_URL not set")
|
||||
}
|
||||
ctx := context.Background()
|
||||
pool, err := pgxpool.New(ctx, dbURL)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(pool.Close)
|
||||
if _, err := pool.Exec(ctx, `drop schema public cascade; create schema public`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := migrate(ctx, pool); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return &app{cfg: config{adminUser: "admin", adminPass: "s3cret"}, pool: pool}
|
||||
}
|
||||
|
||||
func post(t *testing.T, h http.Handler, path string, form url.Values) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
r := httptest.NewRequest("POST", path, strings.NewReader(form.Encode()))
|
||||
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
w := httptest.NewRecorder()
|
||||
h.ServeHTTP(w, r)
|
||||
return w
|
||||
}
|
||||
|
||||
func (a *app) inviteValid(t *testing.T, code string) bool {
|
||||
t.Helper()
|
||||
var valid bool
|
||||
if err := a.pool.QueryRow(context.Background(),
|
||||
`select is_valid from invites where code = $1`, code).Scan(&valid); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return valid
|
||||
}
|
||||
|
||||
// A failed registration must leave the code usable; a successful one must not.
|
||||
func TestInviteIsSpentOnlyBySuccess(t *testing.T) {
|
||||
a := testApp(t)
|
||||
ctx := context.Background()
|
||||
mux := a.withMember(a.memberMux())
|
||||
|
||||
if _, err := a.pool.Exec(ctx, `insert into invites (code) values ('kutsu1')`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := a.pool.Exec(ctx,
|
||||
`insert into users (name, email, password_hash) values ('Esa', '[email protected]', 'x')`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Taken email — the insert fails after the invite has already been marked spent in the tx.
|
||||
w := post(t, mux, "/register", url.Values{
|
||||
"code": {"kutsu1"}, "name": {"Toinen"},
|
||||
"email": {"[email protected]"}, "password": {"salasana1"},
|
||||
})
|
||||
if w.Code != http.StatusUnprocessableEntity {
|
||||
t.Fatalf("duplicate email: status = %d, want 422", w.Code)
|
||||
}
|
||||
if !a.inviteValid(t, "kutsu1") {
|
||||
t.Fatal("failed registration spent the invite")
|
||||
}
|
||||
|
||||
// Missing password — rejected before the invite is touched at all.
|
||||
w = post(t, mux, "/register", url.Values{
|
||||
"code": {"kutsu1"}, "name": {"Toinen"}, "email": {"[email protected]"}, "password": {""},
|
||||
})
|
||||
if w.Code != http.StatusUnprocessableEntity {
|
||||
t.Fatalf("short password: status = %d, want 422", w.Code)
|
||||
}
|
||||
if !a.inviteValid(t, "kutsu1") {
|
||||
t.Fatal("rejected registration spent the invite")
|
||||
}
|
||||
|
||||
w = post(t, mux, "/register", url.Values{
|
||||
"code": {"kutsu1"}, "name": {"Toinen"}, "email": {"[email protected]"}, "password": {"salasana1"},
|
||||
})
|
||||
if w.Code != http.StatusSeeOther {
|
||||
t.Fatalf("valid registration: status = %d, want 303", w.Code)
|
||||
}
|
||||
if a.inviteValid(t, "kutsu1") {
|
||||
t.Fatal("successful registration left the invite usable")
|
||||
}
|
||||
|
||||
// And it cannot be used twice.
|
||||
w = post(t, mux, "/register", url.Values{
|
||||
"code": {"kutsu1"}, "name": {"Kolmas"}, "email": {"[email protected]"}, "password": {"salasana1"},
|
||||
})
|
||||
if w.Code != http.StatusUnprocessableEntity {
|
||||
t.Fatalf("reused invite: status = %d, want 422", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// The limiter has its own unit test; this covers the wiring into the handler.
|
||||
func TestLoginHandlerRefusesAfterTooManyFailures(t *testing.T) {
|
||||
a := testApp(t)
|
||||
mux := a.withMember(a.memberMux())
|
||||
a.seedMember(t, "[email protected]")
|
||||
|
||||
bad := url.Values{"email": {"[email protected]"}, "password": {"väärin"}}
|
||||
for i := range loginMaxFailures {
|
||||
if w := post(t, mux, "/login", bad); w.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("attempt %d: status = %d, want 401", i+1, w.Code)
|
||||
}
|
||||
}
|
||||
if w := post(t, mux, "/login", bad); w.Code != http.StatusTooManyRequests {
|
||||
t.Fatalf("attempt %d: status = %d, want 429", loginMaxFailures+1, w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func (a *app) seedMember(t *testing.T, email string) int64 {
|
||||
t.Helper()
|
||||
var id int64
|
||||
err := a.pool.QueryRow(context.Background(),
|
||||
`insert into users (name, email, password_hash) values ('Esa', $1, 'x') returning id`,
|
||||
email).Scan(&id)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
func (a *app) sessionFor(t *testing.T, token string) *member {
|
||||
t.Helper()
|
||||
r := httptest.NewRequest("GET", "/", nil)
|
||||
r.AddCookie(&http.Cookie{Name: sessionCookie, Value: token})
|
||||
return a.session(httptest.NewRecorder(), r)
|
||||
}
|
||||
|
||||
func TestSessionIdleTimeout(t *testing.T) {
|
||||
a := testApp(t)
|
||||
ctx := context.Background()
|
||||
id := a.seedMember(t, "[email protected]")
|
||||
|
||||
live, _, err := a.startSession(ctx, id, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if m := a.sessionFor(t, live); m == nil || m.ID != id {
|
||||
t.Fatal("fresh session did not resolve to its member")
|
||||
}
|
||||
|
||||
// Age it past the idle window: the timeout is what expiry means, so this is the whole rule.
|
||||
if _, err := a.pool.Exec(ctx,
|
||||
`update sessions set expires_at = now() - interval '1 second' where token = $1`, live); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if m := a.sessionFor(t, live); m != nil {
|
||||
t.Fatal("expired session still resolved")
|
||||
}
|
||||
|
||||
// A session used inside the window slides forward.
|
||||
fresh, _, err := a.startSession(ctx, id, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := a.pool.Exec(ctx,
|
||||
`update sessions set expires_at = now() + interval '1 hour' where token = $1`, fresh); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if m := a.sessionFor(t, fresh); m == nil {
|
||||
t.Fatal("session inside the window did not resolve")
|
||||
}
|
||||
var expires time.Time
|
||||
if err := a.pool.QueryRow(ctx,
|
||||
`select expires_at from sessions where token = $1`, fresh).Scan(&expires); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if time.Until(expires) < 23*time.Hour {
|
||||
t.Fatalf("session was not extended: expires in %s", time.Until(expires))
|
||||
}
|
||||
}
|
||||
|
||||
func TestBanDropsSessionsAndBlocksLogin(t *testing.T) {
|
||||
a := testApp(t)
|
||||
ctx := context.Background()
|
||||
mux := a.withMember(a.memberMux())
|
||||
|
||||
if _, err := a.pool.Exec(ctx, `insert into invites (code) values ('kutsu2')`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
w := post(t, mux, "/register", url.Values{
|
||||
"code": {"kutsu2"}, "name": {"Esa"}, "email": {"[email protected]"}, "password": {"salasana1"},
|
||||
})
|
||||
if w.Code != http.StatusSeeOther {
|
||||
t.Fatalf("registration: status = %d, want 303", w.Code)
|
||||
}
|
||||
var id int64
|
||||
if err := a.pool.QueryRow(ctx, `select id from users where email = '[email protected]'`).Scan(&id); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
adminMux := a.adminMux()
|
||||
if w := post(t, adminMux, fmt.Sprintf("/admin/users/%d/ban", id), nil); w.Code != http.StatusSeeOther {
|
||||
t.Fatalf("ban: status = %d, want 303", w.Code)
|
||||
}
|
||||
|
||||
var sessions int
|
||||
if err := a.pool.QueryRow(ctx,
|
||||
`select count(*) from sessions where user_id = $1`, id).Scan(&sessions); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if sessions != 0 {
|
||||
t.Fatalf("banned member kept %d sessions", sessions)
|
||||
}
|
||||
|
||||
w = post(t, mux, "/login", url.Values{"email": {"[email protected]"}, "password": {"salasana1"}})
|
||||
if w.Code != http.StatusForbidden {
|
||||
t.Fatalf("banned login: status = %d, want 403", w.Code)
|
||||
}
|
||||
|
||||
// Reversible: unban, and the same credentials work again.
|
||||
if w := post(t, adminMux, fmt.Sprintf("/admin/users/%d/ban", id), nil); w.Code != http.StatusSeeOther {
|
||||
t.Fatalf("unban: status = %d, want 303", w.Code)
|
||||
}
|
||||
w = post(t, mux, "/login", url.Values{"email": {"[email protected]"}, "password": {"salasana1"}})
|
||||
if w.Code != http.StatusSeeOther {
|
||||
t.Fatalf("login after unban: status = %d, want 303", w.Code)
|
||||
}
|
||||
}
|
||||
+3
-2
@@ -27,11 +27,12 @@ services:
|
||||
# published below, so it stays unreachable from outside without a tunnel or the proxy.
|
||||
ADMIN_ADDR: ":8081"
|
||||
SECURE_COOKIES: ${SECURE_COOKIES:-true}
|
||||
PUBLIC_URL: ${PUBLIC_URL:-}
|
||||
volumes:
|
||||
- ./storage:/storage
|
||||
ports:
|
||||
- "127.0.0.1:8080:8080"
|
||||
- "127.0.0.1:8081:8081"
|
||||
- "8080:8080"
|
||||
- "8081:8081"
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
|
||||
@@ -158,3 +158,12 @@ says so.
|
||||
40. **`main` is release code, `dev` is development.** Work lands on `dev` and reaches `main` by merge
|
||||
at release, so `main` is always a list of things that shipped. Nightly builds, if any, come off
|
||||
`dev`.
|
||||
41. **No password minimum; rate limit logins instead.** A length policy protects against guessing,
|
||||
and guessing is better answered directly: 10 failures per email in 15 minutes, then a 15-minute
|
||||
lockout, cleared by a correct password. The floor was rejected because typing an 8-character
|
||||
password on every dev account is friction with nothing behind it — there is no public
|
||||
registration to spray, and the admin is the reset path. It was also deliberately *not* made
|
||||
configurable: settings like this belong in code, not in an env file that grows a line per
|
||||
preference. The limiter is keyed by email rather than IP (a proxy would mean trusting
|
||||
`X-Forwarded-For`) and locks the *attempt rate*, not the account, so nobody can lock someone
|
||||
else out by trying.
|
||||
|
||||
+6
-1
@@ -49,7 +49,12 @@ and a cross-origin page cannot set `Authorization` without CORS, which is not en
|
||||
### 1.2 Security behaviours
|
||||
|
||||
- Changing your own password requires the current password.
|
||||
- Passwords are bcrypt.
|
||||
- Passwords are bcrypt. **There is no minimum length** — only non-empty. Invite-only registration,
|
||||
ten members, and an admin-only reset path leave a length policy nothing to protect.
|
||||
- **Login attempts are rate limited**: 10 failures for one email address within 15 minutes lock
|
||||
*that address's login* for 15 minutes, and a correct password clears the counter. Keyed by email
|
||||
rather than IP, because behind a proxy the address requires trusting `X-Forwarded-For`. Held in
|
||||
memory, so a restart clears it. Registration is not limited — an invite code is 128 bits.
|
||||
- Invite codes carry 128 bits of entropy (`crypto/rand`, 16 bytes hex).
|
||||
- Avatar upload: 5 MB max, normalised through ffmpeg to a 256 px JPEG. The re-encode **is** the
|
||||
validation, and it caps what lands on disk. ffmpeg handles webp and avif; stdlib `image` does not.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Login attempts are limited per email address, which is the thing under attack — and the only key
|
||||
// available without parsing X-Forwarded-For and maintaining a trusted-proxy list.
|
||||
//
|
||||
// ponytail: in memory, dies with the process. A restart clearing the counters is not an attack an
|
||||
// attacker can mount. A shared store is the upgrade if this ever runs as more than one process.
|
||||
const (
|
||||
loginMaxFailures = 10
|
||||
loginWindow = 15 * time.Minute
|
||||
loginLockout = 15 * time.Minute
|
||||
)
|
||||
|
||||
type attempts struct {
|
||||
count int
|
||||
first time.Time
|
||||
until time.Time // zero unless locked
|
||||
}
|
||||
|
||||
type limiter struct {
|
||||
mu sync.Mutex
|
||||
by map[string]*attempts
|
||||
}
|
||||
|
||||
// locked reports whether the key is currently refused. It does not count as an attempt.
|
||||
func (l *limiter) locked(key string) bool {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
a := l.by[key]
|
||||
return a != nil && time.Now().Before(a.until)
|
||||
}
|
||||
|
||||
func (l *limiter) fail(key string) {
|
||||
now := time.Now()
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
if l.by == nil {
|
||||
l.by = map[string]*attempts{}
|
||||
}
|
||||
l.sweep(now)
|
||||
|
||||
a := l.by[key]
|
||||
if a == nil || now.Sub(a.first) > loginWindow {
|
||||
l.by[key] = &attempts{count: 1, first: now}
|
||||
return
|
||||
}
|
||||
a.count++
|
||||
if a.count >= loginMaxFailures {
|
||||
a.until = now.Add(loginLockout)
|
||||
}
|
||||
}
|
||||
|
||||
// A correct password clears the record: the limit is on guessing, not on the account. Locking the
|
||||
// account itself would let anyone lock its owner out by trying.
|
||||
func (l *limiter) succeed(key string) {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
delete(l.by, key)
|
||||
}
|
||||
|
||||
// Called under the lock, on failures only — there is nothing to grow the map otherwise.
|
||||
func (l *limiter) sweep(now time.Time) {
|
||||
for k, a := range l.by {
|
||||
if now.Sub(a.first) > loginWindow && now.After(a.until) {
|
||||
delete(l.by, k)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestLoginRateLimit(t *testing.T) {
|
||||
var l limiter
|
||||
|
||||
for i := range loginMaxFailures - 1 {
|
||||
l.fail("[email protected]")
|
||||
if l.locked("[email protected]") {
|
||||
t.Fatalf("locked after %d failures, limit is %d", i+1, loginMaxFailures)
|
||||
}
|
||||
}
|
||||
l.fail("[email protected]")
|
||||
if !l.locked("[email protected]") {
|
||||
t.Fatalf("not locked after %d failures", loginMaxFailures)
|
||||
}
|
||||
|
||||
// The limit is per email: locking one address must not lock anyone else out.
|
||||
if l.locked("[email protected]") {
|
||||
t.Fatal("a different address was locked too")
|
||||
}
|
||||
|
||||
// A correct password clears it, so a member who mistypes nine times and then gets it right
|
||||
// starts from zero.
|
||||
l.succeed("[email protected]")
|
||||
if l.locked("[email protected]") {
|
||||
t.Fatal("still locked after a successful login")
|
||||
}
|
||||
|
||||
// Failures older than the window don't accumulate.
|
||||
for range loginMaxFailures - 1 {
|
||||
l.fail("[email protected]")
|
||||
}
|
||||
l.by["[email protected]"].first = time.Now().Add(-loginWindow - time.Minute)
|
||||
l.fail("[email protected]")
|
||||
if l.locked("[email protected]") {
|
||||
t.Fatal("failures outside the window were counted")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"embed"
|
||||
"html/template"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"time"
|
||||
)
|
||||
|
||||
//go:embed templates static
|
||||
var assetFS embed.FS
|
||||
|
||||
var funcs = template.FuncMap{
|
||||
"fidate": func(t time.Time) string { return t.Local().Format("2.1.2006 15:04") },
|
||||
}
|
||||
|
||||
// Each page is parsed with the layout into its own set, so two pages may both define "content".
|
||||
var pages = map[string]*template.Template{}
|
||||
|
||||
func init() {
|
||||
entries, err := assetFS.ReadDir("templates")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
for _, e := range entries {
|
||||
if e.Name() == "layout.html" {
|
||||
continue
|
||||
}
|
||||
pages[e.Name()] = template.Must(template.New("layout.html").Funcs(funcs).
|
||||
ParseFS(assetFS, "templates/layout.html", "templates/"+e.Name()))
|
||||
}
|
||||
}
|
||||
|
||||
// page is everything the layout needs, plus whatever the page itself wants in Data.
|
||||
type page struct {
|
||||
Title string
|
||||
Member *member
|
||||
Admin bool
|
||||
Flash string
|
||||
Path string
|
||||
Data any
|
||||
}
|
||||
|
||||
func (a *app) render(w http.ResponseWriter, r *http.Request, status int, name string, p page) {
|
||||
t, ok := pages[name]
|
||||
if !ok {
|
||||
slog.Error("unknown template", "name", name)
|
||||
http.Error(w, "template", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
p.Member = memberFrom(r.Context())
|
||||
p.Path = r.URL.Path
|
||||
p.Flash = a.takeFlash(w, r)
|
||||
|
||||
// Render to memory first: a template that fails halfway must not leave a half-written 200.
|
||||
var buf bytes.Buffer
|
||||
if err := t.ExecuteTemplate(&buf, "layout.html", p); err != nil {
|
||||
slog.Error("render", "name", name, "error", err)
|
||||
http.Error(w, "template", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.WriteHeader(status)
|
||||
buf.WriteTo(w)
|
||||
}
|
||||
|
||||
// Toasts are a cookie rendered server-side and cleared on read — no JS, no session storage.
|
||||
func (a *app) flash(w http.ResponseWriter, msg string) {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: "flash", Value: url.QueryEscape(msg), Path: "/",
|
||||
HttpOnly: true, Secure: a.cfg.secureCookies, SameSite: http.SameSiteLaxMode,
|
||||
})
|
||||
}
|
||||
|
||||
func (a *app) takeFlash(w http.ResponseWriter, r *http.Request) string {
|
||||
c, err := r.Cookie("flash")
|
||||
if err != nil || c.Value == "" {
|
||||
return ""
|
||||
}
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: "flash", Value: "", Path: "/", MaxAge: -1,
|
||||
HttpOnly: true, Secure: a.cfg.secureCookies, SameSite: http.SameSiteLaxMode,
|
||||
})
|
||||
msg, err := url.QueryUnescape(c.Value)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return msg
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
/* Theme tokens first — the dark rock/metal look lives here and nowhere else. */
|
||||
:root {
|
||||
--bg: #121212;
|
||||
--surface: #1c1c1e;
|
||||
--surface-2: #26262a;
|
||||
--border: #35353a;
|
||||
--text: #ece9e6;
|
||||
--muted: #9a948d;
|
||||
--accent: #ff5722;
|
||||
--accent-2: #c62828;
|
||||
--error: #ef5350;
|
||||
--radius: 4px;
|
||||
/* ponytail: system stack until an Oswald woff2 is vendored into /static. */
|
||||
--font-head: "Oswald", "Fira Sans Condensed", "Arial Narrow", system-ui, sans-serif;
|
||||
--font-body: system-ui, -apple-system, "Segoe UI", sans-serif;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-family: var(--font-body);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
h1, h2, h3 {
|
||||
font-family: var(--font-head);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
margin: 0 0 0.6rem;
|
||||
}
|
||||
|
||||
h1 { color: var(--accent); font-size: 1.9rem; }
|
||||
h2 { font-size: 1.2rem; border-bottom: 1px solid var(--border); padding-bottom: 0.3rem; }
|
||||
|
||||
a { color: var(--accent); }
|
||||
|
||||
header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
padding: 0.8rem 1.2rem;
|
||||
background: var(--surface);
|
||||
border-bottom: 2px solid var(--accent-2);
|
||||
}
|
||||
|
||||
.brand {
|
||||
font-family: var(--font-head);
|
||||
font-size: 1.3rem;
|
||||
text-transform: uppercase;
|
||||
text-decoration: none;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
nav { display: flex; align-items: center; gap: 1rem; }
|
||||
nav a { text-decoration: none; }
|
||||
|
||||
main { max-width: 52rem; margin: 0 auto; padding: 1.5rem 1.2rem 4rem; }
|
||||
section { margin-bottom: 2.5rem; }
|
||||
|
||||
.muted { color: var(--muted); }
|
||||
.error { color: var(--error); display: block; font-size: 0.9rem; }
|
||||
|
||||
.tag {
|
||||
font-size: 0.7rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
background: var(--accent-2);
|
||||
padding: 0.1rem 0.4rem;
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
|
||||
.flash {
|
||||
max-width: 52rem;
|
||||
margin: 1rem auto 0;
|
||||
padding: 0.7rem 1rem;
|
||||
background: var(--surface-2);
|
||||
border-left: 3px solid var(--accent);
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
|
||||
.avatar {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
border-radius: 50%;
|
||||
background: var(--accent-2);
|
||||
font-family: var(--font-head);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
form.stack { display: flex; flex-direction: column; gap: 0.9rem; max-width: 24rem; }
|
||||
form.stack label { display: flex; flex-direction: column; gap: 0.25rem; }
|
||||
form.stack label.row { flex-direction: row; align-items: center; gap: 0.5rem; }
|
||||
|
||||
input {
|
||||
background: var(--surface-2);
|
||||
color: var(--text);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 0.5rem 0.6rem;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
input:focus-visible, button:focus-visible, a:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
button {
|
||||
background: var(--accent);
|
||||
color: #150c07;
|
||||
border: 0;
|
||||
border-radius: var(--radius);
|
||||
padding: 0.5rem 0.9rem;
|
||||
font: inherit;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button:hover { background: #ff7043; }
|
||||
|
||||
button.link {
|
||||
background: none;
|
||||
color: var(--accent);
|
||||
padding: 0;
|
||||
font-weight: normal;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
table { width: 100%; border-collapse: collapse; margin-top: 0.8rem; }
|
||||
th, td { text-align: left; padding: 0.5rem 0.4rem; border-bottom: 1px solid var(--border); }
|
||||
th { font-family: var(--font-head); text-transform: uppercase; font-size: 0.8rem; color: var(--muted); }
|
||||
tr.banned { opacity: 0.55; }
|
||||
|
||||
.actions { display: flex; flex-wrap: wrap; gap: 0.4rem; }
|
||||
.actions form { display: flex; gap: 0.3rem; }
|
||||
.actions input { width: 10rem; }
|
||||
|
||||
code { background: var(--surface-2); padding: 0.1rem 0.35rem; border-radius: var(--radius); }
|
||||
|
||||
.invite { word-break: break-all; }
|
||||
@@ -0,0 +1,56 @@
|
||||
{{define "content"}}
|
||||
<h1>Ylläpito</h1>
|
||||
|
||||
<section>
|
||||
<h2>Kutsut</h2>
|
||||
<form method="post" action="/admin/invites"><button type="submit">Luo kutsukoodi</button></form>
|
||||
<table>
|
||||
<thead><tr><th>Kutsulinkki</th><th>Tila</th><th>Luotu</th></tr></thead>
|
||||
<tbody>
|
||||
{{range .Data.Invites}}
|
||||
<tr>
|
||||
<td>
|
||||
{{if .IsValid}}
|
||||
<a href="{{.Link}}" class="invite">{{.Link}}</a>
|
||||
{{else}}
|
||||
<code class="muted">{{.Code}}</code>
|
||||
{{end}}
|
||||
</td>
|
||||
<td>{{if .IsValid}}käyttämätön{{else}}käytetty{{end}}</td>
|
||||
<td>{{fidate .CreatedAt}}</td>
|
||||
</tr>
|
||||
{{else}}
|
||||
<tr><td colspan="3" class="muted">Ei kutsuja.</td></tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
<p class="muted">Lähetä linkki kaverille — se avaa liittymislomakkeen koodi valmiiksi täytettynä.</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Jäsenet</h2>
|
||||
<table>
|
||||
<thead><tr><th>Nimi</th><th>Sähköposti</th><th>Liittyi</th><th>Toiminnot</th></tr></thead>
|
||||
<tbody>
|
||||
{{range .Data.Members}}
|
||||
<tr{{if .Banned}} class="banned"{{end}}>
|
||||
<td>{{.Name}}{{if .Banned}} <span class="tag">estetty</span>{{end}}</td>
|
||||
<td>{{.Email}}</td>
|
||||
<td>{{fidate .CreatedAt}}</td>
|
||||
<td class="actions">
|
||||
<form method="post" action="/admin/users/{{.ID}}/ban">
|
||||
<button type="submit">{{if .Banned}}Poista esto{{else}}Estä{{end}}</button>
|
||||
</form>
|
||||
<form method="post" action="/admin/users/{{.ID}}/password">
|
||||
<input type="password" name="password" placeholder="uusi salasana" required>
|
||||
<button type="submit">Vaihda salasana</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{{else}}
|
||||
<tr><td colspan="4" class="muted">Ei jäseniä. Luo kutsukoodi ja lähetä se jollekulle.</td></tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
{{end}}
|
||||
@@ -0,0 +1,5 @@
|
||||
{{define "content"}}
|
||||
<h1>Jono</h1>
|
||||
<p class="muted">Jono on tyhjä — kappaleita ei vielä voi lähettää. Tämä sivu täyttyy kun
|
||||
lähetysputki ja arvostelut ovat valmiit.</p>
|
||||
{{end}}
|
||||
@@ -0,0 +1,29 @@
|
||||
<!doctype html>
|
||||
<html lang="fi">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{{.Title}} — Levyraati</title>
|
||||
<link rel="stylesheet" href="/static/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<a class="brand" href="/">Levyraati{{if .Admin}} <span class="tag">ylläpito</span>{{end}}</a>
|
||||
<nav>
|
||||
{{if .Admin}}
|
||||
<a href="/admin">Ylläpito</a>
|
||||
{{else if .Member}}
|
||||
<a href="/">Jono</a>
|
||||
<span class="avatar" title="{{.Member.Name}}">{{.Member.Initials}}</span>
|
||||
<form method="post" action="/logout"><button class="link">Kirjaudu ulos</button></form>
|
||||
{{else}}
|
||||
<a href="/login">Kirjaudu</a>
|
||||
{{end}}
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
{{with .Flash}}<p class="flash">{{.}}</p>{{end}}
|
||||
|
||||
<main>{{template "content" .}}</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,21 @@
|
||||
{{define "content"}}
|
||||
<h1>Kirjaudu</h1>
|
||||
|
||||
{{with .Data.Errors.form}}<p class="error">{{.}}</p>{{end}}
|
||||
|
||||
<form method="post" action="/login" class="stack">
|
||||
<label>Sähköposti
|
||||
<input type="email" name="email" value="{{.Data.Email}}" required autofocus autocomplete="email">
|
||||
</label>
|
||||
<label>Salasana
|
||||
<input type="password" name="password" required autocomplete="current-password">
|
||||
</label>
|
||||
<label class="row">
|
||||
<input type="checkbox" name="remember" value="1"> Pysy kirjautuneena 30 päivää
|
||||
</label>
|
||||
<button type="submit">Kirjaudu</button>
|
||||
</form>
|
||||
|
||||
<p class="muted">Levyraati on kutsuvierasklubi. Kutsukoodilla pääset mukaan
|
||||
<a href="/register">tästä</a>.</p>
|
||||
{{end}}
|
||||
@@ -0,0 +1,25 @@
|
||||
{{define "content"}}
|
||||
<h1>Liity</h1>
|
||||
|
||||
<form method="post" action="/register" class="stack">
|
||||
<label>Kutsukoodi
|
||||
<input name="code" value="{{.Data.Code}}" required>
|
||||
{{with .Data.Errors.code}}<span class="error">{{.}}</span>{{end}}
|
||||
</label>
|
||||
<label>Nimi
|
||||
<input name="name" value="{{.Data.Name}}" required maxlength="50">
|
||||
{{with .Data.Errors.name}}<span class="error">{{.}}</span>{{end}}
|
||||
</label>
|
||||
<label>Sähköposti
|
||||
<input type="email" name="email" value="{{.Data.Email}}" required autocomplete="email">
|
||||
{{with .Data.Errors.email}}<span class="error">{{.}}</span>{{end}}
|
||||
</label>
|
||||
<label>Salasana
|
||||
<input type="password" name="password" required autocomplete="new-password">
|
||||
{{with .Data.Errors.password}}<span class="error">{{.}}</span>{{end}}
|
||||
</label>
|
||||
<button type="submit">Liity</button>
|
||||
</form>
|
||||
|
||||
<p class="muted">Sähköpostiosoite on kirjautumistunnuksesi. Levyraati ei lähetä sähköpostia.</p>
|
||||
{{end}}
|
||||
Reference in New Issue
Block a user