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} 이미지 바이트 */ 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} 저장된 공개 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 } }