From 5e6e1ca245b9b6e01cad4400b121cfde545cd39e Mon Sep 17 00:00:00 2001 From: Cita Date: Fri, 6 Mar 2026 21:36:23 +0800 Subject: [PATCH 1/3] ci: make release packaging locally reproducible --- .github/workflows/release.yml | 21 +++------------------ 1 file changed, 3 insertions(+), 18 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6d31f67..5d5e3ab 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -114,20 +114,6 @@ jobs: name: metapi-dist-${{ github.sha }} path: . - - name: Prefetch AppImage tools - if: runner.os == 'Linux' - shell: bash - env: - APPIMAGE_TOOLS_URL: https://github.com/electron-userland/electron-builder-binaries/releases/download/appimage%401.0.2/appimage-tools-runtime-20251108.tar.gz - run: | - set -euo pipefail - APPIMAGE_TOOLS_DIR="${{ runner.temp }}/.cache/appimage-tools" - if [ -x "$APPIMAGE_TOOLS_DIR/mksquashfs" ]; then - exit 0 - fi - mkdir -p "$APPIMAGE_TOOLS_DIR" - curl -fL --retry 5 --retry-all-errors --retry-delay 5 "$APPIMAGE_TOOLS_URL" | tar -xz -C "$APPIMAGE_TOOLS_DIR" - - name: Build desktop artifacts (Windows) if: runner.os == 'Windows' shell: pwsh @@ -145,7 +131,7 @@ jobs: if ($env:WIN_CSC_KEY_PASSWORD_INPUT) { $env:WIN_CSC_KEY_PASSWORD = $env:WIN_CSC_KEY_PASSWORD_INPUT } for ($attempt = 1; $attempt -le 2; $attempt++) { Write-Host "Desktop build attempt $attempt/2" - npm run package:desktop + npm run package:desktop -- --config.npmRebuild=false if ($LASTEXITCODE -eq 0) { break } @@ -165,12 +151,11 @@ jobs: ELECTRON_BUILDER_CACHE: ${{ runner.temp }}/.cache/electron-builder ELECTRON_BUILDER_BINARIES_MIRROR: https://github.com/electron-userland/electron-builder-binaries/releases/download/ npm_config_electron_builder_binaries_mirror: https://github.com/electron-userland/electron-builder-binaries/releases/download/ - APPIMAGE_TOOLS_PATH: ${{ runner.temp }}/.cache/appimage-tools run: | set -euo pipefail for attempt in 1 2; do echo "Desktop build attempt ${attempt}/2" - if npm run package:desktop; then + if npm run package:desktop -- --config.npmRebuild=false; then break fi if [ "$attempt" -eq 2 ]; then @@ -199,7 +184,7 @@ jobs: if [ -n "${APPLE_TEAM_ID_INPUT:-}" ]; then export APPLE_TEAM_ID="$APPLE_TEAM_ID_INPUT"; fi for attempt in 1 2; do echo "Desktop build attempt ${attempt}/2" - if npm run package:desktop; then + if npm run package:desktop -- --config.npmRebuild=false; then break fi if [ "$attempt" -eq 2 ]; then From d668906c8e9c424866cecea3bd3cb193ccbb7860 Mon Sep 17 00:00:00 2001 From: Cita Date: Fri, 6 Mar 2026 22:00:29 +0800 Subject: [PATCH 2/3] ci: normalize dist artifact before desktop packaging --- .github/workflows/release.yml | 53 ++++++++++++++++++++++++++--------- 1 file changed, 40 insertions(+), 13 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6d31f67..1af2a67 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -112,21 +112,49 @@ jobs: uses: actions/download-artifact@v4 with: name: metapi-dist-${{ github.sha }} - path: . + path: dist - - name: Prefetch AppImage tools - if: runner.os == 'Linux' + + - name: Normalize downloaded dist layout (Windows) + if: runner.os == 'Windows' + shell: pwsh + run: | + if (Test-Path 'dist/desktop/main.js') { + exit 0 + } + if (Test-Path 'dist/dist/desktop/main.js') { + Copy-Item 'dist/dist/*' 'dist/' -Recurse -Force + Remove-Item 'dist/dist' -Recurse -Force + exit 0 + } + if ((Test-Path 'desktop/main.js') -or (Test-Path 'web/index.html') -or (Test-Path 'server/index.js')) { + New-Item -ItemType Directory -Path 'dist' -Force | Out-Null + if (Test-Path 'desktop') { Move-Item 'desktop' 'dist/' -Force } + if (Test-Path 'web') { Move-Item 'web' 'dist/' -Force } + if (Test-Path 'server') { Move-Item 'server' 'dist/' -Force } + } + if (-not (Test-Path 'dist/desktop/main.js')) { + throw 'Missing dist/desktop/main.js after downloading dist artifact' + } + + - name: Normalize downloaded dist layout (Linux/macOS) + if: runner.os != 'Windows' shell: bash - env: - APPIMAGE_TOOLS_URL: https://github.com/electron-userland/electron-builder-binaries/releases/download/appimage%401.0.2/appimage-tools-runtime-20251108.tar.gz run: | set -euo pipefail - APPIMAGE_TOOLS_DIR="${{ runner.temp }}/.cache/appimage-tools" - if [ -x "$APPIMAGE_TOOLS_DIR/mksquashfs" ]; then + if [ -f dist/desktop/main.js ]; then exit 0 fi - mkdir -p "$APPIMAGE_TOOLS_DIR" - curl -fL --retry 5 --retry-all-errors --retry-delay 5 "$APPIMAGE_TOOLS_URL" | tar -xz -C "$APPIMAGE_TOOLS_DIR" + if [ -f dist/dist/desktop/main.js ]; then + cp -R dist/dist/. dist/ + rm -rf dist/dist + elif [ -f desktop/main.js ] || [ -f web/index.html ] || [ -f server/index.js ]; then + mkdir -p dist + [ -d desktop ] && mv desktop dist/ + [ -d web ] && mv web dist/ + [ -d server ] && mv server dist/ + fi + test -f dist/desktop/main.js - name: Build desktop artifacts (Windows) if: runner.os == 'Windows' @@ -145,7 +173,7 @@ jobs: if ($env:WIN_CSC_KEY_PASSWORD_INPUT) { $env:WIN_CSC_KEY_PASSWORD = $env:WIN_CSC_KEY_PASSWORD_INPUT } for ($attempt = 1; $attempt -le 2; $attempt++) { Write-Host "Desktop build attempt $attempt/2" - npm run package:desktop + npm run package:desktop -- --config.npmRebuild=false if ($LASTEXITCODE -eq 0) { break } @@ -165,12 +193,11 @@ jobs: ELECTRON_BUILDER_CACHE: ${{ runner.temp }}/.cache/electron-builder ELECTRON_BUILDER_BINARIES_MIRROR: https://github.com/electron-userland/electron-builder-binaries/releases/download/ npm_config_electron_builder_binaries_mirror: https://github.com/electron-userland/electron-builder-binaries/releases/download/ - APPIMAGE_TOOLS_PATH: ${{ runner.temp }}/.cache/appimage-tools run: | set -euo pipefail for attempt in 1 2; do echo "Desktop build attempt ${attempt}/2" - if npm run package:desktop; then + if npm run package:desktop -- --config.npmRebuild=false; then break fi if [ "$attempt" -eq 2 ]; then @@ -199,7 +226,7 @@ jobs: if [ -n "${APPLE_TEAM_ID_INPUT:-}" ]; then export APPLE_TEAM_ID="$APPLE_TEAM_ID_INPUT"; fi for attempt in 1 2; do echo "Desktop build attempt ${attempt}/2" - if npm run package:desktop; then + if npm run package:desktop -- --config.npmRebuild=false; then break fi if [ "$attempt" -eq 2 ]; then From 0d99419b9f5a58c1ff7fe27ff19101e278e4a05c Mon Sep 17 00:00:00 2001 From: cita Date: Fri, 6 Mar 2026 22:44:56 +0800 Subject: [PATCH 3/3] feat: implement GOB encoding support for user ID extraction and enhance cookie handling in NewApiAdapter --- src/server/services/platforms/newApi.test.ts | 43 ++++ src/server/services/platforms/newApi.ts | 250 ++++++++++++++----- 2 files changed, 232 insertions(+), 61 deletions(-) diff --git a/src/server/services/platforms/newApi.test.ts b/src/server/services/platforms/newApi.test.ts index de55a71..fefb94f 100644 --- a/src/server/services/platforms/newApi.test.ts +++ b/src/server/services/platforms/newApi.test.ts @@ -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); diff --git a/src/server/services/platforms/newApi.ts b/src/server/services/platforms/newApi.ts index 8aa487e..31d6cfb 100644 --- a/src/server/services/platforms/newApi.ts +++ b/src/server/services/platforms/newApi.ts @@ -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 { + 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 { 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(`${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 => { + for (const cookie of this.buildCookieCandidates(accessToken)) { + try { + const signInRes = await this.fetchJsonRaw(`${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 = { Cookie: cookie }; + if (cookieUserId) headers['New-Api-User'] = String(cookieUserId); + const res = await this.fetchJsonRaw(`${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 = { Cookie: cookie }; - if (cookieUserId) headers['New-Api-User'] = String(cookieUserId); - const res = await this.fetchJsonRaw(`${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 []; } }