release: PicHost v1.0.0
Publish Docker image / build-and-push (push) Canceled after 0s
ci / ci (22, ubuntu-latest) (push) Canceled after 0s

Multi-user image hosting with RBAC, upload preferences, and admin settings.
This commit is contained in:
O96u committed 2026-08-22 14:36:57 +08:00
commit bc4cba7e52
115 files changed
+26508

No files matched your search

+62
View File
@@ -0,0 +1,62 @@
import { describe, expect, it } from 'vitest'
import { validateImageKey } from './image-key'
/**
* Mirrors assertImageOwnership rules (access.ts) for documentation + regression.
*/
function canOperateImage(
actor: { type: 'user', id: number, role: 'user' | 'admin' }
| { type: 'user-token', id: number }
| { type: 'global-token' },
ownerId: number | null
): boolean {
if (actor.type === 'global-token') return true
if (actor.type === 'user-token') {
return ownerId === actor.id
}
if (actor.role === 'admin') return true
return ownerId !== null && ownerId === actor.id
}
describe('image ownership rules', () => {
it('regular user can only operate own images', () => {
const user1 = { type: 'user' as const, id: 1, role: 'user' as const }
expect(canOperateImage(user1, 1)).toBe(true)
expect(canOperateImage(user1, 2)).toBe(false)
expect(canOperateImage(user1, null)).toBe(false)
})
it('admin can operate any image', () => {
const admin = { type: 'user' as const, id: 1, role: 'admin' as const }
expect(canOperateImage(admin, 2)).toBe(true)
expect(canOperateImage(admin, null)).toBe(true)
})
it('user API token is scoped to owner', () => {
const token = { type: 'user-token' as const, id: 2 }
expect(canOperateImage(token, 2)).toBe(true)
expect(canOperateImage(token, 1)).toBe(false)
expect(canOperateImage(token, null)).toBe(false)
})
it('global API token can operate any image', () => {
const token = { type: 'global-token' as const }
expect(canOperateImage(token, 1)).toBe(true)
expect(canOperateImage(token, null)).toBe(true)
})
})
describe('path traversal hardening', () => {
it('rejects keys that could escape data dir', () => {
const attacks = [
'images/2026/08/../secret.webp',
'images/../../../etc/passwd',
'images//2026/08/a.webp',
'/images/2026/08/a.webp',
'images/2026/08/%2e%2e/other.webp'
]
for (const key of attacks) {
expect(validateImageKey(key), key).toBe(false)
}
})
})
+17
View File
@@ -0,0 +1,17 @@
import { describe, expect, it } from 'vitest'
import { createLegacySessionToken } from './access'
describe('createLegacySessionToken', () => {
it('returns a stable hex digest for the same secret', async () => {
const first = await createLegacySessionToken('test-secret')
const second = await createLegacySessionToken('test-secret')
expect(first).toBe(second)
expect(first).toMatch(/^[0-9a-f]{64}$/)
})
it('changes when the secret changes', async () => {
const a = await createLegacySessionToken('secret-a')
const b = await createLegacySessionToken('secret-b')
expect(a).not.toBe(b)
})
})
+256
View File
@@ -0,0 +1,256 @@
import type { H3Event } from 'h3'
import { findUserByApiToken } from './db'
import { getAdminSecret, getApiUploadToken } from './env'
import { createApiError } from './api-error'
import {
getCurrentUser,
type AuthUser
} from './auth'
const LEGACY_AUTH_COOKIE = 'pic_auth'
const LEGACY_SESSION_SALT = 'pic-session'
const LEGACY_COOKIE_MAX_AGE = 60 * 60 * 24 * 7
function timingSafeEqual(a: string, b: string): boolean {
if (a.length !== b.length) return false
let result = 0
for (let i = 0; i < a.length; i++) {
result |= a.charCodeAt(i) ^ b.charCodeAt(i)
}
return result === 0
}
export async function createLegacySessionToken(secret: string): Promise<string> {
const encoder = new TextEncoder()
const key = await crypto.subtle.importKey(
'raw',
encoder.encode(secret),
{ name: 'HMAC', hash: 'SHA-256' },
false,
['sign']
)
const sig = await crypto.subtle.sign('HMAC', key, encoder.encode(LEGACY_SESSION_SALT))
return Array.from(new Uint8Array(sig), byte => byte.toString(16).padStart(2, '0')).join('')
}
export async function setLegacyAuthCookie(event: H3Event, secret: string): Promise<void> {
const token = await createLegacySessionToken(secret)
const isHttps = getRequestURL(event, {
xForwardedHost: true,
xForwardedProto: true
}).protocol === 'https:'
setCookie(event, LEGACY_AUTH_COOKIE, token, {
httpOnly: true,
secure: isHttps,
sameSite: 'lax',
path: '/',
maxAge: LEGACY_COOKIE_MAX_AGE
})
}
export function clearLegacyAuthCookie(event: H3Event): void {
deleteCookie(event, LEGACY_AUTH_COOKIE, { path: '/' })
}
export async function verifyLegacyAuth(event: H3Event): Promise<boolean> {
const adminSecret = getAdminSecret(event)
if (!adminSecret) return false
const cookie = getCookie(event, LEGACY_AUTH_COOKIE)
if (!cookie) return false
const expected = await createLegacySessionToken(adminSecret)
return timingSafeEqual(cookie, expected)
}
export async function checkAdminSecret(
input: string,
secret: string
): Promise<boolean> {
return timingSafeEqual(input, secret)
}
function extractApiToken(event: H3Event): string {
const token = getHeader(event, 'auth-token')
return token?.trim() ?? ''
}
export function verifyGlobalApiUploadToken(event: H3Event): boolean {
const expected = getApiUploadToken(event)
if (!expected) return false
const token = extractApiToken(event)
if (!token) return false
return timingSafeEqual(token, expected)
}
export function resolveUserIdFromApiToken(event: H3Event): number | null {
const token = extractApiToken(event)
if (!token) return null
return findUserByApiToken(token)?.id ?? null
}
export function verifyApiUploadToken(event: H3Event): boolean {
return verifyGlobalApiUploadToken(event)
|| resolveUserIdFromApiToken(event) !== null
}
/** EasyImage 等图床在表单字段 token 中传 API_UPLOAD_TOKEN */
export function verifyUploadTokenValue(event: H3Event, token: string): boolean {
const expected = getApiUploadToken(event)
if (expected && token && timingSafeEqual(token, expected)) {
return true
}
return findUserByApiToken(token) !== null
}
function devBypass(event: H3Event): boolean {
const config = useRuntimeConfig(event)
return import.meta.dev && Boolean(config.devBypassAccess)
}
export async function requireUserAuth(event: H3Event): Promise<AuthUser> {
if (devBypass(event)) {
return { id: 0, username: 'dev', role: 'admin' }
}
const user = await getCurrentUser(event)
if (!user) {
createApiError(event, 'UNAUTHORIZED', '未授权访问,请先登录', 401)
}
return user
}
export async function requireAdminAuth(event: H3Event): Promise<AuthUser> {
const user = await requireUserAuth(event)
if (user.role !== 'admin') {
createApiError(event, 'FORBIDDEN', '需要管理员权限', 403)
}
return user
}
export async function requireUploadAuth(event: H3Event): Promise<void> {
if (devBypass(event)) {
return
}
if (verifyApiUploadToken(event)) {
return
}
const user = await getCurrentUser(event)
if (!user) {
createApiError(event, 'UNAUTHORIZED', '未授权访问,请先登录', 401)
}
}
/** 图库读删等管理接口:API Token 或网页登录 Session */
export async function requireApiOrAdminAuth(event: H3Event): Promise<void> {
if (devBypass(event)) {
return
}
if (verifyApiUploadToken(event)) {
return
}
const user = await getCurrentUser(event)
if (!user) {
createApiError(event, 'UNAUTHORIZED', '未授权访问,请先登录', 401)
}
}
/** 列表/搜索/删除时按用户过滤;全局 Token 视为管理员,用户 Token 仅见自己的 */
export async function getImageUserFilter(
event: H3Event
): Promise<number | 'admin'> {
if (devBypass(event)) {
return 'admin'
}
const userIdFromToken = resolveUserIdFromApiToken(event)
if (userIdFromToken !== null) {
return userIdFromToken
}
if (verifyGlobalApiUploadToken(event)) {
return 'admin'
}
const user = await requireUserAuth(event)
return user.role === 'admin' ? 'admin' : user.id
}
export async function assertImageOwnership(
event: H3Event,
userId: number | null | undefined
): Promise<void> {
if (devBypass(event)) {
return
}
const userIdFromToken = resolveUserIdFromApiToken(event)
if (userIdFromToken !== null) {
const ownerId = userId ?? null
if (ownerId !== userIdFromToken) {
createApiError(event, 'FORBIDDEN', '无权操作此图片', 403)
}
return
}
if (verifyGlobalApiUploadToken(event)) {
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)
}
}
export function resolveActivitySource(event: H3Event): 'api' | 'web' {
return verifyApiUploadToken(event) ? 'api' : 'web'
}
export async function getUploadUserId(event: H3Event): Promise<number | null> {
if (devBypass(event)) {
return null
}
const userIdFromToken = resolveUserIdFromApiToken(event)
if (userIdFromToken !== null) {
return userIdFromToken
}
if (verifyGlobalApiUploadToken(event)) {
return null
}
const user = await getCurrentUser(event)
return user?.id ?? null
}
/** 管理员会话 / 全局 API Token 可使用自定义上传目录 */
export async function canUseCustomUploadFolder(event: H3Event): Promise<boolean> {
if (devBypass(event)) {
return true
}
if (verifyGlobalApiUploadToken(event)) {
return true
}
if (resolveUserIdFromApiToken(event) !== null) {
return false
}
const user = await getCurrentUser(event)
return user?.role === 'admin'
}
+25
View File
@@ -0,0 +1,25 @@
import type { H3Event } from 'h3'
import type { ApiError, ErrorCode } from '~/types/image'
export function createApiError(
event: H3Event,
code: ErrorCode,
message: string,
statusCode = 400
): never {
throw createError({
statusCode,
statusMessage: message,
data: {
success: false,
error: { code, message } satisfies ApiError
}
})
}
export function isApiErrorResponse(data: unknown): data is { error: ApiError } {
return typeof data === 'object'
&& data !== null
&& 'error' in data
&& typeof (data as { error: unknown }).error === 'object'
}
+22
View File
@@ -0,0 +1,22 @@
import { describe, expect, it } from 'vitest'
import { hashPassword, isValidPassword, isValidUsername, verifyPassword } from './auth'
describe('auth password', () => {
it('hashes and verifies a password', () => {
const hash = hashPassword('test-password-123')
expect(hash.startsWith('scrypt$')).toBe(true)
expect(verifyPassword('test-password-123', hash)).toBe(true)
expect(verifyPassword('wrong-password', hash)).toBe(false)
})
it('rejects invalid passwords', () => {
expect(isValidPassword('short')).toBe(false)
expect(isValidPassword('long-enough')).toBe(true)
})
it('validates usernames', () => {
expect(isValidUsername('ab')).toBe(false)
expect(isValidUsername('user_1')).toBe(true)
expect(isValidUsername('bad name')).toBe(false)
})
})
+197
View File
@@ -0,0 +1,197 @@
import { randomBytes, scryptSync, timingSafeEqual, createHash } from 'node:crypto'
import type { H3Event } from 'h3'
import {
countUsers,
createSessionRow,
deleteSession,
findSessionById,
findUserById,
findUserByUsername,
insertUser,
type UserRole,
type UserRow
} from './db'
const SCRYPT_N = 16384
const SCRYPT_R = 8
const SCRYPT_P = 1
const SCRYPT_KEY_LEN = 64
const MIN_PASSWORD_LENGTH = 8
const USERNAME_PATTERN = /^[a-zA-Z0-9_]{3,32}$/
export const SESSION_COOKIE = 'pic_session'
const SESSION_MAX_AGE_SEC = 60 * 60 * 24 * 7 // 7 days
export interface AuthUser {
id: number
username: string
role: UserRole
}
function timingSafeEqualStr(a: string, b: string): boolean {
if (a.length !== b.length) return false
return timingSafeEqual(Buffer.from(a), Buffer.from(b))
}
export function isValidUsername(username: string): boolean {
return USERNAME_PATTERN.test(username)
}
export function isValidPassword(password: string): boolean {
return password.length >= MIN_PASSWORD_LENGTH
}
export function hashPassword(password: string): string {
const salt = randomBytes(16).toString('hex')
const derived = scryptSync(password, salt, SCRYPT_KEY_LEN, {
N: SCRYPT_N,
r: SCRYPT_R,
p: SCRYPT_P
})
return `scrypt$${SCRYPT_N}$${SCRYPT_R}$${SCRYPT_P}$${salt}$${derived.toString('hex')}`
}
export function verifyPassword(password: string, stored: string): boolean {
const parts = stored.split('$')
if (parts.length !== 6 || parts[0] !== 'scrypt') return false
const N = Number(parts[1])
const r = Number(parts[2])
const p = Number(parts[3])
const salt = parts[4]
const hashHex = parts[5]
if (!salt || !hashHex || !Number.isFinite(N)) return false
try {
const derived = scryptSync(password, salt, SCRYPT_KEY_LEN, { N, r, p })
return timingSafeEqualStr(derived.toString('hex'), hashHex)
} catch {
return false
}
}
function hashSessionToken(token: string): string {
return createHash('sha256').update(token).digest('hex')
}
function generateSessionToken(): string {
return randomBytes(32).toString('hex')
}
function sessionExpiresAt(): string {
return new Date(Date.now() + SESSION_MAX_AGE_SEC * 1000).toISOString()
}
function cookieSecure(event: H3Event): boolean {
return getRequestURL(event, {
xForwardedHost: true,
xForwardedProto: true
}).protocol === 'https:'
}
export function setSessionCookie(event: H3Event, sessionId: string): void {
setCookie(event, SESSION_COOKIE, sessionId, {
httpOnly: true,
secure: cookieSecure(event),
sameSite: 'lax',
path: '/',
maxAge: SESSION_MAX_AGE_SEC
})
}
export function clearSessionCookie(event: H3Event): void {
deleteCookie(event, SESSION_COOKIE, { path: '/' })
}
export async function createSession(
event: H3Event,
userId: number
): Promise<string> {
const sessionId = randomBytes(24).toString('hex')
const token = generateSessionToken()
const tokenHash = hashSessionToken(token)
const expiresAt = sessionExpiresAt()
const createdAt = new Date().toISOString()
createSessionRow({
id: sessionId,
userId,
tokenHash,
expiresAt,
createdAt
})
setSessionCookie(event, sessionId)
return sessionId
}
export async function destroySession(event: H3Event): Promise<void> {
const sessionId = getCookie(event, SESSION_COOKIE)
if (sessionId) {
deleteSession(sessionId)
}
clearSessionCookie(event)
}
export function rowToAuthUser(row: UserRow): AuthUser {
return {
id: row.id,
username: row.username,
role: row.role
}
}
export async function getCurrentUser(event: H3Event): Promise<AuthUser | null> {
const sessionId = getCookie(event, SESSION_COOKIE)
if (!sessionId) return null
const session = findSessionById(sessionId)
if (!session) return null
if (new Date(session.expires_at) <= new Date()) {
deleteSession(sessionId)
return null
}
const user = findUserById(session.user_id)
if (!user) return null
return rowToAuthUser(user)
}
export async function createUserAccount(input: {
username: string
password: string
role: UserRole
}): Promise<AuthUser> {
if (!isValidUsername(input.username)) {
throw new Error('INVALID_USERNAME')
}
if (!isValidPassword(input.password)) {
throw new Error('INVALID_PASSWORD')
}
const existing = findUserByUsername(input.username)
if (existing) {
throw new Error('USERNAME_TAKEN')
}
const passwordHash = hashPassword(input.password)
const createdAt = new Date().toISOString()
const id = insertUser({
username: input.username,
passwordHash,
role: input.role,
createdAt
})
return {
id,
username: input.username,
role: input.role
}
}
export function isInitialized(): boolean {
return countUsers() > 0
}
+99
View File
@@ -0,0 +1,99 @@
import { describe, expect, it } from 'vitest'
import { shouldAutoDeleteImage } from './auto-delete'
import type { StoredImage } from './storage'
function image(userId: number | null, uploadedAt: string): StoredImage {
return {
key: 'images/2026/01/a.webp',
originalName: 'a.webp',
uploadedAt,
contentType: 'image/webp',
size: 100,
mtimeMs: 0,
userId
}
}
const nowMs = Date.parse('2026-09-15T00:00:00.000Z')
const enabledAt = '2026-08-01T00:00:00.000Z'
const adminIds = new Set([1])
describe('shouldAutoDeleteImage', () => {
it('deletes user images by their own policy after enabled', () => {
const userPolicies = new Map([[2, { days: 30, enabledAt }]])
const uploadedAt = '2026-07-01T00:00:00.000Z'
expect(shouldAutoDeleteImage(
image(2, uploadedAt),
{ days: 0, enabledAt: null },
adminIds,
userPolicies,
nowMs
)).toBe(false)
expect(shouldAutoDeleteImage(
image(2, '2026-08-05T00:00:00.000Z'),
{ days: 0, enabledAt: null },
adminIds,
userPolicies,
nowMs
)).toBe(true)
})
it('does not delete other users images', () => {
const userPolicies = new Map([[2, { days: 30, enabledAt }]])
expect(shouldAutoDeleteImage(
image(3, '2026-07-01T00:00:00.000Z'),
{ days: 0, enabledAt: null },
adminIds,
userPolicies,
nowMs
)).toBe(false)
})
it('applies admin global policy only to admin or legacy images', () => {
const globalPolicy = { days: 30, enabledAt }
const userPolicies = new Map<number, { days: number, enabledAt: string | null }>()
expect(shouldAutoDeleteImage(
image(2, '2026-07-01T00:00:00.000Z'),
globalPolicy,
adminIds,
userPolicies,
nowMs
)).toBe(false)
expect(shouldAutoDeleteImage(
image(1, '2026-07-01T00:00:00.000Z'),
globalPolicy,
adminIds,
userPolicies,
nowMs
)).toBe(false)
expect(shouldAutoDeleteImage(
image(1, '2026-08-05T00:00:00.000Z'),
globalPolicy,
adminIds,
userPolicies,
nowMs
)).toBe(true)
expect(shouldAutoDeleteImage(
image(null, '2026-08-05T00:00:00.000Z'),
globalPolicy,
adminIds,
userPolicies,
nowMs
)).toBe(true)
})
it('keeps recent images', () => {
const userPolicies = new Map([[2, { days: 30, enabledAt }]])
expect(shouldAutoDeleteImage(
image(2, '2026-09-10T00:00:00.000Z'),
{ days: 0, enabledAt: null },
adminIds,
userPolicies,
nowMs
)).toBe(false)
})
})
+110
View File
@@ -0,0 +1,110 @@
import { insertActivityLog, listAdminUserIds, listUserAutoDeletePolicies, type AutoDeletePolicy } from './db'
import { getGlobalAutoDeletePolicy } from './env'
import { deleteImage, headImage, listImageKeys, type StoredImage } from './storage'
import { logInfo, logWarn } from './logger'
export interface AutoDeleteResult {
deleted: number
failed: number
skipped: boolean
days: number
}
const MS_PER_DAY = 24 * 60 * 60 * 1000
function imageAgeDays(uploadedMs: number, nowMs: number): number {
return (nowMs - uploadedMs) / MS_PER_DAY
}
function policyMatches(
uploadedMs: number,
nowMs: number,
policy: AutoDeletePolicy
): boolean {
if (policy.days <= 0) return false
if (policy.enabledAt) {
const enabledMs = new Date(policy.enabledAt).getTime()
if (!Number.isFinite(enabledMs) || uploadedMs < enabledMs) {
return false
}
}
return imageAgeDays(uploadedMs, nowMs) >= policy.days
}
export function shouldAutoDeleteImage(
image: StoredImage,
globalPolicy: AutoDeletePolicy,
adminUserIds: Set<number>,
userPolicies: Map<number, AutoDeletePolicy>,
nowMs = Date.now()
): boolean {
const uploadedMs = new Date(image.uploadedAt).getTime()
if (!Number.isFinite(uploadedMs) || uploadedMs > nowMs) {
return false
}
const ownerId = image.userId ?? null
if (ownerId != null) {
const userPolicy = userPolicies.get(ownerId)
if (userPolicy && policyMatches(uploadedMs, nowMs, userPolicy)) {
return true
}
if (adminUserIds.has(ownerId) && policyMatches(uploadedMs, nowMs, globalPolicy)) {
return true
}
return false
}
return policyMatches(uploadedMs, nowMs, globalPolicy)
}
export async function runAutoDeleteCleanup(): Promise<AutoDeleteResult> {
const globalPolicy = getGlobalAutoDeletePolicy()
const userPolicies = listUserAutoDeletePolicies()
const adminUserIds = listAdminUserIds()
if (globalPolicy.days <= 0 && userPolicies.size === 0) {
return { deleted: 0, failed: 0, skipped: true, days: 0 }
}
const nowMs = Date.now()
const keys = await listImageKeys()
let deleted = 0
let failed = 0
for (const key of keys) {
const image = await headImage(key)
if (!image) continue
if (!shouldAutoDeleteImage(image, globalPolicy, adminUserIds, userPolicies, nowMs)) {
continue
}
try {
await deleteImage(key)
insertActivityLog({
action: 'delete',
key,
originalName: image.originalName,
size: image.size,
contentType: image.contentType,
source: 'web'
})
deleted++
} catch (error) {
logWarn('auto-delete failed', { key, error })
failed++
}
}
if (deleted > 0 || failed > 0) {
logInfo('auto-delete cleanup finished', { globalDays: globalPolicy.days, deleted, failed })
}
return { deleted, failed, skipped: false, days: globalPolicy.days }
}
+19
View File
@@ -0,0 +1,19 @@
export const ALLOWED_MIME_TYPES = [
'image/jpeg',
'image/png',
'image/webp',
'image/gif',
'image/svg+xml',
'image/x-icon'
] as const
export type AllowedMimeType = (typeof ALLOWED_MIME_TYPES)[number]
export const MAX_FILE_SIZE = 10 * 1024 * 1024
export const MAX_FILES_PER_UPLOAD = 10
export const MAX_DELETE_BATCH = 100
export const DEFAULT_LIST_LIMIT = 30
export const MAX_LIST_LIMIT = 100
export const IMAGE_CACHE_CONTROL
= 'public, max-age=86400, s-maxage=604800, stale-while-revalidate=86400'
+570
View File
@@ -0,0 +1,570 @@
import { DatabaseSync } from 'node:sqlite'
import { mkdirSync } from 'node:fs'
import { join } from 'node:path'
import { getDataDir } from './storage'
export type LogAction = 'upload' | 'delete'
export type LogSource = 'web' | 'api' | 'twikoo'
export interface ActivityLogRow {
id: number
action: LogAction
key: string
original_name: string
size: number
content_type: string
source: LogSource
created_at: string
}
export interface ActivityLogInput {
action: LogAction
key: string
originalName: string
size: number
contentType: string
source: LogSource
createdAt?: string
}
let db: DatabaseSync | null = null
function getDb(): DatabaseSync {
if (db) return db
const dataDir = getDataDir()
mkdirSync(dataDir, { recursive: true })
const path = join(dataDir, 'pichost.db')
db = new DatabaseSync(path)
db.exec(`
PRAGMA journal_mode = WAL;
CREATE TABLE IF NOT EXISTS activity_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
action TEXT NOT NULL CHECK(action IN ('upload', 'delete')),
key TEXT NOT NULL,
original_name TEXT NOT NULL,
size INTEGER NOT NULL DEFAULT 0,
content_type TEXT NOT NULL DEFAULT '',
source TEXT NOT NULL CHECK(source IN ('web', 'api', 'twikoo')),
created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_activity_logs_created_at
ON activity_logs(created_at DESC);
CREATE INDEX IF NOT EXISTS idx_activity_logs_action_created
ON activity_logs(action, created_at DESC);
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
role TEXT NOT NULL CHECK(role IN ('admin', 'user')),
created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY,
user_id INTEGER NOT NULL,
token_hash TEXT NOT NULL,
expires_at TEXT NOT NULL,
created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_sessions_user_id ON sessions(user_id);
CREATE INDEX IF NOT EXISTS idx_sessions_expires_at ON sessions(expires_at);
`)
migrateSchema(db)
return db
}
function migrateSchema(database: DatabaseSync): void {
const columns = database.prepare('PRAGMA table_info(users)').all() as Array<{
name: string
}>
if (!columns.some(column => column.name === 'api_token')) {
database.exec('ALTER TABLE users ADD COLUMN api_token TEXT')
database.exec(
'CREATE UNIQUE INDEX IF NOT EXISTS idx_users_api_token ON users(api_token) WHERE api_token IS NOT NULL'
)
}
if (!columns.some(column => column.name === 'auto_delete_days')) {
database.exec('ALTER TABLE users ADD COLUMN auto_delete_days INTEGER NOT NULL DEFAULT 0')
}
const userColumns = database.prepare('PRAGMA table_info(users)').all() as Array<{
name: string
}>
if (!userColumns.some(column => column.name === 'auto_delete_enabled_at')) {
database.exec('ALTER TABLE users ADD COLUMN auto_delete_enabled_at TEXT')
database.exec(`
UPDATE users
SET auto_delete_enabled_at = datetime('now')
WHERE auto_delete_days > 0 AND auto_delete_enabled_at IS NULL
`)
}
const daysRow = database.prepare(`
SELECT value FROM settings WHERE key = 'auto_delete_days'
`).get() as { value: string } | undefined
const enabledAtRow = database.prepare(`
SELECT value FROM settings WHERE key = 'auto_delete_enabled_at'
`).get() as { value: string } | undefined
const globalDays = daysRow ? Number(daysRow.value) : 0
if (globalDays > 0 && !enabledAtRow) {
const now = new Date().toISOString()
database.prepare(`
INSERT INTO settings (key, value, updated_at)
VALUES ('auto_delete_enabled_at', ?, ?)
`).run(now, now)
}
}
const USER_COLUMNS = 'id, username, password_hash, role, created_at, api_token, auto_delete_days, auto_delete_enabled_at'
export type UserRole = 'admin' | 'user'
export interface UserRow {
id: number
username: string
password_hash: string
role: UserRole
created_at: string
api_token: string | null
auto_delete_days: number
auto_delete_enabled_at: string | null
}
export interface AutoDeletePolicy {
days: number
enabledAt: string | null
}
export interface SessionRow {
id: string
user_id: number
token_hash: string
expires_at: string
created_at: string
}
export function countUsers(): number {
const row = getDb().prepare('SELECT COUNT(*) AS count FROM users').get() as {
count: number
}
return row.count
}
export function listUserIdUsernameMap(): Map<number, string> {
const rows = getDb().prepare(`
SELECT id, username
FROM users
`).all() as Array<{ id: number, username: string }>
return new Map(rows.map(row => [row.id, row.username]))
}
export function findUserById(id: number): UserRow | null {
const row = getDb().prepare(`
SELECT ${USER_COLUMNS}
FROM users
WHERE id = ?
`).get(id) as UserRow | undefined
return row ?? null
}
export function findUserByUsername(username: string): UserRow | null {
const row = getDb().prepare(`
SELECT ${USER_COLUMNS}
FROM users
WHERE username = ?
`).get(username) as UserRow | undefined
return row ?? null
}
export function findUserByApiToken(token: string): UserRow | null {
if (!token) return null
const row = getDb().prepare(`
SELECT ${USER_COLUMNS}
FROM users
WHERE api_token = ?
`).get(token) as UserRow | undefined
return row ?? null
}
export function getUserApiToken(userId: number): string | null {
const row = getDb().prepare(`
SELECT api_token
FROM users
WHERE id = ?
`).get(userId) as { api_token: string | null } | undefined
return row?.api_token ?? null
}
export function setUserApiToken(userId: number, token: string): void {
getDb().prepare(`
UPDATE users
SET api_token = ?
WHERE id = ?
`).run(token, userId)
}
export function getUserAutoDeletePolicy(userId: number): AutoDeletePolicy {
const row = getDb().prepare(`
SELECT auto_delete_days, auto_delete_enabled_at
FROM users
WHERE id = ?
`).get(userId) as { auto_delete_days: number, auto_delete_enabled_at: string | null } | undefined
return {
days: row?.auto_delete_days ?? 0,
enabledAt: row?.auto_delete_enabled_at ?? null
}
}
export function getUserAutoDeleteDays(userId: number): number {
return getUserAutoDeletePolicy(userId).days
}
export function setUserAutoDeletePolicy(userId: number, days: number): void {
const current = getUserAutoDeletePolicy(userId)
const now = new Date().toISOString()
let enabledAt = current.enabledAt
if (days > 0 && current.days === 0) {
enabledAt = now
} else if (days === 0) {
enabledAt = null
}
getDb().prepare(`
UPDATE users
SET auto_delete_days = ?, auto_delete_enabled_at = ?
WHERE id = ?
`).run(days, enabledAt, userId)
}
export function listUserAutoDeletePolicies(): Map<number, AutoDeletePolicy> {
const rows = getDb().prepare(`
SELECT id, auto_delete_days, auto_delete_enabled_at
FROM users
WHERE auto_delete_days > 0
`).all() as Array<{
id: number
auto_delete_days: number
auto_delete_enabled_at: string | null
}>
return new Map(rows.map(row => [
row.id,
{ days: row.auto_delete_days, enabledAt: row.auto_delete_enabled_at }
]))
}
export function listAdminUserIds(): Set<number> {
const rows = getDb().prepare(`
SELECT id
FROM users
WHERE role = 'admin'
`).all() as Array<{ id: number }>
return new Set(rows.map(row => row.id))
}
export function setUserAutoDeleteDays(userId: number, days: number): void {
setUserAutoDeletePolicy(userId, days)
}
export function listUserAutoDeleteDays(): Map<number, number> {
const policies = listUserAutoDeletePolicies()
return new Map([...policies.entries()].map(([id, policy]) => [id, policy.days]))
}
export function updateUserPassword(userId: number, passwordHash: string): void {
getDb().prepare(`
UPDATE users
SET password_hash = ?
WHERE id = ?
`).run(passwordHash, userId)
}
export function ensureUserApiToken(userId: number): string {
const existing = getUserApiToken(userId)
if (existing) return existing
const token = generateApiUploadToken()
setUserApiToken(userId, token)
return token
}
export function insertUser(input: {
username: string
passwordHash: string
role: UserRole
createdAt: string
}): number {
const result = getDb().prepare(`
INSERT INTO users (username, password_hash, role, created_at)
VALUES (?, ?, ?, ?)
`).run(input.username, input.passwordHash, input.role, input.createdAt)
return Number(result.lastInsertRowid)
}
export function createSessionRow(input: {
id: string
userId: number
tokenHash: string
expiresAt: string
createdAt: string
}): void {
getDb().prepare(`
INSERT INTO sessions (id, user_id, token_hash, expires_at, created_at)
VALUES (?, ?, ?, ?, ?)
`).run(
input.id,
input.userId,
input.tokenHash,
input.expiresAt,
input.createdAt
)
}
export function findSessionById(id: string): SessionRow | null {
const row = getDb().prepare(`
SELECT id, user_id, token_hash, expires_at, created_at
FROM sessions
WHERE id = ?
`).get(id) as SessionRow | undefined
return row ?? null
}
export function deleteSession(id: string): void {
getDb().prepare('DELETE FROM sessions WHERE id = ?').run(id)
}
export function deleteSessionsForUser(userId: number): void {
getDb().prepare('DELETE FROM sessions WHERE user_id = ?').run(userId)
}
export const SETTINGS_API_UPLOAD_TOKEN = 'api_upload_token'
export const SETTINGS_WEBP_QUALITY = 'webp_quality'
export const SETTINGS_ALLOWED_REFERER_HOSTS = 'allowed_referer_hosts'
export const SETTINGS_IMAGE_BASE_URL = 'image_base_url'
export const SETTINGS_AUTO_DELETE_DAYS = 'auto_delete_days'
export const SETTINGS_AUTO_DELETE_ENABLED_AT = 'auto_delete_enabled_at'
export const SETTINGS_ALLOW_REGISTRATION = 'allow_registration'
export function isAllowRegistration(): boolean {
return getSetting(SETTINGS_ALLOW_REGISTRATION) === 'true'
}
export function setAllowRegistration(allow: boolean): void {
setSetting(SETTINGS_ALLOW_REGISTRATION, allow ? 'true' : 'false')
}
export function getSetting(key: string): string | null {
const row = getDb().prepare(`
SELECT value
FROM settings
WHERE key = ?
`).get(key) as { value: string } | undefined
return row?.value ?? null
}
export function setSetting(key: string, value: string): void {
const updatedAt = new Date().toISOString()
getDb().prepare(`
INSERT INTO settings (key, value, updated_at)
VALUES (?, ?, ?)
ON CONFLICT(key) DO UPDATE SET
value = excluded.value,
updated_at = excluded.updated_at
`).run(key, value, updatedAt)
}
export function generateApiUploadToken(): string {
const bytes = new Uint8Array(32)
crypto.getRandomValues(bytes)
return Array.from(bytes, byte => byte.toString(16).padStart(2, '0')).join('')
}
export function insertActivityLog(input: ActivityLogInput): void {
try {
const createdAt = input.createdAt ?? new Date().toISOString()
getDb().prepare(`
INSERT INTO activity_logs
(action, key, original_name, size, content_type, source, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
`).run(
input.action,
input.key,
input.originalName,
input.size,
input.contentType,
input.source,
createdAt
)
} catch (error) {
console.error('[activity-log] insert failed', error)
}
}
export function listActivityLogs(options: {
limit: number
page?: number
action?: LogAction
source?: LogSource
folder?: string
}): {
items: ActivityLogRow[]
total: number
page: number
pageSize: number
totalPages: number
} {
const database = getDb()
const pageSize = options.limit
const params: Array<string | number> = []
const where: string[] = []
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
${whereSql}
`).get(...params) as { count: number }
const total = countRow.count
const totalPages = Math.max(1, Math.ceil(total / pageSize) || 1)
const requestedPage = Math.max(1, options.page ?? 1)
const safePage = Math.min(requestedPage, totalPages)
const offset = (safePage - 1) * pageSize
const rows = database.prepare(`
SELECT id, action, key, original_name, size, content_type, source, created_at
FROM activity_logs
${whereSql}
ORDER BY id DESC
LIMIT ? OFFSET ?
`).all(...params, pageSize, offset) as unknown as ActivityLogRow[]
return {
items: rows,
total,
page: safePage,
pageSize,
totalPages
}
}
function countSince(action: LogAction, sinceIso: string): number {
const row = getDb().prepare(`
SELECT COUNT(*) AS total
FROM activity_logs
WHERE action = ? AND created_at >= ?
`).get(action, sinceIso) as unknown as { total: number } | undefined
return row?.total ?? 0
}
function sumUploadBytes(): number {
const row = getDb().prepare(`
SELECT COALESCE(SUM(size), 0) AS total
FROM activity_logs
WHERE action = 'upload'
`).get() as unknown as { total: number } | undefined
return row?.total ?? 0
}
function countByUploadFolder(): Array<{ folder: string, count: number }> {
const rows = getDb().prepare(`
SELECT
CASE
WHEN instr(key, '/') > 0 THEN substr(key, 1, instr(key, '/') - 1)
ELSE '(root)'
END AS folder,
COUNT(*) AS total
FROM activity_logs
WHERE action = 'upload'
GROUP BY folder
ORDER BY total DESC, folder ASC
`).all() as unknown as Array<{ folder: string, total: number }>
return rows.map(row => ({ folder: row.folder, count: row.total }))
}
function countBySource(): Record<LogSource, number> {
const rows = getDb().prepare(`
SELECT source, COUNT(*) AS total
FROM activity_logs
WHERE action = 'upload'
GROUP BY source
`).all() as unknown as Array<{ source: LogSource, total: number }>
const result: Record<LogSource, number> = { web: 0, api: 0, twikoo: 0 }
for (const row of rows) {
result[row.source] = row.total
}
return result
}
function startOfDayIsoInShanghai(now = new Date()): string {
// Asia/Shanghai = UTC+8,没有夏令时
const shifted = new Date(now.getTime() + 8 * 60 * 60 * 1000)
const y = shifted.getUTCFullYear()
const m = shifted.getUTCMonth()
const d = shifted.getUTCDate()
return new Date(Date.UTC(y, m, d) - 8 * 60 * 60 * 1000).toISOString()
}
function startOfMonthIsoInShanghai(now = new Date()): string {
const shifted = new Date(now.getTime() + 8 * 60 * 60 * 1000)
const y = shifted.getUTCFullYear()
const m = shifted.getUTCMonth()
return new Date(Date.UTC(y, m, 1) - 8 * 60 * 60 * 1000).toISOString()
}
export function getActivityStats(): {
uploadToday: number
uploadMonth: number
deleteToday: number
deleteMonth: number
uploadTotal: number
deleteTotal: number
uploadBytesTotal: number
bySource: Record<LogSource, number>
byFolderUploads: Array<{ folder: string, count: number }>
} {
const startOfDay = startOfDayIsoInShanghai()
const startOfMonth = startOfMonthIsoInShanghai()
const uploadTotalRow = getDb().prepare(`
SELECT COUNT(*) AS total FROM activity_logs WHERE action = 'upload'
`).get() as unknown as { total: number }
const deleteTotalRow = getDb().prepare(`
SELECT COUNT(*) AS total FROM activity_logs WHERE action = 'delete'
`).get() as unknown as { total: number }
return {
uploadToday: countSince('upload', startOfDay),
uploadMonth: countSince('upload', startOfMonth),
deleteToday: countSince('delete', startOfDay),
deleteMonth: countSince('delete', startOfMonth),
uploadTotal: uploadTotalRow.total,
deleteTotal: deleteTotalRow.total,
uploadBytesTotal: sumUploadBytes(),
bySource: countBySource(),
byFolderUploads: countByUploadFolder()
}
}
+203
View File
@@ -0,0 +1,203 @@
import { describe, expect, it, vi, afterEach } from 'vitest'
import type { H3Event } from 'h3'
import {
getApiUploadToken,
getApiUploadTokenFromEnv,
getApiUploadTokenSource,
getAllowedRefererHostsRaw,
getAutoDeleteDays,
getAutoDeleteDaysSource,
getRefererSource,
getWebpQuality,
getWebpQualitySource,
isApiUploadTokenEnvConfigured,
isAutoDeleteDaysEnvConfigured,
isRefererEnvConfigured,
isWebpQualityEnvConfigured
} from './env'
import {
getSetting,
SETTINGS_ALLOWED_REFERER_HOSTS,
SETTINGS_API_UPLOAD_TOKEN,
SETTINGS_AUTO_DELETE_DAYS,
SETTINGS_WEBP_QUALITY,
generateApiUploadToken
} from './db'
vi.mock('./db', async (importOriginal) => {
const actual = await importOriginal<typeof import('./db')>()
return {
...actual,
getSetting: vi.fn(),
setSetting: vi.fn()
}
})
vi.stubGlobal('useRuntimeConfig', () => ({
apiUploadToken: ''
}))
function mockEvent(): H3Event {
return {
node: { req: {}, res: {} },
context: {}
} as H3Event
}
describe('getApiUploadToken priority', () => {
afterEach(() => {
vi.clearAllMocks()
delete process.env.API_UPLOAD_TOKEN
delete process.env.NUXT_API_UPLOAD_TOKEN
})
it('prefers environment variable over database', () => {
process.env.API_UPLOAD_TOKEN = 'env-token'
vi.mocked(getSetting).mockReturnValue('db-token')
const event = mockEvent()
expect(getApiUploadToken(event)).toBe('env-token')
expect(getApiUploadTokenSource(event)).toBe('env')
expect(isApiUploadTokenEnvConfigured(event)).toBe(true)
})
it('falls back to database when env is unset', () => {
vi.mocked(getSetting).mockImplementation(key =>
key === SETTINGS_API_UPLOAD_TOKEN ? 'db-token' : null
)
const event = mockEvent()
expect(getApiUploadToken(event)).toBe('db-token')
expect(getApiUploadTokenSource(event)).toBe('db')
expect(isApiUploadTokenEnvConfigured(event)).toBe(false)
})
it('returns empty when neither env nor db is configured', () => {
vi.mocked(getSetting).mockReturnValue(null)
const event = mockEvent()
expect(getApiUploadToken(event)).toBe('')
expect(getApiUploadTokenSource(event)).toBe('none')
})
})
describe('generateApiUploadToken', () => {
it('returns a 64-char hex string', () => {
const token = generateApiUploadToken()
expect(token).toMatch(/^[0-9a-f]{64}$/)
})
it('generates unique tokens', () => {
const a = generateApiUploadToken()
const b = generateApiUploadToken()
expect(a).not.toBe(b)
})
})
describe('settings storage helpers', () => {
it('getApiUploadTokenFromEnv reads API_UPLOAD_TOKEN', () => {
process.env.API_UPLOAD_TOKEN = 'from-env'
const event = mockEvent()
expect(getApiUploadTokenFromEnv(event)).toBe('from-env')
delete process.env.API_UPLOAD_TOKEN
})
})
describe('getWebpQuality priority', () => {
afterEach(() => {
vi.clearAllMocks()
delete process.env.WEBP_QUALITY
})
it('prefers database over environment variable', () => {
process.env.WEBP_QUALITY = '95'
vi.mocked(getSetting).mockImplementation(key =>
key === SETTINGS_WEBP_QUALITY ? '72' : null
)
expect(getWebpQuality()).toBe(72)
expect(getWebpQualitySource()).toBe('db')
})
it('falls back to environment variable when db is unset', () => {
process.env.WEBP_QUALITY = '95'
vi.mocked(getSetting).mockReturnValue(null)
expect(getWebpQuality()).toBe(95)
expect(getWebpQualitySource()).toBe('env')
expect(isWebpQualityEnvConfigured()).toBe(true)
})
it('uses default 80 when neither env nor db is configured', () => {
vi.mocked(getSetting).mockReturnValue(null)
expect(getWebpQuality()).toBe(80)
expect(getWebpQualitySource()).toBe('default')
})
})
describe('getAllowedRefererHostsRaw priority', () => {
afterEach(() => {
vi.clearAllMocks()
delete process.env.ALLOWED_REFERER_HOSTS
})
it('prefers database over environment variable', () => {
process.env.ALLOWED_REFERER_HOSTS = 'blog.example.com'
vi.mocked(getSetting).mockImplementation(key =>
key === SETTINGS_ALLOWED_REFERER_HOSTS ? 'wiki.example.com' : null
)
expect(getAllowedRefererHostsRaw()).toBe('wiki.example.com')
expect(getRefererSource()).toBe('db')
})
it('falls back to environment variable when db is unset', () => {
process.env.ALLOWED_REFERER_HOSTS = 'blog.example.com'
vi.mocked(getSetting).mockReturnValue(null)
expect(getAllowedRefererHostsRaw()).toBe('blog.example.com')
expect(getRefererSource()).toBe('env')
expect(isRefererEnvConfigured()).toBe(true)
})
it('returns empty when neither env nor db is configured', () => {
vi.mocked(getSetting).mockReturnValue(null)
expect(getAllowedRefererHostsRaw()).toBe('')
expect(getRefererSource()).toBe('none')
})
it('allows database empty string to override env', () => {
process.env.ALLOWED_REFERER_HOSTS = 'blog.example.com'
vi.mocked(getSetting).mockImplementation(key =>
key === SETTINGS_ALLOWED_REFERER_HOSTS ? '' : null
)
expect(getAllowedRefererHostsRaw()).toBe('')
expect(getRefererSource()).toBe('db')
})
})
describe('getAutoDeleteDays priority', () => {
afterEach(() => {
vi.clearAllMocks()
delete process.env.AUTO_DELETE_DAYS
})
it('prefers database over environment variable', () => {
process.env.AUTO_DELETE_DAYS = '90'
vi.mocked(getSetting).mockImplementation(key =>
key === SETTINGS_AUTO_DELETE_DAYS ? '30' : null
)
expect(getAutoDeleteDays()).toBe(30)
expect(getAutoDeleteDaysSource()).toBe('db')
})
it('falls back to environment variable when db is unset', () => {
process.env.AUTO_DELETE_DAYS = '45'
vi.mocked(getSetting).mockReturnValue(null)
expect(getAutoDeleteDays()).toBe(45)
expect(getAutoDeleteDaysSource()).toBe('env')
expect(isAutoDeleteDaysEnvConfigured()).toBe(true)
})
it('returns 0 when neither env nor db is configured', () => {
vi.mocked(getSetting).mockReturnValue(null)
expect(getAutoDeleteDays()).toBe(0)
expect(getAutoDeleteDaysSource()).toBe('default')
})
})
+339
View File
@@ -0,0 +1,339 @@
import type { H3Event } from 'h3'
import {
getSetting,
setSetting,
SETTINGS_ALLOWED_REFERER_HOSTS,
SETTINGS_API_UPLOAD_TOKEN,
SETTINGS_AUTO_DELETE_DAYS,
SETTINGS_AUTO_DELETE_ENABLED_AT,
SETTINGS_IMAGE_BASE_URL,
SETTINGS_WEBP_QUALITY,
isAllowRegistration
} from './db'
export function getRuntimeEnv(
event: H3Event,
envKey: string,
configKey?: string
): string {
try {
const fromProcess = process.env[envKey]
if (fromProcess) {
return fromProcess
}
} catch {
// process.env may be unavailable in some runtimes
}
if (configKey) {
const config = useRuntimeConfig(event) as Record<string, unknown>
const fromConfig = config[configKey]
if (typeof fromConfig === 'string' && fromConfig.length > 0) {
return fromConfig
}
}
return ''
}
export function isAdminSecretConfigured(event: H3Event): boolean {
return getAdminSecret(event).length > 0
}
export function getAdminSecret(event: H3Event): string {
return getRuntimeEnv(event, 'ADMIN_SECRET', 'adminSecret')
|| getRuntimeEnv(event, 'NUXT_ADMIN_SECRET', 'adminSecret')
}
export type SettingSource = 'env' | 'db' | 'none'
export function normalizeImageBaseUrl(raw: string): string {
return raw.trim().replace(/\/$/, '')
}
export function getImageBaseUrlFromEnv(event: H3Event): string {
return normalizeImageBaseUrl(
getRuntimeEnv(event, 'IMAGE_BASE_URL', 'imageBaseUrl')
)
}
export function isImageBaseUrlEnvConfigured(event: H3Event): boolean {
return getImageBaseUrlFromEnv(event).length > 0
}
export function getImageBaseUrlSource(event: H3Event): SettingSource {
if (getSetting(SETTINGS_IMAGE_BASE_URL) !== null) return 'db'
if (isImageBaseUrlEnvConfigured(event)) return 'env'
return 'none'
}
export function getImageBaseUrlConfigured(event: H3Event): string {
const dbRaw = getSetting(SETTINGS_IMAGE_BASE_URL)
if (dbRaw !== null) return normalizeImageBaseUrl(dbRaw)
return getImageBaseUrlFromEnv(event)
}
export function getImageBaseUrl(event: H3Event): string {
const dbRaw = getSetting(SETTINGS_IMAGE_BASE_URL)
if (dbRaw !== null) {
const configured = normalizeImageBaseUrl(dbRaw)
if (configured) return configured
} else {
const fromEnv = getImageBaseUrlFromEnv(event)
if (fromEnv) return fromEnv
}
// 单域名部署:未配置时用当前请求的域名(支持反代转发头)
const requestUrl = getRequestURL(event, {
xForwardedHost: true,
xForwardedProto: true
})
return requestUrl.origin
}
export function getApiUploadTokenFromEnv(event: H3Event): string {
return (
getRuntimeEnv(event, 'API_UPLOAD_TOKEN', 'apiUploadToken')
|| getRuntimeEnv(event, 'NUXT_API_UPLOAD_TOKEN', 'apiUploadToken')
)
}
export function isApiUploadTokenEnvConfigured(event: H3Event): boolean {
return getApiUploadTokenFromEnv(event).length > 0
}
export type ApiUploadTokenSource = SettingSource
export function getApiUploadTokenSource(event: H3Event): ApiUploadTokenSource {
if (getApiUploadTokenFromEnv(event)) return 'env'
if (getSetting(SETTINGS_API_UPLOAD_TOKEN)) return 'db'
return 'none'
}
export function isApiUploadTokenConfigured(event: H3Event): boolean {
return getApiUploadToken(event).length > 0
}
/** 优先级:环境变量 → SQLite settings → runtimeConfig */
export function getApiUploadToken(event: H3Event): string {
const fromEnv = getApiUploadTokenFromEnv(event)
if (fromEnv) return fromEnv
const fromDb = getSetting(SETTINGS_API_UPLOAD_TOKEN)
if (fromDb) return fromDb
return ''
}
export const DEFAULT_WEBP_QUALITY = 80
export type WebpQualitySource = 'env' | 'db' | 'default'
export function parseWebpQuality(raw: string): number | null {
const value = Number(raw)
if (Number.isFinite(value) && value >= 1 && value <= 100) {
return Math.round(value)
}
return null
}
export function getWebpQualityFromEnv(): string {
try {
return process.env.WEBP_QUALITY?.trim() ?? ''
} catch {
return ''
}
}
export function isWebpQualityEnvConfigured(): boolean {
return parseWebpQuality(getWebpQualityFromEnv()) !== null
}
export function getWebpQualitySource(): WebpQualitySource {
if (getSetting(SETTINGS_WEBP_QUALITY) !== null) return 'db'
if (isWebpQualityEnvConfigured()) return 'env'
return 'default'
}
/** 优先级:SQLite settings → 环境变量 → 默认 80 */
export function getWebpQuality(_event?: H3Event): number {
const dbRaw = getSetting(SETTINGS_WEBP_QUALITY)
if (dbRaw !== null) {
const fromDb = parseWebpQuality(dbRaw)
if (fromDb !== null) return fromDb
}
const fromEnv = parseWebpQuality(getWebpQualityFromEnv())
if (fromEnv !== null) return fromEnv
return DEFAULT_WEBP_QUALITY
}
export function normalizeRefererHosts(raw: string): string {
const seen = new Set<string>()
const hosts: string[] = []
for (const part of raw.split(',')) {
const host = part.trim().toLowerCase()
if (!host || seen.has(host)) continue
seen.add(host)
hosts.push(host)
}
return hosts.join(',')
}
export function getRefererHostsFromEnv(): string {
try {
return normalizeRefererHosts(process.env.ALLOWED_REFERER_HOSTS ?? '')
} catch {
return ''
}
}
export function isRefererEnvConfigured(): boolean {
return getRefererHostsFromEnv().length > 0
}
export function getRefererSource(): SettingSource {
if (getSetting(SETTINGS_ALLOWED_REFERER_HOSTS) !== null) return 'db'
if (isRefererEnvConfigured()) return 'env'
return 'none'
}
/** 优先级:SQLite settings → 环境变量;均可不配置 */
export function getAllowedRefererHostsRaw(_event?: H3Event): string {
const dbRaw = getSetting(SETTINGS_ALLOWED_REFERER_HOSTS)
if (dbRaw !== null) return normalizeRefererHosts(dbRaw)
return getRefererHostsFromEnv()
}
export function isRefererConfigured(event?: H3Event): boolean {
return getAllowedRefererHostsRaw(event).length > 0
}
export const DEFAULT_AUTO_DELETE_DAYS = 0
export const MAX_AUTO_DELETE_DAYS = 3650
export type AutoDeleteDaysSource = 'env' | 'db' | 'default'
export function parseAutoDeleteDays(raw: string): number | null {
const trimmed = raw.trim()
if (!trimmed) return null
const value = Number(trimmed)
if (!Number.isFinite(value) || value < 0 || !Number.isInteger(value)) {
return null
}
if (value > MAX_AUTO_DELETE_DAYS) {
return null
}
return value
}
export function getAutoDeleteDaysFromEnv(): string {
try {
return process.env.AUTO_DELETE_DAYS?.trim() ?? ''
} catch {
return ''
}
}
export function isAutoDeleteDaysEnvConfigured(): boolean {
return parseAutoDeleteDays(getAutoDeleteDaysFromEnv()) !== null
}
export function getAutoDeleteDaysSource(): AutoDeleteDaysSource {
if (getSetting(SETTINGS_AUTO_DELETE_DAYS) !== null) return 'db'
if (isAutoDeleteDaysEnvConfigured()) return 'env'
return 'default'
}
/** 优先级:SQLite settings → 环境变量 → 默认 0(关闭) */
export function getAutoDeleteDays(_event?: H3Event): number {
const dbRaw = getSetting(SETTINGS_AUTO_DELETE_DAYS)
if (dbRaw !== null) {
const fromDb = parseAutoDeleteDays(dbRaw)
if (fromDb !== null) return fromDb
}
const fromEnv = parseAutoDeleteDays(getAutoDeleteDaysFromEnv())
if (fromEnv !== null) return fromEnv
return DEFAULT_AUTO_DELETE_DAYS
}
export function getGlobalAutoDeleteEnabledAt(): string | null {
const raw = getSetting(SETTINGS_AUTO_DELETE_ENABLED_AT)
return raw?.trim() ? raw : null
}
export function getGlobalAutoDeletePolicy(): { days: number, enabledAt: string | null } {
return {
days: getAutoDeleteDays(),
enabledAt: getGlobalAutoDeleteEnabledAt()
}
}
export function setGlobalAutoDeletePolicy(days: number): void {
const dbRaw = getSetting(SETTINGS_AUTO_DELETE_DAYS)
const currentDays = dbRaw !== null ? (parseAutoDeleteDays(dbRaw) ?? 0) : 0
const currentEnabledAt = getGlobalAutoDeleteEnabledAt()
const now = new Date().toISOString()
let enabledAt = currentEnabledAt
if (days > 0 && currentDays === 0) {
enabledAt = now
} else if (days === 0) {
enabledAt = null
}
setSetting(SETTINGS_AUTO_DELETE_DAYS, String(days))
setSetting(SETTINGS_AUTO_DELETE_ENABLED_AT, enabledAt ?? '')
}
export function isValidRefererHost(host: string): boolean {
return /^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*$/i.test(host)
}
export function isValidImageBaseUrl(value: string): boolean {
if (!value) return true
try {
const url = new URL(value)
return url.protocol === 'http:' || url.protocol === 'https:'
} catch {
return false
}
}
export function getSettingsPayload(event: H3Event) {
const config = useRuntimeConfig(event)
const webpQuality = getWebpQuality(event)
const allowedRefererHosts = getAllowedRefererHostsRaw(event)
const imageBaseUrl = getImageBaseUrl(event)
const autoDeleteDays = getAutoDeleteDays(event)
const appVersion = config.appVersion as string
return {
apiUploadToken: getApiUploadToken(event),
tokenSource: getApiUploadTokenSource(event),
envTokenOverride: isApiUploadTokenEnvConfigured(event),
webpQuality,
webpQualitySource: getWebpQualitySource(),
allowedRefererHosts,
refererSource: getRefererSource(),
refererEnvFallback: getRefererHostsFromEnv(),
imageBaseUrl,
imageBaseUrlConfigured: getImageBaseUrlConfigured(event),
imageBaseUrlSource: getImageBaseUrlSource(event),
autoDeleteDays,
autoDeleteDaysSource: getAutoDeleteDaysSource(),
autoDeleteEnvFallback: parseAutoDeleteDays(getAutoDeleteDaysFromEnv()) ?? 0,
allowRegistration: isAllowRegistration(),
appVersion,
env: {
webpQuality,
refererConfigured: allowedRefererHosts.length > 0,
imageBaseUrl,
appVersion
}
}
}
+37
View File
@@ -0,0 +1,37 @@
import { describe, expect, it } from 'vitest'
import {
detectMimeFromSignature,
validateFileSignature
} from './file-signature'
function bytes(values: number[]): Uint8Array {
return Uint8Array.from(values)
}
describe('file signature detection', () => {
it('detects JPEG', () => {
const jpeg = bytes([0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10])
expect(detectMimeFromSignature(jpeg)).toBe('image/jpeg')
expect(validateFileSignature('image/jpeg', jpeg)).toBe(true)
})
it('detects PNG', () => {
const png = bytes([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00])
expect(detectMimeFromSignature(png)).toBe('image/png')
expect(validateFileSignature('image/png', png)).toBe(true)
})
it('detects WebP', () => {
const webp = bytes([
0x52, 0x49, 0x46, 0x46, 0x24, 0x00, 0x00, 0x00,
0x57, 0x45, 0x42, 0x50
])
expect(detectMimeFromSignature(webp)).toBe('image/webp')
expect(validateFileSignature('image/webp', webp)).toBe(true)
})
it('rejects mismatched mime and bytes', () => {
const png = bytes([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00])
expect(validateFileSignature('image/jpeg', png)).toBe(false)
})
})
+82
View File
@@ -0,0 +1,82 @@
import type { AllowedMimeType } from './constants'
function matchJpeg(bytes: Uint8Array): boolean {
return bytes.length >= 3
&& bytes[0] === 0xFF
&& bytes[1] === 0xD8
&& bytes[2] === 0xFF
}
function matchPng(bytes: Uint8Array): boolean {
const signature = [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]
if (bytes.length < signature.length) return false
return signature.every((byte, index) => bytes[index] === byte)
}
function matchGif(bytes: Uint8Array): boolean {
if (bytes.length < 6) return false
const header = new TextDecoder().decode(bytes.subarray(0, 6))
return header === 'GIF87a' || header === 'GIF89a'
}
function matchWebp(bytes: Uint8Array): boolean {
if (bytes.length < 12) return false
const riff = new TextDecoder().decode(bytes.subarray(0, 4))
const webp = new TextDecoder().decode(bytes.subarray(8, 12))
return riff === 'RIFF' && webp === 'WEBP'
}
/** ICO 文件头:00 00 01 00 */
function matchIco(bytes: Uint8Array): boolean {
return bytes.length >= 6
&& bytes[0] === 0x00
&& bytes[1] === 0x00
&& bytes[2] === 0x01
&& bytes[3] === 0x00
}
/** SVG 是 XML 文本,无二进制签名,按内容开头识别 */
function matchSvg(bytes: Uint8Array): boolean {
if (!bytes.length) return false
const head = new TextDecoder('utf-8', { fatal: false })
.decode(bytes.subarray(0, 1024))
const trimmed = head.replace(/^\uFEFF/, '').trimStart()
if (
!trimmed.startsWith('<?xml')
&& !trimmed.startsWith('<svg')
&& !trimmed.startsWith('<!--')
) {
return false
}
return /<svg[\s>]/i.test(head)
}
const SIGNATURE_CHECKS: Record<AllowedMimeType, (bytes: Uint8Array) => boolean> = {
'image/jpeg': matchJpeg,
'image/png': matchPng,
'image/gif': matchGif,
'image/webp': matchWebp,
'image/x-icon': matchIco,
'image/svg+xml': matchSvg
}
export function isAllowedMimeType(mime: string): mime is AllowedMimeType {
return mime in SIGNATURE_CHECKS
}
export function validateFileSignature(
mime: AllowedMimeType,
bytes: Uint8Array
): boolean {
const checker = SIGNATURE_CHECKS[mime]
return checker(bytes)
}
export function detectMimeFromSignature(bytes: Uint8Array): AllowedMimeType | null {
for (const mime of Object.keys(SIGNATURE_CHECKS) as AllowedMimeType[]) {
if (SIGNATURE_CHECKS[mime](bytes)) {
return mime
}
}
return null
}
+19
View File
@@ -0,0 +1,19 @@
import { describe, expect, it } from 'vitest'
import { validateImageKey } from './image-key'
describe('validateImageKey', () => {
it('accepts valid keys', () => {
expect(validateImageKey('images/2026/08/abc123.webp')).toBe(true)
expect(validateImageKey('twikoo/2025/01/photo.jpg')).toBe(true)
})
it('rejects path traversal and malformed keys', () => {
expect(validateImageKey('')).toBe(false)
expect(validateImageKey('/images/2026/08/a.webp')).toBe(false)
expect(validateImageKey('images/2026/08/../secret.webp')).toBe(false)
expect(validateImageKey('images//2026/08/a.webp')).toBe(false)
expect(validateImageKey('api/2026/08/a.webp')).toBe(false)
expect(validateImageKey('images/26/8/a.webp')).toBe(false)
expect(validateImageKey('images/2026/08/a.txt')).toBe(false)
})
})
+75
View File
@@ -0,0 +1,75 @@
import type { AllowedMimeType } from './constants'
const EXT_MAP: Record<AllowedMimeType, string> = {
'image/jpeg': 'jpg',
'image/png': 'png',
'image/webp': 'webp',
'image/gif': 'gif',
'image/svg+xml': 'svg',
'image/x-icon': 'ico'
}
export const DEFAULT_FOLDER = 'images'
/** 避免与站点路由 / 静态资源冲突 */
const RESERVED_FOLDERS = new Set([
'api',
'stats',
'_nuxt',
'__nuxt',
'assets',
'public',
'favicon',
'robots',
'sitemap',
'sw'
])
function generateRandomId(length = 12): string {
const alphabet = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'
const bytes = new Uint8Array(length)
crypto.getRandomValues(bytes)
return Array.from(bytes, byte => alphabet[byte % alphabet.length]).join('')
}
export function mimeToExtension(mime: AllowedMimeType): string {
return EXT_MAP[mime]
}
/** 目录名:字母数字开头,允许 - _,最长 32;不含路径分隔符 */
export function isValidFolderName(name: string): boolean {
if (!name || typeof name !== 'string') return false
if (name.length > 32) return false
if (!/^[A-Za-z0-9][A-Za-z0-9_-]*$/.test(name)) return false
return !RESERVED_FOLDERS.has(name.toLowerCase())
}
export function normalizeFolderName(raw: string | undefined | null): string {
const trimmed = raw?.trim() || DEFAULT_FOLDER
return trimmed
}
export function generateImageKey(
contentType: AllowedMimeType,
date = new Date(),
prefix = DEFAULT_FOLDER
): string {
const year = date.getUTCFullYear()
const month = String(date.getUTCMonth() + 1).padStart(2, '0')
const id = generateRandomId(12)
const ext = mimeToExtension(contentType)
return `${prefix}/${year}/${month}/${id}.${ext}`
}
export function validateImageKey(key: string): boolean {
if (!key || typeof key !== 'string') return false
if (key.startsWith('/')) return false
if (key.includes('..')) return false
if (key.includes('//')) return false
const match = key.match(
/^([A-Za-z0-9][A-Za-z0-9_-]*)\/\d{4}\/\d{2}\/[^/\\]+\.(jpg|jpeg|png|webp|gif|svg|ico)$/i
)
if (!match) return false
return isValidFolderName(match[1]!)
}
+95
View File
@@ -0,0 +1,95 @@
import type { H3Event } from 'h3'
import type { ImageItem } from '~/types/image'
import type { AllowedMimeType } from './constants'
import type { StoredImage } from './storage'
import { getImageBaseUrl } from './env'
export function resolveImageOwner(
userId: number | null | undefined,
ownerMap?: Map<number, string>
): ImageItem['owner'] | undefined {
if (!ownerMap) return undefined
if (userId == null) {
return { userId: null, username: '系统/API' }
}
return {
userId,
username: ownerMap.get(userId) ?? `用户 #${userId}`
}
}
export function mapStoredImageToItem(
event: H3Event,
stored: StoredImage,
options?: { ownerMap?: Map<number, string> }
): ImageItem {
const item = buildImageItem({
key: stored.key,
baseUrl: getImageBaseUrl(event),
originalName: stored.originalName,
contentType: stored.contentType,
size: stored.size,
uploadedAt: stored.uploadedAt
})
const owner = resolveImageOwner(stored.userId, options?.ownerMap)
if (owner) {
return { ...item, owner }
}
return item
}
export function buildImageUrl(baseUrl: string, key: string): string {
const normalizedBase = baseUrl.replace(/\/$/, '')
return `${normalizedBase}/${key}`
}
export function buildMarkdown(url: string, alt: string): string {
const safeAlt = alt.replace(/[[\]]/g, '')
return `![${safeAlt}](${url})`
}
export function buildHtml(url: string, alt: string): string {
const safeAlt = escapeHtml(alt)
const safeUrl = escapeHtml(url)
return `<img src="${safeUrl}" alt="${safeAlt}">`
}
function escapeHtml(value: string): string {
return value
.replace(/&/g, '&amp;')
.replace(/"/g, '&quot;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
}
export function buildImageItem(input: {
key: string
baseUrl: string
originalName: string
contentType: AllowedMimeType | string
size: number
uploadedAt: string
}) {
const url = buildImageUrl(input.baseUrl, input.key)
const alt = sanitizeOriginalName(input.originalName)
return {
key: input.key,
url,
originalName: alt,
contentType: input.contentType,
size: input.size,
uploadedAt: input.uploadedAt,
markdown: buildMarkdown(url, alt),
html: buildHtml(url, alt)
}
}
export function sanitizeOriginalName(name: string): string {
const base = name.split(/[/\\]/).pop() ?? 'image'
return base.replace(/[^\w.\-()]/g, '_').slice(0, 200) || 'image'
}
+52
View File
@@ -0,0 +1,52 @@
type LogLevel = 'debug' | 'info' | 'warn' | 'error'
const LEVEL_ORDER: Record<LogLevel, number> = {
debug: 10,
info: 20,
warn: 30,
error: 40
}
function currentLevel(): LogLevel {
const raw = (process.env.LOG_LEVEL || 'info').toLowerCase()
if (raw === 'debug' || raw === 'info' || raw === 'warn' || raw === 'error') {
return raw
}
return 'info'
}
function shouldLog(level: LogLevel): boolean {
return LEVEL_ORDER[level] >= LEVEL_ORDER[currentLevel()]
}
function formatLine(level: LogLevel, message: string, extra?: Record<string, unknown>): string {
const time = new Date().toISOString()
const payload = extra && Object.keys(extra).length
? ` ${JSON.stringify(extra)}`
: ''
return `[${time}] [${level.toUpperCase()}] ${message}${payload}`
}
export function logDebug(message: string, extra?: Record<string, unknown>) {
if (!shouldLog('debug')) return
console.debug(formatLine('debug', message, extra))
}
export function logInfo(message: string, extra?: Record<string, unknown>) {
if (!shouldLog('info')) return
console.log(formatLine('info', message, extra))
}
export function logWarn(message: string, extra?: Record<string, unknown>) {
if (!shouldLog('warn')) return
console.warn(formatLine('warn', message, extra))
}
export function logError(message: string, extra?: Record<string, unknown>) {
if (!shouldLog('error')) return
console.error(formatLine('error', message, extra))
}
export function clientIp(event: Parameters<typeof getRequestIP>[0]): string {
return getRequestIP(event, { xForwardedFor: true }) || '-'
}
+194
View File
@@ -0,0 +1,194 @@
import sharp from 'sharp'
import type { H3Event } from 'h3'
import type { ImageItem, UploadErrorItem } from '~/types/image'
import type { AllowedMimeType } from './constants'
import {
ALLOWED_MIME_TYPES,
MAX_FILE_SIZE
} from './constants'
import {
detectMimeFromSignature,
isAllowedMimeType,
validateFileSignature
} from './file-signature'
import type { LogSource } from './db'
import { generateImageKey } from './image-key'
import { buildImageItem, sanitizeOriginalName } from './image-response'
import { getImageBaseUrl, getWebpQuality } from './env'
import { insertActivityLog } from './db'
import { putImage } from './storage'
import { logInfo, logWarn } from './logger'
/** 可转 WebP 的位图格式;SVG(矢量)、ICO、已是 WebP 的原样存储 */
const WEBP_CONVERTIBLE: readonly AllowedMimeType[] = [
'image/jpeg',
'image/png',
'image/gif'
]
/**
* 服务端统一转 WebP(浏览器直传、Twikoo/EasyImage、油猴脚本一视同仁)。
* 转换失败或转出来更大时,保留原格式存储。
*/
async function compressToWebp(
bytes: Uint8Array,
mime: AllowedMimeType,
quality: number
): Promise<{ bytes: Uint8Array, mime: AllowedMimeType }> {
if (!WEBP_CONVERTIBLE.includes(mime)) {
return { bytes, mime }
}
try {
const output = await sharp(Buffer.from(bytes), {
// GIF 保留动画帧
animated: mime === 'image/gif'
})
.webp({ quality })
.toBuffer()
if (output.byteLength >= bytes.byteLength) {
return { bytes, mime }
}
return { bytes: new Uint8Array(output), mime: 'image/webp' }
} catch {
return { bytes, mime }
}
}
export async function processSingleImageUpload(
event: H3Event,
input: {
bytes: Uint8Array
filename?: string
prefix?: string
source?: LogSource
userId?: number | null
}
): Promise<{ item: ImageItem } | { error: UploadErrorItem }> {
const originalName = sanitizeOriginalName(input.filename ?? 'image')
const bytes = input.bytes
if (bytes.byteLength > MAX_FILE_SIZE) {
return {
error: {
name: originalName,
error: {
code: 'FILE_TOO_LARGE',
message: '图片大小不能超过 10 MB'
}
}
}
}
const detectedMime = detectMimeFromSignature(bytes)
if (!detectedMime) {
return {
error: {
name: originalName,
error: {
code: 'INVALID_FILE_SIGNATURE',
message: '无法识别的图片格式'
}
}
}
}
if (!ALLOWED_MIME_TYPES.includes(detectedMime)) {
return {
error: {
name: originalName,
error: {
code: 'UNSUPPORTED_FILE_TYPE',
message: '不支持的图片格式'
}
}
}
}
if (!validateFileSignature(detectedMime, bytes)) {
return {
error: {
name: originalName,
error: {
code: 'INVALID_FILE_SIGNATURE',
message: '文件签名校验失败'
}
}
}
}
const compressed = await compressToWebp(bytes, detectedMime, getWebpQuality(event))
const imageBaseUrl = getImageBaseUrl(event)
const uploadedAt = new Date().toISOString()
const key = generateImageKey(
compressed.mime,
new Date(uploadedAt),
input.prefix ?? 'images'
)
try {
await putImage(key, compressed.bytes, {
originalName,
uploadedAt,
contentType: compressed.mime,
size: compressed.bytes.byteLength,
userId: input.userId ?? null
})
} catch {
logWarn('upload failed: storage error', {
name: originalName,
source: input.source ?? 'web'
})
return {
error: {
name: originalName,
error: {
code: 'UPLOAD_FAILED',
message: '上传失败,请稍后重试'
}
}
}
}
insertActivityLog({
action: 'upload',
key,
originalName,
size: compressed.bytes.byteLength,
contentType: compressed.mime,
source: input.source ?? 'web',
createdAt: uploadedAt
})
logInfo('upload success', {
key,
size: compressed.bytes.byteLength,
contentType: compressed.mime,
source: input.source ?? 'web'
})
return {
item: buildImageItem({
key,
baseUrl: imageBaseUrl,
originalName,
contentType: compressed.mime,
size: compressed.bytes.byteLength,
uploadedAt
})
}
}
/** 与 multipart 声明的 MIME 交叉校验(可选) */
export function mimeMatchesDeclared(
declaredMime: string,
detectedMime: string
): boolean {
if (!declaredMime || !isAllowedMimeType(declaredMime)) {
return true
}
return declaredMime === detectedMime
}
+23
View File
@@ -0,0 +1,23 @@
import { describe, expect, it } from 'vitest'
import { isRefererAllowed } from './serve-image'
describe('isRefererAllowed', () => {
const allowedHosts = new Set(['pic.example.com', 'blog.example.com'])
it('allows missing referer', () => {
expect(isRefererAllowed(null, allowedHosts)).toBe(true)
})
it('allows referer from an allowed host', () => {
expect(isRefererAllowed('https://blog.example.com/post/1', allowedHosts)).toBe(true)
expect(isRefererAllowed('https://PIC.EXAMPLE.COM/img.webp', allowedHosts)).toBe(true)
})
it('blocks referer from unknown hosts', () => {
expect(isRefererAllowed('https://evil.example.com/hotlink', allowedHosts)).toBe(false)
})
it('rejects malformed referer URLs', () => {
expect(isRefererAllowed('not-a-url', allowedHosts)).toBe(false)
})
})
+124
View File
@@ -0,0 +1,124 @@
import type { H3Event } from 'h3'
import { IMAGE_CACHE_CONTROL } from './constants'
import { getAllowedRefererHostsRaw } from './env'
import { validateImageKey } from './image-key'
import { createImageStream, headImage } from './storage'
/**
* 图片直链处理(/{type}/YYYY/MM/file)
*
* 防盗链说明:
* - Referer 可以缺失(直接访问、部分客户端),缺失时放行
* - Referer 可被非浏览器客户端伪造,本方案防普通网站盗链,不是身份认证
*/
const ERROR_CACHE_CONTROL = 'public, max-age=60'
function getAllowedHosts(event: H3Event): Set<string> {
const hosts = new Set<string>()
const raw = getAllowedRefererHostsRaw(event)
for (const part of raw.split(',')) {
const trimmed = part.trim().toLowerCase()
if (trimmed) hosts.add(trimmed)
}
const requestUrl = getRequestURL(event, {
xForwardedHost: true,
xForwardedProto: true
})
hosts.add(requestUrl.hostname.toLowerCase())
return hosts
}
export function isRefererAllowed(referer: string | null, allowedHosts: Set<string>): boolean {
if (!referer) return true
try {
const hostname = new URL(referer).hostname.toLowerCase()
return allowedHosts.has(hostname)
} catch {
return false
}
}
function parseRangeHeader(
rangeHeader: string | undefined,
size: number
): { start: number, end: number } | null {
if (!rangeHeader || !rangeHeader.startsWith('bytes=')) return null
const [startStr, endStr] = rangeHeader.slice(6).split('-', 2)
const start = startStr ? Number(startStr) : NaN
const end = endStr ? Number(endStr) : size - 1
if (!Number.isFinite(start) || start < 0 || start >= size) return null
const safeEnd = Number.isFinite(end) ? Math.min(end, size - 1) : size - 1
if (safeEnd < start) return null
return { start, end: safeEnd }
}
export function requestPathToImageKey(pathname: string): string | null {
const key = decodeURIComponent(pathname).replace(/^\/+/, '')
return validateImageKey(key) ? key : null
}
export async function serveImageByKey(event: H3Event, key: string) {
const referer = getHeader(event, 'referer') ?? null
if (!isRefererAllowed(referer, getAllowedHosts(event))) {
setHeader(event, 'Cache-Control', ERROR_CACHE_CONTROL)
throw createError({
statusCode: 403,
statusMessage: 'Forbidden: hotlink protection'
})
}
const stored = await headImage(key)
if (!stored) {
setHeader(event, 'Cache-Control', ERROR_CACHE_CONTROL)
throw createError({ statusCode: 404, statusMessage: 'Not Found' })
}
const etag = `"${stored.size}-${Math.round(stored.mtimeMs)}"`
setHeader(event, 'ETag', etag)
setHeader(event, 'Cache-Control', IMAGE_CACHE_CONTROL)
setHeader(event, 'Accept-Ranges', 'bytes')
setHeader(event, 'X-Content-Type-Options', 'nosniff')
setHeader(event, 'Content-Type', stored.contentType)
if (stored.contentType === 'image/svg+xml') {
setHeader(
event,
'Content-Security-Policy',
'default-src \'none\'; style-src \'unsafe-inline\''
)
}
const ifNoneMatch = getHeader(event, 'if-none-match')
if (ifNoneMatch && ifNoneMatch === etag) {
setResponseStatus(event, 304)
return null
}
const method = getMethod(event)
if (method === 'HEAD') {
setHeader(event, 'Content-Length', stored.size)
return null
}
const range = parseRangeHeader(getHeader(event, 'range'), stored.size)
if (range) {
setResponseStatus(event, 206)
setHeader(event, 'Content-Range', `bytes ${range.start}-${range.end}/${stored.size}`)
setHeader(event, 'Content-Length', range.end - range.start + 1)
return sendStream(event, createImageStream(key, range))
}
setHeader(event, 'Content-Length', stored.size)
return sendStream(event, createImageStream(key))
}
+25
View File
@@ -0,0 +1,25 @@
import { describe, expect, it } from 'vitest'
import type { StoredImageMeta } from './storage'
function matchesUserFilter(
meta: StoredImageMeta,
userFilter: number | 'admin'
): boolean {
if (userFilter === 'admin') return true
const ownerId = meta.userId ?? null
if (ownerId === null) return false
return ownerId === userFilter
}
describe('image user filter', () => {
it('admin sees all images including system uploads', () => {
expect(matchesUserFilter({ userId: null } as StoredImageMeta, 'admin')).toBe(true)
expect(matchesUserFilter({ userId: 2 } as StoredImageMeta, 'admin')).toBe(true)
})
it('regular user sees only own images', () => {
expect(matchesUserFilter({ userId: 2 } as StoredImageMeta, 2)).toBe(true)
expect(matchesUserFilter({ userId: 3 } as StoredImageMeta, 2)).toBe(false)
expect(matchesUserFilter({ userId: null } as StoredImageMeta, 2)).toBe(false)
})
})
+398
View File
@@ -0,0 +1,398 @@
import { createReadStream, promises as fs } from 'node:fs'
import type { ReadStream } from 'node:fs'
import { dirname, join } from 'node:path'
import { isValidFolderName, validateImageKey, DEFAULT_FOLDER } from './image-key'
/**
* 本地文件系统存储层
*
* 目录布局(DATA_DIR 默认 ./data,容器内挂载 /data):
* <DATA_DIR>/<type>/YYYY/MM/<随机ID>.webp 按上传 type 分目录(默认 images)
* <DATA_DIR>/twikoo/YYYY/MM/<随机ID>.webp twikoo 评论图片
* <DATA_DIR>/<type>/YYYY/MM/<随机ID>.webp.meta.json 元数据(原始文件名等)
*
* 列表按日期降序(新月份在前),meta 缺失时回退到文件属性。
*/
export interface StoredImageMeta {
originalName: string
uploadedAt: string
contentType: string
size: number
userId?: number | null
}
export interface StoredImage extends StoredImageMeta {
key: string
mtimeMs: number
}
const META_SUFFIX = '.meta.json'
export function getDataDir(): string {
return process.env.DATA_DIR || join(process.cwd(), 'data')
}
function keyToFilePath(key: string): string {
// key 均已通过 validateImageKey 校验,不存在路径穿越
return join(getDataDir(), ...key.split('/'))
}
function contentTypeFromKey(key: string): string {
const ext = key.split('.').pop()?.toLowerCase()
switch (ext) {
case 'jpg':
case 'jpeg':
return 'image/jpeg'
case 'png':
return 'image/png'
case 'gif':
return 'image/gif'
case 'webp':
return 'image/webp'
case 'svg':
return 'image/svg+xml'
case 'ico':
return 'image/x-icon'
default:
return 'application/octet-stream'
}
}
async function readMeta(key: string): Promise<Partial<StoredImageMeta>> {
try {
const raw = await fs.readFile(keyToFilePath(key) + META_SUFFIX, 'utf8')
return JSON.parse(raw) as Partial<StoredImageMeta>
} catch {
return {}
}
}
export async function putImage(
key: string,
bytes: Uint8Array,
meta: StoredImageMeta
): Promise<void> {
const filePath = keyToFilePath(key)
await fs.mkdir(dirname(filePath), { recursive: true })
await fs.writeFile(filePath, bytes)
await fs.writeFile(filePath + META_SUFFIX, JSON.stringify(meta))
}
export async function headImage(key: string): Promise<StoredImage | null> {
if (!validateImageKey(key)) return null
let stat
try {
stat = await fs.stat(keyToFilePath(key))
} catch {
return null
}
if (!stat.isFile()) return null
const meta = await readMeta(key)
return {
key,
size: stat.size,
mtimeMs: stat.mtimeMs,
contentType: meta.contentType ?? contentTypeFromKey(key),
originalName: meta.originalName ?? key.split('/').pop() ?? 'image',
uploadedAt: meta.uploadedAt ?? stat.mtime.toISOString(),
userId: meta.userId ?? null
}
}
export function createImageStream(
key: string,
range?: { start: number, end: number }
): ReadStream {
return createReadStream(keyToFilePath(key), range)
}
export async function deleteImage(key: string): Promise<void> {
const filePath = keyToFilePath(key)
await fs.unlink(filePath)
await fs.unlink(filePath + META_SUFFIX).catch(() => {
// meta 文件可能不存在(外部导入的历史图片)
})
}
export interface PaginatedResult<T> {
items: T[]
total: number
page: number
pageSize: number
totalPages: number
}
export async function listFolders(): Promise<string[]> {
const names = await readDirNames(getDataDir())
const folders = names.filter(isValidFolderName)
if (!folders.includes(DEFAULT_FOLDER)) {
folders.push(DEFAULT_FOLDER)
}
return folders.sort((a, b) => a.localeCompare(b))
}
/** 遍历各 type 目录下的 YYYY/MM,返回按日期降序的 key 列表(新月份在前) */
export async function listImageKeys(folder?: string): Promise<string[]> {
const keys: string[] = []
const prefixes = folder && isValidFolderName(folder)
? [folder]
: await listFolders()
for (const prefix of prefixes) {
const root = join(getDataDir(), prefix)
const years = await readDirNames(root)
for (const year of years) {
const months = await readDirNames(join(root, year))
for (const month of months) {
const files = await readFileNames(join(root, year, month))
for (const file of files) {
if (file.endsWith(META_SUFFIX)) continue
const key = `${prefix}/${year}/${month}/${file}`
if (validateImageKey(key)) keys.push(key)
}
}
}
}
// 按 YYYY/MM/文件名 降序排,使不同 type 按时间交错而不是分块
return keys.sort((a, b) => {
const aDate = a.slice(a.indexOf('/') + 1)
const bDate = b.slice(b.indexOf('/') + 1)
return bDate.localeCompare(aDate) || b.localeCompare(a)
})
}
async function readDirNames(path: string): Promise<string[]> {
try {
const entries = await fs.readdir(path, { withFileTypes: true })
return entries.filter(e => e.isDirectory()).map(e => e.name)
} catch {
return []
}
}
function matchesUserFilter(
meta: StoredImageMeta,
userFilter: number | 'admin'
): boolean {
if (userFilter === 'admin') return true
const ownerId = meta.userId ?? null
if (ownerId === null) return false
return ownerId === userFilter
}
async function filterKeysByUser(
keys: string[],
userFilter?: number | 'admin'
): Promise<string[]> {
if (userFilter === undefined || userFilter === 'admin') {
return keys
}
const filtered: string[] = []
for (const key of keys) {
const meta = await readMeta(key)
if (matchesUserFilter(meta as StoredImageMeta, userFilter)) {
filtered.push(key)
}
}
return filtered
}
async function readFileNames(path: string): Promise<string[]> {
try {
const entries = await fs.readdir(path, { withFileTypes: true })
return entries.filter(e => e.isFile()).map(e => e.name)
} catch {
return []
}
}
function paginateSlice<T>(
all: T[],
limit: number,
page?: number
): PaginatedResult<T> {
const total = all.length
const pageSize = limit
const totalPages = Math.max(1, Math.ceil(total / pageSize) || 1)
const requestedPage = Math.max(1, page ?? 1)
const safePage = Math.min(requestedPage, totalPages)
const start = (safePage - 1) * pageSize
const items = all.slice(start, start + pageSize)
return {
items,
total,
page: safePage,
pageSize,
totalPages
}
}
export async function countImages(
folder?: string,
userFilter?: number | 'admin'
): Promise<number> {
let keys = await listImageKeys(folder)
keys = await filterKeysByUser(keys, userFilter)
return keys.length
}
export interface FolderStorageStat {
folder: string
count: number
bytes: number
}
/** 按顶层目录统计当前磁盘上的图片数量与体积 */
export async function getFolderStorageStats(
userFilter?: number | 'admin'
): Promise<FolderStorageStat[]> {
let keys = await listImageKeys()
keys = await filterKeysByUser(keys, userFilter)
const map = new Map<string, { count: number, bytes: number }>()
for (const key of keys) {
const folder = key.split('/')[0] ?? 'unknown'
const meta = await headImage(key)
if (!meta) continue
const current = map.get(folder) ?? { count: 0, bytes: 0 }
current.count += 1
current.bytes += meta.size
map.set(folder, current)
}
return [...map.entries()]
.map(([folder, stat]) => ({ folder, ...stat }))
.sort((a, b) => b.bytes - a.bytes || a.folder.localeCompare(b.folder))
}
export async function listFoldersForUser(
userFilter?: number | 'admin'
): Promise<string[]> {
if (userFilter === undefined || userFilter === 'admin') {
return listFolders()
}
let keys = await listImageKeys()
keys = await filterKeysByUser(keys, userFilter)
const folders = new Set<string>()
for (const key of keys) {
const folder = key.split('/')[0]
if (folder && isValidFolderName(folder)) {
folders.add(folder)
}
}
if (!folders.size) {
folders.add(DEFAULT_FOLDER)
}
return [...folders].sort((a, b) => a.localeCompare(b))
}
function startOfDayIsoInShanghai(now = new Date()): string {
const shifted = new Date(now.getTime() + 8 * 60 * 60 * 1000)
const y = shifted.getUTCFullYear()
const m = shifted.getUTCMonth()
const d = shifted.getUTCDate()
return new Date(Date.UTC(y, m, d) - 8 * 60 * 60 * 1000).toISOString()
}
function startOfMonthIsoInShanghai(now = new Date()): string {
const shifted = new Date(now.getTime() + 8 * 60 * 60 * 1000)
const y = shifted.getUTCFullYear()
const m = shifted.getUTCMonth()
return new Date(Date.UTC(y, m, 1) - 8 * 60 * 60 * 1000).toISOString()
}
/** 普通用户统计:基于本人图片 meta,不依赖全局 activity_logs */
export async function getUserScopedStorageStats(userId: number): Promise<{
storedCount: number
uploadBytesTotal: number
uploadToday: number
uploadMonth: number
byFolder: FolderStorageStat[]
}> {
let keys = await listImageKeys()
keys = await filterKeysByUser(keys, userId)
const startOfDay = startOfDayIsoInShanghai()
const startOfMonth = startOfMonthIsoInShanghai()
const map = new Map<string, { count: number, bytes: number }>()
let uploadBytesTotal = 0
let uploadToday = 0
let uploadMonth = 0
for (const key of keys) {
const meta = await headImage(key)
if (!meta) continue
uploadBytesTotal += meta.size
if (meta.uploadedAt >= startOfDay) uploadToday += 1
if (meta.uploadedAt >= startOfMonth) uploadMonth += 1
const folder = key.split('/')[0] ?? 'unknown'
const current = map.get(folder) ?? { count: 0, bytes: 0 }
current.count += 1
current.bytes += meta.size
map.set(folder, current)
}
const byFolder = [...map.entries()]
.map(([folder, stat]) => ({ folder, ...stat }))
.sort((a, b) => b.bytes - a.bytes || a.folder.localeCompare(b.folder))
return {
storedCount: keys.length,
uploadBytesTotal,
uploadToday,
uploadMonth,
byFolder
}
}
export async function listImages(options: {
limit: number
page?: number
folder?: string
userFilter?: number | 'admin'
}): Promise<PaginatedResult<StoredImage>> {
let keys = await listImageKeys(options.folder)
keys = await filterKeysByUser(keys, options.userFilter)
const sliced = paginateSlice(keys, options.limit, options.page)
const items = (await Promise.all(sliced.items.map(headImage)))
.filter((item): item is StoredImage => item !== null)
return {
...sliced,
items
}
}
export async function searchImages(options: {
query: string
limit: number
page?: number
folder?: string
userFilter?: number | 'admin'
}): Promise<PaginatedResult<StoredImage>> {
const needle = options.query.trim().toLowerCase()
let keys = await listImageKeys(options.folder)
keys = await filterKeysByUser(keys, options.userFilter)
const matches: StoredImage[] = []
for (const key of keys) {
const keyMatches = key.toLowerCase().includes(needle)
const item = await headImage(key)
if (!item) continue
if (keyMatches || item.originalName.toLowerCase().includes(needle)) {
matches.push(item)
}
}
return paginateSlice(matches, options.limit, options.page)
}