-
-
- {{ post.title }}
-
-
- - {{ post.excerpt }} -
-From b490d5b90f9b22a19cacadeabdad82c613ed8cb4 Mon Sep 17 00:00:00 2001
From: zenn
+ 지원하지 않는 북마크 URL입니다. +
diff --git a/components/content/ProseEmbed.vue b/components/content/ProseEmbed.vue index 4266dc2..fe449f9 100644 --- a/components/content/ProseEmbed.vue +++ b/components/content/ProseEmbed.vue @@ -63,6 +63,22 @@ const youtubeId = computed(() => getYouTubeId(props.url)) const youtubeEmbedUrl = computed(() => youtubeId.value ? `https://www.youtube.com/embed/${youtubeId.value}` : '') const tweetId = computed(() => getTweetId(props.url)) +/** + * 외부 링크로 열어도 되는 URL인지 확인한다. + * @param {string} value - 검사할 URL + * @returns {boolean} 허용 여부 + */ +const isSafeExternalUrl = (value) => { + try { + const parsedUrl = new URL(value) + return ['http:', 'https:'].includes(parsedUrl.protocol) + } catch { + return false + } +} + +const safeExternalUrl = computed(() => isSafeExternalUrl(props.url) ? props.url : '') + /** * Twitter 공식 embed iframe 주소 * @returns {string} @@ -98,13 +114,16 @@ const tweetEmbedUrl = computed(() => { loading="lazy" /> {{ url }} + diff --git a/db/migrations/005_add_navigation_items.sql b/db/migrations/005_add_navigation_items.sql index deb90ef..86004e1 100644 --- a/db/migrations/005_add_navigation_items.sql +++ b/db/migrations/005_add_navigation_items.sql @@ -15,15 +15,24 @@ CREATE INDEX IF NOT EXISTS navigation_items_location_sort_order_idx ON navigation_items (location, sort_order ASC, label ASC); INSERT INTO navigation_items (label, url, location, sort_order, is_visible) -VALUES - ('Home pages', '/', 'primary', 10, true), - ('Tags', '/tags', 'primary', 20, true), - ('Authors', '/pages/about', 'primary', 30, true), - ('Style', '/post/hello-sori-studio', 'primary', 40, true), - ('Post types', '/post/custom-writing-tool', 'primary', 50, true), - ('Members', '/pages/contact', 'primary', 60, true), - ('Landing pages', '/pages/projects', 'primary', 70, true), - ('Portal', '/pages/links', 'footer', 10, true), - ('Docs', '/pages/about', 'footer', 20, true), - ('Projects', '/pages/projects', 'footer', 30, true) -ON CONFLICT DO NOTHING; +SELECT seed.label, seed.url, seed.location, seed.sort_order, seed.is_visible +FROM ( + VALUES + ('Home pages', '/', 'primary', 10, true), + ('Tags', '/tags', 'primary', 20, true), + ('Authors', '/pages/about', 'primary', 30, true), + ('Style', '/post/hello-sori-studio', 'primary', 40, true), + ('Post types', '/post/custom-writing-tool', 'primary', 50, true), + ('Members', '/pages/contact', 'primary', 60, true), + ('Landing pages', '/pages/projects', 'primary', 70, true), + ('Portal', '/pages/links', 'footer', 10, true), + ('Docs', '/pages/about', 'footer', 20, true), + ('Projects', '/pages/projects', 'footer', 30, true) +) AS seed(label, url, location, sort_order, is_visible) +WHERE NOT EXISTS ( + SELECT 1 + FROM navigation_items existing + WHERE existing.location = seed.location + AND existing.label = seed.label + AND existing.url = seed.url +); diff --git a/db/migrations/019_dedupe_navigation_items.sql b/db/migrations/019_dedupe_navigation_items.sql new file mode 100644 index 0000000..ebb4ac0 --- /dev/null +++ b/db/migrations/019_dedupe_navigation_items.sql @@ -0,0 +1,23 @@ +-- 반복 마이그레이션 실행으로 생긴 동일 위치·상위·라벨·URL 메뉴 중복 정리 +WITH ranked_navigation AS ( + SELECT + id, + row_number() OVER ( + PARTITION BY location, COALESCE(parent_id::text, ''), label, url + ORDER BY + CASE WHEN is_folder THEN 0 ELSE 1 END, + sort_order ASC, + created_at ASC, + id ASC + ) AS row_rank + FROM navigation_items +) +DELETE FROM navigation_items +WHERE id IN ( + SELECT id + FROM ranked_navigation + WHERE row_rank > 1 +); + +CREATE UNIQUE INDEX IF NOT EXISTS navigation_items_location_parent_label_url_unique_idx + ON navigation_items (location, COALESCE(parent_id::text, ''), label, url); diff --git a/docs/history.md b/docs/history.md index 7c04b5f..9d8217d 100644 --- a/docs/history.md +++ b/docs/history.md @@ -1,5 +1,33 @@ # 의사결정 이력 +## 2026-05-13 v0.0.108 + +### 관리자 캔버스 높이와 사이드바 폭 정리 + +관리자 개별 페이지가 각자 `section` 배경과 여백을 책임하면 콘텐츠가 짧은 화면에서 우측 배경이 끊겨 보인다. Ghost 관리자처럼 레이아웃의 우측 캔버스가 기본 화면 높이와 배경을 먼저 책임지게 하고, 사이드바는 320px 고정 폭으로 맞춰 목록·설정 화면의 기준 여백을 더 여유롭게 잡는다. + +## 2026-05-13 v0.0.107 + +### 관리자 사이드바 Ghost형 톤 전환 + +기능 구현이 어느 정도 갖춰진 뒤에도 관리자 첫 화면의 어두운 사이드바는 오래된 CMS 느낌을 강하게 만들었다. Ghost 관리자처럼 밝은 바탕, 낮은 대비의 활성 행, 아이콘+라벨 내비게이션으로 바꾸고, 게시글 행 우측에 새 글 작성 `+` 버튼을 두어 목록을 거치지 않고 바로 작성으로 들어가게 했다. + +## 2026-05-12 v0.0.105 + +### 네비게이션 기본 시드 중복 방지 + +`017_navigation_hierarchy.sql`에서 메뉴 계층 지원을 위해 `(location,label,url)` 유니크 제약을 제거한 뒤, 기존 `005_add_navigation_items.sql`의 `ON CONFLICT DO NOTHING`은 더 이상 동일 라벨·URL 기본 메뉴 중복을 막지 못했다. 개발 DB 마이그레이션은 전체 SQL 파일을 반복 실행하므로 기본 메뉴가 새 UUID로 누적될 수 있어, 시드 삽입을 `NOT EXISTS` 조건으로 바꾸고 `019_dedupe_navigation_items.sql`에서 기존 중복을 정리한 뒤 표현식 유니크 인덱스로 재발을 막는다. + +## 2026-05-12 v0.0.104 + +### 관리자 권한 재검증과 마지막 소유자 보호 + +관리자 세션 쿠키는 서명과 만료만으로는 권한 변경·회원 탈퇴 이후 상태를 반영하지 못한다. `/admin/api/*` 요청마다 DB의 현재 `owner`/`admin` 권한을 다시 확인하는 서버 미들웨어를 추가해, 권한이 내려가거나 계정이 삭제된 세션은 즉시 차단한다. 회원 탈퇴는 마지막 `owner`를 없애지 못하도록 막고, 탈퇴 시 관리자 쿠키도 함께 정리한다. + +### OTP 발송 실패와 초기 owner 판정 안정화 + +OTP는 메일 발송에 실패했는데 DB 챌린지만 남으면 사용자가 코드를 받지 못한 채 쿨다운에 걸릴 수 있다. 새 챌린지는 먼저 만들되 발송 실패 시 즉시 삭제하고, 발송 성공 후 이전 pending 챌린지를 정리한다. 첫 회원 생성은 동시에 들어온 요청이 모두 owner로 판정되지 않도록 `users` 테이블 잠금 안에서 처리한다. + ## 2026-05-12 v0.0.103 ### map.md와 관리자 메뉴 화면 동기화 diff --git a/docs/map.md b/docs/map.md index 406e4a3..e6feda8 100644 --- a/docs/map.md +++ b/docs/map.md @@ -8,7 +8,7 @@ |------|------| | layouts/default.vue | 메인·목록·태그 — 3열 `gap`+중앙 `1fr`, `site-main` `max-w-[720px]`, 모바일 슬라이드 메뉴 | | layouts/post.vue | 개별 게시물 — `default`와 동일 | -| layouts/admin.vue | 관리자 전체, 글 작성/수정 화면의 전체 화면 편집 모드와 문서 스크롤 잠금 | +| layouts/admin.vue | 관리자 전체, 320px 밝은 Ghost형 사이드바, 우측 전체 높이 캔버스, 게시글 `+` 새 글 진입, 글 작성/수정 화면의 전체 화면 편집 모드와 문서 스크롤 잠금 | | layouts/page.vue | 고정 페이지 전체 화면 | ## Composables @@ -30,6 +30,12 @@ |------|------| | modules/nuxt-ssr-paths-write.mjs | `paths.mjs`를 `.nuxt`에 기록해 Node가 `#internal/nuxt/paths`를 해석할 수 있게 함 | +## 서버 미들웨어 + +| 파일 | 용도 | +|------|------| +| server/middleware/admin-api-session.js | `/admin/api/*` 요청마다 관리자 세션과 현재 DB 권한(`owner`/`admin`) 재확인 | + ## 사이트 컴포넌트 | 파일 | 화면 위치 | @@ -72,10 +78,10 @@ | components/content/ProseAudio.vue | 오디오 | | components/content/ProseFile.vue | 파일 | | components/content/ProseProduct.vue | 상품 카드 | -| components/content/ProseBookmark.vue | 본문 북마크 카드(썸네일·도메인·외부 링크) | +| components/content/ProseBookmark.vue | 본문 북마크 카드(썸네일·도메인·`http(s)` 외부 링크) | | components/content/ProseSignup.vue | 본문 회원가입·뉴스레터 CTA 카드 | | components/content/ProseHeaderCard.vue | 헤더 카드 | -| components/content/ProseEmbed.vue | YouTube·Twitter/X iframe 임베드, 기타 외부 링크 | +| components/content/ProseEmbed.vue | YouTube·Twitter/X iframe 임베드, 기타 `http(s)` 외부 링크 | ## 관리자 페이지 @@ -108,7 +114,7 @@ | pages/post/[slug].vue | 블로그 글 상세, 게시물 SEO/OG 메타 출력, 공유 모달(X/Bluesky/Facebook/LinkedIn/Email/링크복사), 회원 댓글 섹션 | | pages/tags/index.vue | 태그 전체 목록, 중앙 히어로와 3열 태그 카드, 좌측 컬러 보더/hover 오버레이 | | pages/tags/[slug].vue | `/tag/:slug` 리다이렉트 | -| pages/tag/[slug].vue | 태그별 글 목록, 상단 태그 헤더 + 리스트형 게시물 카드 | +| pages/tag/[slug].vue | 태그별 글 목록, 상단 태그 헤더 + 공통 섹션 패딩을 쓰는 리스트형 게시물 카드 | | pages/pages/[slug].vue | 고정 페이지 상세 | | pages/signup.vue | 회원가입 3단계, `emailOtpConfigured`일 때 이메일 OTP·인증번호 받기, `POST /api/auth/email-otp/request` | | pages/signin.vue | 로그인, `/forgot-password` 링크 | @@ -129,7 +135,7 @@ | server/api/navigation.get.js | 공개 네비게이션 API | | server/api/auth/signup.post.js | 회원 가입 API(선택 `emailOtp`, Resend 설정 시 일반 가입에 필수) | | server/api/auth/bootstrap-status.get.js | 최초 관리자 여부 + `emailOtpConfigured` | -| server/api/auth/email-otp/request.post.js | Resend로 OTP 발송(`signup` / `password_reset`) | +| server/api/auth/email-otp/request.post.js | Resend로 OTP 발송(`signup` / `password_reset`), 발송 실패 챌린지 삭제 | | server/api/auth/password-reset/confirm.post.js | OTP 검증 후 비밀번호 재설정 | | server/repositories/email-otp-repository.js | `email_otp_challenges` CRUD·검증 | | server/utils/email-otp.js | OTP 생성·해시 | @@ -143,7 +149,7 @@ | server/api/auth/avatar.delete.js | 회원 썸네일 삭제 API | | server/api/auth/check-username.get.js | 닉네임 중복 확인 API | | server/api/auth/password.put.js | 회원 비밀번호 변경 API | -| server/api/auth/account.delete.js | 회원 탈퇴 API | +| server/api/auth/account.delete.js | 회원 탈퇴 API(마지막 `owner` 보호, 관리자 세션 함께 정리) | | server/api/posts/[slug]/comments.get.js | 게시물 댓글 목록 조회 API | | server/api/posts/[slug]/comments.post.js | 게시물 댓글 작성 API | | server/api/posts/[slug]/comments/[commentId]/like.post.js | 댓글 좋아요 토글 API | @@ -206,6 +212,7 @@ | db/migrations/003_add_tag_display_fields.sql | 태그 표시 순서와 색상 필드 추가 | | db/migrations/004_add_site_settings.sql | 사이트 설정 테이블 추가 | | db/migrations/017_navigation_hierarchy.sql | `navigation_items`에 `parent_id`·`is_folder`, `(location,label,url)` 유니크 제거 | +| db/migrations/019_dedupe_navigation_items.sql | 반복 마이그레이션으로 생긴 네비게이션 중복 행 정리 및 중복 방지 인덱스 | | db/migrations/005_add_navigation_items.sql | 네비게이션 항목 테이블 추가 | | db/migrations/006_add_media_metadata.sql | 미디어 메타데이터 테이블 추가 | | db/migrations/007_add_media_folders.sql | 미디어 폴더 테이블 추가 | diff --git a/docs/spec.md b/docs/spec.md index 59eac5f..5e2dbb6 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -86,6 +86,7 @@ - `/post/:slug` - 개별 게시물 상세 - `/tags` - 태그 전체 목록 - `/tag/:slug` - 태그별 게시물 목록 +- `/tag/:slug` 화면의 헤더와 게시물 목록은 공개 목록 공통 섹션 패딩(`site-section-header`, `site-section-body`)을 사용해 메인 컬럼 좌우 여백을 다른 목록 화면과 맞춘다. - `/signup` - 회원가입(3단계: 환영/입력/이메일 확인) - `/signin` - 로그인 - `/settings` - 회원 설정(썸네일 미리보기/변경/제거, 닉네임, 비밀번호, 회원 탈퇴) @@ -110,6 +111,14 @@ layouts/ └── admin.vue # 관리자 화면 ``` +### 관리자 레이아웃 + +- 관리자 사이드바는 밝은 Ghost형 톤을 기준으로 하며, 메뉴 행은 아이콘+라벨 구조를 사용한다. +- 관리자 사이드바는 데스크톱에서 320px 너비를 사용한다. +- 관리자 우측 캔버스는 기본 `min-h-screen`과 `bg-paper`를 유지해 콘텐츠 높이가 짧아도 배경이 화면 전체를 채운다. 일반 관리자 화면은 레이아웃 레벨에서 넉넉한 외부 여백을 둔다. +- 게시글 메뉴 라벨은 `게시글`로 표시하고, 우측 `+` 아이콘은 `/admin/posts/new`로 바로 이동한다. +- 게시글·페이지·태그·미디어·멤버·설정 메뉴는 전용 SVG 아이콘을 사용한다. + --- ## 컴포넌트 구조 @@ -351,7 +360,7 @@ components/content/ - `GET /api/navigation` - 공개 네비게이션(`primary`는 트리·`footer`는 평면, 상세는 위 메뉴/네비게이션 절) - `POST /api/auth/signup` - 회원 가입. 본문: `username`, `email`, `password`, 선택 `emailOtp`(6자리 숫자). **`GET /api/auth/bootstrap-status`의 `emailOtpConfigured`가 true이고 `needsAdminSetup`이 false일 때**(서버에 Resend·pepper 설정됨) `emailOtp`가 필수이며, 직전에 `POST /api/auth/email-otp/request`로 같은 이메일·`purpose: "signup"` 발송분과 일치해야 한다. 최초 관리자 부트스트랩(`needsAdminSetup: true`)에서는 `emailOtp` 없이 가입 가능. 이메일은 저장 시 소문자로 정규화한다. - `GET /api/auth/bootstrap-status` - 최초 관리자 등록 필요 여부(`hasUsers`, `needsAdminSetup`) 및 **이메일 OTP 사용 가능 여부** `emailOtpConfigured`(서버 `RESEND_API_KEY`·`RESEND_FROM_EMAIL` 및 pepper: 비어 있지 않은 `EMAIL_OTP_PEPPER` **또는** `MEMBER_SESSION_SECRET`)를 반환한다. pepper는 OTP 6자리를 DB에 해시해 둘 때만 쓰이는 서버 비밀(긴 난문자열 권장)이다. -- `POST /api/auth/email-otp/request` - 본문: `email`, `purpose`(`"signup"` | `"password_reset"`). Resend로 6자리 OTP 메일을 보낸다. 미설정 시 503. `signup`은 이미 가입된 이메일이면 409, 최초 관리자 단계에서는 400. `password_reset`은 존재하지 않는 이메일도 동일한 성공 메시지를 반환하며(이메일 미발송), 요청 빈도 제한을 위해 DB에 더미 챌린지를 남길 수 있다. 55초 쿨다운·시간당 5회 한도. DB 테이블 `email_otp_challenges`(마이그레이션 `018_email_otp_challenges.sql`) 필요. +- `POST /api/auth/email-otp/request` - 본문: `email`, `purpose`(`"signup"` | `"password_reset"`). Resend로 6자리 OTP 메일을 보낸다. 미설정 시 503. `signup`은 이미 가입된 이메일이면 409, 최초 관리자 단계에서는 400. `password_reset`은 존재하지 않는 이메일도 동일한 성공 메시지를 반환하며(이메일 미발송), 요청 빈도 제한을 위해 DB에 더미 챌린지를 남길 수 있다. 55초 쿨다운·시간당 5회 한도. 실제 메일 발송이 실패하면 방금 생성한 챌린지는 즉시 삭제한다. 발송 성공 후 같은 이메일·용도의 이전 pending 챌린지는 무효화한다. DB 테이블 `email_otp_challenges`(마이그레이션 `018_email_otp_challenges.sql`) 필요. - `POST /api/auth/password-reset/confirm` - 본문: `email`, `code`(6자리), `newPassword`(8~32자). `password_reset` OTP 검증·소진 후 해당 이메일 회원 비밀번호를 갱신한다. - `POST /api/auth/login` - 회원 로그인 - `GET /api/auth/me` - 현재 회원 세션 조회 @@ -362,7 +371,7 @@ components/content/ - `DELETE /api/auth/avatar` - 회원 썸네일 제거 - `GET /api/auth/check-username?username=` - 닉네임 중복 확인 - `PUT /api/auth/password` - 회원 비밀번호 변경 -- `DELETE /api/auth/account` - 회원 탈퇴 +- `DELETE /api/auth/account` - 회원 탈퇴. 마지막 `owner` 계정은 삭제할 수 없으며, 탈퇴 성공 시 회원 세션과 관리자 세션 쿠키를 함께 정리한다. > 회원 썸네일 이미지는 `/uploads/members/avatars/YYYY/MM` 경로로 저장하며, 업로드 시 WebP로 변환하고 `AVATAR_MIN_WIDTH`/`AVATAR_MIN_HEIGHT` 최소 해상도 검증 후 중앙 기준 1:1 정사각형으로 크롭한다. > 최종 저장 크기는 `AVATAR_MAX_WIDTH`, `AVATAR_MAX_HEIGHT` 중 작은 값을 사용해 `N x N` 정사각형으로 맞춘다. @@ -520,6 +529,7 @@ components/content/ - 네비게이션은 `navigation_items` 테이블로 관리한다. - 컬럼: `parent_id`(nullable, self FK, `ON DELETE CASCADE`), `is_folder`(boolean, **자식이 있는 상단 항목이면 저장 시 서버가 true로 설정**), `location`(`primary`|`footer`), `sort_order`, `label`, `url`, `is_visible`(저장 시 항상 true로 정리). `(location, label, url)` 유니크 제약은 제거되었다. +- 동일 `location`·`parent_id`·`label`·`url` 조합은 중복 저장하지 않는다. 반복 마이그레이션으로 생긴 중복은 `019_dedupe_navigation_items.sql`이 정리하고, 표현식 유니크 인덱스로 재발을 막는다. - `GET /api/navigation` 응답: `primary`는 **트리**(각 노드에 `children` 배열이 있을 수 있음, 리프는 `children` 없음). 노드 필드: `id`, `label`, `url`, `isFolder`, `isVisible`. 트리 조립 시 **동일 `id`는 한 번만 반영**하고, DB 평면 행에 `parent_id`가 있어 자식으로 붙은 항목은 **루트에 두지 않는다**(레거시·중복 행으로 인한 사이드바 이중 표시 방지). `footer`는 **평면** 배열(`parent_id` 없음)이며 `id`, `label`, `url`, `isVisible`만 내려간다. - `PUT /admin/api/navigation` 요청 본문의 각 항목은 반드시 `id`(UUID)를 포함한다. 저장 시 `sort_order`는 서버가 위치별 트리 DFS 순으로 다시 부여한다. `parent_id`는 `primary`에서만 허용되며 `footer` 항목은 항상 루트다. `is_visible`·`is_folder`는 요청값과 무관하게 서버에서 **항상 표시·자식 유무 기준 폴더**로 덮어쓴다. - URL은 `/`로 시작하는 내부 경로, `http(s)://` 외부 URL, 폴더 전용 자리 표시 `#`를 허용한다. @@ -532,7 +542,7 @@ components/content/ - 관리자 인증은 `users.is_admin=true` 회원의 이메일/비밀번호(bcrypt 해시) 기반으로 처리한다. - DB에 사용자가 없으면 `/signup`에서 관리자 등록 모드(관리자 이름/이메일/비밀번호/비밀번호 확인)로 첫 계정을 생성한다. -- 첫 계정 생성 시 `is_admin=true`, `user_role=owner`를 자동 부여한다. +- 첫 계정 생성 시 `is_admin=true`, `user_role=owner`를 자동 부여한다. 최초 가입 판정은 동시 요청에서 중복 owner가 생기지 않도록 `users` 테이블 잠금 안에서 처리한다. - 관리자 로그인 성공 시 httpOnly 세션 쿠키를 `/admin` 경로에 설정한다. - `/admin/login` 로그인 제출 버튼은 이메일·비밀번호가 모두 입력된 경우에만 활성화한다. - 관리자 로그인 성공 시 관리자/회원 세션 쿠키를 함께 설정해 관리자 화면에서도 프로필(썸네일, 이름) 수정 API를 공통으로 사용한다. @@ -540,6 +550,7 @@ components/content/ - 관리자 멤버 화면에서 권한은 `소유자(owner)`, `관리자(admin)`, `멤버(member)` 3단계로 표시하고 변경할 수 있다. - 관리자 페이지 접근은 `/admin/api/auth/me` 확인 후 허용한다. - 관리자 세션 토큰은 `ADMIN_PASSWORD` 기반 HMAC 서명으로 검증한다. +- `/admin/api/auth/login`, `/admin/api/auth/logout`을 제외한 관리자 API 요청은 서버 미들웨어에서 세션 사용자 ID의 현재 DB 권한이 `owner` 또는 `admin`인지 다시 확인한다. 권한 변경이나 계정 삭제 이후 남아 있는 기존 관리자 쿠키는 이 단계에서 차단한다. ### 회원 인증 diff --git a/docs/update.md b/docs/update.md index 8487e7c..ed24b2f 100644 --- a/docs/update.md +++ b/docs/update.md @@ -1,5 +1,41 @@ # 업데이트 이력 +## v0.0.108 + +- 관리자 사이드바 너비를 Ghost 기준 320px로 조정. +- 관리자 우측 캔버스가 기본 화면 높이와 배경색을 유지하도록 `admin-layout__main` 배경·여백 수정. +- 관리자 사이드바 페이지·미디어·설정 아이콘 추가. +- 패키지 버전 `0.0.108`로 갱신. + +## v0.0.107 + +- 관리자 사이드바를 밝은 Ghost형 톤으로 조정. +- 관리자 `글` 메뉴명을 `게시글`로 변경하고 게시글·태그·멤버 메뉴 아이콘 추가. +- 게시글 메뉴 우측 `+` 버튼으로 `/admin/posts/new` 바로 진입 추가. +- 패키지 버전 `0.0.107`로 갱신. + +## v0.0.106 + +- 태그 상세(`/tag/:slug`) 헤더와 게시물 목록을 공통 `site-section-header`·`site-section-body` 패딩 구조로 맞춤. +- 태그 상세 게시물 목록의 중복 구분선을 정리. +- 패키지 버전 `0.0.106`으로 갱신. + +## v0.0.105 + +- `005_add_navigation_items.sql`: `(location,label,url)` 유니크 제약 제거 후에도 기본 메뉴가 재삽입되지 않도록 `NOT EXISTS` 기반 시드로 수정. +- `019_dedupe_navigation_items.sql`: 반복 마이그레이션 실행으로 생긴 네비게이션 중복 행 정리 및 중복 방지 인덱스 추가. +- 로컬 개발 DB 네비게이션 중복 행 정리. +- 패키지 버전 `0.0.105`로 갱신. + +## v0.0.104 + +- 관리자 API 요청마다 현재 DB 권한 재확인 미들웨어 추가. +- 회원 탈퇴 시 마지막 `owner` 계정 삭제 차단 및 관리자 세션 쿠키 정리. +- 최초 회원 생성 시 `users` 테이블 잠금으로 동시 가입 owner 판정 안정화. +- 이메일 OTP 발송 실패 시 방금 만든 챌린지 삭제, 발송 성공 후 이전 pending 챌린지 정리. +- 본문 북마크·임베드 외부 링크를 `http(s)` URL로 제한. +- 패키지 버전 `0.0.104`로 갱신. + ## v0.0.103 - `docs/map.md`: 관리자 메뉴 관리 행 설명에서 제거된 마이그레이션 안내 문구 반영. diff --git a/layouts/admin.vue b/layouts/admin.vue index a5e6de2..2c62e1c 100644 --- a/layouts/admin.vue +++ b/layouts/admin.vue @@ -6,6 +6,13 @@ const isPostEditorRoute = computed(() => route.path === '/admin/posts/new' const editorDocumentClass = 'admin-post-editor-document' +/** + * 관리자 내비게이션 활성 경로 확인 + * @param {string} path - 확인할 경로 + * @returns {boolean} 활성 여부 + */ +const isAdminNavActive = (path) => route.path === path || route.path.startsWith(`${path}/`) + /** * 글쓰기 전체 화면 문서 스크롤 잠금 적용 * @returns {void} @@ -44,48 +51,109 @@ const logoutAdmin = async () => {- {{ post.excerpt }} -
-회원가입을 위한 인증번호입니다.
${code}
15분 이내에 입력해 주세요.
` : `비밀번호 재설정을 위한 인증번호입니다.
${code}
15분 이내에 입력해 주세요.
` - await sendResendEmail({ - apiKey: String(config.resendApiKey).trim(), - from: String(config.resendFromEmail).trim(), - to: email, - subject, - html - }) + try { + await sendResendEmail({ + apiKey: String(config.resendApiKey).trim(), + from: String(config.resendFromEmail).trim(), + to: email, + subject, + html + }) + } catch (error) { + await deleteOtpChallengeById(sql, challengeId) + throw error + } + + await invalidatePendingOtpChallengesExcept(sql, email, purpose, challengeId) return { ok: true, diff --git a/server/middleware/admin-api-session.js b/server/middleware/admin-api-session.js new file mode 100644 index 0000000..9f5fe04 --- /dev/null +++ b/server/middleware/admin-api-session.js @@ -0,0 +1,34 @@ +import { createError, getRequestURL } from 'h3' +import { getAdminSession } from '../utils/admin-auth' +import { isPrivilegedMember } from '../repositories/member-repository' + +/** + * 관리자 API 요청마다 현재 DB 권한을 다시 확인한다. + * @param {import('h3').H3Event} event - 요청 이벤트 + * @returns {Promise