v1.7.1: 멤버 권한·추천 사이트·네비게이션 UI 개선

관리자는 VIP·멤버만 수정 가능하도록 서버·UI 권한을 정리하고 소유자 권한 이전 API와 확인 모달을 추가했다. 추천 사이트 파비콘 캐시·URL 접두사·카드 편집 UI를 보강하고 확인 얼럿을 AdminConfirmModal로 통일했다.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-17 14:32:11 +09:00
parent f206499f99
commit 5b2ed97748
30 changed files with 1837 additions and 211 deletions

View File

@@ -1,9 +1,13 @@
import { z } from 'zod'
import { isValidNavigationUrl, normalizeNavigationUrl } from '../../lib/navigation-url.js'
export const adminNavigationItemInputSchema = z.object({
id: z.string().uuid(),
label: z.string().trim().min(1),
url: z.string().trim().min(1).regex(/^(\/|https?:\/\/|#)/),
url: z.string().trim().min(1).transform(normalizeNavigationUrl).refine(
isValidNavigationUrl,
{ message: 'URL은 / 로 시작하는 내부 경로, http(s):// 외부 URL, # 중 하나여야 합니다.' }
),
descriptionText: z.string().trim().max(200).optional().default(''),
thumbnailUrl: z.string().trim().max(500).optional().default(''),
location: z.enum(['primary', 'footer', 'recommended']),

View File

@@ -137,7 +137,7 @@ const isAvatarUrlReferencedByProfile = async (url) => {
/**
* URL 목록에 대해 아바타를 사용 중인 회원 정보를 조회한다.
* @param {Array<string>} urls - 미디어 URL 목록
* @returns {Promise<Map<string, { id: string, username: string, email: string, lastSeenAt: string | null, lastSeenIp: string }>>} URL별 회원 요약
* @returns {Promise<Map<string, { id: string, username: string, email: string, roleCode: string, lastSeenAt: string | null, lastSeenIp: string }>>} URL별 회원 요약
*/
const getAvatarOwnersByUrls = async (urls) => {
const sql = getPostgresClient()
@@ -152,6 +152,7 @@ const getAvatarOwnersByUrls = async (urls) => {
id,
username,
email,
user_role AS "roleCode",
last_seen_at,
last_seen_ip,
avatar_url
@@ -169,6 +170,7 @@ const getAvatarOwnersByUrls = async (urls) => {
id: row.id,
username: row.username,
email: row.email,
roleCode: String(row.roleCode || 'member'),
lastSeenAt: row.last_seen_at ? row.last_seen_at.toISOString() : null,
lastSeenIp: row.last_seen_ip || ''
})

View File

@@ -0,0 +1,216 @@
import { mkdir, writeFile } from 'node:fs/promises'
import { join } from 'node:path'
import sharp from 'sharp'
import { getExternalFaviconUrl } from '../../lib/external-favicon-url.js'
import { isExternalRecommendedSiteUrl } from '../../lib/recommended-site-icon-url.js'
import {
getNavigationItemById,
listNavigationItems,
updateNavigationItemFaviconCache
} from '../repositories/content-repository.js'
import { upsertMediaMetadataCategory } from './media-library.js'
const FETCH_TIMEOUT_MS = 15000
const FAVICON_OUTPUT_SIZE = 64
/**
* 추천 사이트 파비콘 파일명 접미사를 만든다.
* @returns {string} 파일명 접미사
*/
const createFaviconAssetSuffix = () => {
const now = new Date()
const year = now.getFullYear()
const month = String(now.getMonth() + 1).padStart(2, '0')
return `${year}${month}-${Math.random().toString(36).slice(2, 8)}`
}
/**
* 업로드 기본 경로를 반환한다.
* @returns {string} `/uploads` 형태 경로
*/
const getUploadBaseUrl = () => {
try {
const config = useRuntimeConfig()
const fromConfig = String(config.uploadDir || '').trim()
if (fromConfig) {
return fromConfig.replace(/\/+$/g, '') || '/uploads'
}
} catch {
// Nuxt 컨텍스트 밖(스크립트 실행)에서는 환경 변수를 사용한다.
}
return String(process.env.UPLOAD_DIR || '/uploads').replace(/\/+$/g, '') || '/uploads'
}
/**
* 외부 파비콘 이미지 바이트를 가져온다.
* @param {string} sourceUrl - 파비콘 소스 URL
* @returns {Promise<Buffer>} 이미지 바이트
*/
const fetchFaviconBytes = async (sourceUrl) => {
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS)
try {
const response = await fetch(sourceUrl, {
signal: controller.signal,
headers: {
'User-Agent': 'sori.studio recommended-favicon-cache'
}
})
if (!response.ok) {
throw new Error(`파비콘을 가져오지 못했습니다. (${response.status})`)
}
const contentType = String(response.headers.get('content-type') || '').toLowerCase()
if (contentType && !contentType.startsWith('image/')) {
throw new Error('파비콘 응답이 이미지가 아닙니다.')
}
const bytes = Buffer.from(await response.arrayBuffer())
if (!bytes.length) {
throw new Error('파비콘 응답이 비어 있습니다.')
}
return bytes
} finally {
clearTimeout(timeout)
}
}
/**
* 추천 사이트 파비콘을 디스크에 저장한다.
* @param {string} itemId - 네비게이션 항목 ID
* @param {Buffer} bytes - 원본 이미지 바이트
* @returns {Promise<string>} 저장된 공개 URL
*/
const writeRecommendedFaviconFile = async (itemId, bytes) => {
const uploadBaseUrl = getUploadBaseUrl()
const publicBasePath = uploadBaseUrl.replace(/^\/+/, '')
const directoryPath = join(process.cwd(), 'public', publicBasePath, 'system')
const shortId = String(itemId).replace(/-/g, '').slice(0, 8)
const fileName = `recommended-favicon-${shortId}-${createFaviconAssetSuffix()}.png`
const filePath = join(directoryPath, fileName)
const publicUrl = `${uploadBaseUrl}/system/${fileName}`
const output = await sharp(bytes)
.rotate()
.resize({
width: FAVICON_OUTPUT_SIZE,
height: FAVICON_OUTPUT_SIZE,
fit: 'cover',
position: 'centre'
})
.png()
.toBuffer()
await mkdir(directoryPath, { recursive: true })
await writeFile(filePath, output)
await upsertMediaMetadataCategory(publicUrl, '시스템')
return publicUrl
}
/**
* 추천 사이트 항목의 파비콘을 서버에 갱신한다.
* @param {Object} item - 네비게이션 항목
* @returns {Promise<{ cachedFaviconUrl: string, faviconCachedAt: string }>} 갱신 결과
*/
export const refreshRecommendedSiteFavicon = async (item) => {
if (!item || item.location !== 'recommended') {
throw new Error('추천 사이트 항목만 파비콘을 갱신할 수 있습니다.')
}
if (!isExternalRecommendedSiteUrl(item.url)) {
throw new Error('외부 URL이 있는 추천 사이트만 파비콘을 갱신할 수 있습니다.')
}
const sourceUrl = getExternalFaviconUrl(item.url, 128)
if (!sourceUrl) {
throw new Error('파비콘 소스 URL을 만들 수 없습니다.')
}
const bytes = await fetchFaviconBytes(sourceUrl)
const cachedFaviconUrl = await writeRecommendedFaviconFile(item.id, bytes)
const updated = await updateNavigationItemFaviconCache(item.id, cachedFaviconUrl)
if (!updated) {
throw new Error('파비콘 캐시를 저장하지 못했습니다.')
}
return {
cachedFaviconUrl: updated.cachedFaviconUrl,
faviconCachedAt: updated.faviconCachedAt
}
}
/**
* ID로 추천 사이트 파비콘을 갱신한다.
* @param {string} itemId - 네비게이션 항목 ID
* @returns {Promise<{ cachedFaviconUrl: string, faviconCachedAt: string }>} 갱신 결과
*/
export const refreshRecommendedSiteFaviconById = async (itemId) => {
const item = await getNavigationItemById(itemId)
if (!item) {
throw new Error('네비게이션 항목을 찾을 수 없습니다.')
}
return refreshRecommendedSiteFavicon(item)
}
/**
* 캐시가 없는 추천 사이트 파비콘을 갱신한다.
* @returns {Promise<{ refreshed: number, failed: number }>} 처리 결과
*/
export const refreshRecommendedSiteFaviconsWithoutCache = async () => {
const items = await listNavigationItems()
const targets = items.filter((item) =>
item.location === 'recommended'
&& !item.parentId
&& isExternalRecommendedSiteUrl(item.url)
&& !String(item.cachedFaviconUrl || '').trim()
)
let refreshed = 0
let failed = 0
for (const item of targets) {
try {
await refreshRecommendedSiteFavicon(item)
refreshed += 1
} catch {
failed += 1
}
}
return { refreshed, failed }
}
/**
* 모든 추천 사이트 파비콘을 갱신한다.
* @returns {Promise<{ refreshed: number, failed: number }>} 처리 결과
*/
export const refreshAllRecommendedSiteFavicons = async () => {
const items = await listNavigationItems()
const targets = items.filter((item) =>
item.location === 'recommended'
&& !item.parentId
&& isExternalRecommendedSiteUrl(item.url)
)
let refreshed = 0
let failed = 0
for (const item of targets) {
try {
await refreshRecommendedSiteFavicon(item)
refreshed += 1
} catch {
failed += 1
}
}
return { refreshed, failed }
}