generated from ttipo/project-base
feat(backend): 임시 저장·서명(그림)·받는 사람 추천 API와 MAIL_008
- /api/drafts: 발송과 같은 준비(정화·한도)로 초안을 만들고, 바꿔 저장은 새로 만들어 다시 읽은 뒤 옛것을 지운다. 만들기와 지우기를 한 요청에 넣지 않는다 — 엔진이 만들기가 실패해도 지우기를 해서 저장본이 사라졌다. 옛 초안이 이미 없으면 409 MAIL_008(새 코드), 초안이 아닌 id 는 404 RES_001 로 아무것도 지우지 않는다. - 사용자 메일의 영구 삭제(교체한 옛 초안 · 보낸 뒤 draftId · 휴지통 영구 삭제)는 대상인지 확인한 상태 그대로일 때만 ifInState 로 지운다 — 확인과 지우기 사이에 다른 탭이 옮긴 메일까지 지우지 않게 (Stalwart 0.16.27 에서 확인) - /api/mail-signature: 서명(DB)과 그림(업로드 볼륨). 그림은 확장자·MIME·매직 바이트·크기·가로세로를 보고, 보낼 때 multipart/related 의 cid 그림으로 싣는다. 서명 저장은 사용자별로 잠가 차례로 한다 - /api/recipient-suggestions: 로그인한 직원 기록(mail_directory)과 내 메일함의 최근 주소로 추천, 사용자별 10분 캐시 - 본문 그림은 첨부 표시에서 뺀다(hasAttachment 직접 계산 · isInline), 예외 필터 로그의 검색어(q)는 길이만 남긴다 - 마이그레이션 AddMailSignaturesAndDirectory: 새 표 3개만 만든다 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -12,6 +12,7 @@ import { DataSource } from 'typeorm';
|
||||
|
||||
import { AppModule } from '@/app.module';
|
||||
import { configureApp } from '@/app.setup';
|
||||
import { BusinessException } from '@/common/errors/business.exception';
|
||||
import { AUTH_SETTINGS, type AuthSettings } from '@/modules/auth/auth.settings';
|
||||
import {
|
||||
MAX_BODY_HTML_LENGTH,
|
||||
@@ -20,7 +21,22 @@ import {
|
||||
} from '@/modules/mail/mail.constants';
|
||||
import { MAIL_PROVIDER } from '@/modules/mail/mail.provider';
|
||||
import { MAIL_SETTINGS, type MailSettings } from '@/modules/mail/mail.settings';
|
||||
import type {
|
||||
Correspondence,
|
||||
DraftContent,
|
||||
DraftSource,
|
||||
OutgoingMessage,
|
||||
SavedDraft,
|
||||
} from '@/modules/mail/mail.types';
|
||||
import {
|
||||
SIGNATURE_IMAGES_PER_SIGNATURE,
|
||||
SIGNATURE_IMAGES_STORED_MAX,
|
||||
SIGNATURE_IMAGE_DIR,
|
||||
SIGNATURE_IMAGE_MAX_BYTES,
|
||||
} from '@/modules/mail-signature/mail-signature.constants';
|
||||
import { MailSignatureRepository } from '@/modules/mail-signature/mail-signature.repository';
|
||||
import { UPLOAD_DIR } from '@/modules/upload/upload.config';
|
||||
import { pngBytes } from './image-fixtures';
|
||||
|
||||
/**
|
||||
* 세 플랫폼이 공유하는 응답 계약을 실제 HTTP 요청으로 검증한다.
|
||||
@@ -55,6 +71,9 @@ interface ContractBody {
|
||||
|
||||
const body = (res: Response): ContractBody => res.body as ContractBody;
|
||||
|
||||
/** 성공 응답의 data — 계약 형태를 테스트가 정해 읽는다 */
|
||||
const dataOf = <T>(res: Response): T => (res.body as { data: T }).data;
|
||||
|
||||
/** Set-Cookie 헤더 중 이름이 같은 것 */
|
||||
function setCookie(res: Response, name: string): string | undefined {
|
||||
const raw = res.headers['set-cookie'] as unknown;
|
||||
@@ -78,6 +97,15 @@ const ADMIN_ACCOUNT = 'admin@e2e.arasystem.test';
|
||||
/** 대역 메일 엔진이 테스트가 문을 열 때까지 답하지 않는 주소 — 동시 로그인 시도 확인용 ("로그인 실패 한도") */
|
||||
const SLOW_ACCOUNT = 'slow.target@e2e.arasystem.test';
|
||||
|
||||
/** 대역 메일 엔진이 "다른 창·기기에서 먼저 바뀌었다"고 답하는 임시 저장본 id (409 MAIL_008 경로) */
|
||||
const DRAFT_CHANGED_ELSEWHERE = 'draftchangedelsewhere';
|
||||
|
||||
/** 대역 메일 엔진이 로그인 때 알려 주는 이름 — 없으면 EMPLOYEE.name */
|
||||
const PROFILE_NAMES = new Map([
|
||||
['colleague.a@e2e.arasystem.test', '가나다'],
|
||||
['colleague.b@e2e.arasystem.test', '박서준'],
|
||||
]);
|
||||
|
||||
/** 문을 열 조건을 다시 보는 간격 */
|
||||
const GATE_POLL_MS = 10;
|
||||
/** 문을 열 조건을 기다리는 최대 시간 — 회귀가 생기면 멈춰 있지 않고 이 시간 뒤에 분명히 실패한다 */
|
||||
@@ -150,6 +178,18 @@ describe('응답 계약 (e2e)', () => {
|
||||
* 열기 전에 온 확인은 모두 같은 문을 기다린다 (회귀로 여러 번 와도 한 번 열면 모두 풀린다).
|
||||
*/
|
||||
let slowEngineGate: Promise<void> | null = null;
|
||||
/** 대역 엔진의 임시 저장본 (id → 주인·내용) — 계약 2절 "임시 저장" */
|
||||
const drafts = new Map<
|
||||
string,
|
||||
{ user: string; content: DraftContent; updatedAt: string }
|
||||
>();
|
||||
let draftSequence = 0;
|
||||
/** 대역 엔진이 지운 임시 저장본 id (보낸 뒤 지우기) */
|
||||
const deletedDrafts: string[] = [];
|
||||
/** 대역 엔진이 받은 발송 */
|
||||
const sentMessages: { user: string; message: OutgoingMessage }[] = [];
|
||||
/** 대역 엔진이 돌려줄 주고받은 주소 (직원별) */
|
||||
const correspondents = new Map<string, Correspondence[]>();
|
||||
|
||||
/** 이 앱의 서명 키로 토큰을 만든다 (CI 처럼 JWT_SECRET 이 없으면 임시 키) */
|
||||
const sign = (payload: object, expiresIn: number) =>
|
||||
@@ -188,13 +228,79 @@ describe('응답 계약 (e2e)', () => {
|
||||
.useValue({
|
||||
// 리프레시는 계정이 살아 있는지 메일 엔진에 다시 묻는다
|
||||
getProfile: (email: string) =>
|
||||
Promise.resolve({ email, name: EMPLOYEE.name }),
|
||||
Promise.resolve({
|
||||
email,
|
||||
name: PROFILE_NAMES.get(email) ?? EMPLOYEE.name,
|
||||
}),
|
||||
verifyCredentials: async (email: string, password: string) => {
|
||||
engineChecks.set(email, (engineChecks.get(email) ?? 0) + 1);
|
||||
// 테스트가 문을 열 때까지 답하지 않는다 — 그동안 같은 주소의 다른 시도가 어떻게 되는지 본다
|
||||
if (email === SLOW_ACCOUNT && slowEngineGate) await slowEngineGate;
|
||||
return password === CORRECT_PASSWORD ? email : null;
|
||||
},
|
||||
// 발송은 받은 것을 기록만 한다 — 중복 방지는 "처음 보내는 키"로 본다
|
||||
findSentMessage: () => Promise.resolve(null),
|
||||
sendMessage: (user: string, message: OutgoingMessage) => {
|
||||
sentMessages.push({ user, message });
|
||||
return Promise.resolve({ id: `sent${sentMessages.length}` });
|
||||
},
|
||||
// 임시 저장 — 엔진 쪽 판단(초안이 아님 RES_001 · 이미 바뀜 MAIL_008)만 흉내 낸다 (실제 규칙은 jmap-mail.provider.spec.ts)
|
||||
saveDraft: (
|
||||
user: string,
|
||||
content: DraftContent,
|
||||
replaceId?: string,
|
||||
): Promise<SavedDraft> => {
|
||||
if (replaceId === DRAFT_CHANGED_ELSEWHERE) {
|
||||
return Promise.reject(
|
||||
new BusinessException('MAIL_008', {
|
||||
context: { reason: 'e2e-draft-replaced-elsewhere' },
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (replaceId !== undefined && drafts.get(replaceId)?.user !== user) {
|
||||
return Promise.reject(
|
||||
new BusinessException('RES_001', {
|
||||
context: { reason: 'e2e-not-a-draft' },
|
||||
}),
|
||||
);
|
||||
}
|
||||
draftSequence += 1;
|
||||
const id = `draft${draftSequence}`;
|
||||
const updatedAt = new Date().toISOString();
|
||||
drafts.set(id, { user, content, updatedAt });
|
||||
if (replaceId !== undefined) drafts.delete(replaceId);
|
||||
return Promise.resolve({ id, attachments: [], updatedAt });
|
||||
},
|
||||
getDraft: (user: string, id: string): Promise<DraftSource> => {
|
||||
const draft = drafts.get(id);
|
||||
if (!draft || draft.user !== user) {
|
||||
return Promise.reject(
|
||||
new BusinessException('RES_001', {
|
||||
context: { reason: 'e2e-not-a-draft' },
|
||||
}),
|
||||
);
|
||||
}
|
||||
const { content, updatedAt } = draft;
|
||||
return Promise.resolve({
|
||||
id,
|
||||
to: content.to,
|
||||
cc: content.cc,
|
||||
bcc: content.bcc,
|
||||
subject: content.subject,
|
||||
textBody: content.body,
|
||||
rawHtmlBody: content.htmlBody ?? null,
|
||||
attachments: [],
|
||||
inReplyTo: null,
|
||||
updatedAt,
|
||||
});
|
||||
},
|
||||
deleteDraft: (user: string, id: string) => {
|
||||
deletedDrafts.push(id);
|
||||
if (drafts.get(id)?.user === user) drafts.delete(id);
|
||||
return Promise.resolve();
|
||||
},
|
||||
listCorrespondents: (user: string) =>
|
||||
Promise.resolve(correspondents.get(user) ?? []),
|
||||
})
|
||||
.overrideProvider(MAIL_SETTINGS)
|
||||
.useValue(TEST_MAIL_SETTINGS)
|
||||
@@ -222,6 +328,22 @@ describe('응답 계약 (e2e)', () => {
|
||||
'DELETE FROM auth_sessions WHERE user_email LIKE $1',
|
||||
[TEST_DOMAIN_PATTERN],
|
||||
);
|
||||
// 서명 그림은 파일도 지운다 (개발 업로드 볼륨에 남기지 않는다)
|
||||
const images: { file_name: string }[] = await dataSource.query(
|
||||
'SELECT file_name FROM mail_signature_images WHERE user_email LIKE $1',
|
||||
[TEST_DOMAIN_PATTERN],
|
||||
);
|
||||
for (const { file_name: fileName } of images) {
|
||||
const stored = join(SIGNATURE_IMAGE_DIR, basename(fileName));
|
||||
if (existsSync(stored)) unlinkSync(stored);
|
||||
}
|
||||
for (const statement of [
|
||||
'DELETE FROM mail_signature_images WHERE user_email LIKE $1',
|
||||
'DELETE FROM mail_signatures WHERE user_email LIKE $1',
|
||||
'DELETE FROM mail_directory WHERE email LIKE $1',
|
||||
]) {
|
||||
await dataSource.query(statement, [TEST_DOMAIN_PATTERN]);
|
||||
}
|
||||
await app.close();
|
||||
});
|
||||
|
||||
@@ -544,6 +666,17 @@ describe('응답 계약 (e2e)', () => {
|
||||
['get', '/api/messages/eaaaaab'],
|
||||
['post', '/api/messages'],
|
||||
['post', '/api/mail-attachments'],
|
||||
['get', '/api/drafts/eaaaaab'],
|
||||
['post', '/api/drafts'],
|
||||
['put', '/api/drafts/eaaaaab'],
|
||||
['get', '/api/mail-signature'],
|
||||
['put', '/api/mail-signature'],
|
||||
['post', '/api/mail-signature/images'],
|
||||
[
|
||||
'get',
|
||||
'/api/mail-signature/images/3f1c0a2e-0f1b-4f2a-9a1e-6c9f0a1b2c3d',
|
||||
],
|
||||
['get', '/api/recipient-suggestions'],
|
||||
// 템플릿 업로드 예제도 로그인한 직원만 쓴다
|
||||
['post', '/api/files'],
|
||||
] as const)('쿠키 없이 %s %s 는 401 AUTH_001', async (method, path) => {
|
||||
@@ -594,7 +727,8 @@ describe('응답 계약 (e2e)', () => {
|
||||
{ sub: EMPLOYEE.email, typ: 'access', name: EMPLOYEE.name },
|
||||
600,
|
||||
);
|
||||
// 대역 엔진에는 발송이 없다 — 엔진에 닿으면 500 이 된다 (한도는 엔진에 묻기 전에 본다)
|
||||
// 한도는 엔진에 묻기 전에 본다 — 대역 엔진이 받은 발송이 늘지 않아야 한다
|
||||
const sentBefore = sentMessages.length;
|
||||
const send = (overrides: object) =>
|
||||
request(server)
|
||||
.post('/api/messages')
|
||||
@@ -620,6 +754,644 @@ describe('응답 계약 (e2e)', () => {
|
||||
subject: '가'.repeat(MAX_SUBJECT_LENGTH + 1),
|
||||
}).expect(400);
|
||||
expect(body(longSubject).error?.code).toBe('VAL_001');
|
||||
expect(sentMessages).toHaveLength(sentBefore);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* 2단계 — 임시 저장 · 서명 · 받는 사람 추천 (계약 2절).
|
||||
* 엔진 쪽 판단(초안인지·옛것이 그사이 없어졌는지)은 대역 엔진이 흉내 낸다 — 여기서는 HTTP 형식·정화·DB·파일을 본다.
|
||||
*/
|
||||
describe('2단계 — 임시 저장 · 서명 · 받는 사람 추천', () => {
|
||||
/** 그 직원의 액세스 쿠키 (세션 없이 서명만 맞춘다 — 보호 라우트는 액세스 토큰만 본다) */
|
||||
const cookieOf = (email: string) =>
|
||||
`access_token=${sign({ sub: email, typ: 'access', name: '테스트 직원' }, 600)}`;
|
||||
const UUID =
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
||||
const DOYUN = { name: '이도윤', email: 'doyun.lee@e2e.arasystem.test' };
|
||||
|
||||
describe('임시 저장 (/api/drafts)', () => {
|
||||
const save = (method: 'post' | 'put', path: string, payload: object) =>
|
||||
request(server)
|
||||
[method](path)
|
||||
.set('Cookie', cookieOf(EMPLOYEE.email))
|
||||
.send({ to: [], subject: '쓰는 중', body: '본문', ...payload });
|
||||
|
||||
it('받는 사람 없이도 처음 저장(POST)은 201 — 서식 본문은 보내는 정화를 거쳐 엔진에 넘어간다', async () => {
|
||||
const res = await save('post', '/api/drafts', {
|
||||
bodyHtml: '<p onclick="x()">쓰는 중<script>alert(1)</script></p>',
|
||||
}).expect(201);
|
||||
|
||||
const saved = dataOf<SavedDraft>(res);
|
||||
expect(saved).toEqual({
|
||||
id: expect.stringMatching(/^draft\d+$/) as string,
|
||||
attachments: [],
|
||||
updatedAt: expect.any(String) as string,
|
||||
});
|
||||
expect(drafts.get(saved.id)?.content).toMatchObject({
|
||||
to: [],
|
||||
htmlBody: '<p>쓰는 중</p>',
|
||||
});
|
||||
});
|
||||
|
||||
it('바꿔 저장(PUT)은 새 id 를 돌려주고, 이어 쓰기(GET)는 계약 모양이다', async () => {
|
||||
const first = dataOf<SavedDraft>(
|
||||
await save('post', '/api/drafts', {}).expect(201),
|
||||
);
|
||||
const second = dataOf<SavedDraft>(
|
||||
await save('put', `/api/drafts/${first.id}`, {
|
||||
to: [DOYUN],
|
||||
subject: '고친 제목',
|
||||
bodyHtml:
|
||||
'<p><strong>굵게</strong><img src="https://x.test/p.gif"></p>',
|
||||
}).expect(200),
|
||||
);
|
||||
expect(second.id).not.toBe(first.id);
|
||||
expect(drafts.has(first.id)).toBe(false);
|
||||
|
||||
const res = await request(server)
|
||||
.get(`/api/drafts/${second.id}`)
|
||||
.set('Cookie', cookieOf(EMPLOYEE.email))
|
||||
.expect(200);
|
||||
expect(dataOf<unknown>(res)).toEqual({
|
||||
id: second.id,
|
||||
to: [DOYUN],
|
||||
cc: [],
|
||||
bcc: [],
|
||||
subject: '고친 제목',
|
||||
body: '본문',
|
||||
bodyHtml: '<p><strong>굵게</strong></p>',
|
||||
attachments: [],
|
||||
inReplyTo: null,
|
||||
updatedAt: second.updatedAt,
|
||||
});
|
||||
});
|
||||
|
||||
it('옛 저장본이 다른 곳에서 먼저 바뀌었으면 409 MAIL_008 · 초안이 아닌 id 는 404 RES_001 · 형식이 틀린 id 도 404', async () => {
|
||||
const changed = await save(
|
||||
'put',
|
||||
`/api/drafts/${DRAFT_CHANGED_ELSEWHERE}`,
|
||||
{},
|
||||
).expect(409);
|
||||
expect(body(changed).error?.code).toBe('MAIL_008');
|
||||
expect(body(changed).error?.traceId).toBe(
|
||||
changed.headers['x-request-id'],
|
||||
);
|
||||
|
||||
for (const path of ['/api/drafts/eaaaaab', '/api/drafts/bad!id']) {
|
||||
const res = await save('put', path, {}).expect(404);
|
||||
expect(body(res).error?.code).toBe('RES_001');
|
||||
}
|
||||
});
|
||||
|
||||
it('다른 직원의 저장본은 없는 것과 같다 (404 RES_001)', async () => {
|
||||
const mine = dataOf<SavedDraft>(
|
||||
await save('post', '/api/drafts', {}).expect(201),
|
||||
);
|
||||
|
||||
const res = await request(server)
|
||||
.get(`/api/drafts/${mine.id}`)
|
||||
.set('Cookie', cookieOf('other.person@e2e.arasystem.test'))
|
||||
.expect(404);
|
||||
expect(body(res).error?.code).toBe('RES_001');
|
||||
});
|
||||
|
||||
it('저장 본문은 발송 본문과 같은 한도 — 본문 MAIL_007 · 받는 사람 합계 MAIL_002 · draftId 같은 모르는 필드 VAL_001', async () => {
|
||||
const recipients = (count: number) =>
|
||||
Array.from({ length: count }, (_, index) => ({
|
||||
name: null,
|
||||
email: `r${index}@e2e.arasystem.test`,
|
||||
}));
|
||||
const cases: [object, number, string][] = [
|
||||
[{ body: '😀'.repeat(MAX_BODY_LENGTH / 2 + 1) }, 400, 'MAIL_007'],
|
||||
[{ to: recipients(60), cc: recipients(41) }, 400, 'MAIL_002'],
|
||||
[{ draftId: 'eaaaaab' }, 400, 'VAL_001'],
|
||||
];
|
||||
|
||||
const before = drafts.size;
|
||||
for (const [payload, status, code] of cases) {
|
||||
const res = await save('post', '/api/drafts', payload).expect(status);
|
||||
expect(body(res).error?.code).toBe(code);
|
||||
}
|
||||
expect(drafts.size).toBe(before);
|
||||
});
|
||||
|
||||
it('저장(POST·PUT /api/drafts)도 JSON 본문 4MB 까지 받는다 — 2MB 는 인증 검사에 닿고, 4MB 를 넘으면 413 FILE_002', async () => {
|
||||
for (const [method, path] of [
|
||||
['post', '/api/drafts'],
|
||||
['put', '/api/drafts/eaaaaab'],
|
||||
] as const) {
|
||||
const parsed = await request(server)
|
||||
[method](path)
|
||||
.set('Content-Type', 'application/json')
|
||||
.send(JSON.stringify({ body: '', bodyHtml: 'x'.repeat(2_000_000) }))
|
||||
.expect(401);
|
||||
expect(body(parsed).error?.code).toBe('AUTH_001');
|
||||
|
||||
const tooLarge = await request(server)
|
||||
[method](path)
|
||||
.set('Content-Type', 'application/json')
|
||||
.send(JSON.stringify({ body: '', bodyHtml: 'x'.repeat(4_300_000) }))
|
||||
.expect(413);
|
||||
expect(body(tooLarge).error?.code).toBe('FILE_002');
|
||||
}
|
||||
});
|
||||
|
||||
it('이어 쓴 저장본(draftId)을 보내면 보낸 뒤 지운다 — 형식이 틀린 draftId 는 400 VAL_001 (보내지 않는다)', async () => {
|
||||
const draft = dataOf<SavedDraft>(
|
||||
await save('post', '/api/drafts', { to: [DOYUN] }).expect(201),
|
||||
);
|
||||
const send = (draftId: string) =>
|
||||
request(server)
|
||||
.post('/api/messages')
|
||||
.set('Cookie', cookieOf(EMPLOYEE.email))
|
||||
.send({ to: [DOYUN], subject: '제목', body: '본문', draftId });
|
||||
|
||||
const sentBefore = sentMessages.length;
|
||||
const malformed = await send('../draft1').expect(400);
|
||||
expect(body(malformed).error?.code).toBe('VAL_001');
|
||||
expect(sentMessages).toHaveLength(sentBefore);
|
||||
|
||||
await send(draft.id).expect(201);
|
||||
expect(sentMessages).toHaveLength(sentBefore + 1);
|
||||
// 발송 내용에는 draftId 가 없다 (지문에 넣지 않는다)
|
||||
expect(sentMessages[sentBefore].message).not.toHaveProperty('draftId');
|
||||
expect(deletedDrafts).toContain(draft.id);
|
||||
expect(drafts.has(draft.id)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('서명 (/api/mail-signature)', () => {
|
||||
const imageUrl = (id: string) => `/api/mail-signature/images/${id}`;
|
||||
const uploadAs = (
|
||||
email: string,
|
||||
data: Buffer,
|
||||
filename = 'logo.png',
|
||||
contentType = 'image/png',
|
||||
) =>
|
||||
request(server)
|
||||
.post('/api/mail-signature/images')
|
||||
.set('Cookie', cookieOf(email))
|
||||
.attach('file', data, { filename, contentType });
|
||||
const uploaded = async (email: string, data = pngBytes(480, 120)) =>
|
||||
dataOf<{ id: string }>(await uploadAs(email, data).expect(201)).id;
|
||||
const saveSignature = (email: string, payload: object) =>
|
||||
request(server)
|
||||
.put('/api/mail-signature')
|
||||
.set('Cookie', cookieOf(email))
|
||||
.send({ isForNew: true, isForReply: true, ...payload });
|
||||
|
||||
it('저장한 적 없으면 기본값', async () => {
|
||||
const res = await request(server)
|
||||
.get('/api/mail-signature')
|
||||
.set('Cookie', cookieOf('sign.default@e2e.arasystem.test'))
|
||||
.expect(200);
|
||||
|
||||
expect(dataOf<unknown>(res)).toEqual({
|
||||
bodyHtml: null,
|
||||
body: null,
|
||||
isForNew: true,
|
||||
isForReply: true,
|
||||
images: [],
|
||||
updatedAt: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('그림을 올리면 파일 내용으로 확인한 형식·크기를 돌려주고, 그 그림은 주인만 받는다 (nosniff · inline · private 캐시)', async () => {
|
||||
const owner = 'sign.owner@e2e.arasystem.test';
|
||||
const data = pngBytes(480, 120, 2_048);
|
||||
|
||||
const image = dataOf<Record<string, unknown>>(
|
||||
await uploadAs(owner, data, '회사 로고.png').expect(201),
|
||||
);
|
||||
expect(image).toEqual({
|
||||
id: expect.stringMatching(UUID) as string,
|
||||
type: 'image/png',
|
||||
size: 2_048,
|
||||
width: 480,
|
||||
height: 120,
|
||||
});
|
||||
const id = String(image.id);
|
||||
|
||||
const res = await request(server)
|
||||
.get(imageUrl(id))
|
||||
.set('Cookie', cookieOf(owner))
|
||||
.expect(200);
|
||||
expect(res.headers['content-type']).toBe('image/png');
|
||||
expect(res.headers['x-content-type-options']).toBe('nosniff');
|
||||
expect(res.headers['cache-control']).toBe('private, max-age=86400');
|
||||
expect(res.headers['content-disposition']).toBe(
|
||||
'inline; filename="signature.png"',
|
||||
);
|
||||
expect(res.body).toEqual(data);
|
||||
|
||||
for (const [cookie, path] of [
|
||||
[cookieOf('other.person@e2e.arasystem.test'), imageUrl(id)],
|
||||
[cookieOf(owner), imageUrl('not-a-uuid')],
|
||||
[cookieOf(owner), imageUrl('11111111-1111-4111-8111-111111111111')],
|
||||
]) {
|
||||
const denied = await request(server)
|
||||
.get(path)
|
||||
.set('Cookie', cookie)
|
||||
.expect(404);
|
||||
expect(body(denied).error?.code).toBe('RES_001');
|
||||
}
|
||||
});
|
||||
|
||||
it('그림이 아니거나 형식이 다르면 400 FILE_001 (webp·svg 는 받지 않는다) · 300KB 를 넘으면 413 FILE_002 · 파일이 없으면 400 VAL_001', async () => {
|
||||
const user = 'sign.reject@e2e.arasystem.test';
|
||||
for (const [data, filename, contentType] of [
|
||||
// 이름·MIME 은 PNG 인데 내용은 HTML
|
||||
[
|
||||
Buffer.from('<html><script>alert(1)</script></html>'),
|
||||
'logo.png',
|
||||
'image/png',
|
||||
],
|
||||
// 이름과 MIME 이 다르다
|
||||
[pngBytes(10, 10), 'logo.png', 'image/gif'],
|
||||
[pngBytes(10, 10), 'logo.webp', 'image/webp'],
|
||||
[Buffer.from('<svg onload="x()"/>'), 'logo.svg', 'image/svg+xml'],
|
||||
] as const) {
|
||||
const res = await uploadAs(user, data, filename, contentType).expect(
|
||||
400,
|
||||
);
|
||||
expect(body(res).error?.code).toBe('FILE_001');
|
||||
}
|
||||
|
||||
const tooLarge = await uploadAs(
|
||||
user,
|
||||
pngBytes(10, 10, SIGNATURE_IMAGE_MAX_BYTES + 1),
|
||||
).expect(413);
|
||||
expect(body(tooLarge).error?.code).toBe('FILE_002');
|
||||
|
||||
const missing = await request(server)
|
||||
.post('/api/mail-signature/images')
|
||||
.set('Cookie', cookieOf(user))
|
||||
.expect(400);
|
||||
expect(body(missing).error?.code).toBe('VAL_001');
|
||||
|
||||
// 하나도 저장되지 않았다
|
||||
const rows: { id: string }[] = await dataSource.query(
|
||||
'SELECT id FROM mail_signature_images WHERE user_email = $1',
|
||||
[user],
|
||||
);
|
||||
expect(rows).toEqual([]);
|
||||
});
|
||||
|
||||
it(`둘 수 있는 그림은 ${SIGNATURE_IMAGES_STORED_MAX}장까지(넘으면 400 VAL_001) · 서명 하나에는 서로 다른 그림 ${SIGNATURE_IMAGES_PER_SIGNATURE}장까지(넘으면 400 VAL_001)`, async () => {
|
||||
const user = 'sign.many@e2e.arasystem.test';
|
||||
const ids: string[] = [];
|
||||
for (let index = 0; index < SIGNATURE_IMAGES_STORED_MAX; index += 1) {
|
||||
ids.push(await uploaded(user));
|
||||
}
|
||||
|
||||
const overflow = await uploadAs(user, pngBytes(10, 10)).expect(400);
|
||||
expect(body(overflow).error?.code).toBe('VAL_001');
|
||||
|
||||
const img = (id: string) => `<img src="${imageUrl(id)}" width="96">`;
|
||||
const tooMany = await saveSignature(user, {
|
||||
bodyHtml: ids
|
||||
.slice(0, SIGNATURE_IMAGES_PER_SIGNATURE + 1)
|
||||
.map(img)
|
||||
.join(''),
|
||||
}).expect(400);
|
||||
expect(body(tooMany).error?.code).toBe('VAL_001');
|
||||
|
||||
const fits = await saveSignature(user, {
|
||||
bodyHtml: ids
|
||||
.slice(0, SIGNATURE_IMAGES_PER_SIGNATURE)
|
||||
.map(img)
|
||||
.join(''),
|
||||
}).expect(200);
|
||||
expect(
|
||||
dataOf<{ images: { id: string }[] }>(fits).images.map(
|
||||
(image) => image.id,
|
||||
),
|
||||
).toEqual(ids.slice(0, SIGNATURE_IMAGES_PER_SIGNATURE));
|
||||
});
|
||||
|
||||
it('저장하면 정화해 두고, 서명이 더는 가리키지 않는 그림은 지운다 — 막 올리고 아직 넣지 않은 그림은 남긴다', async () => {
|
||||
const user = 'sign.save@e2e.arasystem.test';
|
||||
const [a, b] = [await uploaded(user), await uploaded(user)];
|
||||
|
||||
const first = await saveSignature(user, {
|
||||
bodyHtml: `<p onclick="x()">김아라</p><img src="${imageUrl(a)}" width="144" alt=""><img src="${imageUrl(b)}" width="96"><img src="${imageUrl('11111111-1111-4111-8111-111111111111')}">`,
|
||||
body: '김아라 · 아라시스템',
|
||||
isForReply: false,
|
||||
}).expect(200);
|
||||
expect(dataOf<Record<string, unknown>>(first)).toMatchObject({
|
||||
bodyHtml: `<p>김아라</p><img src="${imageUrl(a)}" width="144" alt="" /><img src="${imageUrl(b)}" width="96" />`,
|
||||
body: '김아라 · 아라시스템',
|
||||
isForNew: true,
|
||||
isForReply: false,
|
||||
images: [
|
||||
expect.objectContaining({ id: a }) as unknown,
|
||||
expect.objectContaining({ id: b }) as unknown,
|
||||
],
|
||||
updatedAt: expect.any(String) as string,
|
||||
});
|
||||
|
||||
// b 를 빼고 저장한다 — 그사이 올린 c 는 아직 서명에 넣지 않았다
|
||||
const c = await uploaded(user);
|
||||
const second = await saveSignature(user, {
|
||||
bodyHtml: `<img src="${imageUrl(a)}" width="144">`,
|
||||
body: null,
|
||||
}).expect(200);
|
||||
expect(
|
||||
dataOf<{ images: { id: string }[] }>(second).images.map(
|
||||
(image) => image.id,
|
||||
),
|
||||
).toEqual([a]);
|
||||
|
||||
const statusOf = async (id: string) =>
|
||||
(
|
||||
await request(server)
|
||||
.get(imageUrl(id))
|
||||
.set('Cookie', cookieOf(user))
|
||||
).status;
|
||||
expect(await statusOf(a)).toBe(200);
|
||||
expect(await statusOf(b)).toBe(404);
|
||||
expect(await statusOf(c)).toBe(200);
|
||||
// 지운 그림은 파일도 없다
|
||||
expect(existsSync(join(SIGNATURE_IMAGE_DIR, `${b}.png`))).toBe(false);
|
||||
|
||||
const current = await request(server)
|
||||
.get('/api/mail-signature')
|
||||
.set('Cookie', cookieOf(user))
|
||||
.expect(200);
|
||||
expect(dataOf<unknown>(current)).toEqual(dataOf<unknown>(second));
|
||||
});
|
||||
|
||||
it('한도를 넘으면 400 VAL_001 — 텍스트 2,000자 · 정화한 서식 10,000자', async () => {
|
||||
const user = 'sign.limits@e2e.arasystem.test';
|
||||
for (const payload of [
|
||||
{ body: '가'.repeat(2_001) },
|
||||
{ bodyHtml: `<p>${'가'.repeat(10_000)}</p>` },
|
||||
]) {
|
||||
const res = await saveSignature(user, payload).expect(400);
|
||||
expect(body(res).error?.code).toBe('VAL_001');
|
||||
}
|
||||
});
|
||||
|
||||
it(`보내거나 임시 저장하는 본문의 서명 그림도 서로 다른 그림 ${SIGNATURE_IMAGES_PER_SIGNATURE}장까지 — 넘으면 400 VAL_001, 메일은 나가지 않는다`, async () => {
|
||||
const user = 'sign.body@e2e.arasystem.test';
|
||||
const ids: string[] = [];
|
||||
for (
|
||||
let index = 0;
|
||||
index < SIGNATURE_IMAGES_PER_SIGNATURE + 1;
|
||||
index += 1
|
||||
) {
|
||||
ids.push(await uploaded(user, pngBytes(40, 10)));
|
||||
}
|
||||
const bodyHtml = `<p>본문</p>${ids.map((id) => `<img src="${imageUrl(id)}" width="96">`).join('')}`;
|
||||
const sentBefore = sentMessages.length;
|
||||
const draftsBefore = drafts.size;
|
||||
|
||||
const send = await request(server)
|
||||
.post('/api/messages')
|
||||
.set('Cookie', cookieOf(user))
|
||||
.send({ to: [DOYUN], subject: '그림 넷', body: '본문', bodyHtml })
|
||||
.expect(400);
|
||||
expect(body(send).error?.code).toBe('VAL_001');
|
||||
const draft = await request(server)
|
||||
.post('/api/drafts')
|
||||
.set('Cookie', cookieOf(user))
|
||||
.send({ to: [], subject: '그림 넷', body: '본문', bodyHtml })
|
||||
.expect(400);
|
||||
expect(body(draft).error?.code).toBe('VAL_001');
|
||||
|
||||
expect(sentMessages).toHaveLength(sentBefore);
|
||||
expect(drafts.size).toBe(draftsBefore);
|
||||
});
|
||||
|
||||
it('두 탭이 동시에 서명을 저장해도 남은 서명이 없는 그림을 가리키지 않는다 (사용자별로 차례대로 저장)', async () => {
|
||||
const user = 'sign.race@e2e.arasystem.test';
|
||||
const [a, b] = [await uploaded(user), await uploaded(user)];
|
||||
const img = (id: string) => `<img src="${imageUrl(id)}" width="96">`;
|
||||
await saveSignature(user, { bodyHtml: `${img(a)}${img(b)}` }).expect(
|
||||
200,
|
||||
);
|
||||
|
||||
// 한 탭은 a 만, 다른 탭은 b 만 남기고 동시에 저장한다 — 누가 나중이든 결과는 앞뒤가 맞아야 한다
|
||||
const [first, second] = await Promise.all([
|
||||
saveSignature(user, { bodyHtml: `<p>탭 1</p>${img(a)}` }),
|
||||
saveSignature(user, { bodyHtml: `<p>탭 2</p>${img(b)}` }),
|
||||
]);
|
||||
expect([first.status, second.status]).toEqual([200, 200]);
|
||||
|
||||
const final = dataOf<{
|
||||
bodyHtml: string | null;
|
||||
images: { id: string }[];
|
||||
}>(
|
||||
await request(server)
|
||||
.get('/api/mail-signature')
|
||||
.set('Cookie', cookieOf(user))
|
||||
.expect(200),
|
||||
);
|
||||
const referenced = [
|
||||
...(final.bodyHtml ?? '').matchAll(
|
||||
/\/api\/mail-signature\/images\/([0-9a-f-]{36})/g,
|
||||
),
|
||||
].map((match) => match[1]);
|
||||
expect(final.images.map((image) => image.id)).toEqual(referenced);
|
||||
// 서명이 가리키는 그림은 모두 있다 — 가리키지 않는 그림은 지워졌다
|
||||
for (const id of [a, b]) {
|
||||
const res = await request(server)
|
||||
.get(imageUrl(id))
|
||||
.set('Cookie', cookieOf(user));
|
||||
expect(res.status).toBe(referenced.includes(id) ? 200 : 404);
|
||||
}
|
||||
});
|
||||
|
||||
it('정리 작업의 지우기는 지우는 순간에도 서명에 붙지 않은 그림만 지우고, 실제로 지운 행만 돌려준다 (그 행의 파일만 지운다)', async () => {
|
||||
const user = 'sign.cleanup@e2e.arasystem.test';
|
||||
const [attached, loose] = [await uploaded(user), await uploaded(user)];
|
||||
await saveSignature(user, {
|
||||
bodyHtml: `<img src="${imageUrl(attached)}" width="96">`,
|
||||
}).expect(200);
|
||||
|
||||
// 정리 작업이 둘을 "붙지 않은 그림"으로 찾았는데, 그사이 하나가 서명에 붙은 경우와 같다
|
||||
const repository = app.get(MailSignatureRepository, { strict: false });
|
||||
const deleted = await repository.deleteUnattachedImages([
|
||||
attached,
|
||||
loose,
|
||||
]);
|
||||
// 표에서 지운 그림의 파일은 정리 작업이 지운다 — 여기서는 테스트가 먼저 (단언이 실패해도 개발 볼륨에 남기지 않게)
|
||||
const left: { id: string }[] = await dataSource.query(
|
||||
'SELECT id FROM mail_signature_images WHERE id = ANY($1)',
|
||||
[[attached, loose]],
|
||||
);
|
||||
for (const id of [attached, loose]) {
|
||||
const stored = join(SIGNATURE_IMAGE_DIR, `${id}.png`);
|
||||
if (!left.some((row) => row.id === id) && existsSync(stored)) {
|
||||
unlinkSync(stored);
|
||||
}
|
||||
}
|
||||
expect(deleted).toEqual([{ id: loose, fileName: `${loose}.png` }]);
|
||||
|
||||
const statusOf = async (id: string) =>
|
||||
(
|
||||
await request(server)
|
||||
.get(imageUrl(id))
|
||||
.set('Cookie', cookieOf(user))
|
||||
).status;
|
||||
expect(await statusOf(attached)).toBe(200);
|
||||
expect(await statusOf(loose)).toBe(404);
|
||||
});
|
||||
|
||||
it('보낼 때 내 서명 그림을 메일 안에 함께 싣는다(cid) — 임시 저장본은 그림 주소 그대로', async () => {
|
||||
const user = 'sign.send@e2e.arasystem.test';
|
||||
const data = pngBytes(200, 50, 1_000);
|
||||
const id = await uploaded(user, data);
|
||||
const html = `<p>본문</p><img src="${imageUrl(id)}" width="144" alt="로고">`;
|
||||
const cid = `sig-${id}@e2e.arasystem.test`;
|
||||
|
||||
await request(server)
|
||||
.post('/api/messages')
|
||||
.set('Cookie', cookieOf(user))
|
||||
.send({ to: [DOYUN], subject: '서명', body: '본문', bodyHtml: html })
|
||||
.expect(201);
|
||||
const sent = sentMessages[sentMessages.length - 1];
|
||||
expect(sent.user).toBe(user);
|
||||
expect(sent.message.htmlBody).toBe(
|
||||
`<p>본문</p><img src="cid:${cid}" width="144" alt="로고" />`,
|
||||
);
|
||||
expect(sent.message.inlineImages).toEqual([
|
||||
{ cid, type: 'image/png', name: 'signature-1.png', data },
|
||||
]);
|
||||
|
||||
const saved = dataOf<SavedDraft>(
|
||||
await request(server)
|
||||
.post('/api/drafts')
|
||||
.set('Cookie', cookieOf(user))
|
||||
.send({ to: [], subject: '', body: '본문', bodyHtml: html })
|
||||
.expect(201),
|
||||
);
|
||||
expect(drafts.get(saved.id)?.content.htmlBody).toBe(
|
||||
`<p>본문</p><img src="${imageUrl(id)}" width="144" alt="로고" />`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('받는 사람 추천 (/api/recipient-suggestions)', () => {
|
||||
const ME = 'suggest.me@e2e.arasystem.test';
|
||||
const COLLEAGUE_A = 'colleague.a@e2e.arasystem.test';
|
||||
const COLLEAGUE_B = 'colleague.b@e2e.arasystem.test';
|
||||
const suggest = (params: Record<string, string | number>) =>
|
||||
request(server)
|
||||
.get('/api/recipient-suggestions')
|
||||
.query(params)
|
||||
.set('Cookie', cookieOf(ME));
|
||||
interface SuggestionPage {
|
||||
items: { name: string | null; email: string; source: string }[];
|
||||
pageInfo: {
|
||||
hasNext: boolean;
|
||||
nextCursor: string | null;
|
||||
total: number;
|
||||
};
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
// 사내 목록은 로그인에 성공한 직원으로 만든다
|
||||
await login(COLLEAGUE_A);
|
||||
await login(COLLEAGUE_B);
|
||||
const now = new Date().toISOString();
|
||||
correspondents.set(ME, [
|
||||
{ name: '박서준', email: COLLEAGUE_B, at: now },
|
||||
{
|
||||
name: '협력사',
|
||||
email: 'partner@vendor.e2e.arasystem.test',
|
||||
at: now,
|
||||
},
|
||||
{ name: null, email: 'no-reply@notice.e2e.arasystem.test', at: now },
|
||||
{ name: null, email: 'mail-gateway@e2e.arasystem.test', at: now },
|
||||
{ name: '나', email: ME, at: now },
|
||||
]);
|
||||
});
|
||||
|
||||
it('사내(로그인한 적 있는 직원) → 최근 주고받은 주소 순 — 나·게이트웨이·noreply 는 빼고, Cache-Control: private, no-store', async () => {
|
||||
// 개발 DB 의 사내 목록에는 다른 직원도 있다 — 이 테스트의 도메인만 거른다
|
||||
const res = await suggest({
|
||||
q: 'e2e.arasystem.test',
|
||||
limit: 100,
|
||||
}).expect(200);
|
||||
|
||||
expect(res.headers['cache-control']).toBe('private, no-store');
|
||||
const { items } = dataOf<SuggestionPage>(res);
|
||||
const emails = items.map((item) => item.email);
|
||||
expect(items).toContainEqual({
|
||||
name: '가나다',
|
||||
email: COLLEAGUE_A,
|
||||
source: 'directory',
|
||||
});
|
||||
// 주고받은 적 있는 동료가 사내 묶음에서 먼저, 두 쪽에 다 있어도 사내로 한 번만
|
||||
expect(emails.indexOf(COLLEAGUE_B)).toBeLessThan(
|
||||
emails.indexOf(COLLEAGUE_A),
|
||||
);
|
||||
expect(emails.filter((email) => email === COLLEAGUE_B)).toHaveLength(1);
|
||||
expect(items[items.length - 1]).toEqual({
|
||||
name: '협력사',
|
||||
email: 'partner@vendor.e2e.arasystem.test',
|
||||
source: 'recent',
|
||||
});
|
||||
expect(emails).not.toContain(ME);
|
||||
expect(emails).not.toContain('mail-gateway@e2e.arasystem.test');
|
||||
expect(emails).not.toContain('no-reply@notice.e2e.arasystem.test');
|
||||
});
|
||||
|
||||
it('q 와 커서 페이지네이션 — 다른 q 의 커서 · 틀린 커서 · 100자 넘는 q · limit 범위 밖은 400 VAL_001', async () => {
|
||||
const first = dataOf<SuggestionPage>(
|
||||
await suggest({ q: 'COLLEAGUE', limit: 1 }).expect(200),
|
||||
);
|
||||
expect(first.items.map((item) => item.email)).toEqual([COLLEAGUE_B]);
|
||||
expect(first.pageInfo).toMatchObject({ hasNext: true, total: 2 });
|
||||
const cursor = first.pageInfo.nextCursor ?? '';
|
||||
|
||||
const second = dataOf<SuggestionPage>(
|
||||
await suggest({ q: 'COLLEAGUE', limit: 1, cursor }).expect(200),
|
||||
);
|
||||
expect(second.items.map((item) => item.email)).toEqual([COLLEAGUE_A]);
|
||||
expect(second.pageInfo).toEqual({
|
||||
hasNext: false,
|
||||
nextCursor: null,
|
||||
total: 2,
|
||||
});
|
||||
|
||||
const rejected: Record<string, string | number>[] = [
|
||||
{ q: 'partner', cursor },
|
||||
{ cursor: 'not-a-cursor' },
|
||||
{ q: 'x'.repeat(101) },
|
||||
{ limit: 101 },
|
||||
];
|
||||
for (const params of rejected) {
|
||||
const res = await suggest(params).expect(400);
|
||||
expect(body(res).error?.code).toBe('VAL_001');
|
||||
}
|
||||
});
|
||||
|
||||
it('메일을 보내면 다시 모은다 — 그 전에는 10분 동안 기억한 목록을 준다', async () => {
|
||||
const now = new Date().toISOString();
|
||||
correspondents.set(ME, [
|
||||
{
|
||||
name: '새 거래처',
|
||||
email: 'new@client.e2e.arasystem.test',
|
||||
at: now,
|
||||
},
|
||||
]);
|
||||
const emailsOf = async () =>
|
||||
dataOf<SuggestionPage>(
|
||||
await suggest({ q: 'client.e2e', limit: 100 }).expect(200),
|
||||
).items.map((item) => item.email);
|
||||
|
||||
expect(await emailsOf()).toEqual([]);
|
||||
|
||||
await request(server)
|
||||
.post('/api/messages')
|
||||
.set('Cookie', cookieOf(ME))
|
||||
.send({ to: [DOYUN], subject: '제목', body: '본문' })
|
||||
.expect(201);
|
||||
expect(await emailsOf()).toEqual(['new@client.e2e.arasystem.test']);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* 서명 그림 테스트용 그림 파일 머리 — 테스트 전용 도우미 (앱 코드에서 import 하지 않는다).
|
||||
*
|
||||
* 형식 확인(signature-image-format.ts)은 파일 앞부분만 본다 — 여기서는 그 앞부분과, 크기를 맞출 채움 바이트만 만든다
|
||||
* (실제로 그릴 수 있는 그림일 필요는 없다).
|
||||
*
|
||||
* 위치: src 밖(test/) — cpu-time.ts 와 같은 이유로 빌드·커버리지에 들어가지 않는다.
|
||||
* 단위 테스트는 `@/../test/image-fixtures`, e2e 는 `./image-fixtures` 로 부른다.
|
||||
*/
|
||||
|
||||
/** PNG 서명 + IHDR(가로·세로·비트 깊이·색 형식) — CRC 는 보지 않으므로 비워 둔다. `size` 바이트가 되게 0 으로 채운다 */
|
||||
export function pngBytes(width: number, height: number, size = 33): Buffer {
|
||||
const data = Buffer.alloc(Math.max(size, 33));
|
||||
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]).copy(data);
|
||||
data.writeUInt32BE(13, 8);
|
||||
data.write('IHDR', 12, 'ascii');
|
||||
data.writeUInt32BE(width, 16);
|
||||
data.writeUInt32BE(height, 20);
|
||||
data[24] = 8; // 비트 깊이
|
||||
data[25] = 6; // 색 형식 RGBA
|
||||
return data;
|
||||
}
|
||||
|
||||
/** GIF 머리 (논리 화면의 가로·세로, 리틀 엔디언) */
|
||||
export function gifBytes(
|
||||
width: number,
|
||||
height: number,
|
||||
version = 'GIF89a',
|
||||
): Buffer {
|
||||
const data = Buffer.alloc(13);
|
||||
data.write(version, 0, 'ascii');
|
||||
data.writeUInt16LE(width, 6);
|
||||
data.writeUInt16LE(height, 8);
|
||||
return data;
|
||||
}
|
||||
|
||||
/** SOI + APP0(JFIF) + (선택) 표지 앞 채움 바이트 + SOFn(세로·가로) + EOI */
|
||||
export function jpegBytes(
|
||||
width: number,
|
||||
height: number,
|
||||
options: { sof?: number; fill?: boolean } = {},
|
||||
): Buffer {
|
||||
const app0 = Buffer.from([
|
||||
0xff, 0xe0, 0x00, 0x10, 0x4a, 0x46, 0x49, 0x46, 0x00, 0x01, 0x01, 0x00,
|
||||
0x00, 0x01, 0x00, 0x01, 0x00, 0x00,
|
||||
]);
|
||||
const sof = Buffer.from([
|
||||
0xff,
|
||||
options.sof ?? 0xc0,
|
||||
0x00,
|
||||
0x11,
|
||||
0x08,
|
||||
(height >> 8) & 0xff,
|
||||
height & 0xff,
|
||||
(width >> 8) & 0xff,
|
||||
width & 0xff,
|
||||
0x03,
|
||||
0x01,
|
||||
0x22,
|
||||
0x00,
|
||||
0x02,
|
||||
0x11,
|
||||
0x01,
|
||||
0x03,
|
||||
0x11,
|
||||
0x01,
|
||||
]);
|
||||
return Buffer.concat([
|
||||
Buffer.from([0xff, 0xd8]),
|
||||
app0,
|
||||
...(options.fill ? [Buffer.from([0xff, 0xff])] : []),
|
||||
sof,
|
||||
Buffer.from([0xff, 0xd9]),
|
||||
]);
|
||||
}
|
||||
Reference in New Issue
Block a user