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

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:
O96u committed 2026-08-28 17:18:03 +08:00
1 parent 3dee0437e6
commit 469d3be735
50 files changed
+1273 -956

No files matched your search

-18
View File
@@ -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'
}
+15
View File
@@ -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')
})
})
+20
View File
@@ -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
View File
@@ -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
}
+5 -2
View File
@@ -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
View File
@@ -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(`
+18 -14
View File
@@ -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
View File
@@ -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
}
+1 -1
View File
@@ -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')
})
})
+1 -7
View File
@@ -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
View File
@@ -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>> {
+2 -23
View File
@@ -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> {