1
0
forked from boos/BoosAPI

feat: implement GOB encoding support for user ID extraction and enhance cookie handling in NewApiAdapter

This commit is contained in:
cita
2026-03-06 22:44:56 +08:00
parent 02bf0393e6
commit 0d99419b9f
2 changed files with 232 additions and 61 deletions
@@ -26,6 +26,12 @@ const COOKIE_ONLY_LOGIN_SESSION = 'cookie-only-session';
const COOKIE_SHIELDED_TOKEN = Buffer.from( const COOKIE_SHIELDED_TOKEN = Buffer.from(
`1771864970|${Buffer.from('username=linuxdo_131936').toString('base64')}|sig`, `1771864970|${Buffer.from('username=linuxdo_131936').toString('base64')}|sig`,
).toString('base64'); ).toString('base64');
const COOKIE_GOB_USER_TOKEN = Buffer.from(
`1772806887|${Buffer.from(
'0d7f040102ff8000011001100000ff93ff80000506737472696e670c060004726f6c6503696e740402000206737472696e670c08000673746174757303696e740402000206737472696e670c07000567726f757006737472696e670c09000764656661756c7406737472696e670c040002696403696e74040500fd04683006737472696e670c0a0008757365726e616d6506737472696e670c09000773756974313539',
'hex',
).toString('base64')}|sig`,
).toString('base64');
const ANYROUTER_CHALLENGE_HTML = readFileSync( const ANYROUTER_CHALLENGE_HTML = readFileSync(
new URL('./__fixtures__/anyrouter-challenge.html', import.meta.url), new URL('./__fixtures__/anyrouter-challenge.html', import.meta.url),
'utf8', 'utf8',
@@ -293,6 +299,20 @@ describe('NewApiAdapter', () => {
return; 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.writeHead(401, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ success: false, message: 'unauthorized' })); res.end(JSON.stringify({ success: false, message: 'unauthorized' }));
return; return;
@@ -467,6 +487,29 @@ describe('NewApiAdapter', () => {
).toBe(true); ).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 () => { it('uses shielded cookie flow for balance and checkin', async () => {
const adapter = new NewApiAdapter(); const adapter = new NewApiAdapter();
const balance = await adapter.getBalance(baseUrl, COOKIE_SHIELDED_TOKEN); const balance = await adapter.getBalance(baseUrl, COOKIE_SHIELDED_TOKEN);
+189 -61
View File
@@ -72,6 +72,76 @@ export class NewApiAdapter extends BasePlatformAdapter {
return null; 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[] { private extractLikelyUserIds(token: string): number[] {
const ids: number[] = []; const ids: number[] = [];
const push = (value: unknown) => { const push = (value: unknown) => {
@@ -96,14 +166,20 @@ export class NewApiAdapter extends BasePlatformAdapter {
} }
for (const sessionValue of sessionValues) { for (const sessionValue of sessionValues) {
const decoded = this.decodeBase64Loose(sessionValue); const decodedBuffer = this.decodeBase64BufferLoose(sessionValue);
if (!decoded) continue; if (!decodedBuffer) continue;
const decoded = decodedBuffer.toString('utf8');
const payloadCandidates: string[] = [decoded]; const payloadCandidates: string[] = [decoded];
const payloadBuffers: Buffer[] = [decodedBuffer];
const parts = decoded.split('|'); const parts = decoded.split('|');
if (parts.length >= 2) { if (parts.length >= 2) {
const middlePayload = this.decodeBase64Loose(parts[1]); const middlePayloadBuffer = this.decodeBase64BufferLoose(parts[1]);
if (middlePayload) payloadCandidates.push(middlePayload); if (middlePayloadBuffer) {
payloadCandidates.push(middlePayloadBuffer.toString('utf8'));
payloadBuffers.push(middlePayloadBuffer);
}
} }
for (const payload of payloadCandidates) { for (const payload of payloadCandidates) {
@@ -114,6 +190,12 @@ export class NewApiAdapter extends BasePlatformAdapter {
push(m[1]); push(m[1]);
} }
} }
for (const payload of payloadBuffers) {
for (const value of this.extractGobFieldInts(payload, 'id')) {
push(value);
}
}
} }
return ids; return ids;
@@ -584,6 +666,19 @@ export class NewApiAdapter extends BasePlatformAdapter {
return null; 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[]> { private async getApiTokensByCookie(baseUrl: string, token: string, userId?: number | null): Promise<ApiTokenInfo[]> {
for (const cookie of this.buildCookieCandidates(token)) { for (const cookie of this.buildCookieCandidates(token)) {
try { try {
@@ -670,6 +765,16 @@ export class NewApiAdapter extends BasePlatformAdapter {
} }
} catch {} } 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; return null;
} }
@@ -771,17 +876,15 @@ export class NewApiAdapter extends BasePlatformAdapter {
return { tokenType: 'session', userInfo, balance, apiToken }; return { tokenType: 'session', userInfo, balance, apiToken };
} }
if (!platformUserId) { const cookieUserId = await this.probeAlternateUserIdByCookie(baseUrl, token, platformUserId);
const cookieUserId = await this.probeUserIdByCookie(baseUrl, token); if (cookieUserId) {
if (cookieUserId) { const cookieRetry = await this.fetchUserSelfByCookie(baseUrl, token, cookieUserId);
const cookieRetry = await this.fetchUserSelfByCookie(baseUrl, token, cookieUserId); if (cookieRetry?.success && cookieRetry?.data) {
if (cookieRetry?.success && cookieRetry?.data) { const userInfo = this.parseUserInfo(cookieRetry.data);
const userInfo = this.parseUserInfo(cookieRetry.data); const balance = this.parseBalance(cookieRetry.data);
const balance = this.parseBalance(cookieRetry.data); let apiToken: string | null = null;
let apiToken: string | null = null; try { apiToken = await this.getApiTokenWithUser(baseUrl, token, cookieUserId); } catch {}
try { apiToken = await this.getApiTokenWithUser(baseUrl, token, cookieUserId); } catch {} return { tokenType: 'session', userInfo, balance, apiToken };
return { tokenType: 'session', userInfo, balance, apiToken };
}
} }
} }
@@ -839,47 +942,59 @@ export class NewApiAdapter extends BasePlatformAdapter {
return { success: false, message: firstFailureMessage }; return { success: false, message: firstFailureMessage };
} }
const cookieUserId = resolvedUserId || await this.probeUserIdByCookie(baseUrl, accessToken); const tryCookieCheckin = async (cookieUserId?: number | null): Promise<CheckinResult | null> => {
for (const cookie of this.buildCookieCandidates(accessToken)) { for (const cookie of this.buildCookieCandidates(accessToken)) {
try { try {
const signInRes = await this.fetchJsonRaw<any>(`${baseUrl}/api/user/sign_in`, { const signInRes = await this.fetchJsonRaw<any>(`${baseUrl}/api/user/sign_in`, {
method: 'POST', method: 'POST',
body: '{}', body: '{}',
headers: { headers: {
Cookie: cookie, Cookie: cookie,
'X-Requested-With': 'XMLHttpRequest', 'X-Requested-With': 'XMLHttpRequest',
}, },
}); });
if (signInRes?.success) { if (signInRes?.success) {
return { return {
success: true, success: true,
message: signInRes.message || 'checked in', message: signInRes.message || 'checked in',
reward: signInRes.data?.reward?.toString(), 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 { return null;
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`, { const initialCookieResult = await tryCookieCheckin(resolvedUserId);
method: 'POST', if (initialCookieResult) return initialCookieResult;
headers,
}); const alternateCookieUserId = await this.probeAlternateUserIdByCookie(baseUrl, accessToken, resolvedUserId);
if (res?.success) { if (alternateCookieUserId) {
return { success: true, message: res.message || 'checkin success', reward: res.data?.reward?.toString() }; const retriedCookieResult = await tryCookieCheckin(alternateCookieUserId);
} if (retriedCookieResult) return retriedCookieResult;
const cookieMessage = this.extractResponseMessage(res);
if (cookieMessage) firstFailureMessage = cookieMessage;
} catch (err) {
const parsed = this.formatRequestErrorMessage(err);
if (parsed) firstFailureMessage = parsed;
}
} }
return { success: false, message: firstFailureMessage || 'checkin failed' }; return { success: false, message: firstFailureMessage || 'checkin failed' };
@@ -917,13 +1032,11 @@ export class NewApiAdapter extends BasePlatformAdapter {
return this.parseBalance(cookieRes.data); return this.parseBalance(cookieRes.data);
} }
if (!resolvedUserId) { const cookieUserId = await this.probeAlternateUserIdByCookie(baseUrl, accessToken, resolvedUserId);
const cookieUserId = await this.probeUserIdByCookie(baseUrl, accessToken); if (cookieUserId) {
if (cookieUserId) { const cookieRetry = await this.fetchUserSelfByCookie(baseUrl, accessToken, cookieUserId, rememberFailure);
const cookieRetry = await this.fetchUserSelfByCookie(baseUrl, accessToken, cookieUserId, rememberFailure); if (cookieRetry?.success && cookieRetry?.data) {
if (cookieRetry?.success && cookieRetry?.data) { return this.parseBalance(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); const cookieModels = await this.getSessionModelsByCookie(baseUrl, token, userId || platformUserId);
if (cookieModels.length > 0) return cookieModels; 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 []; return [];
} }
@@ -1119,6 +1238,15 @@ export class NewApiAdapter extends BasePlatformAdapter {
if (normalized.length > 0) return normalized; if (normalized.length > 0) return normalized;
} catch {} } 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 [];
} }
} }