Files
silo-server/internal/userstore/pgstore/profiles.go
T
f637404c46 fix(userstore): batch allowed-library and profile lookups when listing (#380)
* fix(userstore): batch allowed-library and profile lookups when listing

Listing profiles cost 1 + P queries: one allowed-libraries lookup fired
per profile row while the cursor was still open, which on Postgres also
checks out a second pooled connection mid-scan. The admin sessions
dashboard makes it worse, calling ListProfiles once per streaming user
just to resolve names. The SQLite store had the same pattern for both
profiles and collections, even though the Postgres collections path was
already written with array_agg to avoid exactly this.

Collect the rows first, then fetch the child lists in one batched query
and stitch them together in Go. No behaviour change, just fewer round
trips.

* fix(userstore): avoid sqlite batch variable limits

---------

Co-authored-by: Quick104 <31828688+Quick104@users.noreply.github.com>
2026-07-20 11:25:24 -04:00

325 lines
10 KiB
Go

package pgstore
import (
"context"
"fmt"
"strings"
"time"
"github.com/jackc/pgx/v5"
"golang.org/x/crypto/bcrypt"
"github.com/Silo-Server/silo-server/internal/userstore"
)
// scanProfile scans a profile row, converting TIMESTAMPTZ to string.
func scanProfile(scanner interface {
Scan(dest ...any) error
}) (*userstore.Profile, error) {
var p userstore.Profile
var createdAt, updatedAt time.Time
err := scanner.Scan(
&p.ID, &p.Name, &p.Avatar, &p.PINHash, &p.IsChild, &p.IsPrimary, &p.MaxContentRating,
&p.QualityPreference, &p.Language, &p.PreferredMetadataLanguage, &p.SubtitleLanguage, &p.SubtitleMode,
&p.AutoSkipIntro, &p.AutoSkipCredits, &p.AutoSkipRecap, &p.AutoPlayNextPreview,
&p.LibraryRestrictionsEnabled,
&p.ShowForcedSubtitles, &p.MaxPlaybackQuality, &createdAt, &updatedAt,
)
if err != nil {
return nil, err
}
p.CreatedAt = timeToString(createdAt)
p.UpdatedAt = timeToString(updatedAt)
return &p, nil
}
func (s *PostgresUserStore) CreateProfile(ctx context.Context, p userstore.Profile) error {
if p.ID == "" {
p.ID = generateUUID()
}
now := nowUTC()
if p.CreatedAt == "" {
p.CreatedAt = now
}
if p.UpdatedAt == "" {
p.UpdatedAt = now
}
if !p.ShowForcedSubtitles {
p.ShowForcedSubtitles = true
}
// The first profile created for a user becomes the primary, giving it
// rights to manage the household's other profiles without requiring a
// server-wide admin role.
var hasExisting bool
if err := s.pool.QueryRow(ctx,
"SELECT EXISTS(SELECT 1 FROM user_profiles WHERE user_id = $1)", s.userID,
).Scan(&hasExisting); err != nil {
return fmt.Errorf("checking existing profiles for user %d: %w", s.userID, err)
}
if !hasExisting {
p.IsPrimary = true
} else {
p.IsPrimary = false
}
_, err := s.pool.Exec(ctx, `
INSERT INTO user_profiles (
id, user_id, name, avatar, pin_hash, is_child, is_primary, max_content_rating,
quality_preference, language, preferred_metadata_language, subtitle_language, subtitle_mode,
auto_skip_intro, auto_skip_credits, auto_skip_recap, auto_play_next_preview,
library_restrictions_enabled,
show_forced_subtitles, max_playback_quality, created_at, updated_at
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22)`,
p.ID, s.userID, p.Name, p.Avatar, p.PINHash, p.IsChild, p.IsPrimary, p.MaxContentRating,
p.QualityPreference, p.Language, p.PreferredMetadataLanguage, p.SubtitleLanguage, p.SubtitleMode,
p.AutoSkipIntro, p.AutoSkipCredits, p.AutoSkipRecap, p.AutoPlayNextPreview,
p.LibraryRestrictionsEnabled,
p.ShowForcedSubtitles, p.MaxPlaybackQuality, p.CreatedAt, p.UpdatedAt,
)
if err != nil {
return fmt.Errorf("inserting profile %s: %w", p.ID, err)
}
if err := replaceProfileAllowedLibraries(ctx, s.pool, s.userID, p.ID, p.AllowedLibraryIDs); err != nil {
return err
}
return nil
}
func (s *PostgresUserStore) GetProfile(ctx context.Context, id string) (*userstore.Profile, error) {
row := s.pool.QueryRow(ctx, `
SELECT id, name, avatar, pin_hash, is_child, is_primary, max_content_rating,
quality_preference, language, preferred_metadata_language, subtitle_language, subtitle_mode,
auto_skip_intro, auto_skip_credits, auto_skip_recap, auto_play_next_preview, library_restrictions_enabled,
show_forced_subtitles, max_playback_quality, created_at, updated_at
FROM user_profiles WHERE user_id = $1 AND id = $2`, s.userID, id)
p, err := scanProfile(row)
if err == pgx.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("querying profile %s: %w", id, err)
}
p.AllowedLibraryIDs, err = listProfileAllowedLibraries(ctx, s.pool, s.userID, p.ID)
if err != nil {
return nil, err
}
return p, nil
}
func (s *PostgresUserStore) ListProfiles(ctx context.Context) ([]userstore.Profile, error) {
rows, err := s.pool.Query(ctx, `
SELECT id, name, avatar, pin_hash, is_child, is_primary, max_content_rating,
quality_preference, language, preferred_metadata_language, subtitle_language, subtitle_mode,
auto_skip_intro, auto_skip_credits, auto_skip_recap, auto_play_next_preview, library_restrictions_enabled,
show_forced_subtitles, max_playback_quality, created_at, updated_at
FROM user_profiles WHERE user_id = $1 ORDER BY created_at ASC`, s.userID)
if err != nil {
return nil, fmt.Errorf("listing profiles: %w", err)
}
defer rows.Close()
var profiles []userstore.Profile
for rows.Next() {
p, err := scanProfile(rows)
if err != nil {
return nil, fmt.Errorf("scanning profile row: %w", err)
}
profiles = append(profiles, *p)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterating profile rows: %w", err)
}
if err := s.attachAllowedLibraries(ctx, profiles); err != nil {
return nil, err
}
return profiles, nil
}
func (s *PostgresUserStore) UpdateProfile(ctx context.Context, id string, u userstore.UpdateProfileInput) error {
var setClauses []string
var args []any
argIdx := 1
accessPolicyChanged := u.PIN != nil ||
u.IsChild != nil ||
u.MaxContentRating != nil ||
u.LibraryRestrictionsEnabled != nil ||
u.AllowedLibraryIDs != nil ||
u.MaxPlaybackQuality != nil
addArg := func(clause string, val any) {
setClauses = append(setClauses, fmt.Sprintf("%s = $%d", clause, argIdx))
args = append(args, val)
argIdx++
}
if u.Name != nil {
addArg("name", *u.Name)
}
if u.Avatar != nil {
addArg("avatar", *u.Avatar)
}
if u.PIN != nil {
if *u.PIN == "" {
// Empty string clears the PIN rather than hashing an empty value.
addArg("pin_hash", "")
} else {
hash, err := bcrypt.GenerateFromPassword([]byte(*u.PIN), bcrypt.DefaultCost)
if err != nil {
return fmt.Errorf("hashing PIN: %w", err)
}
addArg("pin_hash", string(hash))
}
}
if u.IsChild != nil {
addArg("is_child", *u.IsChild)
}
if u.MaxContentRating != nil {
addArg("max_content_rating", *u.MaxContentRating)
}
if u.QualityPreference != nil {
addArg("quality_preference", *u.QualityPreference)
}
if u.Language != nil {
addArg("language", *u.Language)
}
if u.PreferredMetadataLanguage != nil {
addArg("preferred_metadata_language", *u.PreferredMetadataLanguage)
}
if u.SubtitleLanguage != nil {
addArg("subtitle_language", *u.SubtitleLanguage)
}
if u.SubtitleMode != nil {
addArg("subtitle_mode", *u.SubtitleMode)
}
if u.AutoSkipIntro != nil {
addArg("auto_skip_intro", *u.AutoSkipIntro)
}
if u.AutoSkipCredits != nil {
addArg("auto_skip_credits", *u.AutoSkipCredits)
}
if u.AutoSkipRecap != nil {
addArg("auto_skip_recap", *u.AutoSkipRecap)
}
if u.AutoPlayNextPreview != nil {
addArg("auto_play_next_preview", *u.AutoPlayNextPreview)
}
if u.LibraryRestrictionsEnabled != nil {
addArg("library_restrictions_enabled", *u.LibraryRestrictionsEnabled)
}
if u.ShowForcedSubtitles != nil {
addArg("show_forced_subtitles", *u.ShowForcedSubtitles)
}
if u.MaxPlaybackQuality != nil {
addArg("max_playback_quality", *u.MaxPlaybackQuality)
}
if len(setClauses) == 0 && u.AllowedLibraryIDs == nil {
return nil
}
var exists bool
if err := s.pool.QueryRow(ctx, "SELECT EXISTS(SELECT 1 FROM user_profiles WHERE user_id = $1 AND id = $2)", s.userID, id).Scan(&exists); err != nil {
return fmt.Errorf("checking profile %s existence: %w", id, err)
}
if !exists {
return fmt.Errorf("profile %s not found", id)
}
if len(setClauses) > 0 {
addArg("updated_at", nowUTC())
whereClause := fmt.Sprintf("WHERE user_id = $%d AND id = $%d", argIdx, argIdx+1)
args = append(args, s.userID, id)
query := fmt.Sprintf("UPDATE user_profiles SET %s %s", strings.Join(setClauses, ", "), whereClause)
result, err := s.pool.Exec(ctx, query, args...)
if err != nil {
return fmt.Errorf("updating profile %s: %w", id, err)
}
if result.RowsAffected() == 0 {
return fmt.Errorf("profile %s not found", id)
}
}
if u.AllowedLibraryIDs != nil {
if err := replaceProfileAllowedLibraries(ctx, s.pool, s.userID, id, *u.AllowedLibraryIDs); err != nil {
return err
}
}
if accessPolicyChanged {
if _, err := s.pool.Exec(ctx, "UPDATE users SET access_policy_revision = access_policy_revision + 1 WHERE id = $1", s.userID); err != nil {
return fmt.Errorf("bumping access policy revision for user %d: %w", s.userID, err)
}
}
return nil
}
func (s *PostgresUserStore) DeleteProfile(ctx context.Context, id string) error {
tx, err := s.pool.Begin(ctx)
if err != nil {
return fmt.Errorf("beginning transaction for profile delete: %w", err)
}
defer tx.Rollback(ctx) //nolint:errcheck
_, err = tx.Exec(ctx, `
DELETE FROM user_personal_collection_items
WHERE user_id = $1 AND collection_id IN (
SELECT id FROM user_personal_collections WHERE user_id = $1 AND profile_id = $2
)`, s.userID, id)
if err != nil {
return fmt.Errorf("deleting collection items for profile %s: %w", id, err)
}
cascadeTables := []string{
"user_favorites",
"user_watchlist",
"user_watch_progress",
"user_personal_collections",
"user_series_playback_preferences",
"user_library_playback_preferences",
}
for _, table := range cascadeTables {
if _, err := tx.Exec(ctx, fmt.Sprintf("DELETE FROM %s WHERE user_id = $1 AND profile_id = $2", table), s.userID, id); err != nil {
return fmt.Errorf("deleting from %s for profile %s: %w", table, id, err)
}
}
result, err := tx.Exec(ctx, "DELETE FROM user_profiles WHERE user_id = $1 AND id = $2", s.userID, id)
if err != nil {
return fmt.Errorf("deleting profile %s: %w", id, err)
}
if result.RowsAffected() == 0 {
return fmt.Errorf("profile %s not found", id)
}
return tx.Commit(ctx)
}
func (s *PostgresUserStore) VerifyPIN(ctx context.Context, profileID, pin string) (bool, error) {
var pinHash string
err := s.pool.QueryRow(ctx,
"SELECT pin_hash FROM user_profiles WHERE user_id = $1 AND id = $2",
s.userID, profileID,
).Scan(&pinHash)
if err == pgx.ErrNoRows {
return false, fmt.Errorf("profile %s not found", profileID)
}
if err != nil {
return false, fmt.Errorf("querying PIN hash for profile %s: %w", profileID, err)
}
if pinHash == "" {
return false, fmt.Errorf("profile %s has no PIN set", profileID)
}
err = bcrypt.CompareHashAndPassword([]byte(pinHash), []byte(pin))
if err == bcrypt.ErrMismatchedHashAndPassword {
return false, nil
}
if err != nil {
return false, fmt.Errorf("comparing PIN for profile %s: %w", profileID, err)
}
return true, nil
}