From 9d911c8696602a4416fecbdbb2b081b2d3b05117 Mon Sep 17 00:00:00 2001 From: jmgasper Date: Tue, 11 Aug 2026 14:10:23 +1000 Subject: [PATCH] PM-5831: Fix My Submissions download redirect What was broken Downloading a submission from My Submissions failed after Review API began returning a signed storage redirect. Root cause Community App fetched the redirecting endpoint as a blob, so Firefox followed the authenticated request to S3 and triggered a rejected CORS preflight. What was changed Request the browser-safe /download-url endpoint with the member token, validate the signed URL, and start the existing browser-managed download directly from that URL. Any added/updated tests Added service coverage for the authenticated, URL-encoded download-url request, signed URL parsing, and missing URL rejection. --- __tests__/shared/services/submissions.js | 35 ++++++++++++++++++- .../containers/SubmissionManagement/index.jsx | 17 ++++----- src/shared/services/submissions.js | 31 ++++++++++++++++ 3 files changed, 74 insertions(+), 9 deletions(-) diff --git a/__tests__/shared/services/submissions.js b/__tests__/shared/services/submissions.js index 9e2060b22..bacffb1db 100644 --- a/__tests__/shared/services/submissions.js +++ b/__tests__/shared/services/submissions.js @@ -1,6 +1,9 @@ /* eslint-env jest */ import { config } from 'topcoder-react-utils'; -import { getChallengeSubmissions } from '../../../src/shared/services/submissions'; +import { + getChallengeSubmissions, + getSubmissionDownloadUrl, +} from '../../../src/shared/services/submissions'; const baseUrl = `${config.API.V6}/submissions`; @@ -35,6 +38,36 @@ describe('submissions service', () => { global.fetch = jest.fn(); }); + it('returns a signed submission URL without following the storage redirect', async () => { + global.fetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ + url: ' https://storage.example.test/signed-submission ', + }), + }); + + const result = await getSubmissionDownloadUrl('token-v3', 'submission/id'); + + expect(result).toBe('https://storage.example.test/signed-submission'); + expect(global.fetch).toHaveBeenCalledTimes(1); + expect(global.fetch).toHaveBeenCalledWith( + `${baseUrl}/submission%2Fid/download-url`, + expect.objectContaining({ method: 'GET' }), + ); + expect(global.fetch.mock.calls[0][1].headers.get('Authorization')) + .toBe('Bearer token-v3'); + }); + + it('rejects a submission download response without a signed URL', async () => { + global.fetch.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({}), + }); + + await expect(getSubmissionDownloadUrl('token-v3', 'submission-id')) + .rejects.toThrow('Submission download URL is missing'); + }); + it('loads every submissions page reported by metadata', async () => { global.fetch .mockResolvedValueOnce({ diff --git a/src/shared/containers/SubmissionManagement/index.jsx b/src/shared/containers/SubmissionManagement/index.jsx index 91fe8117e..047637e43 100644 --- a/src/shared/containers/SubmissionManagement/index.jsx +++ b/src/shared/containers/SubmissionManagement/index.jsx @@ -15,15 +15,18 @@ import { safeForDownload } from 'utils/tc'; import { connect } from 'react-redux'; import { Modal, PrimaryButton } from 'topcoder-react-ui-kit'; import { config } from 'topcoder-react-utils'; -import { actions, services } from 'topcoder-react-lib'; +import { actions } from 'topcoder-react-lib'; import getReviewSummationsService from 'services/reviewSummations'; -import { getSubmissionArtifacts, downloadSubmissions } from 'services/submissions'; +import { + downloadSubmissions, + getSubmissionArtifacts, + getSubmissionDownloadUrl, +} from 'services/submissions'; import style from './styles.scss'; import smpActions from '../../actions/page/submission_management'; -const { getService } = services.submissions; const SUMMATION_TYPE_PRIORITY = { example: 0, provisional: 1, @@ -401,12 +404,10 @@ class SubmissionManagementPageContainer extends React.Component { onShowDetails, onDelete: onSubmissionDelete, onDownload: (challengeType, submissionId) => { - const submissionsService = getService(authTokens.tokenV3); - submissionsService.downloadSubmission(submissionId) - .then((blob) => { - const url = window.URL.createObjectURL(new Blob([blob])); + getSubmissionDownloadUrl(authTokens.tokenV3, submissionId) + .then((downloadUrl) => { const link = document.createElement('a'); - link.href = url; + link.href = downloadUrl; link.setAttribute('download', `submission-${challengeType}-${submissionId}.zip`); document.body.appendChild(link); link.click(); diff --git a/src/shared/services/submissions.js b/src/shared/services/submissions.js index dfd588899..545277cb6 100644 --- a/src/shared/services/submissions.js +++ b/src/shared/services/submissions.js @@ -25,6 +25,37 @@ function getHeaders(tokenV3) { return headers; } +/** + * Requests a short-lived URL for downloading a submission directly from storage. + * + * @param {String} tokenV3 Topcoder auth token v3 used to authorize the download. + * @param {String|Number} submissionId Submission identifier used by the Review API. + * @return {Promise} Signed submission download URL. + * @throws {Error} Throws when the submission id is empty, the request fails, or no URL is returned. + */ +export async function getSubmissionDownloadUrl(tokenV3, submissionId) { + const normalizedSubmissionId = String(submissionId).trim(); + if (!normalizedSubmissionId) { + throw new Error('Submission id is required'); + } + + const response = await fetch(`${v6ApiUrl}/submissions/${encodeURIComponent(normalizedSubmissionId)}/download-url`, { + method: 'GET', + headers: getHeaders(tokenV3), + }); + + if (!response.ok) { + throw new Error(`Failed to get submission download URL: ${response.status} ${response.statusText}`); + } + + const payload = await response.json(); + if (!payload || typeof payload.url !== 'string' || !payload.url.trim()) { + throw new Error('Submission download URL is missing'); + } + + return payload.url.trim(); +} + async function fetchChallengeSubmissionsPage({ tokenV3, challengeId,