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
@@ -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