Paginate tag (and ASC) lists via GQL with integrity retry; mint follow integrity from a WebView because Dio /integrity tokens are bad_bot. Co-authored-by: Cursor <cursoragent@cursor.com>
81 lines
2.5 KiB
Dart
81 lines
2.5 KiB
Dart
import 'dart:convert';
|
|
|
|
/// Helpers for Twitch `Client-Integrity` tokens (`v4.public.*` / `v4.local.*`).
|
|
class TwitchIntegrity {
|
|
const TwitchIntegrity._();
|
|
|
|
/// Decodes the JSON payload embedded in an integrity token, if present.
|
|
static Map<String, dynamic>? decodePayload(String token) {
|
|
final parts = token.split('.');
|
|
if (parts.length < 2) return null;
|
|
|
|
for (final part in parts.skip(1)) {
|
|
if (part.isEmpty) continue;
|
|
try {
|
|
final normalized = base64Url.normalize(part);
|
|
final decoded = utf8.decode(base64Url.decode(normalized));
|
|
final dynamic json = jsonDecode(decoded);
|
|
if (json is Map &&
|
|
(json.containsKey('is_bad_bot') ||
|
|
json.containsKey('client_id') ||
|
|
json.containsKey('device_id'))) {
|
|
return Map<String, dynamic>.from(json);
|
|
}
|
|
} catch (_) {
|
|
// Try the next segment — token layout varies by version.
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/// Dio/`POST /integrity` tokens from non-browser clients are usually flagged.
|
|
static bool isBadBot(String token) {
|
|
final payload = decodePayload(token);
|
|
if (payload == null) return false;
|
|
final flag = payload['is_bad_bot'];
|
|
if (flag is bool) return flag;
|
|
if (flag is String) return flag.toLowerCase() == 'true';
|
|
return false;
|
|
}
|
|
|
|
/// Expiration in epoch millis from the payload, if present.
|
|
static int? expirationMs(String token) {
|
|
final payload = decodePayload(token);
|
|
if (payload == null) return null;
|
|
|
|
final exp = payload['exp'];
|
|
if (exp is int) {
|
|
// Already millis if large; seconds if typical JWT.
|
|
return exp > 100000000000 ? exp : exp * 1000;
|
|
}
|
|
if (exp is String) {
|
|
final asInt = int.tryParse(exp);
|
|
if (asInt != null) {
|
|
return asInt > 100000000000 ? asInt : asInt * 1000;
|
|
}
|
|
final dt = DateTime.tryParse(exp);
|
|
if (dt != null) return dt.millisecondsSinceEpoch;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/// Device id the token was minted for (`X-Device-Id` must match).
|
|
static String? deviceId(String token) {
|
|
final payload = decodePayload(token);
|
|
final id = payload?['device_id'];
|
|
return id is String && id.isNotEmpty ? id : null;
|
|
}
|
|
}
|
|
|
|
/// Credentials captured from a browser/WebView GQL request.
|
|
class GqlIntegrityCredentials {
|
|
final String integrityToken;
|
|
final String deviceId;
|
|
final int expirationMs;
|
|
|
|
const GqlIntegrityCredentials({
|
|
required this.integrityToken,
|
|
required this.deviceId,
|
|
required this.expirationMs,
|
|
});
|
|
}
|