用户管理与数据操作
This commit is contained in:
1 parent
ed0f20b754
commit
51d3acfbb5
14 files changed
+256
-33
No files matched your search
@@ -7,6 +7,7 @@ interface AdminUser {
|
|||||||
role: 'admin' | 'user'
|
role: 'admin' | 'user'
|
||||||
created_at: string
|
created_at: string
|
||||||
password_reset: number
|
password_reset: number
|
||||||
|
disabled_at: string | null
|
||||||
usage?: {
|
usage?: {
|
||||||
imageBytes: number
|
imageBytes: number
|
||||||
videoBytes: number
|
videoBytes: number
|
||||||
@@ -31,6 +32,7 @@ const loading = ref(false)
|
|||||||
const deleteTarget = ref<AdminUser | null>(null)
|
const deleteTarget = ref<AdminUser | null>(null)
|
||||||
const deleteConfirm = ref('')
|
const deleteConfirm = ref('')
|
||||||
const deleting = ref(false)
|
const deleting = ref(false)
|
||||||
|
const statusBusyId = ref<number | null>(null)
|
||||||
|
|
||||||
const canDelete = computed(() =>
|
const canDelete = computed(() =>
|
||||||
Boolean(
|
Boolean(
|
||||||
@@ -76,6 +78,34 @@ async function clearPassword(user: AdminUser) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function toggleDisabled(user: AdminUser) {
|
||||||
|
if (user.role === 'admin' || statusBusyId.value !== null) return
|
||||||
|
const disabled = !user.disabled_at
|
||||||
|
statusBusyId.value = user.id
|
||||||
|
try {
|
||||||
|
await $fetch(`/api/admin/users/${user.id}/status`, {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
|
body: { disabled }
|
||||||
|
})
|
||||||
|
toast.add({
|
||||||
|
title: t(disabled ? 'users.disableSuccess' : 'users.enableSuccess', {
|
||||||
|
name: user.username
|
||||||
|
}),
|
||||||
|
color: 'success'
|
||||||
|
})
|
||||||
|
await loadUsers()
|
||||||
|
} catch (error: unknown) {
|
||||||
|
handleAuthError(error)
|
||||||
|
toast.add({
|
||||||
|
title: getFetchErrorMessage(error, t('users.statusFailed')),
|
||||||
|
color: 'error'
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
statusBusyId.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function openDeleteDialog(user: AdminUser) {
|
function openDeleteDialog(user: AdminUser) {
|
||||||
if (user.role === 'admin') return
|
if (user.role === 'admin') return
|
||||||
deleteTarget.value = user
|
deleteTarget.value = user
|
||||||
@@ -226,6 +256,14 @@ onMounted(async () => {
|
|||||||
v-if="user.role !== 'admin'"
|
v-if="user.role !== 'admin'"
|
||||||
class="flex items-center gap-2"
|
class="flex items-center gap-2"
|
||||||
>
|
>
|
||||||
|
<UBadge
|
||||||
|
v-if="user.disabled_at"
|
||||||
|
color="error"
|
||||||
|
variant="subtle"
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
{{ t('users.disabled') }}
|
||||||
|
</UBadge>
|
||||||
<UBadge
|
<UBadge
|
||||||
v-if="user.password_reset === 1"
|
v-if="user.password_reset === 1"
|
||||||
color="warning"
|
color="warning"
|
||||||
@@ -234,6 +272,15 @@ onMounted(async () => {
|
|||||||
>
|
>
|
||||||
{{ t('users.resetPending') }}
|
{{ t('users.resetPending') }}
|
||||||
</UBadge>
|
</UBadge>
|
||||||
|
<UButton
|
||||||
|
variant="outline"
|
||||||
|
:color="user.disabled_at ? 'primary' : 'warning'"
|
||||||
|
size="sm"
|
||||||
|
:label="user.disabled_at ? t('users.enable') : t('users.disable')"
|
||||||
|
:loading="statusBusyId === user.id"
|
||||||
|
:disabled="statusBusyId !== null && statusBusyId !== user.id"
|
||||||
|
@click="toggleDisabled(user)"
|
||||||
|
/>
|
||||||
<UButton
|
<UButton
|
||||||
v-if="user.password_reset !== 1"
|
v-if="user.password_reset !== 1"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
|
|||||||
@@ -1283,12 +1283,18 @@
|
|||||||
,
|
,
|
||||||
"users": {
|
"users": {
|
||||||
"title": "Users",
|
"title": "Users",
|
||||||
"subtitle": "Review accounts, clear passwords, or delete users",
|
"subtitle": "Review accounts, clear passwords, disable, or delete users",
|
||||||
"adminOnly": "Admins only",
|
"adminOnly": "Admins only",
|
||||||
"roleAdmin": "Admin",
|
"roleAdmin": "Admin",
|
||||||
"roleUser": "User",
|
"roleUser": "User",
|
||||||
"clearPassword": "Clear password",
|
"clearPassword": "Clear password",
|
||||||
"deleteUser": "Delete user",
|
"deleteUser": "Delete user and clear all data",
|
||||||
|
"disable": "Disable",
|
||||||
|
"enable": "Enable",
|
||||||
|
"disabled": "Disabled",
|
||||||
|
"disableSuccess": "Disabled user {name}",
|
||||||
|
"enableSuccess": "Enabled user {name}",
|
||||||
|
"statusFailed": "Failed to update account status",
|
||||||
"usageImages": "Images",
|
"usageImages": "Images",
|
||||||
"usageVideos": "Videos",
|
"usageVideos": "Videos",
|
||||||
"usageFiles": "Files",
|
"usageFiles": "Files",
|
||||||
@@ -1301,7 +1307,7 @@
|
|||||||
"deleteSubmit": "Delete user",
|
"deleteSubmit": "Delete user",
|
||||||
"deleteFailed": "Delete failed",
|
"deleteFailed": "Delete failed",
|
||||||
"userDeleted": "Deleted user {name}",
|
"userDeleted": "Deleted user {name}",
|
||||||
"adminProtected": "Admin accounts cannot be cleared or deleted",
|
"adminProtected": "Admin accounts cannot be cleared, disabled, or deleted",
|
||||||
"loadFailed": "Failed to load users"
|
"loadFailed": "Failed to load users"
|
||||||
},
|
},
|
||||||
"reset": {
|
"reset": {
|
||||||
|
|||||||
@@ -1286,12 +1286,18 @@
|
|||||||
,
|
,
|
||||||
"users": {
|
"users": {
|
||||||
"title": "用户管理",
|
"title": "用户管理",
|
||||||
"subtitle": "查看账号、清除密码或注销用户",
|
"subtitle": "查看账号、清除密码、禁用或注销用户",
|
||||||
"adminOnly": "仅管理员可访问此页面",
|
"adminOnly": "仅管理员可访问此页面",
|
||||||
"roleAdmin": "管理员",
|
"roleAdmin": "管理员",
|
||||||
"roleUser": "普通用户",
|
"roleUser": "普通用户",
|
||||||
"clearPassword": "清除密码",
|
"clearPassword": "清除密码",
|
||||||
"deleteUser": "注销用户",
|
"deleteUser": "注销该用户并清除所有数据",
|
||||||
|
"disable": "禁用",
|
||||||
|
"enable": "启用",
|
||||||
|
"disabled": "已禁用",
|
||||||
|
"disableSuccess": "已禁用用户 {name}",
|
||||||
|
"enableSuccess": "已启用用户 {name}",
|
||||||
|
"statusFailed": "账号状态修改失败",
|
||||||
"usageImages": "图片",
|
"usageImages": "图片",
|
||||||
"usageVideos": "视频",
|
"usageVideos": "视频",
|
||||||
"usageFiles": "文件",
|
"usageFiles": "文件",
|
||||||
@@ -1304,7 +1310,7 @@
|
|||||||
"deleteSubmit": "确认注销",
|
"deleteSubmit": "确认注销",
|
||||||
"deleteFailed": "注销失败",
|
"deleteFailed": "注销失败",
|
||||||
"userDeleted": "已注销用户 {name}",
|
"userDeleted": "已注销用户 {name}",
|
||||||
"adminProtected": "管理员账号不可清除密码或注销",
|
"adminProtected": "管理员账号不可清除密码、禁用或注销",
|
||||||
"loadFailed": "读取用户列表失败"
|
"loadFailed": "读取用户列表失败"
|
||||||
},
|
},
|
||||||
"reset": {
|
"reset": {
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { requireAdminAuth } from '../../../../utils/access'
|
||||||
|
import { createApiError } from '../../../../utils/api-error'
|
||||||
|
import { logActivity } from '../../../../utils/activity-log'
|
||||||
|
import { findUserById, setUserDisabled } from '../../../../utils/db'
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
await requireAdminAuth(event)
|
||||||
|
|
||||||
|
const id = Number(getRouterParam(event, 'id'))
|
||||||
|
const target = Number.isInteger(id) ? findUserById(id) : null
|
||||||
|
if (!target) {
|
||||||
|
createApiError(event, 'INVALID_REQUEST', '用户不存在', 404)
|
||||||
|
}
|
||||||
|
if (target.role === 'admin') {
|
||||||
|
createApiError(event, 'FORBIDDEN', '不允许禁用管理员账号', 403)
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await readBody<{ disabled?: unknown }>(event).catch(() => null)
|
||||||
|
if (typeof body?.disabled !== 'boolean') {
|
||||||
|
createApiError(event, 'INVALID_REQUEST', '无效的账号状态', 400)
|
||||||
|
}
|
||||||
|
|
||||||
|
setUserDisabled(target.id, body.disabled)
|
||||||
|
logActivity(event, {
|
||||||
|
action: 'settings',
|
||||||
|
key: `users/${target.id}/status`,
|
||||||
|
originalName: target.username,
|
||||||
|
source: 'admin'
|
||||||
|
})
|
||||||
|
|
||||||
|
return { success: true, disabled: body.disabled }
|
||||||
|
})
|
||||||
@@ -57,6 +57,11 @@ export default defineEventHandler(async (event) => {
|
|||||||
|
|
||||||
const user = findUserByUsername(username)
|
const user = findUserByUsername(username)
|
||||||
|
|
||||||
|
if (user?.disabled_at) {
|
||||||
|
logWarn('login failed: user disabled', { ip, username })
|
||||||
|
createApiError(event, 'FORBIDDEN', '该账号已被禁用', 403)
|
||||||
|
}
|
||||||
|
|
||||||
// 管理员已清除密码:下次登录时直接进入设置新密码流程
|
// 管理员已清除密码:下次登录时直接进入设置新密码流程
|
||||||
if (user && isPasswordResetRequired(user.id)) {
|
if (user && isPasswordResetRequired(user.id)) {
|
||||||
return { success: false, requiresPasswordReset: true, username: user.username }
|
return { success: false, requiresPasswordReset: true, username: user.username }
|
||||||
|
|||||||
@@ -8,13 +8,15 @@ function canOperateImage(
|
|||||||
actor: { type: 'user', id: number, role: 'user' | 'admin' }
|
actor: { type: 'user', id: number, role: 'user' | 'admin' }
|
||||||
| { type: 'user-token', id: number }
|
| { type: 'user-token', id: number }
|
||||||
| { type: 'global-token' },
|
| { type: 'global-token' },
|
||||||
ownerId: number | null
|
ownerId: number | null,
|
||||||
|
primaryAdminId = 1
|
||||||
): boolean {
|
): boolean {
|
||||||
if (actor.type === 'global-token') return true
|
if (actor.type === 'global-token') {
|
||||||
|
return ownerId !== null && ownerId === primaryAdminId
|
||||||
|
}
|
||||||
if (actor.type === 'user-token') {
|
if (actor.type === 'user-token') {
|
||||||
return ownerId === actor.id
|
return ownerId === actor.id
|
||||||
}
|
}
|
||||||
if (actor.role === 'admin') return true
|
|
||||||
return ownerId !== null && ownerId === actor.id
|
return ownerId !== null && ownerId === actor.id
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -26,10 +28,11 @@ describe('image ownership rules', () => {
|
|||||||
expect(canOperateImage(user1, null)).toBe(false)
|
expect(canOperateImage(user1, null)).toBe(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('admin can operate any image', () => {
|
it('admin can only operate own images', () => {
|
||||||
const admin = { type: 'user' as const, id: 1, role: 'admin' as const }
|
const admin = { type: 'user' as const, id: 1, role: 'admin' as const }
|
||||||
expect(canOperateImage(admin, 2)).toBe(true)
|
expect(canOperateImage(admin, 1)).toBe(true)
|
||||||
expect(canOperateImage(admin, null)).toBe(true)
|
expect(canOperateImage(admin, 2)).toBe(false)
|
||||||
|
expect(canOperateImage(admin, null)).toBe(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('user API token is scoped to owner', () => {
|
it('user API token is scoped to owner', () => {
|
||||||
@@ -39,10 +42,11 @@ describe('image ownership rules', () => {
|
|||||||
expect(canOperateImage(token, null)).toBe(false)
|
expect(canOperateImage(token, null)).toBe(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('global API token can operate any image', () => {
|
it('global API token is scoped to the primary admin owner', () => {
|
||||||
const token = { type: 'global-token' as const }
|
const token = { type: 'global-token' as const }
|
||||||
expect(canOperateImage(token, 1)).toBe(true)
|
expect(canOperateImage(token, 1)).toBe(true)
|
||||||
expect(canOperateImage(token, null)).toBe(true)
|
expect(canOperateImage(token, 2)).toBe(false)
|
||||||
|
expect(canOperateImage(token, null)).toBe(false)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
+7
-10
@@ -211,12 +211,12 @@ export async function requireApiOrAdminAuth(event: H3Event): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 列表/搜索/删除时按用户过滤;全局 Token 视为管理员,用户 Token 仅见自己的 */
|
/** 列表/搜索/删除时按用户过滤;管理员和普通用户都只能访问自己的媒体 */
|
||||||
export async function getImageUserFilter(
|
export async function getImageUserFilter(
|
||||||
event: H3Event
|
event: H3Event
|
||||||
): Promise<number | 'admin'> {
|
): Promise<number | 'admin'> {
|
||||||
if (devBypass(event)) {
|
if (devBypass(event)) {
|
||||||
return 'admin'
|
return getPrimaryAdminUserId() ?? 'admin'
|
||||||
}
|
}
|
||||||
|
|
||||||
const userIdFromToken = resolveUserIdFromApiToken(event)
|
const userIdFromToken = resolveUserIdFromApiToken(event)
|
||||||
@@ -225,13 +225,10 @@ export async function getImageUserFilter(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (verifyGlobalApiUploadToken(event)) {
|
if (verifyGlobalApiUploadToken(event)) {
|
||||||
return 'admin'
|
return getPrimaryAdminUserId() ?? 'admin'
|
||||||
}
|
}
|
||||||
|
|
||||||
const user = await requireUserAuth(event)
|
const user = await requireUserAuth(event)
|
||||||
if (user.role === 'admin') {
|
|
||||||
return 'admin'
|
|
||||||
}
|
|
||||||
return user.id
|
return user.id
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -253,14 +250,14 @@ export async function assertImageOwnership(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (verifyGlobalApiUploadToken(event)) {
|
if (verifyGlobalApiUploadToken(event)) {
|
||||||
|
const ownerId = userId ?? null
|
||||||
|
if (ownerId === null || ownerId !== getPrimaryAdminUserId()) {
|
||||||
|
createApiError(event, 'FORBIDDEN', '无权操作此图片', 403)
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const user = await requireUserAuth(event)
|
const user = await requireUserAuth(event)
|
||||||
if (user.role === 'admin') {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const ownerId = userId ?? null
|
const ownerId = userId ?? null
|
||||||
if (ownerId === null || ownerId !== user.id) {
|
if (ownerId === null || ownerId !== user.id) {
|
||||||
createApiError(event, 'FORBIDDEN', '无权操作此图片', 403)
|
createApiError(event, 'FORBIDDEN', '无权操作此图片', 403)
|
||||||
|
|||||||
@@ -154,7 +154,7 @@ export async function getCurrentUser(event: H3Event): Promise<AuthUser | null> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const user = findUserById(session.user_id)
|
const user = findUserById(session.user_id)
|
||||||
if (!user) return null
|
if (!user || user.disabled_at) return null
|
||||||
|
|
||||||
return rowToAuthUser(user)
|
return rowToAuthUser(user)
|
||||||
}
|
}
|
||||||
|
|||||||
+18
-3
@@ -191,6 +191,9 @@ function migrateSchema(database: DatabaseSync): void {
|
|||||||
WHERE auto_delete_days > 0 AND auto_delete_enabled_at IS NULL
|
WHERE auto_delete_days > 0 AND auto_delete_enabled_at IS NULL
|
||||||
`)
|
`)
|
||||||
}
|
}
|
||||||
|
if (!userColumns.some(column => column.name === 'disabled_at')) {
|
||||||
|
database.exec('ALTER TABLE users ADD COLUMN disabled_at TEXT')
|
||||||
|
}
|
||||||
|
|
||||||
const daysRow = database.prepare(`
|
const daysRow = database.prepare(`
|
||||||
SELECT value FROM settings WHERE key = 'auto_delete_days'
|
SELECT value FROM settings WHERE key = 'auto_delete_days'
|
||||||
@@ -263,7 +266,7 @@ function migrateSchema(database: DatabaseSync): void {
|
|||||||
`)
|
`)
|
||||||
}
|
}
|
||||||
|
|
||||||
const USER_COLUMNS = 'id, username, password_hash, role, created_at, api_token, auto_delete_days, auto_delete_enabled_at'
|
const USER_COLUMNS = 'id, username, password_hash, role, created_at, api_token, auto_delete_days, auto_delete_enabled_at, disabled_at'
|
||||||
|
|
||||||
export type UserRole = 'admin' | 'user'
|
export type UserRole = 'admin' | 'user'
|
||||||
|
|
||||||
@@ -276,6 +279,7 @@ export interface UserRow {
|
|||||||
api_token: string | null
|
api_token: string | null
|
||||||
auto_delete_days: number
|
auto_delete_days: number
|
||||||
auto_delete_enabled_at: string | null
|
auto_delete_enabled_at: string | null
|
||||||
|
disabled_at: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AutoDeletePolicy {
|
export interface AutoDeletePolicy {
|
||||||
@@ -330,7 +334,7 @@ export function findUserByApiToken(token: string): UserRow | null {
|
|||||||
const row = getDb().prepare(`
|
const row = getDb().prepare(`
|
||||||
SELECT ${USER_COLUMNS}
|
SELECT ${USER_COLUMNS}
|
||||||
FROM users
|
FROM users
|
||||||
WHERE api_token = ?
|
WHERE api_token = ? AND disabled_at IS NULL
|
||||||
`).get(token) as UserRow | undefined
|
`).get(token) as UserRow | undefined
|
||||||
return row ?? null
|
return row ?? null
|
||||||
}
|
}
|
||||||
@@ -457,6 +461,7 @@ export interface AdminUserRow {
|
|||||||
role: UserRole
|
role: UserRole
|
||||||
created_at: string
|
created_at: string
|
||||||
password_reset: number
|
password_reset: number
|
||||||
|
disabled_at: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AdminUserUsage {
|
export interface AdminUserUsage {
|
||||||
@@ -469,7 +474,7 @@ export interface AdminUserUsage {
|
|||||||
export function listAdminUsers(): AdminUserRow[] {
|
export function listAdminUsers(): AdminUserRow[] {
|
||||||
ensurePasswordResetColumn()
|
ensurePasswordResetColumn()
|
||||||
return getDb().prepare(`
|
return getDb().prepare(`
|
||||||
SELECT id, username, role, created_at, password_reset
|
SELECT id, username, role, created_at, password_reset, disabled_at
|
||||||
FROM users ORDER BY id ASC
|
FROM users ORDER BY id ASC
|
||||||
`).all() as unknown as AdminUserRow[]
|
`).all() as unknown as AdminUserRow[]
|
||||||
}
|
}
|
||||||
@@ -526,6 +531,16 @@ export function setUserPassword(userId: number, passwordHash: string): void {
|
|||||||
`).run(passwordHash, userId)
|
`).run(passwordHash, userId)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function setUserDisabled(userId: number, disabled: boolean): void {
|
||||||
|
ensurePasswordResetColumn()
|
||||||
|
getDb().prepare(`
|
||||||
|
UPDATE users
|
||||||
|
SET disabled_at = ?
|
||||||
|
WHERE id = ?
|
||||||
|
`).run(disabled ? new Date().toISOString() : null, userId)
|
||||||
|
if (disabled) deleteSessionsForUser(userId)
|
||||||
|
}
|
||||||
|
|
||||||
export function updateUsername(userId: number, username: string): void {
|
export function updateUsername(userId: number, username: string): void {
|
||||||
getDb().prepare('UPDATE users SET username = ? WHERE id = ?').run(username, userId)
|
getDb().prepare('UPDATE users SET username = ? WHERE id = ?').run(username, userId)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
import { describe, expect, it } from 'vitest'
|
import { describe, expect, it } from 'vitest'
|
||||||
import { validateImageKey } from './image-key'
|
import { validateImageKey } from './image-key'
|
||||||
|
import { insertActivityLog, insertUser } from './db'
|
||||||
|
import { getMediaStats, insertImageIndex } from './image-index'
|
||||||
|
import { ensureDefaultBackends } from './storage-backends'
|
||||||
|
|
||||||
describe('image index key validation', () => {
|
describe('image index key validation', () => {
|
||||||
it('accepts standard image keys used in index', () => {
|
it('accepts standard image keys used in index', () => {
|
||||||
@@ -16,3 +19,62 @@ describe('image index key validation', () => {
|
|||||||
expect(validateImageKey('blog/abc.webp')).toBe(false)
|
expect(validateImageKey('blog/abc.webp')).toBe(false)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('image stats isolation', () => {
|
||||||
|
it('scopes upload source counts to the selected user', () => {
|
||||||
|
ensureDefaultBackends()
|
||||||
|
const now = new Date().toISOString()
|
||||||
|
const userA = insertUser({
|
||||||
|
username: 'stats-user-a',
|
||||||
|
passwordHash: 'hash-a',
|
||||||
|
role: 'user',
|
||||||
|
createdAt: now
|
||||||
|
})
|
||||||
|
const userB = insertUser({
|
||||||
|
username: 'stats-user-b',
|
||||||
|
passwordHash: 'hash-b',
|
||||||
|
role: 'user',
|
||||||
|
createdAt: now
|
||||||
|
})
|
||||||
|
|
||||||
|
insertImageIndex({
|
||||||
|
key: 'images/2026/10/stats-a.webp',
|
||||||
|
backendId: 'local',
|
||||||
|
userId: userA,
|
||||||
|
originalName: 'stats-a.webp',
|
||||||
|
contentType: 'image/webp',
|
||||||
|
size: 1,
|
||||||
|
uploadedAt: now
|
||||||
|
})
|
||||||
|
insertImageIndex({
|
||||||
|
key: 'images/2026/10/stats-b.webp',
|
||||||
|
backendId: 'local',
|
||||||
|
userId: userB,
|
||||||
|
originalName: 'stats-b.webp',
|
||||||
|
contentType: 'image/webp',
|
||||||
|
size: 1,
|
||||||
|
uploadedAt: now
|
||||||
|
})
|
||||||
|
insertActivityLog({
|
||||||
|
action: 'upload',
|
||||||
|
key: 'images/2026/10/stats-a.webp',
|
||||||
|
originalName: 'stats-a.webp',
|
||||||
|
size: 1,
|
||||||
|
contentType: 'image/webp',
|
||||||
|
source: 'web',
|
||||||
|
userId: userA
|
||||||
|
})
|
||||||
|
insertActivityLog({
|
||||||
|
action: 'upload',
|
||||||
|
key: 'images/2026/10/stats-b.webp',
|
||||||
|
originalName: 'stats-b.webp',
|
||||||
|
size: 1,
|
||||||
|
contentType: 'image/webp',
|
||||||
|
source: 'api',
|
||||||
|
userId: userB
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(getMediaStats('image', userA).bySource).toEqual({ web: 1, api: 0 })
|
||||||
|
expect(getMediaStats('image', userB).bySource).toEqual({ web: 0, api: 1 })
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -602,11 +602,16 @@ export function getMediaStats(
|
|||||||
|
|
||||||
// 来源分布来自上传日志,按目录前缀区分类型
|
// 来源分布来自上传日志,按目录前缀区分类型
|
||||||
const prefix = mediaType === 'video' ? 'videos/' : mediaType === 'file' ? 'files/' : 'images/'
|
const prefix = mediaType === 'video' ? 'videos/' : mediaType === 'file' ? 'files/' : 'images/'
|
||||||
|
const sourceParams: Array<string | number> = [`${prefix}%`]
|
||||||
|
const sourceUserSql = userFilter === undefined || userFilter === 'admin'
|
||||||
|
? ''
|
||||||
|
: ' AND user_id = ?'
|
||||||
|
if (sourceUserSql) sourceParams.push(userFilter as number)
|
||||||
const sourceRows = getDb().prepare(`
|
const sourceRows = getDb().prepare(`
|
||||||
SELECT source, COUNT(*) AS count FROM activity_logs
|
SELECT source, COUNT(*) AS count FROM activity_logs
|
||||||
WHERE action = 'upload' AND key LIKE ?
|
WHERE action = 'upload' AND key LIKE ?${sourceUserSql}
|
||||||
GROUP BY source
|
GROUP BY source
|
||||||
`).all(`${prefix}%`) as Array<{ source: string, count: number }>
|
`).all(...sourceParams) as Array<{ source: string, count: number }>
|
||||||
|
|
||||||
const bySource = { web: 0, api: 0 }
|
const bySource = { web: 0, api: 0 }
|
||||||
for (const row of sourceRows) {
|
for (const row of sourceRows) {
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ function matchesUserFilter(
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe('image user filter', () => {
|
describe('image user filter', () => {
|
||||||
it('admin sees all images including system uploads', () => {
|
it('explicit global filter includes all images', () => {
|
||||||
expect(matchesUserFilter({ userId: null } as StoredImageMeta, 'admin')).toBe(true)
|
expect(matchesUserFilter({ userId: null } as StoredImageMeta, 'admin')).toBe(true)
|
||||||
expect(matchesUserFilter({ userId: 2 } as StoredImageMeta, 'admin')).toBe(true)
|
expect(matchesUserFilter({ userId: 2 } as StoredImageMeta, 'admin')).toBe(true)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -363,13 +363,13 @@ export function resolveTagIdsForImageTagging(
|
|||||||
return [...new Set(resolved)]
|
return [...new Set(resolved)]
|
||||||
}
|
}
|
||||||
|
|
||||||
export function assertCanTagImage(key: string, actorUserId: number, isAdmin: boolean): void {
|
export function assertCanTagImage(key: string, actorUserId: number, _isAdmin: boolean): void {
|
||||||
const indexedKey = resolveIndexedImageKey(key)
|
const indexedKey = resolveIndexedImageKey(key)
|
||||||
if (!indexedKey) {
|
if (!indexedKey) {
|
||||||
throw new Error('IMAGE_NOT_FOUND')
|
throw new Error('IMAGE_NOT_FOUND')
|
||||||
}
|
}
|
||||||
const ownerId = getImageIndexRow(indexedKey)!.user_id
|
const ownerId = getImageIndexRow(indexedKey)!.user_id
|
||||||
if (!isAdmin && (ownerId === null || ownerId !== actorUserId)) {
|
if (ownerId === null || ownerId !== actorUserId) {
|
||||||
throw new Error('FORBIDDEN')
|
throw new Error('FORBIDDEN')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import {
|
||||||
|
createSessionRow,
|
||||||
|
findUserByApiToken,
|
||||||
|
findUserById,
|
||||||
|
getDb,
|
||||||
|
insertUser,
|
||||||
|
setUserApiToken,
|
||||||
|
setUserDisabled
|
||||||
|
} from './db'
|
||||||
|
|
||||||
|
describe('user disabled status', () => {
|
||||||
|
it('blocks API tokens and revokes sessions until re-enabled', () => {
|
||||||
|
const now = new Date().toISOString()
|
||||||
|
const userId = insertUser({
|
||||||
|
username: 'disabled-user',
|
||||||
|
passwordHash: 'hash',
|
||||||
|
role: 'user',
|
||||||
|
createdAt: now
|
||||||
|
})
|
||||||
|
const token = 'disabled-user-token'
|
||||||
|
const sessionId = 'disabled-user-session'
|
||||||
|
setUserApiToken(userId, token)
|
||||||
|
createSessionRow({
|
||||||
|
id: sessionId,
|
||||||
|
userId,
|
||||||
|
tokenHash: 'hash',
|
||||||
|
expiresAt: new Date(Date.now() + 60_000).toISOString(),
|
||||||
|
createdAt: now
|
||||||
|
})
|
||||||
|
|
||||||
|
setUserDisabled(userId, true)
|
||||||
|
|
||||||
|
expect(findUserById(userId)?.disabled_at).not.toBeNull()
|
||||||
|
expect(findUserByApiToken(token)).toBeNull()
|
||||||
|
expect(getDb().prepare('SELECT 1 FROM sessions WHERE id = ?').get(sessionId))
|
||||||
|
.toBeUndefined()
|
||||||
|
|
||||||
|
setUserDisabled(userId, false)
|
||||||
|
|
||||||
|
expect(findUserById(userId)?.disabled_at).toBeNull()
|
||||||
|
expect(findUserByApiToken(token)?.id).toBe(userId)
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in new issue
Block a user