release: v1.1.1 — session fixes, storage cleanup, logging
GitHub Release / release (push) Canceled after 0s
ci / ci (22, ubuntu-latest) (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

This commit is contained in:
O96u committed 2026-08-26 15:38:56 +08:00
1 parent acc64751ef
commit 901fefb3f5
20 files changed
+231 -37

No files matched your search

+2 -1
View File
@@ -1,5 +1,6 @@
import { requireAdminAuth } from '../../utils/access'
import { createApiError } from '../../utils/api-error'
import { logException } from '../../utils/logger'
import {
listActivityLogs,
type LogAction,
@@ -58,7 +59,7 @@ export default defineEventHandler(async (event) => {
totalPages: result.totalPages
}
} catch (error) {
console.error('[logs] list failed', error)
logException('activity logs list failed', error)
createApiError(event, 'INVALID_REQUEST', '读取操作记录失败', 500)
}
})
+2 -1
View File
@@ -1,5 +1,6 @@
import { getImageUserFilter, requireUserAuth } from '../utils/access'
import { createApiError } from '../utils/api-error'
import { logException } from '../utils/logger'
import { countUsers, getActivityStats } from '../utils/db'
import {
countImages,
@@ -46,7 +47,7 @@ export default defineEventHandler(async (event) => {
byFolder: scoped.byFolder
}
} catch (error) {
console.error('[stats] failed', error)
logException('stats failed', error)
createApiError(event, 'INVALID_REQUEST', '读取统计失败', 500)
}
})
+43
View File
@@ -0,0 +1,43 @@
import { clientIp, logError, logException, logWarn, serializeError } from '../utils/logger'
/**
* 记录未捕获的 H3/Nitro 错误(含 createApiError 抛出的 4xx/5xx)。
*/
export default defineNitroPlugin((nitroApp) => {
nitroApp.hooks.hook('error', (error, { event }) => {
const serialized = serializeError(error)
const status = typeof serialized.statusCode === 'number'
? serialized.statusCode
: 500
if (!event) {
logError('unhandled error', serialized)
return
}
const path = getRequestURL(event).pathname
const method = getMethod(event)
const base = {
method,
path,
status,
ip: clientIp(event),
message: serialized.message,
...(serialized.statusMessage ? { reason: serialized.statusMessage } : {}),
...(serialized.data ? { data: serialized.data } : {})
}
if (!path.startsWith('/api/')) {
if (status >= 500) {
logException(`${method} ${path}`, error, base)
}
return
}
if (status >= 500) {
logException('API error', error, base)
} else if (status >= 400) {
logWarn('API rejected', base)
}
})
})
+16 -3
View File
@@ -1,4 +1,4 @@
import { clientIp, logInfo } from '../utils/logger'
import { clientIp, logError, logInfo, logWarn } from '../utils/logger'
/**
* 只记录 /api/*,避免 /images、/twikoo 直链刷屏。
@@ -19,11 +19,24 @@ export default defineNitroPlugin((nitroApp) => {
const status = getResponseStatus(event)
const started = event.context._reqStartedAt as number | undefined
const ms = started ? Date.now() - started : undefined
const reason = status >= 400 ? getResponseStatusText(event) : undefined
logInfo(`${method} ${path}`, {
const detail: Record<string, unknown> = {
status,
ms,
ip: clientIp(event)
})
}
if (reason && reason !== String(status)) {
detail.reason = reason
}
const line = `${method} ${path}`
if (status >= 500) {
logError(line, detail)
} else if (status >= 400) {
logWarn(line, detail)
} else {
logInfo(line, detail)
}
})
})
+5 -1
View File
@@ -2,7 +2,7 @@ import { getDataDir } from '../utils/data-dir'
import { runAutoDeleteCleanup } from '../utils/auto-delete'
import { migrateLocalImagesToIndex } from '../utils/image-index'
import { ensureDefaultBackends } from '../utils/storage-backends'
import { logInfo } from '../utils/logger'
import { logException, logInfo } from '../utils/logger'
export default defineNitroPlugin(() => {
const port = process.env.NITRO_PORT || process.env.PORT || '6892'
@@ -20,11 +20,15 @@ export default defineNitroPlugin(() => {
if (count > 0) {
logInfo('migrated local images to index', { count })
}
}).catch((error) => {
logException('migrate local images to index failed', error)
})
void runAutoDeleteCleanup().then((result) => {
if (!result.skipped && (result.deleted > 0 || result.failed > 0)) {
logInfo('startup auto-delete', { ...result })
}
}).catch((error) => {
logException('startup auto-delete failed', error)
})
})
+2 -2
View File
@@ -2,7 +2,7 @@ import { insertActivityLog, listAdminUserIds, listUserAutoDeletePolicies, type A
import { getGlobalAutoDeletePolicy } from './env'
import { deleteImage, type StoredImage } from './storage'
import { listImageKeysForAutoDelete } from './image-index'
import { logInfo, logWarn } from './logger'
import { logInfo, logWarn, serializeError } from './logger'
export interface AutoDeleteResult {
deleted: number
@@ -95,7 +95,7 @@ export async function runAutoDeleteCleanup(): Promise<AutoDeleteResult> {
})
deleted++
} catch (error) {
logWarn('auto-delete failed', { key: image.key, error })
logWarn('auto-delete failed', { key: image.key, error: serializeError(error) })
failed++
}
}
+5 -1
View File
@@ -2,6 +2,7 @@ import { DatabaseSync } from 'node:sqlite'
import { mkdirSync } from 'node:fs'
import { join } from 'node:path'
import { getDataDir } from './data-dir'
import { logException } from './logger'
export type LogAction = 'upload' | 'delete'
export type LogSource = 'web' | 'api' | 'twikoo'
@@ -404,7 +405,10 @@ export function insertActivityLog(input: ActivityLogInput): void {
createdAt
)
} catch (error) {
console.error('[activity-log] insert failed', error)
logException('activity-log insert failed', error, {
action: input.action,
key: input.key
})
}
}
+32
View File
@@ -0,0 +1,32 @@
import { describe, expect, it } from 'vitest'
import { serializeError } from './logger'
describe('serializeError', () => {
it('extracts message and status from H3-like errors', () => {
const error = new Error('bucket not found') as Error & {
statusCode: number
statusMessage: string
data: { error: { code: string } }
}
error.statusCode = 404
error.statusMessage = '存储桶不存在'
error.data = { error: { code: 'NOT_FOUND' } }
expect(serializeError(error)).toMatchObject({
name: 'Error',
message: 'bucket not found',
statusCode: 404,
statusMessage: '存储桶不存在',
data: { error: { code: 'NOT_FOUND' } }
})
})
it('includes stack for 5xx errors at info log level', () => {
const error = new Error('db locked') as Error & { statusCode: number }
error.statusCode = 500
error.stack = 'Error: db locked\n at test'
const serialized = serializeError(error)
expect(serialized.stack).toBe(error.stack)
})
})
+40
View File
@@ -7,6 +7,12 @@ const LEVEL_ORDER: Record<LogLevel, number> = {
error: 40
}
type H3LikeError = Error & {
statusCode?: number
statusMessage?: string
data?: unknown
}
function currentLevel(): LogLevel {
const raw = (process.env.LOG_LEVEL || 'info').toLowerCase()
if (raw === 'debug' || raw === 'info' || raw === 'warn' || raw === 'error') {
@@ -27,6 +33,35 @@ function formatLine(level: LogLevel, message: string, extra?: Record<string, unk
return `[${time}] [${level.toUpperCase()}] ${message}${payload}`
}
/** 将 unknown / Error / H3Error 转为可 JSON 输出的字段,便于 Docker 日志排查 */
export function serializeError(error: unknown): Record<string, unknown> {
if (error instanceof Error) {
const h3 = error as H3LikeError
const out: Record<string, unknown> = {
name: error.name,
message: error.message
}
if (h3.statusCode !== undefined) out.statusCode = h3.statusCode
if (h3.statusMessage) out.statusMessage = h3.statusMessage
if (h3.data !== undefined) out.data = h3.data
if (error.stack && shouldLog('debug')) out.stack = error.stack
else if (error.stack && (h3.statusCode ?? 500) >= 500) out.stack = error.stack
if ('cause' in error && error.cause !== undefined) {
out.cause = serializeError(error.cause)
}
return out
}
if (typeof error === 'string') return { message: error }
if (error && typeof error === 'object') {
try {
return { detail: JSON.parse(JSON.stringify(error)) }
} catch {
return { message: String(error) }
}
}
return { message: String(error) }
}
export function logDebug(message: string, extra?: Record<string, unknown>) {
if (!shouldLog('debug')) return
console.debug(formatLine('debug', message, extra))
@@ -47,6 +82,11 @@ export function logError(message: string, extra?: Record<string, unknown>) {
console.error(formatLine('error', message, extra))
}
/** message + 原始 error 对象(自动 serialize) */
export function logException(message: string, error: unknown, extra?: Record<string, unknown>) {
logError(message, { ...extra, error: serializeError(error) })
}
export function clientIp(event: Parameters<typeof getRequestIP>[0]): string {
return getRequestIP(event, { xForwardedFor: true }) || '-'
}
+3 -3
View File
@@ -17,7 +17,7 @@ import { buildImageItem, sanitizeOriginalName } from './image-response'
import { getWebpQuality } from './env'
import { insertActivityLog } from './db'
import { putImage } from './storage'
import { logInfo, logWarn } from './logger'
import { logInfo, logException } from './logger'
/** 可转 WebP 的位图格式;SVG(矢量)、ICO、已是 WebP 的原样存储 */
const WEBP_CONVERTIBLE: readonly AllowedMimeType[] = [
@@ -136,8 +136,8 @@ export async function processSingleImageUpload(
size: compressed.bytes.byteLength,
userId: input.userId ?? null
})
} catch {
logWarn('upload failed: storage error', {
} catch (error) {
logException('upload failed: storage error', error, {
name: originalName,
source: input.source ?? 'web'
})
+29 -10
View File
@@ -71,18 +71,37 @@ export function ensureDefaultBackends(): void {
`).run(LOCAL_BACKEND_ID, '本地磁盘')
}
const s3 = db.prepare(`
SELECT id FROM storage_backends WHERE id = ?
`).get(S3_BACKEND_ID)
cleanupPlaceholderS3Backend(db)
}
if (!s3) {
db.prepare(`
INSERT INTO storage_backends
(id, name, type, config_json, secret_json, serving_mode, public_url,
quota_bytes, enabled, is_default, sort_order)
VALUES (?, ?, 's3', '{}', '{}', 'proxy', '', NULL, 0, 0, 1)
`).run(S3_BACKEND_ID, 'S3 兼容存储')
/** 移除早期版本自动种下的空 S3 占位(未配置、未启用、非默认、无图片) */
function cleanupPlaceholderS3Backend(db: DatabaseSync): void {
const row = db.prepare(`
SELECT * FROM storage_backends WHERE id = ?
`).get(S3_BACKEND_ID) as StorageBackendRow | undefined
if (!row || row.type !== 's3' || row.is_default === 1) {
return
}
const config = parseS3Config(row.config_json)
const secrets = parseS3Secrets(row.secret_json)
const configured = Boolean(
config?.bucket?.trim()
|| config?.endpoint?.trim()
|| secrets?.accessKeyId?.trim()
|| secrets?.secretAccessKey?.trim()
)
if (configured) {
return
}
const usage = getBackendUsageStats(S3_BACKEND_ID)
if (usage.count > 0) {
return
}
db.prepare('DELETE FROM storage_backends WHERE id = ?').run(S3_BACKEND_ID)
}
function rowToInfo(row: StorageBackendRow, _maskSecrets = true): StorageBackendInfo & {