Sessions are stored as hashes, so a database dump contains nothing that can be pasted into a browser. A login that has passed the password but not the second factor is modelled as a pending session: it expires, is revocable, and reaches exactly one endpoint. Signup is invite-only and takes the email address from the invite rather than the form, so a link cannot be redirected to somebody else. The first admin is created by an endpoint that refuses once any account exists, checked and inserted in one transaction because it is necessarily unauthenticated. Login failures and forgotten-password requests answer identically whether or not the address has an account — otherwise the form is an account-existence oracle. Password rules are a length floor and nothing else: composition rules push people towards worse passwords. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
204 lines
6.4 KiB
Go
204 lines
6.4 KiB
Go
package store
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
var (
|
|
ErrNotFound = errors.New("store: not found")
|
|
ErrEmailInUse = errors.New("store: that email address already has an account")
|
|
ErrInviteInvalid = errors.New("store: invite is not valid")
|
|
)
|
|
|
|
type User struct {
|
|
ID string
|
|
Email string
|
|
Name string
|
|
PasswordHash string
|
|
TOTPSecret string
|
|
TOTPEnabled bool
|
|
AvatarColor string
|
|
Timezone string
|
|
Locale string
|
|
IsAdmin bool
|
|
CreatedAt time.Time
|
|
UpdatedAt time.Time
|
|
}
|
|
|
|
// NormalizeEmail is the one place an address is canonicalised. Addresses arrive
|
|
// with stray whitespace and inconsistent case from every direction — a login form,
|
|
// an invite, an import — and two spellings of one address must never become two
|
|
// accounts.
|
|
//
|
|
// Only case and surrounding space are touched. Gmail's dots-and-plus rules are
|
|
// deliberately not applied: they are Gmail's, not the internet's, and stripping a
|
|
// "+todo" suffix would break the very addressing people use to route mail here.
|
|
func NormalizeEmail(email string) string {
|
|
return strings.ToLower(strings.TrimSpace(email))
|
|
}
|
|
|
|
// CreateUser inserts a user and gives them an Inbox, in one transaction.
|
|
//
|
|
// The Inbox is created here rather than lazily on first use because every other
|
|
// part of the app is entitled to assume it exists — a task with no project has
|
|
// nowhere to go, and "create it if missing" scattered across handlers is how two
|
|
// of them end up racing to create the second one.
|
|
func (db *DB) CreateUser(ctx context.Context, u *User, inboxName string) error {
|
|
u.Email = NormalizeEmail(u.Email)
|
|
if u.ID == "" {
|
|
u.ID = NewID()
|
|
}
|
|
if u.AvatarColor == "" {
|
|
u.AvatarColor = avatarColorFor(u.Email)
|
|
}
|
|
now := time.Now().UTC()
|
|
u.CreatedAt, u.UpdatedAt = now, now
|
|
|
|
return db.Tx(ctx, func(tx *sql.Tx) error {
|
|
_, err := tx.ExecContext(ctx,
|
|
`INSERT INTO users (id, email, name, password_hash, totp_secret, totp_enabled,
|
|
avatar_color, timezone, locale, is_admin, created_at, updated_at)
|
|
VALUES (?, ?, ?, ?, NULL, 0, ?, ?, ?, ?, ?, ?)`,
|
|
u.ID, u.Email, u.Name, u.PasswordHash, u.AvatarColor,
|
|
orDefault(u.Timezone, "Europe/Copenhagen"), orDefault(u.Locale, "da"),
|
|
boolToInt(u.IsAdmin), now.Unix(), now.Unix())
|
|
if err != nil {
|
|
if isUniqueViolation(err) {
|
|
return ErrEmailInUse
|
|
}
|
|
return fmt.Errorf("insert user: %w", err)
|
|
}
|
|
|
|
_, err = tx.ExecContext(ctx,
|
|
`INSERT INTO projects (id, name, color, owner_id, is_inbox, sort_order, created_at, updated_at)
|
|
VALUES (?, ?, 'graphite', ?, 1, 0, ?, ?)`,
|
|
NewID(), inboxName, u.ID, now.Unix(), now.Unix())
|
|
if err != nil {
|
|
return fmt.Errorf("create inbox: %w", err)
|
|
}
|
|
return nil
|
|
})
|
|
}
|
|
|
|
func (db *DB) UserByEmail(ctx context.Context, email string) (*User, error) {
|
|
return db.scanUser(ctx, `SELECT `+userColumns+` FROM users WHERE email = ?`, NormalizeEmail(email))
|
|
}
|
|
|
|
func (db *DB) UserByID(ctx context.Context, id string) (*User, error) {
|
|
return db.scanUser(ctx, `SELECT `+userColumns+` FROM users WHERE id = ?`, id)
|
|
}
|
|
|
|
const userColumns = `id, email, name, password_hash, totp_secret, totp_enabled,
|
|
avatar_color, timezone, locale, is_admin, created_at, updated_at`
|
|
|
|
func (db *DB) scanUser(ctx context.Context, query string, args ...any) (*User, error) {
|
|
var u User
|
|
var secret sql.NullString
|
|
var totpEnabled, isAdmin int
|
|
var created, updated int64
|
|
|
|
err := db.QueryRowContext(ctx, query, args...).Scan(
|
|
&u.ID, &u.Email, &u.Name, &u.PasswordHash, &secret, &totpEnabled,
|
|
&u.AvatarColor, &u.Timezone, &u.Locale, &isAdmin, &created, &updated)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return nil, ErrNotFound
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
u.TOTPSecret = secret.String
|
|
u.TOTPEnabled = totpEnabled == 1
|
|
u.IsAdmin = isAdmin == 1
|
|
u.CreatedAt = time.Unix(created, 0).UTC()
|
|
u.UpdatedAt = time.Unix(updated, 0).UTC()
|
|
return &u, nil
|
|
}
|
|
|
|
// UserCount is what decides whether the instance still needs its first admin.
|
|
func (db *DB) UserCount(ctx context.Context) (int, error) {
|
|
var n int
|
|
err := db.QueryRowContext(ctx, `SELECT count(*) FROM users`).Scan(&n)
|
|
return n, err
|
|
}
|
|
|
|
// UpdatePasswordHash also clears every session but the one asking, so changing a
|
|
// password actually ends the sessions of whoever else was using the account. That
|
|
// is most of the reason people change one.
|
|
func (db *DB) UpdatePasswordHash(ctx context.Context, userID, hash, keepSessionID string) error {
|
|
return db.Tx(ctx, func(tx *sql.Tx) error {
|
|
_, err := tx.ExecContext(ctx,
|
|
`UPDATE users SET password_hash = ?, updated_at = ? WHERE id = ?`,
|
|
hash, time.Now().Unix(), userID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
_, err = tx.ExecContext(ctx,
|
|
`DELETE FROM sessions WHERE user_id = ? AND id <> ?`, userID, keepSessionID)
|
|
return err
|
|
})
|
|
}
|
|
|
|
func (db *DB) SetTOTPSecret(ctx context.Context, userID, secret string, enabled bool) error {
|
|
var value any
|
|
if secret != "" {
|
|
value = secret
|
|
}
|
|
_, err := db.ExecContext(ctx,
|
|
`UPDATE users SET totp_secret = ?, totp_enabled = ?, updated_at = ? WHERE id = ?`,
|
|
value, boolToInt(enabled), time.Now().Unix(), userID)
|
|
return err
|
|
}
|
|
|
|
// InboxID returns the user's Inbox project.
|
|
func (db *DB) InboxID(ctx context.Context, userID string) (string, error) {
|
|
var id string
|
|
err := db.QueryRowContext(ctx,
|
|
`SELECT id FROM projects WHERE owner_id = ? AND is_inbox = 1 AND deleted_at IS NULL`,
|
|
userID).Scan(&id)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return "", ErrNotFound
|
|
}
|
|
return id, err
|
|
}
|
|
|
|
// avatarColorFor picks a stable colour from the address, so a person looks the same
|
|
// to everyone who shares a project with them without anyone having to choose one.
|
|
func avatarColorFor(seed string) string {
|
|
// Muted tones only: an avatar is a small mark beside text, not a highlight.
|
|
palette := []string{
|
|
"#8a8f98", "#7d8c7a", "#9a8577", "#7a8a9a", "#8f7f92",
|
|
"#a08a6b", "#6f8a86", "#947d7d",
|
|
}
|
|
var sum uint32
|
|
for i := 0; i < len(seed); i++ {
|
|
sum = sum*31 + uint32(seed[i])
|
|
}
|
|
return palette[sum%uint32(len(palette))]
|
|
}
|
|
|
|
func orDefault(v, def string) string {
|
|
if strings.TrimSpace(v) == "" {
|
|
return def
|
|
}
|
|
return v
|
|
}
|
|
|
|
func boolToInt(b bool) int {
|
|
if b {
|
|
return 1
|
|
}
|
|
return 0
|
|
}
|
|
|
|
// isUniqueViolation recognises a broken UNIQUE constraint without depending on the
|
|
// driver's error type, which modernc's SQLite does not export in a usable form.
|
|
func isUniqueViolation(err error) bool {
|
|
return err != nil && strings.Contains(strings.ToLower(err.Error()), "unique constraint")
|
|
}
|