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,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)
|
||||
}
|
||||
})
|
||||
@@ -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 }
|
||||
}
|
||||
})
|
||||
@@ -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 }
|
||||
})
|
||||
@@ -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 }
|
||||
}
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
@@ -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 }
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
@@ -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 }
|
||||
})
|
||||
@@ -0,0 +1,7 @@
|
||||
export default defineEventHandler((event) => {
|
||||
const config = useRuntimeConfig(event)
|
||||
return {
|
||||
status: 'ok',
|
||||
version: config.appVersion
|
||||
}
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
@@ -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 }
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
@@ -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
|
||||
})
|
||||
@@ -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
|
||||
})
|
||||
@@ -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
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
@@ -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 }
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
Reference in new issue
Block a user