v1.7.1: 멤버 권한·추천 사이트·네비게이션 UI 개선
관리자는 VIP·멤버만 수정 가능하도록 서버·UI 권한을 정리하고 소유자 권한 이전 API와 확인 모달을 추가했다. 추천 사이트 파비콘 캐시·URL 접두사·카드 편집 UI를 보강하고 확인 얼럿을 AdminConfirmModal로 통일했다. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
25
server/plugins/recommended-favicon-cron.js
Normal file
25
server/plugins/recommended-favicon-cron.js
Normal file
@@ -0,0 +1,25 @@
|
||||
import { Cron } from 'croner'
|
||||
import { refreshAllRecommendedSiteFavicons } from '../utils/recommended-site-favicon.js'
|
||||
|
||||
/**
|
||||
* 매월 1일 추천 사이트 파비콘을 자동 갱신한다.
|
||||
*/
|
||||
export default defineNitroPlugin(() => {
|
||||
if (process.env.NUXT_RECOMMENDED_FAVICON_CRON === '0') {
|
||||
return
|
||||
}
|
||||
|
||||
new Cron('0 3 1 * *', {
|
||||
timezone: 'Asia/Seoul',
|
||||
protect: true
|
||||
}, async () => {
|
||||
try {
|
||||
const result = await refreshAllRecommendedSiteFavicons()
|
||||
console.info(
|
||||
`[recommended-favicon] monthly refresh complete: refreshed=${result.refreshed}, failed=${result.failed}`
|
||||
)
|
||||
} catch (error) {
|
||||
console.error('[recommended-favicon] monthly refresh failed', error)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -153,6 +153,8 @@ const mapNavigationItemRow = (row) => ({
|
||||
url: row.url,
|
||||
descriptionText: row.description_text || '',
|
||||
thumbnailUrl: row.thumbnail_url || '',
|
||||
cachedFaviconUrl: row.cached_favicon_url || '',
|
||||
faviconCachedAt: row.favicon_cached_at ? row.favicon_cached_at.toISOString() : '',
|
||||
location: row.location,
|
||||
sortOrder: row.sort_order,
|
||||
isVisible: row.is_visible,
|
||||
@@ -1131,6 +1133,7 @@ export const getPublicNavigation = async () => {
|
||||
url: item.url,
|
||||
descriptionText: item.descriptionText,
|
||||
thumbnailUrl: item.thumbnailUrl,
|
||||
cachedFaviconUrl: item.cachedFaviconUrl,
|
||||
isVisible: item.isVisible
|
||||
}))
|
||||
}
|
||||
@@ -1148,6 +1151,20 @@ export const updateNavigationItems = async (items) => {
|
||||
throw new Error('DATABASE_REQUIRED')
|
||||
}
|
||||
|
||||
const existingRows = await sql`
|
||||
SELECT id, cached_favicon_url, favicon_cached_at
|
||||
FROM navigation_items
|
||||
`
|
||||
const faviconCacheById = new Map(
|
||||
existingRows.map((row) => [
|
||||
String(row.id),
|
||||
{
|
||||
cachedFaviconUrl: row.cached_favicon_url || '',
|
||||
faviconCachedAt: row.favicon_cached_at ?? null
|
||||
}
|
||||
])
|
||||
)
|
||||
|
||||
await sql.begin(async (transaction) => {
|
||||
await transaction`
|
||||
DELETE FROM navigation_items
|
||||
@@ -1155,6 +1172,11 @@ export const updateNavigationItems = async (items) => {
|
||||
|
||||
const ordered = orderNavigationItemsForInsert(items)
|
||||
for (const item of ordered) {
|
||||
const cached = faviconCacheById.get(String(item.id)) || {
|
||||
cachedFaviconUrl: '',
|
||||
faviconCachedAt: null
|
||||
}
|
||||
|
||||
await transaction`
|
||||
INSERT INTO navigation_items (
|
||||
id,
|
||||
@@ -1162,6 +1184,8 @@ export const updateNavigationItems = async (items) => {
|
||||
url,
|
||||
description_text,
|
||||
thumbnail_url,
|
||||
cached_favicon_url,
|
||||
favicon_cached_at,
|
||||
location,
|
||||
sort_order,
|
||||
is_visible,
|
||||
@@ -1174,6 +1198,8 @@ export const updateNavigationItems = async (items) => {
|
||||
${item.url},
|
||||
${item.descriptionText || ''},
|
||||
${item.thumbnailUrl || ''},
|
||||
${cached.cachedFaviconUrl},
|
||||
${cached.faviconCachedAt},
|
||||
${item.location},
|
||||
${item.sortOrder},
|
||||
${item.isVisible},
|
||||
@@ -1187,6 +1213,54 @@ export const updateNavigationItems = async (items) => {
|
||||
return listNavigationItems()
|
||||
}
|
||||
|
||||
/**
|
||||
* 네비게이션 항목 단건 조회
|
||||
* @param {string} id - 항목 ID
|
||||
* @returns {Promise<Object | null>} 네비게이션 항목
|
||||
*/
|
||||
export const getNavigationItemById = async (id) => {
|
||||
const sql = getPostgresClient()
|
||||
|
||||
if (!sql) {
|
||||
return null
|
||||
}
|
||||
|
||||
const rows = await sql`
|
||||
SELECT *
|
||||
FROM navigation_items
|
||||
WHERE id = ${id}
|
||||
LIMIT 1
|
||||
`
|
||||
|
||||
return rows[0] ? mapNavigationItemRow(rows[0]) : null
|
||||
}
|
||||
|
||||
/**
|
||||
* 추천 사이트 파비콘 캐시를 갱신한다.
|
||||
* @param {string} id - 항목 ID
|
||||
* @param {string} cachedFaviconUrl - 저장된 파비콘 URL
|
||||
* @returns {Promise<Object | null>} 갱신된 항목
|
||||
*/
|
||||
export const updateNavigationItemFaviconCache = async (id, cachedFaviconUrl) => {
|
||||
const sql = getPostgresClient()
|
||||
|
||||
if (!sql) {
|
||||
throw new Error('DATABASE_REQUIRED')
|
||||
}
|
||||
|
||||
const rows = await sql`
|
||||
UPDATE navigation_items
|
||||
SET
|
||||
cached_favicon_url = ${cachedFaviconUrl},
|
||||
favicon_cached_at = NOW(),
|
||||
updated_at = NOW()
|
||||
WHERE id = ${id}
|
||||
RETURNING *
|
||||
`
|
||||
|
||||
return rows[0] ? mapNavigationItemRow(rows[0]) : null
|
||||
}
|
||||
|
||||
/**
|
||||
* 관리자 태그 상세 조회
|
||||
* @param {string} id - 태그 ID
|
||||
|
||||
@@ -10,6 +10,62 @@ export const MEMBER_ROLE = {
|
||||
|
||||
const PRIVILEGED_ROLES = [MEMBER_ROLE.OWNER, MEMBER_ROLE.ADMIN]
|
||||
const MEMBERSHIP_ACCESS_ROLES = [MEMBER_ROLE.OWNER, MEMBER_ROLE.ADMIN, MEMBER_ROLE.VIP]
|
||||
const BELOW_ADMIN_ROLES = [MEMBER_ROLE.VIP, MEMBER_ROLE.MEMBER]
|
||||
|
||||
/**
|
||||
* 관리자가 대상 회원 프로필을 수정할 수 있는지 확인한다.
|
||||
* @param {{ actorRoleCode: string, targetRoleCode: string, actorUserId: string, targetUserId: string }} input - 검사 정보
|
||||
* @returns {boolean} 수정 가능 여부
|
||||
*/
|
||||
export const canActorEditMemberProfile = (input) => {
|
||||
if (input.actorRoleCode === MEMBER_ROLE.OWNER) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (input.actorRoleCode === MEMBER_ROLE.ADMIN) {
|
||||
return String(input.actorUserId) !== String(input.targetUserId)
|
||||
&& BELOW_ADMIN_ROLES.includes(input.targetRoleCode)
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* 관리자가 대상 회원 프로필을 수정할 수 있는지 검증한다.
|
||||
* @param {{ actorUserId: string, targetUserId: string }} input - 검증 정보
|
||||
* @returns {Promise<{ actor: Object, target: Object }>} 검증된 회원 정보
|
||||
*/
|
||||
export const assertActorCanEditMemberProfile = async (input) => {
|
||||
const actor = await getMemberForAdmin(input.actorUserId)
|
||||
if (!actor || !isPrivilegedRole(actor.roleCode)) {
|
||||
throw createError({
|
||||
statusCode: 403,
|
||||
message: '회원 수정 권한이 없습니다.'
|
||||
})
|
||||
}
|
||||
|
||||
const target = await getMemberForAdmin(input.targetUserId)
|
||||
if (!target) {
|
||||
throw createError({
|
||||
statusCode: 404,
|
||||
message: '회원을 찾을 수 없습니다.'
|
||||
})
|
||||
}
|
||||
|
||||
if (!canActorEditMemberProfile({
|
||||
actorRoleCode: actor.roleCode,
|
||||
targetRoleCode: target.roleCode,
|
||||
actorUserId: actor.id,
|
||||
targetUserId: target.id
|
||||
})) {
|
||||
throw createError({
|
||||
statusCode: 403,
|
||||
message: '이 회원 정보를 수정할 권한이 없습니다.'
|
||||
})
|
||||
}
|
||||
|
||||
return { actor, target }
|
||||
}
|
||||
|
||||
/**
|
||||
* 회원 권한 표시 문자열을 반환한다.
|
||||
@@ -467,15 +523,12 @@ export const deleteMember = async (userId) => {
|
||||
* @returns {Promise<Object>} 삭제된 회원
|
||||
*/
|
||||
export const deleteMemberByAdmin = async (input) => {
|
||||
const target = await getMemberForAdmin(input.targetUserId)
|
||||
if (!target) {
|
||||
throw createError({
|
||||
statusCode: 404,
|
||||
message: '회원을 찾을 수 없습니다.'
|
||||
})
|
||||
}
|
||||
const { target } = await assertActorCanEditMemberProfile({
|
||||
actorUserId: input.actorUserId,
|
||||
targetUserId: input.targetUserId
|
||||
})
|
||||
|
||||
if (target.id === input.actorUserId) {
|
||||
if (String(target.id) === String(input.actorUserId)) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: '현재 로그인한 계정은 여기서 삭제할 수 없습니다.'
|
||||
@@ -668,10 +721,15 @@ export const createMemberByAdmin = async (input) => {
|
||||
|
||||
/**
|
||||
* 관리자 화면에서 회원 기본 정보를 수정한다.
|
||||
* @param {{ memberId: string, username: string, email: string, avatarUrl: string, labels: string[], note: string }} input - 수정 값
|
||||
* @param {{ actorUserId: string, memberId: string, username: string, email: string, avatarUrl: string, labels: string[], note: string }} input - 수정 값
|
||||
* @returns {Promise<Object | null>} 수정된 회원
|
||||
*/
|
||||
export const updateMemberByAdmin = async (input) => {
|
||||
await assertActorCanEditMemberProfile({
|
||||
actorUserId: input.actorUserId,
|
||||
targetUserId: input.memberId
|
||||
})
|
||||
|
||||
const sql = requireSql()
|
||||
const rows = await sql`
|
||||
UPDATE users
|
||||
@@ -691,10 +749,15 @@ export const updateMemberByAdmin = async (input) => {
|
||||
|
||||
/**
|
||||
* 관리자 화면에서 회원 썸네일만 수정한다.
|
||||
* @param {{ memberId: string, avatarUrl: string }} input - 수정 값
|
||||
* @param {{ actorUserId: string, memberId: string, avatarUrl: string }} input - 수정 값
|
||||
* @returns {Promise<Object | null>} 수정된 회원
|
||||
*/
|
||||
export const updateMemberAvatarByAdmin = async (input) => {
|
||||
await assertActorCanEditMemberProfile({
|
||||
actorUserId: input.actorUserId,
|
||||
targetUserId: input.memberId
|
||||
})
|
||||
|
||||
const sql = requireSql()
|
||||
const rows = await sql`
|
||||
UPDATE users
|
||||
@@ -871,7 +934,7 @@ export const updateMemberRoleByAdmin = async (input) => {
|
||||
if (isPrivilegedRole(target.roleCode)) {
|
||||
throw createError({
|
||||
statusCode: 403,
|
||||
message: '관리자는 다른 소유자 또는 관리자 권한을 변경할 수 없습니다.'
|
||||
message: '관리자는 소유자 또는 관리자 회원의 등급을 변경할 수 없습니다.'
|
||||
})
|
||||
}
|
||||
|
||||
@@ -883,6 +946,17 @@ export const updateMemberRoleByAdmin = async (input) => {
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
normalizedRole === MEMBER_ROLE.OWNER
|
||||
&& actor.roleCode === MEMBER_ROLE.OWNER
|
||||
&& target.id !== actor.id
|
||||
) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: '소유자 권한 이전은 전용 확인 절차를 사용하세요.'
|
||||
})
|
||||
}
|
||||
|
||||
if (target.id === input.actorUserId && !isPrivilegedRole(normalizedRole)) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
@@ -934,3 +1008,93 @@ export const updateMemberRoleByAdmin = async (input) => {
|
||||
isAdmin: Boolean(updated.isAdmin)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 소유자 권한을 다른 회원에게 이전하고 기존 소유자를 관리자로 변경한다.
|
||||
* @param {{ actorUserId: string, targetUserId: string }} input - 이전 정보
|
||||
* @returns {Promise<{ target: Object, previousOwner: Object }>} 이전 결과
|
||||
*/
|
||||
export const transferMemberOwnership = async (input) => {
|
||||
const sql = requireSql()
|
||||
|
||||
await sql.begin(async (tx) => {
|
||||
await tx`LOCK TABLE users IN SHARE ROW EXCLUSIVE MODE`
|
||||
|
||||
const actorRows = await tx`
|
||||
SELECT id, user_role AS "roleCode"
|
||||
FROM users
|
||||
WHERE id = ${input.actorUserId}
|
||||
LIMIT 1
|
||||
`
|
||||
const actor = actorRows?.[0]
|
||||
|
||||
if (!actor || actor.roleCode !== MEMBER_ROLE.OWNER) {
|
||||
throw createError({
|
||||
statusCode: 403,
|
||||
message: '소유자만 권한을 이전할 수 있습니다.'
|
||||
})
|
||||
}
|
||||
|
||||
const targetRows = await tx`
|
||||
SELECT id, user_role AS "roleCode"
|
||||
FROM users
|
||||
WHERE id = ${input.targetUserId}
|
||||
LIMIT 1
|
||||
`
|
||||
const target = targetRows?.[0]
|
||||
|
||||
if (!target) {
|
||||
throw createError({
|
||||
statusCode: 404,
|
||||
message: '대상 회원을 찾을 수 없습니다.'
|
||||
})
|
||||
}
|
||||
|
||||
if (String(target.id) === String(actor.id)) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: '본인에게 소유자 권한을 이전할 수 없습니다.'
|
||||
})
|
||||
}
|
||||
|
||||
if (target.roleCode === MEMBER_ROLE.OWNER) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: '이미 소유자입니다.'
|
||||
})
|
||||
}
|
||||
|
||||
await tx`
|
||||
UPDATE users
|
||||
SET
|
||||
user_role = ${MEMBER_ROLE.OWNER},
|
||||
is_admin = true,
|
||||
updated_at = now()
|
||||
WHERE id = ${input.targetUserId}
|
||||
`
|
||||
|
||||
await tx`
|
||||
UPDATE users
|
||||
SET
|
||||
user_role = ${MEMBER_ROLE.ADMIN},
|
||||
is_admin = true,
|
||||
updated_at = now()
|
||||
WHERE id = ${input.actorUserId}
|
||||
`
|
||||
})
|
||||
|
||||
const target = await getMemberForAdmin(input.targetUserId)
|
||||
const previousOwner = await getMemberForAdmin(input.actorUserId)
|
||||
|
||||
if (!target || !previousOwner) {
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
message: '소유자 권한 이전에 실패했습니다.'
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
target,
|
||||
previousOwner
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { uploadMemberAvatarImage } from '../../../utils/member-avatar-upload'
|
||||
import { MEDIA_THUMBNAIL_ROOT, upsertMediaMetadataCategory } from '../../../utils/media-library'
|
||||
|
||||
/**
|
||||
* 관리자 새 회원용 썸네일 사전 업로드 API
|
||||
* 관리자 멤버 썸네일 사전 업로드 API(신규·편집 미리보기)
|
||||
* @param {import('h3').H3Event} event - 요청 이벤트
|
||||
* @returns {Promise<{ avatarUrl: string }>} 업로드 결과
|
||||
*/
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { createError, getRouterParam, readBody } from 'h3'
|
||||
import { z } from 'zod'
|
||||
import { requireAdminSession } from '../../../../utils/admin-auth'
|
||||
import { getMemberForAdmin, isEmailTaken, isUsernameTaken, updateMemberByAdmin } from '../../../../repositories/member-repository'
|
||||
import { getMemberForAdmin, isEmailTaken, isUsernameTaken, updateMemberByAdmin, assertActorCanEditMemberProfile } from '../../../../repositories/member-repository'
|
||||
import { removeManagedAvatarAsset } from '../../../../utils/member-avatar'
|
||||
|
||||
const memberInputSchema = z.object({
|
||||
@@ -18,7 +18,7 @@ const memberInputSchema = z.object({
|
||||
* @returns {Promise<Object>} 수정된 회원
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
requireAdminSession(event)
|
||||
const session = requireAdminSession(event)
|
||||
const memberId = String(getRouterParam(event, 'id') || '')
|
||||
const parsedBody = memberInputSchema.safeParse(await readBody(event))
|
||||
|
||||
@@ -62,6 +62,7 @@ export default defineEventHandler(async (event) => {
|
||||
}
|
||||
|
||||
const updated = await updateMemberByAdmin({
|
||||
actorUserId: session.userId,
|
||||
memberId,
|
||||
username: body.username,
|
||||
email,
|
||||
|
||||
@@ -11,7 +11,7 @@ import { MEDIA_THUMBNAIL_ROOT, upsertMediaMetadataCategory } from '../../../../.
|
||||
* @returns {Promise<Object>} 수정된 회원
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
requireAdminSession(event)
|
||||
const session = requireAdminSession(event)
|
||||
const memberId = String(getRouterParam(event, 'id') || '')
|
||||
|
||||
if (!memberId) {
|
||||
@@ -31,6 +31,7 @@ export default defineEventHandler(async (event) => {
|
||||
|
||||
const { avatarUrl } = await uploadMemberAvatarImage(event)
|
||||
const updated = await updateMemberAvatarByAdmin({
|
||||
actorUserId: session.userId,
|
||||
memberId,
|
||||
avatarUrl
|
||||
})
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { createError, getRouterParam } from 'h3'
|
||||
import { requireAdminSession } from '../../../../../utils/admin-auth'
|
||||
import { transferMemberOwnership } from '../../../../../repositories/member-repository'
|
||||
|
||||
/**
|
||||
* 소유자 권한 이전 API
|
||||
* @param {import('h3').H3Event} event - 요청 이벤트
|
||||
* @returns {Promise<{ target: Object, previousOwner: Object }>} 이전 결과
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
const session = requireAdminSession(event)
|
||||
const memberId = String(getRouterParam(event, 'id') || '')
|
||||
|
||||
if (!memberId) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: '대상 회원 ID가 필요합니다.'
|
||||
})
|
||||
}
|
||||
|
||||
return transferMemberOwnership({
|
||||
actorUserId: session.userId,
|
||||
targetUserId: memberId
|
||||
})
|
||||
})
|
||||
@@ -2,7 +2,7 @@ import bcrypt from 'bcrypt'
|
||||
import { createError, getRouterParam, readBody } from 'h3'
|
||||
import { z } from 'zod'
|
||||
import { requireAdminSession } from '../../../../../utils/admin-auth'
|
||||
import { getMemberForAdmin, updateMemberPassword } from '../../../../../repositories/member-repository'
|
||||
import { getMemberForAdmin, updateMemberPassword, assertActorCanEditMemberProfile } from '../../../../../repositories/member-repository'
|
||||
|
||||
const adminMemberPasswordSchema = z.object({
|
||||
password: z.string().min(8).max(32)
|
||||
@@ -14,7 +14,7 @@ const adminMemberPasswordSchema = z.object({
|
||||
* @returns {Promise<{ ok: true }>} 변경 결과
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
requireAdminSession(event)
|
||||
const session = requireAdminSession(event)
|
||||
const memberId = String(getRouterParam(event, 'id') || '')
|
||||
const parsedBody = adminMemberPasswordSchema.safeParse(await readBody(event))
|
||||
|
||||
@@ -32,6 +32,11 @@ export default defineEventHandler(async (event) => {
|
||||
})
|
||||
}
|
||||
|
||||
await assertActorCanEditMemberProfile({
|
||||
actorUserId: session.userId,
|
||||
targetUserId: memberId
|
||||
})
|
||||
|
||||
const member = await getMemberForAdmin(memberId)
|
||||
if (!member) {
|
||||
throw createError({
|
||||
|
||||
@@ -3,6 +3,7 @@ import { requireAdminSession } from '../../../utils/admin-auth'
|
||||
import { parseAdminNavigationInput } from '../../../utils/admin-navigation-input'
|
||||
import { updateNavigationItems } from '../../../repositories/content-repository'
|
||||
import { renumberSortOrderByTree, validateNavigationItems } from '../../../utils/navigation-tree'
|
||||
import { refreshRecommendedSiteFaviconsWithoutCache } from '../../../utils/recommended-site-favicon'
|
||||
|
||||
/**
|
||||
* 저장 직전에 항목별 표시·폴더 플래그를 정리한다(관리자 UI는 항상 노출, 폴더는 자식 유무로만).
|
||||
@@ -68,7 +69,9 @@ export default defineEventHandler(async (event) => {
|
||||
applyNavigationDerivedFlags(items)
|
||||
|
||||
try {
|
||||
return await updateNavigationItems(items)
|
||||
const saved = await updateNavigationItems(items)
|
||||
void refreshRecommendedSiteFaviconsWithoutCache().catch(() => {})
|
||||
return saved
|
||||
} catch (err) {
|
||||
const msg = err?.message != null ? String(err.message) : String(err)
|
||||
const code = err?.code != null ? String(err.code) : ''
|
||||
@@ -77,6 +80,8 @@ export default defineEventHandler(async (event) => {
|
||||
msg.includes('is_folder') ||
|
||||
msg.includes('description_text') ||
|
||||
msg.includes('thumbnail_url') ||
|
||||
msg.includes('cached_favicon_url') ||
|
||||
msg.includes('favicon_cached_at') ||
|
||||
code === '42703'
|
||||
) {
|
||||
throw createError({
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { createError, getRouterParam } from 'h3'
|
||||
import { requireAdminSession } from '../../../../../utils/admin-auth'
|
||||
import { refreshRecommendedSiteFaviconById } from '../../../../../utils/recommended-site-favicon'
|
||||
|
||||
/**
|
||||
* 추천 사이트 파비콘 수동 갱신 API
|
||||
* @param {import('h3').H3Event} event - 요청 이벤트
|
||||
* @returns {Promise<{ cachedFaviconUrl: string, faviconCachedAt: string }>} 갱신 결과
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
requireAdminSession(event)
|
||||
|
||||
const itemId = String(getRouterParam(event, 'id') || '').trim()
|
||||
if (!itemId) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: '네비게이션 항목 ID가 필요합니다.'
|
||||
})
|
||||
}
|
||||
|
||||
try {
|
||||
return await refreshRecommendedSiteFaviconById(itemId)
|
||||
} catch (err) {
|
||||
const msg = err?.message != null ? String(err.message) : '파비콘을 갱신하지 못했습니다.'
|
||||
if (
|
||||
msg.includes('cached_favicon_url')
|
||||
|| msg.includes('favicon_cached_at')
|
||||
|| String(err?.code || '') === '42703'
|
||||
) {
|
||||
throw createError({
|
||||
statusCode: 503,
|
||||
message: 'DB에 추천 사이트 파비콘 캐시 컬럼이 없습니다. npm run db:migrate:dev 로 최신 마이그레이션을 적용한 뒤 다시 시도하세요.'
|
||||
})
|
||||
}
|
||||
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: msg
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -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']),
|
||||
|
||||
@@ -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 || ''
|
||||
})
|
||||
|
||||
216
server/utils/recommended-site-favicon.js
Normal file
216
server/utils/recommended-site-favicon.js
Normal 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 }
|
||||
}
|
||||
Reference in New Issue
Block a user