From 6fc054328dba812cc6fb9bc80b7a77851dbc117a Mon Sep 17 00:00:00 2001 From: O96u Date: Wed, 26 Aug 2026 17:08:01 +0800 Subject: [PATCH] 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. --- app/components/AppShell.vue | 5 + app/components/ImageCard.vue | 24 +- app/components/ImageGrid.vue | 3 + app/components/MobileUserMenu.vue | 18 + app/components/storage/StorageBackendForm.vue | 21 +- app/composables/useImageList.ts | 92 ++- app/pages/logs.vue | 587 ++++++++++++++++++ app/pages/settings/index.vue | 15 +- app/pages/stats.vue | 42 +- app/pages/storage.vue | 9 +- app/types/image.ts | 6 + app/types/logs.ts | 43 ++ i18n/locales/en.json | 40 +- i18n/locales/zh-CN.json | 40 +- server/api/images/batch-delete.post.ts | 4 +- server/api/images/count.get.ts | 8 +- server/api/images/index.delete.ts | 4 +- server/api/images/index.get.ts | 12 +- server/api/images/search.get.ts | 11 +- server/api/images/upload.post.ts | 13 +- server/api/logs/index.get.ts | 47 +- server/api/storage-backends.get.ts | 30 + server/routes/api/index.php.post.ts | 2 + server/utils/auto-delete.ts | 4 +- server/utils/db.ts | 140 ++++- server/utils/image-index.ts | 23 +- server/utils/image-query.ts | 9 + server/utils/image-response.ts | 35 +- server/utils/process-image-upload.ts | 5 +- server/utils/rate-limit.test.ts | 43 ++ server/utils/rate-limit.ts | 67 ++ server/utils/storage-backends.ts | 12 + server/utils/storage-capacity.test.ts | 5 +- server/utils/storage.ts | 10 +- 34 files changed, 1357 insertions(+), 72 deletions(-) create mode 100644 app/pages/logs.vue create mode 100644 app/types/logs.ts create mode 100644 server/api/storage-backends.get.ts create mode 100644 server/utils/image-query.ts create mode 100644 server/utils/rate-limit.test.ts create mode 100644 server/utils/rate-limit.ts diff --git a/app/components/AppShell.vue b/app/components/AppShell.vue index 53bc43d..d06ad8e 100644 --- a/app/components/AppShell.vue +++ b/app/components/AppShell.vue @@ -23,6 +23,11 @@ const navItems = computed(() => { }) const accountMenuItems = computed(() => [ + { + label: t('nav.logs'), + icon: 'i-lucide-scroll-text', + to: '/logs' + }, { label: t('nav.changePassword'), icon: 'i-lucide-key-round', diff --git a/app/components/ImageCard.vue b/app/components/ImageCard.vue index 56d2d6f..2d61ef3 100644 --- a/app/components/ImageCard.vue +++ b/app/components/ImageCard.vue @@ -9,10 +9,12 @@ const props = withDefaults(defineProps<{ deleting?: boolean selectable?: boolean showKey?: boolean + showStorage?: boolean compact?: boolean }>(), { selectable: true, showKey: true, + showStorage: false, compact: false }) @@ -62,6 +64,10 @@ const copySuccessTitle = computed(() => { } }) +const storageIcon = computed(() => + props.image.storage?.type === 'local' ? 'i-lucide-hard-drive' : 'i-lucide-cloud' +) + function toggleSelected(value: boolean | 'indeterminate') { emit('update:selected', value === true) } @@ -106,7 +112,7 @@ function toggleSelected(value: boolean | 'indeterminate') {
{{ image.owner.username }}
+
+ + + {{ image.storage.name }} + +
selectable?: boolean showKey?: boolean + showStorage?: boolean emptyText?: string dense?: boolean compact?: boolean }>(), { selectable: true, showKey: true, + showStorage: false, dense: false, compact: false }) @@ -48,6 +50,7 @@ function updateSelection(key: string, selected: boolean) { :deleting="deletingKeys.has(image.key)" :selectable="selectable" :show-key="showKey" + :show-storage="showStorage" :compact="compact" @update:selected="updateSelection(image.key, $event)" @delete="emit('delete', image.key)" diff --git a/app/components/MobileUserMenu.vue b/app/components/MobileUserMenu.vue index e450fea..c4af159 100644 --- a/app/components/MobileUserMenu.vue +++ b/app/components/MobileUserMenu.vue @@ -136,6 +136,24 @@ function toggleOpen() { {{ t('nav.account') }}
+ + + + {{ t('nav.logs') }} + + + +
- +
- +
- + >([]) function folderQueryParams(): Record { const folder = activeFolder.value.trim() @@ -24,18 +26,53 @@ export function useImageList() { return { folder } } + function storageQueryParams(): Record { + const backendId = activeStorageBackend.value.trim() + if (!backendId || backendId === 'all') return {} + return { backendId } + } + + function listQueryParams(): Record { + return { + ...folderQueryParams(), + ...storageQueryParams() + } + } + function matchesActiveFolder(key: string) { const folder = activeFolder.value.trim() if (!folder || folder === 'all') return true return key.startsWith(`${folder}/`) } + function matchesActiveStorage(item: ImageItem) { + const backendId = activeStorageBackend.value.trim() + if (!backendId || backendId === 'all') return true + return item.storage?.id === backendId + } + + async function loadStorageBackendOptions() { + try { + const data = await $fetch<{ + backends: Array<{ id: string, name: string, type: string }> + }>('/api/storage-backends', { + credentials: 'include' + }) + storageBackendOptions.value = data.backends.map(backend => ({ + id: backend.id, + name: backend.name + })) + } catch { + storageBackendOptions.value = [] + } + } + async function fetchTotal() { loadingTotal.value = true try { const data = await $fetch<{ total: number }>('/api/images/count', { credentials: 'include', - query: folderQueryParams() + query: listQueryParams() }) folderTotal.value = data.total } catch { @@ -53,7 +90,7 @@ export function useImageList() { query: { limit: PAGE_SIZE, page: targetPage, - ...folderQueryParams() + ...listQueryParams() } }) @@ -81,7 +118,7 @@ export function useImageList() { q, limit: PAGE_SIZE, page: targetPage, - ...folderQueryParams() + ...listQueryParams() } }) @@ -107,7 +144,7 @@ export function useImageList() { page.value = 1 activeSearch.value = '' searchQuery.value = '' - await Promise.all([fetchList(1), fetchTotal()]) + await Promise.all([fetchList(1), fetchTotal(), loadStorageBackendOptions()]) } async function setActiveFolder(folder: string) { @@ -122,6 +159,18 @@ export function useImageList() { } } + async function setActiveStorageBackend(backendId: string) { + const next = backendId.trim() || 'all' + if (next === activeStorageBackend.value) return + activeStorageBackend.value = next + page.value = 1 + if (activeSearch.value) { + await Promise.all([fetchSearch(1), fetchTotal()]) + } else { + await Promise.all([fetchList(1), fetchTotal()]) + } + } + async function submitSearch() { const q = searchQuery.value.trim() activeSearch.value = q @@ -143,7 +192,9 @@ export function useImageList() { } function prependItems(newItems: ImageItem[]) { - const matched = newItems.filter(item => matchesActiveFolder(item.key)) + const matched = newItems.filter(item => + matchesActiveFolder(item.key) && matchesActiveStorage(item) + ) if (!matched.length || page.value !== 1) return items.value = [...matched, ...items.value] total.value += matched.length @@ -164,16 +215,41 @@ export function useImageList() { } } + const activeStorageLabel = computed(() => { + if (activeStorageBackend.value === 'all') return '' + return storageBackendOptions.value.find( + backend => backend.id === activeStorageBackend.value + )?.name ?? activeStorageBackend.value + }) + const listSummary = computed(() => { const folder = activeFolder.value.trim() || 'all' const folderLabel = folder === 'all' ? t('stats.allFolders') : folder + const storageLabel = activeStorageLabel.value if (loadingTotal.value && folderTotal.value === null) { - return `${folderLabel} · ${t('stats.counting')}` + return storageLabel + ? `${folderLabel} · ${storageLabel} · ${t('stats.counting')}` + : `${folderLabel} · ${t('stats.counting')}` } if (activeSearch.value) { + if (storageLabel) { + return t('stats.summarySearchStorage', { + folder: folderLabel, + storage: storageLabel, + q: activeSearch.value, + total: total.value + }) + } return t('stats.summarySearch', { folder: folderLabel, q: activeSearch.value, total: total.value }) } const count = folderTotal.value === null ? total.value : folderTotal.value + if (storageLabel) { + return t('stats.summaryTotalStorage', { + folder: folderLabel, + storage: storageLabel, + total: count + }) + } return t('stats.summaryTotal', { folder: folderLabel, total: count }) }) @@ -184,6 +260,8 @@ export function useImageList() { total, loading, activeFolder, + activeStorageBackend, + storageBackendOptions, searchQuery, activeSearch, listSummary, @@ -193,6 +271,8 @@ export function useImageList() { refreshList, initializeList, setActiveFolder, + setActiveStorageBackend, + loadStorageBackendOptions, submitSearch, goToPage, prependItems, diff --git a/app/pages/logs.vue b/app/pages/logs.vue new file mode 100644 index 0000000..f3c7899 --- /dev/null +++ b/app/pages/logs.vue @@ -0,0 +1,587 @@ + + + diff --git a/app/pages/settings/index.vue b/app/pages/settings/index.vue index ef8e06b..b355a2b 100644 --- a/app/pages/settings/index.vue +++ b/app/pages/settings/index.vue @@ -333,7 +333,20 @@ watch(isAuthenticated, async (authed, prev) => { class="size-3.5 shrink-0" /> - {{ t('settings.apiTokenLine', { source: tokenSourceText(settings.tokenSource), version: settings.appVersion }) }} + {{ t('settings.apiTokenLine', { source: tokenSourceText(settings.tokenSource) }) }} + + v{{ settings.appVersion }} + + diff --git a/app/pages/stats.vue b/app/pages/stats.vue index c0c41ff..952cb21 100644 --- a/app/pages/stats.vue +++ b/app/pages/stats.vue @@ -41,7 +41,11 @@ const { setActiveFolder, submitSearch, goToPage, - removeItems + removeItems, + activeStorageBackend, + storageBackendOptions, + setActiveStorageBackend, + loadStorageBackendOptions } = useImageList() const { formatFileSize } = useFileSize() @@ -60,6 +64,7 @@ const deleteTargetKeys = ref([]) const batchDeleting = ref(false) const showScrollTop = ref(false) const currentFolder = ref('all') +const currentStorage = ref('all') const folderOptions = ref(['images']) const folderSelectItems = computed(() => [ @@ -67,6 +72,14 @@ const folderSelectItems = computed(() => [ ...folderOptions.value.map(folder => ({ label: folder, value: folder })) ]) +const storageSelectItems = computed(() => [ + { label: t('stats.filterStorageAll'), value: 'all' }, + ...storageBackendOptions.value.map(backend => ({ + label: backend.name, + value: backend.id + })) +]) + const selectedCount = computed(() => selectedKeys.value.size) const FOLDER_CHART_COLORS = [ @@ -165,7 +178,8 @@ async function refreshGallery() { if (!isAuthenticated.value) return selectedKeys.value = new Set() try { - await Promise.all([refreshList(), loadFolders()]) + await Promise.all([refreshList(), loadFolders(), loadStorageBackendOptions()]) + currentStorage.value = activeStorageBackend.value } catch (error: unknown) { handleAuthError(error) } @@ -188,8 +202,10 @@ async function refreshAll() { await Promise.all([ fetchStats(), reloadGallery(), - loadFolders() + loadFolders(), + loadStorageBackendOptions() ]) + currentStorage.value = activeStorageBackend.value } catch (error: unknown) { handleAuthError(error) if (isAuthenticated.value) { @@ -211,6 +227,17 @@ async function handleFolderChange(folder: string) { } } +async function handleStorageChange(backendId: string) { + if (!isAuthenticated.value) return + currentStorage.value = backendId + selectedKeys.value = new Set() + try { + await setActiveStorageBackend(backendId) + } catch (error: unknown) { + handleAuthError(error) + } +} + async function handleSearch() { if (!isAuthenticated.value) return selectedKeys.value = new Set() @@ -481,6 +508,14 @@ watch(isAuthenticated, async (authed, prev) => { :aria-label="t('stats.folder')" @update:model-value="handleFolderChange" /> + { (null) +const deleteConfirmName = ref('') const deleting = ref(false) async function loadStorage() { @@ -165,14 +166,16 @@ function confirmDelete(backend: StorageBackendItem) { return } deleteTarget.value = backend + deleteConfirmName.value = backend.name deleteOpen.value = true } async function executeDelete() { - if (!deleteTarget.value) return + const target = deleteTarget.value + if (!target) return deleting.value = true try { - await $fetch(`/api/admin/storage/${deleteTarget.value.id}`, { + await $fetch(`/api/admin/storage/${target.id}`, { method: 'DELETE', credentials: 'include' }) @@ -326,7 +329,7 @@ watch(isAuthenticated, async (authed) => { {{ t('storage.deleteConfirmTitle') }}

- {{ t('storage.deleteConfirmBody', { name: deleteTarget?.name ?? '' }) }} + {{ t('storage.deleteConfirmBody', { name: deleteConfirmName }) }}

{ 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 }) diff --git a/server/api/images/count.get.ts b/server/api/images/count.get.ts index beba154..822f1da 100644 --- a/server/api/images/count.get.ts +++ b/server/api/images/count.get.ts @@ -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 } }) diff --git a/server/api/images/index.delete.ts b/server/api/images/index.delete.ts index 9c56aea..2458f11 100644 --- a/server/api/images/index.delete.ts +++ b/server/api/images/index.delete.ts @@ -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 }) diff --git a/server/api/images/index.get.ts b/server/api/images/index.get.ts index 005eda9..292beb8 100644 --- a/server/api/images/index.get.ts +++ b/server/api/images/index.get.ts @@ -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 | 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)) diff --git a/server/api/images/search.get.ts b/server/api/images/search.get.ts index 5772105..3255c0f 100644 --- a/server/api/images/search.get.ts +++ b/server/api/images/search.get.ts @@ -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 | 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)) diff --git a/server/api/images/upload.post.ts b/server/api/images/upload.post.ts index 5be2e59..d2919ad 100644 --- a/server/api/images/upload.post.ts +++ b/server/api/images/upload.post.ts @@ -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' diff --git a/server/api/logs/index.get.ts b/server/api/logs/index.get.ts index 35a8cd5..b6b18bc 100644 --- a/server/api/logs/index.get.ts +++ b/server/api/logs/index.get.ts @@ -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) diff --git a/server/api/storage-backends.get.ts b/server/api/storage-backends.get.ts new file mode 100644 index 0000000..cef91f2 --- /dev/null +++ b/server/api/storage-backends.get.ts @@ -0,0 +1,30 @@ +import { requireUserAuth } from '../utils/access' +import { + ensureStorageSchema, + listStorageBackendNameMap, + LOCAL_BACKEND_ID +} from '../utils/storage-backends' + +function sortBackendsForFilter(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 } +}) diff --git a/server/routes/api/index.php.post.ts b/server/routes/api/index.php.post.ts index 9226ac5..16e302f 100644 --- a/server/routes/api/index.php.post.ts +++ b/server/routes/api/index.php.post.ts @@ -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( diff --git a/server/utils/auto-delete.ts b/server/utils/auto-delete.ts index 75e275a..7b1434b 100644 --- a/server/utils/auto-delete.ts +++ b/server/utils/auto-delete.ts @@ -91,7 +91,9 @@ export async function runAutoDeleteCleanup(): Promise { originalName: image.originalName, size: image.size, contentType: image.contentType, - source: 'web' + source: 'web', + userId: image.userId ?? null, + backendId: image.backendId ?? null }) deleted++ } catch (error) { diff --git a/server/utils/db.ts b/server/utils/db.ts index f1b03c0..c5f887b 100644 --- a/server/utils/db.ts +++ b/server/utils/db.ts @@ -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 = [] - 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 } { + const params: Array = [] + 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 diff --git a/server/utils/image-index.ts b/server/utils/image-index.ts index de32a0a..123f738 100644 --- a/server/utils/image-index.ts +++ b/server/utils/image-index.ts @@ -222,9 +222,19 @@ export async function listFoldersForUser( return folders.sort((a, b) => a.localeCompare(b)) } +function buildBackendFilterSql( + backendId: string | undefined, + params: Array +): 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 { ensureStorageSchema() const params: Array = [] @@ -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> { ensureStorageSchema() const params: Array = [] @@ -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> { 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[] diff --git a/server/utils/image-query.ts b/server/utils/image-query.ts new file mode 100644 index 0000000..aa5de18 --- /dev/null +++ b/server/utils/image-query.ts @@ -0,0 +1,9 @@ +import { getStorageBackendRow } from './storage-backends' + +export function readBackendIdQuery( + query: Record +): string | undefined | null { + const raw = typeof query.backendId === 'string' ? query.backendId.trim() : '' + if (!raw || raw === 'all') return undefined + return getStorageBackendRow(raw)?.id ?? null +} diff --git a/server/utils/image-response.ts b/server/utils/image-response.ts index 3197d17..810918d 100644 --- a/server/utils/image-response.ts +++ b/server/utils/image-response.ts @@ -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 +): 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 } + options?: { + ownerMap?: Map + backendMap?: Map + } ): 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 diff --git a/server/utils/process-image-upload.ts b/server/utils/process-image-upload.ts index c5265c2..1e3e766 100644 --- a/server/utils/process-image-upload.ts +++ b/server/utils/process-image-upload.ts @@ -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 }) diff --git a/server/utils/rate-limit.test.ts b/server/utils/rate-limit.test.ts new file mode 100644 index 0000000..4b06dea --- /dev/null +++ b/server/utils/rate-limit.test.ts @@ -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[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() + }) +}) diff --git a/server/utils/rate-limit.ts b/server/utils/rate-limit.ts new file mode 100644 index 0000000..db14e66 --- /dev/null +++ b/server/utils/rate-limit.ts @@ -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>() + +function getStore(name: string): Map { + 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, + '上传过于频繁,请稍后再试' + ) +} diff --git a/server/utils/storage-backends.ts b/server/utils/storage-backends.ts index 0214d28..ace3017 100644 --- a/server/utils/storage-backends.ts +++ b/server/utils/storage-backends.ts @@ -159,6 +159,17 @@ export function listStorageBackends(maskSecrets = true): Array rowToInfo(row, maskSecrets)) } +export function listStorageBackendNameMap(): Map { + 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(` diff --git a/server/utils/storage-capacity.test.ts b/server/utils/storage-capacity.test.ts index f9a3838..5b50342 100644 --- a/server/utils/storage-capacity.test.ts +++ b/server/utils/storage-capacity.test.ts @@ -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() } }) }) diff --git a/server/utils/storage.ts b/server/utils/storage.ts index de77434..addf6a3 100644 --- a/server/utils/storage.ts +++ b/server/utils/storage.ts @@ -37,7 +37,7 @@ export async function putImage( key: string, bytes: Uint8Array, meta: StoredImageMeta -): Promise { +): Promise { 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 { @@ -97,9 +98,10 @@ export async function listImageKeys(): Promise { export async function countImages( folder?: string, - userFilter?: number | 'admin' + userFilter?: number | 'admin', + backendId?: string ): Promise { - 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> { return listImagesFromIndex(options) } @@ -139,6 +142,7 @@ export async function searchImages(options: { page?: number folder?: string userFilter?: number | 'admin' + backendId?: string }): Promise> { return searchImagesFromIndex(options) }