File size: 6,179 Bytes
00a912e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
import type { MusicSource } from '@music-together/shared'
import { createCipheriv, createDecipheriv, createHash, randomBytes } from 'node:crypto'
import { config } from '../config.js'
import { logger } from '../utils/logger.js'
import { db } from './database.js'

export interface PersistedPlatformAuth {
  userId: string
  platform: MusicSource
  cookie: string
  nickname: string
  vipType: number
  vipLabel?: string
  vipLevel?: number
  credentialRefreshAttemptedAt?: number
}

interface PlatformAuthRow {
  user_id: string
  platform: MusicSource
  cookie_encrypted: string
  nickname_snapshot: string | null
  vip_type: number | null
  vip_label: string | null
  vip_level: number | null
  credential_refresh_attempted_at: number | null
}

const upsertAuth = db.prepare(`
  INSERT INTO platform_auth (
    id,
    user_id,
    platform,
    cookie_encrypted,
    nickname_snapshot,
    vip_type,
    vip_label,
    vip_level,
    credential_refresh_attempted_at,
    created_at,
    updated_at
  )
  VALUES (@id, @userId, @platform, @cookie, @nickname, @vipType, @vipLabel, @vipLevel, @refreshAttemptedAt, @now, @now)
  ON CONFLICT(id) DO UPDATE SET
    cookie_encrypted = excluded.cookie_encrypted,
    nickname_snapshot = excluded.nickname_snapshot,
    vip_type = excluded.vip_type,
    vip_label = excluded.vip_label,
    vip_level = excluded.vip_level,
    credential_refresh_attempted_at = COALESCE(
      excluded.credential_refresh_attempted_at,
      platform_auth.credential_refresh_attempted_at
    ),
    updated_at = excluded.updated_at
`)
const loadUserAuth = db.prepare<[string], PlatformAuthRow>(
  'SELECT user_id, platform, cookie_encrypted, nickname_snapshot, vip_type, vip_label, vip_level, credential_refresh_attempted_at FROM platform_auth WHERE user_id = ? ORDER BY updated_at DESC',
)
const loadDueTencentAuth = db.prepare<[number], PlatformAuthRow>(
  `SELECT user_id, platform, cookie_encrypted, nickname_snapshot, vip_type, vip_label, vip_level, credential_refresh_attempted_at
   FROM platform_auth
   WHERE platform = 'tencent' AND (credential_refresh_attempted_at IS NULL OR credential_refresh_attempted_at <= ?)
   ORDER BY updated_at ASC`,
)
const deleteUserPlatformAuth = db.prepare('DELETE FROM platform_auth WHERE user_id = ? AND platform = ?')
const markCredentialRefreshAttempt = db.prepare(
  'UPDATE platform_auth SET credential_refresh_attempted_at = ?, updated_at = ? WHERE user_id = ? AND platform = ?',
)

function authId(userId: string, platform: MusicSource): string {
  return `account:${userId}:${platform}`
}

const encryptionKey = createHash('sha256').update(config.identity.secret).digest()

function encryptCookie(cookie: string): string {
  const iv = randomBytes(12)
  const cipher = createCipheriv('aes-256-gcm', encryptionKey, iv)
  const encrypted = Buffer.concat([cipher.update(cookie, 'utf8'), cipher.final()])
  return `v1:${iv.toString('base64url')}:${cipher.getAuthTag().toString('base64url')}:${encrypted.toString('base64url')}`
}

function decryptCookie(value: string): string | null {
  // Existing fork databases stored plaintext in this column. Keep those rows readable.
  if (!value.startsWith('v1:')) return value
  const [, iv, tag, encrypted] = value.split(':')
  if (!iv || !tag || !encrypted) return null
  try {
    const decipher = createDecipheriv('aes-256-gcm', encryptionKey, Buffer.from(iv, 'base64url'))
    decipher.setAuthTag(Buffer.from(tag, 'base64url'))
    return Buffer.concat([decipher.update(Buffer.from(encrypted, 'base64url')), decipher.final()]).toString('utf8')
  } catch (error) {
    logger.warn('Failed to decrypt persisted platform cookie', { error })
    return null
  }
}

export const platformAuthRepo = {
  save(entry: PersistedPlatformAuth, options?: { resetCredentialRefreshSchedule?: boolean }): void {
    const now = Date.now()
    upsertAuth.run({
      id: authId(entry.userId, entry.platform),
      userId: entry.userId,
      platform: entry.platform,
      cookie: encryptCookie(entry.cookie),
      nickname: entry.nickname,
      vipType: entry.vipType,
      vipLabel: entry.vipLabel ?? null,
      vipLevel: entry.vipLevel ?? null,
      refreshAttemptedAt: options?.resetCredentialRefreshSchedule ? now : (entry.credentialRefreshAttemptedAt ?? null),
      now,
    })
  },

  loadUser(userId: string): PersistedPlatformAuth[] {
    const seen = new Set<MusicSource>()
    return loadUserAuth
      .all(userId)
      .filter((row) => {
        if (seen.has(row.platform)) return false
        seen.add(row.platform)
        return true
      })
      .flatMap((row) => {
        const cookie = decryptCookie(row.cookie_encrypted)
        return cookie
          ? [
              {
                userId: row.user_id,
                platform: row.platform,
                cookie,
                nickname: row.nickname_snapshot ?? row.user_id,
                vipType: row.vip_type ?? 0,
                vipLabel: row.vip_label ?? undefined,
                vipLevel: row.vip_level ?? undefined,
                credentialRefreshAttemptedAt: row.credential_refresh_attempted_at ?? undefined,
              },
            ]
          : []
      })
  },

  remove(userId: string, platform: MusicSource): boolean {
    return deleteUserPlatformAuth.run(userId, platform).changes > 0
  },

  loadDueTencent(cutoff: number): PersistedPlatformAuth[] {
    return loadDueTencentAuth.all(cutoff).flatMap((row) => {
      const cookie = decryptCookie(row.cookie_encrypted)
      return cookie
        ? [
            {
              userId: row.user_id,
              platform: row.platform,
              cookie,
              nickname: row.nickname_snapshot ?? row.user_id,
              vipType: row.vip_type ?? 0,
              vipLabel: row.vip_label ?? undefined,
              vipLevel: row.vip_level ?? undefined,
              credentialRefreshAttemptedAt: row.credential_refresh_attempted_at ?? undefined,
            },
          ]
        : []
    })
  },

  markCredentialRefreshAttempt(userId: string, platform: MusicSource, attemptedAt = Date.now()): void {
    markCredentialRefreshAttempt.run(attemptedAt, attemptedAt, userId, platform)
  },
}