test-web / test (push) Successful in 2m4s
test-go / test (push) Successful in 2m23s
test-go / integration (push) Successful in 5m28s
release / Build signed APK (releases and dev) (push) Successful in 6m29s
release / Build + push container image (push) Successful in 29s
release / Verify release artifacts (tag releases only) (push) Skipped
users.api_token held each user's apiKey in plaintext and was looked up by equality, so a leaked row or backup handed out working keys. Migration 0063 replaces it with api_token_hash (sha256, hex), computed in place from the existing keys so every Subsonic client keeps working. The key can no longer be read back: GET /api/me/api-token is gone, and POST returns the new key once. Settings shows it right after Regenerate with a copy button and a "won't be shown again" note. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
189 lines
6.7 KiB
SQL
189 lines
6.7 KiB
SQL
-- name: CreateUser :one
|
|
INSERT INTO users (username, password_hash, api_token_hash, is_admin, display_name)
|
|
VALUES ($1, $2, $3, $4, $5)
|
|
RETURNING *;
|
|
|
|
-- name: CreateUserFirstAdminRace :one
|
|
-- Inserts a new user; sets is_admin=true ONLY when no users currently
|
|
-- exist. The (SELECT NOT EXISTS ...) subquery is evaluated at INSERT
|
|
-- time within the same statement, so the result reflects committed
|
|
-- state at that moment.
|
|
--
|
|
-- Race semantics: two concurrent calls in an empty-users state may
|
|
-- both observe "no users" and both insert with is_admin=true. That's
|
|
-- benign — having two admins from the gate is fine; what matters is
|
|
-- that there's AT LEAST one. If the two calls happened to share the
|
|
-- same username, the unique constraint on users.username arbitrates
|
|
-- and the second caller's INSERT fails with a unique violation. The
|
|
-- caller (registration handler) can retry as a regular non-admin in
|
|
-- that case (or surface a "username taken" error to the user).
|
|
INSERT INTO users (username, password_hash, api_token_hash, is_admin, display_name)
|
|
VALUES (
|
|
$1, $2, $3,
|
|
(SELECT NOT EXISTS (SELECT 1 FROM users)),
|
|
$4
|
|
)
|
|
RETURNING *;
|
|
|
|
-- name: GetUserByUsername :one
|
|
SELECT * FROM users WHERE username = $1;
|
|
|
|
-- name: GetUserByAPITokenHash :one
|
|
SELECT * FROM users WHERE api_token_hash = $1;
|
|
|
|
-- name: CountUsers :one
|
|
SELECT count(*) FROM users;
|
|
|
|
-- name: SetSubsonicPassword :exec
|
|
-- Stores (or clears with NULL) the per-user Subsonic legacy credential used
|
|
-- for t/s and p auth on /rest/*. Must be plaintext; see migration 0003.
|
|
UPDATE users SET subsonic_password = $2 WHERE id = $1;
|
|
|
|
-- name: GetUserByID :one
|
|
SELECT * FROM users WHERE id = $1;
|
|
|
|
-- name: SetListenBrainzToken :exec
|
|
UPDATE users
|
|
SET listenbrainz_token = $2,
|
|
listenbrainz_enabled = CASE WHEN COALESCE($2, '') = '' THEN FALSE ELSE listenbrainz_enabled END
|
|
WHERE id = $1;
|
|
|
|
-- name: SetListenBrainzEnabled :exec
|
|
UPDATE users
|
|
SET listenbrainz_enabled = $2
|
|
WHERE id = $1;
|
|
|
|
-- name: ListUsers :many
|
|
-- Admin user-management list. Sort newest-first.
|
|
SELECT id, username, display_name, is_admin, auto_approve_requests,
|
|
debug_mode_enabled, created_at
|
|
FROM users
|
|
ORDER BY created_at DESC;
|
|
|
|
-- name: CountAdmins :one
|
|
SELECT count(*) FROM users WHERE is_admin = true;
|
|
|
|
-- name: UpdateUserAdmin :one
|
|
-- Sets is_admin to the given value. Returns the updated row so the
|
|
-- handler can echo it back to the caller.
|
|
UPDATE users
|
|
SET is_admin = $2
|
|
WHERE id = $1
|
|
RETURNING *;
|
|
|
|
-- name: GetListenBrainzConfig :one
|
|
-- Returns the user's LB token + enabled flag and the most recent
|
|
-- play_events.scrobbled_at for last-scrobbled-at status.
|
|
SELECT
|
|
u.listenbrainz_token,
|
|
u.listenbrainz_enabled,
|
|
(SELECT MAX(pe.scrobbled_at)::timestamptz
|
|
FROM play_events pe
|
|
WHERE pe.user_id = u.id) AS last_scrobbled_at
|
|
FROM users u
|
|
WHERE u.id = $1;
|
|
|
|
-- name: CreateUserAdmin :one
|
|
-- Admin-driven user creation. Distinct from CreateUser/CreateUserFirstAdminRace:
|
|
-- the caller (an admin) supplies all five fields explicitly including is_admin,
|
|
-- so an admin can promote on creation. Used by POST /api/admin/users.
|
|
INSERT INTO users (username, password_hash, api_token_hash, is_admin, display_name)
|
|
VALUES ($1, $2, $3, $4, $5)
|
|
RETURNING *;
|
|
|
|
-- name: DeleteUser :exec
|
|
-- Hard delete with cascade. The schema's ON DELETE CASCADE foreign keys
|
|
-- handle plays/likes/sessions/etc. Caller must enforce the last-admin
|
|
-- guard at the HTTP layer (count admins, refuse if target is the only
|
|
-- admin). The lidarr_quarantine, general_likes*, play_events,
|
|
-- sessions, and other user-FK tables all have ON DELETE CASCADE so
|
|
-- this is a clean drop.
|
|
DELETE FROM users WHERE id = $1;
|
|
|
|
-- name: ResetUserPassword :exec
|
|
-- Admin-driven password reset: caller writes a new hashed password to
|
|
-- the target user's row. No knowledge of the old password is required
|
|
-- (this is the admin path; self-service "knows current password" is
|
|
-- a separate U3 query).
|
|
UPDATE users
|
|
SET password_hash = $2
|
|
WHERE id = $1;
|
|
|
|
-- name: UpdateUserAutoApprove :one
|
|
-- Toggle the per-user auto_approve_requests flag.
|
|
UPDATE users
|
|
SET auto_approve_requests = $2
|
|
WHERE id = $1
|
|
RETURNING *;
|
|
|
|
-- name: SetDebugMode :one
|
|
-- Toggle the per-account diagnostics/debug-reporting opt-in. Admin-driven
|
|
-- from /admin (flip remotely while a bug is live) or self-driven OFF from
|
|
-- the client. Returns the updated row so the handler can echo it.
|
|
UPDATE users
|
|
SET debug_mode_enabled = $2
|
|
WHERE id = $1
|
|
RETURNING *;
|
|
|
|
-- name: ChangeUserPassword :exec
|
|
-- Self-service password change. Caller (HTTP handler) verifies the
|
|
-- current password before calling this. Distinct from
|
|
-- ResetUserPassword which is admin-driven (no current-password
|
|
-- check).
|
|
UPDATE users SET password_hash = $2 WHERE id = $1;
|
|
|
|
-- name: UpdateUserProfile :one
|
|
-- Self-service: set display name and/or email. Both fields are
|
|
-- nullable in the DB; pass NULL ptr to clear, non-NULL ptr to set.
|
|
UPDATE users
|
|
SET display_name = $2,
|
|
email = $3
|
|
WHERE id = $1
|
|
RETURNING *;
|
|
|
|
-- name: RegenerateApiToken :exec
|
|
-- Self-service: caller wants a new API token. Used by the /settings
|
|
-- API Token card's "Regenerate" button. Only the hash is stored; the
|
|
-- handler returns the raw key once.
|
|
UPDATE users SET api_token_hash = $2 WHERE id = $1;
|
|
|
|
-- name: GetUserByEmail :one
|
|
-- Used by forgot-password lookup. Lowercase comparison both sides
|
|
-- to keep the unique index happy and to make the lookup
|
|
-- case-insensitive.
|
|
SELECT * FROM users WHERE lower(email) = lower($1);
|
|
|
|
-- name: UpdateUserTimezone :exec
|
|
-- Sets the user's IANA timezone and bumps timezone_updated_at. The
|
|
-- handler validates the timezone string via time.LoadLocation before
|
|
-- calling this; the DB does not re-validate.
|
|
UPDATE users
|
|
SET timezone = $2,
|
|
timezone_updated_at = now()
|
|
WHERE id = $1;
|
|
|
|
-- name: ListActiveUsersWithTimezones :many
|
|
-- Returns (id, timezone) for every user with a play in the last 7
|
|
-- days. The system-playlist scheduler iterates this list at startup
|
|
-- and during its hourly reconciliation to discover newly-active /
|
|
-- no-longer-active users.
|
|
SELECT u.id, u.timezone FROM users u
|
|
WHERE EXISTS (
|
|
SELECT 1 FROM play_events pe
|
|
WHERE pe.user_id = u.id
|
|
AND pe.started_at > now() - INTERVAL '7 days'
|
|
);
|
|
|
|
-- name: GetOldestAdmin :one
|
|
-- The account a system-initiated action is attributed to (milestone #290).
|
|
-- lidarr_requests.user_id is NOT NULL and a re-acquisition has no requesting
|
|
-- human, so the row is owned by the longest-standing admin: it keeps the
|
|
-- request auditable and puts it in the same admin queue as everything else,
|
|
-- without inventing a synthetic principal the rest of the schema would have
|
|
-- to understand. Ordered by id as a tiebreak so the choice is stable across
|
|
-- calls rather than depending on scan order.
|
|
SELECT * FROM users
|
|
WHERE is_admin = true
|
|
ORDER BY created_at, id
|
|
LIMIT 1;
|