diff --git a/internal/caldav/caldav.go b/internal/caldav/caldav.go
index cf143e75..d37e0cf0 100644
--- a/internal/caldav/caldav.go
+++ b/internal/caldav/caldav.go
@@ -208,9 +208,9 @@ func (c *Client) conflictConns(ctx context.Context, userID string) ([]conn, erro
return nil, err
}
// Resolve selection + decrypt after the cursor is closed (single-connection DB pool; the
- // ConflictCalendarIDs query would deadlock against an open cursor). CalDAV binds one
- // calendar per connection, so the sub-calendar picker is simply an on/off toggle: an empty
- // result means the user deselected it.
+ // ConflictCalendarIDs query would deadlock against an open cursor). One connection row
+ // can bind several selected collections (the picker), so every selected URL gets its
+ // own REPORT below; an empty result means the user deselected the whole account.
var conns []conn
for _, d := range data {
calIDs, err := calendar.ConflictCalendarIDs(ctx, c.db, "caldav", userID, d.username, d.calURL)
@@ -225,7 +225,9 @@ func (c *Client) conflictConns(ctx context.Context, userID string) ([]conn, erro
c.logger.Warn("caldav: skipping connection with bad credentials", "user_id", userID, "error", err)
continue
}
- conns = append(conns, conn{id: d.id, username: d.username, password: string(pw), calURL: d.calURL})
+ for _, calURL := range calIDs {
+ conns = append(conns, conn{id: d.id, username: d.username, password: string(pw), calURL: calURL})
+ }
}
return conns, nil
}
@@ -234,8 +236,8 @@ func (c *Client) conflictConns(ctx context.Context, userID string) ([]conn, erro
// a user can connect several. On a new connection: check_conflicts=1, and it becomes the
// destination only if the user has none yet. On a re-connect (row already exists for this
// account): the existing check_conflicts/is_destination flags are preserved and only the
-// password + calendar URL are refreshed. Mirrors gcal.saveToken.
-func (c *Client) saveConnection(ctx context.Context, userID, accountEmail, password, calURL string) error {
+// password + calendar URL (+ home URL) are refreshed. Mirrors gcal.saveToken.
+func (c *Client) saveConnection(ctx context.Context, userID, accountEmail, password, calURL, homeURL string) error {
pwEnc, err := c.encrypt([]byte(password))
if err != nil {
return err
@@ -261,9 +263,9 @@ func (c *Client) saveConnection(ctx context.Context, userID, accountEmail, passw
if _, err := tx.ExecContext(ctx, `
INSERT INTO calendar_connections
(id, user_id, provider, account_email, access_token_enc, calendar_id,
- check_conflicts, is_destination, created_at)
- VALUES (?, ?, 'caldav', ?, ?, ?, ?, ?, ?)`,
- uid.New(), userID, accountEmail, pwEnc, calURL, checkConflicts, isDest, now); err != nil {
+ check_conflicts, is_destination, caldav_home_url, created_at)
+ VALUES (?, ?, 'caldav', ?, ?, ?, ?, ?, ?, ?)`,
+ uid.New(), userID, accountEmail, pwEnc, calURL, checkConflicts, isDest, homeURL, now); err != nil {
return fmt.Errorf("caldav: save insert: %w", err)
}
return tx.Commit()
diff --git a/internal/caldav/caldav_test.go b/internal/caldav/caldav_test.go
index ab24ed7a..fee684fa 100644
--- a/internal/caldav/caldav_test.go
+++ b/internal/caldav/caldav_test.go
@@ -81,10 +81,10 @@ func TestSaveConnection_multiAccountDestinationInvariants(t *testing.T) {
ctx := context.Background()
seedUser(t, c.db, "u1")
- if err := c.saveConnection(ctx, "u1", "a@icloud.com", "pw1", "https://x/cal/a/"); err != nil {
+ if err := c.saveConnection(ctx, "u1", "a@icloud.com", "pw1", "https://x/cal/a/", "https://x/home/"); err != nil {
t.Fatalf("save a: %v", err)
}
- if err := c.saveConnection(ctx, "u1", "b@icloud.com", "pw2", "https://x/cal/b/"); err != nil {
+ if err := c.saveConnection(ctx, "u1", "b@icloud.com", "pw2", "https://x/cal/b/", "https://x/home/"); err != nil {
t.Fatalf("save b: %v", err)
}
@@ -103,7 +103,7 @@ func TestSaveConnection_multiAccountDestinationInvariants(t *testing.T) {
if err := svc.SetDestination(ctx, "u1", "caldav", "b@icloud.com"); err != nil {
t.Fatalf("set dest b: %v", err)
}
- if err := c.saveConnection(ctx, "u1", "b@icloud.com", "pw2-rotated", "https://x/cal/b/"); err != nil {
+ if err := c.saveConnection(ctx, "u1", "b@icloud.com", "pw2-rotated", "https://x/cal/b/", "https://x/home/"); err != nil {
t.Fatalf("re-save b: %v", err)
}
if dest := destEmail(t, c.db, "u1"); dest != "b@icloud.com" {
@@ -114,15 +114,20 @@ func TestSaveConnection_multiAccountDestinationInvariants(t *testing.T) {
}
}
-// A fake CalDAV server: answers the three discovery PROPFINDs, a calendar-query REPORT, and
-// records PUT/DELETE so write-back can be asserted.
-func fakeServer(t *testing.T, putBody, deletePath *string) *httptest.Server {
+// A fake CalDAV server: answers the three discovery PROPFINDs, a calendar-query REPORT,
+// and records PUT/DELETE so write-back can be asserted. The home exposes two VEVENT
+// calendars (work, personal) plus a tasks-only collection that must be filtered out.
+// REPORT targets are appended to reportPaths so multi-calendar conflict checks can be
+// asserted.
+func fakeServer(t *testing.T, putBody, deletePath *string, reportPaths *[]string) *httptest.Server {
t.Helper()
const principal = `
/HTTP/1.1 200 OK/principals/user/`
const home = `
/principals/user/HTTP/1.1 200 OK/calendars/user/`
const collections = `
/calendars/user/HTTP/1.1 200 OK
/calendars/user/work/HTTP/1.1 200 OKCalendar
+ /calendars/user/personal/HTTP/1.1 200 OKPersonal
+ /calendars/user/tasks/HTTP/1.1 200 OKReminders
`
// One busy event, one TRANSPARENT (ignored), one CANCELLED (ignored).
const report = `
/calendars/user/work/ev1.icsHTTP/1.1 200 OKBEGIN:VCALENDAR
@@ -144,6 +149,14 @@ DTSTART:20260625T130000Z
DTEND:20260625T140000Z
STATUS:CANCELLED
END:VEVENT
+END:VCALENDAR`
+ const personalReport = `
/calendars/user/personal/ev9.icsHTTP/1.1 200 OKBEGIN:VCALENDAR
+BEGIN:VEVENT
+UID:personal-busy@x
+DTSTART:20260625T150000Z
+DTEND:20260625T160000Z
+SUMMARY:Personal busy
+END:VEVENT
END:VCALENDAR`
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -158,8 +171,17 @@ END:VCALENDAR
`
)
-// Connect validates the given CalDAV credentials by discovering the user's default calendar
-// collection, then stores the connection (encrypting the app password). Returns the resolved
-// account email and calendar URL. The serverURL is a base (a Presets value or a user-supplied
-// Nextcloud/custom URL); discovery resolves the rest. An auth failure or a server with no
-// VEVENT-capable calendar is surfaced as an error so the connect form can show it.
+// Connect validates the given CalDAV credentials by discovering the user's calendar
+// collections, then stores the connection (encrypting the app password) bound to the
+// default collection. Every discovered collection is seeded into connection_calendars
+// so the calendar picker lists them all with their server-side names. Returns the
+// resolved account email and calendar URL. The serverURL is a base (a Presets value or
+// a user-supplied Nextcloud/custom URL); discovery resolves the rest. An auth failure
+// or a server with no VEVENT-capable calendar is surfaced as an error so the connect
+// form can show it.
func (c *Client) Connect(ctx context.Context, userID, serverURL, username, password string) (accountEmail, calURL string, err error) {
username = strings.TrimSpace(username)
if username == "" || password == "" || strings.TrimSpace(serverURL) == "" {
return "", "", fmt.Errorf("caldav: server URL, username and app password are all required")
}
- calURL, err = c.discoverCalendar(ctx, strings.TrimSpace(serverURL), username, password)
+ home, cals, err := c.discoverCalendars(ctx, strings.TrimSpace(serverURL), username, password)
if err != nil {
return "", "", err
}
+ calURL = preferDefault(cals)
accountEmail = strings.ToLower(username)
- if err := c.saveConnection(ctx, userID, accountEmail, password, calURL); err != nil {
+ if err := c.saveConnection(ctx, userID, accountEmail, password, calURL, home); err != nil {
+ return "", "", err
+ }
+ if err := c.seedDiscoveredCalendars(ctx, userID, accountEmail, calURL, cals); err != nil {
return "", "", err
}
return accountEmail, calURL, nil
}
-// discoverCalendar walks principal → calendar-home → calendar collections and returns the URL
-// of the first VEVENT-capable calendar collection.
-func (c *Client) discoverCalendar(ctx context.Context, serverURL, username, password string) (string, error) {
+// preferDefault picks the bound collection out of the discovered set: a calendar
+// literally named/pathed "calendar" wins when present, else the first one. Discovery
+// guarantees at least one entry.
+func preferDefault(cals []discoveredCalendar) string {
+ first := cals[0].URL
+ for _, cal := range cals {
+ if strings.EqualFold(cal.Name, "Calendar") || strings.Contains(strings.ToLower(cal.URL), "/calendar") {
+ return cal.URL
+ }
+ }
+ return first
+}
+
+// discoverCalendars walks principal → calendar-home → calendar collections and returns
+// the home URL plus every VEVENT-capable collection, with server-side display names.
+func (c *Client) discoverCalendars(ctx context.Context, serverURL, username, password string) (home string, cals []discoveredCalendar, err error) {
// 1. current-user-principal — try the base URL, then the RFC 5785 well-known path.
principal, base, err := c.findPrincipal(ctx, serverURL, username, password)
if err != nil {
- return "", err
+ return "", nil, err
}
// 2. calendar-home-set on the principal.
ms, homeReqURL, err := c.propfind(ctx, principal, username, password, "0", propCalendarHomeSet)
if err != nil {
- return "", err
+ return "", nil, err
}
- var home string
for _, r := range ms.Responses {
if h := r.okProp().CalendarHomeSet.Href; h != "" {
home = resolveRef(homeReqURL, h)
@@ -74,12 +102,11 @@ func (c *Client) discoverCalendar(ctx context.Context, serverURL, username, pass
home = principal
}
- // 3. list calendar collections (Depth 1) and pick the first that supports VEVENT.
+ // 3. list calendar collections (Depth 1), keeping every VEVENT-capable one.
ms, homeReqURL, err = c.propfind(ctx, home, username, password, "1", propCalendarCollections)
if err != nil {
- return "", err
+ return "", nil, err
}
- var firstCal string
for _, r := range ms.Responses {
p := r.okProp()
if p.ResourceType.Calendar == nil {
@@ -89,19 +116,44 @@ func (c *Client) discoverCalendar(ctx context.Context, serverURL, username, pass
continue // e.g. a tasks/reminders or birthdays calendar
}
u := resolveRef(homeReqURL, r.Href)
- if firstCal == "" {
- firstCal = u
- }
- // Prefer a calendar literally named/pathed "calendar" as the default when present.
- if strings.EqualFold(p.DisplayName, "Calendar") || strings.Contains(strings.ToLower(u), "/calendar") {
- return u, nil
+ name := strings.TrimSpace(p.DisplayName)
+ if name == "" {
+ name = path.Base(strings.TrimSuffix(u, "/"))
+ if name == "" || name == "." || name == "/" {
+ name = "Calendar"
+ }
}
+ cals = append(cals, discoveredCalendar{URL: u, Name: name})
}
- if firstCal == "" {
+ if len(cals) == 0 {
_ = base
- return "", fmt.Errorf("caldav: no writable calendar found on the server")
+ return "", nil, fmt.Errorf("caldav: no writable calendar found on the server")
+ }
+ return home, cals, nil
+}
+
+// seedDiscoveredCalendars records every discovered collection in connection_calendars
+// so the calendar picker lists them all immediately with server-side names. The bound
+// (default) collection is seeded conflict-checked, preserving the pre-picker behaviour
+// where a fresh connection checks its one calendar; the rest start unchecked for the
+// user to tick. Names refresh on every (re-)connect; the user's check_conflicts /
+// is_destination flags are preserved (only name is overwritten on conflict) — a
+// reconnect never resets the picker.
+func (c *Client) seedDiscoveredCalendars(ctx context.Context, userID, accountEmail, boundCalURL string, cals []discoveredCalendar) error {
+ for _, cal := range cals {
+ check := 0
+ if cal.URL == boundCalURL {
+ check = 1
+ }
+ if _, err := c.db.ExecContext(ctx, `
+ INSERT INTO connection_calendars (id, user_id, provider, account_email, calendar_id, name, check_conflicts, is_destination)
+ VALUES (?, ?, 'caldav', ?, ?, ?, ?, 0)
+ ON CONFLICT(user_id, provider, account_email, calendar_id) DO UPDATE SET name = excluded.name`,
+ uid.New(), userID, accountEmail, cal.URL, cal.Name, check); err != nil {
+ return fmt.Errorf("caldav: seed calendars: %w", err)
+ }
}
- return firstCal, nil
+ return nil
}
// findPrincipal resolves current-user-principal, trying the base URL first and then the
diff --git a/internal/db/migrations/00058_password_reset_tokens.sql b/internal/db/migrations/00058_password_reset_tokens.sql
new file mode 100644
index 00000000..d358a568
--- /dev/null
+++ b/internal/db/migrations/00058_password_reset_tokens.sql
@@ -0,0 +1,15 @@
+-- +goose Up
+-- One-time, short-lived password-reset tokens emailed to a user. We store only the
+-- SHA-256 of the token (never the raw value); single-use is enforced by used_at.
+-- Mirrors magic_link_tokens (00040) with a longer TTL: a reset email sits in an
+-- inbox longer than a just-requested login link.
+CREATE TABLE password_reset_tokens (
+ token_hash TEXT PRIMARY KEY,
+ user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ expires_at TEXT NOT NULL,
+ used_at TEXT,
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
+);
+
+-- +goose Down
+DROP TABLE password_reset_tokens;
diff --git a/internal/db/migrations/00059_caldav_home_url.sql b/internal/db/migrations/00059_caldav_home_url.sql
new file mode 100644
index 00000000..47a51bb6
--- /dev/null
+++ b/internal/db/migrations/00059_caldav_home_url.sql
@@ -0,0 +1,10 @@
+-- +goose Up
+-- CalDAV calendar-home URL per connection. Discovery resolves principal ->
+-- calendar-home -> collections once at connect time; the home URL is needed later
+-- to re-list every collection (ListCalendars) without the server base URL, which
+-- is not stored. Existing connections get NULL and keep the previous behaviour
+-- (the single bound calendar) until reconnected.
+ALTER TABLE calendar_connections ADD COLUMN caldav_home_url TEXT;
+
+-- +goose Down
+-- SQLite doesn't support DROP COLUMN before v3.35; leave the column in place.
diff --git a/internal/handler/booking_abuse_test.go b/internal/handler/booking_abuse_test.go
index 4398b64a..228ec968 100644
--- a/internal/handler/booking_abuse_test.go
+++ b/internal/handler/booking_abuse_test.go
@@ -8,13 +8,13 @@ import (
"testing"
)
-// TestCreateBooking_honeypotRejected: a filled honeypot ("company") field marks an
+// TestCreateBooking_honeypotRejected: a filled honeypot ("hp_extra") field marks an
// automated submission and is rejected without creating a booking.
func TestCreateBooking_honeypotRejected(t *testing.T) {
h, key, _ := setupWorkspace(t)
slug, _ := seedEventTypeHTTP(t, h, key)
- body := fmt.Sprintf(`{"event_type_slug":%q,"start_at":"2026-06-15T09:00:00Z","name":"Bot","email":"bot@example.com","company":"Acme Spam Co"}`, slug)
+ body := fmt.Sprintf(`{"event_type_slug":%q,"start_at":"2026-06-15T09:00:00Z","name":"Bot","email":"bot@example.com","hp_extra":"Acme Spam Co"}`, slug)
req := httptest.NewRequest(http.MethodPost, "/v1/bookings", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
@@ -25,6 +25,25 @@ func TestCreateBooking_honeypotRejected(t *testing.T) {
}
}
+// TestCreateBooking_legacyCompanyFieldIgnored: the honeypot used to be named "company",
+// which browsers autofill for real humans (#33). A submission carrying the old field
+// (e.g. an autofilled legacy page, or a stale embed) must be treated as a normal
+// booking, not a bot.
+func TestCreateBooking_legacyCompanyFieldIgnored(t *testing.T) {
+ h, key, _ := setupWorkspace(t)
+ slug, _ := seedEventTypeHTTP(t, h, key)
+
+ body := fmt.Sprintf(`{"event_type_slug":%q,"start_at":"2026-06-15T09:00:00Z","name":"Sam","email":"sam@example.com","company":"Acme Corp"}`, slug)
+ req := httptest.NewRequest(http.MethodPost, "/v1/bookings", strings.NewReader(body))
+ req.Header.Set("Content-Type", "application/json")
+ rec := httptest.NewRecorder()
+ h.CreateBooking(rec, req)
+
+ if rec.Code != http.StatusCreated {
+ t.Errorf("booking with legacy company value: %d; want 201 — %s", rec.Code, rec.Body.String())
+ }
+}
+
// TestCreateBooking_perEmailThrottle: one email can create up to the hourly cap
// across distinct slots, then is throttled with 429.
func TestCreateBooking_perEmailThrottle(t *testing.T) {
diff --git a/internal/handler/booking_handler.go b/internal/handler/booking_handler.go
index 6b7abe35..c695305e 100644
--- a/internal/handler/booking_handler.go
+++ b/internal/handler/booking_handler.go
@@ -649,7 +649,10 @@ func (h *Handler) CreateBooking(w http.ResponseWriter, r *http.Request) {
// calnode_lang cookie, and a site owner's forced lang= override wouldn't be visible
// from headers alone. See internal-docs/i18n-plan.md.
Language string `json:"language"`
- Company string `json:"company"` // honeypot: a hidden form field; must stay empty
+ // Deliberately NOT named "company": browsers map that to the organization
+ // autofill entry (ignoring autocomplete="off") and fill this invisible field
+ // for real humans, who are then rejected as bots (#33).
+ Honeypot string `json:"hp_extra"`
Answers []struct {
QuestionID string `json:"question_id"`
Value string `json:"value"`
@@ -662,7 +665,7 @@ func (h *Handler) CreateBooking(w http.ResponseWriter, r *http.Request) {
// Honeypot: a field hidden from humans on the booking form. A non-empty value
// means an automated submission — reject with a generic error.
- if strings.TrimSpace(req.Company) != "" {
+ if strings.TrimSpace(req.Honeypot) != "" {
h.logger.InfoContext(r.Context(), "booking rejected: honeypot filled")
h.writeError(w, http.StatusBadRequest, "invalid submission")
return
@@ -1802,14 +1805,17 @@ func (h *Handler) loadCancellationData(ctx context.Context, b *booking.Booking)
d.LocationValue = b.LocationValue
d.CancellationReason = b.CancellationReason
- // Event type name + slug and host name + email in one join.
+ // Event type name + slug and the assigned host's name + email. The host
+ // comes from the booking (b.HostID), not the event-type owner: with
+ // multi-host event types the owner rarely hosts the booking (#48).
err := h.db.QueryRowContext(ctx, `
- SELECT et.name, et.slug, u.name, u.email
- FROM event_types et JOIN users u ON u.id = et.user_id
- WHERE et.id = ?`, b.EventTypeID).
- Scan(&d.EventTypeName, &d.EventTypeSlug, &d.HostName, &d.HostEmail)
+ SELECT et.name, et.slug FROM event_types et WHERE et.id = ?`, b.EventTypeID).
+ Scan(&d.EventTypeName, &d.EventTypeSlug)
if err != nil {
- return d, fmt.Errorf("load event/host: %w", err)
+ return d, fmt.Errorf("load event: %w", err)
+ }
+ if err := h.loadHostIntoData(ctx, b.HostID, &d); err != nil {
+ return d, fmt.Errorf("load host: %w", err)
}
// Organizer attendee.
diff --git a/internal/handler/embed.js b/internal/handler/embed.js
index 4853ddea..f9f1c279 100644
--- a/internal/handler/embed.js
+++ b/internal/handler/embed.js
@@ -555,7 +555,7 @@
var back = el('button', { class: 'back-btn', html: SVG_BACK + ' ' + t(this.i18n, 'back') });
back.addEventListener('click', function () { self.state.view = 'pick'; self.render(); });
var form = el('form', { novalidate: 'novalidate' });
- var hp = el('input', { type: 'text', tabindex: '-1', autocomplete: 'off' });
+ var hp = el('input', { type: 'text', name: 'hp_extra', tabindex: '-1', autocomplete: 'off' });
form.appendChild(el('div', { 'aria-hidden': 'true', style: 'position:absolute;left:-5000px;height:0;width:0;overflow:hidden;' }, [hp]));
var name = el('input', { type: 'text', required: 'required', autocomplete: 'name', placeholder: t(this.i18n, 'name_placeholder') });
var email = el('input', { type: 'email', required: 'required', autocomplete: 'email', placeholder: t(this.i18n, 'email_placeholder') });
@@ -603,7 +603,7 @@
});
fetch(BASE + '/v1/bookings', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ event_type_slug: self.slug, start_at: slot.start, name: name.value.trim(), email: email.value.trim().toLowerCase(), timezone: TZ, language: self.locale, company: hp.value, answers: answers }),
+ body: JSON.stringify({ event_type_slug: self.slug, start_at: slot.start, name: name.value.trim(), email: email.value.trim().toLowerCase(), timezone: TZ, language: self.locale, hp_extra: hp.value, answers: answers }),
}).then(function (r) {
return r.json().then(function (data) { return { ok: r.ok, status: r.status, data: data }; });
}).then(function (res) {
diff --git a/internal/handler/password_reset.go b/internal/handler/password_reset.go
new file mode 100644
index 00000000..add65c66
--- /dev/null
+++ b/internal/handler/password_reset.go
@@ -0,0 +1,187 @@
+package handler
+
+import (
+ "context"
+ "crypto/rand"
+ "crypto/sha256"
+ "database/sql"
+ "encoding/hex"
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "strings"
+ "time"
+
+ "github.com/calnode/calnode/internal/mailer"
+ "golang.org/x/crypto/bcrypt"
+)
+
+// resetLinkTTL is longer than the magic-link TTL: a reset email sits in an
+// inbox longer than a just-requested login link, and the token is single-use
+// and hashed at rest either way.
+const resetLinkTTL = time.Hour
+
+// RequestPasswordReset handles POST /v1/auth/password/forgot — emails a one-time
+// password-reset link to the address if it belongs to an active account with email
+// login enabled. Always responds 200 with the same message (no account enumeration):
+// the user lookup happens inline (a single indexed SELECT), but token generation,
+// the DB write, and the email send are dispatched to a background goroutine rather
+// than awaited, so a timing attacker can't distinguish "no such user" from "found
+// user, still emailing" — the same pattern as RequestMagicLink.
+func (h *Handler) RequestPasswordReset(w http.ResponseWriter, r *http.Request) {
+ r.Body = http.MaxBytesReader(w, r.Body, 4<<10)
+ var req struct {
+ Email string `json:"email"`
+ }
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ h.writeError(w, http.StatusBadRequest, "invalid JSON")
+ return
+ }
+ email := strings.TrimSpace(strings.ToLower(req.Email))
+ if email != "" {
+ var userID string
+ var archivedAt sql.NullString
+ var emailLogin int
+ err := h.db.QueryRowContext(r.Context(),
+ `SELECT id, archived_at, email_login FROM users WHERE email = ?`, email).
+ Scan(&userID, &archivedAt, &emailLogin)
+ if err == nil && !archivedAt.Valid && emailLogin != 0 {
+ go h.sendPasswordReset(context.WithoutCancel(r.Context()), userID, email)
+ }
+ }
+ h.writeJSON(w, http.StatusOK, map[string]any{
+ "ok": true,
+ "message": "If an account with that email exists, a password-reset link is on its way.",
+ })
+}
+
+// sendPasswordReset generates and stores a token and emails the reset link. Run in a
+// background goroutine by RequestPasswordReset so its variable cost (DB write +
+// mailer round-trip) never shows up in the HTTP response timing.
+func (h *Handler) sendPasswordReset(ctx context.Context, userID, email string) {
+ rawBytes := make([]byte, 32)
+ if _, err := rand.Read(rawBytes); err != nil {
+ h.logger.ErrorContext(ctx, "password reset: rand", "error", err)
+ return
+ }
+ raw := hex.EncodeToString(rawBytes)
+ sum := sha256.Sum256([]byte(raw))
+ tokenHash := hex.EncodeToString(sum[:])
+ expiresAt := time.Now().UTC().Add(resetLinkTTL).Format(time.RFC3339)
+
+ if _, err := h.db.ExecContext(ctx,
+ `INSERT INTO password_reset_tokens (token_hash, user_id, expires_at) VALUES (?, ?, ?)`,
+ tokenHash, userID, expiresAt); err != nil {
+ h.logger.ErrorContext(ctx, "password reset: store token", "error", err)
+ return
+ }
+
+ link := h.baseURL + "/admin/reset-password?token=" + raw
+ if h.mailer != nil {
+ if err := h.mailer.Send(ctx, resetPasswordMessage(email, link)); err != nil {
+ h.logger.ErrorContext(ctx, "password reset: send email", "error", err, "user_id", userID)
+ }
+ }
+}
+
+// ResetPassword handles POST /v1/auth/password/reset — consumes a reset token and
+// sets a new password. The token proves ownership of the email address, so no session
+// is required; on success every existing session is revoked and a fresh one is
+// created, signing the user straight in (same assurance as VerifyMagicLink).
+func (h *Handler) ResetPassword(w http.ResponseWriter, r *http.Request) {
+ r.Body = http.MaxBytesReader(w, r.Body, 4<<10)
+ var req struct {
+ Token string `json:"token"`
+ NewPassword string `json:"new_password"`
+ }
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ h.writeError(w, http.StatusBadRequest, "invalid JSON")
+ return
+ }
+ // Validate the password before touching the token so a weak-password attempt
+ // doesn't burn the single-use link.
+ if msg := validatePassword(req.NewPassword); msg != "" {
+ h.writeError(w, http.StatusBadRequest, msg)
+ return
+ }
+ if req.Token == "" {
+ h.writeError(w, http.StatusBadRequest, "reset link is invalid or has expired")
+ return
+ }
+ sum := sha256.Sum256([]byte(req.Token))
+ tokenHash := hex.EncodeToString(sum[:])
+ now := time.Now().UTC().Format(time.RFC3339)
+
+ // Atomically consume: only succeeds if unused and unexpired (single-use, race-safe).
+ res, err := h.db.ExecContext(r.Context(),
+ `UPDATE password_reset_tokens SET used_at = ? WHERE token_hash = ? AND used_at IS NULL AND expires_at > ?`,
+ now, tokenHash, now)
+ if err != nil {
+ h.logger.ErrorContext(r.Context(), "password reset: consume", "error", err)
+ h.writeError(w, http.StatusInternalServerError, "internal error")
+ return
+ }
+ if n, _ := res.RowsAffected(); n == 0 {
+ h.writeError(w, http.StatusBadRequest, "reset link is invalid or has expired")
+ return
+ }
+
+ var userID string
+ var archivedAt sql.NullString
+ var emailLogin int
+ if err := h.db.QueryRowContext(r.Context(),
+ `SELECT u.id, u.archived_at, u.email_login FROM password_reset_tokens t JOIN users u ON u.id = t.user_id WHERE t.token_hash = ?`,
+ tokenHash).Scan(&userID, &archivedAt, &emailLogin); err != nil {
+ h.logger.ErrorContext(r.Context(), "password reset: load user", "error", err)
+ h.writeError(w, http.StatusInternalServerError, "internal error")
+ return
+ }
+ // The token was emailed to this address, so saying *why* it can't be used leaks
+ // nothing — but an archived or email-login-disabled account must not gain access.
+ if archivedAt.Valid || emailLogin == 0 {
+ h.writeError(w, http.StatusForbidden, "password reset is not available for this account")
+ return
+ }
+
+ newHash, err := bcrypt.GenerateFromPassword([]byte(req.NewPassword), bcrypt.DefaultCost)
+ if err != nil {
+ h.logger.ErrorContext(r.Context(), "password reset: bcrypt", "error", err)
+ h.writeError(w, http.StatusInternalServerError, "internal error")
+ return
+ }
+ if _, err := h.db.ExecContext(r.Context(),
+ `UPDATE users SET password_hash = ? WHERE id = ?`, string(newHash), userID); err != nil {
+ h.logger.ErrorContext(r.Context(), "password reset: update", "error", err)
+ h.writeError(w, http.StatusInternalServerError, "internal error")
+ return
+ }
+ // Unauthenticated request, so there is no current session to preserve — revoke
+ // everything (a compromised session must not survive the reset), then sign in.
+ if _, err := h.db.ExecContext(r.Context(),
+ `DELETE FROM sessions WHERE user_id = ?`, userID); err != nil {
+ h.logger.ErrorContext(r.Context(), "password reset: revoke sessions", "error", err, "user_id", userID)
+ }
+ if err := h.createSession(r.Context(), w, userID); err != nil {
+ // The password IS reset; only the convenience sign-in failed. The login
+ // page bounces to /admin/login without a session, so report success and
+ // let the user sign in with the new password.
+ h.logger.ErrorContext(r.Context(), "password reset: create session", "error", err)
+ }
+ h.writeJSON(w, http.StatusOK, map[string]any{"ok": true})
+}
+
+// resetPasswordMessage builds the reset-link email (multipart text + minimal HTML).
+func resetPasswordMessage(to, link string) mailer.Message {
+ return mailer.Message{
+ To: []string{to},
+ Subject: "Reset your Calnode password",
+ Text: "Click the link below to set a new Calnode password. It expires in 1 hour and can be used once.\n\n" +
+ link + "\n\nIf you didn't request this, you can ignore this email — your password stays unchanged.",
+ HTML: fmt.Sprintf(`
`+
+ `
Click the button below to set a new Calnode password. It expires in 1 hour and can be used once.
`+
+ `
Set a new password
`+
+ `
Or paste this link into your browser:
%s
`+
+ `
If you didn't request this, you can ignore this email — your password stays unchanged.
`,
+ link, link, link),
+ }
+}
diff --git a/internal/handler/password_reset_test.go b/internal/handler/password_reset_test.go
new file mode 100644
index 00000000..5ec4dc60
--- /dev/null
+++ b/internal/handler/password_reset_test.go
@@ -0,0 +1,241 @@
+package handler_test
+
+import (
+ "crypto/sha256"
+ "database/sql"
+ "encoding/hex"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+ "time"
+
+ "golang.org/x/crypto/bcrypt"
+)
+
+func resetReq(token, newPassword string) *http.Request {
+ body := `{"token":"` + token + `","new_password":"` + newPassword + `"}`
+ return httptest.NewRequest(http.MethodPost, "/v1/auth/password/reset", strings.NewReader(body))
+}
+
+func seedResetToken(t *testing.T, db *sql.DB, raw, userID string, expiresAt time.Time) string {
+ t.Helper()
+ sum := sha256.Sum256([]byte(raw))
+ hash := hex.EncodeToString(sum[:])
+ if _, err := db.Exec(`INSERT INTO password_reset_tokens (token_hash,user_id,expires_at) VALUES (?,?,?)`,
+ hash, userID, expiresAt.UTC().Format(time.RFC3339)); err != nil {
+ t.Fatalf("seed reset token: %v", err)
+ }
+ return hash
+}
+
+func seedPasswordUser(t *testing.T, db *sql.DB, id, email, password string, emailLogin int) {
+ t.Helper()
+ hash, _ := bcrypt.GenerateFromPassword([]byte(password), bcrypt.MinCost)
+ if _, err := db.Exec(`INSERT INTO users (id,email,name,iana_timezone,is_admin,email_login,password_hash) VALUES (?,?,?, 'UTC',0,?,?)`,
+ id, email, "Test User", emailLogin, string(hash)); err != nil {
+ t.Fatalf("seed user: %v", err)
+ }
+}
+
+func TestPasswordReset_requestUnknownEmailGeneric(t *testing.T) {
+ h, db := newTestHandlerDB(t)
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodPost, "/v1/auth/password/forgot",
+ strings.NewReader(`{"email":"nobody@example.com"}`))
+ h.RequestPasswordReset(rec, req)
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d; want 200 (generic)", rec.Code)
+ }
+ var n int
+ db.QueryRow(`SELECT COUNT(*) FROM password_reset_tokens`).Scan(&n) //nolint:errcheck
+ if n != 0 {
+ t.Errorf("token rows = %d; want 0 for unknown email", n)
+ }
+}
+
+func TestPasswordReset_requestKnownEmailCreatesToken(t *testing.T) {
+ h, db := newTestHandlerDB(t)
+ seedPasswordUser(t, db, "u1", "known@example.com", "oldpassword", 1)
+
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodPost, "/v1/auth/password/forgot",
+ strings.NewReader(`{"email":"known@example.com"}`))
+ h.RequestPasswordReset(rec, req)
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status = %d; want 200", rec.Code)
+ }
+
+ deadline := time.Now().Add(2 * time.Second)
+ for time.Now().Before(deadline) {
+ var n int
+ db.QueryRow(`SELECT COUNT(*) FROM password_reset_tokens WHERE user_id = 'u1'`).Scan(&n) //nolint:errcheck
+ if n == 1 {
+ return
+ }
+ time.Sleep(5 * time.Millisecond)
+ }
+ t.Fatal("no token row for u1 after waiting (background send never completed)")
+}
+
+func TestPasswordReset_requestSkipsArchivedAndDisabled(t *testing.T) {
+ h, db := newTestHandlerDB(t)
+ seedPasswordUser(t, db, "u1", "archived@example.com", "oldpassword", 1)
+ db.Exec(`UPDATE users SET archived_at = '2026-01-01T00:00:00Z' WHERE id = 'u1'`) //nolint:errcheck
+ seedPasswordUser(t, db, "u2", "disabled@example.com", "oldpassword", 0)
+
+ for _, email := range []string{"archived@example.com", "disabled@example.com"} {
+ rec := httptest.NewRecorder()
+ req := httptest.NewRequest(http.MethodPost, "/v1/auth/password/forgot",
+ strings.NewReader(`{"email":"`+email+`"}`))
+ h.RequestPasswordReset(rec, req)
+ if rec.Code != http.StatusOK {
+ t.Fatalf("%s: status = %d; want generic 200", email, rec.Code)
+ }
+ }
+ time.Sleep(100 * time.Millisecond) // no goroutine should have been spawned; fail loudly if one was
+ var n int
+ db.QueryRow(`SELECT COUNT(*) FROM password_reset_tokens`).Scan(&n) //nolint:errcheck
+ if n != 0 {
+ t.Errorf("token rows = %d; want 0 (archived and email-login-disabled get no email)", n)
+ }
+}
+
+func TestPasswordReset_happyPath(t *testing.T) {
+ h, db := newTestHandlerDB(t)
+ seedPasswordUser(t, db, "u1", "alice@example.com", "oldpassword", 1)
+ seedResetToken(t, db, "rawtoken123", "u1", time.Now().Add(time.Hour))
+
+ rec := httptest.NewRecorder()
+ h.ResetPassword(rec, resetReq("rawtoken123", "newpassword1"))
+ if rec.Code != http.StatusOK {
+ t.Fatalf("reset status = %d; want 200 — %s", rec.Code, rec.Body.String())
+ }
+ found := false
+ for _, c := range rec.Result().Cookies() {
+ if c.Name == "calnode_session" && c.Value != "" {
+ found = true
+ }
+ }
+ if !found {
+ t.Error("reset did not set a session cookie (user should be signed straight in)")
+ }
+
+ // New password works, old one doesn't.
+ rec2 := httptest.NewRecorder()
+ h.LoginEmail(rec2, loginEmailReq("alice@example.com", "newpassword1"))
+ if rec2.Code != http.StatusOK {
+ t.Errorf("login with new password: got %d; want 200", rec2.Code)
+ }
+ rec3 := httptest.NewRecorder()
+ h.LoginEmail(rec3, loginEmailReq("alice@example.com", "oldpassword"))
+ if rec3.Code != http.StatusUnauthorized {
+ t.Errorf("login with old password: got %d; want 401", rec3.Code)
+ }
+}
+
+func TestPasswordReset_tokenSingleUse(t *testing.T) {
+ h, db := newTestHandlerDB(t)
+ seedPasswordUser(t, db, "u1", "a@example.com", "oldpassword", 1)
+ seedResetToken(t, db, "oncetoken", "u1", time.Now().Add(time.Hour))
+
+ rec := httptest.NewRecorder()
+ h.ResetPassword(rec, resetReq("oncetoken", "newpassword1"))
+ if rec.Code != http.StatusOK {
+ t.Fatalf("first reset: got %d; want 200", rec.Code)
+ }
+ rec2 := httptest.NewRecorder()
+ h.ResetPassword(rec2, resetReq("oncetoken", "anotherpw2"))
+ if rec2.Code != http.StatusBadRequest {
+ t.Errorf("second reset with same token: got %d; want 400", rec2.Code)
+ }
+}
+
+func TestPasswordReset_expiredToken(t *testing.T) {
+ h, db := newTestHandlerDB(t)
+ seedPasswordUser(t, db, "u1", "a@example.com", "oldpassword", 1)
+ seedResetToken(t, db, "expiredtok", "u1", time.Now().Add(-time.Minute))
+
+ rec := httptest.NewRecorder()
+ h.ResetPassword(rec, resetReq("expiredtok", "newpassword1"))
+ if rec.Code != http.StatusBadRequest {
+ t.Errorf("expired reset: got %d; want 400", rec.Code)
+ }
+}
+
+func TestPasswordReset_weakPasswordDoesNotConsume(t *testing.T) {
+ h, db := newTestHandlerDB(t)
+ seedPasswordUser(t, db, "u1", "a@example.com", "oldpassword", 1)
+ hash := seedResetToken(t, db, "retrytoken", "u1", time.Now().Add(time.Hour))
+
+ rec := httptest.NewRecorder()
+ h.ResetPassword(rec, resetReq("retrytoken", "short"))
+ if rec.Code != http.StatusBadRequest {
+ t.Fatalf("weak password reset: got %d; want 400", rec.Code)
+ }
+ var n int
+ db.QueryRow(`SELECT COUNT(*) FROM password_reset_tokens WHERE token_hash = ? AND used_at IS NULL`, hash).Scan(&n) //nolint:errcheck
+ if n != 1 {
+ t.Fatalf("weak attempt consumed the token; want it still usable")
+ }
+
+ // Same link works with a good password.
+ rec2 := httptest.NewRecorder()
+ h.ResetPassword(rec2, resetReq("retrytoken", "goodpassword1"))
+ if rec2.Code != http.StatusOK {
+ t.Errorf("retry with good password: got %d; want 200", rec2.Code)
+ }
+}
+
+func TestPasswordReset_archivedAndDisabledForbidden(t *testing.T) {
+ h, db := newTestHandlerDB(t)
+ seedPasswordUser(t, db, "u1", "archived@example.com", "oldpassword", 1)
+ db.Exec(`UPDATE users SET archived_at = '2026-01-01T00:00:00Z' WHERE id = 'u1'`) //nolint:errcheck
+ seedResetToken(t, db, "archtok", "u1", time.Now().Add(time.Hour))
+ seedPasswordUser(t, db, "u2", "disabled@example.com", "oldpassword", 0)
+ seedResetToken(t, db, "distok", "u2", time.Now().Add(time.Hour))
+
+ for _, tc := range []struct{ raw string }{{"archtok"}, {"distok"}} {
+ rec := httptest.NewRecorder()
+ h.ResetPassword(rec, resetReq(tc.raw, "newpassword1"))
+ if rec.Code != http.StatusForbidden {
+ t.Errorf("token %s: got %d; want 403", tc.raw, rec.Code)
+ }
+ for _, c := range rec.Result().Cookies() {
+ if c.Name == "calnode_session" {
+ t.Errorf("token %s: must not create a session", tc.raw)
+ }
+ }
+ }
+}
+
+func TestPasswordReset_revokesOldSessions(t *testing.T) {
+ h, db := newTestHandlerDB(t)
+ seedPasswordUser(t, db, "u1", "a@example.com", "oldpassword", 1)
+ future := time.Now().UTC().Add(24 * time.Hour).Format(time.RFC3339)
+ db.Exec(`INSERT INTO sessions (id,user_id,expires_at) VALUES ('old1','u1',?),('old2','u1',?)`, future, future) //nolint:errcheck
+ seedResetToken(t, db, "sesstoken", "u1", time.Now().Add(time.Hour))
+
+ rec := httptest.NewRecorder()
+ h.ResetPassword(rec, resetReq("sesstoken", "newpassword1"))
+ if rec.Code != http.StatusOK {
+ t.Fatalf("reset: got %d; want 200", rec.Code)
+ }
+ var ids []string
+ rows, err := db.Query(`SELECT id FROM sessions WHERE user_id = 'u1'`) //nolint:errcheck
+ if err != nil {
+ t.Fatalf("list sessions: %v", err)
+ }
+ defer rows.Close()
+ for rows.Next() {
+ var id string
+ rows.Scan(&id) //nolint:errcheck
+ ids = append(ids, id)
+ }
+ if len(ids) != 1 {
+ t.Fatalf("sessions after reset = %v; want exactly the fresh one", ids)
+ }
+ if ids[0] == "old1" || ids[0] == "old2" {
+ t.Errorf("pre-reset session %q survived the reset", ids[0])
+ }
+}
diff --git a/internal/handler/templates/book.html b/internal/handler/templates/book.html
index e2bd96d6..4145bedb 100644
--- a/internal/handler/templates/book.html
+++ b/internal/handler/templates/book.html
@@ -191,9 +191,11 @@
{{.Name}}