관리자 부트스트랩 복구 보강
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
# 업데이트 요약
|
||||
|
||||
## v1.0.4
|
||||
|
||||
- owner/admin 계정이 없는 운영 DB에서도 환경 변수 관리자 계정으로 첫 owner를 생성하거나 기존 일반 회원을 승격할 수 있도록 보강.
|
||||
|
||||
## v1.0.3
|
||||
|
||||
- NAS에서 Postgres 초기 마이그레이션 디렉터리 권한 문제로 DB 컨테이너가 재시작될 때 확인할 배포 절차를 정리.
|
||||
|
||||
## v1.0.2
|
||||
|
||||
- 운영 DB 최초 상태에서 환경 변수 관리자 계정으로 첫 owner 계정을 만들고 로그인할 수 있도록 보강.
|
||||
|
||||
@@ -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로 제공한다.
|
||||
|
||||
@@ -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 전용 네트워크 대역 명시
|
||||
|
||||
@@ -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`를 현재 로그인으로 갱신한다.
|
||||
|
||||
@@ -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
|
||||
|
||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "sori.studio",
|
||||
"version": "1.0.2",
|
||||
"version": "1.0.4",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"imports": {
|
||||
|
||||
@@ -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<MemberUser | null>} 생성 또는 승격된 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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user