diff --git a/.env.local.example b/.env.local.example new file mode 100644 index 0000000..9385743 --- /dev/null +++ b/.env.local.example @@ -0,0 +1,18 @@ +MARIADB_ROOT_PASSWORD=tier_cursor_root_local +MARIADB_DATABASE=tier_cursor +MARIADB_USER=tier_cursor +MARIADB_PASSWORD=tier_cursor1234 +DB_HOST=127.0.0.1 +DB_PORT=3307 +DB_USER=tier_cursor +DB_PASSWORD=tier_cursor1234 +DB_NAME=tier_cursor +SESSION_SECRET=tmaker-local-session-secret +APP_ORIGIN=http://localhost:5173 +SMTP_HOST=smtp.gmail.com +SMTP_PORT=465 +SMTP_SECURE=true +SMTP_USER=local-mailer@tmaker.local +SMTP_PASS=local-mailer-app-password +SMTP_FROM="Tier Maker " +SKIP_EMAIL_VERIFICATION=true diff --git a/.env.production.example b/.env.production.example index dff85fd..fb3e958 100644 --- a/.env.production.example +++ b/.env.production.example @@ -1,13 +1,13 @@ -MARIADB_ROOT_PASSWORD=change-this-root-password +MARIADB_ROOT_PASSWORD=tier_cursor_root_production MARIADB_DATABASE=tier_cursor MARIADB_USER=tier_cursor -MARIADB_PASSWORD=change-this-db-password -SESSION_SECRET=change-this-session-secret +MARIADB_PASSWORD=tier_cursor_production_password +SESSION_SECRET=tmaker-production-session-secret APP_ORIGIN=https://tmaker.sori.studio SMTP_HOST=smtp.gmail.com SMTP_PORT=465 SMTP_SECURE=true -SMTP_USER=your-gmail-account@gmail.com -SMTP_PASS=change-this-gmail-app-password -SMTP_FROM="Tier Maker " +SMTP_USER=mailer@tmaker.sori.studio +SMTP_PASS=mailer-app-password +SMTP_FROM="Tier Maker " SKIP_EMAIL_VERIFICATION=false diff --git a/README.md b/README.md index da79ed7..00fa524 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,7 @@ docker compose up -d mariadb phpmyadmin ### 2) 백엔드 실행 ```bash +cp .env.local.example .env.local cd backend npm install npm run dev diff --git a/backend/index.js b/backend/index.js index 5b60a26..1802354 100644 --- a/backend/index.js +++ b/backend/index.js @@ -6,7 +6,22 @@ const cors = require('cors') const session = require('express-session') const FileStoreFactory = require('session-file-store') -dotenv.config({ path: path.join(__dirname, '..', '.env.production') }) +function loadEnvFileIfExists(fileName) { + const envPath = path.join(__dirname, '..', fileName) + if (!fs.existsSync(envPath)) return false + dotenv.config({ path: envPath }) + return true +} + +loadEnvFileIfExists('.env') + +const nodeEnv = String(process.env.NODE_ENV || 'development').trim().toLowerCase() +if (nodeEnv === 'production') { + loadEnvFileIfExists('.env.production') +} else { + const loadedLocal = loadEnvFileIfExists('.env.local') + if (!loadedLocal) loadEnvFileIfExists('.env.production') +} const { ensureData } = require('./src/db') const authRoutes = require('./src/routes/auth') diff --git a/backend/package.json b/backend/package.json index 5ae1b62..4e52135 100644 --- a/backend/package.json +++ b/backend/package.json @@ -4,8 +4,8 @@ "description": "", "main": "index.js", "scripts": { - "dev": "APP_ORIGIN=http://localhost:5173 DB_HOST=127.0.0.1 DB_PORT=3307 DB_USER=tier_cursor DB_PASSWORD=tier_cursor1234 DB_NAME=tier_cursor nodemon --legacy-watch --watch index.js --watch src index.js", - "start": "APP_ORIGIN=http://localhost:5173 DB_HOST=127.0.0.1 DB_PORT=3307 DB_USER=tier_cursor DB_PASSWORD=tier_cursor1234 DB_NAME=tier_cursor node index.js", + "dev": "NODE_ENV=development nodemon --legacy-watch --watch index.js --watch src index.js", + "start": "NODE_ENV=production node index.js", "images:backfill": "DB_HOST=127.0.0.1 DB_PORT=3307 DB_USER=tier_cursor DB_PASSWORD=tier_cursor1234 DB_NAME=tier_cursor node scripts/backfill-legacy-image-assets.js", "images:migrate-legacy": "DB_HOST=127.0.0.1 DB_PORT=3307 DB_USER=tier_cursor DB_PASSWORD=tier_cursor1234 DB_NAME=tier_cursor node scripts/migrate-legacy-uploads-to-assets.js", "images:shard-assets": "DB_HOST=127.0.0.1 DB_PORT=3307 DB_USER=tier_cursor DB_PASSWORD=tier_cursor1234 DB_NAME=tier_cursor node scripts/migrate-flat-assets-to-sharded.js", diff --git a/backend/src/db.js b/backend/src/db.js index 339188e..e5ac7fe 100644 --- a/backend/src/db.js +++ b/backend/src/db.js @@ -699,9 +699,16 @@ async function countUsers() { } async function findUserByEmail(email) { + const normalized = String(email || '').trim().toLowerCase() + if (!normalized) return null const rows = await query( - 'SELECT id, email, nickname, nickname_updated_at, password_hash, email_verified, is_admin, avatar_src, last_login_at, created_at FROM users WHERE email = ? LIMIT 1', - [email] + ` + SELECT id, email, nickname, nickname_updated_at, password_hash, email_verified, is_admin, avatar_src, last_login_at, created_at + FROM users + WHERE TRIM(LOWER(email)) = TRIM(LOWER(?)) + LIMIT 1 + `, + [normalized] ) const row = rows[0] if (!row) return null @@ -745,12 +752,14 @@ async function findUserById(id) { async function createUser({ id, email, nickname, passwordHash, emailVerified = true, isAdmin }) { const createdAt = now() + const normalizedEmail = String(email || '').trim().toLowerCase() + const normalizedNickname = String(nickname || '').trim() await query( ` INSERT INTO users (id, email, nickname, nickname_updated_at, password_hash, email_verified, is_admin, avatar_src, last_login_at, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `, - [id, email, nickname || '', createdAt, passwordHash, emailVerified ? 1 : 0, isAdmin ? 1 : 0, '', isAdmin ? createdAt : 0, createdAt] + [id, normalizedEmail, normalizedNickname, createdAt, passwordHash, emailVerified ? 1 : 0, isAdmin ? 1 : 0, '', isAdmin ? createdAt : 0, createdAt] ) return findUserById(id) } diff --git a/backend/src/routes/auth.js b/backend/src/routes/auth.js index f4a886e..9b08970 100644 --- a/backend/src/routes/auth.js +++ b/backend/src/routes/auth.js @@ -145,6 +145,14 @@ function hashToken(token) { return crypto.createHash('sha256').update(String(token || '')).digest('hex') } +function normalizeEmail(value) { + return String(value || '').trim().toLowerCase() +} + +function normalizeNickname(value) { + return String(value || '').trim() +} + function resolveAppOrigin(req) { const envOrigin = String(process.env.APP_ORIGIN || process.env.PUBLIC_APP_ORIGIN || '').trim() if (envOrigin) return envOrigin.replace(/\/+$/, '') @@ -207,7 +215,9 @@ router.post('/signup', async (req, res) => { const parsed = signupSchema.safeParse(req.body) if (!parsed.success) return res.status(400).json({ error: 'bad_request' }) - const { email, nickname, password } = parsed.data + const email = normalizeEmail(parsed.data.email) + const nickname = normalizeNickname(parsed.data.nickname) + const { password } = parsed.data const exists = await findUserByEmail(email) if (exists) return res.status(409).json({ error: 'email_taken' }) if (isReservedNickname(nickname)) return res.status(400).json({ error: 'nickname_reserved' }) @@ -221,14 +231,22 @@ router.post('/signup', async (req, res) => { return res.status(503).json({ error: 'mail_not_configured' }) } - const user = await createUser({ - id: nanoid(), - email, - nickname, - passwordHash, - emailVerified: isAdmin || skipEmailVerification, - isAdmin, - }) + let user = null + try { + user = await createUser({ + id: nanoid(), + email, + nickname, + passwordHash, + emailVerified: isAdmin || skipEmailVerification, + isAdmin, + }) + } catch (err) { + if (err?.code === 'ER_DUP_ENTRY') { + return res.status(409).json({ error: 'email_or_nickname_taken' }) + } + throw err + } if (!isAdmin && !skipEmailVerification) { try { @@ -260,7 +278,8 @@ router.post('/login', async (req, res) => { }).safeParse(req.body) if (!parsed.success) return res.status(400).json({ error: 'bad_request' }) - const { email, password } = parsed.data + const email = normalizeEmail(parsed.data.email) + const { password } = parsed.data const user = await findUserByEmail(email) if (!user) return res.status(401).json({ error: 'invalid_credentials' }) @@ -320,7 +339,7 @@ router.post('/email/resend', async (req, res) => { const parsed = resendVerificationSchema.safeParse(req.body) if (!parsed.success) return res.status(400).json({ error: 'bad_request' }) - const user = await findUserByEmail(parsed.data.email) + const user = await findUserByEmail(normalizeEmail(parsed.data.email)) if (!user || user.emailVerified) return res.json({ ok: true }) try { @@ -336,7 +355,7 @@ router.post('/password-reset/request', async (req, res) => { const parsed = requestPasswordResetSchema.safeParse(req.body) if (!parsed.success) return res.status(400).json({ error: 'bad_request' }) - const user = await findUserByEmail(parsed.data.email) + const user = await findUserByEmail(normalizeEmail(parsed.data.email)) if (!user) return res.json({ ok: true }) try { diff --git a/docs/history.md b/docs/history.md index 759a71b..2283ed1 100644 --- a/docs/history.md +++ b/docs/history.md @@ -4,6 +4,8 @@ - 캐릭터 이름 표시를 켠 상태에서 긴 라벨이 잘리는 문제는 글자 수 제한으로 자르기보다, 카드 안에서 overflow 시에만 자동 슬라이드시키는 편이 정보 전달과 가독성 모두에 유리하다고 판단했다. - 짧은 라벨까지 항상 움직이면 오히려 시선 피로가 생기므로, 실제 렌더링 너비를 기준으로 overflow 여부를 계산해 마퀴 애니메이션을 조건부로 켜는 방식으로 정리했다. - 로컬 개발에서는 SMTP가 없거나 불안정한 경우 가입/로그인 플로우 자체를 막아버리기 쉬우므로, 운영 환경과 분리된 개발 전용 스위치(`SKIP_EMAIL_VERIFICATION`)로 메일 인증 단계를 건너뛸 수 있게 두는 편이 낫다고 판단했다. +- 이메일/닉네임 중복은 DB unique 제약에만 기대기보다 입력 정규화(`trim + lowercase`)와 앱 레벨 중복 체크를 함께 두는 편이 재현 케이스(대소문자/공백 변형)에 더 안전하다고 정리했다. +- 로컬 개발과 운영이 같은 `.env.production` 파일을 공유하면 인증 우회 같은 개발용 옵션이 운영에 섞일 위험이 있으므로, 실행 모드(`NODE_ENV`) 기준으로 로컬 `.env.local`과 운영 `.env.production`을 분리해 읽는 편이 더 안전하다고 판단했다. ## 2026-04-08 v1.1.30 - 관리자 목록관리의 상단 고정 템플릿 후보가 많아지면 모든 후보를 화면에 상시 렌더링하는 방식은 탐색성이 떨어지고 관리 영역을 불필요하게 길게 만든다고 판단했다. 따라서 고정 목록은 현재 선택된 항목만 보여주고, 추가는 검색 모달로 분리하는 쪽으로 정리했다. diff --git a/docs/local-mariadb.md b/docs/local-mariadb.md index bf1c442..48794d8 100644 --- a/docs/local-mariadb.md +++ b/docs/local-mariadb.md @@ -20,7 +20,13 @@ docker compose up -d mariadb phpmyadmin - phpMyAdmin: `http://localhost:8081` ## 3. 백엔드 실행 -아래 명령은 로컬 MariaDB 기준 환경변수를 포함한다. +로컬 실행은 루트 `.env.local`을 기준으로 동작한다. 처음 한 번 아래처럼 복사해 둔다. + +```bash +cp .env.local.example .env.local +``` + +그 다음 백엔드를 실행한다. ```bash cd backend diff --git a/docs/spec.md b/docs/spec.md index 3aed175..c69226d 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -420,6 +420,7 @@ - 프런트엔드 - `VITE_API_ORIGIN`: API 및 업로드 파일 절대 기준 주소 - 백엔드 + - 환경파일 로딩 기준: `NODE_ENV=production`이면 `.env.production`, 그 외 로컬 개발 기본은 `.env.local`을 우선 읽는다. 로컬 파일이 없으면 `.env.production`을 fallback으로 읽는다. - `DB_HOST`: MariaDB 호스트 - `DB_PORT`: MariaDB 포트 - `DB_USER`: MariaDB 계정 @@ -444,6 +445,7 @@ - 첫 번째 가입 계정은 운영 초기 부트스트랩을 위해 이메일 인증 없이 바로 최고 관리자 계정으로 활성화한다. - 두 번째 이후 일반 회원가입은 가입 직후 로그인 세션을 만들지 않고, 인증 메일 링크를 눌러 `email_verified=1`이 된 뒤에만 로그인할 수 있게 한다. - 단, 개발 환경에서 `SKIP_EMAIL_VERIFICATION=true`를 주면 두 번째 이후 일반 회원도 메일 인증 단계를 건너뛰고 즉시 로그인한다. +- 이메일 식별은 가입/로그인/재전송/재설정 요청 모두 `trim + lowercase` 정규화 기준으로 처리한다. - 인증 메일/비밀번호 재설정 메일 토큰은 원문을 DB에 저장하지 않고 SHA-256 해시만 저장하며, 새 토큰을 발급할 때는 같은 사용자의 이전 미사용 토큰을 먼저 만료 처리한다. - 이메일 인증 토큰은 24시간, 비밀번호 재설정 토큰은 1시간 유효 기간을 사용한다. - 비밀번호 재설정 링크로 새 비밀번호를 저장한 사용자는 같은 메일 주소를 확인한 것으로 보고, 기존에 미인증 상태였더라도 저장과 함께 이메일 인증을 완료 처리한다. diff --git a/docs/todo.md b/docs/todo.md index 394aee2..b8f7d7d 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -1,6 +1,9 @@ # 할 일 및 이슈 ## 단기 확인 +- `v1.1.31` 이후 로컬 실행(`NODE_ENV=development`)에서 `.env.local` 값이 실제로 우선 적용되고, 운영 실행(`NODE_ENV=production`)에서는 `.env.production`만 적용되는지 확인한다. +- `v1.1.31` 이후 회원가입에서 같은 이메일을 대소문자만 바꾼 경우(`A@x.com` vs `a@x.com`)와 앞뒤 공백 케이스가 모두 중복으로 차단되는지 확인한다. +- `v1.1.31` 이후 같은 닉네임(대소문자/공백 변형 포함)으로 재가입 시 `nickname_taken` 또는 `email_or_nickname_taken`으로 안정적으로 막히는지 확인한다. - `v1.1.31` 이후 로컬에서 `SKIP_EMAIL_VERIFICATION=true`를 적용했을 때 두 번째 가입 계정도 인증 메일 단계 없이 즉시 로그인되는지, 운영 환경(`false`)에서는 기존처럼 인증 메일 단계가 유지되는지 확인한다. - `v1.1.31` 이후 티어표 편집 화면과 `preview=1` 화면에서 `캐릭터 이름 표시`를 켰을 때, 긴 이름만 천천히 무한 슬라이드되고 짧은 이름은 고정 표시로 유지되는지 확인한다. - `v1.1.30` 이후 관리자 목록관리에서 `템플릿 추가` 모달을 열고 검색·선택·닫기 흐름이 자연스러운지, 이미 고정된 템플릿이 추가 후보에서 제외되는지 확인한다. diff --git a/docs/update.md b/docs/update.md index 81ad414..9bf2099 100644 --- a/docs/update.md +++ b/docs/update.md @@ -6,6 +6,11 @@ - 같은 이름 오버레이를 쓰는 편집 보드와 `preview=1` 완성본 양쪽에 동일 동작을 적용했다. - 백엔드 회원가입 흐름에 `SKIP_EMAIL_VERIFICATION` 환경변수를 추가했다. 개발 환경에서만 `true`일 때 일반 회원도 메일 인증을 건너뛰고 바로 로그인되도록 처리했다. - `.env.production.example`에 `SKIP_EMAIL_VERIFICATION=false` 기본값을 추가해 로컬/운영 설정을 분리해 적용할 수 있게 했다. +- 이메일/닉네임 중복 검사를 보강했다. 회원가입/로그인/비밀번호 재설정 메일 요청에서 이메일을 `trim + lowercase`로 정규화해 비교하도록 맞췄고, 가입 시 DB `ER_DUP_ENTRY`도 409(`email_or_nickname_taken`)로 안전 처리한다. +- 사용자 생성 시에도 이메일/닉네임을 정규화 저장하도록 보강해, 대소문자/앞뒤 공백 차이로 중복 계정이 생기던 케이스를 줄였다. +- 환경파일 로딩을 로컬/운영으로 분리했다. 백엔드는 `NODE_ENV=production`일 때만 `.env.production`을 읽고, 로컬 개발(`npm run dev`)에서는 `.env.local`을 우선 읽도록 조정했다. +- `backend/package.json`의 `dev/start` 스크립트에서 DB/APP 값을 하드코딩하지 않도록 정리하고, `.env.local.example` 파일을 새로 추가해 로컬 기준값과 `SKIP_EMAIL_VERIFICATION=true` 예시를 분리했다. +- `.env.local.example`, `.env.production.example`의 값은 바로 복사해도 실행 가능한 형태로 기본값을 채워두고, 실제 운영 파일 `.env.production`에는 `SKIP_EMAIL_VERIFICATION=false`를 명시해 실수로 인증 우회가 켜지지 않게 했다. - 확인: `frontend/src/views/TierEditorView.vue` lint 0건 ## 2026-04-08 v1.1.30