feat: 휴대폰 앱을 스토어 없이 사내에 나눠 주는 받기 화면 · API · 운영 도구 · 앱 서명 #22

Merged
ttipo merged 7 commits from feat/app-distribution into main 2026-11-09 09:45:52 +09:00
3 changed files with 85 additions and 16 deletions
Showing only changes of commit 1f8555340e - Show all commits
@@ -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'],
];
}