feat: implement GOB encoding support for user ID extraction and enhance cookie handling in NewApiAdapter
This commit is contained in:
@@ -26,6 +26,12 @@ const COOKIE_ONLY_LOGIN_SESSION = 'cookie-only-session';
|
||||
const COOKIE_SHIELDED_TOKEN = Buffer.from(
|
||||
`1771864970|${Buffer.from('username=linuxdo_131936').toString('base64')}|sig`,
|
||||
).toString('base64');
|
||||
const COOKIE_GOB_USER_TOKEN = Buffer.from(
|
||||
`1772806887|${Buffer.from(
|
||||
'0d7f040102ff8000011001100000ff93ff80000506737472696e670c060004726f6c6503696e740402000206737472696e670c08000673746174757303696e740402000206737472696e670c07000567726f757006737472696e670c09000764656661756c7406737472696e670c040002696403696e74040500fd04683006737472696e670c0a0008757365726e616d6506737472696e670c09000773756974313539',
|
||||
'hex',
|
||||
).toString('base64')}|sig`,
|
||||
).toString('base64');
|
||||
const ANYROUTER_CHALLENGE_HTML = readFileSync(
|
||||
new URL('./__fixtures__/anyrouter-challenge.html', import.meta.url),
|
||||
'utf8',
|
||||
@@ -293,6 +299,20 @@ describe('NewApiAdapter', () => {
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof req.headers.cookie === 'string' && req.headers.cookie.includes(`session=${COOKIE_GOB_USER_TOKEN}`)) {
|
||||
if (req.headers['new-api-user'] !== '144408') {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ success: false, message: 'missing New-Api-User' }));
|
||||
return;
|
||||
}
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({
|
||||
success: true,
|
||||
data: { id: 144408, username: 'suit159', quota: 50000000, used_quota: 0 },
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
res.writeHead(401, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ success: false, message: 'unauthorized' }));
|
||||
return;
|
||||
@@ -467,6 +487,29 @@ describe('NewApiAdapter', () => {
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('extracts gob-encoded user id from anyrouter session cookie when reading balance', async () => {
|
||||
const adapter = new NewApiAdapter();
|
||||
const balance = await adapter.getBalance(baseUrl, COOKIE_GOB_USER_TOKEN);
|
||||
|
||||
expect(balance.balance).toBe(100);
|
||||
expect(
|
||||
requests.some((r) => r.url === '/api/user/self' && r.headers['new-api-user'] === '144408'),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('recovers from mismatched provided user id by probing gob-encoded session payload', async () => {
|
||||
const adapter = new NewApiAdapter();
|
||||
const balance = await adapter.getBalance(baseUrl, COOKIE_GOB_USER_TOKEN, 159);
|
||||
|
||||
expect(balance.balance).toBe(100);
|
||||
expect(
|
||||
requests.some((r) => r.url === '/api/user/self' && r.headers['new-api-user'] === '159'),
|
||||
).toBe(true);
|
||||
expect(
|
||||
requests.some((r) => r.url === '/api/user/self' && r.headers['new-api-user'] === '144408'),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('uses shielded cookie flow for balance and checkin', async () => {
|
||||
const adapter = new NewApiAdapter();
|
||||
const balance = await adapter.getBalance(baseUrl, COOKIE_SHIELDED_TOKEN);
|
||||
|
||||
@@ -72,6 +72,76 @@ export class NewApiAdapter extends BasePlatformAdapter {
|
||||
return null;
|
||||
}
|
||||
|
||||
private decodeBase64BufferLoose(value: string): Buffer | null {
|
||||
if (!value) return null;
|
||||
try {
|
||||
return Buffer.from(value, 'base64');
|
||||
} catch {}
|
||||
try {
|
||||
const normalized = value.replace(/-/g, '+').replace(/_/g, '/');
|
||||
return Buffer.from(normalized, 'base64');
|
||||
} catch {}
|
||||
return null;
|
||||
}
|
||||
|
||||
private decodeGobSignedInt(encoded: Buffer): number | null {
|
||||
if (!encoded.length) return null;
|
||||
|
||||
let unsigned = 0n;
|
||||
if (encoded[0] < 0x80) {
|
||||
unsigned = BigInt(encoded[0]);
|
||||
} else {
|
||||
const width = 0x100 - encoded[0];
|
||||
if (width <= 0 || encoded.length !== width + 1) return null;
|
||||
for (let i = 1; i < encoded.length; i += 1) {
|
||||
unsigned = (unsigned << 8n) | BigInt(encoded[i]);
|
||||
}
|
||||
}
|
||||
|
||||
const signed = (unsigned & 1n) === 0n
|
||||
? unsigned >> 1n
|
||||
: -((unsigned >> 1n) + 1n);
|
||||
if (signed <= 0n || signed > BigInt(Number.MAX_SAFE_INTEGER)) return null;
|
||||
return Number(signed);
|
||||
}
|
||||
|
||||
private extractGobFieldInts(payload: Buffer, fieldName: string): number[] {
|
||||
const ids: number[] = [];
|
||||
const push = (value: number | null) => {
|
||||
if (typeof value !== 'number' || Number.isNaN(value)) return;
|
||||
if (value <= 0 || value > 10_000_000) return;
|
||||
if (!ids.includes(value)) ids.push(value);
|
||||
};
|
||||
|
||||
const marker = Buffer.concat([
|
||||
Buffer.from(fieldName, 'utf8'),
|
||||
Buffer.from([0x03]),
|
||||
Buffer.from('int', 'utf8'),
|
||||
Buffer.from([0x04]),
|
||||
]);
|
||||
|
||||
let start = 0;
|
||||
while (start < payload.length) {
|
||||
const position = payload.indexOf(marker, start);
|
||||
if (position < 0) break;
|
||||
|
||||
const encodedLength = payload[position + marker.length];
|
||||
const delimiter = payload[position + marker.length + 1];
|
||||
if (typeof encodedLength === 'number' && delimiter === 0x00) {
|
||||
const byteLength = encodedLength - 1;
|
||||
const valueStart = position + marker.length + 2;
|
||||
const valueEnd = valueStart + byteLength;
|
||||
if (byteLength > 0 && valueEnd <= payload.length) {
|
||||
push(this.decodeGobSignedInt(payload.subarray(valueStart, valueEnd)));
|
||||
}
|
||||
}
|
||||
|
||||
start = position + marker.length;
|
||||
}
|
||||
|
||||
return ids;
|
||||
}
|
||||
|
||||
private extractLikelyUserIds(token: string): number[] {
|
||||
const ids: number[] = [];
|
||||
const push = (value: unknown) => {
|
||||
@@ -96,14 +166,20 @@ export class NewApiAdapter extends BasePlatformAdapter {
|
||||
}
|
||||
|
||||
for (const sessionValue of sessionValues) {
|
||||
const decoded = this.decodeBase64Loose(sessionValue);
|
||||
if (!decoded) continue;
|
||||
const decodedBuffer = this.decodeBase64BufferLoose(sessionValue);
|
||||
if (!decodedBuffer) continue;
|
||||
|
||||
const decoded = decodedBuffer.toString('utf8');
|
||||
|
||||
const payloadCandidates: string[] = [decoded];
|
||||
const payloadBuffers: Buffer[] = [decodedBuffer];
|
||||
const parts = decoded.split('|');
|
||||
if (parts.length >= 2) {
|
||||
const middlePayload = this.decodeBase64Loose(parts[1]);
|
||||
if (middlePayload) payloadCandidates.push(middlePayload);
|
||||
const middlePayloadBuffer = this.decodeBase64BufferLoose(parts[1]);
|
||||
if (middlePayloadBuffer) {
|
||||
payloadCandidates.push(middlePayloadBuffer.toString('utf8'));
|
||||
payloadBuffers.push(middlePayloadBuffer);
|
||||
}
|
||||
}
|
||||
|
||||
for (const payload of payloadCandidates) {
|
||||
@@ -114,6 +190,12 @@ export class NewApiAdapter extends BasePlatformAdapter {
|
||||
push(m[1]);
|
||||
}
|
||||
}
|
||||
|
||||
for (const payload of payloadBuffers) {
|
||||
for (const value of this.extractGobFieldInts(payload, 'id')) {
|
||||
push(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ids;
|
||||
@@ -584,6 +666,19 @@ export class NewApiAdapter extends BasePlatformAdapter {
|
||||
return null;
|
||||
}
|
||||
|
||||
private async probeAlternateUserIdByCookie(
|
||||
baseUrl: string,
|
||||
token: string,
|
||||
currentUserId?: number | null,
|
||||
): Promise<number | null> {
|
||||
const probed = await this.probeUserIdByCookie(baseUrl, token);
|
||||
if (!probed) return null;
|
||||
if (typeof currentUserId === 'number' && currentUserId > 0 && probed === currentUserId) {
|
||||
return null;
|
||||
}
|
||||
return probed;
|
||||
}
|
||||
|
||||
private async getApiTokensByCookie(baseUrl: string, token: string, userId?: number | null): Promise<ApiTokenInfo[]> {
|
||||
for (const cookie of this.buildCookieCandidates(token)) {
|
||||
try {
|
||||
@@ -670,6 +765,16 @@ export class NewApiAdapter extends BasePlatformAdapter {
|
||||
}
|
||||
} catch {}
|
||||
|
||||
try {
|
||||
const fallbackUserId = await this.probeAlternateUserIdByCookie(baseUrl, accessToken, platformUserId);
|
||||
if (fallbackUserId) {
|
||||
const cookieRetry = await this.fetchUserSelfByCookie(baseUrl, accessToken, fallbackUserId);
|
||||
if (cookieRetry?.success && cookieRetry?.data) {
|
||||
return this.parseUserInfo(cookieRetry.data);
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -771,17 +876,15 @@ export class NewApiAdapter extends BasePlatformAdapter {
|
||||
return { tokenType: 'session', userInfo, balance, apiToken };
|
||||
}
|
||||
|
||||
if (!platformUserId) {
|
||||
const cookieUserId = await this.probeUserIdByCookie(baseUrl, token);
|
||||
if (cookieUserId) {
|
||||
const cookieRetry = await this.fetchUserSelfByCookie(baseUrl, token, cookieUserId);
|
||||
if (cookieRetry?.success && cookieRetry?.data) {
|
||||
const userInfo = this.parseUserInfo(cookieRetry.data);
|
||||
const balance = this.parseBalance(cookieRetry.data);
|
||||
let apiToken: string | null = null;
|
||||
try { apiToken = await this.getApiTokenWithUser(baseUrl, token, cookieUserId); } catch {}
|
||||
return { tokenType: 'session', userInfo, balance, apiToken };
|
||||
}
|
||||
const cookieUserId = await this.probeAlternateUserIdByCookie(baseUrl, token, platformUserId);
|
||||
if (cookieUserId) {
|
||||
const cookieRetry = await this.fetchUserSelfByCookie(baseUrl, token, cookieUserId);
|
||||
if (cookieRetry?.success && cookieRetry?.data) {
|
||||
const userInfo = this.parseUserInfo(cookieRetry.data);
|
||||
const balance = this.parseBalance(cookieRetry.data);
|
||||
let apiToken: string | null = null;
|
||||
try { apiToken = await this.getApiTokenWithUser(baseUrl, token, cookieUserId); } catch {}
|
||||
return { tokenType: 'session', userInfo, balance, apiToken };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -839,47 +942,59 @@ export class NewApiAdapter extends BasePlatformAdapter {
|
||||
return { success: false, message: firstFailureMessage };
|
||||
}
|
||||
|
||||
const cookieUserId = resolvedUserId || await this.probeUserIdByCookie(baseUrl, accessToken);
|
||||
for (const cookie of this.buildCookieCandidates(accessToken)) {
|
||||
try {
|
||||
const signInRes = await this.fetchJsonRaw<any>(`${baseUrl}/api/user/sign_in`, {
|
||||
method: 'POST',
|
||||
body: '{}',
|
||||
headers: {
|
||||
Cookie: cookie,
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
},
|
||||
});
|
||||
if (signInRes?.success) {
|
||||
return {
|
||||
success: true,
|
||||
message: signInRes.message || 'checked in',
|
||||
reward: signInRes.data?.reward?.toString(),
|
||||
};
|
||||
const tryCookieCheckin = async (cookieUserId?: number | null): Promise<CheckinResult | null> => {
|
||||
for (const cookie of this.buildCookieCandidates(accessToken)) {
|
||||
try {
|
||||
const signInRes = await this.fetchJsonRaw<any>(`${baseUrl}/api/user/sign_in`, {
|
||||
method: 'POST',
|
||||
body: '{}',
|
||||
headers: {
|
||||
Cookie: cookie,
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
},
|
||||
});
|
||||
if (signInRes?.success) {
|
||||
return {
|
||||
success: true,
|
||||
message: signInRes.message || 'checked in',
|
||||
reward: signInRes.data?.reward?.toString(),
|
||||
};
|
||||
}
|
||||
const signInMessage = this.extractResponseMessage(signInRes);
|
||||
if (!firstFailureMessage && signInMessage) firstFailureMessage = signInMessage;
|
||||
} catch (err) {
|
||||
const parsed = this.formatRequestErrorMessage(err);
|
||||
if (!firstFailureMessage && parsed) firstFailureMessage = parsed;
|
||||
}
|
||||
|
||||
try {
|
||||
const headers: Record<string, string> = { Cookie: cookie };
|
||||
if (cookieUserId) headers['New-Api-User'] = String(cookieUserId);
|
||||
const res = await this.fetchJsonRaw<any>(`${baseUrl}/api/user/checkin`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
});
|
||||
if (res?.success) {
|
||||
return { success: true, message: res.message || 'checkin success', reward: res.data?.reward?.toString() };
|
||||
}
|
||||
const cookieMessage = this.extractResponseMessage(res);
|
||||
if (cookieMessage) firstFailureMessage = cookieMessage;
|
||||
} catch (err) {
|
||||
const parsed = this.formatRequestErrorMessage(err);
|
||||
if (parsed) firstFailureMessage = parsed;
|
||||
}
|
||||
const signInMessage = this.extractResponseMessage(signInRes);
|
||||
if (!firstFailureMessage && signInMessage) firstFailureMessage = signInMessage;
|
||||
} catch (err) {
|
||||
const parsed = this.formatRequestErrorMessage(err);
|
||||
if (!firstFailureMessage && parsed) firstFailureMessage = parsed;
|
||||
}
|
||||
|
||||
try {
|
||||
const headers: Record<string, string> = { Cookie: cookie };
|
||||
if (cookieUserId) headers['New-Api-User'] = String(cookieUserId);
|
||||
const res = await this.fetchJsonRaw<any>(`${baseUrl}/api/user/checkin`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
});
|
||||
if (res?.success) {
|
||||
return { success: true, message: res.message || 'checkin success', reward: res.data?.reward?.toString() };
|
||||
}
|
||||
const cookieMessage = this.extractResponseMessage(res);
|
||||
if (cookieMessage) firstFailureMessage = cookieMessage;
|
||||
} catch (err) {
|
||||
const parsed = this.formatRequestErrorMessage(err);
|
||||
if (parsed) firstFailureMessage = parsed;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const initialCookieResult = await tryCookieCheckin(resolvedUserId);
|
||||
if (initialCookieResult) return initialCookieResult;
|
||||
|
||||
const alternateCookieUserId = await this.probeAlternateUserIdByCookie(baseUrl, accessToken, resolvedUserId);
|
||||
if (alternateCookieUserId) {
|
||||
const retriedCookieResult = await tryCookieCheckin(alternateCookieUserId);
|
||||
if (retriedCookieResult) return retriedCookieResult;
|
||||
}
|
||||
|
||||
return { success: false, message: firstFailureMessage || 'checkin failed' };
|
||||
@@ -917,13 +1032,11 @@ export class NewApiAdapter extends BasePlatformAdapter {
|
||||
return this.parseBalance(cookieRes.data);
|
||||
}
|
||||
|
||||
if (!resolvedUserId) {
|
||||
const cookieUserId = await this.probeUserIdByCookie(baseUrl, accessToken);
|
||||
if (cookieUserId) {
|
||||
const cookieRetry = await this.fetchUserSelfByCookie(baseUrl, accessToken, cookieUserId, rememberFailure);
|
||||
if (cookieRetry?.success && cookieRetry?.data) {
|
||||
return this.parseBalance(cookieRetry.data);
|
||||
}
|
||||
const cookieUserId = await this.probeAlternateUserIdByCookie(baseUrl, accessToken, resolvedUserId);
|
||||
if (cookieUserId) {
|
||||
const cookieRetry = await this.fetchUserSelfByCookie(baseUrl, accessToken, cookieUserId, rememberFailure);
|
||||
if (cookieRetry?.success && cookieRetry?.data) {
|
||||
return this.parseBalance(cookieRetry.data);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -952,6 +1065,12 @@ export class NewApiAdapter extends BasePlatformAdapter {
|
||||
const cookieModels = await this.getSessionModelsByCookie(baseUrl, token, userId || platformUserId);
|
||||
if (cookieModels.length > 0) return cookieModels;
|
||||
|
||||
const alternateCookieUserId = await this.probeAlternateUserIdByCookie(baseUrl, token, userId || platformUserId);
|
||||
if (alternateCookieUserId) {
|
||||
const fallbackModels = await this.getSessionModelsByCookie(baseUrl, token, alternateCookieUserId);
|
||||
if (fallbackModels.length > 0) return fallbackModels;
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
@@ -1119,6 +1238,15 @@ export class NewApiAdapter extends BasePlatformAdapter {
|
||||
if (normalized.length > 0) return normalized;
|
||||
} catch {}
|
||||
|
||||
return this.getApiTokensByCookie(baseUrl, accessToken, userId);
|
||||
const cookieTokens = await this.getApiTokensByCookie(baseUrl, accessToken, userId);
|
||||
if (cookieTokens.length > 0) return cookieTokens;
|
||||
|
||||
const alternateCookieUserId = await this.probeAlternateUserIdByCookie(baseUrl, accessToken, userId);
|
||||
if (alternateCookieUserId) {
|
||||
const fallbackTokens = await this.getApiTokensByCookie(baseUrl, accessToken, alternateCookieUserId);
|
||||
if (fallbackTokens.length > 0) return fallbackTokens;
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user