diff --git a/app/pages/users.vue b/app/pages/users.vue index 45c867e..28f8ec0 100644 --- a/app/pages/users.vue +++ b/app/pages/users.vue @@ -7,6 +7,7 @@ interface AdminUser { role: 'admin' | 'user' created_at: string password_reset: number + disabled_at: string | null usage?: { imageBytes: number videoBytes: number @@ -31,6 +32,7 @@ const loading = ref(false) const deleteTarget = ref(null) const deleteConfirm = ref('') const deleting = ref(false) +const statusBusyId = ref(null) const canDelete = computed(() => 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) { if (user.role === 'admin') return deleteTarget.value = user @@ -226,6 +256,14 @@ onMounted(async () => { v-if="user.role !== 'admin'" class="flex items-center gap-2" > + + {{ t('users.disabled') }} + { > {{ t('users.resetPending') }} + { + 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 } +}) diff --git a/server/api/auth/login.post.ts b/server/api/auth/login.post.ts index 82d0e6f..ceadb2c 100644 --- a/server/api/auth/login.post.ts +++ b/server/api/auth/login.post.ts @@ -57,6 +57,11 @@ export default defineEventHandler(async (event) => { const user = findUserByUsername(username) + if (user?.disabled_at) { + logWarn('login failed: user disabled', { ip, username }) + createApiError(event, 'FORBIDDEN', '该账号已被禁用', 403) + } + // 管理员已清除密码:下次登录时直接进入设置新密码流程 if (user && isPasswordResetRequired(user.id)) { return { success: false, requiresPasswordReset: true, username: user.username } diff --git a/server/utils/access-isolation.test.ts b/server/utils/access-isolation.test.ts index 5378998..5d7053c 100644 --- a/server/utils/access-isolation.test.ts +++ b/server/utils/access-isolation.test.ts @@ -8,13 +8,15 @@ function canOperateImage( actor: { type: 'user', id: number, role: 'user' | 'admin' } | { type: 'user-token', id: number } | { type: 'global-token' }, - ownerId: number | null + ownerId: number | null, + primaryAdminId = 1 ): boolean { - if (actor.type === 'global-token') return true + if (actor.type === 'global-token') { + return ownerId !== null && ownerId === primaryAdminId + } if (actor.type === 'user-token') { return ownerId === actor.id } - if (actor.role === 'admin') return true return ownerId !== null && ownerId === actor.id } @@ -26,10 +28,11 @@ describe('image ownership rules', () => { 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 } - expect(canOperateImage(admin, 2)).toBe(true) - expect(canOperateImage(admin, null)).toBe(true) + expect(canOperateImage(admin, 1)).toBe(true) + expect(canOperateImage(admin, 2)).toBe(false) + expect(canOperateImage(admin, null)).toBe(false) }) it('user API token is scoped to owner', () => { @@ -39,10 +42,11 @@ describe('image ownership rules', () => { 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 } expect(canOperateImage(token, 1)).toBe(true) - expect(canOperateImage(token, null)).toBe(true) + expect(canOperateImage(token, 2)).toBe(false) + expect(canOperateImage(token, null)).toBe(false) }) }) diff --git a/server/utils/access.ts b/server/utils/access.ts index 90e190e..7f7490e 100644 --- a/server/utils/access.ts +++ b/server/utils/access.ts @@ -211,12 +211,12 @@ export async function requireApiOrAdminAuth(event: H3Event): Promise { } } -/** 列表/搜索/删除时按用户过滤;全局 Token 视为管理员,用户 Token 仅见自己的 */ +/** 列表/搜索/删除时按用户过滤;管理员和普通用户都只能访问自己的媒体 */ export async function getImageUserFilter( event: H3Event ): Promise { if (devBypass(event)) { - return 'admin' + return getPrimaryAdminUserId() ?? 'admin' } const userIdFromToken = resolveUserIdFromApiToken(event) @@ -225,13 +225,10 @@ export async function getImageUserFilter( } if (verifyGlobalApiUploadToken(event)) { - return 'admin' + return getPrimaryAdminUserId() ?? 'admin' } const user = await requireUserAuth(event) - if (user.role === 'admin') { - return 'admin' - } return user.id } @@ -253,14 +250,14 @@ export async function assertImageOwnership( } if (verifyGlobalApiUploadToken(event)) { + const ownerId = userId ?? null + if (ownerId === null || ownerId !== getPrimaryAdminUserId()) { + createApiError(event, 'FORBIDDEN', '无权操作此图片', 403) + } return } const user = await requireUserAuth(event) - if (user.role === 'admin') { - return - } - const ownerId = userId ?? null if (ownerId === null || ownerId !== user.id) { createApiError(event, 'FORBIDDEN', '无权操作此图片', 403) diff --git a/server/utils/auth.ts b/server/utils/auth.ts index b344ada..8364a56 100644 --- a/server/utils/auth.ts +++ b/server/utils/auth.ts @@ -154,7 +154,7 @@ export async function getCurrentUser(event: H3Event): Promise { } const user = findUserById(session.user_id) - if (!user) return null + if (!user || user.disabled_at) return null return rowToAuthUser(user) } diff --git a/server/utils/db.ts b/server/utils/db.ts index 54598b0..c1f1cbc 100644 --- a/server/utils/db.ts +++ b/server/utils/db.ts @@ -191,6 +191,9 @@ function migrateSchema(database: DatabaseSync): void { 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(` 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' @@ -276,6 +279,7 @@ export interface UserRow { api_token: string | null auto_delete_days: number auto_delete_enabled_at: string | null + disabled_at: string | null } export interface AutoDeletePolicy { @@ -330,7 +334,7 @@ export function findUserByApiToken(token: string): UserRow | null { const row = getDb().prepare(` SELECT ${USER_COLUMNS} FROM users - WHERE api_token = ? + WHERE api_token = ? AND disabled_at IS NULL `).get(token) as UserRow | undefined return row ?? null } @@ -457,6 +461,7 @@ export interface AdminUserRow { role: UserRole created_at: string password_reset: number + disabled_at: string | null } export interface AdminUserUsage { @@ -469,7 +474,7 @@ export interface AdminUserUsage { export function listAdminUsers(): AdminUserRow[] { ensurePasswordResetColumn() 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 `).all() as unknown as AdminUserRow[] } @@ -526,6 +531,16 @@ export function setUserPassword(userId: number, passwordHash: string): void { `).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 { getDb().prepare('UPDATE users SET username = ? WHERE id = ?').run(username, userId) } diff --git a/server/utils/image-index.test.ts b/server/utils/image-index.test.ts index ad58eaa..fb5cdfc 100644 --- a/server/utils/image-index.test.ts +++ b/server/utils/image-index.test.ts @@ -1,5 +1,8 @@ import { describe, expect, it } from 'vitest' 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', () => { it('accepts standard image keys used in index', () => { @@ -16,3 +19,62 @@ describe('image index key validation', () => { 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 }) + }) +}) diff --git a/server/utils/image-index.ts b/server/utils/image-index.ts index 454a0f1..afaaf8c 100644 --- a/server/utils/image-index.ts +++ b/server/utils/image-index.ts @@ -602,11 +602,16 @@ export function getMediaStats( // 来源分布来自上传日志,按目录前缀区分类型 const prefix = mediaType === 'video' ? 'videos/' : mediaType === 'file' ? 'files/' : 'images/' + const sourceParams: Array = [`${prefix}%`] + const sourceUserSql = userFilter === undefined || userFilter === 'admin' + ? '' + : ' AND user_id = ?' + if (sourceUserSql) sourceParams.push(userFilter as number) const sourceRows = getDb().prepare(` SELECT source, COUNT(*) AS count FROM activity_logs - WHERE action = 'upload' AND key LIKE ? + WHERE action = 'upload' AND key LIKE ?${sourceUserSql} 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 } for (const row of sourceRows) { diff --git a/server/utils/storage-user-filter.test.ts b/server/utils/storage-user-filter.test.ts index 71d0b0d..d379e89 100644 --- a/server/utils/storage-user-filter.test.ts +++ b/server/utils/storage-user-filter.test.ts @@ -12,7 +12,7 @@ function matchesUserFilter( } 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: 2 } as StoredImageMeta, 'admin')).toBe(true) }) diff --git a/server/utils/tags.ts b/server/utils/tags.ts index 7c3307d..af56484 100644 --- a/server/utils/tags.ts +++ b/server/utils/tags.ts @@ -363,13 +363,13 @@ export function resolveTagIdsForImageTagging( 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) if (!indexedKey) { throw new Error('IMAGE_NOT_FOUND') } const ownerId = getImageIndexRow(indexedKey)!.user_id - if (!isAdmin && (ownerId === null || ownerId !== actorUserId)) { + if (ownerId === null || ownerId !== actorUserId) { throw new Error('FORBIDDEN') } } diff --git a/server/utils/user-status.test.ts b/server/utils/user-status.test.ts new file mode 100644 index 0000000..1b7e325 --- /dev/null +++ b/server/utils/user-status.test.ts @@ -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) + }) +})