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
|
||||
}
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
import { mkdtempSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { DatabaseSync } from 'node:sqlite'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
|
||||
const tempDirs: string[] = []
|
||||
|
||||
afterEach(() => {
|
||||
while (tempDirs.length) {
|
||||
const dir = tempDirs.pop()
|
||||
if (dir) rmSync(dir, { recursive: true, force: true })
|
||||
}
|
||||
process.env.DATA_DIR = undefined
|
||||
})
|
||||
|
||||
function useTempDataDir(): string {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'pichost-smoke-'))
|
||||
tempDirs.push(dir)
|
||||
process.env.DATA_DIR = dir
|
||||
return dir
|
||||
}
|
||||
|
||||
async function loadDbModule() {
|
||||
const mod = await import('./db')
|
||||
mod.closeDb()
|
||||
return mod
|
||||
}
|
||||
|
||||
describe('activity log smoke', () => {
|
||||
it('migrates legacy activity_logs constraints and accepts new action types', async () => {
|
||||
const dir = useTempDataDir()
|
||||
const dbPath = join(dir, 'pichost.db')
|
||||
const legacy = new DatabaseSync(dbPath)
|
||||
legacy.exec(`
|
||||
CREATE TABLE activity_logs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
action TEXT NOT NULL CHECK(action IN ('upload', 'delete')),
|
||||
key TEXT NOT NULL,
|
||||
original_name TEXT NOT NULL,
|
||||
size INTEGER NOT NULL DEFAULT 0,
|
||||
content_type TEXT NOT NULL DEFAULT '',
|
||||
source TEXT NOT NULL CHECK(source IN ('web', 'api')),
|
||||
created_at TEXT NOT NULL,
|
||||
user_id INTEGER,
|
||||
backend_id TEXT,
|
||||
ip_address TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'success'
|
||||
);
|
||||
INSERT INTO activity_logs (action, key, original_name, size, content_type, source, created_at)
|
||||
VALUES ('upload', 'images/a.webp', 'a.webp', 100, 'image/webp', 'web', '2026-01-01T00:00:00.000Z');
|
||||
CREATE TABLE settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
`)
|
||||
legacy.close()
|
||||
|
||||
const { getDb, insertActivityLog, listActivityLogs, closeDb } = await loadDbModule()
|
||||
getDb()
|
||||
const { ensureStorageSchema } = await import('./storage-backends')
|
||||
ensureStorageSchema()
|
||||
|
||||
insertActivityLog({
|
||||
action: 'login',
|
||||
key: 'auth/login',
|
||||
originalName: 'admin',
|
||||
size: 0,
|
||||
contentType: '',
|
||||
source: 'web',
|
||||
userId: 1,
|
||||
ipAddress: '127.0.0.1'
|
||||
})
|
||||
insertActivityLog({
|
||||
action: 'tags',
|
||||
key: 'tags/1',
|
||||
originalName: '生活',
|
||||
size: 0,
|
||||
contentType: '',
|
||||
source: 'admin',
|
||||
userId: 1
|
||||
})
|
||||
|
||||
const result = listActivityLogs({ limit: 10, page: 1 })
|
||||
expect(result.total).toBe(3)
|
||||
expect(result.items.map(item => item.action).sort()).toEqual(['login', 'tags', 'upload'])
|
||||
|
||||
const migrated = getDb().prepare(`
|
||||
SELECT value FROM settings WHERE key = 'activity_logs_schema_v2'
|
||||
`).get() as { value: string } | undefined
|
||||
expect(migrated?.value).toBe('1')
|
||||
|
||||
closeDb()
|
||||
await new Promise(resolve => setTimeout(resolve, 50))
|
||||
})
|
||||
|
||||
it('opens the project data database without throwing', async () => {
|
||||
const projectData = join(process.cwd(), 'data')
|
||||
process.env.DATA_DIR = projectData
|
||||
|
||||
const { getDb, listActivityLogs, closeDb } = await loadDbModule()
|
||||
getDb()
|
||||
const result = listActivityLogs({ limit: 5, page: 1 })
|
||||
expect(result.page).toBeGreaterThan(0)
|
||||
expect(Array.isArray(result.items)).toBe(true)
|
||||
closeDb()
|
||||
await new Promise(resolve => setTimeout(resolve, 50))
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,67 @@
|
||||
import type { H3Event } from 'h3'
|
||||
import { getHeader } from 'h3'
|
||||
import {
|
||||
insertActivityLog,
|
||||
type LogAction,
|
||||
type LogSource,
|
||||
type LogStatus
|
||||
} from './db'
|
||||
import { verifyApiUploadToken } from './access'
|
||||
import { clientIp } from './logger'
|
||||
|
||||
export const LOG_ACTIONS: LogAction[] = [
|
||||
'upload',
|
||||
'delete',
|
||||
'login',
|
||||
'edit',
|
||||
'settings',
|
||||
'tags'
|
||||
]
|
||||
|
||||
export const LOG_SOURCES: LogSource[] = ['web', 'api', 'admin']
|
||||
|
||||
export function isLogAction(value: string): value is LogAction {
|
||||
return LOG_ACTIONS.includes(value as LogAction)
|
||||
}
|
||||
|
||||
export function isLogSource(value: string): value is LogSource {
|
||||
return LOG_SOURCES.includes(value as LogSource)
|
||||
}
|
||||
|
||||
export function resolveActivityLogSource(event: H3Event): LogSource {
|
||||
if (verifyApiUploadToken(event)) {
|
||||
return 'api'
|
||||
}
|
||||
const referer = getHeader(event, 'referer') ?? ''
|
||||
if (/\/settings(?:[/?#]|$)|\/storage(?:[/?#]|$)/.test(referer)) {
|
||||
return 'admin'
|
||||
}
|
||||
return 'web'
|
||||
}
|
||||
|
||||
export interface ActivityLogEventInput {
|
||||
action: LogAction
|
||||
key: string
|
||||
originalName: string
|
||||
size?: number
|
||||
contentType?: string
|
||||
source?: LogSource
|
||||
userId?: number | null
|
||||
backendId?: string | null
|
||||
status?: LogStatus
|
||||
}
|
||||
|
||||
export function logActivity(event: H3Event, input: ActivityLogEventInput): void {
|
||||
insertActivityLog({
|
||||
action: input.action,
|
||||
key: input.key,
|
||||
originalName: input.originalName,
|
||||
size: input.size ?? 0,
|
||||
contentType: input.contentType ?? '',
|
||||
source: input.source ?? resolveActivityLogSource(event),
|
||||
userId: input.userId ?? null,
|
||||
backendId: input.backendId ?? null,
|
||||
ipAddress: clientIp(event),
|
||||
status: input.status ?? 'success'
|
||||
})
|
||||
}
|
||||
+125
-10
@@ -4,8 +4,10 @@ import { join } from 'node:path'
|
||||
import { getDataDir } from './data-dir'
|
||||
import { logException } from './logger'
|
||||
|
||||
export type LogAction = 'upload' | 'delete'
|
||||
export type LogSource = 'web' | 'api'
|
||||
export type LogAction = 'upload' | 'delete' | 'login' | 'edit' | 'settings' | 'tags'
|
||||
export type LogSource = 'web' | 'api' | 'admin'
|
||||
|
||||
export type LogStatus = 'success' | 'failure'
|
||||
|
||||
export interface ActivityLogRow {
|
||||
id: number
|
||||
@@ -20,6 +22,8 @@ export interface ActivityLogRow {
|
||||
backend_id: string | null
|
||||
backend_name: string | null
|
||||
backend_type: string | null
|
||||
ip_address: string | null
|
||||
status: LogStatus
|
||||
created_at: string
|
||||
}
|
||||
|
||||
@@ -32,6 +36,8 @@ export interface ActivityLogInput {
|
||||
source: LogSource
|
||||
userId?: number | null
|
||||
backendId?: string | null
|
||||
ipAddress?: string | null
|
||||
status?: LogStatus
|
||||
createdAt?: string
|
||||
}
|
||||
|
||||
@@ -67,12 +73,12 @@ export function getDb(): DatabaseSync {
|
||||
PRAGMA journal_mode = WAL;
|
||||
CREATE TABLE IF NOT EXISTS activity_logs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
action TEXT NOT NULL CHECK(action IN ('upload', 'delete')),
|
||||
action TEXT NOT NULL,
|
||||
key TEXT NOT NULL,
|
||||
original_name TEXT NOT NULL,
|
||||
size INTEGER NOT NULL DEFAULT 0,
|
||||
content_type TEXT NOT NULL DEFAULT '',
|
||||
source TEXT NOT NULL CHECK(source IN ('web', 'api')),
|
||||
source TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_activity_logs_created_at
|
||||
@@ -105,6 +111,62 @@ export function getDb(): DatabaseSync {
|
||||
return db
|
||||
}
|
||||
|
||||
function migrateActivityLogSchema(database: DatabaseSync): void {
|
||||
const migrated = database.prepare(`
|
||||
SELECT 1 FROM settings WHERE key = 'activity_logs_schema_v2'
|
||||
`).get()
|
||||
if (migrated) return
|
||||
|
||||
database.exec('BEGIN')
|
||||
try {
|
||||
database.exec(`
|
||||
CREATE TABLE activity_logs_new (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
action TEXT NOT NULL,
|
||||
key TEXT NOT NULL,
|
||||
original_name TEXT NOT NULL,
|
||||
size INTEGER NOT NULL DEFAULT 0,
|
||||
content_type TEXT NOT NULL DEFAULT '',
|
||||
source TEXT NOT NULL,
|
||||
user_id INTEGER,
|
||||
backend_id TEXT,
|
||||
ip_address TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'success',
|
||||
created_at TEXT NOT NULL
|
||||
)
|
||||
`)
|
||||
database.exec(`
|
||||
INSERT INTO activity_logs_new (
|
||||
id, action, key, original_name, size, content_type, source,
|
||||
user_id, backend_id, ip_address, status, created_at
|
||||
)
|
||||
SELECT
|
||||
id, action, key, original_name, size, content_type, source,
|
||||
user_id, backend_id, ip_address, COALESCE(status, 'success'), created_at
|
||||
FROM activity_logs
|
||||
`)
|
||||
database.exec('DROP TABLE activity_logs')
|
||||
database.exec('ALTER TABLE activity_logs_new RENAME TO activity_logs')
|
||||
database.exec(`
|
||||
CREATE INDEX IF NOT EXISTS idx_activity_logs_created_at
|
||||
ON activity_logs(created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_activity_logs_action_created
|
||||
ON activity_logs(action, created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_activity_logs_user_created
|
||||
ON activity_logs(user_id, created_at DESC);
|
||||
`)
|
||||
const now = new Date().toISOString()
|
||||
database.prepare(`
|
||||
INSERT INTO settings (key, value, updated_at)
|
||||
VALUES ('activity_logs_schema_v2', '1', ?)
|
||||
`).run(now)
|
||||
database.exec('COMMIT')
|
||||
} catch (error) {
|
||||
database.exec('ROLLBACK')
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
function migrateSchema(database: DatabaseSync): void {
|
||||
const columns = database.prepare('PRAGMA table_info(users)').all() as Array<{
|
||||
name: string
|
||||
@@ -161,10 +223,44 @@ function migrateSchema(database: DatabaseSync): void {
|
||||
if (!activityLogColumns.some(column => column.name === 'backend_id')) {
|
||||
database.exec('ALTER TABLE activity_logs ADD COLUMN backend_id TEXT')
|
||||
}
|
||||
const activityLogColumnsAfter = database.prepare('PRAGMA table_info(activity_logs)').all() as Array<{
|
||||
name: string
|
||||
}>
|
||||
if (!activityLogColumnsAfter.some(column => column.name === 'ip_address')) {
|
||||
database.exec('ALTER TABLE activity_logs ADD COLUMN ip_address TEXT')
|
||||
}
|
||||
if (!activityLogColumnsAfter.some(column => column.name === 'status')) {
|
||||
database.exec(`ALTER TABLE activity_logs ADD COLUMN status TEXT NOT NULL DEFAULT 'success'`)
|
||||
}
|
||||
|
||||
database.exec(`
|
||||
UPDATE activity_logs SET source = 'api' WHERE source = 'twikoo'
|
||||
`)
|
||||
|
||||
migrateActivityLogSchema(database)
|
||||
|
||||
// tags schema (lazy import avoided — keep migration in tags.ts)
|
||||
database.exec(`
|
||||
CREATE TABLE IF NOT EXISTS tags (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
color TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
UNIQUE(user_id, name),
|
||||
FOREIGN KEY (user_id) REFERENCES users(id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_tags_user_id ON tags(user_id);
|
||||
CREATE TABLE IF NOT EXISTS image_tags (
|
||||
image_key TEXT NOT NULL,
|
||||
tag_id INTEGER NOT NULL,
|
||||
PRIMARY KEY (image_key, tag_id),
|
||||
FOREIGN KEY (image_key) REFERENCES images(key) ON DELETE CASCADE,
|
||||
FOREIGN KEY (tag_id) REFERENCES tags(id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_image_tags_tag_id ON image_tags(tag_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_image_tags_image_key ON image_tags(image_key);
|
||||
`)
|
||||
}
|
||||
|
||||
const USER_COLUMNS = 'id, username, password_hash, role, created_at, api_token, auto_delete_days, auto_delete_enabled_at'
|
||||
@@ -448,8 +544,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, user_id, backend_id, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
(action, key, original_name, size, content_type, source, user_id, backend_id, ip_address, status, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
input.action,
|
||||
input.key,
|
||||
@@ -459,6 +555,8 @@ export function insertActivityLog(input: ActivityLogInput): void {
|
||||
input.source,
|
||||
input.userId ?? null,
|
||||
input.backendId ?? null,
|
||||
input.ipAddress ?? null,
|
||||
input.status ?? 'success',
|
||||
createdAt
|
||||
)
|
||||
} catch (error) {
|
||||
@@ -505,6 +603,8 @@ export function listActivityLogs(options: {
|
||||
folder?: string
|
||||
userId?: number
|
||||
search?: string
|
||||
dateFrom?: string
|
||||
dateTo?: string
|
||||
}): {
|
||||
items: ActivityLogRow[]
|
||||
total: number
|
||||
@@ -542,6 +642,8 @@ export function listActivityLogs(options: {
|
||||
al.backend_id,
|
||||
sb.name AS backend_name,
|
||||
sb.type AS backend_type,
|
||||
al.ip_address,
|
||||
al.status,
|
||||
al.created_at
|
||||
FROM activity_logs al
|
||||
LEFT JOIN users u ON u.id = al.user_id
|
||||
@@ -565,6 +667,8 @@ export function summarizeActivityLogs(options: {
|
||||
folder?: string
|
||||
userId?: number
|
||||
search?: string
|
||||
dateFrom?: string
|
||||
dateTo?: string
|
||||
}): {
|
||||
total: number
|
||||
upload: number
|
||||
@@ -595,6 +699,8 @@ function buildActivityLogWhere(
|
||||
folder?: string
|
||||
userId?: number
|
||||
search?: string
|
||||
dateFrom?: string
|
||||
dateTo?: string
|
||||
},
|
||||
{ includeAction = true }: { includeAction?: boolean } = {}
|
||||
): { whereSql: string, params: Array<string | number> } {
|
||||
@@ -617,9 +723,18 @@ function buildActivityLogWhere(
|
||||
where.push('al.user_id = ?')
|
||||
params.push(options.userId)
|
||||
}
|
||||
if (options.dateFrom) {
|
||||
where.push('al.created_at >= ?')
|
||||
params.push(options.dateFrom)
|
||||
}
|
||||
if (options.dateTo) {
|
||||
where.push('al.created_at < ?')
|
||||
params.push(options.dateTo)
|
||||
}
|
||||
if (options.search) {
|
||||
where.push('al.original_name LIKE ?')
|
||||
params.push(`%${options.search}%`)
|
||||
where.push('(al.original_name LIKE ? OR al.key LIKE ? OR al.ip_address LIKE ?)')
|
||||
const pattern = `%${options.search}%`
|
||||
params.push(pattern, pattern, pattern)
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -680,11 +795,11 @@ function countBySource(): Record<LogSource, number> {
|
||||
GROUP BY source
|
||||
`).all() as unknown as Array<{ source: LogSource, total: number }>
|
||||
|
||||
const result: Record<LogSource, number> = { web: 0, api: 0 }
|
||||
const result: Record<LogSource, number> = { web: 0, api: 0, admin: 0 }
|
||||
for (const row of rows) {
|
||||
const raw = row.source as string
|
||||
const source = raw === 'twikoo' ? 'api' : row.source
|
||||
if (source === 'web' || source === 'api') {
|
||||
if (source === 'web' || source === 'api' || source === 'admin') {
|
||||
result[source] += row.total
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { getDb, getUploadSourcesForKeys } from './db'
|
||||
import { getDataDir } from './data-dir'
|
||||
import { contentTypeFromKey } from './content-type'
|
||||
import { DEFAULT_FOLDER, toCanonicalImageKey, validateImageKey } from './image-key'
|
||||
import { buildTagFilterSql, type TagFilterInput } from './tags'
|
||||
import { isBareImageFilename, isHiddenImageDatePath } from './image-public-path'
|
||||
import {
|
||||
ensureDefaultBackends,
|
||||
@@ -406,7 +407,8 @@ export function updateImageBackendId(key: string, backendId: string): void {
|
||||
|
||||
export async function countImages(
|
||||
userFilter?: number | 'admin',
|
||||
backendId?: string
|
||||
backendId?: string,
|
||||
tagFilter?: TagFilterInput
|
||||
): Promise<number> {
|
||||
ensureStorageSchema()
|
||||
const params: Array<string | number> = []
|
||||
@@ -414,8 +416,9 @@ export async function countImages(
|
||||
|
||||
const userSql = buildUserFilterSql(userFilter, params)
|
||||
const backendSql = buildBackendFilterSql(backendId, params)
|
||||
const tagSql = buildTagFilterSql(tagFilter, params)
|
||||
const row = getDb().prepare(`
|
||||
SELECT COUNT(*) AS count FROM images WHERE ${clauses.join(' AND ')}${userSql}${backendSql}
|
||||
SELECT COUNT(*) AS count FROM images WHERE ${clauses.join(' AND ')}${userSql}${backendSql}${tagSql}
|
||||
`).get(...params) as { count: number }
|
||||
|
||||
return row.count
|
||||
@@ -462,6 +465,7 @@ export async function listImages(options: {
|
||||
backendId?: string
|
||||
contentType?: string
|
||||
uploadSource?: 'web' | 'api'
|
||||
tagFilter?: TagFilterInput
|
||||
}): Promise<PaginatedResult<StoredImage>> {
|
||||
ensureStorageSchema()
|
||||
const params: Array<string | number> = []
|
||||
@@ -469,10 +473,11 @@ export async function listImages(options: {
|
||||
|
||||
const userSql = buildUserFilterSql(options.userFilter, params)
|
||||
const backendSql = buildBackendFilterSql(options.backendId, params)
|
||||
const tagSql = buildTagFilterSql(options.tagFilter, 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}${backendSql}
|
||||
WHERE ${clauses.join(' AND ')}${userSql}${backendSql}${tagSql}
|
||||
ORDER BY uploaded_at DESC, key DESC
|
||||
`).all(...params) as unknown as ImageIndexRow[]
|
||||
|
||||
@@ -495,6 +500,7 @@ export async function searchImages(options: {
|
||||
backendId?: string
|
||||
contentType?: string
|
||||
uploadSource?: 'web' | 'api'
|
||||
tagFilter?: TagFilterInput
|
||||
}): Promise<PaginatedResult<StoredImage>> {
|
||||
ensureStorageSchema()
|
||||
const needle = options.query.trim().toLowerCase()
|
||||
@@ -503,10 +509,11 @@ export async function searchImages(options: {
|
||||
|
||||
const userSql = buildUserFilterSql(options.userFilter, params)
|
||||
const backendSql = buildBackendFilterSql(options.backendId, params)
|
||||
const tagSql = buildTagFilterSql(options.tagFilter, 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}${backendSql}
|
||||
WHERE ${clauses.join(' AND ')}${userSql}${backendSql}${tagSql}
|
||||
ORDER BY uploaded_at DESC, key DESC
|
||||
`).all(...params) as unknown as ImageIndexRow[]
|
||||
|
||||
@@ -528,16 +535,18 @@ export async function searchImages(options: {
|
||||
}
|
||||
|
||||
export async function getFolderStorageStats(
|
||||
userFilter?: number | 'admin'
|
||||
userFilter?: number | 'admin',
|
||||
tagFilter?: TagFilterInput
|
||||
): Promise<Array<{ folder: string, count: number, bytes: number }>> {
|
||||
ensureStorageSchema()
|
||||
const params: Array<string | number> = []
|
||||
const userSql = buildUserFilterSql(userFilter, params)
|
||||
const tagSql = buildTagFilterSql(tagFilter, params)
|
||||
|
||||
const rows = getDb().prepare(`
|
||||
SELECT folder, COUNT(*) AS count, COALESCE(SUM(size), 0) AS bytes
|
||||
FROM images
|
||||
WHERE 1=1${userSql}
|
||||
WHERE 1=1${userSql}${tagSql}
|
||||
GROUP BY folder
|
||||
ORDER BY bytes DESC, folder ASC
|
||||
`).all(...params) as Array<{ folder: string, count: number, bytes: number }>
|
||||
@@ -572,7 +581,10 @@ function startOfYesterdayIsoInShanghai(now = new Date()): string {
|
||||
return new Date(new Date(startOfDay).getTime() - 24 * 60 * 60 * 1000).toISOString()
|
||||
}
|
||||
|
||||
export async function getUserScopedStorageStats(userId: number): Promise<{
|
||||
export async function getUserScopedStorageStats(
|
||||
userId: number,
|
||||
tagFilter?: TagFilterInput
|
||||
): Promise<{
|
||||
storedCount: number
|
||||
uploadBytesTotal: number
|
||||
uploadToday: number
|
||||
@@ -587,11 +599,13 @@ export async function getUserScopedStorageStats(userId: number): Promise<{
|
||||
const startOfYesterday = startOfYesterdayIsoInShanghai()
|
||||
const startOfLastMonth = startOfLastMonthIsoInShanghai()
|
||||
|
||||
const params: Array<string | number> = [userId]
|
||||
const tagSql = buildTagFilterSql(tagFilter, params)
|
||||
const rows = getDb().prepare(`
|
||||
SELECT folder, size, uploaded_at
|
||||
FROM images
|
||||
WHERE user_id = ?
|
||||
`).all(userId) as Array<{ folder: string, size: number, uploaded_at: string }>
|
||||
WHERE user_id = ?${tagSql}
|
||||
`).all(...params) as Array<{ folder: string, size: number, uploaded_at: string }>
|
||||
|
||||
const map = new Map<string, { count: number, bytes: number }>()
|
||||
let uploadBytesTotal = 0
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { getStorageBackendRow } from './storage-backends'
|
||||
import { ALLOWED_MIME_TYPES } from './constants'
|
||||
import { parseTagIdsParam, type TagFilterInput } from './tags'
|
||||
|
||||
export function readBackendIdQuery(
|
||||
query: Record<string, unknown>
|
||||
@@ -26,3 +27,30 @@ export function readUploadSourceQuery(
|
||||
if (raw === 'web' || raw === 'api') return raw
|
||||
return null
|
||||
}
|
||||
|
||||
export function readTagFilterQuery(
|
||||
query: Record<string, unknown>
|
||||
): TagFilterInput | null | undefined {
|
||||
const untaggedRaw = query.untagged
|
||||
const untaggedOnly = untaggedRaw === '1'
|
||||
|| untaggedRaw === 'true'
|
||||
|| untaggedRaw === true
|
||||
|
||||
const tagIds = parseTagIdsParam(query.tagIds ?? query.tagId)
|
||||
const tagModeRaw = typeof query.tagMode === 'string' ? query.tagMode.trim().toLowerCase() : ''
|
||||
const tagMode = tagModeRaw === 'and' ? 'and' : 'or'
|
||||
|
||||
if (untaggedOnly && tagIds.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (!untaggedOnly && !tagIds.length) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return {
|
||||
tagIds: tagIds.length ? tagIds : undefined,
|
||||
tagMode,
|
||||
untaggedOnly: untaggedOnly || undefined
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,8 @@ import type { ImageItem } from '~/types/image'
|
||||
import type { LogSource } from './db'
|
||||
import type { AllowedMimeType } from './constants'
|
||||
import type { StoredImage } from './storage'
|
||||
import type { TagItem } from './tags'
|
||||
import { getTagsForImageKeys } from './tags'
|
||||
import { getImageBaseUrl, isHideFolderInUrl } from './env'
|
||||
import { toPublicImagePath } from './image-public-path'
|
||||
import { getActiveBackendRow } from './storage/resolver'
|
||||
@@ -10,6 +12,17 @@ import { getStorageBackendRow, getStorageBackendFromEnv } from './storage-backen
|
||||
import type { StorageBackendType, StorageBackendRow } from './storage/types'
|
||||
import { buildObjectKey, parseS3Config } from './storage/s3'
|
||||
|
||||
export function attachTagsToImageItems(items: ImageItem[]): ImageItem[] {
|
||||
if (!items.length) return items
|
||||
const tagMap = getTagsForImageKeys(items.map(item => item.key))
|
||||
return items.map(item => ({
|
||||
...item,
|
||||
tags: tagMap.get(item.key) ?? []
|
||||
}))
|
||||
}
|
||||
|
||||
export type { TagItem }
|
||||
|
||||
export function resolveImageOwner(
|
||||
userId: number | null | undefined,
|
||||
ownerMap?: Map<number, string>
|
||||
@@ -72,7 +85,7 @@ export function mapStoredImageToItem(
|
||||
...item,
|
||||
...(owner ? { owner } : {}),
|
||||
...(storage ? { storage } : {}),
|
||||
...(uploadSource ? { uploadSource } : {})
|
||||
...(uploadSource === 'web' || uploadSource === 'api' ? { uploadSource } : {})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -15,7 +15,8 @@ import type { LogSource } from './db'
|
||||
import { generateImageKey } from './image-key'
|
||||
import { buildImageItem, sanitizeOriginalName } from './image-response'
|
||||
import { getStorageLayout, getWebpQuality } from './env'
|
||||
import { insertActivityLog } from './db'
|
||||
import { logActivity } from './activity-log'
|
||||
import { attachTagsAfterUpload, getTagsForImageKeys } from './tags'
|
||||
import { putImage } from './storage'
|
||||
import { logInfo, logException } from './logger'
|
||||
|
||||
@@ -64,6 +65,7 @@ export async function processSingleImageUpload(
|
||||
filename?: string
|
||||
source?: LogSource
|
||||
userId?: number | null
|
||||
tagIds?: number[]
|
||||
}
|
||||
): Promise<{ item: ImageItem } | { error: UploadErrorItem }> {
|
||||
const originalName = sanitizeOriginalName(input.filename ?? 'image')
|
||||
@@ -149,16 +151,15 @@ export async function processSingleImageUpload(
|
||||
}
|
||||
}
|
||||
|
||||
insertActivityLog({
|
||||
logActivity(event, {
|
||||
action: 'upload',
|
||||
key,
|
||||
originalName,
|
||||
size: compressed.bytes.byteLength,
|
||||
contentType: compressed.mime,
|
||||
source: input.source ?? 'web',
|
||||
source: input.source,
|
||||
userId: input.userId ?? null,
|
||||
backendId,
|
||||
createdAt: uploadedAt
|
||||
backendId
|
||||
})
|
||||
|
||||
logInfo('upload success', {
|
||||
@@ -168,17 +169,26 @@ export async function processSingleImageUpload(
|
||||
source: input.source ?? 'web'
|
||||
})
|
||||
|
||||
if (input.tagIds?.length && input.userId != null) {
|
||||
attachTagsAfterUpload(key, input.tagIds, input.userId)
|
||||
}
|
||||
|
||||
const item = buildImageItem({
|
||||
key,
|
||||
event,
|
||||
originalName,
|
||||
contentType: compressed.mime,
|
||||
size: compressed.bytes.byteLength,
|
||||
uploadedAt
|
||||
})
|
||||
|
||||
const tags = getTagsForImageKeys([key]).get(key) ?? []
|
||||
|
||||
return {
|
||||
item: {
|
||||
...buildImageItem({
|
||||
key,
|
||||
event,
|
||||
originalName,
|
||||
contentType: compressed.mime,
|
||||
size: compressed.bytes.byteLength,
|
||||
uploadedAt
|
||||
}),
|
||||
uploadSource: input.source ?? 'web'
|
||||
...item,
|
||||
uploadSource: input.source === 'api' ? 'api' : 'web',
|
||||
...(tags.length ? { tags } : {})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+14
-6
@@ -19,6 +19,7 @@ import {
|
||||
searchImages as searchImagesFromIndex
|
||||
} from './image-index'
|
||||
import { DEFAULT_FOLDER, toCanonicalImageKey, validateImageKey } from './image-key'
|
||||
import type { TagFilterInput } from './tags'
|
||||
import {
|
||||
getActiveBackend,
|
||||
getBackendForKey
|
||||
@@ -171,15 +172,17 @@ export async function listImageKeys(): Promise<string[]> {
|
||||
|
||||
export async function countImages(
|
||||
userFilter?: number | 'admin',
|
||||
backendId?: string
|
||||
backendId?: string,
|
||||
tagFilter?: TagFilterInput
|
||||
): Promise<number> {
|
||||
return countImagesFromIndex(userFilter, backendId)
|
||||
return countImagesFromIndex(userFilter, backendId, tagFilter)
|
||||
}
|
||||
|
||||
export async function getFolderStorageStats(
|
||||
userFilter?: number | 'admin'
|
||||
userFilter?: number | 'admin',
|
||||
tagFilter?: TagFilterInput
|
||||
): Promise<FolderStorageStat[]> {
|
||||
return getFolderStorageStatsFromIndex(userFilter)
|
||||
return getFolderStorageStatsFromIndex(userFilter, tagFilter)
|
||||
}
|
||||
|
||||
export async function listFoldersForUser(
|
||||
@@ -188,7 +191,10 @@ export async function listFoldersForUser(
|
||||
return listFoldersForUserFromIndex(userFilter)
|
||||
}
|
||||
|
||||
export async function getUserScopedStorageStats(userId: number): Promise<{
|
||||
export async function getUserScopedStorageStats(
|
||||
userId: number,
|
||||
tagFilter?: TagFilterInput
|
||||
): Promise<{
|
||||
storedCount: number
|
||||
uploadBytesTotal: number
|
||||
uploadToday: number
|
||||
@@ -197,7 +203,7 @@ export async function getUserScopedStorageStats(userId: number): Promise<{
|
||||
uploadLastMonth: number
|
||||
byFolder: FolderStorageStat[]
|
||||
}> {
|
||||
return getUserScopedStorageStatsFromIndex(userId)
|
||||
return getUserScopedStorageStatsFromIndex(userId, tagFilter)
|
||||
}
|
||||
|
||||
export async function listImages(options: {
|
||||
@@ -207,6 +213,7 @@ export async function listImages(options: {
|
||||
backendId?: string
|
||||
contentType?: string
|
||||
uploadSource?: 'web' | 'api'
|
||||
tagFilter?: TagFilterInput
|
||||
}): Promise<PaginatedResult<StoredImage>> {
|
||||
return listImagesFromIndex(options)
|
||||
}
|
||||
@@ -219,6 +226,7 @@ export async function searchImages(options: {
|
||||
backendId?: string
|
||||
contentType?: string
|
||||
uploadSource?: 'web' | 'api'
|
||||
tagFilter?: TagFilterInput
|
||||
}): Promise<PaginatedResult<StoredImage>> {
|
||||
return searchImagesFromIndex(options)
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { H3Event } from 'h3'
|
||||
import { createApiError } from './api-error'
|
||||
|
||||
export function handleTagApiError(event: H3Event, error: unknown): never {
|
||||
if (error instanceof Error) {
|
||||
if (error.message === 'INVALID_TAG_NAME') {
|
||||
createApiError(event, 'INVALID_REQUEST', '标签名称无效(1–32 字)', 400)
|
||||
}
|
||||
if (error.message === 'INVALID_TAG_COLOR') {
|
||||
createApiError(event, 'INVALID_REQUEST', '标签颜色格式无效', 400)
|
||||
}
|
||||
if (error.message === 'TAG_EXISTS') {
|
||||
createApiError(event, 'CONFLICT', '标签已存在', 409)
|
||||
}
|
||||
if (error.message === 'TAG_LIMIT_REACHED') {
|
||||
createApiError(event, 'INVALID_REQUEST', '标签数量已达上限', 400)
|
||||
}
|
||||
if (error.message === 'TAG_NOT_FOUND') {
|
||||
createApiError(event, 'INVALID_REQUEST', '标签不存在', 404)
|
||||
}
|
||||
if (error.message === 'IMAGE_NOT_FOUND') {
|
||||
createApiError(event, 'IMAGE_NOT_FOUND', '图片不存在', 404)
|
||||
}
|
||||
if (error.message === 'FORBIDDEN') {
|
||||
createApiError(event, 'FORBIDDEN', '无权操作', 403)
|
||||
}
|
||||
}
|
||||
throw error
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
buildTagFilterSql,
|
||||
isValidTagColor,
|
||||
isValidTagName,
|
||||
normalizeTagName,
|
||||
parseTagIdsParam
|
||||
} from './tags'
|
||||
|
||||
describe('normalizeTagName', () => {
|
||||
it('trims and collapses whitespace', () => {
|
||||
expect(normalizeTagName(' 工作 截图 ')).toBe('工作 截图')
|
||||
})
|
||||
})
|
||||
|
||||
describe('isValidTagName', () => {
|
||||
it('accepts 1-32 char names', () => {
|
||||
expect(isValidTagName('工作')).toBe(true)
|
||||
expect(isValidTagName('a'.repeat(32))).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects empty or too long names', () => {
|
||||
expect(isValidTagName('')).toBe(false)
|
||||
expect(isValidTagName(' ')).toBe(false)
|
||||
expect(isValidTagName('a'.repeat(33))).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isValidTagColor', () => {
|
||||
it('accepts hex colors and empty', () => {
|
||||
expect(isValidTagColor('#22c55e')).toBe(true)
|
||||
expect(isValidTagColor(null)).toBe(true)
|
||||
expect(isValidTagColor('')).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects invalid colors', () => {
|
||||
expect(isValidTagColor('red')).toBe(false)
|
||||
expect(isValidTagColor('#abc')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseTagIdsParam', () => {
|
||||
it('parses comma-separated and array values', () => {
|
||||
expect(parseTagIdsParam('1,2,3')).toEqual([1, 2, 3])
|
||||
expect(parseTagIdsParam(['4', '5'])).toEqual([4, 5])
|
||||
})
|
||||
|
||||
it('deduplicates and skips invalid entries', () => {
|
||||
expect(parseTagIdsParam('1,1,abc,0,-1')).toEqual([1])
|
||||
expect(parseTagIdsParam('')).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildTagFilterSql', () => {
|
||||
it('returns empty sql without filter', () => {
|
||||
const params: Array<string | number> = []
|
||||
expect(buildTagFilterSql(undefined, params)).toBe('')
|
||||
expect(params).toEqual([])
|
||||
})
|
||||
|
||||
it('filters untagged images', () => {
|
||||
const params: Array<string | number> = []
|
||||
const sql = buildTagFilterSql({ untaggedOnly: true }, params)
|
||||
expect(sql).toContain('NOT IN')
|
||||
expect(params).toEqual([])
|
||||
})
|
||||
|
||||
it('filters by tag ids with OR semantics', () => {
|
||||
const params: Array<string | number> = []
|
||||
const sql = buildTagFilterSql({ tagIds: [2, 5] }, params)
|
||||
expect(sql).toContain('tag_id IN')
|
||||
expect(params).toEqual([2, 5])
|
||||
})
|
||||
|
||||
it('filters by tag ids with AND semantics', () => {
|
||||
const params: Array<string | number> = []
|
||||
const sql = buildTagFilterSql({ tagIds: [2, 5], tagMode: 'and' }, params)
|
||||
expect(sql).toContain('HAVING COUNT(DISTINCT tag_id) = 2')
|
||||
expect(params).toEqual([2, 5])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,473 @@
|
||||
import { getDb } from './db'
|
||||
import { getImageIndexRow } from './image-index'
|
||||
|
||||
export const TAG_COLOR_PRESETS = [
|
||||
'#22c55e',
|
||||
'#3b82f6',
|
||||
'#f59e0b',
|
||||
'#ef4444',
|
||||
'#8b5cf6',
|
||||
'#06b6d4',
|
||||
'#ec4899',
|
||||
'#84cc16',
|
||||
'#f97316',
|
||||
'#6366f1',
|
||||
'#14b8a6',
|
||||
'#64748b'
|
||||
] as const
|
||||
|
||||
export const MAX_TAGS_PER_USER = 50
|
||||
export const MAX_TAG_NAME_LENGTH = 32
|
||||
|
||||
export interface TagRow {
|
||||
id: number
|
||||
user_id: number
|
||||
name: string
|
||||
color: string | null
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface TagItem {
|
||||
id: number
|
||||
name: string
|
||||
color: string
|
||||
imageCount: number
|
||||
createdAt: string
|
||||
lastUsedAt: string | null
|
||||
}
|
||||
|
||||
export function normalizeTagName(name: string): string {
|
||||
return name.trim().replace(/\s+/g, ' ')
|
||||
}
|
||||
|
||||
export function isValidTagName(name: string): boolean {
|
||||
const normalized = normalizeTagName(name)
|
||||
return normalized.length >= 1 && normalized.length <= MAX_TAG_NAME_LENGTH
|
||||
}
|
||||
|
||||
export function isValidTagColor(color: string | null | undefined): boolean {
|
||||
if (color == null || color === '') return true
|
||||
return /^#[0-9A-Fa-f]{6}$/.test(color)
|
||||
}
|
||||
|
||||
export function normalizeStoredTagColor(color: string | null | undefined): string | null {
|
||||
if (color == null) return null
|
||||
const trimmed = color.trim()
|
||||
if (!trimmed) return null
|
||||
const withHash = trimmed.startsWith('#') ? trimmed : `#${trimmed}`
|
||||
return /^#[0-9A-Fa-f]{6}$/.test(withHash) ? withHash.toLowerCase() : null
|
||||
}
|
||||
|
||||
export function defaultTagColor(tagId: number): string {
|
||||
return TAG_COLOR_PRESETS[tagId % TAG_COLOR_PRESETS.length]!
|
||||
}
|
||||
|
||||
export function ensureTagSchema(): void {
|
||||
const db = getDb()
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS tags (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
color TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
UNIQUE(user_id, name),
|
||||
FOREIGN KEY (user_id) REFERENCES users(id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_tags_user_id ON tags(user_id);
|
||||
CREATE TABLE IF NOT EXISTS image_tags (
|
||||
image_key TEXT NOT NULL,
|
||||
tag_id INTEGER NOT NULL,
|
||||
PRIMARY KEY (image_key, tag_id),
|
||||
FOREIGN KEY (image_key) REFERENCES images(key) ON DELETE CASCADE,
|
||||
FOREIGN KEY (tag_id) REFERENCES tags(id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_image_tags_tag_id ON image_tags(tag_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_image_tags_image_key ON image_tags(image_key);
|
||||
`)
|
||||
}
|
||||
|
||||
function rowToTagItem(
|
||||
row: TagRow,
|
||||
imageCount: number,
|
||||
lastUsedAt: string | null = null
|
||||
): TagItem {
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
color: normalizeStoredTagColor(row.color) ?? defaultTagColor(row.id),
|
||||
imageCount,
|
||||
createdAt: row.created_at,
|
||||
lastUsedAt
|
||||
}
|
||||
}
|
||||
|
||||
function countImagesForTag(tagId: number): number {
|
||||
const row = getDb().prepare(`
|
||||
SELECT COUNT(*) AS count FROM image_tags WHERE tag_id = ?
|
||||
`).get(tagId) as { count: number }
|
||||
return row.count
|
||||
}
|
||||
|
||||
export function listTagsForUser(userId: number): TagItem[] {
|
||||
ensureTagSchema()
|
||||
const rows = getDb().prepare(`
|
||||
SELECT t.id, t.user_id, t.name, t.color, t.created_at,
|
||||
COUNT(it.image_key) AS image_count,
|
||||
MAX(i.uploaded_at) AS last_used_at
|
||||
FROM tags t
|
||||
LEFT JOIN image_tags it ON it.tag_id = t.id
|
||||
LEFT JOIN images i ON i.key = it.image_key
|
||||
WHERE t.user_id = ?
|
||||
GROUP BY t.id
|
||||
ORDER BY t.name ASC
|
||||
`).all(userId) as unknown as Array<TagRow & { image_count: number, last_used_at: string | null }>
|
||||
|
||||
return rows.map(row => rowToTagItem(row, row.image_count, row.last_used_at))
|
||||
}
|
||||
|
||||
export function getTagById(tagId: number): TagRow | null {
|
||||
ensureTagSchema()
|
||||
return getDb().prepare(`
|
||||
SELECT id, user_id, name, color, created_at FROM tags WHERE id = ?
|
||||
`).get(tagId) as TagRow | undefined ?? null
|
||||
}
|
||||
|
||||
export function getTagForUser(userId: number, tagId: number): TagRow | null {
|
||||
const row = getTagById(tagId)
|
||||
if (!row || row.user_id !== userId) return null
|
||||
return row
|
||||
}
|
||||
|
||||
export function findTagByName(userId: number, name: string): TagRow | null {
|
||||
ensureTagSchema()
|
||||
const normalized = normalizeTagName(name)
|
||||
return getDb().prepare(`
|
||||
SELECT id, user_id, name, color, created_at
|
||||
FROM tags
|
||||
WHERE user_id = ? AND name = ?
|
||||
`).get(userId, normalized) as TagRow | undefined ?? null
|
||||
}
|
||||
|
||||
export function createTag(
|
||||
userId: number,
|
||||
name: string,
|
||||
color?: string | null
|
||||
): TagItem {
|
||||
ensureTagSchema()
|
||||
const normalized = normalizeTagName(name)
|
||||
if (!isValidTagName(normalized)) {
|
||||
throw new Error('INVALID_TAG_NAME')
|
||||
}
|
||||
if (color != null && color !== '' && !isValidTagColor(color)) {
|
||||
throw new Error('INVALID_TAG_COLOR')
|
||||
}
|
||||
|
||||
const existing = findTagByName(userId, normalized)
|
||||
if (existing) {
|
||||
throw new Error('TAG_EXISTS')
|
||||
}
|
||||
|
||||
const count = getDb().prepare(`
|
||||
SELECT COUNT(*) AS count FROM tags WHERE user_id = ?
|
||||
`).get(userId) as { count: number }
|
||||
if (count.count >= MAX_TAGS_PER_USER) {
|
||||
throw new Error('TAG_LIMIT_REACHED')
|
||||
}
|
||||
|
||||
const createdAt = new Date().toISOString()
|
||||
const storedColor = normalizeStoredTagColor(color)
|
||||
const result = getDb().prepare(`
|
||||
INSERT INTO tags (user_id, name, color, created_at)
|
||||
VALUES (?, ?, ?, ?)
|
||||
`).run(userId, normalized, storedColor, createdAt)
|
||||
|
||||
const id = Number(result.lastInsertRowid)
|
||||
const row = getTagById(id)!
|
||||
return rowToTagItem(row, 0)
|
||||
}
|
||||
|
||||
export function updateTag(
|
||||
userId: number,
|
||||
tagId: number,
|
||||
patch: { name?: string, color?: string | null }
|
||||
): TagItem {
|
||||
ensureTagSchema()
|
||||
const row = getTagForUser(userId, tagId)
|
||||
if (!row) {
|
||||
throw new Error('TAG_NOT_FOUND')
|
||||
}
|
||||
|
||||
let nextName = row.name
|
||||
if (patch.name !== undefined) {
|
||||
const normalized = normalizeTagName(patch.name)
|
||||
if (!isValidTagName(normalized)) {
|
||||
throw new Error('INVALID_TAG_NAME')
|
||||
}
|
||||
if (normalized !== row.name) {
|
||||
const duplicate = findTagByName(userId, normalized)
|
||||
if (duplicate && duplicate.id !== tagId) {
|
||||
throw new Error('TAG_EXISTS')
|
||||
}
|
||||
nextName = normalized
|
||||
}
|
||||
}
|
||||
|
||||
let nextColor = row.color
|
||||
if (patch.color !== undefined) {
|
||||
if (patch.color !== null && patch.color !== '' && !isValidTagColor(patch.color)) {
|
||||
throw new Error('INVALID_TAG_COLOR')
|
||||
}
|
||||
nextColor = normalizeStoredTagColor(patch.color)
|
||||
}
|
||||
|
||||
getDb().prepare(`
|
||||
UPDATE tags SET name = ?, color = ? WHERE id = ?
|
||||
`).run(nextName, nextColor, tagId)
|
||||
|
||||
const updated = getTagById(tagId)!
|
||||
return rowToTagItem(updated, countImagesForTag(tagId))
|
||||
}
|
||||
|
||||
export function deleteTag(userId: number, tagId: number): boolean {
|
||||
ensureTagSchema()
|
||||
const row = getTagForUser(userId, tagId)
|
||||
if (!row) return false
|
||||
getDb().prepare('DELETE FROM tags WHERE id = ?').run(tagId)
|
||||
return true
|
||||
}
|
||||
|
||||
export function mergeTags(
|
||||
userId: number,
|
||||
sourceIds: number[],
|
||||
targetId: number
|
||||
): TagItem {
|
||||
ensureTagSchema()
|
||||
const uniqueSources = [...new Set(sourceIds.filter(id => id !== targetId))]
|
||||
const target = getTagForUser(userId, targetId)
|
||||
if (!target) {
|
||||
throw new Error('TAG_NOT_FOUND')
|
||||
}
|
||||
|
||||
for (const sourceId of uniqueSources) {
|
||||
const source = getTagForUser(userId, sourceId)
|
||||
if (!source) {
|
||||
throw new Error('TAG_NOT_FOUND')
|
||||
}
|
||||
}
|
||||
|
||||
const db = getDb()
|
||||
db.exec('BEGIN')
|
||||
try {
|
||||
for (const sourceId of uniqueSources) {
|
||||
db.prepare(`
|
||||
INSERT OR IGNORE INTO image_tags (image_key, tag_id)
|
||||
SELECT image_key, ? FROM image_tags WHERE tag_id = ?
|
||||
`).run(targetId, sourceId)
|
||||
db.prepare('DELETE FROM image_tags WHERE tag_id = ?').run(sourceId)
|
||||
db.prepare('DELETE FROM tags WHERE id = ?').run(sourceId)
|
||||
}
|
||||
db.exec('COMMIT')
|
||||
} catch (error) {
|
||||
db.exec('ROLLBACK')
|
||||
throw error
|
||||
}
|
||||
|
||||
const updated = getTagById(targetId)!
|
||||
return rowToTagItem(updated, countImagesForTag(targetId))
|
||||
}
|
||||
|
||||
export function getTagsForImageKeys(keys: string[]): Map<string, TagItem[]> {
|
||||
ensureTagSchema()
|
||||
const map = new Map<string, TagItem[]>()
|
||||
if (!keys.length) return map
|
||||
|
||||
const placeholders = keys.map(() => '?').join(', ')
|
||||
const rows = getDb().prepare(`
|
||||
SELECT it.image_key, t.id, t.user_id, t.name, t.color, t.created_at
|
||||
FROM image_tags it
|
||||
INNER JOIN tags t ON t.id = it.tag_id
|
||||
WHERE it.image_key IN (${placeholders})
|
||||
ORDER BY t.name ASC
|
||||
`).all(...keys) as unknown as Array<TagRow & { image_key: string }>
|
||||
|
||||
for (const row of rows) {
|
||||
const list = map.get(row.image_key) ?? []
|
||||
list.push(rowToTagItem(row, 0))
|
||||
map.set(row.image_key, list)
|
||||
}
|
||||
return map
|
||||
}
|
||||
|
||||
function assertTagsOwnedByUser(userId: number, tagIds: number[]): void {
|
||||
if (!tagIds.length) return
|
||||
const placeholders = tagIds.map(() => '?').join(', ')
|
||||
const rows = getDb().prepare(`
|
||||
SELECT id FROM tags WHERE user_id = ? AND id IN (${placeholders})
|
||||
`).all(userId, ...tagIds) as Array<{ id: number }>
|
||||
if (rows.length !== tagIds.length) {
|
||||
throw new Error('TAG_NOT_FOUND')
|
||||
}
|
||||
}
|
||||
|
||||
function resolveImageOwnerUserId(key: string): number | null {
|
||||
const row = getImageIndexRow(key)
|
||||
return row?.user_id ?? null
|
||||
}
|
||||
|
||||
export function assertCanTagImage(key: string, actorUserId: number, isAdmin: boolean): void {
|
||||
const ownerId = resolveImageOwnerUserId(key)
|
||||
if (ownerId == null) {
|
||||
throw new Error('IMAGE_NOT_FOUND')
|
||||
}
|
||||
if (!isAdmin && ownerId !== actorUserId) {
|
||||
throw new Error('FORBIDDEN')
|
||||
}
|
||||
}
|
||||
|
||||
export function addTagsToImage(
|
||||
key: string,
|
||||
tagIds: number[],
|
||||
actorUserId: number,
|
||||
isAdmin: boolean
|
||||
): TagItem[] {
|
||||
ensureTagSchema()
|
||||
assertCanTagImage(key, actorUserId, isAdmin)
|
||||
const ownerId = resolveImageOwnerUserId(key)!
|
||||
const uniqueTagIds = [...new Set(tagIds)]
|
||||
assertTagsOwnedByUser(ownerId, uniqueTagIds)
|
||||
|
||||
const insert = getDb().prepare(`
|
||||
INSERT OR IGNORE INTO image_tags (image_key, tag_id) VALUES (?, ?)
|
||||
`)
|
||||
for (const tagId of uniqueTagIds) {
|
||||
insert.run(key, tagId)
|
||||
}
|
||||
|
||||
return getTagsForImageKeys([key]).get(key) ?? []
|
||||
}
|
||||
|
||||
export function removeTagFromImage(
|
||||
key: string,
|
||||
tagId: number,
|
||||
actorUserId: number,
|
||||
isAdmin: boolean
|
||||
): TagItem[] {
|
||||
ensureTagSchema()
|
||||
assertCanTagImage(key, actorUserId, isAdmin)
|
||||
const ownerId = resolveImageOwnerUserId(key)!
|
||||
assertTagsOwnedByUser(ownerId, [tagId])
|
||||
|
||||
getDb().prepare(`
|
||||
DELETE FROM image_tags WHERE image_key = ? AND tag_id = ?
|
||||
`).run(key, tagId)
|
||||
|
||||
return getTagsForImageKeys([key]).get(key) ?? []
|
||||
}
|
||||
|
||||
export function setImageTags(
|
||||
key: string,
|
||||
tagIds: number[],
|
||||
actorUserId: number,
|
||||
isAdmin: boolean
|
||||
): TagItem[] {
|
||||
ensureTagSchema()
|
||||
assertCanTagImage(key, actorUserId, isAdmin)
|
||||
const ownerId = resolveImageOwnerUserId(key)!
|
||||
const uniqueTagIds = [...new Set(tagIds)]
|
||||
assertTagsOwnedByUser(ownerId, uniqueTagIds)
|
||||
|
||||
const db = getDb()
|
||||
db.exec('BEGIN')
|
||||
try {
|
||||
db.prepare('DELETE FROM image_tags WHERE image_key = ?').run(key)
|
||||
const insert = db.prepare(`
|
||||
INSERT INTO image_tags (image_key, tag_id) VALUES (?, ?)
|
||||
`)
|
||||
for (const tagId of uniqueTagIds) {
|
||||
insert.run(key, tagId)
|
||||
}
|
||||
db.exec('COMMIT')
|
||||
} catch (error) {
|
||||
db.exec('ROLLBACK')
|
||||
throw error
|
||||
}
|
||||
|
||||
return getTagsForImageKeys([key]).get(key) ?? []
|
||||
}
|
||||
|
||||
export function attachTagsAfterUpload(
|
||||
key: string,
|
||||
tagIds: number[],
|
||||
userId: number | null
|
||||
): void {
|
||||
if (!tagIds.length || userId == null) return
|
||||
addTagsToImage(key, tagIds, userId, true)
|
||||
}
|
||||
|
||||
export function batchAddTagsToImages(
|
||||
keys: string[],
|
||||
tagIds: number[],
|
||||
actorUserId: number,
|
||||
isAdmin: boolean
|
||||
): number {
|
||||
ensureTagSchema()
|
||||
let updated = 0
|
||||
for (const key of keys) {
|
||||
addTagsToImage(key, tagIds, actorUserId, isAdmin)
|
||||
updated++
|
||||
}
|
||||
return updated
|
||||
}
|
||||
|
||||
export interface TagFilterInput {
|
||||
tagIds?: number[]
|
||||
tagMode?: 'or' | 'and'
|
||||
untaggedOnly?: boolean
|
||||
}
|
||||
|
||||
export function buildTagFilterSql(
|
||||
filter: TagFilterInput | undefined,
|
||||
params: Array<string | number>
|
||||
): string {
|
||||
if (!filter?.untaggedOnly && !filter?.tagIds?.length) {
|
||||
return ''
|
||||
}
|
||||
|
||||
if (filter.untaggedOnly) {
|
||||
return ` AND images.key NOT IN (SELECT image_key FROM image_tags)`
|
||||
}
|
||||
|
||||
const tagIds = filter.tagIds!
|
||||
const placeholders = tagIds.map(() => '?').join(', ')
|
||||
params.push(...tagIds)
|
||||
|
||||
if (filter.tagMode === 'and' && tagIds.length > 1) {
|
||||
return ` AND images.key IN (
|
||||
SELECT image_key FROM image_tags
|
||||
WHERE tag_id IN (${placeholders})
|
||||
GROUP BY image_key
|
||||
HAVING COUNT(DISTINCT tag_id) = ${tagIds.length}
|
||||
)`
|
||||
}
|
||||
|
||||
return ` AND images.key IN (
|
||||
SELECT image_key FROM image_tags WHERE tag_id IN (${placeholders})
|
||||
)`
|
||||
}
|
||||
|
||||
export function parseTagIdsParam(raw: unknown): number[] {
|
||||
if (raw == null || raw === '') return []
|
||||
const parts = Array.isArray(raw)
|
||||
? raw.flatMap(item => String(item).split(','))
|
||||
: String(raw).split(',')
|
||||
const ids: number[] = []
|
||||
for (const part of parts) {
|
||||
const trimmed = part.trim()
|
||||
if (!trimmed || trimmed === 'all') continue
|
||||
const id = Number(trimmed)
|
||||
if (!Number.isInteger(id) || id <= 0) continue
|
||||
ids.push(id)
|
||||
}
|
||||
return [...new Set(ids)]
|
||||
}
|
||||
Reference in new issue
Block a user