관리자 부트스트랩 복구 보강
This commit is contained in:
@@ -29,6 +29,7 @@ services:
|
|||||||
- "${DB_PORT:-43119}:5432"
|
- "${DB_PORT:-43119}:5432"
|
||||||
volumes:
|
volumes:
|
||||||
- sori-studio-postgres:/var/lib/postgresql/data
|
- 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
|
- ./db/migrations:/docker-entrypoint-initdb.d:ro
|
||||||
networks:
|
networks:
|
||||||
- sori-studio-network
|
- sori-studio-network
|
||||||
|
|||||||
@@ -1,5 +1,13 @@
|
|||||||
# 업데이트 요약
|
# 업데이트 요약
|
||||||
|
|
||||||
|
## v1.0.4
|
||||||
|
|
||||||
|
- owner/admin 계정이 없는 운영 DB에서도 환경 변수 관리자 계정으로 첫 owner를 생성하거나 기존 일반 회원을 승격할 수 있도록 보강.
|
||||||
|
|
||||||
|
## v1.0.3
|
||||||
|
|
||||||
|
- NAS에서 Postgres 초기 마이그레이션 디렉터리 권한 문제로 DB 컨테이너가 재시작될 때 확인할 배포 절차를 정리.
|
||||||
|
|
||||||
## v1.0.2
|
## v1.0.2
|
||||||
|
|
||||||
- 운영 DB 최초 상태에서 환경 변수 관리자 계정으로 첫 owner 계정을 만들고 로그인할 수 있도록 보강.
|
- 운영 DB 최초 상태에서 환경 변수 관리자 계정으로 첫 owner 계정을 만들고 로그인할 수 있도록 보강.
|
||||||
|
|||||||
@@ -129,7 +129,7 @@ cd sori.studio
|
|||||||
# .env.production은 Git에 올리지 않는 운영 전용 파일
|
# .env.production은 Git에 올리지 않는 운영 전용 파일
|
||||||
cp .env.example .env.production
|
cp .env.example .env.production
|
||||||
# .env.production 파일에 운영 DB 연결 정보, 운영 전용 랜덤 비밀번호, APP_PORT=43118 입력
|
# .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와 다른 긴 난수 문자열로 반드시 입력
|
# MEMBER_SESSION_SECRET은 ADMIN_PASSWORD와 다른 긴 난수 문자열로 반드시 입력
|
||||||
# Docker 네트워크 대역이 NAS 기존 컨테이너와 겹치면 DOCKER_SUBNET을 다른 사설 대역으로 변경
|
# Docker 네트워크 대역이 NAS 기존 컨테이너와 겹치면 DOCKER_SUBNET을 다른 사설 대역으로 변경
|
||||||
# Docker 내부 앱에서 PostgreSQL에 접근할 때는 sori-studio-db:5432 사용
|
# 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`
|
- NAS Docker 예시: `postgres://sori_studio:비밀번호@sori-studio-db:5432/sori_studio`
|
||||||
- `.env.example`에는 실제 비밀번호나 개인 이메일을 기록하지 않음
|
- `.env.example`에는 실제 비밀번호나 개인 이메일을 기록하지 않음
|
||||||
- 개발/운영 DB 비밀번호와 관리자 비밀번호는 서로 다른 랜덤 값을 사용
|
- 개발/운영 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`은 관리자 비밀번호와 분리된 긴 난수 문자열을 사용
|
- 회원 세션 서명용 `MEMBER_SESSION_SECRET`은 관리자 비밀번호와 분리된 긴 난수 문자열을 사용
|
||||||
- 개발 DB와 운영 DB는 반드시 별도 인스턴스 또는 별도 데이터베이스로 분리
|
- 개발 DB와 운영 DB는 반드시 별도 인스턴스 또는 별도 데이터베이스로 분리
|
||||||
- 운영 DB는 로컬 개발 서버에서 직접 연결하지 않음
|
- 운영 DB는 로컬 개발 서버에서 직접 연결하지 않음
|
||||||
@@ -263,6 +263,60 @@ git diff -- . ':!package-lock.json'
|
|||||||
- `.env.development`, `.env.production`이 변경 목록에 포함되지 않음
|
- `.env.development`, `.env.production`이 변경 목록에 포함되지 않음
|
||||||
- 문서와 코드 diff에 실제 DB 비밀번호, 관리자 비밀번호, 운영 접속 주소가 포함되지 않음
|
- 문서와 코드 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로 제공한다.
|
- 관리자 글쓰기에서 업로드한 이미지는 `/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
|
## 2026-05-14 v1.0.1
|
||||||
|
|
||||||
### Docker Compose 전용 네트워크 대역 명시
|
### Docker Compose 전용 네트워크 대역 명시
|
||||||
|
|||||||
@@ -567,8 +567,8 @@ components/content/
|
|||||||
|
|
||||||
- 관리자 인증은 `users.is_admin=true` 회원의 이메일/비밀번호(bcrypt 해시) 기반으로 처리한다.
|
- 관리자 인증은 `users.is_admin=true` 회원의 이메일/비밀번호(bcrypt 해시) 기반으로 처리한다.
|
||||||
- DB에 사용자가 없으면 `/signup`에서 관리자 등록 모드(관리자 이름/이메일/비밀번호/비밀번호 확인)로 첫 계정을 생성한다.
|
- DB에 사용자가 없으면 `/signup`에서 관리자 등록 모드(관리자 이름/이메일/비밀번호/비밀번호 확인)로 첫 계정을 생성한다.
|
||||||
- DB에 사용자가 없는 최초 상태에서 `/admin/login`에 `ADMIN_EMAIL`/`ADMIN_PASSWORD`와 같은 값을 입력하면 첫 owner 계정을 DB에 생성한 뒤 로그인한다. 이미 사용자가 있으면 환경 변수 계정으로 우회 로그인하지 않고 DB의 관리자 계정만 사용한다.
|
- DB에 owner/admin 계정이 없는 최초 상태에서 `/admin/login`에 `ADMIN_EMAIL`/`ADMIN_PASSWORD`와 같은 값을 입력하면 첫 owner 계정을 DB에 생성한 뒤 로그인한다. 같은 이메일의 일반 회원이 이미 있으면 해당 회원을 owner로 승격하고 비밀번호를 `ADMIN_PASSWORD` 기준 bcrypt 해시로 갱신한다. 이미 owner/admin 계정이 있으면 환경 변수 계정으로 우회 로그인하지 않고 DB의 관리자 계정만 사용한다.
|
||||||
- 첫 계정 생성 시 `is_admin=true`, `user_role=owner`를 자동 부여한다. 최초 가입 판정은 동시 요청에서 중복 owner가 생기지 않도록 `users` 테이블 잠금 안에서 처리한다.
|
- 최초 owner 생성 시 `is_admin=true`, `user_role=owner`를 자동 부여한다. 최초 관리자 판정은 동시 요청에서 중복 owner가 생기지 않도록 `users` 테이블 잠금 안에서 처리한다.
|
||||||
- 관리자 로그인 성공 시 httpOnly 세션 쿠키를 `/admin` 경로에 설정한다.
|
- 관리자 로그인 성공 시 httpOnly 세션 쿠키를 `/admin` 경로에 설정한다.
|
||||||
- `/admin/login` 로그인 제출 버튼은 이메일·비밀번호가 모두 입력된 경우에만 활성화한다.
|
- `/admin/login` 로그인 제출 버튼은 이메일·비밀번호가 모두 입력된 경우에만 활성화한다.
|
||||||
- 관리자 로그인 성공 시 관리자/회원 세션 쿠키를 함께 설정하고 `users.previous_last_seen_at`/`previous_last_seen_ip`에 직전 로그인 값을 보존한 뒤 `last_seen_at`/`last_seen_ip`를 현재 로그인으로 갱신한다.
|
- 관리자 로그인 성공 시 관리자/회원 세션 쿠키를 함께 설정하고 `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
|
## v1.0.2
|
||||||
|
|
||||||
- 운영 DB가 비어 있을 때 `/admin/login`에서 `ADMIN_EMAIL`/`ADMIN_PASSWORD`로 최초 owner 계정을 생성하도록 수정.
|
- 운영 DB가 비어 있을 때 `/admin/login`에서 `ADMIN_EMAIL`/`ADMIN_PASSWORD`로 최초 owner 계정을 생성하도록 수정.
|
||||||
- 배포 문서의 `.env.production` 생성 명령과 최초 관리자 생성 기준 정리.
|
- 배포 문서의 `.env.production` 생성 명령과 최초 관리자 생성 기준 정리.
|
||||||
|
- 배포 문서에 Docker 컨테이너 `Restarting` 루프 시 로그 확인 절차 추가.
|
||||||
- 패키지 버전 `1.0.2`로 갱신.
|
- 패키지 버전 `1.0.2`로 갱신.
|
||||||
|
|
||||||
## v1.0.1
|
## v1.0.1
|
||||||
|
|||||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "sori.studio",
|
"name": "sori.studio",
|
||||||
"version": "1.0.2",
|
"version": "1.0.4",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "sori.studio",
|
"name": "sori.studio",
|
||||||
"version": "1.0.2",
|
"version": "1.0.4",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@nuxtjs/tailwindcss": "^6.14.0",
|
"@nuxtjs/tailwindcss": "^6.14.0",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "sori.studio",
|
"name": "sori.studio",
|
||||||
"version": "1.0.2",
|
"version": "1.0.4",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"imports": {
|
"imports": {
|
||||||
|
|||||||
@@ -217,6 +217,14 @@ export const createUser = async (input) => {
|
|||||||
|
|
||||||
const rows = await sql.begin(async (tx) => {
|
const rows = await sql.begin(async (tx) => {
|
||||||
await tx`LOCK TABLE users IN SHARE ROW EXCLUSIVE MODE`
|
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`
|
return tx`
|
||||||
INSERT INTO users (username, email, password_hash, avatar_url, is_admin, user_role)
|
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.email},
|
||||||
${input.passwordHash},
|
${input.passwordHash},
|
||||||
'',
|
'',
|
||||||
NOT EXISTS (SELECT 1 FROM users),
|
${shouldCreateOwner},
|
||||||
CASE
|
CASE
|
||||||
WHEN NOT EXISTS (SELECT 1 FROM users) THEN ${MEMBER_ROLE.OWNER}
|
WHEN ${shouldCreateOwner} THEN ${MEMBER_ROLE.OWNER}
|
||||||
ELSE ${MEMBER_ROLE.MEMBER}
|
ELSE ${MEMBER_ROLE.MEMBER}
|
||||||
END
|
END
|
||||||
)
|
)
|
||||||
@@ -258,6 +266,91 @@ export const createUser = async (input) => {
|
|||||||
return created
|
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
|
* @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 () => {
|
export const getMemberBootstrapState = async () => {
|
||||||
const sql = requireSql()
|
const sql = requireSql()
|
||||||
const rows = await sql`
|
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
|
FROM users
|
||||||
`
|
`
|
||||||
|
|
||||||
const userCount = Number(rows?.[0]?.userCount || 0)
|
const userCount = Number(rows?.[0]?.userCount || 0)
|
||||||
|
const privilegedUserCount = Number(rows?.[0]?.privilegedUserCount || 0)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
hasUsers: userCount > 0,
|
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 { createError, getRequestIP, readBody } from 'h3'
|
||||||
import bcrypt from 'bcrypt'
|
import bcrypt from 'bcrypt'
|
||||||
import { safeCompare, setAdminSession } from '../../../../utils/admin-auth'
|
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'
|
import { setMemberSession } from '../../../../utils/member-auth'
|
||||||
|
|
||||||
const loginSchema = z.object({
|
const loginSchema = z.object({
|
||||||
@@ -40,16 +40,11 @@ const createBootstrapAdminUser = async (credentials) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const passwordHash = await bcrypt.hash(credentials.password, 12)
|
const passwordHash = await bcrypt.hash(credentials.password, 12)
|
||||||
const created = await createUser({
|
return upsertBootstrapOwner({
|
||||||
username: createBootstrapUsername(adminEmail),
|
username: createBootstrapUsername(adminEmail),
|
||||||
email: adminEmail,
|
email: adminEmail,
|
||||||
passwordHash
|
passwordHash
|
||||||
})
|
})
|
||||||
|
|
||||||
return {
|
|
||||||
...created,
|
|
||||||
passwordHash
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user