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

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

No files matched your search

+13
View File
@@ -0,0 +1,13 @@
import { isAllowRegistration } from '../../utils/db'
import { isAdminSecretConfigured, isApiUploadTokenConfigured } from '../../utils/env'
import { isInitialized } from '../../utils/auth'
export default defineEventHandler((event) => {
return {
initialized: isInitialized(),
allowRegistration: isAllowRegistration(),
legacyMode: !isInitialized() && isAdminSecretConfigured(event),
adminSecretConfigured: isAdminSecretConfigured(event),
apiUploadTokenConfigured: isApiUploadTokenConfigured(event)
}
})
+86
View File
@@ -0,0 +1,86 @@
import {
checkAdminSecret,
setLegacyAuthCookie
} from '../../utils/access'
import { createApiError } from '../../utils/api-error'
import {
createSession,
isInitialized,
verifyPassword
} from '../../utils/auth'
import { findUserByUsername } from '../../utils/db'
import { getAdminSecret } from '../../utils/env'
import { clientIp, logInfo, logWarn } from '../../utils/logger'
interface LoginBody {
username?: string
password?: string
secret?: string
}
const loginAttempts = new Map<string, { count: number, resetAt: number }>()
const MAX_ATTEMPTS = 10
const WINDOW_MS = 15 * 60 * 1000
function checkRateLimit(ip: string): void {
const now = Date.now()
const entry = loginAttempts.get(ip)
if (!entry || now > entry.resetAt) {
loginAttempts.set(ip, { count: 1, resetAt: now + WINDOW_MS })
return
}
entry.count += 1
if (entry.count > MAX_ATTEMPTS) {
throw createError({
statusCode: 429,
statusMessage: '登录尝试过于频繁,请稍后再试'
})
}
}
export default defineEventHandler(async (event) => {
const ip = clientIp(event)
checkRateLimit(ip)
const body = await readBody<LoginBody>(event)
const initialized = isInitialized()
if (!initialized) {
const adminSecret = getAdminSecret(event)
if (!adminSecret) {
createApiError(event, 'FORBIDDEN', '请先完成系统初始化', 403)
}
const input = body?.secret?.trim()
if (!input || !(await checkAdminSecret(input, adminSecret))) {
logWarn('legacy login failed: invalid secret', { ip })
createApiError(event, 'UNAUTHORIZED', '密钥错误', 401)
}
await setLegacyAuthCookie(event, adminSecret)
logInfo('legacy login success', { ip })
return { success: true, needsMigration: true }
}
const username = body?.username?.trim() ?? ''
const password = body?.password ?? ''
if (!username || !password) {
createApiError(event, 'INVALID_REQUEST', '请输入用户名和密码', 400)
}
const user = findUserByUsername(username)
if (!user || !verifyPassword(password, user.password_hash)) {
logWarn('login failed: invalid credentials', { ip, username })
createApiError(event, 'UNAUTHORIZED', '用户名或密码错误', 401)
}
await createSession(event, user.id)
logInfo('login success', { ip, username: user.username })
return {
success: true,
user: { id: user.id, username: user.username, role: user.role }
}
})
+10
View File
@@ -0,0 +1,10 @@
import { destroySession } from '../../utils/auth'
import { clearLegacyAuthCookie } from '../../utils/access'
import { clientIp, logInfo } from '../../utils/logger'
export default defineEventHandler(async (event) => {
await destroySession(event)
clearLegacyAuthCookie(event)
logInfo('logout', { ip: clientIp(event) })
return { success: true }
})
+14
View File
@@ -0,0 +1,14 @@
import { getCurrentUser } from '../../utils/auth'
import { createApiError } from '../../utils/api-error'
export default defineEventHandler(async (event) => {
const user = await getCurrentUser(event)
if (!user) {
createApiError(event, 'UNAUTHORIZED', '未登录', 401)
}
return {
authenticated: true,
user: { id: user.id, username: user.username, role: user.role }
}
})
+75
View File
@@ -0,0 +1,75 @@
import {
clearLegacyAuthCookie,
verifyLegacyAuth
} from '../../utils/access'
import { createApiError } from '../../utils/api-error'
import {
createSession,
createUserAccount,
isInitialized
} from '../../utils/auth'
import { setAllowRegistration } from '../../utils/db'
import { clientIp, logInfo } from '../../utils/logger'
interface MigrateBody {
username?: string
password?: string
allowRegistration?: boolean
}
export default defineEventHandler(async (event) => {
if (isInitialized()) {
createApiError(event, 'FORBIDDEN', '系统已初始化', 403)
}
if (!(await verifyLegacyAuth(event))) {
createApiError(event, 'UNAUTHORIZED', '请先使用管理密钥登录', 401)
}
const body = await readBody<MigrateBody>(event)
const username = body?.username?.trim() ?? ''
const password = body?.password ?? ''
try {
const user = await createUserAccount({
username,
password,
role: 'admin'
})
setAllowRegistration(Boolean(body?.allowRegistration))
clearLegacyAuthCookie(event)
await createSession(event, user.id)
logInfo('legacy migration complete', {
ip: clientIp(event),
username: user.username
})
return {
success: true,
user: { id: user.id, username: user.username, role: user.role }
}
} catch (error: unknown) {
if (error instanceof Error) {
if (error.message === 'INVALID_USERNAME') {
createApiError(
event,
'INVALID_REQUEST',
'用户名需为 3–32 位字母、数字或下划线',
400
)
} else if (error.message === 'INVALID_PASSWORD') {
createApiError(
event,
'INVALID_REQUEST',
'密码至少 8 位',
400
)
} else if (error.message === 'USERNAME_TAKEN') {
createApiError(event, 'INVALID_REQUEST', '用户名已被占用', 400)
}
}
throw error
}
})
+43
View File
@@ -0,0 +1,43 @@
import { createApiError } from '../../utils/api-error'
import { requireUserAuth } from '../../utils/access'
import {
hashPassword,
isValidPassword,
verifyPassword
} from '../../utils/auth'
import { findUserById, updateUserPassword } from '../../utils/db'
import { clientIp, logInfo } from '../../utils/logger'
interface PasswordBody {
currentPassword?: string
newPassword?: string
}
export default defineEventHandler(async (event) => {
const authUser = await requireUserAuth(event)
const body = await readBody<PasswordBody>(event)
const currentPassword = body?.currentPassword ?? ''
const newPassword = body?.newPassword ?? ''
if (!currentPassword || !newPassword) {
createApiError(event, 'INVALID_REQUEST', '请填写当前密码和新密码', 400)
}
if (!isValidPassword(newPassword)) {
createApiError(event, 'INVALID_REQUEST', '新密码至少 8 位', 400)
}
if (currentPassword === newPassword) {
createApiError(event, 'INVALID_REQUEST', '新密码不能与当前密码相同', 400)
}
const user = findUserById(authUser.id)
if (!user || !verifyPassword(currentPassword, user.password_hash)) {
createApiError(event, 'UNAUTHORIZED', '当前密码错误', 401)
}
updateUserPassword(authUser.id, hashPassword(newPassword))
logInfo('password changed', { ip: clientIp(event), username: authUser.username })
return { success: true }
})
+65
View File
@@ -0,0 +1,65 @@
import { createApiError } from '../../utils/api-error'
import {
createSession,
createUserAccount,
isInitialized
} from '../../utils/auth'
import { isAllowRegistration } from '../../utils/db'
import { clientIp, logInfo } from '../../utils/logger'
interface RegisterBody {
username?: string
password?: string
}
export default defineEventHandler(async (event) => {
if (!isInitialized()) {
createApiError(event, 'FORBIDDEN', '请先完成系统初始化', 403)
}
if (!isAllowRegistration()) {
createApiError(event, 'FORBIDDEN', '当前未开放用户注册', 403)
}
const body = await readBody<RegisterBody>(event)
const username = body?.username?.trim() ?? ''
const password = body?.password ?? ''
try {
const user = await createUserAccount({
username,
password,
role: 'user'
})
await createSession(event, user.id)
logInfo('register success', { ip: clientIp(event), username: user.username })
return {
success: true,
user: { id: user.id, username: user.username, role: user.role }
}
} catch (error: unknown) {
if (error instanceof Error) {
if (error.message === 'INVALID_USERNAME') {
createApiError(
event,
'INVALID_REQUEST',
'用户名需为 3–32 位字母、数字或下划线',
400
)
} else if (error.message === 'INVALID_PASSWORD') {
createApiError(
event,
'INVALID_REQUEST',
'密码至少 8 位',
400
)
} else if (error.message === 'USERNAME_TAKEN') {
createApiError(event, 'INVALID_REQUEST', '用户名已被占用', 400)
}
}
throw error
}
})
+24
View File
@@ -0,0 +1,24 @@
import { isAllowRegistration } from '../../utils/db'
import { isAdminSecretConfigured } from '../../utils/env'
import {
getCurrentUser,
isInitialized
} from '../../utils/auth'
import { verifyLegacyAuth } from '../../utils/access'
export default defineEventHandler(async (event) => {
const initialized = isInitialized()
const user = await getCurrentUser(event)
const legacyMode = !initialized && isAdminSecretConfigured(event)
const needsMigration = legacyMode && (await verifyLegacyAuth(event))
return {
initialized,
allowRegistration: isAllowRegistration(),
legacyMode,
needsMigration,
user: user
? { id: user.id, username: user.username, role: user.role }
: null
}
})
+11
View File
@@ -0,0 +1,11 @@
import { getImageUserFilter, requireUserAuth } from '../utils/access'
import { listFolders, listFoldersForUser } from '../utils/storage'
export default defineEventHandler(async (event) => {
await requireUserAuth(event)
const userFilter = await getImageUserFilter(event)
const folders = userFilter === 'admin'
? await listFolders()
: await listFoldersForUser(userFilter)
return { folders }
})
+7
View File
@@ -0,0 +1,7 @@
export default defineEventHandler((event) => {
const config = useRuntimeConfig(event)
return {
status: 'ok',
version: config.appVersion
}
})
+104
View File
@@ -0,0 +1,104 @@
import type { BatchDeleteResponse } from '~/types/image'
import { requireApiOrAdminAuth, assertImageOwnership, resolveActivitySource } from '../../utils/access'
import { createApiError } from '../../utils/api-error'
import { MAX_DELETE_BATCH } from '../../utils/constants'
import { insertActivityLog } from '../../utils/db'
import { validateImageKey } from '../../utils/image-key'
import { deleteImage, headImage } from '../../utils/storage'
import { logInfo } from '../../utils/logger'
interface BatchDeleteBody {
keys?: string[]
}
// POST 版本:避免部分环境下 DELETE 请求体丢失
export default defineEventHandler(async (event) => {
try {
await requireApiOrAdminAuth(event)
const body = await readBody<BatchDeleteBody>(event)
const keys = body?.keys
if (!Array.isArray(keys) || !keys.length) {
createApiError(event, 'INVALID_REQUEST', '请提供要删除的图片列表', 400)
}
if (keys.length > MAX_DELETE_BATCH) {
createApiError(
event,
'INVALID_REQUEST',
`每次最多删除 ${MAX_DELETE_BATCH} 张图片`,
400
)
}
for (const key of keys) {
if (!validateImageKey(key)) {
createApiError(event, 'INVALID_IMAGE_KEY', `无效的图片路径: ${key}`, 400)
}
}
const deleted: string[] = []
const failed: BatchDeleteResponse['failed'] = []
for (const key of keys) {
try {
const existing = await headImage(key)
if (!existing) {
failed.push({
key,
error: { code: 'IMAGE_NOT_FOUND', message: '图片不存在' }
})
continue
}
try {
await assertImageOwnership(event, existing.userId)
} catch {
failed.push({
key,
error: { code: 'FORBIDDEN', message: '无权操作此图片' }
})
continue
}
await deleteImage(key)
deleted.push(key)
insertActivityLog({
action: 'delete',
key,
originalName: existing.originalName,
size: existing.size,
contentType: existing.contentType,
source: resolveActivitySource(event)
})
logInfo('delete success', { key, size: existing.size })
} catch {
failed.push({
key,
error: { code: 'DELETE_FAILED', message: '删除失败' }
})
}
}
const response: BatchDeleteResponse = {
success: failed.length === 0,
deleted,
failed
}
if (!deleted.length && failed.length) {
setResponseStatus(event, 400)
}
return response
} catch (error: unknown) {
if (error && typeof error === 'object' && 'statusCode' in error) {
throw error
}
createApiError(event, 'DELETE_FAILED', '删除失败', 500)
}
})
+16
View File
@@ -0,0 +1,16 @@
import { getImageUserFilter, requireApiOrAdminAuth } from '../../utils/access'
import { isValidFolderName } from '../../utils/image-key'
import { countImages } from '../../utils/storage'
export default defineEventHandler(async (event) => {
await requireApiOrAdminAuth(event)
const userFilter = await getImageUserFilter(event)
const query = getQuery(event)
const raw = typeof query.folder === 'string' ? query.folder.trim() : ''
const folder = raw && raw !== 'all' && isValidFolderName(raw) ? raw : undefined
const total = await countImages(folder, userFilter)
return { total }
})
+58
View File
@@ -0,0 +1,58 @@
import type { DeleteResponse } from '~/types/image'
import { requireApiOrAdminAuth, assertImageOwnership, resolveActivitySource } from '../../utils/access'
import { createApiError } from '../../utils/api-error'
import { insertActivityLog } from '../../utils/db'
import { validateImageKey } from '../../utils/image-key'
import { deleteImage, headImage } from '../../utils/storage'
import { logInfo } from '../../utils/logger'
interface DeleteBody {
key?: string
}
export default defineEventHandler(async (event) => {
try {
await requireApiOrAdminAuth(event)
const query = getQuery(event)
const body = await readBody<DeleteBody>(event).catch(() => null)
const key = (typeof query.key === 'string' ? query.key : undefined)
?? body?.key
if (!key || !validateImageKey(key)) {
createApiError(event, 'INVALID_IMAGE_KEY', '无效的图片路径', 400)
}
const existing = await headImage(key)
if (!existing) {
createApiError(event, 'IMAGE_NOT_FOUND', '图片不存在', 404)
}
await assertImageOwnership(event, existing.userId)
try {
await deleteImage(key)
} catch {
createApiError(event, 'DELETE_FAILED', '删除失败', 500)
}
insertActivityLog({
action: 'delete',
key,
originalName: existing.originalName,
size: existing.size,
contentType: existing.contentType,
source: resolveActivitySource(event)
})
logInfo('delete success', { key, size: existing.size })
const response: DeleteResponse = { success: true }
return response
} catch (error: unknown) {
if (error && typeof error === 'object' && 'statusCode' in error) {
throw error
}
createApiError(event, 'DELETE_FAILED', '删除失败', 500)
}
})
+56
View File
@@ -0,0 +1,56 @@
import type { ImageListResponse } from '~/types/image'
import { requireApiOrAdminAuth, getImageUserFilter } from '../../utils/access'
import { getCurrentUser } from '../../utils/auth'
import {
DEFAULT_LIST_LIMIT,
MAX_LIST_LIMIT
} from '../../utils/constants'
import { listUserIdUsernameMap } from '../../utils/db'
import { mapStoredImageToItem } from '../../utils/image-response'
import { isValidFolderName } from '../../utils/image-key'
import { listImages } from '../../utils/storage'
function readFolderQuery(query: Record<string, unknown>): string | undefined {
const raw = typeof query.folder === 'string' ? query.folder.trim() : ''
if (!raw || raw === 'all') return undefined
return isValidFolderName(raw) ? raw : undefined
}
export default defineEventHandler(async (event) => {
await requireApiOrAdminAuth(event)
const query = getQuery(event)
const limitRaw = Number(query.limit ?? DEFAULT_LIST_LIMIT)
const limit = Number.isFinite(limitRaw)
? Math.min(Math.max(1, Math.floor(limitRaw)), MAX_LIST_LIMIT)
: DEFAULT_LIST_LIMIT
const pageRaw = Number(query.page ?? 1)
const page = Number.isFinite(pageRaw) && pageRaw > 0
? Math.floor(pageRaw)
: 1
const folder = readFolderQuery(query)
const userFilter = await getImageUserFilter(event)
const listing = await listImages({ limit, page, folder, userFilter })
const user = await getCurrentUser(event)
const ownerMap = user?.role === 'admin' ? listUserIdUsernameMap() : undefined
const items = listing.items.map(stored =>
mapStoredImageToItem(event, stored, { ownerMap })
)
items.sort((a, b) => b.uploadedAt.localeCompare(a.uploadedAt))
const response: ImageListResponse = {
items,
total: listing.total,
page: listing.page,
pageSize: listing.pageSize,
totalPages: listing.totalPages
}
return response
})
+66
View File
@@ -0,0 +1,66 @@
import type { ImageListResponse } from '~/types/image'
import { requireApiOrAdminAuth, getImageUserFilter } from '../../utils/access'
import { getCurrentUser } from '../../utils/auth'
import {
DEFAULT_LIST_LIMIT,
MAX_LIST_LIMIT
} from '../../utils/constants'
import { createApiError } from '../../utils/api-error'
import { listUserIdUsernameMap } from '../../utils/db'
import { mapStoredImageToItem } from '../../utils/image-response'
import { isValidFolderName } from '../../utils/image-key'
import { searchImages } from '../../utils/storage'
function readFolderQuery(query: Record<string, unknown>): string | undefined {
const raw = typeof query.folder === 'string' ? query.folder.trim() : ''
if (!raw || raw === 'all') return undefined
return isValidFolderName(raw) ? raw : undefined
}
export default defineEventHandler(async (event) => {
await requireApiOrAdminAuth(event)
const query = getQuery(event)
const q = typeof query.q === 'string' ? query.q.trim() : ''
if (!q) {
createApiError(event, 'INVALID_REQUEST', '请提供搜索关键词 q', 400)
}
if (q.length > 200) {
createApiError(event, 'INVALID_REQUEST', '搜索关键词过长', 400)
}
const limitRaw = Number(query.limit ?? DEFAULT_LIST_LIMIT)
const limit = Number.isFinite(limitRaw)
? Math.min(Math.max(1, Math.floor(limitRaw)), MAX_LIST_LIMIT)
: DEFAULT_LIST_LIMIT
const pageRaw = Number(query.page ?? 1)
const page = Number.isFinite(pageRaw) && pageRaw > 0
? Math.floor(pageRaw)
: 1
const folder = readFolderQuery(query)
const userFilter = await getImageUserFilter(event)
const result = await searchImages({ query: q, limit, page, folder, userFilter })
const user = await getCurrentUser(event)
const ownerMap = user?.role === 'admin' ? listUserIdUsernameMap() : undefined
const items = result.items.map(stored =>
mapStoredImageToItem(event, stored, { ownerMap })
)
items.sort((a, b) => b.uploadedAt.localeCompare(a.uploadedAt))
const response: ImageListResponse = {
items,
total: result.total,
page: result.page,
pageSize: result.pageSize,
totalPages: result.totalPages
}
return response
})
+145
View File
@@ -0,0 +1,145 @@
import { readMultipartFormData } from 'h3'
import type { UploadResponse, UploadErrorItem, ImageItem } from '~/types/image'
import { requireUploadAuth, verifyApiUploadToken, getUploadUserId, canUseCustomUploadFolder } from '../../utils/access'
import { MAX_FILES_PER_UPLOAD } from '../../utils/constants'
import {
detectMimeFromSignature,
isAllowedMimeType
} from '../../utils/file-signature'
import {
mimeMatchesDeclared,
processSingleImageUpload
} from '../../utils/process-image-upload'
import { createApiError } from '../../utils/api-error'
import {
DEFAULT_FOLDER,
isValidFolderName,
normalizeFolderName
} from '../../utils/image-key'
function readFormText(
formData: { name?: string, data?: Buffer | Uint8Array }[],
name: string
): string {
const part = formData.find(item => item.name === name && item.data?.length)
if (!part?.data) return ''
return new TextDecoder().decode(part.data).trim()
}
export default defineEventHandler(async (event) => {
await requireUploadAuth(event)
const source = verifyApiUploadToken(event) ? 'api' : 'web'
const uploadUserId = await getUploadUserId(event)
const formData = await readMultipartFormData(event)
if (!formData?.length) {
createApiError(event, 'INVALID_REQUEST', '未收到上传文件', 400)
}
const query = getQuery(event)
const folderFromForm = readFormText(formData, 'folder') || readFormText(formData, 'type')
const folderFromQuery = typeof query.folder === 'string'
? query.folder
: typeof query.type === 'string'
? query.type
: ''
const requestedFolder = normalizeFolderName(folderFromForm || folderFromQuery)
const canCustomFolder = await canUseCustomUploadFolder(event)
if (!canCustomFolder && requestedFolder && requestedFolder !== DEFAULT_FOLDER) {
createApiError(
event,
'FORBIDDEN',
'当前账号不支持自定义上传目录',
403
)
}
const folder = canCustomFolder ? requestedFolder : ''
if (folder && !isValidFolderName(folder)) {
createApiError(
event,
'INVALID_REQUEST',
'无效的 folder:仅允许字母数字开头,可含 - _,最长 32 位,且不能是 api/stats 等保留名',
400
)
}
// 兼容 image / file / files 字段名(图床脚本 / 通用客户端 / 网页多图)
const fileParts = formData.filter(
part => (part.name === 'image' || part.name === 'file' || part.name === 'files')
&& part.data?.length
)
if (!fileParts.length) {
createApiError(event, 'INVALID_REQUEST', '未收到上传文件', 400)
}
if (fileParts.length > MAX_FILES_PER_UPLOAD) {
createApiError(
event,
'TOO_MANY_FILES',
`每次最多上传 ${MAX_FILES_PER_UPLOAD} 张图片`,
400
)
}
const items: ImageItem[] = []
const errors: UploadErrorItem[] = []
for (const part of fileParts) {
const originalName = part.filename ?? 'image'
const bytes = new Uint8Array(part.data)
const declaredMime = part.type?.toLowerCase() ?? ''
const detectedMime = detectMimeFromSignature(bytes)
if (detectedMime && !mimeMatchesDeclared(declaredMime, detectedMime)) {
errors.push({
name: originalName,
error: {
code: 'INVALID_FILE_SIGNATURE',
message: '文件类型与内容不匹配'
}
})
continue
}
if (declaredMime && isAllowedMimeType(declaredMime) && !detectedMime) {
errors.push({
name: originalName,
error: {
code: 'INVALID_FILE_SIGNATURE',
message: '无法识别的图片格式'
}
})
continue
}
const result = await processSingleImageUpload(event, {
bytes,
filename: part.filename,
source,
prefix: folder || DEFAULT_FOLDER,
userId: uploadUserId
})
if ('error' in result) {
errors.push(result.error)
} else {
items.push(result.item)
}
}
const response: UploadResponse = {
success: items.length > 0,
items,
errors
}
if (!items.length && errors.length) {
setResponseStatus(event, 400)
}
return response
})
+64
View File
@@ -0,0 +1,64 @@
import { requireAdminAuth } from '../../utils/access'
import { createApiError } from '../../utils/api-error'
import {
listActivityLogs,
type LogAction,
type LogSource
} from '../../utils/db'
import {
DEFAULT_LIST_LIMIT,
MAX_LIST_LIMIT
} from '../../utils/constants'
export default defineEventHandler(async (event) => {
await requireAdminAuth(event)
const query = getQuery(event)
const limitRaw = Number(query.limit ?? DEFAULT_LIST_LIMIT)
const limit = Number.isFinite(limitRaw)
? Math.min(Math.max(1, Math.floor(limitRaw)), MAX_LIST_LIMIT)
: DEFAULT_LIST_LIMIT
const actionRaw = typeof query.action === 'string' ? query.action : ''
const action = (actionRaw === 'upload' || actionRaw === 'delete')
? actionRaw as LogAction
: undefined
const sourceRaw = typeof query.source === 'string' ? query.source : ''
const source = (sourceRaw === 'web' || sourceRaw === 'api' || sourceRaw === 'twikoo')
? sourceRaw as LogSource
: undefined
const folder = typeof query.folder === 'string' && query.folder.trim()
? query.folder.trim()
: undefined
const pageRaw = Number(query.page ?? 1)
const page = Number.isFinite(pageRaw) && pageRaw > 0
? Math.floor(pageRaw)
: 1
try {
const result = listActivityLogs({ limit, page, action, source, folder })
return {
items: result.items.map(row => ({
id: row.id,
action: row.action,
key: row.key,
originalName: row.original_name,
size: row.size,
contentType: row.content_type,
source: row.source,
createdAt: row.created_at
})),
total: result.total,
page: result.page,
pageSize: result.pageSize,
totalPages: result.totalPages
}
} catch (error) {
console.error('[logs] list failed', error)
createApiError(event, 'INVALID_REQUEST', '读取操作记录失败', 500)
}
})
@@ -0,0 +1,29 @@
import { requireAdminAuth } from '../../../utils/access'
import { createApiError } from '../../../utils/api-error'
import {
generateApiUploadToken,
setSetting,
SETTINGS_API_UPLOAD_TOKEN
} from '../../../utils/db'
import { isApiUploadTokenEnvConfigured } from '../../../utils/env'
export default defineEventHandler(async (event) => {
await requireAdminAuth(event)
if (isApiUploadTokenEnvConfigured(event)) {
createApiError(
event,
'CONFLICT',
'API_UPLOAD_TOKEN 已在环境变量中配置,无法在后台重新生成',
409
)
}
const token = generateApiUploadToken()
setSetting(SETTINGS_API_UPLOAD_TOKEN, token)
return {
apiUploadToken: token,
tokenSource: 'db' as const
}
})
+7
View File
@@ -0,0 +1,7 @@
import { requireAdminAuth } from '../../utils/access'
import { getSettingsPayload } from '../../utils/env'
export default defineEventHandler(async (event) => {
await requireAdminAuth(event)
return getSettingsPayload(event)
})
+97
View File
@@ -0,0 +1,97 @@
import { requireAdminAuth } from '../../utils/access'
import { createApiError } from '../../utils/api-error'
import {
setSetting,
SETTINGS_ALLOWED_REFERER_HOSTS,
SETTINGS_IMAGE_BASE_URL,
SETTINGS_WEBP_QUALITY,
setAllowRegistration
} from '../../utils/db'
import {
getSettingsPayload,
isValidImageBaseUrl,
isValidRefererHost,
MAX_AUTO_DELETE_DAYS,
normalizeImageBaseUrl,
normalizeRefererHosts,
parseAutoDeleteDays,
parseWebpQuality,
setGlobalAutoDeletePolicy
} from '../../utils/env'
interface SettingsPatchBody {
webpQuality?: unknown
allowedRefererHosts?: unknown
imageBaseUrl?: unknown
autoDeleteDays?: unknown
allowRegistration?: unknown
}
export default defineEventHandler(async (event) => {
await requireAdminAuth(event)
const body = await readBody<SettingsPatchBody>(event).catch(
(): SettingsPatchBody => ({})
)
if (body.webpQuality !== undefined) {
const quality = parseWebpQuality(String(body.webpQuality))
if (quality === null) {
createApiError(event, 'INVALID_REQUEST', '压缩质量需为 1–100 的整数', 400)
}
setSetting(SETTINGS_WEBP_QUALITY, String(quality))
}
if (body.allowedRefererHosts !== undefined) {
const raw = typeof body.allowedRefererHosts === 'string'
? body.allowedRefererHosts
: ''
const normalized = normalizeRefererHosts(raw)
if (normalized) {
const invalid = normalized.split(',').find(host => !isValidRefererHost(host))
if (invalid) {
createApiError(
event,
'INVALID_REQUEST',
`无效的 Referer 域名:${invalid}`,
400
)
}
}
setSetting(SETTINGS_ALLOWED_REFERER_HOSTS, normalized)
}
if (body.imageBaseUrl !== undefined) {
const configured = normalizeImageBaseUrl(
typeof body.imageBaseUrl === 'string' ? body.imageBaseUrl : ''
)
if (configured && !isValidImageBaseUrl(configured)) {
createApiError(
event,
'INVALID_REQUEST',
'IMAGE_BASE_URL 需为 http(s) 地址,或留空使用当前请求域名',
400
)
}
setSetting(SETTINGS_IMAGE_BASE_URL, configured)
}
if (body.autoDeleteDays !== undefined) {
const days = parseAutoDeleteDays(String(body.autoDeleteDays))
if (days === null) {
createApiError(
event,
'INVALID_REQUEST',
`自动删除天数需为 0–${MAX_AUTO_DELETE_DAYS} 的整数(0 表示关闭)`,
400
)
}
setGlobalAutoDeletePolicy(days)
}
if (body.allowRegistration !== undefined) {
setAllowRegistration(Boolean(body.allowRegistration))
}
return getSettingsPayload(event)
})
+63
View File
@@ -0,0 +1,63 @@
import { createApiError } from '../utils/api-error'
import {
createSession,
createUserAccount,
isInitialized
} from '../utils/auth'
import { setAllowRegistration } from '../utils/db'
import { clientIp, logInfo } from '../utils/logger'
interface SetupBody {
username?: string
password?: string
allowRegistration?: boolean
}
export default defineEventHandler(async (event) => {
if (isInitialized()) {
createApiError(event, 'FORBIDDEN', '系统已初始化', 403)
}
const body = await readBody<SetupBody>(event)
const username = body?.username?.trim() ?? ''
const password = body?.password ?? ''
try {
const user = await createUserAccount({
username,
password,
role: 'admin'
})
setAllowRegistration(Boolean(body?.allowRegistration))
await createSession(event, user.id)
logInfo('setup complete', { ip: clientIp(event), username: user.username })
return {
success: true,
user: { id: user.id, username: user.username, role: user.role }
}
} catch (error: unknown) {
if (error instanceof Error) {
if (error.message === 'INVALID_USERNAME') {
createApiError(
event,
'INVALID_REQUEST',
'用户名需为 3–32 位字母、数字或下划线',
400
)
} else if (error.message === 'INVALID_PASSWORD') {
createApiError(
event,
'INVALID_REQUEST',
'密码至少 8 位',
400
)
} else if (error.message === 'USERNAME_TAKEN') {
createApiError(event, 'INVALID_REQUEST', '用户名已被占用', 400)
}
}
throw error
}
})
+52
View File
@@ -0,0 +1,52 @@
import { getImageUserFilter, requireUserAuth } from '../utils/access'
import { createApiError } from '../utils/api-error'
import { countUsers, getActivityStats } from '../utils/db'
import {
countImages,
getFolderStorageStats,
getUserScopedStorageStats
} from '../utils/storage'
export default defineEventHandler(async (event) => {
const user = await requireUserAuth(event)
const userFilter = await getImageUserFilter(event)
try {
if (user.role === 'admin' && userFilter === 'admin') {
const [activity, storedCount, byFolder] = await Promise.all([
Promise.resolve(getActivityStats()),
countImages(),
getFolderStorageStats()
])
return {
...activity,
storedCount,
byFolder,
userCount: countUsers()
}
}
const scoped = await getUserScopedStorageStats(user.id)
return {
uploadToday: scoped.uploadToday,
uploadMonth: scoped.uploadMonth,
deleteToday: 0,
deleteMonth: 0,
uploadTotal: scoped.storedCount,
deleteTotal: 0,
uploadBytesTotal: scoped.uploadBytesTotal,
bySource: { web: scoped.storedCount, api: 0, twikoo: 0 },
byFolderUploads: scoped.byFolder.map(item => ({
folder: item.folder,
count: item.count
})),
storedCount: scoped.storedCount,
byFolder: scoped.byFolder
}
} catch (error) {
console.error('[stats] failed', error)
createApiError(event, 'INVALID_REQUEST', '读取统计失败', 500)
}
})
+20
View File
@@ -0,0 +1,20 @@
import { requireUserAuth } from '../../utils/access'
import { ensureUserApiToken } from '../../utils/db'
import { getImageBaseUrl } from '../../utils/env'
export default defineEventHandler(async (event) => {
const user = await requireUserAuth(event)
const config = useRuntimeConfig(event)
return {
apiUploadToken: ensureUserApiToken(user.id),
tokenSource: 'db' as const,
envTokenOverride: false,
env: {
webpQuality: 80,
refererConfigured: false,
imageBaseUrl: getImageBaseUrl(event),
appVersion: config.appVersion as string
}
}
})
@@ -0,0 +1,10 @@
import { requireUserAuth } from '../../../utils/access'
import { generateApiUploadToken, setUserApiToken } from '../../../utils/db'
export default defineEventHandler(async (event) => {
const user = await requireUserAuth(event)
const token = generateApiUploadToken()
setUserApiToken(user.id, token)
return { apiUploadToken: token }
})
+11
View File
@@ -0,0 +1,11 @@
import { requireUserAuth } from '../../utils/access'
import { getUserAutoDeletePolicy } from '../../utils/db'
export default defineEventHandler(async (event) => {
const user = await requireUserAuth(event)
const policy = getUserAutoDeletePolicy(user.id)
return {
autoDeleteDays: policy.days
}
})
+34
View File
@@ -0,0 +1,34 @@
import { requireUserAuth } from '../../utils/access'
import { createApiError } from '../../utils/api-error'
import { getUserAutoDeletePolicy, setUserAutoDeletePolicy } from '../../utils/db'
import { MAX_AUTO_DELETE_DAYS, parseAutoDeleteDays } from '../../utils/env'
interface UserSettingsPatchBody {
autoDeleteDays?: unknown
}
export default defineEventHandler(async (event) => {
const user = await requireUserAuth(event)
const body = await readBody<UserSettingsPatchBody>(event).catch(
(): UserSettingsPatchBody => ({})
)
if (body.autoDeleteDays !== undefined) {
const days = parseAutoDeleteDays(String(body.autoDeleteDays))
if (days === null) {
createApiError(
event,
'INVALID_REQUEST',
`自动删除天数需为 0–${MAX_AUTO_DELETE_DAYS} 的整数(0 表示关闭)`,
400
)
}
setUserAutoDeletePolicy(user.id, days)
}
const policy = getUserAutoDeletePolicy(user.id)
return {
autoDeleteDays: policy.days
}
})