diff --git a/CHANGELOG.md b/CHANGELOG.md index 221a990..39a2eb7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,18 @@ 本仓库自 **v1.0.0** 起作为正式版维护;此前历史提交已归档重置。 +## [1.1.1] - 2026-08-26 + +### 修复 + +- 移除启动时自动创建的空白「S3 兼容存储」占位;升级后自动清理旧占位记录 +- 修复管理页切换路由时反复闪现「正在验证登录状态」 +- 修复静默会话校验后设置 / 统计 / API 页不加载数据 + +### 改进 + +- Docker 日志:4xx/5xx 分级输出,记录错误原因与堆栈;上传/启动任务失败写入详细错误 + ## [1.1.0] - 2026-08-26 ### 新增 @@ -83,8 +95,9 @@ ### 路线图 -- **v1.1.0**(当前 `main`):S3、Cloudflare R2 等多后端对象存储 +- **v1.1.1**(当前 `main`):会话校验与存储占位修复、Docker 日志增强 +[1.1.1]: https://github.com/O96u/PicHost/releases/tag/v1.1.1 [1.1.0]: https://github.com/O96u/PicHost/releases/tag/v1.1.0 [1.0.4]: https://github.com/O96u/PicHost/releases/tag/v1.0.4 [1.0.3]: https://github.com/O96u/PicHost/releases/tag/v1.0.3 diff --git a/README.en.md b/README.en.md index 1d7ed18..2610a5c 100644 --- a/README.en.md +++ b/README.en.md @@ -32,7 +32,7 @@ | Branch | Notes | | ------ | ----- | -| [**main**](https://github.com/O96u/PicHost/tree/main) (default) | **v1.1.0** — local disk + multi-backend object storage (R2 / COS / OSS / AWS), [`/storage`](docs/README.md) UI, hybrid URLs | +| [**main**](https://github.com/O96u/PicHost/tree/main) (default) | **v1.1.1** — local disk + multi-backend object storage (R2 / COS / OSS / AWS), [`/storage`](docs/README.md) UI, hybrid URLs | | [**cloudflare**](https://github.com/O96u/PicHost/tree/cloudflare) | **Cloudflare R2–only line** — streamlined for deployments that use R2 exclusively | Use **main** for the full product and multiple cloud backends. @@ -193,7 +193,8 @@ npm test # Unit tests | Version | Plan | | ---------- | -------------------------------------------------------------- | -| **v1.1.0** | Multi-backend object storage, hybrid URLs, storage UI (current `main`) | +| **v1.1.1** | Session check fixes, storage placeholder cleanup, Docker logging (current `main`) | +| **v1.1.0** | Multi-backend object storage, hybrid URLs, storage UI | | **v1.0.4** | Password visibility toggle, legacy ADMIN_SECRET migration | | **v1.0.3** | CI and Docker multi-arch build fixes | | **v1.0.2** | i18n, mobile menus, stats/upload UI, Docker password reset | diff --git a/README.md b/README.md index 921f1fc..8079efb 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,7 @@ | 分支 | 说明 | | ---- | ---- | -| [**main**](https://github.com/O96u/PicHost/tree/main)(默认) | **v1.1.0** 主线:本地磁盘 + 多对象存储后端(R2 / COS / OSS / AWS)、[`/storage`](docs/README.md) 存储管理、混合直链 | +| [**main**](https://github.com/O96u/PicHost/tree/main)(默认) | **v1.1.1** 主线:本地磁盘 + 多对象存储后端(R2 / COS / OSS / AWS)、[`/storage`](docs/README.md) 存储管理、混合直链 | | [**cloudflare**](https://github.com/O96u/PicHost/tree/cloudflare) | **Cloudflare R2 专用线**:面向「只用 R2」的部署,预设与配置更聚焦 R2 | 日常使用、需要管理多种存储后端,请直接用 **main**。 @@ -198,7 +198,8 @@ npm test # 单元测试 | 版本 | 说明 | | ---------- | ------------------------------------------------- | -| **v1.1.0** | S3 / R2 多后端对象存储、混合直链、存储管理页(当前 main) | +| **v1.1.1** | 会话校验与存储占位修复、Docker 日志增强(当前 main) | +| **v1.1.0** | S3 / R2 多后端对象存储、混合直链、存储管理页 | | **v1.0.4** | 密码显示切换、遗留 ADMIN_SECRET 迁移对齐 | | **v1.0.3** | CI 与 Docker 多架构构建修复 | | **v1.0.2** | 中英文、移动端菜单、统计/上传 UI、Docker 重置密码 | diff --git a/app/composables/useAuth.ts b/app/composables/useAuth.ts index 96a83b4..edafde5 100644 --- a/app/composables/useAuth.ts +++ b/app/composables/useAuth.ts @@ -71,8 +71,10 @@ export function useAuth() { const status = useState('auth-status', () => 'checking') const user = useState('auth-user', () => null) const authStatus = useState('auth-status-response', () => null) + /** 是否已完成至少一次会话校验(用于避免路由切换时反复全屏 loading) */ + const hasValidatedOnce = useState('auth-validated-once', () => false) - const isChecking = computed(() => status.value === 'checking') + const isChecking = computed(() => status.value === 'checking' && !hasValidatedOnce.value) const isAuthenticated = computed(() => status.value === 'authenticated') const isAdmin = computed(() => user.value?.role === 'admin') @@ -84,10 +86,14 @@ export function useAuth() { return data } - async function checkSession() { - status.value = 'checking' + async function checkSession(options?: { force?: boolean }) { + const silent = !options?.force && hasValidatedOnce.value + if (!silent) { + status.value = 'checking' + } try { const data = await fetchStatus() + hasValidatedOnce.value = true if (data.user) { user.value = data.user status.value = 'authenticated' @@ -97,6 +103,7 @@ export function useAuth() { status.value = 'unauthenticated' return false } catch { + hasValidatedOnce.value = true user.value = null status.value = 'unauthenticated' return false @@ -129,6 +136,7 @@ export function useAuth() { user.value = result.user } status.value = 'authenticated' + hasValidatedOnce.value = true await fetchStatus() return { ok: true } } catch (error: unknown) { @@ -159,6 +167,7 @@ export function useAuth() { ) user.value = result.user status.value = 'authenticated' + hasValidatedOnce.value = true await fetchStatus() return { ok: true } } catch (error: unknown) { @@ -194,6 +203,7 @@ export function useAuth() { ) user.value = result.user status.value = 'authenticated' + hasValidatedOnce.value = true await fetchStatus() return { ok: true } } catch (error: unknown) { @@ -229,6 +239,7 @@ export function useAuth() { ) user.value = result.user status.value = 'authenticated' + hasValidatedOnce.value = true await fetchStatus() return { ok: true } } catch (error: unknown) { @@ -271,6 +282,7 @@ export function useAuth() { status.value = 'unauthenticated' user.value = null authStatus.value = null + hasValidatedOnce.value = true } } diff --git a/app/pages/api.vue b/app/pages/api.vue index 4f71930..6caed3b 100644 --- a/app/pages/api.vue +++ b/app/pages/api.vue @@ -240,10 +240,13 @@ onMounted(async () => { return } await checkSession() + if (isAuthenticated.value) { + await loadSettings() + } }) -watch(isAuthenticated, async (authed) => { - if (authed) { +watch(isAuthenticated, async (authed, prev) => { + if (authed && prev === false) { await nextTick() await loadSettings() } else { diff --git a/app/pages/settings/index.vue b/app/pages/settings/index.vue index 60bdc24..ef8e06b 100644 --- a/app/pages/settings/index.vue +++ b/app/pages/settings/index.vue @@ -161,11 +161,15 @@ onMounted(async () => { await checkSession() if (isAuthenticated.value && !isAdmin.value) { await navigateTo('/') + return + } + if (isAuthenticated.value && isAdmin.value) { + await loadPage() } }) -watch(isAuthenticated, async (authed) => { - if (authed) { +watch(isAuthenticated, async (authed, prev) => { + if (authed && prev === false) { if (!isAdmin.value) { await navigateTo('/') return diff --git a/app/pages/stats.vue b/app/pages/stats.vue index ad4e667..c0c41ff 100644 --- a/app/pages/stats.vue +++ b/app/pages/stats.vue @@ -316,6 +316,9 @@ onMounted(async () => { return } await checkSession() + if (isAuthenticated.value) { + await refreshAll() + } }) onUnmounted(() => { diff --git a/package-lock.json b/package-lock.json index 2fcc5cf..33d3029 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "pichost", - "version": "1.1.0", + "version": "1.1.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "pichost", - "version": "1.1.0", + "version": "1.1.1", "hasInstallScript": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index e2f2ab3..a73c900 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "pichost", - "version": "1.1.0", + "version": "1.1.1", "description": "Self-hosted image hosting for your NAS / home server — Nuxt admin UI, local disk or S3-compatible storage, server-side WebP compression, referer protection, Twikoo/EasyImage compatible.", "homepage": "https://github.com/O96u/PicHost#readme", "repository": { diff --git a/server/api/logs/index.get.ts b/server/api/logs/index.get.ts index dc5d928..35a8cd5 100644 --- a/server/api/logs/index.get.ts +++ b/server/api/logs/index.get.ts @@ -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) } }) diff --git a/server/api/stats.get.ts b/server/api/stats.get.ts index 6a9d6ba..d29a8ff 100644 --- a/server/api/stats.get.ts +++ b/server/api/stats.get.ts @@ -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) } }) diff --git a/server/plugins/error-log.ts b/server/plugins/error-log.ts new file mode 100644 index 0000000..315162d --- /dev/null +++ b/server/plugins/error-log.ts @@ -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) + } + }) +}) diff --git a/server/plugins/request-log.ts b/server/plugins/request-log.ts index a81727c..b675b6e 100644 --- a/server/plugins/request-log.ts +++ b/server/plugins/request-log.ts @@ -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 = { 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) + } }) }) diff --git a/server/plugins/startup.ts b/server/plugins/startup.ts index ff7d696..4877532 100644 --- a/server/plugins/startup.ts +++ b/server/plugins/startup.ts @@ -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) }) }) diff --git a/server/utils/auto-delete.ts b/server/utils/auto-delete.ts index d31d01b..75e275a 100644 --- a/server/utils/auto-delete.ts +++ b/server/utils/auto-delete.ts @@ -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 { }) deleted++ } catch (error) { - logWarn('auto-delete failed', { key: image.key, error }) + logWarn('auto-delete failed', { key: image.key, error: serializeError(error) }) failed++ } } diff --git a/server/utils/db.ts b/server/utils/db.ts index acb98ae..f1b03c0 100644 --- a/server/utils/db.ts +++ b/server/utils/db.ts @@ -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 + }) } } diff --git a/server/utils/logger.test.ts b/server/utils/logger.test.ts new file mode 100644 index 0000000..b8e40f7 --- /dev/null +++ b/server/utils/logger.test.ts @@ -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) + }) +}) diff --git a/server/utils/logger.ts b/server/utils/logger.ts index 299bc0f..f3a5440 100644 --- a/server/utils/logger.ts +++ b/server/utils/logger.ts @@ -7,6 +7,12 @@ const LEVEL_ORDER: Record = { 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 { + if (error instanceof Error) { + const h3 = error as H3LikeError + const out: Record = { + 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) { if (!shouldLog('debug')) return console.debug(formatLine('debug', message, extra)) @@ -47,6 +82,11 @@ export function logError(message: string, extra?: Record) { console.error(formatLine('error', message, extra)) } +/** message + 原始 error 对象(自动 serialize) */ +export function logException(message: string, error: unknown, extra?: Record) { + logError(message, { ...extra, error: serializeError(error) }) +} + export function clientIp(event: Parameters[0]): string { return getRequestIP(event, { xForwardedFor: true }) || '-' } diff --git a/server/utils/process-image-upload.ts b/server/utils/process-image-upload.ts index 6ce48d2..c5265c2 100644 --- a/server/utils/process-image-upload.ts +++ b/server/utils/process-image-upload.ts @@ -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' }) diff --git a/server/utils/storage-backends.ts b/server/utils/storage-backends.ts index d6636d8..0214d28 100644 --- a/server/utils/storage-backends.ts +++ b/server/utils/storage-backends.ts @@ -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 & {