feat: activity logs, gallery storage filter, and upload rate limits

Add per-user activity logs with storage backend tracking, gallery
storage filter, and IP/token upload rate limiting. Fix storage delete
modal flash, pin local backend in filter order, and link settings
version to GitHub releases.
This commit is contained in:
O96u committed 2026-08-26 17:09:38 +08:00
1 parent 901fefb3f5
commit 6fc054328d
34 files changed
+1357 -72

No files matched your search

+3 -1
View File
@@ -72,7 +72,9 @@ export default defineEventHandler(async (event) => {
originalName: existing.originalName,
size: existing.size,
contentType: existing.contentType,
source: resolveActivitySource(event)
source: resolveActivitySource(event),
userId: existing.userId ?? null,
backendId: existing.backendId ?? null
})
logInfo('delete success', { key, size: existing.size })
+7 -1
View File
@@ -1,5 +1,7 @@
import { getImageUserFilter, requireApiOrAdminAuth } from '../../utils/access'
import { isValidFolderName } from '../../utils/image-key'
import { readBackendIdQuery } from '../../utils/image-query'
import { createApiError } from '../../utils/api-error'
import { countImages } from '../../utils/storage'
export default defineEventHandler(async (event) => {
@@ -9,8 +11,12 @@ export default defineEventHandler(async (event) => {
const query = getQuery(event)
const raw = typeof query.folder === 'string' ? query.folder.trim() : ''
const folder = raw && raw !== 'all' && isValidFolderName(raw) ? raw : undefined
const backendId = readBackendIdQuery(query)
if (backendId === null) {
createApiError(event, 'INVALID_REQUEST', '无效的存储后端', 400)
}
const total = await countImages(folder, userFilter)
const total = await countImages(folder, userFilter, backendId)
return { total }
})
+3 -1
View File
@@ -42,7 +42,9 @@ export default defineEventHandler(async (event) => {
originalName: existing.originalName,
size: existing.size,
contentType: existing.contentType,
source: resolveActivitySource(event)
source: resolveActivitySource(event),
userId: existing.userId ?? null,
backendId: existing.backendId ?? null
})
logInfo('delete success', { key, size: existing.size })
+10 -2
View File
@@ -5,9 +5,12 @@ import {
DEFAULT_LIST_LIMIT,
MAX_LIST_LIMIT
} from '../../utils/constants'
import { createApiError } from '../../utils/api-error'
import { listUserIdUsernameMap } from '../../utils/db'
import { mapStoredImageToItem } from '../../utils/image-response'
import { listStorageBackendNameMap } from '../../utils/storage-backends'
import { isValidFolderName } from '../../utils/image-key'
import { readBackendIdQuery } from '../../utils/image-query'
import { listImages } from '../../utils/storage'
function readFolderQuery(query: Record<string, unknown>): string | undefined {
@@ -32,14 +35,19 @@ export default defineEventHandler(async (event) => {
: 1
const folder = readFolderQuery(query)
const backendId = readBackendIdQuery(query)
if (backendId === null) {
createApiError(event, 'INVALID_REQUEST', '无效的存储后端', 400)
}
const userFilter = await getImageUserFilter(event)
const listing = await listImages({ limit, page, folder, userFilter })
const listing = await listImages({ limit, page, folder, userFilter, backendId })
const user = await getCurrentUser(event)
const ownerMap = user?.role === 'admin' ? listUserIdUsernameMap() : undefined
const backendMap = listStorageBackendNameMap()
const items = listing.items.map(stored =>
mapStoredImageToItem(event, stored, { ownerMap })
mapStoredImageToItem(event, stored, { ownerMap, backendMap })
)
items.sort((a, b) => b.uploadedAt.localeCompare(a.uploadedAt))
+9 -2
View File
@@ -8,7 +8,9 @@ import {
import { createApiError } from '../../utils/api-error'
import { listUserIdUsernameMap } from '../../utils/db'
import { mapStoredImageToItem } from '../../utils/image-response'
import { listStorageBackendNameMap } from '../../utils/storage-backends'
import { isValidFolderName } from '../../utils/image-key'
import { readBackendIdQuery } from '../../utils/image-query'
import { searchImages } from '../../utils/storage'
function readFolderQuery(query: Record<string, unknown>): string | undefined {
@@ -42,14 +44,19 @@ export default defineEventHandler(async (event) => {
: 1
const folder = readFolderQuery(query)
const backendId = readBackendIdQuery(query)
if (backendId === null) {
createApiError(event, 'INVALID_REQUEST', '无效的存储后端', 400)
}
const userFilter = await getImageUserFilter(event)
const result = await searchImages({ query: q, limit, page, folder, userFilter })
const result = await searchImages({ query: q, limit, page, folder, userFilter, backendId })
const user = await getCurrentUser(event)
const ownerMap = user?.role === 'admin' ? listUserIdUsernameMap() : undefined
const backendMap = listStorageBackendNameMap()
const items = result.items.map(stored =>
mapStoredImageToItem(event, stored, { ownerMap })
mapStoredImageToItem(event, stored, { ownerMap, backendMap })
)
items.sort((a, b) => b.uploadedAt.localeCompare(a.uploadedAt))
+10 -3
View File
@@ -16,6 +16,7 @@ import {
isValidFolderName,
normalizeFolderName
} from '../../utils/image-key'
import { checkUploadRateLimit } from '../../utils/rate-limit'
function readFormText(
formData: { name?: string, data?: Buffer | Uint8Array }[],
@@ -29,14 +30,20 @@ function readFormText(
export default defineEventHandler(async (event) => {
await requireUploadAuth(event)
const source = verifyApiUploadToken(event) ? 'api' : 'web'
const uploadUserId = await getUploadUserId(event)
const formData = await readMultipartFormData(event)
if (!formData?.length) {
createApiError(event, 'INVALID_REQUEST', '未收到上传文件', 400)
}
const tokenField = formData.find(part => part.name === 'token')
const formToken = tokenField?.data?.length
? new TextDecoder().decode(tokenField.data).trim()
: ''
checkUploadRateLimit(event, formToken)
const source = verifyApiUploadToken(event) ? 'api' : 'web'
const uploadUserId = await getUploadUserId(event)
const query = getQuery(event)
const folderFromForm = readFormText(formData, 'folder') || readFormText(formData, 'type')
const folderFromQuery = typeof query.folder === 'string'
+43 -4
View File
@@ -1,18 +1,21 @@
import { requireAdminAuth } from '../../utils/access'
import { requireUserAuth } from '../../utils/access'
import { createApiError } from '../../utils/api-error'
import { logException } from '../../utils/logger'
import {
listActivityLogs,
listUserIdUsernameMap,
summarizeActivityLogs,
type LogAction,
type LogSource
} from '../../utils/db'
import { ensureStorageSchema } from '../../utils/storage-backends'
import {
DEFAULT_LIST_LIMIT,
MAX_LIST_LIMIT
} from '../../utils/constants'
export default defineEventHandler(async (event) => {
await requireAdminAuth(event)
const user = await requireUserAuth(event)
const query = getQuery(event)
@@ -35,13 +38,34 @@ export default defineEventHandler(async (event) => {
? query.folder.trim()
: undefined
const searchRaw = typeof query.q === 'string' ? query.q.trim() : ''
const search = searchRaw || undefined
const pageRaw = Number(query.page ?? 1)
const page = Number.isFinite(pageRaw) && pageRaw > 0
? Math.floor(pageRaw)
: 1
let userId: number | undefined
if (user.role === 'admin') {
const userIdRaw = query.userId
if (userIdRaw !== undefined && userIdRaw !== '' && userIdRaw !== 'all') {
const parsed = Number(userIdRaw)
if (!Number.isFinite(parsed) || parsed <= 0) {
createApiError(event, 'INVALID_REQUEST', '无效的用户筛选', 400)
}
userId = Math.floor(parsed)
}
} else {
userId = user.id
}
try {
const result = listActivityLogs({ limit, page, action, source, folder })
ensureStorageSchema()
const result = listActivityLogs({ limit, page, action, source, folder, userId, search })
const summary = summarizeActivityLogs({ source, folder, userId, search })
const userMap = user.role === 'admin' ? listUserIdUsernameMap() : undefined
return {
items: result.items.map(row => ({
id: row.id,
@@ -51,12 +75,27 @@ export default defineEventHandler(async (event) => {
size: row.size,
contentType: row.content_type,
source: row.source,
userId: row.user_id,
username: row.username,
storage: row.backend_id
? {
id: row.backend_id,
name: row.backend_name ?? row.backend_id,
type: (row.backend_type === 'local' ? 'local' : 's3') as 'local' | 's3'
}
: null,
createdAt: row.created_at
})),
total: result.total,
page: result.page,
pageSize: result.pageSize,
totalPages: result.totalPages
totalPages: result.totalPages,
summary,
users: userMap
? Array.from(userMap.entries())
.map(([id, username]) => ({ id, username }))
.sort((a, b) => a.username.localeCompare(b.username))
: undefined
}
} catch (error) {
logException('activity logs list failed', error)
+30
View File
@@ -0,0 +1,30 @@
import { requireUserAuth } from '../utils/access'
import {
ensureStorageSchema,
listStorageBackendNameMap,
LOCAL_BACKEND_ID
} from '../utils/storage-backends'
function sortBackendsForFilter<T extends { id: string, name: string }>(backends: T[]): T[] {
return [...backends].sort((a, b) => {
if (a.id === LOCAL_BACKEND_ID) return -1
if (b.id === LOCAL_BACKEND_ID) return 1
return a.name.localeCompare(b.name, 'zh-CN')
})
}
export default defineEventHandler(async (event) => {
await requireUserAuth(event)
ensureStorageSchema()
const map = listStorageBackendNameMap()
const backends = sortBackendsForFilter(
Array.from(map.entries()).map(([id, backend]) => ({
id,
name: backend.name,
type: backend.type
}))
)
return { backends }
})
+2
View File
@@ -1,5 +1,6 @@
import { readMultipartFormData } from 'h3'
import { verifyUploadTokenValue } from '../../utils/access'
import { checkUploadRateLimit } from '../../utils/rate-limit'
import {
detectMimeFromSignature,
isAllowedMimeType
@@ -34,6 +35,7 @@ export default defineEventHandler(async (event) => {
if (!verifyUploadTokenValue(event, token)) {
return easyImageError(401, 'token 错误或无效')
}
checkUploadRateLimit(event, token)
}
const imagePart = formData.find(
+3 -1
View File
@@ -91,7 +91,9 @@ export async function runAutoDeleteCleanup(): Promise<AutoDeleteResult> {
originalName: image.originalName,
size: image.size,
contentType: image.contentType,
source: 'web'
source: 'web',
userId: image.userId ?? null,
backendId: image.backendId ?? null
})
deleted++
} catch (error) {
+118 -22
View File
@@ -15,6 +15,11 @@ export interface ActivityLogRow {
size: number
content_type: string
source: LogSource
user_id: number | null
username: string | null
backend_id: string | null
backend_name: string | null
backend_type: string | null
created_at: string
}
@@ -25,6 +30,8 @@ export interface ActivityLogInput {
size: number
contentType: string
source: LogSource
userId?: number | null
backendId?: string | null
createdAt?: string
}
@@ -118,6 +125,23 @@ function migrateSchema(database: DatabaseSync): void {
VALUES ('auto_delete_enabled_at', ?, ?)
`).run(now, now)
}
const logColumns = database.prepare('PRAGMA table_info(activity_logs)').all() as Array<{
name: string
}>
if (!logColumns.some(column => column.name === 'user_id')) {
database.exec('ALTER TABLE activity_logs ADD COLUMN user_id INTEGER')
database.exec(`
CREATE INDEX IF NOT EXISTS idx_activity_logs_user_created
ON activity_logs(user_id, created_at DESC)
`)
}
const activityLogColumns = database.prepare('PRAGMA table_info(activity_logs)').all() as Array<{
name: string
}>
if (!activityLogColumns.some(column => column.name === 'backend_id')) {
database.exec('ALTER TABLE activity_logs ADD COLUMN backend_id TEXT')
}
}
const USER_COLUMNS = 'id, username, password_hash, role, created_at, api_token, auto_delete_days, auto_delete_enabled_at'
@@ -393,8 +417,8 @@ export function insertActivityLog(input: ActivityLogInput): void {
const createdAt = input.createdAt ?? new Date().toISOString()
getDb().prepare(`
INSERT INTO activity_logs
(action, key, original_name, size, content_type, source, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
(action, key, original_name, size, content_type, source, user_id, backend_id, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
input.action,
input.key,
@@ -402,6 +426,8 @@ export function insertActivityLog(input: ActivityLogInput): void {
input.size,
input.contentType,
input.source,
input.userId ?? null,
input.backendId ?? null,
createdAt
)
} catch (error) {
@@ -418,6 +444,8 @@ export function listActivityLogs(options: {
action?: LogAction
source?: LogSource
folder?: string
userId?: number
search?: string
}): {
items: ActivityLogRow[]
total: number
@@ -427,26 +455,11 @@ export function listActivityLogs(options: {
} {
const database = getDb()
const pageSize = options.limit
const params: Array<string | number> = []
const where: string[] = []
const { whereSql, params } = buildActivityLogWhere(options)
if (options.action) {
where.push('action = ?')
params.push(options.action)
}
if (options.source) {
where.push('source = ?')
params.push(options.source)
}
if (options.folder) {
where.push('key LIKE ?')
params.push(`${options.folder}/%`)
}
const whereSql = where.length ? `WHERE ${where.join(' AND ')}` : ''
const countRow = database.prepare(`
SELECT COUNT(*) AS count
FROM activity_logs
FROM activity_logs al
${whereSql}
`).get(...params) as { count: number }
@@ -457,10 +470,25 @@ export function listActivityLogs(options: {
const offset = (safePage - 1) * pageSize
const rows = database.prepare(`
SELECT id, action, key, original_name, size, content_type, source, created_at
FROM activity_logs
SELECT
al.id,
al.action,
al.key,
al.original_name,
al.size,
al.content_type,
al.source,
al.user_id,
u.username,
al.backend_id,
sb.name AS backend_name,
sb.type AS backend_type,
al.created_at
FROM activity_logs al
LEFT JOIN users u ON u.id = al.user_id
LEFT JOIN storage_backends sb ON sb.id = al.backend_id
${whereSql}
ORDER BY id DESC
ORDER BY al.id DESC
LIMIT ? OFFSET ?
`).all(...params, pageSize, offset) as unknown as ActivityLogRow[]
@@ -473,6 +501,74 @@ export function listActivityLogs(options: {
}
}
export function summarizeActivityLogs(options: {
source?: LogSource
folder?: string
userId?: number
search?: string
}): {
total: number
upload: number
delete: number
} {
const database = getDb()
const { whereSql, params } = buildActivityLogWhere(options, { includeAction: false })
const row = database.prepare(`
SELECT
COUNT(*) AS total,
COALESCE(SUM(CASE WHEN al.action = 'upload' THEN 1 ELSE 0 END), 0) AS upload_count,
COALESCE(SUM(CASE WHEN al.action = 'delete' THEN 1 ELSE 0 END), 0) AS delete_count
FROM activity_logs al
${whereSql}
`).get(...params) as { total: number, upload_count: number, delete_count: number } | undefined
return {
total: row?.total ?? 0,
upload: row?.upload_count ?? 0,
delete: row?.delete_count ?? 0
}
}
function buildActivityLogWhere(
options: {
action?: LogAction
source?: LogSource
folder?: string
userId?: number
search?: string
},
{ includeAction = true }: { includeAction?: boolean } = {}
): { whereSql: string, params: Array<string | number> } {
const params: Array<string | number> = []
const where: string[] = []
if (includeAction && options.action) {
where.push('al.action = ?')
params.push(options.action)
}
if (options.source) {
where.push('al.source = ?')
params.push(options.source)
}
if (options.folder) {
where.push('al.key LIKE ?')
params.push(`${options.folder}/%`)
}
if (options.userId !== undefined) {
where.push('al.user_id = ?')
params.push(options.userId)
}
if (options.search) {
where.push('al.original_name LIKE ?')
params.push(`%${options.search}%`)
}
return {
whereSql: where.length ? `WHERE ${where.join(' AND ')}` : '',
params
}
}
function countSince(action: LogAction, sinceIso: string): number {
const row = getDb().prepare(`
SELECT COUNT(*) AS total
+19 -4
View File
@@ -222,9 +222,19 @@ export async function listFoldersForUser(
return folders.sort((a, b) => a.localeCompare(b))
}
function buildBackendFilterSql(
backendId: string | undefined,
params: Array<string | number>
): string {
if (!backendId) return ''
params.push(backendId)
return ' AND backend_id = ?'
}
export async function countImages(
folder?: string,
userFilter?: number | 'admin'
userFilter?: number | 'admin',
backendId?: string
): Promise<number> {
ensureStorageSchema()
const params: Array<string | number> = []
@@ -236,8 +246,9 @@ export async function countImages(
}
const userSql = buildUserFilterSql(userFilter, params)
const backendSql = buildBackendFilterSql(backendId, params)
const row = getDb().prepare(`
SELECT COUNT(*) AS count FROM images WHERE ${clauses.join(' AND ')}${userSql}
SELECT COUNT(*) AS count FROM images WHERE ${clauses.join(' AND ')}${userSql}${backendSql}
`).get(...params) as { count: number }
return row.count
@@ -264,6 +275,7 @@ export async function listImages(options: {
page?: number
folder?: string
userFilter?: number | 'admin'
backendId?: string
}): Promise<PaginatedResult<StoredImage>> {
ensureStorageSchema()
const params: Array<string | number> = []
@@ -275,10 +287,11 @@ export async function listImages(options: {
}
const userSql = buildUserFilterSql(options.userFilter, params)
const backendSql = buildBackendFilterSql(options.backendId, 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}
WHERE ${clauses.join(' AND ')}${userSql}${backendSql}
ORDER BY uploaded_at DESC, key DESC
`).all(...params) as unknown as ImageIndexRow[]
@@ -295,6 +308,7 @@ export async function searchImages(options: {
page?: number
folder?: string
userFilter?: number | 'admin'
backendId?: string
}): Promise<PaginatedResult<StoredImage>> {
ensureStorageSchema()
const needle = options.query.trim().toLowerCase()
@@ -307,10 +321,11 @@ export async function searchImages(options: {
}
const userSql = buildUserFilterSql(options.userFilter, params)
const backendSql = buildBackendFilterSql(options.backendId, 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}
WHERE ${clauses.join(' AND ')}${userSql}${backendSql}
ORDER BY uploaded_at DESC, key DESC
`).all(...params) as unknown as ImageIndexRow[]
+9
View File
@@ -0,0 +1,9 @@
import { getStorageBackendRow } from './storage-backends'
export function readBackendIdQuery(
query: Record<string, unknown>
): string | undefined | null {
const raw = typeof query.backendId === 'string' ? query.backendId.trim() : ''
if (!raw || raw === 'all') return undefined
return getStorageBackendRow(raw)?.id ?? null
}
+32 -3
View File
@@ -5,6 +5,7 @@ import type { StoredImage } from './storage'
import { getImageBaseUrl } from './env'
import { getActiveBackendRow } from './storage/resolver'
import { getStorageBackendRow, getStorageBackendFromEnv } from './storage-backends'
import type { StorageBackendType } from './storage/types'
import { buildObjectKey, parseS3Config } from './storage/s3'
import type { StorageBackendRow } from './storage/types'
@@ -24,10 +25,32 @@ export function resolveImageOwner(
}
}
export function resolveImageStorage(
backendId: string | undefined,
backendMap?: Map<string, { name: string, type: StorageBackendType }>
): ImageItem['storage'] | undefined {
if (!backendId) return undefined
const fromMap = backendMap?.get(backendId)
if (fromMap) {
return { id: backendId, name: fromMap.name, type: fromMap.type }
}
const row = getStorageBackendRow(backendId)
if (row) {
return { id: backendId, name: row.name, type: row.type }
}
return { id: backendId, name: backendId, type: 's3' }
}
export function mapStoredImageToItem(
event: H3Event,
stored: StoredImage,
options?: { ownerMap?: Map<number, string> }
options?: {
ownerMap?: Map<number, string>
backendMap?: Map<string, { name: string, type: StorageBackendType }>
}
): ImageItem {
const item = buildImageItem({
key: stored.key,
@@ -40,8 +63,14 @@ export function mapStoredImageToItem(
})
const owner = resolveImageOwner(stored.userId, options?.ownerMap)
if (owner) {
return { ...item, owner }
const storage = resolveImageStorage(stored.backendId, options?.backendMap)
if (owner || storage) {
return {
...item,
...(owner ? { owner } : {}),
...(storage ? { storage } : {})
}
}
return item
+4 -1
View File
@@ -128,8 +128,9 @@ export async function processSingleImageUpload(
input.prefix ?? 'images'
)
let backendId: string
try {
await putImage(key, compressed.bytes, {
backendId = await putImage(key, compressed.bytes, {
originalName,
uploadedAt,
contentType: compressed.mime,
@@ -159,6 +160,8 @@ export async function processSingleImageUpload(
size: compressed.bytes.byteLength,
contentType: compressed.mime,
source: input.source ?? 'web',
userId: input.userId ?? null,
backendId,
createdAt: uploadedAt
})
+43
View File
@@ -0,0 +1,43 @@
import { describe, expect, it, vi } from 'vitest'
vi.mock('./logger', () => ({
clientIp: (event: { __ip?: string }) => event.__ip ?? '127.0.0.1'
}))
import { checkUploadRateLimit } from './rate-limit'
function mockEvent(ip = '127.0.0.1', authToken = '') {
return {
__ip: ip,
node: {
req: {
headers: {
...(authToken ? { 'auth-token': authToken } : {})
}
}
}
} as unknown as Parameters<typeof checkUploadRateLimit>[0]
}
describe('checkUploadRateLimit', () => {
it('allows requests under the IP limit', () => {
const event = mockEvent('10.0.0.1')
expect(() => checkUploadRateLimit(event)).not.toThrow()
})
it('limits repeated uploads by IP', () => {
const event = mockEvent('10.0.0.3')
for (let i = 0; i < 60; i++) {
checkUploadRateLimit(event)
}
expect(() => checkUploadRateLimit(event)).toThrow()
})
it('limits repeated API token uploads', () => {
const token = 'token-a'
for (let i = 0; i < 120; i++) {
checkUploadRateLimit(mockEvent(`10.0.${i % 200}`), token)
}
expect(() => checkUploadRateLimit(mockEvent('10.0.250'), token)).toThrow()
})
})
+67
View File
@@ -0,0 +1,67 @@
import { createHash } from 'node:crypto'
import { createError, getHeader, type H3Event } from 'h3'
import { clientIp } from './logger'
const UPLOAD_IP_MAX = 60
const UPLOAD_TOKEN_MAX = 120
const UPLOAD_WINDOW_MS = 15 * 60 * 1000
const stores = new Map<string, Map<string, { count: number, resetAt: number }>>()
function getStore(name: string): Map<string, { count: number, resetAt: number }> {
let store = stores.get(name)
if (!store) {
store = new Map()
stores.set(name, store)
}
return store
}
function assertRateLimit(
storeName: string,
key: string,
max: number,
windowMs: number,
message: string
): void {
const store = getStore(storeName)
const now = Date.now()
const entry = store.get(key)
if (!entry || now > entry.resetAt) {
store.set(key, { count: 1, resetAt: now + windowMs })
return
}
entry.count += 1
if (entry.count > max) {
throw createError({
statusCode: 429,
statusMessage: message
})
}
}
export function checkUploadRateLimit(event: H3Event, apiToken?: string): void {
const ip = clientIp(event)
assertRateLimit(
'upload-ip',
ip,
UPLOAD_IP_MAX,
UPLOAD_WINDOW_MS,
'上传过于频繁,请稍后再试'
)
const headerToken = getHeader(event, 'auth-token')?.trim() ?? ''
const token = apiToken?.trim() || headerToken
if (!token) return
const hash = createHash('sha256').update(token).digest('hex').slice(0, 32)
assertRateLimit(
'upload-token',
hash,
UPLOAD_TOKEN_MAX,
UPLOAD_WINDOW_MS,
'上传过于频繁,请稍后再试'
)
}
+12
View File
@@ -159,6 +159,17 @@ export function listStorageBackends(maskSecrets = true): Array<StorageBackendInf
return listStorageBackendRows().map(row => rowToInfo(row, maskSecrets))
}
export function listStorageBackendNameMap(): Map<string, {
name: string
type: StorageBackendType
}> {
ensureStorageSchema()
const rows = getDb().prepare(`
SELECT id, name, type FROM storage_backends
`).all() as Array<{ id: string, name: string, type: StorageBackendType }>
return new Map(rows.map(row => [row.id, { name: row.name, type: row.type }]))
}
export function getStorageBackendRow(id: string): StorageBackendRow | null {
ensureDefaultBackends()
const row = getDb().prepare(`
@@ -191,6 +202,7 @@ export function getBackendIdForKey(key: string): string | null {
}
export function setDefaultBackend(id: string): void {
ensureDefaultBackends()
const db = getDb()
db.exec('UPDATE storage_backends SET is_default = 0')
db.prepare(`
+4 -1
View File
@@ -21,12 +21,15 @@ describe('buildBackendCapacity', () => {
it('reads disk stats for local backend when available', async () => {
const capacity = await buildBackendCapacity('local', 'local', 1024, null)
if (capacity.source === 'disk') {
expect(capacity.source).toBe('disk')
if (capacity.totalBytes != null) {
expect(capacity.totalBytes).toBeGreaterThan(0)
expect(capacity.freeBytes).not.toBeNull()
expect(capacity.percent).not.toBeNull()
} else {
expect(capacity.usedBytes).toBe(1024)
expect(capacity.freeBytes).toBeNull()
expect(capacity.percent).toBeNull()
}
})
})
+7 -3
View File
@@ -37,7 +37,7 @@ export async function putImage(
key: string,
bytes: Uint8Array,
meta: StoredImageMeta
): Promise<void> {
): Promise<string> {
const backend = await getActiveBackend()
await backend.put(key, bytes, meta)
insertImageIndex({
@@ -49,6 +49,7 @@ export async function putImage(
size: meta.size,
uploadedAt: meta.uploadedAt
})
return backend.id
}
export async function headImage(key: string): Promise<StoredImage | null> {
@@ -97,9 +98,10 @@ export async function listImageKeys(): Promise<string[]> {
export async function countImages(
folder?: string,
userFilter?: number | 'admin'
userFilter?: number | 'admin',
backendId?: string
): Promise<number> {
return countImagesFromIndex(folder, userFilter)
return countImagesFromIndex(folder, userFilter, backendId)
}
export async function getFolderStorageStats(
@@ -129,6 +131,7 @@ export async function listImages(options: {
page?: number
folder?: string
userFilter?: number | 'admin'
backendId?: string
}): Promise<PaginatedResult<StoredImage>> {
return listImagesFromIndex(options)
}
@@ -139,6 +142,7 @@ export async function searchImages(options: {
page?: number
folder?: string
userFilter?: number | 'admin'
backendId?: string
}): Promise<PaginatedResult<StoredImage>> {
return searchImagesFromIndex(options)
}