用户管理与数据操作

This commit is contained in:
tianmo committed 2026-10-27 11:03:36 +08:00
1 parent ed0f20b754
commit 51d3acfbb5
14 files changed
+256 -33

No files matched your search

+12 -8
View File
@@ -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)
})
})
+7 -10
View File
@@ -211,12 +211,12 @@ export async function requireApiOrAdminAuth(event: H3Event): Promise<void> {
}
}
/** 列表/搜索/删除时按用户过滤;全局 Token 视为管理员,用户 Token 仅见自己的 */
/** 列表/搜索/删除时按用户过滤;管理员和普通用户都只能访问自己的媒体 */
export async function getImageUserFilter(
event: H3Event
): Promise<number | 'admin'> {
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)
+1 -1
View File
@@ -154,7 +154,7 @@ export async function getCurrentUser(event: H3Event): Promise<AuthUser | null> {
}
const user = findUserById(session.user_id)
if (!user) return null
if (!user || user.disabled_at) return null
return rowToAuthUser(user)
}
+18 -3
View File
@@ -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)
}
+62
View File
@@ -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 })
})
})
+7 -2
View File
@@ -602,11 +602,16 @@ export function getMediaStats(
// 来源分布来自上传日志,按目录前缀区分类型
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(`
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) {
+1 -1
View File
@@ -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)
})
+2 -2
View File
@@ -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')
}
}
+44
View File
@@ -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)
})
})