release: v1.4.0 — 标签系统、操作日志扩展与文档截图更新
ci / ci (22, ubuntu-latest) (push) Canceled after 0s
GitHub Release / release (push) Canceled after 0s
Publish Docker image / build (linux/amd64, ubuntu-latest, amd64) (push) Canceled after 0s
Publish Docker image / build (linux/arm64, ubuntu-24.04-arm, arm64) (push) Canceled after 0s
Publish Docker image / merge (push) Canceled after 0s
ci / ci (22, ubuntu-latest) (push) Canceled after 0s
GitHub Release / release (push) Canceled after 0s
Publish Docker image / build (linux/amd64, ubuntu-latest, amd64) (push) Canceled after 0s
Publish Docker image / build (linux/arm64, ubuntu-24.04-arm, arm64) (push) Canceled after 0s
Publish Docker image / merge (push) Canceled after 0s
- 每用户标签与彩色打标,图库 OR/AND 筛选与批量打标 - 操作日志记录登录、删除、设置与标签操作 - API 标签/打标接口;README 与文档站截图同步
This commit is contained in:
1 parent
f17d79b236
commit
737235acff
110 files changed
+5881
-778
No files matched your search
@@ -12,6 +12,7 @@ import {
|
||||
import { findUserByUsername } from '../../utils/db'
|
||||
import { getAdminSecret } from '../../utils/env'
|
||||
import { verifyLoginVerification, type VerificationBody } from '../../utils/login-verification'
|
||||
import { logActivity } from '../../utils/activity-log'
|
||||
import { clientIp, logInfo, logWarn } from '../../utils/logger'
|
||||
|
||||
interface LoginBody extends VerificationBody {
|
||||
@@ -81,6 +82,13 @@ export default defineEventHandler(async (event) => {
|
||||
|
||||
await createSession(event, user.id)
|
||||
logInfo('login success', { ip, username: user.username })
|
||||
logActivity(event, {
|
||||
action: 'login',
|
||||
key: 'auth/login',
|
||||
originalName: user.username,
|
||||
userId: user.id,
|
||||
source: 'web'
|
||||
})
|
||||
|
||||
return {
|
||||
success: true,
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import type { BatchDeleteResponse } from '~/types/image'
|
||||
import { requireApiOrAdminAuth, assertImageOwnership, resolveActivitySource } from '../../utils/access'
|
||||
import { requireApiOrAdminAuth, assertImageOwnership } from '../../utils/access'
|
||||
import { createApiError } from '../../utils/api-error'
|
||||
import { MAX_DELETE_BATCH } from '../../utils/constants'
|
||||
import { insertActivityLog } from '../../utils/db'
|
||||
import { logActivity } from '../../utils/activity-log'
|
||||
import { getImageIndexRow, deleteImageIndex } from '../../utils/image-index'
|
||||
import { toCanonicalImageKey, validateImageKey } from '../../utils/image-key'
|
||||
import { deleteImage, headImage, resolveStorageImageKey } from '../../utils/storage'
|
||||
@@ -67,13 +67,12 @@ export default defineEventHandler(async (event) => {
|
||||
if (storageKey !== key) deleteImageIndex(storageKey)
|
||||
|
||||
deleted.push(key)
|
||||
insertActivityLog({
|
||||
logActivity(event, {
|
||||
action: 'delete',
|
||||
key,
|
||||
originalName: indexed.original_name,
|
||||
size: indexed.size,
|
||||
contentType: indexed.content_type,
|
||||
source: resolveActivitySource(event),
|
||||
userId: indexed.user_id ?? null,
|
||||
backendId: indexed.backend_id ?? null
|
||||
})
|
||||
@@ -102,13 +101,12 @@ export default defineEventHandler(async (event) => {
|
||||
await deleteImage(storageKey, key)
|
||||
deleted.push(key)
|
||||
|
||||
insertActivityLog({
|
||||
logActivity(event, {
|
||||
action: 'delete',
|
||||
key,
|
||||
originalName: existing.originalName,
|
||||
size: existing.size,
|
||||
contentType: existing.contentType,
|
||||
source: resolveActivitySource(event),
|
||||
userId: existing.userId ?? null,
|
||||
backendId: existing.backendId ?? null
|
||||
})
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import { requireApiOrAdminAuth } from '../../utils/access'
|
||||
import { logActivity } from '../../utils/activity-log'
|
||||
import { getCurrentUser } from '../../utils/auth'
|
||||
import { createApiError } from '../../utils/api-error'
|
||||
import { MAX_DELETE_BATCH } from '../../utils/constants'
|
||||
import { getImageIndexRow } from '../../utils/image-index'
|
||||
import { toCanonicalImageKey, validateImageKey } from '../../utils/image-key'
|
||||
import {
|
||||
addTagsToImage,
|
||||
removeTagFromImage
|
||||
} from '../../utils/tags'
|
||||
import { handleTagApiError } from '../../utils/tag-api'
|
||||
|
||||
interface BatchTagsBody {
|
||||
keys?: string[]
|
||||
tagIds?: number[]
|
||||
action?: 'add' | 'remove'
|
||||
}
|
||||
|
||||
function validateKeys(keys: string[]): void {
|
||||
for (const key of keys) {
|
||||
if (
|
||||
!validateImageKey(key)
|
||||
&& !toCanonicalImageKey(key)
|
||||
&& !getImageIndexRow(key)
|
||||
) {
|
||||
throw new Error(`INVALID_KEY:${key}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
await requireApiOrAdminAuth(event)
|
||||
const user = await getCurrentUser(event)
|
||||
const body = await readBody<BatchTagsBody>(event)
|
||||
|
||||
const keys = Array.isArray(body?.keys)
|
||||
? body.keys.filter(key => typeof key === 'string' && key.trim())
|
||||
: []
|
||||
const tagIds = Array.isArray(body?.tagIds)
|
||||
? body.tagIds.filter(id => Number.isInteger(id) && id > 0)
|
||||
: []
|
||||
const action = body?.action === 'remove' ? 'remove' : 'add'
|
||||
|
||||
if (!keys.length) {
|
||||
createApiError(event, 'INVALID_REQUEST', '请提供图片列表', 400)
|
||||
}
|
||||
if (!tagIds.length) {
|
||||
createApiError(event, 'INVALID_REQUEST', '请提供标签列表', 400)
|
||||
}
|
||||
if (keys.length > MAX_DELETE_BATCH) {
|
||||
createApiError(
|
||||
event,
|
||||
'INVALID_REQUEST',
|
||||
`每次最多处理 ${MAX_DELETE_BATCH} 张图片`,
|
||||
400
|
||||
)
|
||||
}
|
||||
|
||||
try {
|
||||
validateKeys(keys)
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message.startsWith('INVALID_KEY:')) {
|
||||
createApiError(event, 'INVALID_IMAGE_KEY', `无效的图片路径: ${error.message.slice(12)}`, 400)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
|
||||
let updated = 0
|
||||
try {
|
||||
for (const key of keys) {
|
||||
if (action === 'add') {
|
||||
addTagsToImage(key, tagIds, user!.id, user!.role === 'admin')
|
||||
} else {
|
||||
for (const tagId of tagIds) {
|
||||
removeTagFromImage(key, tagId, user!.id, user!.role === 'admin')
|
||||
}
|
||||
}
|
||||
updated++
|
||||
}
|
||||
} catch (error) {
|
||||
handleTagApiError(event, error)
|
||||
}
|
||||
|
||||
logActivity(event, {
|
||||
action: 'tags',
|
||||
key: 'images/batch-tags',
|
||||
originalName: action === 'add'
|
||||
? `批量打标(${keys.length} 张)`
|
||||
: `批量移除标签(${keys.length} 张)`,
|
||||
userId: user!.id
|
||||
})
|
||||
|
||||
return { success: true, updated }
|
||||
})
|
||||
@@ -1,5 +1,5 @@
|
||||
import { getImageUserFilter, requireApiOrAdminAuth } from '../../utils/access'
|
||||
import { readBackendIdQuery } from '../../utils/image-query'
|
||||
import { readBackendIdQuery, readTagFilterQuery } from '../../utils/image-query'
|
||||
import { createApiError } from '../../utils/api-error'
|
||||
import { countImages } from '../../utils/storage'
|
||||
|
||||
@@ -12,8 +12,12 @@ export default defineEventHandler(async (event) => {
|
||||
if (backendId === null) {
|
||||
createApiError(event, 'INVALID_REQUEST', '无效的存储后端', 400)
|
||||
}
|
||||
const tagFilter = readTagFilterQuery(query)
|
||||
if (tagFilter === null) {
|
||||
createApiError(event, 'INVALID_REQUEST', '无效的标签筛选参数', 400)
|
||||
}
|
||||
|
||||
const total = await countImages(userFilter, backendId)
|
||||
const total = await countImages(userFilter, backendId, tagFilter)
|
||||
|
||||
return { total }
|
||||
})
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { DeleteResponse } from '~/types/image'
|
||||
import { requireApiOrAdminAuth, assertImageOwnership, resolveActivitySource } from '../../utils/access'
|
||||
import { requireApiOrAdminAuth, assertImageOwnership } from '../../utils/access'
|
||||
import { createApiError } from '../../utils/api-error'
|
||||
import { insertActivityLog } from '../../utils/db'
|
||||
import { logActivity } from '../../utils/activity-log'
|
||||
import { getImageIndexRow, deleteImageIndex } from '../../utils/image-index'
|
||||
import { toCanonicalImageKey, validateImageKey } from '../../utils/image-key'
|
||||
import { deleteImage, headImage, resolveStorageImageKey } from '../../utils/storage'
|
||||
@@ -40,13 +40,12 @@ export default defineEventHandler(async (event) => {
|
||||
deleteImageIndex(key)
|
||||
if (storageKey !== key) deleteImageIndex(storageKey)
|
||||
|
||||
insertActivityLog({
|
||||
logActivity(event, {
|
||||
action: 'delete',
|
||||
key,
|
||||
originalName: indexed.original_name,
|
||||
size: indexed.size,
|
||||
contentType: indexed.content_type,
|
||||
source: resolveActivitySource(event),
|
||||
userId: indexed.user_id ?? null,
|
||||
backendId: indexed.backend_id ?? null
|
||||
})
|
||||
@@ -69,13 +68,12 @@ export default defineEventHandler(async (event) => {
|
||||
createApiError(event, 'DELETE_FAILED', '删除失败', 500)
|
||||
}
|
||||
|
||||
insertActivityLog({
|
||||
logActivity(event, {
|
||||
action: 'delete',
|
||||
key,
|
||||
originalName: existing.originalName,
|
||||
size: existing.size,
|
||||
contentType: existing.contentType,
|
||||
source: resolveActivitySource(event),
|
||||
userId: existing.userId ?? null,
|
||||
backendId: existing.backendId ?? null
|
||||
})
|
||||
|
||||
@@ -7,9 +7,9 @@ import {
|
||||
} from '../../utils/constants'
|
||||
import { createApiError } from '../../utils/api-error'
|
||||
import { getUploadSourcesForKeys, listUserIdUsernameMap } from '../../utils/db'
|
||||
import { mapStoredImageToItem } from '../../utils/image-response'
|
||||
import { mapStoredImageToItem, attachTagsToImageItems } from '../../utils/image-response'
|
||||
import { listStorageBackendNameMap } from '../../utils/storage-backends'
|
||||
import { readBackendIdQuery, readContentTypeQuery, readUploadSourceQuery } from '../../utils/image-query'
|
||||
import { readBackendIdQuery, readContentTypeQuery, readTagFilterQuery, readUploadSourceQuery } from '../../utils/image-query'
|
||||
import { listImages } from '../../utils/storage'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
@@ -39,6 +39,10 @@ export default defineEventHandler(async (event) => {
|
||||
if (uploadSource === null) {
|
||||
createApiError(event, 'INVALID_REQUEST', '无效的上传来源', 400)
|
||||
}
|
||||
const tagFilter = readTagFilterQuery(query)
|
||||
if (tagFilter === null) {
|
||||
createApiError(event, 'INVALID_REQUEST', '无效的标签筛选参数', 400)
|
||||
}
|
||||
const userFilter = await getImageUserFilter(event)
|
||||
|
||||
const listing = await listImages({
|
||||
@@ -47,15 +51,18 @@ export default defineEventHandler(async (event) => {
|
||||
userFilter,
|
||||
backendId,
|
||||
contentType,
|
||||
uploadSource
|
||||
uploadSource,
|
||||
tagFilter
|
||||
})
|
||||
|
||||
const user = await getCurrentUser(event)
|
||||
const ownerMap = user?.role === 'admin' ? listUserIdUsernameMap() : undefined
|
||||
const backendMap = listStorageBackendNameMap()
|
||||
const sourceMap = getUploadSourcesForKeys(listing.items.map(item => item.key))
|
||||
const items = listing.items.map(stored =>
|
||||
mapStoredImageToItem(event, stored, { ownerMap, backendMap, sourceMap })
|
||||
const items = attachTagsToImageItems(
|
||||
listing.items.map(stored =>
|
||||
mapStoredImageToItem(event, stored, { ownerMap, backendMap, sourceMap })
|
||||
)
|
||||
)
|
||||
|
||||
items.sort((a, b) => b.uploadedAt.localeCompare(a.uploadedAt))
|
||||
|
||||
@@ -7,9 +7,9 @@ import {
|
||||
} from '../../utils/constants'
|
||||
import { createApiError } from '../../utils/api-error'
|
||||
import { getUploadSourcesForKeys, listUserIdUsernameMap } from '../../utils/db'
|
||||
import { mapStoredImageToItem } from '../../utils/image-response'
|
||||
import { mapStoredImageToItem, attachTagsToImageItems } from '../../utils/image-response'
|
||||
import { listStorageBackendNameMap } from '../../utils/storage-backends'
|
||||
import { readBackendIdQuery, readContentTypeQuery, readUploadSourceQuery } from '../../utils/image-query'
|
||||
import { readBackendIdQuery, readContentTypeQuery, readTagFilterQuery, readUploadSourceQuery } from '../../utils/image-query'
|
||||
import { searchImages } from '../../utils/storage'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
@@ -48,6 +48,10 @@ export default defineEventHandler(async (event) => {
|
||||
if (uploadSource === null) {
|
||||
createApiError(event, 'INVALID_REQUEST', '无效的上传来源', 400)
|
||||
}
|
||||
const tagFilter = readTagFilterQuery(query)
|
||||
if (tagFilter === null) {
|
||||
createApiError(event, 'INVALID_REQUEST', '无效的标签筛选参数', 400)
|
||||
}
|
||||
const userFilter = await getImageUserFilter(event)
|
||||
|
||||
const result = await searchImages({
|
||||
@@ -57,15 +61,18 @@ export default defineEventHandler(async (event) => {
|
||||
userFilter,
|
||||
backendId,
|
||||
contentType,
|
||||
uploadSource
|
||||
uploadSource,
|
||||
tagFilter
|
||||
})
|
||||
|
||||
const user = await getCurrentUser(event)
|
||||
const ownerMap = user?.role === 'admin' ? listUserIdUsernameMap() : undefined
|
||||
const backendMap = listStorageBackendNameMap()
|
||||
const sourceMap = getUploadSourcesForKeys(result.items.map(item => item.key))
|
||||
const items = result.items.map(stored =>
|
||||
mapStoredImageToItem(event, stored, { ownerMap, backendMap, sourceMap })
|
||||
const items = attachTagsToImageItems(
|
||||
result.items.map(stored =>
|
||||
mapStoredImageToItem(event, stored, { ownerMap, backendMap, sourceMap })
|
||||
)
|
||||
)
|
||||
|
||||
items.sort((a, b) => b.uploadedAt.localeCompare(a.uploadedAt))
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import { requireApiOrAdminAuth } from '../../utils/access'
|
||||
import { logActivity } from '../../utils/activity-log'
|
||||
import { getCurrentUser } from '../../utils/auth'
|
||||
import { createApiError } from '../../utils/api-error'
|
||||
import { getImageIndexRow } from '../../utils/image-index'
|
||||
import { toCanonicalImageKey, validateImageKey } from '../../utils/image-key'
|
||||
import { removeTagFromImage } from '../../utils/tags'
|
||||
import { handleTagApiError } from '../../utils/tag-api'
|
||||
|
||||
interface ImageTagDeleteBody {
|
||||
key?: string
|
||||
tagId?: number
|
||||
}
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
await requireApiOrAdminAuth(event)
|
||||
const user = await getCurrentUser(event)
|
||||
|
||||
const query = getQuery(event)
|
||||
const body = await readBody<ImageTagDeleteBody>(event).catch(() => ({} as ImageTagDeleteBody))
|
||||
|
||||
const key = (typeof query.key === 'string' ? query.key : undefined) ?? body?.key?.trim()
|
||||
const tagId = Number(
|
||||
typeof query.tagId === 'string' ? query.tagId : body?.tagId
|
||||
)
|
||||
|
||||
if (
|
||||
!key
|
||||
|| (
|
||||
!validateImageKey(key)
|
||||
&& !toCanonicalImageKey(key)
|
||||
&& !getImageIndexRow(key)
|
||||
)
|
||||
) {
|
||||
createApiError(event, 'INVALID_IMAGE_KEY', '无效的图片路径', 400)
|
||||
}
|
||||
|
||||
if (!Number.isInteger(tagId) || tagId <= 0) {
|
||||
createApiError(event, 'INVALID_REQUEST', '无效的标签 ID', 400)
|
||||
}
|
||||
|
||||
try {
|
||||
const tags = removeTagFromImage(
|
||||
key,
|
||||
tagId,
|
||||
user!.id,
|
||||
user!.role === 'admin'
|
||||
)
|
||||
const indexed = getImageIndexRow(key)
|
||||
logActivity(event, {
|
||||
action: 'tags',
|
||||
key,
|
||||
originalName: indexed?.original_name ?? key,
|
||||
userId: user!.id
|
||||
})
|
||||
return { tags }
|
||||
} catch (error) {
|
||||
handleTagApiError(event, error)
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,54 @@
|
||||
import { requireApiOrAdminAuth } from '../../utils/access'
|
||||
import { logActivity } from '../../utils/activity-log'
|
||||
import { getCurrentUser } from '../../utils/auth'
|
||||
import { createApiError } from '../../utils/api-error'
|
||||
import { getImageIndexRow } from '../../utils/image-index'
|
||||
import { toCanonicalImageKey, validateImageKey } from '../../utils/image-key'
|
||||
import { setImageTags } from '../../utils/tags'
|
||||
import { handleTagApiError } from '../../utils/tag-api'
|
||||
|
||||
interface ImageTagsBody {
|
||||
key?: string
|
||||
tagIds?: number[]
|
||||
}
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
await requireApiOrAdminAuth(event)
|
||||
const user = await getCurrentUser(event)
|
||||
const body = await readBody<ImageTagsBody>(event)
|
||||
|
||||
const key = body?.key?.trim()
|
||||
if (
|
||||
!key
|
||||
|| (
|
||||
!validateImageKey(key)
|
||||
&& !toCanonicalImageKey(key)
|
||||
&& !getImageIndexRow(key)
|
||||
)
|
||||
) {
|
||||
createApiError(event, 'INVALID_IMAGE_KEY', '无效的图片路径', 400)
|
||||
}
|
||||
|
||||
const tagIds = Array.isArray(body?.tagIds)
|
||||
? body.tagIds.filter(id => Number.isInteger(id) && id > 0)
|
||||
: []
|
||||
|
||||
try {
|
||||
const tags = setImageTags(
|
||||
key,
|
||||
tagIds,
|
||||
user!.id,
|
||||
user!.role === 'admin'
|
||||
)
|
||||
const indexed = getImageIndexRow(key)
|
||||
logActivity(event, {
|
||||
action: 'tags',
|
||||
key,
|
||||
originalName: indexed?.original_name ?? key,
|
||||
userId: user!.id
|
||||
})
|
||||
return { tags }
|
||||
} catch (error) {
|
||||
handleTagApiError(event, error)
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,57 @@
|
||||
import { requireApiOrAdminAuth } from '../../utils/access'
|
||||
import { logActivity } from '../../utils/activity-log'
|
||||
import { getCurrentUser } from '../../utils/auth'
|
||||
import { createApiError } from '../../utils/api-error'
|
||||
import { getImageIndexRow } from '../../utils/image-index'
|
||||
import { toCanonicalImageKey, validateImageKey } from '../../utils/image-key'
|
||||
import { addTagsToImage } from '../../utils/tags'
|
||||
import { handleTagApiError } from '../../utils/tag-api'
|
||||
|
||||
interface ImageTagsBody {
|
||||
key?: string
|
||||
tagIds?: number[]
|
||||
}
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
await requireApiOrAdminAuth(event)
|
||||
const user = await getCurrentUser(event)
|
||||
const body = await readBody<ImageTagsBody>(event)
|
||||
|
||||
const key = body?.key?.trim()
|
||||
if (
|
||||
!key
|
||||
|| (
|
||||
!validateImageKey(key)
|
||||
&& !toCanonicalImageKey(key)
|
||||
&& !getImageIndexRow(key)
|
||||
)
|
||||
) {
|
||||
createApiError(event, 'INVALID_IMAGE_KEY', '无效的图片路径', 400)
|
||||
}
|
||||
|
||||
const tagIds = Array.isArray(body?.tagIds)
|
||||
? body.tagIds.filter(id => Number.isInteger(id) && id > 0)
|
||||
: []
|
||||
if (!tagIds.length) {
|
||||
createApiError(event, 'INVALID_REQUEST', '请提供要添加的标签', 400)
|
||||
}
|
||||
|
||||
try {
|
||||
const tags = addTagsToImage(
|
||||
key,
|
||||
tagIds,
|
||||
user!.id,
|
||||
user!.role === 'admin'
|
||||
)
|
||||
const indexed = getImageIndexRow(key)
|
||||
logActivity(event, {
|
||||
action: 'tags',
|
||||
key,
|
||||
originalName: indexed?.original_name ?? key,
|
||||
userId: user!.id
|
||||
})
|
||||
return { tags }
|
||||
} catch (error) {
|
||||
handleTagApiError(event, error)
|
||||
}
|
||||
})
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
} from '../../utils/process-image-upload'
|
||||
import { createApiError } from '../../utils/api-error'
|
||||
import { checkUploadRateLimit } from '../../utils/rate-limit'
|
||||
import { parseTagIdsParam } from '../../utils/tags'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
await requireUploadAuth(event)
|
||||
@@ -30,6 +31,25 @@ export default defineEventHandler(async (event) => {
|
||||
const source = verifyApiUploadToken(event) ? 'api' : 'web'
|
||||
const uploadUserId = await getUploadUserId(event)
|
||||
|
||||
const tagIdsField = formData.find(part => part.name === 'tagIds')
|
||||
let uploadTagIds: number[] = []
|
||||
if (tagIdsField?.data?.length) {
|
||||
const raw = new TextDecoder().decode(tagIdsField.data).trim()
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as unknown
|
||||
uploadTagIds = parseTagIdsParam(parsed)
|
||||
} catch {
|
||||
uploadTagIds = parseTagIdsParam(raw)
|
||||
}
|
||||
} else {
|
||||
const repeatFields = formData.filter(part => part.name === 'tagIds' && part.data?.length)
|
||||
if (repeatFields.length) {
|
||||
uploadTagIds = parseTagIdsParam(
|
||||
repeatFields.map(part => new TextDecoder().decode(part.data!).trim())
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 兼容 image / file / files 字段名(图床脚本 / 通用客户端 / 网页多图)
|
||||
const fileParts = formData.filter(
|
||||
part => (part.name === 'image' || part.name === 'file' || part.name === 'files')
|
||||
@@ -83,7 +103,8 @@ export default defineEventHandler(async (event) => {
|
||||
bytes,
|
||||
filename: part.filename,
|
||||
source,
|
||||
userId: uploadUserId
|
||||
userId: uploadUserId,
|
||||
tagIds: uploadTagIds
|
||||
})
|
||||
|
||||
if ('error' in result) {
|
||||
|
||||
@@ -4,10 +4,9 @@ import { logException } from '../../utils/logger'
|
||||
import {
|
||||
listActivityLogs,
|
||||
listUserIdUsernameMap,
|
||||
summarizeActivityLogs,
|
||||
type LogAction,
|
||||
type LogSource
|
||||
summarizeActivityLogs
|
||||
} from '../../utils/db'
|
||||
import { isLogAction, isLogSource } from '../../utils/activity-log'
|
||||
import { ensureStorageSchema } from '../../utils/storage-backends'
|
||||
import {
|
||||
DEFAULT_LIST_LIMIT,
|
||||
@@ -25,14 +24,10 @@ export default defineEventHandler(async (event) => {
|
||||
: DEFAULT_LIST_LIMIT
|
||||
|
||||
const actionRaw = typeof query.action === 'string' ? query.action : ''
|
||||
const action = (actionRaw === 'upload' || actionRaw === 'delete')
|
||||
? actionRaw as LogAction
|
||||
: undefined
|
||||
const action = isLogAction(actionRaw) ? actionRaw : undefined
|
||||
|
||||
const sourceRaw = typeof query.source === 'string' ? query.source : ''
|
||||
const source = (sourceRaw === 'web' || sourceRaw === 'api')
|
||||
? sourceRaw as LogSource
|
||||
: undefined
|
||||
const source = isLogSource(sourceRaw) ? sourceRaw : undefined
|
||||
|
||||
const folder = typeof query.folder === 'string' && query.folder.trim()
|
||||
? query.folder.trim()
|
||||
@@ -41,6 +36,19 @@ export default defineEventHandler(async (event) => {
|
||||
const searchRaw = typeof query.q === 'string' ? query.q.trim() : ''
|
||||
const search = searchRaw || undefined
|
||||
|
||||
const dateFromRaw = typeof query.from === 'string' ? query.from.trim() : ''
|
||||
const dateToRaw = typeof query.to === 'string' ? query.to.trim() : ''
|
||||
const dateFrom = dateFromRaw && /^\d{4}-\d{2}-\d{2}$/.test(dateFromRaw)
|
||||
? `${dateFromRaw}T00:00:00.000Z`
|
||||
: undefined
|
||||
const dateTo = dateToRaw && /^\d{4}-\d{2}-\d{2}$/.test(dateToRaw)
|
||||
? (() => {
|
||||
const end = new Date(`${dateToRaw}T00:00:00.000Z`)
|
||||
end.setUTCDate(end.getUTCDate() + 1)
|
||||
return end.toISOString()
|
||||
})()
|
||||
: undefined
|
||||
|
||||
const pageRaw = Number(query.page ?? 1)
|
||||
const page = Number.isFinite(pageRaw) && pageRaw > 0
|
||||
? Math.floor(pageRaw)
|
||||
@@ -62,8 +70,25 @@ export default defineEventHandler(async (event) => {
|
||||
|
||||
try {
|
||||
ensureStorageSchema()
|
||||
const result = listActivityLogs({ limit, page, action, source, folder, userId, search })
|
||||
const summary = summarizeActivityLogs({ source, folder, userId, search })
|
||||
const result = listActivityLogs({
|
||||
limit,
|
||||
page,
|
||||
action,
|
||||
source,
|
||||
folder,
|
||||
userId,
|
||||
search,
|
||||
dateFrom,
|
||||
dateTo
|
||||
})
|
||||
const summary = summarizeActivityLogs({
|
||||
source,
|
||||
folder,
|
||||
userId,
|
||||
search,
|
||||
dateFrom,
|
||||
dateTo
|
||||
})
|
||||
const userMap = user.role === 'admin' ? listUserIdUsernameMap() : undefined
|
||||
|
||||
return {
|
||||
@@ -84,6 +109,8 @@ export default defineEventHandler(async (event) => {
|
||||
type: (row.backend_type === 'local' ? 'local' : 's3') as 'local' | 's3'
|
||||
}
|
||||
: null,
|
||||
ipAddress: row.ip_address,
|
||||
status: row.status === 'failure' ? 'failure' : 'success',
|
||||
createdAt: row.created_at
|
||||
})),
|
||||
total: result.total,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { requireAdminAuth } from '../../utils/access'
|
||||
import { logActivity } from '../../utils/activity-log'
|
||||
import { createApiError } from '../../utils/api-error'
|
||||
import {
|
||||
getSetting,
|
||||
@@ -52,7 +53,7 @@ interface SettingsPatchBody {
|
||||
}
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
await requireAdminAuth(event)
|
||||
const user = await requireAdminAuth(event)
|
||||
|
||||
const body = await readBody<SettingsPatchBody>(event).catch(
|
||||
(): SettingsPatchBody => ({})
|
||||
@@ -248,5 +249,13 @@ export default defineEventHandler(async (event) => {
|
||||
setSetting(SETTINGS_CAP_SECRET, nextCapSecret)
|
||||
}
|
||||
|
||||
logActivity(event, {
|
||||
action: 'settings',
|
||||
key: 'settings/system',
|
||||
originalName: '系统设置',
|
||||
userId: user.id,
|
||||
source: 'admin'
|
||||
})
|
||||
|
||||
return getSettingsPayload(event)
|
||||
})
|
||||
@@ -2,6 +2,7 @@ import { getImageUserFilter, requireUserAuth } from '../utils/access'
|
||||
import { createApiError } from '../utils/api-error'
|
||||
import { logException } from '../utils/logger'
|
||||
import { countUsers, getActivityStats } from '../utils/db'
|
||||
import { readTagFilterQuery } from '../utils/image-query'
|
||||
import {
|
||||
countImages,
|
||||
getFolderStorageStats,
|
||||
@@ -39,13 +40,18 @@ async function resolveStorageUsage(usedBytes: number): Promise<StorageUsageStat>
|
||||
export default defineEventHandler(async (event) => {
|
||||
const user = await requireUserAuth(event)
|
||||
const userFilter = await getImageUserFilter(event)
|
||||
const query = getQuery(event)
|
||||
const tagFilter = readTagFilterQuery(query)
|
||||
if (tagFilter === null) {
|
||||
createApiError(event, 'INVALID_REQUEST', '无效的标签筛选参数', 400)
|
||||
}
|
||||
|
||||
try {
|
||||
if (userFilter === 'admin') {
|
||||
const activity = getActivityStats()
|
||||
const [storedCount, byFolder, storageUsage] = await Promise.all([
|
||||
countImages(),
|
||||
getFolderStorageStats(),
|
||||
countImages('admin', undefined, tagFilter),
|
||||
getFolderStorageStats('admin', tagFilter),
|
||||
resolveStorageUsage(activity.uploadBytesTotal)
|
||||
])
|
||||
|
||||
@@ -58,7 +64,7 @@ export default defineEventHandler(async (event) => {
|
||||
}
|
||||
}
|
||||
|
||||
const scoped = await getUserScopedStorageStats(user.id)
|
||||
const scoped = await getUserScopedStorageStats(user.id, tagFilter)
|
||||
const storageUsage = await resolveStorageUsage(scoped.uploadBytesTotal)
|
||||
|
||||
return {
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { requireUserAuth } from '../../utils/access'
|
||||
import { logActivity } from '../../utils/activity-log'
|
||||
import { createApiError } from '../../utils/api-error'
|
||||
import { deleteTag, getTagForUser } from '../../utils/tags'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const user = await requireUserAuth(event)
|
||||
const id = Number(getRouterParam(event, 'id'))
|
||||
|
||||
if (!Number.isInteger(id) || id <= 0) {
|
||||
createApiError(event, 'INVALID_REQUEST', '无效的标签 ID', 400)
|
||||
}
|
||||
|
||||
const existing = getTagForUser(user.id, id)
|
||||
if (!existing) {
|
||||
createApiError(event, 'INVALID_REQUEST', '标签不存在', 404)
|
||||
}
|
||||
|
||||
const deleted = deleteTag(user.id, id)
|
||||
if (!deleted) {
|
||||
createApiError(event, 'INVALID_REQUEST', '标签不存在', 404)
|
||||
}
|
||||
|
||||
logActivity(event, {
|
||||
action: 'tags',
|
||||
key: `tags/${id}`,
|
||||
originalName: existing.name,
|
||||
userId: user.id
|
||||
})
|
||||
|
||||
return { success: true }
|
||||
})
|
||||
@@ -0,0 +1,40 @@
|
||||
import { requireUserAuth } from '../../utils/access'
|
||||
import { logActivity } from '../../utils/activity-log'
|
||||
import { createApiError } from '../../utils/api-error'
|
||||
import { updateTag } from '../../utils/tags'
|
||||
import { handleTagApiError } from '../../utils/tag-api'
|
||||
|
||||
interface PatchTagBody {
|
||||
name?: string
|
||||
color?: string | null
|
||||
}
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const user = await requireUserAuth(event)
|
||||
const id = Number(getRouterParam(event, 'id'))
|
||||
|
||||
if (!Number.isInteger(id) || id <= 0) {
|
||||
createApiError(event, 'INVALID_REQUEST', '无效的标签 ID', 400)
|
||||
}
|
||||
|
||||
const body = await readBody<PatchTagBody>(event)
|
||||
if (body.name === undefined && body.color === undefined) {
|
||||
createApiError(event, 'INVALID_REQUEST', '请提供要更新的字段', 400)
|
||||
}
|
||||
|
||||
try {
|
||||
const tag = updateTag(user.id, id, {
|
||||
name: body.name,
|
||||
color: body.color
|
||||
})
|
||||
logActivity(event, {
|
||||
action: 'edit',
|
||||
key: `tags/${tag.id}`,
|
||||
originalName: tag.name,
|
||||
userId: user.id
|
||||
})
|
||||
return { tag }
|
||||
} catch (error) {
|
||||
handleTagApiError(event, error)
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,7 @@
|
||||
import { requireUserAuth } from '../../utils/access'
|
||||
import { listTagsForUser } from '../../utils/tags'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const user = await requireUserAuth(event)
|
||||
return { items: listTagsForUser(user.id) }
|
||||
})
|
||||
@@ -0,0 +1,32 @@
|
||||
import { requireUserAuth } from '../../utils/access'
|
||||
import { logActivity } from '../../utils/activity-log'
|
||||
import { createApiError } from '../../utils/api-error'
|
||||
import { createTag } from '../../utils/tags'
|
||||
import { handleTagApiError } from '../../utils/tag-api'
|
||||
|
||||
interface CreateTagBody {
|
||||
name?: string
|
||||
color?: string | null
|
||||
}
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const user = await requireUserAuth(event)
|
||||
const body = await readBody<CreateTagBody>(event)
|
||||
|
||||
if (!body?.name || typeof body.name !== 'string') {
|
||||
createApiError(event, 'INVALID_REQUEST', '请提供标签名称', 400)
|
||||
}
|
||||
|
||||
try {
|
||||
const tag = createTag(user.id, body.name, body.color)
|
||||
logActivity(event, {
|
||||
action: 'tags',
|
||||
key: `tags/${tag.id}`,
|
||||
originalName: tag.name,
|
||||
userId: user.id
|
||||
})
|
||||
return { tag }
|
||||
} catch (error) {
|
||||
handleTagApiError(event, error)
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,37 @@
|
||||
import { requireUserAuth } from '../../utils/access'
|
||||
import { logActivity } from '../../utils/activity-log'
|
||||
import { createApiError } from '../../utils/api-error'
|
||||
import { mergeTags } from '../../utils/tags'
|
||||
import { handleTagApiError } from '../../utils/tag-api'
|
||||
|
||||
interface MergeTagsBody {
|
||||
sourceIds?: number[]
|
||||
targetId?: number
|
||||
}
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const user = await requireUserAuth(event)
|
||||
const body = await readBody<MergeTagsBody>(event)
|
||||
|
||||
const sourceIds = Array.isArray(body?.sourceIds)
|
||||
? body.sourceIds.filter(id => Number.isInteger(id) && id > 0)
|
||||
: []
|
||||
const targetId = Number(body?.targetId)
|
||||
|
||||
if (!sourceIds.length || !Number.isInteger(targetId) || targetId <= 0) {
|
||||
createApiError(event, 'INVALID_REQUEST', '请提供有效的源标签与目标标签', 400)
|
||||
}
|
||||
|
||||
try {
|
||||
const tag = mergeTags(user.id, sourceIds, targetId)
|
||||
logActivity(event, {
|
||||
action: 'tags',
|
||||
key: `tags/${tag.id}`,
|
||||
originalName: `合并标签 → ${tag.name}`,
|
||||
userId: user.id
|
||||
})
|
||||
return { tag }
|
||||
} catch (error) {
|
||||
handleTagApiError(event, error)
|
||||
}
|
||||
})
|
||||
@@ -1,4 +1,5 @@
|
||||
import { requireUserAuth } from '../../utils/access'
|
||||
import { logActivity } from '../../utils/activity-log'
|
||||
import { createApiError } from '../../utils/api-error'
|
||||
import { getUserAutoDeletePolicy, setUserAutoDeletePolicy } from '../../utils/db'
|
||||
import { MAX_AUTO_DELETE_DAYS, parseAutoDeleteDays } from '../../utils/env'
|
||||
@@ -28,6 +29,16 @@ export default defineEventHandler(async (event) => {
|
||||
}
|
||||
|
||||
const policy = getUserAutoDeletePolicy(user.id)
|
||||
|
||||
if (body.autoDeleteDays !== undefined) {
|
||||
logActivity(event, {
|
||||
action: 'settings',
|
||||
key: 'user/settings',
|
||||
originalName: '上传偏好',
|
||||
userId: user.id
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
autoDeleteDays: policy.days
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user