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)
},
}
|