From 87ac6ffedbcd32418b9404eb424cdc61522df9d2 Mon Sep 17 00:00:00 2001 From: DrSmoothl <1787882683@qq.com> Date: Tue, 19 May 2026 21:18:08 +0800 Subject: [PATCH] fix(file-upload): fall back through parallel host fields when uploadIp is empty MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit User report: `upload_private_file` was failing with "private file upload host is invalid" on a fresh upload (boolFileExist=false). The OIDB call succeeded but `uploadIp` (field 60) came back empty, and we treated that single field as the only source of truth. Cross-referenced LagrangeGo's `OidbSvcTrpcTcp0xE37_800.proto` + NapCat's `Oidb.0XE37_800.ts`: the server has six parallel host slots on this response (uploadIp / uploadDomain / uploadIpList[0] / uploadHttpsDomain / uploadDns / uploadLanip). LagrangeGo + Lagrange.Core only read field 60 and would hit the same dead-end — we walk the chain instead. Also log the full slot set when we genuinely can't find a host so future reports can name which field the server actually populated. Adds two regression tests: one proves the fallback to `uploadDomain` goes to highway successfully, one proves we still throw when every slot is empty. --- .../core/src/bridge/actions/group-file.ts | 34 +++++++++++++- .../src/bridge/proto/proton/oidb-action.ts | 16 +++++++ .../core/tests/actions/group-file.test.ts | 45 +++++++++++++++++++ 3 files changed, 94 insertions(+), 1 deletion(-) diff --git a/packages/core/src/bridge/actions/group-file.ts b/packages/core/src/bridge/actions/group-file.ts index f56b408f..ef12dbd3 100644 --- a/packages/core/src/bridge/actions/group-file.ts +++ b/packages/core/src/bridge/actions/group-file.ts @@ -440,10 +440,42 @@ export async function uploadPrivateFile( if (!fileId) throw new Error('private file upload response missing file_id'); if (!upload.boolFileExist && uploadFile) { + // The server normally returns the highway host in `uploadIp` (field + // 60). LagrangeGo / Lagrange.Core both read that field directly with + // no fallback. We've seen real captures where the server picks one + // of the parallel host fields (uploadDomain / uploadIpList[0] / + // uploadHttpsDomain / uploadDns) instead — likely a rollout + // difference. Walk them in order rather than die with "upload host + // is invalid" when the server gives us a perfectly usable host + // through a different slot. The highway client uses HTTP PUT so we + // can pair any of these with the regular `uploadPort`; we only fall + // back to `uploadHttpsPort` if we picked the HTTPS-flavored host. + const ipListFirst = (Array.isArray(upload.uploadIpList) && upload.uploadIpList[0]) + ? upload.uploadIpList[0] : ''; const uploadHost = (typeof upload.uploadIp === 'string' && upload.uploadIp) + || (typeof upload.uploadDomain === 'string' && upload.uploadDomain) + || (ipListFirst) + || (typeof upload.uploadHttpsDomain === 'string' && upload.uploadHttpsDomain) + || (typeof upload.uploadDns === 'string' && upload.uploadDns) || ''; - const uploadPort = toInt(upload.uploadPort); + const httpsHostUsed = !upload.uploadIp && !upload.uploadDomain && !ipListFirst + && typeof upload.uploadHttpsDomain === 'string' && !!upload.uploadHttpsDomain; + const uploadPort = httpsHostUsed && toInt(upload.uploadHttpsPort) > 0 + ? toInt(upload.uploadHttpsPort) + : toInt(upload.uploadPort); if (!uploadHost || uploadPort <= 0) { + // Surface every host-bearing field we know about so a user + // hitting this can show us exactly which slot the server filled + // (or whether it returned nothing at all — which would point at + // a request mismatch rather than a missing decoder). + log.warn( + 'private file upload host missing — ip=%s domain=%s ipList=%s httpsDomain=%s dns=%s lanip=%s port=%s httpsPort=%s', + upload.uploadIp ?? '', upload.uploadDomain ?? '', + JSON.stringify(upload.uploadIpList ?? []), + upload.uploadHttpsDomain ?? '', upload.uploadDns ?? '', + upload.uploadLanip ?? '', upload.uploadPort ?? 0, + upload.uploadHttpsPort ?? 0, + ); throw new Error('private file upload host is invalid'); } diff --git a/packages/core/src/bridge/proto/proton/oidb-action.ts b/packages/core/src/bridge/proto/proton/oidb-action.ts index 1aede706..19ea7e4b 100644 --- a/packages/core/src/bridge/proto/proton/oidb-action.ts +++ b/packages/core/src/bridge/proto/proton/oidb-action.ts @@ -646,11 +646,27 @@ export interface OidbPrivateFileUploadReq { export interface OidbPrivateFileUploadRespBody { retCode?: pb<10, int_32>; retMsg?: pb<20, string>; + // 30/40/50 (totalSpace/usedSpace/uploadedSize int64) intentionally + // omitted — proton only needs fields we actually consume, and the + // quota numbers don't drive any code path. uploadIp?: pb<60, string>; + // Cross-checked against LagrangeGo `OidbSvcTrpcTcp0xE37_800.proto` + // (fields 60-170 enumerated below) + NapCat `Oidb.0XE37_800.ts`. + // Real server has been observed to leave `uploadIp` empty while + // populating `uploadDomain` / `uploadIpList[0]` instead (likely a + // server-side rollout difference). Decoding all of these lets us + // fall through in the consumer rather than die with "upload host + // is invalid" when the server picks a different field. + uploadDomain?: pb<70, string>; uploadPort?: pb<80, uint_32>; uuid?: pb<90, string>; uploadKey?: pb<100, bytes>; boolFileExist?: pb<110, bool>; + uploadIpList?: pb_repeated<130, string>; + uploadHttpsPort?: pb<140, int_32>; + uploadHttpsDomain?: pb<150, string>; + uploadDns?: pb<160, string>; + uploadLanip?: pb<170, string>; fileAddon?: pb<200, string>; mediaPlatformUploadKey?: pb<220, bytes>; } diff --git a/packages/core/tests/actions/group-file.test.ts b/packages/core/tests/actions/group-file.test.ts index 54eb890a..a5148277 100644 --- a/packages/core/tests/actions/group-file.test.ts +++ b/packages/core/tests/actions/group-file.test.ts @@ -242,6 +242,51 @@ describe('actions/group-file', () => { expect(bridge.sendC2cFileMessage).not.toHaveBeenCalled(); }); + it('uploadPrivateFile falls back to uploadDomain when uploadIp is empty', async () => { + // Regression: the server has been observed in the wild returning the + // highway host in `uploadDomain` (field 70) instead of `uploadIp` + // (field 60), causing "private file upload host is invalid". The + // helper should walk the parallel host fields rather than dying on + // a single missing slot. Cross-referenced against napcat's proto + // (`Oidb.0XE37_800.ts` ApplyUploadRespV3) — fields 60/70/130/150/160 + // all carry plausible host values from the same server endpoint. + const bridge = mockBridge(); + vi.mocked(bridge.resolveUserUid).mockResolvedValueOnce('target-uid'); + vi.mocked(oidb.runOidb).mockResolvedValueOnce( + protobuf_encode>({ + body: { + upload: { + uuid: 'pfid', + fileAddon: 'phash', + // boolFileExist defaults to false (proto3) — forces the + // highway path where the host fields actually matter. + uploadDomain: 'upload.qpic.cn', + uploadPort: 8080, + mediaPlatformUploadKey: new Uint8Array([1, 2, 3]), + } as any, + }, + }), + ); + await groupFile.uploadPrivateFile(bridge as any, 67890, '/path/file.bin'); + expect(highwayClient.uploadHighwayHttp).toHaveBeenCalledOnce(); + }); + + it('uploadPrivateFile throws "host is invalid" only when every host field is empty', async () => { + // The diagnostic warn-log lists every host slot to help users + // report which one the server actually populated; this test just + // asserts that the throw still fires when none of them carry a + // value (boolFileExist=false + no usable host). + const bridge = mockBridge(); + vi.mocked(bridge.resolveUserUid).mockResolvedValueOnce('target-uid'); + vi.mocked(oidb.runOidb).mockResolvedValueOnce( + protobuf_encode>({ + body: { upload: { uuid: 'pfid', fileAddon: 'phash', uploadPort: 8080 } as any }, + }), + ); + await expect(groupFile.uploadPrivateFile(bridge as any, 67890, '/path/file.bin')) + .rejects.toThrow(/upload host is invalid/); + }); + it('fetchGroupFiles paginates files + folders out of OIDB items', async () => { const bridge = mockBridge(); vi.mocked(oidb.runOidb).mockResolvedValueOnce(