generated from ttipo/project-base
refactor(backend): 내려받기 머리글을 download-headers 한 곳으로 모은다
휴대폰 앱 받기(다음 커밋)가 대용량 첨부 받기와 같은 머리글을 쓰게 하려고, file-sharing 의 writeFileHeaders 가 만들던 머리글을 shared/utils/download-headers.ts 로 옮겼다. 머리글 이름 · 값 · 차례는 그대로다(새 단위 시험과 대용량 받기의 단위 · e2e 가 지킨다). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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'],
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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 (따옴표 포함 — `"<id>"`) — 이어 받기의 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'],
|
||||
];
|
||||
}
|
||||
Reference in New Issue
Block a user