Files

156 lines
5.4 KiB
Go
Raw Permalink Normal View History

2026-05-22 20:26:11 -04:00
package api
import (
"context"
"errors"
"fmt"
"strings"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/Silo-Server/silo-server/internal/catalog"
"github.com/Silo-Server/silo-server/internal/secret"
2026-05-22 20:26:11 -04:00
"github.com/Silo-Server/silo-server/internal/watchsync"
watchtrakt "github.com/Silo-Server/silo-server/internal/watchsync/providers/trakt"
)
type traktCollectionTokenResolver struct {
pool *pgxpool.Pool
settings catalog.SettingsStore // encrypting decorator: decrypts trakt client_secret
cipher *secret.Cipher // decrypts watch_provider_connections tokens read via raw SQL
2026-05-22 20:26:11 -04:00
provider *watchtrakt.Provider
}
func (r *traktCollectionTokenResolver) ResolveTraktAccessToken(ctx context.Context, profileID string) (string, error) {
if r == nil || r.pool == nil || r.settings == nil || r.provider == nil {
return "", errors.New("trakt token resolver is not configured")
}
profileID = strings.TrimSpace(profileID)
if profileID == "" {
return "", errors.New("profile id is required")
}
conn, err := r.loadConnection(ctx, profileID)
if err != nil {
return "", err
}
if strings.TrimSpace(conn.AccessToken) == "" {
return "", errors.New("trakt connection is missing an access token")
}
if conn.TokenExpiresAt == nil || conn.TokenExpiresAt.After(time.Now().UTC().Add(time.Minute)) {
return conn.AccessToken, nil
}
if strings.TrimSpace(conn.RefreshToken) == "" {
return "", errors.New("trakt connection is expired and missing a refresh token")
}
cfg, err := r.serverConfig(ctx)
if err != nil {
return "", err
}
tokens, err := r.provider.RefreshToken(ctx, cfg, conn)
if err != nil {
return "", fmt.Errorf("refresh trakt token: %w", err)
}
if strings.TrimSpace(tokens.AccessToken) == "" {
return "", errors.New("trakt refresh returned an empty access token")
}
conn.AccessToken = tokens.AccessToken
if strings.TrimSpace(tokens.RefreshToken) != "" {
conn.RefreshToken = tokens.RefreshToken
}
if tokens.TokenExpiresAt != nil {
conn.TokenExpiresAt = tokens.TokenExpiresAt
}
if err := r.updateTokens(ctx, conn); err != nil {
return "", err
}
return conn.AccessToken, nil
}
func (r *traktCollectionTokenResolver) serverConfig(ctx context.Context) (watchsync.ServerConfig, error) {
clientID, err := r.settings.Get(ctx, "watchsync.trakt.client_id")
if err != nil {
return watchsync.ServerConfig{}, err
}
clientSecret, err := r.settings.Get(ctx, "watchsync.trakt.client_secret")
if err != nil {
return watchsync.ServerConfig{}, err
}
cfg := watchsync.ServerConfig{ClientID: clientID, ClientSecret: clientSecret}
if !cfg.Configured() {
return watchsync.ServerConfig{}, errors.New("trakt credentials are not configured")
}
return cfg, nil
}
func (r *traktCollectionTokenResolver) loadConnection(ctx context.Context, profileID string) (watchsync.Connection, error) {
row := r.pool.QueryRow(ctx, `
SELECT
id::text, provider, user_id, profile_id, provider_account_id, provider_username,
access_token, refresh_token, token_expires_at
FROM watch_provider_connections
WHERE provider = 'trakt'
AND profile_id = $1
AND access_token <> ''
ORDER BY updated_at DESC
LIMIT 1
`, profileID)
var conn watchsync.Connection
err := row.Scan(
2026-05-22 20:26:11 -04:00
&conn.ID,
&conn.Provider,
&conn.UserID,
&conn.ProfileID,
&conn.ProviderAccountID,
&conn.ProviderUsername,
&conn.AccessToken,
&conn.RefreshToken,
&conn.TokenExpiresAt,
)
if err != nil {
2026-05-22 20:26:11 -04:00
if errors.Is(err, pgx.ErrNoRows) {
return watchsync.Connection{}, errors.New("trakt connection not found for profile")
}
return watchsync.Connection{}, fmt.Errorf("load trakt connection: %w", err)
}
// This resolver reads watch_provider_connections directly (bypassing the
// watchsync repo), so it must apply the same at-rest decryption the repo
// does, bound to the same connection identity (watchsync.TokenAAD).
if conn.AccessToken, err = r.cipher.DecryptIfEncrypted(conn.AccessToken, watchsync.TokenAAD("access_token", conn.Provider, conn.UserID, conn.ProfileID)); err != nil {
return watchsync.Connection{}, fmt.Errorf("decrypt trakt access token: %w", err)
}
if conn.RefreshToken, err = r.cipher.DecryptIfEncrypted(conn.RefreshToken, watchsync.TokenAAD("refresh_token", conn.Provider, conn.UserID, conn.ProfileID)); err != nil {
return watchsync.Connection{}, fmt.Errorf("decrypt trakt refresh token: %w", err)
}
2026-05-22 20:26:11 -04:00
return conn, nil
}
func (r *traktCollectionTokenResolver) updateTokens(ctx context.Context, conn watchsync.Connection) error {
// Encrypt the refreshed tokens inline, bound to the connection identity, so
// the raw-SQL write matches what the watchsync repo (and this resolver's read
// path) store.
accessToken, err := r.cipher.Encrypt(conn.AccessToken, watchsync.TokenAAD("access_token", conn.Provider, conn.UserID, conn.ProfileID))
if err != nil {
return fmt.Errorf("encrypt trakt access token: %w", err)
}
refreshToken, err := r.cipher.Encrypt(conn.RefreshToken, watchsync.TokenAAD("refresh_token", conn.Provider, conn.UserID, conn.ProfileID))
if err != nil {
return fmt.Errorf("encrypt trakt refresh token: %w", err)
}
_, err = r.pool.Exec(ctx, `
2026-05-22 20:26:11 -04:00
UPDATE watch_provider_connections
SET access_token = $2,
refresh_token = $3,
token_expires_at = $4,
updated_at = now()
WHERE id = $1::uuid
`, conn.ID, accessToken, refreshToken, conn.TokenExpiresAt)
2026-05-22 20:26:11 -04:00
if err != nil {
return fmt.Errorf("update refreshed trakt connection tokens: %w", err)
}
return nil
}