feat: activity logs, gallery storage filter, and upload rate limits

Add per-user activity logs with storage backend tracking, gallery
storage filter, and IP/token upload rate limiting. Fix storage delete
modal flash, pin local backend in filter order, and link settings
version to GitHub releases.
This commit is contained in:
O96u committed 2026-08-26 17:09:38 +08:00
1 parent 901fefb3f5
commit 6fc054328d
34 files changed
+1357 -72

No files matched your search

+118 -22
View File
@@ -15,6 +15,11 @@ export interface ActivityLogRow {
size: number
content_type: string
source: LogSource
user_id: number | null
username: string | null
backend_id: string | null
backend_name: string | null
backend_type: string | null
created_at: string
}
@@ -25,6 +30,8 @@ export interface ActivityLogInput {
size: number
contentType: string
source: LogSource
userId?: number | null
backendId?: string | null
createdAt?: string
}
@@ -118,6 +125,23 @@ function migrateSchema(database: DatabaseSync): void {
VALUES ('auto_delete_enabled_at', ?, ?)
`).run(now, now)
}
const logColumns = database.prepare('PRAGMA table_info(activity_logs)').all() as Array<{
name: string
}>
if (!logColumns.some(column => column.name === 'user_id')) {
database.exec('ALTER TABLE activity_logs ADD COLUMN user_id INTEGER')
database.exec(`
CREATE INDEX IF NOT EXISTS idx_activity_logs_user_created
ON activity_logs(user_id, created_at DESC)
`)
}
const activityLogColumns = database.prepare('PRAGMA table_info(activity_logs)').all() as Array<{
name: string
}>
if (!activityLogColumns.some(column => column.name === 'backend_id')) {
database.exec('ALTER TABLE activity_logs ADD COLUMN backend_id TEXT')
}
}
const USER_COLUMNS = 'id, username, password_hash, role, created_at, api_token, auto_delete_days, auto_delete_enabled_at'
@@ -393,8 +417,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, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
(action, key, original_name, size, content_type, source, user_id, backend_id, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
input.action,
input.key,
@@ -402,6 +426,8 @@ export function insertActivityLog(input: ActivityLogInput): void {
input.size,
input.contentType,
input.source,
input.userId ?? null,
input.backendId ?? null,
createdAt
)
} catch (error) {
@@ -418,6 +444,8 @@ export function listActivityLogs(options: {
action?: LogAction
source?: LogSource
folder?: string
userId?: number
search?: string
}): {
items: ActivityLogRow[]
total: number
@@ -427,26 +455,11 @@ export function listActivityLogs(options: {
} {
const database = getDb()
const pageSize = options.limit
const params: Array<string | number> = []
const where: string[] = []
const { whereSql, params } = buildActivityLogWhere(options)
if (options.action) {
where.push('action = ?')
params.push(options.action)
}
if (options.source) {
where.push('source = ?')
params.push(options.source)
}
if (options.folder) {
where.push('key LIKE ?')
params.push(`${options.folder}/%`)
}
const whereSql = where.length ? `WHERE ${where.join(' AND ')}` : ''
const countRow = database.prepare(`
SELECT COUNT(*) AS count
FROM activity_logs
FROM activity_logs al
${whereSql}
`).get(...params) as { count: number }
@@ -457,10 +470,25 @@ export function listActivityLogs(options: {
const offset = (safePage - 1) * pageSize
const rows = database.prepare(`
SELECT id, action, key, original_name, size, content_type, source, created_at
FROM activity_logs
SELECT
al.id,
al.action,
al.key,
al.original_name,
al.size,
al.content_type,
al.source,
al.user_id,
u.username,
al.backend_id,
sb.name AS backend_name,
sb.type AS backend_type,
al.created_at
FROM activity_logs al
LEFT JOIN users u ON u.id = al.user_id
LEFT JOIN storage_backends sb ON sb.id = al.backend_id
${whereSql}
ORDER BY id DESC
ORDER BY al.id DESC
LIMIT ? OFFSET ?
`).all(...params, pageSize, offset) as unknown as ActivityLogRow[]
@@ -473,6 +501,74 @@ export function listActivityLogs(options: {
}
}
export function summarizeActivityLogs(options: {
source?: LogSource
folder?: string
userId?: number
search?: string
}): {
total: number
upload: number
delete: number
} {
const database = getDb()
const { whereSql, params } = buildActivityLogWhere(options, { includeAction: false })
const row = database.prepare(`
SELECT
COUNT(*) AS total,
COALESCE(SUM(CASE WHEN al.action = 'upload' THEN 1 ELSE 0 END), 0) AS upload_count,
COALESCE(SUM(CASE WHEN al.action = 'delete' THEN 1 ELSE 0 END), 0) AS delete_count
FROM activity_logs al
${whereSql}
`).get(...params) as { total: number, upload_count: number, delete_count: number } | undefined
return {
total: row?.total ?? 0,
upload: row?.upload_count ?? 0,
delete: row?.delete_count ?? 0
}
}
function buildActivityLogWhere(
options: {
action?: LogAction
source?: LogSource
folder?: string
userId?: number
search?: string
},
{ includeAction = true }: { includeAction?: boolean } = {}
): { whereSql: string, params: Array<string | number> } {
const params: Array<string | number> = []
const where: string[] = []
if (includeAction && options.action) {
where.push('al.action = ?')
params.push(options.action)
}
if (options.source) {
where.push('al.source = ?')
params.push(options.source)
}
if (options.folder) {
where.push('al.key LIKE ?')
params.push(`${options.folder}/%`)
}
if (options.userId !== undefined) {
where.push('al.user_id = ?')
params.push(options.userId)
}
if (options.search) {
where.push('al.original_name LIKE ?')
params.push(`%${options.search}%`)
}
return {
whereSql: where.length ? `WHERE ${where.join(' AND ')}` : '',
params
}
}
function countSince(action: LogAction, sinceIso: string): number {
const row = getDb().prepare(`
SELECT COUNT(*) AS total