package db import ( "context" "database/sql" "errors" "time" ) // AdminUsername is the single privileged account name. const AdminUsername = "admin" type User struct { ID int64 Username string PasswordHash *string CreatedAt string AvatarVersion int64 IsPending bool RegisterApplication *string } const userColumns = `id, username, password_hash, created_at, avatar_version, is_pending, register_application` func scanUser(row *sql.Row) (*User, error) { var u User err := row.Scan(&u.ID, &u.Username, &u.PasswordHash, &u.CreatedAt, &u.AvatarVersion, &u.IsPending, &u.RegisterApplication) if errors.Is(err, sql.ErrNoRows) { return nil, nil } if err != nil { return nil, err } return &u, nil } func (d *DB) UserByName(ctx context.Context, name string) (*User, error) { return scanUser(d.QueryRowContext(ctx, `SELECT `+userColumns+` FROM users WHERE username = ?`, name)) } func (d *DB) UserByID(ctx context.Context, id int64) (*User, error) { return scanUser(d.QueryRowContext(ctx, `SELECT `+userColumns+` FROM users WHERE id = ?`, id)) } // ActivePasswordHash returns the hash of a non-pending user, used by git // smart-HTTP basic auth. The second result is false when there is no such user. func (d *DB) ActivePasswordHash(ctx context.Context, username string) (string, bool, error) { var hash *string err := d.QueryRowContext(ctx, `SELECT password_hash FROM users WHERE username = ? AND is_pending = 0`, username).Scan(&hash) if errors.Is(err, sql.ErrNoRows) || (err == nil && hash == nil) { return "", false, nil } if err != nil { return "", false, err } return *hash, true, nil } func (d *DB) CreateUser(ctx context.Context, username string, passwordHash *string, createdAt string, isPending bool, registerApplication *string, ) (int64, error) { res, err := d.ExecContext(ctx, `INSERT INTO users (username, password_hash, created_at, is_pending, register_application) VALUES (?, ?, ?, ?, ?)`, username, passwordHash, createdAt, isPending, registerApplication) if err != nil { return 0, err } return res.LastInsertId() } // SetPasskeySetupStarted marks an account as waiting for its first passkey, // or clears the mark when at is empty. A passwordless signup that never // registers a credential leaves an account nobody can log in to. func (d *DB) SetPasskeySetupStarted(ctx context.Context, id int64, at string) error { var value any if at != "" { value = at } _, err := d.ExecContext(ctx, `UPDATE users SET passkey_setup_started_at = ? WHERE id = ?`, value, id) return err } // DeleteStalePasskeySignups removes abandoned passwordless signups: marked // before cutoff, still without a passkey and without a password. Sessions go // with them through the foreign key. func (d *DB) DeleteStalePasskeySignups(ctx context.Context, cutoff string) error { _, err := d.ExecContext(ctx, `DELETE FROM users WHERE passkey_setup_started_at IS NOT NULL AND passkey_setup_started_at < ? AND password_hash IS NULL AND NOT EXISTS (SELECT 1 FROM passkeys WHERE passkeys.user_id = users.id)`, cutoff) return err } func (d *DB) DeleteUser(ctx context.Context, id int64) error { _, err := d.ExecContext(ctx, `DELETE FROM users WHERE id = ?`, id) return err } // SetPasswordHash sets or clears (nil) a user's password. func (d *DB) SetPasswordHash(ctx context.Context, id int64, hash *string) error { _, err := d.ExecContext(ctx, `UPDATE users SET password_hash = ? WHERE id = ?`, hash, id) return err } // ClearPasswordHash removes the password only while a passkey is left. The // check is part of the statement, so two concurrent removals cannot race an // account into having no login method at all. It reports false when it did // not run. func (d *DB) ClearPasswordHash(ctx context.Context, id int64) (bool, error) { res, err := d.ExecContext(ctx, `UPDATE users SET password_hash = NULL WHERE id = ? AND EXISTS (SELECT 1 FROM passkeys WHERE user_id = ?)`, id, id) if err != nil { return false, err } n, err := res.RowsAffected() return n > 0, err } func (d *DB) BumpAvatarVersion(ctx context.Context, id int64) error { _, err := d.ExecContext(ctx, `UPDATE users SET avatar_version = avatar_version + 1 WHERE id = ?`, id) return err } // PendingUser is one row of the admin approval queue. type PendingUser struct { ID int64 Username string RegisterApplication *string CreatedAt string } func (d *DB) PendingUsers(ctx context.Context) ([]PendingUser, error) { rows, err := d.QueryContext(ctx, `SELECT id, username, register_application, created_at FROM users WHERE is_pending = 1 ORDER BY created_at DESC`) if err != nil { return nil, err } defer rows.Close() var out []PendingUser for rows.Next() { var p PendingUser if err := rows.Scan(&p.ID, &p.Username, &p.RegisterApplication, &p.CreatedAt); err != nil { return nil, err } out = append(out, p) } return out, rows.Err() } func (d *DB) ApprovePendingUser(ctx context.Context, id int64) error { _, err := d.ExecContext(ctx, `UPDATE users SET is_pending = 0 WHERE id = ? AND is_pending = 1`, id) return err } func (d *DB) RejectPendingUser(ctx context.Context, id int64) error { _, err := d.ExecContext(ctx, `DELETE FROM users WHERE id = ? AND is_pending = 1`, id) return err } func (d *DB) ApproveAllPendingUsers(ctx context.Context) error { _, err := d.ExecContext(ctx, `UPDATE users SET is_pending = 0 WHERE is_pending = 1`) return err } func (d *DB) RejectAllPendingUsers(ctx context.Context) error { _, err := d.ExecContext(ctx, `DELETE FROM users WHERE is_pending = 1`) return err } // NowISO formats the current time as UTC with milliseconds. Every timestamp // column in the database already uses this format. func NowISO() string { return time.Now().UTC().Format(ISOLayout) } // ISOLayout is the layout NowISO uses. Callers that format a time other than // "now" use it directly. const ISOLayout = "2006-01-02T15:04:05.000Z" // InitAdmin creates the admin account when it does not exist yet. It reports // whether a new account was created. func (d *DB) InitAdmin(ctx context.Context, password string) (bool, error) { existing, err := d.UserByName(ctx, AdminUsername) if err != nil || existing != nil { return false, err } hash, err := HashPassword(password) if err != nil { return false, err } if _, err := d.CreateUser(ctx, AdminUsername, &hash, NowISO(), false, nil); err != nil { return false, err } return true, nil }