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 }
})