diff --git a/backend/src/modules/file-sharing/file-sharing.service.ts b/backend/src/modules/file-sharing/file-sharing.service.ts index e0af247..76fc345 100644 --- a/backend/src/modules/file-sharing/file-sharing.service.ts +++ b/backend/src/modules/file-sharing/file-sharing.service.ts @@ -21,6 +21,7 @@ import { import type { StoredFileView } from '@/modules/file-storage/file-storage.types'; import { currentRequestId, traceTag } from '@/shared/logger/request-context'; import { buildAttachmentDisposition } from '@/shared/utils/content-disposition'; +import { downloadHeaders } from '@/shared/utils/download-headers'; import { decideRange } from '@/shared/utils/http-range'; import { kstEndOfDayAfter } from '@/shared/utils/kst-date'; import { maskEmail } from '@/shared/utils/mask'; @@ -67,10 +68,8 @@ import { /** 보낸 링크 목록의 커서 범위 (shared/utils/offset-cursor.ts) */ const SHARE_LINKS_SCOPE = 'share-links'; -/** 파일 응답의 머리글 (계약 4절 "파일 응답") — 형식은 서버가 정한다(늘 내려받기) */ +/** 파일 응답의 형식 (계약 4절 "파일 응답") — 서버가 정한다(늘 내려받기). 나머지 머리글은 shared/utils/download-headers.ts */ const FILE_CONTENT_TYPE = 'application/octet-stream'; -const FILE_CSP = "default-src 'none'; sandbox"; -const FILE_CACHE_CONTROL = 'private, no-store'; /** 내려받기 — 누가 받나 */ export interface DownloadOptions { @@ -634,25 +633,24 @@ export class FileSharingService implements BeforeApplicationShutdown { }); } - /** 파일 응답의 머리글 (계약 4절 "파일 응답") — 형식 · 이름 · 검증자 · 캐시 · 버퍼링 */ + /** + * 파일 응답의 머리글 (계약 4절 "파일 응답") — 형식 · 이름 · 검증자 · 캐시 · 버퍼링. + * 휴대폰 앱 받기와 같은 꼴이다(shared/utils/download-headers.ts — 형식만 다르다) + */ private writeFileHeaders( res: Response, name: string, etag: string, lastModified: Date, ): void { - res.setHeader('Content-Type', FILE_CONTENT_TYPE); - res.setHeader('Content-Disposition', buildAttachmentDisposition(name)); - res.setHeader('X-Content-Type-Options', 'nosniff'); - res.setHeader('Content-Security-Policy', FILE_CSP); - res.setHeader('Cache-Control', FILE_CACHE_CONTROL); - res.setHeader('Referrer-Policy', 'no-referrer'); - res.setHeader('X-Robots-Tag', 'noindex'); - res.setHeader('Accept-Ranges', 'bytes'); - res.setHeader('ETag', etag); - res.setHeader('Last-Modified', lastModified.toUTCString()); - // 앞단 nginx · 외부 프록시가 이 응답을 임시 파일에 받아 두지 않게 (느린 받는 사람마다 1GB 까지) - res.setHeader('X-Accel-Buffering', 'no'); + for (const [header, value] of downloadHeaders({ + contentType: FILE_CONTENT_TYPE, + contentDisposition: buildAttachmentDisposition(name), + etag, + lastModified, + })) { + res.setHeader(header, value); + } } private writeLength( diff --git a/backend/src/shared/utils/download-headers.spec.ts b/backend/src/shared/utils/download-headers.spec.ts new file mode 100644 index 0000000..c8930a2 --- /dev/null +++ b/backend/src/shared/utils/download-headers.spec.ts @@ -0,0 +1,27 @@ +import { downloadHeaders } from './download-headers'; + +describe('downloadHeaders — 내려받기 응답의 머리글 (대용량 첨부 · 휴대폰 앱 공용)', () => { + it('형식 · 이름 · 검증자만 부르는 쪽이 정하고, 나머지는 늘 같다 (쓰는 차례 그대로)', () => { + const lastModified = new Date('2026-11-18T01:23:45.000Z'); + expect( + downloadHeaders({ + contentType: 'application/vnd.android.package-archive', + contentDisposition: 'attachment; filename="ARA-SYSTEM-1.0.0.apk"', + etag: '"3f1c0a2e-0f1b-4f2a-9a1e-6c9f0a1b2c3d"', + lastModified, + }), + ).toEqual([ + ['Content-Type', 'application/vnd.android.package-archive'], + ['Content-Disposition', 'attachment; filename="ARA-SYSTEM-1.0.0.apk"'], + ['X-Content-Type-Options', 'nosniff'], + ['Content-Security-Policy', "default-src 'none'; sandbox"], + ['Cache-Control', 'private, no-store'], + ['Referrer-Policy', 'no-referrer'], + ['X-Robots-Tag', 'noindex'], + ['Accept-Ranges', 'bytes'], + ['ETag', '"3f1c0a2e-0f1b-4f2a-9a1e-6c9f0a1b2c3d"'], + ['Last-Modified', 'Wed, 18 Nov 2026 01:23:45 GMT'], + ['X-Accel-Buffering', 'no'], + ]); + }); +}); diff --git a/backend/src/shared/utils/download-headers.ts b/backend/src/shared/utils/download-headers.ts new file mode 100644 index 0000000..009ac9b --- /dev/null +++ b/backend/src/shared/utils/download-headers.ts @@ -0,0 +1,44 @@ +/** + * 파일 내려받기 응답의 머리글 (순수 함수) — 대용량 첨부 받기(11단계 — 계약 4절 "파일 응답")와 휴대폰 앱 받기(계약 1절 "휴대폰 앱 받기")가 함께 쓴다. + * 두 받기가 같은 꼴이어야 한다 — 한쪽만 고치지 않게 여기 한 곳에 둔다. + * + * 늘 내려받기(attachment) · 형식 짐작 막기(nosniff) · 열어도 아무것도 하지 못하게(sandbox CSP) · 남기지 않음(private, no-store) · + * 나가는 요청에 주소를 싣지 않음 · 검색에 걸리지 않음 · 범위 요청을 받음 · 앞단 nginx · 외부 프록시가 임시 파일에 받아 두지 않음. + * 형식 · 이름 · 검증자(ETag · Last-Modified)만 부르는 쪽이 정한다 — 이름은 서버가 만든 값만 넣는다 + */ + +/** 파일을 열어도 스크립트 · 하위 요청이 돌지 않게 */ +export const DOWNLOAD_CSP = "default-src 'none'; sandbox"; +/** 공유 캐시 · 브라우저에 남기지 않는다 */ +export const DOWNLOAD_CACHE_CONTROL = 'private, no-store'; + +/** 내려줄 파일 */ +export interface DownloadDescriptor { + /** 형식 — 서버가 정한다(받은 값 · 파일 이름으로 짐작하지 않는다) */ + readonly contentType: string; + /** `attachment; …` — 늘 내려받기 */ + readonly contentDisposition: string; + /** 강한 ETag (따옴표 포함 — `""`) — 이어 받기의 If-Range 와 견준다 */ + readonly etag: string; + readonly lastModified: Date; +} + +/** 머리글 이름 · 값 — 쓰는 차례 그대로 (`res.setHeader` 로 하나씩 쓴다) */ +export function downloadHeaders( + file: DownloadDescriptor, +): readonly (readonly [string, string])[] { + return [ + ['Content-Type', file.contentType], + ['Content-Disposition', file.contentDisposition], + ['X-Content-Type-Options', 'nosniff'], + ['Content-Security-Policy', DOWNLOAD_CSP], + ['Cache-Control', DOWNLOAD_CACHE_CONTROL], + ['Referrer-Policy', 'no-referrer'], + ['X-Robots-Tag', 'noindex'], + ['Accept-Ranges', 'bytes'], + ['ETag', file.etag], + ['Last-Modified', file.lastModified.toUTCString()], + // 앞단 nginx · 외부 프록시가 이 응답을 임시 파일에 받아 두지 않게 (느린 받는 사람마다 파일 크기만큼) + ['X-Accel-Buffering', 'no'], + ]; +}