release: v1.2.0 — unified images/ storage and legacy migration
ci / ci (22, ubuntu-latest) (push) Canceled after 0s
Publish Docker image / build (linux/amd64, ubuntu-latest, amd64) (push) Canceled after 0s
Publish Docker image / build (linux/arm64, ubuntu-24.04-arm, arm64) (push) Canceled after 0s
Publish Docker image / merge (push) Canceled after 0s
GitHub Release / release (push) Canceled after 0s
ci / ci (22, ubuntu-latest) (push) Canceled after 0s
Publish Docker image / build (linux/amd64, ubuntu-latest, amd64) (push) Canceled after 0s
Publish Docker image / build (linux/arm64, ubuntu-24.04-arm, arm64) (push) Canceled after 0s
Publish Docker image / merge (push) Canceled after 0s
GitHub Release / release (push) Canceled after 0s
Consolidate local images under data/images/, add docker migrate CLI, startup index sync with orphan cleanup, and remove upload folder UI/API.
This commit is contained in:
1 parent
3dee0437e6
commit
469d3be735
50 files changed
+1273
-956
No files matched your search
@@ -3,8 +3,9 @@ import { requireApiOrAdminAuth, assertImageOwnership, resolveActivitySource } fr
|
||||
import { createApiError } from '../../utils/api-error'
|
||||
import { MAX_DELETE_BATCH } from '../../utils/constants'
|
||||
import { insertActivityLog } from '../../utils/db'
|
||||
import { validateImageKey } from '../../utils/image-key'
|
||||
import { deleteImage, headImage } from '../../utils/storage'
|
||||
import { getImageIndexRow, deleteImageIndex } from '../../utils/image-index'
|
||||
import { toCanonicalImageKey, validateImageKey } from '../../utils/image-key'
|
||||
import { deleteImage, headImage, resolveStorageImageKey } from '../../utils/storage'
|
||||
import { logInfo } from '../../utils/logger'
|
||||
|
||||
interface BatchDeleteBody {
|
||||
@@ -33,7 +34,11 @@ export default defineEventHandler(async (event) => {
|
||||
}
|
||||
|
||||
for (const key of keys) {
|
||||
if (!validateImageKey(key)) {
|
||||
if (
|
||||
!validateImageKey(key)
|
||||
&& !toCanonicalImageKey(key)
|
||||
&& !getImageIndexRow(key)
|
||||
) {
|
||||
createApiError(event, 'INVALID_IMAGE_KEY', `无效的图片路径: ${key}`, 400)
|
||||
}
|
||||
}
|
||||
@@ -43,7 +48,38 @@ export default defineEventHandler(async (event) => {
|
||||
|
||||
for (const key of keys) {
|
||||
try {
|
||||
const existing = await headImage(key)
|
||||
const storageKey = await resolveStorageImageKey(key) ?? key
|
||||
const existing = await headImage(storageKey)
|
||||
const indexed = getImageIndexRow(key) ?? getImageIndexRow(storageKey)
|
||||
|
||||
if (!existing && indexed) {
|
||||
try {
|
||||
await assertImageOwnership(event, indexed.user_id)
|
||||
} catch {
|
||||
failed.push({
|
||||
key,
|
||||
error: { code: 'FORBIDDEN', message: '无权操作此图片' }
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
deleteImageIndex(key)
|
||||
if (storageKey !== key) deleteImageIndex(storageKey)
|
||||
|
||||
deleted.push(key)
|
||||
insertActivityLog({
|
||||
action: 'delete',
|
||||
key,
|
||||
originalName: indexed.original_name,
|
||||
size: indexed.size,
|
||||
contentType: indexed.content_type,
|
||||
source: resolveActivitySource(event),
|
||||
userId: indexed.user_id ?? null,
|
||||
backendId: indexed.backend_id ?? null
|
||||
})
|
||||
logInfo('delete orphan index', { key })
|
||||
continue
|
||||
}
|
||||
|
||||
if (!existing) {
|
||||
failed.push({
|
||||
@@ -63,7 +99,7 @@ export default defineEventHandler(async (event) => {
|
||||
continue
|
||||
}
|
||||
|
||||
await deleteImage(key)
|
||||
await deleteImage(storageKey, key)
|
||||
deleted.push(key)
|
||||
|
||||
insertActivityLog({
|
||||
@@ -77,7 +113,7 @@ export default defineEventHandler(async (event) => {
|
||||
backendId: existing.backendId ?? null
|
||||
})
|
||||
|
||||
logInfo('delete success', { key, size: existing.size })
|
||||
logInfo('delete success', { key: storageKey, size: existing.size })
|
||||
} catch {
|
||||
failed.push({
|
||||
key,
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { getImageUserFilter, requireApiOrAdminAuth } from '../../utils/access'
|
||||
import { isValidFolderName } from '../../utils/image-key'
|
||||
import { readBackendIdQuery } from '../../utils/image-query'
|
||||
import { createApiError } from '../../utils/api-error'
|
||||
import { countImages } from '../../utils/storage'
|
||||
@@ -9,14 +8,12 @@ export default defineEventHandler(async (event) => {
|
||||
const userFilter = await getImageUserFilter(event)
|
||||
|
||||
const query = getQuery(event)
|
||||
const raw = typeof query.folder === 'string' ? query.folder.trim() : ''
|
||||
const folder = raw && raw !== 'all' && isValidFolderName(raw) ? raw : undefined
|
||||
const backendId = readBackendIdQuery(query)
|
||||
if (backendId === null) {
|
||||
createApiError(event, 'INVALID_REQUEST', '无效的存储后端', 400)
|
||||
}
|
||||
|
||||
const total = await countImages(folder, userFilter, backendId)
|
||||
const total = await countImages(userFilter, backendId)
|
||||
|
||||
return { total }
|
||||
})
|
||||
@@ -2,8 +2,9 @@ import type { DeleteResponse } from '~/types/image'
|
||||
import { requireApiOrAdminAuth, assertImageOwnership, resolveActivitySource } from '../../utils/access'
|
||||
import { createApiError } from '../../utils/api-error'
|
||||
import { insertActivityLog } from '../../utils/db'
|
||||
import { validateImageKey } from '../../utils/image-key'
|
||||
import { deleteImage, headImage } from '../../utils/storage'
|
||||
import { getImageIndexRow, deleteImageIndex } from '../../utils/image-index'
|
||||
import { toCanonicalImageKey, validateImageKey } from '../../utils/image-key'
|
||||
import { deleteImage, headImage, resolveStorageImageKey } from '../../utils/storage'
|
||||
import { logInfo } from '../../utils/logger'
|
||||
|
||||
interface DeleteBody {
|
||||
@@ -19,11 +20,43 @@ export default defineEventHandler(async (event) => {
|
||||
const key = (typeof query.key === 'string' ? query.key : undefined)
|
||||
?? body?.key
|
||||
|
||||
if (!key || !validateImageKey(key)) {
|
||||
if (
|
||||
!key
|
||||
|| (
|
||||
!validateImageKey(key)
|
||||
&& !toCanonicalImageKey(key)
|
||||
&& !getImageIndexRow(key)
|
||||
)
|
||||
) {
|
||||
createApiError(event, 'INVALID_IMAGE_KEY', '无效的图片路径', 400)
|
||||
}
|
||||
|
||||
const existing = await headImage(key)
|
||||
const storageKey = await resolveStorageImageKey(key) ?? key
|
||||
const existing = await headImage(storageKey)
|
||||
const indexed = getImageIndexRow(key) ?? getImageIndexRow(storageKey)
|
||||
|
||||
if (!existing && indexed) {
|
||||
await assertImageOwnership(event, indexed.user_id)
|
||||
deleteImageIndex(key)
|
||||
if (storageKey !== key) deleteImageIndex(storageKey)
|
||||
|
||||
insertActivityLog({
|
||||
action: 'delete',
|
||||
key,
|
||||
originalName: indexed.original_name,
|
||||
size: indexed.size,
|
||||
contentType: indexed.content_type,
|
||||
source: resolveActivitySource(event),
|
||||
userId: indexed.user_id ?? null,
|
||||
backendId: indexed.backend_id ?? null
|
||||
})
|
||||
|
||||
logInfo('delete orphan index', { key })
|
||||
|
||||
const response: DeleteResponse = { success: true }
|
||||
return response
|
||||
}
|
||||
|
||||
if (!existing) {
|
||||
createApiError(event, 'IMAGE_NOT_FOUND', '图片不存在', 404)
|
||||
}
|
||||
@@ -31,7 +64,7 @@ export default defineEventHandler(async (event) => {
|
||||
await assertImageOwnership(event, existing.userId)
|
||||
|
||||
try {
|
||||
await deleteImage(key)
|
||||
await deleteImage(storageKey, key)
|
||||
} catch {
|
||||
createApiError(event, 'DELETE_FAILED', '删除失败', 500)
|
||||
}
|
||||
@@ -47,7 +80,7 @@ export default defineEventHandler(async (event) => {
|
||||
backendId: existing.backendId ?? null
|
||||
})
|
||||
|
||||
logInfo('delete success', { key, size: existing.size })
|
||||
logInfo('delete success', { key: storageKey, size: existing.size })
|
||||
|
||||
const response: DeleteResponse = { success: true }
|
||||
return response
|
||||
|
||||
@@ -9,16 +9,9 @@ import { createApiError } from '../../utils/api-error'
|
||||
import { listUserIdUsernameMap } from '../../utils/db'
|
||||
import { mapStoredImageToItem } from '../../utils/image-response'
|
||||
import { listStorageBackendNameMap } from '../../utils/storage-backends'
|
||||
import { isValidFolderName } from '../../utils/image-key'
|
||||
import { readBackendIdQuery } from '../../utils/image-query'
|
||||
import { listImages } from '../../utils/storage'
|
||||
|
||||
function readFolderQuery(query: Record<string, unknown>): string | undefined {
|
||||
const raw = typeof query.folder === 'string' ? query.folder.trim() : ''
|
||||
if (!raw || raw === 'all') return undefined
|
||||
return isValidFolderName(raw) ? raw : undefined
|
||||
}
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
await requireApiOrAdminAuth(event)
|
||||
|
||||
@@ -34,14 +27,13 @@ export default defineEventHandler(async (event) => {
|
||||
? Math.floor(pageRaw)
|
||||
: 1
|
||||
|
||||
const folder = readFolderQuery(query)
|
||||
const backendId = readBackendIdQuery(query)
|
||||
if (backendId === null) {
|
||||
createApiError(event, 'INVALID_REQUEST', '无效的存储后端', 400)
|
||||
}
|
||||
const userFilter = await getImageUserFilter(event)
|
||||
|
||||
const listing = await listImages({ limit, page, folder, userFilter, backendId })
|
||||
const listing = await listImages({ limit, page, userFilter, backendId })
|
||||
|
||||
const user = await getCurrentUser(event)
|
||||
const ownerMap = user?.role === 'admin' ? listUserIdUsernameMap() : undefined
|
||||
|
||||
@@ -9,16 +9,9 @@ import { createApiError } from '../../utils/api-error'
|
||||
import { listUserIdUsernameMap } from '../../utils/db'
|
||||
import { mapStoredImageToItem } from '../../utils/image-response'
|
||||
import { listStorageBackendNameMap } from '../../utils/storage-backends'
|
||||
import { isValidFolderName } from '../../utils/image-key'
|
||||
import { readBackendIdQuery } from '../../utils/image-query'
|
||||
import { searchImages } from '../../utils/storage'
|
||||
|
||||
function readFolderQuery(query: Record<string, unknown>): string | undefined {
|
||||
const raw = typeof query.folder === 'string' ? query.folder.trim() : ''
|
||||
if (!raw || raw === 'all') return undefined
|
||||
return isValidFolderName(raw) ? raw : undefined
|
||||
}
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
await requireApiOrAdminAuth(event)
|
||||
|
||||
@@ -43,14 +36,13 @@ export default defineEventHandler(async (event) => {
|
||||
? Math.floor(pageRaw)
|
||||
: 1
|
||||
|
||||
const folder = readFolderQuery(query)
|
||||
const backendId = readBackendIdQuery(query)
|
||||
if (backendId === null) {
|
||||
createApiError(event, 'INVALID_REQUEST', '无效的存储后端', 400)
|
||||
}
|
||||
const userFilter = await getImageUserFilter(event)
|
||||
|
||||
const result = await searchImages({ query: q, limit, page, folder, userFilter, backendId })
|
||||
const result = await searchImages({ query: q, limit, page, userFilter, backendId })
|
||||
|
||||
const user = await getCurrentUser(event)
|
||||
const ownerMap = user?.role === 'admin' ? listUserIdUsernameMap() : undefined
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { readMultipartFormData } from 'h3'
|
||||
import type { UploadResponse, UploadErrorItem, ImageItem } from '~/types/image'
|
||||
import { requireUploadAuth, verifyApiUploadToken, getUploadUserId, canUseCustomUploadFolder } from '../../utils/access'
|
||||
import { requireUploadAuth, verifyApiUploadToken, getUploadUserId } from '../../utils/access'
|
||||
import { MAX_FILES_PER_UPLOAD } from '../../utils/constants'
|
||||
import {
|
||||
detectMimeFromSignature,
|
||||
@@ -11,22 +11,8 @@ import {
|
||||
processSingleImageUpload
|
||||
} from '../../utils/process-image-upload'
|
||||
import { createApiError } from '../../utils/api-error'
|
||||
import {
|
||||
DEFAULT_FOLDER,
|
||||
isValidFolderName,
|
||||
normalizeFolderName
|
||||
} from '../../utils/image-key'
|
||||
import { checkUploadRateLimit } from '../../utils/rate-limit'
|
||||
|
||||
function readFormText(
|
||||
formData: { name?: string, data?: Buffer | Uint8Array }[],
|
||||
name: string
|
||||
): string {
|
||||
const part = formData.find(item => item.name === name && item.data?.length)
|
||||
if (!part?.data) return ''
|
||||
return new TextDecoder().decode(part.data).trim()
|
||||
}
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
await requireUploadAuth(event)
|
||||
|
||||
@@ -44,36 +30,6 @@ export default defineEventHandler(async (event) => {
|
||||
const source = verifyApiUploadToken(event) ? 'api' : 'web'
|
||||
const uploadUserId = await getUploadUserId(event)
|
||||
|
||||
const query = getQuery(event)
|
||||
const folderFromForm = readFormText(formData, 'folder') || readFormText(formData, 'type')
|
||||
const folderFromQuery = typeof query.folder === 'string'
|
||||
? query.folder
|
||||
: typeof query.type === 'string'
|
||||
? query.type
|
||||
: ''
|
||||
const requestedFolder = normalizeFolderName(folderFromForm || folderFromQuery)
|
||||
const canCustomFolder = await canUseCustomUploadFolder(event)
|
||||
|
||||
if (!canCustomFolder && requestedFolder && requestedFolder !== DEFAULT_FOLDER) {
|
||||
createApiError(
|
||||
event,
|
||||
'FORBIDDEN',
|
||||
'当前账号不支持自定义上传目录',
|
||||
403
|
||||
)
|
||||
}
|
||||
|
||||
const folder = canCustomFolder ? requestedFolder : ''
|
||||
|
||||
if (folder && !isValidFolderName(folder)) {
|
||||
createApiError(
|
||||
event,
|
||||
'INVALID_REQUEST',
|
||||
'无效的 folder:仅允许字母数字开头,可含 - _,最长 32 位,且不能是 api/stats 等保留名',
|
||||
400
|
||||
)
|
||||
}
|
||||
|
||||
// 兼容 image / file / files 字段名(图床脚本 / 通用客户端 / 网页多图)
|
||||
const fileParts = formData.filter(
|
||||
part => (part.name === 'image' || part.name === 'file' || part.name === 'files')
|
||||
@@ -127,7 +83,6 @@ export default defineEventHandler(async (event) => {
|
||||
bytes,
|
||||
filename: part.filename,
|
||||
source,
|
||||
prefix: folder || DEFAULT_FOLDER,
|
||||
userId: uploadUserId
|
||||
})
|
||||
|
||||
|
||||
@@ -30,7 +30,7 @@ export default defineEventHandler(async (event) => {
|
||||
: undefined
|
||||
|
||||
const sourceRaw = typeof query.source === 'string' ? query.source : ''
|
||||
const source = (sourceRaw === 'web' || sourceRaw === 'api' || sourceRaw === 'twikoo')
|
||||
const source = (sourceRaw === 'web' || sourceRaw === 'api')
|
||||
? sourceRaw as LogSource
|
||||
: undefined
|
||||
|
||||
|
||||
@@ -38,7 +38,7 @@ export default defineEventHandler(async (event) => {
|
||||
uploadTotal: scoped.storedCount,
|
||||
deleteTotal: 0,
|
||||
uploadBytesTotal: scoped.uploadBytesTotal,
|
||||
bySource: { web: scoped.storedCount, api: 0, twikoo: 0 },
|
||||
bySource: { web: scoped.storedCount, api: 0 },
|
||||
byFolderUploads: scoped.byFolder.map(item => ({
|
||||
folder: item.folder,
|
||||
count: item.count
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
/**
|
||||
* 将 data/ 下与 images 并列的顶层目录中的图片迁入 data/images/{原目录名}/...
|
||||
*
|
||||
* 用法:
|
||||
* node server/cli/migrate-to-single-images.mjs # dry-run
|
||||
* node server/cli/migrate-to-single-images.mjs --apply # 执行迁移
|
||||
*
|
||||
* Docker:
|
||||
* docker exec pichost migrate
|
||||
* docker exec pichost migrate --apply
|
||||
*
|
||||
* 输出 data/mapping.json(旧 key → 新 key)供替换外链。执行后重启 PicHost。
|
||||
*/
|
||||
import { promises as fs } from 'node:fs'
|
||||
import { join, relative, dirname } from 'node:path'
|
||||
|
||||
const DATA_DIR = process.env.DATA_DIR || join(process.cwd(), 'data')
|
||||
const IMAGES_DIR = join(DATA_DIR, 'images')
|
||||
|
||||
const IMAGE_EXT = /\.(jpg|jpeg|png|webp|gif|svg|ico)$/i
|
||||
const SKIP_TOP_LEVEL_DIRS = new Set(['images'])
|
||||
|
||||
const apply = process.argv.includes('--apply')
|
||||
|
||||
async function exists(path) {
|
||||
try {
|
||||
await fs.access(path)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async function collectImageFiles(dir) {
|
||||
const files = []
|
||||
const entries = await fs.readdir(dir, { withFileTypes: true })
|
||||
for (const entry of entries) {
|
||||
const full = join(dir, entry.name)
|
||||
if (entry.isDirectory()) {
|
||||
files.push(...await collectImageFiles(full))
|
||||
} else if (
|
||||
entry.isFile()
|
||||
&& IMAGE_EXT.test(entry.name)
|
||||
&& !entry.name.endsWith('.meta.json')
|
||||
) {
|
||||
files.push(full)
|
||||
}
|
||||
}
|
||||
return files
|
||||
}
|
||||
|
||||
async function dirHasAnyFiles(dir) {
|
||||
let entries
|
||||
try {
|
||||
entries = await fs.readdir(dir, { withFileTypes: true })
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
for (const entry of entries) {
|
||||
if (entry.isFile()) return true
|
||||
if (entry.isDirectory() && await dirHasAnyFiles(join(dir, entry.name))) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
async function main() {
|
||||
if (!await exists(DATA_DIR)) {
|
||||
console.error(`数据目录不存在: ${DATA_DIR}`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const topEntries = await fs.readdir(DATA_DIR, { withFileTypes: true })
|
||||
const sourceDirs = topEntries
|
||||
.filter(entry => entry.isDirectory() && !SKIP_TOP_LEVEL_DIRS.has(entry.name))
|
||||
.map(entry => ({ name: entry.name, path: join(DATA_DIR, entry.name) }))
|
||||
|
||||
if (!sourceDirs.length) {
|
||||
console.log('未发现需迁移的顶层目录(data/ 下除 images 外无其它目录)')
|
||||
return
|
||||
}
|
||||
|
||||
const mapping = {}
|
||||
const plan = []
|
||||
|
||||
for (const { name, path } of sourceDirs) {
|
||||
const files = await collectImageFiles(path)
|
||||
for (const filePath of files) {
|
||||
const rel = relative(path, filePath).replace(/\\/g, '/')
|
||||
const newKey = `images/${name}/${rel}`
|
||||
const newDisk = join(IMAGES_DIR, name, rel)
|
||||
const oldKey = `${name}/${rel}`
|
||||
mapping[oldKey] = newKey
|
||||
plan.push({ from: filePath, to: newDisk, oldKey, newKey, sourceName: name })
|
||||
}
|
||||
}
|
||||
|
||||
if (!plan.length) {
|
||||
console.log('未发现需迁移的图片(已扫描 data/ 下除 images 外的顶层目录)')
|
||||
return
|
||||
}
|
||||
|
||||
const scanned = sourceDirs.map(dir => dir.name).join(', ')
|
||||
console.log(`已扫描顶层目录: ${scanned}`)
|
||||
console.log(`模式: ${apply ? 'APPLY' : 'DRY-RUN'}`)
|
||||
console.log(`待迁移 ${plan.length} 个文件:`)
|
||||
for (const item of plan.slice(0, 20)) {
|
||||
console.log(` ${item.oldKey} → ${item.newKey}`)
|
||||
}
|
||||
if (plan.length > 20) {
|
||||
console.log(` … 另有 ${plan.length - 20} 个`)
|
||||
}
|
||||
|
||||
const mappingPath = join(DATA_DIR, 'mapping.json')
|
||||
await fs.writeFile(mappingPath, JSON.stringify(mapping, null, 2))
|
||||
console.log(`已写入 ${mappingPath}`)
|
||||
|
||||
if (!apply) {
|
||||
console.log('未执行文件移动。确认后运行:')
|
||||
console.log(' docker exec pichost migrate --apply')
|
||||
console.log(' 或 npm run migrate -- --apply')
|
||||
return
|
||||
}
|
||||
|
||||
await fs.mkdir(IMAGES_DIR, { recursive: true })
|
||||
|
||||
for (const item of plan) {
|
||||
await fs.mkdir(dirname(item.to), { recursive: true })
|
||||
try {
|
||||
await fs.rename(item.from, item.to)
|
||||
} catch (error) {
|
||||
if (error?.code === 'EXDEV') {
|
||||
await fs.copyFile(item.from, item.to)
|
||||
await fs.unlink(item.from)
|
||||
} else {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
const metaFrom = item.from + '.meta.json'
|
||||
const metaTo = item.to + '.meta.json'
|
||||
try {
|
||||
await fs.rename(metaFrom, metaTo)
|
||||
} catch {
|
||||
// optional meta
|
||||
}
|
||||
}
|
||||
|
||||
const sourceNames = [...new Set(plan.map(item => item.sourceName))]
|
||||
for (const name of sourceNames) {
|
||||
const path = join(DATA_DIR, name)
|
||||
if (await dirHasAnyFiles(path)) {
|
||||
console.log(`保留 ${name}/(仍有未迁移文件)`)
|
||||
continue
|
||||
}
|
||||
await fs.rm(path, { recursive: true, force: true })
|
||||
console.log(`已移除空目录 ${name}/`)
|
||||
}
|
||||
|
||||
console.log('迁移完成。请重启 PicHost。')
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error)
|
||||
process.exit(1)
|
||||
})
|
||||
@@ -1,7 +1,7 @@
|
||||
import { clientIp, logError, logInfo, logWarn } from '../utils/logger'
|
||||
|
||||
/**
|
||||
* 只记录 /api/*,避免 /images、/twikoo 直链刷屏。
|
||||
* 只记录 /api/*,避免 /images 直链刷屏。
|
||||
*/
|
||||
export default defineNitroPlugin((nitroApp) => {
|
||||
nitroApp.hooks.hook('request', (event) => {
|
||||
|
||||
+57
-14
@@ -1,6 +1,6 @@
|
||||
import { getDataDir } from '../utils/data-dir'
|
||||
import { runAutoDeleteCleanup } from '../utils/auto-delete'
|
||||
import { migrateLocalImagesToIndex } from '../utils/image-index'
|
||||
import { migrateLocalImagesToIndex, purgeOrphanImageIndexRows, reconcileLegacyImageKeys, repairLocalImageIndexMetadata } from '../utils/image-index'
|
||||
import { ensureDefaultBackends } from '../utils/storage-backends'
|
||||
import { logException, logInfo } from '../utils/logger'
|
||||
|
||||
@@ -16,19 +16,62 @@ export default defineNitroPlugin(() => {
|
||||
})
|
||||
|
||||
ensureDefaultBackends()
|
||||
void migrateLocalImagesToIndex().then((count) => {
|
||||
if (count > 0) {
|
||||
logInfo('migrated local images to index', { count })
|
||||
}
|
||||
}).catch((error) => {
|
||||
logException('migrate local images to index failed', error)
|
||||
})
|
||||
|
||||
void runAutoDeleteCleanup().then((result) => {
|
||||
if (!result.skipped && (result.deleted > 0 || result.failed > 0)) {
|
||||
logInfo('startup auto-delete', { ...result })
|
||||
void (async () => {
|
||||
const dataDir = getDataDir()
|
||||
try {
|
||||
logInfo('开始同步图片索引:扫描本地磁盘', { path: `${dataDir}/images/` })
|
||||
|
||||
const inserted = await migrateLocalImagesToIndex()
|
||||
if (inserted > 0) {
|
||||
logInfo('磁盘扫描完成:已补全缺失索引', { inserted })
|
||||
} else {
|
||||
logInfo('磁盘扫描完成:索引与磁盘一致,无需补全')
|
||||
}
|
||||
|
||||
logInfo('开始归一化遗留图片路径(如 blog/ → images/blog/)')
|
||||
const reconciled = await reconcileLegacyImageKeys()
|
||||
if (reconciled.updated > 0 || reconciled.removed > 0) {
|
||||
logInfo('遗留路径归一化完成', reconciled)
|
||||
} else {
|
||||
logInfo('遗留路径归一化完成:无需调整')
|
||||
}
|
||||
|
||||
logInfo('开始校验索引与磁盘一致性,清理无文件的孤儿记录')
|
||||
const purged = await purgeOrphanImageIndexRows()
|
||||
if (purged > 0) {
|
||||
logInfo('孤儿索引清理完成', { removed: purged })
|
||||
} else {
|
||||
logInfo('孤儿索引清理完成:未发现孤儿记录')
|
||||
}
|
||||
|
||||
logInfo('开始修复存量 Content-Type 元数据')
|
||||
const repaired = repairLocalImageIndexMetadata()
|
||||
if (repaired > 0) {
|
||||
logInfo('Content-Type 修复完成', { repaired })
|
||||
} else {
|
||||
logInfo('Content-Type 修复完成:无需修复')
|
||||
}
|
||||
|
||||
logInfo('图片索引同步结束')
|
||||
} catch (error) {
|
||||
logException('图片索引同步失败', error)
|
||||
}
|
||||
}).catch((error) => {
|
||||
logException('startup auto-delete failed', error)
|
||||
})
|
||||
})()
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
logInfo('开始执行启动时自动删除检查')
|
||||
const result = await runAutoDeleteCleanup()
|
||||
if (result.skipped) {
|
||||
logInfo('自动删除检查完成:策略未启用')
|
||||
} else if (result.deleted > 0 || result.failed > 0) {
|
||||
logInfo('自动删除检查完成', { ...result })
|
||||
} else {
|
||||
logInfo('自动删除检查完成:无到期图片')
|
||||
}
|
||||
} catch (error) {
|
||||
logException('启动时自动删除失败', error)
|
||||
}
|
||||
})()
|
||||
})
|
||||
@@ -56,12 +56,11 @@ export default defineEventHandler(async (event) => {
|
||||
return easyImageError(415, '无法识别的图片格式')
|
||||
}
|
||||
|
||||
// Twikoo 评论图片单独存放在 twikoo/ 目录下
|
||||
// Twikoo 走 token 上传,记入 activity log source=api
|
||||
const result = await processSingleImageUpload(event, {
|
||||
bytes,
|
||||
filename: imagePart.filename,
|
||||
prefix: 'twikoo',
|
||||
source: 'twikoo'
|
||||
source: 'api'
|
||||
})
|
||||
|
||||
if ('error' in result) {
|
||||
|
||||
@@ -236,21 +236,3 @@ export async function getUploadUserId(event: H3Event): Promise<number | null> {
|
||||
const user = await getCurrentUser(event)
|
||||
return user?.id ?? null
|
||||
}
|
||||
|
||||
/** 管理员会话 / 全局 API Token 可使用自定义上传目录 */
|
||||
export async function canUseCustomUploadFolder(event: H3Event): Promise<boolean> {
|
||||
if (devBypass(event)) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (verifyGlobalApiUploadToken(event)) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (resolveUserIdFromApiToken(event) !== null) {
|
||||
return false
|
||||
}
|
||||
|
||||
const user = await getCurrentUser(event)
|
||||
return user?.role === 'admin'
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { contentTypeFromKey } from './content-type'
|
||||
|
||||
describe('contentTypeFromKey', () => {
|
||||
it('infers image types from extensions', () => {
|
||||
expect(contentTypeFromKey('images/a.webp')).toBe('image/webp')
|
||||
expect(contentTypeFromKey('images/file/111.webp')).toBe('image/webp')
|
||||
expect(contentTypeFromKey('images/2026/08/28/123.webp')).toBe('image/webp')
|
||||
expect(contentTypeFromKey('images/photo.JPG')).toBe('image/jpeg')
|
||||
})
|
||||
|
||||
it('falls back to octet-stream for unknown extensions', () => {
|
||||
expect(contentTypeFromKey('images/file.dat')).toBe('application/octet-stream')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,20 @@
|
||||
export function contentTypeFromKey(key: string): string {
|
||||
const ext = key.split('.').pop()?.toLowerCase()
|
||||
switch (ext) {
|
||||
case 'jpg':
|
||||
case 'jpeg':
|
||||
return 'image/jpeg'
|
||||
case 'png':
|
||||
return 'image/png'
|
||||
case 'gif':
|
||||
return 'image/gif'
|
||||
case 'webp':
|
||||
return 'image/webp'
|
||||
case 'svg':
|
||||
return 'image/svg+xml'
|
||||
case 'ico':
|
||||
return 'image/x-icon'
|
||||
default:
|
||||
return 'application/octet-stream'
|
||||
}
|
||||
}
|
||||
+12
-4
@@ -5,7 +5,7 @@ import { getDataDir } from './data-dir'
|
||||
import { logException } from './logger'
|
||||
|
||||
export type LogAction = 'upload' | 'delete'
|
||||
export type LogSource = 'web' | 'api' | 'twikoo'
|
||||
export type LogSource = 'web' | 'api'
|
||||
|
||||
export interface ActivityLogRow {
|
||||
id: number
|
||||
@@ -53,7 +53,7 @@ export function getDb(): DatabaseSync {
|
||||
original_name TEXT NOT NULL,
|
||||
size INTEGER NOT NULL DEFAULT 0,
|
||||
content_type TEXT NOT NULL DEFAULT '',
|
||||
source TEXT NOT NULL CHECK(source IN ('web', 'api', 'twikoo')),
|
||||
source TEXT NOT NULL CHECK(source IN ('web', 'api')),
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_activity_logs_created_at
|
||||
@@ -142,6 +142,10 @@ function migrateSchema(database: DatabaseSync): void {
|
||||
if (!activityLogColumns.some(column => column.name === 'backend_id')) {
|
||||
database.exec('ALTER TABLE activity_logs ADD COLUMN backend_id TEXT')
|
||||
}
|
||||
|
||||
database.exec(`
|
||||
UPDATE activity_logs SET source = 'api' WHERE source = 'twikoo'
|
||||
`)
|
||||
}
|
||||
|
||||
const USER_COLUMNS = 'id, username, password_hash, role, created_at, api_token, auto_delete_days, auto_delete_enabled_at'
|
||||
@@ -615,9 +619,13 @@ function countBySource(): Record<LogSource, number> {
|
||||
GROUP BY source
|
||||
`).all() as unknown as Array<{ source: LogSource, total: number }>
|
||||
|
||||
const result: Record<LogSource, number> = { web: 0, api: 0, twikoo: 0 }
|
||||
const result: Record<LogSource, number> = { web: 0, api: 0 }
|
||||
for (const row of rows) {
|
||||
result[row.source] = row.total
|
||||
const raw = row.source as string
|
||||
const source = raw === 'twikoo' ? 'api' : row.source
|
||||
if (source === 'web' || source === 'api') {
|
||||
result[source] += row.total
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -4,12 +4,15 @@ import { validateImageKey } from './image-key'
|
||||
describe('image index key validation', () => {
|
||||
it('accepts standard image keys used in index', () => {
|
||||
expect(validateImageKey('images/2026/03/abc.webp')).toBe(true)
|
||||
expect(validateImageKey('twikoo/2026/03/abc.webp')).toBe(true)
|
||||
expect(validateImageKey('images/file/111.webp')).toBe(true)
|
||||
expect(validateImageKey('images/2026/08/28/123.webp')).toBe(true)
|
||||
expect(validateImageKey('images/abc.webp')).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects path traversal keys', () => {
|
||||
it('rejects legacy top-level keys and path traversal', () => {
|
||||
expect(validateImageKey('../secret')).toBe(false)
|
||||
expect(validateImageKey('images/../../etc/passwd')).toBe(false)
|
||||
expect(validateImageKey('twikoo/2026/03/abc.webp')).toBe(false)
|
||||
expect(validateImageKey('blog/abc.webp')).toBe(false)
|
||||
})
|
||||
})
|
||||
+152
-81
@@ -1,8 +1,9 @@
|
||||
import { promises as fs } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { getDb } from './db'
|
||||
import { getDataDir } from './data-dir'
|
||||
import { DEFAULT_FOLDER, isValidFolderName, sortFolderNames, validateImageKey } from './image-key'
|
||||
import { contentTypeFromKey } from './content-type'
|
||||
import { DEFAULT_FOLDER, toCanonicalImageKey, validateImageKey } from './image-key'
|
||||
import { isBareImageFilename, isHiddenImageDatePath } from './image-public-path'
|
||||
import {
|
||||
ensureDefaultBackends,
|
||||
@@ -100,6 +101,99 @@ export function deleteImageIndex(key: string): void {
|
||||
getDb().prepare('DELETE FROM images WHERE key = ?').run(key)
|
||||
}
|
||||
|
||||
function preferIndexedImageKey(key: string): string {
|
||||
const canonical = toCanonicalImageKey(key)
|
||||
if (canonical && getImageIndexRow(canonical)) return canonical
|
||||
if (validateImageKey(key)) return key
|
||||
return key
|
||||
}
|
||||
|
||||
async function fileExistsAtKey(key: string): Promise<boolean> {
|
||||
try {
|
||||
await fs.access(keyToFilePath(key))
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/** 将 SQLite 中遗留顶层目录 key 归一为 images/ 下路径,并同步磁盘文件 */
|
||||
export async function reconcileLegacyImageKeys(): Promise<{
|
||||
updated: number
|
||||
removed: number
|
||||
}> {
|
||||
ensureStorageSchema()
|
||||
const rows = getDb().prepare('SELECT key FROM images').all() as Array<{ key: string }>
|
||||
let updated = 0
|
||||
let removed = 0
|
||||
|
||||
for (const { key } of rows) {
|
||||
if (validateImageKey(key)) continue
|
||||
|
||||
const canonical = toCanonicalImageKey(key)
|
||||
if (!canonical) continue
|
||||
|
||||
const canonicalRow = getImageIndexRow(canonical)
|
||||
if (canonicalRow) {
|
||||
deleteImageIndex(key)
|
||||
removed++
|
||||
continue
|
||||
}
|
||||
|
||||
const canonicalExists = await fileExistsAtKey(canonical)
|
||||
const legacyExists = await fileExistsAtKey(key)
|
||||
|
||||
if (canonicalExists) {
|
||||
getDb().prepare(`
|
||||
UPDATE images
|
||||
SET key = ?, folder = ?
|
||||
WHERE key = ?
|
||||
`).run(canonical, DEFAULT_FOLDER, key)
|
||||
updated++
|
||||
continue
|
||||
}
|
||||
|
||||
if (legacyExists) {
|
||||
const legacyPath = keyToFilePath(key)
|
||||
const canonicalPath = keyToFilePath(canonical)
|
||||
await fs.mkdir(dirname(canonicalPath), { recursive: true })
|
||||
await fs.rename(legacyPath, canonicalPath)
|
||||
try {
|
||||
await fs.rename(legacyPath + META_SUFFIX, canonicalPath + META_SUFFIX)
|
||||
} catch {
|
||||
// no meta
|
||||
}
|
||||
getDb().prepare(`
|
||||
UPDATE images
|
||||
SET key = ?, folder = ?
|
||||
WHERE key = ?
|
||||
`).run(canonical, DEFAULT_FOLDER, key)
|
||||
updated++
|
||||
}
|
||||
}
|
||||
|
||||
return { updated, removed }
|
||||
}
|
||||
|
||||
/** 移除 SQLite 中有记录但本地磁盘无对应文件的索引行 */
|
||||
export async function purgeOrphanImageIndexRows(): Promise<number> {
|
||||
ensureStorageSchema()
|
||||
const rows = getDb().prepare(`
|
||||
SELECT key, backend_id FROM images
|
||||
`).all() as Array<{ key: string, backend_id: string }>
|
||||
|
||||
let removed = 0
|
||||
for (const row of rows) {
|
||||
if (row.backend_id !== LOCAL_BACKEND_ID) continue
|
||||
if (await fileExistsAtKey(row.key)) continue
|
||||
const canonical = toCanonicalImageKey(row.key)
|
||||
if (canonical && await fileExistsAtKey(canonical)) continue
|
||||
deleteImageIndex(row.key)
|
||||
removed++
|
||||
}
|
||||
return removed
|
||||
}
|
||||
|
||||
export function getImageIndexRow(key: string): ImageIndexRow | null {
|
||||
ensureStorageSchema()
|
||||
const row = getDb().prepare(`
|
||||
@@ -129,7 +223,7 @@ export function findImageKeyByDatePath(datePath: string): string | null {
|
||||
const fallback = `${DEFAULT_FOLDER}/${datePath}`
|
||||
return validateImageKey(fallback) ? fallback : null
|
||||
}
|
||||
if (rows.length === 1) return rows[0]!.key
|
||||
if (rows.length === 1) return preferIndexedImageKey(rows[0]!.key)
|
||||
|
||||
const exactDefault = rows.find(row => row.key === `${DEFAULT_FOLDER}/${datePath}`)
|
||||
if (exactDefault) return exactDefault.key
|
||||
@@ -152,7 +246,7 @@ export function findImageKeyByFilename(filename: string): string | null {
|
||||
const flatFallback = `${DEFAULT_FOLDER}/${filename}`
|
||||
return validateImageKey(flatFallback) ? flatFallback : null
|
||||
}
|
||||
if (rows.length === 1) return rows[0]!.key
|
||||
if (rows.length === 1) return preferIndexedImageKey(rows[0]!.key)
|
||||
|
||||
const exactDefault = rows.find(row => row.key === `${DEFAULT_FOLDER}/${filename}`)
|
||||
if (exactDefault) return exactDefault.key
|
||||
@@ -161,57 +255,39 @@ export function findImageKeyByFilename(filename: string): string | null {
|
||||
return defaultFolder?.key ?? rows[0]!.key
|
||||
}
|
||||
|
||||
async function readDirNames(path: string): Promise<string[]> {
|
||||
try {
|
||||
const entries = await fs.readdir(path, { withFileTypes: true })
|
||||
return entries.filter(e => e.isDirectory()).map(e => e.name)
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
async function readFileNames(path: string): Promise<string[]> {
|
||||
try {
|
||||
const entries = await fs.readdir(path, { withFileTypes: true })
|
||||
return entries.filter(e => e.isFile()).map(e => e.name)
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
async function listLocalImageKeys(): Promise<string[]> {
|
||||
async function collectImageKeysRecursive(
|
||||
dir: string,
|
||||
keyPrefix: string
|
||||
): Promise<string[]> {
|
||||
const keys: string[] = []
|
||||
const dataDir = getDataDir()
|
||||
const prefixes = await readDirNames(dataDir)
|
||||
|
||||
for (const prefix of prefixes) {
|
||||
if (!isValidFolderName(prefix)) continue
|
||||
const root = join(dataDir, prefix)
|
||||
const rootFiles = await readFileNames(root)
|
||||
for (const file of rootFiles) {
|
||||
if (file.endsWith(META_SUFFIX)) continue
|
||||
const key = `${prefix}/${file}`
|
||||
if (validateImageKey(key)) keys.push(key)
|
||||
}
|
||||
|
||||
const years = await readDirNames(root)
|
||||
for (const year of years) {
|
||||
if (!/^\d{4}$/.test(year)) continue
|
||||
const months = await readDirNames(join(root, year))
|
||||
for (const month of months) {
|
||||
const files = await readFileNames(join(root, year, month))
|
||||
for (const file of files) {
|
||||
if (file.endsWith(META_SUFFIX)) continue
|
||||
const key = `${prefix}/${year}/${month}/${file}`
|
||||
if (validateImageKey(key)) keys.push(key)
|
||||
}
|
||||
try {
|
||||
const entries = await fs.readdir(dir, { withFileTypes: true })
|
||||
for (const entry of entries) {
|
||||
const name = String(entry.name)
|
||||
if (entry.isDirectory()) {
|
||||
const childKeys = await collectImageKeysRecursive(
|
||||
join(dir, name),
|
||||
`${keyPrefix}/${name}`
|
||||
)
|
||||
keys.push(...childKeys)
|
||||
} else if (entry.isFile()) {
|
||||
if (name.endsWith(META_SUFFIX)) continue
|
||||
const key = `${keyPrefix}/${name}`
|
||||
if (validateImageKey(key)) keys.push(key)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
return keys
|
||||
}
|
||||
|
||||
return keys
|
||||
}
|
||||
|
||||
async function listLocalImageKeys(): Promise<string[]> {
|
||||
const imagesRoot = join(getDataDir(), DEFAULT_FOLDER)
|
||||
return collectImageKeysRecursive(imagesRoot, DEFAULT_FOLDER)
|
||||
}
|
||||
|
||||
export async function migrateLocalImagesToIndex(): Promise<number> {
|
||||
ensureDefaultBackends()
|
||||
const keys = await listLocalImageKeys()
|
||||
@@ -231,12 +307,17 @@ export async function migrateLocalImagesToIndex(): Promise<number> {
|
||||
const meta = await readLocalMeta(key)
|
||||
const uploadedAt = meta.uploadedAt ?? stat.mtime.toISOString()
|
||||
|
||||
const inferredType = contentTypeFromKey(key)
|
||||
const contentType = meta.contentType && meta.contentType !== 'application/octet-stream'
|
||||
? meta.contentType
|
||||
: inferredType
|
||||
|
||||
insertImageIndex({
|
||||
key,
|
||||
backendId: LOCAL_BACKEND_ID,
|
||||
userId: meta.userId ?? null,
|
||||
originalName: meta.originalName ?? key.split('/').pop() ?? 'image',
|
||||
contentType: meta.contentType ?? 'application/octet-stream',
|
||||
contentType,
|
||||
size: stat.size,
|
||||
uploadedAt
|
||||
})
|
||||
@@ -247,28 +328,36 @@ export async function migrateLocalImagesToIndex(): Promise<number> {
|
||||
}
|
||||
|
||||
export async function listFolders(): Promise<string[]> {
|
||||
ensureStorageSchema()
|
||||
const rows = getDb().prepare(`
|
||||
SELECT DISTINCT folder FROM images ORDER BY folder ASC
|
||||
`).all() as Array<{ folder: string }>
|
||||
|
||||
const folders = rows.map(row => row.folder).filter(isValidFolderName)
|
||||
return sortFolderNames(folders)
|
||||
return [DEFAULT_FOLDER]
|
||||
}
|
||||
|
||||
export async function listFoldersForUser(
|
||||
userFilter?: number | 'admin'
|
||||
_userFilter?: number | 'admin'
|
||||
): Promise<string[]> {
|
||||
return [DEFAULT_FOLDER]
|
||||
}
|
||||
|
||||
/** 修正存量索引中错误的 application/octet-stream */
|
||||
export function repairLocalImageIndexMetadata(): number {
|
||||
ensureStorageSchema()
|
||||
const params: Array<string | number> = []
|
||||
const userSql = buildUserFilterSql(userFilter, params)
|
||||
|
||||
const rows = getDb().prepare(`
|
||||
SELECT DISTINCT folder FROM images WHERE 1=1${userSql} ORDER BY folder ASC
|
||||
`).all(...params) as Array<{ folder: string }>
|
||||
SELECT key, content_type FROM images WHERE content_type = 'application/octet-stream'
|
||||
`).all() as Array<{ key: string, content_type: string }>
|
||||
|
||||
const folders = rows.map(row => row.folder).filter(isValidFolderName)
|
||||
return sortFolderNames(folders)
|
||||
let repaired = 0
|
||||
const update = getDb().prepare(`
|
||||
UPDATE images SET content_type = ? WHERE key = ?
|
||||
`)
|
||||
|
||||
for (const row of rows) {
|
||||
const inferred = contentTypeFromKey(row.key)
|
||||
if (inferred !== 'application/octet-stream') {
|
||||
update.run(inferred, row.key)
|
||||
repaired++
|
||||
}
|
||||
}
|
||||
|
||||
return repaired
|
||||
}
|
||||
|
||||
function buildBackendFilterSql(
|
||||
@@ -281,7 +370,6 @@ function buildBackendFilterSql(
|
||||
}
|
||||
|
||||
export async function countImages(
|
||||
folder?: string,
|
||||
userFilter?: number | 'admin',
|
||||
backendId?: string
|
||||
): Promise<number> {
|
||||
@@ -289,11 +377,6 @@ export async function countImages(
|
||||
const params: Array<string | number> = []
|
||||
const clauses: string[] = ['1=1']
|
||||
|
||||
if (folder && isValidFolderName(folder)) {
|
||||
clauses.push('folder = ?')
|
||||
params.push(folder)
|
||||
}
|
||||
|
||||
const userSql = buildUserFilterSql(userFilter, params)
|
||||
const backendSql = buildBackendFilterSql(backendId, params)
|
||||
const row = getDb().prepare(`
|
||||
@@ -322,7 +405,6 @@ function paginateSlice<T>(
|
||||
export async function listImages(options: {
|
||||
limit: number
|
||||
page?: number
|
||||
folder?: string
|
||||
userFilter?: number | 'admin'
|
||||
backendId?: string
|
||||
}): Promise<PaginatedResult<StoredImage>> {
|
||||
@@ -330,11 +412,6 @@ export async function listImages(options: {
|
||||
const params: Array<string | number> = []
|
||||
const clauses: string[] = ['1=1']
|
||||
|
||||
if (options.folder && isValidFolderName(options.folder)) {
|
||||
clauses.push('folder = ?')
|
||||
params.push(options.folder)
|
||||
}
|
||||
|
||||
const userSql = buildUserFilterSql(options.userFilter, params)
|
||||
const backendSql = buildBackendFilterSql(options.backendId, params)
|
||||
const rows = getDb().prepare(`
|
||||
@@ -355,7 +432,6 @@ export async function searchImages(options: {
|
||||
query: string
|
||||
limit: number
|
||||
page?: number
|
||||
folder?: string
|
||||
userFilter?: number | 'admin'
|
||||
backendId?: string
|
||||
}): Promise<PaginatedResult<StoredImage>> {
|
||||
@@ -364,11 +440,6 @@ export async function searchImages(options: {
|
||||
const params: Array<string | number> = []
|
||||
const clauses: string[] = ['1=1']
|
||||
|
||||
if (options.folder && isValidFolderName(options.folder)) {
|
||||
clauses.push('folder = ?')
|
||||
params.push(options.folder)
|
||||
}
|
||||
|
||||
const userSql = buildUserFilterSql(options.userFilter, params)
|
||||
const backendSql = buildBackendFilterSql(options.backendId, params)
|
||||
const rows = getDb().prepare(`
|
||||
|
||||
@@ -1,31 +1,29 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { generateImageKey, sortFolderNames, validateImageKey } from './image-key'
|
||||
import { generateImageKey, validateImageKey } from './image-key'
|
||||
|
||||
describe('validateImageKey', () => {
|
||||
it('accepts valid date-layout keys', () => {
|
||||
expect(validateImageKey('images/2026/08/abc123.webp')).toBe(true)
|
||||
expect(validateImageKey('twikoo/2025/01/photo.jpg')).toBe(true)
|
||||
})
|
||||
|
||||
it('accepts valid flat-layout keys', () => {
|
||||
expect(validateImageKey('images/abc123.webp')).toBe(true)
|
||||
expect(validateImageKey('twikoo/photo.jpg')).toBe(true)
|
||||
})
|
||||
|
||||
it('sorts images first then alphabetically', () => {
|
||||
expect(sortFolderNames(['blog', 'images', 'twikoo'])).toEqual(['images', 'blog', 'twikoo'])
|
||||
expect(sortFolderNames(['blog'])).toEqual(['images', 'blog'])
|
||||
it('accepts migrated nested paths under images/', () => {
|
||||
expect(validateImageKey('images/file/111.webp')).toBe(true)
|
||||
expect(validateImageKey('images/2026/08/28/123.webp')).toBe(true)
|
||||
expect(validateImageKey('images/twikoo/2025/01/photo.jpg')).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects path traversal and malformed keys', () => {
|
||||
it('rejects non-images top-level and path traversal', () => {
|
||||
expect(validateImageKey('')).toBe(false)
|
||||
expect(validateImageKey('/images/2026/08/a.webp')).toBe(false)
|
||||
expect(validateImageKey('images/2026/08/../secret.webp')).toBe(false)
|
||||
expect(validateImageKey('images//2026/08/a.webp')).toBe(false)
|
||||
expect(validateImageKey('api/2026/08/a.webp')).toBe(false)
|
||||
expect(validateImageKey('images/26/8/a.webp')).toBe(false)
|
||||
expect(validateImageKey('blog/2026/08/a.webp')).toBe(false)
|
||||
expect(validateImageKey('twikoo/photo.jpg')).toBe(false)
|
||||
expect(validateImageKey('images/2026/08/a.txt')).toBe(false)
|
||||
expect(validateImageKey('images/2026/a.webp')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -33,14 +31,20 @@ describe('generateImageKey', () => {
|
||||
const date = new Date('2026-08-15T12:00:00Z')
|
||||
|
||||
it('generates date-layout keys by default', () => {
|
||||
const key = generateImageKey('image/webp', date, 'images')
|
||||
expect(key).toMatch(/^images\/2026\/08\/[A-Za-z0-9]+\.webp$/)
|
||||
const key = generateImageKey('image/webp', date)
|
||||
expect(key).toMatch(/^images\/2026\/08\/[A-Za-z0-9]{12}\.webp$/)
|
||||
expect(validateImageKey(key)).toBe(true)
|
||||
})
|
||||
|
||||
it('generates flat-layout keys when requested', () => {
|
||||
const key = generateImageKey('image/webp', date, 'images', 'flat')
|
||||
expect(key).toMatch(/^images\/[A-Za-z0-9]+\.webp$/)
|
||||
const key = generateImageKey('image/webp', date, 'flat')
|
||||
expect(key).toMatch(/^images\/[A-Za-z0-9]{12}\.webp$/)
|
||||
expect(validateImageKey(key)).toBe(true)
|
||||
})
|
||||
|
||||
it('uses 12-character random IDs', () => {
|
||||
const key = generateImageKey('image/webp', date, 'flat')
|
||||
const id = key.split('/')[1]?.replace('.webp', '') ?? ''
|
||||
expect(id.length).toBe(12)
|
||||
})
|
||||
})
|
||||
+31
-57
@@ -13,20 +13,6 @@ export const DEFAULT_FOLDER = 'images'
|
||||
|
||||
export type StorageLayout = 'date' | 'flat'
|
||||
|
||||
/** 避免与站点路由 / 静态资源冲突 */
|
||||
const RESERVED_FOLDERS = new Set([
|
||||
'api',
|
||||
'stats',
|
||||
'_nuxt',
|
||||
'__nuxt',
|
||||
'assets',
|
||||
'public',
|
||||
'favicon',
|
||||
'robots',
|
||||
'sitemap',
|
||||
'sw'
|
||||
])
|
||||
|
||||
const IMAGE_EXT_PATTERN = /\.(jpg|jpeg|png|webp|gif|svg|ico)$/i
|
||||
|
||||
function generateRandomId(length = 12): string {
|
||||
@@ -40,51 +26,26 @@ export function mimeToExtension(mime: AllowedMimeType): string {
|
||||
return EXT_MAP[mime]
|
||||
}
|
||||
|
||||
/** 目录名:字母数字开头,允许 - _,最长 32;不含路径分隔符 */
|
||||
export function isValidFolderName(name: string): boolean {
|
||||
if (!name || typeof name !== 'string') return false
|
||||
if (name.length > 32) return false
|
||||
if (!/^[A-Za-z0-9][A-Za-z0-9_-]*$/.test(name)) return false
|
||||
return !RESERVED_FOLDERS.has(name.toLowerCase())
|
||||
}
|
||||
|
||||
export function normalizeFolderName(raw: string | undefined | null): string {
|
||||
const trimmed = raw?.trim() || DEFAULT_FOLDER
|
||||
return trimmed
|
||||
}
|
||||
|
||||
/** 默认目录 images 始终排在首位,其余按字母序 */
|
||||
export function sortFolderNames(folders: string[]): string[] {
|
||||
const seen = new Set<string>()
|
||||
const valid: string[] = []
|
||||
for (const folder of folders) {
|
||||
if (!isValidFolderName(folder) || seen.has(folder)) continue
|
||||
seen.add(folder)
|
||||
valid.push(folder)
|
||||
}
|
||||
if (!seen.has(DEFAULT_FOLDER)) {
|
||||
valid.push(DEFAULT_FOLDER)
|
||||
}
|
||||
const rest = valid
|
||||
.filter(folder => folder !== DEFAULT_FOLDER)
|
||||
.sort((a, b) => a.localeCompare(b))
|
||||
return [DEFAULT_FOLDER, ...rest]
|
||||
/** 路径段:字母数字开头,允许 - _ .,最长 64 */
|
||||
function isValidPathSegment(segment: string): boolean {
|
||||
if (!segment || segment.length > 64) return false
|
||||
if (segment === '.' || segment === '..') return false
|
||||
return /^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(segment)
|
||||
}
|
||||
|
||||
export function generateImageKey(
|
||||
contentType: AllowedMimeType,
|
||||
date = new Date(),
|
||||
prefix = DEFAULT_FOLDER,
|
||||
layout: StorageLayout = 'date'
|
||||
): string {
|
||||
const id = generateRandomId(12)
|
||||
const ext = mimeToExtension(contentType)
|
||||
if (layout === 'flat') {
|
||||
return `${prefix}/${id}.${ext}`
|
||||
return `${DEFAULT_FOLDER}/${id}.${ext}`
|
||||
}
|
||||
const year = date.getUTCFullYear()
|
||||
const month = String(date.getUTCMonth() + 1).padStart(2, '0')
|
||||
return `${prefix}/${year}/${month}/${id}.${ext}`
|
||||
return `${DEFAULT_FOLDER}/${year}/${month}/${id}.${ext}`
|
||||
}
|
||||
|
||||
export function validateImageKey(key: string): boolean {
|
||||
@@ -96,21 +57,34 @@ export function validateImageKey(key: string): boolean {
|
||||
const parts = key.split('/')
|
||||
if (parts.length < 2 || parts.some(part => !part)) return false
|
||||
|
||||
const folder = parts[0]!
|
||||
if (!isValidFolderName(folder)) return false
|
||||
if (parts[0] !== DEFAULT_FOLDER) return false
|
||||
|
||||
const filename = parts[parts.length - 1]!
|
||||
if (!IMAGE_EXT_PATTERN.test(filename)) return false
|
||||
|
||||
if (parts.length === 2) {
|
||||
return true
|
||||
for (let i = 1; i < parts.length - 1; i++) {
|
||||
if (!isValidPathSegment(parts[i]!)) return false
|
||||
}
|
||||
|
||||
if (parts.length === 4) {
|
||||
const year = parts[1]!
|
||||
const month = parts[2]!
|
||||
return /^\d{4}$/.test(year) && /^\d{2}$/.test(month)
|
||||
}
|
||||
|
||||
return false
|
||||
return true
|
||||
}
|
||||
|
||||
/** 将遗留顶层目录 key(如 blog/foo.webp)映射为 images/blog/foo.webp */
|
||||
export function toCanonicalImageKey(key: string): string | null {
|
||||
if (validateImageKey(key)) return key
|
||||
if (!key || key.startsWith('/') || key.includes('..') || key.includes('//')) return null
|
||||
|
||||
const parts = key.split('/')
|
||||
if (parts.length < 2 || parts.some(part => !part)) return null
|
||||
if (parts[0] === DEFAULT_FOLDER) return null
|
||||
|
||||
const filename = parts[parts.length - 1]!
|
||||
if (!IMAGE_EXT_PATTERN.test(filename)) return null
|
||||
|
||||
for (let i = 0; i < parts.length - 1; i++) {
|
||||
if (!isValidPathSegment(parts[i]!)) return null
|
||||
}
|
||||
|
||||
const canonical = `${DEFAULT_FOLDER}/${key}`
|
||||
return validateImageKey(canonical) ? canonical : null
|
||||
}
|
||||
@@ -13,7 +13,7 @@ describe('toPublicImagePath', () => {
|
||||
|
||||
it('returns basename when hideFolder is true', () => {
|
||||
expect(toPublicImagePath('images/2026/08/a.webp', true)).toBe('a.webp')
|
||||
expect(toPublicImagePath('blog/2026/08/a.webp', true)).toBe('a.webp')
|
||||
expect(toPublicImagePath('images/blog/2026/08/a.webp', true)).toBe('a.webp')
|
||||
expect(toPublicImagePath('images/a.webp', true)).toBe('a.webp')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -62,7 +62,6 @@ export async function processSingleImageUpload(
|
||||
input: {
|
||||
bytes: Uint8Array
|
||||
filename?: string
|
||||
prefix?: string
|
||||
source?: LogSource
|
||||
userId?: number | null
|
||||
}
|
||||
@@ -123,12 +122,7 @@ export async function processSingleImageUpload(
|
||||
|
||||
const uploadedAt = new Date().toISOString()
|
||||
const layout = getStorageLayout(event)
|
||||
const key = generateImageKey(
|
||||
compressed.mime,
|
||||
new Date(uploadedAt),
|
||||
input.prefix ?? 'images',
|
||||
layout
|
||||
)
|
||||
const key = generateImageKey(compressed.mime, new Date(uploadedAt), layout)
|
||||
|
||||
let backendId: string
|
||||
try {
|
||||
|
||||
+68
-7
@@ -1,10 +1,15 @@
|
||||
import type { ReadStream } from 'node:fs'
|
||||
import { promises as fs } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import type { Readable } from 'node:stream'
|
||||
import { contentTypeFromKey } from './content-type'
|
||||
import { getDataDir } from './data-dir'
|
||||
import {
|
||||
countImages as countImagesFromIndex,
|
||||
deleteImageIndex,
|
||||
getFolderStorageStats as getFolderStorageStatsFromIndex,
|
||||
getImageIndexAsStored,
|
||||
getImageIndexRow,
|
||||
getUserScopedStorageStats as getUserScopedStorageStatsFromIndex,
|
||||
insertImageIndex,
|
||||
listFolders as listFoldersFromIndex,
|
||||
@@ -13,6 +18,7 @@ import {
|
||||
listImages as listImagesFromIndex,
|
||||
searchImages as searchImagesFromIndex
|
||||
} from './image-index'
|
||||
import { DEFAULT_FOLDER, toCanonicalImageKey, validateImageKey } from './image-key'
|
||||
import {
|
||||
getActiveBackend,
|
||||
getBackendForKey
|
||||
@@ -33,6 +39,58 @@ export type {
|
||||
|
||||
export { getDataDir } from './data-dir'
|
||||
|
||||
function keyToFilePath(key: string): string {
|
||||
return join(getDataDir(), ...key.split('/'))
|
||||
}
|
||||
|
||||
async function fileExistsAtKey(key: string): Promise<boolean> {
|
||||
try {
|
||||
await fs.access(keyToFilePath(key))
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/** 删除/变更前解析 key:接受遗留 blog/… 并映射到 images/blog/… */
|
||||
export async function resolveStorageImageKey(key: string): Promise<string | null> {
|
||||
if (!key || typeof key !== 'string') return null
|
||||
|
||||
if (validateImageKey(key)) {
|
||||
if (await fileExistsAtKey(key) || getImageIndexRow(key)) return key
|
||||
return null
|
||||
}
|
||||
|
||||
if (getImageIndexRow(key)) {
|
||||
const canonical = toCanonicalImageKey(key)
|
||||
if (canonical && (await fileExistsAtKey(canonical) || getImageIndexRow(canonical))) {
|
||||
return canonical
|
||||
}
|
||||
if (await fileExistsAtKey(key)) return key
|
||||
return canonical ?? key
|
||||
}
|
||||
|
||||
const canonical = toCanonicalImageKey(key)
|
||||
if (canonical && (await fileExistsAtKey(canonical) || getImageIndexRow(canonical))) {
|
||||
return canonical
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function deleteImageIndexAliases(storageKey: string, requestedKey?: string) {
|
||||
deleteImageIndex(storageKey)
|
||||
if (requestedKey && requestedKey !== storageKey) {
|
||||
deleteImageIndex(requestedKey)
|
||||
}
|
||||
if (storageKey.startsWith(`${DEFAULT_FOLDER}/`)) {
|
||||
const legacy = storageKey.slice(DEFAULT_FOLDER.length + 1)
|
||||
if (legacy && legacy !== storageKey) {
|
||||
deleteImageIndex(legacy)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function putImage(
|
||||
key: string,
|
||||
bytes: Uint8Array,
|
||||
@@ -59,11 +117,17 @@ export async function headImage(key: string): Promise<StoredImage | null> {
|
||||
|
||||
const indexed = getImageIndexAsStored(key)
|
||||
if (indexed) {
|
||||
let contentType = indexed.contentType
|
||||
if (contentType === 'application/octet-stream') {
|
||||
contentType = stored.contentType !== 'application/octet-stream'
|
||||
? stored.contentType
|
||||
: contentTypeFromKey(key)
|
||||
}
|
||||
return {
|
||||
...stored,
|
||||
backendId: backend.id,
|
||||
originalName: indexed.originalName,
|
||||
contentType: indexed.contentType,
|
||||
contentType,
|
||||
uploadedAt: indexed.uploadedAt,
|
||||
userId: indexed.userId
|
||||
}
|
||||
@@ -90,10 +154,10 @@ export async function createImageStream(
|
||||
return backend.createStream(key, range)
|
||||
}
|
||||
|
||||
export async function deleteImage(key: string): Promise<void> {
|
||||
export async function deleteImage(key: string, requestedKey?: string): Promise<void> {
|
||||
const backend = await getBackendForKey(key)
|
||||
await backend.delete(key)
|
||||
deleteImageIndex(key)
|
||||
deleteImageIndexAliases(key, requestedKey)
|
||||
}
|
||||
|
||||
export async function listFolders(): Promise<string[]> {
|
||||
@@ -106,11 +170,10 @@ export async function listImageKeys(): Promise<string[]> {
|
||||
}
|
||||
|
||||
export async function countImages(
|
||||
folder?: string,
|
||||
userFilter?: number | 'admin',
|
||||
backendId?: string
|
||||
): Promise<number> {
|
||||
return countImagesFromIndex(folder, userFilter, backendId)
|
||||
return countImagesFromIndex(userFilter, backendId)
|
||||
}
|
||||
|
||||
export async function getFolderStorageStats(
|
||||
@@ -138,7 +201,6 @@ export async function getUserScopedStorageStats(userId: number): Promise<{
|
||||
export async function listImages(options: {
|
||||
limit: number
|
||||
page?: number
|
||||
folder?: string
|
||||
userFilter?: number | 'admin'
|
||||
backendId?: string
|
||||
}): Promise<PaginatedResult<StoredImage>> {
|
||||
@@ -149,7 +211,6 @@ export async function searchImages(options: {
|
||||
query: string
|
||||
limit: number
|
||||
page?: number
|
||||
folder?: string
|
||||
userFilter?: number | 'admin'
|
||||
backendId?: string
|
||||
}): Promise<PaginatedResult<StoredImage>> {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { createReadStream, promises as fs } from 'node:fs'
|
||||
import type { ReadStream } from 'node:fs'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { contentTypeFromKey } from '../content-type'
|
||||
import { getDataDir } from '../data-dir'
|
||||
import { validateImageKey } from '../image-key'
|
||||
import type { StoredImage, StoredImageMeta, StorageBackend } from './types'
|
||||
@@ -11,27 +12,6 @@ function keyToFilePath(key: string): string {
|
||||
return join(getDataDir(), ...key.split('/'))
|
||||
}
|
||||
|
||||
function contentTypeFromKey(key: string): string {
|
||||
const ext = key.split('.').pop()?.toLowerCase()
|
||||
switch (ext) {
|
||||
case 'jpg':
|
||||
case 'jpeg':
|
||||
return 'image/jpeg'
|
||||
case 'png':
|
||||
return 'image/png'
|
||||
case 'gif':
|
||||
return 'image/gif'
|
||||
case 'webp':
|
||||
return 'image/webp'
|
||||
case 'svg':
|
||||
return 'image/svg+xml'
|
||||
case 'ico':
|
||||
return 'image/x-icon'
|
||||
default:
|
||||
return 'application/octet-stream'
|
||||
}
|
||||
}
|
||||
|
||||
async function readMeta(key: string): Promise<Partial<StoredImageMeta>> {
|
||||
try {
|
||||
const raw = await fs.readFile(keyToFilePath(key) + META_SUFFIX, 'utf8')
|
||||
@@ -57,11 +37,10 @@ export class LocalStorageBackend implements StorageBackend {
|
||||
this.publicUrl = publicUrl
|
||||
}
|
||||
|
||||
async put(key: string, bytes: Uint8Array, meta: StoredImageMeta): Promise<void> {
|
||||
async put(key: string, bytes: Uint8Array, _meta: StoredImageMeta): Promise<void> {
|
||||
const filePath = keyToFilePath(key)
|
||||
await fs.mkdir(dirname(filePath), { recursive: true })
|
||||
await fs.writeFile(filePath, bytes)
|
||||
await fs.writeFile(filePath + META_SUFFIX, JSON.stringify(meta))
|
||||
}
|
||||
|
||||
async head(key: string): Promise<StoredImage | null> {
|
||||
|
||||
Reference in new issue
Block a user