release: PicHost v1.0.0
Publish Docker image / build-and-push (push) Canceled after 0s
ci / ci (22, ubuntu-latest) (push) Canceled after 0s
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:
commit
bc4cba7e52
115 files changed
+26508
No files matched your search
@@ -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)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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'
|
||||
}
|
||||
@@ -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'
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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'
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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]!)
|
||||
}
|
||||
@@ -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 ``
|
||||
}
|
||||
|
||||
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, '&')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
}
|
||||
|
||||
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'
|
||||
}
|
||||
@@ -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 }) || '-'
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
Reference in new issue
Block a user