@@ -158,9 +158,12 @@ export async function fileUploadMultipart(
158158 core . info ( `Multipart: initiated, uploadId obtained for ${ file } ` )
159159
160160 // 2. Upload all parts in parallel (MULTIPART_CONCURRENCY at a time).
161+ // Each part: GET a presigned S3 URL from the proxy (auth + tiny payload),
162+ // then PUT the part body directly to S3 — data bypasses the nginx proxy
163+ // and the node NIC entirely.
161164 const etags : { partNumber : number ; etag : string } [ ] = [ ]
162- const partUrl = new URL (
163- path . join ( '/upload-multipart/ part/' , buildName , filePath ) ,
165+ const presignPartBaseUrl = new URL (
166+ path . join ( '/presign- upload-part/' , buildName , filePath ) ,
164167 baseUrl
165168 ) . toString ( )
166169
@@ -171,29 +174,59 @@ export async function fileUploadMultipart(
171174 core . info (
172175 `Multipart: uploading part ${ partNumber } /${ partCount } (${ Math . round ( partSize / 1e6 ) } MB) for ${ file } `
173176 )
177+
178+ // Step 2a — get presigned URL (authenticated, lightweight).
179+ const presignResp = await client . get ( presignPartBaseUrl , {
180+ params : { partNumber, uploadId} ,
181+ timeout : 30000
182+ } )
183+ const s3PartUrl = ( presignResp . data as string ) . trim ( )
184+
185+ // Step 2b — PUT part directly to S3 using raw https.request.
186+ // Axios re-encodes presigned URL query strings via the URL API, which
187+ // decodes %2B → + and re-serialises it as + (space in query strings).
188+ // This corrupts the AWS Signature V2 and causes 403 at Scaleway.
189+ // Using https.request preserves the query string exactly as returned
190+ // by the proxy.
174191 const partStream = fs . createReadStream ( file , { start, end} )
175- let resp
176- try {
177- resp = await client . put ( partUrl , partStream , {
178- params : { partNumber, uploadId} ,
179- headers : { 'Content-Length' : partSize . toString ( ) } ,
180- maxBodyLength : Infinity ,
181- maxContentLength : Infinity ,
182- maxRedirects : 0 ,
183- timeout : 600000
184- } )
185- } catch ( e : unknown ) {
186- if ( axios . isAxiosError ( e ) && e . response ) {
187- core . error (
188- `Multipart: part ${ partNumber } /${ partCount } failed with status ${ e . response . status } : ${ JSON . stringify ( e . response . data ) } `
189- )
190- }
191- throw e
192- }
193- const etag = resp . headers [ 'etag' ] as string
194- if ( ! etag ) {
195- throw new Error ( `No ETag returned for part ${ partNumber } of ${ file } ` )
196- }
192+ const s3Url = new URL ( s3PartUrl )
193+ const etag = await new Promise < string > ( ( resolve , reject ) => {
194+ const req = https . request (
195+ {
196+ method : 'PUT' ,
197+ hostname : s3Url . hostname ,
198+ port : s3Url . port ? parseInt ( s3Url . port ) : 443 ,
199+ path : s3Url . pathname + s3Url . search ,
200+ headers : { 'Content-Length' : String ( partSize ) }
201+ } ,
202+ res => {
203+ let body = ''
204+ res . on ( 'data' , ( chunk : Buffer ) => {
205+ body += chunk . toString ( )
206+ } )
207+ res . on ( 'end' , ( ) => {
208+ if ( res . statusCode === 200 ) {
209+ const tag = res . headers [ 'etag' ] as string
210+ if ( ! tag ) {
211+ reject (
212+ new Error ( `No ETag returned for part ${ partNumber } of ${ file } ` )
213+ )
214+ } else {
215+ resolve ( tag )
216+ }
217+ } else {
218+ reject (
219+ new Error (
220+ `Multipart: part ${ partNumber } /${ partCount } failed with status ${ res . statusCode } : ${ body } `
221+ )
222+ )
223+ }
224+ } )
225+ }
226+ )
227+ req . on ( 'error' , reject )
228+ partStream . pipe ( req )
229+ } )
197230 etags . push ( { partNumber, etag} )
198231 core . info ( `Multipart: part ${ partNumber } /${ partCount } done for ${ file } ` )
199232 }
0 commit comments