세션·로그인 제한, ZIP/업로드 스트리밍, CSP 최소 강제와 Report-Only, Playwright 격리 검증을 추가하고 미디어 목록을 reconcile 후 database 모드로 고정한다. Co-authored-by: Cursor <cursoragent@cursor.com>
218 lines
6.4 KiB
JavaScript
218 lines
6.4 KiB
JavaScript
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'
|
|
import { getConfiguredUploadRoot } from './upload-root'
|
|
|
|
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(getConfiguredUploadRoot(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 }
|
|
}
|