Skip to content

Commit 173b870

Browse files
committed
fix: enhance song file download process and CORS handling
- Updated the song download logic in SongController to proxy files directly from the backend, avoiding CORS issues. - Implemented a new method in SongService to retrieve song files, including error handling for private songs and download restrictions. - Refactored frontend download functions to align with the new backend logic, improving user experience and error management. - Added CORS configuration in docker-compose for MinIO to allow necessary headers and methods.
1 parent 3abf71e commit 173b870

File tree

5 files changed

+116
-71
lines changed

5 files changed

+116
-71
lines changed

apps/backend/src/song/song.controller.ts

Lines changed: 31 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
Delete,
77
Get,
88
Headers,
9+
HttpException,
910
HttpStatus,
1011
Logger,
1112
Param,
@@ -378,15 +379,37 @@ export class SongController {
378379
): Promise<void> {
379380
user = validateUser(user);
380381

381-
// TODO: no longer used
382-
res.set({
383-
'Content-Disposition': 'attachment; filename="song.nbs"',
384-
// Expose the Content-Disposition header to the client
385-
'Access-Control-Expose-Headers': 'Content-Disposition',
386-
});
382+
try {
383+
// Get file directly from S3/MinIO and proxy it to avoid CORS issues
384+
// This bypasses presigned URLs and CORS entirely
385+
const { buffer, filename } = await this.songService.getSongFileBuffer(
386+
id,
387+
user,
388+
src,
389+
false,
390+
);
391+
392+
// Set headers and send file
393+
res.set({
394+
'Content-Type': 'application/octet-stream',
395+
'Content-Disposition': `attachment; filename="${filename.replace(
396+
/[/"]/g,
397+
'_',
398+
)}"`,
399+
'Access-Control-Expose-Headers': 'Content-Disposition',
400+
});
387401

388-
const url = await this.songService.getSongDownloadUrl(id, user, src, false);
389-
res.redirect(HttpStatus.FOUND, url);
402+
res.send(Buffer.from(buffer));
403+
} catch (error) {
404+
this.logger.error('Error downloading song file:', error);
405+
if (error instanceof HttpException) {
406+
throw error;
407+
}
408+
throw new HttpException(
409+
'An error occurred while retrieving the song file',
410+
HttpStatus.INTERNAL_SERVER_ERROR,
411+
);
412+
}
390413
}
391414

392415
@Get('/:id/open')

apps/backend/src/song/song.service.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -386,6 +386,57 @@ export class SongService {
386386
}
387387
}
388388

389+
public async getSongFileBuffer(
390+
publicId: string,
391+
user: UserDocument | null,
392+
src?: string,
393+
packed: boolean = false,
394+
): Promise<{ buffer: ArrayBuffer; filename: string }> {
395+
const foundSong = await this.songModel.findOne({ publicId: publicId });
396+
397+
if (!foundSong) {
398+
throw new HttpException('Song not found with ID', HttpStatus.NOT_FOUND);
399+
}
400+
401+
if (foundSong.visibility !== 'public') {
402+
if (!user || foundSong.uploader.toString() !== user._id.toString()) {
403+
throw new HttpException(
404+
'This song is private',
405+
HttpStatus.UNAUTHORIZED,
406+
);
407+
}
408+
}
409+
410+
if (!packed && !foundSong.allowDownload) {
411+
throw new HttpException(
412+
'The uploader has disabled downloads of this song',
413+
HttpStatus.UNAUTHORIZED,
414+
);
415+
}
416+
417+
const fileKey = packed ? foundSong.packedSongUrl : foundSong.nbsFileUrl;
418+
const fileExt = packed ? '.zip' : '.nbs';
419+
const fileName = `${foundSong.title}${fileExt}`;
420+
421+
try {
422+
const fileBuffer = await this.fileService.getSongFile(fileKey);
423+
424+
// increment download count
425+
if (!packed && src === 'downloadButton') {
426+
foundSong.downloadCount++;
427+
await foundSong.save();
428+
}
429+
430+
return { buffer: fileBuffer, filename: fileName };
431+
} catch (e) {
432+
this.logger.error('Error getting song file', e);
433+
throw new HttpException(
434+
'An error occurred while retrieving the song file',
435+
HttpStatus.INTERNAL_SERVER_ERROR,
436+
);
437+
}
438+
}
439+
389440
public async getMySongsPage({
390441
query,
391442
user,

apps/frontend/src/modules/song-edit/components/client/context/EditSong.context.tsx

Lines changed: 12 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@ import {
2121
editSongFormSchema,
2222
} from '@web/modules/song/components/client/SongForm.zod';
2323

24+
// TODO: THIS FORM IS CURRENTLY BROKEN, WE NEED TO FIX IT
25+
2426
export type useEditSongProviderType = {
2527
formMethods: UseFormReturn<EditSongForm>;
2628
submitSong: () => void;
@@ -237,51 +239,16 @@ export const EditSongProvider = ({
237239
const token = getTokenLocal();
238240

239241
try {
240-
// The backend redirects (302) to an S3 signed URL
241-
// Get the redirect URL first, then fetch from S3 directly
242-
let s3Url: string | null = null;
243-
244-
try {
245-
// Make request without following redirects to get the S3 URL
246-
await axiosInstance.get(`/song/${id}/download`, {
247-
params: {
248-
src: 'edit',
249-
},
250-
headers: { authorization: `Bearer ${token}` },
251-
maxRedirects: 0, // Don't follow redirects
252-
validateStatus: () => false, // Don't throw on any status
253-
});
254-
} catch (redirectError: any) {
255-
// Axios throws an error when maxRedirects is 0 and a redirect occurs
256-
// Extract the Location header from the redirect response
257-
if (
258-
redirectError.response?.status >= 300 &&
259-
redirectError.response?.status < 400
260-
) {
261-
s3Url = redirectError.response.headers.location;
262-
if (!s3Url) {
263-
throw new Error('Redirect received but no Location header found');
264-
}
265-
} else {
266-
// Not a redirect error, re-throw it
267-
throw redirectError;
268-
}
269-
}
270-
271-
if (!s3Url) {
272-
throw new Error('Failed to get song download URL from redirect');
273-
}
274-
275-
// Fetch the file directly from S3 using native fetch (handles CORS better)
276-
const s3Response = await fetch(s3Url);
277-
if (!s3Response.ok) {
278-
throw new Error(
279-
`Failed to fetch song from storage: ${s3Response.status} ${s3Response.statusText}`,
280-
);
281-
}
282-
283-
const arrayBuffer = await s3Response.arrayBuffer();
284-
const songFile = arrayBuffer;
242+
// Backend now proxies the file directly, avoiding CORS issues
243+
const response = await axiosInstance.get(`/song/${id}/download`, {
244+
params: {
245+
src: 'edit',
246+
},
247+
headers: { authorization: `Bearer ${token}` },
248+
responseType: 'arraybuffer', // Get as ArrayBuffer for parsing
249+
});
250+
251+
const songFile = response.data;
285252

286253
// convert to song
287254
const song = await parseSongFromBuffer(songFile);

apps/frontend/src/modules/song/util/downloadSong.ts

Lines changed: 17 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -11,30 +11,32 @@ export const downloadSongFile = async (song: {
1111
}) => {
1212
const token = getTokenLocal();
1313

14-
axios
15-
.get(`/song/${song.publicId}/download`, {
14+
try {
15+
// Backend now proxies the file directly, avoiding CORS issues
16+
const response = await axios.get(`/song/${song.publicId}/download`, {
1617
params: {
1718
src: 'downloadButton',
1819
},
1920
headers: {
2021
authorization: `Bearer ${token}`,
2122
},
2223
responseType: 'blob',
23-
withCredentials: true,
24-
})
25-
.then((res) => {
26-
const url = window.URL.createObjectURL(res.data);
27-
const link = document.createElement('a');
28-
link.href = url;
24+
});
2925

30-
link.setAttribute('download', `${song.title}.nbs`);
31-
document.body.appendChild(link);
32-
link.click();
26+
const url = window.URL.createObjectURL(response.data);
27+
const link = document.createElement('a');
28+
link.href = url;
29+
link.setAttribute('download', `${song.title}.nbs`);
30+
document.body.appendChild(link);
31+
link.click();
3332

34-
// Clean up
35-
link.remove();
36-
window.URL.revokeObjectURL(url);
37-
});
33+
// Clean up
34+
link.remove();
35+
window.URL.revokeObjectURL(url);
36+
} catch (error) {
37+
console.error('Error downloading song:', error);
38+
toast.error('Failed to download song');
39+
}
3840
};
3941

4042
export const openSongInNBS = async (song: { publicId: string }) => {

docker-compose.yml

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -53,10 +53,12 @@ services:
5353
echo "Waiting for MinIO to be available..."
5454
sleep 2
5555
done &&
56-
mc mb minio/noteblockworld-songs &&
57-
mc mb minio/noteblockworld-thumbs &&
56+
mc mb minio/noteblockworld-songs || true &&
57+
mc mb minio/noteblockworld-thumbs || true &&
5858
mc policy set public minio/noteblockworld-songs &&
59-
mc policy set public minio/noteblockworld-thumbs
59+
mc policy set public minio/noteblockworld-thumbs &&
60+
mc admin config set minio api "cors_allow_origin=* cors_allow_methods=GET,PUT,POST,DELETE,HEAD,OPTIONS cors_allow_headers=*" || echo "CORS config may already be set" &&
61+
echo "CORS configuration applied successfully"
6062
'
6163
6264
volumes:

0 commit comments

Comments
 (0)