v0.1.62 - 운영 인증 링크 노출 제한

This commit is contained in:
2026-04-23 18:01:48 +09:00
parent d59795b089
commit 54f4b34e5e
4 changed files with 20 additions and 9 deletions

View File

@@ -10,3 +10,4 @@ APP_BASE_URL=https://planner.sori.studio
RESEND_API_KEY=replace-with-private-resend-api-key
MAIL_FROM_EMAIL=planner@sori.studio
MAIL_FROM_NAME=10 Minute Planner
AUTH_PREVIEW_LINKS=false

View File

@@ -222,6 +222,7 @@
- 왼쪽 사이드바의 인쇄 영역은 `PRINT` 버튼 하나로 줄이고, 클릭 시 모달에서 시작일/종료일과 `1페이지씩` 또는 `2페이지씩` 출력 방식을 선택한다. 인쇄 전용 렌더링은 선택 기간의 날짜를 순서대로 여러 장 생성하며, 2페이지씩 출력에서 홀수 날짜가 남으면 오른쪽은 빈 페이지 프레임으로 둔다.
- 회원가입과 프로필 수정 시 이메일뿐 아니라 닉네임 중복도 서버에서 409로 막는다. 비밀번호 재설정 API는 로그인 모달의 `비밀번호 찾기` 흐름과 연결되어 있고, `/reset-password?token=...` URL로 들어오면 새 비밀번호 설정 모드가 열린다. 실제 메일 발송 전까지는 백엔드 응답의 `resetPreviewUrl`을 개발용 링크로 표시한다.
- Resend 메일러가 추가되었다. `RESEND_API_KEY`, `MAIL_FROM_EMAIL`, `MAIL_FROM_NAME`, `APP_BASE_URL` 환경변수를 설정하면 이메일 인증과 비밀번호 재설정 메일을 실제로 발송한다. API 키는 저장소에 커밋하지 말고 루트 `.env`/`.env.dev`에만 넣는다.
- 인증/비밀번호 재설정 개발용 링크는 `AUTH_PREVIEW_LINKS=true`일 때만 API 응답에 포함된다. 운영 `.env`는 반드시 `AUTH_PREVIEW_LINKS=false`로 두어야 하며, 현재 예시 파일도 false가 기본값이다.
- `5173` 포트가 직접 `npm run dev` 없이 열려 있으면 대부분 `ten-minute-frontend-dev` 컨테이너가 떠 있는 상태다. 개발용 Docker를 끄려면 `docker compose -f docker-compose.dev.yml down`을 사용한다.
- 현재 환경에서는 Docker 데몬이 꺼져 있어서 `docker compose build` 실검증은 하지 못했고, 데몬 시작 후 다시 확인이 필요하다.
- 이미지 저장 기능은 추후 `print-only` 또는 별도 export 전용 레이아웃을 기준으로 구현하면 화면/인쇄/공유 결과를 맞추기 쉽다.

View File

@@ -17,6 +17,7 @@ const envSchema = z.object({
RESEND_API_KEY: z.string().optional(),
MAIL_FROM_EMAIL: z.string().email().default('planner@sori.studio'),
MAIL_FROM_NAME: z.string().default('10 Minute Planner'),
AUTH_PREVIEW_LINKS: z.coerce.boolean().default(false),
ADMIN_ACCOUNT_ID: z.string().min(1),
ADMIN_ACCOUNT_PASSWORD: z.string().min(12),
ADMIN_ACCOUNT_EMAIL: z.string().email(),

View File

@@ -109,6 +109,17 @@ function sanitizeUser(user) {
}
}
function withPreviewUrl(payload, key, previewUrl) {
if (!env.AUTH_PREVIEW_LINKS) {
return payload
}
return {
...payload,
[key]: previewUrl,
}
}
async function findUserByNickname(nickname) {
const [user] = await db
.select()
@@ -177,12 +188,11 @@ export async function registerAuthRoutes(app) {
linkUrl: verification.previewUrl,
})
return reply.code(201).send({
return reply.code(201).send(withPreviewUrl({
message: '회원가입이 완료되었습니다.',
token,
user: sanitizeUser(user),
verificationPreviewUrl: verification.previewUrl,
})
}, 'verificationPreviewUrl', verification.previewUrl))
})
app.post('/api/auth/login', async (request, reply) => {
@@ -397,10 +407,9 @@ export async function registerAuthRoutes(app) {
linkUrl: verification.previewUrl,
})
return {
return withPreviewUrl({
message: '이메일 인증 링크를 준비했습니다.',
verificationPreviewUrl: verification.previewUrl,
}
}, 'verificationPreviewUrl', verification.previewUrl)
})
app.post('/api/auth/verification/confirm', async (request, reply) => {
@@ -485,10 +494,9 @@ export async function registerAuthRoutes(app) {
linkUrl: reset.previewUrl,
})
return {
return withPreviewUrl({
message: '비밀번호 재설정 링크를 준비했습니다.',
resetPreviewUrl: reset.previewUrl,
}
}, 'resetPreviewUrl', reset.previewUrl)
})
app.post('/api/auth/password-reset/confirm', async (request, reply) => {