release: v1.1.0 — S3 multi-backend storage and admin UI
This commit is contained in:
1 parent
7c45a78572
commit
acc64751ef
46 files changed
+4005
-480
No files matched your search
@@ -0,0 +1,39 @@
|
||||
import { requireAdminAuth } from '../../../utils/access'
|
||||
import { createApiError } from '../../../utils/api-error'
|
||||
import {
|
||||
deleteStorageBackend,
|
||||
isStorageEnvConfigured,
|
||||
LOCAL_BACKEND_ID
|
||||
} from '../../../utils/storage-backends'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
await requireAdminAuth(event)
|
||||
|
||||
if (isStorageEnvConfigured()) {
|
||||
createApiError(event, 'FORBIDDEN', '存储配置已由环境变量覆盖,无法在后台修改', 409)
|
||||
}
|
||||
|
||||
const id = getRouterParam(event, 'id')
|
||||
if (!id) {
|
||||
createApiError(event, 'INVALID_REQUEST', '缺少存储后端 ID', 400)
|
||||
}
|
||||
|
||||
if (id === LOCAL_BACKEND_ID) {
|
||||
createApiError(event, 'INVALID_REQUEST', '本地磁盘后端不可删除', 400)
|
||||
}
|
||||
|
||||
try {
|
||||
deleteStorageBackend(id)
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : '删除失败'
|
||||
if (message.includes('stored images')) {
|
||||
createApiError(event, 'INVALID_REQUEST', '该后端仍有图片,无法删除', 400)
|
||||
}
|
||||
if (message.includes('default backend')) {
|
||||
createApiError(event, 'INVALID_REQUEST', '请先切换默认后端后再删除', 400)
|
||||
}
|
||||
createApiError(event, 'INVALID_REQUEST', message, 400)
|
||||
}
|
||||
|
||||
return { success: true }
|
||||
})
|
||||
@@ -0,0 +1,129 @@
|
||||
import { requireAdminAuth } from '../../../utils/access'
|
||||
import { createApiError } from '../../../utils/api-error'
|
||||
import {
|
||||
isStorageEnvConfigured,
|
||||
LOCAL_BACKEND_ID,
|
||||
updateStorageBackend,
|
||||
getStorageBackendRow,
|
||||
setDefaultBackend,
|
||||
type StorageBackendPatch
|
||||
} from '../../../utils/storage-backends'
|
||||
import { parseS3Config, validateS3Config } from '../../../utils/storage/s3'
|
||||
import { parseQuotaBytes } from '../../../utils/storage-capacity'
|
||||
import type { ServingMode } from '../../../utils/storage/types'
|
||||
|
||||
interface StoragePatchBody {
|
||||
name?: string
|
||||
config?: {
|
||||
endpoint?: string
|
||||
region?: string
|
||||
bucket?: string
|
||||
prefix?: string
|
||||
forcePathStyle?: boolean
|
||||
}
|
||||
secrets?: {
|
||||
accessKeyId?: string
|
||||
secretAccessKey?: string
|
||||
}
|
||||
servingMode?: ServingMode
|
||||
publicUrl?: string
|
||||
quotaBytes?: number | null
|
||||
enabled?: boolean
|
||||
isDefault?: boolean
|
||||
}
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
await requireAdminAuth(event)
|
||||
|
||||
if (isStorageEnvConfigured()) {
|
||||
createApiError(event, 'FORBIDDEN', '存储配置已由环境变量覆盖,无法在后台修改', 409)
|
||||
}
|
||||
|
||||
const id = getRouterParam(event, 'id')
|
||||
if (!id) {
|
||||
createApiError(event, 'INVALID_REQUEST', '缺少存储后端 ID', 400)
|
||||
}
|
||||
|
||||
const row = getStorageBackendRow(id)
|
||||
if (!row) {
|
||||
createApiError(event, 'INVALID_REQUEST', '存储后端不存在', 404)
|
||||
}
|
||||
|
||||
const body = await readBody<StoragePatchBody>(event).catch(
|
||||
(): StoragePatchBody => ({})
|
||||
)
|
||||
|
||||
if (id === LOCAL_BACKEND_ID && body.isDefault === false) {
|
||||
createApiError(event, 'INVALID_REQUEST', '必须保留一个默认存储后端', 400)
|
||||
}
|
||||
|
||||
const patch: StorageBackendPatch = {}
|
||||
|
||||
if (body.name !== undefined) {
|
||||
patch.name = body.name.trim() || row.name
|
||||
}
|
||||
|
||||
if (body.config !== undefined && row.type === 's3') {
|
||||
const current = parseS3Config(row.config_json) ?? {
|
||||
endpoint: '',
|
||||
region: 'auto',
|
||||
bucket: ''
|
||||
}
|
||||
const next = {
|
||||
endpoint: body.config.endpoint?.trim() ?? current.endpoint,
|
||||
region: body.config.region?.trim() ?? current.region,
|
||||
bucket: body.config.bucket?.trim() ?? current.bucket,
|
||||
prefix: body.config.prefix?.trim() || undefined,
|
||||
forcePathStyle: body.config.forcePathStyle ?? current.forcePathStyle
|
||||
}
|
||||
const error = validateS3Config(next)
|
||||
if (error) {
|
||||
createApiError(event, 'INVALID_REQUEST', error, 400)
|
||||
}
|
||||
patch.config = next
|
||||
}
|
||||
|
||||
if (body.secrets !== undefined && row.type === 's3') {
|
||||
patch.secrets = body.secrets
|
||||
}
|
||||
|
||||
if (body.servingMode !== undefined) {
|
||||
if (body.servingMode !== 'proxy' && body.servingMode !== 'public') {
|
||||
createApiError(event, 'INVALID_REQUEST', '访问方式无效', 400)
|
||||
}
|
||||
patch.servingMode = body.servingMode
|
||||
}
|
||||
|
||||
if (body.publicUrl !== undefined) {
|
||||
patch.publicUrl = body.publicUrl.trim()
|
||||
}
|
||||
|
||||
if (body.quotaBytes !== undefined) {
|
||||
if (row.type === 's3') {
|
||||
const quotaBytes = parseQuotaBytes(body.quotaBytes)
|
||||
if (!quotaBytes) {
|
||||
createApiError(event, 'INVALID_REQUEST', '请填写有效的存储套餐总量', 400)
|
||||
}
|
||||
patch.quotaBytes = quotaBytes
|
||||
}
|
||||
}
|
||||
|
||||
if (body.enabled !== undefined) {
|
||||
if (id === LOCAL_BACKEND_ID && !body.enabled) {
|
||||
createApiError(event, 'INVALID_REQUEST', '本地磁盘后端不可停用', 400)
|
||||
}
|
||||
patch.enabled = body.enabled
|
||||
}
|
||||
|
||||
if (body.isDefault !== undefined) {
|
||||
patch.isDefault = body.isDefault
|
||||
}
|
||||
|
||||
updateStorageBackend(id, patch)
|
||||
|
||||
if (body.isDefault) {
|
||||
setDefaultBackend(id)
|
||||
}
|
||||
|
||||
return { success: true }
|
||||
})
|
||||
@@ -0,0 +1,34 @@
|
||||
import { requireAdminAuth } from '../../../../utils/access'
|
||||
import { createApiError } from '../../../../utils/api-error'
|
||||
import {
|
||||
createBackendInstance,
|
||||
getStorageBackendRow,
|
||||
isStorageEnvConfigured
|
||||
} from '../../../../utils/storage-backends'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
await requireAdminAuth(event)
|
||||
|
||||
if (isStorageEnvConfigured()) {
|
||||
createApiError(event, 'FORBIDDEN', '存储配置已由环境变量覆盖,无法在后台测试', 409)
|
||||
}
|
||||
|
||||
const id = getRouterParam(event, 'id')
|
||||
if (!id) {
|
||||
createApiError(event, 'INVALID_REQUEST', '缺少存储后端 ID', 400)
|
||||
}
|
||||
|
||||
const row = getStorageBackendRow(id)
|
||||
if (!row) {
|
||||
createApiError(event, 'INVALID_REQUEST', '存储后端不存在', 404)
|
||||
}
|
||||
|
||||
try {
|
||||
const backend = createBackendInstance(row)
|
||||
const result = await backend.testConnection()
|
||||
return result
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : '连接失败'
|
||||
return { ok: false, message }
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,39 @@
|
||||
import { requireAdminAuth } from '../../../utils/access'
|
||||
import {
|
||||
getBackendUsageStats,
|
||||
isStorageEnvConfigured,
|
||||
listStorageBackends
|
||||
} from '../../../utils/storage-backends'
|
||||
import { buildBackendCapacity } from '../../../utils/storage-capacity'
|
||||
import { getActiveBackendRow } from '../../../utils/storage/resolver'
|
||||
import type { StorageBackendInfo } from '../../../utils/storage/types'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
await requireAdminAuth(event)
|
||||
|
||||
const rows = listStorageBackends()
|
||||
const backends = await Promise.all(rows.map(async (backend: StorageBackendInfo & {
|
||||
secretsMasked: Record<string, string>
|
||||
}) => {
|
||||
const usage = getBackendUsageStats(backend.id)
|
||||
const capacity = await buildBackendCapacity(
|
||||
backend.id,
|
||||
backend.type,
|
||||
usage.bytes,
|
||||
backend.quotaBytes
|
||||
)
|
||||
return {
|
||||
...backend,
|
||||
usage,
|
||||
capacity
|
||||
}
|
||||
}))
|
||||
|
||||
const activeBackend = getActiveBackendRow()
|
||||
|
||||
return {
|
||||
backends,
|
||||
activeBackendId: activeBackend?.id ?? 'local',
|
||||
envOverride: isStorageEnvConfigured()
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,83 @@
|
||||
import { requireAdminAuth } from '../../../utils/access'
|
||||
import { createApiError } from '../../../utils/api-error'
|
||||
import {
|
||||
insertStorageBackend,
|
||||
isStorageEnvConfigured
|
||||
} from '../../../utils/storage-backends'
|
||||
import { validateS3Config } from '../../../utils/storage/s3'
|
||||
import { parseQuotaBytes } from '../../../utils/storage-capacity'
|
||||
import type { ServingMode } from '../../../utils/storage/types'
|
||||
|
||||
interface StorageCreateBody {
|
||||
name?: string
|
||||
config?: {
|
||||
endpoint?: string
|
||||
region?: string
|
||||
bucket?: string
|
||||
prefix?: string
|
||||
forcePathStyle?: boolean
|
||||
}
|
||||
secrets?: {
|
||||
accessKeyId?: string
|
||||
secretAccessKey?: string
|
||||
}
|
||||
servingMode?: ServingMode
|
||||
publicUrl?: string
|
||||
quotaBytes?: number | null
|
||||
enabled?: boolean
|
||||
isDefault?: boolean
|
||||
}
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
await requireAdminAuth(event)
|
||||
|
||||
if (isStorageEnvConfigured()) {
|
||||
createApiError(event, 'FORBIDDEN', '存储配置已由环境变量覆盖,无法在后台修改', 409)
|
||||
}
|
||||
|
||||
const body = await readBody<StorageCreateBody>(event).catch(
|
||||
(): StorageCreateBody => ({})
|
||||
)
|
||||
|
||||
const name = body.name?.trim() || 'S3 兼容存储'
|
||||
const config = {
|
||||
endpoint: body.config?.endpoint?.trim() ?? '',
|
||||
region: body.config?.region?.trim() || 'auto',
|
||||
bucket: body.config?.bucket?.trim() ?? '',
|
||||
prefix: body.config?.prefix?.trim() || undefined,
|
||||
forcePathStyle: body.config?.forcePathStyle ?? false
|
||||
}
|
||||
|
||||
const error = validateS3Config(config)
|
||||
if (error) {
|
||||
createApiError(event, 'INVALID_REQUEST', error, 400)
|
||||
}
|
||||
|
||||
const accessKeyId = body.secrets?.accessKeyId?.trim() ?? ''
|
||||
const secretAccessKey = body.secrets?.secretAccessKey?.trim() ?? ''
|
||||
if (!accessKeyId || !secretAccessKey) {
|
||||
createApiError(event, 'INVALID_REQUEST', 'Access Key 与 Secret Key 不能为空', 400)
|
||||
}
|
||||
|
||||
if (body.servingMode && body.servingMode !== 'proxy' && body.servingMode !== 'public') {
|
||||
createApiError(event, 'INVALID_REQUEST', '访问方式无效', 400)
|
||||
}
|
||||
|
||||
const quotaBytes = parseQuotaBytes(body.quotaBytes)
|
||||
if (!quotaBytes) {
|
||||
createApiError(event, 'INVALID_REQUEST', '请填写有效的存储套餐总量', 400)
|
||||
}
|
||||
|
||||
const id = insertStorageBackend({
|
||||
name,
|
||||
config,
|
||||
secrets: { accessKeyId, secretAccessKey },
|
||||
servingMode: body.servingMode,
|
||||
publicUrl: body.publicUrl?.trim(),
|
||||
quotaBytes,
|
||||
enabled: body.enabled,
|
||||
isDefault: body.isDefault
|
||||
})
|
||||
|
||||
return { success: true, id }
|
||||
})
|
||||
@@ -1,5 +1,7 @@
|
||||
import { getDataDir } from '../utils/storage'
|
||||
import { getDataDir } from '../utils/data-dir'
|
||||
import { runAutoDeleteCleanup } from '../utils/auto-delete'
|
||||
import { migrateLocalImagesToIndex } from '../utils/image-index'
|
||||
import { ensureDefaultBackends } from '../utils/storage-backends'
|
||||
import { logInfo } from '../utils/logger'
|
||||
|
||||
export default defineNitroPlugin(() => {
|
||||
@@ -9,9 +11,17 @@ export default defineNitroPlugin(() => {
|
||||
dataDir: getDataDir(),
|
||||
nodeEnv: process.env.NODE_ENV || 'development',
|
||||
imageBaseUrl: process.env.IMAGE_BASE_URL || '(request origin)',
|
||||
storageBackend: process.env.STORAGE_BACKEND || 'db',
|
||||
logLevel: process.env.LOG_LEVEL || 'info'
|
||||
})
|
||||
|
||||
ensureDefaultBackends()
|
||||
void migrateLocalImagesToIndex().then((count) => {
|
||||
if (count > 0) {
|
||||
logInfo('migrated local images to index', { count })
|
||||
}
|
||||
})
|
||||
|
||||
void runAutoDeleteCleanup().then((result) => {
|
||||
if (!result.skipped && (result.deleted > 0 || result.failed > 0)) {
|
||||
logInfo('startup auto-delete', { ...result })
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { insertActivityLog, listAdminUserIds, listUserAutoDeletePolicies, type AutoDeletePolicy } from './db'
|
||||
import { getGlobalAutoDeletePolicy } from './env'
|
||||
import { deleteImage, headImage, listImageKeys, type StoredImage } from './storage'
|
||||
import { deleteImage, type StoredImage } from './storage'
|
||||
import { listImageKeysForAutoDelete } from './image-index'
|
||||
import { logInfo, logWarn } from './logger'
|
||||
|
||||
export interface AutoDeleteResult {
|
||||
@@ -73,23 +74,20 @@ export async function runAutoDeleteCleanup(): Promise<AutoDeleteResult> {
|
||||
}
|
||||
|
||||
const nowMs = Date.now()
|
||||
const keys = await listImageKeys()
|
||||
const images = await listImageKeysForAutoDelete()
|
||||
let deleted = 0
|
||||
let failed = 0
|
||||
|
||||
for (const key of keys) {
|
||||
const image = await headImage(key)
|
||||
if (!image) continue
|
||||
|
||||
for (const image of images) {
|
||||
if (!shouldAutoDeleteImage(image, globalPolicy, adminUserIds, userPolicies, nowMs)) {
|
||||
continue
|
||||
}
|
||||
|
||||
try {
|
||||
await deleteImage(key)
|
||||
await deleteImage(image.key)
|
||||
insertActivityLog({
|
||||
action: 'delete',
|
||||
key,
|
||||
key: image.key,
|
||||
originalName: image.originalName,
|
||||
size: image.size,
|
||||
contentType: image.contentType,
|
||||
@@ -97,7 +95,7 @@ export async function runAutoDeleteCleanup(): Promise<AutoDeleteResult> {
|
||||
})
|
||||
deleted++
|
||||
} catch (error) {
|
||||
logWarn('auto-delete failed', { key, error })
|
||||
logWarn('auto-delete failed', { key: image.key, error })
|
||||
failed++
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import { join } from 'node:path'
|
||||
|
||||
export function getDataDir(): string {
|
||||
return process.env.DATA_DIR || join(process.cwd(), 'data')
|
||||
}
|
||||
+2
-2
@@ -1,7 +1,7 @@
|
||||
import { DatabaseSync } from 'node:sqlite'
|
||||
import { mkdirSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { getDataDir } from './storage'
|
||||
import { getDataDir } from './data-dir'
|
||||
|
||||
export type LogAction = 'upload' | 'delete'
|
||||
export type LogSource = 'web' | 'api' | 'twikoo'
|
||||
@@ -29,7 +29,7 @@ export interface ActivityLogInput {
|
||||
|
||||
let db: DatabaseSync | null = null
|
||||
|
||||
function getDb(): DatabaseSync {
|
||||
export function getDb(): DatabaseSync {
|
||||
if (db) return db
|
||||
|
||||
const dataDir = getDataDir()
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
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)
|
||||
})
|
||||
|
||||
it('rejects path traversal keys', () => {
|
||||
expect(validateImageKey('../secret')).toBe(false)
|
||||
expect(validateImageKey('images/../../etc/passwd')).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,418 @@
|
||||
import { promises as fs } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { getDb } from './db'
|
||||
import { getDataDir } from './data-dir'
|
||||
import { isValidFolderName, validateImageKey, DEFAULT_FOLDER } from './image-key'
|
||||
import {
|
||||
ensureDefaultBackends,
|
||||
ensureStorageSchema,
|
||||
LOCAL_BACKEND_ID
|
||||
} from './storage-backends'
|
||||
import type { PaginatedResult, StoredImage, StoredImageMeta } from './storage/types'
|
||||
|
||||
const META_SUFFIX = '.meta.json'
|
||||
|
||||
export interface ImageIndexRow {
|
||||
key: string
|
||||
backend_id: string
|
||||
user_id: number | null
|
||||
folder: string
|
||||
original_name: string
|
||||
content_type: string
|
||||
size: number
|
||||
uploaded_at: string
|
||||
}
|
||||
|
||||
function keyToFilePath(key: string): string {
|
||||
return join(getDataDir(), ...key.split('/'))
|
||||
}
|
||||
|
||||
async function readLocalMeta(key: string): Promise<Partial<StoredImageMeta>> {
|
||||
try {
|
||||
const raw = await fs.readFile(keyToFilePath(key) + META_SUFFIX, 'utf8')
|
||||
return JSON.parse(raw) as Partial<StoredImageMeta>
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
function rowToStoredImage(row: ImageIndexRow): StoredImage {
|
||||
const uploadedMs = new Date(row.uploaded_at).getTime()
|
||||
return {
|
||||
key: row.key,
|
||||
backendId: row.backend_id,
|
||||
originalName: row.original_name,
|
||||
uploadedAt: row.uploaded_at,
|
||||
contentType: row.content_type,
|
||||
size: row.size,
|
||||
userId: row.user_id,
|
||||
mtimeMs: Number.isFinite(uploadedMs) ? uploadedMs : Date.now()
|
||||
}
|
||||
}
|
||||
|
||||
function buildUserFilterSql(
|
||||
userFilter: number | 'admin' | undefined,
|
||||
params: Array<string | number>
|
||||
): string {
|
||||
if (userFilter === undefined || userFilter === 'admin') return ''
|
||||
params.push(userFilter)
|
||||
return ' AND user_id = ?'
|
||||
}
|
||||
|
||||
export function insertImageIndex(input: {
|
||||
key: string
|
||||
backendId: string
|
||||
userId?: number | null
|
||||
originalName: string
|
||||
contentType: string
|
||||
size: number
|
||||
uploadedAt: string
|
||||
}): void {
|
||||
ensureStorageSchema()
|
||||
const folder = input.key.split('/')[0] ?? DEFAULT_FOLDER
|
||||
getDb().prepare(`
|
||||
INSERT INTO images
|
||||
(key, backend_id, user_id, folder, original_name, content_type, size, uploaded_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(key) DO UPDATE SET
|
||||
backend_id = excluded.backend_id,
|
||||
user_id = excluded.user_id,
|
||||
folder = excluded.folder,
|
||||
original_name = excluded.original_name,
|
||||
content_type = excluded.content_type,
|
||||
size = excluded.size,
|
||||
uploaded_at = excluded.uploaded_at
|
||||
`).run(
|
||||
input.key,
|
||||
input.backendId,
|
||||
input.userId ?? null,
|
||||
folder,
|
||||
input.originalName,
|
||||
input.contentType,
|
||||
input.size,
|
||||
input.uploadedAt
|
||||
)
|
||||
}
|
||||
|
||||
export function deleteImageIndex(key: string): void {
|
||||
ensureStorageSchema()
|
||||
getDb().prepare('DELETE FROM images WHERE key = ?').run(key)
|
||||
}
|
||||
|
||||
export function getImageIndexRow(key: string): ImageIndexRow | null {
|
||||
ensureStorageSchema()
|
||||
const row = getDb().prepare(`
|
||||
SELECT key, backend_id, user_id, folder, original_name, content_type, size, uploaded_at
|
||||
FROM images
|
||||
WHERE key = ?
|
||||
`).get(key) as ImageIndexRow | undefined
|
||||
return row ?? null
|
||||
}
|
||||
|
||||
export function getImageIndexAsStored(key: string): StoredImage | null {
|
||||
const row = getImageIndexRow(key)
|
||||
return row ? rowToStoredImage(row) : null
|
||||
}
|
||||
|
||||
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[]> {
|
||||
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 years = await readDirNames(root)
|
||||
for (const year of years) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return keys
|
||||
}
|
||||
|
||||
export async function migrateLocalImagesToIndex(): Promise<number> {
|
||||
ensureDefaultBackends()
|
||||
const keys = await listLocalImageKeys()
|
||||
let inserted = 0
|
||||
|
||||
for (const key of keys) {
|
||||
const existing = getImageIndexRow(key)
|
||||
if (existing) continue
|
||||
|
||||
let stat
|
||||
try {
|
||||
stat = await fs.stat(keyToFilePath(key))
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
|
||||
const meta = await readLocalMeta(key)
|
||||
const uploadedAt = meta.uploadedAt ?? stat.mtime.toISOString()
|
||||
|
||||
insertImageIndex({
|
||||
key,
|
||||
backendId: LOCAL_BACKEND_ID,
|
||||
userId: meta.userId ?? null,
|
||||
originalName: meta.originalName ?? key.split('/').pop() ?? 'image',
|
||||
contentType: meta.contentType ?? 'application/octet-stream',
|
||||
size: stat.size,
|
||||
uploadedAt
|
||||
})
|
||||
inserted++
|
||||
}
|
||||
|
||||
return inserted
|
||||
}
|
||||
|
||||
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)
|
||||
if (!folders.includes(DEFAULT_FOLDER)) {
|
||||
folders.push(DEFAULT_FOLDER)
|
||||
}
|
||||
return folders.sort((a, b) => a.localeCompare(b))
|
||||
}
|
||||
|
||||
export async function listFoldersForUser(
|
||||
userFilter?: number | 'admin'
|
||||
): Promise<string[]> {
|
||||
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 }>
|
||||
|
||||
const folders = rows.map(row => row.folder).filter(isValidFolderName)
|
||||
if (!folders.length) {
|
||||
folders.push(DEFAULT_FOLDER)
|
||||
}
|
||||
return folders.sort((a, b) => a.localeCompare(b))
|
||||
}
|
||||
|
||||
export async function countImages(
|
||||
folder?: string,
|
||||
userFilter?: number | 'admin'
|
||||
): Promise<number> {
|
||||
ensureStorageSchema()
|
||||
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 row = getDb().prepare(`
|
||||
SELECT COUNT(*) AS count FROM images WHERE ${clauses.join(' AND ')}${userSql}
|
||||
`).get(...params) as { count: number }
|
||||
|
||||
return row.count
|
||||
}
|
||||
|
||||
function paginateSlice<T>(
|
||||
all: T[],
|
||||
limit: number,
|
||||
page?: number
|
||||
): PaginatedResult<T> {
|
||||
const total = all.length
|
||||
const pageSize = limit
|
||||
const totalPages = Math.max(1, Math.ceil(total / pageSize) || 1)
|
||||
const requestedPage = Math.max(1, page ?? 1)
|
||||
const safePage = Math.min(requestedPage, totalPages)
|
||||
const start = (safePage - 1) * pageSize
|
||||
const items = all.slice(start, start + pageSize)
|
||||
|
||||
return { items, total, page: safePage, pageSize, totalPages }
|
||||
}
|
||||
|
||||
export async function listImages(options: {
|
||||
limit: number
|
||||
page?: number
|
||||
folder?: string
|
||||
userFilter?: number | 'admin'
|
||||
}): Promise<PaginatedResult<StoredImage>> {
|
||||
ensureStorageSchema()
|
||||
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 rows = getDb().prepare(`
|
||||
SELECT key, backend_id, user_id, folder, original_name, content_type, size, uploaded_at
|
||||
FROM images
|
||||
WHERE ${clauses.join(' AND ')}${userSql}
|
||||
ORDER BY uploaded_at DESC, key DESC
|
||||
`).all(...params) as unknown as ImageIndexRow[]
|
||||
|
||||
const sliced = paginateSlice(rows, options.limit, options.page)
|
||||
return {
|
||||
...sliced,
|
||||
items: sliced.items.map(rowToStoredImage)
|
||||
}
|
||||
}
|
||||
|
||||
export async function searchImages(options: {
|
||||
query: string
|
||||
limit: number
|
||||
page?: number
|
||||
folder?: string
|
||||
userFilter?: number | 'admin'
|
||||
}): Promise<PaginatedResult<StoredImage>> {
|
||||
ensureStorageSchema()
|
||||
const needle = options.query.trim().toLowerCase()
|
||||
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 rows = getDb().prepare(`
|
||||
SELECT key, backend_id, user_id, folder, original_name, content_type, size, uploaded_at
|
||||
FROM images
|
||||
WHERE ${clauses.join(' AND ')}${userSql}
|
||||
ORDER BY uploaded_at DESC, key DESC
|
||||
`).all(...params) as unknown as ImageIndexRow[]
|
||||
|
||||
const matches = rows.filter((row) => {
|
||||
if (!needle) return true
|
||||
return row.key.toLowerCase().includes(needle)
|
||||
|| row.original_name.toLowerCase().includes(needle)
|
||||
})
|
||||
|
||||
const sliced = paginateSlice(matches, options.limit, options.page)
|
||||
return {
|
||||
...sliced,
|
||||
items: sliced.items.map(rowToStoredImage)
|
||||
}
|
||||
}
|
||||
|
||||
export async function getFolderStorageStats(
|
||||
userFilter?: number | 'admin'
|
||||
): Promise<Array<{ folder: string, count: number, bytes: number }>> {
|
||||
ensureStorageSchema()
|
||||
const params: Array<string | number> = []
|
||||
const userSql = buildUserFilterSql(userFilter, params)
|
||||
|
||||
const rows = getDb().prepare(`
|
||||
SELECT folder, COUNT(*) AS count, COALESCE(SUM(size), 0) AS bytes
|
||||
FROM images
|
||||
WHERE 1=1${userSql}
|
||||
GROUP BY folder
|
||||
ORDER BY bytes DESC, folder ASC
|
||||
`).all(...params) as Array<{ folder: string, count: number, bytes: number }>
|
||||
|
||||
return rows
|
||||
}
|
||||
|
||||
function startOfDayIsoInShanghai(now = new Date()): string {
|
||||
const shifted = new Date(now.getTime() + 8 * 60 * 60 * 1000)
|
||||
const y = shifted.getUTCFullYear()
|
||||
const m = shifted.getUTCMonth()
|
||||
const d = shifted.getUTCDate()
|
||||
return new Date(Date.UTC(y, m, d) - 8 * 60 * 60 * 1000).toISOString()
|
||||
}
|
||||
|
||||
function startOfMonthIsoInShanghai(now = new Date()): string {
|
||||
const shifted = new Date(now.getTime() + 8 * 60 * 60 * 1000)
|
||||
const y = shifted.getUTCFullYear()
|
||||
const m = shifted.getUTCMonth()
|
||||
return new Date(Date.UTC(y, m, 1) - 8 * 60 * 60 * 1000).toISOString()
|
||||
}
|
||||
|
||||
export async function getUserScopedStorageStats(userId: number): Promise<{
|
||||
storedCount: number
|
||||
uploadBytesTotal: number
|
||||
uploadToday: number
|
||||
uploadMonth: number
|
||||
byFolder: Array<{ folder: string, count: number, bytes: number }>
|
||||
}> {
|
||||
ensureStorageSchema()
|
||||
const startOfDay = startOfDayIsoInShanghai()
|
||||
const startOfMonth = startOfMonthIsoInShanghai()
|
||||
|
||||
const rows = getDb().prepare(`
|
||||
SELECT folder, size, uploaded_at
|
||||
FROM images
|
||||
WHERE user_id = ?
|
||||
`).all(userId) as Array<{ folder: string, size: number, uploaded_at: string }>
|
||||
|
||||
const map = new Map<string, { count: number, bytes: number }>()
|
||||
let uploadBytesTotal = 0
|
||||
let uploadToday = 0
|
||||
let uploadMonth = 0
|
||||
|
||||
for (const row of rows) {
|
||||
uploadBytesTotal += row.size
|
||||
if (row.uploaded_at >= startOfDay) uploadToday += 1
|
||||
if (row.uploaded_at >= startOfMonth) uploadMonth += 1
|
||||
|
||||
const current = map.get(row.folder) ?? { count: 0, bytes: 0 }
|
||||
current.count += 1
|
||||
current.bytes += row.size
|
||||
map.set(row.folder, current)
|
||||
}
|
||||
|
||||
const byFolder = [...map.entries()]
|
||||
.map(([folder, stat]) => ({ folder, ...stat }))
|
||||
.sort((a, b) => b.bytes - a.bytes || a.folder.localeCompare(b.folder))
|
||||
|
||||
return {
|
||||
storedCount: rows.length,
|
||||
uploadBytesTotal,
|
||||
uploadToday,
|
||||
uploadMonth,
|
||||
byFolder
|
||||
}
|
||||
}
|
||||
|
||||
export async function listImageKeysForAutoDelete(): Promise<StoredImage[]> {
|
||||
ensureStorageSchema()
|
||||
const rows = getDb().prepare(`
|
||||
SELECT key, backend_id, user_id, folder, original_name, content_type, size, uploaded_at
|
||||
FROM images
|
||||
ORDER BY uploaded_at ASC
|
||||
`).all() as unknown as ImageIndexRow[]
|
||||
|
||||
return rows.map(rowToStoredImage)
|
||||
}
|
||||
@@ -3,6 +3,10 @@ import type { ImageItem } from '~/types/image'
|
||||
import type { AllowedMimeType } from './constants'
|
||||
import type { StoredImage } from './storage'
|
||||
import { getImageBaseUrl } from './env'
|
||||
import { getActiveBackendRow } from './storage/resolver'
|
||||
import { getStorageBackendRow, getStorageBackendFromEnv } from './storage-backends'
|
||||
import { buildObjectKey, parseS3Config } from './storage/s3'
|
||||
import type { StorageBackendRow } from './storage/types'
|
||||
|
||||
export function resolveImageOwner(
|
||||
userId: number | null | undefined,
|
||||
@@ -27,7 +31,8 @@ export function mapStoredImageToItem(
|
||||
): ImageItem {
|
||||
const item = buildImageItem({
|
||||
key: stored.key,
|
||||
baseUrl: getImageBaseUrl(event),
|
||||
event,
|
||||
backendId: stored.backendId,
|
||||
originalName: stored.originalName,
|
||||
contentType: stored.contentType,
|
||||
size: stored.size,
|
||||
@@ -47,6 +52,42 @@ export function buildImageUrl(baseUrl: string, key: string): string {
|
||||
return `${normalizedBase}/${key}`
|
||||
}
|
||||
|
||||
export function buildPublicImageUrl(backendRow: StorageBackendRow, key: string): string {
|
||||
const base = backendRow.public_url.replace(/\/$/, '')
|
||||
if (backendRow.type === 's3') {
|
||||
const config = parseS3Config(backendRow.config_json)
|
||||
const objectKey = buildObjectKey(config?.prefix, key)
|
||||
return `${base}/${objectKey}`
|
||||
}
|
||||
return `${base}/${key}`
|
||||
}
|
||||
|
||||
export function resolveImageUrl(
|
||||
event: H3Event,
|
||||
key: string,
|
||||
backendId?: string
|
||||
): string {
|
||||
const envConfig = getStorageBackendFromEnv()
|
||||
if (envConfig && (!backendId || backendId === 's3-primary')) {
|
||||
if (envConfig.servingMode === 'public' && envConfig.publicUrl) {
|
||||
const prefix = envConfig.config.prefix as string | undefined
|
||||
const objectKey = buildObjectKey(prefix, key)
|
||||
return `${envConfig.publicUrl.replace(/\/$/, '')}/${objectKey}`
|
||||
}
|
||||
return buildImageUrl(getImageBaseUrl(event), key)
|
||||
}
|
||||
|
||||
const backendRow = backendId
|
||||
? getStorageBackendRow(backendId)
|
||||
: getActiveBackendRow()
|
||||
|
||||
if (backendRow?.serving_mode === 'public' && backendRow.public_url) {
|
||||
return buildPublicImageUrl(backendRow, key)
|
||||
}
|
||||
|
||||
return buildImageUrl(getImageBaseUrl(event), key)
|
||||
}
|
||||
|
||||
export function buildMarkdown(url: string, alt: string): string {
|
||||
const safeAlt = alt.replace(/[[\]]/g, '')
|
||||
return ``
|
||||
@@ -68,13 +109,17 @@ function escapeHtml(value: string): string {
|
||||
|
||||
export function buildImageItem(input: {
|
||||
key: string
|
||||
baseUrl: string
|
||||
baseUrl?: string
|
||||
event?: H3Event
|
||||
backendId?: string
|
||||
originalName: string
|
||||
contentType: AllowedMimeType | string
|
||||
size: number
|
||||
uploadedAt: string
|
||||
}) {
|
||||
const url = buildImageUrl(input.baseUrl, input.key)
|
||||
const url = input.event
|
||||
? resolveImageUrl(input.event, input.key, input.backendId)
|
||||
: buildImageUrl(input.baseUrl ?? '', input.key)
|
||||
const alt = sanitizeOriginalName(input.originalName)
|
||||
|
||||
return {
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
import type { LogSource } from './db'
|
||||
import { generateImageKey } from './image-key'
|
||||
import { buildImageItem, sanitizeOriginalName } from './image-response'
|
||||
import { getImageBaseUrl, getWebpQuality } from './env'
|
||||
import { getWebpQuality } from './env'
|
||||
import { insertActivityLog } from './db'
|
||||
import { putImage } from './storage'
|
||||
import { logInfo, logWarn } from './logger'
|
||||
@@ -121,7 +121,6 @@ export async function processSingleImageUpload(
|
||||
|
||||
const compressed = await compressToWebp(bytes, detectedMime, getWebpQuality(event))
|
||||
|
||||
const imageBaseUrl = getImageBaseUrl(event)
|
||||
const uploadedAt = new Date().toISOString()
|
||||
const key = generateImageKey(
|
||||
compressed.mime,
|
||||
@@ -173,7 +172,7 @@ export async function processSingleImageUpload(
|
||||
return {
|
||||
item: buildImageItem({
|
||||
key,
|
||||
baseUrl: imageBaseUrl,
|
||||
event,
|
||||
originalName,
|
||||
contentType: compressed.mime,
|
||||
size: compressed.bytes.byteLength,
|
||||
|
||||
@@ -2,7 +2,9 @@ import type { H3Event } from 'h3'
|
||||
import { IMAGE_CACHE_CONTROL } from './constants'
|
||||
import { getAllowedRefererHostsRaw } from './env'
|
||||
import { validateImageKey } from './image-key'
|
||||
import { buildPublicImageUrl } from './image-response'
|
||||
import { createImageStream, headImage } from './storage'
|
||||
import { getBackendRowForKey } from './storage/resolver'
|
||||
|
||||
/**
|
||||
* 图片直链处理(/{type}/YYYY/MM/file)
|
||||
@@ -82,6 +84,12 @@ export async function serveImageByKey(event: H3Event, key: string) {
|
||||
throw createError({ statusCode: 404, statusMessage: 'Not Found' })
|
||||
}
|
||||
|
||||
const backendRow = await getBackendRowForKey(key)
|
||||
if (backendRow.serving_mode === 'public' && backendRow.public_url) {
|
||||
const publicUrl = buildPublicImageUrl(backendRow, key)
|
||||
return sendRedirect(event, publicUrl, 302)
|
||||
}
|
||||
|
||||
const etag = `"${stored.size}-${Math.round(stored.mtimeMs)}"`
|
||||
|
||||
setHeader(event, 'ETag', etag)
|
||||
@@ -116,9 +124,9 @@ export async function serveImageByKey(event: H3Event, key: string) {
|
||||
setResponseStatus(event, 206)
|
||||
setHeader(event, 'Content-Range', `bytes ${range.start}-${range.end}/${stored.size}`)
|
||||
setHeader(event, 'Content-Length', range.end - range.start + 1)
|
||||
return sendStream(event, createImageStream(key, range))
|
||||
return sendStream(event, await createImageStream(key, range))
|
||||
}
|
||||
|
||||
setHeader(event, 'Content-Length', stored.size)
|
||||
return sendStream(event, createImageStream(key))
|
||||
return sendStream(event, await createImageStream(key))
|
||||
}
|
||||
@@ -0,0 +1,406 @@
|
||||
import type { DatabaseSync } from 'node:sqlite'
|
||||
import { getDb } from './db'
|
||||
import { LocalStorageBackend } from './storage/local'
|
||||
import {
|
||||
parseS3Config,
|
||||
parseS3Secrets,
|
||||
S3StorageBackend
|
||||
} from './storage/s3'
|
||||
import type {
|
||||
S3BackendConfig,
|
||||
ServingMode,
|
||||
StorageBackendInfo,
|
||||
StorageBackendRow,
|
||||
StorageBackendType
|
||||
} from './storage/types'
|
||||
|
||||
export const LOCAL_BACKEND_ID = 'local'
|
||||
export const S3_BACKEND_ID = 's3-primary'
|
||||
|
||||
function migrateStorageTables(database: DatabaseSync): void {
|
||||
database.exec(`
|
||||
CREATE TABLE IF NOT EXISTS storage_backends (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
type TEXT NOT NULL CHECK(type IN ('local', 's3')),
|
||||
config_json TEXT NOT NULL DEFAULT '{}',
|
||||
secret_json TEXT NOT NULL DEFAULT '{}',
|
||||
serving_mode TEXT NOT NULL DEFAULT 'proxy' CHECK(serving_mode IN ('proxy', 'public')),
|
||||
public_url TEXT NOT NULL DEFAULT '',
|
||||
quota_bytes INTEGER,
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
is_default INTEGER NOT NULL DEFAULT 0,
|
||||
sort_order INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS images (
|
||||
key TEXT PRIMARY KEY,
|
||||
backend_id TEXT NOT NULL,
|
||||
user_id INTEGER,
|
||||
folder TEXT NOT NULL,
|
||||
original_name TEXT NOT NULL,
|
||||
content_type TEXT NOT NULL,
|
||||
size INTEGER NOT NULL,
|
||||
uploaded_at TEXT NOT NULL,
|
||||
FOREIGN KEY (backend_id) REFERENCES storage_backends(id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_images_uploaded_at ON images(uploaded_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_images_backend_id ON images(backend_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_images_user_id ON images(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_images_folder ON images(folder);
|
||||
`)
|
||||
}
|
||||
|
||||
export function ensureStorageSchema(): void {
|
||||
migrateStorageTables(getDb())
|
||||
}
|
||||
|
||||
export function ensureDefaultBackends(): void {
|
||||
ensureStorageSchema()
|
||||
const db = getDb()
|
||||
|
||||
const local = db.prepare(`
|
||||
SELECT id FROM storage_backends WHERE id = ?
|
||||
`).get(LOCAL_BACKEND_ID)
|
||||
|
||||
if (!local) {
|
||||
db.prepare(`
|
||||
INSERT INTO storage_backends
|
||||
(id, name, type, config_json, secret_json, serving_mode, public_url,
|
||||
quota_bytes, enabled, is_default, sort_order)
|
||||
VALUES (?, ?, 'local', '{}', '{}', 'proxy', '', NULL, 1, 1, 0)
|
||||
`).run(LOCAL_BACKEND_ID, '本地磁盘')
|
||||
}
|
||||
|
||||
const s3 = db.prepare(`
|
||||
SELECT id FROM storage_backends WHERE id = ?
|
||||
`).get(S3_BACKEND_ID)
|
||||
|
||||
if (!s3) {
|
||||
db.prepare(`
|
||||
INSERT INTO storage_backends
|
||||
(id, name, type, config_json, secret_json, serving_mode, public_url,
|
||||
quota_bytes, enabled, is_default, sort_order)
|
||||
VALUES (?, ?, 's3', '{}', '{}', 'proxy', '', NULL, 0, 0, 1)
|
||||
`).run(S3_BACKEND_ID, 'S3 兼容存储')
|
||||
}
|
||||
}
|
||||
|
||||
function rowToInfo(row: StorageBackendRow, _maskSecrets = true): StorageBackendInfo & {
|
||||
secretsMasked: Record<string, string>
|
||||
} {
|
||||
const config = row.type === 's3'
|
||||
? (parseS3Config(row.config_json) ?? {})
|
||||
: {}
|
||||
let secretsMasked: Record<string, string> = {}
|
||||
if (row.type === 's3') {
|
||||
const secrets = parseS3Secrets(row.secret_json)
|
||||
secretsMasked = {
|
||||
accessKeyId: secrets?.accessKeyId
|
||||
? maskSecret(secrets.accessKeyId)
|
||||
: '',
|
||||
secretAccessKey: secrets?.secretAccessKey
|
||||
? maskSecret(secrets.secretAccessKey)
|
||||
: ''
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
type: row.type,
|
||||
config,
|
||||
servingMode: row.serving_mode,
|
||||
publicUrl: row.public_url,
|
||||
quotaBytes: row.quota_bytes,
|
||||
enabled: row.enabled === 1,
|
||||
isDefault: row.is_default === 1,
|
||||
sortOrder: row.sort_order,
|
||||
secretsMasked
|
||||
}
|
||||
}
|
||||
|
||||
function maskSecret(value: string): string {
|
||||
if (value.length <= 4) return '****'
|
||||
return `${value.slice(0, 4)}${'*'.repeat(Math.min(8, value.length - 4))}`
|
||||
}
|
||||
|
||||
export function listStorageBackendRows(): StorageBackendRow[] {
|
||||
ensureDefaultBackends()
|
||||
return getDb().prepare(`
|
||||
SELECT id, name, type, config_json, secret_json, serving_mode, public_url,
|
||||
quota_bytes, enabled, is_default, sort_order
|
||||
FROM storage_backends
|
||||
ORDER BY sort_order ASC, id ASC
|
||||
`).all() as unknown as StorageBackendRow[]
|
||||
}
|
||||
|
||||
export function listStorageBackends(maskSecrets = true): Array<StorageBackendInfo & {
|
||||
secretsMasked: Record<string, string>
|
||||
}> {
|
||||
return listStorageBackendRows().map(row => rowToInfo(row, maskSecrets))
|
||||
}
|
||||
|
||||
export function getStorageBackendRow(id: string): StorageBackendRow | null {
|
||||
ensureDefaultBackends()
|
||||
const row = getDb().prepare(`
|
||||
SELECT id, name, type, config_json, secret_json, serving_mode, public_url,
|
||||
quota_bytes, enabled, is_default, sort_order
|
||||
FROM storage_backends
|
||||
WHERE id = ?
|
||||
`).get(id) as StorageBackendRow | undefined
|
||||
return row ?? null
|
||||
}
|
||||
|
||||
export function getDefaultBackendRow(): StorageBackendRow | null {
|
||||
ensureDefaultBackends()
|
||||
const row = getDb().prepare(`
|
||||
SELECT id, name, type, config_json, secret_json, serving_mode, public_url,
|
||||
quota_bytes, enabled, is_default, sort_order
|
||||
FROM storage_backends
|
||||
WHERE is_default = 1 AND enabled = 1
|
||||
LIMIT 1
|
||||
`).get() as StorageBackendRow | undefined
|
||||
return row ?? null
|
||||
}
|
||||
|
||||
export function getBackendIdForKey(key: string): string | null {
|
||||
ensureStorageSchema()
|
||||
const row = getDb().prepare(`
|
||||
SELECT backend_id FROM images WHERE key = ?
|
||||
`).get(key) as { backend_id: string } | undefined
|
||||
return row?.backend_id ?? null
|
||||
}
|
||||
|
||||
export function setDefaultBackend(id: string): void {
|
||||
const db = getDb()
|
||||
db.exec('UPDATE storage_backends SET is_default = 0')
|
||||
db.prepare(`
|
||||
UPDATE storage_backends SET is_default = 1, enabled = 1 WHERE id = ?
|
||||
`).run(id)
|
||||
}
|
||||
|
||||
export interface StorageBackendPatch {
|
||||
name?: string
|
||||
config?: Record<string, unknown>
|
||||
secrets?: { accessKeyId?: string, secretAccessKey?: string }
|
||||
servingMode?: ServingMode
|
||||
publicUrl?: string
|
||||
quotaBytes?: number | null
|
||||
enabled?: boolean
|
||||
isDefault?: boolean
|
||||
}
|
||||
|
||||
export function updateStorageBackend(id: string, patch: StorageBackendPatch): void {
|
||||
const row = getStorageBackendRow(id)
|
||||
if (!row) {
|
||||
throw new Error(`Storage backend not found: ${id}`)
|
||||
}
|
||||
|
||||
const name = patch.name ?? row.name
|
||||
let configJson = row.config_json
|
||||
let secretJson = row.secret_json
|
||||
|
||||
if (patch.config !== undefined) {
|
||||
const current = row.type === 's3'
|
||||
? (parseS3Config(row.config_json) ?? {})
|
||||
: {}
|
||||
configJson = JSON.stringify({ ...current, ...patch.config })
|
||||
}
|
||||
|
||||
if (patch.secrets !== undefined && row.type === 's3') {
|
||||
const current = parseS3Secrets(row.secret_json) ?? {
|
||||
accessKeyId: '',
|
||||
secretAccessKey: ''
|
||||
}
|
||||
const next = {
|
||||
accessKeyId: patch.secrets.accessKeyId?.trim()
|
||||
? patch.secrets.accessKeyId.trim()
|
||||
: current.accessKeyId,
|
||||
secretAccessKey: patch.secrets.secretAccessKey?.trim()
|
||||
? patch.secrets.secretAccessKey.trim()
|
||||
: current.secretAccessKey
|
||||
}
|
||||
secretJson = JSON.stringify(next)
|
||||
}
|
||||
|
||||
const servingMode = patch.servingMode ?? row.serving_mode
|
||||
const publicUrl = patch.publicUrl ?? row.public_url
|
||||
const quotaBytes = patch.quotaBytes !== undefined
|
||||
? patch.quotaBytes
|
||||
: row.quota_bytes
|
||||
const enabled = patch.enabled !== undefined
|
||||
? (patch.enabled ? 1 : 0)
|
||||
: row.enabled
|
||||
|
||||
getDb().prepare(`
|
||||
UPDATE storage_backends
|
||||
SET name = ?, config_json = ?, secret_json = ?, serving_mode = ?,
|
||||
public_url = ?, quota_bytes = ?, enabled = ?
|
||||
WHERE id = ?
|
||||
`).run(name, configJson, secretJson, servingMode, publicUrl, quotaBytes, enabled, id)
|
||||
|
||||
if (patch.isDefault) {
|
||||
setDefaultBackend(id)
|
||||
}
|
||||
}
|
||||
|
||||
export function isStorageEnvConfigured(): boolean {
|
||||
return getStorageBackendFromEnv() !== null
|
||||
}
|
||||
|
||||
export function getStorageBackendFromEnv(): {
|
||||
type: StorageBackendType
|
||||
config: Record<string, unknown>
|
||||
secrets: { accessKeyId: string, secretAccessKey: string }
|
||||
servingMode: ServingMode
|
||||
publicUrl: string
|
||||
} | null {
|
||||
const backendType = process.env.STORAGE_BACKEND?.trim()
|
||||
if (backendType !== 's3') return null
|
||||
|
||||
const endpoint = process.env.S3_ENDPOINT?.trim() ?? ''
|
||||
const region = process.env.S3_REGION?.trim() ?? ''
|
||||
const bucket = process.env.S3_BUCKET?.trim() ?? ''
|
||||
const accessKeyId = process.env.S3_ACCESS_KEY?.trim() ?? ''
|
||||
const secretAccessKey = process.env.S3_SECRET_KEY?.trim() ?? ''
|
||||
|
||||
if (!endpoint || !bucket || !accessKeyId || !secretAccessKey) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
type: 's3',
|
||||
config: {
|
||||
endpoint,
|
||||
region: region || 'auto',
|
||||
bucket,
|
||||
prefix: process.env.S3_PREFIX?.trim() || undefined,
|
||||
forcePathStyle: process.env.S3_FORCE_PATH_STYLE === 'true'
|
||||
},
|
||||
secrets: { accessKeyId, secretAccessKey },
|
||||
servingMode: process.env.S3_PUBLIC_URL?.trim() ? 'public' : 'proxy',
|
||||
publicUrl: process.env.S3_PUBLIC_URL?.trim() ?? ''
|
||||
}
|
||||
}
|
||||
|
||||
export function createBackendInstance(row: StorageBackendRow) {
|
||||
if (row.type === 'local') {
|
||||
return new LocalStorageBackend(
|
||||
row.id,
|
||||
row.serving_mode,
|
||||
row.public_url
|
||||
)
|
||||
}
|
||||
|
||||
const config = parseS3Config(row.config_json)
|
||||
const secrets = parseS3Secrets(row.secret_json)
|
||||
if (!config || !secrets) {
|
||||
throw new Error(`Invalid S3 configuration for backend ${row.id}`)
|
||||
}
|
||||
|
||||
return new S3StorageBackend(
|
||||
row.id,
|
||||
config,
|
||||
secrets,
|
||||
row.serving_mode,
|
||||
row.public_url
|
||||
)
|
||||
}
|
||||
|
||||
export function createBackendFromEnv() {
|
||||
const env = getStorageBackendFromEnv()
|
||||
if (!env) return null
|
||||
|
||||
return new S3StorageBackend(
|
||||
S3_BACKEND_ID,
|
||||
env.config as unknown as S3BackendConfig,
|
||||
env.secrets,
|
||||
env.servingMode,
|
||||
env.publicUrl
|
||||
)
|
||||
}
|
||||
|
||||
export function getBackendUsageStats(backendId: string): { count: number, bytes: number } {
|
||||
ensureStorageSchema()
|
||||
const row = getDb().prepare(`
|
||||
SELECT COUNT(*) AS count, COALESCE(SUM(size), 0) AS bytes
|
||||
FROM images
|
||||
WHERE backend_id = ?
|
||||
`).get(backendId) as { count: number, bytes: number }
|
||||
return { count: row.count, bytes: row.bytes }
|
||||
}
|
||||
|
||||
function getNextSortOrder(): number {
|
||||
const row = getDb().prepare(`
|
||||
SELECT COALESCE(MAX(sort_order), 0) AS max_order FROM storage_backends
|
||||
`).get() as { max_order: number }
|
||||
return row.max_order + 1
|
||||
}
|
||||
|
||||
export function generateStorageBackendId(): string {
|
||||
const bytes = new Uint8Array(4)
|
||||
crypto.getRandomValues(bytes)
|
||||
const suffix = Array.from(bytes, b => b.toString(16).padStart(2, '0')).join('')
|
||||
return `s3-${suffix}`
|
||||
}
|
||||
|
||||
export function insertStorageBackend(input: {
|
||||
id?: string
|
||||
name: string
|
||||
config: S3BackendConfig
|
||||
secrets: { accessKeyId: string, secretAccessKey: string }
|
||||
servingMode?: ServingMode
|
||||
publicUrl?: string
|
||||
quotaBytes?: number | null
|
||||
enabled?: boolean
|
||||
isDefault?: boolean
|
||||
}): string {
|
||||
ensureDefaultBackends()
|
||||
const id = input.id ?? generateStorageBackendId()
|
||||
const sortOrder = getNextSortOrder()
|
||||
|
||||
getDb().prepare(`
|
||||
INSERT INTO storage_backends
|
||||
(id, name, type, config_json, secret_json, serving_mode, public_url,
|
||||
quota_bytes, enabled, is_default, sort_order)
|
||||
VALUES (?, ?, 's3', ?, ?, ?, ?, ?, ?, 0, ?)
|
||||
`).run(
|
||||
id,
|
||||
input.name,
|
||||
JSON.stringify(input.config),
|
||||
JSON.stringify(input.secrets),
|
||||
input.servingMode ?? 'proxy',
|
||||
input.publicUrl ?? '',
|
||||
input.quotaBytes ?? null,
|
||||
input.enabled === false ? 0 : 1,
|
||||
sortOrder
|
||||
)
|
||||
|
||||
if (input.isDefault) {
|
||||
setDefaultBackend(id)
|
||||
}
|
||||
|
||||
return id
|
||||
}
|
||||
|
||||
export function deleteStorageBackend(id: string): void {
|
||||
if (id === LOCAL_BACKEND_ID) {
|
||||
throw new Error('Cannot delete local backend')
|
||||
}
|
||||
|
||||
const usage = getBackendUsageStats(id)
|
||||
if (usage.count > 0) {
|
||||
throw new Error('Cannot delete backend with stored images')
|
||||
}
|
||||
|
||||
const row = getStorageBackendRow(id)
|
||||
if (!row) {
|
||||
throw new Error(`Storage backend not found: ${id}`)
|
||||
}
|
||||
|
||||
if (row.is_default) {
|
||||
throw new Error('Cannot delete the default backend')
|
||||
}
|
||||
|
||||
getDb().prepare('DELETE FROM storage_backends WHERE id = ?').run(id)
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { buildBackendCapacity } from './storage-capacity'
|
||||
|
||||
describe('buildBackendCapacity', () => {
|
||||
it('calculates quota-based capacity for s3 backends', async () => {
|
||||
const quotaBytes = 10 * 1024 ** 3
|
||||
const capacity = await buildBackendCapacity('s3-test', 's3', 72_390, quotaBytes)
|
||||
expect(capacity.source).toBe('quota')
|
||||
expect(capacity.totalBytes).toBe(quotaBytes)
|
||||
expect(capacity.usedBytes).toBe(72_390)
|
||||
expect(capacity.freeBytes).toBe(quotaBytes - 72_390)
|
||||
expect(capacity.percent).toBeGreaterThanOrEqual(0)
|
||||
})
|
||||
|
||||
it('allows usage percent to exceed 100 when over quota', async () => {
|
||||
const quotaBytes = 1024
|
||||
const capacity = await buildBackendCapacity('s3-test', 's3', 2048, quotaBytes)
|
||||
expect(capacity.percent).toBe(200)
|
||||
expect(capacity.freeBytes).toBe(0)
|
||||
})
|
||||
|
||||
it('reads disk stats for local backend when available', async () => {
|
||||
const capacity = await buildBackendCapacity('local', 'local', 1024, null)
|
||||
if (capacity.source === 'disk') {
|
||||
expect(capacity.totalBytes).toBeGreaterThan(0)
|
||||
expect(capacity.freeBytes).not.toBeNull()
|
||||
expect(capacity.percent).not.toBeNull()
|
||||
} else {
|
||||
expect(capacity.usedBytes).toBe(1024)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,92 @@
|
||||
import { statfs } from 'node:fs/promises'
|
||||
import { getDataDir } from './data-dir'
|
||||
import { LOCAL_BACKEND_ID } from './storage-backends'
|
||||
|
||||
export interface StorageCapacity {
|
||||
totalBytes: number | null
|
||||
usedBytes: number
|
||||
freeBytes: number | null
|
||||
percent: number | null
|
||||
source: 'disk' | 'quota'
|
||||
}
|
||||
|
||||
export function parseQuotaBytes(raw: unknown): number | null {
|
||||
const value = Number(raw)
|
||||
if (!Number.isFinite(value) || value <= 0) return null
|
||||
return Math.round(value)
|
||||
}
|
||||
|
||||
export async function getLocalVolumeStats(): Promise<{
|
||||
totalBytes: number
|
||||
freeBytes: number
|
||||
usedBytes: number
|
||||
} | null> {
|
||||
try {
|
||||
const stats = await statfs(getDataDir())
|
||||
const totalBytes = Number(stats.bsize) * Number(stats.blocks)
|
||||
const freeBytes = Number(stats.bsize) * Number(stats.bavail)
|
||||
if (!Number.isFinite(totalBytes) || totalBytes <= 0) return null
|
||||
return {
|
||||
totalBytes,
|
||||
freeBytes: Math.max(0, freeBytes),
|
||||
usedBytes: Math.max(0, totalBytes - freeBytes)
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function buildQuotaCapacity(quotaBytes: number, indexBytes: number): StorageCapacity {
|
||||
const usedBytes = indexBytes
|
||||
const freeBytes = Math.max(0, quotaBytes - usedBytes)
|
||||
const percent = Math.round((usedBytes / quotaBytes) * 1000) / 10
|
||||
return {
|
||||
totalBytes: quotaBytes,
|
||||
usedBytes,
|
||||
freeBytes,
|
||||
percent,
|
||||
source: 'quota'
|
||||
}
|
||||
}
|
||||
|
||||
export async function buildBackendCapacity(
|
||||
backendId: string,
|
||||
backendType: 'local' | 's3',
|
||||
indexBytes: number,
|
||||
quotaBytes: number | null
|
||||
): Promise<StorageCapacity> {
|
||||
if (backendType === 's3') {
|
||||
if (quotaBytes && quotaBytes > 0) {
|
||||
return buildQuotaCapacity(quotaBytes, indexBytes)
|
||||
}
|
||||
return {
|
||||
totalBytes: null,
|
||||
usedBytes: indexBytes,
|
||||
freeBytes: null,
|
||||
percent: null,
|
||||
source: 'quota'
|
||||
}
|
||||
}
|
||||
|
||||
if (backendId === LOCAL_BACKEND_ID || backendType === 'local') {
|
||||
const volume = await getLocalVolumeStats()
|
||||
if (volume) {
|
||||
const percent = Math.round((volume.usedBytes / volume.totalBytes) * 1000) / 10
|
||||
return {
|
||||
totalBytes: volume.totalBytes,
|
||||
usedBytes: volume.usedBytes,
|
||||
freeBytes: volume.freeBytes,
|
||||
percent,
|
||||
source: 'disk'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
totalBytes: null,
|
||||
usedBytes: indexBytes,
|
||||
freeBytes: null,
|
||||
percent: null,
|
||||
source: 'disk'
|
||||
}
|
||||
}
|
||||
+75
-329
@@ -1,314 +1,119 @@
|
||||
import { createReadStream, promises as fs } from 'node:fs'
|
||||
import type { ReadStream } from 'node:fs'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { isValidFolderName, validateImageKey, DEFAULT_FOLDER } from './image-key'
|
||||
import type { Readable } from 'node:stream'
|
||||
import {
|
||||
countImages as countImagesFromIndex,
|
||||
deleteImageIndex,
|
||||
getFolderStorageStats as getFolderStorageStatsFromIndex,
|
||||
getImageIndexAsStored,
|
||||
getUserScopedStorageStats as getUserScopedStorageStatsFromIndex,
|
||||
insertImageIndex,
|
||||
listFolders as listFoldersFromIndex,
|
||||
listFoldersForUser as listFoldersForUserFromIndex,
|
||||
listImageKeysForAutoDelete,
|
||||
listImages as listImagesFromIndex,
|
||||
searchImages as searchImagesFromIndex
|
||||
} from './image-index'
|
||||
import {
|
||||
getActiveBackend,
|
||||
getBackendForKey
|
||||
} from './storage/resolver'
|
||||
import type {
|
||||
FolderStorageStat,
|
||||
PaginatedResult,
|
||||
StoredImage,
|
||||
StoredImageMeta
|
||||
} from './storage/types'
|
||||
|
||||
/**
|
||||
* 本地文件系统存储层
|
||||
*
|
||||
* 目录布局(DATA_DIR 默认 ./data,容器内挂载 /data):
|
||||
* <DATA_DIR>/<type>/YYYY/MM/<随机ID>.webp 按上传 type 分目录(默认 images)
|
||||
* <DATA_DIR>/twikoo/YYYY/MM/<随机ID>.webp twikoo 评论图片
|
||||
* <DATA_DIR>/<type>/YYYY/MM/<随机ID>.webp.meta.json 元数据(原始文件名等)
|
||||
*
|
||||
* 列表按日期降序(新月份在前),meta 缺失时回退到文件属性。
|
||||
*/
|
||||
export type {
|
||||
FolderStorageStat,
|
||||
PaginatedResult,
|
||||
StoredImage,
|
||||
StoredImageMeta
|
||||
} from './storage/types'
|
||||
|
||||
export interface StoredImageMeta {
|
||||
originalName: string
|
||||
uploadedAt: string
|
||||
contentType: string
|
||||
size: number
|
||||
userId?: number | null
|
||||
}
|
||||
|
||||
export interface StoredImage extends StoredImageMeta {
|
||||
key: string
|
||||
mtimeMs: number
|
||||
}
|
||||
|
||||
const META_SUFFIX = '.meta.json'
|
||||
|
||||
export function getDataDir(): string {
|
||||
return process.env.DATA_DIR || join(process.cwd(), 'data')
|
||||
}
|
||||
|
||||
function keyToFilePath(key: string): string {
|
||||
// key 均已通过 validateImageKey 校验,不存在路径穿越
|
||||
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')
|
||||
return JSON.parse(raw) as Partial<StoredImageMeta>
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
export { getDataDir } from './data-dir'
|
||||
|
||||
export async function putImage(
|
||||
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))
|
||||
const backend = await getActiveBackend()
|
||||
await backend.put(key, bytes, meta)
|
||||
insertImageIndex({
|
||||
key,
|
||||
backendId: backend.id,
|
||||
userId: meta.userId,
|
||||
originalName: meta.originalName,
|
||||
contentType: meta.contentType,
|
||||
size: meta.size,
|
||||
uploadedAt: meta.uploadedAt
|
||||
})
|
||||
}
|
||||
|
||||
export async function headImage(key: string): Promise<StoredImage | null> {
|
||||
if (!validateImageKey(key)) return null
|
||||
const indexed = getImageIndexAsStored(key)
|
||||
if (indexed) return indexed
|
||||
|
||||
let stat
|
||||
try {
|
||||
stat = await fs.stat(keyToFilePath(key))
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
if (!stat.isFile()) return null
|
||||
const backend = await getBackendForKey(key)
|
||||
const stored = await backend.head(key)
|
||||
if (!stored) return null
|
||||
|
||||
const meta = await readMeta(key)
|
||||
return {
|
||||
key,
|
||||
size: stat.size,
|
||||
mtimeMs: stat.mtimeMs,
|
||||
contentType: meta.contentType ?? contentTypeFromKey(key),
|
||||
originalName: meta.originalName ?? key.split('/').pop() ?? 'image',
|
||||
uploadedAt: meta.uploadedAt ?? stat.mtime.toISOString(),
|
||||
userId: meta.userId ?? null
|
||||
}
|
||||
insertImageIndex({
|
||||
key: stored.key,
|
||||
backendId: backend.id,
|
||||
userId: stored.userId,
|
||||
originalName: stored.originalName,
|
||||
contentType: stored.contentType,
|
||||
size: stored.size,
|
||||
uploadedAt: stored.uploadedAt
|
||||
})
|
||||
|
||||
return { ...stored, backendId: backend.id }
|
||||
}
|
||||
|
||||
export function createImageStream(
|
||||
export async function createImageStream(
|
||||
key: string,
|
||||
range?: { start: number, end: number }
|
||||
): ReadStream {
|
||||
return createReadStream(keyToFilePath(key), range)
|
||||
): Promise<ReadStream | Readable> {
|
||||
const backend = await getBackendForKey(key)
|
||||
return backend.createStream(key, range)
|
||||
}
|
||||
|
||||
export async function deleteImage(key: string): Promise<void> {
|
||||
const filePath = keyToFilePath(key)
|
||||
await fs.unlink(filePath)
|
||||
await fs.unlink(filePath + META_SUFFIX).catch(() => {
|
||||
// meta 文件可能不存在(外部导入的历史图片)
|
||||
})
|
||||
}
|
||||
|
||||
export interface PaginatedResult<T> {
|
||||
items: T[]
|
||||
total: number
|
||||
page: number
|
||||
pageSize: number
|
||||
totalPages: number
|
||||
const backend = await getBackendForKey(key)
|
||||
await backend.delete(key)
|
||||
deleteImageIndex(key)
|
||||
}
|
||||
|
||||
export async function listFolders(): Promise<string[]> {
|
||||
const names = await readDirNames(getDataDir())
|
||||
const folders = names.filter(isValidFolderName)
|
||||
if (!folders.includes(DEFAULT_FOLDER)) {
|
||||
folders.push(DEFAULT_FOLDER)
|
||||
}
|
||||
return folders.sort((a, b) => a.localeCompare(b))
|
||||
return listFoldersFromIndex()
|
||||
}
|
||||
|
||||
/** 遍历各 type 目录下的 YYYY/MM,返回按日期降序的 key 列表(新月份在前) */
|
||||
export async function listImageKeys(folder?: string): Promise<string[]> {
|
||||
const keys: string[] = []
|
||||
const prefixes = folder && isValidFolderName(folder)
|
||||
? [folder]
|
||||
: await listFolders()
|
||||
|
||||
for (const prefix of prefixes) {
|
||||
const root = join(getDataDir(), prefix)
|
||||
const years = await readDirNames(root)
|
||||
for (const year of years) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 按 YYYY/MM/文件名 降序排,使不同 type 按时间交错而不是分块
|
||||
return keys.sort((a, b) => {
|
||||
const aDate = a.slice(a.indexOf('/') + 1)
|
||||
const bDate = b.slice(b.indexOf('/') + 1)
|
||||
return bDate.localeCompare(aDate) || b.localeCompare(a)
|
||||
})
|
||||
}
|
||||
|
||||
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 []
|
||||
}
|
||||
}
|
||||
|
||||
function matchesUserFilter(
|
||||
meta: StoredImageMeta,
|
||||
userFilter: number | 'admin'
|
||||
): boolean {
|
||||
if (userFilter === 'admin') return true
|
||||
const ownerId = meta.userId ?? null
|
||||
if (ownerId === null) return false
|
||||
return ownerId === userFilter
|
||||
}
|
||||
|
||||
async function filterKeysByUser(
|
||||
keys: string[],
|
||||
userFilter?: number | 'admin'
|
||||
): Promise<string[]> {
|
||||
if (userFilter === undefined || userFilter === 'admin') {
|
||||
return keys
|
||||
}
|
||||
|
||||
const filtered: string[] = []
|
||||
for (const key of keys) {
|
||||
const meta = await readMeta(key)
|
||||
if (matchesUserFilter(meta as StoredImageMeta, userFilter)) {
|
||||
filtered.push(key)
|
||||
}
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
|
||||
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 []
|
||||
}
|
||||
}
|
||||
|
||||
function paginateSlice<T>(
|
||||
all: T[],
|
||||
limit: number,
|
||||
page?: number
|
||||
): PaginatedResult<T> {
|
||||
const total = all.length
|
||||
const pageSize = limit
|
||||
const totalPages = Math.max(1, Math.ceil(total / pageSize) || 1)
|
||||
const requestedPage = Math.max(1, page ?? 1)
|
||||
const safePage = Math.min(requestedPage, totalPages)
|
||||
const start = (safePage - 1) * pageSize
|
||||
const items = all.slice(start, start + pageSize)
|
||||
|
||||
return {
|
||||
items,
|
||||
total,
|
||||
page: safePage,
|
||||
pageSize,
|
||||
totalPages
|
||||
}
|
||||
export async function listImageKeys(): Promise<string[]> {
|
||||
const images = await listImageKeysForAutoDelete()
|
||||
return images.map(image => image.key)
|
||||
}
|
||||
|
||||
export async function countImages(
|
||||
folder?: string,
|
||||
userFilter?: number | 'admin'
|
||||
): Promise<number> {
|
||||
let keys = await listImageKeys(folder)
|
||||
keys = await filterKeysByUser(keys, userFilter)
|
||||
return keys.length
|
||||
return countImagesFromIndex(folder, userFilter)
|
||||
}
|
||||
|
||||
export interface FolderStorageStat {
|
||||
folder: string
|
||||
count: number
|
||||
bytes: number
|
||||
}
|
||||
|
||||
/** 按顶层目录统计当前磁盘上的图片数量与体积 */
|
||||
export async function getFolderStorageStats(
|
||||
userFilter?: number | 'admin'
|
||||
): Promise<FolderStorageStat[]> {
|
||||
let keys = await listImageKeys()
|
||||
keys = await filterKeysByUser(keys, userFilter)
|
||||
const map = new Map<string, { count: number, bytes: number }>()
|
||||
|
||||
for (const key of keys) {
|
||||
const folder = key.split('/')[0] ?? 'unknown'
|
||||
const meta = await headImage(key)
|
||||
if (!meta) continue
|
||||
const current = map.get(folder) ?? { count: 0, bytes: 0 }
|
||||
current.count += 1
|
||||
current.bytes += meta.size
|
||||
map.set(folder, current)
|
||||
}
|
||||
|
||||
return [...map.entries()]
|
||||
.map(([folder, stat]) => ({ folder, ...stat }))
|
||||
.sort((a, b) => b.bytes - a.bytes || a.folder.localeCompare(b.folder))
|
||||
return getFolderStorageStatsFromIndex(userFilter)
|
||||
}
|
||||
|
||||
export async function listFoldersForUser(
|
||||
userFilter?: number | 'admin'
|
||||
): Promise<string[]> {
|
||||
if (userFilter === undefined || userFilter === 'admin') {
|
||||
return listFolders()
|
||||
}
|
||||
|
||||
let keys = await listImageKeys()
|
||||
keys = await filterKeysByUser(keys, userFilter)
|
||||
const folders = new Set<string>()
|
||||
for (const key of keys) {
|
||||
const folder = key.split('/')[0]
|
||||
if (folder && isValidFolderName(folder)) {
|
||||
folders.add(folder)
|
||||
}
|
||||
}
|
||||
if (!folders.size) {
|
||||
folders.add(DEFAULT_FOLDER)
|
||||
}
|
||||
return [...folders].sort((a, b) => a.localeCompare(b))
|
||||
return listFoldersForUserFromIndex(userFilter)
|
||||
}
|
||||
|
||||
function startOfDayIsoInShanghai(now = new Date()): string {
|
||||
const shifted = new Date(now.getTime() + 8 * 60 * 60 * 1000)
|
||||
const y = shifted.getUTCFullYear()
|
||||
const m = shifted.getUTCMonth()
|
||||
const d = shifted.getUTCDate()
|
||||
return new Date(Date.UTC(y, m, d) - 8 * 60 * 60 * 1000).toISOString()
|
||||
}
|
||||
|
||||
function startOfMonthIsoInShanghai(now = new Date()): string {
|
||||
const shifted = new Date(now.getTime() + 8 * 60 * 60 * 1000)
|
||||
const y = shifted.getUTCFullYear()
|
||||
const m = shifted.getUTCMonth()
|
||||
return new Date(Date.UTC(y, m, 1) - 8 * 60 * 60 * 1000).toISOString()
|
||||
}
|
||||
|
||||
/** 普通用户统计:基于本人图片 meta,不依赖全局 activity_logs */
|
||||
export async function getUserScopedStorageStats(userId: number): Promise<{
|
||||
storedCount: number
|
||||
uploadBytesTotal: number
|
||||
@@ -316,42 +121,7 @@ export async function getUserScopedStorageStats(userId: number): Promise<{
|
||||
uploadMonth: number
|
||||
byFolder: FolderStorageStat[]
|
||||
}> {
|
||||
let keys = await listImageKeys()
|
||||
keys = await filterKeysByUser(keys, userId)
|
||||
|
||||
const startOfDay = startOfDayIsoInShanghai()
|
||||
const startOfMonth = startOfMonthIsoInShanghai()
|
||||
const map = new Map<string, { count: number, bytes: number }>()
|
||||
let uploadBytesTotal = 0
|
||||
let uploadToday = 0
|
||||
let uploadMonth = 0
|
||||
|
||||
for (const key of keys) {
|
||||
const meta = await headImage(key)
|
||||
if (!meta) continue
|
||||
|
||||
uploadBytesTotal += meta.size
|
||||
if (meta.uploadedAt >= startOfDay) uploadToday += 1
|
||||
if (meta.uploadedAt >= startOfMonth) uploadMonth += 1
|
||||
|
||||
const folder = key.split('/')[0] ?? 'unknown'
|
||||
const current = map.get(folder) ?? { count: 0, bytes: 0 }
|
||||
current.count += 1
|
||||
current.bytes += meta.size
|
||||
map.set(folder, current)
|
||||
}
|
||||
|
||||
const byFolder = [...map.entries()]
|
||||
.map(([folder, stat]) => ({ folder, ...stat }))
|
||||
.sort((a, b) => b.bytes - a.bytes || a.folder.localeCompare(b.folder))
|
||||
|
||||
return {
|
||||
storedCount: keys.length,
|
||||
uploadBytesTotal,
|
||||
uploadToday,
|
||||
uploadMonth,
|
||||
byFolder
|
||||
}
|
||||
return getUserScopedStorageStatsFromIndex(userId)
|
||||
}
|
||||
|
||||
export async function listImages(options: {
|
||||
@@ -360,16 +130,7 @@ export async function listImages(options: {
|
||||
folder?: string
|
||||
userFilter?: number | 'admin'
|
||||
}): Promise<PaginatedResult<StoredImage>> {
|
||||
let keys = await listImageKeys(options.folder)
|
||||
keys = await filterKeysByUser(keys, options.userFilter)
|
||||
const sliced = paginateSlice(keys, options.limit, options.page)
|
||||
const items = (await Promise.all(sliced.items.map(headImage)))
|
||||
.filter((item): item is StoredImage => item !== null)
|
||||
|
||||
return {
|
||||
...sliced,
|
||||
items
|
||||
}
|
||||
return listImagesFromIndex(options)
|
||||
}
|
||||
|
||||
export async function searchImages(options: {
|
||||
@@ -379,20 +140,5 @@ export async function searchImages(options: {
|
||||
folder?: string
|
||||
userFilter?: number | 'admin'
|
||||
}): Promise<PaginatedResult<StoredImage>> {
|
||||
const needle = options.query.trim().toLowerCase()
|
||||
let keys = await listImageKeys(options.folder)
|
||||
keys = await filterKeysByUser(keys, options.userFilter)
|
||||
const matches: StoredImage[] = []
|
||||
|
||||
for (const key of keys) {
|
||||
const keyMatches = key.toLowerCase().includes(needle)
|
||||
const item = await headImage(key)
|
||||
if (!item) continue
|
||||
|
||||
if (keyMatches || item.originalName.toLowerCase().includes(needle)) {
|
||||
matches.push(item)
|
||||
}
|
||||
}
|
||||
|
||||
return paginateSlice(matches, options.limit, options.page)
|
||||
return searchImagesFromIndex(options)
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export { getActiveBackend, getBackendForKey, getActiveBackendRow, getBackendRowForKey } from './resolver'
|
||||
export { LocalStorageBackend } from './local'
|
||||
export { S3StorageBackend, buildObjectKey, parseS3Config, validateS3Config } from './s3'
|
||||
@@ -0,0 +1,116 @@
|
||||
import { createReadStream, promises as fs } from 'node:fs'
|
||||
import type { ReadStream } from 'node:fs'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { getDataDir } from '../data-dir'
|
||||
import { validateImageKey } from '../image-key'
|
||||
import type { StoredImage, StoredImageMeta, StorageBackend } from './types'
|
||||
|
||||
const META_SUFFIX = '.meta.json'
|
||||
|
||||
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')
|
||||
return JSON.parse(raw) as Partial<StoredImageMeta>
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
export class LocalStorageBackend implements StorageBackend {
|
||||
readonly id: string
|
||||
readonly type = 'local' as const
|
||||
readonly servingMode: 'proxy' | 'public'
|
||||
readonly publicUrl: string
|
||||
|
||||
constructor(
|
||||
id = 'local',
|
||||
servingMode: 'proxy' | 'public' = 'proxy',
|
||||
publicUrl = ''
|
||||
) {
|
||||
this.id = id
|
||||
this.servingMode = servingMode
|
||||
this.publicUrl = publicUrl
|
||||
}
|
||||
|
||||
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> {
|
||||
if (!validateImageKey(key)) return null
|
||||
|
||||
let stat
|
||||
try {
|
||||
stat = await fs.stat(keyToFilePath(key))
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
if (!stat.isFile()) return null
|
||||
|
||||
const meta = await readMeta(key)
|
||||
return {
|
||||
key,
|
||||
size: stat.size,
|
||||
mtimeMs: stat.mtimeMs,
|
||||
contentType: meta.contentType ?? contentTypeFromKey(key),
|
||||
originalName: meta.originalName ?? key.split('/').pop() ?? 'image',
|
||||
uploadedAt: meta.uploadedAt ?? stat.mtime.toISOString(),
|
||||
userId: meta.userId ?? null,
|
||||
backendId: this.id
|
||||
}
|
||||
}
|
||||
|
||||
async delete(key: string): Promise<void> {
|
||||
const filePath = keyToFilePath(key)
|
||||
await fs.unlink(filePath)
|
||||
await fs.unlink(filePath + META_SUFFIX).catch(() => {})
|
||||
}
|
||||
|
||||
async createStream(
|
||||
key: string,
|
||||
range?: { start: number, end: number }
|
||||
): Promise<ReadStream> {
|
||||
return createReadStream(keyToFilePath(key), range)
|
||||
}
|
||||
|
||||
async testConnection(): Promise<{ ok: boolean, message: string }> {
|
||||
try {
|
||||
await fs.access(getDataDir())
|
||||
return { ok: true, message: '本地磁盘可访问' }
|
||||
} catch {
|
||||
return { ok: false, message: '无法访问数据目录' }
|
||||
}
|
||||
}
|
||||
|
||||
async getUsageStats(): Promise<{ count: number, bytes: number }> {
|
||||
return { count: 0, bytes: 0 }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { LOCAL_BACKEND_ID } from '../storage-backends'
|
||||
|
||||
describe('storage resolver constants', () => {
|
||||
it('uses stable local backend id', () => {
|
||||
expect(LOCAL_BACKEND_ID).toBe('local')
|
||||
})
|
||||
})
|
||||
|
||||
describe('default backend routing', () => {
|
||||
it('returns local when no env override and local is default', async () => {
|
||||
const original = process.env.STORAGE_BACKEND
|
||||
delete process.env.STORAGE_BACKEND
|
||||
|
||||
const { setDefaultBackend, LOCAL_BACKEND_ID } = await import('../storage-backends')
|
||||
setDefaultBackend(LOCAL_BACKEND_ID)
|
||||
|
||||
const { getActiveBackend } = await import('./resolver')
|
||||
const backend = await getActiveBackend()
|
||||
expect(backend.type).toBe('local')
|
||||
|
||||
if (original !== undefined) {
|
||||
process.env.STORAGE_BACKEND = original
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,115 @@
|
||||
import {
|
||||
createBackendFromEnv,
|
||||
createBackendInstance,
|
||||
getBackendIdForKey,
|
||||
getDefaultBackendRow,
|
||||
getStorageBackendRow,
|
||||
LOCAL_BACKEND_ID,
|
||||
ensureDefaultBackends
|
||||
} from '../storage-backends'
|
||||
import type { StorageBackend, StorageBackendRow } from './types'
|
||||
|
||||
export async function getActiveBackend(): Promise<StorageBackend> {
|
||||
const fromEnv = createBackendFromEnv()
|
||||
if (fromEnv) return fromEnv
|
||||
|
||||
ensureDefaultBackends()
|
||||
const row = getDefaultBackendRow()
|
||||
if (!row) {
|
||||
return createBackendInstance({
|
||||
id: LOCAL_BACKEND_ID,
|
||||
name: '本地磁盘',
|
||||
type: 'local',
|
||||
config_json: '{}',
|
||||
secret_json: '{}',
|
||||
serving_mode: 'proxy',
|
||||
public_url: '',
|
||||
quota_bytes: null,
|
||||
enabled: 1,
|
||||
is_default: 1,
|
||||
sort_order: 0
|
||||
})
|
||||
}
|
||||
|
||||
return createBackendInstance(row)
|
||||
}
|
||||
|
||||
export async function getBackendForKey(key: string): Promise<StorageBackend> {
|
||||
const fromEnv = createBackendFromEnv()
|
||||
if (fromEnv) return fromEnv
|
||||
|
||||
ensureDefaultBackends()
|
||||
const backendId = getBackendIdForKey(key) ?? LOCAL_BACKEND_ID
|
||||
const row = getStorageBackendRow(backendId)
|
||||
|
||||
if (!row || !row.enabled) {
|
||||
return createBackendInstance(fallbackLocalRow())
|
||||
}
|
||||
|
||||
try {
|
||||
return createBackendInstance(row)
|
||||
} catch {
|
||||
return createBackendInstance(fallbackLocalRow())
|
||||
}
|
||||
}
|
||||
|
||||
export function getActiveBackendRow(): StorageBackendRow | null {
|
||||
const fromEnv = createBackendFromEnv()
|
||||
if (fromEnv) {
|
||||
return {
|
||||
id: fromEnv.id,
|
||||
name: 'S3 (环境变量)',
|
||||
type: 's3',
|
||||
config_json: '{}',
|
||||
secret_json: '{}',
|
||||
serving_mode: fromEnv.servingMode,
|
||||
public_url: fromEnv.publicUrl,
|
||||
quota_bytes: null,
|
||||
enabled: 1,
|
||||
is_default: 1,
|
||||
sort_order: 0
|
||||
}
|
||||
}
|
||||
|
||||
ensureDefaultBackends()
|
||||
return getDefaultBackendRow()
|
||||
}
|
||||
|
||||
export async function getBackendRowForKey(key: string): Promise<StorageBackendRow> {
|
||||
const fromEnv = createBackendFromEnv()
|
||||
if (fromEnv) {
|
||||
return {
|
||||
id: fromEnv.id,
|
||||
name: 'S3 (环境变量)',
|
||||
type: 's3',
|
||||
config_json: '{}',
|
||||
secret_json: '{}',
|
||||
serving_mode: fromEnv.servingMode,
|
||||
public_url: fromEnv.publicUrl,
|
||||
quota_bytes: null,
|
||||
enabled: 1,
|
||||
is_default: 1,
|
||||
sort_order: 0
|
||||
}
|
||||
}
|
||||
|
||||
ensureDefaultBackends()
|
||||
const backendId = getBackendIdForKey(key) ?? LOCAL_BACKEND_ID
|
||||
return getStorageBackendRow(backendId) ?? fallbackLocalRow()
|
||||
}
|
||||
|
||||
function fallbackLocalRow(): StorageBackendRow {
|
||||
return {
|
||||
id: LOCAL_BACKEND_ID,
|
||||
name: '本地磁盘',
|
||||
type: 'local',
|
||||
config_json: '{}',
|
||||
secret_json: '{}',
|
||||
serving_mode: 'proxy',
|
||||
public_url: '',
|
||||
quota_bytes: null,
|
||||
enabled: 1,
|
||||
is_default: 1,
|
||||
sort_order: 0
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { buildObjectKey, parseS3Config, validateS3Config } from './s3'
|
||||
|
||||
describe('S3 backend helpers', () => {
|
||||
it('buildObjectKey joins prefix and key', () => {
|
||||
expect(buildObjectKey('pichost', 'images/2026/01/a.webp')).toBe(
|
||||
'pichost/images/2026/01/a.webp'
|
||||
)
|
||||
expect(buildObjectKey(undefined, 'images/a.webp')).toBe('images/a.webp')
|
||||
expect(buildObjectKey('/pichost/', 'images/a.webp')).toBe('pichost/images/a.webp')
|
||||
})
|
||||
|
||||
it('parseS3Config requires endpoint, region and bucket', () => {
|
||||
expect(parseS3Config('{}')).toBeNull()
|
||||
expect(parseS3Config(JSON.stringify({
|
||||
endpoint: 'https://example.r2.cloudflarestorage.com',
|
||||
region: 'auto',
|
||||
bucket: 'pics'
|
||||
}))).toEqual({
|
||||
endpoint: 'https://example.r2.cloudflarestorage.com',
|
||||
region: 'auto',
|
||||
bucket: 'pics',
|
||||
prefix: undefined,
|
||||
forcePathStyle: false
|
||||
})
|
||||
})
|
||||
|
||||
it('validateS3Config rejects invalid endpoint', () => {
|
||||
expect(validateS3Config({
|
||||
endpoint: 'not-a-url',
|
||||
region: 'auto',
|
||||
bucket: 'pics'
|
||||
})).toBeTruthy()
|
||||
expect(validateS3Config({
|
||||
endpoint: 'https://example.com',
|
||||
region: 'auto',
|
||||
bucket: ''
|
||||
})).toBeTruthy()
|
||||
expect(validateS3Config({
|
||||
endpoint: 'https://example.com',
|
||||
region: 'auto',
|
||||
bucket: 'pics'
|
||||
})).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,218 @@
|
||||
import {
|
||||
DeleteObjectCommand,
|
||||
GetObjectCommand,
|
||||
HeadBucketCommand,
|
||||
HeadObjectCommand,
|
||||
PutObjectCommand,
|
||||
S3Client
|
||||
} from '@aws-sdk/client-s3'
|
||||
import type { Readable } from 'node:stream'
|
||||
import { validateImageKey } from '../image-key'
|
||||
import type {
|
||||
S3BackendConfig,
|
||||
S3BackendSecrets,
|
||||
ServingMode,
|
||||
StoredImage,
|
||||
StoredImageMeta,
|
||||
StorageBackend
|
||||
} from './types'
|
||||
|
||||
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'
|
||||
}
|
||||
}
|
||||
|
||||
export function buildObjectKey(prefix: string | undefined, key: string): string {
|
||||
const trimmed = prefix?.replace(/^\/+|\/+$/g, '') ?? ''
|
||||
return trimmed ? `${trimmed}/${key}` : key
|
||||
}
|
||||
|
||||
export function parseS3Config(raw: string): S3BackendConfig | null {
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as Partial<S3BackendConfig>
|
||||
if (!parsed.endpoint || !parsed.region || !parsed.bucket) return null
|
||||
return {
|
||||
endpoint: parsed.endpoint,
|
||||
region: parsed.region,
|
||||
bucket: parsed.bucket,
|
||||
prefix: parsed.prefix,
|
||||
forcePathStyle: parsed.forcePathStyle ?? false
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function parseS3Secrets(raw: string): S3BackendSecrets | null {
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as Partial<S3BackendSecrets>
|
||||
if (!parsed.accessKeyId || !parsed.secretAccessKey) return null
|
||||
return {
|
||||
accessKeyId: parsed.accessKeyId,
|
||||
secretAccessKey: parsed.secretAccessKey
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function validateS3Config(config: S3BackendConfig): string | null {
|
||||
if (!config.endpoint.startsWith('http')) {
|
||||
return 'Endpoint 需为 http(s) 地址'
|
||||
}
|
||||
if (!config.bucket.trim()) {
|
||||
return 'Bucket 不能为空'
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export class S3StorageBackend implements StorageBackend {
|
||||
readonly id: string
|
||||
readonly type = 's3' as const
|
||||
readonly servingMode: ServingMode
|
||||
readonly publicUrl: string
|
||||
private readonly client: S3Client
|
||||
private readonly bucket: string
|
||||
private readonly prefix: string | undefined
|
||||
|
||||
constructor(
|
||||
id: string,
|
||||
config: S3BackendConfig,
|
||||
secrets: S3BackendSecrets,
|
||||
servingMode: ServingMode = 'proxy',
|
||||
publicUrl = ''
|
||||
) {
|
||||
this.id = id
|
||||
this.servingMode = servingMode
|
||||
this.publicUrl = publicUrl
|
||||
this.bucket = config.bucket
|
||||
this.prefix = config.prefix
|
||||
this.client = new S3Client({
|
||||
endpoint: config.endpoint,
|
||||
region: config.region,
|
||||
credentials: {
|
||||
accessKeyId: secrets.accessKeyId,
|
||||
secretAccessKey: secrets.secretAccessKey
|
||||
},
|
||||
forcePathStyle: config.forcePathStyle ?? false
|
||||
})
|
||||
}
|
||||
|
||||
private objectKey(key: string): string {
|
||||
return buildObjectKey(this.prefix, key)
|
||||
}
|
||||
|
||||
async put(key: string, bytes: Uint8Array, meta: StoredImageMeta): Promise<void> {
|
||||
await this.client.send(new PutObjectCommand({
|
||||
Bucket: this.bucket,
|
||||
Key: this.objectKey(key),
|
||||
Body: bytes,
|
||||
ContentType: meta.contentType,
|
||||
Metadata: {
|
||||
originalName: meta.originalName,
|
||||
uploadedAt: meta.uploadedAt,
|
||||
userId: meta.userId != null ? String(meta.userId) : ''
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
async head(key: string): Promise<StoredImage | null> {
|
||||
if (!validateImageKey(key)) return null
|
||||
|
||||
try {
|
||||
const response = await this.client.send(new HeadObjectCommand({
|
||||
Bucket: this.bucket,
|
||||
Key: this.objectKey(key)
|
||||
}))
|
||||
|
||||
const uploadedAt = response.Metadata?.uploadedat
|
||||
?? response.LastModified?.toISOString()
|
||||
?? new Date().toISOString()
|
||||
const originalName = response.Metadata?.originalname
|
||||
?? key.split('/').pop()
|
||||
?? 'image'
|
||||
const userIdRaw = response.Metadata?.userid
|
||||
const userId = userIdRaw ? Number(userIdRaw) : null
|
||||
|
||||
return {
|
||||
key,
|
||||
size: response.ContentLength ?? 0,
|
||||
mtimeMs: response.LastModified?.getTime() ?? Date.now(),
|
||||
contentType: response.ContentType ?? contentTypeFromKey(key),
|
||||
originalName,
|
||||
uploadedAt,
|
||||
userId: Number.isFinite(userId) ? userId : null,
|
||||
backendId: this.id
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async delete(key: string): Promise<void> {
|
||||
await this.client.send(new DeleteObjectCommand({
|
||||
Bucket: this.bucket,
|
||||
Key: this.objectKey(key)
|
||||
}))
|
||||
}
|
||||
|
||||
async createStream(
|
||||
key: string,
|
||||
range?: { start: number, end: number }
|
||||
): Promise<Readable> {
|
||||
const response = await this.client.send(new GetObjectCommand({
|
||||
Bucket: this.bucket,
|
||||
Key: this.objectKey(key),
|
||||
Range: range ? `bytes=${range.start}-${range.end}` : undefined
|
||||
}))
|
||||
|
||||
if (!response.Body) {
|
||||
throw new Error('Empty S3 response body')
|
||||
}
|
||||
|
||||
return response.Body as Readable
|
||||
}
|
||||
|
||||
async testConnection(): Promise<{ ok: boolean, message: string }> {
|
||||
const probeKey = `__pichost-probe-${Date.now()}.txt`
|
||||
const probeBytes = new TextEncoder().encode('pichost-probe')
|
||||
|
||||
try {
|
||||
await this.client.send(new HeadBucketCommand({ Bucket: this.bucket }))
|
||||
await this.client.send(new PutObjectCommand({
|
||||
Bucket: this.bucket,
|
||||
Key: buildObjectKey(this.prefix, probeKey),
|
||||
Body: probeBytes,
|
||||
ContentType: 'text/plain'
|
||||
}))
|
||||
await this.client.send(new DeleteObjectCommand({
|
||||
Bucket: this.bucket,
|
||||
Key: buildObjectKey(this.prefix, probeKey)
|
||||
}))
|
||||
return { ok: true, message: '连接成功' }
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : '连接失败'
|
||||
return { ok: false, message }
|
||||
}
|
||||
}
|
||||
|
||||
async getUsageStats(): Promise<{ count: number, bytes: number }> {
|
||||
return { count: 0, bytes: 0 }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import type { Readable } from 'node:stream'
|
||||
|
||||
export interface StoredImageMeta {
|
||||
originalName: string
|
||||
uploadedAt: string
|
||||
contentType: string
|
||||
size: number
|
||||
userId?: number | null
|
||||
}
|
||||
|
||||
export interface StoredImage extends StoredImageMeta {
|
||||
key: string
|
||||
mtimeMs: number
|
||||
backendId?: string
|
||||
}
|
||||
|
||||
export interface PaginatedResult<T> {
|
||||
items: T[]
|
||||
total: number
|
||||
page: number
|
||||
pageSize: number
|
||||
totalPages: number
|
||||
}
|
||||
|
||||
export interface FolderStorageStat {
|
||||
folder: string
|
||||
count: number
|
||||
bytes: number
|
||||
}
|
||||
|
||||
export type StorageBackendType = 'local' | 's3'
|
||||
export type ServingMode = 'proxy' | 'public'
|
||||
|
||||
export interface S3BackendConfig {
|
||||
endpoint: string
|
||||
region: string
|
||||
bucket: string
|
||||
prefix?: string
|
||||
forcePathStyle?: boolean
|
||||
}
|
||||
|
||||
export interface S3BackendSecrets {
|
||||
accessKeyId: string
|
||||
secretAccessKey: string
|
||||
}
|
||||
|
||||
export interface StorageBackendRow {
|
||||
id: string
|
||||
name: string
|
||||
type: StorageBackendType
|
||||
config_json: string
|
||||
secret_json: string
|
||||
serving_mode: ServingMode
|
||||
public_url: string
|
||||
quota_bytes: number | null
|
||||
enabled: number
|
||||
is_default: number
|
||||
sort_order: number
|
||||
}
|
||||
|
||||
export interface StorageBackendInfo {
|
||||
id: string
|
||||
name: string
|
||||
type: StorageBackendType
|
||||
config: S3BackendConfig | Record<string, never>
|
||||
servingMode: ServingMode
|
||||
publicUrl: string
|
||||
quotaBytes: number | null
|
||||
enabled: boolean
|
||||
isDefault: boolean
|
||||
sortOrder: number
|
||||
}
|
||||
|
||||
export interface StorageBackend {
|
||||
readonly id: string
|
||||
readonly type: StorageBackendType
|
||||
readonly servingMode: ServingMode
|
||||
readonly publicUrl: string
|
||||
put(key: string, bytes: Uint8Array, meta: StoredImageMeta): Promise<void>
|
||||
head(key: string): Promise<StoredImage | null>
|
||||
delete(key: string): Promise<void>
|
||||
createStream(key: string, range?: { start: number, end: number }): Promise<Readable>
|
||||
testConnection(): Promise<{ ok: boolean, message: string }>
|
||||
getUsageStats(): Promise<{ count: number, bytes: number }>
|
||||
}
|
||||
Reference in new issue
Block a user