From 4862b52b3a6e790748c4a391304cd8e892054d21 Mon Sep 17 00:00:00 2001 From: zenn Date: Thu, 14 May 2026 13:36:51 +0900 Subject: [PATCH] =?UTF-8?q?=EA=B4=80=EB=A6=AC=EC=9E=90=20=EB=B6=80?= =?UTF-8?q?=ED=8A=B8=EC=8A=A4=ED=8A=B8=EB=9E=A9=20=EB=B3=B5=EA=B5=AC=20?= =?UTF-8?q?=EB=B3=B4=EA=B0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docker-compose.yml | 1 + docs/changelog.md | 8 ++ docs/deploy.md | 58 ++++++++++- docs/history.md | 12 +++ docs/spec.md | 4 +- docs/update.md | 13 +++ package-lock.json | 4 +- package.json | 2 +- server/repositories/member-repository.js | 107 ++++++++++++++++++++- server/routes/admin/api/auth/login.post.js | 9 +- 10 files changed, 199 insertions(+), 19 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index 826fe7b..5198057 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -29,6 +29,7 @@ services: - "${DB_PORT:-43119}:5432" volumes: - sori-studio-postgres:/var/lib/postgresql/data + # NAS 등: 호스트 db/migrations 가 다른 UID만 읽을 수 있으면 컨테이너에서 Permission denied → DB 재시작 루프. 프로젝트 루트에서 chmod -R a+rX db/migrations 및 상위 경로 통과 권한 확인. - ./db/migrations:/docker-entrypoint-initdb.d:ro networks: - sori-studio-network diff --git a/docs/changelog.md b/docs/changelog.md index d806c6f..851a579 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -1,5 +1,13 @@ # 업데이트 요약 +## v1.0.4 + +- owner/admin 계정이 없는 운영 DB에서도 환경 변수 관리자 계정으로 첫 owner를 생성하거나 기존 일반 회원을 승격할 수 있도록 보강. + +## v1.0.3 + +- NAS에서 Postgres 초기 마이그레이션 디렉터리 권한 문제로 DB 컨테이너가 재시작될 때 확인할 배포 절차를 정리. + ## v1.0.2 - 운영 DB 최초 상태에서 환경 변수 관리자 계정으로 첫 owner 계정을 만들고 로그인할 수 있도록 보강. diff --git a/docs/deploy.md b/docs/deploy.md index 5471fea..79fdb2f 100644 --- a/docs/deploy.md +++ b/docs/deploy.md @@ -129,7 +129,7 @@ cd sori.studio # .env.production은 Git에 올리지 않는 운영 전용 파일 cp .env.example .env.production # .env.production 파일에 운영 DB 연결 정보, 운영 전용 랜덤 비밀번호, APP_PORT=43118 입력 -# 운영 DB가 비어 있으면 /admin/login에서 ADMIN_EMAIL/ADMIN_PASSWORD로 최초 owner 계정이 생성됨 +# 운영 DB에 owner/admin이 없으면 /admin/login에서 ADMIN_EMAIL/ADMIN_PASSWORD로 최초 owner 계정이 생성됨 # MEMBER_SESSION_SECRET은 ADMIN_PASSWORD와 다른 긴 난수 문자열로 반드시 입력 # Docker 네트워크 대역이 NAS 기존 컨테이너와 겹치면 DOCKER_SUBNET을 다른 사설 대역으로 변경 # Docker 내부 앱에서 PostgreSQL에 접근할 때는 sori-studio-db:5432 사용 @@ -166,7 +166,7 @@ docker compose --env-file .env.production up -d --build - NAS Docker 예시: `postgres://sori_studio:비밀번호@sori-studio-db:5432/sori_studio` - `.env.example`에는 실제 비밀번호나 개인 이메일을 기록하지 않음 - 개발/운영 DB 비밀번호와 관리자 비밀번호는 서로 다른 랜덤 값을 사용 -- `ADMIN_EMAIL`/`ADMIN_PASSWORD`는 운영 DB가 비어 있는 최초 관리자 생성에만 사용한다. 첫 owner 계정이 DB에 생성된 뒤에는 관리자 로그인도 DB의 bcrypt 비밀번호를 기준으로 검증한다. +- `ADMIN_EMAIL`/`ADMIN_PASSWORD`는 운영 DB에 owner/admin이 없는 최초 관리자 생성에만 사용한다. 같은 이메일의 일반 회원이 이미 있으면 owner로 승격하고 비밀번호를 `ADMIN_PASSWORD` 기준으로 갱신한다. 첫 owner 계정이 DB에 생성된 뒤에는 관리자 로그인도 DB의 bcrypt 비밀번호를 기준으로 검증한다. - 회원 세션 서명용 `MEMBER_SESSION_SECRET`은 관리자 비밀번호와 분리된 긴 난수 문자열을 사용 - 개발 DB와 운영 DB는 반드시 별도 인스턴스 또는 별도 데이터베이스로 분리 - 운영 DB는 로컬 개발 서버에서 직접 연결하지 않음 @@ -263,6 +263,60 @@ git diff -- . ':!package-lock.json' - `.env.development`, `.env.production`이 변경 목록에 포함되지 않음 - 문서와 코드 diff에 실제 DB 비밀번호, 관리자 비밀번호, 운영 접속 주소가 포함되지 않음 +### 컨테이너가 `Restarting`일 때 + +`Error response from daemon: Container … is restarting, wait until the container is running`은 **프로세스가 곧바로 종료**되어 `restart: unless-stopped`가 반복 시도하는 상태다. 원인은 로그에 나온다. + +1. **어느 서비스인지 확인** (`docker-compose.yml` 기준 이름은 `sori-studio`, `sori-studio-db`). + +```bash +docker ps -a --filter "name=sori-studio" +``` + +2. **해당 컨테이너 로그** (가장 중요). + +```bash +docker logs sori-studio --tail 150 +docker logs sori-studio-db --tail 150 +``` + +Compose로 올렸다면: + +```bash +docker compose --env-file .env.production logs sori-studio --tail 200 +docker compose --env-file .env.production logs sori-studio-db --tail 200 +``` + +3. **자주 나오는 원인** + +- **`sori-studio`**: `DATABASE_URL` 누락·오타, `MEMBER_SESSION_SECRET` 미설정, DB 호스트가 컨테이너 기준으로 잘못됨(예: 앱은 Docker 안인데 URL만 `127.0.0.1`로 DB를 가리킴), 애플리케이션 예외로 즉시 종료. +- **`sori-studio-db`**: 이미 초기화된 볼륨과 다른 `POSTGRES_PASSWORD`로 다시 올린 경우, `docker-entrypoint-initdb.d` 마이그레이션 SQL 오류, 디스크/권한 문제. +- **`sori-studio-db` 로그에 `ls: can't open '/docker-entrypoint-initdb.d/': Permission denied`**: 아래 **NAS·호스트에서 `db/migrations` 권한** 절차를 확인한다. + +4. 로그를 고친 뒤에는 `docker compose --env-file .env.production up -d`로 다시 올리고, `docker ps`에서 `Up` 상태인지 확인한다. + +### NAS·호스트에서 `db/migrations` 권한 + +`docker-compose.yml`은 `./db/migrations`를 Postgres 이미지의 `/docker-entrypoint-initdb.d`에 **읽기 전용**으로 붙인다. 공식 엔트리포인트는 이 디렉터리를 `ls`로 읽는데, NAS(UGREEN 등)나 SSH로 복사한 트리에서 **폴더·파일이 700/600만 허용**이거나 **상위 디렉터리에 실행(x) 비트가 없으면** 컨테이너 안 `postgres` 사용자가 경로를 통과하지 못해 `Permission denied`가 반복되고 DB 컨테이너가 재시작 루프에 들어갈 수 있다. + +프로젝트 루트( `docker compose` 를 실행하는 디렉터리)에서 SSH로 다음을 적용한다. **비밀번호는 바꾸지 않으며**, 읽기·디렉터리 통과만 연다. + +```bash +cd /volume1/docker/projects/apps/sori.studio +# 마이그레이션 디렉터리와 그 안 SQL: 모두 읽기, 디렉터리는 검색 가능 +sudo chmod -R a+rX db/migrations +# 상위 db/, 프로젝트 루트가 다른 사용자만 rwx 인 경우 통과 허용 +sudo chmod a+x . db db/migrations +``` + +그다음 DB 컨테이너만 재시작한다. + +```bash +docker compose --env-file .env.production restart sori-studio-db +``` + +여전히 동일하면 프로젝트가 **SMB 공유 폴더 위**에만 있지 않은지 확인한다. Docker 데몬이 네이티브 경로(ext4 등)의 디렉터리를 마운트할 때 권한이 더 예측 가능하다. + ## 업로드 파일 - 관리자 글쓰기에서 업로드한 이미지는 `/uploads/posts/YYYY/MM/` URL로 제공한다. diff --git a/docs/history.md b/docs/history.md index b2f80a2..de2611f 100644 --- a/docs/history.md +++ b/docs/history.md @@ -1,5 +1,17 @@ # 의사결정 이력 +## 2026-05-14 v1.0.4 + +### 최초 관리자 기준을 owner/admin 존재 여부로 변경 + +운영 DB에 일반 회원이 먼저 생성되면 기존의 “사용자 0명” 기준 부트스트랩이 더 이상 동작하지 않아 관리자 계정이 없는 잠금 상태가 생길 수 있다. 최초 관리자 필요 여부를 전체 사용자 수가 아니라 `owner`/`admin` 권한 보유자 존재 여부로 판단하고, `ADMIN_EMAIL`과 같은 일반 회원이 이미 있으면 해당 회원을 `owner`로 승격하면서 `ADMIN_PASSWORD` 기준 비밀번호 해시를 갱신해 운영 복구 경로를 보장한다. 이미 `owner`/`admin`이 있으면 환경 변수 로그인은 우회 권한으로 쓰지 않는다. + +## 2026-05-14 v1.0.3 + +### NAS에서 Postgres init 디렉터리 Permission denied + +Docker가 호스트의 `db/migrations`를 `postgres:16-alpine`의 `/docker-entrypoint-initdb.d`에 마운트할 때, NAS 파일 시스템이나 복사 시 기본 umask 때문에 디렉터리가 `700`·파일이 `600`만 되면 컨테이너 내부 `postgres` UID로는 목록을 읽지 못한다. 엔트리포인트가 `ls /docker-entrypoint-initdb.d`에서 실패하면 DB가 즉시 종료되고 `restart: unless-stopped`로 루프에 들어간다. 배포 문서에 `chmod -R a+rX db/migrations` 및 상위 경로 `a+x` 절차를 명시하고, compose에 주석으로 동일 원인을 남겨 재발 시 빠르게 대응할 수 있게 했다. + ## 2026-05-14 v1.0.1 ### Docker Compose 전용 네트워크 대역 명시 diff --git a/docs/spec.md b/docs/spec.md index 32f39e7..05ca5a9 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -567,8 +567,8 @@ components/content/ - 관리자 인증은 `users.is_admin=true` 회원의 이메일/비밀번호(bcrypt 해시) 기반으로 처리한다. - DB에 사용자가 없으면 `/signup`에서 관리자 등록 모드(관리자 이름/이메일/비밀번호/비밀번호 확인)로 첫 계정을 생성한다. -- DB에 사용자가 없는 최초 상태에서 `/admin/login`에 `ADMIN_EMAIL`/`ADMIN_PASSWORD`와 같은 값을 입력하면 첫 owner 계정을 DB에 생성한 뒤 로그인한다. 이미 사용자가 있으면 환경 변수 계정으로 우회 로그인하지 않고 DB의 관리자 계정만 사용한다. -- 첫 계정 생성 시 `is_admin=true`, `user_role=owner`를 자동 부여한다. 최초 가입 판정은 동시 요청에서 중복 owner가 생기지 않도록 `users` 테이블 잠금 안에서 처리한다. +- DB에 owner/admin 계정이 없는 최초 상태에서 `/admin/login`에 `ADMIN_EMAIL`/`ADMIN_PASSWORD`와 같은 값을 입력하면 첫 owner 계정을 DB에 생성한 뒤 로그인한다. 같은 이메일의 일반 회원이 이미 있으면 해당 회원을 owner로 승격하고 비밀번호를 `ADMIN_PASSWORD` 기준 bcrypt 해시로 갱신한다. 이미 owner/admin 계정이 있으면 환경 변수 계정으로 우회 로그인하지 않고 DB의 관리자 계정만 사용한다. +- 최초 owner 생성 시 `is_admin=true`, `user_role=owner`를 자동 부여한다. 최초 관리자 판정은 동시 요청에서 중복 owner가 생기지 않도록 `users` 테이블 잠금 안에서 처리한다. - 관리자 로그인 성공 시 httpOnly 세션 쿠키를 `/admin` 경로에 설정한다. - `/admin/login` 로그인 제출 버튼은 이메일·비밀번호가 모두 입력된 경우에만 활성화한다. - 관리자 로그인 성공 시 관리자/회원 세션 쿠키를 함께 설정하고 `users.previous_last_seen_at`/`previous_last_seen_ip`에 직전 로그인 값을 보존한 뒤 `last_seen_at`/`last_seen_ip`를 현재 로그인으로 갱신한다. diff --git a/docs/update.md b/docs/update.md index 9a77b50..33ea1db 100644 --- a/docs/update.md +++ b/docs/update.md @@ -1,9 +1,22 @@ # 업데이트 이력 +## v1.0.4 + +- 최초 관리자 부트스트랩 기준을 전체 사용자 수가 아니라 owner/admin 존재 여부로 변경. +- owner/admin이 없는 상태에서 `ADMIN_EMAIL`과 같은 일반 회원이 이미 있으면 해당 회원을 owner로 승격하고 `ADMIN_PASSWORD`로 비밀번호를 갱신하도록 수정. +- 패키지 버전 `1.0.4`로 갱신. + +## v1.0.3 + +- NAS 등에서 `db/migrations` 바인드 마운트 권한 부족 시 `docker-entrypoint-initdb.d` Permission denied로 DB 컨테이너가 재시작하는 경우를 배포 문서에 정리. +- `docker-compose.yml`에 동일 이슈용 주석 추가. +- 패키지 버전 `1.0.3`으로 갱신. + ## v1.0.2 - 운영 DB가 비어 있을 때 `/admin/login`에서 `ADMIN_EMAIL`/`ADMIN_PASSWORD`로 최초 owner 계정을 생성하도록 수정. - 배포 문서의 `.env.production` 생성 명령과 최초 관리자 생성 기준 정리. +- 배포 문서에 Docker 컨테이너 `Restarting` 루프 시 로그 확인 절차 추가. - 패키지 버전 `1.0.2`로 갱신. ## v1.0.1 diff --git a/package-lock.json b/package-lock.json index f3b4c95..0afbd53 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "sori.studio", - "version": "1.0.2", + "version": "1.0.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "sori.studio", - "version": "1.0.2", + "version": "1.0.4", "hasInstallScript": true, "dependencies": { "@nuxtjs/tailwindcss": "^6.14.0", diff --git a/package.json b/package.json index 8996074..95cde6d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "sori.studio", - "version": "1.0.2", + "version": "1.0.4", "private": true, "type": "module", "imports": { diff --git a/server/repositories/member-repository.js b/server/repositories/member-repository.js index 72b790c..762653f 100644 --- a/server/repositories/member-repository.js +++ b/server/repositories/member-repository.js @@ -217,6 +217,14 @@ export const createUser = async (input) => { const rows = await sql.begin(async (tx) => { await tx`LOCK TABLE users IN SHARE ROW EXCLUSIVE MODE` + const privilegedRows = await tx` + SELECT EXISTS ( + SELECT 1 + FROM users + WHERE user_role = ANY(${PRIVILEGED_ROLES}) + ) AS "hasPrivilegedUsers" + ` + const shouldCreateOwner = !Boolean(privilegedRows?.[0]?.hasPrivilegedUsers) return tx` INSERT INTO users (username, email, password_hash, avatar_url, is_admin, user_role) @@ -225,9 +233,9 @@ export const createUser = async (input) => { ${input.email}, ${input.passwordHash}, '', - NOT EXISTS (SELECT 1 FROM users), + ${shouldCreateOwner}, CASE - WHEN NOT EXISTS (SELECT 1 FROM users) THEN ${MEMBER_ROLE.OWNER} + WHEN ${shouldCreateOwner} THEN ${MEMBER_ROLE.OWNER} ELSE ${MEMBER_ROLE.MEMBER} END ) @@ -258,6 +266,91 @@ export const createUser = async (input) => { return created } +/** + * 관리자 권한이 없을 때 환경 변수 기반 owner 계정을 생성하거나 기존 회원을 승격한다. + * @param {{ username: string, email: string, passwordHash: string }} input - 입력 + * @returns {Promise} 생성 또는 승격된 owner 회원 + */ +export const upsertBootstrapOwner = async (input) => { + const sql = requireSql() + + const rows = await sql.begin(async (tx) => { + await tx`LOCK TABLE users IN SHARE ROW EXCLUSIVE MODE` + + const privilegedRows = await tx` + SELECT id + FROM users + WHERE user_role = ANY(${PRIVILEGED_ROLES}) + LIMIT 1 + ` + + if (privilegedRows?.[0]) { + return [] + } + + const existingRows = await tx` + SELECT id + FROM users + WHERE lower(email) = lower(${input.email}) + LIMIT 1 + ` + + if (existingRows?.[0]) { + return tx` + UPDATE users + SET + password_hash = ${input.passwordHash}, + is_admin = true, + user_role = ${MEMBER_ROLE.OWNER}, + updated_at = now() + WHERE id = ${existingRows[0].id} + RETURNING + id, + username, + email, + password_hash AS "passwordHash", + avatar_url AS "avatarUrl", + is_admin AS "isAdmin", + user_role AS "role", + created_at AS "createdAt", + updated_at AS "updatedAt", + last_seen_at AS "lastSeenAt", + last_seen_ip AS "lastSeenIp", + previous_last_seen_at AS "previousLastSeenAt", + previous_last_seen_ip AS "previousLastSeenIp" + ` + } + + return tx` + INSERT INTO users (username, email, password_hash, avatar_url, is_admin, user_role) + VALUES ( + ${input.username}, + ${input.email}, + ${input.passwordHash}, + '', + true, + ${MEMBER_ROLE.OWNER} + ) + RETURNING + id, + username, + email, + password_hash AS "passwordHash", + avatar_url AS "avatarUrl", + is_admin AS "isAdmin", + user_role AS "role", + created_at AS "createdAt", + updated_at AS "updatedAt", + last_seen_at AS "lastSeenAt", + last_seen_ip AS "lastSeenIp", + previous_last_seen_at AS "previousLastSeenAt", + previous_last_seen_ip AS "previousLastSeenIp" + ` + }) + + return rows?.[0] || null +} + /** * 회원 최근 활동 정보를 기록한다. * @param {{ userId: string, ip: string }} input - 사용자 ID와 접속 IP @@ -618,20 +711,24 @@ export const getAdminUserByEmail = async (email) => { /** * 최초 관리자 등록 필요 여부를 확인한다. - * @returns {Promise<{ hasUsers: boolean, needsAdminSetup: boolean }>} 부트스트랩 상태 + * @returns {Promise<{ hasUsers: boolean, hasPrivilegedUsers: boolean, needsAdminSetup: boolean }>} 부트스트랩 상태 */ export const getMemberBootstrapState = async () => { const sql = requireSql() const rows = await sql` - SELECT COUNT(*)::int AS "userCount" + SELECT + COUNT(*)::int AS "userCount", + COUNT(*) FILTER (WHERE user_role = ANY(${PRIVILEGED_ROLES}))::int AS "privilegedUserCount" FROM users ` const userCount = Number(rows?.[0]?.userCount || 0) + const privilegedUserCount = Number(rows?.[0]?.privilegedUserCount || 0) return { hasUsers: userCount > 0, - needsAdminSetup: userCount === 0 + hasPrivilegedUsers: privilegedUserCount > 0, + needsAdminSetup: privilegedUserCount === 0 } } diff --git a/server/routes/admin/api/auth/login.post.js b/server/routes/admin/api/auth/login.post.js index 5950c97..db5ff25 100644 --- a/server/routes/admin/api/auth/login.post.js +++ b/server/routes/admin/api/auth/login.post.js @@ -2,7 +2,7 @@ import { z } from 'zod' import { createError, getRequestIP, readBody } from 'h3' import bcrypt from 'bcrypt' import { safeCompare, setAdminSession } from '../../../../utils/admin-auth' -import { createUser, getAdminUserByEmail, getMemberBootstrapState, touchUserActivity } from '../../../../repositories/member-repository' +import { getAdminUserByEmail, getMemberBootstrapState, touchUserActivity, upsertBootstrapOwner } from '../../../../repositories/member-repository' import { setMemberSession } from '../../../../utils/member-auth' const loginSchema = z.object({ @@ -40,16 +40,11 @@ const createBootstrapAdminUser = async (credentials) => { } const passwordHash = await bcrypt.hash(credentials.password, 12) - const created = await createUser({ + return upsertBootstrapOwner({ username: createBootstrapUsername(adminEmail), email: adminEmail, passwordHash }) - - return { - ...created, - passwordHash - } } /**