release: v1.1.0 — S3 multi-backend storage and admin UI

This commit is contained in:
O96u committed 2026-08-26 14:49:46 +08:00
1 parent 7c45a78572
commit acc64751ef
46 files changed
+4005 -480

No files matched your search

+39
View File
@@ -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 }
})
+129
View File
@@ -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 }
}
})
+39
View File
@@ -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()
}
})
+83
View File
@@ -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 }
})
+11 -1
View File
@@ -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 })
+7 -9
View File
@@ -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++
}
}
+5
View File
@@ -0,0 +1,5 @@
import { join } from 'node:path'
export function getDataDir(): string {
return process.env.DATA_DIR || join(process.cwd(), 'data')
}
+2 -2
View File
@@ -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()
+14
View File
@@ -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)
})
})
+418
View File
@@ -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)
}
+48 -3
View File
@@ -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 `![${safeAlt}](${url})`
@@ -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 {
+2 -3
View File
@@ -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,
+10 -2
View File
@@ -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))
}
+406
View File
@@ -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)
}
+32
View File
@@ -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)
}
})
})
+92
View File
@@ -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
View File
@@ -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)
}
+3
View File
@@ -0,0 +1,3 @@
export { getActiveBackend, getBackendForKey, getActiveBackendRow, getBackendRowForKey } from './resolver'
export { LocalStorageBackend } from './local'
export { S3StorageBackend, buildObjectKey, parseS3Config, validateS3Config } from './s3'
+116
View File
@@ -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 }
}
}
+26
View File
@@ -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
}
})
})
+115
View File
@@ -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
}
}
+45
View File
@@ -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()
})
})
+218
View File
@@ -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 }
}
}
+85
View File
@@ -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 }>
}