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:
1 parent
901fefb3f5
commit
6fc054328d
34 files changed
+1357
-72
No files matched your search
@@ -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
@@ -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
|
||||
|
||||
@@ -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[]
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
})
|
||||
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
'上传过于频繁,请稍后再试'
|
||||
)
|
||||
}
|
||||
@@ -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(`
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
Reference in new issue
Block a user